ethagent 4.2.0 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ethagent",
3
- "version": "4.2.0",
3
+ "version": "4.3.0",
4
4
  "description": "Portable Ethereum identity for your AI agent. Its soul, memory, and skills live onchain via ERC-8004 + IPFS and snap back into any session.",
5
5
  "author": { "name": "bairon.dev" },
6
6
  "homepage": "https://github.com/baairon/ethagent",
package/README.md CHANGED
@@ -63,7 +63,7 @@ Using another harness? You can still sync, but only Claude Code does it automati
63
63
  npx ethagent --sync
64
64
  ```
65
65
 
66
- One command syncs it into every harness on this machine — soul, memory, and skills (public and private) — but only when you run it. To back it up so you can restore it anywhere, open `ethagent` and choose **Save Snapshot**.
66
+ One command syncs it into every harness on this machine — soul, memory, and skills (public and private) — but only when you run it. To back it up so you can restore it anywhere, run `ethagent save` (or open `ethagent` and choose **Save Snapshot**); either way you approve the signature in your wallet.
67
67
 
68
68
  ## 🔒 What stays private
69
69
 
@@ -117,7 +117,8 @@ Run with `npx ethagent`:
117
117
 
118
118
  | Command | What it does |
119
119
  | --- | --- |
120
- | `ethagent` | Open the interactive identity manager: create, ENS, custody, snapshots, transfer. |
120
+ | `ethagent` | Open the interactive identity manager: create, ENS, custody, transfer. |
121
+ | `save` | Save an encrypted snapshot: pin to IPFS and rotate the onchain pointer. Opens your browser wallet to approve; add `--no-open` to only print the URL, `--json` for machine output. |
121
122
  | `--sync` | Sync soul, memory, and skills (public and private) into every harness it detects. |
122
123
  | `--sync-list` | List sync adapters and which ones detect in the current environment. |
123
124
  | `--status` | Print a one-line identity summary. |
@@ -3,9 +3,10 @@ name: ethagent
3
3
  description: Point the user at ethagent. ethagent stores an agent's identity onchain via ERC-8004 and syncs its soul, memory, and public skills into the active harness (Claude Code or Codex) on every SessionStart. To manage identity (create, ENS, custody, snapshots, transfer), the user runs `npx ethagent` in a separate terminal.
4
4
  ---
5
5
 
6
- Tell the user to run these in a separate terminal window, not inside this session:
6
+ Most of these run in a separate terminal because they need a wallet or a TTY, but a few are safe to run from inside this session; each bullet says which:
7
7
 
8
- - `npx ethagent` opens the interactive identity manager for anything that needs a wallet signature (create agent, set ENS, switch custody, save snapshot, prepare transfer).
8
+ - `npx ethagent` opens the interactive identity manager for anything that needs a wallet signature (create agent, set ENS, switch custody, prepare transfer).
9
+ - `ethagent save` runs Save Snapshot (encrypt, pin to IPFS, rotate the onchain pointer). **You can run this yourself** as a normal tool call (e.g. `npx ethagent save`): no separate terminal and no TTY, and it will not hang. It prints a localhost wallet URL and opens the browser tab so the user approves the signature and transaction there. You trigger and run it; the user only approves in the wallet. Pass `--no-open` to just print the URL. Only run it when the user specifically asks to save or back up the agent; never run it on your own initiative or as a side effect of other work. It is a no-op (no wallet, no gas) when there are no local changes since the last snapshot.
9
10
  - `npx ethagent --sync` syncs the agent's soul, memory, and skills with every detected harness; soul and memory sync both ways, newest edit wins.
10
11
  - `npx ethagent --sync-list` shows which harnesses ethagent detects on this machine.
11
12
  - `npx ethagent --demo` walks the identity manager with synthetic data, no wallet or network required.
@@ -14,7 +15,7 @@ Tell the user to run these in a separate terminal window, not inside this sessio
14
15
 
15
16
  To rebuild the agent on a new machine, the user runs `npx ethagent`; it restores the identity from an ENS name or ERC-8004 token id, then asks the wallet to sign.
16
17
 
17
- You may run the non-interactive flags yourself when it helps (`--sync`, `--status`, `--sync-list`). Never launch the bare interactive `npx ethagent` or `--demo` from inside a session: they open a full-screen terminal app that needs a TTY and will hang the tool call. Anything that needs a wallet signature, the user always runs themselves.
18
+ You may run the read-only non-interactive commands yourself whenever they help (`--sync`, `--status`, `--sync-list`). `ethagent save` is different: run it only when the user specifically asks you to save or back up the agent, never on your own initiative or as a side effect of other work. When they do ask, you can run it directly: there is no CLI step for the user and no separate terminal needed. It is headless, will not hang, prints a wallet URL, and opens the browser tab where the user approves the signature. You trigger and run the command; you never sign. (It is also a no-op when there are no local changes since the last snapshot.) Never launch the bare interactive `npx ethagent` or `--demo` from inside a session: they open a full-screen terminal app that needs a TTY and will hang the tool call. Anything else that needs a wallet signature (create, ENS, custody, transfer), the user always runs themselves.
18
19
 
19
20
  Where the synced files land:
20
21
 
@@ -35,6 +36,6 @@ The agent's portable identity lives in the ethagent vault and syncs into every h
35
36
  - Voice, standards, operating principles, and boundaries go in the vault `SOUL.md` in the same folder.
36
37
  - The vault files are the source of truth. After editing them, run `npx ethagent --sync` so the change propagates into `~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md`, and any other detected harness.
37
38
  - Do NOT store durable identity in Claude Code's per-project memory (`~/.claude/projects/<slug>/memory/`): the sync treats those files as write-only mirror targets and overwrites them, and they never reach Codex or the encrypted backup. Keep only session- or repo-specific scratch notes there.
38
- - Syncing is not backup. To persist durable changes into the encrypted IPFS snapshot and onchain pointer, the user runs `npx ethagent` and chooses Save Snapshot (needs a wallet signature).
39
+ - Syncing is not backup. To persist durable changes into the encrypted IPFS snapshot and onchain pointer, run `ethagent save` yourself: it pins the encrypted snapshot and rotates the onchain pointer, and the user only approves the signature in the browser wallet.
39
40
 
40
41
  If they ask "what's my agent" or "list my skills" without an identity yet, point them at `npx ethagent` to set one up first.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ethagent",
3
- "version": "4.2.0",
3
+ "version": "4.3.0",
4
4
  "description": "Portable Ethereum identity for your AI agent. Its soul, memory, and skills live onchain via ERC-8004 + IPFS and snap back into any session.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/main.tsx CHANGED
@@ -18,6 +18,7 @@ import { runPreToolGuard } from './pretoolGuard.js'
18
18
  import { runSessionStart } from './sessionStart.js'
19
19
  import { runStatus } from './status.js'
20
20
  import { runResetCommand } from './reset.js'
21
+ import { runSave } from './save.js'
21
22
  import { enableDemoMode, synthDemoConfig } from './demo.js'
22
23
 
23
24
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
@@ -38,6 +39,7 @@ function printHelp(): void {
38
39
  '',
39
40
  'usage:',
40
41
  ' ethagent manage identity',
42
+ ' ethagent save save an encrypted snapshot: pin to IPFS, rotate the onchain pointer (you approve in your wallet)',
41
43
  ' ethagent reset delete local identity, continuity, and secrets',
42
44
  ' ethagent --sync sync soul, memory, and skills to every harness',
43
45
  ' ethagent --sync-list list sync adapters and which ones detect here',
@@ -163,6 +165,8 @@ async function main(): Promise<number> {
163
165
  enableDemoMode()
164
166
  return renderHub(synthDemoConfig())
165
167
  }
168
+ if (argv[0] === 'save') return runSave(argv.slice(1))
169
+ if (flags.has('--save')) return runSave(argv.filter(a => a !== '--save'))
166
170
  if (argv[0] === 'reset') return runResetCommand(argv.slice(1))
167
171
 
168
172
  const unknown = argv.find(a => a.startsWith('-'))
@@ -0,0 +1,162 @@
1
+ import { stdout, stderr } from 'node:process'
2
+ import { loadConfig, saveConfig, type EthagentConfig } from '../storage/config.js'
3
+ import { resolveRegistryForIdentity } from '../identity/registry/registryConfig.js'
4
+ import { continuityVaultStatus, continuityWorkingTreeStatus } from '../identity/continuity/storage/status.js'
5
+ import { listPublishedContinuitySnapshots } from '../identity/continuity/snapshots.js'
6
+ import { resolveValidatedPinataJwt } from '../identity/storage/pinataJwt.js'
7
+ import { runRebackupSigning } from '../identity/manager/continuity/effects.js'
8
+ import type { EffectCallbacks } from '../identity/manager/shared/effects/types.js'
9
+ import { isWalletCancelled } from '../identity/manager/shared/utils.js'
10
+ import type { Step } from '../identity/manager/reducer.js'
11
+ import { openExternalUrl } from '../utils/openExternal.js'
12
+
13
+ // Headless Save Snapshot: encrypt soul/memory/skills, pin to IPFS, and rotate the
14
+ // ERC-8004 onchain pointer. The agent can trigger this; the human still approves the
15
+ // signature and transaction in the browser wallet tab. The whole pipeline is reused
16
+ // from the ink TUI (`runRebackupSigning`) with a print-only callbacks shim.
17
+ //
18
+ // Exit codes: 0 success · 1 no identity / not restored / generic runtime error ·
19
+ // 2 usage (unknown option) · 3 credential or wallet problem the human must resolve
20
+ // (no JWT, invalid JWT, or wallet cancelled/timed out).
21
+
22
+ export type RunSaveDeps = {
23
+ loadConfig: typeof loadConfig
24
+ saveConfig: typeof saveConfig
25
+ resolveValidatedPinataJwt: typeof resolveValidatedPinataJwt
26
+ continuityVaultStatus: typeof continuityVaultStatus
27
+ continuityWorkingTreeStatus: typeof continuityWorkingTreeStatus
28
+ listPublishedContinuitySnapshots: typeof listPublishedContinuitySnapshots
29
+ runRebackupSigning: typeof runRebackupSigning
30
+ openExternalUrl: typeof openExternalUrl
31
+ }
32
+
33
+ const defaultDeps: RunSaveDeps = {
34
+ loadConfig,
35
+ saveConfig,
36
+ resolveValidatedPinataJwt,
37
+ continuityVaultStatus,
38
+ continuityWorkingTreeStatus,
39
+ listPublishedContinuitySnapshots,
40
+ runRebackupSigning,
41
+ openExternalUrl,
42
+ }
43
+
44
+ export async function runSave(args: string[] = [], deps: RunSaveDeps = defaultDeps): Promise<number> {
45
+ const json = args.includes('--json')
46
+ const noOpen = args.includes('--no-open')
47
+ const unknown = args.filter(a => a !== '--json' && a !== '--no-open')
48
+ if (unknown.length > 0) {
49
+ stderr.write(`unknown save option: ${unknown[0]}\nusage: ethagent save [--json] [--no-open]\n`)
50
+ return 2
51
+ }
52
+
53
+ const fail = (code: number, message: string): number => {
54
+ if (json) stdout.write(JSON.stringify({ ok: false, code, error: message }) + '\n')
55
+ else stderr.write(message + '\n')
56
+ return code
57
+ }
58
+
59
+ const config = await deps.loadConfig().catch(() => null)
60
+ if (!config?.identity) {
61
+ return fail(1, 'No agent identity yet. Run `npx ethagent` to create or link one.')
62
+ }
63
+ const activeConfig: EthagentConfig = config
64
+ const identity = config.identity
65
+
66
+ if (!identity.agentId) {
67
+ return fail(1, 'This identity has no agent token ID yet. Create or restore it with `npx ethagent` first.')
68
+ }
69
+
70
+ const registry = resolveRegistryForIdentity(identity, activeConfig)
71
+ if (!registry) {
72
+ return fail(1, 'No agent registry configured for this identity. Run `npx ethagent` to set it up.')
73
+ }
74
+
75
+ const vault = await deps.continuityVaultStatus(identity).catch(() => ({ ready: false }))
76
+ if (!vault.ready) {
77
+ return fail(1, 'Local continuity files are not restored. Run `npx ethagent` and restore this identity before saving a snapshot.')
78
+ }
79
+
80
+ // Only proceed when the working tree actually differs from the last published
81
+ // snapshot. If it is already up to date, do nothing: no wallet, no transaction, no
82
+ // gas. We only block on the confirmed-equal state ('published'); first saves
83
+ // ('not-published') and undeterminable cases still go through.
84
+ let publishState: string | undefined
85
+ try {
86
+ const [latest] = await deps.listPublishedContinuitySnapshots(identity, 1)
87
+ const tree = await deps.continuityWorkingTreeStatus(identity, latest)
88
+ publishState = tree.publishState
89
+ } catch {
90
+ publishState = undefined
91
+ }
92
+ if (publishState === 'published') {
93
+ if (json) stdout.write(JSON.stringify({ ok: true, skipped: true, reason: 'no-local-changes' }) + '\n')
94
+ else stdout.write('No local changes since the last snapshot; nothing to save.\n')
95
+ return 0
96
+ }
97
+
98
+ // JWT is resolved and validated BEFORE opening the wallet, so we never ask for a
99
+ // signature on a snapshot that then cannot be pinned.
100
+ let jwt: string | undefined
101
+ try {
102
+ jwt = await deps.resolveValidatedPinataJwt()
103
+ } catch (err) {
104
+ const detail = err instanceof Error ? err.message : String(err)
105
+ return fail(3, `The configured Pinata JWT is invalid or unreachable (${detail}). The wallet was not opened. Update it via \`npx ethagent\` -> IPFS Storage, then retry \`ethagent save\`.`)
106
+ }
107
+ if (!jwt) {
108
+ return fail(3, 'No IPFS storage credential configured, so the snapshot cannot be pinned and the wallet was not opened. Run `npx ethagent` once and set up IPFS Storage (or export PINATA_JWT in this shell), then retry `ethagent save`.')
109
+ }
110
+
111
+ let completed = false
112
+ const callbacks: EffectCallbacks = {
113
+ onStep: () => {},
114
+ onWalletReady: ready => {
115
+ if (!ready) return
116
+ const sink = json ? stderr : stdout
117
+ sink.write(`Approve this snapshot in your browser wallet tab: ${ready.url}\n`)
118
+ sink.write('Connect your wallet, sign one message, and approve one transaction (up to ~5 minutes)...\n')
119
+ if (!noOpen) deps.openExternalUrl(ready.url)
120
+ },
121
+ onIdentityComplete: async nextIdentity => {
122
+ await deps.saveConfig({ ...activeConfig, identity: nextIdentity })
123
+ completed = true
124
+ if (json) {
125
+ stdout.write(JSON.stringify({
126
+ ok: true,
127
+ cid: nextIdentity.backup?.cid ?? null,
128
+ txHash: nextIdentity.backup?.txHash ?? null,
129
+ agentUri: nextIdentity.agentUri ?? null,
130
+ }) + '\n')
131
+ } else {
132
+ stdout.write('Snapshot saved.\n')
133
+ if (nextIdentity.backup?.cid) stdout.write(` CID: ${nextIdentity.backup.cid}\n`)
134
+ if (nextIdentity.backup?.txHash) stdout.write(` tx: ${nextIdentity.backup.txHash}\n`)
135
+ if (nextIdentity.agentUri) stdout.write(` agentURI: ${nextIdentity.agentUri}\n`)
136
+ }
137
+ },
138
+ }
139
+
140
+ const step: Extract<Step, { kind: 'rebackup-signing' }> = {
141
+ kind: 'rebackup-signing',
142
+ identity,
143
+ registry,
144
+ pinataJwt: jwt,
145
+ returnTo: { kind: 'menu' },
146
+ }
147
+
148
+ try {
149
+ await deps.runRebackupSigning(step, callbacks)
150
+ } catch (err) {
151
+ const message = err instanceof Error ? err.message : String(err)
152
+ if (isWalletCancelled(err) || /timed out/i.test(message)) {
153
+ return fail(3, 'Wallet approval was cancelled or timed out. No snapshot was saved. Retry `ethagent save` when ready.')
154
+ }
155
+ return fail(1, `Save failed: ${message}`)
156
+ }
157
+
158
+ if (!completed) {
159
+ return fail(1, 'Save did not complete and no snapshot was recorded. Retry `ethagent save`.')
160
+ }
161
+ return 0
162
+ }
@@ -2,7 +2,7 @@ import { useEffect, useReducer, useState } from 'react'
2
2
  import type { EthagentConfig, EthagentIdentity } from '../../storage/config.js'
