tokenjam 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. tokenjam/__init__.py +1 -0
  2. tokenjam/api/__init__.py +0 -0
  3. tokenjam/api/app.py +104 -0
  4. tokenjam/api/deps.py +18 -0
  5. tokenjam/api/middleware.py +28 -0
  6. tokenjam/api/routes/__init__.py +0 -0
  7. tokenjam/api/routes/agents.py +33 -0
  8. tokenjam/api/routes/alerts.py +77 -0
  9. tokenjam/api/routes/budget.py +96 -0
  10. tokenjam/api/routes/cost.py +43 -0
  11. tokenjam/api/routes/drift.py +63 -0
  12. tokenjam/api/routes/logs.py +511 -0
  13. tokenjam/api/routes/metrics.py +81 -0
  14. tokenjam/api/routes/otlp.py +63 -0
  15. tokenjam/api/routes/spans.py +202 -0
  16. tokenjam/api/routes/status.py +84 -0
  17. tokenjam/api/routes/tools.py +22 -0
  18. tokenjam/api/routes/traces.py +92 -0
  19. tokenjam/cli/__init__.py +0 -0
  20. tokenjam/cli/cmd_alerts.py +94 -0
  21. tokenjam/cli/cmd_budget.py +119 -0
  22. tokenjam/cli/cmd_cost.py +90 -0
  23. tokenjam/cli/cmd_demo.py +82 -0
  24. tokenjam/cli/cmd_doctor.py +173 -0
  25. tokenjam/cli/cmd_drift.py +238 -0
  26. tokenjam/cli/cmd_export.py +200 -0
  27. tokenjam/cli/cmd_mcp.py +78 -0
  28. tokenjam/cli/cmd_onboard.py +779 -0
  29. tokenjam/cli/cmd_serve.py +85 -0
  30. tokenjam/cli/cmd_status.py +153 -0
  31. tokenjam/cli/cmd_stop.py +87 -0
  32. tokenjam/cli/cmd_tools.py +45 -0
  33. tokenjam/cli/cmd_traces.py +161 -0
  34. tokenjam/cli/cmd_uninstall.py +159 -0
  35. tokenjam/cli/main.py +110 -0
  36. tokenjam/core/__init__.py +0 -0
  37. tokenjam/core/alerts.py +619 -0
  38. tokenjam/core/api_backend.py +235 -0
  39. tokenjam/core/config.py +360 -0
  40. tokenjam/core/cost.py +102 -0
  41. tokenjam/core/db.py +718 -0
  42. tokenjam/core/drift.py +256 -0
  43. tokenjam/core/ingest.py +265 -0
  44. tokenjam/core/models.py +225 -0
  45. tokenjam/core/pricing.py +54 -0
  46. tokenjam/core/retention.py +21 -0
  47. tokenjam/core/schema_validator.py +156 -0
  48. tokenjam/demo/__init__.py +0 -0
  49. tokenjam/demo/env.py +96 -0
  50. tokenjam/mcp/__init__.py +0 -0
  51. tokenjam/mcp/server.py +1067 -0
  52. tokenjam/otel/__init__.py +0 -0
  53. tokenjam/otel/exporters.py +26 -0
  54. tokenjam/otel/provider.py +207 -0
  55. tokenjam/otel/semconv.py +144 -0
  56. tokenjam/pricing/models.toml +70 -0
  57. tokenjam/py.typed +0 -0
  58. tokenjam/sdk/__init__.py +21 -0
  59. tokenjam/sdk/agent.py +206 -0
  60. tokenjam/sdk/bootstrap.py +120 -0
  61. tokenjam/sdk/http_exporter.py +109 -0
  62. tokenjam/sdk/integrations/__init__.py +0 -0
  63. tokenjam/sdk/integrations/anthropic.py +200 -0
  64. tokenjam/sdk/integrations/autogen.py +97 -0
  65. tokenjam/sdk/integrations/base.py +27 -0
  66. tokenjam/sdk/integrations/bedrock.py +103 -0
  67. tokenjam/sdk/integrations/crewai.py +96 -0
  68. tokenjam/sdk/integrations/gemini.py +131 -0
  69. tokenjam/sdk/integrations/langchain.py +156 -0
  70. tokenjam/sdk/integrations/langgraph.py +101 -0
  71. tokenjam/sdk/integrations/litellm.py +323 -0
  72. tokenjam/sdk/integrations/llamaindex.py +52 -0
  73. tokenjam/sdk/integrations/nemoclaw.py +139 -0
  74. tokenjam/sdk/integrations/openai.py +159 -0
  75. tokenjam/sdk/integrations/openai_agents_sdk.py +47 -0
  76. tokenjam/sdk/transport.py +98 -0
  77. tokenjam/ui/index.html +1213 -0
  78. tokenjam/utils/__init__.py +0 -0
  79. tokenjam/utils/formatting.py +43 -0
  80. tokenjam/utils/ids.py +15 -0
  81. tokenjam/utils/time_parse.py +54 -0
  82. tokenjam-0.2.0.dist-info/METADATA +622 -0
  83. tokenjam-0.2.0.dist-info/RECORD +86 -0
  84. tokenjam-0.2.0.dist-info/WHEEL +4 -0
  85. tokenjam-0.2.0.dist-info/entry_points.txt +2 -0
  86. tokenjam-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ import click
