Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ With Entire, you can:
- [Strategy](#strategy)
- [Local Device Auth Testing](#local-device-auth-testing)
- [Commands Reference](#commands-reference)
- [Plugins](#plugins)
- [Configuration](#configuration)
- [Security & Privacy](#security--privacy)
- [Troubleshooting](#troubleshooting)
Expand Down Expand Up @@ -247,6 +248,7 @@ go test -tags=integration ./cmd/entire/cli/integration_test -run TestLogin
| `entire checkpoint explain` | Explain a session, commit, or checkpoint |
| `entire checkpoint rewind` | Rewind to a previous checkpoint (deprecated, will be removed in a future release) |
| `entire login` | Authenticate the CLI with Entire device auth |
| `entire plugin` | Discover, install, upgrade, and remove plugins (see [Plugins](#plugins)) |
| `entire session` | View and manage agent sessions tracked by Entire |
| `entire session resume` | Switch to a branch, restore latest checkpointed session metadata, and show command(s) |
| `entire session attach` | Attach to a previously detached session |
Expand Down Expand Up @@ -315,6 +317,27 @@ entire agent add claude-code
entire agent remove claude-code
```

## Plugins

Plugins extend the CLI with new verbs: any executable named `entire-<name>` on `$PATH` runs as `entire <name>`, kubectl-style — stdio passes through, exit codes propagate, no SDK or protocol required.

```sh
entire plugin search # browse the plugin index
entire plugin install upgrade # install by name (index lookup)
entire plugin install https://github.com/you/entire-x # install from any git host
entire plugin install ./dist/entire-x # link a local build
entire upgrade # run an installed plugin
entire plugin upgrade --all # update remote-installed plugins
entire plugin doctor # check for broken installs and missing dependencies
entire plugin remove x
```

Remote installs are forge-agnostic: the newest semver tag is resolved over the git protocol, and the platform's release asset is downloaded and verified against the release's `checksums.txt`. Plugins can declare dependencies on other plugins in an `entire-plugin.yml`; missing ones are installed after a single confirmation.

Discovery uses a git-synced catalog, [entireio/plugin-index](https://github.com/entireio/plugin-index) by default. Organizations can point the CLI at an internal catalog via `plugins.index_url` in `.entire/settings.json`, the `ENTIRE_PLUGIN_INDEX_URL` environment variable, or `--index`.

For the full contract — resolution rules, environment filtering, release-asset conventions, and how to author a plugin — see [External Commands](docs/architecture/external-commands.md).

## Configuration

Entire uses two configuration files in the `.entire/` directory:
Expand Down
1 change: 1 addition & 0 deletions cmd/entire/cli/explain.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const (
lessPagerName = "less"
lessRawControlEnv = "LESS=-R"
windowsGOOS = "windows"
darwinGOOS = "darwin"
)

var checkpointSummaryTimeout = defaultCheckpointSummaryTimeout
Expand Down
275 changes: 275 additions & 0 deletions cmd/entire/cli/integration_test/plugin_remote_install_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
//go:build integration

package integration

import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"

"github.com/entireio/cli/cmd/entire/cli/execx"
"github.com/entireio/cli/cmd/entire/cli/testutil"
)

// Integration tests for remote plugin install: the spawned binary resolves
// tags over the git protocol (file:// repos), downloads release assets from
// a local HTTP server (download_url template), installs into an isolated
// ENTIRE_PLUGIN_DIR, and then the kubectl dispatcher actually runs the
// installed plugin — the full M1+M2+M3 path with zero real network.

// makeScriptTarGz builds a tar.gz holding an executable shell script named
// entire-<name> that echoes a version marker.
func makeScriptTarGz(t *testing.T, name, marker string) []byte {
t.Helper()
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
script := "#!/bin/sh\necho " + marker + "\n"
if err := tw.WriteHeader(&tar.Header{Name: "entire-" + name, Mode: 0o755, Size: int64(len(script)), Typeflag: tar.TypeReg}); err != nil {
t.Fatal(err)
}
if _, err := tw.Write([]byte(script)); err != nil {
t.Fatal(err)
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
if err := gz.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}

// startReleaseServer serves tar.gz assets for the named plugins/versions.
func startReleaseServer(t *testing.T, plugins map[string][]string) *httptest.Server {
t.Helper()
assets := map[string][]byte{}
for name, versions := range plugins {
for _, v := range versions {
asset := fmt.Sprintf("entire-%s_%s_%s_%s.tar.gz", name, v, runtime.GOOS, runtime.GOARCH)
assets[asset] = makeScriptTarGz(t, name, name+"-ran-v"+v)
}
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
base := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]
if data, ok := assets[base]; ok {
_, _ = w.Write(data) //nolint:errcheck // test server write; failure surfaces as a client error
return
}
http.NotFound(w, r)
}))
t.Cleanup(srv.Close)
return srv
}

// newPluginRepo creates a tagged git repo with entire-plugin.yml.
func newPluginRepo(t *testing.T, metadata string, tags ...string) string {
t.Helper()
dir := t.TempDir()
testutil.InitRepo(t, dir)
testutil.WriteFile(t, dir, "entire-plugin.yml", metadata)
testutil.GitAdd(t, dir, "entire-plugin.yml")
testutil.GitCommit(t, dir, "init")
for _, tag := range tags {
if out, err := exec.CommandContext(t.Context(), "git", "-C", dir, "tag", tag).CombinedOutput(); err != nil {
t.Fatalf("git tag: %v: %s", err, out)
}
}
return "file://" + filepath.ToSlash(dir)
}

// newIndexRepo creates a git repo holding index.json.
func newIndexRepo(t *testing.T, indexJSON string) string {
t.Helper()
dir := t.TempDir()
testutil.InitRepo(t, dir)
testutil.WriteFile(t, dir, "index.json", indexJSON)
testutil.GitAdd(t, dir, "index.json")
testutil.GitCommit(t, dir, "index")
return "file://" + filepath.ToSlash(dir)
}

// pluginEnv builds the child env: isolated plugin dir + cache + index URL.
func pluginTestEnv(t *testing.T, indexURL string) ([]string, string) {
t.Helper()
pluginDir := t.TempDir()
env := os.Environ()
env = append(env,
"ENTIRE_PLUGIN_DIR="+pluginDir,
"XDG_CACHE_HOME="+t.TempDir(),
)
if indexURL != "" {
env = append(env, "ENTIRE_PLUGIN_INDEX_URL="+indexURL)
}
return env, pluginDir
}

func runEntire(t *testing.T, env []string, args ...string) (stdout, stderr string, err error) {
t.Helper()
cmd := execx.NonInteractive(context.Background(), getTestBinary(), args...)
cmd.Env = env
var out, errBuf bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &errBuf
err = cmd.Run()
return out.String(), errBuf.String(), err
}

func TestPluginRemoteInstall_FromIndexAndDispatch(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("shell-script plugin payloads are Unix-only")
}
srv := startReleaseServer(t, map[string][]string{"demo": {"0.1.0"}})
repoURL := newPluginRepo(t, fmt.Sprintf("name: demo\ndownload_url: \"%s/dl/{tag}/{asset}\"\n", srv.URL), "v0.1.0")
indexURL := newIndexRepo(t, fmt.Sprintf(`{"version":1,"plugins":[{"name":"demo","repo_url":"%s","description":"Demo plugin","official":true}]}`, repoURL))
env, _ := pluginTestEnv(t, indexURL)

// Bare-name install resolves through the index; index-listed repos
// need no --yes.
stdout, stderr, err := runEntire(t, env, "plugin", "install", "demo")
if err != nil {
t.Fatalf("plugin install demo: %v\nstdout: %s\nstderr: %s", err, stdout, stderr)
}
if !strings.Contains(stdout, `Installed plugin "demo" v0.1.0`) {
t.Errorf("install output = %q", stdout)
}

// The dispatcher must now run it as `entire demo`.
stdout, stderr, err = runEntire(t, env, "demo")
if err != nil {
t.Fatalf("entire demo: %v\nstderr: %s", err, stderr)
}
if !strings.Contains(stdout, "demo-ran-v0.1.0") {
t.Errorf("dispatched plugin output = %q, want demo-ran-v0.1.0", stdout)
}

// list shows the tag from the manifest.
stdout, _, err = runEntire(t, env, "plugin", "list")
if err != nil {
t.Fatalf("plugin list: %v", err)
}
if !strings.Contains(stdout, "demo") || !strings.Contains(stdout, "v0.1.0") {
t.Errorf("plugin list output = %q, want name+tag", stdout)
}
}

func TestPluginRemoteInstall_URLNeedsYesWhenUnlisted(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("shell-script plugin payloads are Unix-only")
}
srv := startReleaseServer(t, map[string][]string{"solo": {"0.1.0"}})
repoURL := newPluginRepo(t, fmt.Sprintf("name: solo\ndownload_url: \"%s/dl/{tag}/{asset}\"\n", srv.URL), "v0.1.0")
// Empty index: the URL is unlisted → untrusted.
indexURL := newIndexRepo(t, `{"version":1,"plugins":[]}`)
env, _ := pluginTestEnv(t, indexURL)

// Non-interactive without --yes refuses.
_, stderr, err := runEntire(t, env, "plugin", "install", repoURL)
if err == nil {
t.Fatal("unlisted URL install without --yes succeeded in non-interactive mode")
}
if !strings.Contains(stderr, "--yes") {
t.Errorf("stderr = %q, want a hint about --yes", stderr)
}

// With --yes it proceeds.
stdout, stderr, err := runEntire(t, env, "plugin", "install", repoURL, "--yes")
if err != nil {
t.Fatalf("install --yes: %v\nstderr: %s", err, stderr)
}
if !strings.Contains(stdout, `Installed plugin "solo" v0.1.0`) {
t.Errorf("install output = %q", stdout)
}
}

func TestPluginRemoteInstall_DependenciesAndRemoveGuard(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("shell-script plugin payloads are Unix-only")
}
srv := startReleaseServer(t, map[string][]string{"brainy": {"0.1.0"}, "semy": {"0.1.0"}})
semRepo := newPluginRepo(t, fmt.Sprintf("name: semy\ndownload_url: \"%s/dl/{tag}/{asset}\"\n", srv.URL), "v0.1.0")
brainRepo := newPluginRepo(t, fmt.Sprintf(
"name: brainy\ndownload_url: \"%s/dl/{tag}/{asset}\"\nrequires:\n - name: semy\n repo_url: %s\n", srv.URL, semRepo), "v0.1.0")
indexURL := newIndexRepo(t, fmt.Sprintf(
`{"version":1,"plugins":[{"name":"brainy","repo_url":"%s"},{"name":"semy","repo_url":"%s"}]}`, brainRepo, semRepo))
env, _ := pluginTestEnv(t, indexURL)

stdout, stderr, err := runEntire(t, env, "plugin", "install", "brainy", "--yes")
if err != nil {
t.Fatalf("install brainy: %v\nstdout: %s\nstderr: %s", err, stdout, stderr)
}
if !strings.Contains(stdout, `Installed dependency "semy"`) {
t.Errorf("install output = %q, want dependency install", stdout)
}

// Both dispatchable.
for _, name := range []string{"brainy", "semy"} {
out, _, err := runEntire(t, env, name)
if err != nil || !strings.Contains(out, name+"-ran-v0.1.0") {
t.Errorf("entire %s = %q, %v", name, out, err)
}
}

// Remove guard: semy is required by brainy.
_, stderr, err = runEntire(t, env, "plugin", "remove", "semy")
if err == nil {
t.Fatal("remove of depended-on plugin succeeded without --force")
}
if !strings.Contains(stderr, "brainy") {
t.Errorf("remove stderr = %q, want dependent named", stderr)
}
if _, _, err := runEntire(t, env, "plugin", "remove", "semy", "--force"); err != nil {
t.Errorf("remove --force failed: %v", err)
}

// Doctor now reports the missing dependency, exit code 1.
stdout, _, err = runEntire(t, env, "plugin", "doctor")
if err == nil {
t.Error("doctor exit code = 0 with missing dependency, want failure")
}
if !strings.Contains(stdout, `requires "semy"`) {
t.Errorf("doctor output = %q, want missing-dep report", stdout)
}
}

func TestPluginSearchAndInfo(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("shell-script plugin payloads are Unix-only")
}
indexURL := newIndexRepo(t, `{"version":1,"plugins":[
{"name":"alpha","repo_url":"https://example.com/entire-alpha","description":"First letter","official":true},
{"name":"beta","repo_url":"https://example.com/entire-beta","description":"Second letter"}]}`)
env, _ := pluginTestEnv(t, indexURL)

stdout, stderr, err := runEntire(t, env, "plugin", "search", "letter")
if err != nil {
t.Fatalf("plugin search: %v\nstderr: %s", err, stderr)
}
if !strings.Contains(stdout, "alpha") || !strings.Contains(stdout, "beta") || !strings.Contains(stdout, "[official]") {
t.Errorf("search output = %q", stdout)
}

stdout, _, err = runEntire(t, env, "plugin", "info", "alpha")
if err != nil {
t.Fatalf("plugin info: %v", err)
}
for _, want := range []string{"First letter", "https://example.com/entire-alpha", "Official: true"} {
if !strings.Contains(stdout, want) {
t.Errorf("info output missing %q: %s", want, stdout)
}
}
}
2 changes: 1 addition & 1 deletion cmd/entire/cli/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -531,7 +531,7 @@ func openBrowser(ctx context.Context, browserURL string) error {
var args []string

switch runtime.GOOS {
case "darwin":
case darwinGOOS:
command = "open"
args = []string{browserURL}
case "linux":
Expand Down
Loading
Loading