Skip to main content

track_core/
wizard.rs

1use std::collections::BTreeMap;
2use std::fs;
3use std::io::{self, IsTerminal};
4
5use dialoguer::{theme::ColorfulTheme, Input};
6
7use crate::config::{
8    ApiConfigFile, ConfigService, LlamaCppConfigFile, RemoteAgentConfigFile,
9    RemoteAgentReviewFollowUpConfigFile, TrackConfigFile, DEFAULT_LLAMACPP_MODEL_HF_FILE,
10    DEFAULT_LLAMACPP_MODEL_HF_REPO, DEFAULT_REMOTE_AGENT_PORT, DEFAULT_REMOTE_AGENT_WORKSPACE_ROOT,
11    DEFAULT_REMOTE_PROJECTS_REGISTRY_PATH,
12};
13use crate::errors::{ErrorCode, TrackError};
14use crate::paths::{
15    collapse_home_path, collapse_path_value, get_managed_remote_agent_key_path,
16    get_managed_remote_agent_known_hosts_path, resolve_path_from_invocation_dir,
17};
18use crate::terminal_ui::{
19    format_note, format_prompt_label, format_summary, SummaryTone, ValueTone,
20};
21use crate::types::RemoteAgentPreferredTool;
22
23pub const NONE_SENTINEL: &str = "none";
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum ConfigureReason {
27    FirstRun,
28    Manual,
29}
30
31pub trait Prompter {
32    fn ask(&mut self, prompt: &str) -> Result<String, TrackError>;
33    fn println(&mut self, line: &str);
34}
35
36pub struct TerminalPrompter;
37
38impl Prompter for TerminalPrompter {
39    fn ask(&mut self, prompt: &str) -> Result<String, TrackError> {
40        Input::<String>::with_theme(&ColorfulTheme::default())
41            .with_prompt(prompt)
42            .allow_empty(true)
43            .interact_text()
44            .map_err(|error| {
45                TrackError::new(
46                    ErrorCode::InteractiveRequired,
47                    format!("Could not read interactive input: {error}"),
48                )
49            })
50    }
51
52    fn println(&mut self, line: &str) {
53        println!("{line}");
54    }
55}
56
57pub fn parse_project_roots_input(input: &str) -> Vec<String> {
58    input
59        .split([',', '\n'])
60        .map(|value| value.trim().to_owned())
61        .filter(|value| !value.is_empty())
62        .collect::<Vec<_>>()
63}
64
65pub fn parse_project_aliases_input(input: &str) -> Result<BTreeMap<String, String>, TrackError> {
66    if input.trim().is_empty() {
67        return Ok(BTreeMap::new());
68    }
69
70    let mut aliases = BTreeMap::new();
71
72    for entry in input
73        .split([',', '\n'])
74        .map(|value| value.trim())
75        .filter(|value| !value.is_empty())
76    {
77        let Some((alias, canonical_name)) = entry.split_once('=') else {
78            return Err(TrackError::new(
79                ErrorCode::InvalidConfigInput,
80                "Project aliases must use alias=canonical-name format.",
81            ));
82        };
83
84        let alias = alias.trim();
85        let canonical_name = canonical_name.trim();
86        if alias.is_empty() || canonical_name.is_empty() {
87            return Err(TrackError::new(
88                ErrorCode::InvalidConfigInput,
89                "Project aliases must use alias=canonical-name format.",
90            ));
91        }
92
93        aliases.insert(alias.to_owned(), canonical_name.to_owned());
94    }
95
96    Ok(aliases)
97}
98
99fn format_project_aliases_input(aliases: &BTreeMap<String, String>) -> String {
100    aliases
101        .iter()
102        .map(|(alias, canonical_name)| format!("{alias}={canonical_name}"))
103        .collect::<Vec<_>>()
104        .join(", ")
105}
106
107fn format_project_roots_display(roots: &[String]) -> String {
108    roots
109        .iter()
110        .map(|value| collapse_path_value(value))
111        .collect::<Vec<_>>()
112        .join(", ")
113}
114
115fn create_default_config_file() -> TrackConfigFile {
116    TrackConfigFile {
117        project_roots: Vec::new(),
118        project_aliases: BTreeMap::new(),
119        api: ApiConfigFile::default(),
120        llama_cpp: LlamaCppConfigFile::default(),
121        remote_agent: None,
122    }
123}
124
125fn ensure_interactive_terminal(config_path: &std::path::Path) -> Result<(), TrackError> {
126    if io::stdin().is_terminal() && io::stdout().is_terminal() {
127        return Ok(());
128    }
129
130    Err(TrackError::new(
131        ErrorCode::InteractiveRequired,
132        format!(
133            "Config setup requires an interactive terminal. Create {} manually or rerun `track` in a terminal.",
134            collapse_home_path(config_path)
135        ),
136    ))
137}
138
139fn prompt_with_default(
140    prompter: &mut dyn Prompter,
141    label: &str,
142    default_value: Option<&str>,
143    allow_clear: bool,
144) -> Result<String, TrackError> {
145    let prompt = format_prompt_label(label, default_value.filter(|value| !value.is_empty()));
146
147    let response = prompter.ask(&prompt)?.trim().to_owned();
148
149    if allow_clear && response.eq_ignore_ascii_case(NONE_SENTINEL) {
150        return Ok(String::new());
151    }
152
153    if response.is_empty() {
154        return Ok(default_value.unwrap_or_default().to_owned());
155    }
156
157    Ok(response)
158}
159
160fn prompt_required_value(
161    prompter: &mut dyn Prompter,
162    label: &str,
163    default_value: Option<&str>,
164) -> Result<String, TrackError> {
165    loop {
166        let response = prompt_with_default(prompter, label, default_value, false)?;
167        if !response.trim().is_empty() {
168            return Ok(response.trim().to_owned());
169        }
170
171        prompter.println("Please enter a value.");
172    }
173}
174
175fn prompt_project_roots(
176    prompter: &mut dyn Prompter,
177    defaults: &[String],
178) -> Result<Vec<String>, TrackError> {
179    loop {
180        let response = prompt_with_default(
181            prompter,
182            "Project roots (comma-separated)",
183            Some(&format_project_roots_display(defaults)),
184            false,
185        )?;
186
187        let project_roots = parse_project_roots_input(&response);
188        if !project_roots.is_empty() {
189            return Ok(project_roots);
190        }
191
192        prompter.println("Please enter at least one project root.");
193    }
194}
195
196fn prompt_project_aliases(
197    prompter: &mut dyn Prompter,
198    defaults: &BTreeMap<String, String>,
199) -> Result<BTreeMap<String, String>, TrackError> {
200    loop {
201        let response = prompt_with_default(
202            prompter,
203            "Project aliases (alias=canonical-name, comma-separated)",
204            Some(&format_project_aliases_input(defaults)),
205            true,
206        )?;
207
208        match parse_project_aliases_input(&response) {
209            Ok(aliases) => return Ok(aliases),
210            Err(error) => prompter.println(error.message()),
211        }
212    }
213}
214
215fn prompt_api_port(prompter: &mut dyn Prompter, default_port: u16) -> Result<u16, TrackError> {
216    loop {
217        let response = prompt_with_default(
218            prompter,
219            "Local API port",
220            Some(&default_port.to_string()),
221            false,
222        )?;
223
224        match response.parse::<u16>() {
225            Ok(port) if port > 0 => return Ok(port),
226            _ => prompter.println("Please enter a valid TCP port."),
227        }
228    }
229}
230
231fn prompt_remote_agent_host(
232    prompter: &mut dyn Prompter,
233    default_host: Option<&str>,
234) -> Result<Option<String>, TrackError> {
235    let response = prompt_with_default(prompter, "Remote agent host", default_host, true)?;
236    let trimmed = response.trim();
237    if trimmed.is_empty() {
238        Ok(None)
239    } else {
240        Ok(Some(trimmed.to_owned()))
241    }
242}
243
244fn prompt_remote_agent_port(
245    prompter: &mut dyn Prompter,
246    default_port: u16,
247) -> Result<u16, TrackError> {
248    loop {
249        let response = prompt_with_default(
250            prompter,
251            "Remote SSH port",
252            Some(&default_port.to_string()),
253            false,
254        )?;
255
256        match response.parse::<u16>() {
257            Ok(port) if port > 0 => return Ok(port),
258            _ => prompter.println("Please enter a valid SSH port."),
259        }
260    }
261}
262
263fn prompt_yes_no(
264    prompter: &mut dyn Prompter,
265    label: &str,
266    default_value: bool,
267) -> Result<bool, TrackError> {
268    loop {
269        let default_display = if default_value { "yes" } else { "no" };
270        let response = prompt_with_default(prompter, label, Some(default_display), false)?;
271
272        match response.trim().to_ascii_lowercase().as_str() {
273            "y" | "yes" | "true" => return Ok(true),
274            "n" | "no" | "false" => return Ok(false),
275            _ => prompter.println("Please answer yes or no."),
276        }
277    }
278}
279
280fn managed_remote_agent_key_exists() -> Result<bool, TrackError> {
281    Ok(get_managed_remote_agent_key_path()?.exists())
282}
283
284fn install_managed_remote_agent_key(source_path: &str) -> Result<(), TrackError> {
285    let source_path = resolve_path_from_invocation_dir(source_path)?;
286    let managed_key_path = get_managed_remote_agent_key_path()?;
287    let known_hosts_path = get_managed_remote_agent_known_hosts_path()?;
288
289    let Some(parent_directory) = managed_key_path.parent() else {
290        return Err(TrackError::new(
291            ErrorCode::InvalidRemoteAgentConfig,
292            "Could not determine the managed remote-agent directory.",
293        ));
294    };
295
296    fs::create_dir_all(parent_directory).map_err(|error| {
297        TrackError::new(
298            ErrorCode::InvalidRemoteAgentConfig,
299            format!(
300                "Could not create the managed remote-agent directory at {}: {error}",
301                collapse_home_path(parent_directory)
302            ),
303        )
304    })?;
305
306    fs::copy(&source_path, &managed_key_path).map_err(|error| {
307        TrackError::new(
308            ErrorCode::InvalidRemoteAgentConfig,
309            format!(
310                "Could not copy the SSH private key from {} to {}: {error}",
311                collapse_home_path(&source_path),
312                collapse_home_path(&managed_key_path)
313            ),
314        )
315    })?;
316
317    #[cfg(unix)]
318    {
319        use std::os::unix::fs::PermissionsExt;
320
321        fs::set_permissions(&managed_key_path, fs::Permissions::from_mode(0o600)).map_err(
322            |error| {
323                TrackError::new(
324                    ErrorCode::InvalidRemoteAgentConfig,
325                    format!(
326                        "Could not set permissions on the managed SSH private key at {}: {error}",
327                        collapse_home_path(&managed_key_path)
328                    ),
329                )
330            },
331        )?;
332    }
333
334    if !known_hosts_path.exists() {
335        fs::write(&known_hosts_path, "").map_err(|error| {
336            TrackError::new(
337                ErrorCode::InvalidRemoteAgentConfig,
338                format!(
339                    "Could not create the managed known_hosts file at {}: {error}",
340                    collapse_home_path(&known_hosts_path)
341                ),
342            )
343        })?;
344    }
345
346    Ok(())
347}
348
349fn prompt_remote_agent_key_import(
350    prompter: &mut dyn Prompter,
351    has_existing_managed_key: bool,
352) -> Result<(), TrackError> {
353    loop {
354        let label = if has_existing_managed_key {
355            "SSH private key to import"
356        } else {
357            "SSH private key to import"
358        };
359        let response = prompt_with_default(prompter, label, None, false)?;
360        let trimmed = response.trim();
361
362        if trimmed.is_empty() && has_existing_managed_key {
363            return Ok(());
364        }
365
366        if trimmed.is_empty() {
367            prompter.println(
368                "Please provide a private SSH key path or finish setup later by rerunning `track`.",
369            );
370            continue;
371        }
372
373        return install_managed_remote_agent_key(trimmed);
374    }
375}
376
377fn format_config_saved_output(
378    config: &TrackConfigFile,
379    config_path: &std::path::Path,
380    reason: ConfigureReason,
381) -> String {
382    let (remote_agent_display, remote_agent_tone) = match config.remote_agent.as_ref() {
383        Some(remote_agent) => (
384            format!(
385                "{}@{}:{}",
386                remote_agent.user, remote_agent.host, remote_agent.port
387            ),
388            ValueTone::Plain,
389        ),
390        None => ("disabled".to_owned(), ValueTone::Plain),
391    };
392
393    let summary = format_summary(
394        match reason {
395            ConfigureReason::FirstRun => "Config created",
396            ConfigureReason::Manual => "Config updated",
397        },
398        SummaryTone::Success,
399        &[
400            ("File", collapse_home_path(config_path), ValueTone::Path),
401            (
402                "Project roots",
403                format!("{} configured", config.project_roots.len()),
404                ValueTone::Plain,
405            ),
406            (
407                "Aliases",
408                format!("{} configured", config.project_aliases.len()),
409                ValueTone::Plain,
410            ),
411            ("API port", config.api.port.to_string(), ValueTone::Plain),
412            ("Remote", remote_agent_display, remote_agent_tone),
413        ],
414    );
415
416    let preserved_model_override_fields = preserved_model_override_fields(&config.llama_cpp);
417    if preserved_model_override_fields.is_empty() {
418        return summary;
419    }
420
421    format!(
422        "{summary}\n\n{}",
423        format_note(
424            "Advanced",
425            &format!(
426                "The following fields are set in {} but are not managed by the wizard: {}. Edit the file directly if you need to change them.",
427                collapse_home_path(config_path),
428                preserved_model_override_fields.join(", "),
429            ),
430        )
431    )
432}
433
434fn preserved_model_override_fields(config: &LlamaCppConfigFile) -> Vec<&'static str> {
435    let mut fields = Vec::new();
436
437    if config.model_path.is_some() {
438        fields.push("llamaCpp.modelPath");
439    }
440
441    if let (Some(repo), Some(file)) = (
442        config.model_hf_repo.as_deref(),
443        config.model_hf_file.as_deref(),
444    ) {
445        let uses_builtin_default =
446            repo == DEFAULT_LLAMACPP_MODEL_HF_REPO && file == DEFAULT_LLAMACPP_MODEL_HF_FILE;
447        if !uses_builtin_default {
448            fields.push("llamaCpp.modelHfRepo");
449            fields.push("llamaCpp.modelHfFile");
450        }
451    }
452
453    fields
454}
455
456pub fn run_configure_command(
457    config_service: &ConfigService,
458    reason: ConfigureReason,
459) -> Result<String, TrackError> {
460    ensure_interactive_terminal(config_service.resolved_path())?;
461    let mut prompter = TerminalPrompter;
462    run_configure_command_with_prompter(config_service, &mut prompter, reason)
463}
464
465pub fn run_configure_command_with_prompter(
466    config_service: &ConfigService,
467    prompter: &mut dyn Prompter,
468    reason: ConfigureReason,
469) -> Result<String, TrackError> {
470    let existing_config = match config_service.load_config_file() {
471        Ok(config) => Some(config),
472        Err(error) if error.code == ErrorCode::ConfigNotFound => None,
473        Err(error) => return Err(error),
474    };
475    let defaults = existing_config.unwrap_or_else(create_default_config_file);
476
477    // The wizard stays linear on purpose: establish the local filesystem and
478    // API settings first, then optionally layer in remote-agent details.
479    let intro = match reason {
480        ConfigureReason::FirstRun => format_summary(
481            "Config setup",
482            SummaryTone::Info,
483            &[(
484                "File",
485                collapse_home_path(config_service.resolved_path()),
486                ValueTone::Path,
487            )],
488        ),
489        ConfigureReason::Manual => format_summary(
490            "Config editor",
491            SummaryTone::Info,
492            &[(
493                "File",
494                collapse_home_path(config_service.resolved_path()),
495                ValueTone::Path,
496            )],
497        ),
498    };
499    prompter.println(&intro);
500    prompter.println(&format_note("Enter", "keep current values"));
501    prompter.println(&format_note(NONE_SENTINEL, "clear optional values"));
502
503    let api_port = prompt_api_port(prompter, defaults.api.port)?;
504    let project_roots = prompt_project_roots(prompter, &defaults.project_roots)?;
505    let project_aliases = prompt_project_aliases(prompter, &defaults.project_aliases)?;
506    let remote_agent_host = prompt_remote_agent_host(
507        prompter,
508        defaults
509            .remote_agent
510            .as_ref()
511            .map(|remote_agent| remote_agent.host.as_str()),
512    )?;
513    let remote_agent = if let Some(host) = remote_agent_host {
514        let existing_remote_agent = defaults.remote_agent.as_ref();
515        let remote_user = prompt_required_value(
516            prompter,
517            "Remote agent user",
518            existing_remote_agent.map(|remote_agent| remote_agent.user.as_str()),
519        )?;
520        let remote_port = prompt_remote_agent_port(
521            prompter,
522            existing_remote_agent
523                .map(|remote_agent| remote_agent.port)
524                .unwrap_or(DEFAULT_REMOTE_AGENT_PORT),
525        )?;
526        let remote_workspace_root = prompt_required_value(
527            prompter,
528            "Remote workspace root",
529            existing_remote_agent
530                .map(|remote_agent| remote_agent.workspace_root.as_str())
531                .or(Some(DEFAULT_REMOTE_AGENT_WORKSPACE_ROOT)),
532        )?;
533        let remote_projects_registry_path = prompt_required_value(
534            prompter,
535            "Remote projects registry path",
536            existing_remote_agent
537                .map(|remote_agent| remote_agent.projects_registry_path.as_str())
538                .or(Some(DEFAULT_REMOTE_PROJECTS_REGISTRY_PATH)),
539        )?;
540        prompt_remote_agent_key_import(prompter, managed_remote_agent_key_exists()?)?;
541        let existing_review_follow_up =
542            existing_remote_agent.and_then(|remote_agent| remote_agent.review_follow_up.as_ref());
543        let review_follow_up_enabled = prompt_yes_no(
544            prompter,
545            "Enable automatic GitHub review follow-ups",
546            existing_review_follow_up
547                .map(|review_follow_up| review_follow_up.enabled)
548                .unwrap_or(false),
549        )?;
550
551        // We intentionally remember the last configured reviewer when the
552        // feature is toggled off. That keeps the wizard fast to re-enable on a
553        // later run instead of forcing users back through the web UI.
554        // TODO: If users ask to clear the remembered GitHub user from the
555        // wizard, add an explicit prompt for that instead of overloading the
556        // yes/no flow here.
557        let review_follow_up = if review_follow_up_enabled {
558            let main_user = prompt_required_value(
559                prompter,
560                "GitHub user for automatic follow-ups",
561                existing_review_follow_up
562                    .and_then(|review_follow_up| review_follow_up.main_user.as_deref()),
563            )?;
564
565            Some(RemoteAgentReviewFollowUpConfigFile {
566                enabled: true,
567                main_user: Some(main_user),
568                default_review_prompt: existing_review_follow_up
569                    .and_then(|review_follow_up| review_follow_up.default_review_prompt.clone()),
570            })
571        } else {
572            existing_review_follow_up
573                .and_then(|review_follow_up| review_follow_up.main_user.clone())
574                .map(|main_user| RemoteAgentReviewFollowUpConfigFile {
575                    enabled: false,
576                    main_user: Some(main_user),
577                    default_review_prompt: existing_review_follow_up.and_then(|review_follow_up| {
578                        review_follow_up.default_review_prompt.clone()
579                    }),
580                })
581        };
582
583        Some(RemoteAgentConfigFile {
584            host,
585            user: remote_user,
586            port: remote_port,
587            workspace_root: remote_workspace_root,
588            projects_registry_path: remote_projects_registry_path,
589            // The web UI owns the preferred runner choice today, so the wizard
590            // preserves any existing selection instead of introducing a second
591            // place where users have to keep the same setting in sync.
592            preferred_tool: existing_remote_agent
593                .map(|remote_agent| remote_agent.preferred_tool)
594                .unwrap_or(RemoteAgentPreferredTool::Codex),
595            // TODO: If people start preferring the terminal wizard for remote
596            // dispatch setup, add a multiline editor flow here. For now the
597            // shell prelude stays web-managed so the wizard preserves an
598            // existing value instead of trying to squeeze multiline shell
599            // setup into a single-line prompt.
600            shell_prelude: existing_remote_agent
601                .and_then(|remote_agent| remote_agent.shell_prelude.clone()),
602            review_follow_up,
603        })
604    } else {
605        // TODO: Consider removing the managed SSH key when remote dispatch is
606        // disabled explicitly. We leave it in place for now so a temporary
607        // config change does not silently destroy a secret the user imported on
608        // purpose.
609        None
610    };
611
612    let config = TrackConfigFile {
613        project_roots,
614        project_aliases,
615        api: ApiConfigFile { port: api_port },
616        llama_cpp: defaults.llama_cpp.clone(),
617        remote_agent,
618    };
619
620    config_service.save_config_file(&config)?;
621    Ok(format_config_saved_output(
622        &config,
623        config_service.resolved_path(),
624        reason,
625    ))
626}
627
628#[cfg(test)]
629mod tests {
630    use std::collections::{BTreeMap, VecDeque};
631
632    use tempfile::TempDir;
633
634    use super::{
635        parse_project_aliases_input, parse_project_roots_input,
636        run_configure_command_with_prompter, ConfigureReason, Prompter,
637    };
638    use crate::config::{
639        ConfigService, LlamaCppConfigFile, RemoteAgentReviewFollowUpConfigFile, TrackConfigFile,
640        DEFAULT_LLAMACPP_MODEL_HF_FILE, DEFAULT_LLAMACPP_MODEL_HF_REPO,
641    };
642    use crate::test_support::{set_env_var, track_data_env_lock};
643
644    struct ScriptedPrompter {
645        answers: VecDeque<String>,
646        lines: Vec<String>,
647    }
648
649    impl ScriptedPrompter {
650        fn new(answers: &[&str]) -> Self {
651            Self {
652                answers: answers.iter().map(|value| (*value).to_owned()).collect(),
653                lines: Vec::new(),
654            }
655        }
656    }
657
658    impl Prompter for ScriptedPrompter {
659        fn ask(&mut self, _prompt: &str) -> Result<String, crate::errors::TrackError> {
660            Ok(self
661                .answers
662                .pop_front()
663                .expect("scripted prompt should have enough answers"))
664        }
665
666        fn println(&mut self, line: &str) {
667            self.lines.push(line.to_owned());
668        }
669    }
670
671    fn temp_config_service() -> (TempDir, ConfigService) {
672        let directory = TempDir::new().expect("tempdir should be created");
673        let service = ConfigService::new(Some(directory.path().join("config.json")))
674            .expect("config service should resolve");
675        (directory, service)
676    }
677
678    #[test]
679    fn parses_project_roots() {
680        assert_eq!(
681            parse_project_roots_input("~/work, ~/oss\n~/lab"),
682            vec!["~/work", "~/oss", "~/lab"]
683        );
684    }
685
686    #[test]
687    fn parses_project_aliases() {
688        let aliases = parse_project_aliases_input("proj-x=project-x, infra=platform")
689            .expect("aliases should parse");
690
691        assert_eq!(aliases.get("proj-x"), Some(&"project-x".to_owned()));
692        assert_eq!(aliases.get("infra"), Some(&"platform".to_owned()));
693    }
694
695    #[test]
696    fn writes_first_run_config() {
697        let (_directory, service) = temp_config_service();
698        let mut prompter =
699            ScriptedPrompter::new(&["3210", "~/work, ~/oss", "proj-x=project-x", ""]);
700
701        let output =
702            run_configure_command_with_prompter(&service, &mut prompter, ConfigureReason::FirstRun)
703                .expect("config wizard should succeed");
704
705        assert!(output.contains("Config created"));
706        let raw = std::fs::read_to_string(service.resolved_path()).expect("config should save");
707        assert!(!raw.contains("\"llamaCpp\""));
708        assert!(raw.contains("\"projectRoots\""));
709        assert!(raw.contains("\"api\""));
710    }
711
712    #[test]
713    fn mentions_preserved_manual_model_overrides() {
714        let (_directory, service) = temp_config_service();
715        service
716            .save_config_file(&TrackConfigFile {
717                project_roots: vec!["~/work".to_owned()],
718                project_aliases: BTreeMap::new(),
719                api: crate::config::ApiConfigFile::default(),
720                llama_cpp: LlamaCppConfigFile {
721                    model_path: Some("~/.models/custom.gguf".to_owned()),
722                    model_hf_repo: None,
723                    model_hf_file: None,
724                },
725                remote_agent: None,
726            })
727            .expect("seed config should save");
728
729        let mut prompter = ScriptedPrompter::new(&["", "", "", ""]);
730        let output =
731            run_configure_command_with_prompter(&service, &mut prompter, ConfigureReason::Manual)
732                .expect("config wizard should succeed");
733
734        assert!(output.contains("llamaCpp.modelPath"));
735    }
736
737    #[test]
738    fn does_not_call_out_builtin_hugging_face_defaults() {
739        let (_directory, service) = temp_config_service();
740        service
741            .save_config_file(&TrackConfigFile {
742                project_roots: vec!["~/work".to_owned()],
743                project_aliases: BTreeMap::new(),
744                api: crate::config::ApiConfigFile::default(),
745                llama_cpp: LlamaCppConfigFile {
746                    model_path: None,
747                    model_hf_repo: Some(DEFAULT_LLAMACPP_MODEL_HF_REPO.to_owned()),
748                    model_hf_file: Some(DEFAULT_LLAMACPP_MODEL_HF_FILE.to_owned()),
749                },
750                remote_agent: None,
751            })
752            .expect("seed config should save");
753
754        let mut prompter = ScriptedPrompter::new(&["", "", "", ""]);
755        let output =
756            run_configure_command_with_prompter(&service, &mut prompter, ConfigureReason::Manual)
757                .expect("config wizard should succeed");
758
759        assert!(!output.contains("llamaCpp.modelHfRepo"));
760        assert!(!output.contains("llamaCpp.modelHfFile"));
761    }
762
763    #[test]
764    fn writes_remote_review_follow_up_from_wizard() {
765        let (_directory, service) = temp_config_service();
766        let _track_data_dir_guard = track_data_env_lock()
767            .lock()
768            .expect("track data dir lock should not be poisoned");
769        let data_dir = service
770            .resolved_path()
771            .parent()
772            .expect("config path should have a parent")
773            .join("track-data")
774            .join("issues");
775        let _track_data_dir = set_env_var("TRACK_DATA_DIR", &data_dir);
776
777        let ssh_key_source = data_dir
778            .parent()
779            .expect("data dir should have a parent")
780            .join("id_ed25519.source");
781        std::fs::create_dir_all(
782            ssh_key_source
783                .parent()
784                .expect("SSH key source should have a parent"),
785        )
786        .expect("SSH key source parent should be created");
787        std::fs::write(&ssh_key_source, "not-a-real-private-key")
788            .expect("SSH key source should be written");
789
790        let ssh_key_source = ssh_key_source.to_string_lossy().into_owned();
791        let mut prompter = ScriptedPrompter::new(&[
792            "3210",
793            "~/work",
794            "",
795            "builder.example.com",
796            "codex",
797            "2222",
798            "/srv/track",
799            "/srv/track/projects.json",
800            &ssh_key_source,
801            "yes",
802            "octocat",
803        ]);
804
805        run_configure_command_with_prompter(&service, &mut prompter, ConfigureReason::FirstRun)
806            .expect("config wizard should succeed");
807
808        let saved = service
809            .load_config_file()
810            .expect("saved config should load successfully");
811        let remote_agent = saved
812            .remote_agent
813            .expect("remote agent config should be present");
814
815        assert_eq!(
816            remote_agent.review_follow_up,
817            Some(RemoteAgentReviewFollowUpConfigFile {
818                enabled: true,
819                main_user: Some("octocat".to_owned()),
820                default_review_prompt: None,
821            })
822        );
823    }
824}