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,779 @@
1
+ from __future__ import annotations
2
+
3
+ import json as json_mod
4
+ import platform
5
+ import secrets
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ import click
12
+
13
+ from tokenjam.core.config import find_config_file
14
+ from tokenjam.utils.formatting import console
15
+
16
+
17
+ @click.command("onboard")
18
+ @click.option("--claude-code", "claude_code", is_flag=True, default=False,
19
+ help="Configure Claude Code telemetry to flow into tj")
20
+ @click.option("--codex", "codex", is_flag=True, default=False,
21
+ help="Configure Codex CLI telemetry to flow into tj")
22
+ @click.option("--budget", type=float, default=None,
23
+ help="Daily budget in USD per agent (0 = no limit)")
24
+ @click.option("--install-daemon", "install_daemon", is_flag=True, default=False,
25
+ help="(no-op: daemon is installed by default; use --no-daemon to skip)")
26
+ @click.option("--no-daemon", is_flag=True, default=False,
27
+ help="Skip background daemon installation")
28
+ @click.option("--force", is_flag=True, help="Overwrite existing config")
29
+ @click.pass_context
30
+ def cmd_onboard(ctx: click.Context, claude_code: bool, codex: bool, budget: float | None,
31
+ install_daemon: bool, no_daemon: bool, force: bool) -> None:
32
+ """Interactive setup wizard for tj."""
33
+ if claude_code:
34
+ _onboard_claude_code(ctx, budget, no_daemon, force)
35
+ return
36
+ if codex:
37
+ _onboard_codex(ctx, budget, no_daemon, force)
38
+ return
39
+ existing = find_config_file()
40
+ if existing and not force:
41
+ console.print(f"[bold]Config already exists:[/bold] {existing}")
42
+ console.print("Use [bold]--force[/bold] to overwrite.")
43
+ return
44
+
45
+ console.print()
46
+ console.print("[bold]Setting up TokenJam...[/bold]")
47
+ console.print()
48
+
49
+ if budget is None:
50
+ budget = click.prompt(
51
+ "Daily budget in USD per agent (0 = no limit, default 0)",
52
+ type=float, default=0.0, show_default=False,
53
+ )
54
+
55
+ ingest_secret = secrets.token_hex(32)
56
+
57
+ want_daemon = not no_daemon
58
+
59
+ config_path = Path(".tj/config.toml")
60
+ config_path.parent.mkdir(parents=True, exist_ok=True)
61
+
62
+ budget_line = ""
63
+ if budget and budget > 0:
64
+ budget_line = f"daily_usd = {budget}"
65
+
66
+ config_text = f"""\
67
+ # TokenJam configuration
68
+ # Docs: https://github.com/Metabuilder-Labs/openclawwatch#configuration
69
+
70
+ [defaults.budget]
71
+ {budget_line}
72
+
73
+ [security]
74
+ ingest_secret = "{ingest_secret}"
75
+
76
+ [capture]
77
+ prompts = false
78
+ completions = false
79
+ tool_outputs = false
80
+
81
+ [storage]
82
+ path = "~/.tj/telemetry.duckdb"
83
+ retention_days = 90
84
+
85
+ # Per-agent overrides (optional):
86
+ # [agents.my-agent]
87
+ # description = "My email agent"
88
+ # [agents.my-agent.budget]
89
+ # daily_usd = 5.00
90
+ # session_usd = 1.00
91
+ # [[agents.my-agent.sensitive_actions]]
92
+ # name = "send_email"
93
+ # severity = "critical"
94
+ """
95
+ config_path.write_text(config_text)
96
+
97
+ daemon_msg = None
98
+ if want_daemon:
99
+ daemon_msg = _install_daemon(str(config_path.resolve()))
100
+
101
+ # Output
102
+ console.print()
103
+ console.print("[green]\u2713[/green] Config written to [bold].tj/config.toml[/bold]")
104
+ console.print(f"[green]\u2713[/green] Ingest secret generated: "
105
+ f"[dim]{ingest_secret[:8]}...[/dim]")
106
+ if budget and budget > 0:
107
+ console.print(f"[green]\u2713[/green] Default daily budget: "
108
+ f"[bold]${budget:.2f}[/bold] per agent")
109
+ if daemon_msg:
110
+ console.print(f"[green]\u2713[/green] {daemon_msg}")
111
+
112
+ console.print()
113
+ console.print("[bold]Next steps:[/bold]")
114
+ console.print()
115
+ console.print(" 1. Instrument your agent:")
116
+ console.print()
117
+ console.print("[dim] from tokenjam.sdk import watch[/dim]")
118
+ console.print("[dim] from tokenjam.sdk.integrations.anthropic import patch_anthropic[/dim]")
119
+ console.print()
120
+ console.print("[dim] patch_anthropic()[/dim]")
121
+ console.print()
122
+ console.print('[dim] @watch(agent_id="my-agent")[/dim]')
123
+ console.print("[dim] def run(task):[/dim]")
124
+ console.print("[dim] ...[/dim]")
125
+ console.print()
126
+ console.print(" 2. Run your agent \u2014 spans are recorded automatically")
127
+ console.print()
128
+ console.print(" 3. View telemetry:")
129
+ console.print("[dim] tj status [/dim]# agent overview")
130
+ console.print("[dim] tj traces [/dim]# span history")
131
+ console.print("[dim] tj serve [/dim]# web UI at http://127.0.0.1:7391/")
132
+ console.print()
133
+
134
+ if not want_daemon:
135
+ console.print(" Run [bold]tj serve[/bold] to start the web UI "
136
+ "and enable real-time alerts.")
137
+ console.print()
138
+
139
+ console.print(
140
+ " To configure per-agent budgets, sensitive actions, or drift detection:"
141
+ )
142
+ console.print(
143
+ " Edit [bold].tj/config.toml[/bold] \u2014 see "
144
+ "[dim]https://github.com/Metabuilder-Labs/openclawwatch#configuration[/dim]"
145
+ )
146
+ console.print()
147
+
148
+
149
+ def _onboard_claude_code(
150
+ ctx: click.Context,
151
+ budget: float | None,
152
+ no_daemon: bool,
153
+ force: bool,
154
+ ) -> None:
155
+ """Configure Claude Code to send telemetry to tj."""
156
+ from tokenjam.core.config import (
157
+ AgentConfig, BudgetConfig, TjConfig, SecurityConfig, load_config, write_config,
158
+ )
159
+
160
+ # --claude-code always uses the global config so that all projects share one
161
+ # ingest secret and one running daemon. Per-project configs cause the secret in
162
+ # ~/.claude/settings.json to rotate on every project onboard, breaking auth for
163
+ # every other project.
164
+ global_config_path = Path.home() / ".config" / "tj" / "config.toml"
165
+
166
+ project_name = _derive_project_name()
167
+ agent_id = f"claude-code-{project_name}"
168
+
169
+ if budget is None:
170
+ budget = click.prompt(
171
+ "Daily budget in USD (0 = no limit, default 0)",
172
+ type=float, default=0.0, show_default=False,
173
+ )
174
+
175
+ if global_config_path.exists() and not force:
176
+ config = load_config(str(global_config_path))
177
+ if agent_id not in config.agents:
178
+ config.agents[agent_id] = AgentConfig()
179
+ if budget and budget > 0:
180
+ config.agents[agent_id].budget.daily_usd = budget
181
+ config_path = global_config_path
182
+ write_config(config, config_path)
183
+ console.print(f" tj config updated: {config_path}")
184
+ else:
185
+ ingest_secret = secrets.token_hex(32)
186
+ daily_usd = budget if budget and budget > 0 else None
187
+ agents = {agent_id: AgentConfig(budget=BudgetConfig(daily_usd=daily_usd))}
188
+ config = TjConfig(
189
+ version="1",
190
+ agents=agents,
191
+ security=SecurityConfig(ingest_secret=ingest_secret),
192
+ )
193
+ config_path = global_config_path
194
+ config_path.parent.mkdir(parents=True, exist_ok=True)
195
+ write_config(config, config_path)
196
+ console.print(f" tj config written to: {config_path}")
197
+ if _sync_secret_to_codex(ingest_secret):
198
+ console.print(" Codex config updated to match new ingest secret.")
199
+
200
+ # --- Register MCP server with Claude Code ---
201
+ if shutil.which("claude"):
202
+ subprocess.run(
203
+ ["claude", "mcp", "add", "tj", "--scope", "user", "--", "tj", "mcp"],
204
+ capture_output=True,
205
+ )
206
+
207
+ # --- Global settings (~/.claude/settings.json) ---
208
+ claude_dir = Path.home() / ".claude"
209
+ claude_dir.mkdir(parents=True, exist_ok=True)
210
+ global_settings_path = claude_dir / "settings.json"
211
+
212
+ global_settings: dict = {}
213
+ if global_settings_path.exists():
214
+ try:
215
+ global_settings = json_mod.loads(global_settings_path.read_text())
216
+ except (json_mod.JSONDecodeError, OSError):
217
+ global_settings = {}
218
+
219
+ # Write global OTLP config — always overwrite endpoint vars so reinstall stays in sync.
220
+ # Custom headers (non-OCW) are preserved; only OCW-generated "Authorization=Bearer"
221
+ # headers are replaced when the secret rotates.
222
+ port = config.api.port
223
+ secret = config.security.ingest_secret
224
+ global_env: dict = global_settings.get("env", {})
225
+ global_env["CLAUDE_CODE_ENABLE_TELEMETRY"] = "1"
226
+ global_env["OTEL_LOGS_EXPORTER"] = "otlp"
227
+ global_env["OTEL_EXPORTER_OTLP_PROTOCOL"] = "http/json"
228
+ global_env["OTEL_EXPORTER_OTLP_ENDPOINT"] = f"http://127.0.0.1:{port}"
229
+ existing_header = global_env.get("OTEL_EXPORTER_OTLP_HEADERS", "")
230
+ if secret and (not existing_header or "Authorization=Bearer" in existing_header):
231
+ global_env["OTEL_EXPORTER_OTLP_HEADERS"] = f"Authorization=Bearer {secret}"
232
+ global_settings["env"] = global_env
233
+ global_settings_path.write_text(json_mod.dumps(global_settings, indent=2) + "\n")
234
+
235
+ # --- Project settings (<cwd>/.claude/settings.json) ---
236
+ project_claude_dir = Path.cwd() / ".claude"
237
+ project_claude_dir.mkdir(parents=True, exist_ok=True)
238
+ project_settings_path = project_claude_dir / "settings.json"
239
+
240
+ project_settings: dict = {}
241
+ if project_settings_path.exists():
242
+ try:
243
+ project_settings = json_mod.loads(project_settings_path.read_text())
244
+ except (json_mod.JSONDecodeError, OSError):
245
+ project_settings = {}
246
+
247
+ project_env: dict = project_settings.get("env", {})
248
+ project_env["OTEL_RESOURCE_ATTRIBUTES"] = f"service.name={agent_id}"
249
+ project_settings["env"] = project_env
250
+ project_settings_path.write_text(json_mod.dumps(project_settings, indent=2) + "\n")
251
+
252
+ # --- Track onboarded project paths for clean uninstall ---
253
+ projects_index = config_path.parent / "projects.json"
254
+ try:
255
+ known: list[str] = json_mod.loads(projects_index.read_text()) if projects_index.exists() else []
256
+ except (json_mod.JSONDecodeError, OSError):
257
+ known = []
258
+ cwd_str = str(Path.cwd())
259
+ if cwd_str not in known:
260
+ known.append(cwd_str)
261
+ projects_index.write_text(json_mod.dumps(known, indent=2) + "\n")
262
+
263
+ # --- Shell env (~/.zshrc) ---
264
+ # Writes host.docker.internal endpoint so harness sessions (Docker) pick up
265
+ # the vars automatically via compose.yml passthrough — no manual setup needed.
266
+ # Native Claude Code uses settings.json (127.0.0.1) written above instead.
267
+ zshrc = Path.home() / ".zshrc"
268
+ zshrc.touch(exist_ok=True)
269
+ marker = "# tj harness observability"
270
+ zshrc_text = zshrc.read_text()
271
+ new_block = (
272
+ f"\n{marker}\n"
273
+ f"export CLAUDE_CODE_ENABLE_TELEMETRY=1\n"
274
+ f"export OTEL_LOGS_EXPORTER=otlp\n"
275
+ f"export OTEL_EXPORTER_OTLP_PROTOCOL=http/json\n"
276
+ f"export OTEL_EXPORTER_OTLP_ENDPOINT=http://host.docker.internal:{port}\n"
277
+ f'export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer {secret}"\n'
278
+ )
279
+ if marker not in zshrc_text:
280
+ with zshrc.open("a") as f:
281
+ f.write(new_block)
282
+ else:
283
+ # Marker already present — replace the entire block to keep the secret in sync.
284
+ import re as _re
285
+ updated = _re.sub(
286
+ r"# tj harness observability\n(?:export [^\n]+\n)*",
287
+ new_block.lstrip("\n"),
288
+ zshrc_text,
289
+ )
290
+ zshrc.write_text(updated)
291
+
292
+ want_daemon = not no_daemon
293
+ if want_daemon:
294
+ if not force and _daemon_already_running():
295
+ console.print(" Daemon: already running (skipped reinstall)")
296
+ else:
297
+ console.print(" Daemon: installing...")
298
+ _install_daemon(str(config_path.resolve()))
299
+
300
+ console.print()
301
+ console.print("[bold green]Claude Code observability configured.[/bold green]")
302
+ console.print(f" Global settings: {global_settings_path}")
303
+ console.print(f" Project settings: {project_settings_path}")
304
+ console.print(" Shell env: ~/.zshrc (harness-compatible endpoint)")
305
+ console.print(f" Agent ID: {agent_id}")
306
+ if budget and budget > 0:
307
+ console.print(f" Daily budget: ${budget:.2f}")
308
+ console.print(f" OTLP endpoint: http://127.0.0.1:{port} (native)")
309
+ console.print(f" http://host.docker.internal:{port} (harness)")
310
+ if secret:
311
+ console.print(f" Ingest secret: {secret[:8]}...")
312
+ console.print()
313
+ if not want_daemon:
314
+ console.print("[dim]Start the server:[/dim] tj serve")
315
+ console.print("[dim]Restart Claude Code for settings to take effect.[/dim]")
316
+ console.print(f"[dim]Then run:[/dim] tj status --agent {agent_id}")
317
+
318
+
319
+ def _onboard_codex(
320
+ ctx: click.Context,
321
+ budget: float | None,
322
+ no_daemon: bool,
323
+ force: bool,
324
+ ) -> None:
325
+ """Configure Codex CLI to send telemetry to tj."""
326
+ try:
327
+ import tomllib # type: ignore[import]
328
+ except ImportError:
329
+ import tomli as tomllib # type: ignore[no-redef]
330
+
331
+ from tokenjam.core.config import (
332
+ AgentConfig, BudgetConfig, TjConfig, SecurityConfig,
333
+ load_config, write_config,
334
+ )
335
+
336
+ # Codex hardcodes service.name="codex_exec" in its binary regardless of
337
+ # what [otel.resource] says, so all Codex traces land under "codex_exec".
338
+ agent_id = "codex_exec"
339
+
340
+ if budget is None:
341
+ budget = click.prompt(
342
+ "Daily budget in USD (0 = no limit, default 0)",
343
+ type=float, default=0.0, show_default=False,
344
+ )
345
+
346
+ # `--codex` always writes to the global config, mirroring `--claude-code`.
347
+ # Codex's own config (~/.codex/config.toml) is global and the agent_id
348
+ # `codex_exec` is project-agnostic by design (Codex hardcodes service.name
349
+ # in its binary). Per-project OCW configs would rotate the secret on every
350
+ # onboard, breaking the running server.
351
+ config_path = Path.home() / ".config" / "tj" / "config.toml"
352
+
353
+ previous_secret: str | None = None
354
+ if config_path.exists():
355
+ try:
356
+ prev_cfg = load_config(str(config_path))
357
+ previous_secret = prev_cfg.security.ingest_secret
358
+ except Exception:
359
+ previous_secret = None
360
+
361
+ if config_path.exists():
362
+ config = load_config(str(config_path))
363
+ if agent_id not in config.agents:
364
+ config.agents[agent_id] = AgentConfig()
365
+ if budget and budget > 0:
366
+ config.agents[agent_id].budget.daily_usd = budget
367
+ write_config(config, config_path)
368
+ console.print(f" tj config updated: {config_path}")
369
+ else:
370
+ ingest_secret = secrets.token_hex(32)
371
+ daily_usd = budget if budget and budget > 0 else None
372
+ agents = {agent_id: AgentConfig(budget=BudgetConfig(daily_usd=daily_usd))}
373
+ config = TjConfig(
374
+ version="1",
375
+ agents=agents,
376
+ security=SecurityConfig(ingest_secret=ingest_secret),
377
+ )
378
+ config_path.parent.mkdir(parents=True, exist_ok=True)
379
+ write_config(config, config_path)
380
+ console.print(f" tj config written to: {config_path}")
381
+ if _sync_secret_to_claude_code(ingest_secret):
382
+ console.print(" Claude Code config updated to match new ingest secret.")
383
+
384
+ port = config.api.port
385
+ secret = config.security.ingest_secret
386
+ secret_rotated = bool(previous_secret) and previous_secret != secret
387
+
388
+ # --- Write Codex CLI OTel config (~/.codex/config.toml) ---
389
+ codex_config_path = Path.home() / ".codex" / "config.toml"
390
+ codex_config_path.parent.mkdir(parents=True, exist_ok=True)
391
+
392
+ existing_content = codex_config_path.read_text() if codex_config_path.exists() else ""
393
+
394
+ # Check whether an [otel] section already exists
395
+ existing_codex: dict = {}
396
+ if existing_content:
397
+ try:
398
+ existing_codex = tomllib.loads(existing_content)
399
+ except Exception:
400
+ pass
401
+
402
+ already_has_otel = "otel" in existing_codex
403
+ already_has_mcp = bool(existing_codex.get("mcp_servers", {}).get("tj"))
404
+ if already_has_otel and already_has_mcp and not force:
405
+ # Use plain print() for messages containing TOML section headers like
406
+ # [otel] — Rich treats square brackets as markup tags and would strip
407
+ # them, leaving the message unintelligible ("already has and ").
408
+ click.echo(
409
+ "~/.codex/config.toml already has [otel] and [mcp_servers.tj] sections."
410
+ )
411
+ click.echo("Use --force to overwrite, or add manually:")
412
+ click.echo("")
413
+ _print_codex_otel_block(port, secret, agent_id)
414
+ return
415
+
416
+ otel_block = _codex_otel_toml_block(port, secret, agent_id)
417
+ mcp_block = _codex_mcp_toml_block()
418
+
419
+ # Build the new file content by replacing/appending each managed section.
420
+ base_content = existing_content
421
+ if force:
422
+ # Fully wipe previous OTEL sections so nested tables don't duplicate.
423
+ base_content = _codex_strip_otel_sections(base_content)
424
+ new_content = _codex_apply_block(
425
+ base_content, r"\[otel\]", already_has_otel, otel_block, force,
426
+ )
427
+ # Re-parse after the otel update so section detection is accurate.
428
+ try:
429
+ existing_after_otel = tomllib.loads(new_content)
430
+ except Exception:
431
+ existing_after_otel = existing_codex
432
+ existing_mcp = existing_after_otel.get("mcp_servers", {}).get("tj")
433
+ new_content = _codex_apply_block(
434
+ new_content,
435
+ r"\[mcp_servers\.tj\]",
436
+ bool(existing_mcp),
437
+ mcp_block,
438
+ force,
439
+ )
440
+ codex_config_path.write_text(new_content)
441
+
442
+ # --- Try registering via the Codex CLI as well (best-effort) ---
443
+ if shutil.which("codex"):
444
+ subprocess.run(
445
+ ["codex", "mcp", "add", "tj", "--", "tj", "mcp"],
446
+ capture_output=True,
447
+ )
448
+
449
+ # --- If ingest secret rotated, restart running server first ---
450
+ restart_msg: str | None = None
451
+ if secret_rotated:
452
+ restart_msg = _restart_tj_server_for_secret_rotation(
453
+ str(config_path.resolve()), no_daemon=no_daemon
454
+ )
455
+
456
+ # --- Install daemon if requested ---
457
+ want_daemon = not no_daemon
458
+ if want_daemon and not secret_rotated:
459
+ console.print(" Daemon: auto-installing (use --no-daemon to skip)")
460
+ _install_daemon(str(config_path.resolve()))
461
+
462
+ console.print()
463
+ console.print("[bold green]Codex CLI observability configured.[/bold green]")
464
+ console.print(f" Codex config: {codex_config_path}")
465
+ console.print(f" OCW config: {config_path}")
466
+ if budget and budget > 0:
467
+ console.print(f" Daily budget: ${budget:.2f}")
468
+ console.print(f" OTLP endpoint: http://127.0.0.1:{port}/v1/logs")
469
+ if secret:
470
+ console.print(f" Ingest secret: {secret[:8]}...")
471
+ console.print(" MCP server: tj mcp (registered in Codex config)")
472
+ if restart_msg:
473
+ console.print(f" Server restart: {restart_msg}")
474
+ console.print()
475
+ if not want_daemon:
476
+ console.print("[dim]Start the server:[/dim] tj serve")
477
+ console.print(
478
+ "[dim]Codex can now call OCW tools (open_dashboard, get_status, etc.) directly.[/dim]"
479
+ )
480
+ console.print("[dim]Then run:[/dim] tj traces")
481
+
482
+
483
+ def _restart_tj_server_for_secret_rotation(config_path: str, no_daemon: bool) -> str:
484
+ """Restart running tj serve to pick up a rotated ingest secret.
485
+
486
+ We stop first to ensure any manually-started server process with stale config
487
+ is terminated, then (when daemon mode is enabled) start it again.
488
+ """
489
+ stopped = False
490
+ try:
491
+ # Best-effort: stop launchd/systemd daemon or background serve process.
492
+ stop_result = subprocess.run(
493
+ ["tj", "stop"], capture_output=True, text=True, timeout=10
494
+ )
495
+ stopped = stop_result.returncode == 0
496
+ except Exception:
497
+ stopped = False
498
+
499
+ if no_daemon:
500
+ if stopped:
501
+ return "stopped stale server; run `tj serve` to start with new secret"
502
+ return "could not auto-restart in --no-daemon mode; run `tj serve` manually"
503
+
504
+ daemon_msg = _install_daemon(config_path)
505
+ if daemon_msg:
506
+ return "restarted to pick up new ingest secret"
507
+ if stopped:
508
+ return "stopped stale server; please run `tj serve` manually"
509
+ return "restart attempted; verify with `tj status`"
510
+
511
+
512
+ def _codex_mcp_toml_block() -> str:
513
+ """Return the [mcp_servers.tj] TOML block for ~/.codex/config.toml."""
514
+ return (
515
+ "[mcp_servers.tj]\n"
516
+ "# Managed by tj — gives Codex access to OCW observability tools\n"
517
+ 'command = "tj"\n'
518
+ 'args = ["mcp"]\n'
519
+ )
520
+
521
+
522
+ def _codex_apply_block(
523
+ content: str,
524
+ section_pattern: str,
525
+ section_exists: bool,
526
+ block: str,
527
+ force: bool,
528
+ ) -> str:
529
+ """Replace or append a TOML section identified by *section_pattern* (regex).
530
+
531
+ If the section is absent, the block is appended.
532
+ If the section exists and *force* is True, it is replaced.
533
+ If the section exists and *force* is False, the content is unchanged.
534
+ """
535
+ import re as _re
536
+
537
+ if section_exists:
538
+ if not force:
539
+ return content
540
+ # Replace from the section header to the next section header or EOF.
541
+ stripped = _re.sub(
542
+ section_pattern + r".*?(?=\n\[|\Z)",
543
+ "",
544
+ content,
545
+ flags=_re.DOTALL,
546
+ ).rstrip()
547
+ return (stripped + "\n\n" + block) if stripped else block
548
+ # Section absent — append.
549
+ return (content.rstrip() + "\n\n" + block) if content.strip() else block
550
+
551
+
552
+ def _codex_strip_otel_sections(content: str) -> str:
553
+ """Remove all [otel] sections (including nested exporter/resource tables)."""
554
+ import re as _re
555
+
556
+ patterns = (
557
+ r"\[otel\].*?(?=\n\[|\Z)",
558
+ r"\[otel\.resource\].*?(?=\n\[|\Z)",
559
+ r"\[otel\.exporter\.\"otlp-http\"\].*?(?=\n\[|\Z)",
560
+ r"\[otel\.exporter\.\"otlp-http\"\.headers\].*?(?=\n\[|\Z)",
561
+ r"\[otel\.exporter\.\"otlp-grpc\"\].*?(?=\n\[|\Z)",
562
+ r"\[otel\.exporter\.\"otlp-grpc\"\.headers\].*?(?=\n\[|\Z)",
563
+ )
564
+ stripped = content
565
+ for pat in patterns:
566
+ stripped = _re.sub(pat, "", stripped, flags=_re.DOTALL)
567
+ return stripped.strip()
568
+
569
+
570
+ def _codex_otel_toml_block(port: int, secret: str, agent_id: str) -> str:
571
+ """Return the [otel] TOML block to append to ~/.codex/config.toml."""
572
+ endpoint = f"http://127.0.0.1:{port}/v1/logs"
573
+ # Note: Codex CLI hardcodes service.name="codex_exec" in the binary and
574
+ # ignores [otel.resource] entirely, so we don't write a resource block.
575
+ return (
576
+ f'[otel]\n'
577
+ f'# Managed by tj — do not edit this block manually\n'
578
+ f'log_user_prompt = false\n'
579
+ f'\n'
580
+ f'[otel.exporter."otlp-http"]\n'
581
+ f'endpoint = "{endpoint}"\n'
582
+ f'protocol = "json"\n'
583
+ f'\n'
584
+ f'[otel.exporter."otlp-http".headers]\n'
585
+ f'Authorization = "Bearer {secret}"\n'
586
+ )
587
+
588
+
589
+ def _print_codex_otel_block(port: int, secret: str, agent_id: str = "codex_exec") -> None:
590
+ # Plain print — the TOML block contains [otel] and other section headers
591
+ # that Rich would interpret as markup tags and strip from the output.
592
+ click.echo("Add this to ~/.codex/config.toml:")
593
+ click.echo("")
594
+ block = _codex_otel_toml_block(port, secret, agent_id)
595
+ for line in block.splitlines():
596
+ click.echo(f" {line}")
597
+ click.echo("")
598
+
599
+
600
+ def _sync_secret_to_codex(secret: str) -> bool:
601
+ """Update Authorization header in ~/.codex/config.toml if an [otel] section exists."""
602
+ import re as _re
603
+ codex_path = Path.home() / ".codex" / "config.toml"
604
+ if not codex_path.exists():
605
+ return False
606
+ content = codex_path.read_text()
607
+ if "Authorization" not in content:
608
+ return False
609
+ updated = _re.sub(
610
+ r'(Authorization\s*=\s*"Bearer\s+)[^"]+(")',
611
+ rf'\g<1>{secret}\g<2>',
612
+ content,
613
+ )
614
+ if updated == content:
615
+ return False
616
+ codex_path.write_text(updated)
617
+ return True
618
+
619
+
620
+ def _sync_secret_to_claude_code(secret: str) -> bool:
621
+ """Update OTLP Authorization header in ~/.claude/settings.json if present."""
622
+ settings_path = Path.home() / ".claude" / "settings.json"
623
+ if not settings_path.exists():
624
+ return False
625
+ try:
626
+ settings = json_mod.loads(settings_path.read_text())
627
+ except (json_mod.JSONDecodeError, OSError):
628
+ return False
629
+ env = settings.get("env", {})
630
+ if "Authorization=Bearer" not in env.get("OTEL_EXPORTER_OTLP_HEADERS", ""):
631
+ return False
632
+ env["OTEL_EXPORTER_OTLP_HEADERS"] = f"Authorization=Bearer {secret}"
633
+ settings["env"] = env
634
+ settings_path.write_text(json_mod.dumps(settings, indent=2) + "\n")
635
+ return True
636
+
637
+
638
+ def _derive_project_name() -> str:
639
+ """
640
+ Derive a meaningful project name for the agent ID.
641
+ Priority: git remote origin repo name > current folder name.
642
+ """
643
+ try:
644
+ result = subprocess.run(
645
+ ["git", "remote", "get-url", "origin"],
646
+ capture_output=True, text=True, timeout=3,
647
+ )
648
+ if result.returncode == 0:
649
+ url = result.stdout.strip()
650
+ # Extract repo name from URL — handles both https and ssh forms
651
+ # e.g. https://github.com/org/my-repo.git -> my-repo
652
+ # git@github.com:org/my-repo.git -> my-repo
653
+ name = url.rstrip("/").split("/")[-1].split(":")[-1]
654
+ name = name.removesuffix(".git").lower()
655
+ if name:
656
+ return name
657
+ except Exception:
658
+ pass
659
+ return Path.cwd().name.lower()
660
+
661
+
662
+ def _daemon_already_running() -> bool:
663
+ """Check if the OCW daemon is already installed and loaded."""
664
+ system = platform.system()
665
+ if system == "Darwin":
666
+ plist = Path.home() / "Library/LaunchAgents/com.tokenjam.serve.plist"
667
+ if not plist.exists():
668
+ return False
669
+ result = subprocess.run(
670
+ ["launchctl", "list", "com.tokenjam.serve"],
671
+ capture_output=True, text=True,
672
+ )
673
+ return result.returncode == 0
674
+ elif system == "Linux":
675
+ result = subprocess.run(
676
+ ["systemctl", "--user", "is-active", "tokenjam"],
677
+ capture_output=True, text=True,
678
+ )
679
+ return result.stdout.strip() == "active"
680
+ return False
681
+
682
+
683
+ def _install_daemon(config_path: str) -> str | None:
684
+ """Install background daemon. Returns success message or None."""
685
+ system = platform.system()
686
+ try:
687
+ if system == "Darwin":
688
+ return _install_launchd(config_path)
689
+ elif system == "Linux":
690
+ return _install_systemd(config_path)
691
+ else:
692
+ console.print(f"[yellow]Background daemon not supported on {system}. "
693
+ "Run `tj serve` manually.[/yellow]")
694
+ return None
695
+ except Exception as e:
696
+ console.print(f"[yellow]Daemon installation failed: {e}[/yellow]")
697
+ console.print("[dim]You can run `tj serve` manually instead.[/dim]")
698
+ return None
699
+
700
+
701
+ def _install_launchd(config_path: str) -> str | None:
702
+ tj_path = shutil.which("tj") or sys.executable.replace("/python", "/tj").replace("/python3", "/tj")
703
+ plist_path = Path.home() / "Library/LaunchAgents/com.tokenjam.serve.plist"
704
+ plist_path.parent.mkdir(parents=True, exist_ok=True)
705
+ plist_content = f"""\
706
+ <?xml version="1.0" encoding="UTF-8"?>
707
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
708
+ "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
709
+ <plist version="1.0">
710
+ <dict>
711
+ <key>Label</key>
712
+ <string>com.tokenjam.serve</string>
713
+ <key>ProgramArguments</key>
714
+ <array>
715
+ <string>{tj_path}</string>
716
+ <string>--config</string>
717
+ <string>{config_path}</string>
718
+ <string>serve</string>
719
+ </array>
720
+ <key>RunAtLoad</key>
721
+ <true/>
722
+ <key>KeepAlive</key>
723
+ <true/>
724
+ <key>StandardErrorPath</key>
725
+ <string>/tmp/tj-serve.err</string>
726
+ <key>StandardOutPath</key>
727
+ <string>/tmp/tj-serve.out</string>
728
+ </dict>
729
+ </plist>"""
730
+ plist_path.write_text(plist_content)
731
+ # Unload any existing registration before loading the updated plist.
732
+ # Ignore errors — the service may not be registered yet on first install.
733
+ subprocess.run(
734
+ ["launchctl", "unload", "-w", str(plist_path)],
735
+ capture_output=True, text=True,
736
+ )
737
+ # `-w` clears the Disabled=true flag that `tj stop` writes via
738
+ # `launchctl unload -w`. Without `-w` here, the daemon stays disabled
739
+ # in launchd's database and load is a no-op even though it returns 0.
740
+ result = subprocess.run(
741
+ ["launchctl", "load", "-w", str(plist_path)],
742
+ capture_output=True, text=True,
743
+ )
744
+ if result.returncode != 0:
745
+ console.print(f"[yellow]Daemon plist written to {plist_path} but "
746
+ f"launchctl load failed.[/yellow]")
747
+ console.print("[dim]Try loading manually:[/dim]")
748
+ console.print(f" launchctl load {plist_path}")
749
+ console.print("[dim]Or run the server directly:[/dim]")
750
+ console.print(" tj serve &")
751
+ return None
752
+ console.print(
753
+ " [dim]macOS will show a 'Background Items Added' notification "
754
+ "-- this is normal.[/dim]"
755
+ )
756
+ return f"Daemon installed at {plist_path}"
757
+
758
+
759
+ def _install_systemd(config_path: str) -> str | None:
760
+ tj_path = shutil.which("tj") or sys.executable.replace("/python", "/tj").replace("/python3", "/tj")
761
+ service_path = Path.home() / ".config/systemd/user/tokenjam.service"
762
+ service_path.parent.mkdir(parents=True, exist_ok=True)
763
+ service_content = f"""\
764
+ [Unit]
765
+ Description=TokenJam observability server
766
+ After=network.target
767
+
768
+ [Service]
769
+ ExecStart={tj_path} --config {config_path} serve
770
+ Restart=on-failure
771
+
772
+ [Install]
773
+ WantedBy=default.target"""
774
+ service_path.write_text(service_content)
775
+ subprocess.run(
776
+ ["systemctl", "--user", "enable", "--now", "tokenjam"],
777
+ check=True,
778
+ )
779
+ return f"Daemon installed at {service_path}"