feat(DIS-107): Zod schemas for disclaw.yaml, agent.yaml, CLI output
Add DisclawConfigSchema and AgentYamlSchema with Zod validation. loader.ts and identity.ts validate external data at parse time. Clear error messages with field name and expected type. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6039e992bd
commit
5718284518
7 changed files with 186 additions and 53 deletions
48
package-lock.json
generated
48
package-lock.json
generated
|
|
@ -13,7 +13,8 @@
|
||||||
"discord.js": "^14.16.0",
|
"discord.js": "^14.16.0",
|
||||||
"dotenv": "^16.4.0",
|
"dotenv": "^16.4.0",
|
||||||
"pino": "^10.3.1",
|
"pino": "^10.3.1",
|
||||||
"yaml": "^2.6.0"
|
"yaml": "^2.6.0",
|
||||||
|
"zod": "^3.23.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.0",
|
"@types/better-sqlite3": "^7.6.0",
|
||||||
|
|
@ -1862,15 +1863,6 @@
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/atomic-sleep": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ast-v8-to-istanbul": {
|
"node_modules/ast-v8-to-istanbul": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz",
|
||||||
|
|
@ -1883,6 +1875,15 @@
|
||||||
"js-tokens": "^10.0.0"
|
"js-tokens": "^10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/atomic-sleep": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/balanced-match": {
|
"node_modules/balanced-match": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||||
|
|
@ -2756,7 +2757,8 @@
|
||||||
"node_modules/help-me": {
|
"node_modules/help-me": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz",
|
||||||
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="
|
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
|
||||||
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/html-escaper": {
|
"node_modules/html-escaper": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
|
|
@ -2885,13 +2887,6 @@
|
||||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/joycon": {
|
|
||||||
"version": "3.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz",
|
|
||||||
"integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/istanbul-lib-coverage": {
|
"node_modules/istanbul-lib-coverage": {
|
||||||
"version": "3.2.2",
|
"version": "3.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
|
||||||
|
|
@ -2931,6 +2926,13 @@
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/joycon": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "10.0.0",
|
"version": "10.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
|
||||||
|
|
@ -4343,7 +4345,15 @@
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zod": {
|
||||||
|
"version": "3.25.76",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
|
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,8 @@
|
||||||
"discord.js": "^14.16.0",
|
"discord.js": "^14.16.0",
|
||||||
"dotenv": "^16.4.0",
|
"dotenv": "^16.4.0",
|
||||||
"pino": "^10.3.1",
|
"pino": "^10.3.1",
|
||||||
"yaml": "^2.6.0"
|
"yaml": "^2.6.0",
|
||||||
|
"zod": "^3.23.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.0",
|
"@types/better-sqlite3": "^7.6.0",
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,16 @@
|
||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import * as YAML from "yaml";
|
import * as YAML from "yaml";
|
||||||
|
import { ZodError } from "zod";
|
||||||
|
import { AgentYamlSchema } from "./schema";
|
||||||
|
|
||||||
|
export type { AgentYaml as AgentIdentity } from "./schema";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Metadata stored in agent.yaml -- DisClaw-specific fields only.
|
* Loads agent.yaml from the workspace folder and returns the validated identity.
|
||||||
* Identity, personality, and instructions now live in CLAUDE.md,
|
|
||||||
* which Claude Code reads automatically.
|
|
||||||
*/
|
|
||||||
export interface AgentIdentity {
|
|
||||||
name: string;
|
|
||||||
display_name: string;
|
|
||||||
role: string;
|
|
||||||
channel_id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads agent.yaml from the workspace folder and returns the parsed identity.
|
|
||||||
* Re-read on every call so edits take effect immediately.
|
* Re-read on every call so edits take effect immediately.
|
||||||
*/
|
*/
|
||||||
export function loadAgentIdentity(workspacePath: string): AgentIdentity {
|
export function loadAgentIdentity(workspacePath: string): import("./schema").AgentYaml {
|
||||||
const yamlPath = path.join(workspacePath, "agent.yaml");
|
const yamlPath = path.join(workspacePath, "agent.yaml");
|
||||||
|
|
||||||
if (!fs.existsSync(yamlPath)) {
|
if (!fs.existsSync(yamlPath)) {
|
||||||
|
|
@ -26,7 +18,20 @@ export function loadAgentIdentity(workspacePath: string): AgentIdentity {
|
||||||
}
|
}
|
||||||
|
|
||||||
const raw = fs.readFileSync(yamlPath, "utf-8");
|
const raw = fs.readFileSync(yamlPath, "utf-8");
|
||||||
return YAML.parse(raw) as AgentIdentity;
|
const rawParsed = YAML.parse(raw);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return AgentYamlSchema.parse(rawParsed);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ZodError) {
|
||||||
|
const first = err.errors[0];
|
||||||
|
const fieldPath = first.path.length > 0 ? first.path.join(".") : "(root)";
|
||||||
|
throw new Error(
|
||||||
|
`Invalid agent.yaml at ${yamlPath}: field "${fieldPath}" -- ${first.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
19
src/agent/schema.ts
Normal file
19
src/agent/schema.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const AgentYamlSchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
display_name: z.string().optional(),
|
||||||
|
role: z.string().optional(),
|
||||||
|
channel_id: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AgentYaml = z.infer<typeof AgentYamlSchema>;
|
||||||
|
|
||||||
|
export const ClaudeJsonOutputSchema = z.union([
|
||||||
|
z.object({ result: z.string() }),
|
||||||
|
z.object({ content: z.array(z.object({ type: z.string(), text: z.string().optional() })) }),
|
||||||
|
z.array(z.object({ type: z.string(), text: z.string().optional() })),
|
||||||
|
z.string(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type ClaudeJsonOutput = z.infer<typeof ClaudeJsonOutputSchema>;
|
||||||
|
|
@ -3,19 +3,14 @@ import * as os from "os";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import * as dotenv from "dotenv";
|
import * as dotenv from "dotenv";
|
||||||
import * as YAML from "yaml";
|
import * as YAML from "yaml";
|
||||||
|
import { ZodError } from "zod";
|
||||||
import { childLogger } from "../runtime/logger";
|
import { childLogger } from "../runtime/logger";
|
||||||
|
import { DisclawConfigSchema } from "./schema";
|
||||||
|
|
||||||
|
export type { DisclawConfig } from "./schema";
|
||||||
|
|
||||||
const log = childLogger("config");
|
const log = childLogger("config");
|
||||||
|
|
||||||
export interface DisclawConfig {
|
|
||||||
workspaces_root: string;
|
|
||||||
management_channel: string;
|
|
||||||
claude_command: string;
|
|
||||||
claude_timeout_seconds: number;
|
|
||||||
env_blocklist: string[];
|
|
||||||
max_concurrent_agents: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EnvConfig {
|
export interface EnvConfig {
|
||||||
DISCORD_BOT_TOKEN: string;
|
DISCORD_BOT_TOKEN: string;
|
||||||
DISCORD_GUILD_ID?: string;
|
DISCORD_GUILD_ID?: string;
|
||||||
|
|
@ -78,18 +73,25 @@ export function loadDisclawConfig(rootDir: string): DisclawConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
const raw = fs.readFileSync(configPath, "utf-8");
|
const raw = fs.readFileSync(configPath, "utf-8");
|
||||||
const parsed = YAML.parse(raw) as Partial<DisclawConfig>;
|
const rawParsed = YAML.parse(raw);
|
||||||
|
|
||||||
// Apply defaults
|
let validated: DisclawConfig;
|
||||||
const rawWorkspacesRoot = parsed.workspaces_root ?? "~/.disclaw/workspaces";
|
try {
|
||||||
|
validated = DisclawConfigSchema.parse(rawParsed);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ZodError) {
|
||||||
|
const first = err.errors[0];
|
||||||
|
const fieldPath = first.path.length > 0 ? first.path.join(".") : "(root)";
|
||||||
|
throw new Error(
|
||||||
|
`Invalid disclaw.yaml: field "${fieldPath}" -- ${first.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
const config: DisclawConfig = {
|
const config: DisclawConfig = {
|
||||||
workspaces_root: expandWorkspacesRoot(rawWorkspacesRoot, rootDir),
|
...validated,
|
||||||
management_channel: parsed.management_channel ?? "disclaw",
|
workspaces_root: expandWorkspacesRoot(validated.workspaces_root, rootDir),
|
||||||
claude_command: parsed.claude_command ?? "claude",
|
|
||||||
claude_timeout_seconds: parsed.claude_timeout_seconds ?? 120,
|
|
||||||
env_blocklist: parsed.env_blocklist ?? [],
|
|
||||||
max_concurrent_agents: parsed.max_concurrent_agents ?? 4,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
|
|
|
||||||
12
src/config/schema.ts
Normal file
12
src/config/schema.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const DisclawConfigSchema = z.object({
|
||||||
|
workspaces_root: z.string().min(1),
|
||||||
|
management_channel: z.string().min(1).default("disclaw"),
|
||||||
|
claude_command: z.string().min(1).default("claude"),
|
||||||
|
claude_timeout_seconds: z.number().int().positive().default(120),
|
||||||
|
max_concurrent_agents: z.number().int().positive().default(4),
|
||||||
|
env_blocklist: z.array(z.string()).default([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type DisclawConfig = z.infer<typeof DisclawConfigSchema>;
|
||||||
84
tests/unit/schemas.test.ts
Normal file
84
tests/unit/schemas.test.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { ZodError } from "zod";
|
||||||
|
import { DisclawConfigSchema } from "../../src/config/schema";
|
||||||
|
import { AgentYamlSchema } from "../../src/agent/schema";
|
||||||
|
|
||||||
|
describe("DisclawConfigSchema", () => {
|
||||||
|
it("(a) parses valid disclaw.yaml data correctly", () => {
|
||||||
|
const input = {
|
||||||
|
workspaces_root: "/home/user/.disclaw/workspaces",
|
||||||
|
management_channel: "disclaw",
|
||||||
|
claude_command: "claude",
|
||||||
|
claude_timeout_seconds: 60,
|
||||||
|
max_concurrent_agents: 2,
|
||||||
|
env_blocklist: ["SECRET_KEY"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = DisclawConfigSchema.parse(input);
|
||||||
|
|
||||||
|
expect(result.workspaces_root).toBe("/home/user/.disclaw/workspaces");
|
||||||
|
expect(result.management_channel).toBe("disclaw");
|
||||||
|
expect(result.claude_command).toBe("claude");
|
||||||
|
expect(result.claude_timeout_seconds).toBe(60);
|
||||||
|
expect(result.max_concurrent_agents).toBe(2);
|
||||||
|
expect(result.env_blocklist).toEqual(["SECRET_KEY"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("(b) throws ZodError when workspaces_root is missing", () => {
|
||||||
|
const input = {
|
||||||
|
management_channel: "disclaw",
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() => DisclawConfigSchema.parse(input)).toThrow(ZodError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("(c) throws ZodError when claude_timeout_seconds is a string", () => {
|
||||||
|
const input = {
|
||||||
|
workspaces_root: "/some/path",
|
||||||
|
claude_timeout_seconds: "abc",
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() => DisclawConfigSchema.parse(input)).toThrow(ZodError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("(d) applies defaults when optional fields are not provided", () => {
|
||||||
|
const input = {
|
||||||
|
workspaces_root: "/some/path",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = DisclawConfigSchema.parse(input);
|
||||||
|
|
||||||
|
expect(result.management_channel).toBe("disclaw");
|
||||||
|
expect(result.claude_command).toBe("claude");
|
||||||
|
expect(result.claude_timeout_seconds).toBe(120);
|
||||||
|
expect(result.max_concurrent_agents).toBe(4);
|
||||||
|
expect(result.env_blocklist).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AgentYamlSchema", () => {
|
||||||
|
it("(e) parses valid agent.yaml data correctly", () => {
|
||||||
|
const input = {
|
||||||
|
name: "my-agent",
|
||||||
|
display_name: "My Agent",
|
||||||
|
role: "Backend developer",
|
||||||
|
channel_id: "1234567890",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = AgentYamlSchema.parse(input);
|
||||||
|
|
||||||
|
expect(result.name).toBe("my-agent");
|
||||||
|
expect(result.display_name).toBe("My Agent");
|
||||||
|
expect(result.role).toBe("Backend developer");
|
||||||
|
expect(result.channel_id).toBe("1234567890");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("(f) throws ZodError when channel_id is missing", () => {
|
||||||
|
const input = {
|
||||||
|
name: "my-agent",
|
||||||
|
role: "Backend developer",
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() => AgentYamlSchema.parse(input)).toThrow(ZodError);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue