@zenera/cli 1.1.0 → 1.1.3

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 (61) hide show
  1. package/README.md +88 -11
  2. package/dist/audit.d.ts +8 -6
  3. package/dist/audit.js +14 -22
  4. package/dist/commands/check.js +79 -19
  5. package/dist/commands/init.js +71 -11
  6. package/dist/commands/key.js +126 -36
  7. package/dist/commands/models.js +3 -3
  8. package/dist/commands/open.js +2 -2
  9. package/dist/commands/run.js +3 -0
  10. package/dist/commands/sandbox.js +226 -22
  11. package/dist/engine.d.ts +3 -1
  12. package/dist/engine.js +10 -2
  13. package/dist/image.d.ts +16 -0
  14. package/dist/image.js +85 -0
  15. package/dist/keys.d.ts +95 -12
  16. package/dist/keys.js +175 -34
  17. package/dist/lib.d.ts +2 -2
  18. package/dist/lib.js +2 -2
  19. package/dist/liveness.d.ts +16 -6
  20. package/dist/liveness.js +74 -23
  21. package/dist/main.js +0 -0
  22. package/dist/podman.d.ts +57 -1
  23. package/dist/podman.js +177 -12
  24. package/dist/projects.d.ts +18 -0
  25. package/dist/projects.js +60 -1
  26. package/dist/sandbox.d.ts +14 -1
  27. package/dist/sandbox.js +88 -8
  28. package/dist/scaffold.d.ts +21 -15
  29. package/dist/scaffold.js +133 -167
  30. package/dist/term.d.ts +2 -0
  31. package/dist/term.js +14 -0
  32. package/dist/validate.d.ts +20 -3
  33. package/dist/validate.js +309 -14
  34. package/package.json +2 -18
  35. package/templates/{.github → editor/.github}/copilot-instructions.md +161 -48
  36. package/templates/{.github → editor/.github}/prompts/new-skill.prompt.md +13 -6
  37. package/templates/editor/.github/skills/api-schema-index/SKILL.md +292 -0
  38. package/templates/editor/.github/skills/zen-cli/SKILL.md +74 -0
  39. package/templates/editor/.github/skills/zen-cli/references/check.md +92 -0
  40. package/templates/editor/.github/skills/zen-cli/references/faker.md +111 -0
  41. package/templates/editor/.github/skills/zen-cli/references/frame.md +119 -0
  42. package/templates/editor/.github/skills/zen-cli/references/inspect.md +61 -0
  43. package/templates/editor/.github/skills/zen-cli/references/keys.md +114 -0
  44. package/templates/editor/.github/skills/zen-cli/references/projects.md +99 -0
  45. package/templates/editor/.github/skills/zen-cli/references/rag.md +159 -0
  46. package/templates/editor/.github/skills/zen-cli/references/run.md +104 -0
  47. package/templates/editor/.github/skills/zen-cli/references/sandbox.md +91 -0
  48. package/templates/editor/.vscode/settings.json +6 -0
  49. package/templates/parts/exa.yaml.tmpl +5 -0
  50. package/templates/parts/model.yaml.tmpl +4 -0
  51. package/templates/parts/models.yaml.tmpl +10 -0
  52. package/templates/project/INSTRUCTIONS.md +7 -0
  53. package/templates/project/SPECIFICATION.md +6 -0
  54. package/templates/project/agents/prompts/default.md +15 -0
  55. package/templates/project/agents.yaml.tmpl +44 -0
  56. package/templates/project/assets/README.md +12 -0
  57. package/templates/project/gitignore +9 -0
  58. package/templates/project/sandbox/Dockerfile +21 -0
  59. package/templates/.github/skills/zen-cli/SKILL.md +0 -110
  60. /package/templates/{.github → editor/.github}/prompts/new-agent.prompt.md +0 -0
  61. /package/templates/{.github → editor/.github}/prompts/review-project.prompt.md +0 -0
