mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Add remote skill scope/product_surface/enabled params and cleanup (#11801)
skills/remote/list: params=hazelnutScope, productSurface, enabled;
returns=data: { id, name, description }[]
skills/remote/export: params=hazelnutId; returns={ id, path }
This commit is contained in:
+52
-29
@@ -3174,22 +3174,24 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiv
|
||||
Op::ListSkills { cwds, force_reload } => {
|
||||
handlers::list_skills(&sess, sub.id.clone(), cwds, force_reload).await;
|
||||
}
|
||||
Op::ListRemoteSkills => {
|
||||
handlers::list_remote_skills(&sess, &config, sub.id.clone()).await;
|
||||
}
|
||||
Op::DownloadRemoteSkill {
|
||||
hazelnut_id,
|
||||
is_preload,
|
||||
Op::ListRemoteSkills {
|
||||
hazelnut_scope,
|
||||
product_surface,
|
||||
enabled,
|
||||
} => {
|
||||
handlers::download_remote_skill(
|
||||
handlers::list_remote_skills(
|
||||
&sess,
|
||||
&config,
|
||||
sub.id.clone(),
|
||||
hazelnut_id,
|
||||
is_preload,
|
||||
hazelnut_scope,
|
||||
product_surface,
|
||||
enabled,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Op::DownloadRemoteSkill { hazelnut_id } => {
|
||||
handlers::export_remote_skill(&sess, &config, sub.id.clone(), hazelnut_id).await;
|
||||
}
|
||||
Op::Undo => {
|
||||
handlers::undo(&sess, sub.id.clone()).await;
|
||||
}
|
||||
@@ -3269,6 +3271,8 @@ mod handlers {
|
||||
use codex_protocol::protocol::McpServerRefreshConfig;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::RemoteSkillDownloadedEvent;
|
||||
use codex_protocol::protocol::RemoteSkillHazelnutScope;
|
||||
use codex_protocol::protocol::RemoteSkillProductSurface;
|
||||
use codex_protocol::protocol::RemoteSkillSummary;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use codex_protocol::protocol::ReviewRequest;
|
||||
@@ -3665,19 +3669,33 @@ mod handlers {
|
||||
sess.send_event_raw(event).await;
|
||||
}
|
||||
|
||||
pub async fn list_remote_skills(sess: &Session, config: &Arc<Config>, sub_id: String) {
|
||||
let response = crate::skills::remote::list_remote_skills(config)
|
||||
.await
|
||||
.map(|skills| {
|
||||
skills
|
||||
.into_iter()
|
||||
.map(|skill| RemoteSkillSummary {
|
||||
id: skill.id,
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
pub async fn list_remote_skills(
|
||||
sess: &Session,
|
||||
config: &Arc<Config>,
|
||||
sub_id: String,
|
||||
hazelnut_scope: RemoteSkillHazelnutScope,
|
||||
product_surface: RemoteSkillProductSurface,
|
||||
enabled: Option<bool>,
|
||||
) {
|
||||
let auth = sess.services.auth_manager.auth().await;
|
||||
let response = crate::skills::remote::list_remote_skills(
|
||||
config,
|
||||
auth.as_ref(),
|
||||
hazelnut_scope,
|
||||
product_surface,
|
||||
enabled,
|
||||
)
|
||||
.await
|
||||
.map(|skills| {
|
||||
skills
|
||||
.into_iter()
|
||||
.map(|skill| RemoteSkillSummary {
|
||||
id: skill.id,
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
|
||||
match response {
|
||||
Ok(skills) => {
|
||||
@@ -3702,22 +3720,27 @@ mod handlers {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn download_remote_skill(
|
||||
pub async fn export_remote_skill(
|
||||
sess: &Session,
|
||||
config: &Arc<Config>,
|
||||
sub_id: String,
|
||||
hazelnut_id: String,
|
||||
is_preload: bool,
|
||||
) {
|
||||
match crate::skills::remote::download_remote_skill(config, hazelnut_id.as_str(), is_preload)
|
||||
.await
|
||||
let auth = sess.services.auth_manager.auth().await;
|
||||
match crate::skills::remote::export_remote_skill(
|
||||
config,
|
||||
auth.as_ref(),
|
||||
hazelnut_id.as_str(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
let id = result.id;
|
||||
let event = Event {
|
||||
id: sub_id,
|
||||
msg: EventMsg::RemoteSkillDownloaded(RemoteSkillDownloadedEvent {
|
||||
id: result.id,
|
||||
name: result.name,
|
||||
id: id.clone(),
|
||||
name: id,
|
||||
path: result.path,
|
||||
}),
|
||||
};
|
||||
@@ -3727,7 +3750,7 @@ mod handlers {
|
||||
let event = Event {
|
||||
id: sub_id,
|
||||
msg: EventMsg::Error(ErrorEvent {
|
||||
message: format!("failed to download remote skill {hazelnut_id}: {err}"),
|
||||
message: format!("failed to export remote skill {hazelnut_id}: {err}"),
|
||||
codex_error_info: Some(CodexErrorInfo::Other),
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -1,18 +1,49 @@
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::path::Component;
|
||||
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_protocol::protocol::RemoteSkillHazelnutScope;
|
||||
use codex_protocol::protocol::RemoteSkillProductSurface;
|
||||
|
||||
const REMOTE_SKILLS_API_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
fn as_query_hazelnut_scope(scope: RemoteSkillHazelnutScope) -> Option<&'static str> {
|
||||
match scope {
|
||||
RemoteSkillHazelnutScope::WorkspaceShared => Some("workspace-shared"),
|
||||
RemoteSkillHazelnutScope::AllShared => Some("all-shared"),
|
||||
RemoteSkillHazelnutScope::Personal => Some("personal"),
|
||||
RemoteSkillHazelnutScope::Example => Some("example"),
|
||||
}
|
||||
}
|
||||
|
||||
fn as_query_product_surface(product_surface: RemoteSkillProductSurface) -> &'static str {
|
||||
match product_surface {
|
||||
RemoteSkillProductSurface::Chatgpt => "chatgpt",
|
||||
RemoteSkillProductSurface::Codex => "codex",
|
||||
RemoteSkillProductSurface::Api => "api",
|
||||
RemoteSkillProductSurface::Atlas => "atlas",
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_chatgpt_auth(auth: Option<&CodexAuth>) -> Result<&CodexAuth> {
|
||||
let Some(auth) = auth else {
|
||||
anyhow::bail!("chatgpt authentication required for hazelnut scopes");
|
||||
};
|
||||
if !auth.is_chatgpt_auth() {
|
||||
anyhow::bail!(
|
||||
"chatgpt authentication required for hazelnut scopes; api key auth is not supported"
|
||||
);
|
||||
}
|
||||
Ok(auth)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RemoteSkillSummary {
|
||||
pub id: String,
|
||||
@@ -20,27 +51,12 @@ pub struct RemoteSkillSummary {
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RemoteSkillDownload {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub base_sediment_id: String,
|
||||
pub files: HashMap<String, RemoteSkillFileRange>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RemoteSkillDownloadResult {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct RemoteSkillFileRange {
|
||||
pub start: u64,
|
||||
pub length: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RemoteSkillsResponse {
|
||||
hazelnuts: Vec<RemoteSkill>,
|
||||
@@ -53,36 +69,40 @@ struct RemoteSkill {
|
||||
description: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RemoteSkillsDownloadResponse {
|
||||
hazelnuts: Vec<RemoteSkillDownloadPayload>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RemoteSkillDownloadPayload {
|
||||
id: String,
|
||||
name: String,
|
||||
#[serde(rename = "base_sediment_id")]
|
||||
base_sediment_id: String,
|
||||
files: HashMap<String, RemoteSkillFileRangePayload>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RemoteSkillFileRangePayload {
|
||||
start: u64,
|
||||
length: u64,
|
||||
}
|
||||
|
||||
pub async fn list_remote_skills(config: &Config) -> Result<Vec<RemoteSkillSummary>> {
|
||||
pub async fn list_remote_skills(
|
||||
config: &Config,
|
||||
auth: Option<&CodexAuth>,
|
||||
hazelnut_scope: RemoteSkillHazelnutScope,
|
||||
product_surface: RemoteSkillProductSurface,
|
||||
enabled: Option<bool>,
|
||||
) -> Result<Vec<RemoteSkillSummary>> {
|
||||
let base_url = config.chatgpt_base_url.trim_end_matches('/');
|
||||
let base_url = base_url.strip_suffix("/backend-api").unwrap_or(base_url);
|
||||
let url = format!("{base_url}/public-api/hazelnuts/");
|
||||
let auth = ensure_chatgpt_auth(auth)?;
|
||||
|
||||
let url = format!("{base_url}/hazelnuts");
|
||||
let product_surface = as_query_product_surface(product_surface);
|
||||
let mut query_params = vec![("product_surface", product_surface)];
|
||||
if let Some(scope) = as_query_hazelnut_scope(hazelnut_scope) {
|
||||
query_params.push(("scope", scope));
|
||||
}
|
||||
if let Some(enabled) = enabled {
|
||||
let enabled = if enabled { "true" } else { "false" };
|
||||
query_params.push(("enabled", enabled));
|
||||
}
|
||||
|
||||
let client = build_reqwest_client();
|
||||
let response = client
|
||||
let mut request = client
|
||||
.get(&url)
|
||||
.timeout(REMOTE_SKILLS_API_TIMEOUT)
|
||||
.query(&[("product_surface", "codex")])
|
||||
.query(&query_params);
|
||||
let token = auth
|
||||
.get_token()
|
||||
.context("Failed to read auth token for remote skills")?;
|
||||
request = request.bearer_auth(token);
|
||||
if let Some(account_id) = auth.get_account_id() {
|
||||
request = request.header("chatgpt-account-id", account_id);
|
||||
}
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("Failed to send request to {url}"))?;
|
||||
@@ -107,20 +127,27 @@ pub async fn list_remote_skills(config: &Config) -> Result<Vec<RemoteSkillSummar
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn download_remote_skill(
|
||||
pub async fn export_remote_skill(
|
||||
config: &Config,
|
||||
auth: Option<&CodexAuth>,
|
||||
hazelnut_id: &str,
|
||||
is_preload: bool,
|
||||
) -> Result<RemoteSkillDownloadResult> {
|
||||
let hazelnut = fetch_remote_skill(config, hazelnut_id).await?;
|
||||
let auth = ensure_chatgpt_auth(auth)?;
|
||||
|
||||
let client = build_reqwest_client();
|
||||
let base_url = config.chatgpt_base_url.trim_end_matches('/');
|
||||
let base_url = base_url.strip_suffix("/backend-api").unwrap_or(base_url);
|
||||
let url = format!("{base_url}/public-api/hazelnuts/{hazelnut_id}/export");
|
||||
let response = client
|
||||
.get(&url)
|
||||
.timeout(REMOTE_SKILLS_API_TIMEOUT)
|
||||
let url = format!("{base_url}/hazelnuts/{hazelnut_id}/export");
|
||||
let mut request = client.get(&url).timeout(REMOTE_SKILLS_API_TIMEOUT);
|
||||
|
||||
let token = auth
|
||||
.get_token()
|
||||
.context("Failed to read auth token for remote skills")?;
|
||||
request = request.bearer_auth(token);
|
||||
if let Some(account_id) = auth.get_account_id() {
|
||||
request = request.header("chatgpt-account-id", account_id);
|
||||
}
|
||||
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("Failed to send download request to {url}"))?;
|
||||
@@ -136,48 +163,22 @@ pub async fn download_remote_skill(
|
||||
anyhow::bail!("Downloaded remote skill payload is not a zip archive");
|
||||
}
|
||||
|
||||
let preferred_dir_name = if hazelnut.name.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(hazelnut.name.as_str())
|
||||
};
|
||||
let dir_name = preferred_dir_name
|
||||
.and_then(validate_dir_name_format)
|
||||
.or_else(|| validate_dir_name_format(&hazelnut.id))
|
||||
.ok_or_else(|| anyhow::anyhow!("Remote skill has no valid directory name"))?;
|
||||
let output_root = if is_preload {
|
||||
config
|
||||
.codex_home
|
||||
.join("vendor_imports")
|
||||
.join("skills")
|
||||
.join("skills")
|
||||
.join(".curated")
|
||||
} else {
|
||||
config.codex_home.join("skills").join("downloaded")
|
||||
};
|
||||
let output_dir = output_root.join(dir_name);
|
||||
let output_dir = config.codex_home.join("skills").join(hazelnut_id);
|
||||
tokio::fs::create_dir_all(&output_dir)
|
||||
.await
|
||||
.context("Failed to create downloaded skills directory")?;
|
||||
|
||||
let allowed_files = hazelnut.files.keys().cloned().collect::<HashSet<String>>();
|
||||
let zip_bytes = body.to_vec();
|
||||
let output_dir_clone = output_dir.clone();
|
||||
let prefix_candidates = vec![hazelnut.name.clone(), hazelnut.id.clone()];
|
||||
let prefix_candidates = vec![hazelnut_id.to_string()];
|
||||
tokio::task::spawn_blocking(move || {
|
||||
extract_zip_to_dir(
|
||||
zip_bytes,
|
||||
&output_dir_clone,
|
||||
&allowed_files,
|
||||
&prefix_candidates,
|
||||
)
|
||||
extract_zip_to_dir(zip_bytes, &output_dir_clone, &prefix_candidates)
|
||||
})
|
||||
.await
|
||||
.context("Zip extraction task failed")??;
|
||||
|
||||
Ok(RemoteSkillDownloadResult {
|
||||
id: hazelnut.id,
|
||||
name: hazelnut.name,
|
||||
id: hazelnut_id.to_string(),
|
||||
path: output_dir,
|
||||
})
|
||||
}
|
||||
@@ -195,17 +196,6 @@ fn safe_join(base: &Path, name: &str) -> Result<PathBuf> {
|
||||
Ok(base.join(path))
|
||||
}
|
||||
|
||||
fn validate_dir_name_format(name: &str) -> Option<String> {
|
||||
let mut components = Path::new(name).components();
|
||||
match (components.next(), components.next()) {
|
||||
(Some(Component::Normal(component)), None) => {
|
||||
let value = component.to_string_lossy().to_string();
|
||||
if value.is_empty() { None } else { Some(value) }
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_zip_payload(bytes: &[u8]) -> bool {
|
||||
bytes.starts_with(b"PK\x03\x04")
|
||||
|| bytes.starts_with(b"PK\x05\x06")
|
||||
@@ -215,7 +205,6 @@ fn is_zip_payload(bytes: &[u8]) -> bool {
|
||||
fn extract_zip_to_dir(
|
||||
bytes: Vec<u8>,
|
||||
output_dir: &Path,
|
||||
allowed_files: &HashSet<String>,
|
||||
prefix_candidates: &[String],
|
||||
) -> Result<()> {
|
||||
let cursor = std::io::Cursor::new(bytes);
|
||||
@@ -230,9 +219,6 @@ fn extract_zip_to_dir(
|
||||
let Some(normalized) = normalized else {
|
||||
continue;
|
||||
};
|
||||
if !allowed_files.contains(&normalized) {
|
||||
continue;
|
||||
}
|
||||
let file_path = safe_join(output_dir, &normalized)?;
|
||||
if let Some(parent) = file_path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
@@ -264,51 +250,3 @@ fn normalize_zip_name(name: &str, prefix_candidates: &[String]) -> Option<String
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_remote_skill(config: &Config, hazelnut_id: &str) -> Result<RemoteSkillDownload> {
|
||||
let base_url = config.chatgpt_base_url.trim_end_matches('/');
|
||||
let base_url = base_url.strip_suffix("/backend-api").unwrap_or(base_url);
|
||||
let url = format!("{base_url}/public-api/hazelnuts/");
|
||||
|
||||
let client = build_reqwest_client();
|
||||
let response = client
|
||||
.get(&url)
|
||||
.timeout(REMOTE_SKILLS_API_TIMEOUT)
|
||||
.query(&[("product_surface", "codex")])
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("Failed to send request to {url}"))?;
|
||||
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
if !status.is_success() {
|
||||
anyhow::bail!("Request failed with status {status} from {url}: {body}");
|
||||
}
|
||||
|
||||
let parsed: RemoteSkillsDownloadResponse =
|
||||
serde_json::from_str(&body).context("Failed to parse skills response")?;
|
||||
let hazelnut = parsed
|
||||
.hazelnuts
|
||||
.into_iter()
|
||||
.find(|hazelnut| hazelnut.id == hazelnut_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Remote skill {hazelnut_id} not found"))?;
|
||||
|
||||
Ok(RemoteSkillDownload {
|
||||
id: hazelnut.id,
|
||||
name: hazelnut.name,
|
||||
base_sediment_id: hazelnut.base_sediment_id,
|
||||
files: hazelnut
|
||||
.files
|
||||
.into_iter()
|
||||
.map(|(name, range)| {
|
||||
(
|
||||
name,
|
||||
RemoteSkillFileRange {
|
||||
start: range.start,
|
||||
length: range.length,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user