@zenera/cli 1.1.17 → 1.1.19

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 (56) hide show
  1. package/README.md +99 -16
  2. package/dist/banner.d.ts +19 -0
  3. package/dist/banner.js +17 -10
  4. package/dist/command.d.ts +3 -0
  5. package/dist/commands/index.js +4 -1
  6. package/dist/commands/init.js +1 -1
  7. package/dist/commands/inspect.js +77 -13
  8. package/dist/commands/key.js +49 -1
  9. package/dist/commands/memory.js +16 -3
  10. package/dist/commands/meta.d.ts +3 -0
  11. package/dist/commands/meta.js +596 -0
  12. package/dist/commands/run.js +63 -38
  13. package/dist/engine.d.ts +15 -0
  14. package/dist/engine.js +20 -1
  15. package/dist/home.d.ts +2 -0
  16. package/dist/home.js +2 -0
  17. package/dist/main.js +1 -1
  18. package/dist/meta.d.ts +221 -0
  19. package/dist/meta.js +695 -0
  20. package/dist/podman.js +64 -2
  21. package/dist/projects.d.ts +9 -1
  22. package/dist/projects.js +19 -2
  23. package/dist/resolve.js +7 -1
  24. package/dist/sandbox.d.ts +2 -0
  25. package/dist/sandbox.js +1 -1
  26. package/dist/scaffold.js +1 -1
  27. package/dist/session.d.ts +10 -0
  28. package/dist/session.js +19 -1
  29. package/dist/term.d.ts +21 -0
  30. package/dist/term.js +74 -2
  31. package/dist/tui/app.js +313 -150
  32. package/dist/tui/theme.d.ts +26 -7
  33. package/dist/tui/theme.js +62 -36
  34. package/dist/tui/wrap.d.ts +41 -16
  35. package/dist/tui/wrap.js +191 -18
  36. package/dist/validate.js +3 -16
  37. package/package.json +3 -2
  38. package/templates/editor/.github/copilot-instructions.md +61 -7
  39. package/templates/editor/.github/prompts/{review-project.prompt.md → project-review.prompt.md} +0 -1
  40. package/templates/editor/.github/prompts/{update-spec-from-feedback.prompt.md → spec-apply-feedback.prompt.md} +5 -5
  41. package/templates/editor/.github/prompts/{sync-with-spec.prompt.md → spec-sync-project.prompt.md} +4 -4
  42. package/templates/editor/.github/skills/zen-cli/references/check.md +0 -2
  43. package/templates/editor/.github/skills/zen-cli/references/inspect.md +4 -3
  44. package/templates/editor/.github/skills/zen-cli/references/memory.md +14 -0
  45. package/templates/editor/.github/skills/zen-cli/references/projects.md +1 -0
  46. package/templates/editor/.github/skills/zen-cli/references/run.md +75 -16
  47. package/templates/editor/.github/skills/zen-memory/SKILL.md +52 -4
  48. package/templates/editor/.github/skills/zen-review/SKILL.md +2 -2
  49. package/templates/editor/.github/skills/zen-review/scripts/review.sh +1 -1
  50. package/templates/editor/.github/skills/zen-spec-sync/SKILL.md +2 -2
  51. package/templates/editor/.github/skills/zen-spec-sync/scripts/snapshot.sh +1 -1
  52. package/templates/project/SPECIFICATION.md.tmpl +25 -2
  53. package/templates/project/agents/tools-instructions.md +8 -0
  54. package/templates/project/agents.yaml.tmpl +3 -3
  55. package/templates/project/env +7 -1
  56. package/templates/project/gitignore +4 -4
