Merge pull request #128 from abhi1693/skills

feat(skills): marketplace + packs management
This commit is contained in:
Abhimanyu Saharan
2026-02-14 19:28:39 +05:30
committed by GitHub
46 changed files with 8425 additions and 9 deletions

View File

@@ -20,6 +20,7 @@ from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Literal
from uuid import UUID
from fastapi import Depends, HTTPException, status
@@ -196,7 +197,7 @@ BOARD_READ_DEP = Depends(get_board_for_actor_read)
async def get_task_or_404(
task_id: str,
task_id: UUID,
board: Board = BOARD_READ_DEP,
session: AsyncSession = SESSION_DEP,
) -> Task:

View File

@@ -14,6 +14,7 @@ from app.db import crud
from app.db.pagination import paginate
from app.db.session import get_session
from app.models.agents import Agent
from app.models.gateway_installed_skills import GatewayInstalledSkill
from app.models.gateways import Gateway
from app.schemas.common import OkResponse
from app.schemas.gateways import (
@@ -175,6 +176,15 @@ async def delete_gateway(
await service.clear_agent_foreign_keys(agent_id=agent.id)
await session.delete(agent)
# NOTE: The migration declares `ondelete="CASCADE"` for gateway_installed_skills.gateway_id,
# but some backends/test environments (e.g. SQLite without FK pragma) may not
# enforce cascades. Delete rows explicitly to guarantee cleanup semantics.
installed_skills = await GatewayInstalledSkill.objects.filter_by(
gateway_id=gateway.id,
).all(session)
for installed_skill in installed_skills:
await session.delete(installed_skill)
await session.delete(gateway)
await session.commit()
return OkResponse()

File diff suppressed because it is too large Load Diff

View File

@@ -1967,8 +1967,7 @@ async def _apply_lead_task_update(
if blocked_by:
attempted_fields: set[str] = set(update.updates.keys())
attempted_transition = (
"assigned_agent_id" in attempted_fields
or "status" in attempted_fields
"assigned_agent_id" in attempted_fields or "status" in attempted_fields
)
if attempted_transition:
raise _blocked_task_error(blocked_by)

View File

@@ -24,6 +24,7 @@ from app.api.gateway import router as gateway_router
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.skills_marketplace import router as skills_marketplace_router
from app.api.souls_directory import router as souls_directory_router
from app.api.tags import router as tags_router
from app.api.task_custom_fields import router as task_custom_fields_router
@@ -102,6 +103,7 @@ if origins:
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["X-Total-Count", "X-Limit", "X-Offset"],
)
logger.info("app.cors.enabled origins_count=%s", len(origins))
else:
@@ -138,6 +140,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

@@ -11,12 +11,15 @@ from app.models.board_onboarding import BoardOnboardingSession
from app.models.board_webhook_payloads import BoardWebhookPayload
from app.models.board_webhooks import BoardWebhook
from app.models.boards import Board
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_board_access import OrganizationBoardAccess
from app.models.organization_invite_board_access import OrganizationInviteBoardAccess
from app.models.organization_invites import OrganizationInvite
from app.models.organization_members import OrganizationMember
from app.models.organizations import Organization
from app.models.skill_packs import SkillPack
from app.models.tag_assignments import TagAssignment
from app.models.tags import Tag
from app.models.task_custom_fields import (
@@ -42,6 +45,9 @@ __all__ = [
"BoardGroup",
"Board",
"Gateway",
"GatewayInstalledSkill",
"MarketplaceSkill",
"SkillPack",
"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,42 @@
"""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 JSON, Column, 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)
category: str | None = Field(default=None)
risk: str | None = Field(default=None)
source: str | None = Field(default=None)
source_url: str
metadata_: dict[str, object] = Field(
default_factory=dict,
sa_column=Column("metadata", JSON, nullable=False),
)
created_at: datetime = Field(default_factory=utcnow)
updated_at: datetime = Field(default_factory=utcnow)

View File

@@ -0,0 +1,40 @@
"""Organization-scoped skill pack sources."""
from __future__ import annotations
from datetime import datetime
from uuid import UUID, uuid4
from sqlalchemy import JSON, Column, UniqueConstraint
from sqlmodel import Field
from app.core.time import utcnow
from app.models.tenancy import TenantScoped
RUNTIME_ANNOTATION_TYPES = (datetime,)
class SkillPack(TenantScoped, table=True):
"""A pack repository URL that can be synced into marketplace skills."""
__tablename__ = "skill_packs" # pyright: ignore[reportAssignmentType]
__table_args__ = (
UniqueConstraint(
"organization_id",
"source_url",
name="uq_skill_packs_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
branch: str = Field(default="main")
metadata_: dict[str, object] = Field(
default_factory=dict,
sa_column=Column("metadata", JSON, nullable=False),
)
created_at: datetime = Field(default_factory=utcnow)
updated_at: datetime = Field(default_factory=utcnow)

View File

@@ -33,6 +33,15 @@ from app.schemas.organizations import (
OrganizationMemberUpdate,
OrganizationRead,
)
from app.schemas.skills_marketplace import (
MarketplaceSkillActionResponse,
MarketplaceSkillCardRead,
MarketplaceSkillCreate,
MarketplaceSkillRead,
SkillPackCreate,
SkillPackRead,
SkillPackSyncResponse,
)
from app.schemas.souls_directory import (
SoulsDirectoryMarkdownResponse,
SoulsDirectorySearchResponse,
@@ -83,6 +92,13 @@ __all__ = [
"SoulsDirectoryMarkdownResponse",
"SoulsDirectorySearchResponse",
"SoulsDirectorySoulRef",
"MarketplaceSkillActionResponse",
"MarketplaceSkillCardRead",
"MarketplaceSkillCreate",
"MarketplaceSkillRead",
"SkillPackCreate",
"SkillPackRead",
"SkillPackSyncResponse",
"TagCreate",
"TagRead",
"TagRef",

View File

@@ -0,0 +1,100 @@
"""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 Field, 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 SkillPackCreate(SQLModel):
"""Payload used to register a pack URL in the organization."""
source_url: AnyHttpUrl
name: NonEmptyStr | None = None
description: str | None = None
branch: str = "main"
metadata_: dict[str, object] = Field(default_factory=dict, alias="metadata")
class Config:
allow_population_by_field_name = True
class MarketplaceSkillRead(SQLModel):
"""Serialized marketplace skill catalog record."""
id: UUID
organization_id: UUID
name: str
description: str | None = None
category: str | None = None
risk: str | None = None
source: str | None = None
source_url: str
metadata_: dict[str, object] = Field(default_factory=dict, alias="metadata")
class Config:
allow_population_by_field_name = True
created_at: datetime
updated_at: datetime
class SkillPackRead(SQLModel):
"""Serialized skill pack record."""
id: UUID
organization_id: UUID
name: str
description: str | None = None
source_url: str
branch: str
metadata_: dict[str, object] = Field(default_factory=dict, alias="metadata")
class Config:
allow_population_by_field_name = True
skill_count: int = 0
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
class SkillPackSyncResponse(SQLModel):
"""Pack sync summary payload."""
ok: bool = True
pack_id: UUID
synced: int
created: int
updated: int
warnings: list[str] = Field(default_factory=list)

View File

@@ -2,12 +2,16 @@
from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Iterable
from datetime import datetime
from typing import TYPE_CHECKING
from fastapi import HTTPException, status
from sqlalchemy import or_
from sqlalchemy.exc import IntegrityError
from sqlmodel import col, select
from sqlmodel.ext.asyncio.session import AsyncSession
from app.core.time import utcnow
from app.db import crud
@@ -17,13 +21,13 @@ from app.models.organization_invite_board_access import OrganizationInviteBoardA
from app.models.organization_invites import OrganizationInvite
from app.models.organization_members import OrganizationMember
from app.models.organizations import Organization
from app.models.skill_packs import SkillPack
from app.models.users import User
if TYPE_CHECKING:
from uuid import UUID
from sqlalchemy.sql.elements import ColumnElement
from sqlmodel.ext.asyncio.session import AsyncSession
from app.schemas.organizations import (
OrganizationBoardAccessSpec,
@@ -31,6 +35,30 @@ if TYPE_CHECKING:
)
DEFAULT_ORG_NAME = "Personal"
def _normalize_skill_pack_source_url(source_url: str) -> str:
"""Normalize pack source URL so duplicates with trivial formatting differences match."""
normalized = str(source_url).strip().rstrip("/")
if normalized.endswith(".git"):
return normalized[: -len(".git")]
return normalized
DEFAULT_INSTALLER_SKILL_PACKS = (
(
"sickn33/antigravity-awesome-skills",
"antigravity-awesome-skills",
"The Ultimate Collection of 800+ Agentic Skills for Claude Code/Antigravity/Cursor. "
"Battle-tested, high-performance skills for AI agents including official skills from "
"Anthropic and Vercel.",
),
(
"BrianRWagner/ai-marketing-skills",
"ai-marketing-skills",
"Marketing frameworks that AI actually executes. Use for Claude Code, OpenClaw, etc.",
),
)
ADMIN_ROLES = {"owner", "admin"}
ROLE_RANK = {"member": 0, "admin": 1, "owner": 2}
@@ -209,6 +237,42 @@ async def accept_invite(
return member
def _get_default_skill_pack_records(org_id: UUID, now: "datetime") -> list[SkillPack]:
"""Build default installer skill pack rows for a new organization."""
source_base = "https://github.com"
seen_urls: set[str] = set()
records: list[SkillPack] = []
for repo, name, description in DEFAULT_INSTALLER_SKILL_PACKS:
source_url = _normalize_skill_pack_source_url(f"{source_base}/{repo}")
if source_url in seen_urls:
continue
seen_urls.add(source_url)
records.append(
SkillPack(
organization_id=org_id,
name=name,
description=description,
source_url=source_url,
created_at=now,
updated_at=now,
),
)
return records
async def _fetch_existing_default_pack_sources(
session: AsyncSession,
org_id: UUID,
) -> set[str]:
"""Return existing default skill pack URLs for the organization."""
if not isinstance(session, AsyncSession):
return set()
return {
_normalize_skill_pack_source_url(row.source_url)
for row in await SkillPack.objects.filter_by(organization_id=org_id).all(session)
}
async def ensure_member_for_user(
session: AsyncSession,
user: User,
@@ -250,10 +314,41 @@ async def ensure_member_for_user(
created_at=now,
updated_at=now,
)
default_skill_packs = _get_default_skill_pack_records(org_id=org_id, now=now)
existing_pack_urls = await _fetch_existing_default_pack_sources(session, org_id)
normalized_existing_pack_urls = {
_normalize_skill_pack_source_url(existing_pack_source)
for existing_pack_source in existing_pack_urls
}
user.active_organization_id = org_id
session.add(user)
session.add(member)
await session.commit()
try:
await session.commit()
except IntegrityError:
await session.rollback()
existing_member = await get_first_membership(session, user.id)
if existing_member is None:
raise
if user.active_organization_id != existing_member.organization_id:
user.active_organization_id = existing_member.organization_id
session.add(user)
await session.commit()
await session.refresh(existing_member)
return existing_member
for pack in default_skill_packs:
normalized_source_url = _normalize_skill_pack_source_url(pack.source_url)
if normalized_source_url in normalized_existing_pack_urls:
continue
session.add(pack)
try:
await session.commit()
except IntegrityError:
await session.rollback()
normalized_existing_pack_urls.add(normalized_source_url)
continue
await session.refresh(member)
return member

View File

@@ -0,0 +1,290 @@
"""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 = "b05c7b628636"
branch_labels = None
depends_on = None
def _has_table(table_name: str) -> bool:
return sa.inspect(op.get_bind()).has_table(table_name)
def _has_column(table_name: str, column_name: str) -> bool:
if not _has_table(table_name):
return False
columns = sa.inspect(op.get_bind()).get_columns(table_name)
return any(column["name"] == column_name for column in columns)
def _has_index(table_name: str, index_name: str) -> bool:
if not _has_table(table_name):
return False
indexes = sa.inspect(op.get_bind()).get_indexes(table_name)
return any(index["name"] == index_name for index in indexes)
def _has_constraint(table_name: str, constraint_name: str) -> bool:
if not _has_table(table_name):
return False
constraints = sa.inspect(op.get_bind()).get_check_constraints(table_name)
return any(constraint["name"] == constraint_name for constraint in constraints)
def upgrade() -> None:
if not _has_table("marketplace_skills"):
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("category", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("risk", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("source", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("source_url", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column(
"metadata",
sa.JSON(),
nullable=False,
server_default=sa.text("'{}'"),
),
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",
),
)
if not _has_column("marketplace_skills", "metadata"):
op.add_column(
"marketplace_skills",
sa.Column(
"metadata",
sa.JSON(),
nullable=False,
server_default=sa.text("'{}'"),
),
)
if _has_column("marketplace_skills", "resolution_metadata") and not _has_column(
"marketplace_skills", "metadata",
):
op.execute(
sa.text(
"UPDATE marketplace_skills SET metadata = resolution_metadata WHERE resolution_metadata IS NOT NULL"
)
)
elif _has_column("marketplace_skills", "path_metadata") and not _has_column(
"marketplace_skills", "metadata"
):
op.execute(
sa.text(
"UPDATE marketplace_skills SET metadata = path_metadata WHERE path_metadata IS NOT NULL"
)
)
marketplace_org_idx = op.f("ix_marketplace_skills_organization_id")
if not _has_index("marketplace_skills", marketplace_org_idx):
op.create_index(
marketplace_org_idx,
"marketplace_skills",
["organization_id"],
unique=False,
)
if not _has_table("gateway_installed_skills"):
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"],
ondelete="CASCADE",
),
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",
),
)
gateway_id_idx = op.f("ix_gateway_installed_skills_gateway_id")
if not _has_index("gateway_installed_skills", gateway_id_idx):
op.create_index(
gateway_id_idx,
"gateway_installed_skills",
["gateway_id"],
unique=False,
)
gateway_skill_idx = op.f("ix_gateway_installed_skills_skill_id")
if not _has_index("gateway_installed_skills", gateway_skill_idx):
op.create_index(
gateway_skill_idx,
"gateway_installed_skills",
["skill_id"],
unique=False,
)
if not _has_table("skill_packs"):
op.create_table(
"skill_packs",
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(
"branch",
sqlmodel.sql.sqltypes.AutoString(),
nullable=False,
server_default=sa.text("'main'"),
),
sa.Column(
"metadata",
sa.JSON(),
nullable=False,
server_default=sa.text("'{}'"),
),
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_skill_packs_org_source_url",
),
)
if not _has_constraint(
"skill_packs",
"ck_skill_packs_source_url_github",
):
op.create_check_constraint(
"ck_skill_packs_source_url_github",
"skill_packs",
"source_url LIKE 'https://github.com/%/%'",
)
if not _has_column("skill_packs", "branch"):
op.add_column(
"skill_packs",
sa.Column(
"branch",
sqlmodel.sql.sqltypes.AutoString(),
nullable=False,
server_default=sa.text("'main'"),
),
)
if not _has_column("skill_packs", "metadata"):
op.add_column(
"skill_packs",
sa.Column(
"metadata",
sa.JSON(),
nullable=False,
server_default=sa.text("'{}'"),
),
)
if _has_column("skill_packs", "resolution_metadata") and not _has_column(
"skill_packs", "metadata"
):
op.execute(
sa.text(
"UPDATE skill_packs SET metadata = resolution_metadata WHERE resolution_metadata IS NOT NULL"
)
)
elif _has_column("skill_packs", "path_metadata") and not _has_column(
"skill_packs", "metadata"
):
op.execute(
sa.text(
"UPDATE skill_packs SET metadata = path_metadata WHERE path_metadata IS NOT NULL"
)
)
skill_packs_org_idx = op.f("ix_skill_packs_organization_id")
if not _has_index("skill_packs", skill_packs_org_idx):
op.create_index(
skill_packs_org_idx,
"skill_packs",
["organization_id"],
unique=False,
)
def downgrade() -> None:
skill_pack_github_constraint = "ck_skill_packs_source_url_github"
if _has_constraint("skill_packs", skill_pack_github_constraint):
op.drop_constraint(
skill_pack_github_constraint,
"skill_packs",
type_="check",
)
skill_packs_org_idx = op.f("ix_skill_packs_organization_id")
if _has_index("skill_packs", skill_packs_org_idx):
op.drop_index(
skill_packs_org_idx,
table_name="skill_packs",
)
if _has_table("skill_packs"):
op.drop_table("skill_packs")
gateway_skill_idx = op.f("ix_gateway_installed_skills_skill_id")
if _has_index("gateway_installed_skills", gateway_skill_idx):
op.drop_index(
gateway_skill_idx,
table_name="gateway_installed_skills",
)
gateway_id_idx = op.f("ix_gateway_installed_skills_gateway_id")
if _has_index("gateway_installed_skills", gateway_id_idx):
op.drop_index(
gateway_id_idx,
table_name="gateway_installed_skills",
)
if _has_table("gateway_installed_skills"):
op.drop_table("gateway_installed_skills")
marketplace_org_idx = op.f("ix_marketplace_skills_organization_id")
if _has_index("marketplace_skills", marketplace_org_idx):
op.drop_index(
marketplace_org_idx,
table_name="marketplace_skills",
)
if _has_table("marketplace_skills"):
op.drop_table("marketplace_skills")

View File

@@ -17,8 +17,8 @@ from app.core.error_handling import (
_http_exception_exception_handler,
_json_safe,
_request_validation_exception_handler,
_response_validation_exception_handler,
_request_validation_handler,
_response_validation_exception_handler,
_response_validation_handler,
install_error_handling,
)

View File

@@ -3,11 +3,13 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any
from uuid import uuid4
import pytest
from fastapi import HTTPException
from sqlalchemy.exc import IntegrityError
from app.models.boards import Board
from app.models.organization_board_access import OrganizationBoardAccess
@@ -15,6 +17,7 @@ from app.models.organization_invite_board_access import OrganizationInviteBoardA
from app.models.organization_invites import OrganizationInvite
from app.models.organization_members import OrganizationMember
from app.models.organizations import Organization
from app.models.skill_packs import SkillPack
from app.models.users import User
from app.schemas.organizations import OrganizationBoardAccessSpec, OrganizationMemberAccessUpdate
from app.services import organizations
@@ -107,6 +110,44 @@ def test_normalize_role(value: str, expected: str) -> None:
assert organizations.normalize_role(value) == expected
def test_normalize_skill_pack_source_url_normalizes_trivial_variants() -> None:
assert (
organizations._normalize_skill_pack_source_url("https://github.com/org/repo")
== "https://github.com/org/repo"
)
assert (
organizations._normalize_skill_pack_source_url("https://github.com/org/repo/")
== "https://github.com/org/repo"
)
assert (
organizations._normalize_skill_pack_source_url(" https://github.com/org/repo.git ")
== "https://github.com/org/repo"
)
def test_get_default_skill_pack_records_deduplicates_normalized_urls(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
organizations,
"DEFAULT_INSTALLER_SKILL_PACKS",
(
("owner/repo", "pack one", "first"),
("owner/repo/", "pack duplicate", "duplicate"),
("owner/repo.git", "pack duplicate again", "duplicate again"),
("owner/other", "other", "other"),
),
)
now = datetime(2025, 1, 1)
records = organizations._get_default_skill_pack_records(org_id=uuid4(), now=now)
assert len(records) == 2
assert {pack.source_url for pack in records} == {
"https://github.com/owner/repo",
"https://github.com/owner/other",
}
def test_role_rank_unknown_role_falls_back_to_member_rank() -> None:
assert organizations._role_rank("madeup") == 0
assert organizations._role_rank(None) == 0
@@ -218,7 +259,119 @@ async def test_ensure_member_for_user_creates_personal_org_and_owner(
assert any(
isinstance(item, Organization) and item.id == out.organization_id for item in session.added
)
assert session.committed == 1
skill_packs = [
item
for item in [*session.added, *[record for batch in session.added_all for record in batch]]
if isinstance(item, SkillPack)
]
assert len(skill_packs) == 2
pack_sources = {pack.source_url: pack.description for pack in skill_packs}
assert (
pack_sources["https://github.com/sickn33/antigravity-awesome-skills"]
== "The Ultimate Collection of 800+ Agentic Skills for Claude Code/Antigravity/Cursor. "
"Battle-tested, high-performance skills for AI agents including official skills from "
"Anthropic and Vercel."
)
assert (
pack_sources["https://github.com/BrianRWagner/ai-marketing-skills"]
== "Marketing frameworks that AI actually executes. Use for Claude Code, OpenClaw, etc."
)
assert session.committed == 3
assert len(session.added_all) == 0
assert {pack.source_url for pack in skill_packs} == {
"https://github.com/sickn33/antigravity-awesome-skills",
"https://github.com/BrianRWagner/ai-marketing-skills",
}
@pytest.mark.asyncio
async def test_ensure_member_for_user_skips_already_existing_default_pack_by_source_url(
monkeypatch: pytest.MonkeyPatch,
) -> None:
user = User(clerk_user_id="u1", email=None)
existing_pack_source = "https://github.com/sickn33/antigravity-awesome-skills/"
async def _fake_get_active(_session: Any, _user: User) -> None:
return None
async def _fake_get_first(_session: Any, _user_id: Any) -> None:
return None
async def _fake_fetch_existing_pack_sources(
_session: Any,
_org_id: Any,
) -> set[str]:
return {existing_pack_source}
monkeypatch.setattr(organizations, "get_active_membership", _fake_get_active)
monkeypatch.setattr(organizations, "get_first_membership", _fake_get_first)
monkeypatch.setattr(
organizations,
"_fetch_existing_default_pack_sources",
_fake_fetch_existing_pack_sources,
)
session = _FakeSession(exec_results=[_FakeExecResult()])
out = await organizations.ensure_member_for_user(session, user)
assert out.user_id == user.id
assert out.role == "owner"
assert out.organization_id == user.active_organization_id
skill_packs = [item for item in session.added if isinstance(item, SkillPack)]
assert len(skill_packs) == 1
assert skill_packs[0].source_url == "https://github.com/BrianRWagner/ai-marketing-skills"
assert session.committed == 2
assert len(session.added_all) == 0
@pytest.mark.asyncio
async def test_ensure_member_for_user_recovers_on_default_install_integrity_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
org_id = uuid4()
user = User(clerk_user_id="u1", email=None, active_organization_id=org_id)
existing_member = OrganizationMember(
organization_id=org_id,
user_id=user.id,
role="owner",
)
call_count = 0
async def _fake_get_active(_session: Any, _user: User) -> None:
return None
async def _fake_get_first(_session: Any, _user_id: Any) -> OrganizationMember | None:
nonlocal call_count
call_count += 1
if call_count == 1:
return None
return existing_member
async def _fake_fetch_existing_pack_sources(
_session: Any,
_org_id: Any,
) -> set[str]:
return set()
monkeypatch.setattr(organizations, "get_active_membership", _fake_get_active)
monkeypatch.setattr(organizations, "get_first_membership", _fake_get_first)
monkeypatch.setattr(
organizations,
"_fetch_existing_default_pack_sources",
_fake_fetch_existing_pack_sources,
)
session = _FakeSession(
exec_results=[_FakeExecResult(), _FakeExecResult()],
commit_side_effects=[IntegrityError("statement", [], None)],
)
out = await organizations.ensure_member_for_user(session, user)
assert out is existing_member
assert out.organization_id == org_id
assert call_count == 2
assert user.active_organization_id == org_id
@pytest.mark.asyncio

View File

@@ -0,0 +1,884 @@
# ruff: noqa: INP001
"""Integration tests for skills marketplace APIs."""
from __future__ import annotations
import json
from pathlib import Path
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.gateways import router as gateways_router
from app.api.skills_marketplace import (
PackSkillCandidate,
_collect_pack_skills_from_repo,
_validate_pack_source_url,
)
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.models.skill_packs import SkillPack
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(gateways_router)
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_delete_gateway_removes_installed_skill_rows() -> 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",
)
session.add(skill)
await session.commit()
await session.refresh(skill)
session.add(
GatewayInstalledSkill(
gateway_id=gateway.id,
skill_id=skill.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.delete(f"/api/v1/gateways/{gateway.id}")
assert response.status_code == 200
assert response.json() == {"ok": True}
async with session_maker() as session:
deleted_gateway = await session.get(Gateway, gateway.id)
assert deleted_gateway is None
remaining_installs = (
await session.exec(
select(GatewayInstalledSkill).where(
col(GatewayInstalledSkill.gateway_id) == gateway.id,
),
)
).all()
assert remaining_installs == []
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()
@pytest.mark.asyncio
async def test_sync_pack_clones_and_upserts_skills(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)
pack = SkillPack(
organization_id=organization.id,
name="Antigravity Awesome Skills",
source_url="https://github.com/sickn33/antigravity-awesome-skills",
)
session.add(pack)
await session.commit()
await session.refresh(pack)
app = _build_test_app(session_maker, organization=organization)
collected = [
PackSkillCandidate(
name="Skill Alpha",
description="Alpha description",
source_url="https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/alpha",
category="testing",
risk="low",
source="skills/alpha",
),
PackSkillCandidate(
name="Skill Beta",
description="Beta description",
source_url="https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/beta",
category="automation",
risk="medium",
source="skills/beta",
),
]
def _fake_collect_pack_skills(source_url: str) -> list[PackSkillCandidate]:
assert source_url == "https://github.com/sickn33/antigravity-awesome-skills"
return collected
monkeypatch.setattr(
"app.api.skills_marketplace._collect_pack_skills",
_fake_collect_pack_skills,
)
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://testserver",
) as client:
first_sync = await client.post(f"/api/v1/skills/packs/{pack.id}/sync")
second_sync = await client.post(f"/api/v1/skills/packs/{pack.id}/sync")
assert first_sync.status_code == 200
first_body = first_sync.json()
assert first_body["pack_id"] == str(pack.id)
assert first_body["synced"] == 2
assert first_body["created"] == 2
assert first_body["updated"] == 0
assert second_sync.status_code == 200
second_body = second_sync.json()
assert second_body["pack_id"] == str(pack.id)
assert second_body["synced"] == 2
assert second_body["created"] == 0
assert second_body["updated"] == 0
async with session_maker() as session:
synced_skills = (
await session.exec(
select(MarketplaceSkill).where(
col(MarketplaceSkill.organization_id) == organization.id,
),
)
).all()
assert len(synced_skills) == 2
by_source = {skill.source_url: skill for skill in synced_skills}
assert (
by_source[
"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/alpha"
].name
== "Skill Alpha"
)
assert (
by_source[
"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/alpha"
].category
== "testing"
)
assert (
by_source[
"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/alpha"
].risk
== "low"
)
assert (
by_source[
"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/beta"
].description
== "Beta description"
)
assert (
by_source[
"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/beta"
].source
== "skills/beta"
)
finally:
await engine.dispose()
def test_validate_pack_source_url_allows_https_github_repo_with_optional_dot_git() -> None:
_validate_pack_source_url("https://github.com/org/repo")
_validate_pack_source_url("https://github.com/org/repo.git")
@pytest.mark.parametrize(
"url",
[
"http://github.com/org/repo",
"file:///tmp/repo",
"ssh://github.com/org/repo",
"https://localhost/repo",
"https://127.0.0.1/repo",
"https://[::1]/repo",
],
)
def test_validate_pack_source_url_rejects_unsafe_urls(url: str) -> None:
with pytest.raises(ValueError):
_validate_pack_source_url(url)
def test_validate_pack_source_url_rejects_git_ssh_scp_like_syntax() -> None:
# Not a URL, but worth asserting we fail closed.
with pytest.raises(ValueError):
_validate_pack_source_url("git@github.com:org/repo.git")
@pytest.mark.asyncio
async def test_create_skill_pack_rejects_non_https_source_url() -> 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)
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.post(
"/api/v1/skills/packs",
json={"source_url": "http://github.com/sickn33/antigravity-awesome-skills"},
)
assert response.status_code == 400
assert (
"scheme" in response.json()["detail"].lower()
or "https" in response.json()["detail"].lower()
)
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_create_skill_pack_rejects_localhost_source_url() -> 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)
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.post(
"/api/v1/skills/packs",
json={"source_url": "https://localhost/skills-pack"},
)
assert response.status_code == 400
assert (
"hostname" in response.json()["detail"].lower()
or "not allowed" in response.json()["detail"].lower()
)
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_create_skill_pack_is_unique_by_normalized_source_url() -> 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)
await session.commit()
app = _build_test_app(session_maker, organization=organization)
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://testserver",
) as client:
first = await client.post(
"/api/v1/skills/packs",
json={"source_url": "https://github.com/org/repo"},
)
spaced = await client.post(
"/api/v1/skills/packs",
json={"source_url": " https://github.com/org/repo.git "},
)
second = await client.post(
"/api/v1/skills/packs",
json={"source_url": "https://github.com/org/repo/"},
)
third = await client.post(
"/api/v1/skills/packs",
json={"source_url": "https://github.com/org/repo.git"},
)
packs = await client.get("/api/v1/skills/packs")
assert first.status_code == 200
assert spaced.status_code == 200
assert second.status_code == 200
assert third.status_code == 200
assert spaced.json()["id"] == first.json()["id"]
assert spaced.json()["source_url"] == first.json()["source_url"]
assert second.json()["id"] == first.json()["id"]
assert second.json()["source_url"] == first.json()["source_url"]
assert third.json()["id"] == first.json()["id"]
assert third.json()["source_url"] == first.json()["source_url"]
assert packs.status_code == 200
pack_items = packs.json()
assert len(pack_items) == 1
assert pack_items[0]["source_url"] == "https://github.com/org/repo"
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_list_skill_packs_includes_skill_count() -> 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)
pack = SkillPack(
organization_id=organization.id,
name="Pack One",
source_url="https://github.com/sickn33/antigravity-awesome-skills",
)
session.add(pack)
session.add(
MarketplaceSkill(
organization_id=organization.id,
name="Skill One",
source_url=(
"https://github.com/sickn33/antigravity-awesome-skills"
"/tree/main/skills/alpha"
),
)
)
session.add(
MarketplaceSkill(
organization_id=organization.id,
name="Skill Two",
source_url=(
"https://github.com/sickn33/antigravity-awesome-skills"
"/tree/main/skills/beta"
),
)
)
session.add(
MarketplaceSkill(
organization_id=organization.id,
name="Other Repo Skill",
source_url="https://github.com/other/repo/tree/main/skills/other",
)
)
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/packs")
assert response.status_code == 200
items = response.json()
assert len(items) == 1
assert items[0]["name"] == "Pack One"
assert items[0]["skill_count"] == 2
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_update_skill_pack_rejects_duplicate_normalized_source_url() -> 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)
pack_a = SkillPack(
organization_id=organization.id,
source_url="https://github.com/org/repo",
name="Pack A",
)
pack_b = SkillPack(
organization_id=organization.id,
source_url="https://github.com/org/other-repo",
name="Pack B",
)
session.add(pack_a)
session.add(pack_b)
await session.commit()
await session.refresh(pack_a)
await session.refresh(pack_b)
app = _build_test_app(session_maker, organization=organization)
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://testserver",
) as client:
response = await client.patch(
f"/api/v1/skills/packs/{pack_b.id}",
json={"source_url": "https://github.com/org/repo/"},
)
assert response.status_code == 409
assert "already exists" in response.json()["detail"].lower()
async with session_maker() as session:
pack_rows = (
await session.exec(
select(SkillPack)
.where(col(SkillPack.organization_id) == organization.id)
.order_by(col(SkillPack.created_at).asc())
)
).all()
assert len(pack_rows) == 2
assert {str(pack.source_url) for pack in pack_rows} == {
"https://github.com/org/repo",
"https://github.com/org/other-repo",
}
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_update_skill_pack_normalizes_source_url_on_update() -> 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)
pack = SkillPack(
organization_id=organization.id,
source_url="https://github.com/org/old",
name="Initial",
)
session.add(pack)
await session.commit()
await session.refresh(pack)
app = _build_test_app(session_maker, organization=organization)
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://testserver",
) as client:
response = await client.patch(
f"/api/v1/skills/packs/{pack.id}",
json={"source_url": " https://github.com/org/new.git/ "},
)
assert response.status_code == 200
assert response.json()["source_url"] == "https://github.com/org/new"
async with session_maker() as session:
updated = (
await session.exec(
select(SkillPack).where(col(SkillPack.id) == pack.id),
)
).one()
assert str(updated.source_url) == "https://github.com/org/new"
finally:
await engine.dispose()
def test_collect_pack_skills_from_repo_uses_root_index_when_present(tmp_path: Path) -> None:
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
(repo_dir / "skills").mkdir()
indexed_dir = repo_dir / "skills" / "indexed-fallback"
indexed_dir.mkdir()
(indexed_dir / "SKILL.md").write_text("# Should Not Be Used\n", encoding="utf-8")
(repo_dir / "skills_index.json").write_text(
json.dumps(
[
{
"id": "first",
"name": "Index First",
"description": "From index one",
"path": "skills/index-first",
"category": "uncategorized",
"risk": "unknown",
},
{
"id": "second",
"name": "Index Second",
"description": "From index two",
"path": "skills/index-second/SKILL.md",
"category": "catalog",
"risk": "low",
},
{
"id": "root",
"name": "Root Skill",
"description": "Root from index",
"path": "SKILL.md",
"category": "uncategorized",
"risk": "unknown",
},
]
),
encoding="utf-8",
)
skills = _collect_pack_skills_from_repo(
repo_dir=repo_dir,
source_url="https://github.com/sickn33/antigravity-awesome-skills",
branch="main",
)
assert len(skills) == 3
by_source = {skill.source_url: skill for skill in skills}
assert (
"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/index-first"
in by_source
)
assert (
"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/index-second"
in by_source
)
assert "https://github.com/sickn33/antigravity-awesome-skills/tree/main" in by_source
assert (
by_source[
"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/index-first"
].name
== "Index First"
)
assert (
by_source[
"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/index-first"
].category
== "uncategorized"
)
assert (
by_source[
"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/index-first"
].risk
== "unknown"
)
assert (
by_source[
"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/index-first"
].source
== "skills/index-first"
)
assert (
by_source["https://github.com/sickn33/antigravity-awesome-skills/tree/main"].name
== "Root Skill"
)
def test_collect_pack_skills_from_repo_supports_root_skill_md(tmp_path: Path) -> None:
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
(repo_dir / "SKILL.md").write_text(
"---\nname: x-research-skill\ndescription: Root skill package\n---\n",
encoding="utf-8",
)
skills = _collect_pack_skills_from_repo(
repo_dir=repo_dir,
source_url="https://github.com/rohunvora/x-research-skill",
branch="main",
)
assert len(skills) == 1
only_skill = skills[0]
assert only_skill.name == "x-research-skill"
assert only_skill.description == "Root skill package"
assert only_skill.source_url == "https://github.com/rohunvora/x-research-skill/tree/main"
def test_collect_pack_skills_from_repo_supports_top_level_skill_folders(
tmp_path: Path,
) -> None:
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
first = repo_dir / "content-idea-generator"
second = repo_dir / "homepage-audit"
first.mkdir()
second.mkdir()
(first / "SKILL.md").write_text("# Content Idea Generator\n", encoding="utf-8")
(second / "SKILL.md").write_text("# Homepage Audit\n", encoding="utf-8")
skills = _collect_pack_skills_from_repo(
repo_dir=repo_dir,
source_url="https://github.com/BrianRWagner/ai-marketing-skills",
branch="main",
)
assert len(skills) == 2
by_source = {skill.source_url: skill for skill in skills}
assert (
"https://github.com/BrianRWagner/ai-marketing-skills/tree/main/content-idea-generator"
in by_source
)
assert (
"https://github.com/BrianRWagner/ai-marketing-skills/tree/main/homepage-audit" in by_source
)
def test_collect_pack_skills_from_repo_streams_large_index(tmp_path: Path) -> None:
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
(repo_dir / "SKILL.md").write_text("# Fallback Skill\n", encoding="utf-8")
huge_description = "x" * (300 * 1024)
(repo_dir / "skills_index.json").write_text(
json.dumps(
{
"skills": [
{
"id": "oversized",
"name": "Huge Index Skill",
"description": huge_description,
"path": "skills/ignored",
},
],
}
),
encoding="utf-8",
)
skills = _collect_pack_skills_from_repo(
repo_dir=repo_dir,
source_url="https://github.com/example/oversized-pack",
branch="main",
)
assert len(skills) == 1
assert (
skills[0].source_url == "https://github.com/example/oversized-pack/tree/main/skills/ignored"
)
assert skills[0].name == "Huge Index Skill"

View File

@@ -11,7 +11,7 @@ from sqlmodel import SQLModel, col, select
from sqlmodel.ext.asyncio.session import AsyncSession
from app.api.deps import ActorContext
from app.api.tasks import _TaskUpdateInput, _apply_lead_task_update
from app.api.tasks import _apply_lead_task_update, _TaskUpdateInput
from app.models.agents import Agent
from app.models.boards import Board
from app.models.organizations import Organization

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";
@@ -171,6 +177,9 @@ export * from "./organizationUserRead";
export * from "./readyzReadyzGet200";
export * from "./searchApiV1SoulsDirectorySearchGetParams";
export * from "./sendGatewaySessionMessageApiV1GatewaysSessionsSessionIdMessagePostParams";
export * from "./skillPackCreate";
export * from "./skillPackRead";
export * from "./skillPackSyncResponse";
export * from "./soulsDirectoryMarkdownResponse";
export * from "./soulsDirectorySearchResponse";
export * from "./soulsDirectorySoulRef";
@@ -207,6 +216,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,24 @@
/**
* 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 {
category?: string | null;
created_at: string;
description?: string | null;
id: string;
installed: boolean;
installed_at?: string | null;
name: string;
organization_id: string;
risk?: string | null;
source?: string | null;
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,22 @@
/**
* 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 {
category?: string | null;
created_at: string;
description?: string | null;
id: string;
name: string;
organization_id: string;
risk?: string | null;
source?: string | null;
source_url: string;
updated_at: string;
}

View File

@@ -0,0 +1,18 @@
/**
* Generated by orval v8.3.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
/**
* Payload used to register a pack URL in the organization.
*/
export interface SkillPackCreate {
branch?: string;
description?: string | null;
name?: string | null;
metadata?: Record<string, object>;
/** @minLength 1 */
source_url: string;
}

View File

@@ -0,0 +1,22 @@
/**
* Generated by orval v8.3.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
/**
* Serialized skill pack record.
*/
export interface SkillPackRead {
created_at: string;
description?: string | null;
id: string;
branch: string;
name: string;
organization_id: string;
metadata: Record<string, object>;
skill_count?: number;
source_url: string;
updated_at: string;
}

View File

@@ -0,0 +1,18 @@
/**
* Generated by orval v8.3.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
/**
* Pack sync summary payload.
*/
export interface SkillPackSyncResponse {
created: number;
ok?: boolean;
warnings: string[];
pack_id: string;
synced: number;
updated: number;
}

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,
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,12 @@
import { redirect } from "next/navigation";
type EditMarketplaceSkillPageProps = {
params: Promise<{ skillId: string }>;
};
export default async function EditMarketplaceSkillPage({
params,
}: EditMarketplaceSkillPageProps) {
await params;
redirect("/skills/marketplace");
}

View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function NewMarketplaceSkillPage() {
redirect("/skills/packs/new");
}

View File

@@ -0,0 +1,992 @@
"use client";
export const dynamic = "force-dynamic";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useAuth } from "@/auth/clerk";
import { useQueryClient } from "@tanstack/react-query";
import { ApiError } from "@/api/mutator";
import {
type listGatewaysApiV1GatewaysGetResponse,
useListGatewaysApiV1GatewaysGet,
} from "@/api/generated/gateways/gateways";
import type { MarketplaceSkillCardRead } from "@/api/generated/model";
import {
listMarketplaceSkillsApiV1SkillsMarketplaceGet,
type listMarketplaceSkillsApiV1SkillsMarketplaceGetResponse,
useInstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPost,
useListMarketplaceSkillsApiV1SkillsMarketplaceGet,
useUninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPost,
} from "@/api/generated/skills-marketplace/skills-marketplace";
import {
type listSkillPacksApiV1SkillsPacksGetResponse,
useListSkillPacksApiV1SkillsPacksGet,
} from "@/api/generated/skills/skills";
import { SkillInstallDialog } from "@/components/skills/SkillInstallDialog";
import { MarketplaceSkillsTable } from "@/components/skills/MarketplaceSkillsTable";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { Button, buttonVariants } from "@/components/ui/button";
import { useOrganizationMembership } from "@/lib/use-organization-membership";
import { useUrlSorting } from "@/lib/use-url-sorting";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const MARKETPLACE_SKILLS_SORTABLE_COLUMNS = [
"name",
"category",
"risk",
"source",
"updated_at",
];
const MARKETPLACE_DEFAULT_PAGE_SIZE = 25;
const MARKETPLACE_PAGE_SIZE_OPTIONS = [25, 50, 100, 200] as const;
type MarketplaceSkillListParams = {
gateway_id: string;
search?: string;
category?: string;
risk?: string;
pack_id?: string;
limit?: number;
offset?: number;
};
const RISK_SORT_ORDER: Record<string, number> = {
safe: 10,
low: 20,
minimal: 30,
medium: 40,
moderate: 50,
elevated: 60,
high: 70,
critical: 80,
none: 90,
unknown: 100,
};
function formatRiskLabel(risk: string) {
const normalized = risk.trim().toLowerCase();
if (!normalized) {
return "Unknown";
}
switch (normalized) {
case "safe":
return "Safe";
case "low":
return "Low";
case "minimal":
return "Minimal";
case "medium":
return "Medium";
case "moderate":
return "Moderate";
case "elevated":
return "Elevated";
case "high":
return "High";
case "critical":
return "Critical";
case "none":
return "None";
case "unknown":
return "Unknown";
default:
return normalized
.split(/[\s_-]+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
}
function formatCategoryLabel(category: string) {
const normalized = category.trim();
if (!normalized) {
return "Uncategorized";
}
return normalized
.split(/[\s_-]+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
function parsePositiveIntParam(value: string | null, fallback: number) {
if (!value) {
return fallback;
}
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed < 1) {
return fallback;
}
return parsed;
}
function parsePageSizeParam(value: string | null) {
const parsed = parsePositiveIntParam(value, MARKETPLACE_DEFAULT_PAGE_SIZE);
if (
MARKETPLACE_PAGE_SIZE_OPTIONS.includes(
parsed as (typeof MARKETPLACE_PAGE_SIZE_OPTIONS)[number],
)
) {
return parsed;
}
return MARKETPLACE_DEFAULT_PAGE_SIZE;
}
export default function SkillsMarketplacePage() {
const queryClient = useQueryClient();
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const { isSignedIn } = useAuth();
const { isAdmin } = useOrganizationMembership(isSignedIn);
const [selectedSkill, setSelectedSkill] =
useState<MarketplaceSkillCardRead | null>(null);
const [gatewayInstalledById, setGatewayInstalledById] = useState<
Record<string, boolean>
>({});
const [installedGatewayNamesBySkillId, setInstalledGatewayNamesBySkillId] =
useState<Record<string, { id: string; name: string }[]>>({});
const [isGatewayStatusLoading, setIsGatewayStatusLoading] = useState(false);
const [gatewayStatusError, setGatewayStatusError] = useState<string | null>(
null,
);
const [installingGatewayId, setInstallingGatewayId] = useState<string | null>(
null,
);
const initialSearch = searchParams.get("search") ?? "";
const initialCategory = (searchParams.get("category") ?? "all")
.trim()
.toLowerCase();
const initialRisk = (searchParams.get("risk") ?? "safe").trim().toLowerCase();
const initialPage = parsePositiveIntParam(searchParams.get("page"), 1);
const initialPageSize = parsePageSizeParam(searchParams.get("limit"));
const [searchTerm, setSearchTerm] = useState(initialSearch);
const [selectedCategory, setSelectedCategory] = useState<string>(
initialCategory || "all",
);
const [selectedRisk, setSelectedRisk] = useState<string>(
initialRisk || "safe",
);
const [currentPage, setCurrentPage] = useState(initialPage);
const [pageSize, setPageSize] = useState(initialPageSize);
const { sorting, onSortingChange } = useUrlSorting({
allowedColumnIds: MARKETPLACE_SKILLS_SORTABLE_COLUMNS,
defaultSorting: [{ id: "name", desc: false }],
paramPrefix: "skills_marketplace",
});
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 = gateways[0]?.id ?? "";
const normalizedCategory = useMemo(() => {
const value = selectedCategory.trim().toLowerCase();
return value.length > 0 ? value : "all";
}, [selectedCategory]);
const normalizedRisk = useMemo(() => {
const value = selectedRisk.trim().toLowerCase();
return value.length > 0 ? value : "safe";
}, [selectedRisk]);
const normalizedSearch = useMemo(() => searchTerm.trim(), [searchTerm]);
const selectedPackId = searchParams.get("packId");
const skillsParams = useMemo<MarketplaceSkillListParams>(() => {
const params: MarketplaceSkillListParams = {
gateway_id: resolvedGatewayId,
limit: pageSize,
offset: (currentPage - 1) * pageSize,
};
if (normalizedSearch) {
params.search = normalizedSearch;
}
if (normalizedCategory !== "all") {
params.category = normalizedCategory;
}
if (normalizedRisk && normalizedRisk !== "all") {
params.risk = normalizedRisk;
}
if (selectedPackId) {
params.pack_id = selectedPackId;
}
return params;
}, [
currentPage,
pageSize,
normalizedCategory,
normalizedRisk,
normalizedSearch,
resolvedGatewayId,
selectedPackId,
]);
const filterOptionsParams = useMemo<MarketplaceSkillListParams>(() => {
const params: MarketplaceSkillListParams = {
gateway_id: resolvedGatewayId,
};
if (normalizedSearch) {
params.search = normalizedSearch;
}
if (selectedPackId) {
params.pack_id = selectedPackId;
}
return params;
}, [normalizedSearch, resolvedGatewayId, selectedPackId]);
const skillsQuery = useListMarketplaceSkillsApiV1SkillsMarketplaceGet<
listMarketplaceSkillsApiV1SkillsMarketplaceGetResponse,
ApiError
>(skillsParams, {
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 filterOptionSkillsQuery =
useListMarketplaceSkillsApiV1SkillsMarketplaceGet<
listMarketplaceSkillsApiV1SkillsMarketplaceGetResponse,
ApiError
>(filterOptionsParams, {
query: {
enabled: Boolean(isSignedIn && isAdmin && resolvedGatewayId),
refetchOnMount: "always",
refetchInterval: 15_000,
},
});
const filterOptionSkills = useMemo<MarketplaceSkillCardRead[]>(
() =>
filterOptionSkillsQuery.data?.status === 200
? filterOptionSkillsQuery.data.data
: [],
[filterOptionSkillsQuery.data],
);
const packsQuery = useListSkillPacksApiV1SkillsPacksGet<
listSkillPacksApiV1SkillsPacksGetResponse,
ApiError
>({
query: {
enabled: Boolean(isSignedIn && isAdmin),
refetchOnMount: "always",
},
});
const packs = useMemo(
() => (packsQuery.data?.status === 200 ? packsQuery.data.data : []),
[packsQuery.data],
);
const selectedPack = useMemo(
() => packs.find((pack) => pack.id === selectedPackId) ?? null,
[packs, selectedPackId],
);
const filteredSkills = useMemo(() => skills, [skills]);
const totalCountInfo = useMemo(() => {
if (skillsQuery.data?.status !== 200) {
return { hasKnownTotal: false, total: skills.length };
}
const totalCountHeader = skillsQuery.data.headers.get("x-total-count");
if (
typeof totalCountHeader === "string" &&
totalCountHeader.trim() !== ""
) {
const parsed = Number(totalCountHeader);
if (Number.isFinite(parsed) && parsed >= 0) {
return { hasKnownTotal: true, total: parsed };
}
}
return { hasKnownTotal: false, total: skills.length };
}, [skills, skillsQuery.data]);
const totalSkills = useMemo(() => {
if (totalCountInfo.hasKnownTotal) {
return totalCountInfo.total;
}
return (currentPage - 1) * pageSize + skills.length;
}, [currentPage, pageSize, skills.length, totalCountInfo]);
const totalPages = useMemo(
() => Math.max(1, Math.ceil(totalSkills / pageSize)),
[pageSize, totalSkills],
);
const hasNextPage = useMemo(() => {
if (totalCountInfo.hasKnownTotal) {
return currentPage < totalPages;
}
return skills.length === pageSize;
}, [
currentPage,
pageSize,
skills.length,
totalCountInfo.hasKnownTotal,
totalPages,
]);
const rangeStart = totalSkills === 0 ? 0 : (currentPage - 1) * pageSize + 1;
const rangeEnd =
totalSkills === 0 ? 0 : (currentPage - 1) * pageSize + skills.length;
const categoryFilterOptions = useMemo(() => {
const byValue = new Map<string, string>();
for (const skill of filterOptionSkills) {
const raw = (skill.category || "Uncategorized").trim();
const label = raw.length > 0 ? raw : "Uncategorized";
const value = label.trim().toLowerCase();
if (!value || value === "all" || byValue.has(value)) {
continue;
}
byValue.set(value, label);
}
if (normalizedCategory !== "all" && !byValue.has(normalizedCategory)) {
byValue.set(normalizedCategory, formatCategoryLabel(normalizedCategory));
}
return Array.from(byValue.entries())
.map(([value, label]) => ({ value, label }))
.sort((a, b) => a.label.localeCompare(b.label));
}, [filterOptionSkills, normalizedCategory]);
const riskFilterOptions = useMemo(() => {
const set = new Set<string>();
for (const skill of filterOptionSkills) {
const risk = (skill.risk || "unknown").trim().toLowerCase();
const normalized = risk.length > 0 ? risk : "unknown";
if (normalized !== "all") {
set.add(normalized);
}
}
if (normalizedRisk !== "all") {
set.add(normalizedRisk);
}
const risks = Array.from(set);
return risks.sort((a, b) => {
const rankA = RISK_SORT_ORDER[a] ?? 1000;
const rankB = RISK_SORT_ORDER[b] ?? 1000;
if (rankA !== rankB) {
return rankA - rankB;
}
return a.localeCompare(b);
});
}, [filterOptionSkills, normalizedRisk]);
useEffect(() => {
if (
selectedCategory !== "all" &&
!categoryFilterOptions.some(
(category) => category.value === selectedCategory.trim().toLowerCase(),
)
) {
setSelectedCategory("all");
}
}, [categoryFilterOptions, selectedCategory]);
useEffect(() => {
if (
selectedRisk !== "all" &&
!riskFilterOptions.includes(selectedRisk.trim().toLowerCase())
) {
setSelectedRisk("safe");
}
}, [riskFilterOptions, selectedRisk]);
useEffect(() => {
setCurrentPage(1);
}, [
normalizedCategory,
normalizedRisk,
normalizedSearch,
pageSize,
resolvedGatewayId,
selectedPackId,
]);
useEffect(() => {
if (totalCountInfo.hasKnownTotal && currentPage > totalPages) {
setCurrentPage(totalPages);
}
}, [currentPage, totalCountInfo.hasKnownTotal, totalPages]);
useEffect(() => {
const nextParams = new URLSearchParams(searchParams.toString());
const normalizedSearchForUrl = searchTerm.trim();
if (normalizedSearchForUrl) {
nextParams.set("search", normalizedSearchForUrl);
} else {
nextParams.delete("search");
}
if (selectedCategory !== "all") {
nextParams.set("category", selectedCategory);
} else {
nextParams.delete("category");
}
if (selectedRisk !== "safe") {
nextParams.set("risk", selectedRisk);
} else {
nextParams.delete("risk");
}
if (pageSize !== MARKETPLACE_DEFAULT_PAGE_SIZE) {
nextParams.set("limit", String(pageSize));
} else {
nextParams.delete("limit");
}
if (currentPage > 1) {
nextParams.set("page", String(currentPage));
} else {
nextParams.delete("page");
}
const currentQuery = searchParams.toString();
const nextQuery = nextParams.toString();
if (nextQuery !== currentQuery) {
router.replace(nextQuery ? `${pathname}?${nextQuery}` : pathname, {
scroll: false,
});
}
}, [
currentPage,
pathname,
pageSize,
router,
searchParams,
searchTerm,
selectedCategory,
selectedRisk,
]);
const loadSkillsByGateway = useCallback(async () => {
// NOTE: This is technically N+1 (one request per gateway). We intentionally
// parallelize requests to keep the UI responsive and avoid slow sequential
// fetches. If this becomes a bottleneck for large gateway counts, add a
// backend batch endpoint to return installation state across all gateways.
const gatewaySkills = await Promise.all(
gateways.map(async (gateway) => {
const response = await listMarketplaceSkillsApiV1SkillsMarketplaceGet({
gateway_id: gateway.id,
});
return {
gatewayId: gateway.id,
gatewayName: gateway.name,
skills: response.status === 200 ? response.data : [],
};
}),
);
return gatewaySkills;
}, [gateways]);
const updateInstalledGatewayNames = useCallback(
({
skillId,
gatewayId,
gatewayName,
installed,
}: {
skillId: string;
gatewayId: string;
gatewayName: string;
installed: boolean;
}) => {
setInstalledGatewayNamesBySkillId((previous) => {
const installedOn = previous[skillId] ?? [];
if (installed) {
if (installedOn.some((gateway) => gateway.id === gatewayId)) {
return previous;
}
return {
...previous,
[skillId]: [...installedOn, { id: gatewayId, name: gatewayName }],
};
}
return {
...previous,
[skillId]: installedOn.filter((gateway) => gateway.id !== gatewayId),
};
});
},
[],
);
useEffect(() => {
let cancelled = false;
const loadInstalledGatewaysBySkill = async () => {
if (
!isSignedIn ||
!isAdmin ||
gateways.length === 0 ||
skills.length === 0
) {
setInstalledGatewayNamesBySkillId({});
return;
}
try {
const gatewaySkills = await Promise.all(
gateways.map(async (gateway) => {
const response =
await listMarketplaceSkillsApiV1SkillsMarketplaceGet({
gateway_id: gateway.id,
});
return {
gatewayId: gateway.id,
gatewayName: gateway.name,
skills: response.status === 200 ? response.data : [],
};
}),
);
if (cancelled) return;
const nextInstalledGatewayNamesBySkillId: Record<
string,
{ id: string; name: string }[]
> = {};
for (const skill of skills) {
nextInstalledGatewayNamesBySkillId[skill.id] = [];
}
for (const {
gatewayId,
gatewayName,
skills: gatewaySkillRows,
} of gatewaySkills) {
for (const skill of gatewaySkillRows) {
if (!skill.installed) continue;
if (!nextInstalledGatewayNamesBySkillId[skill.id]) continue;
nextInstalledGatewayNamesBySkillId[skill.id].push({
id: gatewayId,
name: gatewayName,
});
}
}
setInstalledGatewayNamesBySkillId(nextInstalledGatewayNamesBySkillId);
} catch {
if (cancelled) return;
setInstalledGatewayNamesBySkillId({});
}
};
void loadInstalledGatewaysBySkill();
return () => {
cancelled = true;
};
}, [gateways, isAdmin, isSignedIn, skills]);
const installMutation =
useInstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPost<ApiError>(
{
mutation: {
onSuccess: async (_, variables) => {
await queryClient.invalidateQueries({
queryKey: ["/api/v1/skills/marketplace"],
});
setGatewayInstalledById((previous) => ({
...previous,
[variables.params.gateway_id]: true,
}));
const gatewayName = gateways.find(
(gateway) => gateway.id === variables.params.gateway_id,
)?.name;
if (gatewayName) {
updateInstalledGatewayNames({
skillId: variables.skillId,
gatewayId: variables.params.gateway_id,
gatewayName,
installed: true,
});
}
},
},
},
queryClient,
);
const uninstallMutation =
useUninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPost<ApiError>(
{
mutation: {
onSuccess: async (_, variables) => {
await queryClient.invalidateQueries({
queryKey: ["/api/v1/skills/marketplace"],
});
setGatewayInstalledById((previous) => ({
...previous,
[variables.params.gateway_id]: false,
}));
const gatewayName = gateways.find(
(gateway) => gateway.id === variables.params.gateway_id,
)?.name;
if (gatewayName) {
updateInstalledGatewayNames({
skillId: variables.skillId,
gatewayId: variables.params.gateway_id,
gatewayName,
installed: false,
});
}
},
},
},
queryClient,
);
useEffect(() => {
let cancelled = false;
const loadGatewayStatus = async () => {
if (!selectedSkill) {
setGatewayInstalledById({});
setGatewayStatusError(null);
setIsGatewayStatusLoading(false);
return;
}
if (gateways.length === 0) {
setGatewayInstalledById({});
setGatewayStatusError(null);
setIsGatewayStatusLoading(false);
return;
}
setIsGatewayStatusLoading(true);
setGatewayStatusError(null);
try {
const gatewaySkills = await loadSkillsByGateway();
const entries = gatewaySkills.map(
({ gatewayId, skills: gatewaySkillRows }) => {
const row = gatewaySkillRows.find(
(skill) => skill.id === selectedSkill.id,
);
return [gatewayId, Boolean(row?.installed)] as const;
},
);
if (cancelled) return;
setGatewayInstalledById(Object.fromEntries(entries));
} catch (error) {
if (cancelled) return;
setGatewayStatusError(
error instanceof Error
? error.message
: "Unable to load gateway status.",
);
} finally {
if (!cancelled) {
setIsGatewayStatusLoading(false);
}
}
};
void loadGatewayStatus();
return () => {
cancelled = true;
};
}, [gateways, loadSkillsByGateway, selectedSkill]);
const mutationError =
installMutation.error?.message ?? uninstallMutation.error?.message ?? null;
const isMutating = installMutation.isPending || uninstallMutation.isPending;
const handleGatewayInstallAction = async (
gatewayId: string,
isInstalled: boolean,
) => {
if (!selectedSkill) return;
setInstallingGatewayId(gatewayId);
try {
if (isInstalled) {
await uninstallMutation.mutateAsync({
skillId: selectedSkill.id,
params: { gateway_id: gatewayId },
});
} else {
await installMutation.mutateAsync({
skillId: selectedSkill.id,
params: { gateway_id: gatewayId },
});
}
} finally {
setInstallingGatewayId(null);
}
};
return (
<>
<DashboardPageLayout
signedOut={{
message: "Sign in to manage marketplace skills.",
forceRedirectUrl: "/skills/marketplace",
}}
title="Skills Marketplace"
description={
selectedPack
? `${totalSkills} skill${
totalSkills === 1 ? "" : "s"
} for ${selectedPack.name}.`
: `${totalSkills} skill${
totalSkills === 1 ? "" : "s"
} synced from packs.`
}
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 manage installs.
</p>
<Link
href="/gateways/new"
className={`${buttonVariants({ variant: "primary", size: "md" })} mt-4`}
>
Create gateway
</Link>
</div>
) : (
<>
<div className="mb-5 rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
<div className="grid gap-4 md:grid-cols-[1fr_240px_240px]">
<div>
<label
htmlFor="marketplace-search"
className="mb-1 block text-sm font-medium text-slate-700"
>
Search
</label>
<Input
id="marketplace-search"
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
placeholder="Search by name, description, category, pack, source..."
type="search"
/>
</div>
<div>
<label
htmlFor="marketplace-category-filter"
className="mb-1 block text-sm font-medium text-slate-700"
>
Category
</label>
<Select
value={selectedCategory}
onValueChange={setSelectedCategory}
>
<SelectTrigger
id="marketplace-category-filter"
className="h-11"
>
<SelectValue placeholder="All categories" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All categories</SelectItem>
{categoryFilterOptions.map((category) => (
<SelectItem
key={category.value}
value={category.value}
>
{category.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label
htmlFor="marketplace-risk-filter"
className="mb-1 block text-sm font-medium text-slate-700"
>
Risk
</label>
<Select
value={selectedRisk}
onValueChange={setSelectedRisk}
>
<SelectTrigger
id="marketplace-risk-filter"
className="h-11"
>
<SelectValue placeholder="Safe" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All risks</SelectItem>
{riskFilterOptions.map((risk) => (
<SelectItem key={risk} value={risk}>
{formatRiskLabel(risk)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<MarketplaceSkillsTable
skills={filteredSkills}
installedGatewayNamesBySkillId={
installedGatewayNamesBySkillId
}
isLoading={skillsQuery.isLoading}
sorting={sorting}
onSortingChange={onSortingChange}
stickyHeader
isMutating={isMutating}
onSkillClick={setSelectedSkill}
emptyState={{
title: "No marketplace skills yet",
description:
"Add packs first, then synced skills will appear here.",
actionHref: "/skills/packs/new",
actionLabel: "Add your first pack",
}}
/>
</div>
<div className="flex items-center justify-between rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-600 shadow-sm">
<div className="flex items-center gap-3">
<p>
Showing {rangeStart}-{rangeEnd} of {totalSkills}
</p>
<div className="flex items-center gap-2">
<span className="text-xs font-medium uppercase tracking-wide text-slate-500">
Rows
</span>
<Select
value={String(pageSize)}
onValueChange={(value) => {
const next = Number.parseInt(value, 10);
if (
MARKETPLACE_PAGE_SIZE_OPTIONS.includes(
next as (typeof MARKETPLACE_PAGE_SIZE_OPTIONS)[number],
)
) {
setPageSize(next);
}
}}
>
<SelectTrigger
id="marketplace-footer-limit-filter"
className="h-8 w-24"
>
<SelectValue placeholder="25" />
</SelectTrigger>
<SelectContent>
{MARKETPLACE_PAGE_SIZE_OPTIONS.map((option) => (
<SelectItem key={option} value={String(option)}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
disabled={currentPage <= 1 || skillsQuery.isLoading}
onClick={() =>
setCurrentPage((prev) => Math.max(1, prev - 1))
}
>
Previous
</Button>
<span className="text-xs font-medium uppercase tracking-wide text-slate-500">
{totalCountInfo.hasKnownTotal
? `Page ${currentPage} of ${totalPages}`
: `Page ${currentPage}`}
</span>
<Button
type="button"
variant="ghost"
size="sm"
disabled={!hasNextPage || skillsQuery.isLoading}
onClick={() => {
setCurrentPage((prev) =>
totalCountInfo.hasKnownTotal
? Math.min(totalPages, prev + 1)
: prev + 1,
);
}}
>
Next
</Button>
</div>
</div>
</>
)}
{skillsQuery.error ? (
<p className="text-sm text-rose-600">{skillsQuery.error.message}</p>
) : null}
{packsQuery.error ? (
<p className="text-sm text-rose-600">{packsQuery.error.message}</p>
) : null}
{mutationError ? (
<p className="text-sm text-rose-600">{mutationError}</p>
) : null}
</div>
</DashboardPageLayout>
<SkillInstallDialog
selectedSkill={selectedSkill}
gateways={gateways}
gatewayInstalledById={gatewayInstalledById}
isGatewayStatusLoading={isGatewayStatusLoading}
installingGatewayId={installingGatewayId}
isMutating={isMutating}
gatewayStatusError={gatewayStatusError}
mutationError={mutationError}
onOpenChange={(open) => {
if (!open) {
setSelectedSkill(null);
}
}}
onToggleInstall={(gatewayId, isInstalled) => {
void handleGatewayInstallAction(gatewayId, isInstalled);
}}
/>
</>
);
}

View File

@@ -0,0 +1,110 @@
"use client";
export const dynamic = "force-dynamic";
import { useParams, useRouter } from "next/navigation";
import { useAuth } from "@/auth/clerk";
import { ApiError } from "@/api/mutator";
import {
type getSkillPackApiV1SkillsPacksPackIdGetResponse,
useGetSkillPackApiV1SkillsPacksPackIdGet,
useUpdateSkillPackApiV1SkillsPacksPackIdPatch,
} from "@/api/generated/skills/skills";
import { MarketplaceSkillForm } from "@/components/skills/MarketplaceSkillForm";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { useOrganizationMembership } from "@/lib/use-organization-membership";
export default function EditSkillPackPage() {
const router = useRouter();
const params = useParams();
const { isSignedIn } = useAuth();
const { isAdmin } = useOrganizationMembership(isSignedIn);
const packIdParam = params?.packId;
const packId = Array.isArray(packIdParam) ? packIdParam[0] : packIdParam;
const packQuery = useGetSkillPackApiV1SkillsPacksPackIdGet<
getSkillPackApiV1SkillsPacksPackIdGetResponse,
ApiError
>(packId ?? "", {
query: {
enabled: Boolean(isSignedIn && isAdmin && packId),
refetchOnMount: "always",
retry: false,
},
});
const pack = packQuery.data?.status === 200 ? packQuery.data.data : null;
const saveMutation =
useUpdateSkillPackApiV1SkillsPacksPackIdPatch<ApiError>();
return (
<DashboardPageLayout
signedOut={{
message: "Sign in to edit skill packs.",
forceRedirectUrl: `/skills/packs/${packId ?? ""}/edit`,
}}
title={pack ? `Edit ${pack.name}` : "Edit skill pack"}
description="Update skill URL pack details."
isAdmin={isAdmin}
adminOnlyMessage="Only organization owners and admins can manage skill packs."
stickyHeader
>
{packQuery.isLoading ? (
<div className="rounded-xl border border-slate-200 bg-white p-6 text-sm text-slate-500 shadow-sm">
Loading pack...
</div>
) : packQuery.error ? (
<div className="rounded-xl border border-rose-200 bg-rose-50 p-6 text-sm text-rose-700 shadow-sm">
{packQuery.error.message}
</div>
) : !pack ? (
<div className="rounded-xl border border-slate-200 bg-white p-6 text-sm text-slate-500 shadow-sm">
Pack not found.
</div>
) : (
<MarketplaceSkillForm
key={pack.id}
initialValues={{
sourceUrl: pack.source_url,
name: pack.name,
description: pack.description ?? "",
branch: pack.branch || "main",
}}
sourceLabel="Pack URL"
nameLabel="Pack name (optional)"
descriptionLabel="Pack description (optional)"
branchLabel="Pack branch (optional)"
branchPlaceholder="main"
showBranch
descriptionPlaceholder="Short summary shown in the packs list."
requiredUrlMessage="Pack URL is required."
invalidUrlMessage="Pack URL must be a GitHub repository URL (https://github.com/<owner>/<repo>)."
submitLabel="Save changes"
submittingLabel="Saving..."
isSubmitting={saveMutation.isPending}
onCancel={() => router.push("/skills/packs")}
onSubmit={async (values) => {
const result = await saveMutation.mutateAsync({
packId: pack.id,
data: {
source_url: values.sourceUrl,
name: values.name || undefined,
description: values.description || undefined,
branch: values.branch || "main",
metadata: pack.metadata || {},
},
});
if (result.status !== 200) {
throw new Error("Unable to update pack.");
}
router.push("/skills/packs");
}}
/>
)}
</DashboardPageLayout>
);
}

View File

@@ -0,0 +1,66 @@
"use client";
export const dynamic = "force-dynamic";
import { useRouter } from "next/navigation";
import { useAuth } from "@/auth/clerk";
import { ApiError } from "@/api/mutator";
import { useCreateSkillPackApiV1SkillsPacksPost } from "@/api/generated/skills/skills";
import { MarketplaceSkillForm } from "@/components/skills/MarketplaceSkillForm";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { useOrganizationMembership } from "@/lib/use-organization-membership";
export default function NewSkillPackPage() {
const router = useRouter();
const { isSignedIn } = useAuth();
const { isAdmin } = useOrganizationMembership(isSignedIn);
const createMutation = useCreateSkillPackApiV1SkillsPacksPost<ApiError>();
return (
<DashboardPageLayout
signedOut={{
message: "Sign in to add skill packs.",
forceRedirectUrl: "/skills/packs/new",
}}
title="Add skill pack"
description="Add a new skill URL pack for your organization."
isAdmin={isAdmin}
adminOnlyMessage="Only organization owners and admins can manage skill packs."
stickyHeader
>
<MarketplaceSkillForm
sourceLabel="Pack URL"
nameLabel="Pack name (optional)"
descriptionLabel="Pack description (optional)"
descriptionPlaceholder="Short summary shown in the packs list."
branchLabel="Pack branch (optional)"
branchPlaceholder="main"
showBranch
requiredUrlMessage="Pack URL is required."
invalidUrlMessage="Pack URL must be a GitHub repository URL (https://github.com/<owner>/<repo>)."
submitLabel="Add pack"
submittingLabel="Adding..."
isSubmitting={createMutation.isPending}
onCancel={() => router.push("/skills/packs")}
onSubmit={async (values) => {
const result = await createMutation.mutateAsync({
data: {
source_url: values.sourceUrl,
name: values.name || undefined,
description: values.description || undefined,
branch: values.branch || "main",
metadata: {},
},
});
if (result.status !== 200) {
throw new Error("Unable to add pack.");
}
router.push("/skills/packs");
}}
/>
</DashboardPageLayout>
);
}

View File

@@ -0,0 +1,293 @@
"use client";
export const dynamic = "force-dynamic";
import Link from "next/link";
import { useMemo, useState } from "react";
import { useAuth } from "@/auth/clerk";
import { useQueryClient } from "@tanstack/react-query";
import { ApiError } from "@/api/mutator";
import type { SkillPackRead } from "@/api/generated/model";
import {
getListSkillPacksApiV1SkillsPacksGetQueryKey,
type listSkillPacksApiV1SkillsPacksGetResponse,
useDeleteSkillPackApiV1SkillsPacksPackIdDelete,
useListSkillPacksApiV1SkillsPacksGet,
useSyncSkillPackApiV1SkillsPacksPackIdSyncPost,
} from "@/api/generated/skills/skills";
import { SkillPacksTable } from "@/components/skills/SkillPacksTable";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { buttonVariants } from "@/components/ui/button";
import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
import { useOrganizationMembership } from "@/lib/use-organization-membership";
import { useUrlSorting } from "@/lib/use-url-sorting";
const PACKS_SORTABLE_COLUMNS = [
"name",
"source_url",
"branch",
"skill_count",
"updated_at",
];
export default function SkillsPacksPage() {
const queryClient = useQueryClient();
const { isSignedIn } = useAuth();
const { isAdmin } = useOrganizationMembership(isSignedIn);
const [deleteTarget, setDeleteTarget] = useState<SkillPackRead | null>(null);
const [syncingPackIds, setSyncingPackIds] = useState<Set<string>>(new Set());
const [isSyncingAll, setIsSyncingAll] = useState(false);
const [syncAllError, setSyncAllError] = useState<string | null>(null);
const [syncWarnings, setSyncWarnings] = useState<string[]>([]);
const { sorting, onSortingChange } = useUrlSorting({
allowedColumnIds: PACKS_SORTABLE_COLUMNS,
defaultSorting: [{ id: "name", desc: false }],
paramPrefix: "skill_packs",
});
const packsQuery = useListSkillPacksApiV1SkillsPacksGet<
listSkillPacksApiV1SkillsPacksGetResponse,
ApiError
>({
query: {
enabled: Boolean(isSignedIn && isAdmin),
refetchOnMount: "always",
refetchInterval: 15_000,
},
});
const packsQueryKey = getListSkillPacksApiV1SkillsPacksGetQueryKey();
const packs = useMemo<SkillPackRead[]>(
() => (packsQuery.data?.status === 200 ? packsQuery.data.data : []),
[packsQuery.data],
);
const deleteMutation =
useDeleteSkillPackApiV1SkillsPacksPackIdDelete<ApiError>(
{
mutation: {
onSuccess: async () => {
setDeleteTarget(null);
await queryClient.invalidateQueries({
queryKey: packsQueryKey,
});
},
},
},
queryClient,
);
const syncMutation = useSyncSkillPackApiV1SkillsPacksPackIdSyncPost<ApiError>(
{
mutation: {
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: packsQueryKey,
});
},
},
},
queryClient,
);
const handleDelete = () => {
if (!deleteTarget) return;
deleteMutation.mutate({ packId: deleteTarget.id });
};
const handleSyncPack = async (pack: SkillPackRead) => {
if (isSyncingAll || syncingPackIds.has(pack.id)) return;
setSyncAllError(null);
setSyncWarnings([]);
setSyncingPackIds((previous) => {
const next = new Set(previous);
next.add(pack.id);
return next;
});
try {
const response = await syncMutation.mutateAsync({
packId: pack.id,
});
if (response.status === 200) {
setSyncWarnings(response.data.warnings ?? []);
}
} finally {
setSyncingPackIds((previous) => {
const next = new Set(previous);
next.delete(pack.id);
return next;
});
}
};
const handleSyncAllPacks = async () => {
if (
!isAdmin ||
isSyncingAll ||
syncingPackIds.size > 0 ||
packs.length === 0
) {
return;
}
setSyncAllError(null);
setSyncWarnings([]);
setIsSyncingAll(true);
try {
let hasFailure = false;
for (const pack of packs) {
if (!pack.id) continue;
setSyncingPackIds((previous) => {
const next = new Set(previous);
next.add(pack.id);
return next;
});
try {
const response = await syncMutation.mutateAsync({ packId: pack.id });
if (response.status === 200) {
setSyncWarnings((previous) => [
...previous,
...(response.data.warnings ?? []),
]);
}
} catch {
hasFailure = true;
} finally {
setSyncingPackIds((previous) => {
const next = new Set(previous);
next.delete(pack.id);
return next;
});
}
}
if (hasFailure) {
setSyncAllError("Some skill packs failed to sync. Please try again.");
}
} finally {
setIsSyncingAll(false);
await queryClient.invalidateQueries({
queryKey: packsQueryKey,
});
}
};
return (
<>
<DashboardPageLayout
signedOut={{
message: "Sign in to manage skill packs.",
forceRedirectUrl: "/skills/packs",
}}
title="Skill Packs"
description={`${packs.length} pack${packs.length === 1 ? "" : "s"} configured.`}
headerActions={
isAdmin ? (
<div className="flex items-center gap-2">
<button
type="button"
className={buttonVariants({
variant: "outline",
size: "md",
})}
disabled={
isSyncingAll || syncingPackIds.size > 0 || packs.length === 0
}
onClick={() => {
void handleSyncAllPacks();
}}
>
{isSyncingAll ? "Syncing all..." : "Sync all"}
</button>
<Link
href="/skills/packs/new"
className={buttonVariants({ variant: "primary", size: "md" })}
>
Add pack
</Link>
</div>
) : null
}
isAdmin={isAdmin}
adminOnlyMessage="Only organization owners and admins can manage skill packs."
stickyHeader
>
<div className="space-y-6">
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<SkillPacksTable
packs={packs}
isLoading={packsQuery.isLoading}
sorting={sorting}
onSortingChange={onSortingChange}
stickyHeader
getEditHref={(pack) => `/skills/packs/${pack.id}/edit`}
canSync
syncingPackIds={syncingPackIds}
onSync={(pack) => {
void handleSyncPack(pack);
}}
onDelete={setDeleteTarget}
emptyState={{
title: "No packs yet",
description: "Add your first skill URL pack to get started.",
actionHref: "/skills/packs/new",
actionLabel: "Add your first pack",
}}
/>
</div>
{packsQuery.error ? (
<p className="text-sm text-rose-600">{packsQuery.error.message}</p>
) : null}
{deleteMutation.error ? (
<p className="text-sm text-rose-600">
{deleteMutation.error.message}
</p>
) : null}
{syncMutation.error ? (
<p className="text-sm text-rose-600">
{syncMutation.error.message}
</p>
) : null}
{syncAllError ? (
<p className="text-sm text-rose-600">{syncAllError}</p>
) : null}
{syncWarnings.length > 0 ? (
<div className="space-y-1">
{syncWarnings.map((warning) => (
<p key={warning} className="text-sm text-amber-600">
{warning}
</p>
))}
</div>
) : null}
</div>
</DashboardPageLayout>
<ConfirmActionDialog
open={Boolean(deleteTarget)}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null);
}}
ariaLabel="Delete skill pack"
title="Delete skill pack"
description={
<>
This will remove <strong>{deleteTarget?.name}</strong> from your
pack list. This action cannot be undone.
</>
}
errorMessage={deleteMutation.error?.message}
onConfirm={handleDelete}
isConfirming={deleteMutation.isPending}
/>
</>
);
}

View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function SkillsIndexPage() {
redirect("/skills/marketplace");
}

View File

@@ -6,12 +6,14 @@ import {
Activity,
BarChart3,
Bot,
Boxes,
CheckCircle2,
Folder,
Building2,
LayoutGrid,
Network,
Settings,
Store,
Tags,
} from "lucide-react";
@@ -164,6 +166,43 @@ export function DashboardSidebar() {
</div>
</div>
<div>
{isAdmin ? (
<>
<p className="px-3 text-[11px] font-semibold uppercase tracking-wider text-slate-400">
Skills
</p>
<div className="mt-1 space-y-1">
<Link
href="/skills/marketplace"
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-slate-700 transition",
pathname === "/skills" ||
pathname.startsWith("/skills/marketplace")
? "bg-blue-100 text-blue-800 font-medium"
: "hover:bg-slate-100",
)}
>
<Store className="h-4 w-4" />
Marketplace
</Link>
<Link
href="/skills/packs"
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-slate-700 transition",
pathname.startsWith("/skills/packs")
? "bg-blue-100 text-blue-800 font-medium"
: "hover:bg-slate-100",
)}
>
<Boxes className="h-4 w-4" />
Packs
</Link>
</div>
</>
) : null}
</div>
<div>
<p className="px-3 text-[11px] font-semibold uppercase tracking-wider text-slate-400">
Administration

View File

@@ -8,12 +8,14 @@ import { clearLocalAuthToken, isLocalAuthMode } from "@/auth/localAuth";
import {
Activity,
Bot,
Boxes,
ChevronDown,
LayoutDashboard,
LogOut,
Plus,
Server,
Settings,
Store,
Trello,
} from "lucide-react";
@@ -155,6 +157,12 @@ export function UserMenu({
{ href: "/activity", label: "Activity", icon: Activity },
{ href: "/agents", label: "Agents", icon: Bot },
{ href: "/gateways", label: "Gateways", icon: Server },
{
href: "/skills/marketplace",
label: "Skills marketplace",
icon: Store,
},
{ href: "/skills/packs", label: "Skill packs", icon: Boxes },
{ href: "/settings", label: "Settings", icon: Settings },
] as const
).map((item) => (

View File

@@ -0,0 +1,225 @@
import { useState } from "react";
import { ApiError } from "@/api/mutator";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
type MarketplaceSkillFormValues = {
sourceUrl: string;
name: string;
description: string;
branch: string;
};
type MarketplaceSkillFormProps = {
initialValues?: MarketplaceSkillFormValues;
sourceUrlReadOnly?: boolean;
sourceUrlHelpText?: string;
sourceLabel?: string;
sourcePlaceholder?: string;
nameLabel?: string;
namePlaceholder?: string;
descriptionLabel?: string;
descriptionPlaceholder?: string;
branchLabel?: string;
branchPlaceholder?: string;
defaultBranch?: string;
requiredUrlMessage?: string;
invalidUrlMessage?: string;
submitLabel: string;
submittingLabel: string;
showBranch?: boolean;
isSubmitting: boolean;
onCancel: () => void;
onSubmit: (values: MarketplaceSkillFormValues) => Promise<void>;
};
const DEFAULT_VALUES: MarketplaceSkillFormValues = {
sourceUrl: "",
name: "",
description: "",
branch: "main",
};
const extractErrorMessage = (error: unknown, fallback: string) => {
if (error instanceof ApiError) return error.message || fallback;
if (error instanceof Error) return error.message || fallback;
return fallback;
};
export function MarketplaceSkillForm({
initialValues,
sourceUrlReadOnly = false,
sourceUrlHelpText,
sourceLabel = "Skill URL",
sourcePlaceholder = "https://github.com/org/skill-repo",
nameLabel = "Name (optional)",
namePlaceholder = "Deploy Helper",
descriptionLabel = "Description (optional)",
descriptionPlaceholder = "Short summary shown in the marketplace.",
branchLabel = "Branch (optional)",
branchPlaceholder = "main",
defaultBranch = "main",
showBranch = false,
requiredUrlMessage = "Skill URL is required.",
invalidUrlMessage = "Skill URL must be a GitHub repository URL (https://github.com/<owner>/<repo>).",
submitLabel,
submittingLabel,
isSubmitting,
onCancel,
onSubmit,
}: MarketplaceSkillFormProps) {
const resolvedInitial = initialValues ?? DEFAULT_VALUES;
const normalizedDefaultBranch = defaultBranch.trim() || "main";
const [sourceUrl, setSourceUrl] = useState(resolvedInitial.sourceUrl);
const [name, setName] = useState(resolvedInitial.name);
const [description, setDescription] = useState(resolvedInitial.description);
const [branch, setBranch] = useState(
resolvedInitial.branch?.trim() || normalizedDefaultBranch,
);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const isValidSourceUrl = (value: string) => {
try {
const parsed = new URL(value);
if (parsed.protocol !== "https:") return false;
if (parsed.hostname !== "github.com") return false;
const parts = parsed.pathname
.split("/")
.map((segment) => segment.trim())
.filter((segment) => segment.length > 0);
return parts.length >= 2;
} catch {
return false;
}
};
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const normalizedUrl = sourceUrl.trim();
if (!normalizedUrl) {
setErrorMessage(requiredUrlMessage);
return;
}
if (!isValidSourceUrl(normalizedUrl)) {
setErrorMessage(invalidUrlMessage);
return;
}
setErrorMessage(null);
try {
await onSubmit({
sourceUrl: normalizedUrl,
name: name.trim(),
description: description.trim(),
branch: branch.trim() || normalizedDefaultBranch,
});
} catch (error) {
setErrorMessage(extractErrorMessage(error, "Unable to save skill."));
}
};
return (
<form
onSubmit={handleSubmit}
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
>
<div className="space-y-5">
<div className="space-y-2">
<label
htmlFor="source-url"
className="text-xs font-semibold uppercase tracking-wider text-slate-500"
>
{sourceLabel}
</label>
<Input
id="source-url"
type="url"
value={sourceUrl}
onChange={(event) => setSourceUrl(event.target.value)}
placeholder={sourcePlaceholder}
readOnly={sourceUrlReadOnly}
disabled={isSubmitting || sourceUrlReadOnly}
/>
{sourceUrlHelpText ? (
<p className="text-xs text-slate-500">{sourceUrlHelpText}</p>
) : null}
</div>
{showBranch ? (
<div className="space-y-2">
<label
htmlFor="skill-branch"
className="text-xs font-semibold uppercase tracking-wider text-slate-500"
>
{branchLabel}
</label>
<Input
id="skill-branch"
value={branch}
onChange={(event) => setBranch(event.target.value)}
placeholder={branchPlaceholder}
disabled={isSubmitting}
/>
</div>
) : null}
<div className="space-y-2">
<label
htmlFor="skill-name"
className="text-xs font-semibold uppercase tracking-wider text-slate-500"
>
{nameLabel}
</label>
<Input
id="skill-name"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder={namePlaceholder}
disabled={isSubmitting}
/>
</div>
<div className="space-y-2">
<label
htmlFor="skill-description"
className="text-xs font-semibold uppercase tracking-wider text-slate-500"
>
{descriptionLabel}
</label>
<Textarea
id="skill-description"
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder={descriptionPlaceholder}
className="min-h-[120px]"
disabled={isSubmitting}
/>
</div>
{errorMessage ? (
<div className="rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700">
{errorMessage}
</div>
) : null}
</div>
<div className="flex justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? submittingLabel : submitLabel}
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,320 @@
import { useMemo } from "react";
import Link from "next/link";
import {
type ColumnDef,
type OnChangeFn,
type SortingState,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import type { MarketplaceSkillCardRead } from "@/api/generated/model";
import {
DataTable,
type DataTableEmptyState,
} from "@/components/tables/DataTable";
import { dateCell } from "@/components/tables/cell-formatters";
import { Button, buttonVariants } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
SKILLS_TABLE_EMPTY_ICON,
useTableSortingState,
} from "@/components/skills/table-helpers";
import { truncateText as truncate } from "@/lib/formatters";
import {
packLabelFromUrl,
packUrlFromSkillSourceUrl,
packsHrefFromPackUrl,
} from "@/lib/skills-source";
function riskBadgeVariant(risk: string | null | undefined) {
const normalizedRisk = (risk || "unknown").trim().toLowerCase();
switch (normalizedRisk) {
case "safe":
case "low":
return "success";
case "minimal":
case "medium":
case "moderate":
return "outline";
case "high":
case "critical":
return "danger";
case "elevated":
return "warning";
case "unknown":
return "outline";
default:
return "accent";
}
}
function riskPillClassName(risk: string | null | undefined) {
const normalizedRisk = (risk || "unknown").trim().toLowerCase();
switch (normalizedRisk) {
case "safe":
case "low":
return "bg-[color:rgba(16,185,129,0.16)] text-emerald-800 border border-emerald-200/70";
case "medium":
case "moderate":
return "bg-[color:rgba(245,158,11,0.16)] text-amber-800 border border-amber-200/70";
case "elevated":
return "bg-[color:rgba(245,158,11,0.16)] text-amber-800 border border-amber-200/70";
case "high":
case "critical":
return "bg-[color:rgba(244,63,94,0.16)] text-rose-800 border border-rose-200/70";
case "unknown":
return "bg-[color:rgba(148,163,184,0.16)] text-slate-700 border border-slate-200/80";
default:
return "bg-[color:rgba(99,102,241,0.16)] text-indigo-800 border border-indigo-200/70";
}
}
function riskBadgeLabel(risk: string | null | undefined) {
return (risk || "unknown").trim() || "unknown";
}
type MarketplaceSkillsTableProps = {
skills: MarketplaceSkillCardRead[];
installedGatewayNamesBySkillId?: Record<
string,
{ id: string; name: string }[]
>;
isLoading?: boolean;
sorting?: SortingState;
onSortingChange?: OnChangeFn<SortingState>;
stickyHeader?: boolean;
disableSorting?: boolean;
isMutating?: boolean;
onSkillClick?: (skill: MarketplaceSkillCardRead) => void;
onDelete?: (skill: MarketplaceSkillCardRead) => void;
getEditHref?: (skill: MarketplaceSkillCardRead) => string;
emptyState?: Omit<DataTableEmptyState, "icon"> & {
icon?: DataTableEmptyState["icon"];
};
};
export function MarketplaceSkillsTable({
skills,
installedGatewayNamesBySkillId,
isLoading = false,
sorting,
onSortingChange,
stickyHeader = false,
disableSorting = false,
isMutating = false,
onSkillClick,
onDelete,
getEditHref,
emptyState,
}: MarketplaceSkillsTableProps) {
const { resolvedSorting, handleSortingChange } = useTableSortingState(
sorting,
onSortingChange,
[{ id: "name", desc: false }],
);
const columns = useMemo<ColumnDef<MarketplaceSkillCardRead>[]>(() => {
const baseColumns: ColumnDef<MarketplaceSkillCardRead>[] = [
{
accessorKey: "name",
header: "Skill",
cell: ({ row }) => (
<div>
{onSkillClick ? (
<button
type="button"
onClick={() => onSkillClick(row.original)}
className="text-sm font-medium text-blue-700 hover:text-blue-600 hover:underline"
>
{row.original.name}
</button>
) : (
<p className="text-sm font-medium text-slate-900">
{row.original.name}
</p>
)}
<p
className="mt-1 line-clamp-2 text-xs text-slate-500"
title={row.original.description || "No description provided."}
>
{row.original.description || "No description provided."}
</p>
</div>
),
},
{
accessorKey: "source_url",
header: "Pack",
cell: ({ row }) => {
const packUrl = packUrlFromSkillSourceUrl(row.original.source_url);
return (
<Link
href={packsHrefFromPackUrl(packUrl)}
className="inline-flex items-center gap-1 text-sm font-medium text-slate-700 hover:text-blue-600"
>
{truncate(packLabelFromUrl(packUrl), 40)}
</Link>
);
},
},
{
accessorKey: "category",
header: "Category",
cell: ({ row }) => (
<span className="text-sm text-slate-700">
{row.original.category || "uncategorized"}
</span>
),
},
{
accessorKey: "risk",
header: "Risk",
cell: ({ row }) => (
<Badge
variant={riskBadgeVariant(row.original.risk)}
className={`px-2 py-0.5 ${riskPillClassName(row.original.risk)} font-semibold`}
>
{riskBadgeLabel(row.original.risk)}
</Badge>
),
},
{
accessorKey: "source",
header: "Source",
cell: ({ row }) => {
const sourceHref = row.original.source || row.original.source_url;
if (!sourceHref) {
return <span className="text-sm text-slate-400">No source</span>;
}
return (
<Link
href={sourceHref}
target="_blank"
rel="noreferrer"
className="text-sm font-medium text-slate-700 hover:text-blue-600 hover:underline"
title={sourceHref}
>
{truncate(sourceHref, 36)}
</Link>
);
},
},
{
id: "installed_on",
header: "Installed On",
enableSorting: false,
cell: ({ row }) => {
const installedOn =
installedGatewayNamesBySkillId?.[row.original.id] ?? [];
if (installedOn.length === 0) {
return <span className="text-sm text-slate-500">-</span>;
}
return (
<div className="flex flex-wrap gap-1">
{installedOn.map((gateway, index) => {
const isLast = index === installedOn.length - 1;
return (
<span
key={`${gateway.id}-${index}`}
className="inline-flex items-center gap-1 text-sm text-slate-700"
title={gateway.name}
>
<Link
href={`/gateways/${gateway.id}`}
className="text-blue-700 hover:text-blue-600 hover:underline"
>
{gateway.name}
</Link>
{!isLast ? "," : ""}
</span>
);
})}
</div>
);
},
},
{
accessorKey: "updated_at",
header: "Updated",
cell: ({ row }) => dateCell(row.original.updated_at),
},
{
id: "actions",
header: "",
enableSorting: false,
cell: ({ row }) => (
<div className="flex justify-end gap-2">
{getEditHref ? (
<Link
href={getEditHref(row.original)}
className={buttonVariants({ variant: "ghost", size: "sm" })}
>
Edit
</Link>
) : null}
{onDelete ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onDelete(row.original)}
disabled={isMutating}
>
Delete
</Button>
) : null}
</div>
),
},
];
return baseColumns;
}, [
getEditHref,
installedGatewayNamesBySkillId,
isMutating,
onDelete,
onSkillClick,
]);
// eslint-disable-next-line react-hooks/incompatible-library
const table = useReactTable({
data: skills,
columns,
enableSorting: !disableSorting,
state: {
...(!disableSorting ? { sorting: resolvedSorting } : {}),
},
...(disableSorting ? {} : { onSortingChange: handleSortingChange }),
getCoreRowModel: getCoreRowModel(),
...(disableSorting ? {} : { getSortedRowModel: getSortedRowModel() }),
});
return (
<DataTable
table={table}
isLoading={isLoading}
stickyHeader={stickyHeader}
rowClassName="transition hover:bg-slate-50"
cellClassName="px-6 py-4 align-top"
emptyState={
emptyState
? {
icon: emptyState.icon ?? SKILLS_TABLE_EMPTY_ICON,
title: emptyState.title,
description: emptyState.description,
actionHref: emptyState.actionHref,
actionLabel: emptyState.actionLabel,
}
: undefined
}
/>
);
}

View File

@@ -0,0 +1,116 @@
"use client";
import type { MarketplaceSkillCardRead } from "@/api/generated/model";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
type GatewaySummary = {
id: string;
name: string;
};
type SkillInstallDialogProps = {
selectedSkill: MarketplaceSkillCardRead | null;
gateways: GatewaySummary[];
gatewayInstalledById: Record<string, boolean>;
isGatewayStatusLoading: boolean;
installingGatewayId: string | null;
isMutating: boolean;
gatewayStatusError: string | null;
mutationError: string | null;
onOpenChange: (open: boolean) => void;
onToggleInstall: (gatewayId: string, isInstalled: boolean) => void;
};
export function SkillInstallDialog({
selectedSkill,
gateways,
gatewayInstalledById,
isGatewayStatusLoading,
installingGatewayId,
isMutating,
gatewayStatusError,
mutationError,
onOpenChange,
onToggleInstall,
}: SkillInstallDialogProps) {
return (
<Dialog open={Boolean(selectedSkill)} onOpenChange={onOpenChange}>
<DialogContent
aria-label="Install skill on gateways"
className="max-w-xl p-6 sm:p-7"
>
<DialogHeader className="pb-1">
<DialogTitle>
{selectedSkill ? selectedSkill.name : "Install skill"}
</DialogTitle>
<DialogDescription>
Choose one or more gateways where this skill should be installed.
</DialogDescription>
</DialogHeader>
<div className="mt-2 space-y-3.5">
{isGatewayStatusLoading ? (
<p className="text-sm text-slate-500">Loading gateways...</p>
) : (
gateways.map((gateway) => {
const isInstalled = gatewayInstalledById[gateway.id] === true;
const isUpdatingGateway =
installingGatewayId === gateway.id && isMutating;
return (
<div
key={gateway.id}
className="flex items-center justify-between rounded-xl border border-slate-200 bg-white p-4"
>
<div>
<p className="text-sm font-medium text-slate-900">
{gateway.name}
</p>
</div>
<Button
type="button"
size="sm"
variant={isInstalled ? "outline" : "primary"}
onClick={() => onToggleInstall(gateway.id, isInstalled)}
disabled={isMutating}
>
{isInstalled
? isUpdatingGateway
? "Uninstalling..."
: "Uninstall"
: isUpdatingGateway
? "Installing..."
: "Install"}
</Button>
</div>
);
})
)}
{gatewayStatusError ? (
<p className="text-sm text-rose-600">{gatewayStatusError}</p>
) : null}
{mutationError ? (
<p className="text-sm text-rose-600">{mutationError}</p>
) : null}
</div>
<DialogFooter className="mt-6 border-t border-slate-200 pt-4">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isMutating}
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,185 @@
import { useMemo } from "react";
import Link from "next/link";
import {
type ColumnDef,
type OnChangeFn,
type SortingState,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import type { SkillPackRead } from "@/api/generated/model";
import {
DataTable,
type DataTableEmptyState,
} from "@/components/tables/DataTable";
import { dateCell } from "@/components/tables/cell-formatters";
import { Button } from "@/components/ui/button";
import {
SKILLS_TABLE_EMPTY_ICON,
useTableSortingState,
} from "@/components/skills/table-helpers";
import { truncateText as truncate } from "@/lib/formatters";
type SkillPacksTableProps = {
packs: SkillPackRead[];
isLoading?: boolean;
sorting?: SortingState;
onSortingChange?: OnChangeFn<SortingState>;
stickyHeader?: boolean;
canSync?: boolean;
syncingPackIds?: Set<string>;
onSync?: (pack: SkillPackRead) => void;
onDelete?: (pack: SkillPackRead) => void;
getEditHref?: (pack: SkillPackRead) => string;
emptyState?: Omit<DataTableEmptyState, "icon"> & {
icon?: DataTableEmptyState["icon"];
};
};
export function SkillPacksTable({
packs,
isLoading = false,
sorting,
onSortingChange,
stickyHeader = false,
canSync = false,
syncingPackIds,
onSync,
onDelete,
getEditHref,
emptyState,
}: SkillPacksTableProps) {
const { resolvedSorting, handleSortingChange } = useTableSortingState(
sorting,
onSortingChange,
[{ id: "name", desc: false }],
);
const columns = useMemo<ColumnDef<SkillPackRead>[]>(() => {
const baseColumns: ColumnDef<SkillPackRead>[] = [
{
accessorKey: "name",
header: "Pack",
cell: ({ row }) => (
<div>
<p className="text-sm font-medium text-slate-900">
{row.original.name}
</p>
<p className="mt-1 line-clamp-2 text-xs text-slate-500">
{row.original.description || "No description provided."}
</p>
</div>
),
},
{
accessorKey: "source_url",
header: "Pack URL",
cell: ({ row }) => (
<Link
href={row.original.source_url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-sm font-medium text-slate-700 hover:text-blue-600"
>
{truncate(row.original.source_url, 48)}
</Link>
),
},
{
accessorKey: "branch",
header: "Branch",
cell: ({ row }) => (
<p className="text-sm text-slate-900">
{row.original.branch || "main"}
</p>
),
},
{
accessorKey: "skill_count",
header: "Skills",
cell: ({ row }) => (
<Link
href={`/skills/marketplace?packId=${encodeURIComponent(row.original.id)}`}
className="text-sm font-medium text-blue-700 hover:text-blue-600 hover:underline"
>
{row.original.skill_count ?? 0}
</Link>
),
},
{
accessorKey: "updated_at",
header: "Updated",
cell: ({ row }) => dateCell(row.original.updated_at),
},
{
id: "sync",
header: "",
enableSorting: false,
cell: ({ row }) => {
if (!onSync) return null;
const isThisPackSyncing = Boolean(
syncingPackIds?.has(row.original.id),
);
return (
<div className="flex justify-end">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => onSync(row.original)}
disabled={isThisPackSyncing || !canSync}
>
{isThisPackSyncing ? "Syncing..." : "Sync"}
</Button>
</div>
);
},
},
];
return baseColumns;
}, [canSync, onSync, syncingPackIds]);
// eslint-disable-next-line react-hooks/incompatible-library
const table = useReactTable({
data: packs,
columns,
state: {
sorting: resolvedSorting,
},
onSortingChange: handleSortingChange,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
});
return (
<DataTable
table={table}
isLoading={isLoading}
stickyHeader={stickyHeader}
rowClassName="transition hover:bg-slate-50"
cellClassName="px-6 py-4 align-top"
rowActions={
getEditHref || onDelete
? {
...(getEditHref ? { getEditHref } : {}),
...(onDelete ? { onDelete } : {}),
}
: undefined
}
emptyState={
emptyState
? {
icon: emptyState.icon ?? SKILLS_TABLE_EMPTY_ICON,
title: emptyState.title,
description: emptyState.description,
actionHref: emptyState.actionHref,
actionLabel: emptyState.actionLabel,
}
: undefined
}
/>
);
}

View File

@@ -0,0 +1,50 @@
"use client";
import { useState } from "react";
import {
type OnChangeFn,
type SortingState,
type Updater,
} from "@tanstack/react-table";
export const SKILLS_TABLE_EMPTY_ICON = (
<svg
className="h-16 w-16 text-slate-300"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4 7h16" />
<path d="M4 12h16" />
<path d="M4 17h16" />
<path d="M8 7v10" />
<path d="M16 7v10" />
</svg>
);
export const useTableSortingState = (
sorting: SortingState | undefined,
onSortingChange: OnChangeFn<SortingState> | undefined,
defaultSorting: SortingState,
): {
resolvedSorting: SortingState;
handleSortingChange: OnChangeFn<SortingState>;
} => {
const [internalSorting, setInternalSorting] =
useState<SortingState>(defaultSorting);
const resolvedSorting = sorting ?? internalSorting;
const handleSortingChange: OnChangeFn<SortingState> =
onSortingChange ??
((updater: Updater<SortingState>) => {
setInternalSorting(updater);
});
return {
resolvedSorting,
handleSortingChange,
};
};

View File

@@ -0,0 +1,50 @@
export const normalizeRepoSourceUrl = (sourceUrl: string): string => {
const trimmed = sourceUrl.trim().replace(/\/+$/, "");
return trimmed.endsWith(".git") ? trimmed.slice(0, -4) : trimmed;
};
export const repoBaseFromSkillSourceUrl = (
skillSourceUrl: string,
): string | null => {
try {
const parsed = new URL(skillSourceUrl);
const marker = "/tree/";
const markerIndex = parsed.pathname.indexOf(marker);
if (markerIndex <= 0) return null;
// Reject unexpected structures (e.g. multiple /tree/ markers).
if (parsed.pathname.indexOf(marker, markerIndex + marker.length) !== -1)
return null;
const repoPath = parsed.pathname.slice(0, markerIndex);
if (!repoPath || repoPath === "/") return null;
if (repoPath.endsWith("/tree")) return null;
return normalizeRepoSourceUrl(`${parsed.origin}${repoPath}`);
} catch {
return null;
}
};
export const packUrlFromSkillSourceUrl = (skillSourceUrl: string): string => {
const repoBase = repoBaseFromSkillSourceUrl(skillSourceUrl);
return repoBase ?? skillSourceUrl;
};
export const packLabelFromUrl = (packUrl: string): string => {
try {
const parsed = new URL(packUrl);
const segments = parsed.pathname.split("/").filter(Boolean);
if (segments.length >= 2) {
return `${segments[0]}/${segments[1]}`;
}
return parsed.host;
} catch {
return "Open pack";
}
};
export const packsHrefFromPackUrl = (packUrl: string): string => {
const params = new URLSearchParams({ source_url: packUrl });
return `/skills/packs?${params.toString()}`;
};