@zenera/cli 1.1.2 → 1.1.4

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 (59) hide show
  1. package/README.md +228 -31
  2. package/dist/audit.d.ts +13 -8
  3. package/dist/audit.js +21 -24
  4. package/dist/catalog.d.ts +111 -0
  5. package/dist/catalog.js +439 -0
  6. package/dist/commands/check.js +72 -17
  7. package/dist/commands/index.d.ts +2 -2
  8. package/dist/commands/index.js +3 -2
  9. package/dist/commands/init.js +71 -11
  10. package/dist/commands/key.js +144 -36
  11. package/dist/commands/models.d.ts +0 -6
  12. package/dist/commands/models.js +546 -101
  13. package/dist/commands/open.js +2 -2
  14. package/dist/commands/run.js +3 -0
  15. package/dist/engine.d.ts +2 -0
  16. package/dist/engine.js +1 -0
  17. package/dist/home.d.ts +2 -0
  18. package/dist/home.js +2 -0
  19. package/dist/keys.d.ts +104 -13
  20. package/dist/keys.js +175 -34
  21. package/dist/lib.d.ts +2 -1
  22. package/dist/lib.js +2 -1
  23. package/dist/liveness.d.ts +48 -6
  24. package/dist/liveness.js +268 -28
  25. package/dist/sandbox.d.ts +2 -0
  26. package/dist/sandbox.js +58 -7
  27. package/dist/scaffold.d.ts +21 -21
  28. package/dist/scaffold.js +132 -204
  29. package/dist/validate.d.ts +17 -1
  30. package/dist/validate.js +100 -10
  31. package/package.json +2 -18
  32. package/templates/{.github → editor/.github}/copilot-instructions.md +37 -9
  33. package/templates/editor/.github/skills/api-schema-index/SKILL.md +292 -0
  34. package/templates/editor/.github/skills/zen-cli/SKILL.md +77 -0
  35. package/templates/editor/.github/skills/zen-cli/references/check.md +88 -0
  36. package/templates/editor/.github/skills/zen-cli/references/faker.md +111 -0
  37. package/templates/editor/.github/skills/zen-cli/references/frame.md +119 -0
  38. package/templates/editor/.github/skills/zen-cli/references/inspect.md +61 -0
  39. package/templates/editor/.github/skills/zen-cli/references/keys.md +119 -0
  40. package/templates/editor/.github/skills/zen-cli/references/models.md +108 -0
  41. package/templates/editor/.github/skills/zen-cli/references/projects.md +99 -0
  42. package/templates/editor/.github/skills/zen-cli/references/rag.md +159 -0
  43. package/templates/editor/.github/skills/zen-cli/references/run.md +104 -0
  44. package/templates/editor/.github/skills/zen-cli/references/sandbox.md +91 -0
  45. package/templates/editor/.vscode/settings.json +6 -0
  46. package/templates/parts/exa.yaml.tmpl +5 -0
  47. package/templates/parts/model.yaml.tmpl +4 -0
  48. package/templates/parts/models.yaml.tmpl +10 -0
  49. package/templates/project/INSTRUCTIONS.md +7 -0
  50. package/templates/project/SPECIFICATION.md +6 -0
  51. package/templates/project/agents/prompts/default.md +15 -0
  52. package/templates/project/agents.yaml.tmpl +44 -0
  53. package/templates/project/assets/README.md +12 -0
  54. package/templates/project/gitignore +9 -0
  55. package/templates/{sandbox → project/sandbox}/Dockerfile +2 -0
  56. package/templates/.github/skills/zen-cli/SKILL.md +0 -110
  57. /package/templates/{.github → editor/.github}/prompts/new-agent.prompt.md +0 -0
  58. /package/templates/{.github → editor/.github}/prompts/new-skill.prompt.md +0 -0
  59. /package/templates/{.github → editor/.github}/prompts/review-project.prompt.md +0 -0
package/dist/scaffold.js CHANGED
@@ -4,18 +4,61 @@ import { fileURLToPath } from 'node:url';
4
4
  // ---------------------------------------------------------------------------
5
5
  // Scaffolding
6
6
  //
