1use std::collections::{BTreeMap, BTreeSet};
4use std::path::{Path, PathBuf};
5
6use crate::doctor::{
7 ApplePlatform, CheckStatus, apple_container_runtime_check, current_apple_platform,
8 local_docker_runtime_check, local_podman_runtime_check,
9};
10use crate::targets::{CommandExecutor, CommandSpec};
11use mj_core::config::{
12 Config, ContainerTemplate, HarnessHost, HarnessKind, HarnessProfile, ProjectBundle,
13 ProjectRepository, TargetTemplate, unique_config_id as unique_id,
14};
15
16mod startup;
17pub use startup::{SetupReport, actionable_errors, run_setup, run_setup_command};
18
19pub use mj_client::target::DEFAULT_IMAGE;
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct DiscoveredHome {
27 pub kind: HarnessKind,
28 pub path: PathBuf,
29 pub authenticated: bool,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct GithubRepository {
34 pub owner: String,
35 pub repository: String,
36}
37
38impl GithubRepository {
39 fn source(&self) -> String {
40 format!("{}/{}", self.owner, self.repository)
41 }
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum RuntimeKind {
46 Podman,
47 Docker,
48 AppleContainer,
49}
50
51impl RuntimeKind {
52 pub fn label(self) -> &'static str {
53 match self {
54 Self::Podman => "Podman",
55 Self::Docker => "Docker",
56 Self::AppleContainer => "Apple container",
57 }
58 }
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct RuntimeProbe {
63 pub kind: RuntimeKind,
64 pub status: CheckStatus,
65 pub detail: String,
66 pub remediation: Option<String>,
69}
70
71impl RuntimeProbe {
72 pub fn usable(&self) -> bool {
73 self.status == CheckStatus::Ready
74 }
75}
76
77pub fn discover_profiles(executor: &impl CommandExecutor) -> Vec<DiscoveredHome> {
80 let home = dirs::home_dir();
81 let overrides = HarnessKind::ALL
82 .into_iter()
83 .filter_map(|kind| {
84 std::env::var_os(kind.home_env()).map(|path| (kind, kind.home_from_environment(path)))
85 })
86 .collect::<BTreeMap<_, _>>();
87 let mut homes =
88 discover_harness_homes_with_executor(home.as_deref(), overrides.clone(), executor);
89 discover_installed_harnesses(home.as_deref(), &overrides, &mut homes, executor);
90 homes
91}
92
93pub fn discover_runtimes(executor: &impl CommandExecutor) -> Vec<RuntimeProbe> {
96 probe_local_runtimes(executor, ¤t_apple_platform(executor))
97}
98
99pub fn profiles_config(homes: &[DiscoveredHome]) -> Config {
102 build_config(homes, None)
103}
104
105pub fn discover_ssh_hosts(home: Option<&Path>) -> Vec<String> {
113 let Some(home) = home else {
114 return Vec::new();
115 };
116 let Ok(contents) = std::fs::read_to_string(home.join(".ssh").join("config")) else {
117 return Vec::new();
118 };
119 ssh_config_aliases(&contents)
120}
121
122pub fn ssh_config_aliases(contents: &str) -> Vec<String> {
127 let mut aliases: Vec<String> = Vec::new();
128 for line in contents.lines() {
129 let line = line.trim();
130 if line.is_empty() || line.starts_with('#') {
131 continue;
132 }
133 let Some((keyword, rest)) = line.split_once(char::is_whitespace) else {
134 continue;
135 };
136 if !keyword.eq_ignore_ascii_case("host") {
137 continue;
138 }
139 for alias in rest.split_whitespace() {
140 let alias = alias.trim_matches('"');
141 if alias.is_empty() || alias.contains(['*', '?', '!']) {
142 continue;
143 }
144 if !aliases.iter().any(|existing| existing == alias) {
145 aliases.push(alias.to_owned());
146 }
147 }
148 }
149 aliases
150}
151
152fn discover_installed_harnesses(
155 user_home: Option<&Path>,
156 overrides: &BTreeMap<HarnessKind, PathBuf>,
157 homes: &mut Vec<DiscoveredHome>,
158 executor: &impl CommandExecutor,
159) {
160 for kind in HarnessKind::ALL {
161 if homes.iter().any(|home| home.kind == kind) {
162 continue;
163 }
164 let Some(home) = overrides
165 .get(&kind)
166 .cloned()
167 .or_else(|| user_home.map(|home| home.join(kind.default_home_leaf())))
168 else {
169 continue;
170 };
171 let probe = CommandSpec::new(kind.cli_binary_name(), ["--version"])
172 .purpose("detect installed harness before first login");
173 match executor.execute(&probe) {
174 Ok(output) if output.status == 0 => homes.push(DiscoveredHome {
175 kind,
176 path: home,
177 authenticated: false,
178 }),
179 Ok(_) => {}
180 Err(error) => tracing::debug!(
181 harness = kind.id(),
182 "installation probe unavailable: {error:#}"
183 ),
184 }
185 }
186}
187
188pub(crate) fn discover_harness_homes_with_executor(
189 home: Option<&Path>,
190 overrides: impl IntoIterator<Item = (HarnessKind, PathBuf)>,
191 executor: &impl CommandExecutor,
192) -> Vec<DiscoveredHome> {
193 let mut candidates = Vec::new();
194 if let Some(home) = home {
195 candidates.extend(
196 HarnessKind::ALL
197 .into_iter()
198 .map(|kind| (kind, home.join(kind.default_home_leaf()), true)),
199 );
200 }
201 candidates.extend(
202 overrides
203 .into_iter()
204 .map(|(kind, path)| (kind, path, false)),
205 );
206
207 let mut seen = BTreeSet::new();
208 candidates
209 .into_iter()
210 .filter(|(kind, path, _)| seen.insert((*kind, path.clone())) && path.is_dir())
211 .map(|(kind, path, is_default_home)| DiscoveredHome {
212 authenticated: harness_is_authenticated_with(
213 &probe_profile(kind, &path),
214 is_default_home,
215 executor,
216 ),
217 kind,
218 path,
219 })
220 .collect()
221}
222
223fn probe_profile(kind: HarnessKind, home: &Path) -> HarnessProfile {
227 HarnessProfile {
228 enabled: true,
229 kind,
230 home: home.to_path_buf(),
231 environment: Default::default(),
232 context_window_bytes: None,
233 subagents: Default::default(),
234 guardian_review_model: None,
235 }
236}
237
238pub(crate) fn harness_is_authenticated_with_executor(
239 profile: &HarnessProfile,
240 executor: &impl CommandExecutor,
241) -> bool {
242 let is_default_home = dirs::home_dir()
243 .is_some_and(|user_home| profile.home == user_home.join(profile.kind.default_home_leaf()));
244 harness_is_authenticated_with(profile, is_default_home, executor)
245}
246
247fn harness_is_authenticated_with(
254 profile: &HarnessProfile,
255 is_default_home: bool,
256 executor: &impl CommandExecutor,
257) -> bool {
258 let kind = profile.kind;
259 let home = profile.home.as_path();
260 if profile.authentication_marker().is_file()
261 || (kind == HarnessKind::Kimi && home.join("credentials").is_file())
262 {
263 return true;
264 }
265 if kind != HarnessKind::Claude {
266 return false;
267 }
268 if is_default_home || !kind.keeps_login_in_home(HarnessHost::current()) {
272 return claude_keychain_reports_authenticated(executor);
273 }
274 claude_cli_reports_authenticated(home, executor)
275}
276
277fn claude_cli_reports_authenticated(home: &Path, executor: &impl CommandExecutor) -> bool {
281 let mut command = CommandSpec::new("claude", ["auth", "status", "--json"])
282 .purpose("check Claude Code authentication");
283 command.env.insert(
284 HarnessKind::Claude.home_env().to_owned(),
285 home.to_string_lossy().into_owned(),
286 );
287 let Ok(output) = executor.execute(&command) else {
288 return false;
289 };
290 if output.status != 0 {
291 return false;
292 }
293 serde_json::from_slice::<serde_json::Value>(&output.stdout)
294 .ok()
295 .and_then(|status| status.get("loggedIn").and_then(serde_json::Value::as_bool))
296 == Some(true)
297}
298
299#[cfg(target_os = "macos")]
303fn claude_keychain_reports_authenticated(executor: &impl CommandExecutor) -> bool {
304 let command = CommandSpec::new(
305 "security",
306 [
307 "find-generic-password",
308 "-s",
309 "Claude Code-credentials",
310 "-w",
311 ],
312 )
313 .purpose("check Claude Code authentication in the macOS Keychain");
314 let Ok(output) = executor.execute(&command) else {
315 return false;
316 };
317 output.status == 0 && claude_credentials_contain_login(&output.stdout)
318}
319
320#[cfg(not(target_os = "macos"))]
321fn claude_keychain_reports_authenticated(_executor: &impl CommandExecutor) -> bool {
322 false
323}
324
325#[cfg(any(target_os = "macos", test))]
326fn claude_credentials_contain_login(credentials: &[u8]) -> bool {
327 let Ok(document) = serde_json::from_slice::<serde_json::Value>(credentials) else {
328 return false;
329 };
330 [
331 "/claudeAiOauth/accessToken",
332 "/claudeAiOauth/refreshToken",
333 "/oauth/accessToken",
334 "/apiKey",
335 ]
336 .into_iter()
337 .any(|pointer| {
338 document
339 .pointer(pointer)
340 .and_then(serde_json::Value::as_str)
341 .is_some_and(|value| !value.trim().is_empty())
342 })
343}
344
345pub fn github_repository_from_origin(origin: &str) -> Option<GithubRepository> {
346 let origin = origin.trim();
347 let path = origin
348 .strip_prefix("https://github.com/")
349 .or_else(|| origin.strip_prefix("http://github.com/"))
350 .or_else(|| origin.strip_prefix("git@github.com:"))
351 .or_else(|| origin.strip_prefix("ssh://git@github.com/"))
352 .unwrap_or(origin);
355 let path = path.trim_end_matches(".git");
356 let mut parts = path.split('/');
357 let owner = parts.next()?;
358 let repository = parts.next()?;
359 if owner.is_empty()
360 || repository.is_empty()
361 || parts.next().is_some()
362 || owner.chars().any(char::is_whitespace)
363 || repository.chars().any(char::is_whitespace)
364 {
365 return None;
366 }
367 Some(GithubRepository {
368 owner: owner.to_owned(),
369 repository: repository.to_owned(),
370 })
371}
372
373fn discover_github_repository(
379 executor: &impl CommandExecutor,
380 cwd: &Path,
381) -> Option<GithubRepository> {
382 let command = CommandSpec::new(
383 "git",
384 [
385 "-C".to_owned(),
386 cwd.to_string_lossy().into_owned(),
387 "remote".to_owned(),
388 "get-url".to_owned(),
389 "origin".to_owned(),
390 ],
391 )
392 .purpose("detect the current repository's GitHub origin");
393 let output = executor.execute(&command).ok()?;
394 if output.status != 0 {
395 return None;
396 }
397 github_repository_from_origin(&String::from_utf8_lossy(&output.stdout))
398}
399
400pub fn probe_local_runtimes(
403 executor: &impl CommandExecutor,
404 platform: &ApplePlatform,
405) -> Vec<RuntimeProbe> {
406 let mut probes = vec![
407 runtime_probe_from_check(
408 RuntimeKind::Podman,
409 local_podman_runtime_check(executor, platform),
410 ),
411 runtime_probe_from_check(RuntimeKind::Docker, local_docker_runtime_check(executor)),
412 ];
413 if matches!(platform, ApplePlatform::Macos { .. }) {
414 probes.push(runtime_probe_from_check(
415 RuntimeKind::AppleContainer,
416 apple_container_runtime_check(platform, executor),
417 ));
418 }
419 probes
420}
421
422fn runtime_probe_from_check(kind: RuntimeKind, check: crate::doctor::DoctorCheck) -> RuntimeProbe {
423 RuntimeProbe {
424 kind,
425 status: check.status,
426 detail: check.detail,
427 remediation: check.remediation,
428 }
429}
430
431pub fn build_config(homes: &[DiscoveredHome], repository: Option<&GithubRepository>) -> Config {
433 let mut config = Config::default();
434 for home in homes {
435 let id = unique_id(&config.profiles, home.kind.id());
436 config.profiles.insert(
437 id,
438 HarnessProfile {
439 enabled: true,
440 kind: home.kind,
441 home: home.path.clone(),
442 environment: Default::default(),
443 context_window_bytes: None,
444 subagents: Default::default(),
445 guardian_review_model: None,
446 },
447 );
448 }
449
450 if let Some(repository) = repository {
451 let repository_id = config_id(&repository.repository);
452 config.bundles.insert(
453 "current-repository".to_owned(),
454 ProjectBundle {
455 primary_repo: repository_id.clone(),
456 repositories: vec![ProjectRepository {
457 id: repository_id.clone(),
458 github: Some(repository.source()),
459 local: None,
460 destination: PathBuf::from(repository_id),
461 git_ref: None,
462 }],
463 },
464 );
465 }
466
467 #[cfg(unix)]
468 config
469 .targets
470 .insert("localhost".to_owned(), TargetTemplate::LocalBare);
471 config
472}
473
474pub fn local_runtime_target(runtime: RuntimeKind, image: &str) -> (&'static str, TargetTemplate) {
476 let container = ContainerTemplate {
477 build_cache: None,
478 image: image.trim().to_owned(),
479 pull_policy: Default::default(),
480 platform: None,
481 cpus: None,
482 memory: None,
483 environment: Default::default(),
484 workspace_storage: Default::default(),
485 };
486 match runtime {
487 RuntimeKind::Podman => ("podman", TargetTemplate::LocalPodman { container }),
488 RuntimeKind::Docker => ("docker", TargetTemplate::LocalDocker { container }),
489 RuntimeKind::AppleContainer => (
490 "apple-container",
491 TargetTemplate::AppleContainer { container },
492 ),
493 }
494}
495
496fn config_id(value: &str) -> String {
497 let mut id = value
498 .chars()
499 .filter(|character| {
500 character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')
501 })
502 .take(64)
503 .collect::<String>();
504 if id.is_empty() || matches!(id.as_str(), "." | "..") {
505 id = "repository".to_owned();
506 }
507 id
508}
509
510#[cfg(test)]
511mod tests;