@moda-ai/cli 1.3.0 → 1.6.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.js CHANGED
@@ -14,6 +14,7 @@ import { runSyncCommand } from './sync.js';
14
14
  import { runDoctorCommand, runStatusCommand } from './doctor.js';
15
15
  import { CliInputError } from './errors.js';
16
16
  import { isTelemetryDisabled, sendCliUsageTelemetry, stripTelemetryFlag, } from './telemetry.js';
17
+ import { isUpdateCheckDisabled, printUpdateNotice, startUpdateCheck, stripUpdateCheckFlag, } from './update-check.js';
17
18
  import { createCommandContext } from './command-context.js';
18
19
  import { createCommandRegistry } from './command-registry.js';
19
20
  import { createAgentErrorEnvelope, errorToAgentError, outputModeFromFlags, } from './output.js';
@@ -160,6 +161,8 @@ Commands:
160
161
  skills gen Kick off tenant-wide skill generation
161
162
  skills pull Pull tenant-generated skills into .claude/skills (and .cursor/rules)
162
163
  skills pull --status=proposed Include not-yet-promoted skills (default: approved)
164
+ skills sync Push local .claude/skills/**/SKILL.md up to Moda
165
+ skills sync --dry-run Show what would be pushed without writing
163
166
 
164
167
  Options:
165
168
  --help Show this help message
@@ -178,6 +181,7 @@ Common flags:
178
181
  --yes Accept explicit automation defaults
179
182
  --tui Force the human analyst dashboard when supported
180
183
  --no-tui Show raw analyst stream instead of dashboard
184
+ --no-update-check Skip the daily new-version check (or MODA_CLI_UPDATE_CHECK=0)
181
185
 
182
186
  Runs flags:
183
187
  --outcome=OUTCOME Filter by outcome (running|succeeded|partial|failed|loop|abandoned|unknown)
@@ -190,10 +194,11 @@ Init flags:
190
194
  Choose harness analyst adapter
191
195
  --tui Show full-screen analyst dashboard in human TTYs
192
196
  --no-tui Show raw external analyst output
193
- --sdk-guide Write .moda/SDK_INTEGRATION.md
194
- --no-sdk-guide Skip SDK integration guide creation
197
+ --sdk-integrate Run your coding agent to install the Moda SDK and instrument LLM calls
198
+ --no-sdk-integrate Skip SDK integration (alias: --no-sdk-guide)
195
199
  --ci-cd Create GitHub Actions Moda validation workflow
196
200
  --no-ci-cd Skip CI/CD workflow creation
201
+ --offline Run final validation offline (skip the online telemetry check)
197
202
 
198
203
  Examples:
199
204
  moda init