7
- // What `zen init` writes. Deliberately close to empty: a template full of
7
+ // What `zen init` writes and `zen open` refreshes none of which is in this
8
+ // file. `templates/` holds the real thing, laid out the way it lands, so
9
+ // changing what a project starts life as is editing a file rather than a string
10
+ // literal escaping every backtick and `${...}` it contains:
11
+ //
12
+ // templates/project/ the project's own files. Written once and edited from
13
+ // then on, so anything already there is left alone.
14
+ // templates/editor/ ours: `.vscode/settings.json` and the `.github/` tree,
15
+ // which describe this version of `zen` to the editor and
16
+ // are replaced every time.
17
+ // templates/parts/ fragments spliced into a template above.
18
+ //
19
+ // The trees are copied whole and nothing enumerates them, so adding a file to
20
+ // a new project is adding a file to `templates/project/` and nothing else.
21
+ //
22
+ // What is there is deliberately close to empty: a template full of
8
23
  // commented-out options is a template nobody reads and everybody deletes. The
9
- // one agent here works as written, and every other knob is in `docs/`.
24
+ // one agent works as written, and every other knob is in `docs/`.
10
25
  // ---------------------------------------------------------------------------
11
- const INSTRUCTIONS_MD = `# House rules
12
-
13
- Everything in this file is prepended to every agent's prompt, so it is the
14
- place for the things that are true regardless of who is answering: tone,
15
- constraints, what to do when the answer is not knowable.
16
-
17
- Replace this with yours.
18
- `;
26
+ const TEMPLATES = fileURLToPath(new URL('../templates', import.meta.url));
27
+ /** The suffix on a file with `{{...}}` in it, dropped when the file lands. */
28
+ const TEMPLATE = '.tmpl';
29
+ /**
30
+ * Fills the `{{name}}` in a template, in the two shapes templates use.
31
+ *
32
+ * A placeholder alone on a line takes a whole fragment: its own indentation is
33
+ * applied to every line of the value, and an empty value takes the line with
34
+ * it — which is how an optional block leaves nothing behind. Anywhere else it
35
+ * takes a word. A name nothing supplies throws, so a typo in a template is a
36
+ * failing test rather than a `{{provider}}` sitting in somebody's agents.yaml.
37
+ */
38
+ function render(text, vars) {
39
+ const value = (name) => {
40
+ const found = vars[name];
41
+ if (found === undefined) {
42
+ throw new Error(`template asks for {{${name}}}, which nothing supplies`);
43
+ }
44
+ return found;
45
+ };
46
+ return text
47
+ .replace(/^([ \t]*)\{\{(\w+)\}\}[ \t]*\r?\n/gm, (_, indent, name) => {
48
+ const body = value(name).trimEnd();
49
+ if (!body) {
50
+ return '';
51
+ }
52
+ const lines = body.split('\n').map((line) => (line ? indent + line : ''));
53
+ return `${lines.join('\n')}\n`;
54
+ })
55
+ .replace(/\{\{(\w+)\}\}/g, (_, name) => value(name));
56
+ }
57
+ /** Reads one fragment from `templates/parts/`, without its trailing newline. */
58
+ function part(name, vars = {}) {
59
+ const text = readFileSync(join(TEMPLATES, 'parts', `${name}${TEMPLATE}`), 'utf8');
60
+ return render(text, vars).trimEnd();
61
+ }
19
62
  /**
20
63
  * The `model:` section, which is one line until it has to say more.
21
64
  *
@@ -24,118 +67,58 @@ Replace this with yours.
24
67
  * reasoning means splitting the ref back into the two fields and giving the
25
68
  * configuration a name to be referred to by.
26
69
  */
