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.
Files changed (40) hide show
  1. package/.package.json.bak +44 -0
  2. package/.turbo/turbo-build.log +2 -2
  3. package/CHANGELOG.md +33 -0
  4. package/dist/facet +0 -0
  5. package/package.json +7 -4
  6. package/src/__tests__/cli.test.ts +69 -26
  7. package/src/__tests__/create-build.test.ts +32 -12
  8. package/src/__tests__/edit-integration.test.ts +171 -0
  9. package/src/__tests__/resolve-dir.test.ts +95 -0
  10. package/src/commands/build.ts +17 -4
  11. package/src/commands/create/index.ts +51 -5
  12. package/src/commands/create/wizard.tsx +66 -15
  13. package/src/commands/create-scaffold.ts +14 -10
  14. package/src/commands/edit/index.ts +144 -0
  15. package/src/commands/edit/wizard.tsx +74 -0
  16. package/src/commands/resolve-dir.ts +98 -0
  17. package/src/commands.ts +11 -2
  18. package/src/help.ts +17 -10
  19. package/src/index.ts +2 -1
  20. package/src/run.ts +32 -5
  21. package/src/tui/components/asset-description.tsx +17 -0
  22. package/src/tui/components/asset-field-picker.tsx +78 -0
  23. package/src/tui/components/asset-inline-input.tsx +13 -1
  24. package/src/tui/components/asset-item.tsx +3 -7
  25. package/src/tui/components/asset-section.tsx +72 -26
  26. package/src/tui/components/reconciliation-item.tsx +129 -0
  27. package/src/tui/components/stage-row.tsx +16 -4
  28. package/src/tui/context/focus-order-context.ts +8 -2
  29. package/src/tui/context/form-state-context.ts +34 -3
  30. package/src/tui/editor.ts +40 -0
  31. package/src/tui/views/build/build-view.tsx +43 -44
  32. package/src/tui/views/create/create-view.tsx +17 -13
  33. package/src/tui/views/create/wizard.tsx +35 -6
  34. package/src/tui/views/edit/edit-confirm-view.tsx +93 -0
  35. package/src/tui/views/edit/edit-types.ts +34 -0
  36. package/src/tui/views/edit/edit-view.tsx +140 -0
  37. package/src/tui/views/edit/manifest-to-form.ts +38 -0
  38. package/src/tui/views/edit/reconciliation-view.tsx +170 -0
  39. package/src/tui/views/edit/use-edit-session.ts +125 -0
  40. package/src/tui/views/edit/wizard.tsx +129 -0
