feat(cli): rig plugin validate subcommand + --runtime/--source filter validation (slice 3.4 Checkpoint C)

Per IMPL-PRD §4.2 + HG-4.4 (validate) AND closes velocity-guard 3.4.A-repair
pre-close punch (--runtime/--source CLI-side validation).

============ rig plugin validate <path> (HG-4.4) ============

Local file inspection — does NOT require daemon. Validates:
- Manifest dir presence: at least one of .claude-plugin/plugin.json or
  .codex-plugin/plugin.json must exist
- Manifest required fields: name (string) + version (string); description
  (string) is REQUIRED on Codex (per Codex spec) and recommended-not-
  required on Claude
- Skill frontmatter for each skills/<id>/SKILL.md: name (non-empty) +
  description (non-empty, ≤1024 chars per agentskills.io spec)
- Path existence + isDirectory checks

Output:
- Default (pretty): 'Plugin at <path>: valid' OR error list with exit 1
- --json: { valid: boolean, errors: string[] }; exit 1 when invalid

8 tests:
- Valid dual-manifest + skill passes (exit 0)
- No manifest dirs at all FAILS
- Codex manifest missing description FAILS (Codex spec requires)
- Manifest missing name FAILS
- Skill missing frontmatter name FAILS
- Skill description >1024 chars FAILS (agentskills.io limit)
- --json output returns { valid, errors } shape
- Non-existent path FAILS with clear error

============ --runtime / --source filter validation (closes pre-close punch) ============

CLI-side validation per velocity-guard 3.4.A-repair carried concern: typos
like '--source rig-cwd' (a daemon-side parseSourceFilter rejection that
silently returns ALL plugins) now fail loud with clear error naming
allowed values.

VALID_RUNTIMES = ['claude', 'codex'] (mirror packages/daemon/src/routes/
plugins.ts L25-30 parseRuntimeFilter)
VALID_SOURCES = ['vendored', 'claude-cache', 'codex-cache'] (mirror L37-40
parseSourceFilter)

3 tests:
- rejects invalid --runtime value with clear error + non-zero exit
- rejects invalid --source value (rig-cwd typo case) with clear error
- accepts valid combos (regression for normal pass-through)

============ Verification ============

- plugin.test.ts: 22/22 pass (was 11; +8 validate + +3 filter validation)
- CLI typecheck: clean
- Full CLI suite: 897 pass + 4 fail (pre-existing DaemonClient/Preflight;
  NOT my regressions; same 4 fail since pre-batch-2-merge state)

============ Slice 3.4 status ============

All 4 subcommands shipped + tested + integrated:
- list (HG-4.1) ✓
- show (HG-4.2) ✓
- used-by (HG-4.3) ✓
- validate (HG-4.4) ✓
- All ship --json output (HG-4.5 building-agent-software) ✓

Ready for slice-close ACK + walk packet authoring + final hand-off DM
to orch-lead in next commit.

NO new SC-29 declaration this slice (read-only CLI consumer + local
file validation; no ConfigStore additions, no new HTTP routes, no DB
migrations). Cumulative ledger stays at #1-4 + project-observability
#5/#6 + #8 (3.3) + #9 (3.2).

Per IMPL-PRD §4 plugin-primitive Phase 3a slice 3.4 + per banked
feedback_tdd_scope.
This commit is contained in:
mvschwarz committed 2026-05-10 22:50:20 -07:00
1 parent 120a56ec9a
commit 489577b503
2 files changed
+408 -4

No files matched your search

