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.
- tokenjam/__init__.py +1 -0
- tokenjam/api/__init__.py +0 -0
- tokenjam/api/app.py +104 -0
- tokenjam/api/deps.py +18 -0
- tokenjam/api/middleware.py +28 -0
- tokenjam/api/routes/__init__.py +0 -0
- tokenjam/api/routes/agents.py +33 -0
- tokenjam/api/routes/alerts.py +77 -0
- tokenjam/api/routes/budget.py +96 -0
- tokenjam/api/routes/cost.py +43 -0
- tokenjam/api/routes/drift.py +63 -0
- tokenjam/api/routes/logs.py +511 -0
- tokenjam/api/routes/metrics.py +81 -0
- tokenjam/api/routes/otlp.py +63 -0
- tokenjam/api/routes/spans.py +202 -0
- tokenjam/api/routes/status.py +84 -0
- tokenjam/api/routes/tools.py +22 -0
- tokenjam/api/routes/traces.py +92 -0
- tokenjam/cli/__init__.py +0 -0
- tokenjam/cli/cmd_alerts.py +94 -0
- tokenjam/cli/cmd_budget.py +119 -0
- tokenjam/cli/cmd_cost.py +90 -0
- tokenjam/cli/cmd_demo.py +82 -0
- tokenjam/cli/cmd_doctor.py +173 -0
- tokenjam/cli/cmd_drift.py +238 -0
- tokenjam/cli/cmd_export.py +200 -0
- tokenjam/cli/cmd_mcp.py +78 -0
- tokenjam/cli/cmd_onboard.py +779 -0
- tokenjam/cli/cmd_serve.py +85 -0
- tokenjam/cli/cmd_status.py +153 -0
- tokenjam/cli/cmd_stop.py +87 -0
- tokenjam/cli/cmd_tools.py +45 -0
- tokenjam/cli/cmd_traces.py +161 -0
- tokenjam/cli/cmd_uninstall.py +159 -0
- tokenjam/cli/main.py +110 -0
- tokenjam/core/__init__.py +0 -0
- tokenjam/core/alerts.py +619 -0
- tokenjam/core/api_backend.py +235 -0
- tokenjam/core/config.py +360 -0
- tokenjam/core/cost.py +102 -0
- tokenjam/core/db.py +718 -0
- tokenjam/core/drift.py +256 -0
- tokenjam/core/ingest.py +265 -0
- tokenjam/core/models.py +225 -0
- tokenjam/core/pricing.py +54 -0
- tokenjam/core/retention.py +21 -0
- tokenjam/core/schema_validator.py +156 -0
- tokenjam/demo/__init__.py +0 -0
- tokenjam/demo/env.py +96 -0
- tokenjam/mcp/__init__.py +0 -0
- tokenjam/mcp/server.py +1067 -0
- tokenjam/otel/__init__.py +0 -0
- tokenjam/otel/exporters.py +26 -0
- tokenjam/otel/provider.py +207 -0
- tokenjam/otel/semconv.py +144 -0
- tokenjam/pricing/models.toml +70 -0
- tokenjam/py.typed +0 -0
- tokenjam/sdk/__init__.py +21 -0
- tokenjam/sdk/agent.py +206 -0
- tokenjam/sdk/bootstrap.py +120 -0
- tokenjam/sdk/http_exporter.py +109 -0
- tokenjam/sdk/integrations/__init__.py +0 -0
- tokenjam/sdk/integrations/anthropic.py +200 -0
- tokenjam/sdk/integrations/autogen.py +97 -0
- tokenjam/sdk/integrations/base.py +27 -0
- tokenjam/sdk/integrations/bedrock.py +103 -0
- tokenjam/sdk/integrations/crewai.py +96 -0
- tokenjam/sdk/integrations/gemini.py +131 -0
- tokenjam/sdk/integrations/langchain.py +156 -0
- tokenjam/sdk/integrations/langgraph.py +101 -0
- tokenjam/sdk/integrations/litellm.py +323 -0
- tokenjam/sdk/integrations/llamaindex.py +52 -0
- tokenjam/sdk/integrations/nemoclaw.py +139 -0
- tokenjam/sdk/integrations/openai.py +159 -0
- tokenjam/sdk/integrations/openai_agents_sdk.py +47 -0
- tokenjam/sdk/transport.py +98 -0
- tokenjam/ui/index.html +1213 -0
- tokenjam/utils/__init__.py +0 -0
- tokenjam/utils/formatting.py +43 -0
- tokenjam/utils/ids.py +15 -0
- tokenjam/utils/time_parse.py +54 -0
- tokenjam-0.2.0.dist-info/METADATA +622 -0
- tokenjam-0.2.0.dist-info/RECORD +86 -0
- tokenjam-0.2.0.dist-info/WHEEL +4 -0
- tokenjam-0.2.0.dist-info/entry_points.txt +2 -0
- 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}"
|