27
- const MODEL_SECTION = (ref, options) => {
70
+ function modelSection(ref, options) {
28
71
  const colon = ref.indexOf(':');
29
72
  if (!options || colon < 0) {
30
- return ('# The model an agent uses when it does not pin its own. Change it here and the\n' +
31
- '# whole project moves. The prefix is the *provider* name, not the vendor — drop\n' +
32
- '# it and the id goes to the default provider, whatever the id looks like.\n' +
33
- `model: ${ref}`);
73
+ return part('model.yaml', { ref });
74
+ }
75
+ return part('models.yaml', {
76
+ provider: ref.slice(0, colon),
77
+ id: ref.slice(colon + 1),
78
+ options,
79
+ });
80
+ }
81
+ /**
82
+ * The name a template file lands under.
83
+ *
84
+ * `gitignore` gains its dot here because it cannot have one in the repository:
85
+ * npm strips a `.gitignore` out of a published tarball, and git would read this
86
+ * one as rules about `packages/cli/templates/` rather than as content.
87
+ */
88
+ function target(name) {
89
+ if (name.endsWith(TEMPLATE)) {
90
+ return name.slice(0, -TEMPLATE.length);
34
91
  }
35
- const indented = options
36
- .trimEnd()
37
- .split('\n')
38
- .map((line) => (line ? ` ${line}` : ''))
39
- .join('\n');
40
- return ('# The model an agent uses when it does not pin its own. Change it here and the\n' +
41
- '# whole project moves. A named configuration is what gives the knobs below\n' +
42
- '# somewhere to live; a bare `model: <provider>:<id>` works when there are none.\n' +
43
- 'models:\n' +
44
- ' main:\n' +
45
- ` provider: ${ref.slice(0, colon)}\n` +
46
- ` model: ${ref.slice(colon + 1)}\n` +
47
- `${indented}\n` +
48
- '\n' +
49
- 'model: main');
50
- };
92
+ return name === 'gitignore' ? '.gitignore' : name;
93
+ }
51
94
  /**
52
- * Added above the tool list when the project is scaffolded with web access.
53
- * The group is registered whether or not a key exists, so this only ever
54
- * changes what the agent is allowed to reach for.
95
+ * Copies one template directory into `dir` at `rel`, depth first, sorted so the
96
+ * list it returns is the same on every machine. Only a `.tmpl` is read as text;
97
+ * everything else is copied byte for byte.
55
98
  */
56
- const EXA_NOTE = `
57
- #
58
- # exa:* is web search and page reading, here because this machine has an
59
- # Exa key. The key is read from the environment when a tool is called,
60
- # so a clone of this project without one still loads and only the call
61
- # fails.`;
62
- const AGENTS_YAML = (model, options, web) => `# Who exists, and what they may reach for.
63
- #
64
- version: 1
65
-
66
- ${MODEL_SECTION(model, options)}
67
-
68
- # Anything in assets/ is mounted read-only at /assets for every agent: they can
69
- # read, list and search it, and no tool can change it. It is a convention, so
70
- # the folder is enough — set \`assets: <path>\` only to keep the material
71
- # somewhere else in this project.
72
-
73
- # The container \`sandbox:*\` commands run in. \`build:\` names a Dockerfile to
74
- # build instead of an image to pull, so anything this project always needs is
75
- # in the image rather than installed by an agent on every run — put it in
76
- # sandbox/Dockerfile. Swap the whole block for \`image: <ref>\` to pull a
77
- # published one instead; the two cannot both be set.
78
- #
79
- # \`persist: true\` keeps the container between runs rather than throwing it
80
- # away, so what an agent installs for itself is still there next time —
81
- # otherwise only /workspace and its home directory survive. \`zen sandbox
82
- # clean\` removes the ones left behind. Everything else has a default; see the
83
- # sandbox: block in docs/agents-yaml.md to size it.
84
- sandbox:
85
- persist: true
86
- build:
87
- dockerfile: sandbox/Dockerfile
88
-
89
- agents:
90
- - name: default
91
- description: The entry point.
92
- # Instructions live in agents/prompts/<name>.md and are picked up by
93
- # convention — no need to name the file here.
94
- #
95
- # workspace:* is every file tool at once, sandbox:* is the shell. Name
96
- # them one by one to be narrower, or subtract: [workspace:*, -delete_file]
97
- #
98
- # sandbox:* runs commands in a container, not on this machine, so it
99
- # needs podman — \`zen run\` installs and starts what it can on its own,
100
- # and \`zen sandbox status\` says where that got to. Drop the line if you
101
- # would rather this agent never reached a shell.${web ? EXA_NOTE : ''}
102
- tools:
103
- - workspace:*
104
- - sandbox:*${web ? '\n - exa:*' : ''}
105
- `;
106
- const PROMPT = `You are a helpful assistant working inside a project workspace.
107
-
108
- You have tools to read, search and edit files. The workspace is the only
109
- place you can see; paths are relative to its root.
110
-
111
- You can also run shell commands. They run in a container over the same
112
- workspace, not on the user's machine, so a command that fails there has cost
113
- them nothing — but it is still their work in the directory, so read before you
114
- overwrite and say what you ran.
115
-
116
- Read a file before you change it: \`apply_patch\` matches the surrounding text
117
- exactly, so a patch written from memory will not apply. Use \`apply_patch\` to
118
- change part of a file and \`write_file\` only for a new one.
119
-
120
- Say what you changed.
121
- `;
122
- const GITIGNORE = `# Sessions hold run state, memory, blobs and whatever the agent wrote.
123
- # None of it is source.
124
- sessions/
125
- `;
126
- const ASSETS_README = `# assets
127
-
128
- Everything in this folder is mounted at /assets when an agent runs. Every agent
129
- in this project can read, list and search it, and no tool of theirs can change
130
- it — so this is where reference material goes: handbooks, specifications,
131
- schemas, worked examples, the style guide the output is supposed to follow.
132
-
133
- It is the project's own files that agents get without being asked. The
134
- workspace they are pointed at is the work; this is what they consult while
135
- doing it.
136
-
137
- Delete this file once there is something here to read.
138
- `;
99
+ function copyTree(from, dir, rel, opts) {
100
+ const written = [];
101
+ mkdirSync(join(dir, rel), { recursive: true });
102
+ const entries = readdirSync(from, { withFileTypes: true });
103
+ entries.sort((a, b) => a.name.localeCompare(b.name));
104
+ for (const entry of entries) {
105
+ const source = join(from, entry.name);
106
+ if (entry.isDirectory()) {
107
+ written.push(...copyTree(source, dir, join(rel, entry.name), opts));
108
+ continue;
109
+ }
110
+ const child = join(rel, target(entry.name));
111
+ if (opts.keep && existsSync(join(dir, child))) {
112
+ continue;
113
+ }
114
+ const body = entry.name.endsWith(TEMPLATE)
115
+ ? render(readFileSync(source, 'utf8'), opts.vars ?? {})
116
+ : readFileSync(source);
117
+ writeFileSync(join(dir, child), body);
118
+ written.push(child);
119
+ }
120
+ return written;
121
+ }
139
122
  // ---------------------------------------------------------------------------
