@zenera/cli 1.1.2 → 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 (50) 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 +34 -7
  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/engine.d.ts +2 -0
  11. package/dist/engine.js +1 -0
  12. package/dist/keys.d.ts +95 -12
  13. package/dist/keys.js +175 -34
  14. package/dist/lib.d.ts +1 -1
  15. package/dist/lib.js +1 -1
  16. package/dist/liveness.d.ts +16 -6
  17. package/dist/liveness.js +74 -23
  18. package/dist/sandbox.d.ts +2 -0
  19. package/dist/sandbox.js +58 -7
  20. package/dist/scaffold.d.ts +21 -21
  21. package/dist/scaffold.js +132 -204
  22. package/dist/validate.js +3 -3
  23. package/package.json +2 -18
  24. package/templates/{.github → editor/.github}/copilot-instructions.md +7 -6
  25. package/templates/editor/.github/skills/api-schema-index/SKILL.md +292 -0
  26. package/templates/editor/.github/skills/zen-cli/SKILL.md +74 -0
  27. package/templates/editor/.github/skills/zen-cli/references/check.md +92 -0
  28. package/templates/editor/.github/skills/zen-cli/references/faker.md +111 -0
  29. package/templates/editor/.github/skills/zen-cli/references/frame.md +119 -0
  30. package/templates/editor/.github/skills/zen-cli/references/inspect.md +61 -0
  31. package/templates/editor/.github/skills/zen-cli/references/keys.md +114 -0
  32. package/templates/editor/.github/skills/zen-cli/references/projects.md +99 -0
  33. package/templates/editor/.github/skills/zen-cli/references/rag.md +159 -0
  34. package/templates/editor/.github/skills/zen-cli/references/run.md +104 -0
  35. package/templates/editor/.github/skills/zen-cli/references/sandbox.md +91 -0
  36. package/templates/editor/.vscode/settings.json +6 -0
  37. package/templates/parts/exa.yaml.tmpl +5 -0
  38. package/templates/parts/model.yaml.tmpl +4 -0
  39. package/templates/parts/models.yaml.tmpl +10 -0
  40. package/templates/project/INSTRUCTIONS.md +7 -0
  41. package/templates/project/SPECIFICATION.md +6 -0
  42. package/templates/project/agents/prompts/default.md +15 -0
  43. package/templates/project/agents.yaml.tmpl +44 -0
  44. package/templates/project/assets/README.md +12 -0
  45. package/templates/project/gitignore +9 -0
  46. package/templates/{sandbox → project/sandbox}/Dockerfile +2 -0
  47. package/templates/.github/skills/zen-cli/SKILL.md +0 -110
  48. /package/templates/{.github → editor/.github}/prompts/new-agent.prompt.md +0 -0
  49. /package/templates/{.github → editor/.github}/prompts/new-skill.prompt.md +0 -0
  50. /package/templates/{.github → editor/.github}/prompts/review-project.prompt.md +0 -0
package/README.md CHANGED
@@ -22,12 +22,11 @@ carries your keys with it.
22
22
 
23
23
  ## Install
24
24
 
25
- Node.js 24+. Install the CLI together with at least one vendor SDK — they are
26
- **optional peer dependencies**, so you only pay for the ones you use.
25
+ Node.js 24+. One command the OpenAI, Anthropic, Google and OpenRouter SDKs
26
+ all ship with the CLI, so any provider works out of the box.
27
27
 
28
28
  ```sh
29
- npm i -g @zenera/cli openai
30
- # or @anthropic-ai/sdk, @google/genai, @openrouter/sdk — any mix of them
29
+ npm i -g @zenera/cli
31
30
  ```
32
31
 
33
32
  Or without installing anything:
@@ -41,7 +40,7 @@ npx @zenera/cli --help
41
40
  Four commands, from nothing to an answer:
42
41
 
