Skip to content
100 changes: 98 additions & 2 deletions src/cli/commands/init.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
});
});
});
156 changes: 133 additions & 23 deletions src/cli/commands/init.ts
Original file line number Diff line number Diff line change
@@ -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.
*/
Expand Down Expand Up @@ -116,71 +203,94 @@ 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') {
Copy link

Choose a reason for hiding this comment

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

Uppercase 'N' input not treated as decline

Medium Severity

The interactive skill install prompt checks key !== 'n' (lowercase only), so typing 'N' is not recognized as a decline and will proceed with installation. The [Y/n] prompt convention implies both cases of n mean "no." The check needs to be case-insensitive, e.g. comparing against key.toLowerCase().

Fix in Cursor Fix in Web

Copy link
Member Author

Choose a reason for hiding this comment

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

False positive — readSingleKey() in src/cli/input.ts:24 already calls key.toLowerCase() before returning, so uppercase N is converted to n before the check.

Copy link
Member Author

Choose a reason for hiding this comment

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

False positive. readSingleKey() in src/cli/input.ts:24 already lowercases all input via resolve(key.toLowerCase()) before returning. The comparison against "n" is guaranteed to be case-insensitive.

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');
const hasEntry = gitignoreContent.split('\n').some((line) => line.trim() === '.warden/logs/');
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 <skill-name>')}`);
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`);
Copy link

Choose a reason for hiding this comment

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

Misleading tip when skill skipped in non-interactive mode

Low Severity

When the skill doesn't exist but is skipped due to non-interactive mode (or user declining), filesCreated stays at 0 without the skill being installed. The filesCreated === 0 check then shows "All configuration files already exist" — which is factually wrong since the skill was never installed. This is a likely upgrade scenario: users with pre-existing config/workflow but no skill directory running warden init in CI get told everything is set up when it isn't.

Additional Locations (1)

Fix in Cursor Fix in Web

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);
if (githubUrl) {
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;
}