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.
package/README.md CHANGED
@@ -54,20 +54,27 @@ bwx doctor
54
54
 
55
55
  | Command | Description |
56
56
  |---------|-------------|
57
- | `bwx status` | Show vault state |
57
+ | `bwx status` | Show vault state and whether reads work right now |
58
58
  | `bwx unlock` | Unlock vault & cache session |
59
59
  | `bwx lock` | Lock vault & clear session |
60
60
  | `bwx sync` | Sync vault from server |
61
61
  | `bwx doctor` | Check setup (bw CLI, email, password, config) |
62
62
 
63
+ `status` reports a `session` alongside the vault state, because `bw` answers for the
64
+ vault and not for bwx: a `locked` vault with a `valid` session reads fine and needs no
65
+ unlock. `valid` means reads work now, `stale` means the cached session was rejected, and
66
+ `none` means the next read unlocks.
67
+
63
68
  ### Read
64
69
 
65
70
  | Command | Description |
66
71
  |---------|-------------|
67
72
  | `bwx get <field> <item>` | Get field (`password`, `username`, `totp`, `notes`, `uri`, `item`) |
68
- | `bwx field <item> <name>` | Get custom field by name |
73
+ | `bwx field <name> <item>` | Get custom field by name (same argument order as `get`) |
69
74
  | `bwx search <query>` | Search items (`--type`, `--folder`, `--limit`, `--full-items`) |
70
- | `bwx list [type]` | List items (`--folder`, `--limit`, `--full-items`) |
75
+ | `bwx list [type]` | List items (`--type`, `--folder`, `--limit`, `--full-items`) |
76
+ | `bwx folders` | List folder names and IDs |
77
+ | `bwx trash` | List items in the trash |
71
78
  | `bwx run --env NAME=<field>:<item> -- <cmd>` | Run a command with secrets in its environment |
72
79
  | `bwx attach list <item>` | List attachments for an item |
73
80
  | `bwx attach get <item> <attachment>` | Download attachment by ID or filename |
@@ -77,6 +84,41 @@ human, `--plain`, and `--json` form, so asking for machine-readable output never
77
84
  what is exposed. `--full-items` is the explicit opt-in to complete item objects, which
78
85
  carry passwords, notes, and custom fields.
79
86
 
87
+ A lookup that finds nothing suggests what you probably meant, and one that matches
88
+ several items lists them, so neither ends in a guessing round:
89
+
90
+ ```
91
+ $ bwx get password "GitHub PAT vforsh old"
92
+ error: No item matching "GitHub PAT vforsh old". Did you mean:
93
+
94
+ 934c1f57-… note GitHub PAT (vforsh)
95
+ 3683fb9a-… note GitHub PAT (yaforsh) [codex-monitor-pr]
96
+ ```
97
+
98
+ ### Listings are capped
99
+
100
+ Listings stop at 50 items by default — an uncapped `bwx list` returns the whole vault,
101
+ and with `--full-items` that is every secret in one call. `--limit 0` opts out. Truncation
102
+ is never silent: it warns on stderr (surviving `--json`) and adds `meta` to the JSON
103
+ envelope.
104
+
105
+ ```bash
106
+ bwx list --limit 0 # everything
107
+ bwx list --json # { "data": [...], "meta": { "total": 491, "shown": 50, "truncated": true } }
108
+ ```
109
+
110
+ ### Folders
111
+
112
+ `--folder` takes a name or an ID on `list`, `search`, `create`, and `edit`; `none` means
113
+ the unfiled bucket. Names are resolved against the vault, so `bwx folders` is only needed
114
+ to see what exists.
115
+
116
+ ```bash
117
+ bwx folders
118
+ bwx list --folder "API Keys"
119
+ bwx edit "Acme" --folder none # unfile
120
+ ```
121
+
80
122
  ### Secrets into a child process
81
123
 
82
124
  `bwx run` resolves secrets and places them only in the child's environment: nothing is
