@sage-protocol/cli 0.2.9 → 0.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.
@@ -92,6 +92,34 @@ function register(program) {
92
92
  }
93
93
  })
94
94
  )
95
+ .addCommand(
96
+ new Command('set-github')
97
+ .description('Persist GitHub token for private repository access (stored in keychain by default)')
98
+ .option('--token <value>', 'GitHub token (classic or fine-grained)')
99
+ .option('--profile <name>', 'Profile name to update (default: active)')
100
+ .option('--global', 'Persist in XDG config (~/.config/sage/config.json)', false)
101
+ .option('--allow-plain-secret', 'Store plaintext in config instead of keychain (not recommended)', false)
102
+ .action(async (opts) => {
103
+ try {
104
+ if (!opts.token) throw new Error('Provide --token');
105
+ const payload = {};
106
+ if (opts.allowPlainSecret) {
107
+ payload.githubToken = opts.token;
108
+ } else {
109
+ const secrets = require('../services/secrets');
110
+ const profile = opts.profile;
111
+ await secrets.set('github.token', opts.token, { profile }).catch(() => {});
112
+ payload.githubTokenRef = 'github.token';
113
+ }
114
+ config.writeGitConfig(payload, { global: !!opts.global, profile: opts.profile });
115
+ console.log(`✅ Saved GitHub token to ${opts.profile || 'active'} profile${opts.global ? ' (global)' : ''}`);
116
+ console.log('Tip: CLI also respects GITHUB_TOKEN/GH_TOKEN environment variables at runtime.');
117
+ } catch (error) {
118
+ console.error('❌ Failed to save GitHub token:', error.message);
119
+ process.exit(1);
120
+ }
121
+ })
122
+ )
95
123
  )
96
124
  .addCommand(
97
125
  new Command('governance-helper')
@@ -0,0 +1,583 @@
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 chalk = require('chalk'); // Assuming chalk is available
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(chalk.blue('🩺 Running Governance Doctor...'));
439
+ const doctor = await diagnoseSubDAO({ subdao: opts.subdao, provider, signer });
440
+
441
+ if (doctor.recommendations.length > 0) {
442
+ console.log(chalk.yellow('⚠️ 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) {
449
+ console.error(chalk.red('❌ Aborting publish due to governance issues. Use --force to ignore.'));
450
+ process.exit(1);
451
+ }
452
+ } else {
453
+ console.log(chalk.green('✅ 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(chalk.blue('📦 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(chalk.blue('☁️ 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.upload(json) returns { cid }
497
+ const res = await ipfs.upload(manifest);
498
+ cid = res.cid;
499
+ } catch (e) {
500
+ // Fallback or error
501
+ throw new Error(`IPFS upload failed: ${e.message}`);
502
+ }
503
+
504
+ console.log(chalk.green(`✅ Manifest uploaded: ${cid}`));
505
+
506
+ // 5. Generate Transaction Payload
507
+ console.log(chalk.blue('🚀 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 updateLibrary(address subdao, string calldata manifestCid)
520
+ const { ethers } = require('ethers');
521
+ const iface = new ethers.Interface(['function updateLibrary(address subdao, string manifestCid)']);
522
+ const calldata = iface.encodeFunctionData('updateLibrary', [subdaoAddr, cid]);
523
+ const target = process.env.LIBRARY_REGISTRY_ADDRESS;
524
+ // We need to ensure target is known. Doctor found it?
525
+ // doctor.registry might be PromptRegistry, NOT LibraryRegistry.
526
+ // We need LIBRARY_REGISTRY_ADDRESS.
527
+ if (!target) throw new Error('LIBRARY_REGISTRY_ADDRESS not set.');
528
+
529
+ // 6. Route based on Mode
530
+ if (doctor.mode === 1) {
531
+ // Community -> Tally
532
+ console.log(chalk.cyan('\n🗳️ Community Governance Detected (Token Voting)'));
533
+ // Generate Tally URL
534
+ // Tally URL format: https://www.tally.xyz/gov/[slug]/proposal/create? ...
535
+ // We need the DAO slug or address. Tally uses address or slug.
536
+ // Let's output the data for now.
537
+
538
+ console.log('\nProposal Details:');
539
+ console.log(`Target: ${target}`);
540
+ console.log(`Method: updateLibrary(${subdaoAddr}, ${cid})`);
541
+ console.log(`Calldata: ${calldata}`);
542
+
543
+ console.log('\nCreate Proposal on Tally:');
544
+ console.log(`https://www.tally.xyz/gov/${subdaoAddr}/proposal/create`);
545
+ console.log('(Copy/paste the calldata above into the "Custom Action" field)');
546
+
547
+ } else {
548
+ // Creator/Squad -> Safe / Direct
549
+ // Check if we are owner/admin
550
+ // If Squad (Multisig), output Safe JSON.
551
+
552
+ console.log(chalk.cyan('\n🛡️ Team/Creator Governance Detected'));
553
+
554
+ // Output Safe JSON
555
+ const safeBatch = {
556
+ version: "1.0",
557
+ chainId: (await provider.getNetwork()).chainId.toString(),
558
+ createdAt: Date.now(),
559
+ meta: { name: "Update Skills Library", description: `Publish version ${cid}` },
560
+ transactions: [{
561
+ to: target,
562
+ value: "0",
563
+ data: calldata,
564
+ operation: 0 // Call
565
+ }]
566
+ };
567
+
568
+ const filename = `safe-tx-${Date.now()}.json`;
569
+ fs.writeFileSync(filename, JSON.stringify(safeBatch, null, 2));
570
+ console.log(`✅ Safe Transaction Builder JSON saved to ${filename}`);
571
+ console.log('Upload this file to your Safe to execute the update.');
572
+ }
573
+
574
+ } catch (e) {
575
+ console.error('❌ publish failed:', e.message);
576
+ process.exit(1);
577
+ }
578
+ });
579
+
580
+ program.addCommand(cmd);
581
+ }
582
+
583
+ module.exports = { register };