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