Skip to content

fix(cli): survive an unwritable .codex/, inline skills when it happens, and ship the doctrine where the CLI can find it#745

Merged
coderdan merged 4 commits into
mainfrom
fix/codex-skills-eperm-736
Jul 21, 2026
Merged

fix(cli): survive an unwritable .codex/, inline skills when it happens, and ship the doctrine where the CLI can find it#745
coderdan merged 4 commits into
mainfrom
fix/codex-skills-eperm-736

Conversation

@coderdan

@coderdan coderdan commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Closes #736.

Codex sandboxes deny writes under .codex/. installSkills created its destination with an unguarded mkdirSync sitting directly above a per-skill copy loop that was already guarded:

// :68 — unguarded; EPERM here throws past every fallback
mkdirSync(destRoot, { recursive: true })

// :74 — guarded, warns, continues
try { cpSync(src, dest, { recursive: true, force: true }) }
catch (err) { p.log.warn(`Failed to install skill ${name}: ${message}`) }

The graceful-degradation path existed; it just sat one line below the thing that actually failed.

And the blast radius was wider than the skills. installSkills runs first in the handoff step, so the throw took everything after it: no AGENTS.md, no .cipherstash/context.json, no .cipherstash/setup-prompt.md. All five Codex runs of the rc.3 matrix landed here, and that report named it the primary driver of the Claude→Codex quality gap.

The fix

1. installSkills never throws, and reports what happened. It returns { copied, failed } — every filesystem failure degrades to a warning plus a failed entry, so callers distinguish unwritable from stripped build from partial copy directly from the result instead of re-deriving it.

2. The Codex handoff inlines exactly the skills that failed. Whatever couldn't be written goes into AGENTS.md — project root, writable — via the same doctrine-plus-skills path the editor-agent handoff already uses for Cursor / Windsurf / Cline. A partial copy inlines just the missing skills. The launch prompt points at wherever the guidance actually ended up:

state skills land in AGENTS.md mode prompt says
normal .codex/skills/ doctrine-only read .codex/skills/
.codex/ unwritable AGENTS.md doctrine-plus-skills read the inlined references
partial copy both doctrine-plus-skills (failed subset) read both
stripped build nowhere doctrine-only (no skills clause)

The doctrine wasn't shipping where the CLI could find it

The review of the first cut found that in every published build, the fallback would have inlined nothing while claiming it did — and the cause predates this PR:

  • tsup copied the AGENTS.md doctrine to dist/commands/init/doctrine, mirroring the source layout.
  • But findBundledDir probes ancestor directories of the compiled chunk, which lives in dist/bin/dist/bin/doctrine, dist/doctrine, package root, … — and never reaches dist/commands/init/doctrine.
  • So readDoctrine() returned undefined in every published build, and buildAgentsMdBody early-returned a minimal stub that ignored the mode entirely — no doctrine and no inlined skills. Dev runs and unit tests resolve the source-tree copy one level up, which is why nothing ever caught it.

Two fixes, and they compound with the fallback:

  1. The doctrine now ships at dist/doctrine, exactly like dist/skills — the second ancestor probe from dist/bin/ resolves it (verified against a clean rebuild).
  2. buildAgentsMdBody honours doctrine-plus-skills even without the doctrine fragment — skills inline under a minimal header instead of being silently dropped with it, so the two failure modes can no longer combine into an empty AGENTS.md that the prompt calls complete.

Side effect worth noting: this also repairs plain doctrine-only AGENTS.md output — published builds have been writing the stub instead of the doctrine on the happy path too.

The honesty case

The generated artifacts describe what actually happened, not what the handoff hoped for:

  • writeArtifacts takes a SkillsDelivery (installed / inlined / failed); context.json gains an inlinedSkills field, and setup-prompt.md renders distinct branches for installed, inlined, both, failed-undelivered, and genuinely-stripped — an unwritable destination is no longer mislabelled a "stripped build" (the Claude and editor-agent handoffs had the same pre-existing misattribution; fixed there too).
  • Skills only count as inlined if the AGENTS.md write landed: the upsert is guarded (writeAgentsMd catches the malformed-sentinel refusal and fs errors, warns, and keeps going so .cipherstash/ is still written), and on failure the skills are recorded as failed with no inline claim in the prompt.
  • availableSkills filters on SKILL.md — the predicate the inliner actually uses — so "inlining N skills" can never overstate; readBundledSkill/readDoctrine guard their reads so an unreadable bundled file degrades instead of aborting pre-artifact.
  • A stripped CLI build ships no skills at all, so it stays doctrine-only and says nothing — reporting a fallback that didn't happen would be the same false-success bug Non-interactive stash init reports false success (skew, EQL-missing, skills) #714 and feat(cli): honest non-interactive init — fail on skew, no false success (rc.2 M4) #687 removed elsewhere in init.
  • If a stale .codex/skills/ from an earlier run couldn't be refreshed, the handoff says so and notes the inlined copies are current.

