@moda-ai/cli 1.11.0 → 1.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/cli.js +5 -0
  2. package/dist/harness-remote.d.ts +33 -0
  3. package/dist/harness-remote.js +203 -0
  4. package/dist/harness.d.ts +75 -0
  5. package/dist/harness.js +92 -37
  6. package/dist/init/index.js +14 -4
  7. package/dist/init/sdk-guide.d.ts +28 -1
  8. package/dist/init/sdk-guide.js +102 -98
  9. package/dist/init/sdk-integration.d.ts +23 -3
  10. package/dist/init/sdk-integration.js +106 -24
  11. package/dist/init/skill-resolver.d.ts +19 -0
  12. package/dist/init/skill-resolver.js +78 -0
  13. package/dist/skills-bundled.d.ts +35 -0
  14. package/dist/skills-bundled.js +157 -0
  15. package/dist/skills-manifest.d.ts +33 -0
  16. package/dist/skills-manifest.js +244 -0
  17. package/dist/skills.js +22 -142
  18. package/package.json +6 -4
  19. package/skills/integration/index.json +123 -0
  20. package/skills/integration/integration-node-anthropic/EXAMPLE.md +59 -0
  21. package/skills/integration/integration-node-anthropic/SKILL.md +150 -0
  22. package/skills/integration/integration-node-generic/EXAMPLE.md +77 -0
  23. package/skills/integration/integration-node-generic/SKILL.md +170 -0
  24. package/skills/integration/integration-node-langchain/EXAMPLE.md +59 -0
  25. package/skills/integration/integration-node-langchain/SKILL.md +144 -0
  26. package/skills/integration/integration-node-openai/EXAMPLE.md +55 -0
  27. package/skills/integration/integration-node-openai/SKILL.md +148 -0
  28. package/skills/integration/integration-node-vercel-ai-sdk/EXAMPLE.md +55 -0
  29. package/skills/integration/integration-node-vercel-ai-sdk/SKILL.md +179 -0
  30. package/skills/integration/integration-node-vercel-ai-sdk/references/prompt-attribution.md +31 -0
  31. package/skills/integration/integration-python-anthropic/EXAMPLE.md +66 -0
  32. package/skills/integration/integration-python-anthropic/SKILL.md +153 -0
  33. package/skills/integration/integration-python-claude-agent-sdk/EXAMPLE.md +61 -0
  34. package/skills/integration/integration-python-claude-agent-sdk/SKILL.md +129 -0
  35. package/skills/integration/integration-python-generic/EXAMPLE.md +46 -0
  36. package/skills/integration/integration-python-generic/SKILL.md +146 -0
  37. package/skills/integration/integration-python-langchain/EXAMPLE.md +63 -0
  38. package/skills/integration/integration-python-langchain/SKILL.md +141 -0
  39. package/skills/integration/integration-python-openai/EXAMPLE.md +65 -0
  40. package/skills/integration/integration-python-openai/SKILL.md +151 -0
  41. package/dist/init/detect.d.ts +0 -9
  42. package/dist/init/detect.js +0 -179
