From 7bcb82e5ca21cd1df9db8c6f5dddb91e89ee47b8 Mon Sep 17 00:00:00 2001 From: Lorem Dev Date: Wed, 20 May 2026 20:13:26 +0200 Subject: [PATCH 01/25] docs: drop Touch ID wording in favor of generic Keychain references Touch ID is just one of the auth methods the macOS Keychain may use; the plugin does not control which one. Replace explicit Touch ID mentions in CLI help, README, configuration and architecture docs, and plugin READMEs with neutral wording about OS-delegated Keychain authorization. The gopass plugin no longer mentions Touch ID either, keeping only the GPG passphrase / smartcard story. --- CHANGES.md | 4 ++++ README.md | 10 +++++----- docs/architecture.md | 2 +- docs/configuration.md | 4 ++-- internal/cli/root.go | 2 +- plugins/gopass/README.md | 2 +- plugins/gopass/provider.go | 2 +- plugins/keychain/README.md | 7 +++---- 8 files changed, 18 insertions(+), 15 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index d0d0edf..6ed6999 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,10 @@ ## Development +- docs(cli): drop Touch ID wording from CLI help, README, configuration + and architecture docs, and plugin READMEs; keychain authorization is + described as OS-delegated without naming a specific auth method + ## Version v0.3.0 - 2026-05-15 ### Features diff --git a/README.md b/README.md index dbaba5f..b816c9f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Secure middleware that gives AI agents access to secrets stored in vault providers (macOS Keychain, gopass), with per-session caching and -vault-delegated authorization (Touch ID, GPG passphrase). +vault-delegated authorization (Keychain, GPG passphrase). ## Installation @@ -163,7 +163,7 @@ Streamable HTTP is tried first; on `404`/`405` the proxy falls back to SSE. ### Lazy secret resolution `locksmith mcp run` does not contact the vault at startup. The first -vault prompt (Touch ID dialog, GPG passphrase) for a given MCP server +vault prompt (Keychain dialog, GPG passphrase) for a given MCP server fires only when the AI client sends its first MCP request to that server. MCP servers configured but never used in a session never trigger a prompt. @@ -234,9 +234,9 @@ in `~/.config/locksmith/config.yaml` (default: `true`). | Vault | Platform | Auth | Status | |-------|----------|------|--------| -| macOS Keychain | macOS | Touch ID / password | Supported | -| gopass | macOS, Linux | GPG passphrase / Touch ID | Supported | -| 1Password | macOS, Linux | Touch ID / master password | Planned | +| macOS Keychain | macOS | Keychain | Supported | +| gopass | macOS, Linux | GPG passphrase | Supported | +| 1Password | macOS, Linux | Keychain / master password | Planned | | GNOME Keyring | Linux | Keyring password | Planned | ### Plugin Setup Guides diff --git a/docs/architecture.md b/docs/architecture.md index 4b9b76c..470bed3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -66,7 +66,7 @@ blocked from running based on these checks. Each plugin is a standalone binary implementing the `VaultProviderService` gRPC service: -- `GetSecret` - fetches a secret; triggers vault authorization (Touch ID, passphrase) +- `GetSecret` - fetches a secret; triggers vault authorization (Keychain, passphrase) - `HealthCheck` - verifies the vault is installed and accessible - `Info` - returns plugin name, version, and supported platforms diff --git a/docs/configuration.md b/docs/configuration.md index 02996de..461c763 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -75,7 +75,7 @@ locksmith get --vault my-gopass --path dev/key ### keychain (macOS only) Retrieves secrets from the macOS Keychain using the Security framework. -Authorization (Touch ID or password) is triggered by the OS on each access. +Authorization is triggered by the OS Keychain on each access. > Plugin-specific setup, examples, and troubleshooting: > [`plugins/keychain/README.md`](../plugins/keychain/README.md). @@ -299,7 +299,7 @@ Template tokens: | type | Description | |------|-------------| -| `keychain` | macOS Keychain (CGo, Touch ID) | +| `keychain` | macOS Keychain (CGo) | | `gopass` | gopass password manager (shells out to `gopass` CLI) | Default plugins are placed in `~/.config/locksmith/plugins/` automatically diff --git a/internal/cli/root.go b/internal/cli/root.go index a6bb97b..8ab7270 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -12,7 +12,7 @@ func NewRootCmd() *cobra.Command { root := &cobra.Command{ Use: "locksmith", Short: "Secure secret middleware for AI agents", - Long: "Locksmith gives AI agents secure access to secrets from vault providers (macOS Keychain, gopass, etc.) with per-session caching and Touch ID support.", + Long: "Locksmith gives AI agents secure access to secrets from vault providers (macOS Keychain, gopass, etc.) with per-session caching and vault-delegated authorization.", SilenceErrors: true, SilenceUsage: true, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { diff --git a/plugins/gopass/README.md b/plugins/gopass/README.md index 2be0816..dbc80e8 100644 --- a/plugins/gopass/README.md +++ b/plugins/gopass/README.md @@ -2,7 +2,7 @@ Locksmith vault plugin that retrieves secrets from a [gopass](https://github.com/gopasspw/gopass) password store. Authorization is delegated to `gpg-agent` (passphrase prompt or -Touch ID / smartcard when configured). +smartcard when configured). ## See also diff --git a/plugins/gopass/provider.go b/plugins/gopass/provider.go index 491e23f..14fbb22 100644 --- a/plugins/gopass/provider.go +++ b/plugins/gopass/provider.go @@ -67,7 +67,7 @@ func buildGopassEnv() []string { } // GetSecret fetches a secret from gopass by path. Optionally uses a named store -// via opts["store"]. Authorization (GPG passphrase / Touch ID) is handled by gopass. +// via opts["store"]. Authorization (GPG passphrase) is handled by gopass. func (p *GopassProvider) GetSecret( ctx context.Context, req *vaultv1.GetSecretRequest, diff --git a/plugins/keychain/README.md b/plugins/keychain/README.md index 53fdfe0..5e7f0de 100644 --- a/plugins/keychain/README.md +++ b/plugins/keychain/README.md @@ -1,8 +1,8 @@ # locksmith-plugin-keychain Locksmith vault plugin that retrieves secrets from the macOS Keychain via the -Security framework. Authorization (Touch ID or login password) is triggered by -the OS on each access. +Security framework. Authorization is triggered by the OS Keychain on each +access. ## See also @@ -138,8 +138,7 @@ security add-generic-password -s -a -w '' ``` **`keychain: Authentication failed.` (errSecAuthFailed, -25293)** -Touch ID or login password was denied, or biometrics are not enrolled. Re-run -the command and complete the prompt. +The Keychain prompt was denied. Re-run the command and complete the prompt. **Path with more than one `/`** Only one `/` is allowed in a key path. Use `service/account`, not From bd4faef514b70763c479906db7062c564c973686 Mon Sep 17 00:00:00 2001 From: Lorem Dev Date: Wed, 20 May 2026 20:15:23 +0200 Subject: [PATCH 02/25] chore: add CodeGraph index config and Cursor rule Commits the CodeGraph project config (.codegraph/config.json with its local .gitignore for DB/cache artifacts) and the matching Cursor rule describing when to prefer codegraph_* MCP tools over native search. --- .codegraph/.gitignore | 16 ++++ .codegraph/config.json | 143 ++++++++++++++++++++++++++++++++++++ .cursor/rules/codegraph.mdc | 37 ++++++++++ 3 files changed, 196 insertions(+) create mode 100644 .codegraph/.gitignore create mode 100644 .codegraph/config.json create mode 100644 .cursor/rules/codegraph.mdc diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore new file mode 100644 index 0000000..9de0f16 --- /dev/null +++ b/.codegraph/.gitignore @@ -0,0 +1,16 @@ +# CodeGraph data files +# These are local to each machine and should not be committed + +# Database +*.db +*.db-wal +*.db-shm + +# Cache +cache/ + +# Logs +*.log + +# Hook markers +.dirty diff --git a/.codegraph/config.json b/.codegraph/config.json new file mode 100644 index 0000000..7af60ad --- /dev/null +++ b/.codegraph/config.json @@ -0,0 +1,143 @@ +{ + "version": 1, + "include": [ + "**/*.ts", + "**/*.tsx", + "**/*.js", + "**/*.jsx", + "**/*.py", + "**/*.go", + "**/*.rs", + "**/*.java", + "**/*.c", + "**/*.h", + "**/*.cpp", + "**/*.hpp", + "**/*.cc", + "**/*.cxx", + "**/*.cs", + "**/*.php", + "**/*.rb", + "**/*.swift", + "**/*.kt", + "**/*.kts", + "**/*.dart", + "**/*.svelte", + "**/*.vue", + "**/*.liquid", + "**/*.pas", + "**/*.dpr", + "**/*.dpk", + "**/*.lpr", + "**/*.dfm", + "**/*.fmx", + "**/*.scala", + "**/*.sc" + ], + "exclude": [ + "**/.git/**", + "**/node_modules/**", + "**/vendor/**", + "**/Pods/**", + "**/dist/**", + "**/build/**", + "**/out/**", + "**/bin/**", + "**/obj/**", + "**/target/**", + "**/*.min.js", + "**/*.bundle.js", + "**/.next/**", + "**/.nuxt/**", + "**/.svelte-kit/**", + "**/.output/**", + "**/.turbo/**", + "**/.cache/**", + "**/.parcel-cache/**", + "**/.vite/**", + "**/.astro/**", + "**/.docusaurus/**", + "**/.gatsby/**", + "**/.webpack/**", + "**/.nx/**", + "**/.yarn/cache/**", + "**/.pnpm-store/**", + "**/storybook-static/**", + "**/.expo/**", + "**/web-build/**", + "**/ios/Pods/**", + "**/ios/build/**", + "**/android/build/**", + "**/android/.gradle/**", + "**/__pycache__/**", + "**/.venv/**", + "**/venv/**", + "**/site-packages/**", + "**/dist-packages/**", + "**/.pytest_cache/**", + "**/.mypy_cache/**", + "**/.ruff_cache/**", + "**/.tox/**", + "**/.nox/**", + "**/*.egg-info/**", + "**/.eggs/**", + "**/go/pkg/mod/**", + "**/target/debug/**", + "**/target/release/**", + "**/.gradle/**", + "**/.m2/**", + "**/generated-sources/**", + "**/.kotlin/**", + "**/.dart_tool/**", + "**/.vs/**", + "**/.nuget/**", + "**/artifacts/**", + "**/publish/**", + "**/cmake-build-*/**", + "**/CMakeFiles/**", + "**/bazel-*/**", + "**/vcpkg_installed/**", + "**/.conan/**", + "**/Debug/**", + "**/Release/**", + "**/x64/**", + "**/.pio/**", + "**/release/**", + "**/*.app/**", + "**/*.asar", + "**/DerivedData/**", + "**/.build/**", + "**/.swiftpm/**", + "**/xcuserdata/**", + "**/Carthage/Build/**", + "**/SourcePackages/**", + "**/__history/**", + "**/__recovery/**", + "**/*.dcu", + "**/.composer/**", + "**/storage/framework/**", + "**/bootstrap/cache/**", + "**/.bundle/**", + "**/tmp/cache/**", + "**/public/assets/**", + "**/public/packs/**", + "**/.yardoc/**", + "**/coverage/**", + "**/htmlcov/**", + "**/.nyc_output/**", + "**/test-results/**", + "**/.coverage/**", + "**/.idea/**", + "**/logs/**", + "**/tmp/**", + "**/temp/**", + "**/_build/**", + "**/docs/_build/**", + "**/site/**" + ], + "languages": [], + "frameworks": [], + "maxFileSize": 1048576, + "extractDocstrings": true, + "trackCallSites": true +} \ No newline at end of file diff --git a/.cursor/rules/codegraph.mdc b/.cursor/rules/codegraph.mdc new file mode 100644 index 0000000..dac86b3 --- /dev/null +++ b/.cursor/rules/codegraph.mdc @@ -0,0 +1,37 @@ +--- +description: CodeGraph MCP usage guide — when to use which tool +alwaysApply: true +--- + +## CodeGraph + +This project has a CodeGraph MCP server (`codegraph_*` tools) configured. CodeGraph is a tree-sitter-parsed knowledge graph of every symbol, edge, and file. Reads are sub-millisecond and return structural information grep cannot. + +### When to prefer codegraph over native search + +Use codegraph for **structural** questions — what calls what, what would break, where is X defined, what is X's signature. Use native grep/read only for **literal text** queries (string contents, comments, log messages) or after you already have a specific file open. + +| Question | Tool | +|---|---| +| "Where is X defined?" / "Find symbol named X" | `codegraph_search` | +| "What calls function Y?" | `codegraph_callers` | +| "What does Y call?" | `codegraph_callees` | +| "What would break if I changed Z?" | `codegraph_impact` | +| "Show me Y's signature / source / docstring" | `codegraph_node` | +| "Give me focused context for a task/area" | `codegraph_context` | +| "Survey an unfamiliar module/topic" | `codegraph_explore` | +| "What files exist under path/" | `codegraph_files` | +| "Is the index healthy?" | `codegraph_status` | + +### Rules of thumb + +- **Trust codegraph results.** They come from a full AST parse. Do NOT re-verify them with grep — that's slower, less accurate, and wastes context. +- **Don't grep first** when looking up a symbol by name. `codegraph_search` is faster and returns kind + location + signature in one call. +- **Don't chain `codegraph_search` + `codegraph_node`** when you just want context — `codegraph_context` is one call. +- **`codegraph_explore` is the heavy hitter** for unfamiliar areas — it returns full source from all relevant files in one call, but is token-heavy. If your harness supports parallel subagents (e.g., Claude Code's Task tool), spawn one for explore-class questions to keep main session context clean. +- **Index lag**: the file watcher debounces ~500ms behind writes; don't re-query immediately after editing a file in the same turn. + +### If `.codegraph/` doesn't exist + +The MCP server returns "not initialized." Ask the user: *"I notice this project doesn't have CodeGraph initialized. Want me to run `codegraph init -i` to build the index?"* + From d544397b58f4fb1ce99d14190e557fff89c574fa Mon Sep 17 00:00:00 2001 From: Lorem Dev Date: Wed, 20 May 2026 20:22:28 +0200 Subject: [PATCH 03/25] docs: track dev tools and external agent tooling outside LICENSE LICENSE's Third-Party Notices section is for libraries linked into the shipped locksmith binary. Move build tools (buf, golangci-lint) out of it into the new docs/development-tools.md, and add entries for external AI agent tooling that maintainers use locally - the CodeGraph MCP server and the obra/superpowers skill pack. Update the check-licenses skill to route Makefile build tools to the new file, and refresh CONTRIBUTING.md to point readers at the right place when adding or removing such tools. --- .claude/skills/check-licenses/SKILL.md | 71 +++++++++++++++++++++----- CHANGES.md | 5 ++ CONTRIBUTING.md | 8 +++ LICENSE | 17 ++---- docs/development-tools.md | 52 +++++++++++++++++++ 5 files changed, 128 insertions(+), 25 deletions(-) create mode 100644 docs/development-tools.md diff --git a/.claude/skills/check-licenses/SKILL.md b/.claude/skills/check-licenses/SKILL.md index 0eca5ed..87fd693 100644 --- a/.claude/skills/check-licenses/SKILL.md +++ b/.claude/skills/check-licenses/SKILL.md @@ -1,6 +1,6 @@ --- name: check-licenses -description: Audit direct third-party Go dependencies for license compatibility with Apache 2.0, present them for user verification, and maintain the Third-Party Notices section in LICENSE +description: Audit direct third-party Go dependencies for license compatibility with Apache 2.0, present them for user verification, and maintain the Third-Party Notices section in LICENSE. Build tools (linters, code generators) are tracked separately in docs/development-tools.md, never in LICENSE. --- ## Instructions @@ -27,9 +27,18 @@ Also run: git diff HEAD~1 -- Makefile ``` Scan the diff for changes to `go install` lines (anywhere in the file): -- Lines prefixed `+` matching `go install`: added build tools → add to LICENSE. +- Lines prefixed `+` matching `go install`: added build tools → add to + `docs/development-tools.md` under `## Build tools`. - Lines prefixed `-` matching `go install`: removed build tools → remove from - LICENSE only if the base module does NOT also appear in any `go.mod`. + `docs/development-tools.md` only if the base module does NOT also appear + in any `go.mod`. + +Build tools are **never** added to `LICENSE`'s `## Third-Party Notices` +section: that section is for libraries linked into the shipped `locksmith` +binary, while build tools (linters, code generators) run only on the +maintainer's machine. Tools fetched outside `Makefile` (npm packages, +external MCP servers, agent skill packs) are out of scope for this skill - +add them manually to the relevant section of `docs/development-tools.md`. If neither go.mod nor Makefile has relevant changes, report "No dependency changes found" and stop. @@ -76,7 +85,8 @@ For each matching line: Label surviving tools as **(build tool)** in the verification table in Step 3. Build tools **skip the block-list check** in Step 4 - they are never distributed -with the software so copyleft restrictions do not apply to users. +with the software so copyleft restrictions do not apply to users. They are +also written to `docs/development-tools.md` in Step 5, not to `LICENSE`. ### Step 3 - Look up licenses and build the verification table @@ -152,7 +162,15 @@ If a license identifier does not appear in either the block-list above or the acceptable list, treat it as **unknown or ambiguous** and stop to ask the user to verify it manually. -### Step 5 - Update `LICENSE` +### Step 5 - Update `LICENSE` (go.mod deps) and `docs/development-tools.md` (build tools) + +Route each entry by kind: + +- **go.mod direct deps** → `LICENSE`'s `## Third-Party Notices` section. +- **Makefile build tools** (collected in Step 2b) → `docs/development-tools.md` + under `## Build tools`. They are never written to `LICENSE`. + +#### LICENSE (go.mod deps only) If the `## Third-Party Notices` section does not yet exist, append the following block at the very end of `LICENSE` (including the leading `---` @@ -169,9 +187,14 @@ The section header: This project uses the following third-party libraries. Each library retains its original copyright and is distributed under its respective license. + +Development-time tools (linters, code generators, AI agent helpers) +are **not** listed here because they are not linked into or shipped +with the `locksmith` binary. They are tracked separately in +[`docs/development-tools.md`](docs/development-tools.md). ``` -For each dependency, add an entry sorted **alphabetically by module path**: +For each go.mod dep, add an entry sorted **alphabetically by module path**: ``` ### @@ -194,6 +217,28 @@ In incremental mode: it along with all its bullet lines, up to (but not including) the next `###` heading or end of file. +#### docs/development-tools.md (Makefile build tools only) + +If `docs/development-tools.md` is missing entirely, stop and ask the user to +restore it - this skill does not create the file from scratch (its `## AI +agent tooling` section is curated manually). + +Otherwise, insert or remove entries under the `## Build tools` heading, +sorted **alphabetically by module path**: + +``` +### +- Version: +- License: +- Role: +- Source: https:// +- License text: +``` + +For each **removed** build tool identified in Step 1, delete its +`### ` entry and all its bullet lines, up to (but not +including) the next `###` heading or the next `##` section. + ### Step 6 - Update `CONTRIBUTING.md` Check whether `CONTRIBUTING.md` already contains a `## License Compliance` @@ -230,17 +275,19 @@ adding it. ### Step 7 - Commit -Stage and commit changed files: +Stage and commit whichever of `LICENSE`, `docs/development-tools.md`, and +`CONTRIBUTING.md` were actually modified in Steps 5-6. Skip files that were +not touched. ```bash -git add LICENSE CONTRIBUTING.md +git add LICENSE docs/development-tools.md CONTRIBUTING.md git commit -S -m "chore: update third-party notices and license compliance docs" ``` -If `CONTRIBUTING.md` was not modified (section already existed), commit -only `LICENSE`: +If only one file changed, narrow the `git add` and the message accordingly, +for example: ```bash -git add LICENSE -git commit -S -m "chore: update third-party notices" +git add docs/development-tools.md +git commit -S -m "chore: update development tools list" ``` diff --git a/CHANGES.md b/CHANGES.md index 6ed6999..2b7745e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -5,6 +5,11 @@ - docs(cli): drop Touch ID wording from CLI help, README, configuration and architecture docs, and plugin READMEs; keychain authorization is described as OS-delegated without naming a specific auth method +- docs: split development-time tooling out of `LICENSE` into the new + `docs/development-tools.md`; build tools (buf, golangci-lint) and + external AI agent tooling (CodeGraph MCP server, obra/superpowers + skill pack) are listed there instead. The `check-licenses` skill + and `CONTRIBUTING.md` were updated to match. ## Version v0.3.0 - 2026-05-15 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3bdd6c4..3f526f9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,6 +22,14 @@ When adding or removing a library, run the `check-licenses` skill after editing `go.mod`. The skill updates the `LICENSE` "Third-Party Notices" section automatically. +Build tools (linters, code generators) installed via `make +install-tools` are not linked into the `locksmith` binary, so they +do **not** belong in `LICENSE`. They are tracked separately in +[`docs/development-tools.md`](docs/development-tools.md), along with +external AI agent tooling (MCP servers, skill packs) that maintainers +use locally. Update that file in the same commit when adding or +removing such a tool. + ## Branching model Locksmith uses two long-lived branches: diff --git a/LICENSE b/LICENSE index ed50b11..f79a803 100644 --- a/LICENSE +++ b/LICENSE @@ -208,12 +208,10 @@ This project uses the following third-party libraries. Each library retains its original copyright and is distributed under its respective license. -### github.com/bufbuild/buf -- Version: v1.68.1 -- License: Apache-2.0 -- Note: development tool only - not distributed with Locksmith -- Source: https://github.com/bufbuild/buf -- License text: https://github.com/bufbuild/buf/blob/v1.68.1/LICENSE +Development-time tools (linters, code generators, AI agent helpers) +are **not** listed here because they are not linked into or shipped +with the `locksmith` binary. They are tracked separately in +[`docs/development-tools.md`](docs/development-tools.md). ### github.com/charmbracelet/huh - Version: v1.0.0 @@ -233,13 +231,6 @@ license. - Source: https://github.com/fsnotify/fsnotify - License text: https://github.com/fsnotify/fsnotify/blob/v1.9.0/LICENSE -### github.com/golangci/golangci-lint -- Version: v1.64.8 -- License: GPL-3.0 -- Note: development tool only - not distributed with Locksmith -- Source: https://github.com/golangci/golangci-lint -- License text: https://github.com/golangci/golangci-lint/blob/v1.64.8/LICENSE - ### github.com/hashicorp/go-plugin - Version: v1.7.0 - License: MPL-2.0 diff --git a/docs/development-tools.md b/docs/development-tools.md new file mode 100644 index 0000000..52716d6 --- /dev/null +++ b/docs/development-tools.md @@ -0,0 +1,52 @@ +# Development tools + +This page lists third-party tools used during Locksmith development but +**not** linked into or distributed with the `locksmith` binary. Their +licenses are recorded here for attribution; they are intentionally not +included in `LICENSE`'s `## Third-Party Notices` section, which covers +only libraries that ship inside the binary. + +If you add or remove a development tool, update this file and the +matching CHANGES.md entry in the same commit. + +## Build tools + +These are installed via `make install-tools` and invoked by the build, +lint, and codegen targets in `Makefile`. + +### github.com/bufbuild/buf +- Version: v1.68.1 +- License: Apache-2.0 +- Role: protobuf compiler and linter (`make proto`, `make lint`) +- Source: https://github.com/bufbuild/buf +- License text: https://github.com/bufbuild/buf/blob/v1.68.1/LICENSE + +### github.com/golangci/golangci-lint +- Version: v1.64.8 +- License: GPL-3.0 +- Role: Go linter aggregator (`make lint`) +- Source: https://github.com/golangci/golangci-lint +- License text: https://github.com/golangci/golangci-lint/blob/v1.64.8/LICENSE + +## AI agent tooling + +These are optional external MCP servers and agent skill packs used by +maintainers when working in AI-assisted editors. They are not required +to build, test, or ship Locksmith. + +### @colbymchenry/codegraph +- Version: 0.7.10 +- License: MIT +- Role: tree-sitter-based code knowledge graph exposed as an MCP server + (`codegraph_*` tools); used by the `.codegraph/config.json` index and + the `.cursor/rules/codegraph.mdc` rule in this repository +- Source: https://www.npmjs.com/package/@colbymchenry/codegraph + +### obra/superpowers +- Version: v5.1.0 +- License: MIT +- Role: skill pack consumed by Claude Code and similar agents; provides + the `superpowers:*` skills referenced from `CLAUDE.md` and the project + `.claude/skills/` workflows +- Source: https://github.com/obra/superpowers +- License text: https://github.com/obra/superpowers/blob/v5.1.0/LICENSE From a7428131ec4bc7215d3894a7454e372e2d0b7ac5 Mon Sep 17 00:00:00 2001 From: Lorem Dev Date: Wed, 20 May 2026 20:24:39 +0200 Subject: [PATCH 04/25] chore: wire CodeGraph MCP into Claude Code, Cursor, and opencode Add MCP server entries for the CodeGraph CLI in .claude.json, .cursor/ (via .cursor/rules/codegraph.mdc previously committed), and opencode.jsonc, plus the per-tool agent prompt snippets in .claude/ CLAUDE.md and AGENTS.md describing when to prefer codegraph_* tools over native search. .claude/settings.json whitelists the read-only codegraph_* tool calls. Update .gitignore to track .claude/CLAUDE.md and .claude/settings.json alongside .claude/skills/, and to ignore the rest of .cursor/ except the codegraph rule. --- .claude.json | 12 ++++++++++++ .claude/CLAUDE.md | 33 +++++++++++++++++++++++++++++++++ .claude/settings.json | 13 +++++++++++++ .gitignore | 4 ++++ AGENTS.md | 34 ++++++++++++++++++++++++++++++++++ opencode.jsonc | 14 ++++++++++++++ 6 files changed, 110 insertions(+) create mode 100644 .claude.json create mode 100644 .claude/CLAUDE.md create mode 100644 .claude/settings.json create mode 100644 opencode.jsonc diff --git a/.claude.json b/.claude.json new file mode 100644 index 0000000..87ca7de --- /dev/null +++ b/.claude.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "codegraph": { + "type": "stdio", + "command": "codegraph", + "args": [ + "serve", + "--mcp" + ] + } + } +} diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..929fcf4 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,33 @@ + +## CodeGraph + +This project has a CodeGraph MCP server (`codegraph_*` tools) configured. CodeGraph is a tree-sitter-parsed knowledge graph of every symbol, edge, and file. Reads are sub-millisecond and return structural information grep cannot. + +### When to prefer codegraph over native search + +Use codegraph for **structural** questions — what calls what, what would break, where is X defined, what is X's signature. Use native grep/read only for **literal text** queries (string contents, comments, log messages) or after you already have a specific file open. + +| Question | Tool | +|---|---| +| "Where is X defined?" / "Find symbol named X" | `codegraph_search` | +| "What calls function Y?" | `codegraph_callers` | +| "What does Y call?" | `codegraph_callees` | +| "What would break if I changed Z?" | `codegraph_impact` | +| "Show me Y's signature / source / docstring" | `codegraph_node` | +| "Give me focused context for a task/area" | `codegraph_context` | +| "Survey an unfamiliar module/topic" | `codegraph_explore` | +| "What files exist under path/" | `codegraph_files` | +| "Is the index healthy?" | `codegraph_status` | + +### Rules of thumb + +- **Trust codegraph results.** They come from a full AST parse. Do NOT re-verify them with grep — that's slower, less accurate, and wastes context. +- **Don't grep first** when looking up a symbol by name. `codegraph_search` is faster and returns kind + location + signature in one call. +- **Don't chain `codegraph_search` + `codegraph_node`** when you just want context — `codegraph_context` is one call. +- **`codegraph_explore` is the heavy hitter** for unfamiliar areas — it returns full source from all relevant files in one call, but is token-heavy. If your harness supports parallel subagents (e.g., Claude Code's Task tool), spawn one for explore-class questions to keep main session context clean. +- **Index lag**: the file watcher debounces ~500ms behind writes; don't re-query immediately after editing a file in the same turn. + +### If `.codegraph/` doesn't exist + +The MCP server returns "not initialized." Ask the user: *"I notice this project doesn't have CodeGraph initialized. Want me to run `codegraph init -i` to build the index?"* + diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..4a10572 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,13 @@ +{ + "permissions": { + "allow": [ + "mcp__codegraph__codegraph_search", + "mcp__codegraph__codegraph_context", + "mcp__codegraph__codegraph_callers", + "mcp__codegraph__codegraph_callees", + "mcp__codegraph__codegraph_impact", + "mcp__codegraph__codegraph_node", + "mcp__codegraph__codegraph_status" + ] + } +} diff --git a/.gitignore b/.gitignore index ed27092..36816a0 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,11 @@ TODO.md # Track local skills but nothing else under .claude/ .claude/* !.claude/skills/ +!.claude/CLAUDE.md +!.claude/settings.json .agents/* +.cursor/ +!.cursor/rules/codegraph.mdc # Output files *.out # Generated bundle artefacts (built by make build-all / make init). diff --git a/AGENTS.md b/AGENTS.md index 584c9d8..0f80a13 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,3 +21,37 @@ Invoke these via the agent's skill mechanism. Each skill's `release-prep` and `version-bump` never run `git push`, `git tag`, or `gh pr create` themselves - they print the commands for the human maintainer to run. + + +## CodeGraph + +This project has a CodeGraph MCP server (`codegraph_*` tools) configured. CodeGraph is a tree-sitter-parsed knowledge graph of every symbol, edge, and file. Reads are sub-millisecond and return structural information grep cannot. + +### When to prefer codegraph over native search + +Use codegraph for **structural** questions — what calls what, what would break, where is X defined, what is X's signature. Use native grep/read only for **literal text** queries (string contents, comments, log messages) or after you already have a specific file open. + +| Question | Tool | +|---|---| +| "Where is X defined?" / "Find symbol named X" | `codegraph_search` | +| "What calls function Y?" | `codegraph_callers` | +| "What does Y call?" | `codegraph_callees` | +| "What would break if I changed Z?" | `codegraph_impact` | +| "Show me Y's signature / source / docstring" | `codegraph_node` | +| "Give me focused context for a task/area" | `codegraph_context` | +| "Survey an unfamiliar module/topic" | `codegraph_explore` | +| "What files exist under path/" | `codegraph_files` | +| "Is the index healthy?" | `codegraph_status` | + +### Rules of thumb + +- **Trust codegraph results.** They come from a full AST parse. Do NOT re-verify them with grep — that's slower, less accurate, and wastes context. +- **Don't grep first** when looking up a symbol by name. `codegraph_search` is faster and returns kind + location + signature in one call. +- **Don't chain `codegraph_search` + `codegraph_node`** when you just want context — `codegraph_context` is one call. +- **`codegraph_explore` is the heavy hitter** for unfamiliar areas — it returns full source from all relevant files in one call, but is token-heavy. If your harness supports parallel subagents (e.g., Claude Code's Task tool), spawn one for explore-class questions to keep main session context clean. +- **Index lag**: the file watcher debounces ~500ms behind writes; don't re-query immediately after editing a file in the same turn. + +### If `.codegraph/` doesn't exist + +The MCP server returns "not initialized." Ask the user: *"I notice this project doesn't have CodeGraph initialized. Want me to run `codegraph init -i` to build the index?"* + diff --git a/opencode.jsonc b/opencode.jsonc new file mode 100644 index 0000000..2c2509a --- /dev/null +++ b/opencode.jsonc @@ -0,0 +1,14 @@ +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "codegraph": { + "type": "local", + "command": [ + "codegraph", + "serve", + "--mcp" + ], + "enabled": true + } + } +} From 549f979c1ab53da6e708667a617a7bd1dd584036 Mon Sep 17 00:00:00 2001 From: Lorem Dev Date: Fri, 15 May 2026 21:51:53 +0200 Subject: [PATCH 05/25] feat(mcp): add JSON-RPC error inspection helpers --- internal/mcp/jsonrpc_error.go | 56 +++++++++++++ internal/mcp/jsonrpc_error_test.go | 127 +++++++++++++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 internal/mcp/jsonrpc_error.go create mode 100644 internal/mcp/jsonrpc_error_test.go diff --git a/internal/mcp/jsonrpc_error.go b/internal/mcp/jsonrpc_error.go new file mode 100644 index 0000000..4ba6e7b --- /dev/null +++ b/internal/mcp/jsonrpc_error.go @@ -0,0 +1,56 @@ +package mcp + +import ( + "bytes" + "encoding/json" +) + +// rpcEnvelope is the minimal JSON-RPC 2.0 response shape needed to +// classify whether a response carries an error worth one retry. +type rpcEnvelope struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id,omitempty"` + Error json.RawMessage `json:"error,omitempty"` + Result *struct { + IsError bool `json:"isError,omitempty"` + } `json:"result,omitempty"` +} + +// inspectResponse reports whether raw looks like a JSON-RPC response that +// indicates an error worth retrying once with resolved auth headers. +// Returns false for parse failures, notifications, id:null responses, +// non-JSON-RPC payloads, and any response without an error indicator. +func inspectResponse(raw []byte) bool { + var env rpcEnvelope + if err := json.Unmarshal(raw, &env); err != nil { + return false + } + if env.JSONRPC != "2.0" { + return false + } + if len(env.ID) == 0 || bytes.Equal(env.ID, []byte("null")) { + return false + } + if len(env.Error) > 0 { + return true + } + if env.Result != nil && env.Result.IsError { + return true + } + return false +} + +// extractID returns the raw JSON bytes of the response's id field as a +// string, or "" if the id is missing or literal null. The string is +// suitable as a map key: two responses share the same logical id iff +// their extractID values are equal. +func extractID(raw []byte) string { + var env rpcEnvelope + if err := json.Unmarshal(raw, &env); err != nil { + return "" + } + if len(env.ID) == 0 || bytes.Equal(env.ID, []byte("null")) { + return "" + } + return string(env.ID) +} diff --git a/internal/mcp/jsonrpc_error_test.go b/internal/mcp/jsonrpc_error_test.go new file mode 100644 index 0000000..d2364e6 --- /dev/null +++ b/internal/mcp/jsonrpc_error_test.go @@ -0,0 +1,127 @@ +package mcp + +import "testing" + +func TestInspectResponse(t *testing.T) { + cases := []struct { + name string + raw string + want bool + }{ + { + "tool isError true", + `{"jsonrpc":"2.0","id":1,"result":{"isError":true,"content":[{"type":"text","text":"x"}]}}`, + true, + }, + { + "tool isError false", + `{"jsonrpc":"2.0","id":1,"result":{"isError":false}}`, + false, + }, + { + "tool result no isError", + `{"jsonrpc":"2.0","id":1,"result":{"content":[]}}`, + false, + }, + { + "jsonrpc error object", + `{"jsonrpc":"2.0","id":1,"error":{"code":-32600,"message":"bad"}}`, + true, + }, + { + "notification (no id)", + `{"jsonrpc":"2.0","method":"notify","params":{}}`, + false, + }, + { + "id null", + `{"jsonrpc":"2.0","id":null,"error":{"code":-1}}`, + false, + }, + { + "malformed json", + `garbage{not json`, + false, + }, + { + "non-jsonrpc", + `{"foo":"bar"}`, + false, + }, + { + "wrong jsonrpc version", + `{"jsonrpc":"1.0","id":1,"error":{}}`, + false, + }, + { + "string id with isError", + `{"jsonrpc":"2.0","id":"abc","result":{"isError":true}}`, + true, + }, + { + "empty result object", + `{"jsonrpc":"2.0","id":1,"result":{}}`, + false, + }, + { + "both error and result (error wins)", + `{"jsonrpc":"2.0","id":1,"error":{"code":-1},"result":{}}`, + true, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := inspectResponse([]byte(tc.raw)) + if got != tc.want { + t.Errorf("inspectResponse(%q) = %v, want %v", tc.raw, got, tc.want) + } + }) + } +} + +func TestExtractID(t *testing.T) { + cases := []struct { + name string + raw string + want string + }{ + { + "numeric id", + `{"jsonrpc":"2.0","id":1,"result":{}}`, + "1", + }, + { + "string id", + `{"jsonrpc":"2.0","id":"abc","result":{}}`, + `"abc"`, + }, + { + "id null", + `{"jsonrpc":"2.0","id":null,"result":{}}`, + "", + }, + { + "missing id", + `{"jsonrpc":"2.0","method":"notify"}`, + "", + }, + { + "malformed", + `garbage`, + "", + }, + { + "id with spaces inside", + `{"jsonrpc":"2.0","id":42,"result":{}}`, + "42", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := extractID([]byte(tc.raw)) + if got != tc.want { + t.Errorf("extractID(%q) = %q, want %q", tc.raw, got, tc.want) + } + }) + } +} From f625e43230697f730ffbe66b0ab3f0b3c7692d26 Mon Sep 17 00:00:00 2001 From: Lorem Dev Date: Fri, 15 May 2026 21:57:05 +0200 Subject: [PATCH 06/25] feat(mcp): add shared authState for one-shot header resolution --- internal/mcp/auth_state.go | 66 ++++++++++++++++++++ internal/mcp/auth_state_test.go | 105 ++++++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 internal/mcp/auth_state.go create mode 100644 internal/mcp/auth_state_test.go diff --git a/internal/mcp/auth_state.go b/internal/mcp/auth_state.go new file mode 100644 index 0000000..e65bdbc --- /dev/null +++ b/internal/mcp/auth_state.go @@ -0,0 +1,66 @@ +package mcp + +import ( + "context" + "net/http" + "sync" +) + +// authState carries the shared auth-header resolution lifecycle between +// the proxy run loop (body-level error trigger) and the transports +// (HTTP 401/403 trigger). Resolution happens at most once per session; +// after the first attempt succeeds or fails, the result is cached and +// the resolver is never invoked again. +type authState struct { + mu sync.Mutex + attempted bool + headers http.Header + resolver HeaderResolver +} + +// newAuthState constructs an authState whose resolveOnce invokes +// resolver. A nil resolver makes resolveOnce a no-op that still marks +// the state as attempted (so callers do not loop). +func newAuthState(resolver HeaderResolver) *authState { + return &authState{resolver: resolver} +} + +// resolveOnce invokes the resolver under the mutex if it has not been +// invoked yet. After return, Attempted() reports true regardless of +// outcome. If the resolver returns an error, headers remain nil and +// the error is propagated; subsequent calls short-circuit without +// invoking resolver again. +func (s *authState) resolveOnce(ctx context.Context) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.attempted { + return nil + } + s.attempted = true + if s.resolver == nil { + return nil + } + h, err := s.resolver(ctx) + if err != nil { + return err + } + s.headers = h + return nil +} + +// Headers returns the cached headers (may be nil before resolve or +// after a failed resolve). The returned http.Header must not be +// mutated by callers; it is shared across all subsequent requests. +func (s *authState) Headers() http.Header { + s.mu.Lock() + defer s.mu.Unlock() + return s.headers +} + +// Attempted reports whether resolveOnce has been called (success or +// failure). After it returns true, no further resolves will occur. +func (s *authState) Attempted() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.attempted +} diff --git a/internal/mcp/auth_state_test.go b/internal/mcp/auth_state_test.go new file mode 100644 index 0000000..aebe7e7 --- /dev/null +++ b/internal/mcp/auth_state_test.go @@ -0,0 +1,105 @@ +package mcp + +import ( + "context" + "errors" + "net/http" + "sync" + "sync/atomic" + "testing" +) + +func TestAuthState_ResolveOnce_Single(t *testing.T) { + var calls atomic.Int32 + resolver := func(ctx context.Context) (http.Header, error) { + calls.Add(1) + h := http.Header{} + h.Set("Authorization", "Bearer x") + return h, nil + } + s := newAuthState(resolver) + + if s.Attempted() { + t.Fatal("Attempted() = true before first call") + } + if err := s.resolveOnce(context.Background()); err != nil { + t.Fatalf("resolveOnce: %v", err) + } + if calls.Load() != 1 { + t.Errorf("resolver calls = %d, want 1", calls.Load()) + } + if !s.Attempted() { + t.Errorf("Attempted() = false after first call") + } + if got := s.Headers().Get("Authorization"); got != "Bearer x" { + t.Errorf("Headers().Get(Authorization) = %q, want %q", got, "Bearer x") + } +} + +func TestAuthState_ResolveOnce_Concurrent(t *testing.T) { + var calls atomic.Int32 + resolver := func(ctx context.Context) (http.Header, error) { + calls.Add(1) + h := http.Header{} + h.Set("X", "y") + return h, nil + } + s := newAuthState(resolver) + + const numGoroutines = 16 + var wg sync.WaitGroup + wg.Add(numGoroutines) + for i := 0; i < numGoroutines; i++ { + go func() { + defer wg.Done() + _ = s.resolveOnce(context.Background()) + }() + } + wg.Wait() + + if got := calls.Load(); got != 1 { + t.Errorf("resolver calls = %d, want 1", got) + } + if got := s.Headers().Get("X"); got != "y" { + t.Errorf("Headers().Get(X) = %q, want %q", got, "y") + } +} + +func TestAuthState_ResolveOnce_ResolverFails(t *testing.T) { + var calls atomic.Int32 + want := errors.New("boom") + resolver := func(ctx context.Context) (http.Header, error) { + calls.Add(1) + return nil, want + } + s := newAuthState(resolver) + + if err := s.resolveOnce(context.Background()); !errors.Is(err, want) { + t.Errorf("resolveOnce err = %v, want errors.Is(_, boom)", err) + } + if !s.Attempted() { + t.Errorf("Attempted() = false after failed call") + } + if s.Headers() != nil { + t.Errorf("Headers() != nil after failed call") + } + if err := s.resolveOnce(context.Background()); err != nil { + t.Errorf("second resolveOnce err = %v, want nil", err) + } + if got := calls.Load(); got != 1 { + t.Errorf("resolver calls = %d, want 1", got) + } +} + +func TestAuthState_NilResolver(t *testing.T) { + s := newAuthState(nil) + if err := s.resolveOnce(context.Background()); err != nil { + t.Errorf("resolveOnce with nil resolver: %v", err) + } + if !s.Attempted() { + t.Errorf("Attempted() = false") + } + if s.Headers() != nil { + t.Errorf("Headers() != nil") + } +} From ba9afea6889545ce0ef197ffe735410235e5271b Mon Sep 17 00:00:00 2001 From: Lorem Dev Date: Fri, 15 May 2026 22:08:17 +0200 Subject: [PATCH 07/25] refactor(mcp): route transport auth retry through shared authState --- internal/mcp/proxy.go | 15 +++---- internal/mcp/transport.go | 27 ++++++------- internal/mcp/transport_http.go | 55 +++++++++++-------------- internal/mcp/transport_http_test.go | 63 +++++++++++++++++++++++------ internal/mcp/transport_sse.go | 36 +++++++---------- internal/mcp/transport_sse_test.go | 12 +++--- internal/mcp/transport_test.go | 14 +++---- 7 files changed, 120 insertions(+), 102 deletions(-) diff --git a/internal/mcp/proxy.go b/internal/mcp/proxy.go index bbaeaee..e103569 100644 --- a/internal/mcp/proxy.go +++ b/internal/mcp/proxy.go @@ -39,7 +39,7 @@ type transportSetup func(ctx context.Context) (Transport, <-chan []byte, error) // connection. func RunProxy(ctx context.Context, fetcher SecretFetcher, cfg ProxyConfig, in io.Reader, out io.Writer) error { static, templates := splitHeaders(cfg.Headers) - resolver := buildAuthResolver(fetcher, templates) + auth := newAuthState(buildAuthResolver(fetcher, templates)) log.Debug(). Str("url", RedactURL(cfg.URL)). Str("transport", cfg.Transport). @@ -48,7 +48,7 @@ func RunProxy(ctx context.Context, fetcher SecretFetcher, cfg ProxyConfig, in io Msg("mcp proxy: starting (lazy auth)") setup := func(ctx context.Context) (Transport, <-chan []byte, error) { - transport, err := NewTransport(cfg.URL, static, resolver, cfg.Transport) + transport, err := NewTransport(cfg.URL, static, auth, cfg.Transport) if err != nil { log.Debug().Err(err).Msg("mcp proxy: creating transport failed") return nil, nil, err @@ -60,7 +60,7 @@ func RunProxy(ctx context.Context, fetcher SecretFetcher, cfg ProxyConfig, in io } return transport, msgCh, nil } - return runLoop(ctx, setup, in, out) + return runLoop(ctx, setup, auth, in, out) } // RunProxyWithTransport runs the proxy loop against a pre-constructed @@ -81,7 +81,7 @@ func RunProxyWithTransport( } return transport, msgCh, nil } - return runLoop(ctx, setup, in, out) + return runLoop(ctx, setup, nil, in, out) } // splitHeaders partitions cfg.Headers by whether the template contains a @@ -103,8 +103,8 @@ func splitHeaders(mappings []HeaderMapping) (http.Header, []HeaderMapping) { // buildAuthResolver returns a HeaderResolver closure that resolves the // supplied templated headers via fetcher on first call. Returns nil if -// templates is empty - signalling to NewTransport that no auth-retry is -// possible for this proxy session. +// templates is empty; wrapping a nil resolver in authState yields a +// no-op resolveOnce so transports skip the retry path. func buildAuthResolver(fetcher SecretFetcher, templates []HeaderMapping) HeaderResolver { if len(templates) == 0 { return nil @@ -125,7 +125,8 @@ func buildAuthResolver(fetcher SecretFetcher, templates []HeaderMapping) HeaderR } } -func runLoop(ctx context.Context, setup transportSetup, in io.Reader, out io.Writer) error { +func runLoop(ctx context.Context, setup transportSetup, auth *authState, in io.Reader, out io.Writer) error { + _ = auth // Reserved for body-level retry (Task 4/5). reader := bufio.NewReader(in) firstLine, err := readFirstNonEmptyLine(reader) diff --git a/internal/mcp/transport.go b/internal/mcp/transport.go index 2c15d23..b17971e 100644 --- a/internal/mcp/transport.go +++ b/internal/mcp/transport.go @@ -40,14 +40,10 @@ type Transport interface { } // HeaderResolver returns the headers that must be attached to a request -// when the remote MCP server demands authentication. Transport -// implementations call it lazily on the first 401 or 403 response and -// cache the result for the lifetime of the connection. The closure may -// touch the vault and may take seconds to return; callers should not -// hold any lock that blocks unrelated work while waiting. -// -// A nil HeaderResolver disables auth-retry: 401/403 responses are -// propagated to the caller as ordinary errors. +// when the remote MCP server demands authentication. authState invokes +// the resolver at most once per proxy session. The closure may touch +// the vault and may take seconds to return; callers should not hold any +// lock that blocks unrelated work while waiting. type HeaderResolver func(ctx context.Context) (http.Header, error) // SSEEvent is a parsed server-sent event. @@ -124,13 +120,14 @@ func readBody(resp *http.Response) ([]byte, error) { // string. transport must be "auto", "sse", or "http". // // staticHeaders are attached to every request from the first send. -// resolveAuth, when non-nil, is invoked on the first 401/403 response; -// its result is cached and merged with staticHeaders on every -// subsequent request. +// auth carries the shared one-shot header-resolution lifecycle; the +// transport calls auth.resolveOnce on 401/403 responses and merges +// auth.Headers() into every request after a successful resolve. A nil +// auth disables 401/403 retry. func NewTransport( baseURL string, staticHeaders http.Header, - resolveAuth HeaderResolver, + auth *authState, transport string, ) (Transport, error) { client := &http.Client{Timeout: httpClientTimout} @@ -139,21 +136,21 @@ func NewTransport( return &StreamableHTTP{ baseURL: baseURL, staticHeaders: staticHeaders, - resolveAuth: resolveAuth, + auth: auth, client: client, }, nil case "sse": return &SSETransport{ baseURL: baseURL, staticHeaders: staticHeaders, - resolveAuth: resolveAuth, + auth: auth, client: client, }, nil case "auto", "": return &AutoTransport{ baseURL: baseURL, staticHeaders: staticHeaders, - resolveAuth: resolveAuth, + auth: auth, client: client, }, nil default: diff --git a/internal/mcp/transport_http.go b/internal/mcp/transport_http.go index 264f8eb..48f17e1 100644 --- a/internal/mcp/transport_http.go +++ b/internal/mcp/transport_http.go @@ -6,7 +6,6 @@ import ( "fmt" "net/http" "strings" - "sync" "github.com/lorem-dev/locksmith/internal/log" ) @@ -20,10 +19,7 @@ type StreamableHTTP struct { cancel context.CancelFunc staticHeaders http.Header - resolveAuth HeaderResolver - - authMu sync.Mutex - cachedAuth http.Header + auth *authState } func (t *StreamableHTTP) Connect(ctx context.Context) (<-chan []byte, error) { @@ -153,16 +149,17 @@ func (t *StreamableHTTP) postOnce(ctx context.Context, msg []byte) (*http.Respon // shouldRetryWithAuth reports whether the given HTTP status is an // auth-related rejection that warrants resolving the auth header and -// retrying once. Only valid before ensureAuth has succeeded; afterwards +// retrying once. Only valid before authState has resolved; afterwards // 401/403 from the server is a genuine failure (the cached auth is // wrong or expired) and is propagated. func (t *StreamableHTTP) shouldRetryWithAuth(status int) bool { + if t.auth == nil { + return false + } if status != http.StatusUnauthorized && status != http.StatusForbidden { return false } - t.authMu.Lock() - defer t.authMu.Unlock() - return t.cachedAuth == nil + return !t.auth.Attempted() } func (t *StreamableHTTP) Close() error { @@ -173,41 +170,35 @@ func (t *StreamableHTTP) Close() error { } // effectiveHeaders returns the headers to attach to the next request: -// staticHeaders alone before authentication, staticHeaders + cachedAuth -// after. The returned header is a fresh copy safe for the caller to -// mutate (e.g. set Content-Type) without affecting future calls. +// staticHeaders alone before authentication, staticHeaders + auth +// headers after. The returned header is a fresh copy safe for the +// caller to mutate (e.g. set Content-Type) without affecting future +// calls. func (t *StreamableHTTP) effectiveHeaders() http.Header { out := make(http.Header, len(t.staticHeaders)) for k, vs := range t.staticHeaders { out[k] = append([]string(nil), vs...) } - t.authMu.Lock() - for k, vs := range t.cachedAuth { + if t.auth == nil { + return out + } + for k, vs := range t.auth.Headers() { out[k] = append([]string(nil), vs...) } - t.authMu.Unlock() return out } -// ensureAuth resolves the auth headers if they have not been resolved -// yet. It returns true if auth is available (already cached or just -// resolved), false if no resolver is configured. A non-nil error means -// the resolver itself failed; the caller should propagate it. +// ensureAuth resolves auth via the shared authState. Returns (true, nil) +// when headers were resolved on this or a prior call, (false, nil) if +// auth is unavailable, or (false, err) if the resolve failed. func (t *StreamableHTTP) ensureAuth(ctx context.Context) (bool, error) { - if t.resolveAuth == nil { + if t.auth == nil { return false, nil } - t.authMu.Lock() - defer t.authMu.Unlock() - if t.cachedAuth != nil { - return true, nil - } - h, err := t.resolveAuth(ctx) - if err != nil { + if err := t.auth.resolveOnce(ctx); err != nil { return false, fmt.Errorf("resolving auth headers: %w", err) } - t.cachedAuth = h - return true, nil + return t.auth.Headers() != nil, nil } // AutoTransport tries Streamable HTTP first; falls back to SSE on 404/405. @@ -218,7 +209,7 @@ type AutoTransport struct { client *http.Client staticHeaders http.Header - resolveAuth HeaderResolver + auth *authState inner Transport outCh chan []byte @@ -234,7 +225,7 @@ func (t *AutoTransport) Connect(ctx context.Context) (<-chan []byte, error) { t.inner = &StreamableHTTP{ baseURL: t.baseURL, staticHeaders: t.staticHeaders, - resolveAuth: t.resolveAuth, + auth: t.auth, client: t.client, } innerCh, err := t.inner.Connect(fwdCtx) @@ -276,7 +267,7 @@ func (t *AutoTransport) Send(ctx context.Context, msg []byte) error { sse := &SSETransport{ baseURL: t.baseURL, staticHeaders: t.staticHeaders, - resolveAuth: t.resolveAuth, + auth: t.auth, client: t.client, } sseCh, connectErr := sse.Connect(ctx) diff --git a/internal/mcp/transport_http_test.go b/internal/mcp/transport_http_test.go index f324d3b..b7fc884 100644 --- a/internal/mcp/transport_http_test.go +++ b/internal/mcp/transport_http_test.go @@ -1,4 +1,4 @@ -package mcp_test +package mcp import ( "context" @@ -6,13 +6,12 @@ import ( "io" "net/http" "net/http/httptest" + "sync/atomic" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - - "github.com/lorem-dev/locksmith/internal/mcp" ) func TestStreamableHTTP_JSONResponse(t *testing.T) { @@ -31,7 +30,7 @@ func TestStreamableHTTP_JSONResponse(t *testing.T) { })) defer srv.Close() - transport, err := mcp.NewTransport(srv.URL, nil, nil, "http") + transport, err := NewTransport(srv.URL, nil, nil, "http") require.NoError(t, err) defer transport.Close() ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) @@ -72,7 +71,7 @@ func TestStreamableHTTP_SSEResponse(t *testing.T) { })) defer srv.Close() - transport, err := mcp.NewTransport(srv.URL, nil, nil, "http") + transport, err := NewTransport(srv.URL, nil, nil, "http") require.NoError(t, err) defer transport.Close() ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) @@ -123,7 +122,7 @@ func TestAutoTransport_FallsBackToSSE(t *testing.T) { srv := httptest.NewServer(mux) defer srv.Close() - transport, err := mcp.NewTransport(srv.URL, nil, nil, "auto") + transport, err := NewTransport(srv.URL, nil, nil, "auto") require.NoError(t, err) defer transport.Close() ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) @@ -162,7 +161,7 @@ func TestStreamableHTTP_LazyAuth_200_StaysUnauthenticated(t *testing.T) { })) defer srv.Close() - transport, err := mcp.NewTransport(srv.URL, nil, resolver, "http") + transport, err := NewTransport(srv.URL, nil, newAuthState(resolver), "http") require.NoError(t, err) defer transport.Close() @@ -189,7 +188,7 @@ func TestStreamableHTTP_LazyAuth_NoResolver_NoRetry(t *testing.T) { })) defer srv.Close() - transport, err := mcp.NewTransport(srv.URL, nil, nil, "http") + transport, err := NewTransport(srv.URL, nil, nil, "http") require.NoError(t, err) defer transport.Close() @@ -224,7 +223,7 @@ func TestStreamableHTTP_LazyAuth_401_TriggersResolveAndRetry(t *testing.T) { })) defer srv.Close() - transport, err := mcp.NewTransport(srv.URL, nil, resolver, "http") + transport, err := NewTransport(srv.URL, nil, newAuthState(resolver), "http") require.NoError(t, err) defer transport.Close() @@ -260,7 +259,7 @@ func TestStreamableHTTP_LazyAuth_403_TreatedAsAuth(t *testing.T) { })) defer srv.Close() - transport, err := mcp.NewTransport(srv.URL, nil, resolver, "http") + transport, err := NewTransport(srv.URL, nil, newAuthState(resolver), "http") require.NoError(t, err) defer transport.Close() @@ -296,7 +295,7 @@ func TestStreamableHTTP_LazyAuth_AuthSticky(t *testing.T) { })) defer srv.Close() - transport, err := mcp.NewTransport(srv.URL, nil, resolver, "http") + transport, err := NewTransport(srv.URL, nil, newAuthState(resolver), "http") require.NoError(t, err) defer transport.Close() @@ -329,7 +328,7 @@ func TestStreamableHTTP_LazyAuth_AuthFailsAfterRetry(t *testing.T) { })) defer srv.Close() - transport, err := mcp.NewTransport(srv.URL, nil, resolver, "http") + transport, err := NewTransport(srv.URL, nil, newAuthState(resolver), "http") require.NoError(t, err) defer transport.Close() @@ -341,3 +340,43 @@ func TestStreamableHTTP_LazyAuth_AuthFailsAfterRetry(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "401") } + +// TestStreamableHTTP_BodyResolveThen401_NoSecondResolve verifies that +// when the shared authState has already been resolved (e.g. by the +// body-level retry path in the proxy run loop), a subsequent 401 from +// the server is treated as a genuine failure: the transport does NOT +// invoke the resolver again, and the error is propagated. +func TestStreamableHTTP_BodyResolveThen401_NoSecondResolve(t *testing.T) { + var calls atomic.Int32 + resolver := func(_ context.Context) (http.Header, error) { + calls.Add(1) + return http.Header{"X-Token": []string{"ok"}}, nil + } + auth := newAuthState(resolver) + if err := auth.resolveOnce(context.Background()); err != nil { + t.Fatalf("seed resolveOnce: %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer server.Close() + + tr := &StreamableHTTP{ + baseURL: server.URL, + client: server.Client(), + staticHeaders: http.Header{}, + auth: auth, + } + _, err := tr.Connect(context.Background()) + if err != nil { + t.Fatalf("Connect: %v", err) + } + err = tr.Send(context.Background(), []byte(`{"jsonrpc":"2.0","id":1,"method":"x"}`)) + if err == nil { + t.Fatal("Send: expected 401 error, got nil") + } + if got := calls.Load(); got != 1 { + t.Errorf("resolver calls = %d, want 1 (no second resolve)", got) + } +} diff --git a/internal/mcp/transport_sse.go b/internal/mcp/transport_sse.go index d937929..b560732 100644 --- a/internal/mcp/transport_sse.go +++ b/internal/mcp/transport_sse.go @@ -6,7 +6,6 @@ import ( "net/http" "net/url" "strings" - "sync" "time" "github.com/lorem-dev/locksmith/internal/log" @@ -23,10 +22,7 @@ type SSETransport struct { cancel context.CancelFunc staticHeaders http.Header - resolveAuth HeaderResolver - - authMu sync.Mutex - cachedAuth http.Header + auth *authState } func (t *SSETransport) Connect(ctx context.Context) (<-chan []byte, error) { @@ -165,12 +161,13 @@ func (t *SSETransport) postEndpointOnce(ctx context.Context, msg []byte) (*http. // shouldRetryWithAuth reports whether the response status warrants an // auth resolve and retry. Mirrors StreamableHTTP.shouldRetryWithAuth. func (t *SSETransport) shouldRetryWithAuth(status int) bool { + if t.auth == nil { + return false + } if status != http.StatusUnauthorized && status != http.StatusForbidden { return false } - t.authMu.Lock() - defer t.authMu.Unlock() - return t.cachedAuth == nil + return !t.auth.Attempted() } func (t *SSETransport) Close() error { @@ -209,27 +206,24 @@ func (t *SSETransport) effectiveHeaders() http.Header { for k, vs := range t.staticHeaders { out[k] = append([]string(nil), vs...) } - t.authMu.Lock() - for k, vs := range t.cachedAuth { + if t.auth == nil { + return out + } + for k, vs := range t.auth.Headers() { out[k] = append([]string(nil), vs...) } - t.authMu.Unlock() return out } +// ensureAuth resolves auth via the shared authState. Returns (true, nil) +// when headers were resolved on this or a prior call, (false, nil) if +// auth is unavailable, or (false, err) if the resolve failed. func (t *SSETransport) ensureAuth(ctx context.Context) (bool, error) { - if t.resolveAuth == nil { + if t.auth == nil { return false, nil } - t.authMu.Lock() - defer t.authMu.Unlock() - if t.cachedAuth != nil { - return true, nil - } - h, err := t.resolveAuth(ctx) - if err != nil { + if err := t.auth.resolveOnce(ctx); err != nil { return false, fmt.Errorf("resolving auth headers: %w", err) } - t.cachedAuth = h - return true, nil + return t.auth.Headers() != nil, nil } diff --git a/internal/mcp/transport_sse_test.go b/internal/mcp/transport_sse_test.go index 70b30d7..4298b68 100644 --- a/internal/mcp/transport_sse_test.go +++ b/internal/mcp/transport_sse_test.go @@ -1,4 +1,4 @@ -package mcp_test +package mcp import ( "context" @@ -10,8 +10,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - - "github.com/lorem-dev/locksmith/internal/mcp" ) func TestSSETransport_RoundTrip(t *testing.T) { @@ -38,7 +36,7 @@ func TestSSETransport_RoundTrip(t *testing.T) { srv := httptest.NewServer(mux) defer srv.Close() - transport, err := mcp.NewTransport(srv.URL, nil, nil, "sse") + transport, err := NewTransport(srv.URL, nil, nil, "sse") require.NoError(t, err) defer transport.Close() ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) @@ -76,7 +74,7 @@ func TestSSETransport_AuthHeader(t *testing.T) { defer srv.Close() headers := http.Header{"Authorization": {"Bearer tok-123"}} - transport, err := mcp.NewTransport(srv.URL, headers, nil, "sse") + transport, err := NewTransport(srv.URL, headers, nil, "sse") require.NoError(t, err) defer transport.Close() ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) @@ -114,7 +112,7 @@ func TestSSETransport_LazyAuth_Connect_401_ReopensWithAuth(t *testing.T) { srv := httptest.NewServer(mux) defer srv.Close() - transport, err := mcp.NewTransport(srv.URL, nil, resolver, "sse") + transport, err := NewTransport(srv.URL, nil, newAuthState(resolver), "sse") require.NoError(t, err) defer transport.Close() @@ -158,7 +156,7 @@ func TestSSETransport_LazyAuth_Send_401_RetriesWithAuth(t *testing.T) { srv := httptest.NewServer(mux) defer srv.Close() - transport, err := mcp.NewTransport(srv.URL, nil, resolver, "sse") + transport, err := NewTransport(srv.URL, nil, newAuthState(resolver), "sse") require.NoError(t, err) defer transport.Close() diff --git a/internal/mcp/transport_test.go b/internal/mcp/transport_test.go index 737e4c9..7b5d9be 100644 --- a/internal/mcp/transport_test.go +++ b/internal/mcp/transport_test.go @@ -1,4 +1,4 @@ -package mcp_test +package mcp import ( "strings" @@ -6,13 +6,11 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - - "github.com/lorem-dev/locksmith/internal/mcp" ) func TestParseSSE(t *testing.T) { input := "event: endpoint\ndata: /messages\n\nevent: message\ndata: {\"jsonrpc\":\"2.0\"}\n\n" - events := mcp.CollectSSE(strings.NewReader(input)) + events := CollectSSE(strings.NewReader(input)) require.Len(t, events, 2) assert.Equal(t, "endpoint", events[0].Type) assert.Equal(t, "/messages", events[0].Data) @@ -22,14 +20,14 @@ func TestParseSSE(t *testing.T) { func TestParseSSE_DataOnly(t *testing.T) { input := "data: hello\n\ndata: world\n\n" - events := mcp.CollectSSE(strings.NewReader(input)) + events := CollectSSE(strings.NewReader(input)) require.Len(t, events, 2) assert.Equal(t, "", events[0].Type) assert.Equal(t, "hello", events[0].Data) } func TestNewTransport_InvalidType(t *testing.T) { - _, err := mcp.NewTransport("https://example.com", nil, nil, "grpc") + _, err := NewTransport("https://example.com", nil, nil, "grpc") require.ErrorContains(t, err, "unknown transport") } @@ -46,8 +44,8 @@ func TestRedactURL(t *testing.T) { } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - assert.Equal(t, tc.want, mcp.RedactURL(tc.input)) - assert.NotContains(t, mcp.RedactURL(tc.input), "supersecret") + assert.Equal(t, tc.want, RedactURL(tc.input)) + assert.NotContains(t, RedactURL(tc.input), "supersecret") }) } } From 34c9d363a6e4dd94e7dccf4f72469f990b17e754 Mon Sep 17 00:00:00 2001 From: Lorem Dev Date: Fri, 15 May 2026 22:13:12 +0200 Subject: [PATCH 08/25] feat(mcp): track in-flight request ids in proxy run loop --- internal/mcp/proxy.go | 104 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 95 insertions(+), 9 deletions(-) diff --git a/internal/mcp/proxy.go b/internal/mcp/proxy.go index e103569..49ba30f 100644 --- a/internal/mcp/proxy.go +++ b/internal/mcp/proxy.go @@ -126,7 +126,6 @@ func buildAuthResolver(fetcher SecretFetcher, templates []HeaderMapping) HeaderR } func runLoop(ctx context.Context, setup transportSetup, auth *authState, in io.Reader, out io.Writer) error { - _ = auth // Reserved for body-level retry (Task 4/5). reader := bufio.NewReader(in) firstLine, err := readFirstNonEmptyLine(reader) @@ -154,6 +153,7 @@ func runLoop(ctx context.Context, setup transportSetup, auth *authState, in io.R out.Write(append(msg, '\n')) //nolint:errcheck } + state := newProxyState() done := make(chan struct{}) shutdown := func() { select { @@ -167,11 +167,11 @@ func runLoop(ctx context.Context, setup transportSetup, auth *authState, in io.R wg.Add(1) go func() { defer wg.Done() - forwardServerMessages(msgCh, done, writeMsg) + forwardServerMessagesWithTracking(msgCh, done, writeMsg, state, auth) }() var clientMsgCount uint64 - if err := sendClientMessage(ctx, transport, firstLine, &clientMsgCount); err != nil { + if err := sendClientMessageTracked(ctx, transport, firstLine, &clientMsgCount, state, auth); err != nil { shutdown() wg.Wait() return err @@ -182,7 +182,7 @@ func runLoop(ctx context.Context, setup transportSetup, auth *authState, in io.R if len(line) > 0 { trimmed := bytes.TrimRight(line, "\r\n") if len(trimmed) > 0 { - if err := sendClientMessage(ctx, transport, trimmed, &clientMsgCount); err != nil { + if err := sendClientMessageTracked(ctx, transport, trimmed, &clientMsgCount, state, auth); err != nil { shutdown() wg.Wait() return err @@ -225,15 +225,28 @@ func readFirstNonEmptyLine(reader *bufio.Reader) ([]byte, error) { } } -// forwardServerMessages reads server messages from msgCh and dispatches -// them to writeMsg until either msgCh closes or done fires. When done -// fires it first drains any messages already buffered in msgCh so late -// responses still reach the client before shutdown. -func forwardServerMessages(msgCh <-chan []byte, done <-chan struct{}, writeMsg func([]byte)) { +// forwardServerMessagesWithTracking reads server messages from msgCh and +// dispatches them to writeMsg until either msgCh closes or done fires. +// When done fires it first drains any messages already buffered in +// msgCh so late responses still reach the client before shutdown. While +// auth has not been attempted, the response's id is removed from state +// so the in-flight map only holds requests whose response has not been +// forwarded yet. Task 5 swaps this for a variant that also inspects +// responses and triggers resolve+retry. +func forwardServerMessagesWithTracking( + msgCh <-chan []byte, + done <-chan struct{}, + writeMsg func([]byte), + state *proxyState, + auth *authState, +) { var count uint64 forward := func(msg []byte) { count++ log.Debug().Uint64("seq", count).Int("len", len(msg)).Msg("mcp proxy: server -> client") + if auth != nil && !auth.Attempted() { + _ = state.take(extractID(msg)) + } writeMsg(msg) } for { @@ -271,3 +284,76 @@ func sendClientMessage(ctx context.Context, transport Transport, line []byte, co } return nil } + +// sendClientMessageTracked sends to transport and, while auth has not +// been attempted, records the request's id for potential retry. The +// stored bytes are a copy of line so subsequent stdin reads cannot +// mutate them. +func sendClientMessageTracked( + ctx context.Context, + transport Transport, + line []byte, + count *uint64, + state *proxyState, + auth *authState, +) error { + if auth != nil && !auth.Attempted() { + if id := extractID(line); id != "" { + state.record(id, append([]byte(nil), line...)) + } + } + return sendClientMessage(ctx, transport, line, count) +} + +// proxyState tracks in-flight client request bytes by their JSON-RPC id +// while body-level retry is still possible. Once authState.Attempted() +// is true, no further tracking happens (zero overhead via take/record +// short-circuits). +type proxyState struct { + mu sync.Mutex + inFlight map[string][]byte +} + +func newProxyState() *proxyState { + return &proxyState{inFlight: make(map[string][]byte)} +} + +// record stores the request bytes under id. No-op for empty id (e.g. +// notifications) or after clear() has nilled the map. +func (s *proxyState) record(id string, bytes []byte) { + if id == "" { + return + } + s.mu.Lock() + defer s.mu.Unlock() + if s.inFlight == nil { + return + } + s.inFlight[id] = bytes +} + +// take removes and returns the request bytes for id, or nil if the id +// is empty, unknown, or the map has been cleared. +func (s *proxyState) take(id string) []byte { + if id == "" { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + if s.inFlight == nil { + return nil + } + b := s.inFlight[id] + delete(s.inFlight, id) + return b +} + +// clear releases the inFlight map. Subsequent record/take calls are +// no-ops. Called once authState.Attempted() flips to true. +// +//nolint:unused // Wired up by Task 5 (body-level retry). +func (s *proxyState) clear() { + s.mu.Lock() + defer s.mu.Unlock() + s.inFlight = nil +} From d5416f1f8d8b92fa650dc4bd8f9d0ba5d73c2afe Mon Sep 17 00:00:00 2001 From: Lorem Dev Date: Fri, 15 May 2026 22:32:27 +0200 Subject: [PATCH 09/25] feat(mcp): retry on JSON-RPC body errors with shared authState --- internal/mcp/proxy.go | 151 +++++++++++---- internal/mcp/proxy_retry_test.go | 322 +++++++++++++++++++++++++++++++ 2 files changed, 440 insertions(+), 33 deletions(-) create mode 100644 internal/mcp/proxy_retry_test.go diff --git a/internal/mcp/proxy.go b/internal/mcp/proxy.go index 49ba30f..4163f59 100644 --- a/internal/mcp/proxy.go +++ b/internal/mcp/proxy.go @@ -167,7 +167,7 @@ func runLoop(ctx context.Context, setup transportSetup, auth *authState, in io.R wg.Add(1) go func() { defer wg.Done() - forwardServerMessagesWithTracking(msgCh, done, writeMsg, state, auth) + forwardServerMessagesWithTracking(ctx, msgCh, done, writeMsg, state, auth, transport) }() var clientMsgCount uint64 @@ -227,52 +227,139 @@ func readFirstNonEmptyLine(reader *bufio.Reader) ([]byte, error) { // forwardServerMessagesWithTracking reads server messages from msgCh and // dispatches them to writeMsg until either msgCh closes or done fires. -// When done fires it first drains any messages already buffered in -// msgCh so late responses still reach the client before shutdown. While -// auth has not been attempted, the response's id is removed from state -// so the in-flight map only holds requests whose response has not been -// forwarded yet. Task 5 swaps this for a variant that also inspects -// responses and triggers resolve+retry. +// While auth has not been attempted, each incoming response is inspected +// for a body-level JSON-RPC error. On detection, the original request +// bytes are looked up in state, the authState resolver is invoked once, +// the request is re-sent with the freshly resolved headers, and the +// retry response (matching the same id) is forwarded to the client in +// place of the original error. If resolve or retry-Send fails, the +// original error response is forwarded as-is. func forwardServerMessagesWithTracking( + ctx context.Context, msgCh <-chan []byte, done <-chan struct{}, writeMsg func([]byte), state *proxyState, auth *authState, + transport Transport, ) { var count uint64 forward := func(msg []byte) { count++ log.Debug().Uint64("seq", count).Int("len", len(msg)).Msg("mcp proxy: server -> client") - if auth != nil && !auth.Attempted() { - _ = state.take(extractID(msg)) - } writeMsg(msg) } + + // drained tracks whether done has fired; once it has, subsequent + // recv calls read msgCh non-blockingly so any buffered late + // responses are forwarded without waiting on transports that do + // not close their server channel on shutdown. + drained := false + recv := func() ([]byte, bool) { + if drained { + return tryRecv(msgCh) + } + msg, ok, doneFired := blockingRecv(msgCh, done) + if doneFired { + drained = true + return tryRecv(msgCh) + } + return msg, ok + } + for { - select { - case msg, ok := <-msgCh: - if !ok { - log.Debug().Uint64("count", count).Msg("mcp proxy: server channel closed") + msg, ok := recv() + if !ok { + log.Debug().Uint64("count", count).Msg("mcp proxy: server channel closed") + return + } + if auth != nil && !auth.Attempted() && inspectResponse(msg) { + if exit := handleBodyError(ctx, msg, state, auth, transport, forward, recv); exit { return } - forward(msg) - case <-done: - for { - select { - case msg, ok := <-msgCh: - if !ok { - log.Debug().Uint64("count", count).Msg("mcp proxy: server channel closed during drain") - return - } - forward(msg) - default: - log.Debug().Uint64("count", count).Msg("mcp proxy: server reader stopped") - return - } - } + continue + } + if auth != nil && !auth.Attempted() { + _ = state.take(extractID(msg)) + } + forward(msg) + } +} + +// tryRecv attempts a non-blocking receive on msgCh. The second return +// value reports whether a message was produced; closed channels yield +// (nil, false). +func tryRecv(msgCh <-chan []byte) ([]byte, bool) { + select { + case msg, ok := <-msgCh: + if !ok { + return nil, false + } + return msg, true + default: + return nil, false + } +} + +// blockingRecv waits for a message on msgCh, or for done to fire. The +// third return value distinguishes "done fired" from "msgCh closed", +// so the caller can flip into drain mode for any buffered late +// responses. +func blockingRecv(msgCh <-chan []byte, done <-chan struct{}) ([]byte, bool, bool) { + select { + case m, chOk := <-msgCh: + if !chOk { + return nil, false, false + } + return m, true, false + case <-done: + return nil, false, true + } +} + +// handleBodyError performs the body-level retry handshake for one error +// response. It returns true only when recv reports the server channel +// is gone mid-retry, signalling the caller to exit the forward loop. +func handleBodyError( + ctx context.Context, + msg []byte, + state *proxyState, + auth *authState, + transport Transport, + forward func([]byte), + recv func() ([]byte, bool), +) bool { + id := extractID(msg) + orig := state.take(id) + if orig == nil { + forward(msg) + return false + } + log.Debug().Str("id", id).Msg("mcp proxy: body-level error detected; resolving headers") + if err := auth.resolveOnce(ctx); err != nil { + log.Debug().Err(err).Msg("mcp proxy: resolveOnce failed; forwarding original error") + forward(msg) + state.clear() + return false + } + if err := transport.Send(ctx, orig); err != nil { + log.Debug().Err(err).Msg("mcp proxy: retry Send failed; forwarding original error") + forward(msg) + state.clear() + return false + } + for { + next, ok := recv() + if !ok { + return true + } + forward(next) + if extractID(next) == id { + break } } + state.clear() + return false } func sendClientMessage(ctx context.Context, transport Transport, line []byte, count *uint64) error { @@ -320,7 +407,7 @@ func newProxyState() *proxyState { // record stores the request bytes under id. No-op for empty id (e.g. // notifications) or after clear() has nilled the map. -func (s *proxyState) record(id string, bytes []byte) { +func (s *proxyState) record(id string, raw []byte) { if id == "" { return } @@ -329,7 +416,7 @@ func (s *proxyState) record(id string, bytes []byte) { if s.inFlight == nil { return } - s.inFlight[id] = bytes + s.inFlight[id] = raw } // take removes and returns the request bytes for id, or nil if the id @@ -350,8 +437,6 @@ func (s *proxyState) take(id string) []byte { // clear releases the inFlight map. Subsequent record/take calls are // no-ops. Called once authState.Attempted() flips to true. -// -//nolint:unused // Wired up by Task 5 (body-level retry). func (s *proxyState) clear() { s.mu.Lock() defer s.mu.Unlock() diff --git a/internal/mcp/proxy_retry_test.go b/internal/mcp/proxy_retry_test.go new file mode 100644 index 0000000..f615eb9 --- /dev/null +++ b/internal/mcp/proxy_retry_test.go @@ -0,0 +1,322 @@ +package mcp + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +// scriptedTransport is a Transport that records sent messages and lets +// the test push pre-canned server responses on demand. Suitable for +// driving the proxy run loop without a real HTTP server. +type scriptedTransport struct { + mu sync.Mutex + sent [][]byte + out chan []byte + done chan struct{} + sendErr error + closed bool +} + +func newScriptedTransport() *scriptedTransport { + return &scriptedTransport{out: make(chan []byte, 16), done: make(chan struct{})} +} + +func (t *scriptedTransport) Connect(_ context.Context) (<-chan []byte, error) { + return t.out, nil +} + +func (t *scriptedTransport) Send(_ context.Context, msg []byte) error { + t.mu.Lock() + defer t.mu.Unlock() + if t.sendErr != nil { + return t.sendErr + } + t.sent = append(t.sent, append([]byte(nil), msg...)) + return nil +} + +func (t *scriptedTransport) Close() error { + t.mu.Lock() + defer t.mu.Unlock() + if !t.closed { + t.closed = true + close(t.done) + close(t.out) + } + return nil +} + +func (t *scriptedTransport) push(msg string) { t.out <- []byte(msg) } + +func (t *scriptedTransport) sentCount() int { + t.mu.Lock() + defer t.mu.Unlock() + return len(t.sent) +} + +// delayedEOFReader emits the bytes of `body` and then blocks subsequent +// reads on `wait` so the run loop does not observe stdin EOF until the +// test goroutine signals completion (typically by closing `wait` after +// tr.Close() has been called). This lets scripted tests push server +// responses and trigger retries without racing the shutdown path. +type delayedEOFReader struct { + body *strings.Reader + wait <-chan struct{} +} + +func (r *delayedEOFReader) Read(p []byte) (int, error) { + if r.body.Len() > 0 { + return r.body.Read(p) + } + <-r.wait + return 0, io.EOF +} + +// runProxyForTest drives runLoop with the given scripted transport and +// resolver, then returns the non-empty lines written to stdout. stdin +// is kept open until tr is closed so the scripted server goroutine can +// push responses without racing shutdown. +func runProxyForTest( + t *testing.T, + tr *scriptedTransport, + resolver HeaderResolver, + stdin string, +) ([][]byte, *authState) { + t.Helper() + auth := newAuthState(resolver) + var stdout bytes.Buffer + setup := func(ctx context.Context) (Transport, <-chan []byte, error) { + ch, _ := tr.Connect(ctx) + return tr, ch, nil + } + reader := &delayedEOFReader{body: strings.NewReader(stdin), wait: tr.done} + if err := runLoop(context.Background(), setup, auth, reader, &stdout); err != nil { + t.Fatalf("runLoop: %v", err) + } + var lines [][]byte + for _, l := range bytes.Split(stdout.Bytes(), []byte{'\n'}) { + if len(l) > 0 { + lines = append(lines, l) + } + } + return lines, auth +} + +func TestProxy_BodyError_TriggersResolveAndRetry(t *testing.T) { + tr := newScriptedTransport() + var calls atomic.Int32 + resolver := func(ctx context.Context) (http.Header, error) { + calls.Add(1) + h := http.Header{} + h.Set("X-Token", "ok") + return h, nil + } + go func() { + tr.push(`{"jsonrpc":"2.0","id":1,"result":{"isError":true,"content":[{"type":"text","text":"err"}]}}`) + for tr.sentCount() < 2 { + time.Sleep(5 * time.Millisecond) + } + tr.push(`{"jsonrpc":"2.0","id":1,"result":{"isError":false,"content":[{"type":"text","text":"ok"}]}}`) + tr.Close() + }() + stdin := "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\"}\n" + lines, auth := runProxyForTest(t, tr, resolver, stdin) + if calls.Load() != 1 { + t.Errorf("resolver calls = %d, want 1", calls.Load()) + } + if !auth.Attempted() { + t.Errorf("Attempted() = false") + } + if len(lines) != 1 { + t.Fatalf("stdout lines = %d, want 1; got: %q", len(lines), lines) + } + if !bytes.Contains(lines[0], []byte(`"isError":false`)) { + t.Errorf("client did not receive retry success: %q", lines[0]) + } +} + +func TestProxy_BodyError_ResolveFails_ForwardsOriginal(t *testing.T) { + tr := newScriptedTransport() + want := errors.New("vault locked") + resolver := func(ctx context.Context) (http.Header, error) { return nil, want } + go func() { + tr.push(`{"jsonrpc":"2.0","id":1,"result":{"isError":true,"content":[{"type":"text","text":"err"}]}}`) + tr.Close() + }() + stdin := "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\"}\n" + lines, auth := runProxyForTest(t, tr, resolver, stdin) + if !auth.Attempted() { + t.Errorf("Attempted() = false") + } + if len(lines) != 1 || !bytes.Contains(lines[0], []byte(`"isError":true`)) { + t.Fatalf("stdout did not contain original error: %q", lines) + } +} + +func TestProxy_BodyError_RetrySendFails_ForwardsOriginal(t *testing.T) { + tr := newScriptedTransport() + resolver := func(ctx context.Context) (http.Header, error) { + return http.Header{"X-Token": []string{"ok"}}, nil + } + go func() { + // Wait for runLoop to send the original request before flipping + // sendErr; otherwise the initial Send fails and the retry path + // is not exercised. + for tr.sentCount() < 1 { + time.Sleep(5 * time.Millisecond) + } + tr.mu.Lock() + tr.sendErr = errors.New("connect fail") + tr.mu.Unlock() + tr.push(`{"jsonrpc":"2.0","id":1,"result":{"isError":true,"content":[{"type":"text","text":"err"}]}}`) + time.Sleep(50 * time.Millisecond) + tr.Close() + }() + stdin := "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\"}\n" + lines, auth := runProxyForTest(t, tr, resolver, stdin) + if !auth.Attempted() { + t.Errorf("Attempted() = false") + } + if len(lines) != 1 || !bytes.Contains(lines[0], []byte(`"isError":true`)) { + t.Fatalf("stdout did not contain original error: %q", lines) + } +} + +func TestProxy_BodyError_RetryAlsoErrors_ForwardsRetryResponse(t *testing.T) { + tr := newScriptedTransport() + resolver := func(ctx context.Context) (http.Header, error) { + return http.Header{"X-Token": []string{"ok"}}, nil + } + go func() { + tr.push(`{"jsonrpc":"2.0","id":1,"result":{"isError":true,"content":[{"type":"text","text":"err1"}]}}`) + for tr.sentCount() < 2 { + time.Sleep(5 * time.Millisecond) + } + tr.push(`{"jsonrpc":"2.0","id":1,"result":{"isError":true,"content":[{"type":"text","text":"err2"}]}}`) + tr.Close() + }() + stdin := "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\"}\n" + lines, _ := runProxyForTest(t, tr, resolver, stdin) + if len(lines) != 1 || !bytes.Contains(lines[0], []byte(`"err2"`)) { + t.Fatalf("client should see retry response only; got: %q", lines) + } +} + +func TestProxy_ConcurrentInFlight_RetriesCorrectId(t *testing.T) { + tr := newScriptedTransport() + resolver := func(ctx context.Context) (http.Header, error) { + return http.Header{"X-Token": []string{"ok"}}, nil + } + go func() { + tr.push(`{"jsonrpc":"2.0","id":2,"result":{"isError":true,"content":[{"type":"text","text":"e2"}]}}`) + for tr.sentCount() < 3 { + time.Sleep(5 * time.Millisecond) + } + tr.push(`{"jsonrpc":"2.0","id":1,"result":{"isError":false,"content":[{"type":"text","text":"ok1"}]}}`) + tr.push(`{"jsonrpc":"2.0","id":2,"result":{"isError":false,"content":[{"type":"text","text":"ok2"}]}}`) + tr.Close() + }() + stdin := "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\"}\n" + + "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\"}\n" + lines, _ := runProxyForTest(t, tr, resolver, stdin) + if len(lines) != 2 { + t.Fatalf("expected 2 forwarded responses, got %d: %q", len(lines), lines) + } + if !bytes.Contains(lines[0], []byte(`"id":1`)) || !bytes.Contains(lines[1], []byte(`"id":2`)) { + t.Errorf("unexpected line order: %q", lines) + } + if !bytes.Contains(lines[1], []byte(`"ok2"`)) { + t.Errorf("id=2 line should be retry success: %q", lines[1]) + } + for _, l := range lines { + if bytes.Contains(l, []byte(`"e2"`)) { + t.Errorf("original error leaked: %q", l) + } + } +} + +func TestProxy_OnlyFirstErrorTriggers(t *testing.T) { + tr := newScriptedTransport() + var calls atomic.Int32 + resolver := func(ctx context.Context) (http.Header, error) { + calls.Add(1) + return http.Header{"X-Token": []string{"ok"}}, nil + } + go func() { + tr.push(`{"jsonrpc":"2.0","id":1,"result":{"isError":false,"content":[]}}`) + tr.push(`{"jsonrpc":"2.0","id":2,"result":{"isError":true,"content":[]}}`) + for tr.sentCount() < 3 { + time.Sleep(5 * time.Millisecond) + } + tr.push(`{"jsonrpc":"2.0","id":2,"result":{"isError":false,"content":[{"type":"text","text":"ok"}]}}`) + tr.Close() + }() + stdin := "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\"}\n" + + "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\"}\n" + lines, auth := runProxyForTest(t, tr, resolver, stdin) + if calls.Load() != 1 { + t.Errorf("resolver calls = %d, want 1", calls.Load()) + } + if !auth.Attempted() { + t.Errorf("Attempted() = false") + } + if len(lines) != 2 { + t.Fatalf("expected 2 forwarded responses, got %d: %q", len(lines), lines) + } + if !bytes.Contains(lines[1], []byte(`"ok"`)) { + t.Errorf("id=2 line should be retry success: %q", lines[1]) + } +} + +func TestProxy_AfterAttempted_NoParse(t *testing.T) { + tr := newScriptedTransport() + var calls atomic.Int32 + resolver := func(ctx context.Context) (http.Header, error) { + calls.Add(1) + return http.Header{"X-Token": []string{"ok"}}, nil + } + go func() { + tr.push(`{"jsonrpc":"2.0","id":1,"result":{"isError":true,"content":[]}}`) + for tr.sentCount() < 2 { + time.Sleep(5 * time.Millisecond) + } + tr.push(`{"jsonrpc":"2.0","id":1,"result":{"isError":false,"content":[]}}`) + tr.push(`{"jsonrpc":"2.0","id":2,"result":{"isError":true,"content":[]}}`) + tr.Close() + }() + stdin := "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\"}\n" + + "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\"}\n" + _, _ = runProxyForTest(t, tr, resolver, stdin) + if got := calls.Load(); got != 1 { + t.Errorf("resolver calls = %d, want 1", got) + } +} + +func TestProxy_Notification_NotTracked(t *testing.T) { + tr := newScriptedTransport() + resolver := func(ctx context.Context) (http.Header, error) { + return http.Header{"X-Token": []string{"ok"}}, nil + } + go func() { + tr.push(`{"jsonrpc":"2.0","method":"notifications/cancelled","params":{}}`) + tr.push(`{"jsonrpc":"2.0","id":1,"result":{"isError":true,"content":[]}}`) + for tr.sentCount() < 2 { + time.Sleep(5 * time.Millisecond) + } + tr.push(`{"jsonrpc":"2.0","id":1,"result":{"isError":false,"content":[]}}`) + tr.Close() + }() + stdin := "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\"}\n" + lines, _ := runProxyForTest(t, tr, resolver, stdin) + if len(lines) != 2 { + t.Fatalf("expected 2 lines (notification + retry success), got %d: %q", len(lines), lines) + } +} From ce3705f7252c6999bc2c74576afb07785f1c9d02 Mon Sep 17 00:00:00 2001 From: Lorem Dev Date: Fri, 15 May 2026 22:38:15 +0200 Subject: [PATCH 10/25] docs: describe body-level JSON-RPC error trigger for lazy auth --- CHANGES.md | 7 +++++++ docs/architecture.md | 14 ++++++++++++++ docs/configuration.md | 8 ++++++++ 3 files changed, 29 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 2b7745e..629128e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,13 @@ ## Development +- `locksmith mcp run --url` now triggers templated-header resolution + not only on HTTP `401/403` but also when the remote MCP server + returns `200 OK` with a JSON-RPC `error` field or a tool-level + `result.isError: true`. Resolution and retry happen exactly once + per session; if the retry also fails, the error is forwarded to the + AI client unchanged. Detection looks only at structural fields - no + keyword matching against `result.content[].text`. - docs(cli): drop Touch ID wording from CLI help, README, configuration and architecture docs, and plugin READMEs; keychain authorization is described as OS-delegated without naming a specific auth method diff --git a/docs/architecture.md b/docs/architecture.md index 470bed3..054a6d5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -115,6 +115,20 @@ sends its first MCP request: the MCP `initialize` handshake without auth therefore never trigger a vault prompt for that connection. +The same `resolveOnce` lifecycle also triggers on **body-level errors**: +while the resolver has not yet been invoked, the proxy run loop +inspects each server response. If the response is a JSON-RPC envelope +with a non-empty top-level `error` field, or a `result.isError: true` +tool error, the proxy treats it as an auth-failure signal: it calls +`resolveOnce`, re-sends the original request bytes (keyed by JSON-RPC +`id`), and waits for the response with the same id before forwarding +to the client. Any other responses that arrive during the retry wait +are forwarded as-is. After the first attempt (success or failure), no +further body inspection happens; subsequent errors propagate verbatim. +Free-form text inside `result.content[].text` is never matched against +keywords - only structural signals (`error`, `isError`) trigger the +retry. + Each mode resolves its secrets exactly once - lazily, but not repeatedly. Subsequent client requests reuse the env vars or HTTP headers established on the first message. diff --git a/docs/configuration.md b/docs/configuration.md index 461c763..abbe5ec 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -256,6 +256,14 @@ first request. The auth-deferral is automatic and has no config knob; servers that do not require auth on the MCP handshake therefore never trigger a vault prompt for that connection. +The same one-shot resolution also fires on **JSON-RPC body errors**: +if the remote server returns HTTP `200 OK` but the response carries a +JSON-RPC `error` field or a tool-level `result.isError: true`, +locksmith treats it as an auth-failure signal, resolves the templated +headers, and retries the failing request once. If the retry also +fails, the response is forwarded to the AI client unchanged. No +configuration knob - the behaviour is automatic. + ### mcp.servers.\.command **Required (local mode).** List of strings: executable followed by From c80677eb8d81dc4d262de3e61eb14e1d5a29df72 Mon Sep 17 00:00:00 2001 From: Lorem Dev Date: Fri, 15 May 2026 22:41:37 +0200 Subject: [PATCH 11/25] feat(mcp): log retry-wait shutdown and clarify clear() doc --- internal/mcp/proxy.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/mcp/proxy.go b/internal/mcp/proxy.go index 4163f59..3f57e8c 100644 --- a/internal/mcp/proxy.go +++ b/internal/mcp/proxy.go @@ -351,6 +351,7 @@ func handleBodyError( for { next, ok := recv() if !ok { + log.Debug().Str("id", id).Msg("mcp proxy: retry wait interrupted by shutdown") return true } forward(next) @@ -436,7 +437,10 @@ func (s *proxyState) take(id string) []byte { } // clear releases the inFlight map. Subsequent record/take calls are -// no-ops. Called once authState.Attempted() flips to true. +// no-ops. Called from the body-error retry handshake on each exit +// branch (resolve failure, send failure, retry completed) - once any +// of those fires, authState.Attempted() is true and tracking is no +// longer needed. func (s *proxyState) clear() { s.mu.Lock() defer s.mu.Unlock() From a7ebcbbe2bcdcf1e5bd38d105f384718cbf2e35e Mon Sep 17 00:00:00 2001 From: Lorem Dev Date: Wed, 20 May 2026 21:00:11 +0200 Subject: [PATCH 12/25] feat(sdk): add InvalidArgumentError constructor --- sdk/errors/errors.go | 5 +++++ sdk/errors/errors_test.go | 15 +++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/sdk/errors/errors.go b/sdk/errors/errors.go index b81c4d5..7f37c14 100644 --- a/sdk/errors/errors.go +++ b/sdk/errors/errors.go @@ -35,3 +35,8 @@ func UnauthenticatedError(msg string) error { // InternalError returns a VaultError with code Internal. func InternalError(msg string) error { return &VaultError{Code: codes.Internal, Message: msg} } + +// InvalidArgumentError returns a VaultError with code InvalidArgument. +func InvalidArgumentError(msg string) error { + return &VaultError{Code: codes.InvalidArgument, Message: msg} +} diff --git a/sdk/errors/errors_test.go b/sdk/errors/errors_test.go index 992601a..4f217b0 100644 --- a/sdk/errors/errors_test.go +++ b/sdk/errors/errors_test.go @@ -1,6 +1,7 @@ package errors_test import ( + errorsstd "errors" "testing" "google.golang.org/grpc/codes" @@ -30,6 +31,20 @@ func TestVaultError_GRPCStatus(t *testing.T) { } } +func TestInvalidArgumentError(t *testing.T) { + err := sdkerrors.InvalidArgumentError("bad path") + var ve *sdkerrors.VaultError + if !errorsstd.As(err, &ve) { + t.Fatalf("InvalidArgumentError() did not return *VaultError, got %T", err) + } + if ve.Code != codes.InvalidArgument { + t.Errorf("Code = %v, want codes.InvalidArgument", ve.Code) + } + if ve.Message != "bad path" { + t.Errorf("Message = %q, want %q", ve.Message, "bad path") + } +} + func TestVaultError_Constructors(t *testing.T) { cases := []struct { name string From 20c99abf2d2dfe89b9399624a0a868f88a663b3b Mon Sep 17 00:00:00 2001 From: Lorem Dev Date: Sat, 23 May 2026 00:08:31 +0200 Subject: [PATCH 13/25] feat(plugin): add 1Password vault plugin via op CLI Built-in 1Password vault plugin that retrieves secrets through the op CLI. Detected by locksmith init when op is on PATH; falls back to manual selection otherwise. Includes parsePath for three reference forms (op://full, item/field, item with default field), op-stderr error classification into typed SDK errors (NotFound, PermissionDenied, Unauthenticated, InvalidArgument), explicit env allowlist that excludes OP_SERVICE_ACCOUNT_TOKEN, HealthCheck via 'op --version' + 'op vault list', and >=90% test coverage with no real-op shellouts. Adds the vaults..vault config field, delivered to plugins as opts["vault"], so paths like "Item/field" can be qualified against a default 1Password vault. Docs: new plugins/onepassword/README.md, 1password section in docs/configuration.md, vault row in README.md. --- CHANGES.md | 2 + CONTRIBUTING.md | 2 +- README.md | 27 +- docs/architecture.md | 17 +- docs/configuration.md | 70 ++++ docs/development-tools.md | 7 +- docs/plugins/README.md | 3 +- docs/plugins/architecture.md | 4 +- docs/release.md | 51 ++- go.work | 1 + internal/cli/init_cmd_test.go | 25 +- internal/config/config.go | 4 + internal/daemon/server.go | 6 + internal/daemon/server_internal_test.go | 49 +++ internal/initflow/detect.go | 2 +- internal/initflow/flow_test.go | 79 +++- internal/initflow/huh_prompter_test.go | 63 ++- plugins/onepassword/README.md | 225 ++++++++++ plugins/onepassword/go.mod | 29 ++ plugins/onepassword/go.sum | 77 ++++ plugins/onepassword/main.go | 9 + plugins/onepassword/provider.go | 285 +++++++++++++ plugins/onepassword/provider_test.go | 531 ++++++++++++++++++++++++ 23 files changed, 1498 insertions(+), 70 deletions(-) create mode 100644 plugins/onepassword/README.md create mode 100644 plugins/onepassword/go.mod create mode 100644 plugins/onepassword/go.sum create mode 100644 plugins/onepassword/main.go create mode 100644 plugins/onepassword/provider.go create mode 100644 plugins/onepassword/provider_test.go diff --git a/CHANGES.md b/CHANGES.md index 629128e..8a7d003 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -9,6 +9,8 @@ per session; if the retry also fails, the error is forwarded to the AI client unchanged. Detection looks only at structural fields - no keyword matching against `result.content[].text`. +- Add `vaults..vault` config field, delivered to plugins as `opts["vault"]`; used by the 1Password plugin to qualify non-`op://` paths. +- Add 1Password vault plugin backed by the op CLI, auto-detected when `op` is installed. - docs(cli): drop Touch ID wording from CLI help, README, configuration and architecture docs, and plugin READMEs; keychain authorization is described as OS-delegated without naming a specific auth method diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3f526f9..69ea3e6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -249,7 +249,7 @@ Relates # ``` feat(session): add TTL-based expiry with memory wipe -fix(keychain): handle errSecUserCanceled from Touch ID prompt +fix(keychain): handle errSecUserCanceled from Keychain prompt chore: update golangci-lint to v1.57 ``` diff --git a/README.md b/README.md index b816c9f..a5f864d 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Locksmith Secure middleware that gives AI agents access to secrets stored in vault providers -(macOS Keychain, gopass), with per-session caching and -vault-delegated authorization (Keychain, GPG passphrase). +(macOS Keychain, gopass, 1Password), with per-session caching and +vault-delegated authorization (Keychain, GPG passphrase, 1Password desktop app). ## Installation @@ -39,7 +39,7 @@ locksmith init ``` `locksmith init` is an interactive wizard: it detects your available vaults -(Keychain, gopass), writes a starter config, and installs hooks for the AI +(Keychain, gopass, 1Password), writes a starter config, and installs hooks for the AI agents you use (Claude Code, Cursor, Copilot, Codex, Gemini CLI) so they get a `LOCKSMITH_SESSION` automatically. The daemon is started by the installed shell hook on the next shell session. @@ -47,8 +47,8 @@ shell hook on the next shell session. ## Configuration After `init`, your config lives at `~/.config/locksmith/config.yaml`. A -minimal example with two vaults (gopass for work, macOS Keychain for -personal) and three named keys: +minimal example with three vaults (gopass for work, macOS Keychain for +personal, 1Password for shared team secrets) and four named keys: ```yaml defaults: @@ -61,6 +61,9 @@ vaults: personal: type: keychain service: locksmith # macOS only; Keychain service name + op: + type: 1password + vault: Personal # default 1Password vault for non-qualified paths keys: github-token: @@ -72,6 +75,9 @@ keys: slack-webhook: vault: personal path: slack-incoming-webhook + notion-token: + vault: op + path: Notion # resolves to op://Personal/Notion/password mcp: servers: @@ -236,17 +242,18 @@ in `~/.config/locksmith/config.yaml` (default: `true`). |-------|----------|------|--------| | macOS Keychain | macOS | Keychain | Supported | | gopass | macOS, Linux | GPG passphrase | Supported | -| 1Password | macOS, Linux | Keychain / master password | Planned | +| 1Password | macOS, Linux | Desktop app (biometric) / `op signin` | Supported | | GNOME Keyring | Linux | Keyring password | Planned | ### Plugin Setup Guides Per-plugin installation, configuration examples, and troubleshooting: -| Plugin | Platform | Setup guide | -|----------|--------------|----------------------------------------------------------| -| gopass | Linux, macOS | [`plugins/gopass/README.md`](plugins/gopass/README.md) | -| keychain | macOS only | [`plugins/keychain/README.md`](plugins/keychain/README.md) | +| Plugin | Platform | Setup guide | +|-------------|--------------|----------------------------------------------------------------------| +| gopass | Linux, macOS | [`plugins/gopass/README.md`](plugins/gopass/README.md) | +| keychain | macOS only | [`plugins/keychain/README.md`](plugins/keychain/README.md) | +| 1password | Linux, macOS | [`plugins/onepassword/README.md`](plugins/onepassword/README.md) | ## Documentation diff --git a/docs/architecture.md b/docs/architecture.md index 054a6d5..d9135f2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -9,10 +9,10 @@ communicating with the central daemon over gRPC, using ``` locksmith CLI ──(gRPC/Unix socket)──▶ locksmith daemon │ - ┌─────────┴─────────┐ - gRPC ▼ gRPC ▼ - keychain gopass - plugin plugin + ┌───────────────┼───────────────┐ + gRPC ▼ gRPC ▼ gRPC ▼ + keychain gopass 1password + plugin plugin plugin ``` ## Components @@ -72,9 +72,10 @@ Each plugin is a standalone binary implementing the `VaultProviderService` gRPC ### Bundled plugins and pinentry -Default vault plugins and `locksmith-pinentry` ship embedded in the -`locksmith` binary as a per-platform zip and are extracted at `locksmith -init` to `~/.config/locksmith/`. Plugin version is locked to the host +Default vault plugins (`keychain`, `gopass`, `1password`) and +`locksmith-pinentry` ship embedded in the `locksmith` binary as a +per-platform zip and are extracted at `locksmith init` to +`~/.config/locksmith/`. Plugin version is locked to the host `locksmith` version. The full pipeline is documented in [`docs/plugins/architecture.md`](plugins/architecture.md). @@ -181,6 +182,6 @@ Agents interact with the daemon exclusively through the CLI. Session management follows the protocol described in [Agent Integration](agent-integration.md): the `locksmith session ensure` command reuses an existing valid session from `LOCKSMITH_SESSION` or starts a new one. Platform hook templates are embedded -in the binary and installed by `locksmith autostart install`; the root +in the binary and installed by `locksmith init` (autostart prompt); the root [`AGENTS.md`](../AGENTS.md) and [Agent Integration](agent-integration.md) describe the protocol for platforms without hook support. diff --git a/docs/configuration.md b/docs/configuration.md index abbe5ec..98d5eff 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -140,6 +140,75 @@ keys: --- +### 1password + +Retrieves secrets from a local 1Password account by shelling out to the +1Password CLI (`op`). Authorization is delegated to the 1Password 8 desktop +app or to an active `op signin` session. + +> Plugin-specific setup, examples, and troubleshooting: +> [`plugins/onepassword/README.md`](../plugins/onepassword/README.md). + +**Configuration:** + +```yaml +vaults: + op: + type: 1password + vault: Personal # optional: default 1Password vault for non-qualified paths +``` + +> **Note:** The `vault:` key appears in two different contexts and means two +> different things. Under `vaults:`, `vault: Personal` names the 1Password +> vault (the container inside your 1Password account). Under `keys:`, +> `vault: op` names the locksmith vault alias defined in the `vaults:` block. +> Same YAML key, different concepts. + +**Path forms:** + +```yaml +keys: + # Form 1 - full op:// reference (vault-level vault: is ignored) + secret-a: + vault: op + path: "op://Personal/MyItem/password" + # 4-segment form also accepted: op://Personal/MyItem/Section/field + + # Form 2 - item/field pair (vault-level vault: required) + secret-b: + vault: op + path: "MyItem/password" # resolves to op://Personal/MyItem/password + + # Form 3 - item only, field defaults to password (vault-level vault: required) + secret-c: + vault: op + path: "MyItem" # resolves to op://Personal/MyItem/password +``` + +**Resolution rules:** + +- Form 1: the path is passed to `op read` as-is; accepts 3-segment + (`op://vault/item/field`) or 4-segment (`op://vault/item/section/field`) + references. +- Forms 2 and 3: require `vault:` to be set on the locksmith vault entry; the + 1Password vault name is prepended automatically. +- Empty path, malformed reference, or more than 4 segments returns + `InvalidArgument`. + +**Notes:** + +- Requires the `op` CLI installed and on `$PATH` - see + [`plugins/onepassword/README.md`](../plugins/onepassword/README.md). +- For background daemons, enable "Integrate with 1Password CLI" in + 1Password 8 -> Settings -> Developer; daemons run without a TTY and cannot + prompt for a passphrase otherwise. +- Service-account tokens (`OP_SERVICE_ACCOUNT_TOKEN`), 1Password Connect, and + multi-account setups via `OP_ACCOUNT` are not configured here - see the + plugin README for `OP_ACCOUNT` details. +- Windows is not yet supported by the locksmith bundle. + +--- + ### gopass Retrieves secrets from a [gopass](https://github.com/gopasspw/gopass) password store. @@ -308,6 +377,7 @@ Template tokens: | type | Description | |------|-------------| | `keychain` | macOS Keychain (CGo) | +| `1password` | 1Password (shells out to the `op` CLI) | | `gopass` | gopass password manager (shells out to `gopass` CLI) | Default plugins are placed in `~/.config/locksmith/plugins/` automatically diff --git a/docs/development-tools.md b/docs/development-tools.md index 52716d6..00315ad 100644 --- a/docs/development-tools.md +++ b/docs/development-tools.md @@ -21,12 +21,13 @@ lint, and codegen targets in `Makefile`. - Source: https://github.com/bufbuild/buf - License text: https://github.com/bufbuild/buf/blob/v1.68.1/LICENSE -### github.com/golangci/golangci-lint -- Version: v1.64.8 +### github.com/golangci/golangci-lint/v2 +- Version: v2.11.4 - License: GPL-3.0 - Role: Go linter aggregator (`make lint`) - Source: https://github.com/golangci/golangci-lint -- License text: https://github.com/golangci/golangci-lint/blob/v1.64.8/LICENSE +- Install path: github.com/golangci/golangci-lint/v2/cmd/golangci-lint +- License text: https://github.com/golangci/golangci-lint/blob/v2.11.4/LICENSE ## AI agent tooling diff --git a/docs/plugins/README.md b/docs/plugins/README.md index 57ebd4f..ef3e71b 100644 --- a/docs/plugins/README.md +++ b/docs/plugins/README.md @@ -4,7 +4,7 @@ Locksmith vault providers are standalone Go binaries that implement a small gRPC interface (`VaultProviderService`). Two kinds of plugins exist: - **Built-in** - shipped inside the `locksmith` binary as a per-platform zip. - Currently `gopass` (Linux + macOS) and `keychain` (macOS only). Their + Currently `gopass` (Linux + macOS), `keychain` (macOS only), and `1password` (Linux + macOS). Their version is locked to the `locksmith` version that built them; there is no separate plugin release cycle, no network resolution, no version drift. See [`architecture.md`](architecture.md). @@ -25,3 +25,4 @@ gRPC interface (`VaultProviderService`). Two kinds of plugins exist: - [`plugins/gopass/README.md`](../../plugins/gopass/README.md) - [`plugins/keychain/README.md`](../../plugins/keychain/README.md) +- [`plugins/onepassword/README.md`](../../plugins/onepassword/README.md) diff --git a/docs/plugins/architecture.md b/docs/plugins/architecture.md index d792a6e..89ed51d 100644 --- a/docs/plugins/architecture.md +++ b/docs/plugins/architecture.md @@ -1,6 +1,6 @@ # Bundled Plugin Architecture -Default plugins (`gopass`, `keychain`) and `locksmith-pinentry` ship inside +Default plugins (`gopass`, `keychain`, `1password`) and `locksmith-pinentry` ship inside the `locksmith` binary as a per-platform zip embedded with `//go:embed`. This document covers the build pipeline, extraction flow, conflict policy, and the lockstep-versioning principle. @@ -23,7 +23,7 @@ binary developer tool. [ go build pinentry ] | v -[ go run ./.scripts/build-plugins ] (gopass, keychain on darwin) +[ go run ./.scripts/build-plugins ] (gopass, keychain on darwin, 1password) | v [ go run ./.scripts/build-bundle ] -> internal/bundled/assets/bundle-.zip diff --git a/docs/release.md b/docs/release.md index 7537faf..144d747 100644 --- a/docs/release.md +++ b/docs/release.md @@ -2,50 +2,66 @@ This is the maintainer's checklist for shipping a new locksmith version. It assumes the GPG signing setup described in -[CONTRIBUTING.md](../CONTRIBUTING.md#release-signing-setup) is +[CONTRIBUTING.md](../CONTRIBUTING.md#release-signing-ci) is already in place. ## Procedure -1. **Clean main.** From a clean working tree on `main`: +1. **Prepare on `develop`.** From a clean working tree on `develop`: ```sh git status # clean - git pull --ff-only origin main + git pull --ff-only origin develop ``` -2. **Bump version + compress changelog.** Run the `version-bump` - skill. It prompts for the new version, updates - `sdk/version/VERSION`, and invokes the `changelog` skill to - compress `## Development` into a `## Version vX.Y.Z` section in - `CHANGES.md`. Verify the resulting section preserves all + Run the `release-prep` skill. It verifies that each accumulated + `## Development` bullet has matching docs updates since the last + tag, then invokes `version-bump`, which: + + - bumps `sdk/version/VERSION` to the new version, + - syncs the README install pin, and + - invokes the `changelog` skill to compress `## Development` into + a `## Version vX.Y.Z` section in `CHANGES.md`. + + Verify the resulting `## Version vX.Y.Z` section preserves all `BREAKING:` bullets at the top, in their original order - (see [CONTRIBUTING.md "Changelog policy"](../CONTRIBUTING.md#changelog-policy-breaking-changes)). + (see [CONTRIBUTING.md "Changelog policy"](../CONTRIBUTING.md#changelog-policy)). -3. **Optional: licenses.** The `version-bump` skill offers to invoke +2. **Optional: licenses.** The `version-bump` skill offers to invoke `check-licenses`. Accept if there have been dependency changes since the last release. -4. **Review.** `git diff` should show only `sdk/version/VERSION`, - `CHANGES.md`, and (if licenses changed) `LICENSE`. - -5. **Commit and tag.** Use a GPG-signed commit and an annotated tag: +3. **Review and commit.** `git diff` should show only + `sdk/version/VERSION`, `CHANGES.md`, and (if licenses changed) + `LICENSE`. Commit with a GPG-signed commit: ```sh git add sdk/version/VERSION CHANGES.md LICENSE git commit -S -m "release: vX.Y.Z" + git push origin develop + ``` + +4. **Open a PR from `develop` into `main`.** Use the new + `## Version vX.Y.Z` body as the PR description. + +5. **Merge the PR.** The merge commit on `main` is the release commit. + +6. **Tag the merge commit.** After the merge: + + ```sh + git checkout main && git pull --ff-only git tag -s vX.Y.Z -m "vX.Y.Z" - git push --follow-tags + git push origin vX.Y.Z ``` -6. **Watch CI.** The release workflow runs in three stages +7. **Watch CI.** The release workflow runs in three stages (validate -> build matrix + package -> publish): ```sh gh run watch ``` -7. **Smoke-test.** After the workflow completes: +8. **Smoke-test.** After the workflow completes: ```sh docker run --rm -it ubuntu:24.04 sh -c ' @@ -86,7 +102,6 @@ A successful publish produces: - `locksmith-linux-amd64.zip` - `locksmith-linux-arm64.zip` -- `locksmith-darwin-amd64.zip` - `locksmith-darwin-arm64.zip` - `install.sh` - `checksums.txt` diff --git a/go.work b/go.work index 5587a91..a36c459 100644 --- a/go.work +++ b/go.work @@ -4,5 +4,6 @@ use ( . ./plugins/gopass ./plugins/keychain + ./plugins/onepassword ./sdk ) diff --git a/internal/cli/init_cmd_test.go b/internal/cli/init_cmd_test.go index 086f536..d8e5f61 100644 --- a/internal/cli/init_cmd_test.go +++ b/internal/cli/init_cmd_test.go @@ -4,13 +4,25 @@ import ( "testing" "github.com/lorem-dev/locksmith/internal/cli" + "github.com/lorem-dev/locksmith/internal/initflow" ) // TestInitCmd_Auto covers RunE in init_cmd.go via --auto --skip-agents. // The command writes a config file; we redirect HOME so it stays in a temp dir. +// DetectVaultsFn is stubbed to return only gopass so the test does not depend +// on which vault binaries are installed on the test machine. func TestInitCmd_Auto(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) + + orig := initflow.DetectVaultsFn + initflow.DetectVaultsFn = func() []initflow.DetectedVault { + return []initflow.DetectedVault{ + {Type: "gopass", Detected: true, Available: true, Implemented: true}, + } + } + t.Cleanup(func() { initflow.DetectVaultsFn = orig }) + root := cli.NewRootCmd() root.SetArgs([]string{"init", "--auto", "--skip-agents"}) if err := root.Execute(); err != nil { @@ -18,10 +30,21 @@ func TestInitCmd_Auto(t *testing.T) { } } -// TestInitCmd_Auto_AgentOnly covers the --agent flag path. +// TestInitCmd_Auto_AgentOnly covers the --agent flag path. DetectVaultsFn is +// stubbed so the test does not depend on which vault binaries are installed +// on the test machine. func TestInitCmd_Auto_AgentOnly(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) + + orig := initflow.DetectVaultsFn + initflow.DetectVaultsFn = func() []initflow.DetectedVault { + return []initflow.DetectedVault{ + {Type: "gopass", Detected: true, Available: true, Implemented: true}, + } + } + t.Cleanup(func() { initflow.DetectVaultsFn = orig }) + root := cli.NewRootCmd() root.SetArgs([]string{"init", "--auto", "--agent", "claude"}) // Claude Code dir does not exist in the temp home, so it won't be detected. diff --git a/internal/config/config.go b/internal/config/config.go index 1a73a4a..67bef85 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -63,6 +63,10 @@ type Vault struct { // Service is the default Keychain service name for keychain vaults. // Individual keys can override this via the "service/account" path shorthand. Service string `yaml:"service,omitempty"` + // Vault is the default 1Password vault name for 1password backends. It + // is delivered to the plugin as opts["vault"] and prepended to + // non-qualified key paths (e.g. "Item/field" or just "Item"). + Vault string `yaml:"vault,omitempty"` } // Key is a named alias pointing to a secret in a specific vault. diff --git a/internal/daemon/server.go b/internal/daemon/server.go index ccc172e..718be51 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -256,6 +256,9 @@ func (s *Server) resolveKey( if vaultDef.Service != "" { opts["service"] = vaultDef.Service } + if vaultDef.Vault != "" { + opts["vault"] = vaultDef.Vault + } return vaultDef.Type, keyDef.Path, opts, nil } if req.VaultName == "" || req.Path == "" { @@ -268,6 +271,9 @@ func (s *Server) resolveKey( if vaultDef.Service != "" { opts["service"] = vaultDef.Service } + if vaultDef.Vault != "" { + opts["vault"] = vaultDef.Vault + } return vaultDef.Type, req.Path, opts, nil } return req.VaultName, req.Path, opts, nil diff --git a/internal/daemon/server_internal_test.go b/internal/daemon/server_internal_test.go index d67673b..609f190 100644 --- a/internal/daemon/server_internal_test.go +++ b/internal/daemon/server_internal_test.go @@ -431,6 +431,55 @@ func TestResolveKey_PassesServiceOpt(t *testing.T) { } } +func TestResolveKey_PassesVaultOpt(t *testing.T) { + cfg := &config.Config{ + Defaults: config.Defaults{SessionTTL: "1h"}, + Vaults: map[string]config.Vault{"op": {Type: "1password", Vault: "Personal"}}, + Keys: map[string]config.Key{"notion": {Vault: "op", Path: "Notion"}}, + } + provider := &mockProvider{secret: []byte("secret"), contentType: "text/plain"} + reg := &mockRegistry{providers: map[string]vault.Provider{"1password": provider}} + srv := NewServerWithRegistry(func() *config.Config { return cfg }, session.NewStore(), reg, nil) + + startResp, _ := srv.SessionStart(context.Background(), &locksmithv1.SessionStartRequest{}) + _, err := srv.GetSecret(context.Background(), &locksmithv1.GetSecretRequest{ + SessionId: startResp.SessionId, + KeyAlias: "notion", + }) + if err != nil { + t.Fatalf("GetSecret() error: %v", err) + } + if provider.lastReq == nil { + t.Fatal("provider was not called") + } + if provider.lastReq.Opts["vault"] != "Personal" { + t.Errorf("opts[vault] = %q, want %q", provider.lastReq.Opts["vault"], "Personal") + } +} + +func TestResolveKey_DirectVault_PassesVaultOpt(t *testing.T) { + cfg := &config.Config{ + Defaults: config.Defaults{SessionTTL: "1h"}, + Vaults: map[string]config.Vault{"op": {Type: "1password", Vault: "Work"}}, + } + provider := &mockProvider{secret: []byte("secret"), contentType: "text/plain"} + reg := &mockRegistry{providers: map[string]vault.Provider{"1password": provider}} + srv := NewServerWithRegistry(func() *config.Config { return cfg }, session.NewStore(), reg, nil) + + startResp, _ := srv.SessionStart(context.Background(), &locksmithv1.SessionStartRequest{}) + _, err := srv.GetSecret(context.Background(), &locksmithv1.GetSecretRequest{ + SessionId: startResp.SessionId, + VaultName: "op", + Path: "Item/field", + }) + if err != nil { + t.Fatalf("GetSecret() error: %v", err) + } + if provider.lastReq.Opts["vault"] != "Work" { + t.Errorf("opts[vault] = %q, want %q", provider.lastReq.Opts["vault"], "Work") + } +} + func TestResolveKey_DirectVault_PassesServiceOpt(t *testing.T) { cfg := &config.Config{ Defaults: config.Defaults{SessionTTL: "1h"}, diff --git a/internal/initflow/detect.go b/internal/initflow/detect.go index 9b34c5f..159a914 100644 --- a/internal/initflow/detect.go +++ b/internal/initflow/detect.go @@ -66,7 +66,7 @@ func DetectVaults() []DetectedVault { vaults := []DetectedVault{ {Type: config.VaultKeychain, Available: runtime.GOOS == platform.Darwin, Implemented: true}, {Type: config.VaultGopass, Available: true, Implemented: true}, - {Type: config.VaultOnePassword, Available: true, Implemented: false}, + {Type: config.VaultOnePassword, Available: true, Implemented: true}, { Type: config.VaultGnomeKeyring, Available: runtime.GOOS == platform.Linux, diff --git a/internal/initflow/flow_test.go b/internal/initflow/flow_test.go index 70edffe..b7f3ca6 100644 --- a/internal/initflow/flow_test.go +++ b/internal/initflow/flow_test.go @@ -83,6 +83,22 @@ func (m *mockPrompter) BundleExtractPrompt(_, _, _ string) (bundled.ConflictReso return bundled.Keep, nil } +// stubGopassOnly replaces DetectVaultsFn for the duration of t with a stub +// that returns only gopass (detected and implemented). This prevents tests +// that exercise auto-mode plumbing - but do not care about vault detection - +// from picking up real binaries (e.g. "op") that would cause bundle extraction +// to fail because locksmith-plugin-1password is not in the test bundle. +func stubGopassOnly(t *testing.T) { + t.Helper() + orig := initflow.DetectVaultsFn + initflow.DetectVaultsFn = func() []initflow.DetectedVault { + return []initflow.DetectedVault{ + {Type: "gopass", Detected: true, Available: true, Implemented: true}, + } + } + t.Cleanup(func() { initflow.DetectVaultsFn = orig }) +} + func TestAgentMatches_CaseInsensitive(t *testing.T) { cases := []struct { name, query string @@ -105,6 +121,7 @@ func TestAgentMatches_CaseInsensitive(t *testing.T) { func TestRunInit_Auto_NoAgents(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) + stubGopassOnly(t) result, err := initflow.RunInit(initflow.InitOptions{ Auto: true, @@ -121,6 +138,7 @@ func TestRunInit_Auto_NoAgents(t *testing.T) { func TestRunInit_Auto_WithVaultDetection(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) + stubGopassOnly(t) result, err := initflow.RunInit(initflow.InitOptions{ Auto: true, @@ -137,6 +155,7 @@ func TestRunInit_Auto_WithVaultDetection(t *testing.T) { func TestRunInit_Auto_InstallsClaudeCode(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) + stubGopassOnly(t) os.MkdirAll(filepath.Join(home, ".claude"), 0o755) result, err := initflow.RunInit(initflow.InitOptions{ @@ -159,6 +178,7 @@ func TestRunInit_Auto_InstallsClaudeCode(t *testing.T) { func TestRunInit_AgentOnly(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) + stubGopassOnly(t) os.MkdirAll(filepath.Join(home, ".claude"), 0o755) os.MkdirAll(filepath.Join(home, ".codex"), 0o755) @@ -180,6 +200,7 @@ func TestRunInit_AgentOnly(t *testing.T) { func TestRunInit_Auto_InstallsClaudeHook(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) + stubGopassOnly(t) os.MkdirAll(filepath.Join(home, ".claude"), 0o755) result, err := initflow.RunInit(initflow.InitOptions{Auto: true}) @@ -582,6 +603,7 @@ func TestRunInit_ExistingConfig_Exit(t *testing.T) { func TestRunInit_ExistingConfig_Valid_AutoContinues(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) + stubGopassOnly(t) cfgDir := filepath.Join(home, ".config", "locksmith") os.MkdirAll(cfgDir, 0o755) cfgPath := filepath.Join(cfgDir, "config.yaml") @@ -604,6 +626,7 @@ func TestRunInit_ExistingConfig_Valid_AutoContinues(t *testing.T) { func TestRunInit_ExistingConfig_Invalid_AutoOverwrites(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) + stubGopassOnly(t) cfgDir := filepath.Join(home, ".config", "locksmith") os.MkdirAll(cfgDir, 0o755) cfgPath := filepath.Join(cfgDir, "config.yaml") @@ -656,6 +679,7 @@ func TestRunInit_Interactive_GPGPinentryApplied(t *testing.T) { func TestRunInit_ShellHook_AlreadyInstalled(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) + stubGopassOnly(t) // Pre-write the marker to a fake .zshrc zshrc := filepath.Join(home, ".zshrc") @@ -687,6 +711,7 @@ func TestRunInit_ShellHook_AlreadyInstalled(t *testing.T) { func TestRunInit_ShellHook_Auto_Installs(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) + stubGopassOnly(t) t.Setenv("SHELL", "/bin/bash") dir := t.TempDir() @@ -773,6 +798,7 @@ func TestRunInit_ShellHook_Interactive_Declined(t *testing.T) { func TestRunInit_ShellHook_UnknownShell(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) + stubGopassOnly(t) t.Setenv("SHELL", "/bin/sh") // unknown (not bash/zsh/ash/fish) t.Setenv("0", "") @@ -826,45 +852,68 @@ func TestRunInit_Auto_SkipsPlannedVaults(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) - // Override DetectVaultsFn to return a controlled set: - // - 1password: detected but not implemented - // - gopass: detected and implemented orig := initflow.DetectVaultsFn initflow.DetectVaultsFn = func() []initflow.DetectedVault { return []initflow.DetectedVault{ - {Type: "1password", Detected: true, Available: true, Implemented: false}, + {Type: "gnome-keyring", Detected: true, Available: true, Implemented: false, PlatformNote: "Linux only"}, {Type: "gopass", Detected: true, Available: true, Implemented: true}, } } t.Cleanup(func() { initflow.DetectVaultsFn = orig }) - result, err := initflow.RunInit(initflow.InitOptions{ - Auto: true, - SkipAgents: true, - }) + result, err := initflow.RunInit(initflow.InitOptions{Auto: true, SkipAgents: true}) if err != nil { t.Fatalf("RunInit() error: %v", err) } - - // gopass must be selected foundGopass := false for _, v := range result.SelectedVaults { if v == "gopass" { foundGopass = true } + if v == "gnome-keyring" { + t.Errorf("SelectedVaults = %v, must not contain gnome-keyring (not implemented)", result.SelectedVaults) + } } if !foundGopass { t.Errorf("SelectedVaults = %v, want to contain gopass", result.SelectedVaults) } +} - // 1password must NOT be selected +func TestRunInit_Auto_SelectsOnePasswordWhenDetected(t *testing.T) { + // This test requires locksmith-plugin-1password to be present in the + // embedded bundle. Skip if the bundle predates that entry (e.g. on a + // developer machine before running `make build-all` with the 1password plugin). + b, err := bundled.OpenBundle() + if err != nil { + t.Skipf("bundle not available (%v); skipping 1password extraction test", err) + } + if _, ok := b.FindEntry("locksmith-plugin-1password"); !ok { + t.Skip("locksmith-plugin-1password not in bundle; run `make build-all` to include it") + } + + home := t.TempDir() + t.Setenv("HOME", home) + + orig := initflow.DetectVaultsFn + initflow.DetectVaultsFn = func() []initflow.DetectedVault { + return []initflow.DetectedVault{ + {Type: "1password", Detected: true, Available: true, Implemented: true}, + {Type: "gopass", Detected: false, Available: true, Implemented: true}, + } + } + t.Cleanup(func() { initflow.DetectVaultsFn = orig }) + + result, err := initflow.RunInit(initflow.InitOptions{Auto: true, SkipAgents: true}) + if err != nil { + t.Fatalf("RunInit() error: %v", err) + } + found := false for _, v := range result.SelectedVaults { if v == "1password" { - t.Errorf("SelectedVaults = %v, must not contain 1password (not implemented)", result.SelectedVaults) + found = true } } - - if len(result.SelectedVaults) != 1 { - t.Errorf("SelectedVaults = %v, want exactly [gopass]", result.SelectedVaults) + if !found { + t.Errorf("SelectedVaults = %v, want to contain 1password (detected and implemented)", result.SelectedVaults) } } diff --git a/internal/initflow/huh_prompter_test.go b/internal/initflow/huh_prompter_test.go index 3b4a355..ba59bdf 100644 --- a/internal/initflow/huh_prompter_test.go +++ b/internal/initflow/huh_prompter_test.go @@ -402,39 +402,82 @@ func TestHuhPrompter_BundleExtractPrompt_Accessible(t *testing.T) { } func TestHuhPrompter_VaultSelection_PlannedExcluded(t *testing.T) { - // gopass is implemented and pre-selected; 1password is planned and must be excluded. - // Enter 0 to confirm the default selection. + // gnome-keyring is still planned; gopass is implemented and pre-selected. p := newHuhWithInput("0\n") vaults := []initflow.DetectedVault{ {Type: config.VaultGopass, Available: true, Detected: true, Implemented: true}, - {Type: config.VaultOnePassword, Available: true, Detected: true, Implemented: false}, + { + Type: config.VaultGnomeKeyring, + Available: true, + Detected: true, + Implemented: false, + PlatformNote: "Linux only", + }, } got, err := p.VaultSelection(vaults) if err != nil { t.Fatalf("VaultSelection() error: %v", err) } - if len(got) != 1 { - t.Errorf("VaultSelection returned %d vaults, want exactly 1 (gopass)", len(got)) + for _, v := range got { + if v == config.VaultGnomeKeyring { + t.Errorf("VaultSelection() returned planned backend %q", v) + } } found := false for _, v := range got { if v == config.VaultGopass { found = true } - if v == config.VaultOnePassword { - t.Errorf("VaultSelection() returned planned backend %q", v) - } } if !found { t.Errorf("VaultSelection() = %v, want result containing gopass", got) } } +func TestHuhPrompter_VaultSelection_OnePasswordImplemented(t *testing.T) { + // 1password is now implemented and detected (op present), so it + // appears in the picker and is pre-selected. + p := newHuhWithInput("0\n") + vaults := []initflow.DetectedVault{ + {Type: config.VaultOnePassword, Available: true, Detected: true, Implemented: true}, + } + got, err := p.VaultSelection(vaults) + if err != nil { + t.Fatalf("VaultSelection() error: %v", err) + } + if len(got) != 1 || got[0] != config.VaultOnePassword { + t.Errorf("VaultSelection() = %v, want [%q]", got, config.VaultOnePassword) + } +} + +func TestHuhPrompter_VaultSelection_OnePasswordNotDetected(t *testing.T) { + // 1password is implemented but op is not installed: shown but not pre-selected. + p := newHuhWithInput("0\n") + vaults := []initflow.DetectedVault{ + {Type: config.VaultGopass, Available: true, Detected: true, Implemented: true}, + {Type: config.VaultOnePassword, Available: true, Detected: false, Implemented: true}, + } + got, err := p.VaultSelection(vaults) + if err != nil { + t.Fatalf("VaultSelection() error: %v", err) + } + for _, v := range got { + if v == config.VaultOnePassword { + t.Errorf("VaultSelection() pre-selected non-detected 1password") + } + } +} + func TestHuhPrompter_VaultSelection_NoImplemented_Errors(t *testing.T) { - // All vaults are planned (Implemented: false); VaultSelection must return an error. p := newHuhWithInput("") vaults := []initflow.DetectedVault{ - {Type: config.VaultOnePassword, Available: true, Detected: true, Implemented: false}, + { + Type: config.VaultGnomeKeyring, + Available: true, + Detected: true, + Implemented: false, + PlatformNote: "Linux only", + }, } _, err := p.VaultSelection(vaults) if err == nil { diff --git a/plugins/onepassword/README.md b/plugins/onepassword/README.md new file mode 100644 index 0000000..0b38396 --- /dev/null +++ b/plugins/onepassword/README.md @@ -0,0 +1,225 @@ +# locksmith-plugin-1password + +Locksmith vault plugin that retrieves secrets from a local 1Password account +by shelling out to the `op` CLI. Authorization is delegated to the 1Password +8 desktop app (biometric or master password) or to an active `op signin` +session. No service-account token and no self-hosted Connect server are +required. + +## See also + +- [Configuration Reference - 1Password](../../docs/configuration.md#1password) +- [Plugins](../../docs/plugins/README.md) + +## Why the `op` CLI + +No public Go SDK talks directly to the local 1Password desktop app. The +native-messaging channel between `op` and the desktop app is proprietary and +undocumented. The two SDKs that 1Password publishes solve different problems: + +- `connect-sdk-go` - makes HTTP calls to a self-hosted 1Password Connect + server, which requires a Business plan and a server you operate yourself. +- `onepassword-sdk-go` - authenticates with an `OP_SERVICE_ACCOUNT_TOKEN` + issued from the 1Password developer portal, which requires a Business plan + and bypasses the desktop app entirely. + +Neither SDK can unlock a developer's personal local vault the same way `op` +can. The `op` CLI is the only sanctioned, documented channel to a developer's +local 1Password account, and it integrates with the desktop app's biometric +unlock via the "Integrate with 1Password CLI" setting. + +## Requirements + +- 1Password 8 desktop app installed and signed in. +- `op` CLI available on `$PATH`. +- macOS or Linux (Windows is not yet supported by the locksmith bundle). + +## Installing the 1Password CLI + +**macOS (Homebrew):** + +```bash +brew install 1password-cli +``` + +**Linux - Debian / Ubuntu:** + +See for the +apt repository setup. + +**Linux - Arch:** + +```bash +pacman -S 1password-cli +``` + +**Other Linux distributions / manual install:** + + + +## Enable desktop integration + +In 1Password 8, open **Settings -> Developer** and enable +**"Integrate with 1Password CLI"**. + +Without this setting, every `op read` call requires a fresh `op signin` +session. Background daemons run without a TTY and cannot prompt for a +passphrase, so they receive an `Unauthenticated` error unless the desktop +integration is active. + +## Installation + +The plugin is normally installed automatically by `locksmith init` from +the embedded bundle into `~/.config/locksmith/plugins/`. The build commands +below are for development only. + +Build everything from the repository root: + +```bash +make build-all +``` + +This produces `locksmith-plugin-1password` next to the `locksmith` binary. + +To build only this plugin: + +```bash +cd plugins/onepassword +go build -o locksmith-plugin-1password . +``` + +Place the binary in one of: + +1. The same directory as the `locksmith` binary. +2. `~/.config/locksmith/plugins/`. +3. Any directory in `$PATH`. + +Locksmith discovers plugins automatically by name (`locksmith-plugin-`). + +## Configuration + +Minimal configuration in `~/.config/locksmith/config.yaml`: + +```yaml +vaults: + op: + type: 1password + vault: Personal # optional: default 1Password vault for non-qualified paths + +keys: + notion-token: + vault: op # the locksmith-side vault alias + path: Notion # resolves to op://Personal/Notion/password +``` + +> **Note:** The `vault:` key appears in two different contexts above and means +> two different things. Under `vaults:`, `vault: Personal` names the 1Password +> vault (the container inside your 1Password account). Under `keys:`, +> `vault: op` names the locksmith vault alias defined in the `vaults:` block. +> Same YAML key, different concepts. See +> [Configuration Reference](../../docs/configuration.md#1password) for the +> full reference. + +## Path forms + +### Form 1 - full `op://` reference + +```yaml +keys: + my-secret: + vault: op + path: "op://Personal/MyItem/password" + # also valid with a section: op://Personal/MyItem/Section/field +``` + +The path is passed directly to `op read`. Accepts 3-segment +(`op://vault/item/field`) or 4-segment (`op://vault/item/section/field`) +references. The vault-level `vault:` option is ignored for this form. + +### Form 2 - item and field, vault from config + +```yaml +keys: + my-secret: + vault: op + path: "MyItem/password" # resolves to op:///MyItem/password +``` + +Requires `vault:` to be set on the locksmith vault entry (e.g. +`vault: Personal`). The 1Password vault name is prepended automatically. + +### Form 3 - item only, field defaults to `password` + +```yaml +keys: + my-secret: + vault: op + path: "MyItem" # resolves to op:///MyItem/password +``` + +Also requires `vault:` on the locksmith vault entry. The `password` field is +assumed. + +### Direct access without alias + +Skip the `keys:` block and pass `--vault` and `--path` explicitly: + +```bash +locksmith get --vault op --path "op://Personal/MyItem/password" +``` + +## Troubleshooting + +**`op not found in PATH - install the 1Password CLI`** +The 1Password desktop app and the `op` CLI are separate packages. Installing +the desktop app does not put `op` on `$PATH`. Follow the steps in the +"Installing the 1Password CLI" section above. + +**`op found at but no active session`** +Enable "Integrate with 1Password CLI" in **1Password 8 -> Settings -> +Developer** so the CLI can use the desktop app's active session. Alternatively, +run `op signin` to start a standalone session. Background daemons run without a +TTY and can only use the desktop integration path - `op signin` cannot prompt +for a passphrase in that environment. + +**`item not found: ...` / `vault not found: ...`** +Verify the item exists with `op item list` and that the vault name is correct +with `op vault list`. If you manage multiple 1Password accounts, set +`OP_ACCOUNT=` in the daemon's environment to pin the account used +for all `op` calls. + +**`field not found: ...`** +The item exists but the requested field name is incorrect. Use +`op item get --format=json` to list the available fields. + +**`1password account has no access: ...`** +The item exists in a different account or has not been shared with your account. +Open the 1Password app and confirm the item is visible there, or ask the item +owner to share it with you. + +**`malformed op:// reference: ...`** +The `op://` path is syntactically invalid. A valid reference has 3 or 4 +segments: `op://vault/item/field` or `op://vault/item/section/field`. Check +for typos or empty segments. + +**`sign in via 'op signin' or unlock the 1Password desktop app: ...`** +The `op` CLI is present but has no active session. Enable "Integrate with +1Password CLI" in **1Password 8 -> Settings -> Developer**, or run +`op signin` to start a standalone session. + +**`1password vault: 'vault:' option is required for non-qualified paths`** +The key's `path:` is a bare item name or an `item/field` pair, which requires +the plugin to know which 1Password vault to look in. Either add `vault: ` +to the locksmith vault entry, or use a fully qualified `op://vault/item/field` +reference as the path. + +**Trailing newlines** +The plugin passes `--no-newline` to `op read`, so trailing newlines should not +appear in retrieved secrets under normal circumstances. If you observe one, +confirm your `op` CLI version supports `--no-newline` (`op --version` should be +2.0 or later). + +## Source + +- [`provider.go`](provider.go) - `OnePasswordProvider`, `GetSecret`, `HealthCheck`, `Info` +- [`main.go`](main.go) - `sdk.Serve` entry point diff --git a/plugins/onepassword/go.mod b/plugins/onepassword/go.mod new file mode 100644 index 0000000..d343a8d --- /dev/null +++ b/plugins/onepassword/go.mod @@ -0,0 +1,29 @@ +module github.com/lorem-dev/locksmith-plugin-1password + +go 1.25.9 + +replace github.com/lorem-dev/locksmith/sdk => ../../sdk + +replace github.com/lorem-dev/locksmith => ../../ + +require ( + github.com/lorem-dev/locksmith v0.0.0-00010101000000-000000000000 + github.com/lorem-dev/locksmith/sdk v0.0.0-00010101000000-000000000000 + google.golang.org/grpc v1.80.0 +) + +require ( + github.com/fatih/color v1.13.0 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/hashicorp/go-hclog v1.6.3 // indirect + github.com/hashicorp/go-plugin v1.7.0 // indirect + github.com/hashicorp/yamux v0.1.2 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/oklog/run v1.1.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.33.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/plugins/onepassword/go.sum b/plugins/onepassword/go.sum new file mode 100644 index 0000000..0d9934a --- /dev/null +++ b/plugins/onepassword/go.sum @@ -0,0 +1,77 @@ +github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= +github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-plugin v1.7.0 h1:YghfQH/0QmPNc/AZMTFE3ac8fipZyZECHdDPshfk+mA= +github.com/hashicorp/go-plugin v1.7.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8= +github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= +github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= +github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= +github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= +github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/plugins/onepassword/main.go b/plugins/onepassword/main.go new file mode 100644 index 0000000..4ea59a9 --- /dev/null +++ b/plugins/onepassword/main.go @@ -0,0 +1,9 @@ +// Package main implements the locksmith 1Password vault plugin. +// It shells out to the `op` CLI to retrieve secrets, delegating +// authorization to the 1Password desktop app or an active `op signin` +// session. +package main + +import "github.com/lorem-dev/locksmith/sdk/vault" + +func main() { vault.Serve(&OnePasswordProvider{}) } diff --git a/plugins/onepassword/provider.go b/plugins/onepassword/provider.go new file mode 100644 index 0000000..acde1f9 --- /dev/null +++ b/plugins/onepassword/provider.go @@ -0,0 +1,285 @@ +package main + +import ( + "bytes" + "context" + errorsstd "errors" + "fmt" + "os" + "os/exec" + "strings" + + vaultv1 "github.com/lorem-dev/locksmith/gen/proto/vault/v1" + sdkerrors "github.com/lorem-dev/locksmith/sdk/errors" + "github.com/lorem-dev/locksmith/sdk/platform" + sdkversion "github.com/lorem-dev/locksmith/sdk/version" +) + +// buildOpEnv returns the explicit environment for `op` subprocesses. +// Only non-empty allowlisted variables are included. OP_SERVICE_ACCOUNT_TOKEN +// is intentionally excluded: the desktop-app integration in this plugin must +// never inherit a service-account token from the caller's shell. A future +// 1password-service plugin will handle that path explicitly. +func buildOpEnv() []string { + keys := []string{ + "HOME", + "PATH", + "XDG_CONFIG_HOME", + "OP_DEVICE", + "OP_ACCOUNT", + "OP_BIOMETRIC_UNLOCK_ENABLED", + "DISPLAY", + "WAYLAND_DISPLAY", + } + var env []string + for _, k := range keys { + if v := os.Getenv(k); v != "" { + env = append(env, k+"="+v) + } + } + return env +} + +const ( + opScheme = "op://" + defaultField = "password" + minOpSegments = 3 // vault/item/field + maxOpSegments = 4 // vault/item/section/field + nonQualifiedHint = "1password vault: 'vault:' option is required for non-qualified paths, or use a full op:// reference" +) + +// parsePath resolves a key's path into a fully-qualified op:// reference. +// +// Three forms are supported: +// 1. "op:////" or "op:////
/" +// - used as-is; vaultLevel is ignored. +// 2. "/" - requires vaultLevel; expands to op://vaultLevel/item/field. +// 3. "" - requires vaultLevel; field defaults to "password". +// +// Anything with a non-op:// scheme is treated as a literal item name in form 3 +// to avoid rejecting items that legitimately contain colons. +func parsePath(reqPath, vaultLevel string) (string, error) { + if reqPath == "" { + return "", sdkerrors.InvalidArgumentError("1password path is empty") + } + if strings.HasPrefix(reqPath, opScheme) { + rest := strings.TrimPrefix(reqPath, opScheme) + segs := strings.Split(rest, "/") + if len(segs) < minOpSegments || len(segs) > maxOpSegments { + return "", sdkerrors.InvalidArgumentError( + "1password reference must have 3 or 4 segments after op://, got " + reqPath, + ) + } + for _, s := range segs { + if s == "" { + return "", sdkerrors.InvalidArgumentError( + "1password reference contains empty segment: " + reqPath, + ) + } + } + return reqPath, nil + } + if vaultLevel == "" { + return "", sdkerrors.InvalidArgumentError(nonQualifiedHint) + } + // Paths containing "://" (but not the op:// prefix already handled above) are + // treated as literal item names to avoid misinterpreting schemes as path separators. + var item, field string + if strings.Contains(reqPath, "://") { + item = reqPath + field = defaultField + } else { + parts := strings.SplitN(reqPath, "/", 2) + item = parts[0] + if item == "" { + return "", sdkerrors.InvalidArgumentError("1password path is empty") + } + field = defaultField + if len(parts) == 2 && parts[1] != "" { + field = parts[1] + } + } + return opScheme + vaultLevel + "/" + item + "/" + field, nil +} + +// classifyOpError maps `op` stderr to a typed SDK error. Matching is +// case-insensitive substring search; ordering matters because some phrases +// overlap (e.g. "you don't have permission" is checked after the more +// specific "isn't an item in any vault"). +func classifyOpError(stderr string, exitCode int) error { + trimmed := strings.TrimSpace(stderr) + if trimmed == "" { + return sdkerrors.InternalError(fmt.Sprintf("op read exited %d", exitCode)) + } + lower := strings.ToLower(trimmed) + + type rule struct { + needles []string + wrap func(string) error + hint string // user-facing hint, prepended to stderr + } + rules := []rule{ + { + needles: []string{ + "not currently signed in", + "session expired", + "no active session", + "you are not currently signed in", + }, + wrap: sdkerrors.UnauthenticatedError, + hint: "sign in via 'op signin' or unlock the 1Password desktop app", + }, + { + needles: []string{"isn't an item", "no item matched", "item not found"}, + wrap: sdkerrors.NotFoundError, + hint: "item not found", + }, + { + needles: []string{"isn't a vault", "no vault matched", "vault not found"}, + wrap: sdkerrors.NotFoundError, + hint: "vault not found", + }, + { + needles: []string{"doesn't have a field", "no field matched", "isn't a field"}, + wrap: sdkerrors.NotFoundError, + hint: "field not found", + }, + { + needles: []string{"you don't have permission", "access denied", "not authorized"}, + wrap: sdkerrors.PermissionDeniedError, + hint: "1password account has no access", + }, + { + needles: []string{"invalid secret reference", "invalid op:// reference"}, + wrap: sdkerrors.InvalidArgumentError, + hint: "malformed op:// reference", + }, + } + + for _, r := range rules { + for _, n := range r.needles { + if strings.Contains(lower, n) { + return r.wrap(r.hint + ": " + trimmed) + } + } + } + return sdkerrors.InternalError("op read failed: " + trimmed) +} + +// OnePasswordProvider retrieves secrets from 1Password by shelling out to `op`. +// lookPath, runCmd, and cmdFactory are injectable for testing; zero values use +// the real exec functions. +type OnePasswordProvider struct { + lookPath func(string) (string, error) + runCmd func(name string, args ...string) error + cmdFactory func(ctx context.Context, name string, args ...string) *exec.Cmd +} + +func (p *OnePasswordProvider) resolveCmdFactory() func(context.Context, string, ...string) *exec.Cmd { + if p.cmdFactory != nil { + return p.cmdFactory + } + return func(ctx context.Context, name string, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, name, args...) + } +} + +func (p *OnePasswordProvider) resolveLookPath() func(string) (string, error) { + if p.lookPath != nil { + return p.lookPath + } + return exec.LookPath +} + +func (p *OnePasswordProvider) resolveRunCmd() func(string, ...string) error { + if p.runCmd != nil { + return p.runCmd + } + return func(name string, args ...string) error { + cmd := exec.Command(name, args...) + cmd.Env = buildOpEnv() + return cmd.Run() + } +} + +// GetSecret fetches a secret from 1Password by shelling out to `op read`. +// Authorization is delegated to the 1Password desktop app or to an active +// `op signin` session. The optional vault-level "vault" opt selects the +// default 1Password vault for non-qualified paths. +func (p *OnePasswordProvider) GetSecret( + ctx context.Context, + req *vaultv1.GetSecretRequest, +) (*vaultv1.GetSecretResponse, error) { + ref, err := parsePath(req.Path, req.Opts["vault"]) + if err != nil { + return nil, err + } + + factory := p.resolveCmdFactory() + var stdout, stderr bytes.Buffer + cmd := factory(ctx, "op", "read", "--no-newline", ref) + cmd.Env = buildOpEnv() + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if runErr := cmd.Run(); runErr != nil { + exitCode := -1 + var ee *exec.ExitError + if errorsstd.As(runErr, &ee) { + exitCode = ee.ExitCode() + } + return nil, classifyOpError(stderr.String(), exitCode) + } + return &vaultv1.GetSecretResponse{ + Secret: stdout.Bytes(), + ContentType: "text/plain", + }, nil +} + +// HealthCheck reports whether the `op` CLI is installed and has an active +// session. Two checks: `op --version` and `op vault list --format=json`. The +// latter requires an unlocked desktop app or a fresh `op signin`. +func (p *OnePasswordProvider) HealthCheck( + _ context.Context, + _ *vaultv1.HealthCheckRequest, +) (*vaultv1.HealthCheckResponse, error) { + lookPath := p.resolveLookPath() + runCmd := p.resolveRunCmd() + + path, err := lookPath("op") + if err != nil { + return &vaultv1.HealthCheckResponse{ + Available: false, + Message: "op not found in PATH - install the 1Password CLI", + }, nil + } + if err := runCmd("op", "--version"); err != nil { + return &vaultv1.HealthCheckResponse{ + Available: false, + Message: fmt.Sprintf("op at %s failed to run: %v", path, err), + }, nil + } + if err := runCmd("op", "vault", "list", "--format=json"); err != nil { + return &vaultv1.HealthCheckResponse{ + Available: false, + Message: fmt.Sprintf( + "op found at %s but no active session - run 'op signin' or unlock the 1Password desktop app", + path, + ), + }, nil + } + return &vaultv1.HealthCheckResponse{ + Available: true, + Message: fmt.Sprintf("op available at %s", path), + }, nil +} + +func (p *OnePasswordProvider) Info(_ context.Context, _ *vaultv1.InfoRequest) (*vaultv1.InfoResponse, error) { + return &vaultv1.InfoResponse{ + Name: "1password", + Version: "0.1.0", + Platforms: []string{platform.Darwin, platform.Linux}, + MinLocksmithVersion: "0.1.0", + MaxLocksmithVersion: sdkversion.Current, + }, nil +} diff --git a/plugins/onepassword/provider_test.go b/plugins/onepassword/provider_test.go new file mode 100644 index 0000000..db08e68 --- /dev/null +++ b/plugins/onepassword/provider_test.go @@ -0,0 +1,531 @@ +package main + +import ( + "context" + errorsstd "errors" + "os/exec" + "strings" + "testing" + + vaultv1 "github.com/lorem-dev/locksmith/gen/proto/vault/v1" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestParsePath(t *testing.T) { + tests := []struct { + name string + path string + vaultLevel string + wantRef string + wantCode codes.Code // codes.OK on success + }{ + { + name: "full reference, vault-level ignored", + path: "op://Personal/Notion/credential", + vaultLevel: "Work", + wantRef: "op://Personal/Notion/credential", + }, + { + name: "full reference with section", + path: "op://Personal/Item/Section/field", + vaultLevel: "", + wantRef: "op://Personal/Item/Section/field", + }, + { + name: "item plus field with vault-level", + path: "Notion/credential", + vaultLevel: "Personal", + wantRef: "op://Personal/Notion/credential", + }, + { + name: "item only with vault-level defaults field to password", + path: "Notion", + vaultLevel: "Personal", + wantRef: "op://Personal/Notion/password", + }, + { + name: "item plus field without vault-level fails", + path: "Notion/credential", + vaultLevel: "", + wantCode: codes.InvalidArgument, + }, + { + name: "item only without vault-level fails", + path: "Notion", + vaultLevel: "", + wantCode: codes.InvalidArgument, + }, + { + name: "empty path fails", + path: "", + vaultLevel: "Personal", + wantCode: codes.InvalidArgument, + }, + { + name: "op:// with two segments fails", + path: "op://Personal/Item", + vaultLevel: "", + wantCode: codes.InvalidArgument, + }, + { + name: "op:// with five segments fails", + path: "op://Personal/Item/Section/Field/extra", + vaultLevel: "", + wantCode: codes.InvalidArgument, + }, + { + name: "non-op scheme treated as relative path", + path: "weird://thing", + vaultLevel: "Personal", + wantRef: "op://Personal/weird://thing/password", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := parsePath(tc.path, tc.vaultLevel) + if tc.wantCode != codes.OK { + if err == nil { + t.Fatalf("parsePath() error = nil, want code %v", tc.wantCode) + } + s, ok := status.FromError(err) + if !ok { + t.Fatalf("error is not gRPC status: %T %v", err, err) + } + if s.Code() != tc.wantCode { + t.Errorf("Code() = %v, want %v", s.Code(), tc.wantCode) + } + return + } + if err != nil { + t.Fatalf("parsePath() unexpected error: %v", err) + } + if got != tc.wantRef { + t.Errorf("ref = %q, want %q", got, tc.wantRef) + } + }) + } +} + +func TestParsePath_ErrorMessages(t *testing.T) { + // Non-qualified path without vault-level must hint at the fix. + _, err := parsePath("Notion/credential", "") + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "vault:") { + t.Errorf("error %q must mention the 'vault:' option as the fix", err.Error()) + } +} + +func TestBuildOpEnv(t *testing.T) { + t.Setenv("HOME", "/home/tester") + t.Setenv("PATH", "/usr/bin:/bin") + t.Setenv("XDG_CONFIG_HOME", "/home/tester/.config") + t.Setenv("OP_DEVICE", "abcdef") + t.Setenv("OP_ACCOUNT", "") // empty - omit + t.Setenv("OP_BIOMETRIC_UNLOCK_ENABLED", "true") + t.Setenv("DISPLAY", ":0") + t.Setenv("WAYLAND_DISPLAY", "") // empty - omit + t.Setenv("OP_SERVICE_ACCOUNT_TOKEN", "ops_secret_token_should_not_leak") + + env := buildOpEnv() + has := func(key string) bool { + prefix := key + "=" + for _, e := range env { + if strings.HasPrefix(e, prefix) { + return true + } + } + return false + } + + for _, want := range []string{ + "HOME", "PATH", "XDG_CONFIG_HOME", "OP_DEVICE", + "OP_BIOMETRIC_UNLOCK_ENABLED", "DISPLAY", + } { + if !has(want) { + t.Errorf("buildOpEnv() missing %s", want) + } + } + for _, banned := range []string{"OP_ACCOUNT", "WAYLAND_DISPLAY"} { + if has(banned) { + t.Errorf("buildOpEnv() should omit empty %s", banned) + } + } + if has("OP_SERVICE_ACCOUNT_TOKEN") { + t.Error("buildOpEnv() must not forward OP_SERVICE_ACCOUNT_TOKEN") + } +} + +func TestOnePasswordProvider_GetSecret_Success(t *testing.T) { + p := &OnePasswordProvider{ + cmdFactory: func(_ context.Context, name string, args ...string) *exec.Cmd { + if name != "op" { + t.Errorf("name = %q, want op", name) + } + if got, want := args, []string{"read", "--no-newline", "op://Personal/Notion/password"}; !equalStrings(got, want) { + t.Errorf("args = %v, want %v", got, want) + } + return exec.Command("sh", "-c", "printf mysecret") + }, + } + resp, err := p.GetSecret(context.Background(), &vaultv1.GetSecretRequest{ + Path: "Notion", + Opts: map[string]string{"vault": "Personal"}, + }) + if err != nil { + t.Fatalf("GetSecret() error: %v", err) + } + if string(resp.Secret) != "mysecret" { + t.Errorf("Secret = %q, want %q", resp.Secret, "mysecret") + } + if resp.ContentType != "text/plain" { + t.Errorf("ContentType = %q, want text/plain", resp.ContentType) + } +} + +func TestOnePasswordProvider_GetSecret_ParseError(t *testing.T) { + p := &OnePasswordProvider{} + _, err := p.GetSecret(context.Background(), &vaultv1.GetSecretRequest{ + Path: "Notion/credential", + }) + if err == nil { + t.Fatal("expected InvalidArgument error") + } + if s, _ := status.FromError(err); s.Code() != codes.InvalidArgument { + t.Errorf("Code() = %v, want InvalidArgument", s.Code()) + } +} + +func TestOnePasswordProvider_GetSecret_FullReference(t *testing.T) { + p := &OnePasswordProvider{ + cmdFactory: func(_ context.Context, name string, args ...string) *exec.Cmd { + if got := args[len(args)-1]; got != "op://Work/API/token" { + t.Errorf("ref = %q, want op://Work/API/token", got) + } + return exec.Command("sh", "-c", "printf token123") + }, + } + resp, err := p.GetSecret(context.Background(), &vaultv1.GetSecretRequest{ + Path: "op://Work/API/token", + Opts: map[string]string{"vault": "Personal"}, + }) + if err != nil { + t.Fatalf("GetSecret() error: %v", err) + } + if string(resp.Secret) != "token123" { + t.Errorf("Secret = %q, want token123", resp.Secret) + } +} + +func TestOnePasswordProvider_GetSecret_NotSignedIn(t *testing.T) { + p := &OnePasswordProvider{ + cmdFactory: func(_ context.Context, _ string, _ ...string) *exec.Cmd { + return exec.Command( + "sh", "-c", + `echo "you are not currently signed in to any accounts" >&2; exit 1`, + ) + }, + } + _, err := p.GetSecret(context.Background(), &vaultv1.GetSecretRequest{ + Path: "Notion", + Opts: map[string]string{"vault": "Personal"}, + }) + if err == nil { + t.Fatal("expected error") + } + if s, _ := status.FromError(err); s.Code() != codes.Unauthenticated { + t.Errorf("Code() = %v, want Unauthenticated", s.Code()) + } +} + +func TestOnePasswordProvider_GetSecret_ItemNotFound(t *testing.T) { + p := &OnePasswordProvider{ + cmdFactory: func(_ context.Context, _ string, _ ...string) *exec.Cmd { + return exec.Command( + "sh", "-c", + `echo "\"Notion\" isn't an item in any vault" >&2; exit 1`, + ) + }, + } + _, err := p.GetSecret(context.Background(), &vaultv1.GetSecretRequest{ + Path: "Notion", + Opts: map[string]string{"vault": "Personal"}, + }) + if err == nil { + t.Fatal("expected error") + } + if s, _ := status.FromError(err); s.Code() != codes.NotFound { + t.Errorf("Code() = %v, want NotFound", s.Code()) + } +} + +func TestOnePasswordProvider_GetSecret_GenericError(t *testing.T) { + p := &OnePasswordProvider{ + cmdFactory: func(_ context.Context, _ string, _ ...string) *exec.Cmd { + return exec.Command("sh", "-c", `echo "some unexpected failure" >&2; exit 1`) + }, + } + _, err := p.GetSecret(context.Background(), &vaultv1.GetSecretRequest{ + Path: "Notion", + Opts: map[string]string{"vault": "Personal"}, + }) + if err == nil { + t.Fatal("expected error") + } + if s, _ := status.FromError(err); s.Code() != codes.Internal { + t.Errorf("Code() = %v, want Internal", s.Code()) + } +} + +func TestOnePasswordProvider_GetSecret_EmptySecretIsValid(t *testing.T) { + p := &OnePasswordProvider{ + cmdFactory: func(_ context.Context, _ string, _ ...string) *exec.Cmd { + return exec.Command("true") + }, + } + resp, err := p.GetSecret(context.Background(), &vaultv1.GetSecretRequest{ + Path: "op://Personal/Item/empty", + }) + if err != nil { + t.Fatalf("GetSecret() error: %v", err) + } + if len(resp.Secret) != 0 { + t.Errorf("Secret = %q, want empty", resp.Secret) + } +} + +func TestOnePasswordProvider_Info(t *testing.T) { + p := &OnePasswordProvider{} + info, err := p.Info(context.Background(), &vaultv1.InfoRequest{}) + if err != nil { + t.Fatalf("Info() error: %v", err) + } + if info.Name != "1password" { + t.Errorf("Name = %q, want 1password", info.Name) + } + if len(info.Platforms) == 0 { + t.Error("Platforms is empty") + } + // Match the pattern in TestInfoCompatibility for gopass. + if info.MinLocksmithVersion == "" { + t.Error("MinLocksmithVersion must not be empty") + } +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func TestOnePasswordProvider_HealthCheck_NoBinary(t *testing.T) { + p := &OnePasswordProvider{ + lookPath: func(string) (string, error) { return "", errorsstd.New("not found") }, + } + resp, err := p.HealthCheck(context.Background(), &vaultv1.HealthCheckRequest{}) + if err != nil { + t.Fatalf("HealthCheck() error: %v", err) + } + if resp.Available { + t.Error("Available must be false when op is missing") + } + if !strings.Contains(resp.Message, "not found in PATH") { + t.Errorf("Message = %q, want substring 'not found in PATH'", resp.Message) + } +} + +func TestOnePasswordProvider_HealthCheck_VersionFails(t *testing.T) { + p := &OnePasswordProvider{ + lookPath: func(string) (string, error) { return "/usr/local/bin/op", nil }, + runCmd: func(_ string, args ...string) error { + if args[0] == "--version" { + return errorsstd.New("crashy") + } + return nil + }, + } + resp, err := p.HealthCheck(context.Background(), &vaultv1.HealthCheckRequest{}) + if err != nil { + t.Fatalf("HealthCheck() error: %v", err) + } + if resp.Available { + t.Error("Available must be false when op --version fails") + } + if !strings.Contains(resp.Message, "/usr/local/bin/op") { + t.Errorf("Message must reference the op binary path, got %q", resp.Message) + } +} + +func TestOnePasswordProvider_HealthCheck_NoSession(t *testing.T) { + p := &OnePasswordProvider{ + lookPath: func(string) (string, error) { return "/usr/local/bin/op", nil }, + runCmd: func(_ string, args ...string) error { + if args[0] == "vault" { + return errorsstd.New("not signed in") + } + return nil + }, + } + resp, err := p.HealthCheck(context.Background(), &vaultv1.HealthCheckRequest{}) + if err != nil { + t.Fatalf("HealthCheck() error: %v", err) + } + if resp.Available { + t.Error("Available must be false when vault list fails") + } + if !strings.Contains(resp.Message, "no active session") { + t.Errorf("Message = %q, want substring 'no active session'", resp.Message) + } +} + +func TestOnePasswordProvider_HealthCheck_Available(t *testing.T) { + p := &OnePasswordProvider{ + lookPath: func(string) (string, error) { return "/usr/local/bin/op", nil }, + runCmd: func(string, ...string) error { return nil }, + } + resp, err := p.HealthCheck(context.Background(), &vaultv1.HealthCheckRequest{}) + if err != nil { + t.Fatalf("HealthCheck() error: %v", err) + } + if !resp.Available { + t.Error("Available must be true when op is installed and signed in") + } + if !strings.Contains(resp.Message, "/usr/local/bin/op") { + t.Errorf("Message must reference op path, got %q", resp.Message) + } +} + +// TestOnePasswordProvider_DefaultResolvers exercises the default branches of +// resolveLookPath/resolveRunCmd/resolveCmdFactory using a non-existent binary +// name. This proves the defaults are wired correctly without ever shelling +// out to the real `op` CLI. +func TestOnePasswordProvider_DefaultResolvers(t *testing.T) { + const missingBin = "locksmith-no-such-bin-xyz" + p := &OnePasswordProvider{} + + if _, err := p.resolveLookPath()(missingBin); err == nil { + t.Error("default lookPath should fail for non-existent binary") + } + if err := p.resolveRunCmd()(missingBin); err == nil { + t.Error("default runCmd should fail for non-existent binary") + } + cmd := p.resolveCmdFactory()(context.Background(), missingBin) + if cmd == nil { + t.Fatal("default cmdFactory must return a non-nil *exec.Cmd") + } + if err := cmd.Run(); err == nil { + t.Error("running a non-existent binary should fail") + } +} + +func TestClassifyOpError(t *testing.T) { + tests := []struct { + name string + stderr string + exitCode int + wantCode codes.Code + wantMsg string // substring (case-insensitive) to look for in error + }{ + { + name: "not signed in", + stderr: "[ERROR] 2026/05/20 you are not currently signed in to any accounts", + exitCode: 1, + wantCode: codes.Unauthenticated, + wantMsg: "op signin", + }, + { + name: "session expired", + stderr: "session expired, please sign in again", + exitCode: 1, + wantCode: codes.Unauthenticated, + wantMsg: "op signin", + }, + { + name: "no active session", + stderr: "no active session for account", + exitCode: 1, + wantCode: codes.Unauthenticated, + }, + { + name: "item not found", + stderr: `"Foo" isn't an item in any vault`, + exitCode: 1, + wantCode: codes.NotFound, + wantMsg: "item not found", + }, + { + name: "vault not found", + stderr: `"Bogus" isn't a vault in this account`, + exitCode: 1, + wantCode: codes.NotFound, + wantMsg: "vault not found", + }, + { + name: "field not found", + stderr: `item "Notion" doesn't have a field named "missing"`, + exitCode: 1, + wantCode: codes.NotFound, + wantMsg: "field not found", + }, + { + name: "permission denied", + stderr: "you don't have permission to access this item", + exitCode: 1, + wantCode: codes.PermissionDenied, + }, + { + name: "access denied alt phrasing", + stderr: "access denied", + exitCode: 1, + wantCode: codes.PermissionDenied, + }, + { + name: "invalid secret reference", + stderr: "invalid secret reference: op://bad", + exitCode: 1, + wantCode: codes.InvalidArgument, + }, + { + name: "empty stderr non-zero exit", + stderr: "", + exitCode: 7, + wantCode: codes.Internal, + wantMsg: "exited 7", + }, + { + name: "fallback internal", + stderr: "weird new error mode", + exitCode: 1, + wantCode: codes.Internal, + wantMsg: "weird new error mode", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := classifyOpError(tc.stderr, tc.exitCode) + if err == nil { + t.Fatal("classifyOpError() = nil, want error") + } + s, ok := status.FromError(err) + if !ok { + t.Fatalf("error is not gRPC status: %T %v", err, err) + } + if s.Code() != tc.wantCode { + t.Errorf("Code() = %v, want %v (msg=%q)", s.Code(), tc.wantCode, err.Error()) + } + if tc.wantMsg != "" && !strings.Contains(strings.ToLower(err.Error()), strings.ToLower(tc.wantMsg)) { + t.Errorf("error %q must contain %q", err.Error(), tc.wantMsg) + } + }) + } +} From eecda3618f854c47eb1bf7f1d6ad082f6f624211 Mon Sep 17 00:00:00 2001 From: Lorem Dev Date: Sat, 23 May 2026 00:08:43 +0200 Subject: [PATCH 14/25] feat(init): wrap agent templates with markers and pre-allow Bash(locksmith:*) Two coupled changes to make locksmith init re-runnable and friendly to Claude Code's Bash-permission UX: - Every agent template (Claude/Codex/Gemini/OpenCode and the generic agent file) is wrapped in / markers. The init wizard upserts the locksmith-managed block in place on every run, preserving any user content outside the markers. - locksmith init now adds Bash(locksmith:*) to permissions.allow in ~/.claude/settings.json so the agent can call locksmith directly without per-call approval. Idempotent: re-running does not duplicate the rule. The two were originally split across two commits (template-marker support + permission wiring); they ship together so an upgrade gets both behaviours in one go. --- CHANGES.md | 15 ++ README.md | 4 +- docs/agent-integration.md | 29 ++- internal/initflow/agents.go | 63 ++--- internal/initflow/agents_test.go | 47 ++++ internal/initflow/flow.go | 9 +- internal/initflow/flow_test.go | 7 +- internal/initflow/hooks.go | 147 +++++++++--- internal/initflow/hooks_test.go | 216 +++++++++++++++++- internal/initflow/sandbox.go | 31 ++- .../templates/agent_instructions.md.tmpl | 2 + internal/initflow/templates/agents_md.md.tmpl | 2 + internal/initflow/templates/claude_md.md.tmpl | 2 + .../initflow/templates/claude_skill.md.tmpl | 2 + .../initflow/templates/codex_agents.md.tmpl | 2 + internal/initflow/templates/gemini_md.md.tmpl | 2 + plugins/onepassword/README.md | 2 +- plugins/onepassword/provider.go | 7 + plugins/onepassword/provider_test.go | 6 + 19 files changed, 529 insertions(+), 66 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 8a7d003..06e7622 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -9,6 +9,21 @@ per session; if the retry also fails, the error is forwarded to the AI client unchanged. Detection looks only at structural fields - no keyword matching against `result.content[].text`. +- `locksmith init` now also adds two entries to + `permissions.allow` in `~/.claude/settings.json`: `Bash(locksmith:*)` + for the agent's direct `locksmith` calls and an explicit allow rule + for the absolute path to the UserPromptSubmit hook script. Both are + added idempotently; existing installs are topped up on the next + `init`. +- The Claude Code UserPromptSubmit hook script + (`~/.config/locksmith/agent-hook.sh`) is now re-rendered from the + embedded template on every `locksmith init` run, including when the + settings.json entries are already present. This keeps the script in + sync with the latest locksmith release. +- Agent instruction templates (Claude/Codex/Gemini/OpenCode and the + generic agent file) are now wrapped with `` + / `` markers so the locksmith-managed block + can be located and updated reliably. - Add `vaults..vault` config field, delivered to plugins as `opts["vault"]`; used by the 1Password plugin to qualify non-`op://` paths. - Add 1Password vault plugin backed by the op CLI, auto-detected when `op` is installed. - docs(cli): drop Touch ID wording from CLI help, README, configuration diff --git a/README.md b/README.md index a5f864d..5186f99 100644 --- a/README.md +++ b/README.md @@ -215,7 +215,9 @@ Codex). For Claude Code, run `locksmith init` - the `UserPromptSubmit` hook is installed automatically and injects `LOCKSMITH_SESSION` before each prompt. -Restart Claude Code after running `init`. +`init` also adds `Bash(locksmith:*)` to `permissions.allow` so the hook and +`locksmith get` calls run without an extra approval dialog. Restart Claude +Code after running `init`. **Quick start:** diff --git a/docs/agent-integration.md b/docs/agent-integration.md index bd57d59..fe9c8a7 100644 --- a/docs/agent-integration.md +++ b/docs/agent-integration.md @@ -71,8 +71,23 @@ each sub-agent must obtain its own session independently. ## Claude Code Hook Setup Run `locksmith init` - the hook is installed automatically when Claude Code is -detected. It registers a `UserPromptSubmit` hook in `~/.claude/settings.json` -that injects `LOCKSMITH_SESSION` before each prompt. +detected. `init` performs three actions: + +1. Writes the hook script to `~/.config/locksmith/agent-hook.sh`. The script + is **always** regenerated from the embedded template on every `init`, so + it stays in sync after a locksmith upgrade even when the settings entries + are already in place. +2. Registers a `UserPromptSubmit` hook in `~/.claude/settings.json` that + injects `LOCKSMITH_SESSION` before each prompt. +3. Adds two entries to `permissions.allow` so the hook and any + `locksmith` invocations run without an extra approval dialog: + - `Bash(locksmith:*)` - matches every `locksmith` subcommand the agent + runs directly via the Bash tool. + - `Bash()` - explicitly allows the hook + script itself. + +All edits are idempotent: re-running `init` will not duplicate entries. If +any piece is missing on an existing install, `init` tops it up. After installation, restart Claude Code for the hook to take effect. @@ -100,12 +115,20 @@ If you prefer to install the hook without using `init`: ] } ] + }, + "permissions": { + "allow": [ + "Bash(locksmith:*)", + "Bash(/Users//.config/locksmith/agent-hook.sh)" + ] } } ``` The hook exits silently if the Locksmith daemon is not running, so it never -blocks agent work. +blocks agent work. The two `Bash(...)` permissions pre-approve every +`locksmith` invocation and the hook script itself; remove them to require +explicit confirmation for each secret retrieval. ## Locksmith Config Reference diff --git a/internal/initflow/agents.go b/internal/initflow/agents.go index 0d22863..9fc78ed 100644 --- a/internal/initflow/agents.go +++ b/internal/initflow/agents.go @@ -57,9 +57,7 @@ func (w *AgentWriter) installClaudeCode(agent DetectedAgent) error { return fmt.Errorf("writing skill: %w", err) } mdContent := mustReadTemplate("templates/claude_md.md.tmpl") - return appendIfAbsent( - filepath.Join(agent.ConfigDir, "CLAUDE.md"), string(mdContent), "## Locksmith Integration", - ) + return upsertMarkedBlock(filepath.Join(agent.ConfigDir, "CLAUDE.md"), string(mdContent)) } func (w *AgentWriter) installCodex(agent DetectedAgent) error { @@ -70,9 +68,7 @@ func (w *AgentWriter) installCodex(agent DetectedAgent) error { return fmt.Errorf("creating config dir: %w", err) } content := mustReadTemplate("templates/codex_agents.md.tmpl") - return appendIfAbsent( - filepath.Join(agent.ConfigDir, "AGENTS.md"), string(content), "## Locksmith Integration", - ) + return upsertMarkedBlock(filepath.Join(agent.ConfigDir, "AGENTS.md"), string(content)) } func (w *AgentWriter) installOpenCode(agent DetectedAgent) error { @@ -83,9 +79,7 @@ func (w *AgentWriter) installOpenCode(agent DetectedAgent) error { return fmt.Errorf("creating config dir: %w", err) } content := mustReadTemplate("templates/agent_instructions.md.tmpl") - return appendIfAbsent( - filepath.Join(agent.ConfigDir, "instructions.md"), string(content), "# Locksmith", - ) + return upsertMarkedBlock(filepath.Join(agent.ConfigDir, "instructions.md"), string(content)) } func (w *AgentWriter) installGeneric() error { @@ -102,30 +96,43 @@ func (w *AgentWriter) installGeneric() error { return nil } -// appendIfAbsent appends content to filePath only if marker is not already present. -// This makes the operation idempotent. -func appendIfAbsent(filePath, content, marker string) (retErr error) { +const ( + locksmithBlockStart = "" + locksmithBlockEnd = "" +) + +// upsertMarkedBlock writes content to filePath. If the file already contains +// a locksmith-managed block delimited by LOCKSMITH_START/END markers, the +// block is replaced in place. Otherwise content is appended (preserving any +// pre-existing user content). The file is created if absent. content must +// begin with LOCKSMITH_START and end with LOCKSMITH_END (the embedded +// templates already do). +func upsertMarkedBlock(filePath, content string) (retErr error) { existing, err := os.ReadFile(filePath) //nolint:gosec // G304: filePath is derived from agent config dir if err != nil && !os.IsNotExist(err) { return fmt.Errorf("reading %s: %w", filePath, err) } - if strings.Contains(string(existing), marker) { - return nil - } - prefix := "" - if len(existing) > 0 { - prefix = "\n\n" - } - f, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) //nolint:gosec // G302/G304 - if err != nil { - return fmt.Errorf("opening %s: %w", filePath, err) - } - defer func() { - if cerr := f.Close(); cerr != nil && retErr == nil { - retErr = fmt.Errorf("closing %s: %w", filePath, cerr) + text := string(existing) + startIdx := strings.Index(text, locksmithBlockStart) + endIdx := strings.Index(text, locksmithBlockEnd) + var out string + switch { + case startIdx >= 0 && endIdx > startIdx: + // Replace block in place (include the end marker length). + out = text[:startIdx] + strings.TrimRight(content, "\n") + + text[endIdx+len(locksmithBlockEnd):] + case len(existing) == 0: + out = content + default: + // Append. + sep := "\n\n" + if strings.HasSuffix(text, "\n") { + sep = "\n" } - }() - if _, err = f.WriteString(prefix + content); err != nil { + out = text + sep + content + } + //nolint:gosec // G306: documentation, user-readable by design + if err := os.WriteFile(filePath, []byte(out), 0o644); err != nil { return fmt.Errorf("writing %s: %w", filePath, err) } return nil diff --git a/internal/initflow/agents_test.go b/internal/initflow/agents_test.go index 5902f9a..56c507e 100644 --- a/internal/initflow/agents_test.go +++ b/internal/initflow/agents_test.go @@ -268,6 +268,53 @@ func TestInstallSandboxPermissions_UnknownAgent_NoOp(t *testing.T) { } } +func TestAgentWriter_UpsertMarkedBlock_ReplacesExistingBlock(t *testing.T) { + home := t.TempDir() + claudeDir := filepath.Join(home, ".claude") + if err := os.MkdirAll(claudeDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + mdPath := filepath.Join(claudeDir, "CLAUDE.md") + + // Pre-seed CLAUDE.md with user content + an older locksmith block. + seed := `# Project notes + +User content above. + + +## Old Locksmith section +stale content + + +User content below. +` + if err := os.WriteFile(mdPath, []byte(seed), 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + + w := initflow.NewAgentWriter(home) + if err := w.Install(initflow.DetectedAgent{ + Name: "Claude Code", ConfigDir: claudeDir, Detected: true, + }); err != nil { + t.Fatalf("Install: %v", err) + } + + got, _ := os.ReadFile(mdPath) + if !strings.Contains(string(got), "User content above.") || + !strings.Contains(string(got), "User content below.") { + t.Error("user content was lost across re-install") + } + if strings.Contains(string(got), "Old Locksmith section") { + t.Error("stale block survived re-install") + } + if !strings.Contains(string(got), "Locksmith Integration") { + t.Error("fresh template content missing") + } + if c := strings.Count(string(got), ""); c != 1 { + t.Errorf("LOCKSMITH_START appears %d times, want 1", c) + } +} + // --- Template content tests --- func TestInstall_ClaudeCode_SkillContainsBothGetSyntaxes(t *testing.T) { diff --git a/internal/initflow/flow.go b/internal/initflow/flow.go index fb8eba6..7ab4712 100644 --- a/internal/initflow/flow.go +++ b/internal/initflow/flow.go @@ -497,7 +497,14 @@ func applyClaudeHook(result *InitResult, homeDir string) error { ) switch { case result.ClaudeHookAlreadyPresent: - fmt.Printf(" Claude Code: hook already present in %s\n", fmtPaths.Sprint("~/.claude/settings.json")) + // Settings.json entries are already in place, but the hook script + // itself must be regenerated so it stays in sync with the embedded + // template after a locksmith upgrade. + if err := installer.RefreshScript(); err != nil { + return fmt.Errorf("refreshing Claude Code hook script: %w", err) + } + fmt.Printf(" Claude Code: hook already present in %s (script refreshed)\n", + fmtPaths.Sprint("~/.claude/settings.json")) case result.ClaudeHookConfirmed: if err := installer.Install(); err != nil { return fmt.Errorf("installing Claude Code hook: %w", err) diff --git a/internal/initflow/flow_test.go b/internal/initflow/flow_test.go index b7f3ca6..a26731f 100644 --- a/internal/initflow/flow_test.go +++ b/internal/initflow/flow_test.go @@ -304,6 +304,12 @@ func TestRunInit_HookAlreadyPresent(t *testing.T) { }, }, }, + "permissions": map[string]any{ + "allow": []any{ + "Bash(locksmith:*)", + "Bash(" + hookCmd + ")", + }, + }, } data, _ := json.Marshal(settings) os.WriteFile(filepath.Join(claudeDir, "settings.json"), data, 0o644) @@ -800,7 +806,6 @@ func TestRunInit_ShellHook_UnknownShell(t *testing.T) { t.Setenv("HOME", home) stubGopassOnly(t) t.Setenv("SHELL", "/bin/sh") // unknown (not bash/zsh/ash/fish) - t.Setenv("0", "") dir := t.TempDir() result, err := initflow.RunInit(initflow.InitOptions{ diff --git a/internal/initflow/hooks.go b/internal/initflow/hooks.go index 9eeba49..0f3160c 100644 --- a/internal/initflow/hooks.go +++ b/internal/initflow/hooks.go @@ -7,6 +7,11 @@ import ( "path/filepath" ) +// locksmithPermissionRule is the Claude Code permissions entry that +// pre-approves every `locksmith` Bash invocation, so the UserPromptSubmit +// hook and `locksmith get` calls do not trigger an extra approval dialog. +const locksmithPermissionRule = "Bash(locksmith:*)" + // ClaudeHookInstaller installs the Locksmith UserPromptSubmit hook into // the global Claude Code settings file (~/.claude/settings.json). type ClaudeHookInstaller struct { @@ -30,8 +35,11 @@ func (h *ClaudeHookInstaller) settingsPath() string { return filepath.Join(h.claudeConfigDir, "settings.json") } -// IsInstalled reports whether the hook command is already registered in -// ~/.claude/settings.json. +// IsInstalled reports whether the hook command and every required +// permission rule are already present in ~/.claude/settings.json. All pieces +// must be present for the integration to work without an extra approval +// dialog; if any is missing, Install will top up the missing piece +// idempotently. func (h *ClaudeHookInstaller) IsInstalled() bool { data, err := os.ReadFile(h.settingsPath()) if err != nil { @@ -41,17 +49,32 @@ func (h *ClaudeHookInstaller) IsInstalled() bool { if err := json.Unmarshal(data, &settings); err != nil { return false } - return h.findHookCmd(settings, h.hookCmd()) + if !h.findHookCmd(settings, h.hookCmd()) { + return false + } + return len(h.missingPermissionRules(settings, h.requiredPermissionRules())) == 0 } // Install writes the hook script to ~/.config/locksmith/agent-hook.sh and -// merges the UserPromptSubmit entry into ~/.claude/settings.json. -// It is idempotent: calling Install twice produces the same result as calling it once. +// merges the UserPromptSubmit entry plus the locksmith permission rules into +// ~/.claude/settings.json. It is idempotent: calling Install twice produces +// the same result as calling it once. The hook script is always overwritten +// so it stays in sync with the latest embedded template. func (h *ClaudeHookInstaller) Install() error { + if err := h.RefreshScript(); err != nil { + return err + } + return h.mergeSettings() +} + +// RefreshScript rewrites the hook script unconditionally. Call this on every +// `locksmith init` so the script is regenerated from the embedded template +// even when the settings.json entries are already in place. +func (h *ClaudeHookInstaller) RefreshScript() error { if err := h.writeScript(); err != nil { return fmt.Errorf("writing hook script: %w", err) } - return h.mergeSettings() + return nil } func (h *ClaudeHookInstaller) writeScript() error { @@ -82,38 +105,66 @@ func (h *ClaudeHookInstaller) mergeSettings() error { } var settings map[string]any - if data, err := os.ReadFile(h.settingsPath()); err == nil { + data, readErr := os.ReadFile(h.settingsPath()) + switch { + case readErr == nil: if jsonErr := json.Unmarshal(data, &settings); jsonErr != nil { - settings = nil // treat malformed JSON as absent + return fmt.Errorf( + "settings.json at %s is not valid JSON; refusing to overwrite: %w", + h.settingsPath(), jsonErr, + ) } + case !os.IsNotExist(readErr): + return fmt.Errorf("reading %s: %w", h.settingsPath(), readErr) } if settings == nil { settings = make(map[string]any) } - if h.findHookCmd(settings, h.hookCmd()) { - return nil // already present - idempotent + hookAlreadyInstalled := h.findHookCmd(settings, h.hookCmd()) + requiredRules := h.requiredPermissionRules() + missingRules := h.missingPermissionRules(settings, requiredRules) + if hookAlreadyInstalled && len(missingRules) == 0 { + return nil // hook + every permission rule already present - idempotent } - hooks, ok := settings["hooks"].(map[string]any) - if !ok { - hooks = make(map[string]any) + if !hookAlreadyInstalled { + hooks, ok := settings["hooks"].(map[string]any) + if !ok { + hooks = make(map[string]any) + } + var ups []any + if raw, ok := hooks["UserPromptSubmit"].([]any); ok { + ups = raw + } + ups = append(ups, map[string]any{ + "matcher": "", + "hooks": []any{ + map[string]any{ + "type": "command", + "command": h.hookCmd(), + }, + }, + }) + hooks["UserPromptSubmit"] = ups + settings["hooks"] = hooks } - var ups []any - if raw, ok := hooks["UserPromptSubmit"].([]any); ok { - ups = raw + + if len(missingRules) > 0 { + perms, ok := settings["permissions"].(map[string]any) + if !ok { + perms = make(map[string]any) + } + var allow []any + if raw, ok := perms["allow"].([]any); ok { + allow = raw + } + for _, rule := range missingRules { + allow = append(allow, rule) + } + perms["allow"] = allow + settings["permissions"] = perms } - ups = append(ups, map[string]any{ - "matcher": "", - "hooks": []any{ - map[string]any{ - "type": "command", - "command": h.hookCmd(), - }, - }, - }) - hooks["UserPromptSubmit"] = ups - settings["hooks"] = hooks out, err := json.MarshalIndent(settings, "", " ") if err != nil { @@ -127,6 +178,48 @@ func (h *ClaudeHookInstaller) mergeSettings() error { return nil } +// requiredPermissionRules returns the Claude Code permission allow-list +// entries that the locksmith integration needs. Both rules together ensure +// the UserPromptSubmit hook script and any direct `locksmith` invocation by +// the agent run without an additional approval dialog. +func (h *ClaudeHookInstaller) requiredPermissionRules() []string { + return []string{ + locksmithPermissionRule, + "Bash(" + h.hookCmd() + ")", + } +} + +// missingPermissionRules returns the subset of required rules not yet present +// in settings.permissions.allow. +func (h *ClaudeHookInstaller) missingPermissionRules(settings map[string]any, required []string) []string { + var missing []string + for _, rule := range required { + if !h.findPermissionRule(settings, rule) { + missing = append(missing, rule) + } + } + return missing +} + +// findPermissionRule returns true if rule appears as a string entry in +// settings.permissions.allow. +func (h *ClaudeHookInstaller) findPermissionRule(settings map[string]any, rule string) bool { + perms, ok := settings["permissions"].(map[string]any) + if !ok { + return false + } + allow, ok := perms["allow"].([]any) + if !ok { + return false + } + for _, entry := range allow { + if s, ok := entry.(string); ok && s == rule { + return true + } + } + return false +} + // findHookCmd returns true if hookCmd appears as a command value anywhere in // the UserPromptSubmit hook entries of settings. func (h *ClaudeHookInstaller) findHookCmd(settings map[string]any, hookCmd string) bool { diff --git a/internal/initflow/hooks_test.go b/internal/initflow/hooks_test.go index 0405e54..790a814 100644 --- a/internal/initflow/hooks_test.go +++ b/internal/initflow/hooks_test.go @@ -20,6 +20,30 @@ func makeHookInstaller(t *testing.T) (*initflow.ClaudeHookInstaller, string) { return initflow.NewClaudeHookInstaller(lsDir, claudeDir), home } +func TestClaudeHookInstaller_RefreshScript_OverwritesExistingScript(t *testing.T) { + installer, home := makeHookInstaller(t) + lsDir := filepath.Join(home, ".config", "locksmith") + scriptPath := filepath.Join(lsDir, "agent-hook.sh") + + // Pre-seed a stale script that does not contain the canonical command. + os.WriteFile(scriptPath, []byte("#!/bin/sh\necho stale\n"), 0o755) + + if err := installer.RefreshScript(); err != nil { + t.Fatalf("RefreshScript() error: %v", err) + } + + content, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatalf("reading refreshed script: %v", err) + } + if !strings.Contains(string(content), "locksmith session ensure") { + t.Errorf("RefreshScript did not regenerate the script from the template, got: %q", string(content)) + } + if strings.Contains(string(content), "echo stale") { + t.Error("RefreshScript left stale content in place") + } +} + func TestClaudeHookInstaller_IsInstalled_FalseWhenNoSettings(t *testing.T) { installer, _ := makeHookInstaller(t) if installer.IsInstalled() { @@ -41,7 +65,7 @@ func TestClaudeHookInstaller_IsInstalled_FalseWhenHooksAbsent(t *testing.T) { } } -func TestClaudeHookInstaller_IsInstalled_TrueWhenHookPresent(t *testing.T) { +func TestClaudeHookInstaller_IsInstalled_TrueWhenHookAndAllPermissionsPresent(t *testing.T) { installer, home := makeHookInstaller(t) claudeDir := filepath.Join(home, ".claude") lsDir := filepath.Join(home, ".config", "locksmith") @@ -57,12 +81,18 @@ func TestClaudeHookInstaller_IsInstalled_TrueWhenHookPresent(t *testing.T) { }, }, }, + "permissions": map[string]any{ + "allow": []any{ + "Bash(locksmith:*)", + "Bash(" + hookCmd + ")", + }, + }, } data, _ := json.Marshal(settings) os.WriteFile(filepath.Join(claudeDir, "settings.json"), data, 0o644) if !installer.IsInstalled() { - t.Error("IsInstalled() = false, want true when hook command present") + t.Error("IsInstalled() = false, want true when hook and all permission rules present") } } @@ -136,6 +166,164 @@ func TestClaudeHookInstaller_Install_CreatesSettingsJson(t *testing.T) { } } +func TestClaudeHookInstaller_Install_AddsPermissionRules(t *testing.T) { + installer, home := makeHookInstaller(t) + claudeDir := filepath.Join(home, ".claude") + lsDir := filepath.Join(home, ".config", "locksmith") + hookCmd := filepath.Join(lsDir, "agent-hook.sh") + + if err := installer.Install(); err != nil { + t.Fatalf("Install() error: %v", err) + } + + data, _ := os.ReadFile(filepath.Join(claudeDir, "settings.json")) + var settings map[string]any + if err := json.Unmarshal(data, &settings); err != nil { + t.Fatalf("settings.json invalid JSON: %v", err) + } + perms, _ := settings["permissions"].(map[string]any) + if perms == nil { + t.Fatal("permissions key missing from settings.json") + } + allow, _ := perms["allow"].([]any) + want := map[string]bool{ + "Bash(locksmith:*)": false, + "Bash(" + hookCmd + ")": false, + } + for _, item := range allow { + if s, ok := item.(string); ok { + if _, exists := want[s]; exists { + want[s] = true + } + } + } + for rule, present := range want { + if !present { + t.Errorf("permissions.allow missing rule %q (got %v)", rule, allow) + } + } +} + +func TestClaudeHookInstaller_Install_PermissionIdempotent(t *testing.T) { + installer, home := makeHookInstaller(t) + claudeDir := filepath.Join(home, ".claude") + + if err := installer.Install(); err != nil { + t.Fatalf("first Install() error: %v", err) + } + if err := installer.Install(); err != nil { + t.Fatalf("second Install() error: %v", err) + } + + data, _ := os.ReadFile(filepath.Join(claudeDir, "settings.json")) + var settings map[string]any + json.Unmarshal(data, &settings) + perms, _ := settings["permissions"].(map[string]any) + allow, _ := perms["allow"].([]any) + count := 0 + for _, item := range allow { + if s, _ := item.(string); s == "Bash(locksmith:*)" { + count++ + } + } + if count != 1 { + t.Errorf("Bash(locksmith:*) appears %d times after two Installs, want 1", count) + } +} + +func TestClaudeHookInstaller_Install_PreservesExistingPermissions(t *testing.T) { + installer, home := makeHookInstaller(t) + claudeDir := filepath.Join(home, ".claude") + existing := map[string]any{ + "permissions": map[string]any{ + "allow": []any{"Bash(git:*)", "Bash(npm:*)"}, + }, + } + data, _ := json.Marshal(existing) + os.WriteFile(filepath.Join(claudeDir, "settings.json"), data, 0o644) + + if err := installer.Install(); err != nil { + t.Fatalf("Install() error: %v", err) + } + + data, _ = os.ReadFile(filepath.Join(claudeDir, "settings.json")) + var settings map[string]any + json.Unmarshal(data, &settings) + perms, _ := settings["permissions"].(map[string]any) + allow, _ := perms["allow"].([]any) + want := map[string]bool{"Bash(git:*)": false, "Bash(npm:*)": false, "Bash(locksmith:*)": false} + for _, item := range allow { + if s, ok := item.(string); ok { + if _, exists := want[s]; exists { + want[s] = true + } + } + } + for rule, present := range want { + if !present { + t.Errorf("permission rule %q missing after Install()", rule) + } + } +} + +func TestClaudeHookInstaller_IsInstalled_FalseWhenPermissionMissing(t *testing.T) { + installer, home := makeHookInstaller(t) + claudeDir := filepath.Join(home, ".claude") + lsDir := filepath.Join(home, ".config", "locksmith") + hookCmd := filepath.Join(lsDir, "agent-hook.sh") + // Hook present, permission rules missing - IsInstalled must say false so + // Install() runs again and tops up the permissions. + settings := map[string]any{ + "hooks": map[string]any{ + "UserPromptSubmit": []any{ + map[string]any{ + "matcher": "", + "hooks": []any{ + map[string]any{"type": "command", "command": hookCmd}, + }, + }, + }, + }, + } + data, _ := json.Marshal(settings) + os.WriteFile(filepath.Join(claudeDir, "settings.json"), data, 0o644) + + if installer.IsInstalled() { + t.Error("IsInstalled() = true when permission rules are missing, want false") + } +} + +func TestClaudeHookInstaller_IsInstalled_FalseWhenOnlyOnePermissionPresent(t *testing.T) { + installer, home := makeHookInstaller(t) + claudeDir := filepath.Join(home, ".claude") + lsDir := filepath.Join(home, ".config", "locksmith") + hookCmd := filepath.Join(lsDir, "agent-hook.sh") + // Hook + only the Bash(locksmith:*) rule, but NOT the explicit hook-path + // rule. IsInstalled must say false so Install() tops up the missing path + // rule. + settings := map[string]any{ + "hooks": map[string]any{ + "UserPromptSubmit": []any{ + map[string]any{ + "matcher": "", + "hooks": []any{ + map[string]any{"type": "command", "command": hookCmd}, + }, + }, + }, + }, + "permissions": map[string]any{ + "allow": []any{"Bash(locksmith:*)"}, + }, + } + data, _ := json.Marshal(settings) + os.WriteFile(filepath.Join(claudeDir, "settings.json"), data, 0o644) + + if installer.IsInstalled() { + t.Error("IsInstalled() = true when hook-path permission missing, want false") + } +} + func TestClaudeHookInstaller_Install_MergesExistingSettings(t *testing.T) { installer, home := makeHookInstaller(t) claudeDir := filepath.Join(home, ".claude") @@ -251,6 +439,30 @@ func TestClaudeHookInstaller_IsInstalled_MalformedEntries(t *testing.T) { } } +func TestClaudeHookInstaller_Install_RefusesMalformedSettings(t *testing.T) { + installer, home := makeHookInstaller(t) + claudeDir := filepath.Join(home, ".claude") + settingsPath := filepath.Join(claudeDir, "settings.json") + // Pre-seed an invalid JSON file. + if err := os.WriteFile(settingsPath, []byte("not valid json {"), 0o644); err != nil { + t.Fatalf("seed settings.json: %v", err) + } + + err := installer.Install() + if err == nil { + t.Fatal("Install() = nil, want error refusing to overwrite malformed settings.json") + } + if !strings.Contains(err.Error(), "not valid JSON") { + t.Errorf("error %q must mention JSON parse failure", err.Error()) + } + + // The original (invalid) content must be untouched. + after, _ := os.ReadFile(settingsPath) + if string(after) != "not valid json {" { + t.Errorf("settings.json was modified despite refuse-to-overwrite: got %q", string(after)) + } +} + func TestClaudeHookInstaller_Install_WriteScriptError(t *testing.T) { // If the locksmith config path exists as a regular file, MkdirAll fails // and Install should return a wrapped error. diff --git a/internal/initflow/sandbox.go b/internal/initflow/sandbox.go index ce92e7d..1b6bd28 100644 --- a/internal/initflow/sandbox.go +++ b/internal/initflow/sandbox.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "strings" ) // locksmithAllowList is the set of locksmith commands to permit in agent sandboxes. @@ -80,7 +81,17 @@ func installClaudeSandbox(agent DetectedAgent) error { func installCodexSandbox(agent DetectedAgent) error { policyPath := filepath.Join(agent.ConfigDir, "policy.yaml") - content := "# Locksmith permissions\nallow:\n" + const marker = "# Locksmith permissions" + + existing, err := os.ReadFile(policyPath) //nolint:gosec // G304: path is under agent config dir + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("reading %s: %w", policyPath, err) + } + if strings.Contains(string(existing), marker) { + return nil + } + + content := marker + "\nallow:\n" for _, perm := range locksmithAllowList { cmd := perm if len(cmd) > 5 && cmd[:5] == "Bash(" { @@ -88,5 +99,21 @@ func installCodexSandbox(agent DetectedAgent) error { } content += fmt.Sprintf(" - %q\n", cmd) } - return appendIfAbsent(policyPath, content, "# Locksmith permissions") + + prefix := "" + if len(existing) > 0 { + prefix = "\n\n" + } + f, err := os.OpenFile(policyPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) //nolint:gosec // G302/G304 + if err != nil { + return fmt.Errorf("opening %s: %w", policyPath, err) + } + var retErr error + if _, werr := fmt.Fprint(f, prefix+content); werr != nil { + retErr = fmt.Errorf("writing %s: %w", policyPath, werr) + } + if cerr := f.Close(); cerr != nil && retErr == nil { + retErr = fmt.Errorf("closing %s: %w", policyPath, cerr) + } + return retErr } diff --git a/internal/initflow/templates/agent_instructions.md.tmpl b/internal/initflow/templates/agent_instructions.md.tmpl index f16e972..2a8ab78 100644 --- a/internal/initflow/templates/agent_instructions.md.tmpl +++ b/internal/initflow/templates/agent_instructions.md.tmpl @@ -1,3 +1,4 @@ + # Locksmith - Agent Instructions ## Session Protocol @@ -37,3 +38,4 @@ To share a session across multiple calls (avoids repeated vault authorization): Pass `LOCKSMITH_SESSION` to sub-agents via environment. They inherit the parent session's access and secret cache. + diff --git a/internal/initflow/templates/agents_md.md.tmpl b/internal/initflow/templates/agents_md.md.tmpl index ef71810..96624a8 100644 --- a/internal/initflow/templates/agents_md.md.tmpl +++ b/internal/initflow/templates/agents_md.md.tmpl @@ -1,3 +1,4 @@ + # Locksmith Integration This project uses [Locksmith](https://github.com/lorem-dev/locksmith) for @@ -43,3 +44,4 @@ Check `agent.pass_session_to_subagents` in `~/.config/locksmith/config.yaml` If `locksmith session ensure` exits non-zero, the daemon is not running. Continue work without secrets rather than blocking. + diff --git a/internal/initflow/templates/claude_md.md.tmpl b/internal/initflow/templates/claude_md.md.tmpl index 52f3e59..20bbcf2 100644 --- a/internal/initflow/templates/claude_md.md.tmpl +++ b/internal/initflow/templates/claude_md.md.tmpl @@ -1,3 +1,4 @@ + ## Locksmith Integration This environment uses [Locksmith](https://github.com/lorem-dev/locksmith) for @@ -26,3 +27,4 @@ secure MCP secret management. - Sessions expire automatically by TTL - no need to end them manually - Pass `LOCKSMITH_SESSION` to sub-agents so they share the parent session + diff --git a/internal/initflow/templates/claude_skill.md.tmpl b/internal/initflow/templates/claude_skill.md.tmpl index a448bd5..ac2a513 100644 --- a/internal/initflow/templates/claude_skill.md.tmpl +++ b/internal/initflow/templates/claude_skill.md.tmpl @@ -2,6 +2,7 @@ name: locksmith-auth description: Use when MCP server requires authentication headers or secrets from vaults --- + Use `locksmith get` to retrieve secrets from configured vaults. @@ -30,3 +31,4 @@ To reuse the same session across multiple calls (avoids repeated vault authoriza 2. Pass `LOCKSMITH_SESSION` to sub-agents via environment so they share the same session. Never hardcode secrets. Never cache secrets outside of locksmith. + diff --git a/internal/initflow/templates/codex_agents.md.tmpl b/internal/initflow/templates/codex_agents.md.tmpl index 005777d..4aa76ca 100644 --- a/internal/initflow/templates/codex_agents.md.tmpl +++ b/internal/initflow/templates/codex_agents.md.tmpl @@ -1,3 +1,4 @@ + ## Locksmith Integration Secrets are managed by Locksmith. @@ -26,3 +27,4 @@ Use `--path` + `--vault` to access a secret directly by its path in the vault - Export the session ID to reuse it: `export LOCKSMITH_SESSION=$(locksmith session start | jq -r .session_id)` - Pass `LOCKSMITH_SESSION` to sub-agents so they share the parent session. + diff --git a/internal/initflow/templates/gemini_md.md.tmpl b/internal/initflow/templates/gemini_md.md.tmpl index a069325..dd7f0f2 100644 --- a/internal/initflow/templates/gemini_md.md.tmpl +++ b/internal/initflow/templates/gemini_md.md.tmpl @@ -1,3 +1,4 @@ + # Locksmith Integration for Gemini CLI This workspace uses [Locksmith](https://github.com/lorem-dev/locksmith) for @@ -36,3 +37,4 @@ This is the default behavior per `agent.pass_session_to_subagents: true` in If `locksmith session ensure` fails (daemon not running), do not block - work continues without secrets. + diff --git a/plugins/onepassword/README.md b/plugins/onepassword/README.md index 0b38396..d92b49d 100644 --- a/plugins/onepassword/README.md +++ b/plugins/onepassword/README.md @@ -207,7 +207,7 @@ The `op` CLI is present but has no active session. Enable "Integrate with 1Password CLI" in **1Password 8 -> Settings -> Developer**, or run `op signin` to start a standalone session. -**`1password vault: 'vault:' option is required for non-qualified paths`** +**`1password vault: 'vault:' option is required for non-qualified paths, or use a full op:// reference`** The key's `path:` is a bare item name or an `item/field` pair, which requires the plugin to know which 1Password vault to look in. Either add `vault: ` to the locksmith vault entry, or use a fully qualified `op://vault/item/field` diff --git a/plugins/onepassword/provider.go b/plugins/onepassword/provider.go index acde1f9..ca7df77 100644 --- a/plugins/onepassword/provider.go +++ b/plugins/onepassword/provider.go @@ -62,6 +62,13 @@ func parsePath(reqPath, vaultLevel string) (string, error) { if reqPath == "" { return "", sdkerrors.InvalidArgumentError("1password path is empty") } + // Single-slash op: prefix is almost certainly a typo for op://. Reject + // it explicitly rather than silently treating it as a literal item. + if strings.HasPrefix(reqPath, "op:") && !strings.HasPrefix(reqPath, opScheme) { + return "", sdkerrors.InvalidArgumentError( + "1password reference must start with op:// (got " + reqPath + ")", + ) + } if strings.HasPrefix(reqPath, opScheme) { rest := strings.TrimPrefix(reqPath, opScheme) segs := strings.Split(rest, "/") diff --git a/plugins/onepassword/provider_test.go b/plugins/onepassword/provider_test.go index db08e68..3c7a9df 100644 --- a/plugins/onepassword/provider_test.go +++ b/plugins/onepassword/provider_test.go @@ -74,6 +74,12 @@ func TestParsePath(t *testing.T) { vaultLevel: "", wantCode: codes.InvalidArgument, }, + { + name: "single-slash op: prefix rejected as typo", + path: "op:/Personal/Item/field", + vaultLevel: "", + wantCode: codes.InvalidArgument, + }, { name: "non-op scheme treated as relative path", path: "weird://thing", From b9e4044f4182e2af13ab06a25e4955592f80a888 Mon Sep 17 00:00:00 2001 From: Lorem Dev Date: Sat, 23 May 2026 00:08:55 +0200 Subject: [PATCH 15/25] feat(daemon): add lifecycle helpers, locksmith restart, and PATH bootstrap Three coupled additions building the daemon-management surface: - internal/daemon: lifecycle helpers (IsRunning, Start, Stop, Restart, DefaultSocketPath) plus per-platform peerPID via SO_PEERCRED on Linux and LOCAL_PEERPID on darwin. _autostart now delegates to these helpers; the cli has no inline probe/spawn logic left. - internal/cli/restart_cmd.go: public 'locksmith restart' command with --timeout (5s default) and --no-start flags. Stops the running daemon (SIGTERM, escalates to SIGKILL after grace) and starts a fresh one. See docs/restart.md. - internal/shellhook/pathhook.go: per-shell PATH-snippet helpers (bash/zsh/ash/posix use case-style; fish uses 'not contains'). internal/initflow/flow.go::applyPathHook wires it in so init appends the snippet to the user's rc file when the locksmith binary directory is not already on PATH. Never creates a missing rc file. --- go.mod | 2 +- internal/cli/autostart_cmd.go | 64 +++--------- internal/cli/restart_cmd.go | 64 ++++++++++++ internal/cli/restart_cmd_test.go | 136 +++++++++++++++++++++++++ internal/cli/root.go | 1 + internal/daemon/lifecycle.go | 140 ++++++++++++++++++++++++++ internal/daemon/lifecycle_test.go | 114 +++++++++++++++++++++ internal/daemon/peerpid_darwin.go | 43 ++++++++ internal/daemon/peerpid_linux.go | 41 ++++++++ internal/initflow/flow.go | 55 ++++++++++ internal/initflow/flow_test.go | 71 +++++++++++++ internal/shellhook/pathhook.go | 97 ++++++++++++++++++ internal/shellhook/pathhook_test.go | 150 ++++++++++++++++++++++++++++ 13 files changed, 925 insertions(+), 53 deletions(-) create mode 100644 internal/cli/restart_cmd.go create mode 100644 internal/cli/restart_cmd_test.go create mode 100644 internal/daemon/lifecycle.go create mode 100644 internal/daemon/lifecycle_test.go create mode 100644 internal/daemon/peerpid_darwin.go create mode 100644 internal/daemon/peerpid_linux.go create mode 100644 internal/shellhook/pathhook.go create mode 100644 internal/shellhook/pathhook_test.go diff --git a/go.mod b/go.mod index 3c1c988..d407cbe 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/rs/zerolog v1.35.0 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.7.2 + golang.org/x/sys v0.43.0 golang.org/x/term v0.42.0 google.golang.org/grpc v1.80.0 google.golang.org/protobuf v1.36.11 @@ -54,7 +55,6 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/net v0.49.0 // indirect golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.43.0 // indirect golang.org/x/text v0.33.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect diff --git a/internal/cli/autostart_cmd.go b/internal/cli/autostart_cmd.go index a77b12c..9c0bf4f 100644 --- a/internal/cli/autostart_cmd.go +++ b/internal/cli/autostart_cmd.go @@ -1,70 +1,30 @@ package cli import ( - "net" - "os" - "os/exec" - "path/filepath" - "strings" - "time" - "github.com/spf13/cobra" - "github.com/lorem-dev/locksmith/internal/config" + "github.com/lorem-dev/locksmith/internal/daemon" ) -// daemonProbeTimeout is how long _autostart waits when probing the daemon socket. -const daemonProbeTimeout = 200 * time.Millisecond - // newAutostartCmd returns the hidden locksmith _autostart command. -// It is called from shell rc files: it starts the daemon only if it is not -// already running, and always exits 0 so shell sessions never fail because -// of autostart errors. +// It is called from shell rc files: starts the daemon only if it is not +// already running, and always exits 0 so shell sessions never fail +// because of autostart errors. +// +// All real work is delegated to internal/daemon so the same probe/spawn +// logic is shared with `locksmith restart` and the init wizard's +// post-install daemon restart. func newAutostartCmd() *cobra.Command { return &cobra.Command{ Use: "_autostart", Short: "Start the daemon if not already running (used by shell hooks)", Hidden: true, - RunE: func(cmd *cobra.Command, args []string) error { - socketPath := config.ExpandPath("~/.config/locksmith/locksmith.sock") - if env := os.Getenv("LOCKSMITH_SOCKET"); env != "" { - socketPath = env - } - - // Probe the socket. If the dial succeeds the daemon is alive. - conn, err := net.DialTimeout( //nolint:gosec // G704: local Unix socket, not a network SSRF - "unix", socketPath, daemonProbeTimeout, - ) - if err == nil { - conn.Close() //nolint:errcheck // probe connection; error not actionable - return nil // daemon already running - } - - // Daemon not running: spawn it in the background. - binary, err := os.Executable() - if err != nil { - return nil // silently ignore - } - // Guard against recursive spawning when running inside a Go test binary. - // os.Executable() returns the test binary (e.g. cli.test or a path under - // /tmp/go-build…). Spawning it with "serve" would re-run the full test - // suite instead of starting the daemon, causing exponential process growth. - base := filepath.Base(binary) - if strings.HasSuffix(base, ".test") || strings.Contains(binary, string(os.PathSeparator)+"go-build") { + RunE: func(_ *cobra.Command, _ []string) error { + socket := daemon.DefaultSocketPath() + if daemon.IsRunning(socket) { return nil } - c := exec.Command(binary, "serve") //nolint:gosec // G204: binary is os.Executable(), not user input - // Detach stdout/stderr/stdin: the daemon runs silently in the background. - if err := c.Start(); err != nil { - return nil // silently ignore - } - // Prevent zombie: reap the child if it exits before this process does. - // In normal operation the daemon outlives _autostart and is adopted by - // init, which reaps it. If it exits early (e.g. config error), this - // goroutine collects the exit status before _autostart itself exits. - go func() { _ = c.Wait() }() //nolint:errcheck // daemon exit status not actionable here - // Give the daemon a moment to bind its socket before the shell continues. - time.Sleep(50 * time.Millisecond) + _, _ = daemon.Start() // errors swallowed: hooks must not fail return nil }, } diff --git a/internal/cli/restart_cmd.go b/internal/cli/restart_cmd.go new file mode 100644 index 0000000..366d409 --- /dev/null +++ b/internal/cli/restart_cmd.go @@ -0,0 +1,64 @@ +package cli + +import ( + "fmt" + "time" + + "github.com/spf13/cobra" + + "github.com/lorem-dev/locksmith/internal/daemon" +) + +// newRestartCmd returns the public `locksmith restart` command. +// +// Why a dedicated command instead of asking users to kill + serve: +// - Restart is a single intent. Users should not have to know the +// socket path, pid discovery mechanism, or signal semantics. +// - The new daemon must wait for the old one's socket to be released +// before it can bind; doing this dance correctly requires the same +// polling logic the autostart probe already has. Reusing +// internal/daemon ensures both paths behave identically. +// - `locksmith reload` (hot config reload) is intentionally separate. +// Reload is the cheap path for config-only changes; restart is the +// heavy hammer used for version upgrades, plugin re-extraction, +// and any change that requires re-binding plugin RPCs. +func newRestartCmd() *cobra.Command { + var timeout time.Duration + var noStart bool + + cmd := &cobra.Command{ + Use: "restart", + Short: "Restart the locksmith daemon", + RunE: func(c *cobra.Command, _ []string) error { + socket := daemon.DefaultSocketPath() + out := c.OutOrStdout() + running := daemon.IsRunning(socket) + if !running { + if noStart { + fmt.Fprintln(out, "locksmith daemon was not running; --no-start: nothing to do") + return nil + } + if _, err := daemon.Start(); err != nil { + return fmt.Errorf("starting daemon: %w", err) + } + fmt.Fprintln(out, "locksmith daemon was not running; started fresh") + return nil + } + if err := daemon.Stop(socket, timeout); err != nil { + return err + } + if noStart { + fmt.Fprintln(out, "locksmith daemon stopped (--no-start)") + return nil + } + if _, err := daemon.Start(); err != nil { + return fmt.Errorf("starting fresh daemon: %w", err) + } + fmt.Fprintln(out, "locksmith daemon restarted") + return nil + }, + } + cmd.Flags().DurationVar(&timeout, "timeout", 5*time.Second, "max time to wait for the old daemon to release the socket") + cmd.Flags().BoolVar(&noStart, "no-start", false, "stop the daemon but do not start a fresh one") + return cmd +} diff --git a/internal/cli/restart_cmd_test.go b/internal/cli/restart_cmd_test.go new file mode 100644 index 0000000..2baa908 --- /dev/null +++ b/internal/cli/restart_cmd_test.go @@ -0,0 +1,136 @@ +package cli + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestRestart_NoDaemon_NoStartFlag(t *testing.T) { + socketDir, _ := os.MkdirTemp("", "lks") + t.Cleanup(func() { _ = os.RemoveAll(socketDir) }) + socketPath := filepath.Join(socketDir, "ls.sock") + t.Setenv("LOCKSMITH_SOCKET", socketPath) + + var out bytes.Buffer + cmd := newRestartCmd() + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"--no-start"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + if !strings.Contains(out.String(), "was not running") { + t.Errorf("output should mention 'was not running', got %q", out.String()) + } +} + +func TestRestart_DaemonRunning_TermSucceeds(t *testing.T) { + socketDir, _ := os.MkdirTemp("", "lks") + t.Cleanup(func() { _ = os.RemoveAll(socketDir) }) + socketPath := filepath.Join(socketDir, "ls.sock") + t.Setenv("LOCKSMITH_SOCKET", socketPath) + helper := buildRestartHelper(t, socketPath, false) + t.Cleanup(func() { _ = helper.Process.Kill() }) + waitForFileR(t, socketPath, 2*time.Second) + + var out bytes.Buffer + cmd := newRestartCmd() + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"--no-start"}) // do not spawn a real daemon + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + // Helper should have exited and the socket file should be gone. + if _, err := os.Stat(socketPath); err == nil { + t.Errorf("socket %s still present; helper did not exit", socketPath) + } +} + +func TestRestart_TermIgnored_KillSucceeds(t *testing.T) { + socketDir, _ := os.MkdirTemp("", "lks") + t.Cleanup(func() { _ = os.RemoveAll(socketDir) }) + socketPath := filepath.Join(socketDir, "ls.sock") + t.Setenv("LOCKSMITH_SOCKET", socketPath) + helper := buildRestartHelper(t, socketPath, true) // ignore SIGTERM + t.Cleanup(func() { _ = helper.Process.Kill() }) + waitForFileR(t, socketPath, 2*time.Second) + + var out bytes.Buffer + cmd := newRestartCmd() + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"--no-start", "--timeout=500ms"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } +} + +// buildRestartHelper compiles a tiny helper binary that holds socketPath +// and either exits on SIGTERM (ignoreTerm=false) or ignores it +// (ignoreTerm=true) so the test can drive Restart against a known +// process. +func buildRestartHelper(t *testing.T, socketPath string, ignoreTerm bool) *exec.Cmd { + t.Helper() + dir, _ := os.MkdirTemp("", "lks-helper") + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + src := filepath.Join(dir, "h.go") + bin := filepath.Join(dir, "h") + body := ` +package main +import ( + "flag" + "net" + "os" + "os/signal" + "syscall" +) +func main() { + socket := flag.String("socket", "", "") + ignore := flag.Bool("ignore-term", false, "") + flag.Parse() + l, _ := net.Listen("unix", *socket) + defer l.Close() + sig := make(chan os.Signal, 1) + if *ignore { + signal.Notify(sig, syscall.SIGINT) + } else { + signal.Notify(sig, syscall.SIGTERM, syscall.SIGINT) + } + <-sig +} +` + if err := os.WriteFile(src, []byte(body), 0o644); err != nil { + t.Fatalf("write helper: %v", err) + } + out, err := exec.Command("go", "build", "-o", bin, src).CombinedOutput() + if err != nil { + t.Fatalf("build helper: %v: %s", err, out) + } + args := []string{"--socket", socketPath} + if ignoreTerm { + args = append(args, "--ignore-term") + } + c := exec.Command(bin, args...) + if err := c.Start(); err != nil { + t.Fatalf("start helper: %v", err) + } + return c +} + +func waitForFileR(t *testing.T, p string, timeout time.Duration) { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if _, err := os.Stat(p); err == nil { + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("file %s never appeared within %s", p, timeout) +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 8ab7270..d86c777 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -30,6 +30,7 @@ func NewRootCmd() *cobra.Command { newInitCmd(), newAutostartCmd(), newReloadCmd(), + newRestartCmd(), newPluginsCmd(), newVersionCmd(), newMCPCmd(), diff --git a/internal/daemon/lifecycle.go b/internal/daemon/lifecycle.go new file mode 100644 index 0000000..b89258d --- /dev/null +++ b/internal/daemon/lifecycle.go @@ -0,0 +1,140 @@ +// Package daemon exposes lifecycle helpers (probe socket, start/stop the +// locksmith daemon process) shared by the `_autostart` hook, the +// `locksmith restart` command, and the init wizard. +// +// The helpers do not depend on cobra or any CLI-level state; they are +// safe to call from any goroutine. +package daemon + +import ( + "errors" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/lorem-dev/locksmith/internal/config" +) + +const ( + probeTimeout = 200 * time.Millisecond + probeStep = 50 * time.Millisecond +) + +// DefaultSocketPath returns the well-known locksmith socket path, honouring +// the LOCKSMITH_SOCKET environment override if set. +func DefaultSocketPath() string { + if env := os.Getenv("LOCKSMITH_SOCKET"); env != "" { + return env + } + return config.ExpandPath("~/.config/locksmith/locksmith.sock") +} + +// IsRunning returns true when a process is listening on socketPath. The +// check is a single dial with a 200ms timeout; it does not exchange any +// protocol traffic. +func IsRunning(socketPath string) bool { + conn, err := net.DialTimeout("unix", socketPath, probeTimeout) //nolint:gosec // local Unix socket + if err != nil { + return false + } + _ = conn.Close() + return true +} + +// Start spawns a fresh `locksmith serve` in the background. Returns the +// spawned PID. The caller is responsible for any pre-checks. If the +// current binary looks like a Go test artifact, Start refuses to spawn +// (prevents recursive test execution); the returned error is non-nil +// and the caller should treat that as "skip". +func Start() (int, error) { + binary, err := os.Executable() + if err != nil { + return 0, fmt.Errorf("locating locksmith binary: %w", err) + } + base := filepath.Base(binary) + if strings.HasSuffix(base, ".test") || strings.Contains(binary, string(os.PathSeparator)+"go-build") { + return 0, errors.New("refusing to spawn from a test binary") + } + c := exec.Command(binary, "serve") //nolint:gosec // G204: binary is os.Executable() + if err := c.Start(); err != nil { + return 0, fmt.Errorf("starting daemon: %w", err) + } + go func() { _ = c.Wait() }() //nolint:errcheck + // Give the daemon a brief moment to bind its socket. + time.Sleep(50 * time.Millisecond) + return c.Process.Pid, nil +} + +// Stop terminates the daemon currently listening on socketPath. The +// strategy is: locate the peer pid via the platform-specific +// SO_PEERCRED/LOCAL_PEERPID socket option, send SIGTERM, poll until the +// socket stops accepting, escalate to SIGKILL after grace, return error +// if the socket never becomes free. +// +// When no daemon is running, Stop is a no-op and returns nil. +func Stop(socketPath string, timeout time.Duration) error { + if !IsRunning(socketPath) { + return nil + } + pid, err := peerPID(socketPath) + if err != nil { + return fmt.Errorf("locating daemon pid: %w", err) + } + if err := syscall.Kill(pid, syscall.SIGTERM); err != nil && !errors.Is(err, syscall.ESRCH) { + return fmt.Errorf("sending SIGTERM to %d: %w", pid, err) + } + if waitGone(socketPath, timeout) { + return nil + } + // Escalate. + if err := syscall.Kill(pid, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) { + return fmt.Errorf("sending SIGKILL to %d: %w", pid, err) + } + if !waitGone(socketPath, 2*time.Second) { + return fmt.Errorf("daemon at %s did not release socket after SIGKILL", socketPath) + } + return nil +} + +// RestartOptions controls Restart behaviour. +type RestartOptions struct { + SocketPath string // defaults to DefaultSocketPath() + Timeout time.Duration // defaults to 5s + NoStart bool // if true, skip the Start step after Stop +} + +// Restart stops the currently-running daemon (if any) and starts a +// fresh one. If no daemon was running and NoStart is false, Restart +// just starts a fresh daemon. +func Restart(opts RestartOptions) error { + if opts.SocketPath == "" { + opts.SocketPath = DefaultSocketPath() + } + if opts.Timeout == 0 { + opts.Timeout = 5 * time.Second + } + if err := Stop(opts.SocketPath, opts.Timeout); err != nil { + return err + } + if opts.NoStart { + return nil + } + _, err := Start() + return err +} + +func waitGone(socketPath string, timeout time.Duration) bool { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if !IsRunning(socketPath) { + return true + } + time.Sleep(probeStep) + } + return !IsRunning(socketPath) +} diff --git a/internal/daemon/lifecycle_test.go b/internal/daemon/lifecycle_test.go new file mode 100644 index 0000000..2fcad1c --- /dev/null +++ b/internal/daemon/lifecycle_test.go @@ -0,0 +1,114 @@ +package daemon_test + +import ( + "net" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/lorem-dev/locksmith/internal/daemon" +) + +func TestIsRunning_FalseWhenSocketAbsent(t *testing.T) { + socketPath := filepath.Join(t.TempDir(), "ls.sock") + if daemon.IsRunning(socketPath) { + t.Errorf("IsRunning(%q) = true, want false when socket missing", socketPath) + } +} + +func TestIsRunning_TrueWhenSocketAccepting(t *testing.T) { + // Use a short path to stay within macOS's 104-char Unix socket path limit. + dir, err := os.MkdirTemp("", "lks") + if err != nil { + t.Fatalf("MkdirTemp: %v", err) + } + t.Cleanup(func() { os.RemoveAll(dir) }) + socketPath := filepath.Join(dir, "ls.sock") + l, err := net.Listen("unix", socketPath) + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { l.Close() }) + if !daemon.IsRunning(socketPath) { + t.Errorf("IsRunning(%q) = false, want true when socket accepting", socketPath) + } +} + +func TestStop_NoDaemonRunning_ReturnsNil(t *testing.T) { + socketPath := filepath.Join(t.TempDir(), "ls.sock") + if err := daemon.Stop(socketPath, 100*time.Millisecond); err != nil { + t.Errorf("Stop on absent daemon should be a no-op, got %v", err) + } +} + +func TestStop_KillsListenerAndWaits(t *testing.T) { + socketPath := filepath.Join(t.TempDir(), "ls.sock") + pidFile := filepath.Join(t.TempDir(), "pid") + helperPath := buildHelperBinary(t) + cmd := exec.Command(helperPath, "--socket", socketPath, "--pidfile", pidFile) + if err := cmd.Start(); err != nil { + t.Fatalf("start helper: %v", err) + } + t.Cleanup(func() { _ = cmd.Process.Kill() }) + waitForFile(t, socketPath, 2*time.Second) + + if err := daemon.Stop(socketPath, 2*time.Second); err != nil { + t.Fatalf("Stop: %v", err) + } + if daemon.IsRunning(socketPath) { + t.Error("daemon still appears running after Stop") + } +} + +func buildHelperBinary(t *testing.T) string { + t.Helper() + dir := t.TempDir() + src := filepath.Join(dir, "helper.go") + bin := filepath.Join(dir, "helper") + const helper = ` +package main + +import ( + "flag" + "net" + "os" + "os/signal" + "syscall" +) + +func main() { + socket := flag.String("socket", "", "") + pidfile := flag.String("pidfile", "", "") + flag.Parse() + l, err := net.Listen("unix", *socket) + if err != nil { os.Exit(2) } + defer l.Close() + _ = os.WriteFile(*pidfile, []byte("alive"), 0o644) + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGTERM, syscall.SIGINT) + <-sig +} +` + if err := os.WriteFile(src, []byte(helper), 0o644); err != nil { + t.Fatalf("write helper src: %v", err) + } + out, err := exec.Command("go", "build", "-o", bin, src).CombinedOutput() + if err != nil { + t.Fatalf("build helper: %v: %s", err, out) + } + return bin +} + +func waitForFile(t *testing.T, path string, timeout time.Duration) { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if _, err := os.Stat(path); err == nil { + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("file %s never appeared within %s", path, timeout) +} diff --git a/internal/daemon/peerpid_darwin.go b/internal/daemon/peerpid_darwin.go new file mode 100644 index 0000000..2af7480 --- /dev/null +++ b/internal/daemon/peerpid_darwin.go @@ -0,0 +1,43 @@ +//go:build darwin + +package daemon + +import ( + "errors" + "net" + + "golang.org/x/sys/unix" +) + +// peerPID returns the PID of the process listening on socketPath using +// LOCAL_PEERPID on darwin. The socket option returns the peer pid as +// an int via getsockopt; we invoke it inside raw.Control so the fd is +// not concurrently closed by the Go runtime. +func peerPID(socketPath string) (int, error) { + c, err := net.Dial("unix", socketPath) //nolint:gosec // local Unix socket + if err != nil { + return 0, err + } + defer c.Close() //nolint:errcheck + uc, ok := c.(*net.UnixConn) + if !ok { + return 0, errors.New("unix dial did not yield *net.UnixConn") + } + raw, err := uc.SyscallConn() + if err != nil { + return 0, err + } + var ( + pid int + inner error + ) + if cerr := raw.Control(func(fd uintptr) { + pid, inner = unix.GetsockoptInt(int(fd), unix.SOL_LOCAL, unix.LOCAL_PEERPID) + }); cerr != nil { + return 0, cerr + } + if inner != nil { + return 0, inner + } + return pid, nil +} diff --git a/internal/daemon/peerpid_linux.go b/internal/daemon/peerpid_linux.go new file mode 100644 index 0000000..71cdc86 --- /dev/null +++ b/internal/daemon/peerpid_linux.go @@ -0,0 +1,41 @@ +//go:build linux + +package daemon + +import ( + "errors" + "net" + + "golang.org/x/sys/unix" +) + +// peerPID returns the PID of the process listening on socketPath, using +// SO_PEERCRED on Linux. +func peerPID(socketPath string) (int, error) { + c, err := net.Dial("unix", socketPath) //nolint:gosec // local Unix socket + if err != nil { + return 0, err + } + defer c.Close() //nolint:errcheck + uc, ok := c.(*net.UnixConn) + if !ok { + return 0, errors.New("unix dial did not yield *net.UnixConn") + } + raw, err := uc.SyscallConn() + if err != nil { + return 0, err + } + var ( + ucred *unix.Ucred + inner error + ) + if cerr := raw.Control(func(fd uintptr) { + ucred, inner = unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED) + }); cerr != nil { + return 0, cerr + } + if inner != nil { + return 0, inner + } + return int(ucred.Pid), nil +} diff --git a/internal/initflow/flow.go b/internal/initflow/flow.go index 7ab4712..2326e99 100644 --- a/internal/initflow/flow.go +++ b/internal/initflow/flow.go @@ -85,6 +85,10 @@ type InitResult struct { ClaudeHookConfirmed bool // user approved (or --auto); set in RunInit before applyInit ClaudeHookInstalled bool // hook was written successfully; set in applyInit ClaudeHookAlreadyPresent bool // hook was already in settings.json; install skipped + PathHookInstalled bool // true when a PATH snippet was appended to rc + PathHookRCFile string // rc file path; empty when skipped + DaemonRestarted bool // true when init restarted a running daemon + LegacyHookMigrated bool // true when the legacy shell hook was migrated } var ( @@ -426,6 +430,7 @@ func applyInit(result *InitResult, homeDir string, prompter Prompter, auto bool) } applyShellHook(result) + applyPathHook(result, homeDir) return nil } @@ -525,6 +530,56 @@ func applyClaudeHook(result *InitResult, homeDir string) error { return nil } +// applyPathHook augments the user's shell rc file so that `locksmith` +// is resolvable by the Claude Code UserPromptSubmit hook. The hook is +// registered as the bare command `locksmith _agent-hook` and relies on +// PATH at hook-execution time. Many setups (GUI launchers, non-login +// shells) do not inherit a useful PATH, so we ensure the directory +// containing the locksmith binary is on PATH explicitly. +// +// Skipped silently when: +// - The directory is already in the live $PATH at init time. +// - The shell is unknown or has no canonical rc file. +// - The rc file does not exist (init never creates new rc files). +func applyPathHook(result *InitResult, _ string) { + exe, err := os.Executable() + if err != nil { + return + } + if resolved, rerr := filepath.EvalSymlinks(exe); rerr == nil { + exe = resolved + } + binDir := filepath.Dir(exe) + + if shellhook.DirInPath(os.Getenv("PATH"), binDir) { + return + } + + shell := shellhook.DetectShell() + rcFile, ok := shellhook.RCFile(shell) + if !ok { + return + } + + if _, statErr := os.Stat(rcFile); errors.Is(statErr, fs.ErrNotExist) { + return + } + already, err := shellhook.IsPathInstalled(rcFile) + if err != nil || already { + return + } + if err := shellhook.InstallPath(rcFile, shell, binDir); err != nil { + return + } + result.PathHookInstalled = true + result.PathHookRCFile = rcFile +} + +// ApplyPathHookForTest is an export-for-test seam. +func ApplyPathHookForTest(result *InitResult, homeDir string) { + applyPathHook(result, homeDir) +} + // applyShellHook installs or reports status of the shell daemon autostart hook. func applyShellHook(result *InitResult) { switch { diff --git a/internal/initflow/flow_test.go b/internal/initflow/flow_test.go index a26731f..9aff225 100644 --- a/internal/initflow/flow_test.go +++ b/internal/initflow/flow_test.go @@ -922,3 +922,74 @@ func TestRunInit_Auto_SelectsOnePasswordWhenDetected(t *testing.T) { t.Errorf("SelectedVaults = %v, want to contain 1password (detected and implemented)", result.SelectedVaults) } } + +func TestApplyPathHook_AlreadyInPath_Skip(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + exe, _ := os.Executable() + t.Setenv("PATH", filepath.Dir(exe)+":/usr/bin") + + res := &initflow.InitResult{} + initflow.ApplyPathHookForTest(res, home) + + if res.PathHookInstalled { + t.Error("PathHookInstalled = true when binDir is already in PATH") + } +} + +func TestApplyPathHook_NotInPath_NoRCFile_Skip(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("PATH", "/usr/bin") + t.Setenv("SHELL", "/bin/zsh") + + res := &initflow.InitResult{} + initflow.ApplyPathHookForTest(res, home) + + if res.PathHookInstalled { + t.Error("PathHookInstalled = true; init must not create .zshrc") + } + if _, err := os.Stat(filepath.Join(home, ".zshrc")); err == nil { + t.Error(".zshrc was created; init must not do that") + } +} + +func TestApplyPathHook_NotInPath_ExistingRC_Installs(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("PATH", "/usr/bin") + t.Setenv("SHELL", "/bin/zsh") + rc := filepath.Join(home, ".zshrc") + _ = os.WriteFile(rc, []byte("# user content\n"), 0o644) + + res := &initflow.InitResult{} + initflow.ApplyPathHookForTest(res, home) + + if !res.PathHookInstalled { + t.Error("PathHookInstalled = false; expected snippet to be appended") + } + data, _ := os.ReadFile(rc) + if !strings.Contains(string(data), "locksmith PATH (managed by locksmith init)") { + t.Errorf("rc missing snippet: %q", string(data)) + } +} + +func TestApplyPathHook_Idempotent(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("PATH", "/usr/bin") + t.Setenv("SHELL", "/bin/zsh") + rc := filepath.Join(home, ".zshrc") + _ = os.WriteFile(rc, []byte("# user content\n"), 0o644) + + res1 := &initflow.InitResult{} + initflow.ApplyPathHookForTest(res1, home) + res2 := &initflow.InitResult{} + initflow.ApplyPathHookForTest(res2, home) + + data, _ := os.ReadFile(rc) + count := strings.Count(string(data), "locksmith PATH (managed by locksmith init)") + if count != 1 { + t.Errorf("marker appears %d times after two runs, want 1", count) + } +} diff --git a/internal/shellhook/pathhook.go b/internal/shellhook/pathhook.go new file mode 100644 index 0000000..3cfb3fd --- /dev/null +++ b/internal/shellhook/pathhook.go @@ -0,0 +1,97 @@ +package shellhook + +import ( + "errors" + "fmt" + "io/fs" + "os" + "strings" +) + +// PathSnippetMarker is the unique comment that identifies the +// locksmith PATH-augmentation block in a shell rc file. Independent +// from the autostart-hook marker so each block can be detected and +// re-installed separately. +const PathSnippetMarker = "# locksmith PATH (managed by locksmith init)" + +// PathSnippet returns the rc-file snippet that appends binDir to PATH +// when binDir is not already present, formatted for shell s. The +// snippet is idempotent at shell-startup time: sourcing the rc file +// twice in one session does not duplicate the entry. +// +// Why we install this: +// - The Claude Code UserPromptSubmit hook is registered as +// `command: "locksmith _agent-hook"` and relies on PATH to +// resolve the binary. Without this snippet, hooks launched in a +// non-login shell (or by a GUI launcher that does not source the +// user's rc file) would silently fail because PATH would not +// contain the install location. +func PathSnippet(s Shell, binDir string) string { + rationale := PathSnippetMarker + "\n" + + "# Ensures locksmith is on PATH when Claude Code spawns the\n" + + "# UserPromptSubmit hook from a non-login shell. The hook command\n" + + "# in ~/.claude/settings.json is \"locksmith _agent-hook\" and\n" + + "# relies on PATH resolution. Idempotent: skipped when the\n" + + "# directory is already present.\n" + if s == ShellFish { + return rationale + + "if not contains -- " + binDir + " $PATH\n" + + " set -gx PATH $PATH " + binDir + "\n" + + "end\n" + } + return rationale + + "case \":$PATH:\" in\n" + + " *\":" + binDir + ":\"*) ;;\n" + + " *) export PATH=\"$PATH:" + binDir + "\" ;;\n" + + "esac\n" +} + +// DirInPath reports whether dir appears as one of the colon-separated +// entries of pathEnv. Exact match - we do not resolve symlinks because +// users may intentionally point at a canonical symlink. +func DirInPath(pathEnv, dir string) bool { + if dir == "" { + return false + } + for _, entry := range strings.Split(pathEnv, ":") { + if entry == dir { + return true + } + } + return false +} + +// IsPathInstalled returns true when rcFile contains the locksmith PATH +// marker. Missing rcFile is reported as (false, nil). +func IsPathInstalled(rcFile string) (bool, error) { + data, err := os.ReadFile(rcFile) //nolint:gosec // rcFile is derived from user home + if errors.Is(err, fs.ErrNotExist) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("reading %s: %w", rcFile, err) + } + return strings.Contains(string(data), PathSnippetMarker), nil +} + +// InstallPath appends the snippet for shell s and binDir to rcFile. +// Refuses to create rcFile if it does not exist - locksmith init must +// never create a shell rc file that the user has not created +// themselves. +func InstallPath(rcFile string, s Shell, binDir string) error { + if _, err := os.Stat(rcFile); err != nil { + if errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("rc file %s does not exist; refusing to create it", rcFile) + } + return fmt.Errorf("stat %s: %w", rcFile, err) + } + f, err := os.OpenFile(rcFile, os.O_APPEND|os.O_WRONLY, 0o644) //nolint:gosec + if err != nil { + return fmt.Errorf("opening %s: %w", rcFile, err) + } + defer f.Close() //nolint:errcheck + if _, err := f.WriteString("\n" + PathSnippet(s, binDir)); err != nil { + return fmt.Errorf("writing %s: %w", rcFile, err) + } + return nil +} diff --git a/internal/shellhook/pathhook_test.go b/internal/shellhook/pathhook_test.go new file mode 100644 index 0000000..6c9fac2 --- /dev/null +++ b/internal/shellhook/pathhook_test.go @@ -0,0 +1,150 @@ +package shellhook_test + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/lorem-dev/locksmith/internal/shellhook" +) + +func TestDirInPath(t *testing.T) { + cases := []struct { + name, pathEnv, dir string + want bool + }{ + {"empty path", "", "/usr/local/bin", false}, + {"empty dir", "/usr/bin", "", false}, + {"only entry", "/usr/local/bin", "/usr/local/bin", true}, + {"first of many", "/usr/local/bin:/usr/bin", "/usr/local/bin", true}, + {"last of many", "/usr/bin:/usr/local/bin", "/usr/local/bin", true}, + {"middle", "/a:/usr/local/bin:/b", "/usr/local/bin", true}, + {"absent", "/usr/bin:/bin", "/usr/local/bin", false}, + {"prefix must not match", "/usr/local/bin2:/x", "/usr/local/bin", false}, + {"trailing slash differs", "/usr/local/bin/:/x", "/usr/local/bin", false}, + {"doubled colon empty entry", "::/usr/local/bin", "/usr/local/bin", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := shellhook.DirInPath(tc.pathEnv, tc.dir); got != tc.want { + t.Errorf("DirInPath(%q, %q) = %v, want %v", tc.pathEnv, tc.dir, got, tc.want) + } + }) + } +} + +func TestPathSnippet_PosixCaseCheck(t *testing.T) { + dir := "/Users/me/.local/bin" + for _, s := range []shellhook.Shell{ + shellhook.ShellBash, shellhook.ShellZsh, shellhook.ShellAsh, + } { + snippet := shellhook.PathSnippet(s, dir) + if !strings.Contains(snippet, `case ":$PATH:"`) { + t.Errorf("%v snippet missing posix case check: %q", s, snippet) + } + if !strings.Contains(snippet, dir) { + t.Errorf("%v snippet missing dir %q: %q", s, dir, snippet) + } + if !strings.Contains(snippet, shellhook.PathSnippetMarker) { + t.Errorf("%v snippet missing marker", s) + } + } +} + +func TestPathSnippet_FishUsesContains(t *testing.T) { + dir := "/Users/me/.local/bin" + snippet := shellhook.PathSnippet(shellhook.ShellFish, dir) + if !strings.Contains(snippet, "not contains -- "+dir+" $PATH") { + t.Errorf("fish snippet missing 'not contains' form: %q", snippet) + } + if !strings.Contains(snippet, shellhook.PathSnippetMarker) { + t.Errorf("fish snippet missing marker") + } +} + +func TestPathSnippet_IdempotentAtShellStartup(t *testing.T) { + dir := "/tmp/locksmith-bindir-test" + body := shellhook.PathSnippet(shellhook.ShellBash, dir) + "\n" + body += shellhook.PathSnippet(shellhook.ShellBash, dir) + "\n" + body += "echo \"$PATH\"\n" + tmp := filepath.Join(t.TempDir(), "snippet.sh") + if err := os.WriteFile(tmp, []byte(body), 0o644); err != nil { + t.Fatalf("write snippet: %v", err) + } + out, err := exec.Command("sh", tmp).Output() + if err != nil { + t.Fatalf("run sh: %v", err) + } + count := strings.Count(string(out), dir) + if count != 1 { + t.Errorf("dir appears %d times in resulting PATH, want 1: %s", count, string(out)) + } +} + +func TestIsPathInstalled_AbsentFile(t *testing.T) { + ok, err := shellhook.IsPathInstalled(filepath.Join(t.TempDir(), "nonexistent")) + if err != nil || ok { + t.Errorf("IsPathInstalled(missing) = (%v, %v), want (false, nil)", ok, err) + } +} + +func TestIsPathInstalled_NoMarker(t *testing.T) { + rc := filepath.Join(t.TempDir(), ".bashrc") + _ = os.WriteFile(rc, []byte("# unrelated\nexport FOO=bar\n"), 0o644) + ok, _ := shellhook.IsPathInstalled(rc) + if ok { + t.Error("IsPathInstalled returned true for rc without marker") + } +} + +func TestIsPathInstalled_MarkerPresent(t *testing.T) { + rc := filepath.Join(t.TempDir(), ".bashrc") + _ = os.WriteFile(rc, []byte("# stuff\n"+shellhook.PathSnippetMarker+"\n"), 0o644) + ok, err := shellhook.IsPathInstalled(rc) + if err != nil || !ok { + t.Errorf("IsPathInstalled = (%v, %v), want (true, nil)", ok, err) + } +} + +func TestInstallPath_DoesNotCreateMissingRC(t *testing.T) { + rc := filepath.Join(t.TempDir(), ".bashrc") // does not exist + err := shellhook.InstallPath(rc, shellhook.ShellBash, "/opt/locksmith/bin") + if err == nil { + t.Error("InstallPath on missing rc should return an error, not silently create it") + } + if _, statErr := os.Stat(rc); statErr == nil { + t.Error("InstallPath created the rc file; it must not") + } +} + +func TestInstallPath_AppendsPreservingContent(t *testing.T) { + rc := filepath.Join(t.TempDir(), ".bashrc") + original := "# user content\nexport USER_VAR=1\n" + _ = os.WriteFile(rc, []byte(original), 0o644) + if err := shellhook.InstallPath(rc, shellhook.ShellBash, "/opt/locksmith/bin"); err != nil { + t.Fatalf("InstallPath: %v", err) + } + data, _ := os.ReadFile(rc) + if !strings.HasPrefix(string(data), original) { + t.Errorf("user content was modified; got %q", string(data)) + } + if !strings.Contains(string(data), shellhook.PathSnippetMarker) { + t.Errorf("snippet not appended") + } +} + +func TestInstallPath_Fish_WritesToFishRC(t *testing.T) { + dir := filepath.Join(t.TempDir(), ".config", "fish") + _ = os.MkdirAll(dir, 0o755) + rc := filepath.Join(dir, "config.fish") + _ = os.WriteFile(rc, []byte("# fish user content\n"), 0o644) + if err := shellhook.InstallPath(rc, shellhook.ShellFish, "/opt/locksmith/bin"); err != nil { + t.Fatalf("InstallPath: %v", err) + } + data, _ := os.ReadFile(rc) + if !strings.Contains(string(data), "not contains -- /opt/locksmith/bin $PATH") { + t.Errorf("fish-form snippet missing: %q", string(data)) + } +} From b63338ddfb8db87802811653773b177f9511f5bc Mon Sep 17 00:00:00 2001 From: Lorem Dev Date: Sat, 23 May 2026 00:09:10 +0200 Subject: [PATCH 16/25] feat(init): native session-ensure --hook with legacy migration and daemon auto-restart Replace the shell-script Claude Code UserPromptSubmit hook with a native subcommand and migrate existing installs. - locksmith session ensure --hook emits {\"env\":{\"LOCKSMITH_SESSION\":\"...\"}} on stdout via encoding/json. No printf, no \$(...) - eliminates the quoting drift that surfaced as Bash(export LOCKSMITH...) lines in agent logs. Exits 0 silently when the daemon is unreachable so the hook never blocks a prompt; mutually exclusive with --quiet. - ClaudeHookInstaller switches the registered command to 'locksmith session ensure --hook'. The previously added Bash() permission rule is gone; hooks run through Claude's hook subsystem, not Bash, so the rule was redundant. - Legacy installs with command pointing at the deprecated ~/.config/locksmith/agent-hook.sh script are migrated transparently: the command is rewritten, the matching Bash(.../agent-hook.sh) permission rule is dropped, and the on-disk script is removed. - applyDaemonRestart at end of applyInit restarts a running daemon so plugin/config changes take effect immediately. Best-effort: warning on failure, init does not fail. - Cross-package lint fixes from the new wiring. The earlier '_agent-hook' subcommand attempt was discarded; this flag-on-existing-subcommand approach uses the existing 'session ensure' surface and is cleaner. --- CHANGES.md | 15 ++ PLUGINS.md | 7 +- README.md | 9 +- docs/agent-integration.md | 122 ++++----- docs/configuration.md | 31 +++ docs/restart.md | 36 +++ internal/cli/autostart_cmd.go | 2 +- internal/cli/autostart_zombie_test.go | 55 ++--- internal/cli/restart_cmd.go | 13 +- internal/cli/session_cmd.go | 91 +++++-- internal/cli/session_hook_test.go | 103 ++++++++ internal/daemon/lifecycle.go | 11 +- internal/daemon/peerpid_darwin.go | 12 +- internal/daemon/peerpid_linux.go | 12 +- internal/initflow/agents.go | 4 + internal/initflow/agents_test.go | 21 ++ internal/initflow/flow.go | 41 +++- internal/initflow/flow_test.go | 28 ++- internal/initflow/hooks.go | 157 ++++++++---- internal/initflow/hooks_test.go | 232 ++++++++++-------- .../templates/agent_instructions.md.tmpl | 24 +- internal/initflow/templates/agents_md.md.tmpl | 31 +-- .../initflow/templates/claude_hook.sh.tmpl | 10 - internal/initflow/templates/claude_md.md.tmpl | 15 +- .../initflow/templates/claude_skill.md.tmpl | 15 +- .../initflow/templates/codex_agents.md.tmpl | 14 +- internal/initflow/templates/gemini_md.md.tmpl | 21 +- 27 files changed, 724 insertions(+), 408 deletions(-) create mode 100644 docs/restart.md create mode 100644 internal/cli/session_hook_test.go delete mode 100644 internal/initflow/templates/claude_hook.sh.tmpl diff --git a/CHANGES.md b/CHANGES.md index 06e7622..93b193a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -34,6 +34,21 @@ external AI agent tooling (CodeGraph MCP server, obra/superpowers skill pack) are listed there instead. The `check-licenses` skill and `CONTRIBUTING.md` were updated to match. +- Switch the Claude Code UserPromptSubmit hook from a shell script to + the native `locksmith session ensure --hook` invocation. The flag + produces a JSON env block via `encoding/json` (no `printf`, no + `$(...)`). +- Auto-migrate existing installs: legacy `agent-hook.sh` references in + `~/.claude/settings.json` are rewritten in place on the next + `locksmith init`, the matching `Bash(.../agent-hook.sh)` permission + rule is dropped, and the on-disk script is removed. +- Drop `export LOCKSMITH_SESSION=$(...)` instructions from every agent + instruction template. `locksmith get` auto-starts a session when + `LOCKSMITH_SESSION` is unset and the Claude hook injects it + preemptively, so the export was redundant and led agents to copy + the literal `$(...)` syntax into Bash tool calls. +- `locksmith init` now automatically restarts a running daemon at the + end of its run so plugin/config changes take effect immediately. ## Version v0.3.0 - 2026-05-15 diff --git a/PLUGINS.md b/PLUGINS.md index adcfbb7..f13d19f 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -1,8 +1,9 @@ # Locksmith Plugins -Locksmith ships with built-in vault plugins (`gopass`, `keychain`) and -`locksmith-pinentry` embedded inside the `locksmith` binary as a per-platform -zip. One `go install` is enough - no separate plugin install step. +Locksmith ships with built-in vault plugins (`gopass`, `keychain`, +`1password`) and `locksmith-pinentry` embedded inside the `locksmith` +binary as a per-platform zip. One `go install` is enough - no separate +plugin install step. ## Lockstep versioning diff --git a/README.md b/README.md index 5186f99..0526ffc 100644 --- a/README.md +++ b/README.md @@ -214,10 +214,11 @@ platform-specific setup (Claude Code hooks, Gemini CLI, Cursor, Copilot, Codex). For Claude Code, run `locksmith init` - the `UserPromptSubmit` hook is -installed automatically and injects `LOCKSMITH_SESSION` before each prompt. -`init` also adds `Bash(locksmith:*)` to `permissions.allow` so the hook and -`locksmith get` calls run without an extra approval dialog. Restart Claude -Code after running `init`. +installed automatically. The hook command is `locksmith session ensure +--hook` (a native invocation - no shell script), and `init` also adds +`Bash(locksmith:*)` to `permissions.allow`, augments your shell PATH +if needed, and restarts any running daemon. Restart Claude Code after +running `init`. **Quick start:** diff --git a/docs/agent-integration.md b/docs/agent-integration.md index fe9c8a7..d216228 100644 --- a/docs/agent-integration.md +++ b/docs/agent-integration.md @@ -70,65 +70,75 @@ each sub-agent must obtain its own session independently. ## Claude Code Hook Setup -Run `locksmith init` - the hook is installed automatically when Claude Code is -detected. `init` performs three actions: - -1. Writes the hook script to `~/.config/locksmith/agent-hook.sh`. The script - is **always** regenerated from the embedded template on every `init`, so - it stays in sync after a locksmith upgrade even when the settings entries - are already in place. -2. Registers a `UserPromptSubmit` hook in `~/.claude/settings.json` that - injects `LOCKSMITH_SESSION` before each prompt. -3. Adds two entries to `permissions.allow` so the hook and any - `locksmith` invocations run without an extra approval dialog: - - `Bash(locksmith:*)` - matches every `locksmith` subcommand the agent - runs directly via the Bash tool. - - `Bash()` - explicitly allows the hook - script itself. - -All edits are idempotent: re-running `init` will not duplicate entries. If -any piece is missing on an existing install, `init` tops it up. - -After installation, restart Claude Code for the hook to take effect. +Run `locksmith init` - the hook is installed automatically when Claude +Code is detected. `init` performs five actions: + +1. Registers a `UserPromptSubmit` hook in `~/.claude/settings.json` + that invokes `locksmith session ensure --hook`. The command emits + the JSON env block via `encoding/json`; there is no shell script + intermediary. +2. Adds `Bash(locksmith:*)` to `permissions.allow` so any direct + `locksmith` invocation the agent makes through the Bash tool runs + without an extra approval dialog. The hook itself runs through + Claude Code's hook subsystem, not Bash, so it does not need a + separate permission entry. +3. Appends a PATH-augmentation block to the user's shell rc file + (`~/.zshrc`, `~/.bashrc`, or `~/.config/fish/config.fish`) when the + locksmith binary directory is not already in `$PATH`. The snippet + is idempotent at shell-startup time and locksmith never creates a + shell rc file that does not already exist. +4. Migrates any pre-existing reference to the deprecated + `~/.config/locksmith/agent-hook.sh` script: the `command` value in + settings.json is rewritten to the native invocation, the matching + `Bash(.../agent-hook.sh)` permission rule is removed, and the + on-disk script is deleted. +5. Restarts any currently-running locksmith daemon so newly installed + plugins and config changes take effect immediately. If no daemon + is running, the next agent prompt or shell startup will spawn one. + +All five actions are idempotent: re-running `init` will not duplicate +entries. After `init`, restart any running AI agent so it picks up the +updated instructions. ### Manual setup -If you prefer to install the hook without using `init`: - -1. Run `locksmith init --agent claude` to write the hook script to - `~/.config/locksmith/agent-hook.sh` without going through the full wizard. - -2. Add to `~/.claude/settings.json` (merge with existing content, do not - overwrite): - - ```json - { - "hooks": { - "UserPromptSubmit": [ - { - "matcher": "", - "hooks": [ - { - "type": "command", - "command": "/Users//.config/locksmith/agent-hook.sh" - } - ] - } - ] - }, - "permissions": { - "allow": [ - "Bash(locksmith:*)", - "Bash(/Users//.config/locksmith/agent-hook.sh)" - ] - } - } - ``` - -The hook exits silently if the Locksmith daemon is not running, so it never -blocks agent work. The two `Bash(...)` permissions pre-approve every -`locksmith` invocation and the hook script itself; remove them to require -explicit confirmation for each secret retrieval. +If you prefer to install the hook without using `init`, add to +`~/.claude/settings.json`: + +```json +{ + "hooks": { + "UserPromptSubmit": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "locksmith session ensure --hook" + } + ] + } + ] + }, + "permissions": { + "allow": ["Bash(locksmith:*)"] + } +} +``` + +Ensure the directory containing `locksmith` is on PATH for non-login +shells. On most systems adding the following to your `~/.zshrc` or +`~/.bashrc` is sufficient: + +```sh +case ":$PATH:" in + *":/your/locksmith/dir:"*) ;; + *) export PATH="$PATH:/your/locksmith/dir" ;; +esac +``` + +The hook exits silently if the locksmith daemon is not running, so it +never blocks agent work. ## Locksmith Config Reference diff --git a/docs/configuration.md b/docs/configuration.md index 98d5eff..8818d03 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -408,6 +408,37 @@ When running as a background daemon, GPG passphrase prompts require --- +## Daemon management + +### `locksmith reload` + +Sends SIGHUP to the running daemon, causing it to re-read +`config.yaml`. Active sessions and secret caches are preserved; an +invalid config is rejected and the previous config remains active. +Plugin processes are delta-synced (new types launched, removed ones +killed cleanly). Use reload for pure-config changes that do not +require a re-bind. + +### `locksmith restart` + +Stops the running daemon and starts a fresh one. Use this after +upgrading the `locksmith` binary or after changes that hot reload +cannot pick up (plugin re-extraction, socket-binding changes). +Hot-reload-eligible config changes are picked up by +`locksmith reload`; restart is the heavier hammer. + +Flags: + +- `--timeout ` (default `5s`) - how long to wait for the old + daemon to release the socket before escalating from SIGTERM to + SIGKILL. +- `--no-start` - stop the daemon but do not start a fresh one. + +`locksmith init` calls the same logic at the end of its run, so a +manual restart is rarely necessary after `init`. + +--- + ## Shell autostart To start the locksmith daemon automatically when you open a terminal, add a diff --git a/docs/restart.md b/docs/restart.md new file mode 100644 index 0000000..87e6035 --- /dev/null +++ b/docs/restart.md @@ -0,0 +1,36 @@ +# locksmith restart + +`locksmith restart` stops the running daemon (if any) and starts a +fresh one. Use it when: + +- You upgraded the `locksmith` binary in place. +- You manually re-installed a vault plugin and need the daemon to + re-discover it. +- The daemon is in a wedged state and `locksmith reload` does not + resolve it. + +For pure-config changes that don't require a re-bind (e.g. adjusting +log level, adding a new `keys:` entry), prefer `locksmith reload` - +it is cheaper and does not interrupt in-flight requests. + +## Behaviour + +1. Probe the locksmith socket + (`~/.config/locksmith/locksmith.sock`, overridable via + `LOCKSMITH_SOCKET`). +2. If no daemon is running: start a fresh one and exit (or exit + immediately if `--no-start` is set). +3. If a daemon is running: send SIGTERM, poll the socket every 50ms + for up to `--timeout` (default 5s). If the socket is still bound, + escalate to SIGKILL with another 2s grace. +4. If the socket is still bound after SIGKILL: exit non-zero. A new + daemon is not started; this prevents an orphan from blocking the + socket. +5. Otherwise: start a fresh `locksmith serve` in the background and + exit. + +## Auto-restart in `init` + +`locksmith init` calls the same logic at the end of its run, so a +manual restart is rarely necessary after `init`. If `init` reports a +restart warning, retry with `locksmith restart`. diff --git a/internal/cli/autostart_cmd.go b/internal/cli/autostart_cmd.go index 9c0bf4f..e2c1e54 100644 --- a/internal/cli/autostart_cmd.go +++ b/internal/cli/autostart_cmd.go @@ -24,7 +24,7 @@ func newAutostartCmd() *cobra.Command { if daemon.IsRunning(socket) { return nil } - _, _ = daemon.Start() // errors swallowed: hooks must not fail + _, _ = daemon.Start() //nolint:errcheck // errors swallowed: hooks must not fail return nil }, } diff --git a/internal/cli/autostart_zombie_test.go b/internal/cli/autostart_zombie_test.go index 2950a95..323b5d0 100644 --- a/internal/cli/autostart_zombie_test.go +++ b/internal/cli/autostart_zombie_test.go @@ -3,7 +3,6 @@ package cli_test import ( - "os" "os/exec" "strconv" "strings" @@ -13,7 +12,10 @@ import ( // TestAutostart_ZombieReaping verifies that the goroutine used in autostart_cmd.go // to call c.Wait() prevents short-lived child processes from becoming zombies. -// It mirrors the production pattern directly and checks ps(1) for Z-state children. +// It mirrors the production pattern directly and checks ps(1) for the spawned +// child specifically (not all children of the test process - the broader test +// suite spawns its own helpers and counting all zombies makes this test flaky +// when other tests have child processes mid-flight). func TestAutostart_ZombieReaping(t *testing.T) { // Spawn a no-op process that exits immediately, simulating a daemon that // fails to start (e.g. due to a missing config file). @@ -21,47 +23,32 @@ func TestAutostart_ZombieReaping(t *testing.T) { if err := c.Start(); err != nil { t.Fatalf("Start() error: %v", err) } + childPID := c.Process.Pid // Reap in a goroutine - this is the exact pattern in autostart_cmd.go. go func() { _ = c.Wait() }() - // Poll ps for up to 1s and confirm no Z-state children of this process. - myPID := os.Getpid() - deadline := time.Now().Add(1 * time.Second) + // Poll ps for up to 5s and confirm our specific child is not in Z state. + deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { - out, err := exec.Command("ps", "-eo", "pid,ppid,stat").Output() + out, err := exec.Command("ps", "-o", "stat=", "-p", strconv.Itoa(childPID)).Output() if err != nil { - t.Fatalf("ps error: %v", err) + // ps returns non-zero when the PID is gone; that means our child + // was reaped and no longer exists - perfect. + return } - if zombies := zombieChildrenOf(string(out), myPID); len(zombies) == 0 { - return // no zombies found - pass + state := strings.TrimSpace(string(out)) + if state == "" { + return // child no longer in ps output + } + if !strings.HasPrefix(state, "Z") { + return // not a zombie - reaped already (e.g. waitpid completed) } time.Sleep(50 * time.Millisecond) } - // Final definitive check after 1s. - out, _ := exec.Command("ps", "-eo", "pid,ppid,stat").Output() - if zombies := zombieChildrenOf(string(out), myPID); len(zombies) > 0 { - t.Errorf("zombie children of pid %d still present after 1s: PIDs %v", myPID, zombies) - } -} - -// zombieChildrenOf returns the PIDs of processes whose ppid equals parentPID and -// whose stat field starts with "Z" (zombie state). -func zombieChildrenOf(psOut string, parentPID int) []int { - var zombies []int - for _, line := range strings.Split(psOut, "\n") { - fields := strings.Fields(line) - if len(fields) < 3 { - continue - } - ppid, err := strconv.Atoi(fields[1]) - if err != nil || ppid != parentPID { - continue - } - if strings.HasPrefix(fields[2], "Z") { - pid, _ := strconv.Atoi(fields[0]) - zombies = append(zombies, pid) - } + // Final definitive check after the deadline. + out, _ := exec.Command("ps", "-o", "stat=", "-p", strconv.Itoa(childPID)).Output() + if state := strings.TrimSpace(string(out)); strings.HasPrefix(state, "Z") { + t.Errorf("child pid %d still in zombie state after 5s: %q", childPID, state) } - return zombies } diff --git a/internal/cli/restart_cmd.go b/internal/cli/restart_cmd.go index 366d409..0fb3369 100644 --- a/internal/cli/restart_cmd.go +++ b/internal/cli/restart_cmd.go @@ -35,30 +35,33 @@ func newRestartCmd() *cobra.Command { running := daemon.IsRunning(socket) if !running { if noStart { - fmt.Fprintln(out, "locksmith daemon was not running; --no-start: nothing to do") + fmt.Fprintln(out, "locksmith daemon was not running; --no-start: nothing to do") //nolint:errcheck return nil } if _, err := daemon.Start(); err != nil { return fmt.Errorf("starting daemon: %w", err) } - fmt.Fprintln(out, "locksmith daemon was not running; started fresh") + fmt.Fprintln(out, "locksmith daemon was not running; started fresh") //nolint:errcheck return nil } if err := daemon.Stop(socket, timeout); err != nil { return err } if noStart { - fmt.Fprintln(out, "locksmith daemon stopped (--no-start)") + fmt.Fprintln(out, "locksmith daemon stopped (--no-start)") //nolint:errcheck // writing to stdout return nil } if _, err := daemon.Start(); err != nil { return fmt.Errorf("starting fresh daemon: %w", err) } - fmt.Fprintln(out, "locksmith daemon restarted") + fmt.Fprintln(out, "locksmith daemon restarted") //nolint:errcheck // writing to stdout return nil }, } - cmd.Flags().DurationVar(&timeout, "timeout", 5*time.Second, "max time to wait for the old daemon to release the socket") + cmd.Flags().DurationVar( + &timeout, "timeout", daemon.DefaultRestartTimeout, + "max time to wait for the old daemon to release the socket", + ) cmd.Flags().BoolVar(&noStart, "no-start", false, "stop the daemon but do not start a fresh one") return cmd } diff --git a/internal/cli/session_cmd.go b/internal/cli/session_cmd.go index 1afadec..440da3b 100644 --- a/internal/cli/session_cmd.go +++ b/internal/cli/session_cmd.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "io" "os" "time" @@ -94,56 +95,94 @@ func newSessionEndCmd() *cobra.Command { // It reuses an existing valid session from LOCKSMITH_SESSION or starts a new one. // Exits non-zero if the daemon is not running. // With --quiet, only the session ID is printed to stdout (for use in hook scripts). +// With --hook, emits a single JSON line {"env":{"LOCKSMITH_SESSION":""}} for +// use as a Claude Code UserPromptSubmit hook. On any error, exits 0 with empty stdout. func newSessionEnsureCmd() *cobra.Command { var quiet bool + var hook bool cmd := &cobra.Command{ Use: "ensure", Short: "Ensure a valid session exists, reusing or creating one", RunE: func(cmd *cobra.Command, args []string) error { - client, conn, err := dialDaemon() - if err != nil { - return err + if quiet && hook { + return fmt.Errorf("--quiet and --hook are mutually exclusive") } - defer conn.Close() //nolint:errcheck // gRPC connection close; error not actionable in defer - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - // Try to reuse the session from environment. - if existing := os.Getenv("LOCKSMITH_SESSION"); existing != "" { - listResp, listErr := client.SessionList(ctx, &locksmithv1.SessionListRequest{}) - if listErr == nil { - for _, s := range listResp.Sessions { - if s.SessionId == existing { - fmt.Fprintln(cmd.OutOrStdout(), existing) //nolint:errcheck // writing to stdout - return nil - } - } - } - // Session not found or expired - fall through to create a new one. + // In hook mode, drain stdin in the background (Claude Code sends a JSON + // prompt payload on stdin that we do not need). + if hook { + go func() { _, _ = io.Copy(io.Discard, cmd.InOrStdin()) }() //nolint:errcheck // hook drains stdin in background; copy errors are not actionable } - resp, err := client.SessionStart(ctx, &locksmithv1.SessionStartRequest{}) + sessionID, expiresAt, err := ensureSession() if err != nil { - return fmt.Errorf("starting session: %w", err) + if hook { + // Hook must never block agent work: exit 0 silently. + return nil + } + return err } - if quiet { - fmt.Fprintln(cmd.OutOrStdout(), resp.SessionId) //nolint:errcheck // writing to stdout - } else { + switch { + case hook: + out, jsonErr := json.Marshal(map[string]map[string]string{ + "env": {"LOCKSMITH_SESSION": sessionID}, + }) + if jsonErr != nil { + // Should never happen, but treat as silent failure in hook mode. + return nil + } + fmt.Fprintln(cmd.OutOrStdout(), string(out)) //nolint:errcheck // writing to stdout + case quiet: + fmt.Fprintln(cmd.OutOrStdout(), sessionID) //nolint:errcheck // writing to stdout + default: fmt.Fprintf( //nolint:errcheck // writing to stderr cmd.ErrOrStderr(), "locksmith: session started (expires %s)\n export LOCKSMITH_SESSION=%s\n", - resp.ExpiresAt, resp.SessionId) - fmt.Fprintln(cmd.OutOrStdout(), resp.SessionId) //nolint:errcheck // writing to stdout + expiresAt, sessionID) + fmt.Fprintln(cmd.OutOrStdout(), sessionID) //nolint:errcheck // writing to stdout } return nil }, } cmd.Flags().BoolVar(&quiet, "quiet", false, "print only the session ID (for use in scripts)") + cmd.Flags().BoolVar(&hook, "hook", false, "emit JSON env line for use as a Claude Code hook") return cmd } +// ensureSession dials the daemon, reuses an existing valid session from +// LOCKSMITH_SESSION, or starts a new one. Returns the session ID and expiry +// on success. When a session is reused, expiresAt is empty. +func ensureSession() (sessionID, expiresAt string, err error) { + client, conn, dialErr := dialDaemon() + if dialErr != nil { + return "", "", dialErr + } + defer conn.Close() //nolint:errcheck // gRPC connection close; error not actionable in defer + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Try to reuse the session from environment. + if existing := os.Getenv("LOCKSMITH_SESSION"); existing != "" { + listResp, listErr := client.SessionList(ctx, &locksmithv1.SessionListRequest{}) + if listErr == nil { + for _, s := range listResp.Sessions { + if s.SessionId == existing { + return existing, "", nil + } + } + } + // Session not found or expired - fall through to create a new one. + } + + resp, startErr := client.SessionStart(ctx, &locksmithv1.SessionStartRequest{}) + if startErr != nil { + return "", "", fmt.Errorf("starting session: %w", startErr) + } + return resp.SessionId, resp.ExpiresAt, nil +} + // newSessionListCmd returns the `locksmith session list` command. func newSessionListCmd() *cobra.Command { return &cobra.Command{ diff --git a/internal/cli/session_hook_test.go b/internal/cli/session_hook_test.go new file mode 100644 index 0000000..afc3ab0 --- /dev/null +++ b/internal/cli/session_hook_test.go @@ -0,0 +1,103 @@ +package cli_test + +import ( + "bytes" + "encoding/json" + "io" + "strings" + "testing" + + locksmithv1 "github.com/lorem-dev/locksmith/gen/proto/locksmith/v1" + "github.com/lorem-dev/locksmith/internal/cli" +) + +func TestSessionEnsureHook_OutputsJSON(t *testing.T) { + srv := &mockServer{ + sessionStartResp: &locksmithv1.SessionStartResponse{ + SessionId: "sess_abc", + ExpiresAt: "2030-01-01T00:00:00Z", + }, + } + socketPath, cleanup := startMockDaemon(t, srv) + defer cleanup() + t.Setenv("LOCKSMITH_SOCKET", socketPath) + t.Setenv("LOCKSMITH_SESSION", "") + + var stdout bytes.Buffer + root := cli.NewRootCmd() + root.SetOut(&stdout) + root.SetErr(io.Discard) + root.SetArgs([]string{"session", "ensure", "--hook"}) + if err := root.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + + var got struct { + Env map[string]string `json:"env"` + } + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("invalid JSON: %v (%q)", err, stdout.String()) + } + if got.Env["LOCKSMITH_SESSION"] != "sess_abc" { + t.Errorf("env[LOCKSMITH_SESSION] = %q, want sess_abc", got.Env["LOCKSMITH_SESSION"]) + } +} + +func TestSessionEnsureHook_DaemonDown_ExitsZeroSilent(t *testing.T) { + t.Setenv("LOCKSMITH_SOCKET", "/tmp/locksmith-absent-hook-test.sock") + t.Setenv("LOCKSMITH_SESSION", "") + + var stdout, stderr bytes.Buffer + root := cli.NewRootCmd() + root.SetOut(&stdout) + root.SetErr(&stderr) + root.SetArgs([]string{"session", "ensure", "--hook"}) + if err := root.Execute(); err != nil { + t.Fatalf("Execute returned %v, want nil when daemon is down", err) + } + if stdout.Len() != 0 { + t.Errorf("stdout = %q, want empty when daemon is down", stdout.String()) + } +} + +func TestSessionEnsureHook_QuietConflict(t *testing.T) { + var stdout, stderr bytes.Buffer + root := cli.NewRootCmd() + root.SetOut(&stdout) + root.SetErr(&stderr) + root.SetArgs([]string{"session", "ensure", "--quiet", "--hook"}) + err := root.Execute() + if err == nil { + t.Fatal("Execute returned nil, want error for --quiet --hook conflict") + } + if !strings.Contains(err.Error(), "mutually exclusive") { + t.Errorf("error = %q, want mention of 'mutually exclusive'", err.Error()) + } +} + +func TestSessionEnsureHook_DrainsStdin(t *testing.T) { + srv := &mockServer{ + sessionStartResp: &locksmithv1.SessionStartResponse{ + SessionId: "sess_xyz", + ExpiresAt: "2030-01-01T00:00:00Z", + }, + } + socketPath, cleanup := startMockDaemon(t, srv) + defer cleanup() + t.Setenv("LOCKSMITH_SOCKET", socketPath) + t.Setenv("LOCKSMITH_SESSION", "") + + bigInput := strings.Repeat("x", 1<<20) + var stdout bytes.Buffer + root := cli.NewRootCmd() + root.SetIn(strings.NewReader(bigInput)) + root.SetOut(&stdout) + root.SetErr(io.Discard) + root.SetArgs([]string{"session", "ensure", "--hook"}) + if err := root.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + if !strings.Contains(stdout.String(), "LOCKSMITH_SESSION") { + t.Errorf("output missing LOCKSMITH_SESSION: %q", stdout.String()) + } +} diff --git a/internal/daemon/lifecycle.go b/internal/daemon/lifecycle.go index b89258d..2839f59 100644 --- a/internal/daemon/lifecycle.go +++ b/internal/daemon/lifecycle.go @@ -23,6 +23,9 @@ import ( const ( probeTimeout = 200 * time.Millisecond probeStep = 50 * time.Millisecond + killGrace = 2 * time.Second + // DefaultRestartTimeout is the default wait for old daemon shutdown. + DefaultRestartTimeout = 5 * time.Second ) // DefaultSocketPath returns the well-known locksmith socket path, honouring @@ -38,11 +41,11 @@ func DefaultSocketPath() string { // check is a single dial with a 200ms timeout; it does not exchange any // protocol traffic. func IsRunning(socketPath string) bool { - conn, err := net.DialTimeout("unix", socketPath, probeTimeout) //nolint:gosec // local Unix socket + conn, err := net.DialTimeout("unix", socketPath, probeTimeout) if err != nil { return false } - _ = conn.Close() + _ = conn.Close() //nolint:errcheck // probe connection; close error not actionable return true } @@ -95,7 +98,7 @@ func Stop(socketPath string, timeout time.Duration) error { if err := syscall.Kill(pid, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) { return fmt.Errorf("sending SIGKILL to %d: %w", pid, err) } - if !waitGone(socketPath, 2*time.Second) { + if !waitGone(socketPath, killGrace) { return fmt.Errorf("daemon at %s did not release socket after SIGKILL", socketPath) } return nil @@ -116,7 +119,7 @@ func Restart(opts RestartOptions) error { opts.SocketPath = DefaultSocketPath() } if opts.Timeout == 0 { - opts.Timeout = 5 * time.Second + opts.Timeout = DefaultRestartTimeout } if err := Stop(opts.SocketPath, opts.Timeout); err != nil { return err diff --git a/internal/daemon/peerpid_darwin.go b/internal/daemon/peerpid_darwin.go index 2af7480..f7c2ea2 100644 --- a/internal/daemon/peerpid_darwin.go +++ b/internal/daemon/peerpid_darwin.go @@ -4,6 +4,7 @@ package daemon import ( "errors" + "fmt" "net" "golang.org/x/sys/unix" @@ -14,9 +15,9 @@ import ( // an int via getsockopt; we invoke it inside raw.Control so the fd is // not concurrently closed by the Go runtime. func peerPID(socketPath string) (int, error) { - c, err := net.Dial("unix", socketPath) //nolint:gosec // local Unix socket + c, err := net.Dial("unix", socketPath) if err != nil { - return 0, err + return 0, fmt.Errorf("dialing unix socket: %w", err) } defer c.Close() //nolint:errcheck uc, ok := c.(*net.UnixConn) @@ -25,19 +26,20 @@ func peerPID(socketPath string) (int, error) { } raw, err := uc.SyscallConn() if err != nil { - return 0, err + return 0, fmt.Errorf("getting raw connection: %w", err) } var ( pid int inner error ) if cerr := raw.Control(func(fd uintptr) { + //nolint:gosec // fd is a syscall.RawConn descriptor, fits in int on supported platforms pid, inner = unix.GetsockoptInt(int(fd), unix.SOL_LOCAL, unix.LOCAL_PEERPID) }); cerr != nil { - return 0, cerr + return 0, fmt.Errorf("raw.Control: %w", cerr) } if inner != nil { - return 0, inner + return 0, fmt.Errorf("getsockopt LOCAL_PEERPID: %w", inner) } return pid, nil } diff --git a/internal/daemon/peerpid_linux.go b/internal/daemon/peerpid_linux.go index 71cdc86..5191b5c 100644 --- a/internal/daemon/peerpid_linux.go +++ b/internal/daemon/peerpid_linux.go @@ -4,6 +4,7 @@ package daemon import ( "errors" + "fmt" "net" "golang.org/x/sys/unix" @@ -12,9 +13,9 @@ import ( // peerPID returns the PID of the process listening on socketPath, using // SO_PEERCRED on Linux. func peerPID(socketPath string) (int, error) { - c, err := net.Dial("unix", socketPath) //nolint:gosec // local Unix socket + c, err := net.Dial("unix", socketPath) if err != nil { - return 0, err + return 0, fmt.Errorf("dialing unix socket: %w", err) } defer c.Close() //nolint:errcheck uc, ok := c.(*net.UnixConn) @@ -23,19 +24,20 @@ func peerPID(socketPath string) (int, error) { } raw, err := uc.SyscallConn() if err != nil { - return 0, err + return 0, fmt.Errorf("getting raw connection: %w", err) } var ( ucred *unix.Ucred inner error ) if cerr := raw.Control(func(fd uintptr) { + //nolint:gosec // fd is a syscall.RawConn descriptor, fits in int on supported platforms ucred, inner = unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED) }); cerr != nil { - return 0, cerr + return 0, fmt.Errorf("raw.Control: %w", cerr) } if inner != nil { - return 0, inner + return 0, fmt.Errorf("getsockopt SO_PEERCRED: %w", inner) } return int(ucred.Pid), nil } diff --git a/internal/initflow/agents.go b/internal/initflow/agents.go index 9fc78ed..3aea10e 100644 --- a/internal/initflow/agents.go +++ b/internal/initflow/agents.go @@ -96,6 +96,10 @@ func (w *AgentWriter) installGeneric() error { return nil } +// MustReadTemplateForTest exposes the embedded template reader for +// external tests; production callers use the unexported version. +func MustReadTemplateForTest(name string) []byte { return mustReadTemplate(name) } + const ( locksmithBlockStart = "" locksmithBlockEnd = "" diff --git a/internal/initflow/agents_test.go b/internal/initflow/agents_test.go index 56c507e..06058d0 100644 --- a/internal/initflow/agents_test.go +++ b/internal/initflow/agents_test.go @@ -1,6 +1,7 @@ package initflow_test import ( + "bytes" "encoding/json" "os" "path/filepath" @@ -380,3 +381,23 @@ func TestInstall_OpenCode_InstructionsContainsBothGetSyntaxes(t *testing.T) { t.Error("agent_instructions template missing --path syntax") } } + +func TestTemplates_NoExportSnippet(t *testing.T) { + files := []string{ + "templates/agent_instructions.md.tmpl", + "templates/agents_md.md.tmpl", + "templates/claude_md.md.tmpl", + "templates/claude_skill.md.tmpl", + "templates/codex_agents.md.tmpl", + "templates/gemini_md.md.tmpl", + } + for _, f := range files { + data := initflow.MustReadTemplateForTest(f) + if bytes.Contains(data, []byte("export LOCKSMITH_SESSION")) { + t.Errorf( + "%s still contains 'export LOCKSMITH_SESSION'; templates must not instruct agents to export the session", + f, + ) + } + } +} diff --git a/internal/initflow/flow.go b/internal/initflow/flow.go index 2326e99..d450f57 100644 --- a/internal/initflow/flow.go +++ b/internal/initflow/flow.go @@ -16,6 +16,7 @@ import ( "github.com/lorem-dev/locksmith/internal/bundled" "github.com/lorem-dev/locksmith/internal/config" + "github.com/lorem-dev/locksmith/internal/daemon" "github.com/lorem-dev/locksmith/internal/log" "github.com/lorem-dev/locksmith/internal/shellhook" ) @@ -431,6 +432,7 @@ func applyInit(result *InitResult, homeDir string, prompter Prompter, auto bool) applyShellHook(result) applyPathHook(result, homeDir) + applyDaemonRestart(result) return nil } @@ -502,13 +504,7 @@ func applyClaudeHook(result *InitResult, homeDir string) error { ) switch { case result.ClaudeHookAlreadyPresent: - // Settings.json entries are already in place, but the hook script - // itself must be regenerated so it stays in sync with the embedded - // template after a locksmith upgrade. - if err := installer.RefreshScript(); err != nil { - return fmt.Errorf("refreshing Claude Code hook script: %w", err) - } - fmt.Printf(" Claude Code: hook already present in %s (script refreshed)\n", + fmt.Printf(" Claude Code: hook already present in %s\n", fmtPaths.Sprint("~/.claude/settings.json")) case result.ClaudeHookConfirmed: if err := installer.Install(); err != nil { @@ -517,14 +513,17 @@ func applyClaudeHook(result *InitResult, homeDir string) error { result.ClaudeHookInstalled = true fmt.Println(" Claude Code: hook installed in ~/.claude/settings.json") fmt.Println(" Restart Claude Code for the hook to take effect.") + if installer.LastInstallMigrated() { + result.LegacyHookMigrated = true + fmt.Println(" Claude Code: migrated legacy shell hook to native command") + } default: - hookCmd := filepath.Join(homeDir, ".config", "locksmith", "agent-hook.sh") fmt.Printf("\n To install the Claude Code hook manually:\n") fmt.Printf(" 1. Run: locksmith init --agent claude\n") fmt.Printf(" 2. Add to ~/.claude/settings.json:\n") fmt.Printf( " {\"hooks\":{\"UserPromptSubmit\":[{\"matcher\":\"\",\"hooks\":[{\"type\":\"command\",\"command\":%q}]}]}}\n", - hookCmd, + nativeHookCmd, ) } return nil @@ -968,6 +967,30 @@ func isTerminal() bool { return fi.Mode()&os.ModeCharDevice != 0 } +// applyDaemonRestart restarts a currently-running locksmith daemon so +// the just-installed plugin bundle, config changes, and shell-rc +// updates take effect immediately. If no daemon is running it does +// nothing - the next agent prompt or shell startup will spawn one via +// the autostart shellhook. +// +// On restart failure this prints a warning rather than failing init; +// init has already written every config/file successfully and the +// user can recover with `locksmith restart` manually. +func applyDaemonRestart(result *InitResult) { + socket := daemon.DefaultSocketPath() + if !daemon.IsRunning(socket) { + return + } + if err := daemon.Restart(daemon.RestartOptions{Timeout: daemon.DefaultRestartTimeout}); err != nil { + fmt.Fprintf(os.Stderr, "warning: failed to restart daemon: %v\n", err) + return + } + result.DaemonRestarted = true +} + +// ApplyDaemonRestartForTest is an export-for-test seam. +func ApplyDaemonRestartForTest(result *InitResult) { applyDaemonRestart(result) } + // printShellFallback prints manual instructions for adding the shell hook. func printShellFallback(s shellhook.Shell, rcFile string) { snippet := shellhook.Snippet(s) diff --git a/internal/initflow/flow_test.go b/internal/initflow/flow_test.go index 9aff225..47a5023 100644 --- a/internal/initflow/flow_test.go +++ b/internal/initflow/flow_test.go @@ -222,17 +222,12 @@ func TestRunInit_Auto_InstallsClaudeHook(t *testing.T) { t.Error("expected ClaudeHookInstalled or ClaudeHookAlreadyPresent to be true in --auto mode") } - scriptPath := filepath.Join(home, ".config", "locksmith", "agent-hook.sh") - if _, statErr := os.Stat(scriptPath); statErr != nil { - t.Errorf("hook script not written to %s: %v", scriptPath, statErr) - } - data, err := os.ReadFile(filepath.Join(home, ".claude", "settings.json")) if err != nil { t.Fatalf("settings.json not created: %v", err) } - if !strings.Contains(string(data), "agent-hook.sh") { - t.Error("settings.json does not reference agent-hook.sh") + if !strings.Contains(string(data), "locksmith session ensure --hook") { + t.Error("settings.json does not reference the native hook command") } } @@ -292,23 +287,19 @@ func TestRunInit_HookAlreadyPresent(t *testing.T) { os.MkdirAll(claudeDir, 0o755) os.MkdirAll(lsDir, 0o755) - hookCmd := filepath.Join(lsDir, "agent-hook.sh") settings := map[string]any{ "hooks": map[string]any{ "UserPromptSubmit": []any{ map[string]any{ "matcher": "", "hooks": []any{ - map[string]any{"type": "command", "command": hookCmd}, + map[string]any{"type": "command", "command": "locksmith session ensure --hook"}, }, }, }, }, "permissions": map[string]any{ - "allow": []any{ - "Bash(locksmith:*)", - "Bash(" + hookCmd + ")", - }, + "allow": []any{"Bash(locksmith:*)"}, }, } data, _ := json.Marshal(settings) @@ -993,3 +984,14 @@ func TestApplyPathHook_Idempotent(t *testing.T) { t.Errorf("marker appears %d times after two runs, want 1", count) } } + +func TestApplyDaemonRestart_NoDaemon_Skips(t *testing.T) { + socket := filepath.Join(t.TempDir(), "ls.sock") + t.Setenv("LOCKSMITH_SOCKET", socket) + + res := &initflow.InitResult{} + initflow.ApplyDaemonRestartForTest(res) + if res.DaemonRestarted { + t.Error("DaemonRestarted = true with no daemon; want false") + } +} diff --git a/internal/initflow/hooks.go b/internal/initflow/hooks.go index 0f3160c..0ce784f 100644 --- a/internal/initflow/hooks.go +++ b/internal/initflow/hooks.go @@ -2,7 +2,9 @@ package initflow import ( "encoding/json" + "errors" "fmt" + "io/fs" "os" "path/filepath" ) @@ -12,11 +14,18 @@ import ( // hook and `locksmith get` calls do not trigger an extra approval dialog. const locksmithPermissionRule = "Bash(locksmith:*)" +// nativeHookCmd is the command string written to +// ~/.claude/settings.json. It relies on PATH; init's applyPathHook +// ensures the locksmith binary directory is on PATH (in the user's +// shell rc file). +const nativeHookCmd = "locksmith session ensure --hook" + // ClaudeHookInstaller installs the Locksmith UserPromptSubmit hook into // the global Claude Code settings file (~/.claude/settings.json). type ClaudeHookInstaller struct { locksmithConfigDir string // ~/.config/locksmith claudeConfigDir string // ~/.claude + lastMigrated bool } // NewClaudeHookInstaller creates a ClaudeHookInstaller for the given directories. @@ -27,7 +36,11 @@ func NewClaudeHookInstaller(locksmithConfigDir, claudeConfigDir string) *ClaudeH } } -func (h *ClaudeHookInstaller) hookCmd() string { +func (h *ClaudeHookInstaller) hookCmd() string { return nativeHookCmd } + +// legacyScriptPath returns the on-disk location of the deprecated +// shell-script hook for migration purposes. +func (h *ClaudeHookInstaller) legacyScriptPath() string { return filepath.Join(h.locksmithConfigDir, "agent-hook.sh") } @@ -55,47 +68,13 @@ func (h *ClaudeHookInstaller) IsInstalled() bool { return len(h.missingPermissionRules(settings, h.requiredPermissionRules())) == 0 } -// Install writes the hook script to ~/.config/locksmith/agent-hook.sh and -// merges the UserPromptSubmit entry plus the locksmith permission rules into -// ~/.claude/settings.json. It is idempotent: calling Install twice produces -// the same result as calling it once. The hook script is always overwritten -// so it stays in sync with the latest embedded template. +// Install merges the UserPromptSubmit hook entry and the locksmith +// permission rule into ~/.claude/settings.json. Idempotent: calling +// Install twice produces the same result as calling it once. func (h *ClaudeHookInstaller) Install() error { - if err := h.RefreshScript(); err != nil { - return err - } return h.mergeSettings() } -// RefreshScript rewrites the hook script unconditionally. Call this on every -// `locksmith init` so the script is regenerated from the embedded template -// even when the settings.json entries are already in place. -func (h *ClaudeHookInstaller) RefreshScript() error { - if err := h.writeScript(); err != nil { - return fmt.Errorf("writing hook script: %w", err) - } - return nil -} - -func (h *ClaudeHookInstaller) writeScript() error { - if err := os.MkdirAll( //nolint:gosec // G301: 0755 is standard for user config dirs - h.locksmithConfigDir, - 0o755, - ); err != nil { - return fmt.Errorf("creating config dir: %w", err) - } - scriptContent, err := templates.ReadFile("templates/claude_hook.sh.tmpl") - if err != nil { - return fmt.Errorf("reading hook template: %w", err) - } - if err := os.WriteFile( //nolint:gosec // G306: hook script must be executable - h.hookCmd(), scriptContent, 0o755, - ); err != nil { - return fmt.Errorf("writing hook script: %w", err) - } - return nil -} - func (h *ClaudeHookInstaller) mergeSettings() error { if err := os.MkdirAll( //nolint:gosec // G301: 0755 is standard for user home dirs h.claudeConfigDir, @@ -121,6 +100,8 @@ func (h *ClaudeHookInstaller) mergeSettings() error { settings = make(map[string]any) } + h.lastMigrated = h.migrateLegacyShellHook(settings) + hookAlreadyInstalled := h.findHookCmd(settings, h.hookCmd()) requiredRules := h.requiredPermissionRules() missingRules := h.missingPermissionRules(settings, requiredRules) @@ -178,15 +159,99 @@ func (h *ClaudeHookInstaller) mergeSettings() error { return nil } -// requiredPermissionRules returns the Claude Code permission allow-list -// entries that the locksmith integration needs. Both rules together ensure -// the UserPromptSubmit hook script and any direct `locksmith` invocation by -// the agent run without an additional approval dialog. -func (h *ClaudeHookInstaller) requiredPermissionRules() []string { - return []string{ - locksmithPermissionRule, - "Bash(" + h.hookCmd() + ")", +// migrateLegacyShellHook rewrites any settings.json references to the +// deprecated agent-hook.sh script under h.locksmithConfigDir, drops +// the matching Bash(/agent-hook.sh) permission rule, and +// removes the on-disk script. Third-party hook scripts that happen +// to be named agent-hook.sh but live outside h.locksmithConfigDir +// are left untouched. +// +// Returns true if any change was made; the caller propagates this +// into InitResult.LegacyHookMigrated for the init summary. +func (h *ClaudeHookInstaller) migrateLegacyShellHook(settings map[string]any) bool { + cmdChanged := h.migrateHookCommands(settings) + permChanged := h.stripLegacyPermission(settings) + changed := cmdChanged || permChanged + if changed { + if rmErr := os.Remove(h.legacyScriptPath()); rmErr != nil && !errors.Is(rmErr, fs.ErrNotExist) { + _ = rmErr // best-effort cleanup + } } + return changed +} + +// migrateHookCommands rewrites legacy `command:` strings in the +// UserPromptSubmit hook entries to nativeHookCmd. Returns true if any +// rewrite happened. +func (h *ClaudeHookInstaller) migrateHookCommands(settings map[string]any) bool { + legacy := h.legacyScriptPath() + hooks, ok := settings["hooks"].(map[string]any) + if !ok { + return false + } + ups, ok := hooks["UserPromptSubmit"].([]any) + if !ok { + return false + } + changed := false + for _, entry := range ups { + em, ok := entry.(map[string]any) + if !ok { + continue + } + subhooks, _ := em["hooks"].([]any) //nolint:errcheck // optional field; missing key is fine + for _, sh := range subhooks { + shm, ok := sh.(map[string]any) + if !ok { + continue + } + if cmd, ok := shm["command"].(string); ok && cmd == legacy { + shm["command"] = nativeHookCmd + changed = true + } + } + } + return changed +} + +// stripLegacyPermission removes the Bash() allow rule. Returns +// true if a rule was removed. +func (h *ClaudeHookInstaller) stripLegacyPermission(settings map[string]any) bool { + legacyPerm := "Bash(" + h.legacyScriptPath() + ")" + perms, ok := settings["permissions"].(map[string]any) + if !ok { + return false + } + allow, ok := perms["allow"].([]any) + if !ok { + return false + } + filtered := make([]any, 0, len(allow)) + removed := false + for _, item := range allow { + if s, ok := item.(string); ok && s == legacyPerm { + removed = true + continue + } + filtered = append(filtered, item) + } + perms["allow"] = filtered + return removed +} + +// LastInstallMigrated reports whether the most recent Install call +// migrated a legacy shell-script hook. The init wizard uses this to +// print a status line in the summary block. +func (h *ClaudeHookInstaller) LastInstallMigrated() bool { return h.lastMigrated } + +// requiredPermissionRules returns the permissions.allow entries +// locksmith needs in ~/.claude/settings.json. The single rule +// `Bash(locksmith:*)` pre-approves any direct `locksmith` invocation +// made by the agent through the Bash tool. The hook itself runs +// through Claude Code's hook subsystem, not Bash, and therefore needs +// no permission entry. +func (h *ClaudeHookInstaller) requiredPermissionRules() []string { + return []string{locksmithPermissionRule} } // missingPermissionRules returns the subset of required rules not yet present diff --git a/internal/initflow/hooks_test.go b/internal/initflow/hooks_test.go index 790a814..04aad16 100644 --- a/internal/initflow/hooks_test.go +++ b/internal/initflow/hooks_test.go @@ -20,27 +20,15 @@ func makeHookInstaller(t *testing.T) (*initflow.ClaudeHookInstaller, string) { return initflow.NewClaudeHookInstaller(lsDir, claudeDir), home } -func TestClaudeHookInstaller_RefreshScript_OverwritesExistingScript(t *testing.T) { +func TestClaudeHookInstaller_Install_DoesNotWriteScript(t *testing.T) { installer, home := makeHookInstaller(t) lsDir := filepath.Join(home, ".config", "locksmith") - scriptPath := filepath.Join(lsDir, "agent-hook.sh") - - // Pre-seed a stale script that does not contain the canonical command. - os.WriteFile(scriptPath, []byte("#!/bin/sh\necho stale\n"), 0o755) - - if err := installer.RefreshScript(); err != nil { - t.Fatalf("RefreshScript() error: %v", err) - } - - content, err := os.ReadFile(scriptPath) - if err != nil { - t.Fatalf("reading refreshed script: %v", err) - } - if !strings.Contains(string(content), "locksmith session ensure") { - t.Errorf("RefreshScript did not regenerate the script from the template, got: %q", string(content)) + if err := installer.Install(); err != nil { + t.Fatalf("Install: %v", err) } - if strings.Contains(string(content), "echo stale") { - t.Error("RefreshScript left stale content in place") + scriptPath := filepath.Join(lsDir, "agent-hook.sh") + if _, err := os.Stat(scriptPath); err == nil { + t.Error("Install created the legacy script; it must not after the native-hook redesign") } } @@ -68,24 +56,19 @@ func TestClaudeHookInstaller_IsInstalled_FalseWhenHooksAbsent(t *testing.T) { func TestClaudeHookInstaller_IsInstalled_TrueWhenHookAndAllPermissionsPresent(t *testing.T) { installer, home := makeHookInstaller(t) claudeDir := filepath.Join(home, ".claude") - lsDir := filepath.Join(home, ".config", "locksmith") - hookCmd := filepath.Join(lsDir, "agent-hook.sh") settings := map[string]any{ "hooks": map[string]any{ "UserPromptSubmit": []any{ map[string]any{ "matcher": "", "hooks": []any{ - map[string]any{"type": "command", "command": hookCmd}, + map[string]any{"type": "command", "command": "locksmith session ensure --hook"}, }, }, }, }, "permissions": map[string]any{ - "allow": []any{ - "Bash(locksmith:*)", - "Bash(" + hookCmd + ")", - }, + "allow": []any{"Bash(locksmith:*)"}, }, } data, _ := json.Marshal(settings) @@ -96,32 +79,9 @@ func TestClaudeHookInstaller_IsInstalled_TrueWhenHookAndAllPermissionsPresent(t } } -func TestClaudeHookInstaller_Install_WritesHookScript(t *testing.T) { - installer, home := makeHookInstaller(t) - lsDir := filepath.Join(home, ".config", "locksmith") - - if err := installer.Install(); err != nil { - t.Fatalf("Install() error: %v", err) - } - - scriptPath := filepath.Join(lsDir, "agent-hook.sh") - info, err := os.Stat(scriptPath) - if err != nil { - t.Fatalf("hook script not created at %s: %v", scriptPath, err) - } - if info.Mode()&0o111 == 0 { - t.Error("hook script is not executable") - } - content, _ := os.ReadFile(scriptPath) - if !strings.Contains(string(content), "locksmith session ensure") { - t.Error("hook script missing expected locksmith command") - } -} - func TestClaudeHookInstaller_Install_CreatesSettingsJson(t *testing.T) { installer, home := makeHookInstaller(t) claudeDir := filepath.Join(home, ".claude") - lsDir := filepath.Join(home, ".config", "locksmith") if err := installer.Install(); err != nil { t.Fatalf("Install() error: %v", err) @@ -143,7 +103,6 @@ func TestClaudeHookInstaller_Install_CreatesSettingsJson(t *testing.T) { if len(ups) == 0 { t.Fatal("UserPromptSubmit array is empty") } - hookCmd := filepath.Join(lsDir, "agent-hook.sh") found := false for _, entry := range ups { em, _ := entry.(map[string]any) @@ -156,21 +115,19 @@ func TestClaudeHookInstaller_Install_CreatesSettingsJson(t *testing.T) { if shm == nil { continue } - if cmd, _ := shm["command"].(string); cmd == hookCmd { + if cmd, _ := shm["command"].(string); cmd == "locksmith session ensure --hook" { found = true } } } if !found { - t.Errorf("hook command %q not found in settings.json", hookCmd) + t.Errorf("hook command %q not found in settings.json", "locksmith session ensure --hook") } } func TestClaudeHookInstaller_Install_AddsPermissionRules(t *testing.T) { installer, home := makeHookInstaller(t) claudeDir := filepath.Join(home, ".claude") - lsDir := filepath.Join(home, ".config", "locksmith") - hookCmd := filepath.Join(lsDir, "agent-hook.sh") if err := installer.Install(); err != nil { t.Fatalf("Install() error: %v", err) @@ -187,8 +144,7 @@ func TestClaudeHookInstaller_Install_AddsPermissionRules(t *testing.T) { } allow, _ := perms["allow"].([]any) want := map[string]bool{ - "Bash(locksmith:*)": false, - "Bash(" + hookCmd + ")": false, + "Bash(locksmith:*)": false, } for _, item := range allow { if s, ok := item.(string); ok { @@ -269,8 +225,6 @@ func TestClaudeHookInstaller_Install_PreservesExistingPermissions(t *testing.T) func TestClaudeHookInstaller_IsInstalled_FalseWhenPermissionMissing(t *testing.T) { installer, home := makeHookInstaller(t) claudeDir := filepath.Join(home, ".claude") - lsDir := filepath.Join(home, ".config", "locksmith") - hookCmd := filepath.Join(lsDir, "agent-hook.sh") // Hook present, permission rules missing - IsInstalled must say false so // Install() runs again and tops up the permissions. settings := map[string]any{ @@ -279,7 +233,7 @@ func TestClaudeHookInstaller_IsInstalled_FalseWhenPermissionMissing(t *testing.T map[string]any{ "matcher": "", "hooks": []any{ - map[string]any{"type": "command", "command": hookCmd}, + map[string]any{"type": "command", "command": "locksmith session ensure --hook"}, }, }, }, @@ -293,37 +247,6 @@ func TestClaudeHookInstaller_IsInstalled_FalseWhenPermissionMissing(t *testing.T } } -func TestClaudeHookInstaller_IsInstalled_FalseWhenOnlyOnePermissionPresent(t *testing.T) { - installer, home := makeHookInstaller(t) - claudeDir := filepath.Join(home, ".claude") - lsDir := filepath.Join(home, ".config", "locksmith") - hookCmd := filepath.Join(lsDir, "agent-hook.sh") - // Hook + only the Bash(locksmith:*) rule, but NOT the explicit hook-path - // rule. IsInstalled must say false so Install() tops up the missing path - // rule. - settings := map[string]any{ - "hooks": map[string]any{ - "UserPromptSubmit": []any{ - map[string]any{ - "matcher": "", - "hooks": []any{ - map[string]any{"type": "command", "command": hookCmd}, - }, - }, - }, - }, - "permissions": map[string]any{ - "allow": []any{"Bash(locksmith:*)"}, - }, - } - data, _ := json.Marshal(settings) - os.WriteFile(filepath.Join(claudeDir, "settings.json"), data, 0o644) - - if installer.IsInstalled() { - t.Error("IsInstalled() = true when hook-path permission missing, want false") - } -} - func TestClaudeHookInstaller_Install_MergesExistingSettings(t *testing.T) { installer, home := makeHookInstaller(t) claudeDir := filepath.Join(home, ".claude") @@ -368,8 +291,6 @@ func TestClaudeHookInstaller_Install_MergesExistingSettings(t *testing.T) { func TestClaudeHookInstaller_Install_Idempotent(t *testing.T) { installer, home := makeHookInstaller(t) claudeDir := filepath.Join(home, ".claude") - lsDir := filepath.Join(home, ".config", "locksmith") - hookCmd := filepath.Join(lsDir, "agent-hook.sh") installer.Install() installer.Install() @@ -392,7 +313,7 @@ func TestClaudeHookInstaller_Install_Idempotent(t *testing.T) { if shm == nil { continue } - if cmd, _ := shm["command"].(string); cmd == hookCmd { + if cmd, _ := shm["command"].(string); cmd == "locksmith session ensure --hook" { count++ } } @@ -463,20 +384,127 @@ func TestClaudeHookInstaller_Install_RefusesMalformedSettings(t *testing.T) { } } -func TestClaudeHookInstaller_Install_WriteScriptError(t *testing.T) { - // If the locksmith config path exists as a regular file, MkdirAll fails - // and Install should return a wrapped error. +func TestClaudeHookInstaller_Install_ClaudeDirError(t *testing.T) { + // If the claude config path exists as a regular file, MkdirAll in + // mergeSettings fails and Install should return a wrapped error. home := t.TempDir() - claudeDir := filepath.Join(home, ".claude") - os.MkdirAll(claudeDir, 0o755) - os.MkdirAll(filepath.Join(home, ".config"), 0o755) + os.MkdirAll(filepath.Join(home, ".config", "locksmith"), 0o755) + os.MkdirAll(filepath.Join(home, ".claude_parent"), 0o755) - // Create a file where MkdirAll would expect to create a directory. - lsConfigPath := filepath.Join(home, ".config", "locksmith") - os.WriteFile(lsConfigPath, []byte("not a directory"), 0o644) + // Create a file where MkdirAll would expect to create the claude dir. + claudeDir := filepath.Join(home, ".claude_parent", "claude") + os.WriteFile(claudeDir, []byte("not a directory"), 0o644) + lsConfigPath := filepath.Join(home, ".config", "locksmith") installer := initflow.NewClaudeHookInstaller(lsConfigPath, claudeDir) if err := installer.Install(); err == nil { - t.Error("Install() should fail when locksmith config dir path is a file") + t.Error("Install() should fail when claude config dir path is a file") + } +} + +func TestMigrate_RewritesLegacyShellCommand(t *testing.T) { + installer, home := makeHookInstaller(t) + claudeDir := filepath.Join(home, ".claude") + lsDir := filepath.Join(home, ".config", "locksmith") + legacyPath := filepath.Join(lsDir, "agent-hook.sh") + _ = os.WriteFile(legacyPath, []byte("#!/bin/sh\n# legacy\n"), 0o755) + settings := map[string]any{ + "hooks": map[string]any{ + "UserPromptSubmit": []any{ + map[string]any{ + "matcher": "", + "hooks": []any{ + map[string]any{"type": "command", "command": legacyPath}, + }, + }, + }, + }, + "permissions": map[string]any{ + "allow": []any{"Bash(" + legacyPath + ")", "Bash(git:*)"}, + }, + } + data, _ := json.Marshal(settings) + _ = os.WriteFile(filepath.Join(claudeDir, "settings.json"), data, 0o644) + + if err := installer.Install(); err != nil { + t.Fatalf("Install: %v", err) + } + + out, _ := os.ReadFile(filepath.Join(claudeDir, "settings.json")) + if !strings.Contains(string(out), "locksmith session ensure --hook") { + t.Errorf("settings.json missing native command; got %s", string(out)) + } + if strings.Contains(string(out), legacyPath) { + t.Errorf("settings.json still references legacy script: %s", string(out)) + } + if _, err := os.Stat(legacyPath); err == nil { + t.Error("legacy script was not deleted") + } + if !installer.LastInstallMigrated() { + t.Error("LastInstallMigrated = false; want true after migration") + } +} + +func TestMigrate_KeepsUnrelatedHooksAndPermissions(t *testing.T) { + installer, home := makeHookInstaller(t) + claudeDir := filepath.Join(home, ".claude") + lsDir := filepath.Join(home, ".config", "locksmith") + legacy := filepath.Join(lsDir, "agent-hook.sh") + _ = os.WriteFile(legacy, []byte("#!/bin/sh\n"), 0o755) + settings := map[string]any{ + "hooks": map[string]any{ + "UserPromptSubmit": []any{ + map[string]any{ + "matcher": "", + "hooks": []any{ + map[string]any{"type": "command", "command": legacy}, + map[string]any{"type": "command", "command": "/usr/local/bin/other.sh"}, + }, + }, + }, + }, + "permissions": map[string]any{ + "allow": []any{"Bash(git:*)", "Bash(" + legacy + ")"}, + }, + } + data, _ := json.Marshal(settings) + _ = os.WriteFile(filepath.Join(claudeDir, "settings.json"), data, 0o644) + + if err := installer.Install(); err != nil { + t.Fatalf("Install: %v", err) + } + + out, _ := os.ReadFile(filepath.Join(claudeDir, "settings.json")) + if !strings.Contains(string(out), "/usr/local/bin/other.sh") { + t.Error("unrelated hook entry was removed") + } + if !strings.Contains(string(out), "Bash(git:*)") { + t.Error("unrelated permission rule was removed") + } +} + +func TestMigrate_FileAlreadyAbsent_NoError(t *testing.T) { + installer, home := makeHookInstaller(t) + claudeDir := filepath.Join(home, ".claude") + lsDir := filepath.Join(home, ".config", "locksmith") + legacy := filepath.Join(lsDir, "agent-hook.sh") + // Do NOT create the legacy file on disk. + settings := map[string]any{ + "hooks": map[string]any{ + "UserPromptSubmit": []any{ + map[string]any{ + "matcher": "", + "hooks": []any{ + map[string]any{"type": "command", "command": legacy}, + }, + }, + }, + }, + } + data, _ := json.Marshal(settings) + _ = os.WriteFile(filepath.Join(claudeDir, "settings.json"), data, 0o644) + + if err := installer.Install(); err != nil { + t.Fatalf("Install: %v", err) } } diff --git a/internal/initflow/templates/agent_instructions.md.tmpl b/internal/initflow/templates/agent_instructions.md.tmpl index 2a8ab78..f7594d8 100644 --- a/internal/initflow/templates/agent_instructions.md.tmpl +++ b/internal/initflow/templates/agent_instructions.md.tmpl @@ -1,16 +1,11 @@ # Locksmith - Agent Instructions -## Session Protocol - -Before accessing any secret, ensure a Locksmith session is active: - -1. Check if `LOCKSMITH_SESSION` is set in the environment. If yes, use it. -2. If not set, run: - ```bash - export LOCKSMITH_SESSION=$(locksmith session ensure --quiet) - ``` - If this fails (daemon not running), continue without secrets. +Call `locksmith get --key ` (or `locksmith get --vault +--path `). Sessions are handled automatically - locksmith creates +one on first use and reuses it within the configured TTL. Claude Code +installs a UserPromptSubmit hook so the session is also available as +`LOCKSMITH_SESSION` from the very first call. ## Retrieving Secrets @@ -25,15 +20,6 @@ Use `--path` + `--vault` to access a secret directly by its path in the vault locksmith get --vault gopass --path work/aws/access-key-id locksmith get --vault keychain --path "My API Token" -## Session Management - -Sessions are TTL-based and expire automatically - no need to end them manually. - -To share a session across multiple calls (avoids repeated vault authorization): -1. Start: `locksmith session start` - outputs session ID and expiry -2. Export: `export LOCKSMITH_SESSION=` -3. All subsequent `locksmith get` calls reuse this session until it expires - ## Sub-Agents Pass `LOCKSMITH_SESSION` to sub-agents via environment. They inherit the parent diff --git a/internal/initflow/templates/agents_md.md.tmpl b/internal/initflow/templates/agents_md.md.tmpl index 96624a8..a0bbbbc 100644 --- a/internal/initflow/templates/agents_md.md.tmpl +++ b/internal/initflow/templates/agents_md.md.tmpl @@ -2,18 +2,13 @@ # Locksmith Integration This project uses [Locksmith](https://github.com/lorem-dev/locksmith) for -secure secret management. Follow this protocol when accessing secrets. +secure secret management. -## Session Protocol - -Before accessing any secret, ensure a Locksmith session is active: - -1. Check if `LOCKSMITH_SESSION` is set in the environment. If yes, use it. -2. If not set, run: - ```bash - export LOCKSMITH_SESSION=$(locksmith session ensure --quiet) - ``` - If this fails (daemon not running), continue without secrets. +Call `locksmith get --key ` (or `locksmith get --vault +--path `). Sessions are handled automatically - locksmith creates +one on first use and reuses it within the configured TTL. Claude Code +installs a UserPromptSubmit hook so the session is also available as +`LOCKSMITH_SESSION` from the very first call. ## Retrieving Secrets @@ -31,17 +26,7 @@ Use `--path` + `--vault` to access a secret directly by its path in the vault ## Sub-agent Delegation When spawning sub-agents or tools, pass `LOCKSMITH_SESSION` in their -environment so they can reuse the parent session: - -```bash -LOCKSMITH_SESSION=$LOCKSMITH_SESSION -``` - -Check `agent.pass_session_to_subagents` in `~/.config/locksmith/config.yaml` +environment so they can reuse the parent session. Check +`agent.pass_session_to_subagents` in `~/.config/locksmith/config.yaml` (default: `true`). If `false`, skip sub-agent delegation. - -## Error Handling - -If `locksmith session ensure` exits non-zero, the daemon is not running. -Continue work without secrets rather than blocking. diff --git a/internal/initflow/templates/claude_hook.sh.tmpl b/internal/initflow/templates/claude_hook.sh.tmpl deleted file mode 100644 index e3a7b46..0000000 --- a/internal/initflow/templates/claude_hook.sh.tmpl +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/sh -# Locksmith session hook for Claude Code (UserPromptSubmit). -# -# Ensures a valid Locksmith session exists and injects LOCKSMITH_SESSION -# into the Claude Code agent environment before each prompt. -# -# If the Locksmith daemon is not running, this hook exits 0 silently so -# it does not block agent work. -SESSION=$(locksmith session ensure --quiet 2>/dev/null) || exit 0 -printf '{"env": {"LOCKSMITH_SESSION": "%s"}}\n' "$SESSION" diff --git a/internal/initflow/templates/claude_md.md.tmpl b/internal/initflow/templates/claude_md.md.tmpl index 20bbcf2..b88af13 100644 --- a/internal/initflow/templates/claude_md.md.tmpl +++ b/internal/initflow/templates/claude_md.md.tmpl @@ -4,16 +4,11 @@ This environment uses [Locksmith](https://github.com/lorem-dev/locksmith) for secure MCP secret management. -**Session protocol:** - -1. Check if `LOCKSMITH_SESSION` is already set in the environment. If it is, - use it directly. -2. If not set, run before accessing any secret: - ```bash - export LOCKSMITH_SESSION=$(locksmith session ensure --quiet) - ``` - If this fails (daemon not running), continue without secrets. -3. Retrieve secrets: +Call `locksmith get --key ` (or `locksmith get --vault +--path `). Sessions are handled automatically - locksmith creates +one on first use and reuses it within the configured TTL. Claude Code +installs a UserPromptSubmit hook so the session is also available as +`LOCKSMITH_SESSION` from the very first call. **Using a configured alias** (`~/.config/locksmith/config.yaml`): diff --git a/internal/initflow/templates/claude_skill.md.tmpl b/internal/initflow/templates/claude_skill.md.tmpl index ac2a513..88849c3 100644 --- a/internal/initflow/templates/claude_skill.md.tmpl +++ b/internal/initflow/templates/claude_skill.md.tmpl @@ -6,9 +6,11 @@ description: Use when MCP server requires authentication headers or secrets from Use `locksmith get` to retrieve secrets from configured vaults. -If `LOCKSMITH_SESSION` is not set in the environment, `locksmith get` automatically -starts a session using the default TTL from config and prints the session ID to stderr. -Sessions expire by TTL automatically - no manual cleanup needed. +Call `locksmith get --key ` (or `locksmith get --vault +--path `). Sessions are handled automatically - locksmith creates +one on first use and reuses it within the configured TTL. Claude Code +installs a UserPromptSubmit hook so the session is also available as +`LOCKSMITH_SESSION` from the very first call. ## Retrieving Secrets @@ -23,12 +25,7 @@ Use `--path` + `--vault` to access a secret directly by its path in the vault locksmith get --vault gopass --path work/aws/access-key-id locksmith get --vault keychain --path "My API Token" -## Session Reuse - -To reuse the same session across multiple calls (avoids repeated vault authorization): -1. Run `locksmith session start` once and export the token: - `export LOCKSMITH_SESSION=$(locksmith session start | jq -r .session_id)` -2. Pass `LOCKSMITH_SESSION` to sub-agents via environment so they share the same session. +Pass `LOCKSMITH_SESSION` to sub-agents via environment so they share the same session. Never hardcode secrets. Never cache secrets outside of locksmith. diff --git a/internal/initflow/templates/codex_agents.md.tmpl b/internal/initflow/templates/codex_agents.md.tmpl index 4aa76ca..7af9bba 100644 --- a/internal/initflow/templates/codex_agents.md.tmpl +++ b/internal/initflow/templates/codex_agents.md.tmpl @@ -3,14 +3,11 @@ Secrets are managed by Locksmith. -**Session protocol:** - -1. Check if `LOCKSMITH_SESSION` is set in the environment. If yes, use it. -2. If not set, run before accessing any secret: - ```bash - export LOCKSMITH_SESSION=$(locksmith session ensure --quiet) - ``` - If this fails (daemon not running), continue without secrets. +Call `locksmith get --key ` (or `locksmith get --vault +--path `). Sessions are handled automatically - locksmith creates +one on first use and reuses it within the configured TTL. Claude Code +installs a UserPromptSubmit hook so the session is also available as +`LOCKSMITH_SESSION` from the very first call. **Retrieving secrets:** @@ -25,6 +22,5 @@ Use `--path` + `--vault` to access a secret directly by its path in the vault locksmith get --vault gopass --path work/aws/access-key-id locksmith get --vault keychain --path "My API Token" -- Export the session ID to reuse it: `export LOCKSMITH_SESSION=$(locksmith session start | jq -r .session_id)` - Pass `LOCKSMITH_SESSION` to sub-agents so they share the parent session. diff --git a/internal/initflow/templates/gemini_md.md.tmpl b/internal/initflow/templates/gemini_md.md.tmpl index dd7f0f2..b0d6908 100644 --- a/internal/initflow/templates/gemini_md.md.tmpl +++ b/internal/initflow/templates/gemini_md.md.tmpl @@ -4,15 +4,11 @@ This workspace uses [Locksmith](https://github.com/lorem-dev/locksmith) for secure secret management. -## Session Protocol - -At the start of any session where secrets may be needed: - -1. Check `LOCKSMITH_SESSION` in environment. If set and non-empty, use it. -2. If not set: - ```bash - export LOCKSMITH_SESSION=$(locksmith session ensure --quiet) - ``` +Call `locksmith get --key ` (or `locksmith get --vault +--path `). Sessions are handled automatically - locksmith creates +one on first use and reuses it within the configured TTL. Claude Code +installs a UserPromptSubmit hook so the session is also available as +`LOCKSMITH_SESSION` from the very first call. ## Retrieving Secrets @@ -29,12 +25,7 @@ Use `--path` + `--vault` to access a secret directly by its path in the vault ## Sub-agent Session Passing -When spawning sub-agents, export `LOCKSMITH_SESSION` into their environment. +When spawning sub-agents, pass `LOCKSMITH_SESSION` into their environment. This is the default behavior per `agent.pass_session_to_subagents: true` in `~/.config/locksmith/config.yaml`. - -## Non-blocking Behavior - -If `locksmith session ensure` fails (daemon not running), do not block - work -continues without secrets. From 3ff79b4cd5b6e5ce5eb98e04a8d3039eb252d69a Mon Sep 17 00:00:00 2001 From: Lorem Dev Date: Sat, 23 May 2026 00:09:24 +0200 Subject: [PATCH 17/25] refactor(initflow): decompose flow.go into agents/ and hooks/ subpackages Split internal/initflow/flow.go (1002 lines) into focused files in the same package and move per-agent / per-hook logic into subpackages. Top-level initflow files: - flow.go (now ~170 lines, just RunInit + applyInit) - types.go (InitOptions, InitResult, Prompter) - prompter.go (huhPrompter implementation) - apply.go (applyXxx helpers) - select.go (selectXxx + consent helpers) - agents.go (AgentWriter dispatcher) New subpackages: - internal/initflow/agents/marker - LOCKSMITH_START/END constants and Upsert helper. - internal/initflow/agents/{claude,codex,gemini,opencode,generic} - per-agent install logic with embedded templates. Each exports Install(homeDir, configDir) and ReadTemplateForTest. Gemini was dead code (template existed, no dispatch); now wired up. - internal/initflow/hooks - shared JSON-settings helpers (ReadSettings, WriteSettings, FindStringInAllow, ErrMalformed). - internal/initflow/hooks/claude - ClaudeHookInstaller moved here and renamed to Installer + New constructor. No behaviour change. Pure structural refactor that prepares the codebase for the Codex hook installer added in the next commit. CLAUDE.md gains a 'CodeGraph MCP guidance' section copied from AGENTS.md to give Claude Code the same code-navigation defaults. --- CLAUDE.md | 34 + internal/initflow/agents.go | 50 +- internal/initflow/agents/marker/marker.go | 56 ++ .../initflow/agents/marker/marker_test.go | 63 ++ internal/initflow/apply.go | 261 ++++++ internal/initflow/flow.go | 837 +----------------- .../{hooks.go => hooks/claude/claude.go} | 114 +-- .../claude/claude_test.go} | 10 +- internal/initflow/hooks/settings.go | 72 ++ internal/initflow/hooks/settings_test.go | 80 ++ internal/initflow/prompter.go | 393 ++++++++ internal/initflow/select.go | 133 +++ internal/initflow/types.go | 84 ++ 13 files changed, 1229 insertions(+), 958 deletions(-) create mode 100644 internal/initflow/agents/marker/marker.go create mode 100644 internal/initflow/agents/marker/marker_test.go create mode 100644 internal/initflow/apply.go rename internal/initflow/{hooks.go => hooks/claude/claude.go} (68%) rename internal/initflow/{hooks_test.go => hooks/claude/claude_test.go} (98%) create mode 100644 internal/initflow/hooks/settings.go create mode 100644 internal/initflow/hooks/settings_test.go create mode 100644 internal/initflow/prompter.go create mode 100644 internal/initflow/select.go create mode 100644 internal/initflow/types.go diff --git a/CLAUDE.md b/CLAUDE.md index c9d792f..74c5e12 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,40 @@ ## Repository: https://github.com/lorem-dev/locksmith + +## CodeGraph + +This project has a CodeGraph MCP server (`codegraph_*` tools) configured. CodeGraph is a tree-sitter-parsed knowledge graph of every symbol, edge, and file. Reads are sub-millisecond and return structural information grep cannot. + +### When to prefer codegraph over native search + +Use codegraph for **structural** questions — what calls what, what would break, where is X defined, what is X's signature. Use native grep/read only for **literal text** queries (string contents, comments, log messages) or after you already have a specific file open. + +| Question | Tool | +|---|---| +| "Where is X defined?" / "Find symbol named X" | `codegraph_search` | +| "What calls function Y?" | `codegraph_callers` | +| "What does Y call?" | `codegraph_callees` | +| "What would break if I changed Z?" | `codegraph_impact` | +| "Show me Y's signature / source / docstring" | `codegraph_node` | +| "Give me focused context for a task/area" | `codegraph_context` | +| "Survey an unfamiliar module/topic" | `codegraph_explore` | +| "What files exist under path/" | `codegraph_files` | +| "Is the index healthy?" | `codegraph_status` | + +### Rules of thumb + +- **Trust codegraph results.** They come from a full AST parse. Do NOT re-verify them with grep — that's slower, less accurate, and wastes context. +- **Don't grep first** when looking up a symbol by name. `codegraph_search` is faster and returns kind + location + signature in one call. +- **Don't chain `codegraph_search` + `codegraph_node`** when you just want context — `codegraph_context` is one call. +- **`codegraph_explore` is the heavy hitter** for unfamiliar areas — it returns full source from all relevant files in one call, but is token-heavy. If your harness supports parallel subagents (e.g., Claude Code's Task tool), spawn one for explore-class questions to keep main session context clean. +- **Index lag**: the file watcher debounces ~500ms behind writes; don't re-query immediately after editing a file in the same turn. + +### If `.codegraph/` doesn't exist + +The MCP server returns "not initialized." Ask the user: *"I notice this project doesn't have CodeGraph initialized. Want me to run `codegraph init -i` to build the index?"* + + ## Language All code, comments, commit messages, and documentation must be in English. When conversing with the user, always respond in the user's language. diff --git a/internal/initflow/agents.go b/internal/initflow/agents.go index 3aea10e..d707a3d 100644 --- a/internal/initflow/agents.go +++ b/internal/initflow/agents.go @@ -5,7 +5,8 @@ import ( "fmt" "os" "path/filepath" - "strings" + + "github.com/lorem-dev/locksmith/internal/initflow/agents/marker" ) //go:embed templates/* @@ -57,7 +58,7 @@ func (w *AgentWriter) installClaudeCode(agent DetectedAgent) error { return fmt.Errorf("writing skill: %w", err) } mdContent := mustReadTemplate("templates/claude_md.md.tmpl") - return upsertMarkedBlock(filepath.Join(agent.ConfigDir, "CLAUDE.md"), string(mdContent)) + return marker.Upsert(filepath.Join(agent.ConfigDir, "CLAUDE.md"), string(mdContent)) } func (w *AgentWriter) installCodex(agent DetectedAgent) error { @@ -68,7 +69,7 @@ func (w *AgentWriter) installCodex(agent DetectedAgent) error { return fmt.Errorf("creating config dir: %w", err) } content := mustReadTemplate("templates/codex_agents.md.tmpl") - return upsertMarkedBlock(filepath.Join(agent.ConfigDir, "AGENTS.md"), string(content)) + return marker.Upsert(filepath.Join(agent.ConfigDir, "AGENTS.md"), string(content)) } func (w *AgentWriter) installOpenCode(agent DetectedAgent) error { @@ -79,7 +80,7 @@ func (w *AgentWriter) installOpenCode(agent DetectedAgent) error { return fmt.Errorf("creating config dir: %w", err) } content := mustReadTemplate("templates/agent_instructions.md.tmpl") - return upsertMarkedBlock(filepath.Join(agent.ConfigDir, "instructions.md"), string(content)) + return marker.Upsert(filepath.Join(agent.ConfigDir, "instructions.md"), string(content)) } func (w *AgentWriter) installGeneric() error { @@ -100,44 +101,3 @@ func (w *AgentWriter) installGeneric() error { // external tests; production callers use the unexported version. func MustReadTemplateForTest(name string) []byte { return mustReadTemplate(name) } -const ( - locksmithBlockStart = "" - locksmithBlockEnd = "" -) - -// upsertMarkedBlock writes content to filePath. If the file already contains -// a locksmith-managed block delimited by LOCKSMITH_START/END markers, the -// block is replaced in place. Otherwise content is appended (preserving any -// pre-existing user content). The file is created if absent. content must -// begin with LOCKSMITH_START and end with LOCKSMITH_END (the embedded -// templates already do). -func upsertMarkedBlock(filePath, content string) (retErr error) { - existing, err := os.ReadFile(filePath) //nolint:gosec // G304: filePath is derived from agent config dir - if err != nil && !os.IsNotExist(err) { - return fmt.Errorf("reading %s: %w", filePath, err) - } - text := string(existing) - startIdx := strings.Index(text, locksmithBlockStart) - endIdx := strings.Index(text, locksmithBlockEnd) - var out string - switch { - case startIdx >= 0 && endIdx > startIdx: - // Replace block in place (include the end marker length). - out = text[:startIdx] + strings.TrimRight(content, "\n") + - text[endIdx+len(locksmithBlockEnd):] - case len(existing) == 0: - out = content - default: - // Append. - sep := "\n\n" - if strings.HasSuffix(text, "\n") { - sep = "\n" - } - out = text + sep + content - } - //nolint:gosec // G306: documentation, user-readable by design - if err := os.WriteFile(filePath, []byte(out), 0o644); err != nil { - return fmt.Errorf("writing %s: %w", filePath, err) - } - return nil -} diff --git a/internal/initflow/agents/marker/marker.go b/internal/initflow/agents/marker/marker.go new file mode 100644 index 0000000..e00ab93 --- /dev/null +++ b/internal/initflow/agents/marker/marker.go @@ -0,0 +1,56 @@ +// Package marker provides the LOCKSMITH_START/END comment markers and +// the upsert helper that wraps content in those markers. Used by +// every agent-file installer to keep the locksmith-managed block +// updatable without disturbing user content. +package marker + +import ( + "errors" + "fmt" + "io/fs" + "os" + "strings" +) + +const ( + // Start delimits the beginning of the locksmith-managed block in + // agent doc files. Markers stay stable across versions so we can + // re-render the block in place on every `locksmith init` run. + Start = "" + // End delimits the end of the locksmith-managed block. + End = "" +) + +// Upsert writes content to filePath. If the file already contains a +// locksmith-managed block delimited by Start/End markers, the block +// is replaced in place. Otherwise content is appended (preserving any +// pre-existing user content). The file is created if absent. content +// must begin with Start and end with End (the embedded templates +// already do). +func Upsert(filePath, content string) error { + existing, err := os.ReadFile(filePath) //nolint:gosec // G304: filePath is derived from agent config dir + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("reading %s: %w", filePath, err) + } + text := string(existing) + startIdx := strings.Index(text, Start) + endIdx := strings.Index(text, End) + var out string + switch { + case startIdx >= 0 && endIdx > startIdx: + // Replace block in place (include the end marker length). + out = text[:startIdx] + strings.TrimRight(content, "\n") + + text[endIdx+len(End):] + case len(existing) == 0: + out = content + default: + // Append. + sep := "\n\n" + if strings.HasSuffix(text, "\n") { + sep = "\n" + } + out = text + sep + content + } + //nolint:gosec // G306: documentation, user-readable by design + return os.WriteFile(filePath, []byte(out), 0o644) +} diff --git a/internal/initflow/agents/marker/marker_test.go b/internal/initflow/agents/marker/marker_test.go new file mode 100644 index 0000000..6d7db01 --- /dev/null +++ b/internal/initflow/agents/marker/marker_test.go @@ -0,0 +1,63 @@ +package marker_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/lorem-dev/locksmith/internal/initflow/agents/marker" +) + +func TestUpsert_ReplacesExistingBlock(t *testing.T) { + dir := t.TempDir() + mdPath := filepath.Join(dir, "FILE.md") + seed := "# Project notes\n\nUser above.\n\n" + marker.Start + + "\n## Old section\nstale\n" + marker.End + + "\n\nUser below.\n" + if err := os.WriteFile(mdPath, []byte(seed), 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + + fresh := marker.Start + "\n## Fresh\nnew\n" + marker.End + if err := marker.Upsert(mdPath, fresh); err != nil { + t.Fatalf("Upsert: %v", err) + } + + got, _ := os.ReadFile(mdPath) + if !strings.Contains(string(got), "User above.") || !strings.Contains(string(got), "User below.") { + t.Error("user content lost") + } + if strings.Contains(string(got), "Old section") { + t.Error("stale block survived") + } + if c := strings.Count(string(got), marker.Start); c != 1 { + t.Errorf("Start appears %d times, want 1", c) + } +} + +func TestUpsert_AppendsToFileWithoutMarkers(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "F.md") + _ = os.WriteFile(p, []byte("# existing user notes\n"), 0o644) + block := marker.Start + "\n## locksmith\n" + marker.End + if err := marker.Upsert(p, block); err != nil { + t.Fatalf("Upsert: %v", err) + } + got, _ := os.ReadFile(p) + if !strings.Contains(string(got), "existing user notes") || !strings.Contains(string(got), marker.Start) { + t.Errorf("expected both user and managed blocks, got %q", string(got)) + } +} + +func TestUpsert_CreatesAbsentFile(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "NEW.md") + block := marker.Start + "\n## locksmith\n" + marker.End + if err := marker.Upsert(p, block); err != nil { + t.Fatalf("Upsert: %v", err) + } + if _, err := os.Stat(p); err != nil { + t.Error("file was not created") + } +} diff --git a/internal/initflow/apply.go b/internal/initflow/apply.go new file mode 100644 index 0000000..8fef496 --- /dev/null +++ b/internal/initflow/apply.go @@ -0,0 +1,261 @@ +package initflow + +import ( + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + + "gopkg.in/yaml.v3" + + "github.com/lorem-dev/locksmith/internal/bundled" + "github.com/lorem-dev/locksmith/internal/config" + "github.com/lorem-dev/locksmith/internal/daemon" + claudehook "github.com/lorem-dev/locksmith/internal/initflow/hooks/claude" + "github.com/lorem-dev/locksmith/internal/log" + "github.com/lorem-dev/locksmith/internal/shellhook" +) + +// ExtractBundled writes the plugins required by selectedVaults plus +// locksmith-pinentry from the embedded bundle to their canonical paths. +// Returns nil and prints a warning if the bundle is empty (dev build). +func ExtractBundled(selectedVaults []string, prompter Prompter, auto bool) error { + bundle, err := bundled.OpenBundle() + if err != nil { + if errors.Is(err, bundled.ErrEmptyBundle) { + log.Warn().Msg("this build has no bundled plugins; install plugins manually or run `make build-all`") + return nil + } + return fmt.Errorf("opening bundle: %w", err) + } + pluginsDir, err := bundled.PluginsDir() + if err != nil { + return fmt.Errorf("resolving plugins dir: %w", err) + } + pinentryPath, err := bundled.PinentryPath() + if err != nil { + return fmt.Errorf("resolving pinentry path: %w", err) + } + names := []string{"locksmith-pinentry"} + for _, v := range selectedVaults { + names = append(names, "locksmith-plugin-"+v) + } + var p bundled.ExtractPrompter + if !auto { + p = prompter + } + if err := bundled.Extract(bundle, bundled.ExtractOptions{ + Names: names, + PluginsDir: pluginsDir, + PinentryPath: pinentryPath, + Prompter: p, + OnKept: func(name string, withWarning bool) { + if withWarning { + log.Warn().Str("entry", name). + Msg("kept; bundled version differs - functionality may not work as expected") + } + }, + OnExtracted: func(name string) { + fmt.Printf(" Binary extracted from bundle: %s\n", name) + }, + }); err != nil { + return fmt.Errorf("extracting bundled entries: %w", err) + } + return nil +} + +// writeConfigFile writes the YAML config or skips if it pre-existed. +func writeConfigFile(result *InitResult, homeDir string) error { + if result.ConfigPreexisted { + fmt.Printf(" config kept at %s\n", fmtPaths.Sprint(result.ConfigPath)) + return nil + } + + cfg := config.Config{ + Defaults: config.Defaults{SessionTTL: "3h", SocketPath: "~/.config/locksmith/locksmith.sock"}, + Logging: config.Logging{Level: "info", Format: "text"}, + Vaults: make(map[string]config.Vault), + Keys: make(map[string]config.Key), + } + for _, vt := range result.SelectedVaults { + cfg.Vaults[vt] = config.Vault{Type: vt} + } + data, err := yaml.Marshal(&cfg) + if err != nil { + return fmt.Errorf("marshaling config: %w", err) + } + if err := os.WriteFile(result.ConfigPath, data, 0o644); err != nil { //nolint:gosec // G306: user config file + return fmt.Errorf("writing config: %w", err) + } + fmt.Printf(" config written to %s\n", result.ConfigPath) + + // Apply GPG pinentry configuration if the user opted in. + if result.GPGPinentryConfigured { + applyGPGPinentryConfig(homeDir) + } + return nil +} + +// applyGPGPinentryConfig runs the gpg-agent pinentry configuration steps. +func applyGPGPinentryConfig(homeDir string) { + pinentryPath, pathErr := bundled.PinentryPath() + if pathErr != nil { + fmt.Printf(" warning: could not resolve pinentry path: %v\n", pathErr) + return + } + if _, statErr := os.Stat(pinentryPath); errors.Is(statErr, fs.ErrNotExist) { + fmt.Printf(" warning: %s not found - run `locksmith init` to extract"+ + " or `make build-all` for a non-empty bundle\n", pinentryPath) + return + } else if statErr != nil { + fmt.Printf(" warning: stat %s: %v\n", pinentryPath, statErr) + return + } + gnupgDir := filepath.Join(homeDir, ".gnupg") + replaced, applyErr := ApplyGPGPinentry(gnupgDir, pinentryPath) + if applyErr != nil { + fmt.Printf(" warning: could not update gpg-agent.conf: %v\n", applyErr) + return + } + if replaced != "" { + fmt.Printf(" gpg-agent: previous pinentry-program (%s) commented out\n", replaced) + } + fmt.Printf(" gpg-agent: pinentry-program set to %s\n", pinentryPath) + exec.Command("gpgconf", "--kill", "gpg-agent").Run() //nolint:errcheck +} + +// applyClaudeHook installs or reports status of the Claude Code UserPromptSubmit hook. +func applyClaudeHook(result *InitResult, homeDir string) error { + installer := claudehook.New( + filepath.Join(homeDir, ".config", "locksmith"), + filepath.Join(homeDir, ".claude"), + ) + switch { + case result.ClaudeHookAlreadyPresent: + fmt.Printf(" Claude Code: hook already present in %s\n", + fmtPaths.Sprint("~/.claude/settings.json")) + case result.ClaudeHookConfirmed: + if err := installer.Install(); err != nil { + return fmt.Errorf("installing Claude Code hook: %w", err) + } + result.ClaudeHookInstalled = true + fmt.Println(" Claude Code: hook installed in ~/.claude/settings.json") + fmt.Println(" Restart Claude Code for the hook to take effect.") + if installer.LastInstallMigrated() { + result.LegacyHookMigrated = true + fmt.Println(" Claude Code: migrated legacy shell hook to native command") + } + default: + fmt.Printf("\n To install the Claude Code hook manually:\n") + fmt.Printf(" 1. Run: locksmith init --agent claude\n") + fmt.Printf(" 2. Add to ~/.claude/settings.json:\n") + fmt.Printf( + " {\"hooks\":{\"UserPromptSubmit\":[{\"matcher\":\"\",\"hooks\":[{\"type\":\"command\",\"command\":%q}]}]}}\n", + "locksmith session ensure --hook", + ) + } + return nil +} + +// applyPathHook augments the user's shell rc file so that `locksmith` +// is resolvable by the Claude Code UserPromptSubmit hook. The hook is +// registered as the bare command `locksmith _agent-hook` and relies on +// PATH at hook-execution time. Many setups (GUI launchers, non-login +// shells) do not inherit a useful PATH, so we ensure the directory +// containing the locksmith binary is on PATH explicitly. +// +// Skipped silently when: +// - The directory is already in the live $PATH at init time. +// - The shell is unknown or has no canonical rc file. +// - The rc file does not exist (init never creates new rc files). +func applyPathHook(result *InitResult, _ string) { + exe, err := os.Executable() + if err != nil { + return + } + if resolved, rerr := filepath.EvalSymlinks(exe); rerr == nil { + exe = resolved + } + binDir := filepath.Dir(exe) + + if shellhook.DirInPath(os.Getenv("PATH"), binDir) { + return + } + + shell := shellhook.DetectShell() + rcFile, ok := shellhook.RCFile(shell) + if !ok { + return + } + + if _, statErr := os.Stat(rcFile); errors.Is(statErr, fs.ErrNotExist) { + return + } + already, err := shellhook.IsPathInstalled(rcFile) + if err != nil || already { + return + } + if err := shellhook.InstallPath(rcFile, shell, binDir); err != nil { + return + } + result.PathHookInstalled = true + result.PathHookRCFile = rcFile +} + +// ApplyPathHookForTest is an export-for-test seam. +func ApplyPathHookForTest(result *InitResult, homeDir string) { + applyPathHook(result, homeDir) +} + +// applyShellHook installs or reports status of the shell daemon autostart hook. +func applyShellHook(result *InitResult) { + switch { + case result.ShellHookAlreadyPresent: + fmt.Printf(" shell hook already installed (%s)\n", fmtPaths.Sprint(result.ShellHookRCFile)) + case result.ShellHookInstall: + if err := shellhook.Install(result.ShellHookRCFile, result.ShellHookShell); err != nil { + fmt.Printf(" warning: could not write to %s: %v\n", fmtPaths.Sprint(result.ShellHookRCFile), err) + printShellFallback(result.ShellHookShell, result.ShellHookRCFile) + } else { + fmt.Printf(" shell hook added to %s\n", fmtPaths.Sprint(result.ShellHookRCFile)) + } + default: + printShellFallback(result.ShellHookShell, result.ShellHookRCFile) + } +} + +// applyDaemonRestart restarts a currently-running locksmith daemon so +// the just-installed plugin bundle, config changes, and shell-rc +// updates take effect immediately. If no daemon is running it does +// nothing - the next agent prompt or shell startup will spawn one via +// the autostart shellhook. +// +// On restart failure this prints a warning rather than failing init; +// init has already written every config/file successfully and the +// user can recover with `locksmith restart` manually. +func applyDaemonRestart(result *InitResult) { + socket := daemon.DefaultSocketPath() + if !daemon.IsRunning(socket) { + return + } + if err := daemon.Restart(daemon.RestartOptions{Timeout: daemon.DefaultRestartTimeout}); err != nil { + fmt.Fprintf(os.Stderr, "warning: failed to restart daemon: %v\n", err) + return + } + result.DaemonRestarted = true +} + +// ApplyDaemonRestartForTest is an export-for-test seam. +func ApplyDaemonRestartForTest(result *InitResult) { applyDaemonRestart(result) } + +// printShellFallback prints manual instructions for adding the shell hook. +func printShellFallback(s shellhook.Shell, rcFile string) { + snippet := shellhook.Snippet(s) + if rcFile != "" { + fmt.Printf("\nTo start the daemon automatically, add to %s:\n\n %s\n\n", fmtPaths.Sprint(rcFile), snippet) + } else { + fmt.Printf("\nTo start the daemon automatically, add to your shell config:\n\n %s\n\n", snippet) + } +} diff --git a/internal/initflow/flow.go b/internal/initflow/flow.go index d450f57..42e737f 100644 --- a/internal/initflow/flow.go +++ b/internal/initflow/flow.go @@ -1,112 +1,15 @@ +// Package initflow implements the locksmith init wizard: detection, prompting, +// and applying configuration for vault plugins, AI agents, and shell hooks. package initflow import ( - "errors" "fmt" - "io" - "io/fs" "os" - "os/exec" "path/filepath" - "strings" - "github.com/charmbracelet/huh" - "github.com/fatih/color" - "gopkg.in/yaml.v3" - - "github.com/lorem-dev/locksmith/internal/bundled" "github.com/lorem-dev/locksmith/internal/config" - "github.com/lorem-dev/locksmith/internal/daemon" - "github.com/lorem-dev/locksmith/internal/log" - "github.com/lorem-dev/locksmith/internal/shellhook" -) - -// ExistingConfigAction is the user's choice when a config file already exists. -type ExistingConfigAction int - -const ( - // ActionContinue keeps the existing file; applyInit skips writing config.yaml. - ActionContinue ExistingConfigAction = iota - // ActionOverwrite proceeds through the wizard and replaces the file. - ActionOverwrite - // ActionExit cancels init without changes. - ActionExit -) - -// Prompter is the interface for all user-facing interactive prompts. -// The default implementation uses charmbracelet/huh TUI forms; tests can inject -// a mock via InitOptions.Prompter to exercise RunInit without a real TTY. -type Prompter interface { - // ExistingConfig is called when a config file already exists at path. - // validErr is nil if the file passes validation, or the validation error otherwise. - ExistingConfig(path string, validErr error) (ExistingConfigAction, error) - ConfigLocation(defaultDir string) (string, error) - VaultSelection(vaults []DetectedVault) ([]string, error) - AgentSelection(agents []DetectedAgent) ([]DetectedAgent, error) - Sandbox() (bool, error) - Summary(result *InitResult) (bool, error) - // GPGPinentry asks whether to configure locksmith-pinentry in gpg-agent.conf. - // existingPinentry is the current pinentry-program value (empty if none). - GPGPinentry(existingPinentry string) (bool, error) - // ShellHook asks whether to append the daemon autostart snippet to rcFile. - ShellHook(rcFile string) (bool, error) - // ClaudeHook asks whether to install the Locksmith UserPromptSubmit hook - // into settingsPath (~/.claude/settings.json). Shows what will be changed. - ClaudeHook(settingsPath string) (bool, error) - // BundleExtractPrompt is called when an existing plugin or pinentry file - // has different content from the bundled version. Returns the user's - // resolution choice. existingSHA and newSHA are short (8-char) hex - // strings suitable for display. - BundleExtractPrompt(name, existingSHA, newSHA string) (bundled.ConflictResolution, error) -} - -// InitOptions controls the behaviour of RunInit. -type InitOptions struct { - NoTUI bool - Auto bool - AgentOnly string - SkipAgents bool - // Prompter overrides the default huh-based prompts. Nil uses the TUI default. - // Inject a mock in tests to drive non-auto RunInit flows without a real TTY. - Prompter Prompter -} - -// InitResult holds the resolved configuration from the init wizard. -type InitResult struct { - ConfigPath string - SelectedVaults []string - SelectedAgents []DetectedAgent - SandboxEnabled bool - GPGPinentryConfigured bool // true if the user opted to configure locksmith-pinentry - ConfigPreexisted bool // true when an existing config was found and kept - ShellHookInstall bool // true if user agreed (or --auto) to install - ShellHookAlreadyPresent bool // true if hook marker was already in rc file - ShellHookRCFile string // rc file path; empty when shell is unknown - ShellHookShell shellhook.Shell // detected shell - ClaudeHookConfirmed bool // user approved (or --auto); set in RunInit before applyInit - ClaudeHookInstalled bool // hook was written successfully; set in applyInit - ClaudeHookAlreadyPresent bool // hook was already in settings.json; install skipped - PathHookInstalled bool // true when a PATH snippet was appended to rc - PathHookRCFile string // rc file path; empty when skipped - DaemonRestarted bool // true when init restarted a running daemon - LegacyHookMigrated bool // true when the legacy shell hook was migrated -} - -var ( - fmtTitle = color.New(color.Bold) - fmtPaths = color.New(color.FgBlue) - fmtErrors = color.New(color.FgRed) - fmtLists = color.New(color.FgCyan) - fmtBooleans = color.New(color.FgMagenta) ) -// DetectVaultsFnType is the function signature for detecting vault backends. -type DetectVaultsFnType func() []DetectedVault - -// DetectVaultsFn is the function used to detect vault backends. Replaced in -// tests to inject a stub without touching the real filesystem. -var DetectVaultsFn DetectVaultsFnType = DetectVaults - // RunInit runs the interactive setup wizard. In --auto mode all prompts are // skipped and detected defaults are applied. In --no-tui mode huh's accessible // mode is used (plain text prompts), which also activates automatically when @@ -226,177 +129,6 @@ func RunInit(opts InitOptions) (*InitResult, error) { return result, nil } -// selectVaults fills result.SelectedVaults based on detection and prompting. -func selectVaults(result *InitResult, opts InitOptions, prompter Prompter) error { - detectedVaults := DetectVaultsFn() - if opts.Auto { - for _, v := range detectedVaults { - if v.Detected && v.Implemented { - result.SelectedVaults = append(result.SelectedVaults, v.Type) - } - } - return nil - } - var err error - result.SelectedVaults, err = prompter.VaultSelection(detectedVaults) - if err != nil { - return fmt.Errorf("selecting vaults: %w", err) - } - return nil -} - -// selectGPGPinentry prompts for GPG pinentry configuration when gopass is selected. -func selectGPGPinentry(result *InitResult, prompter Prompter, homeDir string) error { - gopassSelected := false - for _, v := range result.SelectedVaults { - if v == config.VaultGopass { - gopassSelected = true - break - } - } - if !gopassSelected { - return nil - } - gnupgDir := filepath.Join(homeDir, ".gnupg") - existing := ReadExistingPinentry(gnupgDir) - configure, err := prompter.GPGPinentry(existing) - if err != nil { - return fmt.Errorf("prompting for GPG pinentry: %w", err) - } - result.GPGPinentryConfigured = configure - return nil -} - -// selectAgents fills result.SelectedAgents based on detection, auto mode, or prompting. -func selectAgents(result *InitResult, opts InitOptions, prompter Prompter, homeDir string) error { - detectedAgents := DetectAgents(homeDir) - switch { - case opts.AgentOnly != "": - for _, a := range detectedAgents { - if AgentMatches(a.Name, opts.AgentOnly) { - result.SelectedAgents = append(result.SelectedAgents, a) - } - } - case opts.Auto: - for _, a := range detectedAgents { - if a.Detected { - result.SelectedAgents = append(result.SelectedAgents, a) - } - } - default: - var err error - result.SelectedAgents, err = prompter.AgentSelection(detectedAgents) - if err != nil { - return fmt.Errorf("selecting agents: %w", err) - } - } - return nil -} - -// detectShellHookConsent determines whether to install the daemon autostart hook. -func detectShellHookConsent(result *InitResult, opts InitOptions, prompter Prompter) error { - detectedShell := shellhook.DetectShell() - rcFile, shellKnown := shellhook.RCFile(detectedShell) - result.ShellHookShell = detectedShell - result.ShellHookRCFile = rcFile - if !shellKnown { - return nil - } - alreadyInstalled := false - if ok, isInstalledErr := shellhook.IsInstalled(rcFile); isInstalledErr == nil { - alreadyInstalled = ok - } - switch { - case alreadyInstalled: - result.ShellHookAlreadyPresent = true - case opts.Auto: - result.ShellHookInstall = true - default: - var err error - result.ShellHookInstall, err = prompter.ShellHook(rcFile) - if err != nil { - return fmt.Errorf("prompting for shell hook: %w", err) - } - } - return nil -} - -// detectClaudeHookConsent checks if Claude Code is selected and asks for hook install consent. -func detectClaudeHookConsent(result *InitResult, opts InitOptions, prompter Prompter, homeDir string) error { - claudeSettingsPath := filepath.Join(homeDir, ".claude", "settings.json") - for _, agent := range result.SelectedAgents { - if agent.Name != "Claude Code" { - continue - } - installer := NewClaudeHookInstaller( - filepath.Join(homeDir, ".config", "locksmith"), - filepath.Join(homeDir, ".claude"), - ) - switch { - case installer.IsInstalled(): - result.ClaudeHookAlreadyPresent = true - case opts.Auto: - result.ClaudeHookConfirmed = true - default: - var err error - result.ClaudeHookConfirmed, err = prompter.ClaudeHook(claudeSettingsPath) - if err != nil { - return fmt.Errorf("prompting for Claude hook: %w", err) - } - } - break // only one Claude Code entry possible - } - return nil -} - -// ExtractBundled writes the plugins required by selectedVaults plus -// locksmith-pinentry from the embedded bundle to their canonical paths. -// Returns nil and prints a warning if the bundle is empty (dev build). -func ExtractBundled(selectedVaults []string, prompter Prompter, auto bool) error { - bundle, err := bundled.OpenBundle() - if err != nil { - if errors.Is(err, bundled.ErrEmptyBundle) { - log.Warn().Msg("this build has no bundled plugins; install plugins manually or run `make build-all`") - return nil - } - return fmt.Errorf("opening bundle: %w", err) - } - pluginsDir, err := bundled.PluginsDir() - if err != nil { - return fmt.Errorf("resolving plugins dir: %w", err) - } - pinentryPath, err := bundled.PinentryPath() - if err != nil { - return fmt.Errorf("resolving pinentry path: %w", err) - } - names := []string{"locksmith-pinentry"} - for _, v := range selectedVaults { - names = append(names, "locksmith-plugin-"+v) - } - var p bundled.ExtractPrompter - if !auto { - p = prompter - } - if err := bundled.Extract(bundle, bundled.ExtractOptions{ - Names: names, - PluginsDir: pluginsDir, - PinentryPath: pinentryPath, - Prompter: p, - OnKept: func(name string, withWarning bool) { - if withWarning { - log.Warn().Str("entry", name). - Msg("kept; bundled version differs - functionality may not work as expected") - } - }, - OnExtracted: func(name string) { - fmt.Printf(" Binary extracted from bundle: %s\n", name) - }, - }); err != nil { - return fmt.Errorf("extracting bundled entries: %w", err) - } - return nil -} - func applyInit(result *InitResult, homeDir string, prompter Prompter, auto bool) error { if err := os.MkdirAll(filepath.Dir(result.ConfigPath), 0o755); err != nil { //nolint:gosec // G301: user config dir return fmt.Errorf("creating config dir: %w", err) @@ -435,568 +167,3 @@ func applyInit(result *InitResult, homeDir string, prompter Prompter, auto bool) applyDaemonRestart(result) return nil } - -// writeConfigFile writes the YAML config or skips if it pre-existed. -func writeConfigFile(result *InitResult, homeDir string) error { - if result.ConfigPreexisted { - fmt.Printf(" config kept at %s\n", fmtPaths.Sprint(result.ConfigPath)) - return nil - } - - cfg := config.Config{ - Defaults: config.Defaults{SessionTTL: "3h", SocketPath: "~/.config/locksmith/locksmith.sock"}, - Logging: config.Logging{Level: "info", Format: "text"}, - Vaults: make(map[string]config.Vault), - Keys: make(map[string]config.Key), - } - for _, vt := range result.SelectedVaults { - cfg.Vaults[vt] = config.Vault{Type: vt} - } - data, err := yaml.Marshal(&cfg) - if err != nil { - return fmt.Errorf("marshaling config: %w", err) - } - if err := os.WriteFile(result.ConfigPath, data, 0o644); err != nil { //nolint:gosec // G306: user config file - return fmt.Errorf("writing config: %w", err) - } - fmt.Printf(" config written to %s\n", result.ConfigPath) - - // Apply GPG pinentry configuration if the user opted in. - if result.GPGPinentryConfigured { - applyGPGPinentryConfig(homeDir) - } - return nil -} - -// applyGPGPinentryConfig runs the gpg-agent pinentry configuration steps. -func applyGPGPinentryConfig(homeDir string) { - pinentryPath, pathErr := bundled.PinentryPath() - if pathErr != nil { - fmt.Printf(" warning: could not resolve pinentry path: %v\n", pathErr) - return - } - if _, statErr := os.Stat(pinentryPath); errors.Is(statErr, fs.ErrNotExist) { - fmt.Printf(" warning: %s not found - run `locksmith init` to extract"+ - " or `make build-all` for a non-empty bundle\n", pinentryPath) - return - } else if statErr != nil { - fmt.Printf(" warning: stat %s: %v\n", pinentryPath, statErr) - return - } - gnupgDir := filepath.Join(homeDir, ".gnupg") - replaced, applyErr := ApplyGPGPinentry(gnupgDir, pinentryPath) - if applyErr != nil { - fmt.Printf(" warning: could not update gpg-agent.conf: %v\n", applyErr) - return - } - if replaced != "" { - fmt.Printf(" gpg-agent: previous pinentry-program (%s) commented out\n", replaced) - } - fmt.Printf(" gpg-agent: pinentry-program set to %s\n", pinentryPath) - exec.Command("gpgconf", "--kill", "gpg-agent").Run() //nolint:errcheck -} - -// applyClaudeHook installs or reports status of the Claude Code UserPromptSubmit hook. -func applyClaudeHook(result *InitResult, homeDir string) error { - installer := NewClaudeHookInstaller( - filepath.Join(homeDir, ".config", "locksmith"), - filepath.Join(homeDir, ".claude"), - ) - switch { - case result.ClaudeHookAlreadyPresent: - fmt.Printf(" Claude Code: hook already present in %s\n", - fmtPaths.Sprint("~/.claude/settings.json")) - case result.ClaudeHookConfirmed: - if err := installer.Install(); err != nil { - return fmt.Errorf("installing Claude Code hook: %w", err) - } - result.ClaudeHookInstalled = true - fmt.Println(" Claude Code: hook installed in ~/.claude/settings.json") - fmt.Println(" Restart Claude Code for the hook to take effect.") - if installer.LastInstallMigrated() { - result.LegacyHookMigrated = true - fmt.Println(" Claude Code: migrated legacy shell hook to native command") - } - default: - fmt.Printf("\n To install the Claude Code hook manually:\n") - fmt.Printf(" 1. Run: locksmith init --agent claude\n") - fmt.Printf(" 2. Add to ~/.claude/settings.json:\n") - fmt.Printf( - " {\"hooks\":{\"UserPromptSubmit\":[{\"matcher\":\"\",\"hooks\":[{\"type\":\"command\",\"command\":%q}]}]}}\n", - nativeHookCmd, - ) - } - return nil -} - -// applyPathHook augments the user's shell rc file so that `locksmith` -// is resolvable by the Claude Code UserPromptSubmit hook. The hook is -// registered as the bare command `locksmith _agent-hook` and relies on -// PATH at hook-execution time. Many setups (GUI launchers, non-login -// shells) do not inherit a useful PATH, so we ensure the directory -// containing the locksmith binary is on PATH explicitly. -// -// Skipped silently when: -// - The directory is already in the live $PATH at init time. -// - The shell is unknown or has no canonical rc file. -// - The rc file does not exist (init never creates new rc files). -func applyPathHook(result *InitResult, _ string) { - exe, err := os.Executable() - if err != nil { - return - } - if resolved, rerr := filepath.EvalSymlinks(exe); rerr == nil { - exe = resolved - } - binDir := filepath.Dir(exe) - - if shellhook.DirInPath(os.Getenv("PATH"), binDir) { - return - } - - shell := shellhook.DetectShell() - rcFile, ok := shellhook.RCFile(shell) - if !ok { - return - } - - if _, statErr := os.Stat(rcFile); errors.Is(statErr, fs.ErrNotExist) { - return - } - already, err := shellhook.IsPathInstalled(rcFile) - if err != nil || already { - return - } - if err := shellhook.InstallPath(rcFile, shell, binDir); err != nil { - return - } - result.PathHookInstalled = true - result.PathHookRCFile = rcFile -} - -// ApplyPathHookForTest is an export-for-test seam. -func ApplyPathHookForTest(result *InitResult, homeDir string) { - applyPathHook(result, homeDir) -} - -// applyShellHook installs or reports status of the shell daemon autostart hook. -func applyShellHook(result *InitResult) { - switch { - case result.ShellHookAlreadyPresent: - fmt.Printf(" shell hook already installed (%s)\n", fmtPaths.Sprint(result.ShellHookRCFile)) - case result.ShellHookInstall: - if err := shellhook.Install(result.ShellHookRCFile, result.ShellHookShell); err != nil { - fmt.Printf(" warning: could not write to %s: %v\n", fmtPaths.Sprint(result.ShellHookRCFile), err) - printShellFallback(result.ShellHookShell, result.ShellHookRCFile) - } else { - fmt.Printf(" shell hook added to %s\n", fmtPaths.Sprint(result.ShellHookRCFile)) - } - default: - printShellFallback(result.ShellHookShell, result.ShellHookRCFile) - } -} - -// huhPrompter is the production Prompter that drives charmbracelet/huh TUI forms. -type huhPrompter struct { - accessible bool - input io.Reader // nil = os.Stdin - output io.Writer // nil = os.Stderr (huh default for TUI output) -} - -// NewHuhPrompter creates a Prompter backed by charmbracelet/huh TUI forms. -// Pass nil for input and output to use the OS defaults (os.Stdin / os.Stderr). -// Inject custom readers/writers in tests to simulate user input without a real TTY. -func NewHuhPrompter(accessible bool, input io.Reader, output io.Writer) Prompter { - return &huhPrompter{accessible: accessible, input: input, output: output} -} - -// formWith applies shared I/O options to a form. -func (p *huhPrompter) formWith(f *huh.Form) *huh.Form { - f = f.WithAccessible(p.accessible) - if p.input != nil { - f = f.WithInput(p.input) - } - if p.output != nil { - f = f.WithOutput(p.output) - } - return f -} - -// ConfigLocation prompts for the config directory. -func (p *huhPrompter) ConfigLocation(defaultDir string) (string, error) { - var selected string - form := p.formWith(huh.NewForm(huh.NewGroup( - huh.NewSelect[string](). - Title("Where to store config?"). - Options( - huh.NewOption(fmt.Sprintf("%s (default)", defaultDir), defaultDir), - huh.NewOption("Custom path", "custom"), - ).Value(&selected), - ))) - if err := form.Run(); err != nil { - return "", fmt.Errorf("selecting config location: %w", err) - } - if selected != "custom" { - return selected, nil - } - var custom string - form2 := p.formWith(huh.NewForm(huh.NewGroup( - huh.NewInput().Title("Config directory:").Value(&custom), - ))) - if err := form2.Run(); err != nil { - return "", fmt.Errorf("entering custom config path: %w", err) - } - return config.ExpandPath(custom), nil -} - -// VaultSelection prompts for vault backend selection. -func (p *huhPrompter) VaultSelection(vaults []DetectedVault) ([]string, error) { - var implemented, planned []DetectedVault - for _, v := range vaults { - if v.Implemented { - implemented = append(implemented, v) - } else { - planned = append(planned, v) - } - } - if len(implemented) == 0 { - return nil, fmt.Errorf("no implemented vault backends available on this platform") - } - - options := make([]huh.Option[string], 0, len(implemented)) - for _, v := range implemented { - label := v.Type - if v.Detected { - label += " (detected)" - } - if !v.Available { - label += " (not available on this platform)" - } - options = append(options, huh.NewOption(label, v.Type)) - } - - var selected []string - for _, v := range implemented { - if v.Detected && v.Available { - selected = append(selected, v.Type) - } - } - - form := p.formWith(huh.NewForm(huh.NewGroup( - huh.NewMultiSelect[string](). - Title("Which vault backends do you use?"). - Description(plannedNote(planned)). - Options(options...).Value(&selected), - ))) - if err := form.Run(); err != nil { - return nil, fmt.Errorf("selecting vaults: %w", err) - } - return selected, nil -} - -// plannedNote formats a description listing planned vault backends. -// Returns empty string when planned is empty (huh shows no description). -func plannedNote(planned []DetectedVault) string { - if len(planned) == 0 { - return "" - } - labels := make([]string, len(planned)) - for i, v := range planned { - labels[i] = plannedLabel(v) - } - return "Planned (not yet supported): " + strings.Join(labels, ", ") + "." -} - -func plannedLabel(v DetectedVault) string { - if v.PlatformNote != "" { - return v.Type + " (" + v.PlatformNote + ")" - } - return v.Type -} - -// AgentSelection prompts which detected agents to configure. -func (p *huhPrompter) AgentSelection(agents []DetectedAgent) ([]DetectedAgent, error) { - var detected []DetectedAgent - for _, a := range agents { - if a.Detected { - detected = append(detected, a) - } - } - if len(detected) == 0 { - fmt.Println("No AI agents detected.") - return nil, nil - } - - var selection string - form := p.formWith(huh.NewForm(huh.NewGroup( - huh.NewSelect[string](). - Title("Install locksmith for detected agents?"). - Options( - huh.NewOption(fmt.Sprintf("All detected (%d)", len(detected)), "all"), - huh.NewOption("Select manually", "manual"), - huh.NewOption("Skip agent setup", "skip"), - ).Value(&selection), - ))) - if err := form.Run(); err != nil { - return nil, fmt.Errorf("selecting agents: %w", err) - } - - if selection == "skip" { - return nil, nil - } - if selection == "all" { - return detected, nil - } - - var options []huh.Option[string] - for _, a := range agents { - label := a.Name - if a.Detected { - label += " (detected)" - } - options = append(options, huh.NewOption(label, a.Name)) - } - var selectedNames []string - form2 := p.formWith(huh.NewForm(huh.NewGroup( - huh.NewMultiSelect[string]().Title("Select agents:").Options(options...).Value(&selectedNames), - ))) - if err := form2.Run(); err != nil { - return nil, fmt.Errorf("selecting agents manually: %w", err) - } - var result []DetectedAgent - for _, a := range agents { - for _, name := range selectedNames { - if a.Name == name { - result = append(result, a) - } - } - } - return result, nil -} - -// Sandbox prompts whether to install sandbox permission allowlists. -func (p *huhPrompter) Sandbox() (bool, error) { - var enabled bool - form := p.formWith(huh.NewForm(huh.NewGroup( - huh.NewConfirm(). - Title("Allow locksmith commands in agent sandboxes?"). - Description("locksmith get, session start/end, vault list/health"). - Value(&enabled), - ))) - if err := form.Run(); err != nil { - return false, fmt.Errorf("prompting for sandbox: %w", err) - } - return enabled, nil -} - -// Summary shows a summary and asks the user to confirm or cancel. -func (p *huhPrompter) Summary(result *InitResult) (bool, error) { - agentNames := make([]string, len(result.SelectedAgents)) - for i, a := range result.SelectedAgents { - agentNames[i] = a.Name - } - - fmt.Println(fmtTitle.Sprint("-- Summary --")) - - fmt.Printf("%s %s\n", fmtTitle.Sprint("Config: "), fmtPaths.Sprint(result.ConfigPath)) - fmt.Printf("%s %s\n", fmtTitle.Sprint("Vaults: "), fmtLists.Sprint(result.SelectedVaults)) - fmt.Printf("%s %s\n", fmtTitle.Sprint("Agents: "), fmtLists.Sprint(agentNames)) - fmt.Printf("%s %s\n", fmtTitle.Sprint("Sandbox: "), fmtBooleans.Sprint(result.SandboxEnabled)) - - var confirmed bool - form := p.formWith(huh.NewForm(huh.NewGroup( - huh.NewConfirm().Title("Apply?").Value(&confirmed), - ))) - if err := form.Run(); err != nil { - return false, fmt.Errorf("showing summary: %w", err) - } - return confirmed, nil -} - -// ExistingConfig prompts the user when a config file already exists at path. -// validErr is nil if the config passed validation, or the error otherwise. -func (p *huhPrompter) ExistingConfig(path string, validErr error) (ExistingConfigAction, error) { - title := fmt.Sprintf("Config already exists at %s", path) - var desc string - continueLabel := "Continue with existing config" - if validErr == nil { - desc = "The existing config is valid." - } else { - desc = fmt.Sprintf("The existing config is invalid: %v", fmtErrors.Sprint(validErr)) - continueLabel = "Continue with invalid config (not recommended)" - } - var selected ExistingConfigAction - form := p.formWith(huh.NewForm(huh.NewGroup( - huh.NewSelect[ExistingConfigAction](). - Title(title). - Description(desc). - Options( - huh.NewOption(continueLabel, ActionContinue), - huh.NewOption("Overwrite with new config", ActionOverwrite), - huh.NewOption("Exit setup", ActionExit), - ).Value(&selected), - ))) - if err := form.Run(); err != nil { - return ActionExit, fmt.Errorf("prompting for existing config: %w", err) - } - return selected, nil -} - -// ShellHook asks whether to install the daemon autostart hook in rcFile. -func (p *huhPrompter) ShellHook(rcFile string) (bool, error) { - var confirmed bool - form := p.formWith(huh.NewForm(huh.NewGroup( - huh.NewConfirm(). - Title("Add daemon autostart to shell config?"). - Description(fmt.Sprintf("Appends locksmith daemon autostart to %s", rcFile)). - Value(&confirmed), - ))) - if err := form.Run(); err != nil { - return false, fmt.Errorf("prompting for shell hook: %w", err) - } - return confirmed, nil -} - -// GPGPinentry prompts whether to configure locksmith-pinentry in gpg-agent.conf. -func (p *huhPrompter) GPGPinentry(existingPinentry string) (bool, error) { - title := "Configure locksmith-pinentry for GPG passphrase prompts?" - desc := "Required for gopass vault when locksmith runs as a background daemon (no TTY)." - if existingPinentry != "" { - desc = fmt.Sprintf( - "WARNING: your gpg-agent.conf already has pinentry-program = %s\n"+ - " The existing line will be commented out and replaced.\n"+ - " You can restore it manually at any time.\n\n"+ - "Configure locksmith-pinentry?", - existingPinentry, - ) - } - var confirmed bool - form := p.formWith(huh.NewForm(huh.NewGroup( - huh.NewConfirm().Title(title).Description(desc).Value(&confirmed), - ))) - if err := form.Run(); err != nil { - return false, fmt.Errorf("prompting for GPG pinentry: %w", err) - } - return confirmed, nil -} - -// ClaudeHook asks whether to install the Locksmith hook into settingsPath. -func (p *huhPrompter) ClaudeHook(settingsPath string) (bool, error) { - var confirmed bool - form := p.formWith(huh.NewForm(huh.NewGroup( - huh.NewConfirm(). - Title("Install Locksmith hook for Claude Code?"). - Description(fmt.Sprintf( - "Adds a UserPromptSubmit hook to %s.\n"+ - "The hook injects LOCKSMITH_SESSION before each prompt.\n"+ - "Existing settings are preserved.", - settingsPath, - )). - Value(&confirmed), - ))) - if err := form.Run(); err != nil { - return false, fmt.Errorf("prompting for Claude hook: %w", err) - } - return confirmed, nil -} - -// BundleExtractPrompt asks the user how to resolve a sha256 mismatch on an -// already-extracted plugin or pinentry binary. -func (p *huhPrompter) BundleExtractPrompt(name, existingSHA, newSHA string) (bundled.ConflictResolution, error) { - if p.accessible { - return bundled.Keep, nil - } - var choice string - prompt := fmt.Sprintf( - "Existing %s differs from bundled (on disk %s vs bundled %s). Overwrite?", - name, bundled.ShortSHA(existingSHA), bundled.ShortSHA(newSHA), - ) - form := p.formWith(huh.NewForm( - huh.NewGroup( - huh.NewSelect[string](). - Title(prompt). - Options( - huh.NewOption("Overwrite this one", "y"), - huh.NewOption("Keep this one", "n"), - huh.NewOption("Overwrite all remaining", "all"), - huh.NewOption("Keep all remaining", "skip"), - ). - Value(&choice), - ), - )) - if err := form.Run(); err != nil { - return bundled.Keep, fmt.Errorf("BundleExtractPrompt: %w", err) - } - switch choice { - case "y": - return bundled.Overwrite, nil - case "n": - return bundled.Keep, nil - case "all": - return bundled.OverwriteAll, nil - case "skip": - return bundled.KeepAll, nil - default: - return bundled.Keep, nil - } -} - -// AgentMatches returns true if agent name matches the query (case-insensitive). -// "claude" matches "Claude Code" as a convenience alias. -func AgentMatches(name, query string) bool { - m := func(s string) string { - b := make([]byte, len(s)) - for i := 0; i < len(s); i++ { - c := s[i] - if c >= 'A' && c <= 'Z' { - c += 32 // ASCII offset from uppercase to lowercase - } - b[i] = c - } - return string(b) - } - ln, lq := m(name), m(query) - return ln == lq || (lq == "claude" && ln == "claude code") -} - -func isTerminal() bool { - fi, err := os.Stdin.Stat() - if err != nil { - return false - } - return fi.Mode()&os.ModeCharDevice != 0 -} - -// applyDaemonRestart restarts a currently-running locksmith daemon so -// the just-installed plugin bundle, config changes, and shell-rc -// updates take effect immediately. If no daemon is running it does -// nothing - the next agent prompt or shell startup will spawn one via -// the autostart shellhook. -// -// On restart failure this prints a warning rather than failing init; -// init has already written every config/file successfully and the -// user can recover with `locksmith restart` manually. -func applyDaemonRestart(result *InitResult) { - socket := daemon.DefaultSocketPath() - if !daemon.IsRunning(socket) { - return - } - if err := daemon.Restart(daemon.RestartOptions{Timeout: daemon.DefaultRestartTimeout}); err != nil { - fmt.Fprintf(os.Stderr, "warning: failed to restart daemon: %v\n", err) - return - } - result.DaemonRestarted = true -} - -// ApplyDaemonRestartForTest is an export-for-test seam. -func ApplyDaemonRestartForTest(result *InitResult) { applyDaemonRestart(result) } - -// printShellFallback prints manual instructions for adding the shell hook. -func printShellFallback(s shellhook.Shell, rcFile string) { - snippet := shellhook.Snippet(s) - if rcFile != "" { - fmt.Printf("\nTo start the daemon automatically, add to %s:\n\n %s\n\n", fmtPaths.Sprint(rcFile), snippet) - } else { - fmt.Printf("\nTo start the daemon automatically, add to your shell config:\n\n %s\n\n", snippet) - } -} diff --git a/internal/initflow/hooks.go b/internal/initflow/hooks/claude/claude.go similarity index 68% rename from internal/initflow/hooks.go rename to internal/initflow/hooks/claude/claude.go index 0ce784f..253e209 100644 --- a/internal/initflow/hooks.go +++ b/internal/initflow/hooks/claude/claude.go @@ -1,12 +1,15 @@ -package initflow +// Package claude installs and manages the Locksmith UserPromptSubmit +// hook in the global Claude Code settings file (~/.claude/settings.json). +package claude import ( - "encoding/json" "errors" "fmt" "io/fs" "os" "path/filepath" + + "github.com/lorem-dev/locksmith/internal/initflow/hooks" ) // locksmithPermissionRule is the Claude Code permissions entry that @@ -20,31 +23,31 @@ const locksmithPermissionRule = "Bash(locksmith:*)" // shell rc file). const nativeHookCmd = "locksmith session ensure --hook" -// ClaudeHookInstaller installs the Locksmith UserPromptSubmit hook into -// the global Claude Code settings file (~/.claude/settings.json). -type ClaudeHookInstaller struct { +// Installer installs the Locksmith UserPromptSubmit hook into the +// global Claude Code settings file (~/.claude/settings.json). +type Installer struct { locksmithConfigDir string // ~/.config/locksmith claudeConfigDir string // ~/.claude lastMigrated bool } -// NewClaudeHookInstaller creates a ClaudeHookInstaller for the given directories. -func NewClaudeHookInstaller(locksmithConfigDir, claudeConfigDir string) *ClaudeHookInstaller { - return &ClaudeHookInstaller{ +// New creates an Installer for the given directories. +func New(locksmithConfigDir, claudeConfigDir string) *Installer { + return &Installer{ locksmithConfigDir: locksmithConfigDir, claudeConfigDir: claudeConfigDir, } } -func (h *ClaudeHookInstaller) hookCmd() string { return nativeHookCmd } +func (h *Installer) hookCmd() string { return nativeHookCmd } // legacyScriptPath returns the on-disk location of the deprecated // shell-script hook for migration purposes. -func (h *ClaudeHookInstaller) legacyScriptPath() string { +func (h *Installer) legacyScriptPath() string { return filepath.Join(h.locksmithConfigDir, "agent-hook.sh") } -func (h *ClaudeHookInstaller) settingsPath() string { +func (h *Installer) settingsPath() string { return filepath.Join(h.claudeConfigDir, "settings.json") } @@ -53,15 +56,11 @@ func (h *ClaudeHookInstaller) settingsPath() string { // must be present for the integration to work without an extra approval // dialog; if any is missing, Install will top up the missing piece // idempotently. -func (h *ClaudeHookInstaller) IsInstalled() bool { - data, err := os.ReadFile(h.settingsPath()) +func (h *Installer) IsInstalled() bool { + settings, err := hooks.ReadSettings(h.settingsPath()) if err != nil { return false } - var settings map[string]any - if err := json.Unmarshal(data, &settings); err != nil { - return false - } if !h.findHookCmd(settings, h.hookCmd()) { return false } @@ -71,11 +70,11 @@ func (h *ClaudeHookInstaller) IsInstalled() bool { // Install merges the UserPromptSubmit hook entry and the locksmith // permission rule into ~/.claude/settings.json. Idempotent: calling // Install twice produces the same result as calling it once. -func (h *ClaudeHookInstaller) Install() error { +func (h *Installer) Install() error { return h.mergeSettings() } -func (h *ClaudeHookInstaller) mergeSettings() error { +func (h *Installer) mergeSettings() error { if err := os.MkdirAll( //nolint:gosec // G301: 0755 is standard for user home dirs h.claudeConfigDir, 0o755, @@ -83,21 +82,15 @@ func (h *ClaudeHookInstaller) mergeSettings() error { return fmt.Errorf("creating Claude config dir: %w", err) } - var settings map[string]any - data, readErr := os.ReadFile(h.settingsPath()) - switch { - case readErr == nil: - if jsonErr := json.Unmarshal(data, &settings); jsonErr != nil { + settings, err := hooks.ReadSettings(h.settingsPath()) + if err != nil { + if errors.Is(err, hooks.ErrMalformed) { return fmt.Errorf( "settings.json at %s is not valid JSON; refusing to overwrite: %w", - h.settingsPath(), jsonErr, + h.settingsPath(), err, ) } - case !os.IsNotExist(readErr): - return fmt.Errorf("reading %s: %w", h.settingsPath(), readErr) - } - if settings == nil { - settings = make(map[string]any) + return err } h.lastMigrated = h.migrateLegacyShellHook(settings) @@ -110,12 +103,12 @@ func (h *ClaudeHookInstaller) mergeSettings() error { } if !hookAlreadyInstalled { - hooks, ok := settings["hooks"].(map[string]any) + hooksMap, ok := settings["hooks"].(map[string]any) if !ok { - hooks = make(map[string]any) + hooksMap = make(map[string]any) } var ups []any - if raw, ok := hooks["UserPromptSubmit"].([]any); ok { + if raw, ok := hooksMap["UserPromptSubmit"].([]any); ok { ups = raw } ups = append(ups, map[string]any{ @@ -127,8 +120,8 @@ func (h *ClaudeHookInstaller) mergeSettings() error { }, }, }) - hooks["UserPromptSubmit"] = ups - settings["hooks"] = hooks + hooksMap["UserPromptSubmit"] = ups + settings["hooks"] = hooksMap } if len(missingRules) > 0 { @@ -147,13 +140,7 @@ func (h *ClaudeHookInstaller) mergeSettings() error { settings["permissions"] = perms } - out, err := json.MarshalIndent(settings, "", " ") - if err != nil { - return fmt.Errorf("marshaling settings: %w", err) - } - if err := os.WriteFile( //nolint:gosec // G306: settings.json is user-readable config - h.settingsPath(), out, 0o644, - ); err != nil { + if err := hooks.WriteSettings(h.settingsPath(), settings); err != nil { return fmt.Errorf("writing settings: %w", err) } return nil @@ -168,7 +155,7 @@ func (h *ClaudeHookInstaller) mergeSettings() error { // // Returns true if any change was made; the caller propagates this // into InitResult.LegacyHookMigrated for the init summary. -func (h *ClaudeHookInstaller) migrateLegacyShellHook(settings map[string]any) bool { +func (h *Installer) migrateLegacyShellHook(settings map[string]any) bool { cmdChanged := h.migrateHookCommands(settings) permChanged := h.stripLegacyPermission(settings) changed := cmdChanged || permChanged @@ -183,13 +170,13 @@ func (h *ClaudeHookInstaller) migrateLegacyShellHook(settings map[string]any) bo // migrateHookCommands rewrites legacy `command:` strings in the // UserPromptSubmit hook entries to nativeHookCmd. Returns true if any // rewrite happened. -func (h *ClaudeHookInstaller) migrateHookCommands(settings map[string]any) bool { +func (h *Installer) migrateHookCommands(settings map[string]any) bool { legacy := h.legacyScriptPath() - hooks, ok := settings["hooks"].(map[string]any) + hooksMap, ok := settings["hooks"].(map[string]any) if !ok { return false } - ups, ok := hooks["UserPromptSubmit"].([]any) + ups, ok := hooksMap["UserPromptSubmit"].([]any) if !ok { return false } @@ -216,7 +203,7 @@ func (h *ClaudeHookInstaller) migrateHookCommands(settings map[string]any) bool // stripLegacyPermission removes the Bash() allow rule. Returns // true if a rule was removed. -func (h *ClaudeHookInstaller) stripLegacyPermission(settings map[string]any) bool { +func (h *Installer) stripLegacyPermission(settings map[string]any) bool { legacyPerm := "Bash(" + h.legacyScriptPath() + ")" perms, ok := settings["permissions"].(map[string]any) if !ok { @@ -242,7 +229,7 @@ func (h *ClaudeHookInstaller) stripLegacyPermission(settings map[string]any) boo // LastInstallMigrated reports whether the most recent Install call // migrated a legacy shell-script hook. The init wizard uses this to // print a status line in the summary block. -func (h *ClaudeHookInstaller) LastInstallMigrated() bool { return h.lastMigrated } +func (h *Installer) LastInstallMigrated() bool { return h.lastMigrated } // requiredPermissionRules returns the permissions.allow entries // locksmith needs in ~/.claude/settings.json. The single rule @@ -250,50 +237,31 @@ func (h *ClaudeHookInstaller) LastInstallMigrated() bool { return h.lastMigrated // made by the agent through the Bash tool. The hook itself runs // through Claude Code's hook subsystem, not Bash, and therefore needs // no permission entry. -func (h *ClaudeHookInstaller) requiredPermissionRules() []string { +func (h *Installer) requiredPermissionRules() []string { return []string{locksmithPermissionRule} } // missingPermissionRules returns the subset of required rules not yet present // in settings.permissions.allow. -func (h *ClaudeHookInstaller) missingPermissionRules(settings map[string]any, required []string) []string { +func (h *Installer) missingPermissionRules(settings map[string]any, required []string) []string { var missing []string for _, rule := range required { - if !h.findPermissionRule(settings, rule) { + if !hooks.FindStringInAllow(settings, rule) { missing = append(missing, rule) } } return missing } -// findPermissionRule returns true if rule appears as a string entry in -// settings.permissions.allow. -func (h *ClaudeHookInstaller) findPermissionRule(settings map[string]any, rule string) bool { - perms, ok := settings["permissions"].(map[string]any) - if !ok { - return false - } - allow, ok := perms["allow"].([]any) - if !ok { - return false - } - for _, entry := range allow { - if s, ok := entry.(string); ok && s == rule { - return true - } - } - return false -} - // findHookCmd returns true if hookCmd appears as a command value anywhere in // the UserPromptSubmit hook entries of settings. -func (h *ClaudeHookInstaller) findHookCmd(settings map[string]any, hookCmd string) bool { - hooks, ok := settings["hooks"].(map[string]any) +func (h *Installer) findHookCmd(settings map[string]any, hookCmd string) bool { + hooksMap, ok := settings["hooks"].(map[string]any) if !ok { return false } var ups []any - if raw, ok := hooks["UserPromptSubmit"].([]any); ok { + if raw, ok := hooksMap["UserPromptSubmit"].([]any); ok { ups = raw } for _, entry := range ups { diff --git a/internal/initflow/hooks_test.go b/internal/initflow/hooks/claude/claude_test.go similarity index 98% rename from internal/initflow/hooks_test.go rename to internal/initflow/hooks/claude/claude_test.go index 04aad16..bd1f1eb 100644 --- a/internal/initflow/hooks_test.go +++ b/internal/initflow/hooks/claude/claude_test.go @@ -1,4 +1,4 @@ -package initflow_test +package claude_test import ( "encoding/json" @@ -7,17 +7,17 @@ import ( "strings" "testing" - "github.com/lorem-dev/locksmith/internal/initflow" + "github.com/lorem-dev/locksmith/internal/initflow/hooks/claude" ) -func makeHookInstaller(t *testing.T) (*initflow.ClaudeHookInstaller, string) { +func makeHookInstaller(t *testing.T) (*claude.Installer, string) { t.Helper() home := t.TempDir() lsDir := filepath.Join(home, ".config", "locksmith") claudeDir := filepath.Join(home, ".claude") os.MkdirAll(lsDir, 0o755) os.MkdirAll(claudeDir, 0o755) - return initflow.NewClaudeHookInstaller(lsDir, claudeDir), home + return claude.New(lsDir, claudeDir), home } func TestClaudeHookInstaller_Install_DoesNotWriteScript(t *testing.T) { @@ -396,7 +396,7 @@ func TestClaudeHookInstaller_Install_ClaudeDirError(t *testing.T) { os.WriteFile(claudeDir, []byte("not a directory"), 0o644) lsConfigPath := filepath.Join(home, ".config", "locksmith") - installer := initflow.NewClaudeHookInstaller(lsConfigPath, claudeDir) + installer := claude.New(lsConfigPath, claudeDir) if err := installer.Install(); err == nil { t.Error("Install() should fail when claude config dir path is a file") } diff --git a/internal/initflow/hooks/settings.go b/internal/initflow/hooks/settings.go new file mode 100644 index 0000000..e46194c --- /dev/null +++ b/internal/initflow/hooks/settings.go @@ -0,0 +1,72 @@ +// Package hooks provides shared primitives for installers that +// register Claude Code / Codex CLI hooks. The sub-packages (claude, +// codex) consume these helpers; this top-level file does not embed +// any installer logic itself. +package hooks + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" +) + +// ErrMalformed is returned by ReadSettings when the file exists but +// contains invalid JSON. Callers treat this as a hard failure - they +// must NOT overwrite a malformed user file. +var ErrMalformed = errors.New("settings file is not valid JSON") + +// ReadSettings loads a JSON object from path. Missing files are +// reported as (empty map, nil) so callers can treat them as a fresh +// install. A malformed file returns (nil, ErrMalformed wrapping the +// parse error) so callers can refuse to overwrite. +func ReadSettings(path string) (map[string]any, error) { + data, err := os.ReadFile(path) //nolint:gosec + if errors.Is(err, fs.ErrNotExist) { + return map[string]any{}, nil + } + if err != nil { + return nil, fmt.Errorf("reading %s: %w", path, err) + } + var settings map[string]any + if jsonErr := json.Unmarshal(data, &settings); jsonErr != nil { + return nil, fmt.Errorf("%w at %s: %v", ErrMalformed, path, jsonErr) + } + if settings == nil { + settings = map[string]any{} + } + return settings, nil +} + +// WriteSettings serialises settings as indented JSON and writes to +// path with 0644. +func WriteSettings(path string, settings map[string]any) error { + out, err := json.MarshalIndent(settings, "", " ") + if err != nil { + return fmt.Errorf("marshalling settings: %w", err) + } + if err := os.WriteFile(path, out, 0o644); err != nil { //nolint:gosec + return fmt.Errorf("writing %s: %w", path, err) + } + return nil +} + +// FindStringInAllow returns true if rule appears as a string entry in +// settings.permissions.allow. Non-string entries are ignored. +func FindStringInAllow(settings map[string]any, rule string) bool { + perms, ok := settings["permissions"].(map[string]any) + if !ok { + return false + } + allow, ok := perms["allow"].([]any) + if !ok { + return false + } + for _, entry := range allow { + if s, ok := entry.(string); ok && s == rule { + return true + } + } + return false +} diff --git a/internal/initflow/hooks/settings_test.go b/internal/initflow/hooks/settings_test.go new file mode 100644 index 0000000..e1ce073 --- /dev/null +++ b/internal/initflow/hooks/settings_test.go @@ -0,0 +1,80 @@ +package hooks_test + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/lorem-dev/locksmith/internal/initflow/hooks" +) + +func TestReadSettings_Missing(t *testing.T) { + got, err := hooks.ReadSettings(filepath.Join(t.TempDir(), "absent.json")) + if err != nil { + t.Fatalf("ReadSettings on missing: %v", err) + } + if len(got) != 0 { + t.Errorf("expected empty map, got %v", got) + } +} + +func TestReadSettings_Malformed_Refuses(t *testing.T) { + p := filepath.Join(t.TempDir(), "bad.json") + _ = os.WriteFile(p, []byte("not json {"), 0o644) + _, err := hooks.ReadSettings(p) + if !errors.Is(err, hooks.ErrMalformed) { + t.Errorf("got %v, want ErrMalformed", err) + } +} + +func TestReadSettings_ValidObject(t *testing.T) { + p := filepath.Join(t.TempDir(), "ok.json") + _ = os.WriteFile(p, []byte(`{"theme":"dark"}`), 0o644) + got, err := hooks.ReadSettings(p) + if err != nil { + t.Fatalf("ReadSettings: %v", err) + } + if got["theme"] != "dark" { + t.Errorf("got %v", got) + } +} + +func TestWriteSettings_RoundTrip(t *testing.T) { + p := filepath.Join(t.TempDir(), "out.json") + in := map[string]any{"k": "v"} + if err := hooks.WriteSettings(p, in); err != nil { + t.Fatalf("WriteSettings: %v", err) + } + got, err := hooks.ReadSettings(p) + if err != nil { + t.Fatalf("ReadSettings: %v", err) + } + if got["k"] != "v" { + t.Errorf("round-trip lost data: %v", got) + } +} + +func TestFindStringInAllow_Present(t *testing.T) { + s := map[string]any{ + "permissions": map[string]any{ + "allow": []any{"Bash(git:*)", "Bash(locksmith:*)"}, + }, + } + if !hooks.FindStringInAllow(s, "Bash(locksmith:*)") { + t.Error("expected true") + } +} + +func TestFindStringInAllow_Missing(t *testing.T) { + s := map[string]any{"permissions": map[string]any{"allow": []any{"Bash(git:*)"}}} + if hooks.FindStringInAllow(s, "Bash(locksmith:*)") { + t.Error("expected false") + } +} + +func TestFindStringInAllow_NoPermissionsKey(t *testing.T) { + if hooks.FindStringInAllow(map[string]any{}, "Bash(x:*)") { + t.Error("expected false") + } +} diff --git a/internal/initflow/prompter.go b/internal/initflow/prompter.go new file mode 100644 index 0000000..986b319 --- /dev/null +++ b/internal/initflow/prompter.go @@ -0,0 +1,393 @@ +package initflow + +import ( + "fmt" + "io" + "os" + "strings" + + "github.com/charmbracelet/huh" + "github.com/fatih/color" + + "github.com/lorem-dev/locksmith/internal/bundled" + "github.com/lorem-dev/locksmith/internal/config" +) + +var ( + fmtTitle = color.New(color.Bold) + fmtPaths = color.New(color.FgBlue) + fmtErrors = color.New(color.FgRed) + fmtLists = color.New(color.FgCyan) + fmtBooleans = color.New(color.FgMagenta) +) + +// huhPrompter is the production Prompter that drives charmbracelet/huh TUI forms. +type huhPrompter struct { + accessible bool + input io.Reader // nil = os.Stdin + output io.Writer // nil = os.Stderr (huh default for TUI output) +} + +// NewHuhPrompter creates a Prompter backed by charmbracelet/huh TUI forms. +// Pass nil for input and output to use the OS defaults (os.Stdin / os.Stderr). +// Inject custom readers/writers in tests to simulate user input without a real TTY. +func NewHuhPrompter(accessible bool, input io.Reader, output io.Writer) Prompter { + return &huhPrompter{accessible: accessible, input: input, output: output} +} + +// formWith applies shared I/O options to a form. +func (p *huhPrompter) formWith(f *huh.Form) *huh.Form { + f = f.WithAccessible(p.accessible) + if p.input != nil { + f = f.WithInput(p.input) + } + if p.output != nil { + f = f.WithOutput(p.output) + } + return f +} + +// ConfigLocation prompts for the config directory. +func (p *huhPrompter) ConfigLocation(defaultDir string) (string, error) { + var selected string + form := p.formWith(huh.NewForm(huh.NewGroup( + huh.NewSelect[string](). + Title("Where to store config?"). + Options( + huh.NewOption(fmt.Sprintf("%s (default)", defaultDir), defaultDir), + huh.NewOption("Custom path", "custom"), + ).Value(&selected), + ))) + if err := form.Run(); err != nil { + return "", fmt.Errorf("selecting config location: %w", err) + } + if selected != "custom" { + return selected, nil + } + var custom string + form2 := p.formWith(huh.NewForm(huh.NewGroup( + huh.NewInput().Title("Config directory:").Value(&custom), + ))) + if err := form2.Run(); err != nil { + return "", fmt.Errorf("entering custom config path: %w", err) + } + return config.ExpandPath(custom), nil +} + +// VaultSelection prompts for vault backend selection. +func (p *huhPrompter) VaultSelection(vaults []DetectedVault) ([]string, error) { + var implemented, planned []DetectedVault + for _, v := range vaults { + if v.Implemented { + implemented = append(implemented, v) + } else { + planned = append(planned, v) + } + } + if len(implemented) == 0 { + return nil, fmt.Errorf("no implemented vault backends available on this platform") + } + + options := make([]huh.Option[string], 0, len(implemented)) + for _, v := range implemented { + label := v.Type + if v.Detected { + label += " (detected)" + } + if !v.Available { + label += " (not available on this platform)" + } + options = append(options, huh.NewOption(label, v.Type)) + } + + var selected []string + for _, v := range implemented { + if v.Detected && v.Available { + selected = append(selected, v.Type) + } + } + + form := p.formWith(huh.NewForm(huh.NewGroup( + huh.NewMultiSelect[string](). + Title("Which vault backends do you use?"). + Description(plannedNote(planned)). + Options(options...).Value(&selected), + ))) + if err := form.Run(); err != nil { + return nil, fmt.Errorf("selecting vaults: %w", err) + } + return selected, nil +} + +// plannedNote formats a description listing planned vault backends. +// Returns empty string when planned is empty (huh shows no description). +func plannedNote(planned []DetectedVault) string { + if len(planned) == 0 { + return "" + } + labels := make([]string, len(planned)) + for i, v := range planned { + labels[i] = plannedLabel(v) + } + return "Planned (not yet supported): " + strings.Join(labels, ", ") + "." +} + +func plannedLabel(v DetectedVault) string { + if v.PlatformNote != "" { + return v.Type + " (" + v.PlatformNote + ")" + } + return v.Type +} + +// AgentSelection prompts which detected agents to configure. +func (p *huhPrompter) AgentSelection(agents []DetectedAgent) ([]DetectedAgent, error) { + var detected []DetectedAgent + for _, a := range agents { + if a.Detected { + detected = append(detected, a) + } + } + if len(detected) == 0 { + fmt.Println("No AI agents detected.") + return nil, nil + } + + var selection string + form := p.formWith(huh.NewForm(huh.NewGroup( + huh.NewSelect[string](). + Title("Install locksmith for detected agents?"). + Options( + huh.NewOption(fmt.Sprintf("All detected (%d)", len(detected)), "all"), + huh.NewOption("Select manually", "manual"), + huh.NewOption("Skip agent setup", "skip"), + ).Value(&selection), + ))) + if err := form.Run(); err != nil { + return nil, fmt.Errorf("selecting agents: %w", err) + } + + if selection == "skip" { + return nil, nil + } + if selection == "all" { + return detected, nil + } + + var options []huh.Option[string] + for _, a := range agents { + label := a.Name + if a.Detected { + label += " (detected)" + } + options = append(options, huh.NewOption(label, a.Name)) + } + var selectedNames []string + form2 := p.formWith(huh.NewForm(huh.NewGroup( + huh.NewMultiSelect[string]().Title("Select agents:").Options(options...).Value(&selectedNames), + ))) + if err := form2.Run(); err != nil { + return nil, fmt.Errorf("selecting agents manually: %w", err) + } + var result []DetectedAgent + for _, a := range agents { + for _, name := range selectedNames { + if a.Name == name { + result = append(result, a) + } + } + } + return result, nil +} + +// Sandbox prompts whether to install sandbox permission allowlists. +func (p *huhPrompter) Sandbox() (bool, error) { + var enabled bool + form := p.formWith(huh.NewForm(huh.NewGroup( + huh.NewConfirm(). + Title("Allow locksmith commands in agent sandboxes?"). + Description("locksmith get, session start/end, vault list/health"). + Value(&enabled), + ))) + if err := form.Run(); err != nil { + return false, fmt.Errorf("prompting for sandbox: %w", err) + } + return enabled, nil +} + +// Summary shows a summary and asks the user to confirm or cancel. +func (p *huhPrompter) Summary(result *InitResult) (bool, error) { + agentNames := make([]string, len(result.SelectedAgents)) + for i, a := range result.SelectedAgents { + agentNames[i] = a.Name + } + + fmt.Println(fmtTitle.Sprint("-- Summary --")) + + fmt.Printf("%s %s\n", fmtTitle.Sprint("Config: "), fmtPaths.Sprint(result.ConfigPath)) + fmt.Printf("%s %s\n", fmtTitle.Sprint("Vaults: "), fmtLists.Sprint(result.SelectedVaults)) + fmt.Printf("%s %s\n", fmtTitle.Sprint("Agents: "), fmtLists.Sprint(agentNames)) + fmt.Printf("%s %s\n", fmtTitle.Sprint("Sandbox: "), fmtBooleans.Sprint(result.SandboxEnabled)) + + var confirmed bool + form := p.formWith(huh.NewForm(huh.NewGroup( + huh.NewConfirm().Title("Apply?").Value(&confirmed), + ))) + if err := form.Run(); err != nil { + return false, fmt.Errorf("showing summary: %w", err) + } + return confirmed, nil +} + +// ExistingConfig prompts the user when a config file already exists at path. +// validErr is nil if the config passed validation, or the error otherwise. +func (p *huhPrompter) ExistingConfig(path string, validErr error) (ExistingConfigAction, error) { + title := fmt.Sprintf("Config already exists at %s", path) + var desc string + continueLabel := "Continue with existing config" + if validErr == nil { + desc = "The existing config is valid." + } else { + desc = fmt.Sprintf("The existing config is invalid: %v", fmtErrors.Sprint(validErr)) + continueLabel = "Continue with invalid config (not recommended)" + } + var selected ExistingConfigAction + form := p.formWith(huh.NewForm(huh.NewGroup( + huh.NewSelect[ExistingConfigAction](). + Title(title). + Description(desc). + Options( + huh.NewOption(continueLabel, ActionContinue), + huh.NewOption("Overwrite with new config", ActionOverwrite), + huh.NewOption("Exit setup", ActionExit), + ).Value(&selected), + ))) + if err := form.Run(); err != nil { + return ActionExit, fmt.Errorf("prompting for existing config: %w", err) + } + return selected, nil +} + +// ShellHook asks whether to install the daemon autostart hook in rcFile. +func (p *huhPrompter) ShellHook(rcFile string) (bool, error) { + var confirmed bool + form := p.formWith(huh.NewForm(huh.NewGroup( + huh.NewConfirm(). + Title("Add daemon autostart to shell config?"). + Description(fmt.Sprintf("Appends locksmith daemon autostart to %s", rcFile)). + Value(&confirmed), + ))) + if err := form.Run(); err != nil { + return false, fmt.Errorf("prompting for shell hook: %w", err) + } + return confirmed, nil +} + +// GPGPinentry prompts whether to configure locksmith-pinentry in gpg-agent.conf. +func (p *huhPrompter) GPGPinentry(existingPinentry string) (bool, error) { + title := "Configure locksmith-pinentry for GPG passphrase prompts?" + desc := "Required for gopass vault when locksmith runs as a background daemon (no TTY)." + if existingPinentry != "" { + desc = fmt.Sprintf( + "WARNING: your gpg-agent.conf already has pinentry-program = %s\n"+ + " The existing line will be commented out and replaced.\n"+ + " You can restore it manually at any time.\n\n"+ + "Configure locksmith-pinentry?", + existingPinentry, + ) + } + var confirmed bool + form := p.formWith(huh.NewForm(huh.NewGroup( + huh.NewConfirm().Title(title).Description(desc).Value(&confirmed), + ))) + if err := form.Run(); err != nil { + return false, fmt.Errorf("prompting for GPG pinentry: %w", err) + } + return confirmed, nil +} + +// ClaudeHook asks whether to install the Locksmith hook into settingsPath. +func (p *huhPrompter) ClaudeHook(settingsPath string) (bool, error) { + var confirmed bool + form := p.formWith(huh.NewForm(huh.NewGroup( + huh.NewConfirm(). + Title("Install Locksmith hook for Claude Code?"). + Description(fmt.Sprintf( + "Adds a UserPromptSubmit hook to %s.\n"+ + "The hook injects LOCKSMITH_SESSION before each prompt.\n"+ + "Existing settings are preserved.", + settingsPath, + )). + Value(&confirmed), + ))) + if err := form.Run(); err != nil { + return false, fmt.Errorf("prompting for Claude hook: %w", err) + } + return confirmed, nil +} + +// BundleExtractPrompt asks the user how to resolve a sha256 mismatch on an +// already-extracted plugin or pinentry binary. +func (p *huhPrompter) BundleExtractPrompt(name, existingSHA, newSHA string) (bundled.ConflictResolution, error) { + if p.accessible { + return bundled.Keep, nil + } + var choice string + prompt := fmt.Sprintf( + "Existing %s differs from bundled (on disk %s vs bundled %s). Overwrite?", + name, bundled.ShortSHA(existingSHA), bundled.ShortSHA(newSHA), + ) + form := p.formWith(huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title(prompt). + Options( + huh.NewOption("Overwrite this one", "y"), + huh.NewOption("Keep this one", "n"), + huh.NewOption("Overwrite all remaining", "all"), + huh.NewOption("Keep all remaining", "skip"), + ). + Value(&choice), + ), + )) + if err := form.Run(); err != nil { + return bundled.Keep, fmt.Errorf("BundleExtractPrompt: %w", err) + } + switch choice { + case "y": + return bundled.Overwrite, nil + case "n": + return bundled.Keep, nil + case "all": + return bundled.OverwriteAll, nil + case "skip": + return bundled.KeepAll, nil + default: + return bundled.Keep, nil + } +} + +// AgentMatches returns true if agent name matches the query (case-insensitive). +// "claude" matches "Claude Code" as a convenience alias. +func AgentMatches(name, query string) bool { + m := func(s string) string { + b := make([]byte, len(s)) + for i := 0; i < len(s); i++ { + c := s[i] + if c >= 'A' && c <= 'Z' { + c += 32 // ASCII offset from uppercase to lowercase + } + b[i] = c + } + return string(b) + } + ln, lq := m(name), m(query) + return ln == lq || (lq == "claude" && ln == "claude code") +} + +func isTerminal() bool { + fi, err := os.Stdin.Stat() + if err != nil { + return false + } + return fi.Mode()&os.ModeCharDevice != 0 +} diff --git a/internal/initflow/select.go b/internal/initflow/select.go new file mode 100644 index 0000000..79cf8c4 --- /dev/null +++ b/internal/initflow/select.go @@ -0,0 +1,133 @@ +package initflow + +import ( + "fmt" + "path/filepath" + + "github.com/lorem-dev/locksmith/internal/config" + claudehook "github.com/lorem-dev/locksmith/internal/initflow/hooks/claude" + "github.com/lorem-dev/locksmith/internal/shellhook" +) + +// selectVaults fills result.SelectedVaults based on detection and prompting. +func selectVaults(result *InitResult, opts InitOptions, prompter Prompter) error { + detectedVaults := DetectVaultsFn() + if opts.Auto { + for _, v := range detectedVaults { + if v.Detected && v.Implemented { + result.SelectedVaults = append(result.SelectedVaults, v.Type) + } + } + return nil + } + var err error + result.SelectedVaults, err = prompter.VaultSelection(detectedVaults) + if err != nil { + return fmt.Errorf("selecting vaults: %w", err) + } + return nil +} + +// selectGPGPinentry prompts for GPG pinentry configuration when gopass is selected. +func selectGPGPinentry(result *InitResult, prompter Prompter, homeDir string) error { + gopassSelected := false + for _, v := range result.SelectedVaults { + if v == config.VaultGopass { + gopassSelected = true + break + } + } + if !gopassSelected { + return nil + } + gnupgDir := filepath.Join(homeDir, ".gnupg") + existing := ReadExistingPinentry(gnupgDir) + configure, err := prompter.GPGPinentry(existing) + if err != nil { + return fmt.Errorf("prompting for GPG pinentry: %w", err) + } + result.GPGPinentryConfigured = configure + return nil +} + +// selectAgents fills result.SelectedAgents based on detection, auto mode, or prompting. +func selectAgents(result *InitResult, opts InitOptions, prompter Prompter, homeDir string) error { + detectedAgents := DetectAgents(homeDir) + switch { + case opts.AgentOnly != "": + for _, a := range detectedAgents { + if AgentMatches(a.Name, opts.AgentOnly) { + result.SelectedAgents = append(result.SelectedAgents, a) + } + } + case opts.Auto: + for _, a := range detectedAgents { + if a.Detected { + result.SelectedAgents = append(result.SelectedAgents, a) + } + } + default: + var err error + result.SelectedAgents, err = prompter.AgentSelection(detectedAgents) + if err != nil { + return fmt.Errorf("selecting agents: %w", err) + } + } + return nil +} + +// detectShellHookConsent determines whether to install the daemon autostart hook. +func detectShellHookConsent(result *InitResult, opts InitOptions, prompter Prompter) error { + detectedShell := shellhook.DetectShell() + rcFile, shellKnown := shellhook.RCFile(detectedShell) + result.ShellHookShell = detectedShell + result.ShellHookRCFile = rcFile + if !shellKnown { + return nil + } + alreadyInstalled := false + if ok, isInstalledErr := shellhook.IsInstalled(rcFile); isInstalledErr == nil { + alreadyInstalled = ok + } + switch { + case alreadyInstalled: + result.ShellHookAlreadyPresent = true + case opts.Auto: + result.ShellHookInstall = true + default: + var err error + result.ShellHookInstall, err = prompter.ShellHook(rcFile) + if err != nil { + return fmt.Errorf("prompting for shell hook: %w", err) + } + } + return nil +} + +// detectClaudeHookConsent checks if Claude Code is selected and asks for hook install consent. +func detectClaudeHookConsent(result *InitResult, opts InitOptions, prompter Prompter, homeDir string) error { + claudeSettingsPath := filepath.Join(homeDir, ".claude", "settings.json") + for _, agent := range result.SelectedAgents { + if agent.Name != "Claude Code" { + continue + } + installer := claudehook.New( + filepath.Join(homeDir, ".config", "locksmith"), + filepath.Join(homeDir, ".claude"), + ) + switch { + case installer.IsInstalled(): + result.ClaudeHookAlreadyPresent = true + case opts.Auto: + result.ClaudeHookConfirmed = true + default: + var err error + result.ClaudeHookConfirmed, err = prompter.ClaudeHook(claudeSettingsPath) + if err != nil { + return fmt.Errorf("prompting for Claude hook: %w", err) + } + } + break // only one Claude Code entry possible + } + return nil +} diff --git a/internal/initflow/types.go b/internal/initflow/types.go new file mode 100644 index 0000000..63848fe --- /dev/null +++ b/internal/initflow/types.go @@ -0,0 +1,84 @@ +package initflow + +import ( + "github.com/lorem-dev/locksmith/internal/bundled" + "github.com/lorem-dev/locksmith/internal/shellhook" +) + +// ExistingConfigAction is the user's choice when a config file already exists. +type ExistingConfigAction int + +const ( + // ActionContinue keeps the existing file; applyInit skips writing config.yaml. + ActionContinue ExistingConfigAction = iota + // ActionOverwrite proceeds through the wizard and replaces the file. + ActionOverwrite + // ActionExit cancels init without changes. + ActionExit +) + +// Prompter is the interface for all user-facing interactive prompts. +// The default implementation uses charmbracelet/huh TUI forms; tests can inject +// a mock via InitOptions.Prompter to exercise RunInit without a real TTY. +type Prompter interface { + // ExistingConfig is called when a config file already exists at path. + // validErr is nil if the file passes validation, or the validation error otherwise. + ExistingConfig(path string, validErr error) (ExistingConfigAction, error) + ConfigLocation(defaultDir string) (string, error) + VaultSelection(vaults []DetectedVault) ([]string, error) + AgentSelection(agents []DetectedAgent) ([]DetectedAgent, error) + Sandbox() (bool, error) + Summary(result *InitResult) (bool, error) + // GPGPinentry asks whether to configure locksmith-pinentry in gpg-agent.conf. + // existingPinentry is the current pinentry-program value (empty if none). + GPGPinentry(existingPinentry string) (bool, error) + // ShellHook asks whether to append the daemon autostart snippet to rcFile. + ShellHook(rcFile string) (bool, error) + // ClaudeHook asks whether to install the Locksmith UserPromptSubmit hook + // into settingsPath (~/.claude/settings.json). Shows what will be changed. + ClaudeHook(settingsPath string) (bool, error) + // BundleExtractPrompt is called when an existing plugin or pinentry file + // has different content from the bundled version. Returns the user's + // resolution choice. existingSHA and newSHA are short (8-char) hex + // strings suitable for display. + BundleExtractPrompt(name, existingSHA, newSHA string) (bundled.ConflictResolution, error) +} + +// InitOptions controls the behaviour of RunInit. +type InitOptions struct { + NoTUI bool + Auto bool + AgentOnly string + SkipAgents bool + // Prompter overrides the default huh-based prompts. Nil uses the TUI default. + // Inject a mock in tests to drive non-auto RunInit flows without a real TTY. + Prompter Prompter +} + +// InitResult holds the resolved configuration from the init wizard. +type InitResult struct { + ConfigPath string + SelectedVaults []string + SelectedAgents []DetectedAgent + SandboxEnabled bool + GPGPinentryConfigured bool // true if the user opted to configure locksmith-pinentry + ConfigPreexisted bool // true when an existing config was found and kept + ShellHookInstall bool // true if user agreed (or --auto) to install + ShellHookAlreadyPresent bool // true if hook marker was already in rc file + ShellHookRCFile string // rc file path; empty when shell is unknown + ShellHookShell shellhook.Shell // detected shell + ClaudeHookConfirmed bool // user approved (or --auto); set in RunInit before applyInit + ClaudeHookInstalled bool // hook was written successfully; set in applyInit + ClaudeHookAlreadyPresent bool // hook was already in settings.json; install skipped + PathHookInstalled bool // true when a PATH snippet was appended to rc + PathHookRCFile string // rc file path; empty when skipped + DaemonRestarted bool // true when init restarted a running daemon + LegacyHookMigrated bool // true when the legacy shell hook was migrated +} + +// DetectVaultsFnType is the function signature for detecting vault backends. +type DetectVaultsFnType func() []DetectedVault + +// DetectVaultsFn is the function used to detect vault backends. Replaced in +// tests to inject a stub without touching the real filesystem. +var DetectVaultsFn DetectVaultsFnType = DetectVaults From 6cd286c3458981f5bcf02953cf37bab554478d79 Mon Sep 17 00:00:00 2001 From: Lorem Dev Date: Sat, 23 May 2026 00:09:55 +0200 Subject: [PATCH 18/25] feat(init): Codex SessionStart hook and per-agent template rewrites with 3 vaults - New internal/initflow/hooks/codex.Installer registers a Codex SessionStart hook in ~/.codex/hooks.json. The hook command is 'locksmith session ensure --quiet >/dev/null 2>&1 || true' with a 10s timeout. Best-effort: any failure is swallowed so Codex startup is never blocked. Codex hooks cannot inject env vars, so the agent still has to 'export LOCKSMITH_SESSION=\$(locksmith session ensure --quiet)' in shell; the hook just removes the vault-unlock latency spike on the first secret request. - locksmith init detects Codex and (with --auto or user consent) installs the hook via the new applyCodexHook step. Adds Prompter.CodexHook to the interface. - Per-agent templates rewritten to: - mention only their own agent (no cross-agent text) - show usage examples for all three shipped vault types: gopass, keychain, and op (1Password) - include the export LOCKSMITH_SESSION=\$(locksmith session ensure --quiet) instruction for Codex / Gemini / OpenCode / generic (no env-injection hook); claude templates omit it (their hook handles env). - CLAUDE.md gets an 'Adding a new vault plugin' checklist that enumerates every doc + every template that must be updated when shipping a new vault. - Docs sweep: agent-integration.md gains Codex Hook Setup, CHANGES.md adds the missing bullets (restart command, PATH bootstrap, Codex hook, per-agent templates) and drops stale ones, README and CONTRIBUTING list onepassword consistently, PLUGINS.md links the onepassword plugin README, verification.md anchor fixed. --- CHANGES.md | 46 ++++-- CLAUDE.md | 33 ++++ CONTRIBUTING.md | 4 +- PLUGINS.md | 2 + README.md | 8 +- docs/agent-integration.md | 65 +++++++- docs/verification.md | 2 +- internal/initflow/agents.go | 112 ++++--------- internal/initflow/agents/claude/claude.go | 51 ++++++ .../initflow/agents/claude/claude_test.go | 60 +++++++ .../agents/claude/templates/claude_md.md.tmpl | 21 +++ .../claude/templates/claude_skill.md.tmpl | 23 +++ internal/initflow/agents/codex/codex.go | 42 +++++ internal/initflow/agents/codex/codex_test.go | 51 ++++++ .../codex/templates/codex_agents.md.tmpl | 28 ++++ internal/initflow/agents/gemini/gemini.go | 41 +++++ .../initflow/agents/gemini/gemini_test.go | 51 ++++++ .../agents/gemini/templates/gemini_md.md.tmpl | 34 ++++ internal/initflow/agents/generic/generic.go | 40 +++++ .../initflow/agents/generic/generic_test.go | 57 +++++++ .../generic/templates/instructions.md.tmpl | 28 ++++ internal/initflow/agents/marker/marker.go | 5 +- internal/initflow/agents/opencode/opencode.go | 41 +++++ .../initflow/agents/opencode/opencode_test.go | 51 ++++++ .../opencode/templates/instructions.md.tmpl | 30 ++++ internal/initflow/agents_test.go | 121 ++++++++++++-- internal/initflow/apply.go | 24 +++ internal/initflow/detect.go | 1 + internal/initflow/flow.go | 8 + internal/initflow/flow_test.go | 38 +++++ internal/initflow/hooks/claude/claude.go | 12 +- internal/initflow/hooks/codex/codex.go | 120 ++++++++++++++ internal/initflow/hooks/codex/codex_test.go | 150 ++++++++++++++++++ internal/initflow/hooks/settings.go | 2 +- internal/initflow/prompter.go | 20 +++ internal/initflow/select.go | 34 ++++ .../templates/agent_instructions.md.tmpl | 27 ---- internal/initflow/templates/agents_md.md.tmpl | 32 ---- internal/initflow/templates/claude_md.md.tmpl | 25 --- .../initflow/templates/claude_skill.md.tmpl | 31 ---- .../initflow/templates/codex_agents.md.tmpl | 26 --- internal/initflow/templates/gemini_md.md.tmpl | 31 ---- internal/initflow/types.go | 7 + 43 files changed, 1332 insertions(+), 303 deletions(-) create mode 100644 internal/initflow/agents/claude/claude.go create mode 100644 internal/initflow/agents/claude/claude_test.go create mode 100644 internal/initflow/agents/claude/templates/claude_md.md.tmpl create mode 100644 internal/initflow/agents/claude/templates/claude_skill.md.tmpl create mode 100644 internal/initflow/agents/codex/codex.go create mode 100644 internal/initflow/agents/codex/codex_test.go create mode 100644 internal/initflow/agents/codex/templates/codex_agents.md.tmpl create mode 100644 internal/initflow/agents/gemini/gemini.go create mode 100644 internal/initflow/agents/gemini/gemini_test.go create mode 100644 internal/initflow/agents/gemini/templates/gemini_md.md.tmpl create mode 100644 internal/initflow/agents/generic/generic.go create mode 100644 internal/initflow/agents/generic/generic_test.go create mode 100644 internal/initflow/agents/generic/templates/instructions.md.tmpl create mode 100644 internal/initflow/agents/opencode/opencode.go create mode 100644 internal/initflow/agents/opencode/opencode_test.go create mode 100644 internal/initflow/agents/opencode/templates/instructions.md.tmpl create mode 100644 internal/initflow/hooks/codex/codex.go create mode 100644 internal/initflow/hooks/codex/codex_test.go delete mode 100644 internal/initflow/templates/agent_instructions.md.tmpl delete mode 100644 internal/initflow/templates/agents_md.md.tmpl delete mode 100644 internal/initflow/templates/claude_md.md.tmpl delete mode 100644 internal/initflow/templates/claude_skill.md.tmpl delete mode 100644 internal/initflow/templates/codex_agents.md.tmpl delete mode 100644 internal/initflow/templates/gemini_md.md.tmpl diff --git a/CHANGES.md b/CHANGES.md index 93b193a..5ec6418 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -9,21 +9,28 @@ per session; if the retry also fails, the error is forwarded to the AI client unchanged. Detection looks only at structural fields - no keyword matching against `result.content[].text`. -- `locksmith init` now also adds two entries to - `permissions.allow` in `~/.claude/settings.json`: `Bash(locksmith:*)` - for the agent's direct `locksmith` calls and an explicit allow rule - for the absolute path to the UserPromptSubmit hook script. Both are - added idempotently; existing installs are topped up on the next - `init`. -- The Claude Code UserPromptSubmit hook script - (`~/.config/locksmith/agent-hook.sh`) is now re-rendered from the - embedded template on every `locksmith init` run, including when the - settings.json entries are already present. This keeps the script in - sync with the latest locksmith release. +- Per-agent installer logic moved into `internal/initflow/agents//` + subpackages (claude, codex, gemini, opencode, generic), each with + embedded templates. `AgentWriter` in `agents.go` is now a thin switch + dispatcher. Gemini CLI is now detected by `DetectAgents` and dispatched + to the new `gemini` subpackage (previously dead code). The dead + `agents_md.md.tmpl` template was removed. +- `locksmith init` now adds `Bash(locksmith:*)` to + `permissions.allow` in `~/.claude/settings.json` so the agent can + call `locksmith` directly without per-call approval. Idempotent. - Agent instruction templates (Claude/Codex/Gemini/OpenCode and the generic agent file) are now wrapped with `` / `` markers so the locksmith-managed block can be located and updated reliably. +- `locksmith restart` command added: stops the running daemon and + starts a fresh one. Flags `--timeout` and `--no-start`. Use after a + binary upgrade or plugin re-extract; see `docs/restart.md`. +- `locksmith init` appends an idempotent PATH-augmentation block to + the user's shell rc (`~/.zshrc` / `~/.bashrc` / + `~/.config/fish/config.fish`) when the locksmith binary directory + is not already on `$PATH`. Skipped silently when the directory is + already on PATH or when the rc file does not exist (init never + creates new rc files). - Add `vaults..vault` config field, delivered to plugins as `opts["vault"]`; used by the 1Password plugin to qualify non-`op://` paths. - Add 1Password vault plugin backed by the op CLI, auto-detected when `op` is installed. - docs(cli): drop Touch ID wording from CLI help, README, configuration @@ -42,13 +49,20 @@ `~/.claude/settings.json` are rewritten in place on the next `locksmith init`, the matching `Bash(.../agent-hook.sh)` permission rule is dropped, and the on-disk script is removed. -- Drop `export LOCKSMITH_SESSION=$(...)` instructions from every agent - instruction template. `locksmith get` auto-starts a session when - `LOCKSMITH_SESSION` is unset and the Claude hook injects it - preemptively, so the export was redundant and led agents to copy - the literal `$(...)` syntax into Bash tool calls. - `locksmith init` now automatically restarts a running daemon at the end of its run so plugin/config changes take effect immediately. +- `locksmith init` now installs a Codex `SessionStart` hook in + `~/.codex/hooks.json` when Codex is detected. The hook pre-warms + the locksmith daemon so vault unlock (passphrase, biometric) fires + before the agent's first secret request. Best-effort: any failure + is swallowed so Codex startup is never blocked. Codex prompts the + user to trust the hook on next run. +- Agent instruction templates are now agent-specific: each template + describes only its own agent's session protocol (Claude relies on + the UserPromptSubmit hook; Codex / Gemini CLI / OpenCode / generic + use `export LOCKSMITH_SESSION=$(locksmith session ensure --quiet)`) + and includes usage examples for all three shipped vault types + (`gopass`, `keychain`, `1password`). ## Version v0.3.0 - 2026-05-15 diff --git a/CLAUDE.md b/CLAUDE.md index 74c5e12..6e8de7c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,6 +45,7 @@ When conversing with the user, always respond in the user's language. - SDK: `github.com/lorem-dev/locksmith/sdk` - Gopass plugin: `github.com/lorem-dev/locksmith-plugin-gopass` - Keychain plugin: `github.com/lorem-dev/locksmith-plugin-keychain` +- 1Password plugin: `github.com/lorem-dev/locksmith-plugin-1password` ## Project Structure - `cmd/locksmith/` - CLI + daemon entry point @@ -205,6 +206,38 @@ than duplicating it. Whenever a plugin's behavior, configuration fields, or external requirements change, update `plugins//README.md` in the same commit. +## Adding a new vault plugin + +When you add a new vault plugin under `plugins//`, update the +following in the same change: + +1. `internal/config/types.go` - add a `Vault` constant. +2. `internal/initflow/detect.go` - add detection of the vault binary + and flip `Implemented: true` once the plugin is shipped. +3. `docs/configuration.md` - new section describing the vault type + and its options. +4. `docs/plugins/README.md` - add to the bundled-plugins list. +5. `README.md` - update the vault-comparison table and the + Configuration example. +6. `PLUGINS.md` - add to the opening paragraph. +7. `CHANGES.md` - bullet under `## Development`. +8. **Agent templates** - update every template under + `internal/initflow/agents//templates/` to add a usage + example in the "Retrieving Secrets" section: + + locksmith get --vault --path "" + + Templates touched: `agents/claude/templates/claude_md.md.tmpl`, + `agents/claude/templates/claude_skill.md.tmpl`, + `agents/codex/templates/codex_agents.md.tmpl`, + `agents/gemini/templates/gemini_md.md.tmpl`, + `agents/opencode/templates/instructions.md.tmpl`, and + `agents/generic/templates/instructions.md.tmpl`. + +9. Run `make verify` to ensure all gates pass. The cross-template + tests in `internal/initflow/agents_test.go` block regressions by + asserting that every shipped vault appears in every template. + ## Bundled Plugins Default plugins (`gopass`, `keychain`) and `locksmith-pinentry` ship diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 69ea3e6..bab060f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -270,8 +270,8 @@ docs completeness, and CHANGES.md checks - run it before submitting. ## Plugin versioning and compatibility -Bundled plugins (`plugins/gopass`, `plugins/keychain`) and any -third-party plugins follow the rules below. `make test-race` +Bundled plugins (`plugins/gopass`, `plugins/keychain`, `plugins/onepassword`) +and any third-party plugins follow the rules below. `make test-race` enforces them - a plugin PR cannot land green without satisfying every check. diff --git a/PLUGINS.md b/PLUGINS.md index f13d19f..9efe14d 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -68,3 +68,5 @@ version compatible with your `locksmith`. See plugin guide. - [plugins/keychain/README.md](plugins/keychain/README.md) - built-in keychain plugin guide. +- [plugins/onepassword/README.md](plugins/onepassword/README.md) - + built-in 1Password plugin guide. diff --git a/README.md b/README.md index 0526ffc..39875cf 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ locksmith init `locksmith init` is an interactive wizard: it detects your available vaults (Keychain, gopass, 1Password), writes a starter config, and installs hooks for the AI -agents you use (Claude Code, Cursor, Copilot, Codex, Gemini CLI) so they get +agents you use (Claude Code, Codex, Gemini CLI, OpenCode) so they get a `LOCKSMITH_SESSION` automatically. The daemon is started by the installed shell hook on the next shell session. @@ -203,15 +203,15 @@ HTTP) instead, which has no long-lived GET. See the [Configuration Reference](docs/configuration.md#mcp-servers) for configuring named servers in `config.yaml`. For client-specific notes -(Claude Code, Cursor, Copilot, Codex, Gemini CLI), see +(Claude Code, Codex, Gemini CLI, OpenCode), see [Agent Integration](docs/agent-integration.md). ## Agent Usage Locksmith provides a session-aware CLI for AI agents. See [Agent Integration](docs/agent-integration.md) for the full protocol and -platform-specific setup (Claude Code hooks, Gemini CLI, Cursor, Copilot, -Codex). +platform-specific setup (Claude Code hooks, Codex hook, Gemini CLI, +OpenCode). For Claude Code, run `locksmith init` - the `UserPromptSubmit` hook is installed automatically. The hook command is `locksmith session ensure diff --git a/docs/agent-integration.md b/docs/agent-integration.md index d216228..a8d9c24 100644 --- a/docs/agent-integration.md +++ b/docs/agent-integration.md @@ -62,11 +62,14 @@ each sub-agent must obtain its own session independently. | Platform | Session automation | Sub-agent passing | Notes | |----------|--------------------|-------------------|-------| -| Claude Code | Hook (auto-installed by `locksmith init`) | Via instructions | Restart Claude Code after `init` | -| Gemini CLI | Via instructions | Via instructions | | -| Cursor | Via instructions | Via instructions | | -| Copilot CLI | Via instructions | Via instructions | | -| Codex | Via instructions | Via instructions | | +| Claude Code | UserPromptSubmit hook injects env (auto-installed by `locksmith init`) | Via instructions | Restart Claude Code after `init` | +| Codex | SessionStart hook pre-warms daemon (auto-installed by `locksmith init`); env still via instructions | Via instructions | Codex prompts the user to trust the hook on next run | +| Gemini CLI | Via instructions in `GEMINI.md` (no hook) | Via instructions | | +| OpenCode | Via instructions in `instructions.md` (no hook) | Via instructions | | + +Other agents (Cursor, Copilot CLI, etc.) are not auto-detected by +`locksmith init`; they fall through to the generic installer which +writes a standalone `~/.config/locksmith/agent-instructions.md`. ## Claude Code Hook Setup @@ -140,6 +143,58 @@ esac The hook exits silently if the locksmith daemon is not running, so it never blocks agent work. +## Codex Hook Setup + +Run `locksmith init` - when Codex is detected, a `SessionStart` hook +is added to `~/.codex/hooks.json` that pre-warms the locksmith daemon +session so vault unlock (passphrase, biometric) fires before the +agent's first secret request. + +Unlike the Claude Code hook, the Codex hook does not inject +environment variables. Codex hooks cannot deliver env vars to the +agent shell, so users still run + +```bash +export LOCKSMITH_SESSION=$(locksmith session ensure --quiet) +``` + +in their shell session (or rely on `locksmith get` auto-starting a +session). The Codex SessionStart hook only removes the latency spike +on the first vault touch. + +The hook installed by `locksmith init` is best-effort: stdout and +stderr are redirected to `/dev/null` and the command trails `|| true` +so it never blocks Codex startup. The install is idempotent: running +`init` again does not duplicate the entry, and third-party +SessionStart hooks already present in `hooks.json` are preserved. + +Codex prompts the user to trust new hooks on the next session start +(`/hooks` command). Locksmith does not auto-trust the hook on the +user's behalf. + +### Manual setup + +If you prefer to install the hook without using `init`, add to +`~/.codex/hooks.json`: + +```json +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "locksmith session ensure --quiet >/dev/null 2>&1 || true", + "timeout": 10 + } + ] + } + ] + } +} +``` + ## Locksmith Config Reference ```yaml diff --git a/docs/verification.md b/docs/verification.md index 937171e..0b8bbfb 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -75,5 +75,5 @@ stay verifiable. - [`install.md`](install.md) - install paths and the install-script flags -- [`../CONTRIBUTING.md#release-signing-setup`](../CONTRIBUTING.md#release-signing-setup) - +- [`../CONTRIBUTING.md#release-signing-ci`](../CONTRIBUTING.md#release-signing-ci) - how the maintainer generates and rotates the key diff --git a/internal/initflow/agents.go b/internal/initflow/agents.go index d707a3d..9bafb4a 100644 --- a/internal/initflow/agents.go +++ b/internal/initflow/agents.go @@ -1,103 +1,55 @@ +// Package initflow agent dispatcher: routes each detected AI agent +// to its per-agent install subpackage under agents//. package initflow import ( - "embed" "fmt" - "os" - "path/filepath" - "github.com/lorem-dev/locksmith/internal/initflow/agents/marker" + "github.com/lorem-dev/locksmith/internal/initflow/agents/claude" + "github.com/lorem-dev/locksmith/internal/initflow/agents/codex" + "github.com/lorem-dev/locksmith/internal/initflow/agents/gemini" + "github.com/lorem-dev/locksmith/internal/initflow/agents/generic" + "github.com/lorem-dev/locksmith/internal/initflow/agents/opencode" ) -//go:embed templates/* -var templates embed.FS - -// mustReadTemplate reads an embedded template by name and panics if not found. -// Templates are embedded at compile time; a missing template is a programming error. -func mustReadTemplate(name string) []byte { - data, err := templates.ReadFile(name) - if err != nil { - panic(fmt.Sprintf("embedded template %q not found: %v", name, err)) - } - return data -} - -// AgentWriter installs locksmith instructions into AI agent configuration directories. +// AgentWriter installs locksmith instructions for AI agents by +// dispatching to per-agent subpackages. type AgentWriter struct { HomeDir string } -// NewAgentWriter creates an AgentWriter for the given home directory. +// NewAgentWriter creates an AgentWriter rooted at homeDir. func NewAgentWriter(homeDir string) *AgentWriter { return &AgentWriter{HomeDir: homeDir} } -// Install writes locksmith instructions for the given agent. +// Install writes locksmith integration files for the given agent. func (w *AgentWriter) Install(agent DetectedAgent) error { switch agent.Name { case "Claude Code": - return w.installClaudeCode(agent) + if err := claude.Install(w.HomeDir, agent.ConfigDir); err != nil { + return fmt.Errorf("installing Claude Code: %w", err) + } + return nil case "Codex": - return w.installCodex(agent) + if err := codex.Install(w.HomeDir, agent.ConfigDir); err != nil { + return fmt.Errorf("installing Codex: %w", err) + } + return nil + case "Gemini CLI": + if err := gemini.Install(w.HomeDir, agent.ConfigDir); err != nil { + return fmt.Errorf("installing Gemini CLI: %w", err) + } + return nil case "OpenCode": - return w.installOpenCode(agent) + if err := opencode.Install(w.HomeDir, agent.ConfigDir); err != nil { + return fmt.Errorf("installing OpenCode: %w", err) + } + return nil default: - return w.installGeneric() - } -} - -func (w *AgentWriter) installClaudeCode(agent DetectedAgent) error { - skillDir := filepath.Join(agent.ConfigDir, "skills") - if err := os.MkdirAll(skillDir, 0o755); err != nil { //nolint:gosec // G301: 0755 is standard for user config dirs - return fmt.Errorf("creating skills dir: %w", err) - } - skillContent := mustReadTemplate("templates/claude_skill.md.tmpl") - if err := os.WriteFile( //nolint:gosec // G306: skill files are documentation, readable by user is intentional - filepath.Join(skillDir, "locksmith.md"), skillContent, 0o644, - ); err != nil { - return fmt.Errorf("writing skill: %w", err) - } - mdContent := mustReadTemplate("templates/claude_md.md.tmpl") - return marker.Upsert(filepath.Join(agent.ConfigDir, "CLAUDE.md"), string(mdContent)) -} - -func (w *AgentWriter) installCodex(agent DetectedAgent) error { - if err := os.MkdirAll( //nolint:gosec // G301: 0755 is standard for user config dirs - agent.ConfigDir, - 0o755, - ); err != nil { - return fmt.Errorf("creating config dir: %w", err) - } - content := mustReadTemplate("templates/codex_agents.md.tmpl") - return marker.Upsert(filepath.Join(agent.ConfigDir, "AGENTS.md"), string(content)) -} - -func (w *AgentWriter) installOpenCode(agent DetectedAgent) error { - if err := os.MkdirAll( //nolint:gosec // G301: 0755 is standard for user config dirs - agent.ConfigDir, - 0o755, - ); err != nil { - return fmt.Errorf("creating config dir: %w", err) - } - content := mustReadTemplate("templates/agent_instructions.md.tmpl") - return marker.Upsert(filepath.Join(agent.ConfigDir, "instructions.md"), string(content)) -} - -func (w *AgentWriter) installGeneric() error { - dir := filepath.Join(w.HomeDir, ".config", "locksmith") - if err := os.MkdirAll(dir, 0o755); err != nil { //nolint:gosec // G301: 0755 is standard for user config dirs - return fmt.Errorf("creating config dir: %w", err) - } - content := mustReadTemplate("templates/agent_instructions.md.tmpl") - if err := os.WriteFile( //nolint:gosec // G306: documentation, user-readable by design - filepath.Join(dir, "agent-instructions.md"), content, 0o644, - ); err != nil { - return fmt.Errorf("writing agent instructions: %w", err) + if err := generic.Install(w.HomeDir); err != nil { + return fmt.Errorf("installing generic agent: %w", err) + } + return nil } - return nil } - -// MustReadTemplateForTest exposes the embedded template reader for -// external tests; production callers use the unexported version. -func MustReadTemplateForTest(name string) []byte { return mustReadTemplate(name) } - diff --git a/internal/initflow/agents/claude/claude.go b/internal/initflow/agents/claude/claude.go new file mode 100644 index 0000000..b3f3b36 --- /dev/null +++ b/internal/initflow/agents/claude/claude.go @@ -0,0 +1,51 @@ +// Package claude installs the Locksmith integration files for Claude +// Code: skills/locksmith.md and a managed block in CLAUDE.md. +package claude + +import ( + "embed" + "fmt" + "os" + "path/filepath" + + "github.com/lorem-dev/locksmith/internal/initflow/agents/marker" +) + +//go:embed templates/*.md.tmpl +var templates embed.FS + +// Install writes the Locksmith skill file and updates CLAUDE.md for +// Claude Code. homeDir is unused but accepted for a uniform call site. +func Install(homeDir, configDir string) error { + _ = homeDir + skillDir := filepath.Join(configDir, "skills") + if err := os.MkdirAll(skillDir, 0o755); err != nil { //nolint:gosec // G301: 0755 is standard for user config dirs + return fmt.Errorf("creating skills dir: %w", err) + } + skill, err := templates.ReadFile("templates/claude_skill.md.tmpl") + if err != nil { + return fmt.Errorf("reading skill template: %w", err) + } + //nolint:gosec // G306: skill files are documentation, readable by user is intentional + err = os.WriteFile(filepath.Join(skillDir, "locksmith.md"), skill, 0o644) + if err != nil { + return fmt.Errorf("writing skill file: %w", err) + } + md, err := templates.ReadFile("templates/claude_md.md.tmpl") + if err != nil { + return fmt.Errorf("reading CLAUDE.md template: %w", err) + } + if err := marker.Upsert(filepath.Join(configDir, "CLAUDE.md"), string(md)); err != nil { + return fmt.Errorf("updating CLAUDE.md: %w", err) + } + return nil +} + +// ReadTemplateForTest exposes the embedded FS for package-level smoke tests. +func ReadTemplateForTest(name string) ([]byte, error) { + data, err := templates.ReadFile(name) + if err != nil { + return nil, fmt.Errorf("reading template %s: %w", name, err) + } + return data, nil +} diff --git a/internal/initflow/agents/claude/claude_test.go b/internal/initflow/agents/claude/claude_test.go new file mode 100644 index 0000000..f8e4767 --- /dev/null +++ b/internal/initflow/agents/claude/claude_test.go @@ -0,0 +1,60 @@ +package claude_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/lorem-dev/locksmith/internal/initflow/agents/claude" +) + +func TestInstall_CreatesSkillAndUpdatesClaudeMd(t *testing.T) { + home := t.TempDir() + configDir := filepath.Join(home, ".claude") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + + if err := claude.Install(home, configDir); err != nil { + t.Fatalf("Install() error: %v", err) + } + + if _, err := os.Stat(filepath.Join(configDir, "skills", "locksmith.md")); err != nil { + t.Error("skill file not created") + } + content, _ := os.ReadFile(filepath.Join(configDir, "CLAUDE.md")) + if !strings.Contains(string(content), "Locksmith") { + t.Error("CLAUDE.md missing Locksmith section") + } +} + +func TestInstall_Idempotent(t *testing.T) { + home := t.TempDir() + configDir := filepath.Join(home, ".claude") + os.MkdirAll(configDir, 0o755) + + claude.Install(home, configDir) + claude.Install(home, configDir) + + content, _ := os.ReadFile(filepath.Join(configDir, "CLAUDE.md")) + if strings.Count(string(content), "") != 1 { + t.Errorf("LOCKSMITH_START duplicated; count = %d", + strings.Count(string(content), "")) + } +} + +func TestReadTemplateForTest(t *testing.T) { + for _, name := range []string{ + "templates/claude_md.md.tmpl", + "templates/claude_skill.md.tmpl", + } { + data, err := claude.ReadTemplateForTest(name) + if err != nil { + t.Errorf("ReadTemplateForTest(%q): %v", name, err) + } + if len(data) == 0 { + t.Errorf("template %q is empty", name) + } + } +} diff --git a/internal/initflow/agents/claude/templates/claude_md.md.tmpl b/internal/initflow/agents/claude/templates/claude_md.md.tmpl new file mode 100644 index 0000000..00dbb70 --- /dev/null +++ b/internal/initflow/agents/claude/templates/claude_md.md.tmpl @@ -0,0 +1,21 @@ + +## Locksmith Integration + +Locksmith installs a UserPromptSubmit hook in `~/.claude/settings.json` +so `LOCKSMITH_SESSION` is already in your environment at every prompt. +Just call `locksmith get` directly. + +**By alias** (configured in `~/.config/locksmith/config.yaml`): + + locksmith get --key github-token + locksmith get --key openai-key + +**Direct vault paths:** + + locksmith get --vault gopass --path work/aws/access-key-id + locksmith get --vault keychain --path "My API Token" + locksmith get --vault op --path "op://Personal/GitHub/token" + +- Sessions expire automatically by TTL; no manual cleanup needed. +- Pass `LOCKSMITH_SESSION` to sub-agents to share the parent session. + diff --git a/internal/initflow/agents/claude/templates/claude_skill.md.tmpl b/internal/initflow/agents/claude/templates/claude_skill.md.tmpl new file mode 100644 index 0000000..780158a --- /dev/null +++ b/internal/initflow/agents/claude/templates/claude_skill.md.tmpl @@ -0,0 +1,23 @@ +--- +name: locksmith-auth +description: Retrieve secrets from configured vaults +--- + + +Use `locksmith get` to retrieve secrets. The Locksmith UserPromptSubmit +hook injects `LOCKSMITH_SESSION` into the agent environment at every +prompt, so no manual session step is needed. + +**By alias** (configured in `~/.config/locksmith/config.yaml`): + + locksmith get --key openai-key + locksmith get --key github-token + +**Direct vault paths:** + + locksmith get --vault gopass --path work/aws/access-key-id + locksmith get --vault keychain --path "My API Token" + locksmith get --vault op --path "op://Personal/GitHub/token" + +Never hardcode secrets. Never cache secrets outside of locksmith. + diff --git a/internal/initflow/agents/codex/codex.go b/internal/initflow/agents/codex/codex.go new file mode 100644 index 0000000..01c3aae --- /dev/null +++ b/internal/initflow/agents/codex/codex.go @@ -0,0 +1,42 @@ +// Package codex installs the Locksmith integration file for Codex: +// a managed block in AGENTS.md. Hook installation is handled separately +// in the apply layer. +package codex + +import ( + "embed" + "fmt" + "os" + "path/filepath" + + "github.com/lorem-dev/locksmith/internal/initflow/agents/marker" +) + +//go:embed templates/*.md.tmpl +var templates embed.FS + +// Install writes the Locksmith block into /AGENTS.md. +// homeDir is unused but accepted for a uniform call site. +func Install(homeDir, configDir string) error { + _ = homeDir + if err := os.MkdirAll(configDir, 0o755); err != nil { //nolint:gosec // G301: 0755 is standard for user config dirs + return fmt.Errorf("creating config dir: %w", err) + } + content, err := templates.ReadFile("templates/codex_agents.md.tmpl") + if err != nil { + return fmt.Errorf("reading AGENTS.md template: %w", err) + } + if err := marker.Upsert(filepath.Join(configDir, "AGENTS.md"), string(content)); err != nil { + return fmt.Errorf("updating AGENTS.md: %w", err) + } + return nil +} + +// ReadTemplateForTest exposes the embedded FS for package-level smoke tests. +func ReadTemplateForTest(name string) ([]byte, error) { + data, err := templates.ReadFile(name) + if err != nil { + return nil, fmt.Errorf("reading template %s: %w", name, err) + } + return data, nil +} diff --git a/internal/initflow/agents/codex/codex_test.go b/internal/initflow/agents/codex/codex_test.go new file mode 100644 index 0000000..780eea2 --- /dev/null +++ b/internal/initflow/agents/codex/codex_test.go @@ -0,0 +1,51 @@ +package codex_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/lorem-dev/locksmith/internal/initflow/agents/codex" +) + +func TestInstall_CreatesAgentsMd(t *testing.T) { + home := t.TempDir() + configDir := filepath.Join(home, ".codex") + + if err := codex.Install(home, configDir); err != nil { + t.Fatalf("Install() error: %v", err) + } + + content, err := os.ReadFile(filepath.Join(configDir, "AGENTS.md")) + if err != nil { + t.Fatalf("AGENTS.md not created: %v", err) + } + if !strings.Contains(string(content), "Locksmith") { + t.Error("AGENTS.md missing Locksmith section") + } +} + +func TestInstall_Idempotent(t *testing.T) { + home := t.TempDir() + configDir := filepath.Join(home, ".codex") + + codex.Install(home, configDir) + codex.Install(home, configDir) + + content, _ := os.ReadFile(filepath.Join(configDir, "AGENTS.md")) + if strings.Count(string(content), "") != 1 { + t.Errorf("LOCKSMITH_START duplicated; count = %d", + strings.Count(string(content), "")) + } +} + +func TestReadTemplateForTest(t *testing.T) { + data, err := codex.ReadTemplateForTest("templates/codex_agents.md.tmpl") + if err != nil { + t.Fatalf("ReadTemplateForTest: %v", err) + } + if len(data) == 0 { + t.Error("template is empty") + } +} diff --git a/internal/initflow/agents/codex/templates/codex_agents.md.tmpl b/internal/initflow/agents/codex/templates/codex_agents.md.tmpl new file mode 100644 index 0000000..1b5722a --- /dev/null +++ b/internal/initflow/agents/codex/templates/codex_agents.md.tmpl @@ -0,0 +1,28 @@ + +## Locksmith Integration + +Secrets are managed by Locksmith. A SessionStart hook in +`~/.codex/hooks.json` pre-warms the locksmith daemon so vault unlock +(passphrase, biometric) happens before the first secret request. + +The hook cannot inject environment variables, so run this once per +shell session: + + export LOCKSMITH_SESSION=$(locksmith session ensure --quiet) + +After that, `locksmith get` reuses the same session within the +configured TTL (default 3h). + +**By alias** (configured in `~/.config/locksmith/config.yaml`): + + locksmith get --key openai-key + locksmith get --key github-token + +**Direct vault paths:** + + locksmith get --vault gopass --path work/aws/access-key-id + locksmith get --vault keychain --path "My API Token" + locksmith get --vault op --path "op://Personal/GitHub/token" + +Pass `LOCKSMITH_SESSION` to sub-agents so they share the parent session. + diff --git a/internal/initflow/agents/gemini/gemini.go b/internal/initflow/agents/gemini/gemini.go new file mode 100644 index 0000000..93011cf --- /dev/null +++ b/internal/initflow/agents/gemini/gemini.go @@ -0,0 +1,41 @@ +// Package gemini installs the Locksmith integration file for Gemini CLI: +// a managed block in GEMINI.md. +package gemini + +import ( + "embed" + "fmt" + "os" + "path/filepath" + + "github.com/lorem-dev/locksmith/internal/initflow/agents/marker" +) + +//go:embed templates/*.md.tmpl +var templates embed.FS + +// Install writes the Locksmith block into /GEMINI.md. +// homeDir is unused but accepted for a uniform call site. +func Install(homeDir, configDir string) error { + _ = homeDir + if err := os.MkdirAll(configDir, 0o755); err != nil { //nolint:gosec // G301: 0755 is standard for user config dirs + return fmt.Errorf("creating config dir: %w", err) + } + content, err := templates.ReadFile("templates/gemini_md.md.tmpl") + if err != nil { + return fmt.Errorf("reading GEMINI.md template: %w", err) + } + if err := marker.Upsert(filepath.Join(configDir, "GEMINI.md"), string(content)); err != nil { + return fmt.Errorf("updating GEMINI.md: %w", err) + } + return nil +} + +// ReadTemplateForTest exposes the embedded FS for package-level smoke tests. +func ReadTemplateForTest(name string) ([]byte, error) { + data, err := templates.ReadFile(name) + if err != nil { + return nil, fmt.Errorf("reading template %s: %w", name, err) + } + return data, nil +} diff --git a/internal/initflow/agents/gemini/gemini_test.go b/internal/initflow/agents/gemini/gemini_test.go new file mode 100644 index 0000000..990d190 --- /dev/null +++ b/internal/initflow/agents/gemini/gemini_test.go @@ -0,0 +1,51 @@ +package gemini_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/lorem-dev/locksmith/internal/initflow/agents/gemini" +) + +func TestInstall_CreatesGeminiMd(t *testing.T) { + home := t.TempDir() + configDir := filepath.Join(home, ".gemini") + + if err := gemini.Install(home, configDir); err != nil { + t.Fatalf("Install() error: %v", err) + } + + content, err := os.ReadFile(filepath.Join(configDir, "GEMINI.md")) + if err != nil { + t.Fatalf("GEMINI.md not created: %v", err) + } + if !strings.Contains(string(content), "Locksmith") { + t.Error("GEMINI.md missing Locksmith section") + } +} + +func TestInstall_Idempotent(t *testing.T) { + home := t.TempDir() + configDir := filepath.Join(home, ".gemini") + + gemini.Install(home, configDir) + gemini.Install(home, configDir) + + content, _ := os.ReadFile(filepath.Join(configDir, "GEMINI.md")) + if strings.Count(string(content), "") != 1 { + t.Errorf("LOCKSMITH_START duplicated; count = %d", + strings.Count(string(content), "")) + } +} + +func TestReadTemplateForTest(t *testing.T) { + data, err := gemini.ReadTemplateForTest("templates/gemini_md.md.tmpl") + if err != nil { + t.Fatalf("ReadTemplateForTest: %v", err) + } + if len(data) == 0 { + t.Error("template is empty") + } +} diff --git a/internal/initflow/agents/gemini/templates/gemini_md.md.tmpl b/internal/initflow/agents/gemini/templates/gemini_md.md.tmpl new file mode 100644 index 0000000..e4f9937 --- /dev/null +++ b/internal/initflow/agents/gemini/templates/gemini_md.md.tmpl @@ -0,0 +1,34 @@ + +# Locksmith Integration + +This workspace uses [Locksmith](https://github.com/lorem-dev/locksmith) +for secure secret management. + +Run this once per shell session to import a locksmith session into +your environment: + + export LOCKSMITH_SESSION=$(locksmith session ensure --quiet) + +After that, `locksmith get` reuses the same session within the +configured TTL (default 3h). + +## Retrieving Secrets + +**By alias** (configured in `~/.config/locksmith/config.yaml`): + + locksmith get --key openai-key + locksmith get --key github-token + +**Direct vault paths:** + + locksmith get --vault gopass --path work/aws/access-key-id + locksmith get --vault keychain --path "My API Token" + locksmith get --vault op --path "op://Personal/GitHub/token" + +## Sub-agent Session Passing + +When spawning sub-agents, export `LOCKSMITH_SESSION` into their +environment so they share the parent session. This is the default +behaviour per `agent.pass_session_to_subagents: true` in +`~/.config/locksmith/config.yaml`. + diff --git a/internal/initflow/agents/generic/generic.go b/internal/initflow/agents/generic/generic.go new file mode 100644 index 0000000..215a7b8 --- /dev/null +++ b/internal/initflow/agents/generic/generic.go @@ -0,0 +1,40 @@ +// Package generic installs a fallback Locksmith instructions file for +// unknown or uncategorised AI agents. +package generic + +import ( + "embed" + "fmt" + "os" + "path/filepath" +) + +//go:embed templates/*.md.tmpl +var templates embed.FS + +// Install writes agent-instructions.md into /.config/locksmith/. +func Install(homeDir string) error { + dir := filepath.Join(homeDir, ".config", "locksmith") + if err := os.MkdirAll(dir, 0o755); err != nil { //nolint:gosec // G301: 0755 is standard for user config dirs + return fmt.Errorf("creating config dir: %w", err) + } + content, err := templates.ReadFile("templates/instructions.md.tmpl") + if err != nil { + return fmt.Errorf("reading template: %w", err) + } + if err := os.WriteFile( //nolint:gosec // G306: documentation, user-readable by design + filepath.Join(dir, "agent-instructions.md"), content, 0o644, + ); err != nil { + return fmt.Errorf("writing agent instructions: %w", err) + } + return nil +} + +// ReadTemplateForTest exposes the embedded FS for package-level smoke tests. +func ReadTemplateForTest(name string) ([]byte, error) { + data, err := templates.ReadFile(name) + if err != nil { + return nil, fmt.Errorf("reading template %s: %w", name, err) + } + return data, nil +} diff --git a/internal/initflow/agents/generic/generic_test.go b/internal/initflow/agents/generic/generic_test.go new file mode 100644 index 0000000..be0cb82 --- /dev/null +++ b/internal/initflow/agents/generic/generic_test.go @@ -0,0 +1,57 @@ +package generic_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/lorem-dev/locksmith/internal/initflow/agents/generic" +) + +func TestInstall_CreatesAgentInstructions(t *testing.T) { + home := t.TempDir() + + if err := generic.Install(home); err != nil { + t.Fatalf("Install() error: %v", err) + } + + content, err := os.ReadFile(filepath.Join(home, ".config", "locksmith", "agent-instructions.md")) + if err != nil { + t.Fatalf("agent-instructions.md not created: %v", err) + } + if !strings.Contains(string(content), "Locksmith") { + t.Error("agent-instructions.md missing Locksmith section") + } +} + +func TestInstall_Idempotent(t *testing.T) { + home := t.TempDir() + + generic.Install(home) + generic.Install(home) // second call overwrites; no duplicates possible + + files, err := os.ReadDir(filepath.Join(home, ".config", "locksmith")) + if err != nil { + t.Fatalf("listing dir: %v", err) + } + count := 0 + for _, f := range files { + if f.Name() == "agent-instructions.md" { + count++ + } + } + if count != 1 { + t.Errorf("expected 1 agent-instructions.md, got %d", count) + } +} + +func TestReadTemplateForTest(t *testing.T) { + data, err := generic.ReadTemplateForTest("templates/instructions.md.tmpl") + if err != nil { + t.Fatalf("ReadTemplateForTest: %v", err) + } + if len(data) == 0 { + t.Error("template is empty") + } +} diff --git a/internal/initflow/agents/generic/templates/instructions.md.tmpl b/internal/initflow/agents/generic/templates/instructions.md.tmpl new file mode 100644 index 0000000..a6dffef --- /dev/null +++ b/internal/initflow/agents/generic/templates/instructions.md.tmpl @@ -0,0 +1,28 @@ + +# Locksmith - Agent Instructions + +Run this once per shell session to import a locksmith session: + + export LOCKSMITH_SESSION=$(locksmith session ensure --quiet) + +After that, `locksmith get` reuses the same session within the +configured TTL (default 3h). + +## Retrieving Secrets + +**By alias** (configured in `~/.config/locksmith/config.yaml`): + + locksmith get --key openai-key + locksmith get --key github-token + +**Direct vault paths:** + + locksmith get --vault gopass --path work/aws/access-key-id + locksmith get --vault keychain --path "My API Token" + locksmith get --vault op --path "op://Personal/GitHub/token" + +## Sub-Agents + +Pass `LOCKSMITH_SESSION` to sub-agents via environment so they share +the parent session. + diff --git a/internal/initflow/agents/marker/marker.go b/internal/initflow/agents/marker/marker.go index e00ab93..0cc53e0 100644 --- a/internal/initflow/agents/marker/marker.go +++ b/internal/initflow/agents/marker/marker.go @@ -52,5 +52,8 @@ func Upsert(filePath, content string) error { out = text + sep + content } //nolint:gosec // G306: documentation, user-readable by design - return os.WriteFile(filePath, []byte(out), 0o644) + if err := os.WriteFile(filePath, []byte(out), 0o644); err != nil { + return fmt.Errorf("writing %s: %w", filePath, err) + } + return nil } diff --git a/internal/initflow/agents/opencode/opencode.go b/internal/initflow/agents/opencode/opencode.go new file mode 100644 index 0000000..e51f4d5 --- /dev/null +++ b/internal/initflow/agents/opencode/opencode.go @@ -0,0 +1,41 @@ +// Package opencode installs the Locksmith integration file for OpenCode: +// a managed block in instructions.md. +package opencode + +import ( + "embed" + "fmt" + "os" + "path/filepath" + + "github.com/lorem-dev/locksmith/internal/initflow/agents/marker" +) + +//go:embed templates/*.md.tmpl +var templates embed.FS + +// Install writes the Locksmith block into /instructions.md. +// homeDir is unused but accepted for a uniform call site. +func Install(homeDir, configDir string) error { + _ = homeDir + if err := os.MkdirAll(configDir, 0o755); err != nil { //nolint:gosec // G301: 0755 is standard for user config dirs + return fmt.Errorf("creating config dir: %w", err) + } + content, err := templates.ReadFile("templates/instructions.md.tmpl") + if err != nil { + return fmt.Errorf("reading instructions template: %w", err) + } + if err := marker.Upsert(filepath.Join(configDir, "instructions.md"), string(content)); err != nil { + return fmt.Errorf("updating instructions.md: %w", err) + } + return nil +} + +// ReadTemplateForTest exposes the embedded FS for package-level smoke tests. +func ReadTemplateForTest(name string) ([]byte, error) { + data, err := templates.ReadFile(name) + if err != nil { + return nil, fmt.Errorf("reading template %s: %w", name, err) + } + return data, nil +} diff --git a/internal/initflow/agents/opencode/opencode_test.go b/internal/initflow/agents/opencode/opencode_test.go new file mode 100644 index 0000000..ded1c8c --- /dev/null +++ b/internal/initflow/agents/opencode/opencode_test.go @@ -0,0 +1,51 @@ +package opencode_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/lorem-dev/locksmith/internal/initflow/agents/opencode" +) + +func TestInstall_CreatesInstructionsMd(t *testing.T) { + home := t.TempDir() + configDir := filepath.Join(home, ".config", "opencode") + + if err := opencode.Install(home, configDir); err != nil { + t.Fatalf("Install() error: %v", err) + } + + content, err := os.ReadFile(filepath.Join(configDir, "instructions.md")) + if err != nil { + t.Fatalf("instructions.md not created: %v", err) + } + if !strings.Contains(string(content), "Locksmith") { + t.Error("instructions.md missing Locksmith section") + } +} + +func TestInstall_Idempotent(t *testing.T) { + home := t.TempDir() + configDir := filepath.Join(home, ".config", "opencode") + + opencode.Install(home, configDir) + opencode.Install(home, configDir) + + content, _ := os.ReadFile(filepath.Join(configDir, "instructions.md")) + if strings.Count(string(content), "") != 1 { + t.Errorf("LOCKSMITH_START duplicated; count = %d", + strings.Count(string(content), "")) + } +} + +func TestReadTemplateForTest(t *testing.T) { + data, err := opencode.ReadTemplateForTest("templates/instructions.md.tmpl") + if err != nil { + t.Fatalf("ReadTemplateForTest: %v", err) + } + if len(data) == 0 { + t.Error("template is empty") + } +} diff --git a/internal/initflow/agents/opencode/templates/instructions.md.tmpl b/internal/initflow/agents/opencode/templates/instructions.md.tmpl new file mode 100644 index 0000000..a9f4ca7 --- /dev/null +++ b/internal/initflow/agents/opencode/templates/instructions.md.tmpl @@ -0,0 +1,30 @@ + +# Locksmith + +This workspace uses Locksmith for secret management. + +Run this once per shell session to import a locksmith session: + + export LOCKSMITH_SESSION=$(locksmith session ensure --quiet) + +After that, `locksmith get` reuses the same session within the +configured TTL (default 3h). + +## Retrieving Secrets + +**By alias** (configured in `~/.config/locksmith/config.yaml`): + + locksmith get --key openai-key + locksmith get --key github-token + +**Direct vault paths:** + + locksmith get --vault gopass --path work/aws/access-key-id + locksmith get --vault keychain --path "My API Token" + locksmith get --vault op --path "op://Personal/GitHub/token" + +## Sub-Agents + +Pass `LOCKSMITH_SESSION` to sub-agents via environment so they share +the parent session. + diff --git a/internal/initflow/agents_test.go b/internal/initflow/agents_test.go index 06058d0..a3d6d81 100644 --- a/internal/initflow/agents_test.go +++ b/internal/initflow/agents_test.go @@ -9,6 +9,11 @@ import ( "testing" "github.com/lorem-dev/locksmith/internal/initflow" + claudefiles "github.com/lorem-dev/locksmith/internal/initflow/agents/claude" + codexfiles "github.com/lorem-dev/locksmith/internal/initflow/agents/codex" + geminifiles "github.com/lorem-dev/locksmith/internal/initflow/agents/gemini" + genericfiles "github.com/lorem-dev/locksmith/internal/initflow/agents/generic" + opencodefiles "github.com/lorem-dev/locksmith/internal/initflow/agents/opencode" ) func TestInstall_ClaudeCode_CreatesSkillAndUpdatesClaudeMd(t *testing.T) { @@ -83,6 +88,20 @@ func TestInstall_Codex(t *testing.T) { } } +func TestInstall_GeminiCLI(t *testing.T) { + home := t.TempDir() + geminiDir := filepath.Join(home, ".gemini") + agent := initflow.DetectedAgent{Name: "Gemini CLI", Detected: true, ConfigDir: geminiDir} + writer := initflow.NewAgentWriter(home) + if err := writer.Install(agent); err != nil { + t.Fatalf("Install() error: %v", err) + } + content, _ := os.ReadFile(filepath.Join(geminiDir, "GEMINI.md")) + if !strings.Contains(string(content), "Locksmith") { + t.Error("GEMINI.md missing Locksmith section") + } +} + func TestInstall_OpenCode(t *testing.T) { home := t.TempDir() openCodeDir := filepath.Join(home, ".config", "opencode") @@ -382,22 +401,94 @@ func TestInstall_OpenCode_InstructionsContainsBothGetSyntaxes(t *testing.T) { } } -func TestTemplates_NoExportSnippet(t *testing.T) { - files := []string{ - "templates/agent_instructions.md.tmpl", - "templates/agents_md.md.tmpl", - "templates/claude_md.md.tmpl", - "templates/claude_skill.md.tmpl", - "templates/codex_agents.md.tmpl", - "templates/gemini_md.md.tmpl", - } - for _, f := range files { - data := initflow.MustReadTemplateForTest(f) +// --- Cross-template tests --- + +func TestTemplates_AllVaultsInExamples(t *testing.T) { + cases := []struct { + name string + loader func(string) ([]byte, error) + files []string + }{ + { + "claude", claudefiles.ReadTemplateForTest, + []string{"templates/claude_md.md.tmpl", "templates/claude_skill.md.tmpl"}, + }, + {"codex", codexfiles.ReadTemplateForTest, []string{"templates/codex_agents.md.tmpl"}}, + {"gemini", geminifiles.ReadTemplateForTest, []string{"templates/gemini_md.md.tmpl"}}, + {"opencode", opencodefiles.ReadTemplateForTest, []string{"templates/instructions.md.tmpl"}}, + {"generic", genericfiles.ReadTemplateForTest, []string{"templates/instructions.md.tmpl"}}, + } + needles := []string{"--vault gopass", "--vault keychain", "--vault op"} + for _, c := range cases { + for _, f := range c.files { + data, err := c.loader(f) + if err != nil { + t.Fatalf("%s %s: %v", c.name, f, err) + } + for _, n := range needles { + if !bytes.Contains(data, []byte(n)) { + t.Errorf("%s/%s missing %q", c.name, f, n) + } + } + } + } +} + +func TestTemplates_NoExportSnippet_ClaudeOnly(t *testing.T) { + for _, f := range []string{"templates/claude_md.md.tmpl", "templates/claude_skill.md.tmpl"} { + data, _ := claudefiles.ReadTemplateForTest(f) if bytes.Contains(data, []byte("export LOCKSMITH_SESSION")) { - t.Errorf( - "%s still contains 'export LOCKSMITH_SESSION'; templates must not instruct agents to export the session", - f, - ) + t.Errorf("%s contains export but Claude hook handles env; remove the instruction", f) + } + } +} + +func TestTemplates_ExportPresent_NonClaude(t *testing.T) { + needle := []byte("export LOCKSMITH_SESSION=$(locksmith session ensure --quiet)") + cases := []struct { + name string + loader func(string) ([]byte, error) + file string + }{ + {"codex", codexfiles.ReadTemplateForTest, "templates/codex_agents.md.tmpl"}, + {"gemini", geminifiles.ReadTemplateForTest, "templates/gemini_md.md.tmpl"}, + {"opencode", opencodefiles.ReadTemplateForTest, "templates/instructions.md.tmpl"}, + {"generic", genericfiles.ReadTemplateForTest, "templates/instructions.md.tmpl"}, + } + for _, c := range cases { + data, _ := c.loader(c.file) + if !bytes.Contains(data, needle) { + t.Errorf("%s missing export instruction; users without a hook need it", c.name) + } + } +} + +func TestTemplates_NoCrossAgentMentions(t *testing.T) { + cases := []struct { + name string + loader func(string) ([]byte, error) + file string + forbidden []string + }{ + { + "codex", codexfiles.ReadTemplateForTest, "templates/codex_agents.md.tmpl", + []string{"Claude Code", "Gemini", "OpenCode"}, + }, + { + "gemini", geminifiles.ReadTemplateForTest, "templates/gemini_md.md.tmpl", + []string{"Claude Code", "Codex", "OpenCode"}, + }, + { + "opencode", opencodefiles.ReadTemplateForTest, "templates/instructions.md.tmpl", + []string{"Claude Code", "Codex", "Gemini"}, + }, + } + for _, c := range cases { + data, _ := c.loader(c.file) + for _, bad := range c.forbidden { + if bytes.Contains(data, []byte(bad)) { + t.Errorf("%s mentions %q; templates must be agent-specific", c.name, bad) + } } } } diff --git a/internal/initflow/apply.go b/internal/initflow/apply.go index 8fef496..484c975 100644 --- a/internal/initflow/apply.go +++ b/internal/initflow/apply.go @@ -14,6 +14,7 @@ import ( "github.com/lorem-dev/locksmith/internal/config" "github.com/lorem-dev/locksmith/internal/daemon" claudehook "github.com/lorem-dev/locksmith/internal/initflow/hooks/claude" + codexhook "github.com/lorem-dev/locksmith/internal/initflow/hooks/codex" "github.com/lorem-dev/locksmith/internal/log" "github.com/lorem-dev/locksmith/internal/shellhook" ) @@ -159,6 +160,29 @@ func applyClaudeHook(result *InitResult, homeDir string) error { return nil } +// applyCodexHook installs or reports status of the Codex SessionStart hook. +func applyCodexHook(result *InitResult, homeDir string) { + switch { + case result.CodexHookAlreadyPresent: + fmt.Printf(" Codex: SessionStart hook already present in %s\n", + fmtPaths.Sprint("~/.codex/hooks.json")) + case result.CodexHookConfirmed: + installer := codexhook.New(filepath.Join(homeDir, ".codex")) + if err := installer.Install(); err != nil { + fmt.Fprintf(os.Stderr, "warning: failed to install Codex hook: %v\n", err) + return + } + result.CodexHookInstalled = true + fmt.Println(" Codex: SessionStart hook installed in ~/.codex/hooks.json") + fmt.Println(" Codex will prompt you to trust the hook on next run.") + } +} + +// ApplyCodexHookForTest is an export-for-test seam. +func ApplyCodexHookForTest(result *InitResult, homeDir string) { + applyCodexHook(result, homeDir) +} + // applyPathHook augments the user's shell rc file so that `locksmith` // is resolvable by the Claude Code UserPromptSubmit hook. The hook is // registered as the bare command `locksmith _agent-hook` and relies on diff --git a/internal/initflow/detect.go b/internal/initflow/detect.go index 159a914..73e8d6d 100644 --- a/internal/initflow/detect.go +++ b/internal/initflow/detect.go @@ -35,6 +35,7 @@ func DetectAgents(homeDir string) []DetectedAgent { agents := []DetectedAgent{ {Name: "Claude Code", HomePath: ".claude", BinaryName: "claude"}, {Name: "Codex", HomePath: ".codex", BinaryName: "codex"}, + {Name: "Gemini CLI", HomePath: ".gemini", BinaryName: "gemini"}, {Name: "OpenCode", HomePath: filepath.Join(".config", "opencode"), BinaryName: "opencode"}, } diff --git a/internal/initflow/flow.go b/internal/initflow/flow.go index 42e737f..68a36ef 100644 --- a/internal/initflow/flow.go +++ b/internal/initflow/flow.go @@ -123,6 +123,11 @@ func RunInit(opts InitOptions) (*InitResult, error) { return nil, err } + // --- Codex hook installation consent --- + if err = detectCodexHookConsent(result, opts, prompter, homeDir); err != nil { + return nil, err + } + if err := applyInit(result, homeDir, prompter, opts.Auto); err != nil { return nil, err } @@ -160,6 +165,9 @@ func applyInit(result *InitResult, homeDir string, prompter Prompter, auto bool) return err } } + if agent.Name == "Codex" { + applyCodexHook(result, homeDir) + } } applyShellHook(result) diff --git a/internal/initflow/flow_test.go b/internal/initflow/flow_test.go index 47a5023..5fa5c37 100644 --- a/internal/initflow/flow_test.go +++ b/internal/initflow/flow_test.go @@ -41,6 +41,8 @@ type mockPrompter struct { shellHookErr error claudeHook bool claudeHookErr error + codexHook bool + codexHookErr error } func (m *mockPrompter) ConfigLocation(_ string) (string, error) { @@ -79,6 +81,10 @@ func (m *mockPrompter) ClaudeHook(_ string) (bool, error) { return m.claudeHook, m.claudeHookErr } +func (m *mockPrompter) CodexHook(_ string) (bool, error) { + return m.codexHook, m.codexHookErr +} + func (m *mockPrompter) BundleExtractPrompt(_, _, _ string) (bundled.ConflictResolution, error) { return bundled.Keep, nil } @@ -985,6 +991,38 @@ func TestApplyPathHook_Idempotent(t *testing.T) { } } +func TestApplyCodexHook_NotSelected_Skips(t *testing.T) { + home := t.TempDir() + res := &initflow.InitResult{SelectedAgents: nil} + initflow.ApplyCodexHookForTest(res, home) + if res.CodexHookInstalled || res.CodexHookAlreadyPresent { + t.Error("Codex hook touched when Codex not selected") + } +} + +func TestApplyCodexHook_Confirmed_WritesHooksJSON(t *testing.T) { + home := t.TempDir() + res := &initflow.InitResult{ + CodexHookConfirmed: true, + } + initflow.ApplyCodexHookForTest(res, home) + if !res.CodexHookInstalled { + t.Error("CodexHookInstalled not set") + } + if _, err := os.Stat(filepath.Join(home, ".codex", "hooks.json")); err != nil { + t.Errorf("hooks.json not written: %v", err) + } +} + +func TestApplyCodexHook_AlreadyPresent_Skips(t *testing.T) { + home := t.TempDir() + res := &initflow.InitResult{CodexHookAlreadyPresent: true} + initflow.ApplyCodexHookForTest(res, home) + if res.CodexHookInstalled { + t.Error("CodexHookInstalled set when AlreadyPresent") + } +} + func TestApplyDaemonRestart_NoDaemon_Skips(t *testing.T) { socket := filepath.Join(t.TempDir(), "ls.sock") t.Setenv("LOCKSMITH_SOCKET", socket) diff --git a/internal/initflow/hooks/claude/claude.go b/internal/initflow/hooks/claude/claude.go index 253e209..63a8b6f 100644 --- a/internal/initflow/hooks/claude/claude.go +++ b/internal/initflow/hooks/claude/claude.go @@ -17,11 +17,11 @@ import ( // hook and `locksmith get` calls do not trigger an extra approval dialog. const locksmithPermissionRule = "Bash(locksmith:*)" -// nativeHookCmd is the command string written to +// NativeHookCmd is the command string written to // ~/.claude/settings.json. It relies on PATH; init's applyPathHook // ensures the locksmith binary directory is on PATH (in the user's // shell rc file). -const nativeHookCmd = "locksmith session ensure --hook" +const NativeHookCmd = "locksmith session ensure --hook" // Installer installs the Locksmith UserPromptSubmit hook into the // global Claude Code settings file (~/.claude/settings.json). @@ -39,7 +39,7 @@ func New(locksmithConfigDir, claudeConfigDir string) *Installer { } } -func (h *Installer) hookCmd() string { return nativeHookCmd } +func (h *Installer) hookCmd() string { return NativeHookCmd } // legacyScriptPath returns the on-disk location of the deprecated // shell-script hook for migration purposes. @@ -90,7 +90,7 @@ func (h *Installer) mergeSettings() error { h.settingsPath(), err, ) } - return err + return fmt.Errorf("reading Claude settings: %w", err) } h.lastMigrated = h.migrateLegacyShellHook(settings) @@ -168,7 +168,7 @@ func (h *Installer) migrateLegacyShellHook(settings map[string]any) bool { } // migrateHookCommands rewrites legacy `command:` strings in the -// UserPromptSubmit hook entries to nativeHookCmd. Returns true if any +// UserPromptSubmit hook entries to NativeHookCmd. Returns true if any // rewrite happened. func (h *Installer) migrateHookCommands(settings map[string]any) bool { legacy := h.legacyScriptPath() @@ -193,7 +193,7 @@ func (h *Installer) migrateHookCommands(settings map[string]any) bool { continue } if cmd, ok := shm["command"].(string); ok && cmd == legacy { - shm["command"] = nativeHookCmd + shm["command"] = NativeHookCmd changed = true } } diff --git a/internal/initflow/hooks/codex/codex.go b/internal/initflow/hooks/codex/codex.go new file mode 100644 index 0000000..6188004 --- /dev/null +++ b/internal/initflow/hooks/codex/codex.go @@ -0,0 +1,120 @@ +// Package codex installs the Locksmith SessionStart hook into Codex +// CLI's ~/.codex/hooks.json. The hook pre-warms the locksmith daemon +// session so vault unlock (passphrase, biometric) fires before the +// agent's first secret request. +// +// Codex hooks cannot inject environment variables; the agent still +// has to run `export LOCKSMITH_SESSION=$(locksmith session ensure +// --quiet)` in its shell. The pre-warm just removes the latency +// spike on the first vault touch. +package codex + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/lorem-dev/locksmith/internal/initflow/hooks" +) + +const ( + // HookCmd is the SessionStart command. The redirects + `|| true` + // make it best-effort: any failure (daemon down, plugin missing) + // is swallowed so Codex startup is never blocked. + HookCmd = "locksmith session ensure --quiet >/dev/null 2>&1 || true" + timeoutSeconds = 10 +) + +// Installer manages ~/.codex/hooks.json for the Locksmith +// SessionStart hook. +type Installer struct { + codexConfigDir string +} + +// New returns an Installer rooted at codexConfigDir (typically +// ~/.codex). The hook config path is "/hooks.json". +func New(codexConfigDir string) *Installer { + return &Installer{codexConfigDir: codexConfigDir} +} + +func (i *Installer) hooksPath() string { + return filepath.Join(i.codexConfigDir, "hooks.json") +} + +// IsInstalled reports whether hooks.json already contains the +// canonical SessionStart command. +func (i *Installer) IsInstalled() bool { + settings, err := hooks.ReadSettings(i.hooksPath()) + if err != nil || len(settings) == 0 { + return false + } + return findSessionStartCmd(settings, HookCmd) +} + +// Install merges the SessionStart hook into hooks.json. Idempotent. +// Returns hooks.ErrMalformed if the existing file is invalid JSON +// (we refuse to overwrite user content). +func (i *Installer) Install() error { + if err := os.MkdirAll(i.codexConfigDir, 0o755); err != nil { //nolint:gosec + return fmt.Errorf("creating Codex config dir: %w", err) + } + settings, err := hooks.ReadSettings(i.hooksPath()) + if err != nil { + return fmt.Errorf("reading Codex hooks config: %w", err) + } + if findSessionStartCmd(settings, HookCmd) { + return nil + } + addSessionStartCmd(settings, HookCmd, timeoutSeconds) + if err := hooks.WriteSettings(i.hooksPath(), settings); err != nil { + return fmt.Errorf("writing Codex hooks config: %w", err) + } + return nil +} + +func findSessionStartCmd(settings map[string]any, cmd string) bool { + hk, ok := settings["hooks"].(map[string]any) + if !ok { + return false + } + ss, ok := hk["SessionStart"].([]any) + if !ok { + return false + } + for _, entry := range ss { + em, ok := entry.(map[string]any) + if !ok { + continue + } + subhooks, _ := em["hooks"].([]any) //nolint:errcheck + for _, sh := range subhooks { + shm, ok := sh.(map[string]any) + if !ok { + continue + } + if c, ok := shm["command"].(string); ok && c == cmd { + return true + } + } + } + return false +} + +func addSessionStartCmd(settings map[string]any, cmd string, timeout int) { + hk, ok := settings["hooks"].(map[string]any) + if !ok { + hk = map[string]any{} + } + ss, _ := hk["SessionStart"].([]any) //nolint:errcheck + entry := map[string]any{ + "hooks": []any{ + map[string]any{ + "type": "command", + "command": cmd, + "timeout": timeout, + }, + }, + } + hk["SessionStart"] = append(ss, entry) + settings["hooks"] = hk +} diff --git a/internal/initflow/hooks/codex/codex_test.go b/internal/initflow/hooks/codex/codex_test.go new file mode 100644 index 0000000..21cd8d6 --- /dev/null +++ b/internal/initflow/hooks/codex/codex_test.go @@ -0,0 +1,150 @@ +package codex_test + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/lorem-dev/locksmith/internal/initflow/hooks" + "github.com/lorem-dev/locksmith/internal/initflow/hooks/codex" +) + +const expectedCmd = codex.HookCmd + +func makeInstaller(t *testing.T) (*codex.Installer, string) { + t.Helper() + home := t.TempDir() + codexDir := filepath.Join(home, ".codex") + if err := os.MkdirAll(codexDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + return codex.New(codexDir), home +} + +func TestInstall_CreatesHooksJSON(t *testing.T) { + installer, home := makeInstaller(t) + if err := installer.Install(); err != nil { + t.Fatalf("Install: %v", err) + } + hp := filepath.Join(home, ".codex", "hooks.json") + data, err := os.ReadFile(hp) + if err != nil { + t.Fatalf("hooks.json not created: %v", err) + } + var settings map[string]any + if err := json.Unmarshal(data, &settings); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if !containsSessionStartCommand(settings, expectedCmd) { + t.Errorf("expected SessionStart command in hooks.json; got %s", string(data)) + } +} + +func TestInstall_Idempotent(t *testing.T) { + installer, home := makeInstaller(t) + if err := installer.Install(); err != nil { + t.Fatalf("first Install: %v", err) + } + if err := installer.Install(); err != nil { + t.Fatalf("second Install: %v", err) + } + hp := filepath.Join(home, ".codex", "hooks.json") + data, _ := os.ReadFile(hp) + var settings map[string]any + _ = json.Unmarshal(data, &settings) + if count := countSessionStartCommands(settings, expectedCmd); count != 1 { + t.Errorf("SessionStart command appears %d times, want 1", count) + } +} + +func TestInstall_PreservesExistingHooks(t *testing.T) { + installer, home := makeInstaller(t) + hp := filepath.Join(home, ".codex", "hooks.json") + existing := map[string]any{ + "hooks": map[string]any{ + "SessionStart": []any{ + map[string]any{ + "hooks": []any{ + map[string]any{"type": "command", "command": "/usr/local/bin/other.sh"}, + }, + }, + }, + }, + } + data, _ := json.Marshal(existing) + _ = os.WriteFile(hp, data, 0o644) + + if err := installer.Install(); err != nil { + t.Fatalf("Install: %v", err) + } + out, _ := os.ReadFile(hp) + var got map[string]any + _ = json.Unmarshal(out, &got) + if !containsSessionStartCommand(got, "/usr/local/bin/other.sh") { + t.Error("third-party hook entry was removed") + } + if !containsSessionStartCommand(got, expectedCmd) { + t.Error("locksmith hook entry not added") + } +} + +func TestInstall_RefusesMalformed(t *testing.T) { + installer, home := makeInstaller(t) + hp := filepath.Join(home, ".codex", "hooks.json") + _ = os.WriteFile(hp, []byte("not json"), 0o644) + err := installer.Install() + if !errors.Is(err, hooks.ErrMalformed) { + t.Errorf("got %v, want ErrMalformed", err) + } +} + +func TestIsInstalled_FalseWhenMissing(t *testing.T) { + installer, _ := makeInstaller(t) + if installer.IsInstalled() { + t.Error("IsInstalled true when hooks.json absent") + } +} + +func TestIsInstalled_TrueAfterInstall(t *testing.T) { + installer, _ := makeInstaller(t) + _ = installer.Install() + if !installer.IsInstalled() { + t.Error("IsInstalled false after Install") + } +} + +// helpers. +func containsSessionStartCommand(settings map[string]any, cmd string) bool { + hk, _ := settings["hooks"].(map[string]any) + ss, _ := hk["SessionStart"].([]any) + for _, entry := range ss { + em, _ := entry.(map[string]any) + subhooks, _ := em["hooks"].([]any) + for _, sh := range subhooks { + shm, _ := sh.(map[string]any) + if c, _ := shm["command"].(string); c == cmd { + return true + } + } + } + return false +} + +func countSessionStartCommands(settings map[string]any, target string) int { + hk, _ := settings["hooks"].(map[string]any) + ss, _ := hk["SessionStart"].([]any) + n := 0 + for _, entry := range ss { + em, _ := entry.(map[string]any) + subhooks, _ := em["hooks"].([]any) + for _, sh := range subhooks { + shm, _ := sh.(map[string]any) + if c, _ := shm["command"].(string); c == target { + n++ + } + } + } + return n +} diff --git a/internal/initflow/hooks/settings.go b/internal/initflow/hooks/settings.go index e46194c..5dd8963 100644 --- a/internal/initflow/hooks/settings.go +++ b/internal/initflow/hooks/settings.go @@ -31,7 +31,7 @@ func ReadSettings(path string) (map[string]any, error) { } var settings map[string]any if jsonErr := json.Unmarshal(data, &settings); jsonErr != nil { - return nil, fmt.Errorf("%w at %s: %v", ErrMalformed, path, jsonErr) + return nil, fmt.Errorf("%w at %s: %w", ErrMalformed, path, jsonErr) } if settings == nil { settings = map[string]any{} diff --git a/internal/initflow/prompter.go b/internal/initflow/prompter.go index 986b319..ba767b0 100644 --- a/internal/initflow/prompter.go +++ b/internal/initflow/prompter.go @@ -325,6 +325,26 @@ func (p *huhPrompter) ClaudeHook(settingsPath string) (bool, error) { return confirmed, nil } +// CodexHook asks whether to install the Locksmith hook into hookConfigPath. +func (p *huhPrompter) CodexHook(hookConfigPath string) (bool, error) { + var confirm bool + desc := fmt.Sprintf( + "Locksmith will add a SessionStart hook to %s that pre-warms the daemon session.\n"+ + "Codex will prompt you to trust the hook on next run.", + fmtPaths.Sprint(hookConfigPath), + ) + form := p.formWith(huh.NewForm(huh.NewGroup( + huh.NewConfirm(). + Title("Install Codex SessionStart hook?"). + Description(desc). + Value(&confirm), + ))) + if err := form.Run(); err != nil { + return false, fmt.Errorf("prompting for Codex hook: %w", err) + } + return confirm, nil +} + // BundleExtractPrompt asks the user how to resolve a sha256 mismatch on an // already-extracted plugin or pinentry binary. func (p *huhPrompter) BundleExtractPrompt(name, existingSHA, newSHA string) (bundled.ConflictResolution, error) { diff --git a/internal/initflow/select.go b/internal/initflow/select.go index 79cf8c4..b34f76f 100644 --- a/internal/initflow/select.go +++ b/internal/initflow/select.go @@ -6,6 +6,7 @@ import ( "github.com/lorem-dev/locksmith/internal/config" claudehook "github.com/lorem-dev/locksmith/internal/initflow/hooks/claude" + codexhook "github.com/lorem-dev/locksmith/internal/initflow/hooks/codex" "github.com/lorem-dev/locksmith/internal/shellhook" ) @@ -104,6 +105,39 @@ func detectShellHookConsent(result *InitResult, opts InitOptions, prompter Promp return nil } +// detectCodexHookConsent decides whether to install the Codex hook +// based on --auto + Codex selection + (interactive) user confirmation. +func detectCodexHookConsent(result *InitResult, opts InitOptions, prompter Prompter, homeDir string) error { + if !isAgentSelected(result.SelectedAgents, "Codex") { + return nil + } + codexDir := filepath.Join(homeDir, ".codex") + installer := codexhook.New(codexDir) + if installer.IsInstalled() { + result.CodexHookAlreadyPresent = true + return nil + } + if opts.Auto { + result.CodexHookConfirmed = true + return nil + } + confirmed, err := prompter.CodexHook(filepath.Join(codexDir, "hooks.json")) + if err != nil { + return fmt.Errorf("prompting for Codex hook: %w", err) + } + result.CodexHookConfirmed = confirmed + return nil +} + +func isAgentSelected(agents []DetectedAgent, name string) bool { + for _, a := range agents { + if a.Name == name { + return true + } + } + return false +} + // detectClaudeHookConsent checks if Claude Code is selected and asks for hook install consent. func detectClaudeHookConsent(result *InitResult, opts InitOptions, prompter Prompter, homeDir string) error { claudeSettingsPath := filepath.Join(homeDir, ".claude", "settings.json") diff --git a/internal/initflow/templates/agent_instructions.md.tmpl b/internal/initflow/templates/agent_instructions.md.tmpl deleted file mode 100644 index f7594d8..0000000 --- a/internal/initflow/templates/agent_instructions.md.tmpl +++ /dev/null @@ -1,27 +0,0 @@ - -# Locksmith - Agent Instructions - -Call `locksmith get --key ` (or `locksmith get --vault ---path `). Sessions are handled automatically - locksmith creates -one on first use and reuses it within the configured TTL. Claude Code -installs a UserPromptSubmit hook so the session is also available as -`LOCKSMITH_SESSION` from the very first call. - -## Retrieving Secrets - -Use `--key` when the alias is configured in `~/.config/locksmith/config.yaml`: - - locksmith get --key openai_api_key - locksmith get --key github_token - -Use `--path` + `--vault` to access a secret directly by its path in the vault -(no alias needed): - - locksmith get --vault gopass --path work/aws/access-key-id - locksmith get --vault keychain --path "My API Token" - -## Sub-Agents - -Pass `LOCKSMITH_SESSION` to sub-agents via environment. They inherit the parent -session's access and secret cache. - diff --git a/internal/initflow/templates/agents_md.md.tmpl b/internal/initflow/templates/agents_md.md.tmpl deleted file mode 100644 index a0bbbbc..0000000 --- a/internal/initflow/templates/agents_md.md.tmpl +++ /dev/null @@ -1,32 +0,0 @@ - -# Locksmith Integration - -This project uses [Locksmith](https://github.com/lorem-dev/locksmith) for -secure secret management. - -Call `locksmith get --key ` (or `locksmith get --vault ---path `). Sessions are handled automatically - locksmith creates -one on first use and reuses it within the configured TTL. Claude Code -installs a UserPromptSubmit hook so the session is also available as -`LOCKSMITH_SESSION` from the very first call. - -## Retrieving Secrets - -Use `--key` when the alias is configured in `~/.config/locksmith/config.yaml`: - - locksmith get --key openai_api_key - locksmith get --key github_token - -Use `--path` + `--vault` to access a secret directly by its path in the vault -(no alias needed): - - locksmith get --vault gopass --path work/aws/access-key-id - locksmith get --vault keychain --path "My API Token" - -## Sub-agent Delegation - -When spawning sub-agents or tools, pass `LOCKSMITH_SESSION` in their -environment so they can reuse the parent session. Check -`agent.pass_session_to_subagents` in `~/.config/locksmith/config.yaml` -(default: `true`). If `false`, skip sub-agent delegation. - diff --git a/internal/initflow/templates/claude_md.md.tmpl b/internal/initflow/templates/claude_md.md.tmpl deleted file mode 100644 index b88af13..0000000 --- a/internal/initflow/templates/claude_md.md.tmpl +++ /dev/null @@ -1,25 +0,0 @@ - -## Locksmith Integration - -This environment uses [Locksmith](https://github.com/lorem-dev/locksmith) for -secure MCP secret management. - -Call `locksmith get --key ` (or `locksmith get --vault ---path `). Sessions are handled automatically - locksmith creates -one on first use and reuses it within the configured TTL. Claude Code -installs a UserPromptSubmit hook so the session is also available as -`LOCKSMITH_SESSION` from the very first call. - -**Using a configured alias** (`~/.config/locksmith/config.yaml`): - - locksmith get --key openai_api_key - locksmith get --key github_token - -**Using a direct vault path** (no alias required): - - locksmith get --vault gopass --path work/aws/access-key-id - locksmith get --vault keychain --path "My API Token" - -- Sessions expire automatically by TTL - no need to end them manually -- Pass `LOCKSMITH_SESSION` to sub-agents so they share the parent session - diff --git a/internal/initflow/templates/claude_skill.md.tmpl b/internal/initflow/templates/claude_skill.md.tmpl deleted file mode 100644 index 88849c3..0000000 --- a/internal/initflow/templates/claude_skill.md.tmpl +++ /dev/null @@ -1,31 +0,0 @@ ---- -name: locksmith-auth -description: Use when MCP server requires authentication headers or secrets from vaults ---- - - -Use `locksmith get` to retrieve secrets from configured vaults. - -Call `locksmith get --key ` (or `locksmith get --vault ---path `). Sessions are handled automatically - locksmith creates -one on first use and reuses it within the configured TTL. Claude Code -installs a UserPromptSubmit hook so the session is also available as -`LOCKSMITH_SESSION` from the very first call. - -## Retrieving Secrets - -Use `--key` when the alias is configured in `~/.config/locksmith/config.yaml`: - - locksmith get --key openai_api_key - locksmith get --key github_token - -Use `--path` + `--vault` to access a secret directly by its path in the vault -(no alias needed): - - locksmith get --vault gopass --path work/aws/access-key-id - locksmith get --vault keychain --path "My API Token" - -Pass `LOCKSMITH_SESSION` to sub-agents via environment so they share the same session. - -Never hardcode secrets. Never cache secrets outside of locksmith. - diff --git a/internal/initflow/templates/codex_agents.md.tmpl b/internal/initflow/templates/codex_agents.md.tmpl deleted file mode 100644 index 7af9bba..0000000 --- a/internal/initflow/templates/codex_agents.md.tmpl +++ /dev/null @@ -1,26 +0,0 @@ - -## Locksmith Integration - -Secrets are managed by Locksmith. - -Call `locksmith get --key ` (or `locksmith get --vault ---path `). Sessions are handled automatically - locksmith creates -one on first use and reuses it within the configured TTL. Claude Code -installs a UserPromptSubmit hook so the session is also available as -`LOCKSMITH_SESSION` from the very first call. - -**Retrieving secrets:** - -Use `--key` when the alias is configured in `~/.config/locksmith/config.yaml`: - - locksmith get --key openai_api_key - locksmith get --key github_token - -Use `--path` + `--vault` to access a secret directly by its path in the vault -(no alias needed): - - locksmith get --vault gopass --path work/aws/access-key-id - locksmith get --vault keychain --path "My API Token" - -- Pass `LOCKSMITH_SESSION` to sub-agents so they share the parent session. - diff --git a/internal/initflow/templates/gemini_md.md.tmpl b/internal/initflow/templates/gemini_md.md.tmpl deleted file mode 100644 index b0d6908..0000000 --- a/internal/initflow/templates/gemini_md.md.tmpl +++ /dev/null @@ -1,31 +0,0 @@ - -# Locksmith Integration for Gemini CLI - -This workspace uses [Locksmith](https://github.com/lorem-dev/locksmith) for -secure secret management. - -Call `locksmith get --key ` (or `locksmith get --vault ---path `). Sessions are handled automatically - locksmith creates -one on first use and reuses it within the configured TTL. Claude Code -installs a UserPromptSubmit hook so the session is also available as -`LOCKSMITH_SESSION` from the very first call. - -## Retrieving Secrets - -Use `--key` when the alias is configured in `~/.config/locksmith/config.yaml`: - - locksmith get --key openai_api_key - locksmith get --key github_token - -Use `--path` + `--vault` to access a secret directly by its path in the vault -(no alias needed): - - locksmith get --vault gopass --path work/aws/access-key-id - locksmith get --vault keychain --path "My API Token" - -## Sub-agent Session Passing - -When spawning sub-agents, pass `LOCKSMITH_SESSION` into their environment. -This is the default behavior per `agent.pass_session_to_subagents: true` in -`~/.config/locksmith/config.yaml`. - diff --git a/internal/initflow/types.go b/internal/initflow/types.go index 63848fe..f74b779 100644 --- a/internal/initflow/types.go +++ b/internal/initflow/types.go @@ -37,6 +37,10 @@ type Prompter interface { // ClaudeHook asks whether to install the Locksmith UserPromptSubmit hook // into settingsPath (~/.claude/settings.json). Shows what will be changed. ClaudeHook(settingsPath string) (bool, error) + // CodexHook asks whether to install the Locksmith SessionStart hook + // into hookConfigPath (~/.codex/hooks.json). Shows what will be + // changed. + CodexHook(hookConfigPath string) (bool, error) // BundleExtractPrompt is called when an existing plugin or pinentry file // has different content from the bundled version. Returns the user's // resolution choice. existingSHA and newSHA are short (8-char) hex @@ -70,6 +74,9 @@ type InitResult struct { ClaudeHookConfirmed bool // user approved (or --auto); set in RunInit before applyInit ClaudeHookInstalled bool // hook was written successfully; set in applyInit ClaudeHookAlreadyPresent bool // hook was already in settings.json; install skipped + CodexHookConfirmed bool // user approved (or --auto) to install Codex hook + CodexHookInstalled bool // hook was written successfully + CodexHookAlreadyPresent bool // hook already in hooks.json; install skipped PathHookInstalled bool // true when a PATH snippet was appended to rc PathHookRCFile string // rc file path; empty when skipped DaemonRestarted bool // true when init restarted a running daemon From 737bc067273a6550ee2931e3028df03f386e467e Mon Sep 17 00:00:00 2001 From: Lorem Dev Date: Sat, 23 May 2026 10:00:27 +0200 Subject: [PATCH 19/25] test(initflow): require MCP fallback block in every agent template --- internal/initflow/agents_test.go | 40 ++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/internal/initflow/agents_test.go b/internal/initflow/agents_test.go index a3d6d81..bf67791 100644 --- a/internal/initflow/agents_test.go +++ b/internal/initflow/agents_test.go @@ -492,3 +492,43 @@ func TestTemplates_NoCrossAgentMentions(t *testing.T) { } } } + +func TestTemplates_AllHaveMCPFallback(t *testing.T) { + cases := []struct { + name string + loader func(string) ([]byte, error) + files []string + }{ + { + "claude", claudefiles.ReadTemplateForTest, + []string{"templates/claude_md.md.tmpl", "templates/claude_skill.md.tmpl"}, + }, + {"codex", codexfiles.ReadTemplateForTest, []string{"templates/codex_agents.md.tmpl"}}, + {"gemini", geminifiles.ReadTemplateForTest, []string{"templates/gemini_md.md.tmpl"}}, + {"opencode", opencodefiles.ReadTemplateForTest, []string{"templates/instructions.md.tmpl"}}, + {"generic", genericfiles.ReadTemplateForTest, []string{"templates/instructions.md.tmpl"}}, + } + needles := []string{ + "Locksmith Commands (runtime whitelist)", + "MCP Servers and Locksmith", + "Fallback for locksmith-wrapped MCP servers", + "{key:", + "{vault:", + "locksmith get --key", + "locksmith get --vault", + "locksmith session ensure --quiet", + } + for _, c := range cases { + for _, f := range c.files { + data, err := c.loader(f) + if err != nil { + t.Fatalf("%s %s: %v", c.name, f, err) + } + for _, n := range needles { + if !bytes.Contains(data, []byte(n)) { + t.Errorf("%s/%s missing %q", c.name, f, n) + } + } + } + } +} From a4437c39a35687473b8013f8b059891a4c7461a5 Mon Sep 17 00:00:00 2001 From: Lorem Dev Date: Sat, 23 May 2026 10:03:54 +0200 Subject: [PATCH 20/25] feat(initflow): document MCP wrapper fallback in agent templates --- CHANGES.md | 1 + .../agents/claude/templates/claude_md.md.tmpl | 97 +++++++++++++++++++ .../claude/templates/claude_skill.md.tmpl | 96 ++++++++++++++++++ .../codex/templates/codex_agents.md.tmpl | 97 +++++++++++++++++++ .../agents/gemini/templates/gemini_md.md.tmpl | 97 +++++++++++++++++++ .../generic/templates/instructions.md.tmpl | 97 +++++++++++++++++++ .../opencode/templates/instructions.md.tmpl | 97 +++++++++++++++++++ 7 files changed, 582 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 5ec6418..d3cdba2 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -63,6 +63,7 @@ use `export LOCKSMITH_SESSION=$(locksmith session ensure --quiet)`) and includes usage examples for all three shipped vault types (`gopass`, `keychain`, `1password`). +- Agent templates now document an MCP-wrapper fallback: when a locksmith-wrapped MCP server fails, the agent parses `--header` and `--env` templates from its own MCP config, resolves them via `locksmith get`, and runs the server directly. Templates also whitelist `session ensure`, `serve`, and `get` as the only runtime locksmith commands. ## Version v0.3.0 - 2026-05-15 diff --git a/internal/initflow/agents/claude/templates/claude_md.md.tmpl b/internal/initflow/agents/claude/templates/claude_md.md.tmpl index 00dbb70..5be22fd 100644 --- a/internal/initflow/agents/claude/templates/claude_md.md.tmpl +++ b/internal/initflow/agents/claude/templates/claude_md.md.tmpl @@ -16,6 +16,103 @@ Just call `locksmith get` directly. locksmith get --vault keychain --path "My API Token" locksmith get --vault op --path "op://Personal/GitHub/token" +## Locksmith Commands (runtime whitelist) + +Use ONLY these at runtime: + +- `locksmith session ensure --quiet` - obtain/refresh a session ID +- `locksmith serve` - start the daemon if it is not running + (rare; `session ensure` will spawn it automatically) +- `locksmith get --key ` or `--vault --path ` - + fetch a secret + +Do not call `init`, `vault`, `plugins`, `config`, `reload`, `mcp`, +or any other subcommand - they are for the human operator only. + +## MCP Servers and Locksmith + +The MCP server config lives in **the agent's own settings** +(e.g. `~/.claude.json`, `~/.codex/mcp.json`, `~/.gemini/...`), +NOT in locksmith's config file. Always read from there. + +- If an MCP entry's `command` is anything other than `"locksmith"` - + run it exactly as written. Do not wrap it in locksmith. Behave + as if locksmith does not exist for this server. +- If `command` is `"locksmith"` with `args` starting with + `["mcp", "run", ...]` - the server is locksmith-wrapped. If it + fails to start or returns an auth error, apply the fallback below. + +### Fallback for locksmith-wrapped MCP servers + +1. Parse these flags from the entry's `args` (after `mcp run`): + - `--header Name=