fix(cli): warn on unrecognized --kind in rig specs commands (#2365)

`rig specs ls`, `show` and `preview` accept any --kind. For a value other than rig, agent or workflow, the command prints one stderr warning naming the supported kinds, still queries the daemon with the value URL-encoded, and keeps only the entries whose kind matches exactly. A known kind trusts the daemon's filtered list as before, so `rig up`'s library lookup is unchanged. When a --kind filter matches nothing, `specs ls` prints "No <kind> specs found" instead of the empty-library message. The warning goes only to stderr, so stdout and --json stay parseable.

Command +35/-10, tests +105/-1.
This commit is contained in:
Tingri Lohar Mohammed Mohsin authored and GitHub committed 2026-11-01 20:29:57 -08:00
1 parent 22b574983e
commit 085d59248f
2 files changed
+140 -11

No files matched your search

+35 -10
View File
@@ -20,6 +20,14 @@ import { daemonMayAnswer } from "../daemon-reachability.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
const KNOWN_SPEC_KINDS = ["rig", "agent", "workflow"] as const;
function warnIfUnknownKind(kind?: string): void {
if (kind !== undefined && !KNOWN_SPEC_KINDS.includes(kind as (typeof KNOWN_SPEC_KINDS)[number])) {
console.error(`Warning: unrecognized --kind '${kind}'. Supported kinds: ${KNOWN_SPEC_KINDS.join(", ")}`);
}
}
interface LibraryEntry {
id: string;
kind: "rig" | "agent" | "workflow";
@@ -147,10 +155,16 @@ function installSpecSource(source: AddSpecSource, userRoot: string): string {
export async function resolveLibrarySpec(
client: DaemonClient,
nameOrId: string,
opts?: { kind?: LibraryEntry["kind"] },
opts?: { kind?: string },
): Promise<LibraryEntry> {
const res = await client.get<LibraryEntry[]>(opts?.kind ? `/api/specs/library?kind=${opts.kind}` : "/api/specs/library");
const entries = res.data ?? [];
const res = await client.get<LibraryEntry[]>(
opts?.kind !== undefined ? `/api/specs/library?kind=${encodeURIComponent(opts.kind)}` : "/api/specs/library",
);
const rawEntries = res.data ?? [];
const entries =
opts?.kind !== undefined && !KNOWN_SPEC_KINDS.includes(opts.kind as (typeof KNOWN_SPEC_KINDS)[number])
? rawEntries.filter((e) => e.kind === opts.kind)
: rawEntries;
// Try exact ID match first
const byId = entries.find((e) => e.id === nameOrId);
@@ -219,11 +233,16 @@ Examples:
.option("--json", "JSON output")
.action(async (opts: { kind?: string; json?: boolean }) => {
try {
warnIfUnknownKind(opts.kind);
const client = await getClient();
const url = opts.kind ? `/api/specs/library?kind=${opts.kind}` : "/api/specs/library";
const url = opts.kind !== undefined ? `/api/specs/library?kind=${encodeURIComponent(opts.kind)}` : "/api/specs/library";
const res = await client.get<LibraryEntry[]>(url);
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`);
const entries = res.data ?? [];
const rawEntries = res.data ?? [];
const entries =
opts.kind !== undefined && !KNOWN_SPEC_KINDS.includes(opts.kind as (typeof KNOWN_SPEC_KINDS)[number])
? rawEntries.filter((e) => e.kind === opts.kind)
: rawEntries;
if (opts.json) {
console.log(JSON.stringify(entries, null, 2));
@@ -231,7 +250,11 @@ Examples:
}
if (entries.length === 0) {
console.log("No specs in library. Add specs with: rig specs add <path>");
if (opts.kind !== undefined) {
console.log(opts.kind ? `No ${opts.kind} specs found` : "No specs found");
} else {
console.log("No specs in library. Add specs with: rig specs add <path>");
}
return;
}
@@ -250,10 +273,11 @@ Examples:
.description("Show spec metadata and path")
.option("--kind <kind>", "Disambiguate by kind (rig, agent, or workflow)")
.option("--json", "JSON output")
.action(async (nameOrId: string, opts: { kind?: LibraryEntry["kind"]; json?: boolean }) => {
.action(async (nameOrId: string, opts: { kind?: string; json?: boolean }) => {
try {
warnIfUnknownKind(opts.kind);
const client = await getClient();
const entry = await resolveLibrarySpec(client, nameOrId, opts.kind ? { kind: opts.kind } : undefined);
const entry = await resolveLibrarySpec(client, nameOrId, opts.kind !== undefined ? { kind: opts.kind } : undefined);
if (opts.json) {
console.log(JSON.stringify(entry, null, 2));
@@ -278,10 +302,11 @@ Examples:
.description("Show structured spec review, including managed app details")
.option("--kind <kind>", "Disambiguate by kind (rig, agent, or workflow)")
.option("--json", "JSON output")
.action(async (nameOrId: string, opts: { kind?: LibraryEntry["kind"]; json?: boolean }) => {
.action(async (nameOrId: string, opts: { kind?: string; json?: boolean }) => {
try {
warnIfUnknownKind(opts.kind);
const client = await getClient();
const entry = await resolveLibrarySpec(client, nameOrId, opts.kind ? { kind: opts.kind } : undefined);
const entry = await resolveLibrarySpec(client, nameOrId, opts.kind !== undefined ? { kind: opts.kind } : undefined);
const res = await client.get<Record<string, unknown>>(`/api/specs/library/${encodeURIComponent(entry.id)}/review`);
const review = res.data;
const teamChoice = entry.sourceType === "builtin" && entry.kind === "rig" && ["starter", "factory"].includes(entry.name)
+105 -1
View File
@@ -19,6 +19,28 @@ function captureLogs(fn: () => Promise<void>): Promise<{ logs: string[]; exitCod
});
}
function captureOutputs(fn: () => Promise<void>): Promise<{ stdout: string[]; stderr: string[]; exitCode: number | undefined }> {
return new Promise(async (resolve) => {
const stdout: string[] = [];
const stderr: string[] = [];
const origLog = console.log;
const origErr = console.error;
const origExitCode = process.exitCode;
process.exitCode = undefined;
console.log = (...args: unknown[]) => stdout.push(args.join(" "));
console.error = (...args: unknown[]) => stderr.push(args.join(" "));
try {
await fn();
} finally {
console.log = origLog;
console.error = origErr;
}
const exitCode = process.exitCode;
process.exitCode = origExitCode;
resolve({ stdout, stderr, exitCode });
});
}
function runningDeps(port: number): StatusDeps {
return { lifecycleDeps: { ...mockLifecycleDeps(), exists: vi.fn((p: string) => p === STATE_FILE), readFile: vi.fn((p: string) => { if (p === STATE_FILE) return JSON.stringify({ pid: 123, port, db: "test.sqlite", startedAt: "2026-04-03T00:00:00Z" } as DaemonState); return null; }), fetch: vi.fn(async () => ({ ok: true })) }, clientFactory: (baseUrl) => new DaemonClient(baseUrl) };
}
@@ -38,10 +60,13 @@ const RIG_REVIEW = {
describe("Specs CLI", () => {
let server: http.Server;
let port: number;
const libraryRequests: string[] = [];
beforeAll(async () => {
server = http.createServer((req, res) => {
const url = decodeURIComponent(req.url ?? "");
const rawUrl = req.url ?? "";
libraryRequests.push(rawUrl);
const url = decodeURIComponent(rawUrl);
if (url === "/api/specs/library" || url.startsWith("/api/specs/library?")) {
res.writeHead(200, { "Content-Type": "application/json" });
const kind = new URL(`http://localhost${url}`).searchParams.get("kind");
@@ -112,6 +137,63 @@ describe("Specs CLI", () => {
expect(parsed.length).toBe(2);
});
it("specs ls --kind warns on unrecognized kind and still queries daemon", async () => {
const requestStart = libraryRequests.length;
const { stdout, stderr, exitCode } = await captureOutputs(async () => {
await makeCmd().parseAsync(["node", "rig", "specs", "ls", "--kind", "invalid"]);
});
expect(stderr.join("\n")).toContain("Warning: unrecognized --kind 'invalid'. Supported kinds: rig, agent, workflow");
expect(stdout.join("\n")).toContain("No invalid specs found");
expect(exitCode).toBeUndefined();
expect(libraryRequests.slice(requestStart)).toContain("/api/specs/library?kind=invalid");
});
it("specs ls --kind '' warns on empty kind and still queries daemon", async () => {
const requestStart = libraryRequests.length;
const { stdout, stderr, exitCode } = await captureOutputs(async () => {
await makeCmd().parseAsync(["node", "rig", "specs", "ls", "--kind", ""]);
});
expect(stderr.join("\n")).toContain("Warning: unrecognized --kind ''. Supported kinds: rig, agent, workflow");
expect(stdout.join("\n")).toContain("No specs found");
expect(exitCode).toBeUndefined();
expect(libraryRequests.slice(requestStart)).toContain("/api/specs/library?kind=");
});
it("specs ls --kind encodes special characters in daemon request", async () => {
const requestStart = libraryRequests.length;
await makeCmd().parseAsync(["node", "rig", "specs", "ls", "--kind", "rig#draft"]);
expect(libraryRequests.slice(requestStart)).toContain("/api/specs/library?kind=rig%23draft");
});
it.each(["rig", "agent", "workflow"])("specs ls --kind %s runs without warning", async (kind) => {
const requestStart = libraryRequests.length;
const { stderr, exitCode } = await captureOutputs(async () => {
await makeCmd().parseAsync(["node", "rig", "specs", "ls", "--kind", kind]);
});
expect(stderr.join("\n")).not.toContain("Warning:");
expect(exitCode).toBeUndefined();
expect(libraryRequests.slice(requestStart)).toContain(`/api/specs/library?kind=${kind}`);
});
it("specs ls with --kind reports 'No <kind> specs found' when empty", async () => {
const { stdout, stderr } = await captureOutputs(async () => {
await makeCmd().parseAsync(["node", "rig", "specs", "ls", "--kind", "workflow"]);
});
expect(stdout.join("\n")).toContain("No workflow specs found");
expect(stdout.join("\n")).not.toContain("No specs in library");
expect(stderr.join("\n")).not.toContain("Warning:");
});
it("specs ls --kind invalid --json emits parseable JSON on stdout and warning on stderr", async () => {
const { stdout, stderr } = await captureOutputs(async () => {
await makeCmd().parseAsync(["node", "rig", "specs", "ls", "--kind", "invalid", "--json"]);
});
expect(stderr.join("\n")).toContain("Warning: unrecognized --kind 'invalid'. Supported kinds: rig, agent, workflow");
const parsed = JSON.parse(stdout.join("\n"));
expect(Array.isArray(parsed)).toBe(true);
expect(parsed.length).toBe(0);
});
it("specs show resolves by name and prints metadata", async () => {
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "specs", "show", "review-rig"]);
@@ -174,6 +256,28 @@ describe("Specs CLI", () => {
expect(exitCode).toBeUndefined();
});
it("specs show --kind warns on unrecognized kind and still queries daemon", async () => {
const requestStart = libraryRequests.length;
const { stderr, exitCode } = await captureOutputs(async () => {
await makeCmd().parseAsync(["node", "rig", "specs", "show", "review-rig", "--kind", "invalid"]);
});
expect(stderr.join("\n")).toContain("Warning: unrecognized --kind 'invalid'. Supported kinds: rig, agent, workflow");
expect(stderr.join("\n")).toContain("Spec 'review-rig' not found in invalid library");
expect(exitCode).toBe(1);
expect(libraryRequests.slice(requestStart)).toContain("/api/specs/library?kind=invalid");
});
it("specs preview --kind warns on unrecognized kind and still queries daemon", async () => {
const requestStart = libraryRequests.length;
const { stderr, exitCode } = await captureOutputs(async () => {
await makeCmd().parseAsync(["node", "rig", "specs", "preview", "review-rig", "--kind", "invalid"]);
});
expect(stderr.join("\n")).toContain("Warning: unrecognized --kind 'invalid'. Supported kinds: rig, agent, workflow");
expect(stderr.join("\n")).toContain("Spec 'review-rig' not found in invalid library");
expect(exitCode).toBe(1);
expect(libraryRequests.slice(requestStart)).toContain("/api/specs/library?kind=invalid");
});
it("specs show not-found error points people back to managed apps in the library", async () => {
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "specs", "show", "missing-app"]);