@moda-ai/cli 1.4.0 → 1.7.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
@@ -161,6 +161,8 @@ Commands:
161
161
  skills gen Kick off tenant-wide skill generation
162
162
  skills pull Pull tenant-generated skills into .claude/skills (and .cursor/rules)
163
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
164
166
 
165
167
  Options:
166
168
  --help Show this help message
@@ -192,10 +194,13 @@ Init flags:
192
194
  Choose harness analyst adapter
193
195
  --tui Show full-screen analyst dashboard in human TTYs
194
196
  --no-tui Show raw external analyst output
195
- --sdk-guide Write .moda/SDK_INTEGRATION.md
196
- --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)
197
199
  --ci-cd Create GitHub Actions Moda validation workflow
198
200
  --no-ci-cd Skip CI/CD workflow creation
201
+ --autosync Create GitHub Actions workflow to sync prompts + skills to Moda on push to main
202
+ --no-autosync Skip autosync workflow creation
203
+ --offline Run final validation offline (skip the online telemetry check)
199
204
 
200
205
  Examples:
201
206
  moda init
@@ -225,6 +230,7 @@ Examples:
225
230
  moda harness scan --json
226
231
  moda harness explain --agent=agent_support
227
232
  moda init --ci-cd
233
+ moda init --autosync
228
234
  moda overview --days-back=30
229
235
  moda clusters --time-range=7d
230
236
  moda conversations --search="error" --limit=5
