feat: implement skills marketplace API with CRUD operations and gateway integration

This commit is contained in:
Abhimanyu Saharan
2026-02-13 23:11:54 +05:30
committed by Abhimanyu Saharan
parent db510a8612
commit e7b5df0bce
22 changed files with 2246 additions and 0 deletions

View File

@@ -0,0 +1,313 @@
"""Skills marketplace API for catalog management and gateway install actions."""
from __future__ import annotations
from typing import TYPE_CHECKING
from urllib.parse import unquote, urlparse
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlmodel import col
from app.api.deps import require_org_admin
from app.core.time import utcnow
from app.db.session import get_session
from app.models.gateway_installed_skills import GatewayInstalledSkill
from app.models.gateways import Gateway
from app.models.marketplace_skills import MarketplaceSkill
from app.schemas.common import OkResponse
from app.schemas.skills_marketplace import (
MarketplaceSkillActionResponse,
MarketplaceSkillCardRead,
MarketplaceSkillCreate,
MarketplaceSkillRead,
)
from app.services.openclaw.gateway_dispatch import GatewayDispatchService
from app.services.openclaw.gateway_resolver import gateway_client_config, require_gateway_workspace_root
from app.services.openclaw.gateway_rpc import OpenClawGatewayError
from app.services.openclaw.shared import GatewayAgentIdentity
from app.services.organizations import OrganizationContext
if TYPE_CHECKING:
from sqlmodel.ext.asyncio.session import AsyncSession
router = APIRouter(prefix="/skills", tags=["skills"])
SESSION_DEP = Depends(get_session)
ORG_ADMIN_DEP = Depends(require_org_admin)
GATEWAY_ID_QUERY = Query(...)
def _skills_install_dir(workspace_root: str) -> str:
normalized = workspace_root.rstrip("/\\")
if not normalized:
return "skills"
return f"{normalized}/skills"
def _infer_skill_name(source_url: str) -> str:
parsed = urlparse(source_url)
path = parsed.path.rstrip("/")
candidate = path.rsplit("/", maxsplit=1)[-1] if path else parsed.netloc
candidate = unquote(candidate).removesuffix(".git").replace("-", " ").replace("_", " ")
if candidate.strip():
return candidate.strip()
return "Skill"
def _install_instruction(*, skill: MarketplaceSkill, gateway: Gateway) -> str:
install_dir = _skills_install_dir(gateway.workspace_root)
return (
"MISSION CONTROL SKILL INSTALL REQUEST\n"
f"Skill name: {skill.name}\n"
f"Skill source URL: {skill.source_url}\n"
f"Install destination: {install_dir}\n\n"
"Actions:\n"
"1. Ensure the install destination exists.\n"
"2. Install or update the skill from the source URL into the destination.\n"
"3. Verify the skill is discoverable by the runtime.\n"
"4. Reply with success or failure details."
)
def _uninstall_instruction(*, skill: MarketplaceSkill, gateway: Gateway) -> str:
install_dir = _skills_install_dir(gateway.workspace_root)
return (
"MISSION CONTROL SKILL UNINSTALL REQUEST\n"
f"Skill name: {skill.name}\n"
f"Skill source URL: {skill.source_url}\n"
f"Install destination: {install_dir}\n\n"
"Actions:\n"
"1. Remove the skill assets previously installed from this source URL.\n"
"2. Ensure the skill is no longer discoverable by the runtime.\n"
"3. Reply with success or failure details."
)
def _as_card(
*,
skill: MarketplaceSkill,
installation: GatewayInstalledSkill | None,
) -> MarketplaceSkillCardRead:
return MarketplaceSkillCardRead(
id=skill.id,
organization_id=skill.organization_id,
name=skill.name,
description=skill.description,
source_url=skill.source_url,
created_at=skill.created_at,
updated_at=skill.updated_at,
installed=installation is not None,
installed_at=installation.created_at if installation is not None else None,
)
async def _require_gateway_for_org(
*,
gateway_id: UUID,
session: AsyncSession,
ctx: OrganizationContext,
) -> Gateway:
gateway = await Gateway.objects.by_id(gateway_id).first(session)
if gateway is None or gateway.organization_id != ctx.organization.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Gateway not found",
)
return gateway
async def _require_marketplace_skill_for_org(
*,
skill_id: UUID,
session: AsyncSession,
ctx: OrganizationContext,
) -> MarketplaceSkill:
skill = await MarketplaceSkill.objects.by_id(skill_id).first(session)
if skill is None or skill.organization_id != ctx.organization.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Marketplace skill not found",
)
return skill
async def _dispatch_gateway_instruction(
*,
session: AsyncSession,
gateway: Gateway,
message: str,
) -> None:
dispatch = GatewayDispatchService(session)
config = gateway_client_config(gateway)
session_key = GatewayAgentIdentity.session_key(gateway)
await dispatch.send_agent_message(
session_key=session_key,
config=config,
agent_name="Gateway Agent",
message=message,
deliver=True,
)
@router.get("/marketplace", response_model=list[MarketplaceSkillCardRead])
async def list_marketplace_skills(
gateway_id: UUID = GATEWAY_ID_QUERY,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> list[MarketplaceSkillCardRead]:
"""List marketplace cards for an org and annotate install state for a gateway."""
gateway = await _require_gateway_for_org(gateway_id=gateway_id, session=session, ctx=ctx)
skills = (
await MarketplaceSkill.objects.filter_by(organization_id=ctx.organization.id)
.order_by(col(MarketplaceSkill.created_at).desc())
.all(session)
)
installations = await GatewayInstalledSkill.objects.filter_by(gateway_id=gateway.id).all(session)
installed_by_skill_id = {record.skill_id: record for record in installations}
return [
_as_card(skill=skill, installation=installed_by_skill_id.get(skill.id))
for skill in skills
]
@router.post("/marketplace", response_model=MarketplaceSkillRead)
async def create_marketplace_skill(
payload: MarketplaceSkillCreate,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> MarketplaceSkill:
"""Register a skill source URL in the organization's marketplace catalog."""
source_url = str(payload.source_url).strip()
existing = await MarketplaceSkill.objects.filter_by(
organization_id=ctx.organization.id,
source_url=source_url,
).first(session)
if existing is not None:
changed = False
if payload.name and existing.name != payload.name:
existing.name = payload.name
changed = True
if payload.description is not None and existing.description != payload.description:
existing.description = payload.description
changed = True
if changed:
existing.updated_at = utcnow()
session.add(existing)
await session.commit()
await session.refresh(existing)
return existing
skill = MarketplaceSkill(
organization_id=ctx.organization.id,
source_url=source_url,
name=payload.name or _infer_skill_name(source_url),
description=payload.description,
)
session.add(skill)
await session.commit()
await session.refresh(skill)
return skill
@router.delete("/marketplace/{skill_id}", response_model=OkResponse)
async def delete_marketplace_skill(
skill_id: UUID,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> OkResponse:
"""Delete a marketplace catalog entry and any install records that reference it."""
skill = await _require_marketplace_skill_for_org(skill_id=skill_id, session=session, ctx=ctx)
installations = await GatewayInstalledSkill.objects.filter_by(skill_id=skill.id).all(session)
for installation in installations:
await session.delete(installation)
await session.delete(skill)
await session.commit()
return OkResponse()
@router.post(
"/marketplace/{skill_id}/install",
response_model=MarketplaceSkillActionResponse,
)
async def install_marketplace_skill(
skill_id: UUID,
gateway_id: UUID = GATEWAY_ID_QUERY,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> MarketplaceSkillActionResponse:
"""Install a marketplace skill by dispatching instructions to the gateway agent."""
gateway = await _require_gateway_for_org(gateway_id=gateway_id, session=session, ctx=ctx)
require_gateway_workspace_root(gateway)
skill = await _require_marketplace_skill_for_org(skill_id=skill_id, session=session, ctx=ctx)
try:
await _dispatch_gateway_instruction(
session=session,
gateway=gateway,
message=_install_instruction(skill=skill, gateway=gateway),
)
except OpenClawGatewayError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=str(exc),
) from exc
installation = await GatewayInstalledSkill.objects.filter_by(
gateway_id=gateway.id,
skill_id=skill.id,
).first(session)
if installation is None:
session.add(
GatewayInstalledSkill(
gateway_id=gateway.id,
skill_id=skill.id,
),
)
else:
installation.updated_at = utcnow()
session.add(installation)
await session.commit()
return MarketplaceSkillActionResponse(
skill_id=skill.id,
gateway_id=gateway.id,
installed=True,
)
@router.post(
"/marketplace/{skill_id}/uninstall",
response_model=MarketplaceSkillActionResponse,
)
async def uninstall_marketplace_skill(
skill_id: UUID,
gateway_id: UUID = GATEWAY_ID_QUERY,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> MarketplaceSkillActionResponse:
"""Uninstall a marketplace skill by dispatching instructions to the gateway agent."""
gateway = await _require_gateway_for_org(gateway_id=gateway_id, session=session, ctx=ctx)
require_gateway_workspace_root(gateway)
skill = await _require_marketplace_skill_for_org(skill_id=skill_id, session=session, ctx=ctx)
try:
await _dispatch_gateway_instruction(
session=session,
gateway=gateway,
message=_uninstall_instruction(skill=skill, gateway=gateway),
)
except OpenClawGatewayError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=str(exc),
) from exc
installation = await GatewayInstalledSkill.objects.filter_by(
gateway_id=gateway.id,
skill_id=skill.id,
).first(session)
if installation is not None:
await session.delete(installation)
await session.commit()
return MarketplaceSkillActionResponse(
skill_id=skill.id,
gateway_id=gateway.id,
installed=False,
)

View File

@@ -25,6 +25,7 @@ from app.api.gateways import router as gateways_router
from app.api.metrics import router as metrics_router
from app.api.organizations import router as organizations_router
from app.api.souls_directory import router as souls_directory_router
from app.api.skills_marketplace import router as skills_marketplace_router
from app.api.tags import router as tags_router
from app.api.task_custom_fields import router as task_custom_fields_router
from app.api.tasks import router as tasks_router
@@ -138,6 +139,7 @@ api_v1.include_router(gateways_router)
api_v1.include_router(metrics_router)
api_v1.include_router(organizations_router)
api_v1.include_router(souls_directory_router)
api_v1.include_router(skills_marketplace_router)
api_v1.include_router(board_groups_router)
api_v1.include_router(board_group_memory_router)
api_v1.include_router(boards_router)

View File

@@ -12,6 +12,8 @@ from app.models.board_webhook_payloads import BoardWebhookPayload
from app.models.board_webhooks import BoardWebhook
from app.models.boards import Board
from app.models.gateways import Gateway
from app.models.gateway_installed_skills import GatewayInstalledSkill
from app.models.marketplace_skills import MarketplaceSkill
from app.models.organization_board_access import OrganizationBoardAccess
from app.models.organization_invite_board_access import OrganizationInviteBoardAccess
from app.models.organization_invites import OrganizationInvite
@@ -42,6 +44,8 @@ __all__ = [
"BoardGroup",
"Board",
"Gateway",
"GatewayInstalledSkill",
"MarketplaceSkill",
"Organization",
"BoardTaskCustomField",
"TaskCustomFieldDefinition",

View File

@@ -0,0 +1,33 @@
"""Gateway-to-skill installation state records."""
from __future__ import annotations
from datetime import datetime
from uuid import UUID, uuid4
from sqlalchemy import UniqueConstraint
from sqlmodel import Field
from app.core.time import utcnow
from app.models.base import QueryModel
RUNTIME_ANNOTATION_TYPES = (datetime,)
class GatewayInstalledSkill(QueryModel, table=True):
"""Marks that a marketplace skill is installed for a specific gateway."""
__tablename__ = "gateway_installed_skills" # pyright: ignore[reportAssignmentType]
__table_args__ = (
UniqueConstraint(
"gateway_id",
"skill_id",
name="uq_gateway_installed_skills_gateway_id_skill_id",
),
)
id: UUID = Field(default_factory=uuid4, primary_key=True)
gateway_id: UUID = Field(foreign_key="gateways.id", index=True)
skill_id: UUID = Field(foreign_key="marketplace_skills.id", index=True)
created_at: datetime = Field(default_factory=utcnow)
updated_at: datetime = Field(default_factory=utcnow)

View File

@@ -0,0 +1,35 @@
"""Organization-scoped skill catalog entries for the skills marketplace."""
from __future__ import annotations
from datetime import datetime
from uuid import UUID, uuid4
from sqlalchemy import UniqueConstraint
from sqlmodel import Field
from app.core.time import utcnow
from app.models.tenancy import TenantScoped
RUNTIME_ANNOTATION_TYPES = (datetime,)
class MarketplaceSkill(TenantScoped, table=True):
"""A marketplace skill entry that can be installed onto one or more gateways."""
__tablename__ = "marketplace_skills" # pyright: ignore[reportAssignmentType]
__table_args__ = (
UniqueConstraint(
"organization_id",
"source_url",
name="uq_marketplace_skills_org_source_url",
),
)
id: UUID = Field(default_factory=uuid4, primary_key=True)
organization_id: UUID = Field(foreign_key="organizations.id", index=True)
name: str
description: str | None = Field(default=None)
source_url: str
created_at: datetime = Field(default_factory=utcnow)
updated_at: datetime = Field(default_factory=utcnow)

View File

@@ -33,6 +33,12 @@ from app.schemas.organizations import (
OrganizationMemberUpdate,
OrganizationRead,
)
from app.schemas.skills_marketplace import (
MarketplaceSkillActionResponse,
MarketplaceSkillCardRead,
MarketplaceSkillCreate,
MarketplaceSkillRead,
)
from app.schemas.souls_directory import (
SoulsDirectoryMarkdownResponse,
SoulsDirectorySearchResponse,
@@ -83,6 +89,10 @@ __all__ = [
"SoulsDirectoryMarkdownResponse",
"SoulsDirectorySearchResponse",
"SoulsDirectorySoulRef",
"MarketplaceSkillActionResponse",
"MarketplaceSkillCardRead",
"MarketplaceSkillCreate",
"MarketplaceSkillRead",
"TagCreate",
"TagRead",
"TagRef",

View File

@@ -0,0 +1,49 @@
"""Schemas for skills marketplace listing and install/uninstall actions."""
from __future__ import annotations
from datetime import datetime
from uuid import UUID
from pydantic import AnyHttpUrl
from sqlmodel import SQLModel
from app.schemas.common import NonEmptyStr
RUNTIME_ANNOTATION_TYPES = (datetime, UUID, NonEmptyStr)
class MarketplaceSkillCreate(SQLModel):
"""Payload used to register a skill URL in the organization marketplace."""
source_url: AnyHttpUrl
name: NonEmptyStr | None = None
description: str | None = None
class MarketplaceSkillRead(SQLModel):
"""Serialized marketplace skill catalog record."""
id: UUID
organization_id: UUID
name: str
description: str | None = None
source_url: str
created_at: datetime
updated_at: datetime
class MarketplaceSkillCardRead(MarketplaceSkillRead):
"""Marketplace card payload with gateway-specific install state."""
installed: bool
installed_at: datetime | None = None
class MarketplaceSkillActionResponse(SQLModel):
"""Install/uninstall action response payload."""
ok: bool = True
skill_id: UUID
gateway_id: UUID
installed: bool

View File

@@ -0,0 +1,99 @@
"""add skills marketplace tables
Revision ID: c9d7e9b6a4f2
Revises: b6f4c7d9e1a2
Create Date: 2026-02-13 00:00:00.000000
"""
from __future__ import annotations
import sqlalchemy as sa
import sqlmodel
from alembic import op
# revision identifiers, used by Alembic.
revision = "c9d7e9b6a4f2"
down_revision = "b6f4c7d9e1a2"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"marketplace_skills",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("organization_id", sa.Uuid(), nullable=False),
sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("source_url", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(
["organization_id"],
["organizations.id"],
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"organization_id",
"source_url",
name="uq_marketplace_skills_org_source_url",
),
)
op.create_index(
op.f("ix_marketplace_skills_organization_id"),
"marketplace_skills",
["organization_id"],
unique=False,
)
op.create_table(
"gateway_installed_skills",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("gateway_id", sa.Uuid(), nullable=False),
sa.Column("skill_id", sa.Uuid(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(
["gateway_id"],
["gateways.id"],
),
sa.ForeignKeyConstraint(
["skill_id"],
["marketplace_skills.id"],
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"gateway_id",
"skill_id",
name="uq_gateway_installed_skills_gateway_id_skill_id",
),
)
op.create_index(
op.f("ix_gateway_installed_skills_gateway_id"),
"gateway_installed_skills",
["gateway_id"],
unique=False,
)
op.create_index(
op.f("ix_gateway_installed_skills_skill_id"),
"gateway_installed_skills",
["skill_id"],
unique=False,
)
def downgrade() -> None:
op.drop_index(
op.f("ix_gateway_installed_skills_skill_id"),
table_name="gateway_installed_skills",
)
op.drop_index(
op.f("ix_gateway_installed_skills_gateway_id"),
table_name="gateway_installed_skills",
)
op.drop_table("gateway_installed_skills")
op.drop_index(
op.f("ix_marketplace_skills_organization_id"),
table_name="marketplace_skills",
)
op.drop_table("marketplace_skills")

View File

@@ -0,0 +1,221 @@
# ruff: noqa: INP001
"""Integration tests for skills marketplace APIs."""
from __future__ import annotations
from uuid import uuid4
import pytest
from fastapi import APIRouter, FastAPI
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
from sqlmodel import SQLModel, col, select
from sqlmodel.ext.asyncio.session import AsyncSession
from app.api.deps import require_org_admin
from app.api.skills_marketplace import router as skills_marketplace_router
from app.db.session import get_session
from app.models.gateway_installed_skills import GatewayInstalledSkill
from app.models.gateways import Gateway
from app.models.marketplace_skills import MarketplaceSkill
from app.models.organization_members import OrganizationMember
from app.models.organizations import Organization
from app.services.organizations import OrganizationContext
async def _make_engine() -> AsyncEngine:
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.connect() as conn, conn.begin():
await conn.run_sync(SQLModel.metadata.create_all)
return engine
def _build_test_app(
session_maker: async_sessionmaker[AsyncSession],
*,
organization: Organization,
) -> FastAPI:
app = FastAPI()
api_v1 = APIRouter(prefix="/api/v1")
api_v1.include_router(skills_marketplace_router)
app.include_router(api_v1)
async def _override_get_session() -> AsyncSession:
async with session_maker() as session:
yield session
async def _override_require_org_admin() -> OrganizationContext:
return OrganizationContext(
organization=organization,
member=OrganizationMember(
organization_id=organization.id,
user_id=uuid4(),
role="owner",
all_boards_read=True,
all_boards_write=True,
),
)
app.dependency_overrides[get_session] = _override_get_session
app.dependency_overrides[require_org_admin] = _override_require_org_admin
return app
async def _seed_base(
session: AsyncSession,
) -> tuple[Organization, Gateway]:
organization = Organization(id=uuid4(), name="Org One")
gateway = Gateway(
id=uuid4(),
organization_id=organization.id,
name="Gateway One",
url="https://gateway.example.local",
workspace_root="/workspace/openclaw",
)
session.add(organization)
session.add(gateway)
await session.commit()
return organization, gateway
@pytest.mark.asyncio
async def test_install_skill_dispatches_instruction_and_persists_installation(
monkeypatch: pytest.MonkeyPatch,
) -> None:
engine = await _make_engine()
session_maker = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
try:
async with session_maker() as session:
organization, gateway = await _seed_base(session)
skill = MarketplaceSkill(
organization_id=organization.id,
name="Deploy Helper",
source_url="https://example.com/skills/deploy-helper.git",
description="Handles deploy workflow checks.",
)
session.add(skill)
await session.commit()
await session.refresh(skill)
app = _build_test_app(session_maker, organization=organization)
sent_messages: list[dict[str, str | bool]] = []
async def _fake_send_agent_message(
_self: object,
*,
session_key: str,
config: object,
agent_name: str,
message: str,
deliver: bool = False,
) -> None:
del config
sent_messages.append(
{
"session_key": session_key,
"agent_name": agent_name,
"message": message,
"deliver": deliver,
},
)
monkeypatch.setattr(
"app.api.skills_marketplace.GatewayDispatchService.send_agent_message",
_fake_send_agent_message,
)
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://testserver",
) as client:
response = await client.post(
f"/api/v1/skills/marketplace/{skill.id}/install",
params={"gateway_id": str(gateway.id)},
)
assert response.status_code == 200
body = response.json()
assert body["installed"] is True
assert body["gateway_id"] == str(gateway.id)
assert len(sent_messages) == 1
assert sent_messages[0]["agent_name"] == "Gateway Agent"
assert sent_messages[0]["deliver"] is True
assert sent_messages[0]["session_key"] == f"agent:mc-gateway-{gateway.id}:main"
message = str(sent_messages[0]["message"])
assert "SKILL INSTALL REQUEST" in message
assert str(skill.source_url) in message
assert "/workspace/openclaw/skills" in message
async with session_maker() as session:
installed_rows = (
await session.exec(
select(GatewayInstalledSkill).where(
col(GatewayInstalledSkill.gateway_id) == gateway.id,
col(GatewayInstalledSkill.skill_id) == skill.id,
),
)
).all()
assert len(installed_rows) == 1
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_list_marketplace_skills_marks_installed_cards() -> None:
engine = await _make_engine()
session_maker = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
try:
async with session_maker() as session:
organization, gateway = await _seed_base(session)
first = MarketplaceSkill(
organization_id=organization.id,
name="First Skill",
source_url="https://example.com/skills/first",
)
second = MarketplaceSkill(
organization_id=organization.id,
name="Second Skill",
source_url="https://example.com/skills/second",
)
session.add(first)
session.add(second)
await session.commit()
await session.refresh(first)
await session.refresh(second)
session.add(
GatewayInstalledSkill(
gateway_id=gateway.id,
skill_id=first.id,
),
)
await session.commit()
app = _build_test_app(session_maker, organization=organization)
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://testserver",
) as client:
response = await client.get(
"/api/v1/skills/marketplace",
params={"gateway_id": str(gateway.id)},
)
assert response.status_code == 200
cards = response.json()
assert len(cards) == 2
cards_by_id = {item["id"]: item for item in cards}
assert cards_by_id[str(first.id)]["installed"] is True
assert cards_by_id[str(first.id)]["installed_at"] is not None
assert cards_by_id[str(second.id)]["installed"] is False
assert cards_by_id[str(second.id)]["installed_at"] is None
finally:
await engine.dispose()

View File

@@ -19,6 +19,7 @@ export interface ApprovalCreate {
* @maximum 100
*/
confidence: number;
lead_reasoning?: string | null;
payload?: ApprovalCreatePayload;
rubric_scores?: ApprovalCreateRubricScores;
status?: ApprovalCreateStatus;

View File

@@ -114,6 +114,7 @@ export * from "./getSessionHistoryApiV1GatewaysSessionsSessionIdHistoryGetParams
export * from "./healthHealthGet200";
export * from "./healthzHealthzGet200";
export * from "./hTTPValidationError";
export * from "./installMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostParams";
export * from "./limitOffsetPageTypeVarCustomizedActivityEventRead";
export * from "./limitOffsetPageTypeVarCustomizedActivityTaskCommentFeedItemRead";
export * from "./limitOffsetPageTypeVarCustomizedAgentRead";
@@ -146,6 +147,7 @@ export * from "./listBoardWebhookPayloadsApiV1BoardsBoardIdWebhooksWebhookIdPayl
export * from "./listBoardWebhooksApiV1BoardsBoardIdWebhooksGetParams";
export * from "./listGatewaysApiV1GatewaysGetParams";
export * from "./listGatewaySessionsApiV1GatewaysSessionsGetParams";
export * from "./listMarketplaceSkillsApiV1SkillsMarketplaceGetParams";
export * from "./listOrgInvitesApiV1OrganizationsMeInvitesGetParams";
export * from "./listOrgMembersApiV1OrganizationsMeMembersGetParams";
export * from "./listTagsApiV1TagsGetParams";
@@ -154,6 +156,10 @@ export * from "./listTaskCommentsApiV1AgentBoardsBoardIdTasksTaskIdCommentsGetPa
export * from "./listTaskCommentsApiV1BoardsBoardIdTasksTaskIdCommentsGetParams";
export * from "./listTasksApiV1AgentBoardsBoardIdTasksGetParams";
export * from "./listTasksApiV1BoardsBoardIdTasksGetParams";
export * from "./marketplaceSkillActionResponse";
export * from "./marketplaceSkillCardRead";
export * from "./marketplaceSkillCreate";
export * from "./marketplaceSkillRead";
export * from "./okResponse";
export * from "./organizationActiveUpdate";
export * from "./organizationBoardAccessRead";
@@ -207,6 +213,7 @@ export * from "./taskReadCustomFieldValues";
export * from "./taskReadStatus";
export * from "./taskUpdate";
export * from "./taskUpdateCustomFieldValues";
export * from "./uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostParams";
export * from "./updateAgentApiV1AgentsAgentIdPatchParams";
export * from "./userRead";
export * from "./userUpdate";

View File

@@ -0,0 +1,11 @@
/**
* Generated by orval v8.3.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
export type InstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostParams =
{
gateway_id: string;
};

View File

@@ -0,0 +1,10 @@
/**
* Generated by orval v8.3.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
export type ListMarketplaceSkillsApiV1SkillsMarketplaceGetParams = {
gateway_id: string;
};

View File

@@ -0,0 +1,16 @@
/**
* Generated by orval v8.3.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
/**
* Install/uninstall action response payload.
*/
export interface MarketplaceSkillActionResponse {
gateway_id: string;
installed: boolean;
ok?: boolean;
skill_id: string;
}

View File

@@ -0,0 +1,21 @@
/**
* Generated by orval v8.3.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
/**
* Marketplace card payload with gateway-specific install state.
*/
export interface MarketplaceSkillCardRead {
created_at: string;
description?: string | null;
id: string;
installed: boolean;
installed_at?: string | null;
name: string;
organization_id: string;
source_url: string;
updated_at: string;
}

View File

@@ -0,0 +1,16 @@
/**
* Generated by orval v8.3.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
/**
* Payload used to register a skill URL in the organization marketplace.
*/
export interface MarketplaceSkillCreate {
description?: string | null;
name?: string | null;
/** @minLength 1 */
source_url: string;
}

View File

@@ -0,0 +1,19 @@
/**
* Generated by orval v8.3.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
/**
* Serialized marketplace skill catalog record.
*/
export interface MarketplaceSkillRead {
created_at: string;
description?: string | null;
id: string;
name: string;
organization_id: string;
source_url: string;
updated_at: string;
}

View File

@@ -0,0 +1,11 @@
/**
* Generated by orval v8.3.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
export type UninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostParams =
{
gateway_id: string;
};

View File

@@ -0,0 +1,939 @@
/**
* Generated by orval v8.3.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
import { useMutation, useQuery } from "@tanstack/react-query";
import type {
DataTag,
DefinedInitialDataOptions,
DefinedUseQueryResult,
MutationFunction,
QueryClient,
QueryFunction,
QueryKey,
UndefinedInitialDataOptions,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult,
} from "@tanstack/react-query";
import type {
HTTPValidationError,
InstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostParams,
ListMarketplaceSkillsApiV1SkillsMarketplaceGetParams,
MarketplaceSkillActionResponse,
MarketplaceSkillCardRead,
MarketplaceSkillCreate,
MarketplaceSkillRead,
OkResponse,
UninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostParams,
} from ".././model";
import { customFetch } from "../../mutator";
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
/**
* List marketplace cards for an org and annotate install state for a gateway.
* @summary List Marketplace Skills
*/
export type listMarketplaceSkillsApiV1SkillsMarketplaceGetResponse200 = {
data: MarketplaceSkillCardRead[];
status: 200;
};
export type listMarketplaceSkillsApiV1SkillsMarketplaceGetResponse422 = {
data: HTTPValidationError;
status: 422;
};
export type listMarketplaceSkillsApiV1SkillsMarketplaceGetResponseSuccess =
listMarketplaceSkillsApiV1SkillsMarketplaceGetResponse200 & {
headers: Headers;
};
export type listMarketplaceSkillsApiV1SkillsMarketplaceGetResponseError =
listMarketplaceSkillsApiV1SkillsMarketplaceGetResponse422 & {
headers: Headers;
};
export type listMarketplaceSkillsApiV1SkillsMarketplaceGetResponse =
| listMarketplaceSkillsApiV1SkillsMarketplaceGetResponseSuccess
| listMarketplaceSkillsApiV1SkillsMarketplaceGetResponseError;
export const getListMarketplaceSkillsApiV1SkillsMarketplaceGetUrl = (
params: ListMarketplaceSkillsApiV1SkillsMarketplaceGetParams,
) => {
const normalizedParams = new URLSearchParams();
Object.entries(params || {}).forEach(([key, value]) => {
if (value !== undefined) {
normalizedParams.append(key, value === null ? "null" : value.toString());
}
});
const stringifiedParams = normalizedParams.toString();
return stringifiedParams.length > 0
? `/api/v1/skills/marketplace?${stringifiedParams}`
: `/api/v1/skills/marketplace`;
};
export const listMarketplaceSkillsApiV1SkillsMarketplaceGet = async (
params: ListMarketplaceSkillsApiV1SkillsMarketplaceGetParams,
options?: RequestInit,
): Promise<listMarketplaceSkillsApiV1SkillsMarketplaceGetResponse> => {
return customFetch<listMarketplaceSkillsApiV1SkillsMarketplaceGetResponse>(
getListMarketplaceSkillsApiV1SkillsMarketplaceGetUrl(params),
{
...options,
method: "GET",
},
);
};
export const getListMarketplaceSkillsApiV1SkillsMarketplaceGetQueryKey = (
params?: ListMarketplaceSkillsApiV1SkillsMarketplaceGetParams,
) => {
return [`/api/v1/skills/marketplace`, ...(params ? [params] : [])] as const;
};
export const getListMarketplaceSkillsApiV1SkillsMarketplaceGetQueryOptions = <
TData = Awaited<
ReturnType<typeof listMarketplaceSkillsApiV1SkillsMarketplaceGet>
>,
TError = HTTPValidationError,
>(
params: ListMarketplaceSkillsApiV1SkillsMarketplaceGetParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<
ReturnType<typeof listMarketplaceSkillsApiV1SkillsMarketplaceGet>
>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ??
getListMarketplaceSkillsApiV1SkillsMarketplaceGetQueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof listMarketplaceSkillsApiV1SkillsMarketplaceGet>>
> = ({ signal }) =>
listMarketplaceSkillsApiV1SkillsMarketplaceGet(params, {
signal,
...requestOptions,
});
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listMarketplaceSkillsApiV1SkillsMarketplaceGet>>,
TError,
TData
> & { queryKey: DataTag<QueryKey, TData, TError> };
};
export type ListMarketplaceSkillsApiV1SkillsMarketplaceGetQueryResult =
NonNullable<
Awaited<ReturnType<typeof listMarketplaceSkillsApiV1SkillsMarketplaceGet>>
>;
export type ListMarketplaceSkillsApiV1SkillsMarketplaceGetQueryError =
HTTPValidationError;
export function useListMarketplaceSkillsApiV1SkillsMarketplaceGet<
TData = Awaited<
ReturnType<typeof listMarketplaceSkillsApiV1SkillsMarketplaceGet>
>,
TError = HTTPValidationError,
>(
params: ListMarketplaceSkillsApiV1SkillsMarketplaceGetParams,
options: {
query: Partial<
UseQueryOptions<
Awaited<
ReturnType<typeof listMarketplaceSkillsApiV1SkillsMarketplaceGet>
>,
TError,
TData
>
> &
Pick<
DefinedInitialDataOptions<
Awaited<
ReturnType<typeof listMarketplaceSkillsApiV1SkillsMarketplaceGet>
>,
TError,
Awaited<
ReturnType<typeof listMarketplaceSkillsApiV1SkillsMarketplaceGet>
>
>,
"initialData"
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): DefinedUseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useListMarketplaceSkillsApiV1SkillsMarketplaceGet<
TData = Awaited<
ReturnType<typeof listMarketplaceSkillsApiV1SkillsMarketplaceGet>
>,
TError = HTTPValidationError,
>(
params: ListMarketplaceSkillsApiV1SkillsMarketplaceGetParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<
ReturnType<typeof listMarketplaceSkillsApiV1SkillsMarketplaceGet>
>,
TError,
TData
>
> &
Pick<
UndefinedInitialDataOptions<
Awaited<
ReturnType<typeof listMarketplaceSkillsApiV1SkillsMarketplaceGet>
>,
TError,
Awaited<
ReturnType<typeof listMarketplaceSkillsApiV1SkillsMarketplaceGet>
>
>,
"initialData"
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useListMarketplaceSkillsApiV1SkillsMarketplaceGet<
TData = Awaited<
ReturnType<typeof listMarketplaceSkillsApiV1SkillsMarketplaceGet>
>,
TError = HTTPValidationError,
>(
params: ListMarketplaceSkillsApiV1SkillsMarketplaceGetParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<
ReturnType<typeof listMarketplaceSkillsApiV1SkillsMarketplaceGet>
>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
/**
* @summary List Marketplace Skills
*/
export function useListMarketplaceSkillsApiV1SkillsMarketplaceGet<
TData = Awaited<
ReturnType<typeof listMarketplaceSkillsApiV1SkillsMarketplaceGet>
>,
TError = HTTPValidationError,
>(
params: ListMarketplaceSkillsApiV1SkillsMarketplaceGetParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<
ReturnType<typeof listMarketplaceSkillsApiV1SkillsMarketplaceGet>
>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
} {
const queryOptions =
getListMarketplaceSkillsApiV1SkillsMarketplaceGetQueryOptions(
params,
options,
);
const query = useQuery(queryOptions, queryClient) as UseQueryResult<
TData,
TError
> & { queryKey: DataTag<QueryKey, TData, TError> };
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* Register a skill source URL in the organization's marketplace catalog.
* @summary Create Marketplace Skill
*/
export type createMarketplaceSkillApiV1SkillsMarketplacePostResponse200 = {
data: MarketplaceSkillRead;
status: 200;
};
export type createMarketplaceSkillApiV1SkillsMarketplacePostResponse422 = {
data: HTTPValidationError;
status: 422;
};
export type createMarketplaceSkillApiV1SkillsMarketplacePostResponseSuccess =
createMarketplaceSkillApiV1SkillsMarketplacePostResponse200 & {
headers: Headers;
};
export type createMarketplaceSkillApiV1SkillsMarketplacePostResponseError =
createMarketplaceSkillApiV1SkillsMarketplacePostResponse422 & {
headers: Headers;
};
export type createMarketplaceSkillApiV1SkillsMarketplacePostResponse =
| createMarketplaceSkillApiV1SkillsMarketplacePostResponseSuccess
| createMarketplaceSkillApiV1SkillsMarketplacePostResponseError;
export const getCreateMarketplaceSkillApiV1SkillsMarketplacePostUrl = () => {
return `/api/v1/skills/marketplace`;
};
export const createMarketplaceSkillApiV1SkillsMarketplacePost = async (
marketplaceSkillCreate: MarketplaceSkillCreate,
options?: RequestInit,
): Promise<createMarketplaceSkillApiV1SkillsMarketplacePostResponse> => {
return customFetch<createMarketplaceSkillApiV1SkillsMarketplacePostResponse>(
getCreateMarketplaceSkillApiV1SkillsMarketplacePostUrl(),
{
...options,
method: "POST",
headers: { "Content-Type": "application/json", ...options?.headers },
body: JSON.stringify(marketplaceSkillCreate),
},
);
};
export const getCreateMarketplaceSkillApiV1SkillsMarketplacePostMutationOptions =
<TError = HTTPValidationError, TContext = unknown>(options?: {
mutation?: UseMutationOptions<
Awaited<
ReturnType<typeof createMarketplaceSkillApiV1SkillsMarketplacePost>
>,
TError,
{ data: MarketplaceSkillCreate },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<
ReturnType<typeof createMarketplaceSkillApiV1SkillsMarketplacePost>
>,
TError,
{ data: MarketplaceSkillCreate },
TContext
> => {
const mutationKey = ["createMarketplaceSkillApiV1SkillsMarketplacePost"];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<
ReturnType<typeof createMarketplaceSkillApiV1SkillsMarketplacePost>
>,
{ data: MarketplaceSkillCreate }
> = (props) => {
const { data } = props ?? {};
return createMarketplaceSkillApiV1SkillsMarketplacePost(
data,
requestOptions,
);
};
return { mutationFn, ...mutationOptions };
};
export type CreateMarketplaceSkillApiV1SkillsMarketplacePostMutationResult =
NonNullable<
Awaited<ReturnType<typeof createMarketplaceSkillApiV1SkillsMarketplacePost>>
>;
export type CreateMarketplaceSkillApiV1SkillsMarketplacePostMutationBody =
MarketplaceSkillCreate;
export type CreateMarketplaceSkillApiV1SkillsMarketplacePostMutationError =
HTTPValidationError;
/**
* @summary Create Marketplace Skill
*/
export const useCreateMarketplaceSkillApiV1SkillsMarketplacePost = <
TError = HTTPValidationError,
TContext = unknown,
>(
options?: {
mutation?: UseMutationOptions<
Awaited<
ReturnType<typeof createMarketplaceSkillApiV1SkillsMarketplacePost>
>,
TError,
{ data: MarketplaceSkillCreate },
TContext
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<ReturnType<typeof createMarketplaceSkillApiV1SkillsMarketplacePost>>,
TError,
{ data: MarketplaceSkillCreate },
TContext
> => {
return useMutation(
getCreateMarketplaceSkillApiV1SkillsMarketplacePostMutationOptions(options),
queryClient,
);
};
/**
* Delete a marketplace catalog entry and any install records that reference it.
* @summary Delete Marketplace Skill
*/
export type deleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDeleteResponse200 =
{
data: OkResponse;
status: 200;
};
export type deleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDeleteResponse422 =
{
data: HTTPValidationError;
status: 422;
};
export type deleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDeleteResponseSuccess =
deleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDeleteResponse200 & {
headers: Headers;
};
export type deleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDeleteResponseError =
deleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDeleteResponse422 & {
headers: Headers;
};
export type deleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDeleteResponse =
| deleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDeleteResponseSuccess
| deleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDeleteResponseError;
export const getDeleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDeleteUrl = (
skillId: string,
) => {
return `/api/v1/skills/marketplace/${skillId}`;
};
export const deleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDelete = async (
skillId: string,
options?: RequestInit,
): Promise<deleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDeleteResponse> => {
return customFetch<deleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDeleteResponse>(
getDeleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDeleteUrl(skillId),
{
...options,
method: "DELETE",
},
);
};
export const getDeleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDeleteMutationOptions =
<TError = HTTPValidationError, TContext = unknown>(options?: {
mutation?: UseMutationOptions<
Awaited<
ReturnType<
typeof deleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDelete
>
>,
TError,
{ skillId: string },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<
ReturnType<
typeof deleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDelete
>
>,
TError,
{ skillId: string },
TContext
> => {
const mutationKey = [
"deleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDelete",
];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<
ReturnType<
typeof deleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDelete
>
>,
{ skillId: string }
> = (props) => {
const { skillId } = props ?? {};
return deleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDelete(
skillId,
requestOptions,
);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDeleteMutationResult =
NonNullable<
Awaited<
ReturnType<
typeof deleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDelete
>
>
>;
export type DeleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDeleteMutationError =
HTTPValidationError;
/**
* @summary Delete Marketplace Skill
*/
export const useDeleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDelete = <
TError = HTTPValidationError,
TContext = unknown,
>(
options?: {
mutation?: UseMutationOptions<
Awaited<
ReturnType<
typeof deleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDelete
>
>,
TError,
{ skillId: string },
TContext
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<
ReturnType<typeof deleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDelete>
>,
TError,
{ skillId: string },
TContext
> => {
return useMutation(
getDeleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDeleteMutationOptions(
options,
),
queryClient,
);
};
/**
* Install a marketplace skill by dispatching instructions to the gateway agent.
* @summary Install Marketplace Skill
*/
export type installMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostResponse200 =
{
data: MarketplaceSkillActionResponse;
status: 200;
};
export type installMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostResponse422 =
{
data: HTTPValidationError;
status: 422;
};
export type installMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostResponseSuccess =
installMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostResponse200 & {
headers: Headers;
};
export type installMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostResponseError =
installMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostResponse422 & {
headers: Headers;
};
export type installMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostResponse =
| installMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostResponseSuccess
| installMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostResponseError;
export const getInstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostUrl =
(
skillId: string,
params: InstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostParams,
) => {
const normalizedParams = new URLSearchParams();
Object.entries(params || {}).forEach(([key, value]) => {
if (value !== undefined) {
normalizedParams.append(
key,
value === null ? "null" : value.toString(),
);
}
});
const stringifiedParams = normalizedParams.toString();
return stringifiedParams.length > 0
? `/api/v1/skills/marketplace/${skillId}/install?${stringifiedParams}`
: `/api/v1/skills/marketplace/${skillId}/install`;
};
export const installMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPost =
async (
skillId: string,
params: InstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostParams,
options?: RequestInit,
): Promise<installMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostResponse> => {
return customFetch<installMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostResponse>(
getInstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostUrl(
skillId,
params,
),
{
...options,
method: "POST",
},
);
};
export const getInstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostMutationOptions =
<TError = HTTPValidationError, TContext = unknown>(options?: {
mutation?: UseMutationOptions<
Awaited<
ReturnType<
typeof installMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPost
>
>,
TError,
{
skillId: string;
params: InstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostParams;
},
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<
ReturnType<
typeof installMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPost
>
>,
TError,
{
skillId: string;
params: InstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostParams;
},
TContext
> => {
const mutationKey = [
"installMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPost",
];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<
ReturnType<
typeof installMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPost
>
>,
{
skillId: string;
params: InstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostParams;
}
> = (props) => {
const { skillId, params } = props ?? {};
return installMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPost(
skillId,
params,
requestOptions,
);
};
return { mutationFn, ...mutationOptions };
};
export type InstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostMutationResult =
NonNullable<
Awaited<
ReturnType<
typeof installMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPost
>
>
>;
export type InstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostMutationError =
HTTPValidationError;
/**
* @summary Install Marketplace Skill
*/
export const useInstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPost =
<TError = HTTPValidationError, TContext = unknown>(
options?: {
mutation?: UseMutationOptions<
Awaited<
ReturnType<
typeof installMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPost
>
>,
TError,
{
skillId: string;
params: InstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostParams;
},
TContext
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<
ReturnType<
typeof installMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPost
>
>,
TError,
{
skillId: string;
params: InstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostParams;
},
TContext
> => {
return useMutation(
getInstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostMutationOptions(
options,
),
queryClient,
);
};
/**
* Uninstall a marketplace skill by dispatching instructions to the gateway agent.
* @summary Uninstall Marketplace Skill
*/
export type uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostResponse200 =
{
data: MarketplaceSkillActionResponse;
status: 200;
};
export type uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostResponse422 =
{
data: HTTPValidationError;
status: 422;
};
export type uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostResponseSuccess =
uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostResponse200 & {
headers: Headers;
};
export type uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostResponseError =
uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostResponse422 & {
headers: Headers;
};
export type uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostResponse =
| uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostResponseSuccess
| uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostResponseError;
export const getUninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostUrl =
(
skillId: string,
params: UninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostParams,
) => {
const normalizedParams = new URLSearchParams();
Object.entries(params || {}).forEach(([key, value]) => {
if (value !== undefined) {
normalizedParams.append(
key,
value === null ? "null" : value.toString(),
);
}
});
const stringifiedParams = normalizedParams.toString();
return stringifiedParams.length > 0
? `/api/v1/skills/marketplace/${skillId}/uninstall?${stringifiedParams}`
: `/api/v1/skills/marketplace/${skillId}/uninstall`;
};
export const uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPost =
async (
skillId: string,
params: UninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostParams,
options?: RequestInit,
): Promise<uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostResponse> => {
return customFetch<uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostResponse>(
getUninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostUrl(
skillId,
params,
),
{
...options,
method: "POST",
},
);
};
export const getUninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostMutationOptions =
<TError = HTTPValidationError, TContext = unknown>(options?: {
mutation?: UseMutationOptions<
Awaited<
ReturnType<
typeof uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPost
>
>,
TError,
{
skillId: string;
params: UninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostParams;
},
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<
ReturnType<
typeof uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPost
>
>,
TError,
{
skillId: string;
params: UninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostParams;
},
TContext
> => {
const mutationKey = [
"uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPost",
];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<
ReturnType<
typeof uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPost
>
>,
{
skillId: string;
params: UninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostParams;
}
> = (props) => {
const { skillId, params } = props ?? {};
return uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPost(
skillId,
params,
requestOptions,
);
};
return { mutationFn, ...mutationOptions };
};
export type UninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostMutationResult =
NonNullable<
Awaited<
ReturnType<
typeof uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPost
>
>
>;
export type UninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostMutationError =
HTTPValidationError;
/**
* @summary Uninstall Marketplace Skill
*/
export const useUninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPost =
<TError = HTTPValidationError, TContext = unknown>(
options?: {
mutation?: UseMutationOptions<
Awaited<
ReturnType<
typeof uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPost
>
>,
TError,
{
skillId: string;
params: UninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostParams;
},
TContext
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<
ReturnType<
typeof uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPost
>
>,
TError,
{
skillId: string;
params: UninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostParams;
},
TContext
> => {
return useMutation(
getUninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostMutationOptions(
options,
),
queryClient,
);
};

View File

@@ -0,0 +1,412 @@
"use client";
export const dynamic = "force-dynamic";
import Link from "next/link";
import { FormEvent, useMemo, useState } from "react";
import { useAuth } from "@/auth/clerk";
import { useQueryClient } from "@tanstack/react-query";
import { ExternalLink, Package, PlusCircle, Trash2 } from "lucide-react";
import { ApiError } from "@/api/mutator";
import {
type listGatewaysApiV1GatewaysGetResponse,
useListGatewaysApiV1GatewaysGet,
} from "@/api/generated/gateways/gateways";
import type { MarketplaceSkillCardRead } from "@/api/generated/model";
import {
getListMarketplaceSkillsApiV1SkillsMarketplaceGetQueryKey,
type listMarketplaceSkillsApiV1SkillsMarketplaceGetResponse,
useCreateMarketplaceSkillApiV1SkillsMarketplacePost,
useDeleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDelete,
useInstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPost,
useListMarketplaceSkillsApiV1SkillsMarketplaceGet,
useUninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPost,
} from "@/api/generated/skills-marketplace/skills-marketplace";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { Badge } from "@/components/ui/badge";
import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { formatRelativeTimestamp } from "@/lib/formatters";
import { useOrganizationMembership } from "@/lib/use-organization-membership";
export default function SkillsMarketplacePage() {
const queryClient = useQueryClient();
const { isSignedIn } = useAuth();
const { isAdmin } = useOrganizationMembership(isSignedIn);
const [selectedGatewayId, setSelectedGatewayId] = useState("");
const [sourceUrl, setSourceUrl] = useState("");
const [skillName, setSkillName] = useState("");
const [description, setDescription] = useState("");
const gatewaysQuery = useListGatewaysApiV1GatewaysGet<
listGatewaysApiV1GatewaysGetResponse,
ApiError
>(undefined, {
query: {
enabled: Boolean(isSignedIn && isAdmin),
refetchOnMount: "always",
refetchInterval: 30_000,
},
});
const gateways = useMemo(
() =>
gatewaysQuery.data?.status === 200
? (gatewaysQuery.data.data.items ?? [])
: [],
[gatewaysQuery.data],
);
const resolvedGatewayId = useMemo(() => {
if (selectedGatewayId && gateways.some((gateway) => gateway.id === selectedGatewayId)) {
return selectedGatewayId;
}
return gateways[0]?.id ?? "";
}, [gateways, selectedGatewayId]);
const skillsQueryKey = getListMarketplaceSkillsApiV1SkillsMarketplaceGetQueryKey(
resolvedGatewayId ? { gateway_id: resolvedGatewayId } : undefined,
);
const skillsQuery = useListMarketplaceSkillsApiV1SkillsMarketplaceGet<
listMarketplaceSkillsApiV1SkillsMarketplaceGetResponse,
ApiError
>(
{ gateway_id: resolvedGatewayId },
{
query: {
enabled: Boolean(isSignedIn && isAdmin && resolvedGatewayId),
refetchOnMount: "always",
refetchInterval: 15_000,
},
},
);
const skills = useMemo<MarketplaceSkillCardRead[]>(
() => (skillsQuery.data?.status === 200 ? skillsQuery.data.data : []),
[skillsQuery.data],
);
const createMutation =
useCreateMarketplaceSkillApiV1SkillsMarketplacePost<ApiError>(
{
mutation: {
onSuccess: async () => {
setSourceUrl("");
setSkillName("");
setDescription("");
await queryClient.invalidateQueries({
queryKey: skillsQueryKey,
});
},
},
},
queryClient,
);
const installMutation =
useInstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPost<ApiError>(
{
mutation: {
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: skillsQueryKey,
});
},
},
},
queryClient,
);
const uninstallMutation =
useUninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPost<ApiError>(
{
mutation: {
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: skillsQueryKey,
});
},
},
},
queryClient,
);
const deleteMutation =
useDeleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDelete<ApiError>(
{
mutation: {
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: skillsQueryKey,
});
},
},
},
queryClient,
);
const mutationError =
createMutation.error?.message ??
installMutation.error?.message ??
uninstallMutation.error?.message ??
deleteMutation.error?.message;
const handleAddSkill = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const normalizedUrl = sourceUrl.trim();
if (!normalizedUrl) return;
createMutation.mutate({
data: {
source_url: normalizedUrl,
name: skillName.trim() || undefined,
description: description.trim() || undefined,
},
});
};
const isMutating =
createMutation.isPending ||
installMutation.isPending ||
uninstallMutation.isPending ||
deleteMutation.isPending;
return (
<DashboardPageLayout
signedOut={{
message: "Sign in to manage marketplace skills.",
forceRedirectUrl: "/skills",
}}
title="Skills Marketplace"
description="Register skill links and install or uninstall them per gateway."
isAdmin={isAdmin}
adminOnlyMessage="Only organization owners and admins can manage skills."
stickyHeader
>
<div className="space-y-6">
{gateways.length === 0 ? (
<div className="rounded-xl border border-slate-200 bg-white p-6 text-sm text-slate-600 shadow-sm">
<p className="font-medium text-slate-900">No gateways available yet.</p>
<p className="mt-2">
Create a gateway first, then return here to install skills.
</p>
<Link
href="/gateways/new"
className={`${buttonVariants({ variant: "primary", size: "md" })} mt-4`}
>
Create gateway
</Link>
</div>
) : (
<Card>
<CardHeader className="border-b border-[color:var(--border)] pb-4">
<h2 className="font-heading text-lg font-semibold text-slate-900">
Add skill source
</h2>
<p className="text-sm text-slate-500">
Add a URL once, then install or uninstall the skill for the selected gateway.
</p>
</CardHeader>
<CardContent className="pt-5">
<form className="space-y-4" onSubmit={handleAddSkill}>
<div className="grid gap-4 md:grid-cols-[260px_1fr]">
<div className="space-y-2">
<label className="text-xs font-semibold uppercase tracking-wide text-slate-500">
Gateway
</label>
<Select value={resolvedGatewayId} onValueChange={setSelectedGatewayId}>
<SelectTrigger>
<SelectValue placeholder="Select gateway" />
</SelectTrigger>
<SelectContent>
{gateways.map((gateway) => (
<SelectItem key={gateway.id} value={gateway.id}>
{gateway.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label
htmlFor="skill-url"
className="text-xs font-semibold uppercase tracking-wide text-slate-500"
>
Skill URL
</label>
<Input
id="skill-url"
type="url"
value={sourceUrl}
onChange={(event) => setSourceUrl(event.target.value)}
placeholder="https://github.com/org/skill-repo"
required
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<label
htmlFor="skill-name"
className="text-xs font-semibold uppercase tracking-wide text-slate-500"
>
Display name (optional)
</label>
<Input
id="skill-name"
value={skillName}
onChange={(event) => setSkillName(event.target.value)}
placeholder="Deploy Helper"
/>
</div>
<div className="space-y-2">
<label
htmlFor="skill-description"
className="text-xs font-semibold uppercase tracking-wide text-slate-500"
>
Description (optional)
</label>
<Textarea
id="skill-description"
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder="Short summary shown on the marketplace card."
className="min-h-[44px] py-3"
/>
</div>
</div>
<div className="flex items-center gap-3">
<Button
type="submit"
disabled={createMutation.isPending || !resolvedGatewayId}
>
<PlusCircle className="h-4 w-4" />
{createMutation.isPending ? "Adding…" : "Add skill"}
</Button>
{createMutation.error ? (
<p className="text-sm text-rose-600">
{createMutation.error.message}
</p>
) : null}
</div>
</form>
</CardContent>
</Card>
)}
{mutationError ? <p className="text-sm text-rose-600">{mutationError}</p> : null}
<div className="space-y-3">
<h2 className="font-heading text-lg font-semibold text-slate-900">
Marketplace skills
</h2>
{skillsQuery.isLoading ? (
<div className="rounded-xl border border-slate-200 bg-white p-6 text-sm text-slate-500 shadow-sm">
Loading skills
</div>
) : skillsQuery.error ? (
<div className="rounded-xl border border-rose-200 bg-rose-50 p-6 text-sm text-rose-700">
{skillsQuery.error.message}
</div>
) : skills.length === 0 ? (
<div className="rounded-xl border border-slate-200 bg-white p-6 text-sm text-slate-600 shadow-sm">
No skill links added yet.
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{skills.map((skill) => (
<Card key={skill.id}>
<CardHeader className="border-b border-[color:var(--border)] pb-4">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<h3 className="text-base font-semibold text-slate-900">
{skill.name}
</h3>
<p className="text-sm text-slate-500">
{skill.description || "No description provided."}
</p>
</div>
<Badge variant={skill.installed ? "success" : "outline"}>
{skill.installed ? "Installed" : "Not installed"}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4 pt-5">
<a
href={skill.source_url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 text-sm font-medium text-[color:var(--accent)] hover:underline"
>
<ExternalLink className="h-4 w-4" />
Open source link
</a>
<p className="text-xs text-slate-500">
{skill.installed && skill.installed_at
? `Installed ${formatRelativeTimestamp(skill.installed_at)}`
: "Not installed on selected gateway"}
</p>
<div className="flex items-center gap-2">
{skill.installed ? (
<Button
type="button"
variant="outline"
onClick={() =>
uninstallMutation.mutate({
skillId: skill.id,
params: { gateway_id: resolvedGatewayId },
})
}
disabled={isMutating || !resolvedGatewayId}
>
<Package className="h-4 w-4" />
Uninstall
</Button>
) : (
<Button
type="button"
onClick={() =>
installMutation.mutate({
skillId: skill.id,
params: { gateway_id: resolvedGatewayId },
})
}
disabled={isMutating || !resolvedGatewayId}
>
<Package className="h-4 w-4" />
Install
</Button>
)}
<Button
type="button"
variant="ghost"
onClick={() => deleteMutation.mutate({ skillId: skill.id })}
disabled={isMutating}
aria-label={`Delete ${skill.name}`}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
</div>
</DashboardPageLayout>
);
}

View File

@@ -11,6 +11,7 @@ import {
Building2,
LayoutGrid,
Network,
Package,
Settings,
Tags,
} from "lucide-react";
@@ -195,6 +196,20 @@ export function DashboardSidebar() {
Gateways
</Link>
) : null}
{isAdmin ? (
<Link
href="/skills"
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-slate-700 transition",
pathname.startsWith("/skills")
? "bg-blue-100 text-blue-800 font-medium"
: "hover:bg-slate-100",
)}
>
<Package className="h-4 w-4" />
Skills
</Link>
) : null}
{isAdmin ? (
<Link
href="/agents"

View File

@@ -11,6 +11,7 @@ import {
ChevronDown,
LayoutDashboard,
LogOut,
Package,
Plus,
Server,
Settings,
@@ -155,6 +156,7 @@ export function UserMenu({
{ href: "/activity", label: "Activity", icon: Activity },
{ href: "/agents", label: "Agents", icon: Bot },
{ href: "/gateways", label: "Gateways", icon: Server },
{ href: "/skills", label: "Skills", icon: Package },
{ href: "/settings", label: "Settings", icon: Settings },
] as const
).map((item) => (