Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 23 additions & 6 deletions packages/cli/src/doctor.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
Expand Down
63 changes: 63 additions & 0 deletions packages/cli/test/init.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down