@@ -576,7 +581,7 @@ export const commandRegistry = createCommandRegistry([
576
581
  {
577
582
  name: 'init',
578
583
  description: 'Set up Moda in your project',
579
- examples: ['moda init', 'moda init --coding-agent-traces', 'moda init --ci-cd'],
584
+ examples: ['moda init', 'moda init --sdk-integrate', 'moda init --ci-cd'],
580
585
  auth: 'none',
581
586
  outputModes: ['human', 'json', 'agent'],
582
587
  mutability: 'write',
@@ -900,8 +905,8 @@ export const commandRegistry = createCommandRegistry([
900
905
  // },
901
906
  {
902
907
  name: 'skills',
903
- description: 'Generate and pull Moda skills',
904
- examples: ['moda skills gen', 'moda skills pull'],
908
+ description: 'Generate, pull, and sync Moda skills',
909
+ examples: ['moda skills gen', 'moda skills pull', 'moda skills sync'],
905
910
  auth: 'none',
906
911
  outputModes: ['human', 'json', 'agent'],
907
912
  mutability: 'write',
@@ -961,8 +966,9 @@ function printAgentCliError(command, error) {
961
966
  }
962
967
  export async function dispatchParsedCommand(parsed, startedAt = Date.now()) {
963
968
  const { command, flags } = parsed;
964
- const commandFlags = stripTelemetryFlag(flags);
969
+ const commandFlags = stripUpdateCheckFlag(stripTelemetryFlag(flags));
965
970
  const telemetryDisabled = isTelemetryDisabled(flags);
971
+ const updateCheckDisabled = isUpdateCheckDisabled(flags);
966
972
  if (command === '--version' || command === '-V' || flags.version === 'true') {
967
973
  console.log(`moda-cli ${VERSION}`);
968
974
  return 0;
@@ -989,6 +995,11 @@ export async function dispatchParsedCommand(parsed, startedAt = Date.now()) {
989
995
  if (definition.name === 'agent' && commandFlags.json !== 'true') {
990
996
  commandFlags.agent = 'true';
991
997
  }
998
+ // Kick off the registry check before the handler so the (at most daily)
999
+ // request overlaps with command work instead of delaying exit.
1000
+ if (!updateCheckDisabled) {
1001
+ void startUpdateCheck();
1002
+ }
992
1003
  if (definition.validateConfigBeforeRun) {
993
1004
  validateConfig();
994
1005
  }
@@ -1014,6 +1025,9 @@ export async function dispatchParsedCommand(parsed, startedAt = Date.now()) {
1014
1025
  apiRequestCount: getApiRequestCount(),
1015
1026
  });
1016
1027
  }
1028
+ if (!updateCheckDisabled) {
1029
+ printUpdateNotice();
1030
+ }
1017
1031
  return result.exitCode;
1018
1032
  }
1019
1033
  catch (error) {
@@ -1035,6 +1049,9 @@ export async function dispatchParsedCommand(parsed, startedAt = Date.now()) {
1035
1049
  else {
1036
1050
  printCliError(error);
1037
1051
  }
1052
+ if (!updateCheckDisabled) {
1053
+ printUpdateNotice();
1054
+ }
1038
1055
  return 1;
1039
1056
  }
1040
1057
  }
package/dist/harness.d.ts CHANGED
@@ -222,9 +222,40 @@ export interface HarnessSyncState {
222
222
  privacy: string[];
223
223
  written: string[];
224
224
  }
225
+ type HarnessAnalystAdapter = 'claude' | 'codex' | 'cursor' | 'local-scan';
226
+ type ExternalHarnessAnalystAdapter = Exclude<HarnessAnalystAdapter, 'local-scan'>;
227
+ export type CodingAgentMode = 'read-only' | 'write';
225
228
  export declare function runHarnessCommand(context: CommandContext): Promise<{
226
229
  exitCode: number;
227
230
  }>;
231
+ export type CodingAgentAdapter = ExternalHarnessAnalystAdapter;
232
+ export interface CodingAgentRun {
233
+ adapter: CodingAgentAdapter;
234
+ command: string;
235
+ status: number | null;
236
+ stdout: string;
237
+ stderr: string;
238
+ timedOut: boolean;
239
+ failedToStart: boolean;
240
+ }
241
+ /** Coding-agent CLIs found on PATH, ranked best-first (claude > codex > cursor). */
242
+ export declare function detectAvailableCodingAgents(): Array<{
243
+ adapter: CodingAgentAdapter;
244
+ command: string;
245
+ }>;
246
+ export declare function codingAgentDisplayName(adapter: CodingAgentAdapter): string;
247
+ /** Spawn an installed coding-agent CLI with a prompt; capture its output. */
248
+ export declare function runCodingAgent(options: {
249
+ adapter: CodingAgentAdapter;
250
+ prompt: string;
251
+ rootDir: string;
252
+ mode: CodingAgentMode;
253
+ env?: NodeJS.ProcessEnv;
254
+ stream?: boolean;
255
+ timeoutMs?: number;
256
+ progressToStderr?: boolean;
257
+ onProgress?: (message: string, phase?: string) => void;
258
+ }): Promise<CodingAgentRun>;
228
259
  export declare function scanHarness(options?: ScanOptions): HarnessGraph;
229
260
  export declare function summarizeHarness(graph: HarnessGraph): HarnessSummary;
230
261
  export declare function readHarnessSyncState(rootDir: string): HarnessSyncState | undefined;
package/dist/harness.js CHANGED
@@ -1215,6 +1215,7 @@ function normalizeAnalystAdapter(raw) {
1215
1215
  throw new CliInputError(`Unknown harness analyst '${raw}'.`, 'Use `--analyst=claude|codex|cursor|local-scan`.');
1216
1216
  }
1217
1217
  function resolveExternalHarnessAnalystInvocation(options) {
1218
+ const mode = options.mode ?? 'read-only';
1218
1219
  const override = options.flags['analyst-command'] || process.env.MODA_HARNESS_ANALYST_COMMAND;
1219
1220
  if (override) {
1220
1221
  const [command, ...args] = splitCommandLine(override);
@@ -1229,16 +1230,19 @@ function resolveExternalHarnessAnalystInvocation(options) {
1229
1230
  }
1230
1231
  if (options.adapter === 'claude') {
1231
1232
  const extraArgs = externalAnalystExtraArgs(options.adapter);
1233
+ // Write mode auto-accepts edits and unblocks the tools an integration needs
1234
+ // (Edit/Write/Bash for package installs); read-only stays in plan mode with a
1235
+ // safe-Bash allowlist so the harness analyst can never mutate the tree.
1236
+ const permission = mode === 'write'
1237
+ ? ['--permission-mode', 'acceptEdits', '--allowedTools', 'Read,Grep,Glob,Edit,Write,Bash']
1238
+ : ['--permission-mode', 'plan', '--allowedTools', 'Read,Grep,Glob,Bash(pwd),Bash(ls *),Bash(find *),Bash(rg *),Bash(grep *),Bash(cat *),Bash(git status *),Bash(git ls-files *),Bash(git grep *)'];
1232
1239
  return {
1233
1240
  adapter: options.adapter,
1234
1241
  command,
1235
1242
  args: [
1236
1243
  '--print',
1237
1244
  ...extraArgs,
1238
- '--permission-mode',
1239
- 'plan',
1240
- '--allowedTools',
1241
- 'Read,Grep,Glob,Bash(pwd),Bash(ls *),Bash(find *),Bash(rg *),Bash(grep *),Bash(cat *),Bash(git status *),Bash(git ls-files *),Bash(git grep *)',
1245
+ ...permission,
1242
1246
  ...(options.stream ? ['--verbose'] : []),
1243
1247
  '--output-format',
1244
1248
  options.stream ? 'stream-json' : 'text',
@@ -1256,7 +1260,7 @@ function resolveExternalHarnessAnalystInvocation(options) {
1256
1260
  'exec',
1257
1261
  '--skip-git-repo-check',
1258
1262
  '--sandbox',
1259
- 'read-only',
1263
+ mode === 'write' ? 'workspace-write' : 'read-only',
1260
1264
  ...(options.stream ? ['--json'] : []),
1261
1265
  '-C',
1262
1266
  options.rootDir,
@@ -1271,10 +1275,7 @@ function resolveExternalHarnessAnalystInvocation(options) {
1271
1275
  args: cursorSubcommandArgs(command, [
1272
1276
  '--print',
1273
1277
  '--trust',
1274
- '--mode',
1275
- 'plan',
1276
- '--sandbox',
1277
- 'enabled',
1278
+ ...(mode === 'write' ? ['--force'] : ['--mode', 'plan', '--sandbox', 'enabled']),
1278
1279
  '--workspace',
1279
1280
  options.rootDir,
1280
1281
  '--output-format',
@@ -1284,6 +1285,49 @@ function resolveExternalHarnessAnalystInvocation(options) {
1284
1285
  streamEvents: options.stream,
1285
1286
  };
1286
1287
  }
1288
+ /** Coding-agent CLIs found on PATH, ranked best-first (claude > codex > cursor). */
1289
+ export function detectAvailableCodingAgents() {
1290
+ return rankHarnessAnalysts(detectAvailableHarnessAnalysts());
1291
+ }
1292
+ export function codingAgentDisplayName(adapter) {
1293
+ return analystDisplayName(adapter);
1294
+ }
1295
+ /** Spawn an installed coding-agent CLI with a prompt; capture its output. */
1296
+ export async function runCodingAgent(options) {
1297
+ const stream = options.stream ?? false;
1298
+ const invocation = resolveExternalHarnessAnalystInvocation({
1299
+ adapter: options.adapter,
1300
+ flags: {},
1301
+ prompt: options.prompt,
1302
+ rootDir: options.rootDir,
1303
+ stream,
1304
+ mode: options.mode,
1305
+ });
1306
+ const timeoutMs = options.timeoutMs ?? numericEnv('MODA_HARNESS_ANALYST_TIMEOUT_MS', 20 * 60 * 1000);
1307
+ const result = await runExternalAnalystWithRetries(invocation, {
1308
+ cwd: options.rootDir,
1309
+ env: buildExternalAnalystEnv(options.env ?? process.env),
1310
+ inheritedOutput: false,
1311
+ timeoutMs,
1312
+ maxBuffer: 20 * 1024 * 1024,
1313
+ progress: Boolean(options.onProgress) || Boolean(options.progressToStderr),
1314
+ progressToStderr: options.progressToStderr ?? false,
1315
+ streamEvents: stream,
1316
+ adapter: invocation.adapter,
1317
+ maxAttempts: externalAnalystMaxAttempts(options.adapter),
1318
+ backoffMs: numericEnvAllowZero('MODA_HARNESS_ANALYST_RETRY_BACKOFF_MS', 6_000),
1319
+ onProgress: options.onProgress,
1320
+ });
1321
+ return {
1322
+ adapter: invocation.adapter,
1323
+ command: invocation.command,
1324
+ status: result.status,
1325
+ stdout: result.stdout,
1326
+ stderr: result.stderr,
1327
+ timedOut: result.timedOut,
1328
+ failedToStart: Boolean(result.error),
1329
+ };
1330
+ }
1287
1331
  function externalAnalystExtraArgs(adapter) {
1288
1332
  return splitCommandLine(process.env[`MODA_HARNESS_ANALYST_${adapter.toUpperCase()}_ARGS`] ?? '');
1289
1333
  }
@@ -6,15 +6,16 @@ import { login, loadToken } from '../auth.js';
6
6
  import { selectTenantAndCreateKey } from './tenant.js';
7
7
  import { detectAgents } from './mcp.js';
8
8
  import { installRules } from './rules.js';
9
- import { initPrompts } from '../prompts.js';
10
- import { pullSkills } from '../skills.js';
9
+ import { initPrompts, runPromptSync } from '../prompts.js';
10
+ import { runSkillSync } from '../skills.js';
11
11
  // Coding-agent capture is temporarily disabled — see the hooks phase below.
12
12
  // import { runHooksInstall } from '../workspace.js';
13
13
  import { SDK_GUIDE_PATH, writeSdkIntegrationGuide } from './sdk-guide.js';
14
+ import { runSdkIntegration } from './sdk-integration.js';
14
15
  import { createCommandContext } from '../command-context.js';
15
16
  import { CliInputError } from '../errors.js';
16
17
  import { runHarnessCommand } from '../harness.js';
17
- import { runDoctorCommand } from '../doctor.js';
18
+ import { buildDoctorReport } from '../doctor.js';
18
19
  import { renderInitBanner, renderInitComplete, renderInitNote, renderInitSetupPlan, renderInitStep, } from '../tui.js';
19
20
  export async function runInit(flags, options = {}) {
20
21
  const explicitYes = flags.yes === true ||
@@ -30,9 +31,7 @@ export async function runInit(flags, options = {}) {
30
31
  if (human)
31
32
  console.log(message);
32
33
  };
33
- // `warn` is only used by the temporarily-disabled hooks phase below; keep the
34
- // underscore-prefixed binding so re-enabling it is a rename away.
35
- const _warn = (message) => {
34
+ const warn = (message) => {
36
35
  if (human)
37
36
  console.error(message);
38
37
  };
@@ -102,6 +101,32 @@ export async function runInit(flags, options = {}) {
102
101
  if (human)
103
102
  printHarnessDetectionSummary();
104
103
  emit('harness', 'Harness discovery complete.', 'completed');
104
+ // Upload the cited harness graph so Moda's backend has the understanding, not
105
+ // just local artifacts. `harness sync --from-report` refuses an unapproved
106
+ // report, so approve it first. Best-effort: a network blip on either call must
107
+ // not abort onboarding — auth + key provisioning already succeeded.
108
+ try {
109
+ await runHarnessCommand(createCommandContext({
110
+ command: 'harness',
111
+ positional: 'approve',
112
+ positionals: ['approve'],
113
+ flags: commandFlags({ yes: true, json: json || agent, agent, quiet: true, 'no-telemetry': true }),
114
+ }));
115
+ await runHarnessCommand(createCommandContext({
116
+ command: 'harness',
117
+ positional: 'sync',
118
+ positionals: ['sync'],
119
+ flags: commandFlags({ 'from-report': true, yes: true, json: json || agent, agent, quiet: true, 'no-telemetry': true }),
120
+ }));
121
+ log(renderInitNote('Synced harness graph to Moda'));
122
+ emit('harness', 'Harness graph synced to Moda.', 'completed');
123
+ }
124
+ catch (err) {
125
+ const message = err instanceof Error ? err.message : String(err);
126
+ warn(renderInitNote(`Harness sync skipped: ${message}`, 'warning'));
127
+ warn(' Run `moda harness approve --yes && moda harness sync --from-report` later to upload.');
128
+ emit('harness', 'Harness sync skipped.', 'warning');
129
+ }
105
130
  phases.push({ id: 'harness', title: 'Harness Discovery', status: 'completed' });
106
131
  // Step 5: Setup plan
107
132
  log(renderInitStep(5, 7, 'Setup Plan', 'Every write/sync action is explicit before it is applied.'));
@@ -109,7 +134,7 @@ export async function runInit(flags, options = {}) {
109
134
  // Coding-agent capture is temporarily disabled; never prompt for or install trace hooks.
110
135
  // installHooks: await shouldIngestCodingAgentTraces(flags, yes),
111
136
  installHooks: false,
112
- installSdkGuide: await shouldCreateSdkGuide(flags, yes),
137
+ integrateSdk: await shouldIntegrateSdk(flags, yes),
113
138
  configureCiCd: await shouldConfigureCiCd(flags, yes),
114
139
  });
115
140
  log(renderInitSetupPlan(setupPlan));
@@ -140,34 +165,69 @@ export async function runInit(flags, options = {}) {
140
165
  const agents = detectAgents();
141
166
  if (agents.length > 0) {
142
167
  log(renderInitNote(`Detected coding agents: ${agents.join(', ')}`));
168
+ // Install only the moda-cli skill so those agents can query Moda analytics
169
+ // from the terminal. SDK integration is a one-shot agent run below, not a
170
+ // persistent skill.
143
171
  await installRules(agents, { yes, silent: !human });
144
- // Pull any skills the pipeline has already generated for this tenant into
145
- // the local agent skill dirs. Best-effort: a brand-new tenant has none yet,
146
- // and a transient ingest/network error must not fail setup.
147
- try {
148
- const pulled = await pullSkills({ silent: true });
149
- if (pulled.wrote + pulled.updated > 0) {
150
- log(renderInitNote(`Pulled ${pulled.wrote + pulled.updated} learned skill(s) into agent skills`));
151
- }
152
- }
153
- catch {
154
- // Silent: skills are additive; never block init on them.
155
- }
156
172
  }
157
173
  else {
158
174
  log(renderInitNote('No coding agents detected, skipping', 'skipped'));
159
175
  }
160
176
  await initPrompts({ silent: true });
161
177
  log(renderInitNote('Created or verified .moda/prompts.yml'));
162
- const installSdkGuide = setupPlan.find((action) => action.category === 'sdk')?.approved === true;
163
- if (installSdkGuide) {
164
- const guidePath = writeSdkIntegrationGuide();
165
- log(renderInitNote(`Wrote ${guidePath}`));
166
- markApplied(setupPlan, 'sdk');
178
+ // Push the manifest's prompt files to Moda so production traces can attribute
179
+ // managed prompt versions. A fresh repo has none yet — that's a harmless no-op.
180
+ try {
181
+ const promptSync = await runPromptSync({}, { profile: stringFlag(flags.profile) });
182
+ log(renderInitNote(promptSync.synced.length > 0
183
+ ? `Synced ${promptSync.synced.length} prompt(s) to Moda`
184
+ : 'No prompt files to sync yet (add paths to .moda/prompts.yml)'));
185
+ }
186
+ catch (err) {
187
+ const message = err instanceof Error ? err.message : String(err);
188
+ warn(renderInitNote(`Prompt sync skipped: ${message}`, 'warning'));
189
+ }
190
+ // Push locally-authored skills (`.claude/skills/**/SKILL.md`) to Moda, in
191
+ // parallel intent with prompt sync. A fresh repo has none yet — harmless
192
+ // no-op. Best-effort: a network blip must not abort onboarding.
193
+ try {
194
+ const skillSync = await runSkillSync({}, { profile: stringFlag(flags.profile) });
195
+ if (skillSync.synced.length > 0) {
196
+ log(renderInitNote(`Synced ${skillSync.synced.length} skill(s) to Moda`));
197
+ }
198
+ else {
199
+ log(renderInitNote('No local skills to sync yet (add .claude/skills/<name>/SKILL.md)'));
200
+ }
201
+ }
202
+ catch (err) {
203
+ const message = err instanceof Error ? err.message : String(err);
204
+ warn(renderInitNote(`Skill sync skipped: ${message}`, 'warning'));
205
+ }
206
+ const sdkAction = setupPlan.find((action) => action.category === 'sdk');
207
+ if (sdkAction?.approved === true) {
208
+ log(renderInitNote('Running your coding agent to integrate the Moda SDK (this can take a few minutes)...', 'warning'));
209
+ const result = await runSdkIntegration({ human });
210
+ if (result.status === 'integrated') {
211
+ log(renderInitNote(`Integrated the Moda SDK with ${result.adapter}. Review the diff before committing.`));
212
+ markApplied(setupPlan, 'sdk');
213
+ }
214
+ else if (result.status === 'no-agent') {
215
+ const guidePath = writeSdkIntegrationGuide();
216
+ sdkAction.files = [guidePath];
217
+ log(renderInitNote(`No coding agent CLI found; wrote ${guidePath} to integrate by hand`, 'warning'));
218
+ markApplied(setupPlan, 'sdk');
219
+ }
220
+ else {
221
+ const guidePath = writeSdkIntegrationGuide();
222
+ sdkAction.files = [guidePath];
223
+ warn(renderInitNote(`SDK integration via ${result.adapter} did not complete: ${result.detail}`, 'warning'));
224
+ log(renderInitNote(`Wrote ${guidePath} so you can finish the integration manually`));
225
+ markApplied(setupPlan, 'sdk');
226
+ }
167
227
  }
168
228
  else {
169
- log(renderInitNote('Skipped SDK integration guide', 'skipped'));
170
- markApplied(setupPlan, 'sdk', 'User skipped SDK integration guide.');
229
+ log(renderInitNote('Skipped SDK integration', 'skipped'));
230
+ markApplied(setupPlan, 'sdk', 'User skipped SDK integration.');
171
231
  }
172
232
  const configureCiCd = setupPlan.find((action) => action.category === 'ci-cd')?.approved === true;
173
233
  if (configureCiCd) {
@@ -182,22 +242,26 @@ export async function runInit(flags, options = {}) {
182
242
  markApplied(setupPlan, 'skills');
183
243
  emit('apply', 'Approved changes applied.', 'completed');
184
244
  phases.push({ id: 'apply', title: 'Apply Approved Changes', status: 'completed' });
185
- log(renderInitStep(7, 7, 'Validate', 'Running local doctor checks so the setup leaves a clear state.'));
186
- emit('validate', 'Running local Moda doctor diagnostics.');
187
- await runDoctorCommand(createCommandContext({
245
+ const offlineValidation = isTruthyFlag(flagValue(flags, 'offline'));
246
+ log(renderInitStep(7, 7, 'Validate', offlineValidation
247
+ ? 'Running local doctor checks so the setup leaves a clear state.'
248
+ : 'Running Moda doctor online to confirm setup and that telemetry is reaching Moda.'));
249
+ emit('validate', `Running Moda doctor diagnostics (${offlineValidation ? 'offline' : 'online'}).`);
250
+ const doctorReport = await buildDoctorReport(createCommandContext({
188
251
  command: 'doctor',
189
252
  positional: undefined,
190
253
  positionals: [],
191
- flags: commandFlags({
192
- json: true,
193
- quiet: true,
194
- offline: true,
195
- 'no-telemetry': true,
196
- }),
197
- }));
254
+ flags: commandFlags({ json: true, quiet: true, 'no-telemetry': true }),
255
+ }), { offline: offlineValidation });
198
256
  markApplied(setupPlan, 'validation');
199
- log(renderInitNote('Local validation checks completed'));
200
- emit('validate', 'Local validation checks complete.', 'completed');
257
+ log(renderInitNote(`Validation checks completed: ${doctorReport.summary.pass} passing, ${doctorReport.summary.warning} warning, ${doctorReport.summary.fail} failing`));
258
+ if (!offlineValidation) {
259
+ const cloud = doctorReport.checks.find((check) => check.id === 'cloud_data_flow');
260
+ if (cloud) {
261
+ log(renderInitNote(`Moda Cloud telemetry: ${cloud.status}`, cloud.status === 'pass' ? undefined : 'warning'));
262
+ }
263
+ }
264
+ emit('validate', 'Validation checks complete.', doctorReport.summary.fail > 0 ? 'warning' : 'completed');
201
265
  phases.push({ id: 'validate', title: 'Validate', status: 'completed' });
202
266
  // Done
203
267
  log(renderInitComplete({
@@ -212,7 +276,7 @@ function buildSetupPlan(options) {
212
276
  return [
213
277
  {
214
278
  category: 'harness',
215
- title: 'Analyze harness and write cited Moda report',
279
+ title: 'Analyze harness, write cited report, and sync the graph to Moda',
216
280
  status: 'planned',
217
281
  approved: true,
218
282
  files: [
@@ -222,7 +286,7 @@ function buildSetupPlan(options) {
222
286
  '.moda/harness.json',
223
287
  '.moda/HARNESS.md',
224
288
  ],
225
- command: 'moda harness analyze --analyst=auto --summary',
289
+ command: 'moda harness analyze --analyst=auto --summary && moda harness approve --yes && moda harness sync --from-report',
226
290
  },
227
291
  {
228
292
  category: 'hooks',
@@ -235,30 +299,34 @@ function buildSetupPlan(options) {
235
299
  },
236
300
  {
237
301
  category: 'prompts',
238
- title: 'Initialize prompt registry manifest',
302
+ title: 'Initialize the prompt registry manifest and sync prompts to Moda',
239
303
  status: 'planned',
240
304
  approved: true,
241
305
  files: ['.moda/prompts.yml'],
242
- command: 'moda prompts init',
306
+ command: 'moda prompts init && moda prompts sync',
243
307
  },
244
308
  {
245
309
  category: 'skills',
246
- title: 'Pull approved Moda skills when available',
310
+ title: 'Install the moda-cli skill into detected coding agents and sync local skills to Moda',
247
311
  status: 'planned',
248
312
  approved: true,
249
313
  files: ['.claude/skills', '.cursor/rules'],
250
- command: 'moda skills pull',
314
+ // Skill install into detected agents happens inside `moda init`; there is no
315
+ // standalone install command, so surface only the re-runnable sync here.
316
+ command: 'moda skills sync',
251
317
  },
252
318
  {
253
319
  category: 'sdk',
254
- title: 'Write SDK integration guide',
255
- status: options.installSdkGuide ? 'planned' : 'manual',
256
- approved: options.installSdkGuide,
257
- files: [SDK_GUIDE_PATH],
258
- command: 'open .moda/SDK_INTEGRATION.md',
259
- reason: options.installSdkGuide
260
- ? 'Guide only; package installs and source edits remain human-approved/manual.'
261
- : 'SDK package installation and code instrumentation stay human-approved/manual.',
320
+ title: 'Run a coding agent to integrate the Moda SDK',
321
+ status: options.integrateSdk ? 'planned' : 'manual',
322
+ approved: options.integrateSdk,
323
+ // Files are repo-specific and unknown ahead of the agent run; the fallback
324
+ // path rewrites this to the guide path when no agent CLI is available.
325
+ files: [],
326
+ command: 'moda init',
327
+ reason: options.integrateSdk
328
+ ? 'Installs the SDK package and edits source via your coding agent; review the diff before committing.'
329
+ : `SDK integration stays manual; see ${SDK_GUIDE_PATH} if you generate the guide.`,
262
330
  },
263
331
  {
264
332
  category: 'ci-cd',
@@ -271,11 +339,11 @@ function buildSetupPlan(options) {
271
339
  },
272
340
  {
273
341
  category: 'validation',
274
- title: 'Run local Moda doctor diagnostics',
342
+ title: 'Run Moda doctor online to confirm telemetry is reaching Moda',
275
343
  status: 'planned',
276
344
  approved: true,
277
345
  files: [],
278
- command: 'moda doctor',
346
+ command: 'moda doctor --online',
279
347
  },
280
348
  ];
281
349
  }
@@ -383,16 +451,19 @@ async function shouldConfigureCiCd(flags, yes) {
383
451
  default: false,
384
452
  });
385
453
  }
386
- async function shouldCreateSdkGuide(flags, yes) {
387
- const explicit = parseOptionalBoolean(flagValue(flags, 'sdk-guide', 'sdk_guide', 'sdk'));
454
+ async function shouldIntegrateSdk(flags, yes) {
455
+ const explicit = parseOptionalBoolean(flagValue(flags, 'sdk-integrate', 'sdk_integrate', 'sdk-guide', 'sdk_guide', 'sdk'));
388
456
  if (explicit !== undefined)
389
457
  return explicit;
390
- if (isTruthyFlag(flagValue(flags, 'no-sdk-guide', 'no_sdk_guide')))
458
+ if (isTruthyFlag(flagValue(flags, 'no-sdk-integrate', 'no_sdk_integrate', 'no-sdk-guide', 'no_sdk_guide', 'no-sdk', 'no_sdk'))) {
391
459
  return false;
460
+ }
461
+ // SDK integration now installs packages and edits source via a coding agent.
462
+ // Never do that unattended (--yes/--json/--agent); require an explicit opt-in.
392
463
  if (yes)
393
- return true;
464
+ return false;
394
465
  return confirm({
395
- message: 'Create a Moda SDK integration guide? (No packages installed, no source files edited.)',
466
+ message: 'Run your coding agent now to install the Moda SDK and instrument your LLM calls? (Edits source, installs packages.)',
396
467
  default: true,
397
468
  });
398
469
  }
@@ -1,3 +1,21 @@
1
1
  export declare const SDK_GUIDE_PATH = ".moda/SDK_INTEGRATION.md";
2
+ type RuntimeSummary = {
3
+ runtimeAgents: number;
4
+ agentFamilies: number;
5
+ frameworks: string[];
6
+ providers: string[];
7
+ runtimeAgentNames: string[];
8
+ };
9
+ export type SdkGuideContext = {
10
+ languages: string[];
11
+ installCommands: string[];
12
+ providerHints: string[];
13
+ frameworkHints: string[];
14
+ packageManagers: string[];
15
+ runtimeSummary: RuntimeSummary;
16
+ };
2
17
  export declare function writeSdkIntegrationGuide(cwd?: string): string;
18
+ export declare function detectSdkGuideContext(cwd: string): SdkGuideContext;
19
+ export declare function renderSdkIntegrationGuide(context: SdkGuideContext): string;
20
+ export {};
3
21
  //# sourceMappingURL=sdk-guide.d.ts.map
@@ -8,7 +8,7 @@ export function writeSdkIntegrationGuide(cwd = process.cwd()) {
8
8
  writeFileSync(absolutePath, renderSdkIntegrationGuide(detectSdkGuideContext(cwd)));
9
9
  return relativePath;
10
10
  }
11
- function detectSdkGuideContext(cwd) {
11
+ export function detectSdkGuideContext(cwd) {
12
12
  const languages = [];
13
13
  const installCommands = [];
14
14
  const packageManagers = [];
@@ -64,7 +64,7 @@ function detectSdkGuideContext(cwd) {
64
64
  runtimeSummary,
65
65
  };
66
66
  }
67
- function renderSdkIntegrationGuide(context) {
67
+ export function renderSdkIntegrationGuide(context) {
68
68
  const languages = context.languages.length > 0 ? context.languages.join(', ') : 'Not detected yet';
69
69
  const packageManagers = context.packageManagers.length > 0 ? context.packageManagers.join(', ') : 'Not detected yet';
70
70
  const installCommands = context.installCommands.length > 0
@@ -0,0 +1,26 @@
1
+ import { type CodingAgentAdapter } from '../harness.js';
2
+ export type SdkIntegrationResult = {
3
+ status: 'integrated';
4
+ adapter: CodingAgentAdapter;
5
+ } | {
6
+ status: 'no-agent';
7
+ } | {
8
+ status: 'failed';
9
+ adapter: CodingAgentAdapter;
10
+ detail: string;
11
+ };
12
+ export interface SdkIntegrationOptions {
13
+ cwd?: string;
14
+ /** Explicit adapter override (e.g. from --analyst); otherwise best-ranked installed CLI. */
15
+ adapter?: CodingAgentAdapter;
16
+ /** Stream the agent's progress to the user (human terminals only). */
17
+ human?: boolean;
18
+ onProgress?: (message: string) => void;
19
+ }
20
+ /**
21
+ * Pick an installed coding-agent CLI and run it, in write mode, to integrate the
22
+ * Moda SDK into the repo. Returns `no-agent` (without throwing) when no CLI is
23
+ * installed so the caller can fall back to writing the static guide.
24
+ */
25
+ export declare function runSdkIntegration(options?: SdkIntegrationOptions): Promise<SdkIntegrationResult>;
26
+ //# sourceMappingURL=sdk-integration.d.ts.map
@@ -0,0 +1,80 @@
1
+ // src/init/sdk-integration.ts — Run an installed coding agent to integrate the
2
+ // Moda SDK directly, instead of leaving a guide for the user to action by hand.
3
+ //
4
+ // This reuses the same coding-agent spawn machinery as `moda harness analyze`
5
+ // (see src/harness.ts), but in write mode: the agent is expected to install the
6
+ // SDK package and edit source to instrument LLM calls.
7
+ import { codingAgentDisplayName, detectAvailableCodingAgents, runCodingAgent, } from '../harness.js';
8
+ import { detectSdkGuideContext, renderSdkIntegrationGuide } from './sdk-guide.js';
9
+ /**
10
+ * Pick an installed coding-agent CLI and run it, in write mode, to integrate the
11
+ * Moda SDK into the repo. Returns `no-agent` (without throwing) when no CLI is
12
+ * installed so the caller can fall back to writing the static guide.
13
+ */
14
+ export async function runSdkIntegration(options = {}) {
15
+ const cwd = options.cwd ?? process.cwd();
16
+ const available = detectAvailableCodingAgents();
17
+ if (available.length === 0)
18
+ return { status: 'no-agent' };
19
+ const adapter = options.adapter && available.some((item) => item.adapter === options.adapter)
20
+ ? options.adapter
21
+ : available[0].adapter;
22
+ const prompt = renderSdkIntegrationPrompt(cwd);
23
+ const human = options.human ?? false;
24
+ const emit = (message) => {
25
+ options.onProgress?.(message);
26
+ if (human)
27
+ process.stderr.write(` ${codingAgentDisplayName(adapter)}: ${message}\n`);
28
+ };
29
+ const run = await runCodingAgent({
30
+ adapter,
31
+ prompt,
32
+ rootDir: cwd,
33
+ mode: 'write',
34
+ stream: human,
35
+ progressToStderr: false,
36
+ onProgress: (message) => emit(message),
37
+ });
38
+ if (run.failedToStart) {
39
+ return { status: 'failed', adapter, detail: `${codingAgentDisplayName(adapter)} CLI failed to start.` };
40
+ }
41
+ if (run.timedOut) {
42
+ return { status: 'failed', adapter, detail: `${codingAgentDisplayName(adapter)} timed out during SDK integration.` };
43
+ }
44
+ if (run.status !== 0) {
45
+ const tail = `${run.stdout}\n${run.stderr}`.trim().slice(-800);
46
+ return {
47
+ status: 'failed',
48
+ adapter,
49
+ detail: `${codingAgentDisplayName(adapter)} exited with status ${run.status ?? 'unknown'}.${tail ? `\n${tail}` : ''}`,
50
+ };
51
+ }
52
+ return { status: 'integrated', adapter };
53
+ }
54
+ function renderSdkIntegrationPrompt(cwd) {
55
+ const guide = renderSdkIntegrationGuide(detectSdkGuideContext(cwd));
56
+ return [
57
+ 'You are running inside `moda init` as a one-shot SDK integration task. The user has',
58
+ 'approved letting you install packages and edit source files in THIS repository to add',
59
+ 'Moda LLM observability. Do the integration now — do not just describe it.',
60
+ '',
61
+ 'Scope and safety rules:',
62
+ '- Only touch this repository (the working directory you were launched in).',
63
+ '- Install the Moda SDK with the package manager this repo already uses.',
64
+ '- Instrument the real LLM call sites; do not invent files or frameworks that are not here.',
65
+ '- Never write MODA_API_KEY (or any secret) into source files, and never create or edit',
66
+ ' .env files. The key belongs in the environment / a secret manager at runtime.',
67
+ '- Make the smallest correct change. Match the existing code style and imports.',
68
+ '- If something is ambiguous or the repo has no LLM calls to instrument, stop and explain',
69
+ ' rather than guessing.',
70
+ '',
71
+ 'Follow this Moda-authored integration spec exactly (it reflects the detected languages,',
72
+ 'package managers, providers, and frameworks for this repo):',
73
+ '',
74
+ guide,
75
+ '',
76
+ 'When finished, print a short summary of: the package(s) installed, the files you edited,',
77
+ 'and any step a human still needs to do (e.g. setting MODA_API_KEY in the deploy env).',
78
+ ].join('\n');
79
+ }
80
+ //# sourceMappingURL=sdk-integration.js.map
package/dist/prompts.d.ts CHANGED
@@ -3,4 +3,20 @@ export declare function runPromptsCommand(positionals: string[], flags: Record<s
3
3
  export declare function initPrompts(options?: {
4
4
  silent?: boolean;
5
5
  }): Promise<void>;
6
+ export interface PromptSyncResult {
7
+ synced: Array<{
8
+ key: string;
9
+ promptId?: string;
10
+ versionId?: string;
11
+ contentHash: string;
12
+ sourcePath?: string;
13
+ }>;
14
+ dryRun: boolean;
15
+ }
16
+ /**
17
+ * Sync the manifest's prompt files to the backend and update the lockfile.
18
+ * Returns the synced list without printing, so callers (e.g. `moda init`)
19
+ * control their own output. `moda prompts sync` wraps this with JSON output.
20
+ */
21
+ export declare function runPromptSync(flags: Record<string, string>, profileOptions: ProfileIOOptions): Promise<PromptSyncResult>;
6
22
  //# sourceMappingURL=prompts.d.ts.map
package/dist/prompts.js CHANGED
@@ -59,7 +59,12 @@ async function syncPrompts(flags, profileOptions) {
59
59
  }
60
60
  await syncPromptsOnce(flags, profileOptions);
61
61
  }
62
- async function syncPromptsOnce(flags, profileOptions) {
62
+ /**
63
+ * Sync the manifest's prompt files to the backend and update the lockfile.
64
+ * Returns the synced list without printing, so callers (e.g. `moda init`)
65
+ * control their own output. `moda prompts sync` wraps this with JSON output.
66
+ */
67
+ export async function runPromptSync(flags, profileOptions) {
63
68
  await initPrompts({ silent: true });
64
69
  const prompts = discoverPrompts();
65
70
  const sourceCommit = getGitCommit();
@@ -81,9 +86,13 @@ async function syncPromptsOnce(flags, profileOptions) {
81
86
  if (!dryRun) {
82
87
  writeLock(prompts, response.synced ?? []);
83
88
  }
89
+ return { synced: response.synced ?? [], dryRun };
90
+ }
91
+ async function syncPromptsOnce(flags, profileOptions) {
92
+ const result = await runPromptSync(flags, profileOptions);
84
93
  printJson({
85
- synced: response.synced ?? [],
86
- lockfile: dryRun ? undefined : LOCK_PATH,
94
+ synced: result.synced,
95
+ lockfile: result.dryRun ? undefined : LOCK_PATH,
87
96
  });
88
97
  }
89
98
  async function watchPromptSync(flags, profileOptions) {
package/dist/skills.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ParsedArgs } from './cli.js';
2
+ import type { ProfileIOOptions } from './profiles.js';
2
3
  interface PullSummary {
3
4
  total: number;
4
5
  pruned: number;
@@ -17,6 +18,31 @@ export declare function pullSkills(options?: {
17
18
  cwd?: string;
18
19
  silent?: boolean;
19
20
  }): Promise<PullSummary>;
21
+ export interface SkillSyncResult {
22
+ synced: Array<{
23
+ key: string;
24
+ skillId?: string;
25
+ versionId?: string;
26
+ version?: number;
27
+ contentHash: string;
28
+ changed?: boolean;
29
+ sourcePath?: string;
30
+ }>;
31
+ dryRun: boolean;
32
+ }
33
+ /**
34
+ * Push locally-authored `.claude/skills/**\/SKILL.md` files up to Moda via
35
+ * `POST /api/skills/sync`. Modeled on `runPromptSync`: returns the synced list
36
+ * without printing so callers (e.g. `moda init`) control their own output.
37
+ * `moda skills sync` wraps this with JSON output.
38
+ *
39
+ * Skills that Moda itself manages — those previously pulled/generated (tracked
40
+ * in `.moda/skills.yml`) and the shipped `moda-cli` skill — are excluded so we
41
+ * only push skills the user authored.
42
+ */
43
+ export declare function runSkillSync(flags?: Record<string, string>, profileOptions?: ProfileIOOptions, options?: {
44
+ cwd?: string;
45
+ }): Promise<SkillSyncResult>;
20
46
  /**
21
47
  * `moda skills <subcommand>` dispatcher.
22
48
  */
package/dist/skills.js CHANGED
@@ -15,9 +15,11 @@
15
15
  // Skills are managed artifacts. A pull installs the latest server-published
16
16
  // generation snapshot, tracked in .moda/skills.yml, and prunes only files that a
17
17
  // previous Moda pull wrote.
18
+ import { execFileSync } from 'node:child_process';
18
19
  import { createHash } from 'node:crypto';
19
- import { existsSync, mkdirSync, readdirSync, readFileSync, rmdirSync, unlinkSync, writeFileSync, } from 'node:fs';
20
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmdirSync, unlinkSync, writeFileSync, } from 'node:fs';
20
21
  import { basename, dirname, isAbsolute, join, normalize, relative, resolve } from 'node:path';
22
+ import { callControlAPI } from './api-client.js';
21
23
  import { resolveApiKey, resolveIngestUrl, resolveTenantId } from './config.js';
22
24
  import { claudeSkillToCursorRule } from './init/rules.js';
23
25
  import { detectAgents } from './init/mcp.js';
@@ -189,6 +191,157 @@ function recordOutcome(summary, status, log, relativePath) {
189
191
  break;
190
192
  }
191
193
  }
194
+ const SKILLS_DIR = join('.claude', 'skills');
195
+ const SKILL_FILE = 'SKILL.md';
196
+ const MAX_SKILL_WALK_DEPTH = 4;
197
+ /**
198
+ * Push locally-authored `.claude/skills/**\/SKILL.md` files up to Moda via
199
+ * `POST /api/skills/sync`. Modeled on `runPromptSync`: returns the synced list
200
+ * without printing so callers (e.g. `moda init`) control their own output.
201
+ * `moda skills sync` wraps this with JSON output.
202
+ *
203
+ * Skills that Moda itself manages — those previously pulled/generated (tracked
204
+ * in `.moda/skills.yml`) and the shipped `moda-cli` skill — are excluded so we
205
+ * only push skills the user authored.
206
+ */
207
+ export async function runSkillSync(flags = {}, profileOptions = {}, options = {}) {
208
+ const cwd = options.cwd ?? process.cwd();
209
+ const dryRun = flags['dry-run'] === 'true';
210
+ const skills = discoverLocalSkills(cwd);
211
+ if (skills.length === 0) {
212
+ return { synced: [], dryRun };
213
+ }
214
+ const sourceCommit = getGitCommit(cwd);
215
+ const response = (await callControlAPI('/skills/sync', {
216
+ method: 'POST',
217
+ body: JSON.stringify({
218
+ skills: skills.map((skill) => ({
219
+ key: skill.key,
220
+ name: skill.name,
221
+ description: skill.description,
222
+ skillMd: skill.skillMd,
223
+ sourcePath: skill.sourcePath,
224
+ contentHash: skill.contentHash,
225
+ sourceCommit,
226
+ })),
227
+ sourceCommit,
228
+ dryRun,
229
+ }),
230
+ }, profileOptions));
231
+ return { synced: response.synced ?? [], dryRun };
232
+ }
233
+ /**
234
+ * Discover user-authored skills under `.claude/skills/`. Excludes Moda-managed
235
+ * skills (the `.moda/skills.yml` managed paths and the shipped `moda-cli`
236
+ * skill) and validates each directory key.
237
+ */
238
+ function discoverLocalSkills(cwd) {
239
+ const skillsRoot = join(cwd, SKILLS_DIR);
240
+ if (!existsSync(skillsRoot))
241
+ return [];
242
+ const managed = new Set(managedSkillPaths(cwd));
243
+ const skills = [];
244
+ for (const absPath of walkSkillFiles(skillsRoot, 0)) {
245
+ const sourcePath = relative(cwd, absPath).replace(/\\/g, '/');
246
+ if (managed.has(sourcePath))
247
+ continue;
248
+ const key = basename(dirname(absPath));
249
+ // Never re-upload Moda's own shipped skill; only accept plain identifiers.
250
+ if (key === 'moda-cli' || !/^[A-Za-z0-9_-]+$/.test(key))
251
+ continue;
252
+ let content;
253
+ try {
254
+ content = readFileSync(absPath, 'utf8');
255
+ }
256
+ catch {
257
+ continue;
258
+ }
259
+ if (!content.trim())
260
+ continue;
261
+ const { name, description } = parseSkillFrontmatter(content, key);
262
+ skills.push({
263
+ key,
264
+ name,
265
+ description,
266
+ skillMd: content,
267
+ sourcePath,
268
+ contentHash: sha256(content),
269
+ });
270
+ }
271
+ return skills.sort((a, b) => a.key.localeCompare(b.key));
272
+ }
273
+ /** Managed SKILL.md paths recorded by a previous `moda skills pull`. */
274
+ function managedSkillPaths(cwd) {
275
+ const manifest = readSkillsManifest(cwd);
276
+ if (!manifest)
277
+ return [];
278
+ return manifest.managedPaths.filter((path) => path.startsWith('.claude/skills/') && path.endsWith('/SKILL.md'));
279
+ }
280
+ /** Recursively find `SKILL.md` files, never following symlinks. */
281
+ function walkSkillFiles(dir, depth) {
282
+ if (depth > MAX_SKILL_WALK_DEPTH)
283
+ return [];
284
+ let entries;
285
+ try {
286
+ entries = readdirSync(dir);
287
+ }
288
+ catch {
289
+ return [];
290
+ }
291
+ const files = [];
292
+ for (const entry of entries) {
293
+ const fullPath = join(dir, entry);
294
+ let stat;
295
+ try {
296
+ stat = lstatSync(fullPath);
297
+ }
298
+ catch {
299
+ continue;
300
+ }
301
+ if (stat.isSymbolicLink())
302
+ continue;
303
+ if (stat.isDirectory()) {
304
+ files.push(...walkSkillFiles(fullPath, depth + 1));
305
+ }
306
+ else if (stat.isFile() && entry === SKILL_FILE) {
307
+ files.push(fullPath);
308
+ }
309
+ }
310
+ return files;
311
+ }
312
+ /** Pull `name`/`description` from a SKILL.md YAML frontmatter block. */
313
+ function parseSkillFrontmatter(raw, fallbackKey) {
314
+ let name = fallbackKey;
315
+ let description;
316
+ if (raw.startsWith('---')) {
317
+ const end = raw.indexOf('\n---', 3);
318
+ if (end !== -1) {
319
+ const frontmatter = raw.slice(3, end);
320
+ const nameMatch = frontmatter.match(/^name:\s*(.+)$/m);
321
+ const descMatch = frontmatter.match(/^description:\s*(.+)$/m);
322
+ if (nameMatch)
323
+ name = stripQuotes(nameMatch[1].trim()) || fallbackKey;
324
+ if (descMatch)
325
+ description = stripQuotes(descMatch[1].trim()) || undefined;
326
+ }
327
+ }
328
+ return { name, description };
329
+ }
330
+ function stripQuotes(value) {
331
+ return value.replace(/^["']|["']$/g, '');
332
+ }
333
+ function getGitCommit(cwd) {
334
+ try {
335
+ return execFileSync('git', ['rev-parse', 'HEAD'], {
336
+ cwd,
337
+ encoding: 'utf8',
338
+ stdio: ['ignore', 'pipe', 'ignore'],
339
+ }).trim();
340
+ }
341
+ catch {
342
+ return undefined;
343
+ }
344
+ }
192
345
  /**
193
346
  * `moda skills <subcommand>` dispatcher.
194
347
  */
@@ -218,8 +371,13 @@ export async function runSkillsCommand(args) {
218
371
  }
219
372
  return Number(result.sessions_failed ?? 0) > 0 ? 1 : 0;
220
373
  }
374
+ if (sub === 'sync') {
375
+ const result = await runSkillSync(args.flags, { profile: args.flags.profile });
376
+ console.log(JSON.stringify({ synced: result.synced, dryRun: result.dryRun }));
377
+ return 0;
378
+ }
221
379
  if (sub !== 'pull') {
222
- throw new CliInputError(sub ? `Unknown skills subcommand '${sub}'` : 'A skills subcommand is required', 'Usage: moda skills gen [--source=all|sdk|coding-agent] [--max-sessions=N] [--start-at=ISO] [--end-at=ISO] [--replay] [--reprocess-segments] [--force-reprocess] [--wait] | moda skills pull [--status=approved|proposed|all]');
380
+ throw new CliInputError(sub ? `Unknown skills subcommand '${sub}'` : 'A skills subcommand is required', 'Usage: moda skills gen [--source=all|sdk|coding-agent] [--max-sessions=N] [--start-at=ISO] [--end-at=ISO] [--replay] [--reprocess-segments] [--force-reprocess] [--wait] | moda skills pull [--status=approved|proposed|all] | moda skills sync [--dry-run]');
223
381
  }
224
382
  const status = (args.flags.status ?? 'approved').toLowerCase();
225
383
  if (!VALID_STATUSES.has(status)) {
@@ -0,0 +1,31 @@
1
+ import { type ProfileIOOptions } from './profiles.js';
2
+ export declare function stripUpdateCheckFlag(flags: Record<string, string>): Record<string, string>;
3
+ export declare function isUpdateCheckDisabled(flags: Record<string, string>, env?: NodeJS.ProcessEnv): boolean;
4
+ /**
5
+ * Numeric semver comparison ignoring prerelease/build suffixes; npm's
6
+ * `latest` dist-tag only carries stable releases, so this is sufficient
7
+ * without pulling in a semver dependency.
8
+ */
9
+ export declare function isNewerVersion(candidate: string, current: string): boolean;
10
+ /**
11
+ * Refresh the cached latest version from the npm registry at most once per
12
+ * 24 hours. Fire-and-forget from the CLI dispatcher. The abort timer is
13
+ * unref'd so it can't keep the process alive after the socket closes (it does
14
+ * not unref the in-flight fetch itself — a fast command may still wait up to
15
+ * REGISTRY_TIMEOUT_MS for the request to resolve, matching the telemetry
16
+ * module's trade-off). Every failure path is swallowed — an update check must
17
+ * never break a command.
18
+ */
19
+ export declare function startUpdateCheck(options?: ProfileIOOptions, now?: number): Promise<void>;
20
+ /**
21
+ * Returns the upgrade notice when the cached registry version is newer than
22
+ * the running build, or undefined. Reads only the local cache — never the
23
+ * network — so it is safe to call synchronously at the end of every command.
24
+ */
25
+ export declare function getUpdateNotice(options?: ProfileIOOptions): string | undefined;
26
+ /**
27
+ * Print the upgrade notice to stderr in interactive terminals only: stdout
28
+ * stays machine-readable, and piped/CI invocations stay noise-free.
29
+ */
30
+ export declare function printUpdateNotice(options?: ProfileIOOptions): void;
31
+ //# sourceMappingURL=update-check.d.ts.map
@@ -0,0 +1,141 @@
1
+ // src/update-check.ts — Daily npm-registry version check with an upgrade
2
+ // notice. Mirrors the telemetry contract: fire-and-forget, short abort
3
+ // timeout, never throws, and never touches stdout (notice goes to stderr so
4
+ // JSON/agent output stays machine-readable).
5
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { VERSION } from './config.js';
8
+ import { getModaDir } from './profiles.js';
9
+ const UPDATE_CHECK_FLAG = 'no-update-check';
10
+ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
11
+ const REGISTRY_TIMEOUT_MS = 1_500;
12
+ const REGISTRY_LATEST_URL = 'https://registry.npmjs.org/@moda-ai/cli/latest';
13
+ // npm's `latest` dist-tag is always a stable semver. Anchored, no control
14
+ // chars: a registry response intercepted by a proxy can't smuggle ANSI escape
15
+ // sequences into the cache (and thus the terminal notice) this way.
16
+ const SEMVER_RE = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
17
+ export function stripUpdateCheckFlag(flags) {
18
+ const { [UPDATE_CHECK_FLAG]: _drop, ...rest } = flags;
19
+ return rest;
20
+ }
21
+ export function isUpdateCheckDisabled(flags, env = process.env) {
22
+ return (flags[UPDATE_CHECK_FLAG] === 'true' ||
23
+ env.MODA_CLI_UPDATE_CHECK === '0' ||
24
+ env.DO_NOT_TRACK === '1' ||
25
+ // Treat CI as set only when it carries a real truthy value: `CI=false`
26
+ // and `CI=0` are common ways to simulate a non-CI shell locally.
27
+ (env.CI !== undefined &&
28
+ env.CI !== '' &&
29
+ env.CI !== 'false' &&
30
+ env.CI !== '0'));
31
+ }
32
+ function cachePath(options = {}) {
33
+ return join(getModaDir(options), 'update-check.json');
34
+ }
35
+ function readCache(options = {}) {
36
+ try {
37
+ const parsed = JSON.parse(readFileSync(cachePath(options), 'utf8'));
38
+ if (parsed &&
39
+ typeof parsed === 'object' &&
40
+ typeof parsed.checked_at === 'number' &&
41
+ typeof parsed.latest === 'string') {
42
+ return parsed;
43
+ }
44
+ }
45
+ catch {
46
+ // Missing or corrupt cache means "never checked".
47
+ }
48
+ return undefined;
49
+ }
50
+ function writeCache(cache, options = {}) {
51
+ try {
52
+ mkdirSync(getModaDir(options), { recursive: true });
53
+ writeFileSync(cachePath(options), `${JSON.stringify(cache, null, 2)}\n`, 'utf8');
54
+ }
55
+ catch {
56
+ // Best-effort: an unwritable cache only means we re-check next run.
57
+ }
58
+ }
59
+ /**
60
+ * Numeric semver comparison ignoring prerelease/build suffixes; npm's
61
+ * `latest` dist-tag only carries stable releases, so this is sufficient
62
+ * without pulling in a semver dependency.
63
+ */
64
+ export function isNewerVersion(candidate, current) {
65
+ const parse = (v) => v.split(/[-+]/)[0].split('.').map((part) => Number.parseInt(part, 10) || 0);
66
+ const a = parse(candidate);
67
+ const b = parse(current);
68
+ for (let i = 0; i < 3; i++) {
69
+ if ((a[i] ?? 0) > (b[i] ?? 0))
70
+ return true;
71
+ if ((a[i] ?? 0) < (b[i] ?? 0))
72
+ return false;
73
+ }
74
+ return false;
75
+ }
76
+ /**
77
+ * Refresh the cached latest version from the npm registry at most once per
78
+ * 24 hours. Fire-and-forget from the CLI dispatcher. The abort timer is
79
+ * unref'd so it can't keep the process alive after the socket closes (it does
80
+ * not unref the in-flight fetch itself — a fast command may still wait up to
81
+ * REGISTRY_TIMEOUT_MS for the request to resolve, matching the telemetry
82
+ * module's trade-off). Every failure path is swallowed — an update check must
83
+ * never break a command.
84
+ */
85
+ export async function startUpdateCheck(options = {}, now = Date.now()) {
86
+ const cache = readCache(options);
87
+ if (cache && now - cache.checked_at < CHECK_INTERVAL_MS) {
88
+ return;
89
+ }
90
+ const controller = new AbortController();
91
+ const timeout = setTimeout(() => controller.abort(), REGISTRY_TIMEOUT_MS);
92
+ if (typeof timeout.unref === 'function') {
93
+ timeout.unref();
94
+ }
95
+ try {
96
+ const res = await fetch(REGISTRY_LATEST_URL, {
97
+ headers: { Accept: 'application/json' },
98
+ signal: controller.signal,
99
+ });
100
+ if (!res.ok)
101
+ return;
102
+ const body = (await res.json());
103
+ if (typeof body.version !== 'string' || !SEMVER_RE.test(body.version))
104
+ return;
105
+ writeCache({ checked_at: now, latest: body.version }, options);
106
+ }
107
+ catch {
108
+ // Network/timeout errors are expected offline; try again next run.
109
+ }
110
+ finally {
111
+ clearTimeout(timeout);
112
+ }
113
+ }
114
+ /**
115
+ * Returns the upgrade notice when the cached registry version is newer than
116
+ * the running build, or undefined. Reads only the local cache — never the
117
+ * network — so it is safe to call synchronously at the end of every command.
118
+ */
119
+ export function getUpdateNotice(options = {}) {
120
+ const cache = readCache(options);
121
+ if (!cache || !isNewerVersion(cache.latest, VERSION)) {
122
+ return undefined;
123
+ }
124
+ return [
125
+ '',
126
+ `Update available: moda-cli ${VERSION} → ${cache.latest}`,
127
+ 'Run `npm install -g @moda-ai/cli@latest` to update.',
128
+ ].join('\n');
129
+ }
130
+ /**
131
+ * Print the upgrade notice to stderr in interactive terminals only: stdout
132
+ * stays machine-readable, and piped/CI invocations stay noise-free.
133
+ */
134
+ export function printUpdateNotice(options = {}) {
135
+ if (!process.stderr.isTTY)
136
+ return;
137
+ const notice = getUpdateNotice(options);
138
+ if (notice)
139
+ console.error(notice);
140
+ }
141
+ //# sourceMappingURL=update-check.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moda-ai/cli",
3
- "version": "1.3.0",
3
+ "version": "1.6.0",
4
4
  "description": "CLI for Moda - AI agent analytics and observability",
5
5
  "type": "module",
6
6
  "bin": {