From 41bf233a952d4d1e9cf994e3beb75a29d2400e73 Mon Sep 17 00:00:00 2001 From: Ari Mayer Date: Wed, 1 Jul 2026 23:12:21 -0400 Subject: [PATCH] feat(skills): ship version-matched agent guides via `pass-cli skills` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pass-cli is evolving into a tool for humans *and* their AI agents, but the only agent-facing usage guidance lived in a hand-written personal skill on one machine — stale, unversioned, and invisible to anyone else. Adopt the agent-browser pattern: the CLI now self-serves its own skills, embedded in the binary via //go:embed so the guidance always matches the installed version and can't drift from shipped behavior. - `pass-cli skills list` / `skills get core [--full]` — a canonical safe-usage guide (exec/export/inject/agent/list/get + the leak traps) plus a full command reference, rewritten against the current binary. - `pass-cli skills install [--dir ] [--force]` — writes a thin discovery stub into a detected agent skills dir (~/.claude/skills or ~/.agents/skills) that points agents at `skills get core`; preserves an existing, differing stub unless --force. - New `internal/skills` package (first //go:embed in the repo) is the single source of truth; `cmd/skills.go` is a thin wrapper. Docs + CHANGELOG added. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014kVLjbUL4F4CkoA7RbMYy8 --- CHANGELOG.md | 3 + cmd/skills.go | 225 +++++++++++++++++++++ cmd/skills_test.go | 102 ++++++++++ docs/05-operations/_index.md | 1 + docs/05-operations/agent-skills.md | 45 +++++ internal/skills/data/skills/core.full.md | 131 +++++++++++++ internal/skills/data/skills/core.md | 239 +++++++++++++++++++++++ internal/skills/data/stub.md | 39 ++++ internal/skills/skills.go | 148 ++++++++++++++ internal/skills/skills_test.go | 141 +++++++++++++ 10 files changed, 1074 insertions(+) create mode 100644 cmd/skills.go create mode 100644 cmd/skills_test.go create mode 100644 docs/05-operations/agent-skills.md create mode 100644 internal/skills/data/skills/core.full.md create mode 100644 internal/skills/data/skills/core.md create mode 100644 internal/skills/data/stub.md create mode 100644 internal/skills/skills.go create mode 100644 internal/skills/skills_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index d2946f2..59e7442 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **`skills` command — self-served, version-matched agent guides** — `pass-cli skills` ships an AI-agent usage guide embedded in the binary, mirroring the pattern that makes tools like agent-browser easy for agents to adopt: `pass-cli skills list` lists the available skills and `pass-cli skills get core` prints the safe-usage guide (`exec`/`export`/`inject`/`agent`/`list`/`get` plus the leak traps to avoid), with `--full` appending a complete command reference. Because the content ships *in the binary* (via `//go:embed`), the guidance always matches the installed version and can't drift from shipped behavior. `pass-cli skills install` writes a small discovery stub into a detected agent skills directory (`~/.claude/skills` or `~/.agents/skills`, or `--dir `) that points agents at `pass-cli skills get core`; an existing, differing stub is preserved unless `--force` is given. This is the first step toward pass-cli being a first-class tool for humans **and** their AI agents. + ## [0.19.0] - 2026-07-02 ### Added diff --git a/cmd/skills.go b/cmd/skills.go new file mode 100644 index 0000000..869534b --- /dev/null +++ b/cmd/skills.go @@ -0,0 +1,225 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "text/tabwriter" + + "github.com/arimxyer/pass-cli/internal/skills" + + "github.com/spf13/cobra" +) + +var ( + skillsGetFull bool + skillsInstallDir string + skillsInstallForce bool +) + +var skillsCmd = &cobra.Command{ + Use: "skills", + GroupID: "utilities", + Short: "Agent-facing usage guides shipped with this binary", + Long: `Skills are version-matched usage guides for driving pass-cli as an AI +agent. They ship embedded in this binary, so the guidance always matches the +installed version and never goes stale. + +For AI agents: start with the core safe-usage guide. + + pass-cli skills get core # exec/export/inject/agent/list/get + leak traps + pass-cli skills get core --full # also include the full command reference + +Run 'pass-cli skills install' to drop a small discovery stub into your agent's +skills directory (e.g. ~/.claude/skills) that points back at this command. + +Examples: + # List available skills + pass-cli skills list + + # Print the core agent guide + pass-cli skills get core + + # Install the discovery stub for AI agents + pass-cli skills install`, + // No RunE: bare `pass-cli skills` prints help (its subcommands), matching + // the other parent commands (vault, config, keychain). +} + +var skillsListCmd = &cobra.Command{ + Use: "list", + Short: "List available skills", + Args: cobra.NoArgs, + RunE: runSkillsList, +} + +var skillsGetCmd = &cobra.Command{ + Use: "get ", + Short: "Print a skill's guide to stdout", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + content, err := skills.Get(args[0], skillsGetFull) + if err != nil { + return err + } + _, err = fmt.Fprint(cmd.OutOrStdout(), content) + return err + }, +} + +var skillsInstallCmd = &cobra.Command{ + Use: "install", + Short: "Write the agent discovery stub into a skills directory", + Long: `Install writes a small discovery stub (a Claude Code / agent skill) that +points AI agents at 'pass-cli skills get core'. The stub is intentionally thin +so it never goes stale; the real guidance is served by the CLI. + +By default the stub is written to the first existing of ~/.claude/skills or +~/.agents/skills (falling back to creating ~/.claude/skills), as +/pass-cli/SKILL.md. Use --dir to choose a different location. + +An existing stub that differs is left untouched unless --force is given.`, + Args: cobra.NoArgs, + RunE: runSkillsInstall, +} + +func init() { + rootCmd.AddCommand(skillsCmd) + skillsCmd.AddCommand(skillsListCmd) + skillsCmd.AddCommand(skillsGetCmd) + skillsCmd.AddCommand(skillsInstallCmd) + + skillsGetCmd.Flags().BoolVar(&skillsGetFull, "full", false, "include the full command reference") + skillsInstallCmd.Flags().StringVar(&skillsInstallDir, "dir", "", "skills directory to install into (default: auto-detect)") + skillsInstallCmd.Flags().BoolVar(&skillsInstallForce, "force", false, "overwrite an existing, differing stub") +} + +func runSkillsList(cmd *cobra.Command, _ []string) error { + list, err := skills.List() + if err != nil { + return err + } + var sb strings.Builder + sb.WriteString("Available skills:\n") + tw := tabwriter.NewWriter(&sb, 0, 0, 2, ' ', 0) + for _, s := range list { + _, _ = fmt.Fprintf(tw, " %s\t%s\n", s.Name, summaryLine(s.Description)) + } + if err := tw.Flush(); err != nil { + return err + } + sb.WriteString("\nRun 'pass-cli skills get ' to print a guide (add --full for the command reference).\n") + + _, err = fmt.Fprint(cmd.OutOrStdout(), sb.String()) + return err +} + +func runSkillsInstall(cmd *cobra.Command, _ []string) error { + dir, err := resolveSkillsDir(skillsInstallDir) + if err != nil { + return err + } + msg, err := installStub(dir, skillsInstallForce) + if err != nil { + return err + } + _, err = fmt.Fprintln(cmd.OutOrStdout(), msg) + return err +} + +// installStub writes the discovery stub to /pass-cli/SKILL.md and returns a +// human-readable status message. An existing stub that already matches is left +// untouched (idempotent); an existing stub that differs is preserved unless +// force is set. Kept separate from the Cobra command so the write behavior is +// unit-testable against a temp dir. +func installStub(dir string, force bool) (string, error) { + stub, err := skills.StubContent() + if err != nil { + return "", err + } + target := filepath.Join(dir, "pass-cli", "SKILL.md") + + if existing, err := os.ReadFile(target); err == nil { + if string(existing) == stub { + return "Already up to date: " + target, nil + } + if !force { + return "", fmt.Errorf("a different stub already exists at %s; re-run with --force to overwrite", target) + } + } + + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return "", fmt.Errorf("failed to create %s: %w", filepath.Dir(target), err) + } + if err := os.WriteFile(target, []byte(stub), 0o644); err != nil { + return "", fmt.Errorf("failed to write %s: %w", target, err) + } + + return "Installed discovery stub: " + target + + "\nAI agents will now be pointed at 'pass-cli skills get core'.", nil +} + +// resolveSkillsDir picks the directory to install the stub into. An explicit +// override is expanded and returned as-is; otherwise the first existing of +// ~/.claude/skills or ~/.agents/skills is used, falling back to ~/.claude/skills. +func resolveSkillsDir(override string) (string, error) { + if override != "" { + return expandHome(override), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("cannot determine home directory: %w", err) + } + candidates := []string{ + filepath.Join(home, ".claude", "skills"), + filepath.Join(home, ".agents", "skills"), + } + for _, c := range candidates { + if info, err := os.Stat(c); err == nil && info.IsDir() { + return c, nil + } + } + return candidates[0], nil +} + +func expandHome(path string) string { + if path == "~" || len(path) >= 2 && path[:2] == "~/" { + if home, err := os.UserHomeDir(); err == nil { + if path == "~" { + return home + } + return filepath.Join(home, path[2:]) + } + } + return path +} + +// summaryLine renders a skill's one-line summary for `skills list`. The +// frontmatter description is written long and keyword-rich for AI-agent +// discovery matching, so for the human list we take just its first sentence +// (each description leads with a self-contained one), with a generous rune cap +// as a backstop against a description that has no sentence break. +func summaryLine(desc string) string { + desc = strings.TrimSpace(desc) + if i := strings.Index(desc, ". "); i >= 0 { + desc = desc[:i+1] // keep the period, drop the rest + } + return truncate(desc, 120) +} + +// truncate shortens s to at most max runes (not bytes), appending an ellipsis +// when it cuts. Rune-aware so a multi-byte character near the limit isn't split. +func truncate(s string, max int) string { + if max <= 0 { + return "" + } + runes := []rune(s) + if len(runes) <= max { + return s + } + if max == 1 { + return string(runes[:1]) + } + return string(runes[:max-1]) + "…" +} diff --git a/cmd/skills_test.go b/cmd/skills_test.go new file mode 100644 index 0000000..6073412 --- /dev/null +++ b/cmd/skills_test.go @@ -0,0 +1,102 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/arimxyer/pass-cli/internal/skills" +) + +func TestInstallStub(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "pass-cli", "SKILL.md") + stub, err := skills.StubContent() + if err != nil { + t.Fatalf("StubContent: %v", err) + } + + // Fresh install writes the stub verbatim. + msg, err := installStub(dir, false) + if err != nil { + t.Fatalf("fresh install: %v", err) + } + if !strings.Contains(msg, "Installed discovery stub") { + t.Errorf("fresh install message = %q", msg) + } + got, err := os.ReadFile(target) + if err != nil { + t.Fatalf("reading written stub: %v", err) + } + if string(got) != stub { + t.Error("written stub does not match StubContent()") + } + + // Re-install with identical content is an idempotent no-op. + msg, err = installStub(dir, false) + if err != nil { + t.Fatalf("idempotent install: %v", err) + } + if !strings.Contains(msg, "Already up to date") { + t.Errorf("idempotent message = %q", msg) + } + + // A differing existing stub is preserved unless --force. + if err := os.WriteFile(target, []byte("changed by user\n"), 0o644); err != nil { + t.Fatalf("mutating stub: %v", err) + } + if _, err := installStub(dir, false); err == nil { + t.Error("install without force overwrote a differing stub") + } + if kept, _ := os.ReadFile(target); string(kept) != "changed by user\n" { + t.Error("differing stub was clobbered despite no --force") + } + + // --force overwrites it back to the canonical stub. + if _, err := installStub(dir, true); err != nil { + t.Fatalf("force install: %v", err) + } + if forced, _ := os.ReadFile(target); string(forced) != stub { + t.Error("--force did not restore the canonical stub") + } +} + +func TestSummaryLine(t *testing.T) { + cases := []struct { + in string + want string + }{ + // First sentence only — no mid-word truncation. + {"Core guide for driving pass-cli safely. Read this before anything.", "Core guide for driving pass-cli safely."}, + // No sentence break → returned as-is (within the cap). + {"A single clause with no period", "A single clause with no period"}, + {" leading/trailing space trimmed. rest ", "leading/trailing space trimmed."}, + } + for _, c := range cases { + if got := summaryLine(c.in); got != c.want { + t.Errorf("summaryLine(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestTruncate(t *testing.T) { + cases := []struct { + in string + max int + want string + }{ + {"short", 100, "short"}, + {"", 5, ""}, + {"abcdef", 4, "abc…"}, + {"abc", 3, "abc"}, + {"abcd", 0, ""}, + // Multi-byte runes must not be split mid-character. + {"héllo wörld", 5, "héll…"}, + } + for _, c := range cases { + if got := truncate(c.in, c.max); got != c.want { + t.Errorf("truncate(%q, %d) = %q, want %q", c.in, c.max, got, c.want) + } + } +} diff --git a/docs/05-operations/_index.md b/docs/05-operations/_index.md index 6688d9e..ab82cef 100644 --- a/docs/05-operations/_index.md +++ b/docs/05-operations/_index.md @@ -9,4 +9,5 @@ Operational guides for maintaining, monitoring, and securing pass-cli in product {{< card link="health-checks" title="Health Checks" icon="heart" subtitle="System diagnostics and vault health monitoring" >}} {{< card link="security-operations" title="Security Operations" icon="shield-exclamation" subtitle="Best practices, checklists, and incident response" >}} {{< card link="agent" title="Background Agent" icon="lightning-bolt" subtitle="Promptless, cached credential access via a local socket" >}} + {{< card link="agent-skills" title="AI Agent Skills" icon="sparkles" subtitle="Version-matched agent usage guides served by the CLI" >}} {{< /cards >}} diff --git a/docs/05-operations/agent-skills.md b/docs/05-operations/agent-skills.md new file mode 100644 index 0000000..a2a207d --- /dev/null +++ b/docs/05-operations/agent-skills.md @@ -0,0 +1,45 @@ +--- +title: "AI Agent Skills" +weight: 4 +toc: true +--- + +pass-cli ships an **agent usage guide inside the binary**. Any AI agent (Claude +Code, Cursor, Codex, …) driving pass-cli can load version-matched guidance +straight from the CLI, so the instructions never drift from the installed +version. + +## For agents: start here + +```sh +pass-cli skills get core # safe-usage guide: exec/export/inject/agent/list/get + leak traps +pass-cli skills get core --full # also include the full command reference +pass-cli skills list # list every skill shipped with this version +``` + +`skills get core` is the entry point. It explains the one rule that matters — +**never let a secret value land in the transcript, a log, or a watched file** — +and which command to reach for (`exec` by default; `inject` for composite +secrets; `list -q` to discover services; `get` only as a last resort). + +## Installing the discovery stub + +`skills install` drops a small skill file into your agent's skills directory so +the agent discovers pass-cli automatically and is pointed at `skills get core`: + +```sh +pass-cli skills install # auto-detects ~/.claude/skills or ~/.agents/skills +pass-cli skills install --dir # choose a specific directory +pass-cli skills install --force # overwrite an existing, differing stub +``` + +The stub is intentionally thin — the real, version-matched guidance stays in the +binary. Re-running `skills install` after upgrading pass-cli is safe (it's a +no-op when the stub is already current). + +## Why embedded, not a static doc + +The skill content is compiled into the binary via `//go:embed`, so it's always +in lockstep with the commands and flags that binary actually has. A stale copy +pasted into a project's docs or a user's dotfiles can't drift out of sync, +because agents read the guide from the CLI itself. diff --git a/internal/skills/data/skills/core.full.md b/internal/skills/data/skills/core.full.md new file mode 100644 index 0000000..f8d1fe5 --- /dev/null +++ b/internal/skills/data/skills/core.full.md @@ -0,0 +1,131 @@ +## Full command reference + +Condensed flag reference for the agent-facing commands. `pass-cli --help` +always has the authoritative, version-matched detail. + +### Mapping grammar (shared by `exec` and `export`) + +``` +--set ENV_NAME=service[/field][|filter] # repeatable +``` + +- `service` — the credential's service name. +- `/field` — one of `username, password, category, url, notes, service` + (default `password`). Legacy `:field` also accepted; prefer `/`. +- `|filter` — one of `base64`, `base64url`, `basicauth` (quote the `|` in a + shell). `basicauth` takes no field. +- Convenience positional form: `pass-cli exec service …` derives the ENV name + from the service (uppercased, non-alphanumerics → `_`). Filters are **not** + supported on this form. + +### `exec` — run a command with injected env + +| Flag | Meaning | +|---|---| +| `--set ENV=service[/field][\|filter]` | map an env var to a credential (repeatable) | +| `-f, --field ` | field for all `--set` mappings (default `password`) | +| `--env-file ` | read `KEY=${pass:svc/field}` template lines (repeatable) | +| `--from ` | read `ENV=service/field` from a `.pass-cli.toml` manifest (repeatable) | + +Everything after `--` is the child command; its exit code is propagated. +Read-only: no usage tracking, no sync push. + +### `export` — print shell statements to eval + +| Flag | Meaning | +|---|---| +| `--set`, `-f/--field`, `--from` | same as `exec` | +| `--format ` | shell syntax (default `sh`) | + +`sh` → `export NAME='value'` (for `eval "$(…)"`); `fish` → `set -gx NAME 'value'` +(for `… | source`); `powershell` → `$env:NAME = 'value'`. + +### `inject` — render a template with `${pass:...}` refs + +| Flag | Meaning | +|---|---| +| `-i, --in-file ` | template to read (default stdin) | +| `-o, --out-file ` | output file, created `0600` (default stdout) | +| `-f, --field ` | default field for refs without an explicit `/field` | + +Reference forms: `${pass:service}`, `${pass:service/field}`, +`${pass:service/field | filter}`. Unknown/malformed ref → hard error, nothing +written. Piped-stdin templates unlock via keychain only (use `-i` for the +password prompt). + +### `agent` — background daemon holding the unlocked vault + +| Subcommand | Meaning | +|---|---| +| `agent start` | unlock once, detach into the background | +| `agent serve` | run in the foreground (same as bare `agent`) | +| `agent status` | show status (never prints secrets) | +| `agent stop` | lock the vault and exit | + +| Flag | Meaning | +|---|---| +| `--idle ` | lock after this much inactivity (default `15m`, `0` = never) | +| `--max-ttl ` | hard cap on unlocked lifetime (default `8h`, `0` = no cap) | + +Socket: `$PASS_CLI_AGENT_SOCK`, else `$XDG_RUNTIME_DIR/pass-cli/agent.sock`, else +`~/.pass-cli/agent.sock`. Serves resolved **values only**. Commands fall back to +direct unlock when no agent is running. + +### `list` — discover services + +| Flag | Meaning | +|---|---| +| `-q, --quiet` | bare service names, one per line (alias for `--format simple`) | +| `-f, --format ` | output format (default `table`) | +| `--show-usernames` | include the username column (hidden by default) | +| `--unused [--days N]` | only credentials unused for ≥ N days (default 30) | +| `--by-project` | group by git repository | +| `--location [--recursive]` | filter by access directory | + +Usernames hidden by default (they can hold sensitive values). `--format json` +emits full metadata including usernames. + +### `get` — retrieve one credential (last resort for raw values) + +| Flag | Meaning | +|---|---| +| `-q, --quiet` | output only the requested value (script-friendly) | +| `-f, --field ` | field to extract (default `password`) | +| `--no-clipboard` | do not copy to clipboard | +| `--masked` | display password as asterisks | +| `--totp` / `--totp-qr` / `--totp-qr-file ` | TOTP code / QR | + +Even with `--quiet --no-clipboard` the value is on stdout — see the leak trap in +the core guide. Prefer `exec`. + +### `generate` — cryptographically secure password + +| Flag | Meaning | +|---|---| +| `-l, --length N` | length (default 20) | +| `--no-lower` / `--no-upper` / `--no-digits` / `--no-symbols` | exclude a class | +| `--no-clipboard` | do not copy to clipboard | + +Aliases: `gen`, `pwd`. + +### `.pass-cli.toml` manifest (for `--from`) + +A committable file — references only, never values: + +```toml +[env] +GITHUB_TOKEN = "github" +DB_PASSWORD = "postgres/password" +API_TOKEN = "api/token|base64" +``` + +`pass-cli exec --from .pass-cli.toml -- ./server` or +`eval "$(pass-cli export --from .pass-cli.toml)"`. + +### Global flags (all commands) + +| Flag | Meaning | +|---|---| +| `--config ` | config file (default `$HOME/.pass-cli/config.yaml`) | +| `--offline` | skip cloud sync for this command | +| `-v, --verbose` | verbose output | diff --git a/internal/skills/data/skills/core.md b/internal/skills/data/skills/core.md new file mode 100644 index 0000000..c1acaa0 --- /dev/null +++ b/internal/skills/data/skills/core.md @@ -0,0 +1,239 @@ +--- +name: core +description: Core guide for driving pass-cli safely as an AI agent. Read this before running any pass-cli command that touches a secret. Covers the decision between exec/export/inject/list/get, the env-injection grammar (--set, --field, service/field, value filters base64/base64url/basicauth, --env-file, --from manifest), the background agent daemon for promptless resolves, and the leak traps to avoid so a secret never lands in the transcript, logs, or a watched file. +--- + +# Using pass-cli safely as an agent + +pass-cli is a local, offline-first password manager. Its vault is a single +AES-GCM-encrypted file. As an agent you will mostly need it to **give a stored +secret to a command you're about to run** — and the cardinal rule is: + +> **Never let a secret value land in the transcript, a log, CI output, or any +> file a tool watches.** Once a secret is in the conversation log on disk it is +> compromised and must be rotated. + +Every command below exists to move a secret from the vault into a consumer +**without it passing through stdout, the clipboard, your shell history, or a +`set -x` trace.** + +## The decision: how do you need the secret? + +| You need to… | Use | Why | +|---|---|---| +| Run a command that reads the secret from its environment | **`exec`** | Secret goes straight into the child's env; scoped to that one process; nothing on stdout | +| Load a secret into your *current* shell (last resort for env) | `export` | Weaker than `exec` — the whole shell + its children can read it | +| Build a config file / connection string that *embeds* a secret | **`inject`** | Templating: `${pass:svc/field}` references get replaced; the only tool for composite/derived secrets | +| Know *which* services exist | **`list -q`** | Bare service names; usernames hidden by default | +| Capture a raw value into a variable (last resort) | `get --quiet --no-clipboard` | Only when nothing else can express it; see the trap below | + +**Default to `exec`.** Reach for `export`/`inject` only when `exec` can't express +the shape, and `get` only when nothing else works. + +If an agent will do **many** lookups in a session, start the **agent daemon** +once (see below) so every `exec`/`export`/`inject` resolves with no +master-password prompt. + +## `exec` — hand a secret to a command (the default) + +Runs a child command with credentials injected as environment variables. The +value is passed **only** through the child's environment — never a file, the +clipboard, or shell history. pass-cli writes nothing of its own to stdout, and +the child's exit code is propagated unchanged. + +```bash +# Explicit mapping (repeatable): --set ENV_NAME=service +pass-cli exec --set GITHUB_TOKEN=github -- gh repo list + +# Multiple credentials at once +pass-cli exec --set AWS_ACCESS_KEY_ID=aws-id --set AWS_SECRET_ACCESS_KEY=aws-secret -- aws s3 ls + +# Pick a non-password field for ALL mappings with -f/--field +pass-cli exec --set DB_USER=postgres --field username -- ./run-migration.sh + +# Per-mapping field with service/field (overrides -f for that one entry) +pass-cli exec --set DB_USER=postgres/username --set DB_PASSWORD=postgres/password -- ./run.sh + +# Convenience form: derive ENV name from the service (openai-api -> OPENAI_API) +pass-cli exec openai-api -- python train.py +``` + +Key facts: +- **`--`** separates pass-cli's flags from the child command. Everything after + `--` is the child's argv. Omitting it is an error ("no command to run"). +- **`-f/--field`** (default `password`) selects the field for *all* `--set` + mappings. Valid fields: `username, password, category, url, notes, service`. +- **`service/field`** in a `--set` overrides `-f` for that single mapping. The + legacy `service:field` separator still works, but prefer `/` — in slash form + any `:` in the service name is a literal character. +- **Exit code is propagated** — `pass-cli exec … -- sh -c 'exit 7'` exits 7. + Safe in `&&`/`||` chains and CI gates. +- **`exec` is read-only**: it does NOT record field-access usage and does NOT + trigger a sync push, so calling it in a hot loop won't mutate the vault or hit + the network on every run. +- **Two more mapping sources** for composite/committed setups (compose with + `--set`): + - `--env-file ` — lines of `KEY=