@@ -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]';
@@ -16,35 +16,59 @@ const MARK = {
16
16
  function state(entry) {
17
17
  return entry.check ? MARK[entry.check.state] : dim('unchecked');
18
18
  }
19
- function rows(store) {
19
+ /**
20
+ * An ambient credential borrowed into the shape the rest of this file works
21
+ * in. It is not in the store and never will be: `store.find` misses it, so
22
+ * `store.record` drops its check and nothing is written to disk. That is the
23
+ * intent — it can be listed and probed, not chosen or forgotten.
24
+ */
25
+ function asEntry(cred) {
26
+ return {
27
+ provider: cred.provider,
28
+ name: cred.env ? `$${cred.env}` : 'adc',
29
+ holds: cred.holds,
30
+ value: cred.value,
31
+ env: cred.env,
32
+ addedAt: new Date().toISOString(),
33
+ };
34
+ }
35
+ function rows(store, borrowed) {
20
36
  const out = [
21
37
  [bold(''), bold('KEY'), bold('VALUE'), bold('STATE'), bold('CHECKED')],
22
38
  ];
23
39
  for (const provider of OWNERS) {
24
40
  for (const entry of store.for(provider)) {
25
- const shadowed = Boolean(process.env[SHAPES[provider].env]);
41
+ const shadow = envNames(provider).find((name) => process.env[name]);
26
42
  out.push([
27
43
  store.isActive(entry) ? green('*') : ' ',
28
44
  keyId(entry),
29
45
  dim(describe(store, entry)),
30
46
  state(entry),
31
47
  dim(entry.check ? ago(entry.check.at) : '—') +
32
- (shadowed && store.isActive(entry)
33
- ? yellow(` shadowed by $${SHAPES[provider].env}`)
34
- : ''),
48
+ (shadow && store.isActive(entry) ? yellow(` shadowed by $${shadow}`) : ''),
49
+ ]);
50
+ }
51
+ for (const [cred, entry] of borrowed.filter(([c]) => c.provider === provider)) {
52
+ out.push([
53
+ dim('~'),
54
+ dim(ambientId(cred)),
55
+ dim(describe(store, entry)),
56
+ state(entry),
57
+ dim(cred.env ? 'from the environment' : 'from gcloud'),
35
58
  ]);
36
59
  }
37
60
  }
38
61
  return table(out);
39
62
  }
40
63
  /**
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.
64
+ * Checking is a network round trip per key, so it says which ones have come
65
+ * back. Silence for ten seconds is indistinguishable from a hang.
43
66
  */
44
67
  async function checkAll(ctx, store, targets) {
45
68
  const bar = ctx.json ? undefined : progress();
69
+ bar?.update(dim(`checking ${targets.length} key${targets.length === 1 ? '' : 's'} …`));
46
70
  try {
47
- return await probeAll(store, targets, (entry, index, total) => bar?.update(dim(`checking ${keyId(entry)} … ${index + 1}/${total}`)));
71
+ return await probeAll(store, targets, (entry, done, total) => bar?.update(dim(`checked ${keyId(entry)} … ${done}/${total}`)));
48
72
  }
49
73
  finally {
50
74
  bar?.done();
@@ -53,29 +77,47 @@ async function checkAll(ctx, store, targets) {
53
77
  const ls = async (ctx, args) => {
54
78
  const { values } = parse(args, { check: { type: 'boolean' } }, USAGE);
55
79
  const store = await KeyStore.open();
80
+ const borrowed = ambient(store).map((cred) => [cred, asEntry(cred)]);
56
81
  if (values.check) {
57
- const checks = await checkAll(ctx, store, store.entries);
82
+ // Ambient credentials are checked alongside the stored ones and, unlike
83
+ // them, forgotten again: there is nowhere to write the result, and a
84
+ // variable that changes between runs would make a cached one a lie.
85
+ const checks = await checkAll(ctx, store, [
86
+ ...store.entries,
87
+ ...borrowed.map(([, entry]) => entry),
88
+ ]);
58
89
  for (const [entry, check] of checks) {
59
90
  store.record(entry, check);
91
+ entry.check = check;
60
92
  }
61
93
  store.save();
62
94
  }
63
95
  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
- })));
96
+ json({
97
+ keys: store.entries.map((e) => ({
98
+ ...e,
99
+ value: e.holds === 'file' ? store.fileOf(e) : mask(e.value),
100
+ active: store.isActive(e),
101
+ env: envOf(e),
102
+ })),
103
+ ambient: borrowed.map(([cred, entry]) => ({
104
+ provider: cred.provider,
105
+ id: ambientId(cred),
106
+ env: cred.env ?? null,
107
+ holds: cred.holds,
108
+ value: cred.holds === 'file' ? cred.value : mask(cred.value),
109
+ check: entry.check,
110
+ })),
111
+ });
70
112
  return;
71
113
  }
72
- if (store.entries.length === 0) {
114
+ if (store.entries.length === 0 && borrowed.length === 0) {
73
115
  note('the keyring is empty');
74
116
  note(dim('add one: zen key add openai'));
75
117
  return;
76
118
  }
77
- writeAll(rows(store));
78
- const missing = OWNERS.filter((p) => !store.active(p) && !process.env[SHAPES[p].env]);
119
+ writeAll(rows(store, borrowed));
120
+ const missing = OWNERS.filter((p) => !store.active(p) && !borrowed.some(([c]) => c.provider === p));
79
121
  if (missing.length) {
80
122
  note('');
81
123
  note(dim(`no key for: ${missing.join(', ')}`));
@@ -87,7 +129,12 @@ const ls = async (ctx, args) => {
87
129
  * Piped stdin or an echo-off prompt are the only two ways in.
88
130
  */
89
131
  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]');
132
+ const { values, positionals } = parse(args, {
133
+ name: { type: 'string' },
134
+ 'no-check': { type: 'boolean' },
135
+ project: { type: 'string' },
136
+ location: { type: 'string' },
137
+ }, 'zen key add <provider>[/name] [--name <name>] [--project <id>] [--location <region>] [--no-check]');
91
138
  const ref = positionals[0];
92
139
  if (!ref) {
93
140
  throw usageError('which provider?', `one of: ${OWNERS.join(', ')}`);
@@ -96,6 +143,9 @@ const add = async (ctx, args) => {
96
143
  const provider = parsed.provider;
97
144
  const name = values.name ?? parsed.name ?? 'default';
98
145
  const shape = SHAPES[provider];
146
+ if ((values.project || values.location) && provider !== 'vertex') {
147
+ throw usageError(`--project and --location mean nothing to ${shape.label}`, 'they configure a Vertex service account');
148
+ }
99
149
  ensureHome();
100
150
  const store = await KeyStore.open();
101
151
  if (store.find(provider, name) && isInteractive()) {
@@ -103,14 +153,20 @@ const add = async (ctx, args) => {
103
153
  throw usageError('cancelled');
104
154
  }
105
155
  }
106
- const raw = (await readStdin()) ?? (await promptFor(shape.holds, shape.label, shape.where));
156
+ const raw = (await readStdin()) ?? (await promptFor(shape));
107
157
  if (!raw) {
108
158
  throw usageError('no value given');
109
159
  }
110
- if (shape.holds === 'file' && !existsSync(raw)) {
160
+ // A provider with one form can say up front that a path is wrong. One with
161
+ // two cannot: a value that is not a path is not a mistake there, it is the
162
+ // other kind of credential, and `add` decides which by looking.
163
+ if (shape.forms.length === 1 && shape.forms[0].holds === 'file' && !existsSync(raw)) {
111
164
  throw usageError(`no such file: ${raw}`);
112
165
  }
113
- const entry = store.add(provider, name, raw);
166
+ const entry = store.add(provider, name, raw, {
167
+ project: values.project,
168
+ location: values.location,
169
+ });
114
170
  // Verified before it is trusted, but stored either way: a key that cannot
115
171
  // be checked right now — offline, behind a proxy — is not a key that is
116
172
  // wrong, and refusing to save it would make `zen key add` fail on a plane.
@@ -128,20 +184,37 @@ const add = async (ctx, args) => {
128
184
  if (ctx.json) {
129
185
  json({
130
186
  key: keyId(entry),
131
- env: shape.env,
187
+ env: envOf(entry),
132
188
  active: store.isActive(entry),
133
189
  check: entry.check,
134
190
  });
135
191
  return;
136
192
  }
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`));
193
+ note(`${green('added')} ${bold(keyId(entry))} ${dim(describe(store, entry))} ` +
194
+ `${dim(`→ $${envOf(entry)}`)} ${state(entry)}`);
195
+ if (process.env[envOf(entry)]) {
196
+ note(yellow(`$${envOf(entry)} is set and will win over this`));
197
+ }
198
+ if (provider === 'vertex' && entry.holds === 'file' && !entry.project) {
199
+ note(dim('no --project given; the project_id inside the file will be used'));
140
200
  }
141
201
  };
142
- async function promptFor(holds, label, where) {
143
- note(dim(`${label} ${where}`));
144
- return holds === 'file'
202
+ /**
203
+ * A provider with two forms cannot ask for one of them. It asks with the echo
204
+ * off, because one of the two answers is a secret and there is no way to know
205
+ * which is coming until it arrives.
206
+ */
207
+ async function promptFor(shape) {
208
+ const [first, ...rest] = shape.forms;
209
+ if (rest.length) {
210
+ note(dim(`${shape.label} — ${first.where},`));
211
+ for (const form of rest) {
212
+ note(dim(` or ${form.where}`));
213
+ }
214
+ return askSecret('Paste the key, or a path to the file:');
215
+ }
216
+ note(dim(`${shape.label} — ${first.where}`));
217
+ return first.holds === 'file'
145
218
  ? ask('Path to the credentials file:')
146
219
  : askSecret('Paste the key (it will not be shown):');
147
220
  }
@@ -159,7 +232,7 @@ const use = async (ctx, args) => {
159
232
  const entry = store.use(provider, name);
160
233
  store.save();
161
234
  if (ctx.json) {
162
- json({ key: keyId(entry), env: SHAPES[provider].env });
235
+ json({ key: keyId(entry), env: envOf(entry) });
163
236
  return;
164
237
  }
165
238
  note(`${green('using')} ${bold(keyId(entry))} for ${SHAPES[provider].label}`);
@@ -242,7 +315,7 @@ const show = async (ctx, args) => {
242
315
  if (ctx.json) {
243
316
  json({
244
317
  key: keyId(entry),
245
- env: SHAPES[entry.provider].env,
318
+ env: envOf(entry),
246
319
  value,
247
320
  revealed: Boolean(values.reveal),
248
321
  });
@@ -256,8 +329,10 @@ const show = async (ctx, args) => {
256
329
  }
257
330
  writeAll(table([
258
331
  [dim('key'), keyId(entry)],
259
- [dim('env'), SHAPES[entry.provider].env],
332
+ [dim('env'), envOf(entry)],
260
333
  [dim('value'), value],
334
+ ...(entry.project ? [[dim('project'), entry.project]] : []),
335
+ ...(entry.location ? [[dim('location'), entry.location]] : []),
261
336
  [dim('state'), state(entry)],
262
337
  [dim('added'), ago(entry.addedAt)],
263
338
  ]));
@@ -278,8 +353,18 @@ const env = async (ctx, args) => {
278
353
  const vars = {};
279
354
  for (const provider of only ?? OWNERS) {
280
355
  const entry = store.active(provider);
281
- if (entry) {
282
- vars[SHAPES[provider].env] = store.reveal(entry);
356
+ if (!entry) {
357
+ continue;
358
+ }
359
+ vars[envOf(entry)] = store.reveal(entry);
360
+ // A service account says which project it belongs to but not which
361
+ // region to call, and a shell that has the file and not the region is
362
+ // still a shell that cannot reach a model.
363
+ if (entry.project) {
364
+ vars.GOOGLE_CLOUD_PROJECT = entry.project;
365
+ }
366
+ if (entry.location) {
367
+ vars.GOOGLE_CLOUD_LOCATION = entry.location;
283
368
  }
284
369
  }
285
370
  if (ctx.json) {
@@ -306,11 +391,16 @@ export const key = {
306
391
  usage: USAGE,
307
392
  details: [
308
393
  'Keys live in ~/.zenera/neo/keys.json (0600) and are materialised into',
309
- 'the environment before a run. A real environment variable always wins.',
394
+ 'the environment before a run. A real environment variable always wins,',
395
+ 'and is listed as ~ so it is clear where a working provider comes from.',
310
396
  '',
311
397
  'Model providers: openai, anthropic, google, vertex, openrouter.',
312
398
  'Services the tools call: exa.',
313
399
  '',
400
+ 'Vertex takes either shape: a service-account JSON file, which wants',
401
+ '--project and --location too, or an express-mode API key, which wants',
402
+ 'neither. Which one you gave is read off the value.',
403
+ '',
314
404
  ' zen key ls [--check] Everything stored, and its state.',
315
405
  ' zen key add <provider>[/name] Read a key from stdin, or ask for it.',
316
406
  ' zen key use <provider>/<name> Choose which one a run uses.',
@@ -1,6 +1,6 @@
1
1
  import { loadProject, readProjectConfig } from '@zenera/neo';
2
2
  import { parse } from "../args.js";
3
- import { KeyStore, PROVIDERS, SHAPES } from "../keys.js";
3
+ import { envNames, form, KeyStore, PROVIDERS } from "../keys.js";
4
4
  import { project as resolveProject } from "../resolve.js";
5
5
  import { bold, credentialError, cyan, dim, green, invalidError, json, note, red, table, writeAll, yellow, } from "../term.js";
6
6
  import { availableTools } from "../validate.js";
@@ -22,7 +22,7 @@ export const models = {
22
22
  // Asked before materialising, because materialising is exactly what
23
23
  // erases the difference between "the environment had it" and "the
24
24
  // keyring supplied it".
25
- const fromEnv = new Set(PROVIDERS.filter((p) => process.env[SHAPES[p].env]));
25
+ const fromEnv = new Set(PROVIDERS.filter((p) => envNames(p).some((name) => process.env[name])));
26
26
  store.materialize();
27
27
  let project;
28
28
  try {
@@ -64,7 +64,7 @@ export const models = {
64
64
  }));
65
65
  const credentials = PROVIDERS.map((p) => ({
66
66
  provider: p,
67
- env: SHAPES[p].env,
67
+ env: envNames(p).find((name) => process.env[name]) ?? form(p).env,
68
68
  source: fromEnv.has(p)
69
69
  ? 'environment'
70
70
  : store.active(p)
@@ -4,7 +4,7 @@ import { homedir } from 'node:os';
4
4
  import { delimiter, join, resolve, sep } from 'node:path';
5
5
  import { one, parse } from "../args.js";
6
6
  import { project as resolveProject } from "../resolve.js";
7
- import { copilotInstructions, editorSettings } from "../scaffold.js";
7
+ import { editorFiles } from "../scaffold.js";
8
8
  import { CliError, cyan, dim, EXIT, json, note, usageError } from "../term.js";
9
9
  const USAGE = 'zen open [project] [--editor <cmd>] [--wait]';
10
10
  /**
@@ -42,7 +42,7 @@ export const open = {
42
42
  // opened on, and a project may predate either of them — or the version
43
43
  // this `zen` writes. They are ours, so they are written fresh before
44
44
  // the window is there to read them.
45
- const written = [editorSettings(dir), ...copilotInstructions(dir)];
45
+ const written = editorFiles(dir);
46
46
  if (ctx.json) {
47
47
  json({
48
48
  name: found.name,
@@ -19,6 +19,7 @@ export const run = {
19
19
  ' --model <ref> Override the default model.',
20
20
  ' --image <ref> Override the sandbox image commands run in.',
21
21
  ' --read-only Give the agent no way to write.',
22
+ ' --no-keys Keep the API keys out of the sandbox.',
22
23
  ' --quiet Answer only; no narration.',
23
24
  ' --plain One shot, even on a terminal.',
24
25
  ' --theme <dark|light> Force the palette. Detected otherwise; $ZENERA_THEME.',
@@ -43,6 +44,7 @@ export const run = {
43
44
  workspace: { type: 'string' },
44
45
  model: { type: 'string' },
45
46
  image: { type: 'string' },
47
+ 'no-keys': { type: 'boolean' },
46
48
  'read-only': { type: 'boolean' },
47
49
  yes: { type: 'boolean' },
48
50
  quiet: { type: 'boolean' },
@@ -84,6 +86,7 @@ export const run = {
84
86
  readOnly: values['read-only'],
85
87
  model: values.model,
86
88
  image: values.image,
89
+ keys: values['no-keys'] ? false : undefined,
87
90
  yes: values.yes || ctx.json,
88
91
  });
89
92
  try {