@@ -578,7 +584,7 @@ export const commandRegistry = createCommandRegistry([
578
584
  {
579
585
  name: 'init',
580
586
  description: 'Set up Moda in your project',
581
- examples: ['moda init', 'moda init --coding-agent-traces', 'moda init --ci-cd'],
587
+ examples: ['moda init', 'moda init --sdk-integrate', 'moda init --ci-cd', 'moda init --autosync'],
582
588
  auth: 'none',
583
589
  outputModes: ['human', 'json', 'agent'],
584
590
  mutability: 'write',
@@ -902,8 +908,8 @@ export const commandRegistry = createCommandRegistry([
902
908
  // },
903
909
  {
904
910
  name: 'skills',
905
- description: 'Generate and pull Moda skills',
906
- examples: ['moda skills gen', 'moda skills pull'],
911
+ description: 'Generate, pull, and sync Moda skills',
912
+ examples: ['moda skills gen', 'moda skills pull', 'moda skills sync'],
907
913
  auth: 'none',
908
914
  outputModes: ['human', 'json', 'agent'],
909
915
  mutability: 'write',
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
  }
@@ -11,7 +11,7 @@ export interface InitPhaseResult {
11
11
  details?: string;
12
12
  }
13
13
  export interface InitSetupAction {
14
- category: 'sdk' | 'harness' | 'hooks' | 'prompts' | 'skills' | 'ci-cd' | 'validation';
14
+ category: 'sdk' | 'harness' | 'hooks' | 'prompts' | 'skills' | 'ci-cd' | 'autosync' | 'validation';
15
15
  title: string;
16
16
  status: 'planned' | 'applied' | 'skipped' | 'manual';
17
17
  approved: boolean;
@@ -6,15 +6,17 @@ 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';
16
+ import { VERSION } from '../config.js';
15
17
  import { CliInputError } from '../errors.js';
16
18
  import { runHarnessCommand } from '../harness.js';
17
- import { runDoctorCommand } from '../doctor.js';
19
+ import { buildDoctorReport } from '../doctor.js';
18
20
  import { renderInitBanner, renderInitComplete, renderInitNote, renderInitSetupPlan, renderInitStep, } from '../tui.js';
19
21
  export async function runInit(flags, options = {}) {
20
22
  const explicitYes = flags.yes === true ||
@@ -30,9 +32,7 @@ export async function runInit(flags, options = {}) {
30
32
  if (human)
31
33
  console.log(message);
32
34
  };
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) => {
35
+ const warn = (message) => {
36
36
  if (human)
37
37
  console.error(message);
38
38
  };
@@ -102,6 +102,32 @@ export async function runInit(flags, options = {}) {
102
102
  if (human)
103
103
  printHarnessDetectionSummary();
104
104
  emit('harness', 'Harness discovery complete.', 'completed');
105
+ // Upload the cited harness graph so Moda's backend has the understanding, not
106
+ // just local artifacts. `harness sync --from-report` refuses an unapproved
107
+ // report, so approve it first. Best-effort: a network blip on either call must
108
+ // not abort onboarding — auth + key provisioning already succeeded.
109
+ try {
110
+ await runHarnessCommand(createCommandContext({
111
+ command: 'harness',
112
+ positional: 'approve',
113
+ positionals: ['approve'],
114
+ flags: commandFlags({ yes: true, json: json || agent, agent, quiet: true, 'no-telemetry': true }),
115
+ }));
116
+ await runHarnessCommand(createCommandContext({
117
+ command: 'harness',
118
+ positional: 'sync',
119
+ positionals: ['sync'],
120
+ flags: commandFlags({ 'from-report': true, yes: true, json: json || agent, agent, quiet: true, 'no-telemetry': true }),
121
+ }));
122
+ log(renderInitNote('Synced harness graph to Moda'));
123
+ emit('harness', 'Harness graph synced to Moda.', 'completed');
124
+ }
125
+ catch (err) {
126
+ const message = err instanceof Error ? err.message : String(err);
127
+ warn(renderInitNote(`Harness sync skipped: ${message}`, 'warning'));
128
+ warn(' Run `moda harness approve --yes && moda harness sync --from-report` later to upload.');
129
+ emit('harness', 'Harness sync skipped.', 'warning');
130
+ }
105
131
  phases.push({ id: 'harness', title: 'Harness Discovery', status: 'completed' });
106
132
  // Step 5: Setup plan
107
133
  log(renderInitStep(5, 7, 'Setup Plan', 'Every write/sync action is explicit before it is applied.'));
@@ -109,8 +135,9 @@ export async function runInit(flags, options = {}) {
109
135
  // Coding-agent capture is temporarily disabled; never prompt for or install trace hooks.
110
136
  // installHooks: await shouldIngestCodingAgentTraces(flags, yes),
111
137
  installHooks: false,
112
- installSdkGuide: await shouldCreateSdkGuide(flags, yes),
138
+ integrateSdk: await shouldIntegrateSdk(flags, yes),
113
139
  configureCiCd: await shouldConfigureCiCd(flags, yes),
140
+ configureAutosync: await shouldConfigureAutosync(flags, yes),
114
141
  });
115
142
  log(renderInitSetupPlan(setupPlan));
116
143
  emit('plan', 'Setup plan ready.', 'completed');
@@ -140,34 +167,69 @@ export async function runInit(flags, options = {}) {
140
167
  const agents = detectAgents();
141
168
  if (agents.length > 0) {
142
169
  log(renderInitNote(`Detected coding agents: ${agents.join(', ')}`));
170
+ // Install only the moda-cli skill so those agents can query Moda analytics
171
+ // from the terminal. SDK integration is a one-shot agent run below, not a
172
+ // persistent skill.
143
173
  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
174
  }
157
175
  else {
158
176
  log(renderInitNote('No coding agents detected, skipping', 'skipped'));
159
177
  }
160
178
  await initPrompts({ silent: true });
161
179
  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');
180
+ // Push the manifest's prompt files to Moda so production traces can attribute
181
+ // managed prompt versions. A fresh repo has none yet — that's a harmless no-op.
182
+ try {
183
+ const promptSync = await runPromptSync({}, { profile: stringFlag(flags.profile) });
184
+ log(renderInitNote(promptSync.synced.length > 0
185
+ ? `Synced ${promptSync.synced.length} prompt(s) to Moda`
186
+ : 'No prompt files to sync yet (add paths to .moda/prompts.yml)'));
187
+ }
188
+ catch (err) {
189
+ const message = err instanceof Error ? err.message : String(err);
190
+ warn(renderInitNote(`Prompt sync skipped: ${message}`, 'warning'));
191
+ }
192
+ // Push locally-authored skills (`.claude/skills/**/SKILL.md`) to Moda, in
193
+ // parallel intent with prompt sync. A fresh repo has none yet — harmless
194
+ // no-op. Best-effort: a network blip must not abort onboarding.
195
+ try {
196
+ const skillSync = await runSkillSync({}, { profile: stringFlag(flags.profile) });
197
+ if (skillSync.synced.length > 0) {
198
+ log(renderInitNote(`Synced ${skillSync.synced.length} skill(s) to Moda`));
199
+ }
200
+ else {
201
+ log(renderInitNote('No local skills to sync yet (add .claude/skills/<name>/SKILL.md)'));
202
+ }
203
+ }
204
+ catch (err) {
205
+ const message = err instanceof Error ? err.message : String(err);
206
+ warn(renderInitNote(`Skill sync skipped: ${message}`, 'warning'));
207
+ }
208
+ const sdkAction = setupPlan.find((action) => action.category === 'sdk');
209
+ if (sdkAction?.approved === true) {
210
+ log(renderInitNote('Running your coding agent to integrate the Moda SDK (this can take a few minutes)...', 'warning'));
211
+ const result = await runSdkIntegration({ human });
212
+ if (result.status === 'integrated') {
213
+ log(renderInitNote(`Integrated the Moda SDK with ${result.adapter}. Review the diff before committing.`));
214
+ markApplied(setupPlan, 'sdk');
215
+ }
216
+ else if (result.status === 'no-agent') {
217
+ const guidePath = writeSdkIntegrationGuide();
218
+ sdkAction.files = [guidePath];
219
+ log(renderInitNote(`No coding agent CLI found; wrote ${guidePath} to integrate by hand`, 'warning'));
220
+ markApplied(setupPlan, 'sdk');
221
+ }
222
+ else {
223
+ const guidePath = writeSdkIntegrationGuide();
224
+ sdkAction.files = [guidePath];
225
+ warn(renderInitNote(`SDK integration via ${result.adapter} did not complete: ${result.detail}`, 'warning'));
226
+ log(renderInitNote(`Wrote ${guidePath} so you can finish the integration manually`));
227
+ markApplied(setupPlan, 'sdk');
228
+ }
167
229
  }
168
230
  else {
169
- log(renderInitNote('Skipped SDK integration guide', 'skipped'));
170
- markApplied(setupPlan, 'sdk', 'User skipped SDK integration guide.');
231
+ log(renderInitNote('Skipped SDK integration', 'skipped'));
232
+ markApplied(setupPlan, 'sdk', 'User skipped SDK integration.');
171
233
  }
172
234
  const configureCiCd = setupPlan.find((action) => action.category === 'ci-cd')?.approved === true;
173
235
  if (configureCiCd) {
@@ -175,6 +237,12 @@ export async function runInit(flags, options = {}) {
175
237
  log(renderInitNote(`Wrote ${workflowPath}`));
176
238
  markApplied(setupPlan, 'ci-cd');
177
239
  }
240
+ const configureAutosync = setupPlan.find((action) => action.category === 'autosync')?.approved === true;
241
+ if (configureAutosync) {
242
+ const workflowPath = writeAutosyncWorkflow();
243
+ log(renderInitNote(`Wrote ${workflowPath}`));
244
+ markApplied(setupPlan, 'autosync');
245
+ }
178
246
  log(renderInitNote('Reusing approved Step 4 harness report; no second analyst run needed'));
179
247
  markApplied(setupPlan, 'harness');
180
248
  markApplied(setupPlan, 'hooks', ingestCodingAgentTraces ? undefined : 'Coding-agent trace capture is temporarily unavailable.');
@@ -182,22 +250,26 @@ export async function runInit(flags, options = {}) {
182
250
  markApplied(setupPlan, 'skills');
183
251
  emit('apply', 'Approved changes applied.', 'completed');
184
252
  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({
253
+ const offlineValidation = isTruthyFlag(flagValue(flags, 'offline'));
254
+ log(renderInitStep(7, 7, 'Validate', offlineValidation
255
+ ? 'Running local doctor checks so the setup leaves a clear state.'
256
+ : 'Running Moda doctor online to confirm setup and that telemetry is reaching Moda.'));
257
+ emit('validate', `Running Moda doctor diagnostics (${offlineValidation ? 'offline' : 'online'}).`);
258
+ const doctorReport = await buildDoctorReport(createCommandContext({
188
259
  command: 'doctor',
189
260
  positional: undefined,
190
261
  positionals: [],
191
- flags: commandFlags({
192
- json: true,
193
- quiet: true,
194
- offline: true,
195
- 'no-telemetry': true,
196
- }),
197
- }));
262
+ flags: commandFlags({ json: true, quiet: true, 'no-telemetry': true }),
263
+ }), { offline: offlineValidation });
198
264
  markApplied(setupPlan, 'validation');
199
- log(renderInitNote('Local validation checks completed'));
200
- emit('validate', 'Local validation checks complete.', 'completed');
265
+ log(renderInitNote(`Validation checks completed: ${doctorReport.summary.pass} passing, ${doctorReport.summary.warning} warning, ${doctorReport.summary.fail} failing`));
266
+ if (!offlineValidation) {
267
+ const cloud = doctorReport.checks.find((check) => check.id === 'cloud_data_flow');
268
+ if (cloud) {
269
+ log(renderInitNote(`Moda Cloud telemetry: ${cloud.status}`, cloud.status === 'pass' ? undefined : 'warning'));
270
+ }
271
+ }
272
+ emit('validate', 'Validation checks complete.', doctorReport.summary.fail > 0 ? 'warning' : 'completed');
201
273
  phases.push({ id: 'validate', title: 'Validate', status: 'completed' });
202
274
  // Done
203
275
  log(renderInitComplete({
@@ -212,7 +284,7 @@ function buildSetupPlan(options) {
212
284
  return [
213
285
  {
214
286
  category: 'harness',
215
- title: 'Analyze harness and write cited Moda report',
287
+ title: 'Analyze harness, write cited report, and sync the graph to Moda',
216
288
  status: 'planned',
217
289
  approved: true,
218
290
  files: [
@@ -222,7 +294,7 @@ function buildSetupPlan(options) {
222
294
  '.moda/harness.json',
223
295
  '.moda/HARNESS.md',
224
296
  ],
225
- command: 'moda harness analyze --analyst=auto --summary',
297
+ command: 'moda harness analyze --analyst=auto --summary && moda harness approve --yes && moda harness sync --from-report',
226
298
  },
227
299
  {
228
300
  category: 'hooks',
@@ -235,30 +307,34 @@ function buildSetupPlan(options) {
235
307
  },
236
308
  {
237
309
  category: 'prompts',
238
- title: 'Initialize prompt registry manifest',
310
+ title: 'Initialize the prompt registry manifest and sync prompts to Moda',
239
311
  status: 'planned',
240
312
  approved: true,
241
313
  files: ['.moda/prompts.yml'],
242
- command: 'moda prompts init',
314
+ command: 'moda prompts init && moda prompts sync',
243
315
  },
244
316
  {
245
317
  category: 'skills',
246
- title: 'Pull approved Moda skills when available',
318
+ title: 'Install the moda-cli skill into detected coding agents and sync local skills to Moda',
247
319
  status: 'planned',
248
320
  approved: true,
249
321
  files: ['.claude/skills', '.cursor/rules'],
250
- command: 'moda skills pull',
322
+ // Skill install into detected agents happens inside `moda init`; there is no
323
+ // standalone install command, so surface only the re-runnable sync here.
324
+ command: 'moda skills sync',
251
325
  },
252
326
  {
253
327
  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.',
328
+ title: 'Run a coding agent to integrate the Moda SDK',
329
+ status: options.integrateSdk ? 'planned' : 'manual',
330
+ approved: options.integrateSdk,
331
+ // Files are repo-specific and unknown ahead of the agent run; the fallback
332
+ // path rewrites this to the guide path when no agent CLI is available.
333
+ files: [],
334
+ command: 'moda init',
335
+ reason: options.integrateSdk
336
+ ? 'Installs the SDK package and edits source via your coding agent; review the diff before committing.'
337
+ : `SDK integration stays manual; see ${SDK_GUIDE_PATH} if you generate the guide.`,
262
338
  },
263
339
  {
264
340
  category: 'ci-cd',
@@ -269,13 +345,22 @@ function buildSetupPlan(options) {
269
345
  command: 'moda doctor --online --json',
270
346
  reason: options.configureCiCd ? undefined : 'No CI/CD files are changed until a project-specific plan is approved.',
271
347
  },
348
+ {
349
+ category: 'autosync',
350
+ title: 'Autosync prompts and skills to Moda on push to main',
351
+ status: options.configureAutosync ? 'planned' : 'manual',
352
+ approved: options.configureAutosync,
353
+ files: ['.github/workflows/moda-autosync.yml'],
354
+ command: 'moda prompts sync && moda skills sync',
355
+ reason: options.configureAutosync ? undefined : 'No autosync workflow is written until it is explicitly approved (--autosync).',
356
+ },
272
357
  {
273
358
  category: 'validation',
274
- title: 'Run local Moda doctor diagnostics',
359
+ title: 'Run Moda doctor online to confirm telemetry is reaching Moda',
275
360
  status: 'planned',
276
361
  approved: true,
277
362
  files: [],
278
- command: 'moda doctor',
363
+ command: 'moda doctor --online',
279
364
  },
280
365
  ];
281
366
  }
@@ -296,6 +381,13 @@ function changedFiles(setupPlan) {
296
381
  .filter((action) => action.status === 'applied')
297
382
  .flatMap((action) => action.files))].filter((file) => !file.includes('.env'));
298
383
  }
384
+ // The CLI version generated workflows pin to. Pinning (rather than `@latest`,
385
+ // which resolves at CI run time) keeps a future compromised `@moda-ai/cli`
386
+ // publish from silently executing in adopters' CI with their MODA_API_KEY /
387
+ // MODA_TENANT_ID present — especially important for autosync, which *writes* to
388
+ // the Moda registry. The pin tracks the CLI that generated the file, so each
389
+ // `moda init` upgrade is an explicit, reviewable bump of the workflow.
390
+ const GENERATED_WORKFLOW_CLI = `@moda-ai/cli@${VERSION}`;
299
391
  function writeCiCdWorkflow(cwd = process.cwd()) {
300
392
  const relativePath = '.github/workflows/moda-validation.yml';
301
393
  const path = join(cwd, relativePath);
@@ -308,6 +400,11 @@ function writeCiCdWorkflow(cwd = process.cwd()) {
308
400
  ' push:',
309
401
  ' branches: [main]',
310
402
  '',
403
+ '# Least privilege: these jobs only read the checked-out repo and talk to',
404
+ '# Moda over the API key; they never write back to the repository.',
405
+ 'permissions:',
406
+ ' contents: read',
407
+ '',
311
408
  'jobs:',
312
409
  ' moda-doctor:',
313
410
  ' runs-on: ubuntu-latest',
@@ -320,7 +417,51 @@ function writeCiCdWorkflow(cwd = process.cwd()) {
320
417
  ' with:',
321
418
  ' node-version: 22',
322
419
  ' - name: Validate Moda setup',
323
- ' run: npx -y @moda-ai/cli@latest moda doctor --online --json',
420
+ ` run: npx -y ${GENERATED_WORKFLOW_CLI} moda doctor --online --json`,
421
+ '',
422
+ ].join('\n')}`);
423
+ return relativePath;
424
+ }
425
+ // Generates a push-to-main autosync workflow that keeps the Moda registry in
426
+ // sync with the repo: it pushes prompt and skill changes after they merge. It
427
+ // only runs on `main` (never on pull_request) so unmerged versions are never
428
+ // published. Keep its env in step with writeCiCdWorkflow().
429
+ function writeAutosyncWorkflow(cwd = process.cwd()) {
430
+ const relativePath = '.github/workflows/moda-autosync.yml';
431
+ const path = join(cwd, relativePath);
432
+ mkdirSync(dirname(path), { recursive: true });
433
+ writeFileSync(path, `${[
434
+ 'name: Moda Autosync',
435
+ '',
436
+ 'on:',
437
+ ' push:',
438
+ ' branches: [main]',
439
+ '',
440
+ '# Least privilege: this job only reads the checked-out repo and writes to',
441
+ '# Moda over the API key; it never writes back to the repository.',
442
+ 'permissions:',
443
+ ' contents: read',
444
+ '',
445
+ '# A single in-flight sync at a time; newer pushes supersede older runs.',
446
+ 'concurrency:',
447
+ ' group: moda-autosync',
448
+ ' cancel-in-progress: true',
449
+ '',
450
+ 'jobs:',
451
+ ' moda-sync:',
452
+ ' runs-on: ubuntu-latest',
453
+ ' env:',
454
+ ' MODA_API_KEY: ${{ secrets.MODA_API_KEY }}',
455
+ ' MODA_TENANT_ID: ${{ secrets.MODA_TENANT_ID }}',
456
+ ' steps:',
457
+ ' - uses: actions/checkout@v4',
458
+ ' - uses: actions/setup-node@v4',
459
+ ' with:',
460
+ ' node-version: 22',
461
+ ` - name: Sync prompts to Moda`,
462
+ ` run: npx -y ${GENERATED_WORKFLOW_CLI} moda prompts sync`,
463
+ ` - name: Sync skills to Moda`,
464
+ ` run: npx -y ${GENERATED_WORKFLOW_CLI} moda skills sync`,
324
465
  '',
325
466
  ].join('\n')}`);
326
467
  return relativePath;
@@ -383,16 +524,32 @@ async function shouldConfigureCiCd(flags, yes) {
383
524
  default: false,
384
525
  });
385
526
  }
386
- async function shouldCreateSdkGuide(flags, yes) {
387
- const explicit = parseOptionalBoolean(flagValue(flags, 'sdk-guide', 'sdk_guide', 'sdk'));
527
+ async function shouldConfigureAutosync(flags, yes) {
528
+ const explicit = parseOptionalBoolean(flagValue(flags, 'autosync', 'auto-sync', 'auto_sync'));
388
529
  if (explicit !== undefined)
389
530
  return explicit;
390
- if (isTruthyFlag(flagValue(flags, 'no-sdk-guide', 'no_sdk_guide')))
531
+ if (isTruthyFlag(flagValue(flags, 'no-autosync', 'no_autosync', 'no-auto-sync', 'no_auto_sync')))
391
532
  return false;
392
533
  if (yes)
393
- return true;
534
+ return false;
535
+ return confirm({
536
+ message: 'Create a GitHub Actions workflow to autosync prompts and skills to Moda on push to main?',
537
+ default: false,
538
+ });
539
+ }
540
+ async function shouldIntegrateSdk(flags, yes) {
541
+ const explicit = parseOptionalBoolean(flagValue(flags, 'sdk-integrate', 'sdk_integrate', 'sdk-guide', 'sdk_guide', 'sdk'));
542
+ if (explicit !== undefined)
543
+ return explicit;
544
+ if (isTruthyFlag(flagValue(flags, 'no-sdk-integrate', 'no_sdk_integrate', 'no-sdk-guide', 'no_sdk_guide', 'no-sdk', 'no_sdk'))) {
545
+ return false;
546
+ }
547
+ // SDK integration now installs packages and edits source via a coding agent.
548
+ // Never do that unattended (--yes/--json/--agent); require an explicit opt-in.
549
+ if (yes)
550
+ return false;
394
551
  return confirm({
395
- message: 'Create a Moda SDK integration guide? (No packages installed, no source files edited.)',
552
+ message: 'Run your coding agent now to install the Moda SDK and instrument your LLM calls? (Edits source, installs packages.)',
396
553
  default: true,
397
554
  });
398
555
  }
@@ -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)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moda-ai/cli",
3
- "version": "1.4.0",
3
+ "version": "1.7.0",
4
4
  "description": "CLI for Moda - AI agent analytics and observability",
5
5
  "type": "module",
6
6
  "bin": {