@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
@@ -1,9 +1,12 @@
1
+ import { readProjectConfig } from '@zenera/neo';
1
2
  import { existsSync, readdirSync } from 'node:fs';
2
3
  import { basename, resolve } from 'node:path';
3
4
  import { one, parse } from "../args.js";
4
5
  import { ensureHome } from "../home.js";
5
- import { isProvider, keyId, KeyStore, PROVIDERS, SHAPES } from "../keys.js";
6
+ import { resolveBuild } from "../image.js";
7
+ import { envNames, isProvider, keyId, KeyStore, PROVIDERS } from "../keys.js";
6
8
  import { probeAll } from "../liveness.js";
9
+ import { ensurePodmanReady } from "../podman.js";
7
10
  import { isProjectDir, Registry } from "../projects.js";
8
11
  import { scaffold } from "../scaffold.js";
9
12
  import { bold, cyan, dim, green, invalidError, json, note, progress, usageError, write, yellow, } from "../term.js";
@@ -55,7 +58,7 @@ const DEFAULT_MODEL = {
55
58
  * the wifi is down.
56
59
  */
57
60
  async function reachableProvider(store) {
58
- const fromEnv = PROVIDERS.find((p) => process.env[SHAPES[p].env]);
61
+ const fromEnv = PROVIDERS.find((p) => envNames(p).some((name) => process.env[name]));
59
62
  if (fromEnv) {
60
63
  return fromEnv;
61
64
  }
@@ -64,7 +67,8 @@ async function reachableProvider(store) {
64
67
  return undefined;
65
68
  }
66
69
  const bar = progress();
67
- const checks = await probeAll(store, active, (entry, index, total) => bar.update(dim(`checking ${keyId(entry)} ${index + 1}/${total}`)));
70
+ bar.update(dim(`checking ${active.length} key${active.length === 1 ? '' : 's'} …`));
71
+ const checks = await probeAll(store, active, (entry, done, total) => bar.update(dim(`checked ${keyId(entry)} … ${done}/${total}`)));
68
72
  bar.done();
69
73
  for (const [entry, check] of checks) {
70
74
  store.record(entry, check);
@@ -86,7 +90,7 @@ async function reachableProvider(store) {
86
90
  * `init`.
87
91
  */
88
92
  function hasExa(store) {
89
- return Boolean(process.env[SHAPES.exa.env]) || store.active('exa') !== undefined;
93
+ return envNames('exa').some((name) => process.env[name]) || store.active('exa') !== undefined;
90
94
  }
91
95
  export const init = {
92
96
  summary: 'Create a project here, or in <dir>, and register it.',
@@ -101,6 +105,9 @@ export const init = {
101
105
  '`exa:*` when the keyring holds an Exa key. Without --model, the',
102
106
  'keyring is checked and the model is picked from a credential the',
103
107
  'provider accepts.',
108
+ '',
109
+ 'The sandbox image is built here too, so the first run does not have',
110
+ 'to. A machine without a container engine is told, and carries on.',
104
111
  ],
105
112
  run: async (ctx) => {
106
113
  const { values, positionals } = parse(ctx.args, {
@@ -126,19 +133,31 @@ export const init = {
126
133
  : DEFAULT_MODEL[provider ?? 'openai'];
127
134
  const model = choice.ref;
128
135
  const web = hasExa(store);
129
- const files = scaffold({ dir, model, modelOptions: choice.options, web });
136
+ const written = scaffold({ dir, model, modelOptions: choice.options, web });
130
137
  const registry = await Registry.open();
131
138
  registry.add(name, dir);
132
139
  registry.save();
140
+ if (!ctx.json) {
141
+ note(`${green('created')} ${bold(name)} ${dim(dir)}`);
142
+ for (const file of written.files.filter((f) => !f.startsWith('.'))) {
143
+ note(` ${dim(file)}`);
144
+ }
145
+ note();
146
+ }
147
+ const sandbox = await prepareSandbox(dir, ctx.json);
133
148
  if (ctx.json) {
134
- json({ name, path: dir, model, files, credential: provider ?? null, web });
149
+ json({
150
+ name,
151
+ path: dir,
152
+ model,
153
+ files: written.files,
154
+ editor: written.editor,
155
+ credential: provider ?? null,
156
+ web,
157
+ sandbox,
158
+ });
135
159
  return;
136
160
  }
137
- note(`${green('created')} ${bold(name)} ${dim(dir)}`);
138
- for (const file of files) {
139
- note(` ${dim(file)}`);
140
- }
141
- note();
142
161
  if (web) {
143
162
  note(`${green('exa key found')} ${dim('— the default agent gets web search')}`);
144
163
  note();
@@ -154,4 +173,45 @@ export const init = {
154
173
  write(dir);
155
174
  },
156
175
  };
176
+ /**
177
+ * Builds the sandbox image the project was just given, here rather than on the
178
+ * first run.
179
+ *
180
+ * It has to happen once either way, and the two moments are not equally good:
181
+ * a build during `init` is a command that is visibly setting a project up,
182
+ * while the same minutes during `zen run` land in the middle of a question
183
+ * somebody asked and read as a hung model.
184
+ *
185
+ * Never fatal. A machine with no container engine has a perfectly good project
186
+ * on it — one whose agent cannot start a shell yet — and refusing to finish
187
+ * `init` over that would throw away everything already written.
188
+ */
189
+ async function prepareSandbox(dir, quiet) {
190
+ let image;
191
+ try {
192
+ const { root, config } = readProjectConfig(dir);
193
+ const build = resolveBuild(root, config.sandbox);
194
+ image = build?.tag ?? config.sandbox?.image;
195
+ if (!build && !image) {
196
+ return false;
197
+ }
198
+ // Nothing to answer: `init` is not the place to be asked whether to
199
+ // install a package manager's worth of software.
200
+ await ensurePodmanReady({ image, build, yes: true });
201
+ if (!quiet) {
202
+ note(`${green('sandbox ready')} ${dim(image ?? '')}`);
203
+ note();
204
+ }
205
+ return true;
206
+ }
207
+ catch (err) {
208
+ if (!quiet) {
209
+ const why = err instanceof Error ? err.message : String(err);
210
+ note(`${yellow('sandbox not ready')} ${dim(`— ${why}`)}`);
211
+ note(` ${cyan('zen sandbox up')} ${dim('— or the first run will try again')}`);
212
+ note();
213
+ }
214
+ return false;
215
+ }
216
+ }
157
217
  //# sourceMappingURL=init.js.map
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { parse } from "../args.js";
3
3
  import { ensureHome } from "../home.js";
4
- import { assertNotEmpty, describe, keyId, KeyStore, mask, OWNERS, parseRef, SHAPES, } from "../keys.js";
4
+ import { ambient, ambientId, assertNotEmpty, describe, envNames, envOf, keyId, KeyStore, mask, OWNERS, parseRef, SHAPES, } from "../keys.js";
5
5
  import { probe, probeAll } from "../liveness.js";
6
6
  import { ago, ask, askSecret, bold, confirm, credentialError, cyan, dim, green, isInteractive, json, note, progress, readStdin, red, table, usageError, write, writeAll, yellow, } from "../term.js";
7
7
  const USAGE = 'zen key <ls|add|use|check|rm|show|env> [ref] [options]';
@@ -11,40 +11,65 @@ const USAGE = 'zen key <ls|add|use|check|rm|show|env> [ref] [options]';
11
11
  const MARK = {
12
12
  live: green('live'),
13
13
  dead: red('dead'),
14
+ blocked: yellow('blocked'),
14
15
  unknown: dim('unknown'),
15
16
  };
16
17
  function state(entry) {
17
18
  return entry.check ? MARK[entry.check.state] : dim('unchecked');
18
19
  }
19
- function rows(store) {
20
+ /**
21
+ * An ambient credential borrowed into the shape the rest of this file works
22
+ * in. It is not in the store and never will be: `store.find` misses it, so
23
+ * `store.record` drops its check and nothing is written to disk. That is the
24
+ * intent — it can be listed and probed, not chosen or forgotten.
25
+ */
26
+ function asEntry(cred) {
27
+ return {
28
+ provider: cred.provider,
29
+ name: cred.env ? `$${cred.env}` : 'adc',
30
+ holds: cred.holds,
31
+ value: cred.value,
32
+ env: cred.env,
33
+ addedAt: new Date().toISOString(),
34
+ };
35
+ }
36
+ function rows(store, borrowed) {
20
37
  const out = [
21
38
  [bold(''), bold('KEY'), bold('VALUE'), bold('STATE'), bold('CHECKED')],
22
39
  ];
23
40
  for (const provider of OWNERS) {
24
41
  for (const entry of store.for(provider)) {
25
- const shadowed = Boolean(process.env[SHAPES[provider].env]);
42
+ const shadow = envNames(provider).find((name) => process.env[name]);
26
43
  out.push([
27
44
  store.isActive(entry) ? green('*') : ' ',
28
45
  keyId(entry),
29
46
  dim(describe(store, entry)),
30
47
  state(entry),
31
48
  dim(entry.check ? ago(entry.check.at) : '—') +
32
- (shadowed && store.isActive(entry)
33
- ? yellow(` shadowed by $${SHAPES[provider].env}`)
34
- : ''),
49
+ (shadow && store.isActive(entry) ? yellow(` shadowed by $${shadow}`) : ''),
50
+ ]);
51
+ }
52
+ for (const [cred, entry] of borrowed.filter(([c]) => c.provider === provider)) {
53
+ out.push([
54
+ dim('~'),
55
+ dim(ambientId(cred)),
56
+ dim(describe(store, entry)),
57
+ state(entry),
58
+ dim(cred.env ? 'from the environment' : 'from gcloud'),
35
59
  ]);
36
60
  }
37
61
  }
38
62
  return table(out);
39
63
  }
40
64
  /**
41
- * Checking is a network round trip per key, so it says which one it is waiting
42
- * on. Silence for ten seconds is indistinguishable from a hang.
65
+ * Checking is a network round trip per key, so it says which ones have come
66
+ * back. Silence for ten seconds is indistinguishable from a hang.
43
67
  */
44
68
  async function checkAll(ctx, store, targets) {
45
69
  const bar = ctx.json ? undefined : progress();
70
+ bar?.update(dim(`checking ${targets.length} key${targets.length === 1 ? '' : 's'} …`));
46
71
  try {
47
- return await probeAll(store, targets, (entry, index, total) => bar?.update(dim(`checking ${keyId(entry)} … ${index + 1}/${total}`)));
72
+ return await probeAll(store, targets, (entry, done, total) => bar?.update(dim(`checked ${keyId(entry)} … ${done}/${total}`)));
48
73
  }
49
74
  finally {
50
75
  bar?.done();
@@ -53,29 +78,47 @@ async function checkAll(ctx, store, targets) {
53
78
  const ls = async (ctx, args) => {
54
79
  const { values } = parse(args, { check: { type: 'boolean' } }, USAGE);
55
80
  const store = await KeyStore.open();
81
+ const borrowed = ambient(store).map((cred) => [cred, asEntry(cred)]);
56
82
  if (values.check) {
57
- const checks = await checkAll(ctx, store, store.entries);
83
+ // Ambient credentials are checked alongside the stored ones and, unlike
84
+ // them, forgotten again: there is nowhere to write the result, and a
85
+ // variable that changes between runs would make a cached one a lie.
86
+ const checks = await checkAll(ctx, store, [
87
+ ...store.entries,
88
+ ...borrowed.map(([, entry]) => entry),
89
+ ]);
58
90
  for (const [entry, check] of checks) {
59
91
  store.record(entry, check);
92
+ entry.check = check;
60
93
  }
61
94
  store.save();
62
95
  }
63
96
  if (ctx.json) {
64
- json(store.entries.map((e) => ({
65
- ...e,
66
- value: e.holds === 'file' ? store.fileOf(e) : mask(e.value),
67
- active: store.isActive(e),
68
- env: SHAPES[e.provider].env,
69
- })));
97
+ json({
98
+ keys: store.entries.map((e) => ({
99
+ ...e,
100
+ value: e.holds === 'file' ? store.fileOf(e) : mask(e.value),
101
+ active: store.isActive(e),
102
+ env: envOf(e),
103
+ })),
104
+ ambient: borrowed.map(([cred, entry]) => ({
105
+ provider: cred.provider,
106
+ id: ambientId(cred),
107
+ env: cred.env ?? null,
108
+ holds: cred.holds,
109
+ value: cred.holds === 'file' ? cred.value : mask(cred.value),
110
+ check: entry.check,
111
+ })),
112
+ });
70
113
  return;
71
114
  }
72
- if (store.entries.length === 0) {
115
+ if (store.entries.length === 0 && borrowed.length === 0) {
73
116
  note('the keyring is empty');
74
117
  note(dim('add one: zen key add openai'));
75
118
  return;
76
119
  }
77
- writeAll(rows(store));
78
- const missing = OWNERS.filter((p) => !store.active(p) && !process.env[SHAPES[p].env]);
120
+ writeAll(rows(store, borrowed));
121
+ const missing = OWNERS.filter((p) => !store.active(p) && !borrowed.some(([c]) => c.provider === p));
79
122
  if (missing.length) {
80
123
  note('');
81
124
  note(dim(`no key for: ${missing.join(', ')}`));
@@ -87,7 +130,12 @@ const ls = async (ctx, args) => {
87
130
  * Piped stdin or an echo-off prompt are the only two ways in.
88
131
  */
89
132
  const add = async (ctx, args) => {
90
- const { values, positionals } = parse(args, { name: { type: 'string' }, 'no-check': { type: 'boolean' } }, 'zen key add <provider>[/name] [--name <name>] [--no-check]');
133
+ const { values, positionals } = parse(args, {
134
+ name: { type: 'string' },
135
+ 'no-check': { type: 'boolean' },
136
+ project: { type: 'string' },
137
+ location: { type: 'string' },
138
+ }, 'zen key add <provider>[/name] [--name <name>] [--project <id>] [--location <region>] [--no-check]');
91
139
  const ref = positionals[0];
92
140
  if (!ref) {
93
141
  throw usageError('which provider?', `one of: ${OWNERS.join(', ')}`);
@@ -96,6 +144,9 @@ const add = async (ctx, args) => {
96
144
  const provider = parsed.provider;
97
145
  const name = values.name ?? parsed.name ?? 'default';
98
146
  const shape = SHAPES[provider];
147
+ if ((values.project || values.location) && provider !== 'vertex') {
148
+ throw usageError(`--project and --location mean nothing to ${shape.label}`, 'they configure a Vertex service account');
149
+ }
99
150
  ensureHome();
100
151
  const store = await KeyStore.open();
101
152
  if (store.find(provider, name) && isInteractive()) {
@@ -103,14 +154,20 @@ const add = async (ctx, args) => {
103
154
  throw usageError('cancelled');
104
155
  }
105
156
  }
106
- const raw = (await readStdin()) ?? (await promptFor(shape.holds, shape.label, shape.where));
157
+ const raw = (await readStdin()) ?? (await promptFor(shape));
107
158
  if (!raw) {
108
159
  throw usageError('no value given');
109
160
  }
110
- if (shape.holds === 'file' && !existsSync(raw)) {
161
+ // A provider with one form can say up front that a path is wrong. One with
162
+ // two cannot: a value that is not a path is not a mistake there, it is the
163
+ // other kind of credential, and `add` decides which by looking.
164
+ if (shape.forms.length === 1 && shape.forms[0].holds === 'file' && !existsSync(raw)) {
111
165
  throw usageError(`no such file: ${raw}`);
112
166
  }
113
- const entry = store.add(provider, name, raw);
167
+ const entry = store.add(provider, name, raw, {
168
+ project: values.project,
169
+ location: values.location,
170
+ });
114
171
  // Verified before it is trusted, but stored either way: a key that cannot
115
172
  // be checked right now — offline, behind a proxy — is not a key that is
116
173
  // wrong, and refusing to save it would make `zen key add` fail on a plane.
@@ -123,25 +180,48 @@ const add = async (ctx, args) => {
123
180
  if (check.state === 'dead') {
124
181
  note(`${red('rejected')} ${check.detail ?? 'the provider refused this key'}`);
125
182
  }
183
+ else if (check.state === 'blocked') {
184
+ note(`${yellow('blocked')} ${check.detail ?? 'the account cannot use this key'}`);
185
+ if (check.fix) {
186
+ note(dim(` ${check.fix}`));
187
+ }
188
+ }
126
189
  }
127
190
  store.save();
128
191
  if (ctx.json) {
129
192
  json({
130
193
  key: keyId(entry),
131
- env: shape.env,
194
+ env: envOf(entry),
132
195
  active: store.isActive(entry),
133
196
  check: entry.check,
134
197
  });
135
198
  return;
136
199
  }
137
- note(`${green('added')} ${bold(keyId(entry))} ${dim(describe(store, entry))} ${state(entry)}`);
138
- if (process.env[shape.env]) {
139
- note(yellow(`$${shape.env} is set and will win over this`));
200
+ note(`${green('added')} ${bold(keyId(entry))} ${dim(describe(store, entry))} ` +
201
+ `${dim(`→ $${envOf(entry)}`)} ${state(entry)}`);
202
+ if (process.env[envOf(entry)]) {
203
+ note(yellow(`$${envOf(entry)} is set and will win over this`));
204
+ }
205
+ if (provider === 'vertex' && entry.holds === 'file' && !entry.project) {
206
+ note(dim('no --project given; the project_id inside the file will be used'));
140
207
  }
141
208
  };
142
- async function promptFor(holds, label, where) {
143
- note(dim(`${label} ${where}`));
144
- return holds === 'file'
209
+ /**
210
+ * A provider with two forms cannot ask for one of them. It asks with the echo
211
+ * off, because one of the two answers is a secret and there is no way to know
212
+ * which is coming until it arrives.
213
+ */
214
+ async function promptFor(shape) {
215
+ const [first, ...rest] = shape.forms;
216
+ if (rest.length) {
217
+ note(dim(`${shape.label} — ${first.where},`));
218
+ for (const form of rest) {
219
+ note(dim(` or ${form.where}`));
220
+ }
221
+ return askSecret('Paste the key, or a path to the file:');
222
+ }
223
+ note(dim(`${shape.label} — ${first.where}`));
224
+ return first.holds === 'file'
145
225
  ? ask('Path to the credentials file:')
146
226
  : askSecret('Paste the key (it will not be shown):');
147
227
  }
@@ -159,7 +239,7 @@ const use = async (ctx, args) => {
159
239
  const entry = store.use(provider, name);
160
240
  store.save();
161
241
  if (ctx.json) {
162
- json({ key: keyId(entry), env: SHAPES[provider].env });
242
+ json({ key: keyId(entry), env: envOf(entry) });
163
243
  return;
164
244
  }
165
245
  note(`${green('using')} ${bold(keyId(entry))} for ${SHAPES[provider].label}`);
@@ -183,9 +263,19 @@ const check = async (ctx, args) => {
183
263
  MARK[result.state],
184
264
  dim(result.detail ?? ''),
185
265
  ])));
266
+ for (const [entry, result] of checks) {
267
+ if (result.fix) {
268
+ note(dim(`${keyId(entry)}: ${result.fix}`));
269
+ }
270
+ }
186
271
  if (checks.some(([, r]) => r.state === 'dead')) {
187
272
  throw credentialError('at least one key was refused');
188
273
  }
274
+ // Separately, because the action is not the same one: these authenticated,
275
+ // and a replacement key would be refused for exactly the same reason.
276
+ if (checks.some(([, r]) => r.state === 'blocked')) {
277
+ throw credentialError('at least one key authenticated but cannot be used');
278
+ }
189
279
  };
190
280
  function select(store, ref) {
191
281
  const { provider, name } = parseRef(ref);
@@ -242,7 +332,7 @@ const show = async (ctx, args) => {
242
332
  if (ctx.json) {
243
333
  json({
244
334
  key: keyId(entry),
245
- env: SHAPES[entry.provider].env,
335
+ env: envOf(entry),
246
336
  value,
247
337
  revealed: Boolean(values.reveal),
248
338
  });
@@ -256,9 +346,12 @@ const show = async (ctx, args) => {
256
346
  }
257
347
  writeAll(table([
258
348
  [dim('key'), keyId(entry)],
259
- [dim('env'), SHAPES[entry.provider].env],
349
+ [dim('env'), envOf(entry)],
260
350
  [dim('value'), value],
351
+ ...(entry.project ? [[dim('project'), entry.project]] : []),
352
+ ...(entry.location ? [[dim('location'), entry.location]] : []),
261
353
  [dim('state'), state(entry)],
354
+ ...(entry.check?.fix ? [[dim('fix'), entry.check.fix]] : []),
262
355
  [dim('added'), ago(entry.addedAt)],
263
356
  ]));
264
357
  note(dim('--reveal prints the secret itself'));
@@ -278,8 +371,18 @@ const env = async (ctx, args) => {
278
371
  const vars = {};
279
372
  for (const provider of only ?? OWNERS) {
280
373
  const entry = store.active(provider);
281
- if (entry) {
282
- vars[SHAPES[provider].env] = store.reveal(entry);
374
+ if (!entry) {
375
+ continue;
376
+ }
377
+ vars[envOf(entry)] = store.reveal(entry);
378
+ // A service account says which project it belongs to but not which
379
+ // region to call, and a shell that has the file and not the region is
380
+ // still a shell that cannot reach a model.
381
+ if (entry.project) {
382
+ vars.GOOGLE_CLOUD_PROJECT = entry.project;
383
+ }
384
+ if (entry.location) {
385
+ vars.GOOGLE_CLOUD_LOCATION = entry.location;
283
386
  }
284
387
  }
285
388
  if (ctx.json) {
@@ -306,11 +409,16 @@ export const key = {
306
409
  usage: USAGE,
307
410
  details: [
308
411
  'Keys live in ~/.zenera/neo/keys.json (0600) and are materialised into',
309
- 'the environment before a run. A real environment variable always wins.',
412
+ 'the environment before a run. A real environment variable always wins,',
413
+ 'and is listed as ~ so it is clear where a working provider comes from.',
310
414
  '',
311
415
  'Model providers: openai, anthropic, google, vertex, openrouter.',
312
416
  'Services the tools call: exa.',
313
417
  '',
418
+ 'Vertex takes either shape: a service-account JSON file, which wants',
419
+ '--project and --location too, or an express-mode API key, which wants',
420
+ 'neither. Which one you gave is read off the value.',
421
+ '',
314
422
  ' zen key ls [--check] Everything stored, and its state.',
315
423
  ' zen key add <provider>[/name] Read a key from stdin, or ask for it.',
316
424
  ' zen key use <provider>/<name> Choose which one a run uses.',
@@ -1,9 +1,3 @@
1
1
  import type { Command } from '../command.ts';
2
- /**
3
- * Everything a run would resolve, resolved — and nothing called. Loading a
4
- * project constructs the model clients, so a config that names an impossible
5
- * provider or an agent that hands off to nobody fails here, in a command that
6
- * costs nothing, instead of three seconds into a run that costs money.
7
- */
8
2
  export declare const models: Command;
9
3
  //# sourceMappingURL=models.d.ts.map