@promus/cli 0.24.17

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 (96) hide show
  1. package/README.md +18 -0
  2. package/bin/promus +33 -0
  3. package/package.json +51 -0
  4. package/src/commands/_agents.ts +14 -0
  5. package/src/commands/_inft-ref.ts +43 -0
  6. package/src/commands/_unlock.ts +74 -0
  7. package/src/commands/admin-autotopup-tick.ts +73 -0
  8. package/src/commands/admin.test.ts +34 -0
  9. package/src/commands/admin.ts +32 -0
  10. package/src/commands/balance.test.ts +10 -0
  11. package/src/commands/balance.ts +112 -0
  12. package/src/commands/chat-sandbox.tsx +520 -0
  13. package/src/commands/chat-telegram.ts +398 -0
  14. package/src/commands/chat.tsx +1916 -0
  15. package/src/commands/deploy.ts +204 -0
  16. package/src/commands/drain.ts +90 -0
  17. package/src/commands/gateway-logs.ts +47 -0
  18. package/src/commands/gateway-run.ts +54 -0
  19. package/src/commands/gateway-start.ts +218 -0
  20. package/src/commands/gateway-status.ts +88 -0
  21. package/src/commands/gateway-stop.ts +133 -0
  22. package/src/commands/gateway.ts +101 -0
  23. package/src/commands/init/cost.test.ts +169 -0
  24. package/src/commands/init/cost.ts +154 -0
  25. package/src/commands/init/funding-gate.ts +67 -0
  26. package/src/commands/init/model-picker.ts +81 -0
  27. package/src/commands/init/operator-picker.ts +263 -0
  28. package/src/commands/init/resume.ts +136 -0
  29. package/src/commands/init/sandbox-provision.test.ts +497 -0
  30. package/src/commands/init/sandbox-provision.ts +1177 -0
  31. package/src/commands/init/telegram-step.ts +229 -0
  32. package/src/commands/init/wizard-state.ts +95 -0
  33. package/src/commands/init.ts +612 -0
  34. package/src/commands/inspect.ts +529 -0
  35. package/src/commands/ledger.ts +176 -0
  36. package/src/commands/logs.ts +86 -0
  37. package/src/commands/migrate-keystore.ts +155 -0
  38. package/src/commands/model.ts +48 -0
  39. package/src/commands/pairing-approve.ts +114 -0
  40. package/src/commands/pairing-clear.ts +42 -0
  41. package/src/commands/pairing-list.ts +58 -0
  42. package/src/commands/pairing-revoke.ts +52 -0
  43. package/src/commands/pairing.test.ts +88 -0
  44. package/src/commands/pairing.ts +81 -0
  45. package/src/commands/pause.ts +99 -0
  46. package/src/commands/profile.ts +184 -0
  47. package/src/commands/restore.ts +221 -0
  48. package/src/commands/resume.ts +181 -0
  49. package/src/commands/status.ts +119 -0
  50. package/src/commands/sync.ts +147 -0
  51. package/src/commands/telegram-remove.ts +65 -0
  52. package/src/commands/telegram-setup.ts +74 -0
  53. package/src/commands/telegram-status.ts +89 -0
  54. package/src/commands/telegram.test.ts +50 -0
  55. package/src/commands/telegram.ts +44 -0
  56. package/src/commands/topup.ts +303 -0
  57. package/src/commands/transfer.test.ts +111 -0
  58. package/src/commands/transfer.ts +520 -0
  59. package/src/commands/upgrade.test.ts +137 -0
  60. package/src/commands/upgrade.ts +690 -0
  61. package/src/config/load.ts +35 -0
  62. package/src/config/render.test.ts +96 -0
  63. package/src/config/render.ts +110 -0
  64. package/src/index.ts +378 -0
  65. package/src/sandbox/client.test.ts +251 -0
  66. package/src/sandbox/client.ts +424 -0
  67. package/src/ui/app.tsx +677 -0
  68. package/src/ui/approval-summary.test.ts +154 -0
  69. package/src/ui/approval-summary.ts +34 -0
  70. package/src/ui/markdown-parse.ts +219 -0
  71. package/src/ui/markdown.test.ts +146 -0
  72. package/src/ui/markdown.tsx +37 -0
  73. package/src/ui/state.test.ts +74 -0
  74. package/src/ui/state.ts +198 -0
  75. package/src/util/bootstrap-mode.test.ts +40 -0
  76. package/src/util/bootstrap-mode.ts +25 -0
  77. package/src/util/bootstrap-progress-box.test.ts +190 -0
  78. package/src/util/bootstrap-progress-box.ts +378 -0
  79. package/src/util/brain-secrets.ts +96 -0
  80. package/src/util/cli-version.ts +28 -0
  81. package/src/util/format.test.ts +16 -0
  82. package/src/util/format.ts +11 -0
  83. package/src/util/gateway-spawn.test.ts +86 -0
  84. package/src/util/gateway-spawn.ts +128 -0
  85. package/src/util/gateway-version.test.ts +113 -0
  86. package/src/util/gateway-version.ts +154 -0
  87. package/src/util/github-releases.test.ts +116 -0
  88. package/src/util/github-releases.ts +79 -0
  89. package/src/util/profile-key.test.ts +60 -0
  90. package/src/util/profile-key.ts +25 -0
  91. package/src/util/ref-resolver.test.ts +77 -0
  92. package/src/util/ref-resolver.ts +55 -0
  93. package/src/util/silence-console.test.ts +53 -0
  94. package/src/util/silence-console.ts +40 -0
  95. package/src/util/telegram-secrets.test.ts +227 -0
  96. package/src/util/telegram-secrets.ts +223 -0
