From 6ef16e7b9db843cd78891a18b475275dc59ce343 Mon Sep 17 00:00:00 2001 From: Kinneyzhang Date: Fri, 2 Oct 2026 09:27:14 +0800 Subject: [PATCH] release v0.1.0 --- MANIFEST.json | 215 ++++++ README.md | 14 + package.json | 5 + skills/skill-catalog/REFERENCE.md | 117 +++ skills/skill-catalog/SKILL.md | 31 + skills/skill-catalog/VERSION | 1 + skills/skill-catalog/assets/app.js | 81 ++ skills/skill-catalog/assets/style.css | 336 ++++++++ skills/skill-catalog/contract.lock.json | 43 ++ skills/skill-catalog/interface.json | 106 +++ skills/skill-catalog/scripts/setup-hosting.sh | 32 + .../skill-catalog/scripts/skill-catalog.mjs | 728 ++++++++++++++++++ 12 files changed, 1709 insertions(+) create mode 100644 MANIFEST.json create mode 100644 README.md create mode 100644 package.json create mode 100644 skills/skill-catalog/REFERENCE.md create mode 100644 skills/skill-catalog/SKILL.md create mode 100644 skills/skill-catalog/VERSION create mode 100644 skills/skill-catalog/assets/app.js create mode 100644 skills/skill-catalog/assets/style.css create mode 100644 skills/skill-catalog/contract.lock.json create mode 100644 skills/skill-catalog/interface.json create mode 100755 skills/skill-catalog/scripts/setup-hosting.sh create mode 100644 skills/skill-catalog/scripts/skill-catalog.mjs diff --git a/MANIFEST.json b/MANIFEST.json new file mode 100644 index 0000000..9a95e84 --- /dev/null +++ b/MANIFEST.json @@ -0,0 +1,215 @@ +{ + "manifest_version": 1, + "name": "skill-catalog", + "summary": "技能目录站:把对外发布的技能生成一个静态站(能干什么 / 先装什么 / 要配什么 / 依赖谁 / 一条安装命令),生成物不手改页面。", + "tier": 1, + "status": "stable", + "version": "5da1a0f", + "source": { + "path": "skills/skill-catalog", + "commit": "5da1a0f", + "describe": "5da1a0f", + "dirty": false, + "packed_at": "2026-10-02T01:27:14.099Z" + }, + "platforms": [ + "darwin", + "linux" + ], + "entry": { + "path": "scripts/skill-catalog.mjs" + }, + "commands": [ + "build", + "preview", + "deploy", + "list", + "check" + ], + "errors": [ + "usage", + "deps", + "platform", + "auth", + "config", + "notfound", + "conflict", + "blocked", + "timeout", + "external", + "internal" + ], + "aliases": [ + "skill-catalog" + ], + "admin": [], + "depends_on": [ + { + "skill": "skill-interface", + "tierAtLeast": 1, + "note": "技能发现与接口解析 skill-registry.mjs、配置契约 skill-config.mjs、泄漏扫描 skill-pack.mjs" + } + ], + "requires": [ + { + "kind": "runtime", + "name": "node", + "check": "which node", + "note": "生成器;零 npm 依赖" + }, + { + "kind": "runtime", + "name": "python3", + "check": "which python3", + "note": "预览用的本地文件服务;部署时的 tar/ssh 走系统命令" + }, + { + "kind": "binary", + "name": "tar", + "note": "部署打包(tar)与字体下载/上线自检(curl)" + }, + { + "kind": "binary", + "name": "curl", + "note": "部署打包(tar)与字体下载/上线自检(curl)" + } + ], + "config": [ + { + "key": "site_url", + "desc": "站点公开地址(写进页面的 canonical 与分享链接)", + "type": "url" + }, + { + "key": "deploy_relay", + "desc": "部署用的跳板机(本地能 ssh 的别名)", + "type": "string", + "default": "lightos" + }, + { + "key": "deploy_host", + "desc": "目标主机(在跳板机上能 ssh 的别名)", + "type": "string", + "default": "aliyun-hk" + }, + { + "key": "deploy_path", + "desc": "目标目录(Caddy 站点根,站点以 current 软链切换)", + "type": "path", + "required": true, + "ask": "目录站放到目标机器的哪个目录?" + } + ], + "external_imports": [ + { + "from": "scripts/skill-catalog.mjs", + "import": "../../skill-interface/scripts/skill-registry.mjs", + "target": "../skill-interface/scripts/skill-registry.mjs" + }, + { + "from": "scripts/skill-catalog.mjs", + "import": "../../skill-interface/scripts/skill-config.mjs", + "target": "../skill-interface/scripts/skill-config.mjs" + }, + { + "from": "scripts/skill-catalog.mjs", + "import": "../../skill-interface/scripts/skill-pack.mjs", + "target": "../skill-interface/scripts/skill-pack.mjs" + } + ], + "contract": { + "version": 1, + "skill": "skill-catalog", + "tier": 1, + "platforms": [ + "darwin", + "linux" + ], + "commands": { + "build": { + "destructive": false + }, + "check": { + "destructive": false + }, + "deploy": { + "destructive": false + }, + "list": { + "destructive": false + }, + "preview": { + "destructive": false + } + }, + "errors": [ + "auth", + "blocked", + "config", + "conflict", + "deps", + "external", + "internal", + "notfound", + "platform", + "timeout", + "usage" + ], + "aliases": [ + "skill-catalog" + ], + "adminAliases": [] + }, + "files": [ + { + "path": "REFERENCE.md", + "bytes": 7578, + "sha256": "759ac4d91bed48ae" + }, + { + "path": "SKILL.md", + "bytes": 1675, + "sha256": "66ae1ca77fa750af" + }, + { + "path": "VERSION", + "bytes": 6, + "sha256": "e9dd8507f4bf0c6f" + }, + { + "path": "assets/app.js", + "bytes": 3114, + "sha256": "bfb8b03ff9d4b971" + }, + { + "path": "assets/style.css", + "bytes": 16162, + "sha256": "16217b36cb3db39d" + }, + { + "path": "contract.lock.json", + "bytes": 616, + "sha256": "8ad8365c09c32df8" + }, + { + "path": "interface.json", + "bytes": 2783, + "sha256": "d413b130d1140cfd" + }, + { + "path": "scripts/setup-hosting.sh", + "bytes": 1366, + "sha256": "911b62832db699d6" + }, + { + "path": "scripts/skill-catalog.mjs", + "bytes": 36074, + "sha256": "d97ba74c71008450" + } + ], + "leak_scan": { + "errors": 0, + "warnings": 0, + "findings": [] + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..84a2e4a --- /dev/null +++ b/README.md @@ -0,0 +1,14 @@ +# skill-catalog + +技能目录站:把对外发布的技能生成一个静态站(能干什么 / 先装什么 / 要配什么 / 依赖谁 / 一条安装命令),生成物不手改页面。 + +以 Pi package(技能形态)发布。 +技能本体在 `skills/skill-catalog/`,用法见其 `SKILL.md` 与 `REFERENCE.md`。 + +## 安装 + +```sh +pi install git:gitea.vhkd.top/geekinney/skill-catalog.git@v0.1.0 +``` + +装完 `pi-skill list` 能看到 `skill-catalog`,`pi-skill skill-catalog check` 会告诉还缺什么。 diff --git a/package.json b/package.json new file mode 100644 index 0000000..c56aebf --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "name": "skill-catalog", + "version": "0.1.0", + "description": "技能目录站:把对外发布的技能生成一个静态站(能干什么 / 先装什么 / 要配什么 / 依赖谁 / 一条安装命令),生成物不手改页面。" +} diff --git a/skills/skill-catalog/REFERENCE.md b/skills/skill-catalog/REFERENCE.md new file mode 100644 index 0000000..a090389 --- /dev/null +++ b/skills/skill-catalog/REFERENCE.md @@ -0,0 +1,117 @@ +# skill-catalog 参考(数据流 / 隐私 / 托管) + +## 1. 数据流 + +``` +skills/*/interface.json(publish / summary / useWhen / commands / requires / config / dependsOn) + + VERSION + contract.lock.json + │ skill-catalog build + ▼ + dist/(index.html + skills/<名字>.html + catalog.json + assets/) ← 生成物,不手改 + │ skill-catalog deploy(tar 走 relay→host) + ▼ + 目标机 /releases/<时间戳>/ ,再切 /current 软链 +``` + +`catalog.json` 是同一份数据给机器读的版本;页面只是它的视图。 + +## 1.1 分层(由依赖事实推导,不人为标注) + +页面把公开集合里的技能分三层,全部由 `dependsOn` 的真实关系算出来: + +| 层 | 判据 | 页面上怎么说 | +|---|---|---| +| 底座 core | 有别的公开技能依赖它 | 「打基础的能力,别的技能建在它上面——通常不用单独装,装组合应用时会一起带上」 | +| 工具 tool | 谁都不依赖、也不被依赖 | 「独立可用,装一个用一个」 | +| 组合应用 app | 它依赖别的公开技能 | 「由多个技能协作完成一件事」;页面给**按顺序的完整安装命令**(依赖在前、本体在后,一键复制) | + +同一个技能如果既依赖别人又被别人依赖,按「组合应用」显示(它是链条中间的一环)。 +详情页的「和其它技能的关系」列出 `依赖` 与 `被使用`,都可点进对方页面。 + +## 1.2 多资源种类(技能 / 扩展 / …)与官方画廊 + +Hub 的名字按「Hub」取(`pi.vhkd.top`),不按单一资源种类;页面按两个维度组织: + +- **kind(种类)**:技能 / 扩展 / 提示模板 / 主题 —— 由「这个包提供了什么」自动分组 + (官方机制里一个 Pi package 的 `package.json` 可以同时带 `extensions` / `skills` / `prompts` / `themes`, + 所以**条目 = 一个已发布的 Pi package**,而不是一个技能;混合包显示「包含:技能 ×1、扩展 ×1」)。 + URL 按 kind 分路径(`/skills/<名>`、`/extensions/<名>`…),新种类不需要重做站点结构。 +- **layer(层)**:技能内部按依赖事实分底座 / 工具 / 组合应用(§1.1)。 + +**官方发现渠道**是另一条互补的路:npm 发布带 `pi-package` 关键字的包 → 自动进 +[pi.dev/packages 画廊](https://pi.dev/packages)(`pi.image`/`pi.video` 可给预览图)。 +自建 Hub 的定位是「中文说明 + 前置依赖 + 分层关系 + 自托管」。 + +## 2. 收录与依赖 + +- **收录**:`publish.repo` 存在才收录(发布过才有仓可装);没有 `VERSION` 的技能会被 warn。 +- **依赖闭包**:`dependsOn` 里出现**未公开**的技能 → 该技能标「依赖未公开,需自备」, + 不给安装命令(装完跑不起来比不装更糟)。要让它可装,先把它依赖的技能也公开。 + +## 3. 隐私边界(生成时强制) + +| 上页 | 不上页 | +|---|---| +| 摘要 / useWhen / 命令与说明 / tier / 平台 | REFERENCE 全文、SKILL.md 正文 | +| `requires`(要装的程序与安装命令) | 任何设置**值**(只列键名与说明) | +| `config` 的键名、说明、是否必配、是否私密 | 本机路径、账号、主机名、工单号 | +| 仓库地址、版本、安装命令 | 运行日志、私有数据文件 | + +生成后对整站跑 `skill-pack` 的泄漏扫描(密钥 / 真实家目录 / 工单号),error 级拒绝产出。 + +## 4. 配置与托管 + +配置(`interface.json` 的 `config` 声明,值在 `local/skills/skill-catalog/config.json` 或 +`PI_SKILL_SKILL_CATALOG_` 环境变量): + +- `site_url`:站点公开地址(用于 canonical 与分享链接); +- `deploy_relay` / `deploy_host`:部署两跳(默认 `lightos` → `aliyun-hk`,即 Gitea 那台阿里云香港机); +- `deploy_path`:目标目录(必填)。站点按 `releases/<时间戳>` + `current` 软链维护,保留最近 5 份。 + +**现网事实(2026-10-02)**:站点跑在 Gitea 那台阿里云香港机(本地经 `lightos` 两跳 ssh, +别名 `aliyun-hk`,公网 `47.243.111.1:22022`);部署目录 `<部署目录>` +(geekinney 可写);Caddy 配置 `/etc/caddy/Caddyfile`(systemd `caddy`;改动需 sudo,先 `caddy validate` +再 `systemctl reload caddy`);`vhkd.top` 的 DNS 在万网(hichina)。内容更新只跑 `deploy`,不用动服务器。 + +**坑(实测 2026-10-02)**:站点根放在用户家目录下时,家目录若是 `0700`,Caddy 的 `file_server` 会 **403** —— +证书能签、TLS 也通,只有内容取不到。修法是沿站点根的祖先链补 `o+x`(只给「可穿过」,不给读列表), +`scripts/setup-hosting.sh` 已内置这一步;用 `namei -l <路径>` 一眼能看出是哪一级卡住。 + +**托管(一次性,需目标机 root)**:Caddy 加一个站点块,把域名指到 `/current`: + +```caddyfile +pi.vhkd.top { + encode zstd gzip + root * <部署目录>/current + file_server +} +``` + +DNS 加一条 `pi.vhkd.top A 47.243.111.1`(或先不加 DNS,用 IP/路径访问也行)。 +改完 `caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile && systemctl reload caddy`。 +之后内容更新只走 `skill-catalog deploy`,不需要再动服务器。 + +## 4.1 字体(好看的前提) + +- **中文正文**:霞鹜文楷 Screen(`lxgw-wenkai-screen-webfont` 的 GB 子集,97 个 unicode-range 分片)。 + `build` 首次会把它下载到 `local/skills/skill-catalog/fonts/`(缓存,不进仓库),再挑该 CSS 引用的分片拷进 `dist/assets/fonts/` + (浏览器按需取几片,不一次性下 4.5MB);`OFL.txt` 随字体一起发(OFL 要求)。下载失败只 warn,页面退回系统字体。 +- **拉丁/等宽**:`PragmataPro` 用 `local()` 引用,**不随站分发**(商用字体);读者本机没装就落到系统等宽。 +- **排印约定**:工具名/版本/命令一律等宽;说明文字用文楷;强调靠字号与颜色,**不用假粗**(文楷网络版只有 400)。 + +## 4.2 设计系统(改页面先读这里) + +- **三声音分工**:标题=衬线(`Source Serif 4` + 思源宋,负字距,靠字号建层级);正文=霞鹜文楷; + 标签/命令/数字=等宽(`PragmataPro`,大写 + 字距)。**不要把标题和正文用同一套字体**,也不用假粗。 +- **颜色只用变量**:`--ink/--muted/--line/--surface/--accent` 一套,语义色(底座/工具/组合应用)只在 + 分区头与图例里以 7px 小圆点出现——**不在卡片上加彩色装饰条**。改色只改 `:root` / `[data-theme="dark"]` 两组变量。 +- **组件统一**:细线边界、统一圆角(`--radius`)、状态只变边框与底色;圆角/间距/字号都从变量取,不即兴写死。 +- **双主题**:明暗各一套变量;`` 里的引导脚本按「上次选择 → 系统偏好」在首帧前设好 `data-theme`, + 导航右侧按钮切换并记住(`localStorage: pi-hub-theme`)。新组件必须两套主题下都看过。 + +## 5. 想加东西往哪加 + +- **新页面/新字段**:数据来自技能声明,先想清楚它是不是「声明里已经有的事实」——是就渲染, + 不是就先补声明(别在生成器里造事实)。 +- **新主题**:改 `assets/style.css`(纯 CSS,无构建);交互在 `assets/app.js`(搜索/筛选/复制)。 +- **换托管**:改 `deploy_relay`/`deploy_host`/`deploy_path` 三个配置,发布命令不变。 diff --git a/skills/skill-catalog/SKILL.md b/skills/skill-catalog/SKILL.md new file mode 100644 index 0000000..d7cc4a6 --- /dev/null +++ b/skills/skill-catalog/SKILL.md @@ -0,0 +1,31 @@ +--- +platforms: [darwin, linux] +--- + +# skill-catalog + +把「对外发布的技能」生成一个**静态目录站**:每个技能一页,写清能干什么、先装什么、 +要配什么、依赖谁、一条安装命令。页面是生成物(`dist/`),永远不手改。 + +**收录规则**:只有 `interface.json` 里声明了 `publish.repo` 的技能才上页(见 +skill-interface 的 REFERENCE §12)。依赖里出现未公开的技能会在页面上标出来且不给安装命令。 + +## 首次使用 + +1. 想公开的技能,在它的 `interface.json` 里声明 `publish`(repo 是 CLI 仓,包仓名默认去 `-cli`); +2. `pi-skill skill-catalog check` —— 看收录规则与部署目标有没有配好(缺 `deploy_path` 会告诉你); +3. `pi-skill skill-catalog build` 生成,`pi-skill skill-catalog preview` 本地看效果; +4. `pi-skill skill-catalog deploy` 发布(走 relay→host 两跳,release 目录 + `current` 软链切换)。 + +## 页面上的隐私边界 + +- **只出现声明元数据**:摘要、用法、命令、依赖、要配的键名(不含值)、版本、仓库地址; +- **不渲染 REFERENCE 全文**,设置值一概不上页; +- 生成后整站过一遍泄漏扫描(密钥 / 真实家目录 / 工单号),error 级直接拒绝产出。 + +## 失败 → 动作 + +- `error=usage` → `pi-skill skill-catalog help`; +- 生成被拦(`blocked: …`)→ 检查技能声明里是否混进了密钥或真实家目录,页面只该出现声明元数据; +- 部署失败 → 确认 `deploy_relay`/`deploy_host` 能 ssh、`deploy_path` 可写;Caddy 站点根的 + 一次性配置见 REFERENCE「托管」。 diff --git a/skills/skill-catalog/VERSION b/skills/skill-catalog/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/skills/skill-catalog/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/skills/skill-catalog/assets/app.js b/skills/skill-catalog/assets/app.js new file mode 100644 index 0000000..947e4c0 --- /dev/null +++ b/skills/skill-catalog/assets/app.js @@ -0,0 +1,81 @@ +// 目录站交互:搜索、按层/依赖筛选、复制命令。无依赖、无构建。 +(() => { + const search = document.getElementById('search'); + const count = document.getElementById('count'); + const empty = document.getElementById('empty'); + const sections = [...document.querySelectorAll('.layer-section')]; + const cards = [...document.querySelectorAll('.card')]; + const reqFilters = [...document.querySelectorAll('.chip[data-requires]')]; + const layerFilters = [...document.querySelectorAll('.seg[data-layer-filter]')]; + if (!cards.length) return; + const activeRequires = new Set(); + const activeLayers = new Set(); + + const apply = () => { + const query = (search?.value ?? '').trim().toLowerCase(); + let shown = 0; + for (const card of cards) { + const haystack = (card.dataset.search ?? '').toLowerCase(); + const matchQuery = !query || haystack.includes(query); + const matchRequires = activeRequires.size === 0 || [...activeRequires].every(name => haystack.includes(name.toLowerCase())); + const matchLayer = activeLayers.size === 0 || activeLayers.has(card.dataset.layer); + const visible = matchQuery && matchRequires && matchLayer; + card.hidden = !visible; + if (visible) shown += 1; + } + for (const section of sections) { + section.hidden = ![...section.querySelectorAll('.card')].some(card => !card.hidden); + } + if (count) count.textContent = String(shown); + if (empty) empty.hidden = shown !== 0; + }; + + search?.addEventListener('input', apply); + for (const chip of reqFilters) { + chip.addEventListener('click', () => { + const name = chip.dataset.requires; + if (activeRequires.has(name)) activeRequires.delete(name); + else activeRequires.add(name); + chip.classList.toggle('active', activeRequires.has(name)); + apply(); + }); + } + for (const chip of layerFilters) { + chip.addEventListener('click', () => { + const layer = chip.dataset.layerFilter; + if (activeLayers.has(layer)) activeLayers.delete(layer); + else activeLayers.add(layer); + chip.classList.toggle('active', activeLayers.has(layer)); + apply(); + }); + } + + for (const button of document.querySelectorAll('.copy')) { + const label = button.textContent; + button.addEventListener('click', async () => { + const text = button.dataset.copy ?? ''; + try { + await navigator.clipboard.writeText(text); + button.textContent = '已复制'; + } catch { + button.textContent = '按 ⌘C'; + } + setTimeout(() => (button.textContent = label), 1500); + }); + } + + // 主题切换:写进 并记住;引导脚本已在首帧前设好初值。 + const toggle = document.getElementById('theme-toggle'); + toggle?.addEventListener('click', () => { + const root = document.documentElement; + const next = root.dataset.theme === 'dark' ? 'light' : 'dark'; + root.dataset.theme = next; + try { + localStorage.setItem('pi-hub-theme', next); + } catch { + /* 隐私模式下存不了就只在本次会话生效 */ + } + }); + + apply(); +})(); diff --git a/skills/skill-catalog/assets/style.css b/skills/skill-catalog/assets/style.css new file mode 100644 index 0000000..041c4e5 --- /dev/null +++ b/skills/skill-catalog/assets/style.css @@ -0,0 +1,336 @@ +/* Pi Hub —— 自己的设计语言,不照抄任何参考站。 + 声音分工:标题=衬线(表现力);正文=霞鹜文楷(阅读);标签/命令/数字=等宽(精确)。 + 明暗双主题:跟随系统,可手动切换并记住(引导脚本在 里先跑)。 + 组件统一:细线边界、统一圆角与间距、状态只在边框与底色上变化,语义色只用小圆点。 */ + +:root { + color-scheme: light; + --bg: #fbfbf9; + --bg-soft: #f5f5f2; + --surface: #ffffff; + --surface-2: #f7f7f4; + --ink: #1b1e24; + --ink-2: #454b56; + --muted: #767c88; + --faint: #a3a8b2; + --line: #e6e6e2; + --line-2: #d2d2cd; + --accent: #2f5bd7; + --accent-ink: #ffffff; + --warn: #b4671c; + --core: #6d5ce0; + --tool: #2f8f5b; + --app: #cf5470; + --well: #f5f5f2; + --radius: 3px; + + /* 尺度:字号与间距只用这组值,避免「一处一个数」 */ + --t-xs: 11.5px; + --t-sm: 13px; + --t-base: 16.5px; + --t-md: 18px; + --t-lg: 22px; + --t-xl: 30px; + --t-2xl: 40px; + --s-1: 4px; + --s-2: 8px; + --s-3: 12px; + --s-4: 16px; + --s-5: 24px; + --s-6: 32px; + --s-7: 48px; + --s-8: 64px; + /* 中文行长:一行 24–30 字最好读;--measure 是正文,--measure-tight 是导语 */ + --measure: 30em; + --measure-tight: 24em; + --serif: "Source Serif 4", "Noto Serif SC", Georgia, "Songti SC", serif; + --kai: "LXGW WenKai Screen", "LXGW WenKai", var(--serif); + --mono: "PragmataPro", "Pragmata Pro", "SFMono-Regular", ui-monospace, Menlo, monospace; +} + +[data-theme="dark"] { + color-scheme: dark; + --bg: #101114; + --bg-soft: #141619; + --surface: #191b1f; + --surface-2: #202327; + --ink: #eceef2; + --ink-2: #c2c7d0; + --muted: #8b929e; + --faint: #6b7280; + --line: #26292e; + --line-2: #383c43; + --accent: #93aaff; + --accent-ink: #0e1013; + --warn: #e0a458; + --core: #a99bff; + --tool: #63cf93; + --app: #f1809b; + --well: #14161a; +} + +* { box-sizing: border-box; } + +/* 断行:标题两端平衡;段落避免尾行只剩一两个字;「技能 · 扩展 · …」这类 + 语义组用 .nowrap 整体换行,不允许拆到两行。 */ +h1, h2, h3 { text-wrap: balance; } +p, li, .lede, .summary, .panel-hint { text-wrap: pretty; } +.nowrap { white-space: nowrap; } + +body { + margin: 0; + background: var(--bg); + color: var(--ink); + font: var(--t-base)/1.85 var(--kai); + letter-spacing: 0.005em; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +/* 标题:衬线,紧字距,靠字号建立层级(不用粗体堆叠)。 */ +h1, h2, h3, h4, .display { + font-family: var(--serif); + font-weight: 400; + letter-spacing: -0.015em; + color: var(--ink); +} +h1 { font-size: clamp(26px, 3.2vw, var(--t-2xl)); line-height: 1.1; letter-spacing: -0.028em; margin: 0 0 var(--s-4); max-width: var(--measure-tight); } + +/* 标签 / 导航 / 元信息:等宽小字,字距拉开,全大写。 */ +.label, .nav a, .nav-brand, .ver, .stat, .count, .panel-head, .badge, .rel, .eyebrow, +.card-foot, .foot h4, .foot-line, .cmd-step { + font-family: var(--mono); + font-size: 11.5px; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +a { color: inherit; text-decoration: none; } +a:hover { color: var(--accent); } +code { font-family: var(--mono); font-size: 0.86em; letter-spacing: 0; } + +:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 2px; } + +.page { max-width: 1120px; margin: 0 auto; padding: 0 30px 96px; } + +/* ── 导航 ─────────────────────────────────────────── */ +.nav { + position: sticky; top: 0; z-index: 10; + display: flex; align-items: center; justify-content: space-between; gap: var(--s-5); + padding: var(--s-4) 0; margin-bottom: var(--s-8); + border-bottom: 1px solid var(--line); + background: color-mix(in srgb, var(--bg) 88%, transparent); + backdrop-filter: blur(10px); +} +.nav-brand { display: flex; align-items: center; gap: 10px; letter-spacing: .1em; } +.brand-mark { + width: 24px; height: 24px; border-radius: 2px; + display: grid; place-items: center; + background: var(--ink); color: var(--bg); + font-family: var(--serif); font-size: 15px; line-height: 1; letter-spacing: 0; +} +.nav-brand strong { font-weight: 400; } +.nav-brand em { font-style: normal; color: var(--faint); margin-left: 6px; } +.nav-links { display: flex; align-items: center; gap: 22px; color: var(--muted); } +.nav-links a:hover { color: var(--ink); } +.theme-toggle { + display: grid; place-items: center; cursor: pointer; + width: 30px; height: 30px; padding: 0; + background: transparent; color: var(--muted); + border: 1px solid var(--line); border-radius: 3px; +} +.theme-toggle:hover { color: var(--ink); border-color: var(--line-2); } +.theme-toggle svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 1.6; } +[data-theme="light"] .icon-moon { display: none; } +[data-theme="dark"] .icon-sun { display: none; } + +/* ── 主视觉 ───────────────────────────────────────── */ +.masthead { margin-bottom: var(--s-7); } +.eyebrow { color: var(--faint); margin: 0 0 20px; } +.lede { color: var(--ink-2); font-size: var(--t-md); margin: 0 0 var(--s-5); max-width: var(--measure-tight); } + +.cta { + display: inline-flex; align-items: center; gap: 10px; max-width: 100%; + border: 1px solid var(--line); border-radius: var(--radius); + background: var(--surface); padding: 10px 12px; margin: 0 0 10px; +} +.cta-cmd { + font-family: var(--mono); font-size: 13px; color: var(--ink); + border-bottom: 1px solid var(--line-2); padding-bottom: 1px; + overflow-wrap: anywhere; +} +.cta-hint { display: block; font-size: var(--t-sm); color: var(--muted); margin: 0 0 var(--s-5); } +.cta-hint a { color: var(--ink-2); border-bottom: 1px solid var(--line-2); } + +.meta { display: flex; align-items: center; gap: 20px; flex-wrap: wrap; color: var(--muted); } +.stat { color: var(--ink-2); } +.legend { display: flex; gap: 16px; } +.legend-item { display: inline-flex; align-items: center; gap: 7px; font-size: 13px; } +.dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; background: var(--faint); flex: 0 0 auto; } +[data-layer="core"] .dot, .legend-item.core .dot { background: var(--core); } +[data-layer="tool"] .dot, .legend-item.tool .dot { background: var(--tool); } +[data-layer="app"] .dot, .legend-item.app .dot { background: var(--app); } +.hint { color: var(--faint); font-size: 13px; } + +/* ── 工具条 ───────────────────────────────────────── */ +.toolbar { + position: sticky; top: 57px; z-index: 9; + display: flex; align-items: center; gap: 12px; flex-wrap: wrap; + padding: 12px 0; margin-bottom: 22px; + background: color-mix(in srgb, var(--bg) 92%, transparent); + backdrop-filter: blur(10px); +} +#search { + flex: 1 1 240px; min-width: 180px; + background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); + color: var(--ink); padding: 9px 13px; font: var(--t-sm)/1.6 var(--kai); + outline: none; transition: border-color .15s; +} +#search::placeholder { color: var(--faint); } +#search:focus { border-color: var(--line-2); } + +.segmented { display: inline-flex; border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; } +.seg { + border: 0; border-left: 1px solid var(--line); background: transparent; color: var(--muted); + cursor: pointer; font: 12px/1.6 var(--mono); letter-spacing: .06em; padding: 8px 13px; transition: background .15s, color .15s; +} +.seg:first-child { border-left: 0; } +.seg:hover { color: var(--ink); background: var(--surface-2); } +.seg.active { background: var(--ink); color: var(--bg); } +.count { color: var(--faint); margin-left: auto; } + +.chipbar { display: flex; gap: 8px; flex-wrap: wrap; margin: 0 0 26px; } +.chip { + display: inline-flex; align-items: center; gap: 6px; + border: 1px solid var(--line); background: transparent; color: var(--muted); + border-radius: 999px; padding: 2px 10px; font: 12px/1.7 var(--mono); letter-spacing: .04em; +} +button.chip { cursor: pointer; transition: all .15s; } +button.chip:hover { color: var(--ink); border-color: var(--line-2); } +button.chip.active { color: var(--accent); border-color: currentColor; } +.chip-warn { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 40%, transparent); } + +/* ── 分区面板 ─────────────────────────────────────── */ +.panel { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); margin-bottom: var(--s-5); overflow: hidden; } +.panel-head { + display: flex; align-items: center; gap: 10px; + padding: 11px 16px; border-bottom: 1px solid var(--line); + color: var(--ink-2); background: var(--surface-2); +} +.panel-count { color: var(--faint); } +.panel-hint { margin-left: auto; color: var(--faint); letter-spacing: .04em; text-transform: none; font-family: var(--kai); font-size: 12.5px; } +.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(292px, 1fr)); } +.card { + display: flex; flex-direction: column; gap: var(--s-2); + padding: var(--s-4) var(--s-5) var(--s-4); + border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); + transition: background .15s; +} +.card:hover { background: var(--surface-2); } +.card-top { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; } +.card h3 { margin: 0; font-family: var(--mono); font-size: 15px; letter-spacing: 0; } +.card .ver { color: var(--faint); } +.card .summary { + margin: 0; color: var(--ink-2); font-size: var(--t-sm); line-height: 1.8; + display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; +} +.card-foot { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; color: var(--faint); margin-top: auto; padding-top: 4px; } +.card-layer { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); letter-spacing: .08em; } +.card-foot .chip { padding: 1px 8px; font-size: 11px; letter-spacing: .04em; text-transform: none; } +.used { margin-left: auto; } + +/* ── 详情页 ───────────────────────────────────────── */ +.back { color: var(--muted); font-size: 13px; } +.detail-head { margin-bottom: 34px; } +.title-row { display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap; margin: 0 0 12px; } +.title-row h1 { font-family: var(--mono); font-size: clamp(26px, 3.4vw, 34px); letter-spacing: -0.01em; margin: 0; max-width: none; } +.title-row .ver { color: var(--faint); } +.detail-head .chips { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 16px; } + +.detail-grid { display: grid; grid-template-columns: minmax(0, 1fr) 290px; gap: 36px; align-items: start; } +@media (max-width: 900px) { .detail-grid { grid-template-columns: 1fr; } } + +.block { margin: 0 0 var(--s-6); } +.block h2 { + font-size: 13px; font-family: var(--mono); letter-spacing: .12em; text-transform: uppercase; + color: var(--muted); font-weight: 400; margin: 0 0 14px; padding-bottom: 8px; border-bottom: 1px solid var(--line); +} + +.well { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); margin-bottom: 12px; } +.well.together { border-color: color-mix(in srgb, var(--accent) 34%, var(--line)); } +.well-title { margin: 0; padding: 12px 16px; border-bottom: 1px solid var(--line); color: var(--ink-2); font-size: 14px; } +.well-title .muted { color: var(--faint); font-size: 12.5px; } +.well-note { margin: 0; padding: 12px 16px; color: var(--muted); font-size: 13px; border-top: 1px solid var(--line); } +.well-foot { display: flex; justify-content: flex-end; padding: 12px 16px; border-top: 1px solid var(--line); } +.well.blocked { border-color: color-mix(in srgb, var(--warn) 40%, var(--line)); padding: 14px 16px; } + +.cmd { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border-bottom: 1px solid var(--line); } +.cmd:last-child { border-bottom: 0; } +.cmd-step { flex: 0 0 auto; color: var(--faint); letter-spacing: .06em; } +.cmd code { flex: 1 1 auto; overflow-wrap: anywhere; color: var(--ink); } +.cmd-tag { color: var(--faint); font-size: 12px; } + +.copy { + flex: 0 0 auto; cursor: pointer; font: 12px/1.6 var(--mono); letter-spacing: .04em; + background: transparent; color: var(--muted); border: 1px solid var(--line); + border-radius: var(--radius); padding: 5px 11px; transition: all .15s; +} +.copy:hover { color: var(--ink); border-color: var(--line-2); background: var(--surface-2); } +.copy.primary { background: var(--ink); color: var(--bg); border-color: var(--ink); padding: 7px 14px; } +.copy.primary:hover { opacity: .88; color: var(--bg); background: var(--ink); } + +.facts { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 12px; } +.fact { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); padding: 14px 16px; } +.fact-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; } +.fact-name { font-family: var(--mono); font-size: 13.5px; } +.fact-kind { margin-left: auto; color: var(--faint); font-size: 11.5px; } +.fact p { margin: 0 0 var(--s-1); color: var(--muted); font-size: var(--t-sm); } +.fact .ask { color: var(--ink-2); } +.inline-cmd { display: inline-block; margin-top: 4px; padding: 3px 8px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--well); } +.badge { color: var(--muted); border: 1px solid var(--line); border-radius: 999px; padding: 0 8px; } +.badge-req { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 40%, transparent); } + +.commands { width: 100%; border-collapse: collapse; } +.commands td { padding: 10px 14px; border-bottom: 1px solid var(--line); vertical-align: top; font-size: 14px; color: var(--ink-2); } +.commands tr:last-child td { border-bottom: 0; } +.commands tr:hover td { background: var(--surface-2); } +.cmd-cell { white-space: nowrap; } +.cmd-cell code { color: var(--ink); } +.cmd-cell .badge { margin-left: 8px; } + +.panel.side { margin-bottom: 14px; } +.panel.side .panel-head { padding: 10px 14px; } +.panel.side .panel-body { padding: 14px; } +.relations, .repos { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; } +.relations li, .repos li { display: flex; align-items: center; gap: 9px; font-size: 13px; min-width: 0; } +.relations a, .repos a { font-family: var(--mono); font-size: 12px; color: var(--ink-2); overflow-wrap: anywhere; } +.relations a:hover, .repos a:hover { color: var(--accent); } +.rel { color: var(--faint); border: 1px solid var(--line); border-radius: 999px; padding: 0 7px; flex: 0 0 auto; } +.rel-depend { color: var(--core); border-color: color-mix(in srgb, var(--core) 36%, transparent); } +.rel-used { color: var(--tool); border-color: color-mix(in srgb, var(--tool) 36%, transparent); } + +.muted { color: var(--muted); } +.small { font-size: 13px; } +.empty { text-align: center; padding: 40px 0; color: var(--muted); } + +/* ── 页脚 ─────────────────────────────────────────── */ +.foot { margin-top: 72px; padding-top: 26px; border-top: 1px solid var(--line); } +.foot-cols { display: grid; grid-template-columns: 1.4fr 1fr 1fr; gap: 28px; margin-bottom: 24px; } +.foot h4 { margin: 0 0 10px; color: var(--muted); } +.foot p, .foot li { margin: 0 0 var(--s-1); color: var(--faint); font-size: var(--t-sm); line-height: 1.8; max-width: var(--measure); } +.foot ul { list-style: none; margin: 0; padding: 0; } +.foot a { color: var(--muted); } +.foot a:hover { color: var(--ink); } +.foot-line { border-top: 1px solid var(--line); padding-top: 14px; color: var(--faint); } + +@media (max-width: 720px) { + .page { padding: 0 18px 72px; } + .nav { margin-bottom: 34px; } + .nav-links { gap: 14px; } + .panel-hint { display: none; } + .foot-cols { grid-template-columns: 1fr; } +} + +@media (prefers-reduced-motion: reduce) { + * { transition: none !important; } +} diff --git a/skills/skill-catalog/contract.lock.json b/skills/skill-catalog/contract.lock.json new file mode 100644 index 0000000..e9eb931 --- /dev/null +++ b/skills/skill-catalog/contract.lock.json @@ -0,0 +1,43 @@ +{ + "version": 1, + "skill": "skill-catalog", + "tier": 1, + "platforms": [ + "darwin", + "linux" + ], + "commands": { + "build": { + "destructive": false + }, + "check": { + "destructive": false + }, + "deploy": { + "destructive": false + }, + "list": { + "destructive": false + }, + "preview": { + "destructive": false + } + }, + "errors": [ + "auth", + "blocked", + "config", + "conflict", + "deps", + "external", + "internal", + "notfound", + "platform", + "timeout", + "usage" + ], + "aliases": [ + "skill-catalog" + ], + "adminAliases": [] +} diff --git a/skills/skill-catalog/interface.json b/skills/skill-catalog/interface.json new file mode 100644 index 0000000..936f288 --- /dev/null +++ b/skills/skill-catalog/interface.json @@ -0,0 +1,106 @@ +{ + "summary": "技能目录站:把对外发布的技能生成一个静态站(能干什么 / 先装什么 / 要配什么 / 依赖谁 / 一条安装命令),生成物不手改页面。", + "useWhen": "要更新或发布技能目录页时;想看清哪些技能可公开、依赖闭包是否完整时。", + "tier": 1, + "entry": { + "path": "scripts/skill-catalog.mjs" + }, + "aliases": [ + "skill-catalog" + ], + "commands": [ + { + "name": "build", + "summary": "生成静态站(只收录声明了 publish 的技能)" + }, + { + "name": "preview", + "summary": "生成并本地预览目录站" + }, + { + "name": "deploy", + "summary": "把生成的站点发布到部署目标(relay→host,release + current 切换)" + }, + { + "name": "list", + "summary": "收录清单与依赖闭包状态" + }, + { + "name": "check", + "summary": "只读自检:收录规则与部署目标配置" + } + ], + "errors": [ + "usage", + "deps", + "platform", + "auth", + "config", + "notfound", + "conflict", + "blocked", + "timeout", + "external", + "internal" + ], + "config": [ + { + "key": "site_url", + "desc": "站点公开地址(写进页面的 canonical 与分享链接)", + "type": "url" + }, + { + "key": "deploy_relay", + "desc": "部署用的跳板机(本地能 ssh 的别名)", + "type": "string", + "default": "lightos" + }, + { + "key": "deploy_host", + "desc": "目标主机(在跳板机上能 ssh 的别名)", + "type": "string", + "default": "aliyun-hk" + }, + { + "key": "deploy_path", + "desc": "目标目录(Caddy 站点根,站点以 current 软链切换)", + "type": "path", + "required": true, + "ask": "目录站放到目标机器的哪个目录?" + } + ], + "dependsOn": [ + { + "skill": "skill-interface", + "tierAtLeast": 1, + "note": "技能发现与接口解析 skill-registry.mjs、配置契约 skill-config.mjs、泄漏扫描 skill-pack.mjs" + } + ], + "requires": [ + { + "kind": "runtime", + "name": "node", + "check": "which node", + "note": "生成器;零 npm 依赖" + }, + { + "kind": "runtime", + "name": "python3", + "check": "which python3", + "note": "预览用的本地文件服务;部署时的 tar/ssh 走系统命令" + }, + { + "kind": "binary", + "name": "tar", + "note": "部署打包(tar)与字体下载/上线自检(curl)" + }, + { + "kind": "binary", + "name": "curl", + "note": "部署打包(tar)与字体下载/上线自检(curl)" + } + ], + "publish": { + "package_repo": "https://gitea.vhkd.top/geekinney/skill-catalog.git" + } +} diff --git a/skills/skill-catalog/scripts/setup-hosting.sh b/skills/skill-catalog/scripts/setup-hosting.sh new file mode 100755 index 0000000..cad74e0 --- /dev/null +++ b/skills/skill-catalog/scripts/setup-hosting.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# 一次性托管步骤:给目录站在 Caddy 里加一个站点块(幂等;需 sudo)。 +# 在承载 Caddy 的机器上运行:scripts/setup-hosting.sh [域名] [站点根] +set -Eeuo pipefail +DOMAIN="${1:-pi.vhkd.top}" +ROOT="${2:-<部署目录>/current}" +CONF=/etc/caddy/Caddyfile +sudo -v +if grep -qE "^${DOMAIN//./\\.} \{" "$CONF"; then + echo "已有 ${DOMAIN} 站点块,跳过" +else + BACKUP="$CONF.bak.skill-catalog.$(date +%Y%m%d-%H%M%S)" + sudo cp "$CONF" "$BACKUP" + printf '\n%s {\n\tencode zstd gzip\n\troot * %s\n\tfile_server\n}\n' "$DOMAIN" "$ROOT" | sudo tee -a "$CONF" >/dev/null + if ! sudo caddy validate --config "$CONF" --adapter caddyfile; then + sudo cp "$BACKUP" "$CONF"; echo "配置校验失败,已回滚 $BACKUP" >&2; exit 3 + fi +fi +# 让 caddy 用户能「穿过」站点根的祖先目录(只补 o+x,不给读列表权限)。 +# 家目录默认 0700 时 file_server 会 403。 +path="$(dirname "$ROOT")" +while [ "$path" != "/" ]; do + perms="$(stat -c '%a' "$path" 2>/dev/null || echo 755)" + if [ $(( (8#$perms) & 1 )) -ne 1 ]; then + echo "补充可穿过权限:sudo chmod o+x $path(原 $perms)" + sudo chmod o+x "$path" + fi + path="$(dirname "$path")" +done + +sudo systemctl reload caddy +curl -s -o /dev/null -w "${DOMAIN} 首页: %{http_code}\n" --max-time 15 "https://${DOMAIN}/" || true diff --git a/skills/skill-catalog/scripts/skill-catalog.mjs b/skills/skill-catalog/scripts/skill-catalog.mjs new file mode 100644 index 0000000..4529573 --- /dev/null +++ b/skills/skill-catalog/scripts/skill-catalog.mjs @@ -0,0 +1,728 @@ +#!/usr/bin/env node +/** + * skill-catalog — 把「对外发布的技能」生成一个静态目录站(生成物,页面永不手改)。 + * + * 数据全部来自技能自己的声明:interface.json(summary/useWhen/commands/requires/ + * config/dependsOn/publish)、VERSION、contract.lock.json。只收录声明了 publish 的技能; + * 页面不渲染 REFERENCE 全文,配置只列「要配什么」不列值。 + * + * 隐私门禁:生成后对整个 dist 跑一遍 skill-pack 的泄漏扫描(密钥/真实家目录/工单号), + * error 级直接拒绝产出。 + * + * 用法: + * skill-catalog build [--out DIR] [--json] 生成静态站 + * skill-catalog preview [--port N] 生成并在本地起预览(打印 URL) + * skill-catalog deploy [--dry-run] [--json] 生成的 dist 推到部署目标(relay→host,release+current) + * skill-catalog list [--json] 收录清单与依赖闭包状态 + * + * 配置(interface.json 的 config 声明;值在 local/skills/skill-catalog/config.json): + * site_url / deploy_relay / deploy_host / deploy_path + * + * 退出码:0 成功;2 用法;3 被门禁拦下(含泄漏扫描 error)。 + */ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { loadSkills, repoRoot, agentDir } from '../../skill-interface/scripts/skill-registry.mjs'; +import { resolveConfig, agentDirOf } from '../../skill-interface/scripts/skill-config.mjs'; +import { scanLeaks } from '../../skill-interface/scripts/skill-pack.mjs'; + +const SKILL = 'skill-catalog'; +const BRAND = { name: 'Pi Hub', tagline: '为 Pi 装上你要的能力', scope: '技能 · 扩展 · 主题 · 提示模板' }; +const SCOPE_HTML = '技能 · 扩展 · 主题 · 提示模板'; +// 字体三套(都 OFL,可自托管):正文楷(读者喜欢的)+ 标题衬线(中文用思源宋)+ +// 拉丁/等宽 PragmataPro(本机 local(),商用字体不随站分发)。 +const FONTS = [ + { + key: 'kai', + package: 'lxgw-wenkai-screen-webfont', + version: '1.7.0', + css: 'lxgwwenkaigbscreen.css', + include: /^lxgwwenkaigbscreen-subset-\d+\.woff2$/, + family: 'LXGW WenKai Screen', + }, + { + key: 'serif', + package: '@fontsource/source-serif-4', + version: '5.3.0', + css: '400.css', + include: /^source-serif-4-(latin|latin-ext)-400-normal\.woff2$/, + family: 'Source Serif 4', + }, + { + key: 'serif-sc', + package: '@fontsource/noto-serif-sc', + version: '5.3.0', + css: '400.css', + include: /^noto-serif-sc-\d+-400-normal\.woff2$/, + family: 'Noto Serif SC', + }, +]; +const SKILL_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const EXIT_OK = 0; +const EXIT_USAGE = 2; +const EXIT_BLOCKED = 3; + +const USAGE = `skill-catalog — 生成并发布「技能目录站」(生成物,不手改页面) + +skill-catalog build [--out DIR] [--json] 生成静态站(默认 <技能目录>/dist) +skill-catalog preview [--port N] 生成 + 本地预览(默认 8788) +skill-catalog deploy [--dry-run] [--json] 推到部署目标(relay→host 两跳,release + current 切换) +skill-catalog list [--json] 收录哪些技能、依赖闭包是否完整(只读) +skill-catalog check 只读自检:收录规则与部署目标配置 + +收录规则:interface.json 里声明了 publish.repo 的技能才上页;依赖里出现未公开技能会标出来。 +配置:site_url / deploy_relay / deploy_host / deploy_path(local/skills/skill-catalog/config.json)。 +退出码:0 成功;2 用法;3 被门禁拦下(泄漏扫描 error)。`; + +/** CLI 仓名 → 包仓名:去掉 -cli(与 skill-release 同一约定)。 */ +export const packageRepoOf = cliRepo => cliRepo.replace(/-cli(\.git)?$/, '$1'); + +/** `https://host/a/b.git` / `git@host:a/b.git` → `git:host/a/b`(pi install 的写法)。 */ +export const gitSourceOf = url => `git:${url.replace(/^https?:\/\//, '').replace(/^git@([^:]+):/, '$1/').replace(/\.git$/, '')}`; + +export const piInstallOf = (packageRepo, version) => `pi install ${gitSourceOf(packageRepo)}@v${version}`; +export const cliInstallOf = cliRepo => `git clone ${cliRepo} && cd ${path.basename(cliRepo.replace(/\.git$/, ''))} && ./scripts/install-cli.sh`; + +const readVersion = dir => { + try { + return fs.readFileSync(path.join(dir, 'VERSION'), 'utf8').trim(); + } catch { + return ''; + } +}; + +/** + * 收集可公开的技能;返回 { entries, warnings }。 + * entry 只含可以给外人看的信息:声明里的元数据 + 安装命令 + 依赖状态。 + */ +export function collectCatalog(repo = repoRoot()) { + const skills = loadSkills(repo); + const publishOf = skill => skill.declared?.publish ?? null; + const isPublic = skill => Boolean(publishOf(skill)?.repo || publishOf(skill)?.package_repo); + const publicNames = new Set(skills.filter(isPublic).map(skill => skill.name)); + const warnings = []; + const entries = skills + .filter(isPublic) + .map(skill => { + const declared = skill.declared; + const version = readVersion(skill.dir); + if (!version) warnings.push(`${skill.name}: 没有 VERSION(安装命令的版本号会是 unknown)`); + const cliRepo = declared.publish.repo ?? ''; + const packageRepo = declared.publish.package_repo ?? (cliRepo ? packageRepoOf(cliRepo) : ''); + const dependencies = (declared.dependsOn ?? []).map(item => ({ + name: item.skill, + public: publicNames.has(item.skill), + note: item.note ?? '', + })); + const missingDependencies = dependencies.filter(item => !item.public).map(item => item.name); + if (missingDependencies.length) warnings.push(`${skill.name}: 依赖未公开技能 ${missingDependencies.join(', ')},装完可能跑不起来`); + return { + name: skill.name, + status: declared.status ?? 'stable', + declaredLayer: declared.catalog?.layer ?? null, + summary: declared.summary ?? '', + useWhen: declared.useWhen ?? '', + tier: declared.tier, + version, + platforms: declared.platforms ?? ['all'], + commands: (declared.commands ?? []).map(command => ({ + name: command.name, + summary: command.summary ?? '', + destructive: Boolean(command.destructive), + })), + requires: (declared.requires ?? []).map(item => ({ + kind: item.kind, + name: item.name, + install: item.install ?? '', + check: item.check ?? '', + note: item.note ?? '', + })), + // 只列「要配什么」,永不带值。 + config: (declared.config ?? []).map(item => ({ + key: item.key, + desc: item.desc ?? '', + required: Boolean(item.required), + secret: Boolean(item.secret), + ask: item.ask ?? '', + })), + dependencies, + installable: missingDependencies.length === 0, + install: { + pi: packageRepo ? piInstallOf(packageRepo, version || 'unknown') : '', + cli: cliRepo ? cliInstallOf(cliRepo) : '', + }, + repos: { package: packageRepo, cli: cliRepo, mirrors: declared.publish.mirrors ?? [] }, + }; + }); + entries.sort((left, right) => left.name.localeCompare(right.name)); + // 分层与关系全部由公开集合里的依赖事实推导(不发明分类): + // 底座 core = 有别的公开技能依赖它(别人建在它上面) + // 工具 tool = 谁都不依赖、也不依赖谁(装它就能用) + // 应用 app = 依赖别的公开技能(组合出来的,装它要先装依赖) + for (const entry of entries) entry.usesPublic = entry.dependencies.filter(item => item.public).map(item => item.name); + for (const entry of entries) entry.usedBy = entries.filter(other => other.usesPublic.includes(entry.name)).map(other => other.name); + // 底座的分量来自整个生态:公开页只露数字,不露未公开技能的名字。 + const allUsers = new Map(); + for (const other of skills) { + for (const dep of other.declared?.dependsOn ?? []) { + allUsers.set(dep.skill, (allUsers.get(dep.skill) ?? 0) + 1); + } + } + for (const entry of entries) entry.usedByCount = allUsers.get(entry.name) ?? 0; + for (const entry of entries) entry.layer = entry.usesPublic.length ? 'app' : entry.usedBy.length ? 'core' : 'tool'; + // 技能可声明 catalog.layer 表达「规划中的定位」(例如底座即使暂时没有公开依赖者也成立); + // 未声明时用依赖事实推导,声明只覆盖分层,不改「被谁使用」这类事实。 + for (const entry of entries) { + const declaredLayer = entry.declaredLayer; + if (declaredLayer) entry.layer = declaredLayer; + } + const order = (entry, seen = new Set()) => { + if (seen.has(entry.name)) return []; + seen.add(entry.name); + return [...entry.usesPublic.flatMap(name => { + const dependency = entries.find(item => item.name === name); + return dependency ? order(dependency, seen) : []; + }), entry]; + }; + for (const entry of entries) entry.installOrder = order(entry).map(item => item.name); + return { entries, warnings }; +} + +const escapeHtml = value => + String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + +const kindLabel = { runtime: '运行环境', binary: '命令', service: '服务', account: '账号' }; + +function siteNav(depth = 0) { + const prefix = depth === 0 ? '' : '../'; + return ``; +} + +function siteFoot(depth = 0) { + const prefix = depth === 0 ? '' : '../'; + return `
+
+

关于

这里收录的条目都从技能自己的声明生成,和仓库同步;页面不手改。

+ +

字体

    +
  • 中文 霞鹜文楷 Screen(OFL)
  • +
  • 拉丁 Pragmata Pro(读者本机)
  • +
+
+

${BRAND.name} · 由 skill-catalog 生成 · 条目与技能声明一致

+
`; +} + +function pageShell({ title, description, body, siteUrl, depth = 0, fontsCss = null }) { + const prefix = depth === 0 ? '' : '../'; + return ` + + + + + +${escapeHtml(title)} + +${fontsCss ? fontsCss.map(href => ``).join('\n') : ''} + +${siteUrl ? `` : ''} + + +
+${siteNav(depth)} +${body} +${siteFoot(depth)} +
+ + + +`; +} + +const LAYER_LABEL = { core: '底座', tool: '工具', app: '组合应用' }; +const LAYER_HINT = { + core: '别的技能建在它上面', + tool: '独立可用,装完就用', + app: '由多个技能协作完成', +}; + +function cardOf(entry) { + const facts = [ + ...entry.requires.slice(0, 3).map(item => `${escapeHtml(item.name)}`), + ...(entry.installable ? [] : ['依赖未公开']), + ].join(''); + const used = entry.usedByCount ? `被 ${entry.usedByCount} 个技能使用` : ''; + return ` +

${escapeHtml(entry.name)}

v${escapeHtml(entry.version)}
+

${escapeHtml(entry.summary)}

+
+ ${LAYER_LABEL[entry.layer]} + ${facts}${used} +
+
`; +} + +function detailPage(entry, siteUrl, byName, fontsCss) { + const steps = entry.installOrder.map(name => byName.get(name)).filter(Boolean); + const together = + steps.length > 1 + ? `
+

一次装齐 按顺序执行 ${steps.length} 条

+${steps + .map( + (item, index) => + `
${index + 1}${escapeHtml(item.install.pi)}${item.name === entry.name ? '本体' : '依赖'}
`, + ) + .join('\n')} +
+
` + : ''; + + const installRows = [ + entry.install.pi + ? `
Pi${escapeHtml(entry.install.pi)}
` + : '', + entry.install.cli + ? `
CLI${escapeHtml(entry.install.cli)}
` + : '', + ].join('\n'); + const installBlock = entry.installable + ? `${together} +
+${installRows} +

装完跑 pi-skill ${escapeHtml(entry.name)} check${entry.install.cli ? `(Pi)或 ${escapeHtml(entry.name)} help(命令行)` : ''}。

+
` + : `

依赖未公开组件,单独装跑不起来,暂不提供安装命令。

`; + + const requires = entry.requires.length + ? `
${entry.requires + .map( + item => `
+
${escapeHtml(item.name)}${escapeHtml(kindLabel[item.kind] ?? item.kind)}
+ ${item.note ? `

${escapeHtml(item.note)}

` : ''} + ${item.install ? `${escapeHtml(item.install)}` : ''} +
`, + ) + .join('')}
` + : '

用系统已有的东西即可,没有额外依赖。

'; + + const config = entry.config.length + ? `
${entry.config + .map( + item => `
+
${escapeHtml(item.key)} + ${item.required ? '必配' : '可选'} + ${item.secret ? '私密' : ''}
+

${escapeHtml(item.desc)}

+ ${item.ask ? `

${escapeHtml(item.ask)}

` : ''} +
`, + ) + .join('')}
` + : '

装完即用,不需要提前配置。

'; + + const relations = `
    +${ + entry.usesPublic + .map(name => `
  • 依赖${escapeHtml(name)}
  • `) + .join('\n') + } +${ + entry.usedBy + .map(name => `
  • 被用${escapeHtml(name)}
  • `) + .join('\n') + } +${entry.usesPublic.length || entry.usedBy.length ? '' : '
  • 独立可用,不和其它技能往来。
  • '} +
`; + + const commands = entry.commands.length + ? `${entry.commands + .map( + command => + ``, + ) + .join('')}
${escapeHtml(command.name)}${command.destructive ? '会改状态' : ''}${escapeHtml(command.summary)}
` + : ''; + + const body = `
+ ← 全部技能 +
+

${escapeHtml(entry.name)}

+ v${escapeHtml(entry.version)} +
+

${escapeHtml(entry.summary)}

+
+ ${LAYER_LABEL[entry.layer]} + ${LAYER_HINT[entry.layer]} + ${entry.status !== 'stable' ? `${entry.status === 'experimental' ? '实验性' : '将退役'}` : ''} + ${entry.platforms.map(platform => `${escapeHtml(platform)}`).join('')} +
+
+
+
+

怎么装

${installBlock}
+

先准备什么

${requires}
+

要先配什么

${config}
+ ${commands ? `

常用命令

${commands}
` : ''} +
+ +
`; + + return pageShell({ + title: `${entry.name} — Pi 能力目录`, + description: entry.summary, + body, + fontsCss, + siteUrl: siteUrl ? `${siteUrl.replace(/\/$/, '')}/skills/${entry.name}.html` : '', + depth: 1, + }); +} + +function indexPage(entries, siteUrl, fontsCss) { + const requires = [...new Set(entries.flatMap(entry => entry.requires.map(item => item.name)))].sort().slice(0, 10); + const layers = ['core', 'tool', 'app']; + const sections = layers + .map(layer => { + const items = entries.filter(entry => entry.layer === layer); + if (!items.length) return ''; + return `
+
+ ${LAYER_LABEL[layer]} + ${items.length} + ${LAYER_HINT[layer]} +
+
${items.map(cardOf).join('\n')}
+
`; + }) + .join('\n'); + const latest = entries[0]; + const kinds = entries.length; + const body = `
+

${BRAND.name} · 自托管目录

+

${BRAND.tagline}

+

${SCOPE_HTML}的集中目录。每条都写清依赖、前置条件与安装命令,装完就能用。

+ ${ + latest + ? `
+ ${escapeHtml(latest.install.pi)} + +
+

先从 ${escapeHtml(latest.name)} 试起

` + : '' + } +
+ ${kinds} 个条目 + + 底座 + 工具 + 组合应用 + + 组合应用会把底座一起带上,不用单独装 +
+
+
+ +
+ ${layers + .filter(layer => entries.some(entry => entry.layer === layer)) + .map(layer => ``) + .join('')} +
+ ${entries.length} +
+${requires.length ? `
${requires.map(name => ``).join('')}
` : ''} +
+${sections} +
+`; + return pageShell({ + title: 'Pi 能力目录', + description: 'Pi 能力目录:技能与扩展,写清依赖与前置条件,一条命令装好就能用。', + body, + fontsCss, + siteUrl: siteUrl ?? '', + }); +} + +/** 取(或首次下载)一套网络字体到 local 缓存;失败返回 { dir: null, error }。 */ +export function ensureFont(repo, font) { + // 字体缓存是机器级的(与构建哪个仓库无关),跟着 agent 目录走。 + const cache = path.join(agentDirOf(), 'local', 'skills', SKILL, 'fonts', font.key); + const cssPath = path.join(cache, font.css); + if (fs.existsSync(cssPath)) return { dir: cache, css: font.css, family: font.family, key: font.key }; + try { + fs.mkdirSync(cache, { recursive: true }); + const tarball = path.join(cache, `${font.package.replace(/^@[^/]+\//, '')}.tgz`); + const url = `https://registry.npmjs.org/${font.package}/-/${path.basename(font.package)}-${font.version}.tgz`; + // 网络出口自己挑:先直连(清掉继承来的代理变量),传输层失败就换代理再试一次。 + const direct = { ...process.env }; + for (const key of ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy', 'ALL_PROXY', 'all_proxy']) delete direct[key]; + direct.NO_PROXY = '*'; + direct.no_proxy = '*'; + const attempts = [['direct', direct], ['proxy', { ...process.env }]]; + let fetched = null; + for (const [route, env] of attempts) { + fetched = spawnSync('curl', ['-sL', '--max-time', '180', '-o', tarball, url], { encoding: 'utf8', env }); + if (fetched.status === 0 && fs.existsSync(tarball) && fs.statSync(tarball).size > 0) break; + } + if (!fetched || fetched.status !== 0 || !fs.existsSync(tarball) || fs.statSync(tarball).size === 0) { + throw new Error(`下载失败(直连与代理都不通):${((fetched && fetched.stderr) || '').trim() || `curl 退出码 ${fetched && fetched.status}`}`); + } + const extracted = spawnSync('tar', ['-xzf', tarball, '-C', cache, '--strip-components=1', `package/${font.css}`, 'package/files', 'package/LICENSE'], { encoding: 'utf8' }); + if (extracted.status !== 0) throw new Error(`解包失败:${(extracted.stderr || '').trim()}`); + fs.rmSync(tarball, { force: true }); + if (!fs.existsSync(cssPath)) throw new Error(`包里没有 ${font.css}`); + return { dir: cache, css: font.css, family: font.family, key: font.key }; + } catch (problem) { + return { dir: null, css: null, family: font.family, key: font.key, error: problem.message }; + } +} + +/** 目录页需要的中文字体都准备好;返回 css 相对路径列表。 */ +export function ensureFonts(repo = repoRoot(), warnings = []) { + const links = []; + for (const font of FONTS) { + const ready = ensureFont(repo, font); + if (ready.dir) links.push(`fonts/${font.key}/${font.css}`); + else warnings.push(`${font.family} 没就位(${ready.error}):会退回系统字体`); + } + return links; +} + +/** 生成静态站;返回 { out, files, warnings, leaks }。 */ +export function buildCatalog({ repo = repoRoot(), out, siteUrl = '' } = {}) { + const { entries, warnings } = collectCatalog(repo); + const outDir = out ?? path.join(SKILL_DIR, 'dist'); + fs.rmSync(outDir, { recursive: true, force: true }); + fs.mkdirSync(path.join(outDir, 'skills'), { recursive: true }); + fs.mkdirSync(path.join(outDir, 'assets'), { recursive: true }); + fs.cpSync(path.join(SKILL_DIR, 'assets', 'style.css'), path.join(outDir, 'assets', 'style.css')); + fs.cpSync(path.join(SKILL_DIR, 'assets', 'app.js'), path.join(outDir, 'assets', 'app.js')); + // 中文正文字体(霞鹜文楷 Screen):首次构建下载到 local 缓存,之后直接用; + // 拿不到就退回系统字体并 warn(页面仍可用)。 + const fontLinks = []; + for (const font of FONTS) { + const ready = ensureFont(repo, font); + if (!ready.dir) { + warnings.push(`${font.family} 没就位(${ready.error}):页面会退回系统字体`); + continue; + } + const out = path.join(outDir, 'assets', 'fonts', font.key); + fs.mkdirSync(path.join(out, 'files'), { recursive: true }); + fs.cpSync(path.join(ready.dir, font.css), path.join(out, font.css)); + for (const extra of ['OFL.txt', 'LICENSE']) { + const source = path.join(ready.dir, extra); + if (fs.existsSync(source)) fs.cpSync(source, path.join(out, extra)); + } + // 只带该 css 引用的分片(unicode-range 让浏览器按需取)。 + for (const name of fs.readdirSync(path.join(ready.dir, 'files'))) { + if (font.include.test(name)) fs.cpSync(path.join(ready.dir, 'files', name), path.join(out, 'files', name)); + } + fontLinks.push(`fonts/${font.key}/${font.css}`); + } + const fontsCss = fontLinks.length ? fontLinks : null; + fs.writeFileSync(path.join(outDir, 'index.html'), indexPage(entries, siteUrl, fontsCss)); + const byName = new Map(entries.map(entry => [entry.name, entry])); + for (const entry of entries) { + fs.writeFileSync(path.join(outDir, 'skills', `${entry.name}.html`), detailPage(entry, siteUrl, byName, fontsCss)); + } + const catalog = { generated_at: new Date().toISOString(), site_url: siteUrl, count: entries.length, skills: entries }; + fs.writeFileSync(path.join(outDir, 'catalog.json'), `${JSON.stringify(catalog, null, 2)}\n`); + + // 隐私门禁:扫「原始值」而不是渲染结果——HTML/JSON 会把引号转义成 "/\", + // 渲染后再扫会漏掉 `api_key = "…"` 这类字样。 + const rawFile = path.join(os.tmpdir(), `skill-catalog-raw-${process.pid}-${Date.now()}.txt`); + // 一行一条原始值(不经过 JSON/HTML 转义,转义会把 " 变成 \" 或 " 而漏扫)。 + const rawLines = entries.flatMap(entry => [ + entry.name, entry.summary, entry.useWhen, entry.version, + ...entry.commands.flatMap(command => [command.name, command.summary]), + ...entry.requires.flatMap(item => [item.name, item.install, item.check, item.note]), + ...entry.config.flatMap(item => [item.key, item.desc, item.ask]), + ...entry.dependencies.flatMap(item => [item.name, item.note]), + entry.install.pi, entry.install.cli, entry.repos.package, entry.repos.cli, + ]); + fs.writeFileSync(rawFile, rawLines.join('\n')); + const leaks = [ + ...scanLeaks(path.dirname(rawFile), [path.basename(rawFile)]).map(leak => ({ ...leak, file: 'catalog-source' })), + ...scanLeaks(outDir, ['catalog.json', 'index.html', ...entries.map(entry => `skills/${entry.name}.html`)]), + ]; + fs.rmSync(rawFile, { force: true }); + const errors = leaks.filter(leak => leak.level === 'error'); + return { out: outDir, entries, warnings, leaks, errors, blocked: errors.map(leak => `${leak.file}:${leak.line} ${leak.kind}`) }; +} + +function deployConfig(repo, argv) { + const skill = loadSkills(repo).find(item => item.name === SKILL); + const { values = {}, missing = [] } = resolveConfig(skill, { argv, env: process.env, agentDir: agentDir(repo) }) ?? {}; + return { values, missing }; +} + +const sh = (program, args, options = {}) => spawnSync(program, args, { encoding: 'utf8', ...options }); + +function deployCatalog({ repo = repoRoot(), dryRun = false, out } = {}) { + const { values, missing } = deployConfig(repo, {}); + // 生成时就带上站点地址:页面的 canonical 与分享链接靠它,deploy 不能只搬文件。 + const built = buildCatalog({ repo, out, siteUrl: values.site_url ?? '' }); + if (built.errors.length) return { ok: false, blocked: built.blocked, ...built }; + const relay = values.deploy_relay || 'lightos'; + const host = values.deploy_host || 'aliyun-hk'; + const target = values.deploy_path; + if (!target) { + return { ok: false, blocked: ['没有配置部署目录:设置 PI_SKILL_SKILL_CATALOG_DEPLOY_PATH 或在 local/skills/skill-catalog/config.json 写 deploy_path'], ...built }; + } + const stamp = new Date().toISOString().replace(/[-:]|\.\d+/g, '').replace('T', 'T').replace('Z', 'Z'); + const release = `${target}/releases/${stamp}`; + if (dryRun) return { ok: true, dryRun, relay, host, release, url: values.site_url ?? '', ...built }; + + const tarFile = path.join(os.tmpdir(), `skill-catalog-${stamp}.tar.gz`); + const tar = sh('tar', ['-C', built.out, '-czf', tarFile, '.']); + if (tar.status !== 0) return { ok: false, blocked: [`打包失败:${tar.stderr.trim()}`], ...built }; + const remote = [ + `mkdir -p '${release}'`, + `tar -xzf - -C '${release}'`, + `ln -sfn 'releases/${stamp}' '${target}/current'`, + `ls -dt '${target}/releases/'* | tail -n +6 | xargs -r rm -rf`, + ].join(' && '); + const push = sh('ssh', [relay, `ssh ${host} ${JSON.stringify(remote)}`], { input: fs.readFileSync(tarFile) }); + fs.rmSync(tarFile, { force: true }); + if (push.status !== 0) return { ok: false, blocked: [`推送失败:${(push.stderr || '').trim()}`], ...built }; + // 部署完回读公开地址:内容推上去了但站点块没加/没重载时,这里一眼可见。 + const url = values.site_url ?? ''; + const probe = url ? (sh('curl', ['-s', '-o', '/dev/null', '-w', '%{http_code}', '--max-time', '12', url]).stdout || '').trim() : ''; + return { ok: true, relay, host, release, url, live: probe || 'unreachable', ...built }; +} + +// ---- CLI ---- +const isMain = (() => { + try { + return Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); + } catch { + return false; + } +})(); + +if (isMain) { + const args = process.argv.slice(2); + const positional = args.filter(arg => !arg.startsWith('-')); + const flag = name => args.includes(name); + const value = name => { + const index = args.indexOf(name); + return index >= 0 ? args[index + 1] : undefined; + }; + const json = flag('--json'); + const command = positional[0] ?? 'help'; + const repo = repoRoot(); + + const fail = (message, hint) => { + console.error(`error=usage ${message}`); + if (hint) console.error(`hint=${hint}`); + process.exit(EXIT_USAGE); + }; + + if (command === 'help' || command === '--help' || command === '-h') { + console.log(USAGE); + process.exit(EXIT_OK); + } + if (command === 'check') { + const { values, missing } = deployConfig(repo, {}); + const { entries } = collectCatalog(repo); + console.log(`收录 ${entries.length} 个技能(规则:声明了 publish 的技能)`); + console.log(`部署目标=${values.deploy_path ? `${values.deploy_relay || 'lightos'}→${values.deploy_host || 'aliyun-hk'}:${values.deploy_path}` : '未配置'}`); + if (values.site_url) { + // 只读回读一次公网地址:站点活没活、证书/站点块在不在,一条命令给答案。 + const code = (sh('curl', ['-s', '-o', '/dev/null', '-w', '%{http_code}', '--max-time', '12', values.site_url]).stdout || '').trim(); + console.log(`站点=${values.site_url} HTTP=${code || '000'}${code === '200' ? '' : '(非 200:站点块/证书/目录权限?见 REFERENCE §4)'}`); + } else { + console.log('站点=未配置 site_url'); + } + process.exit(missing.length ? EXIT_BLOCKED : EXIT_OK); + } + if (command === 'list') { + const { entries, warnings } = collectCatalog(repo); + if (json) console.log(JSON.stringify({ count: entries.length, skills: entries.map(e => e.name), warnings }, null, 2)); + else { + console.log(`收录 ${entries.length} 个技能:${entries.map(e => `${e.name} v${e.version}${e.installable ? '' : '(依赖未公开)'}`).join('、') || '(空)'}`); + console.log(`source=shared-repo as_of=${new Date().toISOString()}`); + for (const line of warnings) console.log(`warn ${line}`); + } + process.exit(EXIT_OK); + } + if (command === 'build') { + const out = value('--out'); + const built = buildCatalog({ repo, out }); + if (built.errors.length) { + for (const line of built.blocked) console.error(`blocked: ${line}`); + console.error('hint=检查技能声明里是否带上了密钥/真实家目录;页面只该出现声明元数据'); + process.exit(EXIT_BLOCKED); + } + if (json) console.log(JSON.stringify({ out: built.out, count: built.entries.length, warnings: built.warnings }, null, 2)); + else { + console.log(`已生成 ${built.entries.length} 个技能 → ${built.out}`); + for (const line of built.warnings) console.log(`warn ${line}`); + } + process.exit(EXIT_OK); + } + if (command === 'deploy') { + const dryRun = flag('--dry-run'); + const result = deployCatalog({ repo, dryRun }); + if (!result.ok) { + for (const line of result.blocked) console.error(`blocked: ${line}`); + process.exit(EXIT_BLOCKED); + } + if (json) console.log(JSON.stringify({ ok: true, dryRun, release: result.release, count: result.entries.length, url: result.url }, null, 2)); + else { + console.log(`${dryRun ? 'dry-run:将发布' : '已发布'} ${result.entries.length} 个技能 → ${result.relay}→${result.host}:${result.release}`); + if (result.url) console.log(result.live === '200' ? `公开地址:${result.url}(HTTP 200)` : `公开地址:${result.url}(HTTP ${result.live} —— 站点块/证书没就绪?见 REFERENCE §4 的一次性配置)`); + } + process.exit(EXIT_OK); + } + if (command === 'preview') { + const port = Number(value('--port') ?? 8788); + const built = buildCatalog({ repo }); + if (built.errors.length) { + for (const line of built.blocked) console.error(`blocked: ${line}`); + process.exit(EXIT_BLOCKED); + } + console.log(`预览:http://127.0.0.1:${port}/ (${built.out};Ctrl-C 结束)`); + const server = spawnSync('python3', ['-m', 'http.server', String(port), '--bind', '127.0.0.1', '--directory', built.out], { stdio: 'inherit' }); + process.exit(server.status ?? EXIT_OK); + } + fail(`未知命令「${command}」`, 'skill-catalog help'); +}