Compare commits

..
1 Commits
Author SHA1 Message Date
yyyyyynnn 9cbd7bf351 Add Skills and MCP management 2026-07-06 01:11:13 +08:00
9 changed files with 1228 additions and 29 deletions
+9
View File
@@ -2,6 +2,15 @@
All notable changes to Codex-X will be documented here.
## [v0.2.19] - 2026-07-06
- 新增【技能和 MCP】页面:展示 Codex 当前已安装 Skills 与 MCP,支持导入已有、从 ZIP 安装 Skill、启用/禁用 Skill、启用/禁用 MCP。
- MCP 管理会读写 `~/.codex/config.toml` 的 `[mcp_servers]`,禁用后保留到 Codex-X SQLite,后续可一键重新启用。
- Skills 管理会扫描 `~/.codex/skills`,并可从 `~/.agents/skills`、`~/.cc-switch/skills` 导入到 Codex;禁用后移动到 Codex-X 禁用目录,避免直接删除。
- 优化多个潜在卡顿点:指令提示词 GitHub 检查改为延迟后台执行,远程拉取、会话扫描/同步、Skills/MCP 扫描均放入后台 blocking worker。
- 修复外部自定义提示词切换到内置提示词后重复出现的问题,并自动清理同名 `external-*` 重复项。
- 增加页面切换过渡、启动页动态光效和首次启动向导退出动画,降低页面突然跳转感。
## [v0.2.18] - 2026-07-05
- 指令提示词内置模板支持从 GitHub `examples/` 实时检查更新,并缓存到本地;启用内置模板时优先使用 GitHub 最新版本,离线时自动回退本地缓存或打包内置版本。
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "codex-x",
"version": "0.2.18",
"version": "0.2.19",
"private": true,
"description": "Codex Switch & Instruct desktop manager",
"type": "module",
+52 -1
View File
@@ -59,6 +59,15 @@ version = "1.0.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "atk"
version = "0.18.2"
@@ -337,18 +346,20 @@ dependencies = [
[[package]]
name = "codex-x"
version = "0.2.18"
version = "0.2.19"
dependencies = [
"chrono",
"dirs 5.0.1",
"rusqlite",
"serde",
"serde_json",
"sha2",
"tauri",
"tauri-build",
"thiserror 2.0.18",
"toml_edit 0.22.27",
"ureq",
"zip",
]
[[package]]
@@ -547,6 +558,17 @@ dependencies = [
"serde_core",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "derive_more"
version = "2.1.1"
@@ -4712,8 +4734,37 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "zip"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
dependencies = [
"arbitrary",
"crc32fast",
"crossbeam-utils",
"displaydoc",
"flate2",
"indexmap 2.14.0",
"memchr",
"thiserror 2.0.18",
"zopfli",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
+3 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "codex-x"
version = "0.2.18"
version = "0.2.19"
description = "Codex Switch & Instruct desktop manager"
authors = ["yynxxxxx"]
license = "MIT"
@@ -24,3 +24,5 @@ dirs = "5.0"
thiserror = "2.0"
rusqlite = { version = "0.31", features = ["bundled"] }
ureq = { version = "2.12", features = ["tls"] }
sha2 = "0.10"
zip = { version = "2.2", default-features = false, features = ["deflate"] }
+641 -14
View File
@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::io::Write;
use std::io::{Cursor, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use thiserror::Error;
@@ -272,6 +272,54 @@ struct SessionSyncResult {
backup_dir: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct ManagedMcpServer {
id: String,
name: String,
transport: String,
enabled: bool,
source: String,
summary: String,
command: Option<String>,
url: Option<String>,
config_json: Value,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct ManagedSkill {
id: String,
name: String,
description: Option<String>,
directory: String,
enabled: bool,
source: String,
path: String,
content_hash: Option<String>,
update_status: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct SkillsMcpState {
codex_dir: String,
codex_skills_dir: String,
disabled_skills_dir: String,
skills: Vec<ManagedSkill>,
mcp_servers: Vec<ManagedMcpServer>,
warnings: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct SkillsMcpActionResult {
imported_skills: usize,
imported_mcp: usize,
message: String,
state: SkillsMcpState,
}
#[derive(Debug, Default)]
struct RolloutScan {
rollout_files: usize,
@@ -367,9 +415,37 @@ fn open_db() -> Result<Connection> {
content TEXT NOT NULL,
checked_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS managed_mcp_servers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
server_config TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS managed_skills (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
directory TEXT NOT NULL,
source_path TEXT,
content_hash TEXT,
enabled INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL
);",
)
.map_err(|e| CodexxError::Database(e.to_string()))?;
conn.execute(
"DELETE FROM prompts
WHERE id LIKE 'external-%'
AND EXISTS (
SELECT 1 FROM prompts AS kept
WHERE lower(kept.filename) = lower(prompts.filename)
AND kept.id NOT LIKE 'external-%'
)",
[],
)
.map_err(|e| CodexxError::Database(e.to_string()))?;
Ok(conn)
}
@@ -485,7 +561,20 @@ fn list_saved_prompts_inner() -> Result<Vec<SavedPrompt>> {
.map_err(|e| CodexxError::Database(e.to_string()))?;
let mut prompts = Vec::new();
for row in rows {
prompts.push(row.map_err(|e| CodexxError::Database(e.to_string()))?);
let prompt = row.map_err(|e| CodexxError::Database(e.to_string()))?;
let key = prompt.filename.to_ascii_lowercase();
if let Some(index) = prompts
.iter()
.position(|existing: &SavedPrompt| existing.filename.to_ascii_lowercase() == key)
{
let existing_is_external = prompts[index].id.starts_with("external-");
let prompt_is_external = prompt.id.starts_with("external-");
if existing_is_external && !prompt_is_external {
prompts[index] = prompt;
}
continue;
}
prompts.push(prompt);
}
Ok(prompts)
}
@@ -530,6 +619,30 @@ fn delete_prompt_inner(id: &str) -> Result<()> {
Ok(())
}
fn find_saved_prompt_by_filename(filename: &str) -> Result<Option<SavedPrompt>> {
let conn = open_db()?;
let mut stmt = conn
.prepare(
"SELECT id, title, filename, content FROM prompts
WHERE lower(filename) = lower(?1)
ORDER BY CASE WHEN id LIKE 'external-%' THEN 1 ELSE 0 END, updated_at DESC, created_at DESC
LIMIT 1",
)
.map_err(|e| CodexxError::Database(e.to_string()))?;
match stmt.query_row([filename], |row| {
Ok(SavedPrompt {
id: row.get(0)?,
title: row.get(1)?,
filename: row.get(2)?,
content: row.get(3)?,
})
}) {
Ok(prompt) => Ok(Some(prompt)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(CodexxError::Database(e.to_string())),
}
}
fn builtin_prompt_meta(
template_id: &str,
) -> Result<(&'static str, &'static str, &'static str, &'static str)> {
@@ -593,7 +706,7 @@ fn save_builtin_prompt_cache(
fn fetch_remote_prompt(source_url: &str) -> Result<String> {
let agent = ureq::AgentBuilder::new()
.timeout(std::time::Duration::from_secs(12))
.timeout(std::time::Duration::from_secs(5))
.build();
let response = agent
.get(source_url)
@@ -753,17 +866,475 @@ fn remember_current_instruction_prompt(codex_dir: &Path) -> Result<Option<SavedP
.file_stem()
.and_then(|v| v.to_str())
.unwrap_or("external-prompt");
let id = format!("external-{}", sanitize_id(stem));
let title = format!("外部提示词 · {stem}");
let normalized_filename = normalize_prompt_filename(&file_name, "external-prompt");
let existing = find_saved_prompt_by_filename(&normalized_filename)?;
let (id, title) = existing
.map(|prompt| (prompt.id, prompt.title))
.unwrap_or_else(|| {
(
format!("external-{}", sanitize_id(stem)),
format!("外部提示词 · {stem}"),
)
});
save_prompt_inner(SavedPrompt {
id,
title,
filename: normalize_prompt_filename(&file_name, "external-prompt"),
filename: normalized_filename,
content,
})
.map(Some)
}
fn codex_skills_dir(codex_dir: &Path) -> PathBuf {
codex_dir.join("skills")
}
fn disabled_skills_dir() -> Result<PathBuf> {
Ok(app_home()?.join("disabled-skills"))
}
fn sanitize_dir_name(input: &str, fallback: &str) -> String {
let raw = input.trim().trim_matches('/').trim_matches('\\');
let base = if raw.is_empty() { fallback } else { raw };
let mut out = String::new();
let mut last_dash = false;
for ch in base.to_ascii_lowercase().chars() {
if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
out.push(ch);
last_dash = false;
} else if !last_dash {
out.push('-');
last_dash = true;
}
}
let out = out.trim_matches('-');
if out.is_empty() { fallback.to_string() } else { out.to_string() }
}
fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
fs::create_dir_all(dst).map_err(|e| io_err(dst, e))?;
for entry in fs::read_dir(src).map_err(|e| io_err(src, e))? {
let entry = entry.map_err(|e| io_err(src, e))?;
let path = entry.path();
let file_name = entry.file_name();
let target = dst.join(file_name);
let meta = fs::symlink_metadata(&path).map_err(|e| io_err(&path, e))?;
if meta.file_type().is_symlink() {
continue;
}
if meta.is_dir() {
copy_dir_recursive(&path, &target)?;
} else if meta.is_file() {
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).map_err(|e| io_err(parent, e))?;
}
fs::copy(&path, &target).map_err(|e| io_err(&target, e))?;
}
}
Ok(())
}
fn compute_dir_hash(dir: &Path) -> Result<String> {
use sha2::{Digest, Sha256};
fn collect(base: &Path, current: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
for entry in fs::read_dir(current).map_err(|e| io_err(current, e))? {
let entry = entry.map_err(|e| io_err(current, e))?;
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') { continue; }
let meta = fs::symlink_metadata(&path).map_err(|e| io_err(&path, e))?;
if meta.file_type().is_symlink() { continue; }
if meta.is_dir() { collect(base, &path, out)?; }
else if meta.is_file() { out.push(path); }
}
let _ = base;
Ok(())
}
let mut files = Vec::new();
collect(dir, dir, &mut files)?;
files.sort();
let mut hasher = Sha256::new();
for path in files {
let rel = path.strip_prefix(dir).unwrap_or(&path).to_string_lossy().replace('\\', "/");
hasher.update(rel.as_bytes());
hasher.update(b"\0");
let bytes = fs::read(&path).map_err(|e| io_err(&path, e))?;
hasher.update(&bytes);
hasher.update(b"\0");
}
Ok(format!("{:x}", hasher.finalize()))
}
fn read_skill_metadata(skill_dir: &Path, fallback: &str) -> (String, Option<String>) {
let skill_md = skill_dir.join("SKILL.md");
let text = read_to_string_if_exists(&skill_md).unwrap_or_default();
let mut title = None;
let mut desc = None;
for line in text.lines() {
let trimmed = line.trim();
if trimmed.is_empty() { continue; }
if title.is_none() && trimmed.starts_with('#') {
title = Some(trimmed.trim_start_matches('#').trim().to_string());
continue;
}
if desc.is_none() && !trimmed.starts_with('#') && !trimmed.starts_with("---") {
desc = Some(trimmed.trim_matches('`').to_string());
break;
}
}
(title.filter(|s| !s.is_empty()).unwrap_or_else(|| fallback.to_string()), desc.filter(|s| !s.is_empty()))
}
fn save_managed_skill(skill: &ManagedSkill) -> Result<()> {
let conn = open_db()?;
conn.execute(
"INSERT INTO managed_skills (id, name, description, directory, source_path, content_hash, enabled, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
description = excluded.description,
directory = excluded.directory,
source_path = excluded.source_path,
content_hash = excluded.content_hash,
enabled = excluded.enabled,
updated_at = excluded.updated_at",
params![
skill.id,
skill.name,
skill.description,
skill.directory,
skill.path,
skill.content_hash,
skill.enabled,
now_rfc3339()
],
).map_err(|e| CodexxError::Database(e.to_string()))?;
Ok(())
}
fn scan_skill_dir(base: &Path, enabled: bool, source: &str, out: &mut Vec<ManagedSkill>, seen: &mut HashSet<String>) -> Result<()> {
if !base.exists() { return Ok(()); }
for entry in fs::read_dir(base).map_err(|e| io_err(base, e))? {
let entry = entry.map_err(|e| io_err(base, e))?;
let path = entry.path();
if !path.is_dir() || !path.join("SKILL.md").is_file() { continue; }
let directory = entry.file_name().to_string_lossy().to_string();
let id = sanitize_dir_name(&directory, "skill");
if seen.contains(&id) { continue; }
let (name, description) = read_skill_metadata(&path, &directory);
let hash = compute_dir_hash(&path).ok();
out.push(ManagedSkill {
id: id.clone(), name, description, directory, enabled,
source: source.to_string(), path: path.display().to_string(),
content_hash: hash, update_status: "未检查".to_string(),
});
seen.insert(id);
}
Ok(())
}
fn toml_value_to_json(value: &toml_edit::Value) -> Value {
if let Some(s) = value.as_str() { return json!(s); }
if let Some(i) = value.as_integer() { return json!(i); }
if let Some(f) = value.as_float() { return json!(f); }
if let Some(b) = value.as_bool() { return json!(b); }
if let Some(arr) = value.as_array() {
return Value::Array(arr.iter().map(toml_value_to_json).collect());
}
Value::String(value.to_string())
}
fn toml_item_to_json(item: &Item) -> Value {
if let Some(v) = item.as_value() { return toml_value_to_json(v); }
if let Some(tbl) = item.as_table() {
let mut obj = serde_json::Map::new();
for (k, v) in tbl.iter() {
obj.insert(k.to_string(), toml_item_to_json(v));
}
return Value::Object(obj);
}
Value::Null
}
fn json_to_toml_item(value_json: &Value) -> Item {
match value_json {
Value::String(s) => value(s.clone()),
Value::Bool(b) => value(*b),
Value::Number(n) => {
if let Some(i) = n.as_i64() { value(i) }
else if let Some(f) = n.as_f64() { value(f) }
else { value(n.to_string()) }
}
Value::Array(arr) => {
let mut toml_arr = toml_edit::Array::default();
for item in arr {
match item {
Value::String(s) => { toml_arr.push(s.as_str()); }
Value::Bool(b) => { toml_arr.push(*b); }
Value::Number(n) => {
if let Some(i) = n.as_i64() { toml_arr.push(i); }
else if let Some(f) = n.as_f64() { toml_arr.push(f); }
}
_ => {}
}
}
value(toml_arr)
}
Value::Object(obj) => {
let mut table = Table::new();
for (k, v) in obj {
table.insert(k, json_to_toml_item(v));
}
Item::Table(table)
}
Value::Null => value(""),
}
}
fn mcp_summary(config: &Value) -> (String, Option<String>, Option<String>, String) {
let transport = config.get("type").and_then(Value::as_str).unwrap_or_else(|| {
if config.get("url").is_some() { "http" } else { "stdio" }
}).to_string();
let command = config.get("command").and_then(Value::as_str).map(ToString::to_string);
let url = config.get("url").and_then(Value::as_str).map(ToString::to_string);
let args = config.get("args").and_then(Value::as_array).map(|arr| {
arr.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(" ")
}).unwrap_or_default();
let summary = if let Some(cmd) = &command {
if args.is_empty() { cmd.clone() } else { format!("{cmd} {args}") }
} else if let Some(url) = &url { url.clone() } else { transport.clone() };
(transport, command, url, summary)
}
fn save_managed_mcp(id: &str, name: &str, config: &Value, enabled: bool) -> Result<()> {
let conn = open_db()?;
conn.execute(
"INSERT INTO managed_mcp_servers (id, name, server_config, enabled, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
server_config = excluded.server_config,
enabled = excluded.enabled,
updated_at = excluded.updated_at",
params![id, name, serde_json::to_string(config).unwrap_or_default(), enabled, now_rfc3339()],
).map_err(|e| CodexxError::Database(e.to_string()))?;
Ok(())
}
fn db_managed_mcp() -> Result<Vec<(String, String, Value, bool)>> {
let conn = open_db()?;
let mut stmt = conn.prepare("SELECT id, name, server_config, enabled FROM managed_mcp_servers ORDER BY name ASC, id ASC")
.map_err(|e| CodexxError::Database(e.to_string()))?;
let rows = stmt.query_map([], |row| {
let text: String = row.get(2)?;
let config = serde_json::from_str(&text).unwrap_or(Value::Object(Default::default()));
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, config, row.get::<_, bool>(3)?))
}).map_err(|e| CodexxError::Database(e.to_string()))?;
let mut out = Vec::new();
for row in rows { out.push(row.map_err(|e| CodexxError::Database(e.to_string()))?); }
Ok(out)
}
fn list_mcp_from_config(codex_dir: &Path) -> Result<Vec<ManagedMcpServer>> {
let cfg = config_path(codex_dir);
let text = read_to_string_if_exists(&cfg)?;
if text.trim().is_empty() { return Ok(vec![]); }
let doc = parse_toml_document(&cfg, &text)?;
let Some(mcp_item) = doc.get("mcp_servers") else { return Ok(vec![]); };
let Some(mcp_tbl) = mcp_item.as_table() else { return Ok(vec![]); };
let mut out = Vec::new();
for (id, item) in mcp_tbl.iter() {
if !item.is_table() { continue; }
let config = toml_item_to_json(item);
let (transport, command, url, summary) = mcp_summary(&config);
out.push(ManagedMcpServer {
id: id.to_string(), name: id.to_string(), transport, enabled: true,
source: "config.toml".to_string(), summary, command, url, config_json: config,
});
}
Ok(out)
}
fn build_skills_mcp_state_inner(config_dir: Option<String>) -> Result<SkillsMcpState> {
let codex_dir = resolve_codex_dir(config_dir)?;
let skills_dir = codex_skills_dir(&codex_dir);
let disabled_dir = disabled_skills_dir()?;
let mut warnings = Vec::new();
let mut skills = Vec::new();
let mut seen = HashSet::new();
if let Err(e) = scan_skill_dir(&skills_dir, true, "Codex", &mut skills, &mut seen) { warnings.push(e.to_string()); }
if let Err(e) = scan_skill_dir(&disabled_dir, false, "Codex-X 已禁用", &mut skills, &mut seen) { warnings.push(e.to_string()); }
let mut mcp_servers = list_mcp_from_config(&codex_dir)?;
let enabled_ids: HashSet<String> = mcp_servers.iter().map(|s| s.id.clone()).collect();
for (id, name, config, enabled) in db_managed_mcp()? {
if enabled_ids.contains(&id) { continue; }
let (transport, command, url, summary) = mcp_summary(&config);
mcp_servers.push(ManagedMcpServer { id, name, transport, enabled, source: "Codex-X".to_string(), summary, command, url, config_json: config });
}
mcp_servers.sort_by(|a, b| b.enabled.cmp(&a.enabled).then_with(|| a.name.cmp(&b.name)));
skills.sort_by(|a, b| b.enabled.cmp(&a.enabled).then_with(|| a.name.cmp(&b.name)));
Ok(SkillsMcpState {
codex_dir: codex_dir.display().to_string(),
codex_skills_dir: skills_dir.display().to_string(),
disabled_skills_dir: disabled_dir.display().to_string(),
skills, mcp_servers, warnings,
})
}
fn import_existing_skills_mcp_inner(config_dir: Option<String>) -> Result<SkillsMcpActionResult> {
let codex_dir = resolve_codex_dir(config_dir.clone())?;
let skills_dir = codex_skills_dir(&codex_dir);
fs::create_dir_all(&skills_dir).map_err(|e| io_err(&skills_dir, e))?;
let mut imported_skills = 0usize;
let candidates = vec![
home_dir()?.join(".agents").join("skills"),
home_dir()?.join(".cc-switch").join("skills"),
];
for base in candidates {
if !base.exists() { continue; }
for entry in fs::read_dir(&base).map_err(|e| io_err(&base, e))? {
let entry = entry.map_err(|e| io_err(&base, e))?;
let src = entry.path();
if !src.is_dir() || !src.join("SKILL.md").is_file() { continue; }
let directory = sanitize_dir_name(&entry.file_name().to_string_lossy(), "skill");
let dst = skills_dir.join(&directory);
if !dst.exists() {
copy_dir_recursive(&src, &dst)?;
imported_skills += 1;
}
}
}
let mut imported_mcp = 0usize;
for server in list_mcp_from_config(&codex_dir)? {
save_managed_mcp(&server.id, &server.name, &server.config_json, true)?;
imported_mcp += 1;
}
let state = build_skills_mcp_state_inner(config_dir)?;
Ok(SkillsMcpActionResult {
imported_skills, imported_mcp,
message: format!("已导入 {imported_skills} 个 Skills,纳管 {imported_mcp} 个 MCP"),
state,
})
}
fn toggle_codex_mcp_inner(config_dir: Option<String>, id: String, enabled: bool) -> Result<SkillsMcpState> {
let codex_dir = resolve_codex_dir(config_dir.clone())?;
fs::create_dir_all(&codex_dir).map_err(|e| io_err(&codex_dir, e))?;
let cfg = config_path(&codex_dir);
let text = read_to_string_if_exists(&cfg)?;
let mut doc = parse_toml_document(&cfg, &text)?;
if enabled {
let db = db_managed_mcp()?.into_iter().find(|(sid, _, _, _)| sid == &id)
.ok_or_else(|| CodexxError::Config(format!("未找到 MCP: {id}")))?;
doc["mcp_servers"][&id] = json_to_toml_item(&db.2);
save_managed_mcp(&id, &db.1, &db.2, true)?;
} else {
if let Some(item) = doc.get("mcp_servers").and_then(|m| m.as_table()).and_then(|tbl| tbl.get(&id)) {
let config = toml_item_to_json(item);
save_managed_mcp(&id, &id, &config, false)?;
}
if let Some(tbl) = doc.get_mut("mcp_servers").and_then(|m| m.as_table_mut()) {
tbl.remove(&id);
}
}
write_text(&cfg, &doc.to_string())?;
build_skills_mcp_state_inner(config_dir)
}
fn toggle_codex_skill_inner(config_dir: Option<String>, id: String, enabled: bool) -> Result<SkillsMcpState> {
let codex_dir = resolve_codex_dir(config_dir.clone())?;
let skills_dir = codex_skills_dir(&codex_dir);
let disabled_dir = disabled_skills_dir()?;
fs::create_dir_all(&skills_dir).map_err(|e| io_err(&skills_dir, e))?;
fs::create_dir_all(&disabled_dir).map_err(|e| io_err(&disabled_dir, e))?;
let current_state = build_skills_mcp_state_inner(config_dir.clone())?;
let name = current_state
.skills
.iter()
.find(|skill| skill.id == id)
.map(|skill| skill.directory.clone())
.unwrap_or_else(|| sanitize_dir_name(&id, "skill"));
let enabled_path = skills_dir.join(&name);
let disabled_path = disabled_dir.join(&name);
if enabled {
if disabled_path.exists() && !enabled_path.exists() {
fs::rename(&disabled_path, &enabled_path).or_else(|_| { copy_dir_recursive(&disabled_path, &enabled_path).map(|_| { let _ = fs::remove_dir_all(&disabled_path); }) })
.map_err(|e| CodexxError::Config(format!("启用 Skill 失败: {e}")))?;
}
} else if enabled_path.exists() && !disabled_path.exists() {
fs::rename(&enabled_path, &disabled_path).or_else(|_| { copy_dir_recursive(&enabled_path, &disabled_path).map(|_| { let _ = fs::remove_dir_all(&enabled_path); }) })
.map_err(|e| CodexxError::Config(format!("禁用 Skill 失败: {e}")))?;
}
build_skills_mcp_state_inner(config_dir)
}
fn install_skill_zip_inner(config_dir: Option<String>, file_name: String, bytes: Vec<u8>) -> Result<SkillsMcpActionResult> {
let codex_dir = resolve_codex_dir(config_dir.clone())?;
let skills_dir = codex_skills_dir(&codex_dir);
fs::create_dir_all(&skills_dir).map_err(|e| io_err(&skills_dir, e))?;
let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| CodexxError::Config(format!("读取 ZIP 失败: {e}")))?;
let tmp = app_home()?.join("tmp").join(format!("skill-zip-{}", Local::now().timestamp_millis()));
fs::create_dir_all(&tmp).map_err(|e| io_err(&tmp, e))?;
let mut total_size = 0u64;
for i in 0..archive.len() {
let mut file = archive.by_index(i).map_err(|e| CodexxError::Config(format!("读取 ZIP 条目失败: {e}")))?;
let Some(path) = file.enclosed_name().map(|p| p.to_path_buf()) else { continue; };
total_size += file.size();
if total_size > 80 * 1024 * 1024 { return Err(CodexxError::Config("ZIP 解压后超过 80MB".to_string())); }
let out = tmp.join(path);
if file.name().ends_with('/') {
fs::create_dir_all(&out).map_err(|e| io_err(&out, e))?;
} else {
if let Some(parent) = out.parent() { fs::create_dir_all(parent).map_err(|e| io_err(parent, e))?; }
let mut outfile = fs::File::create(&out).map_err(|e| io_err(&out, e))?;
std::io::copy(&mut file, &mut outfile).map_err(|e| io_err(&out, e))?;
}
}
let mut skill_dirs = Vec::new();
fn find_skill_dirs(current: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
if current.join("SKILL.md").is_file() { out.push(current.to_path_buf()); return Ok(()); }
for entry in fs::read_dir(current).map_err(|e| io_err(current, e))? {
let entry = entry.map_err(|e| io_err(current, e))?;
let path = entry.path();
if path.is_dir() { find_skill_dirs(&path, out)?; }
}
Ok(())
}
find_skill_dirs(&tmp, &mut skill_dirs)?;
if skill_dirs.is_empty() { return Err(CodexxError::Config("ZIP 中没有找到 SKILL.md".to_string())); }
let mut imported_skills = 0usize;
for src in skill_dirs {
let fallback = file_name.trim_end_matches(".zip");
let dir_name = src.file_name().and_then(|v| v.to_str()).unwrap_or(fallback);
let dst_name = sanitize_dir_name(dir_name, "skill");
let dst = skills_dir.join(dst_name);
if dst.exists() { fs::remove_dir_all(&dst).map_err(|e| io_err(&dst, e))?; }
copy_dir_recursive(&src, &dst)?;
imported_skills += 1;
}
let _ = fs::remove_dir_all(&tmp);
let state = build_skills_mcp_state_inner(config_dir)?;
Ok(SkillsMcpActionResult { imported_skills, imported_mcp: 0, message: format!("已从 ZIP 安装 {imported_skills} 个 Skill"), state })
}
fn check_skill_updates_inner(config_dir: Option<String>) -> Result<SkillsMcpState> {
let mut next = build_skills_mcp_state_inner(config_dir)?;
let conn = open_db()?;
for skill in &mut next.skills {
let old: Option<String> = conn.query_row("SELECT content_hash FROM managed_skills WHERE id = ?1", [&skill.id], |row| row.get(0)).ok();
skill.update_status = match (&old, &skill.content_hash) {
(Some(a), Some(b)) if a != b => "本地有变化".to_string(),
(Some(_), Some(_)) => "已是最新".to_string(),
_ => "已记录".to_string(),
};
save_managed_skill(skill)?;
}
Ok(next)
}
fn sanitize_id(input: &str) -> String {
let mut out = String::new();
let mut last_dash = false;
@@ -2306,30 +2877,78 @@ fn ensure_table<'a>(parent: &'a mut Table, key: &str) -> Result<&'a mut Table> {
.ok_or_else(|| CodexxError::Config(format!("{key} 不是 TOML table")))
}
#[tauri::command]
async fn get_skills_mcp_state(config_dir: Option<String>) -> Result<SkillsMcpState> {
tauri::async_runtime::spawn_blocking(move || build_skills_mcp_state_inner(config_dir))
.await
.map_err(|e| CodexxError::Config(format!("读取 Skills/MCP 失败: {e}")))?
}
#[tauri::command]
async fn import_existing_skills_mcp(config_dir: Option<String>) -> Result<SkillsMcpActionResult> {
tauri::async_runtime::spawn_blocking(move || import_existing_skills_mcp_inner(config_dir))
.await
.map_err(|e| CodexxError::Config(format!("导入已有 Skills/MCP 失败: {e}")))?
}
#[tauri::command]
async fn toggle_codex_skill(config_dir: Option<String>, id: String, enabled: bool) -> Result<SkillsMcpState> {
tauri::async_runtime::spawn_blocking(move || toggle_codex_skill_inner(config_dir, id, enabled))
.await
.map_err(|e| CodexxError::Config(format!("切换 Skill 失败: {e}")))?
}
#[tauri::command]
async fn toggle_codex_mcp(config_dir: Option<String>, id: String, enabled: bool) -> Result<SkillsMcpState> {
tauri::async_runtime::spawn_blocking(move || toggle_codex_mcp_inner(config_dir, id, enabled))
.await
.map_err(|e| CodexxError::Config(format!("切换 MCP 失败: {e}")))?
}
#[tauri::command]
async fn install_skill_zip(config_dir: Option<String>, file_name: String, bytes: Vec<u8>) -> Result<SkillsMcpActionResult> {
tauri::async_runtime::spawn_blocking(move || install_skill_zip_inner(config_dir, file_name, bytes))
.await
.map_err(|e| CodexxError::Config(format!("ZIP 安装 Skill 失败: {e}")))?
}
#[tauri::command]
async fn check_skill_updates(config_dir: Option<String>) -> Result<SkillsMcpState> {
tauri::async_runtime::spawn_blocking(move || check_skill_updates_inner(config_dir))
.await
.map_err(|e| CodexxError::Config(format!("检查 Skill 更新失败: {e}")))?
}
#[tauri::command]
fn get_startup_diagnostics(config_dir: Option<String>) -> Result<StartupDiagnostics> {
startup_diagnostics_inner(config_dir)
}
#[tauri::command]
fn get_session_sync_status(
async fn get_session_sync_status(
config_dir: Option<String>,
target_provider: Option<String>,
) -> Result<SessionSyncStatus> {
session_sync_status_inner(config_dir, target_provider)
tauri::async_runtime::spawn_blocking(move || session_sync_status_inner(config_dir, target_provider))
.await
.map_err(|e| CodexxError::Config(format!("读取会话状态失败: {e}")))?
}
#[tauri::command]
fn sync_sessions_provider(
async fn sync_sessions_provider(
config_dir: Option<String>,
target_provider: Option<String>,
) -> Result<SessionSyncResult> {
sync_sessions_provider_inner(config_dir, target_provider)
tauri::async_runtime::spawn_blocking(move || sync_sessions_provider_inner(config_dir, target_provider))
.await
.map_err(|e| CodexxError::Config(format!("同步会话失败: {e}")))?
}
#[tauri::command]
fn sync_selected_sessions_provider(input: SelectedSessionSyncInput) -> Result<SessionSyncResult> {
sync_selected_sessions_provider_inner(input)
async fn sync_selected_sessions_provider(input: SelectedSessionSyncInput) -> Result<SessionSyncResult> {
tauri::async_runtime::spawn_blocking(move || sync_selected_sessions_provider_inner(input))
.await
.map_err(|e| CodexxError::Config(format!("同步选中会话失败: {e}")))?
}
#[tauri::command]
@@ -2365,8 +2984,10 @@ fn get_builtin_prompt_status() -> Result<Vec<BuiltinPromptStatus>> {
}
#[tauri::command]
fn refresh_builtin_prompts() -> Result<Vec<BuiltinPromptStatus>> {
refresh_builtin_prompts_inner()
async fn refresh_builtin_prompts() -> Result<Vec<BuiltinPromptStatus>> {
tauri::async_runtime::spawn_blocking(refresh_builtin_prompts_inner)
.await
.map_err(|e| CodexxError::Config(format!("提示词后台更新失败: {e}")))?
}
#[tauri::command]
@@ -2834,6 +3455,12 @@ pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
get_about_info,
get_skills_mcp_state,
import_existing_skills_mcp,
toggle_codex_skill,
toggle_codex_mcp,
install_skill_zip,
check_skill_updates,
get_startup_diagnostics,
get_session_sync_status,
sync_sessions_provider,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Codex-X",
"version": "0.2.18",
"version": "0.2.19",
"identifier": "com.yynxxxxx.codexx",
"build": {
"frontendDist": "../dist",
+294 -10
View File
@@ -34,7 +34,7 @@ import "./styles.css";
type Lang = "zh" | "en";
type ProviderMode = "list" | "form" | "official";
type InstructionMode = "list" | "form";
type Tab = "dashboard" | "provider" | "sessions" | "instruction" | "toml" | "settings" | "about";
type Tab = "dashboard" | "provider" | "sessions" | "skillsMcp" | "instruction" | "toml" | "settings" | "about";
type InstructionTemplate = {
id: string;
@@ -183,6 +183,46 @@ type SessionSyncResult = {
backupDir: string;
};
type ManagedSkill = {
id: string;
name: string;
description?: string | null;
directory: string;
enabled: boolean;
source: string;
path: string;
contentHash?: string | null;
updateStatus: string;
};
type ManagedMcpServer = {
id: string;
name: string;
transport: string;
enabled: boolean;
source: string;
summary: string;
command?: string | null;
url?: string | null;
configJson: unknown;
};
type SkillsMcpState = {
codexDir: string;
codexSkillsDir: string;
disabledSkillsDir: string;
skills: ManagedSkill[];
mcpServers: ManagedMcpServer[];
warnings: string[];
};
type SkillsMcpActionResult = {
importedSkills: number;
importedMcp: number;
message: string;
state: SkillsMcpState;
};
type DiagnosticItem = {
key: string;
label: string;
@@ -257,6 +297,7 @@ const dict = {
dashboard: "概览",
provider: "供应商",
sessions: "会话管理",
skillsMcp: "技能和MCP",
instruction: "指令提示词",
toml: "TOML",
settings: "设置",
@@ -364,6 +405,7 @@ const dict = {
dashboard: "Overview",
provider: "Provider",
sessions: "Sessions",
skillsMcp: "Skills & MCP",
instruction: "Prompt",
toml: "TOML",
settings: "Settings",
@@ -763,8 +805,10 @@ function App() {
const [releaseInfo, setReleaseInfo] = React.useState<ReleaseInfo>({ status: "idle" });
const [updatePromptOpen, setUpdatePromptOpen] = React.useState(false);
const [sessionStatus, setSessionStatus] = React.useState<SessionSyncStatus | null>(null);
const [skillsMcpState, setSkillsMcpState] = React.useState<SkillsMcpState | null>(null);
const [startupDiagnostics, setStartupDiagnostics] = React.useState<StartupDiagnostics | null>(null);
const [startupWizardOpen, setStartupWizardOpen] = React.useState(() => localStorage.getItem(STARTUP_WIZARD_SEEN_KEY) !== "1");
const [startupClosing, setStartupClosing] = React.useState(false);
const [sessionQuery, setSessionQuery] = React.useState("");
const [sessionGroupByCwd, setSessionGroupByCwd] = React.useState(true);
const [selectedSessionIds, setSelectedSessionIds] = React.useState<string[]>([]);
@@ -783,7 +827,9 @@ function App() {
const [officialForm, setOfficialForm] = React.useState({ model: "gpt-5.5", authJson: "" });
const autoUpdateCheckedRef = React.useRef(false);
const promptImportRef = React.useRef<HTMLInputElement | null>(null);
const skillZipImportRef = React.useRef<HTMLInputElement | null>(null);
const promptUpdateCheckedRef = React.useRef(false);
const skillsMcpLoadedRef = React.useRef(false);
const providerTomlPreview = React.useMemo(() => buildProviderTomlPreview(providerForm, state), [providerForm, state]);
const providerAuthPreview = React.useMemo(() => buildProviderAuthPreview(providerForm), [providerForm]);
const currentInstructionId = instructionIdFromPath(state?.instructionFile);
@@ -889,6 +935,9 @@ function App() {
return (sessionStatus?.sessions || []).filter((item) => set.has(item.id) && item.needsSync).length;
}, [selectedSessionIds, sessionStatus?.sessions]);
const enabledSkillCount = skillsMcpState?.skills.filter((item) => item.enabled).length ?? 0;
const enabledMcpCount = skillsMcpState?.mcpServers.filter((item) => item.enabled).length ?? 0;
React.useEffect(() => {
setSelectedSessionIds((ids) => ids.filter((id) => (sessionStatus?.sessions || []).some((item) => item.id === id)));
}, [sessionStatus?.sessions]);
@@ -1061,8 +1110,10 @@ function App() {
};
const refreshBuiltinPrompts = async ({ quiet = false }: { quiet?: boolean } = {}) => {
setActionBusy("refreshPrompts");
if (!quiet) setLoading(true);
if (!quiet) {
setActionBusy("refreshPrompts");
setLoading(true);
}
try {
const list = await invoke<BuiltinPromptStatus[]>("refresh_builtin_prompts");
setBuiltinPromptStatus(list);
@@ -1075,8 +1126,10 @@ function App() {
} catch (e) {
if (!quiet) setError(String(e));
} finally {
if (!quiet) setLoading(false);
setActionBusy("");
if (!quiet) {
setLoading(false);
setActionBusy("");
}
}
};
@@ -1246,10 +1299,112 @@ function App() {
React.useEffect(() => {
if (tab !== "instruction" || promptUpdateCheckedRef.current) return;
promptUpdateCheckedRef.current = true;
void refreshBuiltinPrompts({ quiet: true });
const timer = window.setTimeout(() => {
void refreshBuiltinPrompts({ quiet: true });
}, 800);
return () => window.clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tab]);
const loadSkillsMcp = React.useCallback(async ({ quiet = false }: { quiet?: boolean } = {}) => {
if (!quiet) {
setActionBusy("loadSkillsMcp");
setError("");
}
try {
const result = await invoke<SkillsMcpState>("get_skills_mcp_state", { configDir: configDir || null });
setSkillsMcpState(result);
} catch (e) {
if (!quiet) setError(String(e));
} finally {
if (!quiet) setActionBusy("");
}
}, [configDir]);
React.useEffect(() => {
if (tab !== "skillsMcp" || skillsMcpLoadedRef.current) return;
skillsMcpLoadedRef.current = true;
void loadSkillsMcp();
}, [tab, loadSkillsMcp]);
const importExistingSkillsMcp = async () => {
setActionBusy("importExistingSkillsMcp");
setError("");
try {
const result = await invoke<SkillsMcpActionResult>("import_existing_skills_mcp", { configDir: configDir || null });
setSkillsMcpState(result.state);
setToast(result.message);
} catch (e) {
setError(String(e));
} finally {
setActionBusy("");
}
};
const checkSkillUpdatesAction = async () => {
setActionBusy("checkSkillUpdates");
setError("");
try {
const result = await invoke<SkillsMcpState>("check_skill_updates", { configDir: configDir || null });
setSkillsMcpState(result);
setToast(lang === "zh" ? "Skills 更新状态已刷新" : "Skill update status refreshed");
} catch (e) {
setError(String(e));
} finally {
setActionBusy("");
}
};
const toggleSkillEnabled = async (id: string, enabled: boolean) => {
setActionBusy(`skill:${id}`);
setError("");
try {
const result = await invoke<SkillsMcpState>("toggle_codex_skill", { configDir: configDir || null, id, enabled });
setSkillsMcpState(result);
setToast(enabled ? (lang === "zh" ? "Skill 已启用" : "Skill enabled") : (lang === "zh" ? "Skill 已禁用" : "Skill disabled"));
} catch (e) {
setError(String(e));
} finally {
setActionBusy("");
}
};
const toggleMcpEnabled = async (id: string, enabled: boolean) => {
setActionBusy(`mcp:${id}`);
setError("");
try {
const result = await invoke<SkillsMcpState>("toggle_codex_mcp", { configDir: configDir || null, id, enabled });
setSkillsMcpState(result);
setToast(enabled ? (lang === "zh" ? "MCP 已启用" : "MCP enabled") : (lang === "zh" ? "MCP 已禁用" : "MCP disabled"));
void refresh();
} catch (e) {
setError(String(e));
} finally {
setActionBusy("");
}
};
const installSkillZipFile = async (file?: File | null) => {
if (!file) return;
if (!file.name.toLowerCase().endsWith(".zip")) {
setError(lang === "zh" ? "请选择 .zip 技能包" : "Please choose a .zip skill package");
return;
}
setActionBusy("installSkillZip");
setError("");
try {
const bytes = Array.from(new Uint8Array(await file.arrayBuffer()));
const result = await invoke<SkillsMcpActionResult>("install_skill_zip", { configDir: configDir || null, fileName: file.name, bytes });
setSkillsMcpState(result.state);
setToast(result.message);
} catch (e) {
setError(String(e));
} finally {
setActionBusy("");
if (skillZipImportRef.current) skillZipImportRef.current.value = "";
}
};
const loadCcSwitchOfficialAuth = async (showToast = true) => {
const candidate = await invoke<OfficialAuthCandidate | null>("read_ccswitch_official_auth", { dbPath: null });
if (candidate) {
@@ -1397,13 +1552,18 @@ function App() {
const closeStartupWizard = () => {
localStorage.setItem(STARTUP_WIZARD_SEEN_KEY, "1");
setStartupWizardOpen(false);
setStartupClosing(true);
window.setTimeout(() => {
setStartupWizardOpen(false);
setStartupClosing(false);
}, 260);
};
const navItems: Array<[Tab, string, React.ReactNode]> = [
["dashboard", t.nav.dashboard, <Layers3 size={18} />],
["provider", t.nav.provider, <Zap size={18} />],
["sessions", t.nav.sessions, <History size={18} />],
["skillsMcp", t.nav.skillsMcp, <Layers3 size={18} />],
["instruction", t.nav.instruction, <Sparkles size={18} />],
["toml", t.nav.toml, <FileCode2 size={18} />],
["settings", t.nav.settings, <Settings size={18} />],
@@ -1495,7 +1655,7 @@ function App() {
)}
{startupWizardOpen && startupDiagnostics && (
<div className="startup-mask">
<div className={cx("startup-mask", startupClosing && "closing")}>
<div className="startup-card glass">
<div className="startup-head">
<div>
@@ -1543,9 +1703,13 @@ function App() {
)}
{!state ? (
<div className="panel glass center-panel">
<Loader2 className="spin" />
<div className="panel glass center-panel boot-panel">
<div className="boot-logo-wrap">
<div className="boot-logo">Codex-X</div>
<div className="boot-orbit" />
</div>
<p>{t.loadingConfig}</p>
<div className="boot-progress"><span /></div>
</div>
) : (
<>
@@ -1910,6 +2074,126 @@ function App() {
</section>
)}
{tab === "skillsMcp" && (
<section className="panel glass skills-mcp-panel">
<div className="panel-head provider-title-row">
<div>
<p className="eyebrow">Skills / MCP</p>
<h3>{lang === "zh" ? "技能和 MCP" : "Skills & MCP"}</h3>
<p className="muted-desc">{lang === "zh" ? "管理 Codex 当前可用的 Skills 与 MCP:导入已有、从 ZIP 安装、启用或禁用。" : "Manage Codex Skills and MCP servers: import existing items, install ZIP packages, enable or disable."}</p>
</div>
<div className="provider-title-actions">
<input
ref={skillZipImportRef}
className="hidden-file-input"
type="file"
accept=".zip,application/zip"
onChange={(e) => void installSkillZipFile(e.target.files?.[0])}
/>
<button className="ghost-btn add-provider-btn lively-btn" onClick={() => void loadSkillsMcp()} disabled={actionBusy === "loadSkillsMcp"}>
{actionBusy === "loadSkillsMcp" ? <Loader2 size={18} className="spin" /> : <RefreshCw size={18} />} {lang === "zh" ? "刷新" : "Refresh"}
</button>
<button className="secondary-btn add-provider-btn lively-btn" onClick={importExistingSkillsMcp} disabled={Boolean(actionBusy)}>
{actionBusy === "importExistingSkillsMcp" ? <Loader2 size={18} className="spin" /> : <Download size={18} />} {lang === "zh" ? "导入已有" : "Import existing"}
</button>
<button className="secondary-btn add-provider-btn lively-btn" onClick={() => skillZipImportRef.current?.click()} disabled={Boolean(actionBusy)}>
{actionBusy === "installSkillZip" ? <Loader2 size={18} className="spin" /> : <Upload size={18} />} {lang === "zh" ? "从 ZIP 安装" : "Install ZIP"}
</button>
<button className="primary-btn add-provider-btn lively-btn" onClick={checkSkillUpdatesAction} disabled={Boolean(actionBusy)}>
{actionBusy === "checkSkillUpdates" ? <Loader2 size={18} className="spin" /> : <Sparkles size={18} />} {lang === "zh" ? "检查更新" : "Check updates"}
</button>
</div>
</div>
{!skillsMcpState ? (
<div className="session-empty skills-loading">
<Loader2 className="spin" size={22} />
<span>{lang === "zh" ? "正在读取本地 Skills / MCP..." : "Loading local Skills / MCP..."}</span>
</div>
) : (
<>
<div className="skills-mcp-summary">
<StatCard icon={<Sparkles size={20} />} label={lang === "zh" ? "已启用 Skills" : "Enabled Skills"} value={`${enabledSkillCount}/${skillsMcpState.skills.length}`} ok={enabledSkillCount > 0} />
<StatCard icon={<TerminalSquare size={20} />} label={lang === "zh" ? "已启用 MCP" : "Enabled MCP"} value={`${enabledMcpCount}/${skillsMcpState.mcpServers.length}`} ok={enabledMcpCount > 0} />
<div className="skills-path-card">
<span>~/.codex/skills</span>
<code>{skillsMcpState.codexSkillsDir}</code>
</div>
</div>
<div className="skills-mcp-columns">
<section className="skills-mcp-card">
<div className="skills-mcp-card-head">
<div>
<strong>Skills</strong>
<p>{lang === "zh" ? "启用后会放入 Codex skills 目录;禁用会移动到 Codex-X 的禁用目录。" : "Enabled skills live in the Codex skills directory; disabled skills are moved into Codex-X storage."}</p>
</div>
<span className="mini-tag ok">{skillsMcpState.skills.length}</span>
</div>
<div className="skills-mcp-list">
{skillsMcpState.skills.length === 0 ? (
<div className="session-empty compact"><Sparkles size={18} /><span>{lang === "zh" ? "还没有发现 Skills,点击导入已有或从 ZIP 安装。" : "No Skills found. Import existing or install a ZIP."}</span></div>
) : skillsMcpState.skills.map((skill) => (
<article className={cx("skill-mcp-row", skill.enabled && "selected")} key={skill.id}>
<div className="instruction-icon custom"><Sparkles size={20} /></div>
<div className="skill-mcp-main">
<strong>{skill.name}</strong>
<p>{skill.description || (lang === "zh" ? "无描述" : "No description")}</p>
<code title={skill.path}>{skill.directory}</code>
</div>
<div className="skill-mcp-meta">
<span className={cx("mini-tag", skill.enabled ? "ok" : undefined)}>{skill.enabled ? (lang === "zh" ? "已启用" : "Enabled") : (lang === "zh" ? "已禁用" : "Disabled")}</span>
<small>{skill.updateStatus}</small>
</div>
<button className={cx(skill.enabled ? "ghost-btn" : "secondary-btn", "small", "lively-btn")} onClick={() => void toggleSkillEnabled(skill.id, !skill.enabled)} disabled={actionBusy === `skill:${skill.id}`}>
{actionBusy === `skill:${skill.id}` ? <Loader2 size={14} className="spin" /> : null}{skill.enabled ? (lang === "zh" ? "禁用" : "Disable") : (lang === "zh" ? "启用" : "Enable")}
</button>
</article>
))}
</div>
</section>
<section className="skills-mcp-card">
<div className="skills-mcp-card-head">
<div>
<strong>MCP</strong>
<p>{lang === "zh" ? "启用会写入 ~/.codex/config.toml 的 [mcp_servers];禁用会移除但保留在 Codex-X。" : "Enabling writes to ~/.codex/config.toml [mcp_servers]; disabling removes it but keeps a Codex-X copy."}</p>
</div>
<span className="mini-tag ok">{skillsMcpState.mcpServers.length}</span>
</div>
<div className="skills-mcp-list">
{skillsMcpState.mcpServers.length === 0 ? (
<div className="session-empty compact"><TerminalSquare size={18} /><span>{lang === "zh" ? "还没有发现 MCP。当前会从 config.toml 导入已有 MCP。" : "No MCP server found. Existing config.toml MCP entries can be imported."}</span></div>
) : skillsMcpState.mcpServers.map((server) => (
<article className={cx("skill-mcp-row", server.enabled && "selected")} key={server.id}>
<div className="instruction-icon"><TerminalSquare size={20} /></div>
<div className="skill-mcp-main">
<strong>{server.name || server.id}</strong>
<p title={server.summary}>{server.summary || server.transport}</p>
<code>{server.transport} · {server.source}</code>
</div>
<div className="skill-mcp-meta">
<span className={cx("mini-tag", server.enabled ? "ok" : undefined)}>{server.enabled ? (lang === "zh" ? "已启用" : "Enabled") : (lang === "zh" ? "已禁用" : "Disabled")}</span>
</div>
<button className={cx(server.enabled ? "ghost-btn" : "secondary-btn", "small", "lively-btn")} onClick={() => void toggleMcpEnabled(server.id, !server.enabled)} disabled={actionBusy === `mcp:${server.id}`}>
{actionBusy === `mcp:${server.id}` ? <Loader2 size={14} className="spin" /> : null}{server.enabled ? (lang === "zh" ? "关闭" : "Disable") : (lang === "zh" ? "开启" : "Enable")}
</button>
</article>
))}
</div>
</section>
</div>
{skillsMcpState.warnings.length > 0 && (
<div className="session-warning-list">
{skillsMcpState.warnings.map((item, index) => <p key={index}><AlertCircle size={15} /> {item}</p>)}
</div>
)}
</>
)}
</section>
)}
{tab === "instruction" && (
<section className="panel glass instruction-panel simple-instruction-panel">
{instructionMode === "list" ? (
+226
View File
@@ -2002,3 +2002,229 @@ html, body, #root {
white-space: nowrap;
text-overflow: ellipsis;
}
/* Smoother boot / first-run transitions */
.boot-panel {
position: relative;
min-height: 420px;
overflow: hidden;
animation: boot-panel-in .42s cubic-bezier(.2,.8,.2,1) both;
}
.boot-panel::before {
content: "";
position: absolute;
width: 420px;
height: 420px;
border-radius: 999px;
background: radial-gradient(circle, rgba(45, 170, 255, .24), transparent 62%);
filter: blur(6px);
animation: boot-glow-drift 4.8s ease-in-out infinite;
}
.boot-logo-wrap {
position: relative;
width: 174px;
height: 174px;
display: grid;
place-items: center;
}
.boot-logo {
position: relative;
z-index: 2;
width: 126px;
height: 126px;
border-radius: 34px;
display: grid;
place-items: center;
color: #f7fbff;
font-weight: 900;
letter-spacing: -.06em;
font-size: 24px;
background: linear-gradient(145deg, rgba(33, 96, 255, .92), rgba(18, 218, 255, .74));
border: 1px solid rgba(255,255,255,.28);
box-shadow: 0 26px 70px rgba(20, 130, 255, .26), inset 0 1px 0 rgba(255,255,255,.38);
animation: boot-logo-float 2.8s ease-in-out infinite;
}
.boot-orbit {
position: absolute;
inset: 12px;
border-radius: 999px;
border: 1px solid rgba(113, 205, 255, .22);
border-top-color: rgba(89, 210, 255, .9);
border-right-color: rgba(108, 134, 255, .55);
animation: boot-orbit-spin 1.35s linear infinite;
}
.boot-progress {
width: min(320px, 68%);
height: 7px;
border-radius: 999px;
overflow: hidden;
background: rgba(255,255,255,.08);
border: 1px solid rgba(255,255,255,.09);
}
.boot-progress span {
display: block;
width: 44%;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, transparent, #43dfff, #6c7dff, transparent);
animation: boot-progress-run 1.25s ease-in-out infinite;
}
.startup-mask {
animation: startup-mask-in .24s ease-out both;
}
.startup-mask.closing {
animation: startup-mask-out .26s ease-in both;
}
.startup-card {
animation: startup-card-in .46s cubic-bezier(.16,1,.3,1) both;
}
.startup-mask.closing .startup-card {
animation: startup-card-out .24s ease-in both;
}
.startup-check-item {
animation: startup-item-in .42s cubic-bezier(.2,.8,.2,1) both;
}
.startup-check-item:nth-child(2) { animation-delay: .04s; }
.startup-check-item:nth-child(3) { animation-delay: .08s; }
.startup-check-item:nth-child(4) { animation-delay: .12s; }
@keyframes boot-panel-in {
from { opacity: 0; transform: translateY(10px) scale(.985); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes boot-logo-float {
0%, 100% { transform: translateY(0); filter: brightness(1); }
50% { transform: translateY(-7px); filter: brightness(1.08); }
}
@keyframes boot-orbit-spin {
to { transform: rotate(360deg); }
}
@keyframes boot-progress-run {
0% { transform: translateX(-110%); }
100% { transform: translateX(250%); }
}
@keyframes boot-glow-drift {
0%, 100% { transform: translate(-120px, -80px) scale(.92); opacity: .75; }
50% { transform: translate(120px, 60px) scale(1.12); opacity: 1; }
}
@keyframes startup-mask-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes startup-mask-out {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes startup-card-in {
from { opacity: 0; transform: translateY(18px) scale(.975); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes startup-card-out {
from { opacity: 1; transform: translateY(0) scale(1); }
to { opacity: 0; transform: translateY(10px) scale(.985); }
}
@keyframes startup-item-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.boot-panel,
.boot-panel::before,
.boot-logo,
.boot-orbit,
.boot-progress span,
.startup-mask,
.startup-card,
.startup-check-item {
animation: none !important;
}
}
/* Page switch transition */
.content > .panel,
.content > .grid,
.content > .about-page,
.content > .settings-panel,
.content > .update-strip {
animation: page-soft-in .24s cubic-bezier(.2,.8,.2,1) both;
}
@keyframes page-soft-in {
from { opacity: 0; transform: translateY(8px) scale(.992); filter: blur(2px); }
to { opacity: 1; transform: translateY(0) scale(1); filter: blur(0); }
}
/* Skills & MCP */
.skills-mcp-panel { display: grid; gap: 16px; }
.skills-mcp-summary {
display: grid;
grid-template-columns: minmax(180px, 240px) minmax(180px, 240px) 1fr;
gap: 14px;
align-items: stretch;
}
.skills-path-card {
min-height: 110px;
border-radius: 24px;
padding: 18px;
background: linear-gradient(180deg, rgba(255,255,255,.08), rgba(255,255,255,.035));
border: 1px solid rgba(255,255,255,.1);
display: grid;
align-content: center;
gap: 8px;
min-width: 0;
}
.skills-path-card span { color: #91a0bb; font-size: 13px; font-weight: 800; }
.skills-path-card code { color: #d9e6ff; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.skills-mcp-columns {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
align-items: start;
}
.skills-mcp-card {
border-radius: 26px;
padding: 18px;
background: rgba(4, 12, 27, .34);
border: 1px solid rgba(255,255,255,.09);
min-width: 0;
}
.skills-mcp-card-head {
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.skills-mcp-card-head strong { font-size: 18px; color: #f5f9ff; }
.skills-mcp-card-head p { margin: 5px 0 0; color: #8fa0bc; line-height: 1.55; font-size: 13px; }
.skills-mcp-list { display: grid; gap: 12px; max-height: calc(100vh - 365px); overflow: auto; padding-right: 4px; }
.skill-mcp-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto;
gap: 12px;
align-items: center;
min-height: 92px;
padding: 14px;
border-radius: 22px;
background: linear-gradient(180deg, rgba(255,255,255,.065), rgba(255,255,255,.03));
border: 1px solid rgba(255,255,255,.09);
transition: transform .18s ease, border-color .18s ease, background .18s ease;
}
.skill-mcp-row:hover { transform: translateY(-1px); border-color: rgba(87, 173, 255, .35); }
.skill-mcp-row.selected { border-color: rgba(35, 207, 255, .62); background: linear-gradient(180deg, rgba(36,135,255,.16), rgba(17,75,112,.12)); }
.skill-mcp-main { min-width: 0; }
.skill-mcp-main strong { display: block; color: #f5f9ff; margin-bottom: 4px; }
.skill-mcp-main p { margin: 0 0 6px; color: #9caac4; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.skill-mcp-main code { display: block; color: #bcd0ef; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.skill-mcp-meta { display: grid; justify-items: end; gap: 6px; color: #8fa0bc; font-size: 12px; }
.session-empty.compact { min-height: 96px; }
.skills-loading { min-height: 280px; }
@media (max-width: 1280px) {
.skills-mcp-summary { grid-template-columns: 1fr; }
.skills-mcp-columns { grid-template-columns: 1fr; }
.skills-mcp-list { max-height: none; }
}
@media (prefers-reduced-motion: reduce) {
.content > .panel,
.content > .grid,
.content > .about-page,
.content > .settings-panel,
.content > .update-strip { animation: none !important; }
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "codex-x-workspace",
"private": true,
"version": "0.2.18",
"version": "0.2.19",
"scripts": {
"dev": "pnpm --dir apps/desktop tauri dev",
"build": "pnpm --dir apps/desktop tauri build",