diff --git a/apps/api/alembic/versions/0008_tool_type_category.py b/apps/api/alembic/versions/0008_tool_type_category.py new file mode 100644 index 0000000..9454f24 --- /dev/null +++ b/apps/api/alembic/versions/0008_tool_type_category.py @@ -0,0 +1,33 @@ +"""add category and interfaces to tool_types + +Revision ID: 0008_tool_type_category +Revises: 0007_instance_container_name +Create Date: 2026-05-20 09:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "0008_tool_type_category" +down_revision: Union[str, None] = "0007_instance_container_name" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "tool_types", + sa.Column("category", sa.String(50), nullable=False, server_default="other") + ) + op.add_column( + "tool_types", + sa.Column("interfaces", sa.JSON(), nullable=False, server_default='["web"]') + ) + + +def downgrade() -> None: + op.drop_column("tool_types", "interfaces") + op.drop_column("tool_types", "category") diff --git a/apps/api/alembic/versions/0009_tool_configs.py b/apps/api/alembic/versions/0009_tool_configs.py new file mode 100644 index 0000000..6f7fddb --- /dev/null +++ b/apps/api/alembic/versions/0009_tool_configs.py @@ -0,0 +1,46 @@ +"""add tool_configs table + +Revision ID: 0009_tool_configs +Revises: 0008_tool_type_category +Create Date: 2026-05-20 09:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "0009_tool_configs" +down_revision: Union[str, None] = "0008_tool_type_category" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "tool_configs", + sa.Column("id", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False), + sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("tool_type_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("key", sa.String(255), nullable=False), + sa.Column("value", sa.Text(), nullable=False), + sa.Column("config_type", sa.String(20), nullable=False, server_default="env"), + sa.Column("file_path", sa.String(1024), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["users.id"]), + sa.ForeignKeyConstraint(["tool_type_id"], ["tool_types.id"]), + sa.ForeignKeyConstraint(["project_id"], ["projects.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("idx_tool_configs_user_tool", "tool_configs", ["user_id", "tool_type_id"]) + op.create_index("idx_tool_configs_project", "tool_configs", ["project_id"]) + + +def downgrade() -> None: + op.drop_index("idx_tool_configs_project", table_name="tool_configs") + op.drop_index("idx_tool_configs_user_tool", table_name="tool_configs") + op.drop_table("tool_configs") diff --git a/apps/api/src/api/tool_configs.py b/apps/api/src/api/tool_configs.py new file mode 100644 index 0000000..0124f84 --- /dev/null +++ b/apps/api/src/api/tool_configs.py @@ -0,0 +1,173 @@ +"""Tool configuration API endpoints.""" + +import logging +import uuid + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.auth.dependencies import get_current_user_id, get_db_session +from src.models.tool_config import ToolConfig +from src.models.tool_type import ToolType + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/tool-configs", tags=["tool-configs"]) + + +class ToolConfigCreate(BaseModel): + tool_type_id: str = Field(description="UUID of the tool type") + project_id: str | None = Field(default=None, description="Optional project ID for project-scoped config") + key: str = Field(description="Config key name") + value: str = Field(description="Config value") + config_type: str = Field(default="env", description="Type: env or file") + file_path: str | None = Field(default=None, description="File path for file-type configs") + + +class ToolConfigResponse(BaseModel): + id: str + tool_type_id: str + project_id: str | None + key: str + value: str + config_type: str + file_path: str | None + + +@router.get("", summary="List tool configs", description="Get all tool configs for the current user.") +async def list_configs( + tool_type_id: str | None = None, + project_id: str | None = None, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """List tool configs for the current user.""" + query = select(ToolConfig).where(ToolConfig.user_id == user_id) + + if tool_type_id: + query = query.where(ToolConfig.tool_type_id == uuid.UUID(tool_type_id)) + if project_id: + query = query.where(ToolConfig.project_id == uuid.UUID(project_id)) + else: + # If no project specified, get only global configs (project_id is None) + query = query.where(ToolConfig.project_id.is_(None)) + + result = await session.execute(query) + configs = result.scalars().all() + + return { + "configs": [ + { + "id": str(c.id), + "tool_type_id": str(c.tool_type_id), + "project_id": str(c.project_id) if c.project_id else None, + "key": c.key, + "value": c.value, + "config_type": c.config_type, + "file_path": c.file_path, + } + for c in configs + ] + } + + +@router.post("", summary="Create tool config", description="Create a new tool config.") +async def create_config( + data: ToolConfigCreate, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Create a tool config.""" + # Verify tool type exists + tool_type = await session.get(ToolType, uuid.UUID(data.tool_type_id)) + if tool_type is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found") + + # Check for existing config with same key + query = select(ToolConfig).where( + ToolConfig.user_id == user_id, + ToolConfig.tool_type_id == uuid.UUID(data.tool_type_id), + ToolConfig.key == data.key, + ) + if data.project_id: + query = query.where(ToolConfig.project_id == uuid.UUID(data.project_id)) + else: + query = query.where(ToolConfig.project_id.is_(None)) + + existing = await session.scalar(query) + if existing: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"config with key '{data.key}' already exists" + ) + + config = ToolConfig( + user_id=user_id, + tool_type_id=uuid.UUID(data.tool_type_id), + project_id=uuid.UUID(data.project_id) if data.project_id else None, + key=data.key, + value=data.value, + config_type=data.config_type, + file_path=data.file_path, + ) + session.add(config) + await session.commit() + await session.refresh(config) + + return { + "id": str(config.id), + "tool_type_id": str(config.tool_type_id), + "project_id": str(config.project_id) if config.project_id else None, + "key": config.key, + "value": config.value, + "config_type": config.config_type, + "file_path": config.file_path, + } + + +@router.put("/{config_id}", summary="Update tool config", description="Update an existing tool config.") +async def update_config( + config_id: uuid.UUID, + data: ToolConfigCreate, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Update a tool config.""" + config = await session.get(ToolConfig, config_id) + if config is None or config.user_id != user_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config not found") + + config.key = data.key + config.value = data.value + config.config_type = data.config_type + config.file_path = data.file_path + + await session.commit() + await session.refresh(config) + + return { + "id": str(config.id), + "tool_type_id": str(config.tool_type_id), + "project_id": str(config.project_id) if config.project_id else None, + "key": config.key, + "value": config.value, + "config_type": config.config_type, + "file_path": config.file_path, + } + + +@router.delete("/{config_id}", summary="Delete tool config", description="Delete a tool config.") +async def delete_config( + config_id: uuid.UUID, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> None: + """Delete a tool config.""" + config = await session.get(ToolConfig, config_id) + if config is None or config.user_id != user_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config not found") + + await session.delete(config) + await session.commit() diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py index 2b2af59..c240552 100644 --- a/apps/api/src/api/tool_instances.py +++ b/apps/api/src/api/tool_instances.py @@ -222,21 +222,23 @@ async def list_instances( ) instances = result.scalars().all() - return { - "instances": [ - { - "id": str(i.id), - "name": i.name, - "display_name": i.display_name, - "tool_type_id": str(i.tool_type_id), - "status": i.status, - "url": i.url, - "port": i.port, - "created_at": i.created_at.isoformat(), - } - for i in instances - ] - } + instances_data = [] + for i in instances: + tool_type = await session.get(ToolType, i.tool_type_id) + instances_data.append({ + "id": str(i.id), + "name": i.name, + "display_name": i.display_name, + "tool_type_id": str(i.tool_type_id), + "tool_type_name": tool_type.name if tool_type else "unknown", + "tool_type_interfaces": tool_type.interfaces if tool_type else [], + "status": i.status, + "url": i.url, + "port": i.port, + "created_at": i.created_at.isoformat(), + }) + + return {"instances": instances_data} @router.get( @@ -727,6 +729,7 @@ async def get_user_sessions( "display_name": instance.display_name, "tool_type_name": tool_type.name if tool_type else "unknown", "tool_icon": tool_type.name if tool_type else "code", + "tool_type_interfaces": tool_type.interfaces if tool_type else [], "repository_name": repo.name if repo else "unknown", "project_name": project.name if project else "unknown", "status": instance.status, diff --git a/apps/api/src/api/tool_types.py b/apps/api/src/api/tool_types.py index 10136ce..b16e848 100644 --- a/apps/api/src/api/tool_types.py +++ b/apps/api/src/api/tool_types.py @@ -115,6 +115,8 @@ class ToolTypeResponse(BaseModel): name: str display_name: str description: str | None + category: str + interfaces: list[str] compose_template: str required_variables: list[str] is_builtin: bool diff --git a/apps/api/src/main.py b/apps/api/src/main.py index 97482db..dcd7002 100644 --- a/apps/api/src/main.py +++ b/apps/api/src/main.py @@ -16,6 +16,7 @@ from src.api.projects import router as projects_router from src.api.ssh_keys import router as ssh_keys_router from src.api.terminal import router as terminal_router from src.api.instance_proxy import router as instance_proxy_router +from src.api.tool_configs import router as tool_configs_router from src.api.tool_instances import router as tool_instances_router from src.api.tool_instances import sessions_router from src.api.tool_types import router as tool_types_router @@ -106,6 +107,8 @@ async def seed_builtin_tool_types(): "name": "code-server", "display_name": "VS Code Server", "description": "VS Code running in the browser via code-server", + "category": "editor", + "interfaces": ["web"], "compose_template": """version: "3.8" services: code-server: @@ -126,6 +129,8 @@ services: "name": "jupyter-notebook", "display_name": "Jupyter Notebook", "description": "Jupyter Lab for interactive development", + "category": "notebook", + "interfaces": ["web"], "compose_template": """version: "3.8" services: jupyter: @@ -140,6 +145,35 @@ services: restart: unless-stopped""", "required_variables": ["REPO_PATH", "TOOL_NAME"], }, + { + "name": "opencode", + "display_name": "OpenCode", + "description": "AI coding assistant in the terminal", + "category": "ai-assistant", + "interfaces": ["terminal"], + "compose_template": """version: "3.8" +services: + opencode: + image: node:20-slim + container_name: {{TOOL_NAME}} + working_dir: /workspace + environment: + - HOME=/tmp + volumes: + - {{REPO_PATH}}:/workspace + - opencode_home:/tmp + command: > + sh -c "npm install -g opencode@latest && + mkdir -p /workspace && + tail -f /dev/null" + stdin_open: true + tty: true + restart: unless-stopped + +volumes: + opencode_home:""", + "required_variables": ["REPO_PATH", "TOOL_NAME"], + }, ] for tool_data in builtin_types: @@ -149,6 +183,8 @@ services: name=tool_data["name"], display_name=tool_data["display_name"], description=tool_data["description"], + category=tool_data["category"], + interfaces=tool_data["interfaces"], compose_template=tool_data["compose_template"], required_variables=tool_data["required_variables"], is_builtin=True, @@ -184,6 +220,7 @@ app.include_router(git_repositories_router) app.include_router(user_config_router) app.include_router(tool_types_router) app.include_router(tool_instances_router) +app.include_router(tool_configs_router) app.include_router(sessions_router) app.include_router(instance_proxy_router) app.include_router(terminal_router) diff --git a/apps/api/src/models/tool_config.py b/apps/api/src/models/tool_config.py new file mode 100644 index 0000000..cf403d9 --- /dev/null +++ b/apps/api/src/models/tool_config.py @@ -0,0 +1,39 @@ +import uuid +from typing import TYPE_CHECKING + +from sqlalchemy import ForeignKey, String, Text +from sqlalchemy import Uuid as UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin + +if TYPE_CHECKING: + from src.models.project import Project + from src.models.tool_type import ToolType + from src.models.user import User + + +class ToolConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base): + __tablename__ = "tool_configs" + + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(), ForeignKey("users.id"), nullable=False + ) + tool_type_id: Mapped[uuid.UUID] = mapped_column( + UUID(), ForeignKey("tool_types.id"), nullable=False + ) + project_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(), ForeignKey("projects.id"), nullable=True + ) + key: Mapped[str] = mapped_column(String(255), nullable=False) + value: Mapped[str] = mapped_column(Text, nullable=False) + config_type: Mapped[str] = mapped_column( + String(20), nullable=False, default="env" + ) # "env" or "file" + file_path: Mapped[str | None] = mapped_column( + String(1024), nullable=True + ) # Only for file type + + user: Mapped["User"] = relationship() + tool_type: Mapped["ToolType"] = relationship() + project: Mapped["Project | None"] = relationship() diff --git a/apps/api/src/models/tool_type.py b/apps/api/src/models/tool_type.py index f5418e4..5680604 100644 --- a/apps/api/src/models/tool_type.py +++ b/apps/api/src/models/tool_type.py @@ -17,6 +17,8 @@ class ToolType(UUIDPrimaryKeyMixin, TimestampMixin, Base): name: Mapped[str] = mapped_column(String(255), unique=True, nullable=False) display_name: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[str | None] = mapped_column(Text, nullable=True) + category: Mapped[str] = mapped_column(String(50), nullable=False, default="other") + interfaces: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False) compose_template: Mapped[str] = mapped_column(Text, nullable=False) required_variables: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False) is_builtin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) diff --git a/apps/web/src/api/sessions.ts b/apps/web/src/api/sessions.ts index 9f807d8..8c7eae7 100644 --- a/apps/web/src/api/sessions.ts +++ b/apps/web/src/api/sessions.ts @@ -5,6 +5,8 @@ export interface ToolInstance { name: string; display_name: string; tool_type_id: string; + tool_type_name: string; + tool_type_interfaces: string[]; status: string; url: string | null; port: number | null; @@ -16,6 +18,7 @@ export interface Session { display_name: string; tool_type_name: string; tool_icon: string; + tool_type_interfaces: string[]; repository_name: string; project_name: string; status: string; diff --git a/apps/web/src/api/tool_types.ts b/apps/web/src/api/tool_types.ts index 1480b5f..adbd8db 100644 --- a/apps/web/src/api/tool_types.ts +++ b/apps/web/src/api/tool_types.ts @@ -5,6 +5,8 @@ export interface ToolType { name: string; display_name: string; description: string | null; + category: string; + interfaces: string[]; compose_template: string; required_variables: string[]; is_builtin: boolean; diff --git a/apps/web/src/components/instance-list.tsx b/apps/web/src/components/instance-list.tsx index a7c0419..e834b3c 100644 --- a/apps/web/src/components/instance-list.tsx +++ b/apps/web/src/components/instance-list.tsx @@ -147,7 +147,7 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
- {instance.status === "running" && instance.url && ( + {instance.status === "running" && instance.url && instance.tool_type_interfaces.includes("web") && ( )} - {instance.status === "running" && ( + {instance.status === "running" && instance.tool_type_interfaces.includes("terminal") && (