diff --git a/console/src/api/modules/skill.ts b/console/src/api/modules/skill.ts index b6270eeba..48d718b9e 100644 --- a/console/src/api/modules/skill.ts +++ b/console/src/api/modules/skill.ts @@ -1,5 +1,14 @@ import { request } from "../request"; -import type { HubSkillSpec, SkillSpec } from "../types"; +import type { + HubSkillSpec, + InstallMarketplacePayload, + InstallSkillResult, + MarketplaceResponse, + SkillSpec, + SkillsMarketSpec, + SkillsMarketsPayload, + ValidateMarketResponse, +} from "../types"; // Declare BASE_URL as global (injected by Vite) declare const BASE_URL: string; @@ -54,12 +63,32 @@ export const skillApi = { enable?: boolean; overwrite?: boolean; }) => - request<{ - installed: boolean; - name: string; - enabled: boolean; - source_url: string; - }>("/skills/hub/install", { + request("/skills/hub/install", { + method: "POST", + body: JSON.stringify(payload), + }), + + getSkillsMarkets: () => request("/skills/markets"), + + updateSkillsMarkets: (payload: SkillsMarketsPayload) => + request("/skills/markets", { + method: "PUT", + body: JSON.stringify(payload), + }), + + validateSkillsMarket: (payload: SkillsMarketSpec) => + request("/skills/markets/validate", { + method: "POST", + body: JSON.stringify(payload), + }), + + getMarketplace: (refresh = false) => + request( + `/skills/marketplace?refresh=${refresh ? "true" : "false"}`, + ), + + installMarketplaceSkill: (payload: InstallMarketplacePayload) => + request("/skills/marketplace/install", { method: "POST", body: JSON.stringify(payload), }), diff --git a/console/src/api/types/skill.ts b/console/src/api/types/skill.ts index af2057494..1b29bddf4 100644 --- a/console/src/api/types/skill.ts +++ b/console/src/api/types/skill.ts @@ -15,6 +15,78 @@ export interface HubSkillSpec { source_url: string; } +export interface SkillsMarketSpec { + id: string; + name: string; + url: string; + branch?: string; + path: string; + enabled: boolean; + order: number; +} + +export interface SkillsMarketsPayload { + version: number; + cache: { + ttl_sec: number; + }; + install: { + overwrite_default: boolean; + }; + markets: SkillsMarketSpec[]; +} + +export interface ValidateMarketResponse { + ok: boolean; + normalized: SkillsMarketSpec; + warnings: string[]; +} + +export interface MarketError { + market_id: string; + code: string; + message: string; + retryable: boolean; +} + +export interface MarketplaceItem { + market_id: string; + skill_id: string; + name: string; + description: string; + version: string; + source_url: string; + install_url: string; + tags: string[]; +} + +export interface MarketplaceMeta { + refreshed_at: number; + cache_hit: boolean; + enabled_market_count: number; + success_market_count: number; +} + +export interface MarketplaceResponse { + items: MarketplaceItem[]; + market_errors: MarketError[]; + meta: MarketplaceMeta; +} + +export interface InstallMarketplacePayload { + market_id: string; + skill_id: string; + enable?: boolean; + overwrite?: boolean; +} + +export interface InstallSkillResult { + installed: boolean; + name: string; + enabled: boolean; + source_url: string; +} + // Legacy Skill interface for backward compatibility export interface Skill { id: string; diff --git a/console/src/locales/en.json b/console/src/locales/en.json index c235a5164..5b53d63ce 100644 --- a/console/src/locales/en.json +++ b/console/src/locales/en.json @@ -101,7 +101,50 @@ "stopOptimize": "Stop", "optimizeSuccess": "Skill optimized successfully", "optimizeFailed": "Failed to optimize skill", - "noContentToOptimize": "No content to optimize" + "noContentToOptimize": "No content to optimize", + "localSkillsTab": "Local Skills", + "marketplaceTab": "Marketplace", + "refreshMarketplace": "Refresh Marketplace", + "searchMarketplacePlaceholder": "Search by name, id, or tag", + "marketplaceEmpty": "No marketplace skills available", + "installFromMarketplace": "Install", + "viewSource": "View Source", + "marketCount": "Enabled markets {{enabled}} / {{total}}", + "marketRefreshSummary": "Fetched successfully {{success}} / {{total}}", + "manageMarkets": "Manage Markets", + "addMarket": "Add Market", + "removeMarket": "Remove", + "validateMarket": "Validate", + "noMarketsConfigured": "No markets configured", + "marketId": "Market ID", + "marketName": "Market Name", + "marketUrl": "Repository URL", + "marketBranch": "Branch (Optional)", + "marketPath": "Index Path", + "marketOrder": "Order", + "marketEnabled": "Enable this market", + "marketValidated": "Validated", + "marketPendingValidation": "Pending Validation", + "marketSaveRequiresValidation": "Validate all market entries before saving.", + "marketSaveRequiresFields": "Fill in market ID, name, and repository URL before saving.", + "marketSaveRequiresUniqueId": "Market IDs must be unique before saving.", + "marketUrlPlaceholder": "Supports owner/repo, .git URLs, or GitHub tree URLs", + "marketUrlExamples": "Examples: openclaw/openclaw or https://github.com/openclaw/openclaw/tree/main/skills", + "marketAutoParse": "Auto Parse", + "marketUrlAutoParsed": "URL auto-parsed. Branch: {{branch}}, Path: {{path}}", + "refreshingMarketplace": "Refreshing marketplace {{progress}}%", + "loadMarketsFailed": "Failed to load skill markets", + "validateMarketSuccess": "Validated market: {{name}}", + "validateMarketFailed": "Market validation failed", + "saveMarketsSuccess": "Skill markets updated", + "saveMarketsFailed": "Failed to update skill markets", + "loadMarketplaceFailed": "Failed to load marketplace", + "installMarketplaceSuccess": "Installed skill: {{name}}", + "installMarketplaceFailed": "Install failed", + "overwriteConfirmTitle": "Skill Already Installed", + "overwriteConfirmContent": "Skill \"{{name}}\" is already installed. Do you want to overwrite it?", + "overwriteConfirmOk": "Overwrite", + "overwriteConfirmCancel": "Cancel" }, "mcp": { "title": "MCP Clients", diff --git a/console/src/locales/zh.json b/console/src/locales/zh.json index 208191bbf..93548c28f 100644 --- a/console/src/locales/zh.json +++ b/console/src/locales/zh.json @@ -101,7 +101,50 @@ "stopOptimize": "停止", "optimizeSuccess": "技能优化成功", "optimizeFailed": "技能优化失败", - "noContentToOptimize": "没有可优化的内容" + "noContentToOptimize": "没有可优化的内容", + "localSkillsTab": "本地技能", + "marketplaceTab": "技能市场", + "refreshMarketplace": "刷新市场", + "searchMarketplacePlaceholder": "搜索技能名称、标识或标签", + "marketplaceEmpty": "当前没有可用的市场技能", + "installFromMarketplace": "安装", + "viewSource": "查看源码", + "marketCount": "已启用市场 {{enabled}} / {{total}}", + "marketRefreshSummary": "成功拉取 {{success}} / {{total}}", + "manageMarkets": "管理市场", + "addMarket": "新增市场", + "removeMarket": "移除", + "validateMarket": "校验", + "noMarketsConfigured": "尚未配置市场", + "marketId": "市场 ID", + "marketName": "市场名称", + "marketUrl": "仓库地址", + "marketBranch": "分支(可选)", + "marketPath": "索引路径", + "marketOrder": "排序", + "marketEnabled": "启用此市场", + "marketValidated": "已校验", + "marketPendingValidation": "待校验", + "marketSaveRequiresValidation": "保存前需要先校验所有市场配置。", + "marketSaveRequiresFields": "请先填写完整的市场 ID、名称和仓库地址。", + "marketSaveRequiresUniqueId": "市场 ID 不能重复,请修改后再保存。", + "marketUrlPlaceholder": "支持 owner/repo、.git 地址,或 GitHub tree URL", + "marketUrlExamples": "示例:openclaw/openclaw 或 https://github.com/openclaw/openclaw/tree/main/skills", + "marketAutoParse": "自动解析", + "marketUrlAutoParsed": "已自动解析 URL,分支:{{branch}},路径:{{path}}", + "refreshingMarketplace": "市场刷新中 {{progress}}%", + "loadMarketsFailed": "加载技能市场配置失败", + "validateMarketSuccess": "市场校验成功:{{name}}", + "validateMarketFailed": "市场校验失败", + "saveMarketsSuccess": "技能市场配置已更新", + "saveMarketsFailed": "更新技能市场配置失败", + "loadMarketplaceFailed": "加载技能市场失败", + "installMarketplaceSuccess": "技能安装成功:{{name}}", + "installMarketplaceFailed": "安装失败", + "overwriteConfirmTitle": "技能已安装", + "overwriteConfirmContent": "技能「{{name}}」已存在,是否强制覆盖安装?", + "overwriteConfirmOk": "强制安装", + "overwriteConfirmCancel": "取消" }, "mcp": { "title": "MCP 客户端", diff --git a/console/src/pages/Agent/Skills/index.module.less b/console/src/pages/Agent/Skills/index.module.less index de08a9a9c..0b3887789 100644 --- a/console/src/pages/Agent/Skills/index.module.less +++ b/console/src/pages/Agent/Skills/index.module.less @@ -9,6 +9,11 @@ margin-bottom: 16px; } +.headerActions { + display: flex; + gap: 8px; +} + .headerInfo { display: flex; flex-direction: column; @@ -26,6 +31,31 @@ font-size: 14px; } +.viewSwitch { + display: inline-flex; + padding: 4px; + border-radius: 10px; + border: 1px solid #eceff6; + background: #f7f8fc; + margin-bottom: 16px; +} + +.viewButton { + border: none; + background: transparent; + color: #667085; + font-size: 13px; + padding: 6px 12px; + border-radius: 8px; + cursor: pointer; +} + +.viewButtonActive { + color: #1f2937; + background: #fff; + box-shadow: 0 1px 3px rgba(16, 24, 40, 0.12); +} + .loading { text-align: center; padding: 60px; @@ -84,6 +114,369 @@ gap: 24px; } +.marketplaceSection { + display: flex; + flex-direction: column; + gap: 16px; +} + +.refreshProgressWrap { + display: flex; + flex-direction: column; + gap: 6px; +} + +.refreshProgressTrack { + width: 100%; + height: 6px; + border-radius: 999px; + background: #eef2ff; + overflow: hidden; +} + +.refreshProgressFill { + height: 100%; + border-radius: 999px; + background: linear-gradient(90deg, #615ced, #34c3ff); + transition: width 0.2s ease; +} + +.refreshProgressText { + font-size: 12px; + color: #667085; +} + +.marketplaceToolbar { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; +} + +.marketStats { + display: flex; + gap: 8px; + align-items: center; +} + +.marketSearchInput { + width: 320px; + max-width: 100%; + height: 36px; + padding: 0 12px; + border: 1px solid #d9d9d9; + border-radius: 8px; + outline: none; + + &:focus { + border-color: #615ced; + } +} + +.marketErrors { + border: 1px solid #ffd8bf; + background: #fff7e6; + border-radius: 10px; + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.marketErrorItem { + font-size: 12px; + color: #ad4e00; +} + +.marketplaceGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 16px; + align-items: stretch; +} + +.marketplaceContentWrap { + position: relative; +} + +.marketplaceGridLoading { + opacity: 0.48; + filter: grayscale(0.2); + pointer-events: none; +} + +.marketplaceLoadingMask { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + border-radius: 12px; + background: rgba(255, 255, 255, 0.28); + backdrop-filter: blur(1px); + pointer-events: none; + font-size: 13px; + color: #475467; +} + +.marketCard { + border-radius: 16px; + border: 1px solid rgba(0, 0, 0, 0.04); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04); + height: 100%; + display: flex; + flex-direction: column; + transition: all 0.2s ease-in-out; + + &:hover { + border: 1px solid #615ced; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.08); + } + + :global(.ant-card-body) { + flex: 1 1 auto; + display: flex; + flex-direction: column; + min-height: 0; + padding: 16px 16px 12px; + } +} + +.marketCardBody { + display: flex; + flex-direction: column; + min-height: 280px; + height: 100%; + gap: 10px; +} + +.marketCardHeader { + display: flex; + justify-content: space-between; + gap: 8px; + align-items: flex-start; +} + +.marketSkillName { + margin: 0; + font-size: 16px; + font-weight: 600; + color: #1f2937; +} + +.marketSkillDescription { + margin: 0; + font-size: 13px; + color: #525866; + line-height: 1.45; + min-height: 64px; + max-height: 64px; + overflow: hidden; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + word-break: break-word; +} + +.marketSkillMeta { + display: flex; + flex-direction: column; + gap: 10px; +} + +.marketTagList { + display: flex; + flex-wrap: wrap; + gap: 6px; + min-height: 28px; + margin-bottom: 4px; + flex: 1 1 auto; +} + +.marketCardFooter { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + margin-top: auto; + padding-top: 10px; + border-top: 1px solid #f1f2f6; +} + +.marketSourceLink { + font-size: 12px; + color: #615ced; + text-decoration: none; + + &:hover { + text-decoration: underline; + } +} + +.marketManageFooter { + display: flex; + justify-content: space-between; + align-items: center; +} + +.marketAutoParseToggle { + display: inline-flex; + align-items: center; + flex-wrap: nowrap; + gap: 8px; + color: #475467; + font-size: 13px; + white-space: nowrap; + + input { + margin: 0; + flex: 0 0 auto; + } + + span { + white-space: nowrap; + line-height: 1; + } +} + +.marketManageList { + display: flex; + flex-direction: column; + gap: 12px; + max-height: 60vh; + overflow: auto; +} + +.marketManageNotice { + border: 1px solid #d6e4ff; + background: #f0f5ff; + border-radius: 10px; + padding: 10px 12px; + color: #1d39c4; + font-size: 12px; +} + +.marketManageCard { + border: 1px solid #eceff6; + border-radius: 10px; + padding: 12px; + background: #fcfcfd; +} + +.marketManageHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + gap: 8px; +} + +.marketManageTitleRow { + display: flex; + align-items: center; + gap: 8px; +} + +.marketManageGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + + > label { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 12px; + color: #667085; + } +} + +.marketManageFieldFull { + grid-column: 1 / -1; +} + +.marketManageInput { + width: 100%; + height: 34px; + border: 1px solid #d0d5dd; + border-radius: 8px; + padding: 0 10px; + outline: none; + + &:focus { + border-color: #615ced; + } +} + +.marketUrlInputRow { + display: flex; + align-items: center; + gap: 10px; +} + +.marketUrlExample { + margin-top: 2px; + font-size: 12px; + color: #98a2b3; + line-height: 1.4; +} + +.marketManageActions { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 10px; +} + +.marketValidationBadge { + display: inline-flex; + align-items: center; + height: 22px; + padding: 0 8px; + border-radius: 999px; + font-size: 12px; + font-weight: 500; +} + +.marketValidationOk { + color: #166534; + background: #dcfce7; +} + +.marketValidationPending { + color: #92400e; + background: #fef3c7; +} + +.marketValidationWarnings { + margin-top: 8px; + font-size: 12px; + color: #ad6800; + background: #fffbe6; + border: 1px solid #ffe58f; + border-radius: 8px; + padding: 8px 10px; +} + +.marketEnabledToggle { + display: inline-flex; + gap: 8px; + align-items: center; + color: #475467; + font-size: 13px; +} + +@media (max-width: 900px) { + .marketManageGrid { + grid-template-columns: 1fr; + } + + .marketUrlInputRow { + flex-direction: column; + align-items: flex-start; + } +} + .skillCard { border-radius: 16px; transition: all 0.2s ease-in-out; diff --git a/console/src/pages/Agent/Skills/index.tsx b/console/src/pages/Agent/Skills/index.tsx index abd99a9f0..21c75037d 100644 --- a/console/src/pages/Agent/Skills/index.tsx +++ b/console/src/pages/Agent/Skills/index.tsx @@ -1,20 +1,121 @@ -import { useState } from "react"; -import { Button, Form, Modal } from "@agentscope-ai/design"; -import { DownloadOutlined, PlusOutlined } from "@ant-design/icons"; -import type { SkillSpec } from "../../../api/types"; +import { useEffect, useMemo, useState } from "react"; +import { + Button, + Card, + Empty, + Form, + Modal, + Tag, + message, +} from "@agentscope-ai/design"; +import { + CloudDownloadOutlined, + DownloadOutlined, + EditOutlined, + MinusCircleOutlined, + PlusOutlined, + ReloadOutlined, +} from "@ant-design/icons"; +import type { + MarketplaceItem, + SkillSpec, + SkillsMarketSpec, +} from "../../../api/types"; import { SkillCard, SkillDrawer } from "./components"; import { useSkills } from "./useSkills"; import { useTranslation } from "react-i18next"; import styles from "./index.module.less"; +type SkillsView = "local" | "marketplace"; +type MarketValidationState = { + ok: boolean; + warnings: string[]; +}; + +type ParsedMarketUrl = { + repoUrl: string; + ownerRepo: string; + branch?: string; + path?: string; +}; + +function parseMarketUrlInput(input: string): ParsedMarketUrl | null { + const text = (input || "").trim(); + if (!text) { + return null; + } + + const ownerRepoMatch = text.match(/^([\w.-]+)\/([\w.-]+)$/); + if (ownerRepoMatch) { + const owner = ownerRepoMatch[1]; + const repo = ownerRepoMatch[2]; + return { + repoUrl: `https://github.com/${owner}/${repo}.git`, + ownerRepo: `${owner}/${repo}`, + }; + } + + try { + const parsed = new URL(text); + const host = parsed.hostname.toLowerCase(); + if (host !== "github.com" && host !== "www.github.com") { + return null; + } + + const parts = parsed.pathname.split("/").filter(Boolean); + if (parts.length < 2) { + return null; + } + + const owner = parts[0]; + const repo = parts[1].replace(/\.git$/i, ""); + const result: ParsedMarketUrl = { + repoUrl: `https://github.com/${owner}/${repo}.git`, + ownerRepo: `${owner}/${repo}`, + }; + + if (parts.length >= 4 && (parts[2] === "tree" || parts[2] === "blob")) { + result.branch = parts[3]; + if (parts.length > 4) { + result.path = parts.slice(4).join("/"); + } + } + + return result; + } catch { + return null; + } +} + +function buildSkillDefinitionUrl(item: MarketplaceItem): string { + const base = (item.install_url || item.source_url || "").trim(); + if (!base) { + return ""; + } + if (base.endsWith("/SKILL.md") || base.endsWith("SKILL.md")) { + return base; + } + return `${base.replace(/\/$/, "")}/SKILL.md`; +} + function SkillsPage() { const { t } = useTranslation(); const { skills, + markets, + marketplace, + marketErrors, + marketMeta, loading, + marketplaceLoading, + installingSkillKey, importing, createSkill, importFromHub, + validateMarket, + saveMarkets, + fetchMarketplace, + installFromMarketplace, toggleEnabled, deleteSkill, } = useSkills(); @@ -24,8 +125,44 @@ function SkillsPage() { const [importUrlError, setImportUrlError] = useState(""); const [editingSkill, setEditingSkill] = useState(null); const [hoverKey, setHoverKey] = useState(null); + const [viewMode, setViewMode] = useState("local"); + const [marketSearch, setMarketSearch] = useState(""); + const [manageMarketsOpen, setManageMarketsOpen] = useState(false); + const [savingMarkets, setSavingMarkets] = useState(false); + const [validatingMarketId, setValidatingMarketId] = useState( + null, + ); + const [marketDraft, setMarketDraft] = useState([]); + const [autoParseEnabled, setAutoParseEnabled] = useState(true); + const [marketValidation, setMarketValidation] = useState< + Record + >({}); + const [refreshProgress, setRefreshProgress] = useState(0); const [form] = Form.useForm(); + useEffect(() => { + if (!marketplaceLoading) { + setRefreshProgress((prev) => (prev > 0 ? 100 : 0)); + const timeout = setTimeout(() => { + setRefreshProgress(0); + }, 300); + return () => clearTimeout(timeout); + } + + setRefreshProgress((prev) => (prev > 6 ? prev : 6)); + const timer = setInterval(() => { + setRefreshProgress((prev) => { + if (prev >= 92) { + return prev; + } + const next = prev + Math.max(2, Math.round((100 - prev) * 0.08)); + return Math.min(92, next); + }); + }, 220); + + return () => clearInterval(timer); + }, [marketplaceLoading]); + const supportedSkillUrlPrefixes = [ "https://skills.sh/", "https://clawhub.ai/", @@ -117,6 +254,287 @@ function SkillsPage() { } }; + const filteredMarketplace = useMemo(() => { + const q = marketSearch.trim().toLowerCase(); + if (!q) { + return marketplace; + } + return marketplace.filter((item) => { + const tagsText = item.tags.join(" ").toLowerCase(); + return ( + item.name.toLowerCase().includes(q) || + item.skill_id.toLowerCase().includes(q) || + item.description.toLowerCase().includes(q) || + tagsText.includes(q) + ); + }); + }, [marketSearch, marketplace]); + + const handleInstallMarketplaceSkill = async (item: MarketplaceItem) => { + await installFromMarketplace(item); + }; + + const refreshMarketplace = async () => { + await fetchMarketplace(true); + }; + + const enabledMarketCount = markets.filter((m) => m.enabled).length; + const showInitialMarketplaceLoading = + marketplaceLoading && marketplace.length === 0; + const marketNameMap = useMemo( + () => Object.fromEntries(markets.map((market) => [market.id, market.name])), + [markets], + ); + + const openManageMarkets = () => { + setMarketDraft(markets.map((m) => ({ ...m }))); + setAutoParseEnabled(true); + setMarketValidation( + Object.fromEntries( + markets.map((market) => [market.id, { ok: true, warnings: [] }]), + ), + ); + setManageMarketsOpen(true); + }; + + const handleAddMarket = () => { + const nextIndex = marketDraft.length + 1; + const newMarket = { + id: `market_${Date.now()}`, + name: "", + url: "", + branch: "", + path: "skills", + enabled: true, + order: nextIndex, + }; + setMarketDraft((prev) => [...prev, newMarket]); + setMarketValidation((prev) => ({ + ...prev, + [newMarket.id]: { ok: false, warnings: [] }, + })); + }; + + const handleRemoveMarket = (id: string) => { + setMarketDraft((prev) => prev.filter((item) => item.id !== id)); + setMarketValidation((prev) => { + const next = { ...prev }; + delete next[id]; + return next; + }); + }; + + const updateMarketField = ( + id: string, + key: "id" | "name" | "url" | "branch" | "path" | "enabled" | "order", + value: string | number | boolean, + ) => { + setMarketDraft((prev) => { + let nextId = id; + const nextDraft = prev.map((item) => { + if (item.id !== id) return item; + const nextItem = { + ...item, + [key]: value, + }; + nextId = nextItem.id; + return nextItem; + }); + setMarketValidation((current) => { + const next = { ...current }; + delete next[id]; + next[nextId] = { ok: false, warnings: [] }; + return next; + }); + return nextDraft; + }); + }; + + const applyMarketUrlDerivation = (id: string) => { + if (!autoParseEnabled) { + return; + } + setMarketDraft((prev) => { + let changed = false; + let changedBranch = ""; + let changedPath = ""; + const nextDraft = prev.map((item) => { + if (item.id !== id) { + return item; + } + + const parsed = parseMarketUrlInput(item.url); + if (!parsed) { + return item; + } + + const nextUrl = parsed.repoUrl; + const nextBranch = parsed.branch ?? item.branch; + const nextPath = parsed.path ?? item.path; + const nextName = item.name.trim() ? item.name : parsed.ownerRepo; + changed = + nextUrl !== item.url || + nextBranch !== item.branch || + nextPath !== item.path || + nextName !== item.name; + changedBranch = nextBranch || ""; + changedPath = nextPath || ""; + + return { + ...item, + url: nextUrl, + name: nextName, + branch: nextBranch, + path: nextPath, + }; + }); + + setMarketValidation((current) => { + const next = { ...current }; + next[id] = { ok: false, warnings: [] }; + return next; + }); + + if (changed) { + message.success( + t("skills.marketUrlAutoParsed", { + branch: changedBranch || "-", + path: changedPath || "-", + }), + ); + } + + return nextDraft; + }); + }; + + const handleMarketUrlInput = (id: string, value: string) => { + const trimmed = value.trim(); + if (!autoParseEnabled) { + updateMarketField(id, "url", trimmed); + return; + } + + const parsed = parseMarketUrlInput(trimmed); + if (!parsed) { + updateMarketField(id, "url", trimmed); + return; + } + + setMarketDraft((prev) => { + let changed = false; + let changedBranch = ""; + let changedPath = ""; + const nextDraft = prev.map((item) => { + if (item.id !== id) { + return item; + } + + const nextUrl = parsed.repoUrl; + const nextBranch = parsed.branch ?? item.branch; + const nextPath = parsed.path ?? item.path; + const nextName = item.name.trim() ? item.name : parsed.ownerRepo; + changed = + nextUrl !== item.url || + nextBranch !== item.branch || + nextPath !== item.path || + nextName !== item.name; + changedBranch = nextBranch || ""; + changedPath = nextPath || ""; + + return { + ...item, + url: nextUrl, + name: nextName, + branch: nextBranch, + path: nextPath, + }; + }); + + setMarketValidation((current) => { + const next = { ...current }; + next[id] = { ok: false, warnings: [] }; + return next; + }); + + if (changed) { + message.success( + t("skills.marketUrlAutoParsed", { + branch: changedBranch || "-", + path: changedPath || "-", + }), + ); + } + + return nextDraft; + }); + }; + + const handleValidateMarket = async (id: string) => { + const target = marketDraft.find((m) => m.id === id); + if (!target) { + return; + } + setValidatingMarketId(id); + const result = await validateMarket(target); + if (result?.normalized) { + const normalizedId = result.normalized.id; + setMarketDraft((prev) => + prev.map((item) => + item.id === id ? { ...item, ...result.normalized } : item, + ), + ); + setMarketValidation((prev) => { + const next = { ...prev }; + delete next[id]; + next[normalizedId] = { + ok: true, + warnings: result.warnings ?? [], + }; + return next; + }); + } + setValidatingMarketId(null); + }; + + const hasIncompleteMarket = marketDraft.some( + (market) => !market.id.trim() || !market.name.trim() || !market.url.trim(), + ); + const marketIdCount = marketDraft.reduce>( + (acc, market) => { + const id = market.id.trim(); + if (!id) { + return acc; + } + acc[id] = (acc[id] || 0) + 1; + return acc; + }, + {}, + ); + const hasDuplicatedMarketId = Object.values(marketIdCount).some( + (count) => count > 1, + ); + const hasUnvalidatedMarket = marketDraft.some( + (market) => !marketValidation[market.id]?.ok, + ); + const canSaveMarkets = + !savingMarkets && + !hasIncompleteMarket && + !hasDuplicatedMarketId && + !hasUnvalidatedMarket; + + const handleSaveMarkets = async () => { + if (!canSaveMarkets) { + return; + } + setSavingMarkets(true); + const success = await saveMarkets(marketDraft); + setSavingMarkets(false); + if (success) { + setManageMarketsOpen(false); + } + }; + return (
@@ -124,20 +542,249 @@ function SkillsPage() {

{t("skills.title")}

{t("skills.description")}

-
- - +
+ {viewMode === "local" ? ( + <> + + + + ) : ( + <> + + + + )}
+ setManageMarketsOpen(false)} + width={920} + footer={ +
+ +
+ + +
+
+ } + > +
+ {!canSaveMarkets ? ( +
+ {hasIncompleteMarket + ? t("skills.marketSaveRequiresFields") + : hasDuplicatedMarketId + ? t("skills.marketSaveRequiresUniqueId") + : t("skills.marketSaveRequiresValidation")} +
+ ) : null} + {marketDraft.map((market) => ( +
+
+
+ {market.id} + + {marketValidation[market.id]?.ok + ? t("skills.marketValidated") + : t("skills.marketPendingValidation")} + +
+ +
+
+ + + + + + +
+
+ + +
+ {marketValidation[market.id]?.ok && + marketValidation[market.id]?.warnings.length ? ( +
+ {marketValidation[market.id].warnings.join(" ")} +
+ ) : null} +
+ ))} + {marketDraft.length === 0 ? ( + + ) : null} +
+
+ +
+ + +
+ - {loading ? ( -
- {t("common.loading")} -
+ {viewMode === "local" ? ( + loading ? ( +
+ {t("common.loading")} +
+ ) : ( +
+ {skills + .slice() + .sort((a, b) => { + if (a.enabled && !b.enabled) return -1; + if (!a.enabled && b.enabled) return 1; + return a.name.localeCompare(b.name); + }) + .map((skill) => ( + handleEdit(skill)} + onMouseEnter={() => setHoverKey(skill.name)} + onMouseLeave={() => setHoverKey(null)} + onToggleEnabled={(e) => handleToggleEnabled(skill, e)} + onDelete={(e) => handleDelete(skill, e)} + /> + ))} +
+ ) ) : ( -
- {skills - .slice() - .sort((a, b) => { - if (a.enabled && !b.enabled) return -1; - if (!a.enabled && b.enabled) return 1; - return a.name.localeCompare(b.name); - }) - .map((skill) => ( - handleEdit(skill)} - onMouseEnter={() => setHoverKey(skill.name)} - onMouseLeave={() => setHoverKey(null)} - onToggleEnabled={(e) => handleToggleEnabled(skill, e)} - onDelete={(e) => handleDelete(skill, e)} - /> - ))} +
+ {refreshProgress > 0 ? ( +
+
+
+
+ + {t("skills.refreshingMarketplace", { + progress: refreshProgress, + })} + +
+ ) : null} + +
+
+ + {t("skills.marketCount", { + enabled: enabledMarketCount, + total: markets.length, + })} + + {marketMeta ? ( + + {t("skills.marketRefreshSummary", { + success: marketMeta.success_market_count, + total: marketMeta.enabled_market_count, + })} + + ) : null} +
+ setMarketSearch(e.target.value)} + placeholder={t("skills.searchMarketplacePlaceholder")} + /> +
+ + {marketErrors.length > 0 ? ( +
+ {marketErrors.map((error) => ( +
+ {error.market_id}: {error.message} +
+ ))} +
+ ) : null} + + {showInitialMarketplaceLoading ? ( +
+ {t("common.loading")} +
+ ) : filteredMarketplace.length === 0 ? ( + + ) : ( +
+
+ {filteredMarketplace.map((item) => { + const key = `${item.market_id}/${item.skill_id}`; + const isInstalling = installingSkillKey === key; + const skillDefinitionUrl = buildSkillDefinitionUrl(item); + return ( + +
+
+

{item.name}

+ + {marketNameMap[item.market_id] || item.market_id} + +
+ +
+
+ {t("skills.skillDescription")} +
+
+ {item.description || item.skill_id} +
+
+ +
+
+
{t("skills.source")}
+
+ {item.skill_id} +
+
+
+
{t("skills.path")}
+
+ {item.install_url || item.source_url} +
+
+
+ +
+ {item.tags.slice(0, 4).map((tag) => ( + {tag} + ))} + {item.version ? v{item.version} : null} +
+ +
+ + {t("skills.viewSource")} + + +
+
+
+ ); + })} +
+ {marketplaceLoading ? ( +
+ {t("skills.refreshingMarketplace", { progress: refreshProgress })} +
+ ) : null} +
+ )}
)} diff --git a/console/src/pages/Agent/Skills/useSkills.ts b/console/src/pages/Agent/Skills/useSkills.ts index 28545f236..8b7976ac9 100644 --- a/console/src/pages/Agent/Skills/useSkills.ts +++ b/console/src/pages/Agent/Skills/useSkills.ts @@ -1,11 +1,31 @@ import { useState, useEffect } from "react"; import { message, Modal } from "@agentscope-ai/design"; +import { useTranslation } from "react-i18next"; import api from "../../../api"; -import type { SkillSpec } from "../../../api/types"; +import type { + MarketError, + MarketplaceItem, + MarketplaceMeta, + SkillSpec, + SkillsMarketsPayload, + SkillsMarketSpec, +} from "../../../api/types"; export function useSkills() { + const { t } = useTranslation(); const [skills, setSkills] = useState([]); + const [markets, setMarkets] = useState([]); + const [marketConfig, setMarketConfig] = useState( + null, + ); + const [marketplace, setMarketplace] = useState([]); + const [marketErrors, setMarketErrors] = useState([]); + const [marketMeta, setMarketMeta] = useState(null); const [loading, setLoading] = useState(false); + const [marketplaceLoading, setMarketplaceLoading] = useState(false); + const [installingSkillKey, setInstallingSkillKey] = useState( + null, + ); const [importing, setImporting] = useState(false); const fetchSkills = async () => { @@ -27,7 +47,7 @@ export function useSkills() { let mounted = true; const loadSkills = async () => { - await fetchSkills(); + await Promise.all([fetchSkills(), fetchMarkets(), fetchMarketplace()]); }; if (mounted) { @@ -39,6 +59,73 @@ export function useSkills() { }; }, []); + const fetchMarkets = async () => { + try { + const data = await api.getSkillsMarkets(); + setMarketConfig(data); + setMarkets(data?.markets ?? []); + } catch (error) { + console.error("Failed to load markets", error); + message.error(t("skills.loadMarketsFailed")); + } + }; + + const validateMarket = async (market: SkillsMarketSpec) => { + try { + const result = await api.validateSkillsMarket(market); + message.success( + t("skills.validateMarketSuccess", { + name: market.name || market.id, + }), + ); + return result; + } catch (error) { + console.error("Failed to validate market", error); + message.error(t("skills.validateMarketFailed")); + return null; + } + }; + + const saveMarkets = async (nextMarkets: SkillsMarketSpec[]) => { + const config = marketConfig ?? { + version: 1, + cache: { ttl_sec: 600 }, + install: { overwrite_default: false }, + markets: [], + }; + try { + const payload: SkillsMarketsPayload = { + ...config, + markets: nextMarkets, + }; + const updated = await api.updateSkillsMarkets(payload); + setMarketConfig(updated); + setMarkets(updated.markets); + message.success(t("skills.saveMarketsSuccess")); + await fetchMarketplace(true); + return true; + } catch (error) { + console.error("Failed to save markets", error); + message.error(t("skills.saveMarketsFailed")); + return false; + } + }; + + const fetchMarketplace = async (refresh = false) => { + try { + setMarketplaceLoading(true); + const data = await api.getMarketplace(refresh); + setMarketplace(data?.items ?? []); + setMarketErrors(data?.market_errors ?? []); + setMarketMeta(data?.meta ?? null); + } catch (error) { + console.error("Failed to load marketplace", error); + message.error(t("skills.loadMarketplaceFailed")); + } finally { + setMarketplaceLoading(false); + } + }; + const createSkill = async (name: string, content: string) => { try { await api.createSkill(name, content); @@ -143,12 +230,73 @@ export function useSkills() { } }; + const installFromMarketplace = async (item: MarketplaceItem) => { + const skillKey = `${item.market_id}/${item.skill_id}`; + + // Check if a skill with the same name is already installed + const alreadyInstalled = skills.some( + (s) => s.name === item.skill_id || s.name === item.name, + ); + + let overwrite = false; + if (alreadyInstalled) { + const confirmed = await new Promise((resolve) => { + Modal.confirm({ + title: t("skills.overwriteConfirmTitle"), + content: t("skills.overwriteConfirmContent", { name: item.name }), + okText: t("skills.overwriteConfirmOk"), + okType: "danger", + cancelText: t("skills.overwriteConfirmCancel"), + onOk: () => resolve(true), + onCancel: () => resolve(false), + }); + }); + if (!confirmed) return false; + overwrite = true; + } + + try { + setInstallingSkillKey(skillKey); + const result = await api.installMarketplaceSkill({ + market_id: item.market_id, + skill_id: item.skill_id, + enable: true, + overwrite, + }); + if (result?.installed) { + message.success( + t("skills.installMarketplaceSuccess", { name: result.name }), + ); + await fetchSkills(); + return true; + } + message.error(t("skills.installMarketplaceFailed")); + return false; + } catch (error) { + console.error("Failed to install marketplace skill", error); + message.error(t("skills.installMarketplaceFailed")); + return false; + } finally { + setInstallingSkillKey(null); + } + }; + return { skills, + markets, + marketplace, + marketErrors, + marketMeta, loading, + marketplaceLoading, + installingSkillKey, importing, createSkill, importFromHub, + validateMarket, + saveMarkets, + fetchMarketplace, + installFromMarketplace, toggleEnabled, deleteSkill, }; diff --git a/src/copaw/app/routers/skills.py b/src/copaw/app/routers/skills.py index 96f2d312f..7c7244010 100644 --- a/src/copaw/app/routers/skills.py +++ b/src/copaw/app/routers/skills.py @@ -1,6 +1,16 @@ # -*- coding: utf-8 -*- +import json import logging +import re +import subprocess +import tempfile +import threading +import time +from pathlib import Path from typing import Any +from urllib.parse import unquote, urlparse + +import frontmatter from fastapi import APIRouter, HTTPException from pydantic import BaseModel, Field from ...agents.skills_manager import ( @@ -12,6 +22,13 @@ search_hub_skills, install_skill_from_hub, ) +from ...config import load_config, save_config +from ...config.config import ( + SkillMarketSpec, + SkillsMarketCacheConfig, + SkillsMarketConfig, + SkillsMarketInstallConfig, +) logger = logging.getLogger(__name__) @@ -56,6 +73,533 @@ class HubInstallRequest(BaseModel): ) +class SkillsMarketPayload(BaseModel): + version: int = Field(default=1) + cache: dict[str, int] = Field(default_factory=lambda: {"ttl_sec": 600}) + install: dict[str, bool] = Field( + default_factory=lambda: {"overwrite_default": False}, + ) + markets: list[SkillMarketSpec] = Field(default_factory=list) + + +class ValidateMarketRequest(SkillMarketSpec): + pass + + +class MarketError(BaseModel): + market_id: str + code: str + message: str + retryable: bool = False + + +class MarketplaceItem(BaseModel): + market_id: str + skill_id: str + name: str + description: str = "" + version: str = "" + source_url: str + install_url: str + tags: list[str] = Field(default_factory=list) + + +class InstallMarketplaceRequest(BaseModel): + market_id: str + skill_id: str + enable: bool = True + overwrite: bool = False + + +_OWNER_REPO_PATTERN = re.compile(r"^[\w.-]+/[\w.-]+$") +_MARKETPLACE_CACHE_LOCK = threading.Lock() +_MARKETPLACE_CACHE: dict[str, Any] = { + "expires_at": 0.0, + "items": [], + "errors": [], + "meta": {}, +} + + +def _extract_github_market_spec( + url: str, +) -> tuple[str, str, str] | None: + parsed = urlparse((url or "").strip()) + host = (parsed.netloc or "").lower() + if host not in {"github.com", "www.github.com"}: + return None + parts = [unquote(p) for p in parsed.path.split("/") if p] + if len(parts) < 2: + return None + + owner, repo = parts[0], parts[1] + if repo.endswith(".git"): + repo = repo[: -len(".git")] + repo_url = f"https://github.com/{owner}/{repo}.git" + branch = "" + path = "" + if len(parts) >= 4 and parts[2] in {"tree", "blob"}: + branch = parts[3].strip() + if len(parts) > 4: + path = "/".join(parts[4:]).strip() + return repo_url, branch, path + + +def _market_repo_web_url(url: str) -> str: + raw = (url or "").strip() + if raw.startswith("git@github.com:"): + repo = raw[len("git@github.com:") :] + if repo.endswith(".git"): + repo = repo[: -len(".git")] + return f"https://github.com/{repo}" + if raw.endswith(".git"): + return raw[: -len(".git")] + return raw + + +def _normalize_market_spec(market: SkillMarketSpec) -> SkillMarketSpec: + normalized = market.model_copy(deep=True) + github_spec = _extract_github_market_spec(normalized.url) + if github_spec is not None: + repo_url, branch, path = github_spec + normalized.url = repo_url + if branch and not normalized.branch: + normalized.branch = branch + if path and ( + not normalized.path + or normalized.path == "index.json" + or normalized.path == "/" + ): + normalized.path = path + return normalized + + normalized.url = _normalize_market_url(normalized.url) + return normalized + + +def _normalize_market_url(url: str) -> str: + raw = (url or "").strip() + if _OWNER_REPO_PATTERN.fullmatch(raw): + return f"https://github.com/{raw}.git" + return raw + + +def _validate_market_url(url: str) -> bool: + raw = (url or "").strip() + if not raw: + return False + if _OWNER_REPO_PATTERN.fullmatch(raw): + return True + if _extract_github_market_spec(raw) is not None: + return True + if raw.startswith(("https://", "http://", "git@")): + return True + if raw.endswith(".git"): + return True + return False + + +def _validate_market_path(path: str) -> bool: + raw = (path or "").strip() or "index.json" + p = Path(raw) + if p.is_absolute(): + return False + return ".." not in p.parts + + +def _run_git_command( + args: list[str], + *, + cwd: str | None = None, + timeout_sec: int = 30, +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", *args], + cwd=cwd, + capture_output=True, + text=True, + check=False, + timeout=timeout_sec, + ) + + +def _validate_market_ids(markets: list[SkillMarketSpec]) -> None: + seen: set[str] = set() + for market in markets: + if market.id in seen: + raise HTTPException( + status_code=400, + detail=f"MARKET_ID_DUPLICATED: {market.id}", + ) + seen.add(market.id) + + +def _market_config_to_payload(cfg: SkillsMarketConfig) -> SkillsMarketPayload: + return SkillsMarketPayload( + version=cfg.version, + cache={"ttl_sec": cfg.cache.ttl_sec}, + install={"overwrite_default": cfg.install.overwrite_default}, + markets=cfg.markets, + ) + + +def _payload_to_market_config(payload: SkillsMarketPayload) -> SkillsMarketConfig: + _validate_market_ids(payload.markets) + normalized_markets: list[SkillMarketSpec] = [] + for market in payload.markets: + normalized_market = _normalize_market_spec(market) + if not _validate_market_url(normalized_market.url): + raise HTTPException( + status_code=400, + detail=( + f"MARKET_URL_INVALID: {market.url}. " + "Use owner/repo, http(s), ssh, or .git URL" + ), + ) + if not _validate_market_path(normalized_market.path): + raise HTTPException( + status_code=400, + detail=( + f"MARKET_INDEX_INVALID: invalid path '{normalized_market.path}'" + ), + ) + normalized_markets.append(normalized_market) + + ttl = int(payload.cache.get("ttl_sec", 600)) + overwrite_default = bool(payload.install.get("overwrite_default", False)) + return SkillsMarketConfig( + version=max(1, int(payload.version or 1)), + markets=normalized_markets, + cache=SkillsMarketCacheConfig( + ttl_sec=max(0, min(ttl, 24 * 3600)), + ), + install=SkillsMarketInstallConfig( + overwrite_default=overwrite_default, + ), + ) + + +def _coerce_tags(value: Any) -> list[str]: + if not isinstance(value, list): + return [] + return [str(item).strip() for item in value if str(item).strip()] + + +def _generate_market_index_from_directory( + market: SkillMarketSpec, + repo_dir: Path, + skills_dir: Path, + effective_branch: str, +) -> dict[str, Any]: + repo_web_url = _market_repo_web_url(market.url) + branch = effective_branch or market.branch or "main" + skill_dirs: list[Path] = [] + + if (skills_dir / "SKILL.md").exists(): + skill_dirs.append(skills_dir) + else: + for skill_md in sorted(skills_dir.rglob("SKILL.md")): + skill_dirs.append(skill_md.parent) + + skills: list[dict[str, Any]] = [] + seen_ids: set[str] = set() + for skill_dir in skill_dirs: + skill_md = skill_dir / "SKILL.md" + if not skill_md.exists(): + continue + relative_dir = skill_dir.relative_to(repo_dir).as_posix() + skill_id = skill_dir.name.strip() + if not skill_id or skill_id in seen_ids: + continue + seen_ids.add(skill_id) + + content = skill_md.read_text(encoding="utf-8") + try: + post = frontmatter.loads(content) + except Exception: + post = None + + description_value = "" + version_value = "" + tags_value: list[str] = [] + display_name = skill_id + if post is not None: + display_name = str(post.get("name") or skill_id) + description_value = _coerce_description(post.get("description")) + version_value = str(post.get("version") or "") + tags_value = _coerce_tags(post.get("tags")) + + skills.append( + { + "skill_id": skill_id, + "name": display_name, + "description": description_value, + "version": version_value, + "tags": tags_value, + "source": { + "type": "git", + "url": market.url, + "branch": branch, + "path": relative_dir, + }, + "homepage": f"{repo_web_url}/tree/{branch}/{relative_dir}", + }, + ) + + return {"skills": skills} + + +def _load_market_index( + market: SkillMarketSpec, +) -> tuple[dict[str, Any], list[str]]: + market_url = _normalize_market_url(market.url) + with tempfile.TemporaryDirectory(prefix="copaw-market-") as tmp: + repo_dir = Path(tmp) / "repo" + clone_args = ["clone", "--depth", "1"] + if market.branch: + clone_args += ["--branch", market.branch] + clone_args += [market_url, str(repo_dir)] + clone_result = _run_git_command(clone_args, timeout_sec=45) + if clone_result.returncode != 0: + raise RuntimeError( + "MARKET_UNREACHABLE: " + + (clone_result.stderr.strip() or "clone failed") + ) + + effective_branch = (market.branch or "").strip() + if not effective_branch: + head_result = _run_git_command( + ["branch", "--show-current"], + cwd=str(repo_dir), + timeout_sec=10, + ) + if head_result.returncode == 0: + effective_branch = head_result.stdout.strip() + + target_path = repo_dir / (market.path or "index.json") + if target_path.exists() and target_path.is_file(): + with open(target_path, "r", encoding="utf-8") as f: + return json.load(f), [] + + if target_path.exists() and target_path.is_dir(): + return ( + _generate_market_index_from_directory( + market, + repo_dir, + target_path, + effective_branch, + ), + [ + "MARKET_INDEX_GENERATED_FROM_DIRECTORY: " + f"{target_path.relative_to(repo_dir).as_posix()}" + ], + ) + + if target_path.name == "index.json" and target_path.parent.is_dir(): + return ( + _generate_market_index_from_directory( + market, + repo_dir, + target_path.parent, + effective_branch, + ), + [ + "MARKET_INDEX_GENERATED_FROM_DIRECTORY: " + f"{target_path.parent.relative_to(repo_dir).as_posix()}" + ], + ) + + raise ValueError( + f"MARKET_INDEX_INVALID: index or skills directory not found at {market.path}" + ) + + +def _coerce_description(value: Any) -> str: + if isinstance(value, str): + return value + if isinstance(value, dict): + return str(value.get("zh") or value.get("en") or "") + return "" + + +def _extract_market_items( + market: SkillMarketSpec, + index_doc: dict[str, Any], +) -> tuple[list[MarketplaceItem], list[MarketError]]: + skills = index_doc.get("skills") + if not isinstance(skills, list): + return [], [ + MarketError( + market_id=market.id, + code="MARKET_INDEX_INVALID", + message="skills must be a list", + retryable=False, + ), + ] + + items: list[MarketplaceItem] = [] + errors: list[MarketError] = [] + seen_ids: set[str] = set() + for raw in skills: + if not isinstance(raw, dict): + errors.append( + MarketError( + market_id=market.id, + code="MARKET_INDEX_INVALID", + message="invalid skill entry type", + retryable=False, + ), + ) + continue + + skill_id = str(raw.get("skill_id") or "").strip() + if not skill_id: + errors.append( + MarketError( + market_id=market.id, + code="MARKET_INDEX_INVALID", + message="missing skill_id", + retryable=False, + ), + ) + continue + if skill_id in seen_ids: + errors.append( + MarketError( + market_id=market.id, + code="MARKET_INDEX_INVALID", + message=f"duplicated skill_id: {skill_id}", + retryable=False, + ), + ) + continue + seen_ids.add(skill_id) + + source = raw.get("source") + if not isinstance(source, dict): + errors.append( + MarketError( + market_id=market.id, + code="MARKET_INDEX_INVALID", + message=f"invalid source for {skill_id}", + retryable=False, + ), + ) + continue + + source_type = str(source.get("type") or "").strip().lower() + source_url = str(source.get("url") or "").strip() + if source_type != "git" or not source_url: + errors.append( + MarketError( + market_id=market.id, + code="MARKET_INDEX_INVALID", + message=f"unsupported source for {skill_id}", + retryable=False, + ), + ) + continue + + source_branch = str(source.get("branch") or "").strip() + source_path = str(source.get("path") or "").strip() + install_url = source_url + if source_path: + branch = source_branch or "main" + source_url = source_url.replace(".git", "") + if "github.com/" in source_url: + install_url = ( + f"{source_url}/tree/{branch}/{source_path.lstrip('/')}" + ) + + tags = raw.get("tags") + items.append( + MarketplaceItem( + market_id=market.id, + skill_id=skill_id, + name=str(raw.get("name") or skill_id), + description=_coerce_description(raw.get("description")), + version=str(raw.get("version") or ""), + source_url=source_url, + install_url=install_url, + tags=tags if isinstance(tags, list) else [], + ), + ) + + return items, errors + + +def _aggregate_marketplace( + market_cfg: SkillsMarketConfig, + *, + refresh: bool, +) -> tuple[list[MarketplaceItem], list[MarketError], dict[str, Any]]: + now = time.time() + with _MARKETPLACE_CACHE_LOCK: + cache_expired = now >= float(_MARKETPLACE_CACHE.get("expires_at", 0.0)) + if not refresh and not cache_expired: + return ( + list(_MARKETPLACE_CACHE.get("items", [])), + list(_MARKETPLACE_CACHE.get("errors", [])), + dict(_MARKETPLACE_CACHE.get("meta", {})), + ) + + enabled_markets = sorted( + [m for m in market_cfg.markets if m.enabled], + key=lambda x: (x.order, x.id), + ) + all_items: list[MarketplaceItem] = [] + all_errors: list[MarketError] = [] + success_count = 0 + for market in enabled_markets: + try: + index_doc, _ = _load_market_index(market) + items, errors = _extract_market_items(market, index_doc) + all_items.extend(items) + all_errors.extend(errors) + success_count += 1 + except ValueError as e: + all_errors.append( + MarketError( + market_id=market.id, + code="MARKET_INDEX_INVALID", + message=str(e), + retryable=False, + ), + ) + except RuntimeError as e: + all_errors.append( + MarketError( + market_id=market.id, + code="MARKET_UNREACHABLE", + message=str(e), + retryable=True, + ), + ) + except (subprocess.SubprocessError, OSError, json.JSONDecodeError) as e: + all_errors.append( + MarketError( + market_id=market.id, + code="MARKET_INDEX_INVALID", + message=str(e), + retryable=True, + ), + ) + + meta = { + "refreshed_at": int(now), + "cache_hit": False, + "enabled_market_count": len(enabled_markets), + "success_market_count": success_count, + } + with _MARKETPLACE_CACHE_LOCK: + _MARKETPLACE_CACHE["expires_at"] = now + market_cfg.cache.ttl_sec + _MARKETPLACE_CACHE["items"] = list(all_items) + _MARKETPLACE_CACHE["errors"] = list(all_errors) + _MARKETPLACE_CACHE["meta"] = dict(meta) + + return all_items, all_errors, meta + + router = APIRouter(prefix="/skills", tags=["skills"]) @@ -107,6 +651,89 @@ async def search_hub( ] +@router.get("/markets", response_model=SkillsMarketPayload) +async def get_markets() -> SkillsMarketPayload: + config = load_config() + return _market_config_to_payload(config.skills_market) + + +@router.put("/markets", response_model=SkillsMarketPayload) +async def put_markets(payload: SkillsMarketPayload) -> SkillsMarketPayload: + config = load_config() + market_cfg = _payload_to_market_config(payload) + config.skills_market = market_cfg + save_config(config) + with _MARKETPLACE_CACHE_LOCK: + _MARKETPLACE_CACHE["expires_at"] = 0.0 + return _market_config_to_payload(market_cfg) + + +@router.post("/markets/validate") +async def validate_market(payload: ValidateMarketRequest): + normalized = _normalize_market_spec(payload) + if not _validate_market_url(normalized.url): + raise HTTPException( + status_code=400, + detail=( + f"MARKET_URL_INVALID: {payload.url}. " + "Use owner/repo, http(s), ssh, or .git URL" + ), + ) + if not _validate_market_path(normalized.path): + raise HTTPException( + status_code=400, + detail=f"MARKET_INDEX_INVALID: invalid path '{normalized.path}'", + ) + + ls_remote = _run_git_command( + ["ls-remote", "--heads", normalized.url], + timeout_sec=20, + ) + if ls_remote.returncode != 0: + raise HTTPException( + status_code=502, + detail=( + "MARKET_UNREACHABLE: " + + (ls_remote.stderr.strip() or "ls-remote failed") + ), + ) + try: + _, warnings = _load_market_index(normalized) + except json.JSONDecodeError as e: + raise HTTPException( + status_code=400, + detail=f"MARKET_INDEX_INVALID: {e}", + ) from e + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except RuntimeError as e: + raise HTTPException(status_code=502, detail=str(e)) from e + except (subprocess.SubprocessError, OSError) as e: + raise HTTPException( + status_code=502, + detail=f"MARKET_UNREACHABLE: {e}", + ) from e + return { + "ok": True, + "normalized": normalized.model_dump(mode="json"), + "warnings": warnings, + } + + +@router.get("/marketplace") +async def get_marketplace(refresh: bool = False): + config = load_config() + items, errors, meta = _aggregate_marketplace( + config.skills_market, + refresh=refresh, + ) + return { + "items": [item.model_dump(mode="json") for item in items], + "market_errors": [err.model_dump(mode="json") for err in errors], + "meta": meta, + } + + def _github_token_hint(bundle_url: str) -> str: """Hint to set GITHUB_TOKEN when URL is from GitHub/skills.sh.""" if not bundle_url: @@ -117,6 +744,61 @@ def _github_token_hint(bundle_url: str) -> str: return "" +@router.post("/marketplace/install") +async def install_from_marketplace(request: InstallMarketplaceRequest): + config = load_config() + overwrite = request.overwrite or bool( + config.skills_market.install.overwrite_default, + ) + items, _, _ = _aggregate_marketplace(config.skills_market, refresh=False) + selected = None + for item in items: + if ( + item.market_id == request.market_id + and item.skill_id == request.skill_id + ): + selected = item + break + + if selected is None: + raise HTTPException( + status_code=404, + detail=( + f"MARKET_ITEM_NOT_FOUND: " + f"{request.market_id}/{request.skill_id}" + ), + ) + + try: + result = install_skill_from_hub( + bundle_url=selected.install_url, + version="", + enable=request.enable, + overwrite=overwrite, + ) + except ValueError as e: + detail = str(e) + if "already exists" in detail.lower() and not overwrite: + raise HTTPException( + status_code=409, + detail=f"SKILL_NAME_CONFLICT: {detail}", + ) from e + raise HTTPException(status_code=400, detail=detail) from e + except RuntimeError as e: + detail = str(e) + _github_token_hint(selected.install_url) + raise HTTPException(status_code=502, detail=detail) from e + except Exception as e: + detail = f"Skill market install failed: {e}" + raise HTTPException(status_code=502, detail=detail) from e + + return { + "installed": True, + "name": result.name, + "enabled": result.enabled, + "source_url": result.source_url, + } + + @router.post("/hub/install") async def install_from_hub(request: HubInstallRequest): try: diff --git a/src/copaw/config/config.py b/src/copaw/config/config.py index 2e8757464..e4da828af 100644 --- a/src/copaw/config/config.py +++ b/src/copaw/config/config.py @@ -476,6 +476,48 @@ class SecurityConfig(BaseModel): tool_guard: ToolGuardConfig = Field(default_factory=ToolGuardConfig) +class SkillMarketSpec(BaseModel): + """A single skills market entry.""" + + id: str = Field(..., description="Stable market id") + name: str = Field(..., description="Display name") + type: Literal["git"] = Field(default="git") + url: str = Field(..., description="Git repository URL") + branch: str = Field(default="", description="Optional branch") + path: str = Field( + default="index.json", + description="Path to market index file in repo", + ) + enabled: bool = Field(default=True) + order: int = Field(default=999) + trust: Optional[Literal["official", "community", "custom"]] = None + + +class SkillsMarketCacheConfig(BaseModel): + """Cache policy for market index aggregation.""" + + ttl_sec: int = Field(default=600, ge=0, le=24 * 3600) + + +class SkillsMarketInstallConfig(BaseModel): + """Default install behavior for marketplace installs.""" + + overwrite_default: bool = Field(default=False) + + +class SkillsMarketConfig(BaseModel): + """Skills market root config.""" + + version: int = Field(default=1, ge=1) + markets: List[SkillMarketSpec] = Field(default_factory=list) + cache: SkillsMarketCacheConfig = Field( + default_factory=SkillsMarketCacheConfig, + ) + install: SkillsMarketInstallConfig = Field( + default_factory=SkillsMarketInstallConfig, + ) + + class Config(BaseModel): """Root config (config.json).""" @@ -484,6 +526,9 @@ class Config(BaseModel): tools: ToolsConfig = Field(default_factory=ToolsConfig) last_api: LastApiConfig = LastApiConfig() agents: AgentsConfig = Field(default_factory=AgentsConfig) + skills_market: SkillsMarketConfig = Field( + default_factory=SkillsMarketConfig, + ) last_dispatch: Optional[LastDispatchConfig] = None security: SecurityConfig = Field(default_factory=SecurityConfig) show_tool_details: bool = True diff --git a/tests/unit/app/routers/test_skills_market.py b/tests/unit/app/routers/test_skills_market.py new file mode 100644 index 000000000..88d3d68fa --- /dev/null +++ b/tests/unit/app/routers/test_skills_market.py @@ -0,0 +1,429 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +import asyncio +import subprocess +from types import SimpleNamespace +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi import HTTPException +from fastapi.testclient import TestClient + +from copaw.app.routers.skills import ( + MarketError, + MarketplaceItem, + SkillsMarketPayload, + SkillsMarketConfig, + ValidateMarketRequest, + _generate_market_index_from_directory, + _extract_market_items, + _payload_to_market_config, + router, + validate_market, +) +from copaw.config.config import SkillMarketSpec + + +@pytest.fixture +def skills_api_client() -> TestClient: + app = FastAPI() + app.include_router(router) + return TestClient(app) + + +def test_payload_to_market_config_normalizes_owner_repo_url() -> None: + payload = SkillsMarketPayload( + version=1, + cache={"ttl_sec": 600}, + install={"overwrite_default": False}, + markets=[ + SkillMarketSpec( + id="official", + name="Official", + url="futuremeng/editor-skills", + branch="main", + path="index.json", + enabled=True, + order=1, + ), + ], + ) + + config = _payload_to_market_config(payload) + + assert config.markets[0].url == "https://github.com/futuremeng/editor-skills.git" + assert config.cache.ttl_sec == 600 + + +def test_payload_to_market_config_rejects_unsafe_index_path() -> None: + payload = SkillsMarketPayload( + version=1, + cache={"ttl_sec": 600}, + install={"overwrite_default": False}, + markets=[ + SkillMarketSpec( + id="unsafe", + name="Unsafe", + url="https://github.com/example/skills.git", + branch="", + path="../index.json", + enabled=True, + order=1, + ), + ], + ) + + with pytest.raises(HTTPException) as exc: + _payload_to_market_config(payload) + + assert exc.value.status_code == 400 + assert "MARKET_INDEX_INVALID" in str(exc.value.detail) + + +def test_extract_market_items_builds_install_url_with_branch_and_path() -> None: + market_payload = SkillsMarketPayload( + version=1, + cache={"ttl_sec": 600}, + install={"overwrite_default": False}, + markets=[ + SkillMarketSpec( + id="official", + name="Official", + url="https://github.com/example/skills.git", + branch="main", + path="index.json", + enabled=True, + order=1, + ), + ], + ) + market = _payload_to_market_config(market_payload).markets[0] + + items, errors = _extract_market_items( + market, + { + "skills": [ + { + "skill_id": "python-dev", + "name": "Python Dev", + "description": {"zh": "Python 开发技能"}, + "version": "0.1.0", + "source": { + "type": "git", + "url": "https://github.com/example/skills.git", + "branch": "dev", + "path": "skills/python-dev", + }, + "tags": ["python", "dev"], + }, + ], + }, + ) + + assert errors == [] + assert len(items) == 1 + assert items[0].install_url == ( + "https://github.com/example/skills/tree/dev/skills/python-dev" + ) + assert items[0].description == "Python 开发技能" + + +def test_extract_market_items_returns_error_for_invalid_skills_field() -> None: + market_payload = SkillsMarketPayload( + version=1, + cache={"ttl_sec": 600}, + install={"overwrite_default": False}, + markets=[ + SkillMarketSpec( + id="official", + name="Official", + url="https://github.com/example/skills.git", + branch="main", + path="index.json", + enabled=True, + order=1, + ), + ], + ) + market = _payload_to_market_config(market_payload).markets[0] + + items, errors = _extract_market_items(market, {"skills": {}}) + + assert items == [] + assert len(errors) == 1 + assert isinstance(errors[0], MarketError) + assert errors[0].code == "MARKET_INDEX_INVALID" + + +def test_payload_to_market_config_parses_github_tree_url() -> None: + payload = SkillsMarketPayload( + version=1, + cache={"ttl_sec": 600}, + install={"overwrite_default": False}, + markets=[ + SkillMarketSpec( + id="openclaw", + name="OpenClaw Skills", + url="https://github.com/openclaw/openclaw/tree/main/skills", + branch="", + path="index.json", + enabled=True, + order=1, + ), + ], + ) + + config = _payload_to_market_config(payload) + + assert config.markets[0].url == "https://github.com/openclaw/openclaw.git" + assert config.markets[0].branch == "main" + assert config.markets[0].path == "skills" + + +def test_payload_to_market_config_keeps_single_git_suffix() -> None: + payload = SkillsMarketPayload( + version=1, + cache={"ttl_sec": 600}, + install={"overwrite_default": False}, + markets=[ + SkillMarketSpec( + id="openclaw", + name="OpenClaw Skills", + url="https://github.com/openclaw/openclaw.git", + branch="main", + path="skills", + enabled=True, + order=1, + ), + ], + ) + + config = _payload_to_market_config(payload) + + assert config.markets[0].url == "https://github.com/openclaw/openclaw.git" + + +def test_generate_market_index_from_directory_scans_skill_folders( + tmp_path: Path, +) -> None: + repo_dir = tmp_path / "repo" + skills_dir = repo_dir / "skills" + skill_dir = skills_dir / "weather" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + "---\n" + "name: Weather Assistant\n" + "description: 查询天气\n" + "tags:\n" + " - weather\n" + " - tools\n" + "---\n\n" + "skill body\n", + encoding="utf-8", + ) + market = SkillMarketSpec( + id="openclaw", + name="OpenClaw Skills", + url="https://github.com/openclaw/openclaw.git", + branch="main", + path="skills", + enabled=True, + order=1, + ) + + index_doc = _generate_market_index_from_directory( + market, + repo_dir, + skills_dir, + "main", + ) + + assert len(index_doc["skills"]) == 1 + assert index_doc["skills"][0]["skill_id"] == "weather" + assert index_doc["skills"][0]["name"] == "Weather Assistant" + assert index_doc["skills"][0]["source"]["path"] == "skills/weather" + + +def test_validate_market_maps_index_value_error_to_http_400( + monkeypatch: pytest.MonkeyPatch, +) -> None: + payload = ValidateMarketRequest( + id="openclaw", + name="OpenClaw Skills", + url="https://github.com/openclaw/openclaw.git", + branch="main", + path="skills", + enabled=True, + order=1, + ) + + def _ok_ls_remote(*args, **kwargs): + return subprocess.CompletedProcess( + args=["git", "ls-remote"], + returncode=0, + stdout="", + stderr="", + ) + + def _raise_index_error(*args, **kwargs): + raise ValueError("MARKET_INDEX_INVALID: missing skills") + + monkeypatch.setattr("copaw.app.routers.skills._run_git_command", _ok_ls_remote) + monkeypatch.setattr("copaw.app.routers.skills._load_market_index", _raise_index_error) + + with pytest.raises(HTTPException) as exc: + asyncio.run(validate_market(payload)) + + assert exc.value.status_code == 400 + assert "MARKET_INDEX_INVALID" in str(exc.value.detail) + + +def test_validate_market_maps_market_runtime_error_to_http_502( + monkeypatch: pytest.MonkeyPatch, +) -> None: + payload = ValidateMarketRequest( + id="openclaw", + name="OpenClaw Skills", + url="https://github.com/openclaw/openclaw.git", + branch="main", + path="skills", + enabled=True, + order=1, + ) + + def _ok_ls_remote(*args, **kwargs): + return subprocess.CompletedProcess( + args=["git", "ls-remote"], + returncode=0, + stdout="", + stderr="", + ) + + def _raise_unreachable(*args, **kwargs): + raise RuntimeError("MARKET_UNREACHABLE: clone failed") + + monkeypatch.setattr("copaw.app.routers.skills._run_git_command", _ok_ls_remote) + monkeypatch.setattr("copaw.app.routers.skills._load_market_index", _raise_unreachable) + + with pytest.raises(HTTPException) as exc: + asyncio.run(validate_market(payload)) + + assert exc.value.status_code == 502 + assert "MARKET_UNREACHABLE" in str(exc.value.detail) + + +def test_validate_market_endpoint_returns_normalized_contract( + monkeypatch: pytest.MonkeyPatch, + skills_api_client: TestClient, +) -> None: + def _ok_ls_remote(*args, **kwargs): + return subprocess.CompletedProcess( + args=["git", "ls-remote"], + returncode=0, + stdout="", + stderr="", + ) + + monkeypatch.setattr("copaw.app.routers.skills._run_git_command", _ok_ls_remote) + monkeypatch.setattr( + "copaw.app.routers.skills._load_market_index", + lambda *_args, **_kwargs: ({"skills": []}, []), + ) + + response = skills_api_client.post( + "/skills/markets/validate", + json={ + "id": "community", + "name": "Community Skills", + "url": "futuremeng/editor-skills", + "branch": "", + "path": "index.json", + "enabled": True, + "order": 1, + }, + ) + + assert response.status_code == 200 + data = response.json() + assert data["ok"] is True + assert isinstance(data["warnings"], list) + assert data["normalized"]["url"] == "https://github.com/futuremeng/editor-skills.git" + + +def test_marketplace_endpoint_returns_expected_shape( + monkeypatch: pytest.MonkeyPatch, + skills_api_client: TestClient, +) -> None: + monkeypatch.setattr( + "copaw.app.routers.skills.load_config", + lambda: SimpleNamespace(skills_market=SkillsMarketConfig()), + ) + monkeypatch.setattr( + "copaw.app.routers.skills._aggregate_marketplace", + lambda *_args, **_kwargs: ( + [ + MarketplaceItem( + market_id="community", + skill_id="weather", + name="Weather", + description="Weather helper", + version="0.1.0", + source_url="https://github.com/futuremeng/editor-skills", + install_url="https://github.com/futuremeng/editor-skills/tree/main/skills/weather", + tags=["tools"], + ), + ], + [ + MarketError( + market_id="community", + code="MARKET_UNREACHABLE", + message="timeout", + retryable=True, + ), + ], + { + "refreshed_at": 123, + "cache_hit": False, + "enabled_market_count": 1, + "success_market_count": 1, + }, + ), + ) + + response = skills_api_client.get("/skills/marketplace?refresh=true") + + assert response.status_code == 200 + data = response.json() + assert set(data.keys()) == {"items", "market_errors", "meta"} + assert data["items"][0]["skill_id"] == "weather" + assert data["market_errors"][0]["code"] == "MARKET_UNREACHABLE" + assert data["meta"]["enabled_market_count"] == 1 + + +def test_marketplace_install_endpoint_returns_not_found_when_item_missing( + monkeypatch: pytest.MonkeyPatch, + skills_api_client: TestClient, +) -> None: + monkeypatch.setattr( + "copaw.app.routers.skills.load_config", + lambda: SimpleNamespace(skills_market=SkillsMarketConfig()), + ) + monkeypatch.setattr( + "copaw.app.routers.skills._aggregate_marketplace", + lambda *_args, **_kwargs: ([], [], {}), + ) + + response = skills_api_client.post( + "/skills/marketplace/install", + json={ + "market_id": "community", + "skill_id": "not-found", + "enable": True, + "overwrite": False, + }, + ) + + assert response.status_code == 404 + assert "MARKET_ITEM_NOT_FOUND" in response.json()["detail"] diff --git a/website/public/docs/config.en.md b/website/public/docs/config.en.md index f6332661c..fc0570cfa 100644 --- a/website/public/docs/config.en.md +++ b/website/public/docs/config.en.md @@ -138,6 +138,28 @@ automatically use defaults. "language": "zh", "installed_md_files_language": "zh" }, + "skills_market": { + "version": 1, + "cache": { + "ttl_sec": 600 + }, + "install": { + "overwrite_default": false + }, + "markets": [ + { + "id": "community", + "name": "Community Skills", + "type": "git", + "url": "https://github.com/futuremeng/editor-skills.git", + "branch": "main", + "path": "index.json", + "enabled": true, + "order": 1, + "trust": "community" + } + ] + }, "last_api": { "host": "127.0.0.1", "port": 8088 @@ -248,6 +270,38 @@ Each channel has a common base and channel-specific fields. --- +#### `skills_market` — Skills marketplace configuration + +Use this section to configure Git-backed skill markets shown in **Agent → Skills → Marketplace**. + +| Field | Type | Default | Description | +| --------------------------------------- | -------------- | ------------ | ----------- | +| `skills_market.version` | int | `1` | Marketplace config schema version | +| `skills_market.cache.ttl_sec` | int | `600` | Marketplace aggregation cache TTL in seconds | +| `skills_market.install.overwrite_default` | bool | `false` | Default overwrite behavior for marketplace install | +| `skills_market.markets` | list | `[]` | List of market definitions | + +Each `markets[]` item: + +| Field | Type | Default | Description | +| --------- | ------ | ------------ | ----------- | +| `id` | string | _(required)_ | Stable unique market ID | +| `name` | string | _(required)_ | Display name in Console | +| `type` | string | `"git"` | Market type (currently only `git`) | +| `url` | string | _(required)_ | Git repo URL; supports `owner/repo`, `.git`, GitHub tree URLs | +| `branch` | string | `""` | Optional branch; empty means remote default branch | +| `path` | string | `"index.json"` | Index path in repo; can be `index.json` or a skills directory | +| `enabled` | bool | `true` | Whether this market participates in aggregation | +| `order` | int | `999` | Sort order (smaller first) | +| `trust` | string | `null` | Optional trust label: `official`, `community`, `custom` | + +Notes: + +- This config is stored in your working directory config file (default `~/.copaw/config.json`). +- If `path` points to a directory (or `index.json` is missing but the parent directory exists), CoPaw scans `SKILL.md` files and generates an index automatically. + +--- + #### `last_api` — Last used API address | Field | Type | Default | Description | diff --git a/website/public/docs/config.zh.md b/website/public/docs/config.zh.md index c9af3e4c5..a1b141b40 100644 --- a/website/public/docs/config.zh.md +++ b/website/public/docs/config.zh.md @@ -132,6 +132,28 @@ copaw app "language": "zh", "installed_md_files_language": "zh" }, + "skills_market": { + "version": 1, + "cache": { + "ttl_sec": 600 + }, + "install": { + "overwrite_default": false + }, + "markets": [ + { + "id": "community", + "name": "社区技能", + "type": "git", + "url": "https://github.com/futuremeng/editor-skills.git", + "branch": "main", + "path": "index.json", + "enabled": true, + "order": 1, + "trust": "community" + } + ] + }, "last_api": { "host": "127.0.0.1", "port": 8088 @@ -241,6 +263,38 @@ copaw app --- +#### `skills_market` — 技能市场配置 + +用于配置 **Agent → Skills → 技能市场** 的 Git 仓库来源。 + +| 字段 | 类型 | 默认值 | 说明 | +| ----------------------------------------- | ------ | -------------- | ---- | +| `skills_market.version` | int | `1` | 市场配置版本 | +| `skills_market.cache.ttl_sec` | int | `600` | 市场聚合缓存 TTL(秒) | +| `skills_market.install.overwrite_default` | bool | `false` | 市场安装时默认是否覆盖同名技能 | +| `skills_market.markets` | list | `[]` | 市场列表 | + +`markets[]` 每项字段: + +| 字段 | 类型 | 默认值 | 说明 | +| --------- | ------ | -------------- | ---- | +| `id` | string | _(必填)_ | 稳定且唯一的市场标识 | +| `name` | string | _(必填)_ | 控制台展示名称 | +| `type` | string | `"git"` | 市场类型(当前仅支持 `git`) | +| `url` | string | _(必填)_ | Git 仓库地址;支持 `owner/repo`、`.git`、GitHub tree URL | +| `branch` | string | `""` | 可选分支;为空时使用远端默认分支 | +| `path` | string | `"index.json"` | 仓库内索引路径;可为 `index.json` 或 skills 目录 | +| `enabled` | bool | `true` | 是否参与市场聚合 | +| `order` | int | `999` | 排序(越小越靠前) | +| `trust` | string | `null` | 可选信任标签:`official`、`community`、`custom` | + +说明: + +- 该配置存储在工作目录配置文件中(默认 `~/.copaw/config.json`)。 +- 若 `path` 指向目录(或 `index.json` 不存在但父目录存在),CoPaw 会扫描 `SKILL.md` 自动生成索引。 + +--- + #### `last_api` — 上次使用的 API 地址 | 字段 | 类型 | 默认值 | 说明 | diff --git a/website/public/docs/skills.en.md b/website/public/docs/skills.en.md index 3220ee5be..57750fdff 100644 --- a/website/public/docs/skills.en.md +++ b/website/public/docs/skills.en.md @@ -46,6 +46,21 @@ In the [Console](./console), go to **Agent → Skills** to: Changes are synced to the working directory and affect the agent. Handy if you prefer not to edit files directly. +### Skills Marketplace (Git repositories) + +In **Agent → Skills → Marketplace**, you can aggregate skills from multiple Git repositories. + +1. Click **Manage Markets** to add market entries. +2. Fill in repository URL (`owner/repo`, `.git`, or GitHub tree URL are supported), then validate. +3. Save the market list and click **Refresh Marketplace**. +4. Install a marketplace skill with one click. + +Notes: + +- Market config is persisted in `~/.copaw/config.json` under `skills_market`. +- `path` supports either `index.json` or a skills directory. If a directory is provided, CoPaw scans `SKILL.md` files and builds the index automatically. +- Refresh uses cache + failure isolation: if one market fails, other markets can still be listed. + --- ## Built-in skill: Cron (scheduled tasks) diff --git a/website/public/docs/skills.zh.md b/website/public/docs/skills.zh.md index b5254932c..f90b8daaa 100644 --- a/website/public/docs/skills.zh.md +++ b/website/public/docs/skills.zh.md @@ -44,6 +44,21 @@ 修改后会自动同步到工作目录并影响 Agent 行为。适合不习惯直接改文件的用户。 +### 技能市场(Git 仓库) + +在 **Agent → Skills → 技能市场** 中,可以聚合多个 Git 仓库里的技能。 + +1. 点击 **管理市场**,新增市场条目。 +2. 填写仓库地址(支持 `owner/repo`、`.git`、GitHub tree URL)并执行校验。 +3. 保存市场列表后,点击 **刷新市场**。 +4. 在市场卡片上一键安装技能。 + +说明: + +- 市场配置会写入 `~/.copaw/config.json` 的 `skills_market` 字段。 +- `path` 可配置为 `index.json` 或 skills 目录;若为目录,CoPaw 会扫描 `SKILL.md` 自动生成索引。 +- 刷新采用缓存与失败隔离:单个市场失败不会影响其他市场展示。 + --- ## 内置 Skill:Cron(定时任务)