diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 116a3c62dd4..6e5f4693067 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -121,6 +121,10 @@ client_request_definitions! { params: v2::ThreadCompactParams, response: v2::ThreadCompactResponse, }, + SkillsList => "skills/list" { + params: v2::SkillsListParams, + response: v2::SkillsListResponse, + }, TurnStart => "turn/start" { params: v2::TurnStartParams, response: v2::TurnStartResponse, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index a0772a17b86..23dceae13c1 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -21,6 +21,9 @@ use codex_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot; use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow; use codex_protocol::protocol::SessionSource as CoreSessionSource; +use codex_protocol::protocol::SkillErrorInfo as CoreSkillErrorInfo; +use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata; +use codex_protocol::protocol::SkillScope as CoreSkillScope; use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; use codex_protocol::user_input::UserInput as CoreUserInput; @@ -967,6 +970,87 @@ pub struct ThreadCompactParams { #[ts(export_to = "v2/")] pub struct ThreadCompactResponse {} +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillsListParams { + /// When empty, defaults to the current session working directory. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub cwds: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillsListResponse { + pub data: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub enum SkillScope { + User, + Repo, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillMetadata { + pub name: String, + pub description: String, + pub path: PathBuf, + pub scope: SkillScope, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillErrorInfo { + pub path: PathBuf, + pub message: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillsListEntry { + pub cwd: PathBuf, + pub skills: Vec, + pub errors: Vec, +} + +impl From for SkillMetadata { + fn from(value: CoreSkillMetadata) -> Self { + Self { + name: value.name, + description: value.description, + path: value.path, + scope: value.scope.into(), + } + } +} + +impl From for SkillScope { + fn from(value: CoreSkillScope) -> Self { + match value { + CoreSkillScope::User => Self::User, + CoreSkillScope::Repo => Self::Repo, + } + } +} + +impl From for SkillErrorInfo { + fn from(value: CoreSkillErrorInfo) -> Self { + Self { + path: value.path, + message: value.message, + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index e9dee71271f..80e882adfc1 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -65,6 +65,7 @@ Example (from OpenAI's official VSCode extension): - `review/start` — kick off Codex’s automated reviewer for a thread; responds like `turn/start` and emits `item/started`/`item/completed` notifications with `enteredReviewMode` and `exitedReviewMode` items, plus a final assistant `agentMessage` containing the review. - `command/exec` — run a single command under the server sandbox without starting a thread/turn (handy for utilities and validation). - `model/list` — list available models (with reasoning effort options). +- `skills/list` — list skills for one or more `cwd` values. - `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes. - `mcpServers/list` — enumerate configured MCP servers with their tools, resources, resource templates, and auth status; supports cursor+limit pagination. - `feedback/upload` — submit a feedback report (classification + optional reason/logs and conversation_id); returns the tracking thread id. diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 53fabd22e40..21acaec9771 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -81,6 +81,8 @@ use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionConfiguredNotification; use codex_app_server_protocol::SetDefaultModelParams; use codex_app_server_protocol::SetDefaultModelResponse; +use codex_app_server_protocol::SkillsListParams; +use codex_app_server_protocol::SkillsListResponse; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadArchiveParams; use codex_app_server_protocol::ThreadArchiveResponse; @@ -373,6 +375,9 @@ impl CodexMessageProcessor { self.send_unimplemented_error(request_id, "thread/compact") .await; } + ClientRequest::SkillsList { request_id, params } => { + self.skills_list(request_id, params).await; + } ClientRequest::TurnStart { request_id, params } => { self.turn_start(request_id, params).await; } @@ -2615,6 +2620,42 @@ impl CodexMessageProcessor { .await; } + async fn skills_list(&self, request_id: RequestId, params: SkillsListParams) { + let SkillsListParams { cwds } = params; + let cwds = if cwds.is_empty() { + vec![self.config.cwd.clone()] + } else { + cwds + }; + + let data = if self.config.features.enabled(Feature::Skills) { + let skills_manager = self.conversation_manager.skills_manager(); + cwds.into_iter() + .map(|cwd| { + let outcome = skills_manager.skills_for_cwd(&cwd); + let errors = errors_to_info(&outcome.errors); + let skills = skills_to_info(&outcome.skills); + codex_app_server_protocol::SkillsListEntry { + cwd, + skills, + errors, + } + }) + .collect() + } else { + cwds.into_iter() + .map(|cwd| codex_app_server_protocol::SkillsListEntry { + cwd, + skills: Vec::new(), + errors: Vec::new(), + }) + .collect() + }; + self.outgoing + .send_response(request_id, SkillsListResponse { data }) + .await; + } + async fn interrupt_conversation( &mut self, request_id: RequestId, @@ -3260,6 +3301,32 @@ impl CodexMessageProcessor { } } +fn skills_to_info( + skills: &[codex_core::skills::SkillMetadata], +) -> Vec { + skills + .iter() + .map(|skill| codex_app_server_protocol::SkillMetadata { + name: skill.name.clone(), + description: skill.description.clone(), + path: skill.path.clone(), + scope: skill.scope.into(), + }) + .collect() +} + +fn errors_to_info( + errors: &[codex_core::skills::SkillError], +) -> Vec { + errors + .iter() + .map(|err| codex_app_server_protocol::SkillErrorInfo { + path: err.path.clone(), + message: err.message.clone(), + }) + .collect() +} + async fn derive_config_from_params( overrides: ConfigOverrides, cli_overrides: Option>, diff --git a/codex-rs/core/src/auth.rs b/codex-rs/core/src/auth.rs index 20943982d4d..933c80548a2 100644 --- a/codex-rs/core/src/auth.rs +++ b/codex-rs/core/src/auth.rs @@ -64,6 +64,7 @@ const REFRESH_TOKEN_UNKNOWN_MESSAGE: &str = const REFRESH_TOKEN_URL: &str = "https://auth.openai.com/oauth/token"; pub const REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR: &str = "CODEX_REFRESH_TOKEN_URL_OVERRIDE"; +#[cfg(any(test, feature = "test-support"))] static TEST_AUTH_TEMP_DIRS: Lazy>> = Lazy::new(|| Mutex::new(Vec::new())); #[derive(Debug, Error)] @@ -1111,6 +1112,18 @@ impl AuthManager { }) } + #[cfg(any(test, feature = "test-support"))] + /// Create an AuthManager with a specific CodexAuth and codex home, for testing only. + pub fn from_auth_for_testing_with_home(auth: CodexAuth, codex_home: PathBuf) -> Arc { + let cached = CachedAuth { auth: Some(auth) }; + Arc::new(Self { + codex_home, + inner: RwLock::new(cached), + enable_codex_api_key_env: false, + auth_credentials_store_mode: AuthCredentialsStoreMode::File, + }) + } + /// Current cached auth (clone). May be `None` if not logged in or load failed. pub fn auth(&self) -> Option { self.inner.read().ok().and_then(|c| c.auth.clone()) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index b74338243a0..f90a85240a5 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -106,8 +106,7 @@ use crate::protocol::ReviewDecision; use crate::protocol::SandboxPolicy; use crate::protocol::SessionConfiguredEvent; use crate::protocol::SkillErrorInfo; -use crate::protocol::SkillInfo; -use crate::protocol::SkillLoadOutcomeInfo; +use crate::protocol::SkillMetadata as ProtocolSkillMetadata; use crate::protocol::StreamErrorEvent; use crate::protocol::Submission; use crate::protocol::TokenCountEvent; @@ -120,10 +119,11 @@ use crate::rollout::RolloutRecorderParams; use crate::rollout::map_session_init_error; use crate::shell; use crate::shell_snapshot::ShellSnapshot; +use crate::skills::SkillError; use crate::skills::SkillInjections; -use crate::skills::SkillLoadOutcome; +use crate::skills::SkillMetadata; +use crate::skills::SkillsManager; use crate::skills::build_skill_injections; -use crate::skills::load_skills; use crate::state::ActiveTurn; use crate::state::SessionServices; use crate::state::SessionState; @@ -207,6 +207,7 @@ impl Codex { config: Config, auth_manager: Arc, models_manager: Arc, + skills_manager: Arc, conversation_history: InitialHistory, session_source: SessionSource, ) -> CodexResult { @@ -214,7 +215,7 @@ impl Codex { let (tx_event, rx_event) = async_channel::unbounded(); let loaded_skills = if config.features.enabled(Feature::Skills) { - Some(load_skills(&config)) + Some(skills_manager.skills_for_cwd(&config.cwd)) } else { None }; @@ -229,11 +230,9 @@ impl Codex { } } - let skills_outcome = loaded_skills.clone(); - let user_instructions = get_user_instructions( &config, - skills_outcome + loaded_skills .as_ref() .map(|outcome| outcome.skills.as_slice()), ) @@ -279,7 +278,7 @@ impl Codex { tx_event.clone(), conversation_history, session_source_clone, - skills_outcome.clone(), + skills_manager, ) .await .map_err(|e| { @@ -546,7 +545,7 @@ impl Session { tx_event: Sender, initial_history: InitialHistory, session_source: SessionSource, - skills: Option, + skills_manager: Arc, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -666,7 +665,7 @@ impl Session { otel_manager, models_manager: Arc::clone(&models_manager), tool_approvals: Mutex::new(ApprovalStore::default()), - skills: skills.clone(), + skills_manager, }; let sess = Arc::new(Session { @@ -682,8 +681,6 @@ impl Session { // Dispatch the SessionConfiguredEvent first and then report any errors. // If resuming, include converted initial messages in the payload so UIs can render them immediately. let initial_messages = initial_history.get_event_msgs(); - let skill_load_outcome = skill_load_outcome_for_client(skills.as_ref()); - let events = std::iter::once(Event { id: INITIAL_SUBMIT_ID.to_owned(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { @@ -697,7 +694,6 @@ impl Session { history_log_id, history_entry_count, initial_messages, - skill_load_outcome, rollout_path, }), }) @@ -1585,6 +1581,9 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv Op::ListCustomPrompts => { handlers::list_custom_prompts(&sess, sub.id.clone()).await; } + Op::ListSkills { cwds } => { + handlers::list_skills(&sess, sub.id.clone(), cwds).await; + } Op::Undo => { handlers::undo(&sess, sub.id.clone()).await; } @@ -1629,6 +1628,7 @@ mod handlers { use crate::codex::spawn_review_thread; use crate::config::Config; + use crate::features::Feature; use crate::mcp::auth::compute_auth_statuses; use crate::mcp::collect_mcp_snapshot_from_manager; use crate::review_prompts::resolve_review_request; @@ -1642,9 +1642,11 @@ mod handlers { use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ListCustomPromptsResponseEvent; + use codex_protocol::protocol::ListSkillsResponseEvent; use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; + use codex_protocol::protocol::SkillsListEntry; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::WarningEvent; @@ -1652,6 +1654,7 @@ mod handlers { use codex_rmcp_client::ElicitationAction; use codex_rmcp_client::ElicitationResponse; use mcp_types::RequestId; + use std::path::PathBuf; use std::sync::Arc; use tracing::info; use tracing::warn; @@ -1879,6 +1882,43 @@ mod handlers { sess.send_event_raw(event).await; } + pub async fn list_skills(sess: &Session, sub_id: String, cwds: Vec) { + let cwds = if cwds.is_empty() { + let state = sess.state.lock().await; + vec![state.session_configuration.cwd.clone()] + } else { + cwds + }; + let skills = if sess.enabled(Feature::Skills) { + let skills_manager = &sess.services.skills_manager; + cwds.into_iter() + .map(|cwd| { + let outcome = skills_manager.skills_for_cwd(&cwd); + let errors = super::errors_to_info(&outcome.errors); + let skills = super::skills_to_info(&outcome.skills); + SkillsListEntry { + cwd, + skills, + errors, + } + }) + .collect() + } else { + cwds.into_iter() + .map(|cwd| SkillsListEntry { + cwd, + skills: Vec::new(), + errors: Vec::new(), + }) + .collect() + }; + let event = Event { + id: sub_id, + msg: EventMsg::ListSkillsResponse(ListSkillsResponseEvent { skills }), + }; + sess.send_event_raw(event).await; + } + pub async fn undo(sess: &Arc, sub_id: String) { let turn_context = sess .new_turn_with_sub_id(sub_id, SessionSettingsUpdate::default()) @@ -2061,28 +2101,26 @@ async fn spawn_review_thread( .await; } -fn skill_load_outcome_for_client( - outcome: Option<&SkillLoadOutcome>, -) -> Option { - outcome.map(|outcome| SkillLoadOutcomeInfo { - skills: outcome - .skills - .iter() - .map(|skill| SkillInfo { - name: skill.name.clone(), - description: skill.description.clone(), - path: skill.path.clone(), - }) - .collect(), - errors: outcome - .errors - .iter() - .map(|err| SkillErrorInfo { - path: err.path.clone(), - message: err.message.clone(), - }) - .collect(), - }) +fn skills_to_info(skills: &[SkillMetadata]) -> Vec { + skills + .iter() + .map(|skill| ProtocolSkillMetadata { + name: skill.name.clone(), + description: skill.description.clone(), + path: skill.path.clone(), + scope: skill.scope, + }) + .collect() +} + +fn errors_to_info(errors: &[SkillError]) -> Vec { + errors + .iter() + .map(|err| SkillErrorInfo { + path: err.path.clone(), + message: err.message.clone(), + }) + .collect() } /// Takes a user message as input and runs a loop where, at each turn, the model @@ -2113,10 +2151,20 @@ pub(crate) async fn run_task( }); sess.send_event(&turn_context, event).await; + let skills_outcome = if sess.enabled(Feature::Skills) { + Some( + sess.services + .skills_manager + .skills_for_cwd(&turn_context.cwd), + ) + } else { + None + }; + let SkillInjections { items: skill_items, warnings: skill_warnings, - } = build_skill_injections(&input, sess.services.skills.as_ref()).await; + } = build_skill_injections(&input, skills_outcome.as_ref()).await; for message in skill_warnings { sess.send_event(&turn_context, EventMsg::Warning(WarningEvent { message })) @@ -3013,6 +3061,7 @@ mod tests { ); let state = SessionState::new(session_configuration.clone()); + let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone())); let services = SessionServices { mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::default())), @@ -3026,7 +3075,7 @@ mod tests { otel_manager: otel_manager.clone(), models_manager, tool_approvals: Mutex::new(ApprovalStore::default()), - skills: None, + skills_manager, }; let turn_context = Session::make_turn_context( @@ -3103,6 +3152,7 @@ mod tests { ); let state = SessionState::new(session_configuration.clone()); + let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone())); let services = SessionServices { mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::default())), @@ -3116,7 +3166,7 @@ mod tests { otel_manager: otel_manager.clone(), models_manager, tool_approvals: Mutex::new(ApprovalStore::default()), - skills: None, + skills_manager, }; let turn_context = Arc::new(Session::make_turn_context( diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 75b29eddeed..c7aebbaf921 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -49,6 +49,7 @@ pub(crate) async fn run_codex_conversation_interactive( config, auth_manager, models_manager, + Arc::clone(&parent_session.services.skills_manager), initial_history.unwrap_or(InitialHistory::New), SessionSource::SubAgent(SubAgentSource::Review), ) diff --git a/codex-rs/core/src/conversation_manager.rs b/codex-rs/core/src/conversation_manager.rs index f340e1a8333..ce38b0018ca 100644 --- a/codex-rs/core/src/conversation_manager.rs +++ b/codex-rs/core/src/conversation_manager.rs @@ -1,4 +1,5 @@ use crate::AuthManager; +#[cfg(any(test, feature = "test-support"))] use crate::CodexAuth; #[cfg(any(test, feature = "test-support"))] use crate::ModelProviderInfo; @@ -14,6 +15,7 @@ use crate::protocol::Event; use crate::protocol::EventMsg; use crate::protocol::SessionConfiguredEvent; use crate::rollout::RolloutRecorder; +use crate::skills::SkillsManager; use codex_protocol::ConversationId; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; @@ -24,6 +26,8 @@ use codex_protocol::protocol::SessionSource; use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; +#[cfg(any(test, feature = "test-support"))] +use tempfile::TempDir; use tokio::sync::RwLock; /// Represents a newly created Codex conversation, including the first event @@ -40,16 +44,23 @@ pub struct ConversationManager { conversations: Arc>>>, auth_manager: Arc, models_manager: Arc, + skills_manager: Arc, session_source: SessionSource, + #[cfg(any(test, feature = "test-support"))] + _test_codex_home_guard: Option, } impl ConversationManager { pub fn new(auth_manager: Arc, session_source: SessionSource) -> Self { + let skills_manager = Arc::new(SkillsManager::new(auth_manager.codex_home().to_path_buf())); Self { conversations: Arc::new(RwLock::new(HashMap::new())), auth_manager: auth_manager.clone(), session_source, models_manager: Arc::new(ModelsManager::new(auth_manager)), + skills_manager, + #[cfg(any(test, feature = "test-support"))] + _test_codex_home_guard: None, } } @@ -57,12 +68,30 @@ impl ConversationManager { /// Construct with a dummy AuthManager containing the provided CodexAuth. /// Used for integration tests: should not be used by ordinary business logic. pub fn with_models_provider(auth: CodexAuth, provider: ModelProviderInfo) -> Self { - let auth_manager = crate::AuthManager::from_auth_for_testing(auth); + let temp_dir = tempfile::tempdir().unwrap_or_else(|err| panic!("temp codex home: {err}")); + let codex_home = temp_dir.path().to_path_buf(); + let mut manager = Self::with_models_provider_and_home(auth, provider, codex_home); + manager._test_codex_home_guard = Some(temp_dir); + manager + } + + #[cfg(any(test, feature = "test-support"))] + /// Construct with a dummy AuthManager containing the provided CodexAuth and codex home. + /// Used for integration tests: should not be used by ordinary business logic. + pub fn with_models_provider_and_home( + auth: CodexAuth, + provider: ModelProviderInfo, + codex_home: PathBuf, + ) -> Self { + let auth_manager = crate::AuthManager::from_auth_for_testing_with_home(auth, codex_home); + let skills_manager = Arc::new(SkillsManager::new(auth_manager.codex_home().to_path_buf())); Self { conversations: Arc::new(RwLock::new(HashMap::new())), auth_manager: auth_manager.clone(), session_source: SessionSource::Exec, models_manager: Arc::new(ModelsManager::with_provider(auth_manager, provider)), + skills_manager, + _test_codex_home_guard: None, } } @@ -70,6 +99,10 @@ impl ConversationManager { self.session_source.clone() } + pub fn skills_manager(&self) -> Arc { + self.skills_manager.clone() + } + pub async fn new_conversation(&self, config: Config) -> CodexResult { self.spawn_conversation( config, @@ -92,6 +125,7 @@ impl ConversationManager { config, auth_manager, models_manager, + self.skills_manager.clone(), InitialHistory::New, self.session_source.clone(), ) @@ -169,6 +203,7 @@ impl ConversationManager { config, auth_manager, self.models_manager.clone(), + self.skills_manager.clone(), initial_history, self.session_source.clone(), ) @@ -210,6 +245,7 @@ impl ConversationManager { config, auth_manager, self.models_manager.clone(), + self.skills_manager.clone(), history, self.session_source.clone(), ) diff --git a/codex-rs/core/src/rollout/policy.rs b/codex-rs/core/src/rollout/policy.rs index 47f051df4f0..2980e768cf8 100644 --- a/codex-rs/core/src/rollout/policy.rs +++ b/codex-rs/core/src/rollout/policy.rs @@ -79,6 +79,7 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool { | EventMsg::McpStartupUpdate(_) | EventMsg::McpStartupComplete(_) | EventMsg::ListCustomPromptsResponse(_) + | EventMsg::ListSkillsResponse(_) | EventMsg::PlanUpdate(_) | EventMsg::ShutdownComplete | EventMsg::ViewImageToolCall(_) diff --git a/codex-rs/core/src/skills/loader.rs b/codex-rs/core/src/skills/loader.rs index f3ed7f0239c..54b859caaf4 100644 --- a/codex-rs/core/src/skills/loader.rs +++ b/codex-rs/core/src/skills/loader.rs @@ -3,6 +3,7 @@ use crate::git_info::resolve_root_git_project_for_trust; use crate::skills::model::SkillError; use crate::skills::model::SkillLoadOutcome; use crate::skills::model::SkillMetadata; +use codex_protocol::protocol::SkillScope; use dunce::canonicalize as normalize_path; use serde::Deserialize; use std::collections::VecDeque; @@ -53,10 +54,21 @@ impl fmt::Display for SkillParseError { impl Error for SkillParseError {} pub fn load_skills(config: &Config) -> SkillLoadOutcome { + load_skills_from_roots(skill_roots(config)) +} + +pub(crate) struct SkillRoot { + pub(crate) path: PathBuf, + pub(crate) scope: SkillScope, +} + +pub(crate) fn load_skills_from_roots(roots: I) -> SkillLoadOutcome +where + I: IntoIterator, +{ let mut outcome = SkillLoadOutcome::default(); - let roots = skill_roots(config); for root in roots { - discover_skills_under_root(&root, &mut outcome); + discover_skills_under_root(&root.path, root.scope, &mut outcome); } outcome @@ -66,21 +78,33 @@ pub fn load_skills(config: &Config) -> SkillLoadOutcome { outcome } -fn skill_roots(config: &Config) -> Vec { - let mut roots = vec![config.codex_home.join(SKILLS_DIR_NAME)]; +pub(crate) fn user_skills_root(codex_home: &Path) -> SkillRoot { + SkillRoot { + path: codex_home.join(SKILLS_DIR_NAME), + scope: SkillScope::User, + } +} + +pub(crate) fn repo_skills_root(cwd: &Path) -> Option { + resolve_root_git_project_for_trust(cwd).map(|repo_root| SkillRoot { + path: repo_root + .join(REPO_ROOT_CONFIG_DIR_NAME) + .join(SKILLS_DIR_NAME), + scope: SkillScope::Repo, + }) +} + +fn skill_roots(config: &Config) -> Vec { + let mut roots = vec![user_skills_root(&config.codex_home)]; - if let Some(repo_root) = resolve_root_git_project_for_trust(&config.cwd) { - roots.push( - repo_root - .join(REPO_ROOT_CONFIG_DIR_NAME) - .join(SKILLS_DIR_NAME), - ); + if let Some(repo_root) = repo_skills_root(&config.cwd) { + roots.push(repo_root); } roots } -fn discover_skills_under_root(root: &Path, outcome: &mut SkillLoadOutcome) { +fn discover_skills_under_root(root: &Path, scope: SkillScope, outcome: &mut SkillLoadOutcome) { let Ok(root) = normalize_path(root) else { return; }; @@ -124,7 +148,7 @@ fn discover_skills_under_root(root: &Path, outcome: &mut SkillLoadOutcome) { } if file_type.is_file() && file_name == SKILLS_FILENAME { - match parse_skill_file(&path) { + match parse_skill_file(&path, scope) { Ok(skill) => outcome.skills.push(skill), Err(err) => outcome.errors.push(SkillError { path, @@ -136,7 +160,7 @@ fn discover_skills_under_root(root: &Path, outcome: &mut SkillLoadOutcome) { } } -fn parse_skill_file(path: &Path) -> Result { +fn parse_skill_file(path: &Path, scope: SkillScope) -> Result { let contents = fs::read_to_string(path).map_err(SkillParseError::Read)?; let frontmatter = extract_frontmatter(&contents).ok_or(SkillParseError::MissingFrontmatter)?; @@ -156,6 +180,7 @@ fn parse_skill_file(path: &Path) -> Result { name, description, path: resolved_path, + scope, }) } diff --git a/codex-rs/core/src/skills/manager.rs b/codex-rs/core/src/skills/manager.rs new file mode 100644 index 00000000000..a031d66a98c --- /dev/null +++ b/codex-rs/core/src/skills/manager.rs @@ -0,0 +1,48 @@ +use std::collections::HashMap; +use std::path::Path; +use std::path::PathBuf; +use std::sync::RwLock; + +use crate::skills::SkillLoadOutcome; +use crate::skills::loader::load_skills_from_roots; +use crate::skills::loader::repo_skills_root; +use crate::skills::loader::user_skills_root; + +pub struct SkillsManager { + codex_home: PathBuf, + cache_by_cwd: RwLock>, +} + +impl SkillsManager { + pub fn new(codex_home: PathBuf) -> Self { + Self { + codex_home, + cache_by_cwd: RwLock::new(HashMap::new()), + } + } + + pub fn skills_for_cwd(&self, cwd: &Path) -> SkillLoadOutcome { + let cached = match self.cache_by_cwd.read() { + Ok(cache) => cache.get(cwd).cloned(), + Err(err) => err.into_inner().get(cwd).cloned(), + }; + if let Some(outcome) = cached { + return outcome; + } + + let mut roots = vec![user_skills_root(&self.codex_home)]; + if let Some(repo_root) = repo_skills_root(cwd) { + roots.push(repo_root); + } + let outcome = load_skills_from_roots(roots); + match self.cache_by_cwd.write() { + Ok(mut cache) => { + cache.insert(cwd.to_path_buf(), outcome.clone()); + } + Err(err) => { + err.into_inner().insert(cwd.to_path_buf(), outcome.clone()); + } + } + outcome + } +} diff --git a/codex-rs/core/src/skills/mod.rs b/codex-rs/core/src/skills/mod.rs index b2ab935ce53..9d15f0333c5 100644 --- a/codex-rs/core/src/skills/mod.rs +++ b/codex-rs/core/src/skills/mod.rs @@ -1,11 +1,13 @@ pub mod injection; pub mod loader; +pub mod manager; pub mod model; pub mod render; pub(crate) use injection::SkillInjections; pub(crate) use injection::build_skill_injections; pub use loader::load_skills; +pub use manager::SkillsManager; pub use model::SkillError; pub use model::SkillLoadOutcome; pub use model::SkillMetadata; diff --git a/codex-rs/core/src/skills/model.rs b/codex-rs/core/src/skills/model.rs index 83a3317d57f..8aff199c3ff 100644 --- a/codex-rs/core/src/skills/model.rs +++ b/codex-rs/core/src/skills/model.rs @@ -1,10 +1,13 @@ use std::path::PathBuf; +use codex_protocol::protocol::SkillScope; + #[derive(Debug, Clone, PartialEq, Eq)] pub struct SkillMetadata { pub name: String, pub description: String, pub path: PathBuf, + pub scope: SkillScope, } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index dbc61502927..e06691955fc 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -4,7 +4,7 @@ use crate::AuthManager; use crate::RolloutRecorder; use crate::mcp_connection_manager::McpConnectionManager; use crate::openai_models::models_manager::ModelsManager; -use crate::skills::SkillLoadOutcome; +use crate::skills::SkillsManager; use crate::tools::sandboxing::ApprovalStore; use crate::unified_exec::UnifiedExecSessionManager; use crate::user_notification::UserNotifier; @@ -25,5 +25,5 @@ pub(crate) struct SessionServices { pub(crate) models_manager: Arc, pub(crate) otel_manager: OtelManager, pub(crate) tool_approvals: Mutex, - pub(crate) skills: Option, + pub(crate) skills_manager: Arc, } diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index b07f4d37412..7a12a24a691 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -107,8 +107,11 @@ impl TestCodexBuilder { let (config, cwd) = self.prepare_config(server, &home).await?; let auth = self.auth.clone(); - let conversation_manager = - ConversationManager::with_models_provider(auth.clone(), config.model_provider.clone()); + let conversation_manager = ConversationManager::with_models_provider_and_home( + auth.clone(), + config.model_provider.clone(), + config.codex_home.clone(), + ); let new_conversation = match resume_from { Some(path) => { diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index cdde1616f28..42a06d3f38e 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -259,9 +259,10 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { // Also configure user instructions to ensure they are NOT delivered on resume. config.user_instructions = Some("be nice".to_string()); - let conversation_manager = ConversationManager::with_models_provider( + let conversation_manager = ConversationManager::with_models_provider_and_home( CodexAuth::from_api_key("Test API Key"), config.model_provider.clone(), + config.codex_home.clone(), ); let auth_manager = codex_core::AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); @@ -345,9 +346,10 @@ async fn includes_conversation_id_and_model_headers_in_request() { let mut config = load_default_config_for_test(&codex_home); config.model_provider = model_provider; - let conversation_manager = ConversationManager::with_models_provider( + let conversation_manager = ConversationManager::with_models_provider_and_home( CodexAuth::from_api_key("Test API Key"), config.model_provider.clone(), + config.codex_home.clone(), ); let NewConversation { conversation: codex, @@ -406,9 +408,10 @@ async fn includes_base_instructions_override_in_request() { config.base_instructions = Some("test instructions".to_string()); config.model_provider = model_provider; - let conversation_manager = ConversationManager::with_models_provider( + let conversation_manager = ConversationManager::with_models_provider_and_home( CodexAuth::from_api_key("Test API Key"), config.model_provider.clone(), + config.codex_home.clone(), ); let codex = conversation_manager .new_conversation(config) @@ -466,9 +469,10 @@ async fn chatgpt_auth_sends_correct_request() { let codex_home = TempDir::new().unwrap(); let mut config = load_default_config_for_test(&codex_home); config.model_provider = model_provider; - let conversation_manager = ConversationManager::with_models_provider( + let conversation_manager = ConversationManager::with_models_provider_and_home( create_dummy_codex_auth(), config.model_provider.clone(), + config.codex_home.clone(), ); let NewConversation { conversation: codex, @@ -602,9 +606,10 @@ async fn includes_user_instructions_message_in_request() { config.model_provider = model_provider; config.user_instructions = Some("be nice".to_string()); - let conversation_manager = ConversationManager::with_models_provider( + let conversation_manager = ConversationManager::with_models_provider_and_home( CodexAuth::from_api_key("Test API Key"), config.model_provider.clone(), + config.codex_home.clone(), ); let codex = conversation_manager .new_conversation(config) @@ -671,9 +676,10 @@ async fn skills_append_to_instructions_when_feature_enabled() { config.features.enable(Feature::Skills); config.cwd = codex_home.path().to_path_buf(); - let conversation_manager = ConversationManager::with_models_provider( + let conversation_manager = ConversationManager::with_models_provider_and_home( CodexAuth::from_api_key("Test API Key"), config.model_provider.clone(), + config.codex_home.clone(), ); let codex = conversation_manager .new_conversation(config) @@ -713,6 +719,7 @@ async fn skills_append_to_instructions_when_feature_enabled() { instructions_text.contains(&expected_path_str), "expected path {expected_path_str} in instructions" ); + let _codex_home_guard = codex_home; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -1027,9 +1034,10 @@ async fn includes_developer_instructions_message_in_request() { config.user_instructions = Some("be nice".to_string()); config.developer_instructions = Some("be useful".to_string()); - let conversation_manager = ConversationManager::with_models_provider( + let conversation_manager = ConversationManager::with_models_provider_and_home( CodexAuth::from_api_key("Test API Key"), config.model_provider.clone(), + config.codex_home.clone(), ); let codex = conversation_manager .new_conversation(config) @@ -1256,9 +1264,10 @@ async fn token_count_includes_rate_limits_snapshot() { let mut config = load_default_config_for_test(&home); config.model_provider = provider; - let conversation_manager = ConversationManager::with_models_provider( + let conversation_manager = ConversationManager::with_models_provider_and_home( CodexAuth::from_api_key("test"), config.model_provider.clone(), + config.codex_home.clone(), ); let codex = conversation_manager .new_conversation(config) @@ -1610,9 +1619,10 @@ async fn azure_overrides_assign_properties_used_for_responses_url() { let mut config = load_default_config_for_test(&codex_home); config.model_provider = provider; - let conversation_manager = ConversationManager::with_models_provider( + let conversation_manager = ConversationManager::with_models_provider_and_home( create_dummy_codex_auth(), config.model_provider.clone(), + config.codex_home.clone(), ); let codex = conversation_manager .new_conversation(config) @@ -1691,9 +1701,10 @@ async fn env_var_overrides_loaded_auth() { let mut config = load_default_config_for_test(&codex_home); config.model_provider = provider; - let conversation_manager = ConversationManager::with_models_provider( + let conversation_manager = ConversationManager::with_models_provider_and_home( create_dummy_codex_auth(), config.model_provider.clone(), + config.codex_home.clone(), ); let codex = conversation_manager .new_conversation(config) @@ -1772,9 +1783,10 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { let mut config = load_default_config_for_test(&codex_home); config.model_provider = model_provider; - let conversation_manager = ConversationManager::with_models_provider( + let conversation_manager = ConversationManager::with_models_provider_and_home( CodexAuth::from_api_key("Test API Key"), config.model_provider.clone(), + config.codex_home.clone(), ); let NewConversation { conversation: codex, diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index d6ced3c1dc3..25a68be763c 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -6,7 +6,6 @@ use codex_core::features::Feature; use codex_core::protocol::AskForApproval; use codex_core::protocol::Op; use codex_core::protocol::SandboxPolicy; -use codex_core::protocol::SkillLoadOutcomeInfo; use codex_protocol::user_input::UserInput; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -115,11 +114,23 @@ async fn skill_load_errors_surface_in_session_configured() -> Result<()> { }); let test = builder.build(&server).await?; - let SkillLoadOutcomeInfo { skills, errors } = test - .session_configured - .skill_load_outcome - .as_ref() - .expect("skill outcome present"); + test.codex + .submit(Op::ListSkills { cwds: Vec::new() }) + .await?; + let response = + core_test_support::wait_for_event_match(test.codex.as_ref(), |event| match event { + codex_core::protocol::EventMsg::ListSkillsResponse(response) => Some(response.clone()), + _ => None, + }) + .await; + + let cwd = test.cwd_path(); + let (skills, errors) = response + .skills + .iter() + .find(|entry| entry.cwd.as_path() == cwd) + .map(|entry| (entry.skills.clone(), entry.errors.clone())) + .unwrap_or_default(); assert!( skills.is_empty(), diff --git a/codex-rs/docs/protocol_v1.md b/codex-rs/docs/protocol_v1.md index f6405c9bc3a..075377f61ef 100644 --- a/codex-rs/docs/protocol_v1.md +++ b/codex-rs/docs/protocol_v1.md @@ -68,6 +68,7 @@ For complete documentation of the `Op` and `EventMsg` variants, refer to [protoc - `Op::UserInput` – Any input from the user to kick off a `Task` - `Op::Interrupt` – Interrupts a running task - `Op::ExecApproval` – Approve or deny code execution + - `Op::ListSkills` – Request skills for one or more cwd values - `EventMsg` - `EventMsg::AgentMessage` – Messages from the `Model` - `EventMsg::ExecApprovalRequest` – Request approval from user to execute a command @@ -75,6 +76,7 @@ For complete documentation of the `Op` and `EventMsg` variants, refer to [protoc - `EventMsg::Error` – A task stopped with an error - `EventMsg::Warning` – A non-fatal warning that the client should surface to the user - `EventMsg::TurnComplete` – Contains a `response_id` bookmark for last `response_id` executed by the task. This can be used to continue the task at a later point in time, perhaps with additional user input. + - `EventMsg::ListSkillsResponse` – Response payload with per-cwd skill entries (`cwd`, `skills`, `errors`) The `response_id` returned from each task matches the OpenAI `response_id` stored in the API's `/responses` endpoint. It can be stored and used in future `Sessions` to resume threads of work. diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index 1da0796a752..a833426dc22 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -572,6 +572,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { | EventMsg::GetHistoryEntryResponse(_) | EventMsg::McpListToolsResponse(_) | EventMsg::ListCustomPromptsResponse(_) + | EventMsg::ListSkillsResponse(_) | EventMsg::RawResponseItem(_) | EventMsg::UserMessage(_) | EventMsg::EnteredReviewMode(_) diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 2291698d665..2b3673f5a6d 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -85,7 +85,6 @@ fn session_configured_produces_thread_started_event() { history_log_id: 0, history_entry_count: 0, initial_messages: None, - skill_load_outcome: None, rollout_path, }), ); diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index d39a38cde94..73e56a605ce 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -279,6 +279,7 @@ async fn run_codex_tool_session_inner( | EventMsg::McpToolCallEnd(_) | EventMsg::McpListToolsResponse(_) | EventMsg::ListCustomPromptsResponse(_) + | EventMsg::ListSkillsResponse(_) | EventMsg::ExecCommandBegin(_) | EventMsg::TerminalInteraction(_) | EventMsg::ExecCommandOutputDelta(_) diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs index 3af472ddb9f..83ac25fdfd4 100644 --- a/codex-rs/mcp-server/src/outgoing_message.rs +++ b/codex-rs/mcp-server/src/outgoing_message.rs @@ -266,7 +266,6 @@ mod tests { history_log_id: 1, history_entry_count: 1000, initial_messages: None, - skill_load_outcome: None, rollout_path: rollout_file.path().to_path_buf(), }), }; @@ -306,7 +305,6 @@ mod tests { history_log_id: 1, history_entry_count: 1000, initial_messages: None, - skill_load_outcome: None, rollout_path: rollout_file.path().to_path_buf(), }; let event = Event { diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 14dc426cea6..c333d431ca3 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -186,6 +186,15 @@ pub enum Op { /// Request the list of available custom prompts. ListCustomPrompts, + /// Request the list of skills for the provided `cwd` values or the session default. + ListSkills { + /// Working directories to scope repo skills discovery. + /// + /// When empty, the session default working directory is used. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + cwds: Vec, + }, + /// Request the agent to summarize the current conversation context. /// The agent will use its existing context (either conversation history or previous response id) /// to generate a summary which will be returned as an AgentMessage event. @@ -588,6 +597,9 @@ pub enum EventMsg { /// List of custom prompts available to the agent. ListCustomPromptsResponse(ListCustomPromptsResponseEvent), + /// List of skills available to the agent. + ListSkillsResponse(ListSkillsResponseEvent), + PlanUpdate(UpdatePlanArgs), TurnAborted(TurnAbortedEvent), @@ -1650,11 +1662,26 @@ pub struct ListCustomPromptsResponseEvent { pub custom_prompts: Vec, } +/// Response payload for `Op::ListSkills`. +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +pub struct ListSkillsResponseEvent { + pub skills: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] +pub enum SkillScope { + User, + Repo, +} + #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] -pub struct SkillInfo { +pub struct SkillMetadata { pub name: String, pub description: String, pub path: PathBuf, + pub scope: SkillScope, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] @@ -1663,9 +1690,10 @@ pub struct SkillErrorInfo { pub message: String, } -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS, Default)] -pub struct SkillLoadOutcomeInfo { - pub skills: Vec, +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +pub struct SkillsListEntry { + pub cwd: PathBuf, + pub skills: Vec, pub errors: Vec, } @@ -1704,9 +1732,6 @@ pub struct SessionConfiguredEvent { #[serde(skip_serializing_if = "Option::is_none")] pub initial_messages: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub skill_load_outcome: Option, - pub rollout_path: PathBuf, } @@ -1834,7 +1859,6 @@ mod tests { history_log_id: 0, history_entry_count: 0, initial_messages: None, - skill_load_outcome: None, rollout_path: rollout_file.path().to_path_buf(), }), }; diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 5397a2eaecf..14be12cb518 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -31,9 +31,10 @@ use codex_core::openai_models::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFI use codex_core::openai_models::models_manager::ModelsManager; use codex_core::protocol::EventMsg; use codex_core::protocol::FinalOutput; +use codex_core::protocol::ListSkillsResponseEvent; use codex_core::protocol::Op; use codex_core::protocol::SessionSource; -use codex_core::protocol::SkillLoadOutcomeInfo; +use codex_core::protocol::SkillErrorInfo; use codex_core::protocol::TokenUsage; use codex_core::skills::SkillError; use codex_protocol::ConversationId; @@ -50,6 +51,7 @@ use ratatui::text::Line; use ratatui::widgets::Paragraph; use ratatui::widgets::Wrap; use std::collections::BTreeMap; +use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicBool; @@ -86,9 +88,8 @@ fn session_summary( }) } -fn skill_errors_from_outcome(outcome: &SkillLoadOutcomeInfo) -> Vec { - outcome - .errors +fn skill_errors_from_info(errors: &[SkillErrorInfo]) -> Vec { + errors .iter() .map(|err| SkillError { path: err.path.clone(), @@ -97,6 +98,15 @@ fn skill_errors_from_outcome(outcome: &SkillLoadOutcomeInfo) -> Vec .collect() } +fn errors_for_cwd(cwd: &Path, response: &ListSkillsResponseEvent) -> Vec { + response + .skills + .iter() + .find(|entry| entry.cwd.as_path() == cwd) + .map(|entry| entry.errors.clone()) + .unwrap_or_default() +} + #[derive(Debug, Clone, PartialEq, Eq)] struct SessionSummary { usage_line: String, @@ -688,11 +698,14 @@ impl App { self.suppress_shutdown_complete = false; return Ok(true); } - if let EventMsg::SessionConfigured(cfg) = &event.msg - && let Some(outcome) = cfg.skill_load_outcome.as_ref() - && !outcome.errors.is_empty() - { - let errors = skill_errors_from_outcome(outcome); + if let EventMsg::ListSkillsResponse(response) = &event.msg { + let cwd = self.chat_widget.config_ref().cwd.clone(); + let errors = errors_for_cwd(&cwd, response); + if errors.is_empty() { + self.chat_widget.handle_codex_event(event); + return Ok(true); + } + let errors = skill_errors_from_info(&errors); match run_skill_error_prompt(tui, &errors).await { SkillErrorPromptOutcome::Exit => { self.chat_widget.submit_op(Op::Shutdown); @@ -1382,7 +1395,6 @@ mod tests { history_log_id: 0, history_entry_count: 0, initial_messages: None, - skill_load_outcome: None, rollout_path: PathBuf::new(), }; Arc::new(new_session_info( @@ -1438,7 +1450,6 @@ mod tests { history_log_id: 0, history_entry_count: 0, initial_messages: None, - skill_load_outcome: None, rollout_path: PathBuf::new(), }; diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index e3f95a612e8..37cd004a159 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -33,6 +33,7 @@ use codex_core::protocol::ExecCommandEndEvent; use codex_core::protocol::ExecCommandSource; use codex_core::protocol::ExitedReviewModeEvent; use codex_core::protocol::ListCustomPromptsResponseEvent; +use codex_core::protocol::ListSkillsResponseEvent; use codex_core::protocol::McpListToolsResponseEvent; use codex_core::protocol::McpStartupCompleteEvent; use codex_core::protocol::McpStartupStatus; @@ -44,7 +45,7 @@ use codex_core::protocol::PatchApplyBeginEvent; use codex_core::protocol::RateLimitSnapshot; use codex_core::protocol::ReviewRequest; use codex_core::protocol::ReviewTarget; -use codex_core::protocol::SkillLoadOutcomeInfo; +use codex_core::protocol::SkillsListEntry; use codex_core::protocol::StreamErrorEvent; use codex_core::protocol::TaskCompleteEvent; use codex_core::protocol::TerminalInteractionEvent; @@ -392,7 +393,7 @@ impl ChatWidget { fn on_session_configured(&mut self, event: codex_core::protocol::SessionConfiguredEvent) { self.bottom_pane .set_history_metadata(event.history_log_id, event.history_entry_count); - self.set_skills_from_outcome(event.skill_load_outcome.as_ref()); + self.set_skills(None); self.conversation_id = Some(event.session_id); self.current_rollout_path = Some(event.rollout_path.clone()); let initial_messages = event.initial_messages.clone(); @@ -409,6 +410,7 @@ impl ChatWidget { } // Ask codex-core to enumerate custom prompts for this session. self.submit_op(Op::ListCustomPrompts); + self.submit_op(Op::ListSkills { cwds: Vec::new() }); if let Some(user_message) = self.initial_user_message.take() { self.submit_user_message(user_message); } @@ -417,11 +419,15 @@ impl ChatWidget { } } - fn set_skills_from_outcome(&mut self, outcome: Option<&SkillLoadOutcomeInfo>) { - let skills = outcome.map(skills_from_outcome); + fn set_skills(&mut self, skills: Option>) { self.bottom_pane.set_skills(skills); } + fn set_skills_from_response(&mut self, response: &ListSkillsResponseEvent) { + let skills = skills_for_cwd(&self.config.cwd, &response.skills); + self.set_skills(Some(skills)); + } + pub(crate) fn open_feedback_note( &mut self, category: crate::app_event::FeedbackCategory, @@ -1879,6 +1885,7 @@ impl ChatWidget { EventMsg::GetHistoryEntryResponse(ev) => self.on_get_history_entry_response(ev), EventMsg::McpListToolsResponse(ev) => self.on_list_mcp_tools(ev), EventMsg::ListCustomPromptsResponse(ev) => self.on_list_custom_prompts(ev), + EventMsg::ListSkillsResponse(ev) => self.on_list_skills(ev), EventMsg::ShutdownComplete => self.on_shutdown_complete(), EventMsg::TurnDiff(TurnDiffEvent { unified_diff }) => self.on_turn_diff(unified_diff), EventMsg::DeprecationNotice(ev) => self.on_deprecation_notice(ev), @@ -3092,6 +3099,10 @@ impl ChatWidget { self.bottom_pane.set_custom_prompts(ev.custom_prompts); } + fn on_list_skills(&mut self, ev: ListSkillsResponseEvent) { + self.set_skills_from_response(&ev); + } + pub(crate) fn open_review_popup(&mut self) { let mut items: Vec = Vec::new(); @@ -3476,16 +3487,23 @@ pub(crate) fn show_review_commit_picker_with_entries( }); } -fn skills_from_outcome(outcome: &SkillLoadOutcomeInfo) -> Vec { - outcome - .skills +fn skills_for_cwd(cwd: &Path, skills_entries: &[SkillsListEntry]) -> Vec { + skills_entries .iter() - .map(|skill| SkillMetadata { - name: skill.name.clone(), - description: skill.description.clone(), - path: skill.path.clone(), + .find(|entry| entry.cwd.as_path() == cwd) + .map(|entry| { + entry + .skills + .iter() + .map(|skill| SkillMetadata { + name: skill.name.clone(), + description: skill.description.clone(), + path: skill.path.clone(), + scope: skill.scope, + }) + .collect() }) - .collect() + .unwrap_or_default() } fn find_skill_mentions(text: &str, skills: &[SkillMetadata]) -> Vec { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index e8432b0af8a..362b1678f4d 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -125,7 +125,6 @@ fn resumed_initial_messages_render_history() { message: "assistant reply".to_string(), }), ]), - skill_load_outcome: None, rollout_path: rollout_file.path().to_path_buf(), }; diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs index ea0673400a9..bb4c632447d 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -31,9 +31,10 @@ use codex_core::openai_models::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFI use codex_core::openai_models::models_manager::ModelsManager; use codex_core::protocol::EventMsg; use codex_core::protocol::FinalOutput; +use codex_core::protocol::ListSkillsResponseEvent; use codex_core::protocol::Op; use codex_core::protocol::SessionSource; -use codex_core::protocol::SkillLoadOutcomeInfo; +use codex_core::protocol::SkillErrorInfo; use codex_core::protocol::TokenUsage; use codex_core::skills::SkillError; use codex_protocol::ConversationId; @@ -50,6 +51,7 @@ use ratatui::text::Line; use ratatui::widgets::Paragraph; use ratatui::widgets::Wrap; use std::collections::BTreeMap; +use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicBool; @@ -96,9 +98,8 @@ fn session_summary( }) } -fn skill_errors_from_outcome(outcome: &SkillLoadOutcomeInfo) -> Vec { - outcome - .errors +fn skill_errors_from_info(errors: &[SkillErrorInfo]) -> Vec { + errors .iter() .map(|err| SkillError { path: err.path.clone(), @@ -107,6 +108,15 @@ fn skill_errors_from_outcome(outcome: &SkillLoadOutcomeInfo) -> Vec .collect() } +fn errors_for_cwd(cwd: &Path, response: &ListSkillsResponseEvent) -> Vec { + response + .skills + .iter() + .find(|entry| entry.cwd.as_path() == cwd) + .map(|entry| entry.errors.clone()) + .unwrap_or_default() +} + #[derive(Debug, Clone, PartialEq, Eq)] struct SessionSummary { usage_line: String, @@ -698,11 +708,14 @@ impl App { self.suppress_shutdown_complete = false; return Ok(true); } - if let EventMsg::SessionConfigured(cfg) = &event.msg - && let Some(outcome) = cfg.skill_load_outcome.as_ref() - && !outcome.errors.is_empty() - { - let errors = skill_errors_from_outcome(outcome); + if let EventMsg::ListSkillsResponse(response) = &event.msg { + let cwd = self.chat_widget.config_ref().cwd.clone(); + let errors = errors_for_cwd(&cwd, response); + if errors.is_empty() { + self.chat_widget.handle_codex_event(event); + return Ok(true); + } + let errors = skill_errors_from_info(&errors); match run_skill_error_prompt(tui, &errors).await { SkillErrorPromptOutcome::Exit => { self.chat_widget.submit_op(Op::Shutdown); @@ -1392,7 +1405,6 @@ mod tests { history_log_id: 0, history_entry_count: 0, initial_messages: None, - skill_load_outcome: None, rollout_path: PathBuf::new(), }; Arc::new(new_session_info( @@ -1448,7 +1460,6 @@ mod tests { history_log_id: 0, history_entry_count: 0, initial_messages: None, - skill_load_outcome: None, rollout_path: PathBuf::new(), }; diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index e3f95a612e8..7dec1ae5420 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -33,6 +33,7 @@ use codex_core::protocol::ExecCommandEndEvent; use codex_core::protocol::ExecCommandSource; use codex_core::protocol::ExitedReviewModeEvent; use codex_core::protocol::ListCustomPromptsResponseEvent; +use codex_core::protocol::ListSkillsResponseEvent; use codex_core::protocol::McpListToolsResponseEvent; use codex_core::protocol::McpStartupCompleteEvent; use codex_core::protocol::McpStartupStatus; @@ -44,7 +45,7 @@ use codex_core::protocol::PatchApplyBeginEvent; use codex_core::protocol::RateLimitSnapshot; use codex_core::protocol::ReviewRequest; use codex_core::protocol::ReviewTarget; -use codex_core::protocol::SkillLoadOutcomeInfo; +use codex_core::protocol::SkillsListEntry; use codex_core::protocol::StreamErrorEvent; use codex_core::protocol::TaskCompleteEvent; use codex_core::protocol::TerminalInteractionEvent; @@ -392,7 +393,7 @@ impl ChatWidget { fn on_session_configured(&mut self, event: codex_core::protocol::SessionConfiguredEvent) { self.bottom_pane .set_history_metadata(event.history_log_id, event.history_entry_count); - self.set_skills_from_outcome(event.skill_load_outcome.as_ref()); + self.set_skills(None); self.conversation_id = Some(event.session_id); self.current_rollout_path = Some(event.rollout_path.clone()); let initial_messages = event.initial_messages.clone(); @@ -409,6 +410,7 @@ impl ChatWidget { } // Ask codex-core to enumerate custom prompts for this session. self.submit_op(Op::ListCustomPrompts); + self.submit_op(Op::ListSkills { cwds: Vec::new() }); if let Some(user_message) = self.initial_user_message.take() { self.submit_user_message(user_message); } @@ -417,11 +419,15 @@ impl ChatWidget { } } - fn set_skills_from_outcome(&mut self, outcome: Option<&SkillLoadOutcomeInfo>) { - let skills = outcome.map(skills_from_outcome); + fn set_skills(&mut self, skills: Option>) { self.bottom_pane.set_skills(skills); } + fn set_skills_from_response(&mut self, response: &ListSkillsResponseEvent) { + let skills = skills_for_cwd(&self.config.cwd, &response.skills); + self.set_skills(Some(skills)); + } + pub(crate) fn open_feedback_note( &mut self, category: crate::app_event::FeedbackCategory, @@ -1879,6 +1885,7 @@ impl ChatWidget { EventMsg::GetHistoryEntryResponse(ev) => self.on_get_history_entry_response(ev), EventMsg::McpListToolsResponse(ev) => self.on_list_mcp_tools(ev), EventMsg::ListCustomPromptsResponse(ev) => self.on_list_custom_prompts(ev), + EventMsg::ListSkillsResponse(ev) => self.on_list_skills(ev), EventMsg::ShutdownComplete => self.on_shutdown_complete(), EventMsg::TurnDiff(TurnDiffEvent { unified_diff }) => self.on_turn_diff(unified_diff), EventMsg::DeprecationNotice(ev) => self.on_deprecation_notice(ev), @@ -3092,6 +3099,10 @@ impl ChatWidget { self.bottom_pane.set_custom_prompts(ev.custom_prompts); } + fn on_list_skills(&mut self, ev: ListSkillsResponseEvent) { + self.set_skills_from_response(&ev); + } + pub(crate) fn open_review_popup(&mut self) { let mut items: Vec = Vec::new(); @@ -3476,18 +3487,6 @@ pub(crate) fn show_review_commit_picker_with_entries( }); } -fn skills_from_outcome(outcome: &SkillLoadOutcomeInfo) -> Vec { - outcome - .skills - .iter() - .map(|skill| SkillMetadata { - name: skill.name.clone(), - description: skill.description.clone(), - path: skill.path.clone(), - }) - .collect() -} - fn find_skill_mentions(text: &str, skills: &[SkillMetadata]) -> Vec { let mut seen: HashSet = HashSet::new(); let mut matches: Vec = Vec::new(); @@ -3504,5 +3503,24 @@ fn find_skill_mentions(text: &str, skills: &[SkillMetadata]) -> Vec Vec { + skills_entries + .iter() + .find(|entry| entry.cwd.as_path() == cwd) + .map(|entry| { + entry + .skills + .iter() + .map(|skill| SkillMetadata { + name: skill.name.clone(), + description: skill.description.clone(), + path: skill.path.clone(), + scope: skill.scope, + }) + .collect() + }) + .unwrap_or_default() +} + #[cfg(test)] pub(crate) mod tests; diff --git a/codex-rs/tui2/src/chatwidget/tests.rs b/codex-rs/tui2/src/chatwidget/tests.rs index e8432b0af8a..362b1678f4d 100644 --- a/codex-rs/tui2/src/chatwidget/tests.rs +++ b/codex-rs/tui2/src/chatwidget/tests.rs @@ -125,7 +125,6 @@ fn resumed_initial_messages_render_history() { message: "assistant reply".to_string(), }), ]), - skill_load_outcome: None, rollout_path: rollout_file.path().to_path_buf(), };