@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.
- package/README.md +228 -31
- package/dist/audit.d.ts +13 -8
- package/dist/audit.js +21 -24
- package/dist/catalog.d.ts +111 -0
- package/dist/catalog.js +439 -0
- package/dist/commands/check.js +72 -17
- package/dist/commands/index.d.ts +2 -2
- package/dist/commands/index.js +3 -2
- package/dist/commands/init.js +71 -11
- package/dist/commands/key.js +144 -36
- package/dist/commands/models.d.ts +0 -6
- package/dist/commands/models.js +546 -101
- package/dist/commands/open.js +2 -2
- package/dist/commands/run.js +3 -0
- package/dist/engine.d.ts +2 -0
- package/dist/engine.js +1 -0
- package/dist/home.d.ts +2 -0
- package/dist/home.js +2 -0
- package/dist/keys.d.ts +104 -13
- package/dist/keys.js +175 -34
- package/dist/lib.d.ts +2 -1
- package/dist/lib.js +2 -1
- package/dist/liveness.d.ts +48 -6
- package/dist/liveness.js +268 -28
- package/dist/sandbox.d.ts +2 -0
- package/dist/sandbox.js +58 -7
- package/dist/scaffold.d.ts +21 -21
- package/dist/scaffold.js +132 -204
- package/dist/validate.d.ts +17 -1
- package/dist/validate.js +100 -10
- package/package.json +2 -18
- package/templates/{.github → editor/.github}/copilot-instructions.md +37 -9
- package/templates/editor/.github/skills/api-schema-index/SKILL.md +292 -0
- package/templates/editor/.github/skills/zen-cli/SKILL.md +77 -0
- package/templates/editor/.github/skills/zen-cli/references/check.md +88 -0
- package/templates/editor/.github/skills/zen-cli/references/faker.md +111 -0
- package/templates/editor/.github/skills/zen-cli/references/frame.md +119 -0
- package/templates/editor/.github/skills/zen-cli/references/inspect.md +61 -0
- package/templates/editor/.github/skills/zen-cli/references/keys.md +119 -0
- package/templates/editor/.github/skills/zen-cli/references/models.md +108 -0
- package/templates/editor/.github/skills/zen-cli/references/projects.md +99 -0
- package/templates/editor/.github/skills/zen-cli/references/rag.md +159 -0
- package/templates/editor/.github/skills/zen-cli/references/run.md +104 -0
- package/templates/editor/.github/skills/zen-cli/references/sandbox.md +91 -0
- package/templates/editor/.vscode/settings.json +6 -0
- package/templates/parts/exa.yaml.tmpl +5 -0
- package/templates/parts/model.yaml.tmpl +4 -0
- package/templates/parts/models.yaml.tmpl +10 -0
- package/templates/project/INSTRUCTIONS.md +7 -0
- package/templates/project/SPECIFICATION.md +6 -0
- package/templates/project/agents/prompts/default.md +15 -0
- package/templates/project/agents.yaml.tmpl +44 -0
- package/templates/project/assets/README.md +12 -0
- package/templates/project/gitignore +9 -0
- package/templates/{sandbox → project/sandbox}/Dockerfile +2 -0
- package/templates/.github/skills/zen-cli/SKILL.md +0 -110
- /package/templates/{.github → editor/.github}/prompts/new-agent.prompt.md +0 -0
- /package/templates/{.github → editor/.github}/prompts/new-skill.prompt.md +0 -0
- /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
|
|
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
|
|
24
|
+
// one agent works as written, and every other knob is in `docs/`.
|
|
10
25
|
// ---------------------------------------------------------------------------
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
70
|
+
function modelSection(ref, options) {
|
|
28
71
|
const colon = ref.indexOf(':');
|
|
29
72
|
if (!options || colon < 0) {
|
|
30
|
-
return ('
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
182
|
-
// standing brief, plus the prompt files and skills the editor picks up from
|
|
183
|
-
//
|
|
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
|
|
194
|
-
*
|
|
195
|
-
*
|
|
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
|
|
198
|
-
return copyTree(
|
|
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
|
|
233
|
-
*
|
|
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
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
};
|
|
244
|
-
|
|
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
|
-
|
|
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
|
-
|
|
256
|
-
return written;
|
|
184
|
+
return { files, editor: editorFiles(opts.dir) };
|
|
257
185
|
}
|
|
258
186
|
//# sourceMappingURL=scaffold.js.map
|
package/dist/validate.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
206
|
-
// reading of the files can tell you is already on the
|
|
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[
|
|
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 $${
|
|
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.
|
|
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.
|
|
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
|
}
|