@sage-protocol/cli 0.2.9 → 0.3.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/commands/config.js +28 -0
  2. package/dist/cli/commands/doctor.js +87 -8
  3. package/dist/cli/commands/gov-config.js +81 -0
  4. package/dist/cli/commands/governance.js +152 -72
  5. package/dist/cli/commands/library.js +9 -0
  6. package/dist/cli/commands/proposals.js +187 -17
  7. package/dist/cli/commands/skills.js +737 -0
  8. package/dist/cli/commands/subdao.js +96 -132
  9. package/dist/cli/config/playbooks.json +62 -0
  10. package/dist/cli/config.js +15 -0
  11. package/dist/cli/governance-manager.js +25 -4
  12. package/dist/cli/index.js +6 -7
  13. package/dist/cli/library-manager.js +79 -0
  14. package/dist/cli/mcp-server-stdio.js +1387 -166
  15. package/dist/cli/schemas/manifest.schema.json +55 -0
  16. package/dist/cli/services/doctor/fixers.js +134 -0
  17. package/dist/cli/services/governance/doctor.js +140 -0
  18. package/dist/cli/services/governance/playbooks.js +97 -0
  19. package/dist/cli/services/mcp/bulk-operations.js +272 -0
  20. package/dist/cli/services/mcp/dependency-analyzer.js +202 -0
  21. package/dist/cli/services/mcp/library-listing.js +2 -2
  22. package/dist/cli/services/mcp/local-prompt-collector.js +1 -0
  23. package/dist/cli/services/mcp/manifest-downloader.js +5 -3
  24. package/dist/cli/services/mcp/manifest-fetcher.js +17 -1
  25. package/dist/cli/services/mcp/manifest-workflows.js +127 -15
  26. package/dist/cli/services/mcp/quick-start.js +287 -0
  27. package/dist/cli/services/mcp/stdio-runner.js +30 -5
  28. package/dist/cli/services/mcp/template-manager.js +156 -0
  29. package/dist/cli/services/mcp/templates/default-templates.json +84 -0
  30. package/dist/cli/services/mcp/tool-args-validator.js +56 -0
  31. package/dist/cli/services/mcp/trending-formatter.js +1 -1
  32. package/dist/cli/services/mcp/unified-prompt-search.js +2 -2
  33. package/dist/cli/services/metaprompt/designer.js +12 -5
  34. package/dist/cli/services/skills/discovery.js +99 -0
  35. package/dist/cli/services/subdao/applier.js +229 -0
  36. package/dist/cli/services/subdao/planner.js +142 -0
  37. package/dist/cli/subdao-manager.js +14 -0
  38. package/dist/cli/utils/aliases.js +28 -6
  39. package/dist/cli/utils/contract-error-decoder.js +61 -0
  40. package/dist/cli/utils/suggestions.js +25 -13
  41. package/package.json +3 -2
  42. package/src/schemas/manifest.schema.json +55 -0
