@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.
- package/dist/cli/commands/config.js +28 -0
- package/dist/cli/commands/skills.js +583 -0
- package/dist/cli/commands/subdao.js +76 -132
- package/dist/cli/config/playbooks.json +47 -0
- package/dist/cli/config.js +15 -0
- package/dist/cli/index.js +1 -1
- package/dist/cli/mcp-server-stdio.js +14 -85
- package/dist/cli/services/governance/doctor.js +140 -0
- package/dist/cli/services/governance/playbooks.js +97 -0
- package/dist/cli/services/skills/discovery.js +99 -0
- package/dist/cli/services/subdao/applier.js +217 -0
- package/dist/cli/services/subdao/planner.js +107 -0
- package/dist/cli/utils/aliases.js +11 -4
- package/dist/cli/utils/suggestions.js +8 -1
- package/package.json +1 -1
|
@@ -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, '<')}</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 };
|