-
-
Notifications
You must be signed in to change notification settings - Fork 6
feat(cli): Add warden skill installation to warden init
#182
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
c483f6f
85b14c0
a57e7fb
d3340d0
5c3148d
42b1e1f
b083ceb
d7594f3
d3b53ae
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. | ||
| */ | ||
|
|
@@ -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'); | ||
sentry[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| if (key !== 'n') { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Uppercase 'N' input not treated as declineMedium Severity The interactive skill install prompt checks
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. False positive —
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. False positive. |
||
| 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`); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Misleading tip when skill skipped in non-interactive modeLow Severity When the skill doesn't exist but is skipped due to non-interactive mode (or user declining), Additional Locations (1) |
||
| 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; | ||
| } | ||


Uh oh!
There was an error while loading. Please reload this page.