From 7376f07db54b7a2d0cbe664f311696be12f39529 Mon Sep 17 00:00:00 2001 From: Thomas Dohmke Date: Tue, 5 May 2026 11:48:25 +0200 Subject: [PATCH] Add gh-style plugin system: install, list, remove, exec, dispatch Implements the foundational milestones from docs/plugin-system-plan.md. External executables named entire- are discovered under $ENTIRE_PLUGIN_DIR (or XDG_DATA_HOME/entire/plugins) and dispatched to when an unknown subcommand is invoked. Cobra resolves built-ins first; 'entire plugin exec ' is the escape hatch for collisions. Local install (symlinking a dev directory), list, remove, and exec are wired up. GitHub-release binary install, git-clone script install, upgrade, create scaffolds, search/browse/pin, and the update notifier remain deferred per the plan. Co-Authored-By: Claude Opus 4.7 (1M context) Entire-Checkpoint: eb3926f7bf64 --- cmd/entire/cli/plugin/dispatch.go | 169 ++++++++++++++ cmd/entire/cli/plugin/dispatch_test.go | 201 ++++++++++++++++ cmd/entire/cli/plugin/dispatch_unix.go | 14 ++ cmd/entire/cli/plugin/dispatch_windows.go | 42 ++++ cmd/entire/cli/plugin/install_local.go | 84 +++++++ cmd/entire/cli/plugin/install_test.go | 114 +++++++++ cmd/entire/cli/plugin/manager.go | 267 ++++++++++++++++++++++ cmd/entire/cli/plugin/manager_test.go | 142 ++++++++++++ cmd/entire/cli/plugin/manifest.go | 55 +++++ cmd/entire/cli/plugin/pin.go | 24 ++ cmd/entire/cli/plugin/remove.go | 35 +++ cmd/entire/cli/plugin_group.go | 189 +++++++++++++++ cmd/entire/cli/root.go | 1 + cmd/entire/main.go | 12 + docs/plugin-system-plan.md | 234 +++++++++++++++++++ go.mod | 2 +- 16 files changed, 1584 insertions(+), 1 deletion(-) create mode 100644 cmd/entire/cli/plugin/dispatch.go create mode 100644 cmd/entire/cli/plugin/dispatch_test.go create mode 100644 cmd/entire/cli/plugin/dispatch_unix.go create mode 100644 cmd/entire/cli/plugin/dispatch_windows.go create mode 100644 cmd/entire/cli/plugin/install_local.go create mode 100644 cmd/entire/cli/plugin/install_test.go create mode 100644 cmd/entire/cli/plugin/manager.go create mode 100644 cmd/entire/cli/plugin/manager_test.go create mode 100644 cmd/entire/cli/plugin/manifest.go create mode 100644 cmd/entire/cli/plugin/pin.go create mode 100644 cmd/entire/cli/plugin/remove.go create mode 100644 cmd/entire/cli/plugin_group.go create mode 100644 docs/plugin-system-plan.md diff --git a/cmd/entire/cli/plugin/dispatch.go b/cmd/entire/cli/plugin/dispatch.go new file mode 100644 index 0000000000..965e2ae200 --- /dev/null +++ b/cmd/entire/cli/plugin/dispatch.go @@ -0,0 +1,169 @@ +package plugin + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + + "github.com/spf13/cobra" +) + +// Dispatch resolves an unknown subcommand to an installed plugin and execs +// it. Returns handled=true if a plugin was found and executed (even if it +// returned a non-zero exit code, which is propagated via err). When handled +// is false, the caller should fall through to its normal unknown-command +// handling. +// +// args is the full argv slice excluding the program name (e.g. +// os.Args[1:]). Cobra resolves built-ins first, so any name registered on +// rootCmd (including aliases) takes precedence over an installed plugin. +func Dispatch(ctx context.Context, rootCmd *cobra.Command, args []string) (bool, error) { + mgr, err := NewManager() + if err != nil { + return false, err + } + return DispatchWith(ctx, rootCmd, args, mgr) +} + +// DispatchWith is Dispatch with an explicit Manager (useful for tests so +// they can avoid mutating ENTIRE_PLUGIN_DIR). +func DispatchWith(ctx context.Context, rootCmd *cobra.Command, args []string, mgr *Manager) (bool, error) { + if len(args) == 0 { + return false, nil + } + + // Skip flags. The first non-flag arg is the candidate subcommand name. + // We deliberately mirror cobra's "first positional after the root" model: + // `entire --foo bar baz` → the candidate is "bar". + first := -1 + for i, a := range args { + if a == "--" { + if i+1 < len(args) { + first = i + 1 + } + break + } + if len(a) == 0 || a[0] == '-' { + continue + } + first = i + break + } + if first == -1 { + return false, nil + } + + name := args[first] + if !ValidName(name) { + return false, nil + } + + // Built-in precedence: cobra resolves built-ins first. If rootCmd.Find() + // returns a non-root command, the user wanted a built-in (potentially + // misspelled) and we should not shadow it. + if isBuiltin(rootCmd, name) { + return false, nil + } + + if mgr == nil { + return false, errors.New("plugin: nil manager") + } + p, err := mgr.Find(name) + if err != nil { + return false, err + } + if p == nil { + return false, nil + } + + rest := args[first+1:] + if err := Exec(ctx, p, rest, mgr.Root); err != nil { + return true, err + } + return true, nil +} + +// Exec runs the plugin executable with the given args, inheriting stdio and +// injecting ENTIRE_* env vars. The caller's exit code should mirror the +// child's; on a non-zero exit Exec returns an *exec.ExitError so callers can +// inspect ProcessState. +func Exec(ctx context.Context, p *Plugin, args []string, pluginRoot string) error { + if p == nil { + return errors.New("plugin: nil plugin") + } + if p.ExecPath == "" { + return fmt.Errorf("plugin %q: missing executable path", p.Name) + } + if _, err := os.Stat(p.ExecPath); err != nil { + return fmt.Errorf("plugin %q: %w", p.Name, err) + } + + cmd, err := buildExecCommand(ctx, p, args) + if err != nil { + return err + } + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Env = pluginEnv(os.Environ(), p, pluginRoot) + if err := cmd.Run(); err != nil { + // Always wrap, but preserve the *exec.ExitError chain via %w so + // callers can errors.As to recover the child's exit code. + return fmt.Errorf("plugin %q: %w", p.Name, err) + } + return nil +} + +// pluginEnv returns the child environment with ENTIRE_* injections. +// - ENTIRE_PLUGIN_DATA_DIR: per-plugin durable storage under the plugins root. +// - ENTIRE_REPO_ROOT: passes through if the parent already set it. +// - ENTIRE_SESSION_ID: passes through if the parent already set it. +// +// Callers (e.g. main.go) are responsible for setting ENTIRE_REPO_ROOT before +// invoking dispatch. We don't compute it here to avoid pulling the paths +// package and its git CLI dependency into the dispatcher. +func pluginEnv(parent []string, p *Plugin, pluginRoot string) []string { + out := make([]string, 0, len(parent)+1) + out = append(out, parent...) + out = append(out, "ENTIRE_PLUGIN_DATA_DIR="+pluginDataDir(pluginRoot, p.Name)) + return out +} + +// pluginDataDir returns a per-plugin data directory adjacent to the plugin +// install location. Plugins should write durable data here, not into the +// install dir (which gets replaced on upgrade). +func pluginDataDir(root, name string) string { + return root + string(os.PathSeparator) + Prefix + name + string(os.PathSeparator) + "data" +} + +// isBuiltin reports whether name resolves to a built-in command or alias on +// rootCmd. This mirrors gh's install-time conflict check. +func isBuiltin(rootCmd *cobra.Command, name string) bool { + if rootCmd == nil { + return false + } + cmd, _, err := rootCmd.Find([]string{name}) + if err != nil { + return false + } + // Find returns the deepest command it could resolve; for an unknown + // subcommand it returns rootCmd itself with no error. So a real built-in + // is anything that resolved past the root. + return cmd != nil && cmd != rootCmd +} + +// PropagateExitCode inspects err for an *exec.ExitError and returns the +// child's exit status, or -1 if err is nil or unrelated to a child exit. +// Callers (typically main.go) can use this to mirror the plugin's exit code. +func PropagateExitCode(err error) int { + if err == nil { + return 0 + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return exitErr.ExitCode() + } + return -1 +} diff --git a/cmd/entire/cli/plugin/dispatch_test.go b/cmd/entire/cli/plugin/dispatch_test.go new file mode 100644 index 0000000000..d2d4043046 --- /dev/null +++ b/cmd/entire/cli/plugin/dispatch_test.go @@ -0,0 +1,201 @@ +package plugin + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +// newTestManager returns a Manager rooted at a fresh temp dir. +func newTestManager(t *testing.T) *Manager { + t.Helper() + return &Manager{Root: t.TempDir()} +} + +// installFakeScript writes a fake script plugin at root/entire-/entire-. +// On Unix the script is made executable; on Windows we skip these tests. +func installFakeScript(t *testing.T, root, name, body string) { + t.Helper() + dir := filepath.Join(root, Prefix+name) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + exe := filepath.Join(dir, Prefix+name) + if err := os.WriteFile(exe, []byte(body), 0o755); err != nil { + t.Fatalf("write exe: %v", err) + } +} + +func TestDispatch_NoArgs(t *testing.T) { + t.Parallel() + root := &cobra.Command{Use: "entire"} + mgr := newTestManager(t) + handled, err := DispatchWith(context.Background(), root, nil, mgr) + if handled || err != nil { + t.Errorf("Dispatch(nil) = (%v, %v); want (false, nil)", handled, err) + } +} + +func TestDispatch_FlagsOnly(t *testing.T) { + t.Parallel() + root := &cobra.Command{Use: "entire"} + mgr := newTestManager(t) + handled, err := DispatchWith(context.Background(), root, []string{"--foo", "--bar"}, mgr) + if handled || err != nil { + t.Errorf("flags-only: handled=%v err=%v; want false,nil", handled, err) + } +} + +func TestDispatch_BuiltinTakesPrecedence(t *testing.T) { + t.Parallel() + if runtime.GOOS == osWindows { + t.Skip("uses Unix shebang scripts") + } + mgr := newTestManager(t) + installFakeScript(t, mgr.Root, "status", "#!/bin/sh\nexit 0\n") + + rootCmd := &cobra.Command{Use: "entire"} + rootCmd.AddCommand(&cobra.Command{Use: "status"}) + + handled, err := DispatchWith(context.Background(), rootCmd, []string{"status"}, mgr) + if handled || err != nil { + t.Errorf("built-in shadow: handled=%v err=%v; want false,nil", handled, err) + } +} + +func TestDispatch_RunsPlugin(t *testing.T) { + t.Parallel() + if runtime.GOOS == osWindows { + t.Skip("uses Unix shebang scripts") + } + mgr := newTestManager(t) + installFakeScript(t, mgr.Root, "echo", "#!/bin/sh\nif [ \"$1\" = \"ok\" ]; then exit 0; else exit 7; fi\n") + + rootCmd := &cobra.Command{Use: "entire"} + handled, err := DispatchWith(context.Background(), rootCmd, []string{"echo", "ok"}, mgr) + if !handled { + t.Fatalf("plugin not handled: err=%v", err) + } + if err != nil { + t.Errorf("Dispatch error: %v", err) + } +} + +func TestDispatch_PropagatesExitCode(t *testing.T) { + t.Parallel() + if runtime.GOOS == osWindows { + t.Skip("uses Unix shebang scripts") + } + mgr := newTestManager(t) + installFakeScript(t, mgr.Root, "fail", "#!/bin/sh\nexit 42\n") + + rootCmd := &cobra.Command{Use: "entire"} + handled, err := DispatchWith(context.Background(), rootCmd, []string{"fail"}, mgr) + if !handled { + t.Fatalf("expected handled=true; err=%v", err) + } + if err == nil { + t.Fatalf("expected non-nil err for non-zero exit") + } + var exitErr *exec.ExitError + if !asExitError(err, &exitErr) { + t.Fatalf("err = %v; want *exec.ExitError", err) + } + if got := exitErr.ExitCode(); got != 42 { + t.Errorf("exit code = %d; want 42", got) + } + if got := PropagateExitCode(err); got != 42 { + t.Errorf("PropagateExitCode = %d; want 42", got) + } +} + +func TestDispatch_InjectsPluginDataDir(t *testing.T) { + t.Parallel() + if runtime.GOOS == osWindows { + t.Skip("uses Unix shebang scripts") + } + mgr := newTestManager(t) + out := filepath.Join(t.TempDir(), "out") + body := "#!/bin/sh\nprintf '%s' \"$ENTIRE_PLUGIN_DATA_DIR\" > " + out + "\n" + installFakeScript(t, mgr.Root, "envprobe", body) + + rootCmd := &cobra.Command{Use: "entire"} + if _, err := DispatchWith(context.Background(), rootCmd, []string{"envprobe"}, mgr); err != nil { + t.Fatalf("Dispatch: %v", err) + } + got, err := os.ReadFile(out) + if err != nil { + t.Fatalf("read out: %v", err) + } + want := filepath.Join(mgr.Root, Prefix+"envprobe", "data") + if string(got) != want { + t.Errorf("ENTIRE_PLUGIN_DATA_DIR = %q; want %q", got, want) + } +} + +func TestDispatch_UnknownPluginFallsThrough(t *testing.T) { + t.Parallel() + mgr := newTestManager(t) + rootCmd := &cobra.Command{Use: "entire"} + handled, err := DispatchWith(context.Background(), rootCmd, []string{"does-not-exist"}, mgr) + if handled || err != nil { + t.Errorf("unknown plugin: handled=%v err=%v; want false,nil", handled, err) + } +} + +func TestDispatch_RejectsInvalidName(t *testing.T) { + t.Parallel() + mgr := newTestManager(t) + rootCmd := &cobra.Command{Use: "entire"} + handled, err := DispatchWith(context.Background(), rootCmd, []string{"BadName"}, mgr) + if handled || err != nil { + t.Errorf("invalid name: handled=%v err=%v; want false,nil", handled, err) + } +} + +func TestDispatch_FirstNonFlagIsCandidate(t *testing.T) { + t.Parallel() + if runtime.GOOS == osWindows { + t.Skip("uses Unix shebang scripts") + } + mgr := newTestManager(t) + out := filepath.Join(t.TempDir(), "out") + body := "#!/bin/sh\nprintf '%s|' \"$@\" > " + out + "\n" + installFakeScript(t, mgr.Root, "args", body) + + rootCmd := &cobra.Command{Use: "entire"} + if _, err := DispatchWith(context.Background(), rootCmd, []string{"--global-flag", "args", "--child-flag", "value"}, mgr); err != nil { + t.Fatalf("Dispatch: %v", err) + } + got, err := os.ReadFile(out) + if err != nil { + t.Fatalf("read out: %v", err) + } + if !strings.Contains(string(got), "--child-flag|value|") { + t.Errorf("child args = %q; want to contain --child-flag|value|", got) + } +} + +func asExitError(err error, target **exec.ExitError) bool { + for cur := err; cur != nil; { + ee := &exec.ExitError{} + if errors.As(cur, &ee) { + *target = ee + return true + } + type unwrapper interface{ Unwrap() error } + u, ok := cur.(unwrapper) + if !ok { + return false + } + cur = u.Unwrap() + } + return false +} diff --git a/cmd/entire/cli/plugin/dispatch_unix.go b/cmd/entire/cli/plugin/dispatch_unix.go new file mode 100644 index 0000000000..a646aa298d --- /dev/null +++ b/cmd/entire/cli/plugin/dispatch_unix.go @@ -0,0 +1,14 @@ +//go:build unix + +package plugin + +import ( + "context" + "os/exec" +) + +// buildExecCommand returns an *exec.Cmd that runs the plugin directly. The +// shebang of script plugins is honored by the OS. +func buildExecCommand(ctx context.Context, p *Plugin, args []string) (*exec.Cmd, error) { + return exec.CommandContext(ctx, p.ExecPath, args...), nil +} diff --git a/cmd/entire/cli/plugin/dispatch_windows.go b/cmd/entire/cli/plugin/dispatch_windows.go new file mode 100644 index 0000000000..dd534ad532 --- /dev/null +++ b/cmd/entire/cli/plugin/dispatch_windows.go @@ -0,0 +1,42 @@ +//go:build windows + +package plugin + +import ( + "context" + "errors" + "os/exec" + "path/filepath" + "strings" +) + +// buildExecCommand returns an *exec.Cmd for the plugin executable. +// +// Binary and local plugins exec directly. Script plugins (git-cloned shell +// scripts) need shebang interpretation, which Windows lacks; we route them +// through sh.exe (gh's approach). The user must have a POSIX sh on PATH — +// Git for Windows ships one. +func buildExecCommand(ctx context.Context, p *Plugin, args []string) (*exec.Cmd, error) { + if p.Kind == KindScript && !looksExecutable(p.ExecPath) { + sh, err := exec.LookPath("sh.exe") + if err != nil { + return nil, errors.New("script plugins on Windows require sh.exe on PATH (e.g. via Git for Windows)") + } + full := append([]string{p.ExecPath}, args...) + return exec.CommandContext(ctx, sh, full...), nil + } + return exec.CommandContext(ctx, p.ExecPath, args...), nil +} + +// looksExecutable returns true if the file ends with a Windows-recognized +// executable extension. We use this to decide whether a script plugin entry +// is actually a compiled .exe (run directly) or a shebang script (route +// through sh.exe). +func looksExecutable(path string) bool { + ext := strings.ToLower(filepath.Ext(path)) + switch ext { + case ".exe", ".bat", ".cmd", ".com": + return true + } + return false +} diff --git a/cmd/entire/cli/plugin/install_local.go b/cmd/entire/cli/plugin/install_local.go new file mode 100644 index 0000000000..50afb737ad --- /dev/null +++ b/cmd/entire/cli/plugin/install_local.go @@ -0,0 +1,84 @@ +package plugin + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/spf13/cobra" +) + +// InstallLocalOptions configures InstallLocal. +type InstallLocalOptions struct { + // SourceDir is the directory containing the plugin executable. + SourceDir string + // Force replaces any existing plugin entry with the same name. + Force bool + // RootCmd is used for built-in conflict detection. Optional; if nil, the + // check is skipped. + RootCmd *cobra.Command +} + +// InstallLocal symlinks SourceDir into the plugins root as a local plugin. +// Used by `entire plugin install .`. The directory name must be `entire-` +// and must contain an executable of the same name. +func (m *Manager) InstallLocal(opts InstallLocalOptions) (*Plugin, error) { + src, err := filepath.Abs(opts.SourceDir) + if err != nil { + return nil, fmt.Errorf("resolve source dir: %w", err) + } + info, err := os.Stat(src) + if err != nil { + return nil, fmt.Errorf("stat source dir: %w", err) + } + if !info.IsDir() { + return nil, fmt.Errorf("source must be a directory: %s", src) + } + + dirName := filepath.Base(src) + if !strings.HasPrefix(dirName, Prefix) { + return nil, fmt.Errorf("directory name must start with %q (got %q)", Prefix, dirName) + } + bare := strings.TrimPrefix(dirName, Prefix) + if !ValidName(bare) { + return nil, fmt.Errorf("invalid plugin name %q", bare) + } + + exec := filepath.Join(src, executableName(dirName)) + execInfo, err := os.Stat(exec) + if err != nil { + return nil, fmt.Errorf("plugin executable %q not found in %s", executableName(dirName), src) + } + if runtime.GOOS != osWindows && execInfo.Mode()&0o111 == 0 { + return nil, fmt.Errorf("plugin executable %s is not executable (chmod +x)", exec) + } + + if opts.RootCmd != nil && isBuiltin(opts.RootCmd, bare) { + return nil, fmt.Errorf("name %q conflicts with a built-in command; rename the plugin or use 'entire plugin exec %s' to invoke", bare, bare) + } + + if err := m.EnsureRoot(); err != nil { + return nil, err + } + + dest := filepath.Join(m.Root, dirName) + if _, err := os.Lstat(dest); err == nil { + if !opts.Force { + return nil, fmt.Errorf("plugin %q already installed; use --force to replace", bare) + } + if err := os.RemoveAll(dest); err != nil { + return nil, fmt.Errorf("remove existing plugin: %w", err) + } + } else if !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("stat install destination: %w", err) + } + + if err := os.Symlink(src, dest); err != nil { + return nil, fmt.Errorf("symlink plugin: %w", err) + } + + return m.Find(bare) +} diff --git a/cmd/entire/cli/plugin/install_test.go b/cmd/entire/cli/plugin/install_test.go new file mode 100644 index 0000000000..c4f7f039d1 --- /dev/null +++ b/cmd/entire/cli/plugin/install_test.go @@ -0,0 +1,114 @@ +package plugin + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/spf13/cobra" +) + +func TestInstallLocal_Symlinks(t *testing.T) { + t.Parallel() + if runtime.GOOS == osWindows { + t.Skip("local install uses symlinks; Windows path differs") + } + + root := t.TempDir() + m := &Manager{Root: root} + + src := filepath.Join(t.TempDir(), "entire-foo") + mustMkdir(t, src) + mustWriteExec(t, filepath.Join(src, "entire-foo"), "#!/bin/sh\nexit 0\n") + + p, err := m.InstallLocal(InstallLocalOptions{SourceDir: src}) + if err != nil { + t.Fatalf("InstallLocal: %v", err) + } + if p.Kind != KindLocal { + t.Errorf("kind = %v; want local", p.Kind) + } + if p.Name != "foo" { + t.Errorf("name = %q; want foo", p.Name) + } + + // Re-installing without --force fails. + if _, err := m.InstallLocal(InstallLocalOptions{SourceDir: src}); err == nil { + t.Errorf("expected error on re-install without --force") + } + + // With --force, succeeds. + if _, err := m.InstallLocal(InstallLocalOptions{SourceDir: src, Force: true}); err != nil { + t.Errorf("InstallLocal --force: %v", err) + } +} + +func TestInstallLocal_RejectsBuiltinName(t *testing.T) { + t.Parallel() + if runtime.GOOS == osWindows { + t.Skip("symlink path") + } + + root := t.TempDir() + m := &Manager{Root: root} + + rootCmd := &cobra.Command{Use: "entire"} + rootCmd.AddCommand(&cobra.Command{Use: "status"}) + + src := filepath.Join(t.TempDir(), "entire-status") + mustMkdir(t, src) + mustWriteExec(t, filepath.Join(src, "entire-status"), "#!/bin/sh\nexit 0\n") + + _, err := m.InstallLocal(InstallLocalOptions{SourceDir: src, RootCmd: rootCmd}) + if err == nil { + t.Fatalf("expected conflict error for built-in name") + } +} + +func TestInstallLocal_RejectsBadDirName(t *testing.T) { + t.Parallel() + if runtime.GOOS == osWindows { + t.Skip("symlink path") + } + + root := t.TempDir() + m := &Manager{Root: root} + + src := filepath.Join(t.TempDir(), "not-prefixed") + mustMkdir(t, src) + mustWriteExec(t, filepath.Join(src, "not-prefixed"), "#!/bin/sh\nexit 0\n") + + if _, err := m.InstallLocal(InstallLocalOptions{SourceDir: src}); err == nil { + t.Errorf("expected error for non-prefixed dir name") + } +} + +func TestRemove(t *testing.T) { + t.Parallel() + if runtime.GOOS == osWindows { + t.Skip("symlink path") + } + + root := t.TempDir() + m := &Manager{Root: root} + + src := filepath.Join(t.TempDir(), "entire-foo") + mustMkdir(t, src) + mustWriteExec(t, filepath.Join(src, "entire-foo"), "#!/bin/sh\nexit 0\n") + + if _, err := m.InstallLocal(InstallLocalOptions{SourceDir: src}); err != nil { + t.Fatalf("InstallLocal: %v", err) + } + if err := m.Remove("foo"); err != nil { + t.Fatalf("Remove: %v", err) + } + if err := m.Remove("foo"); err == nil { + t.Errorf("expected error removing already-removed plugin") + } + + // Source must still exist after removing the symlink. + if _, err := os.ReadDir(src); err != nil { + t.Errorf("source dir was disturbed by Remove: %v", err) + } +} diff --git a/cmd/entire/cli/plugin/manager.go b/cmd/entire/cli/plugin/manager.go new file mode 100644 index 0000000000..765b624b77 --- /dev/null +++ b/cmd/entire/cli/plugin/manager.go @@ -0,0 +1,267 @@ +package plugin + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "sort" + "strings" +) + +// Prefix is the required filename prefix for plugin executables and their +// containing directories. `entire ` dispatches to `entire-`. +const Prefix = "entire-" + +// osWindows mirrors runtime.GOOS for windows; constant to satisfy goconst. +const osWindows = "windows" + +// Kind classifies how a plugin was installed. +type Kind string + +const ( + // KindBinary is a plugin installed from a GitHub release asset. Has a + // manifest.yml describing owner/name/tag. + KindBinary Kind = "binary" + // KindScript is a git-cloned repository whose root contains an executable + // of the same name as the directory. + KindScript Kind = "script" + // KindLocal is a symlink, used by `entire plugin install .` for development. + KindLocal Kind = "local" +) + +// Plugin describes one installed plugin. +type Plugin struct { + // Name is the bare plugin name (without the "entire-" prefix). + Name string + // Kind is how this plugin was installed. + Kind Kind + // Dir is the absolute path to the plugin directory inside the plugins + // root. For local plugins, this is the symlink path itself. + Dir string + // ExecPath is the absolute path to the executable to invoke. + ExecPath string + // Manifest is populated for binary plugins. Nil otherwise. + Manifest *BinaryManifest + // PinnedSHA is the sha recorded in a .pin- marker, empty if unpinned. + PinnedSHA string +} + +// FullName returns the executable name including the "entire-" prefix. +func (p *Plugin) FullName() string { + return Prefix + p.Name +} + +// Manager handles plugin storage discovery and lifecycle. +type Manager struct { + // Root is the plugins directory (e.g. ~/.local/share/entire/plugins). + Root string +} + +// NewManager returns a manager rooted at the configured plugins directory. +// Honors ENTIRE_PLUGIN_DIR; falls back to XDG_DATA_HOME/entire/plugins, then +// to a platform default. +func NewManager() (*Manager, error) { + root, err := DefaultRoot() + if err != nil { + return nil, err + } + return &Manager{Root: root}, nil +} + +// DefaultRoot resolves the plugins directory. +func DefaultRoot() (string, error) { + if v := os.Getenv("ENTIRE_PLUGIN_DIR"); v != "" { + return v, nil + } + if v := os.Getenv("XDG_DATA_HOME"); v != "" { + return filepath.Join(v, "entire", "plugins"), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home dir: %w", err) + } + switch runtime.GOOS { + case "windows": + if appData := os.Getenv("LOCALAPPDATA"); appData != "" { + return filepath.Join(appData, "entire", "plugins"), nil + } + return filepath.Join(home, "AppData", "Local", "entire", "plugins"), nil + case "darwin": + // Match XDG convention even on macOS for consistency with gh. + return filepath.Join(home, ".local", "share", "entire", "plugins"), nil + default: + return filepath.Join(home, ".local", "share", "entire", "plugins"), nil + } +} + +// EnsureRoot creates the plugins directory if it does not exist. +func (m *Manager) EnsureRoot() error { + if err := os.MkdirAll(m.Root, 0o750); err != nil { + return fmt.Errorf("create plugin dir: %w", err) + } + return nil +} + +// List returns all plugins discovered under Root, sorted by name. +// Entries that don't follow the entire- naming or aren't classifiable +// are skipped silently. +func (m *Manager) List() ([]*Plugin, error) { + entries, err := os.ReadDir(m.Root) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, fmt.Errorf("read plugin dir: %w", err) + } + + var plugins []*Plugin + for _, e := range entries { + name := e.Name() + if !strings.HasPrefix(name, Prefix) { + continue + } + p, err := m.classify(name) + if err != nil || p == nil { + continue + } + plugins = append(plugins, p) + } + + sort.Slice(plugins, func(i, j int) bool { return plugins[i].Name < plugins[j].Name }) + return plugins, nil +} + +// Find returns the plugin with the given bare name (without the "entire-" +// prefix), or (nil, nil) if none is installed. Callers check both: a non-nil +// plugin means installed, nil + nil error means not installed. +func (m *Manager) Find(name string) (*Plugin, error) { + if name == "" { + return nil, nil //nolint:nilnil // not-installed signal + } + full := Prefix + name + p, err := m.classify(full) + if err != nil { + return nil, err + } + return p, nil +} + +// classify inspects a single entry name (e.g. "entire-foo") under Root and +// returns the corresponding Plugin or nil if it isn't a valid plugin. +func (m *Manager) classify(fullName string) (*Plugin, error) { + if !strings.HasPrefix(fullName, Prefix) { + return nil, nil //nolint:nilnil // not-a-plugin signal + } + bare := strings.TrimPrefix(fullName, Prefix) + if bare == "" { + return nil, nil //nolint:nilnil // not-a-plugin signal + } + + path := filepath.Join(m.Root, fullName) + info, err := os.Lstat(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil //nolint:nilnil // not-installed signal + } + return nil, fmt.Errorf("stat plugin %q: %w", fullName, err) + } + + // Symlink → local plugin. + if info.Mode()&os.ModeSymlink != 0 { + target, err := filepath.EvalSymlinks(path) + if err != nil { + return nil, fmt.Errorf("resolve local plugin symlink %q: %w", fullName, err) + } + exec := localExecPath(target, fullName) + return &Plugin{ + Name: bare, + Kind: KindLocal, + Dir: path, + ExecPath: exec, + }, nil + } + + // Regular file at root level isn't a valid plugin layout. + if !info.IsDir() { + return nil, nil //nolint:nilnil // not-a-plugin signal + } + + // Manifest present → binary plugin. + manifestPath := filepath.Join(path, ManifestFileName) + if _, err := os.Stat(manifestPath); err == nil { + mf, err := LoadBinaryManifest(manifestPath) + if err != nil { + return nil, err + } + exec := mf.Path + if exec == "" { + exec = filepath.Join(path, executableName(fullName)) + } + return &Plugin{ + Name: bare, + Kind: KindBinary, + Dir: path, + ExecPath: exec, + Manifest: mf, + PinnedSHA: readPinSHA(path), + }, nil + } else if !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("stat manifest %q: %w", manifestPath, err) + } + + // Otherwise → script plugin (must contain an executable matching the dir name). + exec := filepath.Join(path, executableName(fullName)) + if _, err := os.Stat(exec); err != nil { + // Missing executable: not a valid plugin layout, but not an error + // either (the dir might be partial/in-progress). Return not-found. + return nil, nil //nolint:nilerr,nilnil // not-a-plugin signal + } + return &Plugin{ + Name: bare, + Kind: KindScript, + Dir: path, + ExecPath: exec, + PinnedSHA: readPinSHA(path), + }, nil +} + +// localExecPath returns the executable path for a local plugin given the +// resolved symlink target. The target is expected to be either the executable +// itself or a directory containing it. +func localExecPath(target, fullName string) string { + info, err := os.Stat(target) + if err == nil && info.IsDir() { + return filepath.Join(target, executableName(fullName)) + } + return target +} + +// executableName returns the platform-specific executable filename inside a +// plugin directory. +func executableName(fullName string) string { + if runtime.GOOS == osWindows { + return fullName + ".exe" + } + return fullName +} + +// ValidName reports whether s is a valid bare plugin name. Plugin names must +// be non-empty, lowercase ASCII letters, digits, and dashes. They must not +// start with a dash. +func ValidName(s string) bool { + if s == "" || s[0] == '-' { + return false + } + for _, r := range s { + switch { + case r >= 'a' && r <= 'z': + case r >= '0' && r <= '9': + case r == '-': + default: + return false + } + } + return true +} diff --git a/cmd/entire/cli/plugin/manager_test.go b/cmd/entire/cli/plugin/manager_test.go new file mode 100644 index 0000000000..d75a4a6464 --- /dev/null +++ b/cmd/entire/cli/plugin/manager_test.go @@ -0,0 +1,142 @@ +package plugin + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestValidName(t *testing.T) { + t.Parallel() + cases := []struct { + in string + want bool + }{ + {"foo", true}, + {"foo-bar", true}, + {"foo123", true}, + {"123", true}, + {"", false}, + {"-foo", false}, + {"Foo", false}, + {"foo_bar", false}, + {"foo.bar", false}, + {"foo/bar", false}, + } + for _, c := range cases { + if got := ValidName(c.in); got != c.want { + t.Errorf("ValidName(%q) = %v; want %v", c.in, got, c.want) + } + } +} + +func TestDefaultRoot_HonorsEnv(t *testing.T) { + // t.Setenv mutates process state and is incompatible with t.Parallel(). + t.Setenv("ENTIRE_PLUGIN_DIR", "/tmp/test-plugins") + got, err := DefaultRoot() + if err != nil { + t.Fatalf("DefaultRoot: %v", err) + } + if got != "/tmp/test-plugins" { + t.Errorf("DefaultRoot = %q; want /tmp/test-plugins", got) + } +} + +func TestList_EmptyOrMissingRoot(t *testing.T) { + t.Parallel() + dir := filepath.Join(t.TempDir(), "missing") + m := &Manager{Root: dir} + got, err := m.List() + if err != nil { + t.Fatalf("List: %v", err) + } + if len(got) != 0 { + t.Errorf("List = %v; want empty", got) + } +} + +func TestList_ClassifiesAllKinds(t *testing.T) { + t.Parallel() + if runtime.GOOS == osWindows { + t.Skip("symlink classification path uses Unix layout") + } + + root := t.TempDir() + m := &Manager{Root: root} + + // Binary plugin: directory + manifest + executable. + binDir := filepath.Join(root, "entire-bin") + mustMkdir(t, binDir) + mustWriteExec(t, filepath.Join(binDir, "entire-bin"), "#!/bin/sh\necho bin\n") + mf := &BinaryManifest{Owner: "octocat", Name: "bin", Host: "github.com", Tag: "v1.0.0"} + if err := mf.Save(filepath.Join(binDir, ManifestFileName)); err != nil { + t.Fatalf("save manifest: %v", err) + } + + // Script plugin: directory + executable, no manifest. + scriptDir := filepath.Join(root, "entire-script") + mustMkdir(t, scriptDir) + mustWriteExec(t, filepath.Join(scriptDir, "entire-script"), "#!/bin/sh\necho script\n") + + // Local plugin: symlink to a dev directory. + devDir := filepath.Join(t.TempDir(), "entire-local") + mustMkdir(t, devDir) + mustWriteExec(t, filepath.Join(devDir, "entire-local"), "#!/bin/sh\necho local\n") + if err := os.Symlink(devDir, filepath.Join(root, "entire-local")); err != nil { + t.Fatalf("symlink: %v", err) + } + + // Noise: a plain file at root level should be ignored. + if err := os.WriteFile(filepath.Join(root, "entire-bogus"), []byte("not a plugin"), 0o644); err != nil { + t.Fatalf("write bogus: %v", err) + } + // Noise: directory not prefixed. + mustMkdir(t, filepath.Join(root, "random")) + + plugins, err := m.List() + if err != nil { + t.Fatalf("List: %v", err) + } + if len(plugins) != 3 { + t.Fatalf("List returned %d plugins, want 3: %+v", len(plugins), plugins) + } + + got := map[string]Kind{} + for _, p := range plugins { + got[p.Name] = p.Kind + } + want := map[string]Kind{"bin": KindBinary, "local": KindLocal, "script": KindScript} + for name, kind := range want { + if got[name] != kind { + t.Errorf("plugin %q kind = %v, want %v", name, got[name], kind) + } + } +} + +func TestFind_ReturnsNilForUnknown(t *testing.T) { + t.Parallel() + root := t.TempDir() + m := &Manager{Root: root} + p, err := m.Find("nope") + if err != nil { + t.Fatalf("Find: %v", err) + } + if p != nil { + t.Errorf("Find(nope) = %+v; want nil", p) + } +} + +func mustMkdir(t *testing.T, p string) { + t.Helper() + if err := os.MkdirAll(p, 0o755); err != nil { + t.Fatalf("mkdir %s: %v", p, err) + } +} + +func mustWriteExec(t *testing.T, p, body string) { + t.Helper() + if err := os.WriteFile(p, []byte(body), 0o755); err != nil { + t.Fatalf("write %s: %v", p, err) + } +} diff --git a/cmd/entire/cli/plugin/manifest.go b/cmd/entire/cli/plugin/manifest.go new file mode 100644 index 0000000000..195c5f5a59 --- /dev/null +++ b/cmd/entire/cli/plugin/manifest.go @@ -0,0 +1,55 @@ +// Package plugin implements the gh-style plugin system: external executables +// named `entire-` that the CLI discovers and dispatches to when an +// unknown subcommand is invoked. +// +// See docs/plugin-system-plan.md for the architecture, and gh's +// pkg/cmd/extension for the design we mirror. +package plugin + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +// ManifestFileName is the filename of the binary plugin manifest within a +// plugin directory. Field-for-field parity with gh's binManifest. +const ManifestFileName = "manifest.yml" + +// BinaryManifest describes a plugin installed from a GitHub release asset. +// Field-for-field parity with gh's binManifest so user intuition transfers. +type BinaryManifest struct { + Owner string `yaml:"owner"` + Name string `yaml:"name"` + Host string `yaml:"host"` + Tag string `yaml:"tag"` + IsPinned bool `yaml:"isPinned"` + Path string `yaml:"path"` +} + +// LoadBinaryManifest reads a manifest from disk. +func LoadBinaryManifest(path string) (*BinaryManifest, error) { + // G304: path is constructed from the plugins root + a controlled prefix. + data, err := os.ReadFile(path) //nolint:gosec // controlled path under plugins root + if err != nil { + return nil, fmt.Errorf("read manifest: %w", err) + } + var m BinaryManifest + if err := yaml.Unmarshal(data, &m); err != nil { + return nil, fmt.Errorf("parse manifest: %w", err) + } + return &m, nil +} + +// Save writes the manifest to disk at path. +func (m *BinaryManifest) Save(path string) error { + data, err := yaml.Marshal(m) + if err != nil { + return fmt.Errorf("encode manifest: %w", err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + return fmt.Errorf("write manifest: %w", err) + } + return nil +} diff --git a/cmd/entire/cli/plugin/pin.go b/cmd/entire/cli/plugin/pin.go new file mode 100644 index 0000000000..4d53a764de --- /dev/null +++ b/cmd/entire/cli/plugin/pin.go @@ -0,0 +1,24 @@ +package plugin + +import ( + "os" + "strings" +) + +const pinPrefix = ".pin-" + +// readPinSHA returns the pinned SHA from a .pin- marker in dir, or "" if +// none exists. The first matching file wins; multiple pins are not expected. +func readPinSHA(dir string) string { + entries, err := os.ReadDir(dir) + if err != nil { + return "" + } + for _, e := range entries { + name := e.Name() + if strings.HasPrefix(name, pinPrefix) { + return strings.TrimPrefix(name, pinPrefix) + } + } + return "" +} diff --git a/cmd/entire/cli/plugin/remove.go b/cmd/entire/cli/plugin/remove.go new file mode 100644 index 0000000000..75bdcd5656 --- /dev/null +++ b/cmd/entire/cli/plugin/remove.go @@ -0,0 +1,35 @@ +package plugin + +import ( + "errors" + "fmt" + "os" +) + +// Remove deletes the plugin with the given bare name. Local plugins (symlinks) +// remove only the symlink, not the source directory. Returns an error if the +// plugin is not installed. +func (m *Manager) Remove(name string) error { + p, err := m.Find(name) + if err != nil { + return err + } + if p == nil { + return fmt.Errorf("plugin %q is not installed", name) + } + + // Local plugins are symlinks — os.Remove unlinks without touching the + // target. RemoveAll on a symlink would also just unlink it on Unix, but + // os.Remove is the right tool for clarity. + if p.Kind == KindLocal { + if err := os.Remove(p.Dir); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("remove local plugin: %w", err) + } + return nil + } + + if err := os.RemoveAll(p.Dir); err != nil { + return fmt.Errorf("remove plugin: %w", err) + } + return nil +} diff --git a/cmd/entire/cli/plugin_group.go b/cmd/entire/cli/plugin_group.go new file mode 100644 index 0000000000..aebd3f62cc --- /dev/null +++ b/cmd/entire/cli/plugin_group.go @@ -0,0 +1,189 @@ +package cli + +import ( + "errors" + "fmt" + "io" + + "github.com/entireio/cli/cmd/entire/cli/plugin" + "github.com/spf13/cobra" +) + +// newPluginGroupCmd builds `entire plugin` and its subcommands. +func newPluginGroupCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "plugin", + Short: "Manage Entire plugins (install, list, remove, exec)", + Long: `Manage Entire plugins. + +Plugins are external executables named 'entire-' that the CLI dispatches +to when an unknown subcommand is invoked. Install a local development plugin +with 'entire plugin install '. + +Commands: + install Install a plugin (currently: local directories only) + list List installed plugins + remove Uninstall a plugin + exec Run a plugin by name (escape hatch for built-in collisions) + +Examples: + entire plugin install ./entire-foo + entire plugin list + entire plugin remove foo + entire plugin exec foo --help`, + } + + cmd.AddCommand(newPluginListCmd()) + cmd.AddCommand(newPluginInstallCmd()) + cmd.AddCommand(newPluginRemoveCmd()) + cmd.AddCommand(newPluginExecCmd()) + return cmd +} + +func newPluginListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List installed plugins", + RunE: func(cmd *cobra.Command, _ []string) error { + return runPluginList(cmd.OutOrStdout()) + }, + } +} + +func runPluginList(w io.Writer) error { + mgr, err := plugin.NewManager() + if err != nil { + return fmt.Errorf("plugin manager: %w", err) + } + plugins, err := mgr.List() + if err != nil { + return fmt.Errorf("list plugins: %w", err) + } + if len(plugins) == 0 { + fmt.Fprintln(w, "No plugins installed.") + fmt.Fprintln(w, "Install one with 'entire plugin install '.") + return nil + } + for _, p := range plugins { + version := pluginVersionLabel(p) + fmt.Fprintf(w, "%-20s %-7s %s\n", p.Name, p.Kind, version) + } + return nil +} + +func pluginVersionLabel(p *plugin.Plugin) string { + switch p.Kind { + case plugin.KindBinary: + if p.Manifest != nil && p.Manifest.Tag != "" { + if p.PinnedSHA != "" || p.Manifest.IsPinned { + return p.Manifest.Tag + " (pinned)" + } + return p.Manifest.Tag + } + return "unknown" + case plugin.KindLocal: + return p.ExecPath + case plugin.KindScript: + return "" + default: + return "" + } +} + +func newPluginInstallCmd() *cobra.Command { + var force bool + cmd := &cobra.Command{ + Use: "install ", + Short: "Install a plugin", + Long: `Install a plugin. + +Currently only local directories are supported. The directory must be named +'entire-' and contain an executable of the same name. + +Remote installation (GitHub release assets and git-cloned script plugins) is +planned but not yet implemented. + +Examples: + entire plugin install ./entire-foo + entire plugin install . --force`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runPluginInstall(cmd, args[0], force) + }, + } + cmd.Flags().BoolVar(&force, "force", false, "Replace an already-installed plugin") + return cmd +} + +func runPluginInstall(cmd *cobra.Command, src string, force bool) error { + mgr, err := plugin.NewManager() + if err != nil { + return fmt.Errorf("plugin manager: %w", err) + } + root := cmd.Root() + p, err := mgr.InstallLocal(plugin.InstallLocalOptions{ + SourceDir: src, + Force: force, + RootCmd: root, + }) + if err != nil { + return fmt.Errorf("install plugin: %w", err) + } + fmt.Fprintf(cmd.OutOrStdout(), "Installed plugin %q (%s) → %s\n", p.Name, p.Kind, p.ExecPath) + return nil +} + +func newPluginRemoveCmd() *cobra.Command { + return &cobra.Command{ + Use: "remove ", + Short: "Uninstall a plugin", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + mgr, err := plugin.NewManager() + if err != nil { + return fmt.Errorf("plugin manager: %w", err) + } + if err := mgr.Remove(args[0]); err != nil { + return fmt.Errorf("remove plugin: %w", err) + } + fmt.Fprintf(cmd.OutOrStdout(), "Removed plugin %q\n", args[0]) + return nil + }, + } +} + +func newPluginExecCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "exec [args...]", + Short: "Run a plugin by name (bypassing built-in resolution)", + Args: cobra.MinimumNArgs(1), + DisableFlagParsing: true, + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + rest := args[1:] + mgr, err := plugin.NewManager() + if err != nil { + return fmt.Errorf("plugin manager: %w", err) + } + p, err := mgr.Find(name) + if err != nil { + return fmt.Errorf("find plugin: %w", err) + } + if p == nil { + return fmt.Errorf("plugin %q is not installed", name) + } + if err := plugin.Exec(cmd.Context(), p, rest, mgr.Root); err != nil { + code := plugin.PropagateExitCode(err) + if code > 0 { + // Plugin returned a non-zero exit code. Surface it via a + // SilentError so main.go preserves the user's intent + // without printing extra noise. + return NewSilentError(errors.New(p.FullName() + " exited with non-zero status")) + } + return fmt.Errorf("exec plugin: %w", err) + } + return nil + }, + } + return cmd +} diff --git a/cmd/entire/cli/root.go b/cmd/entire/cli/root.go index 18143e5e31..6dfe958fac 100644 --- a/cmd/entire/cli/root.go +++ b/cmd/entire/cli/root.go @@ -85,6 +85,7 @@ func NewRootCmd() *cobra.Command { cmd.AddCommand(newAgentGroupCmd()) // 'agent' cmd.AddCommand(newAuthCmd()) // 'auth' cmd.AddCommand(newDoctorCmd()) // 'doctor' (group: trace/logs/bundle) + cmd.AddCommand(newPluginGroupCmd()) // 'plugin' // Top-level lifecycle and standalone commands. cmd.AddCommand(newCleanCmd()) diff --git a/cmd/entire/main.go b/cmd/entire/main.go index b829d02122..9661d25b3b 100644 --- a/cmd/entire/main.go +++ b/cmd/entire/main.go @@ -11,6 +11,7 @@ import ( "syscall" "github.com/entireio/cli/cmd/entire/cli" + "github.com/entireio/cli/cmd/entire/cli/plugin" "github.com/spf13/cobra" ) @@ -40,6 +41,17 @@ func main() { case errors.As(err, &silent): // Command already printed the error case strings.Contains(err.Error(), "unknown command") || strings.Contains(err.Error(), "unknown flag"): + handled, pluginErr := plugin.Dispatch(ctx, rootCmd, os.Args[1:]) + if handled { + code := plugin.PropagateExitCode(pluginErr) + if code < 0 { + // Local failure (e.g. exec lookup) before the child ran. + fmt.Fprintln(rootCmd.OutOrStderr(), pluginErr) + code = 1 + } + cancel() + os.Exit(code) + } showSuggestion(rootCmd, err) default: fmt.Fprintln(rootCmd.OutOrStderr(), err) diff --git a/docs/plugin-system-plan.md b/docs/plugin-system-plan.md new file mode 100644 index 0000000000..d274981e42 --- /dev/null +++ b/docs/plugin-system-plan.md @@ -0,0 +1,234 @@ +# Plugin System Plan: `entire plugin` + +Status: Proposed +Last updated: 2026-05-04 + +A gh-style plugin system for the Entire CLI. Plugins are external executables +named `entire-` that the CLI discovers and dispatches to when an unknown +subcommand is invoked. Modeled directly on the GitHub CLI extension system +(`gh extension`), with terminology adapted to "plugin" to leave room for +future lifecycle/event hooks. + +## Background: how `gh extension` works + +- **Naming + invocation.** Repos must be named `gh-` and contain an + executable of the same name. `gh args...` falls through to + `gh- args...` with stdio inherited. Built-in commands cannot be + overridden; `gh extension exec ` is the escape hatch. +- **Storage.** Per-user, under `~/.local/share/gh/extensions/gh-/`. The + manager scans this directory and classifies each entry as one of three kinds: + - **Binary** — directory with a `manifest.yml` (`owner`, `name`, `host`, + `tag`, `isPinned`, `path`). Installed by downloading the release asset + matching the host's OS/arch. + - **Git script** — git clone with an executable of the same name (no + manifest). Updated via `git pull` / `git reset --hard origin/HEAD`. + - **Local** — symlink, used by `gh extension install .` for development. +- **Dispatch.** Implemented in `pkg/cmd/extension/manager.go`. On Unix it + `exec.Command`s the binary directly; on Windows it routes through `sh.exe` + so shebangs work. Pinning is a `.pin-` marker file that blocks upgrade + unless `--force`. +- **Conflict handling.** Install-time check walks the cobra tree + (`rootCmd.Find()`) and refuses any name matching a built-in or alias. At + runtime, cobra resolves built-ins first — a later-added built-in silently + shadows an installed extension; `gh extension exec` is the only recovery. +- **Subcommands.** `install`, `create`, `list`, `upgrade`, `remove`, `exec`, + `browse`, `search`. Update checks run every 24h (suppressible via env). + Discovery is via the `gh-extension` GitHub topic; gh does no signing or + verification and explicitly disclaims trust. + +## Open decisions (defaults proposed) + +1. **Trust model** — match gh's: no signing, no allowlist; install prints + repo URL with first-time confirmation; `--yes` skips. Revisit if Entire + ever ships an official registry. +2. **Plugin context** — argv passthrough plus a small set of env vars: + `ENTIRE_REPO_ROOT`, `ENTIRE_SESSION_ID` (when active), + `ENTIRE_PLUGIN_DATA_DIR`. No exposed Go SDK; plugins shell back into + `entire` for privileged operations. +3. **Script plugins on Windows** — match gh: route through `sh.exe` for + shebang support. Adds a small Windows-only code path; the alternative + (Unix-only scripts) breaks parity. + +## Package layout + +``` +cmd/entire/cli/plugin/ + manager.go // discovery, classification, list, paths + manager_test.go + install_binary.go // GitHub release asset → download → manifest + install_git.go // git clone path (script plugins) + install_local.go // symlink for `entire plugin install .` + install_test.go + upgrade.go // binary: refetch release; git: pull/reset + remove.go + dispatch.go // resolve `entire ` → exec + dispatch_unix.go // direct exec + dispatch_windows.go // sh.exe routing for script plugins + dispatch_test.go + manifest.go // YAML schema (binary plugins only) + http.go // GitHub release fetching + pin.go // .pin- markers + create/ + create.go // scaffold subcommand + templates/ + go/ // binary plugin scaffold + GH Actions release workflow + bash/ // script plugin scaffold + testutil/ // fake release server, fake git repo, fake plugin binaries +cmd/entire/cli/plugin_group.go // cobra wiring: `entire plugin {…}` +cmd/entire/cli/plugin_exec.go // `entire plugin exec ` +``` + +Naming follows the existing `_group.go` / `_.go` +convention from CLAUDE.md. + +## CLI surface + +``` +entire plugin install # owner/name, full URL, or "." for local + # auto-detects: release assets → binary; else git clone +entire plugin install --pin +entire plugin list # name, version, kind (binary|script|local), pinned? +entire plugin upgrade [] # all if omitted; --force overrides pin +entire plugin remove +entire plugin exec [...] # bypass cobra (escape hatch for collisions) +entire plugin search # GitHub topic search: "entire-plugin" +entire plugin browse # open repo in browser +entire plugin pin +entire plugin create [--precompiled=go|other] + # default: bash script template + # --precompiled=go: Go binary + release workflow + # --precompiled=other: language-agnostic binary scaffold +``` + +## Dispatcher wiring + +Hook in `main.go`'s existing unknown-command branch (`main.go:42-47`). +Replace the `showSuggestion` call site with: try +`plugin.Dispatch(rootCmd, os.Args[1:])` first; on miss, fall through to +`showSuggestion`. Cobra resolves built-ins first → plugins can never shadow +built-ins. Conflict check at install time uses `rootCmd.Find()` and rejects +collisions with built-ins or aliases. + +`Dispatch` returns `(handled bool, err error)`; `handled=false` falls through +to the suggestion path. Keeps the diff in `main.go` minimal and the contract +explicit. + +Per-kind exec: + +- **Binary / local** — direct `exec.Command` on Unix and Windows. +- **Script (git-cloned)** — direct exec on Unix (shebang honored). On + Windows, route through `sh.exe` (gh's approach); document the dependency. + +## Storage layout + +``` +$XDG_DATA_HOME/entire/plugins/ # ~/.local/share/entire/plugins; ENTIRE_PLUGIN_DIR override + entire-foo/ # binary plugin + manifest.yml # owner, name, host, tag, isPinned, path + entire-foo + .pin- # optional + entire-bar/ # script plugin (git-cloned) + .git/ + entire-bar # executable matching dir name + README.md ... # repo contents + entire-baz # local plugin (symlink → /path/to/dev/dir) +``` + +Classification on scan: symlink → local; has `manifest.yml` → binary; +otherwise → script. + +## Manifest (binary plugins only) + +```yaml +owner: octocat +name: foo +host: github.com +tag: v1.2.3 +isPinned: false +path: /home/u/.local/share/entire/plugins/entire-foo/entire-foo +``` + +Field-for-field parity with gh's `binManifest` so intuition transfers. + +## Install resolution + +`entire plugin install `: + +1. If arg is `.` → local symlink path. +2. Resolve `` to GitHub. Fetch latest release. +3. If release has an asset matching `entire-__(.exe)?` → + binary install. +4. Else → git-clone install (script plugin). Verify executable of same name + as the directory exists and is `+x`. +5. Conflict check: `rootCmd.Find([]string{name})` must return root or the + extension group; otherwise refuse with an error pointing at + `entire plugin exec`. +6. Atomic placement: download/clone to `entire-.tmp`, rename to final. + +## Upgrade + +- **Binary** — refetch latest release, replace binary atomically, rewrite + manifest. +- **Script** — `git -C pull --ff-only` (or + `git reset --hard origin/HEAD` with `--force`). +- **Local** — no-op (warn). +- **Pinned** — skip unless `--force`. + +## Milestones (independently mergeable) + +1. **Skeleton.** Storage, manager, classification, `list`, `remove`. Tests + with hand-placed fake plugins of all three kinds. +2. **Dispatch.** All three kinds, Unix + Windows. Argv passthrough, exit-code + propagation, stdio inheritance, `ENTIRE_*` env injection. Tests via + `execx.NonInteractive` against fake plugins. +3. **Install — binary path.** GitHub release fetch with a mocked `httptest` + server. Conflict check against `rootCmd.Find()`. Atomic swap. +4. **Install — git path + local.** Shell out to the `git` CLI (consistent + with Entire's existing carve-out for go-git v5 quirks). `.` → symlink. +5. **Upgrade.** Both kinds, `--force` flag, pin handling. +6. **`entire plugin exec`.** Tiny; routes through dispatcher with built-in + precedence bypassed. +7. **`entire plugin create`.** `bash` (default) and `go` (with GitHub Actions + release workflow) templates under `create/templates/`. Embedded via + `embed.FS`. `--precompiled=other` writes a minimal language-agnostic + scaffold. +8. **Search + browse + pin.** Topic-based discovery via the `entire-plugin` + GitHub topic. +9. **Update notifier.** 24h check, suppressible via + `ENTIRE_NO_PLUGIN_UPDATE_CHECK`. Reuses `versioncheck` plumbing from + `root.go:70`. +10. **Docs.** `docs/architecture/plugin-system.md`, "writing a plugin" page, + CLAUDE.md update. + +## Testing strategy + +- `t.Parallel()` everywhere; isolation via `t.TempDir()` plus + `ENTIRE_PLUGIN_DIR`. +- Spawn fake plugins via `execx.NonInteractive` (per CLAUDE.md guidance for + spawning real binaries in tests). +- Mock GitHub release server with `httptest`. +- Git-path install tests use a local bare repo as the "remote" — no network. +- E2E: a Vogon-style fake plugin in `e2e/` covering install (binary + git) → + list → exec → upgrade → remove. Add to the `test:ci` canary. +- Conflict-shadowing regression: register a built-in `foo`, install + `entire-foo`, assert install fails; force-place a binary, assert + `entire foo` resolves to the built-in and `entire plugin exec foo` + resolves to the plugin. +- Windows script-dispatch test: gated build tag; verifies `sh.exe` routing. + +## Out of scope for v1 (deferred deliberately) + +- **Lifecycle/event hooks** (PostCommit, session-start, etc.) — phase 2; + the door is left open by choosing "plugin" over "extension" as the name. +- **Signing / verification** — gh does not do this either. +- **Cross-machine sync** — gh does not do this either. +- **Central registry beyond GitHub topic search** — gh does not do this + either. + +## References + +- gh extension manual: https://cli.github.com/manual/gh_extension +- Using GitHub CLI extensions: + https://docs.github.com/en/github-cli/github-cli/using-github-cli-extensions +- gh extension source: https://github.com/cli/cli/tree/trunk/pkg/cmd/extension +- Naming conflict bypass discussion: https://github.com/cli/cli/issues/5427 diff --git a/go.mod b/go.mod index 5ed9b06d6c..a8d4bce607 100644 --- a/go.mod +++ b/go.mod @@ -27,6 +27,7 @@ require ( golang.org/x/sync v0.20.0 golang.org/x/sys v0.43.0 golang.org/x/term v0.42.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -132,5 +133,4 @@ require ( google.golang.org/protobuf v1.36.11 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/ini.v1 v1.67.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect )