Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 2 additions & 6 deletions src/agents/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -391,17 +391,13 @@ fn substitute_command_placeholder(value: &mut serde_json::Value, tracedecay_bin:

/// Stamp the plugin manifest `version` with the crate version.
fn stamp_plugin_version(raw: &str) -> Result<String> {
let mut manifest: serde_json::Value = serde_json::from_str(raw)?;
manifest["version"] = json!(env!("CARGO_PKG_VERSION"));
Ok(format!("{}\n", serde_json::to_string_pretty(&manifest)?))
super::plugin_bundle::stamp_manifest_version(raw)
}

/// Set the plugin `.mcp.json` server command to the resolved absolute binary
/// path, so the plugin does not rely on `tracedecay` being on PATH.
fn set_mcp_command(raw: &str, tracedecay_bin: &str) -> Result<String> {
let mut mcp: serde_json::Value = serde_json::from_str(raw)?;
mcp["mcpServers"]["tracedecay"]["command"] = json!(tracedecay_bin);
Ok(format!("{}\n", serde_json::to_string_pretty(&mcp)?))
super::plugin_bundle::set_mcp_command(raw, tracedecay_bin)
}

/// Remove the deployed bundle dir (idempotent; only touches the tracedecay
Expand Down
139 changes: 55 additions & 84 deletions src/agents/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -505,15 +505,15 @@ fn write_codex_plugin_files(
}

fn codex_plugin_manifest(raw: &str) -> Result<String> {
let mut manifest: serde_json::Value = serde_json::from_str(raw)?;
manifest["version"] = json!(env!("CARGO_PKG_VERSION"));
Ok(format!("{}\n", serde_json::to_string_pretty(&manifest)?))
super::plugin_bundle::stamp_manifest_version(raw)
}

fn codex_plugin_mcp(raw: &str, tracedecay_bin: &str, scope: InstallScope) -> Result<String> {
let mut mcp: serde_json::Value = serde_json::from_str(raw)?;
// Reuse the shared command rewrite, then layer Codex's scope-specific
// args/env on top of the result.
let stamped = super::plugin_bundle::set_mcp_command(raw, tracedecay_bin)?;
let mut mcp: serde_json::Value = serde_json::from_str(&stamped)?;
let server = &mut mcp["mcpServers"]["tracedecay"];
server["command"] = json!(tracedecay_bin);
match scope {
InstallScope::Global => {
server["args"] = json!(["serve"]);
Expand Down Expand Up @@ -726,37 +726,16 @@ fn remove_codex_managed_skill_overlay(install_dir: &Path) {
std::fs::remove_dir_all(install_dir.join("skills/agent-managed")).ok();
}

const RETIRED_CODEX_PLUGIN_SKILL_DIRS: &[&str] = &[
"architecture-overview",
"assessing-test-coverage",
"atomic-code-edits",
"auditing-code-safety",
"cleaning-up-dead-code",
"code-health-report",
"cross-branch-investigation",
"drafting-commit-and-pr",
"exploring-types-and-traits",
"finding-duplicate-logic",
"finding-impacted-areas",
"porting-code",
"project-status",
"reading-code-cheaply",
"refactoring-safely",
"reviewing-a-diff",
"running-impacted-tests",
"searching-for-code",
"tracking-session-health",
];

fn remove_codex_plugin_managed_skills(install_dir: &Path, skills_dir: &Path) -> Result<()> {
remove_retired_codex_plugin_skill_dirs(skills_dir)?;
sweep_retired_bundle_skill_dirs(skills_dir);
let managed: HashSet<PathBuf> = codex_plugin_managed_paths(install_dir)
.into_iter()
.filter(|path| path.starts_with(skills_dir))
.collect();
let mut files = collect_regular_files(skills_dir).map_err(|e| TraceDecayError::Config {
message: format!("failed to list {}: {e}", skills_dir.display()),
})?;
let mut files =
super::collect_regular_files(skills_dir).map_err(|e| TraceDecayError::Config {
message: format!("failed to list {}: {e}", skills_dir.display()),
})?;
files.sort_by_key(|path| std::cmp::Reverse(path.components().count()));
for file in files {
if managed.contains(&file) || codex_skill_file_is_legacy_tracedecay_managed(&file) {
Expand All @@ -781,43 +760,54 @@ fn codex_skill_file_is_legacy_tracedecay_managed(path: &Path) -> bool {
})
}

fn remove_retired_codex_plugin_skill_dirs(skills_dir: &Path) -> Result<()> {
for name in RETIRED_CODEX_PLUGIN_SKILL_DIRS {
let skill_dir = skills_dir.join(name);
if !codex_skill_dir_is_retired_managed(&skill_dir, name) {
/// Remove every `skills/<dir>` under the Codex plugin dir that the current
/// bundle no longer ships. The keep-set is derived from the live embedded
/// bundle (plus the agent-managed overlays deployed separately), so any retired
/// skill is swept on upgrade without a hand-maintained legacy list.
///
/// Only tracedecay-owned skill dirs are swept: a same-name user-authored skill
/// whose `SKILL.md` carries no tracedecay marker is left untouched, so an
/// upgrade never deletes a user's private workflow that collides with a retired
/// bundle slug.
fn sweep_retired_bundle_skill_dirs(skills_dir: &Path) {
let Ok(entries) = std::fs::read_dir(skills_dir) else {
return;
};
let mut shipped: std::collections::BTreeSet<String> = codex_embedded_plugin_files()
.into_iter()
.filter_map(|(relative, _)| {
relative
.strip_prefix("skills/")
.and_then(|rest| rest.split('/').next())
.map(str::to_string)
})
.collect();
// The agent-managed overlays are deployed/removed separately; never treat
// them as retired.
shipped.insert("agent-managed".to_string());
shipped.insert("agent-managed-memory".to_string());
for entry in entries.flatten() {
if !entry.file_type().is_ok_and(|t| t.is_dir()) {
continue;
}
std::fs::remove_dir_all(&skill_dir).map_err(|e| TraceDecayError::Config {
message: format!(
"failed to remove retired Codex skill {}: {e}",
skill_dir.display()
),
})?;
let name = entry.file_name().to_string_lossy().into_owned();
if shipped.contains(&name) {
continue;
}
// Preserve user-authored skills that reuse a retired slug: only sweep a
// non-shipped dir that is demonstrably tracedecay-owned.
if !skill_file_has_tracedecay_marker(&entry.path().join("SKILL.md")) {
continue;
}
std::fs::remove_dir_all(entry.path()).ok();
}
Ok(())
}

fn codex_skill_dir_is_retired_managed(skill_dir: &Path, expected_name: &str) -> bool {
let skill_file = skill_dir.join("SKILL.md");
let expected_name_line = format!("name: {expected_name}");
skill_file.is_file()
&& std::fs::read_to_string(&skill_file).is_ok_and(|contents| {
let expected_name_matches = contents
.lines()
.map(str::trim)
.any(|line| line == expected_name_line);
expected_name_matches && skill_contents_have_tracedecay_marker(&contents)
})
}

fn skill_contents_have_tracedecay_marker(contents: &str) -> bool {
contents.lines().map(str::trim).any(|line| {
line.starts_with("name: tracedecay:")
|| line.starts_with("description: TraceDecay ")
|| line.contains("TraceDecay MCP")
|| line.contains("tracedecay_")
|| line.contains("`tracedecay:")
})
/// True when a Codex `SKILL.md` at `skill_file` carries a tracedecay authorship
/// marker, marking the skill dir as tracedecay-owned.
fn skill_file_has_tracedecay_marker(skill_file: &Path) -> bool {
std::fs::read_to_string(skill_file)
.is_ok_and(|contents| super::skill_contents_have_tracedecay_marker(&contents))
}

fn prune_empty_dirs(root: &Path) -> std::io::Result<()> {
Expand Down Expand Up @@ -881,7 +871,7 @@ fn codex_plugin_dir_is_tracedecay(install_dir: &Path) -> bool {
}

fn codex_plugin_dir_has_only_managed_files(install_dir: &Path) -> bool {
let Ok(entries) = collect_regular_files(install_dir) else {
let Ok(entries) = super::collect_regular_files(install_dir) else {
return false;
};
let managed = codex_plugin_managed_paths(install_dir);
Expand All @@ -897,25 +887,6 @@ fn codex_plugin_managed_paths(install_dir: &Path) -> Vec<PathBuf> {
paths
}

fn collect_regular_files(root: &Path) -> std::io::Result<Vec<PathBuf>> {
let mut out = Vec::new();
collect_regular_files_inner(root, &mut out)?;
Ok(out)
}

fn collect_regular_files_inner(root: &Path, out: &mut Vec<PathBuf>) -> std::io::Result<()> {
for entry in std::fs::read_dir(root)? {
let entry = entry?;
let file_type = entry.file_type()?;
if file_type.is_dir() {
collect_regular_files_inner(&entry.path(), out)?;
} else if file_type.is_file() {
out.push(entry.path());
}
}
Ok(())
}

fn remove_codex_marketplace_entry(home: &Path) -> Result<()> {
let marketplace_path = codex_personal_marketplace_path(home);
remove_codex_marketplace_entry_at(&marketplace_path, "personal")
Expand Down Expand Up @@ -1549,7 +1520,7 @@ mod tests {
/// `codex_bundle_ships_exactly_the_model_invocable_cursor_skills` checks.
/// Every file under a skills root, relative to it, forward-slashed.
fn skill_tree_files(root: &Path) -> Vec<String> {
let mut files: Vec<String> = collect_regular_files(root)
let mut files: Vec<String> = crate::agents::collect_regular_files(root)
.expect("skills dir readable")
.into_iter()
.filter_map(|path| {
Expand Down
44 changes: 7 additions & 37 deletions src/agents/cursor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -269,15 +269,11 @@ fn write_embedded_plugin(install_dir: &Path, tracedecay_bin: &str) -> Result<()>
}

fn cursor_plugin_manifest(raw: &str) -> Result<String> {
let mut manifest: serde_json::Value = serde_json::from_str(raw)?;
manifest["version"] = json!(env!("CARGO_PKG_VERSION"));
Ok(format!("{}\n", serde_json::to_string_pretty(&manifest)?))
super::plugin_bundle::stamp_manifest_version(raw)
}

fn cursor_plugin_mcp(raw: &str, tracedecay_bin: &str) -> Result<String> {
let mut mcp: serde_json::Value = serde_json::from_str(raw)?;
mcp["mcpServers"]["tracedecay"]["command"] = json!(tracedecay_bin);
Ok(format!("{}\n", serde_json::to_string_pretty(&mcp)?))
super::plugin_bundle::set_mcp_command(raw, tracedecay_bin)
}

fn cursor_plugin_hooks(raw: &str, tracedecay_bin: &str) -> Result<String> {
Expand Down Expand Up @@ -397,15 +393,8 @@ fn sweep_retired_bundle_skill_dirs(install_dir: &Path) {
/// True when a Cursor `SKILL.md` carries a tracedecay authorship marker, marking
/// the skill dir as tracedecay-owned (and therefore safe to sweep when retired).
fn skill_file_has_tracedecay_marker(skill_file: &Path) -> bool {
std::fs::read_to_string(skill_file).is_ok_and(|contents| {
contents.lines().map(str::trim).any(|line| {
line.starts_with("name: tracedecay:")
|| line.starts_with("description: TraceDecay ")
|| line.contains("TraceDecay MCP")
|| line.contains("tracedecay_")
|| line.contains("`tracedecay:")
})
})
std::fs::read_to_string(skill_file)
.is_ok_and(|contents| super::skill_contents_have_tracedecay_marker(&contents))
}

fn cursor_plugin_dir_is_tracedecay(install_dir: &Path) -> bool {
Expand All @@ -417,7 +406,7 @@ fn cursor_plugin_dir_is_tracedecay(install_dir: &Path) -> bool {
}

fn cursor_plugin_dir_has_only_managed_files(install_dir: &Path) -> bool {
let Ok(entries) = collect_regular_files(install_dir) else {
let Ok(entries) = super::collect_regular_files(install_dir) else {
return false;
};
let managed = cursor_plugin_managed_paths(install_dir);
Expand All @@ -433,25 +422,6 @@ fn cursor_plugin_managed_paths(install_dir: &Path) -> Vec<PathBuf> {
paths
}

fn collect_regular_files(root: &Path) -> std::io::Result<Vec<PathBuf>> {
let mut out = Vec::new();
collect_regular_files_inner(root, &mut out)?;
Ok(out)
}

fn collect_regular_files_inner(root: &Path, out: &mut Vec<PathBuf>) -> std::io::Result<()> {
for entry in std::fs::read_dir(root)? {
let entry = entry?;
let file_type = entry.file_type()?;
if file_type.is_dir() {
collect_regular_files_inner(&entry.path(), out)?;
} else if file_type.is_file() {
out.push(entry.path());
}
}
Ok(())
}

fn legacy_mcp_has_tracedecay(mcp_path: &Path) -> bool {
load_json_file(mcp_path)
.get("mcpServers")
Expand Down Expand Up @@ -943,7 +913,7 @@ mod tests {

/// Every file under a single skill dir, relative to it, forward-slashed.
fn skill_dir_tree_files(skill_dir: &Path) -> Vec<String> {
let mut files: Vec<String> = collect_regular_files(skill_dir)
let mut files: Vec<String> = crate::agents::collect_regular_files(skill_dir)
.expect("skill dir readable")
.into_iter()
.filter_map(|path| {
Expand Down Expand Up @@ -1491,7 +1461,7 @@ mod tests {
std::fs::read_to_string(cursor_dir.join("rules/tracedecay.mdc")).unwrap(),
rule
);
let mut files = collect_regular_files(&cursor_dir).unwrap();
let mut files = crate::agents::collect_regular_files(&cursor_dir).unwrap();
files.sort();
assert_eq!(
files,
Expand Down
29 changes: 4 additions & 25 deletions src/agents/kiro.rs
Original file line number Diff line number Diff line change
Expand Up @@ -336,32 +336,11 @@ fn mcp_server_entry(tracedecay_bin: &str) -> serde_json::Value {
})
}

/// Render a path as a `file://` resource URI for Kiro's agent config. Reuses
/// the LSP client's encoder, which additionally handles Windows drive paths and
/// UNC (`//server/share`) prefixes; POSIX paths encode identically to before.
fn file_resource_uri(path: &Path) -> String {
let path = path.to_string_lossy().replace('\\', "/");
let path = percent_encode_file_uri_path(&path);
if path.starts_with('/') {
format!("file://{path}")
} else {
format!("file:///{path}")
}
}

fn percent_encode_file_uri_path(path: &str) -> String {
const HEX: &[u8; 16] = b"0123456789ABCDEF";
let mut encoded = String::with_capacity(path.len());
for byte in path.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'/' | b':' | b'-' | b'.' | b'_' | b'~' => {
encoded.push(byte as char);
}
_ => {
encoded.push('%');
encoded.push(HEX[(byte >> 4) as usize] as char);
encoded.push(HEX[(byte & 0x0F) as usize] as char);
}
}
}
encoded
crate::diagnostics::lsp::client::file_uri_from_path_text(&path.to_string_lossy())
}

fn managed_agent_config(
Expand Down
34 changes: 34 additions & 0 deletions src/agents/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -817,6 +817,40 @@ or the server is disconnected, every tool is also available as a shell command:
`tracedecay tool <name> --help` shows parameters). Fall back to that CLI instead of \
querying `.tracedecay` databases directly or abandoning tracedecay.";

/// True when a `SKILL.md`'s contents carry a tracedecay authorship marker,
/// marking the skill dir as tracedecay-owned (and therefore safe to sweep when
/// retired). Shared by the Cursor and Codex plugin-dir sweeps.
pub(crate) fn skill_contents_have_tracedecay_marker(contents: &str) -> bool {
contents.lines().map(str::trim).any(|line| {
line.starts_with("name: tracedecay:")
|| line.starts_with("description: TraceDecay ")
|| line.contains("TraceDecay MCP")
|| line.contains("tracedecay_")
|| line.contains("`tracedecay:")
})
}

/// Recursively collect every regular file under `root` (following the same
/// hand-rolled walk both the Cursor and Codex installers rely on).
pub(crate) fn collect_regular_files(root: &Path) -> std::io::Result<Vec<PathBuf>> {
let mut out = Vec::new();
collect_regular_files_inner(root, &mut out)?;
Ok(out)
}

fn collect_regular_files_inner(root: &Path, out: &mut Vec<PathBuf>) -> std::io::Result<()> {
for entry in std::fs::read_dir(root)? {
let entry = entry?;
let file_type = entry.file_type()?;
if file_type.is_dir() {
collect_regular_files_inner(&entry.path(), out)?;
} else if file_type.is_file() {
out.push(entry.path());
}
}
Ok(())
}

pub(crate) fn hook_command(tracedecay_bin: &str, subcommand: &str) -> String {
hook_command_for_platform(tracedecay_bin, subcommand, cfg!(windows))
}
Expand Down
Loading
Loading