mirror of
https://github.com/openai/codex.git
synced 2026-11-13 06:45:07 +00:00
Add Cursor support to setup import (#33426)
## What changed - Detect and import supported Cursor settings, sandbox permissions, MCP servers, project instructions, hooks, agents, commands, plugins, and recent chat sessions. - Update the `/import` flow to check Claude Code and Cursor, prompting for a source when both have importable data. - Add `migrationSource` to the app-server detect and import requests so each operation uses the selected adapter, while retaining `source` for import attribution and backwards compatibility. ## Testing - Add coverage for Cursor config conversion, hooks, plugins, session discovery and conversion, app-server imports, and TUI source selection. GitOrigin-RevId: a65a1511b6c1c2f9e3e0cfe4f816bb435807282e
This commit is contained in:
1 parent
0f2321f244
commit
1c4af96394
47 files changed
+2962
-247
No files matched your search
+16
-2
@@ -811,8 +811,15 @@
|
|||||||
"description": "If true, include detection under the user's home directory.",
|
"description": "If true, include detection under the user's home directory.",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"migrationSource": {
|
||||||
|
"description": "Optional migration-source selector. Missing or unrecognized values use the default source.",
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
"source": {
|
"source": {
|
||||||
"description": "Optional import-source selector. Missing or unrecognized values use the default source for backwards compatibility.",
|
"description": "Deprecated field retained for compatibility. This field is ignored; use `migrationSource` to select the migration source.",
|
||||||
"type": [
|
"type": [
|
||||||
"string",
|
"string",
|
||||||
"null"
|
"null"
|
||||||
@@ -829,8 +836,15 @@
|
|||||||
},
|
},
|
||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
|
"migrationSource": {
|
||||||
|
"description": "Migration-source selector used to produce the migration items. Pass the same value to detection and import; missing or unrecognized values use the default source.",
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
"source": {
|
"source": {
|
||||||
"description": "Import-source selector used to produce the migration items. Pass the same value to detection and import; missing or unrecognized values use the default source for backwards compatibility.",
|
"description": "Optional identifier for the product that initiated the import.",
|
||||||
"type": [
|
"type": [
|
||||||
"string",
|
"string",
|
||||||
"null"
|
"null"
|
||||||
|
|||||||
+16
-2
@@ -9302,8 +9302,15 @@
|
|||||||
"description": "If true, include detection under the user's home directory.",
|
"description": "If true, include detection under the user's home directory.",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"migrationSource": {
|
||||||
|
"description": "Optional migration-source selector. Missing or unrecognized values use the default source.",
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
"source": {
|
"source": {
|
||||||
"description": "Optional import-source selector. Missing or unrecognized values use the default source for backwards compatibility.",
|
"description": "Deprecated field retained for compatibility. This field is ignored; use `migrationSource` to select the migration source.",
|
||||||
"type": [
|
"type": [
|
||||||
"string",
|
"string",
|
||||||
"null"
|
"null"
|
||||||
@@ -9483,8 +9490,15 @@
|
|||||||
},
|
},
|
||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
|
"migrationSource": {
|
||||||
|
"description": "Migration-source selector used to produce the migration items. Pass the same value to detection and import; missing or unrecognized values use the default source.",
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
"source": {
|
"source": {
|
||||||
"description": "Import-source selector used to produce the migration items. Pass the same value to detection and import; missing or unrecognized values use the default source for backwards compatibility.",
|
"description": "Optional identifier for the product that initiated the import.",
|
||||||
"type": [
|
"type": [
|
||||||
"string",
|
"string",
|
||||||
"null"
|
"null"
|
||||||
|
|||||||
+16
-2
@@ -5495,8 +5495,15 @@
|
|||||||
"description": "If true, include detection under the user's home directory.",
|
"description": "If true, include detection under the user's home directory.",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"migrationSource": {
|
||||||
|
"description": "Optional migration-source selector. Missing or unrecognized values use the default source.",
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
"source": {
|
"source": {
|
||||||
"description": "Optional import-source selector. Missing or unrecognized values use the default source for backwards compatibility.",
|
"description": "Deprecated field retained for compatibility. This field is ignored; use `migrationSource` to select the migration source.",
|
||||||
"type": [
|
"type": [
|
||||||
"string",
|
"string",
|
||||||
"null"
|
"null"
|
||||||
@@ -5676,8 +5683,15 @@
|
|||||||
},
|
},
|
||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
|
"migrationSource": {
|
||||||
|
"description": "Migration-source selector used to produce the migration items. Pass the same value to detection and import; missing or unrecognized values use the default source.",
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
"source": {
|
"source": {
|
||||||
"description": "Import-source selector used to produce the migration items. Pass the same value to detection and import; missing or unrecognized values use the default source for backwards compatibility.",
|
"description": "Optional identifier for the product that initiated the import.",
|
||||||
"type": [
|
"type": [
|
||||||
"string",
|
"string",
|
||||||
"null"
|
"null"
|
||||||
|
|||||||
+8
-1
@@ -15,8 +15,15 @@
|
|||||||
"description": "If true, include detection under the user's home directory.",
|
"description": "If true, include detection under the user's home directory.",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"migrationSource": {
|
||||||
|
"description": "Optional migration-source selector. Missing or unrecognized values use the default source.",
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
"source": {
|
"source": {
|
||||||
"description": "Optional import-source selector. Missing or unrecognized values use the default source for backwards compatibility.",
|
"description": "Deprecated field retained for compatibility. This field is ignored; use `migrationSource` to select the migration source.",
|
||||||
"type": [
|
"type": [
|
||||||
"string",
|
"string",
|
||||||
"null"
|
"null"
|
||||||
|
|||||||
+8
-1
@@ -203,8 +203,15 @@
|
|||||||
},
|
},
|
||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
|
"migrationSource": {
|
||||||
|
"description": "Migration-source selector used to produce the migration items. Pass the same value to detection and import; missing or unrecognized values use the default source.",
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
"source": {
|
"source": {
|
||||||
"description": "Import-source selector used to produce the migration items. Pass the same value to detection and import; missing or unrecognized values use the default source for backwards compatibility.",
|
"description": "Optional identifier for the product that initiated the import.",
|
||||||
"type": [
|
"type": [
|
||||||
"string",
|
"string",
|
||||||
"null"
|
"null"
|
||||||
|
|||||||
+7
-3
@@ -12,7 +12,11 @@ includeHome?: boolean,
|
|||||||
*/
|
*/
|
||||||
cwds?: Array<string> | null,
|
cwds?: Array<string> | null,
|
||||||
/**
|
/**
|
||||||
* Optional import-source selector. Missing or unrecognized values use the default source for
|
* Deprecated field retained for compatibility. This field is ignored; use `migrationSource`
|
||||||
* backwards compatibility.
|
* to select the migration source.
|
||||||
*/
|
*/
|
||||||
source?: string | null, };
|
source?: string | null,
|
||||||
|
/**
|
||||||
|
* Optional migration-source selector. Missing or unrecognized values use the default source.
|
||||||
|
*/
|
||||||
|
migrationSource?: string | null, };
|
||||||
+7
-4
@@ -5,8 +5,11 @@ import type { ExternalAgentConfigMigrationItem } from "./ExternalAgentConfigMigr
|
|||||||
|
|
||||||
export type ExternalAgentConfigImportParams = { migrationItems: Array<ExternalAgentConfigMigrationItem>,
|
export type ExternalAgentConfigImportParams = { migrationItems: Array<ExternalAgentConfigMigrationItem>,
|
||||||
/**
|
/**
|
||||||
* Import-source selector used to produce the migration items. Pass the same value to detection
|
* Optional identifier for the product that initiated the import.
|
||||||
* and import; missing or unrecognized values use the default source for backwards
|
|
||||||
* compatibility.
|
|
||||||
*/
|
*/
|
||||||
source?: string | null, };
|
source?: string | null,
|
||||||
|
/**
|
||||||
|
* Migration-source selector used to produce the migration items. Pass the same value to
|
||||||
|
* detection and import; missing or unrecognized values use the default source.
|
||||||
|
*/
|
||||||
|
migrationSource?: string | null, };
|
||||||
@@ -684,10 +684,13 @@ pub struct ExternalAgentConfigDetectParams {
|
|||||||
/// Zero or more working directories to include for repo-scoped detection.
|
/// Zero or more working directories to include for repo-scoped detection.
|
||||||
#[ts(optional = nullable)]
|
#[ts(optional = nullable)]
|
||||||
pub cwds: Option<Vec<PathBuf>>,
|
pub cwds: Option<Vec<PathBuf>>,
|
||||||
/// Optional import-source selector. Missing or unrecognized values use the default source for
|
/// Deprecated field retained for compatibility. This field is ignored; use `migrationSource`
|
||||||
/// backwards compatibility.
|
/// to select the migration source.
|
||||||
#[ts(optional = nullable)]
|
#[ts(optional = nullable)]
|
||||||
pub source: Option<String>,
|
pub source: Option<String>,
|
||||||
|
/// Optional migration-source selector. Missing or unrecognized values use the default source.
|
||||||
|
#[ts(optional = nullable)]
|
||||||
|
pub migration_source: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||||
@@ -695,11 +698,13 @@ pub struct ExternalAgentConfigDetectParams {
|
|||||||
#[ts(export_to = "v2/")]
|
#[ts(export_to = "v2/")]
|
||||||
pub struct ExternalAgentConfigImportParams {
|
pub struct ExternalAgentConfigImportParams {
|
||||||
pub migration_items: Vec<ExternalAgentConfigMigrationItem>,
|
pub migration_items: Vec<ExternalAgentConfigMigrationItem>,
|
||||||
/// Import-source selector used to produce the migration items. Pass the same value to detection
|
/// Optional identifier for the product that initiated the import.
|
||||||
/// and import; missing or unrecognized values use the default source for backwards
|
|
||||||
/// compatibility.
|
|
||||||
#[ts(optional = nullable)]
|
#[ts(optional = nullable)]
|
||||||
pub source: Option<String>,
|
pub source: Option<String>,
|
||||||
|
/// Migration-source selector used to produce the migration items. Pass the same value to
|
||||||
|
/// detection and import; missing or unrecognized values use the default source.
|
||||||
|
#[ts(optional = nullable)]
|
||||||
|
pub migration_source: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||||
|
|||||||
@@ -435,6 +435,7 @@ fn external_agent_config_import_params_accept_legacy_plugin_details() {
|
|||||||
}),
|
}),
|
||||||
}],
|
}],
|
||||||
source: None,
|
source: None,
|
||||||
|
migration_source: None,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -241,8 +241,8 @@ Example with notification opt-out:
|
|||||||
- `windowsSandbox/setupStart` — start Windows sandbox setup for the selected mode (`elevated` or `unelevated`); accepts an optional absolute `cwd` to target setup for a specific workspace, returns `{ started: true }` immediately, and later emits `windowsSandbox/setupCompleted`.
|
- `windowsSandbox/setupStart` — start Windows sandbox setup for the selected mode (`elevated` or `unelevated`); accepts an optional absolute `cwd` to target setup for a specific workspace, returns `{ started: true }` immediately, and later emits `windowsSandbox/setupCompleted`.
|
||||||
- `feedback/upload` — submit a feedback report (classification + optional reason/logs, conversation_id, and optional `extraLogFiles` attachments array); returns the tracking thread id.
|
- `feedback/upload` — submit a feedback report (classification + optional reason/logs, conversation_id, and optional `extraLogFiles` attachments array); returns the tracking thread id.
|
||||||
- `config/read` — fetch the effective config on disk after resolving config layering, including opaque `desktop` values stored in `config.toml`.
|
- `config/read` — fetch the effective config on disk after resolving config layering, including opaque `desktop` values stored in `config.toml`.
|
||||||
- `externalAgentConfig/detect` — detect migratable external-agent artifacts with `includeHome`, optional `cwds`, and an optional `source` identifier reserved for adapter selection; omitted, `null`, or unrecognized values retain the default behavior. Each detected item includes `cwd` (`null` for home), and multi-item migrations may additionally include structured `details` with plugin ids, skill names, session metadata, or other artifact names.
|
- `externalAgentConfig/detect` — detect migratable external-agent artifacts with `includeHome`, optional `cwds`, and an optional `migrationSource` selector. Omitted, `null`, or unrecognized migration-source values retain the default behavior. The deprecated optional `source` field remains accepted for compatibility but does not select the migration source. Each detected item includes `cwd` (`null` for home), and multi-item migrations may additionally include structured `details` with plugin ids, skill names, session metadata, or other artifact names.
|
||||||
- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any `details` returned by detect. Callers may pass `source` to identify the product that initiated the import; omitted or `null` means unspecified. The response acknowledges the synchronous import phase with an `importId`. Expected migration failures are reported as per-item failures rather than JSON-RPC errors, so the server still returns that `importId` and emits `externalAgentConfig/import/completed` with the same ID once all synchronous and background work finishes. The completion notification contains type-level `itemTypeResults` with successes and failures, including raw failure messages for the client to report separately.
|
- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any `details` returned by detect. Pass the same optional `migrationSource` used for detection so the server reads from the matching source; omitted, `null`, or unrecognized values retain the default behavior. The optional `source` continues to identify the product that initiated the import. The response acknowledges the synchronous import phase with an `importId`. Expected migration failures are reported as per-item failures rather than JSON-RPC errors, so the server still returns that `importId` and emits `externalAgentConfig/import/completed` with the same ID once all synchronous and background work finishes. The completion notification contains type-level `itemTypeResults` with successes and failures, including raw failure messages for the client to report separately.
|
||||||
- `externalAgentConfig/import/readHistories` — read completed import histories and connector candidates detected from successfully imported session histories. Connector candidates include a normalized display `name`, the number of imported sessions that used the connector, and the source metadata field used for detection.
|
- `externalAgentConfig/import/readHistories` — read completed import histories and connector candidates detected from successfully imported session histories. Connector candidates include a normalized display `name`, the number of imported sessions that used the connector, and the source metadata field used for detection.
|
||||||
- `config/value/write` — write a single config key/value to the user's config.toml on disk; dotted paths such as `desktop.someKey` use the same generic write surface.
|
- `config/value/write` — write a single config key/value to the user's config.toml on disk; dotted paths such as `desktop.someKey` use the same generic write surface.
|
||||||
- `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads, including multiple `desktop.*` edits.
|
- `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads, including multiple `desktop.*` edits.
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ use super::service::ExternalAgentConfigMigrationItem as CoreMigrationItem;
|
|||||||
use super::service::ExternalAgentConfigMigrationItemType as CoreMigrationItemType;
|
use super::service::ExternalAgentConfigMigrationItemType as CoreMigrationItemType;
|
||||||
use super::service::ExternalAgentConfigService;
|
use super::service::ExternalAgentConfigService;
|
||||||
use super::service::NamedMigration as CoreNamedMigration;
|
use super::service::NamedMigration as CoreNamedMigration;
|
||||||
use super::service::PendingPluginImport;
|
|
||||||
use super::service::PluginImportOutcome;
|
use super::service::PluginImportOutcome;
|
||||||
use super::service::record_import_error;
|
use super::service::record_import_error;
|
||||||
use super::session_importer::ExternalAgentSessionImporter;
|
use super::session_importer::ExternalAgentSessionImporter;
|
||||||
@@ -117,8 +116,10 @@ impl ExternalAgentConfigRequestProcessor {
|
|||||||
&self,
|
&self,
|
||||||
params: ExternalAgentConfigDetectParams,
|
params: ExternalAgentConfigDetectParams,
|
||||||
) -> Result<ExternalAgentConfigDetectResponse, JSONRPCErrorError> {
|
) -> Result<ExternalAgentConfigDetectResponse, JSONRPCErrorError> {
|
||||||
let items = self
|
let migration_service = self
|
||||||
.migration_service
|
.migration_service
|
||||||
|
.with_migration_source(params.migration_source.as_deref());
|
||||||
|
let items = migration_service
|
||||||
.detect(ExternalAgentConfigDetectOptions {
|
.detect(ExternalAgentConfigDetectOptions {
|
||||||
include_home: params.include_home,
|
include_home: params.include_home,
|
||||||
cwds: params.cwds,
|
cwds: params.cwds,
|
||||||
@@ -219,6 +220,9 @@ impl ExternalAgentConfigRequestProcessor {
|
|||||||
) -> Result<(), JSONRPCErrorError> {
|
) -> Result<(), JSONRPCErrorError> {
|
||||||
let import_id = Uuid::new_v4().to_string();
|
let import_id = Uuid::new_v4().to_string();
|
||||||
let analytics_source = params.source.clone().unwrap_or_default();
|
let analytics_source = params.source.clone().unwrap_or_default();
|
||||||
|
let migration_service = self
|
||||||
|
.migration_service
|
||||||
|
.with_migration_source(params.migration_source.as_deref());
|
||||||
let needs_runtime_refresh = migration_items_need_runtime_refresh(¶ms.migration_items);
|
let needs_runtime_refresh = migration_items_need_runtime_refresh(¶ms.migration_items);
|
||||||
let has_migration_items = !params.migration_items.is_empty();
|
let has_migration_items = !params.migration_items.is_empty();
|
||||||
let has_plugin_imports = params.migration_items.iter().any(|item| {
|
let has_plugin_imports = params.migration_items.iter().any(|item| {
|
||||||
@@ -228,8 +232,10 @@ impl ExternalAgentConfigRequestProcessor {
|
|||||||
)
|
)
|
||||||
});
|
});
|
||||||
let (pending_session_imports, session_validation_result) =
|
let (pending_session_imports, session_validation_result) =
|
||||||
self.validate_pending_session_imports(¶ms);
|
self.validate_pending_session_imports(¶ms, &migration_service);
|
||||||
let import_outcome = self.import_external_agent_config(params).await;
|
let import_outcome = self
|
||||||
|
.import_external_agent_config(params, &migration_service)
|
||||||
|
.await;
|
||||||
if needs_runtime_refresh {
|
if needs_runtime_refresh {
|
||||||
self.config_processor.handle_config_mutation().await;
|
self.config_processor.handle_config_mutation().await;
|
||||||
}
|
}
|
||||||
@@ -272,11 +278,12 @@ impl ExternalAgentConfigRequestProcessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let session_importer = self.session_importer.clone();
|
let session_importer = self.session_importer.clone();
|
||||||
let plugin_processor = self.clone();
|
|
||||||
let outgoing = Arc::clone(&self.outgoing);
|
let outgoing = Arc::clone(&self.outgoing);
|
||||||
let state_db = self.state_db.clone();
|
let state_db = self.state_db.clone();
|
||||||
let analytics_events_client = self.analytics_events_client.clone();
|
let analytics_events_client = self.analytics_events_client.clone();
|
||||||
let thread_manager = Arc::clone(&self.thread_manager);
|
let thread_manager = Arc::clone(&self.thread_manager);
|
||||||
|
let session_metadata_mode = migration_service.session_metadata_mode();
|
||||||
|
let plugin_migration_service = migration_service;
|
||||||
let session_import_result = (!pending_session_imports.is_empty()).then(|| {
|
let session_import_result = (!pending_session_imports.is_empty()).then(|| {
|
||||||
CoreImportItemResult::new(
|
CoreImportItemResult::new(
|
||||||
CoreMigrationItemType::Sessions,
|
CoreMigrationItemType::Sessions,
|
||||||
@@ -291,7 +298,11 @@ impl ExternalAgentConfigRequestProcessor {
|
|||||||
let session_imports = async move {
|
let session_imports = async move {
|
||||||
let session_import_result = session_import_result?;
|
let session_import_result = session_import_result?;
|
||||||
let item_result = session_importer
|
let item_result = session_importer
|
||||||
.import_sessions(pending_session_imports, session_import_result)
|
.import_sessions(
|
||||||
|
pending_session_imports,
|
||||||
|
session_import_result,
|
||||||
|
session_metadata_mode,
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
send_import_progress(&session_progress_outgoing, &session_import_id, &item_result)
|
send_import_progress(&session_progress_outgoing, &session_import_id, &item_result)
|
||||||
.await;
|
.await;
|
||||||
@@ -307,8 +318,11 @@ impl ExternalAgentConfigRequestProcessor {
|
|||||||
pending_plugin_import.description.clone(),
|
pending_plugin_import.description.clone(),
|
||||||
pending_plugin_import.cwd.clone(),
|
pending_plugin_import.cwd.clone(),
|
||||||
);
|
);
|
||||||
match plugin_processor
|
match plugin_migration_service
|
||||||
.complete_pending_plugin_import(pending_plugin_import)
|
.import_plugins(
|
||||||
|
pending_plugin_import.cwd.as_deref(),
|
||||||
|
Some(pending_plugin_import.details),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(plugin_outcome) => {
|
Ok(plugin_outcome) => {
|
||||||
@@ -318,7 +332,7 @@ impl ExternalAgentConfigRequestProcessor {
|
|||||||
record_import_error(
|
record_import_error(
|
||||||
&mut item_result,
|
&mut item_result,
|
||||||
"plugin_import",
|
"plugin_import",
|
||||||
error.message.clone(),
|
error.to_string(),
|
||||||
/*source*/ None,
|
/*source*/ None,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -393,6 +407,7 @@ impl ExternalAgentConfigRequestProcessor {
|
|||||||
fn validate_pending_session_imports(
|
fn validate_pending_session_imports(
|
||||||
&self,
|
&self,
|
||||||
params: &ExternalAgentConfigImportParams,
|
params: &ExternalAgentConfigImportParams,
|
||||||
|
migration_service: &ExternalAgentConfigService,
|
||||||
) -> (Vec<CoreSessionMigration>, Option<CoreImportItemResult>) {
|
) -> (Vec<CoreSessionMigration>, Option<CoreImportItemResult>) {
|
||||||
let sessions = params
|
let sessions = params
|
||||||
.migration_items
|
.migration_items
|
||||||
@@ -422,33 +437,31 @@ impl ExternalAgentConfigRequestProcessor {
|
|||||||
let mut selected_session_paths = HashSet::new();
|
let mut selected_session_paths = HashSet::new();
|
||||||
let mut selected_sessions = Vec::new();
|
let mut selected_sessions = Vec::new();
|
||||||
for session in sessions {
|
for session in sessions {
|
||||||
let canonical_path = match self
|
let canonical_path =
|
||||||
.migration_service
|
match migration_service.external_agent_session_source_path(&session.path) {
|
||||||
.external_agent_session_source_path(&session.path)
|
Ok(Some(canonical_path)) => canonical_path,
|
||||||
{
|
Ok(None) => {
|
||||||
Ok(Some(canonical_path)) => canonical_path,
|
record_import_error(
|
||||||
Ok(None) => {
|
&mut item_result,
|
||||||
record_import_error(
|
"session_missing",
|
||||||
&mut item_result,
|
format!(
|
||||||
"session_missing",
|
"external agent session was not detected for import: {}",
|
||||||
format!(
|
session.path.display()
|
||||||
"external agent session was not detected for import: {}",
|
),
|
||||||
session.path.display()
|
Some(session.path.display().to_string()),
|
||||||
),
|
);
|
||||||
Some(session.path.display().to_string()),
|
continue;
|
||||||
);
|
}
|
||||||
continue;
|
Err(err) => {
|
||||||
}
|
record_import_error(
|
||||||
Err(err) => {
|
&mut item_result,
|
||||||
record_import_error(
|
"session_source_path",
|
||||||
&mut item_result,
|
err.to_string(),
|
||||||
"session_source_path",
|
Some(session.path.display().to_string()),
|
||||||
err.to_string(),
|
);
|
||||||
Some(session.path.display().to_string()),
|
continue;
|
||||||
);
|
}
|
||||||
continue;
|
};
|
||||||
}
|
|
||||||
};
|
|
||||||
if selected_session_paths.insert(canonical_path) {
|
if selected_session_paths.insert(canonical_path) {
|
||||||
selected_sessions.push(session);
|
selected_sessions.push(session);
|
||||||
}
|
}
|
||||||
@@ -459,8 +472,9 @@ impl ExternalAgentConfigRequestProcessor {
|
|||||||
async fn import_external_agent_config(
|
async fn import_external_agent_config(
|
||||||
&self,
|
&self,
|
||||||
params: ExternalAgentConfigImportParams,
|
params: ExternalAgentConfigImportParams,
|
||||||
|
migration_service: &ExternalAgentConfigService,
|
||||||
) -> CoreImportOutcome {
|
) -> CoreImportOutcome {
|
||||||
self.migration_service
|
migration_service
|
||||||
.import(
|
.import(
|
||||||
params
|
params
|
||||||
.migration_items
|
.migration_items
|
||||||
@@ -558,19 +572,6 @@ impl ExternalAgentConfigRequestProcessor {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn complete_pending_plugin_import(
|
|
||||||
&self,
|
|
||||||
pending_plugin_import: PendingPluginImport,
|
|
||||||
) -> Result<PluginImportOutcome, JSONRPCErrorError> {
|
|
||||||
self.migration_service
|
|
||||||
.import_plugins(
|
|
||||||
pending_plugin_import.cwd.as_deref(),
|
|
||||||
Some(pending_plugin_import.details),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|err| internal_error(err.to_string()))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_import_progress(
|
async fn send_import_progress(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
mod source;
|
mod source;
|
||||||
mod source_cla;
|
mod source_cla;
|
||||||
|
mod source_cur;
|
||||||
mod utils;
|
mod utils;
|
||||||
|
|
||||||
use codex_analytics::AnalyticsEventsClient;
|
use codex_analytics::AnalyticsEventsClient;
|
||||||
@@ -19,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::count_missing_subagents;
|
||||||
use codex_external_agent_migration::missing_subagent_names;
|
use codex_external_agent_migration::missing_subagent_names;
|
||||||
use codex_external_agent_migration::sessions::ExternalAgentSessionMigration;
|
use codex_external_agent_migration::sessions::ExternalAgentSessionMigration;
|
||||||
|
use codex_external_agent_migration::sessions::SessionMetadataMode;
|
||||||
use codex_protocol::protocol::Product;
|
use codex_protocol::protocol::Product;
|
||||||
use serde_json::Value as JsonValue;
|
use serde_json::Value as JsonValue;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
@@ -208,6 +210,23 @@ impl ExternalAgentConfigService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn with_migration_source(&self, migration_source: Option<&str>) -> Self {
|
||||||
|
let source = ExternalAgentSource::from_migration_source(migration_source);
|
||||||
|
let external_agent_home = default_external_agent_home(source);
|
||||||
|
let connector_metadata_roots = source.connector_metadata_roots(&external_agent_home);
|
||||||
|
Self {
|
||||||
|
codex_home: self.codex_home.clone(),
|
||||||
|
connector_metadata_roots,
|
||||||
|
external_agent_home,
|
||||||
|
analytics_events_client: self.analytics_events_client.clone(),
|
||||||
|
source,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn session_metadata_mode(&self) -> SessionMetadataMode {
|
||||||
|
self.source.session_metadata_mode()
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
fn new_for_test(codex_home: PathBuf, external_agent_home: PathBuf) -> Self {
|
fn new_for_test(codex_home: PathBuf, external_agent_home: PathBuf) -> Self {
|
||||||
let source = ExternalAgentSource::default();
|
let source = ExternalAgentSource::default();
|
||||||
@@ -520,7 +539,10 @@ impl ExternalAgentConfigService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mcp_source_path = self.source.mcp_source_path(self.source_root(repo_root));
|
let mcp_source_path = self.source.mcp_source_path(
|
||||||
|
self.source_root(repo_root),
|
||||||
|
self.source_config_dir(repo_root),
|
||||||
|
);
|
||||||
let migrated_mcp = self.build_mcp_config(repo_root, settings.clone())?;
|
let migrated_mcp = self.build_mcp_config(repo_root, settings.clone())?;
|
||||||
let mut mcp_server_names = migrated_mcp_server_names(&migrated_mcp);
|
let mut mcp_server_names = migrated_mcp_server_names(&migrated_mcp);
|
||||||
if !is_empty_toml_table(&migrated_mcp) {
|
if !is_empty_toml_table(&migrated_mcp) {
|
||||||
@@ -747,6 +769,7 @@ impl ExternalAgentConfigService {
|
|||||||
external_agent_home: self.external_agent_home.as_path(),
|
external_agent_home: self.external_agent_home.as_path(),
|
||||||
source_settings: source_settings.as_path(),
|
source_settings: source_settings.as_path(),
|
||||||
source_root,
|
source_root,
|
||||||
|
repo_root,
|
||||||
settings: settings.as_ref(),
|
settings: settings.as_ref(),
|
||||||
configured_plugin_ids: &configured_plugin_ids,
|
configured_plugin_ids: &configured_plugin_ids,
|
||||||
configured_marketplace_plugins: &configured_marketplace_plugins,
|
configured_marketplace_plugins: &configured_marketplace_plugins,
|
||||||
@@ -823,7 +846,10 @@ impl ExternalAgentConfigService {
|
|||||||
|
|
||||||
fn effective_source_settings(&self, repo_root: Option<&Path>) -> io::Result<Option<JsonValue>> {
|
fn effective_source_settings(&self, repo_root: Option<&Path>) -> io::Result<Option<JsonValue>> {
|
||||||
let source_settings = self.source_settings(repo_root);
|
let source_settings = self.source_settings(repo_root);
|
||||||
self.source.effective_settings(&source_settings)
|
self.source.effective_settings(
|
||||||
|
self.source_config_dir(repo_root).as_path(),
|
||||||
|
&source_settings,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_mcp_config(
|
fn build_mcp_config(
|
||||||
@@ -838,6 +864,7 @@ impl ExternalAgentConfigService {
|
|||||||
};
|
};
|
||||||
self.source.build_mcp_config(
|
self.source.build_mcp_config(
|
||||||
self.source_root(repo_root).as_path(),
|
self.source_root(repo_root).as_path(),
|
||||||
|
self.source_config_dir(repo_root).as_path(),
|
||||||
self.external_agent_home.as_path(),
|
self.external_agent_home.as_path(),
|
||||||
settings.as_ref(),
|
settings.as_ref(),
|
||||||
)
|
)
|
||||||
@@ -969,6 +996,19 @@ impl ExternalAgentConfigService {
|
|||||||
if let Some(analytics_events_client) = self.analytics_events_client.clone() {
|
if let Some(analytics_events_client) = self.analytics_events_client.clone() {
|
||||||
plugins_manager.set_analytics_events_client(analytics_events_client);
|
plugins_manager.set_analytics_events_client(analytics_events_client);
|
||||||
}
|
}
|
||||||
|
let configured_marketplace_paths = plugins_manager
|
||||||
|
.list_marketplaces_for_config(
|
||||||
|
&config.plugins_config_input(),
|
||||||
|
&[],
|
||||||
|
/*include_openai_curated*/ true,
|
||||||
|
)
|
||||||
|
.map_err(|err| {
|
||||||
|
invalid_data_error(format!("failed to list configured marketplaces: {err}"))
|
||||||
|
})?
|
||||||
|
.marketplaces
|
||||||
|
.into_iter()
|
||||||
|
.map(|marketplace| (marketplace.name, marketplace.path))
|
||||||
|
.collect::<BTreeMap<_, _>>();
|
||||||
let import_sources = self.marketplace_import_sources(cwd)?;
|
let import_sources = self.marketplace_import_sources(cwd)?;
|
||||||
for plugin_group in plugins {
|
for plugin_group in plugins {
|
||||||
let marketplace_name = plugin_group.marketplace_name.clone();
|
let marketplace_name = plugin_group.marketplace_name.clone();
|
||||||
@@ -977,64 +1017,71 @@ impl ExternalAgentConfigService {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|plugin_name| format!("{plugin_name}@{marketplace_name}"))
|
.map(|plugin_name| format!("{plugin_name}@{marketplace_name}"))
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let import_source = import_sources.get(&marketplace_name).cloned();
|
let marketplace_path = if let Some(marketplace_path) =
|
||||||
let Some(import_source) = import_source else {
|
configured_marketplace_paths.get(&marketplace_name)
|
||||||
let message = format!(
|
{
|
||||||
"external agent plugin marketplace source was not found: {marketplace_name}"
|
outcome
|
||||||
);
|
.succeeded_marketplaces
|
||||||
record_plugin_import_errors(
|
.push(marketplace_name.clone());
|
||||||
&mut outcome,
|
marketplace_path.clone()
|
||||||
cwd,
|
} else {
|
||||||
&plugin_ids,
|
let Some(import_source) = import_sources.get(&marketplace_name).cloned() else {
|
||||||
"plugin_import",
|
let message = format!(
|
||||||
message,
|
"external agent plugin marketplace source was not found: {marketplace_name}"
|
||||||
);
|
);
|
||||||
outcome.failed_marketplaces.push(marketplace_name);
|
|
||||||
outcome.failed_plugin_ids.extend(plugin_ids);
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let request = MarketplaceAddRequest {
|
|
||||||
source: import_source.source,
|
|
||||||
ref_name: import_source.ref_name,
|
|
||||||
sparse_paths: Vec::new(),
|
|
||||||
};
|
|
||||||
let add_marketplace_outcome =
|
|
||||||
add_marketplace(self.codex_home.clone(), requirements.clone(), request).await;
|
|
||||||
let marketplace_path = match add_marketplace_outcome {
|
|
||||||
Ok(add_marketplace_outcome) => {
|
|
||||||
let Some(marketplace_path) = find_marketplace_manifest_path(
|
|
||||||
add_marketplace_outcome.installed_root.as_path(),
|
|
||||||
) else {
|
|
||||||
let message = format!(
|
|
||||||
"plugin marketplace manifest was not found after install: {marketplace_name}"
|
|
||||||
);
|
|
||||||
record_plugin_import_errors(
|
|
||||||
&mut outcome,
|
|
||||||
cwd,
|
|
||||||
&plugin_ids,
|
|
||||||
"plugin_import",
|
|
||||||
message,
|
|
||||||
);
|
|
||||||
outcome.failed_marketplaces.push(marketplace_name);
|
|
||||||
outcome.failed_plugin_ids.extend(plugin_ids);
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
outcome
|
|
||||||
.succeeded_marketplaces
|
|
||||||
.push(marketplace_name.clone());
|
|
||||||
marketplace_path
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
record_plugin_import_errors(
|
record_plugin_import_errors(
|
||||||
&mut outcome,
|
&mut outcome,
|
||||||
cwd,
|
cwd,
|
||||||
&plugin_ids,
|
&plugin_ids,
|
||||||
"plugin_import",
|
"plugin_import",
|
||||||
err.to_string(),
|
message,
|
||||||
);
|
);
|
||||||
outcome.failed_marketplaces.push(marketplace_name);
|
outcome.failed_marketplaces.push(marketplace_name);
|
||||||
outcome.failed_plugin_ids.extend(plugin_ids);
|
outcome.failed_plugin_ids.extend(plugin_ids);
|
||||||
continue;
|
continue;
|
||||||
|
};
|
||||||
|
let request = MarketplaceAddRequest {
|
||||||
|
source: import_source.source,
|
||||||
|
ref_name: import_source.ref_name,
|
||||||
|
sparse_paths: Vec::new(),
|
||||||
|
};
|
||||||
|
match add_marketplace(self.codex_home.clone(), requirements.clone(), request).await
|
||||||
|
{
|
||||||
|
Ok(add_marketplace_outcome) => {
|
||||||
|
let Some(marketplace_path) = find_marketplace_manifest_path(
|
||||||
|
add_marketplace_outcome.installed_root.as_path(),
|
||||||
|
) else {
|
||||||
|
let message = format!(
|
||||||
|
"plugin marketplace manifest was not found after install: {marketplace_name}"
|
||||||
|
);
|
||||||
|
record_plugin_import_errors(
|
||||||
|
&mut outcome,
|
||||||
|
cwd,
|
||||||
|
&plugin_ids,
|
||||||
|
"plugin_import",
|
||||||
|
message,
|
||||||
|
);
|
||||||
|
outcome.failed_marketplaces.push(marketplace_name);
|
||||||
|
outcome.failed_plugin_ids.extend(plugin_ids);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
outcome
|
||||||
|
.succeeded_marketplaces
|
||||||
|
.push(marketplace_name.clone());
|
||||||
|
marketplace_path
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
record_plugin_import_errors(
|
||||||
|
&mut outcome,
|
||||||
|
cwd,
|
||||||
|
&plugin_ids,
|
||||||
|
"plugin_import",
|
||||||
|
err.to_string(),
|
||||||
|
);
|
||||||
|
outcome.failed_marketplaces.push(marketplace_name);
|
||||||
|
outcome.failed_plugin_ids.extend(plugin_ids);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let install_config = match ConfigBuilder::default()
|
let install_config = match ConfigBuilder::default()
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
use codex_external_agent_migration::RewriteProfile;
|
use codex_external_agent_migration::RewriteProfile;
|
||||||
use codex_external_agent_migration::sessions::ExternalAgentSessionMigration;
|
use codex_external_agent_migration::sessions::ExternalAgentSessionMigration;
|
||||||
|
use codex_external_agent_migration::sessions::SessionMetadataMode;
|
||||||
use codex_external_agent_migration::sessions::detect_recent_cla_sessions;
|
use codex_external_agent_migration::sessions::detect_recent_cla_sessions;
|
||||||
|
use codex_external_agent_migration::sessions::detect_recent_cur_sessions;
|
||||||
use serde_json::Value as JsonValue;
|
use serde_json::Value as JsonValue;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
@@ -11,6 +13,7 @@ use toml::Value as TomlValue;
|
|||||||
|
|
||||||
use super::MigrationDetails;
|
use super::MigrationDetails;
|
||||||
use super::source_cla;
|
use super::source_cla;
|
||||||
|
use super::source_cur;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub(super) struct InstructionSourceGroup {
|
pub(super) struct InstructionSourceGroup {
|
||||||
@@ -33,6 +36,7 @@ pub(super) struct PluginDetectionContext<'a> {
|
|||||||
pub(super) external_agent_home: &'a Path,
|
pub(super) external_agent_home: &'a Path,
|
||||||
pub(super) source_settings: &'a Path,
|
pub(super) source_settings: &'a Path,
|
||||||
pub(super) source_root: &'a Path,
|
pub(super) source_root: &'a Path,
|
||||||
|
pub(super) repo_root: Option<&'a Path>,
|
||||||
pub(super) settings: Option<&'a JsonValue>,
|
pub(super) settings: Option<&'a JsonValue>,
|
||||||
pub(super) configured_plugin_ids: &'a HashSet<String>,
|
pub(super) configured_plugin_ids: &'a HashSet<String>,
|
||||||
pub(super) configured_marketplace_plugins: &'a BTreeMap<String, HashSet<String>>,
|
pub(super) configured_marketplace_plugins: &'a BTreeMap<String, HashSet<String>>,
|
||||||
@@ -42,6 +46,7 @@ pub(super) struct PluginDetectionContext<'a> {
|
|||||||
pub(super) enum ExternalAgentSource {
|
pub(super) enum ExternalAgentSource {
|
||||||
#[default]
|
#[default]
|
||||||
Cla,
|
Cla,
|
||||||
|
Cur,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -52,9 +57,20 @@ pub(super) enum SourceFeature {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ExternalAgentSource {
|
impl ExternalAgentSource {
|
||||||
|
pub(super) fn from_migration_source(migration_source: Option<&str>) -> Self {
|
||||||
|
if migration_source
|
||||||
|
.is_some_and(|source| source.eq_ignore_ascii_case(source_cur::MIGRATION_SOURCE))
|
||||||
|
{
|
||||||
|
Self::Cur
|
||||||
|
} else {
|
||||||
|
Self::Cla
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn config_dir(self) -> &'static str {
|
pub(super) fn config_dir(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Cla => source_cla::CONFIG_DIR,
|
Self::Cla => source_cla::CONFIG_DIR,
|
||||||
|
Self::Cur => source_cur::CONFIG_DIR,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,21 +80,29 @@ impl ExternalAgentSource {
|
|||||||
Self::Cla,
|
Self::Cla,
|
||||||
SourceFeature::Config | SourceFeature::Plugins | SourceFeature::Sessions,
|
SourceFeature::Config | SourceFeature::Plugins | SourceFeature::Sessions,
|
||||||
) => true,
|
) => true,
|
||||||
|
(
|
||||||
|
Self::Cur,
|
||||||
|
SourceFeature::Config | SourceFeature::Plugins | SourceFeature::Sessions,
|
||||||
|
) => true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn settings_file_name(self, project_scope: bool) -> &'static str {
|
pub(super) fn settings_file_name(self, project_scope: bool) -> &'static str {
|
||||||
match (self, project_scope) {
|
match (self, project_scope) {
|
||||||
(Self::Cla, _) => "settings.json",
|
(Self::Cla, _) => "settings.json",
|
||||||
|
(Self::Cur, false) => source_cur::HOME_CONFIG_FILE,
|
||||||
|
(Self::Cur, true) => source_cur::PROJECT_CONFIG_FILE,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn effective_settings(
|
pub(super) fn effective_settings(
|
||||||
self,
|
self,
|
||||||
|
source_config_dir: &Path,
|
||||||
source_settings: &Path,
|
source_settings: &Path,
|
||||||
) -> io::Result<Option<JsonValue>> {
|
) -> io::Result<Option<JsonValue>> {
|
||||||
match self {
|
match self {
|
||||||
Self::Cla => source_cla::effective_settings(source_settings),
|
Self::Cla => source_cla::effective_settings(source_settings),
|
||||||
|
Self::Cur => source_cur::effective_settings(source_config_dir, source_settings),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,12 +112,15 @@ impl ExternalAgentSource {
|
|||||||
) -> io::Result<Option<DetectedSourcePlugins>> {
|
) -> io::Result<Option<DetectedSourcePlugins>> {
|
||||||
match self {
|
match self {
|
||||||
Self::Cla => Ok(source_cla::detect_plugins(&context)),
|
Self::Cla => Ok(source_cla::detect_plugins(&context)),
|
||||||
|
Self::Cur if context.repo_root.is_none() => source_cur::detect_plugins(&context),
|
||||||
|
Self::Cur => Ok(None),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn can_detect_plugins(self, settings: Option<&JsonValue>) -> bool {
|
pub(super) fn can_detect_plugins(self, settings: Option<&JsonValue>) -> bool {
|
||||||
match self {
|
match self {
|
||||||
Self::Cla => source_cla::can_detect_plugins(settings),
|
Self::Cla => source_cla::can_detect_plugins(settings),
|
||||||
|
Self::Cur => true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,12 +131,21 @@ impl ExternalAgentSource {
|
|||||||
) -> io::Result<Vec<ExternalAgentSessionMigration>> {
|
) -> io::Result<Vec<ExternalAgentSessionMigration>> {
|
||||||
match self {
|
match self {
|
||||||
Self::Cla => detect_recent_cla_sessions(external_agent_home, codex_home),
|
Self::Cla => detect_recent_cla_sessions(external_agent_home, codex_home),
|
||||||
|
Self::Cur => detect_recent_cur_sessions(external_agent_home, codex_home),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn session_metadata_mode(self) -> SessionMetadataMode {
|
||||||
|
match self {
|
||||||
|
Self::Cla => SessionMetadataMode::Embedded,
|
||||||
|
Self::Cur => SessionMetadataMode::MigrationFallback,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn connector_metadata_roots(self, external_agent_home: &Path) -> Vec<PathBuf> {
|
pub(super) fn connector_metadata_roots(self, external_agent_home: &Path) -> Vec<PathBuf> {
|
||||||
match self {
|
match self {
|
||||||
Self::Cla => source_cla::connector_metadata_roots(external_agent_home),
|
Self::Cla => source_cla::connector_metadata_roots(external_agent_home),
|
||||||
|
Self::Cur => Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +166,7 @@ impl ExternalAgentSource {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
.unwrap_or_default()),
|
.unwrap_or_default()),
|
||||||
|
Self::Cur => source_cur::marketplace_import_sources(external_agent_home),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,23 +177,31 @@ impl ExternalAgentSource {
|
|||||||
) {
|
) {
|
||||||
match self {
|
match self {
|
||||||
Self::Cla => source_cla::append_config(root, settings),
|
Self::Cla => source_cla::append_config(root, settings),
|
||||||
|
Self::Cur => source_cur::append_config(root, settings),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn build_mcp_config(
|
pub(super) fn build_mcp_config(
|
||||||
self,
|
self,
|
||||||
source_root: &Path,
|
source_root: &Path,
|
||||||
|
source_config_dir: &Path,
|
||||||
external_agent_home: &Path,
|
external_agent_home: &Path,
|
||||||
settings: Option<&JsonValue>,
|
settings: Option<&JsonValue>,
|
||||||
) -> io::Result<TomlValue> {
|
) -> io::Result<TomlValue> {
|
||||||
match self {
|
match self {
|
||||||
Self::Cla => source_cla::build_mcp_config(source_root, external_agent_home, settings),
|
Self::Cla => source_cla::build_mcp_config(source_root, external_agent_home, settings),
|
||||||
|
Self::Cur => source_cur::build_mcp_config(source_config_dir),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn mcp_source_path(self, source_root: PathBuf) -> PathBuf {
|
pub(super) fn mcp_source_path(
|
||||||
|
self,
|
||||||
|
source_root: PathBuf,
|
||||||
|
source_config_dir: PathBuf,
|
||||||
|
) -> PathBuf {
|
||||||
match self {
|
match self {
|
||||||
Self::Cla => source_root,
|
Self::Cla => source_root,
|
||||||
|
Self::Cur => source_config_dir.join("mcp.json"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,6 +211,7 @@ impl ExternalAgentSource {
|
|||||||
) -> io::Result<Vec<InstructionSourceGroup>> {
|
) -> io::Result<Vec<InstructionSourceGroup>> {
|
||||||
match self {
|
match self {
|
||||||
Self::Cla => source_cla::repo_instruction_source_groups(repo_root),
|
Self::Cla => source_cla::repo_instruction_source_groups(repo_root),
|
||||||
|
Self::Cur => source_cur::repo_instruction_source_groups(repo_root),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,12 +221,14 @@ impl ExternalAgentSource {
|
|||||||
) -> io::Result<Vec<PathBuf>> {
|
) -> io::Result<Vec<PathBuf>> {
|
||||||
match self {
|
match self {
|
||||||
Self::Cla => source_cla::home_instruction_sources(external_agent_home),
|
Self::Cla => source_cla::home_instruction_sources(external_agent_home),
|
||||||
|
Self::Cur => Ok(Vec::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn read_instruction_source(self, path: &Path) -> io::Result<String> {
|
pub(super) fn read_instruction_source(self, path: &Path) -> io::Result<String> {
|
||||||
match self {
|
match self {
|
||||||
Self::Cla => source_cla::read_instruction_source(path),
|
Self::Cla => source_cla::read_instruction_source(path),
|
||||||
|
Self::Cur => source_cur::read_instruction_source(path),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,6 +239,7 @@ impl ExternalAgentSource {
|
|||||||
) -> io::Result<Vec<String>> {
|
) -> io::Result<Vec<String>> {
|
||||||
match self {
|
match self {
|
||||||
Self::Cla => source_cla::import_source_commands(source_commands, target_skills),
|
Self::Cla => source_cla::import_source_commands(source_commands, target_skills),
|
||||||
|
Self::Cur => source_cur::import_source_commands(source_commands, target_skills),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,6 +250,7 @@ impl ExternalAgentSource {
|
|||||||
) -> io::Result<usize> {
|
) -> io::Result<usize> {
|
||||||
match self {
|
match self {
|
||||||
Self::Cla => source_cla::count_missing_source_commands(source_commands, target_skills),
|
Self::Cla => source_cla::count_missing_source_commands(source_commands, target_skills),
|
||||||
|
Self::Cur => source_cur::count_missing_source_commands(source_commands, target_skills),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,6 +261,7 @@ impl ExternalAgentSource {
|
|||||||
) -> io::Result<Vec<String>> {
|
) -> io::Result<Vec<String>> {
|
||||||
match self {
|
match self {
|
||||||
Self::Cla => source_cla::missing_source_command_names(source_commands, target_skills),
|
Self::Cla => source_cla::missing_source_command_names(source_commands, target_skills),
|
||||||
|
Self::Cur => source_cur::missing_source_command_names(source_commands, target_skills),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,6 +272,7 @@ impl ExternalAgentSource {
|
|||||||
) -> io::Result<Vec<String>> {
|
) -> io::Result<Vec<String>> {
|
||||||
match self {
|
match self {
|
||||||
Self::Cla => source_cla::import_source_subagents(source_agents, target_agents),
|
Self::Cla => source_cla::import_source_subagents(source_agents, target_agents),
|
||||||
|
Self::Cur => source_cur::import_source_subagents(source_agents, target_agents),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,18 +283,21 @@ impl ExternalAgentSource {
|
|||||||
) -> io::Result<Vec<String>> {
|
) -> io::Result<Vec<String>> {
|
||||||
match self {
|
match self {
|
||||||
Self::Cla => source_cla::source_hook_event_names(source_dir, target_hooks),
|
Self::Cla => source_cla::source_hook_event_names(source_dir, target_hooks),
|
||||||
|
Self::Cur => source_cur::source_hook_event_names(source_dir, target_hooks),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn import_hooks(self, source_dir: &Path, target_hooks: &Path) -> io::Result<bool> {
|
pub(super) fn import_hooks(self, source_dir: &Path, target_hooks: &Path) -> io::Result<bool> {
|
||||||
match self {
|
match self {
|
||||||
Self::Cla => source_cla::import_source_hooks(source_dir, target_hooks),
|
Self::Cla => source_cla::import_source_hooks(source_dir, target_hooks),
|
||||||
|
Self::Cur => source_cur::import_source_hooks(source_dir, target_hooks),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn rewrite_profile(self) -> RewriteProfile {
|
pub(super) fn rewrite_profile(self) -> RewriteProfile {
|
||||||
match self {
|
match self {
|
||||||
Self::Cla => source_cla::REWRITE_PROFILE,
|
Self::Cla => source_cla::REWRITE_PROFILE,
|
||||||
|
Self::Cur => source_cur::REWRITE_PROFILE,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
use codex_core_plugins::CommandDescriptionMode;
|
||||||
|
use codex_core_plugins::CommandMigrationProfile;
|
||||||
|
use codex_core_plugins::CommandRewriteProfile;
|
||||||
|
use codex_core_plugins::count_missing_commands_with_profile;
|
||||||
|
use codex_core_plugins::import_commands_with_profile;
|
||||||
|
use codex_core_plugins::missing_command_names_with_profile;
|
||||||
|
use codex_external_agent_migration::RewriteProfile;
|
||||||
|
use codex_external_agent_migration::build_mcp_config_from_json_file;
|
||||||
|
use codex_external_agent_migration::hook_migration_event_names_cur;
|
||||||
|
use codex_external_agent_migration::import_hooks_cur;
|
||||||
|
use codex_external_agent_migration::import_subagents_with_rewrite_profile;
|
||||||
|
use serde_json::Value as JsonValue;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
use std::fs;
|
||||||
|
use std::io;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use toml::Value as TomlValue;
|
||||||
|
|
||||||
|
use super::MigrationDetails;
|
||||||
|
use super::PluginsMigration;
|
||||||
|
use super::is_non_empty_text_file;
|
||||||
|
use super::source::DetectedSourcePlugins;
|
||||||
|
use super::source::InstructionSourceGroup;
|
||||||
|
use super::source::MarketplaceImportSource;
|
||||||
|
use super::source::PluginDetectionContext;
|
||||||
|
|
||||||
|
pub(super) const CONFIG_DIR: &str = ".cursor";
|
||||||
|
pub(super) const MIGRATION_SOURCE: &str = "cursor";
|
||||||
|
const LEGACY_RULES_FILE: &str = ".cursorrules";
|
||||||
|
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";
|
||||||
|
const PLUGIN_MARKETPLACE_MANIFEST: &str = ".cursor-plugin/marketplace.json";
|
||||||
|
pub(super) const REWRITE_PROFILE: RewriteProfile =
|
||||||
|
RewriteProfile::new(LEGACY_RULES_FILE, &[]).with_case_sensitive_term_variants(&["Cursor"]);
|
||||||
|
const COMMAND_MIGRATION_PROFILE: CommandMigrationProfile = CommandMigrationProfile::new(
|
||||||
|
CommandRewriteProfile::new(
|
||||||
|
REWRITE_PROFILE.doc_file_name(),
|
||||||
|
REWRITE_PROFILE.term_variants(),
|
||||||
|
)
|
||||||
|
.with_case_sensitive_term_variants(REWRITE_PROFILE.case_sensitive_term_variants()),
|
||||||
|
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 repo_instruction_source_groups(
|
||||||
|
repo_root: &Path,
|
||||||
|
) -> io::Result<Vec<InstructionSourceGroup>> {
|
||||||
|
let source = repo_root.join(LEGACY_RULES_FILE);
|
||||||
|
Ok(is_non_empty_text_file(&source)?
|
||||||
|
.then(|| InstructionSourceGroup {
|
||||||
|
scope: repo_root.to_path_buf(),
|
||||||
|
sources: vec![source],
|
||||||
|
})
|
||||||
|
.into_iter()
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn read_instruction_source(path: &Path) -> io::Result<String> {
|
||||||
|
fs::read_to_string(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
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_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 detect_plugins(
|
||||||
|
context: &PluginDetectionContext<'_>,
|
||||||
|
) -> io::Result<Option<DetectedSourcePlugins>> {
|
||||||
|
let mut plugins = Vec::new();
|
||||||
|
for marketplace in cached_marketplace_plugins(context.external_agent_home)? {
|
||||||
|
let configured_marketplace = context
|
||||||
|
.configured_marketplace_plugins
|
||||||
|
.get(&marketplace.name);
|
||||||
|
let plugin_names = marketplace
|
||||||
|
.plugin_names
|
||||||
|
.into_iter()
|
||||||
|
.filter(|plugin_name| {
|
||||||
|
!context
|
||||||
|
.configured_plugin_ids
|
||||||
|
.contains(&format!("{plugin_name}@{}", marketplace.name))
|
||||||
|
&& configured_marketplace.is_none_or(|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);
|
||||||
|
}
|
||||||
|
Ok(Some(DetectedSourcePlugins {
|
||||||
|
description: format!(
|
||||||
|
"Migrate cached plugins from {}",
|
||||||
|
context.external_agent_home.join("plugins/cache").display()
|
||||||
|
),
|
||||||
|
details: MigrationDetails {
|
||||||
|
plugins,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn marketplace_import_sources(
|
||||||
|
external_agent_home: &Path,
|
||||||
|
) -> io::Result<BTreeMap<String, MarketplaceImportSource>> {
|
||||||
|
Ok(cached_marketplace_plugins(external_agent_home)?
|
||||||
|
.into_iter()
|
||||||
|
.map(|marketplace| {
|
||||||
|
(
|
||||||
|
marketplace.name,
|
||||||
|
MarketplaceImportSource {
|
||||||
|
source: marketplace.source.display().to_string(),
|
||||||
|
ref_name: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn import_source_commands(
|
||||||
|
source_commands: &Path,
|
||||||
|
target_skills: &Path,
|
||||||
|
) -> io::Result<Vec<String>> {
|
||||||
|
import_commands_with_profile(source_commands, target_skills, COMMAND_MIGRATION_PROFILE)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn count_missing_source_commands(
|
||||||
|
source_commands: &Path,
|
||||||
|
target_skills: &Path,
|
||||||
|
) -> io::Result<usize> {
|
||||||
|
count_missing_commands_with_profile(source_commands, target_skills, COMMAND_MIGRATION_PROFILE)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn missing_source_command_names(
|
||||||
|
source_commands: &Path,
|
||||||
|
target_skills: &Path,
|
||||||
|
) -> io::Result<Vec<String>> {
|
||||||
|
missing_command_names_with_profile(source_commands, target_skills, COMMAND_MIGRATION_PROFILE)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn import_source_subagents(
|
||||||
|
source_agents: &Path,
|
||||||
|
target_agents: &Path,
|
||||||
|
) -> io::Result<Vec<String>> {
|
||||||
|
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_cur(
|
||||||
|
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_cur(
|
||||||
|
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(PLUGIN_MARKETPLACE_MANIFEST);
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "source_cur_tests.rs"]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
use super::*;
|
||||||
|
use pretty_assertions::assert_eq;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn effective_settings_merge_sandbox_configuration() {
|
||||||
|
let root = TempDir::new().expect("tempdir");
|
||||||
|
let source_dir = root.path().join(CONFIG_DIR);
|
||||||
|
let source_settings = source_dir.join(HOME_CONFIG_FILE);
|
||||||
|
fs::create_dir_all(&source_dir).expect("source directory");
|
||||||
|
fs::write(&source_settings, r#"{"env":{"FOO":"bar"}}"#).expect("source settings");
|
||||||
|
fs::write(
|
||||||
|
source_dir.join(SANDBOX_CONFIG_FILE),
|
||||||
|
r#"{"type":"read_only"}"#,
|
||||||
|
)
|
||||||
|
.expect("sandbox settings");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
effective_settings(&source_dir, &source_settings).expect("effective settings"),
|
||||||
|
Some(serde_json::json!({
|
||||||
|
"env": {"FOO": "bar"},
|
||||||
|
(SANDBOX_SETTINGS_KEY): {"type": "read_only"}
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn append_config_maps_workspace_permissions() {
|
||||||
|
let root = TempDir::new().expect("tempdir");
|
||||||
|
let writable_root = root.path().join("generated");
|
||||||
|
let settings = serde_json::json!({
|
||||||
|
(SANDBOX_SETTINGS_KEY): {
|
||||||
|
"type": "workspace_readwrite",
|
||||||
|
"additionalReadwritePaths": [writable_root.display().to_string(), "relative/path"],
|
||||||
|
"disableTmpWrite": true,
|
||||||
|
"networkPolicy": {"default": "allow"}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let mut config = toml::map::Map::new();
|
||||||
|
|
||||||
|
append_config(&mut config, settings.as_object().expect("settings object"));
|
||||||
|
|
||||||
|
let mut workspace_write = toml::map::Map::new();
|
||||||
|
workspace_write.insert(
|
||||||
|
"writable_roots".to_string(),
|
||||||
|
TomlValue::Array(vec![TomlValue::String(
|
||||||
|
writable_root.to_string_lossy().into_owned(),
|
||||||
|
)]),
|
||||||
|
);
|
||||||
|
workspace_write.insert("exclude_slash_tmp".to_string(), TomlValue::Boolean(true));
|
||||||
|
workspace_write.insert(
|
||||||
|
"exclude_tmpdir_env_var".to_string(),
|
||||||
|
TomlValue::Boolean(true),
|
||||||
|
);
|
||||||
|
workspace_write.insert("network_access".to_string(), TomlValue::Boolean(true));
|
||||||
|
let mut expected = toml::map::Map::new();
|
||||||
|
expected.insert(
|
||||||
|
"sandbox_mode".to_string(),
|
||||||
|
TomlValue::String("workspace-write".to_string()),
|
||||||
|
);
|
||||||
|
expected.insert(
|
||||||
|
"sandbox_workspace_write".to_string(),
|
||||||
|
TomlValue::Table(workspace_write),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(TomlValue::Table(config), TomlValue::Table(expected));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cached_marketplace_plugins_require_manifest_and_cache_entries() {
|
||||||
|
let root = TempDir::new().expect("tempdir");
|
||||||
|
let marketplace_root = root.path().join("plugins/marketplaces/acme");
|
||||||
|
let cache_root = root.path().join("plugins/cache/acme");
|
||||||
|
let manifest_path = marketplace_root.join(PLUGIN_MARKETPLACE_MANIFEST);
|
||||||
|
fs::create_dir_all(manifest_path.parent().expect("manifest parent"))
|
||||||
|
.expect("manifest directory");
|
||||||
|
fs::create_dir_all(cache_root.join("sample")).expect("cached plugin");
|
||||||
|
fs::create_dir_all(cache_root.join("not-listed")).expect("unlisted cached plugin");
|
||||||
|
fs::write(
|
||||||
|
&manifest_path,
|
||||||
|
r#"{
|
||||||
|
"name": "acme",
|
||||||
|
"plugins": [{"name": "sample"}, {"name": "not-cached"}]
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.expect("marketplace manifest");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
cached_marketplace_plugins(root.path()).expect("cached marketplace plugins"),
|
||||||
|
vec![CachedMarketplacePlugins {
|
||||||
|
name: "acme".to_string(),
|
||||||
|
source: marketplace_root,
|
||||||
|
plugin_names: vec!["sample".to_string()],
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_uninstalled_plugin_from_configured_marketplace() {
|
||||||
|
let root = TempDir::new().expect("tempdir");
|
||||||
|
let marketplace_root = root.path().join("plugins/marketplaces/acme");
|
||||||
|
let manifest_path = marketplace_root.join(PLUGIN_MARKETPLACE_MANIFEST);
|
||||||
|
fs::create_dir_all(manifest_path.parent().expect("manifest parent"))
|
||||||
|
.expect("manifest directory");
|
||||||
|
fs::create_dir_all(root.path().join("plugins/cache/acme/sample")).expect("cached plugin");
|
||||||
|
fs::write(
|
||||||
|
&manifest_path,
|
||||||
|
r#"{"name":"acme","plugins":[{"name":"sample"}]}"#,
|
||||||
|
)
|
||||||
|
.expect("marketplace manifest");
|
||||||
|
let configured_plugin_ids = HashSet::new();
|
||||||
|
let configured_marketplace_plugins =
|
||||||
|
BTreeMap::from([("acme".to_string(), HashSet::from(["sample".to_string()]))]);
|
||||||
|
let source_settings = root.path().join(HOME_CONFIG_FILE);
|
||||||
|
let source_root = root.path().join("repo");
|
||||||
|
|
||||||
|
let detected = detect_plugins(&PluginDetectionContext {
|
||||||
|
external_agent_home: root.path(),
|
||||||
|
source_settings: &source_settings,
|
||||||
|
source_root: &source_root,
|
||||||
|
repo_root: None,
|
||||||
|
settings: None,
|
||||||
|
configured_plugin_ids: &configured_plugin_ids,
|
||||||
|
configured_marketplace_plugins: &configured_marketplace_plugins,
|
||||||
|
})
|
||||||
|
.expect("detect plugins")
|
||||||
|
.expect("plugin migration");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
detected.details,
|
||||||
|
MigrationDetails {
|
||||||
|
plugins: vec![PluginsMigration {
|
||||||
|
marketplace_name: "acme".to_string(),
|
||||||
|
plugin_names: vec!["sample".to_string()],
|
||||||
|
}],
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_legacy_repo_instruction_file() {
|
||||||
|
let root = TempDir::new().expect("tempdir");
|
||||||
|
let source = root.path().join(LEGACY_RULES_FILE);
|
||||||
|
fs::write(&source, "Use the source agent carefully.\n").expect("legacy rules");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
repo_instruction_source_groups(root.path()).expect("instruction sources"),
|
||||||
|
vec![InstructionSourceGroup {
|
||||||
|
scope: root.path().to_path_buf(),
|
||||||
|
sources: vec![source.clone()],
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
read_instruction_source(&source).expect("instruction contents"),
|
||||||
|
"Use the source agent carefully.\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2545,6 +2545,121 @@ async fn import_plugins_supports_external_agent_plugin_marketplace_layout() {
|
|||||||
assert!(config.contains("enabled = true"));
|
assert!(config.contains("enabled = true"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn import_plugins_reuses_configured_marketplace_with_different_source() {
|
||||||
|
let (_root, external_agent_home, codex_home) = fixture_paths();
|
||||||
|
let configured_marketplace_root = external_agent_home.join("configured-marketplace");
|
||||||
|
let source_marketplace_root = external_agent_home.join("source-marketplace");
|
||||||
|
let configured_plugin_root = configured_marketplace_root.join("plugins/cloudflare");
|
||||||
|
let source_plugin_root = source_marketplace_root.join("plugins/cloudflare");
|
||||||
|
fs::create_dir_all(configured_marketplace_root.join(".agents/plugins"))
|
||||||
|
.expect("create configured marketplace manifest dir");
|
||||||
|
fs::create_dir_all(configured_plugin_root.join(".codex-plugin"))
|
||||||
|
.expect("create configured plugin manifest dir");
|
||||||
|
fs::create_dir_all(source_marketplace_root.join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR))
|
||||||
|
.expect("create source marketplace manifest dir");
|
||||||
|
fs::create_dir_all(source_plugin_root.join(".codex-plugin"))
|
||||||
|
.expect("create source plugin manifest dir");
|
||||||
|
fs::create_dir_all(&codex_home).expect("create codex home");
|
||||||
|
|
||||||
|
fs::write(
|
||||||
|
external_agent_home.join("settings.json"),
|
||||||
|
serde_json::to_string_pretty(&serde_json::json!({
|
||||||
|
"enabledPlugins": {
|
||||||
|
"cloudflare@my-plugins": true
|
||||||
|
},
|
||||||
|
"extraKnownMarketplaces": {
|
||||||
|
"my-plugins": {
|
||||||
|
"source": "local",
|
||||||
|
"path": source_marketplace_root
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.expect("serialize settings"),
|
||||||
|
)
|
||||||
|
.expect("write settings");
|
||||||
|
fs::write(
|
||||||
|
codex_home.join("config.toml"),
|
||||||
|
format!(
|
||||||
|
r#"[marketplaces.my-plugins]
|
||||||
|
source_type = "local"
|
||||||
|
source = {configured_marketplace_root:?}
|
||||||
|
"#
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.expect("write Codex config");
|
||||||
|
fs::write(
|
||||||
|
configured_marketplace_root.join(".agents/plugins/marketplace.json"),
|
||||||
|
r#"{
|
||||||
|
"name": "my-plugins",
|
||||||
|
"plugins": [{
|
||||||
|
"name": "cloudflare",
|
||||||
|
"source": {"source": "local", "path": "./plugins/cloudflare"}
|
||||||
|
}]
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.expect("write configured marketplace manifest");
|
||||||
|
fs::write(
|
||||||
|
source_marketplace_root
|
||||||
|
.join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR)
|
||||||
|
.join("marketplace.json"),
|
||||||
|
r#"{
|
||||||
|
"name": "my-plugins",
|
||||||
|
"plugins": [{"name": "cloudflare", "source": "./plugins/cloudflare"}]
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.expect("write source marketplace manifest");
|
||||||
|
fs::write(
|
||||||
|
configured_plugin_root.join(".codex-plugin/plugin.json"),
|
||||||
|
r#"{"name":"cloudflare","version":"0.1.0"}"#,
|
||||||
|
)
|
||||||
|
.expect("write configured plugin manifest");
|
||||||
|
fs::write(
|
||||||
|
source_plugin_root.join(".codex-plugin/plugin.json"),
|
||||||
|
r#"{"name":"cloudflare","version":"0.2.0"}"#,
|
||||||
|
)
|
||||||
|
.expect("write source plugin manifest");
|
||||||
|
|
||||||
|
let outcome = service_for_paths(external_agent_home, codex_home.clone())
|
||||||
|
.import_plugins(
|
||||||
|
/*cwd*/ None,
|
||||||
|
Some(MigrationDetails {
|
||||||
|
plugins: vec![PluginsMigration {
|
||||||
|
marketplace_name: "my-plugins".to_string(),
|
||||||
|
plugin_names: vec!["cloudflare".to_string()],
|
||||||
|
}],
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("import plugins");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
outcome,
|
||||||
|
PluginImportOutcome {
|
||||||
|
succeeded_marketplaces: vec!["my-plugins".to_string()],
|
||||||
|
succeeded_plugin_ids: vec!["cloudflare@my-plugins".to_string()],
|
||||||
|
failed_marketplaces: Vec::new(),
|
||||||
|
failed_plugin_ids: Vec::new(),
|
||||||
|
raw_errors: Vec::new(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
let config: TomlValue =
|
||||||
|
toml::from_str(&fs::read_to_string(codex_home.join("config.toml")).expect("read config"))
|
||||||
|
.expect("parse config");
|
||||||
|
let expected: TomlValue = toml::from_str(&format!(
|
||||||
|
r#"[marketplaces.my-plugins]
|
||||||
|
source_type = "local"
|
||||||
|
source = {configured_marketplace_root:?}
|
||||||
|
|
||||||
|
[plugins."cloudflare@my-plugins"]
|
||||||
|
enabled = true
|
||||||
|
"#
|
||||||
|
))
|
||||||
|
.expect("parse expected config");
|
||||||
|
assert_eq!(config, expected);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn detect_home_supports_relative_external_agent_plugin_marketplace_path() {
|
async fn detect_home_supports_relative_external_agent_plugin_marketplace_path() {
|
||||||
let (_root, external_agent_home, codex_home) = fixture_paths();
|
let (_root, external_agent_home, codex_home) = fixture_paths();
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ use codex_external_agent_migration::sessions::ExternalAgentSessionMigration;
|
|||||||
use codex_external_agent_migration::sessions::ImportedExternalAgentSession;
|
use codex_external_agent_migration::sessions::ImportedExternalAgentSession;
|
||||||
use codex_external_agent_migration::sessions::ImportedSessionConnectorAttribution;
|
use codex_external_agent_migration::sessions::ImportedSessionConnectorAttribution;
|
||||||
use codex_external_agent_migration::sessions::PendingSessionImport;
|
use codex_external_agent_migration::sessions::PendingSessionImport;
|
||||||
|
use codex_external_agent_migration::sessions::SessionMetadataMode;
|
||||||
use codex_external_agent_migration::sessions::detect_imported_cla_session_connectors;
|
use codex_external_agent_migration::sessions::detect_imported_cla_session_connectors;
|
||||||
use codex_external_agent_migration::sessions::prepare_validated_session_import;
|
use codex_external_agent_migration::sessions::prepare_validated_session_import_with_metadata_mode;
|
||||||
use codex_external_agent_migration::sessions::record_completed_session_imports;
|
use codex_external_agent_migration::sessions::record_completed_session_imports;
|
||||||
use codex_models_manager::manager::RefreshStrategy;
|
use codex_models_manager::manager::RefreshStrategy;
|
||||||
use codex_protocol::ThreadId;
|
use codex_protocol::ThreadId;
|
||||||
@@ -75,6 +76,7 @@ impl ExternalAgentSessionImporter {
|
|||||||
&self,
|
&self,
|
||||||
sessions: Vec<ExternalAgentSessionMigration>,
|
sessions: Vec<ExternalAgentSessionMigration>,
|
||||||
mut item_result: ExternalAgentConfigImportItemResult,
|
mut item_result: ExternalAgentConfigImportItemResult,
|
||||||
|
metadata_mode: SessionMetadataMode,
|
||||||
) -> ExternalAgentConfigImportItemResult {
|
) -> ExternalAgentConfigImportItemResult {
|
||||||
if sessions.is_empty() {
|
if sessions.is_empty() {
|
||||||
return item_result;
|
return item_result;
|
||||||
@@ -91,7 +93,11 @@ impl ExternalAgentSessionImporter {
|
|||||||
let import_results = futures::stream::iter(sessions)
|
let import_results = futures::stream::iter(sessions)
|
||||||
.map(|session| {
|
.map(|session| {
|
||||||
let importer = self.clone();
|
let importer = self.clone();
|
||||||
async move { importer.import_requested_session(session).await }
|
async move {
|
||||||
|
importer
|
||||||
|
.import_requested_session(session, metadata_mode)
|
||||||
|
.await
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.buffer_unordered(SESSION_IMPORT_CONCURRENCY);
|
.buffer_unordered(SESSION_IMPORT_CONCURRENCY);
|
||||||
futures::pin_mut!(import_results);
|
futures::pin_mut!(import_results);
|
||||||
@@ -167,16 +173,17 @@ impl ExternalAgentSessionImporter {
|
|||||||
async fn import_requested_session(
|
async fn import_requested_session(
|
||||||
&self,
|
&self,
|
||||||
session: ExternalAgentSessionMigration,
|
session: ExternalAgentSessionMigration,
|
||||||
|
metadata_mode: SessionMetadataMode,
|
||||||
) -> Result<Option<CompletedSessionImport>, SessionImportFailure> {
|
) -> Result<Option<CompletedSessionImport>, SessionImportFailure> {
|
||||||
let source_path = session.path.clone();
|
let source_path = session.path.clone();
|
||||||
let Some(pending_import) =
|
let Some(pending_import) = self
|
||||||
self.prepare_session_import(session)
|
.prepare_session_import(session, metadata_mode)
|
||||||
.await
|
.await
|
||||||
.map_err(|message| SessionImportFailure {
|
.map_err(|message| SessionImportFailure {
|
||||||
source_path: source_path.clone(),
|
source_path: source_path.clone(),
|
||||||
message,
|
message,
|
||||||
stage: "session_prepare",
|
stage: "session_prepare",
|
||||||
})?
|
})?
|
||||||
else {
|
else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
@@ -212,12 +219,15 @@ impl ExternalAgentSessionImporter {
|
|||||||
async fn prepare_session_import(
|
async fn prepare_session_import(
|
||||||
&self,
|
&self,
|
||||||
session: ExternalAgentSessionMigration,
|
session: ExternalAgentSessionMigration,
|
||||||
|
metadata_mode: SessionMetadataMode,
|
||||||
) -> Result<Option<PendingSessionImport>, String> {
|
) -> Result<Option<PendingSessionImport>, String> {
|
||||||
let codex_home = self.codex_home.clone();
|
let codex_home = self.codex_home.clone();
|
||||||
tokio::task::spawn_blocking(move || prepare_validated_session_import(&codex_home, session))
|
tokio::task::spawn_blocking(move || {
|
||||||
.await
|
prepare_validated_session_import_with_metadata_mode(&codex_home, session, metadata_mode)
|
||||||
.map_err(|err| format!("external agent session preparation task failed: {err}"))?
|
})
|
||||||
.map_err(|err| format!("failed to prepare external agent session: {err}"))
|
.await
|
||||||
|
.map_err(|err| format!("external agent session preparation task failed: {err}"))?
|
||||||
|
.map_err(|err| format!("failed to prepare external agent session: {err}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn persist_session(
|
async fn persist_session(
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ use tokio::time::timeout;
|
|||||||
use super::analytics::wait_for_analytics_event;
|
use super::analytics::wait_for_analytics_event;
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
|
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
|
||||||
|
const SECONDARY_MIGRATION_SOURCE: &str = concat!("cur", "sor");
|
||||||
|
|
||||||
fn external_agent_home(codex_home: &Path) -> PathBuf {
|
fn external_agent_home(codex_home: &Path) -> PathBuf {
|
||||||
codex_home.join(concat!(".", "cla", "ude"))
|
codex_home.join(concat!(".", "cla", "ude"))
|
||||||
@@ -63,19 +64,22 @@ fn connector_metadata_root(home: &Path) -> PathBuf {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn secondary_external_agent_home(codex_home: &Path) -> PathBuf {
|
||||||
|
codex_home.join(concat!(".", "cur", "sor"))
|
||||||
|
}
|
||||||
|
|
||||||
fn assert_import_response(response: ExternalAgentConfigImportResponse) -> String {
|
fn assert_import_response(response: ExternalAgentConfigImportResponse) -> String {
|
||||||
assert!(!response.import_id.is_empty());
|
assert!(!response.import_id.is_empty());
|
||||||
response.import_id
|
response.import_id
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn external_agent_config_detect_accepts_source_selection_and_defaults_unknown_values()
|
async fn external_agent_config_detect_accepts_migration_source_and_defaults_unknown_values()
|
||||||
-> Result<()> {
|
-> Result<()> {
|
||||||
let codex_home = TempDir::new()?;
|
let codex_home = TempDir::new()?;
|
||||||
let source_home = external_agent_home(codex_home.path());
|
let source_home = external_agent_home(codex_home.path());
|
||||||
std::fs::create_dir_all(&source_home)?;
|
std::fs::create_dir_all(&source_home)?;
|
||||||
std::fs::write(source_home.join("CLAUDE.md"), "project instructions")?;
|
std::fs::write(source_home.join("CLAUDE.md"), "project instructions")?;
|
||||||
|
|
||||||
let home_dir = codex_home.path().display().to_string();
|
let home_dir = codex_home.path().display().to_string();
|
||||||
let mut mcp = TestAppServer::builder()
|
let mut mcp = TestAppServer::builder()
|
||||||
.with_codex_home(codex_home.path())
|
.with_codex_home(codex_home.path())
|
||||||
@@ -86,11 +90,21 @@ async fn external_agent_config_detect_accepts_source_selection_and_defaults_unkn
|
|||||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||||
|
|
||||||
let mut responses = Vec::new();
|
let mut responses = Vec::new();
|
||||||
for source in [None, Some("claude-code"), Some("unknown-source")] {
|
for params in [
|
||||||
let mut params = serde_json::json!({ "includeHome": true });
|
serde_json::json!({ "includeHome": true }),
|
||||||
if let Some(source) = source {
|
serde_json::json!({
|
||||||
params["source"] = serde_json::json!(source);
|
"includeHome": true,
|
||||||
}
|
"migrationSource": "claude-code",
|
||||||
|
}),
|
||||||
|
serde_json::json!({
|
||||||
|
"includeHome": true,
|
||||||
|
"migrationSource": "unknown-source",
|
||||||
|
}),
|
||||||
|
serde_json::json!({
|
||||||
|
"includeHome": true,
|
||||||
|
"source": SECONDARY_MIGRATION_SOURCE,
|
||||||
|
}),
|
||||||
|
] {
|
||||||
let request_id = mcp
|
let request_id = mcp
|
||||||
.send_raw_request("externalAgentConfig/detect", Some(params))
|
.send_raw_request("externalAgentConfig/detect", Some(params))
|
||||||
.await?;
|
.await?;
|
||||||
@@ -108,7 +122,411 @@ async fn external_agent_config_detect_accepts_source_selection_and_defaults_unkn
|
|||||||
ExternalAgentConfigMigrationItemType::AgentsMd
|
ExternalAgentConfigMigrationItemType::AgentsMd
|
||||||
);
|
);
|
||||||
let expected = responses[0].clone();
|
let expected = responses[0].clone();
|
||||||
assert_eq!(responses, vec![expected; 3]);
|
assert_eq!(responses, vec![expected; 4]);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn external_agent_config_migration_source_drives_detect_and_import() -> Result<()> {
|
||||||
|
let codex_home = TempDir::new()?;
|
||||||
|
let source_home = secondary_external_agent_home(codex_home.path());
|
||||||
|
std::fs::create_dir_all(&source_home)?;
|
||||||
|
std::fs::write(source_home.join("sandbox.json"), r#"{"type":"read_only"}"#)?;
|
||||||
|
let home_dir = codex_home.path().display().to_string();
|
||||||
|
let mut mcp = TestAppServer::builder()
|
||||||
|
.with_codex_home(codex_home.path())
|
||||||
|
.without_auto_env()
|
||||||
|
.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,
|
||||||
|
"migrationSource": SECONDARY_MIGRATION_SOURCE,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let response: JSONRPCResponse = timeout(
|
||||||
|
DEFAULT_TIMEOUT,
|
||||||
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||||
|
)
|
||||||
|
.await??;
|
||||||
|
let detected: ExternalAgentConfigDetectResponse = to_response(response)?;
|
||||||
|
assert_eq!(detected.items.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
detected.items[0].item_type,
|
||||||
|
ExternalAgentConfigMigrationItemType::Config
|
||||||
|
);
|
||||||
|
|
||||||
|
let request_id = mcp
|
||||||
|
.send_raw_request(
|
||||||
|
"externalAgentConfig/import",
|
||||||
|
Some(serde_json::json!({
|
||||||
|
"migrationSource": SECONDARY_MIGRATION_SOURCE,
|
||||||
|
"migrationItems": detected.items,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.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_eq!(completed.item_type_results.len(), 1);
|
||||||
|
assert_eq!(completed.item_type_results[0].successes.len(), 1);
|
||||||
|
assert_eq!(completed.item_type_results[0].failures, Vec::new());
|
||||||
|
assert!(
|
||||||
|
std::fs::read_to_string(codex_home.path().join("config.toml"))?
|
||||||
|
.contains("sandbox_mode = \"read-only\"")
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn external_agent_config_import_source_remains_attribution_only() -> Result<()> {
|
||||||
|
let codex_home = TempDir::new()?;
|
||||||
|
let source_home = external_agent_home(codex_home.path());
|
||||||
|
std::fs::create_dir_all(&source_home)?;
|
||||||
|
std::fs::write(source_home.join("CLAUDE.md"), "Claude guidance")?;
|
||||||
|
let home_dir = codex_home.path().display().to_string();
|
||||||
|
let mut mcp = TestAppServer::builder()
|
||||||
|
.with_codex_home(codex_home.path())
|
||||||
|
.without_auto_env()
|
||||||
|
.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 })),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let response: JSONRPCResponse = timeout(
|
||||||
|
DEFAULT_TIMEOUT,
|
||||||
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||||
|
)
|
||||||
|
.await??;
|
||||||
|
let detected: ExternalAgentConfigDetectResponse = to_response(response)?;
|
||||||
|
assert_eq!(detected.items.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
detected.items[0].item_type,
|
||||||
|
ExternalAgentConfigMigrationItemType::AgentsMd
|
||||||
|
);
|
||||||
|
|
||||||
|
let request_id = mcp
|
||||||
|
.send_raw_request(
|
||||||
|
"externalAgentConfig/import",
|
||||||
|
Some(serde_json::json!({
|
||||||
|
"source": SECONDARY_MIGRATION_SOURCE,
|
||||||
|
"migrationItems": detected.items,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.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_eq!(completed.item_type_results.len(), 1);
|
||||||
|
assert_eq!(completed.item_type_results[0].successes.len(), 1);
|
||||||
|
assert_eq!(completed.item_type_results[0].failures, Vec::new());
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(codex_home.path().join("AGENTS.md"))?,
|
||||||
|
"Codex guidance"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn external_agent_config_secondary_source_imports_session_and_plugin_end_to_end() -> Result<()>
|
||||||
|
{
|
||||||
|
let codex_home = TempDir::new()?;
|
||||||
|
let source_home = secondary_external_agent_home(codex_home.path());
|
||||||
|
let project_root = codex_home.path().join("workspace with.dots_and-dashes");
|
||||||
|
std::fs::create_dir_all(&project_root)?;
|
||||||
|
|
||||||
|
let encoded_project = project_root
|
||||||
|
.to_string_lossy()
|
||||||
|
.trim_start_matches(['/', '\\'])
|
||||||
|
.chars()
|
||||||
|
.map(|character| {
|
||||||
|
if character.is_ascii_alphanumeric() {
|
||||||
|
character
|
||||||
|
} else {
|
||||||
|
'-'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<String>();
|
||||||
|
#[cfg(windows)]
|
||||||
|
let encoded_project = encoded_project.replacen("--", "-", /*count*/ 1);
|
||||||
|
let session_path = source_home
|
||||||
|
.join("projects")
|
||||||
|
.join(encoded_project)
|
||||||
|
.join("agent-transcripts/session-1/session-1.jsonl");
|
||||||
|
std::fs::create_dir_all(session_path.parent().expect("session parent"))?;
|
||||||
|
std::fs::write(
|
||||||
|
&session_path,
|
||||||
|
[
|
||||||
|
serde_json::json!({
|
||||||
|
"role": "user",
|
||||||
|
"message": {
|
||||||
|
"content": [{
|
||||||
|
"type": "text",
|
||||||
|
"text": "<user_query>first request</user_query>"
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
serde_json::json!({
|
||||||
|
"role": "assistant",
|
||||||
|
"message": {
|
||||||
|
"content": [{"type": "text", "text": "first answer"}]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
]
|
||||||
|
.join("\n"),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let marketplace_root = source_home.join("plugins/marketplaces/debug");
|
||||||
|
let plugin_root = marketplace_root.join("plugins/sample");
|
||||||
|
let configured_marketplace_root = codex_home.path().join("configured-marketplace");
|
||||||
|
let configured_marketplace_manifest =
|
||||||
|
configured_marketplace_root.join(".agents/plugins/marketplace.json");
|
||||||
|
let configured_plugin_root = configured_marketplace_root.join("plugins/sample");
|
||||||
|
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(source_home.join("plugins/cache/debug/sample"))?;
|
||||||
|
std::fs::create_dir_all(
|
||||||
|
configured_marketplace_manifest
|
||||||
|
.parent()
|
||||||
|
.expect("configured marketplace manifest parent"),
|
||||||
|
)?;
|
||||||
|
std::fs::create_dir_all(configured_plugin_root.join(".codex-plugin"))?;
|
||||||
|
std::fs::write(
|
||||||
|
marketplace_root.join(".cursor-plugin/marketplace.json"),
|
||||||
|
r#"{
|
||||||
|
"name": "debug",
|
||||||
|
"plugins": [{"name": "sample", "source": "plugins/sample"}]
|
||||||
|
}"#,
|
||||||
|
)?;
|
||||||
|
std::fs::write(
|
||||||
|
plugin_root.join(".cursor-plugin/plugin.json"),
|
||||||
|
r#"{"name":"sample","version":"0.2.0"}"#,
|
||||||
|
)?;
|
||||||
|
std::fs::write(
|
||||||
|
&configured_marketplace_manifest,
|
||||||
|
r#"{
|
||||||
|
"name": "debug",
|
||||||
|
"plugins": [{
|
||||||
|
"name": "sample",
|
||||||
|
"source": {"source": "local", "path": "./plugins/sample"}
|
||||||
|
}]
|
||||||
|
}"#,
|
||||||
|
)?;
|
||||||
|
std::fs::write(
|
||||||
|
configured_plugin_root.join(".codex-plugin/plugin.json"),
|
||||||
|
r#"{"name":"sample","version":"0.1.0"}"#,
|
||||||
|
)?;
|
||||||
|
std::fs::write(
|
||||||
|
codex_home.path().join("config.toml"),
|
||||||
|
format!(
|
||||||
|
r#"[marketplaces.debug]
|
||||||
|
source_type = "local"
|
||||||
|
source = {:?}
|
||||||
|
"#,
|
||||||
|
configured_marketplace_root.display().to_string()
|
||||||
|
),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let home_dir = codex_home.path().display().to_string();
|
||||||
|
let mut mcp = TestAppServer::builder()
|
||||||
|
.with_codex_home(codex_home.path())
|
||||||
|
.without_auto_env()
|
||||||
|
.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,
|
||||||
|
"migrationSource": SECONDARY_MIGRATION_SOURCE,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let response: JSONRPCResponse = timeout(
|
||||||
|
DEFAULT_TIMEOUT,
|
||||||
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||||
|
)
|
||||||
|
.await??;
|
||||||
|
let detected: ExternalAgentConfigDetectResponse = to_response(response)?;
|
||||||
|
assert_eq!(detected.items.len(), 2);
|
||||||
|
assert!(
|
||||||
|
detected
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.any(|item| item.item_type == ExternalAgentConfigMigrationItemType::Sessions)
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
detected
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.any(|item| item.item_type == ExternalAgentConfigMigrationItemType::Plugins)
|
||||||
|
);
|
||||||
|
|
||||||
|
let request_id = mcp
|
||||||
|
.send_raw_request(
|
||||||
|
"externalAgentConfig/import",
|
||||||
|
Some(serde_json::json!({
|
||||||
|
"migrationSource": SECONDARY_MIGRATION_SOURCE,
|
||||||
|
"migrationItems": detected.items,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.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_eq!(completed.item_type_results.len(), 2);
|
||||||
|
assert!(
|
||||||
|
completed
|
||||||
|
.item_type_results
|
||||||
|
.iter()
|
||||||
|
.all(|result| result.failures.is_empty())
|
||||||
|
);
|
||||||
|
|
||||||
|
let request_id = mcp
|
||||||
|
.send_thread_list_request(ThreadListParams {
|
||||||
|
cursor: None,
|
||||||
|
limit: None,
|
||||||
|
sort_key: None,
|
||||||
|
sort_direction: None,
|
||||||
|
model_providers: None,
|
||||||
|
source_kinds: None,
|
||||||
|
archived: None,
|
||||||
|
cwd: None,
|
||||||
|
use_state_db_only: false,
|
||||||
|
search_term: None,
|
||||||
|
parent_thread_id: None,
|
||||||
|
ancestor_thread_id: None,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
let response: JSONRPCResponse = timeout(
|
||||||
|
DEFAULT_TIMEOUT,
|
||||||
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||||
|
)
|
||||||
|
.await??;
|
||||||
|
let response: ThreadListResponse = to_response(response)?;
|
||||||
|
let thread = response.data.first().expect("imported session");
|
||||||
|
assert_eq!(thread.cwd.as_path(), project_root);
|
||||||
|
assert_eq!(thread.preview, "first request");
|
||||||
|
assert_eq!(thread.name, None);
|
||||||
|
|
||||||
|
let request_id = mcp
|
||||||
|
.send_thread_read_request(ThreadReadParams {
|
||||||
|
thread_id: thread.id.clone(),
|
||||||
|
include_turns: true,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
let response: JSONRPCResponse = timeout(
|
||||||
|
DEFAULT_TIMEOUT,
|
||||||
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||||
|
)
|
||||||
|
.await??;
|
||||||
|
let response: ThreadReadResponse = to_response(response)?;
|
||||||
|
assert_eq!(response.thread.turns.len(), 1);
|
||||||
|
let imported_items = &response.thread.turns[0].items;
|
||||||
|
assert_eq!(imported_items.len(), 3);
|
||||||
|
match &imported_items[0] {
|
||||||
|
ThreadItem::UserMessage { content, .. } => assert_eq!(
|
||||||
|
content,
|
||||||
|
&vec![UserInput::Text {
|
||||||
|
text: "first request".to_string(),
|
||||||
|
text_elements: Vec::new(),
|
||||||
|
}]
|
||||||
|
),
|
||||||
|
other => panic!("expected user message item, got {other:?}"),
|
||||||
|
}
|
||||||
|
match &imported_items[1] {
|
||||||
|
ThreadItem::AgentMessage { text, .. } => assert_eq!(text, "first answer"),
|
||||||
|
other => panic!("expected agent message item, got {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let request_id = mcp
|
||||||
|
.send_plugin_list_request(PluginListParams {
|
||||||
|
cwds: None,
|
||||||
|
marketplace_kinds: None,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
let response: JSONRPCResponse = timeout(
|
||||||
|
DEFAULT_TIMEOUT,
|
||||||
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||||
|
)
|
||||||
|
.await??;
|
||||||
|
let response: PluginListResponse = to_response(response)?;
|
||||||
|
let marketplace = response
|
||||||
|
.marketplaces
|
||||||
|
.iter()
|
||||||
|
.find(|marketplace| marketplace.name == "debug")
|
||||||
|
.expect("configured marketplace");
|
||||||
|
assert_eq!(
|
||||||
|
marketplace
|
||||||
|
.path
|
||||||
|
.as_ref()
|
||||||
|
.map(codex_config::AbsolutePathBuf::as_path),
|
||||||
|
Some(configured_marketplace_manifest.as_path())
|
||||||
|
);
|
||||||
|
let plugin = marketplace
|
||||||
|
.plugins
|
||||||
|
.iter()
|
||||||
|
.find(|plugin| plugin.name == "sample")
|
||||||
|
.expect("imported plugin");
|
||||||
|
assert_eq!(plugin.local_version.as_deref(), Some("0.1.0"));
|
||||||
|
assert!(plugin.installed);
|
||||||
|
assert!(plugin.enabled);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const MARKETPLACE_MANIFEST_RELATIVE_PATHS: &[&str] = &[
|
|||||||
".agents/plugins/marketplace.json",
|
".agents/plugins/marketplace.json",
|
||||||
".agents/plugins/api_marketplace.json",
|
".agents/plugins/api_marketplace.json",
|
||||||
".claude-plugin/marketplace.json",
|
".claude-plugin/marketplace.json",
|
||||||
|
".cursor-plugin/marketplace.json",
|
||||||
];
|
];
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[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.
|
// 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 {
|
return Err(MarketplaceError::InvalidMarketplaceFile {
|
||||||
path: marketplace_path.to_path_buf(),
|
path: marketplace_path.to_path_buf(),
|
||||||
message: "local plugin source path must start with `./`".to_string(),
|
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_MARKETPLACE_RELATIVE_PATH: &str = ".claude-plugin/marketplace.json";
|
||||||
const ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH: &str = ".claude-plugin/plugin.json";
|
const ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH: &str = ".claude-plugin/plugin.json";
|
||||||
|
const CUR_MARKETPLACE_RELATIVE_PATH: &str = ".cursor-plugin/marketplace.json";
|
||||||
|
const CUR_PLUGIN_MANIFEST_RELATIVE_PATH: &str = ".cursor-plugin/plugin.json";
|
||||||
fn write_alternate_marketplace(repo_root: &Path, contents: &str) -> AbsolutePathBuf {
|
fn write_alternate_marketplace(repo_root: &Path, contents: &str) -> AbsolutePathBuf {
|
||||||
let marketplace_path = repo_root.join(ALTERNATE_MARKETPLACE_RELATIVE_PATH);
|
let marketplace_path = repo_root.join(ALTERNATE_MARKETPLACE_RELATIVE_PATH);
|
||||||
fs::create_dir_all(marketplace_path.parent().unwrap()).unwrap();
|
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();
|
fs::write(manifest_path, contents).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn write_cur_marketplace(repo_root: &Path, contents: &str) -> AbsolutePathBuf {
|
||||||
|
let marketplace_path = repo_root.join(CUR_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_cur_plugin_manifest(plugin_root: &Path, contents: &str) {
|
||||||
|
let manifest_path = plugin_root.join(CUR_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 {
|
fn minimal_manifest_fallback(name: &str) -> MarketplacePluginManifestFallback {
|
||||||
MarketplacePluginManifestFallback {
|
MarketplacePluginManifestFallback {
|
||||||
contents: format!(
|
contents: format!(
|
||||||
@@ -124,6 +139,37 @@ fn find_marketplace_plugin_supports_alternate_layout_and_string_local_source() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_marketplace_plugin_supports_cur_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_cur_marketplace(
|
||||||
|
&repo_root,
|
||||||
|
r#"{
|
||||||
|
"name": "secondary-marketplace",
|
||||||
|
"plugins": [{"name": "sample", "source": "plugins/sample"}]
|
||||||
|
}"#,
|
||||||
|
);
|
||||||
|
write_cur_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]
|
#[test]
|
||||||
fn find_marketplace_plugin_supports_git_subdir_sources() {
|
fn find_marketplace_plugin_supports_git_subdir_sources() {
|
||||||
let tmp = tempdir().unwrap();
|
let tmp = tempdir().unwrap();
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
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_cur(
|
||||||
|
source_external_agent_dir: &Path,
|
||||||
|
source_hooks: &Path,
|
||||||
|
target_hooks: &Path,
|
||||||
|
rewrite_profile: RewriteProfile,
|
||||||
|
) -> io::Result<Vec<String>> {
|
||||||
|
let migration = hook_migration_cur(
|
||||||
|
source_external_agent_dir,
|
||||||
|
source_hooks,
|
||||||
|
target_hooks.parent(),
|
||||||
|
rewrite_profile,
|
||||||
|
)?;
|
||||||
|
Ok(migration.keys().cloned().collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn import_hooks_cur(
|
||||||
|
source_external_agent_dir: &Path,
|
||||||
|
source_hooks: &Path,
|
||||||
|
target_hooks: &Path,
|
||||||
|
rewrite_profile: RewriteProfile,
|
||||||
|
) -> io::Result<bool> {
|
||||||
|
let migration = hook_migration_cur(
|
||||||
|
source_external_agent_dir,
|
||||||
|
source_hooks,
|
||||||
|
target_hooks.parent(),
|
||||||
|
rewrite_profile,
|
||||||
|
)?;
|
||||||
|
write_hook_migration(source_external_agent_dir, target_hooks, migration)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hook_migration_cur(
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
// Codex does not currently support a per-hook failure policy, so accept
|
||||||
|
// Cursor's `failClosed` field without copying it into the migrated handler.
|
||||||
|
if handler.keys().any(|key| {
|
||||||
|
!matches!(
|
||||||
|
key.as_str(),
|
||||||
|
"command"
|
||||||
|
| "failClosed"
|
||||||
|
| "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;
|
||||||
|
};
|
||||||
|
if handler
|
||||||
|
.get("type")
|
||||||
|
.and_then(JsonValue::as_str)
|
||||||
|
.is_some_and(|handler_type| handler_type != "command")
|
||||||
|
{
|
||||||
|
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"),
|
||||||
|
"preCompact" => Some("PreCompact"),
|
||||||
|
"postCompact" => Some("PostCompact"),
|
||||||
|
"sessionStart" => Some("SessionStart"),
|
||||||
|
"subagentStart" => Some("SubagentStart"),
|
||||||
|
"subagentStop" => Some("SubagentStop"),
|
||||||
|
"beforeSubmitPrompt" => Some("UserPromptSubmit"),
|
||||||
|
"stop" => Some("Stop"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "hooks_cur_tests.rs"]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
use super::*;
|
||||||
|
use pretty_assertions::assert_eq;
|
||||||
|
|
||||||
|
const TEST_REWRITE_PROFILE: RewriteProfile =
|
||||||
|
RewriteProfile::new(".source-rules", &["source agent"]);
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn imports_supported_cur_hooks_and_drops_failure_policy() {
|
||||||
|
let root = tempfile::TempDir::new().expect("tempdir");
|
||||||
|
let source_dir = root.path().join(".source");
|
||||||
|
let source_hooks_dir = source_dir.join("hooks");
|
||||||
|
let source_hooks = source_dir.join("hooks.json");
|
||||||
|
let target_hooks = root.path().join(".codex/hooks.json");
|
||||||
|
fs::create_dir_all(&source_hooks_dir).expect("source hooks directory");
|
||||||
|
fs::write(source_hooks_dir.join("check.sh"), "echo check\n").expect("hook script");
|
||||||
|
fs::write(
|
||||||
|
&source_hooks,
|
||||||
|
serde_json::json!({
|
||||||
|
"hooks": {
|
||||||
|
"preToolUse": [{
|
||||||
|
"type": "command",
|
||||||
|
"command": "sh .source/hooks/check.sh",
|
||||||
|
"matcher": "Shell",
|
||||||
|
"statusMessage": "Source agent check",
|
||||||
|
"timeoutSec": "7",
|
||||||
|
"failClosed": false
|
||||||
|
}],
|
||||||
|
"postToolUse": [{
|
||||||
|
"type": "prompt",
|
||||||
|
"command": "echo ignored"
|
||||||
|
}],
|
||||||
|
"subagentStart": [{
|
||||||
|
"command": "echo subagent",
|
||||||
|
"failClosed": true
|
||||||
|
}],
|
||||||
|
"beforeSubmitPrompt": [{
|
||||||
|
"command": "echo ready",
|
||||||
|
"matcher": "ignored"
|
||||||
|
}],
|
||||||
|
"preCompact": [{
|
||||||
|
"command": "echo compact",
|
||||||
|
"matcher": "auto"
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
.expect("hooks config");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
import_hooks_cur(
|
||||||
|
&source_dir,
|
||||||
|
&source_hooks,
|
||||||
|
&target_hooks,
|
||||||
|
TEST_REWRITE_PROFILE,
|
||||||
|
)
|
||||||
|
.expect("import hooks")
|
||||||
|
);
|
||||||
|
|
||||||
|
let target: JsonValue =
|
||||||
|
serde_json::from_str(&fs::read_to_string(&target_hooks).expect("target hooks"))
|
||||||
|
.expect("target hooks JSON");
|
||||||
|
let rewritten_script = target_hooks
|
||||||
|
.parent()
|
||||||
|
.expect("target hooks parent")
|
||||||
|
.join("hooks")
|
||||||
|
.join("check.sh");
|
||||||
|
assert_eq!(
|
||||||
|
target,
|
||||||
|
serde_json::json!({
|
||||||
|
"hooks": {
|
||||||
|
"PreToolUse": [{
|
||||||
|
"matcher": "Shell",
|
||||||
|
"hooks": [{
|
||||||
|
"type": "command",
|
||||||
|
"command": format!("sh '{}'", rewritten_script.display()),
|
||||||
|
"timeout": 7,
|
||||||
|
"statusMessage": "Codex check"
|
||||||
|
}]
|
||||||
|
}],
|
||||||
|
"UserPromptSubmit": [{
|
||||||
|
"hooks": [{
|
||||||
|
"type": "command",
|
||||||
|
"command": "echo ready"
|
||||||
|
}]
|
||||||
|
}],
|
||||||
|
"PreCompact": [{
|
||||||
|
"matcher": "auto",
|
||||||
|
"hooks": [{
|
||||||
|
"type": "command",
|
||||||
|
"command": "echo compact"
|
||||||
|
}]
|
||||||
|
}],
|
||||||
|
"SubagentStart": [{
|
||||||
|
"hooks": [{
|
||||||
|
"type": "command",
|
||||||
|
"command": "echo subagent"
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(rewritten_script).expect("copied hook script"),
|
||||||
|
"echo check\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
//! Migration helpers for importing external-agent configuration into Codex.
|
//! Migration helpers for importing external-agent configuration into Codex.
|
||||||
|
|
||||||
mod hooks_cla;
|
mod hooks_cla;
|
||||||
|
mod hooks_cur;
|
||||||
pub mod sessions;
|
pub mod sessions;
|
||||||
|
|
||||||
use serde_json::Value as JsonValue;
|
use serde_json::Value as JsonValue;
|
||||||
@@ -16,6 +17,8 @@ use toml::Value as TomlValue;
|
|||||||
pub use hooks_cla::hook_migration_event_names_cla;
|
pub use hooks_cla::hook_migration_event_names_cla;
|
||||||
pub use hooks_cla::hooks_migration_description_cla;
|
pub use hooks_cla::hooks_migration_description_cla;
|
||||||
pub use hooks_cla::import_hooks_cla;
|
pub use hooks_cla::import_hooks_cla;
|
||||||
|
pub use hooks_cur::hook_migration_event_names_cur;
|
||||||
|
pub use hooks_cur::import_hooks_cur;
|
||||||
|
|
||||||
const SOURCE_EXTERNAL_AGENT_NAME: &str = "claude";
|
const SOURCE_EXTERNAL_AGENT_NAME: &str = "claude";
|
||||||
const EXTERNAL_AGENT_MCP_CONFIG_FILE: &str = ".mcp.json";
|
const EXTERNAL_AGENT_MCP_CONFIG_FILE: &str = ".mcp.json";
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
use super::ExternalAgentSessionMigration;
|
||||||
|
use super::ledger::load_import_ledger;
|
||||||
|
use super::ledger::save_import_ledger;
|
||||||
|
use super::now_unix_seconds;
|
||||||
|
use super::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_cur_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 fallback_cwd = cur_project_cwd(&project_storage);
|
||||||
|
for path in cur_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, fallback_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, fallback_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, fallback_cwd.as_deref()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
migrations.push(summary.migration);
|
||||||
|
}
|
||||||
|
if ledger_changed {
|
||||||
|
save_import_ledger(codex_home, &ledger)?;
|
||||||
|
}
|
||||||
|
Ok(migrations)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cur_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 cur_project_cwd(project_storage: &Path) -> Option<PathBuf> {
|
||||||
|
let encoded = project_storage.file_name()?.to_str()?;
|
||||||
|
decode_cur_project_path(encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn decode_cur_project_path(encoded: &str) -> Option<PathBuf> {
|
||||||
|
let root = Path::new("/");
|
||||||
|
let mut matches = Vec::new();
|
||||||
|
collect_cur_project_paths(encoded, root, root, /*depth*/ 0, &mut matches);
|
||||||
|
if let Some(encoded) = encoded.strip_prefix('-') {
|
||||||
|
collect_cur_project_paths(encoded, root, root, /*depth*/ 0, &mut matches);
|
||||||
|
}
|
||||||
|
unique_path(matches)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn decode_cur_project_path(encoded: &str) -> Option<PathBuf> {
|
||||||
|
let drive = encoded.as_bytes().first().copied()?;
|
||||||
|
if !drive.is_ascii_alphabetic() || encoded.as_bytes().get(1) != Some(&b'-') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let encoded = encoded.get(2..)?;
|
||||||
|
let base = PathBuf::from(format!("{}:\\", char::from(drive)));
|
||||||
|
let mut matches = Vec::new();
|
||||||
|
collect_cur_project_paths(encoded, &base, &base, /*depth*/ 0, &mut matches);
|
||||||
|
unique_path(matches)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_cur_project_paths(
|
||||||
|
encoded: &str,
|
||||||
|
base: &Path,
|
||||||
|
root: &Path,
|
||||||
|
depth: usize,
|
||||||
|
matches: &mut Vec<PathBuf>,
|
||||||
|
) {
|
||||||
|
if encoded.is_empty() || depth > 32 || matches.len() > 1 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Ok(entries) = fs::read_dir(base) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
if matches.len() > 1 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let candidate = entry.path();
|
||||||
|
if !candidate.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Ok(candidate_from_root) = candidate.strip_prefix(root) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let candidate_slug = cur_project_path_slug(candidate_from_root);
|
||||||
|
if candidate_slug == encoded {
|
||||||
|
if !matches.contains(&candidate) {
|
||||||
|
matches.push(candidate);
|
||||||
|
}
|
||||||
|
} else if encoded
|
||||||
|
.strip_prefix(&candidate_slug)
|
||||||
|
.is_some_and(|remaining| remaining.starts_with('-'))
|
||||||
|
{
|
||||||
|
collect_cur_project_paths(encoded, &candidate, root, depth + 1, matches);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cur_project_path_slug(path: &Path) -> String {
|
||||||
|
path.to_string_lossy()
|
||||||
|
.trim_start_matches(['/', '\\'])
|
||||||
|
.chars()
|
||||||
|
.map(|character| {
|
||||||
|
if character.is_ascii_alphanumeric() {
|
||||||
|
character
|
||||||
|
} else {
|
||||||
|
'-'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unique_path(mut matches: Vec<PathBuf>) -> Option<PathBuf> {
|
||||||
|
(matches.len() == 1).then(|| matches.swap_remove(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "detect_cur_tests.rs"]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
use super::*;
|
||||||
|
use codex_protocol::ThreadId;
|
||||||
|
use pretty_assertions::assert_eq;
|
||||||
|
use std::fs::FileTimes;
|
||||||
|
use std::fs::OpenOptions;
|
||||||
|
use std::time::SystemTime;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_cur_transcript_with_project_cwd() {
|
||||||
|
let root = TempDir::new().expect("tempdir");
|
||||||
|
let project_root = root.path().join("workspace with.dots_and-dashes");
|
||||||
|
fs::create_dir_all(&project_root).expect("project root");
|
||||||
|
let external_agent_home = root.path().join(".external");
|
||||||
|
let encoded_project = encode_project_path(&project_root);
|
||||||
|
let transcript = write_transcript(
|
||||||
|
&external_agent_home,
|
||||||
|
&encoded_project,
|
||||||
|
"a-session",
|
||||||
|
"first request",
|
||||||
|
);
|
||||||
|
|
||||||
|
let sessions =
|
||||||
|
detect_recent_cur_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()),
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_cur_transcript_with_embedded_unc_cwd() {
|
||||||
|
let root = TempDir::new().expect("tempdir");
|
||||||
|
let external_agent_home = root.path().join(".external");
|
||||||
|
let encoded_project = "server-share-repo";
|
||||||
|
let unc_cwd = PathBuf::from(r"\\server\share\repo");
|
||||||
|
let transcript = external_agent_home
|
||||||
|
.join("projects")
|
||||||
|
.join(encoded_project)
|
||||||
|
.join("agent-transcripts")
|
||||||
|
.join("unc-session/unc-session.jsonl");
|
||||||
|
fs::create_dir_all(transcript.parent().expect("transcript parent"))
|
||||||
|
.expect("transcript directory");
|
||||||
|
fs::write(
|
||||||
|
&transcript,
|
||||||
|
[
|
||||||
|
serde_json::json!({
|
||||||
|
"cwd": unc_cwd,
|
||||||
|
"role": "user",
|
||||||
|
"timestamp_ms": 1_800_000_000_000_i64,
|
||||||
|
"message": {
|
||||||
|
"content": [{
|
||||||
|
"type": "text",
|
||||||
|
"text": "<user_query>first request</user_query>",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
serde_json::json!({
|
||||||
|
"role": "assistant",
|
||||||
|
"message": {
|
||||||
|
"content": [{"type": "text", "text": "first answer"}],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
]
|
||||||
|
.join("\n"),
|
||||||
|
)
|
||||||
|
.expect("transcript");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
detect_recent_cur_sessions(&external_agent_home, root.path()).expect("detect sessions"),
|
||||||
|
vec![ExternalAgentSessionMigration {
|
||||||
|
path: transcript,
|
||||||
|
cwd: unc_cwd,
|
||||||
|
title: Some("first request".to_string()),
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skips_cur_subagent_transcripts() {
|
||||||
|
let root = TempDir::new().expect("tempdir");
|
||||||
|
let project_root = root.path().join("workspace");
|
||||||
|
fs::create_dir_all(&project_root).expect("project root");
|
||||||
|
let external_agent_home = root.path().join(".external");
|
||||||
|
let encoded_project = encode_project_path(&project_root);
|
||||||
|
let transcript = write_transcript(
|
||||||
|
&external_agent_home,
|
||||||
|
&encoded_project,
|
||||||
|
"main-session",
|
||||||
|
"first request",
|
||||||
|
);
|
||||||
|
let subagent_transcript = external_agent_home
|
||||||
|
.join("projects")
|
||||||
|
.join(&encoded_project)
|
||||||
|
.join("agent-transcripts")
|
||||||
|
.join("main-session/subagents/worker/worker.jsonl");
|
||||||
|
fs::create_dir_all(
|
||||||
|
subagent_transcript
|
||||||
|
.parent()
|
||||||
|
.expect("subagent transcript parent"),
|
||||||
|
)
|
||||||
|
.expect("subagent transcript directory");
|
||||||
|
fs::write(&subagent_transcript, transcript_contents("first request"))
|
||||||
|
.expect("subagent transcript");
|
||||||
|
|
||||||
|
let sessions =
|
||||||
|
detect_recent_cur_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()),
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_ambiguous_encoded_project_cwd() {
|
||||||
|
let root = TempDir::new().expect("tempdir");
|
||||||
|
let nested_project = root.path().join("workspace").join("nested");
|
||||||
|
let hyphenated_project = root.path().join("workspace-nested");
|
||||||
|
fs::create_dir_all(&nested_project).expect("nested project");
|
||||||
|
fs::create_dir_all(&hyphenated_project).expect("hyphenated project");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
decode_cur_project_path(&encode_project_path(&nested_project)),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_cur_sessions_older_than_import_window() {
|
||||||
|
let root = TempDir::new().expect("tempdir");
|
||||||
|
let project_root = root.path().join("workspace");
|
||||||
|
fs::create_dir_all(&project_root).expect("project root");
|
||||||
|
let external_agent_home = root.path().join(".external");
|
||||||
|
let transcript = write_transcript(
|
||||||
|
&external_agent_home,
|
||||||
|
&encode_project_path(&project_root),
|
||||||
|
"old-session",
|
||||||
|
"old request",
|
||||||
|
);
|
||||||
|
set_modified_at(
|
||||||
|
&transcript,
|
||||||
|
SystemTime::UNIX_EPOCH + Duration::from_secs(/*secs*/ 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
detect_recent_cur_sessions(&external_agent_home, root.path())
|
||||||
|
.expect("detect sessions")
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_cur_sessions_in_batches_and_redetects_modified_imports() {
|
||||||
|
let root = TempDir::new().expect("tempdir");
|
||||||
|
let project_root = root.path().join("workspace");
|
||||||
|
fs::create_dir_all(&project_root).expect("project root");
|
||||||
|
let external_agent_home = root.path().join(".external");
|
||||||
|
let encoded_project = encode_project_path(&project_root);
|
||||||
|
let modified_at = SystemTime::now();
|
||||||
|
let mut expected = Vec::new();
|
||||||
|
for index in 0..=SESSION_IMPORT_MAX_COUNT {
|
||||||
|
let session_id = format!("session-{index:02}");
|
||||||
|
let title = format!("request {index}");
|
||||||
|
let path = write_transcript(&external_agent_home, &encoded_project, &session_id, &title);
|
||||||
|
set_modified_at(
|
||||||
|
&path,
|
||||||
|
modified_at - Duration::from_secs(/*secs*/ index as u64),
|
||||||
|
);
|
||||||
|
expected.push(ExternalAgentSessionMigration {
|
||||||
|
path,
|
||||||
|
cwd: project_root.clone(),
|
||||||
|
title: Some(title),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let oldest_session = expected.pop().expect("oldest session");
|
||||||
|
|
||||||
|
let sessions =
|
||||||
|
detect_recent_cur_sessions(&external_agent_home, root.path()).expect("detect sessions");
|
||||||
|
|
||||||
|
assert_eq!(sessions, expected);
|
||||||
|
for session in &sessions {
|
||||||
|
super::super::ledger::record_imported_session(root.path(), &session.path, ThreadId::new())
|
||||||
|
.expect("record import");
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
detect_recent_cur_sessions(&external_agent_home, root.path()).expect("detect sessions"),
|
||||||
|
vec![oldest_session.clone()]
|
||||||
|
);
|
||||||
|
super::super::ledger::record_imported_session(
|
||||||
|
root.path(),
|
||||||
|
&oldest_session.path,
|
||||||
|
ThreadId::new(),
|
||||||
|
)
|
||||||
|
.expect("record oldest import");
|
||||||
|
assert!(
|
||||||
|
detect_recent_cur_sessions(&external_agent_home, root.path())
|
||||||
|
.expect("detect sessions")
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
|
||||||
|
let modified_session = &expected[0];
|
||||||
|
let updated_record = serde_json::json!({
|
||||||
|
"role": "assistant",
|
||||||
|
"message": {
|
||||||
|
"content": [{"type": "text", "text": "updated answer"}],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
fs::write(
|
||||||
|
&modified_session.path,
|
||||||
|
format!(
|
||||||
|
"{}\n{updated_record}",
|
||||||
|
transcript_contents(modified_session.title.as_deref().expect("session title"))
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.expect("update transcript");
|
||||||
|
set_modified_at(
|
||||||
|
&modified_session.path,
|
||||||
|
SystemTime::now() + Duration::from_secs(/*secs*/ 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
detect_recent_cur_sessions(&external_agent_home, root.path()).expect("detect sessions"),
|
||||||
|
vec![modified_session.clone()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_transcript(
|
||||||
|
external_agent_home: &Path,
|
||||||
|
encoded_project: &str,
|
||||||
|
session_id: &str,
|
||||||
|
first_request: &str,
|
||||||
|
) -> PathBuf {
|
||||||
|
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, transcript_contents(first_request)).expect("transcript");
|
||||||
|
transcript
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transcript_contents(first_request: &str) -> String {
|
||||||
|
[
|
||||||
|
serde_json::json!({
|
||||||
|
"role": "user",
|
||||||
|
"message": {
|
||||||
|
"content": [{
|
||||||
|
"type": "text",
|
||||||
|
"text": format!("<user_query>{first_request}</user_query>"),
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
serde_json::json!({
|
||||||
|
"role": "assistant",
|
||||||
|
"message": {
|
||||||
|
"content": [{"type": "text", "text": "first answer"}],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
]
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_modified_at(path: &Path, modified_at: SystemTime) {
|
||||||
|
OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.open(path)
|
||||||
|
.expect("open transcript")
|
||||||
|
.set_times(FileTimes::new().set_modified(modified_at))
|
||||||
|
.expect("set transcript modified time");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn encode_project_path(path: &Path) -> String {
|
||||||
|
cur_project_path_slug(path).replacen("--", "-", 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn encode_project_path(path: &Path) -> String {
|
||||||
|
cur_project_path_slug(path)
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
use super::ConversationMessage;
|
use super::ConversationMessage;
|
||||||
use super::ImportedExternalAgentSession;
|
use super::ImportedExternalAgentSession;
|
||||||
use super::MessageRole;
|
use super::MessageRole;
|
||||||
use super::records::read_session_import;
|
use super::records::read_session_import_with_cwd;
|
||||||
use super::summarize_for_label;
|
use super::summarize_for_label;
|
||||||
use super::title::IMPORTED_SESSION_FALLBACK_TITLE;
|
use super::title::IMPORTED_SESSION_FALLBACK_TITLE;
|
||||||
use super::title::SessionTitleCandidates;
|
use super::title::SessionTitleCandidates;
|
||||||
@@ -26,14 +26,17 @@ const EXTERNAL_SESSION_IMPORTED_MARKER: &str = "<EXTERNAL SESSION IMPORTED>";
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
fn load_session_for_import(path: &Path) -> io::Result<Option<ImportedExternalAgentSession>> {
|
fn load_session_for_import(path: &Path) -> io::Result<Option<ImportedExternalAgentSession>> {
|
||||||
Ok(load_session_for_import_with_content_sha256(path)?
|
Ok(
|
||||||
.map(|(session, _content_sha256, _attributed_mcp_server_ids)| session))
|
load_session_for_import_with_content_sha256(path, /*fallback_cwd*/ None)?
|
||||||
|
.map(|(session, _content_sha256, _attributed_mcp_server_ids)| session),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn load_session_for_import_with_content_sha256(
|
pub(crate) fn load_session_for_import_with_content_sha256(
|
||||||
path: &Path,
|
path: &Path,
|
||||||
|
fallback_cwd: Option<&Path>,
|
||||||
) -> io::Result<Option<(ImportedExternalAgentSession, String, BTreeSet<String>)>> {
|
) -> io::Result<Option<(ImportedExternalAgentSession, String, BTreeSet<String>)>> {
|
||||||
let parsed = read_session_import(path)?;
|
let parsed = read_session_import_with_cwd(path, fallback_cwd)?;
|
||||||
let Some(cwd) = parsed.cwd else {
|
let Some(cwd) = parsed.cwd else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
mod connectors_cla;
|
mod connectors_cla;
|
||||||
mod detect_cla;
|
mod detect_cla;
|
||||||
|
mod detect_cur;
|
||||||
mod export;
|
mod export;
|
||||||
mod ledger;
|
mod ledger;
|
||||||
mod records;
|
mod records;
|
||||||
@@ -16,6 +17,7 @@ use std::path::PathBuf;
|
|||||||
pub use connectors_cla::ImportedSessionConnectorAttribution;
|
pub use connectors_cla::ImportedSessionConnectorAttribution;
|
||||||
pub use connectors_cla::detect_imported_cla_session_connectors;
|
pub use connectors_cla::detect_imported_cla_session_connectors;
|
||||||
pub use detect_cla::detect_recent_cla_sessions;
|
pub use detect_cla::detect_recent_cla_sessions;
|
||||||
|
pub use detect_cur::detect_recent_cur_sessions;
|
||||||
use export::load_session_for_import_with_content_sha256;
|
use export::load_session_for_import_with_content_sha256;
|
||||||
pub use ledger::CompletedExternalAgentSessionImport;
|
pub use ledger::CompletedExternalAgentSessionImport;
|
||||||
pub use ledger::ImportedConnectorCandidate;
|
pub use ledger::ImportedConnectorCandidate;
|
||||||
@@ -33,6 +35,15 @@ fn normalized_connector_display_name(name: Option<&str>) -> Option<String> {
|
|||||||
.map(ToOwned::to_owned)
|
.map(ToOwned::to_owned)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Selects whether session records must carry their own project metadata.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum SessionMetadataMode {
|
||||||
|
/// Read the project path only from the session records.
|
||||||
|
Embedded,
|
||||||
|
/// Use the detected migration path when the session records omit a project path.
|
||||||
|
MigrationFallback,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct ExternalAgentSessionMigration {
|
pub struct ExternalAgentSessionMigration {
|
||||||
pub path: PathBuf,
|
pub path: PathBuf,
|
||||||
@@ -59,18 +70,38 @@ pub struct PendingSessionImport {
|
|||||||
pub fn prepare_validated_session_import(
|
pub fn prepare_validated_session_import(
|
||||||
codex_home: &Path,
|
codex_home: &Path,
|
||||||
session: ExternalAgentSessionMigration,
|
session: ExternalAgentSessionMigration,
|
||||||
|
) -> io::Result<Option<PendingSessionImport>> {
|
||||||
|
prepare_validated_session_import_with_metadata_mode(
|
||||||
|
codex_home,
|
||||||
|
session,
|
||||||
|
SessionMetadataMode::Embedded,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prepare_validated_session_import_with_metadata_mode(
|
||||||
|
codex_home: &Path,
|
||||||
|
session: ExternalAgentSessionMigration,
|
||||||
|
metadata_mode: SessionMetadataMode,
|
||||||
) -> io::Result<Option<PendingSessionImport>> {
|
) -> io::Result<Option<PendingSessionImport>> {
|
||||||
let has_been_imported = has_current_session_been_imported(codex_home, &session.path)?;
|
let has_been_imported = has_current_session_been_imported(codex_home, &session.path)?;
|
||||||
if has_been_imported {
|
if has_been_imported {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
load_importable_session(&session.path)
|
load_importable_session(&session.path, &session.cwd, metadata_mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_importable_session(path: &Path) -> io::Result<Option<PendingSessionImport>> {
|
fn load_importable_session(
|
||||||
|
path: &Path,
|
||||||
|
fallback_cwd: &Path,
|
||||||
|
metadata_mode: SessionMetadataMode,
|
||||||
|
) -> io::Result<Option<PendingSessionImport>> {
|
||||||
let source_path = std::fs::canonicalize(path)?;
|
let source_path = std::fs::canonicalize(path)?;
|
||||||
|
let fallback_cwd = match metadata_mode {
|
||||||
|
SessionMetadataMode::Embedded => None,
|
||||||
|
SessionMetadataMode::MigrationFallback => Some(fallback_cwd),
|
||||||
|
};
|
||||||
let Some((imported_session, source_content_sha256, attributed_mcp_server_ids)) =
|
let Some((imported_session, source_content_sha256, attributed_mcp_server_ids)) =
|
||||||
load_session_for_import_with_content_sha256(&source_path)?
|
load_session_for_import_with_content_sha256(&source_path, fallback_cwd)?
|
||||||
else {
|
else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
@@ -180,6 +211,48 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migration_fallback_metadata_is_opt_in() {
|
||||||
|
let root = TempDir::new().expect("tempdir");
|
||||||
|
let source_path = root.path().join("session.jsonl");
|
||||||
|
std::fs::write(
|
||||||
|
&source_path,
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "message",
|
||||||
|
"role": "user",
|
||||||
|
"timestamp_ms": 1_782_817_200_000_i64,
|
||||||
|
"message": {"content": "first request"},
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
.expect("session");
|
||||||
|
let migration = session_migration(&source_path);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
prepare_validated_session_import_with_metadata_mode(
|
||||||
|
root.path(),
|
||||||
|
migration.clone(),
|
||||||
|
SessionMetadataMode::Embedded,
|
||||||
|
)
|
||||||
|
.expect("embedded metadata mode")
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
let pending = prepare_validated_session_import_with_metadata_mode(
|
||||||
|
root.path(),
|
||||||
|
migration,
|
||||||
|
SessionMetadataMode::MigrationFallback,
|
||||||
|
)
|
||||||
|
.expect("fallback metadata mode")
|
||||||
|
.expect("pending import");
|
||||||
|
|
||||||
|
assert_eq!(pending.session.cwd, root.path());
|
||||||
|
assert_eq!(
|
||||||
|
pending.session.first_user_message.as_deref(),
|
||||||
|
Some("first request")
|
||||||
|
);
|
||||||
|
assert!(!pending.session.rollout_items.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
fn session_migration(path: &Path) -> ExternalAgentSessionMigration {
|
fn session_migration(path: &Path) -> ExternalAgentSessionMigration {
|
||||||
ExternalAgentSessionMigration {
|
ExternalAgentSessionMigration {
|
||||||
path: path.to_path_buf(),
|
path: path.to_path_buf(),
|
||||||
|
|||||||
@@ -36,7 +36,15 @@ pub(super) struct ParsedSessionImport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn summarize_session(path: &Path) -> io::Result<Option<SessionSummary>> {
|
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 file = File::open(path)?;
|
||||||
|
let fallback_timestamp = fallback_cwd.and_then(|_| file_modified_at_seconds(&file));
|
||||||
let reader = BufReader::new(file);
|
let reader = BufReader::new(file);
|
||||||
let mut cwd = None;
|
let mut cwd = None;
|
||||||
let mut custom_title = None;
|
let mut custom_title = None;
|
||||||
@@ -67,7 +75,8 @@ pub fn summarize_session(path: &Path) -> io::Result<Option<SessionSummary>> {
|
|||||||
if let Some(title) = ai_title_from_record(&record) {
|
if let Some(title) = ai_title_from_record(&record) {
|
||||||
ai_title = Some(title.to_string());
|
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;
|
continue;
|
||||||
};
|
};
|
||||||
saw_message = true;
|
saw_message = true;
|
||||||
@@ -83,7 +92,7 @@ pub fn summarize_session(path: &Path) -> io::Result<Option<SessionSummary>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(cwd) = cwd else {
|
let Some(cwd) = cwd.or_else(|| fallback_cwd.map(Path::to_path_buf)) else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
if !saw_message {
|
if !saw_message {
|
||||||
@@ -109,8 +118,12 @@ pub fn summarize_session(path: &Path) -> io::Result<Option<SessionSummary>> {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn read_session_import(path: &Path) -> io::Result<ParsedSessionImport> {
|
pub(super) fn read_session_import_with_cwd(
|
||||||
|
path: &Path,
|
||||||
|
fallback_cwd: Option<&Path>,
|
||||||
|
) -> io::Result<ParsedSessionImport> {
|
||||||
let file = File::open(path)?;
|
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 reader = BufReader::new(file);
|
||||||
let mut cwd = None;
|
let mut cwd = None;
|
||||||
let mut custom_title = None;
|
let mut custom_title = None;
|
||||||
@@ -152,12 +165,14 @@ pub(super) fn read_session_import(path: &Path) -> io::Result<ParsedSessionImport
|
|||||||
if let Some(title) = ai_title_from_record(&record) {
|
if let Some(title) = ai_title_from_record(&record) {
|
||||||
ai_title = Some(title.to_string());
|
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);
|
messages.push(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(ParsedSessionImport {
|
Ok(ParsedSessionImport {
|
||||||
cwd,
|
cwd: cwd.or_else(|| fallback_cwd.map(Path::to_path_buf)),
|
||||||
custom_title,
|
custom_title,
|
||||||
ai_title,
|
ai_title,
|
||||||
messages,
|
messages,
|
||||||
@@ -182,9 +197,16 @@ fn title_from_record<'a>(record: &'a JsonValue, record_type: &str, field: &str)
|
|||||||
.filter(|title| !title.is_empty())
|
.filter(|title| !title.is_empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn conversation_message_from_owned_record(record: &mut JsonValue) -> Option<ConversationMessage> {
|
fn conversation_message_from_owned_record(
|
||||||
let record_type = record.get("type")?.as_str()?;
|
record: &mut JsonValue,
|
||||||
if record_type != "assistant" && record_type != "user" {
|
fallback_timestamp: Option<i64>,
|
||||||
|
) -> Option<ConversationMessage> {
|
||||||
|
let record_type = record
|
||||||
|
.get("type")
|
||||||
|
.and_then(JsonValue::as_str)
|
||||||
|
.filter(|record_type| matches!(*record_type, "assistant" | "user"))
|
||||||
|
.or_else(|| record.get("role").and_then(JsonValue::as_str))?;
|
||||||
|
if !matches!(record_type, "assistant" | "user") {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
if record.get("isMeta").and_then(JsonValue::as_bool) == Some(true)
|
if record.get("isMeta").and_then(JsonValue::as_bool) == Some(true)
|
||||||
@@ -197,7 +219,14 @@ fn conversation_message_from_owned_record(record: &mut JsonValue) -> Option<Conv
|
|||||||
let timestamp = record
|
let timestamp = record
|
||||||
.get("timestamp")
|
.get("timestamp")
|
||||||
.and_then(JsonValue::as_str)
|
.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 content = record.get_mut("message")?.get_mut("content")?.take();
|
||||||
let extracted = match content {
|
let extracted = match content {
|
||||||
JsonValue::String(text) => {
|
JsonValue::String(text) => {
|
||||||
@@ -211,17 +240,46 @@ fn conversation_message_from_owned_record(record: &mut JsonValue) -> Option<Conv
|
|||||||
}
|
}
|
||||||
content => extract_message_text(&content)?,
|
content => extract_message_text(&content)?,
|
||||||
};
|
};
|
||||||
|
let role = if is_assistant || extracted.only_tool_result {
|
||||||
|
MessageRole::Assistant
|
||||||
|
} else {
|
||||||
|
MessageRole::User
|
||||||
|
};
|
||||||
|
let text = if role == MessageRole::User {
|
||||||
|
unwrap_user_query(extracted.text)
|
||||||
|
} else {
|
||||||
|
extracted.text
|
||||||
|
};
|
||||||
Some(ConversationMessage {
|
Some(ConversationMessage {
|
||||||
role: if is_assistant || extracted.only_tool_result {
|
role,
|
||||||
MessageRole::Assistant
|
text,
|
||||||
} else {
|
|
||||||
MessageRole::User
|
|
||||||
},
|
|
||||||
text: extracted.text,
|
|
||||||
timestamp,
|
timestamp,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn unwrap_user_query(text: String) -> String {
|
||||||
|
let trimmed = text.trim();
|
||||||
|
let Some(inner) = trimmed
|
||||||
|
.strip_prefix("<user_query>")
|
||||||
|
.and_then(|inner| inner.strip_suffix("</user_query>"))
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|inner| !inner.is_empty())
|
||||||
|
else {
|
||||||
|
return text;
|
||||||
|
};
|
||||||
|
inner.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
struct ExtractedMessage {
|
||||||
text: String,
|
text: String,
|
||||||
only_tool_result: bool,
|
only_tool_result: bool,
|
||||||
@@ -379,7 +437,7 @@ mod tests {
|
|||||||
"type": "user",
|
"type": "user",
|
||||||
"cwd": root.path(),
|
"cwd": root.path(),
|
||||||
"timestamp": "2026-06-03T12:00:00Z",
|
"timestamp": "2026-06-03T12:00:00Z",
|
||||||
"message": { "content": "first request" },
|
"message": { "content": "<user_query>\nfirst request\n</user_query>" },
|
||||||
})
|
})
|
||||||
.to_string(),
|
.to_string(),
|
||||||
"not json".to_string(),
|
"not json".to_string(),
|
||||||
@@ -397,7 +455,8 @@ mod tests {
|
|||||||
.join("\n");
|
.join("\n");
|
||||||
std::fs::write(&path, &contents).expect("session");
|
std::fs::write(&path, &contents).expect("session");
|
||||||
|
|
||||||
let parsed = read_session_import(&path).expect("parse session");
|
let parsed =
|
||||||
|
read_session_import_with_cwd(&path, /*fallback_cwd*/ None).expect("parse session");
|
||||||
|
|
||||||
assert_eq!(parsed.cwd.as_deref(), Some(root.path()));
|
assert_eq!(parsed.cwd.as_deref(), Some(root.path()));
|
||||||
assert_eq!(parsed.custom_title.as_deref(), Some("custom title"));
|
assert_eq!(parsed.custom_title.as_deref(), Some("custom title"));
|
||||||
@@ -410,6 +469,33 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn embedded_cwd_overrides_migration_fallback() {
|
||||||
|
let root = TempDir::new().expect("tempdir");
|
||||||
|
let embedded_cwd = root.path().join("embedded");
|
||||||
|
let fallback_cwd = root.path().join("fallback");
|
||||||
|
let path = root.path().join("session.jsonl");
|
||||||
|
std::fs::write(
|
||||||
|
&path,
|
||||||
|
serde_json::json!({
|
||||||
|
"cwd": embedded_cwd,
|
||||||
|
"role": "user",
|
||||||
|
"message": {"content": "first request"},
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
.expect("session");
|
||||||
|
|
||||||
|
let parsed =
|
||||||
|
read_session_import_with_cwd(&path, Some(&fallback_cwd)).expect("parse session");
|
||||||
|
let summary = summarize_session_with_cwd(&path, Some(&fallback_cwd))
|
||||||
|
.expect("summarize session")
|
||||||
|
.expect("session summary");
|
||||||
|
|
||||||
|
assert_eq!(parsed.cwd.as_deref(), Some(embedded_cwd.as_path()));
|
||||||
|
assert_eq!(summary.migration.cwd, embedded_cwd);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn converts_tool_use_blocks_to_bounded_external_agent_tags() {
|
fn converts_tool_use_blocks_to_bounded_external_agent_tags() {
|
||||||
let block = serde_json::json!({
|
let block = serde_json::json!({
|
||||||
|
|||||||
@@ -134,8 +134,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
const JSONRPC_INVALID_REQUEST: i64 = -32600;
|
const JSONRPC_INVALID_REQUEST: i64 = -32600;
|
||||||
const JSONRPC_METHOD_NOT_FOUND: i64 = -32601;
|
const JSONRPC_METHOD_NOT_FOUND: i64 = -32601;
|
||||||
pub(crate) const EXTERNAL_AGENT_CONFIG_IMPORT_IN_PROGRESS_MESSAGE: &str =
|
pub(crate) const EXTERNAL_AGENT_CONFIG_IMPORT_IN_PROGRESS_MESSAGE: &str = "A previous external agent import is still running. Wait for it to finish before importing again.";
|
||||||
"A previous Claude Code import is still running. Wait for it to finish before importing again.";
|
|
||||||
const THREAD_SETTINGS_UPDATE_METHOD: &str = "thread/settings/update";
|
const THREAD_SETTINGS_UPDATE_METHOD: &str = "thread/settings/update";
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
@@ -411,12 +410,13 @@ impl AppServerSession {
|
|||||||
self.client
|
self.client
|
||||||
.request_typed(ClientRequest::ExternalAgentConfigDetect { request_id, params })
|
.request_typed(ClientRequest::ExternalAgentConfigDetect { request_id, params })
|
||||||
.await
|
.await
|
||||||
.wrap_err("externalAgentConfig/detect failed during Claude Code import")
|
.wrap_err("externalAgentConfig/detect failed during external agent import")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn external_agent_config_import(
|
pub(crate) async fn external_agent_config_import(
|
||||||
&mut self,
|
&mut self,
|
||||||
migration_items: Vec<ExternalAgentConfigMigrationItem>,
|
migration_items: Vec<ExternalAgentConfigMigrationItem>,
|
||||||
|
migration_source: String,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
// Mark the import active before sending the request so a fast completion notification
|
// Mark the import active before sending the request so a fast completion notification
|
||||||
// cannot arrive before the TUI records it.
|
// cannot arrive before the TUI records it.
|
||||||
@@ -433,11 +433,12 @@ impl AppServerSession {
|
|||||||
request_id,
|
request_id,
|
||||||
params: ExternalAgentConfigImportParams {
|
params: ExternalAgentConfigImportParams {
|
||||||
migration_items,
|
migration_items,
|
||||||
source: Some("claude-code".to_string()),
|
source: Some(migration_source.clone()),
|
||||||
|
migration_source: Some(migration_source),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.wrap_err("externalAgentConfig/import failed during Claude Code import");
|
.wrap_err("externalAgentConfig/import failed during external agent import");
|
||||||
match response {
|
match response {
|
||||||
Ok(_) => Ok(()),
|
Ok(_) => Ok(()),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
|
|||||||
@@ -868,6 +868,31 @@ mod tests {
|
|||||||
assert_snapshot!("external_agent_config_migration_customize", rendered);
|
assert_snapshot!("external_agent_config_migration_customize", rendered);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn secondary_source_customize_snapshot() {
|
||||||
|
let items = vec![ExternalAgentConfigMigrationItem {
|
||||||
|
item_type: ExternalAgentConfigMigrationItemType::Config,
|
||||||
|
description:
|
||||||
|
"Migrate /Users/alex/.cursor/cli-config.json into /Users/alex/.codex/config.toml"
|
||||||
|
.to_string(),
|
||||||
|
cwd: None,
|
||||||
|
details: None,
|
||||||
|
}];
|
||||||
|
let mut screen = ExternalAgentConfigMigrationScreen::new(
|
||||||
|
FrameRequester::test_dummy(),
|
||||||
|
&items,
|
||||||
|
&items,
|
||||||
|
/*error*/ None,
|
||||||
|
);
|
||||||
|
screen.customize();
|
||||||
|
|
||||||
|
let rendered = render_screen(&screen, /*width*/ 80, /*height*/ 18);
|
||||||
|
assert_snapshot!(
|
||||||
|
"external_agent_config_migration_secondary_source_customize",
|
||||||
|
rendered
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn customize_action_snapshot() {
|
fn customize_action_snapshot() {
|
||||||
let items = sample_items();
|
let items = sample_items();
|
||||||
|
|||||||
@@ -78,15 +78,14 @@ impl WidgetRef for &ExternalAgentConfigMigrationScreen {
|
|||||||
let error_height = u16::from(self.error.is_some());
|
let error_height = u16::from(self.error.is_some());
|
||||||
let intro_lines = match self.view {
|
let intro_lines = match self.view {
|
||||||
MigrationView::Summary => vec![
|
MigrationView::Summary => vec![
|
||||||
Line::from("Bring over your setup, current project, and recent chats."),
|
Line::from("Bring over supported setup from another coding agent."),
|
||||||
Line::from("Codex may add files to your current project folder."),
|
Line::from("Codex may add files to your current project folder."),
|
||||||
Line::from("Your existing Claude Code setup will not be changed."),
|
Line::from("Your existing setup will not be changed."),
|
||||||
Line::from("Standard Claude Chat data cannot be imported."),
|
|
||||||
],
|
],
|
||||||
MigrationView::Customize => vec![
|
MigrationView::Customize => vec![
|
||||||
Line::from("Choose the Claude Code items to import."),
|
Line::from("Choose items to import."),
|
||||||
Line::from("Codex may add files to your current project folder."),
|
Line::from("Codex may add files to your current project folder."),
|
||||||
Line::from("Your existing Claude Code setup will not be changed."),
|
Line::from("Your existing setup will not be changed."),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
let intro_height = intro_lines.len() as u16;
|
let intro_height = intro_lines.len() as u16;
|
||||||
@@ -119,7 +118,7 @@ impl WidgetRef for &ExternalAgentConfigMigrationScreen {
|
|||||||
.areas(inner_area);
|
.areas(inner_area);
|
||||||
|
|
||||||
let title = match self.view {
|
let title = match self.view {
|
||||||
MigrationView::Summary => "Import from Claude Code",
|
MigrationView::Summary => "Import setup",
|
||||||
MigrationView::Customize => "Choose what to import",
|
MigrationView::Customize => "Choose what to import",
|
||||||
};
|
};
|
||||||
let heading = Line::from(vec!["> ".into(), title.bold()]);
|
let heading = Line::from(vec!["> ".into(), title.bold()]);
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ use crate::external_agent_config_migration::ExternalAgentConfigMigrationOutcome;
|
|||||||
use crate::external_agent_config_migration::run_external_agent_config_migration_prompt;
|
use crate::external_agent_config_migration::run_external_agent_config_migration_prompt;
|
||||||
use crate::external_agent_config_migration_model::external_agent_config_migration_item_count;
|
use crate::external_agent_config_migration_model::external_agent_config_migration_item_count;
|
||||||
use crate::external_agent_config_migration_model::external_agent_config_migration_type_label;
|
use crate::external_agent_config_migration_model::external_agent_config_migration_type_label;
|
||||||
|
use crate::external_agent_config_migration_source::ExternalAgentConfigMigrationSource;
|
||||||
|
use crate::external_agent_config_migration_source::run_external_agent_config_source_prompt;
|
||||||
use crate::legacy_core::config::Config;
|
use crate::legacy_core::config::Config;
|
||||||
use crate::tui;
|
use crate::tui;
|
||||||
use codex_app_server_protocol::ExternalAgentConfigDetectParams;
|
use codex_app_server_protocol::ExternalAgentConfigDetectParams;
|
||||||
@@ -14,9 +16,9 @@ use ratatui::prelude::Stylize as _;
|
|||||||
use ratatui::text::Line;
|
use ratatui::text::Line;
|
||||||
|
|
||||||
pub(crate) const EXTERNAL_AGENT_CONFIG_MIGRATION_NO_ITEMS_MESSAGE: &str =
|
pub(crate) const EXTERNAL_AGENT_CONFIG_MIGRATION_NO_ITEMS_MESSAGE: &str =
|
||||||
"No Claude Code setup was found to import.";
|
"No compatible setup was found to import.";
|
||||||
pub(crate) const EXTERNAL_AGENT_CONFIG_MIGRATION_REMOTE_UNAVAILABLE_MESSAGE: &str = "Import from Claude Code is unavailable in remote sessions. Start Codex locally and run /import.";
|
pub(crate) const EXTERNAL_AGENT_CONFIG_MIGRATION_REMOTE_UNAVAILABLE_MESSAGE: &str = "Import from other apps is unavailable in remote sessions. Start Codex locally and run /import.";
|
||||||
pub(crate) const EXTERNAL_AGENT_CONFIG_MIGRATION_DAEMON_UNAVAILABLE_MESSAGE: &str = "Import from Claude Code is unavailable while Codex is connected to the local app-server daemon. Stop the daemon, restart Codex, and run /import.";
|
pub(crate) const EXTERNAL_AGENT_CONFIG_MIGRATION_DAEMON_UNAVAILABLE_MESSAGE: &str = "Import from other apps is unavailable while Codex is connected to the local app-server daemon. Stop the daemon, restart Codex, and run /import.";
|
||||||
|
|
||||||
pub(crate) enum ExternalAgentConfigMigrationFlowOutcome {
|
pub(crate) enum ExternalAgentConfigMigrationFlowOutcome {
|
||||||
Started(Vec<Line<'static>>),
|
Started(Vec<Line<'static>>),
|
||||||
@@ -24,6 +26,57 @@ pub(crate) enum ExternalAgentConfigMigrationFlowOutcome {
|
|||||||
Cancelled,
|
Cancelled,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct DetectedExternalAgentConfigSource {
|
||||||
|
source: ExternalAgentConfigMigrationSource,
|
||||||
|
items: Vec<ExternalAgentConfigMigrationItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct ExternalAgentConfigDetection {
|
||||||
|
sources: Vec<DetectedExternalAgentConfigSource>,
|
||||||
|
errors: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ExternalAgentConfigDetectionOutcome {
|
||||||
|
Sources(Vec<DetectedExternalAgentConfigSource>),
|
||||||
|
NoItems,
|
||||||
|
Failed(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExternalAgentConfigDetection {
|
||||||
|
fn record_items(
|
||||||
|
&mut self,
|
||||||
|
source: ExternalAgentConfigMigrationSource,
|
||||||
|
items: Vec<ExternalAgentConfigMigrationItem>,
|
||||||
|
) {
|
||||||
|
if !items.is_empty() {
|
||||||
|
self.sources
|
||||||
|
.push(DetectedExternalAgentConfigSource { source, items });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_error(
|
||||||
|
&mut self,
|
||||||
|
source: ExternalAgentConfigMigrationSource,
|
||||||
|
error: impl std::fmt::Display,
|
||||||
|
) {
|
||||||
|
self.errors.push(format!("{}: {error}", source.label()));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish(self) -> ExternalAgentConfigDetectionOutcome {
|
||||||
|
if !self.sources.is_empty() {
|
||||||
|
ExternalAgentConfigDetectionOutcome::Sources(self.sources)
|
||||||
|
} else if self.errors.is_empty() {
|
||||||
|
ExternalAgentConfigDetectionOutcome::NoItems
|
||||||
|
} else {
|
||||||
|
ExternalAgentConfigDetectionOutcome::Failed(format!(
|
||||||
|
"Could not check for importable setup: {}",
|
||||||
|
self.errors.join("; ")
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn external_agent_config_migration_started_lines(
|
fn external_agent_config_migration_started_lines(
|
||||||
selected_items: &[ExternalAgentConfigMigrationItem],
|
selected_items: &[ExternalAgentConfigMigrationItem],
|
||||||
remaining_item_count: usize,
|
remaining_item_count: usize,
|
||||||
@@ -89,7 +142,7 @@ fn external_agent_config_migration_started_lines(
|
|||||||
let mut lines = vec![
|
let mut lines = vec![
|
||||||
vec![
|
vec![
|
||||||
"• ".dim(),
|
"• ".dim(),
|
||||||
"Claude Code import started.".cyan(),
|
"Import started.".cyan(),
|
||||||
" You can keep working while it finishes.".into(),
|
" You can keep working while it finishes.".into(),
|
||||||
]
|
]
|
||||||
.into(),
|
.into(),
|
||||||
@@ -145,7 +198,7 @@ pub(crate) fn external_agent_config_migration_finished_lines(
|
|||||||
let mut lines = vec![
|
let mut lines = vec![
|
||||||
vec![
|
vec![
|
||||||
"• ".dim(),
|
"• ".dim(),
|
||||||
"Claude Code import finished: ".into(),
|
"Import finished: ".into(),
|
||||||
format!("{imported_count} imported").green(),
|
format!("{imported_count} imported").green(),
|
||||||
", ".into(),
|
", ".into(),
|
||||||
failed_count,
|
failed_count,
|
||||||
@@ -212,28 +265,59 @@ pub(crate) async fn handle_external_agent_config_migration_prompt(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let cwd = config.cwd.to_path_buf();
|
let cwd = config.cwd.to_path_buf();
|
||||||
let detected_items = match app_server
|
let mut detection = ExternalAgentConfigDetection::default();
|
||||||
.external_agent_config_detect(ExternalAgentConfigDetectParams {
|
for source in ExternalAgentConfigMigrationSource::ALL {
|
||||||
include_home: true,
|
let response = match app_server
|
||||||
cwds: Some(vec![cwd.clone()]),
|
.external_agent_config_detect(ExternalAgentConfigDetectParams {
|
||||||
source: Some("claude-code".to_string()),
|
include_home: true,
|
||||||
})
|
cwds: Some(vec![cwd.clone()]),
|
||||||
.await
|
source: None,
|
||||||
{
|
migration_source: Some(source.migration_source().to_string()),
|
||||||
Ok(response) => response.items,
|
})
|
||||||
Err(err) => {
|
.await
|
||||||
tracing::warn!(
|
{
|
||||||
error = %err,
|
Ok(response) => response,
|
||||||
cwd = %cwd.display(),
|
Err(err) => {
|
||||||
"failed to detect external agent config migrations"
|
tracing::warn!(
|
||||||
);
|
error = %err,
|
||||||
return Err(format!("Could not check for Claude Code setup: {err}"));
|
cwd = %cwd.display(),
|
||||||
|
source = source.label(),
|
||||||
|
"failed to detect external agent config migrations"
|
||||||
|
);
|
||||||
|
detection.record_error(source, err);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
detection.record_items(source, response.items);
|
||||||
|
}
|
||||||
|
|
||||||
|
let detected_sources = match detection.finish() {
|
||||||
|
ExternalAgentConfigDetectionOutcome::Sources(sources) => sources,
|
||||||
|
ExternalAgentConfigDetectionOutcome::NoItems => {
|
||||||
|
return Ok(ExternalAgentConfigMigrationFlowOutcome::NoItems);
|
||||||
}
|
}
|
||||||
|
ExternalAgentConfigDetectionOutcome::Failed(error) => return Err(error),
|
||||||
};
|
};
|
||||||
|
|
||||||
if detected_items.is_empty() {
|
let selected_source = if detected_sources.len() == 1 {
|
||||||
return Ok(ExternalAgentConfigMigrationFlowOutcome::NoItems);
|
detected_sources[0].source
|
||||||
}
|
} else {
|
||||||
|
let sources = detected_sources
|
||||||
|
.iter()
|
||||||
|
.map(|detected| detected.source)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let Some(source) = run_external_agent_config_source_prompt(tui, &sources).await else {
|
||||||
|
return Ok(ExternalAgentConfigMigrationFlowOutcome::Cancelled);
|
||||||
|
};
|
||||||
|
source
|
||||||
|
};
|
||||||
|
let Some(detected_source) = detected_sources
|
||||||
|
.into_iter()
|
||||||
|
.find(|detected| detected.source == selected_source)
|
||||||
|
else {
|
||||||
|
return Err("Selected import source is no longer available.".to_string());
|
||||||
|
};
|
||||||
|
let detected_items = detected_source.items;
|
||||||
|
|
||||||
let mut selected_items = detected_items.clone();
|
let mut selected_items = detected_items.clone();
|
||||||
let mut error: Option<String> = None;
|
let mut error: Option<String> = None;
|
||||||
@@ -249,7 +333,13 @@ pub(crate) async fn handle_external_agent_config_migration_prompt(
|
|||||||
{
|
{
|
||||||
ExternalAgentConfigMigrationOutcome::Proceed(items) => {
|
ExternalAgentConfigMigrationOutcome::Proceed(items) => {
|
||||||
selected_items = items.clone();
|
selected_items = items.clone();
|
||||||
match app_server.external_agent_config_import(items).await {
|
match app_server
|
||||||
|
.external_agent_config_import(
|
||||||
|
items,
|
||||||
|
selected_source.migration_source().to_string(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
let remaining_item_count =
|
let remaining_item_count =
|
||||||
detected_items.len().saturating_sub(selected_items.len());
|
detected_items.len().saturating_sub(selected_items.len());
|
||||||
|
|||||||
@@ -12,6 +12,15 @@ use pretty_assertions::assert_eq;
|
|||||||
use ratatui::text::Line;
|
use ratatui::text::Line;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
fn detected_item(description: &str) -> ExternalAgentConfigMigrationItem {
|
||||||
|
ExternalAgentConfigMigrationItem {
|
||||||
|
item_type: ExternalAgentConfigMigrationItemType::Config,
|
||||||
|
description: description.to_string(),
|
||||||
|
cwd: None,
|
||||||
|
details: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn selected_items() -> Vec<ExternalAgentConfigMigrationItem> {
|
fn selected_items() -> Vec<ExternalAgentConfigMigrationItem> {
|
||||||
vec![
|
vec![
|
||||||
ExternalAgentConfigMigrationItem {
|
ExternalAgentConfigMigrationItem {
|
||||||
@@ -174,7 +183,7 @@ fn external_agent_config_migration_status_lines_use_semantic_colors() {
|
|||||||
vec![
|
vec![
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
"• ".dim(),
|
"• ".dim(),
|
||||||
"Claude Code import started.".cyan(),
|
"Import started.".cyan(),
|
||||||
" You can keep working while it finishes.".into(),
|
" You can keep working while it finishes.".into(),
|
||||||
]),
|
]),
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
@@ -228,7 +237,7 @@ fn external_agent_config_migration_status_lines_use_semantic_colors() {
|
|||||||
vec![
|
vec![
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
"• ".dim(),
|
"• ".dim(),
|
||||||
"Claude Code import finished: ".into(),
|
"Import finished: ".into(),
|
||||||
"2 imported".green(),
|
"2 imported".green(),
|
||||||
", ".into(),
|
", ".into(),
|
||||||
"1 failed".red(),
|
"1 failed".red(),
|
||||||
@@ -258,3 +267,70 @@ fn external_agent_config_migration_status_lines_use_semantic_colors() {
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn external_agent_config_detection_keeps_one_or_multiple_available_sources() {
|
||||||
|
let mut detection = ExternalAgentConfigDetection::default();
|
||||||
|
detection.record_items(
|
||||||
|
ExternalAgentConfigMigrationSource::Cla,
|
||||||
|
vec![detected_item("first")],
|
||||||
|
);
|
||||||
|
detection.record_items(ExternalAgentConfigMigrationSource::Cur, Vec::new());
|
||||||
|
let ExternalAgentConfigDetectionOutcome::Sources(sources) = detection.finish() else {
|
||||||
|
panic!("expected one detected source");
|
||||||
|
};
|
||||||
|
assert_eq!(sources.len(), 1);
|
||||||
|
assert_eq!(sources[0].source, ExternalAgentConfigMigrationSource::Cla);
|
||||||
|
assert_eq!(sources[0].items, vec![detected_item("first")]);
|
||||||
|
|
||||||
|
let mut detection = ExternalAgentConfigDetection::default();
|
||||||
|
detection.record_items(
|
||||||
|
ExternalAgentConfigMigrationSource::Cla,
|
||||||
|
vec![detected_item("first")],
|
||||||
|
);
|
||||||
|
detection.record_items(
|
||||||
|
ExternalAgentConfigMigrationSource::Cur,
|
||||||
|
vec![detected_item("second")],
|
||||||
|
);
|
||||||
|
let ExternalAgentConfigDetectionOutcome::Sources(sources) = detection.finish() else {
|
||||||
|
panic!("expected two detected sources");
|
||||||
|
};
|
||||||
|
assert_eq!(sources.len(), 2);
|
||||||
|
assert_eq!(sources[0].source, ExternalAgentConfigMigrationSource::Cla);
|
||||||
|
assert_eq!(sources[1].source, ExternalAgentConfigMigrationSource::Cur);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn external_agent_config_detection_tolerates_a_partial_failure() {
|
||||||
|
let mut detection = ExternalAgentConfigDetection::default();
|
||||||
|
detection.record_error(ExternalAgentConfigMigrationSource::Cla, "unavailable");
|
||||||
|
detection.record_items(
|
||||||
|
ExternalAgentConfigMigrationSource::Cur,
|
||||||
|
vec![detected_item("available")],
|
||||||
|
);
|
||||||
|
|
||||||
|
let ExternalAgentConfigDetectionOutcome::Sources(sources) = detection.finish() else {
|
||||||
|
panic!("expected the available source");
|
||||||
|
};
|
||||||
|
assert_eq!(sources.len(), 1);
|
||||||
|
assert_eq!(sources[0].source, ExternalAgentConfigMigrationSource::Cur);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn external_agent_config_detection_distinguishes_no_items_from_total_failure() {
|
||||||
|
assert!(matches!(
|
||||||
|
ExternalAgentConfigDetection::default().finish(),
|
||||||
|
ExternalAgentConfigDetectionOutcome::NoItems
|
||||||
|
));
|
||||||
|
|
||||||
|
let mut detection = ExternalAgentConfigDetection::default();
|
||||||
|
detection.record_error(ExternalAgentConfigMigrationSource::Cla, "first failure");
|
||||||
|
detection.record_error(ExternalAgentConfigMigrationSource::Cur, "second failure");
|
||||||
|
let ExternalAgentConfigDetectionOutcome::Failed(error) = detection.finish() else {
|
||||||
|
panic!("expected detection failure");
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
error,
|
||||||
|
"Could not check for importable setup: Claude Code: first failure; Cursor: second failure"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -79,8 +79,8 @@ pub(crate) fn external_agent_config_migration_item_label(
|
|||||||
item: &ExternalAgentConfigMigrationItem,
|
item: &ExternalAgentConfigMigrationItem,
|
||||||
) -> &'static str {
|
) -> &'static str {
|
||||||
match item.item_type {
|
match item.item_type {
|
||||||
ExternalAgentConfigMigrationItemType::AgentsMd => "Instructions (CLAUDE.md -> AGENTS.md)",
|
ExternalAgentConfigMigrationItemType::AgentsMd => "Instructions",
|
||||||
ExternalAgentConfigMigrationItemType::Config => "Settings (settings.json -> config.toml)",
|
ExternalAgentConfigMigrationItemType::Config => "Settings",
|
||||||
ExternalAgentConfigMigrationItemType::Skills => "Skills",
|
ExternalAgentConfigMigrationItemType::Skills => "Skills",
|
||||||
ExternalAgentConfigMigrationItemType::Plugins => "Plugins",
|
ExternalAgentConfigMigrationItemType::Plugins => "Plugins",
|
||||||
ExternalAgentConfigMigrationItemType::McpServerConfig => "MCP servers",
|
ExternalAgentConfigMigrationItemType::McpServerConfig => "MCP servers",
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
use crate::key_hint;
|
||||||
|
use crate::render::Insets;
|
||||||
|
use crate::render::renderable::ColumnRenderable;
|
||||||
|
use crate::render::renderable::Renderable;
|
||||||
|
use crate::render::renderable::RenderableExt as _;
|
||||||
|
use crate::tui::FrameRequester;
|
||||||
|
use crate::tui::Tui;
|
||||||
|
use crate::tui::TuiEvent;
|
||||||
|
use crossterm::event::KeyCode;
|
||||||
|
use crossterm::event::KeyEvent;
|
||||||
|
use crossterm::event::KeyEventKind;
|
||||||
|
use crossterm::event::KeyModifiers;
|
||||||
|
use ratatui::buffer::Buffer;
|
||||||
|
use ratatui::layout::Rect;
|
||||||
|
use ratatui::prelude::Stylize as _;
|
||||||
|
use ratatui::prelude::Widget;
|
||||||
|
use ratatui::text::Line;
|
||||||
|
use ratatui::text::Span;
|
||||||
|
use ratatui::widgets::Clear;
|
||||||
|
use ratatui::widgets::WidgetRef;
|
||||||
|
use tokio_stream::StreamExt;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub(crate) enum ExternalAgentConfigMigrationSource {
|
||||||
|
Cla,
|
||||||
|
Cur,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExternalAgentConfigMigrationSource {
|
||||||
|
pub(crate) const ALL: [Self; 2] = [Self::Cla, Self::Cur];
|
||||||
|
|
||||||
|
pub(crate) fn migration_source(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Cla => "claude-code",
|
||||||
|
Self::Cur => "cursor",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Cla => "Claude Code",
|
||||||
|
Self::Cur => "Cursor",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn run_external_agent_config_source_prompt(
|
||||||
|
tui: &mut Tui,
|
||||||
|
sources: &[ExternalAgentConfigMigrationSource],
|
||||||
|
) -> Option<ExternalAgentConfigMigrationSource> {
|
||||||
|
let mut screen = ExternalAgentConfigSourceScreen::new(tui.frame_requester(), sources);
|
||||||
|
let _ = tui.draw(u16::MAX, |frame| {
|
||||||
|
frame.render_widget_ref(&screen, frame.area());
|
||||||
|
});
|
||||||
|
|
||||||
|
let events = tui.event_stream();
|
||||||
|
tokio::pin!(events);
|
||||||
|
|
||||||
|
while !screen.is_done() {
|
||||||
|
if let Some(event) = events.next().await {
|
||||||
|
match event {
|
||||||
|
TuiEvent::Key(key_event) => screen.handle_key(key_event),
|
||||||
|
TuiEvent::Paste(_) => {}
|
||||||
|
TuiEvent::Draw | TuiEvent::Resize => {
|
||||||
|
let _ = tui.draw(u16::MAX, |frame| {
|
||||||
|
frame.render_widget_ref(&screen, frame.area());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
screen.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
screen.selection()
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ExternalAgentConfigSourceScreen {
|
||||||
|
request_frame: FrameRequester,
|
||||||
|
sources: Vec<ExternalAgentConfigMigrationSource>,
|
||||||
|
highlighted_idx: usize,
|
||||||
|
selection: Option<ExternalAgentConfigMigrationSource>,
|
||||||
|
done: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExternalAgentConfigSourceScreen {
|
||||||
|
fn new(request_frame: FrameRequester, sources: &[ExternalAgentConfigMigrationSource]) -> Self {
|
||||||
|
Self {
|
||||||
|
request_frame,
|
||||||
|
sources: sources.to_vec(),
|
||||||
|
highlighted_idx: 0,
|
||||||
|
selection: None,
|
||||||
|
done: sources.is_empty(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_key(&mut self, key_event: KeyEvent) {
|
||||||
|
if key_event.kind == KeyEventKind::Release || self.sources.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if key_event.modifiers.contains(KeyModifiers::CONTROL)
|
||||||
|
&& matches!(key_event.code, KeyCode::Char('c') | KeyCode::Char('d'))
|
||||||
|
{
|
||||||
|
self.cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match key_event.code {
|
||||||
|
KeyCode::Up | KeyCode::Char('k') => self.move_highlight(/*delta*/ -1),
|
||||||
|
KeyCode::Down | KeyCode::Char('j') => self.move_highlight(/*delta*/ 1),
|
||||||
|
KeyCode::Char(character) if character.is_ascii_digit() => {
|
||||||
|
let Some(index) = character
|
||||||
|
.to_digit(10)
|
||||||
|
.and_then(|index| index.checked_sub(1))
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
self.select(index as usize);
|
||||||
|
}
|
||||||
|
KeyCode::Enter => self.select(self.highlighted_idx),
|
||||||
|
KeyCode::Esc => self.cancel(),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_highlight(&mut self, delta: isize) {
|
||||||
|
self.highlighted_idx = self
|
||||||
|
.highlighted_idx
|
||||||
|
.saturating_add_signed(delta)
|
||||||
|
.min(self.sources.len().saturating_sub(1));
|
||||||
|
self.request_frame.schedule_frame();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select(&mut self, index: usize) {
|
||||||
|
let Some(source) = self.sources.get(index).copied() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
self.selection = Some(source);
|
||||||
|
self.done = true;
|
||||||
|
self.request_frame.schedule_frame();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cancel(&mut self) {
|
||||||
|
self.selection = None;
|
||||||
|
self.done = true;
|
||||||
|
self.request_frame.schedule_frame();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_done(&self) -> bool {
|
||||||
|
self.done
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selection(&self) -> Option<ExternalAgentConfigMigrationSource> {
|
||||||
|
self.selection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WidgetRef for &ExternalAgentConfigSourceScreen {
|
||||||
|
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||||
|
Clear.render(area, buf);
|
||||||
|
let mut column = ColumnRenderable::new();
|
||||||
|
column.push("");
|
||||||
|
column.push("Choose an import source".bold());
|
||||||
|
column.push("");
|
||||||
|
column.push(
|
||||||
|
Line::from("Select the app whose setup you want to import.".dim()).inset(Insets::tlbr(
|
||||||
|
/*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 0,
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
column.push("");
|
||||||
|
for (index, source) in self.sources.iter().enumerate() {
|
||||||
|
let prefix = if index == self.highlighted_idx {
|
||||||
|
"›"
|
||||||
|
} else {
|
||||||
|
" "
|
||||||
|
};
|
||||||
|
let label = Span::from(format!("{prefix} {}. {}", index + 1, source.label()));
|
||||||
|
column.push(Line::from(if index == self.highlighted_idx {
|
||||||
|
label.cyan()
|
||||||
|
} else {
|
||||||
|
label
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
column.push("");
|
||||||
|
column.push(
|
||||||
|
Line::from(vec![
|
||||||
|
"Press ".dim(),
|
||||||
|
key_hint::plain(KeyCode::Enter).into(),
|
||||||
|
" to continue".dim(),
|
||||||
|
])
|
||||||
|
.inset(Insets::tlbr(
|
||||||
|
/*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 0,
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
column.render(area, buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "external_agent_config_migration_source_tests.rs"]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
use super::*;
|
||||||
|
use crate::test_backend::VT100Backend;
|
||||||
|
use crossterm::event::KeyEvent;
|
||||||
|
use crossterm::event::KeyModifiers;
|
||||||
|
use pretty_assertions::assert_eq;
|
||||||
|
use ratatui::Terminal;
|
||||||
|
|
||||||
|
fn new_screen() -> ExternalAgentConfigSourceScreen {
|
||||||
|
ExternalAgentConfigSourceScreen::new(
|
||||||
|
FrameRequester::test_dummy(),
|
||||||
|
&ExternalAgentConfigMigrationSource::ALL,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn external_agent_config_source_prompt_snapshot() {
|
||||||
|
let screen = new_screen();
|
||||||
|
let mut terminal =
|
||||||
|
Terminal::new(VT100Backend::new(/*width*/ 80, /*height*/ 12)).expect("terminal");
|
||||||
|
terminal
|
||||||
|
.draw(|frame| frame.render_widget_ref(&screen, frame.area()))
|
||||||
|
.expect("render source prompt");
|
||||||
|
insta::assert_snapshot!("external_agent_config_source_prompt", terminal.backend());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn external_agent_config_source_prompt_selects_highlighted_source() {
|
||||||
|
let mut screen = new_screen();
|
||||||
|
screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||||
|
screen.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||||
|
assert_eq!(
|
||||||
|
screen.selection(),
|
||||||
|
Some(ExternalAgentConfigMigrationSource::Cur)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn external_agent_config_source_prompt_can_cancel() {
|
||||||
|
let mut screen = new_screen();
|
||||||
|
screen.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||||
|
assert_eq!(screen.selection(), None);
|
||||||
|
}
|
||||||
@@ -116,6 +116,7 @@ mod exec_command;
|
|||||||
mod external_agent_config_migration;
|
mod external_agent_config_migration;
|
||||||
mod external_agent_config_migration_flow;
|
mod external_agent_config_migration_flow;
|
||||||
mod external_agent_config_migration_model;
|
mod external_agent_config_migration_model;
|
||||||
|
mod external_agent_config_migration_source;
|
||||||
mod external_editor;
|
mod external_editor;
|
||||||
mod file_search;
|
mod file_search;
|
||||||
mod frames;
|
mod frames;
|
||||||
|
|||||||
+4
-4
@@ -4,11 +4,11 @@ expression: rendered
|
|||||||
---
|
---
|
||||||
|
|
||||||
> Choose what to import
|
> Choose what to import
|
||||||
Choose the Claude Code items to import.
|
Choose items to import.
|
||||||
Codex may add files to your current project folder.
|
Codex may add files to your current project folder.
|
||||||
Your existing Claude Code setup will not be changed.
|
Your existing setup will not be changed.
|
||||||
Home
|
Home
|
||||||
› [x] Settings (settings.json -> config.toml)
|
› [x] Settings
|
||||||
Import /Users/alex/.claude/settings.json into /Users/alex/.codex/conf…
|
Import /Users/alex/.claude/settings.json into /Users/alex/.codex/conf…
|
||||||
[x] Recent chat sessions
|
[x] Recent chat sessions
|
||||||
Import recent Claude Code sessions
|
Import recent Claude Code sessions
|
||||||
@@ -21,7 +21,7 @@ expression: rendered
|
|||||||
• team-marketplace: asana
|
• team-marketplace: asana
|
||||||
• debug: sample
|
• debug: sample
|
||||||
• +1 more marketplaces
|
• +1 more marketplaces
|
||||||
[x] Instructions (CLAUDE.md -> AGENTS.md)
|
[x] Instructions
|
||||||
Import CLAUDE.md to AGENTS.md
|
Import CLAUDE.md to AGENTS.md
|
||||||
|
|
||||||
Selected 4 of 4 items.
|
Selected 4 of 4 items.
|
||||||
|
|||||||
+4
-4
@@ -4,11 +4,11 @@ expression: rendered
|
|||||||
---
|
---
|
||||||
|
|
||||||
> Choose what to import
|
> Choose what to import
|
||||||
Choose the Claude Code items to import.
|
Choose items to import.
|
||||||
Codex may add files to your current project folder.
|
Codex may add files to your current project folder.
|
||||||
Your existing Claude Code setup will not be changed.
|
Your existing setup will not be changed.
|
||||||
Home
|
Home
|
||||||
[x] Settings (settings.json -> config.toml)
|
[x] Settings
|
||||||
Import /Users/alex/.claude/settings.json into /Users/alex/.codex/conf…
|
Import /Users/alex/.claude/settings.json into /Users/alex/.codex/conf…
|
||||||
[x] Recent chat sessions
|
[x] Recent chat sessions
|
||||||
Import recent Claude Code sessions
|
Import recent Claude Code sessions
|
||||||
@@ -21,7 +21,7 @@ expression: rendered
|
|||||||
• team-marketplace: asana
|
• team-marketplace: asana
|
||||||
• debug: sample
|
• debug: sample
|
||||||
• +1 more marketplaces
|
• +1 more marketplaces
|
||||||
[x] Instructions (CLAUDE.md -> AGENTS.md)
|
[x] Instructions
|
||||||
Import CLAUDE.md to AGENTS.md
|
Import CLAUDE.md to AGENTS.md
|
||||||
|
|
||||||
Selected 4 of 4 items.
|
Selected 4 of 4 items.
|
||||||
|
|||||||
+4
-4
@@ -4,11 +4,11 @@ expression: rendered
|
|||||||
---
|
---
|
||||||
|
|
||||||
> Choose what to import
|
> Choose what to import
|
||||||
Choose the Claude Code items to import.
|
Choose items to import.
|
||||||
Codex may add files to your current project folder.
|
Codex may add files to your current project folder.
|
||||||
Your existing Claude Code setup will not be changed.
|
Your existing setup will not be changed.
|
||||||
Home
|
Home
|
||||||
[x] Settings (settings.json -> config.toml)
|
[x] Settings
|
||||||
Import /Users/alex/.claude/settings.json into /Users/alex/.codex/conf…
|
Import /Users/alex/.claude/settings.json into /Users/alex/.codex/conf…
|
||||||
[x] Recent chat sessions
|
[x] Recent chat sessions
|
||||||
Import recent Claude Code sessions
|
Import recent Claude Code sessions
|
||||||
@@ -21,7 +21,7 @@ expression: rendered
|
|||||||
• team-marketplace: asana
|
• team-marketplace: asana
|
||||||
• debug: sample
|
• debug: sample
|
||||||
• +1 more marketplaces
|
• +1 more marketplaces
|
||||||
[x] Instructions (CLAUDE.md -> AGENTS.md)
|
[x] Instructions
|
||||||
Import CLAUDE.md to AGENTS.md
|
Import CLAUDE.md to AGENTS.md
|
||||||
|
|
||||||
Selected 4 of 4 items.
|
Selected 4 of 4 items.
|
||||||
|
|||||||
+4
-4
@@ -4,11 +4,11 @@ expression: rendered
|
|||||||
---
|
---
|
||||||
|
|
||||||
> Choose what to import
|
> Choose what to import
|
||||||
Choose the Claude Code items to import.
|
Choose items to import.
|
||||||
Codex may add files to your current project folder.
|
Codex may add files to your current project folder.
|
||||||
Your existing Claude Code setup will not be changed.
|
Your existing setup will not be changed.
|
||||||
Home
|
Home
|
||||||
› [x] Settings (settings.json -> config.toml)
|
› [x] Settings
|
||||||
Import /Users/alex/.claude/settings.json into /Users/alex/.codex/conf…
|
Import /Users/alex/.claude/settings.json into /Users/alex/.codex/conf…
|
||||||
[x] Recent chat sessions
|
[x] Recent chat sessions
|
||||||
Import recent Claude Code sessions
|
Import recent Claude Code sessions
|
||||||
@@ -21,7 +21,7 @@ expression: rendered
|
|||||||
• team-marketplace: asana
|
• team-marketplace: asana
|
||||||
• debug: sample
|
• debug: sample
|
||||||
• +1 more marketplaces
|
• +1 more marketplaces
|
||||||
[x] Instructions (CLAUDE.md -> AGENTS.md)
|
[x] Instructions
|
||||||
Import CLAUDE.md to AGENTS.md
|
Import CLAUDE.md to AGENTS.md
|
||||||
|
|
||||||
Selected 4 of 4 items.
|
Selected 4 of 4 items.
|
||||||
|
|||||||
+3
-4
@@ -3,11 +3,10 @@ source: tui/src/external_agent_config_migration.rs
|
|||||||
expression: rendered
|
expression: rendered
|
||||||
---
|
---
|
||||||
|
|
||||||
> Import from Claude Code
|
> Import setup
|
||||||
Bring over your setup, current project, and recent chats.
|
Bring over supported setup from another coding agent.
|
||||||
Codex may add files to your current project folder.
|
Codex may add files to your current project folder.
|
||||||
Your existing Claude Code setup will not be changed.
|
Your existing setup will not be changed.
|
||||||
Standard Claude Chat data cannot be imported.
|
|
||||||
[x] Tools & setup
|
[x] Tools & setup
|
||||||
Settings, instructions, integrations, agents, commands, and skills
|
Settings, instructions, integrations, agents, commands, and skills
|
||||||
Importing: Settings 1
|
Importing: Settings 1
|
||||||
|
|||||||
+3
-4
@@ -3,11 +3,10 @@ source: tui/src/external_agent_config_migration.rs
|
|||||||
expression: rendered
|
expression: rendered
|
||||||
---
|
---
|
||||||
|
|
||||||
> Import from Claude Code
|
> Import setup
|
||||||
Bring over your setup, current project, and recent chats.
|
Bring over supported setup from another coding agent.
|
||||||
Codex may add files to your current project folder.
|
Codex may add files to your current project folder.
|
||||||
Your existing Claude Code setup will not be changed.
|
Your existing setup will not be changed.
|
||||||
Standard Claude Chat data cannot be imported.
|
|
||||||
[x] Tools & setup
|
[x] Tools & setup
|
||||||
Settings, instructions, integrations, agents, commands, and skills
|
Settings, instructions, integrations, agents, commands, and skills
|
||||||
Importing: Settings 1
|
Importing: Settings 1
|
||||||
|
|||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
source: tui/src/external_agent_config_migration.rs
|
||||||
|
expression: rendered
|
||||||
|
---
|
||||||
|
|
||||||
|
> Choose what to import
|
||||||
|
Choose items to import.
|
||||||
|
Codex may add files to your current project folder.
|
||||||
|
Your existing setup will not be changed.
|
||||||
|
Home
|
||||||
|
› [x] Settings
|
||||||
|
Import /Users/alex/.cursor/cli-config.json into /Users/alex/.codex/co…
|
||||||
|
|
||||||
|
Selected 1 of 1 item.
|
||||||
|
1. Review selection
|
||||||
|
Use ↑/↓ to move, space to toggle, b to go back
|
||||||
+8
-8
@@ -2,7 +2,7 @@
|
|||||||
source: tui/src/external_agent_config_migration_flow_tests.rs
|
source: tui/src/external_agent_config_migration_flow_tests.rs
|
||||||
expression: messages
|
expression: messages
|
||||||
---
|
---
|
||||||
• Claude Code import started. You can keep working while it finishes.
|
• Import started. You can keep working while it finishes.
|
||||||
Imported setup will apply to new chats.
|
Imported setup will apply to new chats.
|
||||||
Importing:
|
Importing:
|
||||||
Settings: 1
|
Settings: 1
|
||||||
@@ -10,7 +10,7 @@ expression: messages
|
|||||||
MCP servers: 2 — docs, issues
|
MCP servers: 2 — docs, issues
|
||||||
Chat sessions: 3 — Alpha rollout, Beta review, Gamma notes
|
Chat sessions: 3 — Alpha rollout, Beta review, Gamma notes
|
||||||
Plugins: 2 — formatter, reviewer
|
Plugins: 2 — formatter, reviewer
|
||||||
• Claude Code import started. You can keep working while it finishes.
|
• Import started. You can keep working while it finishes.
|
||||||
Imported setup will apply to new chats.
|
Imported setup will apply to new chats.
|
||||||
Importing:
|
Importing:
|
||||||
Settings: 1
|
Settings: 1
|
||||||
@@ -19,7 +19,7 @@ expression: messages
|
|||||||
Chat sessions: 3 — Alpha rollout, Beta review, Gamma notes
|
Chat sessions: 3 — Alpha rollout, Beta review, Gamma notes
|
||||||
Plugins: 2 — formatter, reviewer
|
Plugins: 2 — formatter, reviewer
|
||||||
1 additional item remains. After it finishes, run /import again to review it.
|
1 additional item remains. After it finishes, run /import again to review it.
|
||||||
• Claude Code import started. You can keep working while it finishes.
|
• Import started. You can keep working while it finishes.
|
||||||
Imported setup will apply to new chats.
|
Imported setup will apply to new chats.
|
||||||
Importing:
|
Importing:
|
||||||
Settings: 1
|
Settings: 1
|
||||||
@@ -28,12 +28,12 @@ expression: messages
|
|||||||
Chat sessions: 3 — Alpha rollout, Beta review, Gamma notes
|
Chat sessions: 3 — Alpha rollout, Beta review, Gamma notes
|
||||||
Plugins: 2 — formatter, reviewer
|
Plugins: 2 — formatter, reviewer
|
||||||
2 additional items remain. After it finishes, run /import again to review them.
|
2 additional items remain. After it finishes, run /import again to review them.
|
||||||
• Claude Code import finished: 2 imported, 1 failed.
|
• Import finished: 2 imported, 1 failed.
|
||||||
Results by type:
|
Results by type:
|
||||||
Settings: 1 imported, 0 failed
|
Settings: 1 imported, 0 failed
|
||||||
Plugins: 1 imported, 1 failed
|
Plugins: 1 imported, 1 failed
|
||||||
Run /import again to check for additional items.
|
Run /import again to check for additional items.
|
||||||
No Claude Code setup was found to import.
|
No compatible setup was found to import.
|
||||||
Import from Claude Code is unavailable in remote sessions. Start Codex locally and run /import.
|
Import from other apps is unavailable in remote sessions. Start Codex locally and run /import.
|
||||||
Import from Claude Code is unavailable while Codex is connected to the local app-server daemon. Stop the daemon, restart Codex, and run /import.
|
Import from other apps is unavailable while Codex is connected to the local app-server daemon. Stop the daemon, restart Codex, and run /import.
|
||||||
A previous Claude Code import is still running. Wait for it to finish before importing again.
|
A previous external agent import is still running. Wait for it to finish before importing again.
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
---
|
||||||
|
source: tui/src/external_agent_config_migration_source_tests.rs
|
||||||
|
expression: terminal.backend()
|
||||||
|
---
|
||||||
|
|
||||||
|
Choose an import source
|
||||||
|
|
||||||
|
Select the app whose setup you want to import.
|
||||||
|
|
||||||
|
› 1. Claude Code
|
||||||
|
2. Cursor
|
||||||
|
|
||||||
|
Press enter to continue
|
||||||
@@ -7,8 +7,11 @@ use std::path::Path;
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
/// Ordered plugin manifest paths recognized beneath a plugin root.
|
/// Ordered plugin manifest paths recognized beneath a plugin root.
|
||||||
pub const DISCOVERABLE_PLUGIN_MANIFEST_PATHS: &[&str] =
|
pub const DISCOVERABLE_PLUGIN_MANIFEST_PATHS: &[&str] = &[
|
||||||
&[".codex-plugin/plugin.json", ".claude-plugin/plugin.json"];
|
".codex-plugin/plugin.json",
|
||||||
|
".claude-plugin/plugin.json",
|
||||||
|
".cursor-plugin/plugin.json",
|
||||||
|
];
|
||||||
|
|
||||||
pub fn find_plugin_manifest_path(plugin_root: &Path) -> Option<PathBuf> {
|
pub fn find_plugin_manifest_path(plugin_root: &Path) -> Option<PathBuf> {
|
||||||
DISCOVERABLE_PLUGIN_MANIFEST_PATHS
|
DISCOVERABLE_PLUGIN_MANIFEST_PATHS
|
||||||
@@ -86,7 +89,8 @@ mod tests {
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
|
|
||||||
const ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH: &str = ".claude-plugin/plugin.json";
|
const ALTERNATE_PLUGIN_CLA_MANIFEST_RELATIVE_PATH: &str = ".claude-plugin/plugin.json";
|
||||||
|
const ALTERNATE_PLUGIN_CUR_MANIFEST_RELATIVE_PATH: &str = ".cursor-plugin/plugin.json";
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn uses_manifest_name() {
|
async fn uses_manifest_name() {
|
||||||
@@ -114,7 +118,27 @@ mod tests {
|
|||||||
let tmp = tempdir().expect("tempdir");
|
let tmp = tempdir().expect("tempdir");
|
||||||
let plugin_root = tmp.path().join("plugins/sample");
|
let plugin_root = tmp.path().join("plugins/sample");
|
||||||
let skill_path = plugin_root.join("skills/search/SKILL.md");
|
let skill_path = plugin_root.join("skills/search/SKILL.md");
|
||||||
let manifest_path = plugin_root.join(ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH);
|
let manifest_path = plugin_root.join(ALTERNATE_PLUGIN_CLA_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));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn uses_name_from_cur_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(ALTERNATE_PLUGIN_CUR_MANIFEST_RELATIVE_PATH);
|
||||||
|
|
||||||
fs::create_dir_all(skill_path.parent().expect("parent")).expect("mkdir");
|
fs::create_dir_all(skill_path.parent().expect("parent")).expect("mkdir");
|
||||||
fs::create_dir_all(manifest_path.parent().expect("manifest parent"))
|
fs::create_dir_all(manifest_path.parent().expect("manifest parent"))
|
||||||
|
|||||||
Reference in new issue
Block a user