diff --git a/packages/cli/src/doctor.mjs b/packages/cli/src/doctor.mjs index 891e7e41..431f3ba6 100644 --- a/packages/cli/src/doctor.mjs +++ b/packages/cli/src/doctor.mjs @@ -53,17 +53,34 @@ function checkAgent(dir, name) { const path = join(dir, relative); if (!existsSync(path)) return failed(relative, "missing"); const source = readFileSync(path, "utf8").replace(/\r\n?/g, "\n"); - if (!source.startsWith("---\n") || !/\n---\n[\s\S]*\S/.test(source)) return failed(relative, "invalid frontmatter or empty prompt"); - if (!new RegExp(`^name:\\s*${escapeRegExp(name)}\\s*$`, "m").test(source)) return failed(relative, `name must be ${name}`); - if (!/^engine:\s*(?:claude_code|codex)\s*$/m.test(source)) return failed(relative, "engine must be claude_code or codex"); - if (!/^model:\s*\S+\s*$/m.test(source)) return failed(relative, "model is missing"); - if (!/^triggers:\s*$/m.test(source) || !/^\s{2}- type:\s*(?:manual|schedule|github)\s*$/m.test(source)) { + const parsed = splitFrontmatter(source); + if (!parsed || !parsed.prompt.trim()) return failed(relative, "invalid frontmatter or empty prompt"); + const { frontmatter } = parsed; + if (!new RegExp(`^name:\\s*${escapeRegExp(name)}\\s*$`, "m").test(frontmatter)) { + return failed(relative, `name must be ${name}`); + } + if (!/^engine:\s*(?:claude_code|codex)\s*$/m.test(frontmatter)) { + return failed(relative, "engine must be claude_code or codex"); + } + if (!/^model:\s*\S/m.test(frontmatter)) return failed(relative, "model is missing"); + if ( + !/^triggers:\s*$/m.test(frontmatter) || + !/^\s{2}- type:\s*(?:manual|schedule|github|mcp|ui)\s*$/m.test(frontmatter) + ) { return failed(relative, "at least one supported trigger is required"); } - if (/^(?:permissions|sandbox|tools):/m.test(source)) return failed(relative, "per-agent access controls are not supported"); + if (/^(?:permissions|sandbox|tools):/m.test(frontmatter)) { + return failed(relative, "per-agent access controls are not supported"); + } return passed(relative, "valid agent manifest"); } +function splitFrontmatter(source) { + const match = /^---\n([\s\S]*?)\n---(?:\n|$)([\s\S]*)$/.exec(source); + if (!match) return null; + return { frontmatter: match[1] ?? "", prompt: match[2] ?? "" }; +} + function passed(label, detail) { return { label, ok: true, detail }; } diff --git a/packages/cli/test/init.test.mjs b/packages/cli/test/init.test.mjs index 8fc2c656..1fb8256d 100644 --- a/packages/cli/test/init.test.mjs +++ b/packages/cli/test/init.test.mjs @@ -198,6 +198,69 @@ test("local doctor validates the 0.12 contract and preserves its JSON output", ( assert.match(invalidPort.stdout, /between 1 and 65535/); }); +test("doctor inspects agent frontmatter only and accepts quoted models and mcp/ui triggers", (t) => { + const dir = makeTargetRepo(); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const init = runCli( + ["init", "--yes", `--dir=${dir}`, "--repo=acme/demo-app", "--start=npm run dev"], + dir, + ); + assert.equal(init.status, 0, init.stdout + init.stderr); + + const builderPath = join(dir, ".agents/builder.md"); + const original = readFileSync(builderPath, "utf8"); + + writeFileSync( + builderPath, + `${original.trimEnd()}\n\npermissions:\n contents: read\n`, + ); + const promptPermissions = runCli(["doctor", `--dir=${dir}`, "--json"], dir); + assert.equal(promptPermissions.status, 0, promptPermissions.stdout + promptPermissions.stderr); + + writeFileSync( + builderPath, + original.replace(/^name: builder$/m, "name: not-builder") + "\nname: builder\n", + ); + const hiddenName = runCli(["doctor", `--dir=${dir}`, "--json"], dir); + assert.equal(hiddenName.status, 1); + assert.match(hiddenName.stdout, /name must be builder/); + + writeFileSync( + builderPath, + original.replace(/^model: .+$/m, 'model: "gpt-5.6-sol with spaces"'), + ); + const quotedModel = runCli(["doctor", `--dir=${dir}`, "--json"], dir); + assert.equal(quotedModel.status, 0, quotedModel.stdout + quotedModel.stderr); + + writeFileSync( + builderPath, + [ + "---", + "name: builder", + "description: Implements a complete story.", + "engine: codex", + "model: gpt-5.6-sol", + "enabled: true", + "triggers:", + " - type: mcp", + " - type: ui", + "---", + "", + "# Builder", + "", + "Complete the story.", + "", + ].join("\n"), + ); + const interactiveOnly = runCli(["doctor", `--dir=${dir}`, "--json"], dir); + assert.equal(interactiveOnly.status, 0, interactiveOnly.stdout + interactiveOnly.stderr); + + writeFileSync(builderPath, original.replace(/^enabled: true$/m, "permissions: {}\nenabled: true")); + const frontmatterPermissions = runCli(["doctor", `--dir=${dir}`, "--json"], dir); + assert.equal(frontmatterPermissions.status, 1); + assert.match(frontmatterPermissions.stdout, /per-agent access controls are not supported/); +}); + test("local commands reject unknown and valueless flags and legacy commands", () => { const unknown = runCli(["doctor", "--jsoon"]); assert.equal(unknown.status, 1);