Also: the wizard had the same bug

@cipherstash/wizard carries its own copy of installSkills, with the identical unguarded mkdirSync above the identical guarded copy loop. It targets .claude/skills, so the Codex sandbox case doesn't apply — but an unwritable destination (read-only mount, restrictive permissions) crashed it the same way. Guarded to match, returning the same { copied, failed } shape — and a confirmed-then-failed install is now recorded in the wizard changelog instead of vanishing with the terminal output.

Validation

  • 660 unit + 68 pty E2E pass in the CLI, 148 in the wizard (including its first install-skills suite: mkdir failure, partial copy, decline).
  • Tests pin the degrade-to-warning contract in both packages, cover the partial-copy split end to end (including a real-FS read-only-directory trigger), and replace assertions that were vacuously true.
  • Typecheck clean for the changed files; both packages build; 0 biome errors; the built binary smoke-tests clean and its dist layout resolves the doctrine.

Per packages/cli/AGENTS.md I checked both drift surfaces: no E2E asserts on these strings (the only codex assertion is the target list, unchanged), and no skill documents the install location — so neither needed updating. The doctrine's "Where to read more", the build-agents-md docblock, and root AGENTS.md were updated to describe the inline path's real consumers.

Not in scope

Summary by CodeRabbit

  • Bug Fixes
    • Codex handoffs now continue when the .codex/ directory cannot be written.
    • Failed skill installs are handled gracefully by inlining skill content into the root AGENTS.md when needed.
    • Prompts and delivered-skill messaging now correctly reflect installed vs inlined vs failed skills (and avoid misleading references).
    • Wizard skill installation now never aborts due to unwritable destinations.
    • context.json and related setup guidance now track inlined skills and failure states accurately.
  • Tests
    • Expanded handoff/install coverage for full, partial, and failing skill-install scenarios.

…ppens

Codex sandboxes deny writes under `.codex/`. `installSkills` created its
destination with an unguarded `mkdirSync` sitting directly ABOVE a
per-skill copy loop that was already guarded — so the failure threw past
that fallback and past the caller, aborting the whole handoff step.

