bwx 0.4.1 → 0.5.0

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,57 @@
1
+ import type { Command } from "commander";
2
+ import { generateSecret, type GenerateOptions } from "../../bw/generate.ts";
3
+ import { CliError, ExitCode } from "../errors.ts";
4
+ import { emitData } from "../io.ts";
5
+ import { getGlobalOpts } from "../program.ts";
6
+
7
+ export function registerGenerate(program: Command): void {
8
+ program
9
+ .command("generate").alias("gen")
10
+ .description("Generate a password or passphrase (local, no vault access)")
11
+ .option("--length <n>", "Password length", parsePositiveInt("--length"))
12
+ .option("--no-uppercase", "Exclude uppercase letters")
13
+ .option("--no-lowercase", "Exclude lowercase letters")
14
+ .option("--no-numbers", "Exclude digits")
15
+ .option("-s, --special", "Include special characters")
16
+ .option("--ambiguous", "Allow ambiguous characters")
17
+ .option("--passphrase", "Generate a passphrase instead")
18
+ .option("--words <n>", "Words in the passphrase", parsePositiveInt("--words"))
19
+ .option("--separator <char>", "Passphrase word separator")
20
+ .option("--capitalize", "Capitalize passphrase words")
21
+ .option("--include-number", "Include a number in the passphrase")
22
+ .action(async function (this: Command, localOpts: GenerateOptions) {
23
+ const opts = getGlobalOpts(this);
24
+ emitData(await generateSecret(localOpts), opts);
25
+ });
26
+ }
27
+
28
+ /**
29
+ * Strips a password bwx generated out of the echoed item. The entire point of
30
+ * `--password-generate` is that the value goes to the vault without passing
31
+ * through the caller's terminal, transcript, or logs — echoing it back in the
32
+ * `--json` result would hand it straight back.
33
+ */
34
+ export function redactGeneratedPassword(
35
+ item: Record<string, unknown>,
36
+ generated: boolean,
37
+ ): Record<string, unknown> {
38
+ if (!generated) return item;
39
+
40
+ const login = item.login as Record<string, unknown> | null | undefined;
41
+ if (!login) return item;
42
+
43
+ return { ...item, login: { ...login, password: "***GENERATED***" } };
44
+ }
45
+
46
+ function parsePositiveInt(flag: string): (raw: string) => number {
47
+ return (raw: string) => {
48
+ const value = Number(raw);
49
+ if (!Number.isInteger(value) || value <= 0) {
50
+ throw new CliError(
51
+ `Invalid ${flag} "${raw}". Use a positive integer.`,
52
+ ExitCode.BadArgs,
53
+ );
54
+ }
55
+ return value;
56
+ };
57
+ }
@@ -2,6 +2,7 @@ import type { Command } from "commander";
2
2
  import { getGlobalOpts } from "../program.ts";
3
3
  import { emitData } from "../io.ts";
4
4
  import { readItemField } from "../../bw/fields.ts";
5
+ import { CliError } from "../errors.ts";
5
6
 
