-
Notifications
You must be signed in to change notification settings - Fork 29
feat(backend): implement Skill management for Claude Code Skills #182
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 6 commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
652ae48
feat(backend): implement Skill management for Claude Code Skills
qdaxb 99dc175
test(backend): add comprehensive tests for Skill management
qdaxb 71d1fd5
feat(frontend): implement Skills management UI and Bot-Skill integration
qdaxb 12c3936
docs: add Skill management documentation to YAML specification
qdaxb b1e2acb
docs: add comprehensive Skills management user guide (EN & ZH)
qdaxb 2f368c3
fix: fix skills impl bugs
qdaxb b9dffd4
fix: fix test error
qdaxb 6fa85a2
feat: update skills package folder
qdaxb cdf87c5
fix: fix test errors
qdaxb ab44e93
feat: add skill help pages
qdaxb File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
44 changes: 44 additions & 0 deletions
44
backend/alembic/versions/1a2b3c4d5e6f_add_skill_binaries_table.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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") |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" | ||
| } | ||
| ) | ||
|
|
||
|
|
||
| @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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"}, | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sanitize filename in Content-Disposition header.
If
skill.metadata.namecontains 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.quotefor the filename.🤖 Prompt for AI Agents