feat: initial corvid — standalone ticket service extracted from homelab-dashboard
Extracts the ticket/attachment system into a reusable pip-installable package. Centralises homelab MCP server (tickets, attachments, system logs) here. - corvid package: models, router (make_router factory), config, standalone main - Alembic migration chain for fresh standalone deployments - Docker Compose for standalone deployment (port 8090) - mcp_server.py: moved from homelab-dashboard, CORVID_REPO_PATH configurable Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
37b2c21720
27 changed files with 1072 additions and 0 deletions
87
CLAUDE.md
Normal file
87
CLAUDE.md
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
# corvid
|
||||
|
||||
Standalone ticket-management service for AI agents. FastAPI + Postgres + MCP server.
|
||||
|
||||
Extracted from `homelab-dashboard` — the `homelab-dashboard` app continues to use
|
||||
corvid as a pip package (Option A integration).
|
||||
|
||||
## Package
|
||||
|
||||
```
|
||||
corvid/
|
||||
models.py — Ticket, TicketAttachment, Base (SQLAlchemy ORM)
|
||||
router.py — make_router(api_key, get_db, require_user=None) → APIRouter
|
||||
config.py — DATABASE_URL, TICKETS_API_KEY env vars
|
||||
main.py — standalone FastAPI app (no web UI)
|
||||
database.py — engine, get_db
|
||||
alembic/ — migration chain for standalone deployments
|
||||
mcp_server.py — FastMCP server; primary agent interface
|
||||
```
|
||||
|
||||
## MCP server
|
||||
|
||||
`mcp_server.py` is the centralized homelab MCP server. It exposes:
|
||||
- Ticket CRUD + attachments (via `HOMEDASH_URL` API)
|
||||
- Homelab system logs (`/api/logs`)
|
||||
|
||||
Configure in `~/.claude/mcp.json`:
|
||||
|
||||
```json
|
||||
"homelab-tickets": {
|
||||
"command": "python3",
|
||||
"args": ["/home/caoimhinr/Projects/corvid/mcp_server.py"],
|
||||
"env": {
|
||||
"HOMEDASH_URL": "https://homedash.welvaert.org",
|
||||
"HOMEDASH_API_KEY": "<TICKETS_API_KEY>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`TICKETS_API_KEY` value is in `pve/ansible/host_vars/hetzner.yml` (gitignored).
|
||||
|
||||
## Standalone deployment
|
||||
|
||||
```bash
|
||||
cp .env.example .env # set DATABASE_URL and TICKETS_API_KEY
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Default port: `8090` (maps to internal `8080`).
|
||||
|
||||
## homelab-dashboard integration
|
||||
|
||||
The dashboard depends on corvid as an editable pip install:
|
||||
|
||||
```bash
|
||||
# Local dev
|
||||
pip install -e ~/Projects/corvid
|
||||
|
||||
# VPS deploy — corvid is rsynced alongside the dashboard, then installed
|
||||
rsync -az ~/Projects/corvid/ root@157.180.29.73:/opt/corvid/
|
||||
ssh root@157.180.29.73 'pip install -e /opt/corvid'
|
||||
```
|
||||
|
||||
The dashboard's `Dockerfile` copies corvid source before installing:
|
||||
|
||||
```dockerfile
|
||||
COPY ../corvid/ /tmp/corvid/ # requires parent-dir build context
|
||||
RUN pip install --no-cache-dir /tmp/corvid/
|
||||
```
|
||||
|
||||
See `homelab-dashboard/CLAUDE.md` for the full deploy procedure.
|
||||
|
||||
## Local dev
|
||||
|
||||
```bash
|
||||
pip install -e ".[dev,mcp]"
|
||||
TICKETS_API_KEY="" DATABASE_URL="sqlite+aiosqlite:///dev.db" \
|
||||
uvicorn corvid.main:app --reload --port 8090
|
||||
```
|
||||
|
||||
## DB migrations
|
||||
|
||||
Alembic runs at startup (`entrypoint.sh`). Add new migrations in
|
||||
`alembic/versions/` following the `NNNN_<slug>.py` naming convention.
|
||||
|
||||
The dashboard keeps its own separate migration chain — corvid's migrations
|
||||
are only for fresh standalone deployments.
|
||||
14
Dockerfile
Normal file
14
Dockerfile
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml .
|
||||
RUN pip install --no-cache-dir -e ".[mcp]"
|
||||
|
||||
COPY alembic.ini .
|
||||
COPY alembic/ alembic/
|
||||
COPY corvid/ corvid/
|
||||
COPY entrypoint.sh .
|
||||
RUN chmod +x entrypoint.sh
|
||||
|
||||
EXPOSE 8080
|
||||
CMD ["./entrypoint.sh"]
|
||||
41
alembic.ini
Normal file
41
alembic.ini
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
[alembic]
|
||||
script_location = alembic
|
||||
file_template = %%(version_num)s_%%(slug)s
|
||||
prepend_sys_path = .
|
||||
version_path_separator = os
|
||||
|
||||
sqlalchemy.url =
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
33
alembic/env.py
Normal file
33
alembic/env.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from alembic import context
|
||||
|
||||
alembic_cfg = context.config
|
||||
|
||||
if alembic_cfg.config_file_name is not None:
|
||||
fileConfig(alembic_cfg.config_file_name)
|
||||
|
||||
from corvid import config as app_config
|
||||
from corvid.models import Base # noqa: E402 — registers models
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def do_run_migrations(connection):
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations():
|
||||
engine = create_async_engine(app_config.DATABASE_URL)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(do_run_migrations)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
run_migrations_online = lambda: asyncio.run(run_async_migrations())
|
||||
run_migrations_online()
|
||||
24
alembic/script.py.mako
Normal file
24
alembic/script.py.mako
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
53
alembic/versions/0001_tickets_schema.py
Normal file
53
alembic/versions/0001_tickets_schema.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
"""tickets and ticket_attachments initial schema
|
||||
|
||||
Revision ID: 0001
|
||||
Revises:
|
||||
Create Date: 2026-05-25
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0001"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"tickets",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("guid", sa.String(36), nullable=False, unique=True),
|
||||
sa.Column("user", sa.String(64), nullable=False, server_default="kevin"),
|
||||
sa.Column("title", sa.String(256), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("submitted_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("status", sa.String(16), nullable=False, server_default="open"),
|
||||
sa.Column("type", sa.String(16), nullable=False, server_default="feature"),
|
||||
sa.Column("parent_id", sa.Integer(),
|
||||
sa.ForeignKey("tickets.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("effort", sa.Integer(), nullable=True),
|
||||
sa.Column("startup_script", sa.Text(), nullable=True),
|
||||
)
|
||||
op.create_index("ix_tickets_parent_id", "tickets", ["parent_id"])
|
||||
|
||||
op.create_table(
|
||||
"ticket_attachments",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("ticket_id", sa.Integer(),
|
||||
sa.ForeignKey("tickets.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("title", sa.String(256), nullable=False),
|
||||
sa.Column("content", sa.Text(), nullable=False),
|
||||
sa.Column("created_by", sa.String(64), nullable=False, server_default="kevin"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
op.create_index("ix_ticket_attachments_ticket_id", "ticket_attachments", ["ticket_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_ticket_attachments_ticket_id", table_name="ticket_attachments")
|
||||
op.drop_table("ticket_attachments")
|
||||
op.drop_index("ix_tickets_parent_id", table_name="tickets")
|
||||
op.drop_table("tickets")
|
||||
18
corvid.egg-info/PKG-INFO
Normal file
18
corvid.egg-info/PKG-INFO
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
Metadata-Version: 2.4
|
||||
Name: corvid
|
||||
Version: 0.1.0
|
||||
Summary: Standalone ticket management service for AI agents
|
||||
Requires-Python: >=3.12
|
||||
Requires-Dist: fastapi>=0.115
|
||||
Requires-Dist: sqlalchemy[asyncio]>=2.0
|
||||
Requires-Dist: asyncpg>=0.30
|
||||
Requires-Dist: uvicorn[standard]>=0.32
|
||||
Requires-Dist: alembic>=1.14
|
||||
Requires-Dist: pydantic>=2.0
|
||||
Provides-Extra: mcp
|
||||
Requires-Dist: mcp[cli]>=1.0; extra == "mcp"
|
||||
Requires-Dist: httpx>=0.28; extra == "mcp"
|
||||
Provides-Extra: dev
|
||||
Requires-Dist: pytest>=8.0; extra == "dev"
|
||||
Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
|
||||
Requires-Dist: aiosqlite>=0.20; extra == "dev"
|
||||
12
corvid.egg-info/SOURCES.txt
Normal file
12
corvid.egg-info/SOURCES.txt
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
pyproject.toml
|
||||
corvid/__init__.py
|
||||
corvid/config.py
|
||||
corvid/database.py
|
||||
corvid/main.py
|
||||
corvid/models.py
|
||||
corvid/router.py
|
||||
corvid.egg-info/PKG-INFO
|
||||
corvid.egg-info/SOURCES.txt
|
||||
corvid.egg-info/dependency_links.txt
|
||||
corvid.egg-info/requires.txt
|
||||
corvid.egg-info/top_level.txt
|
||||
1
corvid.egg-info/dependency_links.txt
Normal file
1
corvid.egg-info/dependency_links.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
15
corvid.egg-info/requires.txt
Normal file
15
corvid.egg-info/requires.txt
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
fastapi>=0.115
|
||||
sqlalchemy[asyncio]>=2.0
|
||||
asyncpg>=0.30
|
||||
uvicorn[standard]>=0.32
|
||||
alembic>=1.14
|
||||
pydantic>=2.0
|
||||
|
||||
[dev]
|
||||
pytest>=8.0
|
||||
pytest-asyncio>=0.24
|
||||
aiosqlite>=0.20
|
||||
|
||||
[mcp]
|
||||
mcp[cli]>=1.0
|
||||
httpx>=0.28
|
||||
1
corvid.egg-info/top_level.txt
Normal file
1
corvid.egg-info/top_level.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
corvid
|
||||
1
corvid/__init__.py
Normal file
1
corvid/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
__version__ = "0.1.0"
|
||||
BIN
corvid/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
corvid/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
corvid/__pycache__/config.cpython-312.pyc
Normal file
BIN
corvid/__pycache__/config.cpython-312.pyc
Normal file
Binary file not shown.
BIN
corvid/__pycache__/database.cpython-312.pyc
Normal file
BIN
corvid/__pycache__/database.cpython-312.pyc
Normal file
Binary file not shown.
BIN
corvid/__pycache__/main.cpython-312.pyc
Normal file
BIN
corvid/__pycache__/main.cpython-312.pyc
Normal file
Binary file not shown.
BIN
corvid/__pycache__/models.cpython-312.pyc
Normal file
BIN
corvid/__pycache__/models.cpython-312.pyc
Normal file
Binary file not shown.
BIN
corvid/__pycache__/router.cpython-312.pyc
Normal file
BIN
corvid/__pycache__/router.cpython-312.pyc
Normal file
Binary file not shown.
4
corvid/config.py
Normal file
4
corvid/config.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import os
|
||||
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://corvid:corvid@postgres:5432/corvid")
|
||||
TICKETS_API_KEY = os.getenv("TICKETS_API_KEY", "")
|
||||
12
corvid/database.py
Normal file
12
corvid/database.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from .config import DATABASE_URL
|
||||
from .models import Base # noqa: F401 — re-exported for alembic
|
||||
|
||||
engine = create_async_engine(DATABASE_URL, pool_pre_ping=True)
|
||||
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
|
||||
async def get_db():
|
||||
async with AsyncSessionLocal() as session:
|
||||
yield session
|
||||
22
corvid/main.py
Normal file
22
corvid/main.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from .config import TICKETS_API_KEY
|
||||
from .database import get_db
|
||||
from .router import make_router
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="Corvid", lifespan=lifespan)
|
||||
|
||||
app.include_router(make_router(api_key=TICKETS_API_KEY, get_db=get_db))
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
53
corvid/models.py
Normal file
53
corvid/models.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class TicketAttachment(Base):
|
||||
__tablename__ = "ticket_attachments"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
ticket_id: Mapped[int] = mapped_column(ForeignKey("tickets.id", ondelete="CASCADE"), nullable=False)
|
||||
title: Mapped[str] = mapped_column(String(256), nullable=False)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
created_by: Mapped[str] = mapped_column(String(64), nullable=False, default="kevin")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
ticket: Mapped["Ticket"] = relationship("Ticket", back_populates="attachments")
|
||||
|
||||
|
||||
class Ticket(Base):
|
||||
__tablename__ = "tickets"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
guid: Mapped[str] = mapped_column(String(36), unique=True, nullable=False,
|
||||
default=lambda: str(uuid.uuid4()))
|
||||
user: Mapped[str] = mapped_column(String(64), nullable=False, default="kevin")
|
||||
title: Mapped[str] = mapped_column(String(256), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
submitted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc))
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="open")
|
||||
type: Mapped[str] = mapped_column(String(16), nullable=False, default="feature")
|
||||
parent_id: Mapped[int | None] = mapped_column(ForeignKey("tickets.id", ondelete="SET NULL"), nullable=True)
|
||||
effort: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
startup_script: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
attachments: Mapped[list["TicketAttachment"]] = relationship(
|
||||
"TicketAttachment",
|
||||
back_populates="ticket",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="TicketAttachment.created_at",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
@property
|
||||
def human_id(self) -> str:
|
||||
return f"HOME-{self.id:03d}"
|
||||
304
corvid/router.py
Normal file
304
corvid/router.py
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from .models import Ticket, TicketAttachment
|
||||
|
||||
VALID_STATUSES = {"open", "ongoing", "completed", "abandoned"}
|
||||
VALID_TYPES = {"feature", "bug", "chore", "project"}
|
||||
VALID_USERS = {"kevin", "claude"}
|
||||
|
||||
|
||||
class TicketCreate(BaseModel):
|
||||
user: str = "kevin"
|
||||
title: str
|
||||
description: str | None = None
|
||||
status: str = "open"
|
||||
type: str = "feature"
|
||||
parent_id: int | None = None
|
||||
effort: int | None = None
|
||||
startup_script: str | None = None
|
||||
|
||||
|
||||
class TicketUpdate(BaseModel):
|
||||
user: str | None = None
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
status: str | None = None
|
||||
type: str | None = None
|
||||
parent_id: int | None = None
|
||||
effort: int | None = None
|
||||
startup_script: str | None = None
|
||||
|
||||
|
||||
class AttachmentCreate(BaseModel):
|
||||
title: str
|
||||
content: str
|
||||
created_by: str = "kevin"
|
||||
|
||||
|
||||
class AttachmentUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
content: str | None = None
|
||||
|
||||
|
||||
async def _ticket_dict(t: Ticket, db: AsyncSession) -> dict:
|
||||
child_ids = (await db.execute(
|
||||
select(Ticket.id).where(Ticket.parent_id == t.id).order_by(Ticket.id)
|
||||
)).scalars().all()
|
||||
return {
|
||||
"id": t.id,
|
||||
"human_id": t.human_id,
|
||||
"guid": t.guid,
|
||||
"user": t.user,
|
||||
"title": t.title,
|
||||
"description": t.description,
|
||||
"submitted_at": t.submitted_at.isoformat() if t.submitted_at else None,
|
||||
"status": t.status,
|
||||
"type": t.type,
|
||||
"parent_id": t.parent_id,
|
||||
"child_ids": list(child_ids),
|
||||
"attachment_count": len(t.attachments),
|
||||
"effort": t.effort,
|
||||
"startup_script": t.startup_script,
|
||||
}
|
||||
|
||||
|
||||
def _att_dict(a: TicketAttachment) -> dict:
|
||||
return {
|
||||
"id": a.id,
|
||||
"ticket_id": a.ticket_id,
|
||||
"title": a.title,
|
||||
"content": a.content,
|
||||
"created_by": a.created_by,
|
||||
"created_at": a.created_at.isoformat() if a.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
async def _get_ticket_or_404(db: AsyncSession, ticket_id: int) -> Ticket:
|
||||
t = (await db.execute(select(Ticket).where(Ticket.id == ticket_id))).scalar_one_or_none()
|
||||
if not t:
|
||||
raise HTTPException(404, "ticket not found")
|
||||
return t
|
||||
|
||||
|
||||
def make_router(api_key: str, get_db, require_user=None) -> APIRouter:
|
||||
"""
|
||||
Return a FastAPI APIRouter with all /api/tickets/* endpoints.
|
||||
|
||||
Args:
|
||||
api_key: Value of TICKETS_API_KEY — requests presenting this in
|
||||
X-Api-Key are authenticated as the service account.
|
||||
get_db: FastAPI dependency yielding an AsyncSession.
|
||||
require_user: Optional FastAPI dependency / callable(request) that
|
||||
returns a user dict or raises. When provided, web
|
||||
sessions are accepted alongside the API key. When
|
||||
omitted, only API-key auth is accepted.
|
||||
"""
|
||||
router = APIRouter()
|
||||
|
||||
def _api_key_ok(x_api_key: str | None) -> bool:
|
||||
return bool(api_key and x_api_key == api_key)
|
||||
|
||||
def require_ticket_auth(
|
||||
request: Request,
|
||||
x_api_key: str | None = Header(default=None),
|
||||
):
|
||||
if _api_key_ok(x_api_key):
|
||||
return {"username": "claude", "email": "claude@internal"}
|
||||
if require_user is not None:
|
||||
return require_user(request)
|
||||
raise HTTPException(401, "unauthorized")
|
||||
|
||||
# ── Ticket endpoints ──────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/api/tickets")
|
||||
async def list_tickets(
|
||||
status: list[str] = Query(default=[]),
|
||||
type: list[str] = Query(default=[]),
|
||||
q: str = Query(default=""),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(require_ticket_auth),
|
||||
):
|
||||
stmt = select(Ticket).order_by(Ticket.submitted_at.desc())
|
||||
if status:
|
||||
stmt = stmt.where(Ticket.status.in_(status))
|
||||
if type:
|
||||
stmt = stmt.where(Ticket.type.in_(type))
|
||||
if q:
|
||||
term = f"%{q}%"
|
||||
stmt = stmt.where(or_(Ticket.title.ilike(term), Ticket.description.ilike(term)))
|
||||
rows = (await db.execute(stmt)).scalars().all()
|
||||
return [await _ticket_dict(t, db) for t in rows]
|
||||
|
||||
@router.post("/api/tickets", status_code=201)
|
||||
async def create_ticket(
|
||||
body: TicketCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(require_ticket_auth),
|
||||
):
|
||||
if body.status not in VALID_STATUSES:
|
||||
raise HTTPException(400, f"status must be one of {sorted(VALID_STATUSES)}")
|
||||
if body.type not in VALID_TYPES:
|
||||
raise HTTPException(400, f"type must be one of {sorted(VALID_TYPES)}")
|
||||
if body.user not in VALID_USERS:
|
||||
raise HTTPException(400, f"user must be one of {sorted(VALID_USERS)}")
|
||||
if body.effort is not None and not (1 <= body.effort <= 10):
|
||||
raise HTTPException(400, "effort must be between 1 and 10")
|
||||
if body.parent_id is not None:
|
||||
await _get_ticket_or_404(db, body.parent_id)
|
||||
t = Ticket(
|
||||
guid=str(uuid.uuid4()),
|
||||
user=body.user,
|
||||
title=body.title.strip(),
|
||||
description=body.description,
|
||||
status=body.status,
|
||||
type=body.type,
|
||||
parent_id=body.parent_id,
|
||||
effort=body.effort,
|
||||
startup_script=body.startup_script,
|
||||
)
|
||||
db.add(t)
|
||||
await db.commit()
|
||||
return await _ticket_dict(await _get_ticket_or_404(db, t.id), db)
|
||||
|
||||
@router.get("/api/tickets/{ticket_id}")
|
||||
async def get_ticket(
|
||||
ticket_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(require_ticket_auth),
|
||||
):
|
||||
return await _ticket_dict(await _get_ticket_or_404(db, ticket_id), db)
|
||||
|
||||
@router.patch("/api/tickets/{ticket_id}")
|
||||
async def update_ticket(
|
||||
ticket_id: int,
|
||||
body: TicketUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(require_ticket_auth),
|
||||
):
|
||||
t = await _get_ticket_or_404(db, ticket_id)
|
||||
if body.status is not None:
|
||||
if body.status not in VALID_STATUSES:
|
||||
raise HTTPException(400, f"status must be one of {sorted(VALID_STATUSES)}")
|
||||
t.status = body.status
|
||||
if body.type is not None:
|
||||
if body.type not in VALID_TYPES:
|
||||
raise HTTPException(400, f"type must be one of {sorted(VALID_TYPES)}")
|
||||
t.type = body.type
|
||||
if body.user is not None:
|
||||
if body.user not in VALID_USERS:
|
||||
raise HTTPException(400, f"user must be one of {sorted(VALID_USERS)}")
|
||||
t.user = body.user
|
||||
if body.title is not None:
|
||||
t.title = body.title.strip()
|
||||
if body.description is not None:
|
||||
t.description = body.description
|
||||
if "parent_id" in body.model_fields_set:
|
||||
if body.parent_id is not None:
|
||||
if body.parent_id == ticket_id:
|
||||
raise HTTPException(400, "ticket cannot be its own parent")
|
||||
await _get_ticket_or_404(db, body.parent_id)
|
||||
t.parent_id = body.parent_id
|
||||
if "effort" in body.model_fields_set:
|
||||
if body.effort is not None and not (1 <= body.effort <= 10):
|
||||
raise HTTPException(400, "effort must be between 1 and 10")
|
||||
t.effort = body.effort
|
||||
if "startup_script" in body.model_fields_set:
|
||||
t.startup_script = body.startup_script
|
||||
await db.commit()
|
||||
return await _ticket_dict(await _get_ticket_or_404(db, ticket_id), db)
|
||||
|
||||
@router.delete("/api/tickets/{ticket_id}")
|
||||
async def delete_ticket(
|
||||
ticket_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(require_ticket_auth),
|
||||
):
|
||||
t = await _get_ticket_or_404(db, ticket_id)
|
||||
await db.delete(t)
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
# ── Attachment endpoints ──────────────────────────────────────────────────
|
||||
|
||||
@router.get("/api/tickets/{ticket_id}/attachments")
|
||||
async def list_attachments(
|
||||
ticket_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(require_ticket_auth),
|
||||
):
|
||||
await _get_ticket_or_404(db, ticket_id)
|
||||
rows = (await db.execute(
|
||||
select(TicketAttachment)
|
||||
.where(TicketAttachment.ticket_id == ticket_id)
|
||||
.order_by(TicketAttachment.created_at)
|
||||
)).scalars().all()
|
||||
return [_att_dict(a) for a in rows]
|
||||
|
||||
@router.post("/api/tickets/{ticket_id}/attachments", status_code=201)
|
||||
async def create_attachment(
|
||||
ticket_id: int,
|
||||
body: AttachmentCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(require_ticket_auth),
|
||||
):
|
||||
await _get_ticket_or_404(db, ticket_id)
|
||||
if not body.title.strip():
|
||||
raise HTTPException(400, "title is required")
|
||||
if not body.content.strip():
|
||||
raise HTTPException(400, "content is required")
|
||||
if body.created_by not in VALID_USERS:
|
||||
raise HTTPException(400, f"created_by must be one of {sorted(VALID_USERS)}")
|
||||
a = TicketAttachment(
|
||||
ticket_id=ticket_id,
|
||||
title=body.title.strip(),
|
||||
content=body.content,
|
||||
created_by=body.created_by,
|
||||
)
|
||||
db.add(a)
|
||||
await db.commit()
|
||||
await db.refresh(a)
|
||||
return _att_dict(a)
|
||||
|
||||
@router.patch("/api/tickets/{ticket_id}/attachments/{att_id}")
|
||||
async def update_attachment(
|
||||
ticket_id: int,
|
||||
att_id: int,
|
||||
body: AttachmentUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(require_ticket_auth),
|
||||
):
|
||||
a = await db.get(TicketAttachment, att_id)
|
||||
if not a or a.ticket_id != ticket_id:
|
||||
raise HTTPException(404, "attachment not found")
|
||||
if body.title is not None:
|
||||
if not body.title.strip():
|
||||
raise HTTPException(400, "title cannot be empty")
|
||||
a.title = body.title.strip()
|
||||
if body.content is not None:
|
||||
if not body.content.strip():
|
||||
raise HTTPException(400, "content cannot be empty")
|
||||
a.content = body.content
|
||||
await db.commit()
|
||||
await db.refresh(a)
|
||||
return _att_dict(a)
|
||||
|
||||
@router.delete("/api/tickets/{ticket_id}/attachments/{att_id}")
|
||||
async def delete_attachment(
|
||||
ticket_id: int,
|
||||
att_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(require_ticket_auth),
|
||||
):
|
||||
a = await db.get(TicketAttachment, att_id)
|
||||
if not a or a.ticket_id != ticket_id:
|
||||
raise HTTPException(404, "attachment not found")
|
||||
await db.delete(a)
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
return router
|
||||
28
docker-compose.yml
Normal file
28
docker-compose.yml
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: corvid
|
||||
POSTGRES_PASSWORD: corvid
|
||||
POSTGRES_DB: corvid
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U corvid"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
app:
|
||||
build: .
|
||||
ports:
|
||||
- "8090:8080"
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
4
entrypoint.sh
Normal file
4
entrypoint.sh
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
#!/bin/sh
|
||||
set -e
|
||||
alembic upgrade head
|
||||
exec uvicorn corvid.main:app --host 0.0.0.0 --port 8080
|
||||
320
mcp_server.py
Normal file
320
mcp_server.py
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Corvid MCP server — homelab agent tools.
|
||||
|
||||
Provides ticket management and homelab monitoring tools for Claude Code agents.
|
||||
|
||||
Configure in ~/.claude/mcp.json:
|
||||
"homelab-tickets": {
|
||||
"command": "python3",
|
||||
"args": ["/home/caoimhinr/Projects/corvid/mcp_server.py"],
|
||||
"env": {
|
||||
"HOMEDASH_URL": "https://homedash.welvaert.org",
|
||||
"HOMEDASH_API_KEY": "<TICKETS_API_KEY value>"
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import httpx
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
HOMEDASH_URL = os.environ.get("HOMEDASH_URL", "http://localhost:8080").rstrip("/")
|
||||
API_KEY = os.environ.get("HOMEDASH_API_KEY", "")
|
||||
|
||||
mcp = FastMCP("homelab-tickets")
|
||||
|
||||
_HEADERS = {"X-Api-Key": API_KEY, "Content-Type": "application/json"}
|
||||
|
||||
# Repo root used in generated startup scripts (cd here before running claude)
|
||||
_REPO_PATH = os.environ.get("CORVID_REPO_PATH",
|
||||
os.path.expanduser("~/Projects/corvid"))
|
||||
|
||||
# MCP tool names always included so the agent can read and update its own ticket
|
||||
_BASE_TOOLS = [
|
||||
"Read",
|
||||
"mcp__homelab-tickets__get_ticket",
|
||||
"mcp__homelab-tickets__update_ticket",
|
||||
"mcp__homelab-tickets__add_attachment",
|
||||
"mcp__homelab-tickets__list_attachments",
|
||||
]
|
||||
|
||||
_HA_KEYWORDS = (
|
||||
"home assistant", " ha ", "climate", "temperature", "thermostat",
|
||||
"light", "sensor", "entity", "automation", "scene",
|
||||
)
|
||||
_WEB_KEYWORDS = (
|
||||
"research", "investigate", "look up", "documentation", "readme",
|
||||
" api ", "spec ", "rfc",
|
||||
)
|
||||
|
||||
|
||||
def _generate_startup_script(ticket_id: int, human_id: str, title: str,
|
||||
description: str | None, ticket_type: str) -> str:
|
||||
text = f"{title} {description or ''}".lower()
|
||||
tools = list(_BASE_TOOLS)
|
||||
if ticket_type in ("feature", "bug", "chore"):
|
||||
tools += ["Edit", "Write", "Bash"]
|
||||
if any(kw in text for kw in _WEB_KEYWORDS):
|
||||
tools += ["WebSearch", "WebFetch"]
|
||||
if any(kw in text for kw in _HA_KEYWORDS):
|
||||
tools += [
|
||||
"mcp__homeassistant__GetLiveContext",
|
||||
"mcp__homeassistant__HassTurnOn",
|
||||
"mcp__homeassistant__HassTurnOff",
|
||||
"mcp__homeassistant__HassLightSet",
|
||||
"mcp__homeassistant__HassClimateSetTemperature",
|
||||
]
|
||||
tools_str = ",".join(tools)
|
||||
prompt = (
|
||||
f"complete ticket {human_id}: {title}. "
|
||||
f"Start by calling mcp__homelab-tickets__get_ticket({ticket_id}) to read the full "
|
||||
f"ticket details, then complete the work and mark the ticket as completed."
|
||||
)
|
||||
return (
|
||||
f"#!/usr/bin/env bash\n"
|
||||
f"# Startup script for {human_id}: {title}\n"
|
||||
f"cd {_REPO_PATH}\n"
|
||||
f'claude "{prompt}" \\\n'
|
||||
f' --allowedTools "{tools_str}"\n'
|
||||
)
|
||||
|
||||
|
||||
def _client() -> httpx.Client:
|
||||
return httpx.Client(base_url=HOMEDASH_URL, headers=_HEADERS, timeout=10.0)
|
||||
|
||||
|
||||
def _check(r: httpx.Response) -> dict | list:
|
||||
if not r.is_success:
|
||||
raise RuntimeError(f"API error {r.status_code}: {r.text}")
|
||||
return r.json()
|
||||
|
||||
|
||||
# ── Ticket tools ──────────────────────────────────────────────────────────────
|
||||
|
||||
@mcp.tool()
|
||||
def list_tickets(status: str = "", type: str = "", q: str = "") -> list[dict]:
|
||||
"""
|
||||
List homelab tickets. Results include attachment_count so you know
|
||||
which tickets have supporting documents without fetching them.
|
||||
|
||||
Args:
|
||||
status: Filter by status — open, ongoing, completed, abandoned.
|
||||
Leave empty to list all statuses.
|
||||
type: Filter by type — feature, bug, chore, project.
|
||||
Leave empty to list all types.
|
||||
q: Full-text search query matched against ticket title and
|
||||
description (case-insensitive). Leave empty to skip.
|
||||
"""
|
||||
with _client() as c:
|
||||
params = {}
|
||||
if status:
|
||||
params["status"] = status
|
||||
if type:
|
||||
params["type"] = type
|
||||
if q:
|
||||
params["q"] = q
|
||||
return _check(c.get("/api/tickets", params=params))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_ticket(ticket_id: int) -> dict:
|
||||
"""
|
||||
Get a single ticket by its numeric ID, including attachment_count.
|
||||
Use list_attachments to fetch the actual attachment content.
|
||||
|
||||
Args:
|
||||
ticket_id: The integer primary key (e.g. 1 for HOME-001).
|
||||
"""
|
||||
with _client() as c:
|
||||
return _check(c.get(f"/api/tickets/{ticket_id}"))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def create_ticket(
|
||||
title: str,
|
||||
description: str = "",
|
||||
status: str = "open",
|
||||
type: str = "feature",
|
||||
user: str = "claude",
|
||||
parent_id: int = 0,
|
||||
effort: int = 0,
|
||||
) -> dict:
|
||||
"""
|
||||
Create a new homelab ticket. For longer supporting documents
|
||||
(proposals, reports, specs) prefer add_attachment over stuffing
|
||||
everything into description.
|
||||
|
||||
Args:
|
||||
title: Short summary of the work item.
|
||||
description: Markdown body with context, links, acceptance criteria.
|
||||
status: open (default), ongoing, completed, abandoned.
|
||||
type: feature (default), bug, chore, project.
|
||||
user: Who is submitting — kevin or claude (default: claude).
|
||||
parent_id: ID of the parent ticket (omit or pass 0 for no parent).
|
||||
effort: Estimated effort rating 1–10 (omit or pass 0 to leave unset).
|
||||
"""
|
||||
body: dict = {
|
||||
"title": title,
|
||||
"description": description or None,
|
||||
"status": status,
|
||||
"type": type,
|
||||
"user": user,
|
||||
}
|
||||
if parent_id:
|
||||
body["parent_id"] = parent_id
|
||||
if effort:
|
||||
body["effort"] = effort
|
||||
with _client() as c:
|
||||
ticket = _check(c.post("/api/tickets", json=body))
|
||||
script = _generate_startup_script(
|
||||
ticket["id"], ticket["human_id"], title, description, type
|
||||
)
|
||||
ticket = _check(c.patch(f"/api/tickets/{ticket['id']}", json={"startup_script": script}))
|
||||
return ticket
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def delete_ticket(ticket_id: int) -> dict:
|
||||
"""
|
||||
Permanently delete a ticket and all its attachments.
|
||||
|
||||
Args:
|
||||
ticket_id: The integer ID of the ticket to delete.
|
||||
"""
|
||||
with _client() as c:
|
||||
return _check(c.delete(f"/api/tickets/{ticket_id}"))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def update_ticket(
|
||||
ticket_id: int,
|
||||
title: str = "",
|
||||
description: str = "",
|
||||
status: str = "",
|
||||
type: str = "",
|
||||
user: str = "",
|
||||
parent_id: int = -1,
|
||||
effort: int = -1,
|
||||
) -> dict:
|
||||
"""
|
||||
Update an existing homelab ticket. Only provided (non-empty) fields
|
||||
are changed; omit a field to leave it unchanged.
|
||||
|
||||
Args:
|
||||
ticket_id: The integer ID of the ticket to update.
|
||||
title: New title.
|
||||
description: New markdown description.
|
||||
status: open, ongoing, completed, abandoned.
|
||||
type: feature, bug, chore, project.
|
||||
user: kevin or claude.
|
||||
parent_id: Set parent ticket ID; pass 0 to clear the parent.
|
||||
Omit (default -1) to leave unchanged.
|
||||
effort: Effort rating 1–10; pass 0 to clear it.
|
||||
Omit (default -1) to leave unchanged.
|
||||
"""
|
||||
body = {}
|
||||
if title: body["title"] = title
|
||||
if description: body["description"] = description
|
||||
if status: body["status"] = status
|
||||
if type: body["type"] = type
|
||||
if user: body["user"] = user
|
||||
if parent_id >= 0:
|
||||
body["parent_id"] = parent_id if parent_id > 0 else None
|
||||
if effort >= 0:
|
||||
body["effort"] = effort if effort > 0 else None
|
||||
if not body:
|
||||
raise ValueError("Provide at least one field to update")
|
||||
with _client() as c:
|
||||
ticket = _check(c.patch(f"/api/tickets/{ticket_id}", json=body))
|
||||
if title or description or type:
|
||||
script = _generate_startup_script(
|
||||
ticket["id"], ticket["human_id"],
|
||||
ticket["title"], ticket["description"], ticket["type"],
|
||||
)
|
||||
ticket = _check(c.patch(f"/api/tickets/{ticket_id}", json={"startup_script": script}))
|
||||
return ticket
|
||||
|
||||
|
||||
# ── Attachment tools ──────────────────────────────────────────────────────────
|
||||
|
||||
@mcp.tool()
|
||||
def list_attachments(ticket_id: int) -> list[dict]:
|
||||
"""
|
||||
List all markdown attachments on a ticket (title, created_by,
|
||||
created_at, and full content).
|
||||
|
||||
Args:
|
||||
ticket_id: The integer ID of the parent ticket.
|
||||
"""
|
||||
with _client() as c:
|
||||
return _check(c.get(f"/api/tickets/{ticket_id}/attachments"))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def add_attachment(
|
||||
ticket_id: int,
|
||||
title: str,
|
||||
content: str,
|
||||
created_by: str = "claude",
|
||||
) -> dict:
|
||||
"""
|
||||
Attach a markdown document to a ticket. Use this for implementation
|
||||
proposals, test plans, research notes, decision records, or any
|
||||
supporting document that is too long for the ticket description.
|
||||
|
||||
Args:
|
||||
ticket_id: The integer ID of the parent ticket.
|
||||
title: Descriptive name, e.g. "Implementation Proposal v1".
|
||||
content: Full markdown body of the document.
|
||||
created_by: kevin or claude (default: claude).
|
||||
"""
|
||||
with _client() as c:
|
||||
return _check(c.post(f"/api/tickets/{ticket_id}/attachments", json={
|
||||
"title": title,
|
||||
"content": content,
|
||||
"created_by": created_by,
|
||||
}))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def delete_attachment(ticket_id: int, attachment_id: int) -> dict:
|
||||
"""
|
||||
Permanently delete an attachment from a ticket.
|
||||
|
||||
Args:
|
||||
ticket_id: The integer ID of the parent ticket.
|
||||
attachment_id: The integer ID of the attachment to delete.
|
||||
"""
|
||||
with _client() as c:
|
||||
return _check(c.delete(f"/api/tickets/{ticket_id}/attachments/{attachment_id}"))
|
||||
|
||||
|
||||
# ── System log tools ──────────────────────────────────────────────────────────
|
||||
|
||||
@mcp.tool()
|
||||
def list_logs(
|
||||
level: str = "",
|
||||
source: str = "",
|
||||
resolved: bool = False,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
List homelab system log entries (infrastructure alerts, camera issues, disk).
|
||||
|
||||
Args:
|
||||
level: Filter by level — ERR, WARN, INFO. Leave empty for all.
|
||||
source: Filter by source — homelab, hassgrab, disk. Leave empty for all.
|
||||
resolved: Include resolved logs (default False = active only).
|
||||
"""
|
||||
with _client() as c:
|
||||
params: dict = {"resolved": str(resolved).lower()}
|
||||
if level:
|
||||
params["level"] = level
|
||||
if source:
|
||||
params["source"] = source
|
||||
return _check(c.get("/api/logs", params=params))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run(transport="stdio")
|
||||
25
pyproject.toml
Normal file
25
pyproject.toml
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "corvid"
|
||||
version = "0.1.0"
|
||||
description = "Standalone ticket management service for AI agents"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"fastapi>=0.115",
|
||||
"sqlalchemy[asyncio]>=2.0",
|
||||
"asyncpg>=0.30",
|
||||
"uvicorn[standard]>=0.32",
|
||||
"alembic>=1.14",
|
||||
"pydantic>=2.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
mcp = ["mcp[cli]>=1.0", "httpx>=0.28"]
|
||||
dev = ["pytest>=8.0", "pytest-asyncio>=0.24", "aiosqlite>=0.20"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["corvid*"]
|
||||
Loading…
Add table
Add a link
Reference in a new issue