@@ -0,0 +1,170 @@
1
+ import { Box, Text } from 'ink'
2
+ import { useCallback, useEffect, useMemo } from 'react'
3
+ import { Button } from '../../components/button.tsx'
4
+ import { ReconciliationItemRow } from '../../components/reconciliation-item.tsx'
5
+ import { useFocusOrder } from '../../context/focus-order-context.ts'
6
+ import { THEME } from '../../theme.ts'
7
+ import type { ReconciliationItem, ReconciliationResolution } from './edit-types.ts'
8
+
9
+ /** Maps a reconciliation item to a unique key. */
10
+ function itemKey(item: ReconciliationItem): string {
11
+ return `${item.kind}:${item.type}:${item.name}`
12
+ }
13
+
14
+ /** Returns the two action options for a reconciliation item kind. */
15
+ function optionsForKind(kind: ReconciliationItem['kind']): [{ label: string }, { label: string }] {
16
+ switch (kind) {
17
+ case 'addition':
18
+ return [{ label: 'Add to manifest' }, { label: 'Ignore for now' }]
19
+ case 'missing':
20
+ return [{ label: 'Scaffold template' }, { label: 'Remove from manifest' }]
21
+ case 'front-matter':
22
+ return [{ label: 'Strip front matter' }, { label: 'Remove from manifest' }]
23
+ }
24
+ }
25
+
26
+ /** Converts a selected option index to a resolution for the given item kind. */
27
+ function indexToResolution(kind: ReconciliationItem['kind'], index: number): ReconciliationResolution {
28
+ switch (kind) {
29
+ case 'addition':
30
+ return index === 0 ? { action: 'add-to-manifest' } : { action: 'ignore' }
31
+ case 'missing':
32
+ return index === 0 ? { action: 'scaffold-template' } : { action: 'remove-from-manifest' }
33
+ case 'front-matter':
34
+ return index === 0 ? { action: 'strip-front-matter' } : { action: 'remove-from-manifest' }
35
+ }
36
+ }
37
+
38
+ /** Returns the selected option index for a resolution, or null. */
39
+ function resolutionToIndex(
40
+ kind: ReconciliationItem['kind'],
41
+ resolution: ReconciliationResolution | undefined,
42
+ ): number | null {
43
+ if (!resolution) return null
44
+ switch (kind) {
45
+ case 'addition':
46
+ return resolution.action === 'add-to-manifest' ? 0 : resolution.action === 'ignore' ? 1 : null
47
+ case 'missing':
48
+ return resolution.action === 'scaffold-template' ? 0 : resolution.action === 'remove-from-manifest' ? 1 : null
49
+ case 'front-matter':
50
+ return resolution.action === 'strip-front-matter' ? 0 : resolution.action === 'remove-from-manifest' ? 1 : null
51
+ }
52
+ }
53
+
54
+ export function ReconciliationView({
55
+ items,
56
+ resolutions,
57
+ onResolve,
58
+ onContinue,
59
+ }: {
60
+ items: ReconciliationItem[]
61
+ resolutions: Map<string, ReconciliationResolution>
62
+ onResolve: (key: string, resolution: ReconciliationResolution) => void
63
+ onContinue: () => void
64
+ }) {
65
+ const { setFocusIds, focusedId, focus } = useFocusOrder()
66
+
67
+ const allResolved = items.every((item) => resolutions.has(itemKey(item)))
68
+
69
+ // Group items by kind
70
+ const additions = items.filter((i) => i.kind === 'addition')
71
+ const missing = items.filter((i) => i.kind === 'missing')
72
+ const frontMatter = items.filter((i) => i.kind === 'front-matter')
73
+
74
+ // Build focus order: all items then continue button
75
+ const focusIds = useMemo(() => {
76
+ const ids = items.map((item) => `recon-${itemKey(item)}`)
77
+ ids.push('recon-continue')
78
+ return ids
79
+ }, [items])
80
+
81
+ useEffect(() => {
82
+ setFocusIds(focusIds)
83
+ if (!focusedId) {
84
+ focus(focusIds[0] ?? '')
85
+ }
86
+ }, [focusIds, setFocusIds, focusedId, focus])
87
+
88
+ const handleSelect = useCallback(
89
+ (item: ReconciliationItem, optionIndex: number) => {
90
+ const key = itemKey(item)
91
+ const resolution = indexToResolution(item.kind, optionIndex)
92
+ onResolve(key, resolution)
93
+
94
+ // Auto-advance to next unresolved item
95
+ const currentIdx = items.findIndex((i) => itemKey(i) === key)
96
+ for (let i = currentIdx + 1; i < items.length; i++) {
97
+ const nextItem = items[i]
98
+ if (!nextItem) continue
99
+ const nextKey = itemKey(nextItem)
100
+ if (!resolutions.has(nextKey)) {
101
+ focus(`recon-${nextKey}`)
102
+ return
103
+ }
104
+ }
105
+ // All resolved — focus continue button
106
+ focus('recon-continue')
107
+ },
108
+ [items, resolutions, onResolve, focus],
109
+ )
110
+
111
+ const renderGroup = (label: string, groupItems: ReconciliationItem[]) => {
112
+ if (groupItems.length === 0) return null
113
+ return (
114
+ <Box flexDirection="column" key={label}>
115
+ <Box marginBottom={0}>
116
+ <Text bold color={THEME.warning}>
117
+ {label}
118
+ </Text>
119
+ </Box>
120
+ {groupItems.map((item) => {
121
+ const key = itemKey(item)
122
+ const description =
123
+ item.kind === 'missing'
124
+ ? `${item.name} (${item.type}) — ${item.expectedPath}`
125
+ : 'path' in item
126
+ ? item.path
127
+ : ''
128
+
129
+ return (
130
+ <ReconciliationItemRow
131
+ key={key}
132
+ id={`recon-${key}`}
133
+ description={description}
134
+ options={optionsForKind(item.kind)}
135
+ selectedIndex={resolutionToIndex(item.kind, resolutions.get(key))}
136
+ onSelect={(index) => handleSelect(item, index)}
137
+ />
138
+ )
139
+ })}
140
+ </Box>
141
+ )
142
+ }
143
+
144
+ return (
145
+ <Box flexDirection="column" padding={1} gap={1}>
146
+ <Text bold color={THEME.brand}>
147
+ Reconciliation — {items.length} item{items.length !== 1 ? 's' : ''} to resolve
148
+ </Text>
149
+
150
+ {renderGroup('New files on disk:', additions)}
151
+ {renderGroup('Missing from disk:', missing)}
152
+ {renderGroup('Front matter detected:', frontMatter)}
153
+
154
+ <Box marginTop={1}>
155
+ <Button
156
+ id="recon-continue"
157
+ label="[ Continue to edit ]"
158
+ disabled={!allResolved}
159
+ gradient={allResolved}
160
+ animateGradient={allResolved && focusedId === 'recon-continue'}
161
+ onPress={onContinue}
162
+ />
163
+ </Box>
164
+
165
+ <Box>
166
+ <Text dimColor>↑ ↓ navigate · ← → switch option · Enter select · Esc Esc exit</Text>
167
+ </Box>
168
+ </Box>
169
+ )
170
+ }
@@ -0,0 +1,125 @@
1
+ import type { FacetManifest } from '@agent-facets/core'
2
+ import { useCallback, useState } from 'react'
3
+ import type { AssetSectionKey, FormState } from '../../context/form-state-context.ts'
4
+ import type { EditContext, EditOperation, EditResult, ReconciliationResolution } from './edit-types.ts'
5
+
6
+ /** Maps form section keys to manifest asset keys. */
7
+ const FORM_TO_MANIFEST: Record<AssetSectionKey, 'skills' | 'agents' | 'commands'> = {
8
+ skill: 'skills',
9
+ agent: 'agents',
10
+ command: 'commands',
11
+ }
12
+
13
+ /** Builds a manifest from form state, preserving non-asset fields from the original. */
14
+ function buildManifest(original: FacetManifest, form: FormState): FacetManifest {
15
+ const manifest: FacetManifest = {
16
+ ...original,
17
+ name: form.fields.name.value,
18
+ version: form.fields.version.value,
19
+ }
20
+
21
+ if (form.fields.description.value) {
22
+ manifest.description = form.fields.description.value
23
+ }
24
+
25
+ for (const [formKey, manifestKey] of Object.entries(FORM_TO_MANIFEST) as [
26
+ AssetSectionKey,
27
+ 'skills' | 'agents' | 'commands',
28
+ ][]) {
29
+ const items = form.assets[formKey].items
30
+ if (items.length > 0) {
31
+ const section: Record<string, { description: string }> = {}
32
+ for (const name of items) {
33
+ section[name] = { description: form.assets[formKey].descriptions[name] ?? '' }
34
+ }
35
+ manifest[manifestKey] = section
36
+ } else {
37
+ delete manifest[manifestKey]
38
+ }
39
+ }
40
+
41
+ return manifest
42
+ }
43
+
44
+ /** Builds the list of file operations from resolutions + form changes. */
45
+ function buildOperations(
46
+ context: EditContext,
47
+ form: FormState,
48
+ resolutions: Map<string, ReconciliationResolution>,
49
+ ): EditOperation[] {
50
+ const operations: EditOperation[] = [{ op: 'write-manifest' }]
51
+
52
+ // Operations from reconciliation resolutions
53
+ for (const [key, resolution] of resolutions) {
54
+ const parts = key.split(':')
55
+ const kind = parts[0]
56
+ const assetType = parts[1] as 'skills' | 'agents' | 'commands'
57
+ const name = parts[2]
58
+ if (!kind || !assetType || !name) continue
59
+
60
+ if (resolution.action === 'scaffold-template') {
61
+ operations.push({ op: 'scaffold', type: assetType, name })
62
+ } else if (resolution.action === 'remove-from-manifest' && kind === 'front-matter') {
63
+ operations.push({ op: 'delete-file', type: assetType, name })
64
+ } else if (resolution.action === 'strip-front-matter') {
65
+ const item = context.reconciliationItems.find(
66
+ (i) => i.kind === 'front-matter' && i.type === assetType && i.name === name,
67
+ )
68
+ if (item && 'path' in item) {
69
+ operations.push({ op: 'strip-front-matter', type: assetType, name, path: item.path })
70
+ }
71
+ }
72
+ }
73
+
74
+ // New assets added during editing (not from reconciliation)
75
+ for (const [formKey, manifestKey] of Object.entries(FORM_TO_MANIFEST) as [
76
+ AssetSectionKey,
77
+ 'skills' | 'agents' | 'commands',
78
+ ][]) {
79
+ const originalSection = context.manifest[manifestKey]
80
+ const originalNames =
81
+ originalSection && typeof originalSection === 'object' && !Array.isArray(originalSection)
82
+ ? Object.keys(originalSection)
83
+ : []
84
+
85
+ for (const name of form.assets[formKey].items) {
86
+ const isFromReconciliation = resolutions.has(`addition:${manifestKey}:${name}`)
87
+ if (!originalNames.includes(name) && !isFromReconciliation) {
88
+ operations.push({ op: 'scaffold', type: manifestKey, name })
89
+ }
90
+ }
91
+
92
+ // Removed assets
93
+ for (const name of originalNames) {
94
+ if (!form.assets[formKey].items.includes(name)) {
95
+ operations.push({ op: 'delete-file', type: manifestKey, name })
96
+ }
97
+ }
98
+ }
99
+
100
+ return operations
101
+ }
102
+
103
+ export function useEditSession(context: EditContext) {
104
+ const [resolutions, setResolutions] = useState<Map<string, ReconciliationResolution>>(new Map())
105
+
106
+ const resolve = useCallback((key: string, resolution: ReconciliationResolution) => {
107
+ setResolutions((prev) => {
108
+ const next = new Map(prev)
109
+ next.set(key, resolution)
110
+ return next
111
+ })
112
+ }, [])
113
+
114
+ /** Builds the final edit result from current form state and resolutions. */
115
+ const buildResult = useCallback(
116
+ (form: FormState): EditResult => {
117
+ const manifest = buildManifest(context.manifest, form)
118
+ const operations = buildOperations(context, form, resolutions)
119
+ return { outcome: 'applied', manifest, operations }
120
+ },
121
+ [context, resolutions],
122
+ )
123
+
124
+ return { resolutions, resolve, buildResult }
125
+ }
@@ -0,0 +1,129 @@
1
+ import { useApp } from 'ink'
2
+ import { useCallback, useEffect, useMemo, useState } from 'react'
3
+ import { FocusModeProvider, useFocusMode } from '../../context/focus-mode-context.ts'
4
+ import { FocusOrderProvider, useFocusOrder } from '../../context/focus-order-context.ts'
5
+ import type { AssetSectionKey, FormState } from '../../context/form-state-context.ts'
6
+ import { FormStateProvider, useFormState } from '../../context/form-state-context.ts'
7
+ import { useExitKeys } from '../../hooks/use-exit-keys.ts'
8
+ import { useNavigationKeys } from '../../hooks/use-navigation-keys.ts'
9
+ import { EditConfirmView } from './edit-confirm-view.tsx'
10
+ import type { EditContext, EditResult, ReconciliationResolution } from './edit-types.ts'
11
+ import { EditView } from './edit-view.tsx'
12
+ import { manifestToFormState } from './manifest-to-form.ts'
13
+ import { ReconciliationView } from './reconciliation-view.tsx'
14
+ import { useEditSession } from './use-edit-session.ts'
15
+
16
+ type EditPhase = 'reconciliation' | 'editing' | 'confirmation'
17
+
18
+ export interface EditWizardSnapshot {
19
+ phase: EditPhase
20
+ formState?: FormState
21
+ focusedId?: string | null
22
+ resolutions: Map<string, ReconciliationResolution>
23
+ selectedItem?: {
24
+ section: AssetSectionKey
25
+ name: string
26
+ field: 'name' | 'description'
27
+ }
28
+ }
29
+
30
+ export interface EditWizardProps {
31
+ context: EditContext
32
+ snapshot?: EditWizardSnapshot
33
+ onComplete: (result: EditResult) => void
34
+ onSnapshot?: (snapshot: EditWizardSnapshot) => void
35
+ onRequestEditor?: (section: AssetSectionKey, name: string, description: string) => void
36
+ }
37
+
38
+ function EditWizardInner({ context, snapshot, onComplete, onSnapshot, onRequestEditor }: EditWizardProps) {
39
+ const { exit } = useApp()
40
+ const { setMode } = useFocusMode()
41
+ const { form } = useFormState()
42
+ const { focusedId, focus } = useFocusOrder()
43
+ const hasReconciliation = context.reconciliationItems.length > 0
44
+
45
+ const initialPhase = snapshot?.phase ?? (hasReconciliation ? 'reconciliation' : 'editing')
46
+ const [phase, setPhase] = useState<EditPhase>(initialPhase)
47
+ const { resolutions, resolve, buildResult } = useEditSession(context)
48
+
49
+ // Report snapshot to parent for editor round-trips
50
+ useEffect(() => {
51
+ onSnapshot?.({ phase, formState: form, focusedId, resolutions })
52
+ }, [phase, form, focusedId, resolutions, onSnapshot])
53
+
54
+ const cancel = useCallback(() => {
55
+ onComplete({ outcome: 'cancelled' })
56
+ exit()
57
+ }, [onComplete, exit])
58
+
59
+ useExitKeys(cancel)
60
+ useNavigationKeys()
61
+
62
+ const handleEditDescription = useCallback(
63
+ (section: AssetSectionKey, name: string) => {
64
+ const description = form.assets[section].descriptions[name] ?? ''
65
+ onRequestEditor?.(section, name, description)
66
+ },
67
+ [form, onRequestEditor],
68
+ )
69
+
70
+ const handleConfirm = useCallback(() => {
71
+ onComplete(buildResult(form))
72
+ exit()
73
+ }, [form, buildResult, onComplete, exit])
74
+
75
+ if (phase === 'reconciliation') {
76
+ return (
77
+ <ReconciliationView
78
+ items={context.reconciliationItems}
79
+ resolutions={resolutions}
80
+ onResolve={resolve}
81
+ onContinue={() => setPhase('editing')}
82
+ />
83
+ )
84
+ }
85
+
86
+ if (phase === 'editing') {
87
+ return (
88
+ <EditView
89
+ onSubmit={() => {
90
+ setPhase('confirmation')
91
+ setMode('form-confirmation')
92
+ }}
93
+ onEditDescription={handleEditDescription}
94
+ />
95
+ )
96
+ }
97
+
98
+ if (phase === 'confirmation') {
99
+ return (
100
+ <EditConfirmView
101
+ onConfirm={handleConfirm}
102
+ onBack={() => {
103
+ setPhase('editing')
104
+ setMode('form-navigation')
105
+ focus('edit-confirm-btn')
106
+ }}
107
+ />
108
+ )
109
+ }
110
+
111
+ return null
112
+ }
113
+
114
+ export function EditWizard(props: EditWizardProps) {
115
+ const initialFormState = useMemo(
116
+ () => props.snapshot?.formState ?? manifestToFormState(props.context.manifest),
117
+ [props.snapshot?.formState, props.context.manifest],
118
+ )
119
+
120
+ return (
121
+ <FocusModeProvider>
122
+ <FocusOrderProvider initialFocusId={props.snapshot?.focusedId}>
123
+ <FormStateProvider initialState={initialFormState}>
124
+ <EditWizardInner {...props} />
125
+ </FormStateProvider>
126
+ </FocusOrderProvider>
127
+ </FocusModeProvider>
128
+ )
129
+ }