bwx 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.
package/README.md CHANGED
@@ -17,9 +17,11 @@ Bitwarden CLI wrapper for AI agents (`bwx` = `bw extended`). Auto-unlock, sessio
17
17
  The native `bw` CLI requires interactive prompts and manual session management. `bwx` wraps it with:
18
18
 
19
19
  - **Auto-unlock** — first vault command authenticates automatically via macOS Keychain
20
- - **Session caching** — token persisted to `~/.config/bwx/session` (mode 600), reused across calls
20
+ - **Session caching** — token written atomically as mode 600 to `~/.config/bwx/session`, passed to `bw` through `BW_SESSION` so it never appears in a process command line
21
21
  - **Structured output** — `--json` and `--plain` flags for machine-parseable output
22
+ - **Leak-resistant** — listings never print secrets, `bwx run` hands them to a child process instead of stdout
22
23
  - **No interaction** — credentials from config + Keychain, never prompts
24
+ - **Bounded** — every `bw` call has a deadline, so a wedged vault operation cannot hang a caller
23
25
 
24
26
  ## Install
25
27
 
@@ -64,10 +66,47 @@ bwx doctor
64
66
  |---------|-------------|
65
67
  | `bwx get <field> <item>` | Get field (`password`, `username`, `totp`, `notes`, `uri`, `item`) |
66
68
  | `bwx field <item> <name>` | Get custom field by name |
67
- | `bwx search <query>` | Search items (`--type`, `--folder`, `--limit`) |
69
+ | `bwx search <query>` | Search items (`--type`, `--folder`, `--limit`, `--full-items`) |
70
+ | `bwx list [type]` | List items (`--folder`, `--limit`, `--full-items`) |
71
+ | `bwx run --env NAME=<field>:<item> -- <cmd>` | Run a command with secrets in its environment |
68
72
  | `bwx attach list <item>` | List attachments for an item |
69
73
  | `bwx attach get <item> <attachment>` | Download attachment by ID or filename |
70
74
 
75
+ `search` and `list` print the same reduced projection — id, type, name, username — in
76
+ human, `--plain`, and `--json` form, so asking for machine-readable output never widens
77
+ what is exposed. `--full-items` is the explicit opt-in to complete item objects, which
78
+ carry passwords, notes, and custom fields.
79
+
80
+ ### Secrets into a child process
81
+
82
+ `bwx run` resolves secrets and places them only in the child's environment: nothing is
83
+ printed to stdout, and nothing lands in a command line where other processes could read it.
84
+
85
+ ```bash
86
+ bwx run --env KEENETIC_PASS=password:'http://my.keenetic.net' -- expect ./recovery.exp
87
+ bwx run --env DB_PASS=password:'Prod DB' --env TOKEN='API Key:Acme' -- ./deploy.sh
88
+ ```
89
+
90
+ - `--env NAME=<field>:<item>` is repeatable; the field resolves exactly as in `bwx get`
91
+ (built-ins first, otherwise a custom field name).
92
+ - The item may contain colons — only the first one separates field from item.
93
+ - Use `--` before the command whenever it takes its own flags.
94
+ - The child inherits stdio and bwx exits with the child's status (`128 + signal` when killed).
95
+ - `-v` logs the injected variable *names*; values are never logged.
96
+
97
+ ### Vault freshness
98
+
99
+ `bw` answers reads from the local vault copy, so a vault that has not synced in a while
100
+ returns stale secrets. Reads warn on stderr when the last sync is over 24h old, and
101
+ `--sync-if-older-than <duration>` syncs first instead of warning:
102
+
103
+ ```bash
104
+ bwx get password "Router" # warns if the vault is stale
105
+ bwx --sync-if-older-than 15m get password "Router" # syncs first when needed
106
+ ```
107
+
108
+ The check is cached in `~/.config/bwx/state.json`, so a fresh vault costs no extra `bw` call.
109
+
71
110
  ### Write
72
111
 
73
112
  | Command | Description |
@@ -119,8 +158,15 @@ Supported source flags:
119
158
  |------|--------|
120
159
  | `--json` | JSON output: `{ "data": ... }` / `{ "error": ... }` |
