Upgrade CLI to v0.2.0: add validate, list commands, --version, --dry-run, --backup flags

- Fix npm test glob pattern for Node >= 21 compatibility
- Add --version/-v flag to display package version
- Add validate command: run 78 profile checks (files, skills, frontmatter, legacy patterns, AGENTS mapping)
- Add list command: display all 13 bundled skills with descriptions
- Add --dry-run/-n flag for init: preview files without copying
- Add --backup/-b flag for init --force: backup existing .agent before overwrite
- Add name field to workflow frontmatter (brainstorm, execute-plan, write-plan)
- Expand smoke check to cover all 13 skills and new command files
- Increase test coverage from 3 to 20 tests (cli, init, validate)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 01:25:28 +07:00
parent 5325629a81
commit 9f1d6eb880
12 changed files with 627 additions and 15 deletions

View File

@@ -1,18 +1,33 @@
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { initCommand } from "./commands/init.js";
import { validateCommand } from "./commands/validate.js";
import { listCommand } from "./commands/list.js";
async function getVersion() {
const pkgPath = fileURLToPath(new URL("../package.json", import.meta.url));
const pkg = JSON.parse(await readFile(pkgPath, "utf8"));
return pkg.version;
}
function helpText() {
return [
"antigravity-superpowers",
"",
"Usage:",
" antigravity-superpowers init [target-directory] [--force]",
" antigravity-superpowers <command> [options]",
"",
"Commands:",
" init Initialize .agent profile in a project",
" init [dir] [--force] [--backup] [--dry-run] Initialize .agent profile in a project",
" validate [dir] Validate .agent profile in a project",
" list List available skills",
"",
"Options:",
" -f, --force Overwrite existing .agent directory",
" -h, --help Show help",
" -f, --force Overwrite existing .agent directory",
" -b, --backup Backup existing .agent before overwrite (use with --force)",
" -n, --dry-run Preview files that would be copied",
" -v, --version Show version",
" -h, --help Show help",
].join("\n");
}
@@ -24,6 +39,12 @@ export async function runCli(args, io = process) {
return 0;
}
if (command === "-v" || command === "--version") {
const version = await getVersion();
io.stdout.write(`${version}\n`);
return 0;
}
if (command === "init") {
return initCommand(rest, {
cwd: io.cwd?.() ?? process.cwd(),
@@ -32,6 +53,21 @@ export async function runCli(args, io = process) {
});
}
if (command === "validate") {
return validateCommand(rest, {
cwd: io.cwd?.() ?? process.cwd(),
stdout: io.stdout,
stderr: io.stderr,
});
}
if (command === "list") {
return listCommand({
stdout: io.stdout,
stderr: io.stderr,
});
}
io.stderr.write(`Unknown command: ${command}\n\n${helpText()}\n`);
return 1;
}

View File

