Skip to content

Commit ea8aaad

Browse files
jstar0AbirAbbasclaude
authored
feat(harness): add Python provider preflight (#756)
* feat(harness): add Python provider preflight (#685) * fix(harness): restore Python 3.10 CI compatibility * fix(harness): align claude-code doctor spec with Python SDK, trim doc, KeyError fallback Address the three unresolved review threads on #756: - docs/harness-providers.md: drop the TypeScript/Go doctor examples — those APIs (agent.harnessDoctor, harness.Doctor) do not exist yet. Document the Python SDK + af CLI surface that actually ships and note TS/Go as planned follow-ups of #685. - control-plane/internal/cli/harness_doctor.go: the claude-code row now mirrors the Python doctor (_doctor.py::_claude_health). It probes a Python interpreter for the claude_agent_sdk pip package (which bundles its own CLI) instead of looking for a global `claude` binary, and hints `pip install 'agentfield[harness-claude]'` instead of npm. Interpreter candidates (python3/python/py) are run-probed so dead launcher stubs are skipped; issues are wrapper_not_installed / python_not_found. - sdk/python/agentfield/harness/_availability.py: provider_unavailable() falls back to a generic ProviderSpec via .get() so providers without a PROVIDER_SPECS entry (claude-code, or any future provider) raise the helpful HarnessProviderUnavailable instead of a bare KeyError. Regression test added for both provider_unavailable and ensure_cli_available. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Abir Abbas <abirabbas1998@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 7af0fbf commit ea8aaad

23 files changed

Lines changed: 1082 additions & 51 deletions
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"os"
8+
"os/exec"
9+
"strings"
10+
"time"
11+
12+
"github.com/spf13/cobra"
13+
)
14+
15+
// HarnessProviderHealth is the machine-readable result of a provider preflight.
16+
type HarnessProviderHealth struct {
17+
Provider string `json:"provider"`
18+
Binary string `json:"binary,omitempty"`
19+
Installed bool `json:"installed"`
20+
Version string `json:"version,omitempty"`
21+
Auth string `json:"auth"`
22+
Usable bool `json:"usable"`
23+
InstallCommand string `json:"install_command"`
24+
AuthEnvVars []string `json:"auth_env_vars"`
25+
Issues []string `json:"issues"`
26+
}
27+
28+
type harnessProviderSpec struct {
29+
Name string
30+
Binary string
31+
InstallCommand string
32+
AuthEnvVars []string
33+
}
34+
35+
var harnessProviderSpecs = []harnessProviderSpec{
36+
// claude-code has no Binary: the Python provider runs on the
37+
// claude_agent_sdk pip package (which bundles its own CLI), not on a
38+
// globally installed `claude` binary. See claudeCodeHealth.
39+
{Name: "claude-code", InstallCommand: "pip install 'agentfield[harness-claude]'", AuthEnvVars: []string{"ANTHROPIC_API_KEY"}},
40+
{Name: "codex", Binary: "codex", InstallCommand: "npm install -g @openai/codex", AuthEnvVars: []string{"OPENAI_API_KEY"}},
41+
{Name: "gemini", Binary: "gemini", InstallCommand: "npm install -g @google/gemini-cli", AuthEnvVars: []string{"GEMINI_API_KEY", "GOOGLE_API_KEY"}},
42+
{Name: "opencode", Binary: "opencode", InstallCommand: "curl -fsSL https://opencode.ai/install | bash", AuthEnvVars: []string{}},
43+
}
44+
45+
// NewHarnessCommand builds harness-related environment checks.
46+
func NewHarnessCommand() *cobra.Command {
47+
cmd := &cobra.Command{
48+
Use: "harness",
49+
Short: "Inspect and manage coding-agent harness providers",
50+
SilenceUsage: true,
51+
SilenceErrors: true,
52+
}
53+
cmd.AddCommand(newHarnessDoctorCommand())
54+
return cmd
55+
}
56+
57+
func newHarnessDoctorCommand() *cobra.Command {
58+
var providers []string
59+
var jsonOut bool
60+
cmd := &cobra.Command{
61+
Use: "doctor",
62+
Short: "Verify harness provider binaries, versions, and authentication",
63+
RunE: func(cmd *cobra.Command, args []string) error {
64+
reports, err := buildHarnessDoctorReports(providers)
65+
if err != nil {
66+
return err
67+
}
68+
if jsonOut {
69+
enc := json.NewEncoder(cmd.OutOrStdout())
70+
enc.SetIndent("", " ")
71+
if err := enc.Encode(reports); err != nil {
72+
return err
73+
}
74+
} else {
75+
printHarnessDoctorReports(cmd, reports)
76+
}
77+
for _, report := range reports {
78+
if !report.Usable {
79+
return fmt.Errorf("requested harness provider is unavailable: %s", report.Provider)
80+
}
81+
}
82+
return nil
83+
},
84+
}
85+
cmd.Flags().StringSliceVar(&providers, "provider", nil, "Provider(s) to check: claude-code, codex, gemini, opencode")
86+
cmd.Flags().BoolVar(&jsonOut, "json", false, "Output structured JSON")
87+
return cmd
88+
}
89+
90+
func buildHarnessDoctorReports(requested []string) ([]HarnessProviderHealth, error) {
91+
selected := map[string]bool{}
92+
for _, name := range requested {
93+
selected[strings.TrimSpace(name)] = true
94+
}
95+
if len(selected) > 0 {
96+
for name := range selected {
97+
if findHarnessProviderSpec(name) == nil {
98+
return nil, fmt.Errorf("unknown harness provider %q", name)
99+
}
100+
}
101+
}
102+
103+
reports := make([]HarnessProviderHealth, 0, len(harnessProviderSpecs))
104+
for _, spec := range harnessProviderSpecs {
105+
if len(selected) > 0 && !selected[spec.Name] {
106+
continue
107+
}
108+
if spec.Name == "claude-code" {
109+
reports = append(reports, claudeCodeHealth(spec))
110+
continue
111+
}
112+
tool := checkTool(spec.Binary, "--version")
113+
issues := []string{}
114+
if !tool.Available {
115+
issues = append(issues, "binary_not_found")
116+
} else if tool.Version == "" {
117+
issues = append(issues, "version_probe_failed")
118+
}
119+
reports = append(reports, HarnessProviderHealth{
120+
Provider: spec.Name,
121+
Binary: tool.Path,
122+
Installed: tool.Available,
123+
Version: tool.Version,
124+
Auth: harnessAuthStatus(spec.AuthEnvVars),
125+
Usable: tool.Available && tool.Version != "",
126+
InstallCommand: spec.InstallCommand,
127+
AuthEnvVars: append([]string{}, spec.AuthEnvVars...),
128+
Issues: issues,
129+
})
130+
}
131+
return reports, nil
132+
}
133+
134+
// claudeWrapperProbe asks a Python interpreter whether the claude_agent_sdk
135+
// package is importable, exiting zero either way so a non-zero exit always
136+
// means the interpreter itself is unusable.
137+
const claudeWrapperProbe = "import importlib.util, sys; sys.stdout.write('ok' if importlib.util.find_spec('claude_agent_sdk') else 'missing')"
138+
139+
var pythonInterpreterCandidates = []string{"python3", "python", "py"}
140+
141+
// claudeCodeHealth mirrors the Python doctor's semantics for claude-code
142+
// (sdk/python/agentfield/harness/_doctor.py::_claude_health): the provider is
143+
// usable when the claude_agent_sdk pip package is importable — it bundles its
144+
// own CLI — so a globally installed `claude` binary is neither necessary nor
145+
// sufficient.
146+
func claudeCodeHealth(spec harnessProviderSpec) HarnessProviderHealth {
147+
installed, pythonFound := probeClaudeWrapper()
148+
issues := []string{}
149+
if !pythonFound {
150+
issues = append(issues, "python_not_found")
151+
} else if !installed {
152+
issues = append(issues, "wrapper_not_installed")
153+
}
154+
return HarnessProviderHealth{
155+
Provider: spec.Name,
156+
Installed: installed,
157+
Auth: harnessAuthStatus(spec.AuthEnvVars),
158+
Usable: installed,
159+
InstallCommand: spec.InstallCommand,
160+
AuthEnvVars: append([]string{}, spec.AuthEnvVars...),
161+
Issues: issues,
162+
}
163+
}
164+
165+
// probeClaudeWrapper reports whether claude_agent_sdk is importable and
166+
// whether a working Python interpreter was found at all. Candidates are
167+
// run-probed rather than merely resolved on PATH because dead launcher stubs
168+
// (e.g. the Windows Store python3 alias) resolve but fail when executed.
169+
func probeClaudeWrapper() (installed bool, pythonFound bool) {
170+
for _, interpreter := range pythonInterpreterCandidates {
171+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
172+
out, err := exec.CommandContext(ctx, interpreter, "-c", claudeWrapperProbe).Output()
173+
cancel()
174+
if err != nil {
175+
continue
176+
}
177+
switch strings.TrimSpace(string(out)) {
178+
case "ok":
179+
return true, true
180+
case "missing":
181+
return false, true
182+
}
183+
}
184+
return false, false
185+
}
186+
187+
func harnessAuthStatus(envVars []string) string {
188+
for _, envVar := range envVars {
189+
if strings.TrimSpace(os.Getenv(envVar)) != "" {
190+
return "configured"
191+
}
192+
}
193+
return "unknown"
194+
}
195+
196+
func findHarnessProviderSpec(name string) *harnessProviderSpec {
197+
for i := range harnessProviderSpecs {
198+
if harnessProviderSpecs[i].Name == name {
199+
return &harnessProviderSpecs[i]
200+
}
201+
}
202+
return nil
203+
}
204+
205+
func printHarnessDoctorReports(cmd *cobra.Command, reports []HarnessProviderHealth) {
206+
for _, report := range reports {
207+
status := "ready"
208+
if !report.Usable {
209+
status = "unavailable"
210+
}
211+
fmt.Fprintf(cmd.OutOrStdout(), "%s: %s", report.Provider, status)
212+
if report.Version != "" {
213+
fmt.Fprintf(cmd.OutOrStdout(), " (%s)", report.Version)
214+
}
215+
fmt.Fprintln(cmd.OutOrStdout())
216+
if !report.Installed {
217+
fmt.Fprintf(cmd.OutOrStdout(), " install: %s\n", report.InstallCommand)
218+
}
219+
}
220+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
package cli
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"os"
7+
"path/filepath"
8+
"runtime"
9+
"testing"
10+
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
func TestHarnessDoctorJSONReportsRequestedProvider(t *testing.T) {
15+
binDir := t.TempDir()
16+
writeHarnessTestBinary(t, binDir, "codex", "codex-cli 1.2.3")
17+
t.Setenv("PATH", binDir)
18+
t.Setenv("OPENAI_API_KEY", "configured")
19+
20+
cmd := NewHarnessCommand()
21+
var stdout bytes.Buffer
22+
cmd.SetOut(&stdout)
23+
cmd.SetArgs([]string{"doctor", "--provider", "codex", "--json"})
24+
25+
require.NoError(t, cmd.Execute())
26+
var reports []HarnessProviderHealth
27+
require.NoError(t, json.Unmarshal(stdout.Bytes(), &reports))
28+
require.Equal(t, []HarnessProviderHealth{{
29+
Provider: "codex",
30+
Binary: filepath.Join(binDir, "codex"),
31+
Installed: true,
32+
Version: "codex-cli 1.2.3",
33+
Auth: "configured",
34+
Usable: true,
35+
InstallCommand: "npm install -g @openai/codex",
36+
AuthEnvVars: []string{"OPENAI_API_KEY"},
37+
Issues: []string{},
38+
}}, reports)
39+
}
40+
41+
func TestHarnessDoctorReturnsErrorForRequestedMissingProvider(t *testing.T) {
42+
t.Setenv("PATH", t.TempDir())
43+
cmd := NewHarnessCommand()
44+
var stdout bytes.Buffer
45+
cmd.SetOut(&stdout)
46+
cmd.SetArgs([]string{"doctor", "--provider", "opencode", "--json"})
47+
48+
err := cmd.Execute()
49+
require.ErrorContains(t, err, "requested harness provider is unavailable")
50+
51+
var reports []HarnessProviderHealth
52+
require.NoError(t, json.Unmarshal(stdout.Bytes(), &reports))
53+
require.False(t, reports[0].Installed)
54+
require.False(t, reports[0].Usable)
55+
require.Equal(t, []string{"binary_not_found"}, reports[0].Issues)
56+
}
57+
58+
func TestHarnessDoctorClaudeCodeReportsInstalledWrapper(t *testing.T) {
59+
binDir := t.TempDir()
60+
// Stub interpreter standing in for `python3 -c <probe>`: prints "ok" as the
61+
// probe does when claude_agent_sdk is importable.
62+
writeHarnessTestBinary(t, binDir, "python3", "ok")
63+
t.Setenv("PATH", binDir)
64+
t.Setenv("ANTHROPIC_API_KEY", "configured")
65+
66+
cmd := NewHarnessCommand()
67+
var stdout bytes.Buffer
68+
cmd.SetOut(&stdout)
69+
cmd.SetArgs([]string{"doctor", "--provider", "claude-code", "--json"})
70+
71+
require.NoError(t, cmd.Execute())
72+
var reports []HarnessProviderHealth
73+
require.NoError(t, json.Unmarshal(stdout.Bytes(), &reports))
74+
require.Equal(t, []HarnessProviderHealth{{
75+
Provider: "claude-code",
76+
Installed: true,
77+
Auth: "configured",
78+
Usable: true,
79+
InstallCommand: "pip install 'agentfield[harness-claude]'",
80+
AuthEnvVars: []string{"ANTHROPIC_API_KEY"},
81+
Issues: []string{},
82+
}}, reports)
83+
}
84+
85+
func TestHarnessDoctorClaudeCodeReportsMissingWrapper(t *testing.T) {
86+
binDir := t.TempDir()
87+
// Interpreter is present but claude_agent_sdk is not importable.
88+
writeHarnessTestBinary(t, binDir, "python3", "missing")
89+
t.Setenv("PATH", binDir)
90+
91+
cmd := NewHarnessCommand()
92+
var stdout bytes.Buffer
93+
cmd.SetOut(&stdout)
94+
cmd.SetArgs([]string{"doctor", "--provider", "claude-code", "--json"})
95+
96+
err := cmd.Execute()
97+
require.ErrorContains(t, err, "requested harness provider is unavailable: claude-code")
98+
99+
var reports []HarnessProviderHealth
100+
require.NoError(t, json.Unmarshal(stdout.Bytes(), &reports))
101+
require.False(t, reports[0].Installed)
102+
require.False(t, reports[0].Usable)
103+
require.Equal(t, []string{"wrapper_not_installed"}, reports[0].Issues)
104+
require.Equal(t, "pip install 'agentfield[harness-claude]'", reports[0].InstallCommand)
105+
}
106+
107+
func TestHarnessDoctorClaudeCodeReportsMissingPython(t *testing.T) {
108+
t.Setenv("PATH", t.TempDir())
109+
110+
cmd := NewHarnessCommand()
111+
var stdout bytes.Buffer
112+
cmd.SetOut(&stdout)
113+
cmd.SetArgs([]string{"doctor", "--provider", "claude-code", "--json"})
114+
115+
err := cmd.Execute()
116+
require.ErrorContains(t, err, "requested harness provider is unavailable: claude-code")
117+
118+
var reports []HarnessProviderHealth
119+
require.NoError(t, json.Unmarshal(stdout.Bytes(), &reports))
120+
require.False(t, reports[0].Installed)
121+
require.False(t, reports[0].Usable)
122+
require.Equal(t, []string{"python_not_found"}, reports[0].Issues)
123+
}
124+
125+
func TestHarnessDoctorRejectsUnknownProvider(t *testing.T) {
126+
cmd := NewHarnessCommand()
127+
cmd.SetArgs([]string{"doctor", "--provider", "unknown"})
128+
require.ErrorContains(t, cmd.Execute(), "unknown harness provider")
129+
}
130+
131+
func writeHarnessTestBinary(t *testing.T, dir, name, version string) {
132+
t.Helper()
133+
if runtime.GOOS == "windows" {
134+
t.Skip("shell fixture is Unix-only")
135+
}
136+
path := filepath.Join(dir, name)
137+
require.NoError(t, os.WriteFile(path, []byte("#!/bin/sh\nprintf '%s\\n' '"+version+"'\n"), 0o755))
138+
}

control-plane/internal/cli/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ AI Agent? Run "af agent help" for structured JSON output optimized for programma
9898

9999
// Add doctor command — environment introspection for skills/coding agents
100100
RootCmd.AddCommand(NewDoctorCommand())
101+
RootCmd.AddCommand(NewHarnessCommand())
101102

102103
// Add skill command — install/manage AgentField skills across coding agents
103104
RootCmd.AddCommand(NewSkillCommand())

0 commit comments

Comments
 (0)