@zenera/cli 1.1.17 → 1.1.18

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 +99 -16
  2. package/dist/banner.d.ts +19 -0
  3. package/dist/banner.js +12 -9
  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 +7 -0
  30. package/dist/term.js +8 -1
  31. package/dist/tui/app.js +33 -5
  32. package/dist/tui/wrap.d.ts +1 -1
  33. package/dist/tui/wrap.js +1 -1
  34. package/package.json +3 -2
  35. package/templates/editor/.github/copilot-instructions.md +59 -5
  36. package/templates/editor/.github/prompts/{review-project.prompt.md → project-review.prompt.md} +0 -1
  37. package/templates/editor/.github/prompts/{update-spec-from-feedback.prompt.md → spec-apply-feedback.prompt.md} +5 -5
  38. package/templates/editor/.github/prompts/{sync-with-spec.prompt.md → spec-sync-project.prompt.md} +4 -4
  39. package/templates/editor/.github/skills/zen-cli/references/inspect.md +4 -3
  40. package/templates/editor/.github/skills/zen-cli/references/memory.md +14 -0
  41. package/templates/editor/.github/skills/zen-cli/references/run.md +75 -16
  42. package/templates/editor/.github/skills/zen-memory/SKILL.md +52 -4
  43. package/templates/editor/.github/skills/zen-review/SKILL.md +2 -2
  44. package/templates/editor/.github/skills/zen-review/scripts/review.sh +1 -1
  45. package/templates/editor/.github/skills/zen-spec-sync/SKILL.md +2 -2
  46. package/templates/editor/.github/skills/zen-spec-sync/scripts/snapshot.sh +1 -1
  47. package/templates/project/SPECIFICATION.md.tmpl +25 -2
  48. package/templates/project/agents.yaml.tmpl +3 -3
  49. package/templates/project/env +7 -1
  50. package/templates/project/gitignore +4 -4
package/README.md CHANGED
@@ -125,7 +125,7 @@ In the editor, describe the job in `SPECIFICATION.md`, then send this in the
125
125
  agent chat:
126
126
 
127
127
  ```
128
- /sync-with-spec
128
+ /spec-sync-project
129
129
  ```
130
130
 
131
131
  It creates the specialists, responsibilities, tool access and handoffs the job
@@ -145,7 +145,7 @@ zen run my-project # no prompt given: opens a full-screen terminal
145
145
  zen check my-project # validate the project and every file it names
146
146
  zen inspect # open the last run's report.html
147
147
  zen list --sessions # every project, its sessions and last run
