@zenera/cli 1.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/LICENSE +21 -0
- package/README.md +239 -0
- package/dist/args.d.ts +40 -0
- package/dist/args.js +99 -0
- package/dist/audit.d.ts +53 -0
- package/dist/audit.js +144 -0
- package/dist/banner.d.ts +13 -0
- package/dist/banner.js +103 -0
- package/dist/command.d.ts +14 -0
- package/dist/command.js +12 -0
- package/dist/commands/check.d.ts +3 -0
- package/dist/commands/check.js +287 -0
- package/dist/commands/index.d.ts +22 -0
- package/dist/commands/index.js +56 -0
- package/dist/commands/init.d.ts +3 -0
- package/dist/commands/init.js +157 -0
- package/dist/commands/inspect.d.ts +3 -0
- package/dist/commands/inspect.js +158 -0
- package/dist/commands/key.d.ts +3 -0
- package/dist/commands/key.js +335 -0
- package/dist/commands/list.d.ts +3 -0
- package/dist/commands/list.js +101 -0
- package/dist/commands/models.d.ts +9 -0
- package/dist/commands/models.js +120 -0
- package/dist/commands/open.d.ts +9 -0
- package/dist/commands/open.js +270 -0
- package/dist/commands/run.d.ts +3 -0
- package/dist/commands/run.js +167 -0
- package/dist/commands/sandbox.d.ts +3 -0
- package/dist/commands/sandbox.js +112 -0
- package/dist/commands/version.d.ts +6 -0
- package/dist/commands/version.js +39 -0
- package/dist/engine.d.ts +49 -0
- package/dist/engine.js +208 -0
- package/dist/external.d.ts +10 -0
- package/dist/external.js +56 -0
- package/dist/home.d.ts +31 -0
- package/dist/home.js +108 -0
- package/dist/ids.d.ts +12 -0
- package/dist/ids.js +44 -0
- package/dist/keys.d.ts +124 -0
- package/dist/keys.js +309 -0
- package/dist/lib.d.ts +9 -0
- package/dist/lib.js +31 -0
- package/dist/liveness.d.ts +23 -0
- package/dist/liveness.js +221 -0
- package/dist/main.d.ts +3 -0
- package/dist/main.js +155 -0
- package/dist/narrate.d.ts +19 -0
- package/dist/narrate.js +124 -0
- package/dist/podman.d.ts +46 -0
- package/dist/podman.js +254 -0
- package/dist/projects.d.ts +70 -0
- package/dist/projects.js +232 -0
- package/dist/resolve.d.ts +27 -0
- package/dist/resolve.js +138 -0
- package/dist/sandbox.d.ts +36 -0
- package/dist/sandbox.js +104 -0
- package/dist/scaffold.d.ts +29 -0
- package/dist/scaffold.js +220 -0
- package/dist/session.d.ts +77 -0
- package/dist/session.js +156 -0
- package/dist/term.d.ts +69 -0
- package/dist/term.js +242 -0
- package/dist/tui/app.d.ts +8 -0
- package/dist/tui/app.js +257 -0
- package/dist/tui/theme.d.ts +23 -0
- package/dist/tui/theme.js +134 -0
- package/dist/tui/wrap.d.ts +12 -0
- package/dist/tui/wrap.js +62 -0
- package/dist/validate.d.ts +145 -0
- package/dist/validate.js +959 -0
- package/package.json +76 -0
- package/templates/.github/copilot-instructions.md +1579 -0
- package/templates/.github/prompts/new-agent.prompt.md +38 -0
- package/templates/.github/prompts/new-skill.prompt.md +37 -0
- package/templates/.github/prompts/review-project.prompt.md +31 -0
- package/templates/.github/skills/zen-cli/SKILL.md +110 -0
package/dist/validate.js
ADDED
|
@@ -0,0 +1,959 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, join, relative, resolve } from 'node:path';
|
|
3
|
+
import { EXA_GROUP, FileSkillProvider, SANDBOX_MOUNT, exaTools, projectRegistry, readProjectConfig, sandboxTools, selectTools, workspaceTools, } from '@zenera/neo';
|
|
4
|
+
import { auditModels, credentialFor } from "./audit.js";
|
|
5
|
+
import { SHAPES } from "./keys.js";
|
|
6
|
+
// Mirrors the loader's own constants (`packages/neo/src/project/load.ts`).
|
|
7
|
+
// Duplicated rather than exported, because a check that agreed with the loader
|
|
8
|
+
// by construction could not report that the two had diverged.
|
|
9
|
+
const CONFIG_NAMES = ['agents.yaml', 'agents.yml', 'agents/agents.yaml', 'agents/agents.yml'];
|
|
10
|
+
const HOUSE_RULES = 'INSTRUCTIONS.md';
|
|
11
|
+
const PROMPTS_DIR = 'agents/prompts';
|
|
12
|
+
const SKILLS_DIR = 'agents/skills';
|
|
13
|
+
/** What `allow:` and `preload:` accept, so a skill outside it cannot be named. */
|
|
14
|
+
const REFERABLE = /^[a-z0-9]+(?:[-_][a-z0-9]+)*$/;
|
|
15
|
+
export async function validateProject(opts) {
|
|
16
|
+
const root = resolve(opts.dir);
|
|
17
|
+
const findings = [];
|
|
18
|
+
const files = [];
|
|
19
|
+
const agents = [];
|
|
20
|
+
const skills = [];
|
|
21
|
+
const models = [];
|
|
22
|
+
let skillDirs = [];
|
|
23
|
+
let providers = [];
|
|
24
|
+
let config;
|
|
25
|
+
let configPath = null;
|
|
26
|
+
let shadowed = [];
|
|
27
|
+
let entry = null;
|
|
28
|
+
let registered = opts.registered ?? false;
|
|
29
|
+
let name = opts.name ?? null;
|
|
30
|
+
const add = (f) => {
|
|
31
|
+
findings.push(f);
|
|
32
|
+
};
|
|
33
|
+
const record = (rel, role, kind, required, from) => {
|
|
34
|
+
const path = join(root, rel);
|
|
35
|
+
const stat = existsSync(path) ? statSync(path) : undefined;
|
|
36
|
+
const exists = Boolean(stat) && (kind === 'file' ? stat.isFile() : stat.isDirectory());
|
|
37
|
+
files.push({
|
|
38
|
+
path: rel,
|
|
39
|
+
role,
|
|
40
|
+
kind,
|
|
41
|
+
exists,
|
|
42
|
+
required,
|
|
43
|
+
...(stat?.isFile() ? { bytes: stat.size } : {}),
|
|
44
|
+
...(from ? { from } : {}),
|
|
45
|
+
});
|
|
46
|
+
return exists;
|
|
47
|
+
};
|
|
48
|
+
const done = () => finish(root, name, registered, configPath, shadowed, {
|
|
49
|
+
version: config?.version ?? null,
|
|
50
|
+
entry,
|
|
51
|
+
files,
|
|
52
|
+
agents,
|
|
53
|
+
skills,
|
|
54
|
+
skillDirs,
|
|
55
|
+
providers,
|
|
56
|
+
models,
|
|
57
|
+
sandbox: sandboxSummary(config, agents),
|
|
58
|
+
findings,
|
|
59
|
+
});
|
|
60
|
+
// -----------------------------------------------------------------------
|
|
61
|
+
// The directory itself
|
|
62
|
+
// -----------------------------------------------------------------------
|
|
63
|
+
if (!existsSync(root) || !statSync(root).isDirectory()) {
|
|
64
|
+
add({
|
|
65
|
+
severity: 'error',
|
|
66
|
+
code: 'root.missing',
|
|
67
|
+
where: root,
|
|
68
|
+
message: 'not a directory, so there is no project here to check',
|
|
69
|
+
fix: `create one: zen init ${root}`,
|
|
70
|
+
});
|
|
71
|
+
return done();
|
|
72
|
+
}
|
|
73
|
+
// A project is a directory the loader can read; being *listed* is a
|
|
74
|
+
// separate question, and one the caller has already answered — the registry
|
|
75
|
+
// lives in `$HOME`, which this check does not read.
|
|
76
|
+
if (!registered) {
|
|
77
|
+
add({
|
|
78
|
+
severity: 'warning',
|
|
79
|
+
code: 'project.unregistered',
|
|
80
|
+
where: root,
|
|
81
|
+
message: 'this directory is not registered, so `zen open` and `zen list` will not ' +
|
|
82
|
+
'find it by name (naming it by path, as now, works either way)',
|
|
83
|
+
fix: `register it: zen init ${root} --force`,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
// -----------------------------------------------------------------------
|
|
87
|
+
// agents.yaml: which one, and does it parse
|
|
88
|
+
// -----------------------------------------------------------------------
|
|
89
|
+
const found = CONFIG_NAMES.filter((n) => existsSync(join(root, n)));
|
|
90
|
+
configPath = found[0] ?? null;
|
|
91
|
+
shadowed = found.slice(1);
|
|
92
|
+
for (const n of CONFIG_NAMES) {
|
|
93
|
+
if (existsSync(join(root, n))) {
|
|
94
|
+
record(n, n === configPath ? 'project configuration' : 'ignored: another config wins', 'file', false);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (!configPath) {
|
|
98
|
+
record(CONFIG_NAMES[0], 'project configuration', 'file', true);
|
|
99
|
+
add({
|
|
100
|
+
severity: 'error',
|
|
101
|
+
code: 'config.missing',
|
|
102
|
+
where: root,
|
|
103
|
+
message: 'no project configuration — the loader looks for ' +
|
|
104
|
+
`${CONFIG_NAMES.join(', ')} and found none of them`,
|
|
105
|
+
fix: `scaffold one: zen init ${root} --force`,
|
|
106
|
+
});
|
|
107
|
+
return done();
|
|
108
|
+
}
|
|
109
|
+
if (shadowed.length) {
|
|
110
|
+
add({
|
|
111
|
+
severity: 'warning',
|
|
112
|
+
code: 'config.shadowed',
|
|
113
|
+
where: shadowed.join(', '),
|
|
114
|
+
message: `more than one project configuration is present; the loader takes the ` +
|
|
115
|
+
`first it finds (${configPath}) and never reads ${shadowed.join(', ')}`,
|
|
116
|
+
fix: `delete the ones that are not in use, or merge them into ${configPath}`,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
config = readProjectConfig(root).config;
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
for (const f of parseFailure(err, configPath)) {
|
|
124
|
+
add(f);
|
|
125
|
+
}
|
|
126
|
+
// The schema failed, so nothing below can be trusted to have the shape
|
|
127
|
+
// it reads. The file inventory is still worth having: it is what says
|
|
128
|
+
// whether the prompts a broken config points at are even there.
|
|
129
|
+
inventory(root, record);
|
|
130
|
+
return done();
|
|
131
|
+
}
|
|
132
|
+
// -----------------------------------------------------------------------
|
|
133
|
+
// Files the config and the conventions name
|
|
134
|
+
// -----------------------------------------------------------------------
|
|
135
|
+
const hasHouseRules = record(HOUSE_RULES, 'house rules — prepended to every agent prompt', 'file', false);
|
|
136
|
+
if (!hasHouseRules) {
|
|
137
|
+
add({
|
|
138
|
+
severity: 'note',
|
|
139
|
+
code: 'house-rules.missing',
|
|
140
|
+
where: HOUSE_RULES,
|
|
141
|
+
message: 'no INSTRUCTIONS.md, which is allowed: every agent then runs on its own role ' +
|
|
142
|
+
'prompt alone, with nothing shared between them',
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
else if (empty(join(root, HOUSE_RULES))) {
|
|
146
|
+
add({
|
|
147
|
+
severity: 'warning',
|
|
148
|
+
code: 'house-rules.empty',
|
|
149
|
+
where: HOUSE_RULES,
|
|
150
|
+
message: 'INSTRUCTIONS.md is empty, so it contributes nothing but a prompt section',
|
|
151
|
+
fix: 'write the rules that hold regardless of which agent is answering, or delete it',
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
// -----------------------------------------------------------------------
|
|
155
|
+
// Agents
|
|
156
|
+
// -----------------------------------------------------------------------
|
|
157
|
+
const declaredNames = config.agents.map((a) => a.name);
|
|
158
|
+
for (const [duplicate, times] of tally(declaredNames)) {
|
|
159
|
+
if (times > 1) {
|
|
160
|
+
add({
|
|
161
|
+
severity: 'error',
|
|
162
|
+
code: 'agent.duplicate',
|
|
163
|
+
where: `agents.${duplicate}`,
|
|
164
|
+
message: `declared ${times} times; a later entry silently replaces the earlier`,
|
|
165
|
+
fix: 'give each agent its own name',
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
entry = entrypoint(config, add);
|
|
170
|
+
// The tools a `zen run` would actually offer, so a selector is checked
|
|
171
|
+
// against the real set rather than a list written down twice.
|
|
172
|
+
const available = availableTools(root, config);
|
|
173
|
+
for (const spec of config.agents) {
|
|
174
|
+
agents.push(checkAgent(root, config, spec, entry, available, record, add));
|
|
175
|
+
}
|
|
176
|
+
// -----------------------------------------------------------------------
|
|
177
|
+
// Skills
|
|
178
|
+
// -----------------------------------------------------------------------
|
|
179
|
+
const catalog = await checkSkills(root, config, available, record, add);
|
|
180
|
+
skillDirs = catalog.dirs;
|
|
181
|
+
skills.push(...catalog.entries);
|
|
182
|
+
bindSkills(config, skills, catalog.names, add);
|
|
183
|
+
// -----------------------------------------------------------------------
|
|
184
|
+
// Models and credentials
|
|
185
|
+
// -----------------------------------------------------------------------
|
|
186
|
+
const resolved = checkModels(root, config, opts.keys, add);
|
|
187
|
+
providers = resolved.providers;
|
|
188
|
+
models.push(...resolved.models);
|
|
189
|
+
checkServices(agents, available, opts.keys, add);
|
|
190
|
+
return done();
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* A tool that needs a key of its own is invisible to the model audit, which
|
|
194
|
+
* walks `models:` and finds nothing to say about `web_search`. The project is
|
|
195
|
+
* still valid — the credential is read at call time and a missing one is a
|
|
196
|
+
* failed turn, not a failed load — so this is a warning, and only when the
|
|
197
|
+
* keyring was readable at all.
|
|
198
|
+
*/
|
|
199
|
+
function checkServices(agents, available, keys, add) {
|
|
200
|
+
if (!keys) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const byService = {
|
|
204
|
+
exa: new Set(available.filter((t) => t.group === EXA_GROUP).map((t) => t.name)),
|
|
205
|
+
};
|
|
206
|
+
for (const [service, names] of Object.entries(byService)) {
|
|
207
|
+
const users = agents.filter((a) => a.tools.some((t) => names.has(t)));
|
|
208
|
+
if (users.length === 0) {
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
const shape = SHAPES[service];
|
|
212
|
+
if (process.env[shape.env] || keys.active(service)) {
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
add({
|
|
216
|
+
severity: 'warning',
|
|
217
|
+
code: 'service.credential',
|
|
218
|
+
where: users.map((a) => `agents.${a.name}`).join(', '),
|
|
219
|
+
message: `uses the ${shape.label} tools, and nothing on this machine holds a ` +
|
|
220
|
+
`${shape.label} key — those tools will refuse every call`,
|
|
221
|
+
fix: `zen key add ${service}, or set $${shape.env}`,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
// The config file
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
/**
|
|
229
|
+
* Turns a loader failure back into findings.
|
|
230
|
+
*
|
|
231
|
+
* `parseConfig` already renders every schema issue, one indented line each —
|
|
232
|
+
* so the message is a report that has been flattened into a string, and this
|
|
233
|
+
* unflattens it. A yaml syntax error arrives as a single line instead, and
|
|
234
|
+
* carries its own line and column, which is the one thing worth keeping.
|
|
235
|
+
*/
|
|
236
|
+
function parseFailure(err, where) {
|
|
237
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
238
|
+
const lines = message.split('\n');
|
|
239
|
+
const issues = lines
|
|
240
|
+
.slice(1)
|
|
241
|
+
.map((l) => l.trim())
|
|
242
|
+
.filter(Boolean);
|
|
243
|
+
if (!issues.length) {
|
|
244
|
+
return [
|
|
245
|
+
{
|
|
246
|
+
severity: 'error',
|
|
247
|
+
code: 'config.unreadable',
|
|
248
|
+
where,
|
|
249
|
+
message: lines[0].replace(/^.*?: /, ''),
|
|
250
|
+
fix: 'fix the syntax — nothing else can be checked until the file parses',
|
|
251
|
+
},
|
|
252
|
+
];
|
|
253
|
+
}
|
|
254
|
+
return issues.map((issue) => {
|
|
255
|
+
const [key, ...rest] = issue.split(' — ');
|
|
256
|
+
return {
|
|
257
|
+
severity: 'error',
|
|
258
|
+
code: 'config.invalid',
|
|
259
|
+
where: `${where}: ${key}`,
|
|
260
|
+
message: rest.join(' — ') || issue,
|
|
261
|
+
fix: 'see docs/agents-yaml.md for the keys this file accepts',
|
|
262
|
+
};
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
/** The layout, whether or not a config named any of it. */
|
|
266
|
+
function inventory(root, record) {
|
|
267
|
+
record(HOUSE_RULES, 'house rules — prepended to every agent prompt', 'file', false);
|
|
268
|
+
record(PROMPTS_DIR, 'role prompts, one per agent, by convention', 'directory', false);
|
|
269
|
+
record(SKILLS_DIR, 'skill catalog', 'directory', false);
|
|
270
|
+
}
|
|
271
|
+
/** Replicates the loader's `entrypoint`, reporting instead of throwing. */
|
|
272
|
+
function entrypoint(config, add) {
|
|
273
|
+
const names = config.agents.map((a) => a.name);
|
|
274
|
+
if (config.default) {
|
|
275
|
+
if (!names.includes(config.default)) {
|
|
276
|
+
add({
|
|
277
|
+
severity: 'error',
|
|
278
|
+
code: 'entry.unknown',
|
|
279
|
+
where: 'default',
|
|
280
|
+
message: `unknown agent "${config.default}" (declared: ${names.join(', ')})`,
|
|
281
|
+
fix: 'name one of the declared agents, or drop the key and let the first win',
|
|
282
|
+
});
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
return config.default;
|
|
286
|
+
}
|
|
287
|
+
const claimed = config.agents.filter((a) => a.default);
|
|
288
|
+
if (claimed.length > 1) {
|
|
289
|
+
add({
|
|
290
|
+
severity: 'error',
|
|
291
|
+
code: 'entry.ambiguous',
|
|
292
|
+
where: 'agents[].default',
|
|
293
|
+
message: `${claimed.map((a) => a.name).join(' and ')} both claim \`default: true\`, ` +
|
|
294
|
+
'so which agent a bare `zen run` starts on is undecided',
|
|
295
|
+
fix: 'leave it on one of them, or settle it with a top-level `default:`',
|
|
296
|
+
});
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
return claimed[0]?.name ?? names[0] ?? null;
|
|
300
|
+
}
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
// Agents
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
export function availableTools(root, config) {
|
|
305
|
+
// Constructed, not started: a pool creates its container on the first
|
|
306
|
+
// command, so naming one here costs nothing and needs no container engine.
|
|
307
|
+
return [
|
|
308
|
+
...workspaceTools({
|
|
309
|
+
root,
|
|
310
|
+
mount: config.sandbox?.workdir ?? SANDBOX_MOUNT,
|
|
311
|
+
}),
|
|
312
|
+
...sandboxTools({ root, key: 'check' }),
|
|
313
|
+
...exaTools(),
|
|
314
|
+
];
|
|
315
|
+
}
|
|
316
|
+
function checkAgent(root, config, spec, entry, available, record, add) {
|
|
317
|
+
const where = `agents.${spec.name}`;
|
|
318
|
+
const instructions = [];
|
|
319
|
+
if (existsSync(join(root, HOUSE_RULES))) {
|
|
320
|
+
instructions.push(HOUSE_RULES);
|
|
321
|
+
}
|
|
322
|
+
// The role prompt: named by `system:`, or found by convention. A named one
|
|
323
|
+
// that is not there is an error — the loader says so too. An absent
|
|
324
|
+
// conventional one is not, because a project may keep everything it has to
|
|
325
|
+
// say in INSTRUCTIONS.md.
|
|
326
|
+
if (spec.system) {
|
|
327
|
+
const rel = normalise(root, spec.system);
|
|
328
|
+
const outside = rel === undefined;
|
|
329
|
+
const exists = outside
|
|
330
|
+
? false
|
|
331
|
+
: record(rel, `role prompt for agent "${spec.name}"`, 'file', true, `${where}.system`);
|
|
332
|
+
if (outside) {
|
|
333
|
+
add({
|
|
334
|
+
severity: 'error',
|
|
335
|
+
code: 'prompt.outside',
|
|
336
|
+
where: `${where}.system`,
|
|
337
|
+
message: `"${spec.system}" resolves outside the project root, which is refused`,
|
|
338
|
+
fix: 'keep prompts inside the project so it stays portable',
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
else if (!exists) {
|
|
342
|
+
add({
|
|
343
|
+
severity: 'error',
|
|
344
|
+
code: 'prompt.missing',
|
|
345
|
+
where: `${where}.system`,
|
|
346
|
+
message: `no such file: ${rel} — the project will not load`,
|
|
347
|
+
fix: `create ${rel}, or point \`system:\` at a file that exists`,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
else {
|
|
351
|
+
instructions.push(rel);
|
|
352
|
+
if (empty(join(root, rel))) {
|
|
353
|
+
add({
|
|
354
|
+
severity: 'warning',
|
|
355
|
+
code: 'prompt.empty',
|
|
356
|
+
where: `${where}.system`,
|
|
357
|
+
message: `${rel} is empty, so this agent has no instructions of its own`,
|
|
358
|
+
fix: `write what this agent is for in ${rel}`,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
else {
|
|
364
|
+
const rel = join(PROMPTS_DIR, `${spec.name}.md`);
|
|
365
|
+
const exists = record(rel, `role prompt for agent "${spec.name}" (by convention)`, 'file', false);
|
|
366
|
+
if (exists) {
|
|
367
|
+
instructions.push(rel);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
if (instructions.length === 0) {
|
|
371
|
+
add({
|
|
372
|
+
severity: 'warning',
|
|
373
|
+
code: 'agent.no-instructions',
|
|
374
|
+
where,
|
|
375
|
+
message: 'this agent has no prompt at all — no INSTRUCTIONS.md and no role file — so ' +
|
|
376
|
+
'it runs on the tool descriptions alone',
|
|
377
|
+
fix: `write ${join(PROMPTS_DIR, `${spec.name}.md`)}, or name one with \`system:\``,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
// Tools
|
|
381
|
+
const selectors = spec.tools ?? [];
|
|
382
|
+
let tools = [];
|
|
383
|
+
if (selectors.length) {
|
|
384
|
+
let resolved = true;
|
|
385
|
+
try {
|
|
386
|
+
tools = selectTools(available, [...selectors], {
|
|
387
|
+
where: `${where}.tools`,
|
|
388
|
+
hint: 'this CLI provides the workspace and sandbox groups',
|
|
389
|
+
}).map((t) => t.name);
|
|
390
|
+
}
|
|
391
|
+
catch (err) {
|
|
392
|
+
resolved = false;
|
|
393
|
+
add({
|
|
394
|
+
severity: 'error',
|
|
395
|
+
code: 'tools.unresolved',
|
|
396
|
+
where: `${where}.tools`,
|
|
397
|
+
message: (err instanceof Error
|
|
398
|
+
? err.message.replace(`${where}.tools: `, '')
|
|
399
|
+
: String(err)) + ' — `zen run` will refuse to load the project',
|
|
400
|
+
fix: 'correct the name, or drop it. A tool this CLI does not ship can only ' +
|
|
401
|
+
'reach an agent from a TypeScript host, through ProjectOptions.tools',
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
if (resolved && tools.length === 0) {
|
|
405
|
+
add({
|
|
406
|
+
severity: 'warning',
|
|
407
|
+
code: 'tools.empty',
|
|
408
|
+
where: `${where}.tools`,
|
|
409
|
+
message: `${selectors.join(', ')} resolves to no tools at all`,
|
|
410
|
+
fix: 'a selector that subtracts everything it added leaves nothing behind',
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
add({
|
|
416
|
+
severity: 'note',
|
|
417
|
+
code: 'tools.none',
|
|
418
|
+
where,
|
|
419
|
+
message: 'no `tools:`, so this agent can only talk, hand off and use whatever a ' +
|
|
420
|
+
'skill unlocks',
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
// Hand-offs
|
|
424
|
+
for (const target of spec.handoffs ?? []) {
|
|
425
|
+
if (target === spec.name) {
|
|
426
|
+
add({
|
|
427
|
+
severity: 'error',
|
|
428
|
+
code: 'handoff.self',
|
|
429
|
+
where: `${where}.handoffs`,
|
|
430
|
+
message: 'an agent cannot hand off to itself',
|
|
431
|
+
fix: 'remove the entry',
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
else if (!config.agents.some((a) => a.name === target)) {
|
|
435
|
+
add({
|
|
436
|
+
severity: 'error',
|
|
437
|
+
code: 'handoff.unknown',
|
|
438
|
+
where: `${where}.handoffs`,
|
|
439
|
+
message: `unknown agent "${target}" (declared: ` +
|
|
440
|
+
`${config.agents.map((a) => a.name).join(', ')})`,
|
|
441
|
+
fix: `declare "${target}" under \`agents:\`, or correct the spelling`,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
// Forks
|
|
446
|
+
const fork = spec.fork === true ? {} : spec.fork === false ? undefined : spec.fork;
|
|
447
|
+
for (const target of fork?.agents ?? []) {
|
|
448
|
+
if (!config.agents.some((a) => a.name === target)) {
|
|
449
|
+
add({
|
|
450
|
+
severity: 'error',
|
|
451
|
+
code: 'fork.unknown',
|
|
452
|
+
where: `${where}.fork.agents`,
|
|
453
|
+
message: `unknown agent "${target}" (declared: ` +
|
|
454
|
+
`${config.agents.map((a) => a.name).join(', ')})`,
|
|
455
|
+
fix: `declare "${target}" under \`agents:\`, or correct the spelling`,
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
if (!spec.description && (config.agents.length > 1 || (spec.handoffs ?? []).length)) {
|
|
460
|
+
add({
|
|
461
|
+
severity: 'warning',
|
|
462
|
+
code: 'agent.no-description',
|
|
463
|
+
where,
|
|
464
|
+
message: 'no `description:` — it is what a sibling agent’s `transfer_to_' +
|
|
465
|
+
`${spec.name}` +
|
|
466
|
+
'` tool tells the model, so without it a hand-off is a guess',
|
|
467
|
+
fix: 'one line saying what this agent is for',
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
const model = spec.model ?? config.model;
|
|
471
|
+
return {
|
|
472
|
+
name: spec.name,
|
|
473
|
+
entry: spec.name === entry,
|
|
474
|
+
...(spec.description ? { description: spec.description } : {}),
|
|
475
|
+
...(model ? { model } : {}),
|
|
476
|
+
modelSource: spec.model ? 'agent' : config.model ? 'project' : 'none',
|
|
477
|
+
instructions,
|
|
478
|
+
toolSelectors: [...selectors],
|
|
479
|
+
tools,
|
|
480
|
+
handoffs: [...(spec.handoffs ?? [])],
|
|
481
|
+
...(spec.skills
|
|
482
|
+
? {
|
|
483
|
+
skills: {
|
|
484
|
+
provider: spec.skills.provider ?? 'project',
|
|
485
|
+
discovery: spec.skills.discovery,
|
|
486
|
+
...(spec.skills.allow ? { allow: [...spec.skills.allow] } : {}),
|
|
487
|
+
...(spec.skills.preload ? { preload: [...spec.skills.preload] } : {}),
|
|
488
|
+
},
|
|
489
|
+
}
|
|
490
|
+
: {}),
|
|
491
|
+
...(fork ? { fork: { ...fork } } : {}),
|
|
492
|
+
ownSandbox: Boolean(spec.sandbox),
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
async function checkSkills(root, config, available, record, add) {
|
|
496
|
+
const declared = config.skills
|
|
497
|
+
? Array.isArray(config.skills)
|
|
498
|
+
? config.skills
|
|
499
|
+
: [config.skills]
|
|
500
|
+
: existsSync(join(root, SKILLS_DIR))
|
|
501
|
+
? [SKILLS_DIR]
|
|
502
|
+
: [];
|
|
503
|
+
const dirs = [];
|
|
504
|
+
for (const [i, ref] of declared.entries()) {
|
|
505
|
+
const rel = normalise(root, ref);
|
|
506
|
+
if (rel === undefined) {
|
|
507
|
+
add({
|
|
508
|
+
severity: 'error',
|
|
509
|
+
code: 'skills.outside',
|
|
510
|
+
where: `skills[${i}]`,
|
|
511
|
+
message: `"${ref}" resolves outside the project root, which is refused`,
|
|
512
|
+
fix: 'keep the catalog inside the project',
|
|
513
|
+
});
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
const named = config.skills !== undefined;
|
|
517
|
+
const exists = record(rel, 'skill catalog', 'directory', named, named ? `skills[${i}]` : undefined);
|
|
518
|
+
if (!exists) {
|
|
519
|
+
add({
|
|
520
|
+
severity: 'error',
|
|
521
|
+
code: 'skills.missing',
|
|
522
|
+
where: `skills[${i}]`,
|
|
523
|
+
message: `no such directory: ${rel} — the project will not load`,
|
|
524
|
+
fix: `create ${rel}, or drop the \`skills:\` key`,
|
|
525
|
+
});
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
dirs.push(rel);
|
|
529
|
+
}
|
|
530
|
+
if (!dirs.length) {
|
|
531
|
+
return { dirs, entries: [], names: new Set() };
|
|
532
|
+
}
|
|
533
|
+
const provider = new FileSkillProvider({
|
|
534
|
+
id: 'project',
|
|
535
|
+
dir: dirs.map((d) => join(root, d)),
|
|
536
|
+
tools: available,
|
|
537
|
+
});
|
|
538
|
+
let summaries = [];
|
|
539
|
+
try {
|
|
540
|
+
summaries = await provider.list();
|
|
541
|
+
}
|
|
542
|
+
catch (err) {
|
|
543
|
+
add({
|
|
544
|
+
severity: 'error',
|
|
545
|
+
code: 'skills.unreadable',
|
|
546
|
+
where: dirs.join(', '),
|
|
547
|
+
message: err instanceof Error ? err.message : String(err),
|
|
548
|
+
});
|
|
549
|
+
return { dirs, entries: [], names: new Set() };
|
|
550
|
+
}
|
|
551
|
+
const entries = [];
|
|
552
|
+
for (const summary of summaries) {
|
|
553
|
+
const report = {
|
|
554
|
+
name: summary.name,
|
|
555
|
+
description: summary.description,
|
|
556
|
+
path: '',
|
|
557
|
+
usedBy: [],
|
|
558
|
+
};
|
|
559
|
+
// Loading is what resolves the skill's own `tools:` frontmatter, and
|
|
560
|
+
// an unknown name there fails the *run*, not the load — so it is worth
|
|
561
|
+
// paying a read to find out here.
|
|
562
|
+
try {
|
|
563
|
+
const skill = await provider.load(summary.name);
|
|
564
|
+
report.path = display(root, skill.file ?? '');
|
|
565
|
+
const resources = Object.keys(skill.resources ?? {});
|
|
566
|
+
if (resources.length) {
|
|
567
|
+
report.resources = resources;
|
|
568
|
+
}
|
|
569
|
+
if (skill.tools?.length) {
|
|
570
|
+
report.tools = skill.tools.map((t) => t.name);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
catch (err) {
|
|
574
|
+
report.path = locate(root, dirs, summary.name) ?? '';
|
|
575
|
+
add({
|
|
576
|
+
severity: 'error',
|
|
577
|
+
code: 'skill.unloadable',
|
|
578
|
+
where: `skill "${summary.name}"`,
|
|
579
|
+
message: (err instanceof Error ? err.message : String(err)) +
|
|
580
|
+
' — the skill is indexed, so the model can ask for it, and the turn ' +
|
|
581
|
+
'that does will fail',
|
|
582
|
+
fix: "correct the skill's `tools:` frontmatter, or provide the tool from a " +
|
|
583
|
+
'TypeScript host through ProjectOptions.tools',
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
if (!REFERABLE.test(summary.name)) {
|
|
587
|
+
add({
|
|
588
|
+
severity: 'warning',
|
|
589
|
+
code: 'skill.unreferable',
|
|
590
|
+
where: `skill "${summary.name}"`,
|
|
591
|
+
message: `"${summary.name}" is not a name \`allow:\` or \`preload:\` accept — ` +
|
|
592
|
+
'those take lower-case words joined by "-" or "_", so this skill can be ' +
|
|
593
|
+
'discovered but never pinned',
|
|
594
|
+
fix: `rename the file or folder, or set \`name:\` in its frontmatter`,
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
if (!summary.description.trim()) {
|
|
598
|
+
add({
|
|
599
|
+
severity: 'warning',
|
|
600
|
+
code: 'skill.no-description',
|
|
601
|
+
where: `skill "${summary.name}"`,
|
|
602
|
+
message: 'no description — the description is the whole of what the model sees ' +
|
|
603
|
+
'in the skill index, so an empty one means it is never chosen',
|
|
604
|
+
fix: 'add `description:` to the frontmatter, or open the body with one line',
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
entries.push(report);
|
|
608
|
+
}
|
|
609
|
+
for (const finding of ignored(root, dirs, new Set(summaries.map((s) => s.name)))) {
|
|
610
|
+
add(finding);
|
|
611
|
+
}
|
|
612
|
+
return { dirs, entries, names: new Set(summaries.map((s) => s.name)) };
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* Where a skill's markdown lives, for the report a failed `load` cannot fill
|
|
616
|
+
* in. Best effort: a skill renamed by its frontmatter is not findable by name.
|
|
617
|
+
*/
|
|
618
|
+
function locate(root, dirs, name) {
|
|
619
|
+
for (const dir of dirs) {
|
|
620
|
+
for (const rel of [join(dir, name, 'SKILL.md'), join(dir, `${name}.md`)]) {
|
|
621
|
+
if (existsSync(join(root, rel))) {
|
|
622
|
+
return rel;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
return undefined;
|
|
627
|
+
}
|
|
628
|
+
/** Directories in a catalog that hold no `SKILL.md`, and are therefore not skills. */ function ignored(root, dirs, known) {
|
|
629
|
+
const out = [];
|
|
630
|
+
for (const dir of dirs) {
|
|
631
|
+
let items;
|
|
632
|
+
try {
|
|
633
|
+
items = readdirSync(join(root, dir), { withFileTypes: true });
|
|
634
|
+
}
|
|
635
|
+
catch {
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
for (const item of items) {
|
|
639
|
+
if (!item.isDirectory() || known.has(item.name)) {
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
if (existsSync(join(root, dir, item.name, 'SKILL.md'))) {
|
|
643
|
+
// It has one; it is in the catalog under a frontmatter name.
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
646
|
+
out.push({
|
|
647
|
+
severity: 'warning',
|
|
648
|
+
code: 'skill.no-skill-md',
|
|
649
|
+
where: join(dir, item.name),
|
|
650
|
+
message: 'a folder in the skill catalog with no SKILL.md is silently ignored, ' +
|
|
651
|
+
'so nothing in it will ever reach an agent',
|
|
652
|
+
fix: `add ${join(dir, item.name, 'SKILL.md')}, or move the folder out of the catalog`,
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
return out;
|
|
657
|
+
}
|
|
658
|
+
/** `allow:` / `preload:` against the catalog that was actually read. */
|
|
659
|
+
function bindSkills(config, entries, known, add) {
|
|
660
|
+
const byName = new Map(entries.map((e) => [e.name, e]));
|
|
661
|
+
const list = [...known].join(', ') || 'none';
|
|
662
|
+
for (const spec of config.agents) {
|
|
663
|
+
if (!spec.skills) {
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
666
|
+
const where = `agents.${spec.name}.skills`;
|
|
667
|
+
const provider = spec.skills.provider ?? 'project';
|
|
668
|
+
if (provider !== 'project') {
|
|
669
|
+
add({
|
|
670
|
+
severity: 'error',
|
|
671
|
+
code: 'skills.provider-unknown',
|
|
672
|
+
where: `${where}.provider`,
|
|
673
|
+
message: `unknown provider "${provider}" — a project has one, called "project", ` +
|
|
674
|
+
'built from the `skills:` directories',
|
|
675
|
+
fix: 'drop `provider:` and let it default',
|
|
676
|
+
});
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
// A contradiction inside the binding itself, so it is worth saying
|
|
680
|
+
// whether or not there is a catalog to check the names against.
|
|
681
|
+
for (const skill of spec.skills.preload ?? []) {
|
|
682
|
+
if (spec.skills.allow && !spec.skills.allow.includes(skill)) {
|
|
683
|
+
add({
|
|
684
|
+
severity: 'error',
|
|
685
|
+
code: 'skills.preload-not-allowed',
|
|
686
|
+
where: `${where}.preload`,
|
|
687
|
+
message: `"${skill}" is preloaded but not in \`allow\`, so it would be ` +
|
|
688
|
+
'activated and then hidden from the index',
|
|
689
|
+
fix: `add "${skill}" to \`allow\`, or stop preloading it`,
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
if (!known.size) {
|
|
694
|
+
add({
|
|
695
|
+
severity: 'error',
|
|
696
|
+
code: 'skills.no-catalog',
|
|
697
|
+
where,
|
|
698
|
+
message: 'this agent binds skills, but the project has no catalog to bind to',
|
|
699
|
+
fix: 'add a top-level `skills:` directory, or create agents/skills/<name>/SKILL.md',
|
|
700
|
+
});
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
703
|
+
for (const [key, names] of [
|
|
704
|
+
['allow', spec.skills.allow],
|
|
705
|
+
['preload', spec.skills.preload],
|
|
706
|
+
]) {
|
|
707
|
+
for (const skill of names ?? []) {
|
|
708
|
+
if (!known.has(skill)) {
|
|
709
|
+
add({
|
|
710
|
+
severity: 'error',
|
|
711
|
+
code: `skills.${key}-unknown`,
|
|
712
|
+
where: `${where}.${key}`,
|
|
713
|
+
message: `unknown skill "${skill}" (the catalog has: ${list})`,
|
|
714
|
+
fix: 'the name is the frontmatter `name:`, or the folder name when it has none',
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
if (spec.skills.discovery === 'none' && !spec.skills.preload?.length) {
|
|
720
|
+
add({
|
|
721
|
+
severity: 'warning',
|
|
722
|
+
code: 'skills.unreachable',
|
|
723
|
+
where: `${where}.discovery`,
|
|
724
|
+
message: 'discovery is `none` and nothing is preloaded, so this agent is bound ' +
|
|
725
|
+
'to a catalog it can never see into',
|
|
726
|
+
fix: 'preload what it should always have, or use `index` or `search`',
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
for (const skill of known) {
|
|
730
|
+
if (!spec.skills.allow || spec.skills.allow.includes(skill)) {
|
|
731
|
+
byName.get(skill)?.usedBy.push(spec.name);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
for (const entry of entries) {
|
|
736
|
+
if (entry.usedBy.length === 0) {
|
|
737
|
+
add({
|
|
738
|
+
severity: 'note',
|
|
739
|
+
code: 'skill.unused',
|
|
740
|
+
where: `skill "${entry.name}"`,
|
|
741
|
+
message: 'no agent binds a catalog that includes it, so nothing can load it',
|
|
742
|
+
fix: 'add `skills: {}` to an agent, or add it to that agent’s `allow:`',
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
// ---------------------------------------------------------------------------
|
|
748
|
+
// Models
|
|
749
|
+
// ---------------------------------------------------------------------------
|
|
750
|
+
function checkModels(root, config, keys, add) {
|
|
751
|
+
let registry;
|
|
752
|
+
try {
|
|
753
|
+
registry = projectRegistry(config);
|
|
754
|
+
}
|
|
755
|
+
catch (err) {
|
|
756
|
+
add({
|
|
757
|
+
severity: 'error',
|
|
758
|
+
code: 'provider.invalid',
|
|
759
|
+
where: 'providers',
|
|
760
|
+
message: err instanceof Error ? err.message : String(err),
|
|
761
|
+
fix: 'see the `providers:` section of docs/agents-yaml.md',
|
|
762
|
+
});
|
|
763
|
+
return { providers: [], models: [] };
|
|
764
|
+
}
|
|
765
|
+
// Declared under an alias first, so an alias keeps its own name in the
|
|
766
|
+
// report and a `model:` that names one collapses onto it. Mirrors the
|
|
767
|
+
// credential audit, which is what the verdicts below are matched against.
|
|
768
|
+
const declared = new Map();
|
|
769
|
+
for (const [alias, spec] of Object.entries(config.models ?? {})) {
|
|
770
|
+
declared.set(alias, { ref: spec, usedBy: [] });
|
|
771
|
+
}
|
|
772
|
+
const use = (ref, by) => {
|
|
773
|
+
if (!ref) {
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
const known = declared.get(ref);
|
|
777
|
+
if (known) {
|
|
778
|
+
known.usedBy.push(by);
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
declared.set(ref, { ref, usedBy: [by] });
|
|
782
|
+
};
|
|
783
|
+
const inherited = config.agents.filter((a) => !a.model).map((a) => a.name);
|
|
784
|
+
if (config.model && inherited.length) {
|
|
785
|
+
for (const agent of inherited) {
|
|
786
|
+
use(config.model, agent);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
else if (config.model) {
|
|
790
|
+
use(config.model, '(project default)');
|
|
791
|
+
}
|
|
792
|
+
for (const agent of config.agents) {
|
|
793
|
+
use(agent.model, agent.name);
|
|
794
|
+
}
|
|
795
|
+
if (!config.model && inherited.length) {
|
|
796
|
+
add({
|
|
797
|
+
severity: 'warning',
|
|
798
|
+
code: 'model.none',
|
|
799
|
+
where: 'model',
|
|
800
|
+
message: `no project-wide \`model:\`, and ${inherited.join(', ')} pin none of their ` +
|
|
801
|
+
'own — those agents cannot run without `zen run --model <ref>`',
|
|
802
|
+
fix: 'set a top-level `model:`',
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
// A verdict per model, from the same audit `zen run` prints as a warning.
|
|
806
|
+
// Keyed by role too: an alias may name a model and an embedding at once.
|
|
807
|
+
const issues = new Map();
|
|
808
|
+
if (keys) {
|
|
809
|
+
for (const issue of auditModels(root, keys)) {
|
|
810
|
+
issues.set(`${issue.role}:${issue.name}`, issue);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
const models = [];
|
|
814
|
+
const describe = (role, name, ref, usedBy) => {
|
|
815
|
+
const report = {
|
|
816
|
+
name,
|
|
817
|
+
role,
|
|
818
|
+
credential: keys ? 'present' : 'unknown',
|
|
819
|
+
usedBy,
|
|
820
|
+
};
|
|
821
|
+
try {
|
|
822
|
+
const need = registry.requirement(ref);
|
|
823
|
+
const held = credentialFor(need);
|
|
824
|
+
report.provider = need.provider;
|
|
825
|
+
report.kind = need.kind;
|
|
826
|
+
report.env = held.env;
|
|
827
|
+
if (keys) {
|
|
828
|
+
report.credential = held.present ? 'present' : 'missing';
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
catch (err) {
|
|
832
|
+
add({
|
|
833
|
+
severity: 'error',
|
|
834
|
+
code: `${role}.unresolvable`,
|
|
835
|
+
where: whereFor(config, role, name, usedBy),
|
|
836
|
+
message: err instanceof Error ? err.message : String(err),
|
|
837
|
+
fix: role === 'embedding'
|
|
838
|
+
? 'a reference is `[provider:]model`; see docs/agents-yaml.md'
|
|
839
|
+
: 'a reference is `[provider[/api]:]model`; see docs/agents-yaml.md',
|
|
840
|
+
});
|
|
841
|
+
report.credential = 'unknown';
|
|
842
|
+
models.push(report);
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
const issue = issues.get(`${role}:${name}`);
|
|
846
|
+
if (issue) {
|
|
847
|
+
report.credential = issue.reason === 'missing' ? 'missing' : 'rejected';
|
|
848
|
+
report.env = issue.env;
|
|
849
|
+
if (issue.detail) {
|
|
850
|
+
report.detail = issue.detail;
|
|
851
|
+
}
|
|
852
|
+
add({
|
|
853
|
+
severity: 'warning',
|
|
854
|
+
code: `credential.${issue.reason}`,
|
|
855
|
+
where: `${role} "${name}" (${issue.provider})`,
|
|
856
|
+
message: issue.reason === 'missing'
|
|
857
|
+
? `nothing to authenticate with — ${issue.env} is not set, and the ` +
|
|
858
|
+
'keyring holds no key for this provider'
|
|
859
|
+
: `the provider rejected this key when it was last checked${issue.detail ? `: ${issue.detail}` : ''}`,
|
|
860
|
+
fix: issue.add ? `zen key add ${issue.add}` : `set ${issue.env} in the environment`,
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
models.push(report);
|
|
864
|
+
};
|
|
865
|
+
for (const [name, { ref, usedBy }] of declared) {
|
|
866
|
+
describe('model', name, ref, usedBy);
|
|
867
|
+
}
|
|
868
|
+
// `usedBy` stays empty for embeddings: nothing in the runtime consumes one
|
|
869
|
+
// yet, so there is no agent to attribute it to.
|
|
870
|
+
for (const [name, spec] of Object.entries(config.embeddings ?? {})) {
|
|
871
|
+
describe('embedding', name, spec, []);
|
|
872
|
+
}
|
|
873
|
+
if (config.embedding && !config.embeddings?.[config.embedding]) {
|
|
874
|
+
describe('embedding', config.embedding, config.embedding, []);
|
|
875
|
+
}
|
|
876
|
+
return { providers: registry.names(), models };
|
|
877
|
+
}
|
|
878
|
+
/** The config key a bad reference was written under. */
|
|
879
|
+
function whereFor(config, role, name, usedBy) {
|
|
880
|
+
if (role === 'embedding') {
|
|
881
|
+
return config.embeddings?.[name] ? `embeddings.${name}` : 'embedding';
|
|
882
|
+
}
|
|
883
|
+
if (config.models?.[name]) {
|
|
884
|
+
return `models.${name}`;
|
|
885
|
+
}
|
|
886
|
+
return usedBy.length && usedBy[0] !== '(project default)'
|
|
887
|
+
? `agents.${usedBy[0]}.model`
|
|
888
|
+
: 'model';
|
|
889
|
+
}
|
|
890
|
+
// ---------------------------------------------------------------------------
|
|
891
|
+
// Odds and ends
|
|
892
|
+
// ---------------------------------------------------------------------------
|
|
893
|
+
function sandboxSummary(config, agents) {
|
|
894
|
+
const used = agents.some((a) => a.tools.some((t) => SANDBOX_TOOLS.has(t)));
|
|
895
|
+
return {
|
|
896
|
+
image: config?.sandbox?.image ?? null,
|
|
897
|
+
declared: Boolean(config?.sandbox) || agents.some((a) => a.ownSandbox),
|
|
898
|
+
used,
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
const SANDBOX_TOOLS = new Set([
|
|
902
|
+
'run_command',
|
|
903
|
+
'run_command_background',
|
|
904
|
+
'read_command_output',
|
|
905
|
+
'stop_command',
|
|
906
|
+
]);
|
|
907
|
+
/** A project-relative path, or undefined when the reference escapes the root. */
|
|
908
|
+
function normalise(root, ref) {
|
|
909
|
+
const path = resolve(root, ref.replace(/^file:\/\/\//, '/').replace(/^file:/, ''));
|
|
910
|
+
const rel = relative(root, path);
|
|
911
|
+
return rel.startsWith('..') || isAbsolute(rel) ? undefined : rel;
|
|
912
|
+
}
|
|
913
|
+
function display(root, path) {
|
|
914
|
+
const rel = relative(root, path);
|
|
915
|
+
return rel.startsWith('..') || isAbsolute(rel) ? path : rel;
|
|
916
|
+
}
|
|
917
|
+
function empty(path) {
|
|
918
|
+
try {
|
|
919
|
+
return statSync(path).size === 0;
|
|
920
|
+
}
|
|
921
|
+
catch {
|
|
922
|
+
return false;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
function tally(values) {
|
|
926
|
+
const out = new Map();
|
|
927
|
+
for (const v of values) {
|
|
928
|
+
out.set(v, (out.get(v) ?? 0) + 1);
|
|
929
|
+
}
|
|
930
|
+
return out;
|
|
931
|
+
}
|
|
932
|
+
function finish(root, name, registered, config, shadowed, parts) {
|
|
933
|
+
const counts = {
|
|
934
|
+
errors: parts.findings.filter((f) => f.severity === 'error').length,
|
|
935
|
+
warnings: parts.findings.filter((f) => f.severity === 'warning').length,
|
|
936
|
+
notes: parts.findings.filter((f) => f.severity === 'note').length,
|
|
937
|
+
};
|
|
938
|
+
return {
|
|
939
|
+
ok: counts.errors === 0,
|
|
940
|
+
project: {
|
|
941
|
+
name,
|
|
942
|
+
root,
|
|
943
|
+
registered,
|
|
944
|
+
config,
|
|
945
|
+
shadowed,
|
|
946
|
+
version: parts.version,
|
|
947
|
+
entry: parts.entry,
|
|
948
|
+
},
|
|
949
|
+
files: parts.files,
|
|
950
|
+
agents: parts.agents,
|
|
951
|
+
skills: { dirs: parts.skillDirs, entries: parts.skills },
|
|
952
|
+
providers: parts.providers,
|
|
953
|
+
models: parts.models,
|
|
954
|
+
sandbox: parts.sandbox,
|
|
955
|
+
findings: parts.findings,
|
|
956
|
+
counts,
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
//# sourceMappingURL=validate.js.map
|