disclaw/src/config/loader.ts
Nick Tabeling 5718284518
Some checks failed
CI / build-and-test (ubuntu-latest) (pull_request) Has been cancelled
CI / build-and-test (windows-latest) (pull_request) Has been cancelled
CI / lint (pull_request) Has been cancelled
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>
2026-04-09 17:49:44 +02:00

98 lines
2.8 KiB
TypeScript

import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import * as dotenv from "dotenv";
import * as YAML from "yaml";
import { ZodError } from "zod";
import { childLogger } from "../runtime/logger";
import { DisclawConfigSchema } from "./schema";
export type { DisclawConfig } from "./schema";
const log = childLogger("config");
export interface EnvConfig {
DISCORD_BOT_TOKEN: string;
DISCORD_GUILD_ID?: string;
CLAUDE_PATH?: string;
}
/**
* Expands a leading `~` in a path to the user's home directory and resolves
* the result to an absolute, normalised path. No shell expansion is used.
*
* Exported as a pure function so it can be unit-tested independently.
*/
export function expandWorkspacesRoot(raw: string, repoRoot: string): string {
let expanded = raw;
if (expanded.startsWith("~")) {
expanded = os.homedir() + expanded.slice(1);
}
const resolved = path.resolve(repoRoot, expanded);
const rel = path.relative(repoRoot, resolved);
const isInsideRepo = !rel.startsWith("..") && !path.isAbsolute(rel);
if (isInsideRepo) {
log.warn(
{ workspacesRoot: resolved },
"workspaces_root lies inside the DisClaw repository. " +
"Claude Code walks parent directories and concatenates every CLAUDE.md it finds. " +
"A workspace under the repo root will inherit the project CLAUDE.md, " +
"leaking DisClaw system instructions into every agent context. " +
"Set workspaces_root to a path outside the repository (e.g. ~/.disclaw/workspaces)."
);
}
return resolved;
}
export function loadEnv(rootDir: string): EnvConfig {
dotenv.config({ path: path.join(rootDir, ".env") });
const token = process.env.DISCORD_BOT_TOKEN;
if (!token) {
throw new Error("DISCORD_BOT_TOKEN is not set in .env");
}
return {
DISCORD_BOT_TOKEN: token,
DISCORD_GUILD_ID: process.env.DISCORD_GUILD_ID,
CLAUDE_PATH: process.env.CLAUDE_PATH,
};
}
export function loadDisclawConfig(rootDir: string): DisclawConfig {
const configPath = path.join(rootDir, "disclaw.yaml");
if (!fs.existsSync(configPath)) {
throw new Error(`disclaw.yaml not found at ${configPath}`);
}
const raw = fs.readFileSync(configPath, "utf-8");
const rawParsed = YAML.parse(raw);
let validated: DisclawConfig;
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 = {
...validated,
workspaces_root: expandWorkspacesRoot(validated.workspaces_root, rootDir),
};
return config;
}