148
- echo "triage this" | zen run my-project --quiet | jq
148
+ echo "triage this" | zen run my-project --json | jq
149
149
  ```
150
150
 
151
151
  Standing inside the project, the name is optional: a bare `zen run`, `zen check`
@@ -156,7 +156,7 @@ session and uses the current directory as the workspace, with write access.
156
156
  `--session`, `--workspace` and `--read-only` override that.
157
157
 
158
158
  To change what the system does, update `SPECIFICATION.md` and send
159
- `/sync-with-spec` again. The next section explains that workflow in detail.
159
+ `/spec-sync-project` again. The next section explains that workflow in detail.
160
160
 
161
161
  ## You write the specification; a coding agent writes the system
162
162
 
@@ -202,15 +202,15 @@ In the window that opens:
202
202
 
203
203
  1. **Edit `SPECIFICATION.md`** - what the system is for, which agents exist,
204
204
  what each may reach for, and what _done_ means.
205
- 2. **Open the chat panel and send `/sync-with-spec`.** It reads the
205
+ 2. **Open the chat panel and send `/spec-sync-project`.** It reads the
206
206
  specification and every file implementing it, works out the difference in
207
207
  both directions, and changes the smallest thing that closes each gap.
208
208
  3. **Answer `SPECIFICATION-FEEDBACK.md`**, which it writes for anything it could
209
209
  not do without guessing. Every question carries two to four written-out
210
210
  answers, each with a `[ ]` box: tick one, delete the ones you do not want, or
211
211
  write your own in their place.
212
- 4. **Send `/update-spec-from-feedback`.** It folds the answers you chose into
213
- `SPECIFICATION.md` - never into a prompt - and then `/sync-with-spec` again.
212
+ 4. **Send `/spec-apply-feedback`.** It folds the answers you chose into
213
+ `SPECIFICATION.md` - never into a prompt - and then `/spec-sync-project` again.
214
214
 
215
215
  Back in the terminal: `zen check`, then `zen run`.
216
216
 
@@ -221,16 +221,16 @@ They are prompt files under `.github/prompts/`, which VS Code and its forks
221
221
  offer as chat slash-commands. Both `zen init` and `zen open` write them fresh,
222
222
  so they never go stale - and edits to them do not survive.
223
223
 
224
- | In chat | Does |
225
- | ---------------------------- | ---------------------------------------------------------------- |
226
- | `/sync-with-spec` | Makes every file match `SPECIFICATION.md`, both directions. |
227
- | `/update-spec-from-feedback` | Folds your answers in `SPECIFICATION-FEEDBACK.md` into the spec. |
228
- | `/review-project` | Reads the project as a reviewer would, and reports. |
229
- | `/new-agent` | Adds an agent - prompt, wiring and hand-offs. |
230
- | `/new-skill` | Adds a skill under `agents/skills/`. |
224
+ | In chat | Does |
225
+ | ---------------------- | ---------------------------------------------------------------- |
226
+ | `/spec-sync-project` | Makes every file match `SPECIFICATION.md`, both directions. |
227
+ | `/spec-apply-feedback` | Folds your answers in `SPECIFICATION-FEEDBACK.md` into the spec. |
228
+ | `/project-review` | Reads the project as a reviewer would, and reports. |
229
+ | `/new-agent` | Adds an agent - prompt, wiring and hand-offs. |
230
+ | `/new-skill` | Adds a skill under `agents/skills/`. |
231
231
 
232
232
  In an editor that does not support prompt files, paste the contents of
233
- `.github/prompts/sync-with-spec.prompt.md` into its chat instead - it is only a
233
+ `.github/prompts/spec-sync-project.prompt.md` into its chat instead - it is only a
234
234
  prompt. Alongside them, `.github/copilot-instructions.md` is the standing brief
235
235
  that explains this runtime to whatever agent is reading, and
236
236
  `.github/skills/zen-cli/` is the CLI's own reference for it.
@@ -240,7 +240,7 @@ that explains this runtime to whatever agent is reading, and
240
240
  ### The self-improving loop
241
241
 
242
242
  ```
243
- edit SPECIFICATION.md → /sync-with-spec → zen check → zen run
243
+ edit SPECIFICATION.md → /spec-sync-project → zen check → zen run
244
244
  ↑ ↓
245
245
  └───── refine the spec <-──── zen inspect <-───────┘
246
246
  ```
@@ -299,7 +299,7 @@ Read a codebase and leave a short written note about it in the workspace.
299
299
  - Every claim in it comes from a file that was actually read.
300
300
  ```
301
301
 
302
- Send `/sync-with-spec` in the editor's chat. It writes `agents.yaml` - who
302
+ Send `/spec-sync-project` in the editor's chat. It writes `agents.yaml` - who
303
303
  exists, and what each may reach for:
304
304
 
