first commit

This commit is contained in:
2026-08-14 23:41:57 +08:00
commit 086803a8dd
471 changed files with 91938 additions and 0 deletions
+548
View File
@@ -0,0 +1,548 @@
// Command pigo is the CLI entry point for the pigo agent. It parses flags,
// overlays config.toml, and dispatches to one of the run modes — interactive
// REPL, headless print, session listing, or the internal sub-agent RPC server:
//
// pigo # interactive REPL (on a TTY)
// pigo -p "read README and summarize" # print mode: final text
// pigo -p "..." --output-format stream-json # line-delimited JSON events
// pigo install <pkg> | list | uninstall | update # package management
//
// The provider is resolved from --model against the built-in OpenAI-compatible
// gateways (OpenRouter by default, Ollama for local models), with the API key
// taken from the environment. The process exit code reflects success (0) or
// failure (1), so the command composes cleanly in pipelines. All run-assembly,
// REPL, headless, and config logic lives under internal/cli/*; this file keeps
// only flag parsing (cliOptions), config overlay (applyFileConfig), and the
// dispatch seam that wires those subpackages together.
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
flag "github.com/spf13/pflag"
"github.com/smallnest/pigo/internal/cli"
"github.com/smallnest/pigo/internal/cli/config"
"github.com/smallnest/pigo/internal/cli/headless"
"github.com/smallnest/pigo/internal/cli/pkgcmd"
"github.com/smallnest/pigo/internal/cli/repl"
"github.com/smallnest/pigo/internal/cli/run"
"github.com/smallnest/pigo/internal/cli/tui"
"github.com/smallnest/pigo/internal/cli/ui"
"github.com/smallnest/pigo/internal/dream"
"github.com/smallnest/pigo/internal/selfupdate"
)
// Build metadata, injected at release time via -ldflags by goreleaser
// (see .goreleaser.yaml). They keep their default values for `go build`/
// `go run` from source, so `pigo --version` still works without a release build.
var (
version = "dev"
commit = "none"
date = "unknown"
)
// cliOptions is the parsed command line, produced by main() and consumed by
// dispatch. Separating parse from dispatch makes the dispatch logic testable
// without touching the global flag set.
type cliOptions struct {
prompt string
model string
baseURL string
apiKey string
protocol string
// provider, when non-empty, selects a built-in provider by name from the
// registry (mirrors pi's provider selection): provider.ResolveProvider then builds the
// matching wire driver using the provider's default base URL, protocol, and
// API-key env var, ignoring the model-id heuristics.
provider string
outputFmt string
noTools bool
listSessions bool
resumeID string
continueLast bool
// approve grants the launch directory session-level trust up front (mirrors pi's
// --approve/-a): the first-launch trust prompt is skipped and side-effect
// tools (bash/write/edit) run without per-call confirmation for this run.
approve bool
// noSkills disables skill discovery (mirrors pi's --no-skills): skills under
// ~/.agents/skills are not loaded as /skill-name commands.
noSkills bool
// systemPrompt, when non-empty, replaces the default coding-assistant base
// instruction (mirrors pi's --system-prompt). The environment block and
// AGENTS.md injection still apply on top of it.
systemPrompt string
// appendSystemPrompt holds --append-system-prompt values (mirrors pi, repeatable):
// each is a path to a file whose contents are appended, or literal text when
// it is not an existing file. Appended after the base prompt and AGENTS.md.
appendSystemPrompt []string
// configPrompts holds prompt-template paths from the config.toml `prompts`
// array (settings tier); each is a file or directory loaded non-recursively.
// Populated by applyFileConfig; empty when the config omits `prompts`.
configPrompts []string
// promptTemplates holds --prompt-template paths (CLI tier, repeatable); each
// is a file or directory loaded non-recursively.
promptTemplates []string
// noPromptTemplates disables all prompt-template discovery (global, project,
// settings, CLI); built-in slash commands are unaffected. Independent of
// --no-skills.
noPromptTemplates bool
// subagentRPC selects the process-isolated sub-agent server mode (US-019,
// #135): pigo reads JSON-RPC sub-agent run requests from stdin and writes
// results to stdout. Internal, used by SubAgentTool's process mode.
subagentRPC bool
// dream, when set, runs the process-isolated memory-consolidation pass and
// exits: pigo enumerates + consolidates the global/project memory scope, emits
// a single-line Report JSON on stdout, and exits 0/1. Internal, spawned by the
// dream scheduler (and usable headlessly by scripts). See internal/dream and
// SPEC §4.1/§4.2.
dream bool
// dreamDryRun pairs with --dream: analyze and report without writing files or
// updating dream state (the lock is still taken). SPEC §5.5 dry-run row.
dreamDryRun bool
// thinkingLevel, when non-empty, is the --thinking-level flag: the reasoning
// effort for requests (off|minimal|low|medium|high|xhigh|max). It is the highest-
// precedence layer in resolveThinkingLevel, overriding PIGO_THINKING_LEVEL, the
// config files, and the built-in default (medium).
thinkingLevel string
// showVersion prints build metadata (version/commit/date, injected at release
// time by goreleaser) and exits, without running the agent.
showVersion bool
// noTUI forces the line-based REPL instead of the full-screen TUI (US-001).
// When set — or when stdout is not a TTY — the no-prompt path falls back to
// repl.Run rather than launching tui.Run.
noTUI bool
// cwd, when non-empty, is the working directory pigo switches to before doing
// anything else (matches the Claude Agent SDK's cwd option / git -C). Every
// cwd-derived resolution — built-in tool file roots, project trust, hooks
// project dir, .pigo/ project config, git info, the status-bar path — reads
// os.Getwd(), so a single os.Chdir here makes all of them operate in the
// given directory. This is what makes pigo usable as an SDK backend that can
// be pointed at an arbitrary project root.
cwd string
// memory holds the resolved [memory]/[checkpoint]/[compaction] config tables
// (defaults applied, string forms parsed). These have no CLI flags — the
// config file is their only source — so applyFileConfig always populates this
// (defaults when the tables are absent) for downstream memory/checkpoint/
// compaction wiring to consume. See config.MemorySettings.
memory config.MemorySettings
// dreamCfg is the resolved [dream] configuration (enabled / interval /
// recent-sessions), populated by applyFileConfig from the [dream] table with
// defaults applied. The interactive REPL consumes it to decide the startup
// background auto-consolidation (US-008). Like memory it has no CLI flags.
dreamCfg dream.Config
// allowedTools and disallowedTools are the --allowed-tools/--disallowed-tools
// values: the tool-level admission boundary for the run, filling the gap
// between "all tools" and --no-tools. Each is repeatable and each value may be
// comma-separated. Names match case-insensitively, and deny wins over allow
// when a name appears on both sides (fail-closed). The boundary is enforced at
// the tool-registration layer in run.SetupEnv, strictly before the
// BeforeToolCall confirmation gate, so --approve waives confirmation prompts
// but can never widen the boundary.
allowedTools []string
disallowedTools []string
}
func main() {
// Package-management subcommands (pigo install|list|uninstall|update ...) are
// positional and distinct from the flag-driven agent modes, so peel them off
// before pflag parsing — the agent flags don't apply to them.
if len(os.Args) > 1 && pkgcmd.Subcommands[os.Args[1]] {
// `pigo update` routes by whether a positional package name follows it:
// none — or flags-only, e.g. `pigo update --check` — is binary self-update
// (#466: download the latest release and replace this binary); a package
// name stays package-update (handled by pkgcmd). This is the US-003 dispatch
// split, with updateIsSelfUpdate as the pure classifier so routing is
// unit-testable (TestUpdateIsSelfUpdate).
if os.Args[1] == "update" && updateIsSelfUpdate(os.Args[2:]) {
os.Exit(selfupdate.Run(context.Background(), version, os.Stdout, os.Stderr))
}
os.Exit(pkgcmd.Run(os.Args[1], os.Args[2:], os.Stdout, os.Stderr))
}
var opts cliOptions
flag.StringVarP(&opts.prompt, "print", "p", "", "prompt to run in headless print mode")
flag.StringVarP(&opts.model, "model", "m", "openrouter/free", "model id to run against (a well-known model name like claude-opus-4-8 or deepseek-chat auto-selects its provider when --provider/--protocol/--base-url are unset)")
flag.StringVarP(&opts.baseURL, "base-url", "u", "", "override provider base URL (e.g. local Ollama)")
flag.StringVarP(&opts.apiKey, "api-key", "k", "", "API key for the resolved provider (overrides env/config; else <PROVIDER>_API_KEY)")
flag.StringVarP(&opts.protocol, "protocol", "P", "", "force wire protocol for a custom endpoint: openai | anthropic (default: inferred from model id)")
flag.StringVar(&opts.provider, "provider", "", "select a built-in provider by name (e.g. deepseek, minimax); uses its default base URL, protocol, and API-key env var (see --help provider list)")
flag.StringVarP(&opts.outputFmt, "output-format", "o", "text", "output format: text | stream-json")
flag.BoolVarP(&opts.noTools, "no-tools", "n", false, "disable the built-in file/shell tools")
flag.StringArrayVar(&opts.allowedTools, "allowed-tools", nil, "restrict the model to these tools (repeatable, comma-separated, case-insensitive); empty means no restriction and --disallowed-tools wins on conflict")
flag.StringArrayVar(&opts.disallowedTools, "disallowed-tools", nil, "remove these tools from the model's set (repeatable, comma-separated, case-insensitive); takes precedence over --allowed-tools")
flag.BoolVarP(&opts.listSessions, "list-sessions", "l", false, "list stored interactive sessions and exit")
flag.StringVarP(&opts.resumeID, "resume", "r", "", "resume the interactive session with this id")
flag.BoolVarP(&opts.continueLast, "continue", "c", false, "resume the most recent interactive session")
flag.BoolVarP(&opts.approve, "approve", "a", false, "trust the working directory for this run: skip the first-launch trust prompt and run side-effect tools without per-call confirmation")
flag.BoolVar(&opts.noSkills, "no-skills", false, "disable skill discovery (do not load skills under ~/.agents/skills as /skill-name commands)")
flag.BoolVar(&opts.noPromptTemplates, "no-prompt-templates", false, "disable prompt-template discovery (do not load ~/.pigo/{commands,prompts}, .pigo/prompts, config prompts, or --prompt-template); built-in slash commands are unaffected")
flag.StringVar(&opts.systemPrompt, "system-prompt", "", "system prompt to use instead of the default coding-assistant prompt (mirrors pi --system-prompt)")
flag.StringArrayVar(&opts.appendSystemPrompt, "append-system-prompt", nil, "append text or file contents to the system prompt; repeatable (mirrors pi --append-system-prompt)")
flag.StringArrayVar(&opts.promptTemplates, "prompt-template", nil, "load a prompt template from a file or directory (non-recursive); repeatable (mirrors pi --prompt-template)")
flag.StringVar(&opts.thinkingLevel, "thinking-level", "", "reasoning effort: off|minimal|low|medium|high|xhigh|max (overrides PIGO_THINKING_LEVEL and config; default medium)")
flag.BoolVar(&opts.subagentRPC, "subagent-rpc", false, "internal: run as a process-isolated sub-agent JSON-RPC server over stdio (US-019)")
flag.BoolVar(&opts.dream, "dream", false, "internal: run a memory-consolidation pass over the global/project memory scope, emit a Report JSON on stdout, and exit (SPEC §4.1)")
flag.BoolVar(&opts.dreamDryRun, "dream-dry-run", false, "internal: with --dream, analyze and report without writing files or updating dream state (SPEC §5.5)")
flag.BoolVar(&opts.noTUI, "no-tui", false, "use the line-based REPL instead of the full-screen TUI")
flag.StringVarP(&opts.cwd, "cwd", "C", "", "run as if pigo was started in this directory (matches the Claude Agent SDK's cwd; like git -C): tool file access, trust, hooks, and project config all resolve against it")
flag.BoolVarP(&opts.showVersion, "version", "v", false, "print version information and exit")
// Extend the default pflag usage with a "Supported providers" block so
// `--help` documents the values accepted by --provider (name → env var →
// default base URL → protocol). The list is derived from the provider
// registry, so it never drifts from the code.
flag.Usage = func() {
out := flag.CommandLine.Output()
fmt.Fprintf(out, "Usage of %s:\n", os.Args[0])
flag.PrintDefaults()
cli.PrintProviderHelp(out)
}
flag.Parse()
// --cwd switches the process working directory before anything cwd-derived is
// resolved (tool roots, trust, hooks, project config, git info). Doing it here
// — after parse, before config overlay and dispatch — means every downstream
// os.Getwd() sees the requested directory, so pigo behaves as if it had been
// launched there. A bad path is a usage error (exit 2) rather than a silent
// fall-through to the original directory.
if opts.cwd != "" {
if err := os.Chdir(opts.cwd); err != nil {
fmt.Fprintf(os.Stderr, "pigo: --cwd: %v\n", err)
os.Exit(2)
}
}
// Overlay ~/.config/pigo/config.toml: file values replace built-in defaults,
// but any flag the user set on the command line still wins (CLI > file >
// default). A malformed file warns but does not abort — defaults apply.
if cfg, err := config.LoadFileConfig(config.FileConfigPath()); err != nil {
fmt.Fprintf(os.Stderr, "pigo: %v\n", err)
} else {
applyFileConfig(&opts, cfg, flag.CommandLine.Changed)
}
// --version is a standalone action: print build metadata and exit.
if opts.showVersion {
fmt.Printf("pigo %s (commit %s, built %s)\n", version, commit, date)
os.Exit(0)
}
// A prompt may also be supplied as positional args.
if opts.prompt == "" {
opts.prompt = strings.TrimSpace(strings.Join(flag.Args(), " "))
}
os.Exit(dispatch(context.Background(), opts, os.Stdout, os.Stderr))
}
// applyFileConfig overlays config.toml values onto opts, but only for flags the
// user did not set on the command line (changed reports whether a flag name was
// explicitly passed). This yields the precedence: CLI flag > config file >
// default. Zero-valued config fields never override.
func applyFileConfig(opts *cliOptions, cfg config.FileConfig, changed func(string) bool) {
if cfg.Model != "" && !changed("model") {
opts.model = cfg.Model
}
if cfg.BaseURL != "" && !changed("base-url") {
opts.baseURL = cfg.BaseURL
}
if cfg.APIKey != "" && !changed("api-key") {
opts.apiKey = cfg.APIKey
}
if cfg.Protocol != "" && !changed("protocol") {
opts.protocol = cfg.Protocol
}
if cfg.Provider != "" && !changed("provider") {
opts.provider = cfg.Provider
}
if cfg.ThinkingLevel != "" && !changed("thinking-level") {
opts.thinkingLevel = cfg.ThinkingLevel
}
if cfg.OutputFormat != "" && !changed("output-format") {
opts.outputFmt = cfg.OutputFormat
}
if cfg.NoTools && !changed("no-tools") {
opts.noTools = true
}
if cfg.NoSkills && !changed("no-skills") {
opts.noSkills = true
}
if cfg.Approve && !changed("approve") {
opts.approve = true
}
if cfg.SystemPrompt != "" && !changed("system-prompt") {
opts.systemPrompt = cfg.SystemPrompt
}
// The tool boundary follows the standard precedence (CLI > file > default)
// rather than the additive treatment prompts get below. Merging would be the
// wrong semantics for a security boundary: a user passing --allowed-tools to
// widen what the file's allowed_tools narrowed must actually get the wider
// set, not the intersection. Each flag overrides its own key independently:
// --allowed-tools does not clear a file-level disallowed_tools, and because
// deny wins on conflict a file deny survives a CLI allow — re-admitting a
// file-denied tool requires overriding --disallowed-tools on the CLI.
if len(cfg.AllowedTools) > 0 && !changed("allowed-tools") {
opts.allowedTools = cfg.AllowedTools
}
if len(cfg.DisallowedTools) > 0 && !changed("disallowed-tools") {
opts.disallowedTools = cfg.DisallowedTools
}
// prompts (settings tier) are additive with --prompt-template (CLI tier,
// wired in #339), so they are always passed through when present.
if len(cfg.Prompts) > 0 {
opts.configPrompts = cfg.Prompts
}
// The [memory]/[checkpoint]/[compaction] tables have no CLI flags, so they
// are resolved (with defaults) and overlaid unconditionally — an absent set
// of tables yields the default-safe MemorySettings.
opts.memory = cfg.ResolveMemorySettings()
// The [dream] table also has no CLI flags; normalize it (defaults applied when
// the table is absent) so the interactive startup trigger has a resolved
// Config. NewConfig treats a nil enabled as true, so dream is on by default.
opts.dreamCfg = dream.NewConfig(cfg.Dream.Enabled, cfg.Dream.IntervalDays, cfg.Dream.RecentSessions)
}
// dispatch runs the resolved command and returns a process exit code, writing
// diagnostics to errOut. It is the run-assembly seam: every path (list, REPL,
// headless, subagent-rpc) is reached from here, so the CLI's behavior can be
// exercised without re-parsing flags. A returned code of 0 is success.
func dispatch(ctx context.Context, opts cliOptions, out, errOut io.Writer) int {
// --subagent-rpc is a fully separate mode: speak the sub-agent JSON-RPC
// protocol over stdio and exit. It is the subprocess end of process-isolated
// sub-agents and shares nothing with the interactive/headless paths.
if opts.subagentRPC {
return headless.RunSubAgentRPC(ctx, os.Stdin, out, errOut)
}
// --dream is the subprocess consolidation mode (SPEC §4.1/§4.2): run one
// memory-consolidation pass to completion, emit a single-line Report JSON on
// stdout (progress/logs go to stderr), and exit 0 on success / 1 on failure.
// It runs before any interactive/headless session assembly and honors -C/--cwd
// for the project scope (applied above via os.Chdir). It shares nothing with
// the REPL/headless paths.
if opts.dream {
return runDream(ctx, opts, out, errOut)
}
// --list-sessions is a standalone action: print and exit.
if opts.listSessions {
if err := headless.PrintSessions(out); err != nil {
fmt.Fprintf(errOut, "pigo: %v\n", err)
return 1
}
return 0
}
// --continue resolves to the most recently updated session id.
resumeID := opts.resumeID
if opts.continueLast && resumeID == "" {
id, err := headless.MostRecentSessionID()
if err != nil {
fmt.Fprintf(errOut, "pigo: %v\n", err)
return 1
}
if id == "" {
fmt.Fprintln(errOut, "pigo: no sessions to continue")
return 1
}
resumeID = id
}
// No prompt + an interactive terminal → start the interactive UI. By default
// this is the full-screen TUI (US-001); --no-tui (or a non-terminal stdout)
// forces the line-based REPL (US-003). A --resume id also enters the
// interactive UI to continue an existing session. No prompt with a
// non-terminal stdout (pipe/CI) and no resume is an error, since there is
// nothing to run and nothing to interact with.
if opts.prompt == "" {
isTTY := ui.StdoutIsTerminal()
if resumeID == "" && !isTTY {
fmt.Fprintln(errOut, "pigo: no prompt (use -p \"...\" or positional args)")
return 2
}
env, err := run.SetupEnv(opts.model, opts.baseURL, opts.protocol, opts.provider, opts.apiKey, opts.noTools, opts.noSkills, opts.systemPrompt, opts.appendSystemPrompt, opts.memory.Memory.Enabled, run.NewToolPolicy(opts.allowedTools, opts.disallowedTools))
if err != nil {
fmt.Fprintf(errOut, "pigo: %v\n", err)
return setupExitCode(err)
}
if env.Plugins != nil {
defer env.Plugins.Close()
}
if env.Memory != nil {
defer env.Memory.Close()
}
thinking, err := run.ResolveThinkingLevel(opts.thinkingLevel)
if err != nil {
fmt.Fprintf(errOut, "pigo: %v\n", err)
return 2
}
if shouldUseTUI(opts, isTTY) {
// Refresh the cached latest-release check off the hot path so the banner
// can show an upgrade hint on this or the next launch without blocking
// startup (US-004). No-ops for dev builds or a fresh cache.
selfupdate.StartBackgroundCheck(version)
if err := tui.Run(tui.Options{
Model: opts.model,
ProviderName: env.ProviderName,
Provider: env.Provider,
BaseURL: opts.baseURL,
APIKey: opts.apiKey,
Protocol: opts.protocol,
Version: version,
ThinkingLevel: thinking,
Tools: env.Tools,
SysPrompt: env.SysPrompt,
ResumeID: resumeID,
Approve: opts.approve,
Skills: env.Skills,
Plugins: env.Plugins,
ConfigPrompts: opts.configPrompts,
CliPrompts: opts.promptTemplates,
NoPromptTemplates: opts.noPromptTemplates,
}); err != nil {
fmt.Fprintf(errOut, "pigo: %v\n", err)
return 1
}
return 0
}
if err := repl.Run(repl.Options{
Model: opts.model,
ProviderName: env.ProviderName,
Provider: env.Provider,
BaseURL: opts.baseURL,
APIKey: opts.apiKey,
Protocol: opts.protocol,
ThinkingLevel: thinking,
Tools: env.Tools,
SysPrompt: env.SysPrompt,
ResumeID: resumeID,
Approve: opts.approve,
Skills: env.Skills,
Plugins: env.Plugins,
ConfigPrompts: opts.configPrompts,
CliPrompts: opts.promptTemplates,
NoPromptTemplates: opts.noPromptTemplates,
Dream: opts.dreamCfg,
}); err != nil {
fmt.Fprintf(errOut, "pigo: %v\n", err)
return 1
}
return 0
}
mode, err := headless.ParseOutputMode(opts.outputFmt)
if err != nil {
fmt.Fprintf(errOut, "pigo: %v\n", err)
return 2
}
env, err := run.SetupEnv(opts.model, opts.baseURL, opts.protocol, opts.provider, opts.apiKey, opts.noTools, opts.noSkills, opts.systemPrompt, opts.appendSystemPrompt, opts.memory.Memory.Enabled, run.NewToolPolicy(opts.allowedTools, opts.disallowedTools))
if err != nil {
fmt.Fprintf(errOut, "pigo: %v\n", err)
return setupExitCode(err)
}
if env.Plugins != nil {
defer env.Plugins.Close()
}
if env.Memory != nil {
defer env.Memory.Close()
}
return headless.Run(ctx, headless.RunParams{
Mode: mode,
Env: env,
Prompt: opts.prompt,
Model: opts.model,
APIKey: opts.apiKey,
ThinkingLevel: opts.thinkingLevel,
ResumeID: resumeID,
}, out, errOut)
}
// setupExitCode maps a run.SetupEnv failure to a process exit code. A bad tool
// policy is a usage error (2), matching --cwd and --output-format; everything
// else — provider resolution, prompt assembly — is a runtime failure (1).
func setupExitCode(err error) int {
var policyErr *run.ToolPolicyError
if errors.As(err, &policyErr) {
return 2
}
return 1
}
// runDream executes the subprocess memory-consolidation pass (SPEC §4.1/§4.2).
// It runs dream.Runner to completion, marshals the resulting Report as a single
// line of JSON on stdout (the parent/scheduler parses this), and returns the
// process exit code: 0 on success (including a "skipped" run when another dream
// holds the lock) or 1 on failure. Progress and diagnostics go to errOut. The
// project scope comes from the working directory, which -C/--cwd already applied
// via os.Chdir before dispatch, so an empty ProjectDir here resolves to cwd.
func runDream(ctx context.Context, opts cliOptions, out, errOut io.Writer) int {
projectDir, err := os.Getwd()
if err != nil {
fmt.Fprintf(errOut, "pigo: dream: %v\n", err)
return 1
}
// The dream pass reuses the main-session model (SPEC Q3): resolve the same
// model/provider/api-key tuple cmd/pigo already overlaid from flags+config,
// and inject a real LLM-backed Consolidator so `pigo --dream` performs the
// semantic merge/prune step (not just the deterministic dedup/path-clean).
thinking, err := run.ResolveThinkingLevel(opts.thinkingLevel)
if err != nil {
fmt.Fprintf(errOut, "pigo: dream: %v\n", err)
return 1
}
cons, err := dream.NewLLMConsolidator(opts.model, opts.baseURL, opts.protocol, opts.provider, opts.apiKey, thinking)
if err != nil {
fmt.Fprintf(errOut, "pigo: dream: %v\n", err)
return 1
}
r := &dream.Runner{Consolidator: cons}
report, err := r.Run(ctx, dream.RunOptions{
DryRun: opts.dreamDryRun,
ProjectDir: projectDir,
})
if err != nil {
fmt.Fprintf(errOut, "pigo: dream: %v\n", err)
return 1
}
// Single-line JSON on stdout is the stdout contract (SPEC §4.2). Encoder
// writes a trailing newline, keeping the report one line.
if err := json.NewEncoder(out).Encode(report); err != nil {
fmt.Fprintf(errOut, "pigo: dream: encode report: %v\n", err)
return 1
}
return 0
}
// shouldUseTUI is the pure entry-gating predicate for the no-prompt path
// (US-001, SPEC 4.2/5.2): the full-screen TUI is used only when stdout is a TTY
// and --no-tui was not set. --no-tui or a non-terminal stdout always forces the
// line-based REPL. Keeping the decision in a side-effect-free function lets the
// gating be unit-tested without a real terminal or spawning Bubble Tea (see
// TestDispatchTUIGating); dispatch handles the non-TTY/no-resume usage error
// before calling this, so it only decides TUI-vs-REPL for the interactive case.
func shouldUseTUI(opts cliOptions, isTTY bool) bool {
return isTTY && !opts.noTUI
}
// updateIsSelfUpdate classifies the arguments that follow `pigo update` (US-003)
// to route between binary self-update and pkgmgr package-update. It returns true
// — self-update — when no positional package name is present: any argument that
// does not begin with '-' is treated as a package name and routes to
// package-update, while flags-only invocations (e.g. `pigo update --check`) stay
// on the self-update path. Keeping the decision side-effect-free lets the routing
// be unit-tested without spawning either update path (see TestUpdateIsSelfUpdate).
func updateIsSelfUpdate(rest []string) bool {
for _, a := range rest {
if !strings.HasPrefix(a, "-") {
return false
}
}
return true
}
+350
View File
@@ -0,0 +1,350 @@
package main
// Tests for the thin CLI entry point: the dispatch seam (options+writers →
// exit code), the config.toml overlay (applyFileConfig precedence), and the
// settings-tier prompts pass-through. These exercise the branching without
// spawning a provider or re-parsing the global flag set.
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/smallnest/pigo/internal/cli/config"
"github.com/smallnest/pigo/internal/cli/run"
)
// --- dispatch seam ---
// TestDispatchListSessionsEmpty verifies --list-sessions is a standalone action
// that succeeds (exit 0) and prints the empty-store message, using an isolated
// PIGO_HOME so it never touches the real session store.
func TestDispatchListSessionsEmpty(t *testing.T) {
t.Setenv("PIGO_HOME", t.TempDir())
var out, errOut bytes.Buffer
code := dispatch(context.Background(), cliOptions{listSessions: true}, &out, &errOut)
if code != 0 {
t.Fatalf("exit code = %d, want 0 (errOut=%q)", code, errOut.String())
}
if !strings.Contains(out.String(), "no sessions") {
t.Errorf("out = %q, want the empty-store message", out.String())
}
}
// TestDispatchContinueNoSessions verifies --continue with an empty store is an
// error (exit 1) that says there is nothing to continue, rather than starting a
// blank REPL.
func TestDispatchContinueNoSessions(t *testing.T) {
t.Setenv("PIGO_HOME", t.TempDir())
// Ensure a non-terminal path is not taken before the continue guard: continue
// resolves the id first and errors when the store is empty.
var out, errOut bytes.Buffer
code := dispatch(context.Background(), cliOptions{continueLast: true}, &out, &errOut)
if code != 1 {
t.Fatalf("exit code = %d, want 1 (errOut=%q)", code, errOut.String())
}
if !strings.Contains(errOut.String(), "no sessions to continue") {
t.Errorf("errOut = %q, want the no-sessions-to-continue message", errOut.String())
}
}
// TestDispatchNoPromptNonTerminal verifies the CI/pipe guard: no prompt, no
// resume, and a non-terminal stdout is a usage error (exit 2) with a diagnostic
// on errOut — reachable now that dispatch takes its writers as parameters.
func TestDispatchNoPromptNonTerminal(t *testing.T) {
var out, errOut bytes.Buffer
code := dispatch(context.Background(), cliOptions{}, &out, &errOut)
if code != 2 {
t.Fatalf("exit code = %d, want 2", code)
}
if !strings.Contains(errOut.String(), "no prompt") {
t.Errorf("errOut = %q, want it to mention the missing prompt", errOut.String())
}
}
// TestDispatchBadOutputFormat verifies an unknown --output-format is rejected
// (exit 2) before any provider work, naming the offending value.
func TestDispatchBadOutputFormat(t *testing.T) {
var out, errOut bytes.Buffer
code := dispatch(context.Background(), cliOptions{prompt: "hi", outputFmt: "yaml"}, &out, &errOut)
if code != 2 {
t.Fatalf("exit code = %d, want 2", code)
}
if !strings.Contains(errOut.String(), "yaml") {
t.Errorf("errOut = %q, want it to name the bad format", errOut.String())
}
}
// TestDispatchTUIGating verifies the pure entry-gating predicate shouldUseTUI
// (US-001, SPEC 4.2/5.2) that dispatch uses to choose the full-screen TUI vs the
// line-based REPL on the no-prompt path. The decision is tested directly so it
// needs no real TTY and never spawns Bubble Tea: TUI only when stdout is a TTY
// and --no-tui is unset; --no-tui or a non-terminal stdout always forces REPL.
func TestDispatchTUIGating(t *testing.T) {
tests := []struct {
name string
opts cliOptions
isTTY bool
want bool
}{
{name: "TTY and no flag uses TUI", opts: cliOptions{}, isTTY: true, want: true},
{name: "--no-tui forces REPL on a TTY", opts: cliOptions{noTUI: true}, isTTY: true, want: false},
{name: "non-TTY never uses TUI", opts: cliOptions{}, isTTY: false, want: false},
{name: "non-TTY with --no-tui stays REPL", opts: cliOptions{noTUI: true}, isTTY: false, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := shouldUseTUI(tt.opts, tt.isTTY); got != tt.want {
t.Errorf("shouldUseTUI(%+v, %v) = %v, want %v", tt.opts, tt.isTTY, got, tt.want)
}
})
}
}
// TestCwdChdirRootsEnv verifies the guarantee --cwd relies on: after the
// process working directory is switched (what the --cwd flag does via os.Chdir),
// run.SetupEnv roots the run — and thus the built-in file tools — at that
// directory. This is the contract that lets pigo be pointed at an arbitrary
// project root as an SDK backend. It exercises the downstream effect rather than
// re-parsing flags, since the chdir itself lives in main().
func TestCwdChdirRootsEnv(t *testing.T) {
orig, err := os.Getwd()
if err != nil {
t.Fatalf("Getwd: %v", err)
}
t.Cleanup(func() { _ = os.Chdir(orig) })
dir := t.TempDir()
if err := os.Chdir(dir); err != nil {
t.Fatalf("Chdir: %v", err)
}
// macOS temp dirs are symlinks (/tmp → /private/tmp); os.Getwd resolves them,
// so compare against the resolved form.
want, err := filepath.EvalSymlinks(dir)
if err != nil {
t.Fatalf("EvalSymlinks: %v", err)
}
env, err := run.SetupEnv("openrouter/free", "", "", "", "", true /*noTools*/, true /*noSkills*/, "", nil, false /*memEnabled*/, run.ToolPolicy{})
if err != nil {
t.Fatalf("SetupEnv: %v", err)
}
if env.Cwd != want {
t.Errorf("env.Cwd = %q, want %q (the chdir'd directory)", env.Cwd, want)
}
}
// TestUpdateIsSelfUpdate verifies the US-003 `pigo update` routing classifier:
// no positional package name (including flags-only invocations like
// `pigo update --check`) routes to binary self-update (true); any positional
// package name routes to pkgmgr package-update (false). Tested directly so the
// dispatch split needs no argv parsing or spawning either update path.
func TestUpdateIsSelfUpdate(t *testing.T) {
tests := []struct {
name string
rest []string
want bool
}{
{name: "no args is self-update", rest: nil, want: true},
{name: "empty slice is self-update", rest: []string{}, want: true},
{name: "flags-only is self-update", rest: []string{"--check"}, want: true},
{name: "multiple flags is self-update", rest: []string{"--check", "-v"}, want: true},
{name: "single package name is package-update", rest: []string{"pi-mcp-adapter"}, want: false},
{name: "multiple package names is package-update", rest: []string{"a", "b"}, want: false},
{name: "flag then package name is package-update", rest: []string{"--check", "pkg"}, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := updateIsSelfUpdate(tt.rest); got != tt.want {
t.Errorf("updateIsSelfUpdate(%v) = %v, want %v", tt.rest, got, tt.want)
}
})
}
}
// --- config.toml overlay ---
// changedSet turns a set of flag names into a lookup func for applyFileConfig.
func changedSet(names ...string) func(string) bool {
set := make(map[string]bool, len(names))
for _, n := range names {
set[n] = true
}
return func(name string) bool { return set[name] }
}
func TestApplyFileConfig_FillsUnsetFlags(t *testing.T) {
opts := cliOptions{model: "openrouter/free", outputFmt: "text"}
cfg := config.FileConfig{
Model: "claude-opus-4-8",
BaseURL: "https://example.com",
APIKey: "sk-test",
Protocol: "anthropic",
Provider: "deepseek",
ThinkingLevel: "high",
OutputFormat: "stream-json",
NoTools: true,
NoSkills: true,
Approve: true,
SystemPrompt: "be terse",
}
applyFileConfig(&opts, cfg, changedSet())
if opts.model != "claude-opus-4-8" {
t.Errorf("model = %q, want claude-opus-4-8", opts.model)
}
if opts.baseURL != "https://example.com" {
t.Errorf("baseURL = %q", opts.baseURL)
}
if opts.apiKey != "sk-test" {
t.Errorf("apiKey = %q", opts.apiKey)
}
if opts.protocol != "anthropic" {
t.Errorf("protocol = %q", opts.protocol)
}
if opts.provider != "deepseek" {
t.Errorf("provider = %q", opts.provider)
}
if opts.thinkingLevel != "high" {
t.Errorf("thinkingLevel = %q", opts.thinkingLevel)
}
if opts.outputFmt != "stream-json" {
t.Errorf("outputFmt = %q", opts.outputFmt)
}
if !opts.noTools || !opts.noSkills || !opts.approve {
t.Errorf("bool flags not applied: %+v", opts)
}
if opts.systemPrompt != "be terse" {
t.Errorf("systemPrompt = %q", opts.systemPrompt)
}
}
func TestApplyFileConfig_CLIWins(t *testing.T) {
opts := cliOptions{model: "cli-model", outputFmt: "text"}
cfg := config.FileConfig{Model: "config-model", OutputFormat: "stream-json"}
// --model was set on the command line; --output-format was not.
applyFileConfig(&opts, cfg, changedSet("model"))
if opts.model != "cli-model" {
t.Errorf("CLI model should win, got %q", opts.model)
}
if opts.outputFmt != "stream-json" {
t.Errorf("unset output-format should take config value, got %q", opts.outputFmt)
}
}
func TestApplyFileConfig_EmptyConfigNoChange(t *testing.T) {
opts := cliOptions{model: "openrouter/free", outputFmt: "text"}
applyFileConfig(&opts, config.FileConfig{}, changedSet())
if opts.model != "openrouter/free" || opts.outputFmt != "text" {
t.Fatalf("empty config should not change opts, got %+v", opts)
}
if opts.baseURL != "" || opts.provider != "" || opts.noTools {
t.Fatalf("empty config should leave unset fields empty, got %+v", opts)
}
}
func TestApplyFileConfigPrompts(t *testing.T) {
var opts cliOptions
cfg := config.FileConfig{Prompts: []string{"./my-prompts", "/abs/x.md"}}
applyFileConfig(&opts, cfg, func(string) bool { return false })
if len(opts.configPrompts) != 2 || opts.configPrompts[0] != "./my-prompts" || opts.configPrompts[1] != "/abs/x.md" {
t.Errorf("opts.configPrompts = %v, want [./my-prompts /abs/x.md]", opts.configPrompts)
}
}
// The tool boundary follows CLI > file > default like the other scalar flags,
// rather than the additive treatment `prompts` gets: merging would prevent a CLI
// flag from widening a boundary the config file narrowed.
func TestApplyFileConfigToolPolicy(t *testing.T) {
t.Run("fills unset flags", func(t *testing.T) {
var opts cliOptions
cfg := config.FileConfig{
AllowedTools: []string{"read", "grep"},
DisallowedTools: []string{"bash"},
}
applyFileConfig(&opts, cfg, changedSet())
if len(opts.allowedTools) != 2 || opts.allowedTools[0] != "read" {
t.Errorf("allowedTools = %v, want [read grep]", opts.allowedTools)
}
if len(opts.disallowedTools) != 1 || opts.disallowedTools[0] != "bash" {
t.Errorf("disallowedTools = %v, want [bash]", opts.disallowedTools)
}
})
t.Run("CLI replaces file value wholesale", func(t *testing.T) {
opts := cliOptions{allowedTools: []string{"bash"}}
cfg := config.FileConfig{AllowedTools: []string{"read"}, DisallowedTools: []string{"write"}}
applyFileConfig(&opts, cfg, changedSet("allowed-tools"))
if len(opts.allowedTools) != 1 || opts.allowedTools[0] != "bash" {
t.Errorf("CLI --allowed-tools must win outright, got %v", opts.allowedTools)
}
if len(opts.disallowedTools) != 1 || opts.disallowedTools[0] != "write" {
t.Errorf("unset --disallowed-tools should take the config value, got %v", opts.disallowedTools)
}
})
t.Run("absent config leaves the boundary open", func(t *testing.T) {
var opts cliOptions
applyFileConfig(&opts, config.FileConfig{}, changedSet())
if opts.allowedTools != nil || opts.disallowedTools != nil {
t.Errorf("empty config must not constrain tools, got %v / %v", opts.allowedTools, opts.disallowedTools)
}
})
}
// setupExitCode maps a bad tool policy to the usage exit code (2) and everything
// else to a runtime failure (1), so a typo is distinguishable from e.g. a
// provider-resolution error.
func TestSetupExitCode(t *testing.T) {
if got := setupExitCode(&run.ToolPolicyError{UnknownAllowed: []string{"raed"}}); got != 2 {
t.Errorf("setupExitCode(ToolPolicyError) = %d, want 2 (usage error)", got)
}
if got := setupExitCode(errors.New("provider boom")); got != 1 {
t.Errorf("setupExitCode(generic) = %d, want 1", got)
}
if got := setupExitCode(fmt.Errorf("wrapped: %w", &run.ToolPolicyError{})); got != 2 {
t.Errorf("setupExitCode must unwrap, got %d, want 2", got)
}
}
// applyFileConfig always resolves the [memory]/[checkpoint]/[compaction] tables
// into opts.memory, applying defaults when they are absent.
func TestApplyFileConfig_MemoryDefaults(t *testing.T) {
var opts cliOptions
applyFileConfig(&opts, config.FileConfig{}, changedSet())
if !opts.memory.Memory.Enabled || !opts.memory.Memory.ReconcileOnSearch {
t.Errorf("memory defaults not applied: %+v", opts.memory.Memory)
}
if opts.memory.Memory.SearchScoreFloor != 0.15 {
t.Errorf("search_score_floor default = %v, want 0.15", opts.memory.Memory.SearchScoreFloor)
}
if len(opts.memory.CheckpointThresholds) != 3 {
t.Errorf("checkpoint thresholds default = %v, want 3 entries", opts.memory.CheckpointThresholds)
}
if opts.memory.MaxContext.IsSet() {
t.Errorf("max_context should be unset by default")
}
}
// A configured [memory]/[compaction] set overlays into opts.memory.
func TestApplyFileConfig_MemoryOverride(t *testing.T) {
var opts cliOptions
enabled := false
cfg := config.FileConfig{
Memory: config.MemoryConfig{Enabled: &enabled},
Compaction: config.CompactionConfig{MaxContext: "50%"},
}
applyFileConfig(&opts, cfg, changedSet())
if opts.memory.Memory.Enabled {
t.Errorf("memory.enabled=false should overlay, got true")
}
if got := opts.memory.MaxContext.Resolve(200000); got != 100000 {
t.Errorf("max_context 50%% of 200000 = %d, want 100000", got)
}
}