bwx 0.2.2 → 0.3.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
@@ -1,6 +1,16 @@
1
1
  # bwx
2
2
 
3
- Bitwarden CLI wrapper for AI agents. Auto-unlock, session caching, structured output.
3
+ Bitwarden CLI wrapper for AI agents (`bwx` = `bw extended`). Auto-unlock, session caching, structured output.
4
+
5
+ > Currently macOS-only: `bwx` uses macOS Keychain for secure master password storage.
6
+
7
+ ## Platform support
8
+
9
+ - ✅ macOS
10
+ - ❌ Linux (not supported yet)
11
+ - ❌ Windows (not supported yet)
12
+
13
+ `bwx` relies on macOS Keychain for secure master-password storage and retrieval.
4
14
 
5
15
  ## Why
6
16
 
@@ -55,13 +65,15 @@ bwx doctor
55
65
  | `bwx get <field> <item>` | Get field (`password`, `username`, `totp`, `notes`, `uri`, `item`) |
56
66
  | `bwx field <item> <name>` | Get custom field by name |
57
67
  | `bwx search <query>` | Search items (`--type`, `--folder`, `--limit`) |
68
+ | `bwx attach list <item>` | List attachments for an item |
69
+ | `bwx attach get <item> <attachment>` | Download attachment by ID or filename |
58
70
 
59
71
  ### Write
60
72
 
61
73
  | Command | Description |
62
74
  |---------|-------------|
63
- | `bwx create` | Create item (`--type`, `--name`, `--username`, `--password`, `--uri`, `--field k=v`) |
64
- | `bwx edit <item>` | Edit item (`--name`, `--password`, `--add-field k=v`, `--rm-field name`) |
75
+ | `bwx create` | Create item (`--type`, `--name`, `--username`, `--password-stdin`, `--field-file k=path`) |
76
+ | `bwx edit <item>` | Edit item (`--name`, `--password-env`, `--add-field-file k=path`, `--rm-field name`) |
65
77
  | `bwx delete <item>` | Delete item (`--permanent`, `--force`) |
66
78
 
67
79
  ### Config
@@ -72,6 +84,35 @@ bwx doctor
72
84
  | `bwx config master-password` | Store master password in Keychain |
73
85
  | `bwx config list` / `bwx cfg ls` | Show config path + redacted content |
74
86
 
87
+ ### Attachments
88
+
89
+ ```bash
90
+ bwx attach list "Apple Developer"
91
+ bwx attach get "Apple Developer" "AuthKey.p8"
92
+ bwx attach get "Apple Developer" "AuthKey.p8" --output ./secrets/
93
+ ```
94
+
95
+ Aliases: `attach`, `attachment`, `attachments`; `list` also has `ls`.
96
+
97
+ ### Safe input
98
+
99
+ Prefer stdin, files, or environment variables for secrets so values do not land in shell history.
100
+
101
+ ```bash
102
+ printf '%s' "$TOKEN" | bwx create --type login --name "GitHub" --username user --password-stdin
103
+ bwx edit "GitHub" --password-env GITHUB_TOKEN
104
+ bwx create --name "Deploy key" --notes-file ./deploy-key.txt
105
+ bwx create --name "Acme" --field-file '!API Key=./token.txt' --field-env account=ACME_ACCOUNT
106
+ ```
107
+
108
+ Supported source flags:
109
+
110
+ - Passwords: `--password-stdin`, `--password-file <path>`, `--password-env <name>`
111
+ - Notes: `--notes-file <path>`
112
+ - Create fields: `--field-file k=path`, `--field-env k=ENV`
113
+ - Edit fields: `--add-field-file k=path`, `--add-field-env k=ENV`
114
+ - Use `-` as the file path to read that source from stdin.
115
+
75
116
  ### Global flags
76
117
 
77
118
  | Flag | Effect |
@@ -83,7 +124,7 @@ bwx doctor
83
124
 
84
125
  ## Custom fields
85
126
 
86
- Field syntax for `--field` / `--add-field`:
127
+ Field syntax for `--field`, `--field-file`, `--field-env`, and edit variants:
87
128
 
88
129
  - `key=value` — text field
89
130
  - `!key=value` — hidden field
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bwx",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "keywords": [
6
6
  "bitwarden",
