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.
Files changed (39) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/bunfig.toml +2 -0
  3. package/dist/facet +0 -0
  4. package/dist/facet-ink-test +0 -0
  5. package/package.json +41 -0
  6. package/src/__tests__/cli.test.ts +152 -0
  7. package/src/__tests__/create-build.test.ts +207 -0
  8. package/src/commands/build.ts +45 -0
  9. package/src/commands/create/index.ts +30 -0
  10. package/src/commands/create/types.ts +9 -0
  11. package/src/commands/create/wizard.tsx +24 -0
  12. package/src/commands/create-scaffold.ts +180 -0
  13. package/src/commands.ts +31 -0
  14. package/src/help.ts +36 -0
  15. package/src/index.ts +9 -0
  16. package/src/run.ts +55 -0
  17. package/src/suggest.ts +35 -0
  18. package/src/tui/components/asset-inline-input.tsx +79 -0
  19. package/src/tui/components/asset-item.tsx +48 -0
  20. package/src/tui/components/asset-section.tsx +145 -0
  21. package/src/tui/components/button.tsx +92 -0
  22. package/src/tui/components/editable-field.tsx +172 -0
  23. package/src/tui/components/exit-toast.tsx +20 -0
  24. package/src/tui/components/stage-row.tsx +33 -0
  25. package/src/tui/components/version-selector.tsx +79 -0
  26. package/src/tui/context/focus-mode-context.ts +36 -0
  27. package/src/tui/context/focus-order-context.ts +62 -0
  28. package/src/tui/context/form-state-context.ts +229 -0
  29. package/src/tui/gradient.ts +1 -0
  30. package/src/tui/hooks/use-exit-keys.ts +75 -0
  31. package/src/tui/hooks/use-navigation-keys.ts +34 -0
  32. package/src/tui/layouts/wizard-layout.tsx +41 -0
  33. package/src/tui/theme.ts +1 -0
  34. package/src/tui/views/build/build-view.tsx +153 -0
  35. package/src/tui/views/create/confirm-view.tsx +74 -0
  36. package/src/tui/views/create/create-view.tsx +154 -0
  37. package/src/tui/views/create/wizard.tsx +68 -0
  38. package/src/version.ts +3 -0
  39. 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
+ }
@@ -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
+ }