@@ -0,0 +1,157 @@
1
+ // src/skills-bundled.ts — read and materialize the integration skills bundled
2
+ // inside the npm package (skills/integration/, generated at build time by
3
+ // scripts/bundle-skills.ts from packages/integration-skills/).
4
+ //
5
+ // Materialization copies the full skill directory (SKILL.md, EXAMPLE.md,
6
+ // references/) into the project's .claude/skills/<id>/, translates SKILL.md
7
+ // into a Cursor rule when the project uses Cursor, and records the install in
8
+ // .moda/skills.yml with source: integration-bundled so `skills sync` never
9
+ // pushes these files and harness release changes never prune them.
10
+ import { createHash } from 'node:crypto';
11
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
12
+ import { dirname, join, relative } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { claudeSkillToCursorRule } from './init/rules.js';
15
+ import { detectAgents } from './init/mcp.js';
16
+ import { readSkillsManifest, SKILL_SOURCE_INTEGRATION_BUNDLED, upsertSkillManifestRecord, } from './skills-manifest.js';
17
+ // Resolved relative to this module so it works from src/ in dev and from the
18
+ // published dist/ inside node_modules (same pattern as src/init/rules.ts).
19
+ const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
20
+ const BUNDLED_SKILLS_DIR = join(PACKAGE_ROOT, 'skills', 'integration');
21
+ /** Directory holding the bundled skills (overridable for tests). */
22
+ export function bundledSkillsDir() {
23
+ return process.env.MODA_BUNDLED_SKILLS_DIR || BUNDLED_SKILLS_DIR;
24
+ }
25
+ /** Read the bundled skill menu, or null when no bundle is present. */
26
+ export function readBundledIndex() {
27
+ const indexPath = join(bundledSkillsDir(), 'index.json');
28
+ if (!existsSync(indexPath))
29
+ return null;
30
+ try {
31
+ const parsed = JSON.parse(readFileSync(indexPath, 'utf8'));
32
+ if (!Array.isArray(parsed.skills))
33
+ return null;
34
+ return parsed;
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ }
40
+ /** Read a bundled skill's SKILL.md, or null when it is not bundled. */
41
+ export function readBundledSkillMd(skillId) {
42
+ const path = join(bundledSkillsDir(), skillId, 'SKILL.md');
43
+ if (!existsSync(path))
44
+ return null;
45
+ try {
46
+ return readFileSync(path, 'utf8');
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ }
52
+ /**
53
+ * Materialize a bundled integration skill into the project.
54
+ *
55
+ * Idempotent: unchanged files are left untouched, and a second install with
56
+ * identical content reports status 'unchanged' without writing.
57
+ */
58
+ export function installIntegrationSkill(skillId, cwd = process.cwd()) {
59
+ if (!/^[a-z0-9-]+$/.test(skillId)) {
60
+ throw new Error(`Invalid integration skill id: ${JSON.stringify(skillId).slice(0, 80)}`);
61
+ }
62
+ const sourceDir = join(bundledSkillsDir(), skillId);
63
+ if (!existsSync(join(sourceDir, 'SKILL.md'))) {
64
+ const bundled = readBundledIndex()?.skills.map((skill) => skill.id) ?? [];
65
+ throw new Error(`Integration skill '${skillId}' is not bundled with this CLI.${bundled.length > 0 ? ` Bundled skills: ${bundled.join(', ')}` : ''}`);
66
+ }
67
+ const files = listFilesRecursive(sourceDir).sort();
68
+ // Plan every write up front (skill files + optional Cursor rule) so the
69
+ // ownership guard can reject the whole install before touching disk.
70
+ const plannedWrites = [];
71
+ for (const file of files) {
72
+ const relPath = relative(sourceDir, file).replace(/\\/g, '/');
73
+ plannedWrites.push({
74
+ targetRelPath: `.claude/skills/${skillId}/${relPath}`,
75
+ targetPath: join(cwd, '.claude', 'skills', skillId, relPath),
76
+ content: readFileSync(file, 'utf8'),
77
+ });
78
+ }
79
+ // Cursor translation of the entry point, when the project uses Cursor.
80
+ const writeCursor = detectAgents().includes('Cursor') || existsSync(join(cwd, '.cursor'));
81
+ if (writeCursor) {
82
+ const skillMd = readFileSync(join(sourceDir, 'SKILL.md'), 'utf8');
83
+ plannedWrites.push({
84
+ targetRelPath: `.cursor/rules/${skillId}.mdc`,
85
+ targetPath: join(cwd, '.cursor', 'rules', `${skillId}.mdc`),
86
+ content: claudeSkillToCursorRule(skillMd),
87
+ });
88
+ }
89
+ // Ownership guard: never clobber a file Moda does not already manage. A repo
90
+ // that happens to carry a user-authored .claude/skills/<id>/... or
91
+ // .cursor/rules/<id>.mdc at one of our target paths must not be silently
92
+ // overwritten. Paths recorded in .moda/skills.yml (any source) are ours, so
93
+ // idempotent re-installs still pass; only genuinely foreign files trip this.
94
+ const ownedPaths = new Set((readSkillsManifest(cwd)?.records ?? []).flatMap((record) => record.paths));
95
+ const collisions = plannedWrites.filter((write) => existsSync(write.targetPath) && !ownedPaths.has(write.targetRelPath));
96
+ if (collisions.length > 0) {
97
+ throw new Error(`Refusing to overwrite ${collisions.length} existing file(s) not managed by Moda: ` +
98
+ `${collisions.map((write) => write.targetRelPath).join(', ')}. ` +
99
+ 'Move or remove them, then re-run to install the bundled integration skill.');
100
+ }
101
+ const paths = [];
102
+ let wroteAny = false;
103
+ for (const write of plannedWrites) {
104
+ if (writeIfChanged(write.targetPath, write.content))
105
+ wroteAny = true;
106
+ paths.push(write.targetRelPath);
107
+ }
108
+ upsertSkillManifestRecord(cwd, {
109
+ skillId,
110
+ contentHash: hashSkillDir(sourceDir, files),
111
+ source: SKILL_SOURCE_INTEGRATION_BUNDLED,
112
+ paths,
113
+ });
114
+ return { skillId, status: wroteAny ? 'installed' : 'unchanged', paths };
115
+ }
116
+ function writeIfChanged(path, content) {
117
+ if (existsSync(path)) {
118
+ try {
119
+ if (readFileSync(path, 'utf8') === content)
120
+ return false;
121
+ }
122
+ catch {
123
+ // Unreadable existing file: fall through and rewrite.
124
+ }
125
+ }
126
+ mkdirSync(dirname(path), { recursive: true });
127
+ writeFileSync(path, content);
128
+ return true;
129
+ }
130
+ function listFilesRecursive(dir) {
131
+ const files = [];
132
+ const stack = [dir];
133
+ while (stack.length > 0) {
134
+ const current = stack.pop();
135
+ if (!current)
136
+ continue;
137
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
138
+ const path = join(current, entry.name);
139
+ if (entry.isDirectory())
140
+ stack.push(path);
141
+ else if (entry.isFile())
142
+ files.push(path);
143
+ }
144
+ }
145
+ return files;
146
+ }
147
+ function hashSkillDir(sourceDir, files) {
148
+ const hash = createHash('sha256');
149
+ for (const file of files) {
150
+ hash.update(relative(sourceDir, file).replace(/\\/g, '/'));
151
+ hash.update('\0');
152
+ hash.update(readFileSync(file));
153
+ hash.update('\0');
154
+ }
155
+ return hash.digest('hex');
156
+ }
157
+ //# sourceMappingURL=skills-bundled.js.map
@@ -0,0 +1,33 @@
1
+ export declare const SKILLS_MANIFEST_PATH: string;
2
+ export declare const SKILL_SOURCE_HARNESS = "moda";
3
+ export declare const SKILL_SOURCE_INTEGRATION_BUNDLED = "integration-bundled";
4
+ export interface SkillInstallRecord {
5
+ skillId: string;
6
+ contentHash: string;
7
+ source: string;
8
+ paths: string[];
9
+ }
10
+ export interface ManifestHeader {
11
+ tenantId: string;
12
+ releaseKey: string;
13
+ statusFilter: string;
14
+ generationRunId: string;
15
+ generationVersion: number;
16
+ releasedAt: string;
17
+ }
18
+ export interface SkillsManifest extends ManifestHeader {
19
+ managedPaths: string[];
20
+ records: SkillInstallRecord[];
21
+ }
22
+ export declare function readSkillsManifest(cwd: string): SkillsManifest | null;
23
+ export declare function manifestHeaderFrom(manifest: SkillsManifest | null): ManifestHeader;
24
+ export declare function writeSkillsManifest(cwd: string, header: ManifestHeader, records: SkillInstallRecord[]): void;
25
+ /**
26
+ * Upsert a skill record into .moda/skills.yml, preserving the harness release
27
+ * header and all other records. Used by the bundled integration-skill
28
+ * installer so its materialized files become managed paths (excluded from
29
+ * `skills sync`, never pruned by harness release changes).
30
+ */
31
+ export declare function upsertSkillManifestRecord(cwd: string, record: SkillInstallRecord): void;
32
+ export declare function pruneManagedSkillPaths(cwd: string, managedPaths: string[], log: (msg: string) => void): number;
33
+ //# sourceMappingURL=skills-manifest.d.ts.map
@@ -0,0 +1,244 @@
1
+ // src/skills-manifest.ts — the .moda/skills.yml install manifest.
2
+ //
3
+ // The manifest records every skill file Moda manages inside a user's repo so
4
+ // that (a) `skills sync` never pushes Moda-managed files back up, and (b) a
5
+ // harness release change prunes exactly the files the previous release wrote.
6
+ // Two producers write records here: `skills pull` (harness-generated skills,
7
+ // source 'moda') and the bundled integration-skill installer
8
+ // (src/skills-bundled.ts, source 'integration-bundled').
9
+ //
10
+ // IMPORTANT: the YAML here is hand-rolled on both sides — writeSkillsManifest
11
+ // emits a fixed 2/4/6-space layout and readSkillRecords/readManagedPaths parse
12
+ // exactly that layout. Keep reader and writer in lockstep; they live in this
13
+ // module together so a format change cannot land on one side only.
14
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmdirSync, unlinkSync, writeFileSync, } from 'node:fs';
15
+ import { dirname, isAbsolute, join, normalize, relative, resolve } from 'node:path';
16
+ export const SKILLS_MANIFEST_PATH = join('.moda', 'skills.yml');
17
+ // Per-record `source` values. Harness-generated skills (pulled from the
18
+ // tenant store) keep the legacy value 'moda'; skills materialized from the
19
+ // CLI's bundled integration set use 'integration-bundled'. Pruning on a
20
+ // release change is scoped to harness records only, and `skills sync`
21
+ // excludes every managed path regardless of source.
22
+ export const SKILL_SOURCE_HARNESS = 'moda';
23
+ export const SKILL_SOURCE_INTEGRATION_BUNDLED = 'integration-bundled';
24
+ export function readSkillsManifest(cwd) {
25
+ const manifestPath = join(cwd, SKILLS_MANIFEST_PATH);
26
+ if (!existsSync(manifestPath))
27
+ return null;
28
+ let text = '';
29
+ try {
30
+ text = readFileSync(manifestPath, 'utf8');
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ return {
36
+ tenantId: readYamlScalar(text, 'tenant_id'),
37
+ generationRunId: readYamlScalar(text, 'generation_run_id'),
38
+ generationVersion: Number(readYamlScalar(text, 'generation_version') || 0),
39
+ releaseKey: readYamlScalar(text, 'release_key'),
40
+ statusFilter: readYamlScalar(text, 'status_filter'),
41
+ releasedAt: readYamlScalar(text, 'released_at'),
42
+ managedPaths: readManagedPaths(text),
43
+ records: readSkillRecords(text),
44
+ };
45
+ }
46
+ export function manifestHeaderFrom(manifest) {
47
+ return {
48
+ tenantId: manifest?.tenantId ?? '',
49
+ releaseKey: manifest?.releaseKey ?? '',
50
+ statusFilter: manifest?.statusFilter ?? '',
51
+ generationRunId: manifest?.generationRunId ?? '',
52
+ generationVersion: manifest?.generationVersion ?? 0,
53
+ releasedAt: manifest?.releasedAt ?? '',
54
+ };
55
+ }
56
+ export function writeSkillsManifest(cwd, header, records) {
57
+ const manifestPath = join(cwd, SKILLS_MANIFEST_PATH);
58
+ const managedPaths = [...new Set(records.flatMap((record) => record.paths))].sort();
59
+ const lines = [
60
+ 'version: 1',
61
+ 'source: moda',
62
+ `tenant_id: ${yamlQuote(header.tenantId)}`,
63
+ `release_key: ${yamlQuote(header.releaseKey)}`,
64
+ `status_filter: ${yamlQuote(header.statusFilter)}`,
65
+ `generation_run_id: ${yamlQuote(header.generationRunId)}`,
66
+ `generation_version: ${header.generationVersion}`,
67
+ `released_at: ${yamlQuote(header.releasedAt)}`,
68
+ `installed_at: ${yamlQuote(new Date().toISOString())}`,
69
+ 'managed_paths:',
70
+ ...managedPaths.map((path) => ` - ${yamlQuote(path)}`),
71
+ 'skills:',
72
+ ...records.flatMap((record) => [
73
+ ` - skill_id: ${yamlQuote(record.skillId)}`,
74
+ ` content_hash: ${yamlQuote(record.contentHash)}`,
75
+ ` source: ${yamlQuote(record.source)}`,
76
+ ' paths:',
77
+ ...record.paths.map((path) => ` - ${yamlQuote(path)}`),
78
+ ]),
79
+ '',
80
+ ];
81
+ mkdirSync(dirname(manifestPath), { recursive: true });
82
+ writeFileSync(manifestPath, lines.join('\n'));
83
+ }
84
+ /**
85
+ * Upsert a skill record into .moda/skills.yml, preserving the harness release
86
+ * header and all other records. Used by the bundled integration-skill
87
+ * installer so its materialized files become managed paths (excluded from
88
+ * `skills sync`, never pruned by harness release changes).
89
+ */
90
+ export function upsertSkillManifestRecord(cwd, record) {
91
+ const manifest = readSkillsManifest(cwd);
92
+ const records = (manifest?.records ?? []).filter((existing) => !(existing.skillId === record.skillId && existing.source === record.source));
93
+ records.push(record);
94
+ writeSkillsManifest(cwd, manifestHeaderFrom(manifest), records);
95
+ }
96
+ export function pruneManagedSkillPaths(cwd, managedPaths, log) {
97
+ let pruned = 0;
98
+ for (const rawPath of [...new Set(managedPaths)]) {
99
+ const safePath = safeManagedRelativePath(rawPath);
100
+ if (!safePath)
101
+ continue;
102
+ const absolutePath = absoluteManagedPath(cwd, safePath);
103
+ if (!absolutePath || !existsSync(absolutePath))
104
+ continue;
105
+ try {
106
+ unlinkSync(absolutePath);
107
+ pruned++;
108
+ log(` pruned ${safePath}`);
109
+ pruneEmptyClaudeSkillDir(cwd, safePath);
110
+ }
111
+ catch {
112
+ // Best-effort cleanup. The following write pass can still repair files
113
+ // that remain writable.
114
+ }
115
+ }
116
+ return pruned;
117
+ }
118
+ function pruneEmptyClaudeSkillDir(cwd, relativePath) {
119
+ if (!relativePath.startsWith('.claude/skills/') || !relativePath.endsWith('/SKILL.md'))
120
+ return;
121
+ const dir = dirname(join(cwd, relativePath));
122
+ try {
123
+ if (existsSync(dir) && readdirSync(dir).length === 0) {
124
+ rmdirSync(dir);
125
+ }
126
+ }
127
+ catch {
128
+ // Empty-dir pruning is only cosmetic.
129
+ }
130
+ }
131
+ function absoluteManagedPath(cwd, relativePath) {
132
+ const abs = resolve(cwd, relativePath);
133
+ const root = resolve(cwd);
134
+ const rel = relative(root, abs);
135
+ if (rel.startsWith('..') || isAbsolute(rel))
136
+ return null;
137
+ return abs;
138
+ }
139
+ function safeManagedRelativePath(rawPath) {
140
+ if (!rawPath || rawPath.includes('\0') || isAbsolute(rawPath))
141
+ return null;
142
+ const normalized = normalize(rawPath).replace(/\\/g, '/');
143
+ if (normalized.startsWith('../') || normalized === '..' || normalized.includes('/../'))
144
+ return null;
145
+ const isClaudeSkill = normalized.startsWith('.claude/skills/') && normalized.endsWith('/SKILL.md');
146
+ const isCursorRule = normalized.startsWith('.cursor/rules/') && normalized.endsWith('.mdc');
147
+ return isClaudeSkill || isCursorRule ? normalized : null;
148
+ }
149
+ function readYamlScalar(text, key) {
150
+ const match = text.match(new RegExp(`^${key}:\\s*(.*?)\\s*$`, 'm'));
151
+ return match ? parseYamlScalar(match[1]) : '';
152
+ }
153
+ /** Parse the `skills:` record list from the hand-rolled manifest YAML. */
154
+ function readSkillRecords(text) {
155
+ const records = [];
156
+ let inSkills = false;
157
+ let current = null;
158
+ let inPaths = false;
159
+ for (const line of text.split(/\r?\n/)) {
160
+ if (line.trim() === 'skills:') {
161
+ inSkills = true;
162
+ continue;
163
+ }
164
+ if (!inSkills)
165
+ continue;
166
+ if (line.length > 0 && !line.startsWith(' '))
167
+ break;
168
+ const skillIdMatch = line.match(/^ {2}- skill_id:\s*(.*?)\s*$/);
169
+ if (skillIdMatch) {
170
+ if (current)
171
+ records.push(current);
172
+ current = {
173
+ skillId: parseYamlScalar(skillIdMatch[1]),
174
+ contentHash: '',
175
+ // Legacy manifests predate per-record sources; they were all pulls.
176
+ source: SKILL_SOURCE_HARNESS,
177
+ paths: [],
178
+ };
179
+ inPaths = false;
180
+ continue;
181
+ }
182
+ if (!current)
183
+ continue;
184
+ const hashMatch = line.match(/^ {4}content_hash:\s*(.*?)\s*$/);
185
+ if (hashMatch) {
186
+ current.contentHash = parseYamlScalar(hashMatch[1]);
187
+ continue;
188
+ }
189
+ const sourceMatch = line.match(/^ {4}source:\s*(.*?)\s*$/);
190
+ if (sourceMatch) {
191
+ current.source = parseYamlScalar(sourceMatch[1]) || SKILL_SOURCE_HARNESS;
192
+ continue;
193
+ }
194
+ if (/^ {4}paths:\s*$/.test(line)) {
195
+ inPaths = true;
196
+ continue;
197
+ }
198
+ const pathMatch = inPaths ? line.match(/^ {6}-\s*(.*?)\s*$/) : null;
199
+ if (pathMatch) {
200
+ const value = parseYamlScalar(pathMatch[1]);
201
+ if (value)
202
+ current.paths.push(value);
203
+ }
204
+ }
205
+ if (current)
206
+ records.push(current);
207
+ return records;
208
+ }
209
+ function readManagedPaths(text) {
210
+ const paths = [];
211
+ let inManagedPaths = false;
212
+ for (const line of text.split(/\r?\n/)) {
213
+ if (line.trim() === 'managed_paths:') {
214
+ inManagedPaths = true;
215
+ continue;
216
+ }
217
+ if (inManagedPaths && line.length > 0 && !line.startsWith(' '))
218
+ break;
219
+ if (!inManagedPaths)
220
+ continue;
221
+ const match = line.match(/^\s*-\s*(.*?)\s*$/);
222
+ if (match)
223
+ paths.push(parseYamlScalar(match[1]));
224
+ }
225
+ return paths.filter(Boolean);
226
+ }
227
+ function parseYamlScalar(raw) {
228
+ const value = raw.trim();
229
+ if (!value)
230
+ return '';
231
+ if (value.startsWith('"')) {
232
+ try {
233
+ return String(JSON.parse(value));
234
+ }
235
+ catch {
236
+ return value.replace(/^"|"$/g, '');
237
+ }
238
+ }
239
+ return value.replace(/^'|'$/g, '');
240
+ }
241
+ function yamlQuote(value) {
242
+ return JSON.stringify(value);
243
+ }
244
+ //# sourceMappingURL=skills-manifest.js.map
package/dist/skills.js CHANGED
@@ -17,17 +17,17 @@
17
17
  // previous Moda pull wrote.
18
18
  import { execFileSync } from 'node:child_process';
19
19
  import { createHash } from 'node:crypto';
20
- import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmdirSync, unlinkSync, writeFileSync, } from 'node:fs';
21
- import { basename, dirname, isAbsolute, join, normalize, relative, resolve } from 'node:path';
20
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, writeFileSync, } from 'node:fs';
21
+ import { basename, dirname, join, relative } from 'node:path';
22
22
  import { callControlAPI } from './api-client.js';
