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