@@ -1,7 +1,7 @@
import { access, cp, rm, stat } from "node:fs/promises";
import { access, cp, readdir, rename, rm, stat } from "node:fs/promises";
import { constants as fsConstants } from "node:fs";
import { fileURLToPath } from "node:url";
import { join, resolve } from "node:path";
import { join, relative, resolve } from "node:path";
function getTemplateDir() {
return fileURLToPath(new URL("../../templates/.agent", import.meta.url));
@@ -20,6 +20,8 @@ function parseInitArgs(args) {
const parsed = {
target: ".",
force: false,
backup: false,
dryRun: false,
};
let targetSet = false;
@@ -29,6 +31,16 @@ function parseInitArgs(args) {
continue;
}
if (arg === "--backup" || arg === "-b") {
parsed.backup = true;
continue;
}
if (arg === "--dry-run" || arg === "-n") {
parsed.dryRun = true;
continue;
}
if (arg.startsWith("-")) {
throw new Error(`Unknown option for init: ${arg}`);
}
@@ -57,6 +69,20 @@ async function validateTargetDir(targetDir) {
}
}
async function collectFiles(dir, base = dir) {
const entries = await readdir(dir, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...(await collectFiles(fullPath, base)));
} else {
files.push(relative(base, fullPath));
}
}
return files.sort();
}
export async function initCommand(args, { cwd, stdout, stderr }) {
let parsed;
try {
@@ -82,6 +108,20 @@ export async function initCommand(args, { cwd, stdout, stderr }) {
}
const agentExists = await exists(agentDir);
if (parsed.dryRun) {
const files = await collectFiles(templateDir);
stdout.write(`Dry run: the following files would be copied to ${agentDir}/\n\n`);
for (const file of files) {
stdout.write(` .agent/${file}\n`);
}
stdout.write(`\nTotal: ${files.length} files\n`);
if (agentExists && !parsed.force) {
stdout.write(`\nNote: .agent already exists. Use --force to replace it.\n`);
}
return 0;
}
if (agentExists && !parsed.force) {
stderr.write(
`.agent already exists at ${agentDir}. Re-run with --force to replace it.\n`,
@@ -90,13 +130,20 @@ export async function initCommand(args, { cwd, stdout, stderr }) {
}
if (agentExists && parsed.force) {
await rm(agentDir, { recursive: true, force: true });
if (parsed.backup) {
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
const backupDir = join(targetDir, `.agent-backup-${timestamp}`);
await rename(agentDir, backupDir);
stdout.write(`Backed up existing .agent to ${backupDir}\n`);
} else {
await rm(agentDir, { recursive: true, force: true });
}
}
await cp(templateDir, agentDir, { recursive: true });
stdout.write(`Initialized Antigravity Superpowers profile at ${agentDir}\n`);
stdout.write("Next step: bash .agent/tests/run-tests.sh\n");
stdout.write("Next step: antigravity-superpowers validate\n");
stdout.write(
"Note: docs/plans/task.md is created at runtime by skills when task tracking starts.\n",
);

79
src/commands/list.js Normal file
View File

@@ -0,0 +1,79 @@
import { readdir, readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { join } from "node:path";
function getSkillsDir() {
return fileURLToPath(new URL("../../templates/.agent/skills", import.meta.url));
}
function parseFrontmatter(content) {
const match = content.match(/^---\n([\s\S]*?)\n---/);
if (!match) return {};
const fm = {};
const lines = match[1].split("\n");
let currentKey = null;
let currentValue = "";
for (const line of lines) {
const keyMatch = line.match(/^(\w+):\s*(.*)$/);
if (keyMatch) {
if (currentKey) {
fm[currentKey] = currentValue.trim();
}
currentKey = keyMatch[1];
currentValue = keyMatch[2].replace(/^["']|["']$/g, "");
} else if (currentKey) {
currentValue += " " + line.trim();
}
}
if (currentKey) {
fm[currentKey] = currentValue.trim();
}
return fm;
}
export async function listCommand({ stdout, stderr }) {
const skillsDir = getSkillsDir();
let entries;
try {
entries = await readdir(skillsDir, { withFileTypes: true });
} catch {
stderr.write("Could not read bundled skills directory.\n");
return 1;
}
const skills = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const skillFile = join(skillsDir, entry.name, "SKILL.md");
try {
const content = await readFile(skillFile, "utf8");
const fm = parseFrontmatter(content);
skills.push({
name: fm.name || entry.name,
description: fm.description || "(no description)",
});
} catch {
skills.push({ name: entry.name, description: "(SKILL.md not found)" });
}
}
skills.sort((a, b) => a.name.localeCompare(b.name));
stdout.write(`Antigravity Superpowers — ${skills.length} skills available:\n\n`);
const maxName = Math.max(...skills.map((s) => s.name.length));
for (const skill of skills) {
const desc = skill.description.length > 80
? skill.description.slice(0, 77) + "..."
: skill.description;
stdout.write(` ${skill.name.padEnd(maxName + 2)} ${desc}\n`);
}
stdout.write("\n");
return 0;
}

209
src/commands/validate.js Normal file
View File

@@ -0,0 +1,209 @@
import { access, readdir, readFile } from "node:fs/promises";
import { constants as fsConstants } from "node:fs";
import { join, resolve } from "node:path";
async function exists(path) {
try {
await access(path, fsConstants.F_OK);
return true;
} catch {
return false;
}
}
function parseValidateArgs(args) {
const parsed = { target: "." };
let targetSet = false;
for (const arg of args) {
if (arg.startsWith("-")) {
throw new Error(`Unknown option for validate: ${arg}`);
}
if (targetSet) {
throw new Error("Too many positional arguments. Only one target directory is supported.");
}
parsed.target = arg;
targetSet = true;
}
return parsed;
}
const REQUIRED_FILES = [
"AGENTS.md",
"INSTALL.md",
"task.md",
"workflows/brainstorm.md",
"workflows/write-plan.md",
"workflows/execute-plan.md",
"agents/code-reviewer.md",
"tests/run-tests.sh",
"tests/check-antigravity-profile.sh",
];
const REQUIRED_SKILLS = [
"brainstorming",
"executing-plans",
"finishing-a-development-branch",
"receiving-code-review",
"requesting-code-review",
"systematic-debugging",
"test-driven-development",
"using-git-worktrees",
"using-superpowers",
"verification-before-completion",
"writing-plans",
"writing-skills",
"single-flow-task-execution",
];
const LEGACY_PATTERNS = [
"Skill tool",
"Task tool with",
'Task\\("',
"Dispatch implementer subagent",
"Dispatch code-reviewer subagent",
"Create TodoWrite",
"Mark task complete in TodoWrite",
"Use TodoWrite",
"superpowers:",
];
const AGENTS_MAPPINGS = [
"task_boundary",
"browser_subagent",
"view_file",
"docs/plans/task.md",
"run_command",
"grep_search",
"find_by_name",
];
export async function validateCommand(args, { cwd, stdout, stderr }) {
let parsed;
try {
parsed = parseValidateArgs(args);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
stderr.write(`${message}\n`);
return 1;
}
const targetDir = resolve(cwd, parsed.target);
const agentDir = join(targetDir, ".agent");
if (!(await exists(agentDir))) {
stderr.write(`No .agent directory found at ${agentDir}\nRun 'antigravity-superpowers init' first.\n`);
return 1;
}
let passCount = 0;
let failCount = 0;
function pass(msg) {
stdout.write(` [PASS] ${msg}\n`);
passCount++;
}
function fail(msg) {
stdout.write(` [FAIL] ${msg}\n`);
failCount++;
}
stdout.write("Checking required files...\n");
for (const file of REQUIRED_FILES) {
const filePath = join(agentDir, file);
if (await exists(filePath)) {
pass(`File exists: .agent/${file}`);
} else {
fail(`Missing file: .agent/${file}`);
}
}
stdout.write("\nChecking required skills...\n");
for (const skill of REQUIRED_SKILLS) {
const skillPath = join(agentDir, "skills", skill, "SKILL.md");
if (await exists(skillPath)) {
pass(`Skill exists: ${skill}`);
} else {
fail(`Missing skill: ${skill}`);
}
}
stdout.write("\nChecking frontmatter...\n");
for (const skill of REQUIRED_SKILLS) {
const skillPath = join(agentDir, "skills", skill, "SKILL.md");
if (!(await exists(skillPath))) continue;
const content = await readFile(skillPath, "utf8");
const hasDelimiters = /^---$/m.test(content);
const hasName = /^name:\s*\S/m.test(content);
const hasDescription = /^description:\s*\S/m.test(content);
if (hasDelimiters) pass(`${skill} has frontmatter`);
else fail(`${skill} missing frontmatter delimiters`);
if (hasName) pass(`${skill} has name`);
else fail(`${skill} missing name field`);
if (hasDescription) pass(`${skill} has description`);
else fail(`${skill} missing description field`);
}
stdout.write("\nChecking for legacy patterns...\n");
const skillsDir = join(agentDir, "skills");
let allSkillContent = "";
if (await exists(skillsDir)) {
const skills = await readdir(skillsDir, { withFileTypes: true });
for (const entry of skills) {
if (!entry.isDirectory()) continue;
const skillFile = join(skillsDir, entry.name, "SKILL.md");
if (await exists(skillFile)) {
allSkillContent += await readFile(skillFile, "utf8");
}
}
}
for (const pattern of LEGACY_PATTERNS) {
const regex = new RegExp(pattern);
if (regex.test(allSkillContent)) {
fail(`Legacy pattern found: ${pattern}`);
} else {
pass(`Legacy pattern absent: ${pattern}`);
}
}
stdout.write("\nChecking AGENTS mapping contract...\n");
const agentsPath = join(agentDir, "AGENTS.md");
if (await exists(agentsPath)) {
const agentsContent = await readFile(agentsPath, "utf8");
for (const mapping of AGENTS_MAPPINGS) {
if (agentsContent.includes(mapping)) {
pass(`AGENTS includes: ${mapping}`);
} else {
fail(`AGENTS missing: ${mapping}`);
}
}
} else {
fail("AGENTS.md not found");
}
// Check runtime tracker is NOT bundled
const runtimeTracker = join(targetDir, "docs", "plans", "task.md");
if (!(await exists(runtimeTracker))) {
pass("Runtime tracker not bundled (created at runtime)");
} else {
// It's OK if user created it during runtime, don't fail
pass("Runtime tracker exists (created by skill flow)");
}
stdout.write(`\n Passed: ${passCount}\n Failed: ${failCount}\n\n`);
if (failCount > 0) {
stdout.write("STATUS: FAILED\n");
return 1;
}
stdout.write("STATUS: PASSED\n");
return 0;
}