140
123
  // Telling the editor which instructions are not for it
141
124
  //
@@ -152,107 +135,52 @@ Delete this file once there is something here to read.
152
135
  // up whatever `AGENTS.md` a run happened to leave behind. It is a *restricted*
153
136
  // setting, so it applies only in a trusted workspace; that is the right way
154
137
  // round, since an untrusted folder is not one to run agents in either.
155
- // ---------------------------------------------------------------------------
156
- const VSCODE_SETTINGS = `{
157
- "chat.useNestedAgentsMdFiles": false,
158
- "chat.tools.terminal.autoApprove": {
159
- "zen": true
160
- }
161
- }
162
- `;
163
- /**
164
- * Writes `.vscode/settings.json` under `dir`, replacing what is there. The file
165
- * is ours: it says how the editor is to treat a directory the agents write
166
- * into, and a stale copy of that answer is worse than none. Returns the
167
- * relative path.
168
- */
169
- export function editorSettings(dir) {
170
- const rel = join('.vscode', 'settings.json');
171
- mkdirSync(join(dir, '.vscode'), { recursive: true });
172
- writeFileSync(join(dir, rel), VSCODE_SETTINGS);
173
- return rel;
174
- }
175
- // ---------------------------------------------------------------------------
176
- // The other half of the editor story
177
138
  //
178
139
  // `INSTRUCTIONS.md` addresses the *project's* agents. The editor's assistant
179
140
  // still needs a brief of its own, and what it needs to know is how this kind
180
141
  // of project is put together — the file formats, how a prompt is written, when
181
- // to add a skill rather than an agent. That is a whole `.github/` tree the
182
- // standing brief, plus the prompt files and skills the editor picks up from
183
- // the same place — kept as files rather than template literals in here: they
184
- // are full of backticks and `${...}` examples, which a TS template literal
185
- // cannot hold without escaping every one of them into illegibility.
186
- //
187
- // `templates/.github/` mirrors what lands in the project one for one, so
188
- // adding a skill or a prompt file is adding a file there and nothing else.
142
+ // to add a skill rather than an agent. That is what the `.github/` tree is: the
143
+ // standing brief, plus the prompt files and skills the editor picks up from the
144
+ // same place.
189
145
  // ---------------------------------------------------------------------------