305
305
  ```yaml
@@ -465,6 +465,7 @@ remove resources beyond the current project.
465
465
  | `key` | The credential keyring - add, check, switch, remove. |
466
466
  | `models` | What this machine can use - list, search, test, pick. |
467
467
  | `run` | Runs the project - the TUI on a terminal, a single answer otherwise. |
468
+ | `meta` | Runs the meta agent over the project - the one that edits it. |
468
469
  | `inspect` | Opens or rebuilds a run's `report.html`. |
469
470
  | `memory` | What the agents remember - size, listing, one node, or a whole page. |
470
471
  | `check` | Validates the project and every file it names, and asks the models. |
@@ -539,6 +540,88 @@ an accident.
539
540
  | `4` | no usable credential |
540
541
  | `5` | sandbox unavailable |
541
542
 
543
+ ## The meta agent - the agent that works on the project
544
+
545
+ `zen run` runs the agents your project describes. `zen meta` runs an agent _over_
546
+ the project: a coding agent rooted at the project directory, which reads
547
+ `SPECIFICATION.md`, changes `agents.yaml`, the prompts and the skills, and runs
548
+ whatever commands it needs to check itself - on the keys already in your
549
+ keyring, and not on anybody's subscription. It is the loop your editor's chat
550
+ panel drives, as a command you can put in a script.
551
+
552
+ ```sh
553
+ zen meta run "what does this project do?"
554
+ zen meta run acme "review the last commit"
555
+ zen meta prompts # which /<name> prompts this project has
556
+ zen meta run /project-review # .github/prompts/project-review.prompt.md
557
+ zen meta run acme /spec-sync-project agents/triage.md
558
+ git diff | zen meta run "what broke?" --allow-tool read
559
+ zen meta run --dry-run "hello" # what would run, secrets masked
560
+ ```
561
+
562
+ Every prompt goes through `run`, whether you typed it or it is a file: the
563
+ alternative was a verb that appeared only sometimes, and a bare word after
564
+ `zen meta` that was a subcommand on Tuesdays and a question on Wednesdays.
565
+
566
+ It uses every tool without asking. The prompts it runs were written for an
567
+ editor, where reading files, writing them and running commands is the whole
568
+ point, and a `-p` run has nobody sitting there to answer the question - so the
569
+ alternative is not a safer run but a stalled one. `--allow-tool read`,
570
+ `--allow-tool 'shell(git:*)'` narrow it to what a particular run needs, and
571
+ `--ask` puts the question back. It is your own checkout and your own key either
572
+ way, so treat it as you would `git push`: fine on a project you own, worth
573
+ narrowing on one you do not.
574
+
575
+ The answer goes to stdout and the progress to stderr, like everything else here,
576
+ so `zen meta run "…" > out.md` keeps the answer alone.
577
+
578
+ Progress is a small window that rewrites itself rather than a transcript
579
+ scrolling past, because only the last few steps say what it is doing now. The
580
+ whole of it - the prompt, every tool call, the answer - is written as it happens
581
+ to `.tmp/logs/meta.<when>.log`, whose path is printed before the run starts, so
582
+ `tail -f` in another terminal follows a run already going. The answer alone is
583
+ kept beside it as `.tmp/logs/meta.<when>.md`, named again once it has printed -
584
+ a terminal turns that into something you click.
585
+
586
+ `zen meta run /<name>` is the part an editor cannot do for you from a script.
587
+ The prompts under `.github/prompts/` - `/spec-sync-project`, `/project-review` -
588
+ are what a chat panel offers as slash commands, and nothing outside an editor
589
+ reads them. zen reads one, drops the frontmatter and sends the body - so the
590
+ same prompt runs from the editor, from a terminal and from CI.
591
+
592
+ A terminal has no menu dropping down as you type a slash, so
593
+ `zen meta prompts` lists what this project holds, each with its description.
594
+ `zen meta run` with nothing to say asks the same question interactively.
595
+
596
+ ### Which model it runs on
597
+
598
+ ```sh
599
+ zen meta model # the whole chain, with the winner marked
600
+ zen meta model vertex/gemini-3.8-flash # for every project on this machine
601
+ zen meta model --local openai/gpt-5.6-sol # for this project only, via its .env
602
+ zen meta model --pick # choose from providers you hold a key for
603
+ ```
604
+
605
+ Highest first: `--model`, `ZENERA_META_MODEL` in the shell, the same variable in
606
+ the project's `.env`, `zen meta model`, then the project's `agents.yaml`
607
+ `model:`. When none of them answers, a key does: the best model zen knows of
608
+ for the first provider you hold one for, named on stderr as it starts, so a
609
+ fresh machine needs a key and nothing else.
610
+ The store is separate from `agents.yaml` on purpose - the model a coding agent
611
+ runs on is a personal choice about a tool, like a key, where `agents.yaml` is a
612
+ committed decision about the project's own agents.
613
+
614
+ Not every model works. The meta agent declares its tools in a form only the
615
+ reasoning APIs accept, so use a reasoning model - `gpt-5.6-sol`,
616
+ `claude-opus-5`, `gemini-3.8-flash`. Those three are also what it falls back
617
+ to, and what `zen meta model --pick` offers.
618
+
619
+ Setting one asks it a one-word question before storing it, so a model that will
620
+ not answer is refused with the provider's own words rather than at the start of
621
+ the next run. A typed prefix that names no provider - `vertes/...` - is caught
622
+ before that, because it would otherwise be sent whole to a vendor you never
623
+ named. `--force` stores a ref without asking.
624
+
542
625
  ## Credentials
543
626
 
544
627
  One keyring serves every provider, and a key goes in the same way whatever it
package/dist/banner.d.ts CHANGED
@@ -1,3 +1,19 @@
1
+ /** One per sub-brand, so two of them are told apart before the word is read. */
2
+ declare const ACCENTS: {
3
+ readonly orange: {
4
+ readonly face: "\u001B[1;38;5;208m";
5
+ readonly shade: "\u001B[0;38;5;130m";
6
+ };
7
+ readonly cyan: {
8
+ readonly face: "\u001B[1;38;5;51m";
9
+ readonly shade: "\u001B[0;38;5;37m";
10
+ };
11
+ readonly mint: {
12
+ readonly face: "\u001B[1;38;5;121m";
13
+ readonly shade: "\u001B[0;38;5;78m";
14
+ };
15
+ };
16
+ export type Hue = keyof typeof ACCENTS;
1
17
  export interface BannerText {
2
18
  /** drawn white */
3
19
  head: string;
@@ -5,6 +21,8 @@ export interface BannerText {
5
21
  accent: string;
6
22
  /** the line underneath, dim */
7
23
  subtitle: string;
24
+ /** which neon to use. Default: orange, the `zen` mark. */
25
+ hue?: Hue;
8
26
  }
9
27
  export interface PrintBannerOptions {
10
28
  /** Render the block face even when the terminal is narrower than it. */
@@ -14,4 +32,5 @@ export declare const NEO_BANNER: BannerText;
14
32
  export declare function bannerLines(text: BannerText, columns?: number): string[];
15
33
  /** Narration, and only for someone watching. */
16
34
  export declare function printBanner(text: BannerText, options?: PrintBannerOptions): void;
35
+ export {};
17
36
  //# sourceMappingURL=banner.d.ts.map
package/dist/banner.js CHANGED
@@ -1,5 +1,4 @@
1
- import { styleText } from 'node:util';
2
- import { dim, note } from "./term.js";
1
+ import { dim, note, styled } from "./term.js";
3
2
  // ---------------------------------------------------------------------------
4
3
  // The banner
5
4
  //
@@ -65,11 +64,14 @@ function big(word) {
65
64
  }
66
65
  /** A wordmark rather than a sentence, so the letters are set apart. */
67
66
  const spaced = (s) => [...s.toUpperCase()].join(' ');
68
- /** Whether anything should be coloured at all — `styleText`'s own answer. */
69
- const styling = () => styleText('dim', '.') !== '.';
70
67
  const RESET = '\u001b[0m';
71
68
  const HEAD = { face: '\u001b[1;38;5;231m', shade: '\u001b[0;38;5;244m' };
72
- const ACCENT = { face: '\u001b[1;38;5;208m', shade: '\u001b[0;38;5;130m' };
69
+ /** One per sub-brand, so two of them are told apart before the word is read. */
70
+ const ACCENTS = {
71
+ orange: { face: '\u001b[1;38;5;208m', shade: '\u001b[0;38;5;130m' },
72
+ cyan: { face: '\u001b[1;38;5;51m', shade: '\u001b[0;38;5;37m' },
73
+ mint: { face: '\u001b[1;38;5;121m', shade: '\u001b[0;38;5;78m' },
74
+ };
73
75
  /** Runs of stroke and runs of bevel, alternating; spaces stay unstyled. */
74
76
  const RUNS = /[█▀▄]+|[^█▀▄ ]+/g;
75
77
  const isStroke = (run) => /[█▀▄]/.test(run);
@@ -77,11 +79,11 @@ const isStroke = (run) => /[█▀▄]/.test(run);
77
79
  * Two tones per word — without the darker bevel the letter is a flat outline
78
80
  * and the shadow reads as part of the stroke.
79
81
  */
80
- const paint = (text, tone) => styling()
82
+ const paint = (text, tone) => styled()
81
83
  ? text.replace(RUNS, (run) => `${isStroke(run) ? tone.face : tone.shade}${run}${RESET}`)
82
84
  : text;
83
85
  /** The wordmark has no bevel to shade, so it is all face. */
84
- const flat = (text, tone) => styling() ? `${tone.face}${text}${RESET}` : text;
86
+ const flat = (text, tone) => styled() ? `${tone.face}${text}${RESET}` : text;
85
87
  export const NEO_BANNER = {
86
88
  head: 'Zenera',
87
89
  accent: 'Neo',
@@ -92,17 +94,18 @@ export function bannerLines(text, columns = process.stderr.columns || 80) {
92
94
  // Trimmed: the widest accent row is the banner's right edge, and a pad left
93
95
  // inside a styled string cannot be trimmed away later.
94
96
  const accent = big(text.accent).map((row) => row.trimEnd());
97
+ const tone = ACCENTS[text.hue ?? 'orange'];
95
98
  const margin = ' '.repeat(INDENT);
96
99
  const width = INDENT + head[0].length + GAP + Math.max(...accent.map((row) => row.length));
97
100
  if (width > columns) {
98
101
  return [
99
- `${margin}${flat(text.head.toUpperCase(), HEAD)} ${flat(text.accent.toUpperCase(), ACCENT)}`,
102
+ `${margin}${flat(text.head.toUpperCase(), HEAD)} ${flat(text.accent.toUpperCase(), tone)}`,
100
103
  `${margin}${dim(spaced(text.subtitle))}`,
101
104
  ];
102
105
  }
103
106
  const lines = [];
104
107
  for (let r = 0; r < HEIGHT; r++) {
105
- lines.push(`${margin}${paint(head[r], HEAD)}${' '.repeat(GAP)}${paint(accent[r], ACCENT)}`);
108
+ lines.push(`${margin}${paint(head[r], HEAD)}${' '.repeat(GAP)}${paint(accent[r], tone)}`);
106
109
  }
107
110
  // Rules on both sides, centred under the art: the subtitle is a caption,
108
111
  // and without them a short line of spaced capitals floats.
package/dist/command.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { BannerText } from './banner.ts';
1
2
  export interface Context {
2
3
  readonly args: readonly string[];
3
4
  readonly json: boolean;
@@ -9,6 +10,8 @@ export interface Command {
9
10
  readonly usage: string;
10
11
  /** lines printed under the usage line by `zen help <command>` */
11
12
  readonly details?: readonly string[];
13
+ /** printed instead of the `zen` banner, so the sub-brand shows through */
14
+ readonly banner?: BannerText;
12
15
  /**
13
16
  * Answers `--help` itself, for a command whose arguments select a page the
14
17
  * frame cannot see. Without it the frame prints this command's own page.
@@ -5,6 +5,7 @@ import { inspect } from "./inspect.js";
5
5
  import { key } from "./key.js";
6
6
  import { list } from "./list.js";
7
7
  import { memory } from "./memory.js";
8
+ import { meta } from "./meta.js";
8
9
  import { models } from "./models.js";
9
10
  import { open } from "./open.js";
10
11
  import { run } from "./run.js";
@@ -19,6 +20,7 @@ export const COMMANDS = {
19
20
  init,
20
21
  list,
21
22
  run,
23
+ meta,
22
24
  open,
23
25
  key,
24
26
  models,
@@ -37,6 +39,7 @@ export const ALIASES = {
37
39
  model: 'models',
38
40
  validate: 'check',
39
41
  doctor: 'check',
42
+ copilot: 'meta',
40
43
  report: 'inspect',
41
44
  mem: 'memory',
42
45
  edit: 'open',
@@ -49,7 +52,7 @@ export const EXTERNAL = {
49
52
  summary: 'A mock API from an openapi/swagger document.',
50
53
  usage: 'zen faker <serve|build|cache> [spec...]',
51
54
  install: 'npm i -g @zenera/faker',
52
- banner: { head: 'Zenera', accent: 'Faker', subtitle: 'Mock API Server' },
55
+ banner: { head: 'Zenera', accent: 'Faker', subtitle: 'Mock API Server', hue: 'mint' },
53
56
  },
54
57
  rag: {
55
58
  package: '@zenera/rag',
@@ -140,7 +140,7 @@ export const init = {
140
140
  '',
141
141
  'SPECIFICATION.md states what the project is for and what it may do,',
142
142
  'and describes the one that was just written. Say what you are building',
143
- 'there, then run /sync-with-spec in your editor to make the rest match.',
143
+ 'there, then run /spec-sync-project in your editor to make the rest match.',
144
144
  '',
145
145
  'The default agent gets the file tools and a sandboxed shell, plus',
146
146
  '`exa:*` when the keyring holds an Exa key. It keeps a memory of its',
@@ -5,31 +5,39 @@ import { readFile, writeFile } from 'node:fs/promises';
5
5
  import { createServer } from 'node:http';
6
6
  import { extname, join, normalize, resolve, sep } from 'node:path';
7
7
  import { parse } from "../args.js";
8
+ import { sessionIds } from "../projects.js";
8
9
  import { project as resolveProject } from "../resolve.js";
9
- import { display, newestRun, newestSession, requireSession, runPaths, sessionPaths, } from "../session.js";
10
- import { bold, cyan, dim, invalidError, json, note, write } from "../term.js";
10
+ import { display, listRuns, listSessions, newestRun, requireSession, runPaths, sessionPaths, } from "../session.js";
11
+ import { ago, bold, choose, cyan, dim, invalidError, isInteractive, json, note, write, } from "../term.js";
11
12
  const USAGE = 'zen inspect [run] [--session <id>] [--open] [--rebuild] [--serve [port]]';
12
13
  export const inspect = {
13
14
  summary: "Open or rebuild a run's report.html.",
14
15
  usage: USAGE,
15
16
  details: [
16
- 'With no arguments: the newest run of the newest session.',
17
+ 'With no arguments: asks which session and run, or takes the newest of',
18
+ 'each when there is nothing to ask on.',
17
19
  '--serve starts a local server, which the report needs for its assets.',
20
+ '--memory <dir> reads that memory instead of the project’s, for a run',
21
+ 'that was given `zen run --memory`.',
18
22
  ],
19
23
  run: async (ctx) => {
20
24
  const { values, positionals } = parse(ctx.args, {
21
25
  project: { type: 'string' },
22
26
  session: { type: 'string' },
27
+ memory: { type: 'string' },
23
28
  open: { type: 'boolean' },
24
29
  rebuild: { type: 'boolean' },
25
30
  serve: { type: 'string' },
26
31
  }, USAGE);
27
32
  const found = await resolveProject({ cwd: ctx.cwd, project: values.project });
28
33
  const dir = found.dir;
29
- const session = pickSession(dir, values.session);
30
- const run = pickRun(session, positionals[0]);
34
+ // Asking is only possible at a terminal, and only honest when the
35
+ // answer is not being parsed by something.
36
+ const asking = isInteractive() && !ctx.json;
37
+ const session = await pickSession(dir, values.session, positionals[0], asking);
38
+ const run = await pickRun(session, positionals[0], asking);
31
39
  if (values.rebuild || !existsSync(run.report)) {
32
- await rebuild(session, run, await memory(dir));
40
+ await rebuild(session, run, await memory(dir, values.memory, ctx.cwd));
33
41
  }
34
42
  if (ctx.json) {
35
43
  json({ session: session.id, run: run.id, report: run.report });
@@ -52,17 +60,73 @@ export const inspect = {
52
60
  // ---------------------------------------------------------------------------
53
61
  // Choosing what to show
54
62
  // ---------------------------------------------------------------------------
55
- function pickSession(dir, asked) {
63
+ /**
64
+ * Which session to read. With nothing named and a terminal to ask on, the
65
+ * person picks: "the newest" is a guess about which of a dozen runs they meant,
66
+ * and a wrong guess looks the same as a broken command.
67
+ */
68
+ async function pickSession(dir, asked, run, asking) {
56
69
  if (asked) {
57
70
  return requireSession(dir, asked);
58
71
  }
59
- const newest = newestSession(dir);
60
- if (!newest) {
72
+ if (sessionIds(dir).length === 0) {
61
73
  throw invalidError('nothing has been run here yet', 'start one: zen run');
62
74
  }
63
- return sessionPaths(dir, newest);
75
+ // A named run answers the question itself: it is in whichever session holds it.
76
+ if (!asking || run) {
77
+ return newestWorked(dir, run);
78
+ }
79
+ const worked = (await listSessions(dir)).filter((s) => s.runs > 0);
80
+ if (worked.length === 0) {
81
+ throw invalidError('no runs yet — every session is empty', 'start one: zen run');
82
+ }
83
+ return await choose('Which session?', worked.map((s) => ({
84
+ label: s.id,
85
+ detail: [
86
+ s.title,
87
+ `${s.runs} run${s.runs === 1 ? '' : 's'}`,
88
+ ago(s.lastRunAt ?? s.createdAt),
89
+ s.busy ? 'running' : '',
90
+ ]
91
+ .filter(Boolean)
92
+ .join(' '),
93
+ value: sessionPaths(dir, s.id),
94
+ })));
64
95
  }
65
- function pickRun(session, asked) {
96
+ /**
97
+ * The newest session that has a report to show — not simply the newest. A
98
+ * session exists before its first run and outlives one that never recorded
99
+ * anything, so the newest is routinely empty and picking it blindly answers
100
+ * "has no runs" about a project full of them.
101
+ */
102
+ function newestWorked(dir, run) {
103
+ for (const id of sessionIds(dir).reverse()) {
104
+ const session = sessionPaths(dir, id);
105
+ if (run ? existsSync(runPaths(session, run).dir) : newestRun(session)) {
106
+ return session;
107
+ }
108
+ }
109
+ throw invalidError(run ? `no run ${run} in any session` : 'no runs yet — every session is empty', run ? 'see: zen list --sessions' : 'start one: zen run');
110
+ }
111
+ async function pickRun(session, asked, asking) {
112
+ if (!asked && asking) {
113
+ const runs = await listRuns(session);
114
+ if (runs.length === 0) {
115
+ throw invalidError(`session ${session.id} has no runs`);
116
+ }
117
+ // One run is not a question.
118
+ return await choose(`Which run of ${session.id}?`, runs.map((r) => ({
119
+ label: r.id,
120
+ detail: [
121
+ r.agent,
122
+ r.error ? 'failed' : r.stopReason,
123
+ ago(r.finishedAt ?? r.startedAt),
124
+ ]
125
+ .filter(Boolean)
126
+ .join(' '),
127
+ value: runPaths(session, r.id),
128
+ })));
129
+ }
66
130
  const id = asked ?? newestRun(session);
67
131
  if (!id) {
68
132
  throw invalidError(`session ${session.id} has no runs`);
@@ -98,9 +162,9 @@ async function rebuild(session, run, store) {
98
162
  * has the shape of what the run recalled but not a word of it; a run that
99
163
  * never touched memory pays nothing, because the report asks for no node.
100
164
  */
101
- async function memory(dir) {
165
+ async function memory(dir, override, cwd) {
102
166
  const { config } = readProjectConfig(dir);
103
- const at = memoryDir(dir, config);
167
+ const at = memoryDir(dir, config, override && resolve(cwd, override));
104
168
  if (!at || !existsSync(join(at, 'manifest.json'))) {
105
169
  return undefined;
106
170
  }
@@ -454,10 +454,57 @@ const env = async (ctx, args) => {
454
454
  function shellQuote(value) {
455
455
  return `'${value.replaceAll("'", `'\\''`)}'`;