@@ -0,0 +1,596 @@
1
+ import { createModel, readProjectConfig } from '@zenera/neo';
2
+ import { parse } from "../args.js";
3
+ import { loadProjectEnv } from "../env.js";
4
+ import { ensureHome } from "../home.js";
5
+ import { assertOwner, KeyStore, PROVIDERS } from "../keys.js";
6
+ import { probeModel } from "../liveness.js";
7
+ import { answerBox, chooseModel, defaultRef, launch, listPrompts, loadPrompt, locate, masked, misspelledProvider, MODEL_ENV, openLog, ORDER, PROMPT_DIR, providersWarning, readMeta, RECOMMENDED, SOURCE_LABELS, splitRef, wire, writeMeta, } from "../meta.js";
8
+ import * as Projects from "../projects.js";
9
+ import { project as resolveProject } from "../resolve.js";
10
+ import { editorFiles } from "../scaffold.js";
11
+ import { bold, choose, CliError, credentialError, cyan, dim, EXIT, green, isInteractive, json, note, progress, readStdin, table, usageError, warn, write, writeAll, } from "../term.js";
12
+ const USAGE = 'zen meta run [project] [prompt] [options]';
13
+ const VERBS = new Set(['model', 'run', 'prompts']);
14
+ export const meta = {
15
+ summary: 'Run the meta agent over this project, on your own keys.',
16
+ usage: USAGE,
17
+ banner: { head: 'Zenera', accent: 'Meta', subtitle: 'Meta Agent', hue: 'cyan' },
18
+ details: [
19
+ 'Forms:',
20
+ ' zen meta run [project] "<question>" ask it something',
21
+ ' zen meta run [project] /<name> [words] run a stored prompt',
22
+ ' zen meta run [project] pick one of its stored prompts',
23
+ ' zen meta prompts [project] list the stored prompts',
24
+ ' zen meta model [ref] show or set the model it uses',
25
+ '',
26
+ 'The project may come before the verb instead: `zen meta acme run`.',
27
+ '',
28
+ 'Arguments:',
29
+ ' [project] Name of a project. Default: the one you are in.',
30
+ ' [prompt] Your question, in quotes. Also --prompt, or piped in.',
31
+ ' /<name> A prompt file in .github/prompts/<name>.prompt.md.',
32
+ ' [words] Appended to that prompt as a final line.',
33
+ '',
34
+ 'Options:',
35
+ ' --project <name|dir> Which project to work in. The agent is rooted there.',
36
+ ' --model <ref> Model for this run, e.g. vertex/gemini-3.8-flash.',
37
+ ' --provider <name> Force the key to use. Default: the model ref says.',
38
+ ' --agent <name> A .github/agents/<name>.agent.md.',
39
+ ' --effort <level> none, minimal, low, medium, high, xhigh or max.',
40
+ ' --allow-tool <list> Only these: read, shell(git:*), write(README.md).',
41
+ ' --ask Have it ask before each tool. Default: it does not.',
42
+ ' --add-dir <dir> Another directory it may touch. Repeatable.',
43
+ ' --resume <id> Continue a copilot session. --continue takes the last.',
44
+ ' --share <file> Write the transcript to a markdown file.',
45
+ ' --dry-run Print what would run, secrets masked, and stop.',
46
+ '',
47
+ 'It always uses your own keys — `zen key add` — and never a coding-agent',
48
+ 'subscription. The answer goes to stdout and the progress to stderr, so',
49
+ '`> out.md` keeps the answer alone.',
50
+ '',
51
+ 'It may use every tool without asking, because a prompt written for an',
52
+ 'editor expects to read, write and run things, and a terminal has nobody',
53
+ 'watching to answer. Narrow it with --allow-tool, or restore the asking',
54
+ 'with --ask.',
55
+ '',
56
+ 'Nothing it needs is put on a command line: every credential reaches it',
57
+ 'through the environment, where other processes cannot read it.',
58
+ '',
59
+ 'The editor files `zen init` writes (.vscode/settings.json and the .github/',
60
+ 'tree) are refreshed in the project first, the way `zen open` refreshes',
61
+ 'them: the agent is about to read that brief, and it describes this version',
62
+ 'of zen. Edits to them do not survive.',
63
+ '',
64
+ 'The model is looked for in this order: --model, ZENERA_META_MODEL in the',
65
+ "shell, ZENERA_META_MODEL in the project's .env, `zen meta model`, then",
66
+ 'agents.yaml. With none of them set it uses the best model it knows of for',
67
+ 'a provider you hold a key for, and says which on stderr.',
68
+ '',
69
+ 'Setting one asks the provider a one-word question first, so a model that',
70
+ 'will not answer is never stored. --force skips that.',
71
+ '',
72
+ 'Examples:',
73
+ ' zen meta run "what does this project do?"',
74
+ ' zen meta run acme "review the last commit"',
75
+ ' zen meta prompts',
76
+ ' zen meta run /project-review',
77
+ ' zen meta run acme /spec-sync-project agents/triage.md',
78
+ ' git diff | zen meta run "what broke?" --allow-tool read',
79
+ ' zen meta model vertex/gemini-3.8-flash',
80
+ ' zen meta model --pick',
81
+ ' zen meta run --dry-run "hello"',
82
+ ],
83
+ run: async (ctx) => {
84
+ const { values, positionals } = parse(ctx.args, {
85
+ project: { type: 'string' },
86
+ provider: { type: 'string' },
87
+ model: { type: 'string' },
88
+ prompt: { type: 'string', short: 'p' },
89
+ agent: { type: 'string' },
90
+ effort: { type: 'string' },
91
+ share: { type: 'string' },
92
+ resume: { type: 'string' },
93
+ continue: { type: 'boolean' },
94
+ 'allow-all': { type: 'boolean' },
95
+ 'allow-tool': { type: 'string' },
96
+ ask: { type: 'boolean' },
97
+ 'add-dir': { type: 'string', multiple: true },
98
+ 'dry-run': { type: 'boolean' },
99
+ local: { type: 'boolean' },
100
+ pick: { type: 'boolean' },
101
+ clear: { type: 'boolean' },
102
+ force: { type: 'boolean' },
103
+ }, USAGE);
104
+ // `zen meta acme run` is typed at least as often as `zen meta run acme`,
105
+ // and read literally it asks the agent the one-word question "run" —
106
+ // a whole model call spent on a typo, with nothing on screen to say so.
107
+ const [first, second] = positionals;
108
+ const named = first !== undefined &&
109
+ second !== undefined &&
110
+ !VERBS.has(first) &&
111
+ VERBS.has(second) &&
112
+ (await Projects.find(first))
113
+ ? first
114
+ : undefined;
115
+ const verb = named ? second : first;
116
+ const args = named ? [named, ...positionals.slice(2)] : positionals.slice(1);
117
+ if (verb === 'model') {
118
+ if (named) {
119
+ throw usageError('the model is not per-project', 'try: zen meta model');
120
+ }
121
+ return await model(ctx, values, args);
122
+ }
123
+ if (verb === 'run') {
124
+ return await stored(ctx, values, args);
125
+ }
126
+ if (verb === 'prompts') {
127
+ return await prompts(ctx, values, args);
128
+ }
129
+ throw usageError(first === undefined ? 'nothing to run' : 'a prompt goes through `run`', `try: zen meta run ${positionals.map(requote).join(' ') || '"what does this project do?"'}`);
130
+ },
131
+ };
132
+ /** Words the shell would have split are shown back the way they were typed. */
133
+ const requote = (s) => (/\s/.test(s) ? JSON.stringify(s) : s);
134
+ // ---------------------------------------------------------------------------
135
+ // Which project
136
+ //
137
+ // `zen meta run acme "…"` is what gets typed before anyone finds --project, and
138
+ // a project name is a bare word where a prompt is a sentence — the same reading
139
+ // `zen run` gives its first positional, for the same reason.
140
+ // ---------------------------------------------------------------------------
141
+ async function where(ctx, flag, positionals) {
142
+ const [head, ...tail] = positionals;
143
+ const named = !flag && head ? await Projects.find(head) : undefined;
144
+ const project = await resolveProject({
145
+ cwd: ctx.cwd,
146
+ project: flag ?? (named ? head : undefined),
147
+ });
148
+ return { project, rest: named ? tail : positionals };
149
+ }
150
+ /**
151
+ * The editor's files, rewritten before the agent reads them — the same refresh
152
+ * `zen open` does, for the same reason. `.github/` is the brief this agent works
153
+ * from and the prompts it is offered, and it describes the version of `zen` in
154
+ * hand; a project scaffolded by an older one would otherwise be briefed on a
155
+ * runtime that has moved. A failure is narrated, not raised: a read-only
156
+ * checkout is no reason to refuse to answer a question.
157
+ */
158
+ function refresh(project) {
159
+ try {
160
+ const written = editorFiles(project.dir);
161
+ note(dim(`refreshed ${written.length} editor files in ${project.name}`));
162
+ }
163
+ catch (err) {
164
+ warn(`could not refresh the editor files — ${err.message}`);
165
+ }
166
+ }
167
+ // ---------------------------------------------------------------------------
168
+ // zen meta run [project] [/<name>|<question>] [words...]
169
+ //
170
+ // The leading slash is a marker, not decoration: with it there is nothing to
171
+ // guess, because the project is whatever came before and the extra words are
172
+ // whatever came after, even when a project and a prompt share a name. Without
173
+ // one the words are the question itself.
174
+ // ---------------------------------------------------------------------------
175
+ async function stored(ctx, values, args) {
176
+ const slash = args.findIndex((a) => a.startsWith('/'));
177
+ let head;
178
+ let name;
179
+ let rest;
180
+ if (slash >= 0) {
181
+ head = args.slice(0, slash);
182
+ name = args[slash];
183
+ rest = args.slice(slash + 1);
184
+ }
185
+ else if (args[0] !== undefined && (await Projects.find(args[0]))) {
186
+ head = [args[0]];
187
+ rest = args.slice(1);
188
+ }
189
+ else {
190
+ head = [];
191
+ rest = args;
192
+ }
193
+ const { project } = await where(ctx, values.project, head);
194
+ refresh(project);
195
+ if (name) {
196
+ return await runPrompt(ctx, values, project, name, rest);
197
+ }
198
+ const question = (values.prompt ?? rest.join(' ')).trim() || (await readStdin());
199
+ if (question) {
200
+ return await go(ctx, values, project, question);
201
+ }
202
+ // Nothing said and nothing piped: the stored prompts are what is left to offer.
203
+ return await runPrompt(ctx, values, project, await pickPrompt(ctx, project), []);
204
+ }
205
+ async function runPrompt(ctx, values, project, name, extra) {
206
+ const found = await loadPrompt(project.dir, name);
207
+ if (found.description) {
208
+ note(dim(found.description));
209
+ }
210
+ const prompt = extra.length > 0 ? `${found.body}\n\n${extra.join(' ')}` : found.body;
211
+ await go(ctx, values, project, prompt);
212
+ }
213
+ async function pickPrompt(ctx, project) {
214
+ const names = await listPrompts(project.dir);
215
+ if (names.length === 0) {
216
+ throw usageError('nothing to run', `try: zen meta run ${project.name} "what does this project do?"`);
217
+ }
218
+ if (!isInteractive() || ctx.json) {
219
+ throw usageError('which prompt?', `try: ${names.map((n) => `/${n}`).join(', ')}`);
220
+ }
221
+ return await choose('Which prompt?', names.map((n) => ({ label: `/${n}`, value: n })));
222
+ }
223
+ // ---------------------------------------------------------------------------
224
+ // zen meta prompts [project]
225
+ //
226
+ // A slash command is only discoverable inside an editor, where a menu drops
227
+ // down as you type it. On a terminal the same files have to be asked for.
228
+ // ---------------------------------------------------------------------------
229
+ async function prompts(ctx, values, args) {
230
+ const { project } = await where(ctx, values.project, args);
231
+ refresh(project);
232
+ const names = await listPrompts(project.dir);
233
+ const found = await Promise.all(names.map((name) => loadPrompt(project.dir, name)));
234
+ if (ctx.json) {
235
+ return json(found.map(({ name, description, path }) => ({ name, description, path })));
236
+ }
237
+ if (found.length === 0) {
238
+ note(dim(`no prompts in ${project.name}/${PROMPT_DIR}`));
239
+ return;
240
+ }
241
+ writeAll(table(found.map((p) => [`/${p.name}`, p.description ? dim(p.description) : ''])));
242
+ note();
243
+ note(dim(`run one: zen meta run /${found[0].name}`));
244
+ }
245
+ // ---------------------------------------------------------------------------
246
+ // The run itself
247
+ // ---------------------------------------------------------------------------
248
+ async function go(ctx, values, project, prompt) {
249
+ const shell = process.env[MODEL_ENV];
250
+ loadProjectEnv(project.dir);
251
+ ensureHome();
252
+ const store = await KeyStore.open();
253
+ store.materialize();
254
+ const only = values.provider ? asProvider(values.provider) : undefined;
255
+ const chosen = chooseModel({
256
+ flag: values.model,
257
+ shell,
258
+ env: process.env[MODEL_ENV],
259
+ stored: (await readMeta()).model,
260
+ project: projectModel(project.dir),
261
+ fallback: defaultRef(store, only),
262
+ });
263
+ if (!chosen) {
264
+ if (only) {
265
+ throw credentialError(`no ${only} key`, `add one: zen key add ${only}`);
266
+ }
267
+ throw credentialError('no model for the meta agent', store.entries.length === 0
268
+ ? 'add a key: zen key add openai'
269
+ : 'set one: zen meta model <provider>/<id>');
270
+ }
271
+ const { provider, id } = splitRef(chosen.ref);
272
+ const owner = only ?? provider ?? 'openai';
273
+ const meant = misspelledProvider(chosen.ref);
274
+ if (meant && !only) {
275
+ warn(`${chosen.ref} names no provider — did you mean ${meant}/${id}?`);
276
+ }
277
+ const entry = store.active(owner);
278
+ if (!entry) {
279
+ throw credentialError(`no ${owner} key`, `add one: zen key add ${owner}`);
280
+ }
281
+ const wiring = wire(store, entry, id);
282
+ const binary = locate();
283
+ const args = argv(values, project.dir, prompt, wiring.secret);
284
+ if (values['dry-run']) {
285
+ const lines = masked(wiring.env, wiring.secret);
286
+ if (ctx.json) {
287
+ return json({
288
+ project: project.dir,
289
+ provider: owner,
290
+ model: chosen.ref,
291
+ from: SOURCE_LABELS[chosen.from],
292
+ command: [binary.command, ...binary.args, ...args],
293
+ env: Object.fromEntries(lines.map((l) => l.split('=', 2))),
294
+ });
295
+ }
296
+ write(`${binary.command} ${[...binary.args, ...args].join(' ')}`);
297
+ writeAll(lines.map((l) => dim(l)));
298
+ note(dim(`model ${chosen.ref} from ${SOURCE_LABELS[chosen.from]}`));
299
+ return;
300
+ }
301
+ for (const line of wiring.warnings) {
302
+ warn(line);
303
+ }
304
+ const competing = providersWarning();
305
+ if (competing) {
306
+ warn(competing);
307
+ }
308
+ if (chosen.from === 'default') {
309
+ note(dim(`no model set — using ${chosen.ref}, the best ${owner} model zen knows of`));
310
+ note(dim('set another: zen meta model <provider>/<id>'));
311
+ }
312
+ note(cyan(`${owner} · ${wiring.model} · ${project.name}`));
313
+ const log = openLog(project.dir);
314
+ note(`log ${log.path}`);
315
+ const elided = args.map((a, i) => (args[i - 1] === '-p' ? '<prompt>' : a));
316
+ log.line(`zen meta ${new Date().toISOString()}`);
317
+ log.line(`project ${project.name} ${project.dir}`);
318
+ log.line(`model ${chosen.ref} from ${SOURCE_LABELS[chosen.from]}`);
319
+ log.line(`command ${[binary.command, ...binary.args, ...elided].join(' ')}`);
320
+ log.line('');
321
+ log.line('--- prompt ---');
322
+ log.line(prompt);
323
+ log.line('');
324
+ log.line('--- run ---');
325
+ // A run that dies still has to leave a readable file behind.
326
+ try {
327
+ const outcome = await launch({
328
+ binary,
329
+ args,
330
+ env: wiring.env,
331
+ cwd: project.dir,
332
+ log,
333
+ });
334
+ log.line('');
335
+ log.line('--- answer ---');
336
+ log.line(outcome.answer);
337
+ if (outcome.answer) {
338
+ log.saveAnswer(outcome.answer);
339
+ }
340
+ if (ctx.json) {
341
+ json({
342
+ project: project.dir,
343
+ provider: owner,
344
+ model: chosen.ref,
345
+ sessionId: outcome.sessionId,
346
+ exitCode: outcome.exitCode,
347
+ usage: outcome.usage,
348
+ answer: outcome.answer,
349
+ answerFile: outcome.answer ? log.answerPath : undefined,
350
+ });
351
+ }
352
+ else if (outcome.answer) {
353
+ writeAll(answerBox(outcome.answer));
354
+ // After the answer, not before: it is the thing you click once you
355
+ // have read enough to want it in an editor.
356
+ note(cyan(log.answerPath));
357
+ }
358
+ if (outcome.exitCode !== 0) {
359
+ throw new CliError(`the meta agent exited ${outcome.exitCode}`, EXIT.failed, binary.from === 'npx' ? 'install it: npm i -g @github/copilot' : undefined);
360
+ }
361
+ }
362
+ finally {
363
+ log.close();
364
+ }
365
+ }
366
+ /** Copilot's own flags, assembled once. Nothing secret goes on this line. */
367
+ function argv(values, dir, prompt, secret) {
368
+ const args = ['-C', dir, '-p', prompt, '--output-format', 'json', '--no-color'];
369
+ // A prompt written for an editor expects to read, write and run things, and
370
+ // there is nobody at a `-p` run to answer the question. Naming tools is the
371
+ // narrower answer and wins; --ask is the way back to being asked.
372
+ if (values['allow-all'] || (!values.ask && !values['allow-tool'])) {
373
+ args.push('--allow-all-tools');
374
+ }
375
+ if (values['allow-tool']) {
376
+ args.push('--allow-tool', values['allow-tool']);
377
+ }
378
+ for (const extra of values['add-dir'] ?? []) {
379
+ args.push('--add-dir', extra);
380
+ }
381
+ if (values.agent) {
382
+ args.push('--agent', values.agent);
383
+ }
384
+ if (values.effort) {
385
+ args.push('--reasoning-effort', values.effort);
386
+ }
387
+ if (values.share) {
388
+ args.push('--share', values.share);
389
+ }
390
+ if (values.resume) {
391
+ args.push('--resume', values.resume);
392
+ }
393
+ if (values.continue) {
394
+ args.push('--continue');
395
+ }
396
+ if (secret.length > 0) {
397
+ args.push('--secret-env-vars', secret.join(','));
398
+ }
399
+ return args;
400
+ }
401
+ /** The project's own `model:`, with a `models:` alias resolved to what it names. */
402
+ function projectModel(dir) {
403
+ try {
404
+ const { config } = readProjectConfig(dir);
405
+ const top = typeof config.model === 'string' ? config.model : undefined;
406
+ const alias = top ? config.models?.[top] : undefined;
407
+ if (!alias) {
408
+ return top;
409
+ }
410
+ if (typeof alias === 'string') {
411
+ return alias;
412
+ }
413
+ const owner = PROVIDERS.find((p) => p === alias.provider);
414
+ return owner ? `${owner}/${alias.model}` : alias.model;
415
+ }
416
+ catch {
417
+ return undefined;
418
+ }
419
+ }
420
+ function asProvider(name) {
421
+ const owner = assertOwner(name);
422
+ if (owner === 'exa') {
423
+ throw usageError(`${name} is not a model provider`);
424
+ }
425
+ return owner;
426
+ }
427
+ // ---------------------------------------------------------------------------
428
+ // zen meta model
429
+ //
430
+ // Bare, it answers the question that actually gets asked, which is not "what
431
+ // model" but "why that one" — so the chain is printed with the winner marked,
432
+ // the same way `zen key ls` marks the key a run would use.
433
+ // ---------------------------------------------------------------------------
434
+ async function model(ctx, values, args) {
435
+ const project = await Projects.current(ctx.cwd);
436
+ const shell = process.env[MODEL_ENV];
437
+ if (project) {
438
+ loadProjectEnv(project.dir);
439
+ }
440
+ ensureHome();
441
+ if (values.clear) {
442
+ return clear(values, project);
443
+ }
444
+ const ref = values.pick ? await pickModel(ctx) : args[0];
445
+ if (ref) {
446
+ return set(ctx, values, project, ref);
447
+ }
448
+ const sources = {
449
+ flag: undefined,
450
+ shell,
451
+ env: process.env[MODEL_ENV],
452
+ stored: (await readMeta()).model,
453
+ project: project ? projectModel(project.dir) : undefined,
454
+ fallback: defaultRef(await KeyStore.open()),
455
+ };
456
+ const chosen = chooseModel(sources);
457
+ const at = {
458
+ flag: sources.flag,
459
+ shell: sources.shell,
460
+ env: sources.shell ? undefined : sources.env,
461
+ store: sources.stored,
462
+ project: sources.project,
463
+ default: sources.fallback,
464
+ };
465
+ if (ctx.json) {
466
+ return json({
467
+ model: chosen?.ref,
468
+ from: chosen ? SOURCE_LABELS[chosen.from] : undefined,
469
+ sources: Object.fromEntries(ORDER.map((s) => [SOURCE_LABELS[s], at[s] ?? null])),
470
+ });
471
+ }
472
+ if (chosen) {
473
+ write(chosen.ref);
474
+ }
475
+ note();
476
+ for (const source of ORDER) {
477
+ const value = at[source];
478
+ const mark = chosen?.from === source ? green('*') : ' ';
479
+ note(` ${mark} ${SOURCE_LABELS[source].padEnd(26)} ${value ?? dim('not set')}`);
480
+ }
481
+ if (!chosen) {
482
+ note();
483
+ note(dim('set one: zen meta model <provider>/<id>'));
484
+ }
485
+ }
486
+ async function set(ctx, values, project, ref) {
487
+ if (!values.force) {
488
+ await vet(ctx, ref);
489
+ }
490
+ if (values.local) {
491
+ if (!project) {
492
+ throw usageError('--local needs a project', 'run it inside one, or drop --local');
493
+ }
494
+ await writeLocal(project.dir, ref);
495
+ if (!ctx.json) {
496
+ note(dim(`${MODEL_ENV}=${ref} in ${project.name}/.env`));
497
+ }
498
+ }
499
+ else {
500
+ const file = await readMeta();
501
+ writeMeta({ ...file, model: ref });
502
+ }
503
+ if (ctx.json) {
504
+ json({ model: ref, where: values.local ? '.env' : 'meta.json' });
505
+ }
506
+ else {
507
+ write(ref);
508
+ }
509
+ }
510
+ // ---------------------------------------------------------------------------
511
+ // Vetting a ref
512
+ //
513
+ // Naming a model is a deliberate act and the run that follows is not free, so
514
+ // the provider is asked one cheap question now. Without it a slip of the hand
515
+ // is stored happily and surfaces much later as a 404 from a vendor nobody
516
+ // named — `vertes/…` has no known prefix, so it would be sent whole to OpenAI.
517
+ // ---------------------------------------------------------------------------
518
+ async function vet(ctx, ref) {
519
+ const meant = misspelledProvider(ref);
520
+ if (meant) {
521
+ const [head, ...rest] = ref.split('/');
522
+ throw usageError(`no provider called "${head}"`, `did you mean ${meant}/${rest.join('/')}?`);
523
+ }
524
+ const { provider, id } = splitRef(ref);
525
+ const owner = provider ?? 'openai';
526
+ const store = await KeyStore.open();
527
+ if (!store.active(owner)) {
528
+ throw credentialError(`no ${owner} key to try ${id} with`, `add one: zen key add ${owner}`);
529
+ }
530
+ store.materialize();
531
+ const bar = ctx.json ? undefined : progress();
532
+ bar?.update(dim(`asking ${owner} about ${id} …`));
533
+ const probe = await probeModel({
534
+ // The colon form, so a refusal suggests `zen models ls <provider>`.
535
+ ref: `${owner}:${id}`,
536
+ kind: 'model',
537
+ model: createModel({ provider: owner, model: id, maxTokens: 16 }),
538
+ });
539
+ bar?.done();
540
+ if (probe.check.state !== 'live') {
541
+ const why = probe.check.detail ? ` — ${probe.check.detail}` : '';
542
+ const fix = probe.check.fix ?? `see what it serves: zen models ls ${owner}`;
543
+ throw credentialError(`${owner} did not answer to ${id}${why}`, `${fix}, or --force`);
544
+ }
545
+ if (!ctx.json) {
546
+ note(dim(`${owner} answered in ${(probe.ms / 1000).toFixed(1)}s`));
547
+ }
548
+ }
549
+ async function clear(values, project) {
550
+ if (values.local) {
551
+ if (!project) {
552
+ throw usageError('--local needs a project');
553
+ }
554
+ await writeLocal(project.dir, undefined);
555
+ }
556
+ else {
557
+ const file = await readMeta();
558
+ delete file.model;
559
+ writeMeta(file);
560
+ }
561
+ note(dim('cleared'));
562
+ }
563
+ async function writeLocal(dir, ref) {
564
+ const { readFile, writeFile } = await import('node:fs/promises');
565
+ const path = `${dir}/.env`;
566
+ let text = '';
567
+ try {
568
+ text = await readFile(path, 'utf8');
569
+ }
570
+ catch {
571
+ text = '';
572
+ }
573
+ const line = new RegExp(`^${MODEL_ENV}=.*$`, 'm');
574
+ const next = line.test(text)
575
+ ? text.replace(line, ref ? `${MODEL_ENV}=${ref}` : '')
576
+ : ref
577
+ ? `${text}${text.endsWith('\n') || text === '' ? '' : '\n'}${MODEL_ENV}=${ref}\n`
578
+ : text;
579
+ await writeFile(path, next, { mode: 0o600 });
580
+ }
581
+ async function pickModel(ctx) {
582
+ if (!isInteractive() || ctx.json) {
583
+ throw usageError('--pick needs a terminal', 'name the model instead');
584
+ }
585
+ const store = await KeyStore.open();
586
+ const known = store.entries
587
+ .filter((e) => e.provider !== 'exa')
588
+ .map((e) => e.provider);
589
+ if (known.length === 0) {
590
+ throw credentialError('no keys to choose from', 'add one: zen key add openai');
591
+ }
592
+ const suggestions = [...new Set(known)].flatMap((p) => (RECOMMENDED[p] ?? []).map((id) => `${p}/${id}`));
593
+ note(dim(bold('Models zen knows how to wire, for providers you hold a key for')));
594
+ return await choose('Which model?', suggestions.map((ref) => ({ label: ref, value: ref })));
595
+ }
596
+ //# sourceMappingURL=meta.js.map