190
- const GITHUB_TEMPLATE = fileURLToPath(new URL('../templates/.github', import.meta.url));
191
- const SANDBOX_TEMPLATE = fileURLToPath(new URL('../templates/sandbox', import.meta.url));
192
146
  /**
193
- * Writes the `.github/` tree under `dir`, replacing what is there it
194
- * describes the file formats of the version of `zen` in hand, so the current
195
- * one is the only one worth having. Returns the relative paths written.
147
+ * Writes the editor's files under `dir`, replacing what is there. They are
148
+ * ours: they say how the editor is to treat a directory the agents write into,
149
+ * and they describe the file formats of the version of `zen` in hand, so the
150
+ * current answer is the only one worth having and a stale one is worse than
151
+ * none. Returns the relative paths written.
196
152
  */
197
- export function copilotInstructions(dir) {
198
- return copyTree(GITHUB_TEMPLATE, dir, '.github');
199
- }
200
- /**
201
- * Writes `sandbox/`, the Dockerfile the scaffolded `agents.yaml` builds. Unlike
202
- * the editor files this becomes the project's own — it is meant to be edited —
203
- * so anything already there is left alone.
204
- */
205
- export function sandboxTemplate(dir) {
206
- return copyTree(SANDBOX_TEMPLATE, dir, 'sandbox', { keep: true });
207
- }
208
- /**
209
- * Copies one template directory into `dir` at `rel`, depth first, sorted so
210
- * the list it returns is the same on every machine.
211
- */
212
- function copyTree(from, dir, rel, opts) {
213
- const written = [];
214
- mkdirSync(join(dir, rel), { recursive: true });
215
- const entries = readdirSync(from, { withFileTypes: true });
216
- entries.sort((a, b) => a.name.localeCompare(b.name));
217
- for (const entry of entries) {
218
- const child = join(rel, entry.name);
219
- if (entry.isDirectory()) {
220
- written.push(...copyTree(join(from, entry.name), dir, child, opts));
221
- continue;
222
- }
223
- if (opts?.keep && existsSync(join(dir, child))) {
224
- continue;
225
- }
226
- writeFileSync(join(dir, child), readFileSync(join(from, entry.name)));
227
- written.push(child);
228
- }
229
- return written;
153
+ export function editorFiles(dir) {
154
+ return copyTree(join(TEMPLATES, 'editor'), dir, '', {});
230
155
  }
231
156
  /**
232
- * Writes a project. Never overwrites the project's own files: the caller
233
- * decides whether it may. The editor files are the exceptionthey are ours,
234
- * and are replaced.
157
+ * Writes a project. Never overwrites the project's own files a second `init`
158
+ * over a directory fills in what is missing and leaves the rest alone but the
159
+ * editor files are ours, and are replaced.
160
+ *
161
+ * The two are returned apart because they are read differently: the project's
162
+ * files are the thing that was just made, and worth listing; the editor's are
163
+ * plumbing for a tool that may not even be installed, and listing them buries
164
+ * the first set under twice as many lines about the second.
235
165
  */
236
166
  export function scaffold(opts) {
237
- const written = [];
238
- const put = (rel, body) => {
239
- const path = join(opts.dir, rel);
240
- mkdirSync(join(path, '..'), { recursive: true });
241
- writeFileSync(path, body, { flag: 'wx' });
242
- written.push(rel);
243
- };
244
- mkdirSync(join(opts.dir, 'agents', 'prompts'), { recursive: true });
167
+ const files = copyTree(join(TEMPLATES, 'project'), opts.dir, '', {
168
+ keep: true,
169
+ vars: {
170
+ model: modelSection(opts.model, opts.modelOptions),
171
+ exa: opts.web ? part('exa.yaml') : '',
172
+ },
173
+ });
174
+ // The directories with no file to put in them: a skill is a folder someone
175
+ // adds, sessions is written into on the first run, and `.tmp` is scratch —
176
+ // there so an agent has somewhere inside the workspace to put a working
177
+ // file, which is somewhere the sandbox can still reach after the container
178
+ // it was written from is gone.
245
179
  mkdirSync(join(opts.dir, 'agents', 'skills'), { recursive: true });
246
180
  mkdirSync(join(opts.dir, 'sessions'), { recursive: true });
247
- put('INSTRUCTIONS.md', INSTRUCTIONS_MD);
248
- put('agents.yaml', AGENTS_YAML(opts.model, opts.modelOptions, opts.web));
249
- put(join('agents', 'prompts', 'default.md'), PROMPT);
250
- put(join('assets', 'README.md'), ASSETS_README);
251
- put('.gitignore', GITIGNORE);
252
- written.push(...sandboxTemplate(opts.dir));
181
+ mkdirSync(join(opts.dir, '.tmp'), { recursive: true });
253
182
  // The project directory is what `zen open` opens, so this is where the
254
183
  // editor actually reads them.
255
- written.push(editorSettings(opts.dir), ...copilotInstructions(opts.dir));
256
- return written;
184
+ return { files, editor: editorFiles(opts.dir) };
257
185
  }
258
186
  //# sourceMappingURL=scaffold.js.map
@@ -1,6 +1,6 @@
1
1
  import { type AnyTool, type ProjectConfig, type Runner } from '@zenera/neo';
2
2
  import { type DeclaredRole } from './audit.ts';
3
- import { type KeyStore } from './keys.ts';
3
+ import { type KeyStore, type Liveness } from './keys.ts';
4
4
  /**
5
5
  * `error` — the project will not load, or will not run.
6
6
  * `warning` — it loads, and something about it is probably not what was meant.
@@ -82,6 +82,13 @@ export interface ModelReport {
82
82
  env?: string;
83
83
  credential: 'present' | 'missing' | 'rejected' | 'unknown';
84
84
  detail?: string;
85
+ /** the provider's own answer, when the model was actually asked */
86
+ check?: {
87
+ state: Liveness;
88
+ detail?: string;
89
+ fix?: string;
90
+ ms: number;
91
+ };
85
92
  /** agents that would use it */
86
93
  usedBy: string[];
87
94
  }
