1use std::ffi::OsString;
7use std::path::Path;
8
9use crate::config::HarnessMapping;
10
11#[derive(Clone, Copy, Debug, Eq, PartialEq)]
12pub(crate) enum Harness {
13 Claude,
14 Codex,
15 OpenCode,
16}
17
18#[derive(Clone, Copy, Debug, Eq, PartialEq)]
19pub(crate) struct Recognized {
20 pub harness: Harness,
21 pub node_wrapper: bool,
22 pub args_offset: usize,
23}
24
25fn named(name: &str) -> Option<Harness> {
26 match name {
27 "claude" => Some(Harness::Claude),
28 "codex" => Some(Harness::Codex),
29 "opencode" => Some(Harness::OpenCode),
30 _ => None,
31 }
32}
33
34fn mapped(path: &Path, mappings: &[HarnessMapping]) -> Option<Harness> {
35 let mut matches = mappings.iter().filter(|m| m.path == path);
36 let harness = named(&matches.next()?.harness)?;
37 if matches.any(|m| named(&m.harness) != Some(harness)) {
39 return None;
40 }
41 Some(harness)
42}
43
44fn home_relative(path: &str) -> Option<&str> {
45 if let Some(rest) = path.strip_prefix("/root/") {
46 return Some(rest);
47 }
48 let rest = path
49 .strip_prefix("/Users/")
50 .or_else(|| path.strip_prefix("/home/"))?;
51 let (user, rest) = rest.split_once('/')?;
52 (!user.is_empty()).then_some(rest)
53}
54
55fn version(value: &str) -> bool {
56 value.starts_with(|c: char| c.is_ascii_digit())
57 && value
58 .bytes()
59 .all(|c| c.is_ascii_alphanumeric() || b".-_+".contains(&c))
60}
61
62fn codex_target(value: &str) -> bool {
63 matches!(
64 value,
65 "aarch64-apple-darwin"
66 | "x86_64-apple-darwin"
67 | "aarch64-unknown-linux-musl"
68 | "x86_64-unknown-linux-musl"
69 | "aarch64-unknown-linux-gnu"
70 | "x86_64-unknown-linux-gnu"
71 )
72}
73
74fn platform_package(value: &str, prefix: &str) -> bool {
75 let Some(value) = value.strip_prefix(prefix) else {
76 return false;
77 };
78 matches!(
79 value,
80 "darwin-arm64"
81 | "darwin-x64"
82 | "linux-arm64"
83 | "linux-x64"
84 | "linux-arm64-musl"
85 | "linux-x64-musl"
86 | "linux-x64-baseline"
87 | "linux-x64-baseline-musl"
88 | "darwin-x64-baseline"
89 )
90}
91
92pub(crate) fn native_layout(path: &str) -> Option<Harness> {
93 if let Some(rest) = home_relative(path) {
94 if rest
95 .strip_prefix(".local/share/claude/versions/")
96 .is_some_and(version)
97 {
98 return Some(Harness::Claude);
99 }
100 if rest == ".opencode/bin/opencode" {
101 return Some(Harness::OpenCode);
102 }
103 }
104 if let Some(rest) = path
105 .strip_prefix("/opt/homebrew/Cellar/")
106 .or_else(|| path.strip_prefix("/usr/local/Cellar/"))
107 {
108 let parts: Vec<_> = rest.split('/').collect();
109 if let [package, release, "bin", binary] = parts.as_slice() {
110 if package == binary && version(release) {
111 return match *package {
112 "codex" => Some(Harness::Codex),
113 "opencode" => Some(Harness::OpenCode),
114 _ => None,
115 };
116 }
117 }
118 }
119 if let Some(rest) = path
120 .strip_prefix("/opt/homebrew/Caskroom/")
121 .or_else(|| path.strip_prefix("/usr/local/Caskroom/"))
122 {
123 let parts: Vec<_> = rest.split('/').collect();
124 if let ["codex", release, "bin", "codex"] = parts.as_slice() {
125 if version(release) {
126 return Some(Harness::Codex);
127 }
128 }
129 if let [package, release, binary] = parts.as_slice() {
130 if version(release) {
131 if *package == "claude-code" && *binary == "claude" {
132 return Some(Harness::Claude);
133 }
134 if *package == "codex" && binary.strip_prefix("codex-").is_some_and(codex_target) {
135 return Some(Harness::Codex);
136 }
137 }
138 }
139 }
140 let (_, package) = path.rsplit_once("/node_modules/")?;
142 let parts: Vec<_> = package.split('/').collect();
143 match parts.as_slice() {
144 ["@openai", pkg, "vendor", target, "codex" | "bin", "codex"]
145 if (*pkg == "codex" || platform_package(pkg, "codex-")) && codex_target(target) =>
146 {
147 Some(Harness::Codex)
148 }
149 [pkg, "bin", "opencode"] if platform_package(pkg, "opencode-") => Some(Harness::OpenCode),
150 ["opencode-ai", "bin", ".opencode"] => Some(Harness::OpenCode),
151 ["@anthropic-ai", pkg, "claude"] if platform_package(pkg, "claude-code-") => {
152 Some(Harness::Claude)
153 }
154 _ => None,
155 }
156}
157
158fn node_entrypoint(path: &Path) -> Option<Harness> {
159 let path = path.to_str()?;
160 let (_, package) = path.rsplit_once("/node_modules/")?;
161 match package {
162 "@anthropic-ai/claude-code/cli.js" => Some(Harness::Claude),
163 "@openai/codex/bin/codex.js" => Some(Harness::Codex),
164 "opencode-ai/bin/opencode" => Some(Harness::OpenCode),
165 _ => None,
166 }
167}
168
169pub(crate) fn classify(
172 executable: &Path,
173 argv: &[OsString],
174 mappings: &[HarnessMapping],
175) -> Option<Recognized> {
176 if !executable.is_absolute() || argv.is_empty() {
177 return None;
178 }
179 let node = matches!(executable.file_name()?.to_str()?, "node" | "nodejs");
180 let (harness, offset) = if node {
181 let entry = Path::new(argv.get(1)?);
183 if !entry.is_absolute() {
184 return None;
185 }
186 let harness = if mappings.iter().any(|m| m.path == entry) {
187 mapped(entry, mappings)?
188 } else {
189 node_entrypoint(entry)?
190 };
191 (harness, 2)
192 } else {
193 let harness = if mappings.iter().any(|m| m.path == executable) {
194 mapped(executable, mappings)?
195 } else {
196 native_layout(executable.to_str()?)?
197 };
198 (harness, 1)
199 };
200 let argv0 = Path::new(&argv[0]).file_name()?;
201 let expected_name = if node {
202 "node"
203 } else {
204 match harness {
205 Harness::Claude => "claude",
206 Harness::Codex => "codex",
207 Harness::OpenCode => "opencode",
208 }
209 };
210 if argv0 != expected_name && Some(argv0) != executable.file_name() {
213 return None;
214 }
215 interactive(harness, &argv[offset..]).then_some(Recognized {
216 harness,
217 node_wrapper: node,
218 args_offset: offset,
219 })
220}
221
222fn interactive(harness: Harness, args: &[OsString]) -> bool {
223 let mut index = 0;
224 let mut positional = false;
225 let mut command = false;
226 while index < args.len() {
227 let Some(arg) = args[index].to_str() else {
228 return false;
229 };
230 index += 1;
231 if arg == "--" {
232 return false;
234 }
235 if arg.starts_with('-') {
236 let (flag, inline) = arg
237 .split_once('=')
238 .map_or((arg, None), |(k, v)| (k, Some(v)));
239 if harness == Harness::Claude && matches!(flag, "--resume" | "-r") {
240 if let Some(value) = inline {
243 if flag != "--resume" || value.is_empty() {
244 return false;
245 }
246 } else if let Some(value) = args.get(index) {
247 let Some(value) = value.to_str() else {
248 return false;
249 };
250 if value.is_empty() {
251 return false;
252 }
253 if !value.starts_with('-') {
254 index += 1;
255 }
256 }
257 continue;
258 }
259 let (switches, values): (&[&str], &[&str]) = match harness {
260 Harness::Claude => (
261 &[
262 "--continue",
263 "-c",
264 "--verbose",
265 "--dangerously-skip-permissions",
266 "--allow-dangerously-skip-permissions",
267 "--fork-session",
268 "--ide",
269 "--strict-mcp-config",
270 "--disable-slash-commands",
271 ],
272 &[
273 "--model",
274 "--permission-mode",
275 "--system-prompt",
276 "--append-system-prompt",
277 "--settings",
278 "--setting-sources",
279 "--session-id",
280 "--agent",
281 "--agents",
282 "--mcp-config",
283 ],
284 ),
285 Harness::Codex => (
286 &[
287 "--full-auto",
288 "--dangerously-bypass-approvals-and-sandbox",
289 "--oss",
290 "--search",
291 "--no-alt-screen",
292 ],
293 &[
294 "--profile",
295 "-p",
296 "--model",
297 "-m",
298 "--config",
299 "-c",
300 "--sandbox",
301 "-s",
302 "--ask-for-approval",
303 "-a",
304 "--cd",
305 "-C",
306 "--image",
307 "-i",
308 "--add-dir",
309 "--enable",
310 "--disable",
311 "--local-provider",
312 ],
313 ),
314 Harness::OpenCode => (
315 &["--print-logs", "--continue", "-c", "--fork"],
316 &[
317 "--model",
318 "-m",
319 "--agent",
320 "--session",
321 "-s",
322 "--prompt",
323 "--port",
324 "--hostname",
325 "--log-level",
326 "--password",
327 "--dir",
328 ],
329 ),
330 };
331 if harness == Harness::Codex
332 && command
333 && matches!(flag, "--last" | "--all")
334 && inline.is_none()
335 {
336 continue;
337 }
338 if switches.contains(&flag) && inline.is_none() {
339 continue;
340 }
341 if !values.contains(&flag) {
342 return false;
344 }
345 if let Some(value) = inline {
346 if !flag.starts_with("--") || value.is_empty() {
347 return false;
348 }
349 } else {
350 let Some(value) = args.get(index).and_then(|v| v.to_str()) else {
351 return false;
352 };
353 if value.is_empty() || value.starts_with('-') {
354 return false;
355 }
356 index += 1;
357 }
358 } else {
359 if !positional && !command {
360 let allowed_command = match harness {
361 Harness::Claude => false,
362 Harness::Codex => matches!(arg, "resume" | "fork"),
363 Harness::OpenCode => arg == "attach",
364 };
365 if allowed_command {
366 command = true;
367 continue;
368 }
369 let forbidden_command = match harness {
370 Harness::Claude => matches!(
371 arg,
372 "auth"
373 | "help"
374 | "agents"
375 | "remote-control"
376 | "rc"
377 | "bridge"
378 | "config"
379 | "doctor"
380 | "install"
381 | "mcp"
382 | "plugin"
383 | "setup-token"
384 | "update"
385 | "upgrade"
386 ),
387 Harness::Codex => matches!(
388 arg,
389 "exec"
390 | "e"
391 | "review"
392 | "login"
393 | "logout"
394 | "mcp"
395 | "mcp-server"
396 | "app"
397 | "app-server"
398 | "completion"
399 | "sandbox"
400 | "debug"
401 | "apply"
402 | "a"
403 | "cloud"
404 | "features"
405 | "help"
406 ),
407 Harness::OpenCode => matches!(
408 arg,
409 "run"
410 | "serve"
411 | "web"
412 | "auth"
413 | "mcp"
414 | "models"
415 | "upgrade"
416 | "uninstall"
417 | "stats"
418 | "export"
419 | "import"
420 | "github"
421 | "pr"
422 | "session"
423 | "agent"
424 | "debug"
425 | "completion"
426 | "help"
427 | "acp"
428 ),
429 };
430 if forbidden_command {
431 return false;
432 }
433 }
434 if positional || arg.is_empty() {
436 return false;
437 }
438 positional = true;
439 }
440 }
441 true
442}
443
444pub(crate) fn is_shell(executable: &Path) -> bool {
445 let Some(path) = executable.to_str() else {
446 return false;
447 };
448 let Some(name) = executable.file_name().and_then(|v| v.to_str()) else {
449 return false;
450 };
451 if !matches!(name, "sh" | "bash" | "zsh" | "fish" | "dash" | "ksh") {
452 return false;
453 }
454 if matches!(
455 executable.parent().and_then(|p| p.to_str()),
456 Some("/bin" | "/usr/bin" | "/usr/local/bin")
457 ) {
458 return true;
459 }
460 let Some(rest) = path
461 .strip_prefix("/opt/homebrew/Cellar/")
462 .or_else(|| path.strip_prefix("/usr/local/Cellar/"))
463 else {
464 return false;
465 };
466 let parts: Vec<_> = rest.split('/').collect();
467 matches!(parts.as_slice(), [package, release, "bin", binary] if *package == name && *binary == name && version(release))
468}
469
470#[cfg(test)]
471mod tests {
472 use super::*;
473
474 fn args(values: &[&str]) -> Vec<OsString> {
475 values.iter().map(OsString::from).collect()
476 }
477
478 #[test]
479 fn mode_parsing_is_harness_specific() {
480 for (harness, argv, expected) in [
481 (Harness::Claude, vec![], true),
482 (Harness::Claude, vec!["--print", "hello"], false),
483 (Harness::Claude, vec!["-p", "hello"], false),
484 (Harness::Claude, vec!["-phello"], false),
485 (Harness::Claude, vec!["--model=sonnet", "hello"], true),
486 (Harness::Claude, vec!["--resume"], true),
487 (Harness::Claude, vec!["--resume=session"], true),
488 (Harness::Claude, vec!["mcp", "serve"], false),
489 (Harness::Claude, vec!["remote-control"], false),
490 (Harness::Claude, vec!["help"], false),
491 (Harness::Codex, vec!["-p", "work"], true),
492 (
493 Harness::Codex,
494 vec!["--profile=work", "exec", "hello"],
495 false,
496 ),
497 (Harness::Codex, vec!["e", "hello"], false),
498 (Harness::Codex, vec!["resume", "abc"], true),
499 (Harness::Codex, vec!["resume", "--last"], true),
500 (Harness::Codex, vec!["--unknown"], false),
501 (Harness::Codex, vec!["--model"], false),
502 (Harness::Codex, vec!["--model", "--help"], false),
503 (Harness::Codex, vec!["--", "exec"], false),
504 (Harness::OpenCode, vec!["run", "hello"], false),
505 (
506 Harness::OpenCode,
507 vec!["attach", "http://localhost:4096"],
508 true,
509 ),
510 (Harness::OpenCode, vec!["--model", "provider/model"], true),
511 (Harness::OpenCode, vec!["serve"], false),
512 ] {
513 assert_eq!(
514 interactive(harness, &args(&argv)),
515 expected,
516 "{harness:?} {argv:?}"
517 );
518 }
519 }
520
521 #[test]
522 fn native_layouts_not_basenames() {
523 for (path, expected) in [
524 ("/tmp/claude", None),
525 ("/usr/local/bin/codex", None),
526 ("/tmp/.opencode/bin/opencode", None),
527 ("/home/alice/.opencode/bin/opencode", Some(Harness::OpenCode)),
528 ("/Users/alice/.local/share/claude/versions/2.1.1", Some(Harness::Claude)),
529 ("/opt/homebrew/Caskroom/codex/0.98.0/codex-aarch64-apple-darwin", Some(Harness::Codex)),
530 ("/opt/homebrew/Caskroom/codex/0.153.4/bin/codex", Some(Harness::Codex)),
531 ("/usr/local/lib/node_modules/@openai/codex/vendor/x86_64-unknown-linux-musl/codex/codex", Some(Harness::Codex)),
532 ("/usr/local/lib/node_modules/@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl/codex/codex", Some(Harness::Codex)),
533 ("/usr/local/lib/node_modules/@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl/bin/codex", Some(Harness::Codex)),
534 ("/tmp/node_modules/opencode-linux-x64/bin/opencode", Some(Harness::OpenCode)),
535 ("/tmp/node_modules/not-codex/bin/codex", None),
536 ] {
537 assert_eq!(native_layout(path), expected, "{path}");
538 }
539 }
540
541 #[test]
542 fn claude_resume_consumes_only_its_optional_selector() {
543 for (argv, expected) in [
544 (vec!["-r"], true),
545 (vec!["--resume", "session-id"], true),
546 (vec!["-r", "session-id", "hello"], true),
547 (vec!["--resume=session-id", "hello"], true),
548 (vec!["--resume", "--model", "sonnet"], true),
549 (vec!["--resume", "mcp"], true), (vec!["--resume", "session-id", "mcp"], false),
551 (vec!["-r", "session-id", "auth"], false),
552 (vec!["--resume=session-id", "help"], false),
553 (vec!["--resume", "session-id", "--unknown"], false),
554 (vec!["--resume", "--unknown"], false),
555 (vec!["-r", "--print"], false),
556 (vec!["-r", "session-id", "-p", "hello"], false),
557 (vec!["--resume="], false),
558 (vec!["-r", ""], false),
559 (vec!["-rsession-id"], false),
560 (vec!["-r=session-id"], false),
561 ] {
562 assert_eq!(
563 interactive(Harness::Claude, &args(&argv)),
564 expected,
565 "{argv:?}"
566 );
567 }
568 }
569
570 #[test]
571 fn unrecognizable_overwritten_titles_are_not_inferred_from_installation() {
572 assert!(classify(
573 Path::new("/home/alice/.local/share/claude/versions/2.1.1"),
574 &args(&["claude: working"]),
575 &[]
576 )
577 .is_none());
578 assert!(classify(Path::new("/usr/bin/node"), &args(&["opencode"]), &[]).is_none());
579 }
580
581 #[test]
582 fn node_requires_exact_entrypoint() {
583 let node = Path::new("/usr/bin/node");
584 assert!(classify(
585 node,
586 &args(&[
587 "node",
588 "/usr/lib/node_modules/@openai/codex/bin/codex.js",
589 "-p",
590 "work"
591 ]),
592 &[]
593 )
594 .is_some());
595 for argv in [
596 vec!["node", "/tmp/codex.js"],
597 vec!["node", "--eval", "codex"],
598 vec!["node", "node_modules/@openai/codex/bin/codex.js"],
599 vec![
600 "node",
601 "/usr/lib/node_modules/@openai/codex/bin/codex.js",
602 "exec",
603 ],
604 ] {
605 assert!(classify(node, &args(&argv), &[]).is_none());
606 }
607 }
608
609 #[test]
610 fn custom_mapping_opts_in_but_does_not_bypass_mode_check() {
611 let executable = Path::new("/opt/custom/ai");
612 let mappings = vec![HarnessMapping {
613 harness: "codex".into(),
614 path: executable.into(),
615 }];
616 assert!(classify(executable, &args(&["ai"]), &[]).is_none());
617 assert!(classify(executable, &args(&["ai", "-p", "work"]), &mappings).is_some());
618 assert!(classify(executable, &args(&["ai", "exec"]), &mappings).is_none());
619 let ambiguous = vec![
620 HarnessMapping {
621 harness: "codex".into(),
622 path: executable.into(),
623 },
624 HarnessMapping {
625 harness: "claude".into(),
626 path: executable.into(),
627 },
628 ];
629 assert!(classify(executable, &args(&["ai"]), &ambiguous).is_none());
630 }
631}