@respan/cli 0.3.3 → 0.4.0
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.
- package/dist/assets/assets/hook.py +919 -0
- package/dist/assets/hook.py +919 -0
- package/dist/commands/integrate/claude-code.d.ts +20 -0
- package/dist/commands/integrate/claude-code.js +167 -0
- package/dist/commands/integrate/codex-cli.d.ts +20 -0
- package/dist/commands/integrate/codex-cli.js +97 -0
- package/dist/commands/integrate/gemini-cli.d.ts +20 -0
- package/dist/commands/integrate/gemini-cli.js +90 -0
- package/dist/commands/integrate/opencode.d.ts +20 -0
- package/dist/commands/integrate/opencode.js +100 -0
- package/dist/lib/integrate.d.ts +51 -0
- package/dist/lib/integrate.js +153 -0
- package/oclif.manifest.json +619 -178
- package/package.json +2 -2
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { BaseCommand } from '../../lib/base-command.js';
|
|
2
|
+
export default class IntegrateClaudeCode extends BaseCommand {
|
|
3
|
+
static description: string;
|
|
4
|
+
static examples: string[];
|
|
5
|
+
static flags: {
|
|
6
|
+
local: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
7
|
+
global: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
8
|
+
'project-id': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
9
|
+
'base-url': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
|
+
attrs: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
|
+
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
12
|
+
'api-key': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
13
|
+
profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
14
|
+
json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
15
|
+
csv: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
16
|
+
verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
17
|
+
};
|
|
18
|
+
run(): Promise<void>;
|
|
19
|
+
private resolveApiKey;
|
|
20
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import { execSync } from 'node:child_process';
|
|
3
|
+
import { BaseCommand } from '../../lib/base-command.js';
|
|
4
|
+
import { integrateFlags, deepMerge, readJsonFile, writeJsonFile, writeTextFile, expandHome, parseAttrs, getHookScript, resolveScope, findProjectRoot, DEFAULT_BASE_URL, } from '../../lib/integrate.js';
|
|
5
|
+
class IntegrateClaudeCode extends BaseCommand {
|
|
6
|
+
async run() {
|
|
7
|
+
const { flags } = await this.parse(IntegrateClaudeCode);
|
|
8
|
+
this.globalFlags = flags;
|
|
9
|
+
try {
|
|
10
|
+
const apiKey = this.resolveApiKey();
|
|
11
|
+
const baseUrl = flags['base-url'];
|
|
12
|
+
const projectId = flags['project-id'];
|
|
13
|
+
const attrs = parseAttrs(flags.attrs);
|
|
14
|
+
const dryRun = flags['dry-run'];
|
|
15
|
+
// Claude Code default: both global + local
|
|
16
|
+
const scope = resolveScope(flags, 'both');
|
|
17
|
+
const doGlobal = scope === 'global' || scope === 'both';
|
|
18
|
+
const doLocal = scope === 'local' || scope === 'both';
|
|
19
|
+
// ── Global: hook script + registration ────────────────────────
|
|
20
|
+
if (doGlobal) {
|
|
21
|
+
// 1. Ensure Python 'requests' is available
|
|
22
|
+
if (!dryRun) {
|
|
23
|
+
try {
|
|
24
|
+
execSync('python3 -c "import requests"', { stdio: 'pipe' });
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
this.log('Installing Python requests package...');
|
|
28
|
+
try {
|
|
29
|
+
execSync('pip3 install requests', { stdio: 'pipe' });
|
|
30
|
+
this.log('Installed requests.');
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
this.warn('Could not install Python requests package automatically.\n' +
|
|
34
|
+
'Install it manually: pip3 install requests');
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
this.log('[dry-run] Would verify Python requests package');
|
|
40
|
+
}
|
|
41
|
+
// 2. Write hook script
|
|
42
|
+
const hookPath = expandHome('~/.respan/hook.py');
|
|
43
|
+
if (dryRun) {
|
|
44
|
+
this.log(`[dry-run] Would write hook script to: ${hookPath}`);
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
writeTextFile(hookPath, getHookScript());
|
|
48
|
+
fs.chmodSync(hookPath, 0o755);
|
|
49
|
+
this.log(`Wrote hook script: ${hookPath}`);
|
|
50
|
+
}
|
|
51
|
+
// 3. Register Stop hook in global settings (no credentials here)
|
|
52
|
+
const globalSettingsPath = expandHome('~/.claude/settings.json');
|
|
53
|
+
const globalSettings = readJsonFile(globalSettingsPath);
|
|
54
|
+
const hookEntry = {
|
|
55
|
+
matcher: '',
|
|
56
|
+
hooks: [{ type: 'command', command: `python3 ${hookPath}` }],
|
|
57
|
+
};
|
|
58
|
+
const hooksSection = (globalSettings.hooks || {});
|
|
59
|
+
const stopHooks = Array.isArray(hooksSection.Stop)
|
|
60
|
+
? [...hooksSection.Stop]
|
|
61
|
+
: [];
|
|
62
|
+
const existingIdx = stopHooks.findIndex((entry) => {
|
|
63
|
+
const inner = Array.isArray(entry.hooks)
|
|
64
|
+
? entry.hooks
|
|
65
|
+
: [];
|
|
66
|
+
return inner.some((h) => typeof h.command === 'string' &&
|
|
67
|
+
(h.command.includes('respan') || h.command.includes('hook.py')));
|
|
68
|
+
});
|
|
69
|
+
if (existingIdx >= 0) {
|
|
70
|
+
stopHooks[existingIdx] = hookEntry;
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
stopHooks.push(hookEntry);
|
|
74
|
+
}
|
|
75
|
+
const mergedGlobal = deepMerge(globalSettings, {
|
|
76
|
+
hooks: { ...hooksSection, Stop: stopHooks },
|
|
77
|
+
});
|
|
78
|
+
if (dryRun) {
|
|
79
|
+
this.log(`[dry-run] Would update: ${globalSettingsPath}`);
|
|
80
|
+
this.log(JSON.stringify(mergedGlobal, null, 2));
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
writeJsonFile(globalSettingsPath, mergedGlobal);
|
|
84
|
+
this.log(`Updated global settings: ${globalSettingsPath}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// ── Local: credentials + enable in .claude/settings.local.json ─
|
|
88
|
+
if (doLocal) {
|
|
89
|
+
const projectRoot = findProjectRoot();
|
|
90
|
+
const localSettingsPath = `${projectRoot}/.claude/settings.local.json`;
|
|
91
|
+
const localSettings = readJsonFile(localSettingsPath);
|
|
92
|
+
const envBlock = {
|
|
93
|
+
TRACE_TO_RESPAN: 'true',
|
|
94
|
+
RESPAN_API_KEY: apiKey,
|
|
95
|
+
};
|
|
96
|
+
if (baseUrl !== DEFAULT_BASE_URL) {
|
|
97
|
+
envBlock.RESPAN_BASE_URL = baseUrl;
|
|
98
|
+
}
|
|
99
|
+
const metadata = { ...attrs };
|
|
100
|
+
if (projectId) {
|
|
101
|
+
metadata.project_id = projectId;
|
|
102
|
+
}
|
|
103
|
+
if (Object.keys(metadata).length > 0) {
|
|
104
|
+
envBlock.RESPAN_METADATA = JSON.stringify(metadata);
|
|
105
|
+
}
|
|
106
|
+
const mergedLocal = deepMerge(localSettings, { env: envBlock });
|
|
107
|
+
if (dryRun) {
|
|
108
|
+
this.log(`[dry-run] Would update: ${localSettingsPath}`);
|
|
109
|
+
this.log(JSON.stringify(mergedLocal, null, 2));
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
writeJsonFile(localSettingsPath, mergedLocal);
|
|
113
|
+
this.log(`Updated project settings: ${localSettingsPath}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// ── Done ──────────────────────────────────────────────────────
|
|
117
|
+
this.log('');
|
|
118
|
+
if (doGlobal && doLocal) {
|
|
119
|
+
this.log('Claude Code integration complete (global hook + project config).');
|
|
120
|
+
}
|
|
121
|
+
else if (doGlobal) {
|
|
122
|
+
this.log('Claude Code global hook installed.');
|
|
123
|
+
this.log('Run without --global in a project to enable tracing there.');
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
this.log('Claude Code tracing enabled for this project.');
|
|
127
|
+
}
|
|
128
|
+
this.log('');
|
|
129
|
+
this.log('Set dynamic metadata before a session:');
|
|
130
|
+
this.log(" export RESPAN_METADATA='{\"task_id\":\"T-123\"}'");
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
this.handleError(error);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
resolveApiKey() {
|
|
137
|
+
const auth = this.getAuth();
|
|
138
|
+
if (auth.apiKey)
|
|
139
|
+
return auth.apiKey;
|
|
140
|
+
if (auth.accessToken) {
|
|
141
|
+
this.warn('Using access token (JWT) which may expire. Consider using an API key instead.');
|
|
142
|
+
return auth.accessToken;
|
|
143
|
+
}
|
|
144
|
+
this.error('No API key found. Pass --api-key, set RESPAN_API_KEY, or run: respan auth login');
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
IntegrateClaudeCode.description = `Integrate Respan with Claude Code.
|
|
148
|
+
|
|
149
|
+
Installs a Stop hook that reads conversation transcripts and sends
|
|
150
|
+
them to Respan as structured spans (chat, tool, thinking).
|
|
151
|
+
|
|
152
|
+
Scope:
|
|
153
|
+
--global Install hook script + register in ~/.claude/settings.json
|
|
154
|
+
--local Write credentials + enable flag to .claude/settings.local.json
|
|
155
|
+
(default) Both: install hook globally + enable for current project`;
|
|
156
|
+
IntegrateClaudeCode.examples = [
|
|
157
|
+
'respan integrate claude-code',
|
|
158
|
+
'respan integrate claude-code --global',
|
|
159
|
+
'respan integrate claude-code --local --project-id my-project',
|
|
160
|
+
'respan integrate claude-code --attrs \'{"env":"prod"}\'',
|
|
161
|
+
'respan integrate claude-code --dry-run',
|
|
162
|
+
];
|
|
163
|
+
IntegrateClaudeCode.flags = {
|
|
164
|
+
...BaseCommand.baseFlags,
|
|
165
|
+
...integrateFlags,
|
|
166
|
+
};
|
|
167
|
+
export default IntegrateClaudeCode;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { BaseCommand } from '../../lib/base-command.js';
|
|
2
|
+
export default class IntegrateCodexCli extends BaseCommand {
|
|
3
|
+
static description: string;
|
|
4
|
+
static examples: string[];
|
|
5
|
+
static flags: {
|
|
6
|
+
local: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
7
|
+
global: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
8
|
+
'project-id': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
9
|
+
'base-url': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
|
+
attrs: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
|
+
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
12
|
+
'api-key': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
13
|
+
profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
14
|
+
json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
15
|
+
csv: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
16
|
+
verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
17
|
+
};
|
|
18
|
+
run(): Promise<void>;
|
|
19
|
+
private resolveApiKey;
|
|
20
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import * as path from 'node:path';
|
|
2
|
+
import { BaseCommand } from '../../lib/base-command.js';
|
|
3
|
+
import { integrateFlags, readTextFile, writeTextFile, expandHome, parseAttrs, toOtelResourceAttrs, resolveScope, findProjectRoot, } from '../../lib/integrate.js';
|
|
4
|
+
class IntegrateCodexCli extends BaseCommand {
|
|
5
|
+
async run() {
|
|
6
|
+
const { flags } = await this.parse(IntegrateCodexCli);
|
|
7
|
+
this.globalFlags = flags;
|
|
8
|
+
try {
|
|
9
|
+
const apiKey = this.resolveApiKey();
|
|
10
|
+
const baseUrl = (flags['base-url']).replace(/\/+$/, '');
|
|
11
|
+
const projectId = flags['project-id'];
|
|
12
|
+
const attrs = parseAttrs(flags.attrs);
|
|
13
|
+
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;
|
|
25
|
+
}
|
|
26
|
+
// Build resource attributes
|
|
27
|
+
const resourceAttrs = {
|
|
28
|
+
'service.name': 'codex-cli',
|
|
29
|
+
...attrs,
|
|
30
|
+
};
|
|
31
|
+
if (projectId) {
|
|
32
|
+
resourceAttrs['respan.project_id'] = projectId;
|
|
33
|
+
}
|
|
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}"`);
|
|
45
|
+
}
|
|
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);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
writeTextFile(configPath, updated);
|
|
55
|
+
this.log(`Updated config: ${configPath}`);
|
|
56
|
+
}
|
|
57
|
+
this.log('');
|
|
58
|
+
this.log(`Codex CLI integration complete (${scope}).`);
|
|
59
|
+
this.log('');
|
|
60
|
+
this.log('Set dynamic attributes before a session:');
|
|
61
|
+
this.log(' export OTEL_RESOURCE_ATTRIBUTES="env=prod,task_id=T-123"');
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
this.handleError(error);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
resolveApiKey() {
|
|
68
|
+
const auth = this.getAuth();
|
|
69
|
+
if (auth.apiKey)
|
|
70
|
+
return auth.apiKey;
|
|
71
|
+
if (auth.accessToken) {
|
|
72
|
+
this.warn('Using access token (JWT) which may expire. Consider using an API key instead.');
|
|
73
|
+
return auth.accessToken;
|
|
74
|
+
}
|
|
75
|
+
this.error('No API key found. Pass --api-key, set RESPAN_API_KEY, or run: respan auth login');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
IntegrateCodexCli.description = `Integrate Respan with Codex CLI.
|
|
79
|
+
|
|
80
|
+
Codex CLI has native OTel traces (prompts, tool approvals, results)
|
|
81
|
+
so we append an [otel] block to its TOML config.
|
|
82
|
+
|
|
83
|
+
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.`;
|
|
87
|
+
IntegrateCodexCli.examples = [
|
|
88
|
+
'respan integrate codex-cli',
|
|
89
|
+
'respan integrate codex-cli --global',
|
|
90
|
+
'respan integrate codex-cli --project-id my-project --attrs \'{"env":"prod"}\'',
|
|
91
|
+
'respan integrate codex-cli --dry-run',
|
|
92
|
+
];
|
|
93
|
+
IntegrateCodexCli.flags = {
|
|
94
|
+
...BaseCommand.baseFlags,
|
|
95
|
+
...integrateFlags,
|
|
96
|
+
};
|
|
97
|
+
export default IntegrateCodexCli;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { BaseCommand } from '../../lib/base-command.js';
|
|
2
|
+
export default class IntegrateGeminiCli extends BaseCommand {
|
|
3
|
+
static description: string;
|
|
4
|
+
static examples: string[];
|
|
5
|
+
static flags: {
|
|
6
|
+
local: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
7
|
+
global: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
8
|
+
'project-id': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
9
|
+
'base-url': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
|
+
attrs: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
|
+
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
12
|
+
'api-key': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
13
|
+
profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
14
|
+
json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
15
|
+
csv: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
16
|
+
verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
17
|
+
};
|
|
18
|
+
run(): Promise<void>;
|
|
19
|
+
private resolveApiKey;
|
|
20
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import * as path from 'node:path';
|
|
2
|
+
import { BaseCommand } from '../../lib/base-command.js';
|
|
3
|
+
import { integrateFlags, deepMerge, readJsonFile, writeJsonFile, expandHome, parseAttrs, toOtelResourceAttrs, resolveScope, findProjectRoot, } from '../../lib/integrate.js';
|
|
4
|
+
class IntegrateGeminiCli extends BaseCommand {
|
|
5
|
+
async run() {
|
|
6
|
+
const { flags } = await this.parse(IntegrateGeminiCli);
|
|
7
|
+
this.globalFlags = flags;
|
|
8
|
+
try {
|
|
9
|
+
const apiKey = this.resolveApiKey();
|
|
10
|
+
const baseUrl = (flags['base-url']).replace(/\/+$/, '');
|
|
11
|
+
const projectId = flags['project-id'];
|
|
12
|
+
const attrs = parseAttrs(flags.attrs);
|
|
13
|
+
const dryRun = flags['dry-run'];
|
|
14
|
+
const scope = resolveScope(flags, 'local');
|
|
15
|
+
// Resolve target settings file
|
|
16
|
+
const settingsPath = scope === 'global'
|
|
17
|
+
? expandHome('~/.gemini/settings.json')
|
|
18
|
+
: path.join(findProjectRoot(), '.gemini', 'settings.json');
|
|
19
|
+
const existing = readJsonFile(settingsPath);
|
|
20
|
+
// Build resource attributes
|
|
21
|
+
const resourceAttrs = {
|
|
22
|
+
'service.name': 'gemini-cli',
|
|
23
|
+
...attrs,
|
|
24
|
+
};
|
|
25
|
+
if (projectId) {
|
|
26
|
+
resourceAttrs['respan.project_id'] = projectId;
|
|
27
|
+
}
|
|
28
|
+
const patch = {
|
|
29
|
+
telemetry: {
|
|
30
|
+
enabled: true,
|
|
31
|
+
otlpEndpoint: `${baseUrl}/v2/traces`,
|
|
32
|
+
headers: {
|
|
33
|
+
Authorization: `Bearer ${apiKey}`,
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
const otelResStr = toOtelResourceAttrs(resourceAttrs);
|
|
38
|
+
if (otelResStr) {
|
|
39
|
+
patch.env = { OTEL_RESOURCE_ATTRIBUTES: otelResStr };
|
|
40
|
+
}
|
|
41
|
+
const merged = deepMerge(existing, patch);
|
|
42
|
+
if (dryRun) {
|
|
43
|
+
this.log(`[dry-run] Would update: ${settingsPath}`);
|
|
44
|
+
this.log(JSON.stringify(merged, null, 2));
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
writeJsonFile(settingsPath, merged);
|
|
48
|
+
this.log(`Updated settings: ${settingsPath}`);
|
|
49
|
+
}
|
|
50
|
+
this.log('');
|
|
51
|
+
this.log(`Gemini CLI integration complete (${scope}).`);
|
|
52
|
+
this.log('');
|
|
53
|
+
this.log('Set dynamic attributes before a session:');
|
|
54
|
+
this.log(' export OTEL_RESOURCE_ATTRIBUTES="env=prod,task_id=T-123"');
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
this.handleError(error);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
resolveApiKey() {
|
|
61
|
+
const auth = this.getAuth();
|
|
62
|
+
if (auth.apiKey)
|
|
63
|
+
return auth.apiKey;
|
|
64
|
+
if (auth.accessToken) {
|
|
65
|
+
this.warn('Using access token (JWT) which may expire. Consider using an API key instead.');
|
|
66
|
+
return auth.accessToken;
|
|
67
|
+
}
|
|
68
|
+
this.error('No API key found. Pass --api-key, set RESPAN_API_KEY, or run: respan auth login');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
IntegrateGeminiCli.description = `Integrate Respan with Gemini CLI.
|
|
72
|
+
|
|
73
|
+
Gemini CLI has native OTel traces (tool_call, llm_call, agent_call)
|
|
74
|
+
so we configure it to send telemetry directly to the Respan OTLP
|
|
75
|
+
endpoint.
|
|
76
|
+
|
|
77
|
+
Scope:
|
|
78
|
+
--local Write to .gemini/settings.json in project root (default)
|
|
79
|
+
--global Write to ~/.gemini/settings.json`;
|
|
80
|
+
IntegrateGeminiCli.examples = [
|
|
81
|
+
'respan integrate gemini-cli',
|
|
82
|
+
'respan integrate gemini-cli --global',
|
|
83
|
+
'respan integrate gemini-cli --project-id my-project --attrs \'{"env":"prod"}\'',
|
|
84
|
+
'respan integrate gemini-cli --dry-run',
|
|
85
|
+
];
|
|
86
|
+
IntegrateGeminiCli.flags = {
|
|
87
|
+
...BaseCommand.baseFlags,
|
|
88
|
+
...integrateFlags,
|
|
89
|
+
};
|
|
90
|
+
export default IntegrateGeminiCli;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { BaseCommand } from '../../lib/base-command.js';
|
|
2
|
+
export default class IntegrateOpencode extends BaseCommand {
|
|
3
|
+
static description: string;
|
|
4
|
+
static examples: string[];
|
|
5
|
+
static flags: {
|
|
6
|
+
local: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
7
|
+
global: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
8
|
+
'project-id': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
9
|
+
'base-url': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
|
+
attrs: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
|
+
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
12
|
+
'api-key': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
13
|
+
profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
14
|
+
json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
15
|
+
csv: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
16
|
+
verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
17
|
+
};
|
|
18
|
+
run(): Promise<void>;
|
|
19
|
+
private resolveApiKey;
|
|
20
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import * as path from 'node:path';
|
|
2
|
+
import { execSync } from 'node:child_process';
|
|
3
|
+
import { BaseCommand } from '../../lib/base-command.js';
|
|
4
|
+
import { integrateFlags, writeJsonFile, expandHome, parseAttrs, resolveScope, findProjectRoot, } from '../../lib/integrate.js';
|
|
5
|
+
class IntegrateOpencode extends BaseCommand {
|
|
6
|
+
async run() {
|
|
7
|
+
const { flags } = await this.parse(IntegrateOpencode);
|
|
8
|
+
this.globalFlags = flags;
|
|
9
|
+
try {
|
|
10
|
+
const apiKey = this.resolveApiKey();
|
|
11
|
+
const baseUrl = (flags['base-url']).replace(/\/+$/, '');
|
|
12
|
+
const projectId = flags['project-id'];
|
|
13
|
+
const attrs = parseAttrs(flags.attrs);
|
|
14
|
+
const dryRun = flags['dry-run'];
|
|
15
|
+
const scope = resolveScope(flags, 'local');
|
|
16
|
+
// ── 1. Install opencode-otel (always global) ─────────────────
|
|
17
|
+
if (dryRun) {
|
|
18
|
+
this.log('[dry-run] Would run: npm install -g opencode-otel');
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
this.log('Installing opencode-otel...');
|
|
22
|
+
try {
|
|
23
|
+
execSync('npm install -g opencode-otel', { stdio: 'pipe' });
|
|
24
|
+
this.log('Installed opencode-otel globally.');
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
28
|
+
this.warn(`Failed to install opencode-otel: ${msg}`);
|
|
29
|
+
this.warn('You may need to install it manually: npm install -g opencode-otel');
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// ── 2. Write plugin config ────────────────────────────────────
|
|
33
|
+
const pluginPath = scope === 'global'
|
|
34
|
+
? expandHome('~/.config/opencode/plugins/otel.json')
|
|
35
|
+
: path.join(findProjectRoot(), '.opencode', 'plugins', 'otel.json');
|
|
36
|
+
const resourceAttrs = {
|
|
37
|
+
'service.name': 'opencode',
|
|
38
|
+
...attrs,
|
|
39
|
+
};
|
|
40
|
+
if (projectId) {
|
|
41
|
+
resourceAttrs['respan.project_id'] = projectId;
|
|
42
|
+
}
|
|
43
|
+
const pluginConfig = {
|
|
44
|
+
tracesEndpoint: `${baseUrl}/v2/traces`,
|
|
45
|
+
logsEndpoint: `${baseUrl}/v2/logs`,
|
|
46
|
+
headers: {
|
|
47
|
+
Authorization: `Bearer ${apiKey}`,
|
|
48
|
+
},
|
|
49
|
+
resourceAttributes: resourceAttrs,
|
|
50
|
+
};
|
|
51
|
+
if (dryRun) {
|
|
52
|
+
this.log(`[dry-run] Would write: ${pluginPath}`);
|
|
53
|
+
this.log(JSON.stringify(pluginConfig, null, 2));
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
writeJsonFile(pluginPath, pluginConfig);
|
|
57
|
+
this.log(`Wrote plugin config: ${pluginPath}`);
|
|
58
|
+
}
|
|
59
|
+
// ── Done ──────────────────────────────────────────────────────
|
|
60
|
+
this.log('');
|
|
61
|
+
this.log(`OpenCode integration complete (${scope}).`);
|
|
62
|
+
this.log('');
|
|
63
|
+
this.log('Set dynamic attributes before a session:');
|
|
64
|
+
this.log(' export OTEL_RESOURCE_ATTRIBUTES="env=prod,task_id=T-123"');
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
this.handleError(error);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
resolveApiKey() {
|
|
71
|
+
const auth = this.getAuth();
|
|
72
|
+
if (auth.apiKey)
|
|
73
|
+
return auth.apiKey;
|
|
74
|
+
if (auth.accessToken) {
|
|
75
|
+
this.warn('Using access token (JWT) which may expire. Consider using an API key instead.');
|
|
76
|
+
return auth.accessToken;
|
|
77
|
+
}
|
|
78
|
+
this.error('No API key found. Pass --api-key, set RESPAN_API_KEY, or run: respan auth login');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
IntegrateOpencode.description = `Integrate Respan with OpenCode.
|
|
82
|
+
|
|
83
|
+
OpenCode's built-in OTel does not work reliably, so this uses the
|
|
84
|
+
community opencode-otel plugin instead.
|
|
85
|
+
|
|
86
|
+
Scope:
|
|
87
|
+
--local Write plugin config to project root (default)
|
|
88
|
+
--global Write to ~/.config/opencode/plugins/otel.json
|
|
89
|
+
The opencode-otel package is always installed globally.`;
|
|
90
|
+
IntegrateOpencode.examples = [
|
|
91
|
+
'respan integrate opencode',
|
|
92
|
+
'respan integrate opencode --global',
|
|
93
|
+
'respan integrate opencode --project-id my-project --attrs \'{"env":"prod"}\'',
|
|
94
|
+
'respan integrate opencode --dry-run',
|
|
95
|
+
];
|
|
96
|
+
IntegrateOpencode.flags = {
|
|
97
|
+
...BaseCommand.baseFlags,
|
|
98
|
+
...integrateFlags,
|
|
99
|
+
};
|
|
100
|
+
export default IntegrateOpencode;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** Default Respan API base (SaaS). Enterprise users override this. */
|
|
2
|
+
export declare const DEFAULT_BASE_URL = "https://api.respan.ai/api";
|
|
3
|
+
export declare const integrateFlags: {
|
|
4
|
+
local: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
5
|
+
global: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
6
|
+
'project-id': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
7
|
+
'base-url': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
8
|
+
attrs: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
9
|
+
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
10
|
+
};
|
|
11
|
+
export type Scope = 'local' | 'global' | 'both';
|
|
12
|
+
/**
|
|
13
|
+
* Resolve whether to write local, global, or both configs.
|
|
14
|
+
*
|
|
15
|
+
* - `--global` → 'global'
|
|
16
|
+
* - `--local` → 'local'
|
|
17
|
+
* - neither → tool-specific default (passed by each command)
|
|
18
|
+
*/
|
|
19
|
+
export declare function resolveScope(flags: {
|
|
20
|
+
local: boolean;
|
|
21
|
+
global: boolean;
|
|
22
|
+
}, defaultScope?: Scope): Scope;
|
|
23
|
+
/**
|
|
24
|
+
* Find the project root (git root, or cwd if not in a repo).
|
|
25
|
+
*/
|
|
26
|
+
export declare function findProjectRoot(): string;
|
|
27
|
+
/**
|
|
28
|
+
* Return the bundled Claude Code hook script contents.
|
|
29
|
+
*
|
|
30
|
+
* The .py file lives in src/assets/ and is copied to dist/assets/ during
|
|
31
|
+
* build. Reading at runtime keeps the hook version-locked to the CLI
|
|
32
|
+
* version — upgrading the CLI and re-running integrate updates the hook.
|
|
33
|
+
*/
|
|
34
|
+
export declare function getHookScript(): string;
|
|
35
|
+
/**
|
|
36
|
+
* Deep merge source into target.
|
|
37
|
+
* Objects are recursively merged; arrays and primitives from source overwrite.
|
|
38
|
+
*/
|
|
39
|
+
export declare function deepMerge(target: Record<string, unknown>, source: Record<string, unknown>): Record<string, unknown>;
|
|
40
|
+
export declare function ensureDir(dirPath: string): void;
|
|
41
|
+
export declare function readJsonFile(filePath: string): Record<string, unknown>;
|
|
42
|
+
export declare function writeJsonFile(filePath: string, data: Record<string, unknown>): void;
|
|
43
|
+
export declare function readTextFile(filePath: string): string;
|
|
44
|
+
export declare function writeTextFile(filePath: string, content: string): void;
|
|
45
|
+
export declare function expandHome(p: string): string;
|
|
46
|
+
export declare function parseAttrs(raw: string): Record<string, string>;
|
|
47
|
+
/**
|
|
48
|
+
* Convert a Record to OTel resource attributes string.
|
|
49
|
+
* Format: key1=value1,key2=value2
|
|
50
|
+
*/
|
|
51
|
+
export declare function toOtelResourceAttrs(attrs: Record<string, string>): string;
|