23
23
  import { createCommandContext } from './command-context.js';
24
24
  import { resolveApiKey, resolveIngestUrl, resolveTenantId } from './config.js';
25
25
  import { claudeSkillToCursorRule } from './init/rules.js';
26
26
  import { detectAgents } from './init/mcp.js';
27
+ import { SKILL_SOURCE_HARNESS, SKILL_SOURCE_INTEGRATION_BUNDLED, pruneManagedSkillPaths, readSkillsManifest, writeSkillsManifest, } from './skills-manifest.js';
27
28
  import { CliInputError } from './errors.js';
28
29
  const SKILLS_REQUEST_TIMEOUT_MS = 15_000;
29
30
  const VALID_STATUSES = new Set(['approved', 'proposed', 'all']);
30
- const SKILLS_MANIFEST_PATH = join('.moda', 'skills.yml');
31
31
  const SKILL_PROPOSALS_MANIFEST_PATH = join('.moda', 'skill-proposals.yml');
32
32
  /**
33
33
  * Fetch a tenant's generated skills from the ingest worker. Uses the persisted
@@ -122,10 +122,18 @@ export async function pullSkills(options = {}) {
122
122
  return summary;
123
123
  }
124
124
  const previousManifest = readSkillsManifest(cwd);
125
+ // A release change only invalidates harness-generated skills. Paths owned
126
+ // by integration-bundled records are CLI-managed and must never be pruned
127
+ // by a harness release change (cross-source regression guard).
128
+ const preservedIntegrationRecords = (previousManifest?.records ?? []).filter((record) => record.source === SKILL_SOURCE_INTEGRATION_BUNDLED);
125
129
  if (previousManifest &&
126
130
  previousManifest.managedPaths.length > 0 &&
127
131
  previousManifest.releaseKey !== fetched.releaseKey) {
128
- summary.pruned = pruneManagedSkillPaths(cwd, previousManifest.managedPaths, log);
132
+ const integrationPaths = new Set(preservedIntegrationRecords.flatMap((record) => record.paths));
133
+ const harnessPaths = previousManifest.managedPaths.filter((path) => !integrationPaths.has(path));
134
+ if (harnessPaths.length > 0) {
135
+ summary.pruned = pruneManagedSkillPaths(cwd, harnessPaths, log);
136
+ }
129
137
  }
130
138
  // Always target Claude Code (canonical SKILL.md). Also write Cursor rules
131
139
  // when the project has a .cursor directory.
@@ -167,11 +175,12 @@ export async function pullSkills(options = {}) {
167
175
  records.push({
168
176
  skillId: safeId,
169
177
  contentHash: sha256(skill.skill_md),
178
+ source: SKILL_SOURCE_HARNESS,
170
179
  paths,
171
180
  });
172
181
  }
173
182
  }
174
- writeSkillsManifest(cwd, fetched, records);
183
+ writeSkillsManifest(cwd, manifestHeaderFor(fetched), [...records, ...preservedIntegrationRecords]);
175
184
  return summary;
176
185
  }
177
186
  function recordOutcome(summary, status, log, relativePath) {
@@ -631,146 +640,17 @@ function releaseKeyFor(release, skills, status) {
631
640
  hash: sha256(skill.skill_md || ''),
632
641
  })).sort((a, b) => a.skill_id.localeCompare(b.skill_id))))}`;
633
642
  }
634
- function readSkillsManifest(cwd) {
635
- const manifestPath = join(cwd, SKILLS_MANIFEST_PATH);
636
- if (!existsSync(manifestPath))
637
- return null;
638
- let text = '';
639
- try {
640
- text = readFileSync(manifestPath, 'utf8');
641
- }
642
- catch {
643
- return null;
644
- }
643
+ function manifestHeaderFor(fetched) {
644
+ const release = fetched.release;
645
645
  return {
646
- tenantId: readYamlScalar(text, 'tenant_id'),
647
- generationRunId: readYamlScalar(text, 'generation_run_id'),
648
- generationVersion: Number(readYamlScalar(text, 'generation_version') || 0),
649
- releaseKey: readYamlScalar(text, 'release_key'),
650
- releasedAt: readYamlScalar(text, 'released_at'),
651
- managedPaths: readManagedPaths(text),
646
+ tenantId: fetched.tenantId,
647
+ releaseKey: fetched.releaseKey,
648
+ statusFilter: fetched.status,
649
+ generationRunId: release?.generation_run_id || '',
650
+ generationVersion: Number(release?.generation_version || 0),
651
+ releasedAt: release?.released_at || '',
652
652
  };
653
653
  }
654
- function writeSkillsManifest(cwd, fetched, records) {
655
- const manifestPath = join(cwd, SKILLS_MANIFEST_PATH);
656
- const managedPaths = [...new Set(records.flatMap((record) => record.paths))].sort();
657
- const release = fetched.release;
658
- const lines = [
659
- 'version: 1',
660
- 'source: moda',
661
- `tenant_id: ${yamlQuote(fetched.tenantId)}`,
662
- `release_key: ${yamlQuote(fetched.releaseKey)}`,
663
- `status_filter: ${yamlQuote(fetched.status)}`,
664
- `generation_run_id: ${yamlQuote(release?.generation_run_id || '')}`,
665
- `generation_version: ${Number(release?.generation_version || 0)}`,
666
- `released_at: ${yamlQuote(release?.released_at || '')}`,
667
- `installed_at: ${yamlQuote(new Date().toISOString())}`,
668
- 'managed_paths:',
669
- ...managedPaths.map((path) => ` - ${yamlQuote(path)}`),
670
- 'skills:',
671
- ...records.flatMap((record) => [
672
- ` - skill_id: ${yamlQuote(record.skillId)}`,
673
- ` content_hash: ${yamlQuote(record.contentHash)}`,
674
- ' paths:',
675
- ...record.paths.map((path) => ` - ${yamlQuote(path)}`),
676
- ]),
677
- '',
678
- ];
679
- mkdirSync(dirname(manifestPath), { recursive: true });
680
- writeFileSync(manifestPath, lines.join('\n'));
681
- }
682
- function pruneManagedSkillPaths(cwd, managedPaths, log) {
683
- let pruned = 0;
684
- for (const rawPath of [...new Set(managedPaths)]) {
685
- const safePath = safeManagedRelativePath(rawPath);
686
- if (!safePath)
687
- continue;
688
- const absolutePath = absoluteManagedPath(cwd, safePath);
689
- if (!absolutePath || !existsSync(absolutePath))
690
- continue;
691
- try {
692
- unlinkSync(absolutePath);
693
- pruned++;
694
- log(` pruned ${safePath}`);
695
- pruneEmptyClaudeSkillDir(cwd, safePath);
696
- }
697
- catch {
698
- // Best-effort cleanup. The following write pass can still repair files
699
- // that remain writable.
700
- }
701
- }
702
- return pruned;
703
- }
704
- function pruneEmptyClaudeSkillDir(cwd, relativePath) {
705
- if (!relativePath.startsWith('.claude/skills/') || !relativePath.endsWith('/SKILL.md'))
706
- return;
707
- const dir = dirname(join(cwd, relativePath));
708
- try {
709
- if (existsSync(dir) && readdirSync(dir).length === 0) {
710
- rmdirSync(dir);
711
- }
712
- }
713
- catch {
714
- // Empty-dir pruning is only cosmetic.
715
- }
716
- }
717
- function absoluteManagedPath(cwd, relativePath) {
718
- const abs = resolve(cwd, relativePath);
719
- const root = resolve(cwd);
720
- const rel = relative(root, abs);
721
- if (rel.startsWith('..') || isAbsolute(rel))
722
- return null;
723
- return abs;
724
- }
725
- function safeManagedRelativePath(rawPath) {
726
- if (!rawPath || rawPath.includes('\0') || isAbsolute(rawPath))
727
- return null;
728
- const normalized = normalize(rawPath).replace(/\\/g, '/');
729
- if (normalized.startsWith('../') || normalized === '..' || normalized.includes('/../'))
730
- return null;
731
- const isClaudeSkill = normalized.startsWith('.claude/skills/') && normalized.endsWith('/SKILL.md');
732
- const isCursorRule = normalized.startsWith('.cursor/rules/') && normalized.endsWith('.mdc');
733
- return isClaudeSkill || isCursorRule ? normalized : null;
734
- }
735
- function readYamlScalar(text, key) {
736
- const match = text.match(new RegExp(`^${key}:\\s*(.*?)\\s*$`, 'm'));
737
- return match ? parseYamlScalar(match[1]) : '';
738
- }
739
- function readManagedPaths(text) {
740
- const paths = [];
741
- let inManagedPaths = false;
742
- for (const line of text.split(/\r?\n/)) {
743
- if (line.trim() === 'managed_paths:') {
744
- inManagedPaths = true;
745
- continue;
746
- }
747
- if (inManagedPaths && line.length > 0 && !line.startsWith(' '))
748
- break;
749
- if (!inManagedPaths)
750
- continue;
751
- const match = line.match(/^\s*-\s*(.*?)\s*$/);
752
- if (match)
753
- paths.push(parseYamlScalar(match[1]));
754
- }
755
- return paths.filter(Boolean);
756
- }
757
- function parseYamlScalar(raw) {
758
- const value = raw.trim();
759
- if (!value)
760
- return '';
761
- if (value.startsWith('"')) {
762
- try {
763
- return String(JSON.parse(value));
764
- }
765
- catch {
766
- return value.replace(/^"|"$/g, '');
767
- }
768
- }
769
- return value.replace(/^'|'$/g, '');
770
- }
771
- function yamlQuote(value) {
772
- return JSON.stringify(value);
773
- }
774
654
  function sha256(input) {
775
655
  return createHash('sha256').update(input).digest('hex');
776
656
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moda-ai/cli",
3
- "version": "1.11.0",
3
+ "version": "1.12.2",
4
4
  "description": "CLI for Moda - AI agent analytics and observability",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,17 +12,18 @@
12
12
  "dist/**/*.d.ts",
13
13
  "!dist/__tests__",
14
14
  "hooks/**",
15
- "skills/**/SKILL.md",
15
+ "skills/**",
16
16
  "README.md"
17
17
  ],
18
18
  "publishConfig": {
19
19
  "access": "public"
20
20
  },
21
21
  "scripts": {
22
- "build": "tsc",
22
+ "bundle:skills": "bun scripts/bundle-skills.ts",
23
+ "build": "bun run bundle:skills && tsc",
23
24
  "dev": "tsc --watch",
24
25
  "typecheck": "tsc --noEmit",
25
- "test": "vitest run",
26
+ "test": "bun run bundle:skills && vitest run",
26
27
  "test:watch": "vitest",
27
28
  "lint": "eslint ."
28
29
  },
@@ -52,6 +53,7 @@
52
53
  },
53
54
  "devDependencies": {
54
55
  "@moda/eslint-config": "*",
56
+ "@moda/integration-skills": "*",
55
57
  "@types/node": "^22.10.5",
56
58
  "eslint": "^9.17.0",
57
59
  "typescript": "^5.7.2",