From c483f6f4a8037845067332acbfa2b463691c8603 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Wed, 18 Feb 2026 17:34:23 -0800 Subject: [PATCH 1/9] feat(cli): Add warden skill installation to `warden init` Install the built-in warden skill during init with an interactive prompt (default yes). Copies skill files from the package into .agents/skills/warden/ and creates a .claude/skills/warden symlink when .claude/ exists. Restructures init output into sectioned layout (CONFIG, WORKFLOW, SKILL) matching the analysis flow visual style. Co-Authored-By: Claude --- src/cli/commands/init.test.ts | 77 +++++++++++++++- src/cli/commands/init.ts | 162 +++++++++++++++++++++++++++++----- 2 files changed, 214 insertions(+), 25 deletions(-) diff --git a/src/cli/commands/init.test.ts b/src/cli/commands/init.test.ts index fad29ac..0af1376 100644 --- a/src/cli/commands/init.test.ts +++ b/src/cli/commands/init.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdirSync, rmSync, existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { mkdirSync, rmSync, existsSync, readFileSync, readlinkSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { execSync } from 'node:child_process'; -import { runInit } from './init.js'; +import { runInit, installWardenSkill } from './init.js'; import { Reporter } from '../output/reporter.js'; import { detectOutputMode } from '../output/tty.js'; import { Verbosity } from '../output/verbosity.js'; @@ -137,4 +137,77 @@ describe('init command', () => { } }); }); + + describe('installWardenSkill', () => { + it('copies SKILL.md and references into .agents/skills/warden/', () => { + const reporter = createMockReporter(); + installWardenSkill(tempDir, reporter); + + expect(existsSync(join(tempDir, '.agents', 'skills', 'warden', 'SKILL.md'))).toBe(true); + expect(existsSync(join(tempDir, '.agents', 'skills', 'warden', 'references'))).toBe(true); + + // Verify references directory has files + const refs = join(tempDir, '.agents', 'skills', 'warden', 'references'); + expect(existsSync(join(refs, 'cli-reference.md'))).toBe(true); + expect(existsSync(join(refs, 'config-schema.md'))).toBe(true); + expect(existsSync(join(refs, 'configuration.md'))).toBe(true); + expect(existsSync(join(refs, 'creating-skills.md'))).toBe(true); + }); + + it('creates .claude/skills/warden symlink when .claude/ exists', () => { + mkdirSync(join(tempDir, '.claude'), { recursive: true }); + + const reporter = createMockReporter(); + installWardenSkill(tempDir, reporter); + + const symlinkPath = join(tempDir, '.claude', 'skills', 'warden'); + expect(existsSync(symlinkPath)).toBe(true); + expect(readlinkSync(symlinkPath)).toBe('../../.agents/skills/warden'); + }); + + it('does not create .claude symlink when .claude/ does not exist', () => { + const reporter = createMockReporter(); + installWardenSkill(tempDir, reporter); + + expect(existsSync(join(tempDir, '.claude', 'skills', 'warden'))).toBe(false); + }); + }); + + describe('skill install in runInit', () => { + it('skips skill when .agents/skills/warden already exists and no --force', async () => { + // Pre-create the skill directory with a marker file + const skillDir = join(tempDir, '.agents', 'skills', 'warden'); + mkdirSync(skillDir, { recursive: true }); + writeFileSync(join(skillDir, 'SKILL.md'), 'existing skill'); + + const reporter = createMockReporter(); + await runInit(createOptions(), reporter); + + // Should not overwrite + expect(readFileSync(join(skillDir, 'SKILL.md'), 'utf-8')).toBe('existing skill'); + }); + + it('overwrites skill with --force', async () => { + // Pre-create the skill directory + const skillDir = join(tempDir, '.agents', 'skills', 'warden'); + mkdirSync(skillDir, { recursive: true }); + writeFileSync(join(skillDir, 'SKILL.md'), 'existing skill'); + + const reporter = createMockReporter(); + await runInit(createOptions({ force: true }), reporter); + + // Should overwrite with real content + const content = readFileSync(join(skillDir, 'SKILL.md'), 'utf-8'); + expect(content).not.toBe('existing skill'); + expect(content.length).toBeGreaterThan(0); + }); + + it('does not install skill in non-TTY mode without --force', async () => { + // non-TTY reporter (default in test) + no --force = skill should not be installed + const reporter = createMockReporter(); + await runInit(createOptions(), reporter); + + expect(existsSync(join(tempDir, '.agents', 'skills', 'warden', 'SKILL.md'))).toBe(false); + }); + }); }); diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index f555dd1..f91a919 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -1,11 +1,104 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { join, relative } from 'node:path'; +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { dirname, join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; import chalk from 'chalk'; +import figures from 'figures'; import { getRepoRoot, getGitHubRepoUrl } from '../git.js'; +import { readSingleKey } from '../input.js'; +import { ICON_CHECK, ICON_SKIPPED } from '../output/icons.js'; import type { Reporter } from '../output/reporter.js'; import type { CLIOptions } from '../args.js'; import { getMajorVersion } from '../../utils/index.js'; +/** + * Resolve the built-in warden skill directory from the package. + */ +function getPackageSkillDir(): string { + const __dirname = dirname(fileURLToPath(import.meta.url)); + return join(__dirname, '..', '..', '..', 'plugins', 'warden', 'skills', 'warden'); +} + +/** + * Recursively copy a directory's contents. + */ +function copyDirSync(src: string, dest: string): void { + mkdirSync(dest, { recursive: true }); + for (const entry of readdirSync(src, { withFileTypes: true })) { + const srcPath = join(src, entry.name); + const destPath = join(dest, entry.name); + if (entry.isDirectory()) { + copyDirSync(srcPath, destPath); + } else { + copyFileSync(srcPath, destPath); + } + } +} + +/** + * Render a section heading in the init output. + * Matches the CONFIG/FILES section style from the analysis flow. + */ +function renderSection( + reporter: Reporter, + title: string, + filepath: string, + description: string, +): void { + if (reporter.mode.isTTY) { + reporter.text(chalk.bold(title) + chalk.dim(` ${filepath}`)); + reporter.text(` ${chalk.dim(description)}`); + } else { + reporter.text(`${title}: ${filepath}`); + reporter.text(` ${description}`); + } +} + +function renderCreated(reporter: Reporter): void { + if (reporter.mode.isTTY) { + reporter.text(` ${chalk.green(ICON_CHECK)} Created`); + } else { + reporter.text(` Created`); + } +} + +function renderSkipped(reporter: Reporter, reason: string): void { + if (reporter.mode.isTTY) { + reporter.text(` ${chalk.yellow(ICON_SKIPPED)} Skipped ${chalk.dim(`(${reason})`)}`); + } else { + reporter.text(` Skipped (${reason})`); + } +} + +/** + * Install the warden skill into .agents/skills/warden/ and optionally + * create a .claude/skills/warden symlink. + */ +export function installWardenSkill(repoRoot: string, reporter: Reporter): void { + const srcDir = getPackageSkillDir(); + const destDir = join(repoRoot, '.agents', 'skills', 'warden'); + + copyDirSync(srcDir, destDir); + + // Create .claude/skills/warden symlink if .claude/ exists + const claudeDir = join(repoRoot, '.claude'); + if (existsSync(claudeDir)) { + const claudeSkillLink = join(claudeDir, 'skills', 'warden'); + if (!existsSync(claudeSkillLink)) { + mkdirSync(join(claudeDir, 'skills'), { recursive: true }); + symlinkSync('../../.agents/skills/warden', claudeSkillLink); + reporter.text(` ${chalk.dim(`Linked ${relative(process.cwd(), claudeSkillLink)}`)}`); + } + } +} + /** * Template for warden.toml configuration file. */ @@ -116,35 +209,63 @@ export async function runInit(options: CLIOptions, reporter: Reporter): Promise< let filesCreated = 0; - // Create warden.toml + // --- CONFIG section --- const wardenTomlPath = join(repoRoot, 'warden.toml'); + renderSection(reporter, 'CONFIG', relative(cwd, wardenTomlPath), 'Severity thresholds and skill settings'); if (existing.hasWardenToml && !options.force) { - reporter.skipped(relative(cwd, wardenTomlPath), 'already exists'); + renderSkipped(reporter, 'already exists'); } else { - const content = generateWardenToml(); - writeFileSync(wardenTomlPath, content, 'utf-8'); - reporter.created(relative(cwd, wardenTomlPath)); + writeFileSync(wardenTomlPath, generateWardenToml(), 'utf-8'); + renderCreated(reporter); filesCreated++; } + reporter.blank(); - // Create .github/workflows directory if needed + // --- WORKFLOW section --- const workflowDir = join(repoRoot, '.github', 'workflows'); if (!existsSync(workflowDir)) { mkdirSync(workflowDir, { recursive: true }); } - - // Create workflow file const workflowPath = join(workflowDir, 'warden.yml'); + renderSection(reporter, 'WORKFLOW', relative(cwd, workflowPath), 'Runs Warden on pull requests via GitHub Actions'); if (existing.hasWorkflow && !options.force) { - reporter.skipped(relative(cwd, workflowPath), 'already exists'); + renderSkipped(reporter, 'already exists'); } else { - const content = generateWorkflowYaml(); - writeFileSync(workflowPath, content, 'utf-8'); - reporter.created(relative(cwd, workflowPath)); + writeFileSync(workflowPath, generateWorkflowYaml(), 'utf-8'); + renderCreated(reporter); filesCreated++; } + reporter.blank(); + + // --- SKILL section --- + const skillPath = join(repoRoot, '.agents', 'skills', 'warden'); + const skillMarker = join(skillPath, 'SKILL.md'); + renderSection(reporter, 'SKILL', relative(cwd, skillPath), 'Built-in code review skill for AI agents'); + if (existsSync(skillMarker) && !options.force) { + renderSkipped(reporter, 'already exists'); + } else if (options.force) { + installWardenSkill(repoRoot, reporter); + renderCreated(reporter); + filesCreated++; + } else if (reporter.mode.isTTY && process.stdin.isTTY) { + process.stderr.write(` ${chalk.cyan(figures.arrowRight)} Install? ${chalk.dim('[Y/n]')} `); + const key = await readSingleKey(); + process.stderr.write(key === '\r' || key === '\n' ? 'y' : key); + process.stderr.write('\n'); - // Ensure .warden/logs/ is in .gitignore + if (key !== 'n') { + installWardenSkill(repoRoot, reporter); + renderCreated(reporter); + filesCreated++; + } else { + renderSkipped(reporter, 'declined'); + } + } else { + renderSkipped(reporter, 'non-interactive'); + } + reporter.blank(); + + // Ensure .warden/logs/ is in .gitignore (silent housekeeping) const gitignorePath = join(repoRoot, '.gitignore'); if (existsSync(gitignorePath)) { const gitignoreContent = readFileSync(gitignorePath, 'utf-8'); @@ -152,27 +273,22 @@ export async function runInit(options: CLIOptions, reporter: Reporter): Promise< if (!hasEntry) { const newline = gitignoreContent.endsWith('\n') ? '' : '\n'; writeFileSync(gitignorePath, gitignoreContent + newline + '.warden/logs/\n', 'utf-8'); - reporter.created('.gitignore entry for .warden/logs/'); filesCreated++; } } else { writeFileSync(gitignorePath, '.warden/logs/\n', 'utf-8'); - reporter.created('.gitignore with .warden/logs/'); filesCreated++; } if (filesCreated === 0) { - reporter.blank(); reporter.tip('All configuration files already exist. Use --force to overwrite.'); return 0; } // Print next steps - reporter.blank(); reporter.bold('Next steps:'); - reporter.text(` 1. Add a skill: ${chalk.cyan('warden add ')}`); - reporter.text(` 2. Set ${chalk.cyan('WARDEN_ANTHROPIC_API_KEY')} in .env.local`); - reporter.text(` 3. Add ${chalk.cyan('WARDEN_ANTHROPIC_API_KEY')} to organization or repository secrets`); + reporter.text(` 1. Set ${chalk.cyan('WARDEN_ANTHROPIC_API_KEY')} in .env.local`); + reporter.text(` 2. Add ${chalk.cyan('WARDEN_ANTHROPIC_API_KEY')} to organization or repository secrets`); // Show GitHub secrets URL if available const githubUrl = getGitHubRepoUrl(repoRoot); @@ -180,7 +296,7 @@ export async function runInit(options: CLIOptions, reporter: Reporter): Promise< reporter.text(` ${chalk.dim(githubUrl + '/settings/secrets/actions')}`); } - reporter.text(' 4. Commit and open a PR to test'); + reporter.text(' 3. Commit and open a PR to test'); return 0; } From 85b14c017c7d7fccc6c3e5bcaff65c72baf1698b Mon Sep 17 00:00:00 2001 From: David Cramer Date: Wed, 18 Feb 2026 18:10:00 -0800 Subject: [PATCH 2/9] ref(cli): Replace custom copyDirSync with built-in cpSync Use Node's cpSync({ recursive: true }) instead of a hand-rolled directory copy helper. Matches the existing pattern in evals/runner.ts. Co-Authored-By: Claude --- src/cli/commands/init.ts | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index f91a919..ac2cf03 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -1,9 +1,8 @@ import { - copyFileSync, + cpSync, existsSync, mkdirSync, readFileSync, - readdirSync, symlinkSync, writeFileSync, } from 'node:fs'; @@ -26,21 +25,6 @@ function getPackageSkillDir(): string { return join(__dirname, '..', '..', '..', 'plugins', 'warden', 'skills', 'warden'); } -/** - * Recursively copy a directory's contents. - */ -function copyDirSync(src: string, dest: string): void { - mkdirSync(dest, { recursive: true }); - for (const entry of readdirSync(src, { withFileTypes: true })) { - const srcPath = join(src, entry.name); - const destPath = join(dest, entry.name); - if (entry.isDirectory()) { - copyDirSync(srcPath, destPath); - } else { - copyFileSync(srcPath, destPath); - } - } -} /** * Render a section heading in the init output. @@ -85,7 +69,7 @@ export function installWardenSkill(repoRoot: string, reporter: Reporter): void { const srcDir = getPackageSkillDir(); const destDir = join(repoRoot, '.agents', 'skills', 'warden'); - copyDirSync(srcDir, destDir); + cpSync(srcDir, destDir, { recursive: true }); // Create .claude/skills/warden symlink if .claude/ exists const claudeDir = join(repoRoot, '.claude'); From a57e7fbaa26709fcaa24254ef17b0cf53ff879c9 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Wed, 18 Feb 2026 18:20:35 -0800 Subject: [PATCH 3/9] fix(cli): Pass force option to cpSync for skill overwrite Without force: true, cpSync silently skips existing files, which means --force wouldn't actually refresh stale skill content. Co-Authored-By: Claude --- src/cli/commands/init.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index ac2cf03..fe8cb43 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -69,7 +69,7 @@ export function installWardenSkill(repoRoot: string, reporter: Reporter): void { const srcDir = getPackageSkillDir(); const destDir = join(repoRoot, '.agents', 'skills', 'warden'); - cpSync(srcDir, destDir, { recursive: true }); + cpSync(srcDir, destDir, { recursive: true, force: true }); // Create .claude/skills/warden symlink if .claude/ exists const claudeDir = join(repoRoot, '.claude'); From d3340d00d0f87f95f263781c50ae1407c69a2315 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Wed, 18 Feb 2026 18:44:00 -0800 Subject: [PATCH 4/9] fix(init): Handle broken symlinks at .claude/skills/warden existsSync follows symlinks and returns false for broken symlinks, but symlinkSync throws EEXIST if the symlink file itself exists. Use lstatSync to detect any symlink (broken or valid) before attempting to create one. Co-Authored-By: Claude Opus 4.6 --- src/cli/commands/init.test.ts | 12 +++++++++++- src/cli/commands/init.ts | 13 ++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/cli/commands/init.test.ts b/src/cli/commands/init.test.ts index 0af1376..f477365 100644 --- a/src/cli/commands/init.test.ts +++ b/src/cli/commands/init.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdirSync, rmSync, existsSync, readFileSync, readlinkSync, writeFileSync } from 'node:fs'; +import { mkdirSync, rmSync, existsSync, readFileSync, readlinkSync, symlinkSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { execSync } from 'node:child_process'; @@ -165,6 +165,16 @@ describe('init command', () => { expect(readlinkSync(symlinkPath)).toBe('../../.agents/skills/warden'); }); + it('does not crash when a broken symlink exists at .claude/skills/warden', () => { + mkdirSync(join(tempDir, '.claude', 'skills'), { recursive: true }); + // Create a symlink pointing to a non-existent target (broken symlink) + symlinkSync('../../nonexistent', join(tempDir, '.claude', 'skills', 'warden')); + + const reporter = createMockReporter(); + // Should not throw EEXIST + expect(() => installWardenSkill(tempDir, reporter)).not.toThrow(); + }); + it('does not create .claude symlink when .claude/ does not exist', () => { const reporter = createMockReporter(); installWardenSkill(tempDir, reporter); diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index fe8cb43..8041f22 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -1,6 +1,7 @@ import { cpSync, existsSync, + lstatSync, mkdirSync, readFileSync, symlinkSync, @@ -75,7 +76,17 @@ export function installWardenSkill(repoRoot: string, reporter: Reporter): void { const claudeDir = join(repoRoot, '.claude'); if (existsSync(claudeDir)) { const claudeSkillLink = join(claudeDir, 'skills', 'warden'); - if (!existsSync(claudeSkillLink)) { + // existsSync follows symlinks — a broken symlink returns false but + // symlinkSync still throws EEXIST. Use lstatSync to detect any + // symlink (broken or valid) and remove it before recreating. + let linkExists = false; + try { + lstatSync(claudeSkillLink); + linkExists = true; + } catch { + // No file or symlink at path + } + if (!linkExists) { mkdirSync(join(claudeDir, 'skills'), { recursive: true }); symlinkSync('../../.agents/skills/warden', claudeSkillLink); reporter.text(` ${chalk.dim(`Linked ${relative(process.cwd(), claudeSkillLink)}`)}`); From 5c3148de570357e6be60f700f96548c67a24bc60 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Wed, 18 Feb 2026 18:49:50 -0800 Subject: [PATCH 5/9] fix(init): Remove and recreate stale/broken symlinks Instead of just detecting broken symlinks to avoid EEXIST, actively remove any existing symlink (broken or valid) and recreate it. Since installWardenSkill is only called when the user wants installation (--force or interactive confirm), a fresh symlink is always correct. Co-Authored-By: Claude Opus 4.6 --- src/cli/commands/init.test.ts | 6 ++++-- src/cli/commands/init.ts | 12 +++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/cli/commands/init.test.ts b/src/cli/commands/init.test.ts index f477365..e269e72 100644 --- a/src/cli/commands/init.test.ts +++ b/src/cli/commands/init.test.ts @@ -165,14 +165,16 @@ describe('init command', () => { expect(readlinkSync(symlinkPath)).toBe('../../.agents/skills/warden'); }); - it('does not crash when a broken symlink exists at .claude/skills/warden', () => { + it('replaces a broken symlink at .claude/skills/warden', () => { mkdirSync(join(tempDir, '.claude', 'skills'), { recursive: true }); // Create a symlink pointing to a non-existent target (broken symlink) symlinkSync('../../nonexistent', join(tempDir, '.claude', 'skills', 'warden')); const reporter = createMockReporter(); - // Should not throw EEXIST expect(() => installWardenSkill(tempDir, reporter)).not.toThrow(); + + const symlinkPath = join(tempDir, '.claude', 'skills', 'warden'); + expect(readlinkSync(symlinkPath)).toBe('../../.agents/skills/warden'); }); it('does not create .claude symlink when .claude/ does not exist', () => { diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index 8041f22..b34be80 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -5,6 +5,7 @@ import { mkdirSync, readFileSync, symlinkSync, + unlinkSync, writeFileSync, } from 'node:fs'; import { dirname, join, relative } from 'node:path'; @@ -79,18 +80,15 @@ export function installWardenSkill(repoRoot: string, reporter: Reporter): void { // existsSync follows symlinks — a broken symlink returns false but // symlinkSync still throws EEXIST. Use lstatSync to detect any // symlink (broken or valid) and remove it before recreating. - let linkExists = false; try { lstatSync(claudeSkillLink); - linkExists = true; + unlinkSync(claudeSkillLink); } catch { // No file or symlink at path } - if (!linkExists) { - mkdirSync(join(claudeDir, 'skills'), { recursive: true }); - symlinkSync('../../.agents/skills/warden', claudeSkillLink); - reporter.text(` ${chalk.dim(`Linked ${relative(process.cwd(), claudeSkillLink)}`)}`); - } + mkdirSync(join(claudeDir, 'skills'), { recursive: true }); + symlinkSync('../../.agents/skills/warden', claudeSkillLink); + reporter.text(` ${chalk.dim(`Linked ${relative(process.cwd(), claudeSkillLink)}`)}`) } } From 42b1e1f9d860bb87a3c14245fe5e599f1eefdb53 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Wed, 18 Feb 2026 19:06:18 -0800 Subject: [PATCH 6/9] fix(init): Use rmSync to handle directories at symlink path unlinkSync throws EISDIR for directories, causing the catch to swallow the error and symlinkSync to crash with EEXIST. Use rmSync with recursive+force to handle files, symlinks, and directories uniformly. Co-Authored-By: Claude Opus 4.6 --- src/cli/commands/init.test.ts | 11 +++++++++++ src/cli/commands/init.ts | 10 +++++----- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/cli/commands/init.test.ts b/src/cli/commands/init.test.ts index e269e72..1766092 100644 --- a/src/cli/commands/init.test.ts +++ b/src/cli/commands/init.test.ts @@ -177,6 +177,17 @@ describe('init command', () => { expect(readlinkSync(symlinkPath)).toBe('../../.agents/skills/warden'); }); + it('replaces an existing directory at .claude/skills/warden with a symlink', () => { + mkdirSync(join(tempDir, '.claude', 'skills', 'warden'), { recursive: true }); + writeFileSync(join(tempDir, '.claude', 'skills', 'warden', 'old-file.txt'), 'stale'); + + const reporter = createMockReporter(); + expect(() => installWardenSkill(tempDir, reporter)).not.toThrow(); + + const symlinkPath = join(tempDir, '.claude', 'skills', 'warden'); + expect(readlinkSync(symlinkPath)).toBe('../../.agents/skills/warden'); + }); + it('does not create .claude symlink when .claude/ does not exist', () => { const reporter = createMockReporter(); installWardenSkill(tempDir, reporter); diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index b34be80..bcb5e90 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -4,8 +4,8 @@ import { lstatSync, mkdirSync, readFileSync, + rmSync, symlinkSync, - unlinkSync, writeFileSync, } from 'node:fs'; import { dirname, join, relative } from 'node:path'; @@ -77,12 +77,12 @@ export function installWardenSkill(repoRoot: string, reporter: Reporter): void { const claudeDir = join(repoRoot, '.claude'); if (existsSync(claudeDir)) { const claudeSkillLink = join(claudeDir, 'skills', 'warden'); - // existsSync follows symlinks — a broken symlink returns false but - // symlinkSync still throws EEXIST. Use lstatSync to detect any - // symlink (broken or valid) and remove it before recreating. + // Remove any existing file, symlink, or directory at the path. + // existsSync follows symlinks so it misses broken ones; lstatSync + // detects any entry. rmSync handles files, symlinks, and directories. try { lstatSync(claudeSkillLink); - unlinkSync(claudeSkillLink); + rmSync(claudeSkillLink, { recursive: true, force: true }); } catch { // No file or symlink at path } From b083cebcc63ee3517f2de3e3fa4e31bc6de5cbd1 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Wed, 18 Feb 2026 19:13:08 -0800 Subject: [PATCH 7/9] fix(init): Simplify symlink cleanup with unconditional rmSync Replace lstatSync+rmSync guard with a single rmSync call. force:true already handles missing paths silently, making the lstatSync check unnecessary and avoiding behavior differences across Node.js versions. Co-Authored-By: Claude Opus 4.6 --- src/cli/commands/init.ts | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index bcb5e90..89bdcbf 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -1,7 +1,6 @@ import { cpSync, existsSync, - lstatSync, mkdirSync, readFileSync, rmSync, @@ -77,16 +76,10 @@ export function installWardenSkill(repoRoot: string, reporter: Reporter): void { const claudeDir = join(repoRoot, '.claude'); if (existsSync(claudeDir)) { const claudeSkillLink = join(claudeDir, 'skills', 'warden'); - // Remove any existing file, symlink, or directory at the path. - // existsSync follows symlinks so it misses broken ones; lstatSync - // detects any entry. rmSync handles files, symlinks, and directories. - try { - lstatSync(claudeSkillLink); - rmSync(claudeSkillLink, { recursive: true, force: true }); - } catch { - // No file or symlink at path - } + // Clear any existing entry (file, symlink, or directory) before + // creating the symlink. force:true silently handles missing paths. mkdirSync(join(claudeDir, 'skills'), { recursive: true }); + rmSync(claudeSkillLink, { recursive: true, force: true }); symlinkSync('../../.agents/skills/warden', claudeSkillLink); reporter.text(` ${chalk.dim(`Linked ${relative(process.cwd(), claudeSkillLink)}`)}`) } From d7594f346a7b0093f4d39bb7763f8c5fbc17160e Mon Sep 17 00:00:00 2001 From: David Cramer Date: Wed, 18 Feb 2026 19:26:09 -0800 Subject: [PATCH 8/9] fix(init): Split rmSync calls for cross-version symlink compat Use non-recursive rmSync first to handle files and symlinks (including broken ones), then fall back to recursive rmSync for directories. Fixes potential behavior difference with recursive rmSync on broken symlinks across Node.js versions. Co-Authored-By: Claude Opus 4.6 --- src/cli/commands/init.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index 89bdcbf..e5333e1 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -76,10 +76,15 @@ export function installWardenSkill(repoRoot: string, reporter: Reporter): void { const claudeDir = join(repoRoot, '.claude'); if (existsSync(claudeDir)) { const claudeSkillLink = join(claudeDir, 'skills', 'warden'); - // Clear any existing entry (file, symlink, or directory) before - // creating the symlink. force:true silently handles missing paths. mkdirSync(join(claudeDir, 'skills'), { recursive: true }); - rmSync(claudeSkillLink, { recursive: true, force: true }); + // Clear any existing entry (file, symlink, or directory). + // Use non-recursive rm first (handles files/symlinks including + // broken ones), fall back to recursive for directories. + try { + rmSync(claudeSkillLink, { force: true }); + } catch { + rmSync(claudeSkillLink, { recursive: true, force: true }); + } symlinkSync('../../.agents/skills/warden', claudeSkillLink); reporter.text(` ${chalk.dim(`Linked ${relative(process.cwd(), claudeSkillLink)}`)}`) } From d3b53ae7e279468e7e9c42da6683e6cedc7adf90 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Wed, 18 Feb 2026 19:34:44 -0800 Subject: [PATCH 9/9] fix(init): Use try-catch-retry pattern for symlink creation rmSync with force:true may silently skip broken symlinks because it internally uses stat (which follows symlinks) to check existence. Instead, attempt symlinkSync first and handle EEXIST by removing the existing entry (unlinkSync for files/symlinks, rmSync for dirs) then retrying. Co-Authored-By: Claude Opus 4.6 --- src/cli/commands/init.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index e5333e1..f815c12 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -5,6 +5,7 @@ import { readFileSync, rmSync, symlinkSync, + unlinkSync, writeFileSync, } from 'node:fs'; import { dirname, join, relative } from 'node:path'; @@ -77,15 +78,17 @@ export function installWardenSkill(repoRoot: string, reporter: Reporter): void { if (existsSync(claudeDir)) { const claudeSkillLink = join(claudeDir, 'skills', 'warden'); mkdirSync(join(claudeDir, 'skills'), { recursive: true }); - // Clear any existing entry (file, symlink, or directory). - // Use non-recursive rm first (handles files/symlinks including - // broken ones), fall back to recursive for directories. try { - rmSync(claudeSkillLink, { force: true }); - } catch { - rmSync(claudeSkillLink, { recursive: true, force: true }); + symlinkSync('../../.agents/skills/warden', claudeSkillLink); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === 'EEXIST') { + // Path occupied by a file, broken symlink, or directory — clear and retry + try { unlinkSync(claudeSkillLink); } catch { rmSync(claudeSkillLink, { recursive: true }); } + symlinkSync('../../.agents/skills/warden', claudeSkillLink); + } else { + throw err; + } } - symlinkSync('../../.agents/skills/warden', claudeSkillLink); reporter.text(` ${chalk.dim(`Linked ${relative(process.cwd(), claudeSkillLink)}`)}`) } }