@@ -114,6 +156,34 @@ The check is cached in `~/.config/bwx/state.json`, so a fresh vault costs no ext
114
156
  | `bwx create` | Create item (`--type`, `--name`, `--username`, `--password-stdin`, `--field-file k=path`) |
115
157
  | `bwx edit <item>` | Edit item (`--name`, `--password-env`, `--add-field-file k=path`, `--rm-field name`) |
116
158
  | `bwx delete <item>` | Delete item (`--permanent`, `--force`) |
159
+ | `bwx restore <item>` | Restore an item from the trash |
160
+ | `bwx generate` | Generate a password or passphrase (local, no vault access) |
161
+
162
+ Login-only flags (`--username`, any `--password-*`, `--uri`) require a login item. A
163
+ secure note has nowhere to put them, so passing them to one is an error rather than an
164
+ exit 0 over a credential that was silently dropped.
165
+
166
+ `bwx delete` requires `--force` whenever it is not talking to a terminal. The confirmation
167
+ prompt cannot render for a script or an agent, so without it `--force` would be decorative
168
+ and every non-interactive delete unguarded. Deletes go to the trash unless `--permanent`,
169
+ and `bwx trash` / `bwx restore` are the way back.
170
+
171
+ ### Generated passwords
172
+
173
+ `--password-generate` on `create` and `edit` sends a new secret straight to the vault. It
174
+ is never printed, never echoed back in `--json`, and never reaches a terminal, a
175
+ transcript, or a log — which is what makes it safe to use from an agent.
176
+
177
+ ```bash
178
+ bwx create --type login --name "Deploy" --username svc --password-generate
179
+ bwx edit "Deploy" --password-generate --generate-length 32
180
+ bwx generate --length 32 # print one instead
181
+ bwx generate --passphrase --words 5
182
+ ```
183
+
184
+ `bwx generate` runs locally and needs no unlocked vault. Flags: `--length <n>`,
185
+ `--no-uppercase`, `--no-lowercase`, `--no-numbers`, `-s/--special`, `--ambiguous`,
186
+ `--passphrase`, `--words <n>`, `--separator <char>`, `--capitalize`, `--include-number`.
117
187
 
118
188
  ### Config
119
189
 
@@ -156,7 +226,7 @@ Supported source flags:
156
226
 
157
227
  | Flag | Effect |
158
228
  |------|--------|
159
- | `--json` | JSON output: `{ "data": ... }` / `{ "error": ... }` |
229
+ | `--json` | JSON output: `{ "data": ... }` / `{ "error": ... }`, plus `meta` where a response describes itself |
160
230
  | `--plain` | Tab-separated, one item per line |
161
231
  | `-q, --quiet` | Suppress logs and warnings |
162
232
  | `-v, --verbose` | Include stack traces |
@@ -191,6 +261,17 @@ Field syntax for `--field`, `--field-file`, `--field-env`, and edit variants:
191
261
  | 8 | Timeout |
192
262
  | 9 | User cancelled |
193
263
 
264
+ ## Upgrading to 0.5.0
265
+
266
+ Breaking changes, all of them cases where the old behavior failed quietly:
267
+
268
+ - `bwx field` takes `<name> <item>`, matching `bwx get`. It was `<item> <name>`.
269
+ - `bwx delete` needs `--force` when not interactive; it used to delete unprompted.
270
+ - `bwx list` and `bwx search` stop at 50 items unless given `--limit`. Pass `--limit 0`
271
+ for the old behavior.
272
+ - `--limit` rejects non-numeric values instead of ignoring them (and returning everything).
273
+ - Login-only flags on a secure note are an error instead of a silent drop.
274
+
194
275
  ## Stack
195
276
 
196
277
  Bun, TypeScript, Commander, Zod, picocolors.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bwx",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "keywords": [
6
6
  "bitwarden",
@@ -17,7 +17,7 @@ bwx get notes "Deploy key" # secure note content
17
17
  bwx get uri "GitHub" # first URI
