1use std::io::IsTerminal;
2use std::path::PathBuf;
3
4use anyhow::Context;
5use clap::Args;
6use serde::{Deserialize, Serialize};
7
8use crate::config::Config;
9use crate::subprocess::Tool;
10
11#[derive(Debug, Args)]
12pub struct DoctorArgs {
13 #[arg(long)]
15 pub project_root: Option<PathBuf>,
16 #[arg(long)]
18 pub strict: bool,
19 #[arg(long, value_enum)]
21 pub format: Option<OutputFormat>,
22}
23
24#[derive(Debug, Clone, Copy, clap::ValueEnum)]
25pub enum OutputFormat {
26 Pretty,
27 Text,
28 Json,
29}
30
31#[derive(Debug, Serialize, Deserialize)]
32pub struct DoctorReport {
33 pub config: ConfigStatus,
34 pub tools: Vec<ToolStatus>,
35 pub project_files: Vec<FileStatus>,
36 pub issues: Vec<String>,
37 #[serde(skip_serializing_if = "Option::is_none")]
38 pub advice: Option<Vec<String>>,
39}
40
41#[derive(Debug, Serialize, Deserialize)]
42pub struct ConfigStatus {
43 pub project: String,
44 pub version: String,
45 pub agent: String,
46 pub channel: String,
47}
48
49#[derive(Debug, Serialize, Deserialize)]
50pub struct ToolStatus {
51 pub name: String,
52 pub enabled: bool,
53 pub version: Option<String>,
54 pub present: bool,
55}
56
57#[derive(Debug, Serialize, Deserialize)]
58pub struct FileStatus {
59 pub path: String,
60 pub exists: bool,
61}
62
63impl DoctorArgs {
64 pub fn execute(&self) -> anyhow::Result<()> {
65 let project_root = match self.project_root.clone() {
66 Some(p) => p,
67 None => std::env::current_dir().context("could not determine current directory")?,
68 };
69
70 let (config_path, config_dir) = crate::config::find_config_in_project(&project_root)
72 .map_err(|_| anyhow::anyhow!(
73 "no .edict.toml or .botbox.toml found at {} or ws/default/ — is this an edict project?",
74 project_root.display()
75 ))?;
76 let project_root = config_dir;
77 let config = Config::load(&config_path)?;
78
79 let format = self.format.unwrap_or_else(|| {
80 if std::io::stdout().is_terminal() {
81 OutputFormat::Pretty
82 } else {
83 OutputFormat::Text
84 }
85 });
86
87 let mut report = DoctorReport {
88 config: ConfigStatus {
89 project: config.project.name.clone(),
90 version: config.version.clone(),
91 agent: config.default_agent(),
92 channel: config.channel(),
93 },
94 tools: vec![],
95 project_files: vec![],
96 issues: vec![],
97 advice: None,
98 };
99
100 let pi_output = Tool::new("pi").arg("--version").run();
103 if let Ok(output) = pi_output {
104 report.tools.push(ToolStatus {
105 name: "pi (default runtime)".to_string(),
106 enabled: true,
107 version: Some(output.stdout.trim().to_string()),
108 present: true,
109 });
110 } else {
111 report.tools.push(ToolStatus {
112 name: "pi (default runtime)".to_string(),
113 enabled: true,
114 version: None,
115 present: false,
116 });
117 report
118 .issues
119 .push("Tool not found: pi (default agent runtime)".to_string());
120 }
121
122 let required_tools = vec![
123 ("bones (bn)", config.tools.bones, "bn"),
124 ("maw", config.tools.maw, "maw"),
125 ("crit", config.tools.crit, "crit"),
126 ("botbus (bus)", config.tools.botbus, "bus"),
127 ("vessel", config.tools.vessel, "vessel"),
128 ];
129
130 for (label, enabled, binary) in required_tools {
131 if enabled {
132 let version_output = Tool::new(binary).arg("--version").run();
133 if let Ok(output) = version_output {
134 report.tools.push(ToolStatus {
135 name: label.to_string(),
136 enabled: true,
137 version: Some(output.stdout.trim().to_string()),
138 present: true,
139 });
140 } else {
141 report.tools.push(ToolStatus {
142 name: label.to_string(),
143 enabled: true,
144 version: None,
145 present: false,
146 });
147 report.issues.push(format!("Tool not found: {}", binary));
148 }
149 } else {
150 report.tools.push(ToolStatus {
151 name: label.to_string(),
152 enabled: false,
153 version: None,
154 present: false,
155 });
156 }
157 }
158
159 let agents_dir = project_root.join(".agents/edict");
161 let agents_exists = agents_dir.exists();
162 report.project_files.push(FileStatus {
163 path: ".agents/edict".to_string(),
164 exists: agents_exists,
165 });
166
167 if !agents_exists {
168 report
169 .issues
170 .push(".agents/edict/ directory not found".to_string());
171 }
172
173 let agents_md = project_root.join("AGENTS.md");
174 let agents_md_exists = agents_md.exists();
175 report.project_files.push(FileStatus {
176 path: "AGENTS.md".to_string(),
177 exists: agents_md_exists,
178 });
179
180 if !agents_md_exists {
181 report.issues.push("AGENTS.md not found".to_string());
182 }
183
184 let claude_md = project_root.join("CLAUDE.md");
185 let claude_md_exists = claude_md.exists();
186 report.project_files.push(FileStatus {
187 path: "CLAUDE.md".to_string(),
188 exists: claude_md_exists,
189 });
190
191 if !claude_md_exists {
192 report
193 .issues
194 .push("CLAUDE.md symlink not found".to_string());
195 }
196
197 if self.strict && !report.issues.is_empty() {
199 report
200 .issues
201 .insert(0, "strict mode: found issues".to_string());
202 }
203
204 let issue_count = report.issues.len();
205
206 match format {
208 OutputFormat::Pretty => {
209 self.print_pretty(&report);
210 }
211 OutputFormat::Text => {
212 self.print_text(&report);
213 }
214 OutputFormat::Json => {
215 println!("{}", serde_json::to_string_pretty(&report)?);
216 }
217 }
218
219 if issue_count > 0 {
221 return Err(crate::error::ExitError::new(
222 std::cmp::min(issue_count, 125) as u8,
223 format!("{} issue(s) found", issue_count),
224 )
225 .into());
226 }
227
228 Ok(())
229 }
230
231 fn print_pretty(&self, report: &DoctorReport) {
232 println!("=== Botbox Doctor ===\n");
233 println!("Project: {}", report.config.project);
234 println!("Version: {}", report.config.version);
235 println!("Agent: {}", report.config.agent);
236 println!("Channel: {}", report.config.channel);
237 println!();
238
239 println!("Tools:");
240 for tool in &report.tools {
241 if tool.enabled {
242 if tool.present {
243 println!(
244 " ✓ {}: {}",
245 tool.name,
246 tool.version.as_ref().unwrap_or(&"OK".to_string())
247 );
248 } else {
249 println!(" ✗ {}: NOT FOUND", tool.name);
250 }
251 } else {
252 println!(" - {}: disabled", tool.name);
253 }
254 }
255
256 if !report.project_files.is_empty() {
257 println!("\nProject Files:");
258 for file in &report.project_files {
259 if file.exists {
260 println!(" ✓ {}", file.path);
261 } else {
262 println!(" ✗ {}", file.path);
263 }
264 }
265 }
266
267 if !report.issues.is_empty() {
268 println!("\nIssues ({}):", report.issues.len());
269 for issue in &report.issues {
270 println!(" • {}", issue);
271 }
272 } else {
273 println!("\n✓ No issues found");
274 }
275 }
276
277 fn print_text(&self, report: &DoctorReport) {
278 println!(
279 "edict-doctor project={} version={} agent={} channel={}",
280 report.config.project,
281 report.config.version,
282 report.config.agent,
283 report.config.channel
284 );
285
286 for tool in &report.tools {
287 let status = if !tool.enabled {
288 "disabled".to_string()
289 } else if tool.present {
290 format!("ok {}", tool.version.as_ref().unwrap_or(&String::new()))
291 } else {
292 "missing".to_string()
293 };
294 println!("tool {} {}", tool.name, status);
295 }
296
297 for file in &report.project_files {
298 let status = if file.exists { "ok" } else { "missing" };
299 println!("file {} {}", file.path, status);
300 }
301
302 if !report.issues.is_empty() {
303 println!("issues count={}", report.issues.len());
304 for issue in &report.issues {
305 println!("issue {}", issue);
306 }
307 }
308 }
309}