diff --git a/frontend-web/src/components/workspace/settings/skill-settings-page.tsx b/frontend-web/src/components/workspace/settings/skill-settings-page.tsx
index 4f6a3e7d..42f92a24 100644
--- a/frontend-web/src/components/workspace/settings/skill-settings-page.tsx
+++ b/frontend-web/src/components/workspace/settings/skill-settings-page.tsx
@@ -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({
>
- {canManage && (
+ {canManage && (
)}
- {canManage && (
-
- {canManage && (
-
)}
+ {fileDeletable && !editing && (
+ void handleDelete()}
+ disabled={remove.isPending}
+ >
+ {remove.isPending ? : }
+ {tx.filesDeleteAction}
+
+ )}
{editing && (
<>
;
}
-export async function deleteCustomSkill(skillName: string): Promise {
+export async function deleteCustomSkill(skillName: string): Promise {
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 {
+ 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;
-}
+ }
+ return response.json() as Promise;
+}
+
+export async function deleteSkillFile(
+ skillName: string,
+ path: string,
+): Promise {
+ 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;
diff --git a/frontend-web/src/core/skills/hooks.ts b/frontend-web/src/core/skills/hooks.ts
index 4460242e..ad68d7d5 100644
--- a/frontend-web/src/core/skills/hooks.ts
+++ b/frontend-web/src/core/skills/hooks.ts
@@ -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"] });
+ },
+ });
+}
diff --git a/offline-backend-20260512/backend/app/gateway/routers/skills.py b/offline-backend-20260512/backend/app/gateway/routers/skills.py
index 47b14483..07d3f418 100644
--- a/offline-backend-20260512/backend/app/gateway/routers/skills.py
+++ b/offline-backend-20260512/backend/app/gateway/routers/skills.py
@@ -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,
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/skills/types.py b/offline-backend-20260512/backend/packages/harness/deerflow/skills/types.py
index 0e5b9baf..fb964198 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/skills/types.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/skills/types.py
@@ -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.
"""