complete secondary import migrations

This commit is contained in:
charlesgong-openai
2026-07-09 20:14:50 -04:00
parent 00e21e5036
commit daa735cfea
18 changed files with 1250 additions and 102 deletions
+1
View File
@@ -3055,6 +3055,7 @@ dependencies = [
"codex-app-server-protocol",
"codex-protocol",
"codex-utils-output-truncation",
"pretty_assertions",
"serde",
"serde_json",
"sha2 0.10.9",
@@ -20,6 +20,7 @@ use codex_core_plugins::marketplace_add::is_local_marketplace_source;
use codex_external_agent_migration::count_missing_subagents;
use codex_external_agent_migration::missing_subagent_names;
use codex_external_agent_sessions::ExternalAgentSessionMigration;
use codex_external_agent_sessions::detect_recent_cursor_sessions;
use codex_external_agent_sessions::detect_recent_sessions;
use codex_plugin::PluginId;
use codex_protocol::protocol::Product;
@@ -479,7 +480,7 @@ impl ExternalAgentConfigService {
let cwd = repo_root.map(Path::to_path_buf);
let source_settings = self.source_settings(repo_root);
let settings = if self.source.supports(SourceFeature::Config) {
effective_external_settings(&source_settings)?
self.effective_source_settings(repo_root)?
} else {
None
};
@@ -488,7 +489,7 @@ impl ExternalAgentConfigService {
|repo_root| repo_root.join(".codex").join("config.toml"),
);
if let Some(settings) = settings.as_ref() {
let migrated = build_config_from_external(settings)?;
let migrated = build_config_from_external(settings, self.source)?;
if !is_empty_toml_table(&migrated) {
let mut should_include = true;
if target_config.exists() {
@@ -719,9 +720,7 @@ impl ExternalAgentConfigService {
);
}
if self.source.supports(SourceFeature::Plugins)
&& let Some(settings) = settings.as_ref()
{
if self.source.supports(SourceFeature::Plugins) {
match ConfigBuilder::default()
.codex_home(self.codex_home.clone())
.fallback_cwd(Some(self.codex_home.clone()))
@@ -748,14 +747,26 @@ impl ExternalAgentConfigService {
&config,
&PluginsManager::new(self.codex_home.clone()),
)?;
if let Some(item) = self.detect_plugin_migration(
source_settings.as_path(),
repo_root.unwrap_or(self.external_agent_home.as_path()),
cwd.clone(),
settings,
&configured_plugin_ids,
&configured_marketplace_plugins,
) {
let item = match self.source {
ExternalAgentSource::Cl => settings.as_ref().and_then(|settings| {
self.detect_plugin_migration(
source_settings.as_path(),
repo_root.unwrap_or(self.external_agent_home.as_path()),
cwd.clone(),
settings,
&configured_plugin_ids,
&configured_marketplace_plugins,
)
}),
ExternalAgentSource::Cu if repo_root.is_none() => self
.detect_cursor_plugin_migration(
cwd.clone(),
&configured_plugin_ids,
&configured_marketplace_plugins,
)?,
ExternalAgentSource::Cu => None,
};
if let Some(item) = item {
items.push(item);
}
}
@@ -770,7 +781,14 @@ impl ExternalAgentConfigService {
}
if repo_root.is_none() && self.source.supports(SourceFeature::Sessions) {
let sessions = detect_recent_sessions(&self.external_agent_home, &self.codex_home)?;
let sessions = match self.source {
ExternalAgentSource::Cl => {
detect_recent_sessions(&self.external_agent_home, &self.codex_home)?
}
ExternalAgentSource::Cu => {
detect_recent_cursor_sessions(&self.external_agent_home, &self.codex_home)?
}
};
if !sessions.is_empty() {
items.push(ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Sessions,
@@ -810,7 +828,19 @@ impl ExternalAgentConfigService {
}
fn source_settings(&self, repo_root: Option<&Path>) -> PathBuf {
self.source_config_dir(repo_root).join("settings.json")
self.source_config_dir(repo_root)
.join(self.source.settings_file_name(repo_root.is_some()))
}
fn effective_source_settings(&self, repo_root: Option<&Path>) -> io::Result<Option<JsonValue>> {
let source_settings = self.source_settings(repo_root);
match self.source {
ExternalAgentSource::Cl => effective_external_settings(&source_settings),
ExternalAgentSource::Cu => source_cu::effective_settings(
self.source_config_dir(repo_root).as_path(),
&source_settings,
),
}
}
fn build_mcp_config(
@@ -849,8 +879,8 @@ impl ExternalAgentConfigService {
source_settings: Option<JsonValue>,
) -> io::Result<Option<JsonValue>> {
if repo_root.is_some() && source_settings.is_none() {
let home_settings = self.external_agent_home.join("settings.json");
match effective_external_settings(&home_settings) {
let home_settings = self.source_settings(/*repo_root*/ None);
match self.effective_source_settings(/*repo_root*/ None) {
Ok(settings) => Ok(settings),
Err(err) => {
tracing::warn!(
@@ -908,16 +938,86 @@ impl ExternalAgentConfigService {
})
}
fn detect_cursor_plugin_migration(
&self,
cwd: Option<PathBuf>,
configured_plugin_ids: &HashSet<String>,
configured_marketplace_plugins: &BTreeMap<String, HashSet<String>>,
) -> io::Result<Option<ExternalAgentConfigMigrationItem>> {
let mut plugins = Vec::new();
for marketplace in source_cu::cached_marketplace_plugins(&self.external_agent_home)? {
let configured_marketplace = configured_marketplace_plugins.get(&marketplace.name);
let plugin_names = marketplace
.plugin_names
.into_iter()
.filter(|plugin_name| {
!configured_plugin_ids.contains(&format!("{plugin_name}@{}", marketplace.name))
&& !configured_marketplace
.is_some_and(|plugins| plugins.contains(plugin_name))
})
.collect::<Vec<_>>();
if !plugin_names.is_empty() {
plugins.push(PluginsMigration {
marketplace_name: marketplace.name,
plugin_names,
});
}
}
if plugins.is_empty() {
return Ok(None);
}
emit_migration_metric(
EXTERNAL_AGENT_CONFIG_DETECT_METRIC,
ExternalAgentConfigMigrationItemType::Plugins,
/*skills_count*/ None,
);
Ok(Some(ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Plugins,
description: format!(
"Migrate cached plugins from {}",
self.external_agent_home.join("plugins/cache").display()
),
cwd,
details: Some(MigrationDetails {
plugins,
..Default::default()
}),
}))
}
fn cursor_marketplace_import_sources(
&self,
) -> io::Result<BTreeMap<String, MarketplaceImportSource>> {
Ok(
source_cu::cached_marketplace_plugins(&self.external_agent_home)?
.into_iter()
.map(|marketplace| {
(
marketplace.name,
MarketplaceImportSource {
source: marketplace.source.display().to_string(),
ref_name: None,
},
)
})
.collect(),
)
}
fn partition_plugin_migration_details(
&self,
cwd: Option<&Path>,
details: MigrationDetails,
) -> io::Result<(Option<MigrationDetails>, Option<MigrationDetails>)> {
let source_settings = self.source_settings(cwd);
let source_root = cwd.unwrap_or(self.external_agent_home.as_path());
let import_sources = effective_external_settings(&source_settings)?
.map(|settings| self.marketplace_import_sources(&settings, source_root))
.unwrap_or_default();
let import_sources = match self.source {
ExternalAgentSource::Cl => {
let source_root = cwd.unwrap_or(self.external_agent_home.as_path());
self.effective_source_settings(cwd)?
.map(|settings| self.marketplace_import_sources(&settings, source_root))
.unwrap_or_default()
}
ExternalAgentSource::Cu => self.cursor_marketplace_import_sources()?,
};
let mut local_plugins = Vec::new();
let mut remote_plugins = Vec::new();
@@ -978,11 +1078,15 @@ impl ExternalAgentConfigService {
if let Some(analytics_events_client) = self.analytics_events_client.clone() {
plugins_manager.set_analytics_events_client(analytics_events_client);
}
let source_settings = self.source_settings(cwd);
let source_root = cwd.unwrap_or(self.external_agent_home.as_path());
let import_sources = effective_external_settings(&source_settings)?
.map(|settings| self.marketplace_import_sources(&settings, source_root))
.unwrap_or_default();
let import_sources = match self.source {
ExternalAgentSource::Cl => {
let source_root = cwd.unwrap_or(self.external_agent_home.as_path());
self.effective_source_settings(cwd)?
.map(|settings| self.marketplace_import_sources(&settings, source_root))
.unwrap_or_default()
}
ExternalAgentSource::Cu => self.cursor_marketplace_import_sources()?,
};
for plugin_group in plugins {
let marketplace_name = plugin_group.marketplace_name.clone();
let plugin_names = plugin_group.plugin_names;
@@ -1215,14 +1319,14 @@ impl ExternalAgentConfigService {
return Ok(None);
} else {
(
self.external_agent_home.join("settings.json"),
self.source_settings(/*repo_root*/ None),
self.codex_home.join("config.toml"),
)
};
let Some(settings) = effective_external_settings(&source_settings)? else {
let Some(settings) = self.effective_source_settings(repo_root.as_deref())? else {
return Ok(None);
};
let migrated = build_config_from_external(&settings)?;
let migrated = build_config_from_external(&settings, self.source)?;
if is_empty_toml_table(&migrated) {
return Ok(None);
}
@@ -1261,21 +1365,15 @@ impl ExternalAgentConfigService {
fn import_mcp_server_config(&self, cwd: Option<&Path>) -> io::Result<Vec<String>> {
let repo_root = find_repo_root(cwd)?;
let (source_settings, target_config) = if let Some(repo_root) = repo_root.as_ref() {
(
self.source_settings(Some(repo_root)),
repo_root.join(".codex").join("config.toml"),
)
let target_config = if let Some(repo_root) = repo_root.as_ref() {
repo_root.join(".codex").join("config.toml")
} else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) {
return Ok(Vec::new());
} else {
(
self.external_agent_home.join("settings.json"),
self.codex_home.join("config.toml"),
)
self.codex_home.join("config.toml")
};
let settings = if self.source.supports(SourceFeature::Config) {
effective_external_settings(&source_settings)?
self.effective_source_settings(repo_root.as_deref())?
} else {
None
};
@@ -1840,7 +1938,10 @@ fn is_non_empty_text_file(path: &Path) -> io::Result<bool> {
Ok(!fs::read_to_string(path)?.trim().is_empty())
}
fn build_config_from_external(settings: &JsonValue) -> io::Result<TomlValue> {
fn build_config_from_external(
settings: &JsonValue,
source: ExternalAgentSource,
) -> io::Result<TomlValue> {
let Some(settings_obj) = settings.as_object() else {
return Err(invalid_data_error(
"external agent settings root must be an object",
@@ -1864,17 +1965,22 @@ fn build_config_from_external(settings: &JsonValue) -> io::Result<TomlValue> {
);
}
if let Some(sandbox_enabled) = settings_obj
.get("sandbox")
.and_then(JsonValue::as_object)
.and_then(|sandbox| sandbox.get("enabled"))
.and_then(JsonValue::as_bool)
&& sandbox_enabled
{
root.insert(
"sandbox_mode".to_string(),
TomlValue::String("workspace-write".to_string()),
);
match source {
ExternalAgentSource::Cl => {
if settings_obj
.get("sandbox")
.and_then(JsonValue::as_object)
.and_then(|sandbox| sandbox.get("enabled"))
.and_then(JsonValue::as_bool)
== Some(true)
{
root.insert(
"sandbox_mode".to_string(),
TomlValue::String("workspace-write".to_string()),
);
}
}
ExternalAgentSource::Cu => source_cu::append_sandbox_config(&mut root, settings_obj),
}
Ok(TomlValue::Table(root))
@@ -53,7 +53,15 @@ impl ExternalAgentSource {
(
Self::Cu,
SourceFeature::Config | SourceFeature::Plugins | SourceFeature::Sessions,
) => false,
) => true,
}
}
pub(super) fn settings_file_name(self, project_scope: bool) -> &'static str {
match (self, project_scope) {
(Self::Cl, _) => "settings.json",
(Self::Cu, false) => source_cu::HOME_CONFIG_FILE,
(Self::Cu, true) => source_cu::PROJECT_CONFIG_FILE,
}
}
@@ -159,14 +167,14 @@ impl ExternalAgentSource {
) -> io::Result<Vec<String>> {
match self {
Self::Cl => source_cl::source_hook_event_names(source_dir, target_hooks),
Self::Cu => Ok(Vec::new()),
Self::Cu => source_cu::source_hook_event_names(source_dir, target_hooks),
}
}
pub(super) fn import_hooks(self, source_dir: &Path, target_hooks: &Path) -> io::Result<bool> {
match self {
Self::Cl => source_cl::import_source_hooks(source_dir, target_hooks),
Self::Cu => Ok(false),
Self::Cu => source_cu::import_source_hooks(source_dir, target_hooks),
}
}
@@ -3,9 +3,13 @@ use codex_external_agent_migration::CommandMigrationProfile;
use codex_external_agent_migration::RewriteProfile;
use codex_external_agent_migration::build_mcp_config_from_json_file;
use codex_external_agent_migration::count_missing_commands_with_profile;
use codex_external_agent_migration::hook_migration_event_names_from_json_file;
use codex_external_agent_migration::import_commands_with_profile;
use codex_external_agent_migration::import_hooks_from_json_file;
use codex_external_agent_migration::import_subagents_with_rewrite_profile;
use codex_external_agent_migration::missing_command_names_with_profile;
use serde_json::Value as JsonValue;
use std::collections::BTreeSet;
use std::fs;
use std::io;
use std::path::Path;
@@ -17,25 +21,111 @@ use super::source::InstructionSourceGroup;
pub(super) const CONFIG_DIR: &str = ".cursor";
pub(super) const LEGACY_RULES_FILE: &str = ".cursorrules";
pub(super) const REWRITE_PROFILE: RewriteProfile = RewriteProfile::new(
LEGACY_RULES_FILE,
&[
"cursor agent",
"cursor-agent",
"cursor_agent",
"cursoragent",
],
)
.with_case_sensitive_term_variants(&["Cursor"]);
pub(super) const HOME_CONFIG_FILE: &str = "cli-config.json";
pub(super) const PROJECT_CONFIG_FILE: &str = "cli.json";
pub(super) const SANDBOX_CONFIG_FILE: &str = "sandbox.json";
pub(super) const HOOKS_CONFIG_FILE: &str = "hooks.json";
const SANDBOX_SETTINGS_KEY: &str = "__cursorSandbox";
pub(super) const REWRITE_PROFILE: RewriteProfile =
RewriteProfile::new(LEGACY_RULES_FILE, &[]).with_case_sensitive_term_variants(&["Cursor"]);
const COMMAND_MIGRATION_PROFILE: CommandMigrationProfile = CommandMigrationProfile::new(
REWRITE_PROFILE,
CommandDescriptionMode::UseSourceNameFallback,
);
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct CachedMarketplacePlugins {
pub(super) name: String,
pub(super) source: PathBuf,
pub(super) plugin_names: Vec<String>,
}
pub(super) fn build_mcp_config(source_dir: &Path) -> io::Result<TomlValue> {
build_mcp_config_from_json_file(&source_dir.join("mcp.json"))
}
pub(super) fn effective_settings(
source_dir: &Path,
source_settings: &Path,
) -> io::Result<Option<JsonValue>> {
let mut effective = super::read_external_settings(source_settings)?;
let sandbox_settings = super::read_external_settings(&source_dir.join(SANDBOX_CONFIG_FILE))?;
if let Some(sandbox_settings) = sandbox_settings {
let effective = effective.get_or_insert_with(|| JsonValue::Object(serde_json::Map::new()));
let Some(effective) = effective.as_object_mut() else {
return Err(super::invalid_data_error(
"external agent settings root must be an object",
));
};
effective.insert(SANDBOX_SETTINGS_KEY.to_string(), sandbox_settings);
}
Ok(effective)
}
pub(super) fn append_sandbox_config(
root: &mut toml::map::Map<String, TomlValue>,
settings: &serde_json::Map<String, JsonValue>,
) {
let Some(sandbox) = settings
.get(SANDBOX_SETTINGS_KEY)
.and_then(JsonValue::as_object)
else {
return;
};
let sandbox_mode = match sandbox.get("type").and_then(JsonValue::as_str) {
Some("workspace_readwrite") => Some("workspace-write"),
Some("read_only") => Some("read-only"),
_ => None,
};
if let Some(sandbox_mode) = sandbox_mode {
root.insert(
"sandbox_mode".to_string(),
TomlValue::String(sandbox_mode.to_string()),
);
}
if sandbox_mode != Some("workspace-write") {
return;
}
let mut workspace_write = toml::map::Map::new();
if let Some(paths) = sandbox
.get("additionalReadwritePaths")
.and_then(JsonValue::as_array)
{
let paths = paths
.iter()
.filter_map(JsonValue::as_str)
.filter(|path| Path::new(path).is_absolute())
.map(|path| TomlValue::String(path.to_string()))
.collect::<Vec<_>>();
if !paths.is_empty() {
workspace_write.insert("writable_roots".to_string(), TomlValue::Array(paths));
}
}
if sandbox.get("disableTmpWrite").and_then(JsonValue::as_bool) == Some(true) {
workspace_write.insert("exclude_slash_tmp".to_string(), TomlValue::Boolean(true));
workspace_write.insert(
"exclude_tmpdir_env_var".to_string(),
TomlValue::Boolean(true),
);
}
if sandbox
.get("networkPolicy")
.and_then(JsonValue::as_object)
.and_then(|network| network.get("default"))
.and_then(JsonValue::as_str)
== Some("allow")
{
workspace_write.insert("network_access".to_string(), TomlValue::Boolean(true));
}
if !workspace_write.is_empty() {
root.insert(
"sandbox_workspace_write".to_string(),
TomlValue::Table(workspace_write),
);
}
}
pub(super) fn repo_instruction_source_groups(
repo_root: &Path,
) -> io::Result<Vec<InstructionSourceGroup>> {
@@ -101,6 +191,107 @@ pub(super) fn import_source_subagents(
import_subagents_with_rewrite_profile(source_agents, target_agents, REWRITE_PROFILE)
}
pub(super) fn source_hook_event_names(
source_dir: &Path,
target_hooks: &Path,
) -> io::Result<Vec<String>> {
hook_migration_event_names_from_json_file(
source_dir,
&source_dir.join(HOOKS_CONFIG_FILE),
target_hooks,
REWRITE_PROFILE,
)
}
pub(super) fn import_source_hooks(source_dir: &Path, target_hooks: &Path) -> io::Result<bool> {
import_hooks_from_json_file(
source_dir,
&source_dir.join(HOOKS_CONFIG_FILE),
target_hooks,
REWRITE_PROFILE,
)
}
pub(super) fn cached_marketplace_plugins(
external_agent_home: &Path,
) -> io::Result<Vec<CachedMarketplacePlugins>> {
let marketplaces_root = external_agent_home.join("plugins/marketplaces");
let cache_root = external_agent_home.join("plugins/cache");
if !marketplaces_root.is_dir() || !cache_root.is_dir() {
return Ok(Vec::new());
}
let mut marketplaces = Vec::new();
for entry in fs::read_dir(marketplaces_root)? {
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}
let marketplace_root = entry.path();
let manifest_path = marketplace_root.join(".cursor-plugin/marketplace.json");
if !manifest_path.is_file() {
continue;
}
let manifest = match fs::read_to_string(&manifest_path) {
Ok(manifest) => manifest,
Err(err) => {
tracing::warn!(
path = %manifest_path.display(),
error = %err,
"ignoring unreadable external marketplace manifest"
);
continue;
}
};
let manifest: JsonValue = match serde_json::from_str(&manifest) {
Ok(manifest) => manifest,
Err(err) => {
tracing::warn!(
path = %manifest_path.display(),
error = %err,
"ignoring invalid external marketplace manifest"
);
continue;
}
};
let Some(name) = manifest.get("name").and_then(JsonValue::as_str) else {
continue;
};
let available_plugins = manifest
.get("plugins")
.and_then(JsonValue::as_array)
.into_iter()
.flatten()
.filter_map(|plugin| plugin.get("name").and_then(JsonValue::as_str))
.collect::<BTreeSet<_>>();
let cache_marketplace = cache_root.join(entry.file_name());
if !cache_marketplace.is_dir() {
continue;
}
let mut plugin_names = fs::read_dir(cache_marketplace)?
.filter_map(Result::ok)
.filter_map(|plugin| {
plugin
.file_type()
.ok()
.filter(std::fs::FileType::is_dir)
.and_then(|_| plugin.file_name().into_string().ok())
})
.filter(|plugin_name| available_plugins.contains(plugin_name.as_str()))
.collect::<Vec<_>>();
plugin_names.sort();
if !plugin_names.is_empty() {
marketplaces.push(CachedMarketplacePlugins {
name: name.to_string(),
source: marketplace_root,
plugin_names,
});
}
}
marketplaces.sort_by(|left, right| left.name.cmp(&right.name));
Ok(marketplaces)
}
fn instruction_sources(
legacy_rules: Option<PathBuf>,
rules_dir: &Path,
@@ -42,6 +42,9 @@ use super::analytics::wait_for_analytics_event;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
#[path = "external_agent_config_source_cu.rs"]
mod source_cu_tests;
fn external_agent_home(codex_home: &Path) -> PathBuf {
codex_home.join(concat!(".", "cl", "aude"))
}
@@ -0,0 +1,225 @@
use super::*;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn imports_cursor_config_plugins_hooks_sessions_and_capitalized_text() -> Result<()> {
let root = TempDir::new()?;
let home = root.path().join("home");
let codex_home = home.join(".codex");
let source_dir = home.join(".cursor");
let project_root = root.path().join("workspace-with-dashes");
let writable_root = root.path().join("shared-cache");
std::fs::create_dir_all(&codex_home)?;
std::fs::create_dir_all(&source_dir)?;
std::fs::create_dir_all(&project_root)?;
std::fs::create_dir_all(project_root.join(".git"))?;
std::fs::create_dir_all(&writable_root)?;
std::fs::write(
source_dir.join("cli-config.json"),
r#"{"permissions":{"allow":["Shell(git)"]}}"#,
)?;
std::fs::write(
source_dir.join("sandbox.json"),
serde_json::to_string(&serde_json::json!({
"type": "workspace_readwrite",
"additionalReadwritePaths": [writable_root],
"disableTmpWrite": true,
"networkPolicy": {"default": "allow"}
}))?,
)?;
std::fs::create_dir_all(source_dir.join("skills/release"))?;
std::fs::write(
source_dir.join("skills/release/SKILL.md"),
concat!(
"---\nname: release\ndescription: Release with Cursor\n---\n\n",
"Use Cursor to release. Keep the database cursor and CURSOR acronym unchanged.\n"
),
)?;
std::fs::create_dir_all(source_dir.join("hooks"))?;
std::fs::write(
source_dir.join("hooks/session-start.sh"),
"#!/bin/sh\nexit 0\n",
)?;
std::fs::write(
source_dir.join("hooks.json"),
r#"{
"version": 1,
"hooks": {
"sessionStart": [{
"command": "sh .cursor/hooks/session-start.sh",
"statusMessage": "Starting Cursor context"
}],
"preToolUse": [{
"command": "sh .cursor/hooks/session-start.sh",
"matcher": "Shell"
}],
"afterAgentResponse": [{"command": "echo unsupported"}]
}
}"#,
)?;
let marketplace_root = source_dir.join("plugins/marketplaces/team-tools");
let plugin_root = marketplace_root.join("release-helper");
std::fs::create_dir_all(marketplace_root.join(".cursor-plugin"))?;
std::fs::create_dir_all(plugin_root.join(".cursor-plugin"))?;
std::fs::create_dir_all(plugin_root.join("skills/plugin-release"))?;
std::fs::write(
marketplace_root.join(".cursor-plugin/marketplace.json"),
r#"{
"name": "team-tools",
"plugins": [{"name": "release-helper", "source": "release-helper"}]
}"#,
)?;
std::fs::write(
plugin_root.join(".cursor-plugin/plugin.json"),
r#"{
"name": "release-helper",
"version": "1.0.0",
"skills": "skills"
}"#,
)?;
std::fs::write(
plugin_root.join("skills/plugin-release/SKILL.md"),
"---\nname: plugin-release\ndescription: Release helper\n---\n",
)?;
std::fs::create_dir_all(source_dir.join("plugins/cache/team-tools/release-helper/revision"))?;
let session_id = "11111111-1111-1111-1111-111111111111";
let session_path = source_dir
.join("projects")
.join(encoded_cursor_project_path(&project_root))
.join("agent-transcripts")
.join(session_id)
.join(format!("{session_id}.jsonl"));
std::fs::create_dir_all(session_path.parent().expect("session parent"))?;
std::fs::write(
&session_path,
concat!(
"{\"role\":\"user\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"review the release\"}]}}\n",
"{\"role\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"release looks good\"}]}}\n"
),
)?;
let home_dir = home.display().to_string();
let mut mcp = TestAppServer::builder()
.with_codex_home(&codex_home)
.with_env_overrides(&[("HOME", Some(home_dir.as_str()))])
.build()
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_raw_request(
"externalAgentConfig/detect",
Some(serde_json::json!({
"includeHome": true,
"cwds": [project_root],
"source": "cursor"
})),
)
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let detected: ExternalAgentConfigDetectResponse = to_response(response)?;
let detected_types = detected
.items
.iter()
.map(|item| item.item_type)
.collect::<Vec<_>>();
for expected in [
ExternalAgentConfigMigrationItemType::Config,
ExternalAgentConfigMigrationItemType::Hooks,
ExternalAgentConfigMigrationItemType::Skills,
ExternalAgentConfigMigrationItemType::Plugins,
ExternalAgentConfigMigrationItemType::Sessions,
] {
assert!(detected_types.contains(&expected), "missing {expected:?}");
}
assert_eq!(
detected_types
.iter()
.filter(|item_type| **item_type == ExternalAgentConfigMigrationItemType::Plugins)
.count(),
1
);
let request_id = mcp
.send_raw_request(
"externalAgentConfig/import",
Some(serde_json::json!({
"migrationItems": detected.items,
"source": "cursor"
})),
)
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: ExternalAgentConfigImportResponse = to_response(response)?;
let import_id = assert_import_response(response);
let notification = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_notification_message("externalAgentConfig/import/completed"),
)
.await??;
let completed: ExternalAgentConfigImportCompletedNotification =
serde_json::from_value(notification.params.expect("completed params"))?;
assert_eq!(completed.import_id, import_id);
assert!(
completed
.item_type_results
.iter()
.all(|result| result.failures.is_empty()),
"{:#?}",
completed.item_type_results
);
let session_result = completed
.item_type_results
.iter()
.find(|result| result.item_type == ExternalAgentConfigMigrationItemType::Sessions)
.expect("session result");
assert_eq!(session_result.successes.len(), 1);
let config = std::fs::read_to_string(codex_home.join("config.toml"))?;
assert!(config.contains("sandbox_mode = \"workspace-write\""));
assert!(config.contains("network_access = true"));
assert!(config.contains("exclude_slash_tmp = true"));
assert!(config.contains(writable_root.to_string_lossy().as_ref()));
assert!(config.contains(r#"[plugins."release-helper@team-tools"]"#));
let skill = std::fs::read_to_string(home.join(".agents/skills/release/SKILL.md"))?;
assert!(skill.contains("Release with Codex"));
assert!(skill.contains("Use Codex to release"));
assert!(skill.contains("database cursor"));
assert!(skill.contains("CURSOR acronym"));
let hooks = std::fs::read_to_string(codex_home.join("hooks.json"))?;
assert!(hooks.contains("SessionStart"));
assert!(hooks.contains("PreToolUse"));
assert!(hooks.contains("Shell"));
assert!(hooks.contains("Starting Codex context"));
assert!(!hooks.contains("afterAgentResponse"));
assert!(codex_home.join("hooks/session-start.sh").is_file());
Ok(())
}
#[cfg(not(windows))]
fn encoded_cursor_project_path(path: &Path) -> String {
path.to_string_lossy()
.trim_start_matches('/')
.replace('/', "-")
}
#[cfg(windows)]
fn encoded_cursor_project_path(path: &Path) -> String {
path.to_string_lossy().replace([':', '\\'], "-")
}
+85 -4
View File
@@ -162,12 +162,18 @@ pub(crate) fn parse_plugin_manifest_uri(
version,
description,
keywords,
skills,
mcp_servers,
apps,
hooks,
mut skills,
mut mcp_servers,
mut apps,
mut hooks,
interface,
} = serde_json::from_str::<RawPluginManifest>(contents)?;
if manifest_path
.to_string()
.ends_with(".cursor-plugin/plugin.json")
{
normalize_cursor_manifest_paths(&mut skills, &mut mcp_servers, &mut apps, &mut hooks);
}
let name = plugin_root
.basename()
.filter(|_| raw_name.trim().is_empty())
@@ -267,6 +273,45 @@ pub(crate) fn parse_plugin_manifest_uri(
})
}
fn normalize_cursor_manifest_paths(
skills: &mut Option<RawPluginManifestPaths>,
mcp_servers: &mut Option<RawPluginManifestMcpServers>,
apps: &mut Option<String>,
hooks: &mut Option<RawPluginManifestHooks>,
) {
match skills {
Some(RawPluginManifestPaths::Path(path)) => normalize_cursor_manifest_path(path),
Some(RawPluginManifestPaths::Paths(paths)) => {
paths.iter_mut().for_each(normalize_cursor_manifest_path);
}
Some(RawPluginManifestPaths::Invalid(_)) | None => {}
}
if let Some(RawPluginManifestMcpServers::Path(path)) = mcp_servers {
normalize_cursor_manifest_path(path);
}
if let Some(apps) = apps {
normalize_cursor_manifest_path(apps);
}
match hooks {
Some(RawPluginManifestHooks::Path(path)) => normalize_cursor_manifest_path(path),
Some(RawPluginManifestHooks::Paths(paths)) => {
paths.iter_mut().for_each(normalize_cursor_manifest_path);
}
Some(
RawPluginManifestHooks::Inline(_)
| RawPluginManifestHooks::InlineList(_)
| RawPluginManifestHooks::Invalid(_),
)
| None => {}
}
}
fn normalize_cursor_manifest_path(path: &mut String) {
if !path.starts_with("./") {
path.insert_str(0, "./");
}
}
fn resolve_manifest_hooks(
plugin_root: &PathUri,
hooks: Option<RawPluginManifestHooks>,
@@ -532,6 +577,7 @@ mod tests {
use crate::ExecutorPluginProvider;
const ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH: &str = ".claude-plugin/plugin.json";
const CURSOR_PLUGIN_MANIFEST_RELATIVE_PATH: &str = ".cursor-plugin/plugin.json";
fn write_manifest(plugin_root: &Path, version: Option<&str>, interface: &str) {
fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create manifest dir");
@@ -558,6 +604,13 @@ mod tests {
fs::write(manifest_path, contents).expect("write manifest");
}
fn write_cursor_plugin_manifest(plugin_root: &Path, contents: &str) {
let manifest_path = plugin_root.join(CURSOR_PLUGIN_MANIFEST_RELATIVE_PATH);
fs::create_dir_all(manifest_path.parent().expect("manifest parent"))
.expect("create manifest dir");
fs::write(manifest_path, contents).expect("write manifest");
}
fn load_manifest(plugin_root: &Path) -> PluginManifest {
load_plugin_manifest(plugin_root).expect("load plugin manifest")
}
@@ -732,6 +785,34 @@ mod tests {
);
}
#[test]
fn cursor_plugin_manifest_accepts_bare_relative_paths() {
let tmp = tempdir().expect("tempdir");
let plugin_root = tmp.path().join("demo-plugin");
write_cursor_plugin_manifest(
&plugin_root,
r#"{
"name": "demo-plugin",
"skills": "skills",
"hooks": "hooks/hooks.json"
}"#,
);
let manifest = load_manifest(&plugin_root);
assert_eq!(
manifest.paths.skills,
vec![AbsolutePathBuf::try_from(plugin_root.join("skills")).expect("absolute skills")]
);
assert_eq!(
manifest.paths.hooks,
Some(PluginManifestHooks::Paths(vec![
AbsolutePathBuf::try_from(plugin_root.join("hooks/hooks.json"))
.expect("absolute hooks")
]))
);
}
#[test]
fn uri_manifest_uses_the_root_path_convention() {
let windows_root =
+8 -1
View File
@@ -21,6 +21,7 @@ const MARKETPLACE_MANIFEST_RELATIVE_PATHS: &[&str] = &[
".agents/plugins/marketplace.json",
".agents/plugins/api_marketplace.json",
".claude-plugin/marketplace.json",
".cursor-plugin/marketplace.json",
];
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -662,7 +663,13 @@ fn resolve_local_plugin_source_path(
}
// Non-root local sources must keep the explicit `./` prefix and remain normalized.
let Some(relative_path) = path.strip_prefix("./") else {
let relative_path = path.strip_prefix("./").or_else(|| {
marketplace_path
.as_path()
.ends_with(".cursor-plugin/marketplace.json")
.then_some(path)
});
let Some(relative_path) = relative_path else {
return Err(MarketplaceError::InvalidMarketplaceFile {
path: marketplace_path.to_path_buf(),
message: "local plugin source path must start with `./`".to_string(),
@@ -6,6 +6,8 @@ use tempfile::tempdir;
const ALTERNATE_MARKETPLACE_RELATIVE_PATH: &str = ".claude-plugin/marketplace.json";
const ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH: &str = ".claude-plugin/plugin.json";
const CURSOR_MARKETPLACE_RELATIVE_PATH: &str = ".cursor-plugin/marketplace.json";
const CURSOR_PLUGIN_MANIFEST_RELATIVE_PATH: &str = ".cursor-plugin/plugin.json";
fn write_alternate_marketplace(repo_root: &Path, contents: &str) -> AbsolutePathBuf {
let marketplace_path = repo_root.join(ALTERNATE_MARKETPLACE_RELATIVE_PATH);
fs::create_dir_all(marketplace_path.parent().unwrap()).unwrap();
@@ -19,6 +21,19 @@ fn write_alternate_plugin_manifest(plugin_root: &Path, contents: &str) {
fs::write(manifest_path, contents).unwrap();
}
fn write_cursor_marketplace(repo_root: &Path, contents: &str) -> AbsolutePathBuf {
let marketplace_path = repo_root.join(CURSOR_MARKETPLACE_RELATIVE_PATH);
fs::create_dir_all(marketplace_path.parent().unwrap()).unwrap();
fs::write(&marketplace_path, contents).unwrap();
AbsolutePathBuf::try_from(marketplace_path).unwrap()
}
fn write_cursor_plugin_manifest(plugin_root: &Path, contents: &str) {
let manifest_path = plugin_root.join(CURSOR_PLUGIN_MANIFEST_RELATIVE_PATH);
fs::create_dir_all(manifest_path.parent().unwrap()).unwrap();
fs::write(manifest_path, contents).unwrap();
}
fn minimal_manifest_fallback(name: &str) -> MarketplacePluginManifestFallback {
MarketplacePluginManifestFallback {
contents: format!(
@@ -124,6 +139,37 @@ fn find_marketplace_plugin_supports_alternate_layout_and_string_local_source() {
);
}
#[test]
fn find_marketplace_plugin_supports_cursor_layout_and_bare_local_source() {
let tmp = tempdir().unwrap();
let repo_root = tmp.path().join("repo");
let plugin_root = repo_root.join("plugins/sample");
let marketplace_path = write_cursor_marketplace(
&repo_root,
r#"{
"name": "cursor-marketplace",
"plugins": [{"name": "sample", "source": "plugins/sample"}]
}"#,
);
write_cursor_plugin_manifest(&plugin_root, r#"{"name":"sample"}"#);
let resolved = find_marketplace_plugin(&marketplace_path, "sample").unwrap();
assert_eq!(
resolved.source,
MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(plugin_root).unwrap(),
}
);
assert_eq!(
resolved
.manifest
.as_ref()
.map(|manifest| manifest.name.as_str()),
Some("sample")
);
}
#[test]
fn find_marketplace_plugin_supports_git_subdir_sources() {
let tmp = tempdir().unwrap();
@@ -0,0 +1,151 @@
use super::RewriteProfile;
use super::invalid_data_error;
use super::json_u64;
use super::rewrite_external_agent_terms;
use super::rewrite_hook_command_for_source;
use super::write_hook_migration;
use codex_hooks::HOOK_EVENT_NAMES_WITH_MATCHERS;
use serde_json::Value as JsonValue;
use std::fs;
use std::io;
use std::path::Path;
pub fn hook_migration_event_names_from_json_file(
source_external_agent_dir: &Path,
source_hooks: &Path,
target_hooks: &Path,
rewrite_profile: RewriteProfile,
) -> io::Result<Vec<String>> {
let migration = hook_migration_from_json_file(
source_external_agent_dir,
source_hooks,
target_hooks.parent(),
rewrite_profile,
)?;
Ok(migration.keys().cloned().collect())
}
pub fn import_hooks_from_json_file(
source_external_agent_dir: &Path,
source_hooks: &Path,
target_hooks: &Path,
rewrite_profile: RewriteProfile,
) -> io::Result<bool> {
let migration = hook_migration_from_json_file(
source_external_agent_dir,
source_hooks,
target_hooks.parent(),
rewrite_profile,
)?;
write_hook_migration(source_external_agent_dir, target_hooks, migration)
}
fn hook_migration_from_json_file(
source_external_agent_dir: &Path,
source_hooks: &Path,
target_config_dir: Option<&Path>,
rewrite_profile: RewriteProfile,
) -> io::Result<serde_json::Map<String, JsonValue>> {
if !source_hooks.is_file() {
return Ok(serde_json::Map::new());
}
let raw = fs::read_to_string(source_hooks)?;
let settings: JsonValue = serde_json::from_str(&raw)
.map_err(|err| invalid_data_error(format!("invalid hooks config: {err}")))?;
let Some(source_hooks) = settings.get("hooks").and_then(JsonValue::as_object) else {
return Ok(serde_json::Map::new());
};
let mut migration = serde_json::Map::new();
for (source_event_name, handlers) in source_hooks {
let Some(event_name) = compatible_hook_event_name(source_event_name) else {
continue;
};
let Some(handlers) = handlers.as_array() else {
continue;
};
for handler in handlers {
let Some(handler) = handler.as_object() else {
continue;
};
if handler.keys().any(|key| {
!matches!(
key.as_str(),
"command" | "matcher" | "statusMessage" | "timeout" | "timeoutSec" | "type"
)
}) {
continue;
}
let Some(command) = handler
.get("command")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|command| !command.is_empty())
else {
continue;
};
let mut command_payload = serde_json::Map::new();
command_payload.insert("type".to_string(), JsonValue::String("command".to_string()));
command_payload.insert(
"command".to_string(),
JsonValue::String(rewrite_hook_command_for_source(
command,
target_config_dir,
source_external_agent_dir,
)),
);
if let Some(timeout) = handler
.get("timeout")
.or_else(|| handler.get("timeoutSec"))
.and_then(json_u64)
{
command_payload.insert(
"timeout".to_string(),
JsonValue::Number(serde_json::Number::from(timeout)),
);
}
if let Some(status_message) = handler.get("statusMessage").and_then(JsonValue::as_str) {
command_payload.insert(
"statusMessage".to_string(),
JsonValue::String(rewrite_external_agent_terms(
status_message,
rewrite_profile,
)),
);
}
let mut group_payload = serde_json::Map::new();
if HOOK_EVENT_NAMES_WITH_MATCHERS.contains(&event_name)
&& let Some(matcher) = handler.get("matcher").and_then(JsonValue::as_str)
{
group_payload.insert(
"matcher".to_string(),
JsonValue::String(matcher.to_string()),
);
}
group_payload.insert(
"hooks".to_string(),
JsonValue::Array(vec![JsonValue::Object(command_payload)]),
);
let groups = migration
.entry(event_name.to_string())
.or_insert_with(|| JsonValue::Array(Vec::new()));
if let Some(groups) = groups.as_array_mut() {
groups.push(JsonValue::Object(group_payload));
}
}
}
Ok(migration)
}
fn compatible_hook_event_name(event_name: &str) -> Option<&'static str> {
match event_name {
"preToolUse" => Some("PreToolUse"),
"postToolUse" => Some("PostToolUse"),
"sessionStart" => Some("SessionStart"),
"beforeSubmitPrompt" => Some("UserPromptSubmit"),
"stop" => Some("Stop"),
_ => None,
}
}
+42 -18
View File
@@ -1,5 +1,7 @@
//! Migration helpers for importing external-agent configuration into Codex.
mod json_hooks;
use codex_hooks::HOOK_EVENT_NAMES;
use codex_hooks::HOOK_EVENT_NAMES_WITH_MATCHERS;
use serde_json::Value as JsonValue;
@@ -12,6 +14,9 @@ use std::path::Path;
use std::path::PathBuf;
use toml::Value as TomlValue;
pub use json_hooks::hook_migration_event_names_from_json_file;
pub use json_hooks::import_hooks_from_json_file;
const SOURCE_EXTERNAL_AGENT_NAME: &str = "claude";
const EXTERNAL_AGENT_MCP_CONFIG_FILE: &str = ".mcp.json";
const EXTERNAL_AGENT_HOOKS_SUBDIR: &str = "hooks";
@@ -211,20 +216,7 @@ pub fn import_hooks(
return Ok(false);
}
fs::create_dir_all(parent)?;
let mut wrote_active_hooks = false;
if is_missing_or_empty_text_file(target_hooks)? {
copy_hook_scripts(source_external_agent_dir, parent)?;
let mut payload = serde_json::Map::new();
payload.insert("hooks".to_string(), JsonValue::Object(migration));
let rendered = serde_json::to_string_pretty(&JsonValue::Object(payload))
.map_err(|err| invalid_data_error(format!("failed to serialize hooks.json: {err}")))?;
fs::write(target_hooks, format!("{rendered}\n"))?;
wrote_active_hooks = true;
}
Ok(wrote_active_hooks)
write_hook_migration(source_external_agent_dir, target_hooks, migration)
}
pub fn count_missing_subagents(source_agents: &Path, target_agents: &Path) -> io::Result<usize> {
@@ -664,6 +656,27 @@ fn hook_migration(
Ok(migration)
}
fn write_hook_migration(
source_external_agent_dir: &Path,
target_hooks: &Path,
migration: serde_json::Map<String, JsonValue>,
) -> io::Result<bool> {
if migration.is_empty() || !is_missing_or_empty_text_file(target_hooks)? {
return Ok(false);
}
let Some(parent) = target_hooks.parent() else {
return Err(invalid_data_error("hooks target path has no parent"));
};
fs::create_dir_all(parent)?;
copy_hook_scripts(source_external_agent_dir, parent)?;
let mut payload = serde_json::Map::new();
payload.insert("hooks".to_string(), JsonValue::Object(migration));
let rendered = serde_json::to_string_pretty(&JsonValue::Object(payload))
.map_err(|err| invalid_data_error(format!("failed to serialize hooks.json: {err}")))?;
fs::write(target_hooks, format!("{rendered}\n"))?;
Ok(true)
}
fn append_convertible_hook_groups(
settings: &JsonValue,
hooks_payload: &mut serde_json::Map<String, JsonValue>,
@@ -794,6 +807,15 @@ fn append_convertible_hook_groups(
}
fn rewrite_hook_command(command: &str, target_config_dir: Option<&Path>) -> String {
let source_external_agent_dir = PathBuf::from(external_agent_config_dir());
rewrite_hook_command_for_source(command, target_config_dir, &source_external_agent_dir)
}
fn rewrite_hook_command_for_source(
command: &str,
target_config_dir: Option<&Path>,
source_external_agent_dir: &Path,
) -> String {
let Some(target_config_dir) = target_config_dir else {
return command.to_string();
};
@@ -801,10 +823,12 @@ fn rewrite_hook_command(command: &str, target_config_dir: Option<&Path>) -> Stri
return command.to_string();
}
let target_hooks_dir = target_config_dir.join(EXTERNAL_AGENT_MIGRATED_HOOKS_SUBDIR);
let source_hooks_path = format!(
"{}/{EXTERNAL_AGENT_HOOKS_SUBDIR}/",
external_agent_config_dir()
);
let source_config_dir = source_external_agent_dir
.file_name()
.and_then(|name| name.to_str())
.map(str::to_string)
.unwrap_or_else(external_agent_config_dir);
let source_hooks_path = format!("{source_config_dir}/{EXTERNAL_AGENT_HOOKS_SUBDIR}/");
let command = replace_quoted_hook_paths(command, '\'', &source_hooks_path, &target_hooks_dir);
let command = replace_quoted_hook_paths(&command, '"', &source_hooks_path, &target_hooks_dir);
replace_unquoted_hook_paths(&command, &source_hooks_path, &target_hooks_dir)
@@ -22,4 +22,5 @@ sha2 = { workspace = true }
[dev-dependencies]
codex-app-server-protocol = { workspace = true }
pretty_assertions = { workspace = true }
tempfile = { workspace = true }
@@ -0,0 +1,182 @@
use crate::ExternalAgentSessionMigration;
use crate::ledger::load_import_ledger;
use crate::ledger::save_import_ledger;
use crate::now_unix_seconds;
use crate::records::summarize_session_with_cwd;
use std::cmp::Reverse;
use std::collections::BinaryHeap;
use std::fs;
use std::io;
use std::path::Path;
use std::path::PathBuf;
use std::time::Duration;
const SESSION_IMPORT_MAX_COUNT: usize = 50;
const SESSION_IMPORT_MAX_AGE: Duration = Duration::from_secs(30 * 24 * 60 * 60);
pub fn detect_recent_cursor_sessions(
external_agent_home: &Path,
codex_home: &Path,
) -> io::Result<Vec<ExternalAgentSessionMigration>> {
let projects_root = external_agent_home.join("projects");
if !projects_root.is_dir() {
return Ok(Vec::new());
}
let now = now_unix_seconds();
let mut ledger = load_import_ledger(codex_home)?;
let source_states = ledger.source_states();
let mut candidates = BinaryHeap::with_capacity(SESSION_IMPORT_MAX_COUNT + 1);
for project_entry in fs::read_dir(projects_root)? {
let Ok(project_entry) = project_entry else {
continue;
};
let project_storage = project_entry.path();
if !project_storage.is_dir() {
continue;
}
let Some(cwd) = cursor_project_cwd(&project_storage) else {
continue;
};
for path in cursor_transcript_files(&project_storage.join("agent-transcripts")) {
let Ok(metadata) = fs::metadata(&path) else {
continue;
};
let Ok(modified_at) = metadata.modified() else {
continue;
};
let Ok(modified_at) = modified_at.duration_since(std::time::UNIX_EPOCH) else {
continue;
};
if (modified_at.as_secs() as i64)
< now.saturating_sub(SESSION_IMPORT_MAX_AGE.as_secs() as i64)
{
continue;
}
let Ok(modified_at_nanos) = i64::try_from(modified_at.as_nanos()) else {
continue;
};
let Ok(source_path) = fs::canonicalize(&path) else {
continue;
};
if let Some(state) = source_states.get(source_path.as_path())
&& (state.source_modified_at == Some(modified_at_nanos)
|| state.source_modified_at.is_none()
&& modified_at.as_secs() as i64 <= state.imported_at)
{
continue;
}
candidates.push((Reverse(modified_at_nanos), path, cwd.clone()));
if candidates.len() > SESSION_IMPORT_MAX_COUNT {
candidates.pop();
}
}
}
drop(source_states);
let mut migrations = Vec::new();
let mut ledger_changed = false;
for (modified_at, path, cwd) in candidates.into_sorted_vec() {
match ledger.refresh_current_source(&path, modified_at.0) {
Ok(false) => {}
Ok(true) => {
ledger_changed = true;
continue;
}
Err(_) => continue,
}
let Ok(Some(summary)) = summarize_session_with_cwd(&path, Some(&cwd)) else {
continue;
};
migrations.push(summary.migration);
}
if ledger_changed {
save_import_ledger(codex_home, &ledger)?;
}
Ok(migrations)
}
fn cursor_transcript_files(transcripts_root: &Path) -> Vec<PathBuf> {
let mut files = Vec::new();
let mut pending = vec![transcripts_root.to_path_buf()];
while let Some(directory) = pending.pop() {
let Ok(entries) = fs::read_dir(directory) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_dir() {
if entry.file_name() != "subagents" {
pending.push(path);
}
} else if file_type.is_file()
&& path.extension().and_then(|extension| extension.to_str()) == Some("jsonl")
{
files.push(path);
}
}
}
files.sort();
files
}
fn cursor_project_cwd(project_storage: &Path) -> Option<PathBuf> {
let encoded = project_storage.file_name()?.to_str()?;
decode_cursor_project_path(encoded)
}
#[cfg(not(windows))]
fn decode_cursor_project_path(encoded: &str) -> Option<PathBuf> {
decode_cursor_project_path_from(encoded, Path::new("/"), /*depth*/ 0).or_else(|| {
encoded.strip_prefix('-').and_then(|encoded| {
decode_cursor_project_path_from(encoded, Path::new("/"), /*depth*/ 0)
})
})
}
#[cfg(windows)]
fn decode_cursor_project_path(encoded: &str) -> Option<PathBuf> {
let (drive, remaining) = encoded.split_once('-')?;
if drive.len() != 1 || !drive.as_bytes()[0].is_ascii_alphabetic() {
return None;
}
let base = PathBuf::from(format!("{drive}:\\"));
decode_cursor_project_path_from(remaining, &base, /*depth*/ 0).or_else(|| {
remaining.strip_prefix('-').and_then(|remaining| {
decode_cursor_project_path_from(remaining, &base, /*depth*/ 0)
})
})
}
fn decode_cursor_project_path_from(encoded: &str, base: &Path, depth: usize) -> Option<PathBuf> {
if encoded.is_empty() || depth > 32 {
return None;
}
let whole = base.join(encoded);
if whole.is_dir() {
return Some(whole);
}
for (index, _) in encoded.match_indices('-') {
let segment = &encoded[..index];
if segment.is_empty() {
continue;
}
let candidate = base.join(segment);
if !candidate.is_dir() {
continue;
}
if let Some(path) =
decode_cursor_project_path_from(&encoded[index + 1..], &candidate, depth + 1)
{
return Some(path);
}
}
None
}
#[cfg(test)]
#[path = "detect_cursor_tests.rs"]
mod tests;
@@ -0,0 +1,51 @@
use super::*;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
#[test]
fn detects_cursor_transcript_with_project_cwd() {
let root = TempDir::new().expect("tempdir");
let project_root = root.path().join("workspace-with-dashes");
fs::create_dir_all(&project_root).expect("project root");
let external_agent_home = root.path().join(".cursor");
let encoded_project = encode_project_path(&project_root);
let session_id = "a-session";
let transcript = external_agent_home
.join("projects")
.join(encoded_project)
.join("agent-transcripts")
.join(session_id)
.join(format!("{session_id}.jsonl"));
fs::create_dir_all(transcript.parent().expect("transcript parent"))
.expect("transcript directory");
fs::write(
&transcript,
concat!(
"{\"role\":\"user\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"first request\"}]}}\n",
"{\"role\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"first answer\"}]}}\n"
),
)
.expect("transcript");
let sessions =
detect_recent_cursor_sessions(&external_agent_home, root.path()).expect("detect sessions");
assert_eq!(
sessions,
vec![ExternalAgentSessionMigration {
path: transcript,
cwd: project_root,
title: Some("first request".to_string()),
}]
);
}
#[cfg(not(windows))]
fn encode_project_path(path: &Path) -> String {
path.to_string_lossy().replace('/', "-")
}
#[cfg(windows)]
fn encode_project_path(path: &Path) -> String {
path.to_string_lossy().replace([':', '\\'], "-")
}
@@ -1,7 +1,7 @@
use crate::ConversationMessage;
use crate::ImportedExternalAgentSession;
use crate::MessageRole;
use crate::records::read_session_import;
use crate::records::read_session_import_with_cwd;
use crate::summarize_for_label;
use crate::title::IMPORTED_SESSION_FALLBACK_TITLE;
use crate::title::SessionTitleCandidates;
@@ -26,15 +26,16 @@ const EXTERNAL_SESSION_IMPORTED_MARKER: &str = "<EXTERNAL SESSION IMPORTED>";
#[cfg(test)]
fn load_session_for_import(path: &Path) -> io::Result<Option<ImportedExternalAgentSession>> {
Ok(
load_session_for_import_with_content_sha256(path)?
load_session_for_import_with_content_sha256(path, /*fallback_cwd*/ None)?
.map(|(session, _content_sha256)| session),
)
}
pub(crate) fn load_session_for_import_with_content_sha256(
path: &Path,
fallback_cwd: Option<&Path>,
) -> io::Result<Option<(ImportedExternalAgentSession, String)>> {
let parsed = read_session_import(path)?;
let parsed = read_session_import_with_cwd(path, fallback_cwd)?;
let Some(cwd) = parsed.cwd else {
return Ok(None);
};
+5 -2
View File
@@ -1,6 +1,7 @@
//! Parsing and export helpers for external-agent session histories.
mod detect;
mod detect_cursor;
mod export;
mod ledger;
mod records;
@@ -12,6 +13,7 @@ use std::path::Path;
use std::path::PathBuf;
pub use detect::detect_recent_sessions;
pub use detect_cursor::detect_recent_cursor_sessions;
use export::load_session_for_import_with_content_sha256;
pub use ledger::CompletedExternalAgentSessionImport;
pub use ledger::has_current_session_been_imported;
@@ -52,7 +54,7 @@ pub fn prepare_validated_session_import(
return Ok(None);
}
let Some((source_path, imported_session, source_content_sha256)) =
load_importable_session(&session.path)?
load_importable_session(&session.path, &session.cwd)?
else {
return Ok(None);
};
@@ -65,10 +67,11 @@ pub fn prepare_validated_session_import(
fn load_importable_session(
path: &Path,
fallback_cwd: &Path,
) -> io::Result<Option<(PathBuf, ImportedExternalAgentSession, String)>> {
let source_path = std::fs::canonicalize(path)?;
let Some((imported_session, source_content_sha256)) =
load_session_for_import_with_content_sha256(&source_path)?
load_session_for_import_with_content_sha256(&source_path, Some(fallback_cwd))?
else {
return Ok(None);
};
@@ -34,9 +34,17 @@ pub(super) struct ParsedSessionImport {
}
pub fn summarize_session(path: &Path) -> io::Result<Option<SessionSummary>> {
summarize_session_with_cwd(path, /*fallback_cwd*/ None)
}
pub(crate) fn summarize_session_with_cwd(
path: &Path,
fallback_cwd: Option<&Path>,
) -> io::Result<Option<SessionSummary>> {
let file = File::open(path)?;
let fallback_timestamp = fallback_cwd.and_then(|_| file_modified_at_seconds(&file));
let reader = BufReader::new(file);
let mut cwd = None;
let mut cwd = fallback_cwd.map(Path::to_path_buf);
let mut custom_title = None;
let mut ai_title = None;
let mut fallback_title = None;
@@ -65,7 +73,8 @@ pub fn summarize_session(path: &Path) -> io::Result<Option<SessionSummary>> {
if let Some(title) = ai_title_from_record(&record) {
ai_title = Some(title.to_string());
}
let Some(message) = conversation_message_from_owned_record(&mut record) else {
let Some(message) = conversation_message_from_owned_record(&mut record, fallback_timestamp)
else {
continue;
};
saw_message = true;
@@ -107,10 +116,19 @@ pub fn summarize_session(path: &Path) -> io::Result<Option<SessionSummary>> {
}))
}
#[cfg(test)]
pub(super) fn read_session_import(path: &Path) -> io::Result<ParsedSessionImport> {
read_session_import_with_cwd(path, /*fallback_cwd*/ None)
}
pub(super) fn read_session_import_with_cwd(
path: &Path,
fallback_cwd: Option<&Path>,
) -> io::Result<ParsedSessionImport> {
let file = File::open(path)?;
let fallback_timestamp = fallback_cwd.and_then(|_| file_modified_at_seconds(&file));
let mut reader = BufReader::new(file);
let mut cwd = None;
let mut cwd = fallback_cwd.map(Path::to_path_buf);
let mut custom_title = None;
let mut ai_title = None;
let mut messages = Vec::new();
@@ -141,7 +159,9 @@ pub(super) fn read_session_import(path: &Path) -> io::Result<ParsedSessionImport
if let Some(title) = ai_title_from_record(&record) {
ai_title = Some(title.to_string());
}
if let Some(message) = conversation_message_from_owned_record(&mut record) {
if let Some(message) =
conversation_message_from_owned_record(&mut record, fallback_timestamp)
{
messages.push(message);
}
}
@@ -170,8 +190,14 @@ fn title_from_record<'a>(record: &'a JsonValue, record_type: &str, field: &str)
.filter(|title| !title.is_empty())
}
fn conversation_message_from_owned_record(record: &mut JsonValue) -> Option<ConversationMessage> {
let record_type = record.get("type")?.as_str()?;
fn conversation_message_from_owned_record(
record: &mut JsonValue,
fallback_timestamp: Option<i64>,
) -> Option<ConversationMessage> {
let record_type = record
.get("type")
.or_else(|| record.get("role"))?
.as_str()?;
if record_type != "assistant" && record_type != "user" {
return None;
}
@@ -185,7 +211,14 @@ fn conversation_message_from_owned_record(record: &mut JsonValue) -> Option<Conv
let timestamp = record
.get("timestamp")
.and_then(JsonValue::as_str)
.and_then(parse_timestamp);
.and_then(parse_timestamp)
.or_else(|| {
record
.get("timestamp_ms")
.and_then(JsonValue::as_i64)
.map(|value| value / 1_000)
})
.or(fallback_timestamp);
let content = record.get_mut("message")?.get_mut("content")?.take();
let extracted = match content {
JsonValue::String(text) => {
@@ -210,6 +243,16 @@ fn conversation_message_from_owned_record(record: &mut JsonValue) -> Option<Conv
})
}
fn file_modified_at_seconds(file: &File) -> Option<i64> {
file.metadata()
.ok()?
.modified()
.ok()?
.duration_since(std::time::UNIX_EPOCH)
.ok()
.and_then(|duration| i64::try_from(duration.as_secs()).ok())
}
struct ExtractedMessage {
text: String,
only_tool_result: bool,
+26 -2
View File
@@ -7,8 +7,11 @@ use std::path::Path;
use std::path::PathBuf;
/// Ordered plugin manifest paths recognized beneath a plugin root.
pub const DISCOVERABLE_PLUGIN_MANIFEST_PATHS: &[&str] =
&[".codex-plugin/plugin.json", ".claude-plugin/plugin.json"];
pub const DISCOVERABLE_PLUGIN_MANIFEST_PATHS: &[&str] = &[
".codex-plugin/plugin.json",
".claude-plugin/plugin.json",
".cursor-plugin/plugin.json",
];
pub fn find_plugin_manifest_path(plugin_root: &Path) -> Option<PathBuf> {
DISCOVERABLE_PLUGIN_MANIFEST_PATHS
@@ -87,6 +90,7 @@ mod tests {
use tempfile::tempdir;
const ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH: &str = ".claude-plugin/plugin.json";
const CURSOR_PLUGIN_MANIFEST_RELATIVE_PATH: &str = ".cursor-plugin/plugin.json";
#[tokio::test]
async fn uses_manifest_name() {
@@ -128,4 +132,24 @@ mod tests {
);
assert_eq!(find_plugin_manifest_path(&plugin_root), Some(manifest_path));
}
#[tokio::test]
async fn uses_name_from_cursor_plugin_manifest_path() {
let tmp = tempdir().expect("tempdir");
let plugin_root = tmp.path().join("plugins/sample");
let skill_path = plugin_root.join("skills/search/SKILL.md");
let manifest_path = plugin_root.join(CURSOR_PLUGIN_MANIFEST_RELATIVE_PATH);
fs::create_dir_all(skill_path.parent().expect("parent")).expect("mkdir");
fs::create_dir_all(manifest_path.parent().expect("manifest parent"))
.expect("mkdir manifest");
fs::write(&manifest_path, r#"{"name":"sample"}"#).expect("write manifest");
fs::write(&skill_path, "---\ndescription: search\n---\n").expect("write skill");
assert_eq!(
plugin_namespace_for_skill_path(LOCAL_FS.as_ref(), &skill_path.abs()).await,
Some("sample".to_string())
);
assert_eq!(find_plugin_manifest_path(&plugin_root), Some(manifest_path));
}
}