agent-facets 0.1.2 → 0.2.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.
- package/.package.json.bak +44 -0
- package/.turbo/turbo-build.log +2 -2
- package/CHANGELOG.md +33 -0
- package/dist/facet +0 -0
- package/package.json +7 -4
- package/src/__tests__/cli.test.ts +69 -26
- package/src/__tests__/create-build.test.ts +32 -12
- package/src/__tests__/edit-integration.test.ts +171 -0
- package/src/__tests__/resolve-dir.test.ts +95 -0
- package/src/commands/build.ts +17 -4
- package/src/commands/create/index.ts +51 -5
- package/src/commands/create/wizard.tsx +66 -15
- package/src/commands/create-scaffold.ts +14 -10
- package/src/commands/edit/index.ts +144 -0
- package/src/commands/edit/wizard.tsx +74 -0
- package/src/commands/resolve-dir.ts +98 -0
- package/src/commands.ts +11 -2
- package/src/help.ts +17 -10
- package/src/index.ts +2 -1
- package/src/run.ts +32 -5
- package/src/tui/components/asset-description.tsx +17 -0
- package/src/tui/components/asset-field-picker.tsx +78 -0
- package/src/tui/components/asset-inline-input.tsx +13 -1
- package/src/tui/components/asset-item.tsx +3 -7
- package/src/tui/components/asset-section.tsx +72 -26
- package/src/tui/components/reconciliation-item.tsx +129 -0
- package/src/tui/components/stage-row.tsx +16 -4
- package/src/tui/context/focus-order-context.ts +8 -2
- package/src/tui/context/form-state-context.ts +34 -3
- package/src/tui/editor.ts +40 -0
- package/src/tui/views/build/build-view.tsx +43 -44
- package/src/tui/views/create/create-view.tsx +17 -13
- package/src/tui/views/create/wizard.tsx +35 -6
- package/src/tui/views/edit/edit-confirm-view.tsx +93 -0
- package/src/tui/views/edit/edit-types.ts +34 -0
- package/src/tui/views/edit/edit-view.tsx +140 -0
- package/src/tui/views/edit/manifest-to-form.ts +38 -0
- package/src/tui/views/edit/reconciliation-view.tsx +170 -0
- package/src/tui/views/edit/use-edit-session.ts +125 -0
- package/src/tui/views/edit/wizard.tsx +129 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import { readFileSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Opens the user's preferred terminal editor with the given content.
|
|
8
|
+
* Returns the edited content after the editor closes.
|
|
9
|
+
*
|
|
10
|
+
* This is synchronous — it blocks the event loop while the editor is open.
|
|
11
|
+
* Callers must ensure the TUI is in a safe state before calling.
|
|
12
|
+
*
|
|
13
|
+
* Uses $VISUAL, $EDITOR, or falls back to 'vi'.
|
|
14
|
+
*/
|
|
15
|
+
export function openInEditorSync(content: string, filename = 'description.md'): string | null {
|
|
16
|
+
const editor = process.env.VISUAL || process.env.EDITOR || 'vi'
|
|
17
|
+
const tmpFile = join(tmpdir(), `facet-${Date.now()}-${filename}`)
|
|
18
|
+
|
|
19
|
+
writeFileSync(tmpFile, content, 'utf-8')
|
|
20
|
+
|
|
21
|
+
// Split editor command to support args (e.g., "code --wait")
|
|
22
|
+
const [cmd, ...args] = editor.split(' ')
|
|
23
|
+
if (!cmd) return null
|
|
24
|
+
|
|
25
|
+
const result = spawnSync(cmd, [...args, tmpFile], {
|
|
26
|
+
stdio: 'inherit',
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
if (result.status !== 0) return null
|
|
30
|
+
|
|
31
|
+
const edited = readFileSync(tmpFile, 'utf-8')
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
unlinkSync(tmpFile)
|
|
35
|
+
} catch {
|
|
36
|
+
// Ignore cleanup failures
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return edited
|
|
40
|
+
}
|
|
@@ -1,6 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
BUILD_STAGES,
|
|
3
|
+
type BuildProgress,
|
|
4
|
+
type BuildStage,
|
|
5
|
+
runBuildPipeline,
|
|
6
|
+
writeBuildOutput,
|
|
7
|
+
} from '@agent-facets/core'
|
|
2
8
|
import { Box, Text, useApp } from 'ink'
|
|
3
|
-
import { useCallback, useEffect, useState } from 'react'
|
|
9
|
+
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
4
10
|
import type { Stage } from '../../components/stage-row.tsx'
|
|
5
11
|
import { StageRow } from '../../components/stage-row.tsx'
|
|
6
12
|
import { THEME } from '../../theme.ts'
|
|
@@ -24,54 +30,61 @@ export function BuildView({
|
|
|
24
30
|
onFailure?: (errorCount: number) => void
|
|
25
31
|
}) {
|
|
26
32
|
const { exit } = useApp()
|
|
27
|
-
const [stages, setStages] = useState<Stage[]>(
|
|
28
|
-
{ label: 'Validating manifest', status: 'pending' },
|
|
29
|
-
{ label: 'Assembling archive', status: 'pending' },
|
|
30
|
-
{ label: 'Writing output', status: 'pending' },
|
|
31
|
-
])
|
|
33
|
+
const [stages, setStages] = useState<Stage[]>(BUILD_STAGES.map((label) => ({ label, status: 'pending' as const })))
|
|
32
34
|
const [result, setResult] = useState<BuildViewResult | null>(null)
|
|
33
|
-
const [errors, setErrors] = useState<string[]>([])
|
|
34
35
|
const [warnings, setWarnings] = useState<string[]>([])
|
|
35
36
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
// Deferred exit: set this to an Error to exit after the next render cycle,
|
|
38
|
+
// ensuring error/stage state updates are painted before Ink unmounts.
|
|
39
|
+
const [pendingExit, setPendingExit] = useState<Error | null>(null)
|
|
39
40
|
|
|
40
41
|
useEffect(() => {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
42
|
+
if (pendingExit) {
|
|
43
|
+
exit(pendingExit)
|
|
44
|
+
}
|
|
45
|
+
}, [pendingExit, exit])
|
|
46
|
+
|
|
47
|
+
// Build a stable lookup from stage label to index
|
|
48
|
+
const stageIndexMap = useMemo(() => Object.fromEntries(BUILD_STAGES.map((label, i) => [label, i])), [])
|
|
49
|
+
|
|
50
|
+
const updateStage = useCallback(
|
|
51
|
+
(label: BuildStage, update: Partial<Stage>) => {
|
|
52
|
+
const index = stageIndexMap[label]
|
|
53
|
+
if (index !== undefined) {
|
|
54
|
+
setStages((prev) => prev.map((s, i) => (i === index ? { ...s, ...update } : s)))
|
|
46
55
|
}
|
|
56
|
+
},
|
|
57
|
+
[stageIndexMap],
|
|
58
|
+
)
|
|
47
59
|
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
async function run() {
|
|
48
62
|
const pipelineResult = await runBuildPipeline(rootDir, (progress: BuildProgress) => {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
status: progress.status === 'running' ? 'running' : progress.status === 'done' ? 'done' : 'failed',
|
|
53
|
-
})
|
|
54
|
-
}
|
|
63
|
+
updateStage(progress.stage, {
|
|
64
|
+
status: progress.status === 'running' ? 'running' : progress.status === 'done' ? 'done' : 'failed',
|
|
65
|
+
})
|
|
55
66
|
})
|
|
56
67
|
|
|
57
68
|
setWarnings(pipelineResult.warnings)
|
|
58
69
|
|
|
59
70
|
if (!pipelineResult.ok) {
|
|
60
|
-
|
|
71
|
+
const formatted = pipelineResult.errors.map((e) => e.message)
|
|
72
|
+
// Find the stage that failed and attach errors to it
|
|
73
|
+
setStages((prev) => prev.map((s) => (s.status === 'failed' ? { ...s, errors: formatted } : s)))
|
|
61
74
|
onFailure?.(pipelineResult.errors.length)
|
|
62
|
-
exit
|
|
75
|
+
// Defer exit so React renders the errors and failed stage status first
|
|
76
|
+
setPendingExit(new Error('Build failed'))
|
|
63
77
|
return
|
|
64
78
|
}
|
|
65
79
|
|
|
66
|
-
//
|
|
67
|
-
updateStage(
|
|
80
|
+
// Writing output stage — handled here, not by the pipeline
|
|
81
|
+
updateStage('Writing output', { status: 'running' })
|
|
68
82
|
try {
|
|
69
83
|
await writeBuildOutput(pipelineResult, rootDir)
|
|
70
84
|
|
|
71
|
-
// Derive file list from asset hashes (these are the files inside the archive)
|
|
72
85
|
const files = Object.keys(pipelineResult.assetHashes).sort()
|
|
73
86
|
|
|
74
|
-
updateStage(
|
|
87
|
+
updateStage('Writing output', { status: 'done' })
|
|
75
88
|
setResult({
|
|
76
89
|
name: pipelineResult.data.name,
|
|
77
90
|
version: pipelineResult.data.version,
|
|
@@ -83,8 +96,8 @@ export function BuildView({
|
|
|
83
96
|
onSuccess?.(pipelineResult.data.name, pipelineResult.data.version, files.length, pipelineResult.integrity)
|
|
84
97
|
exit()
|
|
85
98
|
} catch (err) {
|
|
86
|
-
updateStage(
|
|
87
|
-
|
|
99
|
+
updateStage('Writing output', { status: 'failed', detail: String(err) })
|
|
100
|
+
setPendingExit(err instanceof Error ? err : new Error(String(err)))
|
|
88
101
|
}
|
|
89
102
|
}
|
|
90
103
|
|
|
@@ -114,20 +127,6 @@ export function BuildView({
|
|
|
114
127
|
</Box>
|
|
115
128
|
)}
|
|
116
129
|
|
|
117
|
-
{errors.length > 0 && (
|
|
118
|
-
<Box flexDirection="column">
|
|
119
|
-
<Text bold color={THEME.warning}>
|
|
120
|
-
Errors:
|
|
121
|
-
</Text>
|
|
122
|
-
{errors.map((e) => (
|
|
123
|
-
<Text key={e} color={THEME.warning}>
|
|
124
|
-
{' '}
|
|
125
|
-
{e}
|
|
126
|
-
</Text>
|
|
127
|
-
))}
|
|
128
|
-
</Box>
|
|
129
|
-
)}
|
|
130
|
-
|
|
131
130
|
{result && (
|
|
132
131
|
<Box flexDirection="column">
|
|
133
132
|
<Text color={THEME.success} bold>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Box, Text } from 'ink'
|
|
2
2
|
import { useCallback, useEffect } from 'react'
|
|
3
3
|
import { ASSET_LABELS, ASSET_TYPES } from '../../../commands/create/types.ts'
|
|
4
|
-
import { isValidKebabCase } from '../../../commands/create-scaffold.ts'
|
|
4
|
+
import { DEFAULT_VERSION, isValidKebabCase } from '../../../commands/create-scaffold.ts'
|
|
5
5
|
import { AssetSection } from '../../components/asset-section.tsx'
|
|
6
6
|
import { Button } from '../../components/button.tsx'
|
|
7
7
|
import { EditableField } from '../../components/editable-field.tsx'
|
|
@@ -9,7 +9,7 @@ import { useFocusOrder } from '../../context/focus-order-context.ts'
|
|
|
9
9
|
import { useFormState } from '../../context/form-state-context.ts'
|
|
10
10
|
import { WizardLayout } from '../../layouts/wizard-layout.tsx'
|
|
11
11
|
|
|
12
|
-
function computeFocusIds(form: ReturnType<typeof useFormState>['form']
|
|
12
|
+
function computeFocusIds(form: ReturnType<typeof useFormState>['form']): string[] {
|
|
13
13
|
const ids: string[] = ['field-name', 'field-description', 'field-version']
|
|
14
14
|
|
|
15
15
|
for (const type of ASSET_TYPES) {
|
|
@@ -22,14 +22,18 @@ function computeFocusIds(form: ReturnType<typeof useFormState>['form'], hasAnyAs
|
|
|
22
22
|
ids.push(`add-${type}`)
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
ids.push('create-btn')
|
|
27
|
-
}
|
|
25
|
+
ids.push('create-btn')
|
|
28
26
|
|
|
29
27
|
return ids
|
|
30
28
|
}
|
|
31
29
|
|
|
32
|
-
export function CreateView({
|
|
30
|
+
export function CreateView({
|
|
31
|
+
onSubmit,
|
|
32
|
+
onEditDescription,
|
|
33
|
+
}: {
|
|
34
|
+
onSubmit: () => void
|
|
35
|
+
onEditDescription?: (section: import('../../context/form-state-context.ts').AssetSectionKey, name: string) => void
|
|
36
|
+
}) {
|
|
33
37
|
const { form } = useFormState()
|
|
34
38
|
const { setFocusIds, focus, focusedId } = useFocusOrder()
|
|
35
39
|
|
|
@@ -54,18 +58,17 @@ export function CreateView({ onSubmit }: { onSubmit: () => void }) {
|
|
|
54
58
|
const assetsReady = nameSettled && descriptionSettled && versionSettled
|
|
55
59
|
|
|
56
60
|
const totalAssets = form.assets.skill.items.length + form.assets.command.items.length + form.assets.agent.items.length
|
|
57
|
-
const
|
|
58
|
-
const canCreate = assetsReady && hasAnyAsset
|
|
61
|
+
const canCreate = assetsReady && totalAssets > 0
|
|
59
62
|
|
|
60
63
|
// Recompute focus order
|
|
61
64
|
useEffect(() => {
|
|
62
|
-
const ids = computeFocusIds(form
|
|
65
|
+
const ids = computeFocusIds(form)
|
|
63
66
|
setFocusIds(ids)
|
|
64
67
|
|
|
65
68
|
if (focusedId && !ids.includes(focusedId)) {
|
|
66
69
|
focus(ids[0] ?? '')
|
|
67
70
|
}
|
|
68
|
-
}, [form,
|
|
71
|
+
}, [form, setFocusIds, focus, focusedId])
|
|
69
72
|
|
|
70
73
|
// Auto-focus name field on mount
|
|
71
74
|
useEffect(() => {
|
|
@@ -97,9 +100,9 @@ export function CreateView({ onSubmit }: { onSubmit: () => void }) {
|
|
|
97
100
|
field="version"
|
|
98
101
|
label="Version"
|
|
99
102
|
hint="SemVer N.N.N"
|
|
100
|
-
defaultValue=
|
|
103
|
+
defaultValue={DEFAULT_VERSION}
|
|
101
104
|
dimmed={!versionReady}
|
|
102
|
-
validate={(v) => (/^\d+\.\d+\.\d+$/.test(v) ? undefined :
|
|
105
|
+
validate={(v) => (/^\d+\.\d+\.\d+$/.test(v) ? undefined : `Must be SemVer (e.g., ${DEFAULT_VERSION})`)}
|
|
103
106
|
onConfirm={() => focus(`add-${ASSET_TYPES[0]}`)}
|
|
104
107
|
/>
|
|
105
108
|
|
|
@@ -110,6 +113,7 @@ export function CreateView({ onSubmit }: { onSubmit: () => void }) {
|
|
|
110
113
|
label={ASSET_LABELS[type]}
|
|
111
114
|
defaultName={form.assets[type].items.length === 0 ? form.fields.name.value : undefined}
|
|
112
115
|
dimmed={!assetsReady}
|
|
116
|
+
onEditDescription={onEditDescription}
|
|
113
117
|
validate={(v) => {
|
|
114
118
|
if (!isValidKebabCase(v)) return 'Must be kebab-case'
|
|
115
119
|
const editing = form.assets[type].editing
|
|
@@ -141,7 +145,7 @@ export function CreateView({ onSubmit }: { onSubmit: () => void }) {
|
|
|
141
145
|
? 'Enter a description to continue'
|
|
142
146
|
: !versionConfirmed
|
|
143
147
|
? 'Enter a version to continue'
|
|
144
|
-
: 'Add at least one skill,
|
|
148
|
+
: 'Add at least one skill, agent, or command'}
|
|
145
149
|
</Text>
|
|
146
150
|
</Box>
|
|
147
151
|
)}
|
|
@@ -1,26 +1,46 @@
|
|
|
1
1
|
import { useApp } from 'ink'
|
|
2
|
-
import { useCallback, useState } from 'react'
|
|
2
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
3
3
|
import type { CreateOptions } from '../../../commands/create-scaffold.ts'
|
|
4
4
|
import { FocusModeProvider, useFocusMode } from '../../context/focus-mode-context.ts'
|
|
5
|
-
import { FocusOrderProvider } from '../../context/focus-order-context.ts'
|
|
5
|
+
import { FocusOrderProvider, useFocusOrder } from '../../context/focus-order-context.ts'
|
|
6
|
+
import type { AssetSectionKey, FormState } from '../../context/form-state-context.ts'
|
|
6
7
|
import { FormStateProvider, useFormState } from '../../context/form-state-context.ts'
|
|
7
8
|
import { useExitKeys } from '../../hooks/use-exit-keys.ts'
|
|
8
9
|
import { useNavigationKeys } from '../../hooks/use-navigation-keys.ts'
|
|
9
10
|
import { ConfirmView } from './confirm-view.tsx'
|
|
10
11
|
import { CreateView } from './create-view.tsx'
|
|
11
12
|
|
|
13
|
+
export interface WizardSnapshot {
|
|
14
|
+
form: FormState
|
|
15
|
+
focusedId: string | null
|
|
16
|
+
selectedItem?: {
|
|
17
|
+
section: AssetSectionKey
|
|
18
|
+
name: string
|
|
19
|
+
field: 'name' | 'description'
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
12
23
|
export interface CreateWizardProps {
|
|
13
24
|
onComplete: (opts: CreateOptions) => void
|
|
14
25
|
onCancel: () => void
|
|
26
|
+
snapshot?: WizardSnapshot
|
|
27
|
+
onSnapshot?: (snapshot: WizardSnapshot) => void
|
|
28
|
+
onRequestEditor?: (section: AssetSectionKey, name: string, description: string) => void
|
|
15
29
|
}
|
|
16
30
|
|
|
17
|
-
function CreateWizardInner({ onComplete, onCancel }: CreateWizardProps) {
|
|
31
|
+
function CreateWizardInner({ onComplete, onCancel, snapshot, onSnapshot, onRequestEditor }: CreateWizardProps) {
|
|
18
32
|
const { exit } = useApp()
|
|
19
33
|
const { setMode } = useFocusMode()
|
|
20
|
-
const { toCreateOptions } = useFormState()
|
|
34
|
+
const { form, toCreateOptions } = useFormState()
|
|
35
|
+
const { focusedId } = useFocusOrder()
|
|
21
36
|
|
|
22
37
|
const [confirming, setConfirming] = useState(false)
|
|
23
38
|
|
|
39
|
+
// Keep the parent informed of current state for snapshot
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
onSnapshot?.({ form, focusedId, selectedItem: snapshot?.selectedItem })
|
|
42
|
+
}, [form, focusedId, onSnapshot, snapshot?.selectedItem])
|
|
43
|
+
|
|
24
44
|
const cancel = useCallback(() => {
|
|
25
45
|
onCancel()
|
|
26
46
|
exit()
|
|
@@ -29,6 +49,14 @@ function CreateWizardInner({ onComplete, onCancel }: CreateWizardProps) {
|
|
|
29
49
|
useExitKeys(cancel)
|
|
30
50
|
useNavigationKeys()
|
|
31
51
|
|
|
52
|
+
const handleEditDescription = useCallback(
|
|
53
|
+
(section: AssetSectionKey, name: string) => {
|
|
54
|
+
const description = form.assets[section].descriptions[name] ?? ''
|
|
55
|
+
onRequestEditor?.(section, name, description)
|
|
56
|
+
},
|
|
57
|
+
[form, onRequestEditor],
|
|
58
|
+
)
|
|
59
|
+
|
|
32
60
|
if (confirming) {
|
|
33
61
|
return (
|
|
34
62
|
<ConfirmView
|
|
@@ -51,6 +79,7 @@ function CreateWizardInner({ onComplete, onCancel }: CreateWizardProps) {
|
|
|
51
79
|
setConfirming(true)
|
|
52
80
|
setMode('form-confirmation')
|
|
53
81
|
}}
|
|
82
|
+
onEditDescription={handleEditDescription}
|
|
54
83
|
/>
|
|
55
84
|
)
|
|
56
85
|
}
|
|
@@ -58,8 +87,8 @@ function CreateWizardInner({ onComplete, onCancel }: CreateWizardProps) {
|
|
|
58
87
|
export function CreateWizard(props: CreateWizardProps) {
|
|
59
88
|
return (
|
|
60
89
|
<FocusModeProvider>
|
|
61
|
-
<FocusOrderProvider>
|
|
62
|
-
<FormStateProvider>
|
|
90
|
+
<FocusOrderProvider initialFocusId={props.snapshot?.focusedId}>
|
|
91
|
+
<FormStateProvider initialState={props.snapshot?.form}>
|
|
63
92
|
<CreateWizardInner {...props} />
|
|
64
93
|
</FormStateProvider>
|
|
65
94
|
</FocusOrderProvider>
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Box, Text } from 'ink'
|
|
2
|
+
import { useEffect } from 'react'
|
|
3
|
+
import { truncateDescription } from '../../components/asset-description.tsx'
|
|
4
|
+
import { Button } from '../../components/button.tsx'
|
|
5
|
+
import { useFocusOrder } from '../../context/focus-order-context.ts'
|
|
6
|
+
import type { AssetSectionKey } from '../../context/form-state-context.ts'
|
|
7
|
+
import { useFormState } from '../../context/form-state-context.ts'
|
|
8
|
+
import { THEME } from '../../theme.ts'
|
|
9
|
+
|
|
10
|
+
const ASSET_TYPES: AssetSectionKey[] = ['skill', 'command', 'agent']
|
|
11
|
+
const ASSET_LABELS: Record<AssetSectionKey, string> = {
|
|
12
|
+
skill: 'Skills',
|
|
13
|
+
command: 'Commands',
|
|
14
|
+
agent: 'Agents',
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function EditConfirmView({ onConfirm, onBack }: { onConfirm: () => void; onBack: () => void }) {
|
|
18
|
+
const { form } = useFormState()
|
|
19
|
+
const { setFocusIds, focus, focusedId } = useFocusOrder()
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
setFocusIds(['edit-apply-btn', 'edit-back-btn'])
|
|
23
|
+
focus('edit-apply-btn')
|
|
24
|
+
}, [setFocusIds, focus])
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<Box flexDirection="column" padding={1} gap={1}>
|
|
28
|
+
<Text bold color={THEME.brand}>
|
|
29
|
+
Review changes
|
|
30
|
+
</Text>
|
|
31
|
+
|
|
32
|
+
<Box flexDirection="column">
|
|
33
|
+
<Box gap={1}>
|
|
34
|
+
<Text bold>Name:</Text>
|
|
35
|
+
<Text>{form.fields.name.value || '(none)'}</Text>
|
|
36
|
+
</Box>
|
|
37
|
+
<Box gap={1}>
|
|
38
|
+
<Text bold>Description:</Text>
|
|
39
|
+
<Text>{truncateDescription(form.fields.description.value || '(none)')}</Text>
|
|
40
|
+
</Box>
|
|
41
|
+
<Box gap={1}>
|
|
42
|
+
<Text bold>Version:</Text>
|
|
43
|
+
<Text>{form.fields.version.value || '(none)'}</Text>
|
|
44
|
+
</Box>
|
|
45
|
+
</Box>
|
|
46
|
+
|
|
47
|
+
{ASSET_TYPES.map((type) => {
|
|
48
|
+
const section = form.assets[type]
|
|
49
|
+
return (
|
|
50
|
+
<Box key={type} flexDirection="column">
|
|
51
|
+
<Text bold>{ASSET_LABELS[type]}:</Text>
|
|
52
|
+
{section.items.length === 0 ? (
|
|
53
|
+
<Box marginLeft={2}>
|
|
54
|
+
<Text dimColor>(none)</Text>
|
|
55
|
+
</Box>
|
|
56
|
+
) : (
|
|
57
|
+
section.items.map((item) => {
|
|
58
|
+
const desc = section.descriptions[item] ?? `A ${item} ${type}`
|
|
59
|
+
return (
|
|
60
|
+
<Box key={item} flexDirection="column" marginLeft={2}>
|
|
61
|
+
<Box gap={1}>
|
|
62
|
+
<Text color={THEME.success}>●</Text>
|
|
63
|
+
<Text>{item}</Text>
|
|
64
|
+
</Box>
|
|
65
|
+
<Box marginLeft={3}>
|
|
66
|
+
<Text dimColor>{truncateDescription(desc)}</Text>
|
|
67
|
+
</Box>
|
|
68
|
+
</Box>
|
|
69
|
+
)
|
|
70
|
+
})
|
|
71
|
+
)}
|
|
72
|
+
</Box>
|
|
73
|
+
)
|
|
74
|
+
})}
|
|
75
|
+
|
|
76
|
+
<Box marginTop={1} gap={2}>
|
|
77
|
+
<Button
|
|
78
|
+
id="edit-apply-btn"
|
|
79
|
+
label="[ Yes, apply ]"
|
|
80
|
+
color={THEME.success}
|
|
81
|
+
gradient={focusedId === 'edit-apply-btn'}
|
|
82
|
+
animateGradient={focusedId === 'edit-apply-btn'}
|
|
83
|
+
onPress={onConfirm}
|
|
84
|
+
/>
|
|
85
|
+
<Button id="edit-back-btn" label="[ No, go back ]" color={THEME.warning} onPress={onBack} />
|
|
86
|
+
</Box>
|
|
87
|
+
|
|
88
|
+
<Box>
|
|
89
|
+
<Text dimColor>← → switch · Enter confirm</Text>
|
|
90
|
+
</Box>
|
|
91
|
+
</Box>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { AssetType, FacetManifest } from '@agent-facets/core'
|
|
2
|
+
|
|
3
|
+
/** A reconciliation item that needs a resolution before editing can proceed. */
|
|
4
|
+
export type ReconciliationItem =
|
|
5
|
+
| { kind: 'addition'; type: AssetType; name: string; path: string }
|
|
6
|
+
| { kind: 'missing'; type: AssetType; name: string; expectedPath: string }
|
|
7
|
+
| { kind: 'front-matter'; type: AssetType; name: string; path: string }
|
|
8
|
+
|
|
9
|
+
/** The resolution chosen for a reconciliation item. */
|
|
10
|
+
export type ReconciliationResolution =
|
|
11
|
+
| { action: 'add-to-manifest' }
|
|
12
|
+
| { action: 'ignore' }
|
|
13
|
+
| { action: 'scaffold-template' }
|
|
14
|
+
| { action: 'remove-from-manifest' }
|
|
15
|
+
| { action: 'strip-front-matter' }
|
|
16
|
+
|
|
17
|
+
/** All data needed to run the edit TUI. */
|
|
18
|
+
export interface EditContext {
|
|
19
|
+
rootDir: string
|
|
20
|
+
manifest: FacetManifest
|
|
21
|
+
reconciliationItems: ReconciliationItem[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The result of the edit wizard — either applied changes or cancelled. */
|
|
25
|
+
export type EditResult =
|
|
26
|
+
| { outcome: 'applied'; manifest: FacetManifest; operations: EditOperation[] }
|
|
27
|
+
| { outcome: 'cancelled' }
|
|
28
|
+
|
|
29
|
+
/** A file operation to perform on confirmation. */
|
|
30
|
+
export type EditOperation =
|
|
31
|
+
| { op: 'write-manifest' }
|
|
32
|
+
| { op: 'scaffold'; type: AssetType; name: string }
|
|
33
|
+
| { op: 'delete-file'; type: AssetType; name: string }
|
|
34
|
+
| { op: 'strip-front-matter'; type: AssetType; name: string; path: string }
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { Box, Text } from 'ink'
|
|
2
|
+
import { useCallback, useEffect } from 'react'
|
|
3
|
+
import { DEFAULT_VERSION, isValidKebabCase } from '../../../commands/create-scaffold.ts'
|
|
4
|
+
import { AssetSection } from '../../components/asset-section.tsx'
|
|
5
|
+
import { Button } from '../../components/button.tsx'
|
|
6
|
+
import { EditableField } from '../../components/editable-field.tsx'
|
|
7
|
+
import { useFocusOrder } from '../../context/focus-order-context.ts'
|
|
8
|
+
import type { AssetSectionKey } from '../../context/form-state-context.ts'
|
|
9
|
+
import { useFormState } from '../../context/form-state-context.ts'
|
|
10
|
+
import { THEME } from '../../theme.ts'
|
|
11
|
+
|
|
12
|
+
const ASSET_TYPES: AssetSectionKey[] = ['skill', 'command', 'agent']
|
|
13
|
+
const ASSET_LABELS: Record<AssetSectionKey, string> = {
|
|
14
|
+
skill: 'Skills',
|
|
15
|
+
command: 'Commands',
|
|
16
|
+
agent: 'Agents',
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function computeFocusIds(form: ReturnType<typeof useFormState>['form']): string[] {
|
|
20
|
+
const ids: string[] = ['field-name', 'field-description', 'field-version']
|
|
21
|
+
|
|
22
|
+
for (const type of ASSET_TYPES) {
|
|
23
|
+
const section = form.assets[type]
|
|
24
|
+
|
|
25
|
+
for (let i = 0; i < section.items.length; i++) {
|
|
26
|
+
ids.push(`item-${type}-${i}`)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
ids.push(`add-${type}`)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
ids.push('edit-confirm-btn')
|
|
33
|
+
return ids
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function EditView({
|
|
37
|
+
onSubmit,
|
|
38
|
+
onEditDescription,
|
|
39
|
+
}: {
|
|
40
|
+
onSubmit: () => void
|
|
41
|
+
onEditDescription?: (section: AssetSectionKey, name: string) => void
|
|
42
|
+
}) {
|
|
43
|
+
const { form } = useFormState()
|
|
44
|
+
const { setFocusIds, focus, focusedId } = useFocusOrder()
|
|
45
|
+
|
|
46
|
+
const validateKebab = useCallback((v: string) => {
|
|
47
|
+
if (!v) return undefined
|
|
48
|
+
if (!isValidKebabCase(v)) return 'Must be kebab-case (e.g., my-facet)'
|
|
49
|
+
return undefined
|
|
50
|
+
}, [])
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
const ids = computeFocusIds(form)
|
|
54
|
+
setFocusIds(ids)
|
|
55
|
+
|
|
56
|
+
if (focusedId && !ids.includes(focusedId)) {
|
|
57
|
+
focus(ids[0] ?? '')
|
|
58
|
+
}
|
|
59
|
+
}, [form, setFocusIds, focus, focusedId])
|
|
60
|
+
|
|
61
|
+
const totalAssets = form.assets.skill.items.length + form.assets.agent.items.length + form.assets.command.items.length
|
|
62
|
+
const canConfirm = totalAssets > 0
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (!focusedId) {
|
|
66
|
+
focus('field-name')
|
|
67
|
+
}
|
|
68
|
+
}, [focusedId, focus])
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<Box flexDirection="column" padding={1} gap={1}>
|
|
72
|
+
<Text bold color={THEME.brand}>
|
|
73
|
+
Edit facet
|
|
74
|
+
</Text>
|
|
75
|
+
|
|
76
|
+
<Box flexDirection="column">
|
|
77
|
+
<EditableField
|
|
78
|
+
field="name"
|
|
79
|
+
label="Name"
|
|
80
|
+
placeholder="my-facet"
|
|
81
|
+
hint="kebab-case"
|
|
82
|
+
validate={validateKebab}
|
|
83
|
+
onConfirm={() => focus('field-description')}
|
|
84
|
+
/>
|
|
85
|
+
|
|
86
|
+
<EditableField
|
|
87
|
+
field="description"
|
|
88
|
+
label="Description"
|
|
89
|
+
placeholder="A brief description"
|
|
90
|
+
onConfirm={() => focus('field-version')}
|
|
91
|
+
/>
|
|
92
|
+
|
|
93
|
+
<EditableField
|
|
94
|
+
field="version"
|
|
95
|
+
label="Version"
|
|
96
|
+
hint="SemVer N.N.N"
|
|
97
|
+
validate={(v) => (/^\d+\.\d+\.\d+$/.test(v) ? undefined : `Must be SemVer (e.g., ${DEFAULT_VERSION})`)}
|
|
98
|
+
onConfirm={() => focus(`add-${ASSET_TYPES[0]}`)}
|
|
99
|
+
/>
|
|
100
|
+
</Box>
|
|
101
|
+
|
|
102
|
+
{ASSET_TYPES.map((type) => (
|
|
103
|
+
<Box key={type} marginTop={0}>
|
|
104
|
+
<AssetSection
|
|
105
|
+
section={type}
|
|
106
|
+
label={ASSET_LABELS[type]}
|
|
107
|
+
onEditDescription={onEditDescription}
|
|
108
|
+
validate={(v) => {
|
|
109
|
+
if (!isValidKebabCase(v)) return 'Must be kebab-case'
|
|
110
|
+
const editing = form.assets[type].editing
|
|
111
|
+
if (form.assets[type].items.some((item) => item === v && item !== editing)) return `"${v}" already exists`
|
|
112
|
+
return undefined
|
|
113
|
+
}}
|
|
114
|
+
/>
|
|
115
|
+
</Box>
|
|
116
|
+
))}
|
|
117
|
+
|
|
118
|
+
<Box marginTop={1}>
|
|
119
|
+
<Button
|
|
120
|
+
id="edit-confirm-btn"
|
|
121
|
+
label="[ Review & Confirm ]"
|
|
122
|
+
disabled={!canConfirm}
|
|
123
|
+
gradient={canConfirm}
|
|
124
|
+
animateGradient={canConfirm && focusedId === 'edit-confirm-btn'}
|
|
125
|
+
onPress={onSubmit}
|
|
126
|
+
/>
|
|
127
|
+
</Box>
|
|
128
|
+
|
|
129
|
+
{!canConfirm && (
|
|
130
|
+
<Box marginLeft={2}>
|
|
131
|
+
<Text dimColor>Add at least one skill, agent, or command</Text>
|
|
132
|
+
</Box>
|
|
133
|
+
)}
|
|
134
|
+
|
|
135
|
+
<Box>
|
|
136
|
+
<Text dimColor>↑ ↓ navigate · Enter edit · Esc Esc exit (no changes)</Text>
|
|
137
|
+
</Box>
|
|
138
|
+
</Box>
|
|
139
|
+
)
|
|
140
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { FacetManifest } from '@agent-facets/core'
|
|
2
|
+
import type { AssetSectionKey, FormState } from '../../context/form-state-context.ts'
|
|
3
|
+
|
|
4
|
+
/** Maps manifest asset keys to form section keys. */
|
|
5
|
+
const MANIFEST_TO_FORM: Record<string, AssetSectionKey> = {
|
|
6
|
+
skills: 'skill',
|
|
7
|
+
agents: 'agent',
|
|
8
|
+
commands: 'command',
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Builds initial form state from an existing manifest. */
|
|
12
|
+
export function manifestToFormState(manifest: FacetManifest): FormState {
|
|
13
|
+
const assets: FormState['assets'] = {
|
|
14
|
+
skill: { items: [], descriptions: {}, editing: undefined, adding: false },
|
|
15
|
+
agent: { items: [], descriptions: {}, editing: undefined, adding: false },
|
|
16
|
+
command: { items: [], descriptions: {}, editing: undefined, adding: false },
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
for (const [manifestKey, formKey] of Object.entries(MANIFEST_TO_FORM)) {
|
|
20
|
+
const section = manifest[manifestKey as keyof FacetManifest]
|
|
21
|
+
if (section && typeof section === 'object' && !Array.isArray(section)) {
|
|
22
|
+
const entries = section as Record<string, { description?: string }>
|
|
23
|
+
for (const [name, descriptor] of Object.entries(entries)) {
|
|
24
|
+
assets[formKey].items.push(name)
|
|
25
|
+
assets[formKey].descriptions[name] = descriptor.description ?? `A ${name} ${formKey}`
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
fields: {
|
|
32
|
+
name: { value: manifest.name, status: 'confirmed' },
|
|
33
|
+
description: { value: manifest.description ?? '', status: 'confirmed' },
|
|
34
|
+
version: { value: manifest.version, status: 'confirmed' },
|
|
35
|
+
},
|
|
36
|
+
assets,
|
|
37
|
+
}
|
|
38
|
+
}
|