Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 92 additions & 21 deletions examples/alerts_and_drift/_shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
"""
from __future__ import annotations

import os
import socket
import sys
from pathlib import Path
from typing import Any
Expand All @@ -18,33 +20,81 @@

import tomli_w

from tokenjam.core.config import find_config_file
from tokenjam.core.config import SEARCH_PATHS, find_config_file


def ensure_demo_agent_config(agent_id: str, agent_block: dict[str, Any]) -> Path:
"""Idempotently merge `agent_block` into [agents.<agent_id>] in the active tj config.
def ensure_demo_agent_config(
agent_id: str, agent_block: dict[str, Any]
) -> list[Path]:
"""Idempotently merge `agent_block` into [agents.<agent_id>] in every
tj config file that currently exists on disk.

Existing keys are left alone — this only fills in missing config so a tester
can override demo settings if they want. Writes to .tj/config.toml in the
cwd if no config exists yet. Returns the path written.
The original helper wrote to `find_config_file()` only — which returns
the first match in the search-path order (project-local before global).
That broke a real footgun (#68 §6 → §5): a tester who'd run
`tj onboard --claude-code` had a global config AND a project-local
config, the daemon was launched with the global one, but the helper
only updated the project-local file. Demo agents stayed invisible
to the running daemon and no alerts fired.

Now: write to every config file in SEARCH_PATHS that exists. Idempotent
merge — existing keys stay, only missing ones get added. Returns the
list of paths actually touched.

If no config file exists anywhere, creates .tj/config.toml in cwd as
a fallback (same as the old behaviour).
"""
cfg_path = find_config_file()
if cfg_path is None:
cfg_path = Path(".tj/config.toml")
cfg_path.parent.mkdir(parents=True, exist_ok=True)
data: dict[str, Any] = {}
else:
cfg_path = Path(cfg_path)
with cfg_path.open("rb") as f:
data = tomllib.load(f)
touched: list[Path] = []
for candidate in SEARCH_PATHS:
p = Path(candidate)
if not p.exists():
continue
_merge_into(p, agent_id, agent_block)
try:
touched.append(p.resolve())
except OSError:
touched.append(p)
if not touched:
fallback = Path(".tj/config.toml")
fallback.parent.mkdir(parents=True, exist_ok=True)
_merge_into(fallback, agent_id, agent_block, allow_create=True)
touched.append(fallback.resolve())
return touched

agents = data.setdefault("agents", {})
existing = agents.setdefault(agent_id, {})
_deep_merge_missing(existing, agent_block)

with cfg_path.open("wb") as f:
tomli_w.dump(data, f)
return cfg_path
def warn_if_daemon_running() -> bool:
"""Print a heads-up if a tj serve daemon is listening on the default port.

Demos write config changes that the AlertEngine needs to load. If the
daemon is up, it loaded config at startup and won't auto-reload on
file changes — alerts for the freshly-added demo agent will silently
fail to fire (issue #68 §6).

Returns True if a daemon appears to be running; the caller can choose
whether to abort, prompt the user, or just continue.
"""
port = int(os.environ.get("TJ_PORT", "7391"))
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(0.2)
try:
running = s.connect_ex(("127.0.0.1", port)) == 0
except OSError:
running = False
finally:
s.close()
if running:
print(
"\n[demo] A tj serve daemon appears to be running on port "
f"{port}. The daemon's AlertEngine loaded its config at "
"startup and does NOT hot-reload — alerts for this demo's "
"agent will only fire if the daemon is restarted to pick "
"up the freshly-added config:\n"
"\n tj stop && tj serve &\n"
"\nOr stop the daemon entirely (`tj stop`) so the SDK writes "
"directly to DuckDB and fires alerts in-process.\n",
file=sys.stderr,
)
return running


def _deep_merge_missing(target: dict[str, Any], source: dict[str, Any]) -> None:
Expand All @@ -53,3 +103,24 @@ def _deep_merge_missing(target: dict[str, Any], source: dict[str, Any]) -> None:
target[k] = v
elif isinstance(v, dict) and isinstance(target[k], dict):
_deep_merge_missing(target[k], v)


def _merge_into(
cfg_path: Path,
agent_id: str,
agent_block: dict[str, Any],
*,
allow_create: bool = False,
) -> None:
if cfg_path.exists():
with cfg_path.open("rb") as f:
data = tomllib.load(f)
elif allow_create:
data = {}
else:
return
agents = data.setdefault("agents", {})
existing = agents.setdefault(agent_id, {})
_deep_merge_missing(existing, agent_block)
with cfg_path.open("wb") as f:
tomli_w.dump(data, f)
7 changes: 6 additions & 1 deletion examples/alerts_and_drift/budget_breach_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,16 @@ def run_expensive_agent() -> None:
# ---------------------------------------------------------------------------

