@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.
- package/dist/cli/commands/config.js +28 -0
- package/dist/cli/commands/doctor.js +87 -8
- package/dist/cli/commands/gov-config.js +81 -0
- package/dist/cli/commands/governance.js +152 -72
- package/dist/cli/commands/library.js +9 -0
- package/dist/cli/commands/proposals.js +187 -17
- package/dist/cli/commands/skills.js +737 -0
- package/dist/cli/commands/subdao.js +96 -132
- package/dist/cli/config/playbooks.json +62 -0
- package/dist/cli/config.js +15 -0
- package/dist/cli/governance-manager.js +25 -4
- package/dist/cli/index.js +6 -7
- package/dist/cli/library-manager.js +79 -0
- package/dist/cli/mcp-server-stdio.js +1387 -166
- package/dist/cli/schemas/manifest.schema.json +55 -0
- package/dist/cli/services/doctor/fixers.js +134 -0
- package/dist/cli/services/governance/doctor.js +140 -0
- package/dist/cli/services/governance/playbooks.js +97 -0
- package/dist/cli/services/mcp/bulk-operations.js +272 -0
- package/dist/cli/services/mcp/dependency-analyzer.js +202 -0
- package/dist/cli/services/mcp/library-listing.js +2 -2
- package/dist/cli/services/mcp/local-prompt-collector.js +1 -0
- package/dist/cli/services/mcp/manifest-downloader.js +5 -3
- package/dist/cli/services/mcp/manifest-fetcher.js +17 -1
- package/dist/cli/services/mcp/manifest-workflows.js +127 -15
- package/dist/cli/services/mcp/quick-start.js +287 -0
- package/dist/cli/services/mcp/stdio-runner.js +30 -5
- package/dist/cli/services/mcp/template-manager.js +156 -0
- package/dist/cli/services/mcp/templates/default-templates.json +84 -0
- package/dist/cli/services/mcp/tool-args-validator.js +56 -0
- package/dist/cli/services/mcp/trending-formatter.js +1 -1
- package/dist/cli/services/mcp/unified-prompt-search.js +2 -2
- package/dist/cli/services/metaprompt/designer.js +12 -5
- package/dist/cli/services/skills/discovery.js +99 -0
- package/dist/cli/services/subdao/applier.js +229 -0
- package/dist/cli/services/subdao/planner.js +142 -0
- package/dist/cli/subdao-manager.js +14 -0
- package/dist/cli/utils/aliases.js +28 -6
- package/dist/cli/utils/contract-error-decoder.js +61 -0
- package/dist/cli/utils/suggestions.js +25 -13
- package/package.json +3 -2
- package/src/schemas/manifest.schema.json +55 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://sage-protocol.org/schemas/manifest.schema.json",
|
|
4
|
+
"title": "Sage Library Manifest",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["version", "library", "prompts"],
|
|
7
|
+
"additionalProperties": true,
|
|
8
|
+
"properties": {
|
|
9
|
+
"version": {
|
|
10
|
+
"anyOf": [
|
|
11
|
+
{ "type": "string", "const": "2.0.0" },
|
|
12
|
+
{ "type": "integer", "const": 2 }
|
|
13
|
+
]
|
|
14
|
+
},
|
|
15
|
+
"library": {
|
|
16
|
+
"type": "object",
|
|
17
|
+
"required": ["name"],
|
|
18
|
+
"additionalProperties": true,
|
|
19
|
+
"properties": {
|
|
20
|
+
"name": { "type": "string", "minLength": 1 },
|
|
21
|
+
"description": { "type": "string" },
|
|
22
|
+
"previous": { "type": "string" }
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"prompts": {
|
|
26
|
+
"type": "array",
|
|
27
|
+
"minItems": 0,
|
|
28
|
+
"items": {
|
|
29
|
+
"type": "object",
|
|
30
|
+
"required": ["key"],
|
|
31
|
+
"additionalProperties": true,
|
|
32
|
+
"properties": {
|
|
33
|
+
"key": { "type": "string", "minLength": 1 },
|
|
34
|
+
"cid": { "type": "string", "minLength": 46 },
|
|
35
|
+
"name": { "type": "string" },
|
|
36
|
+
"description": { "type": "string" },
|
|
37
|
+
"tags": {
|
|
38
|
+
"type": "array",
|
|
39
|
+
"items": { "type": "string", "minLength": 1 }
|
|
40
|
+
},
|
|
41
|
+
"files": {
|
|
42
|
+
"type": "array",
|
|
43
|
+
"items": { "type": "string", "minLength": 1 },
|
|
44
|
+
"minItems": 0
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"compositions": { "type": "object" },
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"type": "array",
|
|
52
|
+
"items": { "type": "string" }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
const inquirer = (require('inquirer').default || require('inquirer'));
|
|
2
|
+
const config = require('../../config');
|
|
3
|
+
const { execSync } = require('child_process');
|
|
4
|
+
const { ethers } = require('ethers');
|
|
5
|
+
|
|
6
|
+
async function fixRpcUrl(context = {}) {
|
|
7
|
+
console.log('\nš§ Fixing RPC URL...');
|
|
8
|
+
const { diagnoseRpcIssues, checkRpcHealth } = require('../../utils/rpc-health');
|
|
9
|
+
const envCandidates = [
|
|
10
|
+
process.env.RPC_URL,
|
|
11
|
+
process.env.BASE_SEPOLIA_RPC,
|
|
12
|
+
'https://base-sepolia.publicnode.com',
|
|
13
|
+
'https://sepolia.base.org',
|
|
14
|
+
'https://base-sepolia.gateway.tenderly.co'
|
|
15
|
+
].filter(Boolean);
|
|
16
|
+
|
|
17
|
+
let recommended = null;
|
|
18
|
+
try {
|
|
19
|
+
const results = await diagnoseRpcIssues({ rpcUrls: envCandidates, timeout: 8000 });
|
|
20
|
+
const healthy = results.filter(r => r.healthy).sort((a,b)=> (a.latency ?? 1e9) - (b.latency ?? 1e9));
|
|
21
|
+
if (healthy.length) recommended = healthy[0].url;
|
|
22
|
+
} catch (_) { /* best-effort */ }
|
|
23
|
+
|
|
24
|
+
let url = recommended || envCandidates[0] || 'https://base-sepolia.publicnode.com';
|
|
25
|
+
if (!context.auto) {
|
|
26
|
+
const { url: picked } = await inquirer.prompt([
|
|
27
|
+
{
|
|
28
|
+
type: 'list',
|
|
29
|
+
name: 'url',
|
|
30
|
+
message: 'Select a new RPC URL:',
|
|
31
|
+
choices: [url, ...envCandidates.filter(u => u !== url)]
|
|
32
|
+
}
|
|
33
|
+
]);
|
|
34
|
+
url = picked;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Fetch chainId and persist
|
|
38
|
+
let chainId = null;
|
|
39
|
+
try {
|
|
40
|
+
const provider = new ethers.JsonRpcProvider(url);
|
|
41
|
+
const net = await provider.getNetwork();
|
|
42
|
+
chainId = Number(net.chainId);
|
|
43
|
+
} catch (e) {
|
|
44
|
+
console.log('ā ļø Failed to detect chainId:', e.message);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const profile = config.readProfiles().activeProfile || 'default';
|
|
48
|
+
const current = config.readProfiles().profiles[profile] || {};
|
|
49
|
+
config.writeProfileSettings({ ...current, rpcUrl: url, chainId: chainId ?? current.chainId }, profile);
|
|
50
|
+
console.log(`ā
RPC URL updated to ${url} for profile '${profile}'${chainId ? ` (chainId=${chainId})` : ''}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function fixIpfsKeys(context = {}) {
|
|
54
|
+
console.log('\nš§ Fixing IPFS Configuration...');
|
|
55
|
+
let provider = 'pinata';
|
|
56
|
+
if (!context.auto) {
|
|
57
|
+
const ans = await inquirer.prompt([
|
|
58
|
+
{ type: 'list', name: 'provider', message: 'Select IPFS Provider:', choices: ['pinata'] }
|
|
59
|
+
]);
|
|
60
|
+
provider = ans.provider;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (provider === 'pinata') {
|
|
64
|
+
let answers = { apiKey: process.env.PINATA_API_KEY, secret: process.env.PINATA_SECRET_API_KEY, jwt: process.env.PINATA_JWT };
|
|
65
|
+
if (!context.auto) {
|
|
66
|
+
answers = await inquirer.prompt([
|
|
67
|
+
{ type: 'input', name: 'apiKey', message: 'Pinata API Key:', default: answers.apiKey || '' },
|
|
68
|
+
{ type: 'password', name: 'secret', message: 'Pinata Secret Key:', default: answers.secret || '' },
|
|
69
|
+
{ type: 'password', name: 'jwt', message: 'Pinata JWT (optional):', default: answers.jwt || '' }
|
|
70
|
+
]);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (answers.apiKey && answers.secret) {
|
|
74
|
+
// Write to profile IPFS config
|
|
75
|
+
config.writeIpfsConfig({
|
|
76
|
+
provider: 'pinata',
|
|
77
|
+
pinataApiKey: answers.apiKey,
|
|
78
|
+
pinataSecret: answers.secret,
|
|
79
|
+
pinataJwt: answers.jwt || undefined
|
|
80
|
+
});
|
|
81
|
+
console.log('ā
Saved Pinata configuration to profile (.sage/config.json)');
|
|
82
|
+
|
|
83
|
+
if (!context.auto) {
|
|
84
|
+
// Optional .env convenience (insecure for prod)
|
|
85
|
+
const { addToEnv } = await inquirer.prompt([{ type: 'confirm', name: 'addToEnv', message: 'Also add to .env for dev convenience? (insecure)', default: false }]);
|
|
86
|
+
if (addToEnv) {
|
|
87
|
+
const fs = require('fs');
|
|
88
|
+
const path = require('path');
|
|
89
|
+
const envPath = path.join(process.cwd(), '.env');
|
|
90
|
+
let envContent = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf8') : '';
|
|
91
|
+
if (!envContent.includes('PINATA_API_KEY=')) envContent += `\nPINATA_API_KEY=${answers.apiKey}`;
|
|
92
|
+
if (!envContent.includes('PINATA_SECRET_API_KEY=')) envContent += `\nPINATA_SECRET_API_KEY=${answers.secret}`;
|
|
93
|
+
if (answers.jwt && !envContent.includes('PINATA_JWT=')) envContent += `\nPINATA_JWT=${answers.jwt}`;
|
|
94
|
+
fs.writeFileSync(envPath, envContent);
|
|
95
|
+
try { require('dotenv').config(); } catch(_){}
|
|
96
|
+
console.log('ā
Added Pinata keys to .env');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function fixAllowance(tokenAddress, spenderAddress, spenderName = 'Spender', context = {}) {
|
|
104
|
+
console.log(`\nš§ Fixing Allowance for ${spenderName}...`);
|
|
105
|
+
let proceed = !!context.auto;
|
|
106
|
+
if (!proceed) {
|
|
107
|
+
const ans = await inquirer.prompt([
|
|
108
|
+
{ type: 'confirm', name: 'confirm', message: `Approve ${spenderName} to spend SXXX?`, default: true }
|
|
109
|
+
]);
|
|
110
|
+
proceed = !!ans.confirm;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!proceed) return;
|
|
114
|
+
|
|
115
|
+
const walletArgs = config.getCastWalletArgs();
|
|
116
|
+
const rpcUrl = process.env.RPC_URL || 'https://base-sepolia.publicnode.com';
|
|
117
|
+
|
|
118
|
+
// Max uint256
|
|
119
|
+
const amount = '115792089237316195423570985008687907853269984665640564039457584007913129639935';
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
console.log('ā³ Submitting approval...');
|
|
123
|
+
execSync(`cast send ${tokenAddress} "approve(address,uint256)" ${spenderAddress} ${amount} --rpc-url ${rpcUrl} ${walletArgs} --legacy`, { stdio: 'inherit' });
|
|
124
|
+
console.log('ā
Approved.');
|
|
125
|
+
} catch (e) {
|
|
126
|
+
console.error('ā Approval failed:', e.message);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = {
|
|
131
|
+
fixRpcUrl,
|
|
132
|
+
fixIpfsKeys,
|
|
133
|
+
fixAllowance
|
|
134
|
+
};
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
const { ethers } = require('ethers');
|
|
2
|
+
const { resolveGovContext } = require('../../utils/gov-context');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Comprehensive Governance Diagnosis
|
|
6
|
+
* @param {object} opts - { subdao, provider, signer }
|
|
7
|
+
* @returns {object} Report with recommendations and structured fix payloads
|
|
8
|
+
*/
|
|
9
|
+
async function diagnoseSubDAO({ subdao, provider, signer }) {
|
|
10
|
+
const out = {
|
|
11
|
+
subdao,
|
|
12
|
+
governor: null,
|
|
13
|
+
timelock: null,
|
|
14
|
+
mode: null,
|
|
15
|
+
checks: {},
|
|
16
|
+
recommendations: [], // { type, msg, fixCmd, fixPayload }
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// 1. Resolve Context
|
|
20
|
+
try {
|
|
21
|
+
const ctx = await resolveGovContext({ subdaoOpt: subdao, govOpt: undefined, provider });
|
|
22
|
+
out.governor = ctx.governor;
|
|
23
|
+
out.timelock = ctx.timelock;
|
|
24
|
+
out.checks.context = 'ok';
|
|
25
|
+
} catch (e) {
|
|
26
|
+
out.checks.context = 'failed';
|
|
27
|
+
out.checks.contextError = e.message;
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!out.governor || !out.timelock) {
|
|
32
|
+
out.recommendations.push({ msg: 'Could not resolve Governor or Timelock. Verify SubDAO address.' });
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 2. Detect Mode
|
|
37
|
+
try {
|
|
38
|
+
const SubABI = ['function getGovernanceMode() view returns (uint8)', 'function promptRegistry() view returns (address)'];
|
|
39
|
+
const sd = new ethers.Contract(subdao, SubABI, provider);
|
|
40
|
+
const mode = await sd.getGovernanceMode();
|
|
41
|
+
out.mode = Number(mode); // 0=Operator/Personal, 1=Community
|
|
42
|
+
out.registry = await sd.promptRegistry();
|
|
43
|
+
} catch (e) {
|
|
44
|
+
out.checks.mode = 'failed';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 3. Roles Check (Timelock)
|
|
48
|
+
try {
|
|
49
|
+
const tl = new ethers.Contract(out.timelock, [
|
|
50
|
+
'function PROPOSER_ROLE() view returns (bytes32)',
|
|
51
|
+
'function EXECUTOR_ROLE() view returns (bytes32)',
|
|
52
|
+
'function hasRole(bytes32,address) view returns (bool)',
|
|
53
|
+
'function grantRole(bytes32,address)'
|
|
54
|
+
], provider);
|
|
55
|
+
|
|
56
|
+
const PROPOSER = await tl.PROPOSER_ROLE();
|
|
57
|
+
const EXECUTOR = await tl.EXECUTOR_ROLE();
|
|
58
|
+
|
|
59
|
+
out.govIsProposer = await tl.hasRole(PROPOSER, out.governor);
|
|
60
|
+
out.anyoneExec = await tl.hasRole(EXECUTOR, ethers.ZeroAddress);
|
|
61
|
+
|
|
62
|
+
if (!out.govIsProposer) {
|
|
63
|
+
const iface = tl.interface;
|
|
64
|
+
const calldata = iface.encodeFunctionData('grantRole', [PROPOSER, out.governor]);
|
|
65
|
+
out.recommendations.push({
|
|
66
|
+
type: 'role_missing',
|
|
67
|
+
msg: 'Governor lacks PROPOSER_ROLE on Timelock.',
|
|
68
|
+
fixCmd: `sage timelock execute-call --subdao ${subdao} --to ${out.timelock} --sig "grantRole(bytes32,address)" --args "${PROPOSER},${out.governor}"`,
|
|
69
|
+
fixPayload: { to: out.timelock, data: calldata, description: 'Grant PROPOSER to Governor' }
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
if (!out.anyoneExec) {
|
|
73
|
+
const iface = tl.interface;
|
|
74
|
+
const calldata = iface.encodeFunctionData('grantRole', [EXECUTOR, ethers.ZeroAddress]);
|
|
75
|
+
out.recommendations.push({
|
|
76
|
+
type: 'role_missing',
|
|
77
|
+
msg: 'Timelock execution is restricted (executor != address(0)).',
|
|
78
|
+
fixCmd: `sage timelock execute-call --subdao ${subdao} --to ${out.timelock} --sig "grantRole(bytes32,address)" --args "${EXECUTOR},${ethers.ZeroAddress}"`,
|
|
79
|
+
fixPayload: { to: out.timelock, data: calldata, description: 'Open Execution (Grant EXECUTOR to 0x0)' }
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
} catch (e) {
|
|
83
|
+
out.checks.roles = 'failed';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 4. PromptRegistry Governance
|
|
87
|
+
if (out.registry && out.registry !== ethers.ZeroAddress) {
|
|
88
|
+
try {
|
|
89
|
+
const reg = new ethers.Contract(out.registry, [
|
|
90
|
+
'function GOVERNANCE_ROLE() view returns (bytes32)',
|
|
91
|
+
'function hasRole(bytes32,address) view returns (bool)',
|
|
92
|
+
'function grantRole(bytes32,address)'
|
|
93
|
+
], provider);
|
|
94
|
+
const GOV_ROLE = await reg.GOVERNANCE_ROLE().catch(() => null);
|
|
95
|
+
if (GOV_ROLE) {
|
|
96
|
+
const has = await reg.hasRole(GOV_ROLE, subdao).catch(() => false);
|
|
97
|
+
if (!has) {
|
|
98
|
+
const calldata = reg.interface.encodeFunctionData('grantRole', [GOV_ROLE, subdao]);
|
|
99
|
+
out.recommendations.push({
|
|
100
|
+
type: 'registry_role',
|
|
101
|
+
msg: 'SubDAO missing GOVERNANCE_ROLE on PromptRegistry.',
|
|
102
|
+
fixCmd: `sage timelock execute-call --subdao ${subdao} --to ${out.registry} --sig "grantRole(bytes32,address)" --args "${GOV_ROLE},${subdao}"`,
|
|
103
|
+
fixPayload: { to: out.registry, data: calldata, description: 'Grant GOVERNANCE_ROLE to SubDAO' }
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
} catch (e) {
|
|
108
|
+
out.checks.registry = 'failed';
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 5. LibraryRegistry (Scoped)
|
|
113
|
+
const libRegAddr = process.env.LIBRARY_REGISTRY_ADDRESS;
|
|
114
|
+
if (libRegAddr && out.timelock) {
|
|
115
|
+
try {
|
|
116
|
+
const lib = new ethers.Contract(libRegAddr, [
|
|
117
|
+
'function libraryAdminRole(address subdao) view returns (bytes32)',
|
|
118
|
+
'function hasRole(bytes32,address) view returns (bool)',
|
|
119
|
+
'function grantRole(bytes32,address)'
|
|
120
|
+
], provider);
|
|
121
|
+
const scopedRole = await lib.libraryAdminRole(subdao);
|
|
122
|
+
const has = await lib.hasRole(scopedRole, out.timelock).catch(()=>false);
|
|
123
|
+
if (!has) {
|
|
124
|
+
const calldata = lib.interface.encodeFunctionData('grantRole', [scopedRole, out.timelock]);
|
|
125
|
+
out.recommendations.push({
|
|
126
|
+
type: 'library_role',
|
|
127
|
+
msg: 'Timelock missing LIBRARY_ADMIN_ROLE (scoped) on LibraryRegistry.',
|
|
128
|
+
fixCmd: `sage safe propose --safe <TREASURY> --to ${libRegAddr} --data ${calldata} --operation 0`,
|
|
129
|
+
fixPayload: { to: libRegAddr, data: calldata, description: 'Grant Scoped LIBRARY_ADMIN to Timelock' }
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
} catch (e) {
|
|
133
|
+
out.checks.library = 'failed';
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
module.exports = { diagnoseSubDAO };
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const semver = require('semver');
|
|
4
|
+
const Ajv = require('ajv');
|
|
5
|
+
|
|
6
|
+
const ajv = new Ajv();
|
|
7
|
+
|
|
8
|
+
const schema = {
|
|
9
|
+
type: 'array',
|
|
10
|
+
items: {
|
|
11
|
+
type: 'object',
|
|
12
|
+
required: ['id', 'name', 'version', 'governance', 'params'],
|
|
13
|
+
properties: {
|
|
14
|
+
id: { type: 'string' },
|
|
15
|
+
name: { type: 'string' },
|
|
16
|
+
version: { type: 'string' }, // We'll validate semver manually too
|
|
17
|
+
governance: { type: 'string', enum: ['operator', 'token'] },
|
|
18
|
+
description: { type: 'string' },
|
|
19
|
+
params: {
|
|
20
|
+
type: 'object',
|
|
21
|
+
required: ['proposalThreshold', 'votingPeriod', 'quorumBps'],
|
|
22
|
+
properties: {
|
|
23
|
+
proposalThreshold: { type: 'string' }, // BigInt as string
|
|
24
|
+
votingPeriod: { type: ['string', 'integer'] },
|
|
25
|
+
quorumBps: { type: 'integer', minimum: 0, maximum: 10000 },
|
|
26
|
+
membershipPolicy: { type: 'string' },
|
|
27
|
+
forkingPolicy: { type: 'string' }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// Lazy load or require
|
|
35
|
+
let playbooksData = null;
|
|
36
|
+
|
|
37
|
+
function loadPlaybooks() {
|
|
38
|
+
if (playbooksData) return playbooksData;
|
|
39
|
+
const p = path.join(__dirname, '../../config/playbooks.json');
|
|
40
|
+
if (!fs.existsSync(p)) return [];
|
|
41
|
+
try {
|
|
42
|
+
const raw = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
43
|
+
|
|
44
|
+
// Schema Validation
|
|
45
|
+
const validate = ajv.compile(schema);
|
|
46
|
+
if (!validate(raw)) {
|
|
47
|
+
console.error('ā Playbook Schema Validation Failed:', validate.errors);
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
playbooksData = raw.filter(p => {
|
|
52
|
+
// Semver check
|
|
53
|
+
if (!semver.valid(p.version)) {
|
|
54
|
+
console.error(`ā ļø Skipping invalid playbook version for ${p.id}: ${p.version}`);
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
return true;
|
|
58
|
+
});
|
|
59
|
+
return playbooksData;
|
|
60
|
+
} catch (e) {
|
|
61
|
+
console.error('Error loading playbooks:', e.message);
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Get a playbook by ID and optional version (semver range).
|
|
68
|
+
* If version is not provided, returns the latest version (highest semver).
|
|
69
|
+
* @param {string} id - Playbook ID (creator, squad, community)
|
|
70
|
+
* @param {string} [versionRange] - Specific version or range (e.g. "^1.0.0")
|
|
71
|
+
*/
|
|
72
|
+
function getPlaybook(id, versionRange) {
|
|
73
|
+
const all = loadPlaybooks();
|
|
74
|
+
const matches = all.filter(p => p.id === id);
|
|
75
|
+
|
|
76
|
+
if (matches.length === 0) return null;
|
|
77
|
+
|
|
78
|
+
if (versionRange) {
|
|
79
|
+
// Find max satisfying version
|
|
80
|
+
const versions = matches.map(p => p.version);
|
|
81
|
+
const maxVer = semver.maxSatisfying(versions, versionRange);
|
|
82
|
+
if (!maxVer) return null;
|
|
83
|
+
return matches.find(p => p.version === maxVer);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Find latest if no range
|
|
87
|
+
return matches.sort((a, b) => semver.rcompare(a.version, b.version))[0];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function listPlaybooks() {
|
|
91
|
+
return loadPlaybooks();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = {
|
|
95
|
+
getPlaybook,
|
|
96
|
+
listPlaybooks
|
|
97
|
+
};
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { LibraryManager } = require('../../library-manager');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Bulk metadata helpers for pinned library manifests.
|
|
7
|
+
*
|
|
8
|
+
* These helpers intentionally mirror the semantics of quick-start flows:
|
|
9
|
+
* - Operate on pinned manifests in ~/.sage/libraries (or project .sage/libraries).
|
|
10
|
+
* - Reuse LibraryManager for directory resolution & manifest loading.
|
|
11
|
+
* - Back up prompt files when content changes.
|
|
12
|
+
*/
|
|
13
|
+
function createBulkOperations({
|
|
14
|
+
libraryManager = new LibraryManager(),
|
|
15
|
+
fsModule = fs,
|
|
16
|
+
pathModule = path,
|
|
17
|
+
logger = console,
|
|
18
|
+
} = {}) {
|
|
19
|
+
function resolvePinnedLibrary(libraryId) {
|
|
20
|
+
const all = libraryManager.listPinned() || [];
|
|
21
|
+
const id = String(libraryId || '').trim();
|
|
22
|
+
if (!id) {
|
|
23
|
+
throw new Error('library parameter is required');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Exact CID match first
|
|
27
|
+
const byCid = all.find((row) => row.cid === id);
|
|
28
|
+
if (byCid) {
|
|
29
|
+
const { manifest, path: manifestPath } = libraryManager.loadPinned(byCid.cid);
|
|
30
|
+
return { row: byCid, manifest, manifestPath };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const lower = id.toLowerCase();
|
|
34
|
+
const matches = all
|
|
35
|
+
.map((row) => {
|
|
36
|
+
try {
|
|
37
|
+
const { manifest, path: manifestPath } = libraryManager.loadPinned(row.cid);
|
|
38
|
+
const libName = String(manifest?.library?.name || row.name || '').toLowerCase();
|
|
39
|
+
return { row, manifest, manifestPath, libName };
|
|
40
|
+
} catch (e) {
|
|
41
|
+
logger?.warn?.('bulk_ops_manifest_load_failed', { cid: row.cid, err: e.message || String(e) });
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
.filter(Boolean)
|
|
46
|
+
.filter((entry) => entry.libName === lower);
|
|
47
|
+
|
|
48
|
+
if (matches.length === 0) {
|
|
49
|
+
throw new Error(`No pinned library found matching "${libraryId}". Try using the CID from ~/.sage/libraries/*.json.`);
|
|
50
|
+
}
|
|
51
|
+
if (matches.length > 1) {
|
|
52
|
+
const cids = matches.map((m) => `${m.row.cid} (${m.manifest?.library?.name || m.row.name || 'unnamed'})`);
|
|
53
|
+
throw new Error(
|
|
54
|
+
`Ambiguous library match for "${libraryId}". Candidates:\n- ${cids.join('\n- ')}\nPass an explicit CID to disambiguate.`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return matches[0];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function normalizeTagsInput(tags) {
|
|
62
|
+
if (tags === undefined) return undefined;
|
|
63
|
+
if (Array.isArray(tags)) {
|
|
64
|
+
return tags.map((t) => String(t).trim()).filter(Boolean);
|
|
65
|
+
}
|
|
66
|
+
// Single string: split on commas/semicolons
|
|
67
|
+
const str = String(tags);
|
|
68
|
+
return str
|
|
69
|
+
.split(/[,;]/)
|
|
70
|
+
.map((t) => t.trim())
|
|
71
|
+
.filter(Boolean);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function uniq(arr) {
|
|
75
|
+
const out = [];
|
|
76
|
+
const seen = new Set();
|
|
77
|
+
for (const v of arr) {
|
|
78
|
+
if (!seen.has(v)) {
|
|
79
|
+
seen.add(v);
|
|
80
|
+
out.push(v);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function updateLibraryMetadata({
|
|
87
|
+
library,
|
|
88
|
+
name,
|
|
89
|
+
description,
|
|
90
|
+
tags,
|
|
91
|
+
apply_to_prompts = false,
|
|
92
|
+
merge_mode = 'merge',
|
|
93
|
+
} = {}) {
|
|
94
|
+
const { row, manifest, manifestPath } = resolvePinnedLibrary(library);
|
|
95
|
+
const updatedFields = [];
|
|
96
|
+
|
|
97
|
+
if (!manifest.library || typeof manifest.library !== 'object') {
|
|
98
|
+
manifest.library = {};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (typeof name === 'string' && name.trim().length) {
|
|
102
|
+
manifest.library.name = name;
|
|
103
|
+
updatedFields.push('name');
|
|
104
|
+
}
|
|
105
|
+
if (typeof description === 'string') {
|
|
106
|
+
manifest.library.description = description;
|
|
107
|
+
updatedFields.push('description');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const normalizedTags = normalizeTagsInput(tags);
|
|
111
|
+
if (normalizedTags) {
|
|
112
|
+
manifest.library.tags = normalizedTags;
|
|
113
|
+
updatedFields.push('tags');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let updatedPromptCount = 0;
|
|
117
|
+
if (apply_to_prompts && normalizedTags && Array.isArray(manifest.prompts)) {
|
|
118
|
+
const mode = merge_mode === 'replace' ? 'replace' : 'merge';
|
|
119
|
+
for (const prompt of manifest.prompts) {
|
|
120
|
+
if (!prompt || typeof prompt !== 'object') continue;
|
|
121
|
+
const existing = Array.isArray(prompt.tags)
|
|
122
|
+
? prompt.tags.map((t) => String(t).trim()).filter(Boolean)
|
|
123
|
+
: [];
|
|
124
|
+
if (mode === 'replace') {
|
|
125
|
+
prompt.tags = normalizedTags.slice();
|
|
126
|
+
} else {
|
|
127
|
+
prompt.tags = uniq([...existing, ...normalizedTags]);
|
|
128
|
+
}
|
|
129
|
+
updatedPromptCount += 1;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
fsModule.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
success: true,
|
|
137
|
+
library: manifest.library?.name || row.name || library,
|
|
138
|
+
cid: row.cid,
|
|
139
|
+
updatedLibraryFields: updatedFields,
|
|
140
|
+
updatedPromptCount,
|
|
141
|
+
manifestPath,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function bulkUpdatePrompts({ library, updates, dry_run = false } = {}) {
|
|
146
|
+
if (!Array.isArray(updates) || updates.length === 0) {
|
|
147
|
+
throw new Error('updates must be a non-empty array');
|
|
148
|
+
}
|
|
149
|
+
const { row, manifest, manifestPath } = resolvePinnedLibrary(library);
|
|
150
|
+
const prompts = Array.isArray(manifest.prompts) ? manifest.prompts : [];
|
|
151
|
+
const byKey = new Map();
|
|
152
|
+
prompts.forEach((p) => {
|
|
153
|
+
if (p && typeof p.key === 'string') {
|
|
154
|
+
byKey.set(p.key, p);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const missingKeys = [];
|
|
159
|
+
const wouldUpdatePrompts = [];
|
|
160
|
+
const backedUpFiles = [];
|
|
161
|
+
const changedKeys = new Set();
|
|
162
|
+
|
|
163
|
+
const libDir = libraryManager.ensureLibrariesDir();
|
|
164
|
+
|
|
165
|
+
for (const update of updates) {
|
|
166
|
+
const key = String(update.key || '').trim();
|
|
167
|
+
if (!key) continue;
|
|
168
|
+
const prompt = byKey.get(key);
|
|
169
|
+
if (!prompt) {
|
|
170
|
+
missingKeys.push(key);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const changes = {};
|
|
175
|
+
let changedThisPrompt = false;
|
|
176
|
+
|
|
177
|
+
// Content update
|
|
178
|
+
if (typeof update.content === 'string') {
|
|
179
|
+
const files = Array.isArray(prompt.files) ? prompt.files : [];
|
|
180
|
+
let targetPath;
|
|
181
|
+
if (files.length > 0) {
|
|
182
|
+
const relativePath = files[0];
|
|
183
|
+
targetPath = pathModule.isAbsolute(relativePath)
|
|
184
|
+
? relativePath
|
|
185
|
+
: pathModule.join(libDir, relativePath);
|
|
186
|
+
} else {
|
|
187
|
+
const promptsDir = pathModule.join(libDir, 'prompts');
|
|
188
|
+
if (!fsModule.existsSync(promptsDir)) {
|
|
189
|
+
fsModule.mkdirSync(promptsDir, { recursive: true });
|
|
190
|
+
}
|
|
191
|
+
const fileName = `${row.cid}_${key}.md`;
|
|
192
|
+
targetPath = pathModule.join(promptsDir, fileName);
|
|
193
|
+
prompt.files = [`prompts/${fileName}`];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const beforeContent = !dry_run && fsModule.existsSync(targetPath)
|
|
197
|
+
? fsModule.readFileSync(targetPath, 'utf8')
|
|
198
|
+
: null;
|
|
199
|
+
|
|
200
|
+
if (dry_run) {
|
|
201
|
+
changes.content = { from: beforeContent === null ? '(new file)' : '(existing file)', to: '(would change)' };
|
|
202
|
+
changedThisPrompt = true;
|
|
203
|
+
} else {
|
|
204
|
+
if (beforeContent !== null) {
|
|
205
|
+
const backupPath = `${targetPath}.v${Date.now()}.bak`;
|
|
206
|
+
fsModule.copyFileSync(targetPath, backupPath);
|
|
207
|
+
backedUpFiles.push(backupPath);
|
|
208
|
+
}
|
|
209
|
+
fsModule.writeFileSync(targetPath, update.content, 'utf8');
|
|
210
|
+
changes.content = { from: '(previous content)', to: '(updated content)' };
|
|
211
|
+
changedThisPrompt = true;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Name / description
|
|
216
|
+
if (typeof update.name === 'string') {
|
|
217
|
+
changes.name = { from: prompt.name || '', to: update.name };
|
|
218
|
+
if (!dry_run) {
|
|
219
|
+
prompt.name = update.name;
|
|
220
|
+
changedThisPrompt = true;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (typeof update.description === 'string') {
|
|
224
|
+
changes.description = { from: prompt.description || '', to: update.description };
|
|
225
|
+
if (!dry_run) {
|
|
226
|
+
prompt.description = update.description;
|
|
227
|
+
changedThisPrompt = true;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (update.tags !== undefined) {
|
|
232
|
+
const normalizedTags = normalizeTagsInput(update.tags) || [];
|
|
233
|
+
const beforeTags = Array.isArray(prompt.tags) ? prompt.tags.slice() : [];
|
|
234
|
+
changes.tags = { from: beforeTags, to: normalizedTags };
|
|
235
|
+
if (!dry_run) {
|
|
236
|
+
prompt.tags = normalizedTags;
|
|
237
|
+
changedThisPrompt = true;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (Object.keys(changes).length > 0) {
|
|
242
|
+
wouldUpdatePrompts.push({ key, changes });
|
|
243
|
+
changedKeys.add(key);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (!dry_run) {
|
|
248
|
+
fsModule.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
success: true,
|
|
253
|
+
library: manifest.library?.name || row.name || library,
|
|
254
|
+
cid: row.cid,
|
|
255
|
+
dryRun: !!dry_run,
|
|
256
|
+
updatedCount: changedKeys.size,
|
|
257
|
+
missingKeys,
|
|
258
|
+
backedUpFiles: dry_run ? [] : backedUpFiles,
|
|
259
|
+
wouldUpdatePrompts,
|
|
260
|
+
manifestPath,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
updateLibraryMetadata,
|
|
266
|
+
bulkUpdatePrompts,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
module.exports = {
|
|
271
|
+
createBulkOperations,
|
|
272
|
+
};
|