Skip to content

Commit 98156ac

Browse files
authored
fix(cli): survive an unwritable .codex/, inline skills when it happens, and ship the doctrine where the CLI can find it (#745)
* fix(cli): survive an unwritable .codex/, and inline skills when it happens 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. * fix(cli): make the Codex skills fallback deliver what it claims 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. * test(cli): make the partial-copy fixture deterministic across platforms 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. * style(cli): format the partial-copy fixture line
1 parent 508f1d5 commit 98156ac

22 files changed

Lines changed: 830 additions & 169 deletions

.changeset/codex-skills-eperm.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'stash': patch
3+
'@cipherstash/wizard': patch
4+
---
5+
6+
Fix the Codex handoff installing zero skills — and losing `AGENTS.md` and `.cipherstash/` with them — when `.codex/` is not writable.
7+
8+
Codex sandboxes deny writes under `.codex/`. `installSkills` created its destination with an unguarded `mkdirSync`, sitting directly above a per-skill copy loop that *was* 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 it was identified in that report as the primary driver of the Claude→Codex quality gap.
9+
10+
The fix, hardened by a follow-up review of the first cut:
11+
12+
- **`installSkills` never throws, and reports what happened.** It returns `{ copied, failed }` instead of a flat list, so callers can tell "unwritable destination" from "stripped build" from "partial copy" without re-deriving it — every filesystem failure degrades to a warning plus a `failed` entry.
13+
- **The Codex handoff inlines exactly the skills that failed.** Whatever could not be copied into `.codex/skills/` — all of them under a sandbox, or a subset after a partial failure — has its body inlined into `AGENTS.md` via the same `doctrine-plus-skills` path the editor-agent handoff uses. The launch prompt points at wherever each skill actually ended up, including both locations after a partial copy. A stripped build that ships no skills stays `doctrine-only` and says nothing.
14+
- **The doctrine now ships where the published CLI can find it.** The bundled AGENTS.md doctrine was copied to `dist/commands/init/doctrine`, but the compiled resolver probes ancestor directories of the chunk in `dist/bin/` — so every published build silently wrote the minimal `AGENTS.md` stub instead of the doctrine (and the inline fallback would have inlined nothing). It now lands at `dist/doctrine`, like the skills bundle. `buildAgentsMdBody` also honours `doctrine-plus-skills` even when the doctrine fragment is missing, so inlined skills are never dropped with it.
15+
- **The generated artifacts describe the fallback honestly.** `context.json` gains an `inlinedSkills` field, and `setup-prompt.md` distinguishes installed / inlined / failed skills instead of mislabelling an unwritable destination as a "stripped build". The Claude handoff now warns when skills exist but could not be installed, and the AGENTS.md handoff records what it inlined.
16+
- **The rest of the handoff is guarded too.** 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.
17+
18+
`@cipherstash/wizard` carries its own copy of `installSkills` with the same unguarded `mkdirSync` above the same guarded copy loop. It targets `.claude/skills` rather than `.codex/skills`, so the Codex sandbox case does not apply, but an unwritable destination crashed it identically — now guarded the same way, with a confirmed-then-failed install recorded in the wizard changelog instead of vanishing with the terminal output.

AGENTS.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,10 @@ sentence in one of them the way you'd treat a wrong line of code:
104104
- `installSkills()` (`packages/cli/src/commands/init/lib/install-skills.ts`) copies the
105105
per-integration set into the user's `.claude/skills/` or `.codex/skills/` at handoff time.
106106
- `readBundledSkill()` inlines a skill's body into the user's `AGENTS.md` for editor
107-
agents (Cursor / Windsurf / Cline). Only `SKILL.md` is inlined — content split into
108-
sibling files is silently dropped on that path, so keep each `SKILL.md` self-sufficient.
107+
agents (Cursor / Windsurf / Cline), and as the Codex fallback for skills that could
108+
not be copied into `.codex/skills/` (#736). Only `SKILL.md` is inlined — content split
109+
into sibling files is silently dropped on that path, so keep each `SKILL.md`
110+
self-sufficient.
109111

110112
**Every change to a package's public API, the CLI command surface, or a user-facing
111113
workflow must check the affected skills in the same PR.** These skills drift silently:

packages/cli/src/commands/impl/steps/__tests__/handoff-claude.test.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,16 +25,37 @@ import { handoffClaudeStep } from '../handoff-claude.js'
2525
// assert on.
2626
const state = { integration: 'postgresql' } as unknown as InitState
2727

28+
const warnings = () => vi.mocked(p.log.warn).mock.calls.map(String).join('\n')
29+
2830
beforeEach(() => vi.clearAllMocks())
2931

3032
it('launch prompt omits the skills dir when no skills were copied', async () => {
31-
installSkills.mockReturnValue([])
33+
installSkills.mockReturnValue({ copied: [], failed: [] })
3234
await handoffClaudeStep.run(state)
3335
expect(vi.mocked(p.note).mock.calls[0][0]).not.toContain('.claude/skills/')
3436
})
3537

3638
it('launch prompt names the skills dir when skills were copied', async () => {
37-
installSkills.mockReturnValue(['stash-encryption'])
39+
installSkills.mockReturnValue({ copied: ['stash-encryption'], failed: [] })
3840
await handoffClaudeStep.run(state)
3941
expect(vi.mocked(p.note).mock.calls[0][0]).toContain('.claude/skills/')
4042
})
43+
44+
// Unlike the Codex handoff there is no AGENTS.md on this path to inline
45+
// into — a failed install must be announced, not left looking complete
46+
// (#736 follow-up review).
47+
it('warns when skills exist but could not be installed', async () => {
48+
installSkills.mockReturnValue({
49+
copied: [],
50+
failed: ['stash-encryption', 'stash-cli'],
51+
})
52+
await handoffClaudeStep.run(state)
53+
expect(warnings()).toContain('could not be installed to .claude/skills/')
54+
expect(warnings()).toContain('stash-encryption, stash-cli')
55+
})
56+
57+
it('does not warn when this build simply ships no skills', async () => {
58+
installSkills.mockReturnValue({ copied: [], failed: [] })
59+
await handoffClaudeStep.run(state)
60+
expect(p.log.warn).not.toHaveBeenCalled()
61+
})
Lines changed: 144 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,25 @@
1-
import { beforeEach, expect, it, vi } from 'vitest'
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
22
import type { InitState } from '../../../init/types.js'
33

44
// Same seam as the handoff-claude test: the launch prompt's skills clause is
5-
// the unit under test. Mock the skill installer to control whether skills were
6-
// "copied". The Codex handoff also writes AGENTS.md to disk before printing
7-
// the prompt, so stub `node:fs` and the AGENTS.md builders to keep the test
8-
// hermetic (no file lands in the repo) — the assertion is purely on the
9-
// launch-prompt text.
5+
// the unit under test. Mock the skill installer to control the copied/failed
6+
// split, and the shared AGENTS.md writer to control whether the inline
7+
// fallback landed. The assertions are on the launch-prompt text, the
8+
// AGENTS.md mode chosen, and the delivery recorded into the artifacts.
109
const installSkills = vi.hoisted(() => vi.fn())
1110
vi.mock('../../../init/lib/install-skills.js', () => ({ installSkills }))
11+
const writeAgentsMd = vi.hoisted(() => vi.fn())
1212
vi.mock('../../../init/lib/handoff-helpers.js', () => ({
13+
AGENTS_MD_REL_PATH: 'AGENTS.md',
14+
writeAgentsMd,
1315
writeArtifacts: vi.fn(),
1416
spawnAgent: vi.fn(async () => 0),
1517
}))
16-
vi.mock('../../../init/lib/build-agents-md.js', () => ({
17-
buildAgentsMdBody: vi.fn(() => '# managed doctrine'),
18-
}))
19-
vi.mock('../../../init/lib/sentinel-upsert.js', () => ({
20-
upsertManagedBlock: vi.fn(() => '# AGENTS.md'),
21-
}))
18+
const buildAgentsMdBody = vi.hoisted(() => vi.fn())
19+
vi.mock('../../../init/lib/build-agents-md.js', () => ({ buildAgentsMdBody }))
2220
vi.mock('node:fs', () => ({
2321
existsSync: vi.fn(() => false),
22+
mkdirSync: vi.fn(),
2423
readFileSync: vi.fn(() => ''),
2524
writeFileSync: vi.fn(),
2625
}))
@@ -30,25 +29,147 @@ vi.mock('@clack/prompts', () => ({
3029
}))
3130

3231
import * as p from '@clack/prompts'
32+
import { writeArtifacts } from '../../../init/lib/handoff-helpers.js'
3333
import { handoffCodexStep } from '../handoff-codex.js'
3434

3535
// `agents` undefined → Codex "not installed" path, which routes the launch
3636
// prompt into a `p.note`.
3737
const state = { integration: 'postgresql' } as unknown as InitState
3838

39-
beforeEach(() => vi.clearAllMocks())
39+
const promptBody = () => vi.mocked(p.note).mock.calls[0][0]
40+
const agentsMdMode = () => vi.mocked(buildAgentsMdBody).mock.calls[0][1]
41+
const inlinedList = () => vi.mocked(buildAgentsMdBody).mock.calls[0][2]
42+
const delivery = () => vi.mocked(writeArtifacts).mock.calls[0][3]
43+
const warnings = () => vi.mocked(p.log.warn).mock.calls.map(String).join('\n')
4044

41-
it('launch prompt drops .codex/skills/ but keeps AGENTS.md when no skills copied', async () => {
42-
installSkills.mockReturnValue([])
43-
await handoffCodexStep.run(state)
44-
const body = vi.mocked(p.note).mock.calls[0][0]
45-
expect(body).not.toContain('.codex/skills/')
46-
// The durable rules live in AGENTS.md regardless, so the prompt still names it.
47-
expect(body).toContain('AGENTS.md')
45+
beforeEach(() => {
46+
vi.clearAllMocks()
47+
writeAgentsMd.mockReturnValue(true)
4848
})
4949

50-
it('launch prompt names .codex/skills/ when skills were copied', async () => {
51-
installSkills.mockReturnValue(['stash-encryption'])
50+
it('launch prompt names .codex/skills/ when all skills were copied', async () => {
51+
installSkills.mockReturnValue({ copied: ['stash-encryption'], failed: [] })
5252
await handoffCodexStep.run(state)
53-
expect(vi.mocked(p.note).mock.calls[0][0]).toContain('.codex/skills/')
53+
54+
expect(promptBody()).toContain('.codex/skills/')
55+
// Skills are on disk, so AGENTS.md stays doctrine-only per Codex guidance.
56+
expect(agentsMdMode()).toBe('doctrine-only')
57+
expect(delivery()).toEqual({
58+
installed: ['stash-encryption'],
59+
inlined: [],
60+
failed: [],
61+
})
62+
})
63+
64+
// #736: Codex sandboxes deny writes under `.codex/`. The skills cannot land,
65+
// but AGENTS.md (project root) can — so the guidance is inlined there rather
66+
// than lost, and Codex is pointed at it.
67+
describe('when .codex/skills could not be written at all', () => {
68+
beforeEach(() => {
69+
installSkills.mockReturnValue({
70+
copied: [],
71+
failed: ['stash-encryption', 'stash-cli'],
72+
})
73+
})
74+
75+
it('inlines the failed skills into AGENTS.md instead of shipping nothing', async () => {
76+
await handoffCodexStep.run(state)
77+
expect(agentsMdMode()).toBe('doctrine-plus-skills')
78+
expect(inlinedList()).toEqual(['stash-encryption', 'stash-cli'])
79+
})
80+
81+
it('points the launch prompt at AGENTS.md, not the directory that failed', async () => {
82+
await handoffCodexStep.run(state)
83+
const body = promptBody()
84+
expect(body).not.toContain('.codex/skills/')
85+
expect(body).toContain('inlined in AGENTS.md')
86+
})
87+
88+
it('says so, rather than reporting a silent success', async () => {
89+
await handoffCodexStep.run(state)
90+
expect(warnings()).toContain('.codex/skills/')
91+
expect(warnings()).toContain('AGENTS.md')
92+
})
93+
94+
it('records the skills as inlined, not installed, in the artifacts', async () => {
95+
await handoffCodexStep.run(state)
96+
expect(delivery()).toEqual({
97+
installed: [],
98+
inlined: ['stash-encryption', 'stash-cli'],
99+
failed: [],
100+
})
101+
})
102+
103+
it('records the skills as failed when AGENTS.md could not be written either', async () => {
104+
writeAgentsMd.mockReturnValue(false)
105+
await handoffCodexStep.run(state)
106+
expect(delivery()).toEqual({
107+
installed: [],
108+
inlined: [],
109+
failed: ['stash-encryption', 'stash-cli'],
110+
})
111+
// Nothing was inlined, so the prompt must not claim otherwise.
112+
expect(promptBody()).not.toContain('inlined in AGENTS.md')
113+
})
114+
})
115+
116+
// A partial copy — mkdir succeeded, one skill's cpSync failed — must inline
117+
// exactly the missing skill, not declare success and drop it (#736 follow-up
118+
// review: the fallback used to be all-or-nothing on installed.length === 0).
119+
describe('when only some skills could be written', () => {
120+
beforeEach(() => {
121+
installSkills.mockReturnValue({
122+
copied: ['stash-encryption'],
123+
failed: ['stash-cli'],
124+
})
125+
})
126+
127+
it('inlines only the failed skill', async () => {
128+
await handoffCodexStep.run(state)
129+
expect(agentsMdMode()).toBe('doctrine-plus-skills')
130+
expect(inlinedList()).toEqual(['stash-cli'])
131+
})
132+
133+
it('points the launch prompt at both locations', async () => {
134+
await handoffCodexStep.run(state)
135+
const body = promptBody()
136+
expect(body).toContain('.codex/skills/')
137+
expect(body).toContain('inlined in AGENTS.md')
138+
})
139+
140+
it('records the split delivery in the artifacts', async () => {
141+
await handoffCodexStep.run(state)
142+
expect(delivery()).toEqual({
143+
installed: ['stash-encryption'],
144+
inlined: ['stash-cli'],
145+
failed: [],
146+
})
147+
})
148+
})
149+
150+
// A stripped CLI build ships no skills at all. Nothing to copy AND nothing to
151+
// inline — claiming a fallback here would be a false success of the kind #714
152+
// and #687 removed elsewhere in init.
153+
describe('when this build ships no skills at all', () => {
154+
beforeEach(() => {
155+
installSkills.mockReturnValue({ copied: [], failed: [] })
156+
})
157+
158+
it('stays doctrine-only — there is nothing to inline', async () => {
159+
await handoffCodexStep.run(state)
160+
expect(agentsMdMode()).toBe('doctrine-only')
161+
})
162+
163+
it('drops the skills clause but still names AGENTS.md for the durable rules', async () => {
164+
await handoffCodexStep.run(state)
165+
const body = promptBody()
166+
expect(body).not.toContain('.codex/skills/')
167+
expect(body).not.toContain('inlined in AGENTS.md')
168+
expect(body).toContain('AGENTS.md')
169+
})
170+
171+
it('does not warn at all — there is no fallback to announce', async () => {
172+
await handoffCodexStep.run(state)
173+
expect(p.log.warn).not.toHaveBeenCalled()
174+
})
54175
})

packages/cli/src/commands/impl/steps/handoff-agents-md.ts

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
1-
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
2-
import { resolve } from 'node:path'
31
import * as p from '@clack/prompts'
42
import { buildAgentsMdBody } from '../../init/lib/build-agents-md.js'
5-
import { writeArtifacts } from '../../init/lib/handoff-helpers.js'
6-
import { upsertManagedBlock } from '../../init/lib/sentinel-upsert.js'
3+
import {
4+
AGENTS_MD_REL_PATH,
5+
writeAgentsMd,
6+
writeArtifacts,
7+
} from '../../init/lib/handoff-helpers.js'
8+
import { availableSkills } from '../../init/lib/install-skills.js'
79
import {
810
CONTEXT_REL_PATH,
911
SETUP_PROMPT_REL_PATH,
1012
} from '../../init/lib/write-context.js'
1113
import type { HandoffStep, InitState } from '../../init/types.js'
1214

13-
const AGENTS_MD_REL_PATH = 'AGENTS.md'
14-
1515
/**
1616
* Write `AGENTS.md`, `.cipherstash/context.json`, and
1717
* `.cipherstash/setup-prompt.md`, then stop.
@@ -32,19 +32,19 @@ export const handoffAgentsMdStep: HandoffStep = {
3232
const cwd = process.cwd()
3333
const integration = state.integration ?? 'postgresql'
3434

35-
const agentsMdAbs = resolve(cwd, AGENTS_MD_REL_PATH)
36-
const managed = buildAgentsMdBody(integration, 'doctrine-plus-skills')
37-
const existing = existsSync(agentsMdAbs)
38-
? readFileSync(agentsMdAbs, 'utf-8')
39-
: undefined
40-
writeFileSync(
41-
agentsMdAbs,
42-
upsertManagedBlock({ existing, managed }),
43-
'utf-8',
35+
const inlinable = availableSkills(integration)
36+
const managed = buildAgentsMdBody(
37+
integration,
38+
'doctrine-plus-skills',
39+
inlinable,
4440
)
45-
p.log.success(`Wrote ${AGENTS_MD_REL_PATH}`)
41+
const written = writeAgentsMd(cwd, managed)
4642

47-
writeArtifacts(cwd, state, 'agents-md', [])
43+
writeArtifacts(cwd, state, 'agents-md', {
44+
installed: [],
45+
inlined: written ? inlinable : [],
46+
failed: written ? [] : inlinable,
47+
})
4848

4949
p.note(
5050
[

packages/cli/src/commands/impl/steps/handoff-claude.ts

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,21 +25,37 @@ export const handoffClaudeStep: HandoffStep = {
2525
const cwd = process.cwd()
2626
const integration = state.integration ?? 'postgresql'
2727

28-
const installed = installSkills(cwd, CLAUDE_SKILLS_DIR, integration)
29-
if (installed.length > 0) {
28+
const { copied, failed } = installSkills(
29+
cwd,
30+
CLAUDE_SKILLS_DIR,
31+
integration,
32+
)
33+
if (copied.length > 0) {
3034
p.log.success(
31-
`Installed ${installed.length} skill${installed.length !== 1 ? 's' : ''} into ${CLAUDE_SKILLS_DIR}/: ${installed.join(', ')}`,
35+
`Installed ${copied.length} skill${copied.length !== 1 ? 's' : ''} into ${CLAUDE_SKILLS_DIR}/: ${copied.join(', ')}`,
36+
)
37+
}
38+
if (failed.length > 0) {
39+
// Unlike the Codex handoff there is no AGENTS.md on this path to
40+
// inline into, so the guidance is genuinely absent — say so rather
41+
// than letting the run look complete (#736 follow-up review).
42+
p.log.warn(
43+
`${failed.length} skill${failed.length !== 1 ? 's' : ''} could not be installed to ${CLAUDE_SKILLS_DIR}/: ${failed.join(', ')}. The agent will run without them — https://cipherstash.com/docs covers the same ground.`,
3244
)
3345
}
3446

35-
writeArtifacts(cwd, state, 'claude-code', installed)
47+
writeArtifacts(cwd, state, 'claude-code', {
48+
installed: copied,
49+
inlined: [],
50+
failed,
51+
})
3652

3753
const mode = state.mode ?? 'implement'
3854
// Only point the agent at the skills dir when skills were actually copied.
39-
// A stripped CLI build (no bundled skills) returns [] — claiming they're
40-
// there would send the agent to read files that don't exist.
55+
// A stripped CLI build (no bundled skills) copies nothing — claiming
56+
// they're there would send the agent to read files that don't exist.
4157
const skillsClause =
42-
installed.length > 0
58+
copied.length > 0
4359
? `The installed skills under ${CLAUDE_SKILLS_DIR}/ have the rules; `
4460
: ''
4561
const launchPrompt =

0 commit comments

Comments
 (0)