From c15a6c5169f3607d31c85e0f392f47de42443929 Mon Sep 17 00:00:00 2001 From: davidabram Date: Sun, 11 Oct 2026 03:28:11 +0200 Subject: [PATCH] feat(setup): select multiple integration targets in interactive setup Interactive setup previously offered a single-choice target prompt with an "All" option, re-asked every confirmation on each run, and always merged the chosen target into the persisted target list, so deselecting a target was impossible. Introduce ConcreteSetupTarget so install, repair, and asset iteration operate on real targets only, removing the unreachable "All" branches. The interactive prompt is now a validated multi-select pre-filled from the targets persisted in .sce/config.json, and previously stored agent-trace auto-sync and attribution-hooks answers are reused instead of prompting again. Interactive runs replace the persisted target list, while non-interactive flag runs keep merging. CLI target flags collapse into a single optional SetupTarget, and auth DB setup skips initialization when the database already exists. Co-authored-by: SCE --- .sce/config.json | 2 +- cli/src/services/auth_db/lifecycle.rs | 4 + cli/src/services/doctor/inspect.rs | 18 +- cli/src/services/parse/command_runtime.rs | 15 +- cli/src/services/setup/command.rs | 28 +- cli/src/services/setup/install.rs | 74 +++-- cli/src/services/setup/mod.rs | 348 ++++++++++++---------- cli/src/services/setup/prompt.rs | 77 ++--- 8 files changed, 304 insertions(+), 262 deletions(-) diff --git a/.sce/config.json b/.sce/config.json index f9789ab7f..08495f79a 100644 --- a/.sce/config.json +++ b/.sce/config.json @@ -6,8 +6,8 @@ "integrations": { "optional_workflows": [], "target": [ - "claude", "opencode", + "claude", "pi", "codex" ] diff --git a/cli/src/services/auth_db/lifecycle.rs b/cli/src/services/auth_db/lifecycle.rs index ffc82fd0b..79a5e3160 100644 --- a/cli/src/services/auth_db/lifecycle.rs +++ b/cli/src/services/auth_db/lifecycle.rs @@ -49,6 +49,10 @@ impl ServiceLifecycle for AuthDbLifecycle { } async fn setup(&self, _ctx: &C) -> Result { + if auth_db_path()?.exists() { + return Ok(SetupOutcome::default()); + } + AuthDb::new() .await .context("Auth DB lifecycle setup failed while initializing auth DB")?; diff --git a/cli/src/services/doctor/inspect.rs b/cli/src/services/doctor/inspect.rs index ef3de754b..0ea03100d 100644 --- a/cli/src/services/doctor/inspect.rs +++ b/cli/src/services/doctor/inspect.rs @@ -28,7 +28,7 @@ use crate::services::repository_identity::resolve::{ use crate::services::setup::{ config_merge, hook_merge, iter_embedded_assets_for_setup_target_with_selection, iter_required_hook_assets, persisted_optional_workflows, repair_merge_target_asset, - EmbeddedAsset, SetupTarget, + ConcreteSetupTarget, EmbeddedAsset, }; use super::render::integration_target_label; @@ -902,7 +902,7 @@ pub(super) fn repair_merge_target_configs( collect_claude_integration_groups(repository_root, &selected_optional_workflows); if let Some(result) = repair_merge_target_if_mismatched( repository_root, - SetupTarget::Claude, + ConcreteSetupTarget::Claude, claude_asset::SETTINGS_FILE, &claude_groups, ) { @@ -915,7 +915,7 @@ pub(super) fn repair_merge_target_configs( collect_opencode_integration_groups(repository_root, &selected_optional_workflows); if let Some(result) = repair_merge_target_if_mismatched( repository_root, - SetupTarget::OpenCode, + ConcreteSetupTarget::OpenCode, OPENCODE_CONFIG_RELATIVE_PATH, &opencode_groups, ) { @@ -974,7 +974,7 @@ fn repair_codex_hooks_json_if_structurally_unhealthy( Some( match repair_merge_target_asset( repository_root, - SetupTarget::Codex, + ConcreteSetupTarget::Codex, CODEX_HOOKS_JSON_RELATIVE_PATH, ) { Ok(()) => DoctorFixResultRecord { @@ -998,7 +998,7 @@ fn repair_codex_hooks_json_if_structurally_unhealthy( fn repair_merge_target_if_mismatched( repository_root: &Path, - target: SetupTarget, + target: ConcreteSetupTarget, relative_path: &str, groups: &[IntegrationGroupHealth], ) -> Option { @@ -1864,7 +1864,7 @@ fn collect_opencode_integration_groups( let opencode_root = repo_paths.opencode_dir(); let manifest_path = repo_paths.opencode_manifest_file(); let embedded_assets = iter_embedded_assets_for_setup_target_with_selection( - SetupTarget::OpenCode, + ConcreteSetupTarget::OpenCode, selected_optional_workflows, ) .collect::>(); @@ -1952,7 +1952,7 @@ fn collect_claude_integration_groups( let repo_paths = RepoPaths::new(repository_root); let claude_root = repo_paths.claude_dir(); let embedded_assets = iter_embedded_assets_for_setup_target_with_selection( - SetupTarget::Claude, + ConcreteSetupTarget::Claude, selected_optional_workflows, ) .collect::>(); @@ -2014,7 +2014,7 @@ fn collect_pi_integration_groups( let repo_paths = RepoPaths::new(repository_root); let pi_root = repo_paths.pi_dir(); let embedded_assets = iter_embedded_assets_for_setup_target_with_selection( - SetupTarget::Pi, + ConcreteSetupTarget::Pi, selected_optional_workflows, ) .collect::>(); @@ -2074,7 +2074,7 @@ fn collect_codex_integration_groups( ) -> Vec { let codex_root = InstallTargetPaths::new(repository_root).codex_target_dir(); let embedded_assets = iter_embedded_assets_for_setup_target_with_selection( - SetupTarget::Codex, + ConcreteSetupTarget::Codex, selected_optional_workflows, ) .collect::>(); diff --git a/cli/src/services/parse/command_runtime.rs b/cli/src/services/parse/command_runtime.rs index a1b6d9a47..c40ef758c 100644 --- a/cli/src/services/parse/command_runtime.rs +++ b/cli/src/services/parse/command_runtime.rs @@ -293,13 +293,16 @@ fn convert_clap_command(command: cli_schema::Commands) -> Result convert_setup_command(services::setup::SetupCliOptions { - help: false, + target: [ + (opencode, services::setup::SetupTarget::OpenCode), + (claude, services::setup::SetupTarget::Claude), + (pi, services::setup::SetupTarget::Pi), + (codex, services::setup::SetupTarget::Codex), + (all, services::setup::SetupTarget::All), + ] + .into_iter() + .find_map(|(selected, target)| selected.then_some(target)), non_interactive, - opencode, - claude, - pi, - codex, - all, hooks, repo_path: repo, bootstrap_context, diff --git a/cli/src/services/setup/command.rs b/cli/src/services/setup/command.rs index 100d35f53..faf0770f7 100644 --- a/cli/src/services/setup/command.rs +++ b/cli/src/services/setup/command.rs @@ -34,23 +34,15 @@ impl SetupCommand { mode, &setup::InquireSetupTargetPrompter, &optional_workflow_defaults, + &setup::persisted_integration_targets(&repository_root), + setup::persisted_setup_confirmations(&repository_root), ) .map_err(unexpected_failure)? { - setup::SetupDispatch::Proceed { - mode: resolved_mode, - optional_workflows, - agent_trace_auto_sync, - attribution_hooks_enabled, - } => Some(( - resolved_mode, - optional_workflows, - agent_trace_auto_sync, - attribution_hooks_enabled, - )), setup::SetupDispatch::Cancelled => { return Ok(setup::setup_cancelled_text()); } + dispatch @ setup::SetupDispatch::Proceed { .. } => Some(dispatch), } } else { None @@ -82,20 +74,22 @@ impl SetupCommand { } } - if let Some(( - resolved_mode, - prompted_optional_workflows, + if let Some(setup::SetupDispatch::Proceed { + targets, + target_persistence, + optional_workflows: prompted_optional_workflows, agent_trace_auto_sync, attribution_hooks_enabled, - )) = setup_dispatch + }) = setup_dispatch { let optional_workflows = prompted_optional_workflows .as_deref() .or(self.request.optional_workflows.as_deref()); - let setup_message = setup::run_setup_for_mode( + let setup_message = setup::run_setup_for_targets( &repository_root, - resolved_mode, + &targets, + target_persistence, optional_workflows, agent_trace_auto_sync, attribution_hooks_enabled, diff --git a/cli/src/services/setup/install.rs b/cli/src/services/setup/install.rs index 986a4c531..ce91124ed 100644 --- a/cli/src/services/setup/install.rs +++ b/cli/src/services/setup/install.rs @@ -13,12 +13,12 @@ use crate::services::security::{ensure_directory_is_writable, redact_sensitive_t use super::config_merge; use super::hook_merge; use super::{ - classify_git_exit, cleanup_path_if_exists, concrete_targets_for, - embedded_assets_for_concrete_target, hook_install_recovery_guidance, - iter_embedded_assets_for_setup_target_with_selection, iter_required_hook_assets, - setup_install_recovery_guidance, EmbeddedAsset, GitExitKind, GitRepositoryResolutionError, - RequiredHookInstallResult, RequiredHookInstallStatus, RequiredHooksInstallOutcome, - SetupInstallOutcome, SetupInstallTargetResult, SetupTarget, + classify_git_exit, cleanup_path_if_exists, embedded_assets_for_concrete_target, + hook_install_recovery_guidance, iter_embedded_assets_for_setup_target_with_selection, + iter_required_hook_assets, setup_install_recovery_guidance, ConcreteSetupTarget, EmbeddedAsset, + GitExitKind, GitRepositoryResolutionError, RequiredHookInstallResult, + RequiredHookInstallStatus, RequiredHooksInstallOutcome, SetupInstallOutcome, + SetupInstallTargetResult, }; use crate::services::default_paths; use crate::services::default_paths::claude_asset; @@ -53,12 +53,12 @@ where pub(super) fn install_embedded_setup_assets( repository_root: &Path, - target: SetupTarget, + targets: &[ConcreteSetupTarget], selected_optional_workflows: &[String], ) -> Result { install_embedded_setup_assets_with_rename( repository_root, - target, + targets, selected_optional_workflows, |from, to| fs::rename(from, to), ) @@ -66,7 +66,7 @@ pub(super) fn install_embedded_setup_assets( pub(super) fn repair_merge_target_asset( repository_root: &Path, - target: SetupTarget, + target: ConcreteSetupTarget, relative_path: &str, ) -> Result<()> { let asset = embedded_assets_for_concrete_target(target) @@ -76,20 +76,26 @@ pub(super) fn repair_merge_target_asset( format!("No embedded asset named '{relative_path}' for target {target:?}") })?; - let install_targets = InstallTargetPaths::new(repository_root); - let destination_root = match target { - SetupTarget::OpenCode => install_targets.opencode_target_dir(), - SetupTarget::Claude => install_targets.claude_target_dir(), - SetupTarget::Pi => install_targets.pi_target_dir(), - SetupTarget::Codex => install_targets.codex_target_dir(), - SetupTarget::All => unreachable!("meta targets are expanded into concrete targets"), - }; + let destination_root = concrete_target_destination_root(repository_root, target); install_single_asset_with_rename(target, &destination_root, asset, &mut |from, to| { fs::rename(from, to) }) } +fn concrete_target_destination_root( + repository_root: &Path, + target: ConcreteSetupTarget, +) -> PathBuf { + let install_targets = InstallTargetPaths::new(repository_root); + match target { + ConcreteSetupTarget::OpenCode => install_targets.opencode_target_dir(), + ConcreteSetupTarget::Claude => install_targets.claude_target_dir(), + ConcreteSetupTarget::Pi => install_targets.pi_target_dir(), + ConcreteSetupTarget::Codex => install_targets.codex_target_dir(), + } +} + fn install_required_git_hooks_in_resolved_repository( resolved_repository_root: &Path, mut rename_fn: F, @@ -494,7 +500,7 @@ fn is_executable_file(path: &Path) -> Result { pub(super) fn install_embedded_setup_assets_with_rename( repository_root: &Path, - target: SetupTarget, + targets: &[ConcreteSetupTarget], selected_optional_workflows: &[String], mut rename_fn: F, ) -> Result @@ -505,7 +511,7 @@ where let mut target_results = Vec::new(); - for concrete_target in concrete_targets_for(target) { + for concrete_target in targets { let concrete_target = *concrete_target; let assets: Vec<&'static EmbeddedAsset> = iter_embedded_assets_for_setup_target_with_selection( @@ -527,23 +533,14 @@ where fn install_assets_for_concrete_target_with_rename( repository_root: &Path, - target: SetupTarget, + target: ConcreteSetupTarget, assets: &[&'static EmbeddedAsset], rename_fn: &mut F, ) -> Result where F: FnMut(&Path, &Path) -> io::Result<()>, { - let install_targets = InstallTargetPaths::new(repository_root); - let destination_root = match target { - SetupTarget::OpenCode => install_targets.opencode_target_dir(), - SetupTarget::Claude => install_targets.claude_target_dir(), - SetupTarget::Pi => install_targets.pi_target_dir(), - SetupTarget::Codex => install_targets.codex_target_dir(), - SetupTarget::All => { - unreachable!("meta targets are expanded into concrete targets") - } - }; + let destination_root = concrete_target_destination_root(repository_root, target); for asset in assets { install_single_asset_with_rename(target, &destination_root, asset, rename_fn)?; @@ -560,7 +557,7 @@ where fn prune_stale_assets_for_concrete_target( destination_root: &Path, - target: SetupTarget, + target: ConcreteSetupTarget, installed_assets: &[&'static EmbeddedAsset], ) -> Result<()> { let installed_paths: std::collections::HashSet<&'static str> = installed_assets @@ -604,20 +601,21 @@ fn remove_empty_ancestor_directories(destination_root: &Path, removed_file: &Pat } } -fn is_claude_settings_merge_target(target: SetupTarget, relative_path: &str) -> bool { - target == SetupTarget::Claude && relative_path == claude_asset::SETTINGS_FILE +fn is_claude_settings_merge_target(target: ConcreteSetupTarget, relative_path: &str) -> bool { + target == ConcreteSetupTarget::Claude && relative_path == claude_asset::SETTINGS_FILE } -fn is_opencode_config_merge_target(target: SetupTarget, relative_path: &str) -> bool { - target == SetupTarget::OpenCode && relative_path == default_paths::repo_file::OPENCODE_MANIFEST +fn is_opencode_config_merge_target(target: ConcreteSetupTarget, relative_path: &str) -> bool { + target == ConcreteSetupTarget::OpenCode + && relative_path == default_paths::repo_file::OPENCODE_MANIFEST } -fn is_codex_hooks_merge_target(target: SetupTarget, relative_path: &str) -> bool { - target == SetupTarget::Codex && relative_path == ".codex/hooks.json" +fn is_codex_hooks_merge_target(target: ConcreteSetupTarget, relative_path: &str) -> bool { + target == ConcreteSetupTarget::Codex && relative_path == ".codex/hooks.json" } fn install_single_asset_with_rename( - target: SetupTarget, + target: ConcreteSetupTarget, destination_root: &Path, asset: &'static EmbeddedAsset, rename_fn: &mut F, diff --git a/cli/src/services/setup/mod.rs b/cli/src/services/setup/mod.rs index 0c8e9ace1..54c2ad2c8 100644 --- a/cli/src/services/setup/mod.rs +++ b/cli/src/services/setup/mod.rs @@ -90,6 +90,25 @@ pub enum SetupTarget { All, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ConcreteSetupTarget { + OpenCode, + Claude, + Pi, + Codex, +} + +impl From for SetupTarget { + fn from(target: ConcreteSetupTarget) -> Self { + match target { + ConcreteSetupTarget::OpenCode => Self::OpenCode, + ConcreteSetupTarget::Claude => Self::Claude, + ConcreteSetupTarget::Pi => Self::Pi, + ConcreteSetupTarget::Codex => Self::Codex, + } + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct EmbeddedAsset { pub relative_path: &'static str, @@ -113,15 +132,12 @@ pub fn iter_required_hook_assets() -> std::slice::Iter<'static, EmbeddedAsset> { HOOK_EMBEDDED_ASSETS.iter() } -fn embedded_assets_for_concrete_target(target: SetupTarget) -> &'static [EmbeddedAsset] { +fn embedded_assets_for_concrete_target(target: ConcreteSetupTarget) -> &'static [EmbeddedAsset] { match target { - SetupTarget::OpenCode => OPENCODE_EMBEDDED_ASSETS, - SetupTarget::Claude => CLAUDE_EMBEDDED_ASSETS, - SetupTarget::Pi => PI_EMBEDDED_ASSETS, - SetupTarget::Codex => CODEX_EMBEDDED_ASSETS, - SetupTarget::All => { - unreachable!("meta targets are expanded into concrete targets") - } + ConcreteSetupTarget::OpenCode => OPENCODE_EMBEDDED_ASSETS, + ConcreteSetupTarget::Claude => CLAUDE_EMBEDDED_ASSETS, + ConcreteSetupTarget::Pi => PI_EMBEDDED_ASSETS, + ConcreteSetupTarget::Codex => CODEX_EMBEDDED_ASSETS, } } @@ -131,27 +147,24 @@ struct WorkflowAssetLayout { skills_dir: &'static str, } -fn workflow_asset_layout(target: SetupTarget) -> WorkflowAssetLayout { +fn workflow_asset_layout(target: ConcreteSetupTarget) -> WorkflowAssetLayout { match target { - SetupTarget::OpenCode => WorkflowAssetLayout { + ConcreteSetupTarget::OpenCode => WorkflowAssetLayout { command_dir: Some(default_paths::opencode_asset::OPENCODE_COMMAND_DIR), skills_dir: default_paths::opencode_asset::SKILLS_DIR, }, - SetupTarget::Claude => WorkflowAssetLayout { + ConcreteSetupTarget::Claude => WorkflowAssetLayout { command_dir: Some(default_paths::claude_asset::COMMANDS_DIR), skills_dir: default_paths::claude_asset::SKILLS_DIR, }, - SetupTarget::Pi => WorkflowAssetLayout { + ConcreteSetupTarget::Pi => WorkflowAssetLayout { command_dir: Some(default_paths::pi_asset::PROMPTS_DIR), skills_dir: default_paths::pi_asset::SKILLS_DIR, }, - SetupTarget::Codex => WorkflowAssetLayout { + ConcreteSetupTarget::Codex => WorkflowAssetLayout { command_dir: None, skills_dir: default_paths::codex_asset::SKILLS_DIR, }, - SetupTarget::All => { - unreachable!("meta targets are expanded into concrete targets") - } } } @@ -169,7 +182,7 @@ fn asset_belongs_to_optional_workflow( } pub fn iter_embedded_assets_for_setup_target_with_selection( - target: SetupTarget, + target: ConcreteSetupTarget, selected_optional_workflows: &[impl AsRef], ) -> std::vec::IntoIter<&'static EmbeddedAsset> { let unselected: Vec<&'static OptionalWorkflow> = OPTIONAL_WORKFLOWS @@ -181,19 +194,15 @@ pub fn iter_embedded_assets_for_setup_target_with_selection( }) .collect(); - let mut assets: Vec<&'static EmbeddedAsset> = Vec::new(); - for concrete in concrete_targets_for(target) { - let layout = workflow_asset_layout(*concrete); - assets.extend( - embedded_assets_for_concrete_target(*concrete) - .iter() - .filter(|asset| { - !unselected.iter().any(|workflow| { - asset_belongs_to_optional_workflow(asset.relative_path, workflow, layout) - }) - }), - ); - } + let layout = workflow_asset_layout(target); + let assets: Vec<&'static EmbeddedAsset> = embedded_assets_for_concrete_target(target) + .iter() + .filter(|asset| { + !unselected.iter().any(|workflow| { + asset_belongs_to_optional_workflow(asset.relative_path, workflow, layout) + }) + }) + .collect(); assets.into_iter() } @@ -204,10 +213,17 @@ pub enum SetupMode { NonInteractive(SetupTarget), } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TargetPersistence { + Merge, + Replace, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub enum SetupDispatch { Proceed { - mode: SetupMode, + targets: Vec, + target_persistence: TargetPersistence, optional_workflows: Option>, agent_trace_auto_sync: Option, attribution_hooks_enabled: Option, @@ -216,15 +232,9 @@ pub enum SetupDispatch { } #[derive(Clone, Debug, Default, Eq, PartialEq)] -#[allow(clippy::struct_excessive_bools)] pub struct SetupCliOptions { - pub help: bool, + pub target: Option, pub non_interactive: bool, - pub opencode: bool, - pub claude: bool, - pub pi: bool, - pub codex: bool, - pub all: bool, pub hooks: bool, pub repo_path: Option, pub bootstrap_context: bool, @@ -263,11 +273,7 @@ pub fn resolve_setup_request(options: SetupCliOptions) -> Result { } let has_other_setup_options = options.non_interactive - || options.opencode - || options.claude - || options.pi - || options.codex - || options.all + || options.target.is_some() || options.hooks || options.repo_path.is_some(); if has_other_setup_options { @@ -285,41 +291,13 @@ pub fn resolve_setup_request(options: SetupCliOptions) -> Result { }); } - let mut selected_targets = Vec::new(); - - if options.opencode { - selected_targets.push(SetupTarget::OpenCode); - } - if options.claude { - selected_targets.push(SetupTarget::Claude); - } - if options.pi { - selected_targets.push(SetupTarget::Pi); - } - if options.codex { - selected_targets.push(SetupTarget::Codex); - } - if options.all { - selected_targets.push(SetupTarget::All); - } - - if selected_targets.len() > 1 { - bail!( - "Options '--opencode', '--claude', '--pi', '--codex', and '--all' are mutually exclusive. Try: choose exactly one target flag (for example 'sce setup --opencode --non-interactive') or omit all target flags for interactive mode." - ); - } - - if options.non_interactive && selected_targets.is_empty() && !options.hooks { - bail!( + let config_mode = match options.target { + Some(target) => Some(SetupMode::NonInteractive(target)), + None if options.non_interactive && !options.hooks => bail!( "Option '--non-interactive' requires a target flag. Try: 'sce setup --opencode --non-interactive', 'sce setup --claude --non-interactive', 'sce setup --pi --non-interactive', 'sce setup --codex --non-interactive', or 'sce setup --all --non-interactive'." - ); - } - - let config_mode = match selected_targets.as_slice() { - [target] => Some(SetupMode::NonInteractive(*target)), - [] if options.hooks => None, - [] => Some(SetupMode::Interactive), - _ => unreachable!("target count already validated"), + ), + None if options.hooks => None, + None => Some(SetupMode::Interactive), }; if config_mode.is_none() && optional_workflows.is_some() { @@ -374,37 +352,32 @@ fn available_optional_workflow_slugs() -> String { .join(", ") } -pub fn run_setup_for_mode( +pub fn run_setup_for_targets( repository_root: &Path, - mode: SetupMode, + targets: &[ConcreteSetupTarget], + target_persistence: TargetPersistence, optional_workflows: Option<&[String]>, agent_trace_auto_sync: Option, attribution_hooks_enabled: Option, ) -> Result { - let target = match mode { - SetupMode::Interactive => { - bail!("Interactive setup mode must be resolved before installation") - } - SetupMode::NonInteractive(target) => target, - }; - let selected_optional_workflows = match optional_workflows { Some(selection) => selection.to_vec(), None => persisted_optional_workflows(repository_root), }; let outcome = - install_embedded_setup_assets(repository_root, target, &selected_optional_workflows) + install_embedded_setup_assets(repository_root, targets, &selected_optional_workflows) .with_context(|| { format!( "Setup installation failed for {}", - setup_target_label(target) + setup_targets_label(targets) ) })?; persist_integration_targets( repository_root, - target, + targets, + target_persistence, &selected_optional_workflows, agent_trace_auto_sync, attribution_hooks_enabled, @@ -412,35 +385,75 @@ pub fn run_setup_for_mode( .with_context(|| { format!( "Setup assets were installed for {} but failed to update repo-local config", - setup_target_label(target) + setup_targets_label(targets) ) })?; Ok(format_setup_install_success_message(&outcome)) } -pub fn persisted_optional_workflows(repository_root: &Path) -> Vec { - use crate::services::config::schema::parse_file_config; - use crate::services::config::ConfigPathSource; - - let config_path = RepoPaths::new(repository_root).sce_config_file(); +pub fn persisted_integration_targets(repository_root: &Path) -> Vec { + use crate::services::config::IntegrationTargetId; - let Ok(raw) = fs::read_to_string(&config_path) else { - return Vec::new(); - }; - - let Ok(config) = - parse_file_config(&raw, &config_path, ConfigPathSource::DefaultDiscoveredLocal) + let Some(integrations) = + persisted_file_config(repository_root).and_then(|config| config.integrations) else { return Vec::new(); }; - config - .integrations + let mut targets = Vec::new(); + for target in integrations.value.target { + let concrete = match target { + IntegrationTargetId::Opencode => ConcreteSetupTarget::OpenCode, + IntegrationTargetId::Claude => ConcreteSetupTarget::Claude, + IntegrationTargetId::Pi => ConcreteSetupTarget::Pi, + IntegrationTargetId::Codex => ConcreteSetupTarget::Codex, + }; + if !targets.contains(&concrete) { + targets.push(concrete); + } + } + targets +} + +pub fn persisted_optional_workflows(repository_root: &Path) -> Vec { + persisted_file_config(repository_root) + .and_then(|config| config.integrations) .map(|integrations| integrations.value.optional_workflows) .unwrap_or_default() } +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct PersistedSetupConfirmations { + pub agent_trace_auto_sync: Option, + pub attribution_hooks_enabled: Option, +} + +pub fn persisted_setup_confirmations(repository_root: &Path) -> PersistedSetupConfirmations { + let Some(config) = persisted_file_config(repository_root) else { + return PersistedSetupConfirmations::default(); + }; + + PersistedSetupConfirmations { + agent_trace_auto_sync: config.agent_trace_auto_sync.map(|setting| setting.value), + attribution_hooks_enabled: config + .attribution_hooks_enabled + .map(|setting| setting.value), + } +} + +fn persisted_file_config( + repository_root: &Path, +) -> Option { + use crate::services::config::schema::parse_file_config; + use crate::services::config::ConfigPathSource; + + let config_path = RepoPaths::new(repository_root).sce_config_file(); + let raw = fs::read_to_string(&config_path).ok()?; + + parse_file_config(&raw, &config_path, ConfigPathSource::DefaultDiscoveredLocal).ok() +} + pub fn ensure_git_repository(directory: &Path) -> Result { install::ensure_git_repository(directory) } @@ -566,7 +579,7 @@ fn format_setup_install_success_message(outcome: &SetupInstallOutcome) -> String let selected_targets = outcome .target_results .iter() - .map(|result| setup_target_label(result.target)) + .map(|result| setup_target_label(result.target.into())) .collect::>() .join(", "); @@ -582,7 +595,7 @@ fn format_setup_install_success_message(outcome: &SetupInstallOutcome) -> String for result in &outcome.target_results { lines.push(format!( "- {}: {} {} {} '{}'", - label(&format!("{}:", setup_target_label(result.target))), + label(&format!("{}:", setup_target_label(result.target.into()))), success("installed"), value(&format!("{} file(s) to", result.installed_file_count)), value("'"), @@ -646,6 +659,14 @@ fn required_hook_status_label(status: RequiredHookInstallStatus) -> &'static str } } +fn setup_targets_label(targets: &[ConcreteSetupTarget]) -> String { + targets + .iter() + .map(|target| setup_target_label((*target).into())) + .collect::>() + .join(", ") +} + fn setup_target_label(target: SetupTarget) -> &'static str { match target { SetupTarget::OpenCode => "OpenCode", @@ -658,7 +679,7 @@ fn setup_target_label(target: SetupTarget) -> &'static str { #[derive(Clone, Debug, Eq, PartialEq)] pub struct SetupInstallTargetResult { - pub target: SetupTarget, + pub target: ConcreteSetupTarget, pub destination_root: PathBuf, pub installed_file_count: usize, } @@ -697,27 +718,27 @@ pub fn install_required_git_hooks(repository_root: &Path) -> Result Result { - install::install_embedded_setup_assets(repository_root, target, selected_optional_workflows) + install::install_embedded_setup_assets(repository_root, targets, selected_optional_workflows) } pub(crate) fn repair_merge_target_asset( repository_root: &Path, - target: SetupTarget, + target: ConcreteSetupTarget, relative_path: &str, ) -> Result<()> { install::repair_merge_target_asset(repository_root, target, relative_path) } pub(crate) fn setup_install_recovery_guidance( - target: SetupTarget, + target: ConcreteSetupTarget, destination_root: &Path, ) -> String { format!( "Setup for {} does not create backups. Recover '{}' from version control if needed.", - setup_target_label(target), + setup_target_label(target.into()), destination_root.display() ) } @@ -745,36 +766,34 @@ pub(crate) fn cleanup_path_if_exists(path: &Path) { } } -pub(crate) fn concrete_targets_for(target: SetupTarget) -> &'static [SetupTarget] { +pub(crate) fn concrete_targets_for(target: SetupTarget) -> &'static [ConcreteSetupTarget] { match target { - SetupTarget::OpenCode => &[SetupTarget::OpenCode], - SetupTarget::Claude => &[SetupTarget::Claude], - SetupTarget::Pi => &[SetupTarget::Pi], - SetupTarget::Codex => &[SetupTarget::Codex], + SetupTarget::OpenCode => &[ConcreteSetupTarget::OpenCode], + SetupTarget::Claude => &[ConcreteSetupTarget::Claude], + SetupTarget::Pi => &[ConcreteSetupTarget::Pi], + SetupTarget::Codex => &[ConcreteSetupTarget::Codex], SetupTarget::All => &[ - SetupTarget::OpenCode, - SetupTarget::Claude, - SetupTarget::Pi, - SetupTarget::Codex, + ConcreteSetupTarget::OpenCode, + ConcreteSetupTarget::Claude, + ConcreteSetupTarget::Pi, + ConcreteSetupTarget::Codex, ], } } -fn integration_target_id_str(target: SetupTarget) -> &'static str { +fn integration_target_id_str(target: ConcreteSetupTarget) -> &'static str { match target { - SetupTarget::OpenCode => "opencode", - SetupTarget::Claude => "claude", - SetupTarget::Pi => "pi", - SetupTarget::Codex => "codex", - SetupTarget::All => { - unreachable!("integration_target_id_str must not be called with meta targets") - } + ConcreteSetupTarget::OpenCode => "opencode", + ConcreteSetupTarget::Claude => "claude", + ConcreteSetupTarget::Pi => "pi", + ConcreteSetupTarget::Codex => "codex", } } pub fn persist_integration_targets( repository_root: &Path, - target: SetupTarget, + targets: &[ConcreteSetupTarget], + target_persistence: TargetPersistence, selected_optional_workflows: &[String], agent_trace_auto_sync: Option, attribution_hooks_enabled: Option, @@ -810,19 +829,21 @@ pub fn persist_integration_targets( ) })?; - let mut existing_targets: Vec = config_obj - .get("integrations") - .and_then(|i| i.get("target")) - .and_then(|t| t.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(String::from)) - .collect() - }) - .unwrap_or_default(); + let mut existing_targets: Vec = match target_persistence { + TargetPersistence::Merge => config_obj + .get("integrations") + .and_then(|i| i.get("target")) + .and_then(|t| t.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(), + TargetPersistence::Replace => Vec::new(), + }; - let new_targets = concrete_targets_for(target); - for concrete in new_targets { + for concrete in targets { let id_str = integration_target_id_str(*concrete); let id_owned = id_str.to_string(); if !existing_targets.contains(&id_owned) { @@ -885,7 +906,10 @@ pub fn persist_integration_targets( mod install; pub trait SetupTargetPrompter { - fn prompt_target(&self) -> Result; + fn prompt_targets( + &self, + defaults: &[ConcreteSetupTarget], + ) -> Result>>; fn prompt_optional_workflows(&self, defaults: &[String]) -> Result>>; fn prompt_agent_trace_auto_sync(&self) -> Result>; fn prompt_attribution_hooks_enabled(&self) -> Result>; @@ -895,8 +919,11 @@ pub trait SetupTargetPrompter { pub struct InquireSetupTargetPrompter; impl SetupTargetPrompter for InquireSetupTargetPrompter { - fn prompt_target(&self) -> Result { - prompt::prompt_target() + fn prompt_targets( + &self, + defaults: &[ConcreteSetupTarget], + ) -> Result>> { + prompt::prompt_targets(defaults) } fn prompt_optional_workflows(&self, defaults: &[String]) -> Result>> { @@ -913,13 +940,7 @@ impl SetupTargetPrompter for InquireSetupTargetPrompter { } #[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum SetupPromptTarget { - OpenCode, - Claude, - Pi, - Codex, - All, -} +struct SetupPromptTarget(ConcreteSetupTarget); impl std::fmt::Display for SetupPromptTarget { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { @@ -937,14 +958,15 @@ pub fn resolve_setup_dispatch

( mode: SetupMode, prompter: &P, optional_workflow_defaults: &[String], + configured_targets: &[ConcreteSetupTarget], + persisted_confirmations: PersistedSetupConfirmations, ) -> Result where P: SetupTargetPrompter, { match mode { SetupMode::Interactive => { - let target_dispatch = prompter.prompt_target()?; - let SetupDispatch::Proceed { mode, .. } = target_dispatch else { + let Some(targets) = prompter.prompt_targets(configured_targets)? else { return Ok(SetupDispatch::Cancelled); }; @@ -954,24 +976,36 @@ where return Ok(SetupDispatch::Cancelled); }; - let Some(agent_trace_auto_sync) = prompter.prompt_agent_trace_auto_sync()? else { + let Some(agent_trace_auto_sync) = + persisted_confirmations.agent_trace_auto_sync.map_or_else( + || prompter.prompt_agent_trace_auto_sync(), + |value| Ok(Some(value)), + )? + else { return Ok(SetupDispatch::Cancelled); }; - let Some(attribution_hooks_enabled) = prompter.prompt_attribution_hooks_enabled()? + let Some(attribution_hooks_enabled) = persisted_confirmations + .attribution_hooks_enabled + .map_or_else( + || prompter.prompt_attribution_hooks_enabled(), + |value| Ok(Some(value)), + )? else { return Ok(SetupDispatch::Cancelled); }; Ok(SetupDispatch::Proceed { - mode, + targets, + target_persistence: TargetPersistence::Replace, optional_workflows: Some(optional_workflows), agent_trace_auto_sync: Some(agent_trace_auto_sync), attribution_hooks_enabled: Some(attribution_hooks_enabled), }) } SetupMode::NonInteractive(target) => Ok(SetupDispatch::Proceed { - mode: SetupMode::NonInteractive(target), + targets: concrete_targets_for(target).to_vec(), + target_persistence: TargetPersistence::Merge, optional_workflows: None, agent_trace_auto_sync: None, attribution_hooks_enabled: None, diff --git a/cli/src/services/setup/prompt.rs b/cli/src/services/setup/prompt.rs index 2e69e192a..fd867bc9c 100644 --- a/cli/src/services/setup/prompt.rs +++ b/cli/src/services/setup/prompt.rs @@ -1,39 +1,49 @@ use anyhow::{bail, Result}; -use inquire::{Confirm, InquireError, MultiSelect, Select}; +use inquire::list_option::ListOption; +use inquire::validator::Validation; +use inquire::{Confirm, InquireError, MultiSelect}; use crate::services::style::{prompt_label, prompt_value_with_color_policy}; -use super::{OptionalWorkflow, SetupDispatch, SetupMode, SetupPromptTarget, SetupTarget}; +use super::{ConcreteSetupTarget, OptionalWorkflow, SetupPromptTarget}; -fn proceed(target: SetupTarget) -> SetupDispatch { - SetupDispatch::Proceed { - mode: SetupMode::NonInteractive(target), - optional_workflows: None, - agent_trace_auto_sync: None, - attribution_hooks_enabled: None, - } -} +const SETUP_PROMPT_TARGETS: [ConcreteSetupTarget; 4] = [ + ConcreteSetupTarget::OpenCode, + ConcreteSetupTarget::Claude, + ConcreteSetupTarget::Pi, + ConcreteSetupTarget::Codex, +]; + +const SETUP_TARGET_REQUIRED_MESSAGE: &str = "Select at least one setup target."; -pub(super) fn prompt_target() -> Result { - let options = vec![ - SetupPromptTarget::OpenCode, - SetupPromptTarget::Claude, - SetupPromptTarget::Pi, - SetupPromptTarget::Codex, - SetupPromptTarget::All, - ]; +pub(super) fn prompt_targets( + defaults: &[ConcreteSetupTarget], +) -> Result>> { + let options: Vec = SETUP_PROMPT_TARGETS + .iter() + .map(|target| SetupPromptTarget(*target)) + .collect(); + let default_indices: Vec = SETUP_PROMPT_TARGETS + .iter() + .enumerate() + .filter(|(_, target)| defaults.contains(target)) + .map(|(index, _)| index) + .collect(); - let selection = Select::new(&setup_prompt_title(), options).prompt(); + let selection = MultiSelect::new(&setup_prompt_title(), options) + .with_default(&default_indices) + .with_validator(|selected: &[ListOption<&SetupPromptTarget>]| { + if selected.is_empty() { + Ok(Validation::Invalid(SETUP_TARGET_REQUIRED_MESSAGE.into())) + } else { + Ok(Validation::Valid) + } + }) + .prompt(); match selection { - Ok(SetupPromptTarget::OpenCode) => Ok(proceed(SetupTarget::OpenCode)), - Ok(SetupPromptTarget::Claude) => Ok(proceed(SetupTarget::Claude)), - Ok(SetupPromptTarget::Pi) => Ok(proceed(SetupTarget::Pi)), - Ok(SetupPromptTarget::Codex) => Ok(proceed(SetupTarget::Codex)), - Ok(SetupPromptTarget::All) => Ok(proceed(SetupTarget::All)), - Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => { - Ok(SetupDispatch::Cancelled) - } + Ok(selected) => Ok(Some(selected.into_iter().map(|row| row.0).collect())), + Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => Ok(None), Err(InquireError::NotTTY) => bail!( "Interactive setup requires a TTY. Re-run with '--non-interactive' and one of '--opencode', '--claude', '--pi', '--codex', or '--all'." ), @@ -159,7 +169,7 @@ pub(super) fn optional_workflow_row_label_with_color_policy( } pub(super) fn setup_prompt_title() -> String { - prompt_label("Select setup target") + prompt_label("Select setup targets") } pub(super) fn setup_prompt_target_label(target: SetupPromptTarget) -> String { @@ -170,12 +180,11 @@ pub(super) fn setup_prompt_target_label_with_color_policy( target: SetupPromptTarget, color_enabled: bool, ) -> String { - let label = match target { - SetupPromptTarget::OpenCode => "OpenCode", - SetupPromptTarget::Claude => "Claude", - SetupPromptTarget::Pi => "Pi", - SetupPromptTarget::Codex => "Codex", - SetupPromptTarget::All => "All (OpenCode + Claude + Pi + Codex)", + let label = match target.0 { + ConcreteSetupTarget::OpenCode => "OpenCode", + ConcreteSetupTarget::Claude => "Claude", + ConcreteSetupTarget::Pi => "Pi", + ConcreteSetupTarget::Codex => "Codex", }; prompt_value_with_color_policy(label, color_enabled)