@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.
Files changed (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +239 -0
  3. package/dist/args.d.ts +40 -0
  4. package/dist/args.js +99 -0
  5. package/dist/audit.d.ts +53 -0
  6. package/dist/audit.js +144 -0
  7. package/dist/banner.d.ts +13 -0
  8. package/dist/banner.js +103 -0
  9. package/dist/command.d.ts +14 -0
  10. package/dist/command.js +12 -0
  11. package/dist/commands/check.d.ts +3 -0
  12. package/dist/commands/check.js +287 -0
  13. package/dist/commands/index.d.ts +22 -0
  14. package/dist/commands/index.js +56 -0
  15. package/dist/commands/init.d.ts +3 -0
  16. package/dist/commands/init.js +157 -0
  17. package/dist/commands/inspect.d.ts +3 -0
  18. package/dist/commands/inspect.js +158 -0
  19. package/dist/commands/key.d.ts +3 -0
  20. package/dist/commands/key.js +335 -0
  21. package/dist/commands/list.d.ts +3 -0
  22. package/dist/commands/list.js +101 -0
  23. package/dist/commands/models.d.ts +9 -0
  24. package/dist/commands/models.js +120 -0
  25. package/dist/commands/open.d.ts +9 -0
  26. package/dist/commands/open.js +270 -0
  27. package/dist/commands/run.d.ts +3 -0
  28. package/dist/commands/run.js +167 -0
  29. package/dist/commands/sandbox.d.ts +3 -0
  30. package/dist/commands/sandbox.js +112 -0
  31. package/dist/commands/version.d.ts +6 -0
  32. package/dist/commands/version.js +39 -0
  33. package/dist/engine.d.ts +49 -0
  34. package/dist/engine.js +208 -0
  35. package/dist/external.d.ts +10 -0
  36. package/dist/external.js +56 -0
  37. package/dist/home.d.ts +31 -0
  38. package/dist/home.js +108 -0
  39. package/dist/ids.d.ts +12 -0
  40. package/dist/ids.js +44 -0
  41. package/dist/keys.d.ts +124 -0
  42. package/dist/keys.js +309 -0
  43. package/dist/lib.d.ts +9 -0
  44. package/dist/lib.js +31 -0
  45. package/dist/liveness.d.ts +23 -0
  46. package/dist/liveness.js +221 -0
  47. package/dist/main.d.ts +3 -0
  48. package/dist/main.js +155 -0
  49. package/dist/narrate.d.ts +19 -0
  50. package/dist/narrate.js +124 -0
  51. package/dist/podman.d.ts +46 -0
  52. package/dist/podman.js +254 -0
  53. package/dist/projects.d.ts +70 -0
  54. package/dist/projects.js +232 -0
  55. package/dist/resolve.d.ts +27 -0
  56. package/dist/resolve.js +138 -0
  57. package/dist/sandbox.d.ts +36 -0
  58. package/dist/sandbox.js +104 -0
  59. package/dist/scaffold.d.ts +29 -0
  60. package/dist/scaffold.js +220 -0
  61. package/dist/session.d.ts +77 -0
  62. package/dist/session.js +156 -0
  63. package/dist/term.d.ts +69 -0
  64. package/dist/term.js +242 -0
  65. package/dist/tui/app.d.ts +8 -0
  66. package/dist/tui/app.js +257 -0
  67. package/dist/tui/theme.d.ts +23 -0
  68. package/dist/tui/theme.js +134 -0
  69. package/dist/tui/wrap.d.ts +12 -0
  70. package/dist/tui/wrap.js +62 -0
  71. package/dist/validate.d.ts +145 -0
  72. package/dist/validate.js +959 -0
  73. package/package.json +76 -0
  74. package/templates/.github/copilot-instructions.md +1579 -0
  75. package/templates/.github/prompts/new-agent.prompt.md +38 -0
  76. package/templates/.github/prompts/new-skill.prompt.md +37 -0
  77. package/templates/.github/prompts/review-project.prompt.md +31 -0
  78. package/templates/.github/skills/zen-cli/SKILL.md +110 -0
@@ -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