Skip to content

Commit e555770

Browse files
committed
feat: add Claude Code harness setup and diagnostics
1 parent 1ec6c9a commit e555770

17 files changed

Lines changed: 1646 additions & 62 deletions

File tree

agent-schema.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1011,6 +1011,7 @@
10111011
"low",
10121012
"medium",
10131013
"high",
1014+
"xhigh",
10141015
"max"
10151016
]
10161017
},

cmd/root/doctor.go

Lines changed: 142 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ import (
1515
"github.com/spf13/cobra"
1616

1717
"github.com/docker/docker-agent/pkg/chatgpt"
18+
"github.com/docker/docker-agent/pkg/codingharness"
1819
"github.com/docker/docker-agent/pkg/config"
20+
"github.com/docker/docker-agent/pkg/config/latest"
1921
"github.com/docker/docker-agent/pkg/environment"
2022
"github.com/docker/docker-agent/pkg/model/provider/dmr"
2123
"github.com/docker/docker-agent/pkg/telemetry"
@@ -83,6 +85,9 @@ type doctorAgentFileStatus struct {
8385
Ref string `json:"ref"`
8486
Requirements []doctorEnvRequirement `json:"requirements,omitempty"`
8587
ToolCheckError string `json:"tool_check_error,omitempty"`
88+
// ClaudeCode is only set when the agent file uses a claude-code harness.
89+
// It carries no identity or credential data (see ClaudeCLIStatus).
90+
ClaudeCode *codingharness.ClaudeCLIStatus `json:"claude_code_harness,omitempty"`
8691
}
8792

8893
type doctorEnvRequirement struct {
@@ -97,12 +102,14 @@ type doctorFlags struct {
97102
runConfig config.RuntimeConfig
98103

99104
// Test seams: sourcesForTests replaces the env-file + default secret-source
100-
// chain, dmrLister replaces dmr.ListModels, and loadUserConfig replaces
101-
// userconfig.Load so tests never exec `docker model` or credential helpers,
102-
// and never read the developer's real configuration.
105+
// chain, dmrLister replaces dmr.ListModels, loadUserConfig replaces
106+
// userconfig.Load, and claudeProbe replaces the Claude Code CLI probe, so
107+
// tests never exec `docker model`, `claude`, or credential helpers, and
108+
// never read the developer's real configuration.
103109
sourcesForTests []environment.Source
104110
dmrLister config.DMRModelLister
105111
loadUserConfig userConfigLoader
112+
claudeProbe func(ctx context.Context) codingharness.ClaudeCLIStatus
106113
}
107114

108115
type doctorCmdOption func(*doctorFlags)
@@ -123,6 +130,8 @@ Reports, without ever printing secret values:
123130
- whether Docker Model Runner is reachable and which models are pulled
124131
- which model the 'auto' selection would pick
125132
- with an agent file: the environment variables it requires and their status
133+
- with an agent file that uses a claude-code harness: whether the official
134+
'claude' CLI is installed and logged in (safe metadata only, no tokens)
126135
127136
Exits with a non-zero status when an issue would prevent an agent from running.`,
128137
Example: ` docker-agent doctor
@@ -242,6 +251,21 @@ func (f *doctorFlags) buildReport(ctx context.Context, agentRef string) (*doctor
242251
report.DMR = doctorDMRStatus{Status: dmrStatusUnreachable, Error: dmrErr.Error()}
243252
}
244253

254+
// Load the supplied agent file before the model diagnosis: when every
255+
// agent in it delegates to a coding harness, the file never uses Docker
256+
// Agent's auto model, so global model issues must not block it.
257+
var agentCfg *latest.Config
258+
if agentRef != "" {
259+
agentSource, err := config.Resolve(agentRef, env)
260+
if err != nil {
261+
return nil, err
262+
}
263+
if agentCfg, err = config.Load(ctx, agentSource); err != nil {
264+
return nil, err
265+
}
266+
}
267+
harnessOnly := agentCfg != nil && allAgentsHarnessBacked(agentCfg)
268+
245269
// Reuse the discovery results above instead of querying DMR a second time.
246270
lister := func(context.Context) ([]string, error) { return dmrModels, dmrErr }
247271
auto := config.AutoModelConfig(ctx, f.runConfig.ModelsGateway, env, f.runConfig.DefaultModel, lister)
@@ -257,6 +281,7 @@ func (f *doctorFlags) buildReport(ctx context.Context, agentRef string) (*doctor
257281
Usable: true,
258282
}
259283

284+
var autoIssues []string
260285
switch {
261286
case f.runConfig.ModelsGateway != "":
262287
autoStatus.Note = "credentials are supplied by the models gateway"
@@ -265,7 +290,7 @@ func (f *doctorFlags) buildReport(ctx context.Context, agentRef string) (*doctor
265290
if environment.IsTrustedDockerURL(f.runConfig.ModelsGateway) {
266291
if _, ok := findSource(ctx, sources, environment.DockerDesktopTokenEnv); !ok {
267292
autoStatus.Usable = false
268-
report.Issues = append(report.Issues,
293+
autoIssues = append(autoIssues,
269294
"the models gateway requires Docker Desktop sign-in and no DOCKER_TOKEN was found; sign in to Docker Desktop (check with `docker agent debug auth`)")
270295
}
271296
}
@@ -275,12 +300,12 @@ func (f *doctorFlags) buildReport(ctx context.Context, agentRef string) (*doctor
275300
switch {
276301
case dmrDown && fromDefault:
277302
autoStatus.Usable = false
278-
report.Issues = append(report.Issues, fmt.Sprintf(
303+
autoIssues = append(autoIssues, fmt.Sprintf(
279304
"the configured default model %s/%s needs Docker Model Runner, which is %s; install or start it (%s)",
280305
auto.Provider, auto.Model, describeDMRStatus(report.DMR.Status), dmrDocsURL))
281306
case dmrDown:
282307
autoStatus.Usable = false
283-
report.Issues = append(report.Issues, fmt.Sprintf(
308+
autoIssues = append(autoIssues, fmt.Sprintf(
284309
"no usable model: no provider credential was found and Docker Model Runner is %s; run `docker agent setup`, or set an API key for one of the providers above (%s) or install Docker Model Runner (%s)",
285310
describeDMRStatus(report.DMR.Status), environment.SecretsDocsURL, dmrDocsURL))
286311
case !slices.Contains(dmrModels, auto.Model):
@@ -292,35 +317,34 @@ func (f *doctorFlags) buildReport(ctx context.Context, agentRef string) (*doctor
292317
// selection never picks a cloud provider without credentials.
293318
if found, known := credFound[auto.Provider]; known && !found {
294319
autoStatus.Usable = false
295-
report.Issues = append(report.Issues, fmt.Sprintf(
320+
autoIssues = append(autoIssues, fmt.Sprintf(
296321
"the configured default model %s/%s has no credential for provider %s; %s (%s)",
297322
auto.Provider, auto.Model, auto.Provider, providerCredentialHint(auto.Provider, primaryEnvVar[auto.Provider]), environment.SecretsDocsURL))
298323
}
299324
}
325+
326+
// Auto-model problems only block runs that need a model from Docker
327+
// Agent. A harness-only file never does, so demote them to a note while
328+
// keeping Usable truthful in the report.
329+
if harnessOnly && len(autoIssues) > 0 {
330+
autoStatus.Note = fmt.Sprintf("not required: every agent in %s runs through a coding harness", agentRef)
331+
} else {
332+
report.Issues = append(report.Issues, autoIssues...)
333+
}
300334
report.AutoModel = autoStatus
301335

302-
if agentRef != "" {
303-
if err := f.checkAgentFile(ctx, agentRef, env, sources, report); err != nil {
304-
return nil, err
305-
}
336+
if agentCfg != nil {
337+
f.checkAgentFile(ctx, agentRef, agentCfg, env, sources, report)
306338
}
307339

308340
return report, nil
309341
}
310342

311343
// checkAgentFile reports the environment variables the agent configuration
312-
// requires (models and tools), whether each one is set, and from which source.
313-
func (f *doctorFlags) checkAgentFile(ctx context.Context, ref string, env environment.Provider, sources []environment.Source, report *doctorReport) error {
314-
agentSource, err := config.Resolve(ref, env)
315-
if err != nil {
316-
return err
317-
}
318-
319-
cfg, err := config.Load(ctx, agentSource)
320-
if err != nil {
321-
return err
322-
}
323-
344+
// requires (models and tools), whether each one is set, and from which
345+
// source. The configuration was already loaded by buildReport (which also
346+
// needs it for the harness-only check), so the file is never loaded twice.
347+
func (f *doctorFlags) checkAgentFile(ctx context.Context, ref string, cfg *latest.Config, env environment.Provider, sources []environment.Source, report *doctorReport) {
324348
status := &doctorAgentFileStatus{Ref: ref}
325349

326350
// first_available selectors resolve to the first candidate with
@@ -361,8 +385,71 @@ func (f *doctorFlags) checkAgentFile(ctx context.Context, ref string, env enviro
361385
ref, strings.Join(missing, ", "), environment.SecretsDocsURL))
362386
}
363387

388+
// The Claude Code harness runs the local `claude` CLI with its own login,
389+
// so its health is only relevant (and only probed) when the file actually
390+
// declares a claude-code harness agent.
391+
if agentsUseClaudeCodeHarness(cfg) {
392+
claudeStatus := f.probeClaudeCode(ctx)
393+
status.ClaudeCode = &claudeStatus
394+
if issue := claudeHarnessIssue(ref, claudeStatus); issue != "" {
395+
report.Issues = append(report.Issues, issue)
396+
}
397+
}
398+
364399
report.AgentFile = status
365-
return nil
400+
}
401+
402+
func (f *doctorFlags) probeClaudeCode(ctx context.Context) codingharness.ClaudeCLIStatus {
403+
if f.claudeProbe != nil {
404+
return f.claudeProbe(ctx)
405+
}
406+
return codingharness.ProbeClaudeCLI(ctx)
407+
}
408+
409+
// agentsUseClaudeCodeHarness reports whether at least one agent in the
410+
// configuration delegates its work to a claude-code harness.
411+
func agentsUseClaudeCodeHarness(cfg *latest.Config) bool {
412+
for _, agent := range cfg.Agents {
413+
if agent.Harness != nil && agent.Harness.Type == codingharness.TypeClaudeCode {
414+
return true
415+
}
416+
}
417+
return false
418+
}
419+
420+
// allAgentsHarnessBacked reports whether every agent in the configuration
421+
// delegates its work to a coding harness. Such a file never uses a Docker
422+
// Agent model provider, so the auto model selection is irrelevant to it.
423+
func allAgentsHarnessBacked(cfg *latest.Config) bool {
424+
if len(cfg.Agents) == 0 {
425+
return false
426+
}
427+
for _, agent := range cfg.Agents {
428+
if agent.Harness == nil {
429+
return false
430+
}
431+
}
432+
return true
433+
}
434+
435+
// claudeHarnessIssue phrases the report issue for a Claude Code harness that
436+
// is not ready to run, including how to fix it. The login always has to
437+
// happen as the OS user and environment that run docker-agent, because the
438+
// harness inherits the CLI's per-user credentials.
439+
func claudeHarnessIssue(ref string, status codingharness.ClaudeCLIStatus) string {
440+
switch status.State {
441+
case codingharness.ClaudeStateNotInstalled:
442+
return fmt.Sprintf("%s uses a claude-code harness but the `claude` CLI was not found in PATH; install Claude Code (%s) and log in with `%s`",
443+
ref, codingharness.ClaudeInstallDocsURL, codingharness.ClaudeLoginCommand)
444+
case codingharness.ClaudeStateAuthCheckFailed:
445+
return fmt.Sprintf("%s uses a claude-code harness but the Claude Code login could not be verified (%s); run `claude auth status` as the same OS user and environment that run docker-agent",
446+
ref, status.Detail)
447+
case codingharness.ClaudeStateUnauthenticated:
448+
return fmt.Sprintf("%s uses a claude-code harness but the `claude` CLI is not logged in; run `%s` as the same OS user and environment that run docker-agent",
449+
ref, codingharness.ClaudeLoginCommand)
450+
default:
451+
return ""
452+
}
366453
}
367454

368455
// secretSources returns the labeled secret sources in the same precedence
@@ -494,6 +581,7 @@ func printDoctorReport(w io.Writer, report *doctorReport) {
494581
if af.ToolCheckError != "" {
495582
fmt.Fprintf(w, " Warning: %s\n", af.ToolCheckError)
496583
}
584+
printClaudeHarnessStatus(w, af.ClaudeCode)
497585
}
498586

499587
if len(report.Issues) == 0 {
@@ -519,3 +607,33 @@ func credentialCandidates(envVars []string) string {
519607
return fmt.Sprintf("%s (+%d more)", envVars[0], len(envVars)-1)
520608
}
521609
}
610+
611+
// printClaudeHarnessStatus renders the Claude Code harness section of the
612+
// report: installation, version, and safe login metadata, with the
613+
// remediation for each unhealthy state.
614+
func printClaudeHarnessStatus(w io.Writer, status *codingharness.ClaudeCLIStatus) {
615+
if status == nil {
616+
return
617+
}
618+
619+
version := status.Version
620+
if version == "" {
621+
version = "unknown version"
622+
}
623+
624+
fmt.Fprintln(w, "\nClaude Code harness")
625+
switch status.State {
626+
case codingharness.ClaudeStateAuthenticated:
627+
fmt.Fprintf(w, " Status: logged in (%s)\n", status.AuthSummary())
628+
fmt.Fprintf(w, " Version: %s\n", version)
629+
case codingharness.ClaudeStateUnauthenticated:
630+
fmt.Fprintf(w, " Status: installed (%s), not logged in\n", version)
631+
fmt.Fprintf(w, " Log in with `%s` as the same OS user and environment that run docker-agent.\n", codingharness.ClaudeLoginCommand)
632+
case codingharness.ClaudeStateNotInstalled:
633+
fmt.Fprintln(w, " Status: the `claude` CLI was not found in PATH")
634+
fmt.Fprintf(w, " Install Claude Code (%s) and log in with `%s`.\n", codingharness.ClaudeInstallDocsURL, codingharness.ClaudeLoginCommand)
635+
default: // auth-check-failed
636+
fmt.Fprintf(w, " Status: installed (%s), login check failed: %s\n", version, status.Detail)
637+
fmt.Fprintln(w, " Run `claude auth status` as the same OS user and environment that run docker-agent.")
638+
}
639+
}

0 commit comments

Comments
 (0)