release v0.1.0
This commit is contained in:
commit
6ef16e7b9d
215
MANIFEST.json
Normal file
215
MANIFEST.json
Normal file
@ -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": []
|
||||
}
|
||||
}
|
||||
14
README.md
Normal file
14
README.md
Normal file
@ -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` 会告诉还缺什么。
|
||||
5
package.json
Normal file
5
package.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "skill-catalog",
|
||||
"version": "0.1.0",
|
||||
"description": "技能目录站:把对外发布的技能生成一个静态站(能干什么 / 先装什么 / 要配什么 / 依赖谁 / 一条安装命令),生成物不手改页面。"
|
||||
}
|
||||
117
skills/skill-catalog/REFERENCE.md
Normal file
117
skills/skill-catalog/REFERENCE.md
Normal file
@ -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)
|
||||
▼
|
||||
目标机 <deploy_path>/releases/<时间戳>/ ,再切 <deploy_path>/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_<KEY>` 环境变量):
|
||||
|
||||
- `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 加一个站点块,把域名指到 `<deploy_path>/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`)、状态只变边框与底色;圆角/间距/字号都从变量取,不即兴写死。
|
||||
- **双主题**:明暗各一套变量;`<head>` 里的引导脚本按「上次选择 → 系统偏好」在首帧前设好 `data-theme`,
|
||||
导航右侧按钮切换并记住(`localStorage: pi-hub-theme`)。新组件必须两套主题下都看过。
|
||||
|
||||
## 5. 想加东西往哪加
|
||||
|
||||
- **新页面/新字段**:数据来自技能声明,先想清楚它是不是「声明里已经有的事实」——是就渲染,
|
||||
不是就先补声明(别在生成器里造事实)。
|
||||
- **新主题**:改 `assets/style.css`(纯 CSS,无构建);交互在 `assets/app.js`(搜索/筛选/复制)。
|
||||
- **换托管**:改 `deploy_relay`/`deploy_host`/`deploy_path` 三个配置,发布命令不变。
|
||||
31
skills/skill-catalog/SKILL.md
Normal file
31
skills/skill-catalog/SKILL.md
Normal file
@ -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「托管」。
|
||||
1
skills/skill-catalog/VERSION
Normal file
1
skills/skill-catalog/VERSION
Normal file
@ -0,0 +1 @@
|
||||
0.1.0
|
||||
81
skills/skill-catalog/assets/app.js
Normal file
81
skills/skill-catalog/assets/app.js
Normal file
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
// 主题切换:写进 <html data-theme> 并记住;引导脚本已在首帧前设好初值。
|
||||
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();
|
||||
})();
|
||||
336
skills/skill-catalog/assets/style.css
Normal file
336
skills/skill-catalog/assets/style.css
Normal file
@ -0,0 +1,336 @@
|
||||
/* Pi Hub —— 自己的设计语言,不照抄任何参考站。
|
||||
声音分工:标题=衬线(表现力);正文=霞鹜文楷(阅读);标签/命令/数字=等宽(精确)。
|
||||
明暗双主题:跟随系统,可手动切换并记住(引导脚本在 <head> 里先跑)。
|
||||
组件统一:细线边界、统一圆角与间距、状态只在边框与底色上变化,语义色只用小圆点。 */
|
||||
|
||||
: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; }
|
||||
}
|
||||
43
skills/skill-catalog/contract.lock.json
Normal file
43
skills/skill-catalog/contract.lock.json
Normal file
@ -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": []
|
||||
}
|
||||
106
skills/skill-catalog/interface.json
Normal file
106
skills/skill-catalog/interface.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
32
skills/skill-catalog/scripts/setup-hosting.sh
Executable file
32
skills/skill-catalog/scripts/setup-hosting.sh
Executable file
@ -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
|
||||
728
skills/skill-catalog/scripts/skill-catalog.mjs
Normal file
728
skills/skill-catalog/scripts/skill-catalog.mjs
Normal file
@ -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 = '<span class="nowrap">技能 · 扩展 · 主题 · 提示模板</span>';
|
||||
// 字体三套(都 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, '>')
|
||||
.replace(/"/g, '"');
|
||||
|
||||
const kindLabel = { runtime: '运行环境', binary: '命令', service: '服务', account: '账号' };
|
||||
|
||||
function siteNav(depth = 0) {
|
||||
const prefix = depth === 0 ? '' : '../';
|
||||
return `<nav class="nav">
|
||||
<a class="nav-brand" href="${prefix}index.html"><span class="brand-mark">π</span><span><strong>${BRAND.name}</strong><em>${BRAND.scope}</em></span></a>
|
||||
<div class="nav-links">
|
||||
<a href="${prefix}index.html">目录</a>
|
||||
<a href="https://gitea.vhkd.top/geekinney" target="_blank" rel="noreferrer">仓库</a>
|
||||
<a href="https://pi.dev/packages" target="_blank" rel="noreferrer">官方画廊</a>
|
||||
<button class="theme-toggle" id="theme-toggle" type="button" aria-label="切换明暗主题" title="切换明暗主题">
|
||||
<svg class="icon-sun" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4.2"/><g stroke-linecap="round"><path d="M12 2.6v2.2M12 19.2v2.2M2.6 12h2.2M19.2 12h2.2M5.3 5.3l1.6 1.6M17.1 17.1l1.6 1.6M18.7 5.3l-1.6 1.6M6.9 17.1l-1.6 1.6"/></g></svg>
|
||||
<svg class="icon-moon" viewBox="0 0 24 24" aria-hidden="true"><path d="M20 14.2A8.2 8.2 0 0 1 9.8 4a8.4 8.4 0 1 0 10.2 10.2Z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</nav>`;
|
||||
}
|
||||
|
||||
function siteFoot(depth = 0) {
|
||||
const prefix = depth === 0 ? '' : '../';
|
||||
return `<footer class="foot">
|
||||
<div class="foot-cols">
|
||||
<div><h4>关于</h4><p>这里收录的条目都从技能自己的声明生成,和仓库同步;页面不手改。</p></div>
|
||||
<div><h4>资源</h4><ul>
|
||||
<li><a href="https://pi.dev/packages" target="_blank" rel="noreferrer">官方画廊 pi.dev/packages</a></li>
|
||||
<li><a href="https://gitea.vhkd.top/geekinney" target="_blank" rel="noreferrer">Gitea 仓库</a></li>
|
||||
<li><a href="${prefix}catalog.json">catalog.json(机器可读)</a></li>
|
||||
</ul></div>
|
||||
<div><h4>字体</h4><ul>
|
||||
<li>中文 霞鹜文楷 Screen(OFL)</li>
|
||||
<li>拉丁 Pragmata Pro(读者本机)</li>
|
||||
</ul></div>
|
||||
</div>
|
||||
<p class="foot-line">${BRAND.name} · 由 skill-catalog 生成 · 条目与技能声明一致</p>
|
||||
</footer>`;
|
||||
}
|
||||
|
||||
function pageShell({ title, description, body, siteUrl, depth = 0, fontsCss = null }) {
|
||||
const prefix = depth === 0 ? '' : '../';
|
||||
return `<!doctype html>
|
||||
<html lang="zh-CN" data-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<script>
|
||||
// 主题:跟随系统,用户切过就记住(在首帧前生效,避免闪一下)。
|
||||
try {
|
||||
var saved = localStorage.getItem('pi-hub-theme');
|
||||
var theme = saved || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
||||
document.documentElement.dataset.theme = theme;
|
||||
} catch (e) {}
|
||||
</script>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${escapeHtml(title)}</title>
|
||||
<meta name="description" content="${escapeHtml(description)}">
|
||||
${fontsCss ? fontsCss.map(href => `<link rel="stylesheet" href="${prefix}assets/${href}">`).join('\n') : ''}
|
||||
<link rel="stylesheet" href="${prefix}assets/style.css">
|
||||
${siteUrl ? `<link rel="canonical" href="${escapeHtml(siteUrl)}">` : ''}
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
${siteNav(depth)}
|
||||
${body}
|
||||
${siteFoot(depth)}
|
||||
</div>
|
||||
<script src="${prefix}assets/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
const LAYER_LABEL = { core: '底座', tool: '工具', app: '组合应用' };
|
||||
const LAYER_HINT = {
|
||||
core: '别的技能建在它上面',
|
||||
tool: '独立可用,装完就用',
|
||||
app: '由多个技能协作完成',
|
||||
};
|
||||
|
||||
function cardOf(entry) {
|
||||
const facts = [
|
||||
...entry.requires.slice(0, 3).map(item => `<span class="chip">${escapeHtml(item.name)}</span>`),
|
||||
...(entry.installable ? [] : ['<span class="chip chip-warn">依赖未公开</span>']),
|
||||
].join('');
|
||||
const used = entry.usedByCount ? `<span class="used">被 ${entry.usedByCount} 个技能使用</span>` : '';
|
||||
return `<a class="card" href="skills/${escapeHtml(entry.name)}.html" data-layer="${entry.layer}"
|
||||
data-search="${escapeHtml(`${entry.name} ${entry.summary} ${entry.useWhen} ${entry.layer} ${entry.commands.map(c => c.name).join(' ')}`)}">
|
||||
<div class="card-top"><h3>${escapeHtml(entry.name)}</h3><span class="ver">v${escapeHtml(entry.version)}</span></div>
|
||||
<p class="summary">${escapeHtml(entry.summary)}</p>
|
||||
<div class="card-foot">
|
||||
<span class="card-layer"><span class="dot"></span>${LAYER_LABEL[entry.layer]}</span>
|
||||
${facts}${used}
|
||||
</div>
|
||||
</a>`;
|
||||
}
|
||||
|
||||
function detailPage(entry, siteUrl, byName, fontsCss) {
|
||||
const steps = entry.installOrder.map(name => byName.get(name)).filter(Boolean);
|
||||
const together =
|
||||
steps.length > 1
|
||||
? `<div class="well together">
|
||||
<p class="well-title">一次装齐 <span class="muted">按顺序执行 ${steps.length} 条</span></p>
|
||||
${steps
|
||||
.map(
|
||||
(item, index) =>
|
||||
` <div class="cmd"><span class="cmd-step">${index + 1}</span><code>${escapeHtml(item.install.pi)}</code><span class="cmd-tag">${item.name === entry.name ? '本体' : '依赖'}</span></div>`,
|
||||
)
|
||||
.join('\n')}
|
||||
<div class="well-foot"><button class="copy primary" data-copy="${escapeHtml(steps.map(item => item.install.pi).join('\n'))}">复制这 ${steps.length} 条</button></div>
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
const installRows = [
|
||||
entry.install.pi
|
||||
? `<div class="cmd"><span class="cmd-step">Pi</span><code>${escapeHtml(entry.install.pi)}</code><button class="copy" data-copy="${escapeHtml(entry.install.pi)}">复制</button></div>`
|
||||
: '',
|
||||
entry.install.cli
|
||||
? `<div class="cmd"><span class="cmd-step">CLI</span><code>${escapeHtml(entry.install.cli)}</code><button class="copy" data-copy="${escapeHtml(entry.install.cli)}">复制</button></div>`
|
||||
: '',
|
||||
].join('\n');
|
||||
const installBlock = entry.installable
|
||||
? `${together}
|
||||
<div class="well">
|
||||
${installRows}
|
||||
<p class="well-note">装完跑 <code>pi-skill ${escapeHtml(entry.name)} check</code>${entry.install.cli ? `(Pi)或 <code>${escapeHtml(entry.name)} help</code>(命令行)` : ''}。</p>
|
||||
</div>`
|
||||
: `<div class="well blocked"><p><strong>依赖未公开组件</strong>,单独装跑不起来,暂不提供安装命令。</p></div>`;
|
||||
|
||||
const requires = entry.requires.length
|
||||
? `<div class="facts">${entry.requires
|
||||
.map(
|
||||
item => `<div class="fact">
|
||||
<div class="fact-head"><span class="fact-name">${escapeHtml(item.name)}</span><span class="fact-kind">${escapeHtml(kindLabel[item.kind] ?? item.kind)}</span></div>
|
||||
${item.note ? `<p class="muted">${escapeHtml(item.note)}</p>` : ''}
|
||||
${item.install ? `<code class="inline-cmd">${escapeHtml(item.install)}</code>` : ''}
|
||||
</div>`,
|
||||
)
|
||||
.join('')}</div>`
|
||||
: '<p class="muted">用系统已有的东西即可,没有额外依赖。</p>';
|
||||
|
||||
const config = entry.config.length
|
||||
? `<div class="facts">${entry.config
|
||||
.map(
|
||||
item => `<div class="fact">
|
||||
<div class="fact-head"><span class="fact-name mono">${escapeHtml(item.key)}</span>
|
||||
${item.required ? '<span class="badge badge-req">必配</span>' : '<span class="badge">可选</span>'}
|
||||
${item.secret ? '<span class="badge">私密</span>' : ''}</div>
|
||||
<p class="muted">${escapeHtml(item.desc)}</p>
|
||||
${item.ask ? `<p class="ask">${escapeHtml(item.ask)}</p>` : ''}
|
||||
</div>`,
|
||||
)
|
||||
.join('')}</div>`
|
||||
: '<p class="muted">装完即用,不需要提前配置。</p>';
|
||||
|
||||
const relations = `<ul class="relations">
|
||||
${
|
||||
entry.usesPublic
|
||||
.map(name => `<li><span class="rel rel-depend">依赖</span><a href="${escapeHtml(name)}.html">${escapeHtml(name)}</a></li>`)
|
||||
.join('\n')
|
||||
}
|
||||
${
|
||||
entry.usedBy
|
||||
.map(name => `<li><span class="rel rel-used">被用</span><a href="${escapeHtml(name)}.html">${escapeHtml(name)}</a></li>`)
|
||||
.join('\n')
|
||||
}
|
||||
${entry.usesPublic.length || entry.usedBy.length ? '' : '<li class="muted">独立可用,不和其它技能往来。</li>'}
|
||||
</ul>`;
|
||||
|
||||
const commands = entry.commands.length
|
||||
? `<table class="commands"><tbody>${entry.commands
|
||||
.map(
|
||||
command =>
|
||||
`<tr><td class="cmd-cell"><code>${escapeHtml(command.name)}</code>${command.destructive ? '<span class="badge">会改状态</span>' : ''}</td><td>${escapeHtml(command.summary)}</td></tr>`,
|
||||
)
|
||||
.join('')}</tbody></table>`
|
||||
: '';
|
||||
|
||||
const body = `<header class="detail-head">
|
||||
<a class="back" href="../index.html">← 全部技能</a>
|
||||
<div class="title-row">
|
||||
<h1>${escapeHtml(entry.name)}</h1>
|
||||
<span class="ver">v${escapeHtml(entry.version)}</span>
|
||||
</div>
|
||||
<p class="lede">${escapeHtml(entry.summary)}</p>
|
||||
<div class="chips">
|
||||
<span class="chip layer-${entry.layer}">${LAYER_LABEL[entry.layer]}</span>
|
||||
<span class="chip">${LAYER_HINT[entry.layer]}</span>
|
||||
${entry.status !== 'stable' ? `<span class="chip chip-warn">${entry.status === 'experimental' ? '实验性' : '将退役'}</span>` : ''}
|
||||
${entry.platforms.map(platform => `<span class="chip">${escapeHtml(platform)}</span>`).join('')}
|
||||
</div>
|
||||
</header>
|
||||
<div class="detail-grid">
|
||||
<article>
|
||||
<section class="block"><h2>怎么装</h2>${installBlock}</section>
|
||||
<section class="block"><h2>先准备什么</h2>${requires}</section>
|
||||
<section class="block"><h2>要先配什么</h2>${config}</section>
|
||||
${commands ? `<section class="block"><h2>常用命令</h2>${commands}</section>` : ''}
|
||||
</article>
|
||||
<aside>
|
||||
<div class="panel side"><div class="panel-head"><span class="label">关系</span></div><div class="panel-body">${relations}</div></div>
|
||||
<div class="panel side"><div class="panel-head"><span class="label">仓库</span></div><div class="panel-body">
|
||||
<ul class="repos">
|
||||
${entry.repos.package ? `<li><span class="rel">Pi 包</span><a href="${escapeHtml(entry.repos.package)}">${escapeHtml(entry.repos.package.replace(/^https?:\/\//, ''))}</a></li>` : ''}
|
||||
${entry.repos.cli ? `<li><span class="rel">CLI</span><a href="${escapeHtml(entry.repos.cli)}">${escapeHtml(entry.repos.cli.replace(/^https?:\/\//, ''))}</a></li>` : ''}
|
||||
</ul>
|
||||
</div></div>
|
||||
</aside>
|
||||
</div>`;
|
||||
|
||||
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 `<section class="panel layer-section" data-layer="${layer}">
|
||||
<div class="panel-head">
|
||||
<span class="dot"></span><span class="label">${LAYER_LABEL[layer]}</span>
|
||||
<span class="panel-count">${items.length}</span>
|
||||
<span class="panel-hint">${LAYER_HINT[layer]}</span>
|
||||
</div>
|
||||
<div class="grid">${items.map(cardOf).join('\n')}</div>
|
||||
</section>`;
|
||||
})
|
||||
.join('\n');
|
||||
const latest = entries[0];
|
||||
const kinds = entries.length;
|
||||
const body = `<header class="masthead">
|
||||
<p class="eyebrow">${BRAND.name} · 自托管目录</p>
|
||||
<h1>${BRAND.tagline}</h1>
|
||||
<p class="lede">${SCOPE_HTML}的集中目录。每条都写清依赖、前置条件与安装命令,装完就能用。</p>
|
||||
${
|
||||
latest
|
||||
? `<div class="cta">
|
||||
<code class="cta-cmd">${escapeHtml(latest.install.pi)}</code>
|
||||
<button class="copy primary" data-copy="${escapeHtml(latest.install.pi)}">复制命令</button>
|
||||
</div>
|
||||
<p class="cta-hint">先从 <a href="skills/${escapeHtml(latest.name)}.html">${escapeHtml(latest.name)}</a> 试起</p>`
|
||||
: ''
|
||||
}
|
||||
<div class="meta">
|
||||
<span class="stat">${kinds} 个条目</span>
|
||||
<span class="legend">
|
||||
<span class="legend-item core"><span class="dot"></span>底座</span>
|
||||
<span class="legend-item tool"><span class="dot"></span>工具</span>
|
||||
<span class="legend-item app"><span class="dot"></span>组合应用</span>
|
||||
</span>
|
||||
<span class="hint">组合应用会把底座一起带上,不用单独装</span>
|
||||
</div>
|
||||
</header>
|
||||
<div class="toolbar">
|
||||
<input id="search" type="search" placeholder="搜索技能、命令或关键词" autocomplete="off">
|
||||
<div class="segmented" id="layers">
|
||||
${layers
|
||||
.filter(layer => entries.some(entry => entry.layer === layer))
|
||||
.map(layer => `<button class="seg" data-layer-filter="${layer}">${LAYER_LABEL[layer]}</button>`)
|
||||
.join('')}
|
||||
</div>
|
||||
<span class="count" id="count">${entries.length}</span>
|
||||
</div>
|
||||
${requires.length ? `<div class="chipbar" id="requires">${requires.map(name => `<button class="chip filter" data-requires="${escapeHtml(name)}">${escapeHtml(name)}</button>`).join('')}</div>` : ''}
|
||||
<main id="sections">
|
||||
${sections}
|
||||
</main>
|
||||
<p class="empty muted" id="empty" hidden>没有匹配的技能。</p>`;
|
||||
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');
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user