diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index ea4958f..cb4a72d 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -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: diff --git a/backend/app/api/gateways.py b/backend/app/api/gateways.py index 5354839..31a102f 100644 --- a/backend/app/api/gateways.py +++ b/backend/app/api/gateways.py @@ -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() diff --git a/backend/app/api/skills_marketplace.py b/backend/app/api/skills_marketplace.py new file mode 100644 index 0000000..3224355 --- /dev/null +++ b/backend/app/api/skills_marketplace.py @@ -0,0 +1,1340 @@ +"""Skills marketplace and skill pack APIs.""" + +from __future__ import annotations + +import ipaddress +import json +import re +import subprocess +from dataclasses import dataclass +from pathlib import Path +from tempfile import TemporaryDirectory +from typing import TYPE_CHECKING, Iterator, TextIO +from urllib.parse import unquote, urlparse +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Query, Response, status +from sqlalchemy import func, or_ +from sqlmodel import col, select + +from app.api.deps import require_org_admin +from app.core.time import utcnow +from app.db.session import get_session +from app.models.gateway_installed_skills import GatewayInstalledSkill +from app.models.gateways import Gateway +from app.models.marketplace_skills import MarketplaceSkill +from app.models.skill_packs import SkillPack +from app.schemas.common import OkResponse +from app.schemas.skills_marketplace import ( + MarketplaceSkillActionResponse, + MarketplaceSkillCardRead, + MarketplaceSkillCreate, + MarketplaceSkillRead, + SkillPackCreate, + SkillPackRead, + SkillPackSyncResponse, +) +from app.services.openclaw.gateway_dispatch import GatewayDispatchService +from app.services.openclaw.gateway_resolver import ( + gateway_client_config, + require_gateway_workspace_root, +) +from app.services.openclaw.gateway_rpc import OpenClawGatewayError +from app.services.openclaw.shared import GatewayAgentIdentity +from app.services.organizations import OrganizationContext + +if TYPE_CHECKING: + from sqlmodel.ext.asyncio.session import AsyncSession + +router = APIRouter(prefix="/skills", tags=["skills"]) +SESSION_DEP = Depends(get_session) +ORG_ADMIN_DEP = Depends(require_org_admin) +GATEWAY_ID_QUERY = Query(...) + +ALLOWED_PACK_SOURCE_SCHEMES = {"https"} +GIT_CLONE_TIMEOUT_SECONDS = 30 +GIT_REV_PARSE_TIMEOUT_SECONDS = 10 +BRANCH_NAME_ALLOWED_RE = r"^[A-Za-z0-9._/\-]+$" +SKILLS_INDEX_READ_CHUNK_BYTES = 16 * 1024 + + +def _normalize_pack_branch(raw_branch: str | None) -> str: + if not raw_branch: + return "main" + normalized = raw_branch.strip() + if not normalized: + return "main" + if any(ch in normalized for ch in {"\n", "\r", "\t"}): + return "main" + if not re.match(BRANCH_NAME_ALLOWED_RE, normalized): + return "main" + return normalized + + +@dataclass(frozen=True) +class PackSkillCandidate: + """Single skill discovered in a pack repository.""" + + name: str + description: str | None + source_url: str + category: str | None = None + risk: str | None = None + source: str | None = None + metadata: dict[str, object] | None = None + + +def _skills_install_dir(workspace_root: str) -> str: + normalized = workspace_root.rstrip("/\\") + if not normalized: + return "skills" + return f"{normalized}/skills" + + +def _infer_skill_name(source_url: str) -> str: + parsed = urlparse(source_url) + path = parsed.path.rstrip("/") + candidate = path.rsplit("/", maxsplit=1)[-1] if path else parsed.netloc + candidate = unquote(candidate).removesuffix(".git").replace("-", " ").replace("_", " ") + if candidate.strip(): + return candidate.strip() + return "Skill" + + +def _infer_skill_description(skill_file: Path) -> str | None: + try: + content = skill_file.read_text(encoding="utf-8", errors="ignore") + except OSError: + return None + + lines = [line.strip() for line in content.splitlines()] + if not lines: + return None + + in_frontmatter = False + for line in lines: + if line == "---": + in_frontmatter = not in_frontmatter + continue + if in_frontmatter: + if line.lower().startswith("description:"): + value = line.split(":", maxsplit=1)[-1].strip().strip("\"'") + return value or None + continue + if not line or line.startswith("#"): + continue + return line + + return None + + +def _infer_skill_display_name(skill_file: Path, fallback: str) -> str: + try: + content = skill_file.read_text(encoding="utf-8", errors="ignore") + except OSError: + content = "" + + in_frontmatter = False + for raw_line in content.splitlines(): + line = raw_line.strip() + if line == "---": + in_frontmatter = not in_frontmatter + continue + if in_frontmatter and line.lower().startswith("name:"): + value = line.split(":", maxsplit=1)[-1].strip().strip("\"'") + if value: + return value + + for raw_line in content.splitlines(): + line = raw_line.strip() + if line.startswith("#"): + heading = line.lstrip("#").strip() + if heading: + return heading + + normalized_fallback = fallback.replace("-", " ").replace("_", " ").strip() + return normalized_fallback or "Skill" + + +def _normalize_repo_source_url(source_url: str) -> str: + normalized = source_url.strip().rstrip("/") + if normalized.endswith(".git"): + return normalized[: -len(".git")] + return normalized + + +def _normalize_pack_source_url(source_url: str) -> str: + """Normalize pack repository source URLs for uniqueness checks.""" + return _normalize_repo_source_url(source_url) + + +def _validate_pack_source_url(source_url: str) -> None: + """Validate that a skill pack source URL is safe to clone. + + The current implementation is intentionally conservative: + - allow only https URLs + - block localhost + - block literal private/loopback/link-local IPs + + Note: DNS-based private resolution is not checked here. + """ + + parsed = urlparse(source_url) + scheme = (parsed.scheme or "").lower() + if scheme not in ALLOWED_PACK_SOURCE_SCHEMES: + raise ValueError(f"Unsupported pack source URL scheme: {parsed.scheme!r}") + + host = (parsed.hostname or "").strip().lower() + if not host: + raise ValueError("Pack source URL must include a hostname") + + if host in {"localhost"}: + raise ValueError("Pack source URL hostname is not allowed") + + if host != "github.com": + raise ValueError( + "Pack source URL must be a GitHub repository URL (https://github.com//)" + ) + + path = parsed.path.strip("/") + if not path or path.count("/") < 1: + raise ValueError( + "Pack source URL must be a GitHub repository URL (https://github.com//)" + ) + + try: + ip = ipaddress.ip_address(host) + except ValueError: + return + + if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_multicast: + raise ValueError("Pack source URL hostname is not allowed") + + +def _to_tree_source_url(repo_source_url: str, branch: str, rel_path: str) -> str: + repo_url = _normalize_repo_source_url(repo_source_url) + safe_branch = branch.strip() or "main" + rel = rel_path.strip().lstrip("/") + if not rel: + return f"{repo_url}/tree/{safe_branch}" + return f"{repo_url}/tree/{safe_branch}/{rel}" + + +def _repo_base_from_tree_source_url(source_url: str) -> str | None: + parsed = urlparse(source_url) + marker = "/tree/" + marker_index = parsed.path.find(marker) + if marker_index <= 0: + return None + + repo_path = parsed.path[:marker_index] + if not repo_path: + return None + return _normalize_repo_source_url(f"{parsed.scheme}://{parsed.netloc}{repo_path}") + + +def _build_skill_count_by_repo(skills: list[MarketplaceSkill]) -> dict[str, int]: + counts: dict[str, int] = {} + for skill in skills: + repo_base = _repo_base_from_tree_source_url(skill.source_url) + if repo_base is None: + continue + counts[repo_base] = counts.get(repo_base, 0) + 1 + return counts + + +def _normalize_repo_path(path_value: str) -> str: + cleaned = path_value.strip().replace("\\", "/") + while cleaned.startswith("./"): + cleaned = cleaned[2:] + cleaned = cleaned.lstrip("/").rstrip("/") + + lowered = cleaned.lower() + if lowered.endswith("/skill.md"): + cleaned = cleaned.rsplit("/", maxsplit=1)[0] + elif lowered == "skill.md": + cleaned = "" + + return cleaned + + +def _coerce_index_entries(payload: object) -> list[dict[str, object]]: + if isinstance(payload, list): + return [entry for entry in payload if isinstance(entry, dict)] + + if isinstance(payload, dict): + entries = payload.get("skills") + if isinstance(entries, list): + return [entry for entry in entries if isinstance(entry, dict)] + + return [] + + +class _StreamingJSONReader: + """Incrementally decode JSON content from a file object.""" + + def __init__(self, file_obj: TextIO): + self._file_obj = file_obj + self._buffer = "" + self._position = 0 + self._eof = False + self._decoder = json.JSONDecoder() + + def _fill_buffer(self) -> None: + if self._eof: + return + + chunk = self._file_obj.read(SKILLS_INDEX_READ_CHUNK_BYTES) + if not chunk: + self._eof = True + return + self._buffer += chunk + + def _peek(self) -> str | None: + self._skip_whitespace() + if self._position >= len(self._buffer): + return None + return self._buffer[self._position] + + def _skip_whitespace(self) -> None: + while True: + while self._position < len(self._buffer) and self._buffer[self._position].isspace(): + self._position += 1 + + if self._position < len(self._buffer): + return + + self._fill_buffer() + if self._position < len(self._buffer): + return + if self._eof: + return + + def _decode_value(self) -> object: + self._skip_whitespace() + + while True: + try: + value, end = self._decoder.raw_decode(self._buffer, self._position) + self._position = end + return value + except json.JSONDecodeError: + if self._eof: + raise RuntimeError("skills_index.json is not valid JSON") + self._fill_buffer() + self._skip_whitespace() + if self._position >= len(self._buffer): + if self._eof: + raise RuntimeError("skills_index.json is not valid JSON") + + def _consume_char(self, expected: str) -> None: + self._skip_whitespace() + if self._position >= len(self._buffer): + self._fill_buffer() + self._skip_whitespace() + if self._position >= len(self._buffer): + raise RuntimeError("skills_index.json is not valid JSON") + + actual = self._buffer[self._position] + if actual != expected: + raise RuntimeError("skills_index.json is not valid JSON") + self._position += 1 + + def read_top_level_entries(self) -> list[dict[str, object]]: + self._fill_buffer() + self._skip_whitespace() + first = self._peek() + if first is None: + raise RuntimeError("skills_index.json is not valid JSON") + + if first == "[": + self._position += 1 + return list(self._read_array_values()) + if first == "{": + self._position += 1 + return list(self._read_skills_from_object()) + raise RuntimeError("skills_index.json is not valid JSON") + + def _read_array_values(self) -> Iterator[dict[str, object]]: + while True: + self._skip_whitespace() + current = self._peek() + if current is None: + if self._eof: + raise RuntimeError("skills_index.json is not valid JSON") + continue + if current == "]": + self._position += 1 + return + + if current == ",": + self._position += 1 + continue + + entry = self._decode_value() + if isinstance(entry, dict): + yield entry + else: + raise RuntimeError("skills_index.json is not valid JSON") + + def _read_skills_from_object(self) -> Iterator[dict[str, object]]: + while True: + self._skip_whitespace() + current = self._peek() + if current is None: + if self._eof: + raise RuntimeError("skills_index.json is not valid JSON") + continue + + if current == "}": + self._position += 1 + return + + key = self._decode_value() + if not isinstance(key, str): + raise RuntimeError("skills_index.json is not valid JSON") + + self._skip_whitespace() + if self._peek() == ":": + self._position += 1 + else: + self._consume_char(":") + + if key == "skills": + self._skip_whitespace() + current = self._peek() + if current is None: + if self._eof: + raise RuntimeError("skills_index.json is not valid JSON") + continue + + if current != "[": + value = self._decode_value() + if isinstance(value, list): + for entry in value: + if isinstance(entry, dict): + yield entry + else: + raise RuntimeError("skills_index.json is not valid JSON") + continue + + self._position += 1 + yield from self._read_array_values() + else: + self._decode_value() + + self._skip_whitespace() + current = self._peek() + if current == ",": + self._position += 1 + continue + if current == "}": + self._position += 1 + return + + +def _collect_pack_skills_from_index( + *, + repo_dir: Path, + source_url: str, + branch: str, + discovery_warnings: list[str] | None = None, +) -> list[PackSkillCandidate] | None: + index_file = repo_dir / "skills_index.json" + if not index_file.is_file(): + return None + + try: + with index_file.open(encoding="utf-8") as fp: + payload = _StreamingJSONReader(fp).read_top_level_entries() + except OSError as exc: + raise RuntimeError("unable to read skills_index.json") from exc + except RuntimeError as exc: + if discovery_warnings is not None: + discovery_warnings.append(f"Failed to parse skills_index.json: {exc}") + return None + + found: dict[str, PackSkillCandidate] = {} + for entry in _coerce_index_entries(payload): + indexed_path = entry.get("path") + has_indexed_path = False + rel_path = "" + resolved_skill_path: str | None = None + if isinstance(indexed_path, str) and indexed_path.strip(): + has_indexed_path = True + rel_path = _normalize_repo_path(indexed_path) + resolved_skill_path = rel_path or None + + indexed_source = entry.get("source_url") + candidate_source_url: str | None = None + resolved_metadata: dict[str, object] = { + "discovery_mode": "skills_index", + "pack_branch": branch, + } + if isinstance(indexed_source, str) and indexed_source.strip(): + source_candidate = indexed_source.strip() + resolved_metadata["source_url"] = source_candidate + 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 + else: + indexed_rel = _normalize_repo_path(source_candidate) + resolved_skill_path = resolved_skill_path or indexed_rel + resolved_metadata["resolved_path"] = indexed_rel + if indexed_rel: + candidate_source_url = _to_tree_source_url(source_url, branch, indexed_rel) + elif has_indexed_path: + resolved_metadata["resolved_path"] = 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: + continue + + indexed_name = entry.get("name") + if isinstance(indexed_name, str) and indexed_name.strip(): + name = indexed_name.strip() + else: + fallback = Path(rel_path).name if rel_path else "Skill" + name = _infer_skill_name(fallback) + + indexed_description = entry.get("description") + description = ( + indexed_description.strip() + if isinstance(indexed_description, str) and indexed_description.strip() + else None + ) + indexed_category = entry.get("category") + category = ( + indexed_category.strip() + if isinstance(indexed_category, str) and indexed_category.strip() + else None + ) + indexed_risk = entry.get("risk") + risk = ( + indexed_risk.strip() if isinstance(indexed_risk, str) and indexed_risk.strip() else None + ) + source_label = resolved_skill_path + + found[candidate_source_url] = PackSkillCandidate( + name=name, + description=description, + source_url=candidate_source_url, + category=category, + risk=risk, + source=source_label, + metadata=resolved_metadata, + ) + + return list(found.values()) + + +def _collect_pack_skills_from_repo( + *, + repo_dir: Path, + source_url: str, + branch: str, + discovery_warnings: list[str] | None = None, +) -> list[PackSkillCandidate]: + indexed = _collect_pack_skills_from_index( + repo_dir=repo_dir, + source_url=source_url, + branch=branch, + discovery_warnings=discovery_warnings, + ) + if indexed is not None: + return indexed + + found: dict[str, PackSkillCandidate] = {} + for skill_file in sorted(repo_dir.rglob("SKILL.md")): + rel_file_parts = skill_file.relative_to(repo_dir).parts + # Skip hidden folders like .git, .github, etc. + if any(part.startswith(".") for part in rel_file_parts): + continue + + skill_dir = skill_file.parent + 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 + name = _infer_skill_display_name(skill_file, fallback=fallback_name) + description = _infer_skill_description(skill_file) + tree_url = _to_tree_source_url(source_url, branch, rel_dir) + found[tree_url] = PackSkillCandidate( + name=name, + description=description, + source_url=tree_url, + metadata={ + "discovery_mode": "skills_md", + "pack_branch": branch, + "skill_dir": rel_dir, + }, + ) + + if found: + return list(found.values()) + + return [] + + +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`.""" + return _collect_pack_skills_with_warnings( + source_url=source_url, + branch=branch, + )[0] + + +def _collect_pack_skills_with_warnings( + *, + source_url: str, + branch: str, +) -> tuple[list[PackSkillCandidate], list[str]]: + """Clone a pack repository and return discovered skills plus sync warnings.""" + # Defense-in-depth: validate again at point of use before invoking git. + _validate_pack_source_url(source_url) + + requested_branch = _normalize_pack_branch(branch) + discovery_warnings: list[str] = [] + + with TemporaryDirectory(prefix="skill-pack-sync-") as tmp_dir: + repo_dir = Path(tmp_dir) + used_branch = requested_branch + try: + subprocess.run( + [ + "git", + "clone", + "--depth", + "1", + "--single-branch", + "--branch", + requested_branch, + source_url, + str(repo_dir), + ], + check=True, + capture_output=True, + text=True, + timeout=GIT_CLONE_TIMEOUT_SECONDS, + ) + except FileNotFoundError as exc: + raise RuntimeError("git binary not available on the server") from exc + except subprocess.TimeoutExpired as exc: + raise RuntimeError("timed out cloning pack repository") from exc + except subprocess.CalledProcessError as exc: + if requested_branch != "main": + try: + subprocess.run( + ["git", "clone", "--depth", "1", source_url, str(repo_dir)], + check=True, + capture_output=True, + text=True, + timeout=GIT_CLONE_TIMEOUT_SECONDS, + ) + used_branch = "main" + except ( + FileNotFoundError, + subprocess.TimeoutExpired, + subprocess.CalledProcessError, + ): + stderr = (exc.stderr or "").strip() + detail = "unable to clone pack repository" + if stderr: + detail = f"{detail}: {stderr.splitlines()[0][:200]}" + raise RuntimeError(detail) from exc + else: + stderr = (exc.stderr or "").strip() + detail = "unable to clone pack repository" + if stderr: + detail = f"{detail}: {stderr.splitlines()[0][:200]}" + raise RuntimeError(detail) from exc + + try: + discovered_branch = subprocess.run( + ["git", "-C", str(repo_dir), "rev-parse", "--abbrev-ref", "HEAD"], + check=True, + capture_output=True, + text=True, + timeout=GIT_REV_PARSE_TIMEOUT_SECONDS, + ).stdout.strip() + except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.CalledProcessError): + discovered_branch = used_branch or "main" + + return ( + _collect_pack_skills_from_repo( + repo_dir=repo_dir, + source_url=source_url, + branch=_normalize_pack_branch(discovered_branch), + discovery_warnings=discovery_warnings, + ), + discovery_warnings, + ) + + +def _install_instruction(*, skill: MarketplaceSkill, gateway: Gateway) -> str: + install_dir = _skills_install_dir(gateway.workspace_root) + return ( + "MISSION CONTROL SKILL INSTALL REQUEST\n" + f"Skill name: {skill.name}\n" + f"Skill source URL: {skill.source_url}\n" + f"Install destination: {install_dir}\n\n" + "Actions:\n" + "1. Ensure the install destination exists.\n" + "2. Install or update the skill from the source URL into the destination.\n" + "3. Verify the skill is discoverable by the runtime.\n" + "4. Reply with success or failure details." + ) + + +def _uninstall_instruction(*, skill: MarketplaceSkill, gateway: Gateway) -> str: + install_dir = _skills_install_dir(gateway.workspace_root) + return ( + "MISSION CONTROL SKILL UNINSTALL REQUEST\n" + f"Skill name: {skill.name}\n" + f"Skill source URL: {skill.source_url}\n" + f"Install destination: {install_dir}\n\n" + "Actions:\n" + "1. Remove the skill assets previously installed from this source URL.\n" + "2. Ensure the skill is no longer discoverable by the runtime.\n" + "3. Reply with success or failure details." + ) + + +def _as_card( + *, + skill: MarketplaceSkill, + installation: GatewayInstalledSkill | None, +) -> MarketplaceSkillCardRead: + card_source: str | None = skill.source_url + if not card_source: + card_source = skill.source + + return MarketplaceSkillCardRead( + id=skill.id, + organization_id=skill.organization_id, + name=skill.name, + description=skill.description, + category=skill.category, + risk=skill.risk, + source=card_source, + source_url=skill.source_url, + metadata_=skill.metadata_ or {}, + created_at=skill.created_at, + updated_at=skill.updated_at, + installed=installation is not None, + installed_at=installation.created_at if installation is not None else None, + ) + + +def _as_skill_pack_read(pack: SkillPack) -> SkillPackRead: + return SkillPackRead( + id=pack.id, + organization_id=pack.organization_id, + name=pack.name, + description=pack.description, + source_url=pack.source_url, + branch=pack.branch or "main", + metadata_=pack.metadata_ or {}, + skill_count=0, + created_at=pack.created_at, + updated_at=pack.updated_at, + ) + + +def _pack_skill_count(*, pack: SkillPack, count_by_repo: dict[str, int]) -> int: + repo_base = _normalize_repo_source_url(pack.source_url) + return count_by_repo.get(repo_base, 0) + + +async def _require_gateway_for_org( + *, + gateway_id: UUID, + session: AsyncSession, + ctx: OrganizationContext, +) -> Gateway: + gateway = await Gateway.objects.by_id(gateway_id).first(session) + if gateway is None or gateway.organization_id != ctx.organization.id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Gateway not found", + ) + return gateway + + +async def _require_marketplace_skill_for_org( + *, + skill_id: UUID, + session: AsyncSession, + ctx: OrganizationContext, +) -> MarketplaceSkill: + skill = await MarketplaceSkill.objects.by_id(skill_id).first(session) + if skill is None or skill.organization_id != ctx.organization.id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Marketplace skill not found", + ) + return skill + + +async def _require_skill_pack_for_org( + *, + pack_id: UUID, + session: AsyncSession, + ctx: OrganizationContext, +) -> SkillPack: + pack = await SkillPack.objects.by_id(pack_id).first(session) + if pack is None or pack.organization_id != ctx.organization.id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Skill pack not found", + ) + return pack + + +async def _dispatch_gateway_instruction( + *, + session: AsyncSession, + gateway: Gateway, + message: str, +) -> None: + dispatch = GatewayDispatchService(session) + config = gateway_client_config(gateway) + session_key = GatewayAgentIdentity.session_key(gateway) + await dispatch.send_agent_message( + session_key=session_key, + config=config, + agent_name="Gateway Agent", + message=message, + deliver=True, + ) + + +async def _load_pack_skill_count_by_repo( + *, + session: AsyncSession, + organization_id: UUID, +) -> dict[str, int]: + skills = await MarketplaceSkill.objects.filter_by(organization_id=organization_id).all(session) + return _build_skill_count_by_repo(skills) + + +def _as_skill_pack_read_with_count( + *, + pack: SkillPack, + count_by_repo: dict[str, int], +) -> SkillPackRead: + return _as_skill_pack_read(pack).model_copy( + update={"skill_count": _pack_skill_count(pack=pack, count_by_repo=count_by_repo)}, + ) + + +async def _sync_gateway_installation_state( + *, + session: AsyncSession, + gateway_id: UUID, + skill_id: UUID, + installed: bool, +) -> None: + installation = await GatewayInstalledSkill.objects.filter_by( + gateway_id=gateway_id, + skill_id=skill_id, + ).first(session) + if installed: + if installation is None: + session.add( + GatewayInstalledSkill( + gateway_id=gateway_id, + skill_id=skill_id, + ), + ) + return + + installation.updated_at = utcnow() + session.add(installation) + return + + if installation is not None: + await session.delete(installation) + + +async def _run_marketplace_skill_action( + *, + session: AsyncSession, + ctx: OrganizationContext, + skill_id: UUID, + gateway_id: UUID, + installed: bool, +) -> MarketplaceSkillActionResponse: + gateway = await _require_gateway_for_org(gateway_id=gateway_id, session=session, ctx=ctx) + require_gateway_workspace_root(gateway) + skill = await _require_marketplace_skill_for_org(skill_id=skill_id, session=session, ctx=ctx) + instruction = ( + _install_instruction(skill=skill, gateway=gateway) + if installed + else _uninstall_instruction(skill=skill, gateway=gateway) + ) + try: + await _dispatch_gateway_instruction( + session=session, + gateway=gateway, + message=instruction, + ) + except OpenClawGatewayError as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=str(exc), + ) from exc + + await _sync_gateway_installation_state( + session=session, + gateway_id=gateway.id, + skill_id=skill.id, + installed=installed, + ) + await session.commit() + return MarketplaceSkillActionResponse( + skill_id=skill.id, + gateway_id=gateway.id, + installed=installed, + ) + + +def _apply_pack_candidate_updates( + *, + existing: MarketplaceSkill, + candidate: PackSkillCandidate, +) -> bool: + changed = False + if existing.name != candidate.name: + existing.name = candidate.name + changed = True + if existing.description != candidate.description: + existing.description = candidate.description + changed = True + if existing.category != candidate.category: + existing.category = candidate.category + changed = True + if existing.risk != candidate.risk: + existing.risk = candidate.risk + changed = True + if existing.source != candidate.source: + existing.source = candidate.source + changed = True + if existing.metadata_ != (candidate.metadata or {}): + existing.metadata_ = candidate.metadata or {} + changed = True + return changed + + +@router.get("/marketplace", response_model=list[MarketplaceSkillCardRead]) +async def list_marketplace_skills( + response: Response, + gateway_id: UUID = GATEWAY_ID_QUERY, + search: str | None = Query(default=None), + category: str | None = Query(default=None), + risk: str | None = Query(default=None), + pack_id: UUID | None = Query(default=None, alias="pack_id"), + limit: int | None = Query(default=None, ge=1, le=200), + offset: int = Query(default=0, ge=0), + session: AsyncSession = SESSION_DEP, + ctx: OrganizationContext = ORG_ADMIN_DEP, +) -> list[MarketplaceSkillCardRead]: + """List marketplace cards for an org and annotate install state for a gateway.""" + gateway = await _require_gateway_for_org(gateway_id=gateway_id, session=session, ctx=ctx) + skills_query = MarketplaceSkill.objects.filter_by(organization_id=ctx.organization.id) + + normalized_category = (category or "").strip().lower() + if normalized_category: + if normalized_category == "uncategorized": + skills_query = skills_query.filter( + or_( + col(MarketplaceSkill.category).is_(None), + func.trim(col(MarketplaceSkill.category)) == "", + ), + ) + else: + skills_query = skills_query.filter( + func.lower(func.trim(col(MarketplaceSkill.category))) == normalized_category, + ) + + normalized_risk = (risk or "").strip().lower() + if normalized_risk: + if normalized_risk == "uncategorized": + skills_query = skills_query.filter( + or_( + col(MarketplaceSkill.risk).is_(None), + func.trim(col(MarketplaceSkill.risk)) == "", + ), + ) + else: + skills_query = skills_query.filter( + func.lower(func.trim(func.coalesce(col(MarketplaceSkill.risk), ""))) + == normalized_risk, + ) + + if pack_id is not None: + pack = await _require_skill_pack_for_org(pack_id=pack_id, session=session, ctx=ctx) + normalized_pack_source = _normalize_pack_source_url(pack.source_url) + skills_query = skills_query.filter( + col(MarketplaceSkill.source_url).ilike(f"{normalized_pack_source}%"), + ) + + normalized_search = (search or "").strip() + if normalized_search: + search_like = f"%{normalized_search}%" + skills_query = skills_query.filter( + or_( + col(MarketplaceSkill.name).ilike(search_like), + col(MarketplaceSkill.description).ilike(search_like), + col(MarketplaceSkill.category).ilike(search_like), + col(MarketplaceSkill.risk).ilike(search_like), + col(MarketplaceSkill.source).ilike(search_like), + ), + ) + + if limit is not None: + count_statement = select(func.count()).select_from( + skills_query.statement.order_by(None).subquery() + ) + total_count = int((await session.exec(count_statement)).one() or 0) + response.headers["X-Total-Count"] = str(total_count) + response.headers["X-Limit"] = str(limit) + response.headers["X-Offset"] = str(offset) + + ordered_query = skills_query.order_by(col(MarketplaceSkill.created_at).desc()) + if limit is not None: + ordered_query = ordered_query.offset(offset).limit(limit) + skills = await ordered_query.all(session) + installations = await GatewayInstalledSkill.objects.filter_by(gateway_id=gateway.id).all( + session + ) + installed_by_skill_id = {record.skill_id: record for record in installations} + return [ + _as_card(skill=skill, installation=installed_by_skill_id.get(skill.id)) for skill in skills + ] + + +@router.post("/marketplace", response_model=MarketplaceSkillRead) +async def create_marketplace_skill( + payload: MarketplaceSkillCreate, + session: AsyncSession = SESSION_DEP, + ctx: OrganizationContext = ORG_ADMIN_DEP, +) -> MarketplaceSkill: + """Register or update a direct marketplace skill URL in the catalog.""" + source_url = str(payload.source_url).strip() + existing = await MarketplaceSkill.objects.filter_by( + organization_id=ctx.organization.id, + source_url=source_url, + ).first(session) + if existing is not None: + changed = False + if payload.name and existing.name != payload.name: + existing.name = payload.name + changed = True + if payload.description is not None and existing.description != payload.description: + existing.description = payload.description + changed = True + if changed: + existing.updated_at = utcnow() + session.add(existing) + await session.commit() + await session.refresh(existing) + existing.metadata_ = existing.metadata_ or {} + return existing + + skill = MarketplaceSkill( + organization_id=ctx.organization.id, + source_url=source_url, + name=payload.name or _infer_skill_name(source_url), + description=payload.description, + metadata_={}, + ) + session.add(skill) + await session.commit() + await session.refresh(skill) + skill.metadata_ = skill.metadata_ or {} + return skill + + +@router.delete("/marketplace/{skill_id}", response_model=OkResponse) +async def delete_marketplace_skill( + skill_id: UUID, + session: AsyncSession = SESSION_DEP, + ctx: OrganizationContext = ORG_ADMIN_DEP, +) -> OkResponse: + """Delete a marketplace catalog entry and any install records that reference it.""" + skill = await _require_marketplace_skill_for_org(skill_id=skill_id, session=session, ctx=ctx) + installations = await GatewayInstalledSkill.objects.filter_by(skill_id=skill.id).all(session) + for installation in installations: + await session.delete(installation) + await session.delete(skill) + await session.commit() + return OkResponse() + + +@router.post( + "/marketplace/{skill_id}/install", + response_model=MarketplaceSkillActionResponse, +) +async def install_marketplace_skill( + skill_id: UUID, + gateway_id: UUID = GATEWAY_ID_QUERY, + session: AsyncSession = SESSION_DEP, + ctx: OrganizationContext = ORG_ADMIN_DEP, +) -> MarketplaceSkillActionResponse: + """Install a marketplace skill by dispatching instructions to the gateway agent.""" + return await _run_marketplace_skill_action( + session=session, + ctx=ctx, + skill_id=skill_id, + gateway_id=gateway_id, + installed=True, + ) + + +@router.post( + "/marketplace/{skill_id}/uninstall", + response_model=MarketplaceSkillActionResponse, +) +async def uninstall_marketplace_skill( + skill_id: UUID, + gateway_id: UUID = GATEWAY_ID_QUERY, + session: AsyncSession = SESSION_DEP, + ctx: OrganizationContext = ORG_ADMIN_DEP, +) -> MarketplaceSkillActionResponse: + """Uninstall a marketplace skill by dispatching instructions to the gateway agent.""" + return await _run_marketplace_skill_action( + session=session, + ctx=ctx, + skill_id=skill_id, + gateway_id=gateway_id, + installed=False, + ) + + +@router.get("/packs", response_model=list[SkillPackRead]) +async def list_skill_packs( + session: AsyncSession = SESSION_DEP, + ctx: OrganizationContext = ORG_ADMIN_DEP, +) -> list[SkillPackRead]: + """List skill packs configured for the organization.""" + packs = ( + await SkillPack.objects.filter_by(organization_id=ctx.organization.id) + .order_by(col(SkillPack.created_at).desc()) + .all(session) + ) + count_by_repo = await _load_pack_skill_count_by_repo( + session=session, + organization_id=ctx.organization.id, + ) + return [ + _as_skill_pack_read_with_count(pack=pack, count_by_repo=count_by_repo) for pack in packs + ] + + +@router.get("/packs/{pack_id}", response_model=SkillPackRead) +async def get_skill_pack( + pack_id: UUID, + session: AsyncSession = SESSION_DEP, + ctx: OrganizationContext = ORG_ADMIN_DEP, +) -> SkillPackRead: + """Get one skill pack by ID.""" + pack = await _require_skill_pack_for_org(pack_id=pack_id, session=session, ctx=ctx) + count_by_repo = await _load_pack_skill_count_by_repo( + session=session, + organization_id=ctx.organization.id, + ) + return _as_skill_pack_read_with_count(pack=pack, count_by_repo=count_by_repo) + + +@router.post("/packs", response_model=SkillPackRead) +async def create_skill_pack( + payload: SkillPackCreate, + session: AsyncSession = SESSION_DEP, + ctx: OrganizationContext = ORG_ADMIN_DEP, +) -> SkillPackRead: + """Register a new skill pack source URL.""" + source_url = _normalize_pack_source_url(str(payload.source_url)) + try: + _validate_pack_source_url(source_url) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + + existing = await SkillPack.objects.filter_by( + organization_id=ctx.organization.id, + source_url=source_url, + ).first(session) + if existing is not None: + changed = False + if payload.name and existing.name != payload.name: + existing.name = payload.name + changed = True + if payload.description is not None and existing.description != payload.description: + existing.description = payload.description + changed = True + normalized_branch = _normalize_pack_branch(payload.branch) + if existing.branch != normalized_branch: + existing.branch = normalized_branch + changed = True + if existing.metadata_ != payload.metadata_: + existing.metadata_ = payload.metadata_ + changed = True + if changed: + existing.updated_at = utcnow() + session.add(existing) + await session.commit() + await session.refresh(existing) + count_by_repo = await _load_pack_skill_count_by_repo( + session=session, + organization_id=ctx.organization.id, + ) + return _as_skill_pack_read_with_count(pack=existing, count_by_repo=count_by_repo) + + pack = SkillPack( + organization_id=ctx.organization.id, + source_url=source_url, + name=payload.name or _infer_skill_name(source_url), + description=payload.description, + branch=_normalize_pack_branch(payload.branch), + metadata_=payload.metadata_, + ) + session.add(pack) + await session.commit() + await session.refresh(pack) + count_by_repo = await _load_pack_skill_count_by_repo( + session=session, + organization_id=ctx.organization.id, + ) + return _as_skill_pack_read_with_count(pack=pack, count_by_repo=count_by_repo) + + +@router.patch("/packs/{pack_id}", response_model=SkillPackRead) +async def update_skill_pack( + pack_id: UUID, + payload: SkillPackCreate, + session: AsyncSession = SESSION_DEP, + ctx: OrganizationContext = ORG_ADMIN_DEP, +) -> SkillPackRead: + """Update a skill pack URL and metadata.""" + pack = await _require_skill_pack_for_org(pack_id=pack_id, session=session, ctx=ctx) + source_url = _normalize_pack_source_url(str(payload.source_url)) + try: + _validate_pack_source_url(source_url) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + + duplicate = await SkillPack.objects.filter_by( + organization_id=ctx.organization.id, + source_url=source_url, + ).first(session) + if duplicate is not None and duplicate.id != pack.id: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="A pack with this source URL already exists", + ) + + pack.source_url = source_url + pack.name = payload.name or _infer_skill_name(source_url) + pack.description = payload.description + pack.branch = _normalize_pack_branch(payload.branch) + pack.metadata_ = payload.metadata_ + pack.updated_at = utcnow() + session.add(pack) + await session.commit() + await session.refresh(pack) + count_by_repo = await _load_pack_skill_count_by_repo( + session=session, + organization_id=ctx.organization.id, + ) + return _as_skill_pack_read_with_count(pack=pack, count_by_repo=count_by_repo) + + +@router.delete("/packs/{pack_id}", response_model=OkResponse) +async def delete_skill_pack( + pack_id: UUID, + session: AsyncSession = SESSION_DEP, + ctx: OrganizationContext = ORG_ADMIN_DEP, +) -> OkResponse: + """Delete one pack source from the organization.""" + pack = await _require_skill_pack_for_org(pack_id=pack_id, session=session, ctx=ctx) + await session.delete(pack) + await session.commit() + return OkResponse() + + +@router.post("/packs/{pack_id}/sync", response_model=SkillPackSyncResponse) +async def sync_skill_pack( + pack_id: UUID, + session: AsyncSession = SESSION_DEP, + ctx: OrganizationContext = ORG_ADMIN_DEP, +) -> SkillPackSyncResponse: + """Clone a pack repository and upsert discovered skills from `skills/**/SKILL.md`.""" + pack = await _require_skill_pack_for_org(pack_id=pack_id, session=session, ctx=ctx) + + try: + _validate_pack_source_url(pack.source_url) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + + try: + discovered = _collect_pack_skills( + source_url=pack.source_url, + ) + except RuntimeError as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=str(exc), + ) from exc + + existing_skills = await MarketplaceSkill.objects.filter_by( + organization_id=ctx.organization.id, + ).all(session) + existing_by_source = {skill.source_url: skill for skill in existing_skills} + + created = 0 + updated = 0 + for candidate in discovered: + existing = existing_by_source.get(candidate.source_url) + if existing is None: + session.add( + MarketplaceSkill( + organization_id=ctx.organization.id, + source_url=candidate.source_url, + name=candidate.name, + description=candidate.description, + category=candidate.category, + risk=candidate.risk, + source=candidate.source, + metadata_=candidate.metadata or {}, + ), + ) + created += 1 + continue + + changed = _apply_pack_candidate_updates(existing=existing, candidate=candidate) + if changed: + existing.updated_at = utcnow() + session.add(existing) + updated += 1 + + await session.commit() + + return SkillPackSyncResponse( + pack_id=pack.id, + synced=len(discovered), + created=created, + updated=updated, + warnings=[], + ) diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index 8a28282..2ff74d7 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -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) diff --git a/backend/app/main.py b/backend/app/main.py index c5fb346..8de746c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 1f5c29c..e3f8f71 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", diff --git a/backend/app/models/gateway_installed_skills.py b/backend/app/models/gateway_installed_skills.py new file mode 100644 index 0000000..2222395 --- /dev/null +++ b/backend/app/models/gateway_installed_skills.py @@ -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) diff --git a/backend/app/models/marketplace_skills.py b/backend/app/models/marketplace_skills.py new file mode 100644 index 0000000..e4139ff --- /dev/null +++ b/backend/app/models/marketplace_skills.py @@ -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) diff --git a/backend/app/models/skill_packs.py b/backend/app/models/skill_packs.py new file mode 100644 index 0000000..99b641c --- /dev/null +++ b/backend/app/models/skill_packs.py @@ -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) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 0843a44..163e258 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -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", diff --git a/backend/app/schemas/skills_marketplace.py b/backend/app/schemas/skills_marketplace.py new file mode 100644 index 0000000..edcabba --- /dev/null +++ b/backend/app/schemas/skills_marketplace.py @@ -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) diff --git a/backend/app/services/organizations.py b/backend/app/services/organizations.py index b39dcfb..0e4d14c 100644 --- a/backend/app/services/organizations.py +++ b/backend/app/services/organizations.py @@ -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 diff --git a/backend/migrations/versions/c9d7e9b6a4f2_add_skills_marketplace_tables.py b/backend/migrations/versions/c9d7e9b6a4f2_add_skills_marketplace_tables.py new file mode 100644 index 0000000..f21e174 --- /dev/null +++ b/backend/migrations/versions/c9d7e9b6a4f2_add_skills_marketplace_tables.py @@ -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") diff --git a/backend/tests/test_error_handling.py b/backend/tests/test_error_handling.py index 1063968..02dd99b 100644 --- a/backend/tests/test_error_handling.py +++ b/backend/tests/test_error_handling.py @@ -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, ) diff --git a/backend/tests/test_organizations_service.py b/backend/tests/test_organizations_service.py index 8618496..ea3c940 100644 --- a/backend/tests/test_organizations_service.py +++ b/backend/tests/test_organizations_service.py @@ -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 diff --git a/backend/tests/test_skills_marketplace_api.py b/backend/tests/test_skills_marketplace_api.py new file mode 100644 index 0000000..b9c5b11 --- /dev/null +++ b/backend/tests/test_skills_marketplace_api.py @@ -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" diff --git a/backend/tests/test_tasks_blocked_lead_transitions.py b/backend/tests/test_tasks_blocked_lead_transitions.py index 9f5230b..be35780 100644 --- a/backend/tests/test_tasks_blocked_lead_transitions.py +++ b/backend/tests/test_tasks_blocked_lead_transitions.py @@ -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 diff --git a/frontend/src/api/generated/model/approvalCreate.ts b/frontend/src/api/generated/model/approvalCreate.ts index 0f2a963..320c7a0 100644 --- a/frontend/src/api/generated/model/approvalCreate.ts +++ b/frontend/src/api/generated/model/approvalCreate.ts @@ -19,6 +19,7 @@ export interface ApprovalCreate { * @maximum 100 */ confidence: number; + lead_reasoning?: string | null; payload?: ApprovalCreatePayload; rubric_scores?: ApprovalCreateRubricScores; status?: ApprovalCreateStatus; diff --git a/frontend/src/api/generated/model/index.ts b/frontend/src/api/generated/model/index.ts index da94c88..f4c7f49 100644 --- a/frontend/src/api/generated/model/index.ts +++ b/frontend/src/api/generated/model/index.ts @@ -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"; diff --git a/frontend/src/api/generated/model/installMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostParams.ts b/frontend/src/api/generated/model/installMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostParams.ts new file mode 100644 index 0000000..6672a93 --- /dev/null +++ b/frontend/src/api/generated/model/installMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostParams.ts @@ -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; + }; diff --git a/frontend/src/api/generated/model/listMarketplaceSkillsApiV1SkillsMarketplaceGetParams.ts b/frontend/src/api/generated/model/listMarketplaceSkillsApiV1SkillsMarketplaceGetParams.ts new file mode 100644 index 0000000..8c3ba18 --- /dev/null +++ b/frontend/src/api/generated/model/listMarketplaceSkillsApiV1SkillsMarketplaceGetParams.ts @@ -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; +}; diff --git a/frontend/src/api/generated/model/marketplaceSkillActionResponse.ts b/frontend/src/api/generated/model/marketplaceSkillActionResponse.ts new file mode 100644 index 0000000..218a078 --- /dev/null +++ b/frontend/src/api/generated/model/marketplaceSkillActionResponse.ts @@ -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; +} diff --git a/frontend/src/api/generated/model/marketplaceSkillCardRead.ts b/frontend/src/api/generated/model/marketplaceSkillCardRead.ts new file mode 100644 index 0000000..f63b94c --- /dev/null +++ b/frontend/src/api/generated/model/marketplaceSkillCardRead.ts @@ -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; +} diff --git a/frontend/src/api/generated/model/marketplaceSkillCreate.ts b/frontend/src/api/generated/model/marketplaceSkillCreate.ts new file mode 100644 index 0000000..8b9f676 --- /dev/null +++ b/frontend/src/api/generated/model/marketplaceSkillCreate.ts @@ -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; +} diff --git a/frontend/src/api/generated/model/marketplaceSkillRead.ts b/frontend/src/api/generated/model/marketplaceSkillRead.ts new file mode 100644 index 0000000..da7673f --- /dev/null +++ b/frontend/src/api/generated/model/marketplaceSkillRead.ts @@ -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; +} diff --git a/frontend/src/api/generated/model/skillPackCreate.ts b/frontend/src/api/generated/model/skillPackCreate.ts new file mode 100644 index 0000000..847cf61 --- /dev/null +++ b/frontend/src/api/generated/model/skillPackCreate.ts @@ -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; + /** @minLength 1 */ + source_url: string; +} diff --git a/frontend/src/api/generated/model/skillPackRead.ts b/frontend/src/api/generated/model/skillPackRead.ts new file mode 100644 index 0000000..f615260 --- /dev/null +++ b/frontend/src/api/generated/model/skillPackRead.ts @@ -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; + skill_count?: number; + source_url: string; + updated_at: string; +} diff --git a/frontend/src/api/generated/model/skillPackSyncResponse.ts b/frontend/src/api/generated/model/skillPackSyncResponse.ts new file mode 100644 index 0000000..8f0f099 --- /dev/null +++ b/frontend/src/api/generated/model/skillPackSyncResponse.ts @@ -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; +} diff --git a/frontend/src/api/generated/model/uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostParams.ts b/frontend/src/api/generated/model/uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostParams.ts new file mode 100644 index 0000000..d5a5a8d --- /dev/null +++ b/frontend/src/api/generated/model/uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostParams.ts @@ -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; + }; diff --git a/frontend/src/api/generated/skills-marketplace/skills-marketplace.ts b/frontend/src/api/generated/skills-marketplace/skills-marketplace.ts new file mode 100644 index 0000000..9dcea69 --- /dev/null +++ b/frontend/src/api/generated/skills-marketplace/skills-marketplace.ts @@ -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 unknown> = Parameters[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 => { + return customFetch( + 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 + >, + TError = HTTPValidationError, +>( + params: ListMarketplaceSkillsApiV1SkillsMarketplaceGetParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? + getListMarketplaceSkillsApiV1SkillsMarketplaceGetQueryKey(params); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => + listMarketplaceSkillsApiV1SkillsMarketplaceGet(params, { + signal, + ...requestOptions, + }); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type ListMarketplaceSkillsApiV1SkillsMarketplaceGetQueryResult = + NonNullable< + Awaited> + >; +export type ListMarketplaceSkillsApiV1SkillsMarketplaceGetQueryError = + HTTPValidationError; + +export function useListMarketplaceSkillsApiV1SkillsMarketplaceGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + params: ListMarketplaceSkillsApiV1SkillsMarketplaceGetParams, + options: { + query: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited< + ReturnType + >, + TError, + Awaited< + ReturnType + > + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useListMarketplaceSkillsApiV1SkillsMarketplaceGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + params: ListMarketplaceSkillsApiV1SkillsMarketplaceGetParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited< + ReturnType + >, + TError, + Awaited< + ReturnType + > + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useListMarketplaceSkillsApiV1SkillsMarketplaceGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + params: ListMarketplaceSkillsApiV1SkillsMarketplaceGetParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +/** + * @summary List Marketplace Skills + */ + +export function useListMarketplaceSkillsApiV1SkillsMarketplaceGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + params: ListMarketplaceSkillsApiV1SkillsMarketplaceGetParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = + getListMarketplaceSkillsApiV1SkillsMarketplaceGetQueryOptions( + params, + options, + ); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + 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 => { + return customFetch( + getCreateMarketplaceSkillApiV1SkillsMarketplacePostUrl(), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(marketplaceSkillCreate), + }, + ); +}; + +export const getCreateMarketplaceSkillApiV1SkillsMarketplacePostMutationOptions = + (options?: { + mutation?: UseMutationOptions< + Awaited< + ReturnType + >, + TError, + { data: MarketplaceSkillCreate }, + TContext + >; + request?: SecondParameter; + }): UseMutationOptions< + Awaited< + ReturnType + >, + 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 + >, + { data: MarketplaceSkillCreate } + > = (props) => { + const { data } = props ?? {}; + + return createMarketplaceSkillApiV1SkillsMarketplacePost( + data, + requestOptions, + ); + }; + + return { mutationFn, ...mutationOptions }; + }; + +export type CreateMarketplaceSkillApiV1SkillsMarketplacePostMutationResult = + NonNullable< + Awaited> + >; +export type CreateMarketplaceSkillApiV1SkillsMarketplacePostMutationBody = + MarketplaceSkillCreate; +export type CreateMarketplaceSkillApiV1SkillsMarketplacePostMutationError = + HTTPValidationError; + +/** + * @summary Create Marketplace Skill + */ +export const useCreateMarketplaceSkillApiV1SkillsMarketplacePost = < + TError = HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited< + ReturnType + >, + TError, + { data: MarketplaceSkillCreate }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + 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 => { + return customFetch( + getDeleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDeleteUrl(skillId), + { + ...options, + method: "DELETE", + }, + ); +}; + +export const getDeleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDeleteMutationOptions = + (options?: { + mutation?: UseMutationOptions< + Awaited< + ReturnType< + typeof deleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDelete + > + >, + TError, + { skillId: string }, + TContext + >; + request?: SecondParameter; + }): 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; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited< + ReturnType + >, + 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 => { + return customFetch( + getInstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostUrl( + skillId, + params, + ), + { + ...options, + method: "POST", + }, + ); + }; + +export const getInstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostMutationOptions = + (options?: { + mutation?: UseMutationOptions< + Awaited< + ReturnType< + typeof installMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPost + > + >, + TError, + { + skillId: string; + params: InstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostParams; + }, + TContext + >; + request?: SecondParameter; + }): 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 = + ( + options?: { + mutation?: UseMutationOptions< + Awaited< + ReturnType< + typeof installMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPost + > + >, + TError, + { + skillId: string; + params: InstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostParams; + }, + TContext + >; + request?: SecondParameter; + }, + 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 => { + return customFetch( + getUninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostUrl( + skillId, + params, + ), + { + ...options, + method: "POST", + }, + ); + }; + +export const getUninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostMutationOptions = + (options?: { + mutation?: UseMutationOptions< + Awaited< + ReturnType< + typeof uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPost + > + >, + TError, + { + skillId: string; + params: UninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostParams; + }, + TContext + >; + request?: SecondParameter; + }): 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 = + ( + options?: { + mutation?: UseMutationOptions< + Awaited< + ReturnType< + typeof uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPost + > + >, + TError, + { + skillId: string; + params: UninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostParams; + }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, + ): UseMutationResult< + Awaited< + ReturnType< + typeof uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPost + > + >, + TError, + { + skillId: string; + params: UninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostParams; + }, + TContext + > => { + return useMutation( + getUninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostMutationOptions( + options, + ), + queryClient, + ); + }; diff --git a/frontend/src/api/generated/skills/skills.ts b/frontend/src/api/generated/skills/skills.ts new file mode 100644 index 0000000..1e34e5e --- /dev/null +++ b/frontend/src/api/generated/skills/skills.ts @@ -0,0 +1,1810 @@ +/** + * 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, + SkillPackCreate, + SkillPackRead, + SkillPackSyncResponse, + UninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostParams, +} from ".././model"; + +import { customFetch } from "../../mutator"; + +type SecondParameter unknown> = Parameters[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 => { + return customFetch( + 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 + >, + TError = HTTPValidationError, +>( + params: ListMarketplaceSkillsApiV1SkillsMarketplaceGetParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? + getListMarketplaceSkillsApiV1SkillsMarketplaceGetQueryKey(params); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => + listMarketplaceSkillsApiV1SkillsMarketplaceGet(params, { + signal, + ...requestOptions, + }); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type ListMarketplaceSkillsApiV1SkillsMarketplaceGetQueryResult = + NonNullable< + Awaited> + >; +export type ListMarketplaceSkillsApiV1SkillsMarketplaceGetQueryError = + HTTPValidationError; + +export function useListMarketplaceSkillsApiV1SkillsMarketplaceGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + params: ListMarketplaceSkillsApiV1SkillsMarketplaceGetParams, + options: { + query: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited< + ReturnType + >, + TError, + Awaited< + ReturnType + > + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useListMarketplaceSkillsApiV1SkillsMarketplaceGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + params: ListMarketplaceSkillsApiV1SkillsMarketplaceGetParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited< + ReturnType + >, + TError, + Awaited< + ReturnType + > + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useListMarketplaceSkillsApiV1SkillsMarketplaceGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + params: ListMarketplaceSkillsApiV1SkillsMarketplaceGetParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +/** + * @summary List Marketplace Skills + */ + +export function useListMarketplaceSkillsApiV1SkillsMarketplaceGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + params: ListMarketplaceSkillsApiV1SkillsMarketplaceGetParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = + getListMarketplaceSkillsApiV1SkillsMarketplaceGetQueryOptions( + params, + options, + ); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + +/** + * Register or update a direct marketplace skill URL in the 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 => { + return customFetch( + getCreateMarketplaceSkillApiV1SkillsMarketplacePostUrl(), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(marketplaceSkillCreate), + }, + ); +}; + +export const getCreateMarketplaceSkillApiV1SkillsMarketplacePostMutationOptions = + (options?: { + mutation?: UseMutationOptions< + Awaited< + ReturnType + >, + TError, + { data: MarketplaceSkillCreate }, + TContext + >; + request?: SecondParameter; + }): UseMutationOptions< + Awaited< + ReturnType + >, + 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 + >, + { data: MarketplaceSkillCreate } + > = (props) => { + const { data } = props ?? {}; + + return createMarketplaceSkillApiV1SkillsMarketplacePost( + data, + requestOptions, + ); + }; + + return { mutationFn, ...mutationOptions }; + }; + +export type CreateMarketplaceSkillApiV1SkillsMarketplacePostMutationResult = + NonNullable< + Awaited> + >; +export type CreateMarketplaceSkillApiV1SkillsMarketplacePostMutationBody = + MarketplaceSkillCreate; +export type CreateMarketplaceSkillApiV1SkillsMarketplacePostMutationError = + HTTPValidationError; + +/** + * @summary Create Marketplace Skill + */ +export const useCreateMarketplaceSkillApiV1SkillsMarketplacePost = < + TError = HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited< + ReturnType + >, + TError, + { data: MarketplaceSkillCreate }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + 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 => { + return customFetch( + getDeleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDeleteUrl(skillId), + { + ...options, + method: "DELETE", + }, + ); +}; + +export const getDeleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDeleteMutationOptions = + (options?: { + mutation?: UseMutationOptions< + Awaited< + ReturnType< + typeof deleteMarketplaceSkillApiV1SkillsMarketplaceSkillIdDelete + > + >, + TError, + { skillId: string }, + TContext + >; + request?: SecondParameter; + }): 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; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited< + ReturnType + >, + 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 => { + return customFetch( + getInstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostUrl( + skillId, + params, + ), + { + ...options, + method: "POST", + }, + ); + }; + +export const getInstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostMutationOptions = + (options?: { + mutation?: UseMutationOptions< + Awaited< + ReturnType< + typeof installMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPost + > + >, + TError, + { + skillId: string; + params: InstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostParams; + }, + TContext + >; + request?: SecondParameter; + }): 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 = + ( + options?: { + mutation?: UseMutationOptions< + Awaited< + ReturnType< + typeof installMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPost + > + >, + TError, + { + skillId: string; + params: InstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPostParams; + }, + TContext + >; + request?: SecondParameter; + }, + 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 => { + return customFetch( + getUninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostUrl( + skillId, + params, + ), + { + ...options, + method: "POST", + }, + ); + }; + +export const getUninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostMutationOptions = + (options?: { + mutation?: UseMutationOptions< + Awaited< + ReturnType< + typeof uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPost + > + >, + TError, + { + skillId: string; + params: UninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostParams; + }, + TContext + >; + request?: SecondParameter; + }): 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 = + ( + options?: { + mutation?: UseMutationOptions< + Awaited< + ReturnType< + typeof uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPost + > + >, + TError, + { + skillId: string; + params: UninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostParams; + }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, + ): UseMutationResult< + Awaited< + ReturnType< + typeof uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPost + > + >, + TError, + { + skillId: string; + params: UninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostParams; + }, + TContext + > => { + return useMutation( + getUninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostMutationOptions( + options, + ), + queryClient, + ); + }; +/** + * List skill packs configured for the organization. + * @summary List Skill Packs + */ +export type listSkillPacksApiV1SkillsPacksGetResponse200 = { + data: SkillPackRead[]; + status: 200; +}; + +export type listSkillPacksApiV1SkillsPacksGetResponseSuccess = + listSkillPacksApiV1SkillsPacksGetResponse200 & { + headers: Headers; + }; +export type listSkillPacksApiV1SkillsPacksGetResponse = + listSkillPacksApiV1SkillsPacksGetResponseSuccess; + +export const getListSkillPacksApiV1SkillsPacksGetUrl = () => { + return `/api/v1/skills/packs`; +}; + +export const listSkillPacksApiV1SkillsPacksGet = async ( + options?: RequestInit, +): Promise => { + return customFetch( + getListSkillPacksApiV1SkillsPacksGetUrl(), + { + ...options, + method: "GET", + }, + ); +}; + +export const getListSkillPacksApiV1SkillsPacksGetQueryKey = () => { + return [`/api/v1/skills/packs`] as const; +}; + +export const getListSkillPacksApiV1SkillsPacksGetQueryOptions = < + TData = Awaited>, + TError = unknown, +>(options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; +}) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? getListSkillPacksApiV1SkillsPacksGetQueryKey(); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => + listSkillPacksApiV1SkillsPacksGet({ signal, ...requestOptions }); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type ListSkillPacksApiV1SkillsPacksGetQueryResult = NonNullable< + Awaited> +>; +export type ListSkillPacksApiV1SkillsPacksGetQueryError = unknown; + +export function useListSkillPacksApiV1SkillsPacksGet< + TData = Awaited>, + TError = unknown, +>( + options: { + query: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useListSkillPacksApiV1SkillsPacksGet< + TData = Awaited>, + TError = unknown, +>( + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useListSkillPacksApiV1SkillsPacksGet< + TData = Awaited>, + TError = unknown, +>( + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +/** + * @summary List Skill Packs + */ + +export function useListSkillPacksApiV1SkillsPacksGet< + TData = Awaited>, + TError = unknown, +>( + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = + getListSkillPacksApiV1SkillsPacksGetQueryOptions(options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + +/** + * Register a new skill pack source URL. + * @summary Create Skill Pack + */ +export type createSkillPackApiV1SkillsPacksPostResponse200 = { + data: SkillPackRead; + status: 200; +}; + +export type createSkillPackApiV1SkillsPacksPostResponse422 = { + data: HTTPValidationError; + status: 422; +}; + +export type createSkillPackApiV1SkillsPacksPostResponseSuccess = + createSkillPackApiV1SkillsPacksPostResponse200 & { + headers: Headers; + }; +export type createSkillPackApiV1SkillsPacksPostResponseError = + createSkillPackApiV1SkillsPacksPostResponse422 & { + headers: Headers; + }; + +export type createSkillPackApiV1SkillsPacksPostResponse = + | createSkillPackApiV1SkillsPacksPostResponseSuccess + | createSkillPackApiV1SkillsPacksPostResponseError; + +export const getCreateSkillPackApiV1SkillsPacksPostUrl = () => { + return `/api/v1/skills/packs`; +}; + +export const createSkillPackApiV1SkillsPacksPost = async ( + skillPackCreate: SkillPackCreate, + options?: RequestInit, +): Promise => { + return customFetch( + getCreateSkillPackApiV1SkillsPacksPostUrl(), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(skillPackCreate), + }, + ); +}; + +export const getCreateSkillPackApiV1SkillsPacksPostMutationOptions = < + TError = HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: SkillPackCreate }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { data: SkillPackCreate }, + TContext +> => { + const mutationKey = ["createSkillPackApiV1SkillsPacksPost"]; + 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>, + { data: SkillPackCreate } + > = (props) => { + const { data } = props ?? {}; + + return createSkillPackApiV1SkillsPacksPost(data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type CreateSkillPackApiV1SkillsPacksPostMutationResult = NonNullable< + Awaited> +>; +export type CreateSkillPackApiV1SkillsPacksPostMutationBody = SkillPackCreate; +export type CreateSkillPackApiV1SkillsPacksPostMutationError = + HTTPValidationError; + +/** + * @summary Create Skill Pack + */ +export const useCreateSkillPackApiV1SkillsPacksPost = < + TError = HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: SkillPackCreate }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { data: SkillPackCreate }, + TContext +> => { + return useMutation( + getCreateSkillPackApiV1SkillsPacksPostMutationOptions(options), + queryClient, + ); +}; +/** + * Delete one pack source from the organization. + * @summary Delete Skill Pack + */ +export type deleteSkillPackApiV1SkillsPacksPackIdDeleteResponse200 = { + data: OkResponse; + status: 200; +}; + +export type deleteSkillPackApiV1SkillsPacksPackIdDeleteResponse422 = { + data: HTTPValidationError; + status: 422; +}; + +export type deleteSkillPackApiV1SkillsPacksPackIdDeleteResponseSuccess = + deleteSkillPackApiV1SkillsPacksPackIdDeleteResponse200 & { + headers: Headers; + }; +export type deleteSkillPackApiV1SkillsPacksPackIdDeleteResponseError = + deleteSkillPackApiV1SkillsPacksPackIdDeleteResponse422 & { + headers: Headers; + }; + +export type deleteSkillPackApiV1SkillsPacksPackIdDeleteResponse = + | deleteSkillPackApiV1SkillsPacksPackIdDeleteResponseSuccess + | deleteSkillPackApiV1SkillsPacksPackIdDeleteResponseError; + +export const getDeleteSkillPackApiV1SkillsPacksPackIdDeleteUrl = ( + packId: string, +) => { + return `/api/v1/skills/packs/${packId}`; +}; + +export const deleteSkillPackApiV1SkillsPacksPackIdDelete = async ( + packId: string, + options?: RequestInit, +): Promise => { + return customFetch( + getDeleteSkillPackApiV1SkillsPacksPackIdDeleteUrl(packId), + { + ...options, + method: "DELETE", + }, + ); +}; + +export const getDeleteSkillPackApiV1SkillsPacksPackIdDeleteMutationOptions = < + TError = HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { packId: string }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { packId: string }, + TContext +> => { + const mutationKey = ["deleteSkillPackApiV1SkillsPacksPackIdDelete"]; + 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>, + { packId: string } + > = (props) => { + const { packId } = props ?? {}; + + return deleteSkillPackApiV1SkillsPacksPackIdDelete(packId, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type DeleteSkillPackApiV1SkillsPacksPackIdDeleteMutationResult = + NonNullable< + Awaited> + >; + +export type DeleteSkillPackApiV1SkillsPacksPackIdDeleteMutationError = + HTTPValidationError; + +/** + * @summary Delete Skill Pack + */ +export const useDeleteSkillPackApiV1SkillsPacksPackIdDelete = < + TError = HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { packId: string }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { packId: string }, + TContext +> => { + return useMutation( + getDeleteSkillPackApiV1SkillsPacksPackIdDeleteMutationOptions(options), + queryClient, + ); +}; +/** + * Get one skill pack by ID. + * @summary Get Skill Pack + */ +export type getSkillPackApiV1SkillsPacksPackIdGetResponse200 = { + data: SkillPackRead; + status: 200; +}; + +export type getSkillPackApiV1SkillsPacksPackIdGetResponse422 = { + data: HTTPValidationError; + status: 422; +}; + +export type getSkillPackApiV1SkillsPacksPackIdGetResponseSuccess = + getSkillPackApiV1SkillsPacksPackIdGetResponse200 & { + headers: Headers; + }; +export type getSkillPackApiV1SkillsPacksPackIdGetResponseError = + getSkillPackApiV1SkillsPacksPackIdGetResponse422 & { + headers: Headers; + }; + +export type getSkillPackApiV1SkillsPacksPackIdGetResponse = + | getSkillPackApiV1SkillsPacksPackIdGetResponseSuccess + | getSkillPackApiV1SkillsPacksPackIdGetResponseError; + +export const getGetSkillPackApiV1SkillsPacksPackIdGetUrl = (packId: string) => { + return `/api/v1/skills/packs/${packId}`; +}; + +export const getSkillPackApiV1SkillsPacksPackIdGet = async ( + packId: string, + options?: RequestInit, +): Promise => { + return customFetch( + getGetSkillPackApiV1SkillsPacksPackIdGetUrl(packId), + { + ...options, + method: "GET", + }, + ); +}; + +export const getGetSkillPackApiV1SkillsPacksPackIdGetQueryKey = ( + packId: string, +) => { + return [`/api/v1/skills/packs/${packId}`] as const; +}; + +export const getGetSkillPackApiV1SkillsPacksPackIdGetQueryOptions = < + TData = Awaited>, + TError = HTTPValidationError, +>( + packId: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? + getGetSkillPackApiV1SkillsPacksPackIdGetQueryKey(packId); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => + getSkillPackApiV1SkillsPacksPackIdGet(packId, { + signal, + ...requestOptions, + }); + + return { + queryKey, + queryFn, + enabled: !!packId, + ...queryOptions, + } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type GetSkillPackApiV1SkillsPacksPackIdGetQueryResult = NonNullable< + Awaited> +>; +export type GetSkillPackApiV1SkillsPacksPackIdGetQueryError = + HTTPValidationError; + +export function useGetSkillPackApiV1SkillsPacksPackIdGet< + TData = Awaited>, + TError = HTTPValidationError, +>( + packId: string, + options: { + query: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useGetSkillPackApiV1SkillsPacksPackIdGet< + TData = Awaited>, + TError = HTTPValidationError, +>( + packId: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useGetSkillPackApiV1SkillsPacksPackIdGet< + TData = Awaited>, + TError = HTTPValidationError, +>( + packId: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +/** + * @summary Get Skill Pack + */ + +export function useGetSkillPackApiV1SkillsPacksPackIdGet< + TData = Awaited>, + TError = HTTPValidationError, +>( + packId: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = getGetSkillPackApiV1SkillsPacksPackIdGetQueryOptions( + packId, + options, + ); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + +/** + * Update a skill pack URL and metadata. + * @summary Update Skill Pack + */ +export type updateSkillPackApiV1SkillsPacksPackIdPatchResponse200 = { + data: SkillPackRead; + status: 200; +}; + +export type updateSkillPackApiV1SkillsPacksPackIdPatchResponse422 = { + data: HTTPValidationError; + status: 422; +}; + +export type updateSkillPackApiV1SkillsPacksPackIdPatchResponseSuccess = + updateSkillPackApiV1SkillsPacksPackIdPatchResponse200 & { + headers: Headers; + }; +export type updateSkillPackApiV1SkillsPacksPackIdPatchResponseError = + updateSkillPackApiV1SkillsPacksPackIdPatchResponse422 & { + headers: Headers; + }; + +export type updateSkillPackApiV1SkillsPacksPackIdPatchResponse = + | updateSkillPackApiV1SkillsPacksPackIdPatchResponseSuccess + | updateSkillPackApiV1SkillsPacksPackIdPatchResponseError; + +export const getUpdateSkillPackApiV1SkillsPacksPackIdPatchUrl = ( + packId: string, +) => { + return `/api/v1/skills/packs/${packId}`; +}; + +export const updateSkillPackApiV1SkillsPacksPackIdPatch = async ( + packId: string, + skillPackCreate: SkillPackCreate, + options?: RequestInit, +): Promise => { + return customFetch( + getUpdateSkillPackApiV1SkillsPacksPackIdPatchUrl(packId), + { + ...options, + method: "PATCH", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(skillPackCreate), + }, + ); +}; + +export const getUpdateSkillPackApiV1SkillsPacksPackIdPatchMutationOptions = < + TError = HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { packId: string; data: SkillPackCreate }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { packId: string; data: SkillPackCreate }, + TContext +> => { + const mutationKey = ["updateSkillPackApiV1SkillsPacksPackIdPatch"]; + 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>, + { packId: string; data: SkillPackCreate } + > = (props) => { + const { packId, data } = props ?? {}; + + return updateSkillPackApiV1SkillsPacksPackIdPatch( + packId, + data, + requestOptions, + ); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type UpdateSkillPackApiV1SkillsPacksPackIdPatchMutationResult = + NonNullable< + Awaited> + >; +export type UpdateSkillPackApiV1SkillsPacksPackIdPatchMutationBody = + SkillPackCreate; +export type UpdateSkillPackApiV1SkillsPacksPackIdPatchMutationError = + HTTPValidationError; + +/** + * @summary Update Skill Pack + */ +export const useUpdateSkillPackApiV1SkillsPacksPackIdPatch = < + TError = HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { packId: string; data: SkillPackCreate }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { packId: string; data: SkillPackCreate }, + TContext +> => { + return useMutation( + getUpdateSkillPackApiV1SkillsPacksPackIdPatchMutationOptions(options), + queryClient, + ); +}; +/** + * Clone a pack repository and upsert discovered skills from `skills/**\/SKILL.md`. + * @summary Sync Skill Pack + */ +export type syncSkillPackApiV1SkillsPacksPackIdSyncPostResponse200 = { + data: SkillPackSyncResponse; + status: 200; +}; + +export type syncSkillPackApiV1SkillsPacksPackIdSyncPostResponse422 = { + data: HTTPValidationError; + status: 422; +}; + +export type syncSkillPackApiV1SkillsPacksPackIdSyncPostResponseSuccess = + syncSkillPackApiV1SkillsPacksPackIdSyncPostResponse200 & { + headers: Headers; + }; +export type syncSkillPackApiV1SkillsPacksPackIdSyncPostResponseError = + syncSkillPackApiV1SkillsPacksPackIdSyncPostResponse422 & { + headers: Headers; + }; + +export type syncSkillPackApiV1SkillsPacksPackIdSyncPostResponse = + | syncSkillPackApiV1SkillsPacksPackIdSyncPostResponseSuccess + | syncSkillPackApiV1SkillsPacksPackIdSyncPostResponseError; + +export const getSyncSkillPackApiV1SkillsPacksPackIdSyncPostUrl = ( + packId: string, +) => { + return `/api/v1/skills/packs/${packId}/sync`; +}; + +export const syncSkillPackApiV1SkillsPacksPackIdSyncPost = async ( + packId: string, + options?: RequestInit, +): Promise => { + return customFetch( + getSyncSkillPackApiV1SkillsPacksPackIdSyncPostUrl(packId), + { + ...options, + method: "POST", + }, + ); +}; + +export const getSyncSkillPackApiV1SkillsPacksPackIdSyncPostMutationOptions = < + TError = HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { packId: string }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { packId: string }, + TContext +> => { + const mutationKey = ["syncSkillPackApiV1SkillsPacksPackIdSyncPost"]; + 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>, + { packId: string } + > = (props) => { + const { packId } = props ?? {}; + + return syncSkillPackApiV1SkillsPacksPackIdSyncPost(packId, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type SyncSkillPackApiV1SkillsPacksPackIdSyncPostMutationResult = + NonNullable< + Awaited> + >; + +export type SyncSkillPackApiV1SkillsPacksPackIdSyncPostMutationError = + HTTPValidationError; + +/** + * @summary Sync Skill Pack + */ +export const useSyncSkillPackApiV1SkillsPacksPackIdSyncPost = < + TError = HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { packId: string }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { packId: string }, + TContext +> => { + return useMutation( + getSyncSkillPackApiV1SkillsPacksPackIdSyncPostMutationOptions(options), + queryClient, + ); +}; diff --git a/frontend/src/app/skills/marketplace/[skillId]/edit/page.tsx b/frontend/src/app/skills/marketplace/[skillId]/edit/page.tsx new file mode 100644 index 0000000..f72c638 --- /dev/null +++ b/frontend/src/app/skills/marketplace/[skillId]/edit/page.tsx @@ -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"); +} diff --git a/frontend/src/app/skills/marketplace/new/page.tsx b/frontend/src/app/skills/marketplace/new/page.tsx new file mode 100644 index 0000000..5b9349d --- /dev/null +++ b/frontend/src/app/skills/marketplace/new/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function NewMarketplaceSkillPage() { + redirect("/skills/packs/new"); +} diff --git a/frontend/src/app/skills/marketplace/page.tsx b/frontend/src/app/skills/marketplace/page.tsx new file mode 100644 index 0000000..95d1f3c --- /dev/null +++ b/frontend/src/app/skills/marketplace/page.tsx @@ -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 = { + 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(null); + const [gatewayInstalledById, setGatewayInstalledById] = useState< + Record + >({}); + const [installedGatewayNamesBySkillId, setInstalledGatewayNamesBySkillId] = + useState>({}); + const [isGatewayStatusLoading, setIsGatewayStatusLoading] = useState(false); + const [gatewayStatusError, setGatewayStatusError] = useState( + null, + ); + const [installingGatewayId, setInstallingGatewayId] = useState( + 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( + initialCategory || "all", + ); + const [selectedRisk, setSelectedRisk] = useState( + 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(() => { + 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(() => { + 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( + () => (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( + () => + 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(); + 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(); + 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( + { + 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( + { + 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 ( + <> + +
+ {gateways.length === 0 ? ( +
+

+ No gateways available yet. +

+

+ Create a gateway first, then return here to manage installs. +

+ + Create gateway + +
+ ) : ( + <> +
+
+
+ + setSearchTerm(event.target.value)} + placeholder="Search by name, description, category, pack, source..." + type="search" + /> +
+
+ + +
+
+ + +
+
+
+
+ +
+
+
+

+ Showing {rangeStart}-{rangeEnd} of {totalSkills} +

+
+ + Rows + + +
+
+
+ + + {totalCountInfo.hasKnownTotal + ? `Page ${currentPage} of ${totalPages}` + : `Page ${currentPage}`} + + +
+
+ + )} + + {skillsQuery.error ? ( +

{skillsQuery.error.message}

+ ) : null} + {packsQuery.error ? ( +

{packsQuery.error.message}

+ ) : null} + {mutationError ? ( +

{mutationError}

+ ) : null} +
+
+ + { + if (!open) { + setSelectedSkill(null); + } + }} + onToggleInstall={(gatewayId, isInstalled) => { + void handleGatewayInstallAction(gatewayId, isInstalled); + }} + /> + + ); +} diff --git a/frontend/src/app/skills/packs/[packId]/edit/page.tsx b/frontend/src/app/skills/packs/[packId]/edit/page.tsx new file mode 100644 index 0000000..76f46af --- /dev/null +++ b/frontend/src/app/skills/packs/[packId]/edit/page.tsx @@ -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(); + + return ( + + {packQuery.isLoading ? ( +
+ Loading pack... +
+ ) : packQuery.error ? ( +
+ {packQuery.error.message} +
+ ) : !pack ? ( +
+ Pack not found. +
+ ) : ( + 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"); + }} + /> + )} +
+ ); +} diff --git a/frontend/src/app/skills/packs/new/page.tsx b/frontend/src/app/skills/packs/new/page.tsx new file mode 100644 index 0000000..4490622 --- /dev/null +++ b/frontend/src/app/skills/packs/new/page.tsx @@ -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(); + + return ( + + 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"); + }} + /> + + ); +} diff --git a/frontend/src/app/skills/packs/page.tsx b/frontend/src/app/skills/packs/page.tsx new file mode 100644 index 0000000..8b35eb9 --- /dev/null +++ b/frontend/src/app/skills/packs/page.tsx @@ -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(null); + const [syncingPackIds, setSyncingPackIds] = useState>(new Set()); + const [isSyncingAll, setIsSyncingAll] = useState(false); + const [syncAllError, setSyncAllError] = useState(null); + const [syncWarnings, setSyncWarnings] = useState([]); + + 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( + () => (packsQuery.data?.status === 200 ? packsQuery.data.data : []), + [packsQuery.data], + ); + + const deleteMutation = + useDeleteSkillPackApiV1SkillsPacksPackIdDelete( + { + mutation: { + onSuccess: async () => { + setDeleteTarget(null); + await queryClient.invalidateQueries({ + queryKey: packsQueryKey, + }); + }, + }, + }, + queryClient, + ); + const syncMutation = useSyncSkillPackApiV1SkillsPacksPackIdSyncPost( + { + 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 ( + <> + + + + Add pack + + + ) : null + } + isAdmin={isAdmin} + adminOnlyMessage="Only organization owners and admins can manage skill packs." + stickyHeader + > +
+
+ `/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", + }} + /> +
+ + {packsQuery.error ? ( +

{packsQuery.error.message}

+ ) : null} + {deleteMutation.error ? ( +

+ {deleteMutation.error.message} +

+ ) : null} + {syncMutation.error ? ( +

+ {syncMutation.error.message} +

+ ) : null} + {syncAllError ? ( +

{syncAllError}

+ ) : null} + {syncWarnings.length > 0 ? ( +
+ {syncWarnings.map((warning) => ( +

+ {warning} +

+ ))} +
+ ) : null} +
+
+ + { + if (!open) setDeleteTarget(null); + }} + ariaLabel="Delete skill pack" + title="Delete skill pack" + description={ + <> + This will remove {deleteTarget?.name} from your + pack list. This action cannot be undone. + + } + errorMessage={deleteMutation.error?.message} + onConfirm={handleDelete} + isConfirming={deleteMutation.isPending} + /> + + ); +} diff --git a/frontend/src/app/skills/page.tsx b/frontend/src/app/skills/page.tsx new file mode 100644 index 0000000..d8b663f --- /dev/null +++ b/frontend/src/app/skills/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function SkillsIndexPage() { + redirect("/skills/marketplace"); +} diff --git a/frontend/src/components/organisms/DashboardSidebar.tsx b/frontend/src/components/organisms/DashboardSidebar.tsx index 8cee851..3c470af 100644 --- a/frontend/src/components/organisms/DashboardSidebar.tsx +++ b/frontend/src/components/organisms/DashboardSidebar.tsx @@ -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() { +
+ {isAdmin ? ( + <> +

+ Skills +

+
+ + + Marketplace + + + + Packs + +
+ + ) : null} +
+

Administration diff --git a/frontend/src/components/organisms/UserMenu.tsx b/frontend/src/components/organisms/UserMenu.tsx index 97f9743..e4e43de 100644 --- a/frontend/src/components/organisms/UserMenu.tsx +++ b/frontend/src/components/organisms/UserMenu.tsx @@ -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) => ( diff --git a/frontend/src/components/skills/MarketplaceSkillForm.tsx b/frontend/src/components/skills/MarketplaceSkillForm.tsx new file mode 100644 index 0000000..541e97e --- /dev/null +++ b/frontend/src/components/skills/MarketplaceSkillForm.tsx @@ -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; +}; + +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//).", + 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(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) => { + 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 ( +

+
+
+ + setSourceUrl(event.target.value)} + placeholder={sourcePlaceholder} + readOnly={sourceUrlReadOnly} + disabled={isSubmitting || sourceUrlReadOnly} + /> + {sourceUrlHelpText ? ( +

{sourceUrlHelpText}

+ ) : null} +
+ + {showBranch ? ( +
+ + setBranch(event.target.value)} + placeholder={branchPlaceholder} + disabled={isSubmitting} + /> +
+ ) : null} + +
+ + setName(event.target.value)} + placeholder={namePlaceholder} + disabled={isSubmitting} + /> +
+ +
+ +