diff --git a/openspec/changes/support-non-asset-files/tasks.md b/openspec/changes/support-non-asset-files/tasks.md index b432734d..d4f9e133 100644 --- a/openspec/changes/support-non-asset-files/tasks.md +++ b/openspec/changes/support-non-asset-files/tasks.md @@ -126,11 +126,11 @@ - [x] 13.1 Implement: Add an editable default `README.md` scaffold option and template that writes the file and top-level declaration atomically without regenerating authored content after identity edits - [x] 13.2 Implement: Add the dedicated create README card/editor flow, optional disable behavior, state snapshotting, and explicit confirmation preview, and align headless create with the documented policy - [x] 13.3 Implement: Extend edit scanning and reconciliation for undeclared skill companions, common root files, and missing declared supplementary files while routing only exact `README.md` and `README` paths to a dedicated panel -- [ ] 13.4 Implement: Add independent tagged states and actions for both conventional README paths, preserving bytes on adoption and retaining exact paths for scaffold, edit, removal, and declaration changes +- [x] 13.4 Implement: Add independent tagged states and actions for both conventional README paths, preserving bytes on adoption and retaining exact paths for scaffold, edit, removal, and declaration changes - [x] 13.5 Implement: Replace string-parsed reconciliation keys with stable structured identities and represent file/declaration operations as tagged variants with no invalid combinations - [x] 13.6 Implement: Apply README, companion, and generic supplementary changes transactionally with manifest edits and show every queued exact-path operation before Apply -- [ ] 13.7 Implement: Add engine, CLI, TUI, integration, and create-build end-to-end tests covering README defaults/disable/edit preservation in interactive and headless creates, both README paths, adoption, missing-file choices, companion discovery, skill deletion preserving undeclared files, confirmation, cancellation, and buildability -- [ ] 13.8 Verify: Run focused scaffold, edit, create, TUI, integration, and end-to-end tests +- [x] 13.7 Implement: Add engine, CLI, TUI, integration, and create-build end-to-end tests covering README defaults/disable/edit preservation in interactive and headless creates, both README paths, adoption, missing-file choices, companion discovery, skill deletion preserving undeclared files, confirmation, cancellation, and buildability +- [x] 13.8 Verify: Run focused scaffold, edit, create, TUI, integration, and end-to-end tests ## 14. Documentation — Research diff --git a/packages/cli/src/__tests__/create-build.e2e.test.ts b/packages/cli/src/__tests__/create-build.e2e.test.ts index 812e4e08..951b7713 100644 --- a/packages/cli/src/__tests__/create-build.e2e.test.ts +++ b/packages/cli/src/__tests__/create-build.e2e.test.ts @@ -222,6 +222,34 @@ describe('writeScaffold', () => { const looseManifest = await Bun.file(join(dir, 'dist/facet.json')).exists() expect(looseManifest).toBe(false) }) + + test('scaffolded project with an enabled README builds successfully', async () => { + const dir = await createFixtureDir('scaffold-readme-buildable') + const files = await writeScaffold( + { + name: 'readme-facet', + version: DEFAULT_VERSION, + description: 'Has a README', + skills: ['helper'], + agents: [], + commands: [], + readme: { kind: 'enabled', content: '# readme-facet\n\nHas a README\n' }, + }, + dir, + ) + // README is written and declared as a top-level supplementary file. + expect(files).toContain('README.md') + const manifest = JSON.parse(await Bun.file(join(dir, 'facet.json')).text()) + expect(manifest.files).toEqual(['README.md']) + expect(await Bun.file(join(dir, 'README.md')).text()).toBe('# readme-facet\n\nHas a README\n') + + // The README-bearing project builds without error into a self-contained archive. + const result = await runCli('build', dir) + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain('Built readme-facet') + const distArchive = await Bun.file(join(dir, `dist/readme-facet-${DEFAULT_VERSION}.facet`)).exists() + expect(distArchive).toBe(true) + }) }) // --- Headless create (e2e) --- @@ -254,6 +282,28 @@ describe('facet create — headless', () => { expect(manifest.agents.helper).toBeDefined() }) + test('writes a default README.md and declares it', async () => { + const dir = await createFixtureDir('create-headless-readme') + const result = await runCli('create', dir, '--name', 'doc-facet', '--description', 'Docs', '--skill', 'greet') + expect(result.exitCode).toBe(0) + + // Default-on README is written and declared, matching the interactive default. + expect(await Bun.file(join(dir, 'README.md')).exists()).toBe(true) + expect(await Bun.file(join(dir, 'README.md')).text()).toBe('# doc-facet\n\nDocs\n') + const manifest = JSON.parse(await Bun.file(join(dir, 'facet.json')).text()) + expect(manifest.files).toEqual(['README.md']) + }) + + test('--no-readme omits the README file and declaration', async () => { + const dir = await createFixtureDir('create-headless-noreadme') + const result = await runCli('create', dir, '--name', 'bare-facet', '--skill', 'greet', '--no-readme') + expect(result.exitCode).toBe(0) + + expect(await Bun.file(join(dir, 'README.md')).exists()).toBe(false) + const manifest = JSON.parse(await Bun.file(join(dir, 'facet.json')).text()) + expect('files' in manifest).toBe(false) + }) + test('missing --name fails with a clear error', async () => { const dir = await createFixtureDir('create-headless-noname') const result = await runCli('create', dir, '--skill', 'greet') diff --git a/packages/cli/src/commands/edit/wizard.tsx b/packages/cli/src/commands/edit/wizard.tsx index 6687f6a0..7b65faa9 100644 --- a/packages/cli/src/commands/edit/wizard.tsx +++ b/packages/cli/src/commands/edit/wizard.tsx @@ -1,16 +1,10 @@ import type { EditContext, EditResult } from '@agent-facets/engine' +import { readmeActionFor } from '@agent-facets/engine' import { render } from 'ink' -import type { AssetSectionKey } from '../../tui/context/form-state-context.ts' import { openInEditorSync } from '../../tui/editor.ts' -import type { EditWizardSnapshot } from '../../tui/views/edit/wizard.tsx' +import type { EditEditorRequest, EditWizardSnapshot } from '../../tui/views/edit/wizard.tsx' import { EditWizard } from '../../tui/views/edit/wizard.tsx' -interface EditorRequest { - section: AssetSectionKey - name: string - description: string -} - export interface RunEditWizardOptions { /** Apply edit operations to disk. */ onApply: (result: EditResult & { outcome: 'applied' }) => Promise @@ -25,7 +19,7 @@ export interface RunEditWizardOptions { export async function runEditWizardInk(context: EditContext, options: RunEditWizardOptions): Promise { let completed = false let snapshot: EditWizardSnapshot | undefined - let pendingEditor: EditorRequest | null = null + let pendingEditor: EditEditorRequest | null = null let done = false while (!done) { @@ -44,8 +38,8 @@ export async function runEditWizardInk(context: EditContext, options: RunEditWiz onSnapshot={(s) => { snapshot = s }} - onRequestEditor={(section, name, description) => { - pendingEditor = { section, name, description } + onRequestEditor={(request) => { + pendingEditor = request instance.clear() instance.unmount() }} @@ -56,29 +50,7 @@ export async function runEditWizardInk(context: EditContext, options: RunEditWiz }) if (pendingEditor) { - const req = pendingEditor as EditorRequest - const edited = openInEditorSync(req.description, `${req.name}.md`) - if (snapshot) { - snapshot = { - ...snapshot, - selectedItem: undefined, - formState: snapshot.formState - ? { - ...snapshot.formState, - assets: { - ...snapshot.formState.assets, - [req.section]: { - ...snapshot.formState.assets[req.section], - descriptions: { - ...snapshot.formState.assets[req.section].descriptions, - ...(edited !== null ? { [req.name]: edited.trim() } : {}), - }, - }, - }, - } - : undefined, - } - } + snapshot = applyEditorRoundTrip(pendingEditor, snapshot) } else { done = true } @@ -86,3 +58,48 @@ export async function runEditWizardInk(context: EditContext, options: RunEditWiz return completed } + +/** + * Open the external editor for a pending request and fold the result back into + * the wizard snapshot so the re-rendered wizard sees the edit. Asset-description + * edits update the form; README edits become the chosen tagged action on the + * exact path (bytes discarded → no action, so a cancelled editor leaves the + * path untouched rather than queuing empty content). + */ +function applyEditorRoundTrip( + request: EditEditorRequest, + snapshot: EditWizardSnapshot | undefined, +): EditWizardSnapshot | undefined { + if (!snapshot) return snapshot + + if (request.kind === 'asset-description') { + const edited = openInEditorSync(request.content, `${request.name}.md`) + return { + ...snapshot, + selectedItem: undefined, + formState: snapshot.formState + ? { + ...snapshot.formState, + assets: { + ...snapshot.formState.assets, + [request.section]: { + ...snapshot.formState.assets[request.section], + descriptions: { + ...snapshot.formState.assets[request.section].descriptions, + ...(edited !== null ? { [request.name]: edited.trim() } : {}), + }, + }, + }, + } + : undefined, + } + } + + // README content edit: seed the editor with the request content, then queue + // the tagged action for this path. A cancelled editor (null) queues nothing. + const edited = openInEditorSync(request.content, request.path) + if (edited === null) return { ...snapshot, selectedItem: undefined } + const nextReadme = new Map(snapshot.readmeActions) + nextReadme.set(request.path, readmeActionFor(request.option.kind, edited)) + return { ...snapshot, selectedItem: undefined, readmeActions: nextReadme } +} diff --git a/packages/cli/src/tui/views/__tests__/confirm-privacy.test.tsx b/packages/cli/src/tui/views/__tests__/confirm-privacy.test.tsx index deda3cab..fe0d3ac4 100644 --- a/packages/cli/src/tui/views/__tests__/confirm-privacy.test.tsx +++ b/packages/cli/src/tui/views/__tests__/confirm-privacy.test.tsx @@ -1,4 +1,5 @@ import { describe, expect, test } from 'bun:test' +import type { EditOperation } from '@agent-facets/engine' import { render } from 'ink-testing-library' import { FocusOrderProvider } from '../../context/focus-order-context.ts' import { type FormState, FormStateProvider } from '../../context/form-state-context.ts' @@ -86,3 +87,35 @@ describe('edit confirmation summary privacy', () => { instance.unmount() }) }) + +describe('edit confirmation lists queued README operations', () => { + function renderEditWithOps(operations: EditOperation[]) { + return render( + + + {}} onBack={() => {}} /> + + , + ) + } + + test('shows the exact README path and verb for a queued write', () => { + const instance = renderEditWithOps([ + { op: 'write-manifest', manifest: { name: 'cowsay', version: '0.0.0', files: ['README.md'] } }, + { op: 'write-file', path: 'README.md', content: '# cowsay\n' }, + ]) + const frame = instance.lastFrame() ?? '' + expect(frame).toContain('File changes:') + expect(frame).toContain('Write README.md') + instance.unmount() + }) + + test('shows a queued README removal as a delete of the exact path', () => { + const instance = renderEditWithOps([ + { op: 'write-manifest', manifest: { name: 'cowsay', version: '0.0.0' } }, + { op: 'delete-file', path: 'README' }, + ]) + expect(instance.lastFrame() ?? '').toContain('Delete README') + instance.unmount() + }) +}) diff --git a/packages/cli/src/tui/views/edit/__tests__/readme-session.test.tsx b/packages/cli/src/tui/views/edit/__tests__/readme-session.test.tsx new file mode 100644 index 00000000..8a234c1d --- /dev/null +++ b/packages/cli/src/tui/views/edit/__tests__/readme-session.test.tsx @@ -0,0 +1,135 @@ +import { describe, expect, test } from 'bun:test' +import { + type EditContext, + type EditOperation, + type EditResult, + type ReadmeAction, + type ReadmeFileState, + type ReadmePath, + readmeActionFor, +} from '@agent-facets/engine' +import type { FacetManifest } from '@agent-facets/protocol' +import { render } from 'ink-testing-library' +import { useEffect } from 'react' +import { manifestToFormState } from '../manifest-to-form.ts' +import { useEditSession } from '../use-edit-session.ts' + +/** + * Drives README resolutions on mount, then reports the operations `buildResult` + * produces so assertions observe the settled edit output. README bytes and + * declarations are derived purely from the queued actions — this exercises the + * `use-edit-session` wiring that the dedicated README panel drives. + */ +function Probe({ + context, + steps, + report, +}: { + context: EditContext + steps: (resolveReadme: (path: ReadmePath, action: ReadmeAction) => void) => void + report: (r: EditResult) => void +}) { + const { resolveReadme, buildResult } = useEditSession(context) + useEffect(() => { + steps(resolveReadme) + }, [steps, resolveReadme]) + report(buildResult(manifestToFormState(context.manifest))) + return null +} + +function nextTick(): Promise { + return new Promise((resolve) => setImmediate(resolve)) +} + +async function run( + context: EditContext, + steps: (resolveReadme: (path: ReadmePath, action: ReadmeAction) => void) => void, +): Promise { + let result: EditResult = { outcome: 'cancelled' } as EditResult + const instance = render( (result = r)} />) + await nextTick() + instance.unmount() + if (result.outcome !== 'applied') expect.unreachable() + return result.operations +} + +/** Build an EditContext with the given manifest and README panel states. */ +function contextWith(manifest: FacetManifest, readme: ReadmeFileState[]): EditContext { + return { rootDir: '/tmp/facet', manifest, reconciliationItems: [], readme } +} + +/** The manifest inside the queued `write-manifest` operation. */ +function finalManifest(operations: EditOperation[]): FacetManifest { + const op = operations.find((o) => o.op === 'write-manifest') + if (op?.op !== 'write-manifest') expect.unreachable() + return op.manifest +} + +const BASE: FacetManifest = { name: 'demo', version: '1.0.0' } + +describe('edit README session wiring', () => { + test('adopt adds the declaration and writes no README file (preserves bytes)', async () => { + const ctx = contextWith(BASE, [{ path: 'README.md', state: 'present-undeclared', content: '# on disk' }]) + const ops = await run(ctx, (resolveReadme) => { + resolveReadme('README.md', readmeActionFor('adopt', '')) + }) + // No file op — on-disk bytes are untouched. + expect(ops.some((o) => o.op === 'write-file' || o.op === 'delete-file')).toBe(false) + expect(finalManifest(ops).files).toEqual(['README.md']) + }) + + test('create queues a write-file and adds the declaration', async () => { + const ctx = contextWith(BASE, [{ path: 'README.md', state: 'absent-undeclared' }]) + const ops = await run(ctx, (resolveReadme) => { + resolveReadme('README.md', readmeActionFor('create', '# new\n')) + }) + expect(ops).toContainEqual({ op: 'write-file', path: 'README.md', content: '# new\n' }) + expect(finalManifest(ops).files).toEqual(['README.md']) + }) + + test('remove queues delete-file and drops the declaration', async () => { + const declared: FacetManifest = { ...BASE, files: ['README.md'] } + const ctx = contextWith(declared, [{ path: 'README.md', state: 'present-declared', content: '# old' }]) + const ops = await run(ctx, (resolveReadme) => { + resolveReadme('README.md', readmeActionFor('remove', '')) + }) + expect(ops).toContainEqual({ op: 'delete-file', path: 'README.md' }) + expect('files' in finalManifest(ops)).toBe(false) + }) + + test('scaffold at the exact declared path writes bytes; declaration stays', async () => { + const declared: FacetManifest = { ...BASE, files: ['README'] } + const ctx = contextWith(declared, [{ path: 'README', state: 'declared-missing' }]) + const ops = await run(ctx, (resolveReadme) => { + resolveReadme('README', readmeActionFor('scaffold', '# t\n')) + }) + expect(ops).toContainEqual({ op: 'write-file', path: 'README', content: '# t\n' }) + expect(finalManifest(ops).files).toEqual(['README']) + }) + + test('the two conventional paths are managed independently', async () => { + // README.md present+undeclared → adopt; README absent → create. + const ctx = contextWith(BASE, [ + { path: 'README.md', state: 'present-undeclared', content: '# md' }, + { path: 'README', state: 'absent-undeclared' }, + ]) + const ops = await run(ctx, (resolveReadme) => { + resolveReadme('README.md', readmeActionFor('adopt', '')) + resolveReadme('README', readmeActionFor('create', '# plain\n')) + }) + // Only the created path writes a file; adoption writes nothing. + expect(ops.filter((o) => o.op === 'write-file')).toEqual([ + { op: 'write-file', path: 'README', content: '# plain\n' }, + ]) + // Both paths end up declared, independently. + expect(finalManifest(ops).files).toEqual(['README', 'README.md']) + }) + + test('a path left as-is (no action) queues no README change', async () => { + const ctx = contextWith(BASE, [{ path: 'README.md', state: 'present-undeclared', content: '# on disk' }]) + const ops = await run(ctx, () => { + // No resolveReadme call — the author leaves README alone. + }) + expect(ops).toEqual([{ op: 'write-manifest', manifest: BASE }]) + }) +}) diff --git a/packages/cli/src/tui/views/edit/readme-panel-view.tsx b/packages/cli/src/tui/views/edit/readme-panel-view.tsx new file mode 100644 index 00000000..734a33e3 --- /dev/null +++ b/packages/cli/src/tui/views/edit/readme-panel-view.tsx @@ -0,0 +1,220 @@ +import { + type ReadmeAction, + type ReadmeActionOption, + type ReadmeFileState, + type ReadmePath, + readmeActionOptions, + readmeOptionKindFor, +} from '@agent-facets/engine' +import { Box, Text, useInput } from 'ink' +import Gradient from 'ink-gradient' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { Button } from '../../components/button.tsx' +import { useFocusOrder } from '../../context/focus-order-context.ts' +import { GRADIENT_STOPS, getAnimatedGradient } from '../../gradient.ts' +import { THEME } from '../../theme.ts' + +const ANIMATION_INTERVAL_MS = 75 + +/** Human-readable summary of a README path's current on-disk/declaration state. */ +function stateLabel(state: ReadmeFileState): string { + switch (state.state) { + case 'present-declared': + return 'present, declared' + case 'present-undeclared': + return 'present, not declared' + case 'declared-missing': + return 'declared, missing on disk' + case 'absent-undeclared': + return 'absent' + } +} + +/** + * One README path row. Renders the path, its current state, and its legal + * action options (1 or 2 per state). Left/right move the highlight; Enter + * selects. Content-bearing options are marked so the parent opens the editor. + * `none` (leave as-is) is always available as an implicit first choice so an + * author can decline to touch a path. + */ +function ReadmeRow({ + id, + state, + action, + onSelect, +}: { + id: string + state: ReadmeFileState + action: ReadmeAction | undefined + onSelect: (option: ReadmeActionOption) => void +}) { + const { focusedId } = useFocusOrder() + const isFocused = focusedId === id + const options = useMemo(() => readmeActionOptions(state), [state]) + + const selectedKind = action ? readmeOptionKindFor(action) : null + const selectedIndex = selectedKind ? options.findIndex((o) => o.kind === selectedKind) : -1 + + const [highlightedIndex, setHighlightedIndex] = useState(selectedIndex >= 0 ? selectedIndex : 0) + const [offset, setOffset] = useState(0) + + useEffect(() => { + if (!isFocused) return + const interval = setInterval(() => { + setOffset((prev) => (prev + 1) % GRADIENT_STOPS.length) + }, ANIMATION_INTERVAL_MS) + return () => clearInterval(interval) + }, [isFocused]) + + useInput( + (_input, key) => { + if (key.leftArrow) setHighlightedIndex((i) => Math.max(0, i - 1)) + if (key.rightArrow) setHighlightedIndex((i) => Math.min(options.length - 1, i + 1)) + if (key.return) { + const option = options[highlightedIndex] + if (option) onSelect(option) + } + }, + { isActive: isFocused }, + ) + + const animatedColors = getAnimatedGradient(offset) + const leaveSelected = action === undefined || action.kind === 'none' + + return ( + + + + {isFocused ? ( + + ▸ + + ) : ( + + )} + {state.path} + + + + {options.map((opt, i) => { + const isSelected = selectedIndex === i + const isHighlighted = isFocused && highlightedIndex === i + if (isHighlighted) { + return ( + + {isSelected && } + + {opt.label} + + + ) + } + if (isSelected) { + return ( + + + + {opt.label} + + + ) + } + return ( + + {opt.label} + + ) + })} + + + + + + {stateLabel(state)} + {leaveSelected ? ' · leaving as-is' : ''} + + + + ) +} + +/** + * The dedicated facet-level README panel (design D11). Shows both conventional + * README paths independently, each with its legal actions. Content-bearing + * choices request the external editor via `onEditReadme`; all others are queued + * immediately via `onResolve`. No disk or manifest change happens here — every + * choice is queued until the confirmation Apply. + */ +export function ReadmePanelView({ + states, + actions, + onResolve, + onEditReadme, + onContinue, +}: { + states: ReadmeFileState[] + actions: Map + onResolve: (path: ReadmePath, option: ReadmeActionOption) => void + onEditReadme: (path: ReadmePath, option: ReadmeActionOption) => void + onContinue: () => void +}) { + const { setFocusIds, focusedId, focus } = useFocusOrder() + + const focusIds = useMemo(() => { + const ids = states.map((s) => `readme-${s.path}`) + ids.push('readme-continue') + return ids + }, [states]) + + useEffect(() => { + setFocusIds(focusIds) + if (!focusedId) focus(focusIds[0] ?? '') + }, [focusIds, setFocusIds, focusedId, focus]) + + const handleSelect = useCallback( + (path: ReadmePath, option: ReadmeActionOption) => { + if (option.requiresEditor) { + onEditReadme(path, option) + } else { + onResolve(path, option) + } + focus('readme-continue') + }, + [onResolve, onEditReadme, focus], + ) + + return ( + + + README + + + Manage the conventional README files. Changes apply on the final confirmation. + + + {states.map((state) => ( + handleSelect(state.path, option)} + /> + ))} + + +