456
456
  }
457
+ /**
458
+ * One credential on stdout, alone, for a tool that wants a fresh one before
459
+ * every request rather than an export that goes stale — `zen meta` hands this
460
+ * to copilot as `COPILOT_PROVIDER_API_KEY_COMMAND`.
461
+ *
462
+ * A stored secret is simply printed. A service-account file is exchanged for
463
+ * an OAuth access token, which is the part worth having a command for: those
464
+ * last an hour, and a session does not.
465
+ */
466
+ const token = async (ctx, args) => {
467
+ const { positionals } = parse(args, {}, 'zen key token <provider>[/name]');
468
+ const { provider, name } = parseRef(positionals[0] ?? 'vertex');
469
+ const store = await KeyStore.open();
470
+ const entry = name ? store.find(provider, name) : store.active(provider);
471
+ if (!entry) {
472
+ throw credentialError(`no ${provider} key`, `add one: zen key add ${provider}`);
473
+ }
474
+ if (entry.holds === 'secret') {
475
+ write(store.reveal(entry));
476
+ return;
477
+ }
478
+ const { GoogleAuth } = await import('google-auth-library');
479
+ const auth = new GoogleAuth({
480
+ keyFile: store.fileOf(entry),
481
+ scopes: ['https://www.googleapis.com/auth/cloud-platform'],
482
+ });
483
+ const access = await (await auth.getClient()).getAccessToken();
484
+ if (!access.token) {
485
+ throw credentialError(`${keyId(entry)} minted no token`, 'check the service account is still enabled');
486
+ }
487
+ if (ctx.json) {
488
+ json({ key: keyId(entry), token: access.token });
489
+ return;
490
+ }
491
+ write(access.token);
492
+ };
457
493
  // ---------------------------------------------------------------------------
