mirror of
https://github.com/openai/codex.git
synced 2026-11-01 19:33:35 +00:00
complete secondary import migrations
This commit is contained in:
Generated
+1
@@ -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([':', '\\'], "-")
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user