@archon-claw/cli 0.0.4 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -8,6 +8,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
8
  import { loadAgentConfig } from "./config.js";
9
9
  import { createServer } from "./server.js";
10
10
  import { initSessionStore } from "./session.js";
11
+ import { startDev } from "./dev.js";
11
12
  import { runToolTests, formatResults } from "./test-runner.js";
12
13
  import { runEvals } from "./eval/runner.js";
13
14
  import { scaffoldAgent, scaffoldWorkspace } from "./scaffold.js";
@@ -30,6 +31,24 @@ program
30
31
  .action(() => {
31
32
  console.log("Starting agent...");
32
33
  });
34
+ program
35
+ .command("dev")
36
+ .description("Start dev server with hot-reload for agent development")
37
+ .argument("<agent-dir>", "Path to agent directory")
38
+ .option("-p, --port <port>", "Server port", "5100")
39
+ .option("--no-open", "Don't auto-open browser")
40
+ .action(async (agentDir, opts) => {
41
+ try {
42
+ await startDev(agentDir, {
43
+ port: parseInt(opts.port, 10),
44
+ open: opts.open,
45
+ });
46
+ }
47
+ catch (err) {
48
+ console.error(err instanceof Error ? err.message : err);
49
+ process.exit(1);
50
+ }
51
+ });
33
52
  program
34
53
  .command("start")
35
54
  .description("Start an agent HTTP server")
