Skip to content

Managed plugin install dir + ENTIRE_PLUGIN_DATA_DIR#1121

Draft
ashtom wants to merge 3 commits intosoph/external-command-supportfrom
ashtom/plugin-exploration
Draft

Managed plugin install dir + ENTIRE_PLUGIN_DATA_DIR#1121
ashtom wants to merge 3 commits intosoph/external-command-supportfrom
ashtom/plugin-exploration

Conversation

@ashtom
Copy link
Copy Markdown
Member

@ashtom ashtom commented May 5, 2026

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.

  • `ENTIRE_PLUGIN_DATA_DIR` — per-plugin durable storage dir set in `runPlugin`'s env regardless of where the binary lives. 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` before `MaybeRunPlugin` runs, so the existing `exec.LookPath` discovers managed installs with no special-casing.
  • `entire plugin install/list/remove` Cobra commands manage the dir. Local-symlink installs only; release-asset and git-clone installs remain deferred.

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:

  • No `entire plugin exec` escape hatch. No real demand yet; the first time a built-in shadows a useful plugin we can revisit.
  • No stricter name validation. Soph's path-traversal + `agent-` prefix checks in `isPluginCandidate` are sufficient.

Test plan

  • `mise run lint` (0 issues)
  • `mise run test` (cli unit tests including new `plugin_store_test.go`)
  • `mise run test:integration` (`TestExternalCommand_EnvVarsForwarded` extended to assert `ENTIRE_PLUGIN_DATA_DIR`)
  • `mise run test:e2e:canary`
  • Manual: `entire plugin install ./entire-hello` → `entire plugin list` → `entire hello world` resolves via the auto-prepended PATH and `ENTIRE_PLUGIN_DATA_DIR` is set to the per-plugin path

🤖 Generated with Claude Code


Note

Medium Risk
Moderate risk because it changes CLI startup behavior by mutating PATH and expands plugin execution environment; could affect command resolution or plugin expectations across platforms.

Overview
Adds a managed plugin installation workflow via a new entire plugin command group (install, list, remove) that symlinks entire-<name> executables into a per-user managed bin directory.

Updates startup to prepend the managed bin dir to PATH so existing kubectl-style plugin resolution discovers managed installs without special-casing, and extends plugin execution to always export ENTIRE_PLUGIN_DATA_DIR (per-plugin durable storage path, overrideable via ENTIRE_PLUGIN_DIR).

Adds unit/integration coverage for managed-dir behavior and the new env var, and updates external-command architecture docs accordingly.

Reviewed by Cursor Bugbot for commit 700abdb. Configure here.

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
Copilot AI review requested due to automatic review settings May 5, 2026 16:28
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 PATH before MaybeRunPlugin so exec.LookPath can 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/remove commands 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.

Comment thread cmd/entire/cli/plugin_store.go
Comment thread cmd/entire/cli/plugin_store.go
Comment thread cmd/entire/cli/plugin_store.go
Comment thread cmd/entire/cli/plugin_group.go Outdated
Comment thread cmd/entire/cli/root.go
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 5 potential issues.

Fix All in Cursor

❌ 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 700abdb. Configure here.

Comment thread cmd/entire/cli/plugin_store.go
Comment thread cmd/entire/cli/plugin_store.go Outdated
Comment thread cmd/entire/cli/plugin_store.go
Comment thread cmd/entire/cli/plugin_store.go
Comment thread cmd/entire/cli/plugin_store.go Outdated
- 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
@ashtom
Copy link
Copy Markdown
Member Author

ashtom commented May 5, 2026

Thanks Copilot and Cursor — addressed in 7e1451d. Mapping each comment to the fix:

Copilot — PluginDataDir lets ./.. escape the data subtree
Added validatePluginName rejecting "", ".", "..", agent-*, leading -, and path separators. PluginDataDir calls it first; same rules also tightened in isPluginCandidate so a literal entire . can't even reach runPlugin.

Copilot + Cursor — InstallPluginFromPath accepts unrunnable names (e.g. entire-agent-foo)
The derived bare name is now run through validatePluginName. entire plugin install /path/to/entire-agent-foo errors out with plugin name "agent-foo" is reserved for the external agent protocol.

