@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
@@ -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')
@@ -32,6 +32,9 @@ async function checkArtifacts(roots) {
32
32
  async function runDoctor(opts = {}) {
33
33
  const out = [];
34
34
  const warn = [];
35
+ const diagnostics = [];
36
+ const { fixRpcUrl, fixIpfsKeys, fixAllowance } = require('../services/doctor/fixers');
37
+
35
38
  const useSpinner = opts.disableSpinner !== true;
36
39
  const runWithProgress = async (label, task) => {
37
40
  if (!useSpinner) return task();
@@ -55,7 +58,7 @@ async function runDoctor(opts = {}) {
55
58
  warn.push('Test mode: diagnostics skipped');
56
59
  console.log('Warnings:');
57
60
  warn.forEach((w) => console.log(`- ${w}`));
58
- return;
61
+ return { diagnostics: [] };
59
62
  }
60
63
 
61
64
  // 1) Cast availability
@@ -64,7 +67,14 @@ async function runDoctor(opts = {}) {
64
67
  out.push(`${hasCast ? '✅' : '❌'} Foundry (cast)`);
65
68
 
66
69
  // 2) RPC reachability + chainId (env vs profile)
67
- const rpcUrl = process.env.RPC_URL || 'https://sepolia.base.org';
70
+ // Prefer env RPC over profile for testing/debugging
71
+ let profileRpc = null;
72
+ try {
73
+ const prof = config.readProfiles();
74
+ const ap = prof.activeProfile || 'default';
75
+ profileRpc = prof?.profiles?.[ap]?.rpcUrl || null;
76
+ } catch (_) {}
77
+ const rpcUrl = process.env.RPC_URL || profileRpc || 'https://sepolia.base.org';
68
78
  const expectedChainId = Number(process.env.CHAIN_ID || 84532);
69
79
 
70
80
  // Check RPC health
@@ -74,15 +84,19 @@ async function runDoctor(opts = {}) {
74
84
  out.push(`${c.green('✅')} RPC healthy (${rpcHealth.latency}ms)`);
75
85
  } else {
76
86
  out.push(`${c.red('❌')} RPC unhealthy: ${rpcHealth.errors.join(', ')}`);
87
+ diagnostics.push({
88
+ level: 'error',
89
+ code: 'RPC_UNHEALTHY',
90
+ message: `RPC endpoint is unhealthy: ${rpcHealth.errors.join(', ')}`,
91
+ fix: fixRpcUrl
92
+ });
77
93
  }
78
94
  rpcHealth.warnings.forEach(w => warn.push(`${c.yellow('⚠️')} RPC: ${w}`));
79
- let profileRpc = null;
80
95
  let profileChainId = null;
81
96
  try {
82
97
  const prof = config.readProfiles();
83
98
  const ap = prof.activeProfile || 'default';
84
99
  const p = prof?.profiles?.[ap] || {};
85
- profileRpc = p.rpcUrl || null;
86
100
  profileChainId = p.chainId ? Number(p.chainId) : null;
87
101
  } catch (_) {}
88
102
  let provider, network;
@@ -103,6 +117,15 @@ async function runDoctor(opts = {}) {
103
117
  } catch (e) {
104
118
  out.push(`❌ RPC unreachable: ${rpcUrl}`);
105
119
  warn.push(`RPC error: ${e.message}`);
120
+ // Duplicate check but safe
121
+ if (!diagnostics.find(d => d.code === 'RPC_UNHEALTHY')) {
122
+ diagnostics.push({
123
+ level: 'error',
124
+ code: 'RPC_UNREACHABLE',
125
+ message: `RPC unreachable: ${e.message}`,
126
+ fix: fixRpcUrl
127
+ });
128
+ }
106
129
  }
107
130
 
108
131
  // 2b) Subgraph status (URL + last indexed block, with lag vs RPC)
@@ -308,7 +331,15 @@ async function runDoctor(opts = {}) {
308
331
  const tok = new ethers.Contract(sxxxAddr, ERC20, provider);
309
332
  const allowance = await tok.allowance(proposer, governorAddr);
310
333
  out.push(`💳 SXXX allowance to Governor: ${ethers.formatEther(allowance)} SXXX`);
311
- if (allowance === 0n) warn.push('SXXX allowance is 0. Approve before proposing library updates.');
334
+ if (allowance === 0n) {
335
+ warn.push('SXXX allowance is 0. Approve before proposing library updates.');
336
+ diagnostics.push({
337
+ level: 'warn',
338
+ code: 'ALLOWANCE_ZERO',
339
+ message: 'SXXX allowance to Governor is 0',
340
+ fix: () => fixAllowance(sxxxAddr, governorAddr, 'Governor')
341
+ });
342
+ }
312
343
  }
313
344
  if (sxxxAddr) {
314
345
  const BurnChk = ['function authorizedBurners(address) view returns (bool)'];
@@ -372,7 +403,7 @@ async function runDoctor(opts = {}) {
372
403
 
373
404
  // 7) LibraryRegistry role diagnostics (optional)
374
405
  try {
375
- if (process.argv.includes('--registry')) {
406
+ if (opts.registry) {
376
407
  const { resolveRegistryAddress } = require('../utils/address-resolution');
377
408
  const { registry: regAddr } = resolveRegistryAddress();
378
409
  const facAddr = process.env.SUBDAO_FACTORY_ADDRESS;
@@ -600,6 +631,26 @@ async function runDoctor(opts = {}) {
600
631
  }
601
632
  } catch (e) { warn.push('SimpleKeyStore checks failed: ' + e.message); }
602
633
 
634
+ // 12b) IPFS configuration (Pinata keys)
635
+ try {
636
+ const ipfsCfg = config.readIpfsConfig ? config.readIpfsConfig() : {};
637
+ if (!ipfsCfg.provider) {
638
+ warn.push('IPFS provider not configured');
639
+ diagnostics.push({ level: 'warn', code: 'IPFS_KEYS_MISSING', message: 'IPFS not configured', fix: fixIpfsKeys });
640
+ } else if (String(ipfsCfg.provider).toLowerCase() === 'pinata') {
641
+ const hasJwt = !!ipfsCfg.pinataJwt;
642
+ const hasKeys = !!ipfsCfg.pinataApiKey && !!ipfsCfg.pinataSecret;
643
+ if (!hasJwt && !hasKeys) {
644
+ warn.push('IPFS (pinata) lacks credentials (JWT or API key/secret)');
645
+ diagnostics.push({ level: 'warn', code: 'IPFS_KEYS_MISSING', message: 'IPFS Pinata keys not configured', fix: fixIpfsKeys });
646
+ } else {
647
+ out.push(`✅ IPFS configured: pinata (${hasJwt ? 'jwt' : 'api-key'})`);
648
+ }
649
+ } else {
650
+ out.push(`✅ IPFS configured: ${ipfsCfg.provider}`);
651
+ }
652
+ } catch (e) { warn.push('IPFS config check failed: ' + e.message); }
653
+
603
654
  // Print report
604
655
  console.log(c.cyan('🔎 Sage Doctor Report'));
605
656
  // SAGE_HOME/project-local path
@@ -615,9 +666,10 @@ async function runDoctor(opts = {}) {
615
666
  }
616
667
  console.log('');
617
668
  console.log('Tips:');
618
- console.log(' - Set addresses via: sage config addresses import --file packages-protocol-protocol/contracts/deployment/addresses/sepolia.json');
669
+ console.log(' - Set addresses via: sage config addresses import --file packages/contracts/deployment/addresses/sepolia.json');
619
670
  console.log(' - Set working SubDAO: sage subdao use <0x...>');
620
671
  console.log(' - Verify env: sage config env show');
672
+ return { diagnostics };
621
673
  }
622
674
 
623
675
  function _resolveSageHome() {
@@ -650,6 +702,8 @@ function register(program) {
650
702
  .option('--registry', 'Also check LibraryRegistry roles (FACTORY_ROLE, LIBRARY_ADMIN_ROLE)', false)
651
703
  .option('--cid <cid>', 'Optional CID to warm-test IPFS gateways')
652
704
  .option('--subdao <address>', 'Target SubDAO for mode/roles diagnostics')
705
+ .option('--fix', 'Attempt to fix detected issues interactively', false)
706
+ .option('--auto-fix', 'Attempt to fix non-interactively with safe defaults', false)
653
707
  .action(async (opts) => {
654
708
  const previousQuiet = process.env.SAGE_QUIET_JSON;
655
709
  if (opts.json) process.env.SAGE_QUIET_JSON = '1';
@@ -659,7 +713,9 @@ function register(program) {
659
713
  if (opts.json) {
660
714
  console.log = (...args) => capture.push(args.join(' '));
661
715
  }
662
- await runDoctor({ ...opts, disableSpinner: opts.json });
716
+
717
+ const { diagnostics } = await runDoctor({ ...opts, disableSpinner: opts.json });
718
+
663
719
  if (opts.cid) {
664
720
  try {
665
721
  let normalizeCidV1Base32;
@@ -680,6 +736,28 @@ function register(program) {
680
736
  }
681
737
  } catch (e) { console.log('⚠️ Gateway warm test skipped:', e.message); }
682
738
  }
739
+
740
+ // Handle Fixes
741
+ if (opts.fix && diagnostics && diagnostics.length > 0) {
742
+ console.log('\n🔧 Scanning for fixable issues...');
743
+ const fixable = diagnostics.filter(d => d.fix);
744
+ if (fixable.length === 0) {
745
+ console.log(' No auto-fixable issues found.');
746
+ } else {
747
+ for (const diag of fixable) {
748
+ // Ensure user context
749
+ if (!opts.json) {
750
+ console.log(`\nFound issue: ${diag.message}`);
751
+ }
752
+ try {
753
+ await diag.fix({ auto: opts.autoFix });
754
+ } catch (e) {
755
+ console.error(` ❌ Fix failed: ${e.message}`);
756
+ }
757
+ }
758
+ }
759
+ }
760
+
683
761
  if (opts.json) {
684
762
  // Build JSON object with key facts
685
763
  const rpcUrl = process.env.RPC_URL || 'https://sepolia.base.org';
@@ -697,6 +775,7 @@ function register(program) {
697
775
  TIMELOCK: process.env.TIMELOCK || null
698
776
  },
699
777
  sageHome,
778
+ diagnostics: diagnostics?.map(d => ({ level: d.level, message: d.message, code: d.code, fixable: !!d.fix })) || [],
700
779
  logs: capture
701
780
  };
702
781
  console.log = oldLog; // restore
@@ -188,6 +188,87 @@ function register(program) {
188
188
  })
189
189
  );
190
190
 
191
+ // Voting configuration proposal (set voting period/quorum/threshold via governance)
192
+ cmd.addCommand(
193
+ new Command('voting')
194
+ .description('Propose updates to voting parameters (voting period, quorum bps, proposal threshold)')
195
+ .option('--voting-period <duration>', 'Voting period duration (e.g., 7 days, 36 hours)')
196
+ .option('--quorum-bps <bps>', 'Quorum basis points (e.g., 200 for 2%)')
197
+ .option('--proposal-threshold <amount>', 'Proposal threshold (string/wei)')
198
+ .option('--block-time-sec <n>', 'Override block time (seconds per block); auto-detected if omitted')
199
+ .option('--subdao <address>', 'Resolve governor from this SubDAO')
200
+ .option('--gov <address>', 'Governor address (overrides --subdao resolution)')
201
+ .option('--title <text>', 'Proposal title/description', 'Update voting parameters')
202
+ .action(async (opts) => {
203
+ try {
204
+ const { ethers } = require('ethers');
205
+ const { resolveGovContext } = require('../utils/gov-context');
206
+ const { parseDurationToBlocks } = require('../services/subdao/planner');
207
+ const config = require('../config');
208
+ const wm = new (require('@sage-protocol/wallet-manager'))(); await wm.connect();
209
+ const signer = wm.getSigner(); const provider = wm.getProvider();
210
+ const ctx = await resolveGovContext({ govOpt: opts.gov, subdaoOpt: opts.subdao, provider });
211
+ const govAddr = ctx.governor; if (!govAddr) throw new Error('Governor not resolved; pass --subdao or --gov');
212
+
213
+ const targets = []; const values = []; const calldatas = [];
214
+ const iface = new ethers.Interface([
215
+ 'function setVotingPeriod(uint256)',
216
+ 'function setProposalThreshold(uint256)',
217
+ 'function setQuorumBps(uint256)',
218
+ // fallback OZ method name when available
219
+ 'function updateQuorumNumerator(uint256)'
220
+ ]);
221
+
222
+ const rpc = (config.resolveRpcUrl ? config.resolveRpcUrl() : (process.env.RPC_URL || 'https://base-sepolia.publicnode.com'));
223
+ const providerTmp = new ethers.JsonRpcProvider(rpc);
224
+ const detectBlockTimeSec = async () => {
225
+ try {
226
+ const latest = await providerTmp.getBlockNumber();
227
+ const older = latest > 100 ? latest - 100 : (latest > 1 ? 1 : 0);
228
+ const [bLatest, bOlder] = await Promise.all([providerTmp.getBlock(latest), providerTmp.getBlock(older)]);
229
+ const dt = Number(bLatest.timestamp) - Number(bOlder.timestamp);
230
+ if (dt <= 0) return 12; return Math.max(1, Math.round(dt / Math.max(1, latest - older)));
231
+ } catch (_) { return 12; }
232
+ };
233
+
234
+ // votingPeriod → blocks
235
+ if (opts.votingPeriod) {
236
+ const bt = opts.blockTimeSec ? Number(opts.blockTimeSec) : await detectBlockTimeSec();
237
+ const blocks = parseDurationToBlocks(String(opts.votingPeriod), bt);
238
+ targets.push(govAddr); values.push(0);
239
+ calldatas.push(iface.encodeFunctionData('setVotingPeriod', [BigInt(blocks)]));
240
+ }
241
+
242
+ if (opts.proposalThreshold) {
243
+ targets.push(govAddr); values.push(0);
244
+ calldatas.push(iface.encodeFunctionData('setProposalThreshold', [BigInt(String(opts.proposalThreshold))]));
245
+ }
246
+
247
+ if (opts.quorumBps) {
248
+ // Prefer setQuorumBps; fallback to OZ updateQuorumNumerator
249
+ try {
250
+ calldatas.push(iface.encodeFunctionData('setQuorumBps', [Number(opts.quorumBps)]));
251
+ targets.push(govAddr); values.push(0);
252
+ } catch (_) {
253
+ targets.push(govAddr); values.push(0);
254
+ calldatas.push(iface.encodeFunctionData('updateQuorumNumerator', [Number(opts.quorumBps)]));
255
+ }
256
+ }
257
+
258
+ if (!targets.length) throw new Error('Provide at least one of --voting-period, --quorum-bps, --proposal-threshold');
259
+
260
+ const gov = new ethers.Contract(govAddr, ['function propose(address[],uint256[],bytes[],string) returns (uint256)'], signer);
261
+ const desc = opts.title || 'Update voting parameters';
262
+ const tx = await gov.propose(targets, values, calldatas, desc);
263
+ console.log('🗳️ propose tx:', tx.hash);
264
+ console.log('➡️ Submit votes, then queue and execute to apply new parameters');
265
+ } catch (e) {
266
+ console.error('❌ voting config failed:', e.message);
267
+ process.exit(1);
268
+ }
269
+ })
270
+ );
271
+
191
272
  program.addCommand(cmd);
192
273
  }
193
274
 
@@ -3,6 +3,7 @@ const { Command } = require('commander');
3
3
  const SXXXManager = require('../sxxx-manager');
4
4
  const SubDAOManager = require('../subdao-manager');
5
5
  const LibraryManager = require('../library-manager');
6
+ const GovernanceManager = require('../governance-manager');
6
7
  const { ethers } = require('ethers');
7
8
  const axios = require('axios');
8
9
  const { execSync } = require('child_process');
@@ -15,51 +16,6 @@ const { handleCLIError } = require('../utils/error-handler');
15
16
  const logger = require('../utils/logger');
16
17
  const hooks = require('../utils/hooks');
17
18
 
18
- async function resolveGovContext(options) {
19
- let subdao = options.subdao || process.env.WORKING_SUBDAO_ADDRESS || process.env.SUBDAO;
20
- let governorAddr = process.env.GOV;
21
-
22
- if (subdao) {
23
- // Get governor address from SubDAO contract
24
- const rpcUrl = process.env.RPC_URL || 'https://base-sepolia.publicnode.com';
25
- try {
26
- governorAddr = execSync(`cast call ${subdao} "governor()" --rpc-url ${rpcUrl}`, { encoding: 'utf8' }).trim();
27
- } catch (e) {
28
- console.log(`⚠️ Could not get governor from SubDAO: ${e.message}`);
29
- governorAddr = process.env.GOV;
30
- }
31
- } else {
32
- // Fallback to wizard to select a SubDAO or main gov
33
- const inquirer = (require('inquirer').default || require('inquirer'));
34
- const { scope } = await inquirer.prompt([
35
- { type: 'list', name: 'scope', message: 'Select governance context', choices: [
36
- { name: 'A SubDAO (recommended)', value: 'subdao' },
37
- { name: 'Main protocol (use GOV from env)', value: 'main' }
38
- ]}
39
- ]);
40
-
41
- if (scope === 'subdao') {
42
- // For now, use a simple prompt for SubDAO address
43
- const { subdaoAddr } = await inquirer.prompt([
44
- { type: 'input', name: 'subdaoAddr', message: 'Enter SubDAO address:' }
45
- ]);
46
- subdao = subdaoAddr;
47
- const rpcUrl = process.env.RPC_URL || 'https://base-sepolia.publicnode.com';
48
- try {
49
- governorAddr = execSync(`cast call ${subdao} "governor()" --rpc-url ${rpcUrl}`, { encoding: 'utf8' }).trim();
50
- } catch (e) {
51
- console.log(`⚠️ Could not get governor from SubDAO: ${e.message}`);
52
- governorAddr = process.env.GOV;
53
- }
54
- process.env.SUBDAO = subdao;
55
- process.env.WORKING_SUBDAO_ADDRESS = subdao;
56
- cliConfig.writeAddresses({ SUBDAO: subdao, WORKING_SUBDAO_ADDRESS: subdao, GOV: governorAddr });
57
- }
58
- }
59
-
60
- return { governorAddr, subdao };
61
- }
62
-
63
19
  function register(program) {
64
20
  const governanceCommand = new Command('governance')
65
21
  .description('Governance management commands')
@@ -861,6 +817,8 @@ function register(program) {
861
817
  .action(async () => {
862
818
  try {
863
819
  const inquirer = (require('inquirer').default || require('inquirer'));
820
+ const GovernanceManager = require('../governance-manager');
821
+ const SubDAOManager = require('../subdao-manager');
864
822
  const gm = new GovernanceManager();
865
823
  const sdm = new SubDAOManager();
866
824
  await sdm.initialize();
@@ -871,16 +829,18 @@ function register(program) {
871
829
  { name: 'Main protocol (use GOV from env)', value: 'main' }
872
830
  ]}
873
831
  ]);
832
+ let subdaoAddr = null;
874
833
  if (ctx.scope === 'subdao') {
875
- const subdaoAddr = await sdm.resolveSubDAOAddress(null, { title: 'Select a SubDAO' });
834
+ subdaoAddr = await sdm.resolveSubDAOAddress(null, { title: 'Select a SubDAO' });
876
835
  await gm.initialize(subdaoAddr);
877
836
  // Persist SUBDAO and derived GOV for future runs
878
837
  try {
838
+ const LibraryManager = require('../library-manager');
879
839
  const lm = new LibraryManager();
880
840
  process.env.SUBDAO = subdaoAddr;
881
841
  process.env.WORKING_SUBDAO_ADDRESS = subdaoAddr;
882
842
  const gov = await lm.getGovernorAddress();
883
- cliConfig.writeAddresses({ SUBDAO: subdaoAddr, WORKING_SUBDAO_ADDRESS: subdaoAddr, GOV: gov });
843
+ require('../config').writeAddresses({ SUBDAO: subdaoAddr, WORKING_SUBDAO_ADDRESS: subdaoAddr, GOV: gov });
884
844
  } catch (_) {}
885
845
  } else {
886
846
  await gm.initialize();
@@ -1810,7 +1770,10 @@ function register(program) {
1810
1770
  }
1811
1771
 
1812
1772
  // Update configuration via governance proposal
1813
- const { governorAddr } = await resolveGovContext(opts);
1773
+ const { resolveGovContext } = require('../utils/gov-context');
1774
+ const _prov = new ethers.JsonRpcProvider(process.env.RPC_URL || 'https://base-sepolia.publicnode.com');
1775
+ const _ctx = await resolveGovContext({ govOpt: null, subdaoOpt: opts.subdao, provider: _prov });
1776
+ const governorAddr = _ctx.governor;
1814
1777
  const targets = [];
1815
1778
  const values = [];
1816
1779
  const calldatas = [];
@@ -1959,7 +1922,9 @@ function register(program) {
1959
1922
  .option('--subdao <address>', 'SubDAO context')
1960
1923
  .action(async (proposalId, support, opts) => {
1961
1924
  try {
1962
- const { governorAddr } = await resolveGovContext(opts);
1925
+ const { resolveGovContext } = require('../utils/gov-context');
1926
+ const provider = new ethers.JsonRpcProvider(process.env.RPC_URL || 'https://base-sepolia.publicnode.com');
1927
+ const ctx = await resolveGovContext({ govOpt: opts.gov, subdaoOpt: opts.subdao, provider });
1963
1928
  const rpcUrl = process.env.RPC_URL || 'https://base-sepolia.publicnode.com';
1964
1929
 
1965
1930
  const supportInt = parseInt(support);
@@ -2008,14 +1973,24 @@ function register(program) {
2008
1973
  .option('--description <description>', 'Proposal description')
2009
1974
  .option('--targets <addresses>', 'Comma-separated target contract addresses')
2010
1975
  .option('--values <amounts>', 'Comma-separated ETH values (in ether)')
2011
- .option('--calldatas <data>', 'Comma-separated calldata strings')
1976
+ .option('--calldatas <data>', 'Comma-separated calldata (0x hex)')
1977
+ .option('--target <address>', 'Target address (repeatable)', (v, p = []) => { p.push(v); return p; }, [])
1978
+ .option('--value <eth>', 'ETH value in ether (repeatable)', (v, p = []) => { p.push(v); return p; }, [])
1979
+ .option('--calldata <data>', 'Calldata 0x hex (repeatable)', (v, p = []) => { p.push(v); return p; }, [])
1980
+ .option('--sigs <signatures>', 'Comma-separated function signatures (optional)')
2012
1981
  .option('--subdao <address>', 'SubDAO context')
1982
+ .option('--gov <address>', 'Governor address (overrides SubDAO)')
1983
+ .option('--dry-run', 'Validate proposal without creating it', false)
1984
+ .option('--yes', 'Skip confirmation prompt', false)
1985
+ .option('--verbose', 'Enable verbose logging', false)
2013
1986
  .action(async (opts) => {
2014
1987
  try {
2015
- const { governorAddr } = await resolveGovContext(opts);
1988
+ const { resolveGovContext } = require('../utils/gov-context');
1989
+ const provider = new ethers.JsonRpcProvider(process.env.RPC_URL || 'https://base-sepolia.publicnode.com');
1990
+ const ctx = await resolveGovContext({ govOpt: opts.gov, subdaoOpt: opts.subdao, provider });
2016
1991
 
2017
1992
  // If no options provided, use interactive mode
2018
- if (!opts.title && !opts.targets) {
1993
+ if (!opts.targets && (!opts.target || opts.target.length === 0) && !opts.calldatas && (!opts.calldata || opts.calldata.length === 0)) {
2019
1994
  console.log('🏛️ Interactive proposal creation');
2020
1995
  console.log('Tip: Use the governance wizard for guided proposal creation: sage governance wizard');
2021
1996
 
@@ -2031,44 +2006,149 @@ function register(program) {
2031
2006
  Object.assign(opts, answers);
2032
2007
  }
2033
2008
 
2034
- if (!opts.title || !opts.targets || !opts.calldatas) {
2035
- throw new Error('Missing required fields: title, targets, and calldatas are required');
2036
- }
2009
+ const splitCsv = (s) => String(s || '').split(',').map(x => x.trim()).filter(Boolean);
2010
+ const targetsRaw = [ ...(opts.target || []), ...(opts.targets ? splitCsv(opts.targets) : []) ];
2011
+ const valuesRaw = [ ...(opts.value || []), ...(opts.values ? splitCsv(opts.values) : []) ];
2012
+ const calldatasRaw = [ ...(opts.calldata || []), ...(opts.calldatas ? splitCsv(opts.calldatas) : []) ];
2037
2013
 
2038
- const targets = opts.targets.split(',').map(s => s.trim());
2039
- const values = (opts.values || '0').split(',').map(v => ethers.parseEther(v.trim()));
2040
- const calldatas = opts.calldatas.split(',').map(s => s.trim());
2041
-
2042
- if (targets.length !== values.length || targets.length !== calldatas.length) {
2043
- throw new Error('Targets, values, and calldatas arrays must have the same length');
2014
+ if (targetsRaw.length === 0 || calldatasRaw.length === 0) {
2015
+ throw new Error('Missing required fields: targets and calldatas are required');
2044
2016
  }
2045
2017
 
2046
- const description = opts.description || opts.title;
2018
+ let targets;
2019
+ try {
2020
+ targets = targetsRaw.map((t) => ethers.getAddress(t));
2021
+ } catch (_) {
2022
+ throw new Error('Invalid target address provided');
2023
+ }
2024
+ let values = valuesRaw.length ? valuesRaw.map((v) => ethers.parseEther(String(v))) : Array(targets.length).fill(0n);
2025
+ if (values.length !== targets.length) {
2026
+ throw new Error('Values length must equal targets length (or omit values to default to 0)');
2027
+ }
2028
+ const isHex = (s) => /^0x[0-9a-fA-F]*$/.test(String(s));
2029
+ const calldatas = calldatasRaw.map(String);
2030
+ if (calldatas.length !== targets.length) {
2031
+ throw new Error('Calldatas length must equal targets length');
2032
+ }
2033
+ if (opts.sigs && calldatas.some((c) => !isHex(c))) {
2034
+ throw new Error('Non-hex calldata with --sigs is not supported. Provide 0x-encoded calldatas.');
2035
+ }
2036
+ if (calldatas.some((c) => !isHex(c))) {
2037
+ throw new Error('Calldata entries must be 0x hex-encoded');
2038
+ }
2039
+ const description = opts.description || opts.title || '';
2047
2040
 
2048
2041
  console.log('--- Proposal Preview ---');
2049
- console.log('Title:', opts.title);
2050
- console.log('Description:', description);
2042
+ if (opts.title) console.log('Title:', opts.title);
2043
+ if (description) console.log('Description:', description);
2051
2044
  console.log('Targets:', targets);
2052
2045
  console.log('Values:', values.map(v => v.toString()));
2053
2046
  console.log('Calldatas:', calldatas.map(c => c.substring(0, 20) + '...'));
2054
2047
 
2055
- const inquirer = (require('inquirer').default || require('inquirer'));
2056
- const { confirm } = await inquirer.prompt([{ type: 'confirm', name: 'confirm', message: 'Create proposal?', default: true }]);
2057
- if (!confirm) return;
2048
+ if (opts.dryRun) {
2049
+ console.log(' Proposal validation passed (dry-run)');
2050
+ return;
2051
+ }
2058
2052
 
2059
- const rpcUrl = process.env.RPC_URL || 'https://base-sepolia.publicnode.com';
2060
- const proposalData = execSync(`cast calldata "propose(address[],uint256[],bytes[],string)" '["${targets.join('","')}"]' '[${values.join(',')}]' '["${calldatas.join('","')}"]' "${description}"`, { encoding: 'utf8' }).trim();
2053
+ if (!opts.yes && String(process.env.SAGE_FORCE || '').trim() !== '1') {
2054
+ const inquirer = (require('inquirer').default || require('inquirer'));
2055
+ const { confirm } = await inquirer.prompt([{ type: 'confirm', name: 'confirm', message: 'Create proposal?', default: true }]);
2056
+ if (!confirm) return;
2057
+ }
2058
+
2059
+ if (opts.verbose || process.env.SAGE_VERBOSE === '1') {
2060
+ console.log('🔗 Connecting wallet...');
2061
+ }
2062
+ const gm = new GovernanceManager();
2063
+ await gm.initialize(ctx.subdao || opts.subdao || null);
2061
2064
 
2062
- const walletArgs = cliConfig.getCastWalletArgs();
2063
- execSync(`cast send ${governorAddr} "${proposalData}" --rpc-url ${rpcUrl} ${walletArgs}`, { encoding: 'utf8' });
2065
+ if (opts.verbose || process.env.SAGE_VERBOSE === '1') {
2066
+ console.log('📝 Creating proposal...');
2067
+ }
2068
+ await gm.createProposal(targets, values, calldatas, description);
2064
2069
  console.log('✅ Proposal created successfully');
2065
2070
 
2066
2071
  } catch (e) {
2067
- console.error('❌ Failed to create proposal:', e.message);
2072
+ try {
2073
+ const { handleCLIError } = require('../utils/error-handler');
2074
+ handleCLIError('governance:propose-custom', e, { context: { subdao: opts?.subdao, gov: opts?.gov } });
2075
+ } catch (_) {
2076
+ console.error('❌ Failed to create proposal:', e.message);
2077
+ }
2078
+ process.exit(1);
2079
+ }
2080
+ })
2081
+ )
2082
+ .addCommand(
2083
+ new Command('wait')
2084
+ .description('Wait for a proposal to reach a target state')
2085
+ .argument('<proposalId>', 'Proposal ID')
2086
+ .option('--target <state>', 'Target state (Active, Canceled, Defeated, Succeeded, Queued, Expired, Executed)', 'Executed')
2087
+ .option('--timeout <seconds>', 'Timeout in seconds', '300')
2088
+ .option('--interval <seconds>', 'Poll interval in seconds', '5')
2089
+ .option('--subdao <address>', 'SubDAO address')
2090
+ .action(async (proposalId, opts) => {
2091
+ try {
2092
+ const { ethers } = require('ethers');
2093
+ const { resolveGovContext } = require('../utils/gov-context');
2094
+ const provider = new ethers.JsonRpcProvider(process.env.RPC_URL || 'https://base-sepolia.publicnode.com');
2095
+ const ctx = await resolveGovContext({ govOpt: null, subdaoOpt: opts.subdao, provider });
2096
+ const GovABI = require('../utils/artifacts').resolveArtifact('contracts/cloneable/PromptGovernorCloneable.sol/PromptGovernorCloneable.json').abi;
2097
+ const gov = new ethers.Contract(ctx.governor, GovABI, provider);
2098
+
2099
+ const states = ['Pending', 'Active', 'Canceled', 'Defeated', 'Succeeded', 'Queued', 'Expired', 'Executed'];
2100
+ const targetState = String(opts.target).charAt(0).toUpperCase() + String(opts.target).slice(1).toLowerCase();
2101
+ const targetIdx = states.indexOf(targetState);
2102
+ if (targetIdx === -1) throw new Error(`Invalid target state: ${targetState}. Valid: ${states.join(', ')}`);
2103
+
2104
+ const pid = BigInt(proposalId);
2105
+ const timeout = Number(opts.timeout) * 1000;
2106
+ const interval = Number(opts.interval) * 1000;
2107
+ const start = Date.now();
2108
+
2109
+ console.log(`⏳ Waiting for proposal ${proposalId} to reach ${targetState}...`);
2110
+
2111
+ while (true) {
2112
+ if (Date.now() - start > timeout) {
2113
+ throw new Error(`Timeout waiting for state ${targetState}`);
2114
+ }
2115
+
2116
+ let currentStateIdx;
2117
+ try {
2118
+ currentStateIdx = Number(await gov.state(pid));
2119
+ } catch (e) {
2120
+ // Ignore transient RPC errors
2121
+ await new Promise(r => setTimeout(r, interval));
2122
+ continue;
2123
+ }
2124
+
2125
+ const currentState = states[currentStateIdx] || 'Unknown';
2126
+
2127
+ // Success?
2128
+ if (currentStateIdx === targetIdx) {
2129
+ console.log(`\n✅ Proposal reached target state: ${currentState}`);
2130
+ return;
2131
+ }
2132
+
2133
+ // Failure check (terminal states)
2134
+ if (targetIdx > currentStateIdx) {
2135
+ if (currentState === 'Canceled' || currentState === 'Defeated' || currentState === 'Expired') {
2136
+ throw new Error(`Proposal reached terminal state ${currentState} before ${targetState}`);
2137
+ }
2138
+ }
2139
+
2140
+ process.stdout.write(`\r Current state: ${currentState} (${Math.round((timeout - (Date.now() - start))/1000)}s remaining) `);
2141
+
2142
+ await new Promise(r => setTimeout(r, interval));
2143
+ }
2144
+
2145
+ } catch (e) {
2146
+ console.error(`\n❌ Wait failed: ${e.message}`);
2068
2147
  process.exit(1);
2069
2148
  }
2070
2149
  })
2071
2150
  );
2151
+
2072
2152
  program.addCommand(governanceCommand);
2073
2153
  }
2074
2154
 
@@ -1311,6 +1311,15 @@ function register(program) {
1311
1311
  { exec: !!options.exec, libraryId: options.libraryId || 'main', ctx: { govOpt: ctx.governor || undefined, subdaoOpt: ctx.subdao || undefined, timelockOpt: ctx.timelock || undefined } }
1312
1312
  );
1313
1313
  console.log(`🆔 Proposal ID: ${proposalId}`);
1314
+ // Print next steps hints via suggestions system
1315
+ printSuggestions('library:push', {
1316
+ manifestCID,
1317
+ proposalId: proposalId.toString(),
1318
+ subdao: ctx.subdao || options.subdao || null,
1319
+ exec: !!options.exec,
1320
+ route: 'governance',
1321
+ manifestPath: manifestPath
1322
+ });
1314
1323
  } catch (error) {
1315
1324
  const { handleCLIError } = require('../utils/error-handler');
1316
1325
  handleCLIError('library:propose', error, { context: { manifest: manifestPath, subdao: options.subdao, gov: options.gov } });