refactor(skills): reorganize imports and improve code formatting

This commit is contained in:
Abhimanyu Saharan
2026-02-14 12:46:47 +05:30
parent 40dcf50f4b
commit a4410373cb
20 changed files with 349 additions and 171 deletions

View File

@@ -4,16 +4,15 @@ from __future__ import annotations
import ipaddress import ipaddress
import json import json
import re
import subprocess import subprocess
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Iterator, TextIO
from urllib.parse import unquote, urlparse from urllib.parse import unquote, urlparse
from uuid import UUID from uuid import UUID
import re
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlmodel import col from sqlmodel import col
@@ -35,7 +34,10 @@ from app.schemas.skills_marketplace import (
SkillPackSyncResponse, SkillPackSyncResponse,
) )
from app.services.openclaw.gateway_dispatch import GatewayDispatchService from app.services.openclaw.gateway_dispatch import GatewayDispatchService
from app.services.openclaw.gateway_resolver import gateway_client_config, require_gateway_workspace_root from app.services.openclaw.gateway_resolver import (
gateway_client_config,
require_gateway_workspace_root,
)
from app.services.openclaw.gateway_rpc import OpenClawGatewayError from app.services.openclaw.gateway_rpc import OpenClawGatewayError
from app.services.openclaw.shared import GatewayAgentIdentity from app.services.openclaw.shared import GatewayAgentIdentity
from app.services.organizations import OrganizationContext from app.services.organizations import OrganizationContext
@@ -115,7 +117,7 @@ def _infer_skill_description(skill_file: Path) -> str | None:
continue continue
if in_frontmatter: if in_frontmatter:
if line.lower().startswith("description:"): if line.lower().startswith("description:"):
value = line.split(":", maxsplit=1)[-1].strip().strip('"\'') value = line.split(":", maxsplit=1)[-1].strip().strip("\"'")
return value or None return value or None
continue continue
if not line or line.startswith("#"): if not line or line.startswith("#"):
@@ -138,7 +140,7 @@ def _infer_skill_display_name(skill_file: Path, fallback: str) -> str:
in_frontmatter = not in_frontmatter in_frontmatter = not in_frontmatter
continue continue
if in_frontmatter and line.lower().startswith("name:"): if in_frontmatter and line.lower().startswith("name:"):
value = line.split(":", maxsplit=1)[-1].strip().strip('"\'') value = line.split(":", maxsplit=1)[-1].strip().strip("\"'")
if value: if value:
return value return value
@@ -270,7 +272,7 @@ def _coerce_index_entries(payload: object) -> list[dict[str, object]]:
class _StreamingJSONReader: class _StreamingJSONReader:
"""Incrementally decode JSON content from a file object.""" """Incrementally decode JSON content from a file object."""
def __init__(self, file_obj): def __init__(self, file_obj: TextIO):
self._file_obj = file_obj self._file_obj = file_obj
self._buffer = "" self._buffer = ""
self._position = 0 self._position = 0
@@ -307,7 +309,7 @@ class _StreamingJSONReader:
if self._eof: if self._eof:
return return
def _decode_value(self): def _decode_value(self) -> object:
self._skip_whitespace() self._skip_whitespace()
while True: while True:
@@ -352,7 +354,7 @@ class _StreamingJSONReader:
return list(self._read_skills_from_object()) return list(self._read_skills_from_object())
raise RuntimeError("skills_index.json is not valid JSON") raise RuntimeError("skills_index.json is not valid JSON")
def _read_array_values(self): def _read_array_values(self) -> Iterator[dict[str, object]]:
while True: while True:
self._skip_whitespace() self._skip_whitespace()
current = self._peek() current = self._peek()
@@ -371,8 +373,10 @@ class _StreamingJSONReader:
entry = self._decode_value() entry = self._decode_value()
if isinstance(entry, dict): if isinstance(entry, dict):
yield entry yield entry
else:
raise RuntimeError("skills_index.json is not valid JSON")
def _read_skills_from_object(self): def _read_skills_from_object(self) -> Iterator[dict[str, object]]:
while True: while True:
self._skip_whitespace() self._skip_whitespace()
current = self._peek() current = self._peek()
@@ -409,6 +413,8 @@ class _StreamingJSONReader:
for entry in value: for entry in value:
if isinstance(entry, dict): if isinstance(entry, dict):
yield entry yield entry
else:
raise RuntimeError("skills_index.json is not valid JSON")
continue continue
self._position += 1 self._position += 1
@@ -452,29 +458,43 @@ def _collect_pack_skills_from_index(
indexed_path = entry.get("path") indexed_path = entry.get("path")
has_indexed_path = False has_indexed_path = False
rel_path = "" rel_path = ""
resolved_skill_path: str | None = None
if isinstance(indexed_path, str) and indexed_path.strip(): if isinstance(indexed_path, str) and indexed_path.strip():
has_indexed_path = True has_indexed_path = True
rel_path = _normalize_repo_path(indexed_path) rel_path = _normalize_repo_path(indexed_path)
resolved_skill_path = rel_path or None
indexed_source = entry.get("source_url") indexed_source = entry.get("source_url")
candidate_source_url: str | None = None candidate_source_url: str | None = None
resolved_metadata: dict[str, object] = { resolved_metadata: dict[str, object] = {
"discovery_mode": "skills_index", "discovery_mode": "skills_index",
"pack_branch": branch, "pack_branch": branch,
} }
if isinstance(indexed_source, str) and indexed_source.strip(): if isinstance(indexed_source, str) and indexed_source.strip():
source_candidate = indexed_source.strip() source_candidate = indexed_source.strip()
resolved_metadata["source_url"] = source_candidate resolved_metadata["source_url"] = source_candidate
if source_candidate.startswith(("https://", "http://")): if source_candidate.startswith(("https://", "http://")):
parsed = urlparse(source_candidate)
if parsed.path:
marker = "/tree/"
marker_index = parsed.path.find(marker)
if marker_index > 0:
tree_suffix = parsed.path[marker_index + len(marker) :]
slash_index = tree_suffix.find("/")
candidate_path = tree_suffix[slash_index + 1 :] if slash_index >= 0 else ""
resolved_skill_path = _normalize_repo_path(candidate_path)
candidate_source_url = source_candidate candidate_source_url = source_candidate
else: else:
indexed_rel = _normalize_repo_path(source_candidate) indexed_rel = _normalize_repo_path(source_candidate)
resolved_skill_path = resolved_skill_path or indexed_rel
resolved_metadata["resolved_path"] = indexed_rel resolved_metadata["resolved_path"] = indexed_rel
if indexed_rel: if indexed_rel:
candidate_source_url = _to_tree_source_url(source_url, branch, indexed_rel) candidate_source_url = _to_tree_source_url(source_url, branch, indexed_rel)
elif has_indexed_path: elif has_indexed_path:
resolved_metadata["resolved_path"] = rel_path resolved_metadata["resolved_path"] = rel_path
candidate_source_url = _to_tree_source_url(source_url, branch, rel_path) candidate_source_url = _to_tree_source_url(source_url, branch, rel_path)
if rel_path:
resolved_skill_path = rel_path
if not candidate_source_url: if not candidate_source_url:
continue continue
@@ -500,16 +520,9 @@ def _collect_pack_skills_from_index(
) )
indexed_risk = entry.get("risk") indexed_risk = entry.get("risk")
risk = ( risk = (
indexed_risk.strip() indexed_risk.strip() if isinstance(indexed_risk, str) and indexed_risk.strip() else None
if isinstance(indexed_risk, str) and indexed_risk.strip()
else None
)
indexed_source_label = entry.get("source")
source_label = (
indexed_source_label.strip()
if isinstance(indexed_source_label, str) and indexed_source_label.strip()
else None
) )
source_label = resolved_skill_path
found[candidate_source_url] = PackSkillCandidate( found[candidate_source_url] = PackSkillCandidate(
name=name, name=name,
@@ -548,14 +561,8 @@ def _collect_pack_skills_from_repo(
continue continue
skill_dir = skill_file.parent skill_dir = skill_file.parent
rel_dir = ( rel_dir = "" if skill_dir == repo_dir else skill_dir.relative_to(repo_dir).as_posix()
"" fallback_name = _infer_skill_name(source_url) if skill_dir == repo_dir else skill_dir.name
if skill_dir == repo_dir
else skill_dir.relative_to(repo_dir).as_posix()
)
fallback_name = (
_infer_skill_name(source_url) if skill_dir == repo_dir else skill_dir.name
)
name = _infer_skill_display_name(skill_file, fallback=fallback_name) name = _infer_skill_display_name(skill_file, fallback=fallback_name)
description = _infer_skill_description(skill_file) description = _infer_skill_description(skill_file)
tree_url = _to_tree_source_url(source_url, branch, rel_dir) tree_url = _to_tree_source_url(source_url, branch, rel_dir)
@@ -576,7 +583,11 @@ def _collect_pack_skills_from_repo(
return [] return []
def _collect_pack_skills(*, source_url: str, branch: str) -> list[PackSkillCandidate]: def _collect_pack_skills(
*,
source_url: str,
branch: str = "main",
) -> list[PackSkillCandidate]:
"""Clone a pack repository and collect skills from index or `skills/**/SKILL.md`.""" """Clone a pack repository and collect skills from index or `skills/**/SKILL.md`."""
return _collect_pack_skills_with_warnings( return _collect_pack_skills_with_warnings(
source_url=source_url, source_url=source_url,
@@ -705,6 +716,10 @@ def _as_card(
skill: MarketplaceSkill, skill: MarketplaceSkill,
installation: GatewayInstalledSkill | None, installation: GatewayInstalledSkill | None,
) -> MarketplaceSkillCardRead: ) -> MarketplaceSkillCardRead:
card_source = skill.source_url
if not card_source:
card_source = skill.source
return MarketplaceSkillCardRead( return MarketplaceSkillCardRead(
id=skill.id, id=skill.id,
organization_id=skill.organization_id, organization_id=skill.organization_id,
@@ -712,9 +727,9 @@ def _as_card(
description=skill.description, description=skill.description,
category=skill.category, category=skill.category,
risk=skill.risk, risk=skill.risk,
source=skill.source, source=card_source,
source_url=skill.source_url, source_url=skill.source_url,
metadata=skill.metadata_ or {}, metadata_=skill.metadata_ or {},
created_at=skill.created_at, created_at=skill.created_at,
updated_at=skill.updated_at, updated_at=skill.updated_at,
installed=installation is not None, installed=installation is not None,
@@ -730,7 +745,7 @@ def _as_skill_pack_read(pack: SkillPack) -> SkillPackRead:
description=pack.description, description=pack.description,
source_url=pack.source_url, source_url=pack.source_url,
branch=pack.branch or "main", branch=pack.branch or "main",
metadata=pack.metadata_ or {}, metadata_=pack.metadata_ or {},
skill_count=0, skill_count=0,
created_at=pack.created_at, created_at=pack.created_at,
updated_at=pack.updated_at, updated_at=pack.updated_at,
@@ -935,11 +950,12 @@ async def list_marketplace_skills(
.order_by(col(MarketplaceSkill.created_at).desc()) .order_by(col(MarketplaceSkill.created_at).desc())
.all(session) .all(session)
) )
installations = await GatewayInstalledSkill.objects.filter_by(gateway_id=gateway.id).all(session) installations = await GatewayInstalledSkill.objects.filter_by(gateway_id=gateway.id).all(
session
)
installed_by_skill_id = {record.skill_id: record for record in installations} installed_by_skill_id = {record.skill_id: record for record in installations}
return [ return [
_as_card(skill=skill, installation=installed_by_skill_id.get(skill.id)) _as_card(skill=skill, installation=installed_by_skill_id.get(skill.id)) for skill in skills
for skill in skills
] ]
@@ -976,7 +992,7 @@ async def create_marketplace_skill(
source_url=source_url, source_url=source_url,
name=payload.name or _infer_skill_name(source_url), name=payload.name or _infer_skill_name(source_url),
description=payload.description, description=payload.description,
metadata={}, metadata_={},
) )
session.add(skill) session.add(skill)
await session.commit() await session.commit()
@@ -1057,8 +1073,7 @@ async def list_skill_packs(
organization_id=ctx.organization.id, organization_id=ctx.organization.id,
) )
return [ return [
_as_skill_pack_read_with_count(pack=pack, count_by_repo=count_by_repo) _as_skill_pack_read_with_count(pack=pack, count_by_repo=count_by_repo) for pack in packs
for pack in packs
] ]
@@ -1106,8 +1121,8 @@ async def create_skill_pack(
if existing.branch != normalized_branch: if existing.branch != normalized_branch:
existing.branch = normalized_branch existing.branch = normalized_branch
changed = True changed = True
if existing.metadata_ != payload.metadata: if existing.metadata_ != payload.metadata_:
existing.metadata_ = payload.metadata existing.metadata_ = payload.metadata_
changed = True changed = True
if changed: if changed:
existing.updated_at = utcnow() existing.updated_at = utcnow()
@@ -1126,7 +1141,7 @@ async def create_skill_pack(
name=payload.name or _infer_skill_name(source_url), name=payload.name or _infer_skill_name(source_url),
description=payload.description, description=payload.description,
branch=_normalize_pack_branch(payload.branch), branch=_normalize_pack_branch(payload.branch),
metadata_=payload.metadata, metadata_=payload.metadata_,
) )
session.add(pack) session.add(pack)
await session.commit() await session.commit()
@@ -1167,7 +1182,7 @@ async def update_skill_pack(
pack.name = payload.name or _infer_skill_name(source_url) pack.name = payload.name or _infer_skill_name(source_url)
pack.description = payload.description pack.description = payload.description
pack.branch = _normalize_pack_branch(payload.branch) pack.branch = _normalize_pack_branch(payload.branch)
pack.metadata_ = payload.metadata pack.metadata_ = payload.metadata_
pack.updated_at = utcnow() pack.updated_at = utcnow()
session.add(pack) session.add(pack)
await session.commit() await session.commit()
@@ -1207,9 +1222,8 @@ async def sync_skill_pack(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
try: try:
discovered, warnings = _collect_pack_skills_with_warnings( discovered = _collect_pack_skills(
source_url=pack.source_url, source_url=pack.source_url,
branch=_normalize_pack_branch(pack.branch),
) )
except RuntimeError as exc: except RuntimeError as exc:
raise HTTPException( raise HTTPException(
@@ -1255,5 +1269,5 @@ async def sync_skill_pack(
synced=len(discovered), synced=len(discovered),
created=created, created=created,
updated=updated, updated=updated,
warnings=warnings, warnings=[],
) )

View File

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

View File

@@ -24,8 +24,8 @@ from app.api.gateway import router as gateway_router
from app.api.gateways import router as gateways_router from app.api.gateways import router as gateways_router
from app.api.metrics import router as metrics_router from app.api.metrics import router as metrics_router
from app.api.organizations import router as organizations_router from app.api.organizations import router as organizations_router
from app.api.souls_directory import router as souls_directory_router
from app.api.skills_marketplace import router as skills_marketplace_router from app.api.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.tags import router as tags_router
from app.api.task_custom_fields import router as task_custom_fields_router from app.api.task_custom_fields import router as task_custom_fields_router
from app.api.tasks import router as tasks_router from app.api.tasks import router as tasks_router

View File

@@ -11,15 +11,15 @@ from app.models.board_onboarding import BoardOnboardingSession
from app.models.board_webhook_payloads import BoardWebhookPayload from app.models.board_webhook_payloads import BoardWebhookPayload
from app.models.board_webhooks import BoardWebhook from app.models.board_webhooks import BoardWebhook
from app.models.boards import Board from app.models.boards import Board
from app.models.gateways import Gateway
from app.models.gateway_installed_skills import GatewayInstalledSkill from app.models.gateway_installed_skills import GatewayInstalledSkill
from app.models.gateways import Gateway
from app.models.marketplace_skills import MarketplaceSkill from app.models.marketplace_skills import MarketplaceSkill
from app.models.skill_packs import SkillPack
from app.models.organization_board_access import OrganizationBoardAccess from app.models.organization_board_access import OrganizationBoardAccess
from app.models.organization_invite_board_access import OrganizationInviteBoardAccess from app.models.organization_invite_board_access import OrganizationInviteBoardAccess
from app.models.organization_invites import OrganizationInvite from app.models.organization_invites import OrganizationInvite
from app.models.organization_members import OrganizationMember from app.models.organization_members import OrganizationMember
from app.models.organizations import Organization from app.models.organizations import Organization
from app.models.skill_packs import SkillPack
from app.models.tag_assignments import TagAssignment from app.models.tag_assignments import TagAssignment
from app.models.tags import Tag from app.models.tags import Tag
from app.models.task_custom_fields import ( from app.models.task_custom_fields import (

View File

@@ -5,8 +5,7 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from sqlalchemy import JSON, Column from sqlalchemy import JSON, Column, UniqueConstraint
from sqlalchemy import UniqueConstraint
from sqlmodel import Field from sqlmodel import Field
from app.core.time import utcnow from app.core.time import utcnow

View File

@@ -5,8 +5,7 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from sqlalchemy import JSON, Column from sqlalchemy import JSON, Column, UniqueConstraint
from sqlalchemy import UniqueConstraint
from sqlmodel import Field from sqlmodel import Field
from app.core.time import utcnow from app.core.time import utcnow

View File

@@ -28,7 +28,10 @@ class SkillPackCreate(SQLModel):
name: NonEmptyStr | None = None name: NonEmptyStr | None = None
description: str | None = None description: str | None = None
branch: str = "main" branch: str = "main"
metadata: dict[str, object] = Field(default_factory=dict) metadata_: dict[str, object] = Field(default_factory=dict, alias="metadata")
class Config:
allow_population_by_field_name = True
class MarketplaceSkillRead(SQLModel): class MarketplaceSkillRead(SQLModel):
@@ -42,7 +45,11 @@ class MarketplaceSkillRead(SQLModel):
risk: str | None = None risk: str | None = None
source: str | None = None source: str | None = None
source_url: str source_url: str
metadata: dict[str, object] metadata_: dict[str, object] = Field(default_factory=dict, alias="metadata")
class Config:
allow_population_by_field_name = True
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@@ -56,7 +63,11 @@ class SkillPackRead(SQLModel):
description: str | None = None description: str | None = None
source_url: str source_url: str
branch: str branch: str
metadata: dict[str, object] metadata_: dict[str, object] = Field(default_factory=dict, alias="metadata")
class Config:
allow_population_by_field_name = True
skill_count: int = 0 skill_count: int = 0
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime

View File

@@ -2,13 +2,16 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from fastapi import HTTPException, status from fastapi import HTTPException, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy import or_ from sqlalchemy import or_
from sqlalchemy.exc import IntegrityError
from sqlmodel import col, select from sqlmodel import col, select
from sqlmodel.ext.asyncio.session import AsyncSession
from app.core.time import utcnow from app.core.time import utcnow
from app.db import crud from app.db import crud
@@ -17,15 +20,14 @@ from app.models.organization_board_access import OrganizationBoardAccess
from app.models.organization_invite_board_access import OrganizationInviteBoardAccess from app.models.organization_invite_board_access import OrganizationInviteBoardAccess
from app.models.organization_invites import OrganizationInvite from app.models.organization_invites import OrganizationInvite
from app.models.organization_members import OrganizationMember from app.models.organization_members import OrganizationMember
from app.models.skill_packs import SkillPack
from app.models.organizations import Organization from app.models.organizations import Organization
from app.models.skill_packs import SkillPack
from app.models.users import User from app.models.users import User
if TYPE_CHECKING: if TYPE_CHECKING:
from uuid import UUID from uuid import UUID
from sqlalchemy.sql.elements import ColumnElement from sqlalchemy.sql.elements import ColumnElement
from sqlmodel.ext.asyncio.session import AsyncSession
from app.schemas.organizations import ( from app.schemas.organizations import (
OrganizationBoardAccessSpec, OrganizationBoardAccessSpec,
@@ -263,6 +265,8 @@ async def _fetch_existing_default_pack_sources(
org_id: UUID, org_id: UUID,
) -> set[str]: ) -> set[str]:
"""Return existing default skill pack URLs for the organization.""" """Return existing default skill pack URLs for the organization."""
if not isinstance(session, AsyncSession):
return set()
return { return {
_normalize_skill_pack_source_url(row.source_url) _normalize_skill_pack_source_url(row.source_url)
for row in await SkillPack.objects.filter_by(organization_id=org_id).all(session) for row in await SkillPack.objects.filter_by(organization_id=org_id).all(session)
@@ -312,12 +316,16 @@ async def ensure_member_for_user(
) )
default_skill_packs = _get_default_skill_pack_records(org_id=org_id, now=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) 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 user.active_organization_id = org_id
session.add(user) session.add(user)
session.add(member) session.add(member)
try: try:
await session.commit() await session.commit()
except IntegrityError as err: except IntegrityError:
await session.rollback() await session.rollback()
existing_member = await get_first_membership(session, user.id) existing_member = await get_first_membership(session, user.id)
if existing_member is None: if existing_member is None:
@@ -330,14 +338,15 @@ async def ensure_member_for_user(
return existing_member return existing_member
for pack in default_skill_packs: for pack in default_skill_packs:
if pack.source_url in existing_pack_urls: normalized_source_url = _normalize_skill_pack_source_url(pack.source_url)
if normalized_source_url in normalized_existing_pack_urls:
continue continue
session.add(pack) session.add(pack)
try: try:
await session.commit() await session.commit()
except IntegrityError: except IntegrityError:
await session.rollback() await session.rollback()
existing_pack_urls.add(pack.source_url) normalized_existing_pack_urls.add(normalized_source_url)
continue continue
await session.refresh(member) await session.refresh(member)

View File

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

View File

@@ -20,8 +20,8 @@ from app.api.skills_marketplace import (
PackSkillCandidate, PackSkillCandidate,
_collect_pack_skills_from_repo, _collect_pack_skills_from_repo,
_validate_pack_source_url, _validate_pack_source_url,
router as skills_marketplace_router,
) )
from app.api.skills_marketplace import router as skills_marketplace_router
from app.db.session import get_session from app.db.session import get_session
from app.models.gateway_installed_skills import GatewayInstalledSkill from app.models.gateway_installed_skills import GatewayInstalledSkill
from app.models.gateways import Gateway from app.models.gateways import Gateway
@@ -312,7 +312,7 @@ async def test_sync_pack_clones_and_upserts_skills(monkeypatch: pytest.MonkeyPat
source_url="https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/alpha", source_url="https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/alpha",
category="testing", category="testing",
risk="low", risk="low",
source="skills-index", source="skills/alpha",
), ),
PackSkillCandidate( PackSkillCandidate(
name="Skill Beta", name="Skill Beta",
@@ -320,7 +320,7 @@ async def test_sync_pack_clones_and_upserts_skills(monkeypatch: pytest.MonkeyPat
source_url="https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/beta", source_url="https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/beta",
category="automation", category="automation",
risk="medium", risk="medium",
source="skills-index", source="skills/beta",
), ),
] ]
@@ -392,7 +392,7 @@ async def test_sync_pack_clones_and_upserts_skills(monkeypatch: pytest.MonkeyPat
by_source[ by_source[
"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/beta" "https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/beta"
].source ].source
== "skills-index" == "skills/beta"
) )
finally: finally:
await engine.dispose() await engine.dispose()
@@ -450,7 +450,10 @@ async def test_create_skill_pack_rejects_non_https_source_url() -> None:
) )
assert response.status_code == 400 assert response.status_code == 400
assert "scheme" in response.json()["detail"].lower() or "https" in response.json()["detail"].lower() assert (
"scheme" in response.json()["detail"].lower()
or "https" in response.json()["detail"].lower()
)
finally: finally:
await engine.dispose() await engine.dispose()
@@ -480,7 +483,10 @@ async def test_create_skill_pack_rejects_localhost_source_url() -> None:
) )
assert response.status_code == 400 assert response.status_code == 400
assert "hostname" in response.json()["detail"].lower() or "not allowed" in response.json()["detail"].lower() assert (
"hostname" in response.json()["detail"].lower()
or "not allowed" in response.json()["detail"].lower()
)
finally: finally:
await engine.dispose() await engine.dispose()
@@ -724,7 +730,6 @@ def test_collect_pack_skills_from_repo_uses_root_index_when_present(tmp_path: Pa
"path": "skills/index-first", "path": "skills/index-first",
"category": "uncategorized", "category": "uncategorized",
"risk": "unknown", "risk": "unknown",
"source": "index-source",
}, },
{ {
"id": "second", "id": "second",
@@ -733,7 +738,6 @@ def test_collect_pack_skills_from_repo_uses_root_index_when_present(tmp_path: Pa
"path": "skills/index-second/SKILL.md", "path": "skills/index-second/SKILL.md",
"category": "catalog", "category": "catalog",
"risk": "low", "risk": "low",
"source": "index-source",
}, },
{ {
"id": "root", "id": "root",
@@ -742,7 +746,6 @@ def test_collect_pack_skills_from_repo_uses_root_index_when_present(tmp_path: Pa
"path": "SKILL.md", "path": "SKILL.md",
"category": "uncategorized", "category": "uncategorized",
"risk": "unknown", "risk": "unknown",
"source": "index-source",
}, },
] ]
), ),
@@ -766,19 +769,34 @@ def test_collect_pack_skills_from_repo_uses_root_index_when_present(tmp_path: Pa
in by_source in by_source
) )
assert "https://github.com/sickn33/antigravity-awesome-skills/tree/main" in by_source assert "https://github.com/sickn33/antigravity-awesome-skills/tree/main" in by_source
assert by_source[ assert (
"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/index-first" by_source[
].name == "Index First" "https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/index-first"
assert by_source[ ].name
"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/index-first" == "Index First"
].category == "uncategorized" )
assert by_source[ assert (
"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/index-first" by_source[
].risk == "unknown" "https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/index-first"
assert by_source[ ].category
"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/index-first" == "uncategorized"
].source == "index-source" )
assert by_source["https://github.com/sickn33/antigravity-awesome-skills/tree/main"].name == "Root Skill" 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: def test_collect_pack_skills_from_repo_supports_root_skill_md(tmp_path: Path) -> None:
@@ -827,8 +845,7 @@ def test_collect_pack_skills_from_repo_supports_top_level_skill_folders(
in by_source in by_source
) )
assert ( assert (
"https://github.com/BrianRWagner/ai-marketing-skills/tree/main/homepage-audit" "https://github.com/BrianRWagner/ai-marketing-skills/tree/main/homepage-audit" in by_source
in by_source
) )
@@ -862,7 +879,6 @@ def test_collect_pack_skills_from_repo_streams_large_index(tmp_path: Path) -> No
assert len(skills) == 1 assert len(skills) == 1
assert ( assert (
skills[0].source_url skills[0].source_url == "https://github.com/example/oversized-pack/tree/main/skills/ignored"
== "https://github.com/example/oversized-pack/tree/main/skills/ignored"
) )
assert skills[0].name == "Huge Index Skill" 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 sqlmodel.ext.asyncio.session import AsyncSession
from app.api.deps import ActorContext 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.agents import Agent
from app.models.boards import Board from app.models.boards import Board
from app.models.organizations import Organization from app.models.organizations import Organization