3
3
  import { setTokenIdentity } from '../../storage/identity.js'
4
4
  import type { BrowserWalletReady } from '../wallet/browserWallet.js'
5
- import { registryConfigFromConfig } from '../registry/registryConfig.js'
5
+ import { resolveRegistryForIdentity as resolveRegistryForIdentityFromConfig } from '../registry/registryConfig.js'
6
6
  import type { Erc8004RegistryConfig } from '../registry/erc8004.js'
7
7
  import {
8
8
  hasPinataJwt,
@@ -94,18 +94,8 @@ export function useIdentityManagerController({
94
94
  }
95
95
  }
96
96
 
97
- const resolveRegistryForIdentity = (target: EthagentIdentity): Erc8004RegistryConfig | null => {
98
- const resolution = registryConfigFromConfig(config)
99
- if (target.chainId && target.identityRegistryAddress) {
100
- return {
101
- chainId: target.chainId,
102
- rpcUrl: target.rpcUrl ?? resolution.defaultRpcUrl,
103
- identityRegistryAddress: target.identityRegistryAddress as `0x${string}`,
104
- }
105
- }
106
- if (resolution.config) return resolution.config
107
- return null
108
- }
97
+ const resolveRegistryForIdentity = (target: EthagentIdentity): Erc8004RegistryConfig | null =>
98
+ resolveRegistryForIdentityFromConfig(target, config)
109
99
 
110
100
  const completeTokenIdentity = async (nextIdentity: EthagentIdentity, message: string, source?: IdentityCompletionSource): Promise<void> => {
111
101
  if (mode === 'first-run' || !config) {
@@ -1,4 +1,4 @@
1
- import type { EthagentConfig, SelectableNetwork } from '../../storage/config.js'
1
+ import type { EthagentConfig, EthagentIdentity, SelectableNetwork } from '../../storage/config.js'
2
2
  import {
3
3
  chainIdForNetwork,
4
4
  DEFAULT_ERC8004_CHAIN_ID,
@@ -67,3 +67,22 @@ export function registryConfigFromConfig(config?: EthagentConfig): RegistryResol
67
67
  throw err
68
68
  }
69
69
  }
70
+
71
+ // Resolve the registry an existing identity should operate against: prefer the
72
+ // identity's own chain + registry address (filling in a default RPC when it has
73
+ // none), otherwise fall back to the config-derived registry. Pure function of
74
+ // (identity, config) so both the TUI controller and headless commands share it.
75
+ export function resolveRegistryForIdentity(
76
+ identity: Pick<EthagentIdentity, 'chainId' | 'identityRegistryAddress' | 'rpcUrl'>,
77
+ config?: EthagentConfig,
78
+ ): Erc8004RegistryConfig | null {
79
+ const resolution = registryConfigFromConfig(config)
80
+ if (identity.chainId && identity.identityRegistryAddress) {
81
+ return {
82
+ chainId: identity.chainId,
83
+ rpcUrl: identity.rpcUrl ?? resolution.defaultRpcUrl,
84
+ identityRegistryAddress: identity.identityRegistryAddress as `0x${string}`,
85
+ }
86
+ }
87
+ return resolution.config
88
+ }