@swarp/cli 0.0.1-rc.17

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.
@@ -0,0 +1,265 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { X509Certificate } from 'node:crypto';
6
+ import { generateCerts, rotateCert } from './generate.mjs';
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Helpers
10
+ // ---------------------------------------------------------------------------
11
+
12
+ function makeTmpDir() {
13
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'swarp-certs-test-'));
14
+ }
15
+
16
+ function cleanDir(dir) {
17
+ fs.rmSync(dir, { recursive: true, force: true });
18
+ }
19
+
20
+ /**
21
+ * Builds an in-memory filesystem shim that captures writes.
22
+ * Files are stored as strings keyed by absolute path.
23
+ */
24
+ function makeMemFs(initialFiles = {}) {
25
+ const store = { ...initialFiles };
26
+ return {
27
+ _store: store,
28
+ existsSync(p) { return Object.prototype.hasOwnProperty.call(store, p); },
29
+ mkdirSync(_p, _opts) {},
30
+ writeFileSync(p, data) { store[p] = data; },
31
+ readFileSync(p, _enc) {
32
+ if (!Object.prototype.hasOwnProperty.call(store, p)) {
33
+ throw Object.assign(new Error(`ENOENT: ${p}`), { code: 'ENOENT' });
34
+ }
35
+ return store[p];
36
+ },
37
+ appendFileSync(p, data) {
38
+ store[p] = (store[p] ?? '') + data;
39
+ },
40
+ };
41
+ }
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // generateCerts — output files
45
+ // ---------------------------------------------------------------------------
46
+
47
+ describe('generateCerts', () => {
48
+ let tmpDir;
49
+
50
+ beforeEach(() => {
51
+ tmpDir = makeTmpDir();
52
+ vi.spyOn(console, 'log').mockImplementation(() => {});
53
+ });
54
+
55
+ afterEach(() => {
56
+ cleanDir(tmpDir);
57
+ vi.restoreAllMocks();
58
+ });
59
+
60
+ it('creates all six output files', async () => {
61
+ await generateCerts(path.join(tmpDir, 'certs'));
62
+
63
+ const expected = [
64
+ 'ca.crt',
65
+ 'ca.key',
66
+ 'router.crt',
67
+ 'router.key',
68
+ 'agent-example.crt',
69
+ 'agent-example.key',
70
+ ];
71
+ for (const name of expected) {
72
+ expect(fs.existsSync(path.join(tmpDir, 'certs', name)), `${name} must exist`).toBe(true);
73
+ }
74
+ });
75
+
76
+ it('ca.crt is a valid PEM certificate', async () => {
77
+ await generateCerts(path.join(tmpDir, 'certs'));
78
+ const pem = fs.readFileSync(path.join(tmpDir, 'certs', 'ca.crt'), 'utf8');
79
+ expect(() => new X509Certificate(pem)).not.toThrow();
80
+ });
81
+
82
+ it('ca.crt has isCA = true', async () => {
83
+ await generateCerts(path.join(tmpDir, 'certs'));
84
+ const pem = fs.readFileSync(path.join(tmpDir, 'certs', 'ca.crt'), 'utf8');
85
+ const cert = new X509Certificate(pem);
86
+ expect(cert.ca).toBe(true);
87
+ });
88
+
89
+ it('ca.crt has CN = swarp-ca', async () => {
90
+ await generateCerts(path.join(tmpDir, 'certs'));
91
+ const pem = fs.readFileSync(path.join(tmpDir, 'certs', 'ca.crt'), 'utf8');
92
+ const cert = new X509Certificate(pem);
93
+ expect(cert.subject).toContain('CN=swarp-ca');
94
+ });
95
+
96
+ it('router.crt has CN = swarp-router', async () => {
97
+ await generateCerts(path.join(tmpDir, 'certs'));
98
+ const pem = fs.readFileSync(path.join(tmpDir, 'certs', 'router.crt'), 'utf8');
99
+ const cert = new X509Certificate(pem);
100
+ expect(cert.subject).toContain('CN=swarp-router');
101
+ });
102
+
103
+ it('agent-example.crt has CN = example', async () => {
104
+ await generateCerts(path.join(tmpDir, 'certs'));
105
+ const pem = fs.readFileSync(path.join(tmpDir, 'certs', 'agent-example.crt'), 'utf8');
106
+ const cert = new X509Certificate(pem);
107
+ expect(cert.subject).toContain('CN=example');
108
+ });
109
+
110
+ it('router.crt is signed by CA', async () => {
111
+ await generateCerts(path.join(tmpDir, 'certs'));
112
+ const caPem = fs.readFileSync(path.join(tmpDir, 'certs', 'ca.crt'), 'utf8');
113
+ const routerPem = fs.readFileSync(path.join(tmpDir, 'certs', 'router.crt'), 'utf8');
114
+
115
+ const caCert = new X509Certificate(caPem);
116
+ const routerCert = new X509Certificate(routerPem);
117
+
118
+ // verify() checks the signature — this is the authoritative "signed by CA" check
119
+ expect(routerCert.verify(caCert.publicKey)).toBe(true);
120
+ });
121
+
122
+ it('agent-example.crt is signed by CA', async () => {
123
+ await generateCerts(path.join(tmpDir, 'certs'));
124
+ const caPem = fs.readFileSync(path.join(tmpDir, 'certs', 'ca.crt'), 'utf8');
125
+ const agentPem = fs.readFileSync(path.join(tmpDir, 'certs', 'agent-example.crt'), 'utf8');
126
+
127
+ const caCert = new X509Certificate(caPem);
128
+ const agentCert = new X509Certificate(agentPem);
129
+
130
+ // verify() checks the signature — this is the authoritative "signed by CA" check
131
+ expect(agentCert.verify(caCert.publicKey)).toBe(true);
132
+ });
133
+
134
+ it('ca.key is valid PKCS8 PEM', async () => {
135
+ await generateCerts(path.join(tmpDir, 'certs'));
136
+ const keyPem = fs.readFileSync(path.join(tmpDir, 'certs', 'ca.key'), 'utf8');
137
+ expect(keyPem).toMatch(/-----BEGIN PRIVATE KEY-----/);
138
+ });
139
+
140
+ it('prints security instructions mentioning GitHub Secrets', async () => {
141
+ const logSpy = vi.spyOn(console, 'log');
142
+ await generateCerts(path.join(tmpDir, 'certs'));
143
+ const output = logSpy.mock.calls.map((c) => String(c[0])).join('\n');
144
+ expect(output).toContain('GitHub Secrets');
145
+ expect(output).toContain('Never commit');
146
+ });
147
+
148
+ it('adds certs/ to .gitignore when file exists and entry is absent', async () => {
149
+ // ensureGitignoreEntry resolves .gitignore relative to process.cwd()
150
+ const gitignorePath = path.resolve('.gitignore');
151
+ const memFs = makeMemFs({
152
+ [gitignorePath]: 'node_modules/\n',
153
+ });
154
+
155
+ await generateCerts(path.join(tmpDir, 'certs'), { fs: memFs });
156
+
157
+ // The entry should be 'certs/' (basename of the outDir)
158
+ expect(memFs._store[gitignorePath]).toContain('certs/');
159
+ });
160
+
161
+ it('does not duplicate certs/ in .gitignore when already present', async () => {
162
+ const gitignorePath = path.resolve('.gitignore');
163
+ const memFs = makeMemFs({
164
+ [gitignorePath]: 'node_modules/\ncerts/\n',
165
+ });
166
+
167
+ await generateCerts(path.join(tmpDir, 'certs'), { fs: memFs });
168
+
169
+ const content = memFs._store[gitignorePath];
170
+ const count = (content.match(/certs\//g) ?? []).length;
171
+ expect(count).toBe(1);
172
+ });
173
+ });
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // rotateCert
177
+ // ---------------------------------------------------------------------------
178
+
179
+ describe('rotateCert', () => {
180
+ let tmpDir;
181
+
182
+ beforeEach(() => {
183
+ tmpDir = makeTmpDir();
184
+ vi.spyOn(console, 'log').mockImplementation(() => {});
185
+ });
186
+
187
+ afterEach(() => {
188
+ cleanDir(tmpDir);
189
+ vi.restoreAllMocks();
190
+ });
191
+
192
+ it('generates agent-<name>.crt and agent-<name>.key', async () => {
193
+ const certsDir = path.join(tmpDir, 'certs');
194
+ await generateCerts(certsDir);
195
+ await rotateCert('myagent', certsDir);
196
+
197
+ expect(fs.existsSync(path.join(certsDir, 'agent-myagent.crt'))).toBe(true);
198
+ expect(fs.existsSync(path.join(certsDir, 'agent-myagent.key'))).toBe(true);
199
+ });
200
+
201
+ it('rotated cert is signed by the existing CA', async () => {
202
+ const certsDir = path.join(tmpDir, 'certs');
203
+ await generateCerts(certsDir);
204
+ await rotateCert('myagent', certsDir);
205
+
206
+ const caPem = fs.readFileSync(path.join(certsDir, 'ca.crt'), 'utf8');
207
+ const agentPem = fs.readFileSync(path.join(certsDir, 'agent-myagent.crt'), 'utf8');
208
+
209
+ const caCert = new X509Certificate(caPem);
210
+ const agentCert = new X509Certificate(agentPem);
211
+
212
+ expect(agentCert.verify(caCert.publicKey)).toBe(true);
213
+ });
214
+
215
+ it('rotated cert has the correct CN', async () => {
216
+ const certsDir = path.join(tmpDir, 'certs');
217
+ await generateCerts(certsDir);
218
+ await rotateCert('myagent', certsDir);
219
+
220
+ const pem = fs.readFileSync(path.join(certsDir, 'agent-myagent.crt'), 'utf8');
221
+ const cert = new X509Certificate(pem);
222
+ expect(cert.subject).toContain('CN=myagent');
223
+ });
224
+
225
+ it('generates a fresh keypair on rotation (cert differs from original)', async () => {
226
+ const certsDir = path.join(tmpDir, 'certs');
227
+ await generateCerts(certsDir);
228
+ const originalPem = fs.readFileSync(path.join(certsDir, 'agent-example.crt'), 'utf8');
229
+
230
+ await rotateCert('example', certsDir);
231
+ const rotatedPem = fs.readFileSync(path.join(certsDir, 'agent-example.crt'), 'utf8');
232
+
233
+ // Each cert has a random serial so PEM strings differ
234
+ expect(rotatedPem).not.toBe(originalPem);
235
+ });
236
+
237
+ it('throws when ca.key is missing', async () => {
238
+ const memFs = makeMemFs({
239
+ [path.join(tmpDir, 'certs', 'ca.crt')]: 'some-cert',
240
+ });
241
+ // ca.key is NOT in memFs
242
+ await expect(
243
+ rotateCert('agent1', path.join(tmpDir, 'certs'), { fs: memFs }),
244
+ ).rejects.toThrow(/CA files not found/);
245
+ });
246
+
247
+ it('throws when ca.crt is missing', async () => {
248
+ const memFs = makeMemFs({
249
+ [path.join(tmpDir, 'certs', 'ca.key')]: 'some-key',
250
+ });
251
+ // ca.crt is NOT in memFs
252
+ await expect(
253
+ rotateCert('agent1', path.join(tmpDir, 'certs'), { fs: memFs }),
254
+ ).rejects.toThrow(/CA files not found/);
255
+ });
256
+
257
+ it('prints GitHub Secrets update instructions', async () => {
258
+ const certsDir = path.join(tmpDir, 'certs');
259
+ await generateCerts(certsDir);
260
+ const logSpy = vi.spyOn(console, 'log');
261
+ await rotateCert('ranger', certsDir);
262
+ const output = logSpy.mock.calls.map((c) => String(c[0])).join('\n');
263
+ expect(output).toContain('SWARP_AGENT_RANGER_CERT');
264
+ });
265
+ });
package/src/config.mjs ADDED
@@ -0,0 +1,11 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+
4
+ export function loadConfig(configPath = '.swarp.json') {
5
+ const fullPath = resolve(configPath);
6
+ if (!existsSync(fullPath)) {
7
+ throw new Error(`SWARP config not found at ${fullPath}. Run "swarp init" to create it.`);
8
+ }
9
+ const raw = readFileSync(fullPath, 'utf-8');
10
+ return JSON.parse(raw);
11
+ }
@@ -0,0 +1,164 @@
1
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
2
+ import { resolve, join } from 'node:path';
3
+ import yaml from 'js-yaml';
4
+ import { validateAgentConfig } from './schema.mjs';
5
+
6
+ export function discoverAgents(agentsDir) {
7
+ const dir = resolve(agentsDir);
8
+ const agents = [];
9
+
10
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
11
+ if (!entry.isDirectory() || entry.name === 'shared') continue;
12
+ const yamlPath = join(dir, entry.name, 'agent.yaml');
13
+ if (!existsSync(yamlPath)) continue;
14
+
15
+ const raw = readFileSync(yamlPath, 'utf-8');
16
+ const config = yaml.load(raw);
17
+ agents.push({
18
+ name: config.name ?? entry.name,
19
+ dir: join(dir, entry.name),
20
+ config,
21
+ yamlPath,
22
+ });
23
+ }
24
+
25
+ return agents;
26
+ }
27
+
28
+ export function auditAgent(agent, options = {}) {
29
+ const { skipRemote = false } = options;
30
+ const checks = [];
31
+ const { config, dir } = agent;
32
+
33
+ const schemaErrors = validateAgentConfig(config);
34
+ checks.push({
35
+ name: 'schema_valid',
36
+ severity: 'error',
37
+ status: schemaErrors.length === 0 ? 'pass' : 'fail',
38
+ message: schemaErrors.length === 0 ? 'Schema is valid' : schemaErrors.join('; '),
39
+ });
40
+
41
+ const dirName = agent.dir.split('/').pop();
42
+ if (dirName !== config.name) {
43
+ checks.push({
44
+ name: 'name_match',
45
+ severity: 'warning',
46
+ status: 'fail',
47
+ message: `Directory "${dirName}" doesn't match agent name "${config.name}"`,
48
+ });
49
+ }
50
+
51
+ if (config.persona?.avatar) {
52
+ const exists = existsSync(join(dir, config.persona.avatar));
53
+ checks.push({
54
+ name: 'avatar_exists',
55
+ severity: 'warning',
56
+ status: exists ? 'pass' : 'fail',
57
+ message: exists ? 'Avatar found' : `Avatar missing: ${config.persona.avatar}`,
58
+ });
59
+ }
60
+
61
+ for (const [modeName, mode] of Object.entries(config.modes || {})) {
62
+ if (mode.prompt && mode.input) {
63
+ const refs = [...mode.prompt.matchAll(/\{\{\s*(\w+)/g)].map((m) => m[1]);
64
+ const inputs = Object.keys(mode.input);
65
+ const builtins = ['persona', 'display_name', 'message'];
66
+ const missing = refs.filter((r) => !inputs.includes(r) && !builtins.includes(r));
67
+ checks.push({
68
+ name: `prompt_refs_${modeName}`,
69
+ severity: 'error',
70
+ status: missing.length === 0 ? 'pass' : 'fail',
71
+ message:
72
+ missing.length === 0
73
+ ? `${modeName}: prompt refs valid`
74
+ : `${modeName}: undefined refs: ${missing.join(', ')}`,
75
+ });
76
+ }
77
+
78
+ if (mode.prompt && mode.output?.schema) {
79
+ const keys = Object.keys(mode.output.schema);
80
+ const missing = keys.filter((k) => !mode.prompt.includes(k));
81
+ checks.push({
82
+ name: `output_schema_${modeName}`,
83
+ severity: 'warning',
84
+ status: missing.length === 0 ? 'pass' : 'fail',
85
+ message:
86
+ missing.length === 0
87
+ ? `${modeName}: output keys in prompt`
88
+ : `${modeName}: keys missing from prompt: ${missing.join(', ')}`,
89
+ });
90
+ }
91
+
92
+ const modeSkills = mode.skills || config.skills || [];
93
+ if (modeSkills.length > 0) {
94
+ const hasSkill = mode.tools?.allowed?.includes('Skill');
95
+ checks.push({
96
+ name: `skill_access_${modeName}`,
97
+ severity: 'error',
98
+ status: hasSkill ? 'pass' : 'fail',
99
+ message: hasSkill
100
+ ? `${modeName}: Skill in allowedTools`
101
+ : `${modeName}: has skills but Skill not in allowedTools`,
102
+ });
103
+ }
104
+
105
+ if (modeName === 'chat' && mode.model !== 'haiku') {
106
+ checks.push({
107
+ name: `budget_${modeName}`,
108
+ severity: 'warning',
109
+ status: 'fail',
110
+ message: `${modeName}: should use haiku, using ${mode.model}`,
111
+ });
112
+ }
113
+
114
+ if (mode.max_turns > 100) {
115
+ checks.push({
116
+ name: `budget_turns_${modeName}`,
117
+ severity: 'warning',
118
+ status: 'fail',
119
+ message: `${modeName}: max_turns=${mode.max_turns} excessive`,
120
+ });
121
+ }
122
+ }
123
+
124
+ const settingsPath = join(dir, 'settings.generated.json');
125
+ checks.push({
126
+ name: 'settings_generated',
127
+ severity: 'warning',
128
+ status: existsSync(settingsPath) ? 'pass' : 'skip',
129
+ message: existsSync(settingsPath)
130
+ ? 'settings.generated.json exists'
131
+ : 'Not yet generated',
132
+ });
133
+
134
+ if (!skipRemote) {
135
+ checks.push({
136
+ name: 'remote_env',
137
+ severity: 'error',
138
+ status: 'skip',
139
+ message: 'Remote env check — not yet implemented',
140
+ });
141
+ checks.push({
142
+ name: 'remote_plugins',
143
+ severity: 'warning',
144
+ status: 'skip',
145
+ message: 'Remote plugin check — not yet implemented',
146
+ });
147
+ }
148
+
149
+ return { agent: config.name, checks };
150
+ }
151
+
152
+ export function auditConfigs(agentsDir) {
153
+ const dir = resolve(agentsDir);
154
+ if (!existsSync(dir)) {
155
+ throw new Error(`Agents directory not found: ${dir}`);
156
+ }
157
+
158
+ const agents = discoverAgents(dir);
159
+ if (agents.length === 0) {
160
+ throw new Error(`No agent.yaml files found in ${dir}`);
161
+ }
162
+
163
+ return agents.map((agent) => auditAgent(agent, { skipRemote: true }));
164
+ }
@@ -0,0 +1,105 @@
1
+ import { readFileSync, writeFileSync, existsSync, readdirSync } from 'node:fs';
2
+ import { resolve, join } from 'node:path';
3
+ import yaml from 'js-yaml';
4
+ import { validateAgentConfig } from './schema.mjs';
5
+
6
+ export function renderPrompt(template, params, persona = {}) {
7
+ let result = template;
8
+ if (persona.bio) {
9
+ result = result.replace(/\{\{\s*persona\.bio\s*\}\}/g, persona.bio);
10
+ }
11
+ for (const [key, val] of Object.entries(params)) {
12
+ const re = new RegExp(`\\{\\{\\s*${key}\\s*\\}\\}`, 'g');
13
+ result = result.replace(re, String(val));
14
+ }
15
+ return result;
16
+ }
17
+
18
+ export function parseStructuredOutput(resultText) {
19
+ if (!resultText || !resultText.trim()) {
20
+ return { structured: null, parseError: 'empty result' };
21
+ }
22
+
23
+ const text = resultText.trim();
24
+ let depth = 0;
25
+ let end = -1;
26
+ let start = -1;
27
+
28
+ for (let i = text.length - 1; i >= 0; i--) {
29
+ const ch = text[i];
30
+ if (ch === '}' && end === -1) {
31
+ end = i;
32
+ depth = 1;
33
+ } else if (end !== -1) {
34
+ if (ch === '}') depth++;
35
+ if (ch === '{') depth--;
36
+ if (depth === 0) {
37
+ start = i;
38
+ break;
39
+ }
40
+ }
41
+ }
42
+
43
+ if (start === -1 || end === -1) {
44
+ return { structured: null, parseError: 'no JSON object found' };
45
+ }
46
+
47
+ try {
48
+ const jsonStr = text.slice(start, end + 1);
49
+ return { structured: JSON.parse(jsonStr), parseError: null };
50
+ } catch (e) {
51
+ return { structured: null, parseError: `JSON parse failed: ${e.message}` };
52
+ }
53
+ }
54
+
55
+ export function buildRunnerConfig(agentConfig) {
56
+ const modes = {};
57
+ for (const [name, mode] of Object.entries(agentConfig.modes ?? {})) {
58
+ modes[name] = {
59
+ description: mode.description,
60
+ model: mode.model,
61
+ max_turns: mode.max_turns,
62
+ timeout_minutes: mode.timeout_minutes,
63
+ allowed_tools: mode.tools?.allowed ?? [],
64
+ blocked_tools: mode.tools?.blocked ?? [],
65
+ required_inputs: Object.entries(mode.input ?? {})
66
+ .filter(([, v]) => v && typeof v === 'object' && v.required)
67
+ .map(([k]) => k),
68
+ input_schema: mode.input ?? {},
69
+ output_schema: mode.output?.schema ?? {},
70
+ prompt: mode.prompt ?? '',
71
+ };
72
+ }
73
+
74
+ return {
75
+ name: agentConfig.name,
76
+ version: agentConfig.version ?? '1.0.0',
77
+ grpc_port: agentConfig.grpc_port ?? 50052,
78
+ router_url: agentConfig.router_url ?? '${SWARP_ROUTER_URL}',
79
+ preamble: agentConfig.preamble ?? '',
80
+ persona_bio: agentConfig.persona?.bio ?? '',
81
+ modes,
82
+ };
83
+ }
84
+
85
+ export async function generateRunnerConfig(agentsDir) {
86
+ const dir = resolve(agentsDir);
87
+
88
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
89
+ if (!entry.isDirectory() || entry.name === 'shared') continue;
90
+ const yamlPath = join(dir, entry.name, 'agent.yaml');
91
+ if (!existsSync(yamlPath)) continue;
92
+
93
+ const agentConfig = yaml.load(readFileSync(yamlPath, 'utf-8'));
94
+
95
+ const errors = validateAgentConfig(agentConfig);
96
+ if (errors.length > 0) {
97
+ throw new Error(`${agentConfig.name}: schema errors — ${errors.join('; ')}`);
98
+ }
99
+
100
+ const runnerConfig = buildRunnerConfig(agentConfig);
101
+ const outPath = join(dir, entry.name, 'runner.generated.yaml');
102
+ writeFileSync(outPath, yaml.dump(runnerConfig));
103
+ console.log(`Generated ${outPath}`);
104
+ }
105
+ }
@@ -0,0 +1,105 @@
1
+ import { z } from 'zod';
2
+
3
+ const VALID_MODELS = ['haiku', 'sonnet', 'opus'];
4
+ const RESERVED_NAMES = ['status', 'health', 'keepalive', 'modes', 'task'];
5
+ const BUILTIN_VARS = ['persona', 'display_name', 'message'];
6
+
7
+ const ToolsSchema = z.object({
8
+ allowed: z.array(z.string()),
9
+ blocked: z.array(z.string()),
10
+ });
11
+
12
+ const ModeSchema = z.object({
13
+ description: z.string(),
14
+ model: z.enum(VALID_MODELS),
15
+ max_turns: z.number().int().positive(),
16
+ timeout_minutes: z.number().positive(),
17
+ tools: ToolsSchema,
18
+ input: z.record(z.unknown()),
19
+ output: z.object({ schema: z.record(z.unknown()).optional() }).optional(),
20
+ prompt: z.string(),
21
+ skills: z.array(z.string()).optional(),
22
+ }).passthrough();
23
+
24
+ const PersonaSchema = z.object({
25
+ role: z.string(),
26
+ bio: z.string(),
27
+ avatar: z.string(),
28
+ }).passthrough();
29
+
30
+ const AgentConfigSchema = z.object({
31
+ name: z.string(),
32
+ display_name: z.string(),
33
+ sprite: z.string(),
34
+ sprite_url: z.string(),
35
+ region: z.string(),
36
+ persona: PersonaSchema,
37
+ preamble: z.string(),
38
+ modes: z.record(ModeSchema),
39
+ skills: z.array(z.string()).optional(),
40
+ }).passthrough();
41
+
42
+ export function validateAgentConfig(raw) {
43
+ const zodResult = AgentConfigSchema.safeParse(raw);
44
+ const errors = [];
45
+
46
+ if (!zodResult.success) {
47
+ for (const issue of zodResult.error.issues) {
48
+ errors.push(`${issue.path.join('.')}: ${issue.message}`);
49
+ }
50
+ }
51
+
52
+ if (typeof raw === 'object' && raw !== null) {
53
+ if (raw.modes && typeof raw.modes === 'object') {
54
+ if (!raw.modes.chat) {
55
+ errors.push('Every agent must have a chat mode');
56
+ }
57
+
58
+ for (const [name, mode] of Object.entries(raw.modes)) {
59
+ const p = `modes.${name}`;
60
+
61
+ if (RESERVED_NAMES.includes(name)) {
62
+ errors.push(`${p}: "${name}" is a reserved mode name`);
63
+ }
64
+
65
+ if (name === 'chat' && typeof mode === 'object' && mode !== null) {
66
+ if (mode.model && mode.model !== 'haiku') {
67
+ errors.push(`${p}: chat must use haiku, got "${mode.model}"`);
68
+ }
69
+ if (mode.max_turns !== undefined && mode.max_turns !== 1) {
70
+ errors.push(`${p}: chat must have max_turns: 1, got ${mode.max_turns}`);
71
+ }
72
+ if (!mode.input?.message) {
73
+ errors.push(`${p}: chat must have message input`);
74
+ }
75
+ if (mode.tools?.allowed?.length > 0) {
76
+ errors.push(`${p}: chat must have tools.allowed: [] (no tools)`);
77
+ }
78
+ }
79
+
80
+ if (typeof mode === 'object' && mode !== null) {
81
+ const modeSkills = mode.skills || raw.skills || [];
82
+ if (modeSkills.length > 0 && mode.tools?.allowed && !mode.tools.allowed.includes('Skill')) {
83
+ errors.push(`${p}: has skills but "Skill" not in tools.allowed`);
84
+ }
85
+
86
+ if (mode.prompt && mode.input) {
87
+ const refs = [...mode.prompt.matchAll(/\{\{\s*(\w+)/g)].map((m) => m[1]);
88
+ const inputs = Object.keys(mode.input);
89
+ for (const ref of refs) {
90
+ if (!inputs.includes(ref) && !BUILTIN_VARS.includes(ref)) {
91
+ errors.push(`${p}: prompt references "{{ ${ref} }}" but no matching input`);
92
+ }
93
+ }
94
+ }
95
+
96
+ if (mode.max_turns > 100) {
97
+ errors.push(`${p}: max_turns=${mode.max_turns} seems excessive (>100)`);
98
+ }
99
+ }
100
+ }
101
+ }
102
+ }
103
+
104
+ return errors;
105
+ }