From b649edfda13643dc0ec76e98e13ebc373dfa9d87 Mon Sep 17 00:00:00 2001 From: Aleksandr Date: Mon, 9 Feb 2026 11:17:45 +0500 Subject: [PATCH] feat: agent JSON Schema validation + agent lint CLI Add agent definition schema (packages/core/schemas/agent.schema.json) with validateAgent, lintAgent, lintAgentsDir functions and `agentplane agent lint` CLI command using CommandSpec pattern. Co-Authored-By: Claude Opus 4.6 --- .../agentplane/src/cli/run-cli/registry.ts | 4 + .../src/commands/agent/lint.command.ts | 17 +++ .../agentplane/src/commands/agent/lint.ts | 35 +++++ packages/core/schemas/agent.schema.json | 35 +++++ packages/core/src/agents/agent-schema.test.ts | 121 +++++++++++++++ packages/core/src/agents/agent-schema.ts | 141 ++++++++++++++++++ packages/core/src/index.ts | 8 + packages/spec/examples/agent.json | 12 ++ packages/spec/schemas/agent.schema.json | 35 +++++ 9 files changed, 408 insertions(+) create mode 100644 packages/agentplane/src/commands/agent/lint.command.ts create mode 100644 packages/agentplane/src/commands/agent/lint.ts create mode 100644 packages/core/schemas/agent.schema.json create mode 100644 packages/core/src/agents/agent-schema.test.ts create mode 100644 packages/core/src/agents/agent-schema.ts create mode 100644 packages/spec/examples/agent.json create mode 100644 packages/spec/schemas/agent.schema.json diff --git a/packages/agentplane/src/cli/run-cli/registry.ts b/packages/agentplane/src/cli/run-cli/registry.ts index a753aa80..e23be187 100644 --- a/packages/agentplane/src/cli/run-cli/registry.ts +++ b/packages/agentplane/src/cli/run-cli/registry.ts @@ -173,6 +173,8 @@ import { } from "../../commands/guard/suggest-allow.command.js"; import { guardCommitSpec, makeRunGuardCommitHandler } from "../../commands/guard/commit.command.js"; +import { agentLintSpec, runAgentLint } from "../../commands/agent/lint.command.js"; + import type { CommandContext } from "../../commands/shared/task-backend.js"; const helpNoop = () => Promise.resolve(0); @@ -184,6 +186,7 @@ export function buildHelpFastRegistry(): CommandRegistry { registry.register(quickstartSpec, helpNoop); registry.register(roleSpec, helpNoop); registry.register(agentsSpec, helpNoop); + registry.register(agentLintSpec, helpNoop); registry.register(configShowSpec, helpNoop); registry.register(configSetSpec, helpNoop); registry.register(modeGetSpec, helpNoop); @@ -277,6 +280,7 @@ export function buildRegistry( registry.register(quickstartSpec, runQuickstart); registry.register(roleSpec, runRole); registry.register(agentsSpec, runAgents); + registry.register(agentLintSpec, runAgentLint); registry.register(configShowSpec, runConfigShow); registry.register(configSetSpec, runConfigSet); registry.register(modeGetSpec, runModeGet); diff --git a/packages/agentplane/src/commands/agent/lint.command.ts b/packages/agentplane/src/commands/agent/lint.command.ts new file mode 100644 index 00000000..7056e011 --- /dev/null +++ b/packages/agentplane/src/commands/agent/lint.command.ts @@ -0,0 +1,17 @@ +import type { CommandCtx, CommandSpec } from "../../cli/spec/spec.js"; + +import { cmdAgentLint } from "./lint.js"; + +export type AgentLintParsed = Record; + +export const agentLintSpec: CommandSpec = { + id: ["agent", "lint"], + group: "Agent", + summary: "Lint agent JSON definitions (schema + invariants).", + examples: [{ cmd: "agentplane agent lint", why: "Validate all agent definitions." }], + parse: () => ({}), +}; + +export async function runAgentLint(ctx: CommandCtx): Promise { + return await cmdAgentLint({ cwd: ctx.cwd, rootOverride: ctx.rootOverride }); +} diff --git a/packages/agentplane/src/commands/agent/lint.ts b/packages/agentplane/src/commands/agent/lint.ts new file mode 100644 index 00000000..3a6e0210 --- /dev/null +++ b/packages/agentplane/src/commands/agent/lint.ts @@ -0,0 +1,35 @@ +import { lintAgentsDir, loadConfig, resolveProject } from "@agentplaneorg/core"; +import path from "node:path"; + +import { mapCoreError } from "../../cli/error-map.js"; +import { CliError } from "../../shared/errors.js"; + +export async function cmdAgentLint(opts: { + cwd: string; + rootOverride?: string; +}): Promise { + try { + const resolved = await resolveProject({ + cwd: opts.cwd, + rootOverride: opts.rootOverride ?? null, + }); + const loaded = await loadConfig(resolved.agentplaneDir); + const agentsDir = path.join(resolved.gitRoot, loaded.config.paths.agents_dir); + const result = await lintAgentsDir(agentsDir); + if (result.errors.length > 0) { + throw new CliError({ + exitCode: 3, + code: "E_VALIDATION", + message: result.errors.join("\n"), + }); + } + for (const w of result.warnings) { + process.stderr.write(`warn: ${w}\n`); + } + process.stdout.write("OK\n"); + return 0; + } catch (err) { + if (err instanceof CliError) throw err; + throw mapCoreError(err, { command: "agent lint", root: opts.rootOverride ?? null }); + } +} diff --git a/packages/core/schemas/agent.schema.json b/packages/core/schemas/agent.schema.json new file mode 100644 index 00000000..6ea21785 --- /dev/null +++ b/packages/core/schemas/agent.schema.json @@ -0,0 +1,35 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://agentplane.dev/schemas/agent.schema.json", + "title": "agentplane agent definition", + "type": "object", + "additionalProperties": true, + "required": ["id", "role", "description", "permissions", "workflow"], + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]*$", + "minLength": 1 + }, + "role": { "type": "string", "minLength": 1 }, + "description": { "type": "string", "minLength": 1 }, + "inputs": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "outputs": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "permissions": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "minItems": 1 + }, + "workflow": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "minItems": 1 + } + } +} diff --git a/packages/core/src/agents/agent-schema.test.ts b/packages/core/src/agents/agent-schema.test.ts new file mode 100644 index 00000000..0f2afa59 --- /dev/null +++ b/packages/core/src/agents/agent-schema.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; +import os from "node:os"; +import path from "node:path"; +import { mkdtemp, writeFile, mkdir } from "node:fs/promises"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +import { validateAgent, lintAgent, lintAgentsDir } from "../index.js"; + +const VALID_AGENT = { + id: "TEST_AGENT", + role: "Test role.", + description: "Test description.", + permissions: ["read"], + workflow: ["Do things."], +}; + +describe("validateAgent", () => { + it("accepts a valid agent", () => { + expect(() => validateAgent(VALID_AGENT)).not.toThrow(); + }); + + it("spec example agent validates at runtime", () => { + const exampleUrl = new URL("../../../spec/examples/agent.json", import.meta.url); + const text = readFileSync(fileURLToPath(exampleUrl), "utf8"); + const parsed = JSON.parse(text) as unknown; + expect(() => validateAgent(parsed)).not.toThrow(); + }); + + it("rejects missing required fields", () => { + expect(() => validateAgent({})).toThrow(/agent/); + }); + + it("rejects invalid id pattern", () => { + expect(() => validateAgent({ ...VALID_AGENT, id: "lowercase" })).toThrow(/agent/); + }); + + it("rejects empty permissions", () => { + expect(() => validateAgent({ ...VALID_AGENT, permissions: [] })).toThrow(/agent/); + }); +}); + +describe("lintAgent", () => { + it("returns no errors for valid agent", () => { + const result = lintAgent(VALID_AGENT, "TEST_AGENT.json"); + expect(result.errors).toHaveLength(0); + }); + + it("detects filename mismatch", () => { + const result = lintAgent(VALID_AGENT, "WRONG.json"); + expect(result.errors.some((e) => e.includes("does not match"))).toBe(true); + }); + + it("returns schema errors for invalid input", () => { + const result = lintAgent({ id: "bad" }); + expect(result.errors.length).toBeGreaterThan(0); + }); +}); + +describe("lintAgentsDir", () => { + it("lints a directory of valid agents", async () => { + const tmp = await mkdtemp(path.join(os.tmpdir(), "agentplane-agents-lint-")); + const agentsDir = path.join(tmp, "agents"); + await mkdir(agentsDir, { recursive: true }); + + await writeFile( + path.join(agentsDir, "ALPHA.json"), + JSON.stringify({ ...VALID_AGENT, id: "ALPHA" }), + ); + await writeFile( + path.join(agentsDir, "BETA.json"), + JSON.stringify({ ...VALID_AGENT, id: "BETA" }), + ); + + const result = await lintAgentsDir(agentsDir); + expect(result.errors).toHaveLength(0); + }); + + it("detects duplicate agent IDs", async () => { + const tmp = await mkdtemp(path.join(os.tmpdir(), "agentplane-agents-dup-")); + const agentsDir = path.join(tmp, "agents"); + await mkdir(agentsDir, { recursive: true }); + + await writeFile( + path.join(agentsDir, "A.json"), + JSON.stringify({ ...VALID_AGENT, id: "SAME" }), + ); + await writeFile( + path.join(agentsDir, "B.json"), + JSON.stringify({ ...VALID_AGENT, id: "SAME" }), + ); + + const result = await lintAgentsDir(agentsDir); + expect(result.errors.some((e) => e.includes("duplicate"))).toBe(true); + }); + + it("reports invalid JSON gracefully", async () => { + const tmp = await mkdtemp(path.join(os.tmpdir(), "agentplane-agents-bad-")); + const agentsDir = path.join(tmp, "agents"); + await mkdir(agentsDir, { recursive: true }); + + await writeFile(path.join(agentsDir, "BAD.json"), "not json{"); + + const result = await lintAgentsDir(agentsDir); + expect(result.errors.some((e) => e.includes("invalid JSON"))).toBe(true); + }); + + it("warns on empty directory", async () => { + const tmp = await mkdtemp(path.join(os.tmpdir(), "agentplane-agents-empty-")); + const agentsDir = path.join(tmp, "agents"); + await mkdir(agentsDir, { recursive: true }); + + const result = await lintAgentsDir(agentsDir); + expect(result.warnings.some((w) => w.includes("no agent JSON"))).toBe(true); + }); + + it("reports missing directory", async () => { + const result = await lintAgentsDir("/tmp/nonexistent-agents-dir-xyz"); + expect(result.errors.some((e) => e.includes("cannot read"))).toBe(true); + }); +}); diff --git a/packages/core/src/agents/agent-schema.ts b/packages/core/src/agents/agent-schema.ts new file mode 100644 index 00000000..37daca6f --- /dev/null +++ b/packages/core/src/agents/agent-schema.ts @@ -0,0 +1,141 @@ +import { readdir, readFile } from "node:fs/promises"; +import path from "node:path"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +import type { ErrorObject, Options, ValidateFunction } from "ajv"; +import AjvModule from "ajv"; + +export type AgentDefinition = { + id: string; + role: string; + description: string; + inputs?: string[]; + outputs?: string[]; + permissions: string[]; + workflow: string[]; +}; + +export type AgentLintResult = { + errors: string[]; + warnings: string[]; +}; + +const AGENT_SCHEMA_URL = new URL("../../schemas/agent.schema.json", import.meta.url); +const AGENT_SCHEMA = JSON.parse( + readFileSync(fileURLToPath(AGENT_SCHEMA_URL), "utf8"), +) as Record; + +type AjvInstance = { + compile: (schema: unknown) => ValidateFunction; + errorsText: (errors?: ErrorObject[] | null, opts?: { dataVar?: string }) => string; +}; + +type AjvConstructor = new (opts?: Options) => AjvInstance; + +const Ajv = + (AjvModule as unknown as { default?: AjvConstructor }).default ?? + (AjvModule as unknown as AjvConstructor); + +const AJV = new Ajv({ + allErrors: true, + allowUnionTypes: true, + strict: false, +}); + +const validateSchema = AJV.compile(AGENT_SCHEMA); + +function formatSchemaErrors(errors: ErrorObject[] | null | undefined): string { + if (!errors || errors.length === 0) return "agent schema validation failed"; + return AJV.errorsText(errors, { dataVar: "agent" }); +} + +export function validateAgent(raw: unknown): AgentDefinition { + const candidate = raw && typeof raw === "object" ? structuredClone(raw) : raw; + if (!validateSchema(candidate)) { + throw new Error(formatSchemaErrors(validateSchema.errors)); + } + return candidate; +} + +export function lintAgent(raw: unknown, fileName?: string): AgentLintResult { + const errors: string[] = []; + const warnings: string[] = []; + + // Schema validation + const candidate = raw && typeof raw === "object" ? structuredClone(raw) : raw; + if (!validateSchema(candidate)) { + const msg = formatSchemaErrors(validateSchema.errors); + errors.push(msg); + return { errors, warnings }; + } + + const agent = candidate as AgentDefinition; + + // Filename must match id + if (fileName) { + const expected = `${agent.id}.json`; + if (fileName !== expected) { + errors.push( + `filename "${fileName}" does not match agent id "${agent.id}" (expected "${expected}")`, + ); + } + } + + return { errors, warnings }; +} + +export async function lintAgentsDir(agentsDir: string): Promise { + const errors: string[] = []; + const warnings: string[] = []; + + let entries: string[]; + try { + entries = await readdir(agentsDir); + } catch { + errors.push(`cannot read agents directory: ${agentsDir}`); + return { errors, warnings }; + } + + const jsonFiles = entries.filter((f) => f.endsWith(".json")).sort(); + if (jsonFiles.length === 0) { + warnings.push("no agent JSON files found"); + return { errors, warnings }; + } + + const seenIds = new Map(); + + for (const file of jsonFiles) { + const filePath = path.join(agentsDir, file); + let raw: unknown; + try { + const text = await readFile(filePath, "utf8"); + raw = JSON.parse(text); + } catch (err) { + errors.push(`${file}: invalid JSON – ${(err as Error).message}`); + continue; + } + + const result = lintAgent(raw, file); + for (const e of result.errors) errors.push(`${file}: ${e}`); + for (const w of result.warnings) warnings.push(`${file}: ${w}`); + + // Check duplicate IDs + if ( + raw && + typeof raw === "object" && + "id" in raw && + typeof (raw as Record).id === "string" + ) { + const id = (raw as Record).id as string; + const prev = seenIds.get(id); + if (prev) { + errors.push(`duplicate agent id "${id}" in ${prev} and ${file}`); + } else { + seenIds.set(id, file); + } + } + } + + return { errors, warnings }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a0fffddd..e9fddabf 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -92,3 +92,11 @@ export { } from "./commit/commit-policy.js"; export { getStagedFiles, getUnstagedFiles, getUnstagedTrackedFiles } from "./git/git-utils.js"; + +export { + validateAgent, + lintAgent, + lintAgentsDir, + type AgentDefinition, + type AgentLintResult, +} from "./agents/agent-schema.js"; diff --git a/packages/spec/examples/agent.json b/packages/spec/examples/agent.json new file mode 100644 index 00000000..44ca872e --- /dev/null +++ b/packages/spec/examples/agent.json @@ -0,0 +1,12 @@ +{ + "id": "EXAMPLE_AGENT", + "role": "Demonstrate the agent definition schema.", + "description": "A minimal agent definition used as a reference example in tests and documentation.", + "inputs": ["A task ID or free-form user request."], + "outputs": ["A structured summary of work performed."], + "permissions": ["Project files: read-only."], + "workflow": [ + "Follow shared workflow rules in AGENTS.md.", + "Inspect relevant files and report findings." + ] +} diff --git a/packages/spec/schemas/agent.schema.json b/packages/spec/schemas/agent.schema.json new file mode 100644 index 00000000..6ea21785 --- /dev/null +++ b/packages/spec/schemas/agent.schema.json @@ -0,0 +1,35 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://agentplane.dev/schemas/agent.schema.json", + "title": "agentplane agent definition", + "type": "object", + "additionalProperties": true, + "required": ["id", "role", "description", "permissions", "workflow"], + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]*$", + "minLength": 1 + }, + "role": { "type": "string", "minLength": 1 }, + "description": { "type": "string", "minLength": 1 }, + "inputs": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "outputs": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "permissions": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "minItems": 1 + }, + "workflow": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "minItems": 1 + } + } +}