@@ -0,0 +1,58 @@
1
+ import { CliError, ExitCode } from "../cli/errors.ts";
2
+
3
+ export interface AttachmentInfo {
4
+ id: string;
5
+ fileName: string;
6
+ size?: number;
7
+ sizeName?: string;
8
+ }
9
+
10
+ export function listAttachments(item: Record<string, unknown>): AttachmentInfo[] {
11
+ const raw = item.attachments;
12
+ if (!Array.isArray(raw)) return [];
13
+
14
+ return raw
15
+ .map((attachment) => normalizeAttachment(attachment))
16
+ .filter((attachment): attachment is AttachmentInfo => attachment !== null);
17
+ }
18
+
19
+ export function resolveAttachment(
20
+ attachments: AttachmentInfo[],
21
+ query: string,
22
+ ): AttachmentInfo {
23
+ const matches = attachments.filter(
24
+ (attachment) => attachment.id === query || attachment.fileName === query,
25
+ );
26
+
27
+ if (matches.length === 1) return matches[0]!;
28
+
29
+ if (matches.length > 1) {
30
+ throw new CliError(
31
+ `Multiple attachments match "${query}". Use an attachment ID instead.`,
32
+ ExitCode.BadArgs,
33
+ );
34
+ }
35
+
36
+ const available = attachments.map((attachment) => attachment.fileName).join(", ");
37
+ const suffix = available ? ` Available: ${available}` : "";
38
+ throw new CliError(`Attachment "${query}" not found.${suffix}`, ExitCode.NotFound);
39
+ }
40
+
41
+ function normalizeAttachment(value: unknown): AttachmentInfo | null {
42
+ if (!value || typeof value !== "object") return null;
43
+
44
+ const record = value as Record<string, unknown>;
45
+ if (typeof record.id !== "string" || typeof record.fileName !== "string") {
46
+ return null;
47
+ }
48
+
49
+ const attachment: AttachmentInfo = {
50
+ id: record.id,
51
+ fileName: record.fileName,
52
+ };
53
+
54
+ if (typeof record.size === "number") attachment.size = record.size;
55
+ if (typeof record.sizeName === "string") attachment.sizeName = record.sizeName;
56
+
57
+ return attachment;
58
+ }
@@ -0,0 +1,112 @@
1
+ import type { Command } from "commander";
2
+ import pc from "picocolors";
3
+ import { listAttachments, resolveAttachment } from "../../bw/attachments.ts";
4
+ import { runBwOrThrow } from "../../bw/runner.ts";
5
+ import { withSession } from "../../bw/session.ts";
6
+ import { emitData, emitSuccess } from "../io.ts";
7
+ import { getGlobalOpts } from "../program.ts";
8
+
9
+ export function registerAttach(program: Command): void {
10
+ const attach = program
11
+ .command("attach")
12
+ .aliases(["attachment", "attachments"])
13
+ .description("List and download item attachments");
14
+
15
+ attach
16
+ .command("list")
17
+ .alias("ls")
18
+ .description("List attachments for an item")
19
+ .argument("<item>", "Item name or ID")
20
+ .action(async function (this: Command, item: string) {
21
+ const opts = getGlobalOpts(this);
22
+ const current = await getItem(item, opts);
23
+ const attachments = listAttachments(current);
24
+
25
+ if (opts.json) {
26
+ emitData(attachments, opts);
27
+ return;
28
+ }
29
+
30
+ if (opts.plain) {
31
+ for (const attachment of attachments) {
32
+ process.stdout.write(
33
+ [
34
+ attachment.id,
35
+ attachment.fileName,
36
+ attachment.sizeName ?? attachment.size ?? "-",
37
+ ].join("\t") + "\n",
38
+ );
39
+ }
40
+ return;
41
+ }
42
+
43
+ if (attachments.length === 0) {
44
+ const itemName = typeof current.name === "string" ? current.name : item;
45
+ process.stdout.write(`No attachments on "${itemName}"\n`);
46
+ return;
47
+ }
48
+
49
+ for (const attachment of attachments) {
50
+ const size = attachment.sizeName ?? attachment.size;
51
+ const sizePart = size === undefined ? "" : pc.dim(` (${size})`);
52
+ process.stdout.write(
53
+ `${pc.dim(attachment.id)} ${attachment.fileName}${sizePart}\n`,
54
+ );
55
+ }
56
+ });
57
+
58
+ attach
59
+ .command("get")
60
+ .description("Download an attachment from an item")
61
+ .argument("<item>", "Item name or ID")
62
+ .argument("<attachment>", "Attachment ID or filename")
63
+ .option("-o, --output <path>", "Output file or directory")
64
+ .action(async function (
65
+ this: Command,
66
+ item: string,
67
+ attachmentQuery: string,
68
+ localOpts: { output?: string },
69
+ ) {
70
+ const opts = getGlobalOpts(this);
71
+ const current = await getItem(item, opts);
72
+ const attachments = listAttachments(current);
73
+ const attachment = resolveAttachment(attachments, attachmentQuery);
74
+ const itemId = String(current.id);
75
+ const output = localOpts.output ?? attachment.fileName;
76
+
77
+ await withSession(opts, () =>
78
+ runBwOrThrow([
79
+ "get",
80
+ "attachment",
81
+ attachment.id,
82
+ "--itemid",
83
+ itemId,
84
+ "--output",
85
+ output,
86
+ ]),
87
+ );
88
+
89
+ const result = {
90
+ itemId,
91
+ attachmentId: attachment.id,
92
+ fileName: attachment.fileName,
93
+ output,
94
+ };
95
+
96
+ if (opts.json) {
97
+ emitData(result, opts);
98
+ } else if (opts.plain) {
99
+ emitData(output, opts);
100
+ } else {
101
+ emitSuccess(`Saved "${attachment.fileName}" to ${output}`, opts);
102
+ }
103
+ });
104
+ }
105
+
106
+ async function getItem(
107
+ item: string,
108
+ opts: ReturnType<typeof getGlobalOpts>,
109
+ ): Promise<Record<string, unknown>> {
110
+ const json = await withSession(opts, () => runBwOrThrow(["get", "item", item]));
111
+ return JSON.parse(json) as Record<string, unknown>;
112
+ }
@@ -4,11 +4,29 @@ import { emitData, emitSuccess } from "../io.ts";
4
4
  import { CliError, ExitCode } from "../errors.ts";
