diff --git a/.agents/skills/proof/setup.md b/.agents/skills/proof/setup.md index aad39548..31c3c2bb 100644 --- a/.agents/skills/proof/setup.md +++ b/.agents/skills/proof/setup.md @@ -1,43 +1,28 @@ # Proof setup -The canonical skill files live in this package. The repository -`.agents/skills/proof/` directory is an exclusively generated -projection: do not edit it directly, and stale projected files are deleted by -`pnpm skills:sync`. +## 1. Install the matching CLI and skill -## 1. Choose the package manager - -Use the nearest `package.json`'s `packageManager` field first. If it is absent, -inspect lockfiles. Exactly one of `package-lock.json`, `pnpm-lock.yaml`, -`yarn.lock`, or `bun.lock`/`bun.lockb` must exist. If multiple conflicting -lockfiles exist, ask the user which manager owns the project. - -For an end-user release, read `release.json` next to this file. It is the -canonical package and tag authority: use its `flatbreadVersion` and `gitTag` -values exactly. `skills-lock.json` is installation provenance/restore data only; -do not use its optional ref or version fields as release identity: +From the project root, with npm, pnpm, Yarn, or Bun: ```bash -npx skills add https://github.com/FlatbreadLabs/flatbread/tree//packages/proof/skills/proof --skill proof -npm install --save-dev flatbread@ +npx --yes flatbread@latest proof install-skill ``` -Equivalent commands are: +That command downloads the latest `flatbread` CLI, adds that exact version as +a devDependency, and copies the Proof skill that shipped with it into your +agent skill directories. You do not substitute a version or git tag. -```bash -pnpm dlx skills add https://github.com/FlatbreadLabs/flatbread/tree//packages/proof/skills/proof --skill proof -pnpm add -D flatbread@ +`@latest` only chooses which CLI to run. The installer then pins that CLI's +exact version in the project — it does not write a floating `latest` range. +To pin a specific release, replace `@latest` with that version. -yarn dlx skills add https://github.com/FlatbreadLabs/flatbread/tree//packages/proof/skills/proof --skill proof -yarn add -D flatbread@ - -bunx skills add https://github.com/FlatbreadLabs/flatbread/tree//packages/proof/skills/proof --skill proof -bun add -d flatbread@ -``` +The installer detects your package manager from `package.json`'s +`packageManager` field, then from lockfiles. If several lockfiles conflict +and `packageManager` is unset, it stops rather than guessing. -Do not use a floating branch, `latest`, or a guessed version. When dogfooding -the Flatbread monorepo, use its workspace `flatbread` binary and do not install -Flatbread from npm. +`release.json` next to this file is lockstep identity for the packaged +skill. `skills-lock.json` is installation provenance only; do not treat its +optional ref or version fields as release identity. ## 2. Review the configuration diff --git a/.flatbread-proof/citations/cit-pr-267-review-maintainer-asides-in-shipped-proof--2ftvd4ph0bh0erbr.md b/.flatbread-proof/citations/cit-pr-267-review-maintainer-asides-in-shipped-proof--2ftvd4ph0bh0erbr.md new file mode 100644 index 00000000..c21f4833 --- /dev/null +++ b/.flatbread-proof/citations/cit-pr-267-review-maintainer-asides-in-shipped-proof--2ftvd4ph0bh0erbr.md @@ -0,0 +1,9 @@ +--- +id: cit-pr-267-review-maintainer-asides-in-shipped-proof--2ftvd4ph0bh0erbr +effort: eff-effort-graph-memory-and-agent-wedge--szeqvmgqjqnhd002 +title: 'PR 267 review: maintainer asides in shipped Proof setup.md' +role: evidence +created_at: '2026-08-24T03:18:39.424Z' +--- + +https://github.com/FlatbreadLabs/flatbread/pull/267#discussion_r3840101016 diff --git a/.flatbread-proof/constraints/con-packaged-proof-skill-markdown-must-not-contain-m--a9gse08fkv7zcrxq.md b/.flatbread-proof/constraints/con-packaged-proof-skill-markdown-must-not-contain-m--a9gse08fkv7zcrxq.md new file mode 100644 index 00000000..238fa996 --- /dev/null +++ b/.flatbread-proof/constraints/con-packaged-proof-skill-markdown-must-not-contain-m--a9gse08fkv7zcrxq.md @@ -0,0 +1,11 @@ +--- +id: con-packaged-proof-skill-markdown-must-not-contain-m--a9gse08fkv7zcrxq +effort: eff-effort-graph-memory-and-agent-wedge--szeqvmgqjqnhd002 +title: Packaged Proof skill markdown must not contain maintainer-only asides +kind: hard +created_at: '2026-08-24T03:18:48.880Z' +cites: + - cit-pr-267-review-maintainer-asides-in-shipped-proof--2ftvd4ph0bh0erbr +--- + +Canonical skill files under packages/proof/skills are the bytes cloud and fresh agents Read. Those agents Read SKILL.md and setup.md before any pack, sync, or install script runs, so git bytes are shipped bytes. Maintainer-only asides (workspace dogfood, generated-projection instructions, pnpm skills:sync) must not appear in that markdown. skills:pack-check is the gate. diff --git a/.flatbread-proof/decisions/dec-published-proof-skill-bytes-are-consumer-only--9frae0w0nq6wgqm3.md b/.flatbread-proof/decisions/dec-published-proof-skill-bytes-are-consumer-only--9frae0w0nq6wgqm3.md new file mode 100644 index 00000000..40350aed --- /dev/null +++ b/.flatbread-proof/decisions/dec-published-proof-skill-bytes-are-consumer-only--9frae0w0nq6wgqm3.md @@ -0,0 +1,18 @@ +--- +id: dec-published-proof-skill-bytes-are-consumer-only--9frae0w0nq6wgqm3 +effort: eff-effort-graph-memory-and-agent-wedge--szeqvmgqjqnhd002 +title: Published Proof skill bytes are consumer-only +state: accepted +created_at: '2026-08-24T03:19:00.581Z' +derives_from: + - con-packaged-proof-skill-markdown-must-not-contain-m--a9gse08fkv7zcrxq + - dec-distribute-the-effort-graph-as-a-versioned-agent--8as4sybr34zcqyqh +cites: + - cit-pr-267-review-maintainer-asides-in-shipped-proof--2ftvd4ph0bh0erbr +--- + +The distribution Decision still holds: one skill payload, byte-identical local projection. This narrows what that payload may say. It does not supersede it. + +Cloud and fresh agents Read SKILL.md then setup.md before any pack, sync, or install script. In-skill deixis such as "this monorepo" and hard prohibitions such as "do not install from npm" bind to the consumer workspace, including other pnpm apps. Maintainer asides therefore leave setup.md. Maintainer workflow lives in AGENTS.md, CONTRIBUTING.md, and the install-skill skipped_workspace path. skills:pack-check is the gate. + +Ranked options and the load-order constraint are in the Cursor canvas proof-skill-audience-split. diff --git a/AGENTS.md b/AGENTS.md index 68769784..f7088d75 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,6 +73,7 @@ Quality/simplify stays on Composer. See - **Native build scripts are approved in `pnpm-workspace.yaml`.** The `onlyBuiltDependencies` list allows esbuild, sharp, @swc/core, etc. to run their postinstall scripts automatically during `pnpm install`. - **Vitest packages run in watch mode by default.** Always use `vitest run` (not bare `vitest`) to get a single run and exit. - **`flatbread` CLI is not on PATH globally.** From `examples/nextjs`, prefer `pnpm exec flatbread …` (local binary), or `npx flatbread` from a shell. The `pnpm play` script from the root handles this automatically. +- **Proof skill in this monorepo.** Use the workspace `flatbread` binary. After editing `packages/proof/skills/proof`, run `pnpm skills:sync`. Do not run `proof install-skill` here; the CLI skips it. - **Build before test.** All packages must be built (`pnpm build`) before running tests or starting dev servers. `pnpm test` handles this automatically. - **`-H, --https` does not make Flatbread serve HTTPS.** The server listens over plain HTTP whatever you pass. From `examples/nextjs`, run `pnpm exec flatbread start -- next dev --turbopack`. - **Full local CI parity check:** `pnpm verify` runs lint, typecheck, build, and all tests. diff --git a/CHANGELOG.md b/CHANGELOG.md index f4fedd91..cd7956f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,11 @@ ## Unreleased -Notes for the Flatbread release train. Some packages also keep their own -changelog; this file covers the repository as a whole. +- `flatbread proof install-skill` installs the Proof skill that shipped with + the running CLI and pins that same `flatbread` version as a devDependency. + End-user docs now tell people to run + `npx --yes flatbread@latest proof install-skill`, so README copy-paste + blocks no longer need a version or git tag on every release. ## 1.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 70cd0aee..cc352db7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -234,17 +234,20 @@ Details: Protect release tags in the repository settings so they cannot be moved or deleted after publication. -End users install the skill from that release tag and install the matching -`flatbread` version. Replace `X` with the released version — `1.0.0` for the -first stable release, so the tag is `v1.0.0`: +End users do not copy a version or git tag. After a release is on npm they +run: ```bash -npx skills add https://github.com/FlatbreadLabs/flatbread/tree/vX/packages/proof/skills/proof --skill proof -npm install --save-dev flatbread@X +npx --yes flatbread@latest proof install-skill ``` -`skills update` does not advance an immutable tag. To upgrade deliberately, -install a newer release tag and its matching `flatbread` version. +That command downloads the published CLI, pins that exact `flatbread` +version as a devDependency, and copies the Proof skill that shipped inside +the package. `@latest` only chooses which CLI to run. To pin an older +release, replace `@latest` with that version. + +`skills update` does not advance an immutable install. To upgrade, run the +installer again from a newer CLI. ## Troubleshooting diff --git a/packages/flatbread/README.md b/packages/flatbread/README.md index 912a6a85..34accee6 100644 --- a/packages/flatbread/README.md +++ b/packages/flatbread/README.md @@ -47,17 +47,18 @@ repository, so the next session and your coworkers read the same reasons, review them in a pull request, and trace how a choice changed. Nothing lives in a private chat log or a hosted store. -The install pins below come from the current -[Proof release manifest](https://github.com/FlatbreadLabs/flatbread/blob/main/packages/proof/skills/proof/release.json). Use the values in that file exactly; do not substitute a -floating branch or guessed version. - 1. Install the Proof skill and the matching `flatbread` package: ```bash - npx skills add https://github.com/FlatbreadLabs/flatbread/tree/v1.1.0/packages/proof/skills/proof --skill proof - npm install --save-dev flatbread@1.1.0 + npx --yes flatbread@latest proof install-skill ``` + That command downloads the latest `flatbread` CLI, pins that exact version + as a devDependency, and copies the Proof skill that shipped with it. You + do not copy a version number or git tag. `@latest` only chooses which CLI + to run; the project then receives that CLI's exact version. To pin an + older release, replace `@latest` with that version. + 2. Add the Proof content model to `flatbread.config.js`, keeping any content entries you already have: diff --git a/packages/flatbread/bin/flatbread.js b/packages/flatbread/bin/flatbread.js index 4caa272d..f15cbe41 100755 --- a/packages/flatbread/bin/flatbread.js +++ b/packages/flatbread/bin/flatbread.js @@ -1,18 +1,18 @@ #!/usr/bin/env node -import { resolve } from 'path'; +import { dirname, resolve } from 'path'; import { existsSync } from 'fs'; +import { fileURLToPath } from 'url'; -// In CI/CD, check if the file exists before importing it. This is to prevent some environments from throwing an error before the library is built. -if (process.env.FLATBREAD_CI) { - const cliPath = resolve( - process.cwd(), - 'node_modules', - 'flatbread', - 'dist', - 'cli', - 'index.js' - ); +const cliPath = resolve( + dirname(fileURLToPath(import.meta.url)), + '../dist/cli/index.js' +); +// In CI, skip the import when dist is not built yet so the process +// does not throw before `pnpm build`. Check the file next to this +// bin, not `cwd/node_modules/flatbread`, because spawned tests and +// user projects run with a different working directory. +if (process.env.FLATBREAD_CI) { if (existsSync(cliPath)) { import('../dist/cli/index.js'); } else { diff --git a/packages/flatbread/src/cli/installSkill.test.ts b/packages/flatbread/src/cli/installSkill.test.ts new file mode 100644 index 00000000..7a22636e --- /dev/null +++ b/packages/flatbread/src/cli/installSkill.test.ts @@ -0,0 +1,387 @@ +import test from 'ava'; +import { spawn } from 'node:child_process'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + CLI_VERSION, + detectProjectPackageManager, + handleEffortInstallSkill, + planProofSkillInstall, + quoteWindowsCmdArg, + resolveExecFileInvocation, + resolveProofSkillRoot, +} from './installSkill.js'; + +type TeardownContext = { + teardown(callback: () => void | Promise): void; +}; + +const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)); + +async function tempDir(prefix: string, t: TeardownContext): Promise { + const cwd = await mkdtemp(join(tmpdir(), prefix)); + t.teardown(async () => { + await rm(cwd, { recursive: true, force: true }); + }); + return cwd; +} + +async function writeJson(path: string, value: unknown): Promise { + await writeFile(path, `${JSON.stringify(value, null, 2)}\n`); +} + +async function fakeSkillRoot( + cwd: string, + pinnedVersion = CLI_VERSION +): Promise { + const skillRoot = join(cwd, 'skill'); + await mkdir(skillRoot, { recursive: true }); + await writeFile(join(skillRoot, 'SKILL.md'), '# Proof\n'); + await writeJson(join(skillRoot, 'release.json'), { + format: 1, + flatbreadVersion: pinnedVersion, + proofVersion: pinnedVersion, + gitTag: `v${pinnedVersion}`, + }); + return skillRoot; +} + +test('resolveProofSkillRoot finds the packaged SKILL.md', (t) => { + t.true(resolveProofSkillRoot().replace(/\\/g, '/').endsWith('skills/proof')); +}); + +test('planProofSkillInstall pins an exact npm version and copies the skill', (t) => { + const plan = planProofSkillInstall('npm', '1.2.3', '/tmp/proof-skill'); + t.deepEqual(plan.addPackage, { + command: 'npm', + args: ['install', '--save-dev', '--save-exact', 'flatbread@1.2.3'], + }); + t.deepEqual(plan.addSkill, { + command: 'npx', + args: [ + '--yes', + 'skills', + 'add', + '/tmp/proof-skill', + '--skill', + 'proof', + '--copy', + '-y', + ], + }); +}); + +test('planProofSkillInstall uses pnpm add and pnpm dlx', (t) => { + const plan = planProofSkillInstall('pnpm', '1.2.3', '/tmp/proof-skill'); + t.deepEqual(plan.addPackage.args, [ + 'add', + '-D', + '--save-exact', + 'flatbread@1.2.3', + ]); + t.is(plan.addSkill.command, 'pnpm'); + t.deepEqual(plan.addSkill.args.slice(0, 2), ['dlx', 'skills']); +}); + +test('planProofSkillInstall uses yarn add and npx, not yarn dlx', (t) => { + const plan = planProofSkillInstall('yarn', '1.2.3', '/tmp/proof-skill'); + t.deepEqual(plan.addPackage, { + command: 'yarn', + args: ['add', '--dev', '--exact', 'flatbread@1.2.3'], + }); + t.deepEqual(plan.addSkill, { + command: 'npx', + args: [ + '--yes', + 'skills', + 'add', + '/tmp/proof-skill', + '--skill', + 'proof', + '--copy', + '-y', + ], + }); + t.false(plan.addSkill.args.includes('dlx')); +}); + +test('detectProjectPackageManager prefers packageManager over lockfiles', async (t) => { + const cwd = await tempDir('flatbread-install-pm-', t); + await writeJson(join(cwd, 'package.json'), { + name: 'app', + packageManager: 'pnpm@10.0.0', + }); + await writeFile(join(cwd, 'package-lock.json'), '{}\n'); + t.is(await detectProjectPackageManager(cwd), 'pnpm'); +}); + +test('detectProjectPackageManager reads a single lockfile', async (t) => { + const cwd = await tempDir('flatbread-install-lock-', t); + await writeJson(join(cwd, 'package.json'), { name: 'app' }); + await writeFile(join(cwd, 'yarn.lock'), '\n'); + t.is(await detectProjectPackageManager(cwd), 'yarn'); +}); + +test('detectProjectPackageManager rejects conflicting lockfiles', async (t) => { + const cwd = await tempDir('flatbread-install-conflict-', t); + await writeJson(join(cwd, 'package.json'), { name: 'app' }); + await writeFile(join(cwd, 'yarn.lock'), '\n'); + await writeFile(join(cwd, 'pnpm-lock.yaml'), '\n'); + const error = await t.throwsAsync(() => + detectProjectPackageManager(cwd) + ); + if (!error) { + t.fail('expected an error'); + return; + } + t.is( + JSON.parse(error.message).error.code, + 'PROOF_INSTALL_SKILL_AMBIGUOUS_PACKAGE_MANAGER' + ); +}); + +test('install-skill dry-run plans npm install without running commands', async (t) => { + const cwd = await tempDir('flatbread-install-dry-', t); + await writeJson(join(cwd, 'package.json'), { name: 'app', version: '0.0.0' }); + await writeFile(join(cwd, 'package-lock.json'), '{}\n'); + const skillRoot = await fakeSkillRoot(cwd); + const ran: string[] = []; + const report = await handleEffortInstallSkill({ + cwd, + dryRun: true, + skillRoot, + run: async (command) => { + ran.push(command); + return { stdout: '', stderr: '' }; + }, + }); + t.is(report.status, 'dry_run'); + t.is(report.package_manager, 'npm'); + t.is(report.package, 'planned'); + t.is(report.skill, 'planned'); + t.is(report.flatbread_version, CLI_VERSION); + t.is(report.git_tag, `v${CLI_VERSION}`); + t.deepEqual( + report.commands?.map((step) => step.command), + ['npm', 'npx'] + ); + t.deepEqual(ran, []); +}); + +test('install-skill runs the planned commands in order', async (t) => { + const cwd = await tempDir('flatbread-install-run-', t); + await writeJson(join(cwd, 'package.json'), { name: 'app', version: '0.0.0' }); + const skillRoot = await fakeSkillRoot(cwd); + const ran: { command: string; args: readonly string[] }[] = []; + const report = await handleEffortInstallSkill({ + cwd, + skillRoot, + run: async (command, args) => { + ran.push({ command, args }); + return { stdout: '', stderr: '' }; + }, + }); + t.is(report.status, 'installed'); + t.is(report.package, 'added'); + t.is(report.skill, 'installed'); + t.is(ran.length, 2); + t.true(ran[0].args.includes(`flatbread@${CLI_VERSION}`)); + t.true(ran[1].args.includes(skillRoot)); + t.true(ran[1].args.includes('--copy')); +}); + +test('install-skill skips adding the package when it is already exact', async (t) => { + const cwd = await tempDir('flatbread-install-skip-', t); + await writeJson(join(cwd, 'package.json'), { + name: 'app', + devDependencies: { flatbread: CLI_VERSION }, + }); + const skillRoot = await fakeSkillRoot(cwd); + const ran: string[] = []; + const report = await handleEffortInstallSkill({ + cwd, + skillRoot, + run: async (command) => { + ran.push(command); + return { stdout: '', stderr: '' }; + }, + }); + t.is(report.package, 'skipped'); + t.is(ran.length, 1); + t.is(ran[0], 'npx'); +}); + +test('install-skill skips the Flatbread workspace', async (t) => { + const cwd = await tempDir('flatbread-install-workspace-', t); + await writeJson(join(cwd, 'package.json'), { name: '@flatbread/monorepo' }); + const skillRoot = await fakeSkillRoot(cwd); + const ran: string[] = []; + const report = await handleEffortInstallSkill({ + cwd, + skillRoot, + run: async (command) => { + ran.push(command); + return { stdout: '', stderr: '' }; + }, + }); + t.is(report.status, 'skipped_workspace'); + t.deepEqual(ran, []); +}); + +test('install-skill rejects a missing package.json', async (t) => { + const cwd = await tempDir('flatbread-install-missing-', t); + const skillRoot = await fakeSkillRoot(cwd); + const error = await t.throwsAsync(() => + handleEffortInstallSkill({ cwd, skillRoot, dryRun: true }) + ); + if (!error) { + t.fail('expected an error'); + return; + } + t.is(JSON.parse(error.message).error.code, 'PROOF_INSTALL_SKILL_NO_PACKAGE'); +}); + +test('install-skill rejects a mismatched packaged release.json', async (t) => { + const cwd = await tempDir('flatbread-install-mismatch-', t); + await writeJson(join(cwd, 'package.json'), { name: 'app' }); + const skillRoot = await fakeSkillRoot(cwd, '0.0.1'); + const error = await t.throwsAsync(() => + handleEffortInstallSkill({ cwd, skillRoot, dryRun: true }) + ); + if (!error) { + t.fail('expected an error'); + return; + } + t.is( + JSON.parse(error.message).error.code, + 'PROOF_INSTALL_SKILL_RELEASE_MISMATCH' + ); +}); + +test('install-skill wraps a failed child command as JSON', async (t) => { + const cwd = await tempDir('flatbread-install-fail-', t); + await writeJson(join(cwd, 'package.json'), { name: 'app' }); + const skillRoot = await fakeSkillRoot(cwd); + const error = await t.throwsAsync(() => + handleEffortInstallSkill({ + cwd, + skillRoot, + run: async () => { + const failure = new Error('boom') as Error & { stderr: string }; + failure.stderr = 'skills: not found\n'; + throw failure; + }, + }) + ); + const payload = JSON.parse(error?.message ?? '{}'); + t.is(payload.error.code, 'PROOF_INSTALL_SKILL_COMMAND_FAILED'); + t.true(payload.error.message.includes('skills: not found')); +}); + +test('end-user install docs are copy-pasteable without version placeholders', async (t) => { + const files = [ + 'README.md', + 'packages/flatbread/README.md', + 'packages/proof/README.md', + 'packages/proof/skills/proof/setup.md', + ]; + const command = 'npx --yes flatbread@latest proof install-skill'; + for (const file of files) { + const text = await readFile(join(repoRoot, file), 'utf8'); + t.true(text.includes(command), `${file} must include ${command}`); + t.false(text.includes(''), `${file} still has `); + t.false( + text.includes(''), + `${file} still has ` + ); + t.false( + /tree\/v\d+\.\d+\.\d+/.test(text), + `${file} still pins a GitHub tree version` + ); + } +}); + +test('Windows cmd quoting wraps spaces and doubles inner quotes', (t) => { + t.is(quoteWindowsCmdArg('npm'), 'npm'); + t.is(quoteWindowsCmdArg(''), '""'); + t.is( + quoteWindowsCmdArg('C:\\Users\\Jane Doe\\skill'), + '"C:\\Users\\Jane Doe\\skill"' + ); + t.is(quoteWindowsCmdArg('foo"bar'), '"foo""bar"'); +}); + +test('Windows invocations run through cmd.exe so .cmd shims can spawn', (t) => { + const unix = resolveExecFileInvocation('linux', 'npm', [ + 'install', + '--save-dev', + 'flatbread@1.1.0', + ]); + t.deepEqual(unix, { + file: 'npm', + args: ['install', '--save-dev', 'flatbread@1.1.0'], + options: {}, + }); + + const windows = resolveExecFileInvocation('win32', 'npx', [ + '--yes', + 'skills', + 'add', + 'C:\\Users\\Jane Doe\\proof', + ]); + t.is(windows.file, process.env.ComSpec || 'cmd.exe'); + t.deepEqual(windows.args.slice(0, 3), ['/d', '/s', '/c']); + t.is(windows.args[3], 'npx --yes skills add "C:\\Users\\Jane Doe\\proof"'); + t.deepEqual(windows.options, { + windowsHide: true, + windowsVerbatimArguments: true, + }); +}); + +test.serial('spawned CLI dry-run prints JSON without traces', async (t) => { + const cwd = await tempDir('flatbread-install-cli-', t); + await writeJson(join(cwd, 'package.json'), { + name: 'app', + version: '0.0.0', + }); + await writeFile(join(cwd, 'package-lock.json'), '{}\n'); + const result = await new Promise<{ + code: number | null; + stdout: string; + stderr: string; + }>((resolve, reject) => { + const child = spawn( + process.execPath, + [ + '--no-deprecation', + fileURLToPath(new URL('../../bin/flatbread.js', import.meta.url)), + 'proof', + 'install-skill', + '--dry-run', + ], + { + cwd, + // Pipeline CI sets this. The bin must still load dist relative to + // itself, not `cwd/node_modules/flatbread`. + env: { ...process.env, FLATBREAD_CI: 'true' }, + } + ); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => (stdout += chunk)); + child.stderr.on('data', (chunk) => (stderr += chunk)); + child.on('error', reject); + child.on('close', (code) => resolve({ code, stdout, stderr })); + }); + t.is(result.code, 0, result.stderr || result.stdout); + t.true( + result.stdout.trim().startsWith('{'), + `expected JSON on stdout, got ${JSON.stringify(result.stdout)}` + ); + const payload = JSON.parse(result.stdout); + t.is(payload.status, 'dry_run'); + t.is(payload.package_manager, 'npm'); + t.is(result.stderr, ''); +}); diff --git a/packages/flatbread/src/cli/installSkill.ts b/packages/flatbread/src/cli/installSkill.ts new file mode 100644 index 00000000..bb691394 --- /dev/null +++ b/packages/flatbread/src/cli/installSkill.ts @@ -0,0 +1,464 @@ +import { execFile } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { access, readFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); +const requireJson = createRequire(import.meta.url); +export const CLI_VERSION = ( + requireJson('../../package.json') as { version: string } +).version; +const WORKSPACE_NAME = '@flatbread/monorepo'; +const SKILL_MARK = 'SKILL.md'; + +export type PackageManagerName = 'npm' | 'pnpm' | 'yarn' | 'bun'; + +export type CommandRunner = ( + command: string, + args: readonly string[], + options: { cwd: string } +) => Promise<{ stdout: string; stderr: string }>; + +export interface InstallSkillOptions { + cwd?: string; + dryRun?: boolean; + skipPackage?: boolean; + version?: string; + skillRoot?: string; + run?: CommandRunner; +} + +export interface PlannedCommand { + command: string; + args: string[]; +} + +export interface InstallSkillReport { + status: 'installed' | 'dry_run' | 'skipped_workspace'; + flatbread_version: string; + git_tag: string; + package_manager?: PackageManagerName; + package?: 'added' | 'skipped' | 'planned'; + skill?: 'installed' | 'planned' | 'skipped'; + skill_source?: string; + commands?: PlannedCommand[]; + message?: string; +} + +interface ProjectPackage { + name?: string; + packageManager?: string; + dependencies?: Record; + devDependencies?: Record; +} + +/** + * Locate the Proof skill directory shipped with `@flatbread/proof`. + * + * Walks up from the resolved package entry so the same lookup works in the + * monorepo and in a published `node_modules` layout. + */ +export function resolveProofSkillRoot(): string { + const require = createRequire(import.meta.url); + const starts: string[] = []; + try { + starts.push(dirname(require.resolve('@flatbread/proof'))); + } catch { + // The package entry may be missing when workspace dist is not built. + } + starts.push(join(dirname(fileURLToPath(import.meta.url)), '../../../proof')); + for (const start of starts) { + let dir = start; + for (let i = 0; i < 8; i += 1) { + const skillMd = join(dir, 'skills', 'proof', SKILL_MARK); + if (existsSync(skillMd)) return join(dir, 'skills', 'proof'); + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + } + throw jsonError( + 'PROOF_INSTALL_SKILL_MISSING', + 'The packaged Proof skill was not found next to @flatbread/proof.' + ); +} + +/** + * Detect the package manager that owns `cwd`. + * + * Prefer `package.json`'s `packageManager` field. If that is absent, use a + * single lockfile. Conflicting lockfiles without `packageManager` are an + * error so the installer does not guess. + */ +export async function detectProjectPackageManager( + cwd: string +): Promise { + const pkg = await readProjectPackage(cwd); + const fromField = managerFromField(pkg.packageManager); + if (fromField) return fromField; + + const found: PackageManagerName[] = []; + const lockfiles: readonly [string, PackageManagerName][] = [ + ['pnpm-lock.yaml', 'pnpm'], + ['yarn.lock', 'yarn'], + ['bun.lock', 'bun'], + ['bun.lockb', 'bun'], + ['package-lock.json', 'npm'], + ]; + for (const [file, name] of lockfiles) { + try { + await access(join(cwd, file)); + if (!found.includes(name)) found.push(name); + } catch { + // Lockfile absent. + } + } + if (found.length === 1) return found[0]; + if (found.length > 1) { + throw jsonError( + 'PROOF_INSTALL_SKILL_AMBIGUOUS_PACKAGE_MANAGER', + 'Multiple lockfiles exist. Set package.json "packageManager" to npm, pnpm, yarn, or bun, then rerun.' + ); + } + return 'npm'; +} + +/** + * Build the argv that install the pinned `flatbread` version and copy the + * packaged Proof skill into agent skill directories. + */ +export function planProofSkillInstall( + manager: PackageManagerName, + pinnedVersion: string, + skillRoot: string +): { addPackage: PlannedCommand; addSkill: PlannedCommand } { + const spec = `flatbread@${pinnedVersion}`; + const skillArgs = [ + 'add', + skillRoot, + '--skill', + 'proof', + '--copy', + '-y', + ] as const; + switch (manager) { + case 'npm': + return { + addPackage: { + command: 'npm', + args: ['install', '--save-dev', '--save-exact', spec], + }, + addSkill: { + command: 'npx', + args: ['--yes', 'skills', ...skillArgs], + }, + }; + case 'pnpm': + return { + addPackage: { + command: 'pnpm', + args: ['add', '-D', '--save-exact', spec], + }, + addSkill: { + command: 'pnpm', + args: ['dlx', 'skills', ...skillArgs], + }, + }; + case 'yarn': + // Yarn Classic (v1) has no `dlx`. Berry does, but npx is present + // wherever this CLI runs. Keep `yarn add` for the package pin. + return { + addPackage: { + command: 'yarn', + args: ['add', '--dev', '--exact', spec], + }, + addSkill: { + command: 'npx', + args: ['--yes', 'skills', ...skillArgs], + }, + }; + case 'bun': + return { + addPackage: { + command: 'bun', + args: ['add', '-d', '--exact', spec], + }, + addSkill: { + command: 'bunx', + args: ['skills', ...skillArgs], + }, + }; + default: { + const exhaustive: never = manager; + throw jsonError( + 'PROOF_INSTALL_SKILL_UNKNOWN_PACKAGE_MANAGER', + `Unsupported package manager: ${String(exhaustive)}` + ); + } + } +} + +/** + * Install the Proof skill that shipped with this CLI and pin the same + * `flatbread` version as a devDependency. + * + * `npx --yes flatbread@latest proof install-skill` uses `@latest` only to + * download this command. The project then receives the exact version that + * command is running, not a floating range. + */ +export async function handleEffortInstallSkill( + options: InstallSkillOptions = {} +): Promise { + const cwd = options.cwd ?? process.cwd(); + const pinnedVersion = options.version ?? CLI_VERSION; + const gitTag = `v${pinnedVersion}`; + const skillRoot = options.skillRoot ?? resolveProofSkillRoot(); + await assertSkillRoot(skillRoot); + await assertReleaseIdentity(skillRoot, pinnedVersion); + + const pkg = await readProjectPackage(cwd); + if (pkg.name === WORKSPACE_NAME) { + return { + status: 'skipped_workspace', + flatbread_version: pinnedVersion, + git_tag: gitTag, + package: 'skipped', + skill: 'skipped', + skill_source: skillRoot, + message: + 'This repository already ships the Proof skill. After editing packages/proof/skills/proof, run pnpm skills:sync.', + }; + } + + const manager = await detectProjectPackageManager(cwd); + const plan = planProofSkillInstall(manager, pinnedVersion, skillRoot); + const skipPackage = + options.skipPackage === true || + isWorkspaceSpec(declaredFlatbread(pkg)) || + hasExactFlatbread(pkg, pinnedVersion); + const commands: PlannedCommand[] = []; + if (!skipPackage) commands.push(plan.addPackage); + commands.push(plan.addSkill); + + if (options.dryRun) { + return { + status: 'dry_run', + flatbread_version: pinnedVersion, + git_tag: gitTag, + package_manager: manager, + package: skipPackage ? 'skipped' : 'planned', + skill: 'planned', + skill_source: skillRoot, + commands, + }; + } + + const run = options.run ?? defaultRun; + for (const step of commands) { + try { + await run(step.command, step.args, { cwd }); + } catch (error) { + throw commandFailed(step, error); + } + } + + return { + status: 'installed', + flatbread_version: pinnedVersion, + git_tag: gitTag, + package_manager: manager, + package: skipPackage ? 'skipped' : 'added', + skill: 'installed', + skill_source: skillRoot, + commands, + }; +} + +async function defaultRun( + command: string, + args: readonly string[], + options: { cwd: string } +): Promise<{ stdout: string; stderr: string }> { + const invocation = resolveExecFileInvocation(process.platform, command, args); + const result = await execFileAsync(invocation.file, invocation.args, { + cwd: options.cwd, + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, + ...invocation.options, + }); + return { stdout: result.stdout, stderr: result.stderr }; +} + +/** + * Build the `execFile` invocation for a package-manager command. + * + * Node 20.12+ refuses to spawn `.cmd` / `.bat` shims unless `cmd.exe` + * runs them. npm, pnpm, Yarn, and Bun all install as `.cmd` on Windows. + * `bun` is also `bun.exe`. Going through `ComSpec` lets PATHEXT resolve + * both. Unix keeps a direct `execFile`. + */ +export function resolveExecFileInvocation( + platform: NodeJS.Platform, + command: string, + args: readonly string[] +): { + file: string; + args: string[]; + options: { + windowsHide?: boolean; + windowsVerbatimArguments?: boolean; + }; +} { + if (platform !== 'win32') { + return { file: command, args: [...args], options: {} }; + } + const line = [command, ...args].map(quoteWindowsCmdArg).join(' '); + return { + file: process.env.ComSpec || 'cmd.exe', + args: ['/d', '/s', '/c', line], + options: { windowsHide: true, windowsVerbatimArguments: true }, + }; +} + +export function quoteWindowsCmdArg(value: string): string { + if (value.length === 0) return '""'; + if (!/[\s"&<>()^|%!]/.test(value)) return value; + return `"${value.replace(/"/g, '""')}"`; +} + +async function readProjectPackage(cwd: string): Promise { + const path = join(cwd, 'package.json'); + let text: string; + try { + text = await readFile(path, 'utf8'); + } catch { + throw jsonError( + 'PROOF_INSTALL_SKILL_NO_PACKAGE', + 'No package.json in the working directory. Run this command from your project root.' + ); + } + try { + return JSON.parse(text) as ProjectPackage; + } catch { + throw jsonError( + 'PROOF_INSTALL_SKILL_INVALID_PACKAGE', + 'package.json is not valid JSON.' + ); + } +} + +function managerFromField( + value: string | undefined +): PackageManagerName | undefined { + if (!value) return undefined; + const name = value.split('@')[0]; + if (name === 'npm' || name === 'pnpm' || name === 'yarn' || name === 'bun') { + return name; + } + throw jsonError( + 'PROOF_INSTALL_SKILL_UNKNOWN_PACKAGE_MANAGER', + `package.json "packageManager" must start with npm, pnpm, yarn, or bun. Found "${value}".` + ); +} + +function declaredFlatbread(pkg: ProjectPackage): string | undefined { + return pkg.devDependencies?.flatbread ?? pkg.dependencies?.flatbread; +} + +function isWorkspaceSpec(spec: string | undefined): boolean { + if (!spec) return false; + return ( + spec.startsWith('workspace:') || + spec.startsWith('link:') || + spec.startsWith('file:') + ); +} + +function hasExactFlatbread( + pkg: ProjectPackage, + pinnedVersion: string +): boolean { + const spec = declaredFlatbread(pkg); + return spec === pinnedVersion || spec === `flatbread@${pinnedVersion}`; +} + +async function assertSkillRoot(skillRoot: string): Promise { + try { + await access(join(skillRoot, SKILL_MARK)); + } catch { + throw jsonError( + 'PROOF_INSTALL_SKILL_MISSING', + `No ${SKILL_MARK} in ${skillRoot}.` + ); + } +} + +async function assertReleaseIdentity( + skillRoot: string, + pinnedVersion: string +): Promise { + const path = join(skillRoot, 'release.json'); + let text: string; + try { + text = await readFile(path, 'utf8'); + } catch { + throw jsonError( + 'PROOF_INSTALL_SKILL_RELEASE_MISSING', + `The packaged skill at ${skillRoot} has no release.json.` + ); + } + let release: { + format?: unknown; + flatbreadVersion?: unknown; + gitTag?: unknown; + }; + try { + release = JSON.parse(text) as { + format?: unknown; + flatbreadVersion?: unknown; + gitTag?: unknown; + }; + } catch { + throw jsonError( + 'PROOF_INSTALL_SKILL_RELEASE_INVALID', + 'The packaged skill release.json is not valid JSON.' + ); + } + const expectedTag = `v${pinnedVersion}`; + if ( + release.format !== 1 || + release.flatbreadVersion !== pinnedVersion || + release.gitTag !== expectedTag + ) { + throw jsonError( + 'PROOF_INSTALL_SKILL_RELEASE_MISMATCH', + `The packaged skill release.json must match this CLI (${pinnedVersion}, ${expectedTag}).` + ); + } +} + +function commandFailed(step: PlannedCommand, error: unknown): Error { + const detail = errorDetail(error); + return jsonError( + 'PROOF_INSTALL_SKILL_COMMAND_FAILED', + `Command failed: ${step.command} ${step.args.join(' ')}${ + detail ? `: ${detail}` : '' + }` + ); +} + +function errorDetail(error: unknown): string { + if (error && typeof error === 'object' && 'stderr' in error) { + const stderr = (error as { stderr?: unknown }).stderr; + if (typeof stderr === 'string' && stderr.trim()) return stderr.trim(); + } + if (error instanceof Error && error.message) return error.message; + return ''; +} + +function jsonError(code: string, message: string): Error { + return new Error(JSON.stringify({ error: { code, message } })); +} diff --git a/packages/flatbread/src/cli/proof.ts b/packages/flatbread/src/cli/proof.ts index 0a443146..4de8dd36 100644 --- a/packages/flatbread/src/cli/proof.ts +++ b/packages/flatbread/src/cli/proof.ts @@ -20,6 +20,7 @@ import { listEfforts, } from '../proof/read.js'; import type { PrimitiveKind, ReadRelation } from '@flatbread/proof'; +import { handleEffortInstallSkill } from './installSkill.js'; export interface EffortCliOptions { cwd?: string; @@ -494,6 +495,22 @@ export function registerProofCommands(prog: any): void { .action(async (options: Record) => printResult(handleEffortBootstrap(mapEffortCliOptions(options))) ); + prog + .command( + 'proof install-skill', + 'Install the Proof skill and matching flatbread package' + ) + .option('--dry-run', 'Print planned actions without writing', false) + .option('--skip-package', 'Install the skill only', false) + .action(async (options: Record) => + printResult( + handleEffortInstallSkill({ + dryRun: options['dry-run'] === true || options.dryRun === true, + skipPackage: + options['skip-package'] === true || options.skipPackage === true, + }) + ) + ); paginationOptions( consistencyOptions( prog.command( diff --git a/packages/proof/README.md b/packages/proof/README.md index 98352bd8..8c4d1e6f 100644 --- a/packages/proof/README.md +++ b/packages/proof/README.md @@ -50,20 +50,19 @@ contains your project's one `flatbread.config.*` file. ### 1. Install the skill and the matching runtime -The skill and the `flatbread` package must use the same release. Read -[`skills/proof/release.json`](./skills/proof/release.json) for the current -`gitTag` and `flatbreadVersion` and use those -values exactly: +The skill and the `flatbread` package must use the same release. One command +does both: it downloads the latest `flatbread` CLI, pins that exact version +as a devDependency, and copies the Proof skill that shipped with it. ```bash -npx skills add https://github.com/FlatbreadLabs/flatbread/tree/v1.1.0/packages/proof/skills/proof --skill proof -npm install --save-dev flatbread@1.1.0 +npx --yes flatbread@latest proof install-skill ``` -`npx skills add` runs the `skills` CLI, which copies the pinned skill folder -from that release tag into your project so agent tools can load it. The -[setup guide](./skills/proof/setup.md) gives the equivalent pnpm, Yarn, and -Bun commands. +`@latest` only chooses which CLI to run. The project then receives that +CLI's exact version, not a floating range. To pin an older release, replace +`@latest` with that version. The +[setup guide](./skills/proof/setup.md) explains how the installer detects +npm, pnpm, Yarn, or Bun. ### 2. Add the Proof content model diff --git a/packages/proof/scripts/pack-skills.mjs b/packages/proof/scripts/pack-skills.mjs index 2452d963..46cdc2b9 100644 --- a/packages/proof/scripts/pack-skills.mjs +++ b/packages/proof/scripts/pack-skills.mjs @@ -7,7 +7,20 @@ export const packageRoot = resolve( fileURLToPath(new URL('..', import.meta.url)) ); export const canonicalSkillRoot = resolve(packageRoot, 'skills'); -export const forbiddenInvocation = 'node packages/flatbread/bin/flatbread.js'; +export const forbiddenSkillSubstrings = [ + 'node packages/flatbread/bin/flatbread.js', + 'pnpm skills:sync', + 'dogfooding this monorepo', + 'exclusively generated projection', +]; + +function collapsed(text) { + return text.replace(/\s+/g, ' '); +} + +function skillTextContains(text, needle) { + return collapsed(text).includes(collapsed(needle)); +} export function verifyReleaseIdentity(canonicalTexts, packageVersions) { const entry = canonicalTexts.find( @@ -71,15 +84,32 @@ export function verifyPackPayload( ); } - const forbiddenFiles = canonicalTexts - .filter((entry) => entry.text.includes(forbiddenInvocation)) + const forbiddenHits = []; + for (const entry of canonicalTexts) { + for (const needle of forbiddenSkillSubstrings) { + if (skillTextContains(entry.text, needle)) { + forbiddenHits.push({ path: entry.path, needle }); + } + } + } + if (forbiddenHits.length > 0) { + throw new Error( + [ + 'Canonical skill text contains maintainer-only substrings:', + ...forbiddenHits.map((hit) => ` ${hit.path}: "${hit.needle}"`), + 'Keep packaged skill markdown consumer-only. Maintainer workflow lives in AGENTS.md, CONTRIBUTING.md, and the install-skill skip path.', + ].join('\n') + ); + } + const placeholderFiles = canonicalTexts + .filter((entry) => /|/.test(entry.text)) .map((entry) => entry.path); - if (forbiddenFiles.length > 0) { + if (placeholderFiles.length > 0) { throw new Error( [ - `Canonical skill text contains the monorepo-only invocation "${forbiddenInvocation}":`, - ...forbiddenFiles.map((file) => ` ${file}`), - 'Use the installed flatbread CLI instead.', + 'Canonical skill docs contain version placeholders that block copy-paste:', + ...placeholderFiles.map((file) => ` ${file}`), + 'Tell users to run `npx --yes flatbread@latest proof install-skill` instead.', ].join('\n') ); } diff --git a/packages/proof/skills/proof/setup.md b/packages/proof/skills/proof/setup.md index aad39548..31c3c2bb 100644 --- a/packages/proof/skills/proof/setup.md +++ b/packages/proof/skills/proof/setup.md @@ -1,43 +1,28 @@ # Proof setup -The canonical skill files live in this package. The repository -`.agents/skills/proof/` directory is an exclusively generated -projection: do not edit it directly, and stale projected files are deleted by -`pnpm skills:sync`. +## 1. Install the matching CLI and skill -## 1. Choose the package manager - -Use the nearest `package.json`'s `packageManager` field first. If it is absent, -inspect lockfiles. Exactly one of `package-lock.json`, `pnpm-lock.yaml`, -`yarn.lock`, or `bun.lock`/`bun.lockb` must exist. If multiple conflicting -lockfiles exist, ask the user which manager owns the project. - -For an end-user release, read `release.json` next to this file. It is the -canonical package and tag authority: use its `flatbreadVersion` and `gitTag` -values exactly. `skills-lock.json` is installation provenance/restore data only; -do not use its optional ref or version fields as release identity: +From the project root, with npm, pnpm, Yarn, or Bun: ```bash -npx skills add https://github.com/FlatbreadLabs/flatbread/tree//packages/proof/skills/proof --skill proof -npm install --save-dev flatbread@ +npx --yes flatbread@latest proof install-skill ``` -Equivalent commands are: +That command downloads the latest `flatbread` CLI, adds that exact version as +a devDependency, and copies the Proof skill that shipped with it into your +agent skill directories. You do not substitute a version or git tag. -```bash -pnpm dlx skills add https://github.com/FlatbreadLabs/flatbread/tree//packages/proof/skills/proof --skill proof -pnpm add -D flatbread@ +`@latest` only chooses which CLI to run. The installer then pins that CLI's +exact version in the project — it does not write a floating `latest` range. +To pin a specific release, replace `@latest` with that version. -yarn dlx skills add https://github.com/FlatbreadLabs/flatbread/tree//packages/proof/skills/proof --skill proof -yarn add -D flatbread@ - -bunx skills add https://github.com/FlatbreadLabs/flatbread/tree//packages/proof/skills/proof --skill proof -bun add -d flatbread@ -``` +The installer detects your package manager from `package.json`'s +`packageManager` field, then from lockfiles. If several lockfiles conflict +and `packageManager` is unset, it stops rather than guessing. -Do not use a floating branch, `latest`, or a guessed version. When dogfooding -the Flatbread monorepo, use its workspace `flatbread` binary and do not install -Flatbread from npm. +`release.json` next to this file is lockstep identity for the packaged +skill. `skills-lock.json` is installation provenance only; do not treat its +optional ref or version fields as release identity. ## 2. Review the configuration diff --git a/packages/proof/src/__tests__/pack-skills.test.js b/packages/proof/src/__tests__/pack-skills.test.js index 56f3b469..a7a5558d 100644 --- a/packages/proof/src/__tests__/pack-skills.test.js +++ b/packages/proof/src/__tests__/pack-skills.test.js @@ -1,5 +1,6 @@ import test from 'ava'; import { + forbiddenSkillSubstrings, verifyPackPayload, verifyReleaseIdentity, } from '../../scripts/pack-skills.mjs'; @@ -48,7 +49,28 @@ test('pack verification rejects missing canonical skill files', (t) => { t.true(error.message.includes(canonicalFiles[1])); }); -test('pack verification rejects monorepo-only CLI invocations', (t) => { +for (const needle of forbiddenSkillSubstrings) { + test(`pack verification rejects maintainer-only substring "${needle}"`, (t) => { + const error = t.throws(() => + verifyPackPayload( + [{ files: canonicalFiles.map((path) => ({ path })) }], + canonicalFiles, + [ + ...canonicalTexts, + { + path: canonicalFiles[1], + text: needle, + }, + ], + packageVersions + ) + ); + t.true(error.message.includes(canonicalFiles[1])); + t.true(error.message.includes(needle)); + }); +} + +test('pack verification rejects maintainer-only substrings that wrap across lines', (t) => { const error = t.throws(() => verifyPackPayload( [{ files: canonicalFiles.map((path) => ({ path })) }], @@ -56,14 +78,34 @@ test('pack verification rejects monorepo-only CLI invocations', (t) => { [ ...canonicalTexts, { - path: canonicalFiles[1], - text: 'node packages/flatbread/bin/flatbread.js', + path: canonicalFiles[3], + text: 'directory is an exclusively generated\nprojection: do not edit it', }, ], packageVersions ) ); - t.true(error.message.includes(canonicalFiles[1])); + t.true(error.message.includes(canonicalFiles[3])); + t.true(error.message.includes('exclusively generated projection')); +}); + +test('pack verification rejects version placeholders in skill docs', (t) => { + const error = t.throws(() => + verifyPackPayload( + [{ files: canonicalFiles.map((path) => ({ path })) }], + canonicalFiles, + [ + ...canonicalTexts, + { + path: canonicalFiles[3], + text: 'npx skills add .../tree//packages/proof/skills/proof', + }, + ], + packageVersions + ) + ); + t.true(error.message.includes('version placeholders')); + t.true(error.message.includes('install-skill')); }); test('pack verification rejects release identity drift', (t) => {