ethagent 0.2.1 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +114 -32
  3. package/bin/ethagent.js +11 -2
  4. package/package.json +25 -7
  5. package/src/app/FirstRun.tsx +412 -0
  6. package/src/app/hooks/useCancelRequest.ts +22 -0
  7. package/src/app/hooks/useDoublePress.ts +46 -0
  8. package/src/app/hooks/useExitOnCtrlC.ts +36 -0
  9. package/src/app/input/AppInputProvider.tsx +116 -0
  10. package/src/app/input/appInputParser.ts +279 -0
  11. package/src/app/keybindings/KeybindingProvider.tsx +134 -0
  12. package/src/app/keybindings/resolver.ts +42 -0
  13. package/src/app/keybindings/types.ts +26 -0
  14. package/src/chat/ChatBottomPane.tsx +280 -0
  15. package/src/chat/ChatInput.tsx +722 -0
  16. package/src/chat/ChatScreen.tsx +1575 -0
  17. package/src/chat/ContextLimitView.tsx +95 -0
  18. package/src/chat/ContinuityEditReviewView.tsx +48 -0
  19. package/src/chat/ConversationStack.tsx +47 -0
  20. package/src/chat/CopyPicker.tsx +52 -0
  21. package/src/chat/MessageList.tsx +609 -0
  22. package/src/chat/PermissionPrompt.tsx +153 -0
  23. package/src/chat/PermissionsView.tsx +159 -0
  24. package/src/chat/PlanApprovalView.tsx +91 -0
  25. package/src/chat/ResumeView.tsx +267 -0
  26. package/src/chat/RewindView.tsx +386 -0
  27. package/src/chat/SessionStatus.tsx +51 -0
  28. package/src/chat/TranscriptView.tsx +202 -0
  29. package/src/chat/chatInputState.ts +247 -0
  30. package/src/chat/chatPaste.ts +49 -0
  31. package/src/chat/chatScreenUtils.ts +187 -0
  32. package/src/chat/chatSessionState.ts +142 -0
  33. package/src/chat/chatTurnOrchestrator.ts +701 -0
  34. package/src/chat/commands.ts +673 -0
  35. package/src/chat/textCursor.ts +202 -0
  36. package/src/chat/toolResultDisplay.ts +8 -0
  37. package/src/chat/transcriptViewport.ts +247 -0
  38. package/src/cli/ResetConfirmView.tsx +61 -0
  39. package/src/cli/main.tsx +177 -0
  40. package/src/cli/preview.tsx +19 -0
  41. package/src/cli/reset.ts +106 -0
  42. package/src/identity/continuity/editor.ts +149 -0
  43. package/src/identity/continuity/envelope.ts +345 -0
  44. package/src/identity/continuity/history.ts +153 -0
  45. package/src/identity/continuity/privateEdit.ts +334 -0
  46. package/src/identity/continuity/publicSkills.ts +173 -0
  47. package/src/identity/continuity/snapshots.ts +183 -0
  48. package/src/identity/continuity/storage.ts +507 -0
  49. package/src/identity/crypto/backupEnvelope.ts +486 -0
  50. package/src/identity/crypto/eth.ts +137 -0
  51. package/src/identity/hub/IdentityHub.tsx +845 -0
  52. package/src/identity/hub/identityHubEffects.ts +1100 -0
  53. package/src/identity/hub/identityHubModel.ts +291 -0
  54. package/src/identity/hub/identityHubReducer.ts +209 -0
  55. package/src/identity/hub/screens/BusyScreen.tsx +26 -0
  56. package/src/identity/hub/screens/ContinuityDashboardScreen.tsx +139 -0
  57. package/src/identity/hub/screens/CreateFlow.tsx +206 -0
  58. package/src/identity/hub/screens/DetailsScreen.tsx +64 -0
  59. package/src/identity/hub/screens/EditProfileFlow.tsx +145 -0
  60. package/src/identity/hub/screens/ErrorScreen.tsx +35 -0
  61. package/src/identity/hub/screens/IdentitySummary.tsx +70 -0
  62. package/src/identity/hub/screens/MenuScreen.tsx +117 -0
  63. package/src/identity/hub/screens/NetworkScreen.tsx +41 -0
  64. package/src/identity/hub/screens/RebackupStorageScreen.tsx +50 -0
  65. package/src/identity/hub/screens/RecoveryConfirmScreen.tsx +85 -0
  66. package/src/identity/hub/screens/RestoreFlow.tsx +206 -0
  67. package/src/identity/hub/screens/StorageCredentialScreen.tsx +128 -0
  68. package/src/identity/hub/screens/WalletApprovalScreen.tsx +43 -0
  69. package/src/identity/profile/imagePicker.ts +180 -0
  70. package/src/identity/registry/erc8004.ts +1106 -0
  71. package/src/identity/registry/registryConfig.ts +69 -0
  72. package/src/identity/storage/ipfs.ts +212 -0
  73. package/src/identity/storage/pinataJwt.ts +53 -0
  74. package/src/identity/wallet/browserWallet.ts +393 -0
  75. package/src/identity/wallet/wallet-page/wallet.html +1082 -0
  76. package/src/mcp/approvals.ts +113 -0
  77. package/src/mcp/config.ts +235 -0
  78. package/src/mcp/manager.ts +541 -0
  79. package/src/mcp/names.ts +19 -0
  80. package/src/mcp/output.ts +96 -0
  81. package/src/models/ModelPicker.tsx +1446 -0
  82. package/src/models/catalog.ts +296 -0
  83. package/src/models/huggingface.ts +651 -0
  84. package/src/models/llamacpp.ts +810 -0
  85. package/src/models/llamacppPreflight.ts +150 -0
  86. package/src/models/modelDisplay.ts +105 -0
  87. package/src/models/modelPickerOptions.ts +421 -0
  88. package/src/models/modelRecommendation.ts +140 -0
  89. package/src/models/runtimeDetection.ts +81 -0
  90. package/src/models/uncensoredCatalog.ts +86 -0
  91. package/src/providers/anthropic.ts +259 -0
  92. package/src/providers/contracts.ts +62 -0
  93. package/src/providers/errors.ts +62 -0
  94. package/src/providers/gemini.ts +152 -0
  95. package/src/providers/openai-chat.ts +472 -0
  96. package/src/providers/registry.ts +42 -0
  97. package/src/providers/retry.ts +58 -0
  98. package/src/providers/sse.ts +93 -0
  99. package/src/runtime/compaction.ts +389 -0
  100. package/src/runtime/cwd.ts +43 -0
  101. package/src/runtime/sessionMode.ts +55 -0
  102. package/src/runtime/systemPrompt.ts +209 -0
  103. package/src/runtime/toolClaimGuards.ts +143 -0
  104. package/src/runtime/toolExecution.ts +304 -0
  105. package/src/runtime/toolIntent.ts +163 -0
  106. package/src/runtime/turn.ts +858 -0
  107. package/src/storage/atomicWrite.ts +68 -0
  108. package/src/storage/config.ts +189 -0
  109. package/src/storage/factoryReset.ts +130 -0
  110. package/src/storage/history.ts +58 -0
  111. package/src/storage/identity.ts +99 -0
  112. package/src/storage/permissions.ts +76 -0
  113. package/src/storage/rewind.ts +246 -0
  114. package/src/storage/secrets.ts +181 -0
  115. package/src/storage/sessionExport.ts +49 -0
  116. package/src/storage/sessions.ts +482 -0
  117. package/src/tools/bashSafety.ts +174 -0
  118. package/src/tools/bashTool.ts +140 -0
  119. package/src/tools/changeDirectoryTool.ts +213 -0
  120. package/src/tools/contracts.ts +179 -0
  121. package/src/tools/deleteFileTool.ts +111 -0
  122. package/src/tools/editTool.ts +160 -0
  123. package/src/tools/editUtils.ts +170 -0
  124. package/src/tools/listDirectoryTool.ts +55 -0
  125. package/src/tools/mcpResourceTools.ts +95 -0
  126. package/src/tools/permissionRules.ts +85 -0
  127. package/src/tools/privateContinuityEditTool.ts +178 -0
  128. package/src/tools/privateContinuityReadTool.ts +107 -0
  129. package/src/tools/readTool.ts +85 -0
  130. package/src/tools/registry.ts +67 -0
  131. package/src/tools/writeFileTool.ts +142 -0
  132. package/src/ui/BrandSplash.tsx +193 -0
  133. package/src/ui/ProgressBar.tsx +34 -0
  134. package/src/ui/Select.tsx +143 -0
  135. package/src/ui/Spinner.tsx +269 -0
  136. package/src/ui/Surface.tsx +47 -0
  137. package/src/ui/TextInput.tsx +97 -0
  138. package/src/ui/theme.ts +59 -0
  139. package/src/utils/clipboard.ts +216 -0
  140. package/src/utils/markdownSegments.ts +51 -0
  141. package/src/utils/messages.ts +35 -0
  142. package/src/utils/withRetry.ts +280 -0
  143. package/src/cli.tsx +0 -147