18
18
  bwx get item "GitHub" # full item JSON
19
19
  bwx get "API Key" "Acme" # custom field by name
20
- bwx field "Acme" "API Key" # custom field alias
20
+ bwx field "API Key" "Acme" # custom-field-only lookup, same arg order
21
21
 
22
22
  bwx search github # fuzzy search, human table
23
23
  bwx search github --json # id/type/name/username only — no secrets
@@ -26,6 +26,9 @@ bwx search github --type login --limit 5
26
26
  # hand a secret to a child process instead of printing it
27
27
  bwx run --env KEENETIC_PASS=password:'http://my.keenetic.net' -- expect ./recovery.exp
28
28
 
29
+ # generate straight into the vault — the value never enters your context
30
+ bwx create --type login --name "Deploy" --username svc --password-generate
31
+
29
32
  bwx attach list "Apple Developer" # list item attachments
30
33
  bwx attach get "Apple Developer" AuthKey.p8
31
34
  bwx attach get "Apple Developer" AuthKey.p8 --output ./secrets/
@@ -43,18 +46,31 @@ echo "secret" | bwx create --name "Piped Note"
43
46
  Built-in fields: `password | username | totp | notes | uri | item`.
44
47
  Anything else is looked up as a custom field. Lists available fields on miss.
45
48
 
46
- ### `bwx field <item> <name>`
49
+ A miss suggests near matches; an ambiguous name lists candidates with IDs. Read the
50
+ suggestions instead of retrying variations — the answer is usually in them.
51
+
52
+ ### `bwx field <name> <item>`
47
53
 
48
- Alias for retrieving custom fields with item-first argument order.
54
+ Custom fields only, so an item whose custom field is named `password` is still reachable.
55
+ Takes its arguments in the same order as `get` (this changed in 0.5.0).
49
56
 
50
57
  ### `bwx search <query>` / `bwx list [type]`
51
58
 
52
- Flags: `--type login|note|card|identity` (`search`), `--folder <id>`, `--limit <n>`, `--full-items`
59
+ Flags: `--type login|note|card|identity`, `--folder <name|id>`, `--limit <n>`, `--full-items`
53
60
 
54
61
  Human, `--plain`, and `--json` output all carry the same reduced projection — `id`, `type`,
55
62
  `name`, `username` — so listings never leak passwords, notes, or custom fields. `--full-items`
56
63
  opts into complete item objects and **does** include secrets; avoid it in logged sessions.
57
64
 
65
+ **Listings stop at 50 items** unless you pass `--limit` (`--limit 0` for all). Truncation is
66
+ reported on stderr and in `meta` on the JSON envelope — check it before concluding a listing
67
+ was complete. `--limit` rejects non-numeric values rather than ignoring them.
68
+
69
+ ### `bwx folders`
70
+
71
+ Lists folder names and IDs. `--folder` accepts either on `list`, `search`, `create`, and
72
+ `edit`; `none` selects unfiled items.
73
+
58
74
  ### `bwx run --env NAME=<field>:<item> -- <command>`
59
75
 
60
76
  Resolves secrets and injects them into the child's environment only — not stdout, not argv.
@@ -70,6 +86,8 @@ bwx run --env TOKEN='API Key:Acme' --env USER=username:Acme -- ./deploy.sh
70
86
  - Put `--` before the command when it takes its own flags.
71
87
  - Child stdio is inherited; bwx exits with the child's status (`128 + signal` if killed).
72
88
  - `-v` logs injected variable names only, never values.
89
+ - Several variables from one item cost a single vault read, so group them here rather
90
+ than issuing separate `bwx get` calls.
73
91
 
74
92
  ### `bwx attach list <item>`
75
93
 
@@ -81,7 +99,10 @@ Downloads an attachment by ID or filename. Defaults to saving as the attachment
81
99
 
82
100
  ### `bwx create [options]`
83
101
 
