agent-facets 0.1.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/CHANGELOG.md +30 -0
- package/bunfig.toml +2 -0
- package/dist/facet +0 -0
- package/dist/facet-ink-test +0 -0
- package/package.json +41 -0
- package/src/__tests__/cli.test.ts +152 -0
- package/src/__tests__/create-build.test.ts +207 -0
- package/src/commands/build.ts +45 -0
- package/src/commands/create/index.ts +30 -0
- package/src/commands/create/types.ts +9 -0
- package/src/commands/create/wizard.tsx +24 -0
- package/src/commands/create-scaffold.ts +180 -0
- package/src/commands.ts +31 -0
- package/src/help.ts +36 -0
- package/src/index.ts +9 -0
- package/src/run.ts +55 -0
- package/src/suggest.ts +35 -0
- package/src/tui/components/asset-inline-input.tsx +79 -0
- package/src/tui/components/asset-item.tsx +48 -0
- package/src/tui/components/asset-section.tsx +145 -0
- package/src/tui/components/button.tsx +92 -0
- package/src/tui/components/editable-field.tsx +172 -0
- package/src/tui/components/exit-toast.tsx +20 -0
- package/src/tui/components/stage-row.tsx +33 -0
- package/src/tui/components/version-selector.tsx +79 -0
- package/src/tui/context/focus-mode-context.ts +36 -0
- package/src/tui/context/focus-order-context.ts +62 -0
- package/src/tui/context/form-state-context.ts +229 -0
- package/src/tui/gradient.ts +1 -0
- package/src/tui/hooks/use-exit-keys.ts +75 -0
- package/src/tui/hooks/use-navigation-keys.ts +34 -0
- package/src/tui/layouts/wizard-layout.tsx +41 -0
- package/src/tui/theme.ts +1 -0
- package/src/tui/views/build/build-view.tsx +153 -0
- package/src/tui/views/create/confirm-view.tsx +74 -0
- package/src/tui/views/create/create-view.tsx +154 -0
- package/src/tui/views/create/wizard.tsx +68 -0
- package/src/version.ts +3 -0
- package/tsconfig.json +4 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { createContext, createElement, useCallback, useContext, useMemo, useState } from 'react'
|
|
3
|
+
import type { CreateOptions } from '../../commands/create-scaffold.ts'
|
|
4
|
+
import { isValidKebabCase } from '../../commands/create-scaffold.ts'
|
|
5
|
+
|
|
6
|
+
// --- Types ---
|
|
7
|
+
|
|
8
|
+
export type FieldStatus = 'empty' | 'editing' | 'confirmed'
|
|
9
|
+
export type RequiredFieldKey = 'name' | 'description' | 'version'
|
|
10
|
+
export type AssetSectionKey = 'skill' | 'command' | 'agent'
|
|
11
|
+
|
|
12
|
+
export interface FieldState {
|
|
13
|
+
value: string
|
|
14
|
+
status: FieldStatus
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface AssetSectionState {
|
|
18
|
+
items: string[]
|
|
19
|
+
editing?: string
|
|
20
|
+
adding: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface FormState {
|
|
24
|
+
fields: {
|
|
25
|
+
name: FieldState
|
|
26
|
+
description: FieldState
|
|
27
|
+
version: FieldState
|
|
28
|
+
}
|
|
29
|
+
assets: {
|
|
30
|
+
skill: AssetSectionState
|
|
31
|
+
command: AssetSectionState
|
|
32
|
+
agent: AssetSectionState
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// --- Context value ---
|
|
37
|
+
|
|
38
|
+
interface FormStateContextValue {
|
|
39
|
+
form: FormState
|
|
40
|
+
|
|
41
|
+
// Field operations
|
|
42
|
+
setFieldValue: (field: RequiredFieldKey, value: string) => void
|
|
43
|
+
setFieldStatus: (field: RequiredFieldKey, status: FieldStatus) => void
|
|
44
|
+
|
|
45
|
+
// Asset operations
|
|
46
|
+
addAsset: (section: AssetSectionKey, name: string) => void
|
|
47
|
+
removeAsset: (section: AssetSectionKey, name: string) => void
|
|
48
|
+
renameAsset: (section: AssetSectionKey, oldName: string, newName: string) => void
|
|
49
|
+
setAssetAdding: (section: AssetSectionKey, adding: boolean) => void
|
|
50
|
+
setAssetEditing: (section: AssetSectionKey, name?: string) => void
|
|
51
|
+
|
|
52
|
+
// Build CreateOptions for scaffold
|
|
53
|
+
toCreateOptions: () => CreateOptions
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// --- Defaults ---
|
|
57
|
+
|
|
58
|
+
const defaultAssetSection: AssetSectionState = {
|
|
59
|
+
items: [],
|
|
60
|
+
editing: undefined,
|
|
61
|
+
adding: false,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const defaultForm: FormState = {
|
|
65
|
+
fields: {
|
|
66
|
+
name: { value: '', status: 'empty' },
|
|
67
|
+
description: { value: '', status: 'empty' },
|
|
68
|
+
version: { value: '', status: 'empty' },
|
|
69
|
+
},
|
|
70
|
+
assets: {
|
|
71
|
+
skill: { ...defaultAssetSection },
|
|
72
|
+
command: { ...defaultAssetSection },
|
|
73
|
+
agent: { ...defaultAssetSection },
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const FormStateContext = createContext<FormStateContextValue>({
|
|
78
|
+
form: defaultForm,
|
|
79
|
+
setFieldValue: () => {},
|
|
80
|
+
setFieldStatus: () => {},
|
|
81
|
+
addAsset: () => {},
|
|
82
|
+
removeAsset: () => {},
|
|
83
|
+
renameAsset: () => {},
|
|
84
|
+
setAssetAdding: () => {},
|
|
85
|
+
setAssetEditing: () => {},
|
|
86
|
+
toCreateOptions: () => ({ name: '', version: '', description: '', skills: [], commands: [], agents: [] }),
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
// --- Provider ---
|
|
90
|
+
|
|
91
|
+
export function FormStateProvider({ children }: { children: ReactNode }) {
|
|
92
|
+
const [form, setForm] = useState<FormState>(defaultForm)
|
|
93
|
+
|
|
94
|
+
const setFieldValue = useCallback((field: RequiredFieldKey, value: string) => {
|
|
95
|
+
setForm((prev) => ({
|
|
96
|
+
...prev,
|
|
97
|
+
fields: {
|
|
98
|
+
...prev.fields,
|
|
99
|
+
[field]: { ...prev.fields[field], value },
|
|
100
|
+
},
|
|
101
|
+
}))
|
|
102
|
+
}, [])
|
|
103
|
+
|
|
104
|
+
const setFieldStatus = useCallback((field: RequiredFieldKey, status: FieldStatus) => {
|
|
105
|
+
setForm((prev) => ({
|
|
106
|
+
...prev,
|
|
107
|
+
fields: {
|
|
108
|
+
...prev.fields,
|
|
109
|
+
[field]: { ...prev.fields[field], status },
|
|
110
|
+
},
|
|
111
|
+
}))
|
|
112
|
+
}, [])
|
|
113
|
+
|
|
114
|
+
const addAsset = useCallback((section: AssetSectionKey, name: string) => {
|
|
115
|
+
if (!isValidKebabCase(name)) return
|
|
116
|
+
setForm((prev) => {
|
|
117
|
+
const current = prev.assets[section]
|
|
118
|
+
if (current.items.includes(name)) return prev
|
|
119
|
+
return {
|
|
120
|
+
...prev,
|
|
121
|
+
assets: {
|
|
122
|
+
...prev.assets,
|
|
123
|
+
[section]: { ...current, items: [...current.items, name] },
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
}, [])
|
|
128
|
+
|
|
129
|
+
const removeAsset = useCallback((section: AssetSectionKey, name: string) => {
|
|
130
|
+
setForm((prev) => {
|
|
131
|
+
const current = prev.assets[section]
|
|
132
|
+
return {
|
|
133
|
+
...prev,
|
|
134
|
+
assets: {
|
|
135
|
+
...prev.assets,
|
|
136
|
+
[section]: {
|
|
137
|
+
...current,
|
|
138
|
+
items: current.items.filter((item) => item !== name),
|
|
139
|
+
editing: current.editing === name ? undefined : current.editing,
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
})
|
|
144
|
+
}, [])
|
|
145
|
+
|
|
146
|
+
const renameAsset = useCallback((section: AssetSectionKey, oldName: string, newName: string) => {
|
|
147
|
+
if (!isValidKebabCase(newName)) return
|
|
148
|
+
setForm((prev) => {
|
|
149
|
+
const current = prev.assets[section]
|
|
150
|
+
if (current.items.includes(newName)) return prev
|
|
151
|
+
return {
|
|
152
|
+
...prev,
|
|
153
|
+
assets: {
|
|
154
|
+
...prev.assets,
|
|
155
|
+
[section]: {
|
|
156
|
+
...current,
|
|
157
|
+
items: current.items.map((item) => (item === oldName ? newName : item)),
|
|
158
|
+
editing: current.editing === oldName ? newName : current.editing,
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
}, [])
|
|
164
|
+
|
|
165
|
+
const setAssetAdding = useCallback((section: AssetSectionKey, adding: boolean) => {
|
|
166
|
+
setForm((prev) => ({
|
|
167
|
+
...prev,
|
|
168
|
+
assets: {
|
|
169
|
+
...prev.assets,
|
|
170
|
+
[section]: { ...prev.assets[section], adding },
|
|
171
|
+
},
|
|
172
|
+
}))
|
|
173
|
+
}, [])
|
|
174
|
+
|
|
175
|
+
const setAssetEditing = useCallback((section: AssetSectionKey, name?: string) => {
|
|
176
|
+
setForm((prev) => ({
|
|
177
|
+
...prev,
|
|
178
|
+
assets: {
|
|
179
|
+
...prev.assets,
|
|
180
|
+
[section]: { ...prev.assets[section], editing: name },
|
|
181
|
+
},
|
|
182
|
+
}))
|
|
183
|
+
}, [])
|
|
184
|
+
|
|
185
|
+
const toCreateOptions = useCallback(
|
|
186
|
+
(): CreateOptions => ({
|
|
187
|
+
name: form.fields.name.value,
|
|
188
|
+
version: form.fields.version.value,
|
|
189
|
+
description: form.fields.description.value,
|
|
190
|
+
skills: form.assets.skill.items,
|
|
191
|
+
commands: form.assets.command.items,
|
|
192
|
+
agents: form.assets.agent.items,
|
|
193
|
+
}),
|
|
194
|
+
[form],
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
const value = useMemo<FormStateContextValue>(
|
|
198
|
+
() => ({
|
|
199
|
+
form,
|
|
200
|
+
setFieldValue,
|
|
201
|
+
setFieldStatus,
|
|
202
|
+
addAsset,
|
|
203
|
+
removeAsset,
|
|
204
|
+
renameAsset,
|
|
205
|
+
setAssetAdding,
|
|
206
|
+
setAssetEditing,
|
|
207
|
+
toCreateOptions,
|
|
208
|
+
}),
|
|
209
|
+
[
|
|
210
|
+
form,
|
|
211
|
+
setFieldValue,
|
|
212
|
+
setFieldStatus,
|
|
213
|
+
addAsset,
|
|
214
|
+
removeAsset,
|
|
215
|
+
renameAsset,
|
|
216
|
+
setAssetAdding,
|
|
217
|
+
setAssetEditing,
|
|
218
|
+
toCreateOptions,
|
|
219
|
+
],
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
return createElement(FormStateContext.Provider, { value }, children)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// --- Hook ---
|
|
226
|
+
|
|
227
|
+
export function useFormState() {
|
|
228
|
+
return useContext(FormStateContext)
|
|
229
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { GRADIENT_STOPS, getAnimatedGradient } from '@agent-facets/brand'
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { useInput } from 'ink'
|
|
2
|
+
import { useCallback, useEffect, useRef } from 'react'
|
|
3
|
+
import { useFocusMode } from '../context/focus-mode-context.ts'
|
|
4
|
+
|
|
5
|
+
const EXIT_WINDOW_MS = 3000
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Handles exit via double-tap Escape with a visible countdown toast.
|
|
9
|
+
*
|
|
10
|
+
* First Escape (when not editing): sets mode to 'exit-modal', starts countdown.
|
|
11
|
+
* Second Escape (while in exit-modal): exits.
|
|
12
|
+
* After 3 seconds: returns to 'form-navigation'.
|
|
13
|
+
* Any other key while in exit-modal: returns to previous mode.
|
|
14
|
+
*/
|
|
15
|
+
export function useExitKeys(onExit: () => void) {
|
|
16
|
+
const { mode, setMode, setExitSecondsLeft } = useFocusMode()
|
|
17
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
18
|
+
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
|
19
|
+
const previousModeRef = useRef(mode)
|
|
20
|
+
|
|
21
|
+
const clearTimers = useCallback(() => {
|
|
22
|
+
if (timerRef.current) {
|
|
23
|
+
clearTimeout(timerRef.current)
|
|
24
|
+
timerRef.current = null
|
|
25
|
+
}
|
|
26
|
+
if (countdownRef.current) {
|
|
27
|
+
clearInterval(countdownRef.current)
|
|
28
|
+
countdownRef.current = null
|
|
29
|
+
}
|
|
30
|
+
}, [])
|
|
31
|
+
|
|
32
|
+
const dismiss = useCallback(() => {
|
|
33
|
+
setExitSecondsLeft(0)
|
|
34
|
+
clearTimers()
|
|
35
|
+
setMode(previousModeRef.current === 'exit-modal' ? 'form-navigation' : previousModeRef.current)
|
|
36
|
+
}, [clearTimers, setMode, setExitSecondsLeft])
|
|
37
|
+
|
|
38
|
+
const startExitWindow = useCallback(() => {
|
|
39
|
+
previousModeRef.current = mode
|
|
40
|
+
setMode('exit-modal')
|
|
41
|
+
setExitSecondsLeft(3)
|
|
42
|
+
|
|
43
|
+
clearTimers()
|
|
44
|
+
|
|
45
|
+
countdownRef.current = setInterval(() => {
|
|
46
|
+
setExitSecondsLeft((prev: number) => {
|
|
47
|
+
if (prev <= 1) return 0
|
|
48
|
+
return prev - 1
|
|
49
|
+
})
|
|
50
|
+
}, 1000)
|
|
51
|
+
|
|
52
|
+
timerRef.current = setTimeout(() => {
|
|
53
|
+
dismiss()
|
|
54
|
+
}, EXIT_WINDOW_MS)
|
|
55
|
+
}, [clearTimers, dismiss, mode, setMode, setExitSecondsLeft])
|
|
56
|
+
|
|
57
|
+
useEffect(() => clearTimers, [clearTimers])
|
|
58
|
+
|
|
59
|
+
useInput((_input, key) => {
|
|
60
|
+
if (key.escape) {
|
|
61
|
+
if (mode === 'exit-modal') {
|
|
62
|
+
clearTimers()
|
|
63
|
+
onExit()
|
|
64
|
+
} else if (mode !== 'field-revision') {
|
|
65
|
+
startExitWindow()
|
|
66
|
+
}
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Any other key while in exit-modal dismisses it
|
|
71
|
+
if (mode === 'exit-modal') {
|
|
72
|
+
dismiss()
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { useInput } from 'ink'
|
|
2
|
+
import { useFocusMode } from '../context/focus-mode-context.ts'
|
|
3
|
+
import { useFocusOrder } from '../context/focus-order-context.ts'
|
|
4
|
+
|
|
5
|
+
export function useNavigationKeys() {
|
|
6
|
+
const { mode } = useFocusMode()
|
|
7
|
+
const { focusNext, focusPrevious } = useFocusOrder()
|
|
8
|
+
|
|
9
|
+
const isActive = mode === 'form-navigation' || mode === 'form-confirmation'
|
|
10
|
+
|
|
11
|
+
useInput(
|
|
12
|
+
(_input, key) => {
|
|
13
|
+
if (key.downArrow || key.tab) {
|
|
14
|
+
focusNext()
|
|
15
|
+
return
|
|
16
|
+
}
|
|
17
|
+
if (key.upArrow || (key.shift && key.tab)) {
|
|
18
|
+
focusPrevious()
|
|
19
|
+
return
|
|
20
|
+
}
|
|
21
|
+
if (mode === 'form-confirmation') {
|
|
22
|
+
if (key.rightArrow) {
|
|
23
|
+
focusNext()
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
if (key.leftArrow) {
|
|
27
|
+
focusPrevious()
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
{ isActive },
|
|
33
|
+
)
|
|
34
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Box, Text } from 'ink'
|
|
2
|
+
import Gradient from 'ink-gradient'
|
|
3
|
+
import type { ReactNode } from 'react'
|
|
4
|
+
import { useEffect, useState } from 'react'
|
|
5
|
+
import { ExitFooter } from '../components/exit-toast.tsx'
|
|
6
|
+
import { GRADIENT_STOPS, getAnimatedGradient } from '../gradient.ts'
|
|
7
|
+
import { THEME } from '../theme.ts'
|
|
8
|
+
|
|
9
|
+
const ANIMATION_INTERVAL_MS = 75
|
|
10
|
+
|
|
11
|
+
function AnimatedGradientText({ text }: { text: string }) {
|
|
12
|
+
const [offset, setOffset] = useState(0)
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
const interval = setInterval(() => {
|
|
16
|
+
setOffset((prev) => (prev + 1) % GRADIENT_STOPS.length)
|
|
17
|
+
}, ANIMATION_INTERVAL_MS)
|
|
18
|
+
return () => clearInterval(interval)
|
|
19
|
+
}, [])
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<Gradient colors={getAnimatedGradient(offset)}>
|
|
23
|
+
<Text bold>{text}</Text>
|
|
24
|
+
</Gradient>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function WizardLayout({ children }: { children: ReactNode }) {
|
|
29
|
+
return (
|
|
30
|
+
<Box flexDirection="column" padding={1} gap={1}>
|
|
31
|
+
<Box borderStyle="round" borderColor={THEME.brand} paddingX={2} gap={1}>
|
|
32
|
+
<Text bold color={THEME.brand}>
|
|
33
|
+
Create a new
|
|
34
|
+
</Text>
|
|
35
|
+
<AnimatedGradientText text="FACET" />
|
|
36
|
+
</Box>
|
|
37
|
+
{children}
|
|
38
|
+
<ExitFooter />
|
|
39
|
+
</Box>
|
|
40
|
+
)
|
|
41
|
+
}
|
package/src/tui/theme.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { THEME } from '@agent-facets/brand'
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { type BuildProgress, runBuildPipeline, writeBuildOutput } from '@agent-facets/core'
|
|
2
|
+
import { Box, Text, useApp } from 'ink'
|
|
3
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
4
|
+
import type { Stage } from '../../components/stage-row.tsx'
|
|
5
|
+
import { StageRow } from '../../components/stage-row.tsx'
|
|
6
|
+
import { THEME } from '../../theme.ts'
|
|
7
|
+
|
|
8
|
+
interface BuildViewResult {
|
|
9
|
+
name: string
|
|
10
|
+
version: string
|
|
11
|
+
files: string[]
|
|
12
|
+
archiveFilename: string
|
|
13
|
+
integrity: string
|
|
14
|
+
warnings: string[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function BuildView({
|
|
18
|
+
rootDir,
|
|
19
|
+
onSuccess,
|
|
20
|
+
onFailure,
|
|
21
|
+
}: {
|
|
22
|
+
rootDir: string
|
|
23
|
+
onSuccess?: (name: string, version: string, fileCount: number, integrity: string) => void
|
|
24
|
+
onFailure?: (errorCount: number) => void
|
|
25
|
+
}) {
|
|
26
|
+
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
|
+
])
|
|
32
|
+
const [result, setResult] = useState<BuildViewResult | null>(null)
|
|
33
|
+
const [errors, setErrors] = useState<string[]>([])
|
|
34
|
+
const [warnings, setWarnings] = useState<string[]>([])
|
|
35
|
+
|
|
36
|
+
const updateStage = useCallback((index: number, update: Partial<Stage>) => {
|
|
37
|
+
setStages((prev) => prev.map((s, i) => (i === index ? { ...s, ...update } : s)))
|
|
38
|
+
}, [])
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
async function run() {
|
|
42
|
+
// Stages 1 & 2: Validate and assemble archive (pipeline handles both, emits progress)
|
|
43
|
+
const stageIndexMap: Record<string, number> = {
|
|
44
|
+
'Validating manifest': 0,
|
|
45
|
+
'Assembling archive': 1,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const pipelineResult = await runBuildPipeline(rootDir, (progress: BuildProgress) => {
|
|
49
|
+
const index = stageIndexMap[progress.stage]
|
|
50
|
+
if (index !== undefined) {
|
|
51
|
+
updateStage(index, {
|
|
52
|
+
status: progress.status === 'running' ? 'running' : progress.status === 'done' ? 'done' : 'failed',
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
setWarnings(pipelineResult.warnings)
|
|
58
|
+
|
|
59
|
+
if (!pipelineResult.ok) {
|
|
60
|
+
setErrors(pipelineResult.errors.map((e) => `${e.path ? `${e.path}: ` : ''}${e.message}`))
|
|
61
|
+
onFailure?.(pipelineResult.errors.length)
|
|
62
|
+
exit(new Error('Build failed'))
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Stage 3: Write output
|
|
67
|
+
updateStage(2, { status: 'running' })
|
|
68
|
+
try {
|
|
69
|
+
await writeBuildOutput(pipelineResult, rootDir)
|
|
70
|
+
|
|
71
|
+
// Derive file list from asset hashes (these are the files inside the archive)
|
|
72
|
+
const files = Object.keys(pipelineResult.assetHashes).sort()
|
|
73
|
+
|
|
74
|
+
updateStage(2, { status: 'done' })
|
|
75
|
+
setResult({
|
|
76
|
+
name: pipelineResult.data.name,
|
|
77
|
+
version: pipelineResult.data.version,
|
|
78
|
+
files,
|
|
79
|
+
archiveFilename: pipelineResult.archiveFilename,
|
|
80
|
+
integrity: pipelineResult.integrity,
|
|
81
|
+
warnings: pipelineResult.warnings,
|
|
82
|
+
})
|
|
83
|
+
onSuccess?.(pipelineResult.data.name, pipelineResult.data.version, files.length, pipelineResult.integrity)
|
|
84
|
+
exit()
|
|
85
|
+
} catch (err) {
|
|
86
|
+
updateStage(2, { status: 'failed', detail: String(err) })
|
|
87
|
+
exit(err instanceof Error ? err : new Error(String(err)))
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
run()
|
|
92
|
+
}, [rootDir, exit, onSuccess, onFailure, updateStage])
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<Box flexDirection="column" padding={1} gap={1}>
|
|
96
|
+
<Text bold color={THEME.brand}>
|
|
97
|
+
Building facet...
|
|
98
|
+
</Text>
|
|
99
|
+
|
|
100
|
+
<Box flexDirection="column">
|
|
101
|
+
{stages.map((s) => (
|
|
102
|
+
<StageRow key={s.label} stage={s} />
|
|
103
|
+
))}
|
|
104
|
+
</Box>
|
|
105
|
+
|
|
106
|
+
{warnings.length > 0 && (
|
|
107
|
+
<Box flexDirection="column">
|
|
108
|
+
{warnings.map((w) => (
|
|
109
|
+
<Text key={w} color={THEME.warning}>
|
|
110
|
+
{' '}
|
|
111
|
+
⚠ {w}
|
|
112
|
+
</Text>
|
|
113
|
+
))}
|
|
114
|
+
</Box>
|
|
115
|
+
)}
|
|
116
|
+
|
|
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
|
+
{result && (
|
|
132
|
+
<Box flexDirection="column">
|
|
133
|
+
<Text color={THEME.success} bold>
|
|
134
|
+
Built successfully → dist/
|
|
135
|
+
</Text>
|
|
136
|
+
<Text> {result.archiveFilename}</Text>
|
|
137
|
+
<Text color={THEME.hint}> Archive contents:</Text>
|
|
138
|
+
{result.files.map((f) => (
|
|
139
|
+
<Text key={f}> {f}</Text>
|
|
140
|
+
))}
|
|
141
|
+
<Box marginTop={1}>
|
|
142
|
+
<Text color={THEME.hint}>
|
|
143
|
+
{result.files.length} asset{result.files.length !== 1 ? 's' : ''} · {result.integrity}
|
|
144
|
+
</Text>
|
|
145
|
+
</Box>
|
|
146
|
+
<Box marginTop={1}>
|
|
147
|
+
<Text color={THEME.hint}>Next: facet publish (coming soon)</Text>
|
|
148
|
+
</Box>
|
|
149
|
+
</Box>
|
|
150
|
+
)}
|
|
151
|
+
</Box>
|
|
152
|
+
)
|
|
153
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Box, Text } from 'ink'
|
|
2
|
+
import { useEffect } from 'react'
|
|
3
|
+
import type { CreateOptions } from '../../../commands/create-scaffold.ts'
|
|
4
|
+
import { previewFiles } from '../../../commands/create-scaffold.ts'
|
|
5
|
+
import { Button } from '../../components/button.tsx'
|
|
6
|
+
import { useFocusOrder } from '../../context/focus-order-context.ts'
|
|
7
|
+
import { WizardLayout } from '../../layouts/wizard-layout.tsx'
|
|
8
|
+
import { THEME } from '../../theme.ts'
|
|
9
|
+
|
|
10
|
+
function SummaryField({ label, value }: { label: string; value: string }) {
|
|
11
|
+
return (
|
|
12
|
+
<Box gap={1}>
|
|
13
|
+
<Text color={THEME.success}>✓</Text>
|
|
14
|
+
<Text bold>{label}:</Text>
|
|
15
|
+
<Text>{value}</Text>
|
|
16
|
+
</Box>
|
|
17
|
+
)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function ConfirmView({
|
|
21
|
+
opts,
|
|
22
|
+
onConfirm,
|
|
23
|
+
onBack,
|
|
24
|
+
}: {
|
|
25
|
+
opts: CreateOptions
|
|
26
|
+
onConfirm: () => void
|
|
27
|
+
onBack: () => void
|
|
28
|
+
}) {
|
|
29
|
+
const files = previewFiles(opts)
|
|
30
|
+
const { setFocusIds, focus, focusedId } = useFocusOrder()
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
setFocusIds(['confirm-yes', 'confirm-no'])
|
|
34
|
+
focus('confirm-yes')
|
|
35
|
+
}, [setFocusIds, focus])
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<WizardLayout>
|
|
39
|
+
<Box flexDirection="column" marginLeft={2}>
|
|
40
|
+
<SummaryField label="Name" value={opts.name} />
|
|
41
|
+
<SummaryField label="Description" value={opts.description} />
|
|
42
|
+
<SummaryField label="Version" value={opts.version} />
|
|
43
|
+
{opts.skills.length > 0 && <SummaryField label="Skills" value={opts.skills.join(', ')} />}
|
|
44
|
+
{opts.agents.length > 0 && <SummaryField label="Agents" value={opts.agents.join(', ')} />}
|
|
45
|
+
{opts.commands.length > 0 && <SummaryField label="Commands" value={opts.commands.join(', ')} />}
|
|
46
|
+
</Box>
|
|
47
|
+
|
|
48
|
+
<Box flexDirection="column" borderStyle="round" borderColor={THEME.success} paddingX={2} paddingY={1} gap={0}>
|
|
49
|
+
<Text bold color={THEME.success}>
|
|
50
|
+
Files to create:
|
|
51
|
+
</Text>
|
|
52
|
+
{files.map((f) => (
|
|
53
|
+
<Text key={f}> {f}</Text>
|
|
54
|
+
))}
|
|
55
|
+
</Box>
|
|
56
|
+
|
|
57
|
+
<Box gap={2} marginTop={1}>
|
|
58
|
+
<Button
|
|
59
|
+
id="confirm-yes"
|
|
60
|
+
label="[ Yes, create ]"
|
|
61
|
+
color={THEME.success}
|
|
62
|
+
gradient={focusedId === 'confirm-yes'}
|
|
63
|
+
animateGradient={focusedId === 'confirm-yes'}
|
|
64
|
+
onPress={onConfirm}
|
|
65
|
+
/>
|
|
66
|
+
<Button id="confirm-no" label="[ No, go back ]" color={THEME.warning} onPress={onBack} />
|
|
67
|
+
</Box>
|
|
68
|
+
|
|
69
|
+
<Box marginTop={1}>
|
|
70
|
+
<Text dimColor>← → to switch, Enter to confirm</Text>
|
|
71
|
+
</Box>
|
|
72
|
+
</WizardLayout>
|
|
73
|
+
)
|
|
74
|
+
}
|