diff --git a/.opencode/plugins/sce-agent-trace.ts b/.opencode/plugins/sce-agent-trace.ts index 4a74bd3a..980ceb06 100644 --- a/.opencode/plugins/sce-agent-trace.ts +++ b/.opencode/plugins/sce-agent-trace.ts @@ -337,9 +337,7 @@ async function runDiffTraceHook( child.on("error", (err: NodeJS.ErrnoException) => { if (err.code === "ENOENT") { - console.warn( - `sce CLI not found. Install it from ${SCE_INSTALL_URL}`, - ); + console.warn(`sce CLI not found. Install it from ${SCE_INSTALL_URL}`); } resolve(); }); @@ -364,9 +362,7 @@ async function runConversationTraceHook( child.on("error", (err: NodeJS.ErrnoException) => { if (err.code === "ENOENT") { - console.warn( - `sce CLI not found. Install it from ${SCE_INSTALL_URL}`, - ); + console.warn(`sce CLI not found. Install it from ${SCE_INSTALL_URL}`); } resolve(); }); diff --git a/.opencode/plugins/sce-bash-policy.ts b/.opencode/plugins/sce-bash-policy.ts index 788929f3..0b2b8d9c 100644 --- a/.opencode/plugins/sce-bash-policy.ts +++ b/.opencode/plugins/sce-bash-policy.ts @@ -32,9 +32,7 @@ function evaluateBashCommandPolicy(command: string): JsonPolicyResult | null { if (result.error) { if ((result.error as NodeJS.ErrnoException).code === "ENOENT") { - console.warn( - `sce CLI not found. Install it from ${SCE_INSTALL_URL}`, - ); + console.warn(`sce CLI not found. Install it from ${SCE_INSTALL_URL}`); } return null; } diff --git a/.sce/config.json b/.sce/config.json index 4298810e..dcf5092c 100644 --- a/.sce/config.json +++ b/.sce/config.json @@ -1,15 +1,19 @@ { "$schema": "https://sce.crocoder.dev/config.json", - "log_level": "error", + "integrations": { + "target": [ + "claude", + "opencode" + ] + }, "log_file": "context/tmp/sce.log", "log_file_mode": "append", + "log_level": "error", "policies": { "attribution_hooks": { "enabled": true }, "bash": { - "presets": [ - ], "custom": [ { "id": "use-nix-flake-check-over-cargo-test", @@ -42,7 +46,8 @@ }, "message": "This repository prefers `nix flake check` over direct `cargo check`. Run `nix flake check` instead." } - ] + ], + "presets": [] } } } diff --git a/cli/src/services/config/resolver.rs b/cli/src/services/config/resolver.rs index 3538ce77..b370ca64 100644 --- a/cli/src/services/config/resolver.rs +++ b/cli/src/services/config/resolver.rs @@ -276,6 +276,7 @@ where bash_policy_presets: None, bash_policy_custom: None, database_retry: None, + integrations: None, }; let mut validation_errors = Vec::new(); for loaded_path in &loaded_config_paths { @@ -318,6 +319,9 @@ where if let Some(database_retry) = layer.database_retry { file_config.database_retry = Some(database_retry); } + if let Some(integrations) = layer.integrations { + file_config.integrations = Some(integrations); + } } let mut resolved_log_level = ResolvedValue { diff --git a/cli/src/services/config/schema.rs b/cli/src/services/config/schema.rs index 7ff867a0..c5944b8d 100644 --- a/cli/src/services/config/schema.rs +++ b/cli/src/services/config/schema.rs @@ -17,7 +17,10 @@ use serde::Deserialize; use serde_json::Value; use super::policy::{parse_bash_policy_presets, parse_custom_bash_policies, CustomBashPolicyEntry}; -use super::types::{ConfigPathSource, DatabaseRetryConfig, LogFileMode, LogFormat, LogLevel}; +use super::types::{ + ConfigPathSource, DatabaseRetryConfig, IntegrationTargetId, IntegrationsConfig, LogFileMode, + LogFormat, LogLevel, +}; use crate::services::resilience::RetryPolicy; pub(crate) const SCE_CONFIG_SCHEMA_JSON: &str = @@ -34,10 +37,11 @@ pub(crate) const TOP_LEVEL_CONFIG_KEYS: &[&str] = &[ "timeout_ms", super::resolver::WORKOS_CLIENT_ID_KEY.config_key, "policies", + "integrations", ]; pub(crate) const TOP_LEVEL_CONFIG_KEYS_DESCRIPTION: &str = - "$schema, log_level, log_format, log_file, log_file_mode, timeout_ms, workos_client_id, policies"; + "$schema, log_level, log_format, log_file, log_file_mode, timeout_ms, workos_client_id, policies, integrations"; static CONFIG_SCHEMA_VALIDATOR: OnceLock = OnceLock::new(); @@ -68,6 +72,12 @@ pub(crate) struct ParsedFileConfigDocument { pub(crate) timeout_ms: Option, pub(crate) workos_client_id: Option, pub(crate) policies: Option, + pub(crate) integrations: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +pub(crate) struct ParsedIntegrationsConfigDocument { + pub(crate) target: Option>, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] @@ -141,6 +151,7 @@ pub(crate) struct FileConfig { pub(crate) bash_policy_presets: Option>>, pub(crate) bash_policy_custom: Option>>, pub(crate) database_retry: Option>, + pub(crate) integrations: Option>, } pub(crate) type ParsedBashPolicyConfig = ( @@ -285,6 +296,7 @@ pub(crate) fn parse_file_config( .map(|value| FileConfigValue { value, source }); let (attribution_hooks_enabled, bash_policy_presets, bash_policy_custom, database_retry) = map_policies_config(typed.policies.as_ref(), object, path, source)?; + let integrations = map_integrations_config(typed.integrations.as_ref(), object, path, source)?; Ok(FileConfig { log_level, @@ -297,6 +309,7 @@ pub(crate) fn parse_file_config( bash_policy_presets, bash_policy_custom, database_retry, + integrations, }) } @@ -569,3 +582,43 @@ pub(crate) fn map_database_retry_config( source, })) } + +fn map_integrations_config( + typed: Option<&ParsedIntegrationsConfigDocument>, + object: &serde_json::Map, + path: &Path, + source: ConfigPathSource, +) -> Result>> { + let Some(integrations_value) = object.get("integrations") else { + return Ok(None); + }; + + let integrations_object = integrations_value.as_object().with_context(|| { + format!( + "Config key 'integrations' in '{}' must be an object.", + path.display() + ) + })?; + + validate_object_keys( + integrations_object, + path, + Some("integrations"), + &["target"], + "target", + )?; + + let Some(raw_targets) = typed.and_then(|config| config.target.as_ref()) else { + return Ok(None); + }; + + let targets: Vec = raw_targets + .iter() + .map(|raw| IntegrationTargetId::parse(raw, &format!("config file '{}'", path.display()))) + .collect::>>()?; + + Ok(Some(FileConfigValue { + value: IntegrationsConfig { target: targets }, + source, + })) +} diff --git a/cli/src/services/config/types.rs b/cli/src/services/config/types.rs index 35732105..7a95c1b0 100644 --- a/cli/src/services/config/types.rs +++ b/cli/src/services/config/types.rs @@ -264,3 +264,26 @@ pub(crate) struct PerDbRetryConfig { pub(crate) connection_open: Option, pub(crate) query: Option, } + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum IntegrationTargetId { + Opencode, + Claude, +} + +impl IntegrationTargetId { + pub(crate) fn parse(raw: &str, source: &str) -> anyhow::Result { + match raw { + "opencode" => Ok(Self::Opencode), + "claude" => Ok(Self::Claude), + _ => anyhow::bail!( + "Invalid integration target '{raw}' from {source}. Valid values: opencode, claude." + ), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct IntegrationsConfig { + pub(crate) target: Vec, +} diff --git a/cli/src/services/default_paths.rs b/cli/src/services/default_paths.rs index 1a1d0e59..932a41dc 100644 --- a/cli/src/services/default_paths.rs +++ b/cli/src/services/default_paths.rs @@ -270,7 +270,6 @@ pub fn local_db_path() -> anyhow::Result { /// The path is `/sce/auth.db`, where `state_root` comes /// from the shared default-path catalog (`XDG_STATE_HOME` or platform /// equivalent). -#[allow(dead_code)] pub fn auth_db_path() -> anyhow::Result { Ok(resolve_sce_default_locations()? .roots() @@ -284,7 +283,6 @@ pub fn auth_db_path() -> anyhow::Result { /// The path is `/sce/agent-trace.db`, where `state_root` comes /// from the shared default-path catalog (`XDG_STATE_HOME` or platform /// equivalent). -#[allow(dead_code)] pub fn agent_trace_db_path() -> anyhow::Result { Ok(resolve_sce_default_locations()? .roots() @@ -298,7 +296,6 @@ pub fn agent_trace_db_path() -> anyhow::Result { /// The path is `/sce/agent-trace-{checkout_id}.db`, where /// `state_root` comes from the shared default-path catalog (`XDG_STATE_HOME` or /// platform equivalent). -#[allow(dead_code)] pub fn agent_trace_db_path_for_checkout(checkout_id: &str) -> anyhow::Result { let checkout_id = checkout_id.trim(); if checkout_id.is_empty() { @@ -378,11 +375,13 @@ pub(crate) mod opencode_asset { pub const AGENTS_DIR: &str = "agents"; } -#[allow(dead_code)] pub(crate) mod claude_asset { pub const CLAUDE_DIR: &str = "claude"; + pub const SETTINGS_FILE: &str = "settings.json"; + pub const HOOKS_DIR: &str = "hooks"; pub const SKILLS_DIR: &str = "skills"; pub const AGENTS_DIR: &str = "agents"; + pub const COMMANDS_DIR: &str = "commands"; } #[allow(dead_code)] @@ -410,7 +409,6 @@ pub(crate) mod schema { pub const SCE_CONFIG_SCHEMA: &str = "sce-config.schema.json"; } -#[allow(dead_code)] #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct RepoPaths { root: PathBuf, diff --git a/cli/src/services/doctor/inspect.rs b/cli/src/services/doctor/inspect.rs index 6eeaa043..a974122f 100644 --- a/cli/src/services/doctor/inspect.rs +++ b/cli/src/services/doctor/inspect.rs @@ -5,8 +5,10 @@ use sha2::{Digest, Sha256}; use crate::services::agent_trace_db::lifecycle::diagnose_agent_trace_db_health; use crate::services::checkout; +use crate::services::config::schema::parse_file_config; +use crate::services::config::{ConfigPathSource, IntegrationTargetId}; use crate::services::default_paths::{ - agent_trace_db_path_for_checkout, opencode_asset, InstallTargetPaths, RepoPaths, + agent_trace_db_path_for_checkout, claude_asset, opencode_asset, InstallTargetPaths, RepoPaths, }; use crate::services::setup::{ iter_embedded_assets_for_setup_target, iter_required_hook_assets, EmbeddedAsset, SetupTarget, @@ -16,116 +18,12 @@ use super::types::{ CheckoutIdentityHealth, DoctorProblem, FileLocationHealth, GlobalStateHealth, HookContentState, HookDoctorReport, HookFileHealth, HookPathSource, IntegrationChildHealth, IntegrationContentState, IntegrationGroupHealth, ProblemCategory, ProblemFixability, - ProblemKind, ProblemSeverity, Readiness, OPENCODE_AGENTS_LABEL, OPENCODE_COMMANDS_LABEL, + 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, }; use super::{is_executable, DoctorDependencies, DoctorMode, REQUIRED_HOOKS}; -#[allow(dead_code)] -pub(super) fn build_report_with_dependencies( - mode: DoctorMode, - repository_root: &Path, - dependencies: &DoctorDependencies<'_>, -) -> HookDoctorReport { - let mut problems = Vec::new(); - let global_state = collect_global_state_health(repository_root, &mut problems, dependencies); - let checkout_identity = collect_checkout_identity_health(repository_root); - let agent_trace_db = collect_agent_trace_db_health(repository_root, &mut problems); - let git_available = (dependencies.check_git_available)(); - - let detected_repository_root = if git_available { - (dependencies.run_git_command)(repository_root, &["rev-parse", "--show-toplevel"]) - .map(PathBuf::from) - } else { - None - }; - - let bare_repository = if git_available { - (dependencies.run_git_command)(repository_root, &["rev-parse", "--is-bare-repository"]) - .is_some_and(|value| value == "true") - } else { - false - }; - - let local_hooks_path = if git_available { - (dependencies.run_git_command)( - repository_root, - &["config", "--local", "--get", "core.hooksPath"], - ) - } else { - None - }; - let global_hooks_path = if git_available { - (dependencies.run_git_command)( - repository_root, - &["config", "--global", "--get", "core.hooksPath"], - ) - } else { - None - }; - - let hook_path_source = if local_hooks_path.is_some() { - HookPathSource::LocalConfig - } else if global_hooks_path.is_some() { - HookPathSource::GlobalConfig - } else { - HookPathSource::Default - }; - - let hooks_directory = detected_repository_root.as_ref().and_then(|resolved_root| { - (dependencies.run_git_command)(resolved_root, &["rev-parse", "--git-path", "hooks"]).map( - |value| { - let path = PathBuf::from(value); - if path.is_absolute() { - path - } else { - resolved_root.join(path) - } - }, - ) - }); - - let hooks = inspect_repository_hooks( - repository_root, - git_available, - bare_repository, - detected_repository_root.as_deref(), - hooks_directory.as_deref(), - &mut problems, - ); - - let integration_groups = inspect_repository_integrations( - git_available, - bare_repository, - detected_repository_root.as_deref(), - &mut problems, - ); - - let readiness = if problems - .iter() - .any(|problem| problem.severity == ProblemSeverity::Error) - { - Readiness::NotReady - } else { - Readiness::Ready - }; - - HookDoctorReport { - mode, - readiness, - state_root: global_state.state_root, - checkout_identity, - agent_trace_db, - repository_root: detected_repository_root, - hook_path_source, - hooks_directory, - config_locations: global_state.config_locations, - hooks, - integration_groups, - problems, - } -} - pub(super) fn build_report_with_lifecycle_problems( mode: DoctorMode, repository_root: &Path, @@ -224,6 +122,11 @@ fn build_report_without_service_owned_problem_checks( Vec::new() }; + let integration_targets_absent = should_show_no_integrations_message( + git_available, + bare_repository, + detected_repository_root.as_deref(), + ); let integration_groups = inspect_repository_integrations( git_available, bare_repository, @@ -243,6 +146,7 @@ fn build_report_without_service_owned_problem_checks( config_locations: global_state.config_locations, hooks, integration_groups, + integration_targets_absent, problems, } } @@ -495,6 +399,57 @@ fn inspect_repository_hooks( Vec::new() } +/// Returns `true` when the doctor was able to check for integration targets +/// and found none (neither configured in `.sce/config.json` nor detected +/// via repo-root `.opencode/` / `.claude/` directories). +fn should_show_no_integrations_message( + git_available: bool, + bare_repository: bool, + detected_repository_root: Option<&Path>, +) -> bool { + if !git_available || bare_repository { + return false; + } + let Some(root) = detected_repository_root else { + return false; + }; + resolve_doctor_integration_targets(root).is_empty() +} + +fn resolve_doctor_integration_targets(repository_root: &Path) -> Vec { + let repo_paths = RepoPaths::new(repository_root); + let config_path = repo_paths.sce_config_file(); + + // Try reading config first + if config_path.exists() { + if let Ok(raw) = std::fs::read_to_string(&config_path) { + if let Ok(config) = + parse_file_config(&raw, &config_path, ConfigPathSource::DefaultDiscoveredLocal) + { + if let Some(integrations) = config.integrations { + // integrations key present with a target property + if integrations.value.target.is_empty() { + // Empty target array — user has not recorded any integration targets + return Vec::new(); + } + // Non-empty configured targets + return integrations.value.target; + } + } + } + } + + // Fallback: no integrations config — detect installed directories + let mut detected = Vec::new(); + if repo_paths.opencode_dir().exists() { + detected.push(IntegrationTargetId::Opencode); + } + if repo_paths.claude_dir().exists() { + detected.push(IntegrationTargetId::Claude); + } + detected +} + fn inspect_repository_integrations( git_available: bool, bare_repository: bool, @@ -509,8 +464,40 @@ fn inspect_repository_integrations( return Vec::new(); }; - let integration_groups = collect_opencode_integration_groups(resolved_root); - inspect_opencode_integration_health(resolved_root, &integration_groups, problems); + let targets = resolve_doctor_integration_targets(resolved_root); + if targets.is_empty() { + problems.push(DoctorProblem { + kind: ProblemKind::NoIntegrationsInstalled, + category: ProblemCategory::RepoAssets, + severity: ProblemSeverity::Error, + fixability: ProblemFixability::ManualOnly, + summary: String::from( + "No integrations are installed. Run 'sce setup' to install OpenCode and/or Claude integration assets.", + ), + remediation: String::from( + "Run 'sce setup --opencode', 'sce setup --claude', or 'sce setup --both' to install integration assets.", + ), + next_action: "manual_steps", + }); + return Vec::new(); + } + let mut integration_groups = Vec::new(); + + for target in &targets { + match target { + IntegrationTargetId::Opencode => { + let opencode_groups = collect_opencode_integration_groups(resolved_root); + inspect_opencode_integration_health(resolved_root, &opencode_groups, problems); + integration_groups.extend(opencode_groups); + } + IntegrationTargetId::Claude => { + let claude_groups = collect_claude_integration_groups(resolved_root); + inspect_claude_integration_health(&claude_groups, problems); + integration_groups.extend(claude_groups); + } + } + } + integration_groups } @@ -731,6 +718,15 @@ fn inspect_opencode_integration_health( inspect_opencode_plugin_dependency_health(&install_targets, problems); } +fn inspect_claude_integration_health( + integration_groups: &[IntegrationGroupHealth], + problems: &mut Vec, +) { + push_claude_integration_missing_problems(integration_groups, problems); + push_claude_integration_mismatch_problems(integration_groups, problems); + push_claude_integration_read_fail_problems(integration_groups, problems); +} + fn push_opencode_integration_missing_problems( integration_groups: &[IntegrationGroupHealth], problems: &mut Vec, @@ -834,6 +830,109 @@ fn push_opencode_integration_read_fail_problems( } } +fn push_claude_integration_missing_problems( + integration_groups: &[IntegrationGroupHealth], + problems: &mut Vec, +) { + for group in integration_groups { + let missing_children = group + .children + .iter() + .filter(|child| matches!(&child.content_state, IntegrationContentState::Missing)) + .collect::>(); + if missing_children.is_empty() { + continue; + } + + let missing_paths = missing_children + .iter() + .map(|child| format!("'{}'", child.path.display())) + .collect::>() + .join(", "); + problems.push(DoctorProblem { + kind: ProblemKind::ClaudeIntegrationFilesMissing, + category: ProblemCategory::RepoAssets, + severity: ProblemSeverity::Error, + fixability: ProblemFixability::ManualOnly, + summary: format!( + "{} required file(s) are missing: {}.", + group.label, missing_paths + ), + remediation: format!( + "Reinstall repo-root Claude assets to restore the missing {} file(s), then rerun 'sce doctor'.", + group.label.to_ascii_lowercase() + ), + next_action: "manual_steps", + }); + } +} + +fn push_claude_integration_mismatch_problems( + integration_groups: &[IntegrationGroupHealth], + problems: &mut Vec, +) { + for group in integration_groups { + let mismatched_children = group + .children + .iter() + .filter(|child| matches!(&child.content_state, IntegrationContentState::Mismatch)) + .collect::>(); + if mismatched_children.is_empty() { + continue; + } + + let mismatched_paths = mismatched_children + .iter() + .map(|child| format!("'{}'", child.path.display())) + .collect::>() + .join(", "); + problems.push(DoctorProblem { + kind: ProblemKind::ClaudeIntegrationContentMismatch, + category: ProblemCategory::RepoAssets, + severity: ProblemSeverity::Error, + fixability: ProblemFixability::ManualOnly, + summary: format!( + "{} file(s) differ from the canonical embedded content: {}.", + group.label, mismatched_paths + ), + remediation: format!( + "Reinstall repo-root Claude assets to restore the canonical {} content, then rerun 'sce doctor'.", + group.label.to_ascii_lowercase() + ), + next_action: "manual_steps", + }); + } +} + +fn push_claude_integration_read_fail_problems( + integration_groups: &[IntegrationGroupHealth], + problems: &mut Vec, +) { + for group in integration_groups { + for child in &group.children { + let IntegrationContentState::ReadFailed(error) = &child.content_state else { + continue; + }; + problems.push(DoctorProblem { + kind: ProblemKind::ClaudeAssetReadFailed, + category: ProblemCategory::FilesystemPermissions, + severity: ProblemSeverity::Error, + fixability: ProblemFixability::ManualOnly, + summary: format!( + "Unable to read Claude asset '{}' at '{}': {error}", + child.relative_path, + child.path.display() + ), + remediation: format!( + "Verify that '{}' is readable before rerunning 'sce doctor'.", + child.path.display() + ), + next_action: "manual_steps", + }); + } + } +} + fn inspect_opencode_plugin_registry_health( repository_root: &Path, problems: &mut Vec, @@ -1000,16 +1099,78 @@ fn collect_opencode_integration_groups(repository_root: &Path) -> Vec Vec { + let repo_paths = RepoPaths::new(repository_root); + let claude_root = repo_paths.claude_dir(); + let embedded_assets = + iter_embedded_assets_for_setup_target(SetupTarget::Claude).collect::>(); + let mut plugin_children = Vec::new(); + let mut agent_children = Vec::new(); + let mut command_children = Vec::new(); + let mut skill_children = Vec::new(); + + for asset in embedded_assets { + let child = build_integration_child_from_asset(&claude_root, asset); + + if child.relative_path == claude_asset::SETTINGS_FILE + || child + .relative_path + .starts_with(&format!("{}/", claude_asset::HOOKS_DIR)) + { + plugin_children.push(child); + } else if child + .relative_path + .starts_with(&format!("{}/", claude_asset::AGENTS_DIR)) + { + agent_children.push(child); + } else if child + .relative_path + .starts_with(&format!("{}/", claude_asset::COMMANDS_DIR)) + { + command_children.push(child); + } else if child + .relative_path + .starts_with(&format!("{}/", claude_asset::SKILLS_DIR)) + { + skill_children.push(child); + } + } + + sort_integration_children(&mut plugin_children); + sort_integration_children(&mut agent_children); + sort_integration_children(&mut command_children); + sort_integration_children(&mut skill_children); + + vec![ + IntegrationGroupHealth { + label: CLAUDE_PLUGINS_LABEL, + children: plugin_children, + }, + IntegrationGroupHealth { + label: CLAUDE_AGENTS_LABEL, + children: agent_children, + }, + IntegrationGroupHealth { + label: CLAUDE_COMMANDS_LABEL, + children: command_children, + }, + IntegrationGroupHealth { + label: CLAUDE_SKILLS_LABEL, + children: skill_children, + }, + ] +} + fn sort_integration_children(children: &mut [IntegrationChildHealth]) { children.sort_by(|left, right| left.relative_path.cmp(&right.relative_path)); } fn build_integration_child_from_asset( - opencode_root: &Path, + integration_root: &Path, asset: &EmbeddedAsset, ) -> IntegrationChildHealth { - let path = opencode_root.join(asset.relative_path); - let content_state = inspect_opencode_asset_state(&path, &asset.sha256); + let path = integration_root.join(asset.relative_path); + let content_state = inspect_integration_asset_state(&path, &asset.sha256); IntegrationChildHealth { relative_path: asset.relative_path.to_string(), path, @@ -1033,7 +1194,7 @@ fn build_integration_child_presence_only( } } -fn inspect_opencode_asset_state( +fn inspect_integration_asset_state( path: &Path, expected_sha256: &[u8; 32], ) -> IntegrationContentState { diff --git a/cli/src/services/doctor/mod.rs b/cli/src/services/doctor/mod.rs index 4ad657c6..a6e20dce 100644 --- a/cli/src/services/doctor/mod.rs +++ b/cli/src/services/doctor/mod.rs @@ -293,12 +293,19 @@ fn doctor_problem_kind(kind: HealthProblemKind) -> ProblemKind { HealthProblemKind::RequiredHookMissing => ProblemKind::RequiredHookMissing, HealthProblemKind::HookNotExecutable => ProblemKind::HookNotExecutable, HealthProblemKind::HookContentStale => ProblemKind::HookContentStale, + HealthProblemKind::NoIntegrationsInstalled => ProblemKind::NoIntegrationsInstalled, HealthProblemKind::OpenCodeIntegrationFilesMissing => { ProblemKind::OpenCodeIntegrationFilesMissing } HealthProblemKind::OpenCodeIntegrationContentMismatch => { ProblemKind::OpenCodeIntegrationContentMismatch } + HealthProblemKind::ClaudeIntegrationFilesMissing => { + ProblemKind::ClaudeIntegrationFilesMissing + } + HealthProblemKind::ClaudeIntegrationContentMismatch => { + ProblemKind::ClaudeIntegrationContentMismatch + } HealthProblemKind::OpenCodePluginRegistryInvalid => { ProblemKind::OpenCodePluginRegistryInvalid } @@ -307,6 +314,7 @@ fn doctor_problem_kind(kind: HealthProblemKind) -> ProblemKind { } HealthProblemKind::HookReadFailed => ProblemKind::HookReadFailed, HealthProblemKind::OpenCodeAssetReadFailed => ProblemKind::OpenCodeAssetReadFailed, + HealthProblemKind::ClaudeAssetReadFailed => ProblemKind::ClaudeAssetReadFailed, HealthProblemKind::AgentTraceDbConnectionFailed => { ProblemKind::AgentTraceDbConnectionFailed } @@ -335,12 +343,19 @@ fn health_problem_kind(kind: ProblemKind) -> HealthProblemKind { ProblemKind::RequiredHookMissing => HealthProblemKind::RequiredHookMissing, ProblemKind::HookNotExecutable => HealthProblemKind::HookNotExecutable, ProblemKind::HookContentStale => HealthProblemKind::HookContentStale, + ProblemKind::NoIntegrationsInstalled => HealthProblemKind::NoIntegrationsInstalled, ProblemKind::OpenCodeIntegrationFilesMissing => { HealthProblemKind::OpenCodeIntegrationFilesMissing } ProblemKind::OpenCodeIntegrationContentMismatch => { HealthProblemKind::OpenCodeIntegrationContentMismatch } + ProblemKind::ClaudeIntegrationFilesMissing => { + HealthProblemKind::ClaudeIntegrationFilesMissing + } + ProblemKind::ClaudeIntegrationContentMismatch => { + HealthProblemKind::ClaudeIntegrationContentMismatch + } ProblemKind::OpenCodePluginRegistryInvalid => { HealthProblemKind::OpenCodePluginRegistryInvalid } @@ -349,6 +364,7 @@ fn health_problem_kind(kind: ProblemKind) -> HealthProblemKind { } ProblemKind::HookReadFailed => HealthProblemKind::HookReadFailed, ProblemKind::OpenCodeAssetReadFailed => HealthProblemKind::OpenCodeAssetReadFailed, + ProblemKind::ClaudeAssetReadFailed => HealthProblemKind::ClaudeAssetReadFailed, ProblemKind::AgentTraceDbConnectionFailed => { HealthProblemKind::AgentTraceDbConnectionFailed } diff --git a/cli/src/services/doctor/render.rs b/cli/src/services/doctor/render.rs index a81c5cd3..053ab172 100644 --- a/cli/src/services/doctor/render.rs +++ b/cli/src/services/doctor/render.rs @@ -7,11 +7,16 @@ use super::types::{ fix_result_outcome, problem_category, problem_fixability, problem_severity, FileLocationHealth, HookContentState, HookDoctorReport, HookFileHealth, HookPathSource, HumanTextStatus, IntegrationChildHealth, IntegrationContentState, IntegrationGroupHealth, ProblemKind, - ProblemSeverity, Readiness, OPENCODE_AGENTS_LABEL, OPENCODE_COMMANDS_LABEL, - OPENCODE_PLUGINS_LABEL, OPENCODE_SKILLS_LABEL, + 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, }; use super::{DoctorExecution, DoctorFormat, DoctorMode, DoctorRequest, NAME, REQUIRED_HOOKS}; +/// Guidance message rendered in the Integrations section when no integration +/// targets are configured, detected, or both. +const NO_INTEGRATIONS_MESSAGE: &str = "No integrations installed; run 'sce setup'"; + pub(super) fn render_report(request: DoctorRequest, execution: &DoctorExecution) -> Result { match request.format { DoctorFormat::Text => Ok(format_execution(execution)), @@ -107,20 +112,29 @@ fn format_report_with_color_policy(report: &HookDoctorReport, color_enabled: boo push_git_hooks_section(report, color_enabled, &mut lines); lines.push(format!("\n{}:", heading("Integrations"))); - for group in integration_groups_for_text(report) { + if report.integration_targets_absent { lines.push(format_human_text_row( color_enabled, - integration_group_status(&group, report.repository_root.is_some()), - group.label, + HumanTextStatus::Fail, + NO_INTEGRATIONS_MESSAGE, "", )); - for child in &group.children { - lines.push(format_human_text_child_row( + } else { + for group in integration_groups_for_text(report) { + lines.push(format_human_text_row( color_enabled, - integration_child_status(child, report.repository_root.is_some()), - &child.relative_path, - integration_child_detail(child), + integration_group_status(&group, report.repository_root.is_some()), + group.label, + "", )); + for child in &group.children { + lines.push(format_human_text_child_row( + color_enabled, + integration_child_status(child, report.repository_root.is_some()), + &child.relative_path, + integration_child_detail(child), + )); + } } } @@ -362,6 +376,22 @@ fn integration_groups_for_text(report: &HookDoctorReport) -> Vec, pub(super) hooks: Vec, pub(super) integration_groups: Vec, + pub(super) integration_targets_absent: bool, pub(super) problems: Vec, } @@ -128,12 +133,16 @@ pub(crate) enum ProblemKind { RequiredHookMissing, HookNotExecutable, HookContentStale, + NoIntegrationsInstalled, OpenCodeIntegrationFilesMissing, OpenCodeIntegrationContentMismatch, + ClaudeIntegrationFilesMissing, + ClaudeIntegrationContentMismatch, OpenCodePluginRegistryInvalid, OpenCodeAssetMissingOrInvalid, HookReadFailed, OpenCodeAssetReadFailed, + ClaudeAssetReadFailed, AgentTraceDbConnectionFailed, AgentTraceDbSchemaNotReady, } diff --git a/cli/src/services/lifecycle.rs b/cli/src/services/lifecycle.rs index da6afffd..dc2c93d4 100644 --- a/cli/src/services/lifecycle.rs +++ b/cli/src/services/lifecycle.rs @@ -49,12 +49,16 @@ pub enum HealthProblemKind { RequiredHookMissing, HookNotExecutable, HookContentStale, + NoIntegrationsInstalled, OpenCodeIntegrationFilesMissing, OpenCodeIntegrationContentMismatch, + ClaudeIntegrationFilesMissing, + ClaudeIntegrationContentMismatch, OpenCodePluginRegistryInvalid, OpenCodeAssetMissingOrInvalid, HookReadFailed, OpenCodeAssetReadFailed, + ClaudeAssetReadFailed, AgentTraceDbConnectionFailed, AgentTraceDbSchemaNotReady, } @@ -111,8 +115,8 @@ pub struct SetupOutcome { pub required_hooks_install: Option, } -#[allow(dead_code)] pub trait ServiceLifecycle: Send + Sync { + #[allow(dead_code)] fn id(&self) -> LifecycleProviderId; fn diagnose(&self, _ctx: &C) -> Vec { diff --git a/cli/src/services/setup/mod.rs b/cli/src/services/setup/mod.rs index eb0164a4..721227a1 100644 --- a/cli/src/services/setup/mod.rs +++ b/cli/src/services/setup/mod.rs @@ -1,4 +1,5 @@ use anyhow::{bail, Context, Result}; +use serde_json::json; use std::{ fs, path::{Path, PathBuf}, @@ -187,6 +188,14 @@ pub fn run_setup_for_mode(repository_root: &Path, mode: SetupMode) -> Result &'static [SetupTarget } } +/// Convert a concrete [`SetupTarget`] (not `Both`) to its canonical +/// `integrations.target` string representation. +fn integration_target_id_str(target: SetupTarget) -> &'static str { + match target { + SetupTarget::OpenCode => "opencode", + SetupTarget::Claude => "claude", + SetupTarget::Both => { + unreachable!("integration_target_id_str must not be called with SetupTarget::Both") + } + } +} + +/// Persist a successfully installed setup target into the repo-local config file. +/// +/// Reads the existing `.sce/config.json`, merges the new concrete target(s) into +/// `integrations.target` (deduped, preserving existing unrelated fields), +/// and writes the file back. Creates the file with the bootstrap payload +/// if it does not already exist. +pub fn persist_integration_targets(repository_root: &Path, target: SetupTarget) -> Result<()> { + let repo_paths = RepoPaths::new(repository_root); + let config_file = repo_paths.sce_config_file(); + + // Read existing config or start with bootstrap payload. + let raw = if config_file.exists() { + fs::read_to_string(&config_file) + .with_context(|| format!("Failed to read config file '{}'", config_file.display()))? + } else { + bootstrap_repo_local_config(repository_root)?; + fs::read_to_string(&config_file) + .with_context(|| format!("Failed to read config file '{}'", config_file.display()))? + }; + + let mut config: serde_json::Value = serde_json::from_str(&raw).with_context(|| { + format!( + "Config file '{}' must contain valid JSON.", + config_file.display() + ) + })?; + + let config_obj = config.as_object_mut().with_context(|| { + format!( + "Config file '{}' must contain a top-level JSON object.", + config_file.display() + ) + })?; + + // Collect existing integration target values, if any. + let mut existing_targets: Vec = config_obj + .get("integrations") + .and_then(|i| i.get("target")) + .and_then(|t| t.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + + // Add new concrete targets (expanding Both), deduping as we go. + let new_targets = concrete_targets_for(target); + for concrete in new_targets { + let id_str = integration_target_id_str(*concrete); + let id_owned = id_str.to_string(); + if !existing_targets.contains(&id_owned) { + existing_targets.push(id_owned); + } + } + + // Write the merged integrations block back. + config_obj.insert( + "integrations".to_string(), + json!({ "target": existing_targets }), + ); + + let updated = serde_json::to_string_pretty(&config).with_context(|| { + format!( + "Failed to serialize updated config for '{}'", + config_file.display() + ) + })? + "\n"; + + fs::write(&config_file, updated) + .with_context(|| format!("Failed to write config file '{}'", config_file.display()))?; + + Ok(()) +} + mod install { use anyhow::{bail, Context, Result}; use std::{ diff --git a/config/.opencode/plugins/sce-agent-trace.ts b/config/.opencode/plugins/sce-agent-trace.ts index 4a74bd3a..980ceb06 100644 --- a/config/.opencode/plugins/sce-agent-trace.ts +++ b/config/.opencode/plugins/sce-agent-trace.ts @@ -337,9 +337,7 @@ async function runDiffTraceHook( child.on("error", (err: NodeJS.ErrnoException) => { if (err.code === "ENOENT") { - console.warn( - `sce CLI not found. Install it from ${SCE_INSTALL_URL}`, - ); + console.warn(`sce CLI not found. Install it from ${SCE_INSTALL_URL}`); } resolve(); }); @@ -364,9 +362,7 @@ async function runConversationTraceHook( child.on("error", (err: NodeJS.ErrnoException) => { if (err.code === "ENOENT") { - console.warn( - `sce CLI not found. Install it from ${SCE_INSTALL_URL}`, - ); + console.warn(`sce CLI not found. Install it from ${SCE_INSTALL_URL}`); } resolve(); }); diff --git a/config/.opencode/plugins/sce-bash-policy.ts b/config/.opencode/plugins/sce-bash-policy.ts index 788929f3..0b2b8d9c 100644 --- a/config/.opencode/plugins/sce-bash-policy.ts +++ b/config/.opencode/plugins/sce-bash-policy.ts @@ -32,9 +32,7 @@ function evaluateBashCommandPolicy(command: string): JsonPolicyResult | null { if (result.error) { if ((result.error as NodeJS.ErrnoException).code === "ENOENT") { - console.warn( - `sce CLI not found. Install it from ${SCE_INSTALL_URL}`, - ); + console.warn(`sce CLI not found. Install it from ${SCE_INSTALL_URL}`); } return null; } diff --git a/config/automated/.opencode/plugins/sce-agent-trace.ts b/config/automated/.opencode/plugins/sce-agent-trace.ts index 4a74bd3a..980ceb06 100644 --- a/config/automated/.opencode/plugins/sce-agent-trace.ts +++ b/config/automated/.opencode/plugins/sce-agent-trace.ts @@ -337,9 +337,7 @@ async function runDiffTraceHook( child.on("error", (err: NodeJS.ErrnoException) => { if (err.code === "ENOENT") { - console.warn( - `sce CLI not found. Install it from ${SCE_INSTALL_URL}`, - ); + console.warn(`sce CLI not found. Install it from ${SCE_INSTALL_URL}`); } resolve(); }); @@ -364,9 +362,7 @@ async function runConversationTraceHook( child.on("error", (err: NodeJS.ErrnoException) => { if (err.code === "ENOENT") { - console.warn( - `sce CLI not found. Install it from ${SCE_INSTALL_URL}`, - ); + console.warn(`sce CLI not found. Install it from ${SCE_INSTALL_URL}`); } resolve(); }); diff --git a/config/automated/.opencode/plugins/sce-bash-policy.ts b/config/automated/.opencode/plugins/sce-bash-policy.ts index 788929f3..0b2b8d9c 100644 --- a/config/automated/.opencode/plugins/sce-bash-policy.ts +++ b/config/automated/.opencode/plugins/sce-bash-policy.ts @@ -32,9 +32,7 @@ function evaluateBashCommandPolicy(command: string): JsonPolicyResult | null { if (result.error) { if ((result.error as NodeJS.ErrnoException).code === "ENOENT") { - console.warn( - `sce CLI not found. Install it from ${SCE_INSTALL_URL}`, - ); + console.warn(`sce CLI not found. Install it from ${SCE_INSTALL_URL}`); } return null; } diff --git a/config/pkl/base/sce-config-schema.pkl b/config/pkl/base/sce-config-schema.pkl index 36622300..beb20335 100644 --- a/config/pkl/base/sce-config-schema.pkl +++ b/config/pkl/base/sce-config-schema.pkl @@ -163,6 +163,20 @@ local sceConfigSchema = new JsonSchema { } } } + ["integrations"] = new JsonSchema { + type = "object" + additionalProperties = false + properties { + ["target"] = new JsonSchema { + type = "array" + uniqueItems = true + items = new JsonSchema { + type = "string" + enum = new { "opencode"; "claude" } + } + } + } + } } dependentRequired { ["log_file_mode"] = new { "log_file" } diff --git a/config/schema/sce-config.schema.json b/config/schema/sce-config.schema.json index 43d7605b..23966f09 100644 --- a/config/schema/sce-config.schema.json +++ b/config/schema/sce-config.schema.json @@ -338,6 +338,23 @@ } }, "additionalProperties": false + }, + "integrations": { + "type": "object", + "properties": { + "target": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "opencode", + "claude" + ] + }, + "uniqueItems": true + } + }, + "additionalProperties": false } }, "additionalProperties": false, diff --git a/context/cli/cli-command-surface.md b/context/cli/cli-command-surface.md index 03aab9c1..f3b83479 100644 --- a/context/cli/cli-command-surface.md +++ b/context/cli/cli-command-surface.md @@ -63,7 +63,7 @@ Deferred or gated command surfaces currently avoid claiming unimplemented behavi `setup` now also exposes compile-time embedded config assets for OpenCode/Claude targets, sourced from the generated `config/.opencode/**` and `config/.claude/**` 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/` (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` 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` into the `trace` group (`sce trace db list`, `sce trace status`, `sce trace status --all`); 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 checkout/global 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 integration presence for `plugins`, `agents`, `commands`, and `skills`. 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. +`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` into the `trace` group (`sce trace db list`, `sce trace status`, `sce trace status --all`); 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 checkout/global 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 and Claude 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`. ## Command loop and error model @@ -88,7 +88,7 @@ A user-invocable `sync` command is not wired in the current CLI surface; local D - `cli/src/services/setup/mod.rs` defines setup parsing/selection contracts plus runtime install orchestration (`run_setup_for_mode`) over the embedded asset install engine; `cli/src/services/setup/command.rs` owns the setup runtime command handler. Setup now aggregates `ServiceLifecycle::setup` calls across registered providers (`config`, `local_db`, `auth_db`, `agent_trace_db`, `hooks`) in order, using a `ContextWithRepoRoot`-scoped context with resolved repository root. - `cli/src/services/setup/mod.rs` now keeps its larger internal responsibilities behind focused inline support modules: `install` owns repository canonicalization, staging/swap install flows, required-hook installation, and repo/writeability guards, while `prompt` owns interactive target selection and styled prompt labels. - `cli/src/services/config/mod.rs` defines config parser/runtime contracts (`show`, `validate`, `--help`), strict config-file key/type validation, deterministic text/JSON rendering, repo-configured bash-policy preset/custom validation and reporting under `policies.bash`, and shared auth-key metadata that declares env key, config-file key, and optional baked-default eligibility for supported auth runtime values starting with `workos_client_id` (`WORKOS_CLIENT_ID` vs `workos_client_id`); auth-key provenance/preference metadata stays on `show`, while `validate` stays trimmed to validation status plus issues/warnings. `cli/src/services/config/lifecycle.rs` implements `ServiceLifecycle` for config health checks and setup (global/local config validation and repo-local config bootstrap). -- `cli/src/services/doctor/mod.rs` defines the implemented doctor request/report contract (`DoctorRequest`, `DoctorAction`, `DoctorMode`, `run_doctor`) while focused submodules under `cli/src/services/doctor/` handle runtime command dispatch (`command.rs`), diagnosis (`inspect.rs`), rendering (`render.rs`), fix execution (`fixes.rs`), and doctor-owned domain types (`types.rs`). Together they preserve explicit fix-mode parsing, checkout-database discovery, stable text/JSON problem and database-record rendering, deterministic fix-result reporting, and aggregation of `ServiceLifecycle::diagnose`/`ServiceLifecycle::fix` across registered providers (`config`, `local_db`, `auth_db`, `agent_trace_db`, `hooks`). The doctor module coordinates state-root/config/database reporting and validation, path-source detection plus required-hook presence/executable/content checks when a repository target is detected, repo-root installed OpenCode integration presence inventory for `plugins`, `agents`, `commands`, and `skills` derived from the embedded OpenCode setup asset catalog, shared-style bracketed human status token rendering (`[PASS]`, `[FAIL]`, `[MISS]`) with simplified `label (path)` text rows, and repair-mode delegation to service-owned fix implementations. +- `cli/src/services/doctor/mod.rs` defines the implemented doctor request/report contract (`DoctorRequest`, `DoctorAction`, `DoctorMode`, `run_doctor`) while focused submodules under `cli/src/services/doctor/` handle runtime command dispatch (`command.rs`), diagnosis (`inspect.rs`), rendering (`render.rs`), fix execution (`fixes.rs`), and doctor-owned domain types (`types.rs`). Together they preserve explicit fix-mode parsing, checkout-database discovery, stable text/JSON problem and database-record rendering, deterministic fix-result reporting, and aggregation of `ServiceLifecycle::diagnose`/`ServiceLifecycle::fix` across registered providers (`config`, `local_db`, `auth_db`, `agent_trace_db`, `hooks`). The doctor module coordinates state-root/config/database reporting and validation, path-source detection plus required-hook presence/executable/content checks when a repository target is detected, repo-root installed OpenCode and Claude integration inventory derived from embedded setup asset catalogs, shared-style bracketed human status token rendering (`[PASS]`, `[FAIL]`, `[MISS]`) with simplified `label (path)` text rows, and repair-mode delegation to service-owned fix implementations. Claude grouping is path-based: `settings.json`/`hooks/**` as `ClaudeCode plugins` (including `.claude/hooks/run-sce-or-show-install-guidance.sh`), plus `ClaudeCode agents`, `ClaudeCode commands`, and `ClaudeCode skills`. - `cli/src/services/version/mod.rs` defines the version parser/output contract (`parse_version_request`, `render_version`) with deterministic text/JSON output modes; `cli/src/services/version/command.rs` owns the version runtime command handler. - `cli/src/services/completion/mod.rs` defines the completion output contract (`render_completion`) using clap_complete to generate deterministic shell scripts for Bash, Zsh, and Fish; `cli/src/services/completion/command.rs` owns the completion runtime command handler. - `cli/src/services/hooks/mod.rs` defines production local hook runtime parsing/dispatch (`HookSubcommand`, `run_hooks_subcommand`) for `pre-commit`, `commit-msg`, `post-commit`, `post-rewrite`, `diff-trace`, and `conversation-trace`; `cli/src/services/hooks/command.rs` owns the hook runtime command handler. Current runtime behavior is commit-msg-only attribution behind the enabled-by-default attribution gate with explicit opt-out controls; `pre-commit` and `post-rewrite` are deterministic no-ops; `post-commit` requires validated `--remote-url`, threads that value through Agent Trace flow, prints it to stderr, and remains an active intersection + Agent Trace DB persistence path; `diff-trace` performs STDIN JSON intake, required-field validation, and best-effort AgentTraceDb insertion with tool-prefixed stored `session_id` values plus direct nullable `model_id` / `tool_version` attribution. Claude structured `PostToolUse` payloads may derive `model_id` from top-level or nested `model` metadata with `claude/` prefix normalization. `session-model` is no longer a supported hooks route. `cli/src/services/hooks/lifecycle.rs` implements `ServiceLifecycle` for hook health checks, fix, and setup (hook rollout integrity and required-hook installation). diff --git a/context/cli/config-precedence-contract.md b/context/cli/config-precedence-contract.md index 7a76ad22..a2689941 100644 --- a/context/cli/config-precedence-contract.md +++ b/context/cli/config-precedence-contract.md @@ -61,7 +61,7 @@ When a default-discovered global or repo-local config file exists but fails JSON - Startup/runtime config resolution now degrades gracefully only for default-discovered files: invalid discovered files are skipped and reported via collected `validation_errors`, while explicit `--config` / `SCE_CONFIG_FILE` targets still fail immediately on the same parse or validation errors. - Config file content must be valid JSON with a top-level object. -- Allowed keys: `$schema`, `log_level`, `log_format`, `log_file`, `log_file_mode`, `timeout_ms`, `workos_client_id`, `policies`. +- Allowed keys: `$schema`, `log_level`, `log_format`, `log_file`, `log_file_mode`, `timeout_ms`, `workos_client_id`, `policies`, `integrations`. - Unknown keys fail validation. - `log_level` must be one of `error|warn|info|debug`. - `log_format` must be `text` or `json` when present. @@ -71,6 +71,11 @@ When a default-discovered global or repo-local config file exists but fails JSON - `timeout_ms` must be an unsigned integer. - `workos_client_id` must be a string when present. +- `integrations` must be an object when present and currently allows only `target`. +- `integrations.target` must be an array of unique canonical target IDs when present. +- Supported target ID values: `opencode`, `claude`. +- Unknown target IDs fail schema validation. + - `policies` must be an object when present and currently allows `attribution_hooks`, `database_retry`, and `bash`. - `policies.attribution_hooks` must be an object when present and currently allows `enabled`; the generated schema documents default `true`, and explicit `enabled: false` remains a valid opt-out alongside the runtime `SCE_ATTRIBUTION_HOOKS_DISABLED` environment opt-out. - `policies.bash` must be an object when present and currently allows only `presets` and `custom`. diff --git a/context/context-map.md b/context/context-map.md index 49d9f1fd..f1941732 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -34,13 +34,13 @@ Feature/domain context: - `context/sce/agent-trace-commit-msg-coauthor-policy.md` (current commit-msg canonical co-author trailer policy with enabled-by-default attribution hooks, explicit opt-out controls, `SCE_DISABLED` kill switch, caller-provided `ai_contribution_present` transformer seam wired from staged-diff AI-overlap preflight, idempotent dedupe, the `agent_trace::patches_have_overlap` pure overlap seam, the `StagedDiffAiOverlapResult` three-valued evidence gate, and `sce.hooks.commit_msg.ai_overlap_error` error logging) - `context/sce/agent-trace-post-commit-dual-write.md` (historical post-commit no-op/dual-write reference; current post-commit behavior is documented in `agent-trace-hooks-command-routing.md`) - `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, 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, and OpenCode integration group rendering rules) +- `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, no-installed-integrations guidance, and OpenCode plus Claude integration group rendering rules) - `context/sce/setup-githooks-install-contract.md` (T01 canonical `sce setup --hooks` install contract for target-path resolution, 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-githooks-hook-asset-packaging.md` (T02 compile-time `sce setup --hooks` required-hook template packaging contract, including current post-commit missing-`sce` install guidance, origin remote lookup, remote-URL forwarding/fallback behavior, and setup-service accessor surface) - `context/sce/setup-githooks-install-flow.md` (T03 setup-service required-hook install orchestration with git-truth hooks-path resolution, 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) -- `context/sce/setup-repo-local-config-bootstrap.md` (setup local bootstrap behavior: repo-local `.sce/config.json` create-if-missing via config lifecycle plus lifecycle-owned local DB initialization before hooks/config asset dispatch) +- `context/sce/setup-repo-local-config-bootstrap.md` (setup local bootstrap behavior: repo-local `.sce/config.json` create-if-missing via config lifecycle, additive `integrations.target` persistence after successful target installs, plus lifecycle-owned local DB initialization before hooks/config asset dispatch) - `context/sce/cli-security-hardening-contract.md` (T06 CLI redaction contract, setup `--repo` canonicalization/validation, and setup write-permission probe behavior) - `context/sce/agent-trace-post-rewrite-local-remap-ingestion.md` (current post-rewrite no-op baseline plus historical remap-ingestion reference) - `context/sce/agent-trace-rewrite-trace-transformation.md` (current post-rewrite no-op baseline plus historical rewrite-transformation reference) diff --git a/context/glossary.md b/context/glossary.md index 116fd772..f9caa13c 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -141,7 +141,7 @@ - `agent trace commit-msg co-author policy`: Current contract in `cli/src/services/hooks/mod.rs` (`apply_commit_msg_coauthor_policy`) that applies exactly one canonical trailer (`Co-authored-by: SCE `) only when attribution hooks are enabled, SCE is not disabled, and the staged-diff AI-overlap preflight confirms AI/editor evidence (`StagedDiffAiOverlapResult::Overlap`); `NoOverlap` and `Error` both suppress the trailer, with `Error` logged via `sce.hooks.commit_msg.ai_overlap_error`; duplicate canonical trailers are deduped idempotently. - `local DB migration contract`: `cli/src/services/local_db/mod.rs` delegates migration execution to `TursoDb` through the `DbSpec::migrations()` contract. The current `LocalDbSpec` migration list is empty, so `LocalDb::new()` opens/creates the canonical local DB without creating local tables. - `hook no-op baseline`: Current `cli/src/services/hooks/mod.rs` runtime posture where `pre-commit` and `post-rewrite` return deterministic no-op status text, `commit-msg` is a gated mutating path, `post-commit` persists intersections and built Agent Trace payloads without post-commit file artifacts, `diff-trace` validates STDIN payloads, uses only direct payload `model_id` and `tool_version` (session-model fallback removed), applies stored `session_id` prefixes (`oc_`/`cc_`), and inserts DB-only AgentTraceDb rows, and `conversation-trace` is the active message/part intake path. `session-model` is no longer a supported hook route. -- `sce doctor` operator-health contract: `cli/src/services/doctor/mod.rs` is the stable doctor entrypoint, with focused `doctor/{inspect,render,fixes,types}.rs` submodules implementing the current approved operator-health surface in `context/sce/agent-trace-hook-doctor.md`: `sce doctor --fix` selects repair intent, `sce doctor dbs` lists registered checkouts, help/output expose deterministic doctor mode/action, JSON includes stable problem taxonomy/fixability fields plus checkout/database records and fix-result records, the runtime validates state-root resolution, global and repo-local `sce/config.json` readability/schema health, local DB and checkout/global Agent Trace DB path/health, DB-parent readiness barriers, git availability, non-repo vs bare-repo targeting failures, effective hook-path source resolution, required hook presence/executable/content drift against canonical embedded hook assets, and repo-root installed OpenCode integration presence for `OpenCode plugins`, `OpenCode agents`, `OpenCode commands`, and `OpenCode skills`. Human text mode uses the approved sectioned layout (`Environment`, `Configuration` with checkout identity + Agent Trace checkout DB rows when available, `Repository`, `Git Hooks`, `Integrations`), `SCE doctor diagnose` / `SCE doctor fix` headers, bracketed `[PASS]`/`[FAIL]`/`[MISS]` status tokens with shared-style green/red colorization when enabled, simplified `label (path)` row formatting, top-level-only hook rows, and presence-only integration parent/child rows where missing required files surface as `[MISS]` children and `[FAIL]` parent groups. Fix mode reuses canonical setup hook installation for missing/stale/non-executable required hooks and missing hooks directories and can bootstrap canonical missing SCE-owned DB parent directories. +- `sce doctor` operator-health contract: `cli/src/services/doctor/mod.rs` is the stable doctor entrypoint, with focused `doctor/{inspect,render,fixes,types}.rs` submodules implementing the current approved operator-health surface in `context/sce/agent-trace-hook-doctor.md`: `sce doctor --fix` selects repair intent, checkout DB discovery lives under `sce trace`, and output exposes deterministic doctor mode, readiness, stable problem taxonomy/fixability fields, checkout/database records, and fix-result records. The runtime validates state-root resolution, global and repo-local `sce/config.json` readability/schema health, local DB and checkout/global Agent Trace DB path/health, DB-parent readiness barriers, git availability, non-repo vs bare-repo targeting failures, effective hook-path source resolution, required hook presence/executable/content drift against canonical embedded hook assets, and repo-root installed OpenCode plus Claude integration content health. Human text mode uses the approved sectioned layout (`Environment`, `Configuration` with checkout identity + Agent Trace checkout DB rows when available, `Repository`, `Git Hooks`, `Integrations`), `SCE doctor diagnose` / `SCE doctor fix` headers, bracketed `[PASS]`/`[FAIL]`/`[MISS]` status tokens with shared-style green/red colorization when enabled, simplified `label (path)` row formatting, top-level-only hook rows, and integration parent/child rows where missing files surface as `[MISS]`, mismatches/read failures as `[FAIL]`, and affected parent groups as `[FAIL]`. Current integration groups are `OpenCode plugins`, `OpenCode agents`, `OpenCode commands`, `OpenCode skills`, `ClaudeCode plugins`, `ClaudeCode agents`, `ClaudeCode commands`, and `ClaudeCode skills`; Claude `settings.json` plus `hooks/**` belong to `ClaudeCode plugins`, including `.claude/hooks/run-sce-or-show-install-guidance.sh`. Fix mode reuses canonical setup hook installation for missing/stale/non-executable required hooks and missing hooks directories and can bootstrap canonical missing SCE-owned DB parent directories. - `cli warnings-denied lint policy`: `cli/Cargo.toml` sets `warnings = "deny"`, so plain `cargo clippy --manifest-path cli/Cargo.toml` already fails on warnings without needing an extra `-- -D warnings` tail. - `agent trace local DB schema migration contract`: Retired `apply_core_schema_migrations` behavior removed from the current runtime during `agent-trace-removal-and-hook-noop-reset` T01; the local DB baseline is now file open/create only. - `agent trace removed local-hook paths`: Current-state shorthand for the removed local-hook runtime behaviors that are no longer active: staged-checkpoint persistence, post-commit dual-write, post-rewrite remap ingestion, rewrite trace transformation, and retry replay. diff --git a/context/sce/agent-trace-hook-doctor.md b/context/sce/agent-trace-hook-doctor.md index b85467e1..4f1cc544 100644 --- a/context/sce/agent-trace-hook-doctor.md +++ b/context/sce/agent-trace-hook-doctor.md @@ -41,9 +41,10 @@ The runtime in `cli/src/services/doctor/mod.rs` exposes the approved doctor comm - top-level-only human text hook rows for `pre-commit`, `commit-msg`, and `post-commit`, with nested `content` / `executable` detail removed from text mode - required hook presence and executable permissions for `pre-commit`, `commit-msg`, and `post-commit` when repo-scoped checks apply (delegated to `HooksLifecycle::diagnose`) - byte-for-byte stale-content detection for required hook payloads against canonical embedded SCE-managed hook assets (delegated to `HooksLifecycle::diagnose`) -- repo-root installed OpenCode integration inventory for `OpenCode plugins`, `OpenCode agents`, `OpenCode commands`, and `OpenCode skills` -- integration child-row reporting for those four groups now validates file content against embedded SHA-256; missing files render as `[MISS]`, content mismatches render as `[FAIL]`, and any affected parent group renders as `[FAIL]` -- repo-root OpenCode plugin inventory includes the installed manifest file plus plugin/preset artifacts as required presence-only files; generated `config/.opencode/**` trees are not inspected by doctor +- integration target resolution that reads `integrations.target` from repo-local `.sce/config.json` when present, or falls back to detecting repo-root `.opencode/` and `.claude/` directories when config has no `integrations` or `integrations.target`; only the resolved targets are inspected +- repo-root installed OpenCode integration inventory for `OpenCode plugins`, `OpenCode agents`, `OpenCode commands`, and `OpenCode skills`, plus Claude integration inventory for `ClaudeCode plugins`, `ClaudeCode agents`, `ClaudeCode commands`, and `ClaudeCode skills`, scoped to the resolved targets +- integration child-row reporting validates installed files against embedded SHA-256 content; missing files render as `[MISS]`, content mismatches render as `[FAIL]`, and any affected parent group renders as `[FAIL]` +- OpenCode plugin inventory includes the installed manifest file plus plugin/preset artifacts as required presence-only files; Claude groups are derived from embedded `.claude` assets (`settings.json` and `hooks/**` under `ClaudeCode plugins`, including `.claude/hooks/run-sce-or-show-install-guidance.sh`, then `agents/**`, `commands/**`, and `skills/**`); generated `config/.opencode/**` and `config/.claude/**` trees are not inspected by doctor - repair-mode delegation to `ServiceLifecycle::fix` implementations: `HooksLifecycle::fix` reuses `install_required_git_hooks` for missing hooks directories plus missing, stale, or non-executable required hooks; `LocalDbLifecycle::fix`, `AuthDbLifecycle::fix`, and `AgentTraceDbLifecycle::fix` handle bootstrap of missing canonical SCE-owned DB parent directories ## Approved human text-mode contract diff --git a/context/sce/doctor-human-text-contract.md b/context/sce/doctor-human-text-contract.md index b6cdcfae..bafe7792 100644 --- a/context/sce/doctor-human-text-contract.md +++ b/context/sce/doctor-human-text-contract.md @@ -44,16 +44,34 @@ This simplification is text-mode only and does not change JSON output requiremen ## Integrations text contract -Human text output for `Integrations` must use exactly these groups: +Integration checks are target-scoped. The doctor resolves which integration targets to inspect using the following priority: + +1. **Configured targets**: If `.sce/config.json` has `integrations.target` with a non-empty array, only the listed targets (`opencode`, `claude`) are inspected. +2. **Empty target array**: If `integrations.target` exists but is an empty array `[]`, the user has not recorded any integration targets. The doctor returns no targets and renders a guidance message instead of group rows. +3. **Directory detection fallback**: When config has no `integrations` property or `integrations.target` property is absent, the doctor falls back to detecting installed repo-root directories — `.opencode/` is detected as OpenCode, `.claude/` is detected as Claude. +4. **No targets**: When directory detection identifies no installed directories either, the `Integrations` section renders `[FAIL] No integrations installed; run 'sce setup'` and a blocking `NoIntegrationsInstalled` problem is recorded, so the Summary counts it as a blocking problem. + +Human text output renders group rows only for the resolved targets: - `OpenCode plugins` - `OpenCode agents` - `OpenCode commands` - `OpenCode skills` +- `ClaudeCode plugins` +- `ClaudeCode agents` +- `ClaudeCode commands` +- `ClaudeCode skills` Integration checks for this contract inspect installed repo-root artifacts only. -They validate file presence and content hashes against embedded OpenCode assets. -Generated `config/.opencode/**` trees are out of scope for doctor integration checks in this change stream. +They validate file presence and content hashes against embedded OpenCode and Claude setup assets. +Generated `config/.opencode/**` and `config/.claude/**` trees are out of scope for doctor integration checks in this change stream. + +Claude installed assets are grouped by repo-root `.claude/` relative path: + +- `settings.json` and `hooks/**` -> `ClaudeCode plugins` (including `hooks/run-sce-or-show-install-guidance.sh`) +- `agents/**` -> `ClaudeCode agents` +- `commands/**` -> `ClaudeCode commands` +- `skills/**` -> `ClaudeCode skills` For `agents`, `commands`, and `skills`, the installed repo-root trees are required inventory. If any required file in an integration group is missing or mismatched: @@ -71,7 +89,6 @@ Integration child rows render as `[STATUS] relative/path (absolute/path)` in tex - no JSON output shape or semantic changes - no `sce doctor --fix` behavior changes -- no Claude integration content validation -- no new integration group names +- no Claude plugin registry or preset-catalog checks See also: [doctor operator contract](agent-trace-hook-doctor.md), [CLI command surface](../cli/cli-command-surface.md). diff --git a/context/sce/setup-githooks-cli-ux.md b/context/sce/setup-githooks-cli-ux.md index 34e81775..d4f2fc8d 100644 --- a/context/sce/setup-githooks-cli-ux.md +++ b/context/sce/setup-githooks-cli-ux.md @@ -36,6 +36,20 @@ Target-install mode contract: - legacy one-purpose invocations remain valid (`sce setup --hooks` for hooks-only, and `sce setup --opencode|--claude|--both` for config-only) - interactive setup without a TTY returns actionable guidance to rerun with `--non-interactive` plus a target flag +## Integration target persistence + +Non-interactive `--opencode`, `--claude`, and `--both` target installs persist the selected target(s) into `.sce/config.json` under `integrations.target` after successful config asset installation: + +- `--opencode` records `["opencode"]`. +- `--claude` adds `"claude"` to an existing array (e.g. `["opencode"]` → `["opencode", "claude"]`). +- `--both` records both `["opencode", "claude"]` atomically. +- Repeated runs are idempotent — existing targets are deduplicated; previously unrelated config keys (`$schema`, `log_level`, etc.) are preserved. +- If the config file does not exist, it is bootstrapped first, then the targets are written. +- `--hooks` only setup (`sce setup --hooks`) does not modify `integrations.target`. +- Interactive `sce setup` (no target flag) persists the interactively selected target(s) the same way as non-interactive flag equivalents. + +Hooks-only setup and failed installs do not update `integrations.target`. + ## Output contract Successful hook setup emits deterministic human/automation-friendly output including: diff --git a/context/sce/setup-repo-local-config-bootstrap.md b/context/sce/setup-repo-local-config-bootstrap.md index 4abcb5be..30f2849b 100644 --- a/context/sce/setup-repo-local-config-bootstrap.md +++ b/context/sce/setup-repo-local-config-bootstrap.md @@ -13,9 +13,20 @@ Task `setup-repo-gate-and-local-config-bootstrap` T02 and `turso-local-db-sync` - The setup flow also bootstraps the canonical local DB through `LocalDbLifecycle::setup` and the Agent Trace DB through `AgentTraceDbLifecycle::setup`; both use the shared `TursoDb` adapter. - The bootstrap runs after the git-repo gate (`ensure_git_repository`) and before config/hooks dispatch, so it applies to all setup modes: config-only, hooks-only, combined, and interactive. +## Post-install integration target persistence + +After config asset installation succeeds for a non-interactive target (`--opencode`, `--claude`, or `--both`), setup persists the selected target(s) into `.sce/config.json` under `integrations.target`: + +- `--opencode` records `["opencode"]`. +- `--claude` adds `"claude"` to an existing array (e.g. `["opencode"]` → `["opencode", "claude"]`). +- `--both` records both `["opencode", "claude"]` atomically. +- Repeated runs are idempotent — existing targets are deduplicated; previously unrelated config keys (`$schema`, `log_level`, etc.) are preserved. +- If the config file does not exist, it is bootstrapped first, then the targets are written. +- `--hooks` only setup does not modify `integrations.target`. + ## Implementation -- `cli/src/services/setup/mod.rs` exports `bootstrap_repo_local_config(repository_root: &Path) -> Result<()>`. +- `cli/src/services/setup/mod.rs` exports `bootstrap_repo_local_config(repository_root: &Path) -> Result<()>` and `persist_integration_targets(repository_root: &Path, target: SetupTarget) -> Result<()>`. - `cli/src/services/local_db/lifecycle.rs` implements `LocalDbLifecycle::setup()` for local DB initialization. - `cli/src/services/agent_trace_db/lifecycle.rs` implements `AgentTraceDbLifecycle::setup()` for Agent Trace DB initialization. - The function uses `RepoPaths::sce_config_file()` and `RepoPaths::sce_dir()` from `default_paths` for path resolution.