Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions backend/alembic/versions/1a2b3c4d5e6f_add_skill_binaries_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""add skill binaries table for skill management

Revision ID: 1a2b3c4d5e6f
Revises: 0c086b93f8b9
Create Date: 2025-01-26 12:00:00.000000+08:00

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = '1a2b3c4d5e6f'
down_revision: Union[str, Sequence[str], None] = '0c086b93f8b9'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
"""Add skill_binaries table for storing Skill ZIP packages."""

# Create skill_binaries table
op.execute("""
CREATE TABLE IF NOT EXISTS skill_binaries (
id INT NOT NULL AUTO_INCREMENT,
kind_id INT NOT NULL,
binary_data LONGBLOB NOT NULL,
file_size INT NOT NULL,
file_hash VARCHAR(64) NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY (kind_id),
KEY ix_skill_binaries_id (id),
CONSTRAINT fk_skill_binaries_kind_id FOREIGN KEY (kind_id)
REFERENCES kinds (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
""")


def downgrade() -> None:
"""Remove skill_binaries table."""
op.execute("DROP TABLE IF EXISTS skill_binaries")
3 changes: 3 additions & 0 deletions backend/app/api/endpoints/kind/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,14 @@

from app.api.endpoints.kind.kinds import router as kinds_router
from app.api.endpoints.kind.batch import router as batch_router
from app.api.endpoints.kind.skills import router as skills_router

# Create main kind API router
k_router = APIRouter(prefix="/v1")

# Include batch router first to avoid path conflicts
k_router.include_router(batch_router, tags=["kinds-batch"])
# Include skills router
k_router.include_router(skills_router, prefix="/kinds/skills", tags=["skills"])
# Include unified kinds router after batch router
k_router.include_router(kinds_router, tags=["kinds"])
207 changes: 207 additions & 0 deletions backend/app/api/endpoints/kind/skills.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
# SPDX-FileCopyrightText: 2025 Weibo, Inc.
#
# SPDX-License-Identifier: Apache-2.0

"""
Skills API endpoints for managing Claude Code Skills
"""
from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
import io

from app.api.dependencies import get_db
from app.core import security
from app.models.user import User
from app.schemas.kind import Skill, SkillList
from app.services.adapters.skill_kinds import skill_kinds_service

router = APIRouter()


@router.post("/upload", response_model=Skill, status_code=201)
async def upload_skill(
file: UploadFile = File(..., description="Skill ZIP package (max 10MB)"),
name: str = Form(..., description="Skill name (unique)"),
namespace: str = Form("default", description="Namespace"),
current_user: User = Depends(security.get_current_user),
db: Session = Depends(get_db)
):
"""
Upload and create a new Skill.

The ZIP package must contain a SKILL.md file with YAML frontmatter:
```
---
description: "Skill description"
version: "1.0.0"
author: "Author name"
tags: ["tag1", "tag2"]
---
```
"""
# Validate file type
if not file.filename.endswith('.zip'):
raise HTTPException(
status_code=400,
detail="File must be a ZIP package (.zip)"
)

# Read file content
file_content = await file.read()

# Create skill using service
skill = skill_kinds_service.create_skill(
db=db,
name=name.strip(),
namespace=namespace,
file_content=file_content,
file_name=file.filename,
user_id=current_user.id
)

return skill


@router.get("", response_model=SkillList)
def list_skills(
skip: int = Query(0, ge=0, description="Number of items to skip"),
limit: int = Query(100, ge=1, le=100, description="Number of items to return"),
namespace: str = Query("default", description="Namespace filter"),
name: str = Query(None, description="Filter by skill name"),
current_user: User = Depends(security.get_current_user),
db: Session = Depends(get_db)
):
"""
Get current user's Skill list.

If 'name' parameter is provided, returns only the skill with that name.
"""
if name:
# Query by name
skill = skill_kinds_service.get_skill_by_name(
db=db,
name=name,
namespace=namespace,
user_id=current_user.id
)
return SkillList(items=[skill] if skill else [])

