ethagent 2.3.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/README.md +18 -4
  2. package/package.json +2 -1
  3. package/src/app/FirstRun.tsx +157 -15
  4. package/src/app/FirstRunTimeline.tsx +4 -0
  5. package/src/app/input/AppInputProvider.tsx +19 -0
  6. package/src/app/input/appInputParser.ts +19 -4
  7. package/src/chat/ChatBottomPane.tsx +12 -1
  8. package/src/chat/ChatScreen.tsx +17 -5
  9. package/src/chat/ConversationStack.tsx +25 -19
  10. package/src/chat/MessageList.tsx +194 -53
  11. package/src/chat/chatSessionState.ts +4 -1
  12. package/src/chat/chatTurnOrchestrator.ts +65 -2
  13. package/src/chat/input/ChatInput.tsx +28 -2
  14. package/src/chat/input/imageRefs.ts +30 -0
  15. package/src/chat/input/textCursor.ts +13 -3
  16. package/src/chat/transcript/TranscriptView.tsx +7 -5
  17. package/src/chat/transcript/transcriptViewport.ts +88 -17
  18. package/src/chat/views/PermissionPrompt.tsx +26 -26
  19. package/src/chat/views/PermissionsView.tsx +18 -12
  20. package/src/chat/views/ResumeView.tsx +16 -7
  21. package/src/chat/views/RewindView.tsx +3 -1
  22. package/src/cli/ResetConfirmView.tsx +24 -9
  23. package/src/identity/continuity/editor.ts +27 -2
  24. package/src/identity/continuity/envelope.ts +125 -0
  25. package/src/identity/continuity/publicSkills.ts +37 -1
  26. package/src/identity/continuity/skills/frontmatter.ts +183 -0
  27. package/src/identity/continuity/skills/loadSkills.ts +609 -0
  28. package/src/identity/continuity/skills/publicSkillsSync.ts +32 -0
  29. package/src/identity/continuity/skills/scaffold.ts +52 -0
  30. package/src/identity/continuity/skills/types.ts +30 -0
  31. package/src/identity/continuity/storage/defaults.ts +28 -47
  32. package/src/identity/continuity/storage/files.ts +1 -0
  33. package/src/identity/continuity/storage/paths.ts +1 -0
  34. package/src/identity/continuity/storage/scaffold.ts +25 -23
  35. package/src/identity/continuity/storage/status.ts +34 -5
  36. package/src/identity/continuity/storage/types.ts +3 -2
  37. package/src/identity/continuity/storage.ts +3 -0
  38. package/src/identity/hub/OperationalRoutes.tsx +105 -3
  39. package/src/identity/hub/Routes.tsx +5 -3
  40. package/src/identity/hub/continuity/ContinuityDashboardScreen.tsx +5 -51
  41. package/src/identity/hub/continuity/RecoveryConfirmScreen.tsx +1 -1
  42. package/src/identity/hub/continuity/SavePromptScreen.tsx +1 -0
  43. package/src/identity/hub/continuity/effects.ts +36 -5
  44. package/src/identity/hub/continuity/skills/DeleteSkillConfirmScreen.tsx +112 -0
  45. package/src/identity/hub/continuity/skills/DeleteSkillScreen.tsx +123 -0
  46. package/src/identity/hub/continuity/skills/NewSkillScreen.tsx +57 -0
  47. package/src/identity/hub/continuity/skills/NewSkillVisibilityScreen.tsx +52 -0
  48. package/src/identity/hub/continuity/skills/SkillVisibilityScreen.tsx +171 -0
  49. package/src/identity/hub/continuity/skills/SkillsTreeScreen.tsx +213 -0
  50. package/src/identity/hub/continuity/snapshot.ts +3 -0
  51. package/src/identity/hub/continuity/state.ts +3 -2
  52. package/src/identity/hub/continuity/vault.ts +42 -10
  53. package/src/identity/hub/custody/CustodyEditFlow.tsx +3 -3
  54. package/src/identity/hub/identityHubReducer.ts +21 -0
  55. package/src/identity/hub/profile/effects.ts +16 -3
  56. package/src/identity/hub/restore/RestoreFlow.tsx +43 -6
  57. package/src/identity/hub/restore/apply.ts +12 -1
  58. package/src/identity/hub/restore/recovery.ts +11 -1
  59. package/src/identity/hub/restore/resolve.ts +1 -1
  60. package/src/identity/hub/restore/useRestoreEffects.ts +4 -6
  61. package/src/identity/hub/shared/components/DetailsScreen.tsx +4 -1
  62. package/src/identity/hub/shared/components/IdentitySummary.tsx +97 -53
  63. package/src/identity/hub/shared/components/MenuScreen.tsx +18 -15
  64. package/src/identity/hub/shared/components/UnlinkedIdentityScreen.tsx +1 -1
  65. package/src/identity/hub/shared/components/menuFlagsFromReconciliation.ts +8 -12
  66. package/src/identity/hub/shared/effects/sync.ts +16 -3
  67. package/src/identity/hub/shared/model/copy.ts +2 -4
  68. package/src/identity/hub/transfer/effects.ts +15 -2
  69. package/src/identity/hub/useIdentityHubContinuity.ts +145 -23
  70. package/src/identity/hub/useIdentityHubController.ts +5 -1
  71. package/src/identity/hub/useIdentityHubSideEffects.ts +2 -4
  72. package/src/mcp/manager.ts +1 -1
  73. package/src/models/ModelPicker.tsx +211 -74
  74. package/src/models/huggingface.ts +180 -2
  75. package/src/models/llamacpp.ts +261 -17
  76. package/src/models/llamacppPreflight.ts +16 -12
  77. package/src/models/modelPickerOptions.ts +57 -38
  78. package/src/providers/anthropic.ts +36 -5
  79. package/src/providers/contracts.ts +10 -1
  80. package/src/providers/gemini.ts +29 -3
  81. package/src/providers/openai-chat.ts +131 -11
  82. package/src/providers/openai-responses-format.ts +29 -8
  83. package/src/providers/openai-responses.ts +41 -11
  84. package/src/providers/registry.ts +1 -0
  85. package/src/runtime/toolExecution.ts +4 -3
  86. package/src/runtime/turn.ts +61 -30
  87. package/src/storage/config.ts +1 -0
  88. package/src/storage/sessions.ts +14 -2
  89. package/src/tools/changeDirectoryTool.ts +1 -1
  90. package/src/tools/contracts.ts +10 -0
  91. package/src/tools/deleteFileTool.ts +1 -1
  92. package/src/tools/editTool.ts +1 -1
  93. package/src/tools/listDirectoryTool.ts +1 -1
  94. package/src/tools/listSkillFilesTool.ts +77 -0
  95. package/src/tools/listSkillsTool.ts +68 -0
  96. package/src/tools/mcpResourceTools.ts +2 -2
  97. package/src/tools/privateContinuityReadTool.ts +1 -1
  98. package/src/tools/readSkillTool.ts +107 -0
  99. package/src/tools/readTool.ts +1 -1
  100. package/src/tools/registry.ts +6 -0
  101. package/src/tools/writeFileTool.ts +22 -2
  102. package/src/ui/Spinner.tsx +15 -3
  103. package/src/ui/theme.ts +2 -0
  104. package/src/utils/images.ts +140 -0
  105. package/src/utils/messages.ts +2 -0
  106. package/src/identity/continuity/localBackup.ts +0 -249
  107. package/src/identity/continuity/zipWriter.ts +0 -95
  108. package/src/identity/hub/continuity/index.ts +0 -7
  109. package/src/identity/hub/ens/index.ts +0 -11
  110. package/src/identity/hub/restore/index.ts +0 -22
