ethagent 2.4.0 → 3.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 (103) hide show
  1. package/README.md +7 -4
  2. package/package.json +2 -1
  3. package/src/app/FirstRun.tsx +155 -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 +3 -1
  8. package/src/chat/ChatScreen.tsx +7 -1
  9. package/src/chat/ConversationStack.tsx +25 -19
  10. package/src/chat/MessageList.tsx +194 -53
  11. package/src/chat/chatSessionState.ts +1 -1
  12. package/src/chat/chatTurnOrchestrator.ts +59 -0
  13. package/src/chat/input/ChatInput.tsx +3 -0
  14. package/src/chat/input/textCursor.ts +13 -3
  15. package/src/chat/transcript/TranscriptView.tsx +7 -5
  16. package/src/chat/transcript/transcriptViewport.ts +88 -17
  17. package/src/chat/views/PermissionPrompt.tsx +26 -26
  18. package/src/chat/views/PermissionsView.tsx +18 -12
  19. package/src/chat/views/RewindView.tsx +3 -1
  20. package/src/cli/ResetConfirmView.tsx +24 -9
  21. package/src/identity/continuity/editor.ts +27 -2
  22. package/src/identity/continuity/envelope.ts +134 -9
  23. package/src/identity/continuity/publicSkills.ts +54 -1
  24. package/src/identity/continuity/skills/frontmatter.ts +183 -0
  25. package/src/identity/continuity/skills/loadSkills.ts +609 -0
  26. package/src/identity/continuity/skills/publicSkillsSync.ts +32 -0
  27. package/src/identity/continuity/skills/scaffold.ts +52 -0
  28. package/src/identity/continuity/skills/types.ts +30 -0
  29. package/src/identity/continuity/storage/defaults.ts +28 -47
  30. package/src/identity/continuity/storage/files.ts +1 -0
  31. package/src/identity/continuity/storage/paths.ts +1 -0
  32. package/src/identity/continuity/storage/scaffold.ts +25 -23
  33. package/src/identity/continuity/storage/status.ts +34 -5
  34. package/src/identity/continuity/storage/types.ts +3 -2
  35. package/src/identity/continuity/storage.ts +3 -0
  36. package/src/identity/hub/OperationalRoutes.tsx +79 -5
  37. package/src/identity/hub/Routes.tsx +5 -3
  38. package/src/identity/hub/continuity/ContinuityDashboardScreen.tsx +7 -73
  39. package/src/identity/hub/continuity/RecoveryConfirmScreen.tsx +6 -6
  40. package/src/identity/hub/continuity/SavePromptScreen.tsx +1 -2
  41. package/src/identity/hub/continuity/effects.ts +36 -5
  42. package/src/identity/hub/continuity/skills/DeleteSkillConfirmScreen.tsx +112 -0
  43. package/src/identity/hub/continuity/skills/NewSkillScreen.tsx +57 -0
  44. package/src/identity/hub/continuity/skills/NewSkillVisibilityScreen.tsx +52 -0
  45. package/src/identity/hub/continuity/skills/SkillActionsScreen.tsx +151 -0
  46. package/src/identity/hub/continuity/skills/SkillsTreeScreen.tsx +181 -0
  47. package/src/identity/hub/continuity/snapshot.ts +3 -0
  48. package/src/identity/hub/continuity/state.ts +9 -8
  49. package/src/identity/hub/continuity/vault.ts +42 -10
  50. package/src/identity/hub/create/CreateFlow.tsx +1 -1
  51. package/src/identity/hub/custody/CustodyEditFlow.tsx +3 -3
  52. package/src/identity/hub/custody/routes.tsx +1 -1
  53. package/src/identity/hub/ens/EnsEditAdvancedScreens.tsx +0 -1
  54. package/src/identity/hub/ens/EnsEditMaintenanceScreens.tsx +0 -1
  55. package/src/identity/hub/identityHubReducer.ts +15 -0
  56. package/src/identity/hub/profile/EditProfileFlow.tsx +5 -5
  57. package/src/identity/hub/profile/effects.ts +16 -3
  58. package/src/identity/hub/restore/RestoreFlow.tsx +43 -6
  59. package/src/identity/hub/restore/apply.ts +12 -1
  60. package/src/identity/hub/restore/recovery.ts +14 -4
  61. package/src/identity/hub/restore/resolve.ts +1 -1
  62. package/src/identity/hub/restore/useRestoreEffects.ts +4 -6
  63. package/src/identity/hub/shared/components/DetailsScreen.tsx +4 -1
  64. package/src/identity/hub/shared/components/IdentitySummary.tsx +118 -54
  65. package/src/identity/hub/shared/components/MenuScreen.tsx +21 -18
  66. package/src/identity/hub/shared/components/UnlinkedIdentityScreen.tsx +4 -4
  67. package/src/identity/hub/shared/components/menuFlagsFromReconciliation.ts +8 -12
  68. package/src/identity/hub/shared/effects/sync.ts +16 -3
  69. package/src/identity/hub/shared/model/copy.ts +2 -4
  70. package/src/identity/hub/transfer/effects.ts +15 -2
  71. package/src/identity/hub/useIdentityHubContinuity.ts +145 -23
  72. package/src/identity/hub/useIdentityHubController.ts +5 -1
  73. package/src/identity/hub/useIdentityHubSideEffects.ts +2 -4
  74. package/src/identity/wallet/page/copy.ts +43 -43
  75. package/src/mcp/manager.ts +1 -1
  76. package/src/models/ModelPicker.tsx +89 -84
  77. package/src/models/llamacpp.ts +160 -11
  78. package/src/models/llamacppPreflight.ts +1 -16
  79. package/src/models/modelPickerOptions.ts +45 -37
  80. package/src/providers/contracts.ts +1 -0
  81. package/src/providers/openai-chat.ts +50 -9
  82. package/src/providers/openai-responses.ts +19 -4
  83. package/src/runtime/toolExecution.ts +4 -3
  84. package/src/runtime/turn.ts +61 -30
  85. package/src/tools/changeDirectoryTool.ts +1 -1
  86. package/src/tools/contracts.ts +10 -0
  87. package/src/tools/deleteFileTool.ts +1 -1
  88. package/src/tools/editTool.ts +1 -1
  89. package/src/tools/listDirectoryTool.ts +1 -1
  90. package/src/tools/listSkillFilesTool.ts +77 -0
  91. package/src/tools/listSkillsTool.ts +68 -0
  92. package/src/tools/mcpResourceTools.ts +2 -2
  93. package/src/tools/privateContinuityReadTool.ts +1 -1
  94. package/src/tools/readSkillTool.ts +107 -0
  95. package/src/tools/readTool.ts +1 -1
  96. package/src/tools/registry.ts +6 -0
  97. package/src/tools/writeFileTool.ts +22 -2
  98. package/src/ui/Spinner.tsx +1 -1
  99. package/src/identity/continuity/localBackup.ts +0 -249
  100. package/src/identity/continuity/zipWriter.ts +0 -95
  101. package/src/identity/hub/continuity/index.ts +0 -7
  102. package/src/identity/hub/ens/index.ts +0 -11
  103. package/src/identity/hub/restore/index.ts +0 -22
