Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions openspec/changes/support-non-asset-files/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
50 changes: 50 additions & 0 deletions packages/cli/src/__tests__/create-build.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) ---
Expand Down Expand Up @@ -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')
Expand Down
85 changes: 51 additions & 34 deletions packages/cli/src/commands/edit/wizard.tsx
Original file line number Diff line number Diff line change
@@ -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<void>
Expand All @@ -25,7 +19,7 @@ export interface RunEditWizardOptions {
export async function runEditWizardInk(context: EditContext, options: RunEditWizardOptions): Promise<boolean> {
let completed = false
let snapshot: EditWizardSnapshot | undefined
let pendingEditor: EditorRequest | null = null
let pendingEditor: EditEditorRequest | null = null
let done = false

while (!done) {
Expand All @@ -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()
}}
Expand All @@ -56,33 +50,56 @@ 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
}
}

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 }
}
33 changes: 33 additions & 0 deletions packages/cli/src/tui/views/__tests__/confirm-privacy.test.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -86,3 +87,35 @@ describe('edit confirmation summary privacy', () => {
instance.unmount()
})
})

describe('edit confirmation lists queued README operations', () => {
function renderEditWithOps(operations: EditOperation[]) {
return render(
<FocusOrderProvider>
<FormStateProvider initialState={formWith(false)}>
<EditConfirmView operations={operations} onConfirm={() => {}} onBack={() => {}} />
</FormStateProvider>
</FocusOrderProvider>,
)
}

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()
})
})
135 changes: 135 additions & 0 deletions packages/cli/src/tui/views/edit/__tests__/readme-session.test.tsx
Original file line number Diff line number Diff line change
@@ -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<void> {
return new Promise((resolve) => setImmediate(resolve))
}

async function run(
context: EditContext,
steps: (resolveReadme: (path: ReadmePath, action: ReadmeAction) => void) => void,
): Promise<EditOperation[]> {
let result: EditResult = { outcome: 'cancelled' } as EditResult
const instance = render(<Probe context={context} steps={steps} report={(r) => (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 }])
})
})
Loading