@@ -37,6 +37,7 @@ export const SavePromptScreen: React.FC<SavePromptScreenProps> = ({ workingStatu
37
37
  options={[
38
38
  { value: 'save-now', role: 'section', label: 'Save' },
39
39
  { value: 'save-now', label: 'Save now', hint: 'Sign once and save the encrypted snapshot' },
40
+ { value: 'later', role: 'section', label: 'Defer' },
40
41
  { value: 'later', label: 'Not now', hint: 'Ask again on the next ethagent launch', role: 'utility' },
41
42
  ]}
42
43
  hintLayout="inline"
@@ -4,14 +4,25 @@ import type { EthagentIdentity } from '../../../storage/config.js'
4
4
  import {
5
5
  continuityVaultStatus,
6
6
  prepareSyncedIdentityMarkdownScaffold,
7
+ prepareSyncedSkillsTree,
7
8
  readContinuityFiles,
8
9
  readPublicSkillsFile,
9
10
  writeIdentityMarkdownScaffold,
10
11
  type IdentityMarkdownScaffold,
11
12
  } from '../../continuity/storage.js'
13
+ import {
14
+ appendPublicSkillEntries,
15
+ defaultPublicSkillsProfile as basePublicSkillsProfile,
16
+ } from '../../continuity/publicSkills.js'
17
+ import {
18
+ derivePublicSkillEntries,
19
+ syncPublicSkillsManifest,
20
+ } from '../../continuity/skills/publicSkillsSync.js'
12
21
  import {
13
22
  createWalletRestoreAccessChallenge,
14
23
  serializeContinuitySnapshotEnvelope,
24
+ type ContinuityFiles,
25
+ type ContinuitySkillsTree,
15
26
  type WalletChallengePurpose,
16
27
  } from '../../continuity/envelope.js'
17
28
  import {
@@ -78,6 +89,11 @@ type RebackupPreparedTransaction = {
78
89
  publicSkills: PublicSkillsMetadata
79
90
  identity: EthagentIdentity
80
91
  markdownScaffold?: IdentityMarkdownScaffold
92
+ publishedSources: {
93
+ privateFiles: ContinuityFiles
94
+ publicSkills: string
95
+ skills: ContinuitySkillsTree
96
+ }
81
97
  }
82
98
 
83
99
  export async function runRebackupPreflight(
@@ -224,18 +240,22 @@ async function runRebackupSigningInner(
224
240
  const continuityFiles = markdownScaffold
225
241
  ? { 'SOUL.md': markdownScaffold['SOUL.md'], 'MEMORY.md': markdownScaffold['MEMORY.md'] }
226
242
  : await readContinuityFiles(nextIdentityForFiles)
227
- const publicSkillsJson = markdownScaffold
228
- ? markdownScaffold['skills.json']
229
- : await readPublicSkillsFile(nextIdentityForFiles)
243
+ const publicSkillsJson = await syncPublicSkillsManifest(nextIdentityForFiles)
230
244
  const publicSkillsPin = await addToIpfs(DEFAULT_IPFS_API_URL, publicSkillsJson, fetch, { pinataJwt: step.pinataJwt })
231
245
  assertVerifiedPin(publicSkillsPin)
246
+ const publicSkillEntries = await derivePublicSkillEntries(nextIdentityForFiles)
247
+ const augmentedPublicProfile = appendPublicSkillEntries(
248
+ basePublicSkillsProfile(nextIdentityForFiles),
249
+ publicSkillEntries,
250
+ )
232
251
  const agentCardPin = await addToIpfs(
233
252
  DEFAULT_IPFS_API_URL,
234
- serializeAgentCard(createAgentCard(defaultPublicSkillsProfile(nextIdentityForFiles))),
253
+ serializeAgentCard(createAgentCard(augmentedPublicProfile)),
235
254
  fetch,
236
255
  { pinataJwt: step.pinataJwt },
237
256
  )
238
257
  assertVerifiedPin(agentCardPin)
258
+ const skillsTree = await prepareSyncedSkillsTree(nextIdentityForFiles)
239
259
  const envelope = createContinuityEnvelopeForSave({
240
260
  identity: nextIdentityForFiles,
241
261
  registry: step.registry,
@@ -244,6 +264,7 @@ async function runRebackupSigningInner(
244
264
  walletSignature: wallet.signature,
245
265
  state,
246
266
  files: continuityFiles,
267
+ skills: skillsTree,
247
268
  walletAccess,
248
269
  ...(challengePurpose ? { challengePurpose } : {}),
249
270
  })
@@ -310,6 +331,11 @@ async function runRebackupSigningInner(
310
331
  publicSkills,
311
332
  identity: { ...step.identity, state },
312
333
  ...(markdownScaffold ? { markdownScaffold } : {}),
334
+ publishedSources: {
335
+ privateFiles: continuityFiles,
336
+ publicSkills: publicSkillsJson,
337
+ skills: skillsTree,
338
+ },
313
339
  },
314
340
  }
315
341
  }
@@ -330,6 +356,11 @@ async function runRebackupSigningInner(
330
356
  publicSkills,
331
357
  identity: { ...step.identity, state },
332
358
  ...(markdownScaffold ? { markdownScaffold } : {}),
359
+ publishedSources: {
360
+ privateFiles: continuityFiles,
361
+ publicSkills: publicSkillsJson,
362
+ skills: skillsTree,
363
+ },
333
364
  },
334
365
  }
335
366
  },
@@ -359,7 +390,7 @@ async function runRebackupSigningInner(
359
390
  await writeIdentityMarkdownScaffold(nextIdentity, result.prepared.markdownScaffold)
360
391
  }
361
392
  await recordPublishedContinuitySnapshot({ identity: nextIdentity, label: 'published encrypted snapshot' }).catch(() => null)
362
- await markCurrentContinuityFilesPublished(nextIdentity).catch(() => null)
393
+ await markCurrentContinuityFilesPublished(nextIdentity, result.prepared.publishedSources).catch(() => null)
363
394
  const resolverSyncWarning = await syncVaultOperatorsAfterOwnerSave({
364
395
  beforeIdentity: step.identity,
365
396
  afterIdentity: nextIdentity,
@@ -0,0 +1,112 @@
1
+ import React, { useEffect, useState } from 'react'
2
+ import { Box, 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 { EthagentIdentity } from '../../../../storage/config.js'
7
+ import {
8
+ listSkills,
9
+ listSkillFiles,
10
+ type SkillFileEntry,
11
+ } from '../../../continuity/skills/loadSkills.js'
12
+ import { continuityVaultRef } from '../../../continuity/storage.js'
13
+ import type { SkillIndexEntry } from '../../../continuity/skills/types.js'
14
+
15
+ type ConfirmChoice = 'delete' | 'cancel'
16
+
17
+ type DeleteTarget = { kind: 'skill'; relativePath: string }
18
+
19
+ interface DeleteSkillConfirmScreenProps {
20
+ identity?: EthagentIdentity
21
+ target: DeleteTarget
22
+ footer: React.ReactNode
23
+ onConfirm: () => void
24
+ onCancel: () => void
25
+ }
26
+
27
+ const MAX_LISTED_FILES = 10
28
+
29
+ export const DeleteSkillConfirmScreen: React.FC<DeleteSkillConfirmScreenProps> = ({
30
+ identity,
31
+ target,
32
+ footer,
33
+ onConfirm,
34
+ onCancel,
35
+ }) => {
36
+ const [entries, setEntries] = useState<SkillIndexEntry[] | null>(null)
37
+ const [files, setFiles] = useState<SkillFileEntry[] | null>(null)
38
+ const skillName = target.relativePath.split('/')[0] ?? ''
39
+
40
+ useEffect(() => {
41
+ let cancelled = false
42
+ if (!identity) { setEntries([]); setFiles([]); return () => { cancelled = true } }
43
+ setEntries(null)
44
+ setFiles(null)
45
+ listSkills(identity)
46
+ .then(result => { if (!cancelled) setEntries(result) })
47
+ .catch(() => { if (!cancelled) setEntries([]) })
48
+ listSkillFiles(identity, skillName)
49
+ .then(result => { if (!cancelled) setFiles(result) })
50
+ .catch(() => { if (!cancelled) setFiles([]) })
51
+ return () => { cancelled = true }
52
+ }, [identity, target.relativePath, skillName])
53
+
54
+ const ref = identity ? continuityVaultRef(identity) : undefined
55
+ const skillMissing = entries !== null && !entries.some(entry => entry.relativePath === target.relativePath)
56
+ const supportingCount = (files ?? []).filter(f => f.relativePath !== 'SKILL.md').length
57
+ const subtitle = skillMissing
58
+ ? `Skill ${skillName} was already removed elsewhere. Cancel and refresh.`
59
+ : supportingCount > 0
60
+ ? `Removes ${skillName}/ and ${supportingCount} supporting file${supportingCount === 1 ? '' : 's'}. This cannot be undone.`
61
+ : `Removes ${skillName}/. This cannot be undone.`
62
+
63
+ return (
64
+ <Surface title={`Delete ${skillName}?`} subtitle={subtitle} footer={footer} tone="error">
65
+ <Box marginTop={1} flexDirection="column">
66
+ <Text color={theme.dim}>This will remove:</Text>
67
+ {ref && (
68
+ <Box marginLeft={2} flexDirection="column">
69
+ <Text color={theme.text}>{ref.skillsDir}/{skillName}/</Text>
70
+ {files === null ? (
71
+ <Text color={theme.dim}>(loading folder contents...)</Text>
72
+ ) : (
73
+ <FolderContents files={files} />
74
+ )}
75
+ </Box>
76
+ )}
77
+ </Box>
78
+ <Box marginTop={1}>
79
+ <Select<ConfirmChoice>
80
+ options={[
81
+ { value: 'delete', label: 'Yes, delete', hint: skillMissing ? 'Skill already missing on disk' : 'Permanent. Will be reflected in the next snapshot.', bold: true, disabled: skillMissing },
82
+ { value: 'cancel', label: 'No, back', hint: 'Return to the delete picker', role: 'utility' },
83
+ ]}
84
+ hintLayout="inline"
85
+ initialIndex={1}
86
+ onSubmit={choice => {
87
+ if (choice === 'delete') return onConfirm()
88
+ return onCancel()
89
+ }}
90
+ onCancel={onCancel}
91
+ />
92
+ </Box>
93
+ </Surface>
94
+ )
95
+ }
96
+
97
+ const FolderContents: React.FC<{ files: SkillFileEntry[] }> = ({ files }) => {
98
+ if (files.length === 0) {
99
+ return <Text color={theme.dim}>(empty folder)</Text>
100
+ }
101
+ const shown = files.slice(0, MAX_LISTED_FILES)
102
+ const extra = files.length - shown.length
103
+ return (
104
+ <Box flexDirection="column" marginTop={1}>
105
+ <Text color={theme.dim}>Files to be removed:</Text>
106
+ {shown.map(f => (
107
+ <Text key={f.relativePath} color={theme.text}> · {f.relativePath}</Text>
108
+ ))}
109
+ {extra > 0 && <Text color={theme.dim}> · ...and {extra} more</Text>}
110
+ </Box>
111
+ )
112
+ }
@@ -0,0 +1,123 @@
1
+ import React, { useEffect, useState } from 'react'
2
+ import { Box, Text } from 'ink'
3
+ import { Surface } from '../../../../ui/Surface.js'
4
+ import { Select, type SelectOption } from '../../../../ui/Select.js'
5
+ import { theme } from '../../../../ui/theme.js'
6
+ import type { EthagentIdentity } from '../../../../storage/config.js'
7
+ import { listSkills } from '../../../continuity/skills/loadSkills.js'
8
+ import type { SkillIndexEntry } from '../../../continuity/skills/types.js'
9
+
10
+ type DeleteTarget =
11
+ | { kind: 'skill'; relativePath: string }
12
+ | { kind: 'cancel' }
13
+
14
+ interface DeleteSkillScreenProps {
15
+ identity?: EthagentIdentity
16
+ notice?: string
17
+ footer: React.ReactNode
18
+ onPick: (target: { kind: 'skill'; relativePath: string }) => void
19
+ onCancel: () => void
20
+ }
21
+
22
+ export const DeleteSkillScreen: React.FC<DeleteSkillScreenProps> = ({
23
+ identity,
24
+ notice,
25
+ footer,
26
+ onPick,
27
+ onCancel,
28
+ }) => {
29
+ const [entries, setEntries] = useState<SkillIndexEntry[] | null>(null)
30
+ const [error, setError] = useState<string | null>(null)
31
+
32
+ useEffect(() => {
33
+ let cancelled = false
34
+ if (!identity) {
35
+ setEntries([])
36
+ return () => { cancelled = true }
37
+ }
38
+ listSkills(identity)
39
+ .then(list => { if (!cancelled) { setEntries(list); setError(null) } })
40
+ .catch(err => {
41
+ if (cancelled) return
42
+ setEntries([])
43
+ setError(String((err as Error).message ?? err))
44
+ })
45
+ return () => { cancelled = true }
46
+ }, [identity])
47
+
48
+ const subtitle = notice ?? 'Pick a skill to delete. The whole folder will be removed.'
49
+ const isLoading = entries === null
50
+ const skills = entries ?? []
51
+ const hasAnything = skills.length > 0
52
+
53
+ const options = buildOptions(skills, isLoading)
54
+
55
+ return (
56
+ <Surface title="Delete Skill" subtitle={subtitle} footer={footer} tone="error">
57
+ {error && (
58
+ <Box marginTop={1}>
59
+ <Text color={theme.accentError}>{error}</Text>
60
+ </Box>
61
+ )}
62
+ {!isLoading && !hasAnything && (
63
+ <Box marginTop={1}>
64
+ <Text color={theme.dim}>Nothing to delete. The skills tree is empty.</Text>
65
+ </Box>
66
+ )}
67
+ <Box marginTop={1}>
68
+ <Select<DeleteTarget>
69
+ options={options}
70
+ hintLayout="inline"
71
+ onSubmit={choice => {
72
+ if (choice.kind === 'cancel') return onCancel()
73
+ return onPick(choice)
74
+ }}
75
+ onCancel={onCancel}
76
+ />
77
+ </Box>
78
+ </Surface>
79
+ )
80
+ }
81
+
82
+ function buildOptions(
83
+ entries: SkillIndexEntry[],
84
+ isLoading: boolean,
85
+ ): Array<SelectOption<DeleteTarget>> {
86
+ const rows: Array<SelectOption<DeleteTarget>> = []
87
+ const noopValue: DeleteTarget = { kind: 'cancel' }
88
+
89
+ if (isLoading) {
90
+ rows.push({
91
+ value: noopValue,
92
+ role: 'notice',
93
+ label: 'Loading...',
94
+ labelColor: theme.dim,
95
+ indent: 0,
96
+ })
97
+ } else if (entries.length > 0) {
98
+ rows.push({ value: noopValue, role: 'section', label: 'Targets' })
99
+ rows.push({ value: noopValue, role: 'notice', label: 'skills/', labelColor: theme.dim, indent: 3 })
100
+ const sorted = [...entries].sort((a, b) => a.name.localeCompare(b.name))
101
+ for (let i = 0; i < sorted.length; i++) {
102
+ const skill = sorted[i]
103
+ if (!skill) continue
104
+ const isLast = i === sorted.length - 1
105
+ const branch = isLast ? '└── ' : '├── '
106
+ rows.push({
107
+ value: { kind: 'skill', relativePath: skill.relativePath },
108
+ label: `${branch}${skill.name}/`,
109
+ indent: 3,
110
+ })
111
+ }
112
+ }
113
+
114
+ rows.push({ value: noopValue, role: 'section', label: 'Navigation' })
115
+ rows.push({
116
+ value: { kind: 'cancel' },
117
+ label: 'Back',
118
+ hint: 'Return to Skills',
119
+ role: 'utility',
120
+ })
121
+
122
+ return rows
123
+ }
@@ -0,0 +1,57 @@
1
+ import React from 'react'
2
+ import { Box, Text } from 'ink'
3
+ import { Surface } from '../../../../ui/Surface.js'
4
+ import { TextInput } from '../../../../ui/TextInput.js'
5
+ import { theme } from '../../../../ui/theme.js'
6
+
7
+ interface NewSkillScreenProps {
8
+ error?: string
9
+ footer: React.ReactNode
10
+ onSubmit: (name: string) => void
11
+ onCancel: () => void
12
+ }
13
+
14
+ export const NewSkillScreen: React.FC<NewSkillScreenProps> = ({
15
+ error,
16
+ footer,
17
+ onSubmit,
18
+ onCancel,
19
+ }) => (
20
+ <Surface
21
+ title="New Skill"
22
+ subtitle="Folder name. The skill activates by this name."
23
+ footer={footer}
24
+ >
25
+ <Box marginTop={1} flexDirection="column">
26
+ <TextInput
27
+ label="Folder name"
28
+ placeholder="<folder>"
29
+ validate={value => validateSegment(value)}
30
+ onSubmit={raw => {
31
+ const trimmed = raw.trim()
32
+ if (validateSegment(trimmed) === null) onSubmit(trimmed)
33
+ }}
34
+ onCancel={onCancel}
35
+ />
36
+ <Box marginTop={1}>
37
+ <Text color={theme.dim}>
38
+ Saved as skills/&lt;folder&gt;/SKILL.md. Add supporting files later by dropping them into the same folder.
39
+ </Text>
40
+ </Box>
41
+ {error && (
42
+ <Box marginTop={1}>
43
+ <Text color={theme.accentError}>{error}</Text>
44
+ </Box>
45
+ )}
46
+ </Box>
47
+ </Surface>
48
+ )
49
+
50
+ export function validateSegment(value: string): string | null {
51
+ const trimmed = value.trim()
52
+ if (!trimmed) return 'Enter a name.'
53
+ if (trimmed.includes('/') || trimmed.includes('\\')) return 'One segment only, no slashes.'
54
+ if (trimmed === '.' || trimmed === '..') return 'Reserved name.'
55
+ if (!/^[A-Za-z0-9._-]+$/.test(trimmed)) return 'Only letters, digits, dashes, underscores, dots.'
56
+ return null
57
+ }
@@ -0,0 +1,52 @@
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 { theme } from '../../../../ui/theme.js'
6
+ import type { SkillVisibility } from '../../../continuity/skills/types.js'
7
+
8
+ interface NewSkillVisibilityScreenProps {
9
+ name: string
10
+ error?: string
11
+ footer: React.ReactNode
12
+ onSelect: (visibility: SkillVisibility) => void
13
+ onCancel: () => void
14
+ }
15
+
16
+ export const NewSkillVisibilityScreen: React.FC<NewSkillVisibilityScreenProps> = ({
17
+ name,
18
+ error,
19
+ footer,
20
+ onSelect,
21
+ onCancel,
22
+ }) => (
23
+ <Surface
24
+ title={`Visibility · ${name}`}
25
+ subtitle="Discoverable is the default. You can change it later from Change Visibility."
26
+ footer={footer}
27
+ >
28
+ {error && (
29
+ <Box marginTop={1}>
30
+ <Text color={theme.accentError}>{error}</Text>
31
+ </Box>
32
+ )}
33
+ <Box marginTop={1}>
34
+ <Select<SkillVisibility | 'back'>
35
+ options={[
36
+ { value: 'private', label: 'Private', hint: 'Local-only. Not in skills.json.' },
37
+ { value: 'discoverable', label: 'Discoverable', hint: 'Default. Indexed in skills.json with description.' },
38
+ { value: 'public', label: 'Public', hint: 'Indexed in skills.json and Agent Card.' },
39
+ { value: 'back', role: 'section', label: 'Navigation' },
40
+ { value: 'back', label: 'Back', hint: 'Return to the name step', role: 'utility' },
41
+ ]}
42
+ hintLayout="inline"
43
+ initialIndex={1}
44
+ onSubmit={choice => {
45
+ if (choice === 'back') return onCancel()
46
+ return onSelect(choice)
47
+ }}
48
+ onCancel={onCancel}
49
+ />
50
+ </Box>
51
+ </Surface>
52
+ )
@@ -0,0 +1,171 @@
1
+ import React, { useEffect, useState } from 'react'
2
+ import { Box, Text } from 'ink'
3
+ import { Surface } from '../../../../ui/Surface.js'
4
+ import { Select, type SelectOption } from '../../../../ui/Select.js'
5
+ import { theme } from '../../../../ui/theme.js'
6
+ import type { EthagentIdentity } from '../../../../storage/config.js'
7
+ import { listSkills } from '../../../continuity/skills/loadSkills.js'
8
+ import type { SkillIndexEntry, SkillVisibility } from '../../../continuity/skills/types.js'
9
+
10
+ interface SkillVisibilityListProps {
11
+ identity?: EthagentIdentity
12
+ notice?: string
13
+ footer: React.ReactNode
14
+ onPick: (relativePath: string) => void
15
+ onCancel: () => void
16
+ }
17
+
18
+ type PickAction =
19
+ | { kind: 'skill'; relativePath: string }
20
+ | { kind: 'cancel' }
21
+
22
+ export const SkillVisibilityListScreen: React.FC<SkillVisibilityListProps> = ({
23
+ identity,
24
+ notice,
25
+ footer,
26
+ onPick,
27
+ onCancel,
28
+ }) => {
29
+ const [entries, setEntries] = useState<SkillIndexEntry[] | null>(null)
30
+ const [error, setError] = useState<string | null>(null)
31
+
32
+ useEffect(() => {
33
+ let cancelled = false
34
+ if (!identity) {
35
+ setEntries([])
36
+ return () => { cancelled = true }
37
+ }
38
+ listSkills(identity)
39
+ .then(list => { if (!cancelled) { setEntries(list); setError(null) } })
40
+ .catch(err => {
41
+ if (cancelled) return
42
+ setEntries([])
43
+ setError(String((err as Error).message ?? err))
44
+ })
45
+ return () => { cancelled = true }
46
+ }, [identity])
47
+
48
+ const subtitle = notice ?? 'Pick a skill to change its visibility in the public manifest.'
49
+ const isLoading = entries === null
50
+ const skills = entries ?? []
51
+
52
+ const options: Array<SelectOption<PickAction>> = []
53
+ if (isLoading) {
54
+ options.push({ value: { kind: 'cancel' }, role: 'notice', label: 'Loading...', labelColor: theme.dim, indent: 0 })
55
+ } else if (skills.length === 0) {
56
+ options.push({ value: { kind: 'cancel' }, role: 'notice', label: 'No skills yet. Create one first.', labelColor: theme.dim, indent: 0 })
57
+ } else {
58
+ options.push({ value: { kind: 'cancel' }, role: 'section', label: 'Skills' })
59
+ for (const skill of skills) {
60
+ options.push({
61
+ value: { kind: 'skill', relativePath: skill.relativePath },
62
+ label: skill.displayName ?? skill.name,
63
+ hint: visibilityBadge(skill.visibility),
64
+ hintColor: visibilityColor(skill.visibility),
65
+ })
66
+ }
67
+ }
68
+ options.push({ value: { kind: 'cancel' }, role: 'section', label: 'Navigation' })
69
+ options.push({ value: { kind: 'cancel' }, label: 'Back', hint: 'Return to Skills', role: 'utility' })
70
+
71
+ return (
72
+ <Surface title="Skill Visibility" subtitle={subtitle} footer={footer}>
73
+ {error && (
74
+ <Box marginTop={1}>
75
+ <Text color={theme.accentError}>{error}</Text>
76
+ </Box>
77
+ )}
78
+ <Box marginTop={1}>
79
+ <Select<PickAction>
80
+ options={options}
81
+ hintLayout="inline"
82
+ onSubmit={choice => {
83
+ if (choice.kind === 'cancel') return onCancel()
84
+ return onPick(choice.relativePath)
85
+ }}
86
+ onCancel={onCancel}
87
+ />
88
+ </Box>
89
+ </Surface>
90
+ )
91
+ }
92
+
93
+ interface SkillVisibilityPickProps {
94
+ identity?: EthagentIdentity
95
+ relativePath: string
96
+ footer: React.ReactNode
97
+ onSelect: (visibility: SkillVisibility) => void
98
+ onCancel: () => void
99
+ }
100
+
101
+ export const SkillVisibilityPickScreen: React.FC<SkillVisibilityPickProps> = ({
102
+ identity,
103
+ relativePath,
104
+ footer,
105
+ onSelect,
106
+ onCancel,
107
+ }) => {
108
+ const [current, setCurrent] = useState<SkillVisibility | null>(null)
109
+ const [displayName, setDisplayName] = useState<string>(relativePath)
110
+
111
+ useEffect(() => {
112
+ let cancelled = false
113
+ if (!identity) return () => { cancelled = true }
114
+ listSkills(identity)
115
+ .then(list => {
116
+ if (cancelled) return
117
+ const match = list.find(entry => entry.relativePath === relativePath)
118
+ if (match) {
119
+ setCurrent(match.visibility)
120
+ setDisplayName(match.displayName ?? match.name)
121
+ }
122
+ })
123
+ .catch(() => null)
124
+ return () => { cancelled = true }
125
+ }, [identity, relativePath])
126
+
127
+ const subtitle = current
128
+ ? `Current: ${current}. Pick a new value.`
129
+ : 'Pick a visibility level.'
130
+
131
+ return (
132
+ <Surface title={`Visibility · ${displayName}`} subtitle={subtitle} footer={footer}>
133
+ <Box marginTop={1}>
134
+ <Select<SkillVisibility | 'cancel'>
135
+ options={[
136
+ { value: 'private', label: 'Private', hint: 'Local-only. Not in skills.json.' },
137
+ { value: 'discoverable', label: 'Discoverable', hint: 'Default. Indexed in skills.json with description.' },
138
+ { value: 'public', label: 'Public', hint: 'Indexed in skills.json and Agent Card.' },
139
+ { value: 'cancel', role: 'section', label: 'Navigation' },
140
+ { value: 'cancel', label: 'Back', hint: 'Return to skill list', role: 'utility' },
141
+ ]}
142
+ hintLayout="inline"
143
+ initialIndex={current ? indexForVisibility(current) : 0}
144
+ onSubmit={choice => {
145
+ if (choice === 'cancel') return onCancel()
146
+ return onSelect(choice)
147
+ }}
148
+ onCancel={onCancel}
149
+ />
150
+ </Box>
151
+ </Surface>
152
+ )
153
+ }
154
+
155
+ function visibilityBadge(visibility: SkillVisibility): string {
156
+ if (visibility === 'public') return '[public]'
157
+ if (visibility === 'discoverable') return '[discoverable]'
158
+ return '[private]'
159
+ }
160
+
161
+ function visibilityColor(visibility: SkillVisibility): string {
162
+ if (visibility === 'public') return theme.accentPeriwinkle
163
+ if (visibility === 'discoverable') return theme.textSubtle
164
+ return theme.dim
165
+ }
166
+
167
+ function indexForVisibility(visibility: SkillVisibility): number {
168
+ if (visibility === 'private') return 0
169
+ if (visibility === 'discoverable') return 1
170
+ return 2
171
+ }