package/dist/config.js CHANGED
@@ -11,7 +11,7 @@ export async function loadAgentConfig(agentDir) {
11
11
  // Validate directory structure
12
12
  const validation = await validateDir("agent-dir", absDir);
13
13
  if (!validation.valid) {
14
- throw new Error(`Invalid agent directory:\n${validation.errors.map((e) => ` - ${e}`).join("\n")}`);
14
+ throw new Error(`Invalid agent directory:\n${validation.errors.map((e) => ` - ${e.file ? `[${e.file}] ` : ""}${e.message}`).join("\n")}`);
15
15
  }
16
16
  // Load model.json
17
17
  const modelRaw = await fs.readFile(path.join(absDir, "model.json"), "utf-8");
@@ -74,7 +74,8 @@ export async function loadAgentConfig(agentDir) {
74
74
  const implEntries = await Promise.all(implFiles.map(async (file) => {
75
75
  const name = file.replace(/\.impl\.js$/, "");
76
76
  const implPath = pathToFileURL(path.join(implsDir, file)).href;
77
- const mod = await import(implPath);
77
+ // Cache-bust ESM module cache so dev reload picks up changes
78
+ const mod = await import(`${implPath}?t=${Date.now()}`);
78
79
  return [name, mod.default];
79
80
  }));
80
81
  for (const [name, impl] of implEntries) {
package/dist/dev.d.ts ADDED
@@ -0,0 +1,20 @@
1
+ import http from "node:http";
2
+ import { type FSWatcher } from "chokidar";
3
+ import type { AgentConfig } from "./types.js";
4
+ export interface DevOptions {
5
+ port: number;
6
+ open: boolean;
7
+ }
8
+ /** Returned by startDev for cleanup and testing */
9
+ export interface DevHandle {
10
+ server: http.Server;
11
+ watcher: FSWatcher;
12
+ /** Current config reference (mutable via reload) */
13
+ getConfig(): AgentConfig;
14
+ /** Manually trigger config reload */
15
+ reload(changedFiles?: string[]): Promise<void>;
16
+ /** Graceful shutdown */
17
+ close(): Promise<void>;
18
+ }
19
+ export declare function startDev(agentDir: string, opts: DevOptions): Promise<DevHandle>;
20
+ export declare function printBanner(config: AgentConfig, port: number, absDir: string): void;
package/dist/dev.js ADDED
@@ -0,0 +1,121 @@
1
+ import path from "node:path";
2
+ import { watch } from "chokidar";
3
+ import open from "open";
4
+ import { loadAgentConfig } from "./config.js";
5
+ import { createServer } from "./server.js";
6
+ import { initSessionStore } from "./session.js";
7
+ export async function startDev(agentDir, opts) {
8
+ const absDir = path.resolve(agentDir);
9
+ // Initial load
10
+ let currentConfig = await loadAgentConfig(absDir);
11
+ await initSessionStore(absDir);
12
+ // Start server with getter — every request reads latest config
13
+ const server = createServer(() => currentConfig, opts.port);
14
+ printBanner(currentConfig, opts.port, absDir);
15
+ // Open browser (skip in SSH)
16
+ if (opts.open && !process.env.SSH_CLIENT && !process.env.SSH_TTY) {
17
+ open(`http://localhost:${opts.port}`).catch(() => { });
18
+ }
19
+ // Reload logic
20
+ let reloadInFlight = false;
21
+ async function reload(changedFiles) {
22
+ if (reloadInFlight)
23
+ return;
24
+ reloadInFlight = true;
25
+ const ts = new Date().toLocaleTimeString();
26
+ if (changedFiles?.length) {
27
+ const relFiles = changedFiles.map((f) => path.relative(absDir, f));
28
+ console.log(`\n[dev] ${ts} Changed: ${relFiles.join(", ")}`);
29
+ }
30
+ try {
31
+ // Shutdown old MCP connections before reloading
32
+ await currentConfig.mcpManager?.shutdown();
33
+ const newConfig = await loadAgentConfig(absDir);
34
+ currentConfig = newConfig;
35
+ console.log(`[dev] ${ts} Reloaded \u2713` +
36
+ ` (tools: ${newConfig.tools.length}, skills: ${Object.keys(newConfig.skills).length})`);
37
+ }
38
+ catch (err) {
39
+ console.error(`[dev] ${ts} Reload failed: ${err instanceof Error ? err.message : err}`);
40
+ console.error(`[dev] Continuing with previous valid configuration`);
41
+ }
42
+ finally {
43
+ reloadInFlight = false;
44
+ }
45
+ }
46
+ // Watch agent directory
47
+ const watcher = watch(absDir, {
48
+ ignored: [
49
+ "**/sessions/**",
50
+ "**/node_modules/**",
51
+ "**/eval-results/**",
52
+ "**/.DS_Store",
53
+ ],
54
+ ignoreInitial: true,
55
+ });
56
+ let debounceTimer = null;
57
+ let pendingFiles = [];
58
+ watcher.on("all", (_event, filePath) => {
59
+ pendingFiles.push(filePath);
60
+ if (debounceTimer)
61
+ clearTimeout(debounceTimer);
62
+ debounceTimer = setTimeout(async () => {
63
+ const files = [...pendingFiles];
64
+ pendingFiles = [];
65
+ await reload(files);
66
+ }, 100);
67
+ });
68
+ // Graceful shutdown
69
+ const shutdown = async () => {
70
+ console.log("\nShutting down...");
71
+ await close();
72
+ process.exit(0);
73
+ };
74
+ process.on("SIGINT", shutdown);
75
+ process.on("SIGTERM", shutdown);
76
+ async function close() {
77
+ process.removeListener("SIGINT", shutdown);
78
+ process.removeListener("SIGTERM", shutdown);
79
+ if (debounceTimer)
80
+ clearTimeout(debounceTimer);
81
+ await watcher.close();
82
+ await currentConfig.mcpManager?.shutdown();
83
+ await new Promise((resolve) => server.close(() => resolve()));
84
+ }
85
+ return {
86
+ server,
87
+ watcher,
88
+ getConfig: () => currentConfig,
89
+ reload,
90
+ close,
91
+ };
92
+ }
93
+ export function printBanner(config, port, absDir) {
94
+ const mcpCount = config.mcpManager
95
+ ? config.tools.length -
96
+ config.tools.filter((t) => config.toolImpls.has(t.name) &&
97
+ !t.name.includes("__")).length
98
+ : 0;
99
+ const localCount = config.tools.length - mcpCount;
100
+ const lines = [
101
+ "",
102
+ " archon-claw dev",
103
+ "",
104
+ ` Agent: ${path.relative(process.cwd(), absDir)}`,
105
+ ` Model: ${config.model.provider}/${config.model.model}`,
106
+ ` Tools: ${localCount}${mcpCount > 0 ? ` (+ ${mcpCount} from MCP)` : ""}`,
107
+ ` Skills: ${Object.keys(config.skills).length}`,
108
+ ` URL: http://localhost:${port}`,
109
+ "",
110
+ " Watching for changes...",
111
+ "",
112
+ ];
113
+ const maxLen = Math.max(...lines.map((l) => l.length));
114
+ const top = "\u250C" + "\u2500".repeat(maxLen + 1) + "\u2510";
115
+ const bot = "\u2514" + "\u2500".repeat(maxLen + 1) + "\u2518";
116
+ console.log(top);
117
+ for (const line of lines) {
118
+ console.log("\u2502" + line.padEnd(maxLen + 1) + "\u2502");
119
+ }
120
+ console.log(bot);
121
+ }
package/dist/server.d.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  import http from "node:http";
2
2
  import type { AgentConfig } from "./types.js";
3
- export declare function createServer(config: AgentConfig, port: number): http.Server;
3
+ export declare function createServer(getConfig: AgentConfig | (() => AgentConfig), port: number): http.Server;
package/dist/server.js CHANGED
@@ -96,8 +96,10 @@ function readBody(req) {
96
96
  });
97
97
  });
98
98
  }
