diff --git a/src/cli/commands/init.test.ts b/src/cli/commands/init.test.ts index fad29ac..1766092 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, symlinkSync, 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,100 @@ 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('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(); + expect(() => installWardenSkill(tempDir, reporter)).not.toThrow(); + + const symlinkPath = join(tempDir, '.claude', 'skills', 'warden'); + 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); + + 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..f815c12 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -1,11 +1,98 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { join, relative } from 'node:path'; +import { + cpSync, + existsSync, + mkdirSync, + readFileSync, + rmSync, + symlinkSync, + unlinkSync, + 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'); +} + + +/** + * 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'); + + cpSync(srcDir, destDir, { recursive: true, force: true }); + + // Create .claude/skills/warden symlink if .claude/ exists + const claudeDir = join(repoRoot, '.claude'); + if (existsSync(claudeDir)) { + const claudeSkillLink = join(claudeDir, 'skills', 'warden'); + mkdirSync(join(claudeDir, 'skills'), { recursive: true }); + try { + 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; + } + } + reporter.text(` ${chalk.dim(`Linked ${relative(process.cwd(), claudeSkillLink)}`)}`) + } +} + /** * Template for warden.toml configuration file. */ @@ -116,35 +203,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(); - // Ensure .warden/logs/ is in .gitignore + // --- 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'); + + 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 +267,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 +290,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; }