@moda-ai/cli 1.1.1 → 1.3.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/README.md +36 -0
- package/dist/agent.d.ts +5 -0
- package/dist/agent.js +477 -0
- package/dist/analyst-tui.d.ts +81 -0
- package/dist/analyst-tui.js +314 -0
- package/dist/api-client.d.ts +4 -0
- package/dist/api-client.js +20 -3
- package/dist/auth.d.ts +22 -3
- package/dist/auth.js +230 -66
- package/dist/cli.d.ts +20 -3
- package/dist/cli.js +745 -93
- package/dist/command-context.d.ts +34 -0
- package/dist/command-context.js +38 -0
- package/dist/command-registry.d.ts +22 -0
- package/dist/command-registry.js +15 -0
- package/dist/config.d.ts +40 -1
- package/dist/config.js +70 -3
- package/dist/doctor.d.ts +79 -0
- package/dist/doctor.js +1111 -0
- package/dist/errors.d.ts +5 -0
- package/dist/errors.js +9 -0
- package/dist/harness-report.d.ts +138 -0
- package/dist/harness-report.js +1464 -0
- package/dist/harness.d.ts +233 -0
- package/dist/harness.js +3264 -0
- package/dist/init/index.d.ts +30 -1
- package/dist/init/index.js +389 -23
- package/dist/init/rules.d.ts +5 -1
- package/dist/init/rules.js +22 -16
- package/dist/init/sdk-guide.d.ts +3 -0
- package/dist/init/sdk-guide.js +397 -0
- package/dist/init/tenant.js +12 -3
- package/dist/keys.d.ts +47 -0
- package/dist/keys.js +136 -0
- package/dist/output.d.ts +118 -0
- package/dist/output.js +228 -0
- package/dist/production-intelligence.d.ts +134 -0
- package/dist/production-intelligence.js +1113 -0
- package/dist/profile-commands.d.ts +10 -0
- package/dist/profile-commands.js +252 -0
- package/dist/profiles.d.ts +90 -0
- package/dist/profiles.js +432 -0
- package/dist/prompts.d.ts +6 -0
- package/dist/prompts.js +368 -0
- package/dist/schemas.d.ts +4 -4
- package/dist/skills.d.ts +25 -0
- package/dist/skills.js +468 -0
- package/dist/sync.d.ts +5 -0
- package/dist/sync.js +27 -0
- package/dist/telemetry.d.ts +18 -0
- package/dist/telemetry.js +63 -0
- package/dist/tui.d.ts +40 -0
- package/dist/tui.js +387 -0
- package/dist/workspace.d.ts +12 -0
- package/dist/workspace.js +1294 -0
- package/hooks/moda-snapshot.sh +41 -0
- package/package.json +10 -7
- package/skills/moda-cli/SKILL.md +48 -4
- package/skills/integrate-moda/SKILL.md +0 -63
package/dist/skills.js
ADDED
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
// src/skills.ts — `moda skills` command: pull tenant-generated skills locally.
|
|
2
|
+
//
|
|
3
|
+
// The Moda skill-creation pipeline generates per-tenant SKILL.md files
|
|
4
|
+
// server-side (moda-skill-harness) and stores their full text in ClickHouse.
|
|
5
|
+
// `moda skills gen` kicks off server-side skill generation through the ingest
|
|
6
|
+
// worker, which validates the user's Bearer API key before forwarding to the
|
|
7
|
+
// internal harness. `moda skills pull` fetches generated skills via
|
|
8
|
+
// `GET /v1/skills` (authenticated with the same Bearer API key used for
|
|
9
|
+
// snapshots) and writes each into the user's coding-agent skill directory so
|
|
10
|
+
// the agent can use them:
|
|
11
|
+
//
|
|
12
|
+
// .claude/skills/<skill_id>/SKILL.md (Claude Code, canonical format)
|
|
13
|
+
// .cursor/rules/<skill_id>.mdc (Cursor, when a .cursor dir exists)
|
|
14
|
+
//
|
|
15
|
+
// Skills are managed artifacts. A pull installs the latest server-published
|
|
16
|
+
// generation snapshot, tracked in .moda/skills.yml, and prunes only files that a
|
|
17
|
+
// previous Moda pull wrote.
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
19
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmdirSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
20
|
+
import { basename, dirname, isAbsolute, join, normalize, relative, resolve } from 'node:path';
|
|
21
|
+
import { resolveApiKey, resolveIngestUrl, resolveTenantId } from './config.js';
|
|
22
|
+
import { claudeSkillToCursorRule } from './init/rules.js';
|
|
23
|
+
import { detectAgents } from './init/mcp.js';
|
|
24
|
+
import { CliInputError } from './errors.js';
|
|
25
|
+
const SKILLS_REQUEST_TIMEOUT_MS = 15_000;
|
|
26
|
+
const VALID_STATUSES = new Set(['approved', 'proposed', 'all']);
|
|
27
|
+
const SKILLS_MANIFEST_PATH = join('.moda', 'skills.yml');
|
|
28
|
+
/**
|
|
29
|
+
* Fetch a tenant's generated skills from the ingest worker. Uses the persisted
|
|
30
|
+
* API key + ingest URL (`~/.moda/config.json`), mirroring snapshot auth.
|
|
31
|
+
*/
|
|
32
|
+
async function fetchSkills(status) {
|
|
33
|
+
const apiKey = resolveApiKey();
|
|
34
|
+
if (!apiKey) {
|
|
35
|
+
throw new CliInputError('No Moda API key found. Run `moda init` first (or set MODA_API_KEY).');
|
|
36
|
+
}
|
|
37
|
+
const ingestUrl = resolveIngestUrl().replace(/\/$/, '');
|
|
38
|
+
const url = `${ingestUrl}/v1/skills?status=${encodeURIComponent(status)}`;
|
|
39
|
+
const controller = new AbortController();
|
|
40
|
+
const timeout = setTimeout(() => controller.abort(), SKILLS_REQUEST_TIMEOUT_MS);
|
|
41
|
+
let response;
|
|
42
|
+
try {
|
|
43
|
+
response = await fetch(url, {
|
|
44
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
45
|
+
signal: controller.signal,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
50
|
+
throw new Error(`Network error: failed to reach ${ingestUrl} (${message})`);
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
clearTimeout(timeout);
|
|
54
|
+
}
|
|
55
|
+
if (!response.ok) {
|
|
56
|
+
const body = await response.text().catch(() => '');
|
|
57
|
+
throw new Error(`Failed to fetch skills (HTTP ${response.status}): ${body.slice(0, 200)}`);
|
|
58
|
+
}
|
|
59
|
+
const data = (await response.json());
|
|
60
|
+
if (!data.success) {
|
|
61
|
+
throw new Error(data.message || 'Failed to fetch skills');
|
|
62
|
+
}
|
|
63
|
+
const skills = Array.isArray(data.skills) ? data.skills : [];
|
|
64
|
+
const release = data.release && typeof data.release === 'object' ? data.release : null;
|
|
65
|
+
return {
|
|
66
|
+
skills,
|
|
67
|
+
tenantId: data.tenant_id || resolveTenantId() || '',
|
|
68
|
+
release,
|
|
69
|
+
releaseKey: releaseKeyFor(release, skills, status),
|
|
70
|
+
status,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Write `content` to `filePath`, creating parent dirs. Returns whether the
|
|
75
|
+
* file was created, updated (content differed), or left unchanged.
|
|
76
|
+
*/
|
|
77
|
+
function writeIfChanged(filePath, content) {
|
|
78
|
+
try {
|
|
79
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
80
|
+
if (existsSync(filePath)) {
|
|
81
|
+
const current = readFileSync(filePath, 'utf8');
|
|
82
|
+
if (current === content)
|
|
83
|
+
return 'unchanged';
|
|
84
|
+
writeFileSync(filePath, content);
|
|
85
|
+
return 'updated';
|
|
86
|
+
}
|
|
87
|
+
writeFileSync(filePath, content);
|
|
88
|
+
return 'wrote';
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return 'failed';
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Pull generated skills into the detected coding agents' skill directories.
|
|
96
|
+
* Exported so `moda init` can run it as a setup step. Best-effort and quiet on
|
|
97
|
+
* an empty store; never throws for "no skills yet".
|
|
98
|
+
*/
|
|
99
|
+
export async function pullSkills(options = {}) {
|
|
100
|
+
const status = options.status ?? 'approved';
|
|
101
|
+
const cwd = options.cwd ?? process.cwd();
|
|
102
|
+
const log = (msg) => {
|
|
103
|
+
if (!options.silent)
|
|
104
|
+
console.log(msg);
|
|
105
|
+
};
|
|
106
|
+
const fetched = await fetchSkills(status);
|
|
107
|
+
const { skills } = fetched;
|
|
108
|
+
const summary = {
|
|
109
|
+
total: skills.length,
|
|
110
|
+
pruned: 0,
|
|
111
|
+
wrote: 0,
|
|
112
|
+
updated: 0,
|
|
113
|
+
unchanged: 0,
|
|
114
|
+
failed: 0,
|
|
115
|
+
};
|
|
116
|
+
if (skills.length === 0) {
|
|
117
|
+
log(' No skills to pull yet.');
|
|
118
|
+
return summary;
|
|
119
|
+
}
|
|
120
|
+
const previousManifest = readSkillsManifest(cwd);
|
|
121
|
+
if (previousManifest &&
|
|
122
|
+
previousManifest.managedPaths.length > 0 &&
|
|
123
|
+
previousManifest.releaseKey !== fetched.releaseKey) {
|
|
124
|
+
summary.pruned = pruneManagedSkillPaths(cwd, previousManifest.managedPaths, log);
|
|
125
|
+
}
|
|
126
|
+
// Always target Claude Code (canonical SKILL.md). Also write Cursor rules
|
|
127
|
+
// when the project has a .cursor directory.
|
|
128
|
+
const agents = detectAgents();
|
|
129
|
+
const writeCursor = agents.includes('Cursor') || existsSync(join(cwd, '.cursor'));
|
|
130
|
+
const records = [];
|
|
131
|
+
for (const skill of skills) {
|
|
132
|
+
if (!skill.skill_id || !skill.skill_md) {
|
|
133
|
+
summary.failed++;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
// Path-traversal guard: the API response is trusted but defense-in-depth
|
|
137
|
+
// for a compromised / MITM'd ingest endpoint. `basename` strips any
|
|
138
|
+
// leading directory components, and the allowlist rejects `..`, `/`,
|
|
139
|
+
// `\`, null bytes, and anything else outside a plain identifier so the
|
|
140
|
+
// resolved path cannot escape the per-skill directory.
|
|
141
|
+
const safeId = basename(skill.skill_id);
|
|
142
|
+
if (!safeId || !/^[A-Za-z0-9_-]+$/.test(safeId)) {
|
|
143
|
+
summary.failed++;
|
|
144
|
+
log(` skipped invalid skill_id: ${JSON.stringify(skill.skill_id).slice(0, 80)}`);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const claudePath = join(cwd, '.claude', 'skills', safeId, 'SKILL.md');
|
|
148
|
+
const claudeStatus = writeIfChanged(claudePath, skill.skill_md);
|
|
149
|
+
const claudeRelativePath = `.claude/skills/${safeId}/SKILL.md`;
|
|
150
|
+
recordOutcome(summary, claudeStatus, log, claudeRelativePath);
|
|
151
|
+
const paths = [];
|
|
152
|
+
if (claudeStatus !== 'failed')
|
|
153
|
+
paths.push(claudeRelativePath);
|
|
154
|
+
if (writeCursor) {
|
|
155
|
+
const cursorPath = join(cwd, '.cursor', 'rules', `${safeId}.mdc`);
|
|
156
|
+
const cursorStatus = writeIfChanged(cursorPath, claudeSkillToCursorRule(skill.skill_md));
|
|
157
|
+
const cursorRelativePath = `.cursor/rules/${safeId}.mdc`;
|
|
158
|
+
recordOutcome(summary, cursorStatus, log, cursorRelativePath);
|
|
159
|
+
if (cursorStatus !== 'failed')
|
|
160
|
+
paths.push(cursorRelativePath);
|
|
161
|
+
}
|
|
162
|
+
if (paths.length > 0) {
|
|
163
|
+
records.push({
|
|
164
|
+
skillId: safeId,
|
|
165
|
+
contentHash: sha256(skill.skill_md),
|
|
166
|
+
paths,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
writeSkillsManifest(cwd, fetched, records);
|
|
171
|
+
return summary;
|
|
172
|
+
}
|
|
173
|
+
function recordOutcome(summary, status, log, relativePath) {
|
|
174
|
+
switch (status) {
|
|
175
|
+
case 'wrote':
|
|
176
|
+
summary.wrote++;
|
|
177
|
+
log(` wrote ${relativePath}`);
|
|
178
|
+
break;
|
|
179
|
+
case 'updated':
|
|
180
|
+
summary.updated++;
|
|
181
|
+
log(` updated ${relativePath}`);
|
|
182
|
+
break;
|
|
183
|
+
case 'unchanged':
|
|
184
|
+
summary.unchanged++;
|
|
185
|
+
break;
|
|
186
|
+
case 'failed':
|
|
187
|
+
summary.failed++;
|
|
188
|
+
log(` failed ${relativePath}`);
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* `moda skills <subcommand>` dispatcher.
|
|
194
|
+
*/
|
|
195
|
+
export async function runSkillsCommand(args) {
|
|
196
|
+
const sub = args.positional;
|
|
197
|
+
if (sub === 'gen') {
|
|
198
|
+
const result = await generateSkills(args.flags);
|
|
199
|
+
if (args.flags.json === 'true') {
|
|
200
|
+
console.log(JSON.stringify(result));
|
|
201
|
+
}
|
|
202
|
+
else if (result.accepted) {
|
|
203
|
+
console.log([
|
|
204
|
+
'accepted: true',
|
|
205
|
+
result.generation_run_id && `generation_run_id: ${result.generation_run_id}`,
|
|
206
|
+
result.message && `message: ${result.message}`,
|
|
207
|
+
].filter(Boolean).join('\n'));
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
console.log([
|
|
211
|
+
`processed: ${result.sessions_processed ?? 0}`,
|
|
212
|
+
`failed: ${result.sessions_failed ?? 0}`,
|
|
213
|
+
`pending: ${result.sessions_pending ?? 0}`,
|
|
214
|
+
`pending_found: ${result.pending_sessions_found ?? 0}`,
|
|
215
|
+
`distilled: ${Number(result.cluster_result?.distilled_skills_generated ?? 0)}`,
|
|
216
|
+
`indexed: ${Number(result.cluster_result?.skills_indexed ?? 0)}`,
|
|
217
|
+
].join('\n'));
|
|
218
|
+
}
|
|
219
|
+
return Number(result.sessions_failed ?? 0) > 0 ? 1 : 0;
|
|
220
|
+
}
|
|
221
|
+
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]');
|
|
223
|
+
}
|
|
224
|
+
const status = (args.flags.status ?? 'approved').toLowerCase();
|
|
225
|
+
if (!VALID_STATUSES.has(status)) {
|
|
226
|
+
throw new CliInputError(`Invalid --status '${status}'`, `Must be one of: ${[...VALID_STATUSES].join(', ')}`);
|
|
227
|
+
}
|
|
228
|
+
const summary = await pullSkills({ status });
|
|
229
|
+
const parts = [
|
|
230
|
+
summary.pruned && `${summary.pruned} pruned`,
|
|
231
|
+
summary.wrote && `${summary.wrote} written`,
|
|
232
|
+
summary.updated && `${summary.updated} updated`,
|
|
233
|
+
summary.unchanged && `${summary.unchanged} unchanged`,
|
|
234
|
+
summary.failed && `${summary.failed} failed`,
|
|
235
|
+
].filter(Boolean);
|
|
236
|
+
if (summary.total > 0) {
|
|
237
|
+
console.log(` Done — ${parts.join(', ') || 'no changes'}`);
|
|
238
|
+
}
|
|
239
|
+
return summary.failed > 0 ? 1 : 0;
|
|
240
|
+
}
|
|
241
|
+
async function generateSkills(flags) {
|
|
242
|
+
const apiKey = resolveApiKey();
|
|
243
|
+
if (!apiKey) {
|
|
244
|
+
throw new CliInputError('No Moda API key found. Run `moda init` first (or set MODA_API_KEY).');
|
|
245
|
+
}
|
|
246
|
+
const tenantId = flags['tenant-id'] || resolveTenantId();
|
|
247
|
+
const directHarnessUrl = flags['harness-url'] || process.env.MODA_SKILL_HARNESS_URL;
|
|
248
|
+
if (directHarnessUrl && !tenantId) {
|
|
249
|
+
throw new CliInputError('No Moda tenant id found. Run `moda init` first (or set MODA_TENANT_ID / --tenant-id).');
|
|
250
|
+
}
|
|
251
|
+
const endpoint = directHarnessUrl
|
|
252
|
+
? `${directHarnessUrl.replace(/\/+$/, '')}/skills/generate`
|
|
253
|
+
: `${resolveIngestUrl().replace(/\/+$/, '')}/v1/skills/generate`;
|
|
254
|
+
const waitForCompletion = flags.wait === 'true' || flags['wait-for-completion'] === 'true';
|
|
255
|
+
const forceReprocess = flags['force-reprocess'] === 'true';
|
|
256
|
+
const body = compactObject({
|
|
257
|
+
tenant_id: tenantId || undefined,
|
|
258
|
+
max_sessions: parsePositiveInt(flags['max-sessions'], 0),
|
|
259
|
+
lookback_hours: parsePositiveInt(flags['lookback-hours'], 0),
|
|
260
|
+
start_at: flags['start-at'] || undefined,
|
|
261
|
+
end_at: flags['end-at'] || undefined,
|
|
262
|
+
clustering_run_id: flags['clustering-run-id'] || undefined,
|
|
263
|
+
run_clustering: flags['no-clustering'] !== 'true',
|
|
264
|
+
source: normalizeSkillSource(flags.source),
|
|
265
|
+
clustering_timeout_seconds: parsePositiveInt(flags['clustering-timeout-seconds'], 0),
|
|
266
|
+
clustering_poll_seconds: parsePositiveInt(flags['clustering-poll-seconds'], 15),
|
|
267
|
+
max_cluster_pct: parseOptionalFloat(flags['max-cluster-pct']),
|
|
268
|
+
reprocess_segments: flags['reprocess-segments'] === 'true' || forceReprocess,
|
|
269
|
+
run_replay: flags.replay === 'true' && flags['no-replay'] !== 'true',
|
|
270
|
+
max_traces: parsePositiveInt(flags['max-traces'], 0),
|
|
271
|
+
improve_candidates: flags['improve-candidates'] === 'true',
|
|
272
|
+
improvement_rounds: parsePositiveInt(flags['improvement-rounds'], 2),
|
|
273
|
+
force_reprocess: forceReprocess,
|
|
274
|
+
wait_for_completion: waitForCompletion,
|
|
275
|
+
});
|
|
276
|
+
const headers = {
|
|
277
|
+
Authorization: `Bearer ${apiKey}`,
|
|
278
|
+
'Content-Type': 'application/json',
|
|
279
|
+
};
|
|
280
|
+
if (tenantId)
|
|
281
|
+
headers['X-Moda-Tenant-Id'] = tenantId;
|
|
282
|
+
const response = await fetch(endpoint, {
|
|
283
|
+
method: 'POST',
|
|
284
|
+
headers,
|
|
285
|
+
body: JSON.stringify(body),
|
|
286
|
+
});
|
|
287
|
+
if (!response.ok) {
|
|
288
|
+
const text = await response.text().catch(() => '');
|
|
289
|
+
throw new Error(`Failed to generate skills (HTTP ${response.status}): ${text.slice(0, 200)}`);
|
|
290
|
+
}
|
|
291
|
+
return (await response.json());
|
|
292
|
+
}
|
|
293
|
+
function parsePositiveInt(value, fallback) {
|
|
294
|
+
if (!value)
|
|
295
|
+
return fallback;
|
|
296
|
+
const parsed = Number.parseInt(value, 10);
|
|
297
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
|
|
298
|
+
}
|
|
299
|
+
function parseOptionalFloat(value) {
|
|
300
|
+
if (!value)
|
|
301
|
+
return undefined;
|
|
302
|
+
const parsed = Number.parseFloat(value);
|
|
303
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
304
|
+
}
|
|
305
|
+
function compactObject(input) {
|
|
306
|
+
return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
|
|
307
|
+
}
|
|
308
|
+
function normalizeSkillSource(value) {
|
|
309
|
+
const normalized = (value || 'all').toLowerCase().replace(/-/g, '_');
|
|
310
|
+
if (['all', 'sdk', 'coding_agent'].includes(normalized))
|
|
311
|
+
return normalized;
|
|
312
|
+
return 'all';
|
|
313
|
+
}
|
|
314
|
+
function releaseKeyFor(release, skills, status) {
|
|
315
|
+
if (release?.generation_run_id)
|
|
316
|
+
return `${status}:run:${release.generation_run_id}`;
|
|
317
|
+
if (Number(release?.generation_version || 0) > 0) {
|
|
318
|
+
return `${status}:version:${Number(release?.generation_version)}`;
|
|
319
|
+
}
|
|
320
|
+
return `${status}:legacy:${sha256(JSON.stringify(skills.map((skill) => ({
|
|
321
|
+
skill_id: skill.skill_id,
|
|
322
|
+
hash: sha256(skill.skill_md || ''),
|
|
323
|
+
})).sort((a, b) => a.skill_id.localeCompare(b.skill_id))))}`;
|
|
324
|
+
}
|
|
325
|
+
function readSkillsManifest(cwd) {
|
|
326
|
+
const manifestPath = join(cwd, SKILLS_MANIFEST_PATH);
|
|
327
|
+
if (!existsSync(manifestPath))
|
|
328
|
+
return null;
|
|
329
|
+
let text = '';
|
|
330
|
+
try {
|
|
331
|
+
text = readFileSync(manifestPath, 'utf8');
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
return {
|
|
337
|
+
tenantId: readYamlScalar(text, 'tenant_id'),
|
|
338
|
+
generationRunId: readYamlScalar(text, 'generation_run_id'),
|
|
339
|
+
generationVersion: Number(readYamlScalar(text, 'generation_version') || 0),
|
|
340
|
+
releaseKey: readYamlScalar(text, 'release_key'),
|
|
341
|
+
releasedAt: readYamlScalar(text, 'released_at'),
|
|
342
|
+
managedPaths: readManagedPaths(text),
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
function writeSkillsManifest(cwd, fetched, records) {
|
|
346
|
+
const manifestPath = join(cwd, SKILLS_MANIFEST_PATH);
|
|
347
|
+
const managedPaths = [...new Set(records.flatMap((record) => record.paths))].sort();
|
|
348
|
+
const release = fetched.release;
|
|
349
|
+
const lines = [
|
|
350
|
+
'version: 1',
|
|
351
|
+
'source: moda',
|
|
352
|
+
`tenant_id: ${yamlQuote(fetched.tenantId)}`,
|
|
353
|
+
`release_key: ${yamlQuote(fetched.releaseKey)}`,
|
|
354
|
+
`status_filter: ${yamlQuote(fetched.status)}`,
|
|
355
|
+
`generation_run_id: ${yamlQuote(release?.generation_run_id || '')}`,
|
|
356
|
+
`generation_version: ${Number(release?.generation_version || 0)}`,
|
|
357
|
+
`released_at: ${yamlQuote(release?.released_at || '')}`,
|
|
358
|
+
`installed_at: ${yamlQuote(new Date().toISOString())}`,
|
|
359
|
+
'managed_paths:',
|
|
360
|
+
...managedPaths.map((path) => ` - ${yamlQuote(path)}`),
|
|
361
|
+
'skills:',
|
|
362
|
+
...records.flatMap((record) => [
|
|
363
|
+
` - skill_id: ${yamlQuote(record.skillId)}`,
|
|
364
|
+
` content_hash: ${yamlQuote(record.contentHash)}`,
|
|
365
|
+
' paths:',
|
|
366
|
+
...record.paths.map((path) => ` - ${yamlQuote(path)}`),
|
|
367
|
+
]),
|
|
368
|
+
'',
|
|
369
|
+
];
|
|
370
|
+
mkdirSync(dirname(manifestPath), { recursive: true });
|
|
371
|
+
writeFileSync(manifestPath, lines.join('\n'));
|
|
372
|
+
}
|
|
373
|
+
function pruneManagedSkillPaths(cwd, managedPaths, log) {
|
|
374
|
+
let pruned = 0;
|
|
375
|
+
for (const rawPath of [...new Set(managedPaths)]) {
|
|
376
|
+
const safePath = safeManagedRelativePath(rawPath);
|
|
377
|
+
if (!safePath)
|
|
378
|
+
continue;
|
|
379
|
+
const absolutePath = absoluteManagedPath(cwd, safePath);
|
|
380
|
+
if (!absolutePath || !existsSync(absolutePath))
|
|
381
|
+
continue;
|
|
382
|
+
try {
|
|
383
|
+
unlinkSync(absolutePath);
|
|
384
|
+
pruned++;
|
|
385
|
+
log(` pruned ${safePath}`);
|
|
386
|
+
pruneEmptyClaudeSkillDir(cwd, safePath);
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
// Best-effort cleanup. The following write pass can still repair files
|
|
390
|
+
// that remain writable.
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return pruned;
|
|
394
|
+
}
|
|
395
|
+
function pruneEmptyClaudeSkillDir(cwd, relativePath) {
|
|
396
|
+
if (!relativePath.startsWith('.claude/skills/') || !relativePath.endsWith('/SKILL.md'))
|
|
397
|
+
return;
|
|
398
|
+
const dir = dirname(join(cwd, relativePath));
|
|
399
|
+
try {
|
|
400
|
+
if (existsSync(dir) && readdirSync(dir).length === 0) {
|
|
401
|
+
rmdirSync(dir);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
// Empty-dir pruning is only cosmetic.
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
function absoluteManagedPath(cwd, relativePath) {
|
|
409
|
+
const abs = resolve(cwd, relativePath);
|
|
410
|
+
const root = resolve(cwd);
|
|
411
|
+
const rel = relative(root, abs);
|
|
412
|
+
if (rel.startsWith('..') || isAbsolute(rel))
|
|
413
|
+
return null;
|
|
414
|
+
return abs;
|
|
415
|
+
}
|
|
416
|
+
function safeManagedRelativePath(rawPath) {
|
|
417
|
+
if (!rawPath || rawPath.includes('\0') || isAbsolute(rawPath))
|
|
418
|
+
return null;
|
|
419
|
+
const normalized = normalize(rawPath).replace(/\\/g, '/');
|
|
420
|
+
if (normalized.startsWith('../') || normalized === '..' || normalized.includes('/../'))
|
|
421
|
+
return null;
|
|
422
|
+
const isClaudeSkill = normalized.startsWith('.claude/skills/') && normalized.endsWith('/SKILL.md');
|
|
423
|
+
const isCursorRule = normalized.startsWith('.cursor/rules/') && normalized.endsWith('.mdc');
|
|
424
|
+
return isClaudeSkill || isCursorRule ? normalized : null;
|
|
425
|
+
}
|
|
426
|
+
function readYamlScalar(text, key) {
|
|
427
|
+
const match = text.match(new RegExp(`^${key}:\\s*(.*?)\\s*$`, 'm'));
|
|
428
|
+
return match ? parseYamlScalar(match[1]) : '';
|
|
429
|
+
}
|
|
430
|
+
function readManagedPaths(text) {
|
|
431
|
+
const paths = [];
|
|
432
|
+
let inManagedPaths = false;
|
|
433
|
+
for (const line of text.split(/\r?\n/)) {
|
|
434
|
+
if (line.trim() === 'managed_paths:') {
|
|
435
|
+
inManagedPaths = true;
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
if (inManagedPaths && line.length > 0 && !line.startsWith(' '))
|
|
439
|
+
break;
|
|
440
|
+
if (!inManagedPaths)
|
|
441
|
+
continue;
|
|
442
|
+
const match = line.match(/^\s*-\s*(.*?)\s*$/);
|
|
443
|
+
if (match)
|
|
444
|
+
paths.push(parseYamlScalar(match[1]));
|
|
445
|
+
}
|
|
446
|
+
return paths.filter(Boolean);
|
|
447
|
+
}
|
|
448
|
+
function parseYamlScalar(raw) {
|
|
449
|
+
const value = raw.trim();
|
|
450
|
+
if (!value)
|
|
451
|
+
return '';
|
|
452
|
+
if (value.startsWith('"')) {
|
|
453
|
+
try {
|
|
454
|
+
return String(JSON.parse(value));
|
|
455
|
+
}
|
|
456
|
+
catch {
|
|
457
|
+
return value.replace(/^"|"$/g, '');
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return value.replace(/^'|'$/g, '');
|
|
461
|
+
}
|
|
462
|
+
function yamlQuote(value) {
|
|
463
|
+
return JSON.stringify(value);
|
|
464
|
+
}
|
|
465
|
+
function sha256(input) {
|
|
466
|
+
return createHash('sha256').update(input).digest('hex');
|
|
467
|
+
}
|
|
468
|
+
//# sourceMappingURL=skills.js.map
|
package/dist/sync.d.ts
ADDED
package/dist/sync.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { CliInputError } from './errors.js';
|
|
2
|
+
import { runHarnessCommand } from './harness.js';
|
|
3
|
+
import { runPromptsCommand } from './prompts.js';
|
|
4
|
+
export async function runSyncCommand(context) {
|
|
5
|
+
const resource = context.positionals[0] ?? 'harness';
|
|
6
|
+
const rest = context.positionals.slice(1);
|
|
7
|
+
if (resource === 'harness') {
|
|
8
|
+
return runHarnessCommand({
|
|
9
|
+
...context,
|
|
10
|
+
command: 'harness',
|
|
11
|
+
positional: 'sync',
|
|
12
|
+
positionals: ['sync', ...rest],
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
if (resource === 'prompts') {
|
|
16
|
+
await runPromptsCommand(['sync', ...rest], context.flags, {
|
|
17
|
+
profile: context.profile,
|
|
18
|
+
env: context.env,
|
|
19
|
+
});
|
|
20
|
+
return { exitCode: 0 };
|
|
21
|
+
}
|
|
22
|
+
if (resource === 'skills') {
|
|
23
|
+
throw new CliInputError('`moda sync skills` is not implemented yet.', 'Use `moda skills gen` to ask Moda to generate skills, then `moda skills pull` to install approved skills locally.');
|
|
24
|
+
}
|
|
25
|
+
throw new CliInputError(`Unknown sync resource '${resource}'.`, 'Usage: moda sync [harness|prompts] [--dry-run] [--profile=NAME]');
|
|
26
|
+
}
|
|
27
|
+
//# sourceMappingURL=sync.js.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type TelemetryStatus = 'success' | 'error';
|
|
2
|
+
export type TelemetryErrorType = 'validation_error' | 'api_error' | 'network_error' | 'unknown_error';
|
|
3
|
+
export interface CliUsageTelemetryInput {
|
|
4
|
+
apiKey: string | undefined;
|
|
5
|
+
command: string;
|
|
6
|
+
flags: Record<string, string>;
|
|
7
|
+
status: TelemetryStatus;
|
|
8
|
+
errorType?: TelemetryErrorType;
|
|
9
|
+
exitCode: number;
|
|
10
|
+
durationMs: number;
|
|
11
|
+
apiRequestCount: number;
|
|
12
|
+
}
|
|
13
|
+
export declare function stripTelemetryFlag(flags: Record<string, string>): Record<string, string>;
|
|
14
|
+
export declare function isTelemetryDisabled(flags: Record<string, string>): boolean;
|
|
15
|
+
export declare function getInvocationId(): string;
|
|
16
|
+
export declare function sanitizedFlagNames(flags: Record<string, string>): string[];
|
|
17
|
+
export declare function sendCliUsageTelemetry(input: CliUsageTelemetryInput): Promise<void>;
|
|
18
|
+
//# sourceMappingURL=telemetry.d.ts.map
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { VERSION, resolveModaBaseUrl } from './config.js';
|
|
3
|
+
const TELEMETRY_TIMEOUT_MS = 500;
|
|
4
|
+
const INVOCATION_ID = randomUUID();
|
|
5
|
+
const TELEMETRY_FLAG = 'no-telemetry';
|
|
6
|
+
export function stripTelemetryFlag(flags) {
|
|
7
|
+
const { [TELEMETRY_FLAG]: _drop, ...rest } = flags;
|
|
8
|
+
return rest;
|
|
9
|
+
}
|
|
10
|
+
export function isTelemetryDisabled(flags) {
|
|
11
|
+
return (flags[TELEMETRY_FLAG] === 'true' ||
|
|
12
|
+
process.env.MODA_CLI_TELEMETRY === '0' ||
|
|
13
|
+
process.env.DO_NOT_TRACK === '1');
|
|
14
|
+
}
|
|
15
|
+
export function getInvocationId() {
|
|
16
|
+
return INVOCATION_ID;
|
|
17
|
+
}
|
|
18
|
+
export function sanitizedFlagNames(flags) {
|
|
19
|
+
return Object.keys(stripTelemetryFlag(flags)).sort();
|
|
20
|
+
}
|
|
21
|
+
export async function sendCliUsageTelemetry(input) {
|
|
22
|
+
if (!input.apiKey || isTelemetryDisabled(input.flags)) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const controller = new AbortController();
|
|
26
|
+
const timeout = setTimeout(() => controller.abort(), TELEMETRY_TIMEOUT_MS);
|
|
27
|
+
// Allow Node's event loop to drain naturally without the timer keeping
|
|
28
|
+
// the process alive after telemetry resolves.
|
|
29
|
+
if (typeof timeout.unref === 'function') {
|
|
30
|
+
timeout.unref();
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
await fetch(`${resolveModaBaseUrl()}/api/v1/data/cli-usage`, {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
headers: {
|
|
36
|
+
'x-api-key': input.apiKey,
|
|
37
|
+
'Content-Type': 'application/json',
|
|
38
|
+
},
|
|
39
|
+
body: JSON.stringify({
|
|
40
|
+
invocation_id: INVOCATION_ID,
|
|
41
|
+
command: input.command.slice(0, 64),
|
|
42
|
+
status: input.status,
|
|
43
|
+
error_type: input.errorType,
|
|
44
|
+
exit_code: input.exitCode,
|
|
45
|
+
duration_ms: Math.max(0, Math.round(input.durationMs)),
|
|
46
|
+
api_request_count: Math.max(0, Math.round(input.apiRequestCount)),
|
|
47
|
+
cli_version: VERSION,
|
|
48
|
+
node_version: process.versions.node,
|
|
49
|
+
platform: process.platform,
|
|
50
|
+
arch: process.arch,
|
|
51
|
+
flags_json: { names: sanitizedFlagNames(input.flags) },
|
|
52
|
+
}),
|
|
53
|
+
signal: controller.signal,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// Telemetry must never affect command output or exit behavior.
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
clearTimeout(timeout);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=telemetry.js.map
|
package/dist/tui.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { DoctorCheckStatus, DoctorReport, StatusPayload } from './doctor.js';
|
|
2
|
+
import type { InitPhaseResult, InitSetupAction } from './init/index.js';
|
|
3
|
+
type TerminalBadge = DoctorCheckStatus | 'info' | 'run';
|
|
4
|
+
export declare function renderDoctorHuman(report: DoctorReport, options?: {
|
|
5
|
+
color?: boolean;
|
|
6
|
+
width?: number;
|
|
7
|
+
}): string;
|
|
8
|
+
export declare function renderStatusHuman(status: StatusPayload, options?: {
|
|
9
|
+
color?: boolean;
|
|
10
|
+
width?: number;
|
|
11
|
+
}): string;
|
|
12
|
+
export declare function renderInitBanner(): string;
|
|
13
|
+
export declare function renderInitStep(index: number, total: number, title: string, detail?: string): string;
|
|
14
|
+
export declare function renderInitNote(message: string, status?: DoctorCheckStatus): string;
|
|
15
|
+
export declare function renderInitSetupPlan(actions: InitSetupAction[]): string;
|
|
16
|
+
export declare function renderInitComplete(options: {
|
|
17
|
+
tenantName: string;
|
|
18
|
+
filesChanged: string[];
|
|
19
|
+
skipped: InitSetupAction[];
|
|
20
|
+
phases: InitPhaseResult[];
|
|
21
|
+
}): string;
|
|
22
|
+
export declare function renderHarnessProgress(message: string, status?: TerminalBadge): string;
|
|
23
|
+
export declare function renderHarnessAnalystPreflight(options: {
|
|
24
|
+
root: string;
|
|
25
|
+
willRead: readonly string[];
|
|
26
|
+
willSkip: readonly string[];
|
|
27
|
+
}): string;
|
|
28
|
+
export declare function renderHarnessAnalystLaunch(options: {
|
|
29
|
+
adapterName: string;
|
|
30
|
+
command: string;
|
|
31
|
+
root: string;
|
|
32
|
+
reportPath: string;
|
|
33
|
+
}): string;
|
|
34
|
+
export declare function renderHarnessAnalystChoice(options: {
|
|
35
|
+
detected: readonly string[];
|
|
36
|
+
}): string;
|
|
37
|
+
export declare function renderHarnessAnalyzeHumanResult(data: unknown): string;
|
|
38
|
+
export declare function renderOverviewHuman(data: unknown): string;
|
|
39
|
+
export {};
|
|
40
|
+
//# sourceMappingURL=tui.d.ts.map
|