Copilot + Cursor — bareNameFromBinaryName strips .exe/.bat/.cmd on every platform, breaking Unix discovery
Stripping is now gated on runtime.GOOS == windowsGOOS. On Unix, entire-foo.exe no longer collapses to foo (which would have been an entry that exact-match exec.LookPath could never find). Test exercises both branches.

Cursor — --force can delete the source if src == dest
Added a self-install guard that compares filepath.Clean(src) with filepath.Clean(dest) and refuses early. Used path equality rather than os.SameFile because SameFile would false-fire on the normal repeat-install case (where the existing managed entry is a symlink we created pointing back at src). Test verifies the source survives the rejection.

Cursor — failed --force replace leaves the plugin uninstalled
Replaced os.Remove(dest) + os.Symlink(src, dest) with os.Symlink(src, dest.tmp) + os.Rename(dest.tmp, dest). Atomic on POSIX; Go's os.Rename on Windows uses MoveFileEx with MOVEFILE_REPLACE_EXISTING. If symlink or rename fails, the previous install is intact.

Copilot — help text hard-codes ~/.local/share/...
plugin_group.go Long description now lists the precedence ($ENTIRE_PLUGIN_DIR/bin$XDG_DATA_HOME/... → Linux/macOS default → %LOCALAPPDATA%\...).

Copilot — registering entire plugin shadows any existing entire-plugin external command
The collision is intentional (managing plugins is a built-in concern), but added a "Compatibility note" callout to docs/architecture/external-commands.md flagging it.

mise run check is clean; new tests cover validator rules, self-install rejection, atomic replace, and platform-conditional extension stripping.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.

Comment thread cmd/entire/cli/plugin_store.go Outdated
Comment on lines +41 to +46
if v := os.Getenv(pluginEnvPluginDir); v != "" {
return v, nil
}
if v := os.Getenv("XDG_DATA_HOME"); v != "" {
return filepath.Join(v, pluginManagedDirEntireXD), nil
}
Comment on lines +296 to +304
// Atomic replace via tmp + rename. Rename is atomic on POSIX and
// replaces an existing target on Windows (Go's os.Rename uses
// MoveFileEx with MOVEFILE_REPLACE_EXISTING). If symlink or rename
// fails, the previously installed plugin (if any) is unaffected.
tmpDest := dest + ".tmp"
_ = os.Remove(tmpDest) // best-effort: clean up any stale tmp from a prior failed run
if err := os.Symlink(src, tmpDest); err != nil {
return nil, fmt.Errorf("create symlink: %w", err)
}
Comment thread cmd/entire/cli/plugin_store.go Outdated
Comment on lines +332 to +335
// Extension stripping is platform-conditional. On Unix the dispatcher's
// exec.LookPath matches the exact filename, so accepting "entire-pgr.exe"
// would produce a managed entry that can never be invoked. On Windows the
// runtime resolves PATHEXT, so .exe/.bat/.cmd entries are valid.
- 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
@ashtom
Copy link
Copy Markdown
Member Author

ashtom commented May 5, 2026

Second round addressed in 6aab5ce.

pluginParentDir honors XDG_DATA_HOME on Windows
Gated the XDG branch to non-Windows. On Windows, resolution is now ENTIRE_PLUGIN_DIRLOCALAPPDATA%USERPROFILE%\AppData\Local\..., with XDG_DATA_HOME deliberately ignored even when set (e.g. under MSYS/Cygwin). Two new tests pin both branches.

os.Symlink fails on Windows for non-admin users
Introduced materializeManagedEntry(src, dest, srcInfo) that tries symlink → hardlink → copy in that order. Mirrors the pattern in setup_test.go's copyExecutable. Symlink remains the preferred path so the dev-loop property (rebuild source, managed entry follows) is preserved on Unix and on Windows-with-Developer-Mode. The atomic-rename via dest.tmp is unchanged. Added a Unix smoke test that the helper terminates correctly on the happy path; a contrived "symlink and hardlink both fail" scenario is hard to set up portably and the implementation just composes three well-tested stdlib calls.

bareNameFromBinaryName comment is misleading
Right — on Unix entire-pgr.exe is invocable, just only as entire pgr.exe (since isPluginCandidate allows dots). The reason we don't strip there is the list/invocation-name mismatch ("listed as pgr but only pgr.exe resolves"), not literal non-invocability. Comment rewritten to explain that.

mise run check clean; canary green.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants