From df2cb29f8f5e5a4d23d8c12de2181561da8fd45e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 22:32:49 +0000 Subject: [PATCH 1/3] feat: ethagent check lists every token value and what needs attention One read-only report with the identifiers the manager's Token Values shows and its Needs Attention items, each with the headless command that fixes it. Exits 4 when something needs attention. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HG2miyE22qTuug1Quxj7iw --- src/cli/onchain/check.ts | 189 ++++++++++++++++++++++++++++++++++++ src/cli/onchain/commands.ts | 2 +- src/cli/onchain/index.ts | 1 + 3 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 src/cli/onchain/check.ts diff --git a/src/cli/onchain/check.ts b/src/cli/onchain/check.ts new file mode 100644 index 0000000..85eceae --- /dev/null +++ b/src/cli/onchain/check.ts @@ -0,0 +1,189 @@ +import type { EthagentConfig, EthagentIdentity } from '../../storage/config.js' +import { runReconciliation } from '../../identity/manager/shared/reconciliation/agentReconciliation/run.js' +import type { AgentReconciliation } from '../../identity/manager/shared/reconciliation/agentReconciliation/types.js' +import { readCustodyMode, readIdentityStateString } from '../../identity/manager/custody/state.js' +import { changedContinuitySnapshotFiles, hasPendingPublish } from '../../identity/manager/continuity/state.js' +import { transferSnapshotView } from '../../identity/manager/transfer/state.js' +import { listPublishedContinuitySnapshots } from '../../identity/continuity/snapshots.js' +import { continuityWorkingTreeStatus } from '../../identity/continuity/storage/status.js' +import { emitJson, failFrom, HistoryError, parseHistoryArgs, requireIdentity, type HistoryDeps } from '../history/shared.js' +import { networkOfChain } from './shared.js' + +export const CHECK_USAGE = 'ethagent check [--json]' + +const HELP = [ + `usage: ${CHECK_USAGE}`, + '', + 'Every value that identifies this agent, read against the chain, and anything that needs', + 'attention with the command that fixes it. Read-only: nothing is signed or sent.', + '', + 'Exits 0 when nothing needs attention and 4 when something does.', + '', +].join('\n') + +export type CheckSeams = { + reconcile: (identity: EthagentIdentity, config: EthagentConfig) => Promise + localChanges: (identity: EthagentIdentity) => Promise +} + +async function localChangedFiles(identity: EthagentIdentity): Promise { + try { + const [latest] = await listPublishedContinuitySnapshots(identity, 1) + const status = await continuityWorkingTreeStatus(identity, latest) + if (!status.ready) return null + return status.publishState === 'local-changes' ? changedContinuitySnapshotFiles(status) : [] + } catch { + return null + } +} + +const defaultSeams: CheckSeams = { + reconcile: runReconciliation, + localChanges: localChangedFiles, +} + +type Attention = { code: string; message: string; fix?: string } + +// The manager's Token Values, as data: null where a value is not set yet. +export function tokenValues(identity: EthagentIdentity): Record { + const state = identity.state + const custody = readCustodyMode(state) + const transfer = transferSnapshotView(identity) + const owner = readIdentityStateString(state, 'ownerAddress') || identity.ownerAddress || identity.address + return { + agentId: identity.agentId ?? null, + network: identity.chainId ? networkOfChain(identity.chainId) : null, + chainId: identity.chainId ?? null, + registry: identity.identityRegistryAddress ?? null, + owner, + agentUri: identity.agentUri ?? (identity.metadataCid ? `ipfs://${identity.metadataCid}` : null), + metadataCid: identity.metadataCid ?? null, + snapshotCid: identity.backup?.cid ?? null, + agentCardCid: identity.agentCard?.cid ?? null, + ensName: readIdentityStateString(state, 'ensName') || null, + custody: custody ?? null, + vault: custody === 'advanced' ? readIdentityStateString(state, 'operatorVaultAddress') || null : null, + activeOperator: readIdentityStateString(state, 'activeOperatorAddress') || null, + lastSaved: identity.backup?.createdAt ?? null, + pendingPublish: hasPendingPublish(identity), + transfer: transfer + ? { + state: transfer.kind, + receiver: transfer.receiver, + ...(transfer.receiverHandle && transfer.receiverHandle !== transfer.receiver ? { receiverName: transfer.receiverHandle } : {}), + } + : null, + pendingTx: identity.pendingTx ?? null, + } +} + +// The manager's Needs Attention, each with the headless command that settles it. +export function attentionItems(identity: EthagentIdentity, recon: AgentReconciliation, changed: string[] | null): Attention[] { + const items: Attention[] = [] + const pending = hasPendingPublish(identity) + if (recon.rpc === 'failing') { + items.push({ code: 'chain-unreachable', message: 'The chain could not be read, so only local values are shown.', fix: 'Retry later, or point ETHAGENT_RPC_URL at another RPC endpoint for this chain.' }) + } + if (recon.token === 'unlinked') { + const label = recon.tokenAgentId ? `Token #${recon.tokenAgentId}` : 'The token' + items.push(transferSnapshotView(identity) + ? { code: 'token-transferred', message: `${label} was transferred. Soul, memory, and skills stay on this machine.` } + : { + code: 'token-unlinked', + message: `${label} left this wallet without a prepared transfer${recon.onChainOwner ? `; it is now held by ${recon.onChainOwner}` : ''}.`, + fix: 'ethagent restore --owner
', + }) + } + if (recon.custody === 'mid-flow-uri-pending') { + items.push({ code: 'custody-unfinished', message: 'Advanced custody setup is unfinished.', fix: 'ethagent custody --advanced' }) + } else if (recon.agentUri === 'local-newer' || recon.agentUri === 'chain-newer') { + items.push(pending + ? { code: 'publish-pending', message: 'Your newest snapshot is pinned but not onchain yet.', fix: 'ethagent save' } + : { code: 'agent-uri-differs', message: 'The onchain agent URI differs from this machine\'s. Another machine may have saved since.', fix: 'ethagent restore' }) + } else if (pending) { + items.push({ code: 'publish-pending', message: 'Your newest snapshot is pinned but not onchain yet.', fix: 'ethagent save' }) + } + if (recon.vault === 'missing') items.push({ code: 'vault-missing', message: 'The Vault contract was not found.', fix: 'ethagent custody --verify' }) + if (recon.vault === 'unrecognized') items.push({ code: 'vault-unrecognized', message: 'The Vault address holds code that is not a known Vault build.', fix: 'ethagent custody --verify' }) + if (changed && changed.length > 0) { + items.push({ code: 'local-changes', message: `Local changes since the last snapshot: ${changed.join(', ')}.`, fix: 'ethagent save' }) + } + if (identity.pendingTx) { + items.push({ + code: 'transaction-unconfirmed', + message: `A ${identity.pendingTx.kind} transaction sent ${identity.pendingTx.submittedAt} was not seen to confirm: ${identity.pendingTx.hash}.`, + fix: 'Look it up on the block explorer; running the same command again resumes from chain state.', + }) + } + return items +} + +export async function runCheckCommand(args: string[], deps: HistoryDeps, seams: CheckSeams = defaultSeams): Promise { + const json = args.includes('--json') + try { + const { values, positionals } = parseHistoryArgs(args, {}, CHECK_USAGE) + if (values.help) { + await deps.io.out(HELP) + return 0 + } + if (positionals.length > 0) throw new HistoryError(2, `unexpected argument: ${positionals[0]}`, `usage: ${CHECK_USAGE}`) + const { config, identity } = await requireIdentity(deps) + if (!identity.agentId || !identity.chainId || !identity.identityRegistryAddress) { + throw new HistoryError(1, 'This identity has no agent token yet.', 'Mint one with `ethagent create`, or bring one back with `ethagent restore `.') + } + // ETHAGENT_RPC_URL replaces the saved RPC for this read, so a failing endpoint can be routed around. + const rpcOverride = deps.env.ETHAGENT_RPC_URL?.trim() + const probed = rpcOverride ? { ...identity, rpcUrl: rpcOverride } : identity + const [recon, changed] = await Promise.all([seams.reconcile(probed, config), seams.localChanges(identity)]) + const valuesView = tokenValues(identity) + const attention = attentionItems(identity, recon, changed) + const chain = { + reachable: recon.rpc === 'reachable', + token: recon.token, + onchainOwner: recon.onChainOwner ?? null, + custody: recon.custody, + agentUri: recon.agentUri, + vault: recon.vault, + vaultBuild: recon.vaultBuild?.label ?? null, + } + if (json) { + await emitJson(deps.io, { values: valuesView, chain, attention }) + } else { + const out: string[] = [] + const rows: Array<[string, unknown]> = [ + ['agent', valuesView.agentId ? `#${valuesView.agentId}` : null], + ['network', valuesView.network], + ['registry', valuesView.registry], + ['owner', valuesView.owner], + ['onchain owner', chain.onchainOwner && String(chain.onchainOwner).toLowerCase() !== String(valuesView.owner).toLowerCase() ? chain.onchainOwner : null], + ['agent URI', valuesView.agentUri], + ['snapshot CID', valuesView.snapshotCid], + ['metadata CID', valuesView.metadataCid], + ['agent card CID', valuesView.agentCardCid], + ['ENS name', valuesView.ensName], + ['custody', valuesView.custody], + ['vault', valuesView.vault ? `${valuesView.vault}${chain.vaultBuild ? ` (${chain.vaultBuild})` : ''}` : null], + ['operator', valuesView.activeOperator], + ['last saved', valuesView.lastSaved ?? 'never'], + ] + const transfer = valuesView.transfer as { state: string; receiver: string; receiverName?: string } | null + if (transfer) rows.push(['transfer', `${transfer.state === 'ready-to-transfer' ? 'snapshot ready for' : 'snapshot received for'} ${transfer.receiver}${transfer.receiverName ? ` (${transfer.receiverName})` : ''}`]) + const width = Math.max(...rows.map(([label]) => label.length)) + for (const [label, value] of rows) { + if (value === null || value === undefined || value === '') continue + out.push(`${label.padEnd(width)} ${String(value)}`) + } + out.push('') + if (attention.length === 0) { + out.push('Nothing needs attention.') + } else { + out.push('Needs attention:') + for (const item of attention) out.push(` ${item.message}${item.fix ? `\n fix: ${item.fix}` : ''}`) + } + await deps.io.out(`${out.join('\n')}\n`) + } + return attention.length > 0 ? 4 : 0 + } catch (err) { + return failFrom(deps.io, json, err) + } +} diff --git a/src/cli/onchain/commands.ts b/src/cli/onchain/commands.ts index 71eada0..81f2f53 100644 --- a/src/cli/onchain/commands.ts +++ b/src/cli/onchain/commands.ts @@ -1,3 +1,3 @@ // Onchain identity commands. They load lazily, next to the history verbs, before // --help and bootstrap. -export const ONCHAIN_COMMANDS = new Set(['custody', 'ens', 'restore', 'profile', 'create', 'storage', 'transfer']) +export const ONCHAIN_COMMANDS = new Set(['custody', 'ens', 'restore', 'profile', 'create', 'storage', 'transfer', 'check']) diff --git a/src/cli/onchain/index.ts b/src/cli/onchain/index.ts index a7eabca..ca8b813 100644 --- a/src/cli/onchain/index.ts +++ b/src/cli/onchain/index.ts @@ -11,6 +11,7 @@ export async function runOnchainCommand(verb: string, args: string[], baseDeps: case 'create': return (await import('./create.js')).runCreateCommand(args, deps) case 'storage': return (await import('./storage.js')).runStorageCommand(args, deps) case 'transfer': return (await import('./transfer.js')).runTransferCommand(args, deps) + case 'check': return (await import('./check.js')).runCheckCommand(args, deps) default: return 2 } } From 46475136325d849c68990838e38b90dd102651f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 22:32:49 +0000 Subject: [PATCH 2/3] feat: ethagent skills lists, publishes, hides, and deletes skills Visibility changes rewrite the frontmatter and the agent card. Delete previews until --yes, checkpoints first, and names the rollback that brings it back. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HG2miyE22qTuug1Quxj7iw --- src/cli/history/commands.ts | 2 +- src/cli/history/index.ts | 1 + src/cli/history/skills.ts | 158 ++++++++++++++++++++++++++++++++++++ 3 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 src/cli/history/skills.ts diff --git a/src/cli/history/commands.ts b/src/cli/history/commands.ts index cbd3c6f..5dc0433 100644 --- a/src/cli/history/commands.ts +++ b/src/cli/history/commands.ts @@ -1 +1 @@ -export const HISTORY_COMMANDS = new Set(['status', 'history', 'show', 'diff', 'fetch', 'checkpoint', 'rollback', 'forget']) +export const HISTORY_COMMANDS = new Set(['status', 'history', 'show', 'diff', 'fetch', 'checkpoint', 'rollback', 'forget', 'skills']) diff --git a/src/cli/history/index.ts b/src/cli/history/index.ts index dd82f7e..637d163 100644 --- a/src/cli/history/index.ts +++ b/src/cli/history/index.ts @@ -12,6 +12,7 @@ export async function runHistoryCommand(verb: string, args: string[], baseDeps: case 'checkpoint': return (await import('./checkpoint.js')).runCheckpointCommand(args, deps) case 'rollback': return (await import('./rollback.js')).runRollbackCommand(args, deps) case 'forget': return (await import('./forget.js')).runForgetCommand(args, deps) + case 'skills': return (await import('./skills.js')).runSkillsCommand(args, deps) default: return 2 } } diff --git a/src/cli/history/skills.ts b/src/cli/history/skills.ts new file mode 100644 index 0000000..b4e3fb0 --- /dev/null +++ b/src/cli/history/skills.ts @@ -0,0 +1,158 @@ +import type { EthagentIdentity } from '../../storage/config.js' +import { continuityVaultRef } from '../../identity/continuity/storage.js' +import { continuityVaultStatus } from '../../identity/continuity/storage/status.js' +import { + deleteSkillEntry, + invalidateSkillsCache, + listSkillEntriesView, + setSkillVisibility, +} from '../../identity/continuity/skills/loadSkills.js' +import { packedAgentCard, renderAgentCardJson, syncAgentCardManifest } from '../../identity/continuity/skills/publicSkillsSync.js' +import { isDraftScaffold } from '../../identity/continuity/skills/scaffold.js' +import { readOrDefault } from '../../identity/continuity/storage/files.js' +import type { SkillIndexEntry, SkillVisibility } from '../../identity/continuity/skills/types.js' +import { checkpointVault } from '../../identity/continuity/snapshotCapture.js' +import { emitJson, failFrom, HistoryError, parseHistoryArgs, requireIdentity, type HistoryDeps } from './shared.js' + +export const SKILLS_USAGE = 'ethagent skills [--public | --private | --delete [--yes]] [--json]' + +const HELP = [ + `usage: ${SKILLS_USAGE}`, + '', + ' ethagent skills every skill in the vault, its visibility, and whether the', + ' Agent Card publishes it. Read-only.', + ' ethagent skills --public publish the skill\'s name and description on the Agent Card.', + ' ethagent skills --private take it off the Agent Card.', + ' ethagent skills --delete remove the skill folder (preview, then --yes). A checkpoint', + ' is taken first, and the output names the rollback that', + ' brings it back.', + '', + ' is the skill\'s folder under skills/. Changes are local; `ethagent save` publishes them.', + 'A skill without a real description is a draft: the Agent Card leaves it out even when public.', + '', +].join('\n') + +export type SkillsSeams = { + vaultReady: (identity: EthagentIdentity) => Promise + checkpoint: (identity: EthagentIdentity, label: string) => Promise +} + +const defaultSeams: SkillsSeams = { + vaultReady: async identity => (await continuityVaultStatus(identity).catch(() => ({ ready: false }))).ready, + checkpoint: async (identity, label) => (await checkpointVault(identity, 'manual', { label }))?.id ?? null, +} + +type Change = { kind: 'public' | 'private' | 'delete'; name: string } + +function parseChange(values: Record): Change | null { + const chosen = (['public', 'private', 'delete'] as const).filter(flag => values[flag] !== undefined) + if (chosen.length > 1) throw new HistoryError(2, `choose one of ${chosen.map(flag => `--${flag}`).join(', ')}`, `usage: ${SKILLS_USAGE}`) + const kind = chosen[0] + if (!kind) return null + const name = String(values[kind]).trim().replace(/\/+$/, '').replace(/\/SKILL\.md$/i, '') + if (!name) throw new HistoryError(2, `--${kind} needs a skill name`, `usage: ${SKILLS_USAGE}`) + return { kind, name } +} + +function skillView(entry: SkillIndexEntry): Record { + const draft = isDraftScaffold(entry) + return { + name: entry.name, + ...(entry.displayName && entry.displayName !== entry.name ? { displayName: entry.displayName } : {}), + description: entry.description, + visibility: entry.visibility, + onAgentCard: entry.visibility === 'public' && !draft, + draft, + path: `skills/${entry.relativePath}`, + } +} + +export async function runSkillsCommand(args: string[], deps: HistoryDeps, seams: SkillsSeams = defaultSeams): Promise { + const json = args.includes('--json') + try { + const { values, positionals } = parseHistoryArgs(args, { + public: { type: 'string' }, + private: { type: 'string' }, + delete: { type: 'string' }, + yes: { type: 'boolean' }, + }, SKILLS_USAGE) + if (values.help) { + await deps.io.out(HELP) + return 0 + } + if (positionals.length > 0) throw new HistoryError(2, `unexpected argument: ${positionals[0]}`, `usage: ${SKILLS_USAGE}`) + const change = parseChange(values) + if (values.yes && change?.kind !== 'delete') throw new HistoryError(2, '--yes only applies to --delete', `usage: ${SKILLS_USAGE}`) + const { identity } = await requireIdentity(deps) + if (!(await seams.vaultReady(identity))) { + throw new HistoryError(1, 'Local continuity files are not restored on this machine.', 'Bring them back with `ethagent restore`.') + } + const ref = continuityVaultRef(identity) + const entries = await listSkillEntriesView(ref.skillsDir) + + if (!change) { + const current = await readOrDefault(ref.agentCardPath, '') + const cardStale = packedAgentCard(current, renderAgentCardJson(identity, entries)).stale + const skills = entries.map(skillView) + if (json) { + await emitJson(deps.io, { skills, agentCardInSync: !cardStale }) + } else if (skills.length === 0) { + await deps.io.out(`No skills yet. Add a folder with a SKILL.md under ${ref.skillsDir}.\n`) + } else { + const width = Math.max(...skills.map(skill => String(skill.name).length)) + const lines = skills.map(skill => { + const tag = skill.onAgentCard ? 'public' : skill.visibility === 'public' ? 'public (draft, not on the card)' : 'private' + return `${String(skill.name).padEnd(width)} ${tag.padEnd(7)} ${String(skill.description)}` + }) + if (cardStale) lines.push('', 'The agent card file is behind these skills; it is rewritten on the next change or save.') + await deps.io.out(`${lines.join('\n')}\n`) + } + return 0 + } + + const entry = entries.find(item => item.name === change.name) + if (!entry) { + const known = entries.map(item => item.name) + throw new HistoryError(1, `No skill named ${change.name}.`, known.length ? `Skills in this vault: ${known.join(', ')}.` : 'This vault has no skills yet.') + } + + if (change.kind === 'delete') { + if (!values.yes) { + const preview = { applied: false, action: 'delete', skill: skillView(entry), removes: `skills/${entry.name}/` } + if (json) await emitJson(deps.io, preview) + else await deps.io.out(`Preview: removes skills/${entry.name}/ and everything in it${entry.visibility === 'public' ? ', and takes it off the Agent Card' : ''}. A checkpoint is taken first. Run again with --yes.\n`) + return 0 + } + const checkpoint = await seams.checkpoint(identity, `before skills --delete ${entry.name}`) + await deleteSkillEntry(identity, entry.relativePath) + invalidateSkillsCache(identity) + await syncAgentCardManifest(identity) + const undo = checkpoint ? `ethagent rollback cp:${checkpoint} --yes` : null + if (json) await emitJson(deps.io, { applied: true, action: 'delete', name: entry.name, checkpoint, undo }) + else await deps.io.out(`Deleted skills/${entry.name}/.${undo ? ` Undo with \`${undo}\`.` : ''} \`ethagent save\` publishes the change.\n`) + return 0 + } + + const visibility: SkillVisibility = change.kind + const unchanged = entry.visibility === visibility + if (!unchanged) { + await setSkillVisibility(identity, entry.relativePath, visibility) + invalidateSkillsCache(identity) + } + await syncAgentCardManifest(identity) + const draft = isDraftScaffold(entry) + const onAgentCard = visibility === 'public' && !draft + if (json) { + await emitJson(deps.io, { applied: !unchanged, name: entry.name, visibility, onAgentCard, draft }) + } else { + const lines = [unchanged ? `${entry.name} is already ${visibility}.` : `${entry.name} is now ${visibility}.`] + if (visibility === 'public' && draft) lines.push('It has no real description yet, so the Agent Card leaves it out until it does.') + else if (visibility === 'public') lines.push('Its name and description go on the Agent Card; keep personal details out of them.') + if (!unchanged) lines.push('`ethagent save` publishes the change.') + await deps.io.out(`${lines.join('\n')}\n`) + } + return 0 + } catch (err) { + return failFrom(deps.io, json, err) + } +} From b3db98a3a64fb575192ed9cd6479581686a3da2e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 22:32:49 +0000 Subject: [PATCH 3/3] docs: document check and skills, point hints at headless commands Messages that sent people to the interactive manager now name the command that does it, including save's pending-publish note. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HG2miyE22qTuug1Quxj7iw --- README.md | 16 ++- commands/ethagent.md | 6 +- src/cli/bootstrap.ts | 2 +- src/cli/harnessGuidance.ts | 4 +- src/cli/history/shared.ts | 2 +- src/cli/main.tsx | 2 + src/cli/onchain/custody.ts | 4 +- src/cli/onchain/ens.ts | 8 +- src/cli/operatorSave.ts | 18 +-- src/cli/save.ts | 14 +-- src/cli/sessionStart.ts | 4 +- src/cli/vaultDir.ts | 2 +- test/cli/checkAndSkills.test.ts | 197 ++++++++++++++++++++++++++++++++ test/run.ts | 1 + 14 files changed, 248 insertions(+), 32 deletions(-) create mode 100644 test/cli/checkAndSkills.test.ts diff --git a/README.md b/README.md index 9da89aa..b6762f8 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,7 @@ The foundation is built on open standards, so your agent is never tied to one ap | To | Run | | --- | --- | | Back up your agent onchain | `ethagent save` | +| See every identifier and what needs attention | `ethagent check` | | See what changed since the last save | `ethagent status` | | See what changed in memory since a date | `ethagent diff at:YYYY-MM-DD working --file MEMORY.md --sections` | | Find when a rule first appeared | `ethagent history --file MEMORY.md --sections` | @@ -158,6 +159,9 @@ The foundation is built on open standards, so your agent is never tied to one ap | Remove leaked content | Edit it out, `ethagent save`, then `ethagent forget --file --yes` for each version that held it | | Connect another tool | `ethagent --add ""` | | Find the vault | `ethagent --vault-dir` | +| List skills and what the Agent Card shows | `ethagent skills` | +| Make a skill public or private | `ethagent skills --public ` or `ethagent skills --private ` | +| Delete a skill | `ethagent skills --delete `, then again with `--yes` | | See the Vault, its build, and who may do what | `ethagent custody`, or `ethagent custody --verify` to simulate each permission | | Check the agent's ENS name | `ethagent ens` | | Point the agent at another ENS name | `ethagent ens `, then again with `--yes` | @@ -188,6 +192,10 @@ Run any of these with `npx ethagent`. Commands marked interactive need a termina | `pause` / `resume` | Stop or restart background sync. | Local | | `--status` | One-line summary: agent, network, local changes, connected tools. | Read-only | | `--vault-dir` | Print the vault path. | Read-only | +| `check [--json]` | Every value that identifies the agent: token ID, network, registry, owner, agent URI, snapshot, metadata and agent card CIDs, ENS name, custody, Vault, operator, last saved, pending publish, transfer state, and any unconfirmed transaction. Then anything that needs attention, each with the command that fixes it. Exits 4 when something does. | Goes online | +| `skills [--json]` | Every skill in the vault, its visibility, and whether the Agent Card publishes it. | Read-only | +| `skills --public \| --private [--json]` | Change a skill's visibility and update the Agent Card. `ethagent save` publishes it. | Writes the vault | +| `skills --delete [--yes] [--json]` | Remove a skill's folder. Previews until `--yes`, checkpoints first, and names the rollback that brings it back. | Writes the vault | | `reset [--yes]` | Delete the local identity, vault, history, and saved secrets (the IPFS storage credential is kept), and disconnect your tools. Asks first unless `--yes`. | Deletes local data | | `status [--verify] [--json]` | What changed since the latest snapshot, per file and per skill. | Read-only; `--verify` goes online | | `history [--json]` | Snapshots and checkpoints, newest first. | Read-only | @@ -235,7 +243,7 @@ Run any of these with `npx ethagent`. Commands marked interactive need a termina | `1` | Failed. | | `2` | Usage error. | | `3` | No working storage credential, no operator key, or the wallet was cancelled or timed out. | -| `4` | Pinned, but the owner still needs to publish it. | +| `4` | Pinned, but the owner still needs to publish it: run `ethagent save` again and approve with the owner wallet. | ### Onchain identity @@ -249,7 +257,7 @@ Run any of these with `npx ethagent`. Commands marked interactive need a termina ### Setting up and moving an agent -`create`, `restore`, `profile`, `transfer`, `storage`, and the custody changes run without the manager too. Everything the manager can do now has a command. +`create`, `restore`, `profile`, `transfer`, `storage`, and the custody changes run without the manager too. Everything the manager can do has a command, except deleting an ENS subname, which stays in the manager on purpose; `ens --unlink` clears the agent's records instead. - **Previews.** Anything that signs or sends previews first: every signature and transaction, who signs it, and what can be simulated now. Nothing happens without `--yes`. - **One tab.** Each command runs all its wallet prompts in one browser tab. `--no-open` prints the link instead of opening it. @@ -380,6 +388,8 @@ Instructions for the agent. What a save packs: folder and file names of letters, digits, `.`, `_`, and `-`; files with an extension; up to 4 folders deep; up to 2 MiB per file and 500 files in total. Build caches (`__pycache__`, `.pyc`, `node_modules`) and dotfiles are left out. Files are stored as text, so `ethagent status` lists anything a save would skip or couldn't store exactly. +`ethagent skills` lists the skills and which ones the Agent Card publishes; `--public ` and `--private ` set `visibility` for you. A public skill without a real description is treated as a draft and left off the card. + ### Files and locations | What | Where | Notes | @@ -435,7 +445,7 @@ With `--json`, every history and onchain command prints one line of ASCII-only J | `1` | Failed: unknown ref, missing path, or a store error. | | `2` | Usage error, or a ref that matches more than one thing. | | `3` | The snapshot isn't cached here or there's no key to open it, `--operator` ran without an injected operator key, or the wallet approval was cancelled. | -| `4` | Partly done (a custody change or `create --advanced` that stopped after something landed; run it again to finish), or `status --verify` or `custody --verify` found a mismatch. | +| `4` | Partly done (a custody change or `create --advanced` that stopped after something landed; run it again to finish), `status --verify` or `custody --verify` found a mismatch, or `check` found something that needs attention. | ### Environment variables diff --git a/commands/ethagent.md b/commands/ethagent.md index 216a7f5..f0655ac 100644 --- a/commands/ethagent.md +++ b/commands/ethagent.md @@ -8,21 +8,23 @@ Most of these run in a separate terminal because they need a wallet or a TTY, bu - `npx ethagent` opens the interactive identity manager. Every action in it also has a headless command (`create`, `restore`, `profile`, `custody`, `ens`, `transfer`, `storage`); see "Onchain identity" below. - `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. - Syncing is automatic: ethagent keeps the agent's soul, memory, and skills in step across every detected tool in the background, both ways, newest edit wins. There is no sync command to run. Pause it with `npx ethagent pause` and resume with `npx ethagent resume`. +- `npx ethagent check` lists every value that identifies the agent (token ID, owner, agent URI, snapshot, metadata and agent card CIDs, registry, Vault, last saved, pending publish, transfer state) and anything that needs attention, each with the command that fixes it. Read-only and safe to run yourself; it exits 4 when something needs attention. - `npx ethagent --status` prints a short summary: agent id, chain, address, any local changes, the vault path, and the connected tools. - `npx ethagent --vault-dir` prints this agent's vault directory (where soul, memory, and the `skills/` folder live). Read-only and safe to run yourself. - `npx ethagent reset` deletes the local identity, vault, history, and saved secrets (the IPFS storage credential is kept), and disconnects every tool. It asks to confirm in a terminal, so it hangs inside a session unless you pass `--yes`. Run it only when the user explicitly asks. - `npx ethagent status`, `history`, `show`, `diff`, `fetch`, `checkpoint`, `rollback`, and `forget` manage continuity history headlessly. See "Continuity history" below. +- `npx ethagent skills` lists the vault's skills, their visibility, and which ones the Agent Card publishes. Read-only and safe to run yourself. `skills --public ` and `skills --private ` change a skill's visibility, and `skills --delete ` removes one (preview, then `--yes`; it checkpoints first and names the rollback that undoes it). Run those only when the user asks; `ethagent save` publishes the change. - `npx ethagent custody` and `npx ethagent ens` inspect and manage the onchain identity headlessly. See "Onchain identity" below. 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. -You may run the read-only non-interactive commands yourself whenever they help (`--status`, `--vault-dir`, and the read-only history commands below). `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` from inside a session: it opens a full-screen terminal app that needs a TTY and will hang the tool call. For anything else that signs or sends (`create`, `restore`, `profile`, `custody`, `ens`, `transfer`), run the preview yourself and leave the `--yes` run to the user. +You may run the read-only non-interactive commands yourself whenever they help (`check`, `--status`, `--vault-dir`, `skills`, and the read-only history commands below). `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` from inside a session: it opens a full-screen terminal app that needs a TTY and will hang the tool call. For anything else that signs or sends (`create`, `restore`, `profile`, `custody`, `ens`, `transfer`), run the preview yourself and leave the `--yes` run to the user. Where the synced files land: - Every skill, public and private, is mirrored into `~/.claude/skills/` (per-skill folders) for Claude Code and into the managed block in `~/.codex/AGENTS.md` for Codex. - The vault at `~/.ethagent/continuity/` is the source of truth and holds everything as plain files on this machine. Nothing leaves it unencrypted: a save encrypts soul, memory, and every skill before it pins them. -- `~/.claude/skills/` is a read-only generated mirror; never create or edit files there (the sync overwrites it from the vault). To add or change a skill, put its folder directly in the vault skills dir: run `npx ethagent --vault-dir` (non-interactive, safe to run yourself) to print the vault path, then create or edit the `/SKILL.md` folder inside its `skills/` subdir. Skills are private by default; set one to public only when it should be listed on the Agent Card. +- `~/.claude/skills/` is a read-only generated mirror; never create or edit files there (the sync overwrites it from the vault). To add or change a skill, put its folder directly in the vault skills dir: run `npx ethagent --vault-dir` (non-interactive, safe to run yourself) to print the vault path, then create or edit the `/SKILL.md` folder inside its `skills/` subdir. Skills are private by default; set one to public (`ethagent skills --public `) only when the user wants it listed on the Agent Card. Privacy and secrets: diff --git a/src/cli/bootstrap.ts b/src/cli/bootstrap.ts index a8e99f2..05914e2 100644 --- a/src/cli/bootstrap.ts +++ b/src/cli/bootstrap.ts @@ -30,7 +30,7 @@ function stampState(): void { export async function runBootstrap(opts: { quiet?: boolean } = {}): Promise { const config = await loadConfig() if (!config?.identity) { - if (!opts.quiet) process.stdout.write('ethagent: no identity yet; run `npx ethagent` to create or link one first\n') + if (!opts.quiet) process.stdout.write('ethagent: no identity yet; run `npx ethagent create` or `npx ethagent restore ` first\n') return 0 } diff --git a/src/cli/harnessGuidance.ts b/src/cli/harnessGuidance.ts index 0b9660a..b268eb1 100644 --- a/src/cli/harnessGuidance.ts +++ b/src/cli/harnessGuidance.ts @@ -19,7 +19,9 @@ export function buildPortableInstruction(opts: PortableInstructionOptions): stri 'put its folder in the vault skills dir (NOT this read-only mirror): run `npx ethagent --vault-dir` to print the vault ' + "path, then create or edit the `/SKILL.md` folder inside its `skills/` subdir. Past snapshots are kept locally " + 'byte for byte; `npx ethagent status --json` and `npx ethagent diff --json` show what changed. ' + - '`npx ethagent custody --json`, `ens --json`, and `profile --json` show the onchain identity; `create`, `restore`, ' + + '`npx ethagent check --json` lists the agent\'s identifiers and what needs attention; `custody --json`, `ens --json`, ' + + '`profile --json`, and `skills --json` show the rest and are read-only. Change skill visibility or delete a skill with ' + + '`skills --public`, `--private`, or `--delete` only when the user asks. `create`, `restore`, ' + "`profile`, `custody`, `ens`, and `transfer` preview without `--yes`, and anything with `--yes` is the user's to run." ) } diff --git a/src/cli/history/shared.ts b/src/cli/history/shared.ts index 700e920..6c2854e 100644 --- a/src/cli/history/shared.ts +++ b/src/cli/history/shared.ts @@ -93,7 +93,7 @@ export function parseHistoryArgs(args: string[], options: ParseArgsConfig['optio export async function requireIdentity(deps: HistoryDeps): Promise<{ config: EthagentConfig; identity: EthagentIdentity }> { const config = await deps.loadConfig().catch(() => null) if (!config?.identity) { - throw new HistoryError(1, 'No agent identity yet.', 'Create or link one by running `npx ethagent` in a terminal.') + throw new HistoryError(1, 'No agent identity yet.', 'Mint one with `ethagent create`, or bring one back with `ethagent restore `.') } return { config, identity: config.identity } } diff --git a/src/cli/main.tsx b/src/cli/main.tsx index 4968a15..7114d04 100644 --- a/src/cli/main.tsx +++ b/src/cli/main.tsx @@ -61,6 +61,7 @@ function printHelp(): void { ' ethagent checkpoint [label] save a local checkpoint', ' ethagent rollback restore past bytes (preview, then --yes)', ' ethagent forget erase local history (preview, then --yes)', + ' ethagent skills list skills; --public, --private, or --delete (delete previews)', '', 'setting up and moving an agent (preview first, act with --yes; --help for details):', ' ethagent create mint a new agent (--name, --network, --advanced, --import)', @@ -70,6 +71,7 @@ function printHelp(): void { ' ethagent storage IPFS storage credential (--set reads it from stdin)', '', 'onchain identity (add --json for machine output, --help for details):', + ' ethagent check every identifier, and what needs attention with the fix', ' ethagent custody [--verify] Vault, build, holder, and operators; --verify simulates permissions', ' ethagent custody --advanced | --simple | --add-operator | --remove-operator | --activate-operator', ' change custody (preview, then --yes)', diff --git a/src/cli/onchain/custody.ts b/src/cli/onchain/custody.ts index a62d2e9..4c117da 100644 --- a/src/cli/onchain/custody.ts +++ b/src/cli/onchain/custody.ts @@ -115,9 +115,9 @@ export async function runCustodyCommand( }, deps, config, identity, writeSeams) } if (values.yes || values.operator) throw new HistoryError(2, '--yes and --operator need a change: --advanced, --simple, or an operator flag', `usage: ${CUSTODY_USAGE}`) - if (!identity.agentId) throw new HistoryError(1, 'This identity has no agent token ID yet.', 'Create or restore it with `npx ethagent` first.') + if (!identity.agentId) throw new HistoryError(1, 'This identity has no agent token ID yet.', 'Mint one with `ethagent create`, or bring one back with `ethagent restore `.') const registry = resolveRegistryForIdentity(identity, config) - if (!registry) throw new HistoryError(1, 'No agent registry is configured for this identity.', 'Run `npx ethagent` to set it up.') + if (!registry) throw new HistoryError(1, 'No agent registry is configured for this identity.', '`ethagent restore ` records it.') const agentId = BigInt(identity.agentId) const state = identity.state as Record | undefined const custodyMode = readCustodyMode(state) ?? 'simple' diff --git a/src/cli/onchain/ens.ts b/src/cli/onchain/ens.ts index 5ac658f..106a94e 100644 --- a/src/cli/onchain/ens.ts +++ b/src/cli/onchain/ens.ts @@ -221,7 +221,7 @@ export async function runEnsCommand(args: string[], deps: HistoryDeps, seams: En const mode = parseMode(values, positionals) const { config, identity } = await requireIdentity(deps) const registry = resolveRegistryForIdentity(identity, config) - if (!registry) throw new HistoryError(1, 'No agent registry is configured for this identity.', 'Run `npx ethagent` to set it up.') + if (!registry) throw new HistoryError(1, 'No agent registry is configured for this identity.', '`ethagent restore ` records it.') if (mode.kind === 'read') { const view = await readView(identity, registry, deps, seams) @@ -230,7 +230,7 @@ export async function runEnsCommand(args: string[], deps: HistoryDeps, seams: En return 0 } - if (!identity.agentId) throw new HistoryError(1, 'This identity has no agent token ID yet.', 'Create or restore it with `npx ethagent` first.') + if (!identity.agentId) throw new HistoryError(1, 'This identity has no agent token ID yet.', 'Mint one with `ethagent create`, or bring one back with `ethagent restore `.') const owner = getAddress(humanOwnerAddress(identity)) const signer = chooseSigner(deps, Boolean(values.operator), owner) const client = seams.readClient() @@ -332,14 +332,14 @@ export async function runEnsCommand(args: string[], deps: HistoryDeps, seams: En let saveReady: { jwt: string } | null = null if (publishName !== null) { const vault = await seams.vaultStatus(identity).catch(() => ({ ready: false })) - if (!vault.ready) throw new HistoryError(1, 'Local continuity files are not restored.', 'Run `npx ethagent` and restore this identity before changing its name. Nothing was sent.') + if (!vault.ready) throw new HistoryError(1, 'Local continuity files are not restored.', 'Bring them back with `ethagent restore` before changing its name. Nothing was sent.') let jwt: string | undefined try { jwt = await seams.resolveJwt() } catch (err) { throw new HistoryError(3, `The configured Pinata JWT is invalid or unreachable (${err instanceof Error ? err.message : String(err)}). Nothing was sent.`) } - if (!jwt) throw new HistoryError(3, 'No IPFS storage credential configured, so the name cannot be published.', 'Set up IPFS Storage in `npx ethagent` (or export PINATA_JWT), then retry. Nothing was sent.') + if (!jwt) throw new HistoryError(3, 'No IPFS storage credential configured, so the name cannot be published.', 'Save one with `ethagent storage --set` (or export PINATA_JWT), then retry. Nothing was sent.') saveReady = { jwt } await seams.pullHarness(identity).catch(() => []) } diff --git a/src/cli/operatorSave.ts b/src/cli/operatorSave.ts index ce351d9..97bccce 100644 --- a/src/cli/operatorSave.ts +++ b/src/cli/operatorSave.ts @@ -28,24 +28,24 @@ export async function runOperatorSave(args: string[] = []): Promise { const privateKey = operatorKey.key const config = await loadConfig().catch(() => null) - if (!config?.identity) return fail(1, 'No agent identity yet. Run `npx ethagent` to create or link one.') + if (!config?.identity) return fail(1, 'No agent identity yet. Mint one with `ethagent create`, or bring one back with `ethagent restore `.') const identity = config.identity - if (!identity.agentId) return fail(1, 'This identity has no agent token ID yet. Create or restore it with `npx ethagent` first.') + if (!identity.agentId) return fail(1, 'This identity has no agent token ID yet. Mint one with `ethagent create`, or bring one back with `ethagent restore `.') const registry = resolveRegistryForIdentity(identity, config) - if (!registry) return fail(1, 'No agent registry configured for this identity. Run `npx ethagent` to set it up.') + if (!registry) return fail(1, 'No agent registry configured for this identity. `ethagent restore ` records it.') const vault = await continuityVaultStatus(identity).catch(() => ({ ready: false })) - if (!vault.ready) return fail(1, 'Local continuity files are not restored. Run `npx ethagent` and restore this identity before saving a snapshot.') + if (!vault.ready) return fail(1, 'Local continuity files are not restored. Bring them back with `ethagent restore --operator` before saving a snapshot.') const role = snapshotSaveWalletRole(identity, undefined) if (role !== 'operator') { - return fail(1, `This agent is not set up for operator-key saves (current role: ${role}). The owner must save once and authorize this operator wallet via \`npx ethagent\` -> Custody Mode.`) + return fail(1, `This agent is not set up for operator-key saves (current role: ${role}). The owner must save once and authorize this operator wallet with \`ethagent custody --add-operator\`.`) } const vaultAddress = resolveVaultAddress(identity, config.erc8004?.operatorVaults) if (!vaultAddress) { - return fail(1, 'Advanced custody is configured but the operator vault address could not be resolved. Run `npx ethagent` -> Custody Mode to repair the vault link.') + return fail(1, 'Advanced custody is configured but the operator vault address could not be resolved. `ethagent custody` shows where the token is held, and `ethagent custody --advanced` previews the repair.') } let jwt: string | undefined @@ -53,10 +53,10 @@ export async function runOperatorSave(args: string[] = []): Promise { jwt = await resolveValidatedPinataJwt() } catch (err) { const detail = err instanceof Error ? err.message : String(err) - return fail(3, `The configured Pinata JWT is invalid or unreachable (${detail}). Update it via \`npx ethagent\` -> IPFS Storage, then retry.`) + return fail(3, `The configured Pinata JWT is invalid or unreachable (${detail}). Replace it with \`ethagent storage --set\` (reads the JWT from stdin), then retry.`) } if (!jwt) { - return fail(3, 'No IPFS storage credential configured, so the snapshot cannot be pinned. Run `npx ethagent` once and set up IPFS Storage (or export PINATA_JWT), then retry.') + return fail(3, 'No IPFS storage credential configured, so the snapshot cannot be pinned. Save one with `ethagent storage --set` (or export PINATA_JWT), then retry.') } let signAndTransaction @@ -112,7 +112,7 @@ export async function runOperatorSave(args: string[] = []): Promise { if (txHash) stdout.write(` tx: ${txHash}\n`) if (agentUri) stdout.write(` agentURI: ${agentUri}\n`) } else { - stdout.write('Snapshot pinned locally, but the onchain pointer was not rotated. Retry, or publish with `npx ethagent`.\n') + stdout.write('Snapshot pinned locally, but the onchain pointer was not rotated. Retry, or publish it with the owner wallet through `ethagent save`.\n') if (cid) stdout.write(` pinned CID: ${cid}\n`) } return published ? 0 : 4 diff --git a/src/cli/save.ts b/src/cli/save.ts index ff6a84d..b66f908 100644 --- a/src/cli/save.ts +++ b/src/cli/save.ts @@ -63,23 +63,23 @@ export async function runSave(args: string[] = [], deps: RunSaveDeps = defaultDe const config = await deps.loadConfig().catch(() => null) if (!config?.identity) { - return fail(1, 'No agent identity yet. Run `npx ethagent` to create or link one.') + return fail(1, 'No agent identity yet. Mint one with `ethagent create`, or bring one back with `ethagent restore `.') } const activeConfig: EthagentConfig = config const identity = config.identity if (!identity.agentId) { - return fail(1, 'This identity has no agent token ID yet. Create or restore it with `npx ethagent` first.') + return fail(1, 'This identity has no agent token ID yet. Mint one with `ethagent create`, or bring one back with `ethagent restore `.') } const registry = resolveRegistryForIdentity(identity, activeConfig) if (!registry) { - return fail(1, 'No agent registry configured for this identity. Run `npx ethagent` to set it up.') + return fail(1, 'No agent registry configured for this identity. `ethagent restore ` records it.') } const vault = await deps.continuityVaultStatus(identity).catch(() => ({ ready: false })) if (!vault.ready) { - return fail(1, 'Local continuity files are not restored. Run `npx ethagent` and restore this identity before saving a snapshot.') + return fail(1, 'Local continuity files are not restored. Bring them back with `ethagent restore` before saving a snapshot.') } await deps.pullHarnessSoulMemoryIntoVault(identity).catch(() => []) @@ -103,10 +103,10 @@ export async function runSave(args: string[] = [], deps: RunSaveDeps = defaultDe jwt = await deps.resolveValidatedPinataJwt() } catch (err) { const detail = err instanceof Error ? err.message : String(err) - 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\`.`) + return fail(3, `The configured Pinata JWT is invalid or unreachable (${detail}). The wallet was not opened. Replace it with \`ethagent storage --set\` (reads the JWT from stdin), then retry \`ethagent save\`.`) } if (!jwt) { - 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`.') + return fail(3, 'No IPFS storage credential configured, so the snapshot cannot be pinned and the wallet was not opened. Save one with `ethagent storage --set` (reads the JWT from stdin), or export PINATA_JWT in this shell, then retry `ethagent save`.') } let completed = false @@ -191,7 +191,7 @@ export async function runSave(args: string[] = [], deps: RunSaveDeps = defaultDe stdout.write(`${detail}\n`) if (cid) stdout.write(` pinned CID: ${cid}\n`) stderr.write('NOT published onchain yet: this snapshot will not survive a reset/restore until the owner publishes it.\n') - stderr.write('Publish with the owner wallet via `npx ethagent` -> Save Snapshot (it shows a "publish" step).\n') + stderr.write('Publish it with the owner wallet: run `ethagent save` again and approve with the owner. `ethagent check` shows what is still pending.\n') } return 4 } diff --git a/src/cli/sessionStart.ts b/src/cli/sessionStart.ts index 77a4652..f4a378e 100644 --- a/src/cli/sessionStart.ts +++ b/src/cli/sessionStart.ts @@ -19,7 +19,9 @@ export function buildSessionStartContext(identity?: EthagentIdentity | null): st 'Every save is also kept locally byte for byte: `npx ethagent status`, `history`, `show`, and `diff` (add `--json`) ' + 'are read-only and safe to run anytime; run `rollback` or `forget` only when the user asks, preview first, and see ' + '`npx ethagent --help` for usage. ' + - '`npx ethagent custody`, `ens`, `profile`, and `storage` (with `--json`) show the onchain identity and are safe to run. ' + + '`npx ethagent check --json` lists every value that identifies the agent and anything that needs attention, with the ' + + 'command that fixes it; it, `custody`, `ens`, `profile`, `storage`, and `skills` (with `--json`) are read-only and safe to run. ' + + 'Change a skill\'s visibility or delete one with `skills --public`, `--private`, or `--delete` only when the user asks. ' + '`create`, `restore`, `profile`, `custody`, `ens`, and `transfer` preview their changes without `--yes`; anything with ' + '`--yes` stays the user\'s to run, except `restore --operator --yes` when the user asks you to restore or refresh the agent.' ) diff --git a/src/cli/vaultDir.ts b/src/cli/vaultDir.ts index e1ba3c3..fa62b12 100644 --- a/src/cli/vaultDir.ts +++ b/src/cli/vaultDir.ts @@ -4,7 +4,7 @@ import { continuityVaultRef } from '../identity/continuity/storage.js' export async function runVaultDir(): Promise { const config = await loadConfig().catch(() => null) if (!config?.identity) { - process.stderr.write('ethagent: no identity yet; run `npx ethagent` to create one (the vault exists once an identity is set)\n') + process.stderr.write('ethagent: no identity yet; run `npx ethagent create` or `npx ethagent restore ` (the vault exists once an identity is set)\n') return 1 } process.stdout.write(continuityVaultRef(config.identity).dir + '\n') diff --git a/test/cli/checkAndSkills.test.ts b/test/cli/checkAndSkills.test.ts new file mode 100644 index 0000000..e43e15f --- /dev/null +++ b/test/cli/checkAndSkills.test.ts @@ -0,0 +1,197 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import path from 'node:path' +import { getAddress } from 'viem' +import { runCheckCommand, type CheckSeams } from '../../src/cli/onchain/check.js' +import { runSkillsCommand } from '../../src/cli/history/skills.js' +import { runHistoryCommand } from '../../src/cli/history/index.js' +import type { HistoryDeps } from '../../src/cli/history/shared.js' +import type { AgentReconciliation } from '../../src/identity/manager/shared/reconciliation/agentReconciliation/types.js' +import { continuityVaultRef } from '../../src/identity/continuity/storage/paths.js' +import type { EthagentConfig, EthagentIdentity } from '../../src/storage/config.js' +import { captureIo, withHome, type CapturedIo } from '../support/home.js' + +const OWNER = getAddress('0xA1E9000000000000000000000000000000000001') +const VAULT = getAddress('0x6bdC0000000000000000000000000000000051d7') +const REGISTRY = getAddress('0x8004A169FB4a3325136EB29fA0ceB6D2e539a432') +const CID = 'bafkreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1' + +function identity(extra: Partial = {}, state: Record = {}): EthagentIdentity { + return { + source: 'erc8004', address: OWNER, ownerAddress: OWNER, createdAt: '2026-01-01T00:00:00.000Z', + chainId: 8453, rpcUrl: 'https://mainnet.base.org', identityRegistryAddress: REGISTRY, + agentId: '45744', agentUri: `ipfs://${CID}`, metadataCid: CID, + state: { name: 'meow', description: 'test agent', ensName: 'meow.example.eth', custodyMode: 'simple', ...state }, + backup: { cid: 'bafysnapshot', createdAt: '2026-02-01T00:00:00.000Z', envelopeVersion: '1', ipfsApiUrl: 'https://uploads.pinata.cloud/v3/files', status: 'pinned', metadataCid: CID }, + agentCard: { cid: 'bafycard' }, + ...extra, + } as EthagentIdentity +} + +function deps(id: EthagentIdentity | null, env: NodeJS.ProcessEnv = {}): HistoryDeps & { io: CapturedIo } { + return { + io: captureIo(), + env, + now: () => new Date('2026-03-01T00:00:00.000Z'), + loadConfig: async () => (id ? { version: 2, firstSeenAt: id.createdAt, identity: id } as EthagentConfig : null), + listLedger: async () => [], + } +} + +function recon(overrides: Partial = {}): AgentReconciliation { + return { + token: 'linked', onChainOwner: OWNER, custody: 'simple', agentUri: 'in-sync', vault: 'unset', + workingTree: 'clean', rpc: 'reachable', driftCount: 0, lastCheckedAt: '', ...overrides, + } +} + +function checkSeams(r: AgentReconciliation, changed: string[] | null = [], seen: { rpcUrl?: string } = {}): CheckSeams { + return { + reconcile: async id => { seen.rpcUrl = id.rpcUrl; return r }, + localChanges: async () => changed, + } +} + +// --- check ------------------------------------------------------------------------ + +test('check lists every token value and exits 0 when nothing needs attention', async () => { + const d = deps(identity()) + assert.equal(await runCheckCommand(['--json'], d, checkSeams(recon())), 0) + const out = d.io.json() as Record + assert.equal(out.values.agentId, '45744') + assert.equal(out.values.network, 'base') + assert.equal(out.values.registry, REGISTRY) + assert.equal(out.values.owner, OWNER) + assert.equal(out.values.agentUri, `ipfs://${CID}`) + assert.equal(out.values.snapshotCid, 'bafysnapshot') + assert.equal(out.values.metadataCid, CID) + assert.equal(out.values.agentCardCid, 'bafycard') + assert.equal(out.values.ensName, 'meow.example.eth') + assert.equal(out.values.custody, 'simple') + assert.equal(out.values.pendingPublish, false) + assert.equal(out.values.transfer, null) + assert.deepEqual(out.attention, []) + const text = deps(identity()) + assert.equal(await runCheckCommand([], text, checkSeams(recon())), 0) + assert.match(text.io.stdout(), /Nothing needs attention/) +}) + +test('check names the fix for each thing that needs attention and exits 4', async () => { + const pending = identity({ backup: { ...identity().backup!, metadataCid: 'bafyold' } }, { custodyMode: 'advanced', operatorVaultAddress: VAULT }) + const pendingTx = { hash: '0xabc', kind: 'vault-deposit' as const, chainId: 8453, submittedAt: '2026-02-02T00:00:00.000Z' } + const d = deps({ ...pending, pendingTx }) + assert.equal(await runCheckCommand(['--json'], d, checkSeams(recon({ agentUri: 'local-newer', vault: 'unrecognized' }), ['MEMORY.md'])), 4) + const out = d.io.json() as Record + assert.equal(out.values.vault, VAULT) + assert.equal(out.values.pendingPublish, true) + const byCode = Object.fromEntries((out.attention as Array<{ code: string; fix?: string }>).map(item => [item.code, item.fix])) + assert.equal(byCode['publish-pending'], 'ethagent save') + assert.equal(byCode['vault-unrecognized'], 'ethagent custody --verify') + assert.equal(byCode['local-changes'], 'ethagent save') + assert.ok('transaction-unconfirmed' in byCode) +}) + +test('check reports an unfinished custody switch and a token that left the wallet', async () => { + const d = deps(identity()) + assert.equal(await runCheckCommand(['--json'], d, checkSeams(recon({ custody: 'mid-flow-uri-pending', agentUri: 'local-newer', token: 'unlinked', tokenAgentId: '45744', onChainOwner: VAULT }))), 4) + const attention = d.io.json().attention as Array<{ code: string; fix?: string }> + assert.deepEqual(attention.map(item => item.code), ['token-unlinked', 'custody-unfinished']) + assert.equal(attention[1]!.fix, 'ethagent custody --advanced') +}) + +test('check reads through ETHAGENT_RPC_URL and refuses without an agent', async () => { + const seen: { rpcUrl?: string } = {} + const d = deps(identity(), { ETHAGENT_RPC_URL: 'https://rpc.example.invalid' }) + assert.equal(await runCheckCommand(['--json'], d, checkSeams(recon(), [], seen)), 0) + assert.equal(seen.rpcUrl, 'https://rpc.example.invalid') + const none = deps(null) + assert.equal(await runCheckCommand(['--json'], none, checkSeams(recon())), 1) + assert.match(String(none.io.json().hint), /ethagent create/) + const extra = deps(identity()) + assert.equal(await runCheckCommand(['extra', '--json'], extra, checkSeams(recon())), 2) +}) + +// --- skills ----------------------------------------------------------------------- + +async function seedSkills(id: EthagentIdentity): Promise { + const ref = continuityVaultRef(id) + await fs.mkdir(path.join(ref.skillsDir, 'alpha', 'scripts'), { recursive: true }) + await fs.mkdir(path.join(ref.skillsDir, 'draft'), { recursive: true }) + await fs.writeFile(ref.soulPath, '# SOUL.md\n') + await fs.writeFile(ref.memoryPath, '# MEMORY.md\n') + await fs.writeFile(path.join(ref.skillsDir, 'alpha', 'SKILL.md'), '---\nname: alpha\ndescription: Summarizes release notes.\nvisibility: private\n---\nbody\n') + await fs.writeFile(path.join(ref.skillsDir, 'alpha', 'scripts', 'run.py'), 'print(1)\n') + await fs.writeFile(path.join(ref.skillsDir, 'draft', 'SKILL.md'), '---\nname: draft\ndescription: \nvisibility: public\n---\nbody\n') + return ref.skillsDir +} + +const ready = { vaultReady: async () => true, checkpoint: undefined as never } + +test('skills lists visibility and what the Agent Card publishes, leaving drafts off', async () => { + await withHome(async () => { + const id = identity() + await seedSkills(id) + const d = deps(id) + assert.equal(await runSkillsCommand(['--json'], d), 0, d.io.stdout()) + const skills = d.io.json().skills as Array> + assert.deepEqual(skills.map(skill => [skill.name, skill.visibility, skill.onAgentCard, skill.draft]), [ + ['alpha', 'private', false, false], + ['draft', 'public', false, true], + ]) + }) +}) + +test('skills --public and --private rewrite the frontmatter and the agent card', async () => { + await withHome(async () => { + const id = identity() + const skillsDir = await seedSkills(id) + const pub = deps(id) + assert.equal(await runSkillsCommand(['--public', 'alpha', '--json'], pub), 0, pub.io.stdout()) + assert.equal(pub.io.json().onAgentCard, true) + assert.match(await fs.readFile(path.join(skillsDir, 'alpha', 'SKILL.md'), 'utf8'), /visibility: public/) + assert.match(await fs.readFile(continuityVaultRef(id).agentCardPath, 'utf8'), /Summarizes release notes/) + const again = deps(id) + assert.equal(await runSkillsCommand(['--public', 'alpha', '--json'], again), 0) + assert.equal(again.io.json().applied, false) + const priv = deps(id) + assert.equal(await runSkillsCommand(['--private', 'alpha/', '--json'], priv), 0) + assert.doesNotMatch(await fs.readFile(continuityVaultRef(id).agentCardPath, 'utf8'), /Summarizes release notes/) + }) +}) + +test('skills --delete previews, then checkpoints, deletes, and the named rollback brings it back', async () => { + await withHome(async () => { + const id = identity() + const skillsDir = await seedSkills(id) + const preview = deps(id) + assert.equal(await runSkillsCommand(['--delete', 'alpha', '--json'], preview), 0) + assert.equal(preview.io.json().applied, false) + await fs.access(path.join(skillsDir, 'alpha', 'SKILL.md')) + const run = deps(id) + assert.equal(await runSkillsCommand(['--delete', 'alpha', '--yes', '--json'], run), 0, run.io.stdout()) + const undo = String(run.io.json().undo) + assert.match(undo, /^ethagent rollback cp:\S+ --yes$/) + await assert.rejects(fs.access(path.join(skillsDir, 'alpha'))) + const back = deps(id) + assert.equal(await runHistoryCommand('rollback', [undo.split(' ')[2]!, '--yes', '--json'], back), 0, back.io.stdout()) + assert.equal(await fs.readFile(path.join(skillsDir, 'alpha', 'scripts', 'run.py'), 'utf8'), 'print(1)\n') + }) +}) + +test('skills refuses unknown names, mixed flags, a stray --yes, and an unrestored vault', async () => { + await withHome(async () => { + const id = identity() + await seedSkills(id) + const unknown = deps(id) + assert.equal(await runSkillsCommand(['--public', 'nope', '--json'], unknown), 1) + assert.match(String(unknown.io.json().hint), /alpha, draft/) + const both = deps(id) + assert.equal(await runSkillsCommand(['--public', 'alpha', '--delete', 'alpha', '--json'], both), 2) + const yes = deps(id) + assert.equal(await runSkillsCommand(['--public', 'alpha', '--yes', '--json'], yes), 2) + const notReady = deps(id) + assert.equal(await runSkillsCommand(['--json'], notReady, { ...ready, vaultReady: async () => false }), 1) + assert.match(String(notReady.io.json().hint), /ethagent restore/) + }) +}) diff --git a/test/run.ts b/test/run.ts index a3a2a67..6c36f33 100644 --- a/test/run.ts +++ b/test/run.ts @@ -21,6 +21,7 @@ import './cli/restoreCommand.test.js' import './cli/profileCommand.test.js' import './cli/custodyWrite.test.js' import './cli/setupCommands.test.js' +import './cli/checkAndSkills.test.js' import './storage/config.test.js' import './storage/gitignore.test.js' import './storage/reset.test.js'