Merge branch 'master-nj' into 'master'

feat: 管理员可删除内置技能并管理技能内文件

See merge request knowledge-base-bj/deerflow-code!21
This commit is contained in:
董宏凯 committed 2026-08-12 07:08:19 +00:00
commit bc82072710
9 files changed
+361 -73

No files matched your search

@@ -71,8 +71,9 @@ import { useTheme } from "@/contexts/ThemeContext";
import { getStoredAuth } from "@/core/auth"; import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import type { ValidateSkillUploadResponse } from "@/core/skills/api"; import type { ValidateSkillUploadResponse } from "@/core/skills/api";
import { import {
useDeleteCustomSkill, useDeleteBuiltinSkill,
useDeleteCustomSkill,
useDuplicateSkill, useDuplicateSkill,
useGenerateMissingSkillNameZh, useGenerateMissingSkillNameZh,
useGenerateSkillNameZh, useGenerateSkillNameZh,
@@ -616,9 +617,10 @@ export function SkillRow({
const { data: cardPublishSquares } = usePublishSquares(); const { data: cardPublishSquares } = usePublishSquares();
const setPinned = useSetSkillPinned(); const setPinned = useSetSkillPinned();
const setFeatured = useSetSkillFeatured(); const setFeatured = useSetSkillFeatured();
const setAlwaysOn = useSetSkillAlwaysOn(); const setAlwaysOn = useSetSkillAlwaysOn();
const { data: compression } = useSkillCompressionSettings(); const { data: compression } = useSkillCompressionSettings();
const deleteSkill = useDeleteCustomSkill(); const deleteCustom = useDeleteCustomSkill();
const deleteBuiltin = useDeleteBuiltinSkill();
const duplicateSkillMutation = useDuplicateSkill(); const duplicateSkillMutation = useDuplicateSkill();
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [tagPickerOpen, setTagPickerOpen] = useState(false); const [tagPickerOpen, setTagPickerOpen] = useState(false);
@@ -634,8 +636,9 @@ export function SkillRow({
const isOwned = const isOwned =
isCustom && !isBuiltin && !!skill.owner_user_id && skill.owner_user_id === currentUserId; isCustom && !isBuiltin && !!skill.owner_user_id && skill.owner_user_id === currentUserId;
const isFromOthers = isCustom && !isBuiltin && !isOwned; const isFromOthers = isCustom && !isBuiltin && !isOwned;
// Admin can edit/delete/unpublish anyone's skill; non-admin only their own. // Admin can manage custom skills from any owner; non-admin only their own.
const canManage = isCustom && (isOwned || isAdmin); const canManage = isCustom && (isOwned || isAdmin);
const canDelete = isAdmin || isOwned;
const canTogglePublish = canManage && !isBuiltin; const canTogglePublish = canManage && !isBuiltin;
// 常驻(免压缩):内置技能仅管理员可设;自定义技能其所有者或管理员可设(与后端一致)。 // 常驻(免压缩):内置技能仅管理员可设;自定义技能其所有者或管理员可设(与后端一致)。
const canSetAlwaysOn = isAdmin || isOwned; const canSetAlwaysOn = isAdmin || isOwned;
@@ -686,11 +689,15 @@ export function SkillRow({
} }
} }
async function handleDelete() { async function handleDelete() {
try { try {
await deleteSkill.mutateAsync(skill.name); if (isBuiltin) {
toast.success(t.settings.skills.deleteSuccess); await deleteBuiltin.mutateAsync(skill.name);
setConfirmDeleteOpen(false); } else {
await deleteCustom.mutateAsync(skill.name);
}
toast.success(t.settings.skills.deleteSuccess);
setConfirmDeleteOpen(false);
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : String(err)); toast.error(err instanceof Error ? err.message : String(err));
} }
@@ -918,7 +925,7 @@ export function SkillRow({
> >
<TagsIcon className="size-3.5" /> <TagsIcon className="size-3.5" />
</Button> </Button>
{canManage && ( {canManage && (
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
@@ -939,12 +946,12 @@ export function SkillRow({
/> />
</Button> </Button>
)} )}
{canManage && ( {canManage && (
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
className={cn( className={cn(
"h-8 w-8 shrink-0", "h-8 w-8 shrink-0",
skill.pinned && "text-emerald-600 hover:text-emerald-700", skill.pinned && "text-emerald-600 hover:text-emerald-700",
)} )}
disabled={setPinned.isPending} disabled={setPinned.isPending}
@@ -1011,11 +1018,11 @@ export function SkillRow({
> >
<CopyIcon className="size-3.5" /> <CopyIcon className="size-3.5" />
</Button> </Button>
{canManage && ( {canDelete && (
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
className="text-destructive hover:text-destructive h-8 w-8 shrink-0" className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
onClick={() => setConfirmDeleteOpen(true)} onClick={() => setConfirmDeleteOpen(true)}
title={t.settings.skills.deleteAction} title={t.settings.skills.deleteAction}
> >
@@ -1109,16 +1116,18 @@ export function SkillRow({
<Button <Button
variant="outline" variant="outline"
onClick={() => setConfirmDeleteOpen(false)} onClick={() => setConfirmDeleteOpen(false)}
disabled={deleteSkill.isPending} disabled={deleteCustom.isPending || deleteBuiltin.isPending}
> >
{t.common.cancel} {t.common.cancel}
</Button> </Button>
<Button <Button
variant="destructive" variant="destructive"
onClick={() => void handleDelete()} onClick={() => void handleDelete()}
disabled={deleteSkill.isPending} disabled={deleteCustom.isPending || deleteBuiltin.isPending}
> >
{deleteSkill.isPending ? t.common.loading : t.common.delete} {deleteCustom.isPending || deleteBuiltin.isPending
? t.common.loading
: t.common.delete}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -5,6 +5,7 @@ import {
DownloadIcon, DownloadIcon,
Loader2, Loader2,
PencilIcon, PencilIcon,
Trash2Icon,
XIcon, XIcon,
} from "lucide-react"; } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
@@ -22,6 +23,7 @@ import { Textarea } from "@/components/ui/textarea";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content"; import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { getSkillFileDownloadURL } from "@/core/skills/api"; import { getSkillFileDownloadURL } from "@/core/skills/api";
import { import {
useDeleteSkillFile,
useSaveSkillFile, useSaveSkillFile,
useSkillFileContent, useSkillFileContent,
} from "@/core/skills/hooks"; } from "@/core/skills/hooks";
@@ -66,6 +68,7 @@ export function SkillFilePreviewDialog({
open ? path : null, open ? path : null,
); );
const save = useSaveSkillFile(); const save = useSaveSkillFile();
const remove = useDeleteSkillFile();
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
@@ -77,6 +80,8 @@ export function SkillFilePreviewDialog({
}, [path, open]); }, [path, open]);
const fileEditable = !!data?.editable && treeEditable; const fileEditable = !!data?.editable && treeEditable;
const isRootSkillFile = path?.replace(/\\/g, "/").toLowerCase() === "skill.md";
const fileDeletable = !!data && treeEditable && !isRootSkillFile;
const isMarkdown = useMemo(() => { const isMarkdown = useMemo(() => {
if (!path) return false; if (!path) return false;
const lower = path.toLowerCase(); const lower = path.toLowerCase();
@@ -115,6 +120,18 @@ export function SkillFilePreviewDialog({
} }
} }
async function handleDelete() {
if (!skillName || !path) return;
if (!window.confirm(tx.filesDeleteConfirm.replace("{path}", path))) return;
try {
await remove.mutateAsync({ skillName, path });
toast.success(tx.filesDeleteSuccess);
onOpenChange(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleCopyPath() { async function handleCopyPath() {
if (!path) return; if (!path) return;
await navigator.clipboard.writeText(path); await navigator.clipboard.writeText(path);
@@ -158,6 +175,18 @@ export function SkillFilePreviewDialog({
{tx.filesEditAction} {tx.filesEditAction}
</Button> </Button>
)} )}
{fileDeletable && !editing && (
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-xs text-destructive hover:text-destructive"
onClick={() => void handleDelete()}
disabled={remove.isPending}
>
{remove.isPending ? <Loader2 className="size-3.5 animate-spin" /> : <Trash2Icon className="size-3.5" />}
{tx.filesDeleteAction}
</Button>
)}
{editing && ( {editing && (
<> <>
<Button <Button
+7 -4
View File
@@ -545,10 +545,13 @@ export const enUS: Translations = {
filesSaveAction: "Save", filesSaveAction: "Save",
filesCancelEditAction: "Cancel", filesCancelEditAction: "Cancel",
filesDownloadAction: "Download", filesDownloadAction: "Download",
filesCopyPathAction: "Copy path", filesCopyPathAction: "Copy path",
filesDiscardConfirm: "Discard unsaved changes?", filesDiscardConfirm: "Discard unsaved changes?",
filesSaveSuccess: "File saved", filesSaveSuccess: "File saved",
filesReadOnlyHint: "Built-in skills are read-only.", filesDeleteAction: "Delete",
filesDeleteConfirm: "Delete \"{path}\"? This cannot be undone.",
filesDeleteSuccess: "File deleted",
filesReadOnlyHint: "Only administrators can edit or delete built-in skill files.",
viewDetailAction: "View skill details", viewDetailAction: "View skill details",
stateSheet: { stateSheet: {
archivedTitle: "Archived skills", archivedTitle: "Archived skills",
+7 -4
View File
@@ -475,10 +475,13 @@ export interface Translations {
filesSaveAction: string; filesSaveAction: string;
filesCancelEditAction: string; filesCancelEditAction: string;
filesDownloadAction: string; filesDownloadAction: string;
filesCopyPathAction: string; filesCopyPathAction: string;
filesDiscardConfirm: string; filesDiscardConfirm: string;
filesSaveSuccess: string; filesSaveSuccess: string;
filesReadOnlyHint: string; filesDeleteAction: string;
filesDeleteConfirm: string;
filesDeleteSuccess: string;
filesReadOnlyHint: string;
viewDetailAction: string; viewDetailAction: string;
stateSheet: { stateSheet: {
archivedTitle: string; archivedTitle: string;
+5 -2
View File
@@ -524,8 +524,11 @@ export const zhCN: Translations = {
filesDownloadAction: "下载", filesDownloadAction: "下载",
filesCopyPathAction: "复制路径", filesCopyPathAction: "复制路径",
filesDiscardConfirm: "有未保存的修改,确定放弃吗?", filesDiscardConfirm: "有未保存的修改,确定放弃吗?",
filesSaveSuccess: "文件已保存", filesSaveSuccess: "文件已保存",
filesReadOnlyHint: "内置技能为只读,仅可查看。", filesDeleteAction: "删除",
filesDeleteConfirm: "确定删除“{path}”?该操作不可撤销。",
filesDeleteSuccess: "文件已删除",
filesReadOnlyHint: "仅管理员可以编辑或删除内置技能文件。",
viewDetailAction: "查看技能详情", viewDetailAction: "查看技能详情",
stateSheet: { stateSheet: {
archivedTitle: "归档的技能", archivedTitle: "归档的技能",
+31 -7
View File
@@ -402,15 +402,26 @@ export async function getSkillContent(
return response.json() as Promise<SkillContentResponse>; return response.json() as Promise<SkillContentResponse>;
} }
export async function deleteCustomSkill(skillName: string): Promise<void> { export async function deleteCustomSkill(skillName: string): Promise<void> {
const response = await apiFetch( const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}`, `${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}`,
{ method: "DELETE" }, { method: "DELETE" },
); );
if (!response.ok) { if (!response.ok) {
throw await parseDetailedError(response, "Failed to delete skill"); throw await parseDetailedError(response, "Failed to delete skill");
} }
} }
/** Delete a built-in skill. The backend permits this only for administrators. */
export async function deleteBuiltinSkill(skillName: string): Promise<void> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}`,
{ method: "DELETE" },
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to delete built-in skill");
}
}
export interface SkillFileNode { export interface SkillFileNode {
name: string; name: string;
@@ -479,7 +490,7 @@ export function getSkillFileDownloadURL(skillName: string, path: string): string
); );
} }
export async function saveSkillFile( export async function saveSkillFile(
skillName: string, skillName: string,
path: string, path: string,
content: string, content: string,
@@ -494,9 +505,22 @@ export async function saveSkillFile(
); );
if (!response.ok) { if (!response.ok) {
throw await parseDetailedError(response, "Failed to save skill file"); throw await parseDetailedError(response, "Failed to save skill file");
} }
return response.json() as Promise<SkillFileSaveResponse>; return response.json() as Promise<SkillFileSaveResponse>;
} }
export async function deleteSkillFile(
skillName: string,
path: string,
): Promise<void> {
const url =
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/files/content` +
`?path=${encodeURIComponent(path)}`;
const response = await apiFetch(url, { method: "DELETE" });
if (!response.ok) {
throw await parseDetailedError(response, "Failed to delete skill file");
}
}
export interface SkillNameZhResponse { export interface SkillNameZhResponse {
name: string; name: string;
+35 -5
View File
@@ -5,8 +5,10 @@ import {
useQueryClient, useQueryClient,
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { import {
deleteCustomSkill, deleteBuiltinSkill,
deleteCustomSkill,
deleteSkillFile,
duplicateSkill, duplicateSkill,
enableSkill, enableSkill,
generateMissingSkillNameZh, generateMissingSkillNameZh,
@@ -318,7 +320,7 @@ export function useSkillFileContent(skillName: string | null, path: string | nul
}); });
} }
export function useSaveSkillFile() { export function useSaveSkillFile() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ mutationFn: ({
@@ -339,5 +341,33 @@ export function useSaveSkillFile() {
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] }); void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
} }
}, },
}); });
} }
export function useDeleteSkillFile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
skillName,
path,
}: {
skillName: string;
path: string;
}) => deleteSkillFile(skillName, path),
onSuccess: (_data, { skillName, path }) => {
void queryClient.invalidateQueries({ queryKey: ["skill-files", skillName] });
void queryClient.removeQueries({ queryKey: ["skill-file", skillName, path] });
},
});
}
export function useDeleteBuiltinSkill() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteBuiltinSkill,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
void queryClient.invalidateQueries({ queryKey: ["admin-skills"] });
},
});
}
@@ -1622,6 +1622,102 @@ async def delete_custom_skill(
raise HTTPException(status_code=500, detail=f"Failed to delete custom skill: {str(e)}") raise HTTPException(status_code=500, detail=f"Failed to delete custom skill: {str(e)}")
def _remove_skill_state_config(skill_name: str) -> None:
"""Remove a deleted skill's enabled/display override from extensions config."""
extensions_config = get_extensions_config()
if skill_name not in extensions_config.skills:
return
config_path = ExtensionsConfig.resolve_config_path()
if config_path is None:
config_path = Path.cwd().parent / "extensions_config.json"
extensions_config.skills.pop(skill_name, None)
config_data = _dump_extensions_config(extensions_config)
tmp_path: Path | None = None
try:
with tempfile.NamedTemporaryFile(
"w",
encoding="utf-8",
delete=False,
dir=str(config_path.parent),
) as tmp_file:
json.dump(config_data, tmp_file, indent=2)
tmp_path = Path(tmp_file.name)
os.replace(tmp_path, config_path)
finally:
if tmp_path is not None and tmp_path.exists():
tmp_path.unlink()
reload_extensions_config()
@router.delete(
"/skills/{skill_name}",
summary="Delete Built-in Skill",
description=(
"Administrators may permanently delete a built-in skill. The skill is "
"removed from enabled configuration and detached from agents that used it."
),
)
async def delete_builtin_skill(
skill_name: str,
request: Request,
config: AppConfig = Depends(get_config),
skill_store: SkillStore = Depends(get_skill_store),
agent_store=Depends(get_agent_store),
) -> dict[str, bool]:
try:
if not _is_admin_user(request):
raise HTTPException(status_code=403, detail="Only administrators can delete built-in skills")
skill_name = _normalize_skill_name_param(skill_name)
skill = _load_skill_or_404(skill_name, config)
if skill.category != SkillCategory.PUBLIC:
raise HTTPException(status_code=400, detail="Use the custom skill deletion endpoint for a custom skill")
storage = get_or_new_skill_storage(app_config=config)
public_root = (storage.get_skills_root_path() / SkillCategory.PUBLIC.value).resolve()
skill_dir = skill.skill_file.parent.resolve()
if skill_dir == public_root or public_root not in skill_dir.parents:
raise ValueError(f"Refusing to delete built-in skill outside the public skills directory: {skill.name}")
_remove_skill_state_config(skill.name)
shutil.rmtree(skill_dir)
try:
from deerflow.skills.usage import forget
forget(skill.name)
except Exception:
pass
await skill_store.delete_admin(skill.name)
try:
await get_tag_store(request).unassign_all("skill", skill.name)
except Exception: # noqa: BLE001 -- tag cleanup must not block deletion
pass
await cascade_skill_unpublished_or_deleted(
skill.name,
actor_user_id=_current_user_id(request),
operation="delete",
skill_owner_user_id=None,
agent_store=agent_store,
)
await refresh_skills_system_prompt_cache_async()
logger.info("Administrator deleted built-in skill %s", skill.name)
return {"success": True}
except HTTPException:
raise
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error("Failed to delete built-in skill %s: %s", skill_name, e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to delete built-in skill: {str(e)}")
class SkillPublishRequest(BaseModel): class SkillPublishRequest(BaseModel):
"""Request body for toggling a custom skill's published flag.""" """Request body for toggling a custom skill's published flag."""
@@ -2080,11 +2176,16 @@ def _caller_can_edit_skill_files(
record: dict[str, Any] | None, record: dict[str, Any] | None,
request: Request, request: Request,
) -> bool: ) -> bool:
"""Editing is limited to custom skills, and to either owner or admin.""" """Whether the caller can edit or delete a file within *skill*.
if skill.category != SkillCategory.CUSTOM:
return False Built-in skills are deployment-owned, so only administrators may change
their files. Custom skills remain editable by their owner or by an
administrator.
"""
if _is_admin_user(request): if _is_admin_user(request):
return True return True
if skill.category != SkillCategory.CUSTOM:
return False
if record is None: if record is None:
return False return False
return record.get("owner_user_id") == _current_user_id(request) return record.get("owner_user_id") == _current_user_id(request)
@@ -2125,6 +2226,24 @@ def _is_text_file(path: Path) -> bool:
return path.suffix.lower() in _SKILL_FILE_TEXT_EXTS return path.suffix.lower() in _SKILL_FILE_TEXT_EXTS
def _write_skill_text_file_atomically(target: Path, content: str) -> None:
"""Replace an already-validated skill file without exposing partial text."""
tmp_path: Path | None = None
try:
with tempfile.NamedTemporaryFile(
"w",
encoding="utf-8",
delete=False,
dir=str(target.parent),
) as tmp_file:
tmp_file.write(content)
tmp_path = Path(tmp_file.name)
os.replace(tmp_path, target)
finally:
if tmp_path is not None and tmp_path.exists():
tmp_path.unlink()
def _build_skill_file_tree(skill_dir: Path) -> SkillFileNode: def _build_skill_file_tree(skill_dir: Path) -> SkillFileNode:
"""Walk *skill_dir* and build a SkillFileNode tree, depth/count capped. """Walk *skill_dir* and build a SkillFileNode tree, depth/count capped.
@@ -2302,9 +2421,9 @@ async def download_skill_file(
response_model=SkillFileSaveResponse, response_model=SkillFileSaveResponse,
summary="Write Skill File (Text)", summary="Write Skill File (Text)",
description=( description=(
"Replace the contents of a text file under a custom skill. Only the " "Replace the contents of a text file under a skill. Administrators may "
"skill's owner or an admin may write; built-in (public) skills are " "write built-in and custom skills; custom-skill owners may write their "
"read-only. Edits to ``SKILL.md`` re-validate its YAML frontmatter and " "own skills. Edits to ``SKILL.md`` re-validate its YAML frontmatter and "
"refresh the system-prompt cache." "refresh the system-prompt cache."
), ),
) )
@@ -2318,11 +2437,9 @@ async def save_skill_file(
try: try:
skill_name = _normalize_skill_name_param(skill_name) skill_name = _normalize_skill_name_param(skill_name)
skill = _load_skill_or_404(skill_name, config) skill = _load_skill_or_404(skill_name, config)
if skill.category != SkillCategory.CUSTOM:
raise HTTPException(status_code=403, detail="Built-in skills are read-only")
record = await skill_store.get_any(skill.name) record = await skill_store.get_any(skill.name)
if not _caller_can_edit_skill_files(skill, record, request): if not _caller_can_edit_skill_files(skill, record, request):
raise HTTPException(status_code=403, detail="Only the skill owner or an admin can edit files in this skill") raise HTTPException(status_code=403, detail="Only an administrator or the custom skill owner can edit files in this skill")
target = _resolve_skill_relative_path(skill, body.path) target = _resolve_skill_relative_path(skill, body.path)
if not target.exists(): if not target.exists():
@@ -2338,7 +2455,7 @@ async def save_skill_file(
relative_posix = target.relative_to(skill.skill_file.parent.resolve()).as_posix() relative_posix = target.relative_to(skill.skill_file.parent.resolve()).as_posix()
storage = get_or_new_skill_storage(app_config=config) storage = get_or_new_skill_storage(app_config=config)
is_skill_md = relative_posix == SKILL_MD_FILE is_skill_md = relative_posix.casefold() == SKILL_MD_FILE.casefold()
prev_content: str | None = None prev_content: str | None = None
if target.exists(): if target.exists():
try: try:
@@ -2350,20 +2467,21 @@ async def save_skill_file(
# SKILL.md edits must keep their frontmatter valid (name must match). # SKILL.md edits must keep their frontmatter valid (name must match).
storage.validate_skill_markdown_content(skill.name, body.content) storage.validate_skill_markdown_content(skill.name, body.content)
storage.write_custom_skill(skill.name, relative_posix, body.content) _write_skill_text_file_atomically(target, body.content)
try: try:
storage.append_history( if skill.category == SkillCategory.CUSTOM:
skill.name, storage.append_history(
{ skill.name,
"action": "human_edit_file", {
"author": "human", "action": "human_edit_file",
"thread_id": None, "author": "human",
"file_path": relative_posix, "thread_id": None,
"prev_content": prev_content, "file_path": relative_posix,
"new_content": body.content, "prev_content": prev_content,
}, "new_content": body.content,
) },
)
except Exception as history_err: # noqa: BLE001 — history is best-effort except Exception as history_err: # noqa: BLE001 — history is best-effort
logger.warning("Failed to append edit history for %s/%s: %s", skill.name, relative_posix, history_err) logger.warning("Failed to append edit history for %s/%s: %s", skill.name, relative_posix, history_err)
@@ -2393,6 +2511,75 @@ async def save_skill_file(
raise HTTPException(status_code=500, detail=f"Failed to save skill file: {e}") raise HTTPException(status_code=500, detail=f"Failed to save skill file: {e}")
@router.delete(
"/skills/{skill_name}/files/content",
status_code=204,
summary="Delete Skill File",
description=(
"Delete one non-SKILL.md file under a skill. Administrators may delete "
"files from built-in and custom skills; custom-skill owners may delete "
"files from their own skills. The root ``SKILL.md`` manifest cannot be "
"deleted because it defines the skill."
),
)
async def delete_skill_file(
skill_name: str,
request: Request,
path: str = Query(..., description="File path relative to the skill directory"),
config: AppConfig = Depends(get_config),
skill_store: SkillStore = Depends(get_skill_store),
) -> None:
try:
skill_name = _normalize_skill_name_param(skill_name)
skill = _load_skill_or_404(skill_name, config)
record = await skill_store.get_any(skill.name)
if not _caller_can_edit_skill_files(skill, record, request):
raise HTTPException(status_code=403, detail="Only an administrator or the custom skill owner can delete files in this skill")
target = _resolve_skill_relative_path(skill, path)
if not target.exists() or not target.is_file():
raise HTTPException(status_code=404, detail=f"File '{path}' not found in skill '{skill_name}'")
relative_posix = target.relative_to(skill.skill_file.parent.resolve()).as_posix()
if relative_posix.casefold() == SKILL_MD_FILE.casefold():
raise HTTPException(status_code=400, detail="SKILL.md defines the skill and cannot be deleted")
prev_content: str | None = None
if _is_text_file(target):
try:
prev_content = target.read_text(encoding="utf-8")
except UnicodeDecodeError:
pass
target.unlink()
if skill.category == SkillCategory.CUSTOM:
storage = get_or_new_skill_storage(app_config=config)
try:
storage.append_history(
skill.name,
{
"action": "human_delete_file",
"author": "human",
"thread_id": None,
"file_path": relative_posix,
"prev_content": prev_content,
},
)
except Exception as history_err: # noqa: BLE001
logger.warning("Failed to append deletion history for %s/%s: %s", skill.name, relative_posix, history_err)
else:
logger.info("Administrator deleted built-in skill file %s/%s", skill.name, relative_posix)
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to delete skill file %s/%s: %s", skill_name, path, e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to delete skill file: {e}")
@router.put( @router.put(
"/skills/{skill_name}", "/skills/{skill_name}",
response_model=SkillResponse, response_model=SkillResponse,
@@ -8,7 +8,7 @@ SKILL_MD_FILE = "SKILL.md"
class SkillCategory(StrEnum): class SkillCategory(StrEnum):
"""Source category for a skill. """Source category for a skill.
- ``PUBLIC``: built-in skill bundled with the platform, read-only. - ``PUBLIC``: built-in skill bundled with the platform; administrator-managed.
- ``CUSTOM``: user-authored skill that can be edited or deleted. - ``CUSTOM``: user-authored skill that can be edited or deleted.
""" """