@moda-ai/cli 1.22.1 → 1.23.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/cli-c4se7a5d.js +762 -0
- package/dist/cli-mnqa4rp6.js +1563 -0
- package/dist/cli-stmjdgvy.js +2842 -0
- package/dist/cli-y55bgg14.js +6980 -0
- package/dist/cli-ysegdrm5.js +286 -0
- package/dist/cli.js +4779 -1159
- package/dist/harness-github-actions-7t3cncnt.js +95 -0
- package/dist/index-4ser01zr.js +2769 -0
- package/dist/provision-hm1ppwj6.js +74 -0
- package/package.json +3 -2
- package/skills/integration/index.json +2 -2
- package/dist/agent-schemas.d.ts +0 -3
- package/dist/agent-schemas.js +0 -358
- package/dist/agent.d.ts +0 -5
- package/dist/agent.js +0 -477
- package/dist/analyst-tui.d.ts +0 -81
- package/dist/analyst-tui.js +0 -314
- package/dist/api-client.d.ts +0 -46
- package/dist/api-client.js +0 -120
- package/dist/ask-stream.d.ts +0 -146
- package/dist/ask-stream.js +0 -473
- package/dist/auth-storage.d.ts +0 -35
- package/dist/auth-storage.js +0 -159
- package/dist/auth.d.ts +0 -50
- package/dist/auth.js +0 -694
- package/dist/cli-session.d.ts +0 -26
- package/dist/cli-session.js +0 -169
- package/dist/cli.d.ts +0 -44
- package/dist/command-context.d.ts +0 -62
- package/dist/command-context.js +0 -57
- package/dist/command-registry.d.ts +0 -22
- package/dist/command-registry.js +0 -15
- package/dist/config.d.ts +0 -60
- package/dist/config.js +0 -110
- package/dist/doctor.d.ts +0 -79
- package/dist/doctor.js +0 -1182
- package/dist/errors.d.ts +0 -50
- package/dist/errors.js +0 -64
- package/dist/exit-funnel.d.ts +0 -14
- package/dist/exit-funnel.js +0 -53
- package/dist/harness-artifact-bodies.d.ts +0 -28
- package/dist/harness-artifact-bodies.js +0 -276
- package/dist/harness-candidates.d.ts +0 -77
- package/dist/harness-candidates.js +0 -482
- package/dist/harness-remote.d.ts +0 -135
- package/dist/harness-remote.js +0 -540
- package/dist/harness-report.d.ts +0 -145
- package/dist/harness-report.js +0 -1579
- package/dist/harness-sync-limits.d.ts +0 -8
- package/dist/harness-sync-limits.js +0 -21
- package/dist/harness.d.ts +0 -463
- package/dist/harness.js +0 -4009
- package/dist/init/index.d.ts +0 -78
- package/dist/init/index.js +0 -1442
- package/dist/init/mcp.d.ts +0 -2
- package/dist/init/mcp.js +0 -27
- package/dist/init/progress-board.d.ts +0 -80
- package/dist/init/progress-board.js +0 -225
- package/dist/init/rules.d.ts +0 -6
- package/dist/init/rules.js +0 -171
- package/dist/init/sdk-catalog.d.ts +0 -10
- package/dist/init/sdk-catalog.js +0 -80
- package/dist/init/sdk-guide.d.ts +0 -19
- package/dist/init/sdk-guide.js +0 -185
- package/dist/init/sdk-integration.d.ts +0 -53
- package/dist/init/sdk-integration.js +0 -835
- package/dist/init/sdk-protocol.d.ts +0 -135
- package/dist/init/sdk-protocol.js +0 -152
- package/dist/init/sdk-verification.d.ts +0 -27
- package/dist/init/sdk-verification.js +0 -123
- package/dist/init/tenant.d.ts +0 -67
- package/dist/init/tenant.js +0 -421
- package/dist/intelligence-schema.d.ts +0 -349
- package/dist/intelligence-schema.js +0 -174
- package/dist/keys.d.ts +0 -47
- package/dist/keys.js +0 -136
- package/dist/manifest.d.ts +0 -5
- package/dist/manifest.js +0 -114
- package/dist/markdown-terminal.d.ts +0 -49
- package/dist/markdown-terminal.js +0 -212
- package/dist/output.d.ts +0 -191
- package/dist/output.js +0 -624
- package/dist/production-intelligence.d.ts +0 -175
- package/dist/production-intelligence.js +0 -1430
- package/dist/profile-commands.d.ts +0 -10
- package/dist/profile-commands.js +0 -342
- package/dist/profiles.d.ts +0 -112
- package/dist/profiles.js +0 -573
- package/dist/prompts-ab.d.ts +0 -71
- package/dist/prompts-ab.js +0 -313
- package/dist/prompts-propose.d.ts +0 -15
- package/dist/prompts-propose.js +0 -212
- package/dist/prompts.d.ts +0 -58
- package/dist/prompts.js +0 -418
- package/dist/provision.d.ts +0 -28
- package/dist/provision.js +0 -104
- package/dist/redact.d.ts +0 -2
- package/dist/redact.js +0 -90
- package/dist/schemas.d.ts +0 -225
- package/dist/schemas.js +0 -126
- package/dist/skills-bundled.d.ts +0 -35
- package/dist/skills-bundled.js +0 -157
- package/dist/skills-manifest.d.ts +0 -33
- package/dist/skills-manifest.js +0 -244
- package/dist/skills.d.ts +0 -57
- package/dist/skills.js +0 -783
- package/dist/sync.d.ts +0 -5
- package/dist/sync.js +0 -36
- package/dist/telemetry.d.ts +0 -56
- package/dist/telemetry.js +0 -167
- package/dist/transforms.d.ts +0 -25
- package/dist/transforms.js +0 -102
- package/dist/tui.d.ts +0 -48
- package/dist/tui.js +0 -393
- package/dist/update-check.d.ts +0 -31
- package/dist/update-check.js +0 -141
- package/dist/workspace.d.ts +0 -12
- package/dist/workspace.js +0 -1294
package/dist/workspace.js
DELETED
|
@@ -1,1294 +0,0 @@
|
|
|
1
|
-
import { spawnSync } from 'node:child_process';
|
|
2
|
-
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
-
import { appendFileSync, chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync, } from 'node:fs';
|
|
4
|
-
import { tmpdir } from 'node:os';
|
|
5
|
-
import { basename, dirname, join, relative, resolve, sep, } from 'node:path';
|
|
6
|
-
import { fileURLToPath } from 'node:url';
|
|
7
|
-
import { resolveApiKey, resolveIngestUrl, resolveTenantId } from './config.js';
|
|
8
|
-
import { getActiveProfile } from './profiles.js';
|
|
9
|
-
class WorkspaceError extends Error {
|
|
10
|
-
exitCode;
|
|
11
|
-
constructor(message, exitCode = 1) {
|
|
12
|
-
super(message);
|
|
13
|
-
this.exitCode = exitCode;
|
|
14
|
-
this.name = 'WorkspaceError';
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
const MODA_DIR = '.moda';
|
|
18
|
-
const WORKSPACES_DIR = 'workspaces';
|
|
19
|
-
const GIT_STATE_DIR = 'git-state';
|
|
20
|
-
const META_FILE = '.moda-workspace.json';
|
|
21
|
-
const COMMIT_NAME = 'moda-snapshot';
|
|
22
|
-
const COMMIT_EMAIL = 'noreply@moda.dev';
|
|
23
|
-
const ALL_HOOK_AGENTS = ['claude', 'codex', 'cursor'];
|
|
24
|
-
const RSYNC_EXCLUDES = [
|
|
25
|
-
'.git',
|
|
26
|
-
'node_modules',
|
|
27
|
-
'.turbo',
|
|
28
|
-
'dist',
|
|
29
|
-
'build',
|
|
30
|
-
'.next',
|
|
31
|
-
];
|
|
32
|
-
export async function runWorkspaceCommand(args, options = {}) {
|
|
33
|
-
const subcommand = args.positionals[0] || 'log';
|
|
34
|
-
try {
|
|
35
|
-
switch (subcommand) {
|
|
36
|
-
case 'init':
|
|
37
|
-
return workspaceInit(args, options);
|
|
38
|
-
case 'snapshot':
|
|
39
|
-
return await workspaceSnapshot(args, options);
|
|
40
|
-
case 'status':
|
|
41
|
-
return workspaceStatus(args, options);
|
|
42
|
-
case 'explain':
|
|
43
|
-
return workspaceExplain(args, options);
|
|
44
|
-
case 'log':
|
|
45
|
-
return workspaceLog(args, options);
|
|
46
|
-
case 'bundle':
|
|
47
|
-
return workspaceBundle(args, options);
|
|
48
|
-
case 'remove':
|
|
49
|
-
return workspaceRemove(args, options);
|
|
50
|
-
default:
|
|
51
|
-
throw new WorkspaceError(`Unknown workspace command '${subcommand}'. Use init, snapshot, status, explain, log, bundle, or remove.`, 2);
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
catch (error) {
|
|
55
|
-
printWorkspaceError(error);
|
|
56
|
-
return error instanceof WorkspaceError ? error.exitCode : 1;
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
export async function runSparkCommand(context) {
|
|
60
|
-
const subcommand = context.positionals[0] || 'status';
|
|
61
|
-
try {
|
|
62
|
-
switch (subcommand) {
|
|
63
|
-
case 'init':
|
|
64
|
-
context.output.writeData(workspaceInitPayload({ cwd: context.cwd }));
|
|
65
|
-
return { exitCode: 0 };
|
|
66
|
-
case 'snapshot':
|
|
67
|
-
context.output.writeData(await createWorkspaceSnapshotPayload(contextToWorkspaceArgs(context, 'snapshot'), { cwd: context.cwd }));
|
|
68
|
-
return { exitCode: 0 };
|
|
69
|
-
case 'status':
|
|
70
|
-
context.output.writeData(workspaceStatusPayload(context.cwd, context.profile));
|
|
71
|
-
return { exitCode: 0 };
|
|
72
|
-
case 'explain':
|
|
73
|
-
context.output.writeData(workspaceExplainPayload(context.cwd, context.profile));
|
|
74
|
-
return { exitCode: 0 };
|
|
75
|
-
case 'log':
|
|
76
|
-
context.output.writeData(workspaceLogPayload(contextToWorkspaceArgs(context, 'log'), { cwd: context.cwd }));
|
|
77
|
-
return { exitCode: 0 };
|
|
78
|
-
case 'bundle':
|
|
79
|
-
context.output.writeData(workspaceBundlePayload(contextToWorkspaceArgs(context, 'bundle'), { cwd: context.cwd }));
|
|
80
|
-
return { exitCode: 0 };
|
|
81
|
-
case 'remove':
|
|
82
|
-
context.output.writeData(workspaceRemovePayload(contextToWorkspaceArgs(context, 'remove'), { cwd: context.cwd }));
|
|
83
|
-
return { exitCode: 0 };
|
|
84
|
-
default:
|
|
85
|
-
throw new WorkspaceError(`Unknown spark command '${subcommand}'. Use status, explain, snapshot, log, bundle, init, or remove.`, 2);
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
catch (error) {
|
|
89
|
-
if (error instanceof WorkspaceError) {
|
|
90
|
-
throw error;
|
|
91
|
-
}
|
|
92
|
-
throw new WorkspaceError(error instanceof Error ? error.message : String(error));
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
export async function runHooksInstall(args) {
|
|
96
|
-
const subcommand = args.positionals[0] || args.positional;
|
|
97
|
-
if (subcommand && subcommand !== 'install') {
|
|
98
|
-
console.error(`Error: Unknown hooks command '${subcommand}'. Use install.`);
|
|
99
|
-
return 2;
|
|
100
|
-
}
|
|
101
|
-
try {
|
|
102
|
-
installAgentHooks(args.flags);
|
|
103
|
-
return 0;
|
|
104
|
-
}
|
|
105
|
-
catch (error) {
|
|
106
|
-
printWorkspaceError(error);
|
|
107
|
-
return 1;
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
function workspaceInit(args, options) {
|
|
111
|
-
const payload = workspaceInitPayload(options);
|
|
112
|
-
printOutput(args.flags.json === 'true', payload, [
|
|
113
|
-
`workspace_id: ${payload.workspace_id}`,
|
|
114
|
-
`shadow_path: ${payload.shadow_path}`,
|
|
115
|
-
`config_path: ${payload.config_path}`,
|
|
116
|
-
]);
|
|
117
|
-
return 0;
|
|
118
|
-
}
|
|
119
|
-
function workspaceInitPayload(options) {
|
|
120
|
-
const workspace = ensureShadowWorkspace(options.cwd);
|
|
121
|
-
const meta = readWorkspaceMeta(workspace.workspaceId);
|
|
122
|
-
writeWorkspaceMeta(workspace.workspaceId, {
|
|
123
|
-
...meta,
|
|
124
|
-
user_toplevel: workspace.repo.toplevel,
|
|
125
|
-
origin_url: workspace.repo.originUrl,
|
|
126
|
-
user_head_sha: workspace.repo.head,
|
|
127
|
-
updated_at: new Date().toISOString(),
|
|
128
|
-
});
|
|
129
|
-
return {
|
|
130
|
-
workspace_id: workspace.workspaceId,
|
|
131
|
-
shadow_path: workspace.shadowPath,
|
|
132
|
-
config_path: workspace.configPath,
|
|
133
|
-
user_toplevel: workspace.repo.toplevel,
|
|
134
|
-
origin_url: workspace.repo.originUrl,
|
|
135
|
-
user_head_sha: workspace.repo.head,
|
|
136
|
-
};
|
|
137
|
-
}
|
|
138
|
-
/**
|
|
139
|
-
* Read Claude's hook JSON from stdin (fd 0). Returns `{}` on any failure —
|
|
140
|
-
* a malformed or missing hook payload must NEVER fail the snapshot, which
|
|
141
|
-
* also serves as a manual-run path (no stdin, falls through to flag/env
|
|
142
|
-
* defaults).
|
|
143
|
-
*/
|
|
144
|
-
function readHookStdinPayload() {
|
|
145
|
-
try {
|
|
146
|
-
const text = readFileSync(0, 'utf8');
|
|
147
|
-
if (!text.trim())
|
|
148
|
-
return {};
|
|
149
|
-
const parsed = JSON.parse(text);
|
|
150
|
-
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
151
|
-
return {};
|
|
152
|
-
}
|
|
153
|
-
return parsed;
|
|
154
|
-
}
|
|
155
|
-
catch {
|
|
156
|
-
return {};
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
async function workspaceSnapshot(args, options) {
|
|
160
|
-
const payload = await createWorkspaceSnapshotPayload(args, options);
|
|
161
|
-
if (args.flags.agent === 'true') {
|
|
162
|
-
printAgentOutput(args.command || 'workspace', payload);
|
|
163
|
-
}
|
|
164
|
-
else if (args.flags.json === 'true') {
|
|
165
|
-
console.log(JSON.stringify(payload));
|
|
166
|
-
}
|
|
167
|
-
else if (payload.dry_run === true) {
|
|
168
|
-
console.log([
|
|
169
|
-
'Shadow Spark snapshot dry run',
|
|
170
|
-
`workspace_id: ${payload.workspace_id}`,
|
|
171
|
-
`would_change: ${payload.would_change}`,
|
|
172
|
-
`files_considered: ${payload.files_considered}`,
|
|
173
|
-
`fingerprint: ${payload.fingerprint}`,
|
|
174
|
-
].join('\n'));
|
|
175
|
-
}
|
|
176
|
-
else {
|
|
177
|
-
console.log(payload.shadow_sha);
|
|
178
|
-
}
|
|
179
|
-
return 0;
|
|
180
|
-
}
|
|
181
|
-
async function createWorkspaceSnapshotPayload(args, options) {
|
|
182
|
-
const workspace = ensureShadowWorkspace(options.cwd);
|
|
183
|
-
const before = readWorkspaceMeta(workspace.workspaceId);
|
|
184
|
-
// When invoked by `moda-snapshot.sh` Claude's hook context arrives on
|
|
185
|
-
// stdin (session_id, tool_use_id, prompt). Stdin parsing is gated on
|
|
186
|
-
// --hook-stdin so an interactive `moda workspace snapshot` doesn't
|
|
187
|
-
// block waiting for input that will never come.
|
|
188
|
-
const hookStdin = args.flags['hook-stdin'] === 'true'
|
|
189
|
-
? readHookStdinPayload()
|
|
190
|
-
: {};
|
|
191
|
-
const provider = normalizeSnapshotProvider(args.flags.provider || hookStdin.provider);
|
|
192
|
-
const sessionId = hookStdin.session_id ||
|
|
193
|
-
args.flags['session-id'] ||
|
|
194
|
-
process.env.CLAUDE_SESSION_ID ||
|
|
195
|
-
randomUUID();
|
|
196
|
-
const event = hookStdin.hook_event_name ||
|
|
197
|
-
args.flags.event ||
|
|
198
|
-
'UserPromptSubmit';
|
|
199
|
-
// tool_use_id is the documented join key for PostToolUse-derived
|
|
200
|
-
// snapshots (see migration 030 header). Empty for non-tool events.
|
|
201
|
-
const toolUseId = hookStdin.tool_use_id || args.flags['tool-use-id'] || '';
|
|
202
|
-
// sha256(utf8(prompt)) lowercase hex — see migration 030 for the
|
|
203
|
-
// recipe that MUST match the ingest worker's OTLP prompt normalizer.
|
|
204
|
-
const rawPrompt = typeof hookStdin.prompt === 'string' ? hookStdin.prompt : '';
|
|
205
|
-
const promptSha = rawPrompt.length > 0
|
|
206
|
-
? createHash('sha256').update(rawPrompt).digest('hex')
|
|
207
|
-
: '';
|
|
208
|
-
// Legacy --prompt-hash flag is preserved for back-compat with manual
|
|
209
|
-
// callers but no longer set by `moda-snapshot.sh`.
|
|
210
|
-
const promptHash = promptSha || args.flags['prompt-hash'] || process.env.CLAUDE_PROMPT_HASH || '';
|
|
211
|
-
const harness = readHarnessSnapshotContext(workspace.repo.toplevel);
|
|
212
|
-
const profile = safeActiveProfile(args.flags.profile);
|
|
213
|
-
const tenantId = resolveTenantId({ profile });
|
|
214
|
-
const deployment = args.flags.deployment || args.flags.environment || undefined;
|
|
215
|
-
const dryRun = args.flags['dry-run'] === 'true';
|
|
216
|
-
if (dryRun) {
|
|
217
|
-
const files = listSyncFiles(workspace.repo.toplevel);
|
|
218
|
-
const fingerprint = fingerprintFiles(workspace.repo.toplevel, files);
|
|
219
|
-
return {
|
|
220
|
-
schemaVersion: 'shadow_spark_snapshot.v0.1',
|
|
221
|
-
dry_run: true,
|
|
222
|
-
workspace_id: workspace.workspaceId,
|
|
223
|
-
shadow_path: workspace.shadowPath,
|
|
224
|
-
user_head_sha: workspace.repo.head,
|
|
225
|
-
session_id: sessionId,
|
|
226
|
-
event,
|
|
227
|
-
tool_use_id: toolUseId || undefined,
|
|
228
|
-
prompt_sha: promptSha || undefined,
|
|
229
|
-
prompt_hash: promptHash || undefined,
|
|
230
|
-
provider,
|
|
231
|
-
would_change: before.last_fingerprint !== fingerprint,
|
|
232
|
-
fingerprint,
|
|
233
|
-
files_considered: files.length,
|
|
234
|
-
included_files_sample: files.slice(0, 100),
|
|
235
|
-
excluded: RSYNC_EXCLUDES,
|
|
236
|
-
profile,
|
|
237
|
-
tenant_id: tenantId,
|
|
238
|
-
deployment,
|
|
239
|
-
harness_id: harness.harnessId,
|
|
240
|
-
harness_graph_hash: harness.graphHash,
|
|
241
|
-
harness_schema_version: harness.schemaVersion,
|
|
242
|
-
privacy: [
|
|
243
|
-
'Dry-run does not copy files, create commits, append JSONL, or post to Moda Cloud.',
|
|
244
|
-
'Snapshot POSTs include hashes and refs, not raw prompts.',
|
|
245
|
-
],
|
|
246
|
-
};
|
|
247
|
-
}
|
|
248
|
-
const sync = syncTree(workspace.repo.toplevel, workspace.shadowPath);
|
|
249
|
-
const messageParts = ['moda', sessionId];
|
|
250
|
-
if (promptHash)
|
|
251
|
-
messageParts.push(promptHash);
|
|
252
|
-
const commit = sync.changed
|
|
253
|
-
? shadowCommit(workspace.shadowPath, messageParts.join(':'))
|
|
254
|
-
: { sha: before.last_shadow_sha };
|
|
255
|
-
const shadowSha = commit.sha || before.last_shadow_sha;
|
|
256
|
-
const reused = !sync.changed || (Boolean(before.last_shadow_sha) && shadowSha === before.last_shadow_sha);
|
|
257
|
-
const now = new Date().toISOString();
|
|
258
|
-
const meta = {
|
|
259
|
-
...before,
|
|
260
|
-
user_toplevel: workspace.repo.toplevel,
|
|
261
|
-
origin_url: workspace.repo.originUrl,
|
|
262
|
-
user_head_sha: workspace.repo.head,
|
|
263
|
-
last_shadow_sha: shadowSha,
|
|
264
|
-
last_fingerprint: sync.fingerprint,
|
|
265
|
-
updated_at: now,
|
|
266
|
-
};
|
|
267
|
-
writeWorkspaceMeta(workspace.workspaceId, meta);
|
|
268
|
-
const payload = {
|
|
269
|
-
workspace_id: workspace.workspaceId,
|
|
270
|
-
shadow_sha: shadowSha,
|
|
271
|
-
user_head_sha: workspace.repo.head,
|
|
272
|
-
session_id: sessionId,
|
|
273
|
-
event,
|
|
274
|
-
tool_use_id: toolUseId || undefined,
|
|
275
|
-
prompt_sha: promptSha || undefined,
|
|
276
|
-
prompt_hash: promptHash || undefined,
|
|
277
|
-
provider,
|
|
278
|
-
profile,
|
|
279
|
-
tenant_id: tenantId,
|
|
280
|
-
deployment,
|
|
281
|
-
harness_id: harness.harnessId,
|
|
282
|
-
harness_graph_hash: harness.graphHash,
|
|
283
|
-
harness_schema_version: harness.schemaVersion,
|
|
284
|
-
reused,
|
|
285
|
-
fingerprint: sync.fingerprint,
|
|
286
|
-
captured_at: now,
|
|
287
|
-
origin_url: workspace.repo.originUrl,
|
|
288
|
-
user_toplevel: workspace.repo.toplevel,
|
|
289
|
-
};
|
|
290
|
-
appendJsonl(sessionId, payload);
|
|
291
|
-
// Ship snapshot through the existing /v1/otel/claude/hooks endpoint so
|
|
292
|
-
// the shadow_sha / workspace_id / user_head_sha land in
|
|
293
|
-
// moda.coding_agent_events as a `workspace_snapshot` row. Never throws —
|
|
294
|
-
// a network blip can't break the user's Claude session.
|
|
295
|
-
await postSnapshot(payload);
|
|
296
|
-
return payload;
|
|
297
|
-
}
|
|
298
|
-
const SNAPSHOT_POST_TIMEOUT_MS = 2000;
|
|
299
|
-
/**
|
|
300
|
-
* POST the shadow-repo snapshot to the ingest worker's existing Claude
|
|
301
|
-
* Code hooks endpoint, reshaped as a `WorkspaceSnapshot` hook payload
|
|
302
|
-
* (see `coding-agent-claude-hooks.ts` normalizer + migration 030).
|
|
303
|
-
*
|
|
304
|
-
* Failures are swallowed:
|
|
305
|
-
* - no MODA_API_KEY: ingest auth is unavailable, skip silently. The
|
|
306
|
-
* local JSONL still has the snapshot for manual replay.
|
|
307
|
-
* - network error / non-2xx: log nothing visible; the user's Claude
|
|
308
|
-
* session must never be interrupted by an ingest blip.
|
|
309
|
-
* - timeout after SNAPSHOT_POST_TIMEOUT_MS: aborted via AbortController
|
|
310
|
-
* so we don't keep the Node event loop alive after the hook
|
|
311
|
-
* subshell ends.
|
|
312
|
-
*/
|
|
313
|
-
async function postSnapshot(snapshot) {
|
|
314
|
-
// Resolve at call time (not module-load time) so the hook subshell picks
|
|
315
|
-
// up the current env, and so tests can set/unset between cases. resolveApiKey
|
|
316
|
-
// falls back to ~/.moda/config.json (written by `moda init`) so the snapshot
|
|
317
|
-
// works even when MODA_API_KEY is not exported into the shell that launched
|
|
318
|
-
// Claude (e.g. under Infisical or a login-shell that lacks the var).
|
|
319
|
-
const apiKey = resolveApiKey({ profile: snapshot.profile });
|
|
320
|
-
if (!apiKey)
|
|
321
|
-
return;
|
|
322
|
-
const ingestUrl = resolveIngestUrl({ profile: snapshot.profile });
|
|
323
|
-
// End/stop snapshots double as the creds-free "session ended" signal the
|
|
324
|
-
// server-side scheduler polls for (lands as event_type='session_end'); all
|
|
325
|
-
// other events stay 'WorkspaceSnapshot' (event_type='workspace_snapshot').
|
|
326
|
-
// Either way the shadow_sha/workspace_id columns are projected on the row.
|
|
327
|
-
const hook = snapshotEndHook(snapshot);
|
|
328
|
-
const body = {
|
|
329
|
-
hook,
|
|
330
|
-
workspace_id: snapshot.workspace_id,
|
|
331
|
-
shadow_sha: snapshot.shadow_sha,
|
|
332
|
-
user_head_sha: snapshot.user_head_sha,
|
|
333
|
-
origin_url: snapshot.origin_url,
|
|
334
|
-
session_id: snapshot.session_id,
|
|
335
|
-
tool_use_id: snapshot.tool_use_id,
|
|
336
|
-
prompt_sha: snapshot.prompt_sha,
|
|
337
|
-
event: snapshot.event,
|
|
338
|
-
provider: snapshot.provider,
|
|
339
|
-
profile: snapshot.profile,
|
|
340
|
-
tenant_id: snapshot.tenant_id,
|
|
341
|
-
deployment: snapshot.deployment,
|
|
342
|
-
harness_id: snapshot.harness_id,
|
|
343
|
-
harness_graph_hash: snapshot.harness_graph_hash,
|
|
344
|
-
harness_schema_version: snapshot.harness_schema_version,
|
|
345
|
-
timestamp: snapshot.captured_at,
|
|
346
|
-
reused: snapshot.reused,
|
|
347
|
-
fingerprint: snapshot.fingerprint,
|
|
348
|
-
cwd: snapshot.user_toplevel,
|
|
349
|
-
};
|
|
350
|
-
const controller = new AbortController();
|
|
351
|
-
const timeout = setTimeout(() => controller.abort(), SNAPSHOT_POST_TIMEOUT_MS);
|
|
352
|
-
if (typeof timeout.unref === 'function') {
|
|
353
|
-
timeout.unref();
|
|
354
|
-
}
|
|
355
|
-
try {
|
|
356
|
-
await fetch(`${ingestUrl.replace(/\/$/, '')}/v1/otel/${snapshotProviderPath(snapshot.provider)}/hooks`, {
|
|
357
|
-
method: 'POST',
|
|
358
|
-
headers: {
|
|
359
|
-
'Authorization': `Bearer ${apiKey}`,
|
|
360
|
-
'Content-Type': 'application/json',
|
|
361
|
-
},
|
|
362
|
-
body: JSON.stringify(body),
|
|
363
|
-
signal: controller.signal,
|
|
364
|
-
});
|
|
365
|
-
}
|
|
366
|
-
catch {
|
|
367
|
-
// Intentionally swallowed — snapshot POST is best-effort. Local
|
|
368
|
-
// ~/.moda/git-state/{session}.jsonl already has the canonical record.
|
|
369
|
-
}
|
|
370
|
-
finally {
|
|
371
|
-
clearTimeout(timeout);
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
function normalizeSnapshotProvider(value) {
|
|
375
|
-
const normalized = (value || '').trim().toLowerCase().replace(/-/g, '_');
|
|
376
|
-
if (normalized === 'codex')
|
|
377
|
-
return 'codex';
|
|
378
|
-
if (normalized === 'cursor')
|
|
379
|
-
return 'cursor';
|
|
380
|
-
return 'claude_code';
|
|
381
|
-
}
|
|
382
|
-
function snapshotProviderPath(provider) {
|
|
383
|
-
return provider === 'claude_code' ? 'claude' : provider;
|
|
384
|
-
}
|
|
385
|
-
function snapshotEndHook(snapshot) {
|
|
386
|
-
if (snapshot.provider === 'claude_code') {
|
|
387
|
-
return snapshot.event === 'SessionEnd' ? 'SessionEnd' : 'WorkspaceSnapshot';
|
|
388
|
-
}
|
|
389
|
-
return snapshot.event === 'Stop' || snapshot.event === 'SessionEnd'
|
|
390
|
-
? 'Stop'
|
|
391
|
-
: 'WorkspaceSnapshot';
|
|
392
|
-
}
|
|
393
|
-
function workspaceStatus(args, options) {
|
|
394
|
-
const payload = workspaceStatusPayload(options.cwd, args.flags.profile);
|
|
395
|
-
if (args.flags.agent === 'true') {
|
|
396
|
-
printAgentOutput(args.command || 'workspace', payload);
|
|
397
|
-
}
|
|
398
|
-
else {
|
|
399
|
-
printOutput(args.flags.json === 'true', payload, [
|
|
400
|
-
`workspace_id: ${payload.workspace_id}`,
|
|
401
|
-
`shadow_path: ${payload.shadow_path}`,
|
|
402
|
-
`shadow_git: ${payload.shadow_git}`,
|
|
403
|
-
`last_shadow_sha: ${payload.last_shadow_sha || 'none'}`,
|
|
404
|
-
`would_change: ${payload.would_change}`,
|
|
405
|
-
]);
|
|
406
|
-
}
|
|
407
|
-
return 0;
|
|
408
|
-
}
|
|
409
|
-
function workspaceStatusPayload(cwd = process.cwd(), profileFlag) {
|
|
410
|
-
const workspace = resolveShadowWorkspacePaths(cwd);
|
|
411
|
-
const meta = readWorkspaceMeta(workspace.workspaceId);
|
|
412
|
-
const files = listSyncFiles(workspace.repo.toplevel);
|
|
413
|
-
const fingerprint = fingerprintFiles(workspace.repo.toplevel, files);
|
|
414
|
-
const harness = readHarnessSnapshotContext(workspace.repo.toplevel);
|
|
415
|
-
const profile = safeActiveProfile(profileFlag);
|
|
416
|
-
return {
|
|
417
|
-
schemaVersion: 'shadow_spark_status.v0.1',
|
|
418
|
-
workspace_id: workspace.workspaceId,
|
|
419
|
-
shadow_path: workspace.shadowPath,
|
|
420
|
-
config_path: workspace.configPath,
|
|
421
|
-
user_toplevel: workspace.repo.toplevel,
|
|
422
|
-
origin_url: workspace.repo.originUrl,
|
|
423
|
-
user_head_sha: workspace.repo.head,
|
|
424
|
-
shadow_exists: workspace.exists,
|
|
425
|
-
shadow_git: existsSync(join(workspace.shadowPath, '.git')) ? 'present' : 'missing',
|
|
426
|
-
last_shadow_sha: meta.last_shadow_sha,
|
|
427
|
-
last_fingerprint: meta.last_fingerprint,
|
|
428
|
-
current_fingerprint: fingerprint,
|
|
429
|
-
would_change: meta.last_fingerprint !== fingerprint,
|
|
430
|
-
files_considered: files.length,
|
|
431
|
-
updated_at: meta.updated_at,
|
|
432
|
-
profile,
|
|
433
|
-
tenant_id: resolveTenantId({ profile }),
|
|
434
|
-
harness_id: harness.harnessId,
|
|
435
|
-
harness_graph_hash: harness.graphHash,
|
|
436
|
-
next_commands: [
|
|
437
|
-
'moda spark snapshot --dry-run --json',
|
|
438
|
-
'moda spark snapshot --json',
|
|
439
|
-
'moda spark explain',
|
|
440
|
-
],
|
|
441
|
-
};
|
|
442
|
-
}
|
|
443
|
-
function workspaceExplain(args, options) {
|
|
444
|
-
const payload = workspaceExplainPayload(options.cwd, args.flags.profile);
|
|
445
|
-
if (args.flags.agent === 'true') {
|
|
446
|
-
printAgentOutput(args.command || 'workspace', payload);
|
|
447
|
-
}
|
|
448
|
-
else if (args.flags.json === 'true') {
|
|
449
|
-
console.log(JSON.stringify(payload));
|
|
450
|
-
}
|
|
451
|
-
else {
|
|
452
|
-
console.log([
|
|
453
|
-
'Shadow Spark Workspace',
|
|
454
|
-
'',
|
|
455
|
-
'Moda keeps a git-backed shadow copy of this repo so agent sessions can be correlated to exact local code state without touching your git history.',
|
|
456
|
-
`Local path: ${payload.shadow_path}`,
|
|
457
|
-
'',
|
|
458
|
-
'Captures:',
|
|
459
|
-
'- git-tracked and unignored local files',
|
|
460
|
-
'- prompt hash, session id, provider, user head sha, and shadow sha',
|
|
461
|
-
'- harness id/hash when .moda/harness.json exists',
|
|
462
|
-
'',
|
|
463
|
-
'Does not capture:',
|
|
464
|
-
'- raw prompt text in cloud payloads',
|
|
465
|
-
'- .git, node_modules, build output, dist, .next, or .turbo',
|
|
466
|
-
'',
|
|
467
|
-
'Cloud behavior:',
|
|
468
|
-
'- snapshot hooks POST metadata refs best-effort and never block the agent',
|
|
469
|
-
'- local JSONL remains under ~/.moda/git-state for replay/debugging',
|
|
470
|
-
'',
|
|
471
|
-
'Disable/remove:',
|
|
472
|
-
'- remove hook entries from Claude/Codex/Cursor config, or rerun hooks install after editing',
|
|
473
|
-
'- delete the local shadow repo with `moda spark remove --yes`',
|
|
474
|
-
].join('\n'));
|
|
475
|
-
}
|
|
476
|
-
return 0;
|
|
477
|
-
}
|
|
478
|
-
function workspaceExplainPayload(cwd = process.cwd(), profileFlag) {
|
|
479
|
-
const workspace = resolveShadowWorkspacePaths(cwd);
|
|
480
|
-
const harness = readHarnessSnapshotContext(workspace.repo.toplevel);
|
|
481
|
-
const profile = safeActiveProfile(profileFlag);
|
|
482
|
-
return {
|
|
483
|
-
schemaVersion: 'shadow_spark_explain.v0.1',
|
|
484
|
-
name: 'Shadow Spark Workspace',
|
|
485
|
-
workspace_id: workspace.workspaceId,
|
|
486
|
-
shadow_path: workspace.shadowPath,
|
|
487
|
-
shadow_exists: workspace.exists,
|
|
488
|
-
user_toplevel: workspace.repo.toplevel,
|
|
489
|
-
captures: [
|
|
490
|
-
'git-tracked and unignored local files copied into a private shadow git repo',
|
|
491
|
-
'workspace_id, shadow_sha, user_head_sha, session_id, tool_use_id, provider, prompt_sha',
|
|
492
|
-
'harness_id and harness_graph_hash when a local harness manifest exists',
|
|
493
|
-
],
|
|
494
|
-
skips: RSYNC_EXCLUDES,
|
|
495
|
-
cloud_posts: [
|
|
496
|
-
'metadata refs through /v1/otel/{provider}/hooks',
|
|
497
|
-
'no raw prompt text',
|
|
498
|
-
'best-effort only; hook failures are swallowed',
|
|
499
|
-
],
|
|
500
|
-
local_files: [
|
|
501
|
-
'~/.moda/workspaces/<workspace_id>',
|
|
502
|
-
'~/.moda/git-state/<session_id>.jsonl',
|
|
503
|
-
'~/.claude/hooks/moda-snapshot.sh',
|
|
504
|
-
'~/.moda/hooks/moda-snapshot.sh',
|
|
505
|
-
],
|
|
506
|
-
disable_or_remove: [
|
|
507
|
-
'Remove Moda hook entries from ~/.claude/settings.json, ~/.codex/hooks.json, or ~/.cursor/hooks.json.',
|
|
508
|
-
'Run `moda spark remove --yes` to delete the local shadow repo for this project.',
|
|
509
|
-
],
|
|
510
|
-
harness_id: harness.harnessId,
|
|
511
|
-
harness_graph_hash: harness.graphHash,
|
|
512
|
-
profile,
|
|
513
|
-
tenant_id: resolveTenantId({ profile }),
|
|
514
|
-
};
|
|
515
|
-
}
|
|
516
|
-
function workspaceLog(args, options) {
|
|
517
|
-
const payload = workspaceLogPayload(args, options);
|
|
518
|
-
if (args.flags.json === 'true') {
|
|
519
|
-
console.log(JSON.stringify(payload));
|
|
520
|
-
}
|
|
521
|
-
else if (args.flags.agent === 'true') {
|
|
522
|
-
printAgentOutput(args.command || 'workspace', payload);
|
|
523
|
-
}
|
|
524
|
-
else {
|
|
525
|
-
console.log(String(payload.log));
|
|
526
|
-
}
|
|
527
|
-
return 0;
|
|
528
|
-
}
|
|
529
|
-
function workspaceLogPayload(args, options) {
|
|
530
|
-
const workspace = ensureShadowWorkspace(options.cwd);
|
|
531
|
-
const limit = parsePositiveInt(args.flags.limit, 20);
|
|
532
|
-
return {
|
|
533
|
-
workspace_id: workspace.workspaceId,
|
|
534
|
-
shadow_path: workspace.shadowPath,
|
|
535
|
-
log: shadowLog(workspace.shadowPath, limit),
|
|
536
|
-
};
|
|
537
|
-
}
|
|
538
|
-
function workspaceBundle(args, options) {
|
|
539
|
-
const payload = workspaceBundlePayload(args, options);
|
|
540
|
-
if (args.flags.agent === 'true') {
|
|
541
|
-
printAgentOutput(args.command || 'workspace', payload);
|
|
542
|
-
}
|
|
543
|
-
else {
|
|
544
|
-
printOutput(args.flags.json === 'true', payload, [
|
|
545
|
-
`bundle: ${payload.out_path}`,
|
|
546
|
-
`workspace_id: ${payload.workspace_id}`,
|
|
547
|
-
]);
|
|
548
|
-
}
|
|
549
|
-
return 0;
|
|
550
|
-
}
|
|
551
|
-
function workspaceBundlePayload(args, options) {
|
|
552
|
-
const workspace = ensureShadowWorkspace(options.cwd);
|
|
553
|
-
const caseId = args.flags['case-id'];
|
|
554
|
-
const endSha = args.flags['end-sha'];
|
|
555
|
-
const startSha = args.flags['start-sha'] || '';
|
|
556
|
-
const outPath = args.flags.out;
|
|
557
|
-
if (!caseId)
|
|
558
|
-
throw new WorkspaceError('Missing required flag: --case-id ID', 2);
|
|
559
|
-
if (!endSha)
|
|
560
|
-
throw new WorkspaceError('Missing required flag: --end-sha SHA', 2);
|
|
561
|
-
if (!outPath)
|
|
562
|
-
throw new WorkspaceError('Missing required flag: --out PATH', 2);
|
|
563
|
-
if (args.flags['dry-run'] !== 'true') {
|
|
564
|
-
createBundle({
|
|
565
|
-
startSha,
|
|
566
|
-
endSha,
|
|
567
|
-
outPath,
|
|
568
|
-
workspaceId: workspace.workspaceId,
|
|
569
|
-
});
|
|
570
|
-
}
|
|
571
|
-
return {
|
|
572
|
-
workspace_id: workspace.workspaceId,
|
|
573
|
-
case_id: caseId,
|
|
574
|
-
start_sha: startSha,
|
|
575
|
-
end_sha: endSha,
|
|
576
|
-
out_path: resolve(outPath),
|
|
577
|
-
dry_run: args.flags['dry-run'] === 'true',
|
|
578
|
-
};
|
|
579
|
-
}
|
|
580
|
-
function workspaceRemove(args, options) {
|
|
581
|
-
const payload = workspaceRemovePayload(args, options);
|
|
582
|
-
if (args.flags.agent === 'true') {
|
|
583
|
-
printAgentOutput(args.command || 'workspace', payload);
|
|
584
|
-
return 0;
|
|
585
|
-
}
|
|
586
|
-
printOutput(args.flags.json === 'true', payload, [
|
|
587
|
-
`workspace_id: ${payload.workspace_id}`,
|
|
588
|
-
`shadow_path: ${payload.shadow_path}`,
|
|
589
|
-
payload.removed ? 'removed: true' : `removed: false (${payload.reason})`,
|
|
590
|
-
]);
|
|
591
|
-
return 0;
|
|
592
|
-
}
|
|
593
|
-
function workspaceRemovePayload(args, options) {
|
|
594
|
-
const repo = detectUserRepo(options.cwd);
|
|
595
|
-
const id = workspaceId(repo.toplevel, repo.originUrl);
|
|
596
|
-
const path = shadowPath(id);
|
|
597
|
-
const exists = existsSync(path);
|
|
598
|
-
const dryRun = args.flags['dry-run'] === 'true';
|
|
599
|
-
if (!dryRun && args.flags.yes !== 'true') {
|
|
600
|
-
throw new WorkspaceError('Refusing to remove the local shadow workspace without --yes.', 2);
|
|
601
|
-
}
|
|
602
|
-
if (!dryRun) {
|
|
603
|
-
rmSync(path, { recursive: true, force: true });
|
|
604
|
-
}
|
|
605
|
-
return {
|
|
606
|
-
workspace_id: id,
|
|
607
|
-
shadow_path: path,
|
|
608
|
-
existed: exists,
|
|
609
|
-
removed: !dryRun,
|
|
610
|
-
dry_run: dryRun,
|
|
611
|
-
reason: dryRun ? 'dry-run' : undefined,
|
|
612
|
-
};
|
|
613
|
-
}
|
|
614
|
-
function detectUserRepo(cwd = process.cwd()) {
|
|
615
|
-
const toplevelResult = runGit(cwd, ['rev-parse', '--show-toplevel']);
|
|
616
|
-
if (toplevelResult.code !== 0 || !toplevelResult.stdout.trim()) {
|
|
617
|
-
throw new WorkspaceError('Not inside a git repository. Run this command from a project with a git working tree.', 2);
|
|
618
|
-
}
|
|
619
|
-
const toplevel = resolve(toplevelResult.stdout.trim());
|
|
620
|
-
const originResult = runGit(toplevel, ['config', '--get', 'remote.origin.url']);
|
|
621
|
-
const headResult = runGit(toplevel, ['rev-parse', 'HEAD']);
|
|
622
|
-
return {
|
|
623
|
-
toplevel,
|
|
624
|
-
originUrl: originResult.code === 0 ? originResult.stdout.trim() : '',
|
|
625
|
-
head: headResult.code === 0 ? headResult.stdout.trim() : '',
|
|
626
|
-
};
|
|
627
|
-
}
|
|
628
|
-
function workspaceId(toplevel, originUrl) {
|
|
629
|
-
return createHash('sha256')
|
|
630
|
-
.update(`${toplevel}:${originUrl || ''}`)
|
|
631
|
-
.digest('hex')
|
|
632
|
-
.slice(0, 16);
|
|
633
|
-
}
|
|
634
|
-
function shadowPath(id) {
|
|
635
|
-
return join(homeDir(), MODA_DIR, WORKSPACES_DIR, id);
|
|
636
|
-
}
|
|
637
|
-
function resolveShadowWorkspacePaths(cwd = process.cwd()) {
|
|
638
|
-
const repo = detectUserRepo(cwd);
|
|
639
|
-
const id = workspaceId(repo.toplevel, repo.originUrl);
|
|
640
|
-
const path = shadowPath(id);
|
|
641
|
-
return {
|
|
642
|
-
workspaceId: id,
|
|
643
|
-
shadowPath: path,
|
|
644
|
-
configPath: join(path, META_FILE),
|
|
645
|
-
repo,
|
|
646
|
-
exists: existsSync(path),
|
|
647
|
-
};
|
|
648
|
-
}
|
|
649
|
-
function ensureShadowWorkspace(cwd = process.cwd()) {
|
|
650
|
-
const repo = detectUserRepo(cwd);
|
|
651
|
-
const id = workspaceId(repo.toplevel, repo.originUrl);
|
|
652
|
-
const path = shadowPath(id);
|
|
653
|
-
const configPath = join(path, META_FILE);
|
|
654
|
-
mkdirSync(path, { recursive: true });
|
|
655
|
-
if (!existsSync(join(path, '.git'))) {
|
|
656
|
-
const init = runGit(path, ['init']);
|
|
657
|
-
if (init.code !== 0) {
|
|
658
|
-
throw new WorkspaceError(`Failed to initialize shadow git repo: ${init.stderr || init.stdout}`);
|
|
659
|
-
}
|
|
660
|
-
}
|
|
661
|
-
ensureShadowGitExclude(path);
|
|
662
|
-
if (!existsSync(configPath)) {
|
|
663
|
-
writeWorkspaceMeta(id, {
|
|
664
|
-
version: 1,
|
|
665
|
-
workspace_id: id,
|
|
666
|
-
user_toplevel: repo.toplevel,
|
|
667
|
-
origin_url: repo.originUrl,
|
|
668
|
-
user_head_sha: repo.head,
|
|
669
|
-
last_shadow_sha: currentShadowHead(path),
|
|
670
|
-
last_fingerprint: '',
|
|
671
|
-
updated_at: new Date().toISOString(),
|
|
672
|
-
});
|
|
673
|
-
}
|
|
674
|
-
return { workspaceId: id, shadowPath: path, configPath, repo };
|
|
675
|
-
}
|
|
676
|
-
function readWorkspaceMeta(id) {
|
|
677
|
-
const path = join(shadowPath(id), META_FILE);
|
|
678
|
-
if (!existsSync(path)) {
|
|
679
|
-
return {
|
|
680
|
-
version: 1,
|
|
681
|
-
workspace_id: id,
|
|
682
|
-
user_toplevel: '',
|
|
683
|
-
origin_url: '',
|
|
684
|
-
user_head_sha: '',
|
|
685
|
-
last_shadow_sha: '',
|
|
686
|
-
last_fingerprint: '',
|
|
687
|
-
updated_at: new Date().toISOString(),
|
|
688
|
-
};
|
|
689
|
-
}
|
|
690
|
-
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
691
|
-
return {
|
|
692
|
-
version: 1,
|
|
693
|
-
workspace_id: id,
|
|
694
|
-
user_toplevel: parsed.user_toplevel ?? '',
|
|
695
|
-
origin_url: parsed.origin_url ?? '',
|
|
696
|
-
user_head_sha: parsed.user_head_sha ?? '',
|
|
697
|
-
last_shadow_sha: parsed.last_shadow_sha ?? '',
|
|
698
|
-
last_fingerprint: parsed.last_fingerprint ?? '',
|
|
699
|
-
updated_at: parsed.updated_at ?? new Date().toISOString(),
|
|
700
|
-
};
|
|
701
|
-
}
|
|
702
|
-
function writeWorkspaceMeta(id, meta) {
|
|
703
|
-
const path = shadowPath(id);
|
|
704
|
-
mkdirSync(path, { recursive: true });
|
|
705
|
-
writeFileSync(join(path, META_FILE), `${JSON.stringify(meta, null, 2)}\n`);
|
|
706
|
-
}
|
|
707
|
-
function syncTree(toplevel, targetShadowPath) {
|
|
708
|
-
const files = listSyncFiles(toplevel);
|
|
709
|
-
const fingerprint = fingerprintFiles(toplevel, files);
|
|
710
|
-
const meta = readMetaFromShadowPath(targetShadowPath);
|
|
711
|
-
if (meta?.last_fingerprint === fingerprint) {
|
|
712
|
-
return { changed: false, fingerprint };
|
|
713
|
-
}
|
|
714
|
-
cleanShadowTree(targetShadowPath);
|
|
715
|
-
copyListedFiles(toplevel, targetShadowPath, files);
|
|
716
|
-
return { changed: true, fingerprint };
|
|
717
|
-
}
|
|
718
|
-
function shadowCommit(targetShadowPath, message) {
|
|
719
|
-
const add = runGit(targetShadowPath, ['add', '-A']);
|
|
720
|
-
if (add.code !== 0) {
|
|
721
|
-
throw new WorkspaceError(`Failed to stage shadow workspace: ${add.stderr || add.stdout}`);
|
|
722
|
-
}
|
|
723
|
-
const diff = runGit(targetShadowPath, ['diff', '--cached', '--quiet']);
|
|
724
|
-
if (diff.code === 0) {
|
|
725
|
-
return { sha: currentShadowHead(targetShadowPath) };
|
|
726
|
-
}
|
|
727
|
-
if (diff.code !== 1) {
|
|
728
|
-
throw new WorkspaceError(`Failed to inspect shadow changes: ${diff.stderr || diff.stdout}`);
|
|
729
|
-
}
|
|
730
|
-
const commit = runGit(targetShadowPath, [
|
|
731
|
-
'-c',
|
|
732
|
-
`user.name=${COMMIT_NAME}`,
|
|
733
|
-
'-c',
|
|
734
|
-
`user.email=${COMMIT_EMAIL}`,
|
|
735
|
-
'commit',
|
|
736
|
-
'-m',
|
|
737
|
-
message,
|
|
738
|
-
]);
|
|
739
|
-
if (commit.code !== 0) {
|
|
740
|
-
throw new WorkspaceError(`Failed to commit shadow workspace: ${commit.stderr || commit.stdout}`);
|
|
741
|
-
}
|
|
742
|
-
return { sha: currentShadowHead(targetShadowPath) };
|
|
743
|
-
}
|
|
744
|
-
function appendJsonl(sessionId, payload) {
|
|
745
|
-
const dir = join(homeDir(), MODA_DIR, GIT_STATE_DIR);
|
|
746
|
-
mkdirSync(dir, { recursive: true });
|
|
747
|
-
const safeSessionId = sanitizeSessionId(sessionId);
|
|
748
|
-
appendFileSync(join(dir, `${safeSessionId}.jsonl`), `${JSON.stringify(payload)}\n`);
|
|
749
|
-
}
|
|
750
|
-
function shadowLog(targetShadowPath, limit) {
|
|
751
|
-
const result = runGit(targetShadowPath, ['log', '--oneline', '-n', String(limit)]);
|
|
752
|
-
if (result.code === 0)
|
|
753
|
-
return result.stdout.trim();
|
|
754
|
-
const noCommits = runGit(targetShadowPath, ['rev-parse', '--verify', 'HEAD']);
|
|
755
|
-
if (noCommits.code !== 0)
|
|
756
|
-
return '';
|
|
757
|
-
throw new WorkspaceError(`Failed to read shadow log: ${result.stderr || result.stdout}`);
|
|
758
|
-
}
|
|
759
|
-
function createBundle(opts) {
|
|
760
|
-
const targetShadowPath = shadowPath(opts.workspaceId);
|
|
761
|
-
mkdirSync(dirname(resolve(opts.outPath)), { recursive: true });
|
|
762
|
-
const range = opts.startSha ? `${opts.startSha}..${opts.endSha}` : opts.endSha;
|
|
763
|
-
const result = runGit(targetShadowPath, ['bundle', 'create', resolve(opts.outPath), range]);
|
|
764
|
-
if (result.code === 0)
|
|
765
|
-
return;
|
|
766
|
-
if ((result.stderr || result.stdout).includes('Refusing to create empty bundle')) {
|
|
767
|
-
createBundleFromTemporaryRef(targetShadowPath, opts);
|
|
768
|
-
return;
|
|
769
|
-
}
|
|
770
|
-
if (result.code !== 0) {
|
|
771
|
-
throw new WorkspaceError(`Failed to create git bundle: ${result.stderr || result.stdout}`);
|
|
772
|
-
}
|
|
773
|
-
}
|
|
774
|
-
function createBundleFromTemporaryRef(targetShadowPath, opts) {
|
|
775
|
-
const tempRef = `refs/moda/bundles/${Date.now()}-${randomUUID()}`;
|
|
776
|
-
const update = runGit(targetShadowPath, ['update-ref', tempRef, opts.endSha]);
|
|
777
|
-
if (update.code !== 0) {
|
|
778
|
-
throw new WorkspaceError(`Failed to prepare bundle ref: ${update.stderr || update.stdout}`);
|
|
779
|
-
}
|
|
780
|
-
try {
|
|
781
|
-
const bundleArgs = opts.startSha
|
|
782
|
-
? ['bundle', 'create', resolve(opts.outPath), tempRef, `^${opts.startSha}`]
|
|
783
|
-
: ['bundle', 'create', resolve(opts.outPath), tempRef];
|
|
784
|
-
const fallback = runGit(targetShadowPath, bundleArgs);
|
|
785
|
-
if (fallback.code !== 0) {
|
|
786
|
-
throw new WorkspaceError(`Failed to create git bundle: ${fallback.stderr || fallback.stdout}`);
|
|
787
|
-
}
|
|
788
|
-
}
|
|
789
|
-
finally {
|
|
790
|
-
runGit(targetShadowPath, ['update-ref', '-d', tempRef]);
|
|
791
|
-
}
|
|
792
|
-
}
|
|
793
|
-
function runGit(cwd, args) {
|
|
794
|
-
const result = spawnSync('git', args, {
|
|
795
|
-
cwd,
|
|
796
|
-
encoding: 'utf8',
|
|
797
|
-
timeout: 60_000,
|
|
798
|
-
});
|
|
799
|
-
return {
|
|
800
|
-
stdout: result.stdout ?? '',
|
|
801
|
-
stderr: result.stderr ?? result.error?.message ?? '',
|
|
802
|
-
code: typeof result.status === 'number' ? result.status : 1,
|
|
803
|
-
};
|
|
804
|
-
}
|
|
805
|
-
function listSyncFiles(toplevel) {
|
|
806
|
-
const result = runGit(toplevel, ['ls-files', '-z', '--cached', '--others', '--exclude-standard']);
|
|
807
|
-
if (result.code === 0) {
|
|
808
|
-
return result.stdout
|
|
809
|
-
.split('\0')
|
|
810
|
-
.filter((file) => file.length > 0)
|
|
811
|
-
.filter(isSafeRelativePath)
|
|
812
|
-
.sort();
|
|
813
|
-
}
|
|
814
|
-
return listFilesFallback(toplevel).sort();
|
|
815
|
-
}
|
|
816
|
-
function fingerprintFiles(toplevel, files) {
|
|
817
|
-
const hash = createHash('sha256');
|
|
818
|
-
for (const file of files) {
|
|
819
|
-
const absolute = join(toplevel, file);
|
|
820
|
-
if (!existsSync(absolute))
|
|
821
|
-
continue;
|
|
822
|
-
const stat = lstatSync(absolute);
|
|
823
|
-
if (!stat.isFile())
|
|
824
|
-
continue;
|
|
825
|
-
hash.update(`${Math.floor(stat.mtimeMs)}|${stat.size}|${file}\0`);
|
|
826
|
-
}
|
|
827
|
-
return hash.digest('hex');
|
|
828
|
-
}
|
|
829
|
-
function copyListedFiles(toplevel, targetShadowPath, files) {
|
|
830
|
-
const fileList = writeRsyncFileList(files);
|
|
831
|
-
let rsync;
|
|
832
|
-
try {
|
|
833
|
-
rsync = spawnSync('rsync', [
|
|
834
|
-
'-a',
|
|
835
|
-
...RSYNC_EXCLUDES.flatMap((name) => ['--exclude', name]),
|
|
836
|
-
`--files-from=${fileList}`,
|
|
837
|
-
`${withTrailingSep(toplevel)}`,
|
|
838
|
-
targetShadowPath,
|
|
839
|
-
], {
|
|
840
|
-
encoding: 'utf8',
|
|
841
|
-
timeout: 60_000,
|
|
842
|
-
});
|
|
843
|
-
}
|
|
844
|
-
finally {
|
|
845
|
-
// Always clean up the temporary file list so /tmp doesn't grow per snapshot.
|
|
846
|
-
rmSync(fileList, { force: true });
|
|
847
|
-
}
|
|
848
|
-
if (rsync.status === 0)
|
|
849
|
-
return;
|
|
850
|
-
if (rsync.error && 'code' in rsync.error && rsync.error.code !== 'ENOENT') {
|
|
851
|
-
throw new WorkspaceError(`Failed to sync shadow workspace: ${rsync.stderr || rsync.error.message}`);
|
|
852
|
-
}
|
|
853
|
-
if (!rsync.error && rsync.status !== 0) {
|
|
854
|
-
throw new WorkspaceError(`Failed to sync shadow workspace: ${rsync.stderr || rsync.stdout}`);
|
|
855
|
-
}
|
|
856
|
-
for (const file of files) {
|
|
857
|
-
const source = join(toplevel, file);
|
|
858
|
-
if (!existsSync(source) || !lstatSync(source).isFile())
|
|
859
|
-
continue;
|
|
860
|
-
const destination = join(targetShadowPath, file);
|
|
861
|
-
mkdirSync(dirname(destination), { recursive: true });
|
|
862
|
-
copyFileSync(source, destination);
|
|
863
|
-
}
|
|
864
|
-
}
|
|
865
|
-
function writeRsyncFileList(files) {
|
|
866
|
-
const path = join(tmpdir(), `moda-rsync-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
|
867
|
-
writeFileSync(path, files.join('\n'));
|
|
868
|
-
return path;
|
|
869
|
-
}
|
|
870
|
-
function cleanShadowTree(targetShadowPath) {
|
|
871
|
-
for (const entry of readdirSync(targetShadowPath)) {
|
|
872
|
-
if (entry === '.git' || entry === META_FILE)
|
|
873
|
-
continue;
|
|
874
|
-
rmSync(join(targetShadowPath, entry), { recursive: true, force: true });
|
|
875
|
-
}
|
|
876
|
-
}
|
|
877
|
-
function readMetaFromShadowPath(targetShadowPath) {
|
|
878
|
-
const path = join(targetShadowPath, META_FILE);
|
|
879
|
-
if (!existsSync(path))
|
|
880
|
-
return null;
|
|
881
|
-
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
882
|
-
return parsed;
|
|
883
|
-
}
|
|
884
|
-
function currentShadowHead(targetShadowPath) {
|
|
885
|
-
const result = runGit(targetShadowPath, ['rev-parse', 'HEAD']);
|
|
886
|
-
return result.code === 0 ? result.stdout.trim() : '';
|
|
887
|
-
}
|
|
888
|
-
function ensureShadowGitExclude(targetShadowPath) {
|
|
889
|
-
const infoDir = join(targetShadowPath, '.git', 'info');
|
|
890
|
-
const excludePath = join(infoDir, 'exclude');
|
|
891
|
-
mkdirSync(infoDir, { recursive: true });
|
|
892
|
-
const existing = existsSync(excludePath) ? readFileSync(excludePath, 'utf8') : '';
|
|
893
|
-
if (!existing.split(/\r?\n/).includes(META_FILE)) {
|
|
894
|
-
writeFileSync(excludePath, `${existing}${existing.endsWith('\n') || existing.length === 0 ? '' : '\n'}${META_FILE}\n`);
|
|
895
|
-
}
|
|
896
|
-
}
|
|
897
|
-
function listFilesFallback(root) {
|
|
898
|
-
const out = [];
|
|
899
|
-
const excluded = new Set(RSYNC_EXCLUDES);
|
|
900
|
-
function walk(dir) {
|
|
901
|
-
for (const entry of readdirSync(dir)) {
|
|
902
|
-
if (excluded.has(entry))
|
|
903
|
-
continue;
|
|
904
|
-
const absolute = join(dir, entry);
|
|
905
|
-
const rel = relative(root, absolute);
|
|
906
|
-
if (!isSafeRelativePath(rel))
|
|
907
|
-
continue;
|
|
908
|
-
const stat = lstatSync(absolute);
|
|
909
|
-
if (stat.isDirectory()) {
|
|
910
|
-
walk(absolute);
|
|
911
|
-
}
|
|
912
|
-
else if (stat.isFile()) {
|
|
913
|
-
out.push(rel);
|
|
914
|
-
}
|
|
915
|
-
}
|
|
916
|
-
}
|
|
917
|
-
walk(root);
|
|
918
|
-
return out;
|
|
919
|
-
}
|
|
920
|
-
function installAgentHooks(flags) {
|
|
921
|
-
const agents = parseHookAgents(flags.agent);
|
|
922
|
-
const results = agents.map((agent) => {
|
|
923
|
-
if (agent === 'claude')
|
|
924
|
-
return installClaudeHook();
|
|
925
|
-
if (agent === 'codex')
|
|
926
|
-
return installCodexHook();
|
|
927
|
-
return installCursorHook();
|
|
928
|
-
});
|
|
929
|
-
if (results.length === 1) {
|
|
930
|
-
printOutput(flags.json === 'true', results[0].payload, results[0].lines);
|
|
931
|
-
return;
|
|
932
|
-
}
|
|
933
|
-
const payload = {
|
|
934
|
-
agents,
|
|
935
|
-
};
|
|
936
|
-
const lines = [];
|
|
937
|
-
for (const result of results) {
|
|
938
|
-
payload[result.agent] = result.payload;
|
|
939
|
-
lines.push(...result.lines);
|
|
940
|
-
}
|
|
941
|
-
printOutput(flags.json === 'true', payload, lines);
|
|
942
|
-
}
|
|
943
|
-
function parseHookAgents(raw) {
|
|
944
|
-
const value = (raw || 'claude').trim().toLowerCase();
|
|
945
|
-
const requested = value === 'all'
|
|
946
|
-
? ALL_HOOK_AGENTS
|
|
947
|
-
: value.split(',').map((part) => part.trim()).filter(Boolean);
|
|
948
|
-
const agents = [];
|
|
949
|
-
for (const agent of requested) {
|
|
950
|
-
if (!ALL_HOOK_AGENTS.includes(agent)) {
|
|
951
|
-
throw new WorkspaceError(`Unsupported hooks agent '${agent}'. Use one of: claude, codex, cursor, all.`, 2);
|
|
952
|
-
}
|
|
953
|
-
if (!agents.includes(agent)) {
|
|
954
|
-
agents.push(agent);
|
|
955
|
-
}
|
|
956
|
-
}
|
|
957
|
-
return agents.length > 0 ? agents : ['claude'];
|
|
958
|
-
}
|
|
959
|
-
/**
|
|
960
|
-
* Install the Moda Claude Code hooks.
|
|
961
|
-
*
|
|
962
|
-
* The snapshot hook (`moda-snapshot.sh`) is always installed and registered on
|
|
963
|
-
* SessionStart, UserPromptSubmit, PostToolUse, and SessionEnd. The SessionEnd
|
|
964
|
-
* registration posts a creds-free `session_end` signal (only MODA_API_KEY is
|
|
965
|
-
* needed) that `moda skills gen` can process server-side.
|
|
966
|
-
*/
|
|
967
|
-
function installClaudeHook() {
|
|
968
|
-
const claudeHooksDir = join(homeDir(), '.claude', 'hooks');
|
|
969
|
-
mkdirSync(claudeHooksDir, { recursive: true });
|
|
970
|
-
const snapshotHook = installBundledHook(claudeHooksDir, 'moda-snapshot.sh');
|
|
971
|
-
const settingsPath = join(homeDir(), '.claude', 'settings.json');
|
|
972
|
-
const settings = readClaudeSettings(settingsPath);
|
|
973
|
-
const hooks = isRecord(settings.hooks) ? settings.hooks : {};
|
|
974
|
-
settings.hooks = hooks;
|
|
975
|
-
mergeHookEntry(hooks, 'SessionStart', {
|
|
976
|
-
matcher: '*',
|
|
977
|
-
command: '~/.claude/hooks/moda-snapshot.sh SessionStart',
|
|
978
|
-
}, 'moda-snapshot.sh');
|
|
979
|
-
mergeHookEntry(hooks, 'UserPromptSubmit', {
|
|
980
|
-
matcher: '*',
|
|
981
|
-
command: '~/.claude/hooks/moda-snapshot.sh UserPromptSubmit',
|
|
982
|
-
}, 'moda-snapshot.sh');
|
|
983
|
-
mergeHookEntry(hooks, 'PostToolUse', {
|
|
984
|
-
matcher: 'Write|Edit|MultiEdit|Bash',
|
|
985
|
-
command: '~/.claude/hooks/moda-snapshot.sh PostToolUse',
|
|
986
|
-
}, 'moda-snapshot.sh');
|
|
987
|
-
// SessionEnd posts the final snapshot, which the ingest worker stores as a
|
|
988
|
-
// `session_end` event. The server-side scheduler picks it up and runs
|
|
989
|
-
// orchestration. Creds-free: only the snapshot POST (MODA_API_KEY) is needed.
|
|
990
|
-
mergeHookEntry(hooks, 'SessionEnd', {
|
|
991
|
-
matcher: '*',
|
|
992
|
-
command: '~/.claude/hooks/moda-snapshot.sh SessionEnd',
|
|
993
|
-
}, 'moda-snapshot.sh');
|
|
994
|
-
// Wire Claude Code's OTLP exporter so traces/logs/metrics reach Moda without
|
|
995
|
-
// the user exporting OTEL_* by hand. Claude Code reads telemetry config from
|
|
996
|
-
// env (or settings.json's `env` block) — it never looks at ~/.moda/config.json
|
|
997
|
-
// — so this is the only place the resolved API key + ingest URL become visible
|
|
998
|
-
// to the exporter. Returns whether the auth header has a usable key.
|
|
999
|
-
const telemetry = applyTelemetryEnv(settings, 'claude_code');
|
|
1000
|
-
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
1001
|
-
writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
|
|
1002
|
-
// settings.json now embeds the API key in OTEL_EXPORTER_OTLP_HEADERS; tighten
|
|
1003
|
-
// perms to match ~/.moda/config.json (0600). Best-effort on exotic filesystems.
|
|
1004
|
-
chmodSecretConfig(settingsPath);
|
|
1005
|
-
const payload = {
|
|
1006
|
-
hook_path: snapshotHook,
|
|
1007
|
-
settings_path: settingsPath,
|
|
1008
|
-
telemetry_env: true,
|
|
1009
|
-
telemetry_api_key: telemetry.hasApiKey,
|
|
1010
|
-
};
|
|
1011
|
-
const lines = [
|
|
1012
|
-
'Installed Moda Claude Code hooks.',
|
|
1013
|
-
`hook_path: ${snapshotHook}`,
|
|
1014
|
-
];
|
|
1015
|
-
lines.push(`settings_path: ${settingsPath}`);
|
|
1016
|
-
lines.push('Telemetry exporter (traces + logs + metrics) wired into settings.json env.');
|
|
1017
|
-
if (!telemetry.hasApiKey) {
|
|
1018
|
-
lines.push(noTelemetryApiKeyWarning());
|
|
1019
|
-
}
|
|
1020
|
-
return { agent: 'claude', payload, lines };
|
|
1021
|
-
}
|
|
1022
|
-
function installCodexHook() {
|
|
1023
|
-
const hookPath = installSharedSnapshotHook();
|
|
1024
|
-
const hooksPath = join(homeDir(), '.codex', 'hooks.json');
|
|
1025
|
-
const settings = readJsonObject(hooksPath);
|
|
1026
|
-
const hooks = isRecord(settings.hooks) ? settings.hooks : {};
|
|
1027
|
-
settings.hooks = hooks;
|
|
1028
|
-
mergeCodexHookEntry(hooks, 'SessionStart', `${hookPath} codex SessionStart`);
|
|
1029
|
-
mergeCodexHookEntry(hooks, 'UserPromptSubmit', `${hookPath} codex UserPromptSubmit`);
|
|
1030
|
-
mergeCodexHookEntry(hooks, 'PostToolUse', `${hookPath} codex PostToolUse`);
|
|
1031
|
-
mergeCodexHookEntry(hooks, 'Stop', `${hookPath} codex Stop`);
|
|
1032
|
-
const telemetry = applyTelemetryEnv(settings, 'codex');
|
|
1033
|
-
mkdirSync(dirname(hooksPath), { recursive: true });
|
|
1034
|
-
writeFileSync(hooksPath, `${JSON.stringify(settings, null, 2)}\n`);
|
|
1035
|
-
chmodSecretConfig(hooksPath);
|
|
1036
|
-
const payload = {
|
|
1037
|
-
hook_path: hookPath,
|
|
1038
|
-
hooks_path: hooksPath,
|
|
1039
|
-
telemetry_env: true,
|
|
1040
|
-
telemetry_api_key: telemetry.hasApiKey,
|
|
1041
|
-
};
|
|
1042
|
-
const lines = [
|
|
1043
|
-
'Installed Moda Codex hooks.',
|
|
1044
|
-
`hook_path: ${hookPath}`,
|
|
1045
|
-
`hooks_path: ${hooksPath}`,
|
|
1046
|
-
'Telemetry exporter (traces + logs + metrics) wired into hooks.json env.',
|
|
1047
|
-
];
|
|
1048
|
-
if (!telemetry.hasApiKey)
|
|
1049
|
-
lines.push(noTelemetryApiKeyWarning());
|
|
1050
|
-
return { agent: 'codex', payload, lines };
|
|
1051
|
-
}
|
|
1052
|
-
function installCursorHook() {
|
|
1053
|
-
const hookPath = installSharedSnapshotHook();
|
|
1054
|
-
const hooksPath = join(homeDir(), '.cursor', 'hooks.json');
|
|
1055
|
-
const settings = readJsonObject(hooksPath);
|
|
1056
|
-
settings.version = typeof settings.version === 'number' ? settings.version : 1;
|
|
1057
|
-
const hooks = isRecord(settings.hooks) ? settings.hooks : {};
|
|
1058
|
-
settings.hooks = hooks;
|
|
1059
|
-
mergeCursorHookEntry(hooks, 'sessionStart', `${hookPath} cursor SessionStart`);
|
|
1060
|
-
mergeCursorHookEntry(hooks, 'beforeSubmitPrompt', `${hookPath} cursor UserPromptSubmit`);
|
|
1061
|
-
mergeCursorHookEntry(hooks, 'afterShellExecution', `${hookPath} cursor PostToolUse`);
|
|
1062
|
-
mergeCursorHookEntry(hooks, 'sessionEnd', `${hookPath} cursor Stop`);
|
|
1063
|
-
const telemetry = applyTelemetryEnv(settings, 'cursor');
|
|
1064
|
-
mkdirSync(dirname(hooksPath), { recursive: true });
|
|
1065
|
-
writeFileSync(hooksPath, `${JSON.stringify(settings, null, 2)}\n`);
|
|
1066
|
-
chmodSecretConfig(hooksPath);
|
|
1067
|
-
const payload = {
|
|
1068
|
-
hook_path: hookPath,
|
|
1069
|
-
hooks_path: hooksPath,
|
|
1070
|
-
telemetry_env: true,
|
|
1071
|
-
telemetry_api_key: telemetry.hasApiKey,
|
|
1072
|
-
};
|
|
1073
|
-
const lines = [
|
|
1074
|
-
'Installed Moda Cursor hooks.',
|
|
1075
|
-
`hook_path: ${hookPath}`,
|
|
1076
|
-
`hooks_path: ${hooksPath}`,
|
|
1077
|
-
'Telemetry exporter (traces + logs + metrics) wired into hooks.json env.',
|
|
1078
|
-
];
|
|
1079
|
-
if (!telemetry.hasApiKey)
|
|
1080
|
-
lines.push(noTelemetryApiKeyWarning());
|
|
1081
|
-
return { agent: 'cursor', payload, lines };
|
|
1082
|
-
}
|
|
1083
|
-
// Moda-owned keys in settings.json's `env` block. Re-running install
|
|
1084
|
-
// overwrites exactly these (so a rotated key / changed ingest URL propagates)
|
|
1085
|
-
// while leaving any other user-defined env entries untouched.
|
|
1086
|
-
function applyTelemetryEnv(settings, provider) {
|
|
1087
|
-
const apiKey = resolveApiKey();
|
|
1088
|
-
const tenantId = resolveTenantId();
|
|
1089
|
-
const ingestUrl = resolveIngestUrl().replace(/\/+$/, '');
|
|
1090
|
-
const env = isRecord(settings.env) ? settings.env : {};
|
|
1091
|
-
settings.env = env;
|
|
1092
|
-
if (provider === 'claude_code') {
|
|
1093
|
-
env.CLAUDE_CODE_ENABLE_TELEMETRY = '1';
|
|
1094
|
-
}
|
|
1095
|
-
// Capture user prompt text in logs. The ingest worker gates/redacts prompt
|
|
1096
|
-
// content server-side (capture is default-off there), so this only controls
|
|
1097
|
-
// what the local coding agent emits; matches the documented Moda integration.
|
|
1098
|
-
env.OTEL_LOG_USER_PROMPTS = '1';
|
|
1099
|
-
env.OTEL_LOGS_EXPORTER = 'otlp';
|
|
1100
|
-
env.OTEL_METRICS_EXPORTER = 'otlp';
|
|
1101
|
-
env.OTEL_TRACES_EXPORTER = 'otlp';
|
|
1102
|
-
// Logs + metrics ingest as protobuf; the traces endpoint expects JSON
|
|
1103
|
-
// (the worker parses traces via parseOtlpTracesJson).
|
|
1104
|
-
env.OTEL_EXPORTER_OTLP_PROTOCOL = 'http/protobuf';
|
|
1105
|
-
env.OTEL_EXPORTER_OTLP_TRACES_PROTOCOL = 'http/json';
|
|
1106
|
-
const providerPath = snapshotProviderPath(provider);
|
|
1107
|
-
env.OTEL_EXPORTER_OTLP_LOGS_ENDPOINT = `${ingestUrl}/v1/otel/${providerPath}/logs`;
|
|
1108
|
-
env.OTEL_EXPORTER_OTLP_METRICS_ENDPOINT = `${ingestUrl}/v1/otel/${providerPath}/metrics`;
|
|
1109
|
-
env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT = `${ingestUrl}/v1/otel/${providerPath}/traces`;
|
|
1110
|
-
// Tenant is resolved server-side from the API key; company.id is carried as a
|
|
1111
|
-
// resource attribute for context only.
|
|
1112
|
-
env.OTEL_EXPORTER_OTLP_HEADERS = `Authorization=Bearer ${apiKey ?? ''}`;
|
|
1113
|
-
const attrs = [`provider=${provider}`];
|
|
1114
|
-
if (tenantId)
|
|
1115
|
-
attrs.push(`company.id=${tenantId}`);
|
|
1116
|
-
env.OTEL_RESOURCE_ATTRIBUTES = attrs.join(',');
|
|
1117
|
-
return { hasApiKey: Boolean(apiKey) };
|
|
1118
|
-
}
|
|
1119
|
-
function noTelemetryApiKeyWarning() {
|
|
1120
|
-
return 'WARNING: no API key resolved — the OTLP auth header is empty. Run `moda init` ' +
|
|
1121
|
-
'or export MODA_API_KEY, then re-run `moda hooks install`.';
|
|
1122
|
-
}
|
|
1123
|
-
function chmodSecretConfig(path) {
|
|
1124
|
-
try {
|
|
1125
|
-
chmodSync(path, 0o600);
|
|
1126
|
-
}
|
|
1127
|
-
catch {
|
|
1128
|
-
// chmod may fail on some filesystems; the key is still written.
|
|
1129
|
-
}
|
|
1130
|
-
}
|
|
1131
|
-
function installBundledHook(claudeHooksDir, scriptName) {
|
|
1132
|
-
const bundledHook = resolveBundledHookPath(scriptName);
|
|
1133
|
-
const installedHook = join(claudeHooksDir, scriptName);
|
|
1134
|
-
copyFileSync(bundledHook, installedHook);
|
|
1135
|
-
chmodSync(installedHook, 0o755);
|
|
1136
|
-
return installedHook;
|
|
1137
|
-
}
|
|
1138
|
-
function installSharedSnapshotHook() {
|
|
1139
|
-
const hooksDir = join(homeDir(), '.moda', 'hooks');
|
|
1140
|
-
mkdirSync(hooksDir, { recursive: true });
|
|
1141
|
-
return installBundledHook(hooksDir, 'moda-snapshot.sh');
|
|
1142
|
-
}
|
|
1143
|
-
function resolveBundledHookPath(scriptName) {
|
|
1144
|
-
const candidate = fileURLToPath(new URL(`../hooks/${scriptName}`, import.meta.url));
|
|
1145
|
-
if (existsSync(candidate))
|
|
1146
|
-
return candidate;
|
|
1147
|
-
throw new WorkspaceError(`Bundled hook script not found at ${candidate}`);
|
|
1148
|
-
}
|
|
1149
|
-
function readClaudeSettings(settingsPath) {
|
|
1150
|
-
return readJsonObject(settingsPath);
|
|
1151
|
-
}
|
|
1152
|
-
function readJsonObject(settingsPath) {
|
|
1153
|
-
if (!existsSync(settingsPath))
|
|
1154
|
-
return {};
|
|
1155
|
-
const parsed = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
1156
|
-
if (!isRecord(parsed)) {
|
|
1157
|
-
throw new WorkspaceError(`${settingsPath} must contain a JSON object.`);
|
|
1158
|
-
}
|
|
1159
|
-
return parsed;
|
|
1160
|
-
}
|
|
1161
|
-
function mergeHookEntry(hooks, event, entry, scriptName) {
|
|
1162
|
-
const existing = Array.isArray(hooks[event]) ? hooks[event] : [];
|
|
1163
|
-
if (!existing.some((value) => referencesModaHook(value, scriptName))) {
|
|
1164
|
-
// Claude Code's hook schema is { matcher, hooks: [{ type, command }] }.
|
|
1165
|
-
// Emitting the flat { matcher, command } shape trips `/doctor`'s
|
|
1166
|
-
// "hooks: Expected array" validation, so always nest the command.
|
|
1167
|
-
existing.push({
|
|
1168
|
-
matcher: entry.matcher,
|
|
1169
|
-
hooks: [{ type: 'command', command: entry.command }],
|
|
1170
|
-
});
|
|
1171
|
-
}
|
|
1172
|
-
hooks[event] = existing;
|
|
1173
|
-
}
|
|
1174
|
-
function mergeCodexHookEntry(hooks, event, command) {
|
|
1175
|
-
const existing = Array.isArray(hooks[event]) ? hooks[event] : [];
|
|
1176
|
-
if (!existing.some((value) => referencesModaHook(value, 'moda-snapshot.sh'))) {
|
|
1177
|
-
existing.push({
|
|
1178
|
-
hooks: [{ type: 'command', command }],
|
|
1179
|
-
});
|
|
1180
|
-
}
|
|
1181
|
-
hooks[event] = existing;
|
|
1182
|
-
}
|
|
1183
|
-
function mergeCursorHookEntry(hooks, event, command) {
|
|
1184
|
-
const existing = Array.isArray(hooks[event]) ? hooks[event] : [];
|
|
1185
|
-
if (!existing.some((value) => referencesModaHook(value, 'moda-snapshot.sh'))) {
|
|
1186
|
-
existing.push({ command });
|
|
1187
|
-
}
|
|
1188
|
-
hooks[event] = existing;
|
|
1189
|
-
}
|
|
1190
|
-
/**
|
|
1191
|
-
* True if a hook matcher entry already wires up the given Moda hook script.
|
|
1192
|
-
* Handles both the current nested shape ({ hooks: [{ command }] }) and the
|
|
1193
|
-
* legacy flat shape ({ command }) so re-running `moda hooks install` over an
|
|
1194
|
-
* old settings.json dedups instead of appending a duplicate entry.
|
|
1195
|
-
*/
|
|
1196
|
-
function referencesModaHook(value, scriptName) {
|
|
1197
|
-
if (!isRecord(value))
|
|
1198
|
-
return false;
|
|
1199
|
-
if (String(value.command ?? '').includes(scriptName))
|
|
1200
|
-
return true;
|
|
1201
|
-
const nested = Array.isArray(value.hooks) ? value.hooks : [];
|
|
1202
|
-
return nested.some((hook) => isRecord(hook) &&
|
|
1203
|
-
String(hook.command ?? '').includes(scriptName));
|
|
1204
|
-
}
|
|
1205
|
-
function contextToWorkspaceArgs(context, subcommand) {
|
|
1206
|
-
return {
|
|
1207
|
-
command: context.command,
|
|
1208
|
-
positional: subcommand,
|
|
1209
|
-
positionals: [subcommand, ...context.positionals.slice(1)],
|
|
1210
|
-
flags: context.flags,
|
|
1211
|
-
};
|
|
1212
|
-
}
|
|
1213
|
-
function readHarnessSnapshotContext(rootDir) {
|
|
1214
|
-
const path = join(rootDir, MODA_DIR, 'harness.json');
|
|
1215
|
-
if (!existsSync(path))
|
|
1216
|
-
return {};
|
|
1217
|
-
try {
|
|
1218
|
-
const raw = readFileSync(path, 'utf8');
|
|
1219
|
-
const parsed = JSON.parse(raw);
|
|
1220
|
-
if (!isRecord(parsed))
|
|
1221
|
-
return {};
|
|
1222
|
-
const harnessId = typeof parsed.harnessId === 'string' ? parsed.harnessId : undefined;
|
|
1223
|
-
const schemaVersion = typeof parsed.schemaVersion === 'string' ? parsed.schemaVersion : undefined;
|
|
1224
|
-
return {
|
|
1225
|
-
harnessId,
|
|
1226
|
-
schemaVersion,
|
|
1227
|
-
graphHash: createHash('sha256').update(raw).digest('hex'),
|
|
1228
|
-
};
|
|
1229
|
-
}
|
|
1230
|
-
catch {
|
|
1231
|
-
return {};
|
|
1232
|
-
}
|
|
1233
|
-
}
|
|
1234
|
-
function safeActiveProfile(profile) {
|
|
1235
|
-
try {
|
|
1236
|
-
return getActiveProfile({ profile }).name;
|
|
1237
|
-
}
|
|
1238
|
-
catch {
|
|
1239
|
-
return profile;
|
|
1240
|
-
}
|
|
1241
|
-
}
|
|
1242
|
-
function printAgentOutput(command, payload) {
|
|
1243
|
-
console.log(JSON.stringify({
|
|
1244
|
-
schema_version: '2026-06-04',
|
|
1245
|
-
command,
|
|
1246
|
-
status: 'success',
|
|
1247
|
-
data: payload,
|
|
1248
|
-
}, null, 2));
|
|
1249
|
-
}
|
|
1250
|
-
function printOutput(json, payload, lines) {
|
|
1251
|
-
if (json) {
|
|
1252
|
-
console.log(JSON.stringify(payload));
|
|
1253
|
-
}
|
|
1254
|
-
else {
|
|
1255
|
-
console.log(lines.join('\n'));
|
|
1256
|
-
}
|
|
1257
|
-
}
|
|
1258
|
-
function printWorkspaceError(error) {
|
|
1259
|
-
if (error instanceof WorkspaceError) {
|
|
1260
|
-
console.error(`Error: ${error.message}`);
|
|
1261
|
-
}
|
|
1262
|
-
else if (error instanceof Error) {
|
|
1263
|
-
console.error(`Error: ${error.message}`);
|
|
1264
|
-
}
|
|
1265
|
-
else {
|
|
1266
|
-
console.error('Error:', error);
|
|
1267
|
-
}
|
|
1268
|
-
}
|
|
1269
|
-
function parsePositiveInt(value, fallback) {
|
|
1270
|
-
if (!value)
|
|
1271
|
-
return fallback;
|
|
1272
|
-
const parsed = Number.parseInt(value, 10);
|
|
1273
|
-
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
1274
|
-
}
|
|
1275
|
-
function homeDir() {
|
|
1276
|
-
return process.env.HOME || process.env.USERPROFILE || process.cwd();
|
|
1277
|
-
}
|
|
1278
|
-
function sanitizeSessionId(sessionId) {
|
|
1279
|
-
return basename(sessionId || randomUUID()).replace(/[^a-zA-Z0-9._-]/g, '_') || randomUUID();
|
|
1280
|
-
}
|
|
1281
|
-
function isSafeRelativePath(path) {
|
|
1282
|
-
return Boolean(path) &&
|
|
1283
|
-
!path.startsWith('/') &&
|
|
1284
|
-
!path.startsWith(`..${sep}`) &&
|
|
1285
|
-
path !== '..' &&
|
|
1286
|
-
!path.split(/[\\/]/).includes('.git');
|
|
1287
|
-
}
|
|
1288
|
-
function withTrailingSep(path) {
|
|
1289
|
-
return path.endsWith(sep) ? path : `${path}${sep}`;
|
|
1290
|
-
}
|
|
1291
|
-
function isRecord(value) {
|
|
1292
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
1293
|
-
}
|
|
1294
|
-
//# sourceMappingURL=workspace.js.map
|