diff --git a/README.md b/README.md index a92e31b6..63e32dbe 100644 --- a/README.md +++ b/README.md @@ -187,8 +187,8 @@ forwarding, and exit codes. Run in any project directory: ```bash -enclave # Start claude (default) in current project -enclave --tool codex # Use a different tool +enclave # Start the current project's tool (asks which one on the first run) +enclave --tool codex # Use a different tool for one run enclave --backend qemu --tool codex # Experimental QEMU microVM run (implies --slim, all-network) enclave continue # Continue latest session enclave ps # List running containers (--all for stopped, --json for scripts) @@ -198,6 +198,8 @@ enclave info # Show config and image details enclave version # Show binary version and source commit (--json; alias: --version) ``` +**Tool selection:** The first interactive session asks which coding agent to run and saves the answer as `"tool"` in `~/.config/enclave/config.json`; later runs start it without asking. Scripts and non-terminal runs are never asked: without a configured tool they fail with a message naming `--tool` and the `tool` key. See [Tool selection](docs/cli-reference.md#tool-selection). + **Authentication:** The simplest and recommended approach is to just log in from inside the container the first time you run — OAuth sessions are saved to a persistent auth store on the host and reused automatically on every subsequent run. No configuration needed. To use declared env credentials instead, place them in `~/.local/state/enclave/secrets/global.env`. See [Authentication & Secrets](docs/auth.md). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 294470ec..ac23db24 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -174,7 +174,8 @@ The restricted network request flow has a separate - **Profiles** (`extensions/tools//spec.yaml`, `kind: sandbox`): define tool command, session continuation args (`continueArgs`, `resumeArgs`), config location, optional settings/skills metadata (`settingsFile`, `settingsTarget`, `skillsDir`), optional host passthrough allow-list (`passthroughPaths`), optional QEMU bundle minimum memory (`qemuMinMemoryMiB`) and config-store cache hint (`qemuStoreCacheMmap`), declared credential sources (`credentials.sources`) including API-key metadata, YOLO flag, and per-provider auth configuration (`providers`: credentials, auth files, auth session checks, OAuth ports). - **Runtime assets** (`runtime-assets/gateway-allowlists/`, `runtime-assets/build-scripts/`, `runtime-assets/auth-reconcile.sh`, `runtime-assets/net.sh`): DNS allowlists, Docker weaving scripts, and shared entrypoint helpers baked into the image. Tool templates live in `extensions/tools//templates/` and are aggregated during build. - **Asset discovery**: `ENCLAVE_HOME` has explicit precedence, followed by a valid app root above the resolved executable path. This keeps package-managed installs on `/usr/share/enclave` and in-tree builds on live checkout files. Other binaries extract their embedded assets into an append-only `assets//` directory under the platform cache root. The former unversioned data-root lookup is not used. -- **Image selection**: images are per-tool. The default tag is `enclave-:latest` for the selected `--tool` (default `claude`); `--slim` uses `enclave-:slim`. When running from a git checkout on a non-default branch, the tag is prefixed with the branch name and hash (for example, `enclave-codex:branch---latest`) to avoid overwriting main images. `--base-image` or devcontainer mode derives a separate tag (e.g., `enclave-codex:base--latest`) unless `--image-name` is set. +- **Tool selection** ([`internal/app/tool.go`](../internal/app/tool.go)): `--tool` selects the sandbox profile. The default is unset: the first command that needs a tool asks once which installed agent to use and saves the answer as `"tool"` in the global config. `actionNeedsTool` in [`internal/app/actions.go`](../internal/app/actions.go) decides which verbs need one; `update` with explicit tool arguments, `stop ` and `cleanup --all` name their own scope, and listing, status, extension and configuration verbs never read the default and leave it unresolved. The menu offers every installed agent profile and never the IDE profiles; without a terminal, under `--json` or `--yes`, or when the question goes unanswered, the command fails naming `--tool` and the `tool` key instead of guessing an agent. +- **Image selection**: images are per-tool. The default tag is `enclave-:latest` for the selected tool; `--slim` uses `enclave-:slim`. When running from a git checkout on a non-default branch, the tag is prefixed with the branch name and hash (for example, `enclave-codex:branch---latest`) to avoid overwriting main images. `--base-image` or devcontainer mode derives a separate tag (e.g., `enclave-codex:base--latest`) unless `--image-name` is set. - **Agent Node isolation**: Node-based agent CLIs are installed with a private runtime at `/opt/enclave/node` and launcher shebangs are rewritten to that absolute node path. This keeps agent runtime Node independent from user/project `node` on PATH. - **Isolation backend**: `--backend` selects the session isolation backend. The default `auto` resolves once at startup to `docker` or `podman`, whichever CLI is on `PATH` (asking once, and saving the answer to the global config, when both are). `docker` supports the full feature set. `podman` is the same backend driven through podman's Docker-compatible CLI: `internal/docker` switches its binary once at startup, and the backend adds `--userns=keep-id` to the gateway (run as root inside it) and auth-reconcile containers, while the session container joins the gateway's user namespace together with its network namespace, so the host UID/GID survives rootless podman's user namespace on bind-mounted stores and the session never joins a network namespace owned by a foreign user namespace (which fails under runc). Experimental `qemu` supports foreground slim/no-feature unrestricted sessions in an Alpine microVM bundle; it is x86-64 only (`qemu-system-x86_64`, x86-64 guest rootfs) and is KVM-accelerated only on x86-64 Linux hosts. Both backends realize persistent stores from the shared host-directory layout (`internal/backend/hoststore`), so auth, tool config, and persisted env are shared between containers and microVMs. - **User-defined subcommands**: executables under `~/.config/enclave/commands/{host,session}/` become `enclave ` verbs. `cli.Parse` discovers them, registers name-only stub commands (Cobra group "User Commands") so they list in `--help` and shell completion, and intercepts a matching first positional *before* `normalizeArgs`/Cobra so the trailing line reaches the script verbatim (preserving the unknown-command rejection for everything else). enclave flags must precede the name: host commands accept only the global group, session commands accept the full session flag set. `host/` commands exec directly on the host (`os/exec`, exit code/stdin/stdout passthrough, `ENCLAVE_BIN`/`ENCLAVE_PROJECT_ROOT`/`ENCLAVE_CONFIG_DIR` injected). `session/` commands run through the normal run pipeline as a shell-style execution (`opts.Shell=true`, argv `bash -c 'exec "$@"' ` so the script's shebang is honored via execve). @@ -285,7 +286,13 @@ The complete flow from CLI arguments to final options: │ ├── config.ResolveToolOverrideDefaults(global, project, opts.Tool) │ │ └── config.ApplyDefaultsWithSources(..., SourceToolOverride) │ │ │ -│ 7. Final Options with Sources tracked │ +│ 7. Resolve the unset tool (app.resolveTool) │ +│ ├── "auto" with a terminal: ask once among the installed │ +│ │ agent profiles and save the answer as "tool" globally │ +│ ├── otherwise: claude (scripts, --json, --yes, other verbs) │ +│ └── Steps 3-6 run again for the resolved tool │ +│ │ +│ 8. Final Options with Sources tracked │ │ └── Each field knows: CLI, ToolOverride, Project, Global, or Default │ └─────────────────────────────────────────────────────────────────────────────┘ ``` @@ -358,7 +365,7 @@ included explicitly. ## Image Naming -Images are per-tool; `` below is the selected `--tool` (default `claude`). +Images are per-tool; `` below is the selected tool. Default image naming behavior: - Explicit `--image-name`: always used (no auto-derivation). diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 402f640d..df0b1a79 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -16,7 +16,7 @@ unchanged. See [windows.md](windows.md). | Command | Description | |---------|-------------| -| `enclave` | Start a new session (default tool: claude) | +| `enclave` | Start a new session with the selected tool | | `enclave run` | Explicit alias for the above | | `enclave continue` | Continue the latest session for the selected tool | | `enclave resume` | Session picker/list when supported (falls back to `continue`) | @@ -202,7 +202,7 @@ Mutation commands (`add-domain`, `remove-domain`, `set-mode`) apply the new poli | Flag | Description | |------|-------------| -| `--tool ` | Tool profile to use (`claude` by default; run `enclave tools` for the installed list) | +| `--tool ` | Tool profile to use (asked once on the first interactive session, then the saved choice; run `enclave tools` for the installed list) | | `--backend ` | Isolation backend: `auto` (default: docker or podman, whichever is installed), `docker`, `podman`, or experimental `qemu` | | `--name ` | Named persistent session | | `--background` | Detached background session | @@ -277,7 +277,7 @@ Mutation commands (`add-domain`, `remove-domain`, `set-mode`) apply the new poli | Setting | Default | |---------|---------| -| Tool | `claude` | +| Tool | Asked once on the first interactive session, then the saved choice; an error without a terminal until `--tool` or the `tool` key is set | | Network | Restricted (allowlisted domains only) | | Persistence | Enabled (auth, env, history host-directory stores) | | YOLO mode | Enabled | @@ -286,6 +286,14 @@ Mutation commands (`add-domain`, `remove-domain`, `set-mode`) apply the new poli Persistent defaults can be set in `~/.config/enclave/config.json` (global) or `~/.config/enclave/projects//config.json` (per-project). See [Configuration](configuration.md). +## Tool selection + +The tool is unset by default. The first command that needs one asks once which coding agent to use and saves the answer as `"tool"` in `~/.config/enclave/config.json`; later runs read it from there and never ask again. A command needs a tool when it starts a session or builds its image (`run`, `shell`, `continue`, `resume`, `exec`, and `update` without explicit tool arguments) or scopes policy, stores, or containers by tool (`info`, `auth import`, `auth export`, `network …`, `cleanup` without `--all`, and `stop` without a session argument). The question is only asked when stdin, stdout, and stderr are terminals and no `--json` or `--yes` was given; without a terminal, or when the question goes unanswered, the command fails with a message naming `--tool` and the `tool` key instead of guessing an agent. Commands that never read the default tool (`ps`, `status`, `attach`, `tools`, `features`, `config`, `review-target`, `theia`, …) neither ask nor fail; they take `--tool` at most as an explicit filter. + +The menu lists every installed agent. The IDE profiles (`theia`, `theia-next`) are not offered: they attach a host IDE to a container rather than running an agent in the terminal. Pick them with `--tool theia` or the `tool` key. + +`--tool` overrides the saved choice for a single run, and a configured `tool` disables the question. Set `"tool": "auto"` to be asked again. + ## Backend detection The default backend is `auto`: enclave uses docker when its CLI is on `PATH`, otherwise podman. A `docker` command that is really the `podman-docker` shim counts as podman, and an explicit `docker`, from `--backend` or the `backend` key, that turns out to be the shim is driven as podman as well, with a notice. When both engines are installed, a command that uses an engine asks once which one to use and saves the answer as `"backend"` in `~/.config/enclave/config.json`. The question is only asked when stdin, stdout, and stderr are terminals and no `--json` or `--yes` was given; otherwise (scripts, captured output, JSON consumers) docker is used and a notice points at that key. Commands that never touch an engine (`tools`, `features`, `config`, `review-target`, `network print`, `network diff`, `devcontainer generate`) neither detect nor ask. When neither engine is found, the engine check reports it. An explicit `--backend` or a configured `backend` disables detection. diff --git a/docs/configuration.md b/docs/configuration.md index 4e84f246..346b5f15 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -46,7 +46,7 @@ supported under `tool_overrides.`. | Key | Description | |-----|-------------| -| `tool` | Default tool (e.g. `claude`, `codex`) | +| `tool` | Default tool (e.g. `claude`, `codex`); unset means the first interactive session asks once and writes the answer here (see [Tool selection](cli-reference.md#tool-selection)) | | `backend` | Isolation backend: `auto` (default) uses docker or podman, whichever is installed, and asks once when both are; or `docker`, `podman`, experimental `qemu` | | `host_config` | `none` (default) or `passthrough` | | `skills_validation` | Shared skill validation: `strict` (default) or `agent`; supports tool overrides | diff --git a/docs/extensions/README.md b/docs/extensions/README.md index b6d50cc4..9d0d7ea2 100644 --- a/docs/extensions/README.md +++ b/docs/extensions/README.md @@ -505,12 +505,12 @@ Session continuation arguments are declared under `sandbox`: ### Build Selection Runtime images are per-tool: the CLI builds and runs exactly one tool image per -session, selected with `--tool ` (default: `claude`). Each tool gets its +session, selected with `--tool ` or the `tool` key. Each tool gets its own image tagged `enclave-:...`, so rebuilding or updating one tool never invalidates another tool's image. ```bash -enclave --rebuild # build/run the default tool (claude) +enclave --rebuild # build/run the selected tool enclave --tool codex --rebuild # build/run the codex image ``` @@ -797,11 +797,10 @@ go build ./cmd/enclave ### 2. Build and Run with enclave ```bash -# Build and run with default tool (claude) -# This builds the full image with all default-enabled features -./enclave --rebuild +# Build and run the claude image with all default-enabled features +./enclave --tool claude --rebuild -# Run with a specific tool +# Run another tool ./enclave --tool codex ``` @@ -866,7 +865,7 @@ type direnv ```bash # Test agents-only image (no features) -./enclave --slim --rebuild +./enclave --tool claude --slim --rebuild # Inside container: claude --version # Should work (tool installed) @@ -923,7 +922,7 @@ docker build --progress=plain . 2>&1 | \ For production builds with per-tool layer caching, use the CLI: ```bash -enclave --rebuild +enclave --tool --rebuild ``` ### Quick Verification Checklist @@ -931,7 +930,7 @@ enclave --rebuild | Test | Command | Expected | |------|---------|----------| | Go builds | `go build ./cmd/enclave` | No errors | -| Default run | `./enclave` | Starts claude in container | +| Claude run | `./enclave --tool claude` | Starts claude in container | | Tool select | `./enclave --tool codex` | Starts codex | | gh installed | (in container) `gh --version` | Shows version | | glab installed (opt-in) | (in container) `glab --version` | Shows version when enabled | diff --git a/docs/tools.md b/docs/tools.md index 38eb5e07..9730a9c1 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -11,7 +11,7 @@ Built-in tool profiles: | Tool | Description | |------|-------------| -| `claude` | [Claude Code](https://www.anthropic.com/claude-code) (Anthropic) — default | +| `claude` | [Claude Code](https://www.anthropic.com/claude-code) (Anthropic) | | `codex` | [Codex CLI](https://github.com/openai/codex) (OpenAI) | | `mistral-vibe` | [Mistral Vibe CLI](https://github.com/mistralai/mistral-vibe) (opt-in/experimental) | | `opencode` | [OpenCode](https://opencode.ai/) | @@ -21,6 +21,8 @@ Built-in tool profiles: Tool profiles live in `extensions/tools//spec.yaml` (`kind: sandbox`) and declare the command, config directory, optional skills directory, QEMU microVM settings, and auth providers (API key vars, auth files, OAuth ports). +Which one runs is asked once on the first interactive session and then read from the `tool` key; the IDE profiles are selected explicitly with `--tool theia`. See [Tool selection](cli-reference.md#tool-selection). + ## Base Images The default base image is `debian:trixie-slim`. Override with `--base-image`: @@ -54,7 +56,7 @@ Unsupported fields (`dockerComposeFile`, `features`, `remoteEnv`, `initializeCom ## Image Variants Images are per-tool: each agent gets its own image, selected with `--tool` -(default: `claude`). +or the `tool` key. | Flag | Image tag | Description | |------|-----------|-------------| diff --git a/internal/app/actions.go b/internal/app/actions.go index bd60e1e3..41850134 100644 --- a/internal/app/actions.go +++ b/internal/app/actions.go @@ -37,3 +37,40 @@ var backendFreeActions = map[string]bool{ func actionUsesBackend(action string) bool { return !backendFreeActions[action] } + +// toolFreeActions never read the default tool: they list or manage sessions, +// extensions, configuration, and host-side setup, and read --tool at most as +// an explicit filter. The unset "auto" tool stays unresolved for them, so they +// neither ask which agent to use nor fail for lack of an answer. +var toolFreeActions = map[string]bool{ + "attach": true, + "config": true, + "extension-list": true, + "features": true, + "img-import": true, + "ps": true, + "review-target": true, + "ssh-init": true, + "status": true, + "theia": true, + "theia-next": true, + "tools": true, + "validate-extensions": true, + cli.ActionExtensionManage: true, +} + +// actionNeedsTool reports whether an invocation commits to a concrete tool: it +// starts a session, builds an image, or scopes policy, stores, or containers by +// tool. `update` with explicit targets, `stop ` and `cleanup --all` +// name their scope themselves and never read the default. +func actionNeedsTool(parsed cli.Result) bool { + switch parsed.Action { + case "update": + return len(parsed.Options.UpdateTools) == 0 + case "stop": + return len(parsed.Options.CmdArgs) == 0 + case "cleanup": + return !parsed.Options.CleanupAll + } + return !toolFreeActions[parsed.Action] +} diff --git a/internal/app/app.go b/internal/app/app.go index 9d80f635..71856f67 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -98,8 +98,22 @@ func Run(args []string) int { cliOpts := parsed.Options cliSources := parsed.Sources opts, toolDefaults, hasToolDefaults := config.ResolveOptionsForTool(cliOpts, cliSources, globalDefaults, projectDefaults, "") + // The tool is resolved before anything reads it: per-tool overrides, + // image identity and store paths all key on the concrete name, so the + // options are layered again once the unset "auto" has an answer. Verbs + // that never read the default tool leave it unresolved. + if actionNeedsTool(parsed) { + tool, err := resolveTool(opts.Tool, promptAllowed(parsed)) + if err != nil { + logx.Errorf("%v", err) + return 1 + } + if tool != opts.Tool { + opts, toolDefaults, hasToolDefaults = config.ResolveOptionsForTool(cliOpts, cliSources, globalDefaults, projectDefaults, tool) + } + } if actionUsesBackend(parsed.Action) { - resolveBackend(&opts, backendPromptAllowed(parsed)) + resolveBackend(&opts, promptAllowed(parsed)) } sources := opts.Sources parsed.Options = opts diff --git a/internal/app/backend.go b/internal/app/backend.go index 278c16e1..4fcd82c0 100644 --- a/internal/app/backend.go +++ b/internal/app/backend.go @@ -73,7 +73,8 @@ func selectBackend(opts model.Options, dockerOpts backenddocker.Options) (backen var ( detectContainerCLIs = docker.DetectCLIs dockerIsPodmanShim = docker.DockerIsPodmanShim - backendPromptUsable = func() bool { + // promptUsable gates every one-time setup question (backend and tool). + promptUsable = func() bool { // Redirected stdout means a consumer captures the output even while // stdin and stderr are terminals; result=$(enclave ps) must not block // on a question. @@ -82,10 +83,10 @@ var ( saveBackendChoice = config.WriteGlobalDefault ) -// backendPromptAllowed reports whether resolving the "auto" backend may ask the -// user. Structured output and --yes promise not to prompt, whatever the -// terminal looks like. -func backendPromptAllowed(parsed cli.Result) bool { +// promptAllowed reports whether resolving an unset option may ask the user. +// Structured output and --yes promise not to prompt, whatever the terminal +// looks like. +func promptAllowed(parsed cli.Result) bool { if parsed.Options.PSJSON || parsed.Options.StatusJSON || parsed.ConfigView.JSON { return false } @@ -136,7 +137,7 @@ func detectBackend(interactive bool) string { if err != nil { configPath = "the global config.json" } - if !interactive || !backendPromptUsable() { + if !interactive || !promptUsable() { logx.Warnf("Both docker and podman are installed; using docker. Set \"backend\" in %s to choose.", configPath) return backend.NameDocker } diff --git a/internal/app/backend_test.go b/internal/app/backend_test.go index 5792126e..770b4795 100644 --- a/internal/app/backend_test.go +++ b/internal/app/backend_test.go @@ -24,15 +24,15 @@ import ( func stubBackendResolution(t *testing.T, clis []string, interactive bool) *[]string { t.Helper() previousBinary := docker.Binary() - previousDetect, previousShim, previousPrompt, previousSave := detectContainerCLIs, dockerIsPodmanShim, backendPromptUsable, saveBackendChoice + previousDetect, previousShim, previousPrompt, previousSave := detectContainerCLIs, dockerIsPodmanShim, promptUsable, saveBackendChoice t.Cleanup(func() { docker.SetBinary(previousBinary) - detectContainerCLIs, dockerIsPodmanShim, backendPromptUsable, saveBackendChoice = previousDetect, previousShim, previousPrompt, previousSave + detectContainerCLIs, dockerIsPodmanShim, promptUsable, saveBackendChoice = previousDetect, previousShim, previousPrompt, previousSave }) saved := &[]string{} detectContainerCLIs = func() []string { return clis } dockerIsPodmanShim = func() bool { return false } - backendPromptUsable = func() bool { return interactive } + promptUsable = func() bool { return interactive } saveBackendChoice = func(key string, value string) (string, error) { *saved = append(*saved, key+"="+value) return "config.json", nil @@ -143,7 +143,7 @@ func TestResolveBackendNonInteractiveInvocationSkipsPrompt(t *testing.T) { } } -func TestBackendPromptAllowed(t *testing.T) { +func TestPromptAllowed(t *testing.T) { cases := []struct { name string parsed cli.Result @@ -159,8 +159,8 @@ func TestBackendPromptAllowed(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - if got := backendPromptAllowed(tc.parsed); got != tc.want { - t.Fatalf("backendPromptAllowed = %v, want %v", got, tc.want) + if got := promptAllowed(tc.parsed); got != tc.want { + t.Fatalf("promptAllowed = %v, want %v", got, tc.want) } }) } diff --git a/internal/app/tool.go b/internal/app/tool.go new file mode 100644 index 00000000..2396e3dd --- /dev/null +++ b/internal/app/tool.go @@ -0,0 +1,114 @@ +// Copyright (C) 2026 EclipseSource GmbH and others. +// +// This program and the accompanying materials are made available under the +// terms of the MIT License, which is available in the project root. +// +// SPDX-License-Identifier: MIT + +package app + +import ( + "errors" + "fmt" + "os" + "strings" + + "enclave/internal/config" + "enclave/internal/logx" + "enclave/internal/model" + "enclave/internal/prompt" +) + +// Seams for tool resolution, replaced in tests. +var ( + listAgentTools = hostAgentTools + saveToolChoice = config.WriteGlobalDefault + chooseTool = func(question string, options []string) (string, error) { + return prompt.Choose(question, options, os.Stdin, os.Stderr) + } +) + +// toolUnset reports whether the tool still carries the "ask me" value. +func toolUnset(name string) bool { + trimmed := strings.TrimSpace(name) + return trimmed == "" || trimmed == model.ToolAuto +} + +// resolveTool turns the unset "auto" tool into a concrete profile name. A +// configured value or an explicit --tool is returned unchanged. interactive +// permits the one-time question, whose answer is saved to the global config so +// only the first run pays for it. Without a terminal, or when the question goes +// unanswered, the run fails and names the ways to configure a tool instead of +// guessing one. +func resolveTool(tool string, interactive bool) (string, error) { + if !toolUnset(tool) { + return tool, nil + } + configPath, err := config.GlobalConfigPath() + if err != nil { + configPath = "the global config" + } + tools := listAgentTools() + if len(tools) == 0 || !interactive || !promptUsable() { + return "", noToolConfigured(configPath, tools) + } + question := fmt.Sprintf("Which coding agent should enclave use? The answer is saved to %s; --tool overrides it for a single run.", configPath) + choice, err := chooseTool(question, tools) + if err != nil || choice == "" { + return "", fmt.Errorf("no tool chosen; pass --tool or set \"tool\" in %s", configPath) + } + if _, err := saveToolChoice("tool", choice); err != nil { + logx.Warnf("Using %s for this run, but the choice could not be saved: %v", choice, err) + } + return choice, nil +} + +// noToolConfigured is the error for a command that needs a tool, has none +// configured, and cannot ask for one. +func noToolConfigured(configPath string, installed []string) error { + msg := fmt.Sprintf("no tool configured; pass --tool or set \"tool\" in %s", configPath) + if len(installed) > 0 { + msg += " (installed agents: " + strings.Join(installed, ", ") + ")" + } + return errors.New(msg) +} + +// hostAgentTools lists the installed agent profiles the question may offer. Any +// failure to enumerate them leaves the list empty and the caller fails as if +// none were installed. +func hostAgentTools() []string { + paths, err := config.ResolvePaths() + if err != nil { + logx.Debugf("tool question: resolve paths: %v", err) + return nil + } + names, err := config.ListProfiles(paths) + if err != nil { + logx.Debugf("tool question: list profiles: %v", err) + return nil + } + profiles := make([]model.Profile, 0, len(names)) + for _, name := range names { + profile, err := config.LoadProfile(paths, name) + if err != nil { + logx.Debugf("tool question: load profile %s: %v", name, err) + continue + } + profiles = append(profiles, profile) + } + return agentToolNames(profiles) +} + +// agentToolNames drops the IDE profiles. IDE profiles (postStart.openIDE) +// attach a host IDE to a container instead of running an agent in the +// terminal, which is not what this question is about. +func agentToolNames(profiles []model.Profile) []string { + names := make([]string, 0, len(profiles)) + for _, profile := range profiles { + if profile.PostStart != nil && strings.TrimSpace(profile.PostStart.OpenIDE) != "" { + continue + } + names = append(names, profile.Name) + } + return names +} diff --git a/internal/app/tool_test.go b/internal/app/tool_test.go new file mode 100644 index 00000000..a4800c07 --- /dev/null +++ b/internal/app/tool_test.go @@ -0,0 +1,267 @@ +// Copyright (C) 2026 EclipseSource GmbH and others. +// +// This program and the accompanying materials are made available under the +// terms of the MIT License, which is available in the project root. +// +// SPDX-License-Identifier: MIT + +package app + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "enclave/internal/cli" + "enclave/internal/config" + "enclave/internal/extinstall" + "enclave/internal/model" +) + +// toolPromptStub records what the stubbed tool-resolution seams were asked and +// told to save. +type toolPromptStub struct { + Offered []string + Saved []string +} + +// stubToolResolution replaces the tool-resolution seams for one test: the +// installed agents, whether a question could be shown, what the user answers, +// and where the answer is saved. +func stubToolResolution(t *testing.T, tools []string, interactive bool, answer string) *toolPromptStub { + t.Helper() + previousList, previousPrompt, previousChoose, previousSave := listAgentTools, promptUsable, chooseTool, saveToolChoice + t.Cleanup(func() { + listAgentTools, promptUsable, chooseTool, saveToolChoice = previousList, previousPrompt, previousChoose, previousSave + }) + stub := &toolPromptStub{} + listAgentTools = func() []string { return tools } + promptUsable = func() bool { return interactive } + chooseTool = func(_ string, options []string) (string, error) { + stub.Offered = append(stub.Offered, strings.Join(options, ",")) + return answer, nil + } + saveToolChoice = func(key string, value string) (string, error) { + stub.Saved = append(stub.Saved, key+"="+value) + return "config.json", nil + } + return stub +} + +func TestResolveToolKeepsAnExplicitTool(t *testing.T) { + stub := stubToolResolution(t, []string{"claude", "codex"}, true, "codex") + got, err := resolveTool("pi", true) + if err != nil || got != "pi" { + t.Fatalf("resolveTool = %q, %v, want pi", got, err) + } + if len(stub.Offered) != 0 || len(stub.Saved) != 0 { + t.Fatalf("an explicit tool must neither ask nor save, asked %v saved %v", stub.Offered, stub.Saved) + } +} + +// Scripts, CI, --json and --yes cannot be asked, and an unset tool is an error +// there rather than a guess. The message names the ways to configure one. +func TestResolveToolFailsWhenItCannotAsk(t *testing.T) { + for _, tc := range []struct { + name string + allowed bool + terminal bool + tools []string + }{ + {name: "prompt not allowed", allowed: false, terminal: true, tools: []string{"claude", "codex"}}, + {name: "not a terminal", allowed: true, terminal: false, tools: []string{"claude", "codex"}}, + {name: "no installed agents", allowed: true, terminal: true, tools: nil}, + } { + t.Run(tc.name, func(t *testing.T) { + stub := stubToolResolution(t, tc.tools, tc.terminal, "codex") + got, err := resolveTool(model.ToolAuto, tc.allowed) + if err == nil || got != "" { + t.Fatalf("resolveTool = %q, %v, want an error", got, err) + } + for _, hint := range []string{"--tool", `"tool"`} { + if !strings.Contains(err.Error(), hint) { + t.Errorf("error %q must mention %s", err, hint) + } + } + if len(tc.tools) > 0 && !strings.Contains(err.Error(), "claude, codex") { + t.Errorf("error %q must list the installed agents", err) + } + if len(stub.Offered) != 0 { + t.Fatalf("no question may be asked, got %v", stub.Offered) + } + if len(stub.Saved) != 0 { + t.Fatalf("nothing may be saved, got %v", stub.Saved) + } + }) + } +} + +// The first interactive run asks once and persists the answer, so the second +// run reads it from the global config and never asks again. +func TestResolveToolAsksOnceAndSavesTheAnswer(t *testing.T) { + stub := stubToolResolution(t, []string{"claude", "codex", "pi"}, true, "codex") + got, err := resolveTool(model.ToolAuto, true) + if err != nil || got != "codex" { + t.Fatalf("resolveTool = %q, %v, want codex", got, err) + } + if len(stub.Offered) != 1 || stub.Offered[0] != "claude,codex,pi" { + t.Fatalf("asked %v, want one question offering every installed agent", stub.Offered) + } + if len(stub.Saved) != 1 || stub.Saved[0] != "tool=codex" { + t.Fatalf("saved %v, want tool=codex", stub.Saved) + } + // A saved choice reaches the next run as a configured value. + stub.Offered, stub.Saved = nil, nil + got, err = resolveTool("codex", true) + if err != nil || got != "codex" { + t.Fatalf("second run resolveTool = %q, %v, want codex", got, err) + } + if len(stub.Offered) != 0 { + t.Fatalf("second run must not ask, got %v", stub.Offered) + } +} + +// An unanswered question (EOF, or answers the prompt cannot match) fails the +// run and persists nothing. +func TestResolveToolUnansweredFailsWithoutSaving(t *testing.T) { + stub := stubToolResolution(t, []string{"claude", "codex"}, true, "") + got, err := resolveTool(model.ToolAuto, true) + if err == nil || got != "" { + t.Fatalf("resolveTool = %q, %v, want an error", got, err) + } + if len(stub.Offered) != 1 { + t.Fatalf("asked %v, want exactly one question", stub.Offered) + } + if len(stub.Saved) != 0 { + t.Fatalf("an unanswered question must not save, got %v", stub.Saved) + } +} + +func TestActionNeedsTool(t *testing.T) { + for _, tc := range []struct { + name string + parsed cli.Result + want bool + }{ + {name: "plain run", parsed: cli.Result{Action: "run"}, want: true}, + {name: "shell", parsed: cli.Result{Action: "shell"}, want: true}, + {name: "continue", parsed: cli.Result{Action: actionContinue}, want: true}, + {name: "exec", parsed: cli.Result{Action: "exec"}, want: true}, + {name: "update", parsed: cli.Result{Action: "update"}, want: true}, + // Explicit targets rebuild exactly those images; the default tool is + // never read, so asking for one would save an unrelated answer. + {name: "update with explicit targets", parsed: cli.Result{Action: "update", Options: model.Options{UpdateOptions: model.UpdateOptions{UpdateTools: []string{"codex"}}}}, want: false}, + {name: "info", parsed: cli.Result{Action: "info"}, want: true}, + {name: "auth-import", parsed: cli.Result{Action: "auth-import"}, want: true}, + {name: "devcontainer-generate", parsed: cli.Result{Action: "devcontainer-generate"}, want: true}, + {name: "network-print", parsed: cli.Result{Action: "network-print"}, want: true}, + {name: "network-log", parsed: cli.Result{Action: "network-log"}, want: true}, + // Background sessions of the default tool are stopped; a named + // session is found without it. + {name: "stop", parsed: cli.Result{Action: "stop"}, want: true}, + {name: "stop named session", parsed: cli.Result{Action: "stop", Options: model.Options{RunOptions: model.RunOptions{CmdArgs: []string{"my-task"}}}}, want: false}, + {name: "cleanup", parsed: cli.Result{Action: "cleanup"}, want: true}, + {name: "cleanup --all", parsed: cli.Result{Action: "cleanup", Options: model.Options{CleanupOptions: model.CleanupOptions{CleanupAll: true}}}, want: false}, + {name: "ps", parsed: cli.Result{Action: "ps"}, want: false}, + {name: "status", parsed: cli.Result{Action: "status"}, want: false}, + {name: "attach", parsed: cli.Result{Action: "attach"}, want: false}, + {name: "tools", parsed: cli.Result{Action: "tools"}, want: false}, + {name: "features", parsed: cli.Result{Action: "features"}, want: false}, + {name: "extension-manage", parsed: cli.Result{Action: cli.ActionExtensionManage}, want: false}, + {name: "config", parsed: cli.Result{Action: "config"}, want: false}, + {name: "review-target", parsed: cli.Result{Action: "review-target"}, want: false}, + {name: "theia", parsed: cli.Result{Action: "theia"}, want: false}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := actionNeedsTool(tc.parsed); got != tc.want { + t.Fatalf("actionNeedsTool(%s) = %v, want %v", tc.parsed.Action, got, tc.want) + } + }) + } +} + +// --json and --yes runs cannot be asked; the unset tool then fails instead. +func TestToolPromptNotAllowedForJSONAndYes(t *testing.T) { + for _, tc := range []struct { + name string + parsed cli.Result + }{ + {name: "--json extension request", parsed: cli.Result{Action: "run", ExtRequest: &extinstall.Request{JSON: true}}}, + {name: "--yes", parsed: cli.Result{Action: "run", ExtRequest: &extinstall.Request{Yes: true}}}, + } { + t.Run(tc.name, func(t *testing.T) { + if promptAllowed(tc.parsed) { + t.Fatalf("promptAllowed(%s) = true, want false", tc.name) + } + }) + } +} + +func TestToolUnset(t *testing.T) { + for _, tc := range []struct { + tool string + want bool + }{ + {tool: model.ToolAuto, want: true}, + {tool: " auto ", want: true}, + {tool: "", want: true}, + {tool: "claude", want: false}, + } { + if got := toolUnset(tc.tool); got != tc.want { + t.Fatalf("toolUnset(%q) = %v, want %v", tc.tool, got, tc.want) + } + } +} + +// The default tool must stay unset so the first run can ask; a hardcoded +// vendor default is exactly what the question replaces. +func TestDefaultToolIsUnset(t *testing.T) { + if got := config.DefaultOptions().Tool; got != model.ToolAuto { + t.Fatalf("default tool = %q, want %q", got, model.ToolAuto) + } +} + +// The IDE profiles attach a host IDE to a container instead of running an +// agent in the terminal, so the question must not offer them. +func TestAgentToolNamesSkipsIDEProfiles(t *testing.T) { + profiles := []model.Profile{ + {Name: "claude"}, + {Name: "theia", PostStart: &model.PostStartActions{OpenIDE: "theia"}}, + {Name: "theia-next", PostStart: &model.PostStartActions{OpenIDE: "theia-next"}}, + {Name: "pi"}, + } + if got := strings.Join(agentToolNames(profiles), ","); got != "claude,pi" { + t.Fatalf("names = %q, want the agent profiles only", got) + } +} + +// The bundled tool tree must produce a usable menu: the agent profiles, and +// none of the IDE ones. The exact list is deliberately not asserted, so adding +// or removing a tool extension does not fail this test. +func TestHostAgentToolsOffersTheBundledAgents(t *testing.T) { + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("abs repo root: %v", err) + } + if _, err := os.Stat(filepath.Join(root, "extensions", "tools")); err != nil { + t.Skipf("bundled tool extensions not found: %v", err) + } + t.Setenv(model.EnvHome, root) + t.Setenv("HOME", t.TempDir()) + + offered := map[string]bool{} + for _, name := range hostAgentTools() { + offered[name] = true + } + for _, name := range []string{"claude", "codex"} { + if !offered[name] { + t.Errorf("agent profile %s must be offered, got %v", name, offered) + } + } + for _, name := range []string{"theia", "theia-next"} { + if offered[name] { + t.Errorf("IDE profile %s must not be offered, got %v", name, offered) + } + } +} diff --git a/internal/config/default_options.go b/internal/config/default_options.go index f8864743..2dc65e0c 100644 --- a/internal/config/default_options.go +++ b/internal/config/default_options.go @@ -12,7 +12,7 @@ import "enclave/internal/model" func DefaultOptions() model.Options { return model.Options{ RunOptions: model.RunOptions{ - Tool: "claude", + Tool: model.ToolAuto, Backend: "auto", HostConfig: model.HostConfigNone, SkillsValidation: model.SkillsValidationStrict, diff --git a/internal/config/options_cli_gen.go b/internal/config/options_cli_gen.go index 1e3fbcda..79e63a6c 100644 --- a/internal/config/options_cli_gen.go +++ b/internal/config/options_cli_gen.go @@ -15,7 +15,7 @@ import "enclave/internal/model" func optionCLIFlags() map[string][]CLIFlag { return map[string][]CLIFlag{ "tool": { - valueFlag("--tool", "Tool profile (default: claude)", "--tool requires a value (see --help for available tools)", func(opts *model.Options, sources *model.OptionSources, value string) error { + valueFlag("--tool", "Tool profile (default: asked once on the first interactive run, then the saved choice)", "--tool requires a value (see --help for available tools)", func(opts *model.Options, sources *model.OptionSources, value string) error { opts.Tool = value sources.Tool = model.SourceCLI return nil diff --git a/internal/config/options_def.go b/internal/config/options_def.go index 13afa174..1af8bd51 100644 --- a/internal/config/options_def.go +++ b/internal/config/options_def.go @@ -94,7 +94,7 @@ func OptionDefs() []OptionDef { CLIFlags: []CLIFlagDef{ { Name: "--tool", - Usage: "Tool profile (default: claude)", + Usage: "Tool profile (default: asked once on the first interactive run, then the saved choice)", ValueKind: CLIValueRequired, MissingValueMessage: "--tool requires a value (see --help for available tools)", Action: CLIAction{ diff --git a/internal/model/types.go b/internal/model/types.go index 383e87d6..8c5774aa 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -461,6 +461,10 @@ type DevcontainerConfig struct { const ( AppName = "enclave" + // ToolAuto is the unset tool value: a command that needs a tool asks once + // which agent to use and saves the answer when it can, and fails naming + // --tool and the "tool" key when it cannot. + ToolAuto = "auto" // AppID is the reverse-DNS application identifier. It names the app-specific // host directories on platforms that follow that convention (macOS // Library/Application Support and Library/Caches). diff --git a/website/docs/docs/configuration.md b/website/docs/docs/configuration.md index 76d2b306..7558ac98 100644 --- a/website/docs/docs/configuration.md +++ b/website/docs/docs/configuration.md @@ -5,10 +5,12 @@ title: Configuration # Configuration -Enclave works without any configuration: `enclave` starts the `claude` agent at -full autonomy, in a container, behind a restricted network. You configure it -when you want something else, such as a different agent, one more allowed -domain, or an extra directory mounted in. +Enclave works without any configuration: `enclave` starts an agent at full +autonomy, in a container, behind a restricted network. The first interactive +session asks which agent and remembers the answer (a non-terminal run is never +asked; until a tool is configured it fails and points at `--tool` and the `tool` +key). You configure it when you want something else, such as a different agent, +one more allowed domain, or an extra directory mounted in. A setting can come from three places: @@ -86,7 +88,7 @@ enclave config --json # the same data, machine-readable | Key | Flag | What it does | | --- | --- | --- | -| `tool` | `--tool ` | Which agent runs. Defaults to `claude`; `enclave tools` lists what is installed. | +| `tool` | `--tool ` | Which agent runs. Asked once on the first interactive session and saved here; `enclave tools` lists what is installed. | | `yolo` | `--no-yolo` | Full autonomy, on by default. Pass `--no-yolo` to make the agent ask for confirmation again. Every bundled CLI agent pins the mode in its own profile, so `"yolo": false` in a config file does not reach them; use the flag. | | `features` | `--features ` | Extra tooling baked into the image (see [Features](#features)). | | `ports` | `-p 3000` | Publish a container port to the host, so the agent's dev server is reachable from your browser. | diff --git a/website/docs/docs/getting-started.md b/website/docs/docs/getting-started.md index c885ebd8..9fcecc4d 100644 --- a/website/docs/docs/getting-started.md +++ b/website/docs/docs/getting-started.md @@ -154,15 +154,17 @@ schedule; neither replaces the Enclave binary on your host. ## Start your first session -From inside a git repository, launch the default agent in an isolated container: +From inside a git repository, launch your coding agent in an isolated container: ```bash enclave ``` -Enclave builds the environment, mounts the current folder into a container, and -starts your agent against the branch you have checked out. The agent runs at full -autonomy with no confirmation prompts, and it stays contained. +The first interactive run asks which agent to use and saves the answer, so this +is the only time you are asked. Enclave then builds the environment, mounts the +current folder into a container, and starts your agent against the branch you +have checked out. The agent runs at full autonomy with no confirmation prompts, +and it stays contained. To keep parallel sessions from stepping on each other, run each one in its own git worktree. This is plain git, no Enclave-specific setup required: @@ -178,6 +180,8 @@ more. ### Pick a specific agent +To override the saved answer for a single run, or to change it later: + ```bash enclave --tool codex ```