Managed plugin install dir + ENTIRE_PLUGIN_DATA_DIR#1121
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a lightweight “managed plugin install” layer on top of the existing kubectl-style external command dispatcher by (1) prepending a per-user managed bin directory to PATH at startup and (2) injecting a per-plugin durable storage directory via ENTIRE_PLUGIN_DATA_DIR for every plugin invocation.
Changes:
- Prepend a managed plugin bin dir to
PATHbeforeMaybeRunPluginsoexec.LookPathcan discover managed installs without a separate resolution mechanism. - Introduce
ENTIRE_PLUGIN_DATA_DIR(computed from a per-user plugin root + plugin name) and forward it to external commands. - Add
entire plugin install/list/removecommands plus unit/integration coverage for the managed store and the new env var.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| docs/architecture/external-commands.md | Documents managed plugin dir discovery and ENTIRE_PLUGIN_DATA_DIR. |
| cmd/entire/main.go | Prepends managed plugin bin dir to PATH before plugin dispatch. |
| cmd/entire/cli/root.go | Registers the new entire plugin command group. |
| cmd/entire/cli/plugin.go | Injects ENTIRE_PLUGIN_DATA_DIR when running a resolved plugin. |
| cmd/entire/cli/plugin_test.go | Updates unit test call site for runPlugin signature change. |
| cmd/entire/cli/plugin_store.go | Implements managed plugin bin/data directories + install/list/remove helpers. |
| cmd/entire/cli/plugin_store_test.go | Adds unit tests for managed store behaviors and PATH prepending. |
| cmd/entire/cli/plugin_group.go | Implements entire plugin {install,list,remove} Cobra commands. |
| cmd/entire/cli/integration_test/external_command_test.go | Extends integration test to assert ENTIRE_PLUGIN_DATA_DIR. |
|
Thanks Copilot and Cursor — addressed in 7e1451d. Mapping each comment to the fix: Copilot — Copilot + Cursor — Copilot + Cursor — Cursor — Cursor — failed Copilot — help text hard-codes Copilot — registering
|
|
Second round addressed in 6aab5ce.
|
|
Third round addressed in 8ac8bc5.
Forward slash in path constant Case-insensitive PATH idempotency on Windows Misleading test name
|
|
Fourth round addressed in 48391f9. Env vars appended after
Copy fallback reads whole binary into memory
|
|
Fifth round addressed in 108065b.
|
|
@cursor review |
|
Cursor Bugbot round addressed in b8ef07e. Extension variants bypass replacement (Medium) Temporary path can clobber a legitimate plugin (Medium) Stale Tests added for tmp-path uniqueness, dotted-plugin survival, bare-name conflict gating, and |
|
@cursor review |
|
Addressed in 2afff28. Variant removal is incomplete (Medium) Managed PATH leaks into built-ins (Medium) Test now asserts PATH is restored to its exact prior value and that a no-op call (already-prepended) returns a safe no-op closure.
|
|
@cursor review |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 2afff28. Configure here.
|
Addressed in 6118494. Windows plugin variants can coexist (Medium)
The fix lives in the canonical helper rather than a parallel local list so plugin install/list/remove and external-agent discovery stay in sync. Test added for
|
c6b5e5c to
ec0a9cb
Compare
Layered on top of the kubectl-style dispatcher in #1104 — purely additive, no parallel mechanism. - ENTIRE_PLUGIN_DATA_DIR: per-plugin durable storage path. Set in runPlugin's env regardless of where the binary lives, so plugins installed via raw $PATH and via 'entire plugin install' get the same contract. - Managed bin dir at $XDG_DATA_HOME/entire/plugins/bin (override: $ENTIRE_PLUGIN_DIR/bin). main.go prepends it to $PATH at startup so the existing exec.LookPath resolution in resolvePlugin discovers managed installs without any special-casing. - 'entire plugin install/list/remove' for managing the dir. Local-symlink installs only; binary-release and git-clone installs remain deferred until there's demand. Docs in docs/architecture/external-commands.md updated to describe the managed dir and the ENTIRE_PLUGIN_DATA_DIR env var. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Entire-Checkpoint: c41bcd1aec30
- validatePluginName: shared rules, used by PluginDataDir and InstallPluginFromPath. Rejects "."/".." (which would collapse out of the joined path), agent-* (dispatcher reserves it), flag-shaped names, and slashes. isPluginCandidate gets the same "."/".." tightening for defense in depth. - bareNameFromBinaryName: strip .exe/.bat/.cmd only on Windows. On Unix the dispatcher uses exact-match exec.LookPath, so accepting entire-foo.exe would yield a managed entry that could never resolve. - InstallPluginFromPath: refuse self-install when the source path equals the managed destination (path-clean equality only — using os.SameFile would false-fire on the legitimate "previous install is a symlink to src" case). Replace step is now atomic via symlink-to- tmp + rename, so a failed --force never leaves the previous install missing. - plugin_group.go Long help: describe the actual XDG / Windows / ENTIRE_PLUGIN_DIR precedence instead of hard-coding the Linux/macOS default. - external-commands.md: note that the new built-in `entire plugin` command group shadows any pre-existing `entire-plugin` external command (intentional, but worth flagging). Tests: - TestValidatePluginName + TestPluginDataDir_RejectsPathTraversal - TestInstallPluginFromPath_RejectsAgentReservedName - TestInstallPluginFromPath_RejectsSelfInstall (verifies source survives the rejection) - TestInstallPluginFromPath_AtomicForceReplace - TestBareNameFromBinaryName: platform-conditional cases Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 93848fc24cf0
- pluginParentDir: gate XDG_DATA_HOME to non-Windows. The Windows branch (LOCALAPPDATA) was previously unreachable when XDG_DATA_HOME was set in MSYS/Cygwin environments, producing a surprising location. Tests for both branches. - materializeManagedEntry: new helper that tries symlink → hardlink → copy in that order. Symlinks on Windows require Developer Mode or admin, which would have made `entire plugin install` unusable for typical users. Mirrors the pattern in setup_test.go's copyExecutable. Symlink stays the preferred path so the dev-loop property of "rebuild source, managed entry follows" is preserved wherever it works. - bareNameFromBinaryName comment: clarify that on Unix we don't strip extensions because doing so would create a list/invocation-name mismatch (entry listed as "pgr" but only invocable as "pgr.exe"), not because the entry would be uninvocable. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 5671463236d4
- pluginParentDir: defer os.UserHomeDir to the fallback branches so LOCALAPPDATA / XDG_DATA_HOME / ENTIRE_PLUGIN_DIR resolution doesn't fail when home lookup is broken. - Split the managed-dir constant into two segments (pluginManagedTopDir + pluginManagedSubDir). filepath.Join now produces native separators throughout, including on Windows where "entire/plugins" would otherwise leak forward slashes into user-facing paths. - PrependPluginBinDirToPATH: use strings.EqualFold on Windows when checking whether the first PATH entry is already the managed dir. Windows PATH lookups are case-insensitive, so a case-different first entry refers to the same dir and we shouldn't double-prepend. - TestMaterializeManagedEntry: rename _FallsThroughToCopy to _HappyPath. The previous name implied the test exercised the copy fallback, but on Unix the symlink branch always wins. Forcing the earlier branches to fail without injection hooks is non-portable; honest naming is the cheaper fix. - runPlugin: when PluginDataDir errors (only realistic case: a degenerate environment with no resolvable home + no LOCALAPPDATA / XDG_DATA_HOME / ENTIRE_PLUGIN_DIR), warn to stderr and proceed without ENTIRE_PLUGIN_DATA_DIR. Plugins that don't read the var shouldn't be punished for the user's environment, but the failure is now visible. Doc updated to reflect the contract caveat. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 6e60baaf0d7b
- runPlugin: use upsertEnv (already in package, see explain.go) for ENTIRE_CLI_VERSION, ENTIRE_REPO_ROOT, ENTIRE_PLUGIN_DATA_DIR. Plain append left any pre-existing ENTIRE_* values from the user's shell in place; getenv() typically returns the first match, so the CLI-computed value would be silently shadowed. - pathEntriesEqual: filepath.Clean both sides before comparing. Trailing-separator differences (`.../bin/` vs `.../bin`) and slash orientation on Windows would otherwise miss the idempotency check and double-prepend on every invocation. - materializeManagedEntry: split the copy fallback into a streaming copyFileStreaming helper using os.Open + io.Copy + chmod-on-create via os.OpenFile mode. os.ReadFile loaded the whole binary into memory, which is a real spike for plugins in the tens of MB. On copy failure, dest is removed so a partial file isn't left behind. - plugin install help: replace "symlink" wording with "link or copy", and explain the symlink-preferred-then-fallback order so the help matches actual behavior on Windows-without-Developer-Mode. - TestPluginParentDir_WindowsIgnoresXDG: replace the loose strings.Contains(got, "fake") assertion with a structural check (filepath.HasPrefix(clean(got), clean(xdgRoot))). The substring could false-fail on Windows paths that legitimately contain the literal "fake". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 0b689149c0a3
bareNameFromBinaryName now calls into agent/external rather than maintaining a parallel .exe/.bat/.cmd switch. plugin.go already imports the same package for isAgentProtocolBinary, so there's no new layering cost — and a single source of truth means agent discovery and managed-plugin listing will stay consistent if the canonical Windows-executable extension list ever needs to grow. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 4b119065e61e
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
- Bare-name conflict check (Medium). InstallPluginFromPath now lists every managed entry whose bare name matches and refuses to install without --force when any exist. With --force, all variants other than the destination are removed before the atomic rename. On Windows this prevents entire-foo.exe and entire-foo.bat coexisting, with PATHEXT silently choosing which one runs. - Random tmp path (Medium). Replace dest+".tmp" with a 16-hex-char ".install-<random>" path generated via crypto/rand. The previous scheme could clobber a legitimate plugin named "foo.tmp" (file "entire-foo.tmp"), since the validator allows dots. The ".install-" prefix doesn't match the "entire-" listing filter, so in-progress installs don't surface in `entire plugin list`. - Strip inherited ENTIRE_PLUGIN_DATA_DIR on error (Low). Add removeEnvKey alongside upsertEnv in explain.go. runPlugin now drops the inherited value when PluginDataDir fails so the warning's "not set" claim matches what the plugin actually sees, blocking a shell-set ENTIRE_PLUGIN_DATA_DIR from leaking through. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 28dbaf5cf678
- RemoveInstalledPlugin now iterates installedVariantsByBareName and removes every match, not just the first. Symmetric with the install-side bare-name conflict fix: on Windows, entire-foo.exe and entire-foo.bat could share the bare name "foo", and the previous implementation would have left a runnable variant behind after `entire plugin remove foo` reported success. - PrependPluginBinDirToPATH now returns a restore closure. main.go invokes it after MaybeRunPlugin returns false, so built-in commands and the subprocesses they spawn (git, hooks, less, ...) see the user's original PATH rather than one with the managed plugin dir prepended. When a plugin is dispatched, the restore is intentionally skipped: the os.Exit ends the process, and the plugin child inherits the prepended PATH so it can spawn sibling managed plugins. No-op cases (already-prepended, lookup failure, Setenv error) return no-op restore funcs so callers always have a safe func to call. Tests: - TestPrependPluginBinDirToPATH now asserts PATH is restored to its exact prior value after the closure runs, and that an idempotent second call returns a no-op restore. - TestRemoveInstalledPlugin_RemovesAllVariants exercises the loop body with a single Unix entry; the multi-variant case it guards against is Windows-specific and covered by the implementation reading installedVariantsByBareName. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 5d2e94150bf2
Windows PATHEXT defaults to ".COM;.EXE;.BAT;.CMD;…", so exec.LookPath resolves a `.com` next to a `.exe`. Without stripping it in the canonical helper, foo.exe and foo.com end up with distinct bare names in the managed plugin store while PATHEXT silently picks the .com first when a user types `entire foo` — giving them a different binary than the one their bare-name conflict check would have flagged. The fix is in StripExeExt itself rather than a parallel local list, so plugin install/list/remove and external-agent discovery share one source of truth for "Windows executable extension". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Entire-Checkpoint: d2f8ff912a93
6118494 to
53c03ed
Compare
A relative ENTIRE_PLUGIN_DIR resolved against the user's startup CWD — typically inside their repo — instead of being a stable per-user location. Reject loudly: a misconfigured override is almost certainly a user error worth surfacing rather than silently falling through to the platform default. PrependPluginBinDirToPATH previously dropped every error path silently, making "managed plugin not discoverable" hard to diagnose. Take a context and emit each failure at debug level so the cause shows up when log_level=DEBUG without affecting normal-run output. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Entire-Checkpoint: c9781df3b591
isPluginCandidate (the dispatcher gate) and validatePluginName (the install-time check) had identical rule sets in two implementations — one returning bool, one returning errors. Express the bool in terms of the validator so the two cannot drift. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 3b6bbfabea32
After a successful install, hint to stderr if the plugin name shadows a built-in. The dispatcher's resolvePlugin gates dispatch on rootCmd.Find so the built-in always wins at runtime; without this hint, a user who installs `entire-status` would get the built-in silently and have no idea their managed install is inert. Drop runPlugin's stderr warning when ENTIRE_PLUGIN_DATA_DIR can't be resolved to a debug log. The condition only fires in degenerate environments (no HOME, or relative ENTIRE_PLUGIN_DIR override) and printed once per plugin invocation; users with the broken setup don't need a warning every call. The strip of any inherited value still runs so the plugin doesn't see a value we never sanctioned. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Entire-Checkpoint: cb85b58c1720
The test name advertised "BareNameConflictAcrossExtensions" but the body's own comment admitted the cross-extension case is Windows-only and not what the test exercises. Rename to match what the assertions actually cover — the same-bare-name guard that fires on every platform — and clarify the comment. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 65ddda3c60e0