99
- export function createServer(config, port) {
99
+ export function createServer(getConfig, port) {
100
+ const resolveConfig = typeof getConfig === "function" ? getConfig : () => getConfig;
100
101
  const server = http.createServer(async (req, res) => {
102
+ const config = resolveConfig();
101
103
  const url = new URL(req.url ?? "/", `http://localhost:${port}`);
102
104
  const method = req.method ?? "GET";
103
105
  // CORS preflight
@@ -1,5 +1,5 @@
1
1
  import type { ValidatorPlugin, DirValidatorPlugin, ValidationResult } from "./plugin.js";
2
- export type { ValidatorPlugin, DirValidatorPlugin, ValidationResult } from "./plugin.js";
2
+ export type { ValidatorPlugin, DirValidatorPlugin, ValidationResult, ValidationError } from "./plugin.js";
3
3
  /** Get a content validator plugin by name */
4
4
  export declare function getPlugin(name: string): ValidatorPlugin | undefined;
5
5
  /** Get all registered content plugins */
@@ -8,6 +8,8 @@ export declare function getPlugins(): ValidatorPlugin[];
8
8
  export declare function getDirPlugin(name: string): DirValidatorPlugin | undefined;
9
9
  /** Register a custom content plugin */
10
10
  export declare function registerPlugin(plugin: ValidatorPlugin): void;
11
+ /** Unregister a content plugin by name */
12
+ export declare function unregisterPlugin(name: string): boolean;
11
13
  /** Register a custom directory plugin */
12
14
  export declare function registerDirPlugin(plugin: DirValidatorPlugin): void;
13
15
  /** Validate content using a specific plugin by name */
@@ -32,6 +32,14 @@ export function getDirPlugin(name) {
32
32
  export function registerPlugin(plugin) {
33
33
  contentPlugins.push(plugin);
34
34
  }
35
+ /** Unregister a content plugin by name */
36
+ export function unregisterPlugin(name) {
37
+ const idx = contentPlugins.findIndex((p) => p.name === name);
38
+ if (idx === -1)
39
+ return false;
40
+ contentPlugins.splice(idx, 1);
41
+ return true;
42
+ }
35
43
  /** Register a custom directory plugin */
36
44
  export function registerDirPlugin(plugin) {
37
45
  dirPlugins.push(plugin);
@@ -40,7 +48,7 @@ export function registerDirPlugin(plugin) {
40
48
  export function validate(pluginName, content, fileName) {
41
49
  const plugin = getPlugin(pluginName);
42
50
  if (!plugin) {
43
- return { valid: false, errors: [`Unknown validator plugin: ${pluginName}`] };
51
+ return { valid: false, errors: [{ message: `Unknown validator plugin: ${pluginName}`, severity: "error" }] };
44
52
  }
45
53
  return plugin.validate(content, fileName);
46
54
  }
@@ -48,7 +56,7 @@ export function validate(pluginName, content, fileName) {
48
56
  export async function validateDir(pluginName, dirPath) {
49
57
  const plugin = getDirPlugin(pluginName);
50
58
  if (!plugin) {
51
- return { valid: false, errors: [`Unknown dir validator plugin: ${pluginName}`] };
59
+ return { valid: false, errors: [{ message: `Unknown dir validator plugin: ${pluginName}`, severity: "error" }] };
52
60
  }
53
61
  return plugin.validate(dirPath);
54
62
  }
@@ -1,7 +1,17 @@
1
+ /** Structured validation error */
2
+ export interface ValidationError {
3
+ message: string;
4
+ severity: "error" | "warning";
5
+ file?: string;
6
+ path?: string;
7
+ expected?: string;
8
+ received?: string;
9
+ line?: number;
10
+ }
1
11
  /** Validation result */
2
12
  export interface ValidationResult {
3
13
  valid: boolean;
4
- errors: string[];
14
+ errors: ValidationError[];
5
15
  }
6
16
  /** Content validator plugin - validates file content */
7
17
  export interface ValidatorPlugin {
@@ -9,6 +19,8 @@ export interface ValidatorPlugin {
9
19
  name: string;
10
20
  /** File pattern this plugin handles, e.g. "*.json", "*.md" */
11
21
  pattern: string;
22
+ /** Whether this plugin's file(s) are required in an agent directory */
23
+ required?: boolean;
12
24
  /** Validate raw content, return result */
13
25
  validate(content: string, fileName?: string): ValidationResult;
14
26
  }
@@ -1,170 +1,154 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { modelPlugin } from "./model.js";
4
- import { systemPromptPlugin } from "./system-prompt.js";
5
- import { toolPlugin } from "./tool.js";
6
- import { agentSkillPlugin } from "./agent-skill.js";
7
- import { datasetPlugin } from "./dataset.js";
8
- import { mcpPlugin } from "./mcp.js";
9
- export const agentDirPlugin = {
10
- name: "agent-dir",
11
- async validate(dirPath) {
12
- const errors = [];
13
- // Check directory exists
3
+ import picomatch from "picomatch";
4
+ import { getPlugins } from "../index.js";
5
+ /**
6
+ * Given a pattern like "model.json" or "tools/*.json", find matching files
7
+ * in the agent directory. Returns relative paths (e.g. "tools/t.json").
8
+ */
9
+ async function matchFiles(dirPath, pattern) {
10
+ const parts = pattern.split("/");
11
+ if (parts.length === 1) {
12
+ // Flat file pattern like "model.json" or "custom-config.json"
13
+ const matcher = picomatch(pattern);
14
14
  try {
15
- const stat = await fs.stat(dirPath);
16
- if (!stat.isDirectory()) {
17
- return { valid: false, errors: [`${dirPath} is not a directory`] };
18
- }
15
+ const entries = await fs.readdir(dirPath);
16
+ return entries.filter((f) => matcher(f));
19
17
  }
20
18
  catch {
21
- return { valid: false, errors: [`${dirPath} does not exist`] };
19
+ return [];
22
20
  }
23
- // Check system-prompt.md
24
- const promptPath = path.join(dirPath, "system-prompt.md");
21
+ }
22
+ // Directory pattern like "tools/*.json" or "skills/*.md"
23
+ const subDir = parts.slice(0, -1).join("/");
24
+ const filePattern = parts[parts.length - 1];
25
+ const matcher = picomatch(filePattern);
26
+ const fullSubDir = path.join(dirPath, subDir);
27
+ try {
28
+ const stat = await fs.stat(fullSubDir);
29
+ if (!stat.isDirectory())
30
+ return [];
31
+ const entries = await fs.readdir(fullSubDir);
32
+ return entries.filter((f) => matcher(f)).map((f) => `${subDir}/${f}`);
33
+ }
34
+ catch {
35
+ return [];
36
+ }
37
+ }
38
+ /**
39
+ * Validate tools/ + tool-impls/ cross-matching.
40
+ * This is special logic that cannot be driven purely by pattern matching.
41
+ * Receives pre-read tool file contents to avoid reading/validating them again.
42
+ */
43
+ async function validateToolImpls(dirPath, toolContents, errors) {
44
+ const toolNames = [];
45
+ const nonServerToolNames = new Set();
46
+ for (const [relPath, content] of toolContents) {
47
+ const name = path.basename(relPath, ".json");
48
+ toolNames.push(name);
25
49
  try {
26
- const content = await fs.readFile(promptPath, "utf-8");
27
- const result = systemPromptPlugin.validate(content, "system-prompt.md");
28
- if (!result.valid) {
29
- errors.push(...result.errors.map((e) => `[system-prompt.md] ${e}`));
50
+ const parsed = JSON.parse(content);
51
+ if (parsed.execution_target === "client" || parsed.execution_target === "host") {
52
+ nonServerToolNames.add(name);
30
53
  }
31
54
  }
32
- catch {
33
- errors.push("Missing required file: system-prompt.md");
34
- }
35
- // Check model.json
36
- const modelPath = path.join(dirPath, "model.json");
37
- try {
38
- const content = await fs.readFile(modelPath, "utf-8");
39
- const result = modelPlugin.validate(content, "model.json");
40
- if (!result.valid) {
41
- errors.push(...result.errors.map((e) => `[model.json] ${e}`));
55
+ catch { /* already validated in main loop */ }
56
+ }
57
+ const serverToolNames = toolNames.filter((n) => !nonServerToolNames.has(n));
58
+ // Check tool-impls/
59
+ const implsDir = path.join(dirPath, "tool-impls");
60
+ try {
61
+ const implStat = await fs.stat(implsDir);
62
+ if (implStat.isDirectory()) {
63
+ const implFiles = await fs.readdir(implsDir);
64
+ const implNames = implFiles
65
+ .filter((f) => f.endsWith(".impl.js"))
66
+ .map((f) => f.replace(/\.impl\.js$/, ""));
67
+ for (const name of serverToolNames) {
68
+ if (!implNames.includes(name)) {
69
+ errors.push({ message: `Missing implementation for tool: ${name}`, severity: "error", file: "tool-impls/" });
70
+ }
71
+ }
72
+ for (const name of implNames) {
73
+ if (!toolNames.includes(name)) {
74
+ errors.push({ message: `No matching tool schema in tools/ for ${name}.impl.js`, severity: "warning", file: `tool-impls/${name}.impl.js` });
75
+ }
42
76
  }
43
77
  }
44
- catch {
45
- errors.push("Missing required file: model.json");
78
+ else {
79
+ errors.push({ message: "tool-impls is not a directory", severity: "error", file: "tool-impls" });
46
80
  }
47
- // Check tools/ directory
48
- const toolsDir = path.join(dirPath, "tools");
81
+ }
82
+ catch {
83
+ if (serverToolNames.length > 0) {
84
+ errors.push({ message: "Missing required directory: tool-impls/", severity: "error", file: "tool-impls/" });
85
+ }
86
+ }
87
+ }
88
+ export const agentDirPlugin = {
89
+ name: "agent-dir",
90
+ async validate(dirPath) {
91
+ const errors = [];
92
+ // Check directory exists
49
93
  try {
50
- const stat = await fs.stat(toolsDir);
94
+ const stat = await fs.stat(dirPath);
51
95
  if (!stat.isDirectory()) {
52
- errors.push("tools is not a directory");
96
+ return { valid: false, errors: [{ message: `${dirPath} is not a directory`, severity: "error", file: dirPath }] };
53
97
  }
54
- else {
55
- const files = await fs.readdir(toolsDir);
56
- const jsonFiles = files.filter((f) => f.endsWith(".json"));
57
- if (jsonFiles.length === 0) {
58
- errors.push("[tools/] No tool definitions found");
59
- }
60
- const toolNames = [];
61
- const nonServerToolNames = new Set();
62
- for (const file of jsonFiles) {
63
- const content = await fs.readFile(path.join(toolsDir, file), "utf-8");
64
- const result = toolPlugin.validate(content, file);
65
- if (!result.valid) {
66
- errors.push(...result.errors.map((e) => `[tools/${file}] ${e}`));
67
- }
68
- else {
69
- const name = file.replace(/\.json$/, "");
70
- toolNames.push(name);
71
- // Check if tool has execution_target that doesn't need server impl
72
- try {
73
- const parsed = JSON.parse(content);
74
- if (parsed.execution_target === "client" || parsed.execution_target === "host") {
75
- nonServerToolNames.add(name);
76
- }
77
- }
78
- catch { /* already validated above */ }
79
- }
98
+ }
99
+ catch {
100
+ return { valid: false, errors: [{ message: `${dirPath} does not exist`, severity: "error", file: dirPath }] };
101
+ }
102
+ const plugins = getPlugins();
103
+ // Track valid tool file contents for cross-matching with tool-impls
104
+ const validToolContents = new Map();
105
+ for (const plugin of plugins) {
106
+ const files = await matchFiles(dirPath, plugin.pattern);
107
+ if (files.length === 0) {
108
+ if (plugin.required) {
109
+ errors.push({
110
+ message: `Missing required file: ${plugin.pattern}`,
111
+ severity: "error",
112
+ file: plugin.pattern,
113
+ });
80
114
  }
81
- // Server tools that need impl files
82
- const serverToolNames = toolNames.filter((n) => !nonServerToolNames.has(n));
83
- // Check tool-impls/ matches server tools
84
- const implsDir = path.join(dirPath, "tool-impls");
85
- try {
86
- const implStat = await fs.stat(implsDir);
87
- if (implStat.isDirectory()) {
88
- const implFiles = await fs.readdir(implsDir);
89
- const implNames = implFiles
90
- .filter((f) => f.endsWith(".impl.js"))
91
- .map((f) => f.replace(/\.impl\.js$/, ""));
92
- // server tools without impl
93
- for (const name of serverToolNames) {
94
- if (!implNames.includes(name)) {
95
- errors.push(`[tool-impls/] Missing implementation for tool: ${name}`);
96
- }
115
+ // Special case: tool plugin needs at least 1 tool file
116
+ if (plugin.name === "tool") {
117
+ // Check if the tools/ directory exists at all
118
+ const toolsDir = path.join(dirPath, "tools");
119
+ try {
120
+ const stat = await fs.stat(toolsDir);
121
+ if (!stat.isDirectory()) {
122
+ errors.push({ message: "tools is not a directory", severity: "error", file: "tools" });
97
123
  }
98
- // impls without tool
99
- for (const name of implNames) {
100
- if (!toolNames.includes(name)) {
101
- errors.push(`[tool-impls/${name}.impl.js] No matching tool schema in tools/`);
102
- }
124
+ else {
125
+ errors.push({ message: "No tool definitions found", severity: "error", file: "tools/" });
103
126
  }
104
127
  }
105
- else {
106
- errors.push("tool-impls is not a directory");
107
- }
108
- }
109
- catch {
110
- if (serverToolNames.length > 0) {
111
- errors.push("Missing required directory: tool-impls/");
128
+ catch {
129
+ errors.push({ message: "Missing required directory: tools/", severity: "error", file: "tools/" });
112
130
  }
113
131
  }
132
+ continue;
114
133
  }
115
- }
116
- catch {
117
- errors.push("Missing required directory: tools/");
118
- }
119
- // Check datasets/ directory (optional)
120
- const datasetsDir = path.join(dirPath, "datasets");
121
- try {
122
- const stat = await fs.stat(datasetsDir);
123
- if (stat.isDirectory()) {
124
- const files = await fs.readdir(datasetsDir);
125
- const jsonFiles = files.filter((f) => f.endsWith(".json"));
126
- for (const file of jsonFiles) {
127
- const content = await fs.readFile(path.join(datasetsDir, file), "utf-8");
128
- const result = datasetPlugin.validate(content, file);
129
- if (!result.valid) {
130
- errors.push(...result.errors.map((e) => `[datasets/${file}] ${e}`));
131
- }
134
+ // Validate each matched file
135
+ for (const relPath of files) {
136
+ const absPath = path.join(dirPath, relPath);
137
+ const content = await fs.readFile(absPath, "utf-8");
138
+ const fileName = path.basename(relPath);
139
+ const result = plugin.validate(content, fileName);
140
+ if (!result.valid) {
141
+ errors.push(...result.errors.map((e) => ({ ...e, file: relPath })));
132
142
  }
133
- }
134
- }
135
- catch {
136
- // datasets/ is optional, skip if not exists
137
- }
138
- // Check skills/ directory (optional)
139
- const skillsDir = path.join(dirPath, "skills");
140
- try {
141
- const stat = await fs.stat(skillsDir);
142
- if (stat.isDirectory()) {
143
- const files = await fs.readdir(skillsDir);
144
- const mdFiles = files.filter((f) => f.endsWith(".md"));
145
- for (const file of mdFiles) {
146
- const content = await fs.readFile(path.join(skillsDir, file), "utf-8");
147
- const result = agentSkillPlugin.validate(content, file);
148
- if (!result.valid) {
149
- errors.push(...result.errors.map((e) => `[skills/${file}] ${e}`));
150
- }
143
+ else if (plugin.name === "tool") {
144
+ // Track valid tool contents for impl cross-matching
145
+ validToolContents.set(relPath, content);
151
146
  }
152
147
  }
153
148
  }
154
- catch {
155
- // skills/ is optional, skip if not exists
156
- }
157
- // Check mcp.json (optional)
158
- const mcpPath = path.join(dirPath, "mcp.json");
159
- try {
160
- const content = await fs.readFile(mcpPath, "utf-8");
161
- const result = mcpPlugin.validate(content, "mcp.json");
162
- if (!result.valid) {
163
- errors.push(...result.errors.map((e) => `[mcp.json] ${e}`));
164
- }
165
- }
166
- catch {
167
- // mcp.json is optional, skip if not exists
149
+ // tools/ + tool-impls/ cross-matching (special logic)
150
+ if (validToolContents.size > 0) {
151
+ await validateToolImpls(dirPath, validToolContents, errors);
168
152
  }
169
153
  return { valid: errors.length === 0, errors };
170
154
  },
@@ -11,20 +11,24 @@ export const agentSkillPlugin = {
11
11
  parsed = matter(content);
12
12
  }
13
13
  catch {
14
- return { valid: false, errors: ["Invalid frontmatter syntax"] };
14
+ return { valid: false, errors: [{ message: "Invalid frontmatter syntax", severity: "error" }] };
15
15
  }
16
16
  if (!parsed.data || Object.keys(parsed.data).length === 0) {
17
- errors.push("Missing frontmatter (---) block");
17
+ errors.push({ message: "Missing frontmatter (---) block", severity: "error" });
18
18
  }
19
19
  else {
20
20
  const result = agentSkillFrontmatterSchema.safeParse(parsed.data);
21
21
  if (!result.success) {
22
- const schemaErrors = formatZodErrors(result.error).map((e) => `frontmatter${e}`);
22
+ const schemaErrors = formatZodErrors(result.error, parsed.data).map((e) => ({
23
+ ...e,
24
+ message: `frontmatter${e.message}`,
25
+ path: e.path ? `frontmatter${e.path}` : e.path,
26
+ }));
23
27
  errors.push(...schemaErrors);
24
28
  }
25
29
  }
26
30
  if (!parsed.content.trim()) {
27
- errors.push("Skill body content is empty");
31
+ errors.push({ message: "Skill body content is empty", severity: "error" });
28
32
  }
29
33
  return { valid: errors.length === 0, errors };
30
34
  },
@@ -9,11 +9,11 @@ export const datasetPlugin = {
9
9
  data = JSON.parse(content);
10
10
  }
11
11
  catch {
12
- return { valid: false, errors: ["Invalid JSON"] };
12
+ return { valid: false, errors: [{ message: "Invalid JSON", severity: "error" }] };
13
13
  }
14
14
  const result = datasetSchema.safeParse(data);
15
15
  if (!result.success) {
16
- return { valid: false, errors: formatZodErrors(result.error) };
16
+ return { valid: false, errors: formatZodErrors(result.error, data, content) };
17
17
  }
18
18
  return { valid: true, errors: [] };
19
19
  },
@@ -9,11 +9,11 @@ export const mcpPlugin = {
9
9
  data = JSON.parse(content);
10
10
  }
11
11
  catch {
12
- return { valid: false, errors: ["Invalid JSON"] };
12
+ return { valid: false, errors: [{ message: "Invalid JSON", severity: "error" }] };
13
13
  }
14
14
  const result = mcpConfigSchema.safeParse(data);
15
15
  if (!result.success) {
16
- return { valid: false, errors: formatZodErrors(result.error) };
16
+ return { valid: false, errors: formatZodErrors(result.error, data, content) };
17
17
  }
18
18
  return { valid: true, errors: [] };
19
19
  },
@@ -3,17 +3,18 @@ import { formatZodErrors } from "../zod-utils.js";
3
3
  export const modelPlugin = {
4
4
  name: "model",
5
5
  pattern: "model.json",
6
+ required: true,
6
7
  validate(content, _fileName) {
7
8
  let data;
8
9
  try {
9
10
  data = JSON.parse(content);
10
11
  }
11
12
  catch {
12
- return { valid: false, errors: ["Invalid JSON"] };
13
+ return { valid: false, errors: [{ message: "Invalid JSON", severity: "error" }] };
13
14
  }
14
15
  const result = modelConfigSchema.safeParse(data);
15
16
  if (!result.success) {
16
- return { valid: false, errors: formatZodErrors(result.error) };
17
+ return { valid: false, errors: formatZodErrors(result.error, data, content) };
17
18
  }
18
19
  return { valid: true, errors: [] };
19
20
  },
@@ -3,10 +3,11 @@ const liquid = new Liquid({ strictVariables: false, strictFilters: true });
3
3
  export const systemPromptPlugin = {
4
4
  name: "system-prompt",
5
5
  pattern: "system-prompt.md",
6
+ required: true,
6
7
  validate(content, _fileName) {
7
8
  const errors = [];
8
9
  if (!content.trim()) {
9
- errors.push("System prompt content is empty");
10
+ errors.push({ message: "System prompt content is empty", severity: "error" });
10
11
  return { valid: false, errors };
11
12
  }
12
13
  // Validate Liquid template syntax if template tags are present
@@ -17,7 +18,7 @@ export const systemPromptPlugin = {
17
18
  }
18
19
  catch (e) {
19
20
  const msg = e instanceof Error ? e.message : String(e);
20
- errors.push(`Liquid template syntax error: ${msg}`);
21
+ errors.push({ message: `Liquid template syntax error: ${msg}`, severity: "error" });
21
22
  }
22
23
  }
23
24
  return { valid: errors.length === 0, errors };
@@ -9,11 +9,11 @@ export const toolPlugin = {
9
9
  data = JSON.parse(content);
10
10
  }
11
11
  catch {
12
- return { valid: false, errors: ["Invalid JSON"] };
12
+ return { valid: false, errors: [{ message: "Invalid JSON", severity: "error" }] };
13
13
  }
14
14
  const result = toolSchemaSchema.safeParse(data);
15
15
  if (!result.success) {
16
- return { valid: false, errors: formatZodErrors(result.error) };
16
+ return { valid: false, errors: formatZodErrors(result.error, data, content) };
17
17
  }
18
18
  return { valid: true, errors: [] };
19
19
  },
@@ -1,3 +1,10 @@
1
1
  import type { ZodError } from "zod";
2
- /** Format Zod errors to match the AJV-style output: `/{path} {message}` */
3
- export declare function formatZodErrors(error: ZodError): string[];
2
+ import type { ValidationError } from "./plugin.js";
3
+ /**
4
+ * Given a raw JSON string, find the 1-based line number where a key at
5
+ * the given path (e.g. ["provider"] or ["input_schema", "type"]) appears.
6
+ * Returns undefined if the key cannot be located.
7
+ */
8
+ export declare function getLineForPath(jsonString: string, pathSegments: (string | number)[]): number | undefined;
9
+ /** Format Zod errors into structured ValidationError objects */
10
+ export declare function formatZodErrors(error: ZodError, data?: unknown, rawJson?: string): ValidationError[];
@@ -1,7 +1,96 @@
1
- /** Format Zod errors to match the AJV-style output: `/{path} {message}` */
2
- export function formatZodErrors(error) {
1
+ /** Traverse an object using a path array to get the value at that path */
2
+ function getValueAtPath(data, path) {
3
+ let current = data;
4
+ for (const key of path) {
5
+ if (current === null || current === undefined)
6
+ return undefined;
7
+ current = current[key];
8
+ }
9
+ return current;
10
+ }
11
+ /** Extract expected/received from a Zod issue based on its code */
12
+ function extractDetails(issue, data) {
13
+ const result = {};
14
+ switch (issue.code) {
15
+ case "invalid_enum_value":
16
+ result.expected = issue.options?.join(" | ");
17
+ result.received = String(issue.received);
18
+ break;
19
+ case "too_big":
20
+ result.expected = `<= ${issue.maximum}`;
21
+ result.received = String(issue.value ?? "");
22
+ break;
23
+ case "too_small":
24
+ result.expected = `>= ${issue.minimum}`;
25
+ result.received = String(issue.value ?? "");
26
+ break;
27
+ case "invalid_string": {
28
+ const validation = issue.validation;
29
+ if (typeof validation === "object" && validation !== null && "regex" in validation) {
30
+ result.expected = `regex: ${validation.regex}`;
31
+ }
32
+ else {
33
+ result.expected = `${validation}`;
34
+ }
35
+ // Zod doesn't include received for invalid_string — look up from original data
36
+ const issueReceived = issue.received;
37
+ if (issueReceived !== undefined) {
38
+ result.received = String(issueReceived);
39
+ }
40
+ else if (data !== undefined) {
41
+ const val = getValueAtPath(data, issue.path);
42
+ if (val !== undefined) {
43
+ result.received = String(val);
44
+ }
45
+ }
46
+ break;
47
+ }
48
+ case "invalid_type":
49
+ result.expected = String(issue.expected);
50
+ result.received = String(issue.received);
51
+ break;
52
+ case "unrecognized_keys":
53
+ result.received = issue.keys?.join(", ");
54
+ break;
55
+ }
56
+ return result;
57
+ }
58
+ /**
59
+ * Given a raw JSON string, find the 1-based line number where a key at
60
+ * the given path (e.g. ["provider"] or ["input_schema", "type"]) appears.
61
+ * Returns undefined if the key cannot be located.
62
+ */
63
+ export function getLineForPath(jsonString, pathSegments) {
64
+ if (pathSegments.length === 0)
65
+ return undefined;
66
+ const lastKey = pathSegments[pathSegments.length - 1];
67
+ if (typeof lastKey === "number")
68
+ return undefined;
69
+ // Build a regex that matches `"key":` — we search for the last segment
70
+ const escaped = lastKey.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
71
+ const pattern = new RegExp(`"${escaped}"\\s*:`);
72
+ const lines = jsonString.split("\n");
73
+ // For simple (flat) paths, find the first occurrence
74
+ // For nested paths, we'd need smarter logic, but for typical JSON configs this works
75
+ for (let i = 0; i < lines.length; i++) {
76
+ if (pattern.test(lines[i])) {
77
+ return i + 1; // 1-based
78
+ }
79
+ }
80
+ return undefined;
81
+ }
82
+ /** Format Zod errors into structured ValidationError objects */
83
+ export function formatZodErrors(error, data, rawJson) {
3
84
  return error.issues.map((issue) => {
4
85
  const path = issue.path.length > 0 ? `/${issue.path.join("/")}` : "/";
5
- return `${path} ${issue.message}`;
86
+ const details = extractDetails(issue, data);
87
+ const line = rawJson ? getLineForPath(rawJson, issue.path) : undefined;
88
+ return {
89
+ message: `${path} ${issue.message}`,
90
+ severity: "error",
91
+ path,
92
+ ...details,
93
+ ...(line !== undefined ? { line } : {}),
94
+ };
6
95
  });
7
96
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@archon-claw/cli",
3
- "version": "0.0.4",
3
+ "version": "0.1.0",
4
4
  "description": "AI Agent CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,10 +8,12 @@
8
8
  },
9
9
  "scripts": {
10
10
  "build": "tsc -p tsconfig.build.json && cp -r templates dist/ && rm -rf dist/public && cp -r ../web/dist dist/public",
11
- "dev": "tsx src/cli.ts start ../../agents/my-agent",
11
+ "dev": "tsx src/cli.ts start examples/my-agent",
12
12
  "start": "node dist/cli.js",
13
- "test": "vitest run --exclude dist",
14
- "test:watch": "vitest --exclude dist"
13
+ "test": "vitest run --exclude dist --exclude examples",
14
+ "test:watch": "vitest --exclude dist --exclude examples",
15
+ "e2e": "playwright test",
16
+ "e2e:ui": "playwright test --ui"
15
17
  },
16
18
  "files": [
17
19
  "dist"
@@ -21,6 +23,8 @@
21
23
  },
22
24
  "license": "MIT",
23
25
  "devDependencies": {
26
+ "@archon-claw/web": "workspace:*",
27
+ "@playwright/test": "^1.58.2",
24
28
  "@types/node": "^25.3.3",
25
29
  "@types/picomatch": "^4.0.2",
26
30
  "@vitest/coverage-v8": "^4.0.18",
@@ -29,12 +33,13 @@
29
33
  "vitest": "^4.0.18"
30
34
  },
31
35
  "dependencies": {
32
- "@archon-claw/web": "workspace:*",
33
36
  "@modelcontextprotocol/sdk": "^1.27.1",
37
+ "chokidar": "^5.0.0",
34
38
  "commander": "^14.0.3",
35
39
  "dotenv": "^17.3.1",
36
40
  "gray-matter": "^4.0.3",
37
41
  "liquidjs": "^10.24.0",
42
+ "open": "^10.2.0",
38
43
  "openai": "^6.25.0",
39
44
  "picomatch": "^4.0.3",
40
45
  "zod": "^3.24.0"