121
160
  | `--plain` | Tab-separated, one item per line |
122
- | `-q, --quiet` | Suppress logs |
161
+ | `-q, --quiet` | Suppress logs and warnings |
123
162
  | `-v, --verbose` | Include stack traces |
163
+ | `--timeout <duration>` | Deadline for each `bw` call (default `1m`, `0` disables) |
164
+ | `--sync-if-older-than <duration>` | Sync before vault access when the last sync is older |
165
+
166
+ Durations accept `250ms`, `30s`, `15m`, `2h`, `1d`; a bare number means seconds.
167
+
168
+ Warnings ("vault is stale", "ignoring cached session") go to stderr even under `--json`,
169
+ because they say the returned data may be wrong. `-q` silences them.
124
170
 
125
171
  ## Custom fields
126
172
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bwx",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "keywords": [
6
6
  "bitwarden",
@@ -12,7 +12,8 @@
12
12
  ],
13
13
  "files": [
14
14
  "bin",
15
- "src"
15
+ "src",
16
+ "skill"
16
17
  ],
17
18
  "bin": {
18
19
  "bwx": "bin/bwx"
@@ -0,0 +1,149 @@
1
+ ---
2
+ name: bwx
3
+ description: Bitwarden Extended CLI — retrieve, create, edit, delete vault items with auto-unlock. Use when the agent needs passwords, API keys, TOTP codes, custom fields, or vault management. Triggers on mentions of Bitwarden, bw, bwx, vault, password, secrets, TOTP, save key/token.
4
+ ---
5
+
6
+ # bwx
7
+
8
+ Extended Bitwarden CLI. Wraps `bw` with auto-unlock, session caching, and structured output.
9
+
10
+ ## Quick start
11
+
12
+ ```bash
13
+ bwx get password "GitHub" # retrieve a password (auto-unlocks)
14
+ bwx get totp "AWS (user@example.com)" # TOTP code
15
+ bwx get username "GitHub" # username
16
+ bwx get notes "Deploy key" # secure note content
17
+ bwx get uri "GitHub" # first URI
18
+ bwx get item "GitHub" # full item JSON
19
+ bwx get "API Key" "Acme" # custom field by name
20
+ bwx field "Acme" "API Key" # custom field alias
21
+
22
+ bwx search github # fuzzy search, human table
23
+ bwx search github --json # id/type/name/username only — no secrets
24
+ bwx search github --type login --limit 5
25
+
26
+ # hand a secret to a child process instead of printing it
27
+ bwx run --env KEENETIC_PASS=password:'http://my.keenetic.net' -- expect ./recovery.exp
28
+
29
+ bwx attach list "Apple Developer" # list item attachments
30
+ bwx attach get "Apple Developer" AuthKey.p8
31
+ bwx attach get "Apple Developer" AuthKey.p8 --output ./secrets/
32
+
33
+ bwx create --name "API Key" --notes "sk_live_abc123" --field account=acme
34
+ printf '%s' "$TOKEN" | bwx create --type login --name "GitHub" --username user --password-stdin --uri https://github.com
35
+ bwx create --name "Acme" --field-file '!API Key=./token.txt' --field-env account=ACME_ACCOUNT
36
+ echo "secret" | bwx create --name "Piped Note"
37
+ ```
38
+
39
+ ## Commands
40
+
41
+ ### `bwx get <field> <item>`
42
+
43
+ Built-in fields: `password | username | totp | notes | uri | item`.
44
+ Anything else is looked up as a custom field. Lists available fields on miss.
45
+
46
+ ### `bwx field <item> <name>`
47
+
48
+ Alias for retrieving custom fields with item-first argument order.
49
+
50
+ ### `bwx search <query>` / `bwx list [type]`
51
+
52
+ Flags: `--type login|note|card|identity` (`search`), `--folder <id>`, `--limit <n>`, `--full-items`
53
+
54
+ Human, `--plain`, and `--json` output all carry the same reduced projection — `id`, `type`,
55
+ `name`, `username` — so listings never leak passwords, notes, or custom fields. `--full-items`
56
+ opts into complete item objects and **does** include secrets; avoid it in logged sessions.
57
+
58
+ ### `bwx run --env NAME=<field>:<item> -- <command>`
59
+
60
+ Resolves secrets and injects them into the child's environment only — not stdout, not argv.
61
+ This is the preferred way to give a subprocess a credential.
62
+
63
+ ```bash
64
+ bwx run --env DB_PASS=password:'Prod DB' -- ./migrate.sh
65
+ bwx run --env TOKEN='API Key:Acme' --env USER=username:Acme -- ./deploy.sh
66
+ ```
67
+
68
+ - `--env` is repeatable; the field resolves as in `bwx get` (built-in, else custom field).
69
+ - Only the first `:` splits field from item, so `password:http://my.router` works.
70
+ - Put `--` before the command when it takes its own flags.
71
+ - Child stdio is inherited; bwx exits with the child's status (`128 + signal` if killed).
72
+ - `-v` logs injected variable names only, never values.
73
+
74
+ ### `bwx attach list <item>`
75
+
76
+ Lists attachments for an item. Aliases: `bwx attachment`, `bwx attachments`; `list` has `ls`.
77
+
78
+ ### `bwx attach get <item> <attachment>`
79
+
80
+ Downloads an attachment by ID or filename. Defaults to saving as the attachment filename in the current directory. Use `--output <path>` to choose a file or directory.
81
+
82
+ ### `bwx create [options]`
83
+
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`
85
+
86
+ 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
+
88
+ ### `bwx edit <item> [options]`
89
+
90
+ Fetch → patch → push.
91
+ ```bash
92
+ bwx edit "API Key" --add-field region=us-east-1
93
+ printf '%s' "$TOKEN" | bwx edit "GitHub" --password-stdin --uri https://github.com
94
+ bwx edit "Note" --rm-field old-key
95
+ ```
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`
97
+
98
+ ### `bwx delete <item>`
99
+
100
+ ```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
104
+ ```
105
+
106
+ ### Other
107
+
108
+ | Command | Description |
109
+ |---------|-------------|
110
+ | `bwx status` | Vault state (no auto-unlock) |
111
+ | `bwx sync` | Force vault sync |
112
+ | `bwx unlock` / `bwx lock` | Manual session control |
113
+ | `bwx config list` / `bwx cfg ls` | Show config path + redacted content |
114
+ | `bwx config master-password` | Set/update master password in Keychain |
115
+
116
+ ## Global flags
117
+
118
+ | Flag | Effect |
119
+ |------|--------|
120
+ | `--json` | `{ "data": ... }` on success, `{ "error": { "message", "exitCode" } }` on failure |
121
+ | `--plain` | Stable parseable lines on stdout |
122
+ | `-q` | Suppress logs and warnings |
123
+ | `-v` | Verbose (stack traces on error) |
124
+ | `--timeout <duration>` | Deadline per `bw` call (default `1m`, `0` disables) → exit 8 |
125
+ | `--sync-if-older-than <duration>` | Sync first when the vault last synced longer ago |
126
+
127
+ Durations: `250ms`, `30s`, `15m`, `2h`, `1d`; a bare number is seconds.
128
+
129
+ ## Vault freshness
130
+
131
+ Reads come from the local vault copy. Reads warn on stderr when the last sync is over 24h
132
+ old — a rotated secret can otherwise look valid or missing. Pass
133
+ `--sync-if-older-than 15m` to sync first instead, or run `bwx sync`. Warnings reach stderr
134
+ even under `--json`; `-q` silences them.
135
+
136
+ ## Safety rules
137
+
138
+ - Never use `--full-items` (or `get item` for discovery) in logged sessions.
139
+ - Prefer `bwx run --env ...` over `export VAR="$(bwx get ...)"`; secrets never touch stdout or argv.
140
+ - Request the narrowest field, resolving an exact item ID first.
141
+ - Use `-q` for command substitution so unlock logs cannot contaminate merged output.
142
+ - On writes, prefer `--password-stdin`, `--password-env`, `--field-file`, `--field-env`.
143
+ - Never echo, log, checksum, or commit retrieved values; `unset` temporary variables.
144
+
145
+ ## Auth
146
+
147
+ - Master password: macOS Keychain (account `bitwarden`, service `bitwarden-master`)
148
+ - Session cached to `~/.config/bwx/session` (mode 600), given to `bw` via `BW_SESSION`
149
+ - Freshness snapshot cached to `~/.config/bwx/state.json`
@@ -0,0 +1,133 @@
1
+ import pc from "picocolors";
2
+ import { CliError, ExitCode } from "../cli/errors.ts";
3
+ import type { GlobalOptions } from "../cli/types.ts";
4
+ import { summarizeItem, type ItemSummary } from "./items.ts";
5
+ import { runBwOrThrow } from "./runner.ts";
6
+ import { withSession } from "./session.ts";
7
+ import { BwItemSchema } from "./types.ts";
8
+
9
+ /** Fields `bw get` resolves natively; anything else is a custom field name. */
10
+ const BUILTIN_FIELDS = [
11
+ "password",
12
+ "username",
13
+ "totp",
14
+ "notes",
15
+ "uri",
16
+ "item",
17
+ ] as const;
18
+
19
+ function isBuiltinField(field: string): boolean {
20
+ return (BUILTIN_FIELDS as readonly string[]).includes(field);
21
+ }
22
+
23
+ export interface ReadFieldOptions {
24
+ /**
25
+ * Always resolve `field` as a custom field name, so an item whose custom
26
+ * field is called e.g. `password` is still reachable.
27
+ */
28
+ customOnly?: boolean;
29
+ }
30
+
31
+ /**
32
+ * Reads one field from a vault item and returns it verbatim (`item` yields the
33
+ * 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
+ */
36
+ export async function readItemField(
37
+ field: string,
38
+ item: string,
39
+ opts: GlobalOptions,
40
+ options?: ReadFieldOptions,
41
+ ): Promise<string> {
42
+ try {
43
+ return isBuiltinField(field) && !options?.customOnly
44
+ ? await readBuiltinField(field, item, opts)
45
+ : await readCustomField(field, item, opts);
46
+ } catch (err) {
47
+ if (err instanceof CliError && /more than one result/i.test(err.message)) {
48
+ const matches = await findMatches(item);
49
+ throw new CliError(
50
+ formatAmbiguousError(item, matches, opts),
51
+ ExitCode.BadArgs,
52
+ );
53
+ }
54
+ throw err;
55
+ }
56
+ }
57
+
58
+ async function readBuiltinField(
59
+ field: string,
60
+ item: string,
61
+ opts: GlobalOptions,
62
+ ): Promise<string> {
63
+ const result = await withSession(opts, () =>
64
+ runBwOrThrow(["get", field, item]),
65
+ );
66
+
67
+ if (!result) {
68
+ throw new CliError(
69
+ `No ${field} found for item: ${item}`,
70
+ ExitCode.NotFound,
71
+ );
72
+ }
73
+
74
+ return result;
75
+ }
76
+
77
+ async function readCustomField(
78
+ fieldName: string,
79
+ item: string,
80
+ opts: GlobalOptions,
81
+ ): Promise<string> {
82
+ const json = await withSession(opts, () =>
83
+ runBwOrThrow(["get", "item", item]),
84
+ );
85
+
86
+ const parsed = BwItemSchema.parse(JSON.parse(json));
87
+ const fields = parsed.fields ?? [];
88
+ const match = fields.find((f) => f.name === fieldName);
89
+
90
+ if (!match) {
91
+ 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);
96
+ }
97
+
98
+ return match.value ?? "";
99
+ }
100
+
101
+ async function findMatches(query: string): Promise<ItemSummary[]> {
102
+ try {
103
+ const json = await runBwOrThrow(["list", "items", "--search", query]);
104
+ const items: Array<Record<string, unknown>> = JSON.parse(json);
105
+ return items.map(summarizeItem);
106
+ } catch {
107
+ return [];
108
+ }
109
+ }
110
+
111
+ function formatAmbiguousError(
112
+ query: string,
113
+ matches: ItemSummary[],
114
+ opts: GlobalOptions,
115
+ ): string {
116
+ if (matches.length === 0) {
117
+ return `Multiple items match "${query}". Use a specific ID instead.`;
118
+ }
119
+
120
+ if (opts.json) {
121
+ return `Multiple items match "${query}": ${JSON.stringify(matches)}`;
122
+ }
123
+
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");
133
+ }
@@ -0,0 +1,161 @@
1
+ import { formatDuration } from "../cli/duration.ts";
2
+ import { emitLog, emitWarn } from "../cli/io.ts";
3
+ import type { GlobalOptions } from "../cli/types.ts";
4
+ import { STATE_FILE } from "../config/paths.ts";
5
+ import { readPrivateFile, writePrivateFile } from "../config/secure-file.ts";
6
+ import { getStatus } from "./status.ts";
7
+ import type { BwStatus } from "./types.ts";
8
+
9
+ /**
10
+ * `bw` serves reads from the local vault copy, so a vault that has not synced in
11
+ * days silently returns rotated-away secrets. Reads therefore check freshness —
12
+ * but `bw status` costs a full `bw` spawn (~2s), far too much to pay on every
13
+ * read, so the answer is cached here and only re-polled when it might matter.
14
+ */
15
+
16
+ /** Warn when the vault has not synced within this window. */
17
+ export const STALE_AFTER_MS = 24 * 60 * 60 * 1_000;
18
+
19
+ /** While the vault looks stale, ask `bw status` again at most this often. */
20
+ const RECHECK_INTERVAL_MS = 5 * 60 * 1_000;
21
+
22
+ export interface FreshnessSnapshot {
23
+ /** `lastSync` as reported by `bw status`; null when the vault never synced. */
24
+ lastSync: string | null;
25
+ /** When bwx last asked `bw status` for that value. */
26
+ checkedAt: string;
27
+ }
28
+
29
+ export type FreshnessPlan =
30
+ /** Known fresh — no `bw` call, no warning. */
31
+ | "skip"
32
+ /** Known stale recently enough to trust the cached answer. */
33
+ | "stale"
34
+ /** Cached answer is missing, unusable, or too old to trust. */
35
+ | "poll";
36
+
37
+ /**
38
+ * Decides what can be concluded from the cached snapshot alone. Pure so the
39
+ * caching policy stays testable without a vault.
40
+ */
41
+ export function planFreshnessCheck(
42
+ snapshot: FreshnessSnapshot | null,
43
+ thresholdMs: number,
44
+ now: number,
45
+ ): FreshnessPlan {
46
+ if (!snapshot) return "poll";
47
+
48
+ if (isFresh(snapshot, thresholdMs, now)) return "skip";
49
+
50
+ const checkedAgeMs = now - Date.parse(snapshot.checkedAt);
51
+ const checkedRecently =
52
+ Number.isFinite(checkedAgeMs) &&
53
+ checkedAgeMs >= 0 &&
54
+ checkedAgeMs < RECHECK_INTERVAL_MS;
55
+
56
+ // lastSync only ever moves forward, so a stale reading stays stale unless
57
+ // another client synced — worth re-checking, just not on every read.
58
+ return checkedRecently ? "stale" : "poll";
59
+ }
60
+
61
+ /**
62
+ * Warns (or syncs, with `--sync-if-older-than`) when the local vault is stale.
63
+ * Advisory only: any failure to determine freshness is ignored rather than
64
+ * failing the operation the caller actually asked for.
65
+ */
66
+ export async function checkVaultFreshness(
67
+ opts: GlobalOptions,
68
+ sync: () => Promise<unknown>,
69
+ ): Promise<void> {
70
+ const syncOnStale = opts.syncIfOlderThanMs !== undefined;
71
+ const thresholdMs = opts.syncIfOlderThanMs ?? STALE_AFTER_MS;
72
+
73
+ let snapshot = loadSnapshot();
74
+ const plan = planFreshnessCheck(snapshot, thresholdMs, Date.now());
75
+ if (plan === "skip") return;
76
+
77
+ if (plan === "poll") {
78
+ try {
79
+ snapshot = recordStatus(await getStatus());
80
+ } catch {
81
+ return; // Freshness is advisory — never block the real operation.
82
+ }
83
+ if (isFresh(snapshot, thresholdMs, Date.now())) return;
84
+ }
85
+
86
+ const ageMs = snapshotAgeMs(snapshot, Date.now());
87
+ const age = ageMs === null ? "never" : `${formatDuration(ageMs)} ago`;
88
+
89
+ if (!syncOnStale) {
90
+ emitWarn(
91
+ `Vault last synced ${age} — reads may be stale. Run 'bwx sync' or pass --sync-if-older-than <duration>.`,
92
+ opts,
93
+ );
94
+ return;
95
+ }
96
+
97
+ emitLog(`Vault last synced ${age} — syncing...`, opts);
98
+ await sync();
99
+ recordSyncNow();
100
+ }
101
+
102
+ /** Caches what `bw status` just reported. */
103
+ export function recordStatus(status: BwStatus): FreshnessSnapshot {
104
+ return saveSnapshot({
105
+ lastSync: status.lastSync,
106
+ checkedAt: new Date().toISOString(),
107
+ });
108
+ }
109
+
110
+ /**
111
+ * Records a sync that just completed. The stored `lastSync` is our own clock
112
+ * rather than the server's, which is close enough to keep later reads quiet.
113
+ */
114
+ export function recordSyncNow(): void {
115
+ const now = new Date().toISOString();
116
+ saveSnapshot({ lastSync: now, checkedAt: now });
117
+ }
118
+
119
+ function isFresh(
120
+ snapshot: FreshnessSnapshot | null,
121
+ thresholdMs: number,
122
+ now: number,
123
+ ): boolean {
124
+ const ageMs = snapshotAgeMs(snapshot, now);
125
+ return ageMs !== null && ageMs < thresholdMs;
126
+ }
127
+
128
+ function snapshotAgeMs(
129
+ snapshot: FreshnessSnapshot | null,
130
+ now: number,
131
+ ): number | null {
132
+ if (!snapshot?.lastSync) return null;
133
+ const syncedAt = Date.parse(snapshot.lastSync);
134
+ if (!Number.isFinite(syncedAt)) return null;
135
+ return Math.max(0, now - syncedAt);
136
+ }
137
+
138
+ function loadSnapshot(): FreshnessSnapshot | null {
139
+ const read = readPrivateFile(STATE_FILE);
140
+ if (read.kind !== "ok") return null;
141
+
142
+ try {
143
+ const parsed = JSON.parse(read.text) as Partial<FreshnessSnapshot>;
144
+ if (typeof parsed.checkedAt !== "string") return null;
145
+ return {
146
+ lastSync: typeof parsed.lastSync === "string" ? parsed.lastSync : null,
147
+ checkedAt: parsed.checkedAt,
148
+ };
149
+ } catch {
150
+ return null;
151
+ }
152
+ }
153
+
154
+ function saveSnapshot(snapshot: FreshnessSnapshot): FreshnessSnapshot {
155
+ try {
156
+ writePrivateFile(STATE_FILE, JSON.stringify(snapshot) + "\n");
157
+ } catch {
158
+ // A read-only config dir must not break vault access.
159
+ }
160
+ return snapshot;
161
+ }
@@ -0,0 +1,25 @@
1
+ import { BW_TYPE_LABELS } from "./types.ts";
2
+
3
+ /**
4
+ * The only shape of a vault item bwx prints when the caller did not ask for a
5
+ * specific secret. Enough to identify an item and follow up with a narrow `get`,
6
+ * with no room for passwords, notes, custom fields, or URIs to leak into logs.
7
+ */
8
+ export interface ItemSummary {
9
+ id: string;
10
+ type: string;
11
+ name: string;
12
+ username: string | null;
13
+ }
14
+
15
+ export function summarizeItem(item: Record<string, unknown>): ItemSummary {
16
+ const login = item.login as Record<string, unknown> | null | undefined;
17
+ const username = login?.username;
18
+
19
+ return {
20
+ id: String(item.id ?? ""),
21
+ type: BW_TYPE_LABELS[item.type as number] ?? `type:${item.type}`,
22
+ name: String(item.name ?? ""),
23
+ username: typeof username === "string" && username ? username : null,
24
+ };
25
+ }