+
- {dialog.conflictPublic.replace("{name}", validation.skill_name)}
+ {(adminCanOverwritePublic ? dialog.conflictPublicAdminHint : dialog.conflictPublic).replace("{name}", validation.skill_name)}
)}
diff --git a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx
index b5c96f58..e0ccb498 100644
--- a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx
+++ b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx
@@ -1,6 +1,6 @@
"use client";
-import { BotIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon } from "lucide-react";
+import { BotIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
@@ -10,11 +10,13 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
+import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
export function WorkspaceNavChatList() {
const { t } = useI18n();
const pathname = usePathname();
+ const isAdmin = getStoredAuth()?.system_role === "admin";
return (
@@ -70,6 +72,19 @@ export function WorkspaceNavChatList() {
+ {isAdmin && (
+
+
+
+
+ {t.sidebar.users}
+
+
+
+ )}
);
diff --git a/frontend-web/src/core/auth/api.ts b/frontend-web/src/core/auth/api.ts
index 4a81b8de..94a64bdb 100644
--- a/frontend-web/src/core/auth/api.ts
+++ b/frontend-web/src/core/auth/api.ts
@@ -3,6 +3,20 @@ import { getBackendBaseURL } from "@/core/config";
import type { LoginByUsernameResponse } from "./index";
+export interface UserListItem {
+ id: string;
+ email: string;
+ system_role: "admin" | "user";
+ skill_count: number;
+ agent_count: number;
+}
+
+export async function listAllUsers(): Promise
{
+ const res = await apiFetch(`${getBackendBaseURL()}/api/v1/auth/users`);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ return res.json() as Promise;
+}
+
export async function loginByUsername(
username: string,
): Promise {
diff --git a/frontend-web/src/core/auth/hooks.ts b/frontend-web/src/core/auth/hooks.ts
new file mode 100644
index 00000000..3bc5b40f
--- /dev/null
+++ b/frontend-web/src/core/auth/hooks.ts
@@ -0,0 +1,11 @@
+import { useQuery } from "@tanstack/react-query";
+
+import { listAllUsers } from "./api";
+
+export function useAllUsers(enabled: boolean) {
+ return useQuery({
+ queryKey: ["admin", "users"],
+ queryFn: listAllUsers,
+ enabled,
+ });
+}
diff --git a/frontend-web/src/core/i18n/locales/en-US.ts b/frontend-web/src/core/i18n/locales/en-US.ts
index d690ae6e..e808cb97 100644
--- a/frontend-web/src/core/i18n/locales/en-US.ts
+++ b/frontend-web/src/core/i18n/locales/en-US.ts
@@ -172,6 +172,7 @@ export const enUS: Translations = {
recentChats: "Recent chats",
demoChats: "Demo chats",
agents: "Agents",
+ users: "Users",
},
// Agents
@@ -471,9 +472,14 @@ export const enUS: Translations = {
"A custom skill named '{name}' is already installed. Use 'Replace existing' to overwrite (its enabled state will be kept).",
conflictPublic:
"A built-in public skill named '{name}' already exists and cannot be overwritten.",
+ conflictPublicAdminHint:
+ "A built-in public skill named '{name}' exists. Switch to 'Built-in' and click 'Replace existing' to overwrite.",
installSuccess: "Skill '{name}' installed.",
installSuccessReplaced: "Skill '{name}' replaced.",
rejectInvalidExt: "Only .zip or .skill files are accepted.",
+ targetCategoryLabel: "Install to",
+ categoryCustom: "Custom skill",
+ categoryPublic: "Built-in (admin)",
},
},
skillEvolution: {
@@ -554,6 +560,19 @@ export const enUS: Translations = {
},
},
+ adminUsers: {
+ title: "User Management",
+ description: "View all registered users and their associated skill and agent counts.",
+ email: "Email",
+ role: "Role",
+ userId: "User ID",
+ skillCount: "Skills",
+ agentCount: "Agents",
+ roleAdmin: "Admin",
+ roleUser: "User",
+ empty: "No users found",
+ },
+
notifications: {
title: "Notifications",
bellAria: "Open notifications",
diff --git a/frontend-web/src/core/i18n/locales/types.ts b/frontend-web/src/core/i18n/locales/types.ts
index f5d7a088..f3174e8a 100644
--- a/frontend-web/src/core/i18n/locales/types.ts
+++ b/frontend-web/src/core/i18n/locales/types.ts
@@ -125,6 +125,7 @@ export interface Translations {
chats: string;
demoChats: string;
agents: string;
+ users: string;
};
// Agents
@@ -397,9 +398,13 @@ export interface Translations {
warningsLabel: string;
conflictCustom: string;
conflictPublic: string;
+ conflictPublicAdminHint: string;
installSuccess: string;
installSuccessReplaced: string;
rejectInvalidExt: string;
+ targetCategoryLabel: string;
+ categoryCustom: string;
+ categoryPublic: string;
};
};
skillEvolution: {
@@ -469,6 +474,20 @@ export interface Translations {
};
};
+ // Admin user management page
+ adminUsers: {
+ title: string;
+ description: string;
+ email: string;
+ role: string;
+ userId: string;
+ skillCount: string;
+ agentCount: string;
+ roleAdmin: string;
+ roleUser: string;
+ empty: string;
+ };
+
// In-app notification inbox (bell in the workspace sidebar)
notifications: NotificationsTranslations;
}
diff --git a/frontend-web/src/core/i18n/locales/zh-CN.ts b/frontend-web/src/core/i18n/locales/zh-CN.ts
index dbc59dfa..6106bc0b 100644
--- a/frontend-web/src/core/i18n/locales/zh-CN.ts
+++ b/frontend-web/src/core/i18n/locales/zh-CN.ts
@@ -166,6 +166,7 @@ export const zhCN: Translations = {
recentChats: "最近的对话",
demoChats: "演示对话",
agents: "智能体",
+ users: "用户管理",
},
// Agents
@@ -449,10 +450,14 @@ export const zhCN: Translations = {
warningsLabel: "警告",
conflictCustom:
"已存在同名自定义技能 “{name}”。点击「覆盖已有」可替换(原启用状态保留)。",
- conflictPublic: "存在同名内置技能 “{name}”,无法覆盖。",
- installSuccess: "技能 “{name}” 已安装。",
- installSuccessReplaced: "技能 “{name}” 已替换。",
- rejectInvalidExt: "只接受 .zip 或 .skill 文件。",
+ conflictPublic: `存在同名内置技能 '{name}',无法覆盖。`,
+ conflictPublicAdminHint: `存在同名内置技能 '{name}'。切换到「内置技能」并点击「覆盖已有」可替换。`,
+ installSuccess: `技能 '{name}' 已安装。`,
+ installSuccessReplaced: `技能 '{name}' 已替换。`,
+ rejectInvalidExt: `只接受 .zip 或 .skill 文件。`,
+ targetCategoryLabel: `安装到`,
+ categoryCustom: `自定义技能`,
+ categoryPublic: `内置技能(管理员)`,
},
},
skillEvolution: {
@@ -526,6 +531,19 @@ export const zhCN: Translations = {
},
},
+ adminUsers: {
+ title: "用户管理",
+ description: "查看所有注册用户及其关联的技能和智能体数量。",
+ email: "邮箱",
+ role: "角色",
+ userId: "用户 ID",
+ skillCount: "技能数",
+ agentCount: "智能体数",
+ roleAdmin: "管理员",
+ roleUser: "普通用户",
+ empty: "暂无用户",
+ },
+
notifications: {
title: "通知",
bellAria: "打开通知",
diff --git a/frontend-web/src/core/skills/api.ts b/frontend-web/src/core/skills/api.ts
index 6ba58604..6930bc86 100644
--- a/frontend-web/src/core/skills/api.ts
+++ b/frontend-web/src/core/skills/api.ts
@@ -96,11 +96,14 @@ export async function validateSkillUpload(file: File): Promise {
const form = new FormData();
form.append("file", file, file.name);
- const query = options.overwrite ? "?overwrite=true" : "";
+ const params = new URLSearchParams();
+ if (options.overwrite) params.set("overwrite", "true");
+ if (options.targetCategory) params.set("target_category", options.targetCategory);
+ const query = params.size > 0 ? `?${params.toString()}` : "";
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/install-upload${query}`,
{ method: "POST", body: form },
diff --git a/frontend-web/src/core/skills/hooks.ts b/frontend-web/src/core/skills/hooks.ts
index d40aae21..4e18f06f 100644
--- a/frontend-web/src/core/skills/hooks.ts
+++ b/frontend-web/src/core/skills/hooks.ts
@@ -45,8 +45,8 @@ export function useValidateSkillUpload() {
export function useInstallSkillUpload() {
const queryClient = useQueryClient();
return useMutation({
- mutationFn: ({ file, overwrite }: { file: File; overwrite?: boolean }) =>
- installSkillUpload(file, { overwrite }),
+ mutationFn: ({ file, overwrite, targetCategory }: { file: File; overwrite?: boolean; targetCategory?: "public" | "custom" }) =>
+ installSkillUpload(file, { overwrite, targetCategory }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
},
diff --git a/frontend-web/src/pages/UsersPage.tsx b/frontend-web/src/pages/UsersPage.tsx
new file mode 100644
index 00000000..2f325665
--- /dev/null
+++ b/frontend-web/src/pages/UsersPage.tsx
@@ -0,0 +1,5 @@
+import { UserManagementPage } from "@/components/workspace/admin/user-management-page";
+
+export default function UsersPage() {
+ return ;
+}
diff --git a/frontend-web/src/pages/WorkspaceRoutes.tsx b/frontend-web/src/pages/WorkspaceRoutes.tsx
index d2ef68b1..a6ba0b7a 100644
--- a/frontend-web/src/pages/WorkspaceRoutes.tsx
+++ b/frontend-web/src/pages/WorkspaceRoutes.tsx
@@ -18,6 +18,7 @@ import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
import ScheduledTasksPage from "./ScheduledTasksPage";
import SkillEvolutionPage from "./SkillEvolutionPage";
import SkillsPage from "./SkillsPage";
+import UsersPage from "./UsersPage";
function WorkspaceLayout({
children,
@@ -149,6 +150,14 @@ export default function WorkspaceRoutes({
}
/>
+
+
+
+ }
+ />
);
}
diff --git a/offline-backend-20260512/backend/app/gateway/auth/local_provider.py b/offline-backend-20260512/backend/app/gateway/auth/local_provider.py
index 9cabc91d..716b9089 100644
--- a/offline-backend-20260512/backend/app/gateway/auth/local_provider.py
+++ b/offline-backend-20260512/backend/app/gateway/auth/local_provider.py
@@ -99,6 +99,10 @@ class LocalAuthProvider(AuthProvider):
"""Update an existing user."""
return await self._repo.update_user(user)
+ async def list_users(self) -> list[User]:
+ """Return all registered users ordered by creation date."""
+ return await self._repo.list_users()
+
async def get_user_by_email(self, email: str) -> User | None:
"""Get user by email."""
return await self._repo.get_user_by_email(email)
diff --git a/offline-backend-20260512/backend/app/gateway/auth/repositories/base.py b/offline-backend-20260512/backend/app/gateway/auth/repositories/base.py
index d5bd2eae..220feab6 100644
--- a/offline-backend-20260512/backend/app/gateway/auth/repositories/base.py
+++ b/offline-backend-20260512/backend/app/gateway/auth/repositories/base.py
@@ -88,6 +88,11 @@ class UserRepository(ABC):
"""Return number of users with system_role == 'admin'."""
raise NotImplementedError
+ @abstractmethod
+ async def list_users(self) -> list[User]:
+ """Return all users ordered by creation date."""
+ raise NotImplementedError
+
@abstractmethod
async def get_user_by_oauth(self, provider: str, oauth_id: str) -> User | None:
"""Get user by OAuth provider and ID.
diff --git a/offline-backend-20260512/backend/app/gateway/auth/repositories/sqlite.py b/offline-backend-20260512/backend/app/gateway/auth/repositories/sqlite.py
index 92ec5f4e..209b3ff6 100644
--- a/offline-backend-20260512/backend/app/gateway/auth/repositories/sqlite.py
+++ b/offline-backend-20260512/backend/app/gateway/auth/repositories/sqlite.py
@@ -119,6 +119,12 @@ class SQLiteUserRepository(UserRepository):
async with self._sf() as session:
return await session.scalar(stmt) or 0
+ async def list_users(self) -> list[User]:
+ stmt = select(UserRow).order_by(UserRow.created_at.asc())
+ async with self._sf() as session:
+ result = await session.execute(stmt)
+ return [self._row_to_user(row) for row in result.scalars()]
+
async def get_user_by_oauth(self, provider: str, oauth_id: str) -> User | None:
stmt = select(UserRow).where(UserRow.oauth_provider == provider, UserRow.oauth_id == oauth_id)
async with self._sf() as session:
diff --git a/offline-backend-20260512/backend/app/gateway/routers/auth.py b/offline-backend-20260512/backend/app/gateway/routers/auth.py
index bc04de7c..5bfb2ed7 100644
--- a/offline-backend-20260512/backend/app/gateway/routers/auth.py
+++ b/offline-backend-20260512/backend/app/gateway/routers/auth.py
@@ -10,6 +10,9 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel, EmailStr, Field, field_validator
+from deerflow.persistence.agents.base import AgentStore
+from deerflow.persistence.skills.base import SkillStore
+
from app.gateway.auth import (
UserResponse,
create_access_token,
@@ -17,7 +20,7 @@ from app.gateway.auth import (
from app.gateway.auth.config import get_auth_config
from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse
from app.gateway.csrf_middleware import is_secure_request
-from app.gateway.deps import get_current_user_from_request, get_local_provider
+from app.gateway.deps import get_agent_store, get_current_user_from_request, get_local_provider, get_skill_store
logger = logging.getLogger(__name__)
@@ -559,6 +562,43 @@ async def initialize_admin(request: Request, response: Response, body: Initializ
return UserResponse(id=str(user.id), email=user.email, system_role=user.system_role)
+# ── Admin Endpoints ───────────────────────────────────────────────────────
+
+
+class UserListItem(BaseModel):
+ id: str
+ email: str
+ system_role: Literal["admin", "user"]
+ skill_count: int
+ agent_count: int
+
+
+@router.get("/users", response_model=list[UserListItem])
+async def list_all_users(
+ request: Request,
+ skill_store: SkillStore = Depends(get_skill_store),
+ agent_store: AgentStore = Depends(get_agent_store),
+):
+ """List all users with their skill and agent counts. Admin only."""
+ user = await get_current_user_from_request(request)
+ if user.system_role != "admin":
+ raise HTTPException(status_code=403, detail="Admin only")
+ users = await get_local_provider().list_users()
+ result = []
+ for u in users:
+ uid = str(u.id)
+ result.append(
+ UserListItem(
+ id=uid,
+ email=u.email,
+ system_role=u.system_role,
+ skill_count=await skill_store.count_owned(uid),
+ agent_count=await agent_store.count_owned(uid),
+ )
+ )
+ return result
+
+
# ── OAuth Endpoints (Future/Placeholder) ─────────────────────────────────
diff --git a/offline-backend-20260512/backend/app/gateway/routers/skills.py b/offline-backend-20260512/backend/app/gateway/routers/skills.py
index 90b03711..a679cd69 100644
--- a/offline-backend-20260512/backend/app/gateway/routers/skills.py
+++ b/offline-backend-20260512/backend/app/gateway/routers/skills.py
@@ -114,6 +114,7 @@ class SkillInstallRequest(BaseModel):
thread_id: str = Field(..., description="The thread ID where the .skill file is located")
path: str = Field(..., description="Virtual path to the .skill file (e.g., mnt/user-data/outputs/my-skill.skill)")
+ target_category: SkillCategory = Field(default=SkillCategory.CUSTOM, description="Destination category: 'public' (admin only) or 'custom'")
class SkillInstallResponse(BaseModel):
@@ -123,6 +124,7 @@ class SkillInstallResponse(BaseModel):
skill_name: str = Field(..., description="Name of the installed skill")
message: str = Field(..., description="Installation result message")
overwritten: bool = Field(default=False, description="True if an existing skill was replaced")
+ category: str = Field(default="custom", description="Destination category: 'public' (admin) or 'custom'")
class SkillUploadValidateResponse(BaseModel):
@@ -320,40 +322,53 @@ async def install_skill_upload(
request: Request,
file: UploadFile = File(..., description="The .zip or .skill archive to install"),
overwrite: bool = False,
+ target_category: SkillCategory = SkillCategory.CUSTOM,
config: AppConfig = Depends(get_config),
skill_store: SkillStore = Depends(get_skill_store),
) -> SkillInstallResponse:
user_id = _current_user_id(request)
+ is_admin = _is_admin_user(request)
+ # Only admins may install to the public (built-in) directory.
+ if target_category == SkillCategory.PUBLIC and not is_admin:
+ raise HTTPException(status_code=403, detail="Only admin users can install skills to the public directory")
tmp_path = await _persist_uploaded_skill(file)
try:
storage = get_or_new_skill_storage(app_config=config)
# Pre-parse the archive to learn the skill name without writing anything,
# so we can reject the upload before touching the filesystem if another
- # user already owns this name.
+ # user already owns this name. Admins bypass the ownership check since
+ # they install to the shared public directory.
try:
validation = storage.validate_skill_archive(tmp_path)
preview_name = validation.get("skill_name") if isinstance(validation, dict) else None
except Exception:
preview_name = None
- if preview_name:
+ if preview_name and not is_admin:
existing = await skill_store.get_any(preview_name)
if existing and existing.get("owner_user_id") not in (None, user_id):
raise HTTPException(
status_code=409,
detail=f"Skill '{preview_name}' already exists and is owned by another user",
)
- result = await storage.ainstall_skill_from_archive(tmp_path, overwrite=overwrite, require_skill_suffix=False)
+ result = await storage.ainstall_skill_from_archive(tmp_path, overwrite=overwrite, require_skill_suffix=False, target_category=target_category)
try:
from deerflow.skills.usage import mark_uploaded
mark_uploaded(result["skill_name"])
except Exception:
pass
- # Register ownership in the SkillStore. ``ensure_legacy`` is idempotent
- # and only patches a NULL owner; it never overwrites an existing owner,
- # so the pre-parse check above is what really guards cross-user steals.
- await skill_store.ensure_legacy(
- {"name": result["skill_name"], "owner_user_id": user_id, "published": False}
- )
+ # For public installs (admin), register as built-in with no owner.
+ # For custom installs, register ownership under the uploading user.
+ if target_category == SkillCategory.PUBLIC:
+ await skill_store.ensure_legacy(
+ {"name": result["skill_name"], "owner_user_id": None, "published": True}
+ )
+ else:
+ # ``ensure_legacy`` is idempotent and only patches a NULL owner; it
+ # never overwrites an existing owner, so the pre-parse check above
+ # is what really guards cross-user steals.
+ await skill_store.ensure_legacy(
+ {"name": result["skill_name"], "owner_user_id": user_id, "published": False}
+ )
await refresh_skills_system_prompt_cache_async()
return SkillInstallResponse(**result)
except FileNotFoundError as e:
@@ -385,16 +400,25 @@ async def install_skill(
) -> SkillInstallResponse:
try:
user_id = _current_user_id(request)
+ is_admin = _is_admin_user(request)
+ target_category = body.target_category
+ if target_category == SkillCategory.PUBLIC and not is_admin:
+ raise HTTPException(status_code=403, detail="Only admin users can install skills to the public directory")
skill_file_path = resolve_thread_virtual_path(body.thread_id, body.path)
- result = await get_or_new_skill_storage(app_config=config).ainstall_skill_from_archive(skill_file_path)
+ result = await get_or_new_skill_storage(app_config=config).ainstall_skill_from_archive(skill_file_path, target_category=target_category)
try:
from deerflow.skills.usage import mark_uploaded
mark_uploaded(result["skill_name"])
except Exception:
pass
- await skill_store.ensure_legacy(
- {"name": result["skill_name"], "owner_user_id": user_id, "published": False}
- )
+ if target_category == SkillCategory.PUBLIC:
+ await skill_store.ensure_legacy(
+ {"name": result["skill_name"], "owner_user_id": None, "published": True}
+ )
+ else:
+ await skill_store.ensure_legacy(
+ {"name": result["skill_name"], "owner_user_id": user_id, "published": False}
+ )
await refresh_skills_system_prompt_cache_async()
return SkillInstallResponse(**result)
except FileNotFoundError as e:
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/base.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/base.py
index 225f8d13..13b3a4d2 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/base.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/base.py
@@ -56,3 +56,8 @@ class AgentStore(ABC):
any user identity and returns ``False`` if the target is not a built-in.
"""
raise NotImplementedError
+
+ @abstractmethod
+ async def count_owned(self, user_id: str) -> int:
+ """Return the number of agents owned by ``user_id`` (user_id IS NOT NULL)."""
+ raise NotImplementedError
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/memory.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/memory.py
index 6a98d15d..2b837103 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/memory.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/memory.py
@@ -114,3 +114,6 @@ class MemoryAgentStore(AgentStore):
return False
self._agents.pop(agent_id, None)
return True
+
+ async def count_owned(self, user_id: str) -> int:
+ return sum(1 for a in self._agents.values() if a.get("user_id") == user_id)
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py
index 87ed7502..60e3f28d 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py
@@ -5,7 +5,7 @@ from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
-from sqlalchemy import or_, select
+from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.agents.base import AgentStore
@@ -137,3 +137,8 @@ class AgentRepository(AgentStore):
await session.delete(row)
await session.commit()
return True
+
+ async def count_owned(self, user_id: str) -> int:
+ stmt = select(func.count()).select_from(AgentRow).where(AgentRow.user_id == user_id)
+ async with self._sf() as session:
+ return await session.scalar(stmt) or 0
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/skills/base.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/skills/base.py
index 5a52841e..52d48a90 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/skills/base.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/skills/base.py
@@ -62,3 +62,8 @@ class SkillStore(ABC):
async def delete_admin(self, name: str) -> bool:
"""Admin-side delete; bypasses ownership check (caller authorizes)."""
raise NotImplementedError
+
+ @abstractmethod
+ async def count_owned(self, user_id: str) -> int:
+ """Return the number of skills owned by ``user_id`` (owner_user_id IS NOT NULL)."""
+ raise NotImplementedError
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/skills/memory.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/skills/memory.py
index 09cb4266..15bf5a53 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/skills/memory.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/skills/memory.py
@@ -124,3 +124,6 @@ class MemorySkillStore(SkillStore):
return False
del self._skills[name]
return True
+
+ async def count_owned(self, user_id: str) -> int:
+ return sum(1 for s in self._skills.values() if s.get("owner_user_id") == user_id)
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/skills/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/skills/sql.py
index e54fdb16..5997daca 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/skills/sql.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/skills/sql.py
@@ -5,7 +5,7 @@ from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
-from sqlalchemy import or_, select
+from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.skills.base import SkillStore
@@ -137,3 +137,8 @@ class SkillRepository(SkillStore):
await session.delete(row)
await session.commit()
return True
+
+ async def count_owned(self, user_id: str) -> int:
+ stmt = select(func.count()).select_from(SkillRow).where(SkillRow.owner_user_id == user_id)
+ async with self._sf() as session:
+ return await session.scalar(stmt) or 0
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/skills/storage/local_skill_storage.py b/offline-backend-20260512/backend/packages/harness/deerflow/skills/storage/local_skill_storage.py
index 2bb93d43..0491c7c8 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/skills/storage/local_skill_storage.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/skills/storage/local_skill_storage.py
@@ -189,6 +189,7 @@ class LocalSkillStorage(SkillStorage):
*,
overwrite: bool = False,
require_skill_suffix: bool = True,
+ target_category: SkillCategory = SkillCategory.CUSTOM,
) -> dict:
from deerflow.skills.installer import (
SkillAlreadyExistsError,
@@ -199,10 +200,10 @@ class LocalSkillStorage(SkillStorage):
)
from deerflow.skills.validation import _validate_skill_frontmatter
- logger.info("Installing skill from %s (overwrite=%s)", archive_path, overwrite)
+ logger.info("Installing skill from %s (overwrite=%s, target_category=%s)", archive_path, overwrite, target_category)
- custom_dir = self._host_root / "custom"
- custom_dir.mkdir(parents=True, exist_ok=True)
+ category_dir = self._host_root / target_category.value
+ category_dir.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
@@ -218,10 +219,12 @@ class LocalSkillStorage(SkillStorage):
if not skill_name or "/" in skill_name or "\\" in skill_name or ".." in skill_name:
raise ValueError(f"Invalid skill name: {skill_name}")
- if self.public_skill_exists(skill_name):
+ # Custom installs cannot overwrite built-in public skills.
+ # Public installs (admin-only) are allowed to replace existing public skills when overwrite=True.
+ if target_category == SkillCategory.CUSTOM and self.public_skill_exists(skill_name):
raise SkillAlreadyExistsError(f"Skill '{skill_name}' conflicts with a built-in public skill and cannot be overwritten")
- target = custom_dir / skill_name
+ target = category_dir / skill_name
replaced = False
if target.exists():
if not overwrite:
@@ -231,7 +234,7 @@ class LocalSkillStorage(SkillStorage):
await _scan_skill_archive_contents_or_raise(skill_dir, skill_name)
- with tempfile.TemporaryDirectory(prefix=f".installing-{skill_name}-", dir=custom_dir) as staging_root:
+ with tempfile.TemporaryDirectory(prefix=f".installing-{skill_name}-", dir=category_dir) as staging_root:
staging_target = Path(staging_root) / skill_name
shutil.copytree(skill_dir, staging_target)
_move_staged_skill_into_reserved_target(staging_target, target)
@@ -242,6 +245,7 @@ class LocalSkillStorage(SkillStorage):
"skill_name": skill_name,
"message": f"Skill '{skill_name}' installed successfully",
"overwritten": replaced,
+ "category": target_category.value,
}
def delete_custom_skill(self, name: str, *, history_meta: dict | None = None) -> None:
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/skills/storage/skill_storage.py b/offline-backend-20260512/backend/packages/harness/deerflow/skills/storage/skill_storage.py
index 40870aa7..6f03eb30 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/skills/storage/skill_storage.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/skills/storage/skill_storage.py
@@ -132,17 +132,28 @@ class SkillStorage(ABC):
"""
@abstractmethod
- async def ainstall_skill_from_archive(self, archive_path: str | Path) -> dict:
+ async def ainstall_skill_from_archive(
+ self,
+ archive_path: str | Path,
+ *,
+ target_category: SkillCategory = SkillCategory.CUSTOM,
+ ) -> dict:
"""Async install of a skill from a ``.skill`` ZIP archive.
+ ``target_category`` controls the destination directory (public or custom).
Origin: ``deerflow.skills.installer.ainstall_skill_from_archive``.
"""
- def install_skill_from_archive(self, archive_path: str | Path) -> dict:
+ def install_skill_from_archive(
+ self,
+ archive_path: str | Path,
+ *,
+ target_category: SkillCategory = SkillCategory.CUSTOM,
+ ) -> dict:
"""Sync wrapper — delegates to :meth:`ainstall_skill_from_archive`."""
from deerflow.skills.installer import _run_async_install
- return _run_async_install(self.ainstall_skill_from_archive(archive_path))
+ return _run_async_install(self.ainstall_skill_from_archive(archive_path, target_category=target_category))
@abstractmethod
def delete_custom_skill(self, name: str, *, history_meta: dict | None = None) -> None: