bwx 0.7.0 → 0.8.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vladislav Forsh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -69,10 +69,10 @@ unlock. `valid` means reads work now, `stale` means the cached session was rejec
69
69
 
70
70
  | Command | Description |
71
71
  |---------|-------------|
72
- | `bwx get <field> <item>` | Get field (`password`, `username`, `totp`, `notes`, `uri`, `item`) |
72
+ | `bwx get <field> <item>` | Get field (`password`, `username`, `totp`, `notes`, `uri`, `item`), `--raw` |
73
73
  | `bwx get totp <item>` | TOTP code (`--fresh [n]`, `--seed`) |
74
74
  | `bwx get <card field> <item>` | Card field (`number`, `cvv`, `cardholder`, `brand`, `expiry`, …) |
75
- | `bwx field <name> <item>` | Get custom field by name (same argument order as `get`) |
75
+ | `bwx field <name> <item>` | Get custom field by name (same argument order as `get`), `--raw` |
76
76
  | `bwx search <query>` | Search items (`--type`, `--folder`, `--limit`, `--full-items`) |
77
77
  | `bwx list [type]` | List items (`--type`, `--folder`, `--limit`, `--full-items`) |
78
78
  | `bwx folders` | List folder names and IDs |
@@ -104,6 +104,29 @@ error: No item matching "GitHub PAT vforsh old". Did you mean:
104
104
  3683fb9a-… note GitHub PAT (yaforsh) [codex-monitor-pr]
105
105
  ```
106
106
 
107
+ ### Piping a secret
108
+
109
+ `get` and `field` end their output with a newline, which reads fine in a terminal and
110
+ lands *inside the secret* for a consumer that treats stdin literally. `--raw` suppresses
111
+ that one newline — and only that one: newlines stored in the value are its own and are
112
+ written through.
113
+
114
+ ```bash
115
+ bwx get password "GitHub" --raw | argus fill app --selector 'input[type=password]' --value-stdin
116
+ ```
117
+
118
+ `--raw` values are byte-for-byte, so whitespace around a password survives too (the
119
+ default output trims it). `--raw` and `--json` are mutually exclusive — the JSON envelope
120
+ quotes and wraps the value, so there is no raw byte stream to hand over — and combining
121
+ them is a usage error rather than a silent winner.
122
+
123
+ For handing a secret to a whole command rather than to one stdin, `bwx run` keeps it out
124
+ of argv entirely:
125
+
126
+ ```bash
127
+ bwx run --env GITHUB_TOKEN=password:"GitHub PAT" -- gh api /user
128
+ ```
129
+
107
130
  ### TOTP
108
131
 
109
132
  Codes are computed locally from the stored secret. A TOTP therefore costs no extra `bw`
@@ -360,3 +383,7 @@ Breaking changes, all of them cases where the old behavior failed quietly:
360
383
  ## Stack
361
384
 
362
385
  Bun, TypeScript, Commander, Zod, picocolors.
386
+
387
+ ## License
388
+
389
+ MIT — see [LICENSE](LICENSE).
package/package.json CHANGED
@@ -1,15 +1,34 @@
1
1
  {
2
2
  "name": "bwx",
3
- "version": "0.7.0",
4
- "type": "module",
3
+ "version": "0.8.1",
4
+ "description": "Extended Bitwarden CLI for scripts and agents — auto-unlock via macOS Keychain, session caching, local TOTP, structured output",
5
5
  "keywords": [
6
6
  "bitwarden",
7
+ "bitwarden-cli",
7
8
  "cli",
8
9
  "vault",
9
10
  "secrets",
11
+ "secrets-management",
12
+ "totp",
13
+ "keychain",
14
+ "macos",
10
15
  "ai-agents",
11
16
  "bun"
12
17
  ],
18
+ "license": "MIT",
19
+ "author": "Vladislav Forsh <vforsh@gmail.com>",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/vforsh/bwx.git"
23
+ },
24
+ "homepage": "https://github.com/vforsh/bwx#readme",
25
+ "bugs": {
26
+ "url": "https://github.com/vforsh/bwx/issues"
27
+ },
28
+ "type": "module",
29
+ "engines": {
30
+ "bun": ">=1.0.0"
31
+ },
13
32
  "files": [
14
33
  "bin",
15
34
  "src",
@@ -20,6 +20,7 @@ bwx get item "GitHub" # full item JSON
20
20
  bwx get number "Visa" # card number (also: cvv, expiry, cardholder, brand)
21
21
  bwx get "API Key" "Acme" # custom field by name
22
22
  bwx field "API Key" "Acme" # custom-field-only lookup, same arg order
23
+ bwx get password "GitHub" --raw # no trailing newline, for piping into stdin
23
24
 
24
25
  bwx search github # fuzzy search, human table
25
26
  bwx search github --json # id/type/name/username only — no secrets
@@ -78,6 +79,22 @@ exactly are delegated to `bw get totp` instead of guessed at.
78
79
  Custom fields only, so an item whose custom field is named `password` is still reachable.
79
80
  Takes its arguments in the same order as `get` (this changed in 0.5.0).
80
81
 
82
+ ### `--raw` (on `get` and `field`)
83
+
84
+ Output normally ends in a newline, which a consumer that reads stdin literally takes as
85
+ the last byte of the secret. `--raw` suppresses that one newline and nothing else —
86
+ newlines inside the value are written through, and the value is byte-for-byte, so
87
+ surrounding whitespace survives (the default output trims it).
88
+
89
+ ```bash
90
+ bwx get password "GitHub" --raw | argus fill app --selector 'input[type=password]' --value-stdin
91
+ ```
92
+
93
+ Prefer this over `$(bwx get …)` when piping a secret: command substitution eats trailing
94
+ newlines *and* puts the secret in the child's argv, where any same-user process can read
95
+ it. `--raw` and `--json` cannot be combined (the envelope quotes and wraps the value); use
96
+ `bwx run --env` when a whole command needs the secret in its environment instead.
97
+
81
98
  ### Card fields
82
99
 
83
100
  ```bash
