"""CLI handlers for ``hermes secrets onepassword ...``. Subcommands: setup — verify the op CLI, set account / token env var, enable status — show config + op binary + auth + configured references set — map an env var to an ``op://…`` reference remove — drop a mapping sync — resolve references now and show what would be applied (dry-run) disable — flip ``secrets.onepassword.enabled`` to False Unlike Bitwarden, the ``op`` binary is NOT auto-installed: 1Password publishes the CLI through OS package managers and signed installers, so Hermes expects an already-installed, already-authenticated ``op`` and never downloads one. """ from __future__ import annotations import argparse import os import subprocess from pathlib import Path from typing import Optional from rich.console import Console from rich.panel import Panel from rich.table import Table from agent.secret_sources import onepassword as op_src from hermes_cli.config import ( get_env_path, load_config, save_config, save_env_value, ) _DEFAULT_TOKEN_ENV = "OP_SERVICE_ACCOUNT_TOKEN" _DOCS_URL = "https://developer.1password.com/docs/cli/get-started/" # --------------------------------------------------------------------------- # Argparse wiring — called from hermes_cli.main # --------------------------------------------------------------------------- def register_cli(parent_parser: argparse.ArgumentParser) -> None: """Attach the ``onepassword`` subcommand tree to a parent parser.""" sub = parent_parser.add_subparsers(dest="secrets_op_command") setup = sub.add_parser( "setup", help="Verify the op CLI, set account / token env var, and enable", ) setup.add_argument( "--account", help="1Password account shorthand or sign-in address (op --account)", ) setup.add_argument( "--token-env", help=f"Env var holding a service-account token (default {_DEFAULT_TOKEN_ENV})", ) setup.add_argument( "--token", help="Service-account token to store in .env non-interactively", ) setup.add_argument( "--binary-path", help="Absolute path to the op binary (skips PATH lookup)", ) setup.set_defaults(func=cmd_setup) status = sub.add_parser("status", help="Show config + op binary + references") status.set_defaults(func=cmd_status) set_p = sub.add_parser("set", help="Map an env var to an op:// reference") set_p.add_argument("env_var", help="Environment variable name, e.g. OPENAI_API_KEY") set_p.add_argument("reference", help="1Password reference, e.g. op://Private/OpenAI/api key") set_p.set_defaults(func=cmd_set) remove = sub.add_parser("remove", help="Remove an env-var → reference mapping") remove.add_argument("env_var", help="Environment variable name to unmap") remove.set_defaults(func=cmd_remove) sync = sub.add_parser("sync", help="Resolve references now and report what changed") sync.add_argument( "--apply", action="store_true", help="Actually export resolved values into the current shell (default: dry-run)", ) sync.set_defaults(func=cmd_sync) disable = sub.add_parser("disable", help="Turn off the 1Password integration") disable.set_defaults(func=cmd_disable) # --------------------------------------------------------------------------- # Handlers # --------------------------------------------------------------------------- def cmd_setup(args: argparse.Namespace) -> int: console = Console() console.print( Panel.fit( "[bold]1Password secret source setup[/bold]\n\n" "Hermes resolves [cyan]op://vault/item/field[/cyan] references through your\n" "already-installed, already-authenticated 1Password CLI (`op`).\n\n" f"Don't have it yet? Install + sign in: [cyan]{_DOCS_URL}[/cyan]", border_style="cyan", ) ) cfg = load_config() op_cfg = cfg.setdefault("secrets", {}).setdefault("onepassword", {}) # ------------------------------------------------------------------ binary console.print() console.print("[bold]Step 1[/bold] Locate the op CLI") binary_path = (args.binary_path or op_cfg.get("binary_path", "") or "").strip() binary = op_src.find_op(binary_path) if binary is None: if binary_path: console.print(f" [red]✗ {binary_path} is not an executable op binary.[/red]") else: console.print(" [red]✗ op not found on PATH.[/red]") console.print(f" Install the 1Password CLI: {_DOCS_URL}") return 1 console.print(f" [green]✓[/green] {binary} ({_op_version(binary)})") if binary_path: op_cfg["binary_path"] = binary_path # ----------------------------------------------------------------- account if args.account and args.account.strip(): op_cfg["account"] = args.account.strip() console.print(f" Account: [cyan]{op_cfg['account']}[/cyan]") # ------------------------------------------------------------------- token console.print() console.print("[bold]Step 2[/bold] Authentication") token_env = (args.token_env or op_cfg.get("service_account_token_env") or _DEFAULT_TOKEN_ENV).strip() op_cfg["service_account_token_env"] = token_env token = (args.token or "").strip() if token: save_env_value(token_env, token) os.environ[token_env] = token console.print(f" [green]✓[/green] service-account token stored in " f"{get_env_path()} as {token_env}") elif os.environ.get(token_env): console.print(f" [green]✓[/green] using service-account token from {token_env}") else: who = _op_whoami(binary, op_cfg.get("account", "")) if who: console.print(f" [green]✓[/green] using existing op session ({who})") else: console.print( " [yellow]No service-account token and no active op session " "detected.[/yellow]\n" " Either run [cyan]op signin[/cyan] (desktop/interactive) or set a " f"service-account token in {token_env}, then re-run status." ) # ----------------------------------------------------------------- enable op_cfg["enabled"] = True op_cfg.setdefault("env", {}) op_cfg.setdefault("cache_ttl_seconds", 300) op_cfg.setdefault("override_existing", True) save_config(cfg) console.print() console.print("[green]✓ 1Password secret source is enabled.[/green]") console.print( " Map credentials: [cyan]hermes secrets onepassword set OPENAI_API_KEY " "\"op://Private/OpenAI/api key\"[/cyan]\n" " Preview: [cyan]hermes secrets onepassword sync[/cyan]\n" " Status: [cyan]hermes secrets onepassword status[/cyan]" ) return 0 def cmd_status(args: argparse.Namespace) -> int: console = Console() cfg = load_config() op_cfg = (cfg.get("secrets") or {}).get("onepassword") or {} enabled = bool(op_cfg.get("enabled")) account = str(op_cfg.get("account", "") or "").strip() token_env = op_cfg.get("service_account_token_env", _DEFAULT_TOKEN_ENV) binary_path = str(op_cfg.get("binary_path", "") or "").strip() references = op_cfg.get("env") if isinstance(op_cfg.get("env"), dict) else {} token_set = bool(os.environ.get(token_env)) binary = op_src.find_op(binary_path) table = Table(show_header=False, box=None, padding=(0, 2)) table.add_column("", style="bold") table.add_column("") table.add_row("Enabled", _yn(enabled)) table.add_row("Account", account or "[dim]default[/dim]") table.add_row("Token env var", token_env) table.add_row("Token in env", _yn(token_set)) table.add_row("Override existing", _yn(bool(op_cfg.get("override_existing", True)))) table.add_row("Cache TTL (s)", str(op_cfg.get("cache_ttl_seconds", 300))) if binary: table.add_row("op binary", f"{binary} ({_op_version(binary)})") else: table.add_row("op binary", "[yellow]not found[/yellow]") table.add_row("References", str(len(references))) console.print(Panel(table, title="1Password secret source", border_style="cyan")) if references: ref_table = Table(show_header=True, header_style="bold") ref_table.add_column("Env var", style="cyan") ref_table.add_column("Reference") for name in sorted(references): ref_table.add_row(name, str(references[name])) console.print(ref_table) if not enabled: console.print("\n Run [cyan]hermes secrets onepassword setup[/cyan] to enable.") return 0 if binary and not token_set: who = _op_whoami(binary, account) if who: console.print(f"\n [green]Active op session:[/green] {who}") else: console.print( f"\n [yellow]No active op session and {token_env} is unset — " "Hermes will warn and skip 1Password on next startup.[/yellow]" ) if not references: console.print( "\n [yellow]No references mapped yet.[/yellow] Add one: " "[cyan]hermes secrets onepassword set ENV_VAR \"op://…\"[/cyan]" ) return 0 def cmd_set(args: argparse.Namespace) -> int: console = Console() # Reuse the backend validator so the CLI and startup paths agree on what a # valid reference is — and store the *validated/stripped* value, not the # raw arg (so trailing whitespace never lands in config.yaml). valid, warnings = op_src._validate_references({args.env_var: args.reference}) if args.env_var not in valid: for w in warnings: console.print(f"[red]{w}[/red]") return 1 cfg = load_config() op_cfg = cfg.setdefault("secrets", {}).setdefault("onepassword", {}) env_map = op_cfg.get("env") if not isinstance(env_map, dict): env_map = {} op_cfg["env"] = env_map env_map[args.env_var] = valid[args.env_var] save_config(cfg) console.print( f"[green]✓[/green] mapped [cyan]{args.env_var}[/cyan] → " f"{valid[args.env_var]}" ) if not op_cfg.get("enabled"): console.print( " [yellow]Note: the integration is disabled — run " "[cyan]hermes secrets onepassword setup[/cyan] to turn it on.[/yellow]" ) return 0 def cmd_remove(args: argparse.Namespace) -> int: console = Console() cfg = load_config() op_cfg = cfg.setdefault("secrets", {}).setdefault("onepassword", {}) env_map = op_cfg.get("env") if not isinstance(env_map, dict) or args.env_var not in env_map: console.print(f"[yellow]{args.env_var} is not mapped.[/yellow]") return 1 del env_map[args.env_var] save_config(cfg) console.print(f"[green]✓[/green] removed mapping for [cyan]{args.env_var}[/cyan]") return 0 def cmd_sync(args: argparse.Namespace) -> int: console = Console() cfg = load_config() op_cfg = (cfg.get("secrets") or {}).get("onepassword") or {} if not op_cfg.get("enabled"): console.print( "[yellow]1Password integration is disabled. Run " "`hermes secrets onepassword setup` first.[/yellow]" ) return 1 references = op_cfg.get("env") if isinstance(op_cfg.get("env"), dict) else {} if not references: console.print( "[yellow]No op:// references configured. Add one with " "`hermes secrets onepassword set ENV_VAR \"op://…\"`.[/yellow]" ) return 0 account = str(op_cfg.get("account", "") or "").strip() token_env = op_cfg.get("service_account_token_env", _DEFAULT_TOKEN_ENV) binary_path = str(op_cfg.get("binary_path", "") or "").strip() # --apply delegates to the same code path startup uses, so the skip / # override / token-guard policy lives in exactly one place. if args.apply: result = op_src.apply_onepassword_secrets( enabled=True, env=references, account=account, service_account_token_env=token_env, binary_path=binary_path, override_existing=bool(op_cfg.get("override_existing", True)), cache_ttl_seconds=0, # an explicit sync always resolves fresh ) if result.error: console.print(f"[red]{result.error}[/red]") return 1 table = Table(show_header=True, header_style="bold") table.add_column("Env var", style="cyan") table.add_column("Action") for name in sorted(result.applied): table.add_row(name, "[green]exported[/green]") for name in sorted(result.skipped): table.add_row(name, "[dim]skipped (already set / token var)[/dim]") console.print(table) for w in result.warnings: console.print(f"[yellow]warning:[/yellow] {w}") console.print( f"\n [green]Exported {len(result.applied)} secret(s) into current " "process.[/green]" ) return 0 # Dry-run: resolve fresh (no cache) and preview, mutating nothing. try: secrets, warnings = op_src.fetch_onepassword_secrets( references=references, account=account, token_env=token_env, binary_path=binary_path, use_cache=False, ) except RuntimeError as exc: console.print(f"[red]{exc}[/red]") return 1 override = bool(op_cfg.get("override_existing", True)) table = Table(show_header=True, header_style="bold") table.add_column("Env var", style="cyan") table.add_column("Action") for name in sorted(references): if name == token_env: table.add_row(name, "[dim]skip (token var)[/dim]") elif name not in secrets: table.add_row(name, "[red]unresolved (see warnings)[/red]") elif os.environ.get(name) and not override: table.add_row(name, "[dim]skip (already set)[/dim]") else: already = bool(os.environ.get(name)) table.add_row( name, "[green]would export[/green]" + (" (overrides)" if already else ""), ) console.print(table) for w in warnings: console.print(f"[yellow]warning:[/yellow] {w}") console.print( "\n This was a dry-run — references resolve automatically on the next " "[cyan]hermes[/cyan] invocation. Re-run with [cyan]--apply[/cyan] to export " "into the current shell instead." ) return 0 def cmd_disable(args: argparse.Namespace) -> int: console = Console() cfg = load_config() op_cfg = cfg.setdefault("secrets", {}).setdefault("onepassword", {}) op_cfg["enabled"] = False save_config(cfg) console.print( "[green]Disabled.[/green] 1Password references will NOT be resolved on the " "next Hermes invocation.\n" " Your reference mappings are left in config.yaml — remove them with " "[cyan]hermes secrets onepassword remove ENV_VAR[/cyan] if you no longer " "need them." ) return 0 # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _yn(b: bool) -> str: return "[green]yes[/green]" if b else "[dim]no[/dim]" def _op_version(binary: Path) -> str: try: res = subprocess.run( [str(binary), "--version"], capture_output=True, text=True, timeout=5, ) if res.returncode == 0: return (res.stdout or res.stderr).strip().splitlines()[0] except (OSError, subprocess.TimeoutExpired): pass return "version unknown" def _op_whoami(binary: Path, account: str) -> Optional[str]: """Return a short identity string if op is authenticated, else None.""" cmd = [str(binary), "whoami"] if account: cmd += ["--account", account] try: res = subprocess.run(cmd, capture_output=True, text=True, timeout=10) except (OSError, subprocess.TimeoutExpired): return None if res.returncode != 0: return None out = (res.stdout or "").strip() return out.replace("\n", " ")[:120] or "authenticated"