6
7
  export function registerGet(program: Command): void {
7
8
  program
@@ -18,11 +19,34 @@ export function registerGet(program: Command): void {
18
19
 
19
20
  program
20
21
  .command("field")
21
- .description("Get a custom field from a vault item")
22
- .argument("<item>", "Item name or ID")
22
+ .description("Get a custom field from a vault item (never a built-in)")
23
+ // Same order as `get`, which is the whole point of this command existing
24
+ // alongside it — the two used to take their arguments back to front.
23
25
  .argument("<name>", "Custom field name")
24
- .action(async function (this: Command, item: string, name: string) {
26
+ .argument("<item>", "Item name or ID")
27
+ .action(async function (this: Command, name: string, item: string) {
25
28
  const opts = getGlobalOpts(this);
26
- emitData(await readItemField(name, item, opts, { customOnly: true }), opts);
29
+ try {
30
+ emitData(
31
+ await readItemField(name, item, opts, { customOnly: true }),
32
+ opts,
33
+ );
34
+ } catch (err) {
35
+ throw withOrderHint(err);
36
+ }
27
37
  });
28
38
  }
39
+
40
+ /**
41
+ * `bwx field` took `<item> <name>` until 0.5.0. A caller using the old order
42
+ * fails somewhere unhelpful — a missing item, or a missing field on the wrong
43
+ * item — so say what changed rather than leaving them to guess.
44
+ */
45
+ function withOrderHint(err: unknown): unknown {
46
+ if (!(err instanceof CliError)) return err;
47
+
48
+ return new CliError(
49
+ `${err.message}\n\nNote: 'bwx field' takes <name> <item> as of 0.5.0 (it was <item> <name>).`,
50
+ err.exitCode,
51
+ );
52
+ }
@@ -1,9 +1,15 @@
1
1
  import type { Command } from "commander";
2
2
  import { getGlobalOpts } from "../program.ts";
3
3
  import { CliError, ExitCode } from "../errors.ts";
4
- import { filterItems, writeItems, type ItemListOptions } from "../items.ts";
4
+ import {
5
+ filterItems,
6
+ parseLimit,
7
+ writeItems,
8
+ type ItemListOptions,
9
+ } from "../items.ts";
5
10
  import { runBwOrThrow } from "../../bw/runner.ts";
6
11
  import { withSession } from "../../bw/session.ts";
12
+ import { resolveFolderFilter } from "../../bw/folders.ts";
7
13
 
8
14
  export function registerList(program: Command): void {
9
15
  program
@@ -11,28 +17,39 @@ export function registerList(program: Command): void {
11
17
  .alias("ls")
12
18
  .description("List all vault items")
13
19
  .argument("[type]", "Filter by type: logins|notes|cards|identities")
14
- .option("--folder <id>", "Filter by folder ID")
15
- .option("--limit <n>", "Limit results", parseInt)
20
+ .option("--type <type>", "Filter by type (same as the positional form)")
21
+ .option("--folder <name|id>", "Filter by folder name or ID ('none' for unfiled)")
22
+ .option("--limit <n>", "Limit results (0 for no limit)", parseLimit)
16
23
  .option("--full-items", "Emit complete item objects (includes secrets)")
17
24
  .action(async function (
18
25
  this: Command,
19
- type: string | undefined,
26
+ positionalType: string | undefined,
20
27
  localOpts: ItemListOptions & { folder?: string },
21
28
  ) {
22
29
  const opts = getGlobalOpts(this);
23
30
 
31
+ if (positionalType && localOpts.type && positionalType !== localOpts.type) {
32
+ throw new CliError(
33
+ `Conflicting types: "${positionalType}" and --type ${localOpts.type}`,
34
+ ExitCode.BadArgs,
35
+ );
36
+ }
37
+
24
38
  const args = ["list", "items"];
25
39
  if (localOpts.folder) {
26
- args.push("--folderid", localOpts.folder);
40
+ args.push("--folderid", await resolveFolderFilter(localOpts.folder, opts));
27
41
  }
28
42
 
29
43
  const json = await withSession(opts, () => runBwOrThrow(args));
30
- const items = filterItems(JSON.parse(json), { ...localOpts, type });
44
+ const filtered = filterItems(JSON.parse(json), {
45
+ ...localOpts,
46
+ type: positionalType ?? localOpts.type,
47
+ });
31
48
 
32
- if (items.length === 0) {
49
+ if (filtered.total === 0) {
33
50
  throw new CliError("No items found", ExitCode.NotFound);
34
51
  }
35
52
 
36
- writeItems(items, opts, localOpts);
53
+ writeItems(filtered, opts, localOpts);
37
54
  });
38
55
  }
@@ -1,6 +1,6 @@
1
1
  import type { Command } from "commander";
2
2
  import { constants } from "node:os";
3
- import { readItemField } from "../../bw/fields.ts";
3
+ import { readFields } from "../../bw/fields.ts";
4
4
  import { CliError, ExitCode } from "../errors.ts";
5
5
  import { collect } from "../input.ts";
6
6
  import { emitLog } from "../io.ts";
@@ -95,18 +95,23 @@ export function parseEnvSpec(raw: string): EnvSpec {
95
95
  return { name, field: ref.slice(0, colon), item: ref.slice(colon + 1) };
96
96
  }
97
97
 
98
- /** Resolves secrets one at a time: each read spawns `bw`, and unlocks must not race. */
98
+ /**
99
+ * Resolves every secret, paying one `bw` spawn per distinct *item* rather than
100
+ * per variable — pulling a username and password from the same item is a single
101
+ * vault read.
102
+ */
99
103
  async function resolveSecrets(
100
104
  specs: EnvSpec[],
101
105
  opts: GlobalOptions,
102
106
  ): Promise<Record<string, string>> {
103
- const secrets: Record<string, string> = {};
104
-
105
- for (const spec of specs) {
106
- secrets[spec.name] = await readItemField(spec.field, spec.item, opts);
107
- }
108
-
109
- return secrets;
107
+ const values = await readFields(
108
+ specs.map((spec) => ({ field: spec.field, item: spec.item })),
109
+ opts,
110
+ );
111
+
112
+ return Object.fromEntries(
113
+ specs.map((spec, index) => [spec.name, values[index]!]),
114
+ );
110
115
  }
111
116
 
112
117
  /**
@@ -1,18 +1,25 @@
1
1
  import type { Command } from "commander";
2
2
  import { getGlobalOpts } from "../program.ts";
3
3
  import { CliError, ExitCode } from "../errors.ts";
4
- import { filterItems, writeItems, type ItemListOptions } from "../items.ts";
4
+ import {
5
+ filterItems,
6
+ parseLimit,
7
+ writeItems,
8
+ type ItemListOptions,
9
+ } from "../items.ts";
5
10
  import { runBwOrThrow } from "../../bw/runner.ts";
6
11
  import { withSession } from "../../bw/session.ts";
12
+ import { resolveFolderFilter } from "../../bw/folders.ts";
7
13
 
8
14
  export function registerSearch(program: Command): void {
9
15
  program
10
- .command("search").alias("find")
16
+ .command("search")
17
+ .alias("find")
11
18
  .description("Search vault items")
12
19
  .argument("<query>", "Search query")
13
20
  .option("--type <type>", "Filter by type: login|note|card|identity")
14
- .option("--folder <id>", "Filter by folder ID")
15
- .option("--limit <n>", "Limit results", parseInt)
21
+ .option("--folder <name|id>", "Filter by folder name or ID ('none' for unfiled)")
22
+ .option("--limit <n>", "Limit results (0 for no limit)", parseLimit)
16
23
  .option("--full-items", "Emit complete item objects (includes secrets)")
17
24
  .action(async function (
18
25
  this: Command,
@@ -23,19 +30,16 @@ export function registerSearch(program: Command): void {
23
30
 
24
31
  const args = ["list", "items", "--search", query];
25
32
  if (localOpts.folder) {
26
- args.push("--folderid", localOpts.folder);
33
+ args.push("--folderid", await resolveFolderFilter(localOpts.folder, opts));
27
34
  }
28
35
 
29
36
  const json = await withSession(opts, () => runBwOrThrow(args));
30
- const items = filterItems(JSON.parse(json), localOpts);
37
+ const filtered = filterItems(JSON.parse(json), localOpts);
31
38
 
32
- if (items.length === 0) {
33
- throw new CliError(
34
- `No items matching "${query}"`,
35
- ExitCode.NotFound,
36
- );
39
+ if (filtered.total === 0) {
40
+ throw new CliError(`No items matching "${query}"`, ExitCode.NotFound);
37
41
  }
38
42
 
39
- writeItems(items, opts, localOpts);
43
+ writeItems(filtered, opts, localOpts);
40
44
  });
41
45
  }
@@ -3,8 +3,15 @@ import { getGlobalOpts } from "../program.ts";
3
3
  import { emitData } from "../io.ts";
4
4
  import { recordStatus } from "../../bw/freshness.ts";
5
5
  import { getStatus } from "../../bw/status.ts";
6
+ import { probeSessionState, type SessionState } from "../../bw/session.ts";
6
7
  import pc from "picocolors";
7
8
 
9
+ const SESSION_HINTS: Record<SessionState, string> = {
10
+ valid: "reads work without unlocking",
11
+ stale: "cached session rejected — next read re-unlocks",
12
+ none: "no cached session — next read unlocks",
13
+ };
14
+
8
15
  export function registerStatus(program: Command): void {
9
16
  program
10
17
  .command("status")
@@ -15,8 +22,11 @@ export function registerStatus(program: Command): void {
15
22
  // Reads reuse this for their staleness check instead of paying for `bw status`.
16
23
  recordStatus(status);
17
24
 
25
+ const session = await resolveSessionState(status.status, opts);
26
+ const payload = { ...status, session };
27
+
18
28
  if (opts.json || opts.plain) {
19
- emitData(status, opts);
29
+ emitData(payload, opts);
20
30
  return;
21
31
  }
22
32
 
@@ -27,12 +37,29 @@ export function registerStatus(program: Command): void {
27
37
  ? pc.yellow
28
38
  : pc.red;
29
39
 
40
+ const sessionColor = session === "valid" ? pc.green : pc.yellow;
41
+
30
42
  const lines = [
31
- `${pc.dim("Status:")} ${statusColor(status.status)}`,
32
- `${pc.dim("Email:")} ${status.userEmail ?? "-"}`,
33
- `${pc.dim("Server:")} ${status.serverUrl ?? "-"}`,
34
- `${pc.dim("Synced:")} ${status.lastSync ?? "never"}`,
43
+ `${pc.dim("Status:")} ${statusColor(status.status)}`,
44
+ `${pc.dim("Session:")} ${sessionColor(session)} ${pc.dim(`(${SESSION_HINTS[session]})`)}`,
45
+ `${pc.dim("Email:")} ${status.userEmail ?? "-"}`,
46
+ `${pc.dim("Server:")} ${status.serverUrl ?? "-"}`,
47
+ `${pc.dim("Synced:")} ${status.lastSync ?? "never"}`,
35
48
  ];
36
49
  process.stdout.write(lines.join("\n") + "\n");
37
50
  });
38
51
  }
52
+
53
+ /**
54
+ * Only a locked vault leaves the question open — an unlocked one reads fine, and
55
+ * an unauthenticated one has nothing a session could unlock. Skipping the probe
56
+ * in those cases keeps `status` at a single `bw` call.
57
+ */
58
+ async function resolveSessionState(
59
+ vaultStatus: string,
60
+ opts: ReturnType<typeof getGlobalOpts>,
61
+ ): Promise<SessionState> {
62
+ if (vaultStatus === "unlocked") return "valid";
63
+ if (vaultStatus === "unauthenticated") return "none";
64
+ return probeSessionState(opts);
65
+ }
@@ -0,0 +1,92 @@
1
+ import type { Command } from "commander";
2
+ import pc from "picocolors";
3
+ import { runBwOrThrow } from "../../bw/runner.ts";
4
+ import { withSession } from "../../bw/session.ts";
5
+ import { CliError, ExitCode } from "../errors.ts";
6
+ import { emitData, emitSuccess } from "../io.ts";
7
+ import { filterItems, parseLimit, writeItems, type ItemListOptions } from "../items.ts";
8
+ import { getGlobalOpts } from "../program.ts";
9
+ import type { GlobalOptions } from "../types.ts";
10
+
11
+ /**
12
+ * `bwx delete` puts an item in the trash rather than destroying it, which is only
13
+ * an undo if the trash can be read and reversed from here.
14
+ */
15
+ export function registerTrash(program: Command): void {
16
+ program
17
+ .command("trash")
18
+ .description("List items in the trash")
19
+ .option("--limit <n>", "Limit results (0 for no limit)", parseLimit)
20
+ .option("--full-items", "Emit complete item objects (includes secrets)")
21
+ .action(async function (this: Command, localOpts: ItemListOptions) {
22
+ const opts = getGlobalOpts(this);
23
+ const filtered = filterItems(await listTrashed(opts), localOpts);
24
+
25
+ if (filtered.total === 0) {
26
+ throw new CliError("Trash is empty", ExitCode.NotFound);
27
+ }
28
+
29
+ writeItems(filtered, opts, localOpts);
30
+ });
31
+
32
+ program
33
+ .command("restore")
34
+ .description("Restore a deleted item from the trash")
35
+ .argument("<item>", "Item name or ID, as shown by 'bwx trash'")
36
+ .action(async function (this: Command, item: string) {
37
+ const opts = getGlobalOpts(this);
38
+ const trashed = await findTrashed(item, opts);
39
+ const id = String(trashed.id);
40
+ const name = String(trashed.name);
41
+
42
+ await withSession(opts, () => runBwOrThrow(["restore", "item", id]));
43
+
44
+ if (opts.json) {
45
+ emitData({ id, name, restored: true }, opts);
46
+ } else if (opts.plain) {
47
+ emitData(id, opts);
48
+ } else {
49
+ emitSuccess(`Restored "${name}" (${id})`, opts);
50
+ }
51
+ });
52
+ }
53
+
54
+ async function listTrashed(
55
+ opts: GlobalOptions,
56
+ ): Promise<Array<Record<string, unknown>>> {
57
+ const json = await withSession(opts, () =>
58
+ runBwOrThrow(["list", "items", "--trash"]),
59
+ );
60
+ return JSON.parse(json) as Array<Record<string, unknown>>;
61
+ }
62
+
63
+ async function findTrashed(
64
+ item: string,
65
+ opts: GlobalOptions,
66
+ ): Promise<Record<string, unknown>> {
67
+ const trashed = await listTrashed(opts);
68
+
69
+ const matches = trashed.filter(
70
+ (entry) =>
71
+ entry.id === item ||
72
+ String(entry.name ?? "").toLowerCase() === item.toLowerCase(),
73
+ );
74
+
75
+ if (matches.length === 0) {
76
+ throw new CliError(
77
+ `No trashed item matching "${item}". Run 'bwx trash' to list them.`,
78
+ ExitCode.NotFound,
79
+ );
80
+ }
81
+
82
+ if (matches.length > 1) {
83
+ const lines = [`Multiple trashed items match "${item}":\n`];
84
+ for (const match of matches) {
85
+ lines.push(` ${pc.dim(String(match.id))} ${String(match.name)}`);
86
+ }
87
+ lines.push("\nUse a specific ID.");
88
+ throw new CliError(lines.join("\n"), ExitCode.BadArgs);
89
+ }
90
+
91
+ return matches[0]!;
92
+ }
package/src/cli/input.ts CHANGED
@@ -6,6 +6,17 @@ export function collect(val: string, prev: string[]): string[] {
6
6
  return prev;
7
7
  }
8
8
 
9
+ /** Reads a single line from stdin, for the few interactive prompts bwx has. */
10
+ export async function readLine(): Promise<string> {
11
+ const chunks: Uint8Array[] = [];
12
+ for await (const chunk of Bun.stdin.stream()) {
13
+ chunks.push(chunk);
14
+ const text = Buffer.concat(chunks).toString();
15
+ if (text.includes("\n")) return text.split("\n")[0]!.trim();
16
+ }
17
+ return Buffer.concat(chunks).toString().trim();
18
+ }
19
+
9
20
  /**
10
21
  * Lazily reads stdin once. Commands validate that only one explicit stdin source
11
22
  * is selected, but caching also keeps implicit legacy stdin reads predictable.
@@ -25,6 +36,8 @@ export interface TextSource {
25
36
  stdin?: boolean;
26
37
  file?: string;
27
38
  env?: string;
39
+ /** Produces the value instead of reading it — see `--password-generate`. */
40
+ generate?: () => Promise<string>;
28
41
  flagNames: string[];
29
42
  allowEmpty?: boolean;
30
43
  }
@@ -45,11 +58,14 @@ export interface ItemTextInputs {
45
58
  passwordStdin?: boolean;
46
59
  passwordFile?: string;
47
60
  passwordEnv?: string;
61
+ passwordGenerate?: boolean;
48
62
  }
49
63
 
50
64
  export interface ResolvedItemTextInputs {
51
65
  notes?: string;
52
66
  password?: string;
67
+ /** True when the password was generated, so callers can keep it unprinted. */
68
+ passwordGenerated?: boolean;
53
69
  }
54
70
 
55
71
  export interface ExplicitStdinInputs extends ItemTextInputs {
@@ -63,11 +79,13 @@ const PASSWORD_SOURCE_FLAGS = [
63
79
  "--password-stdin",
64
80
  "--password-file",
65
81
  "--password-env",
82
+ "--password-generate",
66
83
  ];
67
84
 
68
85
  export async function resolveItemTextInputs(
69
86
  inputs: ItemTextInputs,
70
87
  stdin: StdinReader,
88
+ generatePassword?: () => Promise<string>,
71
89
  ): Promise<ResolvedItemTextInputs> {
72
90
  assertSingleStdinSource(listExplicitStdinInputs(inputs));
73
91
 
@@ -89,13 +107,21 @@ export async function resolveItemTextInputs(
89
107
  stdin: inputs.passwordStdin,
90
108
  file: inputs.passwordFile,
91
109
  env: inputs.passwordEnv,
110
+ generate:
111
+ inputs.passwordGenerate && generatePassword
112
+ ? generatePassword
113
+ : undefined,
92
114
  flagNames: PASSWORD_SOURCE_FLAGS,
93
115
  },
94
116
  stdin,
95
117
  ),
96
118
  ]);
97
119
 
98
- return { notes, password };
120
+ return {
121
+ notes,
122
+ password,
123
+ passwordGenerated: inputs.passwordGenerate ?? false,
124
+ };
99
125
  }
100
126
 
101
127
  export function listExplicitStdinInputs(inputs: ExplicitStdinInputs): string[] {
@@ -118,6 +144,7 @@ export async function resolveOptionalTextSource(
118
144
  source.stdin ? "stdin" : null,
119
145
  source.file !== undefined ? "file" : null,
120
146
  source.env !== undefined ? "env" : null,
147
+ source.generate !== undefined ? "generate" : null,
121
148
  ].filter((value): value is string => value !== null);
122
149
 
123
150
  if (selected.length > 1) {
@@ -136,6 +163,8 @@ export async function resolveOptionalTextSource(
136
163
  value = await stdin.read();
137
164
  } else if (source.file !== undefined) {
138
165
  value = await readFileOrStdin(source.file, stdin, source.label);
166
+ } else if (source.generate !== undefined) {
167
+ value = await source.generate();
139
168
  } else {
140
169
  value = readEnvValue(source.env!, source.label);
141
170
  }
package/src/cli/io.ts CHANGED
@@ -2,9 +2,19 @@ import pc from "picocolors";
2
2
  import { CliError, ExitCode } from "./errors.ts";
3
3
  import type { GlobalOptions } from "./types.ts";
4
4
 
5
- export function emitData(value: unknown, opts: GlobalOptions): void {
5
+ /**
6
+ * `meta` rides along in the JSON envelope for facts about the response itself
7
+ * (truncation, counts) that a machine caller cannot recover from `data` alone.
8
+ * It is omitted entirely when absent, so `{ data }` stays the stable shape.
9
+ */
10
+ export function emitData(
11
+ value: unknown,
12
+ opts: GlobalOptions,
13
+ meta?: Record<string, unknown>,
14
+ ): void {
6
15
  if (opts.json) {
7
- process.stdout.write(JSON.stringify({ data: value }, null, 2) + "\n");
16
+ const payload = meta ? { data: value, meta } : { data: value };
17
+ process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
8
18
  } else if (opts.plain) {
9
19
  if (typeof value === "string") {
10
20
  process.stdout.write(value + "\n");
package/src/cli/items.ts CHANGED
@@ -2,9 +2,17 @@ import pc from "picocolors";
2
2
  import { summarizeItem, type ItemSummary } from "../bw/items.ts";
3
3
  import { BW_TYPE_FROM_NAME } from "../bw/types.ts";
4
4
  import { CliError, ExitCode } from "./errors.ts";
5
- import { emitData } from "./io.ts";
5
+ import { emitData, emitWarn } from "./io.ts";
6
6
  import type { GlobalOptions } from "./types.ts";
7
7
 
8
+ /**
9
+ * Listings are capped by default: an uncapped `bwx list` returns the whole vault,
10
+ * which floods an agent's context and, with `--full-items`, dumps every secret in
11
+ * one call. `--limit 0` is the explicit opt-out, and truncation is always
12
+ * reported so a capped listing can never be mistaken for a complete one.
13
+ */
14
+ export const DEFAULT_LIMIT = 50;
15
+
8
16
  export interface ItemListOptions {
9
17
  type?: string;
10
18
  limit?: number;
@@ -12,10 +20,29 @@ export interface ItemListOptions {
12
20
  fullItems?: boolean;
13
21
  }
14
22
 
23
+ export interface FilteredItems {
24
+ items: Array<Record<string, unknown>>;
25
+ /** Items matching the filters, before the limit was applied. */
26
+ total: number;
27
+ }
28
+
29
+ /** Commander option parser: rejects garbage instead of silently ignoring it. */
30
+ export function parseLimit(raw: string): number {
31
+ // `Number("")` and `Number(" ")` are 0, which would read as "no limit".
32
+ const value = raw.trim() === "" ? Number.NaN : Number(raw);
33
+ if (!Number.isInteger(value) || value < 0) {
34
+ throw new CliError(
35
+ `Invalid --limit "${raw}". Use a non-negative integer (0 for no limit).`,
36
+ ExitCode.BadArgs,
37
+ );
38
+ }
39
+ return value;
40
+ }
41
+
15
42
  export function filterItems(
16
43
  items: Array<Record<string, unknown>>,
17
44
  options: ItemListOptions,
18
- ): Array<Record<string, unknown>> {
45
+ ): FilteredItems {
19
46
  let result = items;
20
47
 
21
48
  if (options.type) {
@@ -29,11 +56,10 @@ export function filterItems(
29
56
  result = result.filter((item) => item.type === typeVal);
30
57
  }
31
58
 
32
- if (options.limit && options.limit > 0) {
33
- result = result.slice(0, options.limit);
34
- }
59
+ const total = result.length;
60
+ const limit = options.limit ?? DEFAULT_LIMIT;
35
61
 
36
- return result;
62
+ return { items: limit > 0 ? result.slice(0, limit) : result, total };
37
63
  }
38
64
 
39
65
  /**
@@ -41,14 +67,32 @@ export function filterItems(
41
67
  * the same reduced projection, so asking for machine-readable output is a
42
68
  * serialization choice and never widens data access. `--full-items` is the
43
69
  * explicit opt-in for callers that genuinely need the complete objects.
70
+ *
71
+ * A truncated listing reports what it dropped through every channel — a stderr
72
+ * warning that survives `--json`, plus `meta` in the JSON envelope — because a
73
+ * silently capped list reads exactly like a complete one.
44
74
  */
45
75
  export function writeItems(
46
- items: Array<Record<string, unknown>>,
76
+ filtered: FilteredItems,
47
77
  opts: GlobalOptions,
48
78
  options: ItemListOptions = {},
49
79
  ): void {
80
+ const { items, total } = filtered;
81
+ const omitted = total - items.length;
82
+
83
+ if (omitted > 0) {
84
+ emitWarn(
85
+ `Showing ${items.length} of ${total} items — pass --limit 0 for all.`,
86
+ opts,
87
+ );
88
+ }
89
+
50
90
  if (opts.json) {
51
- emitData(options.fullItems ? items : items.map(summarizeItem), opts);
91
+ emitData(
92
+ options.fullItems ? items : items.map(summarizeItem),
93
+ opts,
94
+ { total, shown: items.length, truncated: omitted > 0 },
95
+ );
52
96
  return;
53
97
  }
54
98
 
@@ -17,6 +17,9 @@ import { registerDoctor } from "./commands/doctor.ts";
17
17
  import { registerList } from "./commands/list.ts";
18
18
  import { registerAttach } from "./commands/attach.ts";
19
19
  import { registerRun } from "./commands/run.ts";
20
+ import { registerGenerate } from "./commands/generate.ts";
21
+ import { registerFolders } from "./commands/folders.ts";
22
+ import { registerTrash } from "./commands/trash.ts";
20
23
 
21
24
  export function buildProgram(): Command {
22
25
  const program = new Command();
@@ -59,8 +62,11 @@ export function buildProgram(): Command {
59
62
  registerCreate(program);
60
63
  registerEdit(program);
61
64
  registerDelete(program);
65
+ registerTrash(program);
62
66
  registerAttach(program);
63
67
  registerList(program);
68
+ registerFolders(program);
69
+ registerGenerate(program);
64
70
  registerConfig(program);
65
71
  registerDoctor(program);
66
72