diff --git a/packages/agents/src/commands/__tests__/install.test.ts b/packages/agents/src/commands/__tests__/install.test.ts index 3a29e62d..a9eff743 100644 --- a/packages/agents/src/commands/__tests__/install.test.ts +++ b/packages/agents/src/commands/__tests__/install.test.ts @@ -58,6 +58,22 @@ describe(installCommand, () => { expect(manifest.harnesses.claude?.entries.length).toBeGreaterThan(0); }); + it('skips a support directory that renders to zero installable entries', async () => { + const claudeHome = await setupClaudeHome(); + // A support directory whose only content is a dotfile renders to zero entries (the renderer skips dotfiles), + // so install must skip it rather than create anything. + const emptySupportSrc = path.join(contentDir, 'skills', 'empty-support'); + await mkdir(emptySupportSrc, { recursive: true }); + await writeFile(path.join(emptySupportSrc, '.DS_Store'), ''); + + await expect(installCommand(makeOptions({ harness: 'claude' }), tempDir, contentDir)).resolves.toBeUndefined(); + + expect(existsSync(path.join(claudeHome, 'skills', 'empty-support'))).toBe(false); + const manifest = await readManifest(getManifestPath(tempDir)); + const entries = manifest.harnesses.claude?.entries ?? []; + expect(entries.some((entry) => entry.relativePath === 'skills/empty-support')).toBe(false); + }); + it('writes nothing in dry-run mode', async () => { const claudeHome = await setupClaudeHome(); diff --git a/packages/agents/src/commands/install.ts b/packages/agents/src/commands/install.ts index 7ea6462c..c632c28d 100644 --- a/packages/agents/src/commands/install.ts +++ b/packages/agents/src/commands/install.ts @@ -209,7 +209,9 @@ async function installSkills( contentDir, toolMapping, ); - entries.push(result); + if (result !== undefined) { + entries.push(result); + } } // Install harness-specific skills from _harnesses/{harnessId}/ @@ -244,7 +246,9 @@ async function installSkills( toolMapping, '(harness-specific)', ); - entries.push(result); + if (result !== undefined) { + entries.push(result); + } } return entries; @@ -286,7 +290,7 @@ async function installSkillEntry( contentDir: string, toolMapping: ReadonlyMap, label = '', -): Promise { +): Promise { // Eagerly render the skill's final body before the dry-run gate, so missing include targets, cycles, out-of-tree // references, and unmapped tool placeholders surface even when no files are written. Directory entries render the // whole tree (include expansion, tool-name + link/template rewriting) into the exact bytes the write phase emits; @@ -307,6 +311,13 @@ async function installSkillEntry( expandedFileContent = rewriteToolNames(expanded, toolMapping, relativeFromContent(contentDir, srcPath)); } + // A support directory holding only dotfiles or `_partials/` renders to zero entries — nothing to install. Skip it + // entirely: no destination, no markers, no manifest entry. The orphan-prune pass clears any previously installed copy. + if (renderedDir !== undefined && renderedDir.length === 0) { + console.info(` [skip] ${relativePath}${label ? ` ${label}` : ''} (no installable entries)`); + return undefined; + } + if (options.dryRun) { console.info(` [copy] ${relativePath}${label ? ` ${label}` : ''}`); return { relativePath, contentHash: 'dry-run', linked: false };