# List all skills
skills = skill_kinds_service.list_skills(
db=db,
user_id=current_user.id,
skip=skip,
limit=limit,
namespace=namespace
)
return skills


@router.get("/{skill_id}", response_model=Skill)
def get_skill(
skill_id: int,
current_user: User = Depends(security.get_current_user),
db: Session = Depends(get_db)
):
"""Get Skill details by ID"""
skill = skill_kinds_service.get_skill_by_id(
db=db,
skill_id=skill_id,
user_id=current_user.id
)
if not skill:
raise HTTPException(status_code=404, detail="Skill not found")
return skill


@router.get("/{skill_id}/download")
def download_skill(
skill_id: int,
current_user: User = Depends(security.get_current_user),
db: Session = Depends(get_db)
):
"""
Download Skill ZIP package.

Used by Executor to download Skills for deployment.
"""
# Get skill metadata
skill = skill_kinds_service.get_skill_by_id(
db=db,
skill_id=skill_id,
user_id=current_user.id
)
if not skill:
raise HTTPException(status_code=404, detail="Skill not found")

# Get binary data
binary_data = skill_kinds_service.get_skill_binary(
db=db,
skill_id=skill_id,
user_id=current_user.id
)
if not binary_data:
raise HTTPException(status_code=404, detail="Skill binary not found")

# Return as streaming response
return StreamingResponse(
io.BytesIO(binary_data),
media_type="application/zip",
headers={
"Content-Disposition": f"attachment; filename={skill.metadata.name}.zip"
}
)
Comment on lines +161 to +167
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Sanitize filename in Content-Disposition header.

If skill.metadata.name contains special characters (spaces, quotes, non-ASCII), the header may be malformed or exploitable. Use proper quoting:

     return StreamingResponse(
         io.BytesIO(binary_data),
         media_type="application/zip",
         headers={
-            "Content-Disposition": f"attachment; filename={skill.metadata.name}.zip"
+            "Content-Disposition": f'attachment; filename="{skill.metadata.name}.zip"'
         }
     )

For full RFC 5987 compliance with non-ASCII names, consider using urllib.parse.quote for the filename.

🤖 Prompt for AI Agents
In backend/app/api/endpoints/kind/skills.py around lines 148 to 154, the
Content-Disposition header uses raw skill.metadata.name which can break the
header or allow injection for names with spaces, quotes or non-ASCII; sanitize
and properly quote the filename and add an RFC 5987 compliant filename*
parameter: escape any double quotes/backslashes in the ASCII fallback and wrap
it in quotes for filename, and use urllib.parse.quote(skill.metadata.name,
safe='') encoded as UTF-8 for filename* (e.g.
"filename*=UTF-8''<percent-encoded-name>") so clients can correctly handle
non-ASCII names.



@router.put("/{skill_id}", response_model=Skill)
async def update_skill(
skill_id: int,
file: UploadFile = File(..., description="New Skill ZIP package (max 10MB)"),
current_user: User = Depends(security.get_current_user),
db: Session = Depends(get_db)
):
"""
Update Skill by uploading a new ZIP package.

The Skill name and namespace cannot be changed.
"""
# Validate file type
if not file.filename.endswith('.zip'):
raise HTTPException(
status_code=400,
detail="File must be a ZIP package (.zip)"
)

# Read file content
file_content = await file.read()

# Update skill
skill = skill_kinds_service.update_skill(
db=db,
skill_id=skill_id,
user_id=current_user.id,
file_content=file_content,
file_name=file.filename
)

return skill


@router.delete("/{skill_id}", status_code=204)
def delete_skill(
skill_id: int,
current_user: User = Depends(security.get_current_user),
db: Session = Depends(get_db)
):
"""
Delete Skill.

Returns 400 error if the Skill is referenced by any Ghost.
"""
skill_kinds_service.delete_skill(
db=db,
skill_id=skill_id,
user_id=current_user.id
)
return None
4 changes: 3 additions & 1 deletion backend/app/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@
)
from app.models.subtask import Subtask
from app.models.shared_team import SharedTeam
from app.models.skill_binary import SkillBinary

