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
4 changes: 4 additions & 0 deletions packages/agentplane/src/cli/run-cli/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
17 changes: 17 additions & 0 deletions packages/agentplane/src/commands/agent/lint.command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { CommandCtx, CommandSpec } from "../../cli/spec/spec.js";

import { cmdAgentLint } from "./lint.js";

export type AgentLintParsed = Record<string, never>;

export const agentLintSpec: CommandSpec<AgentLintParsed> = {
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<number> {
return await cmdAgentLint({ cwd: ctx.cwd, rootOverride: ctx.rootOverride });
}
35 changes: 35 additions & 0 deletions packages/agentplane/src/commands/agent/lint.ts
Original file line number Diff line number Diff line change
@@ -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<number> {
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 });
}
}
35 changes: 35 additions & 0 deletions packages/core/schemas/agent.schema.json
Original file line number Diff line number Diff line change
@@ -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
}
}
}
121 changes: 121 additions & 0 deletions packages/core/src/agents/agent-schema.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
141 changes: 141 additions & 0 deletions packages/core/src/agents/agent-schema.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

type AjvInstance = {
compile: <T>(schema: unknown) => ValidateFunction<T>;
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<AgentDefinition>(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<AgentLintResult> {
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<string, string>();

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<string, unknown>).id === "string"
) {
const id = (raw as Record<string, unknown>).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 };
}
8 changes: 8 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Loading