Load selected executor skills through extensions (#27184)

## Why

CCA is moving toward a split runtime where the orchestrator may not have
a filesystem, while executors can expose preinstalled plugins and
skills. A thread therefore needs to select capabilities without asking
app-server or core to interpret executor-owned paths through the
orchestrator's filesystem.

The longer-term model is broader than executor skills:

- A plugin is a bundle of skills, MCP servers, connectors/apps, and
hooks.
- A plugin root can be local, executor-owned, or hosted by a backend.
- Components inside one plugin can use different access and execution
mechanisms. A skill may be read from a filesystem or through backend
tools; an HTTP MCP server can run without an executor; a stdio MCP
server or hook needs an execution environment.
- Core should carry generic extension initialization data. The extension
that owns a component should discover it, expose it to the model, and
invoke it through the appropriate runtime.

This PR establishes that architecture through one complete vertical:
selecting a root on an executor, discovering the skills beneath it,
exposing those skills to the model, and reading an explicitly invoked
`SKILL.md` through the same executor.

## Contract

`thread/start` gains an experimental `selectedCapabilityRoots` field:

```json
{
  "selectedCapabilityRoots": [
    {
      "id": "deploy-plugin@1",
      "location": {
        "type": "environment",
        "environmentId": "workspace",
        "path": "/opt/codex/plugins/deploy"
      }
    }
  ]
}
```

The root is intentionally not classified as a "plugin" or "skill" in the
API. It can point at a standalone skill, a directory containing several
skills, or a plugin containing skills and other components. This PR only
teaches the skills extension how to consume it; later extensions can
resolve MCP, connector, and hook components from the same selection.

The platform-supplied `id` is stable selection identity. The location
says which runtime owns the root and gives that runtime an opaque path.
App-server does not inspect or canonicalize the path.

## What changed

### Generic thread extension initialization

App-server converts selected roots into `ExtensionDataInit`. Core
carries that generic initialization value until the final thread ID is
known, then creates thread-scoped `ExtensionData` before lifecycle
contributors run.

This keeps `Session` and core independent of the capability-selection
contract. The initialization value is consumed during construction; it
is not retained as another long-lived `Session` field.

### Executor-backed skills

The skills extension now owns an `ExecutorSkillProvider` that:

- resolves the selected environment through `EnvironmentManager`
- discovers, canonicalizes, and reads skills through that environment's
`ExecutorFileSystem`
- contributes the bounded selected-skill catalog as stable developer
context
- reads an explicitly invoked skill body through the authority that
listed it
- warns when an environment or root is unavailable
- never falls back to the orchestrator filesystem for an executor-owned
root

Skill catalog and instruction fragments have hard byte bounds, which
also bound them below the 10K-token per-item context limit. If a
selected executor skill has the same name as a legacy local skill, the
executor selection owns that invocation and the local body is not
injected a second time.

Existing local and bundled skill loading remains in place. Omitting
`selectedCapabilityRoots` therefore preserves current local-only
behavior.

## Current semantics

- Only environment-owned locations are represented in this first
contract.
- Roots are resolved by the destination extension, not by app-server or
core.
- An unavailable executor or invalid root produces a warning and no
capabilities from that root; it does not trigger a local-filesystem
fallback.
- Selection applies to a newly started active thread.
- MCP servers, connectors, and hooks beneath a selected plugin root are
not activated yet.
- Selection is not yet persisted or inherited across resume, fork, or
subagent creation. Existing local capabilities continue to behave as
they do today in those flows.

## Planned vertical follow-ups

1. **Hosted HTTP MCP:** add an extension-backed HTTP MCP source that
works without an executor, then replace the special-purpose MCP plugins
loader with that implementation.
2. **Executor MCP:** register and execute stdio MCP servers through the
environment that owns the selected plugin root.
3. **Backend skills:** add a hosted skill source whose catalog and
bodies are accessed through extension tools rather than a filesystem.
4. **Connectors and hooks:** activate those components through their
owning extensions, using the same selected-root boundary and
component-specific runtime.
5. **Durable selection:** define the desired-selection lifecycle,
persist it, and make resume, fork, and subagent inheritance explicit
rather than accidental.
6. **Local convergence:** incrementally route existing local plugin,
skill, and MCP loading through the same extension model while preserving
current local behavior.

Each follow-up remains reviewable as an end-to-end capability. The
platform selects roots, generic thread extension data carries the
selection, and the owning extension resolves and operates its component.

## Verification

Coverage added for:

- app-server end-to-end discovery and explicit invocation of a skill
inside an executor-selected plugin root
- exclusive invocation when a selected executor skill collides with a
local skill name
- executor filesystem authority for discovery, canonicalization, and
reads
- thread extension initialization before lifecycle contributors run
- stable executor catalog context, explicit invocation, context
rebuilding, hidden skills, and preserved host/remote catalog behavior

Targeted protocol, core-skills, skills-extension, core lifecycle, and
app-server executor-skill tests were run during development.
This commit is contained in:
jif
2026-06-09 19:51:54 +02:00
committed by GitHub
parent 1026e9de1b
commit 89ac3ec27c
46 changed files with 1460 additions and 127 deletions
+1
View File
@@ -59,3 +59,4 @@ pub use registry::ExtensionRegistry;
pub use registry::ExtensionRegistryBuilder;
pub use registry::empty_extension_registry;
pub use state::ExtensionData;
pub use state::ExtensionDataInit;
+32 -1
View File
@@ -7,6 +7,32 @@ use std::sync::PoisonError;
type ErasedData = Arc<dyn Any + Send + Sync>;
/// Typed values supplied before an [`ExtensionData`] scope is created.
///
/// Hosts consume this value once to seed a scope before lifecycle contributors
/// run. It does not install extensions or provide persistence.
#[derive(Debug, Default)]
pub struct ExtensionDataInit {
entries: HashMap<TypeId, ErasedData>,
}
impl ExtensionDataInit {
/// Creates an empty extension data initializer.
pub fn new() -> Self {
Self::default()
}
/// Stores `value` as the initial attachment of type `T`.
pub fn insert<T>(&mut self, value: T) -> Option<Arc<T>>
where
T: Any + Send + Sync,
{
self.entries
.insert(TypeId::of::<T>(), Arc::new(value))
.map(downcast_data)
}
}
/// Typed extension-owned data attached to one host object.
#[derive(Debug)]
pub struct ExtensionData {
@@ -17,9 +43,14 @@ pub struct ExtensionData {
impl ExtensionData {
/// Creates an empty attachment map for one host-owned scope.
pub fn new(level_id: impl Into<String>) -> Self {
Self::new_with_init(level_id, ExtensionDataInit::default())
}
/// Creates an attachment map seeded with host-supplied initial data.
pub fn new_with_init(level_id: impl Into<String>, init: ExtensionDataInit) -> Self {
Self {
level_id: level_id.into(),
entries: Mutex::new(HashMap::new()),
entries: Mutex::new(init.entries),
}
}
+3
View File
@@ -17,8 +17,11 @@ workspace = true
async-trait = { workspace = true }
codex-core = { workspace = true }
codex-core-skills = { workspace = true }
codex-exec-server = { workspace = true }
codex-extension-api = { workspace = true }
codex-protocol = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-string = { workspace = true }
[dev-dependencies]
pretty_assertions = { workspace = true }
+32 -3
View File
@@ -1,4 +1,5 @@
use codex_core_skills::model::SkillDependencies;
use codex_exec_server::EnvironmentPathRef;
/// Source authority that owns a skill package and must be used to read it.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
@@ -56,9 +57,37 @@ impl SkillAuthority {
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct SkillPackageId(pub String);
/// Opaque resource id inside a skill package.
/// Opaque resource id inside a skill package, optionally bound to the
/// environment path that owns its contents.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct SkillResourceId(pub String);
pub struct SkillResourceId {
id: String,
environment_path: Option<EnvironmentPathRef>,
}
impl SkillResourceId {
pub fn new(id: impl Into<String>) -> Self {
Self {
id: id.into(),
environment_path: None,
}
}
pub fn environment(id: impl Into<String>, path: EnvironmentPathRef) -> Self {
Self {
id: id.into(),
environment_path: Some(path),
}
}
pub fn as_str(&self) -> &str {
&self.id
}
pub(crate) fn environment_path(&self) -> Option<&EnvironmentPathRef> {
self.environment_path.as_ref()
}
}
/// Metadata shown in the always-visible skills catalog.
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -125,7 +154,7 @@ impl SkillCatalogEntry {
pub(crate) fn rendered_path(&self) -> &str {
self.display_path
.as_deref()
.unwrap_or(self.main_prompt.0.as_str())
.unwrap_or_else(|| self.main_prompt.as_str())
}
}
+81 -23
View File
@@ -6,27 +6,32 @@ use codex_core_skills::SkillInstructions;
use codex_core_skills::injection::InjectedHostSkillPrompts;
use codex_core_skills::injection::SkillInjection;
use codex_extension_api::ConfigContributor;
use codex_extension_api::ContextContributor;
use codex_extension_api::ContextualUserFragment;
use codex_extension_api::ExtensionData;
use codex_extension_api::ExtensionEventSink;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::PromptFragment;
use codex_extension_api::ThreadLifecycleContributor;
use codex_extension_api::ThreadStartInput;
use codex_extension_api::TurnInputContext;
use codex_extension_api::TurnInputContributor;
use codex_protocol::capabilities::SelectedCapabilityRoot;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::WarningEvent;
use crate::catalog::SkillAuthority;
use crate::catalog::SkillCatalogEntry;
use crate::catalog::SkillReadResult;
use crate::catalog::SkillSourceKind;
use crate::provider::HostSkillProvider;
use crate::provider::SkillListQuery;
use crate::provider::SkillReadRequest;
use crate::render::MAX_SKILL_NAME_BYTES;
use crate::render::MAX_SKILL_PATH_BYTES;
use crate::render::available_skills_fragment;
use crate::render::truncate_main_prompt_contents;
use crate::render::truncate_utf8_to_bytes;
use crate::selection::collect_explicit_skill_mentions;
use crate::sources::SkillProviders;
use crate::state::SkillsExtensionConfig;
@@ -42,11 +47,15 @@ struct SkillsExtension {
#[async_trait::async_trait]
impl ThreadLifecycleContributor<Config> for SkillsExtension {
async fn on_thread_start(&self, input: ThreadStartInput<'_, Config>) {
input
let selected_roots = input
.thread_store
.insert(SkillsThreadState::new(SkillsExtensionConfig::from_config(
input.config,
)));
.get::<Vec<SelectedCapabilityRoot>>()
.map(|selected_roots| selected_roots.as_ref().clone())
.unwrap_or_default();
input.thread_store.insert(SkillsThreadState::new(
SkillsExtensionConfig::from_config(input.config),
selected_roots,
));
}
}
@@ -62,11 +71,47 @@ impl ConfigContributor<Config> for SkillsExtension {
if let Some(state) = thread_store.get::<SkillsThreadState>() {
state.set_config(next_config);
} else {
thread_store.insert(SkillsThreadState::new(next_config));
thread_store.insert(SkillsThreadState::new(next_config, Vec::new()));
}
}
}
impl ContextContributor for SkillsExtension {
fn contribute<'a>(
&'a self,
_session_store: &'a ExtensionData,
thread_store: &'a ExtensionData,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Vec<PromptFragment>> + Send + 'a>> {
Box::pin(async move {
let Some(thread_state) = thread_store.get::<SkillsThreadState>() else {
return Vec::new();
};
let config = thread_state.config();
if !config.include_instructions || thread_state.selected_roots().is_empty() {
return Vec::new();
}
let catalog = self
.providers
.list_for_turn(SkillListQuery {
turn_id: thread_store.level_id().to_string(),
executor_roots: thread_state.selected_roots().to_vec(),
host: None,
include_host_skills: false,
include_bundled_skills: config.bundled_skills_enabled,
include_remote_skills: false,
})
.await;
for warning in &catalog.warnings {
self.emit_warning(thread_store.level_id(), warning.clone());
}
available_skills_fragment(&catalog)
.map(|fragment| PromptFragment::developer_capability(fragment.render()))
.into_iter()
.collect()
})
}
}
#[async_trait::async_trait]
impl TurnInputContributor for SkillsExtension {
async fn contribute(
@@ -84,16 +129,7 @@ impl TurnInputContributor for SkillsExtension {
let host_loaded_skills = turn_store.get::<HostLoadedSkills>();
let query = SkillListQuery {
turn_id: input.turn_id.clone(),
executor_authorities: input
.environments
.iter()
.map(|environment| {
SkillAuthority::new(
SkillSourceKind::Executor,
environment.environment_id.clone(),
)
})
.collect(),
executor_roots: thread_state.selected_roots().to_vec(),
host: host_loaded_skills.clone(),
include_host_skills: true,
include_bundled_skills: config.bundled_skills_enabled,
@@ -106,10 +142,14 @@ impl TurnInputContributor for SkillsExtension {
let selected_entries = collect_explicit_skill_mentions(&input.user_input, &catalog);
let mut fragments: Vec<Box<dyn ContextualUserFragment + Send>> = Vec::new();
if config.include_instructions
&& let Some(fragment) = available_skills_fragment(&catalog)
{
fragments.push(Box::new(fragment));
if config.include_instructions {
let mut turn_catalog = catalog.clone();
turn_catalog
.entries
.retain(|entry| entry.authority.kind != SkillSourceKind::Executor);
if let Some(fragment) = available_skills_fragment(&turn_catalog) {
fragments.push(Box::new(fragment));
}
}
let mut warnings = catalog.warnings.clone();
@@ -132,14 +172,14 @@ impl TurnInputContributor for SkillsExtension {
warnings.push(warning);
}
let injection = SkillInjection {
name: entry.name.clone(),
path: entry.rendered_path().to_string(),
name: truncate_utf8_to_bytes(&entry.name, MAX_SKILL_NAME_BYTES).0,
path: truncate_utf8_to_bytes(entry.rendered_path(), MAX_SKILL_PATH_BYTES).0,
contents,
};
fragments.push(Box::new(SkillInstructions::from(&injection)));
main_prompts_injected = true;
if entry.authority.kind == SkillSourceKind::Host {
injected_host_skill_prompts.insert_path(entry.main_prompt.0.clone());
injected_host_skill_prompts.insert_path(entry.main_prompt.as_str());
}
}
Err(message) => {
@@ -150,6 +190,23 @@ impl TurnInputContributor for SkillsExtension {
}
}
if let Some(host_loaded_skills) = &host_loaded_skills {
for entry in selected_entries
.iter()
.filter(|entry| entry.authority.kind != SkillSourceKind::Host)
{
for host_skill in host_loaded_skills
.outcome()
.skills
.iter()
.filter(|host_skill| host_skill.name == entry.name)
{
injected_host_skill_prompts
.insert_path(host_skill.path_to_skills_md.to_string_lossy());
}
}
}
turn_store.insert(SkillsTurnState {
catalog,
selected_entries,
@@ -206,5 +263,6 @@ pub fn install_with_providers(
});
registry.thread_lifecycle_contributor(extension.clone());
registry.config_contributor(extension.clone());
registry.prompt_contributor(extension.clone());
registry.turn_input_contributor(extension);
}
+2
View File
@@ -8,6 +8,8 @@ mod state;
pub use extension::install;
pub use extension::install_with_providers;
pub use provider::ExecutorSkillProvider;
pub use provider::HostSkillProvider;
pub use provider::SkillProvider;
pub use sources::SkillProviderSource;
pub use sources::SkillProviders;
+4 -1
View File
@@ -2,9 +2,11 @@ use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
mod executor;
mod host;
use codex_core_skills::HostLoadedSkills;
use codex_protocol::capabilities::SelectedCapabilityRoot;
use crate::catalog::SkillAuthority;
use crate::catalog::SkillCatalog;
@@ -14,12 +16,13 @@ use crate::catalog::SkillReadResult;
use crate::catalog::SkillResourceId;
use crate::catalog::SkillSearchResult;
pub use executor::ExecutorSkillProvider;
pub use host::HostSkillProvider;
#[derive(Clone, Debug)]
pub struct SkillListQuery {
pub turn_id: String,
pub executor_authorities: Vec<SkillAuthority>,
pub executor_roots: Vec<SelectedCapabilityRoot>,
pub host: Option<Arc<HostLoadedSkills>>,
pub include_host_skills: bool,
pub include_bundled_skills: bool,
@@ -0,0 +1,197 @@
use std::path::PathBuf;
use std::sync::Arc;
use codex_core_skills::SkillMetadata;
use codex_core_skills::filter_skill_load_outcome_for_product;
use codex_core_skills::loader::SkillRoot;
use codex_core_skills::loader::load_skills_from_roots;
use codex_exec_server::EnvironmentManager;
use codex_exec_server::EnvironmentPathRef;
use codex_protocol::capabilities::CapabilityRootLocation;
use codex_protocol::protocol::Product;
use codex_protocol::protocol::SkillScope;
use codex_utils_absolute_path::AbsolutePathBuf;
use crate::catalog::SkillAuthority;
use crate::catalog::SkillCatalog;
use crate::catalog::SkillCatalogEntry;
use crate::catalog::SkillPackageId;
use crate::catalog::SkillProviderError;
use crate::catalog::SkillReadResult;
use crate::catalog::SkillResourceId;
use crate::catalog::SkillSearchResult;
use crate::catalog::SkillSourceKind;
use crate::provider::SkillListQuery;
use crate::provider::SkillProvider;
use crate::provider::SkillProviderFuture;
use crate::provider::SkillReadRequest;
use crate::provider::SkillSearchRequest;
/// Discovers and reads skills through the filesystem owned by an execution environment.
#[derive(Clone, Debug)]
pub struct ExecutorSkillProvider {
environment_manager: Arc<EnvironmentManager>,
restriction_product: Option<Product>,
}
impl ExecutorSkillProvider {
pub fn new_with_restriction_product(
environment_manager: Arc<EnvironmentManager>,
restriction_product: Option<Product>,
) -> Self {
Self {
environment_manager,
restriction_product,
}
}
}
impl SkillProvider for ExecutorSkillProvider {
fn list(&self, query: SkillListQuery) -> SkillProviderFuture<'_, SkillCatalog> {
Box::pin(async move {
let mut catalog = SkillCatalog::default();
for selected_root in query.executor_roots {
let selected_root_id = selected_root.id;
let CapabilityRootLocation::Environment {
environment_id,
path,
} = selected_root.location;
let authority =
SkillAuthority::new(SkillSourceKind::Executor, selected_root_id.clone());
let Some(environment) = self.environment_manager.get_environment(&environment_id)
else {
catalog.warnings.push(format!(
"Selected capability root `{selected_root_id}` references unavailable environment `{environment_id}`."
));
continue;
};
let root_path = match executor_absolute_path(&path) {
Ok(root_path) => root_path,
Err(err) => {
catalog.warnings.push(format!(
"Selected capability root `{selected_root_id}` has invalid path `{path}`: {err}"
));
continue;
}
};
let file_system = environment.get_filesystem();
let outcome = filter_skill_load_outcome_for_product(
load_skills_from_roots([SkillRoot {
path: root_path.clone(),
scope: SkillScope::User,
file_system: Arc::clone(&file_system),
plugin_id: None,
plugin_root: None,
}])
.await,
self.restriction_product,
);
catalog.warnings.extend(outcome.errors.iter().map(|err| {
format!(
"Failed to load executor skill at {}: {}",
err.path.display(),
err.message
)
}));
for (skill, enabled) in outcome.skills_with_enabled() {
catalog.push_entry(catalog_entry_from_skill(
skill,
enabled,
authority.clone(),
&selected_root_id,
Arc::clone(&file_system),
));
}
}
Ok(catalog)
})
}
fn read(&self, request: SkillReadRequest) -> SkillProviderFuture<'_, SkillReadResult> {
Box::pin(async move {
if request.authority.kind != SkillSourceKind::Executor {
return Err(SkillProviderError::new(format!(
"executor skill provider cannot read {} resources",
request.authority.kind
)));
}
if request.package.0 != request.resource.as_str() {
return Err(SkillProviderError::new(
"executor skill resource does not match its package",
));
}
let Some(resource_path) = request.resource.environment_path() else {
return Err(SkillProviderError::new(
"executor skill resource is not bound to an environment",
));
};
let contents = resource_path
.read_to_string(/*sandbox*/ None)
.await
.map_err(|err| {
SkillProviderError::new(format!(
"failed to read executor skill resource {}: {err}",
request.resource.as_str()
))
})?;
Ok(SkillReadResult {
resource: request.resource,
contents,
})
})
}
fn search(&self, _request: SkillSearchRequest) -> SkillProviderFuture<'_, SkillSearchResult> {
Box::pin(async { Ok(SkillSearchResult::default()) })
}
}
fn catalog_entry_from_skill(
skill: &SkillMetadata,
enabled: bool,
authority: SkillAuthority,
selected_root_id: &str,
file_system: Arc<dyn codex_exec_server::ExecutorFileSystem>,
) -> SkillCatalogEntry {
let skill_path = skill.path_to_skills_md.to_string_lossy().into_owned();
let normalized_path = skill_path.replace('\\', "/");
let display_path = format!(
"skill://{selected_root_id}/{}",
normalized_path.trim_start_matches('/')
);
let mut entry = SkillCatalogEntry::new(
SkillPackageId(display_path.clone()),
authority,
skill.name.clone(),
skill.description.clone(),
SkillResourceId::environment(
display_path.clone(),
EnvironmentPathRef::new(file_system, skill.path_to_skills_md.clone()),
),
)
.with_short_description(skill.short_description.clone())
.with_display_path(display_path)
.with_dependencies(skill.dependencies.clone());
if !enabled {
entry = entry.disabled();
}
if !skill.allows_implicit_invocation() {
entry = entry.hidden_from_prompt();
}
entry
}
fn executor_absolute_path(path: &str) -> std::io::Result<AbsolutePathBuf> {
let path = PathBuf::from(path);
if !path.is_absolute() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"executor path must be absolute",
));
}
AbsolutePathBuf::from_absolute_path_checked(path)
}
+5 -5
View File
@@ -55,12 +55,12 @@ impl SkillProvider for HostSkillProvider {
};
let Some(skill) = host_loaded_skills.outcome().skills.iter().find(|skill| {
let skill_path = skill.path_to_skills_md.to_string_lossy();
skill_path == request.resource.0.as_str()
|| skill_path.replace('\\', "/") == request.resource.0
skill_path == request.resource.as_str()
|| skill_path.replace('\\', "/") == request.resource.as_str()
}) else {
return Err(SkillProviderError::new(format!(
"host skill resource is not loaded: {}",
request.resource.0
request.resource.as_str()
)));
};
@@ -70,7 +70,7 @@ impl SkillProvider for HostSkillProvider {
.map_err(|err| {
SkillProviderError::new(format!(
"failed to read host skill resource {}: {err}",
request.resource.0
request.resource.as_str()
))
})?;
@@ -117,7 +117,7 @@ fn catalog_entry_from_skill(skill: &SkillMetadata, enabled: bool) -> SkillCatalo
SkillAuthority::new(SkillSourceKind::Host, HOST_AUTHORITY_ID),
skill.name.clone(),
skill.description.clone(),
SkillResourceId(skill_path),
SkillResourceId::new(skill_path),
)
.with_short_description(skill.short_description.clone())
.with_display_path(display_path)
+15 -14
View File
@@ -2,11 +2,14 @@ use codex_core_skills::render_available_skills_body;
use codex_extension_api::ContextualUserFragment;
use codex_protocol::protocol::SKILLS_INSTRUCTIONS_CLOSE_TAG;
use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG;
use codex_utils_string::take_bytes_at_char_boundary;
use crate::catalog::SkillCatalog;
const MAX_AVAILABLE_SKILLS_CHARS: usize = 8_000;
const MAX_MAIN_PROMPT_CHARS: usize = 40_000;
const MAX_AVAILABLE_SKILLS_BYTES: usize = 8_000;
const MAX_MAIN_PROMPT_BYTES: usize = 8_000;
pub(crate) const MAX_SKILL_NAME_BYTES: usize = 256;
pub(crate) const MAX_SKILL_PATH_BYTES: usize = 1_024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct AvailableSkillsFragment {
@@ -32,7 +35,7 @@ impl ContextualUserFragment for AvailableSkillsFragment {
}
pub(crate) fn available_skills_fragment(catalog: &SkillCatalog) -> Option<AvailableSkillsFragment> {
let mut total_chars = 0usize;
let mut total_bytes = 0usize;
let mut omitted = 0usize;
let mut skill_lines = Vec::new();
@@ -46,12 +49,12 @@ pub(crate) fn available_skills_fragment(catalog: &SkillCatalog) -> Option<Availa
.as_deref()
.unwrap_or(entry.description.as_str());
let line = render_skill_line(entry.name.as_str(), description, entry.rendered_path());
let next_chars = total_chars.saturating_add(line.chars().count());
if next_chars > MAX_AVAILABLE_SKILLS_CHARS {
let next_bytes = total_bytes.saturating_add(line.len());
if next_bytes > MAX_AVAILABLE_SKILLS_BYTES {
omitted = omitted.saturating_add(1);
continue;
}
total_chars = next_chars;
total_bytes = next_bytes;
skill_lines.push(line);
}
@@ -79,12 +82,10 @@ fn render_skill_line(name: &str, description: &str, path: &str) -> String {
}
pub(crate) fn truncate_main_prompt_contents(contents: &str) -> (String, bool) {
let mut chars = 0usize;
for (index, _) in contents.char_indices() {
if chars == MAX_MAIN_PROMPT_CHARS {
return (contents[..index].to_string(), true);
}
chars = chars.saturating_add(1);
}
(contents.to_string(), false)
truncate_utf8_to_bytes(contents, MAX_MAIN_PROMPT_BYTES)
}
pub(crate) fn truncate_utf8_to_bytes(contents: &str, max_bytes: usize) -> (String, bool) {
let truncated = take_bytes_at_char_boundary(contents, max_bytes);
(truncated.to_string(), truncated.len() < contents.len())
}
+2 -2
View File
@@ -93,12 +93,12 @@ fn push_selected(
}
fn entry_matches_path(entry: &SkillCatalogEntry, path: &str) -> bool {
entry.main_prompt.0 == path
entry.main_prompt.as_str() == path
|| entry.id.0 == path
|| entry
.display_path
.as_deref()
.is_some_and(|display_path| display_path == path)
.is_some_and(|display_path| normalize_skill_path(display_path) == path)
}
fn path_is_skill(path: &str) -> bool {
+1 -1
View File
@@ -46,7 +46,7 @@ impl SkillProviderSource {
fn should_list(&self, query: &SkillListQuery) -> bool {
match &self.kind {
SkillSourceKind::Host => query.include_host_skills,
SkillSourceKind::Executor => !query.executor_authorities.is_empty(),
SkillSourceKind::Executor => !query.executor_roots.is_empty(),
SkillSourceKind::Remote => query.include_remote_skills,
SkillSourceKind::Custom(_) => true,
}
+11 -1
View File
@@ -1,4 +1,5 @@
use codex_core::config::Config;
use codex_protocol::capabilities::SelectedCapabilityRoot;
use std::sync::Mutex;
use crate::catalog::SkillCatalog;
@@ -22,12 +23,17 @@ impl SkillsExtensionConfig {
#[derive(Debug)]
pub(crate) struct SkillsThreadState {
config: Mutex<SkillsExtensionConfig>,
selected_roots: Vec<SelectedCapabilityRoot>,
}
impl SkillsThreadState {
pub(crate) fn new(config: SkillsExtensionConfig) -> Self {
pub(crate) fn new(
config: SkillsExtensionConfig,
selected_roots: Vec<SelectedCapabilityRoot>,
) -> Self {
Self {
config: Mutex::new(config),
selected_roots,
}
}
@@ -44,6 +50,10 @@ impl SkillsThreadState {
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = config;
}
pub(crate) fn selected_roots(&self) -> &[SelectedCapabilityRoot] {
&self.selected_roots
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
@@ -0,0 +1,337 @@
use std::io;
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use async_trait::async_trait;
use codex_core_skills::HostLoadedSkills;
use codex_core_skills::loader::SkillRoot;
use codex_core_skills::loader::load_skills_from_roots;
use codex_exec_server::CopyOptions;
use codex_exec_server::CreateDirectoryOptions;
use codex_exec_server::EnvironmentManager;
use codex_exec_server::ExecutorFileSystem;
use codex_exec_server::FileMetadata;
use codex_exec_server::FileSystemResult;
use codex_exec_server::FileSystemSandboxContext;
use codex_exec_server::ReadDirectoryEntry;
use codex_exec_server::RemoveOptions;
use codex_protocol::capabilities::CapabilityRootLocation;
use codex_protocol::capabilities::SelectedCapabilityRoot;
use codex_protocol::protocol::SkillScope;
use codex_skills_extension::ExecutorSkillProvider;
use codex_skills_extension::catalog::SkillReadResult;
use codex_skills_extension::provider::SkillListQuery;
use codex_skills_extension::provider::SkillProvider;
use codex_skills_extension::provider::SkillReadRequest;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
const SKILL_CONTENTS: &str =
"---\nname: synthetic\ndescription: Synthetic executor skill.\n---\n\nEXECUTOR_ONLY_BODY\n";
static NEXT_TEST_ROOT_ID: AtomicUsize = AtomicUsize::new(0);
struct SyntheticFileSystem {
alias_root: AbsolutePathBuf,
canonical_root: AbsolutePathBuf,
}
impl SyntheticFileSystem {
fn metadata(&self, path: &AbsolutePathBuf) -> io::Result<FileMetadata> {
let skill_dir = self.canonical_root.join("skill");
let skill_path = skill_dir.join("SKILL.md");
let (is_directory, is_file) = if path == &self.canonical_root || path == &skill_dir {
(true, false)
} else if path == &skill_path {
(false, true)
} else {
return Err(io::Error::new(io::ErrorKind::NotFound, "not found"));
};
Ok(FileMetadata {
is_directory,
is_file,
is_symlink: false,
created_at_ms: 0,
modified_at_ms: 0,
})
}
}
#[async_trait]
impl ExecutorFileSystem for SyntheticFileSystem {
async fn canonicalize(
&self,
path: &AbsolutePathBuf,
_sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<AbsolutePathBuf> {
if path == &self.alias_root {
return Ok(self.canonical_root.clone());
}
self.metadata(path)?;
Ok(path.clone())
}
async fn join(
&self,
base_path: &AbsolutePathBuf,
path: &Path,
) -> FileSystemResult<AbsolutePathBuf> {
Ok(base_path.join(path))
}
async fn parent(&self, path: &AbsolutePathBuf) -> FileSystemResult<Option<AbsolutePathBuf>> {
Ok(path.parent())
}
async fn read_file(
&self,
path: &AbsolutePathBuf,
_sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<Vec<u8>> {
if path == &self.canonical_root.join("skill/SKILL.md") {
Ok(SKILL_CONTENTS.as_bytes().to_vec())
} else {
Err(io::Error::new(io::ErrorKind::NotFound, "not found"))
}
}
async fn write_file(
&self,
_path: &AbsolutePathBuf,
_contents: Vec<u8>,
_sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<()> {
Err(io::Error::new(io::ErrorKind::Unsupported, "read only"))
}
async fn create_directory(
&self,
_path: &AbsolutePathBuf,
_options: CreateDirectoryOptions,
_sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<()> {
Err(io::Error::new(io::ErrorKind::Unsupported, "read only"))
}
async fn get_metadata(
&self,
path: &AbsolutePathBuf,
_sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<FileMetadata> {
self.metadata(path)
}
async fn read_directory(
&self,
path: &AbsolutePathBuf,
_sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<Vec<ReadDirectoryEntry>> {
if path == &self.canonical_root {
Ok(vec![ReadDirectoryEntry {
file_name: "skill".to_string(),
is_directory: true,
is_file: false,
}])
} else if path == &self.canonical_root.join("skill") {
Ok(vec![ReadDirectoryEntry {
file_name: "SKILL.md".to_string(),
is_directory: false,
is_file: true,
}])
} else {
Err(io::Error::new(io::ErrorKind::NotFound, "not found"))
}
}
async fn remove(
&self,
_path: &AbsolutePathBuf,
_options: RemoveOptions,
_sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<()> {
Err(io::Error::new(io::ErrorKind::Unsupported, "read only"))
}
async fn copy(
&self,
_source_path: &AbsolutePathBuf,
_destination_path: &AbsolutePathBuf,
_options: CopyOptions,
_sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<()> {
Err(io::Error::new(io::ErrorKind::Unsupported, "read only"))
}
}
#[tokio::test]
async fn skill_loading_and_reads_use_the_supplied_executor_file_system() {
let test_root =
std::env::temp_dir().join(format!("codex-executor-skill-fs-{}", std::process::id()));
let alias_root = AbsolutePathBuf::from_absolute_path_checked(test_root.join("alias"))
.expect("absolute path");
let canonical_root = AbsolutePathBuf::from_absolute_path_checked(test_root.join("canonical"))
.expect("absolute path");
assert!(!alias_root.as_path().exists());
assert!(!canonical_root.as_path().exists());
let outcome = load_skills_from_roots([SkillRoot {
path: alias_root.clone(),
scope: SkillScope::User,
file_system: Arc::new(SyntheticFileSystem {
alias_root,
canonical_root: canonical_root.clone(),
}),
plugin_id: None,
plugin_root: None,
}])
.await;
assert_eq!(outcome.errors, Vec::new());
assert_eq!(outcome.skills.len(), 1);
let skill = outcome.skills[0].clone();
assert_eq!(skill.name, "synthetic");
assert_eq!(
skill.path_to_skills_md,
canonical_root.join("skill/SKILL.md")
);
let loaded = HostLoadedSkills::new(Arc::new(outcome));
assert_eq!(
loaded.read_skill_text(&skill).await.expect("skill body"),
SKILL_CONTENTS
);
}
#[tokio::test]
async fn executor_provider_reads_from_the_environment_instance_used_for_listing() {
let test_root = create_local_skill_root("bound-instance").expect("create local skill root");
let root_path = test_root.to_string_lossy().into_owned();
let environment_manager = Arc::new(EnvironmentManager::default_for_tests());
let provider = ExecutorSkillProvider::new_with_restriction_product(
Arc::clone(&environment_manager),
/*restriction_product*/ None,
);
let catalog = provider
.list(SkillListQuery {
turn_id: "turn-1".to_string(),
executor_roots: vec![SelectedCapabilityRoot {
id: "root-a".to_string(),
location: CapabilityRootLocation::Environment {
environment_id: "local".to_string(),
path: root_path,
},
}],
host: None,
include_host_skills: false,
include_bundled_skills: true,
include_remote_skills: false,
})
.await
.expect("list executor skills");
let entry = catalog
.entries
.into_iter()
.next()
.expect("listed executor skill");
let resource = entry.main_prompt.clone();
environment_manager
.upsert_environment("local".to_string(), "http://127.0.0.1:1".to_string())
.expect("replace environment");
assert_eq!(
provider
.read(SkillReadRequest {
authority: entry.authority,
package: entry.id,
resource: resource.clone(),
host: None,
})
.await
.expect("read bound executor skill"),
SkillReadResult {
resource,
contents: SKILL_CONTENTS.to_string(),
}
);
std::fs::remove_dir_all(test_root).expect("remove skill directory");
}
#[tokio::test]
async fn selected_root_id_distinguishes_identical_executor_paths() {
let test_root = create_local_skill_root("root-identity").expect("create local skill root");
let root_path = test_root.to_string_lossy().into_owned();
let canonical_root = AbsolutePathBuf::from_absolute_path_checked(&test_root)
.expect("absolute skill root")
.canonicalize()
.expect("canonicalize skill root")
.to_string_lossy()
.replace('\\', "/");
let provider = ExecutorSkillProvider::new_with_restriction_product(
Arc::new(EnvironmentManager::default_for_tests()),
/*restriction_product*/ None,
);
let catalog = provider
.list(SkillListQuery {
turn_id: "turn-1".to_string(),
executor_roots: ["root-a", "root-b"]
.into_iter()
.map(|id| SelectedCapabilityRoot {
id: id.to_string(),
location: CapabilityRootLocation::Environment {
environment_id: "local".to_string(),
path: root_path.clone(),
},
})
.collect(),
host: None,
include_host_skills: false,
include_bundled_skills: true,
include_remote_skills: false,
})
.await
.expect("list executor skills");
assert_eq!(
catalog
.entries
.iter()
.map(|entry| (
entry.authority.id.clone(),
entry.display_path.clone().expect("display path"),
))
.collect::<Vec<_>>(),
vec![
(
"root-a".to_string(),
format!(
"skill://root-a/{}/skill/SKILL.md",
canonical_root.trim_start_matches('/')
),
),
(
"root-b".to_string(),
format!(
"skill://root-b/{}/skill/SKILL.md",
canonical_root.trim_start_matches('/')
),
),
]
);
std::fs::remove_dir_all(test_root).expect("remove skill directory");
}
fn create_local_skill_root(label: &str) -> io::Result<std::path::PathBuf> {
let id = NEXT_TEST_ROOT_ID.fetch_add(1, Ordering::Relaxed);
let test_root = std::env::temp_dir().join(format!(
"codex-executor-skill-{label}-{}-{id}",
std::process::id()
));
let skill_dir = test_root.join("skill");
std::fs::create_dir_all(&skill_dir)?;
std::fs::write(skill_dir.join("SKILL.md"), SKILL_CONTENTS)?;
Ok(test_root)
}
+135 -64
View File
@@ -14,7 +14,8 @@ use codex_extension_api::ExtensionData;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::ThreadStartInput;
use codex_extension_api::TurnInputContext;
use codex_extension_api::TurnInputEnvironment;
use codex_protocol::capabilities::CapabilityRootLocation;
use codex_protocol::capabilities::SelectedCapabilityRoot;
use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG;
use codex_protocol::protocol::SessionSource;
use codex_protocol::user_input::UserInput;
@@ -112,6 +113,7 @@ async fn installed_extension_loads_host_skills_from_legacy_roots() -> TestResult
.await;
assert_eq!(2, fragments.len());
assert_eq!("developer", fragments[0].role());
assert!(fragments[0].render().contains("demo"));
assert!(fragments[0].render().contains(&skill_prompt_path));
assert_eq!("user", fragments[1].role());
@@ -128,42 +130,35 @@ async fn installed_extension_loads_host_skills_from_legacy_roots() -> TestResult
}
#[tokio::test]
async fn installed_extension_injects_available_catalog_and_selected_entrypoint() -> TestResult {
let host_read_requests = Arc::new(Mutex::new(Vec::new()));
let remote_read_requests = Arc::new(Mutex::new(Vec::new()));
let host_provider = Arc::new(StaticSkillProvider {
async fn selected_executor_catalog_is_context_and_selected_entrypoint_is_turn_input() -> TestResult
{
let read_requests = Arc::new(Mutex::new(Vec::new()));
let executor_provider = Arc::new(StaticSkillProvider {
catalog: SkillCatalog {
entries: vec![test_entry(
SkillSourceKind::Host,
"host",
"host/lint-fix",
SkillSourceKind::Executor,
"env-1",
"executor/lint-fix",
"lint-fix/SKILL.md",
)],
warnings: Vec::new(),
},
read_requests: Arc::clone(&host_read_requests),
read_requests: Arc::clone(&read_requests),
});
let remote_provider = Arc::new(StaticSkillProvider {
catalog: SkillCatalog {
entries: vec![test_entry(
SkillSourceKind::Remote,
"remote",
"remote/lint-fix",
"lint-fix/SKILL.md",
)],
warnings: Vec::new(),
},
read_requests: Arc::clone(&remote_read_requests),
});
let providers = SkillProviders::new()
.with_host_provider(host_provider)
.with_remote_provider(remote_provider);
let providers = SkillProviders::new().with_executor_provider(executor_provider);
let mut builder = ExtensionRegistryBuilder::new();
install_with_providers(&mut builder, providers);
let registry = builder.build();
let session_store = ExtensionData::new("session");
let thread_store = ExtensionData::new("thread");
thread_store.insert(vec![SelectedCapabilityRoot {
id: "lint-fix".to_string(),
location: CapabilityRootLocation::Environment {
environment_id: "env-1".to_string(),
path: "/skills/lint-fix".to_string(),
},
}]);
let session_source = SessionSource::Cli;
let config = default_config().await?;
registry.thread_lifecycle_contributors()[0]
@@ -176,6 +171,17 @@ async fn installed_extension_injects_available_catalog_and_selected_entrypoint()
})
.await;
let prompt_fragments = registry.context_contributors()[0]
.contribute(&session_store, &thread_store)
.await;
assert_eq!(1, prompt_fragments.len());
assert!(
prompt_fragments[0]
.text()
.starts_with(SKILLS_INSTRUCTIONS_OPEN_TAG)
);
assert!(prompt_fragments[0].text().contains("lint-fix"));
let turn_store = ExtensionData::new("turn-1");
let fragments = registry.turn_input_contributors()[0]
.contribute(
@@ -185,11 +191,7 @@ async fn installed_extension_injects_available_catalog_and_selected_entrypoint()
text: "$lint-fix please".to_string(),
text_elements: Vec::new(),
}],
environments: vec![TurnInputEnvironment {
environment_id: "env-1".to_string(),
cwd: std::env::temp_dir(),
is_primary: true,
}],
environments: Vec::new(),
},
&session_store,
&thread_store,
@@ -197,31 +199,23 @@ async fn installed_extension_injects_available_catalog_and_selected_entrypoint()
)
.await;
assert_eq!(2, fragments.len());
assert_eq!("developer", fragments[0].role());
assert!(
fragments[0]
.render()
.starts_with(SKILLS_INSTRUCTIONS_OPEN_TAG)
);
assert!(fragments[0].render().contains("lint-fix"));
assert_eq!("user", fragments[1].role());
assert!(fragments[1].render().contains("<name>lint-fix</name>"));
assert!(fragments[1].render().contains("# Lint Fix"));
assert_eq!(1, fragments.len());
assert_eq!("user", fragments[0].role());
assert!(fragments[0].render().contains("<name>lint-fix</name>"));
assert!(fragments[0].render().contains("# Lint Fix"));
assert_eq!(
vec![(
SkillAuthority::new(SkillSourceKind::Host, "host"),
SkillPackageId("host/lint-fix".to_string()),
SkillResourceId("lint-fix/SKILL.md".to_string()),
SkillAuthority::new(SkillSourceKind::Executor, "env-1"),
SkillPackageId("executor/lint-fix".to_string()),
SkillResourceId::new("lint-fix/SKILL.md"),
)],
read_request_keys(&host_read_requests)
);
assert!(
remote_read_requests
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.is_empty()
read_request_keys(&read_requests)
);
let rebuilt_prompt_fragments = registry.context_contributors()[0]
.contribute(&session_store, &thread_store)
.await;
assert_eq!(1, rebuilt_prompt_fragments.len());
assert!(rebuilt_prompt_fragments[0].text().contains("lint-fix"));
let next_turn_store = ExtensionData::new("turn-2");
let next_fragments = registry.turn_input_contributors()[0]
@@ -240,9 +234,91 @@ async fn installed_extension_injects_available_catalog_and_selected_entrypoint()
)
.await;
assert_eq!(1, next_fragments.len());
assert_eq!("developer", next_fragments[0].role());
assert!(next_fragments[0].render().contains("lint-fix"));
assert!(next_fragments.is_empty());
Ok(())
}
#[tokio::test]
async fn root_qualified_locator_selects_only_the_matching_executor_skill() -> TestResult {
let read_requests = Arc::new(Mutex::new(Vec::new()));
let root_a_locator = "skill://root-a/shared/lint-fix/SKILL.md";
let root_b_locator = "skill://root-b/shared/lint-fix/SKILL.md";
let executor_provider = Arc::new(StaticSkillProvider {
catalog: SkillCatalog {
entries: [("root-a", root_a_locator), ("root-b", root_b_locator)]
.into_iter()
.map(|(root_id, locator)| {
SkillCatalogEntry::new(
SkillPackageId(locator.to_string()),
SkillAuthority::new(SkillSourceKind::Executor, root_id),
"lint-fix",
"Fix lint errors.",
SkillResourceId::new(locator),
)
.with_display_path(locator)
})
.collect(),
warnings: Vec::new(),
},
read_requests: Arc::clone(&read_requests),
});
let providers = SkillProviders::new().with_executor_provider(executor_provider);
let mut builder = ExtensionRegistryBuilder::new();
install_with_providers(&mut builder, providers);
let registry = builder.build();
let session_store = ExtensionData::new("session");
let thread_store = ExtensionData::new("thread");
thread_store.insert(
[("root-a", "/skills/root-a"), ("root-b", "/skills/root-b")]
.into_iter()
.map(|(id, path)| SelectedCapabilityRoot {
id: id.to_string(),
location: CapabilityRootLocation::Environment {
environment_id: "env-1".to_string(),
path: path.to_string(),
},
})
.collect::<Vec<_>>(),
);
let session_source = SessionSource::Cli;
let config = default_config().await?;
registry.thread_lifecycle_contributors()[0]
.on_thread_start(ThreadStartInput {
config: &config,
session_source: &session_source,
persistent_thread_state_available: true,
session_store: &session_store,
thread_store: &thread_store,
})
.await;
let fragments = registry.turn_input_contributors()[0]
.contribute(
TurnInputContext {
turn_id: "turn-1".to_string(),
user_input: vec![UserInput::Mention {
name: "lint-fix".to_string(),
path: root_b_locator.to_string(),
}],
environments: Vec::new(),
},
&session_store,
&thread_store,
&ExtensionData::new("turn-1"),
)
.await;
assert_eq!(1, fragments.len());
assert!(fragments[0].render().contains(root_b_locator));
assert_eq!(
vec![(
SkillAuthority::new(SkillSourceKind::Executor, "root-b"),
SkillPackageId(root_b_locator.to_string()),
SkillResourceId::new(root_b_locator),
)],
read_request_keys(&read_requests)
);
Ok(())
}
@@ -306,15 +382,14 @@ async fn prompt_hidden_skill_can_still_be_invoked() -> TestResult {
.await;
assert_eq!(2, fragments.len());
let catalog_fragment = fragments[0].render();
assert!(catalog_fragment.contains("visible-skill"));
assert!(!catalog_fragment.contains("hidden-skill"));
assert!(fragments[0].render().contains("visible-skill"));
assert!(!fragments[0].render().contains("hidden-skill"));
assert!(fragments[1].render().contains("<name>hidden-skill</name>"));
assert_eq!(
vec![(
SkillAuthority::new(SkillSourceKind::Host, "host"),
SkillPackageId("host/hidden-skill".to_string()),
SkillResourceId("hidden-skill/SKILL.md".to_string()),
SkillResourceId::new("hidden-skill/SKILL.md"),
)],
read_request_keys(&read_requests)
);
@@ -329,13 +404,9 @@ struct StaticSkillProvider {
}
impl SkillProvider for StaticSkillProvider {
fn list(&self, query: SkillListQuery) -> SkillProviderFuture<'_, SkillCatalog> {
fn list(&self, _query: SkillListQuery) -> SkillProviderFuture<'_, SkillCatalog> {
let catalog = self.catalog.clone();
Box::pin(async move {
assert!(query.include_host_skills);
assert!(query.include_bundled_skills);
Ok(catalog)
})
Box::pin(async move { Ok(catalog) })
}
fn read(&self, request: SkillReadRequest) -> SkillProviderFuture<'_, SkillReadResult> {
@@ -369,7 +440,7 @@ fn test_entry(
SkillAuthority::new(kind, authority_id),
name,
"Fix lint errors.",
SkillResourceId(main_prompt.to_string()),
SkillResourceId::new(main_prompt),
)
.with_display_path(format!("skill://{package_id}/SKILL.md"))
}