@@ -0,0 +1,206 @@
1
+ import React from 'react'
2
+ import { Box, Text } from 'ink'
3
+ import { Surface } from '../../../ui/Surface.js'
4
+ import { Select } from '../../../ui/Select.js'
5
+ import { TextInput } from '../../../ui/TextInput.js'
6
+ import { theme } from '../../../ui/theme.js'
7
+ import { extractPinataJwt } from '../../storage/ipfs.js'
8
+ import { normalizeErc8004RegistryConfig } from '../../registry/erc8004.js'
9
+ import { networkLabel } from '../identityHubModel.js'
10
+ import type { Step } from '../identityHubReducer.js'
11
+ import { createStepNumber, CREATE_STEP_LABELS } from '../identityHubReducer.js'
12
+ import { WalletApprovalScreen } from './WalletApprovalScreen.js'
13
+ import { BusyScreen } from './BusyScreen.js'
14
+ import type { BrowserWalletReady } from '../../wallet/browserWallet.js'
15
+
16
+ const PINATA_API_KEYS_URL = 'https://app.pinata.cloud/developers/api-keys'
17
+
18
+ type StepIndicatorProps = {
19
+ steps: string[]
20
+ current: number
21
+ }
22
+
23
+ const StepIndicator: React.FC<StepIndicatorProps> = ({ steps, current }) => {
24
+ const parts = steps.map((step, index) => {
25
+ const n = index + 1
26
+ const active = n === current
27
+ const done = n < current
28
+ return `${done ? 'done' : n}. ${step}${active ? ' <' : ''}`
29
+ })
30
+ return <Text color={theme.dim}>{parts.join(' · ')}</Text>
31
+ }
32
+
33
+ type CreateFlowProps = {
34
+ step: Extract<Step, {
35
+ kind:
36
+ | 'replace-confirm'
37
+ | 'create-name'
38
+ | 'create-description'
39
+ | 'create-preflight'
40
+ | 'create-registry'
41
+ | 'create-signing'
42
+ | 'create-storage'
43
+ }>
44
+ walletSession: BrowserWalletReady | null
45
+ onSetStep: (step: Step) => void
46
+ onNameSubmit: (name: string) => void
47
+ onDescriptionSubmit: (name: string, description: string) => void
48
+ onRegistrySubmit: (value: string) => void
49
+ onStorageSubmit: (input: string) => void
50
+ onStorageError: (error: string) => void
51
+ onBack: () => void
52
+ onMenu: () => void
53
+ }
54
+
55
+ export const CreateFlow: React.FC<CreateFlowProps> = ({
56
+ step,
57
+ walletSession,
58
+ onSetStep,
59
+ onNameSubmit,
60
+ onDescriptionSubmit,
61
+ onRegistrySubmit,
62
+ onStorageSubmit,
63
+ onBack,
64
+ onMenu,
65
+ }) => {
66
+ const stepNum = createStepNumber(step)
67
+ const indicator = stepNum > 0
68
+ ? <StepIndicator steps={CREATE_STEP_LABELS} current={stepNum} />
69
+ : null
70
+
71
+ if (step.kind === 'replace-confirm') {
72
+ return (
73
+ <Surface title="Create a New Agent?" footer="enter selects · esc back">
74
+ <Box flexDirection="column" marginBottom={1}>
75
+ <Text color={theme.dim}>
76
+ Your current agent stays in your wallet and remains loadable.
77
+ </Text>
78
+ <Text color={theme.dim}>
79
+ This mints a new agent to this wallet and uses it on this machine.
80
+ </Text>
81
+ </Box>
82
+ <Select<'replace' | 'back'>
83
+ options={[
84
+ { value: 'back', role: 'section', prefix: '--', label: 'Current identity' },
85
+ { value: 'back', label: 'keep current agent', hint: 'return without minting anything', role: 'utility' },
86
+ { value: 'replace', role: 'section', prefix: '--', label: 'New identity' },
87
+ { value: 'replace', label: 'mint and use new agent', hint: 'create separate token and make it active' },
88
+ ]}
89
+ hintLayout="inline"
90
+ onSubmit={choice => {
91
+ if (choice === 'back') return onMenu()
92
+ return onSetStep({ kind: 'create-name' })
93
+ }}
94
+ onCancel={onBack}
95
+ />
96
+ </Surface>
97
+ )
98
+ }
99
+
100
+ if (step.kind === 'create-name') {
101
+ return (
102
+ <Surface title="Name Your Agent" subtitle={indicator} footer="enter continues · esc back">
103
+ {step.error ? <Text color="#e87070">{step.error}</Text> : null}
104
+ <TextInput
105
+ key="agent-name"
106
+ placeholder="agent name"
107
+ validate={value => value.trim().length >= 2 ? null : 'name must be at least 2 characters'}
108
+ onSubmit={name => onNameSubmit(name.trim())}
109
+ onCancel={onBack}
110
+ />
111
+ </Surface>
112
+ )
113
+ }
114
+
115
+ if (step.kind === 'create-description') {
116
+ return (
117
+ <Surface title="Describe Your Agent" subtitle={indicator} footer="enter continues · esc back">
118
+ <Text color={theme.dim}>Optional. One short sentence is enough.</Text>
119
+ <TextInput
120
+ key="agent-description"
121
+ placeholder="description"
122
+ onSubmit={description => onDescriptionSubmit(step.name, description.trim())}
123
+ onCancel={onBack}
124
+ />
125
+ </Surface>
126
+ )
127
+ }
128
+
129
+ if (step.kind === 'create-preflight') {
130
+ return (
131
+ <BusyScreen
132
+ title="Getting Ready"
133
+ subtitle={indicator}
134
+ label="checking IPFS storage and chain..."
135
+ onCancel={onBack}
136
+ />
137
+ )
138
+ }
139
+
140
+ if (step.kind === 'create-registry') {
141
+ return (
142
+ <Surface
143
+ title={`${step.resolution.network ? networkLabel(step.resolution.network).charAt(0).toUpperCase() + networkLabel(step.resolution.network).slice(1) : ''} Agent Registry`}
144
+ subtitle={step.error ?? 'Paste the agent registry address for this network.'}
145
+ footer="enter continues · esc back"
146
+ >
147
+ <Text color={theme.dim}>RPC defaults to {step.resolution.defaultRpcUrl}</Text>
148
+ <TextInput
149
+ key={`create-registry-${step.resolution.network}`}
150
+ placeholder="0x registry address"
151
+ validate={value => {
152
+ try {
153
+ normalizeErc8004RegistryConfig({ chainId: step.resolution.chainId, identityRegistryAddress: value.trim() })
154
+ return null
155
+ } catch (err: unknown) {
156
+ return (err as Error).message
157
+ }
158
+ }}
159
+ onSubmit={onRegistrySubmit}
160
+ onCancel={onBack}
161
+ />
162
+ </Surface>
163
+ )
164
+ }
165
+
166
+ if (step.kind === 'create-signing') {
167
+ return (
168
+ <WalletApprovalScreen
169
+ title="Approve in Wallet"
170
+ subtitle="One browser flow signs, saves the IPFS backup, and submits the token transaction."
171
+ walletSession={walletSession}
172
+ label="waiting for wallet approval..."
173
+ onCancel={onBack}
174
+ />
175
+ )
176
+ }
177
+
178
+ return (
179
+ <Surface
180
+ title="Connect IPFS Storage"
181
+ subtitle={step.error ?? 'Save a Pinata JWT so ethagent can pin encrypted state to IPFS.'}
182
+ footer="enter continues · esc back"
183
+ >
184
+ <Text>
185
+ <Text color={theme.dim}>Paste your Pinata JWT. Get one at </Text>
186
+ <Text color={theme.accentPrimary} underline>{PINATA_API_KEYS_URL}</Text>
187
+ </Text>
188
+ <Text color={theme.dim}>Saved encrypted on this device · used only for IPFS pinning</Text>
189
+ <TextInput
190
+ key="create-storage"
191
+ isSecret
192
+ placeholder="Pinata JWT"
193
+ validate={v => {
194
+ try {
195
+ extractPinataJwt(v)
196
+ return null
197
+ } catch (err: unknown) {
198
+ return (err as Error).message
199
+ }
200
+ }}
201
+ onSubmit={onStorageSubmit}
202
+ onCancel={onBack}
203
+ />
204
+ </Surface>
205
+ )
206
+ }
@@ -0,0 +1,64 @@
1
+ import React from 'react'
2
+ import { Box } from 'ink'
3
+ import { Surface } from '../../../ui/Surface.js'
4
+ import { Select, type SelectOption } from '../../../ui/Select.js'
5
+ import type { EthagentConfig, EthagentIdentity } from '../../../storage/config.js'
6
+ import { copyableIdentityFields } from '../identityHubModel.js'
7
+ import { IdentitySummary } from './IdentitySummary.js'
8
+
9
+ type CopyAction = `copy:${string}` | 'back'
10
+
11
+ type DetailsScreenProps = {
12
+ identity?: EthagentIdentity
13
+ config?: EthagentConfig
14
+ copyNotice?: string | null
15
+ footer: React.ReactNode
16
+ onCopy: (label: string, value: string) => void
17
+ onBack: () => void
18
+ }
19
+
20
+ export const DetailsScreen: React.FC<DetailsScreenProps> = ({
21
+ identity,
22
+ config,
23
+ copyNotice,
24
+ footer,
25
+ onCopy,
26
+ onBack,
27
+ }) => {
28
+ const copyable = copyableIdentityFields(identity)
29
+ const options: Array<SelectOption<CopyAction>> = [
30
+ ...(copyable.length > 0 ? [{ value: 'back' as const, role: 'section' as const, prefix: '--', label: 'Values' }] : []),
31
+ ...copyable.map(field => ({
32
+ value: `copy:${field.label}` as const,
33
+ label: field.label,
34
+ hint: shortPreview(field.value),
35
+ })),
36
+ ...(copyable.length === 0 ? [{ value: 'back' as const, role: 'notice' as const, label: 'no values available yet' }] : []),
37
+ { value: 'back', role: 'section', prefix: '--', label: 'Navigation' },
38
+ { value: 'back', label: 'back to identity hub', hint: 'return without copying', role: 'utility' },
39
+ ]
40
+
41
+ return (
42
+ <Surface title="Copy Identity Values" subtitle={copyNotice ?? 'Choose one value to copy.'} footer={footer}>
43
+ <IdentitySummary identity={identity} config={config} compact />
44
+ <Box marginTop={1}>
45
+ <Select<CopyAction>
46
+ options={options}
47
+ hintLayout="inline"
48
+ onSubmit={choice => {
49
+ if (choice === 'back') return onBack()
50
+ const label = choice.slice('copy:'.length)
51
+ const found = copyable.find(field => field.label === label)
52
+ if (found) onCopy(found.label, found.value)
53
+ }}
54
+ onCancel={onBack}
55
+ />
56
+ </Box>
57
+ </Surface>
58
+ )
59
+ }
60
+
61
+ function shortPreview(value: string): string {
62
+ if (value.length <= 42) return value
63
+ return `${value.slice(0, 18)}...${value.slice(-14)}`
64
+ }
@@ -0,0 +1,145 @@
1
+ import React from 'react'
2
+ import { Box, Text } from 'ink'
3
+ import { Surface } from '../../../ui/Surface.js'
4
+ import { Select } from '../../../ui/Select.js'
5
+ import { TextInput } from '../../../ui/TextInput.js'
6
+ import { theme } from '../../../ui/theme.js'
7
+ import type { Step } from '../identityHubReducer.js'
8
+
9
+ type EditProfileFlowProps = {
10
+ step: Extract<Step, { kind: 'edit-profile-name' | 'edit-profile-description' | 'edit-profile-image' }>
11
+ onNameSubmit: (name: string) => void
12
+ onDescriptionSubmit: (description: string) => void
13
+ onImageSubmit: (imagePath: string) => void
14
+ onImagePick: () => void
15
+ onBack: () => void
16
+ onMenu: () => void
17
+ }
18
+
19
+ const footerHint = (hint: string) => <Text color={theme.dim}>{hint}</Text>
20
+
21
+ export const EditProfileFlow: React.FC<EditProfileFlowProps> = ({
22
+ step,
23
+ onNameSubmit,
24
+ onDescriptionSubmit,
25
+ onImageSubmit,
26
+ onImagePick,
27
+ onBack,
28
+ onMenu,
29
+ }) => {
30
+ const [manualImagePath, setManualImagePath] = React.useState(false)
31
+
32
+ if (step.kind === 'edit-profile-name') {
33
+ const currentName = readStateString(step.identity.state, 'name')
34
+ return (
35
+ <Surface
36
+ title="Rename Agent Identity"
37
+ subtitle="This updates public token metadata and the Agent Card."
38
+ footer={footerHint('enter continues · esc back')}
39
+ >
40
+ <Text color={theme.dim}>Currently: {currentName || '(unnamed)'}</Text>
41
+ <TextInput
42
+ key="edit-profile-name"
43
+ initialValue={currentName}
44
+ placeholder="agent name"
45
+ validate={value => value.trim().length >= 2 ? null : 'name must be at least 2 characters'}
46
+ onSubmit={value => onNameSubmit(value.trim())}
47
+ onCancel={onMenu}
48
+ />
49
+ </Surface>
50
+ )
51
+ }
52
+
53
+ if (step.kind === 'edit-profile-image') {
54
+ const currentImage = readStateString(step.identity.state, 'imageUrl')
55
+ if (!manualImagePath) {
56
+ return (
57
+ <Surface
58
+ title="Upload Agent Image"
59
+ subtitle="Choose a local image. ethagent uploads it to IPFS and attaches it to token metadata."
60
+ footer={footerHint('enter select · esc back')}
61
+ >
62
+ <Box flexDirection="column">
63
+ <Text color={theme.dim}>Current: {currentImage || '(no image)'}</Text>
64
+ {step.error ? <Text color="#e87070">{step.error}</Text> : null}
65
+ </Box>
66
+ <Box marginTop={1}>
67
+ <Select<'choose' | 'manual' | 'skip' | 'delete' | 'back'>
68
+ options={[
69
+ { value: 'choose', role: 'section', prefix: '--', label: 'Image' },
70
+ { value: 'choose', label: 'choose image file', hint: 'open the operating system file picker' },
71
+ { value: 'manual', label: 'enter path manually', hint: 'fallback if a file picker is unavailable' },
72
+ ...(currentImage ? [{ value: 'delete' as const, label: 'delete current image', hint: 'remove the attached image from public profile' }] : []),
73
+ { value: 'skip', label: currentImage ? 'keep current image' : 'no image', hint: 'publish without changing the image' },
74
+ { value: 'back', role: 'section', prefix: '--', label: 'Navigation' },
75
+ { value: 'back', label: 'back', hint: 'return to description', role: 'utility' },
76
+ ]}
77
+ hintLayout="inline"
78
+ onSubmit={choice => {
79
+ if (choice === 'choose') return onImagePick()
80
+ if (choice === 'manual') return setManualImagePath(true)
81
+ if (choice === 'delete') return onImageSubmit('delete')
82
+ if (choice === 'skip') return onImageSubmit('')
83
+ return onBack()
84
+ }}
85
+ onCancel={onBack}
86
+ />
87
+ </Box>
88
+ </Surface>
89
+ )
90
+ }
91
+ return (
92
+ <Surface
93
+ title="Enter Image Path"
94
+ subtitle="Fallback path entry. URLs are rejected because ethagent uploads the local file itself."
95
+ footer={footerHint('enter saves · esc back')}
96
+ >
97
+ <Text color={theme.dim}>Current: {currentImage || '(no image)'}</Text>
98
+ <TextInput
99
+ key="edit-profile-image"
100
+ placeholder="local image path"
101
+ allowEmpty
102
+ validate={value => validateImagePath(value)}
103
+ onSubmit={value => onImageSubmit(value.trim())}
104
+ onCancel={() => setManualImagePath(false)}
105
+ />
106
+ </Surface>
107
+ )
108
+ }
109
+
110
+ const currentDescription = readStateString(step.identity.state, 'description')
111
+ return (
112
+ <Surface
113
+ title="Describe Agent Identity"
114
+ subtitle="This updates public token metadata and the Agent Card."
115
+ footer={footerHint('enter continues · esc back')}
116
+ >
117
+ <Text color={theme.dim}>Currently: {currentDescription || '(no description)'}</Text>
118
+ <TextInput
119
+ key="edit-profile-description"
120
+ initialValue={currentDescription}
121
+ placeholder="description"
122
+ allowEmpty
123
+ onSubmit={value => onDescriptionSubmit(value.trim())}
124
+ onCancel={onBack}
125
+ />
126
+ </Surface>
127
+ )
128
+ }
129
+
130
+ function validateImagePath(value: string): string | null {
131
+ const trimmed = value.trim()
132
+ if (!trimmed) return null
133
+ if (/^https?:\/\//i.test(trimmed) || /^ipfs:\/\//i.test(trimmed)) {
134
+ return 'enter a local image file path; ethagent will upload it to IPFS'
135
+ }
136
+ if (!/\.(png|jpe?g|gif|webp|svg)$/i.test(trimmed)) {
137
+ return 'image must be png, jpg, gif, webp, or svg'
138
+ }
139
+ return null
140
+ }
141
+
142
+ function readStateString(state: Record<string, unknown> | undefined, key: string): string {
143
+ const value = state?.[key]
144
+ return typeof value === 'string' ? value : ''
145
+ }
@@ -0,0 +1,35 @@
1
+ import React from 'react'
2
+ import { Text } from 'ink'
3
+ import { Surface } from '../../../ui/Surface.js'
4
+ import { Select } from '../../../ui/Select.js'
5
+ import { theme } from '../../../ui/theme.js'
6
+ import type { IdentityHubErrorView } from '../identityHubModel.js'
7
+ import type { Step } from '../identityHubReducer.js'
8
+
9
+ type ErrorScreenProps = {
10
+ error: IdentityHubErrorView
11
+ back: Step
12
+ footer: React.ReactNode
13
+ onBack: (back: Step) => void
14
+ onClose: () => void
15
+ }
16
+
17
+ export const ErrorScreen: React.FC<ErrorScreenProps> = ({ error, back, footer, onBack, onClose }) => (
18
+ <Surface title={error.title} tone="error" subtitle={error.detail} footer={footer}>
19
+ {error.hint ? <Text color={theme.dim}>{error.hint}</Text> : null}
20
+ <Select<'back' | 'close'>
21
+ options={[
22
+ { value: 'back', role: 'section', prefix: '--', label: 'Recovery' },
23
+ { value: 'back', label: 'go back', hint: 'return to the previous identity step' },
24
+ { value: 'close', role: 'section', prefix: '--', label: 'Exit' },
25
+ { value: 'close', label: 'close hub', hint: 'return to the chat without retrying', role: 'utility' },
26
+ ]}
27
+ hintLayout="inline"
28
+ onSubmit={choice => {
29
+ if (choice === 'back') onBack(back)
30
+ else onClose()
31
+ }}
32
+ onCancel={() => onBack(back)}
33
+ />
34
+ </Surface>
35
+ )
@@ -0,0 +1,70 @@
1
+ import React from 'react'
2
+ import { Box, Text } from 'ink'
3
+ import { theme } from '../../../ui/theme.js'
4
+ import type { EthagentConfig, EthagentIdentity } from '../../../storage/config.js'
5
+ import { identitySummaryRows, lastBackupLabel } from '../identityHubModel.js'
6
+
7
+ import type { ContinuityWorkingTreeStatus } from '../../continuity/storage.js'
8
+
9
+ export const IdentitySummary: React.FC<{
10
+ identity?: EthagentIdentity
11
+ config?: EthagentConfig
12
+ workingStatus?: ContinuityWorkingTreeStatus | null
13
+ compact?: boolean
14
+ }> = ({ identity, config, workingStatus, compact = false }) => {
15
+ if (!identity) {
16
+ return (
17
+ <Text color={theme.dim}>no agent yet. create or load one.</Text>
18
+ )
19
+ }
20
+
21
+ const rows = identitySummaryRows(identity, config)
22
+ const lastBackup = lastBackupLabel(identity)
23
+ const stateName = typeof (identity.state as Record<string, unknown> | undefined)?.name === 'string'
24
+ ? ((identity.state as Record<string, unknown>).name as string).trim()
25
+ : ''
26
+
27
+ const row = (label: string) => rows.find(item => item.label === label)
28
+
29
+ const needsBackup = workingStatus?.publishState === 'local-changes' || workingStatus?.publishState === 'not-published' || workingStatus?.publishState === 'verify-needed'
30
+ let changedFiles: string[] = []
31
+ if (needsBackup) {
32
+ if (workingStatus?.localContentHashes && workingStatus?.publishedContentHashes) {
33
+ if (workingStatus.localContentHashes['SOUL.md'] !== workingStatus.publishedContentHashes['SOUL.md']) changedFiles.push('SOUL.md')
34
+ if (workingStatus.localContentHashes['MEMORY.md'] !== workingStatus.publishedContentHashes['MEMORY.md']) changedFiles.push('MEMORY.md')
35
+ if (workingStatus.localContentHashes['skills.json'] !== workingStatus.publishedContentHashes['skills.json']) changedFiles.push('skills.json')
36
+ } else {
37
+ changedFiles = ['SOUL.md', 'MEMORY.md', 'skills.json']
38
+ }
39
+ }
40
+
41
+ const lastSavedRow = needsBackup
42
+ ? { label: 'unsaved', value: changedFiles.length > 0 ? changedFiles.join(', ') : 'markdown files', tone: 'warn' as const, highlight: true }
43
+ : { label: 'last saved', value: lastBackup, tone: lastBackup === 'never' ? 'dim' as const : 'ok' as const }
44
+
45
+ const summaryRows = [
46
+ { label: 'token', value: row('token')?.value ?? 'not created', tone: row('token')?.tone ?? 'dim', highlight: true },
47
+ { label: 'network', value: row('network')?.value ?? 'unknown', tone: row('network')?.tone ?? 'dim' },
48
+ { label: 'owner', value: row('owner')?.value ?? 'not connected', tone: row('owner')?.tone ?? 'dim' },
49
+ { label: 'snapshot', value: row('state')?.value ?? 'not saved yet', tone: row('state')?.tone ?? 'dim', highlight: true },
50
+ lastSavedRow,
51
+ { label: 'skills', value: row('skills')?.value ?? 'not published', tone: row('skills')?.tone ?? 'dim' },
52
+ { label: 'agent card', value: row('card')?.value ?? 'not published', tone: row('card')?.tone ?? 'dim' },
53
+ { label: 'image', value: row('image')?.value ?? 'not attached', tone: row('image')?.tone ?? 'dim' },
54
+ ]
55
+
56
+ return (
57
+ <Box flexDirection="column">
58
+ <Text color={theme.accentPrimary} bold>{stateName || 'active agent'}</Text>
59
+ {summaryRows.map(row => {
60
+ const valueColor = row.tone === 'warn' ? 'red' : (row.tone === 'ok' ? theme.text : theme.dim)
61
+ return (
62
+ <Text key={row.label}>
63
+ <Text color={theme.dim}>{row.label.padEnd(12)}</Text>
64
+ <Text color={valueColor} bold={row.highlight}>{row.value}</Text>
65
+ </Text>
66
+ )
67
+ })}
68
+ </Box>
69
+ )
70
+ }
@@ -0,0 +1,117 @@
1
+ import React from 'react'
2
+ import { Box } from 'ink'
3
+ import { Surface } from '../../../ui/Surface.js'
4
+ import { Select, type SelectOption } from '../../../ui/Select.js'
5
+ import type { EthagentConfig, EthagentIdentity } from '../../../storage/config.js'
6
+ import type { ContinuityWorkingTreeStatus } from '../../continuity/storage.js'
7
+ import { IdentitySummary } from './IdentitySummary.js'
8
+
9
+ type MenuScreenProps = {
10
+ mode: 'first-run' | 'manage'
11
+ config?: EthagentConfig
12
+ identity?: EthagentIdentity
13
+ workingStatus?: ContinuityWorkingTreeStatus | null
14
+ canRebackup: boolean
15
+ footer: React.ReactNode
16
+ onCreate: () => void
17
+ onLoad: () => void
18
+ onBackupNow: () => void
19
+ onRefetchLatest: () => void
20
+ onPublicProfile: () => void
21
+ onPrivateMemory: () => void
22
+ onCopyValues: () => void
23
+ onStorageCredential: () => void
24
+ onSkip: () => void
25
+ onCancel: () => void
26
+ }
27
+
28
+ type Action =
29
+ | 'public-profile'
30
+ | 'private-memory'
31
+ | 'backup'
32
+ | 'refetch'
33
+ | 'copy'
34
+ | 'storage-credential'
35
+ | 'create'
36
+ | 'load'
37
+ | 'skip'
38
+ | 'cancel'
39
+
40
+ export const MenuScreen: React.FC<MenuScreenProps> = ({
41
+ mode,
42
+ config,
43
+ identity,
44
+ workingStatus,
45
+ canRebackup,
46
+ footer,
47
+ onCreate,
48
+ onLoad,
49
+ onBackupNow,
50
+ onRefetchLatest,
51
+ onPublicProfile,
52
+ onPrivateMemory,
53
+ onCopyValues,
54
+ onStorageCredential,
55
+ onSkip,
56
+ onCancel,
57
+ }) => {
58
+ const title = mode === 'first-run' ? 'Set Up Agent Identity' : 'Agent Identity'
59
+ const subtitle = mode === 'first-run'
60
+ ? 'Create a portable agent or load one you already own.'
61
+ : 'Public, private, recovery, storage, and device controls are separate.'
62
+
63
+ const canRefetch = Boolean(canRebackup && identity?.backup?.cid)
64
+
65
+ const options: Array<SelectOption<Action>> = identity
66
+ ? [
67
+ { value: 'public-profile', role: 'section', prefix: '--', label: 'Public metadata' },
68
+ { value: 'public-profile', label: 'public profile', hint: 'name, image, skills.json, agent card' },
69
+ { value: 'private-memory', role: 'section', prefix: '--', label: 'Private local files' },
70
+ { value: 'private-memory', label: 'memory and persona', hint: 'SOUL.md and MEMORY.md only on this device' },
71
+ { value: 'backup', role: 'section', prefix: '--', label: 'Recovery' },
72
+ { value: 'backup', label: 'publish snapshot now', hint: 'encrypts and publishes local SOUL.md and MEMORY.md changes', disabled: !canRebackup },
73
+ { value: 'refetch', label: 'refetch latest snapshot', hint: 'restore local files from the latest published snapshot', disabled: !canRefetch },
74
+ { value: 'storage-credential', role: 'section', prefix: '--', label: 'Storage' },
75
+ { value: 'storage-credential', label: 'IPFS credential', hint: 'save, replace, or forget Pinata JWT' },
76
+ { value: 'copy', role: 'section', prefix: '--', label: 'Agent token' },
77
+ { value: 'copy', label: 'copy values', hint: 'copy CIDs, token id, URI, or owner' },
78
+ { value: 'load', label: 'switch agent', hint: 'load a different token owned by your wallet' },
79
+ { value: 'create', label: 'new agent', hint: 'mint another token and make it active here' },
80
+ { value: 'cancel', role: 'section', prefix: '--', label: 'Exit' },
81
+ { value: 'cancel', label: 'close hub', hint: 'return to the chat without changing identity', role: 'utility' },
82
+ ]
83
+ : [
84
+ { value: 'create', role: 'section', prefix: '--', label: 'Setup' },
85
+ { value: 'create', label: 'create new agent', hint: 'mint a wallet-owned token for this machine' },
86
+ { value: 'load', label: 'load existing agent', hint: 'find an agent token your wallet already owns' },
87
+ { value: 'skip', role: 'section', prefix: '--', label: 'Exit' },
88
+ ...(mode === 'first-run'
89
+ ? [{ value: 'skip' as Action, label: 'skip for now', hint: 'continue now; use /identity later', role: 'utility' as const }]
90
+ : [{ value: 'cancel' as Action, label: 'close hub', hint: 'return to the chat without changing identity', role: 'utility' as const }]),
91
+ ]
92
+
93
+ return (
94
+ <Surface title={title} subtitle={subtitle} footer={footer}>
95
+ <IdentitySummary identity={identity} config={config} workingStatus={workingStatus} compact={Boolean(identity)} />
96
+ <Box marginTop={1}>
97
+ <Select<Action>
98
+ options={options}
99
+ hintLayout="inline"
100
+ onSubmit={choice => {
101
+ if (choice === 'skip') return onSkip()
102
+ if (choice === 'cancel') return onCancel()
103
+ if (choice === 'public-profile') return onPublicProfile()
104
+ if (choice === 'private-memory') return onPrivateMemory()
105
+ if (choice === 'backup') return onBackupNow()
106
+ if (choice === 'refetch') return onRefetchLatest()
107
+ if (choice === 'copy') return onCopyValues()
108
+ if (choice === 'storage-credential') return onStorageCredential()
109
+ if (choice === 'load') return onLoad()
110
+ if (choice === 'create') return onCreate()
111
+ }}
112
+ onCancel={() => mode === 'first-run' ? onSkip() : onCancel()}
113
+ />
114
+ </Box>
115
+ </Surface>
116
+ )
117
+ }
@@ -0,0 +1,41 @@
1
+ import React from 'react'
2
+ import { Surface } from '../../../ui/Surface.js'
3
+ import { Select, type SelectOption } from '../../../ui/Select.js'
4
+ import type { SelectableNetwork } from '../../../storage/config.js'
5
+ import { SELECTABLE_NETWORKS } from '../../../storage/config.js'
6
+ import { networkLabel, networkSubtitle } from '../identityHubModel.js'
7
+
8
+ type NetworkScreenProps = {
9
+ subtitle: string
10
+ footer: React.ReactNode
11
+ onSelect: (network: SelectableNetwork) => void
12
+ onCancel: () => void
13
+ }
14
+
15
+ export const NetworkScreen: React.FC<NetworkScreenProps> = ({ subtitle, footer, onSelect, onCancel }) => {
16
+ const options: Array<SelectOption<SelectableNetwork>> = [
17
+ { value: 'mainnet', role: 'section', prefix: '--', label: 'Main network' },
18
+ networkOption('mainnet'),
19
+ { value: 'arbitrum', role: 'section', prefix: '--', label: 'Lower-fee networks' },
20
+ ...SELECTABLE_NETWORKS.filter(network => network !== 'mainnet').map(networkOption),
21
+ ]
22
+
23
+ return (
24
+ <Surface title="Network" subtitle={subtitle} footer={footer}>
25
+ <Select<SelectableNetwork>
26
+ options={options}
27
+ hintLayout="inline"
28
+ onSubmit={onSelect}
29
+ onCancel={onCancel}
30
+ />
31
+ </Surface>
32
+ )
33
+ }
34
+
35
+ function networkOption(network: SelectableNetwork): SelectOption<SelectableNetwork> {
36
+ return {
37
+ value: network,
38
+ label: networkLabel(network),
39
+ hint: networkSubtitle(network),
40
+ }
41
+ }