Compare commits

...

3 Commits

Author SHA1 Message Date
Andrew Ambrosino
e02fd94a8a Move yeet to personal skills 2025-12-16 13:33:06 -08:00
Andrew Ambrosino
3f4a6f91c7 Add yeet skill 2025-12-16 13:30:53 -08:00
Xin Lin
b1adbc01bd Reimplement skills loading using SkillsManager + skills/list op. 2025-12-11 22:26:51 -08:00
32 changed files with 547 additions and 116 deletions

View File

@@ -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,

View File

@@ -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;
@@ -966,6 +969,86 @@ 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 {
#[serde(skip_serializing_if = "Option::is_none")]
pub cwds: Option<Vec<PathBuf>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct SkillsListResponse {
pub data: Vec<SkillsListEntry>,
}
#[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<SkillMetadata>,
pub errors: Vec<SkillErrorInfo>,
}
impl From<CoreSkillMetadata> for SkillMetadata {
fn from(value: CoreSkillMetadata) -> Self {
Self {
name: value.name,
description: value.description,
path: value.path,
scope: value.scope.into(),
}
}
}
impl From<CoreSkillScope> for SkillScope {
fn from(value: CoreSkillScope) -> Self {
match value {
CoreSkillScope::User => Self::User,
CoreSkillScope::Repo => Self::Repo,
}
}
}
impl From<CoreSkillErrorInfo> 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/")]

View File

