feat(codex-skills): surface built-in .system skills as read-only
Scan ~/.codex/skills/.system so Codex CLI's bundled skills (imagegen, skill-creator, etc.) appear in the $ autocomplete and Skills settings. Mark them read_only on the API; the save/delete handlers refuse writes to that path, and the UI shows a system badge with a tooltip and disables edit/delete/save for those entries.
This commit is contained in:
@@ -302,6 +302,10 @@ pub struct AgentSkillItem {
|
||||
pub scope: AgentSkillScope,
|
||||
pub layout: AgentSkillLayout,
|
||||
pub path: String,
|
||||
/// True for skills bundled by the agent CLI itself (e.g. Codex's
|
||||
/// `~/.codex/skills/.system/*`). Surfaced so the UI can show them but
|
||||
/// refuse to edit or delete; the backend also refuses such writes.
|
||||
pub read_only: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
|
||||
@@ -1397,6 +1397,13 @@ pub(crate) fn skill_storage_spec(agent_type: AgentType) -> Option<SkillStorageSp
|
||||
kind: SkillStorageKind::SkillDirectoryOrMarkdownFile,
|
||||
global_dirs: vec![
|
||||
codex_home_dir().join("skills"),
|
||||
// `.system` is where Codex CLI stores its own bundled
|
||||
// skills (imagegen, skill-creator, etc.). The directory
|
||||
// name is a Codex convention, not a stable contract —
|
||||
// if Codex renames it we'll silently stop listing them.
|
||||
// `is_read_only_skill_path` mirrors this path to prevent
|
||||
// edit/delete from clobbering CLI assets.
|
||||
codex_home_dir().join("skills").join(".system"),
|
||||
home_dir_or_default().join(".agents").join("skills"),
|
||||
],
|
||||
project_rel_dirs: vec![".codex/skills", ".agents/skills"],
|
||||
@@ -1532,9 +1539,22 @@ fn build_skill_item(
|
||||
scope,
|
||||
layout,
|
||||
path: path.to_string_lossy().to_string(),
|
||||
read_only: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Codex ships a handful of built-in skills under `~/.codex/skills/.system/`
|
||||
/// (imagegen, skill-creator, etc.). We scan that directory so users see
|
||||
/// these in the `$` autocomplete and the Skills settings list — but any
|
||||
/// write to those files would clobber the CLI's own assets.
|
||||
fn is_read_only_skill_path(agent_type: AgentType, skill_path: &Path) -> bool {
|
||||
if agent_type != AgentType::Codex {
|
||||
return false;
|
||||
}
|
||||
let ro_root = codex_home_dir().join("skills").join(".system");
|
||||
skill_path.starts_with(&ro_root)
|
||||
}
|
||||
|
||||
fn skill_content_path(layout: AgentSkillLayout, skill_path: &Path) -> PathBuf {
|
||||
match layout {
|
||||
AgentSkillLayout::SkillDirectory => skill_path.join("SKILL.md"),
|
||||
@@ -2967,6 +2987,11 @@ pub async fn acp_list_agent_skills(
|
||||
}
|
||||
|
||||
let mut skills = skills_by_key.into_values().collect::<Vec<_>>();
|
||||
for skill in &mut skills {
|
||||
if is_read_only_skill_path(agent_type, Path::new(&skill.path)) {
|
||||
skill.read_only = true;
|
||||
}
|
||||
}
|
||||
skills.sort_by(|a, b| {
|
||||
scope_rank(a.scope)
|
||||
.cmp(&scope_rank(b.scope))
|
||||
@@ -2996,8 +3021,11 @@ pub async fn acp_read_agent_skill(
|
||||
let id = validate_skill_id(&skill_id)?;
|
||||
let dirs = scoped_skill_dirs(agent_type, scope, workspace_path.as_deref())?;
|
||||
|
||||
let skill = locate_existing_skill_across_dirs(&dirs, spec.kind, &id, scope)
|
||||
let mut skill = locate_existing_skill_across_dirs(&dirs, spec.kind, &id, scope)
|
||||
.ok_or_else(|| AcpError::protocol(format!("skill not found: {id}")))?;
|
||||
if is_read_only_skill_path(agent_type, Path::new(&skill.path)) {
|
||||
skill.read_only = true;
|
||||
}
|
||||
let content_path = skill_content_path(skill.layout, Path::new(&skill.path));
|
||||
let content = fs::read_to_string(&content_path)
|
||||
.map_err(|e| AcpError::protocol(format!("failed to read skill content: {e}")))?;
|
||||
@@ -3026,6 +3054,13 @@ pub async fn acp_save_agent_skill(
|
||||
.map_err(|e| AcpError::protocol(format!("failed to create skills directory: {e}")))?;
|
||||
|
||||
let existing = locate_existing_skill_across_dirs(&dirs, spec.kind, &id, scope);
|
||||
if let Some(ref item) = existing {
|
||||
if is_read_only_skill_path(agent_type, Path::new(&item.path)) {
|
||||
return Err(AcpError::protocol(format!(
|
||||
"skill '{id}' is a built-in system skill and cannot be modified"
|
||||
)));
|
||||
}
|
||||
}
|
||||
let skill = if let Some(item) = existing {
|
||||
item
|
||||
} else {
|
||||
@@ -3081,6 +3116,11 @@ pub async fn acp_delete_agent_skill(
|
||||
|
||||
let skill = locate_existing_skill_across_dirs(&dirs, spec.kind, &id, scope)
|
||||
.ok_or_else(|| AcpError::protocol(format!("skill not found: {id}")))?;
|
||||
if is_read_only_skill_path(agent_type, Path::new(&skill.path)) {
|
||||
return Err(AcpError::protocol(format!(
|
||||
"skill '{id}' is a built-in system skill and cannot be deleted"
|
||||
)));
|
||||
}
|
||||
let skill_path = PathBuf::from(&skill.path);
|
||||
remove_skill_entry(&skill_path)
|
||||
.map_err(|e| AcpError::protocol(format!("failed to delete skill entry: {e}")))?;
|
||||
|
||||
@@ -990,6 +990,15 @@ export function SkillsSettings() {
|
||||
>
|
||||
{skill.scope}
|
||||
</Badge>
|
||||
{skill.read_only && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
title={t("systemHint")}
|
||||
className="h-6 px-2 inline-flex items-center gap-1 text-xs leading-none shrink-0 border-amber-500/40 bg-amber-500/10 text-amber-600 dark:text-amber-400"
|
||||
>
|
||||
{t("systemBadge")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground truncate mt-1">
|
||||
{skill.path}
|
||||
@@ -1010,6 +1019,7 @@ export function SkillsSettings() {
|
||||
{t("actions.preview")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
disabled={skill.read_only}
|
||||
onSelect={() => {
|
||||
handleEditSkill(skill).catch((err) => {
|
||||
console.error(
|
||||
@@ -1034,7 +1044,12 @@ export function SkillsSettings() {
|
||||
{t("actions.openInWindow")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
disabled={skillSaving || skillReading || deleting}
|
||||
disabled={
|
||||
skillSaving ||
|
||||
skillReading ||
|
||||
deleting ||
|
||||
skill.read_only
|
||||
}
|
||||
onSelect={() => {
|
||||
handleRequestDeleteSkill(skill)
|
||||
}}
|
||||
@@ -1097,10 +1112,19 @@ export function SkillsSettings() {
|
||||
selectedSkillId || isDrafting ? (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="border-b px-4 py-3 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="min-w-0 flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold truncate">
|
||||
{skillDraftId.trim() || t("newSkillTitle")}
|
||||
</h3>
|
||||
{selectedSkill?.read_only && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
title={t("systemHint")}
|
||||
className="h-5 px-1.5 text-[10px] leading-none shrink-0 border-amber-500/40 bg-amber-500/10 text-amber-600 dark:text-amber-400"
|
||||
>
|
||||
{t("systemBadge")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
@@ -1123,7 +1147,11 @@ export function SkillsSettings() {
|
||||
)
|
||||
})
|
||||
}}
|
||||
disabled={skillSaving || skillReading}
|
||||
disabled={
|
||||
skillSaving ||
|
||||
skillReading ||
|
||||
Boolean(selectedSkill?.read_only)
|
||||
}
|
||||
>
|
||||
{skillSaving ? (
|
||||
<>
|
||||
@@ -1189,7 +1217,10 @@ export function SkillsSettings() {
|
||||
onClick={() => {
|
||||
setIsContentEditing((prev) => !prev)
|
||||
}}
|
||||
disabled={skillReading}
|
||||
disabled={
|
||||
skillReading ||
|
||||
Boolean(selectedSkill?.read_only)
|
||||
}
|
||||
>
|
||||
{isContentEditing ? (
|
||||
<>
|
||||
|
||||
@@ -302,6 +302,8 @@
|
||||
"loadingSkill": "جارٍ تحميل Skill...",
|
||||
"emptyNoAgents": "لا يوجد وكيل متاح.",
|
||||
"noSelectionHint": "اختر Skill من اليسار، أو انقر على \"Skill جديد\" لإنشاء واحد.",
|
||||
"systemBadge": "نظام",
|
||||
"systemHint": "Skill مدمج في CLI · للقراءة فقط",
|
||||
"scope": {
|
||||
"global": "عام",
|
||||
"folder": "مجلد",
|
||||
|
||||
@@ -302,6 +302,8 @@
|
||||
"loadingSkill": "Skill wird geladen...",
|
||||
"emptyNoAgents": "Kein verfügbarer Agent.",
|
||||
"noSelectionHint": "Wählen Sie links einen Skill oder klicken Sie auf „Neuer Skill“, um einen zu erstellen.",
|
||||
"systemBadge": "System",
|
||||
"systemHint": "Integrierter CLI-Skill · schreibgeschützt",
|
||||
"scope": {
|
||||
"global": "Global",
|
||||
"folder": "Ordner",
|
||||
|
||||
@@ -302,6 +302,8 @@
|
||||
"loadingSkill": "Loading skill...",
|
||||
"emptyNoAgents": "No available agent.",
|
||||
"noSelectionHint": "Select a skill on the left, or click \"New Skill\" to create one.",
|
||||
"systemBadge": "System",
|
||||
"systemHint": "Built-in CLI skill · read-only",
|
||||
"scope": {
|
||||
"global": "Global",
|
||||
"folder": "Folder",
|
||||
|
||||
@@ -302,6 +302,8 @@
|
||||
"loadingSkill": "Cargando Skill...",
|
||||
"emptyNoAgents": "No hay agentes disponibles.",
|
||||
"noSelectionHint": "Selecciona un Skill a la izquierda o haz clic en \"Nuevo Skill\" para crear uno.",
|
||||
"systemBadge": "Sistema",
|
||||
"systemHint": "Skill integrado del CLI · solo lectura",
|
||||
"scope": {
|
||||
"global": "Global",
|
||||
"folder": "Carpeta",
|
||||
|
||||
@@ -302,6 +302,8 @@
|
||||
"loadingSkill": "Chargement de la Skill...",
|
||||
"emptyNoAgents": "Aucun agent disponible.",
|
||||
"noSelectionHint": "Sélectionnez un Skill à gauche ou cliquez sur « Nouveau Skill » pour en créer un.",
|
||||
"systemBadge": "Système",
|
||||
"systemHint": "Skill intégré du CLI · lecture seule",
|
||||
"scope": {
|
||||
"global": "Global",
|
||||
"folder": "Dossier",
|
||||
|
||||
@@ -302,6 +302,8 @@
|
||||
"loadingSkill": "Skillを読み込み中...",
|
||||
"emptyNoAgents": "利用可能なエージェントがありません。",
|
||||
"noSelectionHint": "左側から Skill を選択するか、「新規 Skill」をクリックして作成してください。",
|
||||
"systemBadge": "システム",
|
||||
"systemHint": "CLI 組み込み Skill・読み取り専用",
|
||||
"scope": {
|
||||
"global": "グローバル",
|
||||
"folder": "フォルダ",
|
||||
|
||||
@@ -302,6 +302,8 @@
|
||||
"loadingSkill": "Skill 불러오는 중...",
|
||||
"emptyNoAgents": "사용 가능한 에이전트가 없습니다.",
|
||||
"noSelectionHint": "왼쪽에서 Skill을 선택하거나 \"새 Skill\"을 클릭하여 만드세요.",
|
||||
"systemBadge": "시스템",
|
||||
"systemHint": "CLI 내장 Skill · 읽기 전용",
|
||||
"scope": {
|
||||
"global": "전역",
|
||||
"folder": "폴더",
|
||||
|
||||
@@ -302,6 +302,8 @@
|
||||
"loadingSkill": "Carregando Skill...",
|
||||
"emptyNoAgents": "Nenhum agente disponível.",
|
||||
"noSelectionHint": "Selecione um Skill à esquerda ou clique em \"Novo Skill\" para criar um.",
|
||||
"systemBadge": "Sistema",
|
||||
"systemHint": "Skill integrado do CLI · somente leitura",
|
||||
"scope": {
|
||||
"global": "Global",
|
||||
"folder": "Pasta",
|
||||
|
||||
@@ -302,6 +302,8 @@
|
||||
"loadingSkill": "正在加载 Skill...",
|
||||
"emptyNoAgents": "暂无可用 Agent。",
|
||||
"noSelectionHint": "从左侧选择一个 Skill,或点击“新建 Skill”创建。",
|
||||
"systemBadge": "系统",
|
||||
"systemHint": "CLI 内置 Skill · 只读",
|
||||
"scope": {
|
||||
"global": "全局",
|
||||
"folder": "文件夹",
|
||||
|
||||
@@ -302,6 +302,8 @@
|
||||
"loadingSkill": "正在載入 Skill...",
|
||||
"emptyNoAgents": "暫無可用 Agent。",
|
||||
"noSelectionHint": "從左側選擇一個 Skill,或點擊「新建 Skill」建立。",
|
||||
"systemBadge": "系統",
|
||||
"systemHint": "CLI 內建 Skill · 唯讀",
|
||||
"scope": {
|
||||
"global": "全域",
|
||||
"folder": "資料夾",
|
||||
|
||||
@@ -557,6 +557,7 @@ export interface AgentSkillItem {
|
||||
scope: AgentSkillScope
|
||||
layout: AgentSkillLayout
|
||||
path: string
|
||||
read_only: boolean
|
||||
}
|
||||
|
||||
export interface AgentSkillsListResult {
|
||||
|
||||
Reference in New Issue
Block a user