Documentation
¶
Overview ¶
Package cli handles detection, invocation, and version extraction for LLM CLI tools.
Index ¶
- Constants
- Variables
- func AgentSunsetDate(name string) time.Time
- func ClassifyExit(ctx context.Context, err error, combinedOutput []byte, agent string) string
- func ContextWindow(agent string) int
- func DaysUntilAgentSunset(name string, now time.Time) int
- func EstimatePromptPayload(systemPrompt, diff string) int
- func EstimateTokens(s string) int
- func FormatSkipReason(s SkippedAgent) string
- func IsAgentSunset(name string, now time.Time) bool
- func IsReviewCapable(name string) bool
- func PreflightFilter(active []LLM, systemPrompt, diff string) (kept []LLM, skipped []SkippedAgent, promptDiffTokens int)
- func SkipSummary(skipped []SkippedAgent) string
- func SplitReady(results []ProbeResult) (ready, notReady []string)
- type AntigravityInvoker
- type ClaudeInvoker
- type CodexInvoker
- type CopilotInvoker
- type GeminiInvoker
- type Invoker
- type LLM
- type PartialStderrCapturer
- type ProbeResult
- type ProbeStatus
- type ProviderProbeFunc
- type ProviderSpec
- type SkippedAgent
- type TokenUsage
Constants ¶
const DefaultProbeTimeout = 10 * time.Second
DefaultProbeTimeout is the per-LLM deadline for the pre-flight probe. 10s is long enough that a healthy CLI's startup + minimal round-trip fits comfortably (claude-code is the slowest in our roster at ~3-5s on a warm cache) and short enough that a hung CLI doesn't make the readiness block itself feel like the 4-minute gemini hang we're trying to eliminate.
Public constant so doctor and future callers can use the same value as the runner — if we tune it later, every consumer updates at once instead of drifting.
const DefaultTimeoutSec = 600
DefaultTimeoutSec is the per-agent timeout in seconds used when the resolved LLMConfig has no explicit value. Single source of truth shared across the runner's applyConfig fallback, the orchestrator's RunParallel fallback, the merge-step fallback, and the roster line that shows "timeout: Ns" on the pre-run roster. Centralising prevents drift between what the user sees ("timeout: Ns") and what actually fires.
10 minutes accommodates worst-case agents (Anthropic Sonnet on a thinking model) on worst-case diffs. Users wanting shorter timeouts override per-agent via `llms.<agent>.timeout_seconds:` in `.local-review.yml`.
Variables ¶
var CanonicalAPIKeyEnv = map[string]string{
"claude": "ANTHROPIC_API_KEY",
"gemini": "GEMINI_API_KEY",
"codex": "OPENAI_API_KEY",
"copilot": "COPILOT_GITHUB_TOKEN",
}
CanonicalAPIKeyEnv is the env var each CLI itself reads to find its API key. Doctor uses these as the default check when the user hasn't configured a custom APIKeyEnv; invokers use them as the injection target when the user *has* (so a key in $MY_GEMINI_KEY still ends up as $GEMINI_API_KEY for the gemini subprocess).
GeminiSunsetDate is the day after which Google stops serving the Gemini CLI's Pro / Ultra / free-tier requests. Announced 2026-04 as part of the Antigravity (`agy`) succession.
Hard-coded constant so the auto-disable behaviour is auditable without scraping a remote URL at startup (privacy / offline-first constraint) and survives a long-lived binary install across the cutoff. When Google moves the date, bump this constant.
UTC midnight. The sunset is treated as "any moment ON or AFTER this instant" — i.e. once `time.Now().UTC()` is at-or-past this point, gemini is treated as removed (unless force_after_sunset is set; see LLMConfig.ForceAfterSunset).
Functions ¶
func AgentSunsetDate ¶ added in v0.15.0
AgentSunsetDate returns the manufacturer-announced sunset date for an agent, or the zero time when no sunset is known. Today only gemini has one; this indirection exists so adding a future sunset (or removing gemini's once the cutoff passes) is a one-line edit here instead of a grep across detector + doctor + runner.
func ClassifyExit ¶ added in v0.6.3
ClassifyExit produces a short, user-facing summary of why a CLI subprocess failed, suitable for the per-LLM line:
[1/3] claude ✗ <ClassifyExit output>
Every classification that we recognise ends with an *actionable* next step — "try a smaller diff", "raise timeout_sec", etc. — so a user staring at a failure has a path forward without diving into stderr or strace. Pre-fix the failure line was the bare wrapped error ("claude review failed: signal: killed (output: )"), which reliably triggered "delete the tool" frustration.
agent is the LLM name ("claude", "gemini", "codex") — embedded into hints that reference per-agent config (e.g. `llms.<agent>.timeout_seconds`). The YAML key is `timeout_seconds` (see internal/config/config.go LLMConfig struct tag). A previous version of this hint said `timeout_sec` — wrong, and would silently leave the user's config untouched if they pasted it.
func ContextWindow ¶ added in v0.6.5
ContextWindow returns the conservative per-LLM context window we preflight against, in tokens. Values are the smallest current- stable model in each vendor's lineup so we never falsely pass a diff that would 4xx on a smaller model.
Why a static table instead of probing the CLI: each CLI exposes model context differently (or not at all), and the values change monthly. A small table here, conservative by design, fails-safe if a vendor *shrinks* a context window (rare but happens during rate-limit shifts) at the cost of over-skipping when a vendor *grows* one. Users can override per-agent via `llms.<agent>.context_window:` once that config field lands.
Returns 0 for unknown agents — caller should treat that as "don't preflight, let the agent decide" so a future agent type doesn't get silently filtered.
func DaysUntilAgentSunset ¶ added in v0.15.0
DaysUntilAgentSunset returns the integer number of days between `now` and the agent's sunset date, using *ceiling* semantics for positive durations: any remaining time before the sunset rounds up to at least 1, so the doctor countdown never reads "0 days until sunset" while time still remains. Once `now` is at or past the sunset (IsAgentSunset == true), the return value is 0 or negative — caller uses IsAgentSunset to switch banner modes, this function just supplies the magnitude.
Pre-v0.15 the math was a floor (`int(hours / 24)`), which read like a boundary bug for the last ~24h before cutoff. Self-review caught this on PR 2/4.
Returns noSunsetSentinel (a distinctly impossible value) when the agent has no sunset; callers can use IsAgentSunset or AgentSunsetDate to distinguish "no sunset known" from "sunset just happened" if they need to.
func EstimatePromptPayload ¶ added in v0.6.5
EstimatePromptPayload returns an upper-bound token estimate for the exact bytes the invokers ship to the CLI: the wrapped system prompt + separator + diff. Using this — rather than estimating systemPrompt + diff in isolation — prevents preflight from passing an agent that will then 4xx because the actual sent payload was bigger than what we measured.
func EstimateTokens ¶ added in v0.6.5
EstimateTokens approximates the token count of s using a fixed byte-to-token ratio. Returns a true upper bound (math.Ceil over the byte/3.5 division) so preflight biases toward skipping rather than feeding an oversized prompt to a model that will 4xx.
We don't pull in a real tokeniser (tiktoken, sentencepiece): they require SDK dependencies we deliberately avoid (vendor lock-in, binary bloat) and the preflight only needs an upper bound to decide "fit or skip". Off-by-20% in the conservative direction is fine.
func FormatSkipReason ¶ added in v0.6.5
func FormatSkipReason(s SkippedAgent) string
FormatSkipReason returns the user-facing one-liner explaining why an agent was preflight-skipped, with the same actionable-hint shape as ClassifyExit's failure messages: numbers + concrete fix.
"prompt+diff" rather than "diff" because the estimate covers the full payload (system prompt + glue + diff) — saying only "diff is X tokens" misled users into thinking the raw diff alone exceeded the context window. Both the prompt+diff total AND the response-reservation appear because the gate is "payload + margin > window" — saying only "payload exceeds window" is factually wrong near the limit.
func IsAgentSunset ¶ added in v0.15.0
IsAgentSunset reports whether the agent's sunset date has passed relative to `now`. `now` is a parameter so tests can inject a fixed clock — production callers pass `time.Now().UTC()`.
Returns false for agents with no sunset (every agent except gemini today).
func IsReviewCapable ¶ added in v0.11.0
IsReviewCapable reports whether a detected LLM may join the review fan-out. Detection (binary present + authenticated) is necessary but not sufficient: an experimental CLI can be detected yet excluded — see experimentalLLMs.
func PreflightFilter ¶ added in v0.6.5
func PreflightFilter(active []LLM, systemPrompt, diff string) (kept []LLM, skipped []SkippedAgent, promptDiffTokens int)
PreflightFilter takes the active agent list and the system-prompt + diff that will be sent to each agent, returns the subset that fits in each agent's context window plus a list of skipped agents with their numbers.
The filter is purely based on length — we don't know the user's model precisely (CLIs may pick their own default) so we use the conservative-floor table from ContextWindow. An agent whose name isn't in the table is passed through unchanged ("don't preflight what we don't know").
Returns the filtered active list, the skipped-agent details, and the prompt+diff token estimate (useful for the caller's summary line on the all-skipped path).
func SkipSummary ¶ added in v0.6.5
func SkipSummary(skipped []SkippedAgent) string
SkipSummary returns a multi-line summary block listing every skipped agent and what the user should do. Empty string when none were skipped.
func SplitReady ¶ added in v0.10.1
func SplitReady(results []ProbeResult) (ready, notReady []string)
SplitReady partitions a ProbeResult slice into "ready" and "not ready" subsets, preserving order within each subset. Used by the runner to filter the active LLM list to the ones that passed pre-flight, while keeping the not-ready ones around for rendering the readiness block.
Operates on names so the caller can correlate with their own LLM slice without a second lookup. Returned slices are nil when empty (Go idiom) — callers should `len(...)`-check rather than `!= nil`-check.
Types ¶
type AntigravityInvoker ¶ added in v0.11.0
type AntigravityInvoker struct {
// contains filtered or unexported fields
}
AntigravityInvoker runs Google's Antigravity CLI (`agy`), the successor to the Gemini CLI. Google announced (2026-05) that the Gemini CLI stops serving Pro/Ultra/free-tier requests on 2026-06-18, with Antigravity as the replacement.
NOT WIRED INTO THE REVIEW FAN-OUT (cli.IsReviewCapable("antigravity") == false). The 2026-05 authenticated dogfood found agy's `--print` mode runs a full autonomous agent loop: it explores the repo, runs git/python, reconstructs its OWN diff (ignoring the one we hand it), and streams tool-step narration ("I will run git diff…") to stdout rather than a clean review. A real `local-review review --only antigravity` produced 6.5 KB of narration, zero findings, and an empty merged report. So agy is detected + surfaced in `doctor` (statusExperimental) but excluded from the active set. This invoker is retained as the starting point for a future structured-output integration; until then it's only reachable via NewInvoker for the type/interface tests, never from a real review run.
Three ways agy differs from the other invokers, all confirmed by probing `agy --help` / `agy --version` on v1.0.2:
The prompt is a POSITIONAL ARG to `-p`, not stdin. agy's `-p` flag "needs an argument" — piping a prompt to it errors. So unlike claude/gemini/codex (all stdin-fed), agy gets the whole prompt+diff on argv. macOS ARG_MAX is 1 MiB; run() guards with an explicit 256 KiB ceiling so an oversized prompt fails with a clear message instead of a cryptic "argument list too long" from exec. (In real review runs PreflightFilter would cap prompt size first, but agy is excluded from the fan-out, so that backstop never runs here — hence the in-invoker guard.)
No structured-output flag. agy has no `-o json` / `--output-format` (verified against `agy --help` v1.0.2), so there's nothing to parse token usage from — RunPrompt returns TokenUsage{}. Display callers already handle the zero case ("we couldn't determine usage"); agy reviews render without a token count, same as codex pre-v0.128.
`--dangerously-skip-permissions` is passed so the headless call doesn't hang on a tool-permission prompt. Our prompt is self-contained (the diff is in the prompt text; agy doesn't need filesystem tools to review it), but agy is agentic and may still try to read files / run tools unless told to auto-approve. Without the flag a `-p` run can block forever waiting on a permission prompt that has no TTY to answer it.
Auth is OAuth (Google login via `agy`), like claude — there's no API-key env var, so CanonicalAPIKeyEnv has no "antigravity" entry and apiKey is unused here. An unauthenticated agy surfaces "Authentication required. Please visit the URL..." which the pre-flight probe captures and renders as the readiness-block reason; no special-casing needed.
func (*AntigravityInvoker) PartialStderr ¶ added in v0.11.0
func (p *AntigravityInvoker) PartialStderr() string
PartialStderr implements PartialStderrCapturer for any invoker that embeds partialStderrField. Returns whatever's been buffered from the most-recent subprocess invocation, or "" if no run has started yet (capture pointer is still nil).
func (*AntigravityInvoker) Review ¶ added in v0.11.0
func (a *AntigravityInvoker) Review(ctx context.Context, systemPrompt, diff string) (string, TokenUsage, error)
func (*AntigravityInvoker) RunPrompt ¶ added in v0.11.0
func (a *AntigravityInvoker) RunPrompt(ctx context.Context, prompt string) (string, TokenUsage, error)
type ClaudeInvoker ¶
type ClaudeInvoker struct {
// contains filtered or unexported fields
}
ClaudeInvoker runs the Anthropic Claude CLI. Uses stdin pipe similar to Gemini.
func (*ClaudeInvoker) PartialStderr ¶ added in v0.10.6
func (p *ClaudeInvoker) PartialStderr() string
PartialStderr implements PartialStderrCapturer for any invoker that embeds partialStderrField. Returns whatever's been buffered from the most-recent subprocess invocation, or "" if no run has started yet (capture pointer is still nil).
func (*ClaudeInvoker) Review ¶
func (c *ClaudeInvoker) Review(ctx context.Context, systemPrompt, diff string) (string, TokenUsage, error)
func (*ClaudeInvoker) RunPrompt ¶
func (c *ClaudeInvoker) RunPrompt(ctx context.Context, prompt string) (string, TokenUsage, error)
type CodexInvoker ¶
type CodexInvoker struct {
// contains filtered or unexported fields
}
CodexInvoker runs the OpenAI Codex CLI.
Bare `codex` (no subcommand) opens an interactive TUI — that's what the pre-v0.5.1 invoker was doing, which is why every codex review failed with `exit status 1`. We use `codex exec` (non-interactive), pipe the prompt over stdin, and have codex write only the final assistant message to a temp file via --output-last-message. That sidesteps both the interactive-TUI failure AND the noisy "session id / tokens used" preamble that codex exec normally prints to stdout.
We deliberately don't use `codex review` (the dedicated review subcommand) because it re-extracts the diff itself from the local git tree, conflicting with the orchestrator's "extract once, fan out to all LLMs with the same diff string" contract.
func (*CodexInvoker) PartialStderr ¶ added in v0.10.6
func (p *CodexInvoker) PartialStderr() string
PartialStderr implements PartialStderrCapturer for any invoker that embeds partialStderrField. Returns whatever's been buffered from the most-recent subprocess invocation, or "" if no run has started yet (capture pointer is still nil).
func (*CodexInvoker) Review ¶
func (c *CodexInvoker) Review(ctx context.Context, systemPrompt, diff string) (string, TokenUsage, error)
func (*CodexInvoker) RunPrompt ¶
func (c *CodexInvoker) RunPrompt(ctx context.Context, prompt string) (string, TokenUsage, error)
type CopilotInvoker ¶ added in v0.12.0
type CopilotInvoker struct {
// contains filtered or unexported fields
}
CopilotInvoker runs the GitHub Copilot CLI (`copilot`), GitHub's agentic coding assistant, in non-interactive mode as a review agent.
Unlike Antigravity — the other agentic CLI we evaluated, gated out because its `--print` emits agent narration instead of a review — Copilot's `-p` mode returns a clean answer: the review prose goes to STDOUT while agent/usage telemetry (MCP status, "Requests N Premium", the token summary) goes to STDERR. The 2026-05 dogfood confirmed it reviews the diff it's handed (`Changes +0 -0` — it does NOT reconstruct its own) and catches planted bugs, so it joins the active fan-out as a first-class agent.
Invocation specifics (verified against `copilot --help` v1.0.54):
- `-p, --prompt <text>` runs one prompt non-interactively. The prompt is a POSITIONAL value to the flag (not stdin), so it rides on argv — run() guards against ARG_MAX, with PreflightFilter as the upstream cap.
- `--available-tools=` (empty whitelist) disables ALL tools. This is the security boundary: the diff in the prompt is untrusted, so we must NOT let a prompt-injecting diff drive Copilot's shell/write/url tools. With no tools available there's nothing to approve, so non-interactive mode needs no `--allow-all-tools` and can't hang on a permission prompt. See run() for the full note.
- `--no-ask-user` stops the agent from blocking on a clarifying question in non-interactive mode.
- `--no-color` strips ANSI so the captured stdout is clean markdown.
- `--model <id>` selects the model (e.g. gpt-5.3-codex); empty = CLI default.
Auth: a `copilot login` session stored under ~/.copilot (or $COPILOT_HOME), OR a token in COPILOT_GITHUB_TOKEN. We inject the resolved apiKey (when configured) as COPILOT_GITHUB_TOKEN. The copilot CLI also reads GH_TOKEN / GITHUB_TOKEN, but doctor will NOT auto-enable Copilot on those generic tokens (see checkCopilotAuth) — they're too common in CI to safely activate a paid reviewer.
Cost: each run consumes a Copilot "Premium request" from the user's subscription — it is NOT BYOK-free like a Gemini API key. The token summary on stderr is vendor-rounded; parseCopilotStderrTokens reads it best-effort and degrades to zero usage if the format shifts.
func (*CopilotInvoker) PartialStderr ¶ added in v0.12.0
func (p *CopilotInvoker) PartialStderr() string
PartialStderr implements PartialStderrCapturer for any invoker that embeds partialStderrField. Returns whatever's been buffered from the most-recent subprocess invocation, or "" if no run has started yet (capture pointer is still nil).
func (*CopilotInvoker) Review ¶ added in v0.12.0
func (c *CopilotInvoker) Review(ctx context.Context, systemPrompt, diff string) (string, TokenUsage, error)
func (*CopilotInvoker) RunPrompt ¶ added in v0.12.0
func (c *CopilotInvoker) RunPrompt(ctx context.Context, prompt string) (string, TokenUsage, error)
type GeminiInvoker ¶
type GeminiInvoker struct {
// contains filtered or unexported fields
}
GeminiInvoker runs the Google Gemini CLI. Uses: git diff | gemini -p "Review these changes for bugs and security issues"
func (*GeminiInvoker) PartialStderr ¶ added in v0.10.6
func (p *GeminiInvoker) PartialStderr() string
PartialStderr implements PartialStderrCapturer for any invoker that embeds partialStderrField. Returns whatever's been buffered from the most-recent subprocess invocation, or "" if no run has started yet (capture pointer is still nil).
func (*GeminiInvoker) Review ¶
func (g *GeminiInvoker) Review(ctx context.Context, systemPrompt, diff string) (string, TokenUsage, error)
func (*GeminiInvoker) RunPrompt ¶
func (g *GeminiInvoker) RunPrompt(ctx context.Context, prompt string) (string, TokenUsage, error)
type Invoker ¶
Invoker is the review-agent contract. Moved to internal/agents in v0.14 so the HTTP provider invoker (internal/agents/provider) could share it without depending on this CLI-subprocess package. CLI callers continue to use cli.Invoker unchanged via this alias.
See agents.Invoker for the full contract; the CLI subprocess invokers below all satisfy it (they shell out to the vendor CLI, parse its structured output for tokens, and return markdown or JSON depending on what the resolved system prompt asked for).
func NewInvoker ¶
NewInvoker creates an invoker for the given LLM. The Model and APIKey fields on LLM are threaded into each invoker so per-agent --claude-model / --gemini-model / --codex-model flag overrides actually reach the CLI command line, and so a key sourced from a user-named env var (cfg.LLMs[name].APIKeyEnv) still reaches the subprocess under the canonical name the CLI itself expects. An empty Model leaves the CLI on its default; an empty APIKey means "rely on the CLI's own auth flow / OAuth session."
Returns nil if the LLM name is unknown (for CLI agents) — provider agents (BaseURL set) are always recognised, so they never return nil here regardless of name.
type LLM ¶
type LLM struct {
// Name is the agent's identifier. CLI agents use the well-known
// keys "claude" / "gemini" / "codex" / "antigravity" / "copilot".
// Provider agents (BaseURL set) use the free-form user-chosen name
// from cfg.LLMs (e.g. "qwen", "local-fast", "air-gapped").
Name string
Path string // full path to the binary (e.g., "/usr/local/bin/claude")
Version string // version string (e.g., "2.1.0"), or "unknown" if version probe failed
// Available is true only when both the binary is in PATH AND the
// `--version` probe returned a parseable string. A binary that
// resolves but whose version probe fails (broken symlink, corrupted
// install, missing runtime) is reported Available=false so callers
// don't try to invoke an unusable tool.
Available bool
// Model is the agent-specific model id (e.g., "claude-opus-4-7",
// "gemini-2.0-flash", "gpt-5"). Threaded through from cfg.LLMs[*]
// .Model so per-agent model overrides on the runner actually reach
// the invoker — pre-fix the field was set in config and printed in
// the roster but the invoker only got Path, so users got false
// confirmation that the requested model ran.
Model string
// APIKey is the resolved API-key value sourced from
// cfg.LLMs[name].APIKeyEnv (or the legacy hard-coded env var when
// APIKeyEnv is empty). The invoker injects it into the subprocess
// env under the *canonical* variable name each CLI expects
// (ANTHROPIC_API_KEY / GEMINI_API_KEY / OPENAI_API_KEY) so users
// can stash the key under any env name they like and the agent
// still authenticates.
APIKey string
// BaseURL is the OpenAI-compatible HTTP endpoint for a *provider*
// agent (Ollama, vLLM, any /v1/chat/completions endpoint). Empty
// for CLI subprocess agents — that's the kind-discriminator across
// the codebase: BaseURL != "" → provider; else CLI. cli.NewInvoker
// dispatches on it, doctor renders a different row shape for it,
// and the probe layer picks an HTTP vs subprocess probe accordingly.
// Populated by DetectProviders (this package) from the matching
// cfg.LLMs[name].BaseURL entry.
BaseURL string
// APIKeyEnv is the NAME of the env var the provider key was sourced
// from (cfg.LLMs[name].APIKeyEnv), threaded through so an auth-miss
// error names the variable the user actually configured rather than
// a generic/removed default. Only meaningful for provider agents;
// CLI agents inject under CanonicalAPIKeyEnv instead.
APIKeyEnv string
// TimeoutSec is the per-call timeout (from config).
TimeoutSec int
}
LLM represents a detected CLI tool with its metadata.
func Detect ¶
Detect checks if a specific LLM CLI is installed and returns its metadata. Available is true only if the binary is found AND its version probe succeeded; see the LLM.Available field doc for the precise contract.
func DetectAll ¶
func DetectAll() []LLM
DetectAll checks for all supported LLM CLIs and returns their status. Returns a slice of LLM structs, one per supported CLI (even if not found). Runs detections concurrently to avoid sequential timeouts.
Equivalent to DetectAllWithOverrides(nil) — kept for callers that don't need cli_path overrides (e.g. tests).
func DetectAllWithOverrides ¶ added in v0.6.0
DetectAllWithOverrides is DetectAll plus per-LLM cli_path overrides from config (cfg.LLMs[name].CLIPath). An override may be an absolute path (`/opt/corporate/bin/claude`) or a bare binary name; exec.LookPath handles both — anything with a slash bypasses $PATH per Go's docs.
Pre-fix the orchestrator detected only the hardcoded `claude` / `gemini` / `codex` binary names, so the cli_path field shipped in every example config was inert. Users with corporate installs at non-standard paths got "✗ not installed" with no path-override escape hatch.
func DetectProviders ¶ added in v0.14.0
func DetectProviders(ctx context.Context, specs []ProviderSpec) []LLM
DetectProviders runs an HTTP readiness probe per provider spec and returns one LLM per spec with Available set according to the probe outcome. Probes run in parallel — the same way DetectAll fans out the CLI version probes — so N providers don't serialise into N timeouts.
Available reflects ONLY endpoint reachability + auth-acceptance at /v1/models. It does NOT confirm the configured Model is loaded — that's the job of the strict probe (PR 3 wires --strict-probe), the pre-flight in the runner, or, ultimately, the real review call.
Returns an empty slice when specs is empty; never returns nil.
type PartialStderrCapturer ¶ added in v0.10.6
type PartialStderrCapturer interface {
PartialStderr() string
}
PartialStderrCapturer is an OPTIONAL interface invokers can implement to expose whatever's been written to the subprocess's stderr so far — without waiting for the subprocess to actually exit. Used by Probe to surface the vendor's diagnostic line (e.g. "You have exhausted your capacity on this model.") in the readiness block when a probe times out, instead of the generic "timeout after 10s" we'd otherwise have to print because the subprocess is hung past SIGKILL on pipe drain.
The current production invokers (Claude / Gemini / Codex) all implement this — they buffer up to 4 KiB of stderr via a stderrCapture written in parallel to the normal cmd.Stderr destination. The interface is optional so tests + future invokers can opt out by simply not implementing it; Probe falls back to the generic timeout text in that case.
type ProbeResult ¶ added in v0.10.1
type ProbeResult struct {
LLM string
Status ProbeStatus
Err error // populated iff Status != ProbeReady
Duration time.Duration // wall-clock of the probe call
}
ProbeResult is the per-LLM outcome of a pre-flight readiness probe. Symmetric with multi.ReviewResult in that we always emit one of these per LLM (even on failure) so callers can render a complete readiness block without back-tracking.
func Probe ¶ added in v0.10.1
func Probe(ctx context.Context, inv Invoker, name string) ProbeResult
Probe invokes inv with a tiny "reply OK" prompt and returns the outcome. The caller owns the context's deadline: this function does no inner timeout management so there's a single source of truth for "how long do we wait before declaring an LLM unresponsive" (the per-LLM ctx deadline set by ProbeAll, or by a test passing context.WithTimeout directly).
name is plumbed through to ProbeResult.LLM so the caller can build the readiness block without re-deriving identity from the invoker (Invoker is an interface and doesn't expose name).
func ProbeAll ¶ added in v0.10.1
func ProbeAll(ctx context.Context, llms []LLM, perLLMTimeout time.Duration, strict bool) []ProbeResult
ProbeAll runs Probe on each LLM in parallel and returns the results in roster order. Each LLM gets its own context with perLLMTimeout — slow LLMs don't hold up faster ones, and a hung CLI on one agent doesn't block the entire readiness block (which was the v0.10.0-RC observed failure: gemini's exhausted-capacity error surfacing after a ~4 min hang while claude and codex sat idle waiting for it).
Returns one ProbeResult per input LLM, indexed by roster position (not completion order, unlike orchestrator.RunParallel). Roster order matches the readiness block's left-to-right layout in the runner; reordering by completion would make the block non-deterministic across runs, hurting readability when users compare two runs side by side.
invokerFactory is an unexported test seam (see ProbeAllWithInvokerFactory). Production callers use ProbeAll which wires in cli.NewInvoker. strict toggles how provider agents are probed:
false (default): provider agents use a cheap HTTP `GET /v1/models` check — proves the endpoint is up and auth is accepted without loading the model or burning tokens. Right default for the common "is my Ollama tailnet box reachable?" question. CLI agents always use the existing `Reply OK` subprocess probe regardless (they have no lighter alternative).
true: every agent (provider AND CLI) does the full `Reply OK` via the Invoker contract — for providers that's an actual `POST /v1/chat/completions` exercising the named model, catching "endpoint up but model not loaded" cases the light probe misses. Useful when the configured model id matters; surfaced via the `--strict-probe` flag.
func ProbeAllWithInvokerFactory ¶ added in v0.10.1
func ProbeAllWithInvokerFactory(ctx context.Context, llms []LLM, perLLMTimeout time.Duration, factory func(LLM) Invoker, providerProbeFn ProviderProbeFunc, strict bool) []ProbeResult
ProbeAllWithInvokerFactory is the test-seam variant of ProbeAll. Production code calls ProbeAll; this is exported only so the runner's tests can substitute fake invokers (the same pattern internal/multi.Orchestrator uses for its unexported invokerFactory field). Kept as a public function rather than a struct field so callers don't have to construct a Prober type for a single override — the seam is the function pointer itself.
See ProbeAll for the `strict` semantics.
func (ProbeResult) IsReady ¶ added in v0.10.1
func (r ProbeResult) IsReady() bool
IsReady is a named predicate so call sites don't litter the runner with `r.Status == cli.ProbeReady` comparisons.
type ProbeStatus ¶ added in v0.10.1
type ProbeStatus int
ProbeStatus is the outcome of a pre-flight readiness probe.
Distinct from a per-LLM review error because the user-facing rendering differs: a probe failure prints a one-character marker (✗) in the readiness block and gets a single-line error summary, not the multi-line stderr-tail treatment a real-review failure gets. Conflating the two would surface noise in the readiness block (which is meant to read at a glance) and hide auth-vs- capacity-vs-network distinctions a user needs to act on.
const ( // ProbeReady: the CLI returned a non-empty response within the // per-LLM timeout window. The LLM is fit to participate in the // real review run. ProbeReady ProbeStatus = iota // ProbeError: the invoker returned an error other than a context // deadline — typically auth-not-ready, capacity exhausted on the // model, or network/connectivity. The error message carries the // vendor's own string (ClassifyExit'd) so the user sees what to // fix without re-running the real review. ProbeError // ProbeTimeout: the probe context's deadline expired before the // CLI replied. Distinct from ProbeError because it usually means // "vendor SLOW today" rather than "permanently broken" — the // real-review timeout is much longer and might still complete. // Treated the same as ProbeError for run-or-skip purposes; the // distinct status exists so the readiness block can render a // clearer "(timeout after Ns)" hint instead of a generic // error string. ProbeTimeout // ProbeCanceled: the parent context was canceled — typically // the user pressing Ctrl+C, OR a parent timeout firing across // the whole run rather than the per-LLM probe budget. Distinct // from ProbeTimeout because the user-facing message is different // ("user interrupt" vs "vendor slow today") and the runner's // post-probe error handling needs to short-circuit on cancel // rather than complain about "every LLM failed pre-flight" // when the real cause was a deliberate stop signal. ProbeCanceled )
func (ProbeStatus) String ¶ added in v0.10.1
func (s ProbeStatus) String() string
String returns a one-word identifier suitable for rendering / logging. The readiness block uses ✓/✗ glyphs at the call site; this is for log lines and tests.
type ProviderProbeFunc ¶ added in v0.14.0
type ProviderProbeFunc func(ctx context.Context, baseURL, apiKey string, timeout time.Duration) error
ProviderProbeFunc is the seam shape ProbeAllWithInvokerFactory uses to drive the cheap HTTP /v1/models check. Production code passes providerProbe (which adapts internal/agents/provider.Probe); tests pass deterministic fakes. Per-call (not a package global) so tests can run in parallel without seam reassignment races.
type ProviderSpec ¶ added in v0.14.0
type ProviderSpec struct {
Name string
BaseURL string
Model string
APIKey string
// APIKeyEnv is the NAME of the env var APIKey was resolved from
// (cfg.LLMs[name].APIKeyEnv). Carried through so an auth-miss error
// can name the variable the user configured. Empty for keyless
// (local/LAN) providers.
APIKeyEnv string
TimeoutSec int
}
ProviderSpec is the minimal config-derived shape DetectProviders needs per agent. The caller (cmd/local-review's pickAgents) builds these from cfg.LLMs[name] entries that have a BaseURL — that's the kind discriminator. APIKey is the already-resolved key (from config.resolveAPIKeys' env-var lookup); empty means "no auth configured" which the HTTP layer's isLocalURL bypass tolerates for local-or-LAN endpoints.
Why a separate spec type (vs taking config.Config directly): keeps this package free of an import on internal/config and matches the shape DetectAllWithOverrides already uses — caller owns the config vocabulary, this package owns the runtime-agent vocabulary.
type SkippedAgent ¶ added in v0.6.5
SkippedAgent describes one agent that was preflight-skipped, with enough detail for the user to understand why and what to do about it.
PromptDiffTokens is the upper-bound estimate of the *combined* payload sent to the CLI: the system prompt (after wrapping by buildReviewPrompt) plus the diff, with the "# Diff" header glue included. Named "PromptDiffTokens" rather than "EstimatedTokens" so the user-facing message can't accidentally claim "diff is X tokens" when X is actually prompt+diff.
type TokenUsage ¶ added in v0.6.6
type TokenUsage = agents.TokenUsage
TokenUsage is an alias for agents.TokenUsage. The type was moved to internal/agents in v0.14 so the HTTP provider invoker (internal/agents/provider) could share the same shape without depending on this CLI-subprocess package. CLI callers continue to use cli.TokenUsage unchanged via this alias; the methods (IsZero, Total) come along with the alias automatically. See agents.TokenUsage for the full doc.