1pub mod gate;
14pub mod metadata;
15
16mod aider;
17mod amp;
18mod claude;
19mod cline;
20mod codex;
21mod r#continue;
22mod copilot;
23mod cursor;
24#[cfg(debug_assertions)]
25mod fake;
26mod gemini;
27mod kiro;
28mod opencode;
29mod pi;
30
31use std::ffi::{OsStr, OsString};
32use std::fs;
33use std::io::{self, Write};
34use std::path::{Path, PathBuf};
35
36use crate::config::{AppRoot, Config, check_case_twins};
37use crate::error::{Error, Result};
38use crate::exe::{self, Origin};
39use crate::launch::LaunchPlan;
40use crate::name::ProfileName;
41
42pub use aider::Aider;
43pub use amp::Amp;
44pub use claude::Claude;
45pub use cline::Cline;
46pub use codex::Codex;
47pub use r#continue::Continue;
48pub use copilot::Copilot;
49pub use cursor::Cursor;
50#[cfg(debug_assertions)]
51pub use fake::Fake;
52pub use gemini::Gemini;
53pub use kiro::Kiro;
54pub use metadata::{
55 AdapterEvidence, AdapterMetadata, Capability, CapabilityClaim, CapabilityState, ConflictOption,
56 EnvOverride, Mechanism, ProfilePresence, SupportLevel,
57};
58pub use opencode::Opencode;
59pub use pi::Pi;
60
61pub trait Adapter: Sync {
63 fn metadata(&self) -> &'static AdapterMetadata;
64
65 fn paths(&self, root: &AppRoot, profile: &ProfileName) -> Vec<(PathBuf, PathKind)>;
68
69 fn plan(&self, ctx: &PlanContext<'_>) -> Result<PlannedLaunch>;
71
72 fn presence(&self, root: &AppRoot, profile: &ProfileName) -> ProfilePresence {
74 if check_case_twins(root, profile).is_err() {
75 return ProfilePresence::Absent;
76 }
77 let paths = self.paths(root, profile);
78 if paths.iter().all(|(path, kind)| has_kind(path, *kind)) {
79 ProfilePresence::Materialized
80 } else {
81 ProfilePresence::Absent
82 }
83 }
84
85 fn initialize(&self, planned: &PlannedLaunch) -> Result<()> {
87 ensure_paths(&planned.paths)
88 }
89}
90
91#[derive(Debug, Clone, Copy)]
93pub struct PlanContext<'a> {
94 pub profile: &'a ProfileName,
95 pub root: &'a AppRoot,
96 pub config: &'a Config,
97 pub args: &'a [OsString],
98 pub path_var: Option<&'a OsStr>,
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum PathKind {
104 Dir,
105 File {
107 contents: &'static [u8],
108 },
109}
110
111#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct ProfilePath {
114 pub path: PathBuf,
115 pub kind: PathKind,
116 pub existed: bool,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct PlannedLaunch {
122 pub plan: LaunchPlan,
123 pub profile: ProfileName,
124 pub profile_dir: PathBuf,
126 pub paths: Vec<ProfilePath>,
127 pub executable_origin: Origin,
128 pub mechanism: String,
130 pub sensitive_env: Vec<OsString>,
132 pub notes: Vec<String>,
134}
135
136pub const REAL_ADAPTERS: &[&dyn Adapter] = &[
138 &Claude, &Codex, &Aider, &Gemini, &Copilot, &Opencode, &Pi, &Kiro, &Cursor, &Continue, &Amp,
139 &Cline,
140];
141
142pub fn registry() -> Vec<&'static dyn Adapter> {
144 #[cfg_attr(not(debug_assertions), allow(unused_mut))]
145 let mut adapters = REAL_ADAPTERS.to_vec();
146 #[cfg(debug_assertions)]
147 adapters.push(&Fake);
148 adapters
149}
150
151pub fn known_agents() -> Vec<&'static str> {
153 registry().iter().map(|adapter| adapter.metadata().id).collect()
154}
155
156pub fn lookup(id: &str) -> Option<&'static dyn Adapter> {
158 registry().into_iter().find(|adapter| adapter.metadata().id == id)
159}
160
161pub fn check_conflicts(metadata: &AdapterMetadata, args: &[OsString]) -> Result<()> {
168 for arg in args.iter().take_while(|arg| arg.as_os_str() != "--") {
169 let mut options = metadata.conflicts.iter().chain(metadata.mechanism.conflict_option());
170 if let Some(option) = options.find_map(|option| option.matched_spelling(arg)) {
171 return Err(Error::ArgumentConflict {
172 agent: metadata.id.to_owned(),
173 option,
174 mechanism: metadata.mechanism.to_string(),
175 });
176 }
177 }
178 Ok(())
179}
180
181pub(crate) fn profile_dir(root: &AppRoot, profile: &ProfileName, id: &str) -> PathBuf {
183 root.profiles_dir().join(profile.as_str()).join(id)
184}
185
186fn config_file(paths: &[ProfilePath]) -> PathBuf {
188 paths
189 .iter()
190 .find(|entry| matches!(entry.kind, PathKind::File { .. }))
191 .expect("a configuration-file adapter owns a file")
192 .path
193 .clone()
194}
195
196impl Mechanism {
197 pub(crate) fn plan(
211 &self,
212 adapter: &dyn Adapter,
213 ctx: &PlanContext<'_>,
214 ) -> Result<PlannedLaunch> {
215 let metadata = adapter.metadata();
216 let found = discover(metadata, ctx)?;
217 check_case_twins(ctx.root, ctx.profile)?;
218 let paths = profile_paths(adapter, ctx);
219 let dir = paths[0].path.clone();
220
221 let (args, env, target) = match self {
223 Mechanism::Env(name) => (
224 ctx.args.to_vec(),
225 vec![(OsString::from(*name), dir.clone().into_os_string())],
226 dir.clone(),
227 ),
228 Mechanism::EnvFile(name) => {
229 let file = config_file(&paths);
230 (
231 ctx.args.to_vec(),
232 vec![(OsString::from(*name), file.clone().into_os_string())],
233 file,
234 )
235 }
236 Mechanism::FlagDir(option) => {
237 let mut args: Vec<OsString> =
238 vec![option.spelling().into(), dir.clone().into_os_string()];
239 args.extend(ctx.args.iter().cloned());
240 (args, Vec::new(), dir.clone())
241 }
242 Mechanism::FlagFile(option) => {
243 let file = config_file(&paths);
244 let mut args: Vec<OsString> =
245 vec![option.spelling().into(), file.clone().into_os_string()];
246 args.extend(ctx.args.iter().cloned());
247 (args, Vec::new(), file)
248 }
249 };
250
251 Ok(PlannedLaunch {
252 plan: LaunchPlan { executable: found.path, args, env, cwd: None },
253 profile: ctx.profile.clone(),
254 profile_dir: dir,
255 paths,
256 executable_origin: found.origin,
257 mechanism: self.sentence_for(&target),
258 sensitive_env: sensitive_env(metadata),
259 notes: Vec::new(),
260 })
261 }
262}
263
264fn discover(metadata: &AdapterMetadata, ctx: &PlanContext<'_>) -> Result<exe::Found> {
265 exe::discover(
266 metadata.id,
267 metadata.executable,
268 ctx.config.agent_executable(metadata.id),
269 ctx.path_var,
270 &ctx.root.config_path(),
271 )
272}
273
274fn profile_paths(adapter: &dyn Adapter, ctx: &PlanContext<'_>) -> Vec<ProfilePath> {
275 adapter
276 .paths(ctx.root, ctx.profile)
277 .into_iter()
278 .map(|(path, kind)| ProfilePath { existed: has_kind(&path, kind), path, kind })
279 .collect()
280}
281
282fn sensitive_env(metadata: &AdapterMetadata) -> Vec<OsString> {
283 metadata.env.iter().filter(|entry| entry.sensitive).map(|entry| entry.name.into()).collect()
284}
285
286fn has_kind(path: &Path, kind: PathKind) -> bool {
288 fs::metadata(path).is_ok_and(|metadata| match kind {
289 PathKind::Dir => metadata.is_dir(),
290 PathKind::File { .. } => metadata.is_file(),
291 })
292}
293
294pub fn ensure_paths(paths: &[ProfilePath]) -> Result<()> {
296 for entry in paths {
297 let error = |source: io::Error| Error::ProfileDir { path: entry.path.clone(), source };
298 match entry.kind {
299 PathKind::Dir => {
300 create_private_dir_all(&entry.path).map_err(error)?;
301 if !fs::metadata(&entry.path).map_err(error)?.is_dir() {
302 return Err(error(io::Error::other("the path exists but is not a directory")));
303 }
304 }
305 PathKind::File { contents } => {
306 if !has_kind(&entry.path, entry.kind) {
307 write_new_file(&entry.path, contents)?;
308 }
309 }
310 }
311 }
312 Ok(())
313}
314
315fn create_private_dir_all(path: &Path) -> io::Result<()> {
318 let mut builder = fs::DirBuilder::new();
319 builder.recursive(true);
320 #[cfg(unix)]
321 std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
322 builder.create(path)
323}
324
325fn write_new_file(path: &Path, contents: &[u8]) -> Result<()> {
326 write_new_file_with(path, contents, || Ok(()))
327}
328
329fn write_new_file_with(
332 path: &Path,
333 contents: &[u8],
334 before_persist: impl FnOnce() -> Result<()>,
335) -> Result<()> {
336 let error = |source: io::Error| Error::ProfileDir { path: path.to_path_buf(), source };
337 let directory = path.parent().expect("profile file paths have a parent");
338 let name = path.file_name().expect("profile file paths have a name").to_string_lossy();
339 let mut temp = tempfile::Builder::new()
340 .prefix(&format!("{name}."))
341 .suffix(".tmp")
342 .tempfile_in(directory)
343 .map_err(error)?;
344 temp.write_all(contents).and_then(|()| temp.as_file().sync_all()).map_err(error)?;
345 before_persist()?;
346 if let Err(persist) = temp.persist_noclobber(path) {
347 if fs::metadata(path).is_ok_and(|metadata| metadata.is_file()) {
349 return Ok(());
350 }
351 return Err(error(persist.error));
352 }
353 #[cfg(unix)]
354 fs::File::open(directory).and_then(|directory| directory.sync_all()).map_err(|sync| {
355 error(io::Error::new(
356 sync.kind(),
357 format!(
358 "file created, but the directory could not be synced; it may not survive a power loss: {sync}"
359 ),
360 ))
361 })?;
362 Ok(())
363}
364
365#[cfg(all(test, debug_assertions))]
366mod tests {
367 use super::*;
368 use crate::name::Platform;
369
370 fn setup() -> (tempfile::TempDir, AppRoot, Config, PathBuf) {
371 let dir = tempfile::tempdir().unwrap();
372 let root = AppRoot::from_path(dir.path().join("root"));
373 let exe = dir.path().join("fake-agent-bin");
374 fs::write(&exe, b"x").unwrap();
375 fs::create_dir_all(root.path()).unwrap();
376 fs::write(
377 root.config_path(),
378 format!("[agents.fake]\nexecutable = {:?}\n", exe.to_str().unwrap()),
379 )
380 .unwrap();
381 let config = Config::load(&root).unwrap();
382 (dir, root, config, exe)
383 }
384
385 fn profile(name: &str) -> ProfileName {
386 ProfileName::parse(name, Platform::host()).unwrap()
387 }
388
389 fn plan_fake(
390 root: &AppRoot,
391 config: &Config,
392 name: &str,
393 args: &[OsString],
394 ) -> Result<PlannedLaunch> {
395 let profile = profile(name);
396 Fake.plan(&PlanContext { profile: &profile, root, config, args, path_var: None })
397 }
398
399 #[test]
400 fn fake_plan_sets_home_override_and_passes_args_verbatim() {
401 let (_dir, root, config, exe) = setup();
402 let args: Vec<OsString> = vec!["--foo".into(), "a b".into()];
403 let planned = plan_fake(&root, &config, "work", &args).unwrap();
404 let dir = root.profiles_dir().join("work").join("fake");
405 assert_eq!(
406 planned.plan,
407 LaunchPlan {
408 executable: exe,
409 args,
410 env: vec![("FAKE_AGENT_HOME".into(), dir.clone().into_os_string())],
411 cwd: None
412 }
413 );
414 assert_eq!(planned.profile_dir, dir);
415 assert_eq!(
416 planned.paths,
417 vec![ProfilePath { path: dir, kind: PathKind::Dir, existed: false }]
418 );
419 assert_eq!(planned.executable_origin, Origin::Configured);
420 }
421
422 #[test]
423 fn case_only_twin_is_refused_for_any_entry_type() {
424 let (_dir, root, config, _exe) = setup();
425 fs::create_dir_all(root.profiles_dir()).unwrap();
426 fs::write(root.profiles_dir().join("work"), b"a file, not a directory").unwrap();
427 let error = plan_fake(&root, &config, "WORK", &[]).unwrap_err();
428 assert!(
429 matches!(error, Error::ProfileCaseConflict { ref existing, .. } if existing == "work"),
430 "{error:?}"
431 );
432 assert!(plan_fake(&root, &config, "work", &[]).is_ok());
433 }
434
435 #[test]
436 fn initialize_is_idempotent_and_rejects_a_file_at_a_directory_path() {
437 let (_dir, root, config, _exe) = setup();
438 let planned = plan_fake(&root, &config, "work", &[]).unwrap();
439 Fake.initialize(&planned).unwrap();
440 Fake.initialize(&planned).unwrap();
441 assert!(planned.profile_dir.is_dir());
442
443 let blocked = plan_fake(&root, &config, "blocked", &[]).unwrap();
444 fs::create_dir_all(blocked.profile_dir.parent().unwrap()).unwrap();
445 fs::write(&blocked.profile_dir, b"file").unwrap();
446 assert!(matches!(Fake.initialize(&blocked), Err(Error::ProfileDir { .. })));
447 }
448
449 #[test]
450 fn conflict_scan_stops_at_double_dash_and_names_the_option() {
451 let metadata = Aider.metadata();
452 let args = |items: &[&str]| items.iter().map(OsString::from).collect::<Vec<_>>();
453 match check_conflicts(metadata, &args(&["x", "--conf=f"])).unwrap_err() {
454 Error::ArgumentConflict { agent, option, mechanism } => {
455 assert_eq!((agent.as_str(), option.as_str()), ("aider", "--conf"));
456 assert_eq!(mechanism, "argument --config <file>");
457 }
458 other => panic!("{other:?}"),
459 }
460 for (given, printed) in [
463 ("--config=s3cret", "--config"),
464 ("--con=s3cret", "--con"),
465 ("-cs3cret", "-c"),
466 ("-c", "-c"),
467 ] {
468 match check_conflicts(metadata, &args(&["x", given])).unwrap_err() {
469 Error::ArgumentConflict { option, .. } => assert_eq!(option, printed, "{given}"),
470 other => panic!("{other:?}"),
471 }
472 }
473 assert!(check_conflicts(metadata, &args(&["x", "--", "--config", "f"])).is_ok());
474 assert!(check_conflicts(Codex.metadata(), &args(&["-p", "personal"])).is_ok());
475 }
476
477 fn new_file_path() -> (tempfile::TempDir, PathBuf) {
478 let dir = tempfile::tempdir().unwrap();
479 let path = dir.path().join(".aider.conf.yml");
480 (dir, path)
481 }
482
483 #[test]
484 fn new_file_does_not_exist_under_its_final_name_before_persist() {
485 let (_dir, path) = new_file_path();
486 write_new_file_with(&path, b"{}\n", || {
487 assert!(!path.exists(), "the final name must appear only at persist");
488 Ok(())
489 })
490 .unwrap();
491 assert_eq!(fs::read(&path).unwrap(), b"{}\n");
492 }
493
494 #[test]
495 fn the_temp_file_lives_beside_the_target() {
496 let (dir, path) = new_file_path();
497 let names = || -> Vec<String> {
498 fs::read_dir(dir.path())
499 .unwrap()
500 .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
501 .collect()
502 };
503 write_new_file_with(&path, b"{}\n", || {
504 let names = names();
505 assert_eq!(names.len(), 1, "{names:?}");
506 assert!(
507 names[0].starts_with(".aider.conf.yml.") && names[0].ends_with(".tmp"),
508 "{names:?}"
509 );
510 Ok(())
511 })
512 .unwrap();
513 assert_eq!(names(), [".aider.conf.yml"]);
514 }
515
516 #[test]
517 fn a_file_created_before_persist_wins_and_is_never_overwritten() {
518 let (_dir, path) = new_file_path();
519 write_new_file_with(&path, b"{}\n", || {
520 fs::write(&path, b"theirs").unwrap();
521 Ok(())
522 })
523 .unwrap();
524 assert_eq!(fs::read(&path).unwrap(), b"theirs");
525 }
526
527 #[test]
528 fn a_directory_created_before_persist_is_a_profile_error() {
529 let (_dir, path) = new_file_path();
530 let result = write_new_file_with(&path, b"{}\n", || {
531 fs::create_dir(&path).unwrap();
532 Ok(())
533 });
534 assert!(matches!(result, Err(Error::ProfileDir { .. })), "{result:?}");
535 }
536
537 #[test]
538 fn concurrent_file_creation_never_exposes_a_partial_file() {
539 let (dir, path) = new_file_path();
540 fs::write(dir.path().join(".aider.conf.yml.leftover.tmp"), b"stale").unwrap();
541 let entry = ProfilePath {
542 path: path.clone(),
543 kind: PathKind::File { contents: b"{}\n" },
544 existed: false,
545 };
546 let done = std::sync::atomic::AtomicBool::new(false);
547 std::thread::scope(|scope| {
548 let reader = scope.spawn(|| {
549 while !done.load(std::sync::atomic::Ordering::SeqCst) {
550 if let Ok(bytes) = fs::read(&path) {
551 assert_eq!(bytes, b"{}\n");
552 }
553 }
554 });
555 let writers: Vec<_> = (0..8)
556 .map(|_| scope.spawn(|| ensure_paths(std::slice::from_ref(&entry))))
557 .collect();
558 for writer in writers {
559 writer.join().unwrap().unwrap();
560 }
561 done.store(true, std::sync::atomic::Ordering::SeqCst);
562 reader.join().unwrap();
563 });
564 assert_eq!(fs::read(&path).unwrap(), b"{}\n");
565 }
566
567 struct Secretive;
568
569 static SECRETIVE: AdapterMetadata = AdapterMetadata {
570 id: "secretive",
571 executable: "fake-agent",
572 mechanism: Mechanism::Env("PROFILE_SESSION_HANDLE"),
573 support: SupportLevel::Experimental,
574 evidence: AdapterEvidence {
575 mechanism_id: "secretive-v1",
576 verified_at: "2026-09-15",
577 upstream_version: "0.0.0",
578 source_url: "measured",
579 notes: "test-only adapter",
580 },
581 capabilities: &[],
582 env: &[EnvOverride { name: "PROFILE_SESSION_HANDLE", sensitive: true }],
583 conflicts: &[],
584 credential_env: &[],
585 };
586
587 impl Adapter for Secretive {
588 fn metadata(&self) -> &'static AdapterMetadata {
589 &SECRETIVE
590 }
591
592 fn paths(&self, root: &AppRoot, profile: &ProfileName) -> Vec<(PathBuf, PathKind)> {
593 vec![(profile_dir(root, profile, SECRETIVE.id), PathKind::Dir)]
594 }
595
596 fn plan(&self, ctx: &PlanContext<'_>) -> Result<PlannedLaunch> {
597 SECRETIVE.mechanism.plan(self, ctx)
598 }
599 }
600
601 #[test]
602 fn sensitive_declarations_fill_sensitive_env_and_are_redacted() {
603 let (dir, root, _config, exe) = setup();
604 fs::write(
605 root.config_path(),
606 format!("[agents.secretive]\nexecutable = {:?}\n", exe.to_str().unwrap()),
607 )
608 .unwrap();
609 let config = Config::load(&root).unwrap();
610 let profile = profile("work");
611 let ctx = PlanContext {
612 profile: &profile,
613 root: &root,
614 config: &config,
615 args: &[],
616 path_var: None,
617 };
618 let planned = Secretive.plan(&ctx).unwrap();
619 assert_eq!(planned.sensitive_env, vec![OsString::from("PROFILE_SESSION_HANDLE")]);
620 let resolution = crate::resolve::resolve(
621 crate::name::AgentId::parse("secretive").unwrap(),
622 Some(profile.clone()),
623 &config,
624 &crate::repo::Discovery::NotInRepository,
625 );
626 let text = crate::output::report_lines(
627 &planned,
628 &resolution,
629 crate::output::ReportMode::DryRun,
630 &SECRETIVE,
631 )
632 .join("\n");
633 assert!(text.contains("PROFILE_SESSION_HANDLE=<redacted>"), "{text}");
634 drop(dir);
635 }
636}