mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-12 08:41:32 -10:00
Encrypted credential storage - internal/securefile: AES-256-GCM at-rest crypter (fail-closed, atomic, locked). - internal/credstore: provider API-key store, keyring-first with an encrypted-file fallback; plaintext only via an explicit ZERO_CRED_STORAGE=file opt-out. - Provider API keys no longer live in config.json: captured straight into the store from the wizard/CLI setup, and any existing inline plaintext key is migrated to the store on launch (fail-soft; stripped only after the store write succeeds). - buildProvider and every model/provider switch reload the credential (stored key, env var, or OAuth bearer), so a stored-key provider always authenticates. - `zero auth logout <provider>` now also removes the stored API key and its marker. /model and /provider - /model lists every authenticated provider (filtered to those with a usable credential), grouped per provider under section headers. - Each provider's models are live-discovered (its real served models) rather than a static catalog; local providers show only the models actually pulled. - Selecting a model from another provider switches provider and model together. - /provider offers keep / replace / remove when a provider already has a stored key. Gated throughout: go build, go vet ./..., go test ./... -race, lint.
1157 lines
44 KiB
Go
1157 lines
44 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/netip"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Gitlawb/zero/internal/agent"
|
|
"github.com/Gitlawb/zero/internal/config"
|
|
"github.com/Gitlawb/zero/internal/hooks"
|
|
"github.com/Gitlawb/zero/internal/localcontrol"
|
|
"github.com/Gitlawb/zero/internal/mcp"
|
|
"github.com/Gitlawb/zero/internal/observability"
|
|
"github.com/Gitlawb/zero/internal/plugins"
|
|
"github.com/Gitlawb/zero/internal/providerhealth"
|
|
"github.com/Gitlawb/zero/internal/provideronboarding"
|
|
"github.com/Gitlawb/zero/internal/providers"
|
|
"github.com/Gitlawb/zero/internal/redaction"
|
|
"github.com/Gitlawb/zero/internal/sandbox"
|
|
"github.com/Gitlawb/zero/internal/selfverify"
|
|
"github.com/Gitlawb/zero/internal/sessions"
|
|
"github.com/Gitlawb/zero/internal/skills"
|
|
"github.com/Gitlawb/zero/internal/specialist"
|
|
"github.com/Gitlawb/zero/internal/swarm"
|
|
"github.com/Gitlawb/zero/internal/tools"
|
|
"github.com/Gitlawb/zero/internal/tui"
|
|
"github.com/Gitlawb/zero/internal/update"
|
|
"github.com/Gitlawb/zero/internal/verify"
|
|
"github.com/Gitlawb/zero/internal/worktrees"
|
|
"github.com/Gitlawb/zero/internal/zerogit"
|
|
"github.com/Gitlawb/zero/internal/zeroruntime"
|
|
)
|
|
|
|
var version = "dev"
|
|
|
|
type appDeps struct {
|
|
getwd func() (string, error)
|
|
stdin io.Reader
|
|
userConfigPath func() (string, error)
|
|
resolveConfig func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error)
|
|
resolveMCPConfig func(workspaceRoot string) (config.MCPConfig, error)
|
|
newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
|
|
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
|
|
detectLocalRuntimes func(context.Context, provideronboarding.LocalDetectOptions) []provideronboarding.DetectedLocalRuntime
|
|
newSessionStore func() *sessions.Store
|
|
loadPlugins func(plugins.LoadOptions) (plugins.LoadResult, error)
|
|
loadHooks func(hooks.LoadOptions) (hooks.LoadResult, error)
|
|
skillsDir func() string
|
|
pluginsDir func() string
|
|
toolsDir func() string
|
|
newMCPStore func() (*mcp.PermissionStore, error)
|
|
newMCPTokenStore func() (*mcp.TokenStore, error)
|
|
newSandboxStore func() (*sandbox.GrantStore, error)
|
|
selectSandboxBackend func(sandbox.BackendOptions) sandbox.Backend
|
|
runSandboxSetupHelper func(path string, args []string, stdout io.Writer, stderr io.Writer) error
|
|
registerMCPTools func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error)
|
|
prepareWorktree func(context.Context, worktrees.Options) (worktrees.Result, error)
|
|
detectVerifyPlan func(string) (verify.Plan, error)
|
|
runVerify func(context.Context, verify.Plan, verify.RunOptions) verify.Report
|
|
runSelfVerify func(context.Context, verify.Plan, selfverify.Options) selfverify.Report
|
|
runAgentEval func(context.Context, agentEvalOptions) (agentEvalReport, error)
|
|
inspectChanges func(context.Context, zerogit.InspectOptions) (zerogit.ChangeSummary, error)
|
|
commitChanges func(context.Context, zerogit.CommitOptions) (zerogit.CommitResult, error)
|
|
runTUI func(context.Context, tui.Options) int
|
|
runEditor func(string) error
|
|
checkUpdate func(context.Context, update.Options) (update.Result, error)
|
|
now func() time.Time
|
|
}
|
|
|
|
type mcpToolRuntime interface {
|
|
Close() error
|
|
Skipped() []mcp.SkippedServer
|
|
}
|
|
|
|
type noopMCPRuntime struct{}
|
|
|
|
func (noopMCPRuntime) Close() error {
|
|
return nil
|
|
}
|
|
|
|
func (noopMCPRuntime) Skipped() []mcp.SkippedServer {
|
|
return nil
|
|
}
|
|
|
|
// Run executes the minimal Go CLI surface. It returns an exit code so tests can
|
|
// exercise command behavior without terminating the test process.
|
|
func Run(args []string, stdout io.Writer, stderr io.Writer) int {
|
|
return runWithDeps(args, stdout, stderr, defaultAppDeps())
|
|
}
|
|
|
|
func defaultAppDeps() appDeps {
|
|
return appDeps{
|
|
getwd: os.Getwd,
|
|
stdin: os.Stdin,
|
|
userConfigPath: config.DefaultUserConfigPath,
|
|
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
|
options, err := config.DefaultResolveOptions(workspaceRoot)
|
|
if err != nil {
|
|
return config.ResolvedConfig{}, err
|
|
}
|
|
options.Overrides = overrides
|
|
return config.Resolve(options)
|
|
},
|
|
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
|
|
options, err := config.DefaultResolveOptions(workspaceRoot)
|
|
if err != nil {
|
|
return config.MCPConfig{}, err
|
|
}
|
|
return config.ResolveMCP(options)
|
|
},
|
|
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
|
return providers.New(profile, providers.Options{
|
|
UserAgent: userAgent(),
|
|
OAuthResolver: oauthResolverForProfile(profile),
|
|
})
|
|
},
|
|
probeProviderHealth: providerhealth.Probe,
|
|
detectLocalRuntimes: provideronboarding.DetectLocalRuntimes,
|
|
newSessionStore: func() *sessions.Store {
|
|
return sessions.NewStore(sessions.StoreOptions{})
|
|
},
|
|
loadPlugins: plugins.Load,
|
|
loadHooks: hooks.LoadConfig,
|
|
skillsDir: func() string {
|
|
return skills.DefaultDir(nil)
|
|
},
|
|
pluginsDir: defaultUserPluginsDir,
|
|
// Scaffolded tools are plugins, so the toolbox dir is the user plugins root:
|
|
// after activation a `tools make` skeleton is discovered like any plugin.
|
|
toolsDir: defaultUserPluginsDir,
|
|
newMCPStore: func() (*mcp.PermissionStore, error) {
|
|
return mcp.NewPermissionStore(mcp.StoreOptions{})
|
|
},
|
|
newMCPTokenStore: func() (*mcp.TokenStore, error) {
|
|
return mcp.NewTokenStore(mcp.TokenStoreOptions{})
|
|
},
|
|
newSandboxStore: func() (*sandbox.GrantStore, error) {
|
|
return sandbox.NewGrantStore(sandbox.StoreOptions{})
|
|
},
|
|
selectSandboxBackend: sandbox.SelectBackend,
|
|
runSandboxSetupHelper: func(path string, args []string, stdout io.Writer, stderr io.Writer) error {
|
|
cmd := exec.Command(path, args...)
|
|
cmd.Stdout = stdout
|
|
cmd.Stderr = stderr
|
|
return cmd.Run()
|
|
},
|
|
registerMCPTools: func(ctx context.Context, registry *tools.Registry, cfg config.MCPConfig, options mcp.RegisterOptions) (mcpToolRuntime, error) {
|
|
return mcp.RegisterTools(ctx, registry, cfg, options)
|
|
},
|
|
prepareWorktree: worktrees.Prepare,
|
|
detectVerifyPlan: verify.DetectPlan,
|
|
runVerify: verify.Run,
|
|
runSelfVerify: selfverify.Run,
|
|
runAgentEval: defaultRunAgentEval,
|
|
inspectChanges: zerogit.Inspect,
|
|
commitChanges: zerogit.Commit,
|
|
runTUI: tui.Run,
|
|
runEditor: openEditor,
|
|
checkUpdate: update.Check,
|
|
now: time.Now,
|
|
}
|
|
}
|
|
|
|
func userAgent() string {
|
|
return "zero/" + version
|
|
}
|
|
|
|
// defaultUserPluginsDir resolves the user-scoped plugins root
|
|
// ($XDG_CONFIG_HOME/zero/plugins) used as the install target for `plugin add`
|
|
// and the toolbox for `tools make`. It is the SourceUser root from
|
|
// plugins.ResolveRoots; an empty string is returned only if it cannot be
|
|
// resolved, which the command layer surfaces as an error.
|
|
func defaultUserPluginsDir() string {
|
|
roots, err := plugins.ResolveRoots(plugins.ResolveRootOptions{})
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
for _, root := range roots {
|
|
if root.Source == plugins.SourceUser {
|
|
return root.Path
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) (exitCode int) {
|
|
// Self-dispatch as the Windows sandbox helper. When no standalone helper .exe
|
|
// is shipped (dev / plain `go build`), the sandbox launches the running zero
|
|
// binary with one of these hidden subcommands instead of a separate
|
|
// executable (see resolveWindowsSandboxHelper). Routed before crash-recover,
|
|
// dep-fill, and --add-dir splitting so it can never collide with a real
|
|
// subcommand; the "__" prefix is unreachable by normal invocation.
|
|
if len(args) > 0 {
|
|
switch args[0] {
|
|
case sandbox.WindowsCommandRunnerSubcommand:
|
|
return sandbox.RunWindowsSandboxCommandRunner(args[1:], stderr)
|
|
case sandbox.WindowsSandboxSetupSubcommand:
|
|
return sandbox.RunWindowsSandboxSetup(args[1:], stderr)
|
|
}
|
|
}
|
|
|
|
// Convert an unexpected panic anywhere in the CLI into a saved crash report and
|
|
// a brief notice, rather than a raw stack trace dumped at the user.
|
|
defer observability.Recover(observability.DefaultCrashDir(), "cli", stderr, &exitCode)
|
|
deps = fillAppDeps(deps)
|
|
|
|
addDirs, args, err := splitLeadingAddDirFlags(args)
|
|
if err != nil {
|
|
return writeAppError(stderr, err.Error(), 1)
|
|
}
|
|
// --theme {auto|dark|light} selects the TUI palette non-interactively (populates
|
|
// tui.Options.Theme, which resolveThemeMode prefers over ZERO_THEME). Re-split
|
|
// --add-dir afterward so it may appear on either side of --theme.
|
|
theme, args, err := splitLeadingThemeFlag(args)
|
|
if err != nil {
|
|
return writeAppError(stderr, err.Error(), 1)
|
|
}
|
|
moreDirs, args, err := splitLeadingAddDirFlags(args)
|
|
if err != nil {
|
|
return writeAppError(stderr, err.Error(), 1)
|
|
}
|
|
addDirs = append(addDirs, moreDirs...)
|
|
|
|
if len(args) == 0 {
|
|
return runInteractiveTUI(stderr, deps, agent.PermissionModeAsk, addDirs, theme)
|
|
}
|
|
|
|
// --add-dir grants an extra write root, and only the interactive TUI and
|
|
// exec dispatch paths consume one. Fail loud everywhere else rather than
|
|
// silently discarding an explicit grant — including help/version, which
|
|
// run no agent and could only ignore it. The allowlist names exactly the
|
|
// cases below that forward addDirs; a future subcommand is rejected by
|
|
// default until it opts in here.
|
|
if len(addDirs) > 0 {
|
|
switch args[0] {
|
|
case "--skip-permissions-unsafe", "-p", "--prompt", "exec":
|
|
// Forwarded by the matching case below.
|
|
default:
|
|
return writeAppError(stderr, "--add-dir is only supported for the interactive TUI and exec", 1)
|
|
}
|
|
}
|
|
|
|
switch args[0] {
|
|
case "--skip-permissions-unsafe":
|
|
// Launch the interactive TUI directly in unsafe mode. Without this, the
|
|
// flag fell through to the unknown-command path, so a user could never
|
|
// reach unsafe mode in the shell — and the "!" shell escape (which is
|
|
// gated behind unsafe) was therefore unreachable.
|
|
//
|
|
// --add-dir may legally appear on either side of the flag, so re-split
|
|
// the remaining args and merge with the dirs already collected. Any
|
|
// trailing non-flag args were ignored on this path before --add-dir
|
|
// existed and still are — but an --add-dir hidden BEHIND one would be
|
|
// silently dropped with them, so reject that misplacement loudly.
|
|
moreDirs, rest, err := splitLeadingAddDirFlags(args[1:])
|
|
if err != nil {
|
|
return writeAppError(stderr, err.Error(), 1)
|
|
}
|
|
// --theme may appear here too; extract it before the stray-arg checks so it is
|
|
// not rejected as an unexpected positional, then re-split --add-dir after it.
|
|
skipTheme, rest, err := splitLeadingThemeFlag(rest)
|
|
if err != nil {
|
|
return writeAppError(stderr, err.Error(), 1)
|
|
}
|
|
evenMoreDirs, rest, err := splitLeadingAddDirFlags(rest)
|
|
if err != nil {
|
|
return writeAppError(stderr, err.Error(), 1)
|
|
}
|
|
moreDirs = append(moreDirs, evenMoreDirs...)
|
|
// A misplaced --add-dir anywhere in the remainder is the more specific error,
|
|
// so check for it across all of rest before rejecting stray args.
|
|
for _, arg := range rest {
|
|
if arg == "--add-dir" || strings.HasPrefix(arg, "--add-dir=") {
|
|
return writeAppError(stderr, "--add-dir must come before any other arguments (it may precede or follow --skip-permissions-unsafe)", 1)
|
|
}
|
|
}
|
|
// This path launches the interactive TUI, which takes no positional prompt or
|
|
// subcommand. Reject any remaining trailing arg loudly instead of silently
|
|
// dropping it, so `zero --skip-permissions-unsafe "fix bug"` doesn't appear to
|
|
// hang in the TUI with the prompt discarded. (AUDIT-L3)
|
|
for _, arg := range rest {
|
|
if strings.TrimSpace(arg) != "" {
|
|
return writeAppError(stderr, "--skip-permissions-unsafe launches the interactive TUI and takes no prompt or subcommand; for a one-shot unsafe run use `zero exec --skip-permissions-unsafe -p \"...\"`", 1)
|
|
}
|
|
}
|
|
return runInteractiveTUI(stderr, deps, agent.PermissionModeUnsafe, append(append([]string{}, addDirs...), moreDirs...), skipTheme)
|
|
case "-h", "--help", "help":
|
|
if err := writeHelp(stdout); err != nil {
|
|
return 1
|
|
}
|
|
return 0
|
|
case "-v", "--version", "version":
|
|
for _, a := range args[1:] {
|
|
if a == "-h" || a == "--help" {
|
|
if _, err := fmt.Fprintln(stdout, "Usage: zero version\n\nPrint the Zero CLI version. Takes no flags."); err != nil {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
}
|
|
if _, err := fmt.Fprintf(stdout, "zero %s\n", version); err != nil {
|
|
return 1
|
|
}
|
|
return 0
|
|
case "-p", "--prompt":
|
|
if len(args) < 2 {
|
|
return writePromptRequired(stderr)
|
|
}
|
|
// Forward leading --add-dir occurrences so exec's own parser collects them.
|
|
// Use the inline --prompt=<value> form so a prompt whose first character is a
|
|
// dash (e.g. `zero -p "-foo"`) is taken verbatim instead of being mistaken for
|
|
// a flag and rejected with "--prompt requires a value" (matches the cron path).
|
|
execArgs := append(addDirFlagArgs(addDirs), "--prompt="+args[1])
|
|
execArgs = append(execArgs, args[2:]...)
|
|
return runExec(execArgs, stdout, stderr, deps)
|
|
case "exec":
|
|
// Forward leading --add-dir occurrences so exec's own parser collects them.
|
|
return runExec(append(addDirFlagArgs(addDirs), args[1:]...), stdout, stderr, deps)
|
|
case "daemon":
|
|
return runDaemon(args[1:], stdout, stderr, deps)
|
|
case "config":
|
|
return runConfig(args[1:], stdout, stderr, deps)
|
|
case "models":
|
|
return runModels(args[1:], stdout, stderr)
|
|
case "providers":
|
|
return runProviders(args[1:], stdout, stderr, deps)
|
|
case "doctor":
|
|
return runDoctor(args[1:], stdout, stderr, deps)
|
|
case "setup":
|
|
return runSetup(args[1:], stdout, stderr, deps)
|
|
case "context":
|
|
return runContext(args[1:], stdout, stderr, deps)
|
|
case "repo-map", "repomap":
|
|
return runRepoMap(args[1:], stdout, stderr, deps)
|
|
case "search", "find":
|
|
return runSearch(args[1:], stdout, stderr, deps)
|
|
case "sessions", "session":
|
|
return runSessions(args[1:], stdout, stderr, deps)
|
|
case "spec":
|
|
return runSpec(args[1:], stdout, stderr, deps)
|
|
case "init":
|
|
return runInit(args[1:], stdout, stderr, deps)
|
|
case "specialists", "specialist":
|
|
return runSpecialists(args[1:], stdout, stderr, deps)
|
|
case "plugins", "plugin":
|
|
return runPlugins(args[1:], stdout, stderr, deps)
|
|
case "backends", "backend":
|
|
return runBackends(args[1:], stdout, stderr, deps)
|
|
case "skills", "skill":
|
|
return runSkills(args[1:], stdout, stderr, deps)
|
|
case "tools", "tool":
|
|
return runTools(args[1:], stdout, stderr, deps)
|
|
case "hooks":
|
|
return runHooks(args[1:], stdout, stderr, deps)
|
|
case "mcp":
|
|
return runMCP(args[1:], stdout, stderr, deps)
|
|
case "auth":
|
|
return runAuth(args[1:], stdout, stderr, deps)
|
|
case "sandbox":
|
|
return runSandbox(args[1:], stdout, stderr, deps)
|
|
case "update":
|
|
return runUpdate(args[1:], stdout, stderr, deps)
|
|
case "worktrees", "worktree":
|
|
return runWorktrees(args[1:], stdout, stderr, deps)
|
|
case "verify":
|
|
return runVerifyCommand(args[1:], stdout, stderr, deps)
|
|
case "eval":
|
|
return runAgentEvalCommand(args[1:], stdout, stderr, deps)
|
|
case "changes", "change":
|
|
return runChanges(args[1:], stdout, stderr, deps)
|
|
case "usage":
|
|
return runUsage(args[1:], stdout, stderr, deps)
|
|
case "cron":
|
|
return runCron(args[1:], stdout, stderr, deps)
|
|
case "repo-info", "repoinfo":
|
|
return runRepoInfo(args[1:], stdout, stderr, deps)
|
|
case "serve":
|
|
return runServe(args[1:], stdout, stderr, deps)
|
|
case "acp":
|
|
return runACP(args[1:], stdout, stderr, deps)
|
|
default:
|
|
if _, err := fmt.Fprintf(stderr, "unknown command %q\n", args[0]); err != nil {
|
|
return 1
|
|
}
|
|
if _, err := fmt.Fprintln(stderr, "Run zero --help for usage."); err != nil {
|
|
return 1
|
|
}
|
|
return 2
|
|
}
|
|
}
|
|
|
|
func fillAppDeps(deps appDeps) appDeps {
|
|
defaults := defaultAppDeps()
|
|
if deps.getwd == nil {
|
|
deps.getwd = defaults.getwd
|
|
}
|
|
if deps.stdin == nil {
|
|
deps.stdin = defaults.stdin
|
|
}
|
|
if deps.userConfigPath == nil {
|
|
deps.userConfigPath = defaults.userConfigPath
|
|
}
|
|
if deps.resolveConfig == nil {
|
|
deps.resolveConfig = defaults.resolveConfig
|
|
}
|
|
if deps.resolveMCPConfig == nil {
|
|
deps.resolveMCPConfig = defaults.resolveMCPConfig
|
|
}
|
|
if deps.newProvider == nil {
|
|
deps.newProvider = defaults.newProvider
|
|
}
|
|
if deps.probeProviderHealth == nil {
|
|
deps.probeProviderHealth = defaults.probeProviderHealth
|
|
}
|
|
if deps.detectLocalRuntimes == nil {
|
|
deps.detectLocalRuntimes = defaults.detectLocalRuntimes
|
|
}
|
|
if deps.newSessionStore == nil {
|
|
deps.newSessionStore = defaults.newSessionStore
|
|
}
|
|
if deps.loadPlugins == nil {
|
|
deps.loadPlugins = defaults.loadPlugins
|
|
}
|
|
if deps.loadHooks == nil {
|
|
deps.loadHooks = defaults.loadHooks
|
|
}
|
|
if deps.skillsDir == nil {
|
|
deps.skillsDir = defaults.skillsDir
|
|
}
|
|
if deps.pluginsDir == nil {
|
|
deps.pluginsDir = defaults.pluginsDir
|
|
}
|
|
if deps.toolsDir == nil {
|
|
deps.toolsDir = defaults.toolsDir
|
|
}
|
|
if deps.newMCPStore == nil {
|
|
deps.newMCPStore = defaults.newMCPStore
|
|
}
|
|
if deps.newMCPTokenStore == nil {
|
|
deps.newMCPTokenStore = defaults.newMCPTokenStore
|
|
}
|
|
if deps.newSandboxStore == nil {
|
|
deps.newSandboxStore = defaults.newSandboxStore
|
|
}
|
|
if deps.selectSandboxBackend == nil {
|
|
deps.selectSandboxBackend = defaults.selectSandboxBackend
|
|
}
|
|
if deps.runSandboxSetupHelper == nil {
|
|
deps.runSandboxSetupHelper = defaults.runSandboxSetupHelper
|
|
}
|
|
if deps.registerMCPTools == nil {
|
|
deps.registerMCPTools = defaults.registerMCPTools
|
|
}
|
|
if deps.prepareWorktree == nil {
|
|
deps.prepareWorktree = defaults.prepareWorktree
|
|
}
|
|
if deps.detectVerifyPlan == nil {
|
|
deps.detectVerifyPlan = defaults.detectVerifyPlan
|
|
}
|
|
if deps.runVerify == nil {
|
|
deps.runVerify = defaults.runVerify
|
|
}
|
|
if deps.runSelfVerify == nil {
|
|
deps.runSelfVerify = defaults.runSelfVerify
|
|
}
|
|
if deps.runAgentEval == nil {
|
|
deps.runAgentEval = defaults.runAgentEval
|
|
}
|
|
if deps.inspectChanges == nil {
|
|
deps.inspectChanges = defaults.inspectChanges
|
|
}
|
|
if deps.commitChanges == nil {
|
|
deps.commitChanges = defaults.commitChanges
|
|
}
|
|
if deps.runTUI == nil {
|
|
deps.runTUI = defaults.runTUI
|
|
}
|
|
if deps.runEditor == nil {
|
|
deps.runEditor = defaults.runEditor
|
|
}
|
|
if deps.checkUpdate == nil {
|
|
deps.checkUpdate = defaults.checkUpdate
|
|
}
|
|
if deps.now == nil {
|
|
deps.now = defaults.now
|
|
}
|
|
return deps
|
|
}
|
|
|
|
func runInteractiveTUI(stderr io.Writer, deps appDeps, permissionMode agent.PermissionMode, addDirs []string, theme string) int {
|
|
return runInteractiveTUIWithSetup(stderr, deps, permissionMode, addDirs, theme, false)
|
|
}
|
|
|
|
func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode agent.PermissionMode, addDirs []string, theme string, forceSetup bool) int {
|
|
workspaceRoot, err := deps.getwd()
|
|
if err != nil {
|
|
return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), 1)
|
|
}
|
|
|
|
resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{})
|
|
if err != nil {
|
|
return writeAppError(stderr, err.Error(), 1)
|
|
}
|
|
userConfigPath, err := deps.userConfigPath()
|
|
if err != nil {
|
|
return writeAppError(stderr, "failed to resolve user config path: "+err.Error(), 1)
|
|
}
|
|
// Fail-soft, one-time: move any inline plaintext API keys in config.json into
|
|
// the encrypted credential store (interactive runs only; headless exec keeps
|
|
// its existing behavior). Non-fatal — a missing keyring or write error leaves
|
|
// the inline key in place and this run still uses the already-resolved key.
|
|
if store, storeErr := config.ProviderKeyStoreAt(filepath.Dir(userConfigPath)); storeErr == nil {
|
|
_, _ = config.MigratePlaintextProviderKeys(userConfigPath, store)
|
|
}
|
|
doctorUserConfigPath := ""
|
|
projectConfigPath := ""
|
|
if resolveOptions, optErr := config.DefaultResolveOptions(workspaceRoot); optErr == nil {
|
|
doctorUserConfigPath = resolveOptions.UserConfigPath
|
|
projectConfigPath = resolveOptions.ProjectConfigPath
|
|
}
|
|
|
|
needsSetup := setupRequired(resolved)
|
|
if needsSetup && !forceSetup {
|
|
// The active provider lacks a usable credential, but if another saved
|
|
// provider already has one, fall back to it instead of forcing onboarding
|
|
// again. Saved logins persist across launches; switch the active provider
|
|
// any time with `zero provider use <name>`. Onboarding only runs when no
|
|
// configured provider is usable (a genuinely fresh setup).
|
|
if usable, ok := firstUsableProvider(resolved.Providers); ok {
|
|
resolved.Provider = usable
|
|
resolved.ActiveProvider = usable.Name
|
|
needsSetup = false
|
|
}
|
|
}
|
|
setupVisible := forceSetup || needsSetup
|
|
configPath := ""
|
|
if setupVisible {
|
|
configPath, err = deps.userConfigPath()
|
|
if err != nil {
|
|
return writeAppError(stderr, err.Error(), 1)
|
|
}
|
|
}
|
|
|
|
scope, err := sandbox.NewScope(workspaceRoot, append(append([]string{}, resolved.Sandbox.AdditionalWriteRoots...), addDirs...))
|
|
if err != nil {
|
|
return writeAppError(stderr, err.Error(), 1)
|
|
}
|
|
|
|
provider, err := buildProvider(resolved, deps)
|
|
if err != nil {
|
|
return writeAppError(stderr, err.Error(), 1)
|
|
}
|
|
|
|
registry := newCoreRegistryScoped(workspaceRoot, scope)
|
|
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
|
|
specialistRuntime, err := registerSpecialistTools(registry, workspaceRoot, resolved.Swarm.MaxTeamSize)
|
|
if err != nil {
|
|
return writeAppError(stderr, "failed to initialize specialist tools: "+err.Error(), 1)
|
|
}
|
|
defer closeSpecialistRuntime(stderr, specialistRuntime)
|
|
mcpConfig, err := deps.resolveMCPConfig(workspaceRoot)
|
|
if err != nil {
|
|
return writeAppError(stderr, err.Error(), 1)
|
|
}
|
|
mcpPermissionStore, err := deps.newMCPStore()
|
|
if err != nil {
|
|
return writeAppError(stderr, "failed to initialize MCP permissions: "+err.Error(), 1)
|
|
}
|
|
mcpTokenStore, err := deps.newMCPTokenStore()
|
|
if err != nil {
|
|
_, _ = fmt.Fprintf(stderr, "[zero] warning: failed to initialize MCP OAuth tokens: %s\n", err)
|
|
mcpTokenStore = nil
|
|
err = nil
|
|
}
|
|
mcpRuntime := mcpToolRuntime(noopMCPRuntime{})
|
|
if len(mcpConfig.Servers) > 0 {
|
|
mcpRuntime, err = deps.registerMCPTools(context.Background(), registry, mcpConfig, mcp.RegisterOptions{
|
|
PermissionStore: mcpPermissionStore,
|
|
Autonomy: mcp.AutonomyLow,
|
|
})
|
|
}
|
|
if err != nil {
|
|
closeMCPRuntime(stderr, mcpRuntime)
|
|
return writeAppError(stderr, err.Error(), 1)
|
|
}
|
|
defer closeMCPRuntime(stderr, mcpRuntime)
|
|
// A server that could not be reached or validated is skipped, not fatal (one
|
|
// bad MCP server must not abort startup) — surface each so a missing tool set is
|
|
// explained rather than silently absent.
|
|
for _, skipped := range mcpRuntime.Skipped() {
|
|
fmt.Fprintf(stderr, "warning: MCP server %s unavailable, skipped: %s\n", skipped.Name, redaction.ErrorMessage(skipped.Err, redaction.Options{}))
|
|
}
|
|
// Make local plugins live: register their declared tools into the registry and
|
|
// collect their hooks + skill roots for the dispatcher and skill tool below.
|
|
// Done after specialist + MCP registration so plugin tools are part of the
|
|
// deferral count, and it fails OPEN — a malformed plugin is warned and skipped.
|
|
pluginActivation := activatePlugins(workspaceRoot, registry, deps, stderr)
|
|
// Ask (not Auto) is the interactive default: in Auto, ToolAdvertised exposes
|
|
// only PermissionAllow tools, so prompt-gated tools (write_file/edit_file/bash/
|
|
// apply_patch) would never be offered to the model — the TUI could neither edit
|
|
// files nor run shell. Ask advertises them and routes each through the existing
|
|
// OnPermissionRequest flow; shift+tab lets the user switch modes live. An
|
|
// explicit --skip-permissions-unsafe launch overrides this to unsafe (the only
|
|
// way to reach unsafe, since shift+tab deliberately cycles auto↔ask only).
|
|
//
|
|
// Resolve the effective mode BEFORE the deferral gate below so the registration
|
|
// count uses the SAME permission mode the agent loop's partition will use; an
|
|
// empty mode here would mis-gate prompt-advertised deferred tools.
|
|
if permissionMode == "" {
|
|
permissionMode = agent.PermissionModeAsk
|
|
}
|
|
// Activate deferred MCP-tool loading for the interactive run only when the
|
|
// VISIBLE deferred-eligible count meets the resolved threshold, matching exec.
|
|
// The registry is complete (core + specialist + MCP + plugins) here, so the
|
|
// count is accurate; below threshold this is a no-op and the surface is
|
|
// unchanged. The interactive surface applies no operator tool filters, so
|
|
// enabled/disabled are nil — matching the AgentOptions below.
|
|
registerToolSearchIfEligible(registry, resolved.Tools.DeferThreshold, permissionMode, nil, nil)
|
|
sandboxStore, err := deps.newSandboxStore()
|
|
if err != nil {
|
|
return writeAppError(stderr, "failed to initialize sandbox grants: "+err.Error(), 1)
|
|
}
|
|
sandboxBackend := deps.selectSandboxBackend(sandbox.BackendOptions{})
|
|
sandboxEngine := sandbox.NewEngine(sandbox.EngineOptions{
|
|
WorkspaceRoot: workspaceRoot,
|
|
Policy: applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), resolved.Sandbox),
|
|
Store: sandboxStore,
|
|
Backend: sandboxBackend,
|
|
Scope: scope,
|
|
})
|
|
lastKnownMCPConfig := mcpConfig
|
|
return deps.runTUI(context.Background(), tui.Options{
|
|
Cwd: workspaceRoot,
|
|
Theme: theme,
|
|
UserConfigPath: userConfigPath,
|
|
DoctorUserConfigPath: doctorUserConfigPath,
|
|
ProjectConfigPath: projectConfigPath,
|
|
ProviderName: resolved.Provider.Name,
|
|
ModelName: resolved.Provider.Model,
|
|
ProviderProfile: resolved.Provider,
|
|
SavedProviders: usableSavedProviders(resolved.Providers),
|
|
FavoriteModels: resolved.Preferences.FavoriteModels,
|
|
RecapsEnabled: resolved.Preferences.RecapsEnabled(),
|
|
Provider: provider,
|
|
NewProvider: deps.newProvider,
|
|
ProbeProviderHealth: deps.probeProviderHealth,
|
|
UserAgent: userAgent(),
|
|
Registry: registry,
|
|
SessionStore: deps.newSessionStore(),
|
|
SandboxStore: sandboxStore,
|
|
MCPConfig: mcpConfig,
|
|
MCPPermissionStore: mcpPermissionStore,
|
|
MCPTokenStore: mcpTokenStore,
|
|
MCPCommand: func(ctx context.Context, args []string) tui.MCPCommandResult {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
var stdout, stderr bytes.Buffer
|
|
exitCode := runMCPWithContext(ctx, args, &stdout, &stderr, deps)
|
|
nextConfig := lastKnownMCPConfig
|
|
if refreshed, err := deps.resolveMCPConfig(workspaceRoot); err == nil {
|
|
lastKnownMCPConfig = refreshed
|
|
nextConfig = refreshed
|
|
}
|
|
return tui.MCPCommandResult{
|
|
Config: nextConfig,
|
|
Output: strings.TrimSpace(stdout.String()),
|
|
Error: strings.TrimSpace(stderr.String()),
|
|
ExitCode: exitCode,
|
|
}
|
|
},
|
|
SandboxSetupCommand: tuiSandboxSetupCommand(sandboxBackend, deps),
|
|
AgentOptions: agent.Options{
|
|
MaxTurns: resolved.MaxTurns,
|
|
Registry: registry,
|
|
PermissionMode: permissionMode,
|
|
Autonomy: "low",
|
|
Sandbox: sandboxEngine,
|
|
FileTracker: tools.NewFileTracker(),
|
|
Hooks: newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks),
|
|
DeferThreshold: resolved.Tools.DeferThreshold,
|
|
Specialists: specialistRuntime.specialists,
|
|
},
|
|
PermissionMode: permissionMode,
|
|
Notify: resolved.Notify,
|
|
Setup: tui.SetupOptions{
|
|
Visible: setupVisible,
|
|
Required: needsSetup,
|
|
ConfigPath: configPath,
|
|
Providers: setupProviderOptions(),
|
|
Save: func(selection tui.SetupSelection) (tui.SetupResult, error) {
|
|
return saveSetupProvider(deps, selection, setupSaveOptions{})
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func tuiSandboxSetupCommand(backend sandbox.Backend, deps appDeps) func(context.Context) tui.SandboxSetupCommandResult {
|
|
if backend.Platform != "windows" || backend.Name != sandbox.BackendWindowsRestrictedToken || !backend.Available || backend.Executable == "" {
|
|
return nil
|
|
}
|
|
return func(context.Context) tui.SandboxSetupCommandResult {
|
|
var stdout, stderr bytes.Buffer
|
|
exitCode := runSandboxSetup(nil, &stdout, &stderr, deps)
|
|
return tui.SandboxSetupCommandResult{
|
|
Output: strings.TrimSpace(stdout.String()),
|
|
Error: strings.TrimSpace(stderr.String()),
|
|
ExitCode: exitCode,
|
|
}
|
|
}
|
|
}
|
|
|
|
func buildProvider(resolved config.ResolvedConfig, deps appDeps) (zeroruntime.Provider, error) {
|
|
if !config.HasProviderProfile(resolved.Provider) {
|
|
return nil, nil
|
|
}
|
|
// Load the API key from the encrypted credential store when it isn't already
|
|
// resolved from inline config or an env var. This keeps the resolver pure (no
|
|
// I/O) and is a no-op for env/inline-key providers and when nothing is stored.
|
|
profile := resolved.Provider
|
|
if path, perr := deps.userConfigPath(); perr == nil {
|
|
if store, err := config.ProviderKeyStoreAt(filepath.Dir(path)); err == nil {
|
|
profile = config.ApplyStoredAPIKey(profile, store)
|
|
}
|
|
}
|
|
return deps.newProvider(profile)
|
|
}
|
|
|
|
func newCoreRegistry(workspaceRoot string) *tools.Registry {
|
|
return newCoreRegistryScoped(workspaceRoot, nil)
|
|
}
|
|
|
|
func newCoreRegistryScoped(workspaceRoot string, scope tools.PathScope) *tools.Registry {
|
|
registry := tools.NewRegistry()
|
|
for _, tool := range tools.CoreToolsScoped(workspaceRoot, scope) {
|
|
registry.Register(tool)
|
|
}
|
|
registerLocalControlTools(registry, workspaceRoot, config.LocalControlConfig{})
|
|
return registry
|
|
}
|
|
|
|
func registerLocalControlTools(registry *tools.Registry, workspaceRoot string, cfg config.LocalControlConfig) {
|
|
if registry == nil {
|
|
return
|
|
}
|
|
browserOptions := localBrowserOptionsFromConfig(cfg)
|
|
desktopOptions := localDesktopOptionsFromConfig(cfg)
|
|
terminalOptions := localTerminalOptionsFromConfig(cfg)
|
|
for _, tool := range tools.NewLocalBrowserTools(browserOptions) {
|
|
registry.Register(tool)
|
|
}
|
|
for _, tool := range tools.NewLocalDesktopTools(desktopOptions) {
|
|
registry.Register(tool)
|
|
}
|
|
for _, tool := range tools.NewLocalTerminalTools(terminalOptions) {
|
|
registry.Register(tool)
|
|
}
|
|
for _, tool := range tools.NewLocalControlArtifactTools(tools.LocalControlArtifactOptions{
|
|
Browser: browserOptions,
|
|
Desktop: desktopOptions,
|
|
Terminal: terminalOptions,
|
|
ArtifactsDir: localArtifactsDirFromConfig(workspaceRoot, cfg),
|
|
}) {
|
|
registry.Register(tool)
|
|
}
|
|
}
|
|
|
|
func localBrowserOptionsFromConfig(cfg config.LocalControlConfig) localcontrol.BrowserOptions {
|
|
return localcontrol.BrowserOptions{
|
|
Enabled: cfg.BrowserEnabled(),
|
|
Driver: cfg.Browser.Driver,
|
|
HelperPath: cfg.Browser.HelperPath,
|
|
}
|
|
}
|
|
|
|
func localDesktopOptionsFromConfig(cfg config.LocalControlConfig) localcontrol.DesktopOptions {
|
|
return localcontrol.DesktopOptions{
|
|
Enabled: cfg.DesktopEnabled(),
|
|
Driver: cfg.Desktop.Driver,
|
|
HelperPath: cfg.Desktop.HelperPath,
|
|
}
|
|
}
|
|
|
|
func localTerminalOptionsFromConfig(cfg config.LocalControlConfig) localcontrol.TerminalOptions {
|
|
return localcontrol.TerminalOptions{
|
|
Enabled: cfg.TerminalEnabled(),
|
|
Driver: cfg.Terminal.Driver,
|
|
HelperPath: cfg.Terminal.HelperPath,
|
|
}
|
|
}
|
|
|
|
func localArtifactsDirFromConfig(workspaceRoot string, cfg config.LocalControlConfig) string {
|
|
dir := strings.TrimSpace(cfg.ArtifactsDir)
|
|
if dir == "" {
|
|
dir = filepath.Join(".zero", "artifacts")
|
|
}
|
|
if filepath.IsAbs(dir) {
|
|
return dir
|
|
}
|
|
return filepath.Join(workspaceRoot, dir)
|
|
}
|
|
|
|
// agentToolRuntime bundles the specialist runtime with the swarm it backs so
|
|
// their lifetimes (and shutdown) stay paired: registerSpecialistTools brings up
|
|
// both, closeSpecialistRuntime tears down both.
|
|
type agentToolRuntime struct {
|
|
specialist *specialist.Runtime
|
|
swarm *swarm.Swarm
|
|
// specialists summarizes the registered specialists (name + description) for
|
|
// the orchestrator's system-prompt delegation section. Populated alongside the
|
|
// tools, so it is present exactly when the Task tool is.
|
|
specialists []agent.SpecialistInfo
|
|
}
|
|
|
|
// specialistInfos returns the runtime's specialist summaries, nil-safe so the
|
|
// exec path can call it whether or not specialist tools were registered.
|
|
func (r *agentToolRuntime) specialistInfos() []agent.SpecialistInfo {
|
|
if r == nil {
|
|
return nil
|
|
}
|
|
return r.specialists
|
|
}
|
|
|
|
func registerSpecialistTools(registry *tools.Registry, workspaceRoot string, maxTeamSize int) (*agentToolRuntime, error) {
|
|
paths, err := specialist.DefaultPaths(workspaceRoot)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
executor := specialist.Executor{Paths: paths}
|
|
runtime, err := specialist.RegisterTools(registry, executor)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// The swarm reuses the same specialist executor to launch each member, so
|
|
// every member runs under the orchestrator's sandbox + policy. Mailbox state
|
|
// lives under the workspace so its files fall within the sandbox write rules.
|
|
// MaxTeamSize (0 => the swarm's default of 8) caps concurrent members per team.
|
|
sw, err := swarm.New(swarm.Options{
|
|
BaseDir: filepath.Join(workspaceRoot, ".zero", "swarm"),
|
|
Launcher: swarm.NewSpecialistLauncher(executor),
|
|
MaxTeamSize: maxTeamSize,
|
|
})
|
|
if err != nil {
|
|
runtime.Close()
|
|
return nil, err
|
|
}
|
|
swarm.RegisterTools(registry, sw)
|
|
return &agentToolRuntime{specialist: runtime, swarm: sw, specialists: specialistSummaries(paths)}, nil
|
|
}
|
|
|
|
// specialistSummaries loads the available specialists (built-ins + user/project
|
|
// profiles) and returns their name + description for the orchestrator's
|
|
// delegation prompt. A load error yields no summaries (the prompt simply omits
|
|
// the delegation section) rather than failing the run.
|
|
func specialistSummaries(paths specialist.Paths) []agent.SpecialistInfo {
|
|
result, err := specialist.Load(specialist.LoadOptions{Paths: paths})
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
summaries := make([]agent.SpecialistInfo, 0, len(result.Specialists))
|
|
for _, manifest := range result.Specialists {
|
|
name := strings.TrimSpace(manifest.Metadata.Name)
|
|
if name == "" {
|
|
continue
|
|
}
|
|
summaries = append(summaries, agent.SpecialistInfo{
|
|
Name: name,
|
|
WhenToUse: strings.TrimSpace(manifest.Metadata.Description),
|
|
})
|
|
}
|
|
return summaries
|
|
}
|
|
|
|
func shouldRegisterExecSpecialistTools(options execOptions) bool {
|
|
if options.useSpec {
|
|
return false
|
|
}
|
|
if strings.EqualFold(strings.TrimSpace(options.tag), specialist.SessionTagSpecialist) {
|
|
return false
|
|
}
|
|
// Register at medium/high (and unsafe). The Task tool's permission gate
|
|
// auto-approves only read-only specialist spawns and prompts for write-capable
|
|
// ones, so a non-trivial exec can auto-delegate exploration safely (a headless
|
|
// write spawn still gets denied at the prompt). Default "low" stays clean — no
|
|
// specialist tooling or swarm runtime for trivial/CI one-shots.
|
|
autonomy := strings.ToLower(strings.TrimSpace(options.autonomy))
|
|
return options.skipPermissionsUnsafe || autonomy == "high" || autonomy == "medium"
|
|
}
|
|
|
|
func closeMCPRuntime(stderr io.Writer, runtime mcpToolRuntime) {
|
|
if runtime == nil {
|
|
return
|
|
}
|
|
if err := runtime.Close(); err != nil {
|
|
_, _ = fmt.Fprintf(stderr, "[zero] mcp_close_error: %s\n", err)
|
|
}
|
|
}
|
|
|
|
func closeSpecialistRuntime(stderr io.Writer, runtime *agentToolRuntime) {
|
|
if runtime == nil {
|
|
return
|
|
}
|
|
if runtime.swarm != nil {
|
|
runtime.swarm.Close()
|
|
}
|
|
if runtime.specialist != nil {
|
|
if err := runtime.specialist.Close(); err != nil {
|
|
_, _ = fmt.Fprintf(stderr, "[zero] specialist_cleanup_error: %s\n", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func writeAppError(stderr io.Writer, message string, exitCode int) int {
|
|
if _, err := fmt.Fprintf(stderr, "[zero] %s\n", message); err != nil {
|
|
return 1
|
|
}
|
|
return exitCode
|
|
}
|
|
|
|
func writeUsageError(stderr io.Writer, message string) int {
|
|
return writeExecUsageError(stderr, message)
|
|
}
|
|
|
|
func writeHelp(w io.Writer) error {
|
|
_, err := fmt.Fprint(w, `ZERO terminal coding agent
|
|
|
|
Usage:
|
|
zero [command]
|
|
|
|
Commands:
|
|
exec Run a one-shot prompt through the Go agent runtime
|
|
daemon Manage the local background worker daemon (start/stop/status/run/attach)
|
|
setup Guide first-run provider setup
|
|
config Inspect resolved Go configuration without leaking secrets
|
|
models List Zero model registry entries
|
|
providers Inspect resolved provider profiles
|
|
doctor Run backend health checks for config and provider setup
|
|
context Report workspace context budget usage
|
|
repo-map Build a deterministic repository map for agent context
|
|
search Search persisted local Zero session events
|
|
find Alias for search
|
|
sessions Inspect local Zero session lineage
|
|
spec Review and approve saved spec-mode drafts
|
|
specialist Manage local Zero specialist profiles
|
|
plugins Inspect, install, and remove local Zero plugins
|
|
backends Inspect MCP, hook, and plugin backend lifecycle state
|
|
skills Inspect, install, and remove local Zero skills
|
|
tools Scaffold and list local Zero plugin-tools
|
|
hooks Inspect Zero hook configuration
|
|
mcp Manage MCP backend settings
|
|
auth Log in to model providers via OAuth
|
|
sandbox Inspect sandbox policy and persistent grants
|
|
update Check for Zero CLI updates
|
|
worktrees Prepare isolated git worktrees
|
|
verify Detect and run local verification checks
|
|
eval Validate offline agent eval suites
|
|
changes Inspect and commit local git changes
|
|
usage Summarize token usage and estimated cost
|
|
cron Schedule agent jobs (foreground, file-backed)
|
|
repo-info Characterize the current repository (local git only)
|
|
serve Run Zero protocol servers
|
|
acp Serve the Agent Client Protocol over stdio (editor backend)
|
|
help Show this help
|
|
version Print version
|
|
|
|
Flags:
|
|
-h, --help Show this help
|
|
-v, --version Print version
|
|
-p, --prompt Run a one-shot prompt
|
|
--add-dir <path> Allow writes in an extra directory (repeatable)
|
|
--skip-permissions-unsafe Launch the interactive shell in unsafe mode (enables the ! shell escape)
|
|
`)
|
|
return err
|
|
}
|
|
|
|
// addDirFlagArgs rebuilds "--add-dir <dir>" flag pairs so dirs collected by
|
|
// splitLeadingAddDirFlags can be forwarded into exec's own argument parser
|
|
// (which accepts --add-dir anywhere) instead of being silently dropped.
|
|
func addDirFlagArgs(addDirs []string) []string {
|
|
flags := make([]string, 0, 2*len(addDirs))
|
|
for _, dir := range addDirs {
|
|
flags = append(flags, "--add-dir", dir)
|
|
}
|
|
return flags
|
|
}
|
|
|
|
// splitLeadingAddDirFlags strips leading --add-dir flags from the root
|
|
// argument list (zero --add-dir <path> [--add-dir <path>] [subcommand …]).
|
|
// Subcommands like exec parse their own --add-dir occurrences.
|
|
func splitLeadingAddDirFlags(args []string) ([]string, []string, error) {
|
|
addDirs := []string{}
|
|
for len(args) > 0 {
|
|
switch {
|
|
case args[0] == "--add-dir":
|
|
if len(args) < 2 {
|
|
return nil, nil, errors.New("--add-dir requires a directory path")
|
|
}
|
|
value := strings.TrimSpace(args[1])
|
|
if value == "" {
|
|
return nil, nil, errors.New("--add-dir requires a directory path")
|
|
}
|
|
if strings.HasPrefix(value, "-") {
|
|
return nil, nil, errors.New("--add-dir requires a directory path")
|
|
}
|
|
addDirs = append(addDirs, value)
|
|
args = args[2:]
|
|
case strings.HasPrefix(args[0], "--add-dir="):
|
|
value := strings.TrimSpace(strings.TrimPrefix(args[0], "--add-dir="))
|
|
if value == "" {
|
|
return nil, nil, errors.New("--add-dir requires a directory path")
|
|
}
|
|
// Match the space form: a flag-like value is almost certainly a
|
|
// mistyped option, not a directory. A directory literally named
|
|
// "-foo" is still reachable as --add-dir=./-foo.
|
|
if strings.HasPrefix(value, "-") {
|
|
return nil, nil, errors.New("--add-dir requires a directory path")
|
|
}
|
|
addDirs = append(addDirs, value)
|
|
args = args[1:]
|
|
default:
|
|
return addDirs, args, nil
|
|
}
|
|
}
|
|
return addDirs, args, nil
|
|
}
|
|
|
|
// splitLeadingThemeFlag strips a leading --theme {auto|dark|light} (space or =form)
|
|
// from the root argument list and validates it. The last occurrence wins. A value
|
|
// outside the allowed set is a loud error rather than a silent fallback.
|
|
func splitLeadingThemeFlag(args []string) (string, []string, error) {
|
|
theme := ""
|
|
for len(args) > 0 {
|
|
var value string
|
|
switch {
|
|
case args[0] == "--theme":
|
|
if len(args) < 2 {
|
|
return "", nil, errors.New("--theme requires a value (auto, dark, or light)")
|
|
}
|
|
value = strings.TrimSpace(args[1])
|
|
args = args[2:]
|
|
case strings.HasPrefix(args[0], "--theme="):
|
|
value = strings.TrimSpace(strings.TrimPrefix(args[0], "--theme="))
|
|
args = args[1:]
|
|
default:
|
|
return theme, args, nil
|
|
}
|
|
switch strings.ToLower(value) {
|
|
case "auto", "dark", "light":
|
|
theme = strings.ToLower(value)
|
|
default:
|
|
return "", nil, fmt.Errorf("--theme must be auto, dark, or light (got %q)", value)
|
|
}
|
|
}
|
|
return theme, args, nil
|
|
}
|
|
|
|
// profileHasCredential reports whether the profile can authenticate: a direct API
|
|
// key, an auth-header value, or an APIKeyEnv whose environment variable is set. The
|
|
// setup result is not env-resolved, so checking APIKey alone would wrongly flag every
|
|
// env-var-based provider as keyless.
|
|
func profileHasCredential(profile config.ProviderProfile) bool {
|
|
if strings.TrimSpace(profile.APIKey) != "" || profile.APIKeyStored || strings.TrimSpace(profile.AuthHeaderValue) != "" {
|
|
return true
|
|
}
|
|
if env := strings.TrimSpace(profile.APIKeyEnv); env != "" {
|
|
return strings.TrimSpace(os.Getenv(env)) != ""
|
|
}
|
|
return false
|
|
}
|
|
|
|
// baseURLIsLoopback reports whether a provider base_url points at a loopback host
|
|
// (a keyless local provider like Ollama/LM Studio), which needs no API key.
|
|
func baseURLIsLoopback(baseURL string) bool {
|
|
u := strings.TrimSpace(baseURL)
|
|
if u == "" {
|
|
return false
|
|
}
|
|
parsed, err := url.Parse(u)
|
|
if err != nil || parsed.Host == "" {
|
|
return false
|
|
}
|
|
host := strings.TrimSuffix(strings.ToLower(parsed.Hostname()), ".")
|
|
if host == "localhost" || strings.HasSuffix(host, ".localhost") {
|
|
return true
|
|
}
|
|
if addr, err := netip.ParseAddr(host); err == nil {
|
|
return addr.IsLoopback()
|
|
}
|
|
return false
|
|
}
|
|
|
|
func writePromptRequired(stderr io.Writer) int {
|
|
if _, err := fmt.Fprintln(stderr, "[zero] Prompt required. Use `zero exec \"prompt\"` or `zero exec --file prompt.txt`."); err != nil {
|
|
return 1
|
|
}
|
|
return 2
|
|
}
|
|
|
|
func writeExecHelp(w io.Writer) error {
|
|
_, err := fmt.Fprint(w, `Usage:
|
|
zero exec [flags] [prompt]
|
|
|
|
Runs a one-shot prompt through the Go agent runtime.
|
|
|
|
Flags:
|
|
-f, --file <path> Read prompt text from a file
|
|
--image <path> Attach a local image (repeatable; vision models only)
|
|
--add-dir <path> Allow writes in an extra directory (repeatable)
|
|
--mode <name> Apply a preset (smart, deep, fast, large, precise); explicit flags override it
|
|
-m, --model <model> Select the model for provider setup
|
|
--use-spec Draft a spec first and stop for review
|
|
--spec-model <model> Override the draft model when --use-spec is set
|
|
--spec-reasoning-effort <effort>
|
|
Override draft reasoning effort when --use-spec is set
|
|
--max-turns <number> Override the maximum agent loop turns
|
|
--auto <low|medium|high> Set exec autonomy; high enables unsafe tools
|
|
--enabled-tools <tools> Only expose these comma or space separated tools
|
|
--disabled-tools <tools> Hide these comma or space separated tools
|
|
--list-tools List model-visible tools and exit
|
|
--profile <profile> Accept legacy model profile selection
|
|
-r, --reasoning-effort <effort> Accept legacy reasoning effort selection
|
|
-C, --cwd <path> Set the workspace directory
|
|
-w, --worktree [name] Run from an isolated git worktree
|
|
--worktree-dir <path> Base directory for created worktrees
|
|
-i, --input-format text|stream-json
|
|
Select prompt input format
|
|
-o, --output-format text|json|stream-json
|
|
Select text, JSON, or schema-versioned JSONL output
|
|
("debug" is accepted as a stream-json alias)
|
|
--prompt <prompt> Provide prompt text as a flag
|
|
--resume [id] Resume a session; omit id to use the latest
|
|
--fork <id> Fork an existing session into a new session
|
|
--calling-session-id <id> Parent session id for specialist child runs
|
|
--calling-tool-use-id <id> Parent tool-call id for specialist child runs
|
|
--tag <tag> Attach runtime tag metadata to the exec run
|
|
--depth <number> Set specialist nesting depth metadata
|
|
--session-title <text> Set the created session title
|
|
--init-session-id <id> Create a new exec session with this id
|
|
--skip-permissions-unsafe Allow prompt-gated tools without approval
|
|
--allow-escalation Let the agent escalate to a stronger model mid-run via escalate_model
|
|
--self-correct Run the post-edit verify-and-correct loop (auto-fix needs --auto medium or high)
|
|
--notify <off|bell|notify|both>
|
|
Override notification mode for this run
|
|
--no-notify Disable notifications for this run
|
|
`)
|
|
return err
|
|
}
|