@@ -156,6 +163,15 @@ export interface ValidateOptions {
156
163
  /** called with each slow step, so the caller can narrate one */
157
164
  onProgress?: (what: string) => void;
158
165
  };
166
+ /**
167
+ * Whether to ask each model that has a credential to answer once. Costs a
168
+ * few tokens apiece and needs the network, so like the sandbox it is named
169
+ * separately. Without `keys` there is nothing to ask with and it is skipped.
170
+ */
171
+ models?: {
172
+ enabled: boolean;
173
+ onProgress?: (what: string) => void;
174
+ };
159
175
  }
160
176
  export declare function validateProject(opts: ValidateOptions): Promise<Report>;
161
177
  export declare function availableTools(root: string, config: ProjectConfig): AnyTool<unknown>[];
package/dist/validate.js CHANGED
@@ -4,7 +4,8 @@ import { tmpdir } from 'node:os';
4
4
  import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
5
5
  import { auditModels, credentialFor } from "./audit.js";
6
6
  import { resolveBuild } from "./image.js";
7
- import { SHAPES } from "./keys.js";
7
+ import { SHAPES, envNames, form } from "./keys.js";
8
+ import { probeModels } from "./liveness.js";
8
9
  import { BuildError, ensurePodmanReady } from "./podman.js";
9
10
  // Mirrors the loader's own constants (`packages/neo/src/project/load.ts`).
10
11
  // Duplicated rather than exported, because a check that agreed with the loader
@@ -198,18 +199,80 @@ export async function validateProject(opts) {
198
199
  // -----------------------------------------------------------------------
199
200
  // Models and credentials
200
201
  // -----------------------------------------------------------------------
201
- const resolved = checkModels(root, config, opts.keys, add);
202
+ // Nothing to ask with is the same as not asking: the missing credential is
203
+ // already a finding, and a call that cannot be made teaches nothing twice.
204
+ const asking = opts.models?.enabled && opts.keys ? opts.models : undefined;
205
+ const resolved = checkModels(root, config, opts.keys, add, Boolean(asking));
202
206
  providers = resolved.providers;
203
207
  models.push(...resolved.models);
204
208
  checkServices(agents, available, opts.keys, add);
205
- // Last, because it is the only step that starts anything: everything a
206
- // reading of the files can tell you is already on the report by now, so an
207
- // interrupted check is still a useful one.
209
+ // Last, and in this order, because these are the two steps that leave the
210
+ // machine: everything a reading of the files can tell you is already on the
211
+ // report by now, so an interrupted check is still a useful one.
212
+ if (asking && resolved.targets.length) {
213
+ await askModels(resolved.targets, asking, add);
214
+ }
208
215
  if (opts.sandbox?.enabled && sandboxSummary(config, agents).used) {
209
216
  probed = await probeSandbox(config, build, opts.sandbox, add);
210
217
  }
211
218
  return done();
212
219
  }