5
5
  import { runBwOrThrow } from "../../bw/runner.ts";
6
6
  import { withSession } from "../../bw/session.ts";
7
- import { buildNewItem, parseFieldFlag, bwEncode } from "../../bw/encoding.ts";
7
+ import { buildNewItem, bwEncode } from "../../bw/encoding.ts";
8
+ import {
9
+ assertSingleStdinSource,
10
+ collect,
11
+ listExplicitStdinInputs,
12
+ readOptionalStdin,
13
+ resolveFieldSources,
14
+ resolveItemTextInputs,
15
+ StdinReader,
16
+ type ItemTextInputs,
17
+ } from "../input.ts";
8
18
 
9
- function collect(val: string, prev: string[]): string[] {
10
- prev.push(val);
11
- return prev;
19
+ interface CreateOptions extends ItemTextInputs {
20
+ type: string;
21
+ name?: string;
22
+ username?: string;
23
+ uri: string[];
24
+ field: string[];
25
+ fieldFile: string[];
26
+ fieldEnv: string[];
27
+ folder?: string;
28
+ favorite?: boolean;
29
+ fromJson?: boolean;
12
30
  }
13
31
 
14
32
  export function registerCreate(program: Command): void {
@@ -18,44 +36,40 @@ export function registerCreate(program: Command): void {
18
36
  .option("--type <type>", "Item type: login|note", "note")
19
37
  .option("--name <name>", "Item name")
20
38
  .option("--notes <text>", "Notes content")
39
+ .option("--notes-file <path>", "Read notes from file (use - for stdin)")
21
40
  .option("--username <user>", "Username (login type)")
22
41
  .option("--password <pass>", "Password (login type)")
42
+ .option("--password-stdin", "Read password from stdin")
43
+ .option("--password-file <path>", "Read password from file (use - for stdin)")
44
+ .option("--password-env <name>", "Read password from environment variable")
23
45
  .option("--uri <url>", "URI (repeatable)", collect, [])
24
46
  .option("--field <kv>", "Custom field k=v (repeatable)", collect, [])
47
+ .option("--field-file <kv>", "Custom field value from file k=path (repeatable)", collect, [])
48
+ .option("--field-env <kv>", "Custom field value from env var k=ENV (repeatable)", collect, [])
25
49
  .option("--folder <id>", "Folder ID")
26
50
  .option("--favorite", "Mark as favorite")
27
51
  .option("--from-json", "Read full item JSON from stdin")
28
52
  .action(async function (
29
53
  this: Command,
30
- localOpts: {
31
- type: string;
32
- name?: string;
33
- notes?: string;
34
- username?: string;
35
- password?: string;
36
- uri: string[];
37
- field: string[];
38
- folder?: string;
39
- favorite?: boolean;
40
- fromJson?: boolean;
41
- },
54
+ localOpts: CreateOptions,
42
55
  ) {
43
56
  const opts = getGlobalOpts(this);
57
+ const stdin = new StdinReader();
44
58
 
45
59
  let encoded: string;
46
60
 
47
61
  if (localOpts.fromJson) {
48
- const stdin = await readStdin();
49
- if (!stdin) {
62
+ const stdinText = await stdin.read();
63
+ if (!stdinText) {
50
64
  throw new CliError("No JSON provided on stdin", ExitCode.BadArgs);
51
65
  }
52
66
  // Validate it's JSON
53
67
  try {
54
- JSON.parse(stdin);
68
+ JSON.parse(stdinText);
55
69
  } catch {
56
70
  throw new CliError("Invalid JSON on stdin", ExitCode.BadArgs);
57
71
  }
58
- encoded = bwEncode(stdin);
72
+ encoded = bwEncode(stdinText);
59
73
  } else {
60
74
  if (!localOpts.name) {
61
75
  throw new CliError("--name is required", ExitCode.BadArgs);
@@ -68,33 +82,41 @@ export function registerCreate(program: Command): void {
68
82
  );
69
83
  }
70
84
 
71
- // Read notes from stdin if not provided and not a TTY
72
- let notes = localOpts.notes ?? null;
73
- if (notes === null && !process.stdin.isTTY) {
74
- const stdinText = await readStdin();
75
- if (stdinText) notes = stdinText;
76
- }
85
+ const explicitStdinSources = listExplicitStdinInputs({
86
+ ...localOpts,
87
+ fieldFileFlag: "--field-file",
88
+ fieldFiles: localOpts.fieldFile,
89
+ });
90
+ assertSingleStdinSource(explicitStdinSources);
91
+
92
+ const textInputs = await resolveItemTextInputs(localOpts, stdin);
93
+ let notes = textInputs.notes;
77
94
 
78
- // Read password from stdin if not provided, login type, and not a TTY
79
- let password = localOpts.password ?? null;
95
+ // Backward compatibility: piped create input becomes note content.
80
96
  if (
81
- password === null &&
82
- localOpts.type === "login" &&
83
- !process.stdin.isTTY &&
84
- notes === null
97
+ notes === undefined &&
98
+ textInputs.password === undefined &&
99
+ explicitStdinSources.length === 0 &&
100
+ !process.stdin.isTTY
85
101
  ) {
86
- const stdinText = await readStdin();
87
- if (stdinText) password = stdinText;
102
+ notes = (await readOptionalStdin(stdin)) ?? undefined;
88
103
  }
89
104
 
90
- const fields = localOpts.field.map(parseFieldFlag);
105
+ const fields = await resolveFieldSources({
106
+ inline: localOpts.field,
107
+ file: localOpts.fieldFile,
108
+ env: localOpts.fieldEnv,
109
+ fileFlag: "--field-file",
110
+ envFlag: "--field-env",
111
+ stdin,
112
+ });
91
113
 
92
114
  const item = buildNewItem({
93
115
  type: localOpts.type,
94
116
  name: localOpts.name,
95
- notes,
117
+ notes: notes ?? null,
96
118
  username: localOpts.username ?? null,
97
- password,
119
+ password: textInputs.password ?? null,
98
120
  uris: localOpts.uri,
99
121
  fields,
100
122
  folderId: localOpts.folder ?? null,
@@ -118,16 +140,3 @@ export function registerCreate(program: Command): void {
118
140
  }
119
141
  });
120
142
  }
121
-
122
- async function readStdin(): Promise<string | null> {
123
- try {
124
- const chunks: Uint8Array[] = [];
125
- for await (const chunk of Bun.stdin.stream()) {
126
- chunks.push(chunk);
127
- }
128
- const text = Buffer.concat(chunks).toString().trim();
129
- return text || null;
130
- } catch {
131
- return null;
132
- }
133
- }
@@ -4,11 +4,28 @@ import { emitData, emitSuccess } from "../io.ts";
4
4
  import { CliError, ExitCode } from "../errors.ts";
5
5
  import { runBwOrThrow } from "../../bw/runner.ts";
6
6
  import { withSession } from "../../bw/session.ts";
7
- import { patchItem, parseFieldFlag, bwEncode } from "../../bw/encoding.ts";
7
+ import { patchItem, bwEncode } from "../../bw/encoding.ts";
8
+ import {
9
+ assertSingleStdinSource,
10
+ collect,
11
+ listExplicitStdinInputs,
12
+ resolveFieldSources,
13
+ resolveItemTextInputs,
14
+ StdinReader,
15
+ type ItemTextInputs,
16
+ } from "../input.ts";
8
17
 
9
- function collect(val: string, prev: string[]): string[] {
10
- prev.push(val);
11
- return prev;
18
+ interface EditOptions extends ItemTextInputs {
19
+ name?: string;
20
+ username?: string;
21
+ uri: string[];
22
+ addField: string[];
23
+ addFieldFile: string[];
24
+ addFieldEnv: string[];
25
+ rmField: string[];
26
+ folder?: string;
27
+ favorite?: boolean;
28
+ fromJson?: boolean;
12
29
  }
13
30
 
14
31
  export function registerEdit(program: Command): void {
@@ -18,10 +35,16 @@ export function registerEdit(program: Command): void {
18
35
  .argument("<item>", "Item name or ID")
19
36
  .option("--name <name>", "New name")
20
37
  .option("--notes <text>", "New notes")
38
+ .option("--notes-file <path>", "Read notes from file (use - for stdin)")
21
39
  .option("--username <user>", "New username")
22
40
  .option("--password <pass>", "New password")
41
+ .option("--password-stdin", "Read password from stdin")
42
+ .option("--password-file <path>", "Read password from file (use - for stdin)")
43
+ .option("--password-env <name>", "Read password from environment variable")
23
44
  .option("--uri <url>", "Set URIs (repeatable, replaces all)", collect, [])
24
45
  .option("--add-field <kv>", "Add/update field k=v", collect, [])
46
+ .option("--add-field-file <kv>", "Add/update field value from file k=path", collect, [])
47
+ .option("--add-field-env <kv>", "Add/update field value from env var k=ENV", collect, [])
25
48
  .option("--rm-field <name>", "Remove field by name", collect, [])
26
49
  .option("--folder <id>", "Move to folder")
27
50
  .option("--favorite", "Set favorite")
@@ -30,20 +53,10 @@ export function registerEdit(program: Command): void {
30
53
  .action(async function (
31
54
  this: Command,
32
55
  item: string,
33
- localOpts: {
34
- name?: string;
35
- notes?: string;
36
- username?: string;
37
- password?: string;
38
- uri: string[];
39
- addField: string[];
40
- rmField: string[];
41
- folder?: string;
42
- favorite?: boolean;
43
- fromJson?: boolean;
44
- },
56
+ localOpts: EditOptions,
45
57
  ) {
46
58
  const opts = getGlobalOpts(this);
59
+ const stdin = new StdinReader();
47
60
 
48
61
  const result = await withSession(opts, async () => {
49
62
  // Fetch current item
@@ -54,23 +67,38 @@ export function registerEdit(program: Command): void {
54
67
  let encoded: string;
55
68
 
56
69
  if (localOpts.fromJson) {
57
- const chunks: Uint8Array[] = [];
58
- for await (const chunk of Bun.stdin.stream()) {
59
- chunks.push(chunk);
60
- }
61
- const stdinText = Buffer.concat(chunks).toString().trim();
70
+ const stdinText = await stdin.read();
62
71
  if (!stdinText) {
63
72
  throw new CliError("No JSON provided on stdin", ExitCode.BadArgs);
64
73
  }
65
74
  encoded = bwEncode(stdinText);
66
75
  } else {
76
+ assertSingleStdinSource(
77
+ listExplicitStdinInputs({
78
+ ...localOpts,
79
+ fieldFileFlag: "--add-field-file",
80
+ fieldFiles: localOpts.addFieldFile,
81
+ }),
82
+ );
83
+
84
+ const textInputs = await resolveItemTextInputs(localOpts, stdin);
85
+
86
+ const addFields = await resolveFieldSources({
87
+ inline: localOpts.addField,
88
+ file: localOpts.addFieldFile,
89
+ env: localOpts.addFieldEnv,
90
+ fileFlag: "--add-field-file",
91
+ envFlag: "--add-field-env",
92
+ stdin,
93
+ });
94
+
67
95
  const patched = patchItem(current, {
68
96
  name: localOpts.name,
69
- notes: localOpts.notes,
97
+ notes: textInputs.notes,
70
98
  username: localOpts.username,
71
- password: localOpts.password,
99
+ password: textInputs.password,
72
100
  uris: localOpts.uri.length > 0 ? localOpts.uri : undefined,
73
- addFields: localOpts.addField.map(parseFieldFlag),
101
+ addFields,
74
102
  rmFields: localOpts.rmField,
75
103
  folderId: localOpts.folder,
76
104
  favorite: localOpts.favorite,
@@ -24,6 +24,16 @@ export function registerGet(program: Command): void {
24
24
  await getCustomField(field, item, opts);
25
25
  }
26
26
  });
27
+
28
+ program
29
+ .command("field")
30
+ .description("Get a custom field from a vault item")
31
+ .argument("<item>", "Item name or ID")
32
+ .argument("<name>", "Custom field name")
33
+ .action(async function (this: Command, item: string, name: string) {
34
+ const opts = getGlobalOpts(this);
35
+ await getCustomField(name, item, opts);
36
+ });
27
37
  }
28
38
 
29
39
  async function getBuiltinField(
@@ -0,0 +1,248 @@
1
+ import { parseFieldFlag, type ParsedField } from "../bw/encoding.ts";
2
+ import { CliError, ExitCode } from "./errors.ts";
3
+
4
+ export function collect(val: string, prev: string[]): string[] {
5
+ prev.push(val);
6
+ return prev;
7
+ }
8
+
9
+ /**
10
+ * Lazily reads stdin once. Commands validate that only one explicit stdin source
11
+ * is selected, but caching also keeps implicit legacy stdin reads predictable.
12
+ */
13
+ export class StdinReader {
14
+ private textPromise: Promise<string> | null = null;
15
+
16
+ read(): Promise<string> {
17
+ this.textPromise ??= readStdinText();
18
+ return this.textPromise;
19
+ }
20
+ }
21
+
22
+ export interface TextSource {
23
+ label: string;
24
+ inline?: string;
25
+ stdin?: boolean;
26
+ file?: string;
27
+ env?: string;
28
+ flagNames: string[];
29
+ allowEmpty?: boolean;
30
+ }
31
+
32
+ export interface FieldSources {
33
+ inline: string[];
34
+ file: string[];
35
+ env: string[];
36
+ fileFlag: string;
37
+ envFlag: string;
38
+ stdin: StdinReader;
39
+ }
40
+
41
+ export interface ItemTextInputs {
42
+ notes?: string;
43
+ notesFile?: string;
44
+ password?: string;
45
+ passwordStdin?: boolean;
46
+ passwordFile?: string;
47
+ passwordEnv?: string;
48
+ }
49
+
50
+ export interface ResolvedItemTextInputs {
51
+ notes?: string;
52
+ password?: string;
53
+ }
54
+
55
+ export interface ExplicitStdinInputs extends ItemTextInputs {
56
+ fieldFileFlag?: string;
57
+ fieldFiles?: string[];
58
+ }
59
+
60
+ const NOTE_SOURCE_FLAGS = ["--notes", "--notes-file"];
61
+ const PASSWORD_SOURCE_FLAGS = [
62
+ "--password",
63
+ "--password-stdin",
64
+ "--password-file",
65
+ "--password-env",
66
+ ];
67
+
68
+ export async function resolveItemTextInputs(
69
+ inputs: ItemTextInputs,
70
+ stdin: StdinReader,
71
+ ): Promise<ResolvedItemTextInputs> {
72
+ assertSingleStdinSource(listExplicitStdinInputs(inputs));
73
+
74
+ const [notes, password] = await Promise.all([
75
+ resolveOptionalTextSource(
76
+ {
77
+ label: "notes",
78
+ inline: inputs.notes,
79
+ file: inputs.notesFile,
80
+ flagNames: NOTE_SOURCE_FLAGS,
81
+ allowEmpty: true,
82
+ },
83
+ stdin,
84
+ ),
85
+ resolveOptionalTextSource(
86
+ {
87
+ label: "password",
88
+ inline: inputs.password,
89
+ stdin: inputs.passwordStdin,
90
+ file: inputs.passwordFile,
91
+ env: inputs.passwordEnv,
92
+ flagNames: PASSWORD_SOURCE_FLAGS,
93
+ },
94
+ stdin,
95
+ ),
96
+ ]);
97
+
98
+ return { notes, password };
99
+ }
100
+
101
+ export function listExplicitStdinInputs(inputs: ExplicitStdinInputs): string[] {
102
+ return [
103
+ inputs.notesFile === "-" ? "--notes-file -" : null,
104
+ inputs.passwordStdin ? "--password-stdin" : null,
105
+ inputs.passwordFile === "-" ? "--password-file -" : null,
106
+ ...(inputs.fieldFileFlag && inputs.fieldFiles
107
+ ? stdinSourcesForFieldFiles(inputs.fieldFileFlag, inputs.fieldFiles)
108
+ : []),
109
+ ].filter((value): value is string => value !== null);
110
+ }
111
+
112
+ export async function resolveOptionalTextSource(
113
+ source: TextSource,
114
+ stdin: StdinReader,
115
+ ): Promise<string | undefined> {
116
+ const selected = [
117
+ source.inline !== undefined ? "inline" : null,
118
+ source.stdin ? "stdin" : null,
119
+ source.file !== undefined ? "file" : null,
120
+ source.env !== undefined ? "env" : null,
121
+ ].filter((value): value is string => value !== null);
122
+
123
+ if (selected.length > 1) {
124
+ throw new CliError(
125
+ `Choose only one ${source.label} source: ${source.flagNames.join(", ")}`,
126
+ ExitCode.BadArgs,
127
+ );
128
+ }
129
+
130
+ if (selected.length === 0) return undefined;
131
+
132
+ let value: string;
133
+ if (source.inline !== undefined) {
134
+ value = source.inline;
135
+ } else if (source.stdin) {
136
+ value = await stdin.read();
137
+ } else if (source.file !== undefined) {
138
+ value = await readFileOrStdin(source.file, stdin, source.label);
139
+ } else {
140
+ value = readEnvValue(source.env!, source.label);
141
+ }
142
+
143
+ if (!source.allowEmpty && value.length === 0) {
144
+ throw new CliError(`${capitalize(source.label)} cannot be empty`, ExitCode.BadArgs);
145
+ }
146
+
147
+ return value;
148
+ }
149
+
150
+ export async function resolveFieldSources(
151
+ sources: FieldSources,
152
+ ): Promise<ParsedField[]> {
153
+ const fields = sources.inline.map(parseFieldFlag);
154
+
155
+ for (const raw of sources.file) {
156
+ const field = parseRequiredSourceField(raw, sources.fileFlag);
157
+ fields.push({
158
+ ...field,
159
+ value: await readFileOrStdin(field.value, sources.stdin, field.name),
160
+ });
161
+ }
162
+
163
+ for (const raw of sources.env) {
164
+ const field = parseRequiredSourceField(raw, sources.envFlag);
165
+ fields.push({
166
+ ...field,
167
+ value: readEnvValue(field.value, field.name),
168
+ });
169
+ }
170
+
171
+ return fields;
172
+ }
173
+
174
+ export function assertSingleStdinSource(sources: string[]): void {
175
+ if (sources.length <= 1) return;
176
+ throw new CliError(
177
+ `Only one stdin input source can be used at a time: ${sources.join(", ")}`,
178
+ ExitCode.BadArgs,
179
+ );
180
+ }
181
+
182
+ export function stdinSourcesForFieldFiles(flag: string, raws: string[]): string[] {
183
+ return raws
184
+ .filter((raw) => parseRequiredSourceField(raw, flag).value === "-")
185
+ .map((raw) => `${flag} ${raw}`);
186
+ }
187
+
188
+ export async function readOptionalStdin(stdin: StdinReader): Promise<string | null> {
189
+ const text = await stdin.read();
190
+ return text.length > 0 ? text : null;
191
+ }
192
+
193
+ function parseRequiredSourceField(raw: string, flag: string): ParsedField {
194
+ if (!raw.includes("=")) {
195
+ throw new CliError(`Expected ${flag} k=value`, ExitCode.BadArgs);
196
+ }
197
+
198
+ const field = parseFieldFlag(raw);
199
+ if (field.value.length === 0) {
200
+ throw new CliError(`Expected ${flag} ${field.name}=value`, ExitCode.BadArgs);
201
+ }
202
+
203
+ return field;
204
+ }
205
+
206
+ async function readFileOrStdin(
207
+ path: string,
208
+ stdin: StdinReader,
209
+ label: string,
210
+ ): Promise<string> {
211
+ if (path === "-") return stdin.read();
212
+
213
+ try {
214
+ return stripFinalNewline(await Bun.file(path).text());
215
+ } catch (err) {
216
+ const message = err instanceof Error ? err.message : String(err);
217
+ throw new CliError(
218
+ `Failed to read ${label} from "${path}": ${message}`,
219
+ ExitCode.BadArgs,
220
+ );
221
+ }
222
+ }
223
+
224
+ function readEnvValue(name: string, label: string): string {
225
+ if (!Object.prototype.hasOwnProperty.call(process.env, name)) {
226
+ throw new CliError(
227
+ `Environment variable ${name} is not set for ${label}`,
228
+ ExitCode.BadArgs,
229
+ );
230
+ }
231
+ return process.env[name] ?? "";
232
+ }
233
+
234
+ async function readStdinText(): Promise<string> {
235
+ const chunks: Uint8Array[] = [];
236
+ for await (const chunk of Bun.stdin.stream()) {
237
+ chunks.push(chunk);
238
+ }
239
+ return stripFinalNewline(Buffer.concat(chunks).toString());
240
+ }
241
+
242
+ function stripFinalNewline(text: string): string {
243
+ return text.replace(/\r?\n$/, "");
244
+ }
245
+
246
+ function capitalize(value: string): string {
247
+ return value.charAt(0).toUpperCase() + value.slice(1);
248
+ }
@@ -12,6 +12,7 @@ import { registerLock } from "./commands/lock.ts";
12
12
  import { registerConfig } from "./commands/config.ts";
13
13
  import { registerDoctor } from "./commands/doctor.ts";
14
14
  import { registerList } from "./commands/list.ts";
15
+ import { registerAttach } from "./commands/attach.ts";
15
16
 
16
17
  export function buildProgram(): Command {
17
18
  const program = new Command();
@@ -19,7 +20,7 @@ export function buildProgram(): Command {
19
20
  program
20
21
  .name("bwx")
21
22
  .description("Bitwarden Extended CLI")
22
- .version("0.2.2")
23
+ .version("0.3.0")
23
24
  .option("--json", "JSON output")
24
25
  .option("--plain", "Plain parseable output")
25
26
  .option("-q, --quiet", "Suppress log output")
@@ -39,6 +40,7 @@ export function buildProgram(): Command {
39
40
  registerCreate(program);
40
41
  registerEdit(program);
41
42
  registerDelete(program);
43
+ registerAttach(program);
42
44
  registerList(program);
43
45
  registerConfig(program);
44
46
  registerDoctor(program);
package/src/index.ts CHANGED
@@ -14,19 +14,7 @@ export async function main(argv: string[]): Promise<void> {
14
14
  }
15
15
 
16
16
  // Parse global opts early for error formatting
17
- let opts: GlobalOptions = {};
18
- try {
19
- program.parseOptions(argv.slice(2));
20
- const rawOpts = program.opts();
21
- opts = {
22
- json: rawOpts.json ?? false,
23
- plain: rawOpts.plain ?? false,
24
- quiet: rawOpts.quiet ?? false,
25
- verbose: rawOpts.verbose ?? false,
26
- };
27
- } catch {
28
- // Fall through — full parse below will handle
29
- }
17
+ const opts = parseGlobalOpts(argv.slice(2));
30
18
 
31
19
  try {
32
20
  await program.parseAsync(argv);
@@ -56,3 +44,12 @@ export async function main(argv: string[]): Promise<void> {
56
44
  handleFatalError(err, opts);
57
45
  }
58
46
  }
47
+
48
+ function parseGlobalOpts(args: string[]): GlobalOptions {
49
+ return {
50
+ json: args.includes("--json"),
51
+ plain: args.includes("--plain"),
52
+ quiet: args.includes("-q") || args.includes("--quiet"),
53
+ verbose: args.includes("-v") || args.includes("--verbose"),
54
+ };
55
+ }