feat: 管理员可删除内置技能并管理技能内文件
新增内置技能删除与技能文件删除 API,允许管理员编辑/删除内置技能文件;技能设置页与文件预览弹窗同步支持删除操作。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
c9c2dc7ea8
commit
bf023d746a
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 { useI18n } from "@/core/i18n/hooks";
|
||||
import type { ValidateSkillUploadResponse } from "@/core/skills/api";
|
||||
import {
|
||||
useDeleteCustomSkill,
|
||||
import {
|
||||
useDeleteBuiltinSkill,
|
||||
useDeleteCustomSkill,
|
||||
useDuplicateSkill,
|
||||
useGenerateMissingSkillNameZh,
|
||||
useGenerateSkillNameZh,
|
||||
@@ -616,9 +617,10 @@ export function SkillRow({
|
||||
const { data: cardPublishSquares } = usePublishSquares();
|
||||
const setPinned = useSetSkillPinned();
|
||||
const setFeatured = useSetSkillFeatured();
|
||||
const setAlwaysOn = useSetSkillAlwaysOn();
|
||||
const { data: compression } = useSkillCompressionSettings();
|
||||
const deleteSkill = useDeleteCustomSkill();
|
||||
const setAlwaysOn = useSetSkillAlwaysOn();
|
||||
const { data: compression } = useSkillCompressionSettings();
|
||||
const deleteCustom = useDeleteCustomSkill();
|
||||
const deleteBuiltin = useDeleteBuiltinSkill();
|
||||
const duplicateSkillMutation = useDuplicateSkill();
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||
const [tagPickerOpen, setTagPickerOpen] = useState(false);
|
||||
@@ -634,8 +636,9 @@ export function SkillRow({
|
||||
const isOwned =
|
||||
isCustom && !isBuiltin && !!skill.owner_user_id && skill.owner_user_id === currentUserId;
|
||||
const isFromOthers = isCustom && !isBuiltin && !isOwned;
|
||||
// Admin can edit/delete/unpublish anyone's skill; non-admin only their own.
|
||||
const canManage = isCustom && (isOwned || isAdmin);
|
||||
// Admin can manage custom skills from any owner; non-admin only their own.
|
||||
const canManage = isCustom && (isOwned || isAdmin);
|
||||
const canDelete = isAdmin || isOwned;
|
||||
const canTogglePublish = canManage && !isBuiltin;
|
||||
// 常驻(免压缩):内置技能仅管理员可设;自定义技能其所有者或管理员可设(与后端一致)。
|
||||
const canSetAlwaysOn = isAdmin || isOwned;
|
||||
@@ -686,11 +689,15 @@ export function SkillRow({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
try {
|
||||
await deleteSkill.mutateAsync(skill.name);
|
||||
toast.success(t.settings.skills.deleteSuccess);
|
||||
setConfirmDeleteOpen(false);
|
||||
async function handleDelete() {
|
||||
try {
|
||||
if (isBuiltin) {
|
||||
await deleteBuiltin.mutateAsync(skill.name);
|
||||
} else {
|
||||
await deleteCustom.mutateAsync(skill.name);
|
||||
}
|
||||
toast.success(t.settings.skills.deleteSuccess);
|
||||
setConfirmDeleteOpen(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
@@ -918,7 +925,7 @@ export function SkillRow({
|
||||
>
|
||||
<TagsIcon className="size-3.5" />
|
||||
</Button>
|
||||
{canManage && (
|
||||
{canManage && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
@@ -939,12 +946,12 @@ export function SkillRow({
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
{canManage && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"h-8 w-8 shrink-0",
|
||||
{canManage && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"h-8 w-8 shrink-0",
|
||||
skill.pinned && "text-emerald-600 hover:text-emerald-700",
|
||||
)}
|
||||
disabled={setPinned.isPending}
|
||||
@@ -1011,11 +1018,11 @@ export function SkillRow({
|
||||
>
|
||||
<CopyIcon className="size-3.5" />
|
||||
</Button>
|
||||
{canManage && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
|
||||
{canDelete && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
|
||||
onClick={() => setConfirmDeleteOpen(true)}
|
||||
title={t.settings.skills.deleteAction}
|
||||
>
|
||||
@@ -1109,16 +1116,18 @@ export function SkillRow({
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setConfirmDeleteOpen(false)}
|
||||
disabled={deleteSkill.isPending}
|
||||
disabled={deleteCustom.isPending || deleteBuiltin.isPending}
|
||||
>
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => void handleDelete()}
|
||||
disabled={deleteSkill.isPending}
|
||||
>
|
||||
{deleteSkill.isPending ? t.common.loading : t.common.delete}
|
||||
disabled={deleteCustom.isPending || deleteBuiltin.isPending}
|
||||
>
|
||||
{deleteCustom.isPending || deleteBuiltin.isPending
|
||||
? t.common.loading
|
||||
: t.common.delete}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
DownloadIcon,
|
||||
Loader2,
|
||||
PencilIcon,
|
||||
Trash2Icon,
|
||||
XIcon,
|
||||
} from "lucide-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 { getSkillFileDownloadURL } from "@/core/skills/api";
|
||||
import {
|
||||
useDeleteSkillFile,
|
||||
useSaveSkillFile,
|
||||
useSkillFileContent,
|
||||
} from "@/core/skills/hooks";
|
||||
@@ -66,6 +68,7 @@ export function SkillFilePreviewDialog({
|
||||
open ? path : null,
|
||||
);
|
||||
const save = useSaveSkillFile();
|
||||
const remove = useDeleteSkillFile();
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
@@ -77,6 +80,8 @@ export function SkillFilePreviewDialog({
|
||||
}, [path, open]);
|
||||
|
||||
const fileEditable = !!data?.editable && treeEditable;
|
||||
const isRootSkillFile = path?.replace(/\\/g, "/").toLowerCase() === "skill.md";
|
||||
const fileDeletable = !!data && treeEditable && !isRootSkillFile;
|
||||
const isMarkdown = useMemo(() => {
|
||||
if (!path) return false;
|
||||
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() {
|
||||
if (!path) return;
|
||||
await navigator.clipboard.writeText(path);
|
||||
@@ -158,6 +175,18 @@ export function SkillFilePreviewDialog({
|
||||
{tx.filesEditAction}
|
||||
</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 && (
|
||||
<>
|
||||
<Button
|
||||
|
||||
@@ -545,10 +545,13 @@ export const enUS: Translations = {
|
||||
filesSaveAction: "Save",
|
||||
filesCancelEditAction: "Cancel",
|
||||
filesDownloadAction: "Download",
|
||||
filesCopyPathAction: "Copy path",
|
||||
filesDiscardConfirm: "Discard unsaved changes?",
|
||||
filesSaveSuccess: "File saved",
|
||||
filesReadOnlyHint: "Built-in skills are read-only.",
|
||||
filesCopyPathAction: "Copy path",
|
||||
filesDiscardConfirm: "Discard unsaved changes?",
|
||||
filesSaveSuccess: "File saved",
|
||||
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",
|
||||
stateSheet: {
|
||||
archivedTitle: "Archived skills",
|
||||
|
||||
@@ -475,10 +475,13 @@ export interface Translations {
|
||||
filesSaveAction: string;
|
||||
filesCancelEditAction: string;
|
||||
filesDownloadAction: string;
|
||||
filesCopyPathAction: string;
|
||||
filesDiscardConfirm: string;
|
||||
filesSaveSuccess: string;
|
||||
filesReadOnlyHint: string;
|
||||
filesCopyPathAction: string;
|
||||
filesDiscardConfirm: string;
|
||||
filesSaveSuccess: string;
|
||||
filesDeleteAction: string;
|
||||
filesDeleteConfirm: string;
|
||||
filesDeleteSuccess: string;
|
||||
filesReadOnlyHint: string;
|
||||
viewDetailAction: string;
|
||||
stateSheet: {
|
||||
archivedTitle: string;
|
||||
|
||||
@@ -524,8 +524,11 @@ export const zhCN: Translations = {
|
||||
filesDownloadAction: "下载",
|
||||
filesCopyPathAction: "复制路径",
|
||||
filesDiscardConfirm: "有未保存的修改,确定放弃吗?",
|
||||
filesSaveSuccess: "文件已保存",
|
||||
filesReadOnlyHint: "内置技能为只读,仅可查看。",
|
||||
filesSaveSuccess: "文件已保存",
|
||||
filesDeleteAction: "删除",
|
||||
filesDeleteConfirm: "确定删除“{path}”?该操作不可撤销。",
|
||||
filesDeleteSuccess: "文件已删除",
|
||||
filesReadOnlyHint: "仅管理员可以编辑或删除内置技能文件。",
|
||||
viewDetailAction: "查看技能详情",
|
||||
stateSheet: {
|
||||
archivedTitle: "归档的技能",
|
||||
|
||||
@@ -402,15 +402,26 @@ export async function getSkillContent(
|
||||
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(
|
||||
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!response.ok) {
|
||||
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 {
|
||||
name: string;
|
||||
@@ -479,7 +490,7 @@ export function getSkillFileDownloadURL(skillName: string, path: string): string
|
||||
);
|
||||
}
|
||||
|
||||
export async function saveSkillFile(
|
||||
export async function saveSkillFile(
|
||||
skillName: string,
|
||||
path: string,
|
||||
content: string,
|
||||
@@ -494,9 +505,22 @@ export async function saveSkillFile(
|
||||
);
|
||||
if (!response.ok) {
|
||||
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 {
|
||||
name: string;
|
||||
|
||||
@@ -5,8 +5,10 @@ import {
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
deleteCustomSkill,
|
||||
import {
|
||||
deleteBuiltinSkill,
|
||||
deleteCustomSkill,
|
||||
deleteSkillFile,
|
||||
duplicateSkill,
|
||||
enableSkill,
|
||||
generateMissingSkillNameZh,
|
||||
@@ -318,7 +320,7 @@ export function useSkillFileContent(skillName: string | null, path: string | nul
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveSkillFile() {
|
||||
export function useSaveSkillFile() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
@@ -339,5 +341,33 @@ export function useSaveSkillFile() {
|
||||
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)}")
|
||||
|
||||
|
||||
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):
|
||||
"""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,
|
||||
request: Request,
|
||||
) -> bool:
|
||||
"""Editing is limited to custom skills, and to either owner or admin."""
|
||||
if skill.category != SkillCategory.CUSTOM:
|
||||
return False
|
||||
"""Whether the caller can edit or delete a file within *skill*.
|
||||
|
||||
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):
|
||||
return True
|
||||
if skill.category != SkillCategory.CUSTOM:
|
||||
return False
|
||||
if record is None:
|
||||
return False
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
"""Walk *skill_dir* and build a SkillFileNode tree, depth/count capped.
|
||||
|
||||
@@ -2302,9 +2421,9 @@ async def download_skill_file(
|
||||
response_model=SkillFileSaveResponse,
|
||||
summary="Write Skill File (Text)",
|
||||
description=(
|
||||
"Replace the contents of a text file under a custom skill. Only the "
|
||||
"skill's owner or an admin may write; built-in (public) skills are "
|
||||
"read-only. Edits to ``SKILL.md`` re-validate its YAML frontmatter and "
|
||||
"Replace the contents of a text file under a skill. Administrators may "
|
||||
"write built-in and custom skills; custom-skill owners may write their "
|
||||
"own skills. Edits to ``SKILL.md`` re-validate its YAML frontmatter and "
|
||||
"refresh the system-prompt cache."
|
||||
),
|
||||
)
|
||||
@@ -2318,11 +2437,9 @@ async def save_skill_file(
|
||||
try:
|
||||
skill_name = _normalize_skill_name_param(skill_name)
|
||||
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)
|
||||
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)
|
||||
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()
|
||||
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
|
||||
if target.exists():
|
||||
try:
|
||||
@@ -2350,20 +2467,21 @@ async def save_skill_file(
|
||||
# SKILL.md edits must keep their frontmatter valid (name must match).
|
||||
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:
|
||||
storage.append_history(
|
||||
skill.name,
|
||||
{
|
||||
"action": "human_edit_file",
|
||||
"author": "human",
|
||||
"thread_id": None,
|
||||
"file_path": relative_posix,
|
||||
"prev_content": prev_content,
|
||||
"new_content": body.content,
|
||||
},
|
||||
)
|
||||
if skill.category == SkillCategory.CUSTOM:
|
||||
storage.append_history(
|
||||
skill.name,
|
||||
{
|
||||
"action": "human_edit_file",
|
||||
"author": "human",
|
||||
"thread_id": None,
|
||||
"file_path": relative_posix,
|
||||
"prev_content": prev_content,
|
||||
"new_content": body.content,
|
||||
},
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -2393,6 +2511,75 @@ async def save_skill_file(
|
||||
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(
|
||||
"/skills/{skill_name}",
|
||||
response_model=SkillResponse,
|
||||
|
||||
@@ -8,7 +8,7 @@ SKILL_MD_FILE = "SKILL.md"
|
||||
class SkillCategory(StrEnum):
|
||||
"""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.
|
||||
"""
|
||||
|
||||
|
||||
Reference in new issue
Block a user