if __name__ == "__main__":
from examples.alerts_and_drift._shared import ensure_demo_agent_config
from examples.alerts_and_drift._shared import (
ensure_demo_agent_config,
warn_if_daemon_running,
)
ensure_demo_agent_config(
"budget-demo",
{"budget": {"daily_usd": 0.05, "session_usd": 0.02}},
)
# Surface the "daemon already running" footgun before bootstrap (#68 §6).
warn_if_daemon_running()

from tokenjam.sdk.bootstrap import ensure_initialised
ensure_initialised()
Expand Down
8 changes: 8 additions & 0 deletions examples/alerts_and_drift/drift_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,14 @@ def run_anomalous_session() -> None:
# ---------------------------------------------------------------------------

if __name__ == "__main__":
# drift_demo doesn't need ensure_demo_agent_config because drift
# detection is on by default (DriftConfig.enabled = True). But it
# still needs to warn the user about a running daemon — without a
# restart, the daemon's DriftDetector won't reload thresholds set
# in tj.toml at runtime (#68 §6).
from examples.alerts_and_drift._shared import warn_if_daemon_running
warn_if_daemon_running()

from tokenjam.sdk.bootstrap import ensure_initialised
ensure_initialised()

Expand Down
9 changes: 8 additions & 1 deletion examples/alerts_and_drift/sensitive_actions_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,10 @@ def run_sensitive_agent() -> None:
# ---------------------------------------------------------------------------

if __name__ == "__main__":
from examples.alerts_and_drift._shared import ensure_demo_agent_config
from examples.alerts_and_drift._shared import (
ensure_demo_agent_config,
warn_if_daemon_running,
)
ensure_demo_agent_config(
"sensitive-demo",
{
Expand All @@ -130,6 +133,10 @@ def run_sensitive_agent() -> None:
],
},
)
# If the daemon's already up, the freshly-merged config won't reach
# its AlertEngine without a restart. Surface that loudly so users
# don't see "No active alerts" and get confused (#68 §6).
warn_if_daemon_running()

from tokenjam.sdk.bootstrap import ensure_initialised
ensure_initialised()
Expand Down
4 changes: 2 additions & 2 deletions tests/manual-new-release-tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,12 +132,12 @@ tj stop
## Claude Code integration (smoke)

```bash
tj onboard --claude-code --plan max_20x
tj onboard --claude-code --plan max_5x # substitute your actual plan
cat ~/.claude/settings.json | python3 -m json.tool | grep -E "OTEL_LOGS_EXPORTER|OTEL_EXPORTER_OTLP_ENDPOINT"
cat ~/.config/tj/projects.json # current cwd present

# Re-run is a quiet no-op
tj onboard --claude-code --plan max_20x
tj onboard --claude-code --plan max_5x # substitute your actual plan

# Backfill ran automatically during onboard — verify history is present
tj cost --since 30d --agent claude-code-tokenjam || true
Expand Down
54 changes: 38 additions & 16 deletions tests/manual-pre-release-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Run through this sequence to test a branch before merging and cutting a release.
- `ANTHROPIC_API_KEY` and `OPENAI_API_KEY` set (used by example agents)
- Both in `~/tokenjam/.env.local`, sourced before running
- A clean shell (`tj uninstall --yes && rm -rf ~/.tj ~/.config/tj .tj` if anything from a prior test lingers)
- **No lingering daemon from a prior install.** Run `tj stop` early — if a previous `tj onboard` installed the launchd / systemd unit, the daemon may still be live with stale config. Verify with `launchctl list | grep tokenjam` (macOS) or `systemctl --user is-active tokenjam` (Linux).

## 1. Install and verify the build

Expand Down Expand Up @@ -35,25 +36,41 @@ pytest tests/unit/ tests/synthetic/ tests/agents/ tests/integration/
ruff check tokenjam/
```

**Pass criteria:** all tests green, ruff at or below the documented baseline (49 errors as of v0.3.x — none of them new).
**Pass criteria:** all tests green, ruff clean (no errors). If ruff reports anything, only proceed if the errors are pre-existing on `main` — confirm by running `ruff check` on `main` first.

## 3. Onboard with plan-tier prompts
## 3. Onboard

```bash
tj onboard --no-daemon # daemon auto-starts otherwise; stop afterward
```

Verify the onboard flow now prompts for **plan tier** (api / pro / max_5x / max_20x for Anthropic; api / plus / team / enterprise for OpenAI when applicable). Pick `api` for this test run so dollar-denominated rendering kicks in across the rest of the script.
The bare `tj onboard` doesn't prompt for plan tier (plan tier is per-provider — it's set by the integration-specific flows). It only asks for a daily-budget number.

