mirror of
https://github.com/openai/codex.git
synced 2026-04-21 05:04:49 +00:00
Compare commits
28 Commits
starr/exec
...
skills/cra
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1b99210a4 | ||
|
|
b8c6393001 | ||
|
|
8932689519 | ||
|
|
99877f6f4d | ||
|
|
83bd182dbd | ||
|
|
af4c07b59e | ||
|
|
cd240d93c9 | ||
|
|
a8a5235ff4 | ||
|
|
10a20778a7 | ||
|
|
2fd9278a9c | ||
|
|
b86276e4dd | ||
|
|
94710d401c | ||
|
|
21a359eee8 | ||
|
|
6ac900908b | ||
|
|
2ce7a19996 | ||
|
|
cff25a4b80 | ||
|
|
d46d7161b3 | ||
|
|
e4b154f9ff | ||
|
|
079c083716 | ||
|
|
8f934b7988 | ||
|
|
09d4ce896d | ||
|
|
143ca2120a | ||
|
|
1661767afb | ||
|
|
7cc6d8ad19 | ||
|
|
e8b35201fe | ||
|
|
67f0731f23 | ||
|
|
ed70364a71 | ||
|
|
8dc4380448 |
82
codex-rs/Cargo.lock
generated
82
codex-rs/Cargo.lock
generated
@@ -1384,6 +1384,22 @@ version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9b18233253483ce2f65329a24072ec414db782531bdbb7d0bbc4bd2ce6b7e21"
|
||||
|
||||
[[package]]
|
||||
name = "codex-analytics"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"codex-git-utils",
|
||||
"codex-login",
|
||||
"codex-plugin",
|
||||
"codex-protocol",
|
||||
"pretty_assertions",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-ansi-escape"
|
||||
version = "0.0.0"
|
||||
@@ -1830,6 +1846,7 @@ dependencies = [
|
||||
"futures",
|
||||
"multimap",
|
||||
"pretty_assertions",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
@@ -1869,6 +1886,7 @@ dependencies = [
|
||||
"chardetng",
|
||||
"chrono",
|
||||
"clap",
|
||||
"codex-analytics",
|
||||
"codex-api",
|
||||
"codex-app-server-protocol",
|
||||
"codex-apply-patch",
|
||||
@@ -1878,14 +1896,17 @@ dependencies = [
|
||||
"codex-code-mode",
|
||||
"codex-config",
|
||||
"codex-connectors",
|
||||
"codex-core-skills",
|
||||
"codex-exec-server",
|
||||
"codex-execpolicy",
|
||||
"codex-features",
|
||||
"codex-git-utils",
|
||||
"codex-hooks",
|
||||
"codex-instructions",
|
||||
"codex-login",
|
||||
"codex-network-proxy",
|
||||
"codex-otel",
|
||||
"codex-plugin",
|
||||
"codex-protocol",
|
||||
"codex-rmcp-client",
|
||||
"codex-rollout",
|
||||
@@ -1893,7 +1914,6 @@ dependencies = [
|
||||
"codex-secrets",
|
||||
"codex-shell-command",
|
||||
"codex-shell-escalation",
|
||||
"codex-skills",
|
||||
"codex-state",
|
||||
"codex-terminal-detection",
|
||||
"codex-test-macros",
|
||||
@@ -1904,6 +1924,7 @@ dependencies = [
|
||||
"codex-utils-image",
|
||||
"codex-utils-output-truncation",
|
||||
"codex-utils-path",
|
||||
"codex-utils-plugins",
|
||||
"codex-utils-pty",
|
||||
"codex-utils-readiness",
|
||||
"codex-utils-stream-parser",
|
||||
@@ -1942,7 +1963,6 @@ dependencies = [
|
||||
"seccompiler",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"serial_test",
|
||||
"sha1",
|
||||
"shlex",
|
||||
@@ -1971,6 +1991,35 @@ dependencies = [
|
||||
"zstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-core-skills"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"codex-analytics",
|
||||
"codex-app-server-protocol",
|
||||
"codex-config",
|
||||
"codex-instructions",
|
||||
"codex-login",
|
||||
"codex-otel",
|
||||
"codex-protocol",
|
||||
"codex-skills",
|
||||
"codex-utils-absolute-path",
|
||||
"codex-utils-plugins",
|
||||
"dirs",
|
||||
"dunce",
|
||||
"pretty_assertions",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"shlex",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"toml 0.9.11+spec-1.1.0",
|
||||
"tracing",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-debug-client"
|
||||
version = "0.0.0"
|
||||
@@ -2174,6 +2223,15 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-instructions"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"codex-protocol",
|
||||
"pretty_assertions",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-keyring-store"
|
||||
version = "0.0.0"
|
||||
@@ -2383,6 +2441,17 @@ dependencies = [
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-plugin"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"codex-utils-absolute-path",
|
||||
"codex-utils-plugins",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-process-hardening"
|
||||
version = "0.0.0"
|
||||
@@ -2937,6 +3006,15 @@ dependencies = [
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-utils-plugins"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-utils-pty"
|
||||
version = "0.0.0"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"analytics",
|
||||
"backend-client",
|
||||
"ansi-escape",
|
||||
"async-utils",
|
||||
@@ -24,7 +25,9 @@ members = [
|
||||
"shell-escalation",
|
||||
"skills",
|
||||
"core",
|
||||
"core-skills",
|
||||
"hooks",
|
||||
"instructions",
|
||||
"secrets",
|
||||
"exec",
|
||||
"exec-server",
|
||||
@@ -68,6 +71,7 @@ members = [
|
||||
"utils/oss",
|
||||
"utils/output-truncation",
|
||||
"utils/path-utils",
|
||||
"utils/plugins",
|
||||
"utils/fuzzy-match",
|
||||
"utils/stream-parser",
|
||||
"codex-client",
|
||||
@@ -77,6 +81,7 @@ members = [
|
||||
"codex-experimental-api-macros",
|
||||
"test-macros",
|
||||
"package-manager",
|
||||
"plugin",
|
||||
"artifacts",
|
||||
]
|
||||
resolver = "2"
|
||||
@@ -94,6 +99,7 @@ license = "Apache-2.0"
|
||||
# Internal
|
||||
app_test_support = { path = "app-server/tests/common" }
|
||||
codex-ansi-escape = { path = "ansi-escape" }
|
||||
codex-analytics = { path = "analytics" }
|
||||
codex-api = { path = "codex-api" }
|
||||
codex-artifacts = { path = "artifacts" }
|
||||
codex-code-mode = { path = "code-mode" }
|
||||
@@ -113,6 +119,7 @@ codex-cloud-requirements = { path = "cloud-requirements" }
|
||||
codex-connectors = { path = "connectors" }
|
||||
codex-config = { path = "config" }
|
||||
codex-core = { path = "core" }
|
||||
codex-core-skills = { path = "core-skills" }
|
||||
codex-exec = { path = "exec" }
|
||||
codex-exec-server = { path = "exec-server" }
|
||||
codex-execpolicy = { path = "execpolicy" }
|
||||
@@ -122,6 +129,7 @@ codex-features = { path = "features" }
|
||||
codex-file-search = { path = "file-search" }
|
||||
codex-git-utils = { path = "git-utils" }
|
||||
codex-hooks = { path = "hooks" }
|
||||
codex-instructions = { path = "instructions" }
|
||||
codex-keyring-store = { path = "keyring-store" }
|
||||
codex-linux-sandbox = { path = "linux-sandbox" }
|
||||
codex-lmstudio = { path = "lmstudio" }
|
||||
@@ -130,6 +138,7 @@ codex-mcp-server = { path = "mcp-server" }
|
||||
codex-network-proxy = { path = "network-proxy" }
|
||||
codex-ollama = { path = "ollama" }
|
||||
codex-otel = { path = "otel" }
|
||||
codex-plugin = { path = "plugin" }
|
||||
codex-process-hardening = { path = "process-hardening" }
|
||||
codex-protocol = { path = "protocol" }
|
||||
codex-rollout = { path = "rollout" }
|
||||
@@ -160,6 +169,7 @@ codex-utils-json-to-toml = { path = "utils/json-to-toml" }
|
||||
codex-utils-oss = { path = "utils/oss" }
|
||||
codex-utils-output-truncation = { path = "utils/output-truncation" }
|
||||
codex-utils-path = { path = "utils/path-utils" }
|
||||
codex-utils-plugins = { path = "utils/plugins" }
|
||||
codex-utils-pty = { path = "utils/pty" }
|
||||
codex-utils-readiness = { path = "utils/readiness" }
|
||||
codex-utils-rustls-provider = { path = "utils/rustls-provider" }
|
||||
|
||||
6
codex-rs/analytics/BUILD.bazel
Normal file
6
codex-rs/analytics/BUILD.bazel
Normal file
@@ -0,0 +1,6 @@
|
||||
load("//:defs.bzl", "codex_rust_crate")
|
||||
|
||||
codex_rust_crate(
|
||||
name = "analytics",
|
||||
crate_name = "codex_analytics",
|
||||
)
|
||||
30
codex-rs/analytics/Cargo.toml
Normal file
30
codex-rs/analytics/Cargo.toml
Normal file
@@ -0,0 +1,30 @@
|
||||
[package]
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
name = "codex-analytics"
|
||||
version.workspace = true
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
name = "codex_analytics"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
codex-git-utils = { workspace = true }
|
||||
codex-login = { workspace = true }
|
||||
codex-plugin = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
sha1 = { workspace = true }
|
||||
tokio = { workspace = true, features = [
|
||||
"macros",
|
||||
"rt-multi-thread",
|
||||
] }
|
||||
tracing = { workspace = true, features = ["log"] }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -1,9 +1,9 @@
|
||||
use crate::AuthManager;
|
||||
use crate::config::Config;
|
||||
use crate::default_client::create_client;
|
||||
use crate::plugins::PluginTelemetryMetadata;
|
||||
use codex_git_utils::collect_git_info;
|
||||
use codex_git_utils::get_git_repo_root;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::default_client::create_client;
|
||||
use codex_login::default_client::originator;
|
||||
use codex_plugin::PluginTelemetryMetadata;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use serde::Serialize;
|
||||
use sha1::Digest;
|
||||
@@ -17,13 +17,13 @@ use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct TrackEventsContext {
|
||||
pub(crate) model_slug: String,
|
||||
pub(crate) thread_id: String,
|
||||
pub(crate) turn_id: String,
|
||||
pub struct TrackEventsContext {
|
||||
pub model_slug: String,
|
||||
pub thread_id: String,
|
||||
pub turn_id: String,
|
||||
}
|
||||
|
||||
pub(crate) fn build_track_events_context(
|
||||
pub fn build_track_events_context(
|
||||
model_slug: String,
|
||||
thread_id: String,
|
||||
turn_id: String,
|
||||
@@ -36,24 +36,24 @@ pub(crate) fn build_track_events_context(
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct SkillInvocation {
|
||||
pub(crate) skill_name: String,
|
||||
pub(crate) skill_scope: SkillScope,
|
||||
pub(crate) skill_path: PathBuf,
|
||||
pub(crate) invocation_type: InvocationType,
|
||||
pub struct SkillInvocation {
|
||||
pub skill_name: String,
|
||||
pub skill_scope: SkillScope,
|
||||
pub skill_path: PathBuf,
|
||||
pub invocation_type: InvocationType,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub(crate) enum InvocationType {
|
||||
pub enum InvocationType {
|
||||
Explicit,
|
||||
Implicit,
|
||||
}
|
||||
|
||||
pub(crate) struct AppInvocation {
|
||||
pub(crate) connector_id: Option<String>,
|
||||
pub(crate) app_name: Option<String>,
|
||||
pub(crate) invocation_type: Option<InvocationType>,
|
||||
pub struct AppInvocation {
|
||||
pub connector_id: Option<String>,
|
||||
pub app_name: Option<String>,
|
||||
pub invocation_type: Option<InvocationType>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -66,38 +66,38 @@ pub(crate) struct AnalyticsEventsQueue {
|
||||
#[derive(Clone)]
|
||||
pub struct AnalyticsEventsClient {
|
||||
queue: AnalyticsEventsQueue,
|
||||
config: Arc<Config>,
|
||||
analytics_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
impl AnalyticsEventsQueue {
|
||||
pub(crate) fn new(auth_manager: Arc<AuthManager>) -> Self {
|
||||
pub(crate) fn new(auth_manager: Arc<AuthManager>, base_url: String) -> Self {
|
||||
let (sender, mut receiver) = mpsc::channel(ANALYTICS_EVENTS_QUEUE_SIZE);
|
||||
tokio::spawn(async move {
|
||||
while let Some(job) = receiver.recv().await {
|
||||
match job {
|
||||
TrackEventsJob::SkillInvocations(job) => {
|
||||
send_track_skill_invocations(&auth_manager, job).await;
|
||||
send_track_skill_invocations(&auth_manager, &base_url, job).await;
|
||||
}
|
||||
TrackEventsJob::AppMentioned(job) => {
|
||||
send_track_app_mentioned(&auth_manager, job).await;
|
||||
send_track_app_mentioned(&auth_manager, &base_url, job).await;
|
||||
}
|
||||
TrackEventsJob::AppUsed(job) => {
|
||||
send_track_app_used(&auth_manager, job).await;
|
||||
send_track_app_used(&auth_manager, &base_url, job).await;
|
||||
}
|
||||
TrackEventsJob::PluginUsed(job) => {
|
||||
send_track_plugin_used(&auth_manager, job).await;
|
||||
send_track_plugin_used(&auth_manager, &base_url, job).await;
|
||||
}
|
||||
TrackEventsJob::PluginInstalled(job) => {
|
||||
send_track_plugin_installed(&auth_manager, job).await;
|
||||
send_track_plugin_installed(&auth_manager, &base_url, job).await;
|
||||
}
|
||||
TrackEventsJob::PluginUninstalled(job) => {
|
||||
send_track_plugin_uninstalled(&auth_manager, job).await;
|
||||
send_track_plugin_uninstalled(&auth_manager, &base_url, job).await;
|
||||
}
|
||||
TrackEventsJob::PluginEnabled(job) => {
|
||||
send_track_plugin_enabled(&auth_manager, job).await;
|
||||
send_track_plugin_enabled(&auth_manager, &base_url, job).await;
|
||||
}
|
||||
TrackEventsJob::PluginDisabled(job) => {
|
||||
send_track_plugin_disabled(&auth_manager, job).await;
|
||||
send_track_plugin_disabled(&auth_manager, &base_url, job).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -147,60 +147,51 @@ impl AnalyticsEventsQueue {
|
||||
}
|
||||
|
||||
impl AnalyticsEventsClient {
|
||||
pub fn new(config: Arc<Config>, auth_manager: Arc<AuthManager>) -> Self {
|
||||
pub fn new(
|
||||
auth_manager: Arc<AuthManager>,
|
||||
base_url: String,
|
||||
analytics_enabled: Option<bool>,
|
||||
) -> Self {
|
||||
Self {
|
||||
queue: AnalyticsEventsQueue::new(Arc::clone(&auth_manager)),
|
||||
config,
|
||||
queue: AnalyticsEventsQueue::new(Arc::clone(&auth_manager), base_url),
|
||||
analytics_enabled,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn track_skill_invocations(
|
||||
pub fn track_skill_invocations(
|
||||
&self,
|
||||
tracking: TrackEventsContext,
|
||||
invocations: Vec<SkillInvocation>,
|
||||
) {
|
||||
track_skill_invocations(
|
||||
&self.queue,
|
||||
Arc::clone(&self.config),
|
||||
self.analytics_enabled,
|
||||
Some(tracking),
|
||||
invocations,
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn track_app_mentioned(
|
||||
&self,
|
||||
tracking: TrackEventsContext,
|
||||
mentions: Vec<AppInvocation>,
|
||||
) {
|
||||
pub fn track_app_mentioned(&self, tracking: TrackEventsContext, mentions: Vec<AppInvocation>) {
|
||||
track_app_mentioned(
|
||||
&self.queue,
|
||||
Arc::clone(&self.config),
|
||||
self.analytics_enabled,
|
||||
Some(tracking),
|
||||
mentions,
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) {
|
||||
track_app_used(&self.queue, Arc::clone(&self.config), Some(tracking), app);
|
||||
pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) {
|
||||
track_app_used(&self.queue, self.analytics_enabled, Some(tracking), app);
|
||||
}
|
||||
|
||||
pub(crate) fn track_plugin_used(
|
||||
&self,
|
||||
tracking: TrackEventsContext,
|
||||
plugin: PluginTelemetryMetadata,
|
||||
) {
|
||||
track_plugin_used(
|
||||
&self.queue,
|
||||
Arc::clone(&self.config),
|
||||
Some(tracking),
|
||||
plugin,
|
||||
);
|
||||
pub fn track_plugin_used(&self, tracking: TrackEventsContext, plugin: PluginTelemetryMetadata) {
|
||||
track_plugin_used(&self.queue, self.analytics_enabled, Some(tracking), plugin);
|
||||
}
|
||||
|
||||
pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) {
|
||||
track_plugin_management(
|
||||
&self.queue,
|
||||
Arc::clone(&self.config),
|
||||
self.analytics_enabled,
|
||||
PluginManagementEventType::Installed,
|
||||
plugin,
|
||||
);
|
||||
@@ -209,7 +200,7 @@ impl AnalyticsEventsClient {
|
||||
pub fn track_plugin_uninstalled(&self, plugin: PluginTelemetryMetadata) {
|
||||
track_plugin_management(
|
||||
&self.queue,
|
||||
Arc::clone(&self.config),
|
||||
self.analytics_enabled,
|
||||
PluginManagementEventType::Uninstalled,
|
||||
plugin,
|
||||
);
|
||||
@@ -218,7 +209,7 @@ impl AnalyticsEventsClient {
|
||||
pub fn track_plugin_enabled(&self, plugin: PluginTelemetryMetadata) {
|
||||
track_plugin_management(
|
||||
&self.queue,
|
||||
Arc::clone(&self.config),
|
||||
self.analytics_enabled,
|
||||
PluginManagementEventType::Enabled,
|
||||
plugin,
|
||||
);
|
||||
@@ -227,7 +218,7 @@ impl AnalyticsEventsClient {
|
||||
pub fn track_plugin_disabled(&self, plugin: PluginTelemetryMetadata) {
|
||||
track_plugin_management(
|
||||
&self.queue,
|
||||
Arc::clone(&self.config),
|
||||
self.analytics_enabled,
|
||||
PluginManagementEventType::Disabled,
|
||||
plugin,
|
||||
);
|
||||
@@ -246,31 +237,31 @@ enum TrackEventsJob {
|
||||
}
|
||||
|
||||
struct TrackSkillInvocationsJob {
|
||||
config: Arc<Config>,
|
||||
analytics_enabled: Option<bool>,
|
||||
tracking: TrackEventsContext,
|
||||
invocations: Vec<SkillInvocation>,
|
||||
}
|
||||
|
||||
struct TrackAppMentionedJob {
|
||||
config: Arc<Config>,
|
||||
analytics_enabled: Option<bool>,
|
||||
tracking: TrackEventsContext,
|
||||
mentions: Vec<AppInvocation>,
|
||||
}
|
||||
|
||||
struct TrackAppUsedJob {
|
||||
config: Arc<Config>,
|
||||
analytics_enabled: Option<bool>,
|
||||
tracking: TrackEventsContext,
|
||||
app: AppInvocation,
|
||||
}
|
||||
|
||||
struct TrackPluginUsedJob {
|
||||
config: Arc<Config>,
|
||||
analytics_enabled: Option<bool>,
|
||||
tracking: TrackEventsContext,
|
||||
plugin: PluginTelemetryMetadata,
|
||||
}
|
||||
|
||||
struct TrackPluginManagementJob {
|
||||
config: Arc<Config>,
|
||||
analytics_enabled: Option<bool>,
|
||||
plugin: PluginTelemetryMetadata,
|
||||
}
|
||||
|
||||
@@ -379,11 +370,11 @@ struct CodexPluginUsedEventRequest {
|
||||
|
||||
pub(crate) fn track_skill_invocations(
|
||||
queue: &AnalyticsEventsQueue,
|
||||
config: Arc<Config>,
|
||||
analytics_enabled: Option<bool>,
|
||||
tracking: Option<TrackEventsContext>,
|
||||
invocations: Vec<SkillInvocation>,
|
||||
) {
|
||||
if config.analytics_enabled == Some(false) {
|
||||
if analytics_enabled == Some(false) {
|
||||
return;
|
||||
}
|
||||
let Some(tracking) = tracking else {
|
||||
@@ -393,7 +384,7 @@ pub(crate) fn track_skill_invocations(
|
||||
return;
|
||||
}
|
||||
let job = TrackEventsJob::SkillInvocations(TrackSkillInvocationsJob {
|
||||
config,
|
||||
analytics_enabled,
|
||||
tracking,
|
||||
invocations,
|
||||
});
|
||||
@@ -402,11 +393,11 @@ pub(crate) fn track_skill_invocations(
|
||||
|
||||
pub(crate) fn track_app_mentioned(
|
||||
queue: &AnalyticsEventsQueue,
|
||||
config: Arc<Config>,
|
||||
analytics_enabled: Option<bool>,
|
||||
tracking: Option<TrackEventsContext>,
|
||||
mentions: Vec<AppInvocation>,
|
||||
) {
|
||||
if config.analytics_enabled == Some(false) {
|
||||
if analytics_enabled == Some(false) {
|
||||
return;
|
||||
}
|
||||
let Some(tracking) = tracking else {
|
||||
@@ -416,7 +407,7 @@ pub(crate) fn track_app_mentioned(
|
||||
return;
|
||||
}
|
||||
let job = TrackEventsJob::AppMentioned(TrackAppMentionedJob {
|
||||
config,
|
||||
analytics_enabled,
|
||||
tracking,
|
||||
mentions,
|
||||
});
|
||||
@@ -425,11 +416,11 @@ pub(crate) fn track_app_mentioned(
|
||||
|
||||
pub(crate) fn track_app_used(
|
||||
queue: &AnalyticsEventsQueue,
|
||||
config: Arc<Config>,
|
||||
analytics_enabled: Option<bool>,
|
||||
tracking: Option<TrackEventsContext>,
|
||||
app: AppInvocation,
|
||||
) {
|
||||
if config.analytics_enabled == Some(false) {
|
||||
if analytics_enabled == Some(false) {
|
||||
return;
|
||||
}
|
||||
let Some(tracking) = tracking else {
|
||||
@@ -439,7 +430,7 @@ pub(crate) fn track_app_used(
|
||||
return;
|
||||
}
|
||||
let job = TrackEventsJob::AppUsed(TrackAppUsedJob {
|
||||
config,
|
||||
analytics_enabled,
|
||||
tracking,
|
||||
app,
|
||||
});
|
||||
@@ -448,11 +439,11 @@ pub(crate) fn track_app_used(
|
||||
|
||||
pub(crate) fn track_plugin_used(
|
||||
queue: &AnalyticsEventsQueue,
|
||||
config: Arc<Config>,
|
||||
analytics_enabled: Option<bool>,
|
||||
tracking: Option<TrackEventsContext>,
|
||||
plugin: PluginTelemetryMetadata,
|
||||
) {
|
||||
if config.analytics_enabled == Some(false) {
|
||||
if analytics_enabled == Some(false) {
|
||||
return;
|
||||
}
|
||||
let Some(tracking) = tracking else {
|
||||
@@ -462,7 +453,7 @@ pub(crate) fn track_plugin_used(
|
||||
return;
|
||||
}
|
||||
let job = TrackEventsJob::PluginUsed(TrackPluginUsedJob {
|
||||
config,
|
||||
analytics_enabled,
|
||||
tracking,
|
||||
plugin,
|
||||
});
|
||||
@@ -471,14 +462,17 @@ pub(crate) fn track_plugin_used(
|
||||
|
||||
fn track_plugin_management(
|
||||
queue: &AnalyticsEventsQueue,
|
||||
config: Arc<Config>,
|
||||
analytics_enabled: Option<bool>,
|
||||
event_type: PluginManagementEventType,
|
||||
plugin: PluginTelemetryMetadata,
|
||||
) {
|
||||
if config.analytics_enabled == Some(false) {
|
||||
if analytics_enabled == Some(false) {
|
||||
return;
|
||||
}
|
||||
let job = TrackPluginManagementJob { config, plugin };
|
||||
let job = TrackPluginManagementJob {
|
||||
analytics_enabled,
|
||||
plugin,
|
||||
};
|
||||
let job = match event_type {
|
||||
PluginManagementEventType::Installed => TrackEventsJob::PluginInstalled(job),
|
||||
PluginManagementEventType::Uninstalled => TrackEventsJob::PluginUninstalled(job),
|
||||
@@ -488,9 +482,13 @@ fn track_plugin_management(
|
||||
queue.try_send(job);
|
||||
}
|
||||
|
||||
async fn send_track_skill_invocations(auth_manager: &AuthManager, job: TrackSkillInvocationsJob) {
|
||||
async fn send_track_skill_invocations(
|
||||
auth_manager: &AuthManager,
|
||||
base_url: &str,
|
||||
job: TrackSkillInvocationsJob,
|
||||
) {
|
||||
let TrackSkillInvocationsJob {
|
||||
config,
|
||||
analytics_enabled,
|
||||
tracking,
|
||||
invocations,
|
||||
} = job;
|
||||
@@ -525,7 +523,7 @@ async fn send_track_skill_invocations(auth_manager: &AuthManager, job: TrackSkil
|
||||
thread_id: Some(tracking.thread_id.clone()),
|
||||
invoke_type: Some(invocation.invocation_type),
|
||||
model_slug: Some(tracking.model_slug.clone()),
|
||||
product_client_id: Some(crate::default_client::originator().value),
|
||||
product_client_id: Some(originator().value),
|
||||
repo_url,
|
||||
skill_scope: Some(skill_scope.to_string()),
|
||||
},
|
||||
@@ -533,12 +531,16 @@ async fn send_track_skill_invocations(auth_manager: &AuthManager, job: TrackSkil
|
||||
));
|
||||
}
|
||||
|
||||
send_track_events(auth_manager, config, events).await;
|
||||
send_track_events(auth_manager, analytics_enabled, base_url, events).await;
|
||||
}
|
||||
|
||||
async fn send_track_app_mentioned(auth_manager: &AuthManager, job: TrackAppMentionedJob) {
|
||||
async fn send_track_app_mentioned(
|
||||
auth_manager: &AuthManager,
|
||||
base_url: &str,
|
||||
job: TrackAppMentionedJob,
|
||||
) {
|
||||
let TrackAppMentionedJob {
|
||||
config,
|
||||
analytics_enabled,
|
||||
tracking,
|
||||
mentions,
|
||||
} = job;
|
||||
@@ -553,12 +555,12 @@ async fn send_track_app_mentioned(auth_manager: &AuthManager, job: TrackAppMenti
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
send_track_events(auth_manager, config, events).await;
|
||||
send_track_events(auth_manager, analytics_enabled, base_url, events).await;
|
||||
}
|
||||
|
||||
async fn send_track_app_used(auth_manager: &AuthManager, job: TrackAppUsedJob) {
|
||||
async fn send_track_app_used(auth_manager: &AuthManager, base_url: &str, job: TrackAppUsedJob) {
|
||||
let TrackAppUsedJob {
|
||||
config,
|
||||
analytics_enabled,
|
||||
tracking,
|
||||
app,
|
||||
} = job;
|
||||
@@ -568,12 +570,16 @@ async fn send_track_app_used(auth_manager: &AuthManager, job: TrackAppUsedJob) {
|
||||
event_params,
|
||||
})];
|
||||
|
||||
send_track_events(auth_manager, config, events).await;
|
||||
send_track_events(auth_manager, analytics_enabled, base_url, events).await;
|
||||
}
|
||||
|
||||
async fn send_track_plugin_used(auth_manager: &AuthManager, job: TrackPluginUsedJob) {
|
||||
async fn send_track_plugin_used(
|
||||
auth_manager: &AuthManager,
|
||||
base_url: &str,
|
||||
job: TrackPluginUsedJob,
|
||||
) {
|
||||
let TrackPluginUsedJob {
|
||||
config,
|
||||
analytics_enabled,
|
||||
tracking,
|
||||
plugin,
|
||||
} = job;
|
||||
@@ -582,31 +588,52 @@ async fn send_track_plugin_used(auth_manager: &AuthManager, job: TrackPluginUsed
|
||||
event_params: codex_plugin_used_metadata(&tracking, plugin),
|
||||
})];
|
||||
|
||||
send_track_events(auth_manager, config, events).await;
|
||||
send_track_events(auth_manager, analytics_enabled, base_url, events).await;
|
||||
}
|
||||
|
||||
async fn send_track_plugin_installed(auth_manager: &AuthManager, job: TrackPluginManagementJob) {
|
||||
send_track_plugin_management_event(auth_manager, job, "codex_plugin_installed").await;
|
||||
async fn send_track_plugin_installed(
|
||||
auth_manager: &AuthManager,
|
||||
base_url: &str,
|
||||
job: TrackPluginManagementJob,
|
||||
) {
|
||||
send_track_plugin_management_event(auth_manager, base_url, job, "codex_plugin_installed").await;
|
||||
}
|
||||
|
||||
async fn send_track_plugin_uninstalled(auth_manager: &AuthManager, job: TrackPluginManagementJob) {
|
||||
send_track_plugin_management_event(auth_manager, job, "codex_plugin_uninstalled").await;
|
||||
async fn send_track_plugin_uninstalled(
|
||||
auth_manager: &AuthManager,
|
||||
base_url: &str,
|
||||
job: TrackPluginManagementJob,
|
||||
) {
|
||||
send_track_plugin_management_event(auth_manager, base_url, job, "codex_plugin_uninstalled")
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn send_track_plugin_enabled(auth_manager: &AuthManager, job: TrackPluginManagementJob) {
|
||||
send_track_plugin_management_event(auth_manager, job, "codex_plugin_enabled").await;
|
||||
async fn send_track_plugin_enabled(
|
||||
auth_manager: &AuthManager,
|
||||
base_url: &str,
|
||||
job: TrackPluginManagementJob,
|
||||
) {
|
||||
send_track_plugin_management_event(auth_manager, base_url, job, "codex_plugin_enabled").await;
|
||||
}
|
||||
|
||||
async fn send_track_plugin_disabled(auth_manager: &AuthManager, job: TrackPluginManagementJob) {
|
||||
send_track_plugin_management_event(auth_manager, job, "codex_plugin_disabled").await;
|
||||
async fn send_track_plugin_disabled(
|
||||
auth_manager: &AuthManager,
|
||||
base_url: &str,
|
||||
job: TrackPluginManagementJob,
|
||||
) {
|
||||
send_track_plugin_management_event(auth_manager, base_url, job, "codex_plugin_disabled").await;
|
||||
}
|
||||
|
||||
async fn send_track_plugin_management_event(
|
||||
auth_manager: &AuthManager,
|
||||
base_url: &str,
|
||||
job: TrackPluginManagementJob,
|
||||
event_type: &'static str,
|
||||
) {
|
||||
let TrackPluginManagementJob { config, plugin } = job;
|
||||
let TrackPluginManagementJob {
|
||||
analytics_enabled,
|
||||
plugin,
|
||||
} = job;
|
||||
let event_params = codex_plugin_metadata(plugin);
|
||||
let event = CodexPluginEventRequest {
|
||||
event_type,
|
||||
@@ -620,7 +647,7 @@ async fn send_track_plugin_management_event(
|
||||
_ => unreachable!("unknown plugin management event type"),
|
||||
}];
|
||||
|
||||
send_track_events(auth_manager, config, events).await;
|
||||
send_track_events(auth_manager, analytics_enabled, base_url, events).await;
|
||||
}
|
||||
|
||||
fn codex_app_metadata(tracking: &TrackEventsContext, app: AppInvocation) -> CodexAppMetadata {
|
||||
@@ -629,7 +656,7 @@ fn codex_app_metadata(tracking: &TrackEventsContext, app: AppInvocation) -> Code
|
||||
thread_id: Some(tracking.thread_id.clone()),
|
||||
turn_id: Some(tracking.turn_id.clone()),
|
||||
app_name: app.app_name,
|
||||
product_client_id: Some(crate::default_client::originator().value),
|
||||
product_client_id: Some(originator().value),
|
||||
invoke_type: app.invocation_type,
|
||||
model_slug: Some(tracking.model_slug.clone()),
|
||||
}
|
||||
@@ -654,7 +681,7 @@ fn codex_plugin_metadata(plugin: PluginTelemetryMetadata) -> CodexPluginMetadata
|
||||
.map(|connector_id| connector_id.0)
|
||||
.collect()
|
||||
}),
|
||||
product_client_id: Some(crate::default_client::originator().value),
|
||||
product_client_id: Some(originator().value),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -672,9 +699,13 @@ fn codex_plugin_used_metadata(
|
||||
|
||||
async fn send_track_events(
|
||||
auth_manager: &AuthManager,
|
||||
config: Arc<Config>,
|
||||
analytics_enabled: Option<bool>,
|
||||
base_url: &str,
|
||||
events: Vec<TrackEventRequest>,
|
||||
) {
|
||||
if analytics_enabled == Some(false) {
|
||||
return;
|
||||
}
|
||||
if events.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -692,7 +723,7 @@ async fn send_track_events(
|
||||
return;
|
||||
};
|
||||
|
||||
let base_url = config.chatgpt_base_url.trim_end_matches('/');
|
||||
let base_url = base_url.trim_end_matches('/');
|
||||
let url = format!("{base_url}/codex/analytics-events/events");
|
||||
let payload = TrackEventsRequest { events };
|
||||
|
||||
@@ -11,10 +11,11 @@ use super::codex_app_metadata;
|
||||
use super::codex_plugin_metadata;
|
||||
use super::codex_plugin_used_metadata;
|
||||
use super::normalize_path_for_skill_id;
|
||||
use crate::plugins::AppConnectorId;
|
||||
use crate::plugins::PluginCapabilitySummary;
|
||||
use crate::plugins::PluginId;
|
||||
use crate::plugins::PluginTelemetryMetadata;
|
||||
use codex_login::default_client::originator;
|
||||
use codex_plugin::AppConnectorId;
|
||||
use codex_plugin::PluginCapabilitySummary;
|
||||
use codex_plugin::PluginId;
|
||||
use codex_plugin::PluginTelemetryMetadata;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use std::collections::HashSet;
|
||||
@@ -109,7 +110,7 @@ fn app_mentioned_event_serializes_expected_shape() {
|
||||
"thread_id": "thread-1",
|
||||
"turn_id": "turn-1",
|
||||
"app_name": "Calendar",
|
||||
"product_client_id": crate::default_client::originator().value,
|
||||
"product_client_id": originator().value,
|
||||
"invoke_type": "explicit",
|
||||
"model_slug": "gpt-5"
|
||||
}
|
||||
@@ -147,7 +148,7 @@ fn app_used_event_serializes_expected_shape() {
|
||||
"thread_id": "thread-2",
|
||||
"turn_id": "turn-2",
|
||||
"app_name": "Google Drive",
|
||||
"product_client_id": crate::default_client::originator().value,
|
||||
"product_client_id": originator().value,
|
||||
"invoke_type": "implicit",
|
||||
"model_slug": "gpt-5"
|
||||
}
|
||||
@@ -210,7 +211,7 @@ fn plugin_used_event_serializes_expected_shape() {
|
||||
"has_skills": true,
|
||||
"mcp_server_count": 2,
|
||||
"connector_ids": ["calendar", "drive"],
|
||||
"product_client_id": crate::default_client::originator().value,
|
||||
"product_client_id": originator().value,
|
||||
"thread_id": "thread-3",
|
||||
"turn_id": "turn-3",
|
||||
"model_slug": "gpt-5"
|
||||
@@ -239,7 +240,7 @@ fn plugin_management_event_serializes_expected_shape() {
|
||||
"has_skills": true,
|
||||
"mcp_server_count": 2,
|
||||
"connector_ids": ["calendar", "drive"],
|
||||
"product_client_id": crate::default_client::originator().value
|
||||
"product_client_id": originator().value
|
||||
}
|
||||
})
|
||||
);
|
||||
8
codex-rs/analytics/src/lib.rs
Normal file
8
codex-rs/analytics/src/lib.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
mod analytics_client;
|
||||
|
||||
pub use analytics_client::AnalyticsEventsClient;
|
||||
pub use analytics_client::AppInvocation;
|
||||
pub use analytics_client::InvocationType;
|
||||
pub use analytics_client::SkillInvocation;
|
||||
pub use analytics_client::TrackEventsContext;
|
||||
pub use analytics_client::build_track_events_context;
|
||||
@@ -203,6 +203,8 @@ use codex_core::config::types::McpServerTransportConfig;
|
||||
use codex_core::config_loader::CloudRequirementsLoadError;
|
||||
use codex_core::config_loader::CloudRequirementsLoadErrorCode;
|
||||
use codex_core::config_loader::CloudRequirementsLoader;
|
||||
use codex_core::config_loader::LoaderOverrides;
|
||||
use codex_core::config_loader::load_config_layers_state;
|
||||
use codex_core::default_client::set_default_client_residency_requirement;
|
||||
use codex_core::error::CodexErr;
|
||||
use codex_core::error::Result as CodexResult;
|
||||
@@ -282,6 +284,7 @@ use codex_state::StateRuntime;
|
||||
use codex_state::ThreadMetadata;
|
||||
use codex_state::ThreadMetadataBuilder;
|
||||
use codex_state::log_db::LogDbLayer;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_json_to_toml::json_to_toml;
|
||||
use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP;
|
||||
use std::collections::BTreeMap;
|
||||
@@ -5509,13 +5512,63 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
};
|
||||
let skills_manager = self.thread_manager.skills_manager();
|
||||
let plugins_manager = self.thread_manager.plugins_manager();
|
||||
let cli_overrides = self.cli_overrides.as_slice();
|
||||
let mut data = Vec::new();
|
||||
for cwd in cwds {
|
||||
let extra_roots = extra_roots_by_cwd
|
||||
.get(&cwd)
|
||||
.map_or(&[][..], std::vec::Vec::as_slice);
|
||||
let cwd_abs = match AbsolutePathBuf::try_from(cwd.as_path()) {
|
||||
Ok(path) => path,
|
||||
Err(err) => {
|
||||
let error_path = cwd.clone();
|
||||
data.push(codex_app_server_protocol::SkillsListEntry {
|
||||
cwd,
|
||||
skills: Vec::new(),
|
||||
errors: errors_to_info(&[codex_core::skills::SkillError {
|
||||
path: error_path,
|
||||
message: err.to_string(),
|
||||
}]),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let config_layer_stack = match load_config_layers_state(
|
||||
&self.config.codex_home,
|
||||
Some(cwd_abs),
|
||||
cli_overrides,
|
||||
LoaderOverrides::default(),
|
||||
CloudRequirementsLoader::default(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(config_layer_stack) => config_layer_stack,
|
||||
Err(err) => {
|
||||
let error_path = cwd.clone();
|
||||
data.push(codex_app_server_protocol::SkillsListEntry {
|
||||
cwd,
|
||||
skills: Vec::new(),
|
||||
errors: errors_to_info(&[codex_core::skills::SkillError {
|
||||
path: error_path,
|
||||
message: err.to_string(),
|
||||
}]),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let effective_skill_roots = plugins_manager.effective_skill_roots_for_layer_stack(
|
||||
&config_layer_stack,
|
||||
config.features.enabled(Feature::Plugins),
|
||||
);
|
||||
let skills_input = codex_core::skills::SkillsLoadInput::new(
|
||||
cwd.clone(),
|
||||
effective_skill_roots,
|
||||
config_layer_stack,
|
||||
config.bundled_skills_enabled(),
|
||||
);
|
||||
let outcome = skills_manager
|
||||
.skills_for_cwd_with_extra_user_roots(&cwd, &config, force_reload, extra_roots)
|
||||
.skills_for_cwd_with_extra_user_roots(&skills_input, force_reload, extra_roots)
|
||||
.await;
|
||||
let errors = errors_to_info(&outcome.errors);
|
||||
let skills = skills_to_info(&outcome.skills, &outcome.disabled_paths);
|
||||
|
||||
@@ -450,6 +450,8 @@ fn config_write_error(code: ConfigWriteErrorCode, message: impl Into<String>) ->
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_core::AnalyticsEventsClient;
|
||||
use codex_core::AuthManager;
|
||||
use codex_core::CodexAuth;
|
||||
use codex_core::config_loader::NetworkRequirementsToml as CoreNetworkRequirementsToml;
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::protocol::AskForApproval as CoreAskForApproval;
|
||||
@@ -651,6 +653,7 @@ mod tests {
|
||||
.await
|
||||
.expect("load analytics config"),
|
||||
);
|
||||
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test"));
|
||||
let config_api = ConfigApi::new(
|
||||
codex_home.path().to_path_buf(),
|
||||
Arc::new(RwLock::new(Vec::new())),
|
||||
@@ -659,10 +662,12 @@ mod tests {
|
||||
Arc::new(RwLock::new(CloudRequirementsLoader::default())),
|
||||
reloader.clone(),
|
||||
AnalyticsEventsClient::new(
|
||||
analytics_config,
|
||||
codex_core::test_support::auth_manager_from_auth(
|
||||
codex_core::CodexAuth::from_api_key("test"),
|
||||
),
|
||||
auth_manager,
|
||||
analytics_config
|
||||
.chatgpt_base_url
|
||||
.trim_end_matches('/')
|
||||
.to_string(),
|
||||
analytics_config.analytics_enabled,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -222,8 +222,11 @@ impl MessageProcessor {
|
||||
auth_manager.set_external_auth_refresher(Arc::new(ExternalAuthRefreshBridge {
|
||||
outgoing: outgoing.clone(),
|
||||
}));
|
||||
let analytics_events_client =
|
||||
AnalyticsEventsClient::new(Arc::clone(&config), Arc::clone(&auth_manager));
|
||||
let analytics_events_client = AnalyticsEventsClient::new(
|
||||
Arc::clone(&auth_manager),
|
||||
config.chatgpt_base_url.trim_end_matches('/').to_string(),
|
||||
config.analytics_enabled,
|
||||
);
|
||||
thread_manager
|
||||
.plugins_manager()
|
||||
.set_analytics_events_client(analytics_events_client.clone());
|
||||
|
||||
@@ -14,6 +14,7 @@ codex-protocol = { workspace = true }
|
||||
codex-utils-absolute-path = { workspace = true }
|
||||
futures = { workspace = true, features = ["alloc", "std"] }
|
||||
multimap = { workspace = true }
|
||||
schemars = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
serde_path_to_error = { workspace = true }
|
||||
|
||||
@@ -5,7 +5,9 @@ mod diagnostics;
|
||||
mod fingerprint;
|
||||
mod merge;
|
||||
mod overrides;
|
||||
mod project_root_markers;
|
||||
mod requirements_exec_policy;
|
||||
mod skills_config;
|
||||
mod state;
|
||||
|
||||
pub const CONFIG_TOML_FILE: &str = "config.toml";
|
||||
@@ -46,12 +48,17 @@ pub use diagnostics::io_error_from_config_error;
|
||||
pub use fingerprint::version_for_toml;
|
||||
pub use merge::merge_toml_values;
|
||||
pub use overrides::build_cli_overrides_layer;
|
||||
pub use project_root_markers::default_project_root_markers;
|
||||
pub use project_root_markers::project_root_markers_from_config;
|
||||
pub use requirements_exec_policy::RequirementsExecPolicy;
|
||||
pub use requirements_exec_policy::RequirementsExecPolicyDecisionToml;
|
||||
pub use requirements_exec_policy::RequirementsExecPolicyParseError;
|
||||
pub use requirements_exec_policy::RequirementsExecPolicyPatternTokenToml;
|
||||
pub use requirements_exec_policy::RequirementsExecPolicyPrefixRuleToml;
|
||||
pub use requirements_exec_policy::RequirementsExecPolicyToml;
|
||||
pub use skills_config::BundledSkillsConfig;
|
||||
pub use skills_config::SkillConfig;
|
||||
pub use skills_config::SkillsConfig;
|
||||
pub use state::ConfigLayerEntry;
|
||||
pub use state::ConfigLayerStack;
|
||||
pub use state::ConfigLayerStackOrdering;
|
||||
|
||||
50
codex-rs/config/src/project_root_markers.rs
Normal file
50
codex-rs/config/src/project_root_markers.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
use std::io;
|
||||
|
||||
use toml::Value as TomlValue;
|
||||
|
||||
const DEFAULT_PROJECT_ROOT_MARKERS: &[&str] = &[".git"];
|
||||
|
||||
/// Reads `project_root_markers` from a merged `config.toml` [toml::Value].
|
||||
///
|
||||
/// Invariants:
|
||||
/// - If `project_root_markers` is not specified, returns `Ok(None)`.
|
||||
/// - If `project_root_markers` is specified, returns `Ok(Some(markers))` where
|
||||
/// `markers` is a `Vec<String>` (including `Ok(Some(Vec::new()))` for an
|
||||
/// empty array, which indicates that root detection should be disabled).
|
||||
/// - Returns an error if `project_root_markers` is specified but is not an
|
||||
/// array of strings.
|
||||
pub fn project_root_markers_from_config(config: &TomlValue) -> io::Result<Option<Vec<String>>> {
|
||||
let Some(table) = config.as_table() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(markers_value) = table.get("project_root_markers") else {
|
||||
return Ok(None);
|
||||
};
|
||||
let TomlValue::Array(entries) = markers_value else {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"project_root_markers must be an array of strings",
|
||||
));
|
||||
};
|
||||
if entries.is_empty() {
|
||||
return Ok(Some(Vec::new()));
|
||||
}
|
||||
let mut markers = Vec::new();
|
||||
for entry in entries {
|
||||
let Some(marker) = entry.as_str() else {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"project_root_markers must be an array of strings",
|
||||
));
|
||||
};
|
||||
markers.push(marker.to_string());
|
||||
}
|
||||
Ok(Some(markers))
|
||||
}
|
||||
|
||||
pub fn default_project_root_markers() -> Vec<String> {
|
||||
DEFAULT_PROJECT_ROOT_MARKERS
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect()
|
||||
}
|
||||
53
codex-rs/config/src/skills_config.rs
Normal file
53
codex-rs/config/src/skills_config.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
//! Skill-related configuration types shared across crates.
|
||||
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
const fn default_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct SkillConfig {
|
||||
/// Path-based selector.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<AbsolutePathBuf>,
|
||||
/// Name-based selector.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct SkillsConfig {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub bundled: Option<BundledSkillsConfig>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub config: Vec<SkillConfig>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct BundledSkillsConfig {
|
||||
#[serde(default = "default_enabled")]
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for BundledSkillsConfig {
|
||||
fn default() -> Self {
|
||||
Self { enabled: true }
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<toml::Value> for SkillsConfig {
|
||||
type Error = toml::de::Error;
|
||||
|
||||
fn try_from(value: toml::Value) -> Result<Self, Self::Error> {
|
||||
SkillsConfig::deserialize(value)
|
||||
}
|
||||
}
|
||||
15
codex-rs/core-skills/BUILD.bazel
Normal file
15
codex-rs/core-skills/BUILD.bazel
Normal file
@@ -0,0 +1,15 @@
|
||||
load("//:defs.bzl", "codex_rust_crate")
|
||||
|
||||
codex_rust_crate(
|
||||
name = "core-skills",
|
||||
crate_name = "codex_core_skills",
|
||||
compile_data = glob(
|
||||
include = ["**"],
|
||||
exclude = [
|
||||
"**/* *",
|
||||
"BUILD.bazel",
|
||||
"Cargo.toml",
|
||||
],
|
||||
allow_empty = True,
|
||||
),
|
||||
)
|
||||
40
codex-rs/core-skills/Cargo.toml
Normal file
40
codex-rs/core-skills/Cargo.toml
Normal file
@@ -0,0 +1,40 @@
|
||||
[package]
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
name = "codex-core-skills"
|
||||
version.workspace = true
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
name = "codex_core_skills"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
codex-analytics = { workspace = true }
|
||||
codex-app-server-protocol = { workspace = true }
|
||||
codex-config = { workspace = true }
|
||||
codex-instructions = { workspace = true }
|
||||
codex-login = { workspace = true }
|
||||
codex-otel = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
codex-skills = { workspace = true }
|
||||
codex-utils-absolute-path = { workspace = true }
|
||||
codex-utils-plugins = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
dunce = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
serde_yaml = { workspace = true }
|
||||
shlex = { workspace = true }
|
||||
tokio = { workspace = true, features = ["fs", "macros", "rt"] }
|
||||
toml = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
1
codex-rs/core-skills/src/lib.rs
Normal file
1
codex-rs/core-skills/src/lib.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod skills;
|
||||
@@ -3,34 +3,32 @@ use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
use codex_config::ConfigLayerStack;
|
||||
use codex_config::ConfigLayerStackOrdering;
|
||||
use codex_config::SkillConfig;
|
||||
use codex_config::SkillsConfig;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::config::types::SkillConfig;
|
||||
use crate::config::types::SkillsConfig;
|
||||
use crate::config_loader::ConfigLayerStack;
|
||||
use crate::config_loader::ConfigLayerStackOrdering;
|
||||
use crate::skills::SkillMetadata;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub(crate) enum SkillConfigRuleSelector {
|
||||
pub enum SkillConfigRuleSelector {
|
||||
Name(String),
|
||||
Path(PathBuf),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub(crate) struct SkillConfigRule {
|
||||
pub struct SkillConfigRule {
|
||||
pub selector: SkillConfigRuleSelector,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
|
||||
pub(crate) struct SkillConfigRules {
|
||||
pub struct SkillConfigRules {
|
||||
pub entries: Vec<SkillConfigRule>,
|
||||
}
|
||||
|
||||
pub(crate) fn skill_config_rules_from_stack(
|
||||
config_layer_stack: &ConfigLayerStack,
|
||||
) -> SkillConfigRules {
|
||||
pub fn skill_config_rules_from_stack(config_layer_stack: &ConfigLayerStack) -> SkillConfigRules {
|
||||
let mut entries = Vec::new();
|
||||
for layer in config_layer_stack.get_layers(
|
||||
ConfigLayerStackOrdering::LowestPrecedenceFirst,
|
||||
@@ -71,7 +69,7 @@ pub(crate) fn skill_config_rules_from_stack(
|
||||
SkillConfigRules { entries }
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_disabled_skill_paths(
|
||||
pub fn resolve_disabled_skill_paths(
|
||||
skills: &[SkillMetadata],
|
||||
rules: &SkillConfigRules,
|
||||
) -> HashSet<PathBuf> {
|
||||
30
codex-rs/core-skills/src/skills/env_var_dependencies.rs
Normal file
30
codex-rs/core-skills/src/skills/env_var_dependencies.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
use crate::skills::SkillMetadata;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SkillDependencyInfo {
|
||||
pub skill_name: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
pub fn collect_env_var_dependencies(
|
||||
mentioned_skills: &[SkillMetadata],
|
||||
) -> Vec<SkillDependencyInfo> {
|
||||
let mut dependencies = Vec::new();
|
||||
for skill in mentioned_skills {
|
||||
let Some(skill_dependencies) = &skill.dependencies else {
|
||||
continue;
|
||||
};
|
||||
for tool in &skill_dependencies.tools {
|
||||
if tool.r#type != "env_var" || tool.value.is_empty() {
|
||||
continue;
|
||||
}
|
||||
dependencies.push(SkillDependencyInfo {
|
||||
skill_name: skill.name.clone(),
|
||||
name: tool.value.clone(),
|
||||
description: tool.description.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
dependencies
|
||||
}
|
||||
@@ -2,26 +2,26 @@ use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::analytics_client::AnalyticsEventsClient;
|
||||
use crate::analytics_client::InvocationType;
|
||||
use crate::analytics_client::SkillInvocation;
|
||||
use crate::analytics_client::TrackEventsContext;
|
||||
use crate::instructions::SkillInstructions;
|
||||
use crate::mention_syntax::TOOL_MENTION_SIGIL;
|
||||
use crate::mentions::build_skill_name_counts;
|
||||
use crate::skills::SkillMetadata;
|
||||
use crate::skills::build_skill_name_counts;
|
||||
use codex_analytics::AnalyticsEventsClient;
|
||||
use codex_analytics::InvocationType;
|
||||
use codex_analytics::SkillInvocation;
|
||||
use codex_analytics::TrackEventsContext;
|
||||
use codex_instructions::SkillInstructions;
|
||||
use codex_otel::SessionTelemetry;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_utils_plugins::mention_syntax::TOOL_MENTION_SIGIL;
|
||||
use tokio::fs;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct SkillInjections {
|
||||
pub(crate) items: Vec<ResponseItem>,
|
||||
pub(crate) warnings: Vec<String>,
|
||||
pub struct SkillInjections {
|
||||
pub items: Vec<ResponseItem>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
pub(crate) async fn build_skill_injections(
|
||||
pub async fn build_skill_injections(
|
||||
mentioned_skills: &[SkillMetadata],
|
||||
otel: Option<&SessionTelemetry>,
|
||||
analytics_client: &AnalyticsEventsClient,
|
||||
@@ -97,7 +97,7 @@ fn emit_skill_injected_metric(
|
||||
/// Complexity: `O(T + (N_s + N_t) * S)` time, `O(S + M)` space, where:
|
||||
/// `S` = number of skills, `T` = total text length, `N_s` = number of structured skill inputs,
|
||||
/// `N_t` = number of text inputs, `M` = max mentions parsed from a single text input.
|
||||
pub(crate) fn collect_explicit_skill_mentions(
|
||||
pub fn collect_explicit_skill_mentions(
|
||||
inputs: &[UserInput],
|
||||
skills: &[SkillMetadata],
|
||||
disabled_paths: &HashSet<PathBuf>,
|
||||
@@ -159,7 +159,7 @@ struct SkillSelectionContext<'a> {
|
||||
connector_slug_counts: &'a HashMap<String, usize>,
|
||||
}
|
||||
|
||||
pub(crate) struct ToolMentions<'a> {
|
||||
pub struct ToolMentions<'a> {
|
||||
names: HashSet<&'a str>,
|
||||
paths: HashSet<&'a str>,
|
||||
plain_names: HashSet<&'a str>,
|
||||
@@ -170,17 +170,17 @@ impl<'a> ToolMentions<'a> {
|
||||
self.names.is_empty() && self.paths.is_empty()
|
||||
}
|
||||
|
||||
pub(crate) fn plain_names(&self) -> impl Iterator<Item = &'a str> + '_ {
|
||||
pub fn plain_names(&self) -> impl Iterator<Item = &'a str> + '_ {
|
||||
self.plain_names.iter().copied()
|
||||
}
|
||||
|
||||
pub(crate) fn paths(&self) -> impl Iterator<Item = &'a str> + '_ {
|
||||
pub fn paths(&self) -> impl Iterator<Item = &'a str> + '_ {
|
||||
self.paths.iter().copied()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum ToolMentionKind {
|
||||
pub enum ToolMentionKind {
|
||||
App,
|
||||
Mcp,
|
||||
Plugin,
|
||||
@@ -194,7 +194,7 @@ const PLUGIN_PATH_PREFIX: &str = "plugin://";
|
||||
const SKILL_PATH_PREFIX: &str = "skill://";
|
||||
const SKILL_FILENAME: &str = "SKILL.md";
|
||||
|
||||
pub(crate) fn tool_kind_for_path(path: &str) -> ToolMentionKind {
|
||||
pub fn tool_kind_for_path(path: &str) -> ToolMentionKind {
|
||||
if path.starts_with(APP_PATH_PREFIX) {
|
||||
ToolMentionKind::App
|
||||
} else if path.starts_with(MCP_PATH_PREFIX) {
|
||||
@@ -213,12 +213,12 @@ fn is_skill_filename(path: &str) -> bool {
|
||||
file_name.eq_ignore_ascii_case(SKILL_FILENAME)
|
||||
}
|
||||
|
||||
pub(crate) fn app_id_from_path(path: &str) -> Option<&str> {
|
||||
pub fn app_id_from_path(path: &str) -> Option<&str> {
|
||||
path.strip_prefix(APP_PATH_PREFIX)
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
pub(crate) fn plugin_config_name_from_path(path: &str) -> Option<&str> {
|
||||
pub fn plugin_config_name_from_path(path: &str) -> Option<&str> {
|
||||
path.strip_prefix(PLUGIN_PATH_PREFIX)
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
@@ -232,11 +232,11 @@ pub(crate) fn normalize_skill_path(path: &str) -> &str {
|
||||
/// Supports explicit resource links in the form `[$tool-name](resource path)`. When a
|
||||
/// resource path is present, it is captured for exact path matching while also tracking
|
||||
/// the name for fallback matching.
|
||||
pub(crate) fn extract_tool_mentions(text: &str) -> ToolMentions<'_> {
|
||||
pub fn extract_tool_mentions(text: &str) -> ToolMentions<'_> {
|
||||
extract_tool_mentions_with_sigil(text, TOOL_MENTION_SIGIL)
|
||||
}
|
||||
|
||||
pub(crate) fn extract_tool_mentions_with_sigil(text: &str, sigil: char) -> ToolMentions<'_> {
|
||||
pub fn extract_tool_mentions_with_sigil(text: &str, sigil: char) -> ToolMentions<'_> {
|
||||
let text_bytes = text.as_bytes();
|
||||
let mut mentioned_names: HashSet<&str> = HashSet::new();
|
||||
let mut mentioned_paths: HashSet<&str> = HashSet::new();
|
||||
@@ -2,11 +2,6 @@ use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::analytics_client::InvocationType;
|
||||
use crate::analytics_client::SkillInvocation;
|
||||
use crate::analytics_client::build_track_events_context;
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::skills::SkillLoadOutcome;
|
||||
use crate::skills::SkillMetadata;
|
||||
|
||||
@@ -31,14 +26,12 @@ pub(crate) fn build_implicit_skill_path_indexes(
|
||||
(by_scripts_dir, by_skill_doc_path)
|
||||
}
|
||||
|
||||
fn detect_implicit_skill_invocation_for_command(
|
||||
pub fn detect_implicit_skill_invocation_for_command(
|
||||
outcome: &SkillLoadOutcome,
|
||||
turn_context: &TurnContext,
|
||||
command: &str,
|
||||
workdir: Option<&str>,
|
||||
workdir: &Path,
|
||||
) -> Option<SkillMetadata> {
|
||||
let workdir = turn_context.resolve_path(workdir.map(str::to_owned));
|
||||
let workdir = normalize_path(workdir.as_path());
|
||||
let workdir = normalize_path(workdir);
|
||||
let tokens = tokenize_command(command);
|
||||
|
||||
if let Some(candidate) = detect_skill_script_run(outcome, tokens.as_slice(), workdir.as_path())
|
||||
@@ -46,82 +39,12 @@ fn detect_implicit_skill_invocation_for_command(
|
||||
return Some(candidate);
|
||||
}
|
||||
|
||||
if let Some(candidate) = detect_skill_doc_read(outcome, tokens.as_slice(), workdir.as_path()) {
|
||||
return Some(candidate);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_emit_implicit_skill_invocation(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
command: &str,
|
||||
workdir: Option<&str>,
|
||||
) {
|
||||
let Some(candidate) = detect_implicit_skill_invocation_for_command(
|
||||
&turn_context.turn_skills.outcome,
|
||||
turn_context,
|
||||
command,
|
||||
workdir,
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let invocation = SkillInvocation {
|
||||
skill_name: candidate.name,
|
||||
skill_scope: candidate.scope,
|
||||
skill_path: candidate.path_to_skills_md,
|
||||
invocation_type: InvocationType::Implicit,
|
||||
};
|
||||
let skill_scope = match invocation.skill_scope {
|
||||
codex_protocol::protocol::SkillScope::User => "user",
|
||||
codex_protocol::protocol::SkillScope::Repo => "repo",
|
||||
codex_protocol::protocol::SkillScope::System => "system",
|
||||
codex_protocol::protocol::SkillScope::Admin => "admin",
|
||||
};
|
||||
let skill_path = invocation.skill_path.to_string_lossy();
|
||||
let skill_name = invocation.skill_name.clone();
|
||||
let seen_key = format!("{skill_scope}:{skill_path}:{skill_name}");
|
||||
let inserted = {
|
||||
let mut seen_skills = turn_context
|
||||
.turn_skills
|
||||
.implicit_invocation_seen_skills
|
||||
.lock()
|
||||
.await;
|
||||
seen_skills.insert(seen_key)
|
||||
};
|
||||
if !inserted {
|
||||
return;
|
||||
}
|
||||
|
||||
turn_context.session_telemetry.counter(
|
||||
"codex.skill.injected",
|
||||
/*inc*/ 1,
|
||||
&[
|
||||
("status", "ok"),
|
||||
("skill", skill_name.as_str()),
|
||||
("invoke_type", "implicit"),
|
||||
],
|
||||
);
|
||||
sess.services
|
||||
.analytics_events_client
|
||||
.track_skill_invocations(
|
||||
build_track_events_context(
|
||||
turn_context.model_info.slug.clone(),
|
||||
sess.conversation_id.to_string(),
|
||||
turn_context.sub_id.clone(),
|
||||
),
|
||||
vec![invocation],
|
||||
);
|
||||
detect_skill_doc_read(outcome, tokens.as_slice(), workdir.as_path())
|
||||
}
|
||||
|
||||
fn tokenize_command(command: &str) -> Vec<String> {
|
||||
shlex::split(command).unwrap_or_else(|| {
|
||||
command
|
||||
.split_whitespace()
|
||||
.map(std::string::ToString::to_string)
|
||||
.collect()
|
||||
})
|
||||
shlex::split(command)
|
||||
.unwrap_or_else(|| command.split_whitespace().map(str::to_string).collect())
|
||||
}
|
||||
|
||||
fn script_run_token(tokens: &[String]) -> Option<&str> {
|
||||
@@ -137,12 +60,9 @@ fn script_run_token(tokens: &[String]) -> Option<&str> {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut script_token: Option<&str> = None;
|
||||
let mut script_token = None;
|
||||
for token in tokens.iter().skip(1) {
|
||||
if token == "--" {
|
||||
continue;
|
||||
}
|
||||
if token.starts_with('-') {
|
||||
if token == "--" || token.starts_with('-') {
|
||||
continue;
|
||||
}
|
||||
script_token = Some(token.as_str());
|
||||
@@ -1,9 +1,3 @@
|
||||
use crate::config_loader::ConfigLayerStack;
|
||||
use crate::config_loader::ConfigLayerStackOrdering;
|
||||
use crate::config_loader::default_project_root_markers;
|
||||
use crate::config_loader::merge_toml_values;
|
||||
use crate::config_loader::project_root_markers_from_config;
|
||||
use crate::plugins::plugin_namespace_for_skill_path;
|
||||
use crate::skills::model::SkillDependencies;
|
||||
use crate::skills::model::SkillError;
|
||||
use crate::skills::model::SkillInterface;
|
||||
@@ -14,6 +8,11 @@ use crate::skills::model::SkillPolicy;
|
||||
use crate::skills::model::SkillToolDependency;
|
||||
use crate::skills::system::system_cache_root_dir;
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
use codex_config::ConfigLayerStack;
|
||||
use codex_config::ConfigLayerStackOrdering;
|
||||
use codex_config::default_project_root_markers;
|
||||
use codex_config::merge_toml_values;
|
||||
use codex_config::project_root_markers_from_config;
|
||||
use codex_protocol::models::FileSystemPermissions;
|
||||
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
|
||||
use codex_protocol::models::NetworkPermissions;
|
||||
@@ -21,6 +20,7 @@ use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::Product;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use codex_utils_absolute_path::AbsolutePathBufGuard;
|
||||
use codex_utils_plugins::plugin_namespace_for_skill_path;
|
||||
use dirs::home_dir;
|
||||
use dunce::canonicalize as canonicalize_path;
|
||||
use serde::Deserialize;
|
||||
@@ -35,9 +35,6 @@ use std::path::PathBuf;
|
||||
use toml::Value as TomlValue;
|
||||
use tracing::error;
|
||||
|
||||
#[cfg(test)]
|
||||
use crate::config::Config;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SkillFrontmatter {
|
||||
#[serde(default)]
|
||||
@@ -176,12 +173,12 @@ impl fmt::Display for SkillParseError {
|
||||
|
||||
impl Error for SkillParseError {}
|
||||
|
||||
pub(crate) struct SkillRoot {
|
||||
pub(crate) path: PathBuf,
|
||||
pub(crate) scope: SkillScope,
|
||||
pub struct SkillRoot {
|
||||
pub path: PathBuf,
|
||||
pub scope: SkillScope,
|
||||
}
|
||||
|
||||
pub(crate) fn load_skills_from_roots<I>(roots: I) -> SkillLoadOutcome
|
||||
pub fn load_skills_from_roots<I>(roots: I) -> SkillLoadOutcome
|
||||
where
|
||||
I: IntoIterator<Item = SkillRoot>,
|
||||
{
|
||||
@@ -1,14 +1,9 @@
|
||||
use super::*;
|
||||
use crate::config::ConfigBuilder;
|
||||
use crate::config::ConfigOverrides;
|
||||
use crate::config::ConfigToml;
|
||||
use crate::config::ProjectConfig;
|
||||
use crate::config_loader::ConfigLayerEntry;
|
||||
use crate::config_loader::ConfigLayerStack;
|
||||
use crate::config_loader::ConfigRequirements;
|
||||
use crate::config_loader::ConfigRequirementsToml;
|
||||
use codex_config::CONFIG_TOML_FILE;
|
||||
use codex_protocol::config_types::TrustLevel;
|
||||
use codex_config::ConfigLayerEntry;
|
||||
use codex_config::ConfigLayerStack;
|
||||
use codex_config::ConfigRequirements;
|
||||
use codex_config::ConfigRequirementsToml;
|
||||
use codex_protocol::models::FileSystemPermissions;
|
||||
use codex_protocol::models::MacOsAutomationPermission;
|
||||
use codex_protocol::models::MacOsContactsPermission;
|
||||
@@ -19,53 +14,109 @@ use codex_protocol::protocol::Product;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
use toml::Value as TomlValue;
|
||||
|
||||
const REPO_ROOT_CONFIG_DIR_NAME: &str = ".codex";
|
||||
|
||||
async fn make_config(codex_home: &TempDir) -> Config {
|
||||
struct TestConfig {
|
||||
cwd: PathBuf,
|
||||
config_layer_stack: ConfigLayerStack,
|
||||
}
|
||||
|
||||
async fn make_config(codex_home: &TempDir) -> TestConfig {
|
||||
make_config_for_cwd(codex_home, codex_home.path().to_path_buf()).await
|
||||
}
|
||||
|
||||
async fn make_config_for_cwd(codex_home: &TempDir, cwd: PathBuf) -> Config {
|
||||
let trust_root = cwd
|
||||
.ancestors()
|
||||
.find(|ancestor| ancestor.join(".git").exists())
|
||||
.map(Path::to_path_buf)
|
||||
.unwrap_or_else(|| cwd.clone());
|
||||
|
||||
fs::write(
|
||||
codex_home.path().join(CONFIG_TOML_FILE),
|
||||
toml::to_string(&ConfigToml {
|
||||
projects: Some(HashMap::from([(
|
||||
trust_root.to_string_lossy().to_string(),
|
||||
ProjectConfig {
|
||||
trust_level: Some(TrustLevel::Trusted),
|
||||
},
|
||||
)])),
|
||||
..Default::default()
|
||||
})
|
||||
.expect("serialize config"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let harness_overrides = ConfigOverrides {
|
||||
cwd: Some(cwd),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.harness_overrides(harness_overrides)
|
||||
.build()
|
||||
.await
|
||||
.expect("defaults for test should always succeed")
|
||||
fn config_file(path: PathBuf) -> AbsolutePathBuf {
|
||||
AbsolutePathBuf::from_absolute_path(path).expect("config file path should be absolute")
|
||||
}
|
||||
|
||||
fn load_skills_for_test(config: &Config) -> SkillLoadOutcome {
|
||||
fn project_layers_for_cwd(cwd: &Path) -> Vec<ConfigLayerEntry> {
|
||||
let cwd_dir = if cwd.is_dir() {
|
||||
cwd.to_path_buf()
|
||||
} else {
|
||||
cwd.parent()
|
||||
.expect("file cwd should have a parent directory")
|
||||
.to_path_buf()
|
||||
};
|
||||
let project_root = cwd_dir
|
||||
.ancestors()
|
||||
.find(|ancestor| ancestor.join(".git").exists())
|
||||
.unwrap_or(cwd_dir.as_path())
|
||||
.to_path_buf();
|
||||
|
||||
let mut layers = cwd_dir
|
||||
.ancestors()
|
||||
.scan(false, |done, dir| {
|
||||
if *done {
|
||||
None
|
||||
} else {
|
||||
if dir == project_root {
|
||||
*done = true;
|
||||
}
|
||||
Some(dir.to_path_buf())
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
layers.reverse();
|
||||
|
||||
layers
|
||||
.into_iter()
|
||||
.filter_map(|dir| {
|
||||
let dot_codex = dir.join(REPO_ROOT_CONFIG_DIR_NAME);
|
||||
dot_codex.is_dir().then(|| {
|
||||
ConfigLayerEntry::new(
|
||||
ConfigLayerSource::Project {
|
||||
dot_codex_folder: AbsolutePathBuf::from_absolute_path(dot_codex)
|
||||
.expect("project .codex path should be absolute"),
|
||||
},
|
||||
TomlValue::Table(toml::map::Map::new()),
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn make_config_for_cwd(codex_home: &TempDir, cwd: PathBuf) -> TestConfig {
|
||||
let user_config_path = codex_home.path().join(CONFIG_TOML_FILE);
|
||||
let system_config_path = codex_home.path().join("etc/codex/config.toml");
|
||||
fs::create_dir_all(
|
||||
system_config_path
|
||||
.parent()
|
||||
.expect("system config path should have a parent"),
|
||||
)
|
||||
.expect("create fake system config dir");
|
||||
|
||||
let mut layers = vec![
|
||||
ConfigLayerEntry::new(
|
||||
ConfigLayerSource::System {
|
||||
file: config_file(system_config_path),
|
||||
},
|
||||
TomlValue::Table(toml::map::Map::new()),
|
||||
),
|
||||
ConfigLayerEntry::new(
|
||||
ConfigLayerSource::User {
|
||||
file: config_file(user_config_path),
|
||||
},
|
||||
TomlValue::Table(toml::map::Map::new()),
|
||||
),
|
||||
];
|
||||
layers.extend(project_layers_for_cwd(&cwd));
|
||||
|
||||
TestConfig {
|
||||
cwd,
|
||||
config_layer_stack: ConfigLayerStack::new(
|
||||
layers,
|
||||
ConfigRequirements::default(),
|
||||
ConfigRequirementsToml::default(),
|
||||
)
|
||||
.expect("valid config layer stack"),
|
||||
}
|
||||
}
|
||||
|
||||
fn load_skills_for_test(config: &TestConfig) -> SkillLoadOutcome {
|
||||
// Keep unit tests hermetic by never scanning the real `$HOME/.agents/skills`.
|
||||
super::load_skills_from_roots(super::skill_roots_with_home_dir(
|
||||
&config.config_layer_stack,
|
||||
@@ -5,19 +5,12 @@ use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use codex_config::ConfigLayerStack;
|
||||
use codex_protocol::protocol::Product;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use toml::Value as TomlValue;
|
||||
use tracing::info;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::config::types::SkillsConfig;
|
||||
use crate::config_loader::CloudRequirementsLoader;
|
||||
use crate::config_loader::LoaderOverrides;
|
||||
use crate::config_loader::load_config_layers_state;
|
||||
use crate::plugins::PluginsManager;
|
||||
use crate::skills::SkillLoadOutcome;
|
||||
use crate::skills::build_implicit_skill_path_indexes;
|
||||
use crate::skills::config_rules::SkillConfigRules;
|
||||
@@ -28,38 +21,51 @@ use crate::skills::loader::load_skills_from_roots;
|
||||
use crate::skills::loader::skill_roots;
|
||||
use crate::skills::system::install_system_skills;
|
||||
use crate::skills::system::uninstall_system_skills;
|
||||
use codex_config::SkillsConfig;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SkillsLoadInput {
|
||||
pub cwd: PathBuf,
|
||||
pub effective_skill_roots: Vec<PathBuf>,
|
||||
pub config_layer_stack: ConfigLayerStack,
|
||||
pub bundled_skills_enabled: bool,
|
||||
}
|
||||
|
||||
impl SkillsLoadInput {
|
||||
pub fn new(
|
||||
cwd: PathBuf,
|
||||
effective_skill_roots: Vec<PathBuf>,
|
||||
config_layer_stack: ConfigLayerStack,
|
||||
bundled_skills_enabled: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
cwd,
|
||||
effective_skill_roots,
|
||||
config_layer_stack,
|
||||
bundled_skills_enabled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SkillsManager {
|
||||
codex_home: PathBuf,
|
||||
plugins_manager: Arc<PluginsManager>,
|
||||
restriction_product: Option<Product>,
|
||||
cache_by_cwd: RwLock<HashMap<PathBuf, SkillLoadOutcome>>,
|
||||
cache_by_config: RwLock<HashMap<ConfigSkillsCacheKey, SkillLoadOutcome>>,
|
||||
}
|
||||
|
||||
impl SkillsManager {
|
||||
pub fn new(
|
||||
codex_home: PathBuf,
|
||||
plugins_manager: Arc<PluginsManager>,
|
||||
bundled_skills_enabled: bool,
|
||||
) -> Self {
|
||||
Self::new_with_restriction_product(
|
||||
codex_home,
|
||||
plugins_manager,
|
||||
bundled_skills_enabled,
|
||||
Some(Product::Codex),
|
||||
)
|
||||
pub fn new(codex_home: PathBuf, bundled_skills_enabled: bool) -> Self {
|
||||
Self::new_with_restriction_product(codex_home, bundled_skills_enabled, Some(Product::Codex))
|
||||
}
|
||||
|
||||
pub fn new_with_restriction_product(
|
||||
codex_home: PathBuf,
|
||||
plugins_manager: Arc<PluginsManager>,
|
||||
bundled_skills_enabled: bool,
|
||||
restriction_product: Option<Product>,
|
||||
) -> Self {
|
||||
let manager = Self {
|
||||
codex_home,
|
||||
plugins_manager,
|
||||
restriction_product,
|
||||
cache_by_cwd: RwLock::new(HashMap::new()),
|
||||
cache_by_config: RwLock::new(HashMap::new()),
|
||||
@@ -80,9 +86,9 @@ impl SkillsManager {
|
||||
/// This path uses a cache keyed by the effective skill-relevant config state rather than just
|
||||
/// cwd so role-local and session-local skill overrides cannot bleed across sessions that happen
|
||||
/// to share a directory.
|
||||
pub fn skills_for_config(&self, config: &Config) -> SkillLoadOutcome {
|
||||
let roots = self.skill_roots_for_config(config);
|
||||
let skill_config_rules = skill_config_rules_from_stack(&config.config_layer_stack);
|
||||
pub fn skills_for_config(&self, input: &SkillsLoadInput) -> SkillLoadOutcome {
|
||||
let roots = self.skill_roots_for_config(input);
|
||||
let skill_config_rules = skill_config_rules_from_stack(&input.config_layer_stack);
|
||||
let cache_key = config_skills_cache_key(&roots, &skill_config_rules);
|
||||
if let Some(outcome) = self.cached_outcome_for_config(&cache_key) {
|
||||
return outcome;
|
||||
@@ -97,14 +103,13 @@ impl SkillsManager {
|
||||
outcome
|
||||
}
|
||||
|
||||
pub(crate) fn skill_roots_for_config(&self, config: &Config) -> Vec<SkillRoot> {
|
||||
let loaded_plugins = self.plugins_manager.plugins_for_config(config);
|
||||
pub fn skill_roots_for_config(&self, input: &SkillsLoadInput) -> Vec<SkillRoot> {
|
||||
let mut roots = skill_roots(
|
||||
&config.config_layer_stack,
|
||||
&config.cwd,
|
||||
loaded_plugins.effective_skill_roots(),
|
||||
&input.config_layer_stack,
|
||||
input.cwd.as_path(),
|
||||
input.effective_skill_roots.clone(),
|
||||
);
|
||||
if !config.bundled_skills_enabled() {
|
||||
if !input.bundled_skills_enabled {
|
||||
roots.retain(|root| root.scope != SkillScope::System);
|
||||
}
|
||||
roots
|
||||
@@ -112,74 +117,34 @@ impl SkillsManager {
|
||||
|
||||
pub async fn skills_for_cwd(
|
||||
&self,
|
||||
cwd: &Path,
|
||||
config: &Config,
|
||||
input: &SkillsLoadInput,
|
||||
force_reload: bool,
|
||||
) -> SkillLoadOutcome {
|
||||
if !force_reload && let Some(outcome) = self.cached_outcome_for_cwd(cwd) {
|
||||
if !force_reload && let Some(outcome) = self.cached_outcome_for_cwd(input.cwd.as_path()) {
|
||||
return outcome;
|
||||
}
|
||||
|
||||
self.skills_for_cwd_with_extra_user_roots(cwd, config, force_reload, &[])
|
||||
self.skills_for_cwd_with_extra_user_roots(input, force_reload, &[])
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn skills_for_cwd_with_extra_user_roots(
|
||||
&self,
|
||||
cwd: &Path,
|
||||
config: &Config,
|
||||
input: &SkillsLoadInput,
|
||||
force_reload: bool,
|
||||
extra_user_roots: &[PathBuf],
|
||||
) -> SkillLoadOutcome {
|
||||
if !force_reload && let Some(outcome) = self.cached_outcome_for_cwd(cwd) {
|
||||
if !force_reload && let Some(outcome) = self.cached_outcome_for_cwd(input.cwd.as_path()) {
|
||||
return outcome;
|
||||
}
|
||||
let normalized_extra_user_roots = normalize_extra_user_roots(extra_user_roots);
|
||||
|
||||
let cwd_abs = match AbsolutePathBuf::try_from(cwd) {
|
||||
Ok(cwd_abs) => cwd_abs,
|
||||
Err(err) => {
|
||||
return SkillLoadOutcome {
|
||||
errors: vec![crate::skills::model::SkillError {
|
||||
path: cwd.to_path_buf(),
|
||||
message: err.to_string(),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let cli_overrides: Vec<(String, TomlValue)> = Vec::new();
|
||||
let config_layer_stack = match load_config_layers_state(
|
||||
&self.codex_home,
|
||||
Some(cwd_abs),
|
||||
&cli_overrides,
|
||||
LoaderOverrides::default(),
|
||||
CloudRequirementsLoader::default(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(config_layer_stack) => config_layer_stack,
|
||||
Err(err) => {
|
||||
return SkillLoadOutcome {
|
||||
errors: vec![crate::skills::model::SkillError {
|
||||
path: cwd.to_path_buf(),
|
||||
message: err.to_string(),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let loaded_plugins = self
|
||||
.plugins_manager
|
||||
.plugins_for_config_with_force_reload(config, force_reload);
|
||||
let mut roots = skill_roots(
|
||||
&config_layer_stack,
|
||||
cwd,
|
||||
loaded_plugins.effective_skill_roots(),
|
||||
&input.config_layer_stack,
|
||||
input.cwd.as_path(),
|
||||
input.effective_skill_roots.clone(),
|
||||
);
|
||||
if !bundled_skills_enabled_from_stack(&config_layer_stack) {
|
||||
if !bundled_skills_enabled_from_stack(&input.config_layer_stack) {
|
||||
roots.retain(|root| root.scope != SkillScope::System);
|
||||
}
|
||||
roots.extend(
|
||||
@@ -191,13 +156,13 @@ impl SkillsManager {
|
||||
scope: SkillScope::User,
|
||||
}),
|
||||
);
|
||||
let skill_config_rules = skill_config_rules_from_stack(&config_layer_stack);
|
||||
let skill_config_rules = skill_config_rules_from_stack(&input.config_layer_stack);
|
||||
let outcome = self.build_skill_outcome(roots, &skill_config_rules);
|
||||
let mut cache = self
|
||||
.cache_by_cwd
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
cache.insert(cwd.to_path_buf(), outcome.clone());
|
||||
cache.insert(input.cwd.clone(), outcome.clone());
|
||||
outcome
|
||||
}
|
||||
|
||||
@@ -261,8 +226,8 @@ struct ConfigSkillsCacheKey {
|
||||
skill_config_rules: SkillConfigRules,
|
||||
}
|
||||
|
||||
pub(crate) fn bundled_skills_enabled_from_stack(
|
||||
config_layer_stack: &crate::config_loader::ConfigLayerStack,
|
||||
pub fn bundled_skills_enabled_from_stack(
|
||||
config_layer_stack: &codex_config::ConfigLayerStack,
|
||||
) -> bool {
|
||||
let effective_config = config_layer_stack.effective_config();
|
||||
let Some(skills_value) = effective_config
|
||||
@@ -1,15 +1,15 @@
|
||||
use super::*;
|
||||
use crate::config::ConfigBuilder;
|
||||
use crate::config::ConfigOverrides;
|
||||
use crate::config_loader::ConfigLayerEntry;
|
||||
use crate::config_loader::ConfigLayerStack;
|
||||
use crate::config_loader::ConfigRequirementsToml;
|
||||
use crate::plugins::PluginsManager;
|
||||
use crate::skills::SkillMetadata;
|
||||
use crate::skills::config_rules::resolve_disabled_skill_paths;
|
||||
use crate::skills::config_rules::skill_config_rules_from_stack;
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
use codex_config::CONFIG_TOML_FILE;
|
||||
use codex_config::ConfigLayerEntry;
|
||||
use codex_config::ConfigLayerStack;
|
||||
use codex_config::ConfigRequirementsToml;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::TempDir;
|
||||
@@ -64,6 +64,77 @@ fn test_skill(name: &str, path: PathBuf) -> SkillMetadata {
|
||||
}
|
||||
}
|
||||
|
||||
fn user_config_layer(codex_home: &TempDir, config_toml: &str) -> ConfigLayerEntry {
|
||||
let config_path = AbsolutePathBuf::try_from(codex_home.path().join(CONFIG_TOML_FILE))
|
||||
.expect("user config path should be absolute");
|
||||
ConfigLayerEntry::new(
|
||||
ConfigLayerSource::User { file: config_path },
|
||||
toml::from_str(config_toml).expect("user layer toml"),
|
||||
)
|
||||
}
|
||||
|
||||
fn config_stack(codex_home: &TempDir, user_config_toml: &str) -> ConfigLayerStack {
|
||||
ConfigLayerStack::new(
|
||||
vec![user_config_layer(codex_home, user_config_toml)],
|
||||
Default::default(),
|
||||
ConfigRequirementsToml::default(),
|
||||
)
|
||||
.expect("valid config layer stack")
|
||||
}
|
||||
|
||||
fn config_stack_with_session_flags(
|
||||
codex_home: &TempDir,
|
||||
user_config_toml: &str,
|
||||
session_flags_toml: &str,
|
||||
) -> ConfigLayerStack {
|
||||
ConfigLayerStack::new(
|
||||
vec![
|
||||
user_config_layer(codex_home, user_config_toml),
|
||||
ConfigLayerEntry::new(
|
||||
ConfigLayerSource::SessionFlags,
|
||||
toml::from_str(session_flags_toml).expect("session layer toml"),
|
||||
),
|
||||
],
|
||||
Default::default(),
|
||||
ConfigRequirementsToml::default(),
|
||||
)
|
||||
.expect("valid config layer stack")
|
||||
}
|
||||
|
||||
fn path_toggle_config(path: &std::path::Path, enabled: bool) -> String {
|
||||
format!(
|
||||
r#"[[skills.config]]
|
||||
path = "{}"
|
||||
enabled = {enabled}
|
||||
"#,
|
||||
path.display()
|
||||
)
|
||||
}
|
||||
|
||||
fn name_toggle_config(name: &str, enabled: bool) -> String {
|
||||
format!(
|
||||
r#"[[skills.config]]
|
||||
name = "{name}"
|
||||
enabled = {enabled}
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
fn skills_for_config_with_stack(
|
||||
skills_manager: &SkillsManager,
|
||||
cwd: &TempDir,
|
||||
config_layer_stack: &ConfigLayerStack,
|
||||
effective_skill_roots: &[PathBuf],
|
||||
) -> SkillLoadOutcome {
|
||||
let skills_input = SkillsLoadInput::new(
|
||||
cwd.path().to_path_buf(),
|
||||
effective_skill_roots.to_vec(),
|
||||
config_layer_stack.clone(),
|
||||
bundled_skills_enabled_from_stack(config_layer_stack),
|
||||
);
|
||||
skills_manager.skills_for_config(&skills_input)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_with_disabled_bundled_skills_removes_stale_cached_system_skills() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
@@ -72,9 +143,7 @@ fn new_with_disabled_bundled_skills_removes_stale_cached_system_skills() {
|
||||
fs::write(stale_system_skill_dir.join("SKILL.md"), "# stale\n")
|
||||
.expect("write stale system skill");
|
||||
|
||||
let plugins_manager = Arc::new(PluginsManager::new(codex_home.path().to_path_buf()));
|
||||
let _skills_manager =
|
||||
SkillsManager::new(codex_home.path().to_path_buf(), plugins_manager, false);
|
||||
let _skills_manager = SkillsManager::new(codex_home.path().to_path_buf(), false);
|
||||
|
||||
assert!(
|
||||
!codex_home.path().join("skills/.system").exists(),
|
||||
@@ -86,22 +155,11 @@ fn new_with_disabled_bundled_skills_removes_stale_cached_system_skills() {
|
||||
async fn skills_for_config_reuses_cache_for_same_effective_config() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
let cwd = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
let cfg = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.harness_overrides(ConfigOverrides {
|
||||
cwd: Some(cwd.path().to_path_buf()),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.await
|
||||
.expect("defaults for test should always succeed");
|
||||
|
||||
let plugins_manager = Arc::new(PluginsManager::new(codex_home.path().to_path_buf()));
|
||||
let skills_manager = SkillsManager::new(codex_home.path().to_path_buf(), plugins_manager, true);
|
||||
let config_layer_stack = config_stack(&codex_home, "");
|
||||
let skills_manager = SkillsManager::new(codex_home.path().to_path_buf(), true);
|
||||
|
||||
write_user_skill(&codex_home, "a", "skill-a", "from a");
|
||||
let outcome1 = skills_manager.skills_for_config(&cfg);
|
||||
let outcome1 = skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]);
|
||||
assert!(
|
||||
outcome1.skills.iter().any(|s| s.name == "skill-a"),
|
||||
"expected skill-a to be discovered"
|
||||
@@ -110,7 +168,7 @@ async fn skills_for_config_reuses_cache_for_same_effective_config() {
|
||||
// Write a new skill after the first call; the second call should reuse the config-aware cache
|
||||
// entry because the effective skill config is unchanged.
|
||||
write_user_skill(&codex_home, "b", "skill-b", "from b");
|
||||
let outcome2 = skills_manager.skills_for_config(&cfg);
|
||||
let outcome2 = skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]);
|
||||
assert_eq!(outcome2.errors, outcome1.errors);
|
||||
assert_eq!(outcome2.skills, outcome1.skills);
|
||||
}
|
||||
@@ -127,39 +185,23 @@ async fn skills_for_config_disables_plugin_skills_by_name() {
|
||||
"sample-search",
|
||||
"search sample data",
|
||||
);
|
||||
fs::write(
|
||||
codex_home.path().join(crate::config::CONFIG_TOML_FILE),
|
||||
r#"[features]
|
||||
plugins = true
|
||||
|
||||
[[skills.config]]
|
||||
name = "sample:sample-search"
|
||||
enabled = false
|
||||
|
||||
[plugins."sample@test"]
|
||||
enabled = true
|
||||
"#,
|
||||
)
|
||||
.expect("write config");
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.harness_overrides(ConfigOverrides {
|
||||
cwd: Some(cwd.path().to_path_buf()),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.await
|
||||
.expect("load config");
|
||||
|
||||
let plugins_manager = Arc::new(PluginsManager::new(codex_home.path().to_path_buf()));
|
||||
let skills_manager = SkillsManager::new(
|
||||
codex_home.path().to_path_buf(),
|
||||
plugins_manager,
|
||||
config.bundled_skills_enabled(),
|
||||
let config_layer_stack = config_stack(
|
||||
&codex_home,
|
||||
&name_toggle_config("sample:sample-search", false),
|
||||
);
|
||||
let plugin_skill_root = skill_path
|
||||
.parent()
|
||||
.and_then(std::path::Path::parent)
|
||||
.expect("plugin skill should live under a skills root")
|
||||
.to_path_buf();
|
||||
let skills_manager = SkillsManager::new(codex_home.path().to_path_buf(), true);
|
||||
|
||||
let outcome = skills_manager.skills_for_config(&config);
|
||||
let outcome = skills_for_config_with_stack(
|
||||
&skills_manager,
|
||||
&cwd,
|
||||
&config_layer_stack,
|
||||
&[plugin_skill_root],
|
||||
);
|
||||
let skill = outcome
|
||||
.skills
|
||||
.iter()
|
||||
@@ -182,27 +224,21 @@ async fn skills_for_cwd_reuses_cached_entry_even_when_entry_has_extra_roots() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
let cwd = tempfile::tempdir().expect("tempdir");
|
||||
let extra_root = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.harness_overrides(ConfigOverrides {
|
||||
cwd: Some(cwd.path().to_path_buf()),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.await
|
||||
.expect("defaults for test should always succeed");
|
||||
|
||||
let plugins_manager = Arc::new(PluginsManager::new(codex_home.path().to_path_buf()));
|
||||
let skills_manager = SkillsManager::new(codex_home.path().to_path_buf(), plugins_manager, true);
|
||||
let _ = skills_manager.skills_for_config(&config);
|
||||
let config_layer_stack = config_stack(&codex_home, "");
|
||||
let skills_manager = SkillsManager::new(codex_home.path().to_path_buf(), true);
|
||||
let _ = skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]);
|
||||
|
||||
write_user_skill(&extra_root, "x", "extra-skill", "from extra root");
|
||||
let extra_root_path = extra_root.path().to_path_buf();
|
||||
let base_input = SkillsLoadInput::new(
|
||||
cwd.path().to_path_buf(),
|
||||
Vec::new(),
|
||||
config_layer_stack.clone(),
|
||||
bundled_skills_enabled_from_stack(&config_layer_stack),
|
||||
);
|
||||
let outcome_with_extra = skills_manager
|
||||
.skills_for_cwd_with_extra_user_roots(
|
||||
cwd.path(),
|
||||
&config,
|
||||
&base_input,
|
||||
true,
|
||||
std::slice::from_ref(&extra_root_path),
|
||||
)
|
||||
@@ -222,9 +258,13 @@ async fn skills_for_cwd_reuses_cached_entry_even_when_entry_has_extra_roots() {
|
||||
|
||||
// The cwd-only API returns the current cached entry for this cwd, even when that entry
|
||||
// was produced with extra roots.
|
||||
let outcome_without_extra = skills_manager
|
||||
.skills_for_cwd(cwd.path(), &config, false)
|
||||
.await;
|
||||
let base_input = SkillsLoadInput::new(
|
||||
cwd.path().to_path_buf(),
|
||||
Vec::new(),
|
||||
config_layer_stack.clone(),
|
||||
bundled_skills_enabled_from_stack(&config_layer_stack),
|
||||
);
|
||||
let outcome_without_extra = skills_manager.skills_for_cwd(&base_input, false).await;
|
||||
assert_eq!(outcome_without_extra.skills, outcome_with_extra.skills);
|
||||
assert_eq!(outcome_without_extra.errors, outcome_with_extra.errors);
|
||||
}
|
||||
@@ -240,29 +280,8 @@ async fn skills_for_config_excludes_bundled_skills_when_disabled_in_config() {
|
||||
"---\nname: bundled-skill\ndescription: from bundled root\n---\n\n# Body\n",
|
||||
)
|
||||
.expect("write bundled skill");
|
||||
|
||||
fs::write(
|
||||
codex_home.path().join(crate::config::CONFIG_TOML_FILE),
|
||||
"[skills.bundled]\nenabled = false\n",
|
||||
)
|
||||
.expect("write config");
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.harness_overrides(ConfigOverrides {
|
||||
cwd: Some(cwd.path().to_path_buf()),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.await
|
||||
.expect("load config");
|
||||
|
||||
let plugins_manager = Arc::new(PluginsManager::new(codex_home.path().to_path_buf()));
|
||||
let skills_manager = SkillsManager::new(
|
||||
codex_home.path().to_path_buf(),
|
||||
plugins_manager,
|
||||
config.bundled_skills_enabled(),
|
||||
);
|
||||
let config_layer_stack = config_stack(&codex_home, "[skills.bundled]\nenabled = false\n");
|
||||
let skills_manager = SkillsManager::new(codex_home.path().to_path_buf(), false);
|
||||
|
||||
// Recreate the cached bundled skill after startup cleanup so this assertion exercises
|
||||
// root selection rather than relying on directory removal succeeding.
|
||||
@@ -273,7 +292,7 @@ async fn skills_for_config_excludes_bundled_skills_when_disabled_in_config() {
|
||||
)
|
||||
.expect("rewrite bundled skill");
|
||||
|
||||
let outcome = skills_manager.skills_for_config(&config);
|
||||
let outcome = skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]);
|
||||
assert!(
|
||||
outcome
|
||||
.skills
|
||||
@@ -294,29 +313,23 @@ async fn skills_for_cwd_with_extra_roots_only_refreshes_on_force_reload() {
|
||||
let cwd = tempfile::tempdir().expect("tempdir");
|
||||
let extra_root_a = tempfile::tempdir().expect("tempdir");
|
||||
let extra_root_b = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.harness_overrides(ConfigOverrides {
|
||||
cwd: Some(cwd.path().to_path_buf()),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.await
|
||||
.expect("defaults for test should always succeed");
|
||||
|
||||
let plugins_manager = Arc::new(PluginsManager::new(codex_home.path().to_path_buf()));
|
||||
let skills_manager = SkillsManager::new(codex_home.path().to_path_buf(), plugins_manager, true);
|
||||
let _ = skills_manager.skills_for_config(&config);
|
||||
let config_layer_stack = config_stack(&codex_home, "");
|
||||
let skills_manager = SkillsManager::new(codex_home.path().to_path_buf(), true);
|
||||
let _ = skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]);
|
||||
|
||||
write_user_skill(&extra_root_a, "x", "extra-skill-a", "from extra root a");
|
||||
write_user_skill(&extra_root_b, "x", "extra-skill-b", "from extra root b");
|
||||
|
||||
let extra_root_a_path = extra_root_a.path().to_path_buf();
|
||||
let base_input = SkillsLoadInput::new(
|
||||
cwd.path().to_path_buf(),
|
||||
Vec::new(),
|
||||
config_layer_stack.clone(),
|
||||
bundled_skills_enabled_from_stack(&config_layer_stack),
|
||||
);
|
||||
let outcome_a = skills_manager
|
||||
.skills_for_cwd_with_extra_user_roots(
|
||||
cwd.path(),
|
||||
&config,
|
||||
&base_input,
|
||||
true,
|
||||
std::slice::from_ref(&extra_root_a_path),
|
||||
)
|
||||
@@ -337,8 +350,7 @@ async fn skills_for_cwd_with_extra_roots_only_refreshes_on_force_reload() {
|
||||
let extra_root_b_path = extra_root_b.path().to_path_buf();
|
||||
let outcome_b = skills_manager
|
||||
.skills_for_cwd_with_extra_user_roots(
|
||||
cwd.path(),
|
||||
&config,
|
||||
&base_input,
|
||||
false,
|
||||
std::slice::from_ref(&extra_root_b_path),
|
||||
)
|
||||
@@ -358,8 +370,7 @@ async fn skills_for_cwd_with_extra_roots_only_refreshes_on_force_reload() {
|
||||
|
||||
let outcome_reloaded = skills_manager
|
||||
.skills_for_cwd_with_extra_user_roots(
|
||||
cwd.path(),
|
||||
&config,
|
||||
&base_input,
|
||||
true,
|
||||
std::slice::from_ref(&extra_root_b_path),
|
||||
)
|
||||
@@ -399,25 +410,11 @@ fn disabled_paths_for_skills_allows_session_flags_to_override_user_layer() {
|
||||
.expect("user config path should be absolute");
|
||||
let user_layer = ConfigLayerEntry::new(
|
||||
ConfigLayerSource::User { file: user_file },
|
||||
toml::from_str(&format!(
|
||||
r#"[[skills.config]]
|
||||
path = "{}"
|
||||
enabled = false
|
||||
"#,
|
||||
skill_path.display()
|
||||
))
|
||||
.expect("user layer toml"),
|
||||
toml::from_str(&path_toggle_config(&skill_path, false)).expect("user layer toml"),
|
||||
);
|
||||
let session_layer = ConfigLayerEntry::new(
|
||||
ConfigLayerSource::SessionFlags,
|
||||
toml::from_str(&format!(
|
||||
r#"[[skills.config]]
|
||||
path = "{}"
|
||||
enabled = true
|
||||
"#,
|
||||
skill_path.display()
|
||||
))
|
||||
.expect("session layer toml"),
|
||||
toml::from_str(&path_toggle_config(&skill_path, true)).expect("session layer toml"),
|
||||
);
|
||||
let stack = ConfigLayerStack::new(
|
||||
vec![user_layer, session_layer],
|
||||
@@ -443,25 +440,11 @@ fn disabled_paths_for_skills_allows_session_flags_to_disable_user_enabled_skill(
|
||||
.expect("user config path should be absolute");
|
||||
let user_layer = ConfigLayerEntry::new(
|
||||
ConfigLayerSource::User { file: user_file },
|
||||
toml::from_str(&format!(
|
||||
r#"[[skills.config]]
|
||||
path = "{}"
|
||||
enabled = true
|
||||
"#,
|
||||
skill_path.display()
|
||||
))
|
||||
.expect("user layer toml"),
|
||||
toml::from_str(&path_toggle_config(&skill_path, true)).expect("user layer toml"),
|
||||
);
|
||||
let session_layer = ConfigLayerEntry::new(
|
||||
ConfigLayerSource::SessionFlags,
|
||||
toml::from_str(&format!(
|
||||
r#"[[skills.config]]
|
||||
path = "{}"
|
||||
enabled = false
|
||||
"#,
|
||||
skill_path.display()
|
||||
))
|
||||
.expect("session layer toml"),
|
||||
toml::from_str(&path_toggle_config(&skill_path, false)).expect("session layer toml"),
|
||||
);
|
||||
let stack = ConfigLayerStack::new(
|
||||
vec![user_layer, session_layer],
|
||||
@@ -487,13 +470,7 @@ fn disabled_paths_for_skills_disables_matching_name_selectors() {
|
||||
.expect("user config path should be absolute");
|
||||
let user_layer = ConfigLayerEntry::new(
|
||||
ConfigLayerSource::User { file: user_file },
|
||||
toml::from_str(
|
||||
r#"[[skills.config]]
|
||||
name = "github:yeet"
|
||||
enabled = false
|
||||
"#,
|
||||
)
|
||||
.expect("user layer toml"),
|
||||
toml::from_str(&name_toggle_config("github:yeet", false)).expect("user layer toml"),
|
||||
);
|
||||
let stack = ConfigLayerStack::new(
|
||||
vec![user_layer],
|
||||
@@ -519,24 +496,11 @@ fn disabled_paths_for_skills_allows_name_selector_to_override_path_selector() {
|
||||
.expect("user config path should be absolute");
|
||||
let user_layer = ConfigLayerEntry::new(
|
||||
ConfigLayerSource::User { file: user_file },
|
||||
toml::from_str(&format!(
|
||||
r#"[[skills.config]]
|
||||
path = "{}"
|
||||
enabled = false
|
||||
"#,
|
||||
skill_path.display()
|
||||
))
|
||||
.expect("user layer toml"),
|
||||
toml::from_str(&path_toggle_config(&skill_path, false)).expect("user layer toml"),
|
||||
);
|
||||
let session_layer = ConfigLayerEntry::new(
|
||||
ConfigLayerSource::SessionFlags,
|
||||
toml::from_str(
|
||||
r#"[[skills.config]]
|
||||
name = "github:yeet"
|
||||
enabled = true
|
||||
"#,
|
||||
)
|
||||
.expect("session layer toml"),
|
||||
toml::from_str(&name_toggle_config("github:yeet", true)).expect("session layer toml"),
|
||||
);
|
||||
let stack = ConfigLayerStack::new(
|
||||
vec![user_layer, session_layer],
|
||||
@@ -565,58 +529,20 @@ async fn skills_for_config_ignores_cwd_cache_when_session_flags_reenable_skill()
|
||||
"---\nname: demo-skill\ndescription: demo description\n---\n\n# Body\n",
|
||||
)
|
||||
.expect("write skill");
|
||||
fs::write(
|
||||
codex_home.path().join(crate::config::CONFIG_TOML_FILE),
|
||||
format!(
|
||||
r#"[[skills.config]]
|
||||
path = "{}"
|
||||
enabled = false
|
||||
"#,
|
||||
skill_path.display()
|
||||
),
|
||||
)
|
||||
.expect("write config");
|
||||
|
||||
let parent_config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.harness_overrides(ConfigOverrides {
|
||||
cwd: Some(cwd.path().to_path_buf()),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.await
|
||||
.expect("load parent config");
|
||||
let role_path = codex_home.path().join("enable-role.toml");
|
||||
fs::write(
|
||||
&role_path,
|
||||
format!(
|
||||
r#"[[skills.config]]
|
||||
path = "{}"
|
||||
enabled = true
|
||||
"#,
|
||||
skill_path.display()
|
||||
),
|
||||
)
|
||||
.expect("write role config");
|
||||
let mut child_config = parent_config.clone();
|
||||
child_config.agent_roles.insert(
|
||||
"custom".to_string(),
|
||||
crate::config::AgentRoleConfig {
|
||||
description: None,
|
||||
config_file: Some(role_path),
|
||||
nickname_candidates: None,
|
||||
},
|
||||
let disabled_skill_config = path_toggle_config(&skill_path, false);
|
||||
let enabled_skill_config = path_toggle_config(&skill_path, true);
|
||||
let parent_stack = config_stack(&codex_home, &disabled_skill_config);
|
||||
let child_stack =
|
||||
config_stack_with_session_flags(&codex_home, &disabled_skill_config, &enabled_skill_config);
|
||||
let skills_manager = SkillsManager::new(codex_home.path().to_path_buf(), true);
|
||||
let parent_input = SkillsLoadInput::new(
|
||||
cwd.path().to_path_buf(),
|
||||
Vec::new(),
|
||||
parent_stack.clone(),
|
||||
bundled_skills_enabled_from_stack(&parent_stack),
|
||||
);
|
||||
crate::agent::role::apply_role_to_config(&mut child_config, Some("custom"))
|
||||
.await
|
||||
.expect("custom role should apply");
|
||||
|
||||
let plugins_manager = Arc::new(PluginsManager::new(codex_home.path().to_path_buf()));
|
||||
let skills_manager = SkillsManager::new(codex_home.path().to_path_buf(), plugins_manager, true);
|
||||
|
||||
let parent_outcome = skills_manager
|
||||
.skills_for_cwd(cwd.path(), &parent_config, true)
|
||||
.await;
|
||||
let parent_outcome = skills_manager.skills_for_cwd(&parent_input, true).await;
|
||||
let parent_skill = parent_outcome
|
||||
.skills
|
||||
.iter()
|
||||
@@ -624,7 +550,7 @@ enabled = true
|
||||
.expect("demo skill should be discovered");
|
||||
assert_eq!(parent_outcome.is_skill_enabled(parent_skill), false);
|
||||
|
||||
let child_outcome = skills_manager.skills_for_config(&child_config);
|
||||
let child_outcome = skills_for_config_with_stack(&skills_manager, &cwd, &child_stack, &[]);
|
||||
let child_skill = child_outcome
|
||||
.skills
|
||||
.iter()
|
||||
24
codex-rs/core-skills/src/skills/mention_counts.rs
Normal file
24
codex-rs/core-skills/src/skills/mention_counts.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::SkillMetadata;
|
||||
|
||||
/// Counts how often each skill name appears (exact and ASCII-lowercase), excluding disabled paths.
|
||||
pub fn build_skill_name_counts(
|
||||
skills: &[SkillMetadata],
|
||||
disabled_paths: &HashSet<PathBuf>,
|
||||
) -> (HashMap<String, usize>, HashMap<String, usize>) {
|
||||
let mut exact_counts: HashMap<String, usize> = HashMap::new();
|
||||
let mut lower_counts: HashMap<String, usize> = HashMap::new();
|
||||
for skill in skills {
|
||||
if disabled_paths.contains(&skill.path_to_skills_md) {
|
||||
continue;
|
||||
}
|
||||
*exact_counts.entry(skill.name.clone()).or_insert(0) += 1;
|
||||
*lower_counts
|
||||
.entry(skill.name.to_ascii_lowercase())
|
||||
.or_insert(0) += 1;
|
||||
}
|
||||
(exact_counts, lower_counts)
|
||||
}
|
||||
@@ -1,22 +1,22 @@
|
||||
pub(crate) mod config_rules;
|
||||
pub mod config_rules;
|
||||
mod env_var_dependencies;
|
||||
pub mod injection;
|
||||
pub(crate) mod invocation_utils;
|
||||
pub mod loader;
|
||||
pub mod manager;
|
||||
mod mention_counts;
|
||||
pub mod model;
|
||||
pub mod remote;
|
||||
pub mod render;
|
||||
pub mod system;
|
||||
|
||||
pub(crate) use env_var_dependencies::collect_env_var_dependencies;
|
||||
pub(crate) use env_var_dependencies::resolve_skill_dependencies_for_turn;
|
||||
pub(crate) use injection::SkillInjections;
|
||||
pub(crate) use injection::build_skill_injections;
|
||||
pub(crate) use injection::collect_explicit_skill_mentions;
|
||||
pub use env_var_dependencies::SkillDependencyInfo;
|
||||
pub use env_var_dependencies::collect_env_var_dependencies;
|
||||
pub(crate) use invocation_utils::build_implicit_skill_path_indexes;
|
||||
pub(crate) use invocation_utils::maybe_emit_implicit_skill_invocation;
|
||||
pub use invocation_utils::detect_implicit_skill_invocation_for_command;
|
||||
pub use manager::SkillsLoadInput;
|
||||
pub use manager::SkillsManager;
|
||||
pub use mention_counts::build_skill_name_counts;
|
||||
pub use model::SkillError;
|
||||
pub use model::SkillLoadOutcome;
|
||||
pub use model::SkillMetadata;
|
||||
@@ -6,9 +6,8 @@ use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::auth::CodexAuth;
|
||||
use crate::config::Config;
|
||||
use crate::default_client::build_reqwest_client;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_login::default_client::build_reqwest_client;
|
||||
|
||||
const REMOTE_SKILLS_API_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
@@ -88,13 +87,13 @@ struct RemoteSkill {
|
||||
}
|
||||
|
||||
pub async fn list_remote_skills(
|
||||
config: &Config,
|
||||
chatgpt_base_url: String,
|
||||
auth: Option<&CodexAuth>,
|
||||
scope: RemoteSkillScope,
|
||||
product_surface: RemoteSkillProductSurface,
|
||||
enabled: Option<bool>,
|
||||
) -> Result<Vec<RemoteSkillSummary>> {
|
||||
let base_url = config.chatgpt_base_url.trim_end_matches('/');
|
||||
let base_url = chatgpt_base_url.trim_end_matches('/');
|
||||
let auth = ensure_chatgpt_auth(auth)?;
|
||||
|
||||
let url = format!("{base_url}/hazelnuts");
|
||||
@@ -146,14 +145,15 @@ pub async fn list_remote_skills(
|
||||
}
|
||||
|
||||
pub async fn export_remote_skill(
|
||||
config: &Config,
|
||||
chatgpt_base_url: String,
|
||||
codex_home: PathBuf,
|
||||
auth: Option<&CodexAuth>,
|
||||
skill_id: &str,
|
||||
) -> Result<RemoteSkillDownloadResult> {
|
||||
let auth = ensure_chatgpt_auth(auth)?;
|
||||
|
||||
let client = build_reqwest_client();
|
||||
let base_url = config.chatgpt_base_url.trim_end_matches('/');
|
||||
let base_url = chatgpt_base_url.trim_end_matches('/');
|
||||
let url = format!("{base_url}/hazelnuts/{skill_id}/export");
|
||||
let mut request = client.get(&url).timeout(REMOTE_SKILLS_API_TIMEOUT);
|
||||
|
||||
@@ -181,7 +181,7 @@ pub async fn export_remote_skill(
|
||||
anyhow::bail!("Downloaded remote skill payload is not a zip archive");
|
||||
}
|
||||
|
||||
let output_dir = config.codex_home.join("skills").join(skill_id);
|
||||
let output_dir = codex_home.join("skills").join(skill_id);
|
||||
tokio::fs::create_dir_all(&output_dir)
|
||||
.await
|
||||
.context("Failed to create downloaded skills directory")?;
|
||||
@@ -27,6 +27,7 @@ bm25 = { workspace = true }
|
||||
chardetng = { workspace = true }
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
clap = { workspace = true, features = ["derive"] }
|
||||
codex-analytics = { workspace = true }
|
||||
codex-api = { workspace = true }
|
||||
codex-app-server-protocol = { workspace = true }
|
||||
codex-apply-patch = { workspace = true }
|
||||
@@ -34,17 +35,19 @@ codex-async-utils = { workspace = true }
|
||||
codex-code-mode = { workspace = true }
|
||||
codex-connectors = { workspace = true }
|
||||
codex-config = { workspace = true }
|
||||
codex-core-skills = { workspace = true }
|
||||
codex-exec-server = { workspace = true }
|
||||
codex-features = { workspace = true }
|
||||
codex-login = { workspace = true }
|
||||
codex-shell-command = { workspace = true }
|
||||
codex-skills = { workspace = true }
|
||||
codex-execpolicy = { workspace = true }
|
||||
codex-git-utils = { workspace = true }
|
||||
codex-hooks = { workspace = true }
|
||||
codex-instructions = { workspace = true }
|
||||
codex-network-proxy = { workspace = true }
|
||||
codex-otel = { workspace = true }
|
||||
codex-artifacts = { workspace = true }
|
||||
codex-plugin = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
codex-rollout = { workspace = true }
|
||||
codex-rmcp-client = { workspace = true }
|
||||
@@ -57,6 +60,7 @@ codex-utils-image = { workspace = true }
|
||||
codex-utils-home-dir = { workspace = true }
|
||||
codex-utils-output-truncation = { workspace = true }
|
||||
codex-utils-path = { workspace = true }
|
||||
codex-utils-plugins = { workspace = true }
|
||||
codex-utils-pty = { workspace = true }
|
||||
codex-utils-readiness = { workspace = true }
|
||||
codex-secrets = { workspace = true }
|
||||
@@ -89,7 +93,6 @@ rmcp = { workspace = true, default-features = false, features = [
|
||||
schemars = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
serde_yaml = { workspace = true }
|
||||
sha1 = { workspace = true }
|
||||
shlex = { workspace = true }
|
||||
similar = { workspace = true }
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::config::ConfigBuilder;
|
||||
use crate::config_loader::ConfigLayerStackOrdering;
|
||||
use crate::plugins::PluginsManager;
|
||||
use crate::skills::SkillsManager;
|
||||
use crate::skills::skills_load_input_from_config;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::config_types::Verbosity;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
@@ -629,8 +630,11 @@ enabled = false
|
||||
.expect("custom role should apply");
|
||||
|
||||
let plugins_manager = Arc::new(PluginsManager::new(home.path().to_path_buf()));
|
||||
let skills_manager = SkillsManager::new(home.path().to_path_buf(), plugins_manager, true);
|
||||
let outcome = skills_manager.skills_for_config(&config);
|
||||
let skills_manager = SkillsManager::new(home.path().to_path_buf(), true);
|
||||
let plugin_outcome = plugins_manager.plugins_for_config(&config);
|
||||
let effective_skill_roots = plugin_outcome.effective_skill_roots();
|
||||
let skills_input = skills_load_input_from_config(&config, effective_skill_roots);
|
||||
let outcome = skills_manager.skills_for_config(&skills_input);
|
||||
let skill = outcome
|
||||
.skills
|
||||
.iter()
|
||||
|
||||
@@ -12,10 +12,6 @@ use crate::SandboxState;
|
||||
use crate::agent::AgentControl;
|
||||
use crate::agent::AgentStatus;
|
||||
use crate::agent::agent_status_from_event;
|
||||
use crate::analytics_client::AnalyticsEventsClient;
|
||||
use crate::analytics_client::AppInvocation;
|
||||
use crate::analytics_client::InvocationType;
|
||||
use crate::analytics_client::build_track_events_context;
|
||||
use crate::apps::render_apps_section;
|
||||
use crate::auth_env_telemetry::collect_auth_env_telemetry;
|
||||
use crate::commit_attribution::commit_message_trailer_instruction;
|
||||
@@ -40,6 +36,7 @@ use crate::realtime_conversation::handle_start as handle_realtime_conversation_s
|
||||
use crate::realtime_conversation::handle_text as handle_realtime_conversation_text;
|
||||
use crate::rollout::session_index;
|
||||
use crate::skills::render_skills_section;
|
||||
use crate::skills::skills_load_input_from_config;
|
||||
use crate::stream_events_utils::HandleOutputCtx;
|
||||
use crate::stream_events_utils::handle_non_tool_response_item;
|
||||
use crate::stream_events_utils::handle_output_item_done;
|
||||
@@ -52,6 +49,10 @@ use async_channel::Receiver;
|
||||
use async_channel::Sender;
|
||||
use chrono::Local;
|
||||
use chrono::Utc;
|
||||
use codex_analytics::AnalyticsEventsClient;
|
||||
use codex_analytics::AppInvocation;
|
||||
use codex_analytics::InvocationType;
|
||||
use codex_analytics::build_track_events_context;
|
||||
use codex_app_server_protocol::McpServerElicitationRequest;
|
||||
use codex_app_server_protocol::McpServerElicitationRequestParams;
|
||||
use codex_exec_server::Environment;
|
||||
@@ -471,7 +472,10 @@ impl Codex {
|
||||
let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
|
||||
let (tx_event, rx_event) = async_channel::unbounded();
|
||||
|
||||
let loaded_skills = skills_manager.skills_for_config(&config);
|
||||
let plugin_outcome = plugins_manager.plugins_for_config(&config);
|
||||
let effective_skill_roots = plugin_outcome.effective_skill_roots();
|
||||
let skills_input = skills_load_input_from_config(&config, effective_skill_roots);
|
||||
let loaded_skills = skills_manager.skills_for_config(&skills_input);
|
||||
|
||||
for err in &loaded_skills.errors {
|
||||
error!(
|
||||
@@ -1841,8 +1845,9 @@ impl Session {
|
||||
shell_zsh_path: config.zsh_path.clone(),
|
||||
main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(),
|
||||
analytics_events_client: AnalyticsEventsClient::new(
|
||||
Arc::clone(&config),
|
||||
Arc::clone(&auth_manager),
|
||||
config.chatgpt_base_url.trim_end_matches('/').to_string(),
|
||||
config.analytics_enabled,
|
||||
),
|
||||
hooks,
|
||||
rollout: Mutex::new(rollout_recorder),
|
||||
@@ -2430,10 +2435,16 @@ impl Session {
|
||||
&per_turn_config,
|
||||
)
|
||||
.await;
|
||||
let plugin_outcome = self
|
||||
.services
|
||||
.plugins_manager
|
||||
.plugins_for_config(&per_turn_config);
|
||||
let effective_skill_roots = plugin_outcome.effective_skill_roots();
|
||||
let skills_input = skills_load_input_from_config(&per_turn_config, effective_skill_roots);
|
||||
let skills_outcome = Arc::new(
|
||||
self.services
|
||||
.skills_manager
|
||||
.skills_for_config(&per_turn_config),
|
||||
.skills_for_config(&skills_input),
|
||||
);
|
||||
let mut turn_context: TurnContext = Self::make_turn_context(
|
||||
self.conversation_id,
|
||||
@@ -4490,6 +4501,12 @@ mod handlers {
|
||||
|
||||
use crate::codex::spawn_review_thread;
|
||||
use crate::config::Config;
|
||||
use crate::config_loader::CloudRequirementsLoader;
|
||||
use crate::config_loader::LoaderOverrides;
|
||||
use crate::config_loader::load_config_layers_state;
|
||||
use crate::skills::SkillError;
|
||||
use codex_features::Feature;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
|
||||
use crate::mcp::auth::compute_auth_statuses;
|
||||
use crate::mcp::collect_mcp_snapshot_from_manager;
|
||||
@@ -4925,11 +4942,64 @@ mod handlers {
|
||||
};
|
||||
|
||||
let skills_manager = &sess.services.skills_manager;
|
||||
let plugins_manager = &sess.services.plugins_manager;
|
||||
let config = sess.get_config().await;
|
||||
let codex_home = sess.codex_home().await;
|
||||
let mut skills = Vec::new();
|
||||
let empty_cli_overrides: &[(String, toml::Value)] = &[];
|
||||
for cwd in cwds {
|
||||
let cwd_abs = match AbsolutePathBuf::try_from(cwd.as_path()) {
|
||||
Ok(path) => path,
|
||||
Err(err) => {
|
||||
let message = err.to_string();
|
||||
let cwd_for_entry = cwd.clone();
|
||||
skills.push(SkillsListEntry {
|
||||
cwd: cwd_for_entry.clone(),
|
||||
skills: Vec::new(),
|
||||
errors: super::errors_to_info(&[SkillError {
|
||||
path: cwd_for_entry,
|
||||
message,
|
||||
}]),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let config_layer_stack = match load_config_layers_state(
|
||||
&codex_home,
|
||||
Some(cwd_abs),
|
||||
empty_cli_overrides,
|
||||
LoaderOverrides::default(),
|
||||
CloudRequirementsLoader::default(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(config_layer_stack) => config_layer_stack,
|
||||
Err(err) => {
|
||||
let message = err.to_string();
|
||||
let cwd_for_entry = cwd.clone();
|
||||
skills.push(SkillsListEntry {
|
||||
cwd: cwd_for_entry.clone(),
|
||||
skills: Vec::new(),
|
||||
errors: super::errors_to_info(&[SkillError {
|
||||
path: cwd_for_entry,
|
||||
message,
|
||||
}]),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let effective_skill_roots = plugins_manager.effective_skill_roots_for_layer_stack(
|
||||
&config_layer_stack,
|
||||
config.features.enabled(Feature::Plugins),
|
||||
);
|
||||
let skills_input = crate::skills::SkillsLoadInput::new(
|
||||
cwd.clone(),
|
||||
effective_skill_roots,
|
||||
config_layer_stack,
|
||||
config.bundled_skills_enabled(),
|
||||
);
|
||||
let outcome = skills_manager
|
||||
.skills_for_cwd(&cwd, config.as_ref(), force_reload)
|
||||
.skills_for_cwd(&skills_input, force_reload)
|
||||
.await;
|
||||
let errors = super::errors_to_info(&outcome.errors);
|
||||
let skills_metadata = super::skills_to_info(&outcome.skills, &outcome.disabled_paths);
|
||||
|
||||
@@ -2341,7 +2341,10 @@ async fn new_default_turn_uses_config_aware_skills_for_role_overrides() {
|
||||
let parent_outcome = session
|
||||
.services
|
||||
.skills_manager
|
||||
.skills_for_cwd(&parent_config.cwd, &parent_config, true)
|
||||
.skills_for_cwd(
|
||||
&crate::skills::skills_load_input_from_config(&parent_config, Vec::new()),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
let parent_skill = parent_outcome
|
||||
.skills
|
||||
@@ -2508,11 +2511,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() {
|
||||
let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit);
|
||||
let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.clone()));
|
||||
let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager)));
|
||||
let skills_manager = Arc::new(SkillsManager::new(
|
||||
config.codex_home.clone(),
|
||||
Arc::clone(&plugins_manager),
|
||||
true,
|
||||
));
|
||||
let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone(), true));
|
||||
let result = Session::new(
|
||||
session_configuration,
|
||||
Arc::clone(&config),
|
||||
@@ -2613,11 +2612,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
let state = SessionState::new(session_configuration.clone());
|
||||
let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.clone()));
|
||||
let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager)));
|
||||
let skills_manager = Arc::new(SkillsManager::new(
|
||||
config.codex_home.clone(),
|
||||
Arc::clone(&plugins_manager),
|
||||
true,
|
||||
));
|
||||
let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone(), true));
|
||||
let network_approval = Arc::new(NetworkApprovalService::default());
|
||||
let environment = Arc::new(
|
||||
codex_exec_server::Environment::create(None)
|
||||
@@ -2639,8 +2634,9 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
shell_zsh_path: None,
|
||||
main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(),
|
||||
analytics_events_client: AnalyticsEventsClient::new(
|
||||
Arc::clone(&config),
|
||||
Arc::clone(&auth_manager),
|
||||
config.chatgpt_base_url.trim_end_matches('/').to_string(),
|
||||
config.analytics_enabled,
|
||||
),
|
||||
hooks: Hooks::new(HooksConfig {
|
||||
legacy_notify_argv: config.notify.clone(),
|
||||
@@ -2684,7 +2680,13 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
config.js_repl_node_module_dirs.clone(),
|
||||
));
|
||||
|
||||
let skills_outcome = Arc::new(services.skills_manager.skills_for_config(&per_turn_config));
|
||||
let plugin_outcome = services
|
||||
.plugins_manager
|
||||
.plugins_for_config(&per_turn_config);
|
||||
let effective_skill_roots = plugin_outcome.effective_skill_roots();
|
||||
let skills_input =
|
||||
crate::skills::skills_load_input_from_config(&per_turn_config, effective_skill_roots);
|
||||
let skills_outcome = Arc::new(services.skills_manager.skills_for_config(&skills_input));
|
||||
let turn_context = Session::make_turn_context(
|
||||
conversation_id,
|
||||
Some(Arc::clone(&auth_manager)),
|
||||
@@ -3448,11 +3450,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
|
||||
let state = SessionState::new(session_configuration.clone());
|
||||
let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.clone()));
|
||||
let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager)));
|
||||
let skills_manager = Arc::new(SkillsManager::new(
|
||||
config.codex_home.clone(),
|
||||
Arc::clone(&plugins_manager),
|
||||
true,
|
||||
));
|
||||
let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone(), true));
|
||||
let network_approval = Arc::new(NetworkApprovalService::default());
|
||||
let environment = Arc::new(
|
||||
codex_exec_server::Environment::create(None)
|
||||
@@ -3474,8 +3472,9 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
|
||||
shell_zsh_path: None,
|
||||
main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(),
|
||||
analytics_events_client: AnalyticsEventsClient::new(
|
||||
Arc::clone(&config),
|
||||
Arc::clone(&auth_manager),
|
||||
config.chatgpt_base_url.trim_end_matches('/').to_string(),
|
||||
config.analytics_enabled,
|
||||
),
|
||||
hooks: Hooks::new(HooksConfig {
|
||||
legacy_notify_argv: config.notify.clone(),
|
||||
@@ -3519,7 +3518,13 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
|
||||
config.js_repl_node_module_dirs.clone(),
|
||||
));
|
||||
|
||||
let skills_outcome = Arc::new(services.skills_manager.skills_for_config(&per_turn_config));
|
||||
let plugin_outcome = services
|
||||
.plugins_manager
|
||||
.plugins_for_config(&per_turn_config);
|
||||
let effective_skill_roots = plugin_outcome.effective_skill_roots();
|
||||
let skills_input =
|
||||
crate::skills::skills_load_input_from_config(&per_turn_config, effective_skill_roots);
|
||||
let skills_outcome = Arc::new(services.skills_manager.skills_for_config(&skills_input));
|
||||
let turn_context = Arc::new(Session::make_turn_context(
|
||||
conversation_id,
|
||||
Some(Arc::clone(&auth_manager)),
|
||||
|
||||
@@ -429,11 +429,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() {
|
||||
CollaborationModesConfig::default(),
|
||||
));
|
||||
let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.clone()));
|
||||
let skills_manager = Arc::new(SkillsManager::new(
|
||||
config.codex_home.clone(),
|
||||
Arc::clone(&plugins_manager),
|
||||
true,
|
||||
));
|
||||
let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone(), true));
|
||||
let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager)));
|
||||
let skills_watcher = Arc::new(SkillsWatcher::noop());
|
||||
|
||||
|
||||
@@ -801,17 +801,9 @@ impl Notice {
|
||||
pub(crate) const TABLE_KEY: &'static str = "notice";
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct SkillConfig {
|
||||
/// Path-based selector.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<AbsolutePathBuf>,
|
||||
/// Name-based selector.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
pub use codex_config::BundledSkillsConfig;
|
||||
pub use codex_config::SkillConfig;
|
||||
pub use codex_config::SkillsConfig;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
@@ -820,29 +812,6 @@ pub struct PluginConfig {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct SkillsConfig {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub bundled: Option<BundledSkillsConfig>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub config: Vec<SkillConfig>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct BundledSkillsConfig {
|
||||
#[serde(default = "default_enabled")]
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for BundledSkillsConfig {
|
||||
fn default() -> Self {
|
||||
Self { enabled: true }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct SandboxWorkspaceWrite {
|
||||
|
||||
@@ -52,10 +52,12 @@ pub use codex_config::TextRange;
|
||||
pub use codex_config::WebSearchModeRequirement;
|
||||
pub(crate) use codex_config::build_cli_overrides_layer;
|
||||
pub(crate) use codex_config::config_error_from_toml;
|
||||
pub use codex_config::default_project_root_markers;
|
||||
pub use codex_config::format_config_error;
|
||||
pub use codex_config::format_config_error_with_source;
|
||||
pub(crate) use codex_config::io_error_from_config_error;
|
||||
pub use codex_config::merge_toml_values;
|
||||
pub use codex_config::project_root_markers_from_config;
|
||||
#[cfg(test)]
|
||||
pub(crate) use codex_config::version_for_toml;
|
||||
|
||||
@@ -67,8 +69,6 @@ pub const SYSTEM_CONFIG_TOML_FILE_UNIX: &str = "/etc/codex/config.toml";
|
||||
#[cfg(windows)]
|
||||
const DEFAULT_PROGRAM_DATA_DIR_WINDOWS: &str = r"C:\ProgramData";
|
||||
|
||||
const DEFAULT_PROJECT_ROOT_MARKERS: &[&str] = &[".git"];
|
||||
|
||||
pub(crate) async fn first_layer_config_error(layers: &ConfigLayerStack) -> Option<ConfigError> {
|
||||
codex_config::first_layer_config_error::<ConfigToml>(layers, CONFIG_TOML_FILE).await
|
||||
}
|
||||
@@ -524,55 +524,6 @@ async fn load_requirements_from_legacy_scheme(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reads `project_root_markers` from the [toml::Value] produced by merging
|
||||
/// `config.toml` from the config layers in the stack preceding
|
||||
/// [ConfigLayerSource::Project].
|
||||
///
|
||||
/// Invariants:
|
||||
/// - If `project_root_markers` is not specified, returns `Ok(None)`.
|
||||
/// - If `project_root_markers` is specified, returns `Ok(Some(markers))` where
|
||||
/// `markers` is a `Vec<String>` (including `Ok(Some(Vec::new()))` for an
|
||||
/// empty array, which indicates that root detection should be disabled).
|
||||
/// - Returns an error if `project_root_markers` is specified but is not an
|
||||
/// array of strings.
|
||||
pub(crate) fn project_root_markers_from_config(
|
||||
config: &TomlValue,
|
||||
) -> io::Result<Option<Vec<String>>> {
|
||||
let Some(table) = config.as_table() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(markers_value) = table.get("project_root_markers") else {
|
||||
return Ok(None);
|
||||
};
|
||||
let TomlValue::Array(entries) = markers_value else {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"project_root_markers must be an array of strings",
|
||||
));
|
||||
};
|
||||
if entries.is_empty() {
|
||||
return Ok(Some(Vec::new()));
|
||||
}
|
||||
let mut markers = Vec::new();
|
||||
for entry in entries {
|
||||
let Some(marker) = entry.as_str() else {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"project_root_markers must be an array of strings",
|
||||
));
|
||||
};
|
||||
markers.push(marker.to_string());
|
||||
}
|
||||
Ok(Some(markers))
|
||||
}
|
||||
|
||||
pub(crate) fn default_project_root_markers() -> Vec<String> {
|
||||
DEFAULT_PROJECT_ROOT_MARKERS
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
struct ProjectTrustContext {
|
||||
project_root: AbsolutePathBuf,
|
||||
project_root_key: String,
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
use codex_instructions::AGENTS_MD_FRAGMENT;
|
||||
use codex_instructions::ContextualUserFragmentDefinition;
|
||||
use codex_instructions::SKILL_FRAGMENT;
|
||||
use codex_protocol::items::HookPromptItem;
|
||||
use codex_protocol::items::parse_hook_prompt_fragment;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG;
|
||||
use codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG;
|
||||
|
||||
pub(crate) const AGENTS_MD_START_MARKER: &str = "# AGENTS.md instructions for ";
|
||||
pub(crate) const AGENTS_MD_END_MARKER: &str = "</INSTRUCTIONS>";
|
||||
pub(crate) const SKILL_OPEN_TAG: &str = "<skill>";
|
||||
pub(crate) const SKILL_CLOSE_TAG: &str = "</skill>";
|
||||
pub(crate) const USER_SHELL_COMMAND_OPEN_TAG: &str = "<user_shell_command>";
|
||||
pub(crate) const USER_SHELL_COMMAND_CLOSE_TAG: &str = "</user_shell_command>";
|
||||
pub(crate) const TURN_ABORTED_OPEN_TAG: &str = "<turn_aborted>";
|
||||
@@ -16,64 +14,11 @@ pub(crate) const TURN_ABORTED_CLOSE_TAG: &str = "</turn_aborted>";
|
||||
pub(crate) const SUBAGENT_NOTIFICATION_OPEN_TAG: &str = "<subagent_notification>";
|
||||
pub(crate) const SUBAGENT_NOTIFICATION_CLOSE_TAG: &str = "</subagent_notification>";
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct ContextualUserFragmentDefinition {
|
||||
start_marker: &'static str,
|
||||
end_marker: &'static str,
|
||||
}
|
||||
|
||||
impl ContextualUserFragmentDefinition {
|
||||
pub(crate) const fn new(start_marker: &'static str, end_marker: &'static str) -> Self {
|
||||
Self {
|
||||
start_marker,
|
||||
end_marker,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn matches_text(&self, text: &str) -> bool {
|
||||
let trimmed = text.trim_start();
|
||||
let starts_with_marker = trimmed
|
||||
.get(..self.start_marker.len())
|
||||
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(self.start_marker));
|
||||
let trimmed = trimmed.trim_end();
|
||||
let ends_with_marker = trimmed
|
||||
.get(trimmed.len().saturating_sub(self.end_marker.len())..)
|
||||
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(self.end_marker));
|
||||
starts_with_marker && ends_with_marker
|
||||
}
|
||||
|
||||
pub(crate) const fn start_marker(&self) -> &'static str {
|
||||
self.start_marker
|
||||
}
|
||||
|
||||
pub(crate) const fn end_marker(&self) -> &'static str {
|
||||
self.end_marker
|
||||
}
|
||||
|
||||
pub(crate) fn wrap(&self, body: String) -> String {
|
||||
format!("{}\n{}\n{}", self.start_marker, body, self.end_marker)
|
||||
}
|
||||
|
||||
pub(crate) fn into_message(self, text: String) -> ResponseItem {
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText { text }],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) const AGENTS_MD_FRAGMENT: ContextualUserFragmentDefinition =
|
||||
ContextualUserFragmentDefinition::new(AGENTS_MD_START_MARKER, AGENTS_MD_END_MARKER);
|
||||
pub(crate) const ENVIRONMENT_CONTEXT_FRAGMENT: ContextualUserFragmentDefinition =
|
||||
ContextualUserFragmentDefinition::new(
|
||||
ENVIRONMENT_CONTEXT_OPEN_TAG,
|
||||
ENVIRONMENT_CONTEXT_CLOSE_TAG,
|
||||
);
|
||||
pub(crate) const SKILL_FRAGMENT: ContextualUserFragmentDefinition =
|
||||
ContextualUserFragmentDefinition::new(SKILL_OPEN_TAG, SKILL_CLOSE_TAG);
|
||||
pub(crate) const USER_SHELL_COMMAND_FRAGMENT: ContextualUserFragmentDefinition =
|
||||
ContextualUserFragmentDefinition::new(
|
||||
USER_SHELL_COMMAND_OPEN_TAG,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::*;
|
||||
use codex_protocol::items::HookPromptFragment;
|
||||
use codex_protocol::items::build_hook_prompt_message;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
|
||||
#[test]
|
||||
fn detects_environment_context_fragment() {
|
||||
|
||||
@@ -1,5 +1,2 @@
|
||||
mod user_instructions;
|
||||
|
||||
pub(crate) use user_instructions::SkillInstructions;
|
||||
pub use user_instructions::USER_INSTRUCTIONS_PREFIX;
|
||||
pub(crate) use user_instructions::UserInstructions;
|
||||
pub use codex_instructions::USER_INSTRUCTIONS_PREFIX;
|
||||
pub(crate) use codex_instructions::UserInstructions;
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
// the TUI or the tracing stack).
|
||||
#![deny(clippy::print_stdout, clippy::print_stderr)]
|
||||
|
||||
mod analytics_client;
|
||||
pub mod api_bridge;
|
||||
mod apply_patch;
|
||||
mod apps;
|
||||
@@ -62,18 +61,25 @@ pub use text_encoding::bytes_to_string_smart;
|
||||
mod mcp_tool_call;
|
||||
mod memories;
|
||||
pub mod mention_syntax;
|
||||
mod mentions;
|
||||
pub mod message_history;
|
||||
mod model_provider_info;
|
||||
pub mod utils;
|
||||
pub use utils::path_utils;
|
||||
pub mod personality_migration;
|
||||
pub mod plugins;
|
||||
pub(crate) mod mentions {
|
||||
pub(crate) use crate::plugins::build_connector_slug_counts;
|
||||
pub(crate) use crate::plugins::build_skill_name_counts;
|
||||
pub(crate) use crate::plugins::collect_explicit_app_ids;
|
||||
pub(crate) use crate::plugins::collect_explicit_plugin_mentions;
|
||||
pub(crate) use crate::plugins::collect_tool_mentions_from_messages;
|
||||
}
|
||||
mod sandbox_tags;
|
||||
pub mod sandboxing;
|
||||
mod session_prefix;
|
||||
mod session_startup_prewarm;
|
||||
mod shell_detect;
|
||||
pub mod skills;
|
||||
mod skills_watcher;
|
||||
mod stream_events_utils;
|
||||
pub mod test_support;
|
||||
@@ -108,9 +114,9 @@ pub type NewConversation = NewThread;
|
||||
#[deprecated(note = "use CodexThread")]
|
||||
pub type CodexConversation = CodexThread;
|
||||
// Re-export common auth types for workspace consumers
|
||||
pub use analytics_client::AnalyticsEventsClient;
|
||||
pub use auth::AuthManager;
|
||||
pub use auth::CodexAuth;
|
||||
pub use codex_analytics::AnalyticsEventsClient;
|
||||
mod default_client_forwarding;
|
||||
|
||||
/// Default Codex HTTP client headers and reqwest construction.
|
||||
@@ -127,7 +133,6 @@ pub mod seatbelt;
|
||||
mod session_rollout_init_error;
|
||||
pub mod shell;
|
||||
pub mod shell_snapshot;
|
||||
pub mod skills;
|
||||
pub mod spawn;
|
||||
pub mod state_db_bridge;
|
||||
pub use codex_rollout::state_db;
|
||||
|
||||
@@ -8,9 +8,6 @@ use codex_app_server_protocol::McpServerElicitationRequest;
|
||||
use codex_app_server_protocol::McpServerElicitationRequestParams;
|
||||
use tracing::error;
|
||||
|
||||
use crate::analytics_client::AppInvocation;
|
||||
use crate::analytics_client::InvocationType;
|
||||
use crate::analytics_client::build_track_events_context;
|
||||
use crate::arc_monitor::ArcMonitorOutcome;
|
||||
use crate::arc_monitor::monitor_action;
|
||||
use crate::codex::Session;
|
||||
@@ -32,6 +29,9 @@ use crate::protocol::McpInvocation;
|
||||
use crate::protocol::McpToolCallBeginEvent;
|
||||
use crate::protocol::McpToolCallEndEvent;
|
||||
use crate::state_db;
|
||||
use codex_analytics::AppInvocation;
|
||||
use codex_analytics::InvocationType;
|
||||
use codex_analytics::build_track_events_context;
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::mcp::CallToolResult;
|
||||
use codex_protocol::openai_models::InputModality;
|
||||
|
||||
@@ -1,4 +1,2 @@
|
||||
// Default plaintext sigil for tools.
|
||||
pub const TOOL_MENTION_SIGIL: char = '$';
|
||||
// Plugins use `@` in linked plaintext outside TUI.
|
||||
pub const PLUGIN_TEXT_MENTION_SIGIL: char = '@';
|
||||
pub use codex_utils_plugins::mention_syntax::PLUGIN_TEXT_MENTION_SIGIL;
|
||||
pub use codex_utils_plugins::mention_syntax::TOOL_MENTION_SIGIL;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use super::LoadedPlugin;
|
||||
use super::PluginLoadOutcome;
|
||||
use super::PluginManifestPaths;
|
||||
use super::curated_plugins_repo_path;
|
||||
use super::load_plugin_manifest;
|
||||
@@ -20,14 +22,11 @@ use super::remote::fetch_remote_featured_plugin_ids;
|
||||
use super::remote::fetch_remote_plugin_status;
|
||||
use super::remote::uninstall_remote_plugin;
|
||||
use super::startup_sync::start_startup_remote_plugin_sync_once;
|
||||
use super::store::PluginId;
|
||||
use super::store::PluginIdError;
|
||||
use super::store::PluginInstallResult as StorePluginInstallResult;
|
||||
use super::store::PluginStore;
|
||||
use super::store::PluginStoreError;
|
||||
use super::sync_openai_plugins_repo;
|
||||
use crate::AuthManager;
|
||||
use crate::analytics_client::AnalyticsEventsClient;
|
||||
use crate::auth::CodexAuth;
|
||||
use crate::config::Config;
|
||||
use crate::config::ConfigService;
|
||||
@@ -43,9 +42,16 @@ use crate::skills::config_rules::resolve_disabled_skill_paths;
|
||||
use crate::skills::config_rules::skill_config_rules_from_stack;
|
||||
use crate::skills::loader::SkillRoot;
|
||||
use crate::skills::loader::load_skills_from_roots;
|
||||
use codex_analytics::AnalyticsEventsClient;
|
||||
use codex_app_server_protocol::ConfigValueWriteParams;
|
||||
use codex_app_server_protocol::MergeStrategy;
|
||||
use codex_features::Feature;
|
||||
use codex_plugin::AppConnectorId;
|
||||
use codex_plugin::PluginCapabilitySummary;
|
||||
use codex_plugin::PluginId;
|
||||
use codex_plugin::PluginIdError;
|
||||
use codex_plugin::PluginTelemetryMetadata;
|
||||
use codex_plugin::prompt_safe_plugin_description;
|
||||
use codex_protocol::protocol::Product;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
@@ -73,7 +79,6 @@ const DEFAULT_MCP_CONFIG_FILE: &str = ".mcp.json";
|
||||
const DEFAULT_APP_CONFIG_FILE: &str = ".app.json";
|
||||
pub const OPENAI_CURATED_MARKETPLACE_NAME: &str = "openai-curated";
|
||||
static CURATED_REPO_SYNC_STARTED: AtomicBool = AtomicBool::new(false);
|
||||
const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 1024;
|
||||
const FEATURED_PLUGIN_IDS_CACHE_TTL: std::time::Duration =
|
||||
std::time::Duration::from_secs(60 * 60 * 3);
|
||||
|
||||
@@ -114,9 +119,6 @@ fn featured_plugin_ids_cache_key(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct AppConnectorId(pub String);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginInstallRequest {
|
||||
pub plugin_name: String,
|
||||
@@ -185,89 +187,6 @@ pub struct ConfiguredMarketplaceListOutcome {
|
||||
pub errors: Vec<MarketplaceListError>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct LoadedPlugin {
|
||||
pub config_name: String,
|
||||
pub manifest_name: Option<String>,
|
||||
pub manifest_description: Option<String>,
|
||||
pub root: AbsolutePathBuf,
|
||||
pub enabled: bool,
|
||||
pub skill_roots: Vec<PathBuf>,
|
||||
pub disabled_skill_paths: HashSet<PathBuf>,
|
||||
pub has_enabled_skills: bool,
|
||||
pub mcp_servers: HashMap<String, McpServerConfig>,
|
||||
pub apps: Vec<AppConnectorId>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl LoadedPlugin {
|
||||
fn is_active(&self) -> bool {
|
||||
self.enabled && self.error.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct PluginCapabilitySummary {
|
||||
pub config_name: String,
|
||||
pub display_name: String,
|
||||
pub description: Option<String>,
|
||||
pub has_skills: bool,
|
||||
pub mcp_server_names: Vec<String>,
|
||||
pub app_connector_ids: Vec<AppConnectorId>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginTelemetryMetadata {
|
||||
pub plugin_id: PluginId,
|
||||
pub capability_summary: Option<PluginCapabilitySummary>,
|
||||
}
|
||||
|
||||
impl PluginTelemetryMetadata {
|
||||
pub fn from_plugin_id(plugin_id: &PluginId) -> Self {
|
||||
Self {
|
||||
plugin_id: plugin_id.clone(),
|
||||
capability_summary: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginCapabilitySummary {
|
||||
fn from_plugin(plugin: &LoadedPlugin) -> Option<Self> {
|
||||
if !plugin.is_active() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut mcp_server_names: Vec<String> = plugin.mcp_servers.keys().cloned().collect();
|
||||
mcp_server_names.sort_unstable();
|
||||
|
||||
let summary = Self {
|
||||
config_name: plugin.config_name.clone(),
|
||||
display_name: plugin
|
||||
.manifest_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| plugin.config_name.clone()),
|
||||
description: prompt_safe_plugin_description(plugin.manifest_description.as_deref()),
|
||||
has_skills: plugin.has_enabled_skills,
|
||||
mcp_server_names,
|
||||
app_connector_ids: plugin.apps.clone(),
|
||||
};
|
||||
|
||||
(summary.has_skills
|
||||
|| !summary.mcp_server_names.is_empty()
|
||||
|| !summary.app_connector_ids.is_empty())
|
||||
.then_some(summary)
|
||||
}
|
||||
|
||||
pub fn telemetry_metadata(&self) -> Option<PluginTelemetryMetadata> {
|
||||
PluginId::parse(&self.config_name)
|
||||
.ok()
|
||||
.map(|plugin_id| PluginTelemetryMetadata {
|
||||
plugin_id,
|
||||
capability_summary: Some(self.clone()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl From<PluginDetail> for PluginCapabilitySummary {
|
||||
fn from(value: PluginDetail) -> Self {
|
||||
let has_skills = value.skills.iter().any(|skill| {
|
||||
@@ -286,95 +205,6 @@ impl From<PluginDetail> for PluginCapabilitySummary {
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_safe_plugin_description(description: Option<&str>) -> Option<String> {
|
||||
let description = description?
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
if description.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(
|
||||
description
|
||||
.chars()
|
||||
.take(MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN)
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PluginLoadOutcome {
|
||||
plugins: Vec<LoadedPlugin>,
|
||||
capability_summaries: Vec<PluginCapabilitySummary>,
|
||||
}
|
||||
|
||||
impl Default for PluginLoadOutcome {
|
||||
fn default() -> Self {
|
||||
Self::from_plugins(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginLoadOutcome {
|
||||
fn from_plugins(plugins: Vec<LoadedPlugin>) -> Self {
|
||||
let capability_summaries = plugins
|
||||
.iter()
|
||||
.filter_map(PluginCapabilitySummary::from_plugin)
|
||||
.collect::<Vec<_>>();
|
||||
Self {
|
||||
plugins,
|
||||
capability_summaries,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn effective_skill_roots(&self) -> Vec<PathBuf> {
|
||||
let mut skill_roots: Vec<PathBuf> = self
|
||||
.plugins
|
||||
.iter()
|
||||
.filter(|plugin| plugin.is_active())
|
||||
.flat_map(|plugin| plugin.skill_roots.iter().cloned())
|
||||
.collect();
|
||||
skill_roots.sort_unstable();
|
||||
skill_roots.dedup();
|
||||
skill_roots
|
||||
}
|
||||
|
||||
pub fn effective_mcp_servers(&self) -> HashMap<String, McpServerConfig> {
|
||||
let mut mcp_servers = HashMap::new();
|
||||
for plugin in self.plugins.iter().filter(|plugin| plugin.is_active()) {
|
||||
for (name, config) in &plugin.mcp_servers {
|
||||
mcp_servers
|
||||
.entry(name.clone())
|
||||
.or_insert_with(|| config.clone());
|
||||
}
|
||||
}
|
||||
mcp_servers
|
||||
}
|
||||
|
||||
pub fn effective_apps(&self) -> Vec<AppConnectorId> {
|
||||
let mut apps = Vec::new();
|
||||
let mut seen_connector_ids = std::collections::HashSet::new();
|
||||
|
||||
for plugin in self.plugins.iter().filter(|plugin| plugin.is_active()) {
|
||||
for connector_id in &plugin.apps {
|
||||
if seen_connector_ids.insert(connector_id.clone()) {
|
||||
apps.push(connector_id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
apps
|
||||
}
|
||||
|
||||
pub fn capability_summaries(&self) -> &[PluginCapabilitySummary] {
|
||||
&self.capability_summaries
|
||||
}
|
||||
|
||||
pub fn plugins(&self) -> &[LoadedPlugin] {
|
||||
&self.plugins
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct RemotePluginSyncResult {
|
||||
/// Plugin ids newly installed into the local plugin cache.
|
||||
@@ -575,6 +405,19 @@ impl PluginsManager {
|
||||
*cached_enabled_outcome = None;
|
||||
}
|
||||
|
||||
/// Resolve plugin skill roots for a config layer stack without touching the plugins cache.
|
||||
pub fn effective_skill_roots_for_layer_stack(
|
||||
&self,
|
||||
config_layer_stack: &ConfigLayerStack,
|
||||
plugins_feature_enabled: bool,
|
||||
) -> Vec<PathBuf> {
|
||||
if !plugins_feature_enabled {
|
||||
return Vec::new();
|
||||
}
|
||||
load_plugins_from_layer_stack(config_layer_stack, &self.store, self.restriction_product)
|
||||
.effective_skill_roots()
|
||||
}
|
||||
|
||||
fn cached_enabled_outcome(&self) -> Option<PluginLoadOutcome> {
|
||||
match self.cached_enabled_outcome.read() {
|
||||
Ok(cache) => cache.clone(),
|
||||
@@ -1174,7 +1017,7 @@ impl PluginsManager {
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
configured_curated_plugin_ids.sort_unstable_by_key(super::store::PluginId::as_key);
|
||||
configured_curated_plugin_ids.sort_unstable_by_key(PluginId::as_key);
|
||||
self.start_curated_repo_sync(configured_curated_plugin_ids);
|
||||
start_startup_remote_plugin_sync_once(
|
||||
Arc::clone(self),
|
||||
@@ -1341,7 +1184,7 @@ impl PluginUninstallError {
|
||||
|
||||
fn log_plugin_load_errors(outcome: &PluginLoadOutcome) {
|
||||
for plugin in outcome
|
||||
.plugins
|
||||
.plugins()
|
||||
.iter()
|
||||
.filter(|plugin| plugin.error.is_some())
|
||||
{
|
||||
@@ -1413,16 +1256,6 @@ pub(crate) fn load_plugins_from_layer_stack(
|
||||
PluginLoadOutcome::from_plugins(plugins)
|
||||
}
|
||||
|
||||
pub(crate) fn plugin_namespace_for_skill_path(path: &Path) -> Option<String> {
|
||||
for ancestor in path.ancestors() {
|
||||
if let Some(manifest) = load_plugin_manifest(ancestor) {
|
||||
return Some(manifest.name);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn refresh_curated_plugin_cache(
|
||||
codex_home: &Path,
|
||||
plugin_version: &str,
|
||||
|
||||
@@ -7,7 +7,9 @@ use crate::config_loader::ConfigLayerEntry;
|
||||
use crate::config_loader::ConfigLayerStack;
|
||||
use crate::config_loader::ConfigRequirements;
|
||||
use crate::config_loader::ConfigRequirementsToml;
|
||||
use crate::plugins::LoadedPlugin;
|
||||
use crate::plugins::MarketplacePluginInstallPolicy;
|
||||
use crate::plugins::PluginLoadOutcome;
|
||||
use crate::plugins::test_support::TEST_CURATED_PLUGIN_SHA;
|
||||
use crate::plugins::test_support::write_curated_plugin_sha_with as write_curated_plugin_sha;
|
||||
use crate::plugins::test_support::write_file;
|
||||
@@ -26,6 +28,8 @@ use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
use wiremock::matchers::query_param;
|
||||
|
||||
const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 1024;
|
||||
|
||||
fn write_plugin(root: &Path, dir_name: &str, manifest_name: &str) {
|
||||
let plugin_root = root.join(dir_name);
|
||||
fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap();
|
||||
@@ -130,7 +134,7 @@ fn load_plugins_loads_default_skills_and_mcp_servers() {
|
||||
let outcome = load_plugins_from_config(&plugin_config_toml(true, true), codex_home.path());
|
||||
|
||||
assert_eq!(
|
||||
outcome.plugins,
|
||||
outcome.plugins(),
|
||||
vec![LoadedPlugin {
|
||||
config_name: "sample@test".to_string(),
|
||||
manifest_name: Some("sample".to_string()),
|
||||
@@ -220,10 +224,10 @@ enabled = true
|
||||
let skill_path = dunce::canonicalize(skill_path).expect("skill path should canonicalize");
|
||||
|
||||
assert_eq!(
|
||||
outcome.plugins[0].disabled_skill_paths,
|
||||
outcome.plugins()[0].disabled_skill_paths,
|
||||
HashSet::from([skill_path])
|
||||
);
|
||||
assert!(!outcome.plugins[0].has_enabled_skills);
|
||||
assert!(!outcome.plugins()[0].has_enabled_skills);
|
||||
assert!(outcome.capability_summaries().is_empty());
|
||||
}
|
||||
|
||||
@@ -256,8 +260,8 @@ enabled = true
|
||||
"#;
|
||||
let outcome = load_plugins_from_config(config_toml, codex_home.path());
|
||||
|
||||
assert!(outcome.plugins[0].disabled_skill_paths.is_empty());
|
||||
assert!(outcome.plugins[0].has_enabled_skills);
|
||||
assert!(outcome.plugins()[0].disabled_skill_paths.is_empty());
|
||||
assert!(outcome.plugins()[0].has_enabled_skills);
|
||||
assert_eq!(
|
||||
outcome.capability_summaries(),
|
||||
&[PluginCapabilitySummary {
|
||||
@@ -338,7 +342,7 @@ fn capability_summary_sanitizes_plugin_descriptions_to_one_line() {
|
||||
let outcome = load_plugins_from_config(&plugin_config_toml(true, true), codex_home.path());
|
||||
|
||||
assert_eq!(
|
||||
outcome.plugins[0].manifest_description.as_deref(),
|
||||
outcome.plugins()[0].manifest_description.as_deref(),
|
||||
Some("Plugin that\n includes the sample\tserver")
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -373,7 +377,7 @@ fn capability_summary_truncates_overlong_plugin_descriptions() {
|
||||
let outcome = load_plugins_from_config(&plugin_config_toml(true, true), codex_home.path());
|
||||
|
||||
assert_eq!(
|
||||
outcome.plugins[0].manifest_description.as_deref(),
|
||||
outcome.plugins()[0].manifest_description.as_deref(),
|
||||
Some(too_long.as_str())
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -453,14 +457,14 @@ fn load_plugins_uses_manifest_configured_component_paths() {
|
||||
let outcome = load_plugins_from_config(&plugin_config_toml(true, true), codex_home.path());
|
||||
|
||||
assert_eq!(
|
||||
outcome.plugins[0].skill_roots,
|
||||
outcome.plugins()[0].skill_roots,
|
||||
vec![
|
||||
plugin_root.join("custom-skills"),
|
||||
plugin_root.join("skills")
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
outcome.plugins[0].mcp_servers,
|
||||
outcome.plugins()[0].mcp_servers,
|
||||
HashMap::from([(
|
||||
"custom".to_string(),
|
||||
McpServerConfig {
|
||||
@@ -483,7 +487,7 @@ fn load_plugins_uses_manifest_configured_component_paths() {
|
||||
)])
|
||||
);
|
||||
assert_eq!(
|
||||
outcome.plugins[0].apps,
|
||||
outcome.plugins()[0].apps,
|
||||
vec![AppConnectorId("connector_custom".to_string())]
|
||||
);
|
||||
}
|
||||
@@ -559,11 +563,11 @@ fn load_plugins_ignores_manifest_component_paths_without_dot_slash() {
|
||||
let outcome = load_plugins_from_config(&plugin_config_toml(true, true), codex_home.path());
|
||||
|
||||
assert_eq!(
|
||||
outcome.plugins[0].skill_roots,
|
||||
outcome.plugins()[0].skill_roots,
|
||||
vec![plugin_root.join("skills")]
|
||||
);
|
||||
assert_eq!(
|
||||
outcome.plugins[0].mcp_servers,
|
||||
outcome.plugins()[0].mcp_servers,
|
||||
HashMap::from([(
|
||||
"default".to_string(),
|
||||
McpServerConfig {
|
||||
@@ -586,7 +590,7 @@ fn load_plugins_ignores_manifest_component_paths_without_dot_slash() {
|
||||
)])
|
||||
);
|
||||
assert_eq!(
|
||||
outcome.plugins[0].apps,
|
||||
outcome.plugins()[0].apps,
|
||||
vec![AppConnectorId("connector_default".to_string())]
|
||||
);
|
||||
}
|
||||
@@ -618,7 +622,7 @@ fn load_plugins_preserves_disabled_plugins_without_effective_contributions() {
|
||||
let outcome = load_plugins_from_config(&plugin_config_toml(false, true), codex_home.path());
|
||||
|
||||
assert_eq!(
|
||||
outcome.plugins,
|
||||
outcome.plugins(),
|
||||
vec![LoadedPlugin {
|
||||
config_name: "sample@test".to_string(),
|
||||
manifest_name: None,
|
||||
@@ -805,24 +809,6 @@ fn capability_index_filters_inactive_and_zero_capability_plugins() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_namespace_for_skill_path_uses_manifest_name() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let plugin_root = codex_home.path().join("plugins/sample");
|
||||
let skill_path = plugin_root.join("skills/search/SKILL.md");
|
||||
|
||||
write_file(
|
||||
&plugin_root.join(".codex-plugin/plugin.json"),
|
||||
r#"{"name":"sample"}"#,
|
||||
);
|
||||
write_file(&skill_path, "---\ndescription: search\n---\n");
|
||||
|
||||
assert_eq!(
|
||||
plugin_namespace_for_skill_path(&skill_path),
|
||||
Some("sample".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_plugins_returns_empty_when_feature_disabled() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
@@ -880,9 +866,9 @@ fn load_plugins_rejects_invalid_plugin_keys() {
|
||||
codex_home.path(),
|
||||
);
|
||||
|
||||
assert_eq!(outcome.plugins.len(), 1);
|
||||
assert_eq!(outcome.plugins().len(), 1);
|
||||
assert_eq!(
|
||||
outcome.plugins[0].error.as_deref(),
|
||||
outcome.plugins()[0].error.as_deref(),
|
||||
Some("invalid plugin key `sample`; expected <plugin>@<marketplace>")
|
||||
);
|
||||
assert!(outcome.effective_skill_roots().is_empty());
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_plugins::PLUGIN_MANIFEST_PATH;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::fs;
|
||||
use std::path::Component;
|
||||
use std::path::Path;
|
||||
|
||||
pub(crate) const PLUGIN_MANIFEST_PATH: &str = ".codex-plugin/plugin.json";
|
||||
const MAX_DEFAULT_PROMPT_COUNT: usize = 3;
|
||||
const MAX_DEFAULT_PROMPT_LEN: usize = 128;
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use super::PluginManifestInterface;
|
||||
use super::load_plugin_manifest;
|
||||
use super::store::PluginId;
|
||||
use super::store::PluginIdError;
|
||||
use codex_app_server_protocol::PluginAuthPolicy;
|
||||
use codex_app_server_protocol::PluginInstallPolicy;
|
||||
use codex_git_utils::get_git_repo_root;
|
||||
use codex_plugin::PluginId;
|
||||
use codex_plugin::PluginIdError;
|
||||
use codex_protocol::protocol::Product;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use dirs::home_dir;
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_protocol::user_input::UserInput;
|
||||
|
||||
use crate::connectors;
|
||||
use crate::mention_syntax::PLUGIN_TEXT_MENTION_SIGIL;
|
||||
use crate::mention_syntax::TOOL_MENTION_SIGIL;
|
||||
use crate::plugins::PluginCapabilitySummary;
|
||||
use crate::skills::SkillMetadata;
|
||||
use crate::skills::injection::ToolMentionKind;
|
||||
use crate::skills::injection::app_id_from_path;
|
||||
use crate::skills::injection::extract_tool_mentions_with_sigil;
|
||||
use crate::skills::injection::plugin_config_name_from_path;
|
||||
use crate::skills::injection::tool_kind_for_path;
|
||||
|
||||
use super::PluginCapabilitySummary;
|
||||
|
||||
pub(crate) struct CollectedToolMentions {
|
||||
pub(crate) plain_names: HashSet<String>,
|
||||
pub(crate) paths: HashSet<String>,
|
||||
@@ -102,23 +101,7 @@ pub(crate) fn collect_explicit_plugin_mentions(
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn build_skill_name_counts(
|
||||
skills: &[SkillMetadata],
|
||||
disabled_paths: &HashSet<PathBuf>,
|
||||
) -> (HashMap<String, usize>, HashMap<String, usize>) {
|
||||
let mut exact_counts: HashMap<String, usize> = HashMap::new();
|
||||
let mut lower_counts: HashMap<String, usize> = HashMap::new();
|
||||
for skill in skills {
|
||||
if disabled_paths.contains(&skill.path_to_skills_md) {
|
||||
continue;
|
||||
}
|
||||
*exact_counts.entry(skill.name.clone()).or_insert(0) += 1;
|
||||
*lower_counts
|
||||
.entry(skill.name.to_ascii_lowercase())
|
||||
.or_insert(0) += 1;
|
||||
}
|
||||
(exact_counts, lower_counts)
|
||||
}
|
||||
pub(crate) use crate::skills::build_skill_name_counts;
|
||||
|
||||
pub(crate) fn build_connector_slug_counts(
|
||||
connectors: &[connectors::AppInfo],
|
||||
@@ -1,8 +1,11 @@
|
||||
use crate::config::types::McpServerConfig;
|
||||
|
||||
mod discoverable;
|
||||
mod injection;
|
||||
mod manager;
|
||||
mod manifest;
|
||||
mod marketplace;
|
||||
mod mentions;
|
||||
mod remote;
|
||||
mod render;
|
||||
mod startup_sync;
|
||||
@@ -11,31 +14,35 @@ mod store;
|
||||
pub(crate) mod test_support;
|
||||
mod toggles;
|
||||
|
||||
pub use codex_plugin::AppConnectorId;
|
||||
pub use codex_plugin::EffectiveSkillRoots;
|
||||
pub use codex_plugin::PluginCapabilitySummary;
|
||||
pub use codex_plugin::PluginId;
|
||||
pub use codex_plugin::PluginIdError;
|
||||
pub use codex_plugin::PluginTelemetryMetadata;
|
||||
|
||||
pub type LoadedPlugin = codex_plugin::LoadedPlugin<McpServerConfig>;
|
||||
pub type PluginLoadOutcome = codex_plugin::PluginLoadOutcome<McpServerConfig>;
|
||||
|
||||
pub(crate) use discoverable::list_tool_suggest_discoverable_plugins;
|
||||
pub(crate) use injection::build_plugin_injections;
|
||||
pub use manager::AppConnectorId;
|
||||
pub use manager::ConfiguredMarketplace;
|
||||
pub use manager::ConfiguredMarketplaceListOutcome;
|
||||
pub use manager::ConfiguredMarketplacePlugin;
|
||||
pub use manager::LoadedPlugin;
|
||||
pub use manager::OPENAI_CURATED_MARKETPLACE_NAME;
|
||||
pub use manager::PluginCapabilitySummary;
|
||||
pub use manager::PluginDetail;
|
||||
pub use manager::PluginInstallError;
|
||||
pub use manager::PluginInstallOutcome;
|
||||
pub use manager::PluginInstallRequest;
|
||||
pub use manager::PluginLoadOutcome;
|
||||
pub use manager::PluginReadOutcome;
|
||||
pub use manager::PluginReadRequest;
|
||||
pub use manager::PluginRemoteSyncError;
|
||||
pub use manager::PluginTelemetryMetadata;
|
||||
pub use manager::PluginUninstallError;
|
||||
pub use manager::PluginsManager;
|
||||
pub use manager::RemotePluginSyncResult;
|
||||
pub use manager::installed_plugin_telemetry_metadata;
|
||||
pub use manager::load_plugin_apps;
|
||||
pub use manager::load_plugin_mcp_servers;
|
||||
pub(crate) use manager::plugin_namespace_for_skill_path;
|
||||
pub use manager::plugin_telemetry_metadata_from_root;
|
||||
pub use manifest::PluginManifestInterface;
|
||||
pub(crate) use manifest::PluginManifestPaths;
|
||||
@@ -53,5 +60,10 @@ pub(crate) use render::render_plugins_section;
|
||||
pub(crate) use startup_sync::curated_plugins_repo_path;
|
||||
pub(crate) use startup_sync::read_curated_plugins_sha;
|
||||
pub(crate) use startup_sync::sync_openai_plugins_repo;
|
||||
pub use store::PluginId;
|
||||
pub use toggles::collect_plugin_enabled_candidates;
|
||||
|
||||
pub(crate) use mentions::build_connector_slug_counts;
|
||||
pub(crate) use mentions::build_skill_name_counts;
|
||||
pub(crate) use mentions::collect_explicit_app_ids;
|
||||
pub(crate) use mentions::collect_explicit_plugin_mentions;
|
||||
pub(crate) use mentions::collect_tool_mentions_from_messages;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use super::load_plugin_manifest;
|
||||
use super::manifest::PLUGIN_MANIFEST_PATH;
|
||||
use codex_plugin::PluginId;
|
||||
use codex_plugin::validate_plugin_segment;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_plugins::PLUGIN_MANIFEST_PATH;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
@@ -9,53 +11,6 @@ use std::path::PathBuf;
|
||||
pub(crate) const DEFAULT_PLUGIN_VERSION: &str = "local";
|
||||
pub(crate) const PLUGINS_CACHE_DIR: &str = "plugins/cache";
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PluginIdError {
|
||||
#[error("{0}")]
|
||||
Invalid(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginId {
|
||||
pub plugin_name: String,
|
||||
pub marketplace_name: String,
|
||||
}
|
||||
|
||||
impl PluginId {
|
||||
pub fn new(plugin_name: String, marketplace_name: String) -> Result<Self, PluginIdError> {
|
||||
validate_plugin_segment(&plugin_name, "plugin name").map_err(PluginIdError::Invalid)?;
|
||||
validate_plugin_segment(&marketplace_name, "marketplace name")
|
||||
.map_err(PluginIdError::Invalid)?;
|
||||
Ok(Self {
|
||||
plugin_name,
|
||||
marketplace_name,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn parse(plugin_key: &str) -> Result<Self, PluginIdError> {
|
||||
let Some((plugin_name, marketplace_name)) = plugin_key.rsplit_once('@') else {
|
||||
return Err(PluginIdError::Invalid(format!(
|
||||
"invalid plugin key `{plugin_key}`; expected <plugin>@<marketplace>"
|
||||
)));
|
||||
};
|
||||
if plugin_name.is_empty() || marketplace_name.is_empty() {
|
||||
return Err(PluginIdError::Invalid(format!(
|
||||
"invalid plugin key `{plugin_key}`; expected <plugin>@<marketplace>"
|
||||
)));
|
||||
}
|
||||
|
||||
Self::new(plugin_name.to_string(), marketplace_name.to_string()).map_err(|err| match err {
|
||||
PluginIdError::Invalid(message) => {
|
||||
PluginIdError::Invalid(format!("{message} in `{plugin_key}`"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn as_key(&self) -> String {
|
||||
format!("{}@{}", self.plugin_name, self.marketplace_name)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginInstallResult {
|
||||
pub plugin_id: PluginId,
|
||||
@@ -221,21 +176,6 @@ fn plugin_name_for_source(source_path: &Path) -> Result<String, PluginStoreError
|
||||
.map(|_| plugin_name)
|
||||
}
|
||||
|
||||
fn validate_plugin_segment(segment: &str, kind: &str) -> Result<(), String> {
|
||||
if segment.is_empty() {
|
||||
return Err(format!("invalid {kind}: must not be empty"));
|
||||
}
|
||||
if !segment
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
|
||||
{
|
||||
return Err(format!(
|
||||
"invalid {kind}: only ASCII letters, digits, `_`, and `-` are allowed"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_existing_target(path: &Path) -> Result<(), PluginStoreError> {
|
||||
if !path.exists() {
|
||||
return Ok(());
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use codex_plugin::PluginId;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::tempdir;
|
||||
|
||||
|
||||
230
codex-rs/core/src/skills.rs
Normal file
230
codex-rs/core/src/skills.rs
Normal file
@@ -0,0 +1,230 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::env;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::config::Config;
|
||||
use codex_analytics::InvocationType;
|
||||
use codex_analytics::SkillInvocation;
|
||||
use codex_analytics::build_track_events_context;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use codex_protocol::request_user_input::RequestUserInputArgs;
|
||||
use codex_protocol::request_user_input::RequestUserInputQuestion;
|
||||
use codex_protocol::request_user_input::RequestUserInputResponse;
|
||||
use tracing::warn;
|
||||
|
||||
pub use codex_core_skills::skills::SkillDependencyInfo;
|
||||
pub use codex_core_skills::skills::SkillError;
|
||||
pub use codex_core_skills::skills::SkillLoadOutcome;
|
||||
pub use codex_core_skills::skills::SkillMetadata;
|
||||
pub use codex_core_skills::skills::SkillPolicy;
|
||||
pub use codex_core_skills::skills::SkillsLoadInput;
|
||||
pub use codex_core_skills::skills::SkillsManager;
|
||||
pub use codex_core_skills::skills::build_skill_name_counts;
|
||||
pub use codex_core_skills::skills::collect_env_var_dependencies;
|
||||
pub use codex_core_skills::skills::config_rules;
|
||||
pub use codex_core_skills::skills::detect_implicit_skill_invocation_for_command;
|
||||
pub use codex_core_skills::skills::filter_skill_load_outcome_for_product;
|
||||
pub use codex_core_skills::skills::injection;
|
||||
pub use codex_core_skills::skills::injection::SkillInjections;
|
||||
pub use codex_core_skills::skills::injection::build_skill_injections;
|
||||
pub use codex_core_skills::skills::injection::collect_explicit_skill_mentions;
|
||||
pub use codex_core_skills::skills::loader;
|
||||
pub use codex_core_skills::skills::manager;
|
||||
pub use codex_core_skills::skills::model;
|
||||
pub use codex_core_skills::skills::remote;
|
||||
pub use codex_core_skills::skills::render;
|
||||
pub use codex_core_skills::skills::render_skills_section;
|
||||
pub use codex_core_skills::skills::system;
|
||||
|
||||
pub(crate) fn skills_load_input_from_config(
|
||||
config: &Config,
|
||||
effective_skill_roots: Vec<PathBuf>,
|
||||
) -> SkillsLoadInput {
|
||||
SkillsLoadInput::new(
|
||||
config.cwd.clone(),
|
||||
effective_skill_roots,
|
||||
config.config_layer_stack.clone(),
|
||||
config.bundled_skills_enabled(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_skill_dependencies_for_turn(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
dependencies: &[SkillDependencyInfo],
|
||||
) {
|
||||
if dependencies.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let existing_env = sess.dependency_env().await;
|
||||
let mut loaded_values = HashMap::new();
|
||||
let mut missing = Vec::new();
|
||||
let mut seen_names = HashSet::new();
|
||||
|
||||
for dependency in dependencies {
|
||||
let name = dependency.name.clone();
|
||||
if !seen_names.insert(name.clone()) || existing_env.contains_key(&name) {
|
||||
continue;
|
||||
}
|
||||
match env::var(&name) {
|
||||
Ok(value) => {
|
||||
loaded_values.insert(name.clone(), value);
|
||||
}
|
||||
Err(env::VarError::NotPresent) => {
|
||||
missing.push(dependency.clone());
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("failed to read env var {name}: {err}");
|
||||
missing.push(dependency.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !loaded_values.is_empty() {
|
||||
sess.set_dependency_env(loaded_values).await;
|
||||
}
|
||||
|
||||
if !missing.is_empty() {
|
||||
request_skill_dependencies(sess, turn_context, &missing).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn request_skill_dependencies(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
dependencies: &[SkillDependencyInfo],
|
||||
) {
|
||||
let questions = dependencies
|
||||
.iter()
|
||||
.map(|dependency| {
|
||||
let requirement = dependency.description.as_ref().map_or_else(
|
||||
|| {
|
||||
format!(
|
||||
"The skill \"{}\" requires \"{}\" to be set.",
|
||||
dependency.skill_name, dependency.name
|
||||
)
|
||||
},
|
||||
|description| {
|
||||
format!(
|
||||
"The skill \"{}\" requires \"{}\" to be set ({}).",
|
||||
dependency.skill_name, dependency.name, description
|
||||
)
|
||||
},
|
||||
);
|
||||
RequestUserInputQuestion {
|
||||
id: dependency.name.clone(),
|
||||
header: "Skill requires environment variable".to_string(),
|
||||
question: format!(
|
||||
"{requirement} This is an experimental internal feature. The value is stored in memory for this session only."
|
||||
),
|
||||
is_other: false,
|
||||
is_secret: true,
|
||||
options: None,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if questions.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let response = sess
|
||||
.request_user_input(
|
||||
turn_context,
|
||||
format!("skill-deps-{}", turn_context.sub_id),
|
||||
RequestUserInputArgs { questions },
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|| RequestUserInputResponse {
|
||||
answers: HashMap::new(),
|
||||
});
|
||||
if response.answers.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut values = HashMap::new();
|
||||
for (name, answer) in response.answers {
|
||||
let mut user_note = None;
|
||||
for entry in &answer.answers {
|
||||
if let Some(note) = entry.strip_prefix("user_note: ")
|
||||
&& !note.trim().is_empty()
|
||||
{
|
||||
user_note = Some(note.trim().to_string());
|
||||
}
|
||||
}
|
||||
if let Some(value) = user_note {
|
||||
values.insert(name, value);
|
||||
}
|
||||
}
|
||||
if values.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
sess.set_dependency_env(values).await;
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_emit_implicit_skill_invocation(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
command: &str,
|
||||
workdir: &Path,
|
||||
) {
|
||||
let Some(candidate) = detect_implicit_skill_invocation_for_command(
|
||||
turn_context.turn_skills.outcome.as_ref(),
|
||||
command,
|
||||
workdir,
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let invocation = SkillInvocation {
|
||||
skill_name: candidate.name,
|
||||
skill_scope: candidate.scope,
|
||||
skill_path: candidate.path_to_skills_md,
|
||||
invocation_type: InvocationType::Implicit,
|
||||
};
|
||||
let skill_scope = match invocation.skill_scope {
|
||||
SkillScope::User => "user",
|
||||
SkillScope::Repo => "repo",
|
||||
SkillScope::System => "system",
|
||||
SkillScope::Admin => "admin",
|
||||
};
|
||||
let skill_path = invocation.skill_path.to_string_lossy();
|
||||
let skill_name = invocation.skill_name.clone();
|
||||
let seen_key = format!("{skill_scope}:{skill_path}:{skill_name}");
|
||||
let inserted = {
|
||||
let mut seen_skills = turn_context
|
||||
.turn_skills
|
||||
.implicit_invocation_seen_skills
|
||||
.lock()
|
||||
.await;
|
||||
seen_skills.insert(seen_key)
|
||||
};
|
||||
if !inserted {
|
||||
return;
|
||||
}
|
||||
|
||||
turn_context.session_telemetry.counter(
|
||||
"codex.skill.injected",
|
||||
/*inc*/ 1,
|
||||
&[
|
||||
("status", "ok"),
|
||||
("skill", skill_name.as_str()),
|
||||
("invoke_type", "implicit"),
|
||||
],
|
||||
);
|
||||
sess.services
|
||||
.analytics_events_client
|
||||
.track_skill_invocations(
|
||||
build_track_events_context(
|
||||
turn_context.model_info.slug.clone(),
|
||||
sess.conversation_id.to_string(),
|
||||
turn_context.sub_id.clone(),
|
||||
),
|
||||
vec![invocation],
|
||||
);
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::env;
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_protocol::request_user_input::RequestUserInputArgs;
|
||||
use codex_protocol::request_user_input::RequestUserInputQuestion;
|
||||
use codex_protocol::request_user_input::RequestUserInputResponse;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::skills::SkillMetadata;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct SkillDependencyInfo {
|
||||
pub(crate) skill_name: String,
|
||||
pub(crate) name: String,
|
||||
pub(crate) description: Option<String>,
|
||||
}
|
||||
|
||||
/// Resolve required dependency values (session cache, then env vars),
|
||||
/// and prompt the UI for any missing ones.
|
||||
pub(crate) async fn resolve_skill_dependencies_for_turn(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
dependencies: &[SkillDependencyInfo],
|
||||
) {
|
||||
if dependencies.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let existing_env = sess.dependency_env().await;
|
||||
let mut loaded_values = HashMap::new();
|
||||
let mut missing = Vec::new();
|
||||
let mut seen_names = HashSet::new();
|
||||
|
||||
for dependency in dependencies {
|
||||
let name = dependency.name.clone();
|
||||
if !seen_names.insert(name.clone()) {
|
||||
continue;
|
||||
}
|
||||
if existing_env.contains_key(&name) {
|
||||
continue;
|
||||
}
|
||||
match env::var(&name) {
|
||||
Ok(value) => {
|
||||
loaded_values.insert(name.clone(), value);
|
||||
continue;
|
||||
}
|
||||
Err(env::VarError::NotPresent) => {}
|
||||
Err(err) => {
|
||||
warn!("failed to read env var {name}: {err}");
|
||||
}
|
||||
}
|
||||
missing.push(dependency.clone());
|
||||
}
|
||||
|
||||
if !loaded_values.is_empty() {
|
||||
sess.set_dependency_env(loaded_values).await;
|
||||
}
|
||||
|
||||
if !missing.is_empty() {
|
||||
request_skill_dependencies(sess, turn_context, &missing).await;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn collect_env_var_dependencies(
|
||||
mentioned_skills: &[SkillMetadata],
|
||||
) -> Vec<SkillDependencyInfo> {
|
||||
let mut dependencies = Vec::new();
|
||||
for skill in mentioned_skills {
|
||||
let Some(skill_dependencies) = &skill.dependencies else {
|
||||
continue;
|
||||
};
|
||||
for tool in &skill_dependencies.tools {
|
||||
if tool.r#type != "env_var" {
|
||||
continue;
|
||||
}
|
||||
if tool.value.is_empty() {
|
||||
continue;
|
||||
}
|
||||
dependencies.push(SkillDependencyInfo {
|
||||
skill_name: skill.name.clone(),
|
||||
name: tool.value.clone(),
|
||||
description: tool.description.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
dependencies
|
||||
}
|
||||
|
||||
/// Prompt via request_user_input to gather missing env vars.
|
||||
pub(crate) async fn request_skill_dependencies(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
dependencies: &[SkillDependencyInfo],
|
||||
) {
|
||||
let questions = dependencies
|
||||
.iter()
|
||||
.map(|dep| {
|
||||
let requirement = dep.description.as_ref().map_or_else(
|
||||
|| format!("The skill \"{}\" requires \"{}\" to be set.", dep.skill_name, dep.name),
|
||||
|description| {
|
||||
format!(
|
||||
"The skill \"{}\" requires \"{}\" to be set ({}).",
|
||||
dep.skill_name, dep.name, description
|
||||
)
|
||||
},
|
||||
);
|
||||
let question = format!(
|
||||
"{requirement} This is an experimental internal feature. The value is stored in memory for this session only.",
|
||||
);
|
||||
RequestUserInputQuestion {
|
||||
id: dep.name.clone(),
|
||||
header: "Skill requires environment variable".to_string(),
|
||||
question,
|
||||
is_other: false,
|
||||
is_secret: true,
|
||||
options: None,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if questions.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let args = RequestUserInputArgs { questions };
|
||||
let call_id = format!("skill-deps-{}", turn_context.sub_id);
|
||||
let response = sess
|
||||
.request_user_input(turn_context, call_id, args)
|
||||
.await
|
||||
.unwrap_or_else(|| RequestUserInputResponse {
|
||||
answers: HashMap::new(),
|
||||
});
|
||||
|
||||
if response.answers.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut values = HashMap::new();
|
||||
for (name, answer) in response.answers {
|
||||
let mut user_note = None;
|
||||
for entry in &answer.answers {
|
||||
if let Some(note) = entry.strip_prefix("user_note: ")
|
||||
&& !note.trim().is_empty()
|
||||
{
|
||||
user_note = Some(note.trim().to_string());
|
||||
}
|
||||
}
|
||||
if let Some(value) = user_note {
|
||||
values.insert(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
if values.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
sess.set_dependency_env(values).await;
|
||||
}
|
||||
@@ -15,7 +15,9 @@ use crate::file_watcher::Receiver;
|
||||
use crate::file_watcher::ThrottledWatchReceiver;
|
||||
use crate::file_watcher::WatchPath;
|
||||
use crate::file_watcher::WatchRegistration;
|
||||
use crate::plugins::PluginsManager;
|
||||
use crate::skills::SkillsManager;
|
||||
use crate::skills::skills_load_input_from_config;
|
||||
|
||||
#[cfg(not(test))]
|
||||
const WATCHER_THROTTLE_INTERVAL: Duration = Duration::from_secs(10);
|
||||
@@ -56,9 +58,13 @@ impl SkillsWatcher {
|
||||
&self,
|
||||
config: &Config,
|
||||
skills_manager: &SkillsManager,
|
||||
plugins_manager: &PluginsManager,
|
||||
) -> WatchRegistration {
|
||||
let plugin_outcome = plugins_manager.plugins_for_config(config);
|
||||
let effective_skill_roots = plugin_outcome.effective_skill_roots();
|
||||
let skills_input = skills_load_input_from_config(config, effective_skill_roots);
|
||||
let roots = skills_manager
|
||||
.skill_roots_for_config(config)
|
||||
.skill_roots_for_config(&skills_input)
|
||||
.into_iter()
|
||||
.map(|root| WatchPath {
|
||||
path: root.path,
|
||||
|
||||
@@ -4,7 +4,6 @@ use std::sync::Arc;
|
||||
use crate::AuthManager;
|
||||
use crate::RolloutRecorder;
|
||||
use crate::agent::AgentControl;
|
||||
use crate::analytics_client::AnalyticsEventsClient;
|
||||
use crate::client::ModelClient;
|
||||
use crate::config::StartedNetworkProxy;
|
||||
use crate::exec_policy::ExecPolicyManager;
|
||||
@@ -20,6 +19,7 @@ use crate::tools::network_approval::NetworkApprovalService;
|
||||
use crate::tools::runtimes::ExecveSessionApproval;
|
||||
use crate::tools::sandboxing::ApprovalStore;
|
||||
use crate::unified_exec::UnifiedExecProcessManager;
|
||||
use codex_analytics::AnalyticsEventsClient;
|
||||
use codex_exec_server::Environment;
|
||||
use codex_hooks::Hooks;
|
||||
use codex_otel::SessionTelemetry;
|
||||
|
||||
@@ -231,7 +231,6 @@ impl ThreadManager {
|
||||
let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager)));
|
||||
let skills_manager = Arc::new(SkillsManager::new_with_restriction_product(
|
||||
codex_home.clone(),
|
||||
Arc::clone(&plugins_manager),
|
||||
config.bundled_skills_enabled(),
|
||||
restriction_product,
|
||||
));
|
||||
@@ -297,7 +296,6 @@ impl ThreadManager {
|
||||
let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager)));
|
||||
let skills_manager = Arc::new(SkillsManager::new_with_restriction_product(
|
||||
codex_home.clone(),
|
||||
Arc::clone(&plugins_manager),
|
||||
/*bundled_skills_enabled*/ true,
|
||||
restriction_product,
|
||||
));
|
||||
@@ -835,9 +833,11 @@ impl ThreadManagerState {
|
||||
parent_trace: Option<W3cTraceContext>,
|
||||
user_shell_override: Option<crate::shell::Shell>,
|
||||
) -> CodexResult<NewThread> {
|
||||
let watch_registration = self
|
||||
.skills_watcher
|
||||
.register_config(&config, self.skills_manager.as_ref());
|
||||
let watch_registration = self.skills_watcher.register_config(
|
||||
&config,
|
||||
self.skills_manager.as_ref(),
|
||||
self.plugins_manager.as_ref(),
|
||||
);
|
||||
let CodexSpawnOk {
|
||||
codex, thread_id, ..
|
||||
} = Codex::spawn(CodexSpawnArgs {
|
||||
|
||||
@@ -288,11 +288,12 @@ impl ToolHandler for ShellCommandHandler {
|
||||
let cwd = resolve_workdir_base_path(&arguments, turn.cwd.as_path())?;
|
||||
let params: ShellCommandToolCallParams =
|
||||
parse_arguments_with_base_path(&arguments, cwd.as_path())?;
|
||||
let workdir = turn.resolve_path(params.workdir.clone());
|
||||
maybe_emit_implicit_skill_invocation(
|
||||
session.as_ref(),
|
||||
turn.as_ref(),
|
||||
¶ms.command,
|
||||
params.workdir.as_deref(),
|
||||
&workdir,
|
||||
)
|
||||
.await;
|
||||
let prefix_rule = params.prefix_rule.clone();
|
||||
|
||||
@@ -145,11 +145,12 @@ impl ToolHandler for UnifiedExecHandler {
|
||||
let cwd = resolve_workdir_base_path(&arguments, context.turn.cwd.as_path())?;
|
||||
let args: ExecCommandArgs =
|
||||
parse_arguments_with_base_path(&arguments, cwd.as_path())?;
|
||||
let workdir = context.turn.resolve_path(args.workdir.clone());
|
||||
maybe_emit_implicit_skill_invocation(
|
||||
session.as_ref(),
|
||||
turn.as_ref(),
|
||||
context.turn.as_ref(),
|
||||
&args.cmd,
|
||||
args.workdir.as_deref(),
|
||||
&workdir,
|
||||
)
|
||||
.await;
|
||||
let process_id = manager.allocate_process_id().await;
|
||||
|
||||
@@ -13,6 +13,7 @@ use crate::sandboxing::ExecRequest;
|
||||
use crate::sandboxing::SandboxPermissions;
|
||||
use crate::shell::ShellType;
|
||||
use crate::skills::SkillMetadata;
|
||||
use crate::skills::skills_load_input_from_config;
|
||||
use crate::tools::runtimes::ExecveSessionApproval;
|
||||
use crate::tools::runtimes::build_sandbox_command;
|
||||
use crate::tools::sandboxing::SandboxAttempt;
|
||||
@@ -487,11 +488,19 @@ impl CoreShellActionProvider {
|
||||
/// any skills.
|
||||
async fn find_skill(&self, program: &AbsolutePathBuf) -> Option<SkillMetadata> {
|
||||
let force_reload = false;
|
||||
let turn_config = self.turn.config.as_ref();
|
||||
let plugin_outcome = self
|
||||
.session
|
||||
.services
|
||||
.plugins_manager
|
||||
.plugins_for_config(turn_config);
|
||||
let effective_skill_roots = plugin_outcome.effective_skill_roots();
|
||||
let skills_input = skills_load_input_from_config(turn_config, effective_skill_roots);
|
||||
let skills_outcome = self
|
||||
.session
|
||||
.services
|
||||
.skills_manager
|
||||
.skills_for_cwd(&self.turn.cwd, self.turn.config.as_ref(), force_reload)
|
||||
.skills_for_cwd(&skills_input, force_reload)
|
||||
.await;
|
||||
|
||||
let program_path = program.as_path();
|
||||
|
||||
16
codex-rs/instructions/BUILD.bazel
Normal file
16
codex-rs/instructions/BUILD.bazel
Normal file
@@ -0,0 +1,16 @@
|
||||
load("//:defs.bzl", "codex_rust_crate")
|
||||
|
||||
codex_rust_crate(
|
||||
name = "instructions",
|
||||
crate_name = "codex_instructions",
|
||||
compile_data = glob(
|
||||
include = ["**"],
|
||||
exclude = [
|
||||
"BUILD.bazel",
|
||||
"Cargo.toml",
|
||||
],
|
||||
allow_empty = True,
|
||||
) + [
|
||||
"//codex-rs:node-version.txt",
|
||||
],
|
||||
)
|
||||
20
codex-rs/instructions/Cargo.toml
Normal file
20
codex-rs/instructions/Cargo.toml
Normal file
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
name = "codex-instructions"
|
||||
version.workspace = true
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
name = "codex_instructions"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
codex-protocol = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
61
codex-rs/instructions/src/fragment.rs
Normal file
61
codex-rs/instructions/src/fragment.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
|
||||
pub const AGENTS_MD_START_MARKER: &str = "# AGENTS.md instructions for ";
|
||||
pub const AGENTS_MD_END_MARKER: &str = "</INSTRUCTIONS>";
|
||||
pub const SKILL_OPEN_TAG: &str = "<skill>";
|
||||
pub const SKILL_CLOSE_TAG: &str = "</skill>";
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct ContextualUserFragmentDefinition {
|
||||
start_marker: &'static str,
|
||||
end_marker: &'static str,
|
||||
}
|
||||
|
||||
impl ContextualUserFragmentDefinition {
|
||||
pub const fn new(start_marker: &'static str, end_marker: &'static str) -> Self {
|
||||
Self {
|
||||
start_marker,
|
||||
end_marker,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn matches_text(&self, text: &str) -> bool {
|
||||
let trimmed = text.trim_start();
|
||||
let starts_with_marker = trimmed
|
||||
.get(..self.start_marker.len())
|
||||
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(self.start_marker));
|
||||
let trimmed = trimmed.trim_end();
|
||||
let ends_with_marker = trimmed
|
||||
.get(trimmed.len().saturating_sub(self.end_marker.len())..)
|
||||
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(self.end_marker));
|
||||
starts_with_marker && ends_with_marker
|
||||
}
|
||||
|
||||
pub const fn start_marker(&self) -> &'static str {
|
||||
self.start_marker
|
||||
}
|
||||
|
||||
pub const fn end_marker(&self) -> &'static str {
|
||||
self.end_marker
|
||||
}
|
||||
|
||||
pub fn wrap(&self, body: String) -> String {
|
||||
format!("{}\n{}\n{}", self.start_marker, body, self.end_marker)
|
||||
}
|
||||
|
||||
pub fn into_message(self, text: String) -> ResponseItem {
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText { text }],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub const AGENTS_MD_FRAGMENT: ContextualUserFragmentDefinition =
|
||||
ContextualUserFragmentDefinition::new(AGENTS_MD_START_MARKER, AGENTS_MD_END_MARKER);
|
||||
pub const SKILL_FRAGMENT: ContextualUserFragmentDefinition =
|
||||
ContextualUserFragmentDefinition::new(SKILL_OPEN_TAG, SKILL_CLOSE_TAG);
|
||||
15
codex-rs/instructions/src/lib.rs
Normal file
15
codex-rs/instructions/src/lib.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
//! User and skill instruction payloads and contextual user fragment markers for Codex prompts.
|
||||
|
||||
mod fragment;
|
||||
mod user_instructions;
|
||||
|
||||
pub use fragment::AGENTS_MD_END_MARKER;
|
||||
pub use fragment::AGENTS_MD_FRAGMENT;
|
||||
pub use fragment::AGENTS_MD_START_MARKER;
|
||||
pub use fragment::ContextualUserFragmentDefinition;
|
||||
pub use fragment::SKILL_CLOSE_TAG;
|
||||
pub use fragment::SKILL_FRAGMENT;
|
||||
pub use fragment::SKILL_OPEN_TAG;
|
||||
pub use user_instructions::SkillInstructions;
|
||||
pub use user_instructions::USER_INSTRUCTIONS_PREFIX;
|
||||
pub use user_instructions::UserInstructions;
|
||||
@@ -3,20 +3,21 @@ use serde::Serialize;
|
||||
|
||||
use codex_protocol::models::ResponseItem;
|
||||
|
||||
use crate::contextual_user_message::AGENTS_MD_FRAGMENT;
|
||||
use crate::contextual_user_message::SKILL_FRAGMENT;
|
||||
use crate::fragment::AGENTS_MD_FRAGMENT;
|
||||
use crate::fragment::AGENTS_MD_START_MARKER;
|
||||
use crate::fragment::SKILL_FRAGMENT;
|
||||
|
||||
pub const USER_INSTRUCTIONS_PREFIX: &str = "# AGENTS.md instructions for ";
|
||||
pub const USER_INSTRUCTIONS_PREFIX: &str = AGENTS_MD_START_MARKER;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename = "user_instructions", rename_all = "snake_case")]
|
||||
pub(crate) struct UserInstructions {
|
||||
pub struct UserInstructions {
|
||||
pub directory: String,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
impl UserInstructions {
|
||||
pub(crate) fn serialize_to_text(&self) -> String {
|
||||
pub fn serialize_to_text(&self) -> String {
|
||||
format!(
|
||||
"{prefix}{directory}\n\n<INSTRUCTIONS>\n{contents}\n{suffix}",
|
||||
prefix = AGENTS_MD_FRAGMENT.start_marker(),
|
||||
@@ -35,14 +36,12 @@ impl From<UserInstructions> for ResponseItem {
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename = "skill_instructions", rename_all = "snake_case")]
|
||||
pub(crate) struct SkillInstructions {
|
||||
pub struct SkillInstructions {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub contents: String,
|
||||
}
|
||||
|
||||
impl SkillInstructions {}
|
||||
|
||||
impl From<SkillInstructions> for ResponseItem {
|
||||
fn from(si: SkillInstructions) -> Self {
|
||||
SKILL_FRAGMENT.into_message(SKILL_FRAGMENT.wrap(format!(
|
||||
@@ -1,7 +1,11 @@
|
||||
use super::*;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use crate::fragment::AGENTS_MD_FRAGMENT;
|
||||
use crate::fragment::SKILL_FRAGMENT;
|
||||
|
||||
#[test]
|
||||
fn test_user_instructions() {
|
||||
let user_instructions = UserInstructions {
|
||||
15
codex-rs/plugin/BUILD.bazel
Normal file
15
codex-rs/plugin/BUILD.bazel
Normal file
@@ -0,0 +1,15 @@
|
||||
load("//:defs.bzl", "codex_rust_crate")
|
||||
|
||||
codex_rust_crate(
|
||||
name = "plugin",
|
||||
crate_name = "codex_plugin",
|
||||
compile_data = glob(
|
||||
include = ["**"],
|
||||
exclude = [
|
||||
"**/* *",
|
||||
"BUILD.bazel",
|
||||
"Cargo.toml",
|
||||
],
|
||||
allow_empty = True,
|
||||
),
|
||||
)
|
||||
21
codex-rs/plugin/Cargo.toml
Normal file
21
codex-rs/plugin/Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
name = "codex-plugin"
|
||||
version.workspace = true
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
name = "codex_plugin"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
codex-utils-absolute-path = { workspace = true }
|
||||
codex-utils-plugins = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
55
codex-rs/plugin/src/lib.rs
Normal file
55
codex-rs/plugin/src/lib.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
//! Shared plugin identifiers and telemetry-facing summaries.
|
||||
|
||||
pub use codex_utils_plugins::PLUGIN_MANIFEST_PATH;
|
||||
pub use codex_utils_plugins::mention_syntax;
|
||||
pub use codex_utils_plugins::plugin_namespace_for_skill_path;
|
||||
|
||||
mod load_outcome;
|
||||
mod plugin_id;
|
||||
|
||||
pub use load_outcome::EffectiveSkillRoots;
|
||||
pub use load_outcome::LoadedPlugin;
|
||||
pub use load_outcome::PluginLoadOutcome;
|
||||
pub use load_outcome::prompt_safe_plugin_description;
|
||||
pub use plugin_id::PluginId;
|
||||
pub use plugin_id::PluginIdError;
|
||||
pub use plugin_id::validate_plugin_segment;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct AppConnectorId(pub String);
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct PluginCapabilitySummary {
|
||||
pub config_name: String,
|
||||
pub display_name: String,
|
||||
pub description: Option<String>,
|
||||
pub has_skills: bool,
|
||||
pub mcp_server_names: Vec<String>,
|
||||
pub app_connector_ids: Vec<AppConnectorId>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginTelemetryMetadata {
|
||||
pub plugin_id: PluginId,
|
||||
pub capability_summary: Option<PluginCapabilitySummary>,
|
||||
}
|
||||
|
||||
impl PluginTelemetryMetadata {
|
||||
pub fn from_plugin_id(plugin_id: &PluginId) -> Self {
|
||||
Self {
|
||||
plugin_id: plugin_id.clone(),
|
||||
capability_summary: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginCapabilitySummary {
|
||||
pub fn telemetry_metadata(&self) -> Option<PluginTelemetryMetadata> {
|
||||
PluginId::parse(&self.config_name)
|
||||
.ok()
|
||||
.map(|plugin_id| PluginTelemetryMetadata {
|
||||
plugin_id,
|
||||
capability_summary: Some(self.clone()),
|
||||
})
|
||||
}
|
||||
}
|
||||
163
codex-rs/plugin/src/load_outcome.rs
Normal file
163
codex-rs/plugin/src/load_outcome.rs
Normal file
@@ -0,0 +1,163 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
|
||||
use crate::AppConnectorId;
|
||||
use crate::PluginCapabilitySummary;
|
||||
|
||||
const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 1024;
|
||||
|
||||
/// A plugin that was loaded from disk, including merged MCP server definitions.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct LoadedPlugin<M> {
|
||||
pub config_name: String,
|
||||
pub manifest_name: Option<String>,
|
||||
pub manifest_description: Option<String>,
|
||||
pub root: AbsolutePathBuf,
|
||||
pub enabled: bool,
|
||||
pub skill_roots: Vec<PathBuf>,
|
||||
pub disabled_skill_paths: HashSet<PathBuf>,
|
||||
pub has_enabled_skills: bool,
|
||||
pub mcp_servers: HashMap<String, M>,
|
||||
pub apps: Vec<AppConnectorId>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl<M> LoadedPlugin<M> {
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.enabled && self.error.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
fn plugin_capability_summary_from_loaded<M>(
|
||||
plugin: &LoadedPlugin<M>,
|
||||
) -> Option<PluginCapabilitySummary> {
|
||||
if !plugin.is_active() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut mcp_server_names: Vec<String> = plugin.mcp_servers.keys().cloned().collect();
|
||||
mcp_server_names.sort_unstable();
|
||||
|
||||
let summary = PluginCapabilitySummary {
|
||||
config_name: plugin.config_name.clone(),
|
||||
display_name: plugin
|
||||
.manifest_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| plugin.config_name.clone()),
|
||||
description: prompt_safe_plugin_description(plugin.manifest_description.as_deref()),
|
||||
has_skills: plugin.has_enabled_skills,
|
||||
mcp_server_names,
|
||||
app_connector_ids: plugin.apps.clone(),
|
||||
};
|
||||
|
||||
(summary.has_skills
|
||||
|| !summary.mcp_server_names.is_empty()
|
||||
|| !summary.app_connector_ids.is_empty())
|
||||
.then_some(summary)
|
||||
}
|
||||
|
||||
/// Normalizes plugin descriptions for inclusion in model-facing capability summaries.
|
||||
pub fn prompt_safe_plugin_description(description: Option<&str>) -> Option<String> {
|
||||
let description = description?
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
if description.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(
|
||||
description
|
||||
.chars()
|
||||
.take(MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN)
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Outcome of loading configured plugins (skills roots, MCP, apps, errors).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PluginLoadOutcome<M> {
|
||||
plugins: Vec<LoadedPlugin<M>>,
|
||||
capability_summaries: Vec<PluginCapabilitySummary>,
|
||||
}
|
||||
|
||||
impl<M: Clone> Default for PluginLoadOutcome<M> {
|
||||
fn default() -> Self {
|
||||
Self::from_plugins(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
impl<M: Clone> PluginLoadOutcome<M> {
|
||||
pub fn from_plugins(plugins: Vec<LoadedPlugin<M>>) -> Self {
|
||||
let capability_summaries = plugins
|
||||
.iter()
|
||||
.filter_map(plugin_capability_summary_from_loaded)
|
||||
.collect::<Vec<_>>();
|
||||
Self {
|
||||
plugins,
|
||||
capability_summaries,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn effective_skill_roots(&self) -> Vec<PathBuf> {
|
||||
let mut skill_roots: Vec<PathBuf> = self
|
||||
.plugins
|
||||
.iter()
|
||||
.filter(|plugin| plugin.is_active())
|
||||
.flat_map(|plugin| plugin.skill_roots.iter().cloned())
|
||||
.collect();
|
||||
skill_roots.sort_unstable();
|
||||
skill_roots.dedup();
|
||||
skill_roots
|
||||
}
|
||||
|
||||
pub fn effective_mcp_servers(&self) -> HashMap<String, M> {
|
||||
let mut mcp_servers = HashMap::new();
|
||||
for plugin in self.plugins.iter().filter(|plugin| plugin.is_active()) {
|
||||
for (name, config) in &plugin.mcp_servers {
|
||||
mcp_servers
|
||||
.entry(name.clone())
|
||||
.or_insert_with(|| config.clone());
|
||||
}
|
||||
}
|
||||
mcp_servers
|
||||
}
|
||||
|
||||
pub fn effective_apps(&self) -> Vec<AppConnectorId> {
|
||||
let mut apps = Vec::new();
|
||||
let mut seen_connector_ids = HashSet::new();
|
||||
|
||||
for plugin in self.plugins.iter().filter(|plugin| plugin.is_active()) {
|
||||
for connector_id in &plugin.apps {
|
||||
if seen_connector_ids.insert(connector_id.clone()) {
|
||||
apps.push(connector_id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
apps
|
||||
}
|
||||
|
||||
pub fn capability_summaries(&self) -> &[PluginCapabilitySummary] {
|
||||
&self.capability_summaries
|
||||
}
|
||||
|
||||
pub fn plugins(&self) -> &[LoadedPlugin<M>] {
|
||||
&self.plugins
|
||||
}
|
||||
}
|
||||
|
||||
/// Implemented by [`PluginLoadOutcome`] so callers (e.g. skills) can depend on `codex-plugin`
|
||||
/// without naming the MCP config type parameter.
|
||||
pub trait EffectiveSkillRoots {
|
||||
fn effective_skill_roots(&self) -> Vec<PathBuf>;
|
||||
}
|
||||
|
||||
impl<M: Clone> EffectiveSkillRoots for PluginLoadOutcome<M> {
|
||||
fn effective_skill_roots(&self) -> Vec<PathBuf> {
|
||||
PluginLoadOutcome::effective_skill_roots(self)
|
||||
}
|
||||
}
|
||||
64
codex-rs/plugin/src/plugin_id.rs
Normal file
64
codex-rs/plugin/src/plugin_id.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
//! Stable plugin identifier parsing and validation shared with the plugin cache.
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PluginIdError {
|
||||
#[error("{0}")]
|
||||
Invalid(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginId {
|
||||
pub plugin_name: String,
|
||||
pub marketplace_name: String,
|
||||
}
|
||||
|
||||
impl PluginId {
|
||||
pub fn new(plugin_name: String, marketplace_name: String) -> Result<Self, PluginIdError> {
|
||||
validate_plugin_segment(&plugin_name, "plugin name").map_err(PluginIdError::Invalid)?;
|
||||
validate_plugin_segment(&marketplace_name, "marketplace name")
|
||||
.map_err(PluginIdError::Invalid)?;
|
||||
Ok(Self {
|
||||
plugin_name,
|
||||
marketplace_name,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn parse(plugin_key: &str) -> Result<Self, PluginIdError> {
|
||||
let Some((plugin_name, marketplace_name)) = plugin_key.rsplit_once('@') else {
|
||||
return Err(PluginIdError::Invalid(format!(
|
||||
"invalid plugin key `{plugin_key}`; expected <plugin>@<marketplace>"
|
||||
)));
|
||||
};
|
||||
if plugin_name.is_empty() || marketplace_name.is_empty() {
|
||||
return Err(PluginIdError::Invalid(format!(
|
||||
"invalid plugin key `{plugin_key}`; expected <plugin>@<marketplace>"
|
||||
)));
|
||||
}
|
||||
|
||||
Self::new(plugin_name.to_string(), marketplace_name.to_string()).map_err(|err| match err {
|
||||
PluginIdError::Invalid(message) => {
|
||||
PluginIdError::Invalid(format!("{message} in `{plugin_key}`"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn as_key(&self) -> String {
|
||||
format!("{}@{}", self.plugin_name, self.marketplace_name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates a single path segment used in plugin IDs and cache layout.
|
||||
pub fn validate_plugin_segment(segment: &str, kind: &str) -> Result<(), String> {
|
||||
if segment.is_empty() {
|
||||
return Err(format!("invalid {kind}: must not be empty"));
|
||||
}
|
||||
if !segment
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
|
||||
{
|
||||
return Err(format!(
|
||||
"invalid {kind}: only ASCII letters, digits, `_`, and `-` are allowed"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
70
codex-rs/plugin/src/plugin_namespace.rs
Normal file
70
codex-rs/plugin/src/plugin_namespace.rs
Normal file
@@ -0,0 +1,70 @@
|
||||
//! Resolve plugin namespace from skill file paths by walking ancestors for `plugin.json`.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// Relative path from a plugin root to its manifest file.
|
||||
pub const PLUGIN_MANIFEST_PATH: &str = ".codex-plugin/plugin.json";
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RawPluginManifestName {
|
||||
#[serde(default)]
|
||||
name: String,
|
||||
}
|
||||
|
||||
fn plugin_manifest_name(plugin_root: &Path) -> Option<String> {
|
||||
let manifest_path = plugin_root.join(PLUGIN_MANIFEST_PATH);
|
||||
if !manifest_path.is_file() {
|
||||
return None;
|
||||
}
|
||||
let contents = fs::read_to_string(&manifest_path).ok()?;
|
||||
let RawPluginManifestName { name: raw_name } = serde_json::from_str(&contents).ok()?;
|
||||
Some(
|
||||
plugin_root
|
||||
.file_name()
|
||||
.and_then(|entry| entry.to_str())
|
||||
.filter(|_| raw_name.trim().is_empty())
|
||||
.unwrap_or(raw_name.as_str())
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns the plugin manifest `name` for the nearest ancestor of `path` that contains a valid
|
||||
/// plugin manifest (same `name` rules as full manifest loading in codex-core).
|
||||
pub fn plugin_namespace_for_skill_path(path: &Path) -> Option<String> {
|
||||
for ancestor in path.ancestors() {
|
||||
if let Some(name) = plugin_manifest_name(ancestor) {
|
||||
return Some(name);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::plugin_namespace_for_skill_path;
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn uses_manifest_name() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let plugin_root = tmp.path().join("plugins/sample");
|
||||
let skill_path = plugin_root.join("skills/search/SKILL.md");
|
||||
|
||||
fs::create_dir_all(skill_path.parent().expect("parent")).expect("mkdir");
|
||||
fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("mkdir manifest");
|
||||
fs::write(
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
r#"{"name":"sample"}"#,
|
||||
)
|
||||
.expect("write manifest");
|
||||
fs::write(&skill_path, "---\ndescription: search\n---\n").expect("write skill");
|
||||
|
||||
assert_eq!(
|
||||
plugin_namespace_for_skill_path(&skill_path),
|
||||
Some("sample".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
6
codex-rs/utils/plugins/BUILD.bazel
Normal file
6
codex-rs/utils/plugins/BUILD.bazel
Normal file
@@ -0,0 +1,6 @@
|
||||
load("//:defs.bzl", "codex_rust_crate")
|
||||
|
||||
codex_rust_crate(
|
||||
name = "plugins",
|
||||
crate_name = "codex_utils_plugins",
|
||||
)
|
||||
21
codex-rs/utils/plugins/Cargo.toml
Normal file
21
codex-rs/utils/plugins/Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
||||
|
||||
[package]
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
name = "codex-utils-plugins"
|
||||
version.workspace = true
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
name = "codex_utils_plugins"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
7
codex-rs/utils/plugins/src/lib.rs
Normal file
7
codex-rs/utils/plugins/src/lib.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
//! Plugin path resolution and plaintext mention sigils shared across Codex crates.
|
||||
|
||||
pub mod mention_syntax;
|
||||
pub mod plugin_namespace;
|
||||
|
||||
pub use plugin_namespace::PLUGIN_MANIFEST_PATH;
|
||||
pub use plugin_namespace::plugin_namespace_for_skill_path;
|
||||
7
codex-rs/utils/plugins/src/mention_syntax.rs
Normal file
7
codex-rs/utils/plugins/src/mention_syntax.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
//! Sigils for tool/plugin mentions in plaintext (shared across Codex crates).
|
||||
|
||||
/// Default plaintext sigil for tools.
|
||||
pub const TOOL_MENTION_SIGIL: char = '$';
|
||||
|
||||
/// Plugins use `@` in linked plaintext outside TUI.
|
||||
pub const PLUGIN_TEXT_MENTION_SIGIL: char = '@';
|
||||
70
codex-rs/utils/plugins/src/plugin_namespace.rs
Normal file
70
codex-rs/utils/plugins/src/plugin_namespace.rs
Normal file
@@ -0,0 +1,70 @@
|
||||
//! Resolve plugin namespace from skill file paths by walking ancestors for `plugin.json`.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// Relative path from a plugin root to its manifest file.
|
||||
pub const PLUGIN_MANIFEST_PATH: &str = ".codex-plugin/plugin.json";
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RawPluginManifestName {
|
||||
#[serde(default)]
|
||||
name: String,
|
||||
}
|
||||
|
||||
fn plugin_manifest_name(plugin_root: &Path) -> Option<String> {
|
||||
let manifest_path = plugin_root.join(PLUGIN_MANIFEST_PATH);
|
||||
if !manifest_path.is_file() {
|
||||
return None;
|
||||
}
|
||||
let contents = fs::read_to_string(&manifest_path).ok()?;
|
||||
let RawPluginManifestName { name: raw_name } = serde_json::from_str(&contents).ok()?;
|
||||
Some(
|
||||
plugin_root
|
||||
.file_name()
|
||||
.and_then(|entry| entry.to_str())
|
||||
.filter(|_| raw_name.trim().is_empty())
|
||||
.unwrap_or(raw_name.as_str())
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns the plugin manifest `name` for the nearest ancestor of `path` that contains a valid
|
||||
/// plugin manifest (same `name` rules as full manifest loading in codex-core).
|
||||
pub fn plugin_namespace_for_skill_path(path: &Path) -> Option<String> {
|
||||
for ancestor in path.ancestors() {
|
||||
if let Some(name) = plugin_manifest_name(ancestor) {
|
||||
return Some(name);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::plugin_namespace_for_skill_path;
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn uses_manifest_name() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let plugin_root = tmp.path().join("plugins/sample");
|
||||
let skill_path = plugin_root.join("skills/search/SKILL.md");
|
||||
|
||||
fs::create_dir_all(skill_path.parent().expect("parent")).expect("mkdir");
|
||||
fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("mkdir manifest");
|
||||
fs::write(
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
r#"{"name":"sample"}"#,
|
||||
)
|
||||
.expect("write manifest");
|
||||
fs::write(&skill_path, "---\ndescription: search\n---\n").expect("write skill");
|
||||
|
||||
assert_eq!(
|
||||
plugin_namespace_for_skill_path(&skill_path),
|
||||
Some("sample".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user