@@ -0,0 +1,737 @@
1
+ const { Command } = require('commander');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const os = require('os');
5
+ const { findWorkspaceSkills, resolveSkillFileByKey, readFrontmatter } = require('../services/skills/discovery');
6
+ const secrets = require('../services/secrets');
7
+ const https = require('https');
8
+ const { readWorkspace, DEFAULT_DIR } = require('../services/prompts/workspace');
9
+
10
+ function generateSkillsXml(skills) {
11
+ const mk = (s) => `<skill>\n<name>${s.name}</name>\n<description>${(s.summary || '').replace(/</g, '&lt;')}</description>\n<location>project</location>\n</skill>`;
12
+ const inner = skills.map(mk).join('\n\n');
13
+ return `<skills_system priority="1">\n\n## Available Skills\n\n<usage>\nUse these skills when they are relevant to the user's task.\n- Only use skills listed in <available_skills>\n- Do not duplicate content already in context\n- Load a single skill at a time as needed\n</usage>\n\n<available_skills>\n\n${inner}\n\n</available_skills>\n\n</skills_system>`;
14
+ }
15
+
16
+ function replaceSkillsSection(content, newSection) {
17
+ const startMarker = '<skills_system';
18
+ const endMarker = '</skills_system>';
19
+ if (content.includes(startMarker)) {
20
+ const regex = /<skills_system[^>]*>[\s\S]*?<\/skills_system>/;
21
+ return content.replace(regex, newSection);
22
+ }
23
+ const htmlStartMarker = '<!-- SKILLS_TABLE_START -->';
24
+ const htmlEndMarker = '<!-- SKILLS_TABLE_END -->';
25
+ if (content.includes(htmlStartMarker)) {
26
+ const inner = newSection.replace(/<skills_system[^>]*>|<\/skills_system>/g, '');
27
+ const regex = new RegExp(`${htmlStartMarker}[\\s\\S]*?${htmlEndMarker}`, 'g');
28
+ return content.replace(regex, `${htmlStartMarker}\n${inner}\n${htmlEndMarker}`);
29
+ }
30
+ return content.trimEnd() + '\n\n' + newSection + '\n';
31
+ }
32
+
33
+ function renderForTarget({ title, body }, target) {
34
+ const header = `# ${title}`;
35
+ if (target === 'cursor') {
36
+ return `${header}\n\n${body.trim()}\n`;
37
+ }
38
+ if (target === 'claude') {
39
+ return `${header}\n\n${body.trim()}\n`;
40
+ }
41
+ // browser / generic
42
+ return `${header}\n\n${body.trim()}\n`;
43
+ }
44
+
45
+ function register(program) {
46
+ const cmd = new Command('skills').description('Skills workflow (discover, iterate, export, and sync)');
47
+
48
+ cmd
49
+ .command('init')
50
+ .description('Initialize skills workspace')
51
+ .action(() => {
52
+ try {
53
+ const cwd = process.cwd();
54
+ const sageDir = path.join(cwd, '.sage');
55
+ const wsFile = path.join(sageDir, 'workspace.json');
56
+ const promptsDir = path.join(cwd, 'prompts');
57
+ const skillsDir = path.join(promptsDir, 'skills');
58
+
59
+ if (!fs.existsSync(sageDir)) fs.mkdirSync(sageDir);
60
+ if (!fs.existsSync(wsFile)) {
61
+ fs.writeFileSync(wsFile, JSON.stringify({ promptsDir: 'prompts' }, null, 2));
62
+ console.log('✅ Created .sage/workspace.json');
63
+ } else {
64
+ console.log('ℹ️ .sage/workspace.json already exists');
65
+ }
66
+
67
+ if (!fs.existsSync(promptsDir)) fs.mkdirSync(promptsDir);
68
+ if (!fs.existsSync(skillsDir)) {
69
+ fs.mkdirSync(skillsDir);
70
+ console.log('✅ Created prompts/skills/');
71
+ } else {
72
+ console.log('ℹ️ prompts/skills/ already exists');
73
+ }
74
+
75
+ console.log('Skills workspace initialized.');
76
+ } catch (e) {
77
+ console.error('❌ init failed:', e.message);
78
+ process.exit(1);
79
+ }
80
+ });
81
+
82
+ cmd
83
+ .command('list')
84
+ .description('List workspace skills (flat files and SKILL.md directories)')
85
+ .option('--json', 'Output JSON', false)
86
+ .action((opts) => {
87
+ try {
88
+ const ws = readWorkspace() || {};
89
+ const promptsDir = ws.promptsDir || DEFAULT_DIR || 'prompts';
90
+ const skills = findWorkspaceSkills({ promptsDir });
91
+ if (opts.json) {
92
+ console.log(JSON.stringify({ skills }, null, 2));
93
+ return;
94
+ }
95
+ if (!skills.length) {
96
+ console.log('No skills found. Create prompts/skills/<name>.md or prompts/skills/<name>/SKILL.md');
97
+ return;
98
+ }
99
+ skills.forEach((s, i) => {
100
+ console.log(`${i + 1}. ${s.name} (${s.key})`);
101
+ if (s.summary) console.log(` ${s.summary}`);
102
+ console.log(` ${path.relative(process.cwd(), s.path)}`);
103
+ });
104
+ } catch (e) {
105
+ console.error('❌ list failed:', e.message);
106
+ process.exit(1);
107
+ }
108
+ });
109
+
110
+ cmd
111
+ .command('variant')
112
+ .description('Clone a skill into a new local variant (suffix appended)')
113
+ .argument('<key>', 'Skill key (e.g., skills/my-skill or skills/cat/my-skill)')
114
+ .argument('[suffix]', 'Suffix (e.g., v2)', 'v2')
115
+ .action((key, suffix) => {
116
+ try {
117
+ const ws = readWorkspace();
118
+ if (!ws) throw new Error('Workspace not found. Run `sage prompts init`.');
119
+ const resolved = resolveSkillFileByKey({ promptsDir: ws.promptsDir, key });
120
+ if (!resolved) throw new Error(`Skill not found for key '${key}'`);
121
+ const src = resolved.path;
122
+ const base = resolved.baseDir;
123
+ if (resolved.type === 'flat') {
124
+ const srcName = path.basename(src, '.md');
125
+ const dest = path.join(base, `${srcName}.${suffix}.md`);
126
+ fs.copyFileSync(src, dest);
127
+ // Update or create frontmatter with lineage
128
+ try {
129
+ const content = fs.readFileSync(dest, 'utf8');
130
+ if (content.startsWith('---')) {
131
+ const end = content.indexOf('\n---', 3);
132
+ if (end !== -1) {
133
+ let front = content.slice(3, end);
134
+ if (!/^\s*version\s*:/m.test(front)) front += `\nversion: ${suffix}`;
135
+ else front = front.replace(/^(\s*version\s*:\s*).+$/m, `$1${suffix}`);
136
+ if (!/^\s*forked_from\s*:/m.test(front)) front += `\nforked_from: ${key}`;
137
+ else front = front.replace(/^(\s*forked_from\s*:\s*).+$/m, `$1${key}`);
138
+ const updated = `---\n${front}\n---` + content.slice(end + 4);
139
+ fs.writeFileSync(dest, updated, 'utf8');
140
+ }
141
+ } else {
142
+ const updated = `---\nversion: ${suffix}\nforked_from: ${key}\n---\n\n${content}`;
143
+ fs.writeFileSync(dest, updated, 'utf8');
144
+ }
145
+ } catch (_) { /* best effort */ }
146
+ console.log(`✅ Created variant: ${path.relative(process.cwd(), dest)}`);
147
+ return;
148
+ }
149
+ // directory skill
150
+ const dirName = path.basename(base);
151
+ const parent = path.dirname(base);
152
+ const destDir = path.join(parent, `${dirName}.${suffix}`);
153
+ fs.cpSync(base, destDir, { recursive: true });
154
+ // Adjust frontmatter 'name' if present
155
+ const skillMd = path.join(destDir, 'SKILL.md');
156
+ if (fs.existsSync(skillMd)) {
157
+ const raw = fs.readFileSync(skillMd, 'utf8');
158
+ let replaced = raw.replace(/^(\s*name\s*:\s*)(.+)$/m, (_, p1, p2) => `${p1}${p2}.${suffix}`);
159
+ if (!/^\s*version\s*:/m.test(replaced)) replaced = replaced.replace(/^---\s*\n/, `---\nversion: ${suffix}\n`);
160
+ else replaced = replaced.replace(/^(\s*version\s*:\s*).+$/m, `$1${suffix}`);
161
+ if (!/^\s*forked_from\s*:/m.test(replaced)) replaced = replaced.replace(/^---\s*\n/, `---\nforked_from: ${key}\n`);
162
+ else replaced = replaced.replace(/^(\s*forked_from\s*:\s*).+$/m, `$1${key}`);
163
+ fs.writeFileSync(skillMd, replaced, 'utf8');
164
+ }
165
+ console.log(`✅ Created variant: ${path.relative(process.cwd(), destDir)}`);
166
+ } catch (e) {
167
+ console.error('❌ variant failed:', e.message);
168
+ process.exit(1);
169
+ }
170
+ });
171
+
172
+ cmd
173
+ .command('agents-sync')
174
+ .description('Update or append a skills section in AGENTS.md describing local skills')
175
+ .option('--json', 'Output JSON', false)
176
+ .action((opts) => {
177
+ try {
178
+ const ws = readWorkspace() || {};
179
+ const promptsDir = ws.promptsDir || DEFAULT_DIR || 'prompts';
180
+ const skills = findWorkspaceSkills({ promptsDir });
181
+ const xml = generateSkillsXml(skills);
182
+ const file = path.join(process.cwd(), 'AGENTS.md');
183
+ let content = '';
184
+ if (fs.existsSync(file)) content = fs.readFileSync(file, 'utf8');
185
+ const updated = replaceSkillsSection(content, xml);
186
+ fs.writeFileSync(file, updated, 'utf8');
187
+ if (opts.json) {
188
+ console.log(JSON.stringify({ ok: true, count: skills.length }, null, 2));
189
+ return;
190
+ }
191
+ console.log(`✅ Synced ${skills.length} skill(s) to AGENTS.md`);
192
+ } catch (e) {
193
+ console.error('❌ agents-sync failed:', e.message);
194
+ process.exit(1);
195
+ }
196
+ });
197
+
198
+ cmd
199
+ .command('export')
200
+ .description('Render a skill for a target environment')
201
+ .argument('<key>', 'Skill key')
202
+ .option('--as <target>', 'Target (cursor|claude|browser)', 'cursor')
203
+ .option('--write', 'Write to project defaults when supported (e.g., .cursor/rules)', false)
204
+ .action((key, opts) => {
205
+ try {
206
+ const ws = readWorkspace();
207
+ if (!ws) throw new Error('Workspace not found. Run `sage prompts init`.');
208
+ const resolved = resolveSkillFileByKey({ promptsDir: ws.promptsDir, key });
209
+ if (!resolved) throw new Error(`Skill not found for key '${key}'`);
210
+ const raw = fs.readFileSync(resolved.path, 'utf8');
211
+ const { meta, body } = readFrontmatter(raw);
212
+ const title = meta.title || path.basename(resolved.type === 'flat' ? resolved.path : path.dirname(resolved.path));
213
+ const out = renderForTarget({ title, body }, String(opts.as || 'cursor').toLowerCase());
214
+ if (opts.write && opts.as === 'cursor') {
215
+ const rulesDir = fs.existsSync('.cursor/rules') ? '.cursor/rules' : null;
216
+ if (!rulesDir) {
217
+ console.log(out);
218
+ console.warn('ℹ️ No .cursor/rules directory found. Printed to stdout instead.');
219
+ return;
220
+ }
221
+ const safe = key.replace(/[^a-z0-9/_\-.]/gi, '_').replace(/^skills\//, '').replace(/\//g, '__');
222
+ const dest = path.join(rulesDir, `sage--${safe}.md`);
223
+ fs.writeFileSync(dest, out, 'utf8');
224
+ console.log(`✅ Wrote Cursor rule: ${dest}`);
225
+ return;
226
+ }
227
+ console.log(out);
228
+ } catch (e) {
229
+ console.error('❌ export failed:', e.message);
230
+ process.exit(1);
231
+ }
232
+ });
233
+
234
+ cmd
235
+ .command('import')
236
+ .description('Import a skill from GitHub, OpenSkills dirs, or a local directory into prompts/skills')
237
+ .argument('<source>', 'Skill source: owner/repo[/path] | https URL | local dir | installed skill name')
238
+ .option('--from-dir <dir>', 'Explicit directory containing SKILL.md')
239
+ .option('-y, --yes', 'Skip interactive selection when importing a repo with multiple skills', false)
240
+ .action(async (source, opts) => {
241
+ try {
242
+ const ws = readWorkspace();
243
+ if (!ws) throw new Error('Workspace not found. Run `sage prompts init`.');
244
+
245
+ const toWorkspace = async (dir, origin = null) => {
246
+ const skillMd = path.join(dir, 'SKILL.md');
247
+ if (!fs.existsSync(skillMd)) throw new Error('SKILL.md not found in provided directory');
248
+ const raw = fs.readFileSync(skillMd, 'utf8');
249
+
250
+ // Security: Size Check
251
+ if (raw.length > 100 * 1024) {
252
+ console.warn(`⚠️ WARNING: Skill file is large (${Math.round(raw.length/1024)}KB).`);
253
+ if (process.stdout.isTTY && !opts.yes) {
254
+ const inquirer = require('inquirer').default || require('inquirer');
255
+ const { confirm } = await inquirer.prompt([{ type: 'confirm', name: 'confirm', message: 'Import large file?', default: false }]);
256
+ if (!confirm) { console.log('Skipped.'); return; }
257
+ }
258
+ }
259
+
260
+ const { meta, body } = readFrontmatter(raw);
261
+
262
+ // Security: License Warning
263
+ if (!meta.license && !opts.yes) {
264
+ console.warn('⚠️ WARNING: No license specified in frontmatter.');
265
+ }
266
+
267
+ const skillName = (meta.title || path.basename(dir)).trim();
268
+ const destDir = path.join(ws.promptsDir, 'skills', skillName);
269
+ fs.mkdirSync(destDir, { recursive: true });
270
+ // Annotate source metadata into frontmatter
271
+ const sourceComment = `source: ${origin || dir}`;
272
+ fs.writeFileSync(
273
+ path.join(destDir, 'SKILL.md'),
274
+ `---\nname: ${skillName}\n${meta.summary ? `description: ${meta.summary}\n` : ''}${sourceComment ? `${sourceComment}\n` : ''}---\n\n${body.trim()}\n`,
275
+ 'utf8',
276
+ );
277
+ console.log(`✅ Imported ${skillName} → ${path.relative(process.cwd(), destDir)}`);
278
+ };
279
+
280
+ // 1) Explicit dir override
281
+ if (opts.fromDir) return await toWorkspace(opts.fromDir);
282
+
283
+ // 2) If 'source' is a local directory path
284
+ if (fs.existsSync(source) && fs.statSync(source).isDirectory()) return await toWorkspace(source);
285
+
286
+ // 3) Try installed skill dirs (.agent/.claude), using provided name as directory
287
+ const installedRoots = [
288
+ path.join(process.cwd(), '.agent', 'skills'),
289
+ path.join(process.cwd(), '.claude', 'skills'),
290
+ path.join(os.homedir(), '.agent', 'skills'),
291
+ path.join(os.homedir(), '.claude', 'skills'),
292
+ ];
293
+ for (const r of installedRoots) {
294
+ const candidate = path.join(r, source);
295
+ if (fs.existsSync(path.join(candidate, 'SKILL.md'))) return await toWorkspace(candidate);
296
+ }
297
+
298
+ // 4) GitHub import: owner/repo[/path] or https URL
299
+ const isUrl = /^(https?:\/\/|git@)/i.test(source);
300
+ const isOwnerRepo = /^[^\s/]+\/[^\s/]+(\/.+)?$/.test(source);
301
+ if (!isUrl && !isOwnerRepo) {
302
+ throw new Error('Source not found. Provide a local dir, installed skill name, or owner/repo[/path].');
303
+ }
304
+ const { spawnSync } = require('child_process');
305
+ const osTmp = os.tmpdir();
306
+ const tmpRoot = path.join(osTmp, `sage-skill-${Date.now()}`);
307
+ fs.mkdirSync(tmpRoot, { recursive: true });
308
+
309
+ let repoUrl = source;
310
+ let subpath = '';
311
+ let owner = null;
312
+ let repo = null;
313
+ if (isOwnerRepo && !isUrl) {
314
+ const parts = source.split('/');
315
+ owner = parts[0];
316
+ repo = parts[1];
317
+ const base = `https://github.com/${owner}/${repo}`;
318
+ repoUrl = base;
319
+ if (parts.length > 2) subpath = parts.slice(2).join('/');
320
+ }
321
+
322
+ const cloneDir = path.join(tmpRoot, 'repo');
323
+ const envToken = process.env.GITHUB_TOKEN || process.env.GH_TOKEN || (await secrets.get('github.token', {})) || '';
324
+ const gitPresent = spawnSync('git', ['--version'], { stdio: 'ignore' }).status === 0;
325
+ let clonedOk = false;
326
+ if (gitPresent) {
327
+ const gitArgs = ['clone', '--depth', '1'];
328
+ const r = envToken && /github\.com/i.test(repoUrl)
329
+ ? spawnSync('git', ['-c', `http.extraheader=AUTHORIZATION: bearer ${envToken}`, ...gitArgs, repoUrl, cloneDir], { stdio: 'inherit' })
330
+ : spawnSync('git', [...gitArgs, repoUrl, cloneDir], { stdio: 'inherit' });
331
+ clonedOk = (r.status === 0);
332
+ }
333
+ if (!clonedOk) {
334
+ // ZIP fallback using codeload.github.com
335
+ if (!owner || !repo) {
336
+ const m = repoUrl.match(/github\.com\/(.+?)\/(.+?)(?:$|\/)/i);
337
+ if (m) { owner = m[1]; repo = m[2]; }
338
+ }
339
+ if (!owner || !repo) throw new Error('Could not parse owner/repo for ZIP fallback');
340
+ const zipPath = path.join(tmpRoot, 'archive.zip');
341
+ const tryDownload = (ref) => new Promise((resolve, reject) => {
342
+ const url = `https://codeload.github.com/${owner}/${repo}/zip/refs/heads/${ref}`;
343
+ const file = fs.createWriteStream(zipPath);
344
+ const req = https.get(url, { headers: envToken ? { Authorization: `Bearer ${envToken}` } : {} }, (res) => {
345
+ if (res.statusCode && res.statusCode >= 400) { reject(new Error(`ZIP download failed: ${res.statusCode}`)); return; }
346
+ res.pipe(file);
347
+ file.on('finish', () => file.close(() => resolve({ ok: true, ref })));
348
+ });
349
+ req.on('error', reject);
350
+ });
351
+ let ok = false; let usedRef = 'main';
352
+ try { await tryDownload('main'); ok = true; usedRef = 'main'; } catch (_) {}
353
+ if (!ok) { try { await tryDownload('master'); ok = true; usedRef = 'master'; } catch (e) { throw e; } }
354
+ const unzip = spawnSync('unzip', ['-q', zipPath, '-d', tmpRoot], { stdio: 'inherit' });
355
+ if (unzip.status !== 0) throw new Error('unzip failed (install git or unzip, or use --from-dir)');
356
+ const expectedDir = path.join(tmpRoot, `${repo}-${usedRef}`);
357
+ if (!fs.existsSync(expectedDir)) {
358
+ // Fallback: find first directory
359
+ const entry = fs.readdirSync(tmpRoot, { withFileTypes: true }).find((e) => e.isDirectory() && e.name !== 'repo');
360
+ if (!entry) throw new Error('ZIP extract succeeded but repo dir not found');
361
+ fs.renameSync(path.join(tmpRoot, entry.name), cloneDir);
362
+ } else {
363
+ fs.renameSync(expectedDir, cloneDir);
364
+ }
365
+ }
366
+
367
+ const resolvedBase = subpath ? path.join(cloneDir, subpath) : cloneDir;
368
+ if (!fs.existsSync(resolvedBase)) throw new Error('Specified subpath not found in repository');
369
+
370
+ // Find skills (directories containing SKILL.md)
371
+ const candidates = [];
372
+ const stack = [resolvedBase];
373
+ while (stack.length) {
374
+ const curr = stack.pop();
375
+ const entries = fs.readdirSync(curr, { withFileTypes: true });
376
+ let hasSkill = false;
377
+ for (const e of entries) {
378
+ if (e.isFile() && e.name === 'SKILL.md') hasSkill = true;
379
+ }
380
+ if (hasSkill) {
381
+ candidates.push(curr);
382
+ } else {
383
+ for (const e of entries) if (e.isDirectory()) stack.push(path.join(curr, e.name));
384
+ }
385
+ }
386
+
387
+ if (candidates.length === 0) throw new Error('No SKILL.md directories found in repository');
388
+
389
+ let selected = candidates;
390
+ if (!opts.yes && candidates.length > 1 && process.stdout.isTTY) {
391
+ const inquirer = require('inquirer');
392
+ const { checkbox } = await inquirer.prompt([
393
+ {
394
+ type: 'checkbox',
395
+ name: 'checkbox',
396
+ message: 'Select skills to import',
397
+ choices: candidates.map((p) => ({ name: path.relative(cloneDir, p), value: p })),
398
+ pageSize: 12,
399
+ },
400
+ ]);
401
+ selected = checkbox;
402
+ if (!selected || !selected.length) {
403
+ console.log('Cancelled.');
404
+ return;
405
+ }
406
+ }
407
+
408
+ const originUrl = `https://github.com/${owner || 'unknown'}/${repo || 'unknown'}${subpath ? `/${subpath}` : ''}`;
409
+ for (const dir of selected) await toWorkspace(dir, originUrl);
410
+ } catch (e) {
411
+ console.error('❌ import failed:', e.message);
412
+ process.exit(1);
413
+ }
414
+ });
415
+
416
+ cmd
417
+ .command('publish')
418
+ .description('Publish local skills to the on-chain library (Safe/Tally aware)')
419
+ .argument('[key]', 'Specific skill key to publish (default: all)')
420
+ .option('--subdao <address>', 'SubDAO/library address')
421
+ .option('--force', 'Ignore doctor warnings')
422
+ .option('--dry-run', 'Skip IPFS upload and transaction generation')
423
+ .action(async (key, opts) => {
424
+ try {
425
+ const fs = require('fs');
426
+ const { diagnoseSubDAO } = require('../services/governance/doctor');
427
+ const WalletManager = require('../wallet-manager');
428
+ const { readWorkspace } = require('../services/prompts/workspace');
429
+ const { findWorkspaceSkills } = require('../services/skills/discovery');
430
+
431
+ // 1. Init Wallet & Context
432
+ const wallet = new WalletManager();
433
+ await wallet.connect();
434
+ const provider = wallet.getProvider();
435
+ const signer = wallet.getSigner();
436
+
437
+ // 2. Doctor Preflight
438
+ console.log('🩺 Running Governance Doctor...');
439
+ const doctor = await diagnoseSubDAO({ subdao: opts.subdao, provider, signer });
440
+
441
+ if (doctor.recommendations.length > 0) {
442
+ console.log('⚠️ Issues detected:');
443
+ doctor.recommendations.forEach(r => {
444
+ const msg = typeof r==='string' ? r : r.msg;
445
+ const fix = typeof r==='string' ? '' : (r.fix || r.fixCmd);
446
+ console.log(` - ${msg} ${fix ? `(Fix: ${fix})` : ''}`);
447
+ });
448
+ if (!opts.force && !process.env.SAGE_FORCE) {
449
+ console.error('❌ Aborting publish due to governance issues. Use --force to ignore.');
450
+ process.exit(1);
451
+ }
452
+ } else {
453
+ console.log('✅ Governance checks passed.');
454
+ }
455
+
456
+ const subdaoAddr = doctor.subdao;
457
+ if (!subdaoAddr) throw new Error('SubDAO not resolved.');
458
+
459
+ // 3. Build Manifest
460
+ console.log('📦 Building manifest...');
461
+ const ws = readWorkspace() || {};
462
+ const skills = findWorkspaceSkills({ promptsDir: ws.promptsDir || 'prompts' });
463
+ // Filter if key provided? "sage skills publish my-skill" usually implies updating just that one?
464
+ // But manifest is usually holistic for the library.
465
+ // For now, publish ALL workspace skills as the new version of the library.
466
+
467
+ if (skills.length === 0) throw new Error('No skills found to publish.');
468
+
469
+ const manifest = {
470
+ version: '1.0.0',
471
+ timestamp: new Date().toISOString(),
472
+ skills: skills.map(s => ({
473
+ id: s.key,
474
+ metadata: s.meta,
475
+ // content? usually we upload content separately or include it?
476
+ // For a "skills library", we probably want content accessible.
477
+ // Let's assume we pin the SKILL.md files or include content in manifest.
478
+ // Including content in manifest is easier for MVP.
479
+ content: fs.readFileSync(s.path, 'utf8')
480
+ }))
481
+ };
482
+
483
+ if (opts.dryRun) {
484
+ console.log('Dry run: Manifest would contain', skills.length, 'skills.');
485
+ return;
486
+ }
487
+
488
+ // 4. IPFS Upload
489
+ console.log('☁️ Uploading to IPFS...');
490
+ // Use IPFS Manager or Worker directly?
491
+ // Try to require IpfsManager
492
+ let cid;
493
+ try {
494
+ const IpfsManager = require('../ipfs-manager');
495
+ const ipfs = new IpfsManager();
496
+ // ipfs.uploadJson(json) returns CID string directly
497
+ cid = await ipfs.uploadJson(manifest, 'skills-manifest');
498
+ if (!cid) throw new Error('IPFS upload returned no CID');
499
+ } catch (e) {
500
+ // Fallback or error
501
+ throw new Error(`IPFS upload failed: ${e.message}`);
502
+ }
503
+
504
+ console.log(`✅ Manifest uploaded: ${cid}`);
505
+
506
+ // 5. Generate Transaction Payload
507
+ console.log('🚀 Generating Governance Payload...');
508
+
509
+ // We need to call updateLibrary(cid) on the Registry or SubDAO?
510
+ // SubDAOs usually have a `updateLibrary` or `publishManifest` method?
511
+ // Or LibraryRegistry?
512
+ // "Creator Playbook": updateLibraryForSubDAO(subdao, libraryId, cid, ...)
513
+
514
+ // Let's assume SubDAO has a `publish(cid)` or we call Registry.
515
+ // The architecture says: LibraryRegistry.
516
+
517
+ const RegistryABI = require('../utils/factory-abi'); // Wait, need LibraryRegistry ABI
518
+ // We can construct Interface manually for updateLibrary
519
+ // function updateLibraryForSubDAO(address subdao, string calldata manifestCid)
520
+ const { ethers } = require('ethers');
521
+ const iface = new ethers.Interface(['function updateLibraryForSubDAO(address subdao, string manifestCid)']);
522
+ const calldata = iface.encodeFunctionData('updateLibraryForSubDAO', [subdaoAddr, cid]);
523
+ const config = require('../config');
524
+ // Resolve from env first, then active profile addresses (aliases supported)
525
+ const resolvedFromConfig = (() => {
526
+ try { return config.resolveAddress('LIBRARY_REGISTRY_ADDRESS', null); } catch (_) { return null; }
527
+ })();
528
+ const target = process.env.LIBRARY_REGISTRY_ADDRESS || resolvedFromConfig;
529
+ if (!process.env.LIBRARY_REGISTRY_ADDRESS && resolvedFromConfig) {
530
+ try {
531
+ const profiles = config.readProfiles?.() || {};
532
+ const active = profiles.activeProfile || 'default';
533
+ console.log(`ℹ️ Using LIBRARY_REGISTRY_ADDRESS from profile '${active}': ${target}`);
534
+ } catch (_) {}
535
+ }
536
+ // We need to ensure target is known. Doctor found it?
537
+ // doctor.registry might be PromptRegistry, NOT LibraryRegistry.
538
+ // We need LIBRARY_REGISTRY_ADDRESS.
539
+ if (!target) throw new Error('LIBRARY_REGISTRY_ADDRESS not set in env or active profile. Set env: export LIBRARY_REGISTRY_ADDRESS=0x... or run: sage config addresses (and add LIBRARY_REGISTRY_ADDRESS).');
540
+
541
+ // 6. Route based on Mode
542
+ if (doctor.mode === 1) {
543
+ // Community -> Tally
544
+ console.log('\n🗳️ Community Governance Detected (Token Voting)');
545
+ // Generate Tally URL
546
+ // Tally URL format: https://www.tally.xyz/gov/[slug]/proposal/create? ...
547
+ // We need the DAO slug or address. Tally uses address or slug.
548
+ // Let's output the data for now.
549
+
550
+ console.log('\nProposal Details:');
551
+ console.log(`Target: ${target}`);
552
+ console.log(`Method: updateLibrary(${subdaoAddr}, ${cid})`);
553
+ console.log(`Calldata: ${calldata}`);
554
+
555
+ console.log('\nCreate Proposal on Tally:');
556
+ console.log(`https://www.tally.xyz/gov/${subdaoAddr}/proposal/create`);
557
+ console.log('(Copy/paste the calldata above into the "Custom Action" field)');
558
+
559
+ } else {
560
+ // Creator/Squad -> Safe / Direct
561
+ // Check if we are owner/admin
562
+ // If Squad (Multisig), output Safe JSON.
563
+
564
+ console.log('\n🛡️ Team/Creator Governance Detected');
565
+
566
+ // Output Safe JSON
567
+ const safeBatch = {
568
+ version: "1.0",
569
+ chainId: (await provider.getNetwork()).chainId.toString(),
570
+ createdAt: Date.now(),
571
+ meta: { name: "Update Skills Library", description: `Publish version ${cid}` },
572
+ transactions: [{
573
+ to: target,
574
+ value: "0",
575
+ data: calldata,
576
+ operation: 0 // Call
577
+ }]
578
+ };
579
+
580
+ const filename = `safe-tx-${Date.now()}.json`;
581
+ fs.writeFileSync(filename, JSON.stringify(safeBatch, null, 2));
582
+ console.log(`✅ Safe Transaction Builder JSON saved to ${filename}`);
583
+ console.log('\n📋 Next steps:');
584
+ console.log(` 1. Import ${filename} in Safe Transaction Builder`);
585
+ console.log(` 2. Review and sign the transaction`);
586
+ console.log(` 3. Execute the transaction`);
587
+ if (subdaoAddr) {
588
+ console.log(` 4. Verify: sage library status --subdao ${subdaoAddr}`);
589
+ }
590
+ }
591
+
592
+ } catch (e) {
593
+ console.error('❌ publish failed:', e.message);
594
+ process.exit(1);
595
+ }
596
+ });
597
+
598
+ // QoL: publish-manifest (pin only, no governance)
599
+ cmd
600
+ .command('publish-manifest')
601
+ .description('Build and pin a manifest from workspace skills without proposing')
602
+ .option('--json', 'Output CID as JSON')
603
+ .action(async (opts) => {
604
+ try {
605
+ const { readWorkspace } = require('../services/prompts/workspace');
606
+ const { findWorkspaceSkills } = require('../services/skills/discovery');
607
+ const IpfsManager = require('../ipfs-manager');
608
+ const fs = require('fs');
609
+
610
+ console.log('📦 Building manifest...');
611
+ const ws = readWorkspace() || {};
612
+ const skills = findWorkspaceSkills({ promptsDir: ws.promptsDir || 'prompts' });
613
+ if (skills.length === 0) throw new Error('No skills found in workspace');
614
+ const manifest = {
615
+ version: '1.0.0',
616
+ timestamp: new Date().toISOString(),
617
+ skills: skills.map((s) => ({ id: s.key, metadata: s.meta, content: fs.readFileSync(s.path, 'utf8') })),
618
+ };
619
+
620
+ console.log('☁️ Uploading to IPFS...');
621
+ const ipfs = new IpfsManager();
622
+ const { cid } = await ipfs.uploadJson(manifest, 'skills-manifest');
623
+ if (opts.json) {
624
+ console.log(JSON.stringify({ ok: true, cid }, null, 2));
625
+ } else {
626
+ console.log(`✅ Manifest CID: ${cid}`);
627
+ }
628
+ } catch (e) {
629
+ console.error('❌ publish-manifest failed:', e.message);
630
+ process.exit(1);
631
+ }
632
+ });
633
+
634
+ // QoL: doctor wrapper for skills/governance readiness
635
+ cmd
636
+ .command('doctor')
637
+ .description('Validate workspace skills and diagnose SubDAO governance readiness')
638
+ .argument('[key]', 'Optional skill key to validate frontmatter')
639
+ .option('--subdao <address>', 'SubDAO address for governance checks')
640
+ .action(async (key, opts) => {
641
+ try {
642
+ const { readWorkspace } = require('../services/prompts/workspace');
643
+ const { resolveSkillFileByKey, readFrontmatter, findWorkspaceSkills } = require('../services/skills/discovery');
644
+ const { diagnoseSubDAO } = require('../services/governance/doctor');
645
+ const WalletManager = require('../wallet-manager');
646
+
647
+ // Validate skill frontmatter if requested
648
+ if (key) {
649
+ const ws = readWorkspace() || {};
650
+ const resolved = resolveSkillFileByKey({ promptsDir: ws.promptsDir || 'prompts', key });
651
+ if (!resolved) throw new Error(`Skill not found for key '${key}'`);
652
+ const fm = readFrontmatter(resolved.path);
653
+ console.log(`✅ Skill frontmatter OK for ${key}`);
654
+ console.log(fm);
655
+ } else {
656
+ // List count as a quick sanity
657
+ const ws = readWorkspace() || {};
658
+ const list = findWorkspaceSkills({ promptsDir: ws.promptsDir || 'prompts' });
659
+ console.log(`✅ Workspace skills found: ${list.length}`);
660
+ }
661
+
662
+ if (opts.subdao) {
663
+ const wallet = new WalletManager();
664
+ await wallet.connect();
665
+ const provider = wallet.getProvider();
666
+ const signer = wallet.getSigner();
667
+ console.log('🩺 Running Governance Doctor...');
668
+ const doctor = await diagnoseSubDAO({ subdao: opts.subdao, provider, signer });
669
+ console.log(JSON.stringify({
670
+ status: doctor.recommendations.length ? 'warn' : 'ok',
671
+ canPropose: doctor.canPropose === true,
672
+ subdao: doctor.subdao,
673
+ issues: doctor.recommendations,
674
+ }, null, 2));
675
+ }
676
+ } catch (e) {
677
+ console.error('❌ doctor failed:', e.message);
678
+ process.exit(1);
679
+ }
680
+ });
681
+
682
+ // QoL: use (MCP helper)
683
+ cmd
684
+ .command('use')
685
+ .description('Help use a skill with MCP (prints config; optional server start)')
686
+ .argument('<key>', 'Skill key (e.g., skills/my-skill)')
687
+ .option('--server-start', 'Start MCP HTTP server')
688
+ .option('--stdio-config', 'Print Claude Desktop stdio config snippet instead')
689
+ .action(async (key, opts) => {
690
+ try {
691
+ const path = require('path');
692
+ const chalk = require('chalk');
693
+ const { readWorkspace } = require('../services/prompts/workspace');
694
+ const { resolveSkillFileByKey } = require('../services/skills/discovery');
695
+
696
+ const ws = readWorkspace() || {};
697
+ const resolved = resolveSkillFileByKey({ promptsDir: ws.promptsDir || 'prompts', key });
698
+ if (!resolved) throw new Error(`Skill not found for key '${key}'`);
699
+
700
+ if (opts.stdioConfig) {
701
+ const stdioPath = path.resolve(__dirname, '..', 'mcp-server-stdio.js');
702
+ const snippet = {
703
+ mcpServers: {
704
+ sage: {
705
+ command: process.execPath,
706
+ args: [stdioPath],
707
+ env: {},
708
+ }
709
+ }
710
+ };
711
+ console.log(JSON.stringify(snippet, null, 2));
712
+ return;
713
+ }
714
+
715
+ if (opts.serverStart) {
716
+ const MCPServer = require('../mcp-server');
717
+ const server = new MCPServer({ port: 3333 });
718
+ await server.start();
719
+ console.log(chalk.green('✅ MCP server started on http://localhost:3333'));
720
+ console.log(chalk.cyan('Use in Cursor/Claude by configuring the HTTP/WS MCP server.'));
721
+ console.log(chalk.gray('Press Ctrl+C to stop.'));
722
+ } else {
723
+ console.log(chalk.cyan('To start the MCP server, run:'));
724
+ console.log(' sage mcp start');
725
+ console.log(chalk.cyan('For Claude stdio config, run:'));
726
+ console.log(' sage skills use', key, '--stdio-config');
727
+ }
728
+ } catch (e) {
729
+ console.error('❌ use failed:', e.message);
730
+ process.exit(1);
731
+ }
732
+ });
733
+
734
+ program.addCommand(cmd);
735
+ }
736
+
737
+ module.exports = { register };