diff --git a/README.md b/README.md index d0b68d2053..40190fb867 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,7 @@ tracedecay status tracedecay install --agent claude tracedecay install --agent codex tracedecay install --agent cursor +tracedecay install --agent devin tracedecay install --agent gemini tracedecay install --agent hermes ``` @@ -74,6 +75,7 @@ Project-local setup: ```bash tracedecay install --local --agent cursor tracedecay install --local --agent codex +tracedecay install --local --agent devin ``` After setup, restart the agent so it loads the MCP server, plugin, hooks, or rules written for that host. diff --git a/crates/tracedecay-agent-hosts/src/agents/devin.rs b/crates/tracedecay-agent-hosts/src/agents/devin.rs new file mode 100644 index 0000000000..63e599fea3 --- /dev/null +++ b/crates/tracedecay-agent-hosts/src/agents/devin.rs @@ -0,0 +1,418 @@ +//! Devin agent integration. +//! +//! Devin discovers stdio MCP servers from a dedicated configuration +//! document. TraceDecay owns only the `mcpServers.tracedecay` entry and leaves +//! every other server untouched. Current Devin releases use +//! `~/.config/devin/mcp_config.json` for user scope and +//! `/.devin/mcp_config.json` for shared project scope; older main +//! config entries are migrated by Devin itself. + +use std::path::{Path, PathBuf}; + +use serde_json::json; + +use crate::errors::{Result, TraceDecayError}; + +use super::host_bundle_v2::HostBundleRegistrationStateV1; +use super::{ + AgentIntegration, DoctorCounters, HealthcheckContext, InstallContext, JsonConfigDialect, + McpDoctorLabels, TextFileMutation, config_backup_path, doctor_check_mcp_registration, + load_json_file, update_config_file_transactionally, +}; + +pub struct DevinIntegration; + +fn devin_config_dir(home: &Path) -> PathBuf { + home.join(".config/devin") +} + +/// Current user-scoped MCP configuration path documented by Devin. +fn devin_mcp_config_path(home: &Path) -> PathBuf { + devin_config_dir(home).join("mcp_config.json") +} + +/// Current project-scoped MCP configuration path documented by Devin. +fn devin_project_mcp_config_path(project_path: &Path) -> PathBuf { + project_path.join(".devin/mcp_config.json") +} + +fn devin_original_config_path(config_path: &Path) -> PathBuf { + PathBuf::from(format!("{}.tracedecay-original", config_path.display())) +} + +impl AgentIntegration for DevinIntegration { + fn name(&self) -> &'static str { + "Devin" + } + + fn id(&self) -> &'static str { + "devin" + } + + fn supports_local_install(&self) -> bool { + true + } + + fn healthcheck(&self, dc: &mut DoctorCounters, ctx: &HealthcheckContext) { + eprintln!("\n\x1b[1mDevin integration\x1b[0m"); + doctor_check_mcp_registration( + dc, + &devin_mcp_config_path(&ctx.home), + "mcpServers", + load_json_file, + &McpDoctorLabels { + agent_id: "devin", + product: "Devin user configuration", + registered: "MCP server registered", + missing: "MCP server NOT registered", + }, + ); + let project_config = devin_project_mcp_config_path(&ctx.project_path); + if project_config.exists() { + doctor_check_mcp_registration( + dc, + &project_config, + "mcpServers", + load_json_file, + &McpDoctorLabels { + agent_id: "devin", + product: "Devin project configuration", + registered: "project MCP server registered", + missing: "project MCP server NOT registered", + }, + ); + } + } + + fn host_component_registration( + &self, + component: super::host_bundle_v2::HostBundleComponentV1, + ctx: &HealthcheckContext, + ) -> super::host_bundle_v2::HostBundleRegistrationStateV1 { + if component != super::host_bundle_v2::HostBundleComponentV1::ContextMcp { + return super::host_bundle_v2::HostBundleRegistrationStateV1::Missing; + } + devin_mcp_registration_state(&devin_mcp_config_path(&ctx.home)) + } + + fn is_detected(&self, home: &Path) -> bool { + devin_config_dir(home).is_dir() + } + + fn primary_config_path(&self, home: &Path) -> Option { + Some(devin_mcp_config_path(home)) + } + + fn host_component_registration_paths( + &self, + components: &[super::host_bundle_v2::HostBundleComponentV1], + home: &Path, + ) -> Vec { + if components == [super::host_bundle_v2::HostBundleComponentV1::ContextMcp] { + let path = devin_mcp_config_path(home); + vec![ + path.clone(), + config_backup_path(&path), + devin_original_config_path(&path), + ] + } else { + Vec::new() + } + } + + fn project_host_component_registration_paths( + &self, + components: &[super::host_bundle_v2::HostBundleComponentV1], + _home: &Path, + project_path: &Path, + ) -> Result> { + if components == [super::host_bundle_v2::HostBundleComponentV1::ContextMcp] { + let path = devin_project_mcp_config_path(project_path); + Ok(vec![ + path.clone(), + config_backup_path(&path), + devin_original_config_path(&path), + ]) + } else { + Ok(Vec::new()) + } + } + + #[hotpath::measure(label = "devin_mcp_install")] + fn activate_deployed_host_component_registration( + &self, + components: &[super::host_bundle_v2::HostBundleComponentV1], + ctx: &InstallContext, + ) -> Result<()> { + install_mcp_if_selected(components, &devin_mcp_config_path(&ctx.home), ctx) + } + + fn deactivate_deployed_host_component_registration( + &self, + components: &[super::host_bundle_v2::HostBundleComponentV1], + ctx: &InstallContext, + ) -> Result<()> { + uninstall_mcp_if_selected(components, &devin_mcp_config_path(&ctx.home)) + } + + fn activate_project_host_component_registration( + &self, + components: &[super::host_bundle_v2::HostBundleComponentV1], + ctx: &InstallContext, + project_path: &Path, + ) -> Result<()> { + let config_path = devin_project_mcp_config_path(project_path); + let original_path = devin_original_config_path(&config_path); + super::ensure_project_local_safe_paths( + project_path, + [config_path.as_path(), original_path.as_path()], + )?; + install_mcp_if_selected(components, &config_path, ctx) + } + + fn deactivate_project_host_component_registration( + &self, + components: &[super::host_bundle_v2::HostBundleComponentV1], + _ctx: &InstallContext, + project_path: &Path, + ) -> Result<()> { + uninstall_mcp_if_selected(components, &devin_project_mcp_config_path(project_path)) + } + + fn has_tracedecay(&self, home: &Path) -> bool { + super::mcp_config_has_tracedecay(&devin_mcp_config_path(home), "mcpServers", load_json_file) + } +} + +/// Devin treats an omitted `disabled` field as enabled. Its documented MCP +/// examples omit the field, so this adapter cannot use the stricter shared +/// state reader used by hosts that require an explicit `disabled: false`. +fn devin_mcp_registration_state(config_path: &Path) -> HostBundleRegistrationStateV1 { + let Ok(bytes) = std::fs::read(config_path) else { + return HostBundleRegistrationStateV1::Missing; + }; + let Ok(settings) = serde_json::from_slice::(&bytes) else { + return HostBundleRegistrationStateV1::Corrupt; + }; + let Some(server) = settings.pointer("/mcpServers/tracedecay") else { + return HostBundleRegistrationStateV1::Missing; + }; + let command_is_present = server + .get("command") + .and_then(serde_json::Value::as_str) + .is_some_and(|command| !command.is_empty()); + let serves_tracedecay = server + .get("args") + .and_then(serde_json::Value::as_array) + .is_some_and(|args| args.iter().any(|arg| arg.as_str() == Some("serve"))); + let disabled = server + .get("disabled") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + if server.is_object() && command_is_present && serves_tracedecay && !disabled { + HostBundleRegistrationStateV1::Current + } else { + HostBundleRegistrationStateV1::Missing + } +} + +fn install_mcp_if_selected( + components: &[super::host_bundle_v2::HostBundleComponentV1], + config_path: &Path, + ctx: &InstallContext, +) -> Result<()> { + if components.contains(&super::host_bundle_v2::HostBundleComponentV1::ContextMcp) { + if let Some(parent) = config_path.parent() { + std::fs::create_dir_all(parent).map_err(|error| TraceDecayError::Config { + message: format!( + "cannot create Devin config directory {}: {error}", + parent.display() + ), + })?; + } + let original_path = devin_original_config_path(config_path); + update_config_file_transactionally(config_path, |existing| { + let mut settings = JsonConfigDialect::Json.parse_for_edit(config_path, existing)?; + if !settings.is_object() { + return Err(TraceDecayError::Config { + message: format!("{} must contain a JSON object", config_path.display()), + }); + } + if settings + .get("mcpServers") + .is_some_and(|value| !value.is_object()) + { + return Err(TraceDecayError::Config { + message: format!("{}.mcpServers must be a JSON object", config_path.display()), + }); + } + let has_tracedecay = settings.pointer("/mcpServers/tracedecay").is_some(); + if !has_tracedecay && config_path.is_file() && !original_path.exists() { + super::safe_write_bytes_file(&original_path, existing.as_bytes(), None)?; + } + settings["mcpServers"]["tracedecay"] = json!({ + "command": ctx.tracedecay_bin.clone(), + "args": ["serve"], + "env": {}, + "transport": "stdio", + }); + Ok(( + (), + TextFileMutation::Write(super::render_json_config(config_path, &settings)?), + )) + })?; + eprintln!( + "\x1b[32m✔\x1b[0m Added tracedecay MCP server to {}", + config_path.display() + ); + } + Ok(()) +} + +enum DevinMcpRemoval { + NoEntry, + RestoredOriginal, + Rewritten, +} + +fn uninstall_mcp_if_selected( + components: &[super::host_bundle_v2::HostBundleComponentV1], + config_path: &Path, +) -> Result<()> { + if components.contains(&super::host_bundle_v2::HostBundleComponentV1::ContextMcp) { + if !config_path.exists() { + eprintln!(" {} not found, skipping", config_path.display()); + return Ok(()); + } + let original_path = devin_original_config_path(config_path); + let outcome = update_config_file_transactionally(config_path, |existing| { + let mut settings = JsonConfigDialect::Json.parse_for_edit(config_path, existing)?; + let Some(servers) = settings + .get_mut("mcpServers") + .and_then(serde_json::Value::as_object_mut) + else { + return Ok((DevinMcpRemoval::NoEntry, TextFileMutation::Unchanged)); + }; + if servers.remove("tracedecay").is_none() { + return Ok((DevinMcpRemoval::NoEntry, TextFileMutation::Unchanged)); + } + if let Ok(original) = std::fs::read(&original_path) + && serde_json::from_slice::(&original).ok() + == Some(settings.clone()) + { + let original = + String::from_utf8(original).map_err(|error| TraceDecayError::Config { + message: format!("{} is not valid UTF-8: {error}", original_path.display()), + })?; + return Ok(( + DevinMcpRemoval::RestoredOriginal, + TextFileMutation::Write(original), + )); + } + Ok(( + DevinMcpRemoval::Rewritten, + TextFileMutation::Write(super::render_json_config(config_path, &settings)?), + )) + })?; + match outcome { + DevinMcpRemoval::NoEntry => eprintln!( + " No tracedecay MCP server in {}, skipping", + config_path.display() + ), + DevinMcpRemoval::RestoredOriginal => { + super::safe_remove_host_file(&original_path).map_err(|error| { + TraceDecayError::Config { + message: format!("failed to remove {}: {error}", original_path.display()), + } + })?; + eprintln!( + "\x1b[32m✔\x1b[0m Restored original Devin configuration in {}", + config_path.display() + ); + } + DevinMcpRemoval::Rewritten => eprintln!( + "\x1b[32m✔\x1b[0m Removed tracedecay MCP server from {}", + config_path.display() + ), + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn current_devin_paths_distinguish_user_and_project_scope() { + let home = Path::new("/tmp/home"); + let project = Path::new("/tmp/project"); + assert_eq!( + devin_mcp_config_path(home), + PathBuf::from("/tmp/home/.config/devin/mcp_config.json") + ); + assert_eq!( + devin_project_mcp_config_path(project), + PathBuf::from("/tmp/project/.devin/mcp_config.json") + ); + } + + #[test] + fn documented_server_entry_is_current_without_disabled_field() { + let temp = tempfile::tempdir().unwrap(); + let config = temp.path().join("mcp_config.json"); + std::fs::write( + &config, + r#"{"mcpServers":{"tracedecay":{"command":"/usr/local/bin/tracedecay","args":["serve"],"env":{}}}}"#, + ) + .unwrap(); + + assert_eq!( + devin_mcp_registration_state(&config), + HostBundleRegistrationStateV1::Current + ); + } + + #[test] + fn project_lifecycle_preserves_foreign_devin_configuration() { + let home = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + let config = devin_project_mcp_config_path(project.path()); + std::fs::create_dir_all(config.parent().unwrap()).unwrap(); + let original = br#"{"mcpServers":{"other":{"command":"other-mcp"}},"ui":{"theme":"dark"}}"#; + std::fs::write(&config, original).unwrap(); + let components = [super::super::host_bundle_v2::HostBundleComponentV1::ContextMcp]; + let install = InstallContext { + home: home.path().to_path_buf(), + tracedecay_bin: "/tmp/tracedecay-a".to_string(), + tool_permissions: Vec::new(), + project_root: Some(project.path().to_path_buf()), + dashboard: false, + }; + + DevinIntegration + .activate_project_host_component_registration(&components, &install, project.path()) + .unwrap(); + assert_eq!( + std::fs::read(devin_original_config_path(&config)).unwrap(), + original + ); + let installed = load_json_file(&config); + assert_eq!(installed["ui"]["theme"], "dark"); + assert_eq!(installed["mcpServers"]["other"]["command"], "other-mcp"); + assert_eq!( + installed["mcpServers"]["tracedecay"]["command"], + "/tmp/tracedecay-a" + ); + + DevinIntegration + .deactivate_project_host_component_registration(&components, &install, project.path()) + .unwrap(); + let removed = load_json_file(&config); + assert_eq!(removed["ui"]["theme"], "dark"); + assert_eq!(removed["mcpServers"]["other"]["command"], "other-mcp"); + assert!(removed["mcpServers"].get("tracedecay").is_none()); + assert_eq!(std::fs::read(&config).unwrap(), original); + assert!(!devin_original_config_path(&config).exists()); + } +} diff --git a/crates/tracedecay-agent-hosts/src/agents/host_bundle_registry.rs b/crates/tracedecay-agent-hosts/src/agents/host_bundle_registry.rs index 007c42929e..63ae00ccf8 100644 --- a/crates/tracedecay-agent-hosts/src/agents/host_bundle_registry.rs +++ b/crates/tracedecay-agent-hosts/src/agents/host_bundle_registry.rs @@ -22,10 +22,11 @@ const FIRST_PARTY_COMPONENT_SCHEMA_VERSION: u16 = 1; /// Canonical hosts whose first-party component lifecycle can publish durable /// ownership receipts. Discovery-only and evidence-unadmitted hosts stay in /// `HostKindV1::ALL`, but never enter install/update/uninstall sweeps. -pub const RECEIPT_BACKED_HOST_KINDS: [HostKindV1; 12] = [ +pub const RECEIPT_BACKED_HOST_KINDS: [HostKindV1; 13] = [ HostKindV1::ClaudeCode, HostKindV1::CursorDesktop, HostKindV1::Codex, + HostKindV1::Devin, HostKindV1::Hermes, HostKindV1::Kiro, HostKindV1::KimiCode, @@ -109,6 +110,7 @@ pub fn unsupported_host_component_set_reason( match host { HostKindV1::ClaudeCode | HostKindV1::Codex + | HostKindV1::Devin | HostKindV1::CursorDesktop | HostKindV1::Hermes | HostKindV1::Kiro @@ -148,6 +150,7 @@ pub fn default_components(host: HostKindV1) -> Vec { HostBundleComponentV1::Core, HostBundleComponentV1::ContextMcp, ], + HostKindV1::Devin => vec![HostBundleComponentV1::ContextMcp], HostKindV1::CursorDesktop | HostKindV1::OpenCode => vec![ HostBundleComponentV1::Core, HostBundleComponentV1::Agent, @@ -605,6 +608,16 @@ fn component_assets( r#"{"host":"cline","registration":"../mcp.json","registrar":"tracedecay managed merge","route":"mcp","server":{"command":"__TRACEDECAY_BIN__","args":["serve"]}}"#, )], ), + // Devin owns the shared user configuration document directly. + // The component receipt owns this descriptor only; the activation + // adapter merges its server entry into `mcp_config.json`. + (HostKindV1::Devin, HostBundleComponentV1::ContextMcp) => ( + ".config/devin/tracedecay", + vec![( + "context-mcp.json", + r#"{"host":"devin","registration":"../mcp_config.json","registrar":"tracedecay managed merge","route":"mcp","server":{"command":"__TRACEDECAY_BIN__","args":["serve"],"transport":"stdio"}}"#, + )], + ), (HostKindV1::RooCode, HostBundleComponentV1::ContextMcp) => ( ".roo/tracedecay", vec![( diff --git a/crates/tracedecay-agent-hosts/src/agents/host_bundle_v2.rs b/crates/tracedecay-agent-hosts/src/agents/host_bundle_v2.rs index 62a4a547a9..5fc2a80ed7 100644 --- a/crates/tracedecay-agent-hosts/src/agents/host_bundle_v2.rs +++ b/crates/tracedecay-agent-hosts/src/agents/host_bundle_v2.rs @@ -74,7 +74,7 @@ pub fn resolved_host_bundle_lifecycle_root() -> crate::errors::Result { /// Canonical stock-host enumeration shared by packaging, delivery, and /// conformance consumers. -pub const fn stock_host_kinds() -> [HostKindV1; 14] { +pub const fn stock_host_kinds() -> [HostKindV1; 15] { HostKindV1::ALL } diff --git a/crates/tracedecay-agent-hosts/src/agents/host_bundle_v2/capability_admission.rs b/crates/tracedecay-agent-hosts/src/agents/host_bundle_v2/capability_admission.rs index bd89a369be..1c18224f56 100644 --- a/crates/tracedecay-agent-hosts/src/agents/host_bundle_v2/capability_admission.rs +++ b/crates/tracedecay-agent-hosts/src/agents/host_bundle_v2/capability_admission.rs @@ -51,6 +51,9 @@ pub fn require_component_capabilities( // the same capability matrix every other host is judged by instead of // through a missing match arm that would silently fall to `&[Mcp]`. (HostKindV1::Copilot, Core) => &[Hooks, Mcp], + // Devin's supported first-party route is its independent + // `mcpServers.tracedecay` registration; it has no Core surface. + (HostKindV1::Devin, Core) => return Err(HostBundleError::UnsupportedCapability), (_, ContextMcp | OperatorMcp) => &[Mcp], (HostKindV1::CursorDesktop, Agent) => &[NativeDiagnostics], (HostKindV1::OpenCode, Agent) => &[Cli], diff --git a/crates/tracedecay-agent-hosts/src/agents/host_component_registration.rs b/crates/tracedecay-agent-hosts/src/agents/host_component_registration.rs index a2faa79f66..4167d2b869 100644 --- a/crates/tracedecay-agent-hosts/src/agents/host_component_registration.rs +++ b/crates/tracedecay-agent-hosts/src/agents/host_component_registration.rs @@ -289,6 +289,7 @@ impl CatalogHostComponentRegistrationAuthority { ) -> CatalogRegistrationMode { if component_set.host == crate::agents::host_bundle_v2::HostKindV1::ClaudeCode || component_set.host == crate::agents::host_bundle_v2::HostKindV1::Codex + || component_set.host == crate::agents::host_bundle_v2::HostKindV1::Devin || component_set.host == crate::agents::host_bundle_v2::HostKindV1::Hermes || component_set.host == crate::agents::host_bundle_v2::HostKindV1::KimiCode || component_set.host == crate::agents::host_bundle_v2::HostKindV1::Kiro diff --git a/crates/tracedecay-agent-hosts/src/agents/mod.rs b/crates/tracedecay-agent-hosts/src/agents/mod.rs index febe2d14a0..ec7fdb1b86 100644 --- a/crates/tracedecay-agent-hosts/src/agents/mod.rs +++ b/crates/tracedecay-agent-hosts/src/agents/mod.rs @@ -16,6 +16,7 @@ pub mod context_scout_v2; pub mod copilot; pub mod cursor; pub(crate) mod cursor_diagnostics; +pub mod devin; /// Legacy Cursor `serve` log marker; the root crate's `src/serve.rs` /// re-exports this instead of declaring its own copy. pub use cursor_diagnostics::DEGRADED_SERVE_STDERR_MARKER; @@ -63,6 +64,7 @@ pub use cline::ClineIntegration; pub use codex::CodexIntegration; pub use copilot::CopilotIntegration; pub use cursor::CursorIntegration; +pub use devin::DevinIntegration; pub use gemini::GeminiIntegration; pub use hermes::HermesIntegration; pub use kilo::KiloIntegration; @@ -608,6 +610,7 @@ pub fn get_integration(id: &str) -> Result> { "gemini" => Ok(Box::new(GeminiIntegration)), "copilot" => Ok(Box::new(CopilotIntegration)), "cursor" => Ok(Box::new(CursorIntegration)), + "devin" => Ok(Box::new(DevinIntegration)), "hermes" => Ok(Box::new(HermesIntegration)), "zed" => Ok(Box::new(ZedIntegration)), "cline" => Ok(Box::new(ClineIntegration)), @@ -635,6 +638,7 @@ pub fn all_integrations() -> Vec> { Box::new(GeminiIntegration), Box::new(CopilotIntegration), Box::new(CursorIntegration), + Box::new(DevinIntegration), Box::new(HermesIntegration), Box::new(ZedIntegration), Box::new(ClineIntegration), @@ -656,6 +660,7 @@ pub fn available_integrations() -> Vec<&'static str> { "gemini", "copilot", "cursor", + "devin", "hermes", "zed", "cline", @@ -668,6 +673,14 @@ pub fn available_integrations() -> Vec<&'static str> { ] } +#[cfg(test)] +#[test] +fn devin_is_a_registered_independent_agent() { + let integration = get_integration("devin").expect("Devin integration is registered"); + assert_eq!(integration.name(), "Devin"); + assert!(available_integrations().contains(&"devin")); +} + pub fn integration_id_for_host(host: host_bundle_v2::HostKindV1) -> &'static str { match host { host_bundle_v2::HostKindV1::ClaudeCode => "claude", @@ -675,6 +688,7 @@ pub fn integration_id_for_host(host: host_bundle_v2::HostKindV1) -> &'static str "cursor" } host_bundle_v2::HostKindV1::Codex => "codex", + host_bundle_v2::HostKindV1::Devin => "devin", host_bundle_v2::HostKindV1::Hermes => "hermes", host_bundle_v2::HostKindV1::Kiro => "kiro", host_bundle_v2::HostKindV1::ClineFamily => "cline", diff --git a/crates/tracedecay-cli/src/agent_cmd.rs b/crates/tracedecay-cli/src/agent_cmd.rs index f74db6b778..4bf2eb7101 100644 --- a/crates/tracedecay-cli/src/agent_cmd.rs +++ b/crates/tracedecay-cli/src/agent_cmd.rs @@ -834,10 +834,74 @@ fn load_host_lifecycle_user_config() -> tracedecay_domain::errors::Result tracedecay_domain::errors::Result<()> { - Err(project_local_host_lifecycle_unavailable()) + if agent_id != "devin" { + return Err(project_local_host_lifecycle_unavailable()); + } + let home = tracedecay::agents::home_dir().ok_or_else(|| { + tracedecay_domain::errors::TraceDecayError::Config { + message: "could not determine home directory".to_string(), + } + })?; + let tracedecay_bin = tracedecay::agents::which_tracedecay().ok_or_else(|| { + tracedecay_domain::errors::TraceDecayError::Config { + message: "tracedecay not found on PATH. Install the checksummed GitHub release:\n \ + https://github.com/ScriptedAlchemy/tracedecay/releases/latest" + .to_string(), + } + })?; + let project_path = std::env::current_dir().map_err(|error| { + tracedecay_domain::errors::TraceDecayError::Config { + message: format!("could not determine project directory: {error}"), + } + })?; + let integration = tracedecay::agents::get_integration(&agent_id)?; + if !integration.supports_local_install() { + return Err(project_local_host_lifecycle_unavailable()); + } + let context = tracedecay::agents::InstallContext { + home: home.clone(), + tracedecay_bin, + tool_permissions: tracedecay::agents::expected_tool_perms(), + project_root: Some(project_path.clone()), + dashboard: false, + }; + let components = [tracedecay::agents::host_bundle_v2::HostBundleComponentV1::ContextMcp]; + let _registration_paths = integration.project_host_component_registration_paths( + &components, + &home, + &project_path, + )?; + match operation { + HostBundleCliOperation::Install + | HostBundleCliOperation::Update + | HostBundleCliOperation::Repair => { + prepare_native_activation_if_needed(integration.as_ref(), &context)?; + integration.activate_project_host_component_registration( + &components, + &context, + &project_path, + )?; + eprintln!( + "\x1b[32m+\x1b[0m {} project MCP registration", + integration.name() + ); + } + HostBundleCliOperation::Uninstall => { + integration.deactivate_project_host_component_registration( + &components, + &context, + &project_path, + )?; + eprintln!( + "\x1b[31m-\x1b[0m {} project MCP registration", + integration.name() + ); + } + } + Ok(()) } fn project_local_host_lifecycle_unavailable() -> tracedecay_domain::errors::TraceDecayError { @@ -2653,7 +2717,11 @@ pub(crate) async fn handle_install_command( ) -> tracedecay_domain::errors::Result<()> { validate_codex_automation_flags(agent.as_deref(), automation)?; if local { - return Err(project_local_host_lifecycle_unavailable()); + let agent_id = agent.ok_or_else(|| tracedecay_domain::errors::TraceDecayError::Config { + message: "`tracedecay install --local` requires `--agent devin`".to_string(), + })?; + return handle_project_local_lifecycle_command(agent_id, HostBundleCliOperation::Install) + .await; } let home = tracedecay::agents::home_dir().ok_or_else(|| { tracedecay_domain::errors::TraceDecayError::Config { diff --git a/crates/tracedecay-cli/tests/host_lifecycle_cli_acceptance.rs b/crates/tracedecay-cli/tests/host_lifecycle_cli_acceptance.rs index 0751d4299b..567bf3d953 100644 --- a/crates/tracedecay-cli/tests/host_lifecycle_cli_acceptance.rs +++ b/crates/tracedecay-cli/tests/host_lifecycle_cli_acceptance.rs @@ -50,6 +50,11 @@ const CODEX_CONFIGS: &[(&str, &[u8])] = &[( ".codex/config.toml", b"# operator comment\nmodel = \"o4-mini\" # keep inline\napproval_policy = \"on-failure\"\n\n[mcp_servers.foreign]\ncommand = \"foreign-bin\"\nargs = [\"--stdio\"]\n", )]; +const DEVIN_CONFIGS: &[(&str, &[u8])] = &[( + ".config/devin/mcp_config.json", + br#"{"mcpServers":{"foreign":{"command":"foreign-bin","args":["serve"]}},"ui":{"theme":"dark"}} +"#, +)]; const HERMES_CONFIGS: &[(&str, &[u8])] = &[ ( ".hermes/config.yaml", @@ -150,6 +155,7 @@ fn host_case(host: HostKindV1) -> HostCase { HostKindV1::ClaudeCode => CLAUDE_CONFIGS, HostKindV1::CursorDesktop => CURSOR_CONFIGS, HostKindV1::Codex => CODEX_CONFIGS, + HostKindV1::Devin => DEVIN_CONFIGS, HostKindV1::Hermes => HERMES_CONFIGS, HostKindV1::Kiro => KIRO_CONFIGS, HostKindV1::KimiCode => &[], @@ -262,6 +268,7 @@ fn assert_success(host: &str, phase: &str, output: Output) { fn assert_documented_mcp_registration(case: HostCase, cli: &IsolatedCli) { let (relative, root) = match case.host { HostKindV1::Cline => (".cline/mcp.json", "mcpServers"), + HostKindV1::Devin => (".config/devin/mcp_config.json", "mcpServers"), HostKindV1::RooCode => ( ".config/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/cline_mcp_settings.json", "mcpServers", @@ -277,7 +284,7 @@ fn assert_documented_mcp_registration(case: HostCase, cli: &IsolatedCli) { case.id ); let theme = match case.host { - HostKindV1::Cline | HostKindV1::RooCode => &config["ui"]["theme"], + HostKindV1::Cline | HostKindV1::Devin | HostKindV1::RooCode => &config["ui"]["theme"], HostKindV1::Kilo => &config["theme"], _ => unreachable!(), }; @@ -293,6 +300,15 @@ fn assert_documented_mcp_registration(case: HostCase, cli: &IsolatedCli) { assert_eq!(entry["disabled"], false); assert_eq!(entry["autoApprove"], serde_json::json!([])); } + HostKindV1::Devin => { + assert_eq!( + entry["command"], + serde_json::json!(cli.bin_dir.join("tracedecay")) + ); + assert_eq!(entry["args"], serde_json::json!(["serve"])); + assert_eq!(entry["env"], serde_json::json!({})); + assert_eq!(entry["transport"], "stdio"); + } HostKindV1::RooCode => { assert_eq!( entry["command"], @@ -519,6 +535,7 @@ fn native_feedback(case: HostCase) -> Vec<(&'static str, &'static str, Vec)> ] } HostKindV1::Kiro + | HostKindV1::Devin | HostKindV1::Gemini | HostKindV1::Copilot | HostKindV1::Cline @@ -555,7 +572,12 @@ fn production_cli_completes_deterministic_lifecycle_for_config_native_hosts() { // keeps one representative for each distinct lifecycle shape: OpenCode's // config-native bundle, Cline's MCP-only bundle, and Hermes' standalone // core integration. - for host in [HostKindV1::OpenCode, HostKindV1::Cline, HostKindV1::Hermes] { + for host in [ + HostKindV1::OpenCode, + HostKindV1::Cline, + HostKindV1::Devin, + HostKindV1::Hermes, + ] { let case = host_case(host); assert!(!lifecycle_requires_absent_host_binary(case.host)); let cli = IsolatedCli::new(); @@ -726,6 +748,40 @@ fn production_cli_completes_deterministic_lifecycle_for_config_native_hosts() { } } +#[test] +fn production_cli_installs_devin_project_mcp_without_touching_siblings() { + let cli = IsolatedCli::new(); + let config = cli.project.path().join(".devin/mcp_config.json"); + fs::create_dir_all(config.parent().unwrap()).unwrap(); + fs::write( + &config, + br#"{"mcpServers":{"foreign":{"command":"foreign-bin"}},"ui":{"theme":"dark"}}"#, + ) + .unwrap(); + + assert_success( + "devin", + "project install", + cli.run(&["install", "--agent", "devin", "--local"]), + ); + + let config: serde_json::Value = serde_json::from_slice(&fs::read(&config).unwrap()).unwrap(); + assert_eq!(config["ui"]["theme"], "dark"); + assert_eq!(config["mcpServers"]["foreign"]["command"], "foreign-bin"); + assert_eq!( + config["mcpServers"]["tracedecay"]["command"], + serde_json::json!(cli.bin_dir.join("tracedecay")) + ); + assert_eq!( + config["mcpServers"]["tracedecay"]["args"], + serde_json::json!(["serve"]) + ); + assert_eq!( + config["mcpServers"]["tracedecay"]["env"], + serde_json::json!({}) + ); +} + #[test] fn hermes_dashboard_opt_out_survives_install_update_and_reinstall() { let cli = IsolatedCli::new(); diff --git a/crates/tracedecay-domain/src/integration.rs b/crates/tracedecay-domain/src/integration.rs index 47b4107865..af34708d71 100644 --- a/crates/tracedecay-domain/src/integration.rs +++ b/crates/tracedecay-domain/src/integration.rs @@ -34,6 +34,7 @@ pub enum HostKindV1 { CursorDesktop, CursorCloud, Codex, + Devin, Hermes, Kiro, ClineFamily, @@ -47,7 +48,7 @@ pub enum HostKindV1 { } impl HostKindV1 { - pub const ALL: [Self; 14] = [ + pub const ALL: [Self; 15] = [ Self::ClaudeCode, Self::CursorDesktop, Self::CursorCloud, @@ -62,6 +63,7 @@ impl HostKindV1 { Self::OpenCode, Self::Gemini, Self::Copilot, + Self::Devin, ]; /// Project a stock host surface into the bounded host observation catalog @@ -73,7 +75,8 @@ impl HostKindV1 { Self::Codex => Some(HostIntegrationIdV1::Codex), Self::Hermes => Some(HostIntegrationIdV1::Hermes), Self::Kiro => Some(HostIntegrationIdV1::Kiro), - Self::CursorCloud + Self::Devin + | Self::CursorCloud | Self::ClineFamily | Self::Cline | Self::RooCode @@ -176,6 +179,15 @@ const fn canonical_stock_host_capabilities(host: HostKindV1) -> [HostCapabilityR Supported, Supported, ), + // Devin owns local stdio MCP registration but exposes no + // TraceDecay-specific diagnostic or hook registration surface. + HostKindV1::Devin => ( + Unavailable(HostRegistrationUnsupported), + Unavailable(HostApiAbsent), + Unavailable(CheckedInEvidenceMissing), + Supported, + Supported, + ), HostKindV1::Kiro => ( Unavailable(HostRegistrationUnsupported), Unavailable(HostApiAbsent), @@ -492,6 +504,7 @@ impl HostIntegrationCatalogV1 { HostKindV1::OpenCode => &STOCK_HOST_CAPABILITIES[11], HostKindV1::Gemini => &STOCK_HOST_CAPABILITIES[12], HostKindV1::Copilot => &STOCK_HOST_CAPABILITIES[13], + HostKindV1::Devin => &STOCK_HOST_CAPABILITIES[14], } } @@ -582,7 +595,7 @@ impl HostIntegrationCatalogV1 { } } -const STOCK_HOST_CAPABILITIES: [[HostCapabilityRecordV1; 5]; 14] = [ +const STOCK_HOST_CAPABILITIES: [[HostCapabilityRecordV1; 5]; 15] = [ canonical_stock_host_capabilities(HostKindV1::ClaudeCode), canonical_stock_host_capabilities(HostKindV1::CursorDesktop), canonical_stock_host_capabilities(HostKindV1::CursorCloud), @@ -597,6 +610,7 @@ const STOCK_HOST_CAPABILITIES: [[HostCapabilityRecordV1; 5]; 14] = [ canonical_stock_host_capabilities(HostKindV1::OpenCode), canonical_stock_host_capabilities(HostKindV1::Gemini), canonical_stock_host_capabilities(HostKindV1::Copilot), + canonical_stock_host_capabilities(HostKindV1::Devin), ]; #[derive(Serialize)] diff --git a/crates/tracedecay-domain/src/integration/descriptor.rs b/crates/tracedecay-domain/src/integration/descriptor.rs index 034e993e6b..7226259bb8 100644 --- a/crates/tracedecay-domain/src/integration/descriptor.rs +++ b/crates/tracedecay-domain/src/integration/descriptor.rs @@ -100,6 +100,7 @@ pub enum HostProjectRegistrationPathV1 { ClaudeProjectDirectory, CursorProjectDirectory, CodexProjectDirectory, + DevinProjectDirectory, HermesProjectDirectory, KiroProjectDirectory, KimiProjectDirectory, @@ -113,6 +114,7 @@ impl HostProjectRegistrationPathV1 { Self::ClaudeProjectDirectory => Some(".claude"), Self::CursorProjectDirectory => Some(".cursor"), Self::CodexProjectDirectory => Some(".codex"), + Self::DevinProjectDirectory => Some(".devin"), Self::HermesProjectDirectory => Some(".hermes"), Self::KiroProjectDirectory => Some(".kiro"), Self::KimiProjectDirectory => Some(".kimi-code"), @@ -188,7 +190,7 @@ impl HostKindV1 { // and Copilot publishes no third-party hook surface at all, so // persisting a hook key for any of them would name a spool no event // can ever reach. - Self::ClineFamily | Self::Gemini | Self::Copilot => None, + Self::Devin | Self::ClineFamily | Self::Gemini | Self::Copilot => None, Self::Cline => Some(NativeHostIdentityV1::Cline), Self::RooCode => Some(NativeHostIdentityV1::RooCode), Self::Kilo => Some(NativeHostIdentityV1::Kilo), @@ -209,7 +211,7 @@ pub fn host_descriptor_v1(host: HostKindV1) -> HostDescriptorV1 { use HostHookMappingV1::{Native, NotApplicable}; use HostProjectRegistrationPathV1::{ ClaudeProjectDirectory, CodexProjectDirectory, CursorProjectDirectory, - HermesProjectDirectory, KimiProjectDirectory, KiroProjectDirectory, + DevinProjectDirectory, HermesProjectDirectory, KimiProjectDirectory, KiroProjectDirectory, OpenCodeProjectDirectory, }; @@ -251,6 +253,15 @@ pub fn host_descriptor_v1(host: HostKindV1) -> HostDescriptorV1 { Managed, CodexProjectDirectory, ), + HostKindV1::Devin => ( + "devin", + "devin", + NotApplicable, + vec![ContextMcp], + ManagedEmbedded, + Managed, + DevinProjectDirectory, + ), HostKindV1::Hermes => ( "hermes", "hermes", diff --git a/crates/tracedecay-domain/tests/host_descriptor_contract.rs b/crates/tracedecay-domain/tests/host_descriptor_contract.rs index cae7956a72..767b13fc55 100644 --- a/crates/tracedecay-domain/tests/host_descriptor_contract.rs +++ b/crates/tracedecay-domain/tests/host_descriptor_contract.rs @@ -59,7 +59,10 @@ fn native_identities_preserve_provider_specific_hosts() { // reports `None` here and a `Native`/`Unavailable` mapping below is // still an incoherent projection and still panics. ( - HostKindV1::ClineFamily | HostKindV1::Gemini | HostKindV1::Copilot, + HostKindV1::Devin + | HostKindV1::ClineFamily + | HostKindV1::Gemini + | HostKindV1::Copilot, None, HostHookMappingV1::NotApplicable, ) => {} @@ -150,6 +153,13 @@ fn activation_and_registration_never_invent_unsupported_routes() { ); } + let devin = HostKindV1::Devin.descriptor(); + assert_eq!(devin.components(), &[HostComponentV1::ContextMcp]); + assert_eq!( + devin.project_registration_path().relative_path(), + Some(".devin") + ); + let kimi = HostKindV1::KimiCode.descriptor(); assert_eq!( kimi.asset_render_policy(), diff --git a/crates/tracedecay-domain/tests/integration_catalog_contract.rs b/crates/tracedecay-domain/tests/integration_catalog_contract.rs index d32ae2a300..95d3f59e45 100644 --- a/crates/tracedecay-domain/tests/integration_catalog_contract.rs +++ b/crates/tracedecay-domain/tests/integration_catalog_contract.rs @@ -33,6 +33,31 @@ const HOST_EVENT_FIXTURES: [(&str, &str); 5] = [ ), ]; +#[test] +fn stock_host_order_preserves_existing_rows_and_appends_devin() { + assert_eq!( + HostKindV1::ALL, + [ + HostKindV1::ClaudeCode, + HostKindV1::CursorDesktop, + HostKindV1::CursorCloud, + HostKindV1::Codex, + HostKindV1::Hermes, + HostKindV1::Kiro, + HostKindV1::ClineFamily, + HostKindV1::Cline, + HostKindV1::RooCode, + HostKindV1::Kilo, + HostKindV1::KimiCode, + HostKindV1::OpenCode, + HostKindV1::Gemini, + HostKindV1::Copilot, + HostKindV1::Devin, + ], + "new stock hosts append so established capability rows retain their positions" + ); +} + #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] enum FixtureAdmissionReason { @@ -279,6 +304,7 @@ fn stock_host_kinds_project_only_fixture_backed_observation_integrations() { "cursor_desktop", "cursor_cloud", "codex", + "devin", "hermes", "kiro", "cline_family", @@ -314,6 +340,7 @@ fn stock_host_kinds_project_only_fixture_backed_observation_integrations() { ); for host in [ HostKindV1::CursorCloud, + HostKindV1::Devin, HostKindV1::ClineFamily, HostKindV1::Cline, HostKindV1::RooCode, diff --git a/crates/tracedecay-host-integration/src/lib.rs b/crates/tracedecay-host-integration/src/lib.rs index 90bedb2e99..fbceec2163 100644 --- a/crates/tracedecay-host-integration/src/lib.rs +++ b/crates/tracedecay-host-integration/src/lib.rs @@ -163,6 +163,20 @@ pub fn stock_host_registration_evidence(host: HostKindV1) -> Vec evidence.extend([ + HostRegistrationEvidenceV1 { + route: Hook, + state: Unavailable(CheckedInEvidenceMissing), + evidence_ref: "https://docs.devin.ai/work-with-devin/mcp", + starts_analyzer: false, + }, + HostRegistrationEvidenceV1 { + route: Mcp, + state: Supported, + evidence_ref: "https://docs.devin.ai/cli/extensibility/mcp/configuration", + starts_analyzer: false, + }, + ]), HostKindV1::Hermes => evidence.extend([ HostRegistrationEvidenceV1 { route: Hook, @@ -537,6 +551,7 @@ pub fn stock_host_native_fixture_evidence_from_embedded_assets( &["saved_edit", "post_tool_use"][..], ), HostKindV1::CursorCloud + | HostKindV1::Devin | HostKindV1::ClineFamily | HostKindV1::Cline | HostKindV1::RooCode diff --git a/docs/DEVIN-INTEGRATION.md b/docs/DEVIN-INTEGRATION.md new file mode 100644 index 0000000000..846c94ed36 --- /dev/null +++ b/docs/DEVIN-INTEGRATION.md @@ -0,0 +1,35 @@ +# Devin integration + +TraceDecay supports Devin as an independent agent integration. Devin's local +terminal and Desktop client stores MCP registrations in a user, project, or +local-project scope. + +`tracedecay install --agent devin` registers `tracedecay serve` as the +`mcpServers.tracedecay` stdio server in the Devin user registry: + +```text +~/.config/devin/mcp_config.json +``` + +Use `tracedecay install --agent devin --local` from a repository to register +the same server in the shared project registry: + +```text +.devin/mcp_config.json +``` + +These entries are compatible with Devin's own MCP CLI. The equivalent native +commands are: + +```sh +devin mcp add --scope user tracedecay -- tracedecay serve +devin mcp add --scope project tracedecay -- tracedecay serve +``` + +Devin also has a non-committed local scope at +`.devin/mcp_config.local.json`; TraceDecay leaves that personal configuration +alone. Uninstall removes only the `mcpServers.tracedecay` key and preserves +other MCP servers and Devin settings. + +Devin controls tool approval through its own permission modes. TraceDecay does +not change those permissions implicitly. diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index aabe87177f..9e9484a493 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -219,6 +219,7 @@ tracedecay install --agent gemini # Gemini CLI tracedecay install --agent hermes # Hermes Agent tracedecay install --agent copilot # GitHub Copilot CLI tracedecay install --agent cursor # Cursor +tracedecay install --agent devin # Devin tracedecay install --agent kiro # AWS Kiro tracedecay install --agent kimi # Kimi Code CLI ``` @@ -231,6 +232,9 @@ MCP registration or native plugin tools, with permissions where available. - Hermes installs one native user plugin through Hermes' plugin API. - Cursor installs a local plugin in `~/.cursor/plugins/local/tracedecay` that bundles MCP, hooks, and the tracedecay rule. +- Devin registers the `tracedecay serve` stdio MCP server in + `~/.config/devin/mcp_config.json`, preserving other Devin MCP entries and + leaving Devin's permission policy unchanged. - Codex uses Codex's plugin source, marketplace, and installed-cache flow: TraceDecay stages the source bundle and marketplace entry, then drives `codex plugin add tracedecay@personal` to install Codex's cache from that source. The plugin owns MCP, hooks, and skills. TraceDecay does not write `~/.codex/AGENTS.md`, `~/.codex/hooks.json`, or `[hooks.state]` trust hashes — Codex still asks you to trust new command hooks via `/hooks`. - Kimi Code CLI stages its plugin source at `~/.tracedecay/host-bundle-stage/kimi/tracedecay`; run the printed `/plugins install ` command in Kimi Code, then rerun TraceDecay so it can record the staged source. Kimi owns `~/.kimi-code/plugins/installed.json` and its managed/cache paths. @@ -301,6 +305,22 @@ Each install writes or stages the active profile's host integration; it does not create per-repository host configuration. The host's workspace/session context selects the active TraceDecay project at runtime. +Devin supports both profile-wide and project installation: + +```bash +tracedecay install --agent devin +tracedecay install --local --agent devin +``` + +The first command writes Devin's user MCP registry at +`~/.config/devin/mcp_config.json`. The second writes the repository's +`.devin/mcp_config.json`. Both register the exact stdio entry accepted by +Devin's `mcp add` command: the resolved `tracedecay` executable, `serve` as its +argument, and `transport: "stdio"`. Existing Devin MCP servers and unrelated +configuration remain intact. Restart Devin after installing, updating, or +removing the integration. See [Devin integration](DEVIN-INTEGRATION.md) for +the config locations and lifecycle details. + Cursor install is plugin-based: - `tracedecay install --agent cursor` installs `cursor-plugin/` into `~/.cursor/plugins/local/tracedecay`.