package/src/bw/fields.ts CHANGED
@@ -40,6 +40,14 @@ export interface ReadFieldOptions {
40
40
  * field is called e.g. `password` is still reachable.
41
41
  */
42
42
  customOnly?: boolean;
43
+ /**
44
+ * Return the value exactly as stored rather than trimmed. Only `bwx get --raw`
45
+ * asks for this; every other caller compares or parses the value, where
46
+ * surrounding whitespace is framing rather than content. Custom and card
47
+ * fields are read out of the item JSON and are verbatim either way — this
48
+ * reaches the fields `bw get <field>` prints on stdout.
49
+ */
50
+ verbatim?: boolean;
43
51
  }
44
52
 
45
53
  /**
@@ -81,7 +89,7 @@ export async function readItemField(
81
89
  }
82
90
 
83
91
  return isBuiltinField(field)
84
- ? readBuiltinField(field, item, opts)
92
+ ? readBuiltinField(field, item, opts, options?.verbatim)
85
93
  : readCustomField(field, item, opts);
86
94
  });
87
95
  }
@@ -330,9 +338,10 @@ async function readBuiltinField(
330
338
  field: string,
331
339
  item: string,
332
340
  opts: GlobalOptions,
341
+ verbatim = false,
333
342
  ): Promise<string> {
334
343
  const result = await withSession(opts, () =>
335
- runBwOrThrow(["get", field, item]),
344
+ runBwOrThrow(["get", field, item], { verbatim }),
336
345
  );
337
346
 
338
347
  if (!result) {
package/src/bw/runner.ts CHANGED
@@ -11,6 +11,13 @@ export interface RunBwOptions {
11
11
  /** Explicit session, or `null` to run without one (login/unlock/status). */
12
12
  session?: string | null;
13
13
  env?: Record<string, string>;
14
+ /**
15
+ * Return stdout as `bw` wrote it, less the single newline `bw` adds to frame
16
+ * its own output. The default trim is right for values bwx parses or compares
17
+ * and wrong for a secret handed back byte-for-byte: whitespace around a
18
+ * password belongs to the password.
19
+ */
20
+ verbatim?: boolean;
14
21
  }
15
22
 
16
23
  /** Deadline for a single `bw` call; overridable with `--timeout`. */
@@ -66,7 +73,11 @@ export async function runBw(
66
73
  );
67
74
  }
68
75
 
69
- return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode };
76
+ return {
77
+ stdout: options?.verbatim ? unframe(stdout) : stdout.trim(),
78
+ stderr: stderr.trim(),
79
+ exitCode,
80
+ };
70
81
  } finally {
71
82
  guard?.disarm();
72
83
  }