84
- Flags: `--type login|note`, `--name`, `--notes`, `--notes-file`, `--username`, `--password`, `--password-stdin`, `--password-file`, `--password-env`, `--uri` (repeatable), `--field k=v` (repeatable), `--field-file k=path`, `--field-env k=ENV`, `--folder`, `--favorite`, `--from-json`
102
+ Flags: `--type login|note`, `--name`, `--notes`, `--notes-file`, `--username`, `--password`, `--password-stdin`, `--password-file`, `--password-env`, `--password-generate`, `--generate-length <n>`, `--uri` (repeatable), `--field k=v` (repeatable), `--field-file k=path`, `--field-env k=ENV`, `--folder <name|id>`, `--favorite`, `--from-json`
103
+
104
+ `--username`, any `--password-*`, and `--uri` require `--type login`; a note cannot hold
105
+ them and passing them to one is now an error rather than a silent drop.
85
106
 
86
107
  Field syntax: `k=v` (text), `!k=v` (hidden), `bool:k=v` (boolean). The same key syntax works for `--field-file` and `--field-env`, where the value is a path or environment variable name. Stdin is read as `--notes` when piped; prefer `--password-stdin` for login passwords.
87
108
 
@@ -93,26 +114,51 @@ bwx edit "API Key" --add-field region=us-east-1
93
114
  printf '%s' "$TOKEN" | bwx edit "GitHub" --password-stdin --uri https://github.com
94
115
  bwx edit "Note" --rm-field old-key
95
116
  ```
96
- Flags: `--name`, `--notes`, `--notes-file`, `--username`, `--password`, `--password-stdin`, `--password-file`, `--password-env`, `--uri` (replaces all), `--add-field k=v`, `--add-field-file k=path`, `--add-field-env k=ENV`, `--rm-field name`, `--folder`, `--favorite/--no-favorite`, `--from-json`
117
+ Flags: `--name`, `--notes`, `--notes-file`, `--username`, `--password`, `--password-stdin`, `--password-file`, `--password-env`, `--password-generate`, `--generate-length <n>`, `--uri` (replaces all), `--add-field k=v`, `--add-field-file k=path`, `--add-field-env k=ENV`, `--rm-field name`, `--folder <name|id>` (`none` unfiles), `--favorite/--no-favorite`, `--from-json`
97
118
 
98
119
  ### `bwx delete <item>`
99
120
 
100
121
  ```bash