__all__ = [
"User",
"Kind",
"Subtask",
"SharedTeam"
"SharedTeam",
"SkillBinary"
]
26 changes: 26 additions & 0 deletions backend/app/models/skill_binary.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# SPDX-FileCopyrightText: 2025 Weibo, Inc.
#
# SPDX-License-Identifier: Apache-2.0

"""
Skill binary storage model for Claude Code Skills ZIP packages
"""
from sqlalchemy import Column, Integer, ForeignKey, LargeBinary, String, DateTime
from datetime import datetime
from app.db.base import Base


class SkillBinary(Base):
"""Skill binary data storage for ZIP packages"""
__tablename__ = "skill_binaries"

id = Column(Integer, primary_key=True, index=True)
kind_id = Column(Integer, ForeignKey("kinds.id", ondelete="CASCADE"), nullable=False, unique=True)
binary_data = Column(LargeBinary, nullable=False) # ZIP package binary data
file_size = Column(Integer, nullable=False) # File size in bytes
file_hash = Column(String(64), nullable=False) # SHA256 hash
created_at = Column(DateTime, default=datetime.now)

__table_args__ = (
{"mysql_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"},
)
5 changes: 4 additions & 1 deletion backend/app/schemas/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
# SPDX-License-Identifier: Apache-2.0

from datetime import datetime
from typing import Any, Optional
from typing import Any, Optional, List

from pydantic import BaseModel

Expand All @@ -16,6 +16,7 @@ class BotBase(BaseModel):
agent_config: dict[str, Any]
system_prompt: Optional[str] = None
mcp_servers: Optional[dict[str, Any]] = None
skills: Optional[List[str]] = None
is_active: bool = True

class BotCreate(BotBase):
Expand All @@ -29,6 +30,7 @@ class BotUpdate(BaseModel):
agent_config: Optional[dict[str, Any]] = None
system_prompt: Optional[str] = None
mcp_servers: Optional[dict[str, Any]] = None
skills: Optional[List[str]] = None
is_active: Optional[bool] = None

class BotInDB(BotBase):
Expand All @@ -50,6 +52,7 @@ class BotDetail(BaseModel):
agent_config: dict[str, Any]
system_prompt: Optional[str] = None
mcp_servers: Optional[dict[str, Any]] = None
skills: Optional[List[str]] = None
is_active: bool = True
created_at: datetime
updated_at: datetime
Expand Down
35 changes: 34 additions & 1 deletion backend/app/schemas/kind.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ class GhostSpec(BaseModel):
"""Ghost specification"""
systemPrompt: str
mcpServers: Optional[Dict[str, Any]] = None
skills: Optional[List[str]] = None # Skill names list


class GhostStatus(Status):
Expand Down Expand Up @@ -285,4 +286,36 @@ class BatchResponse(BaseModel):
"""Batch operation response"""
success: bool
message: str
results: List[Dict[str, Any]]
results: List[Dict[str, Any]]


# Skill CRD schemas
class SkillSpec(BaseModel):
"""Skill specification"""
description: str # Extracted from SKILL.md YAML frontmatter
version: Optional[str] = None # Skill version
author: Optional[str] = None # Author
tags: Optional[List[str]] = None # Tags


class SkillStatus(Status):
"""Skill status"""
state: str = "Available" # Available, Unavailable
fileSize: Optional[int] = None # ZIP package size in bytes
fileHash: Optional[str] = None # SHA256 hash


class Skill(BaseModel):
"""Skill CRD"""
apiVersion: str = "agent.wecode.io/v1"
kind: str = "Skill"
metadata: ObjectMeta
spec: SkillSpec
status: Optional[SkillStatus] = None


class SkillList(BaseModel):
"""Skill list"""
apiVersion: str = "agent.wecode.io/v1"
kind: str = "SkillList"
items: List[Skill]
Loading
Loading