feat(tool-config): add categories, interfaces, and config management

Add support for tool categories, interface types, and per-tool configuration.

Backend:
- Add category and interfaces fields to ToolType model
- Create ToolConfig model for storing tool-specific settings
- Add tool_configs API endpoints (CRUD)
- Update built-in tool types with categories and interfaces:
  - code-server: editor, [web]
  - jupyter-notebook: notebook, [web]
  - opencode: ai-assistant, [terminal]
- Update instance API to include tool type interfaces
- Create Alembic migrations 0008 and 0009

Frontend:
- Update ToolType and Session interfaces with new fields
- Conditionally show Open/Terminal buttons based on tool interfaces
- Add API client for tool configs

OpenSpec: tool-config-management change created and implemented.
This commit is contained in:
Fusion
2026-05-20 11:03:09 +02:00
parent 74b5d0dc8c
commit 63ae706dd0
17 changed files with 539 additions and 17 deletions
@@ -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")
@@ -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")
+173
View File
@@ -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()
+18 -15
View File
@@ -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,
+2
View File
@@ -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
+37
View File
@@ -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)
+39
View File
@@ -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()
+2
View File
@@ -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)
+3
View File
@@ -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;
+2
View File
@@ -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;
+2 -2
View File
@@ -147,7 +147,7 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
</div>
</div>
<div className="instance-actions">
{instance.status === "running" && instance.url && (
{instance.status === "running" && instance.url && instance.tool_type_interfaces.includes("web") && (
<a
href={`${API_BASE_URL}${instance.url}`}
target="_blank"
@@ -158,7 +158,7 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
Open
</a>
)}
{instance.status === "running" && (
{instance.status === "running" && instance.tool_type_interfaces.includes("terminal") && (
<button
className="secondary-button small"
onClick={() => navigate(`/instances/${instance.id}/terminal`)}
+1
View File
@@ -5,6 +5,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;
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-20
@@ -0,0 +1,65 @@
## Context
Tool instances currently start with hardcoded compose templates. There's no way for users to provide API keys (OpenAI, Anthropic), custom settings, or files that tools need. We need a flexible config system that supports both environment variables and file-based configs.
## Goals / Non-Goals
**Goals:**
- Store tool configs per user (global) and per project
- Support env vars and file-based configs
- Mount configs into containers at startup
- Add tool categories (editor, notebook, ai-assistant)
- Add interface types (web, terminal) to control UI
- Add OpenCode as built-in terminal tool
**Non-Goals:**
- Secret encryption at rest (for now)
- Config validation beyond basic type checking
- Per-instance configs (only global and project-scoped)
## Decisions
### Config scope: user-global and user+project
**Decision:** Two scopes - global (user-level) and project-specific (user+project level)
**Rationale:** Some configs (like OpenAI API key) are user-global. Others (like project-specific paths) are per-project.
### Config types: env and file
**Decision:** Support two config types: `env` (injected as environment variables) and `file` (written to files and mounted)
**Rationale:** Most tools need env vars. Some (like OpenCode) need config files.
### Tool categories as enum
**Decision:** Predefined categories: `editor`, `notebook`, `ai-assistant`, `other`
**Rationale:** Simple, predictable, drives UI behavior.
### Interfaces as array
**Decision:** ToolType.interfaces is a JSON array of strings: `["web"]`, `["terminal"]`, `["web", "terminal"]`
**Rationale:** Flexible, allows combination interfaces.
## Risks / Trade-offs
**[Risk]** Config files in container filesystem are readable by any process in container
**Mitigation:** Document this. Future: use Docker secrets for sensitive values.
**[Risk]** Storing API keys in plain text in database
**Mitigation:** Acceptable for MVP. Future: encrypt sensitive configs.
## Migration Plan
1. Create migrations for tool_types (category, interfaces) and tool_configs tables
2. Update seed data for built-in types
3. Deploy backend changes
4. Update frontend to show categories and config UI
5. Test with OpenCode instance
## Open Questions
- Should we encrypt sensitive configs now or later?
- Do we need config templates/tooling per tool type?
@@ -0,0 +1,26 @@
## Why
Tool instances need configuration (API keys, settings, files) that varies by user and project. Currently there's no way to manage these configs. Users need to store LLM API keys, editor preferences, and tool-specific settings that get mounted into containers at runtime.
## What Changes
- Add ToolConfig model for storing key-value configs per user/project/tool
- Add category and interfaces fields to ToolType model
- Create API for managing tool configs (global and project-scoped)
- Mount configs into containers when starting instances
- Add OpenCode as built-in tool type with terminal interface
- Update frontend to show tool categories and interface-appropriate actions
## Capabilities
### New Capabilities
- `tool-config-management`: Store and manage tool configurations
- `tool-categories`: Categorize tools and expose appropriate interfaces
### Modified Capabilities
- `tool-types`: Add category and interfaces fields
## Impact
- Backend: New model, API endpoints, container startup changes
- Frontend: Config management UI, category display
- Database: New tool_configs table, migrations for tool_types
@@ -0,0 +1,46 @@
## ADDED Requirements
### Requirement: Tool configs can be stored per user
The system SHALL allow users to store configuration values for tool types.
#### Scenario: Save global config
- **WHEN** a user saves a config value for a tool type
- **THEN** the config is stored with user_id and tool_type_id
- **AND** it is available for all future instances of that tool
#### Scenario: Save project-specific config
- **WHEN** a user saves a config value with a project_id
- **THEN** the config is scoped to that project
- **AND** it overrides global config for that project
### Requirement: Configs support env and file types
The system SHALL support environment variable configs and file-based configs.
#### Scenario: Env config
- **WHEN** a config has type "env"
- **THEN** it is injected as an environment variable when starting the container
#### Scenario: File config
- **WHEN** a config has type "file"
- **THEN** it is written to a file in the container
- **AND** the file path is configurable
### Requirement: Tool types have categories and interfaces
The system SHALL categorize tool types and declare their interfaces.
#### Scenario: Web interface tool
- **WHEN** a tool type has interface "web"
- **THEN** the UI shows an "Open" button
#### Scenario: Terminal interface tool
- **WHEN** a tool type has interface "terminal"
- **THEN** the UI shows a "Terminal" button
### Requirement: OpenCode is available as built-in tool
The system SHALL include OpenCode as a built-in tool type with terminal interface.
#### Scenario: Create OpenCode instance
- **WHEN** a user creates an OpenCode instance
- **THEN** it starts a container with opencode installed
- **AND** the repo is mounted at /workspace
- **AND** the user can access it via terminal
@@ -0,0 +1,42 @@
## 1. Database & Models
- [ ] 1.1 Add category and interfaces fields to ToolType model
- [ ] 1.2 Create ToolConfig model with user/project/tool scopes
- [ ] 1.3 Create Alembic migrations for tool_types and tool_configs
## 2. Backend - Tool Config API
- [ ] 2.1 Create GET/POST/PUT/DELETE endpoints for tool configs
- [ ] 2.2 Support global and project-scoped configs
- [ ] 2.3 Mount configs into containers when starting instances
- [ ] 2.4 Update start_instance to inject env vars and write files
## 3. Backend - Tool Type Updates
- [ ] 3.1 Update ToolType API to include category and interfaces
- [ ] 3.2 Update seed data with categories and interfaces
- [ ] 3.3 Add OpenCode as built-in tool type
## 4. Frontend - Tool Config UI
- [ ] 4.1 Create tool config management page/component
- [ ] 4.2 Support env var and file config types
- [ ] 4.3 Show configs per tool type with global/project toggle
## 5. Frontend - Category & Interface Support
- [ ] 5.1 Display tool categories in lists
- [ ] 5.2 Show interface-appropriate actions (Open for web, Terminal for CLI)
- [ ] 5.3 Update instance list to check interfaces
## 6. OpenCode Integration
- [ ] 6.1 Create OpenCode compose template
- [ ] 6.2 Ensure terminal access works
- [ ] 6.3 Mount repo and configs correctly
## 7. Quality Gates
- [ ] 7.1 Run ruff and mypy
- [ ] 7.2 Run frontend typecheck and lint
- [ ] 7.3 Test end-to-end