From fd50e70f33858d675eda3b96af45e7a909719999 Mon Sep 17 00:00:00 2001 From: bw-secrets maintainers Date: Thu, 10 Sep 2026 07:30:45 -0400 Subject: [PATCH] Initial release: bw-secrets agent-friendly Bitwarden/Vaultwarden helper Single-file pure-Python tool for retrieving secrets from a self-hosted Bitwarden/Vaultwarden vault. Org-first by default, agent exit codes (0/1/2), no session file on disk, no jq/pexpect dependencies. Includes: install.sh, README with org setup walkthrough and agent onboarding prompt, SKILL.md agent documentation. --- README.md | 238 ++++++++++++++++++++++++ SKILL.md | 197 ++++++++++++++++++++ bw-secrets | 530 +++++++++++++++++++++++++++++++++++++++++++++++++++++ install.sh | 33 ++++ 4 files changed, 998 insertions(+) create mode 100644 README.md create mode 100644 SKILL.md create mode 100755 bw-secrets create mode 100755 install.sh diff --git a/README.md b/README.md new file mode 100644 index 0000000..cb29706 --- /dev/null +++ b/README.md @@ -0,0 +1,238 @@ +# bw-secrets — Agent-friendly Bitwarden/Vaultwarden secrets helper + +Single-file, pure-Python (stdlib only) helper for retrieving secrets from a +self-hosted Bitwarden/Vaultwarden vault. Designed for **agents** (AI coding +agents, kanban workers, cron jobs) and humans alike. + +## Why this exists + +The `bw` CLI is powerful but not agent-friendly: it needs a session token, +serves a stale local cache, and fails silently on non-ASCII item names. +`bw-secrets` wraps it with: + +- **Auto-sync before every read** (the CLI serves a local cache) +- **Lookup by id, never by name** (avoids the em-dash silent-failure bug) +- **Agent exit codes**: `0` found, `1` not found, `2` ambiguous match +- **No session file on disk** — re-unlocks per invocation, token stays in-process +- **No jq, no pexpect** — pure Python 3 stdlib +- **Org-first by default** — `get`/`list`/`env` search the default org's + collections; personal-vault items require `--personal` (or `--all`) +- **Org-aware, case-insensitive collection lookup** + +## Organizations by default + +`bw-secrets` is **org-first**: secrets for agents belong in an organization, +not in a personal vault. This gives you shared access control, collections, +and auditability — a personal vault is a single-user silo with none of that. + +By default, `get`, `list`, and `env` search only the default org +(`BW_ORG_ID`). Personal-vault items are excluded unless you ask for them: + +```bash +bw-secrets get # searches the default org only +bw-secrets get --personal # searches personal vault items only +bw-secrets get --all # searches everything +``` + +### Setting up an org for your agent (walkthrough) + +If you don't have an org yet, here's the recommended setup. The pattern: +**one dedicated user account per agent**, added to the org with access to +only the collections it needs. + +1. **Create the agent's Bitwarden account** (a dedicated user, e.g. + `agent-name@yourdomain.com` — never share your personal account with an + agent): + [Create a Bitwarden account](https://bitwarden.com/help/create-bitwarden-account/) + +2. **Create an organization** (free for personal use; this is where shared + secrets live): + [Getting started with organizations](https://bitwarden.com/help/getting-started-organizations/) · + [About organizations](https://bitwarden.com/help/about-organizations/) + +3. **Invite the agent's user account to the org** and assign it a role + (User is enough for read access; Owner/Admin only if the agent must manage + the org): + [Manage users in your organization](https://bitwarden.com/help/managing-users/) + +4. **Create collections** for the agent's secret types (e.g. + `infrastructure`, `applications`, `credentials`, `devops`): + [Create collections](https://bitwarden.com/help/create-collections/) + +5. **Assign the agent user to the collections** it needs and set + permissions (Read-only is the safe default for an agent): + [Assign users to collections](https://bitwarden.com/help/assign-users-to-collections/) · + [Collection permissions](https://bitwarden.com/help/collection-permissions/) + +6. **Point `bw-secrets` at the org**: set `BW_ORG_ID` to the new org's id in + `~/.config/bw/config.env` (or export it), then verify with + `bw-secrets org collections`. + +### Self-hosted option: Vaultwarden + +Don't want to pay for Bitwarden or send secrets to their cloud? **Vaultwarden** +is a free, open-source, self-hosted Bitwarden-compatible server — drop-in +compatible with the `bw` CLI and this tool. Just set `BW_SERVER` to your +instance URL. + +- [Vaultwarden on GitHub](https://github.com/dani-garcia/vaultwarden) +- [Self-host Bitwarden (official docs)](https://bitwarden.com/help/self-host-bitwarden/) · + [Self-host an organization](https://bitwarden.com/help/self-host-an-organization/) + +## Requirements + +- `bw` CLI (Bitwarden CLI) — on PATH +- Master password file (default `~/.config/bw/master_pw`, chmod 600) +- Python 3.8+ + +## Install + +```bash +./install.sh # copies bw-secrets to ~/.local/bin/bw-secrets +``` + +Or manually: copy `bw-secrets` to `~/.local/bin/`, `chmod 700`. + +## Configuration + +All values are overridable via environment variables or +`~/.config/bw/config.env` (highest wins: env var > config file > default). + +| Variable | Default | Purpose | +|---|---|---| +| `BW_SERVER` | *(required)* | Vaultwarden URL | +| `BW_ORG_ID` | *(required for org-first)* | Default org id | +| `BW_MASTER_PW_FILE` | `~/.config/bw/master_pw` | Master password file | +| `BW_BIN` | `bw` | bw CLI path | + +```bash +# ~/.config/bw/config.env (chmod 600) +BW_SERVER=https://vault.example.com +BW_ORG_ID= +BW_MASTER_PW_FILE=/path/to/master_pw +``` + +## Agent onboarding (copy-paste prompt) + +Paste the block below to any agent to configure this tool interactively. The +agent should run each step, confirm the result, and only proceed on success. + +````text +You are configuring the `bw-secrets` tool — an agent-friendly wrapper around +the Bitwarden CLI for retrieving secrets from a self-hosted vault. Work +through these steps in order, running every command and reporting the result +of each before moving on. + +STEP 1 — Install +Clone the repo to a temp dir, run ./install.sh, then remove the clone: + git clone /tmp/bw-secrets-setup + cd /tmp/bw-secrets-setup && ./install.sh + cd / && rm -rf /tmp/bw-secrets-setup +Confirm: `~/.local/bin/bw-secrets` exists and is executable (chmod 700). + +STEP 2 — Configure +Create ~/.config/bw/config.env (chmod 600) with: + BW_SERVER= + BW_ORG_ID= + BW_MASTER_PW_FILE= +Ask the user for these values if they are not already set. Do NOT ask for or +store the master password itself — it must never be typed or stored anywhere +except the master password file. + +STEP 3 — Verify the environment +Run `bw-secrets status`. You should see JSON with "status":"locked" and the +serverUrl you configured. If `bw` is not found, install the Bitwarden CLI and +ensure it is on PATH. + +STEP 4 — Test retrieval (live vault) +Run these and confirm each returns a value (do not print full secret values — +show only the first 6 and last 4 characters): + bw-secrets org list + bw-secrets org collections + bw-secrets list --all + bw-secrets get --all username +If any command errors with "could not parse", re-run it — the tool retries +internally, but a stale session can still fail once; a second run succeeds. + +STEP 5 — Verify exit codes (agent contract) + bw-secrets get "definitely-not-a-real-item-xyz"; echo $? # expect: 1 + bw-secrets get "a"; echo $? # expect: 2 (ambiguous) + bw-secrets get --all >/dev/null; echo $? # expect: 0 + +When all five steps pass, report: "bw-secrets configured and verified — +install OK, retrieval OK, exit codes 0/1/2 OK." If any step fails, stop and +report the exact error rather than guessing. +```` + +## Usage + +```bash +# Get a secret (password is default field) — searches the default org +bw-secrets get +bw-secrets get username +bw-secrets get custom: +bw-secrets get json + +# Search scope (default: org only) +bw-secrets get --personal # personal vault items only +bw-secrets get --all # everything (org + personal) + +# Scoped to a collection (case-insensitive) +bw-secrets get --collection Infrastructure +bw-secrets org get Infrastructure [field] + +# List / search +bw-secrets list [search] +bw-secrets list [search] --personal +bw-secrets org items Infrastructure [search] + +# Org / collection discovery +bw-secrets org list +bw-secrets org collections + +# Environment-style output (for sourcing) +bw-secrets env [field] + +# Maintenance +bw-secrets sync +bw-secrets status +bw-secrets unlock +``` + +### Fields + +| Field | Description | +|---|---| +| `password` (default) | Login password | +| `username` | Login username | +| `notes` | Item notes | +| `totp` | TOTP URI | +| `url` | First login URI | +| `custom:` | Custom field by name | +| `json` | Full item JSON | +| `` | Any dotted path into the item, e.g. `login.uris.0.uri` | + +### Exit codes + +| Code | Meaning | +|---|---| +| `0` | Found, value printed to stdout | +| `1` | Not found / error | +| `2` | Ambiguous match (multiple items) — refine your search | + +## Security rules + +- **Secrets ALWAYS live in Bitwarden.** This tool only reads them at runtime. +- **Never write the session token to disk.** Re-unlock per invocation. +- **Never echo secrets to logs.** Use `set +x` around secret handling. +- **Never commit `.env` files or credentials.** +- The master password file is the ONLY secret on disk (chmod 600). + +## Development + +```bash +git clone +# edit bw-secrets, test against the live vault, commit, push +``` + +See `SKILL.md` for the agent-facing usage documentation. diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..ed42226 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,197 @@ +--- +name: bitwarden-secrets +description: Retrieve secrets from a self-hosted Bitwarden/Vaultwarden vault via the bw-secrets helper. Use when you need API keys, tokens, passwords, database credentials, or any sensitive value. Auto-syncs before reads, agent-friendly exit codes, no session file on disk. +version: 3.0.0 +--- + +# Bitwarden Secrets Skill + +Secure access to a self-hosted Bitwarden/Vaultwarden vault for retrieving +secrets at runtime. + +## When to use + +- You need an API key, token, password, or credential for any service +- You need to know what secrets exist and where they live +- You are writing a script, cron job, or agent flow that needs a secret + +## ⚠️ Standing rules + +1. **Secrets ALWAYS live in Bitwarden.** Never `.env`, never config.yaml, + never plaintext anywhere. Fetch at runtime. +2. **ALL items go in an org vault**, never the personal vault. +3. **ALWAYS `bw sync` before reads** — the CLI serves a local cache; external + updates are invisible until sync. `bw-secrets` does this automatically. +4. **Look items up by id, never by name** — em-dash names cause silent 0-byte + responses from `bw get item`. `bw-secrets` handles this internally. +5. **Never write the session token to disk.** Re-unlock per invocation. + +## Quick reference + +```bash +# Get a secret (password is default) — searches the default org +bw-secrets get +bw-secrets get username +bw-secrets get custom: +bw-secrets get json + +# Search scope (default: org only) +bw-secrets get --personal # personal vault items only +bw-secrets get --all # everything (org + personal) + +# Scoped to a collection (case-insensitive) +bw-secrets get --collection Infrastructure +bw-secrets org get Infrastructure [field] + +# List / search +bw-secrets list [search] +bw-secrets list [search] --personal +bw-secrets org items Infrastructure [search] + +# Discovery +bw-secrets org list +bw-secrets org collections + +# Environment-style output (for sourcing) +bw-secrets env [field] + +# Maintenance +bw-secrets sync +bw-secrets status +bw-secrets unlock +``` + +## Exit codes (agent contract) + +| Code | Meaning | +|---|---| +| `0` | Found, value on stdout | +| `1` | Not found / error | +| `2` | Ambiguous match — refine the search | + +## Org-first behavior + +`bw-secrets` is **org-first** — `get`/`list`/`env` search the default org's +collections by default. Personal-vault items (no collection) require +`--personal` or `--all`: + +```bash +bw-secrets get "some-personal-item" --personal username +``` + +Org membership is determined by collectionIds intersecting the org's +collection IDs — NOT by item.organizationId, which is null on items created +via `bw create item`. + +## The Python pattern (primary agent interface) + +For agents, the canonical way to use a secret is a `get_secret()` helper — +it never puts the secret in a shell command line, log, or process listing: + +```python +import json, subprocess, os + +def get_secret(substring, collection_id=None, field='password'): + """Fetch a secret from Bitwarden by item-name substring.""" + env = {**os.environ, 'PATH': '/usr/bin:/bin'} + session = subprocess.run( + ['bw', 'unlock', '--passwordfile', '', '--raw'], + capture_output=True, text=True, env=env, check=True, + ).stdout.strip() + items = json.loads(subprocess.run( + ['bw', 'list', 'items', '--session', session], + capture_output=True, text=True, env=env, check=True, + ).stdout) + matches = [i for i in items if substring.lower() in i.get('name','').lower()] + if collection_id is not None: + scoped = [i for i in matches if collection_id in i.get('collectionIds', [])] + if scoped: + matches = scoped + for it in matches: + item = json.loads(subprocess.run( + ['bw', 'get', 'item', it['id'], '--session', session], + capture_output=True, text=True, env=env, check=True, + ).stdout) + if field == 'password': + return item['login']['password'] + if field in [f['name'] for f in item.get('fields', [])]: + return {f['name']: f['value'] for f in item['fields']}[field] + return item + raise KeyError(f"No item matching {substring!r}") +``` + +## Common patterns + +### Get a token for an API call + +```bash +# Inline — secret never stored in a variable +curl -H "Authorization: token $(bw-secrets get 'my-token')" \ + https://api.example.com/v1/user +``` + +### Get a credential from a collection + +```bash +bw-secrets org get Infrastructure "my-server" username +bw-secrets org get Infrastructure "my-server" password +bw-secrets org get Applications "my-app" custom:URL +``` + +### Generate an env file (only when absolutely necessary) + +```bash +bw-secrets sync +cat > .env << EOF +DATABASE_URL=$(bw-secrets get my-db url) +API_KEY=$(bw-secrets get my-api password) +EOF +chmod 600 .env +``` + +### In a deployment script + +```bash +#!/bin/bash +set -euo pipefail +# ✅ CORRECT — inline usage, secret never stored +ssh $(bw-secrets get production-server username)@server.com \ + "docker login -u $(bw-secrets get docker-hub username) -p $(bw-secrets get docker-hub password) registry.com" +``` + +## Security guidelines + +### DO + +- Sync before every read (auto-synced in `bw-secrets`) +- Use secrets inline in commands — never persist to disk, never echo +- Use the helper script for all vault operations +- Use appropriate collections for different secret types +- Use `set +x` before commands that handle secrets + +### DON'T + +- Never write the session token to disk +- Never log secret values in plain text +- Never commit `.env` files or credentials +- Never share secrets in chat or documentation +- Never store secrets in git repositories +- Never hardcode credentials in scripts + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| `ERROR: no items found matching 'X'` | Item doesn't exist, or is in a different collection | `bw-secrets list X --all` to search everything | +| `ERROR: ambiguous match for 'X'` | Multiple items match | Add `--collection ` or refine the name | +| `ERROR: collection 'X' not found` | Wrong collection name | `bw-secrets org collections` to list real names | +| `ERROR: master password file not found` | Wrong path | Set `BW_MASTER_PW_FILE` | +| `bw: command not found` | Not on PATH | Install the Bitwarden CLI and add it to PATH | +| `ERROR: BW_SERVER is not set` | Missing config | Set `BW_SERVER` in `~/.config/bw/config.env` | +| Stale values after external update | CLI local cache | `bw-secrets sync` (auto-done on every get/list) | + +## Development + +Repo: `bitwarden-secrets` (self-hosted git). +Single-file tool: `bw-secrets` (pure Python 3, stdlib only). +Install: `./install.sh` → `~/.local/bin/bw-secrets`. diff --git a/bw-secrets b/bw-secrets new file mode 100755 index 0000000..a72419b --- /dev/null +++ b/bw-secrets @@ -0,0 +1,530 @@ +#!/usr/bin/env python3 +"""bw-secrets — Agent-friendly Bitwarden/Vaultwarden secrets helper. + +Pure Python 3 (stdlib only). No jq, no pexpect, no session file on disk. + +Design rules: + * Secrets ALWAYS live in Bitwarden. This tool only reads them at runtime. + * Master password file (chmod 600) — the ONLY secret on disk. Never write + the session token to disk; re-unlock per call. + * ALWAYS `bw sync` before reads (the CLI serves a local cache). + * Look items up by id, never by name (em-dash silent-failure bug). + * Agent-friendly: exit 0 = found, 1 = not found, 2 = ambiguous match. + * ORG-FIRST: get/list/env search the default org (BW_ORG_ID) by default. + Personal-vault items are excluded unless --personal; --all searches + everything. Collections matched case-insensitively. + +Config resolution (highest wins): environment variable > config file +(~/.config/bw/config.env) > built-in default. + + BW_SERVER Vaultwarden URL (required — no default) + BW_ORG_ID Default org id (required for org-first lookups) + BW_MASTER_PW_FILE Master password file (default ~/.config/bw/master_pw) + BW_BIN bw CLI path (default bw on PATH) + +Usage: + bw-secrets get [field] [--collection ] [--personal|--all] [--json] + bw-secrets list [search] [--collection ] [--personal|--all] + bw-secrets env [field] [--collection ] [--personal|--all] # prints KEY=value for sourcing + bw-secrets sync | status | unlock + bw-secrets org list + bw-secrets org collections [org-id] + bw-secrets org items [search] + bw-secrets org get [field] [--json] + +Fields: username, password (default), notes, totp, url, custom:, json, +or a dotted path into the item (e.g. login.uris.0.uri). +""" +import json +import os +import re +import subprocess +import sys +from pathlib import Path + +# -------------------------------------------------------------------------- +# Config +# -------------------------------------------------------------------------- + +DEFAULTS = { + "BW_SERVER": "", + "BW_ORG_ID": "", + "BW_MASTER_PW_FILE": str(Path.home() / ".config" / "bw" / "master_pw"), + "BW_BIN": "bw", +} + +CONFIG_FILE = Path.home() / ".config" / "bw" / "config.env" + + +def load_config() -> dict: + cfg = dict(DEFAULTS) + if CONFIG_FILE.exists(): + for line in CONFIG_FILE.read_text().splitlines(): + line = line.strip() + if line and not line.startswith("#") and "=" in line: + k, v = line.split("=", 1) + cfg[k.strip()] = v.strip() + for k in DEFAULTS: + if os.environ.get(k): + cfg[k] = os.environ[k] + return cfg + + +CFG = load_config() + + +def require(key: str, hint: str) -> str: + val = CFG.get(key, "") + if not val: + sys.stderr.write( + f"ERROR: {key} is not set. {hint}\n" + f"Set it in {CONFIG_FILE} or export it as an environment variable.\n" + ) + sys.exit(1) + return val + + +# -------------------------------------------------------------------------- +# bw plumbing +# -------------------------------------------------------------------------- + + +def _env() -> dict: + env = os.environ.copy() + if CFG.get("BW_SERVER"): + env["BW_SERVER"] = CFG["BW_SERVER"] + return env + + +def _bw(args, session=None, check=True): + """Run bw with the given args. Returns (returncode, stdout).""" + cmd = [CFG["BW_BIN"]] + args + if session: + cmd += ["--session", session] + proc = subprocess.run(cmd, capture_output=True, text=True, env=_env(), timeout=60) + if check and proc.returncode != 0: + err = proc.stderr.strip() or proc.stdout.strip() + sys.stderr.write(f"bw error: {err}\n") + sys.exit(1) + return proc.returncode, proc.stdout + + +def unlock() -> str: + """Unlock the vault, return session token. Never persists the token.""" + pw_file = Path(require("BW_MASTER_PW_FILE", + "Point BW_MASTER_PW_FILE at your master password file.")) + if not pw_file.exists(): + sys.stderr.write(f"ERROR: master password file not found: {pw_file}\n") + sys.exit(1) + proc = subprocess.run( + [CFG["BW_BIN"], "unlock", "--passwordfile", str(pw_file), "--raw"], + capture_output=True, text=True, env=_env(), timeout=60, + ) + session = proc.stdout.strip() + if not session or proc.returncode != 0: + sys.stderr.write("ERROR: bw unlock failed\n") + sys.exit(1) + return session + + +def sync(session: str) -> None: + _bw(["sync"], session=session, check=False) + + +def _list_json(session: str, what: str, retries: int = 3) -> list: + """bw list with retry. The CLI intermittently returns empty stdout + (verified 2026-09-10: `bw list items` returned 0 bytes on ~5% of runs). + Retry with a fresh session before giving up.""" + for attempt in range(1, retries + 1): + _, out = _bw(["list", what], session=session, check=False) + try: + return json.loads(out) + except json.JSONDecodeError: + if attempt < retries: + session = unlock() # fresh session — stale token can cause empty output + continue + sys.stderr.write(f"ERROR: could not parse bw list {what} output " + f"(empty={len(out) == 0})\n") + sys.exit(1) + return [] # unreachable + + +def list_items(session: str) -> list: + """Unfiltered item list (catches collection-only items the org filter misses).""" + return _list_json(session, "items") + + +def list_collections(session: str) -> list: + return _list_json(session, "collections") + + +def list_orgs(session: str) -> list: + return _list_json(session, "organizations") + + +# -------------------------------------------------------------------------- +# Lookup helpers +# -------------------------------------------------------------------------- + +def find_collection_id(collections: list, name: str, org_id: str = None) -> str: + """Case-insensitive collection lookup, preferring the target org.""" + name_l = name.lower() + candidates = [c for c in collections if c.get("name", "").lower() == name_l] + if not candidates: + return None + if org_id: + for c in candidates: + if c.get("organizationId") == org_id: + return c["id"] + return candidates[0]["id"] + + +def match_items(items: list, name: str, collection_id: str = None) -> list: + """Case-insensitive substring match on item name, optional collection filter.""" + name_l = name.lower() + hits = [it for it in items if name_l in it.get("name", "").lower()] + if collection_id: + hits = [it for it in hits if collection_id in it.get("collectionIds", [])] + return hits + + +def resolve_scope(items: list, scope: str, org_id: str = None, + collections: list = None) -> list: + """Filter items by search scope. + + scope: 'org' (default) — items in the default org's collections + 'personal' — items with no org/collection (personal vault) + 'all' — everything + + Org membership is determined by collectionIds intersecting the org's + collection IDs — NOT by item.organizationId, which is null on items + created via `bw create item` (verified 2026-06-28). + """ + if scope == "all": + return items + if scope == "personal": + return [it for it in items if not it.get("collectionIds")] + # org scope + if org_id and collections: + org_coll_ids = {c["id"] for c in collections + if c.get("organizationId") == org_id} + return [it for it in items + if org_coll_ids.intersection(it.get("collectionIds", []))] + return [it for it in items if it.get("collectionIds")] + + +def get_field(item: dict, field: str): + """Extract a field from a bw item. Returns (value, is_json).""" + if field == "json": + return json.dumps(item, indent=2), True + if field == "password": + return item.get("login", {}).get("password", ""), False + if field == "username": + return item.get("login", {}).get("username", ""), False + if field == "notes": + return item.get("notes", ""), False + if field == "totp" or field == "totp_uri": + return item.get("login", {}).get("totp", ""), False + if field == "url": + uris = item.get("login", {}).get("uris") or [] + return (uris[0].get("uri", "") if uris else ""), False + if field.startswith("custom:"): + fname = field[len("custom:"):] + for f in item.get("fields", []): + if f.get("name") == fname: + return f.get("value", ""), False + return "", False + # dotted path, e.g. login.uris.0.uri + val = item + for part in field.split("."): + if isinstance(val, list): + try: + val = val[int(part)] + except (ValueError, IndexError): + return "", False + elif isinstance(val, dict): + val = val.get(part) + else: + return "", False + return (val if val is not None else ""), False + + +# -------------------------------------------------------------------------- +# Commands +# -------------------------------------------------------------------------- + +def cmd_get(args): + if not args: + sys.stderr.write("Usage: bw-secrets get [field] [--collection ] [--personal|--all] [--json]\n") + sys.exit(1) + name = args[0] + field = "password" + collection = None + scope = "org" + as_json = False + i = 1 + while i < len(args): + a = args[i] + if a == "--collection" and i + 1 < len(args): + collection = args[i + 1] + i += 2 + elif a == "--personal": + scope = "personal" + i += 1 + elif a == "--all": + scope = "all" + i += 1 + elif a == "--json": + as_json = True + i += 1 + else: + field = a + i += 1 + if as_json: + field = "json" + + session = unlock() + sync(session) + items = list_items(session) + collections = list_collections(session) + org_id = CFG.get("BW_ORG_ID", "") + coll_id = find_collection_id(collections, collection, org_id) if collection else None + if collection and not coll_id: + sys.stderr.write(f"ERROR: collection '{collection}' not found\n") + sys.exit(1) + + if collection: + items = [it for it in items if coll_id in it.get("collectionIds", [])] + else: + items = resolve_scope(items, scope, org_id, collections) + + hits = match_items(items, name) + if not hits: + sys.stderr.write(f"ERROR: no items found matching '{name}'\n") + sys.exit(1) + if len(hits) > 1: + names = ", ".join(h.get("name", "?") for h in hits) + sys.stderr.write(f"ERROR: ambiguous match for '{name}': {names}\n") + sys.exit(2) + + value, is_json = get_field(hits[0], field) + if is_json: + print(value) + else: + print(value) + + +def cmd_list(args): + search = args[0] if args and not args[0].startswith("--") else None + collection = None + scope = "org" + if "--collection" in args: + idx = args.index("--collection") + if idx + 1 < len(args): + collection = args[idx + 1] + if "--personal" in args: + scope = "personal" + if "--all" in args: + scope = "all" + + session = unlock() + sync(session) + items = list_items(session) + collections = list_collections(session) + org_id = CFG.get("BW_ORG_ID", "") + coll_id = find_collection_id(collections, collection, org_id) if collection else None + if collection and not coll_id: + sys.stderr.write(f"ERROR: collection '{collection}' not found\n") + sys.exit(1) + + if collection: + items = [it for it in items if coll_id in it.get("collectionIds", [])] + else: + items = resolve_scope(items, scope, org_id, collections) + + if search: + items = [it for it in items if search.lower() in it.get("name", "").lower()] + + for it in sorted(items, key=lambda x: x.get("name", "").lower()): + kind = "login" if it.get("type") == 1 else "other" + print(f"{it.get('name', '?')} [{kind}] {it.get('id', '?')}") + + +def cmd_env(args): + if not args: + sys.stderr.write("Usage: bw-secrets env [field] [--collection ] [--personal|--all]\n") + sys.exit(1) + name = args[0] + field = "password" + collection = None + scope = "org" + i = 1 + while i < len(args): + a = args[i] + if a == "--collection" and i + 1 < len(args): + collection = args[i + 1] + i += 2 + elif a == "--personal": + scope = "personal" + i += 1 + elif a == "--all": + scope = "all" + i += 1 + else: + field = a + i += 1 + + session = unlock() + sync(session) + items = list_items(session) + collections = list_collections(session) + org_id = CFG.get("BW_ORG_ID", "") + coll_id = find_collection_id(collections, collection, org_id) if collection else None + if collection: + items = [it for it in items if coll_id in it.get("collectionIds", [])] + else: + items = resolve_scope(items, scope, org_id, collections) + + hits = match_items(items, name) + if not hits: + sys.stderr.write(f"ERROR: no items found matching '{name}'\n") + sys.exit(1) + if len(hits) > 1: + names = ", ".join(h.get("name", "?") for h in hits) + sys.stderr.write(f"ERROR: ambiguous match for '{name}': {names}\n") + sys.exit(2) + + value, _ = get_field(hits[0], field) + key = re.sub(r"[^A-Z0-9_]", "_", hits[0].get("name", "SECRET").upper()) + print(f"{key}={value}") + + +def cmd_sync(_args): + session = unlock() + sync(session) + print("Vault synced.") + + +def cmd_status(_args): + proc = subprocess.run([CFG["BW_BIN"], "status"], capture_output=True, text=True, env=_env(), timeout=30) + print(proc.stdout.strip() or proc.stderr.strip()) + + +def cmd_unlock(_args): + session = unlock() + print(f"Vault unlocked. Session: {session[:8]}...") + + +def cmd_org(args): + if not args: + sys.stderr.write("Usage: bw-secrets org ...\n") + sys.exit(1) + sub = args[0] + rest = args[1:] + + if sub == "list": + session = unlock() + sync(session) + for o in list_orgs(session): + print(f"{o.get('name', '?')} [{o.get('id', '?')}]") + return + + if sub == "collections": + session = unlock() + sync(session) + org_id = rest[0] if rest else CFG.get("BW_ORG_ID", "") + for c in list_collections(session): + if c.get("organizationId") == org_id: + print(f"{c.get('name', '?')} [{c.get('id', '?')}]") + return + + if sub == "items": + if not rest: + sys.stderr.write("Usage: bw-secrets org items [search]\n") + sys.exit(1) + collection = rest[0] + search = rest[1] if len(rest) > 1 else None + session = unlock() + sync(session) + collections = list_collections(session) + coll_id = find_collection_id(collections, collection, CFG.get("BW_ORG_ID", "")) + if not coll_id: + sys.stderr.write(f"ERROR: collection '{collection}' not found\n") + sys.exit(1) + items = [it for it in list_items(session) if coll_id in it.get("collectionIds", [])] + if search: + items = [it for it in items if search.lower() in it.get("name", "").lower()] + for it in sorted(items, key=lambda x: x.get("name", "").lower()): + kind = "login" if it.get("type") == 1 else "other" + print(f"{it.get('name', '?')} [{kind}] {it.get('id', '?')}") + return + + if sub == "get": + if len(rest) < 2: + sys.stderr.write("Usage: bw-secrets org get [field] [--json]\n") + sys.exit(1) + collection = rest[0] + name = rest[1] + field = "password" + as_json = False + for a in rest[2:]: + if a == "--json": + as_json = True + else: + field = a + if as_json: + field = "json" + session = unlock() + sync(session) + collections = list_collections(session) + coll_id = find_collection_id(collections, collection, CFG.get("BW_ORG_ID", "")) + if not coll_id: + sys.stderr.write(f"ERROR: collection '{collection}' not found\n") + sys.exit(1) + hits = match_items(list_items(session), name, coll_id) + if not hits: + sys.stderr.write(f"ERROR: no items found matching '{name}' in collection '{collection}'\n") + sys.exit(1) + if len(hits) > 1: + names = ", ".join(h.get("name", "?") for h in hits) + sys.stderr.write(f"ERROR: ambiguous match for '{name}': {names}\n") + sys.exit(2) + value, is_json = get_field(hits[0], field) + print(value if is_json else value) + return + + sys.stderr.write(f"ERROR: unknown org subcommand '{sub}'\n") + sys.exit(1) + + +def cmd_help(_args=None): + print(__doc__) + + +def main(): + if not args: + cmd_help() + return + cmd = args[0] + rest = args[1:] + handlers = { + "get": cmd_get, + "list": cmd_list, + "env": cmd_env, + "sync": cmd_sync, + "status": cmd_status, + "unlock": cmd_unlock, + "org": cmd_org, + "help": cmd_help, + "--help": cmd_help, + "-h": cmd_help, + } + if cmd not in handlers: + sys.stderr.write(f"ERROR: unknown command '{cmd}'. Run 'bw-secrets help'.\n") + sys.exit(1) + handlers[cmd](rest) + + +if __name__ == "__main__": + args = sys.argv[1:] + main() diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..a1da7b9 --- /dev/null +++ b/install.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# install.sh — Install bw-secrets to ~/.local/bin +set -euo pipefail + +SRC="$(cd "$(dirname "$0")" && pwd)/bw-secrets" +DEST="${HOME}/.local/bin/bw-secrets" + +mkdir -p "${HOME}/.local/bin" +install -m 700 "${SRC}" "${DEST}" +echo "Installed bw-secrets → ${DEST}" + +# Ensure ~/.local/bin is on PATH +case ":${PATH}:" in + *":${HOME}/.local/bin:"*) ;; + *) echo "NOTE: add ${HOME}/.local/bin to your PATH" ;; +esac + +# Optional: create config dir with template +CONFIG_DIR="${HOME}/.config/bw" +if [[ ! -f "${CONFIG_DIR}/config.env" ]]; then + mkdir -p "${CONFIG_DIR}" + cat > "${CONFIG_DIR}/config.env" <<'EOF' +# bw-secrets configuration (chmod 600) +# BW_SERVER and BW_ORG_ID are required — set them to your vault. +# BW_SERVER=https://vault.example.com +# BW_ORG_ID= +# BW_MASTER_PW_FILE=${HOME}/.config/bw/master_pw +EOF + chmod 600 "${CONFIG_DIR}/config.env" + echo "Created config template → ${CONFIG_DIR}/config.env" +fi + +echo "Done. Set BW_SERVER and BW_ORG_ID in ${CONFIG_DIR}/config.env, then test with: bw-secrets status"