@zocomputer/cli 0.4.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.
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/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,26 +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
- update Install the latest standalone release
22
- --version Show the installed CLI version
23
-
24
- Run zo <command> --help for options; for example, zo machine list --help.
25
- Use --profile to keep separate sign-ins. New profiles use production.
26
- ZO_SYSTEM_API_ACCESS_TOKEN overrides saved credentials; ZO_WORKSPACE_ID selects a workspace.
27
- `;
28
12
  const localHelp = {
29
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",
30
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",
@@ -44,9 +28,13 @@ const localHelp = {
44
28
  };
45
29
  // Printed after login so a first-time caller, person or agent, knows the next
46
30
  // command and that one sign-in serves the CLI, HTTP and MCP alike.
47
- const nextSteps = "Calls act as the identity shown for each workspace, with its current permissions.\n" +
48
- "Next: zo workspace get for a first call, then zo actions list to see what you can call.\n" +
49
- "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
+ }
50
38
  function identity(session, workspace) {
51
39
  const { type, id } = workspace.subject;
52
40
  if (type === "user" &&
@@ -57,9 +45,11 @@ function identity(session, workspace) {
57
45
  return `as service account ${safeText(id)}`;
58
46
  return `as ${type} ${safeText(id)}`;
59
47
  }
60
- 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);
61
51
  if (!args.length) {
62
- stdout.write(help);
52
+ stdout.write(rootHelp(style, (await import("./commands.js")).resourceNames()));
63
53
  return;
64
54
  }
65
55
  const path = args.slice(0, args.findIndex((arg) => arg.startsWith("--")) === -1
@@ -68,25 +58,25 @@ async function showHelp(args, stdout) {
68
58
  const key = path
69
59
  .slice(0, ["auth", "context", "actions"].includes(path[0] ?? "") ? 2 : 1)
70
60
  .join(" ");
71
- if (Object.hasOwn(localHelp, key)) {
72
- stdout.write(localHelp[key] ?? "");
73
- return;
74
- }
75
- 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:")));
76
65
  }
77
66
  export async function run(argv = process.argv.slice(2), runtime = {}) {
78
67
  const { args, options } = parseGlobals(argv);
79
68
  const stdout = runtime.stdout ?? process.stdout;
80
69
  const stderr = runtime.stderr ?? process.stderr;
70
+ const env = runtime.env ?? process.env;
81
71
  const output = {
82
72
  stdout,
83
73
  stderr,
84
74
  json: options.json,
85
75
  envelope: options.envelope,
76
+ env,
86
77
  };
87
- const env = runtime.env ?? process.env;
88
78
  if (options.help || !args.length) {
89
- await showHelp(args, stdout);
79
+ await showHelp(args, stdout, env);
90
80
  return;
91
81
  }
92
82
  if (args[0] === "--version") {
@@ -119,16 +109,37 @@ export async function run(argv = process.argv.slice(2), runtime = {}) {
119
109
  : controller.signal;
120
110
  try {
121
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);
122
120
  const profiles = runtime.profiles ?? new Profiles();
123
- const { writeJson } = await import("./output.js");
121
+ const { toJson, writeJson } = await import("./output.js");
124
122
  const writeLocalJson = (value) => {
125
123
  throwIfInterrupted(signal);
126
124
  writeJson(value, output);
127
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
+ };
128
139
  if (args[0] === "update") {
129
140
  commandOptions(args.slice(1), []);
130
141
  const { update } = await import("./update.js");
131
- writeLocalJson(await update({
142
+ writeLocal(await update({
132
143
  version: runtime.version,
133
144
  env,
134
145
  fetch: runtime.fetch,
@@ -150,20 +161,32 @@ export async function run(argv = process.argv.slice(2), runtime = {}) {
150
161
  fetch: runtime.fetch,
151
162
  signal,
152
163
  });
153
- 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({
154
170
  device: parsed.flags.has("device"),
155
171
  signal,
156
- announce: (prompt) => stderr.write(prompt.flow === "browser"
157
- ? `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`
158
- : `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`),
159
- });
160
- 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`);
161
182
  if (login.cleanupPending)
162
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");
163
184
  const session = await auth.session();
164
185
  // Always list the approved workspaces, so a sign-in someone else
165
186
  // approved shows up as workspaces or identities you don't recognize.
