@zocomputer/cli 0.3.0 → 0.4.1

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.
@@ -0,0 +1,26 @@
1
+ import { type JsonObject, type JsonValue } from "@zocomputer/system-api/json";
2
+ import type { Style } from "./style.ts";
3
+ /** Ends a table: it shows a few columns of each row. */
4
+ export declare const completeResultHint = "Use --json for the complete result.";
5
+ /** Ends a key/value view, which leaves nothing out. */
6
+ export declare const jsonHint = "Use --json for JSON.";
7
+ export declare function jsonObject(value: JsonValue | undefined): JsonObject | undefined;
8
+ export declare function isScalar(value: JsonValue | undefined): boolean;
9
+ /** Plain, or a small object of plain values such as `{type, id}`. */
10
+ export declare function fitsCell(value: JsonValue | undefined): boolean;
11
+ /** One value as escaped single-line text. */
12
+ export declare function cell(value: JsonValue | undefined): string;
13
+ /**
14
+ * Lines of an aligned table no wider than `width`: a header, then one line a
15
+ * row. Cells must already be escaped. A narrow terminal drops trailing columns
16
+ * and shortens the widest ones.
17
+ */
18
+ export declare function tableLines(headers: readonly string[], rows: readonly (readonly string[])[], width: number, style: Style): string[];
19
+ /**
20
+ * Lines of an aligned key/value view of one object, with nothing left out or
21
+ * shortened: a person may not be able to run the command again to see the
22
+ * rest. Nested objects, lists of objects and text with line breaks are
23
+ * indented under their key. Returns undefined for a shape this layout can't
24
+ * hold (lists of lists, mixed lists, deep nesting); the caller prints JSON.
25
+ */
26
+ export declare function detailLines(value: JsonObject, style: Style, depth?: number): string[] | undefined;
package/dist/render.js ADDED
@@ -0,0 +1,145 @@
1
+ import { dumpsJson, JsonNumber, } from "@zocomputer/system-api/json";
2
+ import { safeText } from "./errors.js";
3
+ /** Ends a table: it shows a few columns of each row. */
4
+ export const completeResultHint = "Use --json for the complete result.";
5
+ /** Ends a key/value view, which leaves nothing out. */
6
+ export const jsonHint = "Use --json for JSON.";
7
+ // Results nested deeper than this are easier to read as JSON.
8
+ const MAX_DEPTH = 4;
9
+ // A nested object with at most this many plain values fits one table cell.
10
+ const CELL_FIELDS = 4;
11
+ export function jsonObject(value) {
12
+ return value !== null &&
13
+ typeof value === "object" &&
14
+ !Array.isArray(value) &&
15
+ !(value instanceof JsonNumber)
16
+ ? value
17
+ : undefined;
18
+ }
19
+ export function isScalar(value) {
20
+ return (value === undefined ||
21
+ value === null ||
22
+ typeof value !== "object" ||
23
+ value instanceof JsonNumber);
24
+ }
25
+ /** Plain, or a small object of plain values such as `{type, id}`. */
26
+ export function fitsCell(value) {
27
+ if (isScalar(value))
28
+ return true;
29
+ const values = Object.values(jsonObject(value) ?? {});
30
+ return (values.length > 0 && values.length <= CELL_FIELDS && values.every(isScalar));
31
+ }
32
+ /** One value as escaped single-line text. */
33
+ export function cell(value) {
34
+ if (value === undefined || value === null)
35
+ return "-";
36
+ const nested = jsonObject(value);
37
+ if (nested && fitsCell(value))
38
+ return (Object.values(nested)
39
+ .filter((entry) => entry !== null)
40
+ .map(cell)
41
+ .join(" ") || "-");
42
+ return safeText(typeof value === "string"
43
+ ? value
44
+ : value instanceof JsonNumber
45
+ ? value.text
46
+ : dumpsJson(value));
47
+ }
48
+ const length = (text) => Array.from(text).length;
49
+ function clip(text, width) {
50
+ const characters = Array.from(text);
51
+ return characters.length <= width
52
+ ? text
53
+ : `${characters.slice(0, Math.max(1, width) - 1).join("")}…`;
54
+ }
55
+ /**
56
+ * Lines of an aligned table no wider than `width`: a header, then one line a
57
+ * row. Cells must already be escaped. A narrow terminal drops trailing columns
58
+ * and shortens the widest ones.
59
+ */
60
+ export function tableLines(headers, rows, width, style) {
61
+ const total = Math.max(20, Math.floor(width));
62
+ const visible = headers.slice(0, Math.max(1, Math.floor((total + 2) / 12)));
63
+ const room = total - (visible.length - 1) * 2;
64
+ const share = Math.floor(room / visible.length);
65
+ const natural = visible.map((header, index) => Math.max(length(header), ...rows.map((row) => length(row[index] ?? ""))));
66
+ const widths = natural.map((size) => Math.min(size, share));
67
+ // Columns narrower than their share leave room for the ones that need more.
68
+ let spare = room - widths.reduce((sum, size) => sum + size, 0);
69
+ natural.forEach((size, index) => {
70
+ const extra = Math.min(spare, size - (widths[index] ?? size));
71
+ widths[index] = (widths[index] ?? size) + extra;
72
+ spare -= extra;
73
+ });
74
+ const line = (row) => visible
75
+ .map((_, index) => clip(row[index] ?? "", widths[index] ?? share).padEnd(widths[index] ?? share))
76
+ .join(" ")
77
+ .trimEnd();
78
+ return [style.bold(line(visible)), ...rows.map(line)];
79
+ }
80
+ const oneLine = (value) => isScalar(value) && !(typeof value === "string" && value.includes("\n"));
81
+ /**
82
+ * What goes under a key instead of beside it: the lines of a nested object, a
83
+ * list of objects or text with line breaks. Undefined for a shape this layout
84
+ * can't hold.
85
+ */
86
+ function linesUnder(entry, indent, style, depth) {
87
+ if (typeof entry === "string")
88
+ // The bar is something a field never has, so a name with a line break
89
+ // in it cannot pass for one.
90
+ return entry
91
+ .replaceAll("\r\n", "\n")
92
+ .replace(/\n$/, "")
93
+ .split("\n")
94
+ .map((line) => `${indent} ${style.dim("│")} ${safeText(line.replaceAll("\t", " "))}`);
95
+ const nested = jsonObject(entry);
96
+ if (nested)
97
+ return detailLines(nested, style, depth + 1);
98
+ if (!Array.isArray(entry))
99
+ return undefined;
100
+ const lines = [];
101
+ for (const item of entry) {
102
+ const object = jsonObject(item);
103
+ const below = object && detailLines(object, style, depth + 2);
104
+ if (!below)
105
+ return undefined;
106
+ // Each object starts at a dash, one level in from the key.
107
+ const [first = `${indent} {}`, ...rest] = below;
108
+ lines.push(`${indent} - ${first.slice(indent.length + 4)}`, ...rest);
109
+ }
110
+ return lines;
111
+ }
112
+ /**
113
+ * Lines of an aligned key/value view of one object, with nothing left out or
114
+ * shortened: a person may not be able to run the command again to see the
115
+ * rest. Nested objects, lists of objects and text with line breaks are
116
+ * indented under their key. Returns undefined for a shape this layout can't
117
+ * hold (lists of lists, mixed lists, deep nesting); the caller prints JSON.
118
+ */
119
+ export function detailLines(value, style, depth = 0) {
120
+ if (depth > MAX_DEPTH)
121
+ return undefined;
122
+ const indent = " ".repeat(depth);
123
+ const entries = [];
124
+ for (const [name, entry] of Object.entries(value)) {
125
+ const key = safeText(name);
126
+ if (oneLine(entry))
127
+ entries.push({ key, beside: entry === "" ? '""' : cell(entry) });
128
+ else if (Array.isArray(entry) && entry.every(oneLine))
129
+ entries.push({
130
+ key,
131
+ beside: entry.length ? entry.map(cell).join(", ") : "[]",
132
+ });
133
+ else {
134
+ const below = linesUnder(entry, indent, style, depth);
135
+ if (!below)
136
+ return undefined;
137
+ entries.push(below.length ? { key, below } : { key, beside: "{}" });
138
+ }
139
+ }
140
+ // A very long key stands out of the column instead of widening it.
141
+ const width = Math.min(32, Math.max(0, ...entries.flatMap(({ key, below }) => (below ? [] : [length(key)]))));
142
+ return entries.flatMap(({ key, beside, below }) => below
143
+ ? [`${indent}${style.dim(key)}`, ...below]
144
+ : [`${indent}${style.dim(key.padEnd(width))} ${beside}`]);
145
+ }
package/dist/rpc.js CHANGED
@@ -154,7 +154,7 @@ export async function runRpc(context, options, loadedCatalog) {
154
154
  });