Because the skills install runs first, nothing after it ran either: no
AGENTS.md, no .cipherstash/context.json, no .cipherstash/setup-prompt.md.
All five Codex runs of the rc.3 skilltester matrix landed here, and that
report named it the primary driver of the Claude→Codex quality gap (#736).

Two changes.

`installSkills` never throws. Every filesystem step degrades to a warning
and a shorter return, so the caller decides what an empty result means
rather than being skipped entirely.

The Codex handoff falls back to inlining. When the skills cannot be
written, their bodies go into AGENTS.md — project root, writable —
through the same `doctrine-plus-skills` path the editor-agent handoff
already uses for Cursor / Windsurf / Cline. No new mechanism; the
existing one just was not reachable from this branch. The launch prompt
points at wherever the guidance actually ended up.

The fallback is only claimed when there is something to inline. A
stripped build ships no skills at all, so it stays doctrine-only and says
nothing — reporting a fallback that did not happen would be the same
false-success bug #714 and #687 removed elsewhere in init. That is why
`availableSkills` is split out: an empty install result alone cannot
distinguish "could not write" from "nothing to write".

@cipherstash/wizard carries its own copy of `installSkills` with the
identical unguarded `mkdirSync` above the identical guarded copy loop. It
targets `.claude/skills`, so the Codex sandbox case does not apply, but an
unwritable destination crashed it the same way — guarded to match.

Tests: 2 for the mkdirSync guard (including that the caller can still
distinguish unwritable from empty), 6 for the handoff across all three
states — copied, unwritable-so-inlined, and nothing-to-ship. 645 unit +
68 pty E2E pass; typecheck and build clean; 0 biome errors.

Checked per packages/cli/AGENTS.md: no E2E asserts on these strings (the
only codex assertion is the target list, unchanged), and no skill
documents the install location, so neither needed updating.
@coderdan
coderdan requested a review from a team as a code owner July 21, 2026 07:36
@changeset-bot

changeset-bot Bot commented Jul 21, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: bd40087

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
Name Type
stash Patch
@cipherstash/wizard Patch
@cipherstash/basic-example Patch
@cipherstash/e2e Patch
@cipherstash/stack Patch
@cipherstash/stack-drizzle Patch
@cipherstash/stack-supabase Patch
@cipherstash/prisma-next Patch
@cipherstash/bench Patch
@cipherstash/test-kit Patch
@cipherstash/prisma-next-example Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@coderdan, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 4 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 895010df-154f-4250-9b71-b4fe80be791c

📥 Commits

Reviewing files that changed from the base of the PR and between 3f9e297 and bd40087.

📒 Files selected for processing (1)
  • packages/cli/src/commands/init/lib/__tests__/install-skills.test.ts
📝 Walkthrough

Walkthrough

Skill installation now returns copied and failed results without throwing on unwritable destinations. Codex can inline failed skills into AGENTS.md, prompts and artifacts reflect actual delivery, and wizard installation reports failures.

Changes

Skill handoff resilience

Layer / File(s) Summary
Safe skill installation
packages/cli/src/commands/init/lib/install-skills.ts, packages/cli/src/commands/init/lib/__tests__/install-skills.test.ts
Adds bundled-skill availability detection and structured { copied, failed } results, guarding destination creation and file reads.
Delivery state and prompts
packages/cli/src/commands/init/lib/{write-context,setup-prompt,handoff-helpers}.ts, packages/cli/src/commands/status/__tests__/status.test.ts, packages/cli/src/commands/init/lib/__tests__/*
Tracks installed, inlined, and failed skills in context and renders prompt guidance from actual delivery outcomes.
Codex and editor handoffs
packages/cli/src/commands/impl/steps/{handoff-codex,handoff-claude,handoff-agents-md}.ts, packages/cli/src/commands/impl/steps/__tests__/*, packages/cli/src/commands/init/lib/build-agents-md.ts, packages/cli/tsup.config.ts
Inlines failed Codex skills into AGENTS.md, guards AGENTS.md writes, hardens doctrine loading, and corrects bundled doctrine placement.
Wizard resilience and documentation
packages/wizard/src/lib/install-skills.ts, packages/wizard/src/run.ts, packages/wizard/src/__tests__/install-skills.test.ts, .changeset/*, AGENTS.md
Records wizard installation failures, reports them during finalize, and documents the fallback behavior.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant HandoffCodexStep
  participant InstallSkills
  participant AgentsMdBuilder
  participant WriteAgentsMd
  participant Codex
  HandoffCodexStep->>InstallSkills: Install skills in .codex/skills/
  InstallSkills-->>HandoffCodexStep: Return copied and failed skills
  HandoffCodexStep->>AgentsMdBuilder: Build doctrine-plus-skills for failed skills
  AgentsMdBuilder-->>HandoffCodexStep: Return AGENTS.md content
  HandoffCodexStep->>WriteAgentsMd: Write managed AGENTS.md
  WriteAgentsMd-->>HandoffCodexStep: Return write status
  HandoffCodexStep->>Codex: Point to installed or inlined skill guidance
Loading

Possibly related PRs

Suggested reviewers: calvinbrewer

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 78.95% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes address #736 by guarding unwritable .codex/ installs, inlining failed skills into AGENTS.md, and preserving handoff artifacts.
Out of Scope Changes check ✅ Passed The additional test, docs, and wizard/Claude updates all support the reported skill-install resilience work and do not appear unrelated.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: unwritable .codex fallback, skill inlining, and bundled doctrine path fixes.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/codex-skills-eperm-736

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderdan

Copy link
Copy Markdown
Contributor Author

Code review — 15 findings (ranked by severity)

Multi-pass review of this branch (10 independent finder angles, per-candidate adversarial verification, then a gap sweep). The fix's core idea is right and the never-throw guard works, but the fallback path has one production-blocking problem and several places where its claims and its artifacts disagree.

Critical

1. In the published build, the fallback inlines nothing — while claiming it did.
packages/cli/src/commands/init/lib/build-agents-md.ts:33
tsup ships the doctrine at dist/commands/init/doctrine, but the code calling findBundledDir('doctrine') is bundled into dist/bin/chunk-*.js, and the resolver's ancestor probes (dist/bin/doctrine, dist/doctrine, package root, …) never reach it. So in the published tarball readDoctrine() returns undefined, and buildAgentsMdBody's early return ignores the doctrine-plus-skills mode entirely: on the exact #736 scenario this PR fixes, the CLI warns "inlining N skills into AGENTS.md instead" and the launch prompt says the inlined references are there — but AGENTS.md gets only the minimal stub, zero skill bodies, and stub text pointing at "the installed skills" which don't exist. The resolver bug is pre-existing on main (it affects doctrine-only too), but this PR's headline feature is a no-op in production because of it. Dev runs and unit tests resolve src/commands/init/doctrine via the one-level-up probe, which is why nothing catches it. Verified against the actual dist/ layout.

2. The fallback produces self-contradictory artifacts.
packages/cli/src/commands/impl/steps/handoff-codex.ts:89
Under the fallback, writeArtifacts receives installed=[], so context.json records installedSkills: [] and setup-prompt.md renders the skillsLoadedLines('codex', []) branch: "No skills were installed (stripped build) — the durable rules are in AGENTS.md…" (setup-prompt.ts:142-148). That's a false cause claim in the first file Codex is told to read, it drops the per-skill purpose index, and the "list the skills loaded (if any)" instruction makes the agent tell the user nothing was loaded — all directly contradicting the launch prompt's "the skill references inlined in AGENTS.md have the API details". Hits 100% of fallback runs. Fix: thread the inlined list (or a tri-state) into writeArtifacts/buildSetupPromptContext.

3. A partial copy failure suppresses the fallback entirely.
packages/cli/src/commands/impl/steps/handoff-codex.ts:61
The gate is installed.length === 0, so if mkdirSync succeeds but a subset of cpSync calls fail, the failed skills are neither installed nor inlined — while the step logs "Installed N skills" and the prompt points at .codex/skills/ as complete. Empirically reproduced (Node 24, macOS): a pre-existing read-only .codex/skills/<name>/ (mode 0555, e.g. left by a prior sandboxed run) fails EACCES: unlink for that skill while siblings copy. ENOSPC mid-loop is a second trigger. Comparing installed against availableSkills() and inlining the difference closes it.

The never-throws contract stops one call too early

4. The AGENTS.md write path can still abort the whole step with zero artifacts.
packages/cli/src/commands/impl/steps/handoff-codex.ts:84
upsertManagedBlock deliberately throws on a malformed sentinel pair (sentinel-upsert.ts:51-52), and the readFileSync/writeFileSync around it are unguarded. The step's caller chain rethrows everything except CancelledError (impl/index.ts:301-308), so a user's AGENTS.md containing a lone sentinel marker (interrupted run, hand-editing) kills every re-run before writeArtifacts — the full #736 total-loss the changeset declares fixed, after the log already promised the inlining. Call order is pre-existing, but the fallback now lands its content exactly here.

5. Unwritable .claude/ now silently ships zero guidance, with the wrong cause.
packages/cli/src/commands/impl/steps/handoff-claude.ts:28
The never-throw change alters this caller too: installSkills returns [], the step proceeds, and setup-prompt.md says "No skills or AGENTS.md were written (stripped build) — consult https://cipherstash.com/docs" — misattributed, since the bundle exists and only the destination failed (availableSkills() was added precisely to tell these apart, and this caller never consults it). There's no fallback parity: no AGENTS.md equivalent exists on the Claude path. Degrade-and-continue is an improvement over main's fatal abort; the misattribution and silent loss are the defect.

6. readBundledSkill/readDoctrine can throw through the new fallback path.
packages/cli/src/commands/init/lib/install-skills.ts:132 (and build-agents-md.ts:82)
Both use existsSync-then-readFileSync, so an existing-but-unreadable bundled file (root-owned node_modules from a container UID mismatch, broken umask) throws at handoff-codex.ts:75 — before the AGENTS.md write and writeArtifacts — despite installSkills' "Never throws." doctrine. Exotic trigger, total-loss outcome; a try/catch returning undefined in both helpers closes it.

Fallback content correctness

7. The inlined doctrine points Codex at the directory that just failed.
packages/cli/src/commands/init/doctrine/AGENTS-doctrine.md:47
The doctrine body — the first thing in the fallback AGENTS.md — says workflows "live in skills installed alongside this file" (line 5) and lists .codex/skills/<skill>/SKILL.md (Codex) under "Where to read more" (line 47). On the fallback path those files don't exist, and the doctrine never points at the ## Skill references appendix inlined below it. (Masked today by finding 1; becomes visible the moment that's fixed.)

8. availableSkills and the inliner use different existence predicates.
packages/cli/src/commands/init/lib/install-skills.ts:51
availableSkills checks the skill directory; the inlining it gates requires <skill>/SKILL.md (readBundledSkill). A dir shipping without SKILL.md would be counted in "inlining N skills" yet silently dropped from AGENTS.md. Essentially unreachable today (tsup copies the whole tree; all 7 skills have SKILL.md — verified), but a one-line hardening: filter on join(bundledRoot, name, 'SKILL.md').

9. The fallback warning asserts a cause it never observed.
packages/cli/src/commands/impl/steps/handoff-codex.ts:70
"Could not write .codex/skills/" fires for any empty install result — including mkdir-succeeded-all-copies-failed for unrelated reasons (unreadable bundle source, ENOSPC), where it misdirects debugging after N accurate per-skill warnings. On the true EPERM path it duplicates installSkills' own "Could not create" warning.

Design / state management

10. installSkills' flat string[] return is the root cause of 2, 3, 5, and 9.
packages/cli/src/commands/init/lib/install-skills.ts:74
It conflates "nothing bundled", "unwritable", "every copy failed", and "partial copy"; the availableSkills docstring itself admits callers need to tell these apart, but the disambiguation lives only in the Codex caller's head — handoff-claude and the wizard copy never make the second call, and nobody can detect a partial copy. A discriminated result ({status, copied, failed}) would make the compiler force every caller to handle each outcome.

11. Stale skill state accumulates across runs and target switches.
packages/cli/src/commands/init/lib/install-skills.ts:92
mkdirSync runs before any copy and nothing checks or cleans a pre-existing .codex/skills/: a prior run's stale skills remain on disk (and Codex auto-loads them, per this handoff's own premise) alongside newer inlined AGENTS.md content; conversely, a later claude-code handoff never touches AGENTS.md, so fallback-inlined bodies from an old CLI persist next to freshly installed .claude/skills/.

12. A confirmed-then-failed wizard install leaves no trace in the wizard log.
packages/wizard/src/run.ts:311
if (installedSkills.length > 0) gates the changelog entry and clack warnings are never captured, so the flushed wizard-log.md is indistinguishable from the user declining — no artifact trail for "why does Claude have no skills". [] currently conflates declined/failed, so this needs logging inside maybeInstallSkills or a richer return.

Tests & docs

13. The regression coverage doesn't pin the contract it documents.
packages/cli/src/commands/init/lib/__tests__/install-skills.test.ts:120
The #736 test asserts only not-throw and []p.log.warn is unmocked, so "degrades to a warning" is unverified (a catch { return [] } refactor passes) and real clack output leaks into the run. The wizard's identically-guarded copy — which the changeset bumps — gets no test at all despite an active wizard unit suite. Minor: handoff-codex.test.ts:114's not.toContain('inlining') is vacuously true whenever warn is never called.

14. The diff makes build-agents-md.ts's own docblock wrong.
packages/cli/src/commands/init/lib/build-agents-md.ts:17
It still says doctrine-only is "Used by the Codex handoff" and doctrine-plus-skills is "Used by the AGENTS.md handoff for editor agents (Cursor / Windsurf / Cline)" — both now inaccurate. Root AGENTS.md's readBundledSkill() consumer list has the same gap. Two-line doc fix.

15. An internal issue-tracker ID ships again in a file this PR edits.
packages/wizard/src/lib/install-skills.ts:12
A Linear ID sits in the doc comment of this public-repo file (pre-existing, not added by this diff — the new hunk below correctly cites #736). The file is already being edited ~40 lines down; a one-line drive-by cleanup would remove it per the GitHub-issues-only policy for public artifacts.


Refuted / dropped during verification: the wizard p.confirm cannot reject on non-TTY (clack's promise has no reject path, and run.ts's outer catch flushes the changelog regardless); the silent empty-dist/skills case is designed behavior pinned by this PR's own stripped-build tests; the hard-coded prompt strings follow this test file's existing precedent (messages.ts scopes itself to E2E-asserted strings); and the launch-prompt duplication between the two handoffs is pre-existing.

Address the 15 findings from the PR #745 review.

The structural change: installSkills returns { copied, failed } instead
of a flat list, so callers distinguish unwritable / stripped / partial
outcomes without a second probe. The Codex handoff inlines exactly the
failed skills — a partial copy inlines the missing ones and the launch
prompt points at both locations.

The production-blocking find: the bundled AGENTS.md doctrine shipped at
dist/commands/init/doctrine, but findBundledDir probes ancestors of the
compiled chunk in dist/bin/ — so every published build wrote the minimal
AGENTS.md stub, and the inline fallback would have inlined nothing while
claiming otherwise. The doctrine now lands at dist/doctrine, like the
skills bundle, and buildAgentsMdBody honours doctrine-plus-skills even
when the doctrine fragment is missing.

Honesty and hardening around the fallback:

- writeArtifacts takes a SkillsDelivery (installed/inlined/failed);
  context.json gains inlinedSkills and setup-prompt.md stops
  mislabelling an unwritable destination as a "stripped build" — on the
  Claude and AGENTS.md handoffs too
- availableSkills filters on SKILL.md, the predicate the inliner
  actually uses, so "inlining N skills" never overstates
- the AGENTS.md upsert (which refuses malformed sentinel pairs) and the
  bundled-file reads degrade to warnings instead of aborting the step
  before .cipherstash/ is written; skills only count as inlined when
  the write landed
- the fallback warning announces the observed recovery instead of
  asserting an unverified cause, and flags a stale .codex/skills/ it
  could not refresh
- a confirmed-then-failed wizard install is recorded in the wizard
  changelog instead of vanishing with the terminal output

Tests pin the degrade-to-warning contract in both packages (the wizard
suite is new), cover the partial-copy split end to end, and replace the
vacuously-true warning assertions. The doctrine's "Where to read more",
the build-agents-md docblock, and root AGENTS.md now describe the real
mode consumers.
@coderdan

Copy link
Copy Markdown
Contributor Author

Review response — all 15 findings addressed in 3f9e297

Point-by-point resolution of the review above:

1. Fallback inlines nothing in the published build — fixed at the root. The doctrine now ships at dist/doctrine, where findBundledDir's ancestor probes from the dist/bin/ chunk actually reach it (verified against a clean rebuild: the second probe resolves). buildAgentsMdBody also honours doctrine-plus-skills when the doctrine fragment is missing — skills inline under a minimal header instead of being silently dropped with it, so the two failure modes can no longer compound. The tsup comment now explains why the location matters, since the old comment's "mirror the source layout" reasoning is exactly what broke it.

2. Self-contradictory artifacts — fixed. writeArtifacts now takes a SkillsDelivery (installed / inlined / failed); context.json gains an inlinedSkills field and setup-prompt.md renders distinct branches for installed, inlined, both, failed-undelivered, and genuinely-stripped. The "(stripped build)" text now only appears when the build is actually stripped. The same fix covers the editor-agent handoff, which had the identical pre-existing misattribution.

3. Partial copy suppresses the fallback — fixed structurally. installSkills returns { copied, failed }, and the Codex handoff inlines exactly failed — all skills under a sandbox, the missing subset after a partial copy. The launch prompt points at both locations when both exist. Covered by new unit tests at the decision layer and a real-FS partial-copy test using the read-only-directory trigger the verifier reproduced.

4. Unguarded AGENTS.md write path — fixed. A shared writeAgentsMd helper (used by the Codex and editor handoffs) catches the sentinel-pair refusal and fs errors, warns, and returns whether the write landed — skills only count as inlined when it did; otherwise they're recorded as failed and the prompt makes no inline claim. .cipherstash/ gets written regardless.

5. Claude handoff silent loss + misattribution — fixed. It now warns "N skills could not be installed to .claude/skills/https://cipherstash.com/docs covers the same ground", and the setup prompt renders the failed-install branch instead of "stripped build". (Per the verifier's note, degrade-and-continue itself was an improvement — only the messaging needed fixing. Fallback parity via an AGENTS.md-equivalent on the Claude path is a larger design question I've left out of this PR.)

6. readBundledSkill/readDoctrine unguarded reads — fixed. Both guard readFileSync and degrade to a warning + undefined.

7. Doctrine points at the failed directory — fixed. "Where to read more" now lists the inlined ## Skill references location alongside the two skill directories, and notes that when the section exists it is the current copy.

8. Predicate mismatch — fixed. availableSkills filters on <skill>/SKILL.md — the same predicate readBundledSkill uses — so the inline count can never overstate.

9. Wrong-cause warning — fixed. The handoff's message now announces the observed recovery ("Inlining N skills that could not be installed to .codex/skills/…") rather than asserting an unverified cause; installSkills keeps sole ownership of the per-failure error details.

10. Flat return shape — fixed (see 3); the { copied, failed } result is what makes 2, 3, 5, and 9 fall out, and the second availableSkills() probe in the handoff is gone.

11. Stale-state residue — partially fixed. When the fallback fires and a pre-existing .codex/skills/ couldn't be refreshed, the handoff warns that its contents may be stale and that the inlined copies are current. The cross-target case (Codex-fallback AGENTS.md content persisting after switching to the Claude handoff) is deferred — cleaning another handoff's artifacts needs a design decision about AGENTS.md ownership that I didn't want to bury in this PR.

12. Wizard changelog silence — fixed. maybeInstallSkills returns { copied, failed } and finalize() records a confirmed-then-failed install as a changelog note, so the flushed wizard log is no longer indistinguishable from declining.

13. Test gaps — fixed. The CLI suite mocks the warn channel and asserts the degrade-to-warning contract; the wizard gets its first install-skills suite (mkdir failure, partial copy, decline); the vacuous not.toContain('inlining') became expect(p.log.warn).not.toHaveBeenCalled(). One of the review's meta-findings promptly proved itself: the first cut of the wizard test leaked a throwing mock implementation across tests via clearAllMocks — exactly the semantics finding 13's verifier documented — and was fixed with resetAllMocks.

14. Doc drift — fixed. The build-agents-md.ts docblock and root AGENTS.md now describe both consumers of doctrine-plus-skills.

15. Internal tracker ID — removed from the wizard comment.

Deferred (called out, not fixed): the cross-target AGENTS.md residue from finding 11, and the pre-existing launch-prompt duplication between the two handoffs (finding dropped in review as pre-existing; the divergence is partly semantic).

Verification: 660 CLI unit tests, 68 pty e2e tests, and 148 wizard tests pass; Biome check is error-free; tsc --noEmit reports zero errors in touched files; the built binary smoke-tests clean and its dist layout resolves the doctrine. The changeset was rewritten to cover the full scope, including the doctrine-location fix, which also repairs doctrine-only AGENTS.md output in every published build to date.

@coderdan coderdan changed the title fix(cli): survive an unwritable .codex/, and inline skills when it happens fix(cli): survive an unwritable .codex/, inline skills when it happens, and ship the doctrine where the CLI can find it Jul 21, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
packages/cli/src/commands/init/lib/handoff-helpers.ts (1)

85-103: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

writeContextFile/writeSetupPrompt are still unguarded — the exact #736 failure mode, one directory over.

writeAgentsMd is correctly hardened, but writeArtifacts calls writeContextFile (Line 95) and writeSetupPrompt (Line 100) directly; per the buildContextFile/writeContextFile definitions these do ensureDir + writeFileSync with no try/catch. If .cipherstash/ is unwritable in the same sandboxed environment that made .codex/ unwritable, this throws and aborts the entire handoff step, discarding all the copied/inlined skill work just performed — the same blast radius #736 described, just for a different directory.

🛡️ Proposed fix to guard the remaining artifact writes
   const ctx = buildContextFile(state)
   ctx.envKeys = state.envKeys ?? []
   ctx.installedSkills = skills.installed
   ctx.inlinedSkills = skills.inlined
-  writeContextFile(resolve(cwd, CONTEXT_REL_PATH), ctx)
-  p.log.success(`Wrote ${CONTEXT_REL_PATH}`)
+  try {
+    writeContextFile(resolve(cwd, CONTEXT_REL_PATH), ctx)
+    p.log.success(`Wrote ${CONTEXT_REL_PATH}`)
+  } catch (err) {
+    const message = err instanceof Error ? err.message : String(err)
+    p.log.warn(`Could not write ${CONTEXT_REL_PATH}: ${message}`)
+  }

   const promptCtx = buildSetupPromptContext(state, handoff, skills)
   if (promptCtx) {
-    writeSetupPrompt(resolve(cwd, SETUP_PROMPT_REL_PATH), promptCtx)
-    p.log.success(`Wrote ${SETUP_PROMPT_REL_PATH}`)
+    try {
+      writeSetupPrompt(resolve(cwd, SETUP_PROMPT_REL_PATH), promptCtx)
+      p.log.success(`Wrote ${SETUP_PROMPT_REL_PATH}`)
+    } catch (err) {
+      const message = err instanceof Error ? err.message : String(err)
+      p.log.warn(`Could not write ${SETUP_PROMPT_REL_PATH}: ${message}`)
+    }
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/commands/init/lib/handoff-helpers.ts` around lines 85 - 103,
Guard the artifact writes in writeArtifacts so failures from writeContextFile
and writeSetupPrompt do not abort the handoff after skill delivery. Catch and
handle each write failure independently, preserving successful writes and the
existing success logs while allowing the function to complete when .cipherstash/
or the setup prompt location is unwritable.
packages/cli/src/commands/impl/steps/handoff-codex.ts (1)

90-109: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

"AGENTS.md has the durable rules" is claimed even when the AGENTS.md write itself failed.

None of the messaging surfaces key off the actual per-run AGENTS.md write outcome (agentsMdWritten in handoff-codex.ts, written in handoff-agents-md.ts) — they either hardcode the "AGENTS.md has the rules" text or infer it from handoff type alone (wroteAgentsMd in setup-prompt.ts). This is provably wrong in the exact failure case the PR targets: whenever skills.failed.length > 0 reaches the "nothing delivered" branch for codex/agents-md, the underlying write is guaranteed to have failed (per undelivered = agentsMdWritten ? [] : failed / failed: written ? [] : inlinable), yet the rendered text still tells the agent to "read AGENTS.md" for the durable rules.

  • packages/cli/src/commands/impl/steps/handoff-codex.ts#L90-L109: gate the "AGENTS.md has the durable rules;" clause in launchPrompt on agentsMdWritten, falling back to a docs pointer when it's false.
  • packages/cli/src/commands/impl/steps/handoff-agents-md.ts#L35-L59: gate the p.note "Rules at AGENTS.md" line on written, telling the user the write failed instead when it's false.
  • packages/cli/src/commands/init/lib/setup-prompt.ts#L144-L167: replace the handoff-type-only wroteAgentsMd inference with a real write-success signal (e.g. an agentsMdWritten field threaded through SkillsDelivery/SetupPromptContext from the handoff steps) so the "read AGENTS.md" message isn't shown when the write actually failed.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/commands/impl/steps/handoff-codex.ts` around lines 90 - 109,
Gate the AGENTS.md guidance in
packages/cli/src/commands/impl/steps/handoff-codex.ts:90-109 on agentsMdWritten
and use a docs fallback when false; in
packages/cli/src/commands/impl/steps/handoff-agents-md.ts:35-59, show the
failure message instead of “Rules at AGENTS.md” when written is false. In
packages/cli/src/commands/init/lib/setup-prompt.ts:144-167, replace
handoff-type-only wroteAgentsMd inference with the actual write-success signal,
threading agentsMdWritten through SkillsDelivery/SetupPromptContext so “read
AGENTS.md” is only displayed after a successful write.
🧹 Nitpick comments (1)
packages/cli/src/commands/init/lib/install-skills.ts (1)

125-136: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Partial cpSync failures can leave stale files in the destination.

cpSync copies recursively; if it fails partway (e.g. disk full, permission denied on a nested file), whatever was already written under dest stays behind even though the skill is reported as failed. A later successful retry with force: true will likely overwrite it, but until then an agent reading .codex/skills/<name>/ (or .claude/skills/<name>/) could see incomplete skill content without any signal that it's partial.

Consider a best-effort cleanup on failure:

🧹 Proposed cleanup on copy failure
     try {
       cpSync(src, dest, { recursive: true, force: true })
       copied.push(name)
     } catch (err) {
       const message = err instanceof Error ? err.message : String(err)
       p.log.warn(`Failed to install skill ${name}: ${message}`)
+      try {
+        rmSync(dest, { recursive: true, force: true })
+      } catch {
+        // best-effort; the skill is already reported via `failed`
+      }
       failed.push(name)
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/commands/init/lib/install-skills.ts` around lines 125 - 136,
Update the cpSync failure handling in the skill-installation loop to best-effort
remove the partially copied destination before logging the warning and recording
the skill in failed. Use the existing dest path and filesystem utilities, while
preserving the current error reporting and failed-list behavior even if cleanup
also fails.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/cli/src/commands/init/lib/__tests__/install-skills.test.ts`:
- Around line 155-176: Update the skipped partial-copy test around installSkills
to simulate the blocked stash-drizzle destination with a regular file, reusing
the file-instead-of-directory setup from the preceding test. Remove the
permission-based directory creation and cleanup, while preserving the expected
copied, failed, and warning assertions.

---

Outside diff comments:
In `@packages/cli/src/commands/impl/steps/handoff-codex.ts`:
- Around line 90-109: Gate the AGENTS.md guidance in
packages/cli/src/commands/impl/steps/handoff-codex.ts:90-109 on agentsMdWritten
and use a docs fallback when false; in
packages/cli/src/commands/impl/steps/handoff-agents-md.ts:35-59, show the
failure message instead of “Rules at AGENTS.md” when written is false. In
packages/cli/src/commands/init/lib/setup-prompt.ts:144-167, replace
handoff-type-only wroteAgentsMd inference with the actual write-success signal,
threading agentsMdWritten through SkillsDelivery/SetupPromptContext so “read
AGENTS.md” is only displayed after a successful write.

In `@packages/cli/src/commands/init/lib/handoff-helpers.ts`:
- Around line 85-103: Guard the artifact writes in writeArtifacts so failures
from writeContextFile and writeSetupPrompt do not abort the handoff after skill
delivery. Catch and handle each write failure independently, preserving
successful writes and the existing success logs while allowing the function to
complete when .cipherstash/ or the setup prompt location is unwritable.

---

Nitpick comments:
In `@packages/cli/src/commands/init/lib/install-skills.ts`:
- Around line 125-136: Update the cpSync failure handling in the
skill-installation loop to best-effort remove the partially copied destination
before logging the warning and recording the skill in failed. Use the existing
dest path and filesystem utilities, while preserving the current error reporting
and failed-list behavior even if cleanup also fails.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d4639202-f0bb-4fcd-9e28-693981317729

📥 Commits

Reviewing files that changed from the base of the PR and between a6b6035 and 3f9e297.

📒 Files selected for processing (22)
  • .changeset/codex-skills-eperm.md
  • AGENTS.md
  • packages/cli/src/commands/impl/steps/__tests__/handoff-claude.test.ts
  • packages/cli/src/commands/impl/steps/__tests__/handoff-codex.test.ts
  • packages/cli/src/commands/impl/steps/handoff-agents-md.ts
  • packages/cli/src/commands/impl/steps/handoff-claude.ts
  • packages/cli/src/commands/impl/steps/handoff-codex.ts
  • packages/cli/src/commands/init/doctrine/AGENTS-doctrine.md
  • packages/cli/src/commands/init/lib/__tests__/build-agents-md.test.ts
  • packages/cli/src/commands/init/lib/__tests__/install-skills.test.ts
  • packages/cli/src/commands/init/lib/__tests__/read-context.test.ts
  • packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts
  • packages/cli/src/commands/init/lib/build-agents-md.ts
  • packages/cli/src/commands/init/lib/handoff-helpers.ts
  • packages/cli/src/commands/init/lib/install-skills.ts
  • packages/cli/src/commands/init/lib/setup-prompt.ts
  • packages/cli/src/commands/init/lib/write-context.ts
  • packages/cli/src/commands/status/__tests__/status.test.ts
  • packages/cli/tsup.config.ts
  • packages/wizard/src/__tests__/install-skills.test.ts
  • packages/wizard/src/lib/install-skills.ts
  • packages/wizard/src/run.ts

Comment thread packages/cli/src/commands/init/lib/__tests__/install-skills.test.ts Outdated
coderdan added 2 commits July 21, 2026 21:15
The read-only-directory trigger was platform-dependent: on macOS cpSync
unlinks the destination file first (EACCES from the 0o555 parent), but
on Linux it overwrites the writable SKILL.md in place and the copy
succeeds — so the test failed on CI while passing locally.

Block the skill with a FILE where its directory must go instead:
cpSync then throws ERR_FS_CP_DIR_TO_NON_DIR, a type-mismatch error
raised before any permission-dependent operation, so it fires on every
platform and as root. Same technique the neighbouring mkdir test
already uses. Drops the skipIf guard the chmod approach needed.
@coderdan

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderdan
coderdan merged commit 98156ac into main Jul 21, 2026
9 of 10 checks passed
@coderdan
coderdan deleted the fix/codex-skills-eperm-736 branch July 21, 2026 12:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

stash init/plan installs zero skills for Codex — unguarded mkdirSync on .codex/skills is fatal (rc.3 F2)

1 participant