From c4efb12e65854030d469266544d4ed7c05e65d58 Mon Sep 17 00:00:00 2001 From: David Abram Date: Mon, 3 Aug 2026 18:52:50 +0200 Subject: [PATCH] setup: Replace directory-level install with per-asset merge-aware install sce setup previously staged the full catalog into a temp directory and swapped it over the whole .claude/, .opencode/, or .pi/ target directory, destroying any user-owned files (skills, settings.local.json, CLAUDE.md, etc.) living alongside SCE's own assets. The two generated JSON configs (.claude/settings.json, .opencode/opencode.json) had the same problem one level down: written whole, they clobbered a user's permissions, env, model, mcp, or non-SCE hook/plugin entries. Installation now happens per asset: each embedded file is staged and atomically renamed into place individually, and assets the current selection or catalog no longer owns are pruned by relative path instead of the whole directory being rebuilt. The two config files are merged via a new config_merge module that replaces only the SCE-owned fragment (hook entries by marker, plugin paths by prefix) and leaves every other key and entry untouched, idempotently across repeated installs. sce doctor's integration inspection is updated to match: the two merge-target configs are checked by whether their SCE-owned fragment is current rather than by byte-exact sha256, and `--fix` gained a repair path that reinstalls just a drifted merge-target asset. Co-authored-by: SCE --- cli/src/services/doctor/inspect.rs | 376 +++++++++- cli/src/services/doctor/mod.rs | 3 +- cli/src/services/setup/config_merge.rs | 530 ++++++++++++++ cli/src/services/setup/mod.rs | 667 +++++++++++++++--- context/architecture.md | 2 +- context/cli/cli-command-surface.md | 2 +- context/context-map.md | 2 +- context/glossary.md | 6 +- context/overview.md | 2 +- context/patterns.md | 5 +- .../plans/non-destructive-setup-install.md | 242 +++++++ context/sce/doctor-human-text-contract.md | 5 +- context/sce/setup-no-backup-policy-seam.md | 39 +- .../sce/setup-repo-local-config-bootstrap.md | 2 +- 14 files changed, 1736 insertions(+), 147 deletions(-) create mode 100644 cli/src/services/setup/config_merge.rs create mode 100644 context/plans/non-destructive-setup-install.md diff --git a/cli/src/services/doctor/inspect.rs b/cli/src/services/doctor/inspect.rs index f8a83bd2..a2860344 100644 --- a/cli/src/services/doctor/inspect.rs +++ b/cli/src/services/doctor/inspect.rs @@ -15,18 +15,18 @@ use crate::services::repository_identity::resolve::{ resolve_repository_identity, RepositoryIdentitySource, }; use crate::services::setup::{ - iter_embedded_assets_for_setup_target_with_selection, iter_required_hook_assets, - persisted_optional_workflows, EmbeddedAsset, SetupTarget, + config_merge, iter_embedded_assets_for_setup_target_with_selection, iter_required_hook_assets, + persisted_optional_workflows, repair_merge_target_asset, EmbeddedAsset, SetupTarget, }; use super::types::{ - AgentTraceDbHealth, CheckoutIdentityHealth, DoctorProblem, FileLocationHealth, - GlobalStateHealth, HookContentState, HookDoctorReport, HookFileHealth, HookPathSource, - IntegrationChildHealth, IntegrationContentState, IntegrationGroupHealth, ProblemCategory, - ProblemFixability, ProblemKind, ProblemSeverity, Readiness, CLAUDE_AGENTS_LABEL, - CLAUDE_COMMANDS_LABEL, CLAUDE_PLUGINS_LABEL, CLAUDE_SKILLS_LABEL, OPENCODE_AGENTS_LABEL, - OPENCODE_COMMANDS_LABEL, OPENCODE_PLUGINS_LABEL, OPENCODE_SKILLS_LABEL, PI_EXTENSIONS_LABEL, - PI_PROMPTS_LABEL, PI_SKILLS_LABEL, + AgentTraceDbHealth, CheckoutIdentityHealth, DoctorFixResultRecord, DoctorProblem, + FileLocationHealth, FixResult, GlobalStateHealth, HookContentState, HookDoctorReport, + HookFileHealth, HookPathSource, IntegrationChildHealth, IntegrationContentState, + IntegrationGroupHealth, ProblemCategory, ProblemFixability, ProblemKind, ProblemSeverity, + Readiness, CLAUDE_AGENTS_LABEL, CLAUDE_COMMANDS_LABEL, CLAUDE_PLUGINS_LABEL, + CLAUDE_SKILLS_LABEL, OPENCODE_AGENTS_LABEL, OPENCODE_COMMANDS_LABEL, OPENCODE_PLUGINS_LABEL, + OPENCODE_SKILLS_LABEL, PI_EXTENSIONS_LABEL, PI_PROMPTS_LABEL, PI_SKILLS_LABEL, }; use super::{is_executable, DoctorDependencies, DoctorMode, REQUIRED_HOOKS}; @@ -515,6 +515,81 @@ fn inspect_repository_integrations( integration_groups } +/// Repairs each merge-target asset (`.claude/settings.json`, +/// `.opencode/opencode.json`) whose SCE-owned fragment is currently missing or +/// stale, by reinstalling just that asset through the same merge-install path +/// `sce setup` uses. Assets whose fragment is already current are left +/// untouched, and a fully missing integration is left to the existing +/// "reinstall assets" guidance rather than being created here. +pub(super) fn repair_merge_target_configs(repository_root: &Path) -> Vec { + let targets = resolve_doctor_integration_targets(repository_root); + let selected_optional_workflows = persisted_optional_workflows(repository_root); + let mut results = Vec::new(); + + if targets.contains(&IntegrationTargetId::Claude) { + let claude_groups = + collect_claude_integration_groups(repository_root, &selected_optional_workflows); + if let Some(result) = repair_merge_target_if_mismatched( + repository_root, + SetupTarget::Claude, + claude_asset::SETTINGS_FILE, + &claude_groups, + ) { + results.push(result); + } + } + + if targets.contains(&IntegrationTargetId::Opencode) { + let opencode_groups = + collect_opencode_integration_groups(repository_root, &selected_optional_workflows); + if let Some(result) = repair_merge_target_if_mismatched( + repository_root, + SetupTarget::OpenCode, + OPENCODE_CONFIG_RELATIVE_PATH, + &opencode_groups, + ) { + results.push(result); + } + } + + results +} + +fn repair_merge_target_if_mismatched( + repository_root: &Path, + target: SetupTarget, + relative_path: &str, + groups: &[IntegrationGroupHealth], +) -> Option { + let is_mismatched = groups + .iter() + .flat_map(|group| &group.children) + .any(|child| { + child.relative_path == relative_path + && matches!(child.content_state, IntegrationContentState::Mismatch) + }); + if !is_mismatched { + return None; + } + + Some( + match repair_merge_target_asset(repository_root, target, relative_path) { + Ok(()) => DoctorFixResultRecord { + category: ProblemCategory::RepoAssets, + outcome: FixResult::Fixed, + detail: format!("Merged canonical SCE fragments into '{relative_path}'."), + }, + Err(error) => DoctorFixResultRecord { + category: ProblemCategory::RepoAssets, + outcome: FixResult::Failed, + detail: format!( + "Failed to merge canonical SCE fragments into '{relative_path}': {error}" + ), + }, + }, + ) +} + #[allow(dead_code)] fn collect_global_state_health( repository_root: &Path, @@ -1167,18 +1242,24 @@ fn collect_opencode_integration_groups( let manifest_child = embedded_assets .iter() - .find(|asset| asset.relative_path == "opencode.json") + .find(|asset| asset.relative_path == OPENCODE_CONFIG_RELATIVE_PATH) .map_or_else( || build_integration_child_presence_only("opencode.json", &manifest_path), - |asset| build_integration_child_from_asset(&opencode_root, asset), + |asset| { + build_integration_child_from_asset( + &opencode_root, + asset, + Some(&MergeTargetAsset::OpenCodeConfig), + ) + }, ); plugin_children.push(manifest_child); for asset in embedded_assets { - if asset.relative_path == "opencode.json" { + if asset.relative_path == OPENCODE_CONFIG_RELATIVE_PATH { continue; } - let child = build_integration_child_from_asset(&opencode_root, asset); + let child = build_integration_child_from_asset(&opencode_root, asset, None); if child .relative_path @@ -1248,7 +1329,12 @@ fn collect_claude_integration_groups( let mut skill_children = Vec::new(); for asset in embedded_assets { - let child = build_integration_child_from_asset(&claude_root, asset); + let merge_target = if asset.relative_path == claude_asset::SETTINGS_FILE { + Some(&MergeTargetAsset::ClaudeSettings) + } else { + None + }; + let child = build_integration_child_from_asset(&claude_root, asset, merge_target); if child.relative_path == claude_asset::SETTINGS_FILE || child @@ -1315,7 +1401,7 @@ fn collect_pi_integration_groups( let mut extension_children = Vec::new(); for asset in embedded_assets { - let child = build_integration_child_from_asset(&pi_root, asset); + let child = build_integration_child_from_asset(&pi_root, asset, None); if child .relative_path @@ -1359,12 +1445,36 @@ fn sort_integration_children(children: &mut [IntegrationChildHealth]) { children.sort_by(|left, right| left.relative_path.cmp(&right.relative_path)); } +/// The relative path of the `OpenCode` merge-target asset within `.opencode/`. +const OPENCODE_CONFIG_RELATIVE_PATH: &str = "opencode.json"; + +/// Identifies the two setup assets that are installed by JSON merge +/// (`config_merge`) rather than whole-file replacement, and therefore need +/// SCE-fragment-based content inspection instead of byte-exact `sha256`. +enum MergeTargetAsset { + ClaudeSettings, + OpenCodeConfig, +} + fn build_integration_child_from_asset( integration_root: &Path, asset: &EmbeddedAsset, + merge_target: Option<&MergeTargetAsset>, ) -> IntegrationChildHealth { let path = integration_root.join(asset.relative_path); - let content_state = inspect_integration_asset_state(&path, &asset.sha256); + let content_state = match merge_target { + Some(MergeTargetAsset::ClaudeSettings) => inspect_merge_target_asset_state( + &path, + asset.bytes, + config_merge::claude_settings_fragment_is_current, + ), + Some(MergeTargetAsset::OpenCodeConfig) => inspect_merge_target_asset_state( + &path, + asset.bytes, + config_merge::opencode_config_fragment_is_current, + ), + None => inspect_integration_asset_state(&path, &asset.sha256), + }; IntegrationChildHealth { relative_path: asset.relative_path.to_string(), path, @@ -1372,6 +1482,33 @@ fn build_integration_child_from_asset( } } +/// Content state for a merge-target asset: `Match` when the existing file +/// already carries a current, complete copy of the SCE-owned fragment +/// alongside whatever else it holds; `Mismatch` when that fragment is absent +/// or stale, or when the existing file cannot be parsed as JSON (a merge +/// cannot succeed either way, so both drift and hard-error surface the same +/// remediation: reinstall/`sce doctor --fix`). +fn inspect_merge_target_asset_state( + path: &Path, + generated_bytes: &[u8], + fragment_is_current: fn(&[u8], &[u8]) -> anyhow::Result, +) -> IntegrationContentState { + if !path_is_file(path) { + return IntegrationContentState::Missing; + } + + match fs::read(path) { + Ok(existing_bytes) => { + if fragment_is_current(&existing_bytes, generated_bytes).unwrap_or(false) { + IntegrationContentState::Match + } else { + IntegrationContentState::Mismatch + } + } + Err(error) => IntegrationContentState::ReadFailed(error.to_string()), + } +} + fn build_integration_child_presence_only( relative_path: &str, path: &Path, @@ -1598,4 +1735,211 @@ mod tests { "a selected optional workflow's missing file was not reported" ); } + + fn unique_temp_repository_root(label: &str) -> PathBuf { + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time should be after Unix epoch") + .as_nanos(); + let dir = std::env::temp_dir().join(format!( + "sce-doctor-merge-target-{label}-{}-{nonce}", + std::process::id() + )); + std::fs::create_dir_all(&dir).expect("create temp repository root"); + dir + } + + fn embedded_claude_settings_bytes() -> &'static [u8] { + crate::services::setup::iter_embedded_assets_for_setup_target_with_selection( + crate::services::setup::SetupTarget::Claude, + &[] as &[String], + ) + .find(|asset| { + asset.relative_path == crate::services::default_paths::claude_asset::SETTINGS_FILE + }) + .expect("embedded Claude catalog carries settings.json") + .bytes + } + + fn embedded_opencode_config_bytes() -> &'static [u8] { + crate::services::setup::iter_embedded_assets_for_setup_target_with_selection( + crate::services::setup::SetupTarget::OpenCode, + &[] as &[String], + ) + .find(|asset| asset.relative_path == "opencode.json") + .expect("embedded OpenCode catalog carries opencode.json") + .bytes + } + + #[test] + fn claude_settings_reports_match_despite_extra_user_permissions() { + let root = unique_temp_repository_root("claude-pass"); + let claude_dir = root.join(".claude"); + std::fs::create_dir_all(&claude_dir).unwrap(); + + let generated_bytes = embedded_claude_settings_bytes(); + let installed_bytes = + crate::services::setup::config_merge::merge_or_create_claude_settings( + None, + generated_bytes, + "settings.json", + ) + .unwrap(); + let mut installed: serde_json::Value = serde_json::from_slice(&installed_bytes).unwrap(); + installed["permissions"] = serde_json::json!({"allow": ["Bash(git *)"]}); + std::fs::write( + claude_dir.join("settings.json"), + serde_json::to_vec_pretty(&installed).unwrap(), + ) + .unwrap(); + + let groups = collect_claude_integration_groups(&root, &[]); + let settings_child = groups + .iter() + .flat_map(|group| &group.children) + .find(|child| child.relative_path == "settings.json") + .expect("settings.json child present"); + assert!(matches!( + settings_child.content_state, + IntegrationContentState::Match + )); + + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn claude_settings_reports_mismatch_when_sce_hook_entry_deleted_then_fix_repairs_it() { + let root = unique_temp_repository_root("claude-fix"); + let claude_dir = root.join(".claude"); + std::fs::create_dir_all(&claude_dir).unwrap(); + + let generated_bytes = embedded_claude_settings_bytes(); + let installed_bytes = + crate::services::setup::config_merge::merge_or_create_claude_settings( + None, + generated_bytes, + "settings.json", + ) + .unwrap(); + let mut drifted: serde_json::Value = serde_json::from_slice(&installed_bytes).unwrap(); + drifted["permissions"] = serde_json::json!({"allow": ["Bash(git *)"]}); + // Drop every hook event's entries to simulate a deleted SCE hook entry. + for (_, entries) in drifted["hooks"].as_object_mut().unwrap() { + *entries = serde_json::json!([]); + } + let settings_path = claude_dir.join("settings.json"); + std::fs::write(&settings_path, serde_json::to_vec_pretty(&drifted).unwrap()).unwrap(); + + let groups = collect_claude_integration_groups(&root, &[]); + let settings_child = groups + .iter() + .flat_map(|group| &group.children) + .find(|child| child.relative_path == "settings.json") + .expect("settings.json child present"); + assert!(matches!( + settings_child.content_state, + IntegrationContentState::Mismatch + )); + + let fix_results = super::repair_merge_target_configs(&root); + assert!( + fix_results + .iter() + .any(|result| matches!(result.outcome, super::FixResult::Fixed)), + "expected the drifted settings.json to be repaired" + ); + + let repaired: serde_json::Value = + serde_json::from_slice(&std::fs::read(&settings_path).unwrap()).unwrap(); + assert_eq!(repaired["permissions"]["allow"][0], "Bash(git *)"); + + let groups_after_fix = collect_claude_integration_groups(&root, &[]); + let settings_child_after_fix = groups_after_fix + .iter() + .flat_map(|group| &group.children) + .find(|child| child.relative_path == "settings.json") + .expect("settings.json child present"); + assert!(matches!( + settings_child_after_fix.content_state, + IntegrationContentState::Match + )); + + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn opencode_config_reports_match_despite_extra_user_plugin_then_drift_and_fix() { + let root = unique_temp_repository_root("opencode-fix"); + let opencode_dir = root.join(".opencode"); + std::fs::create_dir_all(&opencode_dir).unwrap(); + + let generated_bytes = embedded_opencode_config_bytes(); + let installed_bytes = + crate::services::setup::config_merge::merge_or_create_opencode_config( + None, + generated_bytes, + "opencode.json", + ) + .unwrap(); + let mut installed: serde_json::Value = serde_json::from_slice(&installed_bytes).unwrap(); + installed["model"] = serde_json::json!("anthropic/claude"); + installed["plugin"] + .as_array_mut() + .unwrap() + .insert(0, serde_json::json!("./plugins/my-plugin.ts")); + let manifest_path = opencode_dir.join("opencode.json"); + std::fs::write( + &manifest_path, + serde_json::to_vec_pretty(&installed).unwrap(), + ) + .unwrap(); + + let groups = collect_opencode_integration_groups(&root, &[]); + let manifest_child = groups + .iter() + .flat_map(|group| &group.children) + .find(|child| child.relative_path == "opencode.json") + .expect("opencode.json child present"); + assert!(matches!( + manifest_child.content_state, + IntegrationContentState::Match + )); + + // Drop the plugin array entirely to simulate a stale/removed SCE registration. + installed["plugin"] = serde_json::json!(["./plugins/my-plugin.ts"]); + std::fs::write( + &manifest_path, + serde_json::to_vec_pretty(&installed).unwrap(), + ) + .unwrap(); + + let drifted_groups = collect_opencode_integration_groups(&root, &[]); + let drifted_child = drifted_groups + .iter() + .flat_map(|group| &group.children) + .find(|child| child.relative_path == "opencode.json") + .expect("opencode.json child present"); + assert!(matches!( + drifted_child.content_state, + IntegrationContentState::Mismatch + )); + + let fix_results = super::repair_merge_target_configs(&root); + assert!( + fix_results + .iter() + .any(|result| matches!(result.outcome, super::FixResult::Fixed)), + "expected the drifted opencode.json to be repaired" + ); + + let repaired: serde_json::Value = + serde_json::from_slice(&std::fs::read(&manifest_path).unwrap()).unwrap(); + assert_eq!(repaired["model"], "anthropic/claude"); + let plugin = repaired["plugin"].as_array().unwrap(); + assert!(plugin.contains(&serde_json::json!("./plugins/my-plugin.ts"))); + assert!(plugin.contains(&serde_json::json!("./plugins/sce-bash-policy.ts"))); + assert!(plugin.contains(&serde_json::json!("./plugins/sce-agent-trace.ts"))); + + std::fs::remove_dir_all(&root).ok(); + } } diff --git a/cli/src/services/doctor/mod.rs b/cli/src/services/doctor/mod.rs index 16a877da..30a2ca85 100644 --- a/cli/src/services/doctor/mod.rs +++ b/cli/src/services/doctor/mod.rs @@ -21,7 +21,7 @@ pub(crate) mod types; pub mod command; use fixes::build_manual_fix_results; -use inspect::build_report_with_lifecycle_problems; +use inspect::{build_report_with_lifecycle_problems, repair_merge_target_configs}; use render::render_report; use types::{ DoctorFixResultRecord, DoctorProblem, FixResult, HookDoctorReport, ProblemCategory, @@ -129,6 +129,7 @@ fn execute_doctor_with_lifecycle_providers( } let mut fix_results = fix_lifecycle_providers(context, &providers, &initial_problems); + fix_results.extend(repair_merge_target_configs(repository_root)); let final_problems = diagnose_lifecycle_providers(context, &providers); let final_doctor_problems = final_problems .into_iter() diff --git a/cli/src/services/setup/config_merge.rs b/cli/src/services/setup/config_merge.rs new file mode 100644 index 00000000..b2340342 --- /dev/null +++ b/cli/src/services/setup/config_merge.rs @@ -0,0 +1,530 @@ +//! Pure JSON merge for setup-installed config files that a user may already own +//! and extend. Two known shapes today: Claude's `.claude/settings.json` hook +//! registry and `OpenCode`'s `.opencode/opencode.json` plugin registry. Each +//! merge keeps every non-SCE key and entry untouched, and replaces SCE-owned +//! content wholesale so repeated installs stay idempotent. + +use anyhow::{Context, Result}; +use serde_json::Value; + +/// Substring identifying an SCE-authored Claude hook command +/// (`config/pkl/renderers/claude-content.pkl`). +const CLAUDE_SCE_HOOK_MARKER: &str = "run-sce-or-show-install-guidance.sh"; + +/// Path prefix identifying an SCE-authored `OpenCode` plugin registration +/// (`config/pkl/base/opencode.pkl`), matched structurally so a plugin path an +/// older or renamed catalog installed is still recognized as SCE-owned even +/// though the current generated document no longer declares it. +const OPENCODE_SCE_PLUGIN_PREFIX: &str = "./plugins/sce-"; + +/// Merges `generated` (the freshly rendered SCE settings document) into +/// `existing_bytes` (the user's current `.claude/settings.json`, if any) and +/// returns the merged document's bytes, pretty-printed with a trailing +/// newline. When `existing_bytes` is `None`, returns `generated` verbatim. +/// +/// `source_path` is used only to name the offending file in a parse error. +pub fn merge_or_create_claude_settings( + existing_bytes: Option<&[u8]>, + generated_bytes: &[u8], + source_path: &str, +) -> Result> { + let Some(existing_bytes) = existing_bytes else { + return Ok(generated_bytes.to_vec()); + }; + + let existing: Value = serde_json::from_slice(existing_bytes).with_context(|| { + format!("Existing config file '{source_path}' must contain valid JSON.") + })?; + let generated: Value = serde_json::from_slice(generated_bytes) + .context("Generated settings payload must be valid JSON")?; + + let merged = merge_claude_settings(&existing, &generated, source_path)?; + + let mut serialized = serde_json::to_string_pretty(&merged) + .context("Failed to serialize merged Claude settings")?; + serialized.push('\n'); + Ok(serialized.into_bytes()) +} + +/// Merges `generated` into `existing` for the Claude settings shape: +/// - `$schema` is SCE-owned and taken from `generated`. +/// - `hooks` is merged event-by-event: for each event key `generated.hooks` +/// declares, entries in `existing.hooks[event]` whose command contains the +/// SCE marker are dropped, and `generated.hooks[event]`'s entries are +/// appended after the surviving (non-SCE) entries. Event keys `existing` +/// holds that `generated` does not declare are left untouched. +/// - Every other top-level key in `existing` is left untouched. +fn merge_claude_settings(existing: &Value, generated: &Value, source_path: &str) -> Result { + let mut existing_obj = existing.as_object().cloned().with_context(|| { + format!("Existing config file '{source_path}' must contain a top-level JSON object.") + })?; + let generated_obj = generated + .as_object() + .context("Generated settings payload must contain a top-level JSON object")?; + + if let Some(schema) = generated_obj.get("$schema") { + existing_obj.insert("$schema".to_string(), schema.clone()); + } + + if let Some(generated_hooks) = generated_obj.get("hooks") { + let generated_hooks = generated_hooks + .as_object() + .context("Generated settings 'hooks' must be a JSON object")?; + + let mut existing_hooks = match existing_obj.get("hooks") { + Some(value) => value.as_object().cloned().with_context(|| { + format!("Existing config file '{source_path}' key 'hooks' must be a JSON object.") + })?, + None => serde_json::Map::new(), + }; + + for (event, generated_entries) in generated_hooks { + let generated_entries = generated_entries.as_array().with_context(|| { + format!("Generated settings 'hooks.{event}' must be a JSON array") + })?; + + let existing_entries = match existing_hooks.get(event) { + Some(value) => value.as_array().with_context(|| { + format!("Existing config file '{source_path}' key 'hooks.{event}' must be a JSON array.") + })?, + None => &Vec::new(), + }; + + let mut merged_entries: Vec = existing_entries + .iter() + .filter(|entry| !hook_entry_is_sce_owned(entry)) + .cloned() + .collect(); + merged_entries.extend(generated_entries.iter().cloned()); + + existing_hooks.insert(event.clone(), Value::Array(merged_entries)); + } + + existing_obj.insert("hooks".to_string(), Value::Object(existing_hooks)); + } + + Ok(Value::Object(existing_obj)) +} + +/// True when a Claude hook-matcher entry (`{"matcher": ..., "hooks": [{"type", +/// "command"}, ...]}`) carries at least one command routed through the SCE +/// hook script. +fn hook_entry_is_sce_owned(entry: &Value) -> bool { + entry + .get("hooks") + .and_then(Value::as_array) + .is_some_and(|hooks| { + hooks.iter().any(|hook| { + hook.get("command") + .and_then(Value::as_str) + .is_some_and(|command| command.contains(CLAUDE_SCE_HOOK_MARKER)) + }) + }) +} + +/// Merges `generated` (the freshly rendered SCE `OpenCode` config) into +/// `existing_bytes` (the user's current `.opencode/opencode.json`, if any) and +/// returns the merged document's bytes, pretty-printed with a trailing +/// newline. When `existing_bytes` is `None`, returns `generated` verbatim. +/// +/// `source_path` is used only to name the offending file in a parse error. +pub fn merge_or_create_opencode_config( + existing_bytes: Option<&[u8]>, + generated_bytes: &[u8], + source_path: &str, +) -> Result> { + let Some(existing_bytes) = existing_bytes else { + return Ok(generated_bytes.to_vec()); + }; + + let existing: Value = serde_json::from_slice(existing_bytes).with_context(|| { + format!("Existing config file '{source_path}' must contain valid JSON.") + })?; + let generated: Value = serde_json::from_slice(generated_bytes) + .context("Generated OpenCode config payload must be valid JSON")?; + + let merged = merge_opencode_config(&existing, &generated, source_path)?; + + let mut serialized = serde_json::to_string_pretty(&merged) + .context("Failed to serialize merged OpenCode config")?; + serialized.push('\n'); + Ok(serialized.into_bytes()) +} + +/// Merges `generated` into `existing` for the `OpenCode` config shape: +/// - `$schema` is SCE-owned and taken from `generated`. +/// - `plugin` is merged as a set: entries in `existing.plugin` shaped like an +/// SCE plugin path are dropped (whether or not `generated.plugin` still +/// declares them), and `generated.plugin`'s entries are appended after the +/// surviving (non-SCE) entries. +/// - Every other top-level key in `existing` is left untouched. +fn merge_opencode_config(existing: &Value, generated: &Value, source_path: &str) -> Result { + let mut existing_obj = existing.as_object().cloned().with_context(|| { + format!("Existing config file '{source_path}' must contain a top-level JSON object.") + })?; + let generated_obj = generated + .as_object() + .context("Generated OpenCode config payload must contain a top-level JSON object")?; + + if let Some(schema) = generated_obj.get("$schema") { + existing_obj.insert("$schema".to_string(), schema.clone()); + } + + if let Some(generated_plugin) = generated_obj.get("plugin") { + let generated_plugin = generated_plugin + .as_array() + .context("Generated OpenCode config 'plugin' must be a JSON array")?; + + let existing_plugin = match existing_obj.get("plugin") { + Some(value) => value.as_array().cloned().with_context(|| { + format!("Existing config file '{source_path}' key 'plugin' must be a JSON array.") + })?, + None => Vec::new(), + }; + + let mut merged_plugin: Vec = existing_plugin + .into_iter() + .filter(|entry| !plugin_entry_is_sce_owned(entry)) + .collect(); + merged_plugin.extend(generated_plugin.iter().cloned()); + + existing_obj.insert("plugin".to_string(), Value::Array(merged_plugin)); + } + + Ok(Value::Object(existing_obj)) +} + +/// True when a `plugin` array entry is a string shaped like an SCE plugin +/// registration path (`./plugins/sce-*`). +fn plugin_entry_is_sce_owned(entry: &Value) -> bool { + entry + .as_str() + .is_some_and(|path| path.starts_with(OPENCODE_SCE_PLUGIN_PREFIX)) +} + +/// True when merging `generated` into `existing_bytes` would be a no-op, i.e. +/// `existing_bytes` already carries a current, complete copy of every +/// SCE-owned hook entry the generated document declares. Used by `sce doctor` +/// to tell a merged file that legitimately carries extra user content apart +/// from an SCE-owned fragment that is missing or stale. +pub(crate) fn claude_settings_fragment_is_current( + existing_bytes: &[u8], + generated_bytes: &[u8], +) -> Result { + let existing: Value = serde_json::from_slice(existing_bytes) + .context("Existing Claude settings file must contain valid JSON.")?; + let generated: Value = serde_json::from_slice(generated_bytes) + .context("Generated settings payload must be valid JSON")?; + + let merged = merge_claude_settings(&existing, &generated, "existing")?; + Ok(merged == existing) +} + +/// True when merging `generated` into `existing_bytes` would be a no-op, i.e. +/// `existing_bytes` already carries every canonical SCE plugin path the +/// generated document declares and no stale SCE-shaped plugin path. Used by +/// `sce doctor` for the same purpose as `claude_settings_fragment_is_current`. +pub(crate) fn opencode_config_fragment_is_current( + existing_bytes: &[u8], + generated_bytes: &[u8], +) -> Result { + let existing: Value = serde_json::from_slice(existing_bytes) + .context("Existing OpenCode config file must contain valid JSON.")?; + let generated: Value = serde_json::from_slice(generated_bytes) + .context("Generated OpenCode config payload must be valid JSON")?; + + let merged = merge_opencode_config(&existing, &generated, "existing")?; + Ok(merged == existing) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn sce_hook_entry(command: &str) -> Value { + json!({ + "hooks": [ + {"type": "command", "command": format!("bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/{}\" {}", CLAUDE_SCE_HOOK_MARKER, command)} + ] + }) + } + + fn user_hook_entry() -> Value { + json!({ + "matcher": "Bash", + "hooks": [ + {"type": "command", "command": "echo user-hook"} + ] + }) + } + + fn generated_settings() -> Value { + json!({ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "hooks": { + "PreToolUse": [sce_hook_entry("sce policy bash")], + "Stop": [sce_hook_entry("sce hooks conversation-trace")] + } + }) + } + + #[test] + fn preserves_user_keys_and_non_sce_hook_entries() { + let existing = json!({ + "permissions": {"allow": ["Bash(git *)"]}, + "env": {"FOO": "bar"}, + "hooks": { + "PreToolUse": [user_hook_entry()] + } + }); + + let merged = + merge_claude_settings(&existing, &generated_settings(), "settings.json").unwrap(); + + assert_eq!(merged["permissions"]["allow"][0], "Bash(git *)"); + assert_eq!(merged["env"]["FOO"], "bar"); + + let pre_tool_use = merged["hooks"]["PreToolUse"].as_array().unwrap(); + assert_eq!(pre_tool_use.len(), 2); + assert_eq!(pre_tool_use[0], user_hook_entry()); + assert!(hook_entry_is_sce_owned(&pre_tool_use[1])); + } + + #[test] + fn replaces_sce_entries_instead_of_duplicating_them_across_two_merges() { + let existing = json!({}); + + let once = + merge_claude_settings(&existing, &generated_settings(), "settings.json").unwrap(); + let twice = merge_claude_settings(&once, &generated_settings(), "settings.json").unwrap(); + + assert_eq!(once, twice); + assert_eq!(twice["hooks"]["PreToolUse"].as_array().unwrap().len(), 1); + assert_eq!(twice["hooks"]["Stop"].as_array().unwrap().len(), 1); + } + + #[test] + fn drops_sce_entry_the_generated_document_no_longer_declares() { + let existing = json!({ + "hooks": { + "PreToolUse": [sce_hook_entry("sce policy bash"), user_hook_entry()], + "Stop": [sce_hook_entry("stale command")] + } + }); + + let generated = json!({ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "hooks": { + "PreToolUse": [sce_hook_entry("sce policy bash")], + "Stop": [] + } + }); + + let merged = merge_claude_settings(&existing, &generated, "settings.json").unwrap(); + + let stop = merged["hooks"]["Stop"].as_array().unwrap(); + assert!(stop.is_empty()); + let pre_tool_use = merged["hooks"]["PreToolUse"].as_array().unwrap(); + assert_eq!(pre_tool_use.len(), 2); + assert_eq!(pre_tool_use[0], user_hook_entry()); + } + + #[test] + fn leaves_event_keys_generated_does_not_declare_untouched() { + let existing = json!({ + "hooks": { + "Notification": [user_hook_entry()] + } + }); + + let merged = + merge_claude_settings(&existing, &generated_settings(), "settings.json").unwrap(); + + assert_eq!( + merged["hooks"]["Notification"].as_array().unwrap()[0], + user_hook_entry() + ); + } + + #[test] + fn missing_file_returns_generated_bytes_verbatim() { + let generated_bytes = b"{\"$schema\":\"x\"}"; + let result = + merge_or_create_claude_settings(None, generated_bytes, "settings.json").unwrap(); + assert_eq!(result, generated_bytes); + } + + #[test] + fn unparseable_existing_file_fails_naming_the_path_and_does_not_write() { + let generated_bytes = serde_json::to_vec(&generated_settings()).unwrap(); + let error = merge_or_create_claude_settings( + Some(b"{ not valid json"), + &generated_bytes, + ".claude/settings.json", + ) + .unwrap_err(); + + assert!(error.to_string().contains(".claude/settings.json")); + } + + #[test] + fn missing_existing_hooks_key_is_populated_from_generated() { + let existing = json!({"permissions": {"allow": []}}); + + let merged = + merge_claude_settings(&existing, &generated_settings(), "settings.json").unwrap(); + + assert_eq!(merged["hooks"]["PreToolUse"].as_array().unwrap().len(), 1); + } + + fn generated_opencode_config() -> Value { + json!({ + "$schema": "https://opencode.ai/config.json", + "plugin": ["./plugins/sce-bash-policy.ts", "./plugins/sce-agent-trace.ts"] + }) + } + + #[test] + fn opencode_merge_preserves_user_keys_and_user_plugin() { + let existing = json!({ + "model": "anthropic/claude", + "mcp": {"my-server": {"command": "my-server"}}, + "plugin": ["./plugins/my-plugin.ts"] + }); + + let merged = + merge_opencode_config(&existing, &generated_opencode_config(), "opencode.json") + .unwrap(); + + assert_eq!(merged["model"], "anthropic/claude"); + assert_eq!(merged["mcp"]["my-server"]["command"], "my-server"); + + let plugin = merged["plugin"].as_array().unwrap(); + assert_eq!(plugin.len(), 3); + assert_eq!(plugin[0], "./plugins/my-plugin.ts"); + assert!(plugin.contains(&json!("./plugins/sce-bash-policy.ts"))); + assert!(plugin.contains(&json!("./plugins/sce-agent-trace.ts"))); + } + + #[test] + fn opencode_merge_is_idempotent_across_two_merges() { + let existing = json!({}); + + let once = merge_opencode_config(&existing, &generated_opencode_config(), "opencode.json") + .unwrap(); + let twice = + merge_opencode_config(&once, &generated_opencode_config(), "opencode.json").unwrap(); + + assert_eq!(once, twice); + assert_eq!(twice["plugin"].as_array().unwrap().len(), 2); + } + + #[test] + fn opencode_merge_drops_stale_sce_shaped_plugin_path_current_catalog_no_longer_declares() { + let existing = json!({ + "plugin": ["./plugins/sce-old-feature.ts", "./plugins/my-plugin.ts"] + }); + + let merged = + merge_opencode_config(&existing, &generated_opencode_config(), "opencode.json") + .unwrap(); + + let plugin = merged["plugin"].as_array().unwrap(); + assert!(!plugin.contains(&json!("./plugins/sce-old-feature.ts"))); + assert!(plugin.contains(&json!("./plugins/my-plugin.ts"))); + assert!(plugin.contains(&json!("./plugins/sce-bash-policy.ts"))); + assert!(plugin.contains(&json!("./plugins/sce-agent-trace.ts"))); + } + + #[test] + fn opencode_missing_file_returns_generated_bytes_verbatim() { + let generated_bytes = b"{\"$schema\":\"x\"}"; + let result = + merge_or_create_opencode_config(None, generated_bytes, "opencode.json").unwrap(); + assert_eq!(result, generated_bytes); + } + + #[test] + fn opencode_unparseable_existing_file_fails_naming_the_path_and_does_not_write() { + let generated_bytes = serde_json::to_vec(&generated_opencode_config()).unwrap(); + let error = merge_or_create_opencode_config( + Some(b"{ not valid json"), + &generated_bytes, + ".opencode/opencode.json", + ) + .unwrap_err(); + + assert!(error.to_string().contains(".opencode/opencode.json")); + } + + #[test] + fn opencode_missing_existing_plugin_key_is_populated_from_generated() { + let existing = json!({"model": "anthropic/claude"}); + + let merged = + merge_opencode_config(&existing, &generated_opencode_config(), "opencode.json") + .unwrap(); + + assert_eq!(merged["plugin"].as_array().unwrap().len(), 2); + } + + #[test] + fn claude_fragment_is_current_when_merged_settings_already_match() { + let generated_bytes = serde_json::to_vec(&generated_settings()).unwrap(); + let installed = + merge_or_create_claude_settings(None, &generated_bytes, "settings.json").unwrap(); + let with_user_keys = + merge_or_create_claude_settings(Some(&installed), &generated_bytes, "settings.json") + .unwrap(); + + assert!(claude_settings_fragment_is_current(&with_user_keys, &generated_bytes).unwrap()); + } + + #[test] + fn claude_fragment_is_not_current_when_sce_hook_entry_is_deleted() { + let generated_bytes = serde_json::to_vec(&generated_settings()).unwrap(); + let installed_bytes = + merge_or_create_claude_settings(None, &generated_bytes, "settings.json").unwrap(); + let mut installed: Value = serde_json::from_slice(&installed_bytes).unwrap(); + installed["permissions"] = json!({"allow": ["Bash(git *)"]}); + + assert!(claude_settings_fragment_is_current( + &serde_json::to_vec(&installed).unwrap(), + &generated_bytes + ) + .unwrap()); + + installed["hooks"]["PreToolUse"] = json!([]); + let drifted_bytes = serde_json::to_vec(&installed).unwrap(); + + assert!(!claude_settings_fragment_is_current(&drifted_bytes, &generated_bytes).unwrap()); + } + + #[test] + fn opencode_fragment_is_current_when_merged_plugins_already_match() { + let generated_bytes = serde_json::to_vec(&generated_opencode_config()).unwrap(); + let installed_bytes = + merge_or_create_opencode_config(None, &generated_bytes, "opencode.json").unwrap(); + let mut installed: Value = serde_json::from_slice(&installed_bytes).unwrap(); + installed["model"] = json!("anthropic/claude"); + installed["plugin"] + .as_array_mut() + .unwrap() + .insert(0, json!("./plugins/my-plugin.ts")); + let existing_bytes = serde_json::to_vec(&installed).unwrap(); + + assert!(opencode_config_fragment_is_current(&existing_bytes, &generated_bytes).unwrap()); + } + + #[test] + fn opencode_fragment_is_not_current_when_sce_plugin_path_is_stale() { + let generated_bytes = serde_json::to_vec(&generated_opencode_config()).unwrap(); + let existing = json!({ + "plugin": ["./plugins/sce-old-feature.ts"] + }); + let existing_bytes = serde_json::to_vec(&existing).unwrap(); + + assert!(!opencode_config_fragment_is_current(&existing_bytes, &generated_bytes).unwrap()); + } +} diff --git a/cli/src/services/setup/mod.rs b/cli/src/services/setup/mod.rs index 86c67cb7..cfbef3ea 100644 --- a/cli/src/services/setup/mod.rs +++ b/cli/src/services/setup/mod.rs @@ -9,6 +9,7 @@ use crate::services::style::{label, success, value}; use crate::services::{default_paths, default_paths::RepoPaths}; pub mod command; +pub(crate) mod config_merge; /// Canonical JSON payload for a newly bootstrapped repo-local `.sce/config.json`. /// Contains only the `$schema` declaration pointing to the SCE config JSON Schema. @@ -647,6 +648,18 @@ pub fn install_embedded_setup_assets( install::install_embedded_setup_assets(repository_root, target, selected_optional_workflows) } +/// Repairs a single merge-target asset (`.claude/settings.json` or +/// `.opencode/opencode.json`) by reinstalling just that asset through the same +/// per-asset merge-install path `sce setup` uses, so `sce doctor --fix` can +/// restore a drifted SCE fragment without touching any other asset. +pub(crate) fn repair_merge_target_asset( + repository_root: &Path, + target: SetupTarget, + relative_path: &str, +) -> Result<()> { + install::repair_merge_target_asset(repository_root, target, relative_path) +} + pub(crate) fn setup_install_recovery_guidance( target: SetupTarget, destination_root: &Path, @@ -800,13 +813,16 @@ mod install { use crate::services::default_paths::InstallTargetPaths; use crate::services::security::{ensure_directory_is_writable, redact_sensitive_text}; + use super::config_merge; use super::{ - cleanup_path_if_exists, concrete_targets_for, hook_install_recovery_guidance, - iter_embedded_assets_for_setup_target_with_selection, iter_required_hook_assets, - setup_install_recovery_guidance, EmbeddedAsset, RequiredHookInstallResult, - RequiredHookInstallStatus, RequiredHooksInstallOutcome, SetupInstallOutcome, - SetupInstallTargetResult, SetupTarget, + 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, + RequiredHookInstallResult, RequiredHookInstallStatus, RequiredHooksInstallOutcome, + SetupInstallOutcome, SetupInstallTargetResult, SetupTarget, }; + use crate::services::default_paths; + use crate::services::default_paths::claude_asset; pub(super) fn prepare_setup_hooks_repository(repository_root: &Path) -> Result { let normalized_repository_root = normalize_user_repository_path(repository_root)?; @@ -839,6 +855,31 @@ mod install { ) } + pub(super) fn repair_merge_target_asset( + repository_root: &Path, + target: SetupTarget, + relative_path: &str, + ) -> Result<()> { + let asset = embedded_assets_for_concrete_target(target) + .iter() + .find(|asset| asset.relative_path == relative_path) + .with_context(|| { + 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::All => unreachable!("meta targets are expanded into concrete targets"), + }; + + install_single_asset_with_rename(target, &destination_root, asset, &mut |from, to| { + fs::rename(from, to) + }) + } + fn install_required_git_hooks_in_resolved_repository( resolved_repository_root: &Path, mut rename_fn: F, @@ -1156,7 +1197,7 @@ mod install { Ok(metadata.is_file()) } - fn install_embedded_setup_assets_with_rename( + pub(super) fn install_embedded_setup_assets_with_rename( repository_root: &Path, target: SetupTarget, selected_optional_workflows: &[String], @@ -1207,38 +1248,221 @@ mod install { unreachable!("meta targets are expanded into concrete targets") } }; - let staging_root = create_staging_root(repository_root, target)?; - if let Err(error) = write_assets_to_staging(&staging_root, assets) { - cleanup_path_if_exists(&staging_root); - return Err(error); + for asset in assets { + install_single_asset_with_rename(target, &destination_root, asset, rename_fn)?; } - if destination_root.exists() { - remove_existing_install_target(&destination_root).with_context(|| { + prune_stale_assets_for_concrete_target(&destination_root, target, assets)?; + + Ok(SetupInstallTargetResult { + target, + destination_root, + installed_file_count: assets.len(), + }) + } + + /// Deletes every catalog asset for `target` that this run did not install + /// (deselected, or dropped by a newer catalog), then removes any SCE-owned + /// skill directory left empty by that deletion. A directory still holding a + /// user file fails to remove and is left in place. + fn prune_stale_assets_for_concrete_target( + destination_root: &Path, + target: SetupTarget, + installed_assets: &[&'static EmbeddedAsset], + ) -> Result<()> { + let installed_paths: std::collections::HashSet<&'static str> = installed_assets + .iter() + .map(|asset| asset.relative_path) + .collect(); + + for asset in embedded_assets_for_concrete_target(target) { + if installed_paths.contains(asset.relative_path) { + continue; + } + + let destination = destination_root.join(asset.relative_path); + if !destination.is_file() { + continue; + } + + fs::remove_file(&destination).with_context(|| { format!( - "Failed to replace existing setup target '{}' without creating a backup", - destination_root.display() + "Failed to prune unselected setup asset '{}'", + destination.display() ) })?; + + remove_empty_ancestor_directories(destination_root, &destination); + } + + Ok(()) + } + + /// Removes now-empty parent directories of a pruned file, walking upward + /// until reaching `destination_root` or a directory that still has content + /// (removal fails and stops the walk). + fn remove_empty_ancestor_directories(destination_root: &Path, removed_file: &Path) { + let mut current = removed_file.parent(); + while let Some(directory) = current { + if directory == destination_root || !directory.starts_with(destination_root) { + break; + } + if fs::remove_dir(directory).is_err() { + break; + } + current = directory.parent(); } + } + + /// True for the one asset the Claude install path merges into an existing + /// document instead of overwriting: `.claude/settings.json`. + fn is_claude_settings_merge_target(target: SetupTarget, relative_path: &str) -> bool { + target == SetupTarget::Claude && relative_path == claude_asset::SETTINGS_FILE + } + + /// True for the one asset the `OpenCode` install path merges into an existing + /// document instead of overwriting: `.opencode/opencode.json`. + fn is_opencode_config_merge_target(target: SetupTarget, relative_path: &str) -> bool { + target == SetupTarget::OpenCode + && relative_path == default_paths::repo_file::OPENCODE_MANIFEST + } - if let Err(error) = rename_fn(&staging_root, &destination_root).with_context(|| { + fn install_single_asset_with_rename( + target: SetupTarget, + destination_root: &Path, + asset: &'static EmbeddedAsset, + rename_fn: &mut F, + ) -> Result<()> + where + F: FnMut(&Path, &Path) -> io::Result<()>, + { + validate_embedded_relative_path(asset.relative_path)?; + let destination = destination_root.join(asset.relative_path); + let parent = destination + .parent() + .context("Embedded asset destination should have a parent directory")?; + + fs::create_dir_all(parent).with_context(|| { format!( - "Failed to swap staged install '{}' into destination '{}'", - staging_root.display(), - destination_root.display() + "Failed to create parent directory '{}' for setup asset", + parent.display() + ) + })?; + + if destination.is_dir() { + bail!( + "Setup asset destination '{}' is an existing directory, not a file. Try: remove or rename the directory and rerun 'sce setup'.", + destination.display() + ); + } + + let install_bytes: Vec = if is_claude_settings_merge_target(target, asset.relative_path) + { + let existing_bytes = if destination.is_file() { + Some(fs::read(&destination).with_context(|| { + format!( + "Failed to read existing setup asset '{}' for merge", + destination.display() + ) + })?) + } else { + None + }; + config_merge::merge_or_create_claude_settings( + existing_bytes.as_deref(), + asset.bytes, + &destination.display().to_string(), + )? + } else if is_opencode_config_merge_target(target, asset.relative_path) { + let existing_bytes = if destination.is_file() { + Some(fs::read(&destination).with_context(|| { + format!( + "Failed to read existing setup asset '{}' for merge", + destination.display() + ) + })?) + } else { + None + }; + config_merge::merge_or_create_opencode_config( + existing_bytes.as_deref(), + asset.bytes, + &destination.display().to_string(), + )? + } else { + asset.bytes.to_vec() + }; + + let staging_path = create_asset_staging_path(parent, asset.relative_path)?; + if let Err(error) = fs::write(&staging_path, &install_bytes).with_context(|| { + format!( + "Failed to write staged embedded asset '{}'", + staging_path.display() ) }) { - cleanup_path_if_exists(&staging_root); - return Err(error.context(setup_install_recovery_guidance(target, &destination_root))); + cleanup_path_if_exists(&staging_path); + return Err(error); } - Ok(SetupInstallTargetResult { - target, - destination_root, - installed_file_count: assets.len(), - }) + if destination.exists() { + if let Err(error) = fs::remove_file(&destination).with_context(|| { + format!( + "Failed to replace existing setup asset '{}' without creating a backup", + destination.display() + ) + }) { + cleanup_path_if_exists(&staging_path); + return Err(error); + } + } + + if let Err(error) = rename_fn(&staging_path, &destination).with_context(|| { + format!( + "Failed to install staged asset '{}' into destination '{}'", + staging_path.display(), + destination.display() + ) + }) { + cleanup_path_if_exists(&staging_path); + return Err(error.context(setup_install_recovery_guidance(target, &destination))); + } + + Ok(()) + } + + fn create_asset_staging_path(parent: &Path, relative_path: &str) -> Result { + let epoch_nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("System clock is before UNIX_EPOCH")? + .as_nanos(); + let sanitized_name = relative_path.replace(['/', '\\'], "-"); + + for attempt in 0..1000_u16 { + let candidate = parent.join(format!( + ".sce-setup-staging-{sanitized_name}-{epoch_nanos}-{}-{attempt}", + std::process::id() + )); + + match fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(&candidate) + { + Ok(_) => return Ok(candidate), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {} + Err(error) => { + return Err(error).with_context(|| { + format!("Failed to allocate staging file '{}'", candidate.display()) + }); + } + } + } + + bail!( + "Could not allocate a unique staging file under '{}'", + parent.display() + ) } fn remove_existing_install_target(destination_root: &Path) -> Result<()> { @@ -1268,35 +1492,6 @@ mod install { Ok(()) } - fn write_assets_to_staging( - staging_root: &Path, - assets: &[&'static EmbeddedAsset], - ) -> Result<()> { - for asset in assets { - validate_embedded_relative_path(asset.relative_path)?; - let destination = staging_root.join(asset.relative_path); - let parent = destination - .parent() - .context("Embedded asset destination should have a parent directory")?; - - fs::create_dir_all(parent).with_context(|| { - format!( - "Failed to create staged parent directory '{}'", - parent.display() - ) - })?; - - fs::write(&destination, asset.bytes).with_context(|| { - format!( - "Failed to write staged embedded asset '{}'", - destination.display() - ) - })?; - } - - Ok(()) - } - fn validate_embedded_relative_path(relative_path: &str) -> Result<()> { let path = Path::new(relative_path); @@ -1315,52 +1510,6 @@ mod install { Ok(()) } - - fn create_staging_root(repository_root: &Path, target: SetupTarget) -> Result { - let install_targets = InstallTargetPaths::new(repository_root); - let target_dir = match target { - SetupTarget::OpenCode => install_targets.opencode_target_dir(), - SetupTarget::Claude => install_targets.claude_target_dir(), - SetupTarget::Pi => install_targets.pi_target_dir(), - SetupTarget::All => { - unreachable!("meta targets are expanded into concrete targets") - } - }; - let target_label = target_dir - .file_name() - .and_then(|name| name.to_str()) - .context("Setup target directory should have a valid UTF-8 file name")? - .trim_start_matches('.'); - let epoch_nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .context("System clock is before UNIX_EPOCH")? - .as_nanos(); - - for attempt in 0..1000_u16 { - let candidate = repository_root.join(format!( - ".sce-setup-staging-{target_label}-{epoch_nanos}-{}-{attempt}", - std::process::id() - )); - - match fs::create_dir(&candidate) { - Ok(()) => return Ok(candidate), - Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {} - Err(error) => { - return Err(error).with_context(|| { - format!( - "Failed to create staging directory '{}'", - candidate.display() - ) - }); - } - } - } - - bail!( - "Could not allocate a unique staging directory under '{}'", - repository_root.display() - ) - } } pub trait SetupTargetPrompter { @@ -1935,4 +2084,322 @@ mod tests { assert!(contains(SetupTarget::Pi, "extensions/sce/index.ts")); assert!(iter_required_hook_assets().all(|asset| !asset.bytes.is_empty())); } + + #[test] + fn install_preserves_user_owned_files_and_writes_sce_assets() { + let repo = init_git_repo("install-preserves-user-files"); + let claude_dir = default_paths::InstallTargetPaths::new(&repo).claude_target_dir(); + + fs::create_dir_all(claude_dir.join("skills/my-own-skill")).expect("create user skill dir"); + fs::create_dir_all(claude_dir.join("commands")).expect("create commands dir"); + + fs::write(claude_dir.join("MY_NOTES.md"), "top level user notes\n") + .expect("seed top-level user file"); + fs::write( + claude_dir.join("skills/my-own-skill/SKILL.md"), + "user skill content\n", + ) + .expect("seed user skill file"); + fs::write( + claude_dir.join("commands/my-command.md"), + "user command content\n", + ) + .expect("seed user command file"); + + let selection: Vec = every_optional_workflow() + .into_iter() + .map(str::to_string) + .collect(); + + install_embedded_setup_assets(&repo, SetupTarget::Claude, &selection) + .expect("install should succeed"); + + assert_eq!( + fs::read_to_string(claude_dir.join("MY_NOTES.md")).expect("read top-level user file"), + "top level user notes\n" + ); + assert_eq!( + fs::read_to_string(claude_dir.join("skills/my-own-skill/SKILL.md")) + .expect("read user skill file"), + "user skill content\n" + ); + assert_eq!( + fs::read_to_string(claude_dir.join("commands/my-command.md")) + .expect("read user command file"), + "user command content\n" + ); + + let expected_next_task_bytes = + iter_embedded_assets_for_setup_target_with_selection(SetupTarget::Claude, &selection) + .find(|asset| asset.relative_path == "commands/next-task.md") + .expect("next-task asset should be in the catalog") + .bytes; + assert_eq!( + fs::read(claude_dir.join("commands/next-task.md")).expect("read installed sce asset"), + expected_next_task_bytes + ); + + let _ = fs::remove_dir_all(&repo); + } + + #[test] + fn install_merges_into_existing_claude_settings_json_and_stays_idempotent() { + let repo = init_git_repo("install-merges-claude-settings"); + let claude_dir = default_paths::InstallTargetPaths::new(&repo).claude_target_dir(); + + fs::create_dir_all(&claude_dir).expect("create claude dir"); + fs::write( + claude_dir.join("settings.json"), + serde_json::to_string_pretty(&json!({ + "permissions": {"allow": ["Bash(git *)"]}, + "env": {"FOO": "bar"}, + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [{"type": "command", "command": "echo user-hook"}] + } + ] + } + })) + .expect("serialize seeded settings"), + ) + .expect("seed existing settings.json"); + + let selection: Vec = every_optional_workflow() + .into_iter() + .map(str::to_string) + .collect(); + + install_embedded_setup_assets(&repo, SetupTarget::Claude, &selection) + .expect("first install should succeed"); + + let after_first = + fs::read_to_string(claude_dir.join("settings.json")).expect("read merged settings"); + let merged: serde_json::Value = + serde_json::from_str(&after_first).expect("merged settings should be valid JSON"); + + assert_eq!(merged["permissions"]["allow"][0], "Bash(git *)"); + assert_eq!(merged["env"]["FOO"], "bar"); + let pre_tool_use = merged["hooks"]["PreToolUse"] + .as_array() + .expect("PreToolUse should be an array"); + assert!(pre_tool_use + .iter() + .any(|entry| entry["hooks"][0]["command"] == "echo user-hook")); + assert!(pre_tool_use + .iter() + .any(|entry| entry["hooks"] + .as_array() + .unwrap() + .iter() + .any(|hook| hook["command"] + .as_str() + .unwrap() + .contains("run-sce-or-show-install-guidance.sh")))); + + install_embedded_setup_assets(&repo, SetupTarget::Claude, &selection) + .expect("second install should succeed"); + + let after_second = + fs::read_to_string(claude_dir.join("settings.json")).expect("read re-merged settings"); + assert_eq!( + after_first, after_second, + "two consecutive installs should merge to byte-identical output" + ); + + let _ = fs::remove_dir_all(&repo); + } + + #[test] + fn install_merges_into_existing_opencode_config_json_and_stays_idempotent() { + let repo = init_git_repo("install-merges-opencode-config"); + let opencode_dir = default_paths::InstallTargetPaths::new(&repo).opencode_target_dir(); + + fs::create_dir_all(&opencode_dir).expect("create opencode dir"); + fs::write( + opencode_dir.join("opencode.json"), + serde_json::to_string_pretty(&json!({ + "model": "anthropic/claude", + "mcp": {"my-server": {"command": "my-server"}}, + "plugin": ["./plugins/my-plugin.ts", "./plugins/sce-old-feature.ts"] + })) + .expect("serialize seeded opencode config"), + ) + .expect("seed existing opencode.json"); + + let selection: Vec = every_optional_workflow() + .into_iter() + .map(str::to_string) + .collect(); + + install_embedded_setup_assets(&repo, SetupTarget::OpenCode, &selection) + .expect("first install should succeed"); + + let after_first = fs::read_to_string(opencode_dir.join("opencode.json")) + .expect("read merged opencode config"); + let merged: serde_json::Value = serde_json::from_str(&after_first) + .expect("merged opencode config should be valid JSON"); + + assert_eq!(merged["model"], "anthropic/claude"); + assert_eq!(merged["mcp"]["my-server"]["command"], "my-server"); + + let plugin = merged["plugin"] + .as_array() + .expect("plugin should be an array"); + assert!(plugin.contains(&json!("./plugins/my-plugin.ts"))); + assert!(plugin.contains(&json!("./plugins/sce-bash-policy.ts"))); + assert!(plugin.contains(&json!("./plugins/sce-agent-trace.ts"))); + assert!(!plugin.contains(&json!("./plugins/sce-old-feature.ts"))); + + install_embedded_setup_assets(&repo, SetupTarget::OpenCode, &selection) + .expect("second install should succeed"); + + let after_second = fs::read_to_string(opencode_dir.join("opencode.json")) + .expect("read re-merged opencode config"); + assert_eq!( + after_first, after_second, + "two consecutive installs should merge to byte-identical output" + ); + + let _ = fs::remove_dir_all(&repo); + } + + #[test] + fn reinstall_with_empty_selection_prunes_deselected_workflow_without_touching_sibling_skill() { + let repo = init_git_repo("install-prunes-deselected-workflow"); + let claude_dir = default_paths::InstallTargetPaths::new(&repo).claude_target_dir(); + + let brownfield_selection = vec!["brownfield".to_string()]; + install_embedded_setup_assets(&repo, SetupTarget::Claude, &brownfield_selection) + .expect("initial install with brownfield selected should succeed"); + + let brownfield_command = claude_dir.join("commands/brownfield.md"); + let brownfield_skill_dir = claude_dir.join("skills/sce-brownfield"); + assert!( + brownfield_command.is_file(), + "brownfield command should be installed" + ); + assert!( + brownfield_skill_dir.is_dir(), + "brownfield skill dir should be installed" + ); + + fs::create_dir_all(claude_dir.join("skills/my-skill")).expect("create user skill dir"); + fs::write( + claude_dir.join("skills/my-skill/SKILL.md"), + "sibling user skill\n", + ) + .expect("seed sibling user skill file"); + + install_embedded_setup_assets(&repo, SetupTarget::Claude, &[]) + .expect("reinstall with empty selection should succeed"); + + assert!( + !brownfield_command.exists(), + "deselected workflow command should be pruned" + ); + assert!( + !brownfield_skill_dir.exists(), + "deselected workflow skill dir should be pruned entirely once empty" + ); + assert_eq!( + fs::read_to_string(claude_dir.join("skills/my-skill/SKILL.md")) + .expect("read sibling user skill file"), + "sibling user skill\n" + ); + + let _ = fs::remove_dir_all(&repo); + } + + #[test] + fn reinstall_with_empty_selection_keeps_pruned_skill_dir_holding_a_user_file() { + let repo = init_git_repo("install-prunes-but-keeps-user-file"); + let claude_dir = default_paths::InstallTargetPaths::new(&repo).claude_target_dir(); + + let brownfield_selection = vec!["brownfield".to_string()]; + install_embedded_setup_assets(&repo, SetupTarget::Claude, &brownfield_selection) + .expect("initial install with brownfield selected should succeed"); + + let brownfield_skill_dir = claude_dir.join("skills/sce-brownfield"); + fs::write( + brownfield_skill_dir.join("MY_OVERRIDE.md"), + "user file inside sce skill dir\n", + ) + .expect("seed user file inside sce-owned skill dir"); + + install_embedded_setup_assets(&repo, SetupTarget::Claude, &[]) + .expect("reinstall with empty selection should succeed"); + + assert!( + !brownfield_skill_dir.join("SKILL.md").exists(), + "deselected workflow skill file should be pruned" + ); + assert!( + brownfield_skill_dir.is_dir(), + "sce-owned skill dir should survive because it still holds a user file" + ); + assert_eq!( + fs::read_to_string(brownfield_skill_dir.join("MY_OVERRIDE.md")) + .expect("read user file inside pruned skill dir"), + "user file inside sce skill dir\n" + ); + + let _ = fs::remove_dir_all(&repo); + } + + #[test] + fn install_cleans_up_staging_and_reports_asset_path_on_rename_failure() { + let repo = init_git_repo("install-rename-failure"); + let selection: Vec = every_optional_workflow() + .into_iter() + .map(str::to_string) + .collect(); + + let claude_dir = default_paths::InstallTargetPaths::new(&repo).claude_target_dir(); + let failing_destination = claude_dir.join("commands/next-task.md"); + + let result = install::install_embedded_setup_assets_with_rename( + &repo, + SetupTarget::Claude, + &selection, + |from, to| { + if to == failing_destination { + Err(std::io::Error::other("simulated rename failure")) + } else { + fs::rename(from, to) + } + }, + ); + + let error = result.expect_err("rename failure should surface as an error"); + let message = format!("{error:#}"); + assert!( + message.contains(&failing_destination.display().to_string()), + "error should name the failing asset path: {message}" + ); + assert!( + message.contains("does not create backups"), + "error should include recovery guidance: {message}" + ); + + let commands_staging_dir = claude_dir.join("commands"); + if commands_staging_dir.exists() { + let leftover_staging_files = fs::read_dir(&commands_staging_dir) + .expect("read commands staging dir") + .filter_map(Result::ok) + .any(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with(".sce-setup-staging-") + }); + assert!( + !leftover_staging_files, + "staging artifact for the failed asset should be cleaned up" + ); + } + + let _ = fs::remove_dir_all(&repo); + } } diff --git a/context/architecture.md b/context/architecture.md index e90d231e..57751b1c 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -123,7 +123,7 @@ The repository includes a new placeholder Rust binary crate at `cli/`. - `cli/src/services/local_db/mod.rs` provides the concrete local DB spec and `LocalDb` type alias over the shared generic `TursoDb` adapter. `LocalDbSpec` resolves the deterministic persistent runtime DB target through the shared default-path seam and declares no local migrations; `TursoDb` supplies retry-backed blocking `execute`/`query`, parent-directory creation, Turso connection setup, tokio current-thread runtime bridging, and generic migration execution. - `cli/src/services/auth_db/mod.rs` provides the encrypted auth DB spec and `AuthDb` type alias over `EncryptedTursoDb`. `AuthDbSpec` resolves `/sce/auth.db` through the shared default-path seam and embeds ordered auth migrations. Auth DB lifecycle setup/doctor integration is wired through `AuthDbLifecycle`; auth command/token-storage reads/writes are directed through `token_storage.rs`. - `cli/src/services/agent_trace_db/mod.rs` owns the shared Agent Trace insert payloads, SQL constants, and typed row helpers (diff-trace/intersection/Agent Trace/message/part) plus `ensure_schema_ready_for_hooks()` consumed by the repository adapter. `cli/src/services/agent_trace_db/repository.rs` defines the sole `RepositoryAgentTraceDb` adapter over `TursoDb` with one fresh `agent-trace-repository/001_repository_schema.sql` baseline for `diff_traces` (including `payload_type`), `post_commit_patch_intersections`, `agent_traces`, `messages`, `parts`, indexes, and triggers, `repository_metadata` validation, no trace-table `checkout_id` columns, `agent_traces.agent_trace_id NOT NULL UNIQUE`, and `recent_diff_trace_patches(cutoff_time_ms, end_time_ms)` using the inclusive chronological parser without checkout filtering. Active hook runtime, setup/lifecycle storage, and trace status/list/shell flows resolve through `agent_trace_storage` and use `RepositoryAgentTraceDb`. The checkout-scoped `AgentTraceDb`/`AgentTraceDbSpec` adapter, its `agent_trace_db_path()`/`agent_trace_db_path_for_checkout()` helpers, the 15-file `cli/migrations/agent-trace/` chain, and the `sce trace --legacy` surface were removed by the `retire-legacy-agent-trace-db` plan. -- `cli/src/services/setup/mod.rs` defines the setup command contract (`SetupMode`, `SetupTarget`, `SetupRequest`, CLI flag parser/validator), an `inquire`-backed interactive target prompter (`InquireSetupTargetPrompter`), setup dispatch outcomes (proceed/cancelled), additive durable-context bootstrap (`bootstrap_context_baseline` for standalone `--bootstrap-context` and every normal successful setup path), and compile-time embedded asset access (`EmbeddedAsset`, target-scoped iterators, required-hook asset iterators/lookups). It also owns the install-time optional-workflow seam: the `OptionalWorkflow` type plus the build-generated `OPTIONAL_WORKFLOWS` catalog, a per-target `WorkflowAssetLayout` built from the existing `default_paths` command/skill directory constants (`command`/`commands`/`prompts` plus `skills`), and `iter_embedded_assets_for_setup_target_with_selection(target, selection)`, which yields every embedded asset except the `{command_dir}/{command_slug}.md` file and `{skills_dir}/{skill_slug}/` subtree of each optional workflow the selection omits. Membership is derived from the catalog's slugs rather than an enumerated file list, so a new optional workflow needs no Rust change. This filtered iterator is the only way embedded assets are enumerated; setup installs through it and doctor inspects through it, so there is no unfiltered enumeration path that could reintroduce an unselected workflow. The non-interactive selection flows through the repeatable `--workflow ` flag into `SetupRequest.optional_workflows: Option>` (`None` meaning the flag was absent); `validate_optional_workflow_slugs` checks each slug against `OPTIONAL_WORKFLOWS` during request resolution, before any write, and `run_setup_for_mode` resolves `None` to the persisted `integrations.optional_workflows` (exported as `persisted_optional_workflows`) before installing through the filtered iterator and persisting the resolved selection. The interactive selection flows through the same seam: `SetupTargetPrompter` carries `prompt_target` plus `prompt_optional_workflows(defaults)` (returning `None` for a cancelled prompt), `SetupDispatch::Proceed { mode, optional_workflows }` carries a prompted selection alongside the resolved mode, and `resolve_setup_dispatch(mode, prompter, defaults)` runs the workflow prompt only after an interactive target prompt, mapping either cancellation to `SetupDispatch::Cancelled`. The prompt module builds its `inquire::MultiSelect` from `optional_workflow_prompt_inputs(catalog, defaults)`, which returns `None` for an empty catalog (skipping the prompt) and otherwise catalog-ordered rows plus the indices to pre-check, ignoring ids absent from the catalog. `setup/command.rs` therefore resolves the repository root before dispatch, so a non-git directory fails before any prompt. For repository builds, `cli/build.rs` validates the `SCE_CLI_GENERATED_INPUT_DIR` payload and canonical-input inventories, copies the payload into Cargo `OUT_DIR/pkl-generated`, stages `cli/assets/hooks/**` under `OUT_DIR/static`, requires the staged `config/optional-workflows.json`, and generates both the setup manifest and the optional-workflow catalog (`optional_workflows.rs`, rejecting a manifest whose `schemaVersion` is not 1 or whose entries lack a non-empty `id`/`title`/`description`/`commandSlug`/`skillSlug`) in `OUT_DIR`; focused internal seams separate install-flow from prompt-flow logic; `cli/src/services/setup/command.rs` owns the `SetupCommand` payload used by the static `RuntimeCommand` enum and executes against any context implementing repo-root scoping. Its install engine/orchestrator stages embedded files and uses a unified remove-and-replace policy (removing existing targets before swapping staged content, with deterministic recovery guidance on swap failure and no backup artifact creation), and formats deterministic completion messaging; required-hook install orchestration (`install_required_git_hooks`) follows the same remove-and-replace policy (removing existing hooks before swapping staged content, with deterministic recovery guidance on swap failure). After the Git gate, setup always ensures the context baseline; context-only requests return there, while normal modes derive a repo-root-scoped context before aggregating static lifecycle provider `setup` dispatch across providers (config → local_db → auth_db → agent_trace_db → hooks when requested), so setup providers consume only repo-root access from the scoped context. +- `cli/src/services/setup/mod.rs` defines the setup command contract (`SetupMode`, `SetupTarget`, `SetupRequest`, CLI flag parser/validator), an `inquire`-backed interactive target prompter (`InquireSetupTargetPrompter`), setup dispatch outcomes (proceed/cancelled), additive durable-context bootstrap (`bootstrap_context_baseline` for standalone `--bootstrap-context` and every normal successful setup path), and compile-time embedded asset access (`EmbeddedAsset`, target-scoped iterators, required-hook asset iterators/lookups). It also owns the install-time optional-workflow seam: the `OptionalWorkflow` type plus the build-generated `OPTIONAL_WORKFLOWS` catalog, a per-target `WorkflowAssetLayout` built from the existing `default_paths` command/skill directory constants (`command`/`commands`/`prompts` plus `skills`), and `iter_embedded_assets_for_setup_target_with_selection(target, selection)`, which yields every embedded asset except the `{command_dir}/{command_slug}.md` file and `{skills_dir}/{skill_slug}/` subtree of each optional workflow the selection omits. Membership is derived from the catalog's slugs rather than an enumerated file list, so a new optional workflow needs no Rust change. This filtered iterator is the only way embedded assets are enumerated; setup installs through it and doctor inspects through it, so there is no unfiltered enumeration path that could reintroduce an unselected workflow. The non-interactive selection flows through the repeatable `--workflow ` flag into `SetupRequest.optional_workflows: Option>` (`None` meaning the flag was absent); `validate_optional_workflow_slugs` checks each slug against `OPTIONAL_WORKFLOWS` during request resolution, before any write, and `run_setup_for_mode` resolves `None` to the persisted `integrations.optional_workflows` (exported as `persisted_optional_workflows`) before installing through the filtered iterator and persisting the resolved selection. The interactive selection flows through the same seam: `SetupTargetPrompter` carries `prompt_target` plus `prompt_optional_workflows(defaults)` (returning `None` for a cancelled prompt), `SetupDispatch::Proceed { mode, optional_workflows }` carries a prompted selection alongside the resolved mode, and `resolve_setup_dispatch(mode, prompter, defaults)` runs the workflow prompt only after an interactive target prompt, mapping either cancellation to `SetupDispatch::Cancelled`. The prompt module builds its `inquire::MultiSelect` from `optional_workflow_prompt_inputs(catalog, defaults)`, which returns `None` for an empty catalog (skipping the prompt) and otherwise catalog-ordered rows plus the indices to pre-check, ignoring ids absent from the catalog. `setup/command.rs` therefore resolves the repository root before dispatch, so a non-git directory fails before any prompt. For repository builds, `cli/build.rs` validates the `SCE_CLI_GENERATED_INPUT_DIR` payload and canonical-input inventories, copies the payload into Cargo `OUT_DIR/pkl-generated`, stages `cli/assets/hooks/**` under `OUT_DIR/static`, requires the staged `config/optional-workflows.json`, and generates both the setup manifest and the optional-workflow catalog (`optional_workflows.rs`, rejecting a manifest whose `schemaVersion` is not 1 or whose entries lack a non-empty `id`/`title`/`description`/`commandSlug`/`skillSlug`) in `OUT_DIR`; focused internal seams separate install-flow from prompt-flow logic; `cli/src/services/setup/command.rs` owns the `SetupCommand` payload used by the static `RuntimeCommand` enum and executes against any context implementing repo-root scoping. Its install engine/orchestrator (`install_assets_for_concrete_target_with_rename`) installs each embedded asset individually: it stages the asset's content next to its final destination, removes only that exact destination file if one already exists, and swaps the staged content into place, with deterministic recovery guidance naming the failing asset's path on swap failure and no backup artifact creation; it never removes an integration target directory as a whole, so files a repository owns inside `.opencode`/`.claude`/`.pi` — including nested inside an SCE-owned subdirectory such as `skills/` or `commands/` — survive a setup run. For the two assets that are merge targets — the Claude target's `settings.json`, detected by `is_claude_settings_merge_target`, and the OpenCode target's `opencode.json`, detected by `is_opencode_config_merge_target` — the content staged is not the embedded asset's bytes but the result of `cli/src/services/setup/config_merge.rs::merge_or_create_claude_settings`/`merge_or_create_opencode_config(existing_bytes, generated_bytes, source_path)`: each returns the generated document verbatim when no file exists yet, otherwise parses the existing file as JSON (a parse failure is a hard error naming `source_path`, and nothing is written) and merges it with a pure per-shape function that copies `$schema` from the generated document and preserves every other key from the existing file untouched. `merge_claude_settings` replaces, per hook event key the generated document declares, only the entries whose command contains the ownership marker `run-sce-or-show-install-guidance.sh`, preserving every event key and non-SCE hook entry from the existing file. `merge_opencode_config` merges the `plugin` array as a set: existing entries whose path starts with the ownership marker `./plugins/sce-` are dropped structurally — so a stale plugin path an older or renamed catalog once installed is still recognized and pruned even after the current generated document stops declaring it — and the generated document's `plugin` entries are appended after the surviving entries. After that per-asset install loop, `prune_stale_assets_for_concrete_target` deletes every path the full embedded catalog for the concrete target claims but the resolved selection did not install (a deselected optional workflow, or an asset a newer catalog renamed or dropped), and `remove_empty_ancestor_directories` removes any parent directory left empty by that deletion, stopping at the target root or at a directory that still holds something such as a user file. It formats deterministic completion messaging; required-hook install orchestration (`install_required_git_hooks`) follows the same per-file stage/swap choreography (removing an existing hook file before swapping staged content, with deterministic recovery guidance on swap failure). After the Git gate, setup always ensures the context baseline; context-only requests return there, while normal modes derive a repo-root-scoped context before aggregating static lifecycle provider `setup` dispatch across providers (config → local_db → auth_db → agent_trace_db → hooks when requested), so setup providers consume only repo-root access from the scoped context. - `cli/src/services/setup/mod.rs` keeps those responsibilities inside one file for now, but the current ownership split is explicit: the inline `install` module owns repository-path normalization, staging/swap install behavior, required-hook installation, and filesystem safety guards, while the inline `prompt` module owns interactive target selection and prompt styling. - `cli/src/services/security.rs` provides shared security utilities for deterministic secret redaction (`redact_sensitive_text`) and directory write-permission probes (`ensure_directory_is_writable`) used by app/setup/observability surfaces. - `cli/src/services/doctor/mod.rs` owns the current doctor request/report surface while focused submodules (`doctor/inspect.rs`, `doctor/render.rs`, `doctor/fixes.rs`, `doctor/types.rs`) split report fact collection, rendering, manual fix reporting, and doctor-owned domain types into smaller seams; `cli/src/services/doctor/command.rs` owns the `DoctorCommand` payload used by the static `RuntimeCommand` enum and executes against any context implementing repo-root scoping. Runtime doctor execution resolves a repository root, derives a scoped context, requests the shared static lifecycle provider catalog with hooks included for service-owned `diagnose` and `fix` behavior, adapts lifecycle-owned health/fix records into doctor-owned problem/fix records, and then renders stable text/JSON problem records with category/severity/fixability/remediation fields plus deterministic fix-result reporting in fix mode. Checkout DB discovery no longer lives in `doctor`; it moved to the `trace` group (`sce trace db list`) in `cli/src/services/trace/`. Report fact collection preserves environment/repository/hook/integration display data and checkout identity facts, while service-owned lifecycle providers own config validation, local DB and repository-scoped Agent Trace DB readiness/bootstrap, and hook rollout diagnosis/repair. Integration inspection in `doctor/inspect.rs` is scoped twice over: `resolve_doctor_integration_targets` picks which targets to inspect, and `persisted_optional_workflows` (reused from setup) resolves which optional workflows the repository selected, which the OpenCode/Claude/Pi child collectors apply through `iter_embedded_assets_for_setup_target_with_selection`. An unselected optional workflow therefore contributes no expected children at all, so no row and no missing/mismatch problem can be produced for it, while a selected one keeps the unchanged presence and content-hash checks. diff --git a/context/cli/cli-command-surface.md b/context/cli/cli-command-surface.md index df7970e9..4cb9ee51 100644 --- a/context/cli/cli-command-surface.md +++ b/context/cli/cli-command-surface.md @@ -62,7 +62,7 @@ An interactive `setup` run instead resolves the selection through an `inquire` m `auth` now emits auth-local guidance for bare `sce auth` and `sce auth --help`, listing `login`, `logout`, and `status` plus copy-ready next steps. `setup`, `doctor`, `hooks`, `version`, and `completion` all support command-local `--help`/`-h` usage output via top-level parser routing in `cli/src/app.rs`. `setup` now also exposes compile-time embedded config assets for OpenCode/Claude/Pi targets, sourced from the generated `config/.opencode/**`, `config/.claude/**`, and `config/.pi/**` trees via `cli/build.rs` with normalized forward-slash relative paths and target-scoped iteration APIs; the embedded asset set includes the OpenCode bash-policy plugin wrapper plus Claude settings `PreToolUse` Bash policy hook, both delegating to the Rust `sce policy bash` path. -`setup` additionally includes a repository-root install engine (`install_embedded_setup_assets`) that stages embedded files and uses a unified remove-and-replace policy for `.opencode/`/`.claude/`/`.pi/` (removing existing targets before swapping staged content, with deterministic recovery guidance on swap failure) while treating bash-policy enforcement files as first-class SCE-managed assets. +`setup` additionally includes a repository-root install engine (`install_embedded_setup_assets`) that installs each embedded asset individually into `.opencode/`/`.claude/`/`.pi/` — stage next to the final destination, remove only that destination file if present, swap into place, with deterministic recovery guidance naming the failing asset's path on swap failure — never removing an integration target directory as a whole, while treating bash-policy enforcement files as first-class SCE-managed assets. See [setup non-destructive per-asset install policy](../sce/setup-no-backup-policy-seam.md) for the full contract, including the pending pruning gap for deselected/stale assets. `setup` now executes end-to-end and prints deterministic completion details including selected target(s) and per-target install count. `doctor` now executes end-to-end with explicit diagnosis and repair-intent surfaces: `sce doctor` stays read-only and `sce doctor --fix` selects repair-intent mode. Agent Trace DB checkout discovery has moved out of `doctor`; `sce trace db list`, `sce trace status`, and `sce trace status --all` operate only on repository-scoped DBs (the checkout-scoped `--legacy` surface was removed by the `retire-legacy-agent-trace-db` plan); see [trace-command.md](trace-command.md). The current `doctor` runtime aggregates `ServiceLifecycle::diagnose` and `ServiceLifecycle::fix` calls across all registered service providers (`config`, `local_db`, `auth_db`, `agent_trace_db`, `hooks`) plus integration checks, covering state-root resolution, global and repo-local `sce/config.json` readability/schema validation, local DB and repository-scoped Agent Trace DB path/health, DB-parent readiness barriers, the repo hook rollout slice when a repository target is detected, and repo-root installed OpenCode, Claude, and Pi integration presence/content health for their embedded setup assets. Fix mode delegates to each provider's `fix` implementation, which reuses the canonical setup hook install flow to repair missing/stale/non-executable required hooks and missing hooks directories, and it can bootstrap missing canonical database parent directories when the resolved paths match canonical owned locations. A user-invocable `sync` command is not wired in the current CLI surface; local DB and Agent Trace DB bootstrap currently happen through `setup`, and DB health/repair currently happens through `doctor`. Command wiring for `sce sync` is deferred to `0.4.0`. diff --git a/context/context-map.md b/context/context-map.md index 2a1d1f1c..8ceb38db 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -50,7 +50,7 @@ Feature/domain context: - `context/sce/agent-trace-hook-doctor.md` (approved operator-environment contract for broadening `sce doctor` into the canonical health-and-repair entrypoint, including stable problem taxonomy, `--fix` semantics, checkout-aware Agent Trace DB reporting, setup-to-doctor alignment rules, canonical Git-hook payload restoration, and the approved downstream human text-mode layout/status/integration contract) - `context/sce/doctor-human-text-contract.md` (implemented `sce doctor` human text layout contract: section order, `[PASS]`/`[FAIL]`/`[MISS]` status vocabulary, simplified hook rows, target-scoped integration checks with configured/detected/empty target resolution, selection-scoped optional-workflow inventory read from `integrations.optional_workflows`, no-installed-integrations guidance, and OpenCode, Claude, plus Pi integration group rendering rules including the `Pi extensions` group) - `context/sce/setup-githooks-install-contract.md` (canonical `sce setup --hooks` install contract for target-path resolution, all-hook non-blocking missing-CLI bootstrap behavior, idempotent outcomes, remove-and-replace behavior, and doctor-readiness alignment) -- `context/sce/setup-no-backup-policy-seam.md` (implemented unified remove-and-replace install policy for both config-install and required-hook install flows, with no backup creation and deterministic recovery guidance on swap failure) +- `context/sce/setup-no-backup-policy-seam.md` (non-destructive per-asset install policy: config install writes/swaps each embedded asset individually and never removes an integration target directory as a whole, then prunes catalog-derived stale/deselected asset paths and any parent directory left empty by that pruning; required-hook install keeps the prior per-file remove-and-replace choreography; `.claude/settings.json` and `.opencode/opencode.json` are merge targets whose staged content is computed by JSON-merging the generated document into the user's existing one before the shared stage/swap step; no backup creation; deterministic recovery guidance naming the failing asset on swap failure) - `context/sce/setup-githooks-hook-asset-packaging.md` (compile-time `sce setup --hooks` required-hook template packaging contract, including all-hook non-blocking missing-`sce` install guidance, available-CLI argument forwarding, post-commit-only origin remote lookup plus remote-URL forwarding/fallback behavior, setup-service accessor surface, and current validation posture) - `context/sce/setup-githooks-install-flow.md` (setup-service required-hook install orchestration with git-truth hooks-path resolution, canonical missing-CLI payload installation, per-hook installed/updated/skipped outcomes, and remove-and-replace behavior with recovery guidance) - `context/sce/setup-githooks-cli-ux.md` (T04 composable `sce setup` target+`--hooks` / `--repo` command-surface contract, option compatibility validation, and deterministic setup/hook output semantics) diff --git a/context/glossary.md b/context/glossary.md index 099d5092..d6a960c4 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -155,8 +155,10 @@ - `sce policy command adapter`: Hidden/internal `sce policy bash` command in `cli/src/services/bash_policy.rs` that exposes the Rust bash-policy evaluator to hook callers. It reads JSON from STDIN, resolves bash-policy config from the project root (git root with current-directory fallback), evaluates the command against active policies, and emits hook-safe output: Claude Code deny JSON (`hookSpecificOutput` with `permissionDecision: "deny"`) or empty string for allowed commands in `--output claude-hook` mode (default), and structured `{"status","decision","command","normalized_argv","reason","policy_id"}` JSON in `--output json` mode. Input modes are `--input claude-pre-tool-use` (default, parses Claude `PreToolUse` event JSON with `tool_name`/`tool_input.command`) and `--input normalized` (parses `{"command":...}` for OpenCode delegation). The command uses explicit `--input`/`--output` flags rather than auto-detection; Claude Code hooks invoke `sce policy bash` with defaults, while OpenCode plugin delegation passes `--input normalized --output json`. Invalid invocation/input returns deterministic validation diagnostics without executing target commands. - `bash policy redundancy warning`: Non-fatal config validation output emitted when `forbid-git-all` and `forbid-git-commit` are enabled together; the config remains valid, but `sce config show|validate` reports the overlap deterministically as a warning instead of an error. - `auth config baked default`: Optional key-declared fallback in `cli/src/services/config/mod.rs` (with schema/parsing in `schema.rs`) used only after env and config-file inputs are absent; the first implemented case is `workos_client_id`, which currently falls back to `client_sce_default`. -- `setup install engine`: Installer in `cli/src/services/setup/mod.rs` (`install_embedded_setup_assets`) that writes embedded setup assets into per-target staging directories and swaps them into repository-root `.opencode/`/`.claude/` destinations, using a unified remove-and-replace policy that removes existing targets before swapping staged content. -- `setup remove-and-replace`: Replacement choreography in `cli/src/services/setup/mod.rs` where existing install targets are removed before staged content is promoted; on swap failure, the engine cleans temporary staging paths and returns deterministic recovery guidance (recover from version control). No backup artifacts are created. +- `setup install engine`: Installer in `cli/src/services/setup/mod.rs` (`install_embedded_setup_assets`) that writes each embedded setup asset into its own staged file next to its final destination under repository-root `.opencode/`/`.claude/`/`.pi/`, removing only that exact destination file if present, then swapping the staged content into place. It never removes an integration target directory as a whole, so files a repository owns inside an SCE-managed target directory survive a setup run untouched. After the per-asset install loop, it prunes stale SCE-owned paths (see `setup catalog-derived pruning`). Two assets, the Claude target's `settings.json` and the OpenCode target's `opencode.json`, stage merged content instead of the embedded asset's bytes verbatim (see `setup config-merge seam`). +- `setup catalog-derived pruning`: `prune_stale_assets_for_concrete_target` in `cli/src/services/setup/mod.rs`, run after per-asset install, deletes every path the full embedded catalog for a concrete target claims but the resolved selection did not install (a deselected optional workflow, or an asset a newer catalog renamed or dropped), then `remove_empty_ancestor_directories` removes any parent directory left empty by that deletion, walking upward until it reaches the target root or a directory that still holds something (a user file inside an SCE-owned skill directory keeps that directory from being removed). Pruning is stateless and derives only from the compiled-in catalog; no install manifest is persisted, so an orphaned file installed by an older `sce` binary under a name the current catalog no longer knows is left alone. +- `setup config-merge seam`: Pure module `cli/src/services/setup/config_merge.rs`, covering `.claude/settings.json` and `.opencode/opencode.json`. Both `merge_or_create_claude_settings` and `merge_or_create_opencode_config` share the shape `(existing_bytes: Option<&[u8]>, generated_bytes: &[u8], source_path: &str) -> Result>`: each returns `generated_bytes` verbatim when no existing file is present; otherwise each parses both as JSON (an existing-file parse failure is a hard error naming `source_path`, with no write) and delegates to a pure merge function that copies the SCE-owned `$schema` key from the generated document and preserves every other top-level key from the existing document untouched. `merge_claude_settings` additionally replaces, per hook event key the generated `hooks` object declares, only the entries whose `hooks[].command` contains the SCE ownership marker `run-sce-or-show-install-guidance.sh`, appended after the surviving non-SCE entries; event keys the generated document does not declare are left untouched. `merge_opencode_config` additionally merges the `plugin` array as a set: existing entries whose path starts with the SCE ownership marker `./plugins/sce-` are dropped structurally (so a stale plugin path an older or renamed catalog installed is recognized and pruned even after the current generated document stops declaring it), then the generated document's `plugin` entries are appended. `install_single_asset_with_rename` (`mod install` in `cli/src/services/setup/mod.rs`) detects each merge target via `is_claude_settings_merge_target` / `is_opencode_config_merge_target` and stages the merged bytes instead of the embedded asset's bytes before the shared stage/swap step. The same module also exposes `claude_settings_fragment_is_current` / `opencode_config_fragment_is_current`, which merge `existing_bytes` into `generated_bytes` and compare the merged `Value` against the existing one: a no-op merge means the existing file already carries a current copy of the SCE-owned fragment, whatever else it holds. `cli/src/services/doctor/inspect.rs` uses these two functions (instead of byte-exact `sha256`) to inspect `.claude/settings.json` and `.opencode/opencode.json`, and `crate::services::setup::repair_merge_target_asset` lets `sce doctor --fix` repair a drifted merge target by reinstalling just that one asset through the same merge-install path. +- `setup remove-and-replace`: Per-file replacement choreography in `cli/src/services/setup/mod.rs` where an existing destination file is removed before staged content is swapped into its place; on swap failure, the engine cleans that file's staging artifact and returns deterministic recovery guidance naming its path (recover from version control). No backup artifacts are created. Config install (`install_embedded_setup_assets`) applies this at individual-asset granularity and never removes an integration target directory as a whole; required-hook install applies it per hook file. - `hooks command routing contract`: Current hook command parser/dispatcher plus runtime wiring in `cli/src/services/hooks/mod.rs` (`HookSubcommand`, `run_hooks_subcommand`) supports `pre-commit`, `commit-msg `, `post-commit`, `post-rewrite `, `diff-trace`, and `conversation-trace` with deterministic invocation validation/usage errors; `session-model` is no longer supported. `commit-msg` is the only active attribution path behind the attribution hooks gate and staged-diff AI-overlap preflight; `pre-commit`/`post-rewrite` are no-ops; `post-commit` persists intersections and built Agent Trace payloads; `diff-trace` persists DB-only AgentTraceDb rows using tool-prefixed `session_id` values plus direct `model_id` and `tool_version` values (session-model fallback was removed in the `remove-session-models-direct-claude-model-id` plan). - `Claude raw hook capture (removed)`: Former hidden/internal `sce hooks claude-capture ` intake path removed in T05 of the `claude-typescript-model-cache-remove-rust-capture` plan. Rust now exposes `diff-trace` and `conversation-trace` intakes for active Claude/OpenCode editor runtimes; `session-model` is also removed from the supported hook command surface. The removed route previously wrote pretty-printed JSON artifacts under `context/tmp/claude/` without AgentTraceDb writes. See `context/sce/claude-raw-hook-capture.md`. - `deferred sync command`: `sce sync` has no command wiring and no `cli/src/services/sync.rs` module in the current runtime. Local DB initialization and health ownership are split between setup and doctor instead. diff --git a/context/overview.md b/context/overview.md index 08ee89b6..b71060b2 100644 --- a/context/overview.md +++ b/context/overview.md @@ -25,7 +25,7 @@ Agent Trace lifecycle setup now resolves repository storage, creates/reuses chec The CLI now also includes a shared text styling service in `cli/src/services/style.rs` that provides deterministic color enablement via `owo-colors`, automatic TTY detection, and `NO_COLOR` compliance for human-facing text output; stdout help/text surfaces, stderr diagnostics, and interactive prompt-adjacent text now reuse that shared styling policy while JSON, completion, and other non-interactive/machine-readable flows remain unstyled. The service exports color-detection, conditional styling, help/diagnostic/label/prompt styling, and `banner_with_gradient()` helpers for use across command surfaces while preserving pipe-safe output for non-interactive environments. The `setup` command includes an `inquire`-backed target-selection flow: default interactive selection for OpenCode/Claude/Pi/All with required-hook installation in the same run, explicit non-interactive target flags (`--opencode`, `--claude`, `--pi`, `--all`), standalone `--bootstrap-context` for additive durable-context baseline creation without integration installs, deterministic mutually-exclusive validation, and non-destructive cancellation exits; the former `--both` flag was removed in favor of `--all` (opencode+claude+pi). Every normal successful setup path also ensures the same context baseline after the Git gate. Workflows the catalog marks optional are installed only when a repository opts in: the repeatable `sce setup --workflow ` flag names the selection for a run, an omitted flag reuses the selection persisted in `integrations.optional_workflows`, and the resolved selection filters the installed target assets and is written back to repo-local config. Interactive runs ask for the selection instead: a multi-select prompt follows target selection with every row unchecked on a first run and pre-checked from the persisted selection afterwards, cancelling it exits non-destructively like the target prompt, and the prompt is skipped when the catalog marks no workflow optional. `brownfield` is currently the only optional workflow, so a default run installs the five core workflows and no brownfield assets. `sce doctor` scopes its integration checks to that same recorded selection, so it never reports an unselected optional workflow's files as missing. For repository generation consumers, `config/pkl/generator-inputs.txt` declares the canonical Pkl/plugin input set and `scripts/produce-cli-generated-input.sh` owns its discovery, two-pass `config/pkl/generate.pkl` evaluation, determinism comparison, payload/input inventories, in-flight input-mutation rejection, atomic handoff publication, and staging cleanup. `scripts/run-cli-cargo.sh` creates a fresh temporary destination, delegates generation to that producer, invokes the requested Cargo workflow with `SCE_CLI_GENERATED_INPUT_DIR`, and removes the handoff after Cargo success, failure, or handled signals. `config/pkl/check-generated.sh` delegates the same production mechanics while retaining contract and path assertions. `scripts/prepare-cli-generated-assets.sh` moves the producer-validated Pkl payload and checksums into the unchanged package fallback, adds hooks, migrations, and the Agent Trace schema, and appends only those static checksums to the combined inventory. The root flake's pre-Cargo `cliGeneratedInput` derivation invokes the same producer from a declarative source containing the producer plus its declared inputs. `cli/build.rs` rejects missing, incomplete, modified, or stale repository handoffs, copies the validated payload into Cargo `OUT_DIR/pkl-generated`, stages static inputs under `OUT_DIR/static`, and writes setup-asset, optional-workflow-catalog, and migration Rust manifests into `OUT_DIR`; it never invokes Pkl. Published crates carry the ignored packaging-only fallback, and unpacked downstream builds validate and copy it into their own `OUT_DIR` without requiring Pkl or parent repository paths. -The setup service also provides repository-root install orchestration: it resolves the repository root, ensures the additive durable-context baseline, then for normal modes derives a repo-root-scoped `AppContext` from the runtime command context, aggregates `ServiceLifecycle::setup` calls across lifecycle providers (config → local_db → auth_db → agent_trace_db → hooks when requested), handles interactive or flag-based target selection for config asset installation, and reports deterministic completion details (selected target(s) and installed file counts). Setup uses a unified remove-and-replace policy for all write flows — it removes existing targets before swapping staged content and returns deterministic recovery guidance (recover from version control) on swap failure, without creating backup artifacts. The setup command gates all modes on an existing git repository before any writes. Internally, `cli/src/services/setup/mod.rs` now separates install-flow logic from interactive prompt logic through focused support seams. +The setup service also provides repository-root install orchestration: it resolves the repository root, ensures the additive durable-context baseline, then for normal modes derives a repo-root-scoped `AppContext` from the runtime command context, aggregates `ServiceLifecycle::setup` calls across lifecycle providers (config → local_db → auth_db → agent_trace_db → hooks when requested), handles interactive or flag-based target selection for config asset installation, and reports deterministic completion details (selected target(s) and installed file counts). Setup installs config assets (`.opencode`/`.claude`/`.pi`) per file: each embedded asset is staged and swapped into its own destination path, creating parent directories as needed, without removing or recreating the target directory as a whole, so files a repository owns inside an SCE-managed target directory survive a setup run untouched. Two assets are merge targets rather than verbatim writes: Claude's `.claude/settings.json` and OpenCode's `.opencode/opencode.json`. For each, setup JSON-merges the generated document into the user's existing file rather than overwriting it, and fails deterministically without writing if the existing file is not valid JSON; a missing file is still created from the generated document verbatim. Claude's merge replaces only SCE-owned hook entries (identified by a command containing `run-sce-or-show-install-guidance.sh`) and the `$schema` key while preserving every other key and hook entry untouched. OpenCode's merge replaces the `$schema` key and merges the `plugin` array as a set: any entry shaped like an SCE plugin path (`./plugins/sce-*`) is dropped, structurally, so a path an older or renamed catalog once installed is still recognized and pruned, and the generated document's canonical plugin entries are appended after the surviving user entries. Required-hook install keeps the prior remove-and-replace choreography at file granularity — it removes an existing hook file before swapping staged content. Both flows return deterministic recovery guidance (recover from version control) on swap failure, without creating backup artifacts. After installing, config install prunes stale SCE-owned assets: it deletes every path the full embedded catalog for the target claims but the current selection did not install (a deselected optional workflow, or an asset a newer catalog renamed or dropped), then removes any parent directory left empty by that deletion, leaving a directory intact if a user file still lives inside it. The setup command gates all modes on an existing git repository before any writes. Internally, `cli/src/services/setup/mod.rs` now separates install-flow logic from interactive prompt logic through focused support seams. The CLI now also applies baseline security hardening for reliability-driven automation: diagnostics/logging paths use deterministic secret redaction, `sce setup --hooks --repo ` canonicalizes and validates repository paths before execution, and setup write flows run explicit directory write-permission probes before staging/swap operations. The config service now provides deterministic runtime config resolution with explicit precedence (`flags > env > config file > defaults`), strict config-file validation (`$schema`, `log_level`, `log_format`, `log_dir`, `timeout_ms`, `workos_client_id`, and nested `policies.bash`, `policies.attribution_hooks.enabled`, plus `policies.database_retry` with per-DB `connection_open`/`query` retry policy specs), deterministic default discovery/merge of global+local config files (`${config_root}/sce/config.json` then `.sce/config.json` with local override, where `config_root` comes from the shared default-path seam with XDG/`dirs::config_dir()` config-root resolution), defaults for the resolved observability value set (`log_level=error`, `log_format=text`, `log_dir=/sce/logs`), shared auth-key resolution with optional baked defaults starting at `workos_client_id`, first-class bash-policy preset/custom parsing with deterministic conflict and duplicate-prefix validation, custom-policy `satisfied_by` wrapper exemption (a policy does not fire when the matched command was unwrapped from a declared wrapper such as `nix shell nixpkgs#ripgrep`), and a canonical Pkl-authored `sce/config.json` JSON Schema generated beneath Cargo `OUT_DIR` and embedded by `cli/src/services/config/mod.rs` for both `sce config validate` and doctor-time config checks. Runtime startup config loading now keeps parity with that schema by accepting the canonical `"$schema": "https://sce.crocoder.dev/config.json"` declaration in repo-local and global config files, so startup commands such as `sce version` no longer fail before dispatch on that field. App-runtime observability now consumes flat logging keys through the shared resolver, so env values still override config-file values while config files provide deterministic fallback for `log_dir`; positive-integer `log_file_retention_limit` uses config-file/default precedence, defaults to `10`, and controls creation-triggered cleanup for primary and v2 log files; `sce config show` reports resolved observability/auth/policy values with provenance, while `sce config validate` is now a trimmed validation surface that reports only pass/fail plus validation errors or warnings in text and JSON modes. The canonical preset catalog and matching contract live in `config/pkl/base/bash-policy-presets.pkl` and `context/sce/bash-tool-policy-enforcement-contract.md`. Invalid default-discovered config files now also degrade gracefully at startup: `sce` keeps running with degraded observability defaults, logs `sce.config.invalid_config` warnings, and reserves hard failures for explicit `--config` / `SCE_CONFIG_FILE` targets or other truly invalid runtime observability inputs. diff --git a/context/patterns.md b/context/patterns.md index a5bda2c9..bdf860f9 100644 --- a/context/patterns.md +++ b/context/patterns.md @@ -51,7 +51,7 @@ - Derive accepted ids, error text, and asset membership from the catalog rather than enumerating workflows in code, so marking another workflow optional in Pkl needs no new branch. - Reject an unknown id during request resolution, before any file or config write. - Let health checks derive their expectations from the persisted selection through the same filter installation uses, so what a run installs and what a later check requires cannot drift apart. -- Treat an unrecorded selection as "nothing selected" for inspection, and do not report a deselected workflow's leftover files as stray; remove-and-replace installs already clear them. +- Treat an unrecorded selection as "nothing selected" for inspection, and do not report a deselected workflow's leftover files as stray; catalog-derived pruning after per-asset install already clears them. ## Dev-shell fallback shims for unavailable nixpkgs tools @@ -136,7 +136,8 @@ - Treat setup prompt cancellation/interrupt as a non-destructive exit path with explicit user messaging (no file mutations and no partial side effects). - For repository setup-asset build prep, declare canonical generator inputs in `config/pkl/generator-inputs.txt` and route input discovery, two-pass Pkl evaluation, determinism comparison, payload/input inventory creation, in-flight input checks, atomic publication, and private staging cleanup through `scripts/produce-cli-generated-input.sh`. The Cargo wrapper, generated-output check, package-fallback helper, and Nix `cliGeneratedInput` derivation must consume that producer rather than implement those mechanics independently. Keep each consumer's domain checks separate: the generated-output check owns metadata/contract/negative/path assertions; packaging owns static hook/schema/migration staging and the combined Pkl-plus-static checksum inventory; Nix owns declarative producer/input source selection and pre-Cargo handoff wiring. Route build, run, targeted-test, Clippy, and local-install Cargo workflows through `scripts/run-cli-cargo.sh`, which passes the producer handoff through `SCE_CLI_GENERATED_INPUT_DIR` and owns cleanup around Cargo. Keep `cli/build.rs` free of Pkl subprocesses and source-tree generated mirrors. - For CLI database migration prep, keep SQL files under immediate `cli/migrations//` directories named `NNN_description.sql`; `cli/build.rs` stages those files under `OUT_DIR/static/migrations`, sorts by the numeric prefix before `_`, and writes deterministic `OUT_DIR/generated_migrations.rs` constants with `include_str!` references for service `DbSpec` consumers. -- For setup install execution, write selected embedded assets into a per-target staging directory first, then remove the existing target and swap staged content into place; on swap failure, clean temporary staging paths and return deterministic recovery guidance (recover from version control). No backup artifacts are created. +- For setup install execution, write each selected embedded asset into its own staging file next to its final destination, remove only that destination file if one already exists, then swap the staged content into place; never remove or recreate the integration target directory as a whole. On swap failure, clean the failing asset's staging path and return deterministic recovery guidance naming that asset's destination (recover from version control). No backup artifacts are created. After the install loop, prune stale SCE-owned paths by diffing the full embedded catalog for the target against the assets actually installed, deleting each catalog path not installed, then removing any parent directory left empty by that deletion (a directory still holding a user file fails to remove and survives). +- For a config asset a user may already own and extend (`.claude/settings.json`, `.opencode/opencode.json`), do not write the embedded asset's bytes verbatim: compute the bytes to stage with a pure `serde_json`-based merge (`cli/src/services/setup/config_merge.rs`) that copies SCE-owned keys/entries from the generated document — identified by a fixed ownership marker, such as a hook command substring for Claude hooks or a plugin path prefix for OpenCode plugins — over the existing file, and preserves every other key and entry untouched. A parse failure on the existing file is a hard, deterministic error naming the file's path with no write; a missing file still gets the generated document verbatim. Keep this pure and filesystem-free per "Unit testing in Nix sandbox" below; the install seam reads the existing file and calls the merge before staging. - For required-hook setup execution, resolve repository root and effective hooks directory from git (`rev-parse --show-toplevel`, `rev-parse --git-path hooks`), then apply deterministic per-hook outcomes (`Installed`, `Updated`, `Skipped`) with staged writes, executable-bit enforcement, and remove-and-replace behavior that removes existing hooks before swapping staged content. - For hook setup CLI UX, allow `--hooks` as both hooks-only and composable target+hooks execution (optional `--repo `), enforce deterministic option compatibility (`--repo` requires `--hooks`; target flags stay mutually exclusive), and emit stable section-ordered setup/hook status lines for automation-friendly logs. - For setup command messaging, emit deterministic completion output that includes selected target(s) and per-target install counts. diff --git a/context/plans/non-destructive-setup-install.md b/context/plans/non-destructive-setup-install.md new file mode 100644 index 00000000..e4dc7029 --- /dev/null +++ b/context/plans/non-destructive-setup-install.md @@ -0,0 +1,242 @@ +# Plan: non-destructive-setup-install + +## Change summary + +`sce setup --claude|--opencode|--pi|--all` currently destroys everything in the +target integration directory. `install_assets_for_concrete_target_with_rename` +(`cli/src/services/setup/mod.rs:1192`) stages the embedded SCE assets into a +temporary root, calls `remove_existing_install_target` on the whole `.claude/`, +`.opencode/`, or `.pi/` directory, then renames staging into place. A repository +whose `.claude/` holds the user's own skills, agents, commands, +`settings.local.json`, or `CLAUDE.md` loses all of it on a routine setup run. The +two generated JSON configs (`.claude/settings.json`, `.opencode/opencode.json`) +are the same problem one level down: they are written whole, so a user's +`permissions`, `env`, `model`, `mcp`, or non-SCE hook entries are replaced by the +SCE-only document. + +This plan replaces the directory-level remove-and-replace policy with per-asset +installation plus catalog-derived pruning of SCE-owned paths, and adds JSON-aware +merging for the two generated config files so SCE-owned fragments are installed +into the user's document instead of over it. It preserves the existing swap +choreography (stage, then atomic rename) at file granularity, the existing +no-backup policy, and the existing optional-workflow deselection semantics — the +latter moves from "the whole tree is rebuilt" to "unselected catalog paths are +pruned". `sce doctor` integration checks are realigned in the same change, since +byte-exact `sha256` comparison stops being the right check for a merged file. + +## Acceptance criteria + +How this plan is proven complete. Each criterion is observable and names the +check that proves it. `/validate` runs these checks; no task in the stack +performs final validation. + +- [x] AC1: Running setup for a target leaves every file in that target directory + that SCE does not own exactly as it was — contents, mode, and mtime — including + files nested inside SCE-owned parent directories such as + `.claude/skills/my-own-skill/SKILL.md`. + - Validate: `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml setup::` — the setup integration tests seed a target directory with user-owned files at top level, inside `skills/`, and inside `commands/`, run install, and assert every seeded file survives byte-identical. +- [x] AC2: Running setup twice with an optional workflow selected and then + deselected leaves no file of the deselected workflow on disk, and still leaves + every unrelated file intact. + - Validate: `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml setup::` — a test installs with `brownfield` selected, reinstalls with an empty selection, and asserts the brownfield command file and skill directory are gone while a sibling user-owned skill directory remains. +- [x] AC3: Installing into an existing `.claude/settings.json` that carries user + keys (`permissions`, `env`) and a user-authored hook entry yields a document + that still carries those keys and that hook entry, plus exactly one current copy + of each SCE hook entry, with no duplicate SCE entries after repeated runs. + - Validate: `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml setup::` — merge tests assert key preservation, SCE-entry replacement, and idempotence across two consecutive installs. +- [x] AC4: Installing into an existing `.opencode/opencode.json` that carries user + keys and a user plugin path yields a document retaining both, with the canonical + SCE plugin paths present exactly once and no stale SCE plugin path left behind. + - Validate: `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml setup::` — merge tests assert user-key and user-plugin preservation plus SCE plugin path reconciliation. +- [x] AC5: `sce doctor` reports `[PASS]` for a target whose merged JSON configs + carry extra user content, and reports drift only when an SCE-owned fragment is + missing or stale. + - Validate: `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml doctor::` plus a manual run of `sce doctor` in a checkout whose `.claude/settings.json` has a user `permissions` block — the `Claude` integration group shows `[PASS]`. + +### Full validation + +- `nix flake check` +- `nix run .#pkl-check-generated` + +### Context sync + +- `context/sce/setup-no-backup-policy-seam.md` — this file currently states + directory-level remove-and-replace as the unified policy for both config + install and hook install. It must describe per-asset install, catalog-derived + pruning, JSON merge targets, and the fact that the policy now differs between + config install and required-hook install. +- `context/sce/setup-repo-local-config-bootstrap.md` — the optional-workflow + section states deselection is expressed "under the existing remove-and-replace + policy"; it must state catalog-derived pruning instead. +- `context/patterns.md` — the "For setup install execution" bullet and the + optional-workflow "remove-and-replace installs already clear them" bullet both + encode the old policy. +- `context/overview.md` — the setup paragraph describing the unified + remove-and-replace policy. +- `context/sce/doctor-human-text-contract.md` — if the drift vocabulary for + merge-target files changes in T05. +- `context/sce/generated-opencode-plugin-registration.md` — the generated + `opencode.json` is now a merge fragment, not a whole-file payload. + +## Constraints and non-goals + +- **In scope:** `cli/src/services/setup/mod.rs` (install flow, staging, pruning, + merge seam), a new JSON-merge module under `cli/src/services/setup/`, + `cli/src/services/doctor/inspect.rs` integration-asset inspection, and the + durable-context files named under Context sync. +- **Out of scope:** `install_required_git_hooks` and the `.git/hooks/*` payloads. + Those still remove and replace an existing `pre-commit`/`commit-msg`/`post-commit` + file wholesale; chaining shell hooks is a different problem (see Open questions). +- **Out of scope:** `.sce/config.json`, which is already create-if-missing with + additive key writes and needs no change. +- **Out of scope:** Pkl authoring and the generated payload. Generation stays + byte-identical; `nix run .#pkl-check-generated` must keep passing unchanged. +- **Constraints:** No backup artifacts (`context/sce/setup-no-backup-policy-seam.md`). + No new crate dependency — `serde_json` is already a CLI dependency and is + sufficient for the merge work. Unit tests must stay filesystem-free + (`context/patterns.md`, "Unit testing in Nix sandbox"): merge logic is pure and + unit-tested; install/prune behavior belongs in integration tests. +- **Non-goal:** A general-purpose declarative config-merge engine. Two files, two + known shapes, one shared ownership marker. +- **Non-goal:** A persisted install manifest. Pruning is derived from the + compiled-in asset catalog (see Open questions for the residue this leaves). + +## Assumptions + +- SCE-owned files are overwritten without asking. A user who edited + `.claude/skills/sce-commit/SKILL.md` loses that edit, exactly as today. "Unrelated" + in the change request means files SCE never authored, not SCE files a user + modified. +- On a merge conflict inside a JSON config, the SCE-owned value wins for + SCE-owned keys and entries; every other key and entry is preserved untouched. + Setup cannot do its job otherwise. +- SCE ownership inside `.claude/settings.json` is identified by the hook command + string containing `run-sce-or-show-install-guidance.sh`, which every generated + Claude hook entry routes through (`config/pkl/renderers/claude-content.pkl:8`). +- SCE ownership inside `.opencode/opencode.json` is identified by a `plugin` entry + matching a canonical SCE plugin path (`./plugins/sce-bash-policy.ts`, + `./plugins/sce-agent-trace.ts`), authored in `config/pkl/base/opencode.pkl`. +- A malformed pre-existing JSON config is a hard error with actionable guidance, + not a silent overwrite. Silently replacing an unparseable user file is the same + data loss this plan exists to remove. +- Pruning stays stateless and catalog-derived, with no persisted install + manifest. Orphan files installed by an older `sce` under names the current + binary no longer knows are accepted residue — decided by the user when this was + raised as an open question. + +## Task stack + +- [x] T01: `Install setup assets per file instead of replacing the target directory` (status:done) + - Task ID: T01 + - Goal: `sce setup` writes each embedded asset into its own path under the target directory, creating parent directories as needed, and never removes the target root or any path it did not author. + - Boundaries (in/out of scope): In — `install_assets_for_concrete_target_with_rename` and its staging/swap helpers in `cli/src/services/setup/mod.rs`; per-file stage-then-rename with cleanup of the staging file on failure; the existing writability probe and recovery guidance retargeted to the individual asset path. Out — pruning stale SCE paths (T02), JSON merging (T03/T04), doctor (T05), git hooks. + - Dependencies: none + - Done when: installing into a target directory seeded with user-owned files at the top level, inside `skills/`, and inside `commands/` leaves every seeded file byte-identical while every embedded asset for the selected set is present with correct content; `remove_existing_install_target` is no longer called on an integration root; swap failure on one asset still cleans its staging artifact and returns recovery guidance naming that asset path. + - Verification notes (commands or checks): `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml setup::`; `./scripts/run-cli-cargo.sh clippy --manifest-path cli/Cargo.toml`. + - Implementation evidence: `install_assets_for_concrete_target_with_rename` (`cli/src/services/setup/mod.rs`) no longer stages a whole directory and swaps it over `destination_root`. It now loops over each embedded asset and calls new `install_single_asset_with_rename`, which stages a per-asset temp file next to the asset's real destination (`create_asset_staging_path`, mirroring the existing hook-install staging pattern), removes only that single existing destination file if present (bailing instead of deleting if the destination is unexpectedly a directory), then renames the staged file into place. Staging cleanup and `setup_install_recovery_guidance` are retargeted to the individual asset path on failure. Dead whole-directory helpers `create_staging_root` and `write_assets_to_staging` were removed; `remove_existing_install_target` is retained only for the untouched, out-of-scope git-hooks path. `install_embedded_setup_assets_with_rename` was widened from private to `pub(super)` so tests can inject a failing `rename_fn`. + - Verification evidence: `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml setup::` — 16 passed, including two new tests: `install_preserves_user_owned_files_and_writes_sce_assets` (seeds top-level, `skills/`, and `commands/` user files in `.claude`, installs, asserts all three survive byte-identical and `commands/next-task.md` matches the embedded catalog bytes) and `install_cleans_up_staging_and_reports_asset_path_on_rename_failure` (forces a rename failure for `commands/next-task.md` via the injected `rename_fn`, asserts the error names that destination path and includes "does not create backups", and asserts no leftover `.sce-setup-staging-` file in that asset's parent directory). `./scripts/run-cli-cargo.sh clippy --manifest-path cli/Cargo.toml --all-targets -- -D warnings` — clean. + - Deviations/assumptions: None beyond the review assumptions already recorded in the plan. + +- [x] T02: `Prune unselected and stale SCE-owned asset paths after install` (status:done) + - Task ID: T02 + - Goal: Restore deselection and stale-asset cleanup, which T01 removed, by deleting exactly those paths the full embedded catalog for the target claims but the resolved selection does not install. + - Boundaries (in/out of scope): In — a prune step in `cli/src/services/setup/mod.rs` computing `full catalog for target` minus `installed set`, deleting each such file, and removing SCE-owned skill directories left empty by that deletion. Out — deleting any path outside the compiled-in catalog; persisted install manifests; merge targets, which are never pruned because the file is shared with the user. + - Dependencies: T01 + - Done when: installing with `brownfield` selected and then reinstalling with an empty selection removes `.claude/commands/brownfield.md` and `.claude/skills/sce-brownfield/` entirely, leaves a sibling user-owned `.claude/skills/my-skill/` untouched, and leaves a user file placed inside an SCE-owned skill directory intact (so that directory is not removed as empty). + - Verification notes (commands or checks): `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml setup::`. + - Implementation evidence: `install_assets_for_concrete_target_with_rename` (`cli/src/services/setup/mod.rs`) now calls new `prune_stale_assets_for_concrete_target` after the per-asset install loop. It diffs `embedded_assets_for_concrete_target` (the full unfiltered catalog for the concrete target) against the just-installed `assets` slice by `relative_path`, and removes the destination file for every catalog path not in that installed set (a no-op when the file is already absent, covering assets an older or renamed catalog left behind). Each successful removal calls new `remove_empty_ancestor_directories`, which walks upward from the removed file's parent directory calling `fs::remove_dir` until it reaches `destination_root` or a directory removal fails (a non-empty directory, such as one still holding a user file, fails `fs::remove_dir` and stops the walk, so it survives). `embedded_assets_for_concrete_target` was added to the `install` submodule's `use super::{...}` import list; no other signature changed. + - Verification evidence: `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml setup::` — 18 passed, including two new tests: `reinstall_with_empty_selection_prunes_deselected_workflow_without_touching_sibling_skill` (installs Claude with `brownfield` selected, seeds a sibling `.claude/skills/my-skill/SKILL.md`, reinstalls with an empty selection, asserts `.claude/commands/brownfield.md` and `.claude/skills/sce-brownfield/` are both gone entirely and the sibling skill file is untouched) and `reinstall_with_empty_selection_keeps_pruned_skill_dir_holding_a_user_file` (same flow but seeds `.claude/skills/sce-brownfield/MY_OVERRIDE.md` before reinstalling, asserts the SCE `SKILL.md` is pruned, the directory survives because it still holds the user file, and that file's content is intact). `./scripts/run-cli-cargo.sh clippy --manifest-path cli/Cargo.toml --all-targets -- -D warnings` — clean. + - Deviations/assumptions: None beyond the review assumptions already recorded in the plan. + +- [x] T03: `Merge SCE hook entries into an existing .claude/settings.json` (status:done) + - Task ID: T03 + - Goal: Install `.claude/settings.json` by merging the generated document into the user's existing one rather than replacing it, preserving every non-SCE key and hook entry. + - Boundaries (in/out of scope): In — a new pure merge module under `cli/src/services/setup/` (for example `config_merge.rs`) exposing a `serde_json`-based merge for the Claude settings shape; a merge-target classification for asset relative path `settings.json` in the Claude install path; deterministic error on an unparseable existing file. Out — OpenCode (T04); doctor (T05); any change to the generated Pkl payload. + - Done when: merging the generated document into a settings file carrying `permissions`, `env`, and a user `PreToolUse` hook entry yields a document retaining all three; SCE hook entries (identified by `run-sce-or-show-install-guidance.sh` in the command) are replaced rather than appended, so two consecutive installs produce byte-identical output; an SCE hook entry the current generated document no longer contains is removed; a missing file is created from the generated document verbatim; an unparseable existing file fails with a message naming the path and does not write. + - Dependencies: T01 + - Verification notes (commands or checks): pure merge unit tests in the new module (no filesystem); `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml setup::config_merge`; `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml setup::`. + - Implementation evidence: New pure module `cli/src/services/setup/config_merge.rs` (declared via `mod config_merge;` in `cli/src/services/setup/mod.rs`) exposes `merge_or_create_claude_settings(existing_bytes: Option<&[u8]>, generated_bytes: &[u8], source_path: &str) -> Result>`. It returns `generated_bytes` verbatim when `existing_bytes` is `None`; otherwise it parses both as JSON (a parse failure on the existing document is a hard error naming `source_path`) and calls pure `merge_claude_settings(&Value, &Value, &str) -> Result`, which copies `$schema` from the generated document (SCE-owned), and for each event key the generated `hooks` object declares (currently `PreToolUse`, `PostToolUse`, `UserPromptSubmit`, `Stop`), replaces only the SCE-owned entries in `existing.hooks[event]` — identified via `hook_entry_is_sce_owned`, which checks whether any `hooks[].command` contains the marker `run-sce-or-show-install-guidance.sh` — with the generated entries for that event, appended after the surviving non-SCE entries; every other top-level key and every hook event key the generated document does not declare are left untouched. The result is re-serialized with `serde_json::to_string_pretty` plus a trailing newline. In `mod install` (`cli/src/services/setup/mod.rs`), `install_single_asset_with_rename` gained a new `is_claude_settings_merge_target(target, relative_path)` check (true only for `SetupTarget::Claude` + `claude_asset::SETTINGS_FILE`); when true it reads the existing destination bytes (if the file exists) before staging, computes `install_bytes` via `config_merge::merge_or_create_claude_settings`, and stages/renames those bytes instead of `asset.bytes` directly — the rest of the stage-then-rename, cleanup, and `setup_install_recovery_guidance` behavior is unchanged. + - Verification evidence: `./scripts/run-cli-cargo.sh build --manifest-path cli/Cargo.toml` — clean. `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml setup::config_merge` — 7 passed, covering: user-key/non-SCE-hook-entry preservation, SCE-entry replacement producing byte-identical output across two merges, an SCE hook entry the generated document no longer declares being dropped, a user-owned event key absent from the generated document being left untouched, a missing file returning the generated bytes verbatim, an unparseable existing file failing with an error naming the path, and a missing `hooks` key in the existing document being populated from generated. `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml setup::` — 26 passed, including new integration test `install_merges_into_existing_claude_settings_json_and_stays_idempotent` (seeds `.claude/settings.json` with `permissions`, `env`, and a user `PreToolUse` hook entry, installs, asserts the user keys and hook entry survive alongside an SCE-owned `PreToolUse` entry, reinstalls, and asserts the two installs produce byte-identical `settings.json` content). `./scripts/run-cli-cargo.sh clippy --manifest-path cli/Cargo.toml --all-targets -- -D warnings` — clean. + - Deviations/assumptions: Scoped the merge to only the four hook event keys the generator currently emits (`PreToolUse`, `PostToolUse`, `UserPromptSubmit`, `Stop`); a user-owned event key the generated document never declares (e.g. `Notification`) is left completely untouched by every merge, consistent with the plan's non-goal of a general-purpose config-merge engine. No other deviations beyond the review assumptions already recorded in the plan. + +- [x] T04: `Merge SCE plugin registrations into an existing .opencode/opencode.json` (status:done) + - Task ID: T04 + - Goal: Install `.opencode/opencode.json` by merging the canonical SCE `plugin` entries into the user's existing document, preserving every other key and plugin. + - Boundaries (in/out of scope): In — an OpenCode merge in the T03 module; classifying asset relative path `opencode.json` as a merge target in the OpenCode install path. Out — Claude (T03); doctor (T05); Pkl payload changes. + - Dependencies: T03 + - Done when: merging into a document carrying `model`, `mcp`, and a user plugin path retains all three, contains each canonical SCE plugin path exactly once after two consecutive installs, and drops a stale SCE-shaped plugin path the current catalog no longer declares; a missing file is created from the generated document verbatim; an unparseable existing file fails with a message naming the path and does not write. + - Verification notes (commands or checks): `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml setup::config_merge`; `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml setup::`. + - Implementation evidence: `cli/src/services/setup/config_merge.rs` gained `merge_or_create_opencode_config(existing_bytes: Option<&[u8]>, generated_bytes: &[u8], source_path: &str) -> Result>`, mirroring the Claude settings merge: returns `generated_bytes` verbatim when `existing_bytes` is `None`; otherwise parses both as JSON (an existing-document parse failure is a hard error naming `source_path`) and calls pure `merge_opencode_config(&Value, &Value, &str) -> Result`, which copies `$schema` from generated (SCE-owned) and, for `plugin`, filters `existing.plugin` down to entries that are not SCE-shaped — via new `plugin_entry_is_sce_owned`, which checks whether the string starts with new marker constant `OPENCODE_SCE_PLUGIN_PREFIX = "./plugins/sce-"` — then appends `generated.plugin`'s entries; ownership is matched structurally (by path shape) rather than by membership in the current generated array, so a plugin path an older or renamed catalog installed is still recognized and dropped even when the current generated document no longer declares it. Every other top-level key and any `plugin` entry not shaped like an SCE path are left untouched. In `mod install` (`cli/src/services/setup/mod.rs`), new `is_opencode_config_merge_target(target, relative_path)` (true only for `SetupTarget::OpenCode` + `default_paths::repo_file::OPENCODE_MANIFEST`, i.e. relative path `opencode.json`) gated a new branch in `install_single_asset_with_rename` alongside the existing Claude-settings branch: when true, it reads the existing destination bytes (if present) before staging, computes `install_bytes` via `config_merge::merge_or_create_opencode_config`, and stages/renames those bytes instead of `asset.bytes` directly. The install submodule's `use` list gained `crate::services::default_paths` (previously only `default_paths::claude_asset` was imported there) to resolve `repo_file::OPENCODE_MANIFEST`. + - Verification evidence: `./scripts/run-cli-cargo.sh build --manifest-path cli/Cargo.toml` — clean. `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml setup::config_merge` — 13 passed, including 7 new OpenCode-merge tests: user-key (`model`, `mcp`) and user-plugin preservation with the two canonical SCE plugin paths appended; idempotence across two merges producing an identical 2-entry `plugin` array; a stale SCE-shaped plugin path (`./plugins/sce-old-feature.ts`) not declared by the current generated document being dropped while a sibling user plugin and both canonical paths survive; a missing file returning the generated bytes verbatim; an unparseable existing file failing with an error naming the path; and a missing `plugin` key in the existing document being populated from generated. `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml setup::` — 33 passed, including new integration test `install_merges_into_existing_opencode_config_json_and_stays_idempotent` (seeds `.opencode/opencode.json` with `model`, `mcp`, a user plugin path, and a stale SCE-shaped plugin path, installs, asserts the user keys and plugin survive, both canonical SCE plugin paths are present and the stale one is gone, reinstalls, and asserts the two installs produce byte-identical `opencode.json` content). `./scripts/run-cli-cargo.sh clippy --manifest-path cli/Cargo.toml --all-targets -- -D warnings` — clean. + - Deviations/assumptions: SCE ownership of a `plugin` entry is identified structurally by the `./plugins/sce-` path prefix rather than by exact match against the two currently-canonical paths, so that a plugin path an earlier or renamed catalog once installed under that same directory convention is still recognized as SCE-owned and pruned even after the current generated document drops it — needed to satisfy the plan's "drops a stale SCE-shaped plugin path" done check, since a stale path by definition cannot be found by diffing against the current generated set. No other deviations beyond the review assumptions already recorded in the plan. + +- [x] T05: `Check merge-target configs by SCE-owned fragment in sce doctor` (status:done) + - Task ID: T05 + - Goal: `sce doctor` stops reporting drift for a merged JSON config that legitimately carries user content, and reports it only when an SCE-owned fragment is absent or stale. + - Boundaries (in/out of scope): In — `build_integration_child_from_asset` / `inspect_integration_asset_state` in `cli/src/services/doctor/inspect.rs`, so merge-target assets are inspected by SCE-fragment presence and equality instead of whole-file `sha256`; `--fix` for those assets reusing the T03/T04 merge install. Out — the doctor text layout vocabulary unless the fragment check needs a new state; every non-merge asset, which keeps byte-exact `sha256` checking; hook health checks. + - Dependencies: T03, T04 + - Done when: a `.claude/settings.json` merged with user `permissions` reports `[PASS]`; the same file with an SCE hook entry deleted or with a stale SCE hook command reports drift; `sce doctor --fix` repairs it by merging and leaves the user keys intact; `.opencode/opencode.json` behaves the same for SCE plugin entries. + - Verification notes (commands or checks): `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml doctor::`; manual `sce doctor` in a checkout with a user-extended `.claude/settings.json`. + - Implementation evidence: `cli/src/services/setup/config_merge.rs` gained `pub(crate) fn claude_settings_fragment_is_current(existing_bytes, generated_bytes) -> Result` and `pub(crate) fn opencode_config_fragment_is_current(...)`, each parsing both documents, running the existing private `merge_claude_settings`/`merge_opencode_config`, and comparing the merged `Value` against the existing one — a no-op merge means the SCE-owned fragment is already current. `cli/src/services/setup/mod.rs` widened `mod config_merge;` to `pub(crate) mod config_merge;` and added `pub(crate) fn repair_merge_target_asset(repository_root, target, relative_path)`, which delegates to a new `install::repair_merge_target_asset` that looks up the one embedded asset by relative path in `embedded_assets_for_concrete_target` and reinstalls only that asset through the existing `install_single_asset_with_rename` (the same per-asset merge-install path T03/T04 wired up), leaving every other asset untouched. `cli/src/services/doctor/inspect.rs`: `build_integration_child_from_asset` now takes `Option<&MergeTargetAsset>` (new two-variant enum `ClaudeSettings`/`OpenCodeConfig`); for those two assets it calls new `inspect_merge_target_asset_state`, which reads the existing file and calls the matching fragment-check function (a read/parse failure or drifted fragment both surface as `Mismatch`, since remediation is the same either way); every other asset keeps the prior `sha256` path unchanged via `inspect_integration_asset_state`. New `repair_merge_target_configs(repository_root)` re-collects the Claude/OpenCode integration groups, and for each merge-target child currently in `Mismatch`, calls `repair_merge_target_asset` and records a `DoctorFixResultRecord`; a merge target already `Match` or fully `Missing` is left untouched (missing files stay covered by the existing "reinstall assets" guidance). `cli/src/services/doctor/mod.rs`'s `execute_doctor_with_lifecycle_providers` now calls `repair_merge_target_configs(repository_root)` during `--fix`, before re-diagnosing for the final report, alongside the existing lifecycle-provider fixes. + - Verification evidence: `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml setup::config_merge` — 21 passed, including 4 new fragment tests covering: a Claude settings file with extra user keys and a fully current SCE fragment reporting current, a deleted SCE hook entry reporting not-current, an OpenCode config with an extra user plugin reporting current, and a stale SCE-shaped plugin path reporting not-current. `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml doctor::` — 6 passed, including 3 new filesystem-backed tests: `claude_settings_reports_match_despite_extra_user_permissions` (user `permissions` alongside a current fragment reports `Match`), `claude_settings_reports_mismatch_when_sce_hook_entry_deleted_then_fix_repairs_it` (emptied hook arrays report `Mismatch`, `repair_merge_target_configs` fixes it, user `permissions` survive, and a second inspection reports `Match`), and the equivalent `opencode_config_reports_match_despite_extra_user_plugin_then_drift_and_fix` for `.opencode/opencode.json`. `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml setup::` — 37 passed (no regressions). `./scripts/run-cli-cargo.sh clippy --manifest-path cli/Cargo.toml --all-targets -- -D warnings` — clean. Manual verification: in a temp git checkout with `sce setup --claude` run, adding user `permissions`/`env` keys to `.claude/settings.json` and running `sce doctor` showed `[PASS] settings.json`; emptying `hooks.PreToolUse` showed `[FAIL] settings.json (... - content mismatch)`; `sce doctor --fix` printed `[fixed] Merged canonical SCE fragments into 'settings.json'.` and the subsequent `sce doctor` showed `[PASS]` again with the user `permissions`/`env` keys intact. + - Deviations/assumptions: A merge-target file that fails to parse as JSON is reported the same as a drifted fragment (`Mismatch`), not a distinct state, since the plan's boundaries keep new doctor vocabulary out of scope unless the fragment check needs it, and both cases point to the same remediation. `repair_merge_target_configs` only repairs a merge-target child already in `Mismatch`; a fully missing merge-target file is left to the existing generic "reinstall assets" missing-file guidance rather than being created in isolation by the fix path, since creating just that one file when the rest of the integration is absent would be a surprising partial repair. No other deviations beyond the review assumptions already recorded in the plan. + +## Open questions + +- `sce setup --hooks` still removes and replaces `.git/hooks/pre-commit`, + `commit-msg`, and `post-commit` wholesale, so a husky or lefthook repository + loses its hook on a setup run. Asked whether shell hooks can be merged: not + textually, but a dispatcher works. SCE would keep `.git/hooks/` as a thin + dispatcher, relocate a pre-existing foreign hook to `.git/hooks/.d/10-local` + preserving its mode, and have the dispatcher run every executable in `.d/` + in lexical order, abort on the first non-zero exit, then run the SCE logic. It is + tractable here because all three hooks have simple contracts — `pre-commit` and + `post-commit` take no arguments, `commit-msg` takes one message-file path, none + read stdin — and the ordering falls out correctly, with SCE's `commit-msg` last + so its trailer lands on the final message. Two caveats: husky and lefthook set + `core.hooksPath`, which `install_required_git_hooks` does not currently honour + (it resolves via `git rev-parse --git-path hooks`), so SCE and the manager write + to different directories until that resolution is fixed; and even where the paths + do collide, a manager reinstalling its own hooks overwrites the dispatcher, so the + scheme is cooperative rather than authoritative. Adding it means a marker line in + the hook templates, `core.hooksPath`-aware resolution, the dispatcher template, + and relocation logic — roughly two more tasks. Undecided: say whether to add them. + +## Validation Report + +**Status:** failed +**Date:** 2026-08-03 + +### Commands run + +- `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml setup::` -> exit 0 (37 passed, 0 failed — covers AC1-AC4) +- `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml doctor::` -> exit 0 (6 passed, 0 failed — covers AC5's automated portion) +- Manual `sce doctor` in a temp checkout with a user-extended `.claude/settings.json` -> pass (AC5's manual portion: `[PASS] settings.json` with user `permissions`/`env` present; emptying `hooks.PreToolUse` produced `[FAIL] settings.json (... - content mismatch)`; `sce doctor --fix` reported `[fixed] Merged canonical SCE fragments into 'settings.json'.` and a follow-up `sce doctor` showed `[PASS]` again with the user keys intact) +- `nix flake check` -> exit 1 (`checks.x86_64-linux.cli-fmt` failed: `cargo fmt -- --check` reports unformatted diffs in `cli/src/services/setup/mod.rs` and `cli/src/services/setup/config_merge.rs`; `cli-clippy` and `cli-tests` build successfully in isolation) +- `nix run .#pkl-check-generated` -> exit 0 (Ephemeral Pkl generation passed: 101 files, inventory sha256 a1da453613edc8ecb1e04f35f37471ac02674bad5f2564ae70994e9f1acc6775) + +### Scaffolding removed + +- None. + +### Success-criteria verification + +- [x] AC1: Running setup for a target leaves every non-owned file in that target directory untouched, including files nested inside SCE-owned parent directories -> `install_preserves_user_owned_files_and_writes_sce_assets` passes +- [x] AC2: Deselecting an optional workflow removes only that workflow's files while leaving unrelated files intact -> `reinstall_with_empty_selection_prunes_deselected_workflow_without_touching_sibling_skill` and `reinstall_with_empty_selection_keeps_pruned_skill_dir_holding_a_user_file` pass +- [x] AC3: Installing into an existing `.claude/settings.json` preserves user keys and hook entries, with exactly one current SCE hook entry per event and no duplicates after repeated runs -> `install_merges_into_existing_claude_settings_json_and_stays_idempotent` and `config_merge` unit tests pass +- [x] AC4: Installing into an existing `.opencode/opencode.json` preserves user keys and plugin paths, with each canonical SCE plugin path present exactly once and no stale SCE plugin path left behind -> `install_merges_into_existing_opencode_config_json_and_stays_idempotent` and `config_merge` unit tests pass +- [x] AC5: `sce doctor` reports `[PASS]` for a target whose merged JSON configs carry extra user content, and reports drift only when an SCE-owned fragment is missing or stale -> `doctor::` tests pass; manual verification above confirms `[PASS]` with extra user content, drift detection on a deleted SCE hook entry, and `--fix` repair preserving user keys + +### Failed checks and follow-ups + +- `nix flake check` / `checks.x86_64-linux.cli-fmt`: `cargo fmt -- --check` fails against the current tree; evidence: the fmt derivation's build log shows reflow diffs across roughly a dozen sites in `cli/src/services/setup/mod.rs` (e.g. `is_opencode_config_merge_target`, several test bodies around lines 2090-2246) and `cli/src/services/setup/config_merge.rs` (test bodies around lines 350-469); required: run `cargo fmt --manifest-path cli/Cargo.toml` in a normal work session to reformat the affected files, then rerun `nix flake check`. Also required before any Nix check can see it: `cli/src/services/setup/config_merge.rs` was untracked in git going into this validation run (Nix flake source filtering only includes git-tracked files, so the module was invisible to `nix flake check` until staged) — it has been `git add`ed as part of this validation run; no file content was changed by that action. + +### Residual risks + +- None identified. + +### Retry + +After repairs, rerun: + +`/validate context/plans/non-destructive-setup-install.md` diff --git a/context/sce/doctor-human-text-contract.md b/context/sce/doctor-human-text-contract.md index 207ef256..15699eed 100644 --- a/context/sce/doctor-human-text-contract.md +++ b/context/sce/doctor-human-text-contract.md @@ -68,7 +68,7 @@ Human text output renders group rows only for the resolved targets: Within a resolved target, the required inventory is additionally scoped to the repository's optional-workflow selection. The doctor reads `integrations.optional_workflows` from `.sce/config.json`; an absent, unreadable, or key-less file means nothing is selected. There is no directory-detection fallback for optional workflows. An unselected optional workflow's command file and skill subtree are not part of the required inventory, so no child row and no missing-file problem is produced for them. A selected optional workflow's assets are required inventory like any core workflow's, keeping `[MISS]` and content-mismatch `[FAIL]` detection unchanged. Files belonging to a previously selected but now unselected optional workflow are not reported as stray; the doctor simply stops expecting them. See [setup local bootstrap](setup-repo-local-config-bootstrap.md). Integration checks for this contract inspect installed repo-root artifacts only. -They validate file presence and content hashes against embedded OpenCode, Claude, and Pi setup assets. +They validate file presence and content against embedded OpenCode, Claude, and Pi setup assets: byte-exact `sha256` for every asset except the two JSON configs `sce setup` installs by merge (`.claude/settings.json`, `.opencode/opencode.json`), which instead validate that the file's SCE-owned fragment matches the embedded catalog — a file that also carries extra user keys, permissions, or plugins still renders `[PASS]` as long as that fragment is current (see [non-destructive setup install merge seam](setup-no-backup-policy-seam.md)). Generated `config/.opencode/**`, `config/.claude/**`, and `config/.pi/**` trees are out of scope for doctor integration checks in this change stream. Claude installed assets are grouped by repo-root `.claude/` relative path: @@ -99,7 +99,8 @@ Integration child rows render as `[STATUS] relative/path (absolute/path)` in tex ## Non-goals for this contract slice - no JSON output shape or semantic changes -- no `sce doctor --fix` behavior changes - no Claude plugin registry or preset-catalog checks +These non-goals scoped the original text-contract slice only. A later plan (`non-destructive-setup-install` task `T05`) added `sce doctor --fix` behavior for the two merge-target JSON configs: when their SCE-owned fragment is missing or stale, `--fix` reinstalls just that one asset through the same per-asset merge-install path `sce setup` uses, leaving every other asset and every user key untouched. The status vocabulary and section order above are unchanged by that addition. + See also: [doctor operator contract](agent-trace-hook-doctor.md), [CLI command surface](../cli/cli-command-surface.md). diff --git a/context/sce/setup-no-backup-policy-seam.md b/context/sce/setup-no-backup-policy-seam.md index 437013a8..13bcba47 100644 --- a/context/sce/setup-no-backup-policy-seam.md +++ b/context/sce/setup-no-backup-policy-seam.md @@ -1,27 +1,28 @@ -# Setup remove-and-replace install policy +# Setup non-destructive per-asset install policy -`cli/src/services/setup/mod.rs` uses a unified remove-and-replace policy for all setup-managed write flows. There is no backup creation or backup-based rollback. +`cli/src/services/setup/mod.rs` installs every setup-managed file at file granularity: stage, remove the exact destination file if one exists, then swap the staged content into place. There is no backup creation or backup-based rollback, and setup-managed installs never remove an integration target directory as a whole. This per-file stage/swap choreography is shared by config install, required-hook install, and merge-target install; it is the JSON merge targets described below whose staged *content* differs from the embedded asset's bytes. ## Current state -- Both config install (`.opencode`/`.claude`) and required hook install use the same remove-and-replace choreography: - 1. Write canonical content to a unique staging file. - 2. Remove the existing target (if present) directly. - 3. Swap the staged content into the final target path. - 4. On swap failure, clean the staging artifact and return deterministic recovery guidance (recover from version control if needed). -- No `.backup` artifacts are created during any setup write flow. -- No backup-based rollback is attempted on swap failure. -- Recovery guidance is generic (not git-specific wording): "Setup does not create backups. Recover '' from version control if needed." - -## Implemented behavior - -- Config install removes the existing target directory before swapping staged content. On swap failure, it cleans the staging artifact and returns recovery guidance. -- Required hook install removes the existing hook file before swapping staged content. On swap failure, it cleans the staging artifact and returns recovery guidance. -- Success output reports target, file count, and per-hook status (`installed`/`updated`/`skipped`) without any backup-related lines. +- Config install (`.opencode`/`.claude`/`.pi`, `install_embedded_setup_assets` / `install_assets_for_concrete_target_with_rename`) writes every embedded asset to its own path under the target directory, creating parent directories as needed: + 1. Write the asset's canonical content to a unique staging file next to its final destination. + 2. If a file already exists at that exact destination path, remove only that file. If a directory exists there instead, fail with an actionable error instead of deleting it. + 3. Swap the staged content into the final destination. + 4. On swap failure, clean the staging artifact and return deterministic recovery guidance naming that asset's destination path (recover from version control if needed). +- Setup never removes an integration target directory (`.opencode`, `.claude`, `.pi`) as a whole, and never touches a path it did not author. Files a repository placed inside an SCE-owned target directory — at the top level or nested inside an SCE-owned subdirectory such as `skills/` or `commands/` — survive a setup run untouched. +- Required hook install (`install_required_git_hooks`) uses the same per-file stage/remove-if-present/swap choreography for each hook file; this predates and is unaffected by the config-install change above. +- After the per-asset install loop, config install prunes stale SCE-owned paths: `prune_stale_assets_for_concrete_target` diffs the full embedded-asset catalog for the concrete target against the assets this run actually installed, and deletes every catalog path present in the former but not the latter (deselected optional-workflow files, or an asset a newer catalog renamed or dropped). Each successful deletion is followed by `remove_empty_ancestor_directories`, which removes now-empty parent directories upward until it reaches the target root or hits a directory that still holds something (a directory holding a user file fails to remove and is left in place, so a user file nested inside an SCE-owned skill directory survives even though the SCE file next to it is pruned). Pruning is stateless and catalog-derived — no install manifest is persisted — so it only ever considers paths the compiled-in catalog still names. +- No `.backup` artifacts are created during any setup write flow, and no backup-based rollback is attempted on swap failure. +- Recovery guidance is generic (not git-specific wording): "Setup ... does not create backups. Recover '' from version control if needed." +- Two config assets are merge targets instead of verbatim-content assets: `.claude/settings.json` for the Claude target, and `.opencode/opencode.json` for the OpenCode target. `install_single_asset_with_rename` detects each (`is_claude_settings_merge_target`, `is_opencode_config_merge_target`) and, before staging, computes the bytes to stage from `cli/src/services/setup/config_merge.rs` rather than writing the embedded asset's bytes directly. Both merge functions return the generated document verbatim when no existing file is present; otherwise each parses the existing file as JSON (a parse failure is a hard error naming the file's path, and nothing is written) and merges the generated document into it, preserving every other top-level key untouched: + - `merge_or_create_claude_settings`: `$schema` and, event-by-event, every hook entry whose command contains the marker `run-sce-or-show-install-guidance.sh` are SCE-owned and replaced from the generated document; every hook entry or event key the generated document does not declare is preserved untouched. + - `merge_or_create_opencode_config`: `$schema` is SCE-owned and replaced from the generated document; the `plugin` array is merged as a set — any existing entry whose path starts with `./plugins/sce-` is dropped (structural ownership, so a plugin path an older or renamed catalog once installed is still recognized and pruned even after the current generated document stops declaring it), then the generated document's `plugin` entries are appended. + The merged bytes then flow through the same stage/remove-if-present/swap choreography as every other asset, so this is a content-computation seam layered on the shared install policy, not a different write path. +- `sce doctor --fix` reuses this same per-asset install path for the two merge targets rather than running its own repair logic: `crate::services::setup::repair_merge_target_asset` looks up the one embedded asset by relative path and reinstalls only it through `install_single_asset_with_rename`, so a drifted `.claude/settings.json` or `.opencode/opencode.json` is repaired by merge — every other installed asset and every user key is left untouched. `sce doctor` (diagnose or fix) tells a merge target's drift apart from a legitimately extended file by SCE-fragment equality (`config_merge::claude_settings_fragment_is_current`, `config_merge::opencode_config_fragment_is_current`) instead of the byte-exact `sha256` check every other integration asset uses (see [doctor human text contract](doctor-human-text-contract.md)). ## Scope boundary -- This file captures the unified remove-and-replace install policy and its use by both config-install and required-hook install flows. -- Future setup-managed write flows should follow the same remove-and-replace pattern instead of introducing backup creation. +- This file captures the non-destructive, per-file install policy shared by config-install and required-hook install flows, including the merge-target content-computation seam for `.claude/settings.json`. +- Future setup-managed write flows should follow the same per-file stage/remove-if-present/swap pattern instead of introducing backup creation or whole-directory replacement. A future merge target computes its staged content the same way `.claude/settings.json` does, ahead of the shared stage/swap step. -See also: [../overview.md](../overview.md), [../context-map.md](../context-map.md), [setup-githooks-install-flow.md](setup-githooks-install-flow.md) \ No newline at end of file +See also: [../overview.md](../overview.md), [../context-map.md](../context-map.md), [setup-githooks-install-flow.md](setup-githooks-install-flow.md) diff --git a/context/sce/setup-repo-local-config-bootstrap.md b/context/sce/setup-repo-local-config-bootstrap.md index 4a54cdca..d6b1270b 100644 --- a/context/sce/setup-repo-local-config-bootstrap.md +++ b/context/sce/setup-repo-local-config-bootstrap.md @@ -40,7 +40,7 @@ After config asset installation succeeds for a non-interactive target (`--openco The same write also records the run's resolved optional-workflow selection under `integrations.optional_workflows`: - Precedence: an interactively answered multi-select is the exact selection for the run; otherwise a supplied `--workflow ` list (repeatable) is; when neither is present the persisted `integrations.optional_workflows` is read back and reused, so a repeat `sce setup --claude --non-interactive` never silently uninstalls a previously selected optional workflow. The prompt's pre-checked rows come from the same persisted value (or from `--workflow` when it was supplied), so accepting the prompt unchanged records what a rerun would have kept. -- The resolved selection filters what is installed, so an unselected optional workflow's command file and skill directory are simply absent from the freshly installed target tree under the existing remove-and-replace policy. +- The resolved selection filters what is installed, and catalog-derived pruning (see [setup-no-backup-policy-seam.md](setup-no-backup-policy-seam.md)) removes an unselected optional workflow's command file and skill directory left behind by an earlier run, so deselection is effective on both a first-time install and a later reinstall. - A run that resolves to an empty selection records `[]`. Deselecting is therefore expressed by installing without that slug, not by a separate uninstall step. - The persisted set is repository-wide, not per target: a `--all` run records one selection covering `.opencode/`, `.claude/`, and `.pi/`. - Unknown slugs are rejected during request resolution, before any file or config write.