@@ -0,0 +1,81 @@
1
+ /**
2
+ * `promus pairing <subcommand>` — argv dispatcher for the DM pairing flow.
3
+ *
4
+ * Subcommands:
5
+ * list show pending codes + approved users
6
+ * approve <platform> <code> approve a pairing code (case-insensitive)
7
+ * revoke <platform> <userId> revoke an approved user
8
+ * clear-pending [platform] drop all pending codes
9
+ *
10
+ * Platform is `telegram` for Phase 12. Future platforms (discord, slack) will
11
+ * reuse the same command surface.
12
+ */
13
+
14
+ export interface PairingArgs {
15
+ sub: 'list' | 'approve' | 'revoke' | 'clear-pending'
16
+ platform?: string
17
+ code?: string
18
+ userId?: string
19
+ yes?: boolean
20
+ }
21
+
22
+ const VALID_SUBS = ['list', 'approve', 'revoke', 'clear-pending'] as const
23
+
24
+ export type PairingParseResult = PairingArgs | { error: string }
25
+
26
+ export function parsePairingArgs(argv: string[]): PairingParseResult {
27
+ const sub = argv[0]
28
+ if (!sub) {
29
+ return {
30
+ error:
31
+ 'usage: promus pairing <list | approve <platform> <code> | revoke <platform> <userId> | clear-pending [platform]>',
32
+ }
33
+ }
34
+ if (!(VALID_SUBS as readonly string[]).includes(sub)) {
35
+ return { error: `unknown subcommand '${sub}' (expected: ${VALID_SUBS.join(' | ')})` }
36
+ }
37
+ const positional = argv.slice(1).filter(a => !a.startsWith('-'))
38
+ const yes = argv.includes('--yes') || argv.includes('-y')
39
+
40
+ if (sub === 'approve') {
41
+ if (positional.length < 2) {
42
+ return { error: 'usage: promus pairing approve <platform> <code>' }
43
+ }
44
+ return { sub: 'approve', platform: positional[0], code: positional[1], yes }
45
+ }
46
+ if (sub === 'revoke') {
47
+ if (positional.length < 2) {
48
+ return { error: 'usage: promus pairing revoke <platform> <userId>' }
49
+ }
50
+ return { sub: 'revoke', platform: positional[0], userId: positional[1], yes }
51
+ }
52
+ if (sub === 'clear-pending') {
53
+ return { sub: 'clear-pending', platform: positional[0], yes }
54
+ }
55
+ return { sub: 'list', platform: positional[0], yes }
56
+ }
57
+
58
+ export async function runPairing(args: PairingArgs): Promise<void> {
59
+ switch (args.sub) {
60
+ case 'list': {
61
+ const { runPairingList } = await import('./pairing-list')
62
+ await runPairingList({ platform: args.platform })
63
+ return
64
+ }
65
+ case 'approve': {
66
+ const { runPairingApprove } = await import('./pairing-approve')
67
+ await runPairingApprove({ platform: args.platform!, code: args.code! })
68
+ return
69
+ }
70
+ case 'revoke': {
71
+ const { runPairingRevoke } = await import('./pairing-revoke')
72
+ await runPairingRevoke({ platform: args.platform!, userId: args.userId!, yes: args.yes })
73
+ return
74
+ }
75
+ case 'clear-pending': {
76
+ const { runPairingClear } = await import('./pairing-clear')
77
+ await runPairingClear({ platform: args.platform, yes: args.yes })
78
+ return
79
+ }
80
+ }
81
+ }
@@ -0,0 +1,99 @@
1
+ import { cancel, confirm, intro, isCancel, note, outro, spinner } from '@clack/prompts'
2
+ import { SANDBOX_PROVIDER_URL_GALILEO, SandboxProviderClient } from '@promus/core'
3
+ import { findAndLoadConfig } from '../config/load'
4
+ import { loadOrPickOperatorSigner } from './init/operator-picker'
5
+ import { ensureSandboxArchived } from './init/sandbox-provision'
6
+
7
+ interface PauseOpts {
8
+ yes?: boolean
9
+ }
10
+
11
+ /**
12
+ * `promus pause`: archive a started sandbox to stop the runtime burn.
13
+ *
14
+ * Use during dev gaps to extend deposit runway. Sandbox UUID + endpoint
15
+ * preserved; resume via `promus resume` (~2-5 min cold restore).
16
+ *
17
+ * Does NOT require operator-keystore unlock. Only needs the operator wallet
18
+ * to sign the provider HTTP request (action=archive). Fast, low-friction.
19
+ */
20
+ export async function runPause(opts: PauseOpts = {}): Promise<void> {
21
+ intro('promus pause')
22
+
23
+ const loaded = await findAndLoadConfig()
24
+ if (!loaded) {
25
+ cancel('No promus.config.ts found.')
26
+ return
27
+ }
28
+ const { config } = loaded
29
+ if (config.deployTarget !== 'sandbox' || !config.sandbox?.id) {
30
+ cancel(
31
+ `Agent is not deployed to a sandbox. (deployTarget=${config.deployTarget ?? 'local'}). Nothing to pause.`,
32
+ )
33
+ return
34
+ }
35
+
36
+ const sandboxId = config.sandbox.id
37
+ const operator = await loadOrPickOperatorSigner({
38
+ network: config.network,
39
+ hint: config.operator,
40
+ })
41
+ if (!operator) {
42
+ cancel('No operator wallet available; cannot sign archive request.')
43
+ return
44
+ }
45
+
46
+ if (!opts.yes) {
47
+ const ok = await confirm({
48
+ message: `Pause sandbox ${sandboxId.slice(0, 8)}? Burn stops; resume with \`promus resume\`.`,
49
+ initialValue: true,
50
+ })
51
+ if (isCancel(ok) || !ok) {
52
+ cancel('Aborted.')
53
+ await operator.close?.()
54
+ return
55
+ }
56
+ }
57
+
58
+ const operatorAccount = await operator.account()
59
+ const provider = new SandboxProviderClient({
60
+ endpoint: SANDBOX_PROVIDER_URL_GALILEO,
61
+ operator: operatorAccount,
62
+ })
63
+
64
+ const sBox = spinner()
65
+ sBox.start('Archiving sandbox')
66
+ try {
67
+ const result = await ensureSandboxArchived(provider, sandboxId, {
68
+ onProgress: msg => sBox.message(msg),
69
+ })
70
+ if (result.alreadyArchived) {
71
+ sBox.stop(`sandbox ${sandboxId.slice(0, 8)} already archived (no-op)`)
72
+ } else {
73
+ sBox.stop(`sandbox ${sandboxId.slice(0, 8)} archived (was ${result.initialState})`)
74
+ }
75
+ outro(
76
+ [
77
+ '',
78
+ ` sandbox ${sandboxId} (preserved)`,
79
+ ` endpoint ${config.sandbox.endpoint} (preserved)`,
80
+ ` state before ${result.initialState}`,
81
+ ' state now archived',
82
+ ' burn stopped',
83
+ '',
84
+ 'To wake: promus resume',
85
+ ].join('\n'),
86
+ )
87
+ } catch (e) {
88
+ sBox.stop(`pause failed: ${(e as Error).message.slice(0, 200)}`)
89
+ note(
90
+ [
91
+ 'The sandbox could not transition to archived.',
92
+ 'Run `promus status` to inspect, or retry. If the underlying state is bad, `promus upgrade --reprovision` is the escape hatch.',
93
+ ].join('\n'),
94
+ 'recoverable',
95
+ )
96
+ } finally {
97
+ await operator.close?.()
98
+ }
99
+ }
@@ -0,0 +1,184 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
2
+ import { cancel, intro, note, outro, spinner } from '@clack/prompts'
3
+ import {
4
+ MemorySyncManager,
5
+ OPERATOR_BLOB_SCOPES,
6
+ agentPaths,
7
+ deriveBlobKey,
8
+ explorerTxUrl,
9
+ fetchAndDecryptKeystore,
10
+ iNFTAgentId,
11
+ } from '@promus/core'
12
+ import type { Address, Hex } from 'viem'
13
+ import { findAndLoadConfig } from '../config/load'
14
+ import { withSilencedConsole } from '../util/silence-console'
15
+ import { loadOrPickOperatorSigner } from './init/operator-picker'
16
+
17
+ /**
18
+ * `promus profile init` — v0.23.0 entry point for the user-partition profile
19
+ * slot. Three things happen:
20
+ *
21
+ * 1. Seed `user/profile.md` on disk if missing (idempotent; never clobbers
22
+ * a non-empty existing file).
23
+ * 2. Derive the operator-scoped PROFILE AES key via one EIP-712 sign.
24
+ * 3a. SANDBOX mode: POST /admin/profile-key (EIP-191-signed) so the daemon
25
+ * picks up the key live + fires a one-shot restore for the slot.
26
+ * 3b. LOCAL mode: trigger a /sync that encrypts profile.md + anchors the
27
+ * PROFILE slot on chain in the same batched updateSlots tx as the
28
+ * other slots.
29
+ *
30
+ * Idempotent: re-running after the first time only re-anchors if profile.md
31
+ * content changed since the last flush.
32
+ */
33
+ export async function runProfileInit(): Promise<void> {
34
+ intro('promus profile init')
35
+
36
+ note(
37
+ [
38
+ 'Legacy command. v0.23.1+ folds profile-key derivation into promus init.',
39
+ 'Run this only if your agent was created before v0.23.1.',
40
+ ].join('\n'),
41
+ 'profile init (legacy)',
42
+ )
43
+
44
+ const loaded = await findAndLoadConfig()
45
+ if (!loaded) {
46
+ cancel('No promus config found. Run `promus init` first.')
47
+ return
48
+ }
49
+ const { config } = loaded
50
+ if (!config.identity.iNFT || !config.identity.agent) {
51
+ cancel('Config has no iNFT or agent. Run `promus init` first.')
52
+ return
53
+ }
54
+
55
+ const network = config.network
56
+ const contractAddress = config.identity.iNFT.contract as Address
57
+ const tokenId = BigInt(config.identity.iNFT.tokenId)
58
+ const agentAddress = config.identity.agent as Address
59
+ const finalAgentId = iNFTAgentId({ contractAddress, tokenId })
60
+ const paths = agentPaths.agent(finalAgentId)
61
+
62
+ // 1. seed profile.md if missing
63
+ const profilePath = `${paths.memoryDir}/user/profile.md`
64
+ await mkdir(`${paths.memoryDir}/user`, { recursive: true })
65
+ let seededNow = false
66
+ try {
67
+ const existing = await readFile(profilePath, 'utf8')
68
+ if (existing.trim().length === 0) throw new Error('empty-file')
69
+ } catch {
70
+ const template =
71
+ '---\nname: profile\ndescription: User profile (operator-scoped, never anchored with agent key).\ntype: user\n---\n# User profile\n\n(empty, fills as we chat)\n'
72
+ await writeFile(profilePath, template, 'utf8')
73
+ seededNow = true
74
+ }
75
+ if (seededNow) console.log(`seeded ${profilePath}`)
76
+
77
+ // 2. derive PROFILE scope key + (sandbox) keystore-decrypt or (local) full sync
78
+ const operator = await loadOrPickOperatorSigner({ network, hint: config.operator })
79
+ if (!operator) {
80
+ cancel('No operator wallet available.')
81
+ return
82
+ }
83
+
84
+ const sUnlock = spinner()
85
+ sUnlock.start('Deriving PROFILE scope key via operator')
86
+ let profileKey: Buffer
87
+ try {
88
+ profileKey = await deriveBlobKey(operator, agentAddress, OPERATOR_BLOB_SCOPES.PROFILE)
89
+ sUnlock.stop('PROFILE key derived')
90
+ } catch (e) {
91
+ sUnlock.stop(`derive failed: ${(e as Error).message.slice(0, 160)}`)
92
+ await operator.close?.()
93
+ return
94
+ }
95
+
96
+ // 3a. SANDBOX path: POST /admin/profile-key
97
+ if (config.deployTarget === 'sandbox' && config.sandbox?.endpoint && config.sandbox.id) {
98
+ const { SandboxClient } = await import('../sandbox/client')
99
+ const operatorAccount = await operator.account()
100
+ const client = new SandboxClient({
101
+ endpoint: config.sandbox.endpoint,
102
+ sandboxId: config.sandbox.id,
103
+ operator: operatorAccount,
104
+ })
105
+ const sShip = spinner()
106
+ sShip.start('Shipping PROFILE key to sandbox /admin/profile-key')
107
+ try {
108
+ const profileScopeKeyHex = `0x${profileKey.toString('hex')}` as `0x${string}`
109
+ const result = await client.setProfileKey(profileScopeKeyHex)
110
+ if (result.ok) {
111
+ sShip.stop('sandbox accepted PROFILE key')
112
+ outro(
113
+ [
114
+ '',
115
+ ' next flush will encrypt profile.md + anchor on chain',
116
+ ' next boot will restore the slot from chain',
117
+ ].join('\n'),
118
+ )
119
+ } else {
120
+ sShip.stop(`sandbox rejected: ${result.reason ?? 'unknown'}`)
121
+ }
122
+ } catch (e) {
123
+ sShip.stop(`shipment failed: ${(e as Error).message.slice(0, 200)}`)
124
+ }
125
+ await operator.close?.()
126
+ return
127
+ }
128
+
129
+ // 3b. LOCAL path: full sync with profileKey injected
130
+ const sUnlock2 = spinner()
131
+ sUnlock2.start('Fetching keystore + decrypting via operator (for local /sync)')
132
+ let agentPrivkey: Hex
133
+ try {
134
+ const decrypted = await withSilencedConsole(() =>
135
+ fetchAndDecryptKeystore({
136
+ network,
137
+ contractAddress,
138
+ tokenId,
139
+ signer: operator,
140
+ agentAddress,
141
+ cachePath: paths.keystore,
142
+ }),
143
+ )
144
+ agentPrivkey = decrypted.privkeyHex
145
+ sUnlock2.stop(`unlocked (source: ${decrypted.source})`)
146
+ } catch (e) {
147
+ sUnlock2.stop(`unlock failed: ${(e as Error).message.slice(0, 160)}`)
148
+ await operator.close?.()
149
+ return
150
+ }
151
+ await operator.close?.()
152
+
153
+ const sFlush = spinner()
154
+ sFlush.start('Encrypting profile.md + anchoring on chain')
155
+ try {
156
+ const res = await withSilencedConsole(async () => {
157
+ const sync = new MemorySyncManager({
158
+ network,
159
+ agentId: finalAgentId,
160
+ agentPrivkey,
161
+ agentAddress,
162
+ contractAddress,
163
+ tokenId,
164
+ profileKey,
165
+ })
166
+ await sync.init()
167
+ return await sync.flushAll()
168
+ })
169
+ if (res.txHash) {
170
+ sFlush.stop(`anchored ${res.changedSlots.length} slot(s)`)
171
+ outro(
172
+ [
173
+ '',
174
+ ` slots: ${res.changedSlots.join(', ')}`,
175
+ ` tx: ${explorerTxUrl(network, res.txHash)}`,
176
+ ].join('\n'),
177
+ )
178
+ } else {
179
+ sFlush.stop('nothing to anchor (profile.md unchanged since last sync)')
180
+ }
181
+ } catch (e) {
182
+ sFlush.stop(`flush failed: ${(e as Error).message.slice(0, 200)}`)
183
+ }
184
+ }
@@ -0,0 +1,221 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises'
2
+ import { cancel, intro, isCancel, note, outro, password, spinner } from '@clack/prompts'
3
+ import {
4
+ type EncryptedKeystore,
5
+ agentPaths,
6
+ decryptKey,
7
+ defineConfig,
8
+ explorerTokenUrl,
9
+ fetchAndDecryptKeystore,
10
+ iNFTAgentId,
11
+ restoreKeystoreFromStorage,
12
+ sniffKeystoreVersion,
13
+ } from '@promus/core'
14
+ import { type Address, bytesToHex } from 'viem'
15
+ import { privateKeyToAccount } from 'viem/accounts'
16
+ import { writeConfigTs } from '../config/render'
17
+ import { type ParsedINFTRef, parseINFTRef } from './_inft-ref'
18
+ import { pickOperatorSigner } from './init/operator-picker'
19
+
20
+ export async function runRestore(opts: { ref: string; cwd?: string }): Promise<void> {
21
+ const configPath = agentPaths.config
22
+
23
+ intro('promus restore')
24
+
25
+ let parsed: ParsedINFTRef
26
+ try {
27
+ parsed = parseINFTRef(opts.ref)
28
+ } catch (e) {
29
+ cancel((e as Error).message)
30
+ return
31
+ }
32
+
33
+ const s1 = spinner()
34
+ s1.start(`Fetching iNFT #${parsed.tokenId} on ${parsed.network}`)
35
+
36
+ let encryptedBytes: Uint8Array
37
+ let operatorAddressOnChain: Address
38
+ try {
39
+ const downloaded = await restoreKeystoreFromStorage({
40
+ network: parsed.network,
41
+ contractAddress: parsed.contract,
42
+ tokenId: parsed.tokenId,
43
+ })
44
+ if (!downloaded) {
45
+ s1.stop('keystore slot is unset or predates storage-backed recovery')
46
+ note(
47
+ [
48
+ 'This iNFT does not have an encrypted keystore uploaded to 0G Storage.',
49
+ 'Either the slot still holds a bootstrap placeholder, or the agent was',
50
+ 'minted before the recovery path was live. If you have a local keystore,',
51
+ 'copy it to ~/.promus/agents/<id>/keystore.json manually.',
52
+ ].join('\n'),
53
+ 'cannot restore',
54
+ )
55
+ return
56
+ }
57
+ encryptedBytes = downloaded.encryptedBytes
58
+ operatorAddressOnChain = downloaded.owner
59
+ s1.stop(`fetched ${encryptedBytes.byteLength} bytes from 0G Storage`)
60
+ } catch (e) {
61
+ s1.stop(`fetch failed: ${(e as Error).message}`)
62
+ return
63
+ }
64
+
65
+ const version = sniffKeystoreVersion(encryptedBytes)
66
+
67
+ const agentId = iNFTAgentId({ contractAddress: parsed.contract, tokenId: parsed.tokenId })
68
+ const paths = agentPaths.agent(agentId)
69
+ let privkeyHex: `0x${string}`
70
+ let agentAddress: Address
71
+
72
+ if (version === 1) {
73
+ note(
74
+ 'Detected legacy v1 (passphrase) keystore. After restore, run `promus migrate-keystore` to upgrade to v2 (operator-wallet-encrypted).',
75
+ 'legacy keystore',
76
+ )
77
+ const pass = await password({
78
+ message: 'Passphrase for the agent keystore',
79
+ validate: v => (v && v.length >= 1 ? undefined : 'Required.'),
80
+ })
81
+ if (isCancel(pass)) {
82
+ cancel('Aborted.')
83
+ return
84
+ }
85
+ try {
86
+ const ks = JSON.parse(new TextDecoder().decode(encryptedBytes)) as EncryptedKeystore
87
+ const privkey = decryptKey(ks, pass)
88
+ privkeyHex = bytesToHex(privkey)
89
+ agentAddress = privateKeyToAccount(privkeyHex).address
90
+ } catch (e) {
91
+ cancel(`decrypt failed: ${(e as Error).message}. Wrong passphrase or corrupted keystore.`)
92
+ return
93
+ }
94
+ await mkdir(paths.dir, { recursive: true })
95
+ await writeFile(paths.keystore, new TextDecoder().decode(encryptedBytes), 'utf8')
96
+ } else if (version === 2) {
97
+ const picked = await pickOperatorSigner({ network: parsed.network })
98
+ if (!picked) return
99
+ const operator = picked.signer
100
+ const pickedAddr = await operator.address()
101
+ if (pickedAddr.toLowerCase() !== operatorAddressOnChain.toLowerCase()) {
102
+ // Hard abort: decrypt is provably impossible from a different wallet,
103
+ // and keeping the WC session open while we ask the user to retype the
104
+ // agent address gives the WC event bus time to fire `chainChanged` /
105
+ // `accountsChanged`, which crashes universal-provider with an uncaught
106
+ // TypeError when the chain isn't in our config. Cancel + disconnect now.
107
+ await operator.close?.()
108
+ cancel(
109
+ [
110
+ 'Operator wallet mismatch.',
111
+ ` iNFT owner: ${operatorAddressOnChain}`,
112
+ ` you connected: ${pickedAddr}`,
113
+ '',
114
+ 'You must connect the same wallet that owns this iNFT, then retry.',
115
+ 'If the iNFT-owning key only exists in your local keystore (e.g.',
116
+ 'macOS Keychain), import it into your mobile wallet first, or pick',
117
+ 'a different operator source on the next run.',
118
+ ].join('\n'),
119
+ )
120
+ return
121
+ }
122
+
123
+ const sUnlock = spinner()
124
+ sUnlock.start('Decrypting keystore via operator wallet signature')
125
+ try {
126
+ // We don't know the agent address yet (it's encoded into the typed data).
127
+ // Best path: read the agent address from the iNFT's text records or
128
+ // attempt decrypt by trying the address derived from a successful
129
+ // decrypt. For MVP we ask the user since restoring on a fresh machine
130
+ // means they can't easily derive it.
131
+ sUnlock.stop('need agent address')
132
+ const agentAddrInput = (await password({
133
+ message: 'Agent EOA address (0x…) — find it on the iNFT subname or your records',
134
+ validate: v => {
135
+ if (!v) return 'Required.'
136
+ if (!/^0x[0-9a-fA-F]{40}$/.test(v)) return 'Must be a 20-byte hex address.'
137
+ return undefined
138
+ },
139
+ })) as string | symbol
140
+ if (isCancel(agentAddrInput)) {
141
+ cancel('Aborted.')
142
+ await operator.close?.()
143
+ return
144
+ }
145
+ agentAddress = agentAddrInput as Address
146
+
147
+ const sDecrypt = spinner()
148
+ sDecrypt.start('Sign typed data + decrypt')
149
+ const decrypted = await fetchAndDecryptKeystore({
150
+ network: parsed.network,
151
+ contractAddress: parsed.contract,
152
+ tokenId: parsed.tokenId,
153
+ signer: operator,
154
+ agentAddress,
155
+ cachePath: paths.keystore,
156
+ })
157
+ privkeyHex = decrypted.privkeyHex
158
+ const derived = privateKeyToAccount(privkeyHex).address
159
+ if (derived.toLowerCase() !== agentAddress.toLowerCase()) {
160
+ sDecrypt.stop('decrypt produced unexpected agent address')
161
+ cancel(
162
+ `Decrypted privkey points to ${derived} but you said ${agentAddress}. Aborting to prevent stale config.`,
163
+ )
164
+ await operator.close?.()
165
+ return
166
+ }
167
+ sDecrypt.stop(`decrypted (source: ${decrypted.source})`)
168
+ } catch (e) {
169
+ cancel(`decrypt failed: ${(e as Error).message}`)
170
+ await operator.close?.()
171
+ return
172
+ }
173
+ await operator.close?.()
174
+ } else {
175
+ cancel(`Unknown keystore version: ${version}. This blob may be corrupted.`)
176
+ return
177
+ }
178
+
179
+ const cfg = defineConfig({
180
+ identity: {
181
+ iNFT: {
182
+ contract: parsed.contract,
183
+ tokenId: parsed.tokenId.toString(),
184
+ network: parsed.network,
185
+ },
186
+ operator: operatorAddressOnChain,
187
+ agent: agentAddress,
188
+ },
189
+ network: parsed.network,
190
+ storage: { network: parsed.network },
191
+ brain: { provider: null, model: null },
192
+ plugins: ['onchain', 'comms', 'system'],
193
+ tools: {},
194
+ imports: { claudeCode: true },
195
+ operator: null,
196
+ })
197
+ await writeConfigTs(configPath, cfg, {
198
+ header: '// Regenerated by `promus restore`. Edit freely; type-safe.',
199
+ subname: null,
200
+ })
201
+
202
+ outro(
203
+ [
204
+ '',
205
+ ` agent id ${agentId}`,
206
+ ` agent EOA ${agentAddress}`,
207
+ ` operator ${operatorAddressOnChain}`,
208
+ ` iNFT #${parsed.tokenId.toString()} at ${parsed.contract}`,
209
+ ` ${explorerTokenUrl(parsed.network, parsed.contract, parsed.tokenId)}`,
210
+ ` config ${configPath}`,
211
+ ` keystore ${paths.keystore}`,
212
+ '',
213
+ 'Next: `promus` to chat, or `promus topup --compute 5` if ledger is dry.',
214
+ version === 1
215
+ ? 'Then: `promus migrate-keystore` to upgrade to v2 (drops the passphrase).'
216
+ : '',
217
+ ]
218
+ .filter(Boolean)
219
+ .join('\n'),
220
+ )
221
+ }