4
+ from pathlib import Path
5
+
6
+ from tokenjam.utils.formatting import console
7
+
8
+
9
+ @click.command("serve")
10
+ @click.option("--host", default=None, help="Bind host (default: from config)")
11
+ @click.option("--port", default=None, type=int, help="Bind port (default: from config)")
12
+ @click.option("--reload", is_flag=True, help="Enable auto-reload for development")
13
+ @click.pass_context
14
+ def cmd_serve(ctx: click.Context, host: str | None, port: int | None,
15
+ reload: bool) -> None:
16
+ """Start the tj API server."""
17
+ config = ctx.obj["config"]
18
+ bind_host = host or config.api.host
19
+ bind_port = port or config.api.port
20
+
21
+ import uvicorn
22
+ from tokenjam.api.app import create_app
23
+ from tokenjam.core.ingest import build_default_pipeline
24
+
25
+ db = ctx.obj["db"]
26
+ pipeline = build_default_pipeline(db, config)
27
+ app = create_app(config, db, pipeline)
28
+
29
+ # Schedule retention cleanup using a separate DB connection per run
30
+ # to avoid concurrent write conflicts with uvicorn worker threads.
31
+ from apscheduler.schedulers.background import BackgroundScheduler
32
+ from tokenjam.core.retention import run_retention_cleanup
33
+ from tokenjam.core.db import DuckDBBackend
34
+
35
+ def _retention_job() -> None:
36
+ retention_db = DuckDBBackend(config.storage)
37
+ try:
38
+ run_retention_cleanup(retention_db, config.storage)
39
+ finally:
40
+ retention_db.close()
41
+
42
+ scheduler = BackgroundScheduler()
43
+ scheduler.add_job(
44
+ _retention_job,
45
+ "cron",
46
+ hour=0,
47
+ minute=0,
48
+ )
49
+ scheduler.start()
50
+
51
+ @app.on_event("shutdown")
52
+ async def _shutdown_scheduler() -> None:
53
+ scheduler.shutdown(wait=False)
54
+
55
+ # Write the resolved config path so other subcommands (e.g. onboard --codex)
56
+ # can find the secret this server is using regardless of CWD. Defer the
57
+ # write to a FastAPI startup event so it only fires after uvicorn binds
58
+ # the port — otherwise a failed-to-bind serve clobbers the state file
59
+ # of the running daemon (D2).
60
+ import json as _json
61
+ _state_path = Path.home() / ".local" / "share" / "tj" / "server.state"
62
+
63
+ @app.on_event("startup")
64
+ async def _write_server_state() -> None:
65
+ _state_path.parent.mkdir(parents=True, exist_ok=True)
66
+ _state_path.write_text(
67
+ _json.dumps({
68
+ "config_path": str(config.config_path) if config.config_path else None,
69
+ "port": bind_port,
70
+ "pid": __import__("os").getpid(),
71
+ })
72
+ )
73
+
74
+ console.print(f"[bold]tj serve[/bold] starting on http://{bind_host}:{bind_port}")
75
+ console.print(f" API docs: http://{bind_host}:{bind_port}/docs")
76
+ if config.export.prometheus.enabled:
77
+ console.print(f" Metrics: http://{bind_host}:{bind_port}/metrics")
78
+ console.print()
79
+
80
+ if reload:
81
+ console.print(
82
+ "[yellow]Warning: --reload requires an import string, not an app instance. "
83
+ "Reload mode is not supported with injected db/config — ignoring --reload.[/yellow]"
84
+ )
85
+ uvicorn.run(app, host=bind_host, port=bind_port)
@@ -0,0 +1,153 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ import click
6
+
7
+ from tokenjam.core.models import AlertFilters
8
+ from tokenjam.utils.formatting import console, format_cost, format_tokens, status_icon
9
+ from tokenjam.utils.time_parse import utcnow
10
+
11
+
12
+ @click.command("status")
13
+ @click.option("--agent", default=None, help="Filter to specific agent_id")
14
+ @click.option("--json", "output_json", is_flag=True)
15
+ @click.pass_context
16
+ def cmd_status(ctx: click.Context, agent: str | None, output_json: bool) -> None:
17
+ """Show agent status overview."""
18
+ db = ctx.obj["db"]
19
+ agent_filter = agent or ctx.obj.get("agent")
20
+
21
+ # Get all agents from recent sessions
22
+ if agent_filter:
23
+ agent_ids = [agent_filter]
24
+ elif hasattr(db, "conn"):
25
+ # Direct DB access
26
+ rows = db.conn.execute(
27
+ "SELECT DISTINCT agent_id FROM sessions WHERE agent_id IS NOT NULL ORDER BY agent_id"
28
+ ).fetchall()
29
+ agent_ids = [r[0] for r in rows]
30
+ else:
31
+ # API mode — discover agents from recent traces
32
+ from tokenjam.core.models import TraceFilters
33
+ traces = db.get_traces(TraceFilters(limit=100))
34
+ agent_ids = sorted({t.agent_id for t in traces if t.agent_id})
35
+
36
+ if not agent_ids:
37
+ if output_json:
38
+ click.echo(json.dumps({"agents": [], "has_active_alerts": False}))
39
+ else:
40
+ console.print("[dim]No agents found. Run an instrumented agent first.[/dim]")
41
+ return
42
+
43
+ has_active_alerts = False
44
+ agents_data = []
45
+
46
+ for aid in agent_ids:
47
+ session = None
48
+ if hasattr(db, "conn"):
49
+ # Direct DB access
50
+ sessions = db.get_completed_sessions(aid, limit=1)
51
+ active_rows = db.conn.execute(
52
+ "SELECT * FROM sessions WHERE agent_id = $1 AND status = 'active' "
53
+ "ORDER BY started_at DESC LIMIT 1",
54
+ [aid],
55
+ ).fetchall()
56
+ if active_rows:
57
+ cols = [d[0] for d in db.conn.description]
58
+ from tokenjam.core.db import _row_to_session
59
+ session = _row_to_session(active_rows[0], cols)
60
+ elif sessions:
61
+ session = sessions[0]
62
+ else:
63
+ # API mode — limited session info
64
+ sessions = db.get_completed_sessions(aid, limit=1)
65
+ if sessions:
66
+ session = sessions[0]
67
+
68
+ today_cost = db.get_daily_cost(aid, utcnow().date())
69
+
70
+ # Budget from config: per-agent overrides defaults
71
+ config = ctx.obj["config"]
72
+ agent_config = config.agents.get(aid)
73
+ if agent_config and agent_config.budget.daily_usd is not None:
74
+ daily_limit = agent_config.budget.daily_usd
75
+ elif hasattr(config, "defaults") and config.defaults.budget.daily_usd is not None:
76
+ daily_limit = config.defaults.budget.daily_usd
77
+ else:
78
+ daily_limit = None
79
+
80
+ # Active alerts
81
+ alerts = db.get_alerts(AlertFilters(agent_id=aid, unread=True, limit=50))
82
+ active_alerts = [a for a in alerts if not a.acknowledged and not a.suppressed]
83
+ if active_alerts:
84
+ has_active_alerts = True
85
+
86
+ agent_data = {
87
+ "agent_id": aid,
88
+ "status": session.status if session else "idle",
89
+ "session_id": session.session_id if session else None,
90
+ "cost_today": today_cost,
91
+ "daily_limit": daily_limit,
92
+ "input_tokens": session.input_tokens if session else 0,
93
+ "output_tokens": session.output_tokens if session else 0,
94
+ "tool_call_count": session.tool_call_count if session else 0,
95
+ "error_count": session.error_count if session else 0,
96
+ "active_alerts": len(active_alerts),
97
+ }
98
+ agents_data.append(agent_data)
99
+
100
+ if not output_json:
101
+ _print_agent_status(agent_data, active_alerts, session)
102
+
103
+ if output_json:
104
+ click.echo(json.dumps({
105
+ "agents": agents_data,
106
+ "has_active_alerts": has_active_alerts,
107
+ }, default=str))
108
+
109
+ ctx.exit(1 if has_active_alerts else 0)
110
+
111
+
112
+ def _print_agent_status(data: dict, active_alerts: list, session: object | None) -> None:
113
+ status = data["status"]
114
+ icon = status_icon(status)
115
+ style = "green" if status == "active" else "dim"
116
+
117
+ duration_str = ""
118
+ if session and hasattr(session, "duration_seconds") and session.duration_seconds:
119
+ secs = int(session.duration_seconds)
120
+ mins, s = divmod(secs, 60)
121
+ duration_str = f" ({mins}m {s}s)"
122
+
123
+ console.print(f"[{style}]{icon}[/] [bold]{data['agent_id']}[/bold] "
124
+ f"{status}{duration_str}")
125
+ console.print()
126
+
127
+ cost_str = format_cost(data["cost_today"])
128
+ if data["daily_limit"]:
129
+ cost_str += f" / {format_cost(data['daily_limit'])} limit"
130
+ console.print(f" Cost today: {cost_str}")
131
+
132
+ in_tok = format_tokens(data["input_tokens"])
133
+ out_tok = format_tokens(data["output_tokens"])
134
+ console.print(f" Tokens: {in_tok} in / {out_tok} out")
135
+
136
+ tool_str = str(data["tool_call_count"])
137
+ if data["error_count"]:
138
+ tool_str += f" ({data['error_count']} failed)"
139
+ console.print(f" Tool calls: {tool_str}")
140
+
141
+ if data["session_id"]:
142
+ console.print(f" Active session: {data['session_id']}")
143
+
144
+ console.print()
145
+ for alert in active_alerts:
146
+ from tokenjam.utils.formatting import severity_colour
147
+ colour = severity_colour(alert.severity.value)
148
+ console.print(f" [{colour}]{alert.title}[/]")
149
+
150
+ if not active_alerts:
151
+ console.print(" [green]No active alerts[/green]")
152
+
153
+ console.print()
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import signal
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ import click
9
+
10
+ from tokenjam.utils.formatting import console
11
+
12
+
13
+ @click.command("stop")
14
+ @click.pass_context
15
+ def cmd_stop(ctx: click.Context) -> None:
16
+ """Stop the tj serve daemon or background process."""
17
+ plist_path = Path.home() / "Library/LaunchAgents/com.tokenjam.serve.plist"
18
+ systemd_path = Path.home() / ".config/systemd/user/tokenjam.service"
19
+
20
+ stopped_via: list[str] = []
21
+
22
+ # Try launchd first (macOS).
23
+ # -w writes a Disabled entry to launchd's database so the daemon does not
24
+ # auto-start on the next login (the plist file stays on disk so the user
25
+ # can re-enable with `launchctl load <plist>` or by re-running tj serve).
26
+ if plist_path.exists():
27
+ result = subprocess.run(
28
+ ["launchctl", "unload", "-w", str(plist_path)],
29
+ capture_output=True, text=True,
30
+ )
31
+ if result.returncode == 0:
32
+ stopped_via.append("launchd daemon unloaded")
33
+
34
+ # Try systemd (Linux).
35
+ # `disable --now` stops the unit immediately AND removes it from the
36
+ # boot-time targets, so it does not auto-start on next login.
37
+ # The service file stays on disk; `systemctl --user enable --now tokenjam`
38
+ # re-enables it.
39
+ if systemd_path.exists():
40
+ result = subprocess.run(
41
+ ["systemctl", "--user", "disable", "--now", "tokenjam"],
42
+ capture_output=True, text=True,
43
+ )
44
+ if result.returncode == 0:
45
+ stopped_via.append("systemd service stopped")
46
+
47
+ # Always sweep for orphan foreground `tj serve` processes started via
48
+ # `tj serve &` — launchd/systemd unload doesn't affect those, and they
49
+ # keep holding the port. Track signaled PIDs so a slow-shutting process
50
+ # doesn't get re-signaled (SIGTERM is async; pgrep can return the same
51
+ # PID before the handler fires, which would otherwise spin forever).
52
+ signaled: set[int] = set()
53
+ for _ in range(20): # hard cap — well above any realistic straggler count
54
+ pid = _find_serve_pid()
55
+ if not pid or pid in signaled:
56
+ break
57
+ try:
58
+ os.kill(pid, signal.SIGTERM)
59
+ except ProcessLookupError:
60
+ break
61
+ signaled.add(pid)
62
+ stopped_via.append(f"PID {pid}")
63
+
64
+ if stopped_via:
65
+ console.print(
66
+ f"[green]tj serve stopped.[/green] ({', '.join(stopped_via)})"
67
+ )
68
+ else:
69
+ console.print("[dim]tj serve is not running.[/dim]")
70
+
71
+
72
+ def _find_serve_pid() -> int | None:
73
+ """Find the PID of a running `tj serve`. process."""
74
+ try:
75
+ result = subprocess.run(
76
+ ["pgrep", "-f", "tokenjam.serve|tj serve"],
77
+ capture_output=True, text=True,
78
+ )
79
+ if result.returncode == 0:
80
+ for line in result.stdout.strip().splitlines():
81
+ pid = int(line.strip())
82
+ # Don't return our own PID
83
+ if pid != os.getpid():
84
+ return pid
85
+ except (FileNotFoundError, ValueError):
86
+ pass
87
+ return None
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ import click
6
+
7
+ from tokenjam.utils.formatting import console, make_table
8
+ from tokenjam.utils.time_parse import parse_since
9
+
10
+
11
+ @click.command("tools")
12
+ @click.option("--agent", default=None, help="Filter to specific agent_id")
13
+ @click.option("--since", default="24h", help="Time window (e.g. 1h, 7d)")
14
+ @click.option("--name", "tool_name", default=None, help="Filter to specific tool")
15
+ @click.option("--json", "output_json", is_flag=True)
16
+ @click.pass_context
17
+ def cmd_tools(ctx: click.Context, agent: str | None, since: str,
18
+ tool_name: str | None, output_json: bool) -> None:
19
+ """Show tool call summary."""
20
+ db = ctx.obj["db"]
21
+ agent_filter = agent or ctx.obj.get("agent")
22
+ since_dt = parse_since(since)
23
+
24
+ rows = db.get_tool_calls(agent_filter, since_dt, tool_name)
25
+
26
+ if output_json:
27
+ click.echo(json.dumps(rows, default=str))
28
+ return
29
+
30
+ if not rows:
31
+ console.print("[dim]No tool calls found for the given filters.[/dim]")
32
+ return
33
+
34
+ table = make_table("TOOL", "AGENT", "CALLS", "AVG DUR")
35
+ for r in rows:
36
+ call_count = r["call_count"]
37
+ total_dur = r["total_duration_ms"]
38
+ avg_dur = f"{total_dur / call_count:.0f}ms" if call_count > 0 else "-"
39
+ table.add_row(
40
+ r["tool_name"],
41
+ r.get("agent_id") or "-",
42
+ str(call_count),
43
+ avg_dur,
44
+ )
45
+ console.print(table)
@@ -0,0 +1,161 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ import click
6
+
7
+ from tokenjam.core.models import NormalizedSpan, TraceFilters
8
+ from tokenjam.utils.formatting import console, format_cost, make_table
9
+ from tokenjam.utils.time_parse import parse_since
10
+
11
+
12
+ @click.command("traces")
13
+ @click.option("--agent", default=None, help="Filter to specific agent_id")
14
+ @click.option("--since", default="24h", help="Time window (e.g. 1h, 7d)")
15
+ @click.option("--limit", default=50, type=int)
16
+ @click.option("--type", "span_type", default=None, help="Filter by span name/type")
17
+ @click.option("--status", default=None, type=click.Choice(["ok", "error"]))
18
+ @click.option("--json", "output_json", is_flag=True)
19
+ @click.pass_context
20
+ def cmd_traces(ctx: click.Context, agent: str | None, since: str, limit: int,
21
+ span_type: str | None, status: str | None, output_json: bool) -> None:
22
+ """List recent traces."""
23
+ db = ctx.obj["db"]
24
+ agent_filter = agent or ctx.obj.get("agent")
25
+ try:
26
+ since_dt = parse_since(since)
27
+ except ValueError as exc:
28
+ raise click.BadParameter(str(exc), param_hint="'--since'") from exc
29
+ filters = TraceFilters(
30
+ agent_id=agent_filter,
31
+ since=since_dt,
32
+ span_name=span_type,
33
+ status=status,
34
+ limit=limit,
35
+ )
36
+ traces = db.get_traces(filters)
37
+
38
+ if output_json:
39
+ click.echo(json.dumps([
40
+ {
41
+ "trace_id": t.trace_id,
42
+ "agent_id": t.agent_id,
43
+ "name": t.name,
44
+ "start_time": t.start_time.isoformat() if t.start_time else None,
45
+ "duration_ms": t.duration_ms,
46
+ "cost_usd": t.cost_usd,
47
+ "status_code": t.status_code,
48
+ "span_count": t.span_count,
49
+ }
50
+ for t in traces
51
+ ], default=str))
52
+ return
53
+
54
+ if not traces:
55
+ console.print("[dim]No traces found for the given filters.[/dim]")
56
+ return
57
+
58
+ table = make_table("TRACE ID", "AGENT", "TYPE", "DUR", "COST", "STATUS")
59
+ for t in traces:
60
+ dur = f"{t.duration_ms:.0f}ms" if t.duration_ms else "-"
61
+ cost = format_cost(t.cost_usd) if t.cost_usd else "-"
62
+ status_style = "red" if t.status_code == "error" else ""
63
+ table.add_row(
64
+ t.trace_id[:12] + "...",
65
+ t.agent_id or "-",
66
+ t.name,
67
+ dur,
68
+ cost,
69
+ f"[{status_style}]{t.status_code}[/]" if status_style else t.status_code,
70
+ )
71
+ console.print(table)
72
+
73
+
74
+ @click.command("trace")
75
+ @click.argument("trace_id")
76
+ @click.option("--json", "output_json", is_flag=True)
77
+ @click.pass_context
78
+ def cmd_trace(ctx: click.Context, trace_id: str, output_json: bool) -> None:
79
+ """Show span waterfall for a single trace."""
80
+ db = ctx.obj["db"]
81
+ spans = db.get_trace_spans(trace_id)
82
+
83
+ # Support prefix matching (like git short hashes)
84
+ if not spans and len(trace_id) < 32:
85
+ if hasattr(db, "conn"):
86
+ rows = db.conn.execute(
87
+ "SELECT DISTINCT trace_id FROM spans WHERE trace_id LIKE $1 LIMIT 2",
88
+ [trace_id + "%"],
89
+ ).fetchall()
90
+ if len(rows) == 1:
91
+ trace_id = rows[0][0]
92
+ spans = db.get_trace_spans(trace_id)
93
+ elif len(rows) > 1:
94
+ console.print(f"[red]Ambiguous prefix '{trace_id}' — matches "
95
+ f"{len(rows)} traces. Use more characters.[/red]")
96
+ return
97
+
98
+ if not spans:
99
+ console.print(f"[dim]No spans found for trace {trace_id}[/dim]")
100
+ return
101
+
102
+ if output_json:
103
+ click.echo(json.dumps([
104
+ {
105
+ "span_id": s.span_id,
106
+ "parent_span_id": s.parent_span_id,
107
+ "name": s.name,
108
+ "kind": s.kind.value,
109
+ "status_code": s.status_code.value,
110
+ "start_time": s.start_time.isoformat() if s.start_time else None,
111
+ "duration_ms": s.duration_ms,
112
+ "provider": s.provider,
113
+ "model": s.model,
114
+ "tool_name": s.tool_name,
115
+ "input_tokens": s.input_tokens,
116
+ "output_tokens": s.output_tokens,
117
+ "cost_usd": s.cost_usd,
118
+ }
119
+ for s in spans
120
+ ], default=str))
121
+ return
122
+
123
+ # Build parent->children map for tree rendering
124
+ children: dict[str | None, list[NormalizedSpan]] = {}
125
+ for s in spans:
126
+ children.setdefault(s.parent_span_id, []).append(s)
127
+
128
+ # Find root spans (no parent or parent not in this trace)
129
+ span_ids = {s.span_id for s in spans}
130
+ roots = [s for s in spans if s.parent_span_id is None
131
+ or s.parent_span_id not in span_ids]
132
+
133
+ for root in roots:
134
+ _print_span_tree(root, children, prefix="", is_last=True)
135
+
136
+
137
+ def _print_span_tree(span: NormalizedSpan, children: dict[str | None, list[NormalizedSpan]],
138
+ prefix: str, is_last: bool) -> None:
139
+ connector = "\u2514\u2500 " if is_last else "\u251c\u2500 "
140
+ dur = f"{span.duration_ms:.0f}ms" if span.duration_ms else ""
141
+ cost = format_cost(span.cost_usd) if span.cost_usd else ""
142
+
143
+ parts = [f"[bold]{span.name}[/bold]"]
144
+ if dur:
145
+ parts.append(f"[dim]{dur}[/dim]")
146
+ if span.model:
147
+ parts.append(f"[cyan]{span.model}[/cyan]")
148
+ if span.tool_name:
149
+ parts.append(f"[magenta]{span.tool_name}[/magenta]")
150
+ if cost:
151
+ parts.append(cost)
152
+ if span.status_code.value == "error":
153
+ parts.append("[red]ERROR[/red]")
154
+
155
+ line = " ".join(parts)
156
+ console.print(f"{prefix}{connector}{line}")
157
+
158
+ child_spans = children.get(span.span_id, [])
159
+ for i, child in enumerate(child_spans):
160
+ child_prefix = prefix + (" " if is_last else "\u2502 ")
161
+ _print_span_tree(child, children, child_prefix, i == len(child_spans) - 1)
@@ -0,0 +1,159 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ import shutil
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+ import click
10
+
11
+ from tokenjam.utils.formatting import console
12
+
13
+
14
+ @click.command("uninstall")
15
+ @click.option("--yes", is_flag=True, help="Skip confirmation prompt")
16
+ @click.pass_context
17
+ def cmd_uninstall(ctx: click.Context, yes: bool) -> None:
18
+ """Remove all OCW data, config, and daemon."""
19
+ if not yes:
20
+ confirmed = click.confirm(
21
+ "This will delete all OCW data including telemetry history. Continue?",
22
+ default=False,
23
+ )
24
+ if not confirmed:
25
+ console.print("[dim]Cancelled.[/dim]")
26
+ return
27
+
28
+ # 1. Stop tj serve if running
29
+ from tokenjam.cli.cmd_stop import cmd_stop
30
+ ctx.invoke(cmd_stop)
31
+
32
+ # 2. Deregister MCP server from Claude Code (Gap #13)
33
+ if shutil.which("claude"):
34
+ subprocess.run(
35
+ ["claude", "mcp", "remove", "tj", "--scope", "user"],
36
+ capture_output=True, text=True,
37
+ )
38
+ console.print(" Removed tj MCP server from Claude Code.")
39
+
40
+ # 3. Unload and delete launchd plist
41
+ plist_path = Path.home() / "Library/LaunchAgents/com.tokenjam.serve.plist"
42
+ if plist_path.exists():
43
+ subprocess.run(
44
+ ["launchctl", "unload", str(plist_path)],
45
+ capture_output=True, text=True,
46
+ )
47
+ plist_path.unlink()
48
+ console.print(f" Removed {plist_path}")
49
+
50
+ # 4. Delete systemd service if present
51
+ systemd_path = Path.home() / ".config/systemd/user/tokenjam.service"
52
+ if systemd_path.exists():
53
+ subprocess.run(
54
+ ["systemctl", "--user", "disable", "--now", "tokenjam"],
55
+ capture_output=True, text=True,
56
+ )
57
+ systemd_path.unlink()
58
+ console.print(f" Removed {systemd_path}")
59
+
60
+ # 5. Delete ~/.tj/ (telemetry DB)
61
+ ocw_dir = Path.home() / ".tj"
62
+ if ocw_dir.exists():
63
+ shutil.rmtree(ocw_dir)
64
+ console.print(f" Removed {ocw_dir}")
65
+
66
+ # 6. Read projects index BEFORE deleting the global config dir.
67
+ global_config_dir = Path.home() / ".config" / "tj"
68
+ project_paths: list[Path] = []
69
+ projects_index = global_config_dir / "projects.json"
70
+ try:
71
+ if projects_index.exists():
72
+ paths = json.loads(projects_index.read_text())
73
+ project_paths = [Path(p) for p in paths if isinstance(p, str)]
74
+ except Exception:
75
+ pass
76
+
77
+ # 7. Delete global config ~/.config/tj/
78
+ if global_config_dir.exists():
79
+ shutil.rmtree(global_config_dir)
80
+ console.print(f" Removed {global_config_dir}")
81
+
82
+ # 8. Delete local .tj/ if present
83
+ local_ocw = Path(".tj")
84
+ if local_ocw.exists():
85
+ shutil.rmtree(local_ocw)
86
+ console.print(f" Removed {local_ocw}")
87
+
88
+ # 9. Delete temp files
89
+ for tmp_file in ["/tmp/tj-serve.out", "/tmp/tj-serve.err"]:
90
+ p = Path(tmp_file)
91
+ if p.exists():
92
+ p.unlink()
93
+ console.print(f" Removed {tmp_file}")
94
+
95
+ # 10. Remove OCW env vars from ~/.claude/settings.json
96
+ _GLOBAL_TJ_KEYS = {
97
+ "CLAUDE_CODE_ENABLE_TELEMETRY",
98
+ "OTEL_LOGS_EXPORTER",
99
+ "OTEL_EXPORTER_OTLP_PROTOCOL",
100
+ "OTEL_EXPORTER_OTLP_ENDPOINT",
101
+ "OTEL_EXPORTER_OTLP_HEADERS",
102
+ }
103
+ global_settings_path = Path.home() / ".claude" / "settings.json"
104
+ if global_settings_path.exists():
105
+ try:
106
+ gs = json.loads(global_settings_path.read_text())
107
+ env = gs.get("env", {})
108
+ removed = [k for k in _GLOBAL_TJ_KEYS if k in env]
109
+ for k in removed:
110
+ del env[k]
111
+ if removed:
112
+ gs["env"] = env
113
+ global_settings_path.write_text(json.dumps(gs, indent=2) + "\n")
114
+ console.print(f" Cleaned {len(removed)} OCW env vars from {global_settings_path}")
115
+ except Exception as exc:
116
+ console.print(f" [yellow]Could not clean {global_settings_path}: {exc}[/yellow]")
117
+
118
+ # 11. Remove OTEL_RESOURCE_ATTRIBUTES from all onboarded project .claude/settings.json files.
119
+ # project_paths was read from projects.json before the global config dir was deleted above.
120
+ # Always include CWD so running uninstall from a project dir works even without the index
121
+ cwd = Path.cwd()
122
+ if cwd not in project_paths:
123
+ project_paths.append(cwd)
124
+
125
+ for proj in project_paths:
126
+ proj_settings = proj / ".claude" / "settings.json"
127
+ if not proj_settings.exists():
128
+ continue
129
+ try:
130
+ ps = json.loads(proj_settings.read_text())
131
+ env = ps.get("env", {})
132
+ if "OTEL_RESOURCE_ATTRIBUTES" in env:
133
+ del env["OTEL_RESOURCE_ATTRIBUTES"]
134
+ ps["env"] = env
135
+ proj_settings.write_text(json.dumps(ps, indent=2) + "\n")
136
+ console.print(f" Removed OTEL_RESOURCE_ATTRIBUTES from {proj_settings}")
137
+ except Exception as exc:
138
+ console.print(f" [yellow]Could not clean {proj_settings}: {exc}[/yellow]")
139
+
140
+ # 11. Remove # tj harness observability block from ~/.zshrc
141
+ zshrc = Path.home() / ".zshrc"
142
+ if zshrc.exists():
143
+ try:
144
+ text = zshrc.read_text()
145
+ # Match the marker line plus all following export lines (any count)
146
+ cleaned = re.sub(
147
+ r"# tj harness observability\n(?:export [^\n]+\n)*",
148
+ "",
149
+ text,
150
+ )
151
+ if cleaned != text:
152
+ zshrc.write_text(cleaned)
153
+ console.print(f" Removed OCW env block from {zshrc}")
154
+ except Exception as exc:
155
+ console.print(f" [yellow]Could not clean {zshrc}: {exc}[/yellow]")
156
+
157
+ console.print()
158
+ console.print("[green]TokenJam data and config removed.[/green]")
159
+ console.print("To remove the package itself, run: [bold]pip uninstall tokenjam[/bold]")