@intelli-1113/stratos-sdk 1.3.4 → 1.3.6
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/README.md +4 -0
- package/package.json +17 -5
- package/src/README.md +583 -0
- package/src/USAGE.md +299 -0
- package/src/claude-code/hook.js +309 -0
- package/src/claude-code/settings.js +155 -0
- package/src/claude-code/transcript.js +259 -0
- package/src/cli-doctor.js +127 -0
- package/src/cli-env.js +134 -0
- package/src/cli-setup.js +171 -0
- package/src/cli.js +115 -0
- package/src/config-templates.js +148 -0
- package/src/enforcement.js +182 -163
- package/src/execution-spans.js +166 -0
- package/src/index.js +24 -3
- package/src/inspect-langchain.js +22 -2
- package/src/mcp-proxy.js +21 -3
- package/src/otel-bridge-harness.js +115 -0
- package/src/otel-bridge.js +170 -0
- package/src/postinstall.js +32 -11
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Shared Claude Code settings helpers: where Stratos keeps its own config,
|
|
2
|
+
// the hook block written into ~/.claude/settings.json, and a merge that never
|
|
3
|
+
// drops the user's own hooks.
|
|
4
|
+
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_URL = "http://localhost:4000";
|
|
11
|
+
export const HOOK_EVENTS = {
|
|
12
|
+
SessionStart: { arg: "session-start", timeout: 10 },
|
|
13
|
+
Stop: { arg: "stop", timeout: 30, async: true },
|
|
14
|
+
SubagentStop: { arg: "subagent-stop", timeout: 30, async: true },
|
|
15
|
+
SessionEnd: { arg: "session-end", timeout: 15 },
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** ~/.stratos — token, URL, cursors and the outbox live here, not in settings.json. */
|
|
19
|
+
export function stratosDir() {
|
|
20
|
+
return process.env.STRATOS_HOME || join(homedir(), ".stratos");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function claudeSettingsPath() {
|
|
24
|
+
return join(process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"), "settings.json");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const configPath = () => join(stratosDir(), "config.json");
|
|
28
|
+
|
|
29
|
+
function flag(v, fallback = false) {
|
|
30
|
+
if (v == null || v === "") return fallback;
|
|
31
|
+
return !["0", "false", "off", "no"].includes(String(v).toLowerCase());
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Config file, overridden by STRATOS_* environment variables when set. */
|
|
35
|
+
export function loadConfig() {
|
|
36
|
+
let file = {};
|
|
37
|
+
try {
|
|
38
|
+
file = JSON.parse(readFileSync(configPath(), "utf8"));
|
|
39
|
+
} catch {
|
|
40
|
+
/* not set up yet */
|
|
41
|
+
}
|
|
42
|
+
const env = process.env;
|
|
43
|
+
return {
|
|
44
|
+
token: env.STRATOS_TOKEN || file.token || "",
|
|
45
|
+
url: (env.STRATOS_URL || file.url || DEFAULT_URL).replace(/\/+$/, ""),
|
|
46
|
+
appName: env.STRATOS_APP_NAME || file.appName || "claude-code",
|
|
47
|
+
recordInputs: flag(env.STRATOS_RECORD_INPUTS, !!file.recordInputs),
|
|
48
|
+
recordOutputs: flag(env.STRATOS_RECORD_OUTPUTS, !!file.recordOutputs),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function saveConfig(cfg) {
|
|
53
|
+
const file = configPath();
|
|
54
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
55
|
+
writeFileSync(file, JSON.stringify(cfg, null, 2) + "\n", { mode: 0o600 });
|
|
56
|
+
try {
|
|
57
|
+
chmodSync(file, 0o600); // no-op on Windows, where the profile folder is already per-user
|
|
58
|
+
} catch {
|
|
59
|
+
/* ignore */
|
|
60
|
+
}
|
|
61
|
+
return file;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function configExists() {
|
|
65
|
+
return existsSync(configPath());
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function configFile() {
|
|
69
|
+
return configPath();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Absolute path of this package's CLI entry. */
|
|
73
|
+
export function cliPath() {
|
|
74
|
+
return join(dirname(fileURLToPath(import.meta.url)), "..", "cli.js");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const fwd = (p) => p.replace(/\\/g, "/");
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The command Claude Code runs for one hook event. The script path is absolute
|
|
81
|
+
* (no global install needed); the program is bare `node` because a command that
|
|
82
|
+
* starts with a quoted path is a parse error in PowerShell, which Claude Code
|
|
83
|
+
* may use for hooks on Windows. `node "<path>" hook stop` works in bash, cmd and
|
|
84
|
+
* PowerShell alike.
|
|
85
|
+
*/
|
|
86
|
+
export function hookCommand(arg, { node = "node", cli = cliPath() } = {}) {
|
|
87
|
+
return `${node} "${fwd(cli)}" hook ${arg}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const OUR_HOOK = /cli\.js"?\s+hook\s+(session-start|stop|subagent-stop|session-end)\b/;
|
|
91
|
+
|
|
92
|
+
export function isStratosHook(h) {
|
|
93
|
+
return !!(h && typeof h.command === "string" && OUR_HOOK.test(h.command) && /stratos/i.test(h.command));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Hook block in Claude Code's schema: { Event: [ { matcher, hooks: [ {type, command, ...} ] } ] } */
|
|
97
|
+
export function buildClaudeCodeHooks(opts = {}) {
|
|
98
|
+
const hooks = {};
|
|
99
|
+
for (const [event, spec] of Object.entries(HOOK_EVENTS)) {
|
|
100
|
+
const handler = { type: "command", command: hookCommand(spec.arg, opts), timeout: spec.timeout };
|
|
101
|
+
if (spec.async) handler.async = true;
|
|
102
|
+
hooks[event] = [{ matcher: "*", hooks: [handler] }];
|
|
103
|
+
}
|
|
104
|
+
return hooks;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Remove our handlers from a hooks object, keeping everything else the user has. */
|
|
108
|
+
export function removeStratosHooks(hooks = {}) {
|
|
109
|
+
const out = {};
|
|
110
|
+
for (const [event, groups] of Object.entries(hooks || {})) {
|
|
111
|
+
if (!Array.isArray(groups)) {
|
|
112
|
+
out[event] = groups;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const kept = groups
|
|
116
|
+
.map((g) => (g && Array.isArray(g.hooks) ? { ...g, hooks: g.hooks.filter((h) => !isStratosHook(h)) } : g))
|
|
117
|
+
.filter((g) => !(g && Array.isArray(g.hooks) && g.hooks.length === 0));
|
|
118
|
+
if (kept.length) out[event] = kept;
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Add our hooks; re-running replaces our old entries instead of duplicating them. */
|
|
124
|
+
export function mergeStratosHooks(hooks = {}, opts = {}) {
|
|
125
|
+
const out = removeStratosHooks(hooks);
|
|
126
|
+
for (const [event, groups] of Object.entries(buildClaudeCodeHooks(opts))) {
|
|
127
|
+
out[event] = [...(Array.isArray(out[event]) ? out[event] : []), ...groups];
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Earlier setup versions wrote these into settings.env; they must go. */
|
|
133
|
+
export const LEGACY_ENV_KEYS = ["STRATOS_TOKEN", "STRATOS_URL", "STRATOS_RECORD_INPUTS", "STRATOS_RECORD_OUTPUTS"];
|
|
134
|
+
|
|
135
|
+
export function hasLegacyNodeOptions(env = {}) {
|
|
136
|
+
return typeof env.NODE_OPTIONS === "string" && env.NODE_OPTIONS.includes("stratos-sdk/register");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function cleanLegacyEnv(env = {}) {
|
|
140
|
+
const out = { ...env };
|
|
141
|
+
for (const k of LEGACY_ENV_KEYS) delete out[k];
|
|
142
|
+
if (hasLegacyNodeOptions(out)) {
|
|
143
|
+
const rest = out.NODE_OPTIONS.replace(/--import[= ]\S*stratos-sdk\/register\S*/g, "").trim();
|
|
144
|
+
if (rest) out.NODE_OPTIONS = rest;
|
|
145
|
+
else delete out.NODE_OPTIONS;
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Managed-settings.json locations for admins deploying to every developer. */
|
|
151
|
+
export const MANAGED_SETTINGS_PATHS = {
|
|
152
|
+
win32: "C:\\Program Files\\ClaudeCode\\managed-settings.json",
|
|
153
|
+
darwin: "/Library/Application Support/ClaudeCode/managed-settings.json",
|
|
154
|
+
linux: "/etc/claude-code/managed-settings.json",
|
|
155
|
+
};
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// Claude Code transcript → Stratos spans (pure, no I/O).
|
|
2
|
+
//
|
|
3
|
+
// Claude Code writes every session to a JSONL transcript
|
|
4
|
+
// (~/.claude/projects/<project>/<session>.jsonl, subagents in
|
|
5
|
+
// <session>/subagents/agent-<id>.jsonl). Each assistant API call carries the
|
|
6
|
+
// model and token usage; tool calls appear as tool_use blocks, answered later
|
|
7
|
+
// by tool_result blocks. The format is internal to Claude Code and not
|
|
8
|
+
// documented, so everything here is defensive: unknown shapes are skipped and
|
|
9
|
+
// nothing throws.
|
|
10
|
+
//
|
|
11
|
+
// Spans use the same OpenLLMetry/Traceloop attribute names the rest of the SDK
|
|
12
|
+
// sends (see mcp-proxy.js reportTool), so /api/ingest needs no changes.
|
|
13
|
+
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
|
|
16
|
+
const MAX_TEXT = 2048;
|
|
17
|
+
|
|
18
|
+
const hash = (s, n) => createHash("sha256").update(String(s)).digest("hex").slice(0, n);
|
|
19
|
+
const attr = (k, v) => ({ key: k, value: { stringValue: String(v) } });
|
|
20
|
+
const clip = (s) => {
|
|
21
|
+
const str = typeof s === "string" ? s : JSON.stringify(s ?? "");
|
|
22
|
+
return str.length > MAX_TEXT ? str.slice(0, MAX_TEXT) + "…" : str;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function toNano(iso) {
|
|
26
|
+
const ms = Date.parse(iso);
|
|
27
|
+
return Number.isFinite(ms) ? (BigInt(ms) * 1000000n).toString() : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const traceIdFor = (sessionId) => hash(`session:${sessionId}`, 32);
|
|
31
|
+
export const llmSpanIdFor = (messageId) => hash(`msg:${messageId}`, 16);
|
|
32
|
+
export const toolSpanIdFor = (toolUseId) => hash(`tool:${toolUseId}`, 16);
|
|
33
|
+
|
|
34
|
+
/** Parse JSONL text into objects, skipping blank or malformed lines. */
|
|
35
|
+
export function parseLines(text) {
|
|
36
|
+
const out = [];
|
|
37
|
+
for (const line of String(text || "").split("\n")) {
|
|
38
|
+
if (!line.trim()) continue;
|
|
39
|
+
try {
|
|
40
|
+
const o = JSON.parse(line);
|
|
41
|
+
if (o && typeof o === "object") out.push(o);
|
|
42
|
+
} catch {
|
|
43
|
+
/* partial or foreign line — skip */
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function contentBlocks(message) {
|
|
50
|
+
return Array.isArray(message?.content) ? message.content : [];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function promptText(entry) {
|
|
54
|
+
const c = entry?.message?.content;
|
|
55
|
+
if (typeof c === "string") return c;
|
|
56
|
+
return contentBlocks(entry?.message)
|
|
57
|
+
.filter((b) => b?.type === "text")
|
|
58
|
+
.map((b) => b.text)
|
|
59
|
+
.join("\n");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function splitMcp(name) {
|
|
63
|
+
const m = /^mcp__(.+?)__(.+)$/.exec(name || "");
|
|
64
|
+
return m ? { server: m[1], tool: m[2] } : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Build spans from transcript entries.
|
|
69
|
+
*
|
|
70
|
+
* @param {object[]} entries parsed transcript lines (one batch, in order)
|
|
71
|
+
* @param {object} opts
|
|
72
|
+
* @param {string} opts.sessionId
|
|
73
|
+
* @param {{id?:string,type?:string,parentToolUseId?:string}} [opts.agent] set for subagent transcripts
|
|
74
|
+
* @param {Set<string>} [opts.sentMessages] message ids already reported (dedupe across runs)
|
|
75
|
+
* @param {Set<string>} [opts.sentTools] tool_use ids already reported
|
|
76
|
+
* @param {object} [opts.pending] tool_use ids seen earlier without a result: { id: {name, ts, input} }
|
|
77
|
+
* @param {boolean} [opts.recordInputs]
|
|
78
|
+
* @param {boolean} [opts.recordOutputs]
|
|
79
|
+
* @returns {{ spans: object[], messageIds: string[], toolIds: string[], pending: object }}
|
|
80
|
+
*/
|
|
81
|
+
export function buildSpans(entries, opts = {}) {
|
|
82
|
+
const sessionId = opts.sessionId || entries.find((e) => e?.sessionId)?.sessionId || "unknown";
|
|
83
|
+
const traceId = traceIdFor(sessionId);
|
|
84
|
+
const sentMessages = opts.sentMessages || new Set();
|
|
85
|
+
const sentTools = opts.sentTools || new Set();
|
|
86
|
+
const pending = { ...(opts.pending || {}) };
|
|
87
|
+
const agent = opts.agent || null;
|
|
88
|
+
const parentSpanId = agent?.parentToolUseId ? toolSpanIdFor(agent.parentToolUseId) : undefined;
|
|
89
|
+
|
|
90
|
+
const common = [attr("stratos.surface", "claude-code"), attr("session.id", sessionId)];
|
|
91
|
+
if (agent) {
|
|
92
|
+
common.push(attr("stratos.subagent", "true"));
|
|
93
|
+
if (agent.id) common.push(attr("stratos.agent.id", agent.id));
|
|
94
|
+
if (agent.type) common.push(attr("stratos.agent.type", agent.type));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Streaming writes one line per content block, each repeating the same
|
|
98
|
+
// usage — group lines by message id and report each API call once.
|
|
99
|
+
const calls = new Map();
|
|
100
|
+
let lastInputTs = null;
|
|
101
|
+
let lastPrompt = null;
|
|
102
|
+
|
|
103
|
+
for (const e of entries) {
|
|
104
|
+
if (e?.type === "user") {
|
|
105
|
+
const blocks = contentBlocks(e.message);
|
|
106
|
+
const results = blocks.filter((b) => b?.type === "tool_result");
|
|
107
|
+
if (!results.length) {
|
|
108
|
+
lastPrompt = promptText(e);
|
|
109
|
+
}
|
|
110
|
+
for (const r of results) {
|
|
111
|
+
const id = r.tool_use_id;
|
|
112
|
+
if (id && pending[id]) pending[id].result = { ts: e.timestamp, isError: !!r.is_error, content: r.content };
|
|
113
|
+
}
|
|
114
|
+
if (e.timestamp) lastInputTs = e.timestamp;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (e?.type !== "assistant" || !e.message) continue;
|
|
118
|
+
|
|
119
|
+
const msg = e.message;
|
|
120
|
+
const key = msg.id || e.requestId || e.uuid;
|
|
121
|
+
if (!key) continue;
|
|
122
|
+
let call = calls.get(key);
|
|
123
|
+
if (!call) {
|
|
124
|
+
call = {
|
|
125
|
+
id: key,
|
|
126
|
+
requestId: e.requestId,
|
|
127
|
+
model: msg.model,
|
|
128
|
+
usage: msg.usage || {},
|
|
129
|
+
stopReason: msg.stop_reason,
|
|
130
|
+
startTs: lastInputTs || e.timestamp,
|
|
131
|
+
endTs: e.timestamp,
|
|
132
|
+
text: [],
|
|
133
|
+
prompt: lastPrompt,
|
|
134
|
+
version: e.version,
|
|
135
|
+
};
|
|
136
|
+
calls.set(key, call);
|
|
137
|
+
lastPrompt = null;
|
|
138
|
+
}
|
|
139
|
+
call.endTs = e.timestamp || call.endTs;
|
|
140
|
+
if ((msg.usage?.output_tokens || 0) >= (call.usage.output_tokens || 0)) call.usage = msg.usage || call.usage;
|
|
141
|
+
if (msg.stop_reason) call.stopReason = msg.stop_reason;
|
|
142
|
+
|
|
143
|
+
for (const b of contentBlocks(msg)) {
|
|
144
|
+
if (b?.type === "text" && b.text) call.text.push(b.text);
|
|
145
|
+
if (b?.type === "tool_use" && b.id && !sentTools.has(b.id) && !pending[b.id]) {
|
|
146
|
+
pending[b.id] = { name: b.name || "tool", ts: e.timestamp, input: b.input };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const spans = [];
|
|
152
|
+
const messageIds = [];
|
|
153
|
+
|
|
154
|
+
for (const call of calls.values()) {
|
|
155
|
+
if (sentMessages.has(call.id)) continue;
|
|
156
|
+
if (call.model === "<synthetic>") continue; // local error placeholders, not API calls
|
|
157
|
+
const u = call.usage || {};
|
|
158
|
+
const input = u.input_tokens || 0;
|
|
159
|
+
const output = u.output_tokens || 0;
|
|
160
|
+
const cacheRead = u.cache_read_input_tokens || 0;
|
|
161
|
+
const cacheWrite = u.cache_creation_input_tokens || 0;
|
|
162
|
+
const start = toNano(call.startTs) || toNano(call.endTs);
|
|
163
|
+
const end = toNano(call.endTs) || start;
|
|
164
|
+
if (!start) continue;
|
|
165
|
+
|
|
166
|
+
const attributes = [
|
|
167
|
+
attr("traceloop.span.kind", "llm"),
|
|
168
|
+
attr("traceloop.entity.name", "claude-code"),
|
|
169
|
+
attr("gen_ai.system", "anthropic"),
|
|
170
|
+
attr("gen_ai.request.model", call.model || "unknown"),
|
|
171
|
+
attr("gen_ai.response.model", call.model || "unknown"),
|
|
172
|
+
attr("gen_ai.usage.input_tokens", input),
|
|
173
|
+
attr("gen_ai.usage.output_tokens", output),
|
|
174
|
+
attr("gen_ai.usage.prompt_tokens", input),
|
|
175
|
+
attr("gen_ai.usage.completion_tokens", output),
|
|
176
|
+
attr("gen_ai.usage.cache_read_input_tokens", cacheRead),
|
|
177
|
+
attr("gen_ai.usage.cache_creation_input_tokens", cacheWrite),
|
|
178
|
+
attr("llm.usage.total_tokens", input + output + cacheRead + cacheWrite),
|
|
179
|
+
...common,
|
|
180
|
+
];
|
|
181
|
+
if (u.output_tokens_details?.thinking_tokens != null) {
|
|
182
|
+
attributes.push(attr("gen_ai.usage.thinking_tokens", u.output_tokens_details.thinking_tokens));
|
|
183
|
+
}
|
|
184
|
+
if (call.stopReason) attributes.push(attr("gen_ai.response.finish_reason", call.stopReason));
|
|
185
|
+
if (call.requestId) attributes.push(attr("stratos.request_id", call.requestId));
|
|
186
|
+
if (call.version) attributes.push(attr("claude_code.version", call.version));
|
|
187
|
+
if (opts.recordInputs && call.prompt) attributes.push(attr("traceloop.entity.input", clip(call.prompt)));
|
|
188
|
+
if (opts.recordOutputs && call.text.length) attributes.push(attr("traceloop.entity.output", clip(call.text.join("\n"))));
|
|
189
|
+
|
|
190
|
+
spans.push({
|
|
191
|
+
traceId,
|
|
192
|
+
spanId: llmSpanIdFor(call.id),
|
|
193
|
+
...(parentSpanId ? { parentSpanId } : {}),
|
|
194
|
+
name: "anthropic.chat",
|
|
195
|
+
kind: 3,
|
|
196
|
+
startTimeUnixNano: start,
|
|
197
|
+
endTimeUnixNano: end,
|
|
198
|
+
attributes,
|
|
199
|
+
});
|
|
200
|
+
messageIds.push(call.id);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const toolIds = [];
|
|
204
|
+
for (const [id, t] of Object.entries(pending)) {
|
|
205
|
+
if (!t.result || sentTools.has(id)) continue;
|
|
206
|
+
const start = toNano(t.ts) || toNano(t.result.ts);
|
|
207
|
+
const end = toNano(t.result.ts) || start;
|
|
208
|
+
if (!start) continue;
|
|
209
|
+
const attributes = [
|
|
210
|
+
attr("traceloop.span.kind", "tool"),
|
|
211
|
+
attr("traceloop.entity.name", t.name),
|
|
212
|
+
attr("stratos.tool.is_error", t.result.isError),
|
|
213
|
+
...common,
|
|
214
|
+
];
|
|
215
|
+
const mcp = splitMcp(t.name);
|
|
216
|
+
if (mcp) attributes.push(attr("mcp.server", mcp.server), attr("mcp.tool", mcp.tool));
|
|
217
|
+
if (opts.recordInputs) {
|
|
218
|
+
attributes.push(attr("traceloop.entity.input", clip({ tool_name: t.name, arguments: t.input ?? {} })));
|
|
219
|
+
}
|
|
220
|
+
if (opts.recordOutputs) {
|
|
221
|
+
attributes.push(attr("traceloop.entity.output", clip({ content: t.result.content, is_error: t.result.isError })));
|
|
222
|
+
}
|
|
223
|
+
spans.push({
|
|
224
|
+
traceId,
|
|
225
|
+
spanId: toolSpanIdFor(id),
|
|
226
|
+
...(parentSpanId ? { parentSpanId } : {}),
|
|
227
|
+
name: `${t.name}.tool`,
|
|
228
|
+
kind: 1,
|
|
229
|
+
startTimeUnixNano: start,
|
|
230
|
+
endTimeUnixNano: end,
|
|
231
|
+
attributes,
|
|
232
|
+
...(t.result.isError ? { status: { code: 2 } } : {}),
|
|
233
|
+
});
|
|
234
|
+
toolIds.push(id);
|
|
235
|
+
delete pending[id];
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Strip bulky inputs from what is carried over to the next run.
|
|
239
|
+
for (const t of Object.values(pending)) {
|
|
240
|
+
if (!opts.recordInputs) delete t.input;
|
|
241
|
+
if (t.result && !opts.recordOutputs) delete t.result.content;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return { spans, messageIds, toolIds, pending };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Wrap spans in an OTLP/JSON body for POST /api/ingest. */
|
|
248
|
+
export function toOtlpPayload(spans, { serviceName = "claude-code", version } = {}) {
|
|
249
|
+
const resource = [attr("service.name", serviceName), attr("stratos.surface", "claude-code")];
|
|
250
|
+
if (version) resource.push(attr("claude_code.version", version));
|
|
251
|
+
return {
|
|
252
|
+
resourceSpans: [
|
|
253
|
+
{
|
|
254
|
+
resource: { attributes: resource },
|
|
255
|
+
scopeSpans: [{ scope: { name: "stratos-sdk.claude-code" }, spans }],
|
|
256
|
+
},
|
|
257
|
+
],
|
|
258
|
+
};
|
|
259
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// `stratos doctor` — check the Claude Code ↔ Stratos connection.
|
|
2
|
+
// Reads the files setup writes (not this shell's environment), so it gives the
|
|
3
|
+
// same answer no matter where it is run from.
|
|
4
|
+
|
|
5
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import spawn from "cross-spawn";
|
|
8
|
+
import {
|
|
9
|
+
HOOK_EVENTS,
|
|
10
|
+
claudeSettingsPath,
|
|
11
|
+
configExists,
|
|
12
|
+
configFile,
|
|
13
|
+
hasLegacyNodeOptions,
|
|
14
|
+
isStratosHook,
|
|
15
|
+
loadConfig,
|
|
16
|
+
stratosDir,
|
|
17
|
+
} from "./claude-code/settings.js";
|
|
18
|
+
|
|
19
|
+
function readJson(file) {
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Paths quoted in a hook command, e.g. node "C:/…/cli.js" hook stop */
|
|
28
|
+
const quotedPaths = (cmd) => [...cmd.matchAll(/"([^"]+)"/g)].map((m) => m[1]);
|
|
29
|
+
|
|
30
|
+
export async function doctorCommand() {
|
|
31
|
+
console.log("[stratos-doctor] Checking Claude Code → Stratos…\n");
|
|
32
|
+
let failed = 0;
|
|
33
|
+
const fixes = [];
|
|
34
|
+
const ok = (m) => console.log(`✓ ${m}`);
|
|
35
|
+
const warn = (m, fix) => {
|
|
36
|
+
console.log(`⚠ ${m}`);
|
|
37
|
+
if (fix) fixes.push(fix);
|
|
38
|
+
};
|
|
39
|
+
const fail = (m, fix) => {
|
|
40
|
+
console.log(`✗ ${m}`);
|
|
41
|
+
failed++;
|
|
42
|
+
if (fix) fixes.push(fix);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const cfg = loadConfig();
|
|
46
|
+
if (configExists()) ok(`Config: ${configFile()}`);
|
|
47
|
+
else if (cfg.token) warn("No config file — using STRATOS_* environment variables", "Run: stratos setup claude-code");
|
|
48
|
+
else fail("No Stratos config", "Run: stratos setup claude-code --token <token> --url <url>");
|
|
49
|
+
|
|
50
|
+
// The token format is the server's business; whether it is valid is checked below against Stratos itself.
|
|
51
|
+
if (cfg.token) ok("Token present");
|
|
52
|
+
else fail("Token missing", "Run: stratos setup claude-code --token <token>");
|
|
53
|
+
|
|
54
|
+
const local = /^http:\/\/(localhost|127\.0\.0\.1|\[::1\])(:|\/|$)/.test(cfg.url);
|
|
55
|
+
if (cfg.url.startsWith("https://") || local) ok(`Server: ${cfg.url}`);
|
|
56
|
+
else warn(`Server ${cfg.url} is plain http — token is sent unencrypted`);
|
|
57
|
+
|
|
58
|
+
const settingsPath = claudeSettingsPath();
|
|
59
|
+
const settings = existsSync(settingsPath) ? readJson(settingsPath) : null;
|
|
60
|
+
if (!settings) {
|
|
61
|
+
fail(`Cannot read ${settingsPath}`, "Run: stratos setup claude-code");
|
|
62
|
+
} else {
|
|
63
|
+
for (const event of Object.keys(HOOK_EVENTS)) {
|
|
64
|
+
const handlers = (settings.hooks?.[event] || []).flatMap((g) => g?.hooks || []);
|
|
65
|
+
const ours = handlers.find(isStratosHook);
|
|
66
|
+
if (!ours) {
|
|
67
|
+
fail(`Hook ${event} missing`, "Run: stratos setup claude-code");
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const missing = quotedPaths(ours.command).filter((p) => !existsSync(p));
|
|
71
|
+
if (missing.length) fail(`Hook ${event} points at missing file: ${missing.join(", ")}`, "Re-run: stratos setup claude-code");
|
|
72
|
+
else ok(`Hook ${event}`);
|
|
73
|
+
}
|
|
74
|
+
const nodeOnPath = spawn.sync("node", ["--version"], { encoding: "utf8", timeout: 5000 });
|
|
75
|
+
if (nodeOnPath.status === 0) ok(`node on PATH (${nodeOnPath.stdout.trim()}) — hooks can start`);
|
|
76
|
+
else fail("`node` is not on PATH, so the hooks cannot start", "Add Node.js to PATH, then restart Claude Code");
|
|
77
|
+
if (hasLegacyNodeOptions(settings.env)) {
|
|
78
|
+
fail(
|
|
79
|
+
"settings.json env still sets NODE_OPTIONS=--import …stratos-sdk/register (breaks Node commands Claude runs)",
|
|
80
|
+
"Re-run: stratos setup claude-code (removes it)"
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
if (settings.env?.STRATOS_TOKEN) {
|
|
84
|
+
warn("settings.json env contains STRATOS_TOKEN — visible to every command Claude runs", "Re-run: stratos setup claude-code (moves it)");
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const status = readJson(join(stratosDir(), "status.json")) || {};
|
|
89
|
+
if (status.lastSendAt) ok(`Last successful send: ${status.lastSendAt}`);
|
|
90
|
+
else console.log("· No usage sent yet (expected until a Claude Code session finishes a turn)");
|
|
91
|
+
if (status.lastError) warn(`Last error: ${status.lastError}`);
|
|
92
|
+
|
|
93
|
+
const outbox = join(stratosDir(), "outbox");
|
|
94
|
+
if (existsSync(outbox)) {
|
|
95
|
+
const files = readdirSync(outbox).filter((f) => f.endsWith(".json"));
|
|
96
|
+
if (files.length) {
|
|
97
|
+
const kb = Math.round(files.reduce((n, f) => n + statSync(join(outbox, f)).size, 0) / 1024);
|
|
98
|
+
warn(`${files.length} undelivered batch(es) waiting in outbox (${kb} KB) — sent on the next hook once Stratos is reachable`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (cfg.token) {
|
|
103
|
+
try {
|
|
104
|
+
const res = await fetch(`${cfg.url}/api/heartbeat`, {
|
|
105
|
+
method: "POST",
|
|
106
|
+
headers: { "Content-Type": "application/json", "x-stratos-token": cfg.token },
|
|
107
|
+
body: JSON.stringify({ framework: "claude-code", model: null, tools: [], doctor: true }),
|
|
108
|
+
signal: AbortSignal.timeout(5000),
|
|
109
|
+
});
|
|
110
|
+
if (res.ok) ok("Stratos accepted the token");
|
|
111
|
+
else if (res.status === 401 || res.status === 403) fail(`Stratos rejected the token (${res.status})`, "Check the token in Stratos > Add agent");
|
|
112
|
+
else fail(`Stratos returned ${res.status}`, `Check the URL: ${cfg.url}`);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
fail(`Cannot reach ${cfg.url}: ${err.cause?.code || err.message}`, "Check network, VPN or firewall, and the URL");
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
console.log("\n" + "=".repeat(60));
|
|
119
|
+
if (!failed) {
|
|
120
|
+
console.log("✓ All checks passed — new Claude Code sessions report usage to Stratos.");
|
|
121
|
+
} else {
|
|
122
|
+
console.log(`✗ ${failed} check(s) failed`);
|
|
123
|
+
[...new Set(fixes)].forEach((f, i) => console.log(` ${i + 1}. ${f}`));
|
|
124
|
+
process.exitCode = 1;
|
|
125
|
+
}
|
|
126
|
+
console.log("=".repeat(60));
|
|
127
|
+
}
|
package/src/cli-env.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync, copyFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { mkdir } from "node:fs/promises";
|
|
6
|
+
|
|
7
|
+
class EnvError extends Error {}
|
|
8
|
+
|
|
9
|
+
function isNodeProject() {
|
|
10
|
+
return existsSync(join(process.cwd(), "package.json"));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function envPath() {
|
|
14
|
+
return join(process.cwd(), ".env");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function readEnvFile(path) {
|
|
18
|
+
if (!existsSync(path)) return "";
|
|
19
|
+
try {
|
|
20
|
+
return readFileSync(path, "utf8");
|
|
21
|
+
} catch {
|
|
22
|
+
return "";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function backup(path) {
|
|
27
|
+
if (!existsSync(path)) return null;
|
|
28
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
29
|
+
const dest = `${path}.stratos-backup-${stamp}`;
|
|
30
|
+
try {
|
|
31
|
+
copyFileSync(path, dest);
|
|
32
|
+
return dest;
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseEnvFile(content) {
|
|
39
|
+
const lines = content.split("\n");
|
|
40
|
+
const vars = new Map();
|
|
41
|
+
for (const line of lines) {
|
|
42
|
+
const trimmed = line.trim();
|
|
43
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
44
|
+
const [key, ...valueParts] = trimmed.split("=");
|
|
45
|
+
if (key) vars.set(key, valueParts.join("="));
|
|
46
|
+
}
|
|
47
|
+
return vars;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function buildEnvContent(vars) {
|
|
51
|
+
return Array.from(vars.entries())
|
|
52
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
53
|
+
.join("\n") + "\n";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function envAddCommand(...varStrings) {
|
|
57
|
+
try {
|
|
58
|
+
// Check if in a Node.js project
|
|
59
|
+
if (!isNodeProject()) {
|
|
60
|
+
throw new EnvError(
|
|
61
|
+
"Not a Node.js project. Run this command from your project root (where package.json is)."
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Validate we have at least one var
|
|
66
|
+
if (varStrings.length === 0) {
|
|
67
|
+
throw new EnvError("Invalid format. Use: stratos env add KEY=value [KEY2=value2 ...]");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Parse and validate all variables
|
|
71
|
+
const newVars = new Map();
|
|
72
|
+
for (const varString of varStrings) {
|
|
73
|
+
if (!varString || !varString.includes("=")) {
|
|
74
|
+
throw new EnvError(`Invalid format: ${varString}. Use: KEY=value`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const [key, ...valueParts] = varString.split("=");
|
|
78
|
+
if (!key || !key.match(/^[A-Z_][A-Z0-9_]*$/i)) {
|
|
79
|
+
throw new EnvError(`Invalid variable name: ${key}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
newVars.set(key, valueParts.join("="));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const path = envPath();
|
|
86
|
+
|
|
87
|
+
// Read existing .env if it exists
|
|
88
|
+
const existingContent = readEnvFile(path);
|
|
89
|
+
const vars = parseEnvFile(existingContent);
|
|
90
|
+
|
|
91
|
+
// Create directory if needed
|
|
92
|
+
const dir = process.cwd();
|
|
93
|
+
await mkdir(dir, { recursive: true });
|
|
94
|
+
|
|
95
|
+
// Backup existing .env if present
|
|
96
|
+
let backupPath = null;
|
|
97
|
+
if (existsSync(path)) {
|
|
98
|
+
backupPath = backup(path);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Process each new variable
|
|
102
|
+
for (const [key, value] of newVars.entries()) {
|
|
103
|
+
if (vars.has(key)) {
|
|
104
|
+
const oldValue = vars.get(key);
|
|
105
|
+
if (oldValue === value) {
|
|
106
|
+
console.log(`✓ ${key}=${value} already set`);
|
|
107
|
+
} else {
|
|
108
|
+
console.log(`✓ Updated ${key} (was: ${oldValue})`);
|
|
109
|
+
vars.set(key, value);
|
|
110
|
+
}
|
|
111
|
+
} else {
|
|
112
|
+
console.log(`✓ Added ${key}=${value}`);
|
|
113
|
+
vars.set(key, value);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Show backup message if one was created
|
|
118
|
+
if (backupPath) {
|
|
119
|
+
console.log(` backup: ${backupPath}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Write new .env file
|
|
123
|
+
const newContent = buildEnvContent(vars);
|
|
124
|
+
writeFileSync(path, newContent, "utf8");
|
|
125
|
+
console.log(`✓ Updated .env`);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
if (err instanceof EnvError) {
|
|
128
|
+
console.error(`[stratos] ${err.message}`);
|
|
129
|
+
process.exitCode = 1;
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
throw err;
|
|
133
|
+
}
|
|
134
|
+
}
|