@@ -20,15 +20,15 @@ interface RecoveryConfirmScreenProps {
20
20
 
21
21
  export const RecoveryConfirmScreen: React.FC<RecoveryConfirmScreenProps> = ({ mode, workingStatus, pendingPublish, footer, onConfirm, onBack }) => {
22
22
  const isPublish = mode === 'publish'
23
- const title = isPublish ? 'Save Snapshot?' : 'Refetch Latest From Chain?'
23
+ const title = isPublish ? 'Save Snapshot?' : 'Refetch Latest From Onchain?'
24
24
  const subtitle = isPublish
25
- ? 'Saves SOUL.md, MEMORY.md, skills.json, and profile changes.'
25
+ ? 'Saves SOUL.md, MEMORY.md, skills, and profile changes.'
26
26
  : 'This overwrites local files with the onchain version.'
27
27
 
28
28
  const headlineColor = theme.accentPeriwinkle
29
29
  const headline = isPublish
30
30
  ? 'Saving updates the onchain pointer for this agent.'
31
- : 'Refetching replaces SOUL.md, MEMORY.md, and skills.json with what is onchain.'
31
+ : 'Refetching replaces SOUL.md, MEMORY.md, and skills with what is onchain.'
32
32
  const detail = isPublish
33
33
  ? 'Your local continuity files and profile edits become the saved state. The previous snapshot pointer is overwritten.'
34
34
  : 'Unsaved local edits will be lost. Use this when local files are missing or out of sync with the latest saved snapshot.'
@@ -47,7 +47,7 @@ export const RecoveryConfirmScreen: React.FC<RecoveryConfirmScreenProps> = ({ mo
47
47
  )}
48
48
  {!isPublish && pendingPublish ? (
49
49
  <Box marginTop={1} flexDirection="column">
50
- <Text color={theme.accentError} bold>Local snapshot is ahead of chain.</Text>
50
+ <Text color={theme.accentError} bold>Local snapshot is ahead of onchain.</Text>
51
51
  <Text color={theme.textSubtle}>Local edits have not yet been rotated to the onchain pointer. Refetching discards them and reverts to the last published snapshot.</Text>
52
52
  </Box>
53
53
  ) : null}
@@ -63,10 +63,10 @@ export const RecoveryConfirmScreen: React.FC<RecoveryConfirmScreenProps> = ({ mo
63
63
  { value: 'confirm', role: 'section', label: isPublish ? 'Save' : 'Refetch' },
64
64
  {
65
65
  value: 'confirm',
66
- label: isPublish ? 'Yes, Save Snapshot Now' : 'Yes, Refetch From Chain',
66
+ label: isPublish ? 'Yes, Save Snapshot Now' : 'Yes, Refetch From Onchain',
67
67
  hint: isPublish ? 'Sign and save the encrypted snapshot' : 'Wallet decrypts and overwrites local files',
68
68
  },
69
- { value: 'back', role: 'section', label: 'Cancel' },
69
+ { value: 'back', role: 'section', label: 'Navigation' },
70
70
  {
71
71
  value: 'back',
72
72
  label: 'No, Go Back',
@@ -35,9 +35,8 @@ export const SavePromptScreen: React.FC<SavePromptScreenProps> = ({ workingStatu
35
35
  <Box marginTop={1}>
36
36
  <Select<SavePromptAction>
37
37
  options={[
38
- { value: 'save-now', role: 'section', label: 'Save' },
39
38
  { value: 'save-now', label: 'Save now', hint: 'Sign once and save the encrypted snapshot' },
40
- { value: 'later', label: 'Not now', hint: 'Ask again on the next ethagent launch', role: 'utility' },
39
+ { value: 'later', label: 'Not now', hint: 'Ask again on the next launch', role: 'utility' },
41
40
  ]}
42
41
  hintLayout="inline"
43
42
  onSubmit={onSelect}
@@ -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,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,151 @@
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, listSkillFiles } from '../../../continuity/skills/loadSkills.js'
8
+ import type { SkillIndexEntry, SkillVisibility } from '../../../continuity/skills/types.js'
9
+
10
+ type SkillAction =
11
+ | { kind: 'open' }
12
+ | { kind: 'set-visibility'; visibility: SkillVisibility }
13
+ | { kind: 'delete' }
14
+ | { kind: 'back' }
15
+ | { kind: 'noop' }
16
+
17
+ interface SkillActionsScreenProps {
18
+ identity?: EthagentIdentity
19
+ relativePath: string
20
+ notice?: string
21
+ footer: React.ReactNode
22
+ onOpenSkill: (relativePath: string) => void
23
+ onSetVisibility: (relativePath: string, visibility: SkillVisibility) => void
24
+ onDelete: (relativePath: string) => void
25
+ onBack: () => void
26
+ }
27
+
28
+ export const SkillActionsScreen: React.FC<SkillActionsScreenProps> = ({
29
+ identity,
30
+ relativePath,
31
+ notice,
32
+ footer,
33
+ onOpenSkill,
34
+ onSetVisibility,
35
+ onDelete,
36
+ onBack,
37
+ }) => {
38
+ const [entry, setEntry] = useState<SkillIndexEntry | null>(null)
39
+ const [supportingCount, setSupportingCount] = useState<number | null>(null)
40
+ const skillName = relativePath.split('/')[0] ?? ''
41
+
42
+ useEffect(() => {
43
+ let cancelled = false
44
+ if (!identity) return () => { cancelled = true }
45
+ listSkills(identity)
46
+ .then(list => {
47
+ if (cancelled) return
48
+ const match = list.find(item => item.relativePath === relativePath)
49
+ setEntry(match ?? null)
50
+ })
51
+ .catch(() => { if (!cancelled) setEntry(null) })
52
+ listSkillFiles(identity, skillName)
53
+ .then(files => {
54
+ if (cancelled) return
55
+ setSupportingCount(files.filter(f => f.relativePath !== 'SKILL.md').length)
56
+ })
57
+ .catch(() => { if (!cancelled) setSupportingCount(null) })
58
+ return () => { cancelled = true }
59
+ }, [identity, relativePath, skillName, notice])
60
+
61
+ const displayName = entry?.displayName ?? entry?.name ?? skillName
62
+ const visibility = entry?.visibility
63
+ const subtitle = notice ?? 'Open, change visibility, or delete this skill.'
64
+
65
+ const options: Array<SelectOption<SkillAction>> = []
66
+ const noop: SkillAction = { kind: 'noop' }
67
+
68
+ options.push({ value: noop, role: 'section', label: 'Open' })
69
+ options.push({
70
+ value: { kind: 'open' },
71
+ label: 'Open SKILL.md',
72
+ hint: 'View or edit this skill',
73
+ })
74
+
75
+ options.push({ value: noop, role: 'section', label: 'Visibility' })
76
+ options.push(visibilityOption('private', visibility))
77
+ options.push(visibilityOption('discoverable', visibility))
78
+ options.push(visibilityOption('public', visibility))
79
+
80
+ options.push({ value: noop, role: 'section', label: 'Manage' })
81
+ options.push({
82
+ value: { kind: 'delete' },
83
+ label: 'Delete',
84
+ hint: 'Remove this skill folder and its supporting files',
85
+ })
86
+ options.push({
87
+ value: { kind: 'back' },
88
+ label: 'Back',
89
+ hint: 'Return to Skills',
90
+ role: 'utility',
91
+ })
92
+
93
+ const leafMeta = formatLeafMeta(visibility, supportingCount)
94
+
95
+ return (
96
+ <Surface title={`Skill · ${displayName}`} subtitle={subtitle} footer={footer}>
97
+ <Box flexDirection="column" marginTop={1}>
98
+ <Text color={theme.dim}>skills/</Text>
99
+ <Text>
100
+ <Text color={theme.dim}>└── </Text>
101
+ <Text color={theme.accentPeriwinkle} bold>{`${skillName}/SKILL.md`}</Text>
102
+ {leafMeta ? <Text color={theme.dim}>{` ${leafMeta}`}</Text> : null}
103
+ </Text>
104
+ </Box>
105
+ <Box marginTop={1}>
106
+ <Select<SkillAction>
107
+ options={options}
108
+ hintLayout="inline"
109
+ onSubmit={choice => {
110
+ if (choice.kind === 'open') return onOpenSkill(relativePath)
111
+ if (choice.kind === 'set-visibility') return onSetVisibility(relativePath, choice.visibility)
112
+ if (choice.kind === 'delete') return onDelete(relativePath)
113
+ if (choice.kind === 'back') return onBack()
114
+ }}
115
+ onCancel={onBack}
116
+ />
117
+ </Box>
118
+ </Surface>
119
+ )
120
+ }
121
+
122
+ function formatLeafMeta(visibility: SkillVisibility | undefined, supportingCount: number | null): string {
123
+ if (!visibility) return ''
124
+ const fileLabel = supportingCount === null
125
+ ? null
126
+ : supportingCount === 0 ? '1 file' : `${supportingCount + 1} files`
127
+ return fileLabel ? `${capitalize(visibility)} · ${fileLabel}` : capitalize(visibility)
128
+ }
129
+
130
+ function visibilityOption(level: SkillVisibility, current?: SkillVisibility): SelectOption<SkillAction> {
131
+ const isCurrent = current === level
132
+ const hint = visibilityHint(level) + (isCurrent ? ' · current' : '')
133
+ const base: SelectOption<SkillAction> = {
134
+ value: { kind: 'set-visibility', visibility: level },
135
+ label: `Set ${capitalize(level)}`,
136
+ hint,
137
+ }
138
+ if (isCurrent) base.labelColor = theme.accentPeriwinkle
139
+ return base
140
+ }
141
+
142
+ function visibilityHint(level: SkillVisibility): string {
143
+ if (level === 'private') return 'Local-only. Not in skills.json.'
144
+ if (level === 'discoverable') return 'Default. Indexed with description.'
145
+ return 'Indexed with description and Agent Card link.'
146
+ }
147
+
148
+ function capitalize(value: string): string {
149
+ if (!value) return value
150
+ return value.charAt(0).toUpperCase() + value.slice(1)
151
+ }