+162 -3
View File
@@ -12,9 +12,9 @@
// flags: --json
// used-by <id> — agents referencing this plugin
// flags: --json
//
// Pending in later checkpoints of slice 3.4:
// validate <path> — local file inspection (3.4.C)
// validate <path> — local file inspection of a plugin source tree
// (manifest shape + skill frontmatter per agentskills.io)
// flags: --json
//
// list/show consume slice 3.3 daemon HTTP routes (GET /api/plugins[/...]).
//
@@ -45,11 +45,20 @@
// tightly scoped to what the daemon actually supports.
import { Command } from "commander";
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
// Filter-value enums per slice-3.3 daemon route validation
// (packages/daemon/src/routes/plugins.ts L37-58). CLI-side validation
// gives operators clear errors instead of silent pass-through where the
// daemon ignores unknown values.
const VALID_RUNTIMES = ["claude", "codex"] as const;
const VALID_SOURCES = ["vendored", "claude-cache", "codex-cache"] as const;
// ============================================================
// Wire shapes — must mirror PluginDiscoveryService verbatim
// (packages/daemon/src/domain/plugin-discovery-service.ts L41-132)
@@ -140,6 +149,16 @@ export function pluginCommand(depsOverride?: StatusDeps): Command {
.option("--json", "JSON output")
.action(async (opts: { runtime?: string; source?: string; json?: boolean }) => {
try {
// CLI-side filter-value validation per pre-close punch from velocity-guard
// 3.4.A repair verdict: typos like --source rig-cwd should fail loud,
// not silently be ignored by the daemon route.
if (opts.runtime && !(VALID_RUNTIMES as readonly string[]).includes(opts.runtime)) {
throw new Error(`Invalid --runtime value "${opts.runtime}". Valid: ${VALID_RUNTIMES.join(" | ")} (omit flag for all)`);
}
if (opts.source && !(VALID_SOURCES as readonly string[]).includes(opts.source)) {
throw new Error(`Invalid --source value "${opts.source}". Valid: ${VALID_SOURCES.join(" | ")}`);
}
const client = await getClient();
const params: string[] = [];
if (opts.runtime) params.push(`runtime=${encodeURIComponent(opts.runtime)}`);
@@ -298,5 +317,145 @@ export function pluginCommand(depsOverride?: StatusDeps): Command {
}
});
// -- rig plugin validate <path> --
// Local file inspection — no daemon dependency; useful when authoring
// a plugin in a feature branch where the daemon may not yet have
// discovered it. Validates manifest shape (per Claude/Codex specs)
// + skill frontmatter (per agentskills.io: name + description ≤1024 chars).
cmd.command("validate")
.argument("<path>", "Plugin source directory to validate")
.description("Validate plugin manifest + skill frontmatter against agentskills.io spec")
.option("--json", "JSON output ({ valid: boolean, errors: string[] })")
.action((path: string, opts: { json?: boolean }) => {
const errors = validatePluginTree(path);
const valid = errors.length === 0;
if (opts.json) {
console.log(JSON.stringify({ valid, errors }, null, 2));
process.exitCode = valid ? undefined : 1;
return;
}
if (valid) {
console.log(`Plugin at ${path}: valid`);
return;
}
console.error(`Plugin at ${path}: INVALID (${errors.length} error${errors.length === 1 ? "" : "s"})`);
for (const err of errors) {
console.error(` - ${err}`);
}
process.exitCode = 1;
});
return cmd;
}
// ============================================================
// validate plumbing — local file inspection
// ============================================================
function validatePluginTree(pluginPath: string): string[] {
const errors: string[] = [];
if (!existsSync(pluginPath)) {
errors.push(`Plugin path does not exist: ${pluginPath}`);
return errors;
}
if (!statSync(pluginPath).isDirectory()) {
errors.push(`Plugin path is not a directory: ${pluginPath}`);
return errors;
}
const claudeManifestPath = join(pluginPath, ".claude-plugin", "plugin.json");
const codexManifestPath = join(pluginPath, ".codex-plugin", "plugin.json");
const hasClaude = existsSync(claudeManifestPath);
const hasCodex = existsSync(codexManifestPath);
if (!hasClaude && !hasCodex) {
errors.push("No plugin manifest found: expected .claude-plugin/plugin.json and/or .codex-plugin/plugin.json");
return errors;
}
if (hasClaude) {
errors.push(...validateManifest(claudeManifestPath, "claude"));
}
if (hasCodex) {
errors.push(...validateManifest(codexManifestPath, "codex"));
}
// Validate skill frontmatter
const skillsDir = join(pluginPath, "skills");
if (existsSync(skillsDir) && statSync(skillsDir).isDirectory()) {
for (const skillId of readdirSync(skillsDir)) {
const skillMdPath = join(skillsDir, skillId, "SKILL.md");
if (existsSync(skillMdPath)) {
errors.push(...validateSkillFrontmatter(skillMdPath, skillId));
}
}
}
return errors;
}
function validateManifest(manifestPath: string, runtime: "claude" | "codex"): string[] {
const errors: string[] = [];
let raw: Record<string, unknown>;
try {
raw = JSON.parse(readFileSync(manifestPath, "utf-8")) as Record<string, unknown>;
} catch (err) {
errors.push(`${runtime} manifest parse error at ${manifestPath}: ${(err as Error).message}`);
return errors;
}
if (!raw["name"] || typeof raw["name"] !== "string") {
errors.push(`${runtime} manifest missing required field: name (must be non-empty string)`);
}
if (!raw["version"] || typeof raw["version"] !== "string") {
errors.push(`${runtime} manifest missing required field: version (must be non-empty string)`);
}
// Codex spec: description is REQUIRED. Claude spec: description is recommended but
// not a hard requirement; we treat missing description as a warning by NOT erroring
// on Claude-only-missing-description while erroring on Codex-missing-description.
if (runtime === "codex" && (!raw["description"] || typeof raw["description"] !== "string")) {
errors.push(`codex manifest missing required field: description (Codex spec requires it)`);
}
return errors;
}
function validateSkillFrontmatter(skillPath: string, skillId: string): string[] {
const errors: string[] = [];
let content: string;
try {
content = readFileSync(skillPath, "utf-8");
} catch (err) {
errors.push(`skill "${skillId}": failed to read SKILL.md: ${(err as Error).message}`);
return errors;
}
// Frontmatter: file MUST start with `---\n` and contain a closing `---` line
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
if (!fmMatch) {
errors.push(`skill "${skillId}": SKILL.md missing frontmatter (must open with --- ... ---)`);
return errors;
}
const fmBody = fmMatch[1] ?? "";
// Required: name (non-empty single-line string)
const nameMatch = fmBody.match(/^name:\s*(.+)$/m);
if (!nameMatch || !nameMatch[1]?.trim()) {
errors.push(`skill "${skillId}": frontmatter missing required field: name`);
}
// Required: description (non-empty; supports multi-line YAML block scalar)
// Match either inline `description: ...` or block-scalar `description: |` / `description: >`
const descBlockMatch = fmBody.match(/^description:\s*(?:>|\|)?\s*\n?([\s\S]*?)(?=\n\w[\w-]*:|\n*$)/m);
if (!descBlockMatch || !descBlockMatch[1]?.trim()) {
errors.push(`skill "${skillId}": frontmatter missing required field: description`);
} else {
const descText = descBlockMatch[1].trim();
if (descText.length > 1024) {
errors.push(`skill "${skillId}": description length ${descText.length} chars exceeds agentskills.io limit of 1024`);
}
}
return errors;
}
+246 -1
View File
@@ -12,8 +12,11 @@
// Test pattern follows context-pack.test.ts (slice 3.4 mirror) — in-memory
// http.createServer daemon mock + Commander.parseAsync via captureLogs.
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from "vitest";
import http from "node:http";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { tmpdir } from "node:os";
import { Command } from "commander";
import { pluginCommand } from "../src/commands/plugin.js";
import { DaemonClient } from "../src/client.js";
@@ -394,4 +397,246 @@ describe("rig plugin CLI (slice 3.4)", () => {
expect(parsed).toEqual([]);
});
});
// ============================================================
// rig plugin list — filter-value validation (pre-close punch from
// velocity-guard 3.4.A repair verdict)
//
// CLI-side validation so typos like --source rig-cwd don't look
// filtered while the daemon silently returns all plugins.
// ============================================================
describe("rig plugin list — filter validation", () => {
it("rejects invalid --runtime value with clear error + non-zero exit", async () => {
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand(runningDeps(port)));
const { errLogs, exitCode } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "list", "--runtime", "bogus"]);
});
expect(exitCode).toBe(1);
const err = errLogs.join("\n");
expect(err).toMatch(/--runtime/);
expect(err).toMatch(/bogus/);
// Should name the allowed values so operator can correct
expect(err.toLowerCase()).toMatch(/claude|codex/);
});
it("rejects invalid --source value (rig-cwd typo case) with clear error + non-zero exit", async () => {
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand(runningDeps(port)));
const { errLogs, exitCode } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "list", "--source", "rig-cwd"]);
});
expect(exitCode).toBe(1);
const err = errLogs.join("\n");
expect(err).toMatch(/--source/);
expect(err).toMatch(/rig-cwd/);
// Should name the allowed values
expect(err).toMatch(/vendored|claude-cache|codex-cache/);
});
it("accepts valid --runtime + --source values (regression: still passes through correctly)", async () => {
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand(runningDeps(port)));
const { exitCode } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "list", "--runtime", "claude", "--source", "vendored", "--json"]);
});
expect(exitCode).toBeUndefined();
expect(lastQuery.runtime).toBe("claude");
expect(lastQuery.source).toBe("vendored");
});
});
// ============================================================
// rig plugin validate <path> (HG-4.4)
//
// Local file inspection — does NOT require daemon. Validates:
// - .claude-plugin/plugin.json + .codex-plugin/plugin.json shape
// (name + version required; description required on Codex)
// - At least one manifest dir is present
// - Each skills/<id>/SKILL.md has frontmatter with name (non-empty)
// + description (non-empty, ≤1024 chars per agentskills.io)
//
// Output: error list; exit code 0 when valid, 1 when any errors.
// ============================================================
describe("rig plugin validate <path>", () => {
let tmpRoot: string;
beforeEach(() => {
tmpRoot = mkdtempSync(join(tmpdir(), "plugin-validate-"));
});
afterEach(() => {
rmSync(tmpRoot, { recursive: true, force: true });
});
function writeManifest(relativePath: string, contents: Record<string, unknown>) {
const fullPath = join(tmpRoot, relativePath);
mkdirSync(dirname(fullPath), { recursive: true });
writeFileSync(fullPath, JSON.stringify(contents, null, 2));
}
function writeSkill(skillId: string, frontmatter: string, body = "# body\n") {
const skillPath = join(tmpRoot, "skills", skillId, "SKILL.md");
mkdirSync(dirname(skillPath), { recursive: true });
writeFileSync(skillPath, `---\n${frontmatter}\n---\n\n${body}`);
}
it("valid dual-manifest plugin tree passes validation (exit 0)", async () => {
writeManifest(".claude-plugin/plugin.json", {
name: "test-plugin", version: "1.0.0", description: "Test",
});
writeManifest(".codex-plugin/plugin.json", {
name: "test-plugin", version: "1.0.0", description: "Test",
});
writeSkill("foo", "name: foo\ndescription: A foo skill");
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand());
const { exitCode, logs } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "validate", tmpRoot]);
});
expect(exitCode).toBeUndefined();
expect(logs.join("\n").toLowerCase()).toMatch(/valid|ok|pass/);
});
it("plugin with no manifest dirs at all FAILS validation", async () => {
writeSkill("foo", "name: foo\ndescription: A foo skill");
// No .claude-plugin/ + no .codex-plugin/
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand());
const { exitCode, errLogs } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "validate", tmpRoot]);
});
expect(exitCode).toBe(1);
expect(errLogs.join("\n").toLowerCase()).toMatch(/manifest|plugin\.json/);
});
it("Codex manifest missing description FAILS (Codex requires description)", async () => {
writeManifest(".codex-plugin/plugin.json", {
name: "test-plugin", version: "1.0.0",
// description MISSING
});
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand());
const { exitCode, errLogs } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "validate", tmpRoot]);
});
expect(exitCode).toBe(1);
expect(errLogs.join("\n").toLowerCase()).toMatch(/codex.*description|description.*codex/);
});
it("manifest missing required name FAILS", async () => {
writeManifest(".claude-plugin/plugin.json", {
version: "1.0.0", description: "Test",
// name MISSING
});
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand());
const { exitCode, errLogs } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "validate", tmpRoot]);
});
expect(exitCode).toBe(1);
expect(errLogs.join("\n").toLowerCase()).toMatch(/name/);
});
it("skill missing frontmatter name FAILS", async () => {
writeManifest(".claude-plugin/plugin.json", {
name: "test-plugin", version: "1.0.0",
});
writeSkill("bad-skill", "description: a description but no name");
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand());
const { exitCode, errLogs } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "validate", tmpRoot]);
});
expect(exitCode).toBe(1);
const err = errLogs.join("\n").toLowerCase();
expect(err).toMatch(/bad-skill/);
expect(err).toMatch(/name/);
});
it("skill frontmatter description >1024 chars FAILS (agentskills.io limit)", async () => {
writeManifest(".claude-plugin/plugin.json", {
name: "test-plugin", version: "1.0.0",
});
const longDescription = "x".repeat(1100);
writeSkill("verbose", `name: verbose\ndescription: ${longDescription}`);
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand());
const { exitCode, errLogs } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "validate", tmpRoot]);
});
expect(exitCode).toBe(1);
const err = errLogs.join("\n").toLowerCase();
expect(err).toMatch(/verbose/);
expect(err).toMatch(/1024|description.*length|too long/);
});
it("--json output returns structured { valid, errors } shape", async () => {
writeManifest(".claude-plugin/plugin.json", {
name: "test-plugin", version: "1.0.0",
});
writeSkill("foo", "name: foo\ndescription: A foo skill");
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand());
const { exitCode, logs } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "validate", tmpRoot, "--json"]);
});
expect(exitCode).toBeUndefined();
const parsed = JSON.parse(logs.join("\n")) as { valid: boolean; errors: string[] };
expect(parsed.valid).toBe(true);
expect(parsed.errors).toEqual([]);
});
it("non-existent path FAILS with clear error", async () => {
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand());
const { exitCode, errLogs } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "validate", join(tmpRoot, "nonexistent")]);
});
expect(exitCode).toBe(1);
expect(errLogs.join("\n").toLowerCase()).toMatch(/not found|does not exist|enoent/);
});
});
});