bwx 0.1.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 ADDED
@@ -0,0 +1,101 @@
1
+ # bwx
2
+
3
+ Bitwarden CLI wrapper for AI agents. Auto-unlock, session caching, structured output.
4
+
5
+ ## Why
6
+
7
+ The native `bw` CLI requires interactive prompts and manual session management. `bwx` wraps it with:
8
+
9
+ - **Auto-unlock** — first vault command authenticates automatically via macOS Keychain
10
+ - **Session caching** — token persisted to `~/.config/bwx/session` (mode 600), reused across calls
11
+ - **Structured output** — `--json` and `--plain` flags for machine-parseable output
12
+ - **No interaction** — credentials from config + Keychain, never prompts
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ bun install # or npm install
18
+ bun link # makes `bwx` available globally
19
+ ```
20
+
21
+ Requires [Bitwarden CLI](https://bitwarden.com/help/cli/) (`bw`) installed and on PATH.
22
+
23
+ ## Setup
24
+
25
+ ```bash
26
+ # Store your Bitwarden account email
27
+ bwx config email your@email.com
28
+
29
+ # Store master password in macOS Keychain
30
+ bwx config master-password
31
+ ```
32
+
33
+ ## Commands
34
+
35
+ ### Vault
36
+
37
+ | Command | Description |
38
+ |---------|-------------|
39
+ | `bwx status` | Show vault state |
40
+ | `bwx unlock` | Unlock vault & cache session |
41
+ | `bwx lock` | Lock vault & clear session |
42
+ | `bwx sync` | Sync vault from server |
43
+
44
+ ### Read
45
+
46
+ | Command | Description |
47
+ |---------|-------------|
48
+ | `bwx get <field> <item>` | Get field (`password`, `username`, `totp`, `notes`, `uri`, `item`) |
49
+ | `bwx field <item> <name>` | Get custom field by name |
50
+ | `bwx search <query>` | Search items (`--type`, `--folder`, `--limit`) |
51
+
52
+ ### Write
53
+
54
+ | Command | Description |
55
+ |---------|-------------|
56
+ | `bwx create` | Create item (`--type`, `--name`, `--username`, `--password`, `--uri`, `--field k=v`) |
57
+ | `bwx edit <item>` | Edit item (`--name`, `--password`, `--add-field k=v`, `--rm-field name`) |
58
+ | `bwx delete <item>` | Delete item (`--permanent`, `--force`) |
59
+
60
+ ### Config
61
+
62
+ | Command | Description |
63
+ |---------|-------------|
64
+ | `bwx config email [addr]` | Set account email |
65
+ | `bwx config master-password` | Store master password in Keychain |
66
+
67
+ ### Global flags
68
+
69
+ | Flag | Effect |
70
+ |------|--------|
71
+ | `--json` | JSON output: `{ "data": ... }` / `{ "error": ... }` |
72
+ | `--plain` | Tab-separated, one item per line |
73
+ | `-q, --quiet` | Suppress logs |
74
+ | `-v, --verbose` | Include stack traces |
75
+
76
+ ## Custom fields
77
+
78
+ Field syntax for `--field` / `--add-field`:
79
+
80
+ - `key=value` — text field
81
+ - `!key=value` — hidden field
82
+ - `bool:key=value` — boolean field
83
+
84
+ ## Exit codes
85
+
86
+ | Code | Meaning |
87
+ |------|---------|
88
+ | 0 | OK |
89
+ | 1 | Unknown error |
90
+ | 2 | Bad arguments |
91
+ | 3 | Auth failed |
92
+ | 4 | Not found |
93
+ | 5 | bw error |
94
+ | 6 | Network error |
95
+ | 7 | Config error |
96
+ | 8 | Timeout |
97
+ | 9 | User cancelled |
98
+
99
+ ## Stack
100
+
101
+ Bun, TypeScript, Commander, Zod, picocolors.
package/bin/bwx ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env bun
2
+ import { main } from "../src/index.ts";
3
+ await main(process.argv);
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "bwx",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "files": [
6
+ "bin",
7
+ "src"
8
+ ],
9
+ "bin": {
10
+ "bwx": "bin/bwx"
11
+ },
12
+ "scripts": {
13
+ "typecheck": "tsc -p tsconfig.json --noEmit",
14
+ "test": "bun test",
15
+ "cmd": "bun run bin/bwx"
16
+ },
17
+ "devDependencies": {
18
+ "@types/bun": "latest"
19
+ },
20
+ "peerDependencies": {
21
+ "typescript": "^5"
22
+ },
23
+ "dependencies": {
24
+ "commander": "^14.0.3",
25
+ "picocolors": "^1.1.1",
26
+ "zod": "^4.3.6"
27
+ }
28
+ }
@@ -0,0 +1,152 @@
1
+ import type { BwField, BwFieldType, BwItem, BwItemType, BwUri } from "./types.ts";
2
+ import { BwFieldType as FT, BwItemType as IT } from "./types.ts";
3
+
4
+ // --- Field flag parsing ---
5
+ // k=v → Text field
6
+ // !k=v → Hidden field
7
+ // bool:k=v → Boolean field
8
+
9
+ export interface ParsedField {
10
+ name: string;
11
+ value: string;
12
+ type: BwFieldType;
13
+ }
14
+
15
+ export function parseFieldFlag(raw: string): ParsedField {
16
+ if (raw.startsWith("bool:")) {
17
+ const rest = raw.slice(5);
18
+ const eq = rest.indexOf("=");
19
+ if (eq === -1) return { name: rest, value: "true", type: FT.Boolean };
20
+ return { name: rest.slice(0, eq), value: rest.slice(eq + 1), type: FT.Boolean };
21
+ }
22
+
23
+ if (raw.startsWith("!")) {
24
+ const rest = raw.slice(1);
25
+ const eq = rest.indexOf("=");
26
+ if (eq === -1) return { name: rest, value: "", type: FT.Hidden };
27
+ return { name: rest.slice(0, eq), value: rest.slice(eq + 1), type: FT.Hidden };
28
+ }
29
+
30
+ const eq = raw.indexOf("=");
31
+ if (eq === -1) return { name: raw, value: "", type: FT.Text };
32
+ return { name: raw.slice(0, eq), value: raw.slice(eq + 1), type: FT.Text };
33
+ }
34
+
35
+ // --- Build new item JSON ---
36
+
37
+ export interface NewItemOptions {
38
+ type?: "login" | "note";
39
+ name: string;
40
+ notes?: string | null;
41
+ username?: string | null;
42
+ password?: string | null;
43
+ uris?: string[];
44
+ fields?: ParsedField[];
45
+ folderId?: string | null;
46
+ favorite?: boolean;
47
+ }
48
+
49
+ export function buildNewItem(options: NewItemOptions): Record<string, unknown> {
50
+ const isLogin = options.type === "login";
51
+ const itemType: BwItemType = isLogin ? IT.Login : IT.SecureNote;
52
+
53
+ const item: Record<string, unknown> = {
54
+ organizationId: null,
55
+ collectionIds: null,
56
+ folderId: options.folderId ?? null,
57
+ type: itemType,
58
+ name: options.name,
59
+ notes: options.notes ?? null,
60
+ favorite: options.favorite ?? false,
61
+ reprompt: 0,
62
+ };
63
+
64
+ if (isLogin) {
65
+ item.login = {
66
+ username: options.username ?? null,
67
+ password: options.password ?? null,
68
+ totp: null,
69
+ uris: options.uris?.length
70
+ ? options.uris.map((uri) => ({ match: null, uri }))
71
+ : null,
72
+ };
73
+ } else {
74
+ item.secureNote = { type: 0 };
75
+ item.login = null;
76
+ }
77
+
78
+ if (options.fields?.length) {
79
+ item.fields = options.fields.map((f) => ({
80
+ name: f.name,
81
+ value: f.value,
82
+ type: f.type,
83
+ }));
84
+ } else {
85
+ item.fields = null;
86
+ }
87
+
88
+ return item;
89
+ }
90
+
91
+ // --- Patch existing item ---
92
+
93
+ export interface PatchOptions {
94
+ name?: string;
95
+ notes?: string;
96
+ username?: string;
97
+ password?: string;
98
+ uris?: string[];
99
+ addFields?: ParsedField[];
100
+ rmFields?: string[];
101
+ folderId?: string;
102
+ favorite?: boolean;
103
+ }
104
+
105
+ export function patchItem(
106
+ item: Record<string, unknown>,
107
+ patch: PatchOptions,
108
+ ): Record<string, unknown> {
109
+ const result = { ...item };
110
+
111
+ if (patch.name !== undefined) result.name = patch.name;
112
+ if (patch.notes !== undefined) result.notes = patch.notes;
113
+ if (patch.folderId !== undefined) result.folderId = patch.folderId;
114
+ if (patch.favorite !== undefined) result.favorite = patch.favorite;
115
+
116
+ // Login fields
117
+ if (patch.username !== undefined || patch.password !== undefined || patch.uris !== undefined) {
118
+ const login = (result.login as Record<string, unknown>) ?? {};
119
+ if (patch.username !== undefined) login.username = patch.username;
120
+ if (patch.password !== undefined) login.password = patch.password;
121
+ if (patch.uris !== undefined) {
122
+ login.uris = patch.uris.map((uri) => ({ match: null, uri }));
123
+ }
124
+ result.login = login;
125
+ }
126
+
127
+ // Fields: remove then add
128
+ let fields = Array.isArray(result.fields) ? [...(result.fields as BwField[])] : [];
129
+ if (patch.rmFields?.length) {
130
+ const removeSet = new Set(patch.rmFields);
131
+ fields = fields.filter((f) => !removeSet.has(f.name));
132
+ }
133
+ if (patch.addFields?.length) {
134
+ for (const f of patch.addFields) {
135
+ const idx = fields.findIndex((e) => e.name === f.name);
136
+ if (idx >= 0) {
137
+ fields[idx] = { name: f.name, value: f.value, type: f.type };
138
+ } else {
139
+ fields.push({ name: f.name, value: f.value, type: f.type });
140
+ }
141
+ }
142
+ }
143
+ result.fields = fields.length > 0 ? fields : null;
144
+
145
+ return result;
146
+ }
147
+
148
+ // --- Base64 encoding (replaces `bw encode`) ---
149
+
150
+ export function bwEncode(input: string): string {
151
+ return Buffer.from(input).toString("base64");
152
+ }
@@ -0,0 +1,60 @@
1
+ import { CliError, ExitCode, classifyBwError } from "../cli/errors.ts";
2
+
3
+ export interface BwResult {
4
+ stdout: string;
5
+ stderr: string;
6
+ exitCode: number;
7
+ }
8
+
9
+ let sessionToken: string | null = null;
10
+
11
+ export function setSession(token: string | null): void {
12
+ sessionToken = token;
13
+ }
14
+
15
+ export function getSession(): string | null {
16
+ return sessionToken;
17
+ }
18
+
19
+ export async function runBw(
20
+ args: string[],
21
+ options?: { session?: string | null; env?: Record<string, string> },
22
+ ): Promise<BwResult> {
23
+ const session = options?.session ?? sessionToken;
24
+ const fullArgs = session ? [...args, "--session", session] : args;
25
+
26
+ const proc = Bun.spawn(["bw", ...fullArgs], {
27
+ stdout: "pipe",
28
+ stderr: "pipe",
29
+ stdin: "ignore",
30
+ env: options?.env ? { ...process.env, ...options.env } : undefined,
31
+ });
32
+
33
+ const [stdout, stderr] = await Promise.all([
34
+ new Response(proc.stdout).text(),
35
+ new Response(proc.stderr).text(),
36
+ ]);
37
+
38
+ const exitCode = await proc.exited;
39
+
40
+ return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode };
41
+ }
42
+
43
+ export async function runBwOrThrow(
44
+ args: string[],
45
+ options?: { session?: string | null; env?: Record<string, string> },
46
+ ): Promise<string> {
47
+ const result = await runBw(args, options);
48
+ if (result.exitCode !== 0) {
49
+ const code = classifyBwError(result.stderr);
50
+ throw new CliError(
51
+ result.stderr || `bw ${args[0]} failed (exit ${result.exitCode})`,
52
+ code,
53
+ );
54
+ }
55
+ return result.stdout;
56
+ }
57
+
58
+ export function isAuthError(result: BwResult): boolean {
59
+ return /not logged in|vault is locked|unauthenticated/i.test(result.stderr);
60
+ }
@@ -0,0 +1,187 @@
1
+ import { CliError, ExitCode } from "../cli/errors.ts";
2
+ import type { GlobalOptions } from "../cli/types.ts";
3
+ import { emitLog } from "../cli/io.ts";
4
+ import {
5
+ runBw,
6
+ runBwOrThrow,
7
+ setSession,
8
+ getSession,
9
+ isAuthError,
10
+ type BwResult,
11
+ } from "./runner.ts";
12
+ import { BwStatusSchema, type BwStatus } from "./types.ts";
13
+ import { loadConfig } from "../config/store.ts";
14
+
15
+ const SESSION_PATH = `${process.env.HOME}/.config/bwx/session`;
16
+
17
+ async function loadCachedSession(): Promise<string | null> {
18
+ try {
19
+ const token = await Bun.file(SESSION_PATH).text();
20
+ return token.trim() || null;
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ async function saveCachedSession(token: string): Promise<void> {
27
+ const dir = SESSION_PATH.replace(/\/[^/]+$/, "");
28
+ const { mkdirSync } = await import("node:fs");
29
+ mkdirSync(dir, { recursive: true });
30
+ await Bun.write(SESSION_PATH, token);
31
+ const proc = Bun.spawn(["chmod", "600", SESSION_PATH]);
32
+ await proc.exited;
33
+ }
34
+
35
+ async function clearCachedSession(): Promise<void> {
36
+ try {
37
+ const { unlinkSync } = await import("node:fs");
38
+ unlinkSync(SESSION_PATH);
39
+ } catch {
40
+ // Already gone
41
+ }
42
+ }
43
+
44
+ export async function getStatus(): Promise<BwStatus> {
45
+ const result = await runBw(["status"], { session: null });
46
+ try {
47
+ return BwStatusSchema.parse(JSON.parse(result.stdout));
48
+ } catch {
49
+ throw new CliError(
50
+ `Failed to parse bw status: ${result.stdout}`,
51
+ ExitCode.BwError,
52
+ );
53
+ }
54
+ }
55
+
56
+ async function getMasterPassword(): Promise<string> {
57
+ const proc = Bun.spawn(
58
+ [
59
+ "security",
60
+ "find-generic-password",
61
+ "-a",
62
+ "bitwarden",
63
+ "-s",
64
+ "bitwarden-master",
65
+ "-w",
66
+ ],
67
+ { stdout: "pipe", stderr: "pipe" },
68
+ );
69
+ const pw = (await new Response(proc.stdout).text()).trim();
70
+ const exitCode = await proc.exited;
71
+ if (exitCode !== 0 || !pw) {
72
+ throw new CliError(
73
+ "Master password not found in Keychain",
74
+ ExitCode.AuthFailed,
75
+ );
76
+ }
77
+ return pw;
78
+ }
79
+
80
+ export async function ensureUnlocked(
81
+ opts: GlobalOptions,
82
+ ): Promise<void> {
83
+ // Try cached session first
84
+ const cached = getSession() || (await loadCachedSession());
85
+ if (cached) {
86
+ setSession(cached);
87
+ // Quick check — try a lightweight command
88
+ const check = await runBw(["status"]);
89
+ if (check.exitCode === 0) {
90
+ try {
91
+ const status = BwStatusSchema.parse(JSON.parse(check.stdout));
92
+ if (status.status === "unlocked") return;
93
+ } catch {
94
+ // Fall through
95
+ }
96
+ }
97
+ }
98
+
99
+ const status = await getStatus();
100
+
101
+ if (status.status === "unlocked") {
102
+ return;
103
+ }
104
+
105
+ if (status.status === "unauthenticated") {
106
+ const config = await loadConfig();
107
+ if (!config.email) {
108
+ throw new CliError(
109
+ "Email not configured. Run: bwx config email",
110
+ ExitCode.AuthFailed,
111
+ );
112
+ }
113
+
114
+ const pw = await getMasterPassword();
115
+ emitLog("Logging in...", opts);
116
+ const loginResult = await runBw(
117
+ [
118
+ "login",
119
+ config.email,
120
+ "--passwordenv",
121
+ "BW_MASTER_PW",
122
+ "--quiet",
123
+ ],
124
+ {
125
+ session: null,
126
+ env: { BW_MASTER_PW: pw, BW_NOINTERACTION: "true" },
127
+ },
128
+ );
129
+ if (loginResult.exitCode !== 0) {
130
+ throw new CliError(
131
+ `Login failed: ${loginResult.stderr}`,
132
+ ExitCode.AuthFailed,
133
+ );
134
+ }
135
+ }
136
+
137
+ // Unlock
138
+ const pw = await getMasterPassword();
139
+ emitLog("Unlocking vault...", opts);
140
+ const unlockResult = await runBw(
141
+ ["unlock", "--passwordenv", "BW_MASTER_PW", "--raw"],
142
+ { session: null, env: { BW_MASTER_PW: pw } },
143
+ );
144
+ if (unlockResult.exitCode !== 0 || !unlockResult.stdout) {
145
+ throw new CliError(
146
+ `Unlock failed: ${unlockResult.stderr}`,
147
+ ExitCode.AuthFailed,
148
+ );
149
+ }
150
+
151
+ const token = unlockResult.stdout;
152
+ setSession(token);
153
+ await saveCachedSession(token);
154
+ }
155
+
156
+ export async function lockVault(): Promise<void> {
157
+ await runBw(["lock"], { session: null });
158
+ setSession(null);
159
+ await clearCachedSession();
160
+ }
161
+
162
+ export async function withSession<T>(
163
+ opts: GlobalOptions,
164
+ fn: () => Promise<T>,
165
+ ): Promise<T> {
166
+ // Try with current/cached session
167
+ const cached = getSession() || (await loadCachedSession());
168
+ if (cached) {
169
+ setSession(cached);
170
+ }
171
+
172
+ try {
173
+ return await fn();
174
+ } catch (err) {
175
+ if (
176
+ err instanceof CliError &&
177
+ err.exitCode === ExitCode.AuthFailed
178
+ ) {
179
+ // Clear stale session and re-auth
180
+ setSession(null);
181
+ await clearCachedSession();
182
+ await ensureUnlocked(opts);
183
+ return await fn();
184
+ }
185
+ throw err;
186
+ }
187
+ }
@@ -0,0 +1,112 @@
1
+ import { z } from "zod/v4";
2
+
3
+ // --- Vault status ---
4
+
5
+ export const BwStatusSchema = z.object({
6
+ serverUrl: z.string().nullable(),
7
+ lastSync: z.string().nullable(),
8
+ userEmail: z.string().nullable().optional(),
9
+ userId: z.string().nullable().optional(),
10
+ status: z.enum(["unlocked", "locked", "unauthenticated"]),
11
+ });
12
+
13
+ export type BwStatus = z.infer<typeof BwStatusSchema>;
14
+
15
+ // --- Item types ---
16
+
17
+ export const BwItemType = {
18
+ Login: 1,
19
+ SecureNote: 2,
20
+ Card: 3,
21
+ Identity: 4,
22
+ } as const;
23
+
24
+ export type BwItemType = (typeof BwItemType)[keyof typeof BwItemType];
25
+
26
+ export const BW_TYPE_LABELS: Record<number, string> = {
27
+ 1: "login",
28
+ 2: "note",
29
+ 3: "card",
30
+ 4: "identity",
31
+ };
32
+
33
+ export const BW_TYPE_FROM_NAME: Record<string, BwItemType> = {
34
+ login: BwItemType.Login,
35
+ note: BwItemType.SecureNote,
36
+ card: BwItemType.Card,
37
+ identity: BwItemType.Identity,
38
+ };
39
+
40
+ // --- Field types ---
41
+
42
+ export const BwFieldType = {
43
+ Text: 0,
44
+ Hidden: 1,
45
+ Boolean: 2,
46
+ } as const;
47
+
48
+ export type BwFieldType = (typeof BwFieldType)[keyof typeof BwFieldType];
49
+
50
+ export interface BwField {
51
+ name: string;
52
+ value: string;
53
+ type: BwFieldType;
54
+ }
55
+
56
+ // --- URI ---
57
+
58
+ export interface BwUri {
59
+ match: null;
60
+ uri: string;
61
+ }
62
+
63
+ // --- Login ---
64
+
65
+ export interface BwLogin {
66
+ username: string | null;
67
+ password: string | null;
68
+ totp: string | null;
69
+ uris: BwUri[] | null;
70
+ }
71
+
72
+ // --- Item (loose schema — passthrough for edit round-trip) ---
73
+
74
+ export const BwItemSchema = z
75
+ .object({
76
+ id: z.string(),
77
+ organizationId: z.string().nullable(),
78
+ folderId: z.string().nullable(),
79
+ type: z.number(),
80
+ name: z.string(),
81
+ notes: z.string().nullable(),
82
+ favorite: z.boolean(),
83
+ fields: z
84
+ .array(
85
+ z.object({
86
+ name: z.string(),
87
+ value: z.string().nullable(),
88
+ type: z.number(),
89
+ }),
90
+ )
91
+ .nullable(),
92
+ login: z
93
+ .object({
94
+ username: z.string().nullable(),
95
+ password: z.string().nullable(),
96
+ totp: z.string().nullable(),
97
+ uris: z
98
+ .array(
99
+ z.object({
100
+ match: z.number().nullable().optional(),
101
+ uri: z.string(),
102
+ }),
103
+ )
104
+ .nullable(),
105
+ })
106
+ .nullable()
107
+ .optional(),
108
+ reprompt: z.number().optional(),
109
+ })
110
+ .passthrough();
111
+
112
+ export type BwItem = z.infer<typeof BwItemSchema>;