220
+ /**
221
+ * One round trip per model, and a verdict per model.
222
+ *
223
+ * A refusal is an error: the provider looked at this reference and said no, and
224
+ * every run of this project will meet the same answer. Silence is a warning —
225
+ * it is the network's problem, not the project's, and a report that failed
226
+ * because a train went into a tunnel would teach the wrong lesson.
227
+ *
228
+ * A *blocked* model is an error too, but a different one: the credential was
229
+ * accepted and the account then refused, so the fix is the vendor's own —
230
+ * enabling an api, adding credit, choosing a model this account is granted.
231
+ * Telling someone to check their spelling there costs them an afternoon.
232
+ */
233
+ async function askModels(targets, opts, add) {
234
+ opts.onProgress?.(`asking ${targets.length} model${targets.length === 1 ? '' : 's'}`);
235
+ const probes = await probeModels(targets.map(([, target]) => target), (target, done, total) => opts.onProgress?.(`asked ${target.ref} … ${done}/${total}`));
236
+ probes.forEach((probe, i) => {
237
+ const [report] = targets[i];
238
+ report.check = {
239
+ state: probe.check.state,
240
+ ...(probe.check.detail ? { detail: probe.check.detail } : {}),
241
+ ...(probe.check.fix ? { fix: probe.check.fix } : {}),
242
+ ms: probe.ms,
243
+ };
244
+ if (probe.check.state === 'live') {
245
+ return;
246
+ }
247
+ const where = `${report.role} "${report.name}"${report.provider ? ` (${report.provider})` : ''}`;
248
+ if (probe.check.state === 'blocked') {
249
+ const pick = report.role === 'embedding' ? '--embedding' : '--chat';
250
+ add({
251
+ severity: 'error',
252
+ code: `${report.role}.blocked`,
253
+ where,
254
+ message: `the credential was accepted and then refused: ${probe.check.detail ?? 'no reason given'}`,
255
+ fix: `${probe.check.fix ?? 'resolve it in the vendor console'}, or find one that works: zen models pick ${pick}`,
256
+ });
257
+ return;
258
+ }
259
+ add(probe.check.state === 'dead'
260
+ ? {
261
+ severity: 'error',
262
+ code: `${report.role}.refused`,
263
+ where,
264
+ message: `the provider refused it: ${probe.check.detail ?? 'no reason given'}`,
265
+ fix: `check that ${probe.id} is spelt right, still served, and granted to this account`,
266
+ }
267
+ : {
268
+ severity: 'warning',
269
+ code: `${report.role}.unreachable`,
270
+ where,
271
+ message: `it could not be asked: ${probe.check.detail ?? 'no answer'}`,
272
+ fix: 'try again, or pass --no-models to skip this',
273
+ });
274
+ });
275
+ }
213
276
  /**
214
277
  * A tool that needs a key of its own is invisible to the model audit, which
215
278
  * walks `models:` and finds nothing to say about `web_search`. The project is
@@ -230,7 +293,7 @@ function checkServices(agents, available, keys, add) {
230
293
  continue;
231
294
  }
232
295
  const shape = SHAPES[service];
233
- if (process.env[shape.env] || keys.active(service)) {
296
+ if (envNames(service).some((name) => process.env[name]) || keys.active(service)) {
234
297
  continue;
235
298
  }
236
299
  add({
@@ -239,7 +302,7 @@ function checkServices(agents, available, keys, add) {
239
302
  where: users.map((a) => `agents.${a.name}`).join(', '),
240
303
  message: `uses the ${shape.label} tools, and nothing on this machine holds a ` +
241
304
  `${shape.label} key — those tools will refuse every call`,
242
- fix: `zen key add ${service}, or set $${shape.env}`,
305
+ fix: `zen key add ${service}, or set $${form(service).env}`,
243
306
  });
244
307
  }
245
308
  }
@@ -1039,7 +1102,7 @@ function siblings(skillFile) {
1039
1102
  // ---------------------------------------------------------------------------
1040
1103
  // Models
1041
1104
  // ---------------------------------------------------------------------------
1042
- function checkModels(root, config, keys, add) {
1105
+ function checkModels(root, config, keys, add, probe = false) {
1043
1106
  let registry;
1044
1107
  try {
1045
1108
  registry = projectRegistry(config);
@@ -1052,7 +1115,7 @@ function checkModels(root, config, keys, add) {
1052
1115
  message: err instanceof Error ? err.message : String(err),
1053
1116
  fix: 'see the `providers:` section of docs/agents-yaml.md',
1054
1117
  });
1055
- return { providers: [], models: [] };
1118
+ return { providers: [], models: [], targets: [] };
1056
1119
  }
1057
1120
  // Declared under an alias first, so an alias keeps its own name in the
1058
1121
  // report and a `model:` that names one collapses onto it. Mirrors the
@@ -1103,6 +1166,7 @@ function checkModels(root, config, keys, add) {
1103
1166
  }
1104
1167
  }
1105
1168
  const models = [];
1169
+ const targets = [];
1106
1170
  const describe = (role, name, ref, usedBy) => {
1107
1171
  const report = {
1108
1172
  name,
@@ -1153,6 +1217,32 @@ function checkModels(root, config, keys, add) {
1153
1217
  });
1154
1218
  }
1155
1219
  models.push(report);
1220
+ // Only what there is something to ask with. Constructing the client is
1221
+ // what reads the credential, so a model with none cannot be built, let
1222
+ // alone asked — and the missing key is already a finding of its own.
1223
+ if (probe && report.credential === 'present') {
1224
+ try {
1225
+ targets.push([
1226
+ report,
1227
+ role === 'model'
1228
+ ? { ref: name, kind: 'model', model: registry.model(ref) }
1229
+ : {
1230
+ ref: name,
1231
+ kind: 'embedding',
1232
+ embedder: registry.embedder(ref),
1233
+ },
1234
+ ]);
1235
+ }
1236
+ catch (err) {
1237
+ add({
1238
+ severity: 'error',
1239
+ code: `${role}.unusable`,
1240
+ where: whereFor(config, role, name, usedBy),
1241
+ message: err instanceof Error ? err.message : String(err),
1242
+ fix: 'the provider is declared but cannot be built — see `providers:`',
1243
+ });
1244
+ }
1245
+ }
1156
1246
  };
1157
1247
  for (const [name, { ref, usedBy }] of declared) {
1158
1248
  describe('model', name, ref, usedBy);
@@ -1165,7 +1255,7 @@ function checkModels(root, config, keys, add) {
1165
1255
  if (config.embedding && !config.embeddings?.[config.embedding]) {
1166
1256
  describe('embedding', config.embedding, config.embedding, []);
1167
1257
  }
1168
- return { providers: registry.names(), models };
1258
+ return { providers: registry.names(), models, targets };
1169
1259
  }
1170
1260
  /** The config key a bad reference was written under. */
1171
1261
  function whereFor(config, role, name, usedBy) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenera/cli",
3
- "version": "1.1.2",
3
+ "version": "1.1.4",
4
4
  "description": "Command-line front end for @zenera/neo: agentic projects you can run, share and commit.",
5
5
  "keywords": [
6
6
  "agents",
@@ -51,26 +51,10 @@
51
51
  "@inkjs/ui": "^2.0.0",
52
52
  "ink": "^7.1.1",
53
53
  "react": "^19.2.8",
54
- "@zenera/neo": "^1.1.2"
55
- },
56
- "peerDependencies": {
54
+ "@zenera/neo": "^1.1.4",
57
55
  "@anthropic-ai/sdk": "^0.120.0",
58
56
  "@google/genai": "^2.18.0",
59
57
  "@openrouter/sdk": "^1.2.80",
60
58
  "openai": "^6.0.0"
61
- },
62
- "peerDependenciesMeta": {
63
- "@anthropic-ai/sdk": {
64
- "optional": true
65
- },
66
- "@google/genai": {
67
- "optional": true
68
- },
69
- "@openrouter/sdk": {
70
- "optional": true
71
- },
72
- "openai": {
73
- "optional": true
74
- }
75
59
  }
76
60
  }