101
- bwx delete "Test Note" # prompts in TTY
102
- bwx delete "Test Note" --force # skip prompt
103
- bwx delete "Test Note" --permanent # no trash
122
+ bwx delete "Test Note" --force # --force is REQUIRED when not a TTY
123
+ bwx delete "Test Note" --permanent --force # skip the trash
124
+ bwx trash # what is recoverable
125
+ bwx restore "Test Note" # undo a non-permanent delete
126
+ ```
127
+
128
+ The prompt only exists for a human at a terminal, so an agent must pass `--force`
129
+ deliberately. Deletes are recoverable through `bwx restore` unless `--permanent`.
130
+
131
+ ### `bwx generate`
132
+
133
+ ```bash
134
+ bwx generate --length 32
135
+ bwx generate --passphrase --words 5 --separator -
104
136
  ```
105
137
 
138
+ Local, no vault access, no unlock. Flags: `--length <n>`, `--no-uppercase`,
139
+ `--no-lowercase`, `--no-numbers`, `-s/--special`, `--ambiguous`, `--passphrase`,
140
+ `--words <n>`, `--separator <char>`, `--capitalize`, `--include-number`.
141
+
142
+ Prefer `--password-generate` on `create`/`edit` over generating and passing the value:
143
+ it goes to the vault without ever entering your context, and `--json` returns
144
+ `***GENERATED***` in place of the password.
145
+
106
146
  ### Other
107
147
 
108
148
  | Command | Description |
109
149
  |---------|-------------|
110
- | `bwx status` | Vault state (no auto-unlock) |
150
+ | `bwx status` | Vault state + session usability (no auto-unlock) |
111
151
  | `bwx sync` | Force vault sync |
112
152
  | `bwx unlock` / `bwx lock` | Manual session control |
153
+ | `bwx folders` | Folder names and IDs |
154
+ | `bwx trash` / `bwx restore <item>` | Inspect and undo deletes |
113
155
  | `bwx config list` / `bwx cfg ls` | Show config path + redacted content |
114
156
  | `bwx config master-password` | Set/update master password in Keychain |
115
157
 
158
+ `bwx status` reports `session: valid | stale | none` separately from the vault state.
159
+ **A `locked` vault with a `valid` session reads fine — do not run `bwx unlock` on it.**
160
+ Only `stale`/`none` mean the next read pays for an unlock.
161
+
116
162
  ## Global flags
117
163
 
118
164
  | Flag | Effect |
@@ -137,6 +183,7 @@ even under `--json`; `-q` silences them.
137
183
 
138
184
  - Never use `--full-items` (or `get item` for discovery) in logged sessions.
139
185
  - Prefer `bwx run --env ...` over `export VAR="$(bwx get ...)"`; secrets never touch stdout or argv.
186
+ - Prefer `--password-generate` over generating a secret yourself and passing it in.
140
187
  - Request the narrowest field, resolving an exact item ID first.
141
188
  - Use `-q` for command substitution so unlock logs cannot contaminate merged output.
142
189
  - On writes, prefer `--password-stdin`, `--password-env`, `--field-file`, `--field-env`.
@@ -98,7 +98,8 @@ export interface PatchOptions {
98
98
  uris?: string[];
99
99
  addFields?: ParsedField[];
100
100
  rmFields?: string[];
101
- folderId?: string;
101
+ /** `null` unfiles the item; `undefined` leaves its folder alone. */
102
+ folderId?: string | null;
102
103
  favorite?: boolean;
103
104
  }
104
105
 
package/src/bw/fields.ts CHANGED
@@ -4,7 +4,7 @@ import type { GlobalOptions } from "../cli/types.ts";
4
4
  import { summarizeItem, type ItemSummary } from "./items.ts";
5
5
  import { runBwOrThrow } from "./runner.ts";
6
6
  import { withSession } from "./session.ts";
7
- import { BwItemSchema } from "./types.ts";
7
+ import { BwItemSchema, type BwItem } from "./types.ts";
8
8
 
9
9
  /** Fields `bw get` resolves natively; anything else is a custom field name. */
10
10
  const BUILTIN_FIELDS = [
@@ -16,6 +16,12 @@ const BUILTIN_FIELDS = [
16
16
  "item",
17
17
  ] as const;
18
18
 
19
+ /**
20
+ * `totp` is a computed code, not stored data — only `bw get totp` can derive it,
21
+ * so it is the one field that cannot be served from a fetched item.
22
+ */
23
+ const DERIVED_FIELDS = new Set(["totp"]);
24
+
19
25
  function isBuiltinField(field: string): boolean {
20
26
  return (BUILTIN_FIELDS as readonly string[]).includes(field);
21
27
  }
@@ -28,10 +34,23 @@ export interface ReadFieldOptions {
28
34
  customOnly?: boolean;
29
35
  }
30
36
 
37
+ /**
38
+ * Raised when the item was found but the requested field was not. Distinct from
39
+ * a missing *item* so the "did you mean this item?" suggestions below never fire
40
+ * on an error that already names the fields that do exist.
41
+ */
42
+ class FieldMissingError extends CliError {}
43
+
44
+ /** One `(field, item)` pair to resolve — see {@link readFields}. */
45
+ export interface FieldRequest {
46
+ field: string;
47
+ item: string;
48
+ options?: ReadFieldOptions;
49
+ }
50
+
31
51
  /**
32
52
  * Reads one field from a vault item and returns it verbatim (`item` yields the
33
53
  * item JSON), leaving the caller to decide whether the value is printed at all.
34
- * Ambiguous item lookups fail with the candidate list instead of a raw bw error.
35
54
  */
36
55
  export async function readItemField(
37
56
  field: string,
@@ -39,22 +58,132 @@ export async function readItemField(
39
58
  opts: GlobalOptions,
40
59
  options?: ReadFieldOptions,
41
60
  ): Promise<string> {
61
+ return withLookupHelp(item, opts, () =>
62
+ isBuiltinField(field) && !options?.customOnly
63
+ ? readBuiltinField(field, item, opts)
64
+ : readCustomField(field, item, opts),
65
+ );
66
+ }
67
+
68
+ /**
69
+ * Resolves many fields at once, paying a single `bw get item` per distinct item
70
+ * rather than one `bw` spawn per field. Each spawn costs ~2.5s, so a caller
71
+ * pulling a username and password from one item halves its own runtime.
72
+ *
73
+ * Returns values positionally aligned with `requests`.
74
+ */
75
+ export async function readFields(
76
+ requests: FieldRequest[],
77
+ opts: GlobalOptions,
78
+ ): Promise<string[]> {
79
+ const values = new Array<string>(requests.length);
80
+ const byItem = new Map<string, number[]>();
81
+
82
+ for (const [index, request] of requests.entries()) {
83
+ const bucket = byItem.get(request.item);
84
+ if (bucket) bucket.push(index);
85
+ else byItem.set(request.item, [index]);
86
+ }
87
+
88
+ for (const [item, indexes] of byItem) {
89
+ // A single `bw` process at a time: each read may trigger an unlock, and
90
+ // concurrent unlocks would race over the cached session.
91
+ const derived = indexes.filter((i) => isDerived(requests[i]!));
92
+ const stored = indexes.filter((i) => !isDerived(requests[i]!));
93
+
94
+ if (stored.length > 0) {
95
+ const fetched = await withLookupHelp(item, opts, () =>
96
+ fetchItem(item, opts),
97
+ );
98
+ for (const index of stored) {
99
+ const request = requests[index]!;
100
+ values[index] = extractField(fetched, request.field, request.options);
101
+ }
102
+ }
103
+
104
+ for (const index of derived) {
105
+ const request = requests[index]!;
106
+ values[index] = await withLookupHelp(item, opts, () =>
107
+ readBuiltinField(request.field, item, opts),
108
+ );
109
+ }
110
+ }
111
+
112
+ return values;
113
+ }
114
+
115
+ function isDerived(request: FieldRequest): boolean {
116
+ return !request.options?.customOnly && DERIVED_FIELDS.has(request.field);
117
+ }
118
+
119
+ /**
120
+ * Turns bw's bare "Not found." and "More than one result" into errors that name
121
+ * the candidates, so a caller can correct itself without a round of guessing.
122
+ */
123
+ async function withLookupHelp<T>(
124
+ item: string,
125
+ opts: GlobalOptions,
126
+ fn: () => Promise<T>,
127
+ ): Promise<T> {
42
128
  try {
43
- return isBuiltinField(field) && !options?.customOnly
44
- ? await readBuiltinField(field, item, opts)
45
- : await readCustomField(field, item, opts);
129
+ return await fn();
46
130
  } catch (err) {
131
+ if (err instanceof FieldMissingError) throw err;
132
+
47
133
  if (err instanceof CliError && /more than one result/i.test(err.message)) {
48
- const matches = await findMatches(item);
49
134
  throw new CliError(
50
- formatAmbiguousError(item, matches, opts),
135
+ formatAmbiguous(item, await findMatches(item), opts),
51
136
  ExitCode.BadArgs,
52
137
  );
53
138
  }
139
+
140
+ if (err instanceof CliError && err.exitCode === ExitCode.NotFound) {
141
+ throw new CliError(
142
+ formatNotFound(item, await findSimilar(item), opts),
143
+ ExitCode.NotFound,
144
+ );
145
+ }
146
+
54
147
  throw err;
55
148
  }
56
149
  }
57
150
 
151
+ async function fetchItem(item: string, opts: GlobalOptions): Promise<BwItem> {
152
+ const json = await withSession(opts, () => runBwOrThrow(["get", "item", item]));
153
+ return BwItemSchema.parse(JSON.parse(json));
154
+ }
155
+
156
+ /** Mirrors what `bw get <field>` would have returned for an already-fetched item. */
157
+ function extractField(
158
+ item: BwItem,
159
+ field: string,
160
+ options?: ReadFieldOptions,
161
+ ): string {
162
+ if (!isBuiltinField(field) || options?.customOnly) {
163
+ return pickCustomField(item, field);
164
+ }
165
+
166
+ if (field === "item") return JSON.stringify(item);
167
+
168
+ const value =
169
+ field === "notes"
170
+ ? item.notes
171
+ : field === "uri"
172
+ ? (item.login?.uris?.[0]?.uri ?? null)
173
+ : field === "username"
174
+ ? (item.login?.username ?? null)
175
+ : (item.login?.password ?? null);
176
+
177
+ if (!value) {
178
+ throw new FieldMissingError(
179
+ `No ${field} found for item: ${item.name}`,
180
+ ExitCode.NotFound,
181
+ );
182
+ }
183
+
184
+ return value;
185
+ }
186
+
58
187
  async function readBuiltinField(
59
188
  field: string,
60
189
  item: string,
@@ -65,7 +194,7 @@ async function readBuiltinField(
65
194
  );
66
195
 
67
196
  if (!result) {
68
- throw new CliError(
197
+ throw new FieldMissingError(
69
198
  `No ${field} found for item: ${item}`,
70
199
  ExitCode.NotFound,
71
200
  );
@@ -79,20 +208,21 @@ async function readCustomField(
79
208
  item: string,
80
209
  opts: GlobalOptions,
81
210
  ): Promise<string> {
82
- const json = await withSession(opts, () =>
83
- runBwOrThrow(["get", "item", item]),
84
- );
211
+ return pickCustomField(await fetchItem(item, opts), fieldName);
212
+ }
85
213
 
86
- const parsed = BwItemSchema.parse(JSON.parse(json));
87
- const fields = parsed.fields ?? [];
214
+ function pickCustomField(item: BwItem, fieldName: string): string {
215
+ const fields = item.fields ?? [];
88
216
  const match = fields.find((f) => f.name === fieldName);
89
217
 
90
218
  if (!match) {
91
219
  const available = fields.map((f) => f.name).join(", ");
92
- const msg = available
93
- ? `Field "${fieldName}" not found. Available: ${available}`
94
- : `Field "${fieldName}" not found (item has no custom fields)`;
95
- throw new CliError(msg, ExitCode.NotFound);
220
+ throw new FieldMissingError(
221
+ available
222
+ ? `Field "${fieldName}" not found. Available: ${available}`
223
+ : `Field "${fieldName}" not found (item has no custom fields)`,
224
+ ExitCode.NotFound,
225
+ );
96
226
  }
97
227
 
98
228
  return match.value ?? "";
@@ -108,7 +238,95 @@ async function findMatches(query: string): Promise<ItemSummary[]> {
108
238
  }
109
239
  }
110
240
 
111
- function formatAmbiguousError(
241
+ /**
242
+ * Looks for what the caller probably meant. bw's own search already came back
243
+ * empty, so this pulls the item list once and scores it locally: one `bw` spawn
244
+ * on the error path, and matching that survives typos and extra words the way
245
+ * repeated `--search` calls do not.
246
+ */
247
+ async function findSimilar(query: string): Promise<ItemSummary[]> {
248
+ let summaries: ItemSummary[];
249
+ try {
250
+ const json = await runBwOrThrow(["list", "items"]);
251
+ const items: Array<Record<string, unknown>> = JSON.parse(json);
252
+ summaries = items.map(summarizeItem);
253
+ } catch {
254
+ return [];
255
+ }
256
+
257
+ return rankSimilar(query, summaries);
258
+ }
259
+
260
+ /** Pure so the suggestion quality is testable without a vault. */
261
+ export function rankSimilar(
262
+ query: string,
263
+ items: ItemSummary[],
264
+ max = MAX_SUGGESTIONS,
265
+ ): ItemSummary[] {
266
+ const tokens = tokenize(query);
267
+ if (tokens.length === 0) return [];
268
+
269
+ const scored = items
270
+ .map((item) => ({ item, score: scoreMatch(tokens, item) }))
271
+ .filter((entry) => entry.score > 0)
272
+ .sort((a, b) => b.score - a.score || a.item.name.localeCompare(b.item.name));
273
+
274
+ return scored.slice(0, max).map((entry) => entry.item);
275
+ }
276
+
277
+ /**
278
+ * Length gates on the fuzzier rules keep short words from matching by accident —
279
+ * without them "not" pulls in every item containing "evernote", and a suggestion
280
+ * list full of noise is worse than none.
281
+ */
282
+ function scoreMatch(tokens: string[], item: ItemSummary): number {
283
+ const haystack = `${item.name} ${item.username ?? ""}`.toLowerCase();
284
+ const itemTokens = tokenize(haystack);
285
+
286
+ let score = 0;
287
+ for (const token of tokens) {
288
+ if (itemTokens.includes(token)) score += 3;
289
+ else if (token.length >= 4 && haystack.includes(token)) score += 2;
290
+ else if (
291
+ token.length >= 3 &&
292
+ itemTokens.some((other) => other.startsWith(token))
293
+ ) {
294
+ score += 1;
295
+ }
296
+ }
297
+
298
+ return score;
299
+ }
300
+
301
+ function tokenize(value: string): string[] {
302
+ return value
303
+ .toLowerCase()
304
+ .split(/[^a-z0-9]+/)
305
+ .filter((token) => token.length >= 2);
306
+ }
307
+
308
+ const MAX_SUGGESTIONS = 5;
309
+
310
+ function formatNotFound(
311
+ query: string,
312
+ matches: ItemSummary[],
313
+ opts: GlobalOptions,
314
+ ): string {
315
+ if (matches.length === 0) {
316
+ return `No item matching "${query}". Run 'bwx search <query>' to find it.`;
317
+ }
318
+
319
+ if (opts.json) {
320
+ return `No item matching "${query}". Did you mean: ${JSON.stringify(matches)}`;
321
+ }
322
+
323
+ return [
324
+ `No item matching "${query}". Did you mean:\n`,
325
+ ...matches.map(formatCandidate),
326
+ ].join("\n");
327
+ }
328
+
329
+ function formatAmbiguous(
112
330
  query: string,
113
331
  matches: ItemSummary[],
114
332
  opts: GlobalOptions,
@@ -121,13 +339,14 @@ function formatAmbiguousError(
121
339
  return `Multiple items match "${query}": ${JSON.stringify(matches)}`;
122
340
  }
123
341
 
124
- const lines = [`Multiple items match "${query}":\n`];
125
- for (const m of matches) {
126
- const user = m.username ? ` (${m.username})` : "";
127
- lines.push(
128
- ` ${pc.dim(m.id)} ${pc.cyan(m.type)} ${m.name}${pc.dim(user)}`,
129
- );
130
- }
131
- lines.push(`\nUse a specific ID: ${pc.dim("bwx get <field> <id>")}`);
132
- return lines.join("\n");
342
+ return [
343
+ `Multiple items match "${query}":\n`,
344
+ ...matches.map(formatCandidate),
345
+ `\nUse a specific ID: ${pc.dim("bwx get <field> <id>")}`,
346
+ ].join("\n");
347
+ }
348
+
349
+ function formatCandidate(match: ItemSummary): string {
350
+ const user = match.username ? ` (${match.username})` : "";
351
+ return ` ${pc.dim(match.id)} ${pc.cyan(match.type)} ${match.name}${pc.dim(user)}`;
133
352
  }