https://entire.io/gh/entireio/cli/trails/298
Stacked on #1104. Targets `soph/external-command-support` as the base so the diff shows only the additive layer; rebase onto `main` once #1104 lands.
Summary
Two additions on top of the kubectl-style dispatcher in #1104. Both are purely additive — the dispatcher in `plugin.go` keeps its raw `$PATH` model.
Docs at `docs/architecture/external-commands.md` updated with a "Managed install directory" subsection and the new env var row.
Why this shape
This is the smaller follow-up I owed after closing #1116 (gh-style managed store). The kubectl dispatcher in #1104 is the right primitive; this just gives users `entire plugin install` for the local-dev workflow without forking the resolution path.
Two things from #1116 we deliberately did not carry over:
Test plan
🤖 Generated with Claude Code
Note
Medium Risk
Medium risk because it introduces new filesystem-managed plugin install/remove behavior and mutates
PATH/plugin environments at startup, which could affect command resolution and subprocess behavior across platforms.Overview
Adds a built-in
entire plugincommand group to install/list/remove external plugins into a per-user managed directory, using symlink→hardlink→copy installs with atomic replace and Windows extension/bare-name conflict handling.Makes managed installs automatically discoverable by prepending the managed
bindir toPATHat startup (with a restore for built-in command runs), while keeping kubectl-styleexec.LookPathdispatch.Extends plugin execution to set
ENTIRE_PLUGIN_DATA_DIR(per-plugin durable storage) and to upsertENTIRE_*vars to avoid inherited shadowing; adds validation to reject./..plugin names and tests/docs covering the new behavior.Reviewed by Cursor Bugbot for commit 2afff28. Configure here.