Files
docker-agent/pkg/codingharness/claudecli.go
T

198 lines
6.8 KiB
Go

package codingharness
import (
"context"
"encoding/json"
"os"
"os/exec"
"strings"
"time"
)
// Claude Code CLI probe states, from worst to best.
const (
ClaudeStateNotInstalled = "not-installed"
ClaudeStateAuthCheckFailed = "auth-check-failed"
ClaudeStateUnauthenticated = "unauthenticated"
ClaudeStateAuthenticated = "authenticated"
)
// ClaudeLoginCommand is the official interactive Claude Code login for a
// Claude (claude.ai) subscription. docker-agent never runs it implicitly:
// remediation messages print it, and the setup wizard only executes it after
// an explicit confirmation.
const ClaudeLoginCommand = "claude auth login --claudeai"
// ClaudeInstallDocsURL is where the Claude Code CLI install instructions live.
const ClaudeInstallDocsURL = "https://docs.anthropic.com/en/docs/claude-code"
const (
claudeBinary = "claude"
// claudeProbeTimeout bounds each CLI invocation of the probe so a hung
// `claude` process cannot stall doctor or setup.
claudeProbeTimeout = 15 * time.Second
)
// claudeLoginArgs is ClaudeLoginCommand as argv, kept next to the constant so
// the printed command and the executed one cannot drift apart.
var claudeLoginArgs = []string{"auth", "login", "--claudeai"}
// ClaudeCLIStatus reports the state of the locally installed Claude Code CLI.
// Only non-identifying fields of `claude auth status` are retained: the
// email, organization, and token material the command also reports are never
// parsed, stored, or printed.
type ClaudeCLIStatus struct {
State string `json:"state"`
Version string `json:"version,omitempty"`
AuthMethod string `json:"auth_method,omitempty"`
APIProvider string `json:"api_provider,omitempty"`
SubscriptionType string `json:"subscription_type,omitempty"`
// Detail explains the not-installed and auth-check-failed states. It only
// ever carries exec-level error strings (exit status, context timeout),
// never command output.
Detail string `json:"detail,omitempty"`
}
// Installed reports whether the `claude` binary was found in PATH.
func (s ClaudeCLIStatus) Installed() bool {
return s.State != "" && s.State != ClaudeStateNotInstalled
}
// Authenticated reports whether the CLI is logged in.
func (s ClaudeCLIStatus) Authenticated() bool {
return s.State == ClaudeStateAuthenticated
}
// AuthSummary renders the safe auth metadata as a short human-readable
// fragment, e.g. "auth: claude.ai, api: firstParty, subscription: pro".
func (s ClaudeCLIStatus) AuthSummary() string {
var parts []string
if s.AuthMethod != "" {
parts = append(parts, "auth: "+s.AuthMethod)
}
if s.APIProvider != "" {
parts = append(parts, "api: "+s.APIProvider)
}
if s.SubscriptionType != "" {
parts = append(parts, "subscription: "+s.SubscriptionType)
}
return strings.Join(parts, ", ")
}
// claudeAuthStatus mirrors only the non-identifying fields of
// `claude auth status --json`. The document also carries identity fields
// (email, orgId, orgName) that docker-agent must never retain; limiting the
// struct to safe fields makes that guarantee structural.
type claudeAuthStatus struct {
// LoggedIn is a pointer so a JSON document without the loggedIn key is
// distinguishable from loggedIn=false and rejected as unexpected output.
LoggedIn *bool `json:"loggedIn"`
AuthMethod string `json:"authMethod"`
APIProvider string `json:"apiProvider"`
SubscriptionType string `json:"subscriptionType"`
}
// ClaudeCLIProbe checks the installation and login state of the Claude Code
// CLI. The zero value probes the real `claude` binary from PATH; tests inject
// the seams.
type ClaudeCLIProbe struct {
// LookPath defaults to exec.LookPath.
LookPath func(file string) (string, error)
// RunOutput runs a command and returns its stdout. It defaults to a
// direct exec.CommandContext invocation (never a shell) bounded by
// Timeout.
RunOutput func(ctx context.Context, name string, args ...string) ([]byte, error)
// Timeout bounds each CLI invocation; defaults to claudeProbeTimeout.
Timeout time.Duration
}
// ProbeClaudeCLI probes the Claude Code CLI with the default seams. It never
// starts a login or a browser; it only observes.
func ProbeClaudeCLI(ctx context.Context) ClaudeCLIStatus {
return ClaudeCLIProbe{}.Probe(ctx)
}
// Probe distinguishes four states: the binary is not installed, the auth
// check could not be completed, the CLI is installed but not logged in, and
// the CLI is logged in.
func (p ClaudeCLIProbe) Probe(ctx context.Context) ClaudeCLIStatus {
lookPath := p.LookPath
if lookPath == nil {
lookPath = exec.LookPath
}
run := p.RunOutput
if run == nil {
run = p.runOutput
}
if _, err := lookPath(claudeBinary); err != nil {
return ClaudeCLIStatus{
State: ClaudeStateNotInstalled,
Detail: "the `claude` CLI was not found in PATH",
}
}
status := ClaudeCLIStatus{}
// A failed --version is not fatal on its own: the auth check below
// decides the state, and a broken binary fails there too.
if out, err := run(ctx, claudeBinary, "--version"); err == nil {
status.Version = firstLine(out)
}
out, runErr := run(ctx, claudeBinary, "auth", "status", "--json")
// `claude auth status` may exit non-zero when logged out, so trust the
// JSON whenever the output parses and report a failed check otherwise.
var auth claudeAuthStatus
if err := json.Unmarshal(out, &auth); err != nil || auth.LoggedIn == nil {
status.State = ClaudeStateAuthCheckFailed
if runErr != nil {
status.Detail = "`claude auth status` failed: " + runErr.Error()
} else {
status.Detail = "`claude auth status --json` returned unexpected output"
}
return status
}
if !*auth.LoggedIn {
status.State = ClaudeStateUnauthenticated
return status
}
status.State = ClaudeStateAuthenticated
status.AuthMethod = auth.AuthMethod
status.APIProvider = auth.APIProvider
status.SubscriptionType = auth.SubscriptionType
return status
}
func (p ClaudeCLIProbe) runOutput(ctx context.Context, name string, args ...string) ([]byte, error) {
timeout := p.Timeout
if timeout <= 0 {
timeout = claudeProbeTimeout
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
// Direct invocation: name and args reach the binary as-is, no shell.
return exec.CommandContext(ctx, name, args...).Output()
}
func firstLine(out []byte) string {
line, _, _ := strings.Cut(strings.TrimSpace(string(out)), "\n")
return strings.TrimSpace(line)
}
// RunClaudeLogin runs the official interactive Claude Code login
// (ClaudeLoginCommand) attached to this process's terminal, so the login
// lands in the exact OS user and environment docker-agent runs as. Callers
// must only invoke it after an explicit user confirmation: the command is
// interactive and opens a browser.
func RunClaudeLogin(ctx context.Context) error {
cmd := exec.CommandContext(ctx, claudeBinary, claudeLoginArgs...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}