Skip to main content

scv_tools/
adapters.rs

1//! The native agent CLIs SCV can delegate to, one descriptor each.
2//!
3//! A descriptor is the whole integration: the default command line, where the
4//! CLI keeps its state inside SCV's private adapter home, which inherited
5//! variables it must never see, and how `scv agents login|status|logout`
6//! handle it. Adding an agent means adding one entry to [`ADAPTERS`].
7
8use std::{
9    ffi::OsStr,
10    path::{Path, PathBuf},
11};
12
13/// How SCV signs an agent in, inside its private adapter home.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum Login {
16    /// Run the CLI's own sign-in command.
17    Command(&'static [&'static str]),
18    /// Open the CLI interactively; `hint` names its in-app sign-in command.
19    Interactive {
20        args: &'static [&'static str],
21        hint: &'static str,
22    },
23    /// Prompt for an API key and store it in the CLI's own credential file.
24    ApiKey(KeyStore),
25}
26
27/// How SCV reports whether an agent is signed in.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum Status {
30    /// The CLI prints its own status and exits non-zero when signed out.
31    Command(&'static [&'static str]),
32    /// SCV inspects the CLI's credential file without printing secrets.
33    Stored(KeyStore),
34}
35
36/// What a CLI prints on stdout when SCV runs it, and so how SCV reads its
37/// reply, usage, and failure out of it.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum OutputFormat {
40    /// Plain text: stdout is the reply.
41    Text,
42    /// Claude Code `--output-format stream-json --verbose`: one JSON event per
43    /// line, ending with a `result` event.
44    ClaudeStreamJson,
45    /// `codex exec --json`: JSON events per line; SCV also passes `-o <file>`
46    /// so the final message survives an unparsable stream.
47    CodexJsonl,
48    /// pi `--mode json`: JSON events per line; the reply is the last
49    /// assistant `message_end`.
50    PiJson,
51}
52
53impl OutputFormat {
54    /// Arguments that select this format, placed after the fixed arguments.
55    pub fn args(self) -> &'static [&'static str] {
56        match self {
57            Self::Text => &[],
58            Self::ClaudeStreamJson => &["--output-format", "stream-json", "--verbose"],
59            Self::CodexJsonl => &["--json"],
60            Self::PiJson => &["--mode", "json"],
61        }
62    }
63}
64
65/// How a CLI continues an earlier conversation. `{session}` in any argument
66/// is replaced by the conversation's vendor session ID.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum Resume {
69    /// Every call starts a fresh conversation.
70    Unsupported,
71    Supported {
72        /// Starts a conversation under an ID SCV chooses. Empty when the CLI
73        /// picks its own ID and reports it in its output (Codex).
74        start: &'static [&'static str],
75        /// Placed right after the fixed arguments when continuing: a
76        /// subcommand such as Codex's `exec resume`.
77        subcommand: &'static [&'static str],
78        /// Options that continue the conversation.
79        options: &'static [&'static str],
80        /// Placed immediately before the prompt when continuing, for a CLI
81        /// that takes the session ID as a positional argument.
82        positional: &'static [&'static str],
83    },
84}
85
86impl Resume {
87    pub fn is_supported(self) -> bool {
88        matches!(self, Self::Supported { .. })
89    }
90
91    /// Whether SCV chooses the vendor session ID when a conversation starts.
92    pub fn assigns_id(self) -> bool {
93        matches!(self, Self::Supported { start, .. } if !start.is_empty())
94    }
95}
96
97/// Where a CLI keeps conversation transcripts inside its adapter home:
98/// files with `extension` anywhere below `dir`, named after their session ID.
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub struct ConversationFiles {
101    pub dir: &'static str,
102    pub extension: &'static str,
103}
104
105/// How SCV condenses a CLI's own status output. The raw output names the
106/// account (an email) or part of a key, so it is never printed.
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub enum StatusSummary {
109    /// `claude auth status` JSON: `loggedIn`, `authMethod`, `subscriptionType`.
110    ClaudeJson,
111    /// `codex login status` text: "Logged in using an API key" or "ChatGPT".
112    CodexText,
113    /// The exit status alone.
114    ExitStatus,
115}
116
117/// How SCV signs an agent out.
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum Logout {
120    Command(&'static [&'static str]),
121    /// SCV removes the credentials it can see in the CLI's own files.
122    Stored(KeyStore),
123}
124
125/// A CLI's native credential file, relative to the adapter home.
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub enum KeyStore {
128    /// A JSON object whose entries are stored sign-ins (Grok's `auth.json`).
129    JsonEntries(&'static str),
130    /// DeepSeek Harness `.credentials.yaml`, holding `refs.<variable>`.
131    DshRefs {
132        path: &'static str,
133        variable: &'static str,
134    },
135    /// pi's agent directory: `auth.json`, plus the SCV-configured
136    /// OpenAI-compatible endpoint in `models.json` and `settings.json`.
137    Pi { dir: &'static str },
138}
139
140#[derive(Debug, Clone, Copy)]
141pub struct AdapterDescriptor {
142    /// Short name: the tool is `agent_<name>` and the home `adapters/<name>`.
143    pub name: &'static str,
144    /// Product name for messages.
145    pub product: &'static str,
146    pub command: &'static str,
147    pub args: &'static [&'static str],
148    /// Placed immediately before the prompt, for CLIs whose prompt is a flag
149    /// value (`grok -p <prompt>`).
150    pub prompt_args: &'static [&'static str],
151    pub model_args: &'static [&'static str],
152    pub effort_args: &'static [&'static str],
153    /// Describes the `model` argument for the calling model.
154    pub model_hint: &'static str,
155    /// Variables pointing the CLI's state into the adapter home, as paths
156    /// relative to it (`""` is the home itself).
157    pub home_environment: &'static [(&'static str, &'static str)],
158    /// Fixed variables for every delegated run.
159    pub fixed_environment: &'static [(&'static str, &'static str)],
160    /// Credential, endpoint, and state-location variables no delegated agent
161    /// inherits. A trailing `*` matches a prefix.
162    pub removed_environment: &'static [&'static str],
163    /// Added after `args` when `[agents.<name>] permissions = "full"`: the
164    /// CLI's own switches that turn off its approval prompts and sandbox and
165    /// enable web search where the CLI gates it. Empty when the CLI has no
166    /// permission system of its own.
167    pub full_permission_args: &'static [&'static str],
168    /// Variables set for `permissions = "full"`, for CLIs configured that way.
169    pub full_permission_environment: &'static [(&'static str, &'static str)],
170    /// Per-user install directories searched before `PATH`, relative to the
171    /// user's home, as a login shell orders them. A user service's `PATH`
172    /// omits them, so without this the daemon would miss or pick a different
173    /// install than the user's shell.
174    pub search_dirs: &'static [&'static str],
175    pub login: Login,
176    pub status: Status,
177    /// How a [`Status::Command`] result is summarized.
178    pub status_summary: StatusSummary,
179    pub logout: Logout,
180    /// What the CLI prints when SCV delegates to it.
181    pub output: OutputFormat,
182    /// How SCV continues a conversation with it, when it can.
183    pub resume: Resume,
184    /// Transcripts `scv agents gc` may remove; `None` when unknown.
185    pub conversation_files: Option<ConversationFiles>,
186}
187
188/// Directories every adapter searches before `PATH`, relative to the user's home.
189const USER_BIN_DIRS: &[&str] = &[".local/bin"];
190
191/// Removed from every agent regardless of adapter: SCV's own selectors and
192/// cloud keys that name no single agent. Any variable ending in `_API_KEY`
193/// is removed as well.
194const COMMON_REMOVED_ENVIRONMENT: &[&str] = &[
195    "SCV_CONFIG",
196    "SCV_MODEL",
197    "SCV_PROVIDER",
198    "SCV_BASE_URL",
199    "SCV_API_KEY_ENV",
200    "GEMINI_API_KEY",
201    "GOOGLE_API_KEY",
202    "AZURE_OPENAI_API_KEY",
203    "AZURE_OPENAI_ENDPOINT",
204];
205
206const PI_STORE: KeyStore = KeyStore::Pi { dir: ".pi/agent" };
207const DSH_STORE: KeyStore = KeyStore::DshRefs {
208    path: ".dsh/.credentials.yaml",
209    variable: "DEEPSEEK_API_KEY",
210};
211
212pub const ADAPTERS: &[AdapterDescriptor] = &[
213    AdapterDescriptor {
214        name: "claude",
215        product: "Claude Code",
216        command: "claude",
217        args: &["-p"],
218        prompt_args: &[],
219        model_args: &["--model", "{model}"],
220        effort_args: &["--effort", "{effort}"],
221        model_hint: "Claude model alias or ID, such as sonnet or opus.",
222        home_environment: &[],
223        fixed_environment: &[],
224        removed_environment: &[
225            "ANTHROPIC_API_KEY",
226            "ANTHROPIC_BASE_URL",
227            "ANTHROPIC_AUTH_TOKEN",
228            "CLAUDE_CODE_OAUTH_TOKEN",
229            "CLAUDE_CONFIG_DIR",
230        ],
231        // Also allows WebSearch and WebFetch without prompting.
232        full_permission_args: &["--permission-mode", "bypassPermissions"],
233        full_permission_environment: &[],
234        search_dirs: &[],
235        login: Login::Command(&["auth", "login"]),
236        status: Status::Command(&["auth", "status"]),
237        status_summary: StatusSummary::ClaudeJson,
238        logout: Logout::Command(&["auth", "logout"]),
239        output: OutputFormat::ClaudeStreamJson,
240        // `--resume` in print mode keeps the original session ID.
241        resume: Resume::Supported {
242            start: &["--session-id", "{session}"],
243            subcommand: &[],
244            options: &["--resume", "{session}"],
245            positional: &[],
246        },
247        conversation_files: Some(ConversationFiles {
248            dir: ".claude/projects",
249            extension: "jsonl",
250        }),
251    },
252    AdapterDescriptor {
253        name: "codex",
254        product: "Codex",
255        command: "codex",
256        args: &["exec"],
257        prompt_args: &[],
258        model_args: &["-m", "{model}"],
259        effort_args: &["-c", "model_reasoning_effort=\"{effort}\""],
260        model_hint: "OpenAI model ID from the Codex configuration; not a Claude alias.",
261        home_environment: &[("CODEX_HOME", "")],
262        fixed_environment: &[],
263        removed_environment: &[
264            "OPENAI_API_KEY",
265            "OPENAI_BASE_URL",
266            "OPENAI_ORG_ID",
267            "OPENAI_PROJECT_ID",
268            "CODEX_API_KEY",
269            "CODEX_BASE_URL",
270        ],
271        // `codex exec` has no `--search`; `web_search = "live"` is its config form.
272        full_permission_args: &[
273            "--dangerously-bypass-approvals-and-sandbox",
274            "-c",
275            "web_search=\"live\"",
276        ],
277        full_permission_environment: &[],
278        search_dirs: &[],
279        login: Login::Command(&["login"]),
280        status: Status::Command(&["login", "status"]),
281        status_summary: StatusSummary::CodexText,
282        logout: Logout::Command(&["logout"]),
283        output: OutputFormat::CodexJsonl,
284        // The thread ID arrives in `thread.started`; `exec resume` takes it
285        // as a positional argument before the prompt.
286        resume: Resume::Supported {
287            start: &[],
288            subcommand: &["resume"],
289            options: &[],
290            positional: &["{session}"],
291        },
292        conversation_files: Some(ConversationFiles {
293            dir: "sessions",
294            extension: "jsonl",
295        }),
296    },
297    AdapterDescriptor {
298        name: "grok",
299        product: "Grok Build",
300        command: "grok",
301        args: &[],
302        prompt_args: &["-p"],
303        model_args: &["-m", "{model}"],
304        effort_args: &["--reasoning-effort", "{effort}"],
305        model_hint: "xAI Grok model ID, such as grok-4.7.",
306        home_environment: &[("GROK_HOME", ".grok")],
307        fixed_environment: &[("GROK_DISABLE_AUTOUPDATER", "1")],
308        removed_environment: &["GROK_*", "XAI_API_KEY"],
309        // Web search is on unless `--disable-web-search` is passed.
310        full_permission_args: &["--always-approve"],
311        full_permission_environment: &[],
312        search_dirs: &[".grok/bin"],
313        login: Login::Command(&["login"]),
314        status: Status::Stored(KeyStore::JsonEntries(".grok/auth.json")),
315        status_summary: StatusSummary::ExitStatus,
316        logout: Logout::Command(&["logout"]),
317        // `--output-format json` exists but its success shape is unverified here.
318        output: OutputFormat::Text,
319        // Grok documents `--session-id` and `--resume`, but they cannot be
320        // verified while it is signed out here.
321        resume: Resume::Unsupported,
322        conversation_files: None,
323    },
324    AdapterDescriptor {
325        name: "dsh",
326        product: "DeepSeek Harness",
327        command: "dsh",
328        args: &["--profile", "headless"],
329        prompt_args: &[],
330        model_args: &[],
331        effort_args: &[],
332        model_hint: "Model ID in the form this agent's CLI accepts.",
333        home_environment: &[("DSH_HOME", ".dsh")],
334        fixed_environment: &[],
335        removed_environment: &["DSH_*", "DEEPSEEK_API_KEY", "DEEPSEEK_BASE_URL"],
336        // Bypasses its file sandbox and sets its approval policy to `never`.
337        full_permission_args: &[],
338        full_permission_environment: &[("DSH_PERMISSION_MODE", "danger-full-access")],
339        search_dirs: &[],
340        login: Login::ApiKey(DSH_STORE),
341        status: Status::Stored(DSH_STORE),
342        status_summary: StatusSummary::ExitStatus,
343        logout: Logout::Stored(DSH_STORE),
344        output: OutputFormat::Text,
345        // Only its interactive profile documents `--resume`.
346        resume: Resume::Unsupported,
347        conversation_files: None,
348    },
349    AdapterDescriptor {
350        name: "pi",
351        product: "pi",
352        command: "pi",
353        args: &["-p"],
354        prompt_args: &[],
355        model_args: &["--model", "{model}"],
356        effort_args: &["--thinking", "{effort}"],
357        model_hint: "pi model pattern or provider/id; the SCV-configured endpoint is provider scv.",
358        home_environment: &[("PI_CODING_AGENT_DIR", ".pi/agent")],
359        fixed_environment: &[],
360        removed_environment: &["PI_*"],
361        // pi has no approval prompts or sandbox, and no built-in web search.
362        full_permission_args: &[],
363        full_permission_environment: &[],
364        search_dirs: &[],
365        login: Login::Interactive {
366            args: &[],
367            hint: "run /login and choose a provider, then /quit",
368        },
369        status: Status::Stored(PI_STORE),
370        status_summary: StatusSummary::ExitStatus,
371        logout: Logout::Stored(PI_STORE),
372        output: OutputFormat::PiJson,
373        // `--session-id` uses the exact project session, creating it if missing.
374        resume: Resume::Supported {
375            start: &["--session-id", "{session}"],
376            subcommand: &[],
377            options: &["--session-id", "{session}"],
378            positional: &[],
379        },
380        conversation_files: Some(ConversationFiles {
381            dir: ".pi/agent/sessions",
382            extension: "jsonl",
383        }),
384    },
385];
386
387/// The descriptor for `name`, such as `"codex"`.
388pub fn adapter(name: &str) -> Option<&'static AdapterDescriptor> {
389    ADAPTERS.iter().find(|adapter| adapter.name == name)
390}
391
392/// Whether a delegated agent must not inherit `variable`: SCV's selectors,
393/// any `*_API_KEY`, and every adapter's credential and state variables, so
394/// no agent sees another's credentials either.
395pub fn is_removed_agent_variable(variable: &OsStr) -> bool {
396    let Some(variable) = variable.to_str() else {
397        return false;
398    };
399    variable.ends_with("_API_KEY")
400        || COMMON_REMOVED_ENVIRONMENT.contains(&variable)
401        || ADAPTERS
402            .iter()
403            .flat_map(|adapter| adapter.removed_environment)
404            .any(|rule| match rule.strip_suffix('*') {
405                Some(prefix) => variable.starts_with(prefix),
406                None => variable == *rule,
407            })
408}
409
410/// One line describing a CLI's own status result without echoing it: the raw
411/// output names the signed-in account or part of a key.
412pub fn summarize_status(summary: StatusSummary, succeeded: bool, output: &str) -> String {
413    let signed_out = "not signed in".to_owned();
414    match summary {
415        StatusSummary::ClaudeJson => {
416            // The first JSON value; anything after it (such as stderr) is ignored.
417            let first = serde_json::Deserializer::from_str(output)
418                .into_iter::<serde_json::Value>()
419                .next();
420            let Some(Ok(value)) = first else {
421                return if succeeded {
422                    "signed in".into()
423                } else {
424                    signed_out
425                };
426            };
427            if value.get("loggedIn").and_then(serde_json::Value::as_bool) != Some(true) {
428                return signed_out;
429            }
430            let method = match value.get("authMethod").and_then(serde_json::Value::as_str) {
431                Some("claude.ai") => "Claude account",
432                Some("api_key" | "apiKey" | "console") => "API key",
433                Some("oauth_token" | "oauthToken") => "OAuth token",
434                _ => "other method",
435            };
436            match value
437                .get("subscriptionType")
438                .and_then(serde_json::Value::as_str)
439                .filter(|plan| ["free", "pro", "max", "team", "enterprise"].contains(plan))
440            {
441                Some(plan) => format!("signed in ({method}, {plan})"),
442                None => format!("signed in ({method})"),
443            }
444        }
445        StatusSummary::CodexText => {
446            let lower = output.to_ascii_lowercase();
447            if !succeeded || lower.contains("not logged in") {
448                signed_out
449            } else if lower.contains("api key") {
450                "signed in (API key)".into()
451            } else if lower.contains("chatgpt") {
452                "signed in (ChatGPT account)".into()
453            } else {
454                "signed in".into()
455            }
456        }
457        StatusSummary::ExitStatus => {
458            if succeeded {
459                "signed in".into()
460            } else {
461                signed_out
462            }
463        }
464    }
465}
466
467/// Resolve `command` in the per-user `search_dirs`, then on `PATH`. A command
468/// containing a path separator is used as given.
469pub fn resolve_agent_executable(command: &str, search_dirs: &[PathBuf]) -> Option<PathBuf> {
470    if command.contains('/') {
471        let path = Path::new(command);
472        return path.is_file().then(|| path.to_path_buf());
473    }
474    std::env::join_paths(search_dirs)
475        .ok()
476        .and_then(|dirs| {
477            let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
478            which::which_in(command, Some(dirs), cwd).ok()
479        })
480        .or_else(|| which::which(command).ok())
481}
482
483/// Absolute per-user search directories for `adapter` under `home`.
484pub fn adapter_search_dirs(adapter: &AdapterDescriptor, home: &Path) -> Vec<PathBuf> {
485    adapter
486        .search_dirs
487        .iter()
488        .chain(USER_BIN_DIRS)
489        .map(|dir| home.join(dir))
490        .collect()
491}
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496
497    #[test]
498    fn descriptors_are_unique_and_self_consistent() {
499        let mut names: Vec<_> = ADAPTERS.iter().map(|adapter| adapter.name).collect();
500        names.sort_unstable();
501        names.dedup();
502        assert_eq!(names.len(), ADAPTERS.len());
503        for adapter in ADAPTERS {
504            assert!(
505                adapter.model_args.is_empty()
506                    || adapter.model_args.iter().any(|arg| arg.contains("{model}")),
507                "{}",
508                adapter.name
509            );
510            assert!(
511                adapter.effort_args.is_empty()
512                    || adapter
513                        .effort_args
514                        .iter()
515                        .any(|arg| arg.contains("{effort}")),
516                "{}",
517                adapter.name
518            );
519            if let Resume::Supported {
520                start,
521                subcommand,
522                options,
523                positional,
524            } = adapter.resume
525            {
526                let names_session =
527                    |args: &[&str]| args.iter().any(|arg| arg.contains("{session}"));
528                assert!(start.is_empty() || names_session(start), "{}", adapter.name);
529                assert!(
530                    names_session(options) || names_session(positional),
531                    "{}",
532                    adapter.name
533                );
534                assert!(!names_session(subcommand), "{}", adapter.name);
535                assert!(adapter.conversation_files.is_some(), "{}", adapter.name);
536            }
537            // Anything SCV sets must survive the removal pass.
538            for (variable, _) in adapter
539                .home_environment
540                .iter()
541                .chain(adapter.fixed_environment)
542            {
543                assert!(!variable.ends_with("_API_KEY"), "{variable}");
544            }
545            // Stored credentials live inside the directory SCV points the CLI at.
546            for store in [
547                match adapter.status {
548                    Status::Stored(store) => Some(store),
549                    Status::Command(_) => None,
550                },
551                match adapter.logout {
552                    Logout::Stored(store) => Some(store),
553                    Logout::Command(_) => None,
554                },
555                match adapter.login {
556                    Login::ApiKey(store) => Some(store),
557                    _ => None,
558                },
559            ]
560            .into_iter()
561            .flatten()
562            {
563                let path = match store {
564                    KeyStore::JsonEntries(path) | KeyStore::DshRefs { path, .. } => path,
565                    KeyStore::Pi { dir } => dir,
566                };
567                assert!(
568                    adapter
569                        .home_environment
570                        .iter()
571                        .any(|(_, home)| !home.is_empty() && path.starts_with(home)),
572                    "{}: {path}",
573                    adapter.name
574                );
575            }
576        }
577    }
578
579    #[test]
580    fn status_summaries_never_echo_accounts_or_keys() {
581        let claude = r#"{"loggedIn":true,"authMethod":"claude.ai","email":"me@example.com","orgName":"me@example.com's Organization","subscriptionType":"max"}"#;
582        assert_eq!(
583            summarize_status(StatusSummary::ClaudeJson, true, claude),
584            "signed in (Claude account, max)"
585        );
586        assert_eq!(
587            summarize_status(
588                StatusSummary::ClaudeJson,
589                true,
590                r#"{"loggedIn":true,"authMethod":"api_key","subscriptionType":"me@example.com"}"#
591            ),
592            "signed in (API key)"
593        );
594        assert_eq!(
595            summarize_status(StatusSummary::ClaudeJson, false, r#"{"loggedIn":false}"#),
596            "not signed in"
597        );
598        assert_eq!(
599            summarize_status(
600                StatusSummary::ClaudeJson,
601                true,
602                "{\"loggedIn\":true,\"authMethod\":\"claude.ai\"}\n\nsome stderr"
603            ),
604            "signed in (Claude account)"
605        );
606        assert_eq!(
607            summarize_status(
608                StatusSummary::CodexText,
609                true,
610                "Logged in using an API key - sk-proj-***abcd"
611            ),
612            "signed in (API key)"
613        );
614        assert_eq!(
615            summarize_status(StatusSummary::CodexText, true, "Logged in using ChatGPT"),
616            "signed in (ChatGPT account)"
617        );
618        assert_eq!(
619            summarize_status(StatusSummary::CodexText, false, "Not logged in"),
620            "not signed in"
621        );
622        for adapter in ADAPTERS {
623            if let Status::Command(_) = adapter.status {
624                assert_ne!(
625                    adapter.status_summary,
626                    StatusSummary::ExitStatus,
627                    "{}",
628                    adapter.name
629                );
630            }
631        }
632    }
633
634    #[test]
635    fn removal_covers_every_adapter_and_generic_api_keys() {
636        for removed in [
637            "OPENAI_API_KEY",
638            "CLAUDE_CONFIG_DIR",
639            "GROK_HOME",
640            "GROK_AUTH",
641            "XAI_API_KEY",
642            "DSH_HOME",
643            "DSH_PERMISSION_MODE",
644            "DEEPSEEK_BASE_URL",
645            "PI_CODING_AGENT_DIR",
646            "OPENROUTER_API_KEY",
647            "SCV_CONFIG",
648        ] {
649            assert!(is_removed_agent_variable(OsStr::new(removed)), "{removed}");
650        }
651        for kept in ["PATH", "HOME", "LANG", "GH_TOKEN", "GROKKING", "PIPX_HOME"] {
652            assert!(!is_removed_agent_variable(OsStr::new(kept)), "{kept}");
653        }
654    }
655
656    #[test]
657    fn executables_resolve_from_per_user_directories_before_path() {
658        let dir = tempfile::tempdir().unwrap();
659        let bin = dir.path().join(".grok/bin");
660        std::fs::create_dir_all(&bin).unwrap();
661        let name = "scv-test-agent-only-in-home";
662        let executable = bin.join(name);
663        std::fs::write(&executable, "#!/bin/sh\n").unwrap();
664        #[cfg(unix)]
665        {
666            use std::os::unix::fs::PermissionsExt;
667            std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap();
668        }
669        let grok = adapter("grok").unwrap();
670        let dirs = adapter_search_dirs(grok, dir.path());
671        assert!(dirs.contains(&dir.path().join(".local/bin")));
672        assert_eq!(
673            resolve_agent_executable(name, &dirs),
674            Some(executable.clone())
675        );
676        assert_eq!(resolve_agent_executable(name, &[]), None);
677        // A per-user install wins over the same command on PATH.
678        let shadow = bin.join("sh");
679        std::fs::write(&shadow, "#!/bin/sh\n").unwrap();
680        #[cfg(unix)]
681        {
682            use std::os::unix::fs::PermissionsExt;
683            std::fs::set_permissions(&shadow, std::fs::Permissions::from_mode(0o755)).unwrap();
684        }
685        assert_eq!(resolve_agent_executable("sh", &dirs), Some(shadow));
686        assert!(resolve_agent_executable("sh", &[]).is_some());
687        assert_eq!(
688            resolve_agent_executable(executable.to_str().unwrap(), &[]),
689            Some(executable)
690        );
691    }
692}