@@ -65,6 +65,7 @@ Example (from OpenAI's official VSCode extension):
- `review/start` — kick off Codexs 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; each skill includes a `scope` of `user` or `repo` (not thread-scoped).
- `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.

View File

@@ -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;
@@ -374,6 +376,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;
}
@@ -2630,6 +2635,40 @@ impl CodexMessageProcessor {
.await;
}
async fn skills_list(&self, request_id: RequestId, params: SkillsListParams) {
let SkillsListParams { cwds } = params;
let cwds = match cwds {
Some(cwds) if !cwds.is_empty() => cwds,
_ => vec![self.config.cwd.clone()],
};
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,
@@ -3275,6 +3314,32 @@ impl CodexMessageProcessor {
}
}
fn skills_to_info(
skills: &[codex_core::skills::SkillMetadata],
) -> Vec<codex_app_server_protocol::SkillMetadata> {
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<codex_app_server_protocol::SkillErrorInfo> {
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<std::collections::HashMap<String, serde_json::Value>>,

View File

@@ -1111,6 +1111,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<Self> {
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<CodexAuth> {
self.inner.read().ok().and_then(|c| c.auth.clone())

View File

@@ -102,8 +102,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;
@@ -116,10 +115,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;
@@ -203,6 +203,7 @@ impl Codex {
config: Config,
auth_manager: Arc<AuthManager>,
models_manager: Arc<ModelsManager>,
skills_manager: Arc<SkillsManager>,
conversation_history: InitialHistory,
session_source: SessionSource,
) -> CodexResult<CodexSpawnOk> {
@@ -210,7 +211,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
};
@@ -225,11 +226,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()),
)
@@ -275,7 +274,7 @@ impl Codex {
tx_event.clone(),
conversation_history,
session_source_clone,
skills_outcome.clone(),
skills_manager,
)
.await
.map_err(|e| {
@@ -542,7 +541,7 @@ impl Session {
tx_event: Sender<Event>,
initial_history: InitialHistory,
session_source: SessionSource,
skills: Option<SkillLoadOutcome>,
skills_manager: Arc<SkillsManager>,
) -> anyhow::Result<Arc<Self>> {
debug!(
"Configuring session: model={}; provider={:?}",
@@ -661,7 +660,7 @@ impl Session {
otel_event_manager,
models_manager: Arc::clone(&models_manager),
tool_approvals: Mutex::new(ApprovalStore::default()),
skills: skills.clone(),
skills_manager,
};
let sess = Arc::new(Session {
@@ -677,8 +676,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 {
@@ -692,7 +689,6 @@ impl Session {
history_log_id,
history_entry_count,
initial_messages,
skill_load_outcome,
rollout_path,
}),
})
@@ -1567,6 +1563,9 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, 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;
}
@@ -1611,6 +1610,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;
@@ -1624,9 +1624,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;
@@ -1634,6 +1636,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;
@@ -1861,6 +1864,44 @@ mod handlers {
sess.send_event_raw(event).await;
}
pub async fn list_skills(sess: &Session, sub_id: String, cwds: Option<Vec<PathBuf>>) {
let cwds = match cwds {
Some(cwds) if !cwds.is_empty() => cwds,
_ => {
let state = sess.state.lock().await;
vec![state.session_configuration.cwd.clone()]
}
};
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<Session>, sub_id: String) {
let turn_context = sess
.new_turn_with_sub_id(sub_id, SessionSettingsUpdate::default())
@@ -2046,28 +2087,26 @@ async fn spawn_review_thread(
.await;
}
fn skill_load_outcome_for_client(
outcome: Option<&SkillLoadOutcome>,
) -> Option<SkillLoadOutcomeInfo> {
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<ProtocolSkillMetadata> {
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<SkillErrorInfo> {
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
@@ -2098,10 +2137,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 }))
@@ -2954,6 +3003,7 @@ mod tests {
otel_event_manager(conversation_id, config.as_ref(), &model_family);
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())),
@@ -2967,7 +3017,7 @@ mod tests {
otel_event_manager: otel_event_manager.clone(),
models_manager,
tool_approvals: Mutex::new(ApprovalStore::default()),
skills: None,
skills_manager,
};
let turn_context = Session::make_turn_context(
@@ -3040,6 +3090,7 @@ mod tests {
otel_event_manager(conversation_id, config.as_ref(), &model_family);
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())),
@@ -3053,7 +3104,7 @@ mod tests {
otel_event_manager: otel_event_manager.clone(),
models_manager,
tool_approvals: Mutex::new(ApprovalStore::default()),
skills: None,
skills_manager,
};
let turn_context = Arc::new(Session::make_turn_context(

View File

@@ -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),
)

View File

@@ -14,6 +14,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;
@@ -40,16 +41,19 @@ pub struct ConversationManager {
conversations: Arc<RwLock<HashMap<ConversationId, Arc<CodexConversation>>>>,
auth_manager: Arc<AuthManager>,
models_manager: Arc<ModelsManager>,
skills_manager: Arc<SkillsManager>,
session_source: SessionSource,
}
impl ConversationManager {
pub fn new(auth_manager: Arc<AuthManager>, 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,
}
}
@@ -58,11 +62,32 @@ impl ConversationManager {
/// 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 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,
}
}
#[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,
}
}
@@ -70,6 +95,10 @@ impl ConversationManager {
self.session_source.clone()
}
pub fn skills_manager(&self) -> Arc<SkillsManager> {
self.skills_manager.clone()
}
pub async fn new_conversation(&self, config: Config) -> CodexResult<NewConversation> {
self.spawn_conversation(
config,
@@ -92,6 +121,7 @@ impl ConversationManager {
config,
auth_manager,
models_manager,
self.skills_manager.clone(),
InitialHistory::New,
self.session_source.clone(),
)
@@ -169,6 +199,7 @@ impl ConversationManager {
config,
auth_manager,
self.models_manager.clone(),
self.skills_manager.clone(),
initial_history,
self.session_source.clone(),
)
@@ -210,6 +241,7 @@ impl ConversationManager {
config,
auth_manager,
self.models_manager.clone(),
self.skills_manager.clone(),
history,
self.session_source.clone(),
)

View File

@@ -342,8 +342,8 @@ pub const FEATURES: &[FeatureSpec] = &[
FeatureSpec {
id: Feature::Skills,
key: "skills",
stage: Stage::Experimental,
default_enabled: false,
stage: Stage::Stable,
default_enabled: true,
},
FeatureSpec {
id: Feature::ShellSnapshot,

View File

@@ -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(_)

View File

@@ -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<I>(roots: I) -> SkillLoadOutcome
where
I: IntoIterator<Item = SkillRoot>,
{
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<PathBuf> {
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,
}
}
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),
);
pub(crate) fn repo_skills_root(cwd: &Path) -> Option<SkillRoot> {
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<SkillRoot> {
let mut roots = vec![user_skills_root(&config.codex_home)];
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<SkillMetadata, SkillParseError> {
fn parse_skill_file(path: &Path, scope: SkillScope) -> Result<SkillMetadata, SkillParseError> {
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<SkillMetadata, SkillParseError> {
name,
description,
path: resolved_path,
scope,
})
}

View File

@@ -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<HashMap<PathBuf, SkillLoadOutcome>>,
}
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
}
}

View File

@@ -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;

View File

@@ -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)]

View File

@@ -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<ModelsManager>,
pub(crate) otel_event_manager: OtelEventManager,
pub(crate) tool_approvals: Mutex<ApprovalStore>,
pub(crate) skills: Option<SkillLoadOutcome>,
pub(crate) skills_manager: Arc<SkillsManager>,
}

View File

@@ -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) => {

View File

@@ -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)
@@ -1255,9 +1263,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)
@@ -1609,9 +1618,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)
@@ -1690,9 +1700,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)
@@ -1771,9 +1782,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,

View File

@@ -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,21 @@ 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: None }).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(),

View File

@@ -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.

View File

@@ -572,6 +572,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
| EventMsg::GetHistoryEntryResponse(_)
| EventMsg::McpListToolsResponse(_)
| EventMsg::ListCustomPromptsResponse(_)
| EventMsg::ListSkillsResponse(_)
| EventMsg::RawResponseItem(_)
| EventMsg::UserMessage(_)
| EventMsg::EnteredReviewMode(_)

View File

@@ -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,
}),
);

View File

@@ -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(_)

View File

@@ -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 {

View File

@@ -184,6 +184,13 @@ 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 {
/// Optional working directories to scope repo skills discovery.
#[serde(skip_serializing_if = "Option::is_none")]
cwds: Option<Vec<PathBuf>>,
},
/// 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.
@@ -562,6 +569,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),
@@ -1624,11 +1634,26 @@ pub struct ListCustomPromptsResponseEvent {
pub custom_prompts: Vec<CustomPrompt>,
}
/// Response payload for `Op::ListSkills`.
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
pub struct SkillInfo {
pub struct ListSkillsResponseEvent {
pub skills: Vec<SkillsListEntry>,
}
#[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 SkillMetadata {
pub name: String,
pub description: String,
pub path: PathBuf,
pub scope: SkillScope,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
@@ -1637,9 +1662,10 @@ pub struct SkillErrorInfo {
pub message: String,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS, Default)]
pub struct SkillLoadOutcomeInfo {
pub skills: Vec<SkillInfo>,
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
pub struct SkillsListEntry {
pub cwd: PathBuf,
pub skills: Vec<SkillMetadata>,
pub errors: Vec<SkillErrorInfo>,
}
@@ -1678,9 +1704,6 @@ pub struct SessionConfiguredEvent {
#[serde(skip_serializing_if = "Option::is_none")]
pub initial_messages: Option<Vec<EventMsg>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub skill_load_outcome: Option<SkillLoadOutcomeInfo>,
pub rollout_path: PathBuf,
}
@@ -1808,7 +1831,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(),
}),
};

View File

@@ -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<SkillError> {
outcome
.errors
fn skill_errors_from_info(errors: &[SkillErrorInfo]) -> Vec<SkillError> {
errors
.iter()
.map(|err| SkillError {
path: err.path.clone(),
@@ -97,6 +98,15 @@ fn skill_errors_from_outcome(outcome: &SkillLoadOutcomeInfo) -> Vec<SkillError>
.collect()
}
fn errors_for_cwd(cwd: &Path, response: &ListSkillsResponseEvent) -> Vec<SkillErrorInfo> {
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(),
};

View File

@@ -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: None });
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<Vec<SkillMetadata>>) {
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),
@@ -3091,6 +3098,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<SelectionItem> = Vec::new();
@@ -3475,16 +3486,23 @@ pub(crate) fn show_review_commit_picker_with_entries(
});
}
fn skills_from_outcome(outcome: &SkillLoadOutcomeInfo) -> Vec<SkillMetadata> {
outcome
.skills
fn skills_for_cwd(cwd: &Path, skills_entries: &[SkillsListEntry]) -> Vec<SkillMetadata> {
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<SkillMetadata> {

View File

@@ -122,7 +122,6 @@ fn resumed_initial_messages_render_history() {
message: "assistant reply".to_string(),
}),
]),
skill_load_outcome: None,
rollout_path: rollout_file.path().to_path_buf(),
};

View File

@@ -1368,7 +1368,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(
@@ -1424,7 +1423,6 @@ mod tests {
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
skill_load_outcome: None,
rollout_path: PathBuf::new(),
};

View File

@@ -135,6 +135,11 @@ impl BottomPane {
self.status.as_ref()
}
pub fn set_skills(&mut self, skills: Option<Vec<SkillMetadata>>) {
self.composer.set_skill_mentions(skills);
self.request_redraw();
}
#[cfg(test)]
pub(crate) fn context_window_percent(&self) -> Option<i64> {
self.context_window_percent

View File

@@ -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,6 +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::SkillsListEntry;
use codex_core::protocol::StreamErrorEvent;
use codex_core::protocol::TaskCompleteEvent;
use codex_core::protocol::TerminalInteractionEvent;
@@ -408,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: None });
if let Some(user_message) = self.initial_user_message.take() {
self.submit_user_message(user_message);
}
@@ -1865,6 +1868,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),
@@ -3075,6 +3079,11 @@ impl ChatWidget {
self.bottom_pane.set_custom_prompts(ev.custom_prompts);
}
fn on_list_skills(&mut self, ev: ListSkillsResponseEvent) {
let skills = skills_for_cwd(&self.config.cwd, &ev.skills);
self.bottom_pane.set_skills(Some(skills));
}
pub(crate) fn open_review_popup(&mut self) {
let mut items: Vec<SelectionItem> = Vec::new();
@@ -3459,5 +3468,24 @@ pub(crate) fn show_review_commit_picker_with_entries(
});
}
fn skills_for_cwd(cwd: &Path, skills_entries: &[SkillsListEntry]) -> Vec<SkillMetadata> {
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;

View File

@@ -122,7 +122,6 @@ fn resumed_initial_messages_render_history() {
message: "assistant reply".to_string(),
}),
]),
skill_load_outcome: None,
rollout_path: rollout_file.path().to_path_buf(),
};

View File

@@ -1,6 +1,4 @@
# Skills (experimental)
> **Warning:** This is an experimental and non-stable feature. If you depend on it, please expect breaking changes over the coming weeks and understand that there is currently no guarantee that this works well. Use at your own risk!
# Skills
Codex can automatically discover reusable "skills" you keep on disk. A skill is a small bundle with a name, a short description (what it does and when to use it), and an optional body of instructions you can open when needed. Codex injects only the name, description, and file path into the runtime context; the body stays on disk.