Plan-tier prompting happens in `--claude-code` and `--codex`:

```bash
# Confirm the plan landed in config.
grep -A2 "^\[budget.anthropic\]" .tj/config.toml || grep -A2 "^\[budget.anthropic\]" ~/.config/tj/config.toml
# [ ] shows plan = "api" (or whichever you picked)
# Use whichever plan you actually have on the test machine.
# The examples below use max_5x; substitute max_20x / pro / plus / etc.
# as appropriate. The format of the expected output is what matters,
# not the specific plan label.
tj onboard --claude-code --plan max_5x --no-daemon
# [ ] prompts for plan tier if --plan not provided
# [ ] writes plan = "max_5x" under [budget.anthropic] in ~/.config/tj/config.toml

# Confirm the plan landed.
grep -A3 "^\[budget.anthropic\]" ~/.config/tj/config.toml
# [ ] plan = "max_5x" (or whichever you picked)
# [ ] does NOT auto-write usd = 200 (that default is gone in v0.3.x)

# Test --reconfigure re-prompts against an existing config.
tj onboard --reconfigure --plan max_20x # non-interactive override
# [ ] succeeds; plan field updated in config
# --reconfigure on the integration paths actually re-prompts.
tj onboard --claude-code --reconfigure --plan api
# [ ] config updated; plan field flipped

# Bare `tj onboard --reconfigure` is an error now (#68 §1) — points at
# the integration-specific flows. Verify the explicit error renders.
tj onboard --reconfigure --plan max_5x; echo "expected exit 1, got $?"
# [ ] prints "--reconfigure has no effect without --claude-code or --codex"
# [ ] exit code 1
```

## 4. Populate test data
Expand Down Expand Up @@ -164,13 +181,18 @@ tj optimize --json | python3 -c \
Reconfigure to a subscription plan and re-run `tj optimize` — output should reframe.

```bash
tj onboard --reconfigure --plan max_20x
# Use the plan you actually have on this machine. Expected output shape:
# pro → "Pro plan, $20/mo flat"
# max_5x → "Max 5x plan, $100/mo flat"
# max_20x → "Max 20x plan, $200/mo flat"
# plus → "ChatGPT Plus, $20/mo flat"
tj onboard --claude-code --reconfigure --plan max_5x
tj optimize
# [ ] Header reads "(Max 20x plan, $200/mo flat)" + "Implied API value: $X — about Y× your plan cost"
# [ ] Header reads "(<Plan label>, $<fee>/mo flat)" + "Implied API value: $X — about Y× your plan cost"
# [ ] NO line that uses the word "spend" against a dollar figure
# [ ] Downgrade body (if any) uses token-share framing, not "$X/mo savings"

tj onboard --reconfigure --plan api
tj onboard --claude-code --reconfigure --plan api
tj optimize
# [ ] Back to "$X spend (last 30d)..." header and dollar-denominated downgrade savings

Expand Down Expand Up @@ -306,27 +328,27 @@ tj stop
## Claude Code integration (if the change touches onboard / settings.json / daemon)

```bash
tj onboard --claude-code --plan max_20x # non-interactive
tj onboard --claude-code --plan max_5x # non-interactive
# [ ] writes ~/.config/tj/config.toml (global, not project-local)
# [ ] writes ~/.claude/settings.json with OTEL_EXPORTER_OTLP_ENDPOINT and Bearer header
# [ ] registers MCP server if `claude` CLI on PATH
# [ ] auto-installs daemon
# [ ] adds cwd to ~/.config/tj/projects.json

# Re-run is a quiet no-op (no duplicate "Background Items Added" notification on macOS)
tj onboard --claude-code --plan max_20x
tj onboard --claude-code --plan max_5x

# Multi-project: secret must NOT rotate on second project
mkdir -p /tmp/tj-test-project-2 && cd /tmp/tj-test-project-2 && git init -q
tj onboard --claude-code --plan max_20x
tj onboard --claude-code --plan max_5x
# [ ] "Daemon: already running (skipped reinstall)"
# [ ] ~/.config/tj/projects.json lists BOTH paths
# [ ] ingest_secret in ~/.claude/settings.json unchanged from the first onboard
test ! -f .tj/config.toml && echo "ok: --claude-code did not create project-local config"
cd ~/tokenjam

# --force does reinstall the daemon
tj onboard --claude-code --plan max_20x --force
tj onboard --claude-code --plan max_5x --force
# [ ] "Daemon: installing..."

tj mcp --help # MCP server CLI exists
Expand Down
Loading
Loading