@@ -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
8893type 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
108115type 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
127136Exits 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 , "\n Claude 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