43
42
  ```sh
44
- npm i -g @zenera/cli openai # the CLI, plus one vendor SDK
43
+ npm i -g @zenera/cli # every vendor SDK comes with it
45
44
  zen key add openai # prompts with the echo off; stored in ~/.zenera
46
45
  zen init my-project # scaffolds a project and registers it
47
46
  cd my-project && zen run "introduce yourself"
@@ -199,6 +198,87 @@ command — so `zen run … | jq` is a supported way to use it, not an accident.
199
198
 
200
199
  The binary is installed under three names: `zen`, `zn` and `zenera`.
201
200
 
201
+ ## Credentials
202
+
203
+ One keyring serves every provider, and a key goes in the same way whatever it
204
+ is for:
205
+
206
+ ```sh
207
+ zen key add <provider> # prompts with the echo off
208
+ zen key add <provider> < key.txt # or pipe it
209
+ ```
210
+
211
+ The value never comes from argv — a command line lands in `ps`, in shell
212
+ history and in CI logs — so piped stdin and the echo-off prompt are the only
213
+ two ways in. Entries live in `~/.zenera/neo/keys.json` (mode `0600`) and are
214
+ materialised into the environment just before a run, so a real environment
215
+ variable always wins and a project checked out on a machine without `zen` still
216
+ runs.
217
+
218
+ | Provider | The value is | Exported as |
219
+ | ------------ | ------------------------------------- | -------------------------------- |
220
+ | `openai` | a secret | `OPENAI_API_KEY` |
221
+ | `anthropic` | a secret | `ANTHROPIC_API_KEY` |
222
+ | `google` | a secret — AI Studio | `GEMINI_API_KEY` |
223
+ | `vertex` | a path to a service-account JSON file | `GOOGLE_APPLICATION_CREDENTIALS` |
224
+ | `openrouter` | a secret | `OPENROUTER_API_KEY` |
225
+ | `exa` | a secret — for the search tool | `EXA_API_KEY` |
226
+
227
+ `zen key add` verifies the credential against the provider before it finishes,
228
+ but stores it either way: a key that cannot be checked right now — offline,
229
+ behind a proxy — is not a key that is wrong. `--no-check` skips the call.
230
+
231
+ ### Which key a model uses
232
+
233
+ A model reference is `[provider[/api]:]model`, and the first segment names a
234
+ **provider, not a vendor**. So `vertex:gemini-3.5-flash` and
235
+ `google:gemini-3.5-flash` are the same model reached through two different
236
+ services, needing two different credentials — and a bare `gpt-5.4-mini` goes to
237
+ the default provider, `openai`. `zen models` resolves every reference in a
238
+ project against what is stored, and calls nothing.
239
+
240
+ ### Vertex AI
241
+
242
+ Vertex accepts either shape, and which one you gave is read off the value.
243
+
244
+ The usual one is a **service-account JSON file** — give its path, not its
245
+ contents:
246
+
247
+ ```sh
248
+ printf '%s' ~/keys/vertex-sa.json | zen key add vertex --location us-central1
249
+ ```
250
+
251
+ The file is copied into `~/.zenera/neo/keys/` at mode `0600`, so moving or
252
+ cleaning up the original later cannot break it.
253
+
254
+ - `--location <region>` is worth setting. It must be `global` or a **concrete
255
+ region**; multi-region names like `us` are rejected with a 404. `global`
256
+ routes across regions and pays about ten seconds of cold start on the first
257
+ request each process makes — a region answers in about two.
258
+ - `--project <id>` is only needed when the `project_id` inside the file is not
259
+ the project you want.
260
+
261
+ The alternative is an **express-mode API key**, an ordinary secret under
262
+ `VERTEX_API_KEY`. Express mode addresses no project, so `--project` and
263
+ `--location` mean nothing there and are not stored.
264
+
265
+ ### More than one key per provider
266
+
267
+ Entries are named, so a provider can hold several and one of them is active:
268
+
269
+ ```sh
270
+ zen key add openai/work # a second entry
271
+ zen key use openai/work # which one runs use
272
+ zen key ls --check # everything stored, and whether it still works
273
+ zen key show vertex/default # masked — --reveal prints the secret
274
+ zen key env openai # shell exports, for other tools
275
+ zen key rm openai/work
276
+ ```
277
+
278
+ `zen key ls` marks the active entry with `*`, and something it found in your
279
+ real environment or in `gcloud`'s ADC with `~`, so it is always clear where a
280
+ working provider actually comes from.
281
+
202
282
  ## Concepts
203
283
 
204
284
  - **Project** — a named directory holding a complete agent definition and the
@@ -225,13 +305,10 @@ want the runtime inside your own application rather than on a terminal:
225
305
 
226
306
  ## Documentation
227
307
 
228
- Full specification:
229
- [packages/cli/DESIGN.md](https://github.com/andreyryabov/ZeneraNeo/blob/main/packages/cli/DESIGN.md).
230
- Also
231
- [DESIGN.md](https://github.com/andreyryabov/ZeneraNeo/blob/main/DESIGN.md) ·
232
308
  [docs/projects.md](https://github.com/andreyryabov/ZeneraNeo/blob/main/docs/projects.md)
233
- ·
234
- [docs/agents-yaml.md](https://github.com/andreyryabov/ZeneraNeo/blob/main/docs/agents-yaml.md).
309
+ — the folder a project is ·
310
+ [docs/agents-yaml.md](https://github.com/andreyryabov/ZeneraNeo/blob/main/docs/agents-yaml.md)
311
+ — every key in the configuration file.
235
312
 
236
313
  ## License
237
314
 
package/dist/audit.d.ts CHANGED
@@ -22,13 +22,15 @@ export interface ModelIssue {
22
22
  * Whether a requirement is actually met, and under which variable.
23
23
  *
24
24
  * `satisfied` is the library's answer and it is about api keys, which is the
25
- * wrong question for Vertex: that kind is `keyOptional` because it
26
- * authenticates from a service-account file instead. So the file-shaped
27
- * credential is looked for where the keyring keeps it.
25
+ * wrong question for Vertex: that kind is `keyOptional` because it can
26
+ * authenticate from a service-account file instead. So both of its variables
27
+ * are looked for, along with the file `gcloud` leaves behind.
28
28
  *
29
- * Exported because a report that named `VERTEX_API_KEY` while the audit
30
- * silently checked `GOOGLE_APPLICATION_CREDENTIALS` would be two answers to
31
- * one question, and the wrong one is the one people would act on.
29
+ * The variable named back is the one that is actually set, because a report
30
+ * that named `VERTEX_API_KEY` while the audit silently checked
31
+ * `GOOGLE_APPLICATION_CREDENTIALS` would be two answers to one question, and
32
+ * the wrong one is the one people would act on. With nothing set there is no
33
+ * such answer, so it names the form the provider is usually reached by.
32
34
  */
33
35
  export declare function credentialFor(need: ModelRequirement): {
34
36
  env: string;
package/dist/audit.js CHANGED
@@ -1,8 +1,5 @@
1
- import { existsSync } from 'node:fs';
2
- import { homedir } from 'node:os';
3
- import { join } from 'node:path';
4
1
  import { projectRegistry, readProjectConfig, } from '@zenera/neo';
5
- import { SHAPES, isProvider } from "./keys.js";
2
+ import { envNames, form, gcloudAdc, isProvider, SHAPES, } from "./keys.js";
6
3
  import { bold, dim } from "./term.js";
7
4
  /**
8
5
  * Every model the project names, keyed by how one would refer to it.
@@ -36,32 +33,27 @@ function declared(config) {
36
33
  ['embedding', embeddings],
37
34
  ]);
38
35
  }
39
- /**
40
- * `gcloud auth application-default login` writes here, and the GenAI SDK finds
41
- * it with no variable set — so without this check every developer using ADC
42
- * would be told their working Vertex setup is broken.
43
- */
44
- function hasGcloudAdc() {
45
- const dir = process.env.CLOUDSDK_CONFIG ?? join(homedir(), '.config', 'gcloud');
46
- return existsSync(join(dir, 'application_default_credentials.json'));
47
- }
48
36
  /**
49
37
  * Whether a requirement is actually met, and under which variable.
50
38
  *
51
39
  * `satisfied` is the library's answer and it is about api keys, which is the
52
- * wrong question for Vertex: that kind is `keyOptional` because it
53
- * authenticates from a service-account file instead. So the file-shaped
54
- * credential is looked for where the keyring keeps it.
40
+ * wrong question for Vertex: that kind is `keyOptional` because it can
41
+ * authenticate from a service-account file instead. So both of its variables
42
+ * are looked for, along with the file `gcloud` leaves behind.
55
43
  *
56
- * Exported because a report that named `VERTEX_API_KEY` while the audit
57
- * silently checked `GOOGLE_APPLICATION_CREDENTIALS` would be two answers to
58
- * one question, and the wrong one is the one people would act on.
44
+ * The variable named back is the one that is actually set, because a report
45
+ * that named `VERTEX_API_KEY` while the audit silently checked
46
+ * `GOOGLE_APPLICATION_CREDENTIALS` would be two answers to one question, and
47
+ * the wrong one is the one people would act on. With nothing set there is no
48
+ * such answer, so it names the form the provider is usually reached by.
59
49
  */
60
50
  export function credentialFor(need) {
61
51
  const provider = isProvider(need.kind) ? need.kind : undefined;
62
- const shape = provider ? SHAPES[provider] : undefined;
63
- if (shape?.holds === 'file') {
64
- return { env: shape.env, present: Boolean(process.env[shape.env]) || hasGcloudAdc() };
52
+ // A provider with a file form can authenticate without an api key, which is
53
+ // the only thing `satisfied` knows how to look for.
54
+ if (provider && SHAPES[provider].forms.some((f) => f.holds === 'file')) {
55
+ const set = envNames(provider).find((name) => process.env[name]);
56
+ return { env: set ?? form(provider).env, present: Boolean(set) || Boolean(gcloudAdc()) };
65
57
  }
66
58
  return { env: need.apiKeyEnv, present: need.satisfied };
67
59
  }
@@ -1,11 +1,12 @@
1
+ import { existsSync, statSync } from 'node:fs';
1
2
  import { basename, dirname, resolve } from 'node:path';
2
3
  import { one, parse } from "../args.js";
3
4
  import { KeyStore } from "../keys.js";
4
5
  import { Registry } from "../projects.js";
5
6
  import { project as resolveProject } from "../resolve.js";
6
- import { bold, count, cyan, dim, green, invalidError, json, progress, red, table, write, writeAll, yellow, } from "../term.js";
7
+ import { bold, count, cyan, dim, green, invalidError, json, progress, red, table, usageError, write, writeAll, yellow, } from "../term.js";
7
8
  import { validateProject, } from "../validate.js";
8
- const USAGE = 'zen check [dir] [--project <name|dir>] [--no-sandbox] [--strict] [--quiet]';
9
+ const USAGE = 'zen check [name|dir] [--project <name|dir>] [--no-sandbox] [--strict] [--quiet]';
9
10
  // ---------------------------------------------------------------------------
10
11
  // zen check
11
12
  //
@@ -41,6 +42,9 @@ export const check = {
41
42
  'Unlike a run, it does not stop at the first problem — the report lists',
42
43
  'everything it found, each with a code and the fix for it.',
43
44
  '',
45
+ 'The argument is a directory if one is there and a registered project',
46
+ 'name otherwise; with neither, the project you are standing in.',
47
+ '',
44
48
  'Exit codes: 0 nothing wrong, 3 at least one error (or, with --strict,',
45
49
  'at least one warning). --quiet prints the findings and nothing else.',
46
50
  ],
@@ -51,12 +55,9 @@ export const check = {
51
55
  strict: { type: 'boolean' },
52
56
  quiet: { type: 'boolean' },
53
57
  }, USAGE);
54
- // A bare directory is accepted so an unregistered folder — a checkout,
55
- // a scaffold in progress — can be checked at all. `--project` goes
56
- // through the registry, like everywhere else.
57
- const here = one(positionals, 'directory', USAGE);
58
+ const here = one(positionals, 'project or directory', USAGE);
58
59
  const dir = here
59
- ? resolve(ctx.cwd, here)
60
+ ? await locate(ctx.cwd, here)
60
61
  : await resolveProject({ cwd: ctx.cwd, project: values.project }).then((p) => p.dir);
61
62
  // Being listed is the registry's answer, not the directory's, so it is
62
63
  // read here and handed to the check rather than looked up inside it.
@@ -96,6 +97,32 @@ export const check = {
96
97
  }
97
98
  },
98
99
  };
100
+ /**
101
+ * What a bare argument means: a directory when one is there, a registered name
102
+ * otherwise. A directory is tried first, and it does not have to be a project
103
+ * yet — an unregistered folder, a checkout, a scaffold in progress is exactly
104
+ * what there is to check.
105
+ *
106
+ * A word that is neither is a usage error and stops here. The check itself
107
+ * would answer it too, but it would answer at the length of a full report, and
108
+ * a page of empty sections about a directory that does not exist buries the one
109
+ * line that matters: there is nothing by that name.
110
+ */
111
+ async function locate(cwd, arg) {
112
+ const at = resolve(cwd, arg);
113
+ if (existsSync(at) && statSync(at).isDirectory()) {
114
+ return at;
115
+ }
116
+ const entry = (await Registry.open()).find(arg);
117
+ if (!entry) {
118
+ throw usageError(`no project or directory named "${arg}"`, 'see what is registered: zen list');
119
+ }
120
+ const path = resolve(entry.path);
121
+ if (!existsSync(path)) {
122
+ throw usageError(`project "${entry.name}" is registered at ${path}, which is gone`, 'forget it: zen list --prune');
123
+ }
124
+ return path;
125
+ }
99
126
  // ---------------------------------------------------------------------------
100
127
  // Rendering
101
128
  // ---------------------------------------------------------------------------
@@ -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