@respan/cli 0.5.1 → 0.5.3

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.
@@ -492,6 +492,7 @@ def create_respan_spans(
492
492
  "span_name": root_span_name,
493
493
  "span_workflow_name": workflow_name,
494
494
  "model": model,
495
+ "provider_id": "",
495
496
  "span_path": "",
496
497
  "input": json.dumps(prompt_messages) if prompt_messages else "",
497
498
  "output": json.dumps(completion_message) if completion_message else "",
@@ -524,6 +525,7 @@ def create_respan_spans(
524
525
  "span_path": "claude_chat",
525
526
  "model": model,
526
527
  "provider_id": "anthropic",
528
+ "metadata": {},
527
529
  "input": json.dumps(prompt_messages) if prompt_messages else "",
528
530
  "output": json.dumps(completion_message) if completion_message else "",
529
531
  "prompt_messages": prompt_messages,
@@ -560,6 +562,8 @@ def create_respan_spans(
560
562
  "span_name": f"Thinking {thinking_num}",
561
563
  "span_workflow_name": workflow_name,
562
564
  "span_path": "thinking",
565
+ "provider_id": "",
566
+ "metadata": {},
563
567
  "input": "",
564
568
  "output": thinking_text,
565
569
  "timestamp": thinking_ts,
@@ -610,13 +614,14 @@ def create_respan_spans(
610
614
  "span_name": f"Tool: {td['name']}",
611
615
  "span_workflow_name": workflow_name,
612
616
  "span_path": f"tool_{td['name'].lower()}",
617
+ "provider_id": "",
618
+ "metadata": td.get("result_metadata") or {},
613
619
  "input": format_tool_input(td["name"], td["input"]),
614
620
  "output": format_tool_output(td["name"], td.get("output")),
615
621
  "timestamp": tool_ts,
616
622
  "start_time": tool_start,
617
623
  }
618
624
  if td.get("result_metadata"):
619
- tool_span["metadata"] = td["result_metadata"]
620
625
  duration_ms = td["result_metadata"].get("duration_ms")
621
626
  if duration_ms:
622
627
  tool_span["latency"] = duration_ms / 1000.0
@@ -937,17 +942,17 @@ def main():
937
942
  debug("Tracing disabled (TRACE_TO_RESPAN != true)")
938
943
  sys.exit(0)
939
944
 
940
- # Resolve API key: env var > ~/.config/respan/credentials.json
945
+ # Resolve API key: env var > ~/.respan/credentials.json
941
946
  api_key = os.getenv("RESPAN_API_KEY")
942
947
  base_url = os.getenv("RESPAN_BASE_URL", "https://api.respan.ai/api")
943
948
 
944
949
  if not api_key:
945
- creds_file = Path.home() / ".config" / "respan" / "credentials.json"
950
+ creds_file = Path.home() / ".respan" / "credentials.json"
946
951
  if creds_file.exists():
947
952
  try:
948
953
  creds = json.loads(creds_file.read_text(encoding="utf-8"))
949
954
  # Find the active profile's credential
950
- config_file = Path.home() / ".config" / "respan" / "config.json"
955
+ config_file = Path.home() / ".respan" / "config.json"
951
956
  profile = "default"
952
957
  if config_file.exists():
953
958
  cfg = json.loads(config_file.read_text(encoding="utf-8"))
@@ -956,6 +961,9 @@ def main():
956
961
  api_key = cred.get("apiKey") or cred.get("accessToken")
957
962
  if not base_url or base_url == "https://api.respan.ai/api":
958
963
  base_url = cred.get("baseUrl", base_url)
964
+ # Ensure base_url ends with /api (credentials store the host only)
965
+ if base_url and not base_url.rstrip("/").endswith("/api"):
966
+ base_url = base_url.rstrip("/") + "/api"
959
967
  if api_key:
960
968
  debug(f"Using API key from credentials.json (profile: {profile})")
961
969
  except (json.JSONDecodeError, IOError) as e:
@@ -7,7 +7,7 @@ class IntegrateClaudeCode extends BaseCommand {
7
7
  const { flags } = await this.parse(IntegrateClaudeCode);
8
8
  this.globalFlags = flags;
9
9
  try {
10
- // Verify the user is authenticated (key is read by hook from ~/.config/respan/)
10
+ // Verify the user is authenticated (key is read by hook from ~/.respan/)
11
11
  this.resolveApiKey();
12
12
  const baseUrl = flags['base-url'];
13
13
  const projectId = flags['project-id'];
@@ -93,7 +93,7 @@ class IntegrateClaudeCode extends BaseCommand {
93
93
  const projectRoot = findProjectRoot();
94
94
  const localSettingsPath = `${projectRoot}/.claude/settings.local.json`;
95
95
  const localSettings = readJsonFile(localSettingsPath);
96
- // settings.local.json: enable flag only (API key from ~/.config/respan/)
96
+ // settings.local.json: enable flag only (API key from ~/.respan/)
97
97
  const envBlock = {
98
98
  TRACE_TO_RESPAN: 'true',
99
99
  };
@@ -150,7 +150,7 @@ class IntegrateClaudeCode extends BaseCommand {
150
150
  this.log('Claude Code tracing enabled for this project.');
151
151
  }
152
152
  this.log('');
153
- this.log('Auth: ~/.config/respan/credentials.json (from `respan auth login`)');
153
+ this.log('Auth: ~/.respan/credentials.json (from `respan auth login`)');
154
154
  this.log('Config: .claude/respan.json (shareable, non-secret)');
155
155
  this.log('');
156
156
  this.log('Set properties via integrate flags or edit .claude/respan.json:');
@@ -19,5 +19,13 @@ export default class IntegrateCodexCli extends BaseCommand {
19
19
  verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
20
20
  };
21
21
  run(): Promise<void>;
22
+ /**
23
+ * Update or insert the `notify` line in a TOML config string.
24
+ *
25
+ * TOML bare keys must appear before the first [table] header.
26
+ * If a `notify` line already exists, replace it in-place.
27
+ * Otherwise, insert the notify line before the first [table] header.
28
+ */
29
+ private updateTomlNotify;
22
30
  private resolveApiKey;
23
31
  }
@@ -1,69 +1,161 @@
1
- import * as path from 'node:path';
1
+ import * as fs from 'node:fs';
2
+ import { execSync } from 'node:child_process';
2
3
  import { BaseCommand } from '../../lib/base-command.js';
3
- import { integrateFlags, readTextFile, writeTextFile, expandHome, parseAttrs, toOtelResourceAttrs, resolveScope, findProjectRoot, } from '../../lib/integrate.js';
4
+ import { integrateFlags, readTextFile, writeTextFile, writeJsonFile, readJsonFile, expandHome, parseAttrs, getCodexHookScript, resolveScope, findProjectRoot, } from '../../lib/integrate.js';
4
5
  class IntegrateCodexCli extends BaseCommand {
5
6
  async run() {
6
7
  const { flags } = await this.parse(IntegrateCodexCli);
7
8
  this.globalFlags = flags;
8
9
  try {
9
- const apiKey = this.resolveApiKey();
10
- const baseUrl = (flags['base-url']).replace(/\/+$/, '');
10
+ // Verify the user is authenticated (key is read by hook from ~/.respan/)
11
+ this.resolveApiKey();
11
12
  const projectId = flags['project-id'];
13
+ const customerId = flags['customer-id'];
14
+ const spanName = flags['span-name'];
15
+ const workflowName = flags['workflow-name'];
12
16
  const attrs = parseAttrs(flags.attrs);
13
17
  const dryRun = flags['dry-run'];
14
- const scope = resolveScope(flags, 'local');
15
- // Resolve target config file
16
- const configPath = scope === 'global'
17
- ? expandHome('~/.codex/config.toml')
18
- : path.join(findProjectRoot(), '.codex', 'config.toml');
19
- const existing = readTextFile(configPath);
20
- // Check for existing [otel] block
21
- if (/^\[otel\]/m.test(existing)) {
22
- this.warn(`[otel] section already exists in ${configPath} — skipping to avoid overwrite.`);
23
- this.warn('Remove or rename the existing [otel] section and re-run to update.');
24
- return;
18
+ // Codex CLI default: both global + local
19
+ const scope = resolveScope(flags, 'both');
20
+ const doGlobal = scope === 'global' || scope === 'both';
21
+ const doLocal = scope === 'local' || scope === 'both';
22
+ // ── Global: hook script + notify registration ──────────────────
23
+ if (doGlobal) {
24
+ // 1. Ensure Python 'requests' is available
25
+ if (!dryRun) {
26
+ try {
27
+ execSync('python3 -c "import requests"', { stdio: 'pipe' });
28
+ }
29
+ catch {
30
+ this.log('Installing Python requests package...');
31
+ try {
32
+ execSync('pip3 install requests', { stdio: 'pipe' });
33
+ this.log('Installed requests.');
34
+ }
35
+ catch {
36
+ this.warn('Could not install Python requests package automatically.\n' +
37
+ 'Install it manually: pip3 install requests');
38
+ }
39
+ }
40
+ }
41
+ else {
42
+ this.log('[dry-run] Would verify Python requests package');
43
+ }
44
+ // 2. Write hook script
45
+ const hookPath = expandHome('~/.respan/codex_hook.py');
46
+ if (dryRun) {
47
+ this.log(`[dry-run] Would write hook script to: ${hookPath}`);
48
+ }
49
+ else {
50
+ writeTextFile(hookPath, getCodexHookScript());
51
+ fs.chmodSync(hookPath, 0o755);
52
+ this.log(`Wrote hook script: ${hookPath}`);
53
+ }
54
+ // 3. Register notify hook in ~/.codex/config.toml
55
+ const configPath = expandHome('~/.codex/config.toml');
56
+ const existing = readTextFile(configPath);
57
+ const notifyValue = `notify = ["python3", "${hookPath}"]`;
58
+ const updated = this.updateTomlNotify(existing, notifyValue);
59
+ if (dryRun) {
60
+ this.log(`[dry-run] Would update: ${configPath}`);
61
+ this.log(updated);
62
+ }
63
+ else {
64
+ writeTextFile(configPath, updated);
65
+ this.log(`Updated config: ${configPath}`);
66
+ }
25
67
  }
26
- // Build resource attributes
27
- const resourceAttrs = {
28
- 'service.name': 'codex-cli',
29
- ...attrs,
30
- };
31
- if (projectId) {
32
- resourceAttrs['respan.project_id'] = projectId;
68
+ // ── Local: .codex/respan.json ──────────────────────────────────
69
+ if (doLocal) {
70
+ const projectRoot = findProjectRoot();
71
+ const respanConfigPath = `${projectRoot}/.codex/respan.json`;
72
+ const respanConfig = readJsonFile(respanConfigPath);
73
+ const newConfig = { ...respanConfig };
74
+ if (customerId) {
75
+ newConfig.customer_id = customerId;
76
+ }
77
+ if (spanName) {
78
+ newConfig.span_name = spanName;
79
+ }
80
+ if (workflowName) {
81
+ newConfig.workflow_name = workflowName;
82
+ }
83
+ if (projectId) {
84
+ newConfig.project_id = projectId;
85
+ }
86
+ // Custom attrs go as top-level keys (unknown keys = custom properties)
87
+ for (const [k, v] of Object.entries(attrs)) {
88
+ newConfig[k] = v;
89
+ }
90
+ if (Object.keys(newConfig).length > 0) {
91
+ if (dryRun) {
92
+ this.log(`[dry-run] Would write: ${respanConfigPath}`);
93
+ this.log(JSON.stringify(newConfig, null, 2));
94
+ }
95
+ else {
96
+ writeJsonFile(respanConfigPath, newConfig);
97
+ this.log(`Wrote Respan config: ${respanConfigPath}`);
98
+ }
99
+ }
33
100
  }
34
- const otelResStr = toOtelResourceAttrs(resourceAttrs);
35
- // Build TOML block
36
- const lines = [
37
- '',
38
- '# Respan observability (added by respan integrate codex-cli)',
39
- '[otel]',
40
- `endpoint = "${baseUrl}/v2/traces"`,
41
- `api_key = "${apiKey}"`,
42
- ];
43
- if (otelResStr) {
44
- lines.push(`resource_attributes = "${otelResStr}"`);
101
+ // ── Done ────────────────────────────────────────────────────────
102
+ this.log('');
103
+ if (doGlobal && doLocal) {
104
+ this.log('Codex CLI integration complete (global hook + project config).');
45
105
  }
46
- lines.push('');
47
- const block = lines.join('\n');
48
- const updated = existing.trimEnd() + '\n' + block;
49
- if (dryRun) {
50
- this.log(`[dry-run] Would append to: ${configPath}`);
51
- this.log(block);
106
+ else if (doGlobal) {
107
+ this.log('Codex CLI global hook installed.');
108
+ this.log('Run without --global in a project to configure tracing there.');
52
109
  }
53
110
  else {
54
- writeTextFile(configPath, updated);
55
- this.log(`Updated config: ${configPath}`);
111
+ this.log('Codex CLI tracing configured for this project.');
56
112
  }
57
113
  this.log('');
58
- this.log(`Codex CLI integration complete (${scope}).`);
114
+ this.log('Auth: ~/.respan/credentials.json (from `respan auth login`)');
115
+ this.log('Config: .codex/respan.json (shareable, non-secret)');
116
+ this.log('');
117
+ this.log('Set properties via integrate flags or edit .codex/respan.json:');
118
+ this.log(' respan integrate codex-cli --customer-id "frank" --span-name "my-app"');
119
+ this.log(' respan integrate codex-cli --attrs \'{"team":"platform","env":"staging"}\'');
59
120
  this.log('');
60
- this.log('Set dynamic attributes before a session:');
61
- this.log(' export OTEL_RESOURCE_ATTRIBUTES="env=prod,task_id=T-123"');
121
+ this.log('Override per-session with env vars:');
122
+ this.log(' export RESPAN_CUSTOMER_ID="your-name"');
123
+ this.log(" export RESPAN_METADATA='{\"task_id\":\"T-123\"}'");
124
+ this.log('');
125
+ this.log('Debug: CODEX_RESPAN_DEBUG=true → check ~/.codex/state/respan_hook.log');
62
126
  }
63
127
  catch (error) {
64
128
  this.handleError(error);
65
129
  }
66
130
  }
131
+ /**
132
+ * Update or insert the `notify` line in a TOML config string.
133
+ *
134
+ * TOML bare keys must appear before the first [table] header.
135
+ * If a `notify` line already exists, replace it in-place.
136
+ * Otherwise, insert the notify line before the first [table] header.
137
+ */
138
+ updateTomlNotify(existing, notifyValue) {
139
+ const lines = existing.split('\n');
140
+ // Check if notify line already exists
141
+ const notifyIdx = lines.findIndex((line) => /^\s*notify\s*=/.test(line));
142
+ if (notifyIdx >= 0) {
143
+ // Replace existing notify line
144
+ lines[notifyIdx] = notifyValue;
145
+ return lines.join('\n');
146
+ }
147
+ // Find the first [table] header to insert before it
148
+ const firstTableIdx = lines.findIndex((line) => /^\s*\[/.test(line));
149
+ if (firstTableIdx >= 0) {
150
+ // Insert notify + comment before the first table header
151
+ lines.splice(firstTableIdx, 0, '# Respan observability (added by respan integrate codex-cli)', notifyValue, '');
152
+ }
153
+ else {
154
+ // No tables at all — append to end
155
+ lines.push('', '# Respan observability (added by respan integrate codex-cli)', notifyValue);
156
+ }
157
+ return lines.join('\n');
158
+ }
67
159
  resolveApiKey() {
68
160
  const auth = this.getAuth();
69
161
  if (auth.apiKey)
@@ -77,17 +169,18 @@ class IntegrateCodexCli extends BaseCommand {
77
169
  }
78
170
  IntegrateCodexCli.description = `Integrate Respan with Codex CLI.
79
171
 
80
- Codex CLI has native OTel traces (prompts, tool approvals, results)
81
- so we append an [otel] block to its TOML config.
172
+ Installs a notify hook that reads session JSONL files and sends
173
+ them to Respan as structured spans (chat, tool, reasoning).
82
174
 
83
175
  Scope:
84
- --local Write to .codex/config.toml in project root (default)
85
- --global Write to ~/.codex/config.toml
86
- If [otel] already exists in the target file, warns and does not overwrite.`;
176
+ --global Install hook script + register notify in ~/.codex/config.toml
177
+ --local Write .codex/respan.json with customer_id, span_name, etc.
178
+ (default) Both: install hook globally + config for current project`;
87
179
  IntegrateCodexCli.examples = [
88
180
  'respan integrate codex-cli',
89
181
  'respan integrate codex-cli --global',
90
- 'respan integrate codex-cli --project-id my-project --attrs \'{"env":"prod"}\'',
182
+ 'respan integrate codex-cli --local --customer-id frank',
183
+ 'respan integrate codex-cli --attrs \'{"env":"prod"}\'',
91
184
  'respan integrate codex-cli --dry-run',
92
185
  ];
93
186
  IntegrateCodexCli.flags = {
@@ -1,6 +1,6 @@
1
1
  import * as path from 'node:path';
2
2
  import { BaseCommand } from '../../lib/base-command.js';
3
- import { integrateFlags, deepMerge, readJsonFile, writeJsonFile, expandHome, parseAttrs, toOtelResourceAttrs, resolveScope, findProjectRoot, } from '../../lib/integrate.js';
3
+ import { integrateFlags, deepMerge, readJsonFile, writeJsonFile, readTextFile, writeTextFile, expandHome, parseAttrs, toOtelResourceAttrs, resolveScope, findProjectRoot, } from '../../lib/integrate.js';
4
4
  class IntegrateGeminiCli extends BaseCommand {
5
5
  async run() {
6
6
  const { flags } = await this.parse(IntegrateGeminiCli);
@@ -25,27 +25,48 @@ class IntegrateGeminiCli extends BaseCommand {
25
25
  if (projectId) {
26
26
  resourceAttrs['respan.project_id'] = projectId;
27
27
  }
28
+ // settings.json — only telemetry fields Gemini CLI supports
28
29
  const patch = {
29
30
  telemetry: {
30
31
  enabled: true,
31
32
  otlpEndpoint: `${baseUrl}/v2/traces`,
32
- headers: {
33
- Authorization: `Bearer ${apiKey}`,
34
- },
33
+ otlpProtocol: 'http',
35
34
  },
36
35
  };
36
+ const merged = deepMerge(existing, patch);
37
+ // .env file — OTel SDK picks up headers & resource attrs from env
38
+ const envDir = scope === 'global'
39
+ ? expandHome('~/.gemini')
40
+ : path.join(findProjectRoot(), '.gemini');
41
+ const envPath = path.join(envDir, '.env');
42
+ const envLines = [];
43
+ envLines.push(`OTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer ${apiKey}`);
37
44
  const otelResStr = toOtelResourceAttrs(resourceAttrs);
38
45
  if (otelResStr) {
39
- patch.env = { OTEL_RESOURCE_ATTRIBUTES: otelResStr };
46
+ envLines.push(`OTEL_RESOURCE_ATTRIBUTES=${otelResStr}`);
40
47
  }
41
- const merged = deepMerge(existing, patch);
48
+ // Merge with existing .env (replace our keys, keep the rest)
49
+ const existingEnv = readTextFile(envPath);
50
+ const envKeysToSet = new Set(envLines.map(l => l.split('=')[0]));
51
+ const keptLines = existingEnv
52
+ .split('\n')
53
+ .filter(line => {
54
+ const key = line.split('=')[0];
55
+ return !envKeysToSet.has(key);
56
+ });
57
+ const finalEnv = [...keptLines.filter(l => l.trim() !== ''), ...envLines].join('\n') + '\n';
42
58
  if (dryRun) {
43
59
  this.log(`[dry-run] Would update: ${settingsPath}`);
44
60
  this.log(JSON.stringify(merged, null, 2));
61
+ this.log('');
62
+ this.log(`[dry-run] Would update: ${envPath}`);
63
+ this.log(finalEnv);
45
64
  }
46
65
  else {
47
66
  writeJsonFile(settingsPath, merged);
48
67
  this.log(`Updated settings: ${settingsPath}`);
68
+ writeTextFile(envPath, finalEnv);
69
+ this.log(`Updated env: ${envPath}`);
49
70
  }
50
71
  this.log('');
51
72
  this.log(`Gemini CLI integration complete (${scope}).`);
@@ -1,7 +1,7 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import * as os from 'node:os';
4
- const CONFIG_DIR = path.join(os.homedir(), '.config', 'respan');
4
+ const CONFIG_DIR = path.join(os.homedir(), '.respan');
5
5
  const CREDENTIALS_FILE = path.join(CONFIG_DIR, 'credentials.json');
6
6
  const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
7
7
  function ensureConfigDir() {
@@ -35,6 +35,10 @@ export declare function findProjectRoot(): string;
35
35
  * version — upgrading the CLI and re-running integrate updates the hook.
36
36
  */
37
37
  export declare function getHookScript(): string;
38
+ /**
39
+ * Return the bundled Codex CLI hook script contents.
40
+ */
41
+ export declare function getCodexHookScript(): string;
38
42
  /**
39
43
  * Deep merge source into target.
40
44
  * Objects are recursively merged; arrays and primitives from source overwrite.
@@ -87,6 +87,14 @@ export function getHookScript() {
87
87
  const hookPath = path.join(dir, '..', 'assets', 'hook.py');
88
88
  return fs.readFileSync(hookPath, 'utf-8');
89
89
  }
90
+ /**
91
+ * Return the bundled Codex CLI hook script contents.
92
+ */
93
+ export function getCodexHookScript() {
94
+ const dir = path.dirname(fileURLToPath(import.meta.url));
95
+ const hookPath = path.join(dir, '..', 'assets', 'codex_hook.py');
96
+ return fs.readFileSync(hookPath, 'utf-8');
97
+ }
90
98
  // ── Utilities ─────────────────────────────────────────────────────────────
91
99
  /**
92
100
  * Deep merge source into target.