458
494
  // Dispatch
459
495
  // ---------------------------------------------------------------------------
460
- const SUBS = { ls, list: ls, add, use, check, rm, remove: rm, show, env };
496
+ const SUBS = {
497
+ ls,
498
+ list: ls,
499
+ add,
500
+ use,
501
+ check,
502
+ rm,
503
+ remove: rm,
504
+ show,
505
+ env,
506
+ token,
507
+ };
461
508
  export const key = {
462
509
  summary: 'The credential store: add, choose, verify and export API keys.',
463
510
  usage: USAGE,
@@ -480,6 +527,7 @@ export const key = {
480
527
  ' zen key rm <provider>/<name> Forget one.',
481
528
  ' zen key show <ref> [--reveal] Masked by default.',
482
529
  ' zen key env [provider …] Shell exports, for other tools.',
530
+ ' zen key token [ref] One credential on stdout, minted now.',
483
531
  ],
484
532
  run: async (ctx) => {
485
533
  const [name, ...rest] = ctx.args;
@@ -2,7 +2,7 @@ import { ALL_AGENTS, buildMemoryReport, hostPath, memoryDir, MemoryIndex, Memory
2
2
  import { spawn } from 'node:child_process';
3
3
  import { existsSync } from 'node:fs';
4
4
  import { writeFile } from 'node:fs/promises';
5
- import { isAbsolute, join, resolve } from 'node:path';
5
+ import { basename, isAbsolute, join, resolve } from 'node:path';
6
6
  import { parse } from "../args.js";
7
7
  import { project as resolveProject } from "../resolve.js";
8
8
  import { ago, bold, bytes, confirm, count, cyan, dim, green, isInteractive, json, note, table, usageError, write, writeAll, yellow, } from "../term.js";
@@ -39,6 +39,7 @@ export const memory = {
39
39
  ' forget <id...> Remove nodes, their vectors and their files.',
40
40
  '',
41
41
  ' --project <name|dir> Which project. Defaults to the one you are in.',
42
+ ' --dir <dir> Read this memory directory instead of the project’s.',
42
43
  ' --kind <name> Only this kind of node.',
43
44
  ' --audience <name> Only nodes committed under this label.',
44
45
  ' --files Only nodes that remember a file.',
@@ -59,6 +60,7 @@ export const memory = {
59
60
  run: async (ctx) => {
60
61
  const { values, positionals } = parse(ctx.args, {
61
62
  project: { type: 'string' },
63
+ dir: { type: 'string' },
62
64
  kind: { type: 'string' },
63
65
  audience: { type: 'string' },
64
66
  files: { type: 'boolean' },
@@ -73,7 +75,7 @@ export const memory = {
73
75
  throw usageError(`unknown subcommand: ${what}`, USAGE);
74
76
  }
75
77
  const rest = positionals.slice(1);
76
- const opened = await open(ctx.cwd, values.project);
78
+ const opened = await open(ctx.cwd, values.project, values.dir);
77
79
  try {
78
80
  switch (what) {
79
81
  case 'stats':
@@ -98,8 +100,19 @@ export const memory = {
98
100
  * project would resolve models and read every prompt file to inspect a graph
99
101
  * that needs none of it — and would fail on a project whose credentials are
100
102
  * missing, which is exactly when someone is debugging.
103
+ *
104
+ * A named directory is opened as it stands, project or no project: a graph
105
+ * copied out of a running session is the one most worth looking at, and it
106
+ * should not have to be given an `agents.yaml` first.
101
107
  */
102
- async function open(cwd, want) {
108
+ async function open(cwd, want, at) {
109
+ if (at) {
110
+ const dir = resolve(cwd, at);
111
+ if (!existsSync(join(dir, 'manifest.json'))) {
112
+ throw usageError(`${dir} is not a memory`, 'no manifest.json in it');
113
+ }
114
+ return { store: await MemoryStore.open(dir), dir, project: basename(dir) };
115
+ }
103
116
  const project = await resolveProject({ cwd, project: want });
104
117
  const { config } = readProjectConfig(project.dir);
105
118
  const dir = memoryDir(project.dir, config);
@@ -0,0 +1,3 @@
1
+ import type { Command } from '../command.ts';
2
+ export declare const meta: Command;
3
+ //# sourceMappingURL=meta.d.ts.map