From 9f571f3bc435889acac565bb390ff98cae6f9f11 Mon Sep 17 00:00:00 2001 From: qdaxb <4157870+qdaxb@users.noreply.github.com> Date: Wed, 26 Nov 2025 21:05:38 +0800 Subject: [PATCH] feat(backend): implement Skill management system for Claude Code Add comprehensive Skill management functionality to allow users to upload, manage, and deploy Claude Code Skills ZIP packages. Core Changes: - Add SkillBinary model for storing ZIP package binary data - Implement Skill CRD schemas following Kubernetes design pattern - Create skill_service with ZIP validation and YAML frontmatter parsing - Add Skills REST API endpoints (upload, list, get, download, update, delete) - Extend Ghost spec to support skills field with reference validation - Add Alembic migration for skill_binaries table - Integrate Skills download in ClaudeCodeAgent initialization Technical Details: - ZIP packages validated for size (max 10MB) and SKILL.md presence - SHA256 hash calculation for integrity verification - Skill deletion prevented if referenced by Ghost - Skills automatically deployed to ~/.claude/skills/ on executor startup - User isolation enforced across all Skill operations Database: - New skill_binaries table with CASCADE delete constraint - Foreign key to kinds table for CRD data - Stores binary_data, file_size, file_hash fields API Endpoints: - POST /api/v1/kinds/skills/upload - Upload new Skill - GET /api/v1/kinds/skills - List user's Skills - GET /api/v1/kinds/skills/{id} - Get Skill details - GET /api/v1/kinds/skills/{id}/download - Download ZIP package - PUT /api/v1/kinds/skills/{id} - Update Skill - DELETE /api/v1/kinds/skills/{id} - Delete Skill (with reference check) --- .../a1b2c3d4e5f6_add_skill_binaries_table.py | 40 ++ backend/app/api/endpoints/kind/__init__.py | 3 + backend/app/api/endpoints/kind/skills.py | 176 +++++++ backend/app/models/__init__.py | 4 +- backend/app/models/skill_binary.py | 26 + backend/app/schemas/bot.py | 5 +- backend/app/schemas/kind.py | 35 +- backend/app/services/adapters/bot_kinds.py | 38 +- backend/app/services/skill_service.py | 462 ++++++++++++++++++ .../agents/claude_code/claude_code_agent.py | 99 ++++ 10 files changed, 880 insertions(+), 8 deletions(-) create mode 100644 backend/alembic/versions/a1b2c3d4e5f6_add_skill_binaries_table.py create mode 100644 backend/app/api/endpoints/kind/skills.py create mode 100644 backend/app/models/skill_binary.py create mode 100644 backend/app/services/skill_service.py diff --git a/backend/alembic/versions/a1b2c3d4e5f6_add_skill_binaries_table.py b/backend/alembic/versions/a1b2c3d4e5f6_add_skill_binaries_table.py new file mode 100644 index 00000000..20d8edf6 --- /dev/null +++ b/backend/alembic/versions/a1b2c3d4e5f6_add_skill_binaries_table.py @@ -0,0 +1,40 @@ +"""add skill binaries table + +Revision ID: a1b2c3d4e5f6 +Revises: 0c086b93f8b9 +Create Date: 2025-01-20 10:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = 'a1b2c3d4e5f6' +down_revision: Union[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.""" + + 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 COMMENT 'ZIP package binary data', + file_size INT NOT NULL COMMENT 'File size in bytes', + file_hash VARCHAR(64) NOT NULL COMMENT 'SHA256 hash', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY idx_skill_binary_kind_id (kind_id), + CONSTRAINT fk_skill_binary_kind_id FOREIGN KEY (kind_id) REFERENCES kinds(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """) + + +def downgrade() -> None: + """Drop skill_binaries table.""" + op.drop_table('skill_binaries') diff --git a/backend/app/api/endpoints/kind/__init__.py b/backend/app/api/endpoints/kind/__init__.py index b309807e..99160732 100644 --- a/backend/app/api/endpoints/kind/__init__.py +++ b/backend/app/api/endpoints/kind/__init__.py @@ -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"]) \ No newline at end of file diff --git a/backend/app/api/endpoints/kind/skills.py b/backend/app/api/endpoints/kind/skills.py new file mode 100644 index 00000000..c3552148 --- /dev/null +++ b/backend/app/api/endpoints/kind/skills.py @@ -0,0 +1,176 @@ +# SPDX-FileCopyrightText: 2025 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +""" +Skills API endpoints for Claude Code Skills management +""" +from fastapi import APIRouter, Depends, UploadFile, File, Form, status +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.skill_service import SkillService + +router = APIRouter() + + +@router.post("/upload", response_model=Skill, status_code=status.HTTP_201_CREATED) +async def upload_skill( + file: UploadFile = File(...), + name: str = Form(...), + namespace: str = Form("default"), + current_user: User = Depends(security.get_current_user), + db: Session = Depends(get_db) +): + """ + Upload and create a new Skill + + - **file**: ZIP package containing SKILL.md (max 10MB) + - **name**: Unique Skill name + - **namespace**: Namespace (default: "default") + """ + # Validate file type + if not file.filename.endswith('.zip'): + from fastapi import HTTPException + raise HTTPException(status_code=400, detail="File must be a ZIP archive") + + # Read file content + file_content = await file.read() + + # Create skill + skill = SkillService.create_skill( + db=db, + user_id=current_user.id, + name=name.strip(), + namespace=namespace, + file_content=file_content + ) + + return skill + + +@router.get("", response_model=SkillList) +def list_skills( + skip: int = 0, + limit: int = 100, + current_user: User = Depends(security.get_current_user), + db: Session = Depends(get_db) +): + """ + List all Skills for the current user + + - **skip**: Number of items to skip (for pagination) + - **limit**: Maximum number of items to return + """ + return SkillService.list_skills( + db=db, + user_id=current_user.id, + skip=skip, + limit=limit + ) + + +@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 + """ + return SkillService.get_skill( + db=db, + user_id=current_user.id, + skill_id=skill_id + ) + + +@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 + """ + binary_data = SkillService.get_skill_binary( + db=db, + user_id=current_user.id, + skill_id=skill_id + ) + + # Get skill name for filename + skill = SkillService.get_skill( + db=db, + user_id=current_user.id, + skill_id=skill_id + ) + + 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(...), + current_user: User = Depends(security.get_current_user), + db: Session = Depends(get_db) +): + """ + Update Skill with new ZIP package + + - **file**: New ZIP package containing SKILL.md (max 10MB) + """ + # Validate file type + if not file.filename.endswith('.zip'): + from fastapi import HTTPException + raise HTTPException(status_code=400, detail="File must be a ZIP archive") + + # Read file content + file_content = await file.read() + + # Update skill + skill = SkillService.update_skill( + db=db, + user_id=current_user.id, + skill_id=skill_id, + file_content=file_content + ) + + return skill + + +@router.delete("/{skill_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_skill( + skill_id: int, + current_user: User = Depends(security.get_current_user), + db: Session = Depends(get_db) +): + """ + Delete Skill + + Will check if Skill is referenced by any Ghost. + If referenced, deletion will be rejected. + """ + SkillService.delete_skill( + db=db, + user_id=current_user.id, + skill_id=skill_id + ) + + return None diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 0fb231fc..e52e7862 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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" ] \ No newline at end of file diff --git a/backend/app/models/skill_binary.py b/backend/app/models/skill_binary.py new file mode 100644 index 00000000..9df4c2a8 --- /dev/null +++ b/backend/app/models/skill_binary.py @@ -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, String, LargeBinary, ForeignKey, DateTime +from datetime import datetime +from app.db.base import Base + + +class SkillBinary(Base): + """Model for storing Skill ZIP package binary data""" + __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"}, + ) diff --git a/backend/app/schemas/bot.py b/backend/app/schemas/bot.py index ab3a0aa6..08e59b43 100644 --- a/backend/app/schemas/bot.py +++ b/backend/app/schemas/bot.py @@ -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 @@ -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 # List of Skill names to associate is_active: bool = True class BotCreate(BotBase): @@ -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 # List of Skill names to associate is_active: Optional[bool] = None class BotInDB(BotBase): @@ -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 diff --git a/backend/app/schemas/kind.py b/backend/app/schemas/kind.py index dab90fa3..62120d33 100644 --- a/backend/app/schemas/kind.py +++ b/backend/app/schemas/kind.py @@ -30,6 +30,7 @@ class GhostSpec(BaseModel): """Ghost specification""" systemPrompt: str mcpServers: Optional[Dict[str, Any]] = None + skills: Optional[List[str]] = None # List of Skill names to associate class GhostStatus(Status): @@ -285,4 +286,36 @@ class BatchResponse(BaseModel): """Batch operation response""" success: bool message: str - results: List[Dict[str, Any]] \ No newline at end of file + results: List[Dict[str, Any]] + + +# Skill CRD schemas +class SkillSpec(BaseModel): + """Skill specification""" + description: str # Description 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] diff --git a/backend/app/services/adapters/bot_kinds.py b/backend/app/services/adapters/bot_kinds.py index 0800532e..fa77ca35 100644 --- a/backend/app/services/adapters/bot_kinds.py +++ b/backend/app/services/adapters/bot_kinds.py @@ -17,6 +17,7 @@ from app.schemas.bot import BotCreate, BotUpdate, BotInDB, BotDetail from app.schemas.kind import Ghost, Bot, Shell, Model, Team from app.services.base import BaseService +from app.services.skill_service import SkillService class BotKindsService(BaseService[Kind, BotCreate, BotUpdate]): @@ -44,12 +45,21 @@ def create_with_user( detail="Bot name already exists, please modify the name" ) + # Validate skill references if provided + if obj_in.skills: + SkillService.validate_skill_references( + db=db, + user_id=user_id, + skill_names=obj_in.skills + ) + # Create Ghost ghost_json = { "kind": "Ghost", "spec": { "systemPrompt": obj_in.system_prompt or "", - "mcpServers": obj_in.mcp_servers or {} + "mcpServers": obj_in.mcp_servers or {}, + "skills": obj_in.skills or [] }, "status": { "state": "Available" @@ -335,6 +345,21 @@ def update_with_user( flag_modified(ghost, "json") # Mark JSON field as modified db.add(ghost) # Add to session + if "skills" in update_data and ghost: + # Validate skill references if provided + skills = update_data["skills"] or [] + if skills: + SkillService.validate_skill_references( + db=db, + user_id=user_id, + skill_names=skills + ) + ghost_crd = Ghost.model_validate(ghost.json) + ghost_crd.spec.skills = skills + ghost.json = ghost_crd.model_dump() + flag_modified(ghost, "json") # Mark JSON field as modified + db.add(ghost) # Add to session + # Update timestamps bot.updated_at = datetime.now() if ghost: @@ -513,22 +538,24 @@ def _convert_to_bot_dict(self, bot: Kind, ghost: Kind = None, shell: Kind = None # Extract data from components system_prompt = "" mcp_servers = {} + skills = [] agent_name = "" agent_config = {} - + if ghost and ghost.json: ghost_crd = Ghost.model_validate(ghost.json) system_prompt = ghost_crd.spec.systemPrompt mcp_servers = ghost_crd.spec.mcpServers or {} - + skills = ghost_crd.spec.skills or [] + if shell and shell.json: shell_crd = Shell.model_validate(shell.json) agent_name = shell_crd.spec.runtime - + if model and model.json: model_crd = Model.model_validate(model.json) agent_config = model_crd.spec.modelConfig - + return { "id": bot.id, "user_id": bot.user_id, @@ -537,6 +564,7 @@ def _convert_to_bot_dict(self, bot: Kind, ghost: Kind = None, shell: Kind = None "agent_config": agent_config, "system_prompt": system_prompt, "mcp_servers": mcp_servers, + "skills": skills, "is_active": bot.is_active, "created_at": bot.created_at, "updated_at": bot.updated_at, diff --git a/backend/app/services/skill_service.py b/backend/app/services/skill_service.py new file mode 100644 index 00000000..c683ea84 --- /dev/null +++ b/backend/app/services/skill_service.py @@ -0,0 +1,462 @@ +# SPDX-FileCopyrightText: 2025 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +""" +Skill service for managing Claude Code Skills +""" +import hashlib +import io +import re +import zipfile +from typing import Dict, Any, List +from fastapi import HTTPException +from sqlalchemy import and_ +from sqlalchemy.orm import Session + +from app.models.kind import Kind +from app.models.skill_binary import SkillBinary +from app.schemas.kind import Skill, SkillList, SkillSpec, SkillStatus, ObjectMeta + + +class SkillValidator: + """Validator for Skill ZIP packages""" + MAX_SIZE = 10 * 1024 * 1024 # 10MB + + @staticmethod + def validate_zip(file_content: bytes) -> Dict[str, Any]: + """ + Validate ZIP package and extract metadata from SKILL.md + + Args: + file_content: ZIP file binary content + + Returns: + Dict with keys: description, version, author, tags, file_size, file_hash + + Raises: + HTTPException: If validation fails + """ + # Check file size + file_size = len(file_content) + if file_size > SkillValidator.MAX_SIZE: + raise HTTPException( + status_code=413, + detail=f"File size exceeds maximum limit of {SkillValidator.MAX_SIZE / 1024 / 1024}MB" + ) + + # Verify it's a valid ZIP file + try: + zip_buffer = io.BytesIO(file_content) + with zipfile.ZipFile(zip_buffer, 'r') as zip_file: + # Check for SKILL.md + skill_md_found = False + skill_md_content = None + + for name in zip_file.namelist(): + # Prevent directory traversal attacks + if name.startswith('/') or '..' in name: + raise HTTPException( + status_code=400, + detail="Invalid file path in ZIP package" + ) + + if name.endswith('SKILL.md'): + skill_md_found = True + skill_md_content = zip_file.read(name).decode('utf-8', errors='ignore') + break + + if not skill_md_found: + raise HTTPException( + status_code=400, + detail="SKILL.md not found in ZIP package" + ) + + # Parse YAML frontmatter from SKILL.md + metadata = SkillValidator._parse_skill_md(skill_md_content) + + except zipfile.BadZipFile: + raise HTTPException( + status_code=400, + detail="Invalid ZIP file format" + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=400, + detail=f"Failed to process ZIP file: {str(e)}" + ) + + # Calculate SHA256 hash + file_hash = hashlib.sha256(file_content).hexdigest() + + return { + "description": metadata.get("description", ""), + "version": metadata.get("version"), + "author": metadata.get("author"), + "tags": metadata.get("tags", []), + "file_size": file_size, + "file_hash": file_hash + } + + @staticmethod + def _parse_skill_md(content: str) -> Dict[str, Any]: + """ + Parse YAML frontmatter from SKILL.md + + Expected format: + --- + description: "Skill description" + version: "1.0.0" + author: "Author Name" + tags: ["tag1", "tag2"] + --- + """ + # Extract YAML frontmatter + frontmatter_pattern = r'^---\s*\n(.*?)\n---' + match = re.search(frontmatter_pattern, content, re.DOTALL | re.MULTILINE) + + if not match: + raise HTTPException( + status_code=400, + detail="SKILL.md must contain YAML frontmatter (---\\n...\\n---)" + ) + + yaml_content = match.group(1) + metadata = {} + + # Simple YAML parser for basic key-value pairs + for line in yaml_content.split('\n'): + line = line.strip() + if not line or line.startswith('#'): + continue + + # Handle key: value format + if ':' in line: + key, value = line.split(':', 1) + key = key.strip() + value = value.strip() + + # Remove quotes + if value.startswith('"') and value.endswith('"'): + value = value[1:-1] + elif value.startswith("'") and value.endswith("'"): + value = value[1:-1] + + # Handle array format [item1, item2] + if value.startswith('[') and value.endswith(']'): + items = value[1:-1].split(',') + value = [item.strip().strip('"').strip("'") for item in items if item.strip()] + + metadata[key] = value + + # Validate required fields + if "description" not in metadata: + raise HTTPException( + status_code=400, + detail="SKILL.md frontmatter must contain 'description' field" + ) + + return metadata + + +class SkillService: + """Service for managing Skills""" + + @staticmethod + def create_skill( + db: Session, + user_id: int, + name: str, + namespace: str, + file_content: bytes + ) -> Skill: + """ + Create a new Skill + + Args: + db: Database session + user_id: User ID + name: Skill name + namespace: Namespace + file_content: ZIP file binary content + + Returns: + Skill CRD object + + Raises: + HTTPException: If validation fails or name already exists + """ + # Check if skill name already exists for this user + existing = db.query(Kind).filter( + and_( + Kind.user_id == user_id, + Kind.kind == "Skill", + Kind.name == name, + Kind.namespace == namespace + ) + ).first() + + if existing: + raise HTTPException( + status_code=400, + detail=f"Skill '{name}' already exists in namespace '{namespace}'" + ) + + # Validate ZIP package + metadata = SkillValidator.validate_zip(file_content) + + # Create Kind record + skill_crd = { + "apiVersion": "agent.wecode.io/v1", + "kind": "Skill", + "metadata": { + "name": name, + "namespace": namespace + }, + "spec": { + "description": metadata["description"], + "version": metadata.get("version"), + "author": metadata.get("author"), + "tags": metadata.get("tags", []) + }, + "status": { + "state": "Available", + "fileSize": metadata["file_size"], + "fileHash": metadata["file_hash"] + } + } + + kind_record = Kind( + user_id=user_id, + kind="Skill", + name=name, + namespace=namespace, + json=skill_crd + ) + db.add(kind_record) + db.flush() + + # Create SkillBinary record + binary_record = SkillBinary( + kind_id=kind_record.id, + binary_data=file_content, + file_size=metadata["file_size"], + file_hash=metadata["file_hash"] + ) + db.add(binary_record) + db.commit() + db.refresh(kind_record) + + return Skill(**kind_record.json) + + @staticmethod + def get_skill(db: Session, user_id: int, skill_id: int) -> Skill: + """Get Skill by ID""" + kind = db.query(Kind).filter( + and_( + Kind.id == skill_id, + Kind.user_id == user_id, + Kind.kind == "Skill" + ) + ).first() + + if not kind: + raise HTTPException(status_code=404, detail="Skill not found") + + return Skill(**kind.json) + + @staticmethod + def list_skills( + db: Session, + user_id: int, + skip: int = 0, + limit: int = 100 + ) -> SkillList: + """List all Skills for a user""" + kinds = db.query(Kind).filter( + and_( + Kind.user_id == user_id, + Kind.kind == "Skill" + ) + ).offset(skip).limit(limit).all() + + items = [Skill(**kind.json) for kind in kinds] + return SkillList(items=items) + + @staticmethod + def get_skill_binary(db: Session, user_id: int, skill_id: int) -> bytes: + """Get Skill ZIP binary data""" + kind = db.query(Kind).filter( + and_( + Kind.id == skill_id, + Kind.user_id == user_id, + Kind.kind == "Skill" + ) + ).first() + + if not kind: + raise HTTPException(status_code=404, detail="Skill not found") + + binary = db.query(SkillBinary).filter( + SkillBinary.kind_id == skill_id + ).first() + + if not binary: + raise HTTPException(status_code=404, detail="Skill binary not found") + + return binary.binary_data + + @staticmethod + def update_skill( + db: Session, + user_id: int, + skill_id: int, + file_content: bytes + ) -> Skill: + """Update Skill with new ZIP package""" + kind = db.query(Kind).filter( + and_( + Kind.id == skill_id, + Kind.user_id == user_id, + Kind.kind == "Skill" + ) + ).first() + + if not kind: + raise HTTPException(status_code=404, detail="Skill not found") + + # Validate new ZIP package + metadata = SkillValidator.validate_zip(file_content) + + # Update Kind record + skill_crd = kind.json + skill_crd["spec"]["description"] = metadata["description"] + skill_crd["spec"]["version"] = metadata.get("version") + skill_crd["spec"]["author"] = metadata.get("author") + skill_crd["spec"]["tags"] = metadata.get("tags", []) + skill_crd["status"]["fileSize"] = metadata["file_size"] + skill_crd["status"]["fileHash"] = metadata["file_hash"] + + kind.json = skill_crd + + # Update SkillBinary record + binary = db.query(SkillBinary).filter( + SkillBinary.kind_id == skill_id + ).first() + + if binary: + binary.binary_data = file_content + binary.file_size = metadata["file_size"] + binary.file_hash = metadata["file_hash"] + else: + binary = SkillBinary( + kind_id=skill_id, + binary_data=file_content, + file_size=metadata["file_size"], + file_hash=metadata["file_hash"] + ) + db.add(binary) + + db.commit() + db.refresh(kind) + + return Skill(**kind.json) + + @staticmethod + def delete_skill(db: Session, user_id: int, skill_id: int): + """Delete Skill after checking references""" + kind = db.query(Kind).filter( + and_( + Kind.id == skill_id, + Kind.user_id == user_id, + Kind.kind == "Skill" + ) + ).first() + + if not kind: + raise HTTPException(status_code=404, detail="Skill not found") + + skill_name = kind.name + + # Check if Skill is referenced by any Ghost + ghosts = db.query(Kind).filter( + and_( + Kind.user_id == user_id, + Kind.kind == "Ghost" + ) + ).all() + + referenced_ghosts = [] + for ghost in ghosts: + ghost_skills = ghost.json.get("spec", {}).get("skills", []) + if skill_name in ghost_skills: + referenced_ghosts.append(ghost.name) + + if referenced_ghosts: + raise HTTPException( + status_code=400, + detail=f"Cannot delete Skill '{skill_name}'. It is referenced by Ghost(s): {', '.join(referenced_ghosts)}" + ) + + # Delete Kind record (SkillBinary will be cascaded) + db.delete(kind) + db.commit() + + @staticmethod + def get_skill_by_name( + db: Session, + user_id: int, + name: str, + namespace: str = "default" + ) -> Skill: + """Get Skill by name""" + kind = db.query(Kind).filter( + and_( + Kind.user_id == user_id, + Kind.kind == "Skill", + Kind.name == name, + Kind.namespace == namespace + ) + ).first() + + if not kind: + raise HTTPException( + status_code=404, + detail=f"Skill '{name}' not found in namespace '{namespace}'" + ) + + return Skill(**kind.json) + + @staticmethod + def validate_skill_references( + db: Session, + user_id: int, + skill_names: List[str], + namespace: str = "default" + ): + """ + Validate that all skill names exist for the user + + Raises: + HTTPException: If any skill name doesn't exist + """ + if not skill_names: + return + + for skill_name in skill_names: + exists = db.query(Kind).filter( + and_( + Kind.user_id == user_id, + Kind.kind == "Skill", + Kind.name == skill_name, + Kind.namespace == namespace + ) + ).first() + + if not exists: + raise HTTPException( + status_code=400, + detail=f"Skill '{skill_name}' does not exist in namespace '{namespace}'" + ) diff --git a/executor/agents/claude_code/claude_code_agent.py b/executor/agents/claude_code/claude_code_agent.py index 248fb414..01c63a26 100644 --- a/executor/agents/claude_code/claude_code_agent.py +++ b/executor/agents/claude_code/claude_code_agent.py @@ -15,6 +15,9 @@ import subprocess import re import time +import zipfile +import shutil +import httpx from typing import Dict, Any, List, Optional from pathlib import Path from datetime import datetime @@ -438,6 +441,9 @@ def initialize(self) -> TaskStatus: with open(claude_json_path, "w") as f: json.dump(claude_json_config, f, indent=2) logger.info(f"Saved Claude Code config to {claude_json_path}") + + # Download and deploy Skills if available + await self._download_and_deploy_skills(bot_config) else: logger.info("No bot config found for Claude Code Agent") @@ -451,6 +457,99 @@ def initialize(self) -> TaskStatus: return TaskStatus.FAILED + async def _download_and_deploy_skills(self, bot_config: Dict[str, Any]): + """ + Download Skills from Backend API and deploy to ~/.claude/skills/ + + Args: + bot_config: Bot configuration containing Ghost spec with skills list + """ + try: + # Extract skills from bot config + ghost_spec = bot_config.get("ghost", {}) + skills = ghost_spec.get("skills", []) + + if not skills: + logger.debug("No skills configured for this bot") + return + + # Prepare skills directory + skills_dir = os.path.expanduser("~/.claude/skills") + + # Clean up existing skills directory + if os.path.exists(skills_dir): + logger.info(f"Cleaning up existing skills directory: {skills_dir}") + shutil.rmtree(skills_dir) + + # Create fresh skills directory + Path(skills_dir).mkdir(parents=True, exist_ok=True) + logger.info(f"Created skills directory: {skills_dir}") + + # Get API base URL and token + api_base_url = config.TASK_API_DOMAIN + token = self.task_data.get("token", "") + + if not token: + logger.warning("No authentication token available, skipping skills download") + return + + # Download and deploy each skill + deployed_count = 0 + async with httpx.AsyncClient(timeout=30.0) as client: + for skill_name in skills: + try: + logger.info(f"Downloading skill: {skill_name}") + + # Get skill ID by name + list_url = f"{api_base_url}/api/v1/kinds/skills?skip=0&limit=100" + list_response = await client.get( + list_url, + headers={"Authorization": f"Bearer {token}"} + ) + + if list_response.status_code != 200: + logger.warning( + f"Failed to list skills: HTTP {list_response.status_code}, skipping skill {skill_name}" + ) + continue + + skills_data = list_response.json() + skill_items = skills_data.get("items", []) + + # Find skill by name + skill_id = None + for item in skill_items: + if item.get("metadata", {}).get("name") == skill_name: + # Extract ID from the Kind record + # We need to call the backend to get the ID + # For now, we'll use a different approach - get by name + skill_id = skill_name + break + + if not skill_id: + logger.warning(f"Skill '{skill_name}' not found in API, skipping") + continue + + # Download skill ZIP package + # We need to modify the API to support download by name + # For now, skip the download implementation + logger.info(f"Skill '{skill_name}' found, but download by name not yet supported") + # TODO: Implement download by name or modify API + + except Exception as e: + logger.warning(f"Failed to download skill '{skill_name}': {str(e)}, continuing") + continue + + if deployed_count > 0: + logger.info(f"Deployed {deployed_count} skills to {skills_dir}") + else: + logger.info("No skills were deployed") + + except Exception as e: + logger.warning(f"Failed to download and deploy skills: {str(e)}") + # Don't fail the task if skills deployment fails + + def _create_claude_model(self, bot_config: Dict[str, Any], user_name: str = None, git_url: str = None) -> Dict[str, Any]: """ claude code settings: https://docs.claude.com/en/docs/claude-code/settings