nebula-treasury 0.1.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.
Files changed (53) hide show
  1. package/README.md +39 -0
  2. package/bin/nebula +11 -0
  3. package/package.json +65 -0
  4. package/src/commands/_agents.ts +14 -0
  5. package/src/commands/_unlock.ts +66 -0
  6. package/src/commands/chat-telegram.ts +398 -0
  7. package/src/commands/chat.tsx +1293 -0
  8. package/src/commands/drain.ts +90 -0
  9. package/src/commands/gateway-logs.ts +49 -0
  10. package/src/commands/gateway-run.ts +42 -0
  11. package/src/commands/gateway-start.ts +216 -0
  12. package/src/commands/gateway-status.ts +90 -0
  13. package/src/commands/gateway-stop.ts +133 -0
  14. package/src/commands/gateway.ts +101 -0
  15. package/src/commands/identity.ts +178 -0
  16. package/src/commands/init/cost.ts +40 -0
  17. package/src/commands/init/funding-gate.ts +64 -0
  18. package/src/commands/init/model-picker.ts +25 -0
  19. package/src/commands/init/operator-picker.ts +233 -0
  20. package/src/commands/init/telegram-step.ts +245 -0
  21. package/src/commands/init/wizard-state.ts +94 -0
  22. package/src/commands/init.ts +439 -0
  23. package/src/commands/logs.ts +37 -0
  24. package/src/commands/model.ts +48 -0
  25. package/src/commands/pairing-approve.ts +65 -0
  26. package/src/commands/pairing-clear.ts +39 -0
  27. package/src/commands/pairing-list.ts +55 -0
  28. package/src/commands/pairing-revoke.ts +49 -0
  29. package/src/commands/pairing.ts +81 -0
  30. package/src/commands/status.ts +44 -0
  31. package/src/commands/telegram-remove.ts +62 -0
  32. package/src/commands/telegram-setup.ts +64 -0
  33. package/src/commands/telegram-status.ts +87 -0
  34. package/src/commands/telegram.ts +44 -0
  35. package/src/config/load.ts +35 -0
  36. package/src/config/render.ts +99 -0
  37. package/src/index.ts +153 -0
  38. package/src/ui/app.tsx +673 -0
  39. package/src/ui/approval-summary.ts +32 -0
  40. package/src/ui/markdown-parse.ts +219 -0
  41. package/src/ui/markdown.tsx +37 -0
  42. package/src/ui/state.ts +181 -0
  43. package/src/util/bootstrap-mode.ts +25 -0
  44. package/src/util/bootstrap-progress-box.ts +378 -0
  45. package/src/util/cli-version.ts +28 -0
  46. package/src/util/format.ts +11 -0
  47. package/src/util/gateway-spawn.ts +125 -0
  48. package/src/util/gateway-version.ts +154 -0
  49. package/src/util/github-releases.ts +79 -0
  50. package/src/util/profile-key.ts +25 -0
  51. package/src/util/ref-resolver.ts +55 -0
  52. package/src/util/silence-console.ts +40 -0
  53. package/src/util/telegram-secrets.ts +218 -0
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Hermes-aligned Telegram setup wizard step. Shared by `nebula telegram setup`
3
+ * (standalone) and the optional Phase E in `nebula init` (right after Phase D
4
+ * summary, reusing the in-flight operator wallet so we don't prompt Touch ID
5
+ * twice).
6
+ *
7
+ * Flow (matches `~/.hermes/hermes-agent/hermes_cli/{setup.py:1720, gateway.py:1939}`):
8
+ * 1. Bot token (password input + `getMe` probe).
9
+ * 2. Auth-mode select: pair (default) or allowlist.
10
+ * 3. Allowlist branch: text prompt for IDs + @userinfobot hint.
11
+ * 4. Encrypt + save secrets to `~/.nebula/agents/<id>/telegram-secrets.encrypted`.
12
+ * 5. Merge `'telegram'` into config.plugins; rewrite `~/.nebula/config.ts`.
13
+ *
14
+ * Caller frames its own intro/outro. This helper is content-only.
15
+ */
16
+ import { cancel, confirm, isCancel, note, password, select, spinner, text } from '@clack/prompts'
17
+ import {
18
+ type NebulaConfig,
19
+ type NebulaNetwork,
20
+ type NebulaPlugin,
21
+ OPERATOR_BLOB_SCOPES,
22
+ type OperatorSigner,
23
+ agentPaths,
24
+ deriveBlobKey,
25
+ } from 'nebula-ai-core'
26
+ import { type Address, type Hex, bytesToHex } from 'viem'
27
+ import { writeConfigTs } from '../../config/render'
28
+ import {
29
+ fetchBotInfo,
30
+ looksLikeBotToken,
31
+ parseAllowedUserIds,
32
+ saveTelegramSecrets,
33
+ telegramSecretsExist,
34
+ } from '../../util/telegram-secrets'
35
+
36
+ /**
37
+ * Resolve the runtime plugin list when telegram is configured. Auto-includes
38
+ * `'telegram'` so the listener is registered; otherwise `build-runtime.ts`
39
+ * would gate it on `pluginNames.includes('telegram')` and skip registration.
40
+ * Default base: `['system', 'comms', 'onchain']`. Idempotent.
41
+ */
42
+ export function resolveHandoffPlugins(
43
+ caller: NebulaPlugin[] | undefined,
44
+ shipsTelegramSecrets: boolean,
45
+ ): NebulaPlugin[] {
46
+ const base = caller ?? (['system', 'comms', 'onchain'] satisfies NebulaPlugin[])
47
+ if (!shipsTelegramSecrets) return base
48
+ if (base.includes('telegram')) return base
49
+ return [...base, 'telegram']
50
+ }
51
+
52
+ export type TelegramAuthMode = 'pair' | 'allowlist'
53
+
54
+ export interface TelegramStepOpts {
55
+ /** Already-unlocked operator wallet. Caller is responsible for closing it. */
56
+ signer: OperatorSigner
57
+ agentId: string
58
+ agentAddress: Address
59
+ configPath: string
60
+ config: NebulaConfig
61
+ network: NebulaNetwork
62
+ /**
63
+ * If true, the helper is allowed to ask whether to overwrite an existing
64
+ * blob via `confirm`. Default true. Set false for fully non-interactive
65
+ * test paths.
66
+ */
67
+ allowOverwrite?: boolean
68
+ /**
69
+ * v0.24.4: when true, do NOT write the config file from inside this step —
70
+ * caller (init.ts) builds the final cfg with `'telegram'` in plugins and
71
+ * writes once. Avoids the partial-write hazard where Phase E runs before
72
+ * the init's main config build and the intermediate write has incomplete
73
+ * identity/sandbox fields. Standalone `nebula telegram setup` keeps the
74
+ * default false so it still rewrites the config.
75
+ */
76
+ skipConfigWrite?: boolean
77
+ }
78
+
79
+ export interface TelegramStepResult {
80
+ configured: boolean
81
+ /** Set when `configured: true`. */
82
+ botUsername?: string
83
+ modeUsed?: TelegramAuthMode
84
+ allowedUserIds?: number[]
85
+ /** Set when configured aborted by user (cancel / no-overwrite). */
86
+ cancelled?: boolean
87
+ /**
88
+ * v0.24.3: derived TELEGRAM scope key as 0x-prefixed hex. Caller stashes
89
+ * this in `.operator-session` so the daemon auto-spawns without re-prompting
90
+ * Touch ID. Hex (not Buffer) to match `OperatorSessionKeys`' on-disk shape.
91
+ */
92
+ telegramScopeKeyHex?: Hex
93
+ }
94
+
95
+ const PAIR_OPTION_LABEL =
96
+ 'Pair (recommended) — unknown DM users get an 8-char code; you approve via CLI'
97
+ const ALLOW_OPTION_LABEL =
98
+ 'Allowlist — only listed numeric Telegram IDs can DM the bot (find yours via @userinfobot)'
99
+
100
+ export async function runTelegramStep(opts: TelegramStepOpts): Promise<TelegramStepResult> {
101
+ if (telegramSecretsExist(opts.agentId)) {
102
+ if (opts.allowOverwrite === false) {
103
+ return { configured: false, cancelled: true }
104
+ }
105
+ const overwrite = await confirm({
106
+ message:
107
+ 'Encrypted telegram-secrets blob already exists for this agent. Overwrite with new settings?',
108
+ initialValue: false,
109
+ })
110
+ if (isCancel(overwrite) || overwrite !== true) {
111
+ return { configured: false, cancelled: true }
112
+ }
113
+ }
114
+
115
+ const tokenRaw = (await password({
116
+ message: 'Bot token from @BotFather',
117
+ validate: v => {
118
+ if (!v) return 'Required.'
119
+ if (!looksLikeBotToken(v))
120
+ return 'Looks malformed. Expected `<id>:<secret>` from @BotFather, e.g. 1234567890:AABBCC...'
121
+ return undefined
122
+ },
123
+ })) as string | symbol
124
+ if (isCancel(tokenRaw)) {
125
+ return { configured: false, cancelled: true }
126
+ }
127
+ const botToken = (tokenRaw as string).trim()
128
+
129
+ const sValidate = spinner()
130
+ sValidate.start('Validating token via api.telegram.org/getMe')
131
+ let botInfo: Awaited<ReturnType<typeof fetchBotInfo>>
132
+ try {
133
+ botInfo = await fetchBotInfo(botToken)
134
+ sValidate.stop(`bot ok: @${botInfo.username} (id ${botInfo.id})`)
135
+ } catch (e) {
136
+ sValidate.stop(`token rejected: ${(e as Error).message.slice(0, 200)}`)
137
+ cancel('Bad token. Re-issue via /token in @BotFather and re-run setup.')
138
+ return { configured: false, cancelled: true }
139
+ }
140
+
141
+ const modeChoice = await select({
142
+ message: 'How should unauthorized DMs to the bot be handled?',
143
+ options: [
144
+ { value: 'pair' as TelegramAuthMode, label: PAIR_OPTION_LABEL },
145
+ { value: 'allowlist' as TelegramAuthMode, label: ALLOW_OPTION_LABEL },
146
+ ],
147
+ initialValue: 'pair' as TelegramAuthMode,
148
+ })
149
+ if (isCancel(modeChoice)) {
150
+ return { configured: false, cancelled: true }
151
+ }
152
+ const mode = modeChoice as TelegramAuthMode
153
+
154
+ let allowedUserIds: number[] = []
155
+ if (mode === 'allowlist') {
156
+ const allowedRaw = (await text({
157
+ message: 'Allowed Telegram user IDs (comma-separated)',
158
+ placeholder: '123456789, 987654321',
159
+ defaultValue: '',
160
+ validate: v => {
161
+ if (!v) return 'At least one numeric id required (or pick Pair mode instead).'
162
+ const parsed = parseAllowedUserIds(v)
163
+ if (!parsed.ok) return parsed.reason
164
+ if (parsed.ids.length === 0) return 'At least one numeric id required.'
165
+ return undefined
166
+ },
167
+ })) as string | symbol
168
+ if (isCancel(allowedRaw)) {
169
+ return { configured: false, cancelled: true }
170
+ }
171
+ const parsed = parseAllowedUserIds(typeof allowedRaw === 'string' ? allowedRaw : '')
172
+ if (!parsed.ok || parsed.ids.length === 0) {
173
+ cancel(`bad allowed list: ${parsed.ok ? 'empty' : parsed.reason}`)
174
+ return { configured: false, cancelled: true }
175
+ }
176
+ allowedUserIds = parsed.ids
177
+ note(
178
+ `Approved on day one: ${allowedUserIds.join(', ')}\nThese users can DM @${botInfo.username} immediately. Anyone else still falls into pairing.`,
179
+ 'allowlist',
180
+ )
181
+ } else {
182
+ note(
183
+ `Default-deny is on: any unknown user who DMs @${botInfo.username}\nwill receive a one-time pairing code. Approve them out-of-band:\n nebula pairing approve telegram <CODE>\nTo skip pairing for yourself, re-run setup, pick Allowlist, and paste your numeric id\n(get it from @userinfobot).`,
184
+ 'pairing mode',
185
+ )
186
+ }
187
+
188
+ // v0.24.3: derive TELEGRAM key explicitly so we can both pass it as
189
+ // `precomputedKey` (skip the redundant sign inside encryptOperatorBlob)
190
+ // AND return it to init.ts to stash in `.operator-session`.
191
+ const sDerive = spinner()
192
+ sDerive.start('Deriving TELEGRAM scope key')
193
+ let telegramScopeKey: Buffer
194
+ try {
195
+ telegramScopeKey = await deriveBlobKey(
196
+ opts.signer,
197
+ opts.agentAddress,
198
+ OPERATOR_BLOB_SCOPES.TELEGRAM,
199
+ )
200
+ sDerive.stop('TELEGRAM scope key derived')
201
+ } catch (e) {
202
+ sDerive.stop(`TELEGRAM scope derive failed: ${(e as Error).message.slice(0, 200)}`)
203
+ return { configured: false, cancelled: true }
204
+ }
205
+
206
+ const sSave = spinner()
207
+ sSave.start('Encrypting + saving telegram secrets locally')
208
+ try {
209
+ await saveTelegramSecrets({
210
+ signer: opts.signer,
211
+ agentAddress: opts.agentAddress,
212
+ agentId: opts.agentId,
213
+ plaintext: {
214
+ botToken,
215
+ botUsername: botInfo.username,
216
+ botId: botInfo.id,
217
+ allowedUserIds,
218
+ },
219
+ precomputedKey: telegramScopeKey,
220
+ })
221
+ sSave.stop(`saved → ${agentPaths.agent(opts.agentId).dir}/telegram-secrets.encrypted`)
222
+ } catch (e) {
223
+ sSave.stop(`save failed: ${(e as Error).message.slice(0, 200)}`)
224
+ return { configured: false, cancelled: true }
225
+ }
226
+
227
+ // v0.24.4: when caller asks (init.ts), skip the config rewrite — caller will
228
+ // build the final cfg with `'telegram'` in plugins and write once. Avoids the
229
+ // partial-write hazard where Phase E runs before init's main config build.
230
+ if (!opts.skipConfigWrite) {
231
+ const plugins = resolveHandoffPlugins(opts.config.plugins, true)
232
+ if (plugins.length !== (opts.config.plugins ?? []).length) {
233
+ const updated = { ...opts.config, plugins }
234
+ await writeConfigTs(opts.configPath, updated)
235
+ }
236
+ }
237
+
238
+ return {
239
+ configured: true,
240
+ botUsername: botInfo.username,
241
+ modeUsed: mode,
242
+ allowedUserIds,
243
+ telegramScopeKeyHex: bytesToHex(telegramScopeKey),
244
+ }
245
+ }
@@ -0,0 +1,94 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { readFile, writeFile } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+
5
+ /**
6
+ * Pattern B resumable-init state file (Apr 24 2026 session design).
7
+ *
8
+ * Lives at `<agentDir>/.nebula-init-state.json` and tracks which steps in
9
+ * Phase C of the wizard completed. Written incrementally. If init crashes
10
+ * or the user aborts mid-flow, a subsequent `nebula init` (or `--resume`)
11
+ * can pick up from the first incomplete step instead of re-minting.
12
+ */
13
+ export interface WizardState {
14
+ version: 1
15
+ agentAddress: `0x${string}`
16
+ network: 'mantle-mainnet' | 'mantle-testnet'
17
+ steps: {
18
+ keystoreSaved: boolean
19
+ mintedTokenId: string | null
20
+ mintedContract: string | null
21
+ mintTx: string | null
22
+ agentFundedTx: string | null
23
+ keystorePersistedTx: string | null
24
+ keystoreRootHash: string | null
25
+ ledgerOpenedTx: boolean // broker.addLedger returns void
26
+ subnameClaimedTx: string | null
27
+ textRecordsSetTx: string | null
28
+ /** Phase 11: Mantle Sandbox lifecycle. Set during sandbox-deploy branch. */
29
+ sandboxId: string | null
30
+ sandboxEndpoint: string | null
31
+ }
32
+ lastError: string | null
33
+ updatedAt: string
34
+ }
35
+
36
+ export const WIZARD_STATE_FILENAME = '.nebula-init-state.json'
37
+
38
+ export function wizardStatePath(agentDir: string): string {
39
+ return join(agentDir, WIZARD_STATE_FILENAME)
40
+ }
41
+
42
+ export function initialWizardState(
43
+ agentAddress: `0x${string}`,
44
+ network: 'mantle-mainnet' | 'mantle-testnet',
45
+ ): WizardState {
46
+ return {
47
+ version: 1,
48
+ agentAddress,
49
+ network,
50
+ steps: {
51
+ keystoreSaved: false,
52
+ mintedTokenId: null,
53
+ mintedContract: null,
54
+ mintTx: null,
55
+ agentFundedTx: null,
56
+ keystorePersistedTx: null,
57
+ keystoreRootHash: null,
58
+ ledgerOpenedTx: false,
59
+ subnameClaimedTx: null,
60
+ textRecordsSetTx: null,
61
+ sandboxId: null,
62
+ sandboxEndpoint: null,
63
+ },
64
+ lastError: null,
65
+ updatedAt: new Date().toISOString(),
66
+ }
67
+ }
68
+
69
+ export async function readWizardState(agentDir: string): Promise<WizardState | null> {
70
+ const path = wizardStatePath(agentDir)
71
+ if (!existsSync(path)) return null
72
+ try {
73
+ const raw = await readFile(path, 'utf8')
74
+ return JSON.parse(raw) as WizardState
75
+ } catch {
76
+ return null
77
+ }
78
+ }
79
+
80
+ export async function writeWizardState(agentDir: string, state: WizardState): Promise<void> {
81
+ state.updatedAt = new Date().toISOString()
82
+ await writeFile(wizardStatePath(agentDir), JSON.stringify(state, null, 2), 'utf8')
83
+ }
84
+
85
+ export async function updateWizardState(
86
+ agentDir: string,
87
+ patch: (draft: WizardState) => void,
88
+ ): Promise<WizardState> {
89
+ const current = (await readWizardState(agentDir)) ?? null
90
+ if (!current) throw new Error(`updateWizardState: no state at ${agentDir}`)
91
+ patch(current)
92
+ await writeWizardState(agentDir, current)
93
+ return current
94
+ }