@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 +19 -0
- package/dist/config.js +3 -2
- package/dist/dev.d.ts +20 -0
- package/dist/dev.js +121 -0
- package/dist/server.d.ts +1 -1
- package/dist/server.js +3 -1
- package/dist/validator/index.d.ts +3 -1
- package/dist/validator/index.js +10 -2
- package/dist/validator/plugin.d.ts +13 -1
- package/dist/validator/plugins/agent-dir.js +125 -141
- package/dist/validator/plugins/agent-skill.js +8 -4
- package/dist/validator/plugins/dataset.js +2 -2
- package/dist/validator/plugins/mcp.js +2 -2
- package/dist/validator/plugins/model.js +3 -2
- package/dist/validator/plugins/system-prompt.js +3 -2
- package/dist/validator/plugins/tool.js +2 -2
- package/dist/validator/zod-utils.d.ts +9 -2
- package/dist/validator/zod-utils.js +92 -3
- package/package.json +10 -5
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
|
-
|
|
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(
|
|
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(
|
|
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 */
|
package/dist/validator/index.js
CHANGED
|
@@ -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:
|
|
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
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
16
|
-
|
|
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
|
|
19
|
+
return [];
|
|
22
20
|
}
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
45
|
-
errors.push("
|
|
78
|
+
else {
|
|
79
|
+
errors.push({ message: "tool-impls is not a directory", severity: "error", file: "tool-impls" });
|
|
46
80
|
}
|
|
47
|
-
|
|
48
|
-
|
|
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(
|
|
94
|
+
const stat = await fs.stat(dirPath);
|
|
51
95
|
if (!stat.isDirectory()) {
|
|
52
|
-
errors
|
|
96
|
+
return { valid: false, errors: [{ message: `${dirPath} is not a directory`, severity: "error", file: dirPath }] };
|
|
53
97
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
|
|
106
|
-
errors.push(
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
-
|
|
155
|
-
|
|
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) =>
|
|
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
|
-
|
|
3
|
-
|
|
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
|
-
/**
|
|
2
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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"
|