155
155
  if (options.preview)
156
156
  return { kind: "preview", workspaceId: context.workspaceId, requestJson };
157
- // Snapshot calls may run for 210 seconds; leave time to receive that result.
157
+ // A call may run for 210 seconds; leave time to receive its result.
158
158
  const timeout = AbortSignal.timeout(240_000);
159
159
  const signal = context.signal
160
160
  ? AbortSignal.any([context.signal, timeout])
package/dist/runner.d.ts CHANGED
@@ -7,7 +7,6 @@ export { parseGlobals } from "./argv.ts";
7
7
  export interface RunOptions extends OutputOptions {
8
8
  version?: string;
9
9
  profiles?: Profiles;
10
- env?: NodeJS.ProcessEnv;
11
10
  fetch?: InvocationContext["fetch"];
12
11
  stdin?: RpcOptions["stdin"];
13
12
  signal?: AbortSignal;
package/dist/runner.js CHANGED
@@ -5,25 +5,10 @@ import { AuthClient } from "./auth.js";
5
5
  import { commandOptions, parseGlobals } from "./argv.js";
6
6
  import { authorizedWorkspace, contextStatus, useContext } from "./context.js";
7
7
  import { CliError, record, safeText, throwIfInterrupted } from "./errors.js";
8
+ import { rootHelp } from "./help.js";
8
9
  import { Profiles, selectedWorkspace } from "./profiles.js";
10
+ import { spinner, styleFor, terminalWidth } from "./style.js";
9
11
  export { parseGlobals } from "./argv.js";
10
- const help = `Zo public command line client
11
-
12
- Usage: zo <command> [--profile <name>] [--workspace <id>] [--json]
13
-
14
- login / logout Sign in through your browser or revoke the sign-in
15
- auth status / token Inspect your identity or explicitly print an access token
16
- context list/use/show Select an authorized workspace
17
- actions list/describe Discover live action contracts
18
- rpc <action> Invoke any callable action from JSON
19
- <resource> <action> Invoke an installed built-in command
20
- completion <shell> Print bash, zsh or fish completion (offline)
21
- --version Show the installed CLI version
22
-
23
- Run zo <command> --help for options; for example, zo machine list --help.
24
- Use --profile to keep separate sign-ins. New profiles use production.
25
- ZO_SYSTEM_API_ACCESS_TOKEN overrides saved credentials; ZO_WORKSPACE_ID selects a workspace.
26
- `;
27
12
  const localHelp = {
28
13
  login: "Usage: zo login [--profile <name>] [--workspace <id>] [--device]\nOpen your browser to sign in and wait for approval.\nNew profiles use production. Browser consent approves workspaces and identities.\n--device prints a URL and code to approve on any device; login does the same when this machine can't open a browser.\nInternal testing: --environment staging explicitly binds a separate profile to staging.\n",
29
14
  logout: "Usage: zo logout [--profile <name>]\nRevoke the saved sign-in and pending replaced sign-ins, then remove them from this computer. If revocation fails, logout says why and removes them anyway.\n",
@@ -39,12 +24,17 @@ const localHelp = {
39
24
  "actions describe": "Usage: zo actions describe <action> [--version <integer>] [--catalog scoped|all] [--json]\nShow live input, output and query schemas. Omit --version to inspect every published version.\n",
40
25
  rpc: "Usage: zo rpc <action> [--input <json> | --input-file <path|->] [--version <integer>]\n --query <json> Declared list-query options; one page per call\n --idempotency-key <key> Envelope key where the action supports it\n --preview Print the request without invoking\n --envelope Print the complete success envelope\n --json Print exact result JSON\n --profile <name> --workspace <id>\nThe live catalog supplies the default version. RPC preserves supplied inputs. Invocations are never retried.\n",
41
26
  completion: "Usage: zo completion bash|zsh|fish\nPrint completion from installed metadata without credentials or network requests.\n",
27
+ update: "Usage: zo update [--json]\nDownload the latest standalone release, verify it and switch the zo command to it. Sign-ins are kept.\nDoes nothing when this version is already the latest. An npm installation upgrades with npm install --global @zocomputer/cli@latest.\n",
42
28
  };
43
29
  // Printed after login so a first-time caller, person or agent, knows the next
44
30
  // command and that one sign-in serves the CLI, HTTP and MCP alike.
45
- const nextSteps = "Calls act as the identity shown for each workspace, with its current permissions.\n" +
46
- "Next: zo workspace get for a first call, then zo actions list to see what you can call.\n" +
47
- "For HTTP or MCP, zo auth token --headers prints an Authorization header for this sign-in.\n";
31
+ const nextSteps = (style) => `\n${style.bold("Next")}\n` +
32
+ " zo workspace get Make a first call\n" +
33
+ " zo actions list See what you can call\n" +
34
+ " zo auth token --headers Print an Authorization header for HTTP or MCP\n";
35
+ function subjectText({ type, id }) {
36
+ return `${type.replaceAll("_", " ")} ${safeText(id)}`;
37
+ }
48
38
  function identity(session, workspace) {
49
39
  const { type, id } = workspace.subject;
50
40
  if (type === "user" &&
@@ -55,9 +45,11 @@ function identity(session, workspace) {
55
45
  return `as service account ${safeText(id)}`;
56
46
  return `as ${type} ${safeText(id)}`;
57
47
  }
58
- async function showHelp(args, stdout) {
48
+ async function showHelp(args, stdout, env) {
49
+ // Help is one text for people and agents; a terminal only adds emphasis.
50
+ const style = styleFor(stdout, env);
59
51
  if (!args.length) {
60
- stdout.write(help);
52
+ stdout.write(rootHelp(style, (await import("./commands.js")).resourceNames()));
61
53
  return;
62
54
  }
63
55
  const path = args.slice(0, args.findIndex((arg) => arg.startsWith("--")) === -1
@@ -66,25 +58,25 @@ async function showHelp(args, stdout) {
66
58
  const key = path
67
59
  .slice(0, ["auth", "context", "actions"].includes(path[0] ?? "") ? 2 : 1)
68
60
  .join(" ");
69
- if (Object.hasOwn(localHelp, key)) {
70
- stdout.write(localHelp[key] ?? "");
71
- return;
72
- }
73
- stdout.write((await import("./commands.js")).builtinHelp(path));
61
+ const text = Object.hasOwn(localHelp, key)
62
+ ? (localHelp[key] ?? "")
63
+ : (await import("./commands.js")).builtinHelp(path);
64
+ stdout.write(text.replace(/^Usage:/, style.bold("Usage:")));
74
65
  }
75
66
  export async function run(argv = process.argv.slice(2), runtime = {}) {
76
67
  const { args, options } = parseGlobals(argv);
77
68
  const stdout = runtime.stdout ?? process.stdout;
78
69
  const stderr = runtime.stderr ?? process.stderr;
70
+ const env = runtime.env ?? process.env;
79
71
  const output = {
80
72
  stdout,
81
73
  stderr,
82
74
  json: options.json,
83
75
  envelope: options.envelope,
76
+ env,
84
77
  };
85
- const env = runtime.env ?? process.env;
86
78
  if (options.help || !args.length) {
87
- await showHelp(args, stdout);
79
+ await showHelp(args, stdout, env);
88
80
  return;
89
81
  }
90
82
  if (args[0] === "--version") {
@@ -106,7 +98,7 @@ export async function run(argv = process.argv.slice(2), runtime = {}) {
106
98
  stdout.write((await import("./commands.js")).completionScript(args[1]));
107
99
  return;
108
100
  }
109
- const local = ["login", "logout", "auth", "context"].includes(args[0] ?? "");
101
+ const local = ["login", "logout", "auth", "context", "update"].includes(args[0] ?? "");
110
102
  if (options.envelope && local)
111
103
  throw new CliError("--envelope is only supported for action invocation.");
112
104
  const controller = new AbortController();
@@ -117,12 +109,45 @@ export async function run(argv = process.argv.slice(2), runtime = {}) {
117
109
  : controller.signal;
118
110
  try {
119
111
  throwIfInterrupted(signal);
112
+ // A built-in command that doesn't exist is reported before anything reads
113
+ // a profile, so the answer isn't "select a workspace".
114
+ const invocation = !local && args[0] !== "rpc" && args[0] !== "actions";
115
+ const command = invocation
116
+ ? (await import("./commands.js")).matchBuiltinCommand(args)
117
+ : undefined;
118
+ if (invocation && !command)
119
+ throw (await import("./commands.js")).unknownCommand(args);
120
120
  const profiles = runtime.profiles ?? new Profiles();
121
- const { writeJson } = await import("./output.js");
121
+ const { toJson, writeJson } = await import("./output.js");
122
122
  const writeLocalJson = (value) => {
123
123
  throwIfInterrupted(signal);
124
124
  writeJson(value, output);
125
125
  };
126
+ // A local command's result is JSON for a script and `text` for a person
127
+ // at a terminal. A command that already reported on stderr passes none.
128
+ const human = stdout.isTTY === true && !options.json;
129
+ const render = human ? await import("./render.js") : undefined;
130
+ const page = styleFor(stdout, env);
131
+ const notice = styleFor(stderr, env);
132
+ const writeLocal = (value, text) => {
133
+ throwIfInterrupted(signal);
134
+ if (!human)
135
+ writeJson(value, output);
136
+ else if (text)
137
+ stdout.write(text);
138
+ };
139
+ if (args[0] === "update") {
140
+ commandOptions(args.slice(1), []);
141
+ const { update } = await import("./update.js");
142
+ writeLocal(await update({
143
+ version: runtime.version,
144
+ env,
145
+ fetch: runtime.fetch,
146
+ signal,
147
+ stderr,
148
+ }));
149
+ return;
150
+ }
126
151
  if (args[0] === "login") {
127
152
  const parsed = commandOptions(args.slice(1), ["environment"], ["device"]);
128
153
  const requested = parsed.values.get("environment");
@@ -136,20 +161,32 @@ export async function run(argv = process.argv.slice(2), runtime = {}) {
136
161
  fetch: runtime.fetch,
137
162
  signal,
138
163
  });
139
- const login = await auth.login({
164
+ // The status lines are one wording for whoever reads stderr. A terminal
165
+ // adds color, the success mark and a spinner; anything else gets the
166
+ // same lines plain, with one static line for the wait.
167
+ let waiting = { stop() { } };
168
+ const login = await auth
169
+ .login({
140
170
  device: parsed.flags.has("device"),
141
171
  signal,
142
- announce: (prompt) => stderr.write(prompt.flow === "browser"
143
- ? `Opening your browser to sign in. If it doesn't open, visit this URL on this computer:\n\n ${prompt.url}\n\nWaiting for approval… (Ctrl-C to cancel)\n`
144
- : `To sign in, visit this URL on any device:\n\n ${prompt.url}\n\nCode: ${prompt.userCode}. Approve only if your browser shows the same code.\nWaiting for approval… (Ctrl-C to cancel)\n`),
145
- });
146
- stderr.write(`\nApproved in the browser. Sign-in saved for profile ${profile.name}.\n`);
172
+ announce: (prompt) => {
173
+ stderr.write(prompt.flow === "browser"
174
+ ? `Opening your browser to sign in.\nIf it doesn't open, visit this URL on this computer:\n ${notice.dim(prompt.url)}\n\n`
175
+ : `To sign in, visit this URL on any device:\n ${notice.dim(prompt.url)}\nCode: ${notice.bold(prompt.userCode)}. Approve only if your browser shows the same code.\n\n`);
176
+ waiting = spinner(stderr, "Waiting for approval… (Ctrl-C to cancel)", env);
177
+ },
178
+ })
179
+ // Approval, refusal, a timeout and Ctrl-C all end the wait.
180
+ .finally(() => waiting.stop());
181
+ stderr.write(`${notice.ok(`Approved. Sign-in saved for profile ${profile.name}.`)}\n`);
147
182
  if (login.cleanupPending)
148
183
  stderr.write("A previous sign-in could not be revoked. Cleanup remains saved for the next login; the current sign-in is active. You can revoke the previous sign-in in Authorized apps.\n");
149
184
  const session = await auth.session();
150
185
  // Always list the approved workspaces, so a sign-in someone else
151
186
  // approved shows up as workspaces or identities you don't recognize.
152
- session.workspaces.forEach((workspace, index) => stderr.write(`${index + 1}. ${safeText(workspace.name)} (${workspace.workspace_id}), ${identity(session, workspace)}\n`));
187
+ session.workspaces.forEach((workspace, index) => stderr.write(` ${index + 1}. ${safeText(workspace.name)} (${workspace.workspace_id}), ${identity(session, workspace)}\n`));
188
+ if (session.workspaces.length)
189
+ stderr.write(` ${notice.dim("Calls use each workspace's identity and its current permissions.")}\n`);
153
190
  const desired = options.workspace ?? env.ZO_WORKSPACE_ID ?? profile.workspaceId;
154
191
  let chosen;
155
192
  if (options.workspace !== undefined)
@@ -185,8 +222,8 @@ export async function run(argv = process.argv.slice(2), runtime = {}) {
185
222
  }
186
223
  if (!chosen) {
187
224
  stderr.write("Select an authorized workspace with zo context use <workspace-id>.\n" +
188
- nextSteps);
189
- writeLocalJson({
225
+ nextSteps(notice));
226
+ writeLocal({
190
227
  profile: profile.name,
191
228
  signed_in: true,
192
229
  workspace_id: null,
@@ -195,8 +232,9 @@ export async function run(argv = process.argv.slice(2), runtime = {}) {
195
232
  return;
196
233
  }
197
234
  await profiles.select(profile, chosen, signal);
198
- stderr.write(nextSteps);
199
- writeLocalJson({ profile: profile.name, workspace_id: chosen });
235
+ const name = session.workspaces.find((workspace) => workspace.workspace_id === chosen)?.name;
236
+ stderr.write(`Using workspace ${safeText(name ?? "")} (${chosen}).\n${nextSteps(notice)}`);
237
+ writeLocal({ profile: profile.name, workspace_id: chosen });
200
238
  return;
201
239
  }
202
240
  const profile = await profiles.resolve(options.profile);
@@ -211,8 +249,8 @@ export async function run(argv = process.argv.slice(2), runtime = {}) {
211
249
  const { warning } = await auth.logout();
212
250
  throwIfInterrupted(signal);
213
251
  stderr.write(warning === undefined
214
- ? "Signed out; saved sign-ins revoked.\n"
215
- : `Warning: ${warning}\nSigned out of this computer.\n`);
252
+ ? `${notice.ok("Signed out; saved sign-ins revoked.")}\n`
253
+ : `${notice.yellow("Warning:")} ${warning}\nSigned out of this computer.\n`);
216
254
  return;
217
255
  }
218
256
  if (args[0] === "auth" && args[1] === "token") {
@@ -228,22 +266,39 @@ export async function run(argv = process.argv.slice(2), runtime = {}) {
228
266
  if ((args[0] === "auth" && args[1] === "status") ||
229
267
  (args[0] === "context" && args[1] === "show")) {
230
268
  commandOptions(args.slice(2), []);
231
- writeLocalJson(await contextStatus(profile, auth, options.workspace, env));
269
+ const status = await contextStatus(profile, auth, options.workspace, env);
270
+ const view = render?.detailLines(render.jsonObject(toJson(status)) ?? {}, page);
271
+ writeLocal(status, view && `${view.join("\n")}\n`);
232
272
  return;
233
273
  }
234
274
  if (args[0] === "context" && args[1] === "list") {
235
275
  commandOptions(args.slice(2), []);
236
- writeLocalJson((await auth.session()).workspaces);
276
+ const { workspaces } = await auth.session();
277
+ const selected = options.workspace ?? env.ZO_WORKSPACE_ID ?? profile.workspaceId;
278
+ // The mark shares the first column: a column of its own is the first
279
+ // one a narrow terminal would give up.
280
+ const lines = render?.tableLines([" WORKSPACE_ID", "NAME", "IDENTITY"], workspaces.map((workspace) => [
281
+ `${workspace.workspace_id === selected ? "*" : " "} ${workspace.workspace_id}`,
282
+ safeText(workspace.name),
283
+ subjectText(workspace.subject),
284
+ ]), terminalWidth(stdout), page);
285
+ lines?.push(!workspaces.length
286
+ ? "No authorized workspaces. Run zo login to approve one."
287
+ : workspaces.some((workspace) => workspace.workspace_id === selected)
288
+ ? page.dim("* The selected workspace.")
289
+ : "None of these is selected. Choose one with zo context use <workspace-id>.");
290
+ writeLocal(workspaces, lines && `${lines.join("\n")}\n`);
237
291
  return;
238
292
  }
239
293
  if (args[0] === "context" && args[1] === "use") {
240
294
  if (args.length !== 3 || !args[2])
241
295
  throw new CliError("Usage: zo context use <workspace-id>");
242
- writeLocalJson(await useContext(profiles, profile, auth, args[2], signal));
296
+ const selected = await useContext(profiles, profile, auth, args[2], signal);
297
+ writeLocal(selected, `${page.ok(`Using workspace ${safeText(selected.name)} (${selected.workspace_id}) as ${subjectText(selected.subject)}.`)}\n`);
243
298
  return;
244
299
  }
245
300
  if (local)
246
- throw new CliError("Unknown command. Run zo --help.");
301
+ throw (await import("./commands.js")).unknownCommand(args);
247
302
  const context = {
248
303
  workspaceId: selectedWorkspace(profile, options.workspace, env),
249
304
  apiOrigin: auth.binding.api,
@@ -288,15 +343,27 @@ export async function run(argv = process.argv.slice(2), runtime = {}) {
288
343
  const version = parsed.values.get("version");
289
344
  const parsedVersion = version === undefined ? undefined : parseVersion(version);
290
345
  const { catalog } = await loadCatalog(context, { catalog: mode });
346
+ if (render && !action) {
347
+ const lines = render.tableLines(["ACTION", "DEFAULT", "VERSIONS", "DESCRIPTION"], catalog.operations.map((operation) => [
348
+ safeText(operation.action),
349
+ String(operation.defaultVersion ?? "-"),
350
+ operation.versions.map((entry) => entry.version).join(", "),
351
+ safeText((operation.versions.find((entry) => entry.version === operation.defaultVersion) ?? operation.versions.at(-1))?.description ?? ""),
352
+ ]), terminalWidth(stdout, 100), page);
353
+ if (!catalog.operations.length)
354
+ lines.push("No results.");
355
+ lines.push(page.dim(render.completeResultHint));
356
+ stdout.write(`${lines.join("\n")}\n`);
357
+ return;
358
+ }
291
359
  writeJson(action
292
360
  ? describeAction(catalog, action, parsedVersion)
293
361
  : listActions(catalog), output);
294
362
  return;
295
363
  }
296
- const { matchBuiltinCommand, runBuiltin } = await import("./commands.js");
297
- const command = matchBuiltinCommand(args);
364
+ const { runBuiltin, unknownCommand } = await import("./commands.js");
298
365
  if (!command)
299
- throw new CliError("Unknown command. Run zo --help.");
366
+ throw unknownCommand(args);
300
367
  const result = await runBuiltin(context, command.action, command.args, runtime.stdin);
301
368
  (await import("./presentation.js")).writeBuiltinResult(command.action, result, { ...output, workspaceId: context.workspaceId });
302
369
  }
package/dist/storage.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { CliError } from "./errors.ts";
2
2
  export declare function configDirectory(env?: NodeJS.ProcessEnv): string;
3
3
  export declare function privateDirectory(path: string): Promise<void>;
4
- export declare function readPrivate(path: string): Promise<unknown | undefined>;
4
+ export declare function readPrivate(path: string, maxBytes?: number): Promise<unknown | undefined>;
5
5
  export declare class PrivateWriteDurabilityError extends CliError {
6
6
  constructor();
7
7
  }
package/dist/storage.js CHANGED
@@ -48,7 +48,7 @@ async function checkedFile(path) {
48
48
  }
49
49
  return file;
50
50
  }
51
- export async function readPrivate(path) {
51
+ export async function readPrivate(path, maxBytes = 1_048_576) {
52
52
  let file;
53
53
  try {
54
54
  file = await checkedFile(path);
@@ -59,7 +59,7 @@ export async function readPrivate(path) {
59
59
  throw error;
60
60
  }
61
61
  try {
62
- if ((await file.stat()).size > 1_048_576)
62
+ if ((await file.stat()).size > maxBytes)
63
63
  throw new CliError("Local configuration is too large.", 2, "invalid_local_data");
64
64
  return JSON.parse(await file.readFile("utf8"));
65
65
  }
@@ -0,0 +1,40 @@
1
+ /** What the CLI reads from an output stream; injected test streams have only this. */
2
+ export interface Terminal {
3
+ write(text: string): unknown;
4
+ isTTY?: boolean;
5
+ columns?: number;
6
+ }
7
+ export interface Style {
8
+ /** The stream is a terminal, so it gets the layout written for people. */
9
+ readonly human: boolean;
10
+ /** Escape sequences may be written: a terminal with NO_COLOR unset. */
11
+ readonly color: boolean;
12
+ bold(text: string): string;
13
+ dim(text: string): string;
14
+ red(text: string): string;
15
+ green(text: string): string;
16
+ yellow(text: string): string;
17
+ cyan(text: string): string;
18
+ /** A line reporting success; a terminal marks it. */
19
+ ok(text: string): string;
20
+ }
21
+ /**
22
+ * Styles for one stream. Text passed in must already be safe to print: these
23
+ * functions only add escape sequences around it.
24
+ */
25
+ export declare function styleFor(stream: Pick<Terminal, "isTTY"> | undefined, env?: NodeJS.ProcessEnv): Style;
26
+ export declare function terminalWidth(stream: Pick<Terminal, "columns"> | undefined, fallback?: number): number;
27
+ /**
28
+ * Returns the cursor to the start of its line and blanks what was there (a
29
+ * spinner frame, the terminal's echoed ^C) without an escape sequence, so it
30
+ * also works where color is off.
31
+ */
32
+ export declare function blankLine(stream: Pick<Terminal, "columns">): string;
33
+ /**
34
+ * Says the CLI is waiting. A terminal gets one animated line, erased when
35
+ * stopped. Anything else gets the text once on a line that stays: a log or
36
+ * an agent reading it has nothing to redraw.
37
+ */
38
+ export declare function spinner(stream: Terminal, text: string, env?: NodeJS.ProcessEnv): {
39
+ stop(): void;
40
+ };
package/dist/style.js ADDED
@@ -0,0 +1,74 @@
1
+ import { styleText } from "node:util";
2
+ /**
3
+ * Styles for one stream. Text passed in must already be safe to print: these
4
+ * functions only add escape sequences around it.
5
+ */
6
+ export function styleFor(stream, env = process.env) {
7
+ const human = stream?.isTTY === true;
8
+ // https://no-color.org: any non-empty value turns color off.
9
+ const color = human && !env.NO_COLOR;
10
+ // styleText's own stream check throws on anything but a real stream and
11
+ // reads process.env, while the CLI is handed its streams and environment.
12
+ // The decision is made here and styleText only supplies the codes.
13
+ const paint = (format) => (text) => color ? styleText(format, text, { validateStream: false }) : text;
14
+ const green = paint("green");
15
+ return {
16
+ human,
17
+ color,
18
+ bold: paint("bold"),
19
+ dim: paint("dim"),
20
+ red: paint("red"),
21
+ green,
22
+ yellow: paint("yellow"),
23
+ cyan: paint("cyan"),
24
+ ok: (text) => (human ? `${green("✓")} ${text}` : text),
25
+ };
26
+ }
27
+ export function terminalWidth(stream, fallback = 80) {
28
+ const columns = stream?.columns;
29
+ return typeof columns === "number" && columns > 0 ? columns : fallback;
30
+ }
31
+ /**
32
+ * Returns the cursor to the start of its line and blanks what was there (a
33
+ * spinner frame, the terminal's echoed ^C) without an escape sequence, so it
34
+ * also works where color is off.
35
+ */
36
+ export function blankLine(stream) {
37
+ return `\r${" ".repeat(terminalWidth(stream) - 1)}\r`;
38
+ }
39
+ const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
40
+ /**
41
+ * Says the CLI is waiting. A terminal gets one animated line, erased when
42
+ * stopped. Anything else gets the text once on a line that stays: a log or
43
+ * an agent reading it has nothing to redraw.
44
+ */
45
+ export function spinner(stream, text, env = process.env) {
46
+ const style = styleFor(stream, env);
47
+ if (!style.human) {
48
+ stream.write(`${text}\n`);
49
+ return { stop() { } };
50
+ }
51
+ // A frame wider than the terminal wraps, and every redraw would then
52
+ // scroll it by a line.
53
+ const room = terminalWidth(stream) - 3;
54
+ const characters = Array.from(text);
55
+ const label = characters.length > room
56
+ ? `${characters.slice(0, Math.max(0, room - 1)).join("")}…`
57
+ : text;
58
+ let frame = 0;
59
+ const draw = () => stream.write(`\r${style.cyan(frames[frame++ % frames.length] ?? "")} ${label}`);
60
+ draw();
61
+ const timer = setInterval(draw, 80);
62
+ // The wait it decorates keeps the process alive; the animation must not.
63
+ timer.unref();
64
+ let stopped = false;
65
+ return {
66
+ stop() {
67
+ if (stopped)
68
+ return;
69
+ stopped = true;
70
+ clearInterval(timer);
71
+ stream.write(blankLine(stream));
72
+ },
73
+ };
74
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * The candidate a mistyped word most likely meant: the nearest within one
3
+ * edit per three letters, else the shortest one the word begins.
4
+ */
5
+ export declare function closest(word: string, candidates: readonly string[]): string | undefined;