@@ -87,6 +98,11 @@ export async function runBwOrThrow(
87
98
  return result.stdout;
88
99
  }
89
100
 
101
+ /** Drops the one newline `bw` prints after a value, and nothing the value owns. */
102
+ function unframe(stdout: string): string {
103
+ return stdout.replace(/\r?\n$/, "");
104
+ }
105
+
90
106
  /**
91
107
  * Child environment for `bw`. `BW_SESSION` is set from our session, or removed
92
108
  * outright so an ambient one from the caller's shell cannot silently substitute
@@ -1,6 +1,6 @@
1
1
  import type { Command } from "commander";
2
2
  import { getGlobalOpts } from "../program.ts";
3
- import { emitData, emitLog } from "../io.ts";
3
+ import { emitData, emitLog, emitRaw } from "../io.ts";
4
4
  import { BUILTIN_FIELDS, readItemField, readTotp, readTotpSecret } from "../../bw/fields.ts";
5
5
  import { CARD_FIELDS } from "../../bw/cards.ts";
6
6
  import { CliError, ExitCode } from "../errors.ts";
@@ -9,6 +9,16 @@ import type { GlobalOptions } from "../types.ts";
9
9
  /** Seconds left below which `--fresh` waits, when the flag is given bare. */
10
10
  const DEFAULT_FRESH_SECONDS = 5;
11
11
 