166
- 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`);
167
190
  const desired = options.workspace ?? env.ZO_WORKSPACE_ID ?? profile.workspaceId;
168
191
  let chosen;
169
192
  if (options.workspace !== undefined)
@@ -199,8 +222,8 @@ export async function run(argv = process.argv.slice(2), runtime = {}) {
199
222
  }
200
223
  if (!chosen) {
201
224
  stderr.write("Select an authorized workspace with zo context use <workspace-id>.\n" +
202
- nextSteps);
203
- writeLocalJson({
225
+ nextSteps(notice));
226
+ writeLocal({
204
227
  profile: profile.name,
205
228
  signed_in: true,
206
229
  workspace_id: null,
@@ -209,8 +232,9 @@ export async function run(argv = process.argv.slice(2), runtime = {}) {
209
232
  return;
210
233
  }
211
234
  await profiles.select(profile, chosen, signal);
212
- stderr.write(nextSteps);
213
- 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 });
214
238
  return;
215
239
  }
216
240
  const profile = await profiles.resolve(options.profile);
@@ -225,8 +249,8 @@ export async function run(argv = process.argv.slice(2), runtime = {}) {
225
249
  const { warning } = await auth.logout();
226
250
  throwIfInterrupted(signal);
227
251
  stderr.write(warning === undefined
228
- ? "Signed out; saved sign-ins revoked.\n"
229
- : `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`);
230
254
  return;
231
255
  }
232
256
  if (args[0] === "auth" && args[1] === "token") {
@@ -242,22 +266,39 @@ export async function run(argv = process.argv.slice(2), runtime = {}) {
242
266
  if ((args[0] === "auth" && args[1] === "status") ||
243
267
  (args[0] === "context" && args[1] === "show")) {
244
268
  commandOptions(args.slice(2), []);
245
- 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`);
246
272
  return;
247
273
  }
248
274
  if (args[0] === "context" && args[1] === "list") {
249
275
  commandOptions(args.slice(2), []);
250
- 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`);
251
291
  return;
252
292
  }
253
293
  if (args[0] === "context" && args[1] === "use") {
254
294
  if (args.length !== 3 || !args[2])
255
295
  throw new CliError("Usage: zo context use <workspace-id>");
256
- 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`);
257
298
  return;
258
299
  }
259
300
  if (local)
260
- throw new CliError("Unknown command. Run zo --help.");
301
+ throw (await import("./commands.js")).unknownCommand(args);
261
302
  const context = {
262
303
  workspaceId: selectedWorkspace(profile, options.workspace, env),
263
304
  apiOrigin: auth.binding.api,
@@ -302,15 +343,27 @@ export async function run(argv = process.argv.slice(2), runtime = {}) {
302
343
  const version = parsed.values.get("version");
303
344
  const parsedVersion = version === undefined ? undefined : parseVersion(version);
304
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
+ }
305
359
  writeJson(action
306
360
  ? describeAction(catalog, action, parsedVersion)
307
361
  : listActions(catalog), output);
308
362
  return;
309
363
  }
310
- const { matchBuiltinCommand, runBuiltin } = await import("./commands.js");
311
- const command = matchBuiltinCommand(args);
364
+ const { runBuiltin, unknownCommand } = await import("./commands.js");
312
365
  if (!command)
313
- throw new CliError("Unknown command. Run zo --help.");
366
+ throw unknownCommand(args);
314
367
  const result = await runBuiltin(context, command.action, command.args, runtime.stdin);
315
368
  (await import("./presentation.js")).writeBuiltinResult(command.action, result, { ...output, workspaceId: context.workspaceId });
316
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;
@@ -0,0 +1,42 @@
1
+ /** Edits that turn one word into another: insert, delete, replace, or swap two neighbors. */
2
+ function editDistance(left, right) {
3
+ let previous = [];
4
+ let current = Array.from({ length: right.length + 1 }, (_, index) => index);
5
+ for (let row = 1; row <= left.length; row++) {
6
+ const next = [row];
7
+ for (let column = 1; column <= right.length; column++) {
8
+ const same = left[row - 1] === right[column - 1];
9
+ let cost = Math.min((current[column] ?? 0) + 1, (next[column - 1] ?? 0) + 1, (current[column - 1] ?? 0) + (same ? 0 : 1));
10
+ if (row > 1 &&
11
+ column > 1 &&
12
+ left[row - 1] === right[column - 2] &&
13
+ left[row - 2] === right[column - 1])
14
+ cost = Math.min(cost, (previous[column - 2] ?? 0) + 1);
15
+ next.push(cost);
16
+ }
17
+ previous = current;
18
+ current = next;
19
+ }
20
+ return current[right.length] ?? 0;
21
+ }
22
+ /**
23
+ * The candidate a mistyped word most likely meant: the nearest within one
24
+ * edit per three letters, else the shortest one the word begins.
25
+ */
26
+ export function closest(word, candidates) {
27
+ const typed = word.toLowerCase();
28
+ let best;
29
+ let bestDistance = Math.max(1, Math.floor(typed.length / 3)) + 1;
30
+ for (const candidate of candidates) {
31
+ const distance = editDistance(typed, candidate);
32
+ if (distance < bestDistance) {
33
+ best = candidate;
34
+ bestDistance = distance;
35
+ }
36
+ }
37
+ if (best !== undefined || typed.length < 3)
38
+ return best;
39
+ return candidates
40
+ .filter((candidate) => candidate.startsWith(typed))
41
+ .sort((left, right) => left.length - right.length)[0];
42
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zocomputer/cli",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "description": "Public Zo command line client",
6
6
  "bin": {
@@ -14,7 +14,7 @@
14
14
  "node": "^22.23.4 || ^24.22.1 || ^26.13.0"
15
15
  },
16
16
  "dependencies": {
17
- "@zocomputer/system-api": "0.3.0"
17
+ "@zocomputer/system-api": "0.3.1"
18
18
  },
19
19
  "publishConfig": {
20
20
  "access": "public",