1use std::collections::HashMap;
2use std::path::Path;
3#[cfg(test)]
4use std::path::PathBuf;
5
6use serde::Deserialize;
7
8#[derive(Debug, Clone, Deserialize)]
10pub struct AgentConfig {
11 pub identity: String,
12 pub name: String,
13 pub short_name: String,
14 #[serde(default = "default_protocol")]
15 pub protocol: String,
16 #[serde(default = "default_type")]
17 pub r#type: String,
18 #[serde(default)]
19 pub active: Option<bool>,
20 pub run_command: HashMap<String, String>,
21 #[serde(default)]
22 pub env: HashMap<String, String>,
23 #[serde(default)]
25 pub install_command: Option<String>,
26 #[serde(default)]
27 pub actions: HashMap<String, HashMap<String, ActionConfig>>,
28 #[serde(skip)]
31 pub connector_installed: bool,
32}
33
34fn default_protocol() -> String {
35 "acp".to_string()
36}
37
38fn default_type() -> String {
39 "coding".to_string()
40}
41
42#[derive(Debug, Clone, Deserialize)]
44pub struct ActionConfig {
45 pub command: Option<String>,
46 pub description: Option<String>,
47}
48
49impl AgentConfig {
50 pub fn run_command_for_platform(&self) -> Option<&str> {
53 let platform = if cfg!(target_os = "macos") {
54 "macos"
55 } else if cfg!(target_os = "windows") {
56 "windows"
57 } else {
58 "linux"
59 };
60 self.run_command
61 .get(platform)
62 .or_else(|| self.run_command.get("*"))
63 .map(|s| s.as_str())
64 }
65
66 pub fn is_active(&self) -> bool {
68 self.active.unwrap_or(true)
69 }
70
71 pub fn detect_connector(&mut self) {
74 self.connector_installed = self
75 .run_command_for_platform()
76 .map(|cmd| {
77 let binary = cmd.split_whitespace().next().unwrap_or("");
79 binary_in_path(binary)
80 })
81 .unwrap_or(false);
82 }
83}
84
85fn binary_in_path(binary: &str) -> bool {
87 resolve_binary_in_path(binary).is_some()
88}
89
90pub fn resolve_binary_in_path(binary: &str) -> Option<std::path::PathBuf> {
94 resolve_binary_in_path_str(binary, &std::env::var("PATH").ok()?)
95}
96
97pub fn resolve_binary_in_path_str(binary: &str, path_var: &str) -> Option<std::path::PathBuf> {
99 if binary.is_empty() {
100 return None;
101 }
102 let path = std::path::Path::new(binary);
104 if path.is_absolute() {
105 return if path.is_file() {
106 Some(path.to_path_buf())
107 } else {
108 None
109 };
110 }
111 for dir in std::env::split_paths(path_var) {
112 let candidate = dir.join(binary);
113 if candidate.is_file() {
114 return Some(candidate);
115 }
116 }
117 None
118}
119
120const KNOWN_SHELLS: &[&str] = &[
131 "sh", "bash", "zsh", "fish", "dash", "ksh", "tcsh", "csh", "elvish", "nu",
132];
133
134pub fn is_known_shell(shell_path: &str) -> bool {
146 let basename = std::path::Path::new(shell_path)
147 .file_name()
148 .and_then(|n| n.to_str())
149 .unwrap_or("");
150 KNOWN_SHELLS.contains(&basename)
151}
152
153pub fn resolve_shell_path() -> Option<String> {
172 let raw_shell = std::env::var("SHELL").unwrap_or_default();
173 let shell = if !raw_shell.is_empty() && is_known_shell(&raw_shell) {
174 raw_shell
175 } else {
176 if !raw_shell.is_empty() {
177 log::warn!(
178 "resolve_shell_path: $SHELL={raw_shell:?} is not in the known-shells allowlist; \
179 falling back to /bin/sh"
180 );
181 }
182 "/bin/sh".to_string()
183 };
184 let output = std::process::Command::new(&shell)
185 .args(["-lic", r#"printf "%s" "$PATH""#])
186 .stdin(std::process::Stdio::null())
187 .stdout(std::process::Stdio::piped())
188 .stderr(std::process::Stdio::null())
189 .output()
190 .ok()?;
191 if output.status.success() {
192 let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
193 if !path.is_empty() {
194 return Some(path);
195 }
196 }
197 None
198}
199
200const EMBEDDED_AGENTS: &[&str] = &[
209 r#"
210identity = "claude.com"
211name = "Claude Code"
212short_name = "claude"
213protocol = "acp"
214type = "coding"
215install_command = "npm install -g @zed-industries/claude-agent-acp"
216
217[run_command]
218"*" = "claude-agent-acp"
219"#,
220 r#"
221identity = "openai.com"
222name = "Codex CLI"
223short_name = "codex"
224protocol = "acp"
225type = "coding"
226install_command = "npm install -g @zed-industries/codex-acp"
227
228[run_command]
229"*" = "npx @zed-industries/codex-acp"
230"#,
231 r#"
232identity = "geminicli.com"
233name = "Gemini CLI"
234short_name = "gemini"
235protocol = "acp"
236type = "coding"
237
238[run_command]
239"*" = "gemini --experimental-acp"
240"#,
241 r#"
242identity = "copilot.github.com"
243name = "Copilot"
244short_name = "copilot"
245protocol = "acp"
246type = "coding"
247
248[run_command]
249"*" = "copilot --acp"
250"#,
251 r#"
252identity = "ampcode.com"
253name = "Amp (AmpCode)"
254short_name = "amp"
255protocol = "acp"
256type = "coding"
257
258[run_command]
259"*" = "npx -y amp-acp"
260"#,
261 r#"
262identity = "augmentcode.com"
263name = "Auggie (Augment Code)"
264short_name = "auggie"
265protocol = "acp"
266type = "coding"
267
268[run_command]
269"*" = "auggie --acp"
270"#,
271 r#"
272identity = "docker.com"
273name = "Docker cagent"
274short_name = "cagent"
275protocol = "acp"
276type = "coding"
277
278[run_command]
279"*" = "cagent acp"
280"#,
281 r#"
282identity = "openhands.dev"
283name = "OpenHands"
284short_name = "openhands"
285protocol = "acp"
286type = "coding"
287
288[run_command]
289"*" = "openhands acp"
290"#,
291];
292
293const BUILT_IN_IDENTITIES: &[&str] = &[
298 "claude.com",
299 "openai.com",
300 "geminicli.com",
301 "copilot.github.com",
302 "ampcode.com",
303 "augmentcode.com",
304 "docker.com",
305 "openhands.dev",
306];
307
308pub fn discover_agents(user_config_dir: &Path) -> Vec<AgentConfig> {
309 let mut agents = Vec::new();
310
311 for embedded in EMBEDDED_AGENTS {
313 if let Ok(config) = toml::from_str::<AgentConfig>(embedded) {
314 agents.push(config);
315 }
316 }
317
318 let bundled_dir = std::env::current_exe()
320 .ok()
321 .and_then(|p| p.parent().map(|p| p.join("agents")));
322 if let Some(ref dir) = bundled_dir {
323 load_agents_from_dir(dir, &mut agents, false);
324 }
325
326 let user_agents_dir = user_config_dir.join("agents");
334 load_agents_from_dir(&user_agents_dir, &mut agents, true);
335
336 agents.retain(|a| a.is_active());
337
338 for agent in &mut agents {
340 agent.detect_connector();
341 }
342
343 agents
344}
345
346fn load_agents_from_dir(dir: &Path, agents: &mut Vec<AgentConfig>, is_user_config: bool) {
353 if !dir.exists() {
354 return;
355 }
356 let Ok(entries) = std::fs::read_dir(dir) else {
357 return;
358 };
359 for entry in entries.flatten() {
360 let path = entry.path();
361 if path.extension().is_some_and(|ext| ext == "toml") {
362 match std::fs::read_to_string(&path) {
363 Ok(content) => match toml::from_str::<AgentConfig>(&content) {
364 Ok(config) => {
365 if is_user_config && BUILT_IN_IDENTITIES.contains(&config.identity.as_str())
368 {
369 log::warn!(
370 "ACP agent config '{}' overrides built-in identity '{}'.\n\
371 User-config-dir agents are executed with par-term's privileges.\n\
372 Verify that '{}' is a trusted file you created intentionally.",
373 path.display(),
374 config.identity,
375 path.display(),
376 );
377 }
378 agents.retain(|a| a.identity != config.identity);
380 agents.push(config);
381 }
382 Err(e) => {
383 log::error!("Failed to parse agent config {}: {e}", path.display());
384 }
385 },
386 Err(e) => log::error!("Failed to read agent config {}: {e}", path.display()),
387 }
388 }
389 }
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395
396 #[test]
397 fn test_parse_agent_toml() {
398 let toml_str = r#"
399identity = "claude.com"
400name = "Claude Code"
401short_name = "claude"
402protocol = "acp"
403type = "coding"
404
405[run_command]
406"*" = "claude-agent-acp"
407macos = "claude-agent-acp"
408"#;
409 let config: AgentConfig = toml::from_str(toml_str).unwrap();
410 assert_eq!(config.identity, "claude.com");
411 assert_eq!(config.name, "Claude Code");
412 assert_eq!(config.short_name, "claude");
413 assert_eq!(config.protocol, "acp");
414 assert_eq!(config.r#type, "coding");
415 assert!(config.is_active());
416 assert!(config.run_command_for_platform().is_some());
417 }
418
419 #[test]
420 fn test_inactive_agent() {
421 let toml_str = r#"
422identity = "test.agent"
423name = "Test"
424short_name = "test"
425active = false
426
427[run_command]
428"*" = "test-agent"
429"#;
430 let config: AgentConfig = toml::from_str(toml_str).unwrap();
431 assert!(!config.is_active());
432 }
433
434 #[test]
435 fn test_default_protocol_and_type() {
436 let toml_str = r#"
437identity = "minimal.agent"
438name = "Minimal"
439short_name = "min"
440
441[run_command]
442"*" = "minimal-agent"
443"#;
444 let config: AgentConfig = toml::from_str(toml_str).unwrap();
445 assert_eq!(config.protocol, "acp");
446 assert_eq!(config.r#type, "coding");
447 }
448
449 #[test]
450 fn test_platform_fallback_to_wildcard() {
451 let toml_str = r#"
452identity = "wildcard.agent"
453name = "Wildcard"
454short_name = "wc"
455
456[run_command]
457"*" = "wildcard-cmd"
458"#;
459 let config: AgentConfig = toml::from_str(toml_str).unwrap();
460 assert_eq!(config.run_command_for_platform(), Some("wildcard-cmd"));
461 }
462
463 #[test]
464 fn test_all_embedded_agents_parse() {
465 for (i, toml_str) in EMBEDDED_AGENTS.iter().enumerate() {
466 let config = toml::from_str::<AgentConfig>(toml_str)
467 .unwrap_or_else(|e| panic!("Embedded agent {i} failed to parse: {e}"));
468 assert!(!config.identity.is_empty(), "Agent {i} has empty identity");
469 assert!(!config.name.is_empty(), "Agent {i} has empty name");
470 assert!(
471 !config.short_name.is_empty(),
472 "Agent {i} has empty short_name"
473 );
474 assert!(
475 config.run_command_for_platform().is_some(),
476 "Agent {} ({}) has no run command for this platform",
477 i,
478 config.identity
479 );
480 }
481 }
482
483 #[test]
484 fn test_embedded_agents_include_known_identities() {
485 let agents: Vec<AgentConfig> = EMBEDDED_AGENTS
486 .iter()
487 .map(|s| toml::from_str(s).unwrap())
488 .collect();
489 let identities: Vec<&str> = agents.iter().map(|a| a.identity.as_str()).collect();
490 assert!(identities.contains(&"claude.com"), "Missing claude.com");
491 assert!(
492 identities.contains(&"openai.com"),
493 "Missing openai.com (codex)"
494 );
495 assert!(
496 identities.contains(&"geminicli.com"),
497 "Missing geminicli.com (gemini)"
498 );
499 }
500
501 #[test]
502 fn test_discover_agents_nonexistent_dir() {
503 let dir = PathBuf::from("/tmp/par_term_test_nonexistent_agents_dir");
504 let agents = discover_agents(&dir);
505 for agent in &agents {
508 assert!(agent.is_active());
509 }
510 }
511
512 #[test]
513 fn test_discover_agents_from_temp_dir() {
514 let tmp_dir = tempfile::tempdir().unwrap();
515 let agents_dir = tmp_dir.path().join("agents");
516 std::fs::create_dir_all(&agents_dir).unwrap();
517
518 let toml_content = r#"
519identity = "test.disco"
520name = "Discovery Test"
521short_name = "disco"
522
523[run_command]
524"*" = "disco-agent"
525"#;
526 std::fs::write(agents_dir.join("test.disco.toml"), toml_content).unwrap();
527
528 let agents = discover_agents(tmp_dir.path());
529 let disco = agents.iter().find(|a| a.identity == "test.disco");
530 assert!(
531 disco.is_some(),
532 "Expected test.disco agent to be discovered"
533 );
534 assert_eq!(disco.unwrap().name, "Discovery Test");
535 }
536
537 #[test]
538 fn test_discover_agents_filters_inactive() {
539 let tmp_dir = tempfile::tempdir().unwrap();
540 let agents_dir = tmp_dir.path().join("agents");
541 std::fs::create_dir_all(&agents_dir).unwrap();
542
543 let active_toml = r#"
544identity = "active.agent"
545name = "Active"
546short_name = "act"
547
548[run_command]
549"*" = "active-cmd"
550"#;
551 let inactive_toml = r#"
552identity = "inactive.agent"
553name = "Inactive"
554short_name = "inact"
555active = false
556
557[run_command]
558"*" = "inactive-cmd"
559"#;
560 std::fs::write(agents_dir.join("active.toml"), active_toml).unwrap();
561 std::fs::write(agents_dir.join("inactive.toml"), inactive_toml).unwrap();
562
563 let agents = discover_agents(tmp_dir.path());
564 assert!(
565 agents.iter().any(|a| a.identity == "active.agent"),
566 "Expected active.agent to be present"
567 );
568 assert!(
569 !agents.iter().any(|a| a.identity == "inactive.agent"),
570 "Expected inactive.agent to be filtered out"
571 );
572 }
573
574 #[test]
575 fn test_binary_in_path_finds_common_binary() {
576 assert!(binary_in_path("ls"));
578 }
579
580 #[test]
581 fn test_binary_in_path_not_found() {
582 assert!(!binary_in_path("nonexistent-binary-12345"));
583 }
584
585 #[test]
586 fn test_binary_in_path_empty() {
587 assert!(!binary_in_path(""));
588 }
589
590 #[test]
591 fn test_detect_connector_for_known_binary() {
592 let mut config: AgentConfig = toml::from_str(
593 r#"
594identity = "test.agent"
595name = "Test"
596short_name = "test"
597
598[run_command]
599"*" = "ls"
600"#,
601 )
602 .unwrap();
603 config.detect_connector();
604 assert!(config.connector_installed);
605 }
606
607 #[test]
608 fn test_detect_connector_for_unknown_binary() {
609 let mut config: AgentConfig = toml::from_str(
610 r#"
611identity = "test.agent"
612name = "Test"
613short_name = "test"
614
615[run_command]
616"*" = "nonexistent-binary-12345"
617"#,
618 )
619 .unwrap();
620 config.detect_connector();
621 assert!(!config.connector_installed);
622 }
623
624 #[test]
625 fn test_detect_connector_extracts_first_token() {
626 let mut config: AgentConfig = toml::from_str(
627 r#"
628identity = "test.agent"
629name = "Test"
630short_name = "test"
631
632[run_command]
633"*" = "ls --some-flag"
634"#,
635 )
636 .unwrap();
637 config.detect_connector();
638 assert!(config.connector_installed);
639 }
640}