@moda-ai/cli 1.4.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 +8 -5
- package/dist/harness.d.ts +31 -0
- package/dist/harness.js +53 -9
- package/dist/init/index.js +130 -59
- package/dist/init/sdk-guide.d.ts +18 -0
- package/dist/init/sdk-guide.js +2 -2
- package/dist/init/sdk-integration.d.ts +26 -0
- package/dist/init/sdk-integration.js +80 -0
- package/dist/prompts.d.ts +16 -0
- package/dist/prompts.js +12 -3
- package/dist/skills.d.ts +26 -0
- package/dist/skills.js +160 -2
- package/package.json +1 -1
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,11 @@ 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-
|
|
196
|
-
--no-sdk-
|
|
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
|
+
--offline Run final validation offline (skip the online telemetry check)
|
|
199
202
|
|
|
200
203
|
Examples:
|
|
201
204
|
moda init
|
|
@@ -578,7 +581,7 @@ export const commandRegistry = createCommandRegistry([
|
|
|
578
581
|
{
|
|
579
582
|
name: 'init',
|
|
580
583
|
description: 'Set up Moda in your project',
|
|
581
|
-
examples: ['moda init', 'moda init --
|
|
584
|
+
examples: ['moda init', 'moda init --sdk-integrate', 'moda init --ci-cd'],
|
|
582
585
|
auth: 'none',
|
|
583
586
|
outputModes: ['human', 'json', 'agent'],
|
|
584
587
|
mutability: 'write',
|
|
@@ -902,8 +905,8 @@ export const commandRegistry = createCommandRegistry([
|
|
|
902
905
|
// },
|
|
903
906
|
{
|
|
904
907
|
name: 'skills',
|
|
905
|
-
description: 'Generate and
|
|
906
|
-
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'],
|
|
907
910
|
auth: 'none',
|
|
908
911
|
outputModes: ['human', 'json', 'agent'],
|
|
909
912
|
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
|
-
|
|
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
|
}
|
package/dist/init/index.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
170
|
-
markApplied(setupPlan, 'sdk', 'User skipped SDK integration
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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(
|
|
200
|
-
|
|
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
|
|
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: '
|
|
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
|
-
|
|
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: '
|
|
255
|
-
status: options.
|
|
256
|
-
approved: options.
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
|
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
|
|
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
|
|
464
|
+
return false;
|
|
394
465
|
return confirm({
|
|
395
|
-
message: '
|
|
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
|
}
|
package/dist/init/sdk-guide.d.ts
CHANGED
|
@@ -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
|
package/dist/init/sdk-guide.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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)) {
|