12
+ /** Written once so `get` and `field` describe `--raw` in the same words. */
13
+ const RAW_DESCRIPTION =
14
+ "Suppress only the trailing newline bwx adds; newlines stored in the value are kept";
15
+
16
+ interface GetOptions {
17
+ fresh?: string | boolean;
18
+ seed?: boolean;
19
+ raw?: boolean;
20
+ }
21
+
12
22
  export function registerGet(program: Command): void {
13
23
  program
14
24
  .command("get")
@@ -23,13 +33,15 @@ export function registerGet(program: Command): void {
23
33
  `Wait for the next TOTP window if fewer than N seconds remain (default ${DEFAULT_FRESH_SECONDS})`,
24
34
  )
25
35
  .option("--seed", "Emit the stored TOTP secret instead of a code")
36
+ .option("--raw", RAW_DESCRIPTION)
26
37
  .action(async function (
27
38
  this: Command,
28
39
  field: string,
29
40
  item: string,
30
- localOpts: { fresh?: string | boolean; seed?: boolean },
41
+ localOpts: GetOptions,
31
42
  ) {
32
43
  const opts = getGlobalOpts(this);
44
+ assertRawUsable(localOpts, opts);
33
45
 
34
46
  if (field === "totp") {
35
47
  await emitTotp(item, localOpts, opts);
@@ -38,9 +50,18 @@ export function registerGet(program: Command): void {
38
50
 
39
51
  assertTotpOnlyFlags(field, localOpts);
40
52
 
41
- const value = await readItemField(field, item, opts);
42
- // `item` is the one field that is itself JSON — emit it as structure.
43
- emitData(field === "item" ? JSON.parse(value) : value, opts);
53
+ const value = await readItemField(field, item, opts, {
54
+ verbatim: localOpts.raw,
55
+ });
56
+
57
+ // `item` is the one field that is itself JSON, so it is emitted as
58
+ // structure — except under `--raw`, which promised the bytes `bw` gave us.
59
+ if (field === "item" && !localOpts.raw) {
60
+ emitData(JSON.parse(value), opts);
61
+ return;
62
+ }
63
+
64
+ emitValue(value, localOpts, opts);
44
65
  });
45
66
 
46
67
  program
@@ -50,19 +71,57 @@ export function registerGet(program: Command): void {
50
71
  // alongside it — the two used to take their arguments back to front.
51
72
  .argument("<name>", "Custom field name")
52
73
  .argument("<item>", "Item name or ID")
53
- .action(async function (this: Command, name: string, item: string) {
74
+ .option("--raw", RAW_DESCRIPTION)
75
+ .action(async function (
76
+ this: Command,
77
+ name: string,
78
+ item: string,
79
+ localOpts: { raw?: boolean },
80
+ ) {
54
81
  const opts = getGlobalOpts(this);
82
+ assertRawUsable(localOpts, opts);
83
+
55
84
  try {
56
- emitData(
57
- await readItemField(name, item, opts, { customOnly: true }),
58
- opts,
59
- );
85
+ const value = await readItemField(name, item, opts, { customOnly: true });
86
+ emitValue(value, localOpts, opts);
60
87
  } catch (err) {
61
88
  throw withOrderHint(err);
62
89
  }
63
90
  });
64
91
  }
65
92
 
93
+ /**
94
+ * Emits one field value. `--raw` writes it alone, so piping a secret into a
95
+ * consumer that reads stdin literally does not append bwx's newline to it;
96
+ * `meta` describes the JSON envelope and so never applies on that path.
97
+ */
98
+ function emitValue(
99
+ value: string,
100
+ localOpts: { raw?: boolean },
101
+ opts: GlobalOptions,
102
+ meta?: Record<string, unknown>,
103
+ ): void {
104
+ if (localOpts.raw) {
105
+ emitRaw(value);
106
+ return;
107
+ }
108
+ emitData(value, opts, meta);
109
+ }
110
+
111
+ /**
112
+ * `--json` is an envelope — the value arrives quoted, escaped, and wrapped in
113
+ * `{ "data": … }` — so its trailing newline is the least of what `--raw` would
114
+ * have to undo. Saying so beats silently letting one win.
115
+ */
116
+ function assertRawUsable(localOpts: { raw?: boolean }, opts: GlobalOptions): void {
117
+ if (localOpts.raw && opts.json) {
118
+ throw new CliError(
119
+ "--raw and --json cannot be combined: --json wraps the value in an envelope. Use one or the other.",
120
+ ExitCode.BadArgs,
121
+ );
122
+ }
123
+ }
124
+
66
125
  /**
67
126
  * Emits a TOTP alongside how long it stays valid. The remainder rides in the
68
127
  * `--json` envelope's `meta` rather than replacing `data`, so `data` is still
@@ -70,7 +129,7 @@ export function registerGet(program: Command): void {
70
129
  */
71
130
  async function emitTotp(
72
131
  item: string,
73
- localOpts: { fresh?: string | boolean; seed?: boolean },
132
+ localOpts: GetOptions,
74
133
  opts: GlobalOptions,
75
134
  ): Promise<void> {
76
135
  if (localOpts.seed) {
@@ -80,7 +139,7 @@ async function emitTotp(
80
139
  ExitCode.BadArgs,
81
140
  );
82
141
  }
83
- emitData(await readTotpSecret(item, opts), opts);
142
+ emitValue(await readTotpSecret(item, opts), localOpts, opts);
84
143
  return;
85
144
  }
86
145
 
@@ -91,7 +150,7 @@ async function emitTotp(
91
150
  });
92
151
 
93
152
  emitLog(`Valid for ${totp.secondsRemaining}s`, opts);
94
- emitData(totp.code, opts, {
153
+ emitValue(totp.code, localOpts, opts, {
95
154
  secondsRemaining: totp.secondsRemaining,
96
155
  period: totp.period,
97
156
  });
@@ -119,10 +178,7 @@ function parseFreshSeconds(raw: string | boolean | undefined): number | undefine
119
178
  * Accepting `--fresh` on a password would read as "give me a fresh password",
120
179
  * which is not what it does. Rejecting it beats quietly ignoring it.
121
180
  */
122
- function assertTotpOnlyFlags(
123
- field: string,
124
- localOpts: { fresh?: string | boolean; seed?: boolean },
125
- ): void {
181
+ function assertTotpOnlyFlags(field: string, localOpts: GetOptions): void {
126
182
  const used = [
127
183
  localOpts.fresh !== undefined ? "--fresh" : null,
128
184
  localOpts.seed ? "--seed" : null,
package/src/cli/io.ts CHANGED
@@ -34,6 +34,16 @@ export function emitData(
34
34
  }
35
35
  }
36
36
 
37
+ /**
38
+ * Writes a value and nothing else — no newline, no formatting. {@link emitData}'s
39
+ * newline is a courtesy to terminals, but a consumer that reads stdin literally
40
+ * takes it as the last byte of the secret. Newlines *inside* the value are the
41
+ * value's own and are left alone.
42
+ */
43
+ export function emitRaw(value: string): void {
44
+ process.stdout.write(value);
45
+ }
46
+
37
47
  export function emitLog(message: string, opts: GlobalOptions): void {
38
48
  if (opts.quiet || opts.json) return;
39
49
  process.stderr.write(message + "\n");