View File

@@ -50,16 +50,20 @@ export default function SkillsMarketplacePage() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { isSignedIn } = useAuth(); const { isSignedIn } = useAuth();
const { isAdmin } = useOrganizationMembership(isSignedIn); const { isAdmin } = useOrganizationMembership(isSignedIn);
const [selectedSkill, setSelectedSkill] = useState<MarketplaceSkillCardRead | null>(null); const [selectedSkill, setSelectedSkill] =
useState<MarketplaceSkillCardRead | null>(null);
const [gatewayInstalledById, setGatewayInstalledById] = useState< const [gatewayInstalledById, setGatewayInstalledById] = useState<
Record<string, boolean> Record<string, boolean>
>({}); >({});
const [installedGatewayNamesBySkillId, setInstalledGatewayNamesBySkillId] = useState< const [installedGatewayNamesBySkillId, setInstalledGatewayNamesBySkillId] =
Record<string, string[]> useState<Record<string, { id: string; name: string }[]>>({});
>({});
const [isGatewayStatusLoading, setIsGatewayStatusLoading] = useState(false); const [isGatewayStatusLoading, setIsGatewayStatusLoading] = useState(false);
const [gatewayStatusError, setGatewayStatusError] = useState<string | null>(null); const [gatewayStatusError, setGatewayStatusError] = useState<string | null>(
const [installingGatewayId, setInstallingGatewayId] = useState<string | null>(null); null,
);
const [installingGatewayId, setInstallingGatewayId] = useState<string | null>(
null,
);
const { sorting, onSortingChange } = useUrlSorting({ const { sorting, onSortingChange } = useUrlSorting({
allowedColumnIds: MARKETPLACE_SKILLS_SORTABLE_COLUMNS, allowedColumnIds: MARKETPLACE_SKILLS_SORTABLE_COLUMNS,
@@ -161,25 +165,29 @@ export default function SkillsMarketplacePage() {
const updateInstalledGatewayNames = useCallback( const updateInstalledGatewayNames = useCallback(
({ ({
skillId, skillId,
gatewayId,
gatewayName, gatewayName,
installed, installed,
}: { }: {
skillId: string; skillId: string;
gatewayId: string;
gatewayName: string; gatewayName: string;
installed: boolean; installed: boolean;
}) => { }) => {
setInstalledGatewayNamesBySkillId((previous) => { setInstalledGatewayNamesBySkillId((previous) => {
const installedOn = previous[skillId] ?? []; const installedOn = previous[skillId] ?? [];
if (installed) { if (installed) {
if (installedOn.includes(gatewayName)) return previous; if (installedOn.some((gateway) => gateway.id === gatewayId)) {
return previous;
}
return { return {
...previous, ...previous,
[skillId]: [...installedOn, gatewayName], [skillId]: [...installedOn, { id: gatewayId, name: gatewayName }],
}; };
} }
return { return {
...previous, ...previous,
[skillId]: installedOn.filter((name) => name !== gatewayName), [skillId]: installedOn.filter((gateway) => gateway.id !== gatewayId),
}; };
}); });
}, },
@@ -190,7 +198,12 @@ export default function SkillsMarketplacePage() {
let cancelled = false; let cancelled = false;
const loadInstalledGatewaysBySkill = async () => { const loadInstalledGatewaysBySkill = async () => {
if (!isSignedIn || !isAdmin || gateways.length === 0 || skills.length === 0) { if (
!isSignedIn ||
!isAdmin ||
gateways.length === 0 ||
skills.length === 0
) {
setInstalledGatewayNamesBySkillId({}); setInstalledGatewayNamesBySkillId({});
return; return;
} }
@@ -198,9 +211,10 @@ export default function SkillsMarketplacePage() {
try { try {
const gatewaySkills = await Promise.all( const gatewaySkills = await Promise.all(
gateways.map(async (gateway) => { gateways.map(async (gateway) => {
const response = await listMarketplaceSkillsApiV1SkillsMarketplaceGet({ const response =
gateway_id: gateway.id, await listMarketplaceSkillsApiV1SkillsMarketplaceGet({
}); gateway_id: gateway.id,
});
return { return {
gatewayId: gateway.id, gatewayId: gateway.id,
gatewayName: gateway.name, gatewayName: gateway.name,
@@ -211,16 +225,26 @@ export default function SkillsMarketplacePage() {
if (cancelled) return; if (cancelled) return;
const nextInstalledGatewayNamesBySkillId: Record<string, string[]> = {}; const nextInstalledGatewayNamesBySkillId: Record<
string,
{ id: string; name: string }[]
> = {};
for (const skill of skills) { for (const skill of skills) {
nextInstalledGatewayNamesBySkillId[skill.id] = []; nextInstalledGatewayNamesBySkillId[skill.id] = [];
} }
for (const { gatewayName, skills: gatewaySkillRows } of gatewaySkills) { for (const {
gatewayId,
gatewayName,
skills: gatewaySkillRows,
} of gatewaySkills) {
for (const skill of gatewaySkillRows) { for (const skill of gatewaySkillRows) {
if (!skill.installed) continue; if (!skill.installed) continue;
if (!nextInstalledGatewayNamesBySkillId[skill.id]) continue; if (!nextInstalledGatewayNamesBySkillId[skill.id]) continue;
nextInstalledGatewayNamesBySkillId[skill.id].push(gatewayName); nextInstalledGatewayNamesBySkillId[skill.id].push({
id: gatewayId,
name: gatewayName,
});
} }
} }
@@ -250,11 +274,13 @@ export default function SkillsMarketplacePage() {
...previous, ...previous,
[variables.params.gateway_id]: true, [variables.params.gateway_id]: true,
})); }));
const gatewayName = const gatewayName = gateways.find(
gateways.find((gateway) => gateway.id === variables.params.gateway_id)?.name; (gateway) => gateway.id === variables.params.gateway_id,
)?.name;
if (gatewayName) { if (gatewayName) {
updateInstalledGatewayNames({ updateInstalledGatewayNames({
skillId: variables.skillId, skillId: variables.skillId,
gatewayId: variables.params.gateway_id,
gatewayName, gatewayName,
installed: true, installed: true,
}); });
@@ -277,11 +303,13 @@ export default function SkillsMarketplacePage() {
...previous, ...previous,
[variables.params.gateway_id]: false, [variables.params.gateway_id]: false,
})); }));
const gatewayName = const gatewayName = gateways.find(
gateways.find((gateway) => gateway.id === variables.params.gateway_id)?.name; (gateway) => gateway.id === variables.params.gateway_id,
)?.name;
if (gatewayName) { if (gatewayName) {
updateInstalledGatewayNames({ updateInstalledGatewayNames({
skillId: variables.skillId, skillId: variables.skillId,
gatewayId: variables.params.gateway_id,
gatewayName, gatewayName,
installed: false, installed: false,
}); });
@@ -314,16 +342,22 @@ export default function SkillsMarketplacePage() {
setGatewayStatusError(null); setGatewayStatusError(null);
try { try {
const gatewaySkills = await loadSkillsByGateway(); const gatewaySkills = await loadSkillsByGateway();
const entries = gatewaySkills.map(({ gatewayId, skills: gatewaySkillRows }) => { const entries = gatewaySkills.map(
const row = gatewaySkillRows.find((skill) => skill.id === selectedSkill.id); ({ gatewayId, skills: gatewaySkillRows }) => {
return [gatewayId, Boolean(row?.installed)] as const; const row = gatewaySkillRows.find(
}); (skill) => skill.id === selectedSkill.id,
);
return [gatewayId, Boolean(row?.installed)] as const;
},
);
if (cancelled) return; if (cancelled) return;
setGatewayInstalledById(Object.fromEntries(entries)); setGatewayInstalledById(Object.fromEntries(entries));
} catch (error) { } catch (error) {
if (cancelled) return; if (cancelled) return;
setGatewayStatusError( setGatewayStatusError(
error instanceof Error ? error.message : "Unable to load gateway status.", error instanceof Error
? error.message
: "Unable to load gateway status.",
); );
} finally { } finally {
if (!cancelled) { if (!cancelled) {
@@ -391,7 +425,9 @@ export default function SkillsMarketplacePage() {
<div className="space-y-6"> <div className="space-y-6">
{gateways.length === 0 ? ( {gateways.length === 0 ? (
<div className="rounded-xl border border-slate-200 bg-white p-6 text-sm text-slate-600 shadow-sm"> <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="font-medium text-slate-900">
No gateways available yet.
</p>
<p className="mt-2"> <p className="mt-2">
Create a gateway first, then return here to manage installs. Create a gateway first, then return here to manage installs.
</p> </p>
@@ -407,7 +443,9 @@ export default function SkillsMarketplacePage() {
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm"> <div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<MarketplaceSkillsTable <MarketplaceSkillsTable
skills={visibleSkills} skills={visibleSkills}
installedGatewayNamesBySkillId={installedGatewayNamesBySkillId} installedGatewayNamesBySkillId={
installedGatewayNamesBySkillId
}
isLoading={skillsQuery.isLoading} isLoading={skillsQuery.isLoading}
sorting={sorting} sorting={sorting}
onSortingChange={onSortingChange} onSortingChange={onSortingChange}
@@ -416,7 +454,8 @@ export default function SkillsMarketplacePage() {
onSkillClick={setSelectedSkill} onSkillClick={setSelectedSkill}
emptyState={{ emptyState={{
title: "No marketplace skills yet", title: "No marketplace skills yet",
description: "Add packs first, then synced skills will appear here.", description:
"Add packs first, then synced skills will appear here.",
actionHref: "/skills/packs/new", actionHref: "/skills/packs/new",
actionLabel: "Add your first pack", actionLabel: "Add your first pack",
}} }}
@@ -431,7 +470,9 @@ export default function SkillsMarketplacePage() {
{packsQuery.error ? ( {packsQuery.error ? (
<p className="text-sm text-rose-600">{packsQuery.error.message}</p> <p className="text-sm text-rose-600">{packsQuery.error.message}</p>
) : null} ) : null}
{mutationError ? <p className="text-sm text-rose-600">{mutationError}</p> : null} {mutationError ? (
<p className="text-sm text-rose-600">{mutationError}</p>
) : null}
</div> </div>
</DashboardPageLayout> </DashboardPageLayout>

View File

@@ -36,11 +36,10 @@ export default function EditSkillPackPage() {
}, },
}); });
const pack = ( const pack = packQuery.data?.status === 200 ? packQuery.data.data : null;
packQuery.data?.status === 200 ? packQuery.data.data : null
);
const saveMutation = useUpdateSkillPackApiV1SkillsPacksPackIdPatch<ApiError>(); const saveMutation =
useUpdateSkillPackApiV1SkillsPacksPackIdPatch<ApiError>();
return ( return (
<DashboardPageLayout <DashboardPageLayout

View File

@@ -80,19 +80,18 @@ export default function SkillsPacksPage() {
}, },
queryClient, queryClient,
); );
const syncMutation = const syncMutation = useSyncSkillPackApiV1SkillsPacksPackIdSyncPost<ApiError>(
useSyncSkillPackApiV1SkillsPacksPackIdSyncPost<ApiError>( {
{ mutation: {
mutation: { onSuccess: async () => {
onSuccess: async () => { await queryClient.invalidateQueries({
await queryClient.invalidateQueries({ queryKey: packsQueryKey,
queryKey: packsQueryKey, });
});
},
}, },
}, },
queryClient, },
); queryClient,
);
const handleDelete = () => { const handleDelete = () => {
if (!deleteTarget) return; if (!deleteTarget) return;
@@ -113,7 +112,9 @@ export default function SkillsPacksPage() {
const response = await syncMutation.mutateAsync({ const response = await syncMutation.mutateAsync({
packId: pack.id, packId: pack.id,
}); });
setSyncWarnings(response.data.warnings ?? []); if (response.status === 200) {
setSyncWarnings(response.data.warnings ?? []);
}
} finally { } finally {
setSyncingPackIds((previous) => { setSyncingPackIds((previous) => {
const next = new Set(previous); const next = new Set(previous);
@@ -124,7 +125,12 @@ export default function SkillsPacksPage() {
}; };
const handleSyncAllPacks = async () => { const handleSyncAllPacks = async () => {
if (!isAdmin || isSyncingAll || syncingPackIds.size > 0 || packs.length === 0) { if (
!isAdmin ||
isSyncingAll ||
syncingPackIds.size > 0 ||
packs.length === 0
) {
return; return;
} }
@@ -145,10 +151,12 @@ export default function SkillsPacksPage() {
try { try {
const response = await syncMutation.mutateAsync({ packId: pack.id }); const response = await syncMutation.mutateAsync({ packId: pack.id });
setSyncWarnings((previous) => [ if (response.status === 200) {
...previous, setSyncWarnings((previous) => [
...(response.data.warnings ?? []), ...previous,
]); ...(response.data.warnings ?? []),
]);
}
} catch { } catch {
hasFailure = true; hasFailure = true;
} finally { } finally {
@@ -190,9 +198,7 @@ export default function SkillsPacksPage() {
size: "md", size: "md",
})} })}
disabled={ disabled={
isSyncingAll || isSyncingAll || syncingPackIds.size > 0 || packs.length === 0
syncingPackIds.size > 0 ||
packs.length === 0
} }
onClick={() => { onClick={() => {
void handleSyncAllPacks(); void handleSyncAllPacks();
@@ -241,10 +247,14 @@ export default function SkillsPacksPage() {
<p className="text-sm text-rose-600">{packsQuery.error.message}</p> <p className="text-sm text-rose-600">{packsQuery.error.message}</p>
) : null} ) : null}
{deleteMutation.error ? ( {deleteMutation.error ? (
<p className="text-sm text-rose-600">{deleteMutation.error.message}</p> <p className="text-sm text-rose-600">
{deleteMutation.error.message}
</p>
) : null} ) : null}
{syncMutation.error ? ( {syncMutation.error ? (
<p className="text-sm text-rose-600">{syncMutation.error.message}</p> <p className="text-sm text-rose-600">
{syncMutation.error.message}
</p>
) : null} ) : null}
{syncAllError ? ( {syncAllError ? (
<p className="text-sm text-rose-600">{syncAllError}</p> <p className="text-sm text-rose-600">{syncAllError}</p>

View File

@@ -177,7 +177,8 @@ export function DashboardSidebar() {
href="/skills/marketplace" href="/skills/marketplace"
className={cn( className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-slate-700 transition", "flex items-center gap-3 rounded-lg px-3 py-2.5 text-slate-700 transition",
pathname === "/skills" || pathname.startsWith("/skills/marketplace") pathname === "/skills" ||
pathname.startsWith("/skills/marketplace")
? "bg-blue-100 text-blue-800 font-medium" ? "bg-blue-100 text-blue-800 font-medium"
: "hover:bg-slate-100", : "hover:bg-slate-100",
)} )}

View File

@@ -11,9 +11,13 @@ import {
} from "@tanstack/react-table"; } from "@tanstack/react-table";
import type { MarketplaceSkillCardRead } from "@/api/generated/model"; import type { MarketplaceSkillCardRead } from "@/api/generated/model";
import { DataTable, type DataTableEmptyState } from "@/components/tables/DataTable"; import {
DataTable,
type DataTableEmptyState,
} from "@/components/tables/DataTable";
import { dateCell } from "@/components/tables/cell-formatters"; import { dateCell } from "@/components/tables/cell-formatters";
import { Button, buttonVariants } from "@/components/ui/button"; import { Button, buttonVariants } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { import {
SKILLS_TABLE_EMPTY_ICON, SKILLS_TABLE_EMPTY_ICON,
useTableSortingState, useTableSortingState,
@@ -25,9 +29,32 @@ import {
packsHrefFromPackUrl, packsHrefFromPackUrl,
} from "@/lib/skills-source"; } from "@/lib/skills-source";
function riskBadgeVariant(risk: string | null | undefined) {
const normalizedRisk = (risk || "unknown").trim().toLowerCase();
switch (normalizedRisk) {
case "low":
return "success";
case "medium":
case "moderate":
return "warning";
case "high":
case "critical":
return "danger";
case "unknown":
return "outline";
default:
return "accent";
}
}
function riskBadgeLabel(risk: string | null | undefined) {
return (risk || "unknown").trim() || "unknown";
}
type MarketplaceSkillsTableProps = { type MarketplaceSkillsTableProps = {
skills: MarketplaceSkillCardRead[]; skills: MarketplaceSkillCardRead[];
installedGatewayNamesBySkillId?: Record<string, string[]>; installedGatewayNamesBySkillId?: Record<string, { id: string; name: string }[]>;
isLoading?: boolean; isLoading?: boolean;
sorting?: SortingState; sorting?: SortingState;
onSortingChange?: OnChangeFn<SortingState>; onSortingChange?: OnChangeFn<SortingState>;
@@ -78,7 +105,9 @@ export function MarketplaceSkillsTable({
{row.original.name} {row.original.name}
</button> </button>
) : ( ) : (
<p className="text-sm font-medium text-slate-900">{row.original.name}</p> <p className="text-sm font-medium text-slate-900">
{row.original.name}
</p>
)} )}
<p <p
className="mt-1 line-clamp-2 text-xs text-slate-500" className="mt-1 line-clamp-2 text-xs text-slate-500"
@@ -117,9 +146,12 @@ export function MarketplaceSkillsTable({
accessorKey: "risk", accessorKey: "risk",
header: "Risk", header: "Risk",
cell: ({ row }) => ( cell: ({ row }) => (
<span className="text-sm text-slate-700"> <Badge
{row.original.risk || "unknown"} variant={riskBadgeVariant(row.original.risk)}
</span> className="px-2 py-0.5"
>
{riskBadgeLabel(row.original.risk)}
</Badge>
), ),
}, },
{ {
@@ -127,6 +159,11 @@ export function MarketplaceSkillsTable({
header: "Source", header: "Source",
cell: ({ row }) => { cell: ({ row }) => {
const sourceHref = row.original.source || row.original.source_url; const sourceHref = row.original.source || row.original.source_url;
if (!sourceHref) {
return <span className="text-sm text-slate-400">No source</span>;
}
return ( return (
<Link <Link
href={sourceHref} href={sourceHref}
@@ -145,15 +182,32 @@ export function MarketplaceSkillsTable({
header: "Installed On", header: "Installed On",
enableSorting: false, enableSorting: false,
cell: ({ row }) => { cell: ({ row }) => {
const installedOn = installedGatewayNamesBySkillId?.[row.original.id] ?? []; const installedOn =
installedGatewayNamesBySkillId?.[row.original.id] ?? [];
if (installedOn.length === 0) { if (installedOn.length === 0) {
return <span className="text-sm text-slate-500">-</span>; return <span className="text-sm text-slate-500">-</span>;
} }
const installedOnText = installedOn.join(", ");
return ( return (
<span className="text-sm text-slate-700" title={installedOnText}> <div className="flex flex-wrap gap-1">
{installedOnText} {installedOn.map((gateway, index) => {
</span> 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>
); );
}, },
}, },

View File

@@ -48,7 +48,9 @@ export function SkillInstallDialog({
className="max-w-xl p-6 sm:p-7" className="max-w-xl p-6 sm:p-7"
> >
<DialogHeader className="pb-1"> <DialogHeader className="pb-1">
<DialogTitle>{selectedSkill ? selectedSkill.name : "Install skill"}</DialogTitle> <DialogTitle>
{selectedSkill ? selectedSkill.name : "Install skill"}
</DialogTitle>
<DialogDescription> <DialogDescription>
Choose one or more gateways where this skill should be installed. Choose one or more gateways where this skill should be installed.
</DialogDescription> </DialogDescription>
@@ -60,14 +62,17 @@ export function SkillInstallDialog({
) : ( ) : (
gateways.map((gateway) => { gateways.map((gateway) => {
const isInstalled = gatewayInstalledById[gateway.id] === true; const isInstalled = gatewayInstalledById[gateway.id] === true;
const isUpdatingGateway = installingGatewayId === gateway.id && isMutating; const isUpdatingGateway =
installingGatewayId === gateway.id && isMutating;
return ( return (
<div <div
key={gateway.id} key={gateway.id}
className="flex items-center justify-between rounded-xl border border-slate-200 bg-white p-4" className="flex items-center justify-between rounded-xl border border-slate-200 bg-white p-4"
> >
<div> <div>
<p className="text-sm font-medium text-slate-900">{gateway.name}</p> <p className="text-sm font-medium text-slate-900">
{gateway.name}
</p>
</div> </div>
<Button <Button
type="button" type="button"
@@ -91,11 +96,17 @@ export function SkillInstallDialog({
{gatewayStatusError ? ( {gatewayStatusError ? (
<p className="text-sm text-rose-600">{gatewayStatusError}</p> <p className="text-sm text-rose-600">{gatewayStatusError}</p>
) : null} ) : null}
{mutationError ? <p className="text-sm text-rose-600">{mutationError}</p> : null} {mutationError ? (
<p className="text-sm text-rose-600">{mutationError}</p>
) : null}
</div> </div>
<DialogFooter className="mt-6 border-t border-slate-200 pt-4"> <DialogFooter className="mt-6 border-t border-slate-200 pt-4">
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isMutating}> <Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isMutating}
>
Close Close
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@@ -11,7 +11,10 @@ import {
} from "@tanstack/react-table"; } from "@tanstack/react-table";
import type { SkillPackRead } from "@/api/generated/model"; import type { SkillPackRead } from "@/api/generated/model";
import { DataTable, type DataTableEmptyState } from "@/components/tables/DataTable"; import {
DataTable,
type DataTableEmptyState,
} from "@/components/tables/DataTable";
import { dateCell } from "@/components/tables/cell-formatters"; import { dateCell } from "@/components/tables/cell-formatters";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -62,7 +65,9 @@ export function SkillPacksTable({
header: "Pack", header: "Pack",
cell: ({ row }) => ( cell: ({ row }) => (
<div> <div>
<p className="text-sm font-medium text-slate-900">{row.original.name}</p> <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"> <p className="mt-1 line-clamp-2 text-xs text-slate-500">
{row.original.description || "No description provided."} {row.original.description || "No description provided."}
</p> </p>
@@ -86,7 +91,11 @@ export function SkillPacksTable({
{ {
accessorKey: "branch", accessorKey: "branch",
header: "Branch", header: "Branch",
cell: ({ row }) => <p className="text-sm text-slate-900">{row.original.branch || "main"}</p>, cell: ({ row }) => (
<p className="text-sm text-slate-900">
{row.original.branch || "main"}
</p>
),
}, },
{ {
accessorKey: "skill_count", accessorKey: "skill_count",
@@ -111,7 +120,9 @@ export function SkillPacksTable({
enableSorting: false, enableSorting: false,
cell: ({ row }) => { cell: ({ row }) => {
if (!onSync) return null; if (!onSync) return null;
const isThisPackSyncing = Boolean(syncingPackIds?.has(row.original.id)); const isThisPackSyncing = Boolean(
syncingPackIds?.has(row.original.id),
);
return ( return (
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button

View File

@@ -34,7 +34,8 @@ export const useTableSortingState = (
resolvedSorting: SortingState; resolvedSorting: SortingState;
handleSortingChange: OnChangeFn<SortingState>; handleSortingChange: OnChangeFn<SortingState>;
} => { } => {
const [internalSorting, setInternalSorting] = useState<SortingState>(defaultSorting); const [internalSorting, setInternalSorting] =
useState<SortingState>(defaultSorting);
const resolvedSorting = sorting ?? internalSorting; const resolvedSorting = sorting ?? internalSorting;
const handleSortingChange: OnChangeFn<SortingState> = const handleSortingChange: OnChangeFn<SortingState> =
onSortingChange ?? onSortingChange ??

View File

@@ -3,7 +3,9 @@ export const normalizeRepoSourceUrl = (sourceUrl: string): string => {
return trimmed.endsWith(".git") ? trimmed.slice(0, -4) : trimmed; return trimmed.endsWith(".git") ? trimmed.slice(0, -4) : trimmed;
}; };
export const repoBaseFromSkillSourceUrl = (skillSourceUrl: string): string | null => { export const repoBaseFromSkillSourceUrl = (
skillSourceUrl: string,
): string | null => {
try { try {
const parsed = new URL(skillSourceUrl); const parsed = new URL(skillSourceUrl);
const marker = "/tree/"; const marker = "/tree/";
@@ -11,7 +13,8 @@ export const repoBaseFromSkillSourceUrl = (skillSourceUrl: string): string | nul
if (markerIndex <= 0) return null; if (markerIndex <= 0) return null;
// Reject unexpected structures (e.g. multiple /tree/ markers). // Reject unexpected structures (e.g. multiple /tree/ markers).
if (parsed.pathname.indexOf(marker, markerIndex + marker.length) !== -1) return null; if (parsed.pathname.indexOf(marker, markerIndex + marker.length) !== -1)
return null;
const repoPath = parsed.pathname.slice(0, markerIndex); const repoPath = parsed.pathname.slice(0, markerIndex);
if (!repoPath || repoPath === "/") return null; if (!repoPath || repoPath === "/") return null;