@moda-ai/cli 1.22.0 → 1.22.2

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.
Files changed (115) hide show
  1. package/dist/cli-g8qs2tj9.js +9777 -0
  2. package/dist/cli-pwbvtes8.js +2312 -0
  3. package/dist/cli-qszcpda3.js +284 -0
  4. package/dist/cli.js +4775 -1159
  5. package/dist/index-6h0nc1r5.js +2765 -0
  6. package/dist/provision-j5gq8cvx.js +72 -0
  7. package/package.json +3 -2
  8. package/skills/integration/index.json +2 -2
  9. package/dist/agent-schemas.d.ts +0 -3
  10. package/dist/agent-schemas.js +0 -358
  11. package/dist/agent.d.ts +0 -5
  12. package/dist/agent.js +0 -477
  13. package/dist/analyst-tui.d.ts +0 -81
  14. package/dist/analyst-tui.js +0 -314
  15. package/dist/api-client.d.ts +0 -46
  16. package/dist/api-client.js +0 -120
  17. package/dist/ask-stream.d.ts +0 -146
  18. package/dist/ask-stream.js +0 -473
  19. package/dist/auth-storage.d.ts +0 -35
  20. package/dist/auth-storage.js +0 -159
  21. package/dist/auth.d.ts +0 -50
  22. package/dist/auth.js +0 -694
  23. package/dist/cli-session.d.ts +0 -26
  24. package/dist/cli-session.js +0 -169
  25. package/dist/cli.d.ts +0 -44
  26. package/dist/command-context.d.ts +0 -62
  27. package/dist/command-context.js +0 -57
  28. package/dist/command-registry.d.ts +0 -22
  29. package/dist/command-registry.js +0 -15
  30. package/dist/config.d.ts +0 -60
  31. package/dist/config.js +0 -110
  32. package/dist/doctor.d.ts +0 -79
  33. package/dist/doctor.js +0 -1182
  34. package/dist/errors.d.ts +0 -50
  35. package/dist/errors.js +0 -64
  36. package/dist/exit-funnel.d.ts +0 -14
  37. package/dist/exit-funnel.js +0 -53
  38. package/dist/harness-artifact-bodies.d.ts +0 -28
  39. package/dist/harness-artifact-bodies.js +0 -276
  40. package/dist/harness-candidates.d.ts +0 -77
  41. package/dist/harness-candidates.js +0 -482
  42. package/dist/harness-remote.d.ts +0 -135
  43. package/dist/harness-remote.js +0 -540
  44. package/dist/harness-report.d.ts +0 -145
  45. package/dist/harness-report.js +0 -1579
  46. package/dist/harness-sync-limits.d.ts +0 -8
  47. package/dist/harness-sync-limits.js +0 -21
  48. package/dist/harness.d.ts +0 -463
  49. package/dist/harness.js +0 -4009
  50. package/dist/init/index.d.ts +0 -78
  51. package/dist/init/index.js +0 -1442
  52. package/dist/init/mcp.d.ts +0 -2
  53. package/dist/init/mcp.js +0 -27
  54. package/dist/init/progress-board.d.ts +0 -80
  55. package/dist/init/progress-board.js +0 -225
  56. package/dist/init/rules.d.ts +0 -6
  57. package/dist/init/rules.js +0 -171
  58. package/dist/init/sdk-catalog.d.ts +0 -10
  59. package/dist/init/sdk-catalog.js +0 -80
  60. package/dist/init/sdk-guide.d.ts +0 -19
  61. package/dist/init/sdk-guide.js +0 -185
  62. package/dist/init/sdk-integration.d.ts +0 -53
  63. package/dist/init/sdk-integration.js +0 -835
  64. package/dist/init/sdk-protocol.d.ts +0 -135
  65. package/dist/init/sdk-protocol.js +0 -152
  66. package/dist/init/sdk-verification.d.ts +0 -27
  67. package/dist/init/sdk-verification.js +0 -123
  68. package/dist/init/tenant.d.ts +0 -67
  69. package/dist/init/tenant.js +0 -421
  70. package/dist/intelligence-schema.d.ts +0 -349
  71. package/dist/intelligence-schema.js +0 -174
  72. package/dist/keys.d.ts +0 -47
  73. package/dist/keys.js +0 -136
  74. package/dist/manifest.d.ts +0 -5
  75. package/dist/manifest.js +0 -114
  76. package/dist/markdown-terminal.d.ts +0 -49
  77. package/dist/markdown-terminal.js +0 -212
  78. package/dist/output.d.ts +0 -191
  79. package/dist/output.js +0 -624
  80. package/dist/production-intelligence.d.ts +0 -175
  81. package/dist/production-intelligence.js +0 -1430
  82. package/dist/profile-commands.d.ts +0 -10
  83. package/dist/profile-commands.js +0 -342
  84. package/dist/profiles.d.ts +0 -112
  85. package/dist/profiles.js +0 -573
  86. package/dist/prompts-ab.d.ts +0 -71
  87. package/dist/prompts-ab.js +0 -313
  88. package/dist/prompts-propose.d.ts +0 -15
  89. package/dist/prompts-propose.js +0 -212
  90. package/dist/prompts.d.ts +0 -58
  91. package/dist/prompts.js +0 -418
  92. package/dist/provision.d.ts +0 -28
  93. package/dist/provision.js +0 -104
  94. package/dist/redact.d.ts +0 -2
  95. package/dist/redact.js +0 -90
  96. package/dist/schemas.d.ts +0 -225
  97. package/dist/schemas.js +0 -126
  98. package/dist/skills-bundled.d.ts +0 -35
  99. package/dist/skills-bundled.js +0 -157
  100. package/dist/skills-manifest.d.ts +0 -33
  101. package/dist/skills-manifest.js +0 -244
  102. package/dist/skills.d.ts +0 -57
  103. package/dist/skills.js +0 -777
  104. package/dist/sync.d.ts +0 -5
  105. package/dist/sync.js +0 -36
  106. package/dist/telemetry.d.ts +0 -56
  107. package/dist/telemetry.js +0 -167
  108. package/dist/transforms.d.ts +0 -25
  109. package/dist/transforms.js +0 -102
  110. package/dist/tui.d.ts +0 -48
  111. package/dist/tui.js +0 -393
  112. package/dist/update-check.d.ts +0 -31
  113. package/dist/update-check.js +0 -141
  114. package/dist/workspace.d.ts +0 -12
  115. 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