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