feat: 管理员可删除内置技能并管理技能内文件
新增内置技能删除与技能文件删除 API,允许管理员编辑/删除内置技能文件;技能设置页与文件预览弹窗同步支持删除操作。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
c9c2dc7ea8
commit
bf023d746a
9 files changed
+320
-32
No files matched your search
@@ -72,6 +72,7 @@ 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 {
|
||||||
|
useDeleteBuiltinSkill,
|
||||||
useDeleteCustomSkill,
|
useDeleteCustomSkill,
|
||||||
useDuplicateSkill,
|
useDuplicateSkill,
|
||||||
useGenerateMissingSkillNameZh,
|
useGenerateMissingSkillNameZh,
|
||||||
@@ -618,7 +619,8 @@ export function SkillRow({
|
|||||||
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;
|
||||||
@@ -688,7 +691,11 @@ export function SkillRow({
|
|||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
try {
|
try {
|
||||||
await deleteSkill.mutateAsync(skill.name);
|
if (isBuiltin) {
|
||||||
|
await deleteBuiltin.mutateAsync(skill.name);
|
||||||
|
} else {
|
||||||
|
await deleteCustom.mutateAsync(skill.name);
|
||||||
|
}
|
||||||
toast.success(t.settings.skills.deleteSuccess);
|
toast.success(t.settings.skills.deleteSuccess);
|
||||||
setConfirmDeleteOpen(false);
|
setConfirmDeleteOpen(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1011,7 +1018,7 @@ 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"
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -548,7 +548,10 @@ export const enUS: Translations = {
|
|||||||
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",
|
||||||
|
|||||||
@@ -478,6 +478,9 @@ export interface Translations {
|
|||||||
filesCopyPathAction: string;
|
filesCopyPathAction: string;
|
||||||
filesDiscardConfirm: string;
|
filesDiscardConfirm: string;
|
||||||
filesSaveSuccess: string;
|
filesSaveSuccess: string;
|
||||||
|
filesDeleteAction: string;
|
||||||
|
filesDeleteConfirm: string;
|
||||||
|
filesDeleteSuccess: string;
|
||||||
filesReadOnlyHint: string;
|
filesReadOnlyHint: string;
|
||||||
viewDetailAction: string;
|
viewDetailAction: string;
|
||||||
stateSheet: {
|
stateSheet: {
|
||||||
|
|||||||
@@ -525,7 +525,10 @@ export const zhCN: Translations = {
|
|||||||
filesCopyPathAction: "复制路径",
|
filesCopyPathAction: "复制路径",
|
||||||
filesDiscardConfirm: "有未保存的修改,确定放弃吗?",
|
filesDiscardConfirm: "有未保存的修改,确定放弃吗?",
|
||||||
filesSaveSuccess: "文件已保存",
|
filesSaveSuccess: "文件已保存",
|
||||||
filesReadOnlyHint: "内置技能为只读,仅可查看。",
|
filesDeleteAction: "删除",
|
||||||
|
filesDeleteConfirm: "确定删除“{path}”?该操作不可撤销。",
|
||||||
|
filesDeleteSuccess: "文件已删除",
|
||||||
|
filesReadOnlyHint: "仅管理员可以编辑或删除内置技能文件。",
|
||||||
viewDetailAction: "查看技能详情",
|
viewDetailAction: "查看技能详情",
|
||||||
stateSheet: {
|
stateSheet: {
|
||||||
archivedTitle: "归档的技能",
|
archivedTitle: "归档的技能",
|
||||||
|
|||||||
@@ -412,6 +412,17 @@ export async function deleteCustomSkill(skillName: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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;
|
||||||
type: "file" | "dir";
|
type: "file" | "dir";
|
||||||
@@ -498,6 +509,19 @@ export async function saveSkillFile(
|
|||||||
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;
|
||||||
name_zh: string | null;
|
name_zh: string | null;
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import {
|
|||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
deleteBuiltinSkill,
|
||||||
deleteCustomSkill,
|
deleteCustomSkill,
|
||||||
|
deleteSkillFile,
|
||||||
duplicateSkill,
|
duplicateSkill,
|
||||||
enableSkill,
|
enableSkill,
|
||||||
generateMissingSkillNameZh,
|
generateMissingSkillNameZh,
|
||||||
@@ -341,3 +343,31 @@ export function useSaveSkillFile() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user