ethagent 4.1.1 → 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.
- package/.claude-plugin/plugin.json +2 -2
- package/README.md +21 -4
- package/commands/ethagent.md +5 -4
- package/package.json +1 -1
- package/src/cli/main.tsx +10 -0
- package/src/cli/pretoolGuard.ts +32 -0
- package/src/cli/save.ts +162 -0
- package/src/cli/skillGuard.ts +47 -0
- package/src/cli/sync.ts +12 -2
- package/src/cli/syncAdapters/claude-code.ts +2 -2
- package/src/cli/syncAdapters/codex.ts +2 -2
- package/src/cli/syncAdapters/shared.ts +56 -5
- package/src/identity/continuity/skills/loadSkills.ts +3 -3
- package/src/identity/continuity/skills/scaffold.ts +1 -1
- package/src/identity/manager/continuity/skills/NewSkillVisibilityScreen.tsx +2 -2
- package/src/identity/manager/useController.ts +3 -13
- package/src/identity/registry/registryConfig.ts +20 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ethagent",
|
|
3
|
-
"version": "4.
|
|
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",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
{
|
|
20
20
|
"matcher": "Edit|Write|MultiEdit",
|
|
21
21
|
"hooks": [
|
|
22
|
-
{ "type": "command", "command": "npx -y ethagent --
|
|
22
|
+
{ "type": "command", "command": "npx -y ethagent --pretool-guard" }
|
|
23
23
|
]
|
|
24
24
|
}
|
|
25
25
|
],
|
package/README.md
CHANGED
|
@@ -42,7 +42,7 @@ That's the whole setup. You'll only open `ethagent` again to hand-edit your agen
|
|
|
42
42
|
|
|
43
43
|
- **Soul** (`SOUL.md`): who it is, your standards, your voice, the way you work.
|
|
44
44
|
- **Memory** (`MEMORY.md`): what it has learned about you, your preferences, and your projects, so context survives the move to a new machine.
|
|
45
|
-
- **Skills:** the commands, tools, and prompts you teach it.
|
|
45
|
+
- **Skills:** the commands, tools, and prompts you teach it. **Private by default** — yours alone, encrypted in your vault and mirrored into your harnesses so you can use them locally, but kept off your public Agent Card. Make one **public** when you want other agents to discover it; then only its name and description go on the card your token publishes.
|
|
46
46
|
|
|
47
47
|
You grow these mostly by talking: with the plugin on, your agent updates its own soul and memory as you converse, and the changes sync automatically. To edit them by hand, open `ethagent`. To save your agent onchain so it can come back on any machine, choose **Save Snapshot** and sign.
|
|
48
48
|
|
|
@@ -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
|
-
|
|
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
|
|
|
@@ -95,14 +95,31 @@ Built on open standards, so your agent is never tied to one harness.
|
|
|
95
95
|
| Naming | ENS | A human-readable name that resolves to your agent and restores it from the name alone. |
|
|
96
96
|
| Backup | IPFS snapshot | The encrypted bundle of soul, memory, and skills, pinned offchain and unlocked only by your wallet. |
|
|
97
97
|
|
|
98
|
+
## 🔄 Updating
|
|
99
|
+
|
|
100
|
+
ethagent ships as two pieces, and a full update can touch both:
|
|
101
|
+
|
|
102
|
+
- **The npm package** (the engine: sync, skills, the guards). The plugin's hooks call `npx -y ethagent`, which resolves the latest published release, so **publishing a new version is the update** — nothing for most people to run. If you installed it globally, or call a bare `ethagent` in a hook or your shell, refresh that copy:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
npm i -g ethagent@latest
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Confirm what you're running with `ethagent --version`.
|
|
109
|
+
|
|
110
|
+
- **The Claude Code plugin** (the hook wiring). New hooks ship in the plugin manifest, so to pick them up, update the plugin from the marketplace with `/plugin`.
|
|
111
|
+
|
|
112
|
+
Rule of thumb: new sync and skill behavior rides your existing hooks as soon as the package is published; a brand-new hook also needs a plugin update.
|
|
113
|
+
|
|
98
114
|
## ⌨️ Commands
|
|
99
115
|
|
|
100
116
|
Run with `npx ethagent`:
|
|
101
117
|
|
|
102
118
|
| Command | What it does |
|
|
103
119
|
| --- | --- |
|
|
104
|
-
| `ethagent` | Open the interactive identity manager: create, ENS, custody,
|
|
105
|
-
|
|
|
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. |
|
|
122
|
+
| `--sync` | Sync soul, memory, and skills (public and private) into every harness it detects. |
|
|
106
123
|
| `--sync-list` | List sync adapters and which ones detect in the current environment. |
|
|
107
124
|
| `--status` | Print a one-line identity summary. |
|
|
108
125
|
| `--demo` | Walk the manager with synthetic data, no wallet needed. |
|
package/commands/ethagent.md
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
package/src/cli/main.tsx
CHANGED
|
@@ -13,9 +13,12 @@ import type { IdentityManagerResult } from '../identity/manager/IdentityManager.
|
|
|
13
13
|
import { loadConfig, saveConfig, type EthagentConfig } from '../storage/config.js'
|
|
14
14
|
import { runSync, runSyncList, runSyncOnEdit } from './sync.js'
|
|
15
15
|
import { runMemoryGuard } from './memoryGuard.js'
|
|
16
|
+
import { runSkillGuard } from './skillGuard.js'
|
|
17
|
+
import { runPreToolGuard } from './pretoolGuard.js'
|
|
16
18
|
import { runSessionStart } from './sessionStart.js'
|
|
17
19
|
import { runStatus } from './status.js'
|
|
18
20
|
import { runResetCommand } from './reset.js'
|
|
21
|
+
import { runSave } from './save.js'
|
|
19
22
|
import { enableDemoMode, synthDemoConfig } from './demo.js'
|
|
20
23
|
|
|
21
24
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
@@ -36,6 +39,7 @@ function printHelp(): void {
|
|
|
36
39
|
'',
|
|
37
40
|
'usage:',
|
|
38
41
|
' ethagent manage identity',
|
|
42
|
+
' ethagent save save an encrypted snapshot: pin to IPFS, rotate the onchain pointer (you approve in your wallet)',
|
|
39
43
|
' ethagent reset delete local identity, continuity, and secrets',
|
|
40
44
|
' ethagent --sync sync soul, memory, and skills to every harness',
|
|
41
45
|
' ethagent --sync-list list sync adapters and which ones detect here',
|
|
@@ -46,7 +50,9 @@ function printHelp(): void {
|
|
|
46
50
|
'',
|
|
47
51
|
'plugin hooks (invoked automatically, not meant to be run by hand):',
|
|
48
52
|
' ethagent --session-start sync, then tell the agent where to record memory',
|
|
53
|
+
' ethagent --pretool-guard one PreToolUse guard combining --memory-guard + --skill-guard',
|
|
49
54
|
' ethagent --memory-guard keep agent memory in the portable markers, not local notes',
|
|
55
|
+
' ethagent --skill-guard keep skills in the portable vault, not the harness mirror',
|
|
50
56
|
]
|
|
51
57
|
for (const line of lines) process.stdout.write(line + '\n')
|
|
52
58
|
}
|
|
@@ -149,7 +155,9 @@ async function main(): Promise<number> {
|
|
|
149
155
|
}
|
|
150
156
|
if (flags.has('--sync-on-edit')) return runSyncOnEdit()
|
|
151
157
|
if (flags.has('--session-start')) return runSessionStart()
|
|
158
|
+
if (flags.has('--pretool-guard')) return runPreToolGuard()
|
|
152
159
|
if (flags.has('--memory-guard')) return runMemoryGuard()
|
|
160
|
+
if (flags.has('--skill-guard')) return runSkillGuard()
|
|
153
161
|
if (flags.has('--sync-list')) return runSyncList()
|
|
154
162
|
if (flags.has('--sync')) return runSync()
|
|
155
163
|
if (flags.has('--status')) return runStatus(version)
|
|
@@ -157,6 +165,8 @@ async function main(): Promise<number> {
|
|
|
157
165
|
enableDemoMode()
|
|
158
166
|
return renderHub(synthDemoConfig())
|
|
159
167
|
}
|
|
168
|
+
if (argv[0] === 'save') return runSave(argv.slice(1))
|
|
169
|
+
if (flags.has('--save')) return runSave(argv.filter(a => a !== '--save'))
|
|
160
170
|
if (argv[0] === 'reset') return runResetCommand(argv.slice(1))
|
|
161
171
|
|
|
162
172
|
const unknown = argv.find(a => a.startsWith('-'))
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { loadConfig } from '../storage/config.js'
|
|
2
|
+
import { hookFilePath, readHookPayload } from './hookIo.js'
|
|
3
|
+
import { decideMemoryGuard } from './memoryGuard.js'
|
|
4
|
+
import { decideSkillGuard } from './skillGuard.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Combined PreToolUse guard: runs the memory-dir guard and the skills-mirror
|
|
8
|
+
* guard from a single process, so each Edit/Write/MultiEdit spawns one `npx`
|
|
9
|
+
* (one config load, one stdin read) instead of two. The two guards check
|
|
10
|
+
* disjoint directories, so at most one denies.
|
|
11
|
+
*/
|
|
12
|
+
export async function runPreToolGuard(): Promise<number> {
|
|
13
|
+
try {
|
|
14
|
+
const config = await loadConfig()
|
|
15
|
+
const filePath = hookFilePath(await readHookPayload())
|
|
16
|
+
const opts = { identityPresent: !!config?.identity }
|
|
17
|
+
const memory = decideMemoryGuard(filePath, opts)
|
|
18
|
+
const decision = memory.deny ? memory : decideSkillGuard(filePath, opts)
|
|
19
|
+
if (decision.deny) {
|
|
20
|
+
process.stdout.write(
|
|
21
|
+
JSON.stringify({
|
|
22
|
+
hookSpecificOutput: {
|
|
23
|
+
hookEventName: 'PreToolUse',
|
|
24
|
+
permissionDecision: 'deny',
|
|
25
|
+
permissionDecisionReason: decision.reason,
|
|
26
|
+
},
|
|
27
|
+
}) + '\n',
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
} catch {}
|
|
31
|
+
return 0
|
|
32
|
+
}
|
package/src/cli/save.ts
ADDED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { loadConfig } from '../storage/config.js'
|
|
2
|
+
import { hookFilePath, isWithinDir, readHookPayload } from './hookIo.js'
|
|
3
|
+
import { claudeSkillsDir } from './syncAdapters/claude-code.js'
|
|
4
|
+
|
|
5
|
+
export const SKILL_REDIRECT_REASON =
|
|
6
|
+
"ethagent keeps this agent's skills in its portable continuity vault and generates the ~/.claude/skills " +
|
|
7
|
+
'mirror from it. Skills you create or edit directly here do not travel with the agent, and ethagent-managed ' +
|
|
8
|
+
'mirror copies are regenerated from the vault on the next sync. Author or edit skills in the vault instead: ' +
|
|
9
|
+
'run `ethagent` and open Skills (create, edit, set visibility) so they are versioned, encrypted, and synced ' +
|
|
10
|
+
'into every harness automatically. New skills are private by default; switch one to public only when you want ' +
|
|
11
|
+
'it listed on your Agent Card.'
|
|
12
|
+
|
|
13
|
+
export function decideSkillGuard(
|
|
14
|
+
filePath: string | null | undefined,
|
|
15
|
+
opts: { identityPresent: boolean },
|
|
16
|
+
): { deny: boolean; reason?: string } {
|
|
17
|
+
if (!opts.identityPresent) return { deny: false }
|
|
18
|
+
if (!filePath) return { deny: false }
|
|
19
|
+
// Scope: this guards the Claude Code skills mirror only. The Codex
|
|
20
|
+
// ~/.codex/AGENTS.md skill mirror is intentionally left unguarded, matching
|
|
21
|
+
// the Claude-only --memory-guard — the PreToolUse hook is registered only in
|
|
22
|
+
// the Claude Code plugin manifest.
|
|
23
|
+
if (isWithinDir(claudeSkillsDir(), filePath)) {
|
|
24
|
+
return { deny: true, reason: SKILL_REDIRECT_REASON }
|
|
25
|
+
}
|
|
26
|
+
return { deny: false }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function runSkillGuard(): Promise<number> {
|
|
30
|
+
try {
|
|
31
|
+
const config = await loadConfig()
|
|
32
|
+
const filePath = hookFilePath(await readHookPayload())
|
|
33
|
+
const decision = decideSkillGuard(filePath, { identityPresent: !!config?.identity })
|
|
34
|
+
if (decision.deny) {
|
|
35
|
+
process.stdout.write(
|
|
36
|
+
JSON.stringify({
|
|
37
|
+
hookSpecificOutput: {
|
|
38
|
+
hookEventName: 'PreToolUse',
|
|
39
|
+
permissionDecision: 'deny',
|
|
40
|
+
permissionDecisionReason: decision.reason,
|
|
41
|
+
},
|
|
42
|
+
}) + '\n',
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
} catch {}
|
|
46
|
+
return 0
|
|
47
|
+
}
|
package/src/cli/sync.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { continuityWorkingTreeStatus } from '../identity/continuity/storage/stat
|
|
|
5
5
|
import { listPublishedContinuitySnapshots } from '../identity/continuity/snapshots.js'
|
|
6
6
|
import { changedContinuitySnapshotFiles } from '../identity/manager/continuity/state.js'
|
|
7
7
|
import { listSkills } from '../identity/continuity/skills/loadSkills.js'
|
|
8
|
+
import { isDraftScaffold } from '../identity/continuity/skills/scaffold.js'
|
|
8
9
|
import { hashManagedBody, normalizeBody, reconstructVaultFile, sectionKey } from './syncAdapters/managedBlock.js'
|
|
9
10
|
import { hookFilePath, readHookPayload, samePath } from './hookIo.js'
|
|
10
11
|
import {
|
|
@@ -16,6 +17,15 @@ import type { PublicSkill } from './syncAdapters/shared.js'
|
|
|
16
17
|
|
|
17
18
|
export type SyncOptions = { quiet?: boolean }
|
|
18
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Skills mirrored into local harnesses: every real skill (public AND private),
|
|
22
|
+
* so private skills are usable locally. The public Agent Card stays public-only
|
|
23
|
+
* (built separately via derivePublicSkillEntries). Drafts/scaffolds are skipped.
|
|
24
|
+
*/
|
|
25
|
+
export function selectMirrorSkills(all: readonly PublicSkill[]): PublicSkill[] {
|
|
26
|
+
return all.filter(s => !isDraftScaffold(s))
|
|
27
|
+
}
|
|
28
|
+
|
|
19
29
|
export async function runSync(opts: SyncOptions = {}): Promise<number> {
|
|
20
30
|
const config = await loadConfig()
|
|
21
31
|
if (!config?.identity) return 0
|
|
@@ -27,7 +37,7 @@ export async function runSync(opts: SyncOptions = {}): Promise<number> {
|
|
|
27
37
|
process.stderr.write(`ethagent: could not load skills, skipping sync to avoid removing managed files (${(err as Error).message})\n`)
|
|
28
38
|
return 1
|
|
29
39
|
}
|
|
30
|
-
const
|
|
40
|
+
const mirrorSkills: PublicSkill[] = selectMirrorSkills(all)
|
|
31
41
|
|
|
32
42
|
const targets: SyncAdapter[] = []
|
|
33
43
|
for (const adapter of BUILT_IN_ADAPTERS) {
|
|
@@ -52,7 +62,7 @@ export async function runSync(opts: SyncOptions = {}): Promise<number> {
|
|
|
52
62
|
const summaries: string[] = []
|
|
53
63
|
for (const adapter of targets) {
|
|
54
64
|
try {
|
|
55
|
-
const { count, skipped } = await adapter.mirror(
|
|
65
|
+
const { count, skipped } = await adapter.mirror(mirrorSkills, context)
|
|
56
66
|
let summary = `${adapter.name}: ${count} skill${count === 1 ? '' : 's'}`
|
|
57
67
|
if (skipped > 0) summary += `, skipped ${skipped} unmanaged`
|
|
58
68
|
summaries.push(summary)
|
|
@@ -9,7 +9,7 @@ function claudeDir(): string {
|
|
|
9
9
|
return path.join(os.homedir(), '.claude')
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
function claudeSkillsDir(): string {
|
|
12
|
+
export function claudeSkillsDir(): string {
|
|
13
13
|
return path.join(claudeDir(), 'skills')
|
|
14
14
|
}
|
|
15
15
|
|
|
@@ -44,7 +44,7 @@ export async function projectMemoryMirrorsUnder(claudeRoot: string): Promise<str
|
|
|
44
44
|
|
|
45
45
|
export const claudeCodeAdapter = {
|
|
46
46
|
name: 'claude-code' as const,
|
|
47
|
-
description: 'Mirror public
|
|
47
|
+
description: 'Mirror skills (public and private) into ~/.claude/skills and inject soul/memory into ~/.claude/CLAUDE.md and the project MEMORY.md.',
|
|
48
48
|
async detect(): Promise<boolean> {
|
|
49
49
|
return pathExists(claudeDir())
|
|
50
50
|
},
|
|
@@ -33,7 +33,7 @@ function neutralizeManagedMarkers(text: string): string {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
function renderSkillsText(skills: EnrichedSkill[]): string {
|
|
36
|
-
if (skills.length === 0) return '_no
|
|
36
|
+
if (skills.length === 0) return '_no skills synced yet._'
|
|
37
37
|
const lines: string[] = []
|
|
38
38
|
for (const skill of skills) {
|
|
39
39
|
lines.push(`## ${neutralizeManagedMarkers(skill.displayName ?? skill.name)}`, '')
|
|
@@ -45,7 +45,7 @@ function renderSkillsText(skills: EnrichedSkill[]): string {
|
|
|
45
45
|
|
|
46
46
|
export const codexAdapter = {
|
|
47
47
|
name: 'codex' as const,
|
|
48
|
-
description: 'Merge soul, memory, and
|
|
48
|
+
description: 'Merge soul, memory, and skill content (public and private) into ~/.codex/AGENTS.md between ethagent markers.',
|
|
49
49
|
async detect(): Promise<boolean> {
|
|
50
50
|
return pathExists(path.join(codexDir(), 'config.toml'))
|
|
51
51
|
},
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import fs from 'node:fs/promises'
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import type { SkillIndexEntry } from '../../identity/continuity/skills/types.js'
|
|
4
|
+
import {
|
|
5
|
+
isReservedWindowsSegment,
|
|
6
|
+
isValidFilenameSegment,
|
|
7
|
+
isValidSegment,
|
|
8
|
+
MAX_FOLDER_DEPTH,
|
|
9
|
+
} from '../../identity/continuity/skills/skillPaths.js'
|
|
10
|
+
|
|
11
|
+
// Cap copied files at the same size the vault loader enforces.
|
|
12
|
+
const MAX_MIRROR_FILE_BYTES = 256 * 1024
|
|
4
13
|
|
|
5
14
|
export type PublicSkill = SkillIndexEntry
|
|
6
15
|
|
|
@@ -30,6 +39,39 @@ export async function pathExists(file: string): Promise<boolean> {
|
|
|
30
39
|
try { await fs.access(file); return true } catch { return false }
|
|
31
40
|
}
|
|
32
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Copy a vault skill folder into the harness, applying the SAME vetting the
|
|
44
|
+
* vault loader uses (skip symlinks, dotfiles, reserved Windows names, invalid
|
|
45
|
+
* segments, and oversize files) so the mirror never copies a superset of — or a
|
|
46
|
+
* symlink escaping — the vault's recognized file set.
|
|
47
|
+
*/
|
|
48
|
+
async function copyVettedSkillTree(srcDir: string, destDir: string, depth = 0): Promise<void> {
|
|
49
|
+
if (depth > MAX_FOLDER_DEPTH) return
|
|
50
|
+
await fs.mkdir(destDir, { recursive: true })
|
|
51
|
+
let entries: import('node:fs').Dirent[]
|
|
52
|
+
try {
|
|
53
|
+
entries = await fs.readdir(srcDir, { withFileTypes: true })
|
|
54
|
+
} catch {
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
for (const ent of entries) {
|
|
58
|
+
if (ent.isSymbolicLink()) continue
|
|
59
|
+
if (ent.name.startsWith('.')) continue
|
|
60
|
+
if (isReservedWindowsSegment(ent.name)) continue
|
|
61
|
+
const srcPath = path.join(srcDir, ent.name)
|
|
62
|
+
const destPath = path.join(destDir, ent.name)
|
|
63
|
+
if (ent.isDirectory()) {
|
|
64
|
+
if (!isValidSegment(ent.name)) continue
|
|
65
|
+
await copyVettedSkillTree(srcPath, destPath, depth + 1)
|
|
66
|
+
} else if (ent.isFile()) {
|
|
67
|
+
if (!isValidFilenameSegment(ent.name)) continue
|
|
68
|
+
const stat = await fs.stat(srcPath).catch(() => null)
|
|
69
|
+
if (!stat || stat.size > MAX_MIRROR_FILE_BYTES) continue
|
|
70
|
+
await fs.copyFile(srcPath, destPath)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
33
75
|
export async function mirrorAsSkillFolders(
|
|
34
76
|
root: string,
|
|
35
77
|
skills: PublicSkill[],
|
|
@@ -41,16 +83,25 @@ export async function mirrorAsSkillFolders(
|
|
|
41
83
|
let skipped = 0
|
|
42
84
|
for (const skill of skills) {
|
|
43
85
|
const targetDir = path.join(root, skill.name)
|
|
44
|
-
const targetFile = path.join(targetDir, 'SKILL.md')
|
|
45
86
|
const exists = await pathExists(targetDir)
|
|
46
87
|
const isOurs = manifest.skills.includes(skill.name)
|
|
47
88
|
if (exists && !isOurs) { skipped++; continue }
|
|
89
|
+
const srcDir = path.dirname(skill.absolutePath)
|
|
90
|
+
const tmpDir = path.join(root, `.${skill.name}.ethagent-tmp`)
|
|
48
91
|
try {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
92
|
+
// Stage the new copy in a temp sibling, then swap it in. This keeps the
|
|
93
|
+
// existing managed copy intact if the copy fails (no destructive
|
|
94
|
+
// rm-before-write window), and refreshes the whole folder (scripts/,
|
|
95
|
+
// assets/), dropping files removed upstream.
|
|
96
|
+
await fs.rm(tmpDir, { recursive: true, force: true })
|
|
97
|
+
await copyVettedSkillTree(srcDir, tmpDir)
|
|
98
|
+
await fs.rm(targetDir, { recursive: true, force: true })
|
|
99
|
+
await fs.rename(tmpDir, targetDir)
|
|
52
100
|
owned.push(skill.name)
|
|
53
|
-
} catch {
|
|
101
|
+
} catch (err) {
|
|
102
|
+
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => null)
|
|
103
|
+
process.stderr.write(`ethagent: failed to mirror skill "${skill.name}": ${(err as Error).message}\n`)
|
|
104
|
+
}
|
|
54
105
|
}
|
|
55
106
|
const keep = new Set<string>(owned)
|
|
56
107
|
for (const name of manifest.skills) if (incoming.has(name)) keep.add(name)
|
|
@@ -517,7 +517,7 @@ async function pathExists(file: string): Promise<boolean> {
|
|
|
517
517
|
}
|
|
518
518
|
}
|
|
519
519
|
|
|
520
|
-
const
|
|
520
|
+
const DEFAULT_SKILL_VISIBILITY: SkillVisibility = 'private'
|
|
521
521
|
const LEGACY_DISCOVERABLE_RE = /^\s*visibility\s*:\s*['"]?discoverable['"]?\s*$/im
|
|
522
522
|
|
|
523
523
|
async function ensureSkillVisibilityWritten(skillFile: string, raw: string): Promise<string> {
|
|
@@ -531,7 +531,7 @@ async function ensureSkillVisibilityWritten(skillFile: string, raw: string): Pro
|
|
|
531
531
|
if (LEGACY_DISCOVERABLE_RE.test(raw)) {
|
|
532
532
|
target = 'private'
|
|
533
533
|
} else if (parsed.frontmatter.visibility === undefined) {
|
|
534
|
-
target =
|
|
534
|
+
target = DEFAULT_SKILL_VISIBILITY
|
|
535
535
|
}
|
|
536
536
|
if (target === null) return raw
|
|
537
537
|
const next = rewriteVisibility(raw, target)
|
|
@@ -587,7 +587,7 @@ function buildIndexEntry(args: {
|
|
|
587
587
|
const derivedName = folder || segments.join('/')
|
|
588
588
|
const fm = args.parsed.frontmatter
|
|
589
589
|
const description = pickDescription(fm.description, args.parsed.body)
|
|
590
|
-
const visibility: SkillVisibility = fm.visibility ??
|
|
590
|
+
const visibility: SkillVisibility = fm.visibility ?? DEFAULT_SKILL_VISIBILITY
|
|
591
591
|
return {
|
|
592
592
|
name: derivedName,
|
|
593
593
|
...(fm.name ? { displayName: fm.name } : {}),
|
|
@@ -5,7 +5,7 @@ export type SkillScaffoldArgs = {
|
|
|
5
5
|
visibility?: SkillVisibility
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
-
export function defaultSkillScaffold({ name, visibility = '
|
|
8
|
+
export function defaultSkillScaffold({ name, visibility = 'private' }: SkillScaffoldArgs): string {
|
|
9
9
|
return [
|
|
10
10
|
'---',
|
|
11
11
|
`name: ${name}`,
|
|
@@ -22,7 +22,7 @@ export const NewSkillVisibilityScreen: React.FC<NewSkillVisibilityScreenProps> =
|
|
|
22
22
|
}) => (
|
|
23
23
|
<Surface
|
|
24
24
|
title={`Visibility · ${name}`}
|
|
25
|
-
subtitle="
|
|
25
|
+
subtitle="Private is the default."
|
|
26
26
|
footer={footer}
|
|
27
27
|
>
|
|
28
28
|
{error && (
|
|
@@ -39,7 +39,7 @@ export const NewSkillVisibilityScreen: React.FC<NewSkillVisibilityScreenProps> =
|
|
|
39
39
|
{ value: 'back', label: 'Back', role: 'utility' },
|
|
40
40
|
]}
|
|
41
41
|
hintLayout="inline"
|
|
42
|
-
initialIndex={
|
|
42
|
+
initialIndex={0}
|
|
43
43
|
onSubmit={choice => {
|
|
44
44
|
if (choice === 'back') return onCancel()
|
|
45
45
|
return onSelect(choice)
|
|
@@ -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 {
|
|
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
|
-
|
|
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
|
+
}
|