b331dcd61a
Plan B from docs/superpowers/plans/2026-05-19-post-slm-unlock.md.
Users with aliased CLI binaries (claude-priv, claude-work,
gemini-personal) can now point gnoma's auto-discovery at them
without renaming. The override flows through to the actual subprocess
spawn at internal/provider/subprocess/provider.go:56, so routing
through the alias is functional, not cosmetic.
Config:
[cli_agents]
claude = "claude-priv" # discovery uses claude-priv instead of claude
gemini = "" # empty value = no override (fall back to canonical)
# vibe is absent = canonical name used
- internal/config/config.go: CLIAgentsSection map[string]string;
TOML [cli_agents] key.
- internal/provider/subprocess/agent.go:
- Package-level lookPath = exec.LookPath for test injection.
- resolveAgentBinary(canonical, override) → (path, binName, err).
Override='' falls back to canonical. Override set but missing from
PATH returns an error (no silent fallback — masks user typos).
- DiscoveredAgent.OverrideBinary records the override binary name
when one was used; empty otherwise.
- DiscoverCLIAgents(ctx, overrides) signature; warning logged when
an override is configured but the binary isn't on PATH.
- cmd/gnoma/main.go: both call sites pass cfg.CLIAgents. The
`gnoma providers` listing renders `claude-priv (via [cli_agents].claude)`
when an override is in effect.
Tests cover: 5 resolver cases (no override, override set, empty
override falls back, override missing, canonical missing); 4
discovery cases (no overrides, override resolves alias, empty value
falls back, override missing skips agent); 2 config round-trip cases.
230 lines
6.3 KiB
Go
230 lines
6.3 KiB
Go
package subprocess
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"os/exec"
|
|
"sort"
|
|
"sync"
|
|
"time"
|
|
|
|
"somegit.dev/Owlibou/gnoma/internal/provider"
|
|
)
|
|
|
|
// lookPath is package-level for test override. Defaults to exec.LookPath.
|
|
// Tests that swap this must NOT call t.Parallel() and should restore via
|
|
// t.Cleanup().
|
|
var lookPath = exec.LookPath
|
|
|
|
// StreamFormat identifies the line-delimited JSON format a CLI agent emits.
|
|
type StreamFormat string
|
|
|
|
const (
|
|
FormatClaudeStreamJSON StreamFormat = "claude-stream-json"
|
|
FormatGeminiStreamJSON StreamFormat = "gemini-stream-json"
|
|
FormatVibeStreaming StreamFormat = "vibe-streaming"
|
|
)
|
|
|
|
// CLIAgent describes a known CLI agent binary.
|
|
type CLIAgent struct {
|
|
Name string
|
|
DisplayName string
|
|
ProbeArgs []string // args to fetch version (e.g. ["--version"])
|
|
PromptArgs func(string) []string // build argv for a non-interactive prompt run
|
|
Format StreamFormat
|
|
Capabilities provider.Capabilities
|
|
}
|
|
|
|
// DiscoveredAgent is a CLIAgent confirmed present on PATH with its resolved path.
|
|
type DiscoveredAgent struct {
|
|
CLIAgent
|
|
Path string
|
|
Version string
|
|
// OverrideBinary is the binary name from [cli_agents].<name>=<value> when
|
|
// an override caused this agent to resolve to a non-canonical binary.
|
|
// Empty when the canonical binary name was used.
|
|
OverrideBinary string
|
|
}
|
|
|
|
// knownAgents is the registry of CLI agents Gnoma supports.
|
|
var knownAgents = []CLIAgent{
|
|
{
|
|
Name: "claude",
|
|
DisplayName: "Claude Code",
|
|
ProbeArgs: []string{"--version"},
|
|
PromptArgs: func(p string) []string {
|
|
return []string{"-p", p, "--output-format", "stream-json", "--verbose"}
|
|
},
|
|
Format: FormatClaudeStreamJSON,
|
|
// ToolUse=true: the claude CLI is a full agent with its own tool loop.
|
|
// This is a routing capability flag, not a provider-layer capability.
|
|
Capabilities: provider.Capabilities{
|
|
ToolUse: true,
|
|
ContextWindow: 200000,
|
|
},
|
|
},
|
|
{
|
|
Name: "gemini",
|
|
DisplayName: "Gemini CLI",
|
|
ProbeArgs: []string{"--version"},
|
|
PromptArgs: func(p string) []string {
|
|
return []string{"-p", p, "--output-format", "stream-json", "--yolo"}
|
|
},
|
|
Format: FormatGeminiStreamJSON,
|
|
Capabilities: provider.Capabilities{
|
|
ToolUse: true,
|
|
ContextWindow: 1048576,
|
|
},
|
|
},
|
|
{
|
|
Name: "vibe",
|
|
DisplayName: "Mistral Vibe",
|
|
ProbeArgs: []string{"--version"},
|
|
PromptArgs: func(p string) []string {
|
|
return []string{"-p", p, "--output", "streaming", "--trust"}
|
|
},
|
|
Format: FormatVibeStreaming,
|
|
Capabilities: provider.Capabilities{
|
|
ToolUse: true,
|
|
ContextWindow: 128000,
|
|
},
|
|
},
|
|
}
|
|
|
|
// newParser returns a FormatParser for the given format.
|
|
func newParser(f StreamFormat) FormatParser {
|
|
switch f {
|
|
case FormatClaudeStreamJSON:
|
|
return newClaudeParser()
|
|
case FormatGeminiStreamJSON:
|
|
return newGeminiParser()
|
|
case FormatVibeStreaming:
|
|
return newVibeParser()
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// resolveAgentBinary picks the binary name to look up for an agent and
|
|
// resolves it on PATH. If override is non-empty, only the override is tried
|
|
// (a missing overridden binary returns an error — we do not silently fall
|
|
// back to the canonical name, since that would mask a user typo). If
|
|
// override is empty, the canonical name is used.
|
|
//
|
|
// Returns the resolved absolute path, the binary name actually used, and an
|
|
// error if PATH lookup failed.
|
|
func resolveAgentBinary(canonical, override string) (resolvedPath, binName string, err error) {
|
|
binName = canonical
|
|
if override != "" {
|
|
binName = override
|
|
}
|
|
resolvedPath, err = lookPath(binName)
|
|
if err != nil {
|
|
return "", binName, fmt.Errorf("lookpath %q: %w", binName, err)
|
|
}
|
|
return resolvedPath, binName, nil
|
|
}
|
|
|
|
// DiscoverCLIAgents scans PATH for known CLI agents in parallel and returns the
|
|
// ones that are present and respond to their probe command.
|
|
//
|
|
// overrides maps canonical agent names to override binary names — see
|
|
// config.CLIAgentsSection. An empty or nil map disables overrides entirely.
|
|
// An empty value for a key (e.g. claude="") is treated as "no override":
|
|
// the canonical name is used.
|
|
func DiscoverCLIAgents(ctx context.Context, overrides map[string]string) []DiscoveredAgent {
|
|
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
defer cancel()
|
|
|
|
var mu sync.Mutex
|
|
var found []DiscoveredAgent
|
|
var wg sync.WaitGroup
|
|
sem := make(chan struct{}, 4)
|
|
|
|
for _, agent := range knownAgents {
|
|
override := overrides[agent.Name]
|
|
path, binName, err := resolveAgentBinary(agent.Name, override)
|
|
if err != nil {
|
|
// Only warn when the user explicitly set an override; a missing
|
|
// canonical binary is the common "user doesn't have this agent
|
|
// installed" case and shouldn't be noisy.
|
|
if override != "" {
|
|
slog.Warn("cli_agents override binary not on PATH",
|
|
"agent", agent.Name,
|
|
"override", override,
|
|
"error", err,
|
|
)
|
|
}
|
|
continue
|
|
}
|
|
recordedOverride := ""
|
|
if binName != agent.Name {
|
|
recordedOverride = binName
|
|
}
|
|
wg.Add(1)
|
|
sem <- struct{}{}
|
|
go func(a CLIAgent, p, ov string) {
|
|
defer wg.Done()
|
|
defer func() { <-sem }()
|
|
version := probeAgentVersion(ctx, p, a.ProbeArgs)
|
|
mu.Lock()
|
|
found = append(found, DiscoveredAgent{
|
|
CLIAgent: a,
|
|
Path: p,
|
|
Version: version,
|
|
OverrideBinary: ov,
|
|
})
|
|
mu.Unlock()
|
|
}(agent, path, recordedOverride)
|
|
}
|
|
wg.Wait()
|
|
|
|
// Stable order: match knownAgents ordering.
|
|
order := make(map[string]int, len(knownAgents))
|
|
for i, a := range knownAgents {
|
|
order[a.Name] = i
|
|
}
|
|
sort.Slice(found, func(i, j int) bool {
|
|
return order[found[i].Name] < order[found[j].Name]
|
|
})
|
|
return found
|
|
}
|
|
|
|
func probeAgentVersion(ctx context.Context, path string, args []string) string {
|
|
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(ctx, path, args...)
|
|
out, err := cmd.Output()
|
|
if err != nil && len(out) == 0 {
|
|
return ""
|
|
}
|
|
// Return the first non-empty line.
|
|
for _, b := range splitNL(out) {
|
|
if len(b) > 0 {
|
|
return string(b)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// splitNL splits bytes by newlines, trimming carriage returns.
|
|
func splitNL(b []byte) [][]byte {
|
|
var lines [][]byte
|
|
start := 0
|
|
for i, c := range b {
|
|
if c == '\n' {
|
|
line := b[start:i]
|
|
if len(line) > 0 && line[len(line)-1] == '\r' {
|
|
line = line[:len(line)-1]
|
|
}
|
|
lines = append(lines, line)
|
|
start = i + 1
|
|
}
|
|
}
|
|
if start < len(b) {
|
|
lines = append(lines, b[start:])
|
|
}
|
|
return lines
|
|
}
|