@yizhuan-cli/cli 0.1.7-beta.0 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/package.json +7 -2
- package/resources/yizhuan-cli/SKILL.md +64 -0
- package/resources/yizhuan-cli/manifest.json +14 -0
- package/src/config.js +203 -0
- package/src/doctor.js +278 -0
- package/src/index.js +27 -3
- package/src/skill.js +283 -0
- package/src/params.test.js +0 -116
- package/src/version.test.js +0 -22
package/README.md
CHANGED
|
@@ -4,14 +4,16 @@
|
|
|
4
4
|
|
|
5
5
|
## 安装
|
|
6
6
|
|
|
7
|
+
需要 Node.js 22 或更高版本。
|
|
8
|
+
|
|
7
9
|
```bash
|
|
8
|
-
npm install -g @yizhuan-cli/cli
|
|
10
|
+
npm install -g @yizhuan-cli/cli@0.1.7
|
|
9
11
|
```
|
|
10
12
|
|
|
11
13
|
如果发布在私有 npm registry,请加上对应 registry:
|
|
12
14
|
|
|
13
15
|
```bash
|
|
14
|
-
npm install -g @yizhuan-cli/cli --registry=https://your-private-registry.example
|
|
16
|
+
npm install -g @yizhuan-cli/cli@0.1.7 --registry=https://your-private-registry.example
|
|
15
17
|
```
|
|
16
18
|
|
|
17
19
|
## 配置
|
|
@@ -20,7 +22,7 @@ npm install -g @yizhuan-cli/cli --registry=https://your-private-registry.example
|
|
|
20
22
|
|
|
21
23
|
```json
|
|
22
24
|
{
|
|
23
|
-
"apiBaseUrl": "https://api.yizhuan5.com",
|
|
25
|
+
"apiBaseUrl": "https://yizhuan-api.yizhuan5.com",
|
|
24
26
|
"apiKey": "yz_cli_xxx"
|
|
25
27
|
}
|
|
26
28
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yizhuan-cli/cli",
|
|
3
|
-
"version": "0.1.7
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "易撰命令行工具,用于通过 API Key 查询易撰真实数据。",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
@@ -8,11 +8,16 @@
|
|
|
8
8
|
"yizhuan": "src/index.js"
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
|
-
"src",
|
|
11
|
+
"src/index.js",
|
|
12
|
+
"src/config.js",
|
|
13
|
+
"src/skill.js",
|
|
14
|
+
"src/doctor.js",
|
|
15
|
+
"resources",
|
|
12
16
|
"README.md"
|
|
13
17
|
],
|
|
14
18
|
"scripts": {
|
|
15
19
|
"start": "node src/index.js",
|
|
20
|
+
"test": "node --test \"src/*.test.js\"",
|
|
16
21
|
"pack:dry-run": "npm pack --dry-run",
|
|
17
22
|
"smoke": "node src/index.js --help && node --test src/version.test.js",
|
|
18
23
|
"test:params": "node --test src/params.test.js",
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: yizhuan-cli
|
|
3
|
+
description: |
|
|
4
|
+
通过易撰 CLI(yizhuan 命令)查询易撰真实数据。
|
|
5
|
+
当用户提到易撰、百家号、作品、账号、热点、雷达、竞品雷达、竞品动态、会员、我的灵感、灵感库、收藏作品、我的关注、关注作者或取消关注作者时,
|
|
6
|
+
必须优先调用 yizhuan CLI 获取真实结果,禁止臆造。
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# 易撰 CLI
|
|
10
|
+
|
|
11
|
+
使用本机 `yizhuan` 命令访问易撰数据。所有查询以 CLI JSON 输出为准,不在对话、日志、Skill 文件或临时请求文件中写入 API Key。
|
|
12
|
+
|
|
13
|
+
## 环境检查
|
|
14
|
+
|
|
15
|
+
开始业务查询前运行:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
yizhuan doctor
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
如果诊断失败,按输出中的修复建议处理。配置使用 `yizhuan config init`,更新本 Skill 使用 `yizhuan skill sync`;不要手工展示或复制配置中的完整 API Key。
|
|
22
|
+
|
|
23
|
+
## 支持能力
|
|
24
|
+
|
|
25
|
+
| 场景 | 命令示例 |
|
|
26
|
+
|------|----------|
|
|
27
|
+
| 查看帮助 | `yizhuan --help` |
|
|
28
|
+
| 配置状态 | `yizhuan config show` |
|
|
29
|
+
| 作品 | `yizhuan execute --ability works --query "百家号昨天新增了哪些作品"` |
|
|
30
|
+
| 作品检索 | `yizhuan execute --ability works --params '{"mode":"search","keyword":"AI"}'` |
|
|
31
|
+
| 指定作者作品 | `yizhuan query --json '{"intent":"works_search","scope":"public","platform":"toutiao","authorName":"大名府名人","timeRange":"3d"}'` |
|
|
32
|
+
| 高级作品筛选 | `yizhuan query --json '{"intent":"works_search","scope":"public","platform":"weibo","categories":["娱乐","情感"],"content_type":"video","min_views":100000,"min_comments":10000,"sort_by":"views","order":"desc"}'` |
|
|
33
|
+
| 作者查询 | `yizhuan execute --ability accounts --query "张三"` |
|
|
34
|
+
| 作者指标筛选 | `yizhuan execute --ability author_query --params '{"platform_id":2,"min_follower":500000,"min_total_likes":100000}'` |
|
|
35
|
+
| 作者全网搜索 | `yizhuan query --json '{"intent":"author_search","scope":"public","platform":"douyin","keyword":"张三"}'` |
|
|
36
|
+
| 热点 | `yizhuan query --json '{"intent":"hot_topics","scope":"platform","platform":"douyin","timeRange":"today"}'` |
|
|
37
|
+
| 我的关注 | `yizhuan execute --ability radar --params '{"mode":"following"}'` |
|
|
38
|
+
| 竞品动态 | `yizhuan execute --ability radar --mode timeline --timeRange 7d` |
|
|
39
|
+
| 会员 | `yizhuan execute --ability membership` |
|
|
40
|
+
| 我的灵感 / 灵感库 / 收藏作品 | `yizhuan execute --ability favorites` |
|
|
41
|
+
|
|
42
|
+
## 调用规则
|
|
43
|
+
|
|
44
|
+
- 需要真实易撰数据时必须调用 CLI,不直接猜测结果。
|
|
45
|
+
- PowerShell 使用单引号包裹 JSON。终端可能改写引号时,将不含密钥的请求对象写入文件,再使用 `--json-file`。
|
|
46
|
+
- `execute --json-file` 是完整参数对象的唯一输入源,不与 `--params`、`--query` 或独立参数混用。
|
|
47
|
+
- 热点查询使用 `query` 统一协议并明确 `platform`;缺少平台时先澄清,不默认猜测。
|
|
48
|
+
- 平台热点必须严格按 CLI 返回的 `data.items` / `rank` 顺序展示,该顺序与 Web 端平台榜单一致;`hot` 仅为展示指标,禁止按 `hot` 或格式化后的“万”值二次排序。
|
|
49
|
+
- 使用 Markdown 表格展示热点时,必须将标题中的 `|` 转义为 `\|`,不得让标题内容错位到热度列。
|
|
50
|
+
- 全网作者搜索使用 `intent=author_search`、`scope=public`,并提供 `platform` 和 `keyword`。
|
|
51
|
+
- 查询指定作者最近作品时,直接使用 `intent=works_search`,同时提供 `platform`、`authorName` 和 `timeRange`;不得先用 `author_search` 的 `workCount` 判断该作者是否有作品。
|
|
52
|
+
- `author_search` 返回的 `extAuthorId` 是平台外部作者 ID,不是易撰内部 `authorId`。需要按外部 ID 查询作品时只能传 `extAuthorId` / `ext_author_id`(兼容 `author_id`),必须同时提供 `platform`,由服务端完成内部 ID 转换;禁止把它直接作为内部 `authorId` 使用。
|
|
53
|
+
- 公开作者结果中的 `workCount: 0` 可能是上游未返回作品数后的缺省值,不代表作者没有作品;是否有作品必须以 `works_search` 的结果为准。
|
|
54
|
+
- 作品复合筛选优先使用统一协议 `intent=works_search`。平台使用 `platform`,分类使用 `categories`,内容类型使用 `content_type`,最低阅读/播放和评论分别使用 `min_views`、`min_comments`;需要固定顺序时显式传 `sort_by` 和 `order`。不得把作品查询改成作者、雷达或热点查询。
|
|
55
|
+
- 作品查询返回后必须逐项核对用户要求的分类、类型和指标阈值;低于阈值或缺少必要指标的项目不得作为符合条件的结果展示,也不得擅自放宽条件。展示总数必须使用返回的 `data.total`,不能把当前页条数当成总数。
|
|
56
|
+
- 作者累计指标筛选使用 `author_query`,不得改成作品指标或擅自降低阈值。必须逐项映射用户给出的所有条件:粉丝使用 `min_follower` / `max_follower`,点赞使用 `min_total_likes` / `max_total_likes`,阅读使用 `min_total_views` / `max_total_views`,关注使用 `min_friends` / `max_friends`,作品数使用 `min_work_count` / `max_work_count`;复合条件不得遗漏任何字段。例如“百家号粉丝大于50万,点赞大于10万”必须同时传 `platform_id:2`、`min_follower:500000` 和 `min_total_likes:100000`。
|
|
57
|
+
- 作者指标查询返回后,必须核对 `data.items` 中所有已请求指标。指标缺失、显示“暂未返回”或低于/高于用户阈值的项目不得作为符合条件的结果展示;一旦发现此类项目,必须先检查实际请求参数是否遗漏对应的 min/max 字段并使用完整参数重新查询,禁止直接报告原总数。
|
|
58
|
+
- “我的关注作者”使用 `radar` 且 `mode=following`,不是作品收藏。
|
|
59
|
+
- “我的灵感”“灵感库”“灵感作品”“我的收藏”和“收藏作品”是同一业务概念,均直接使用 `favorites`;不得尝试 `inspiration` 等不存在的 ability,也不要反问用户是不是指收藏作品。
|
|
60
|
+
- 竞品动态使用 `radar` 且 `mode=timeline`;未给时间范围时使用 `timeRange=7d`。
|
|
61
|
+
- 关注或取消关注属于写操作,必须来自用户明确指令;执行前核对昵称、平台和标识,不批量猜测目标。
|
|
62
|
+
- 取消关注前先查询关注列表,使用匹配项的 `articleId`,不得因结果不符合预期自行取消。
|
|
63
|
+
- `unsupported` 表示当前口径不支持,`empty` 表示没有匹配数据;如实转述,不编造补充结果。
|
|
64
|
+
- CLI 报错时保留脱敏后的错误信息,引导检查 `yizhuan doctor`,不得输出完整 Key。
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"name": "yizhuan-cli",
|
|
4
|
+
"version": "0.1.7",
|
|
5
|
+
"compatibleCliVersion": "0.1.7",
|
|
6
|
+
"source": "npm:@yizhuan-cli/cli@0.1.7",
|
|
7
|
+
"digestAlgorithm": "sha256",
|
|
8
|
+
"files": [
|
|
9
|
+
{
|
|
10
|
+
"path": "SKILL.md",
|
|
11
|
+
"sha256": "7d712024f915ac79f97001b60a20e4c9d8fc2bf527a1d748bdb7e220c0bdf013"
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { Writable } from 'node:stream'
|
|
5
|
+
import { createInterface } from 'node:readline/promises'
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_API_BASE_URL = 'https://yizhuan-api.yizhuan5.com'
|
|
8
|
+
export const CLI_TOOLS_URL = 'https://www.yizhuan5.com/app/cli-tools'
|
|
9
|
+
|
|
10
|
+
export function getConfigPath() {
|
|
11
|
+
return process.env.YIZHUAN_CONFIG_PATH || path.join(os.homedir(), '.config', 'yizhuan', 'config.json')
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function maskApiKey(apiKey) {
|
|
15
|
+
const value = String(apiKey)
|
|
16
|
+
if (value.length <= 12) return '***'
|
|
17
|
+
return `${value.slice(0, 11)}...${value.slice(-4)}`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function validateApiKey(apiKey) {
|
|
21
|
+
const value = String(apiKey || '').trim()
|
|
22
|
+
if (!value) {
|
|
23
|
+
throw new Error(`未提供 API Key。请访问 ${CLI_TOOLS_URL} 创建,然后使用隐藏输入或 --api-key-stdin。`)
|
|
24
|
+
}
|
|
25
|
+
if (!/^yz_cli_[A-Za-z0-9_-]{16,}$/.test(value)) {
|
|
26
|
+
throw new Error('API Key 格式无效,应为以 yz_cli_ 开头的易撰 CLI Key。')
|
|
27
|
+
}
|
|
28
|
+
return value
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function validateApiBaseUrl(apiBaseUrl) {
|
|
32
|
+
const value = String(apiBaseUrl || '').trim().replace(/\/+$/, '')
|
|
33
|
+
let url
|
|
34
|
+
try {
|
|
35
|
+
url = new URL(value)
|
|
36
|
+
} catch {
|
|
37
|
+
throw new Error('API 地址无效,请提供完整 URL。')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const isLoopback = ['localhost', '127.0.0.1', '::1'].includes(url.hostname)
|
|
41
|
+
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && isLoopback)) {
|
|
42
|
+
throw new Error('API 地址必须使用 HTTPS;仅显式配置的本机地址允许 HTTP。')
|
|
43
|
+
}
|
|
44
|
+
if (url.username || url.password || url.search || url.hash) {
|
|
45
|
+
throw new Error('API 地址不能包含凭据、查询参数或片段。')
|
|
46
|
+
}
|
|
47
|
+
return value
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function readExistingConfig(configPath) {
|
|
51
|
+
if (!fs.existsSync(configPath)) return null
|
|
52
|
+
let parsed
|
|
53
|
+
try {
|
|
54
|
+
parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'))
|
|
55
|
+
} catch {
|
|
56
|
+
throw new Error(`已有配置无法解析,已保留原文件:${configPath}`)
|
|
57
|
+
}
|
|
58
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
59
|
+
throw new Error(`已有配置格式无效,已保留原文件:${configPath}`)
|
|
60
|
+
}
|
|
61
|
+
return parsed
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function writeConfigAtomic(configPath, config) {
|
|
65
|
+
const directory = path.dirname(configPath)
|
|
66
|
+
const temporaryPath = path.join(
|
|
67
|
+
directory,
|
|
68
|
+
`.${path.basename(configPath)}.${process.pid}.${Date.now()}.tmp`
|
|
69
|
+
)
|
|
70
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 })
|
|
71
|
+
|
|
72
|
+
let descriptor
|
|
73
|
+
try {
|
|
74
|
+
descriptor = fs.openSync(temporaryPath, 'wx', 0o600)
|
|
75
|
+
fs.writeFileSync(descriptor, `${JSON.stringify(config, null, 2)}\n`, 'utf8')
|
|
76
|
+
fs.fsyncSync(descriptor)
|
|
77
|
+
fs.closeSync(descriptor)
|
|
78
|
+
descriptor = undefined
|
|
79
|
+
fs.renameSync(temporaryPath, configPath)
|
|
80
|
+
try {
|
|
81
|
+
fs.chmodSync(configPath, 0o600)
|
|
82
|
+
} catch {
|
|
83
|
+
// Windows ACLs are not represented by POSIX mode bits.
|
|
84
|
+
}
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (descriptor !== undefined) fs.closeSync(descriptor)
|
|
87
|
+
try {
|
|
88
|
+
fs.unlinkSync(temporaryPath)
|
|
89
|
+
} catch {
|
|
90
|
+
// The temporary file may not have been created.
|
|
91
|
+
}
|
|
92
|
+
throw new Error(`配置写入失败,原配置保持不变:${error.message}`)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function readHiddenInput(
|
|
97
|
+
prompt,
|
|
98
|
+
{ input = process.stdin, output = process.stderr } = {}
|
|
99
|
+
) {
|
|
100
|
+
if (!input.isTTY || !output.isTTY) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
`当前终端不支持隐藏输入,请访问 ${CLI_TOOLS_URL} 创建 Key,并使用 --api-key-stdin 安全传入;不要在 Agent 对话中展示 Key。`
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let muted = false
|
|
107
|
+
const hiddenOutput = new Writable({
|
|
108
|
+
write(chunk, encoding, callback) {
|
|
109
|
+
if (!muted) output.write(chunk, encoding)
|
|
110
|
+
callback()
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
hiddenOutput.isTTY = true
|
|
114
|
+
hiddenOutput.columns = output.columns
|
|
115
|
+
|
|
116
|
+
const readline = createInterface({ input, output: hiddenOutput, terminal: true })
|
|
117
|
+
try {
|
|
118
|
+
hiddenOutput.write(prompt)
|
|
119
|
+
muted = true
|
|
120
|
+
const value = await readline.question('')
|
|
121
|
+
muted = false
|
|
122
|
+
hiddenOutput.write('\n')
|
|
123
|
+
return value.trim()
|
|
124
|
+
} finally {
|
|
125
|
+
readline.close()
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function readApiKeyInput(
|
|
130
|
+
args,
|
|
131
|
+
{
|
|
132
|
+
readStdin = () => fs.readFileSync(0, 'utf8'),
|
|
133
|
+
readHidden = () => readHiddenInput('API Key: '),
|
|
134
|
+
warn = (message) => console.error(message)
|
|
135
|
+
} = {}
|
|
136
|
+
) {
|
|
137
|
+
if (args.apiKey && args.apiKeyStdin) {
|
|
138
|
+
throw new Error('--api-key 与 --api-key-stdin 不能同时使用。')
|
|
139
|
+
}
|
|
140
|
+
if (args.apiKey === true) {
|
|
141
|
+
throw new Error('--api-key 必须提供值。')
|
|
142
|
+
}
|
|
143
|
+
if (args.apiKey) {
|
|
144
|
+
warn('警告:--api-key 的值可能保留在 Shell 历史中;自动化请优先使用 --api-key-stdin。')
|
|
145
|
+
return String(args.apiKey).trim()
|
|
146
|
+
}
|
|
147
|
+
if (args.apiKeyStdin) {
|
|
148
|
+
return String(readStdin()).trim()
|
|
149
|
+
}
|
|
150
|
+
return readHidden()
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function printConfigInitHelp() {
|
|
154
|
+
console.log(`Usage:
|
|
155
|
+
yizhuan config init [options]
|
|
156
|
+
|
|
157
|
+
Options:
|
|
158
|
+
--api-base-url <url> API 地址,默认 ${DEFAULT_API_BASE_URL}
|
|
159
|
+
--api-key-stdin 从标准输入读取 API Key(推荐用于自动化)
|
|
160
|
+
--api-key <key> 从参数读取 API Key(可能保留在 Shell 历史中)
|
|
161
|
+
--force 覆盖已有配置
|
|
162
|
+
--help 显示本帮助
|
|
163
|
+
|
|
164
|
+
未持有 API Key:${CLI_TOOLS_URL}`)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function runConfigInit(
|
|
168
|
+
args,
|
|
169
|
+
{
|
|
170
|
+
configPath = getConfigPath(),
|
|
171
|
+
readApiKey = readApiKeyInput,
|
|
172
|
+
writeConfig = writeConfigAtomic,
|
|
173
|
+
log = (message) => console.log(message)
|
|
174
|
+
} = {}
|
|
175
|
+
) {
|
|
176
|
+
if (args.help) {
|
|
177
|
+
printConfigInitHelp()
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const existingConfig = readExistingConfig(configPath)
|
|
182
|
+
if (existingConfig && !args.force) {
|
|
183
|
+
throw new Error(`配置已存在,未做修改:${configPath}。确认覆盖后请使用 --force。`)
|
|
184
|
+
}
|
|
185
|
+
if (args.apiBaseUrl === true) {
|
|
186
|
+
throw new Error('--api-base-url 必须提供值。')
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const apiKey = validateApiKey(await readApiKey(args))
|
|
190
|
+
const apiBaseUrl = validateApiBaseUrl(
|
|
191
|
+
args.apiBaseUrl || existingConfig?.apiBaseUrl || DEFAULT_API_BASE_URL
|
|
192
|
+
)
|
|
193
|
+
const config = {
|
|
194
|
+
...(existingConfig || {}),
|
|
195
|
+
apiBaseUrl,
|
|
196
|
+
apiKey
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
writeConfig(configPath, config)
|
|
200
|
+
log(`配置已写入:${configPath}`)
|
|
201
|
+
log(`API 地址:${apiBaseUrl}`)
|
|
202
|
+
log(`API Key:${maskApiKey(apiKey)}`)
|
|
203
|
+
}
|
package/src/doctor.js
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import { DEFAULT_API_BASE_URL, getConfigPath, validateApiBaseUrl, validateApiKey } from './config.js'
|
|
5
|
+
import {
|
|
6
|
+
compareVersions,
|
|
7
|
+
loadOfficialSkill,
|
|
8
|
+
readSkillMetadata,
|
|
9
|
+
resolveSkillTarget
|
|
10
|
+
} from './skill.js'
|
|
11
|
+
|
|
12
|
+
const STATUS_LABELS = {
|
|
13
|
+
pass: 'PASS',
|
|
14
|
+
warn: 'WARN',
|
|
15
|
+
fail: 'FAIL'
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const CLI_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
19
|
+
const PACKAGE_PATH = path.join(CLI_DIR, 'package.json')
|
|
20
|
+
|
|
21
|
+
export function redactDiagnosticText(value) {
|
|
22
|
+
return String(value).replace(/yz_cli_[A-Za-z0-9_-]+/g, 'yz_cli_***')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function check(status, id, message, remediation) {
|
|
26
|
+
return {
|
|
27
|
+
id,
|
|
28
|
+
status,
|
|
29
|
+
message: redactDiagnosticText(message),
|
|
30
|
+
...(remediation ? { remediation: redactDiagnosticText(remediation) } : {})
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function inspectLocalEnvironment(
|
|
35
|
+
{
|
|
36
|
+
configPath = getConfigPath(),
|
|
37
|
+
nodeVersion = process.versions.node,
|
|
38
|
+
platform = process.platform
|
|
39
|
+
} = {},
|
|
40
|
+
{
|
|
41
|
+
packageJson = JSON.parse(fs.readFileSync(PACKAGE_PATH, 'utf8')),
|
|
42
|
+
exists = fs.existsSync,
|
|
43
|
+
readFile = fs.readFileSync,
|
|
44
|
+
stat = fs.statSync,
|
|
45
|
+
access = fs.accessSync
|
|
46
|
+
} = {}
|
|
47
|
+
) {
|
|
48
|
+
const checks = []
|
|
49
|
+
const minimumNode = String(packageJson.engines?.node || '').match(/>=\s*(\d+)/)?.[1]
|
|
50
|
+
const nodeSupported = minimumNode && compareVersions(nodeVersion, `${minimumNode}.0.0`) >= 0
|
|
51
|
+
checks.push(nodeSupported
|
|
52
|
+
? check('pass', 'node', `Node.js ${nodeVersion} 满足 >=${minimumNode}`)
|
|
53
|
+
: check('fail', 'node', `Node.js ${nodeVersion} 不满足 ${packageJson.engines?.node || '版本要求'}`, `安装 Node.js ${minimumNode || 22} 或更高版本。`))
|
|
54
|
+
|
|
55
|
+
checks.push(packageJson.version === '0.1.7'
|
|
56
|
+
? check('pass', 'cli_version', `CLI ${packageJson.version}`)
|
|
57
|
+
: check('fail', 'cli_version', `CLI 版本为 ${packageJson.version},预期 0.1.7`, '安装 @yizhuan-cli/cli@0.1.7。'))
|
|
58
|
+
|
|
59
|
+
if (!exists(configPath)) {
|
|
60
|
+
checks.push(check('fail', 'config', `配置不存在:${configPath}`, '运行 yizhuan config init。'))
|
|
61
|
+
checks.push(check('fail', 'api_url', '无法检查 API 地址。', '先完成配置初始化。'))
|
|
62
|
+
checks.push(check('fail', 'config_permissions', '无法检查配置权限。', '先完成配置初始化。'))
|
|
63
|
+
return { checks, config: null }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let config
|
|
67
|
+
try {
|
|
68
|
+
config = JSON.parse(readFile(configPath, 'utf8'))
|
|
69
|
+
validateApiKey(config.apiKey)
|
|
70
|
+
checks.push(check('pass', 'config', `配置可解析且字段完整:${configPath}`))
|
|
71
|
+
} catch (error) {
|
|
72
|
+
checks.push(check('fail', 'config', `配置无效:${error.message}`, '运行 yizhuan config init --force。'))
|
|
73
|
+
checks.push(check('fail', 'api_url', '无法检查 API 地址。', '修复配置后重试。'))
|
|
74
|
+
config = null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (config) {
|
|
78
|
+
try {
|
|
79
|
+
const apiBaseUrl = validateApiBaseUrl(config.apiBaseUrl)
|
|
80
|
+
checks.push(apiBaseUrl === DEFAULT_API_BASE_URL
|
|
81
|
+
? check('pass', 'api_url', `正式 API 地址:${apiBaseUrl}`)
|
|
82
|
+
: check('warn', 'api_url', `使用显式自定义 API 地址:${apiBaseUrl}`, '确认该地址属于当前测试或开发环境。'))
|
|
83
|
+
} catch (error) {
|
|
84
|
+
checks.push(check('fail', 'api_url', `API 地址无效:${error.message}`, '重新运行 config init 并提供有效地址。'))
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
access(configPath, fs.constants.R_OK | fs.constants.W_OK)
|
|
90
|
+
if (platform === 'win32') {
|
|
91
|
+
checks.push(check('warn', 'config_permissions', '配置文件可读写;Windows ACL 需由系统策略最终确认。', '确保仅当前用户可读取配置文件。'))
|
|
92
|
+
} else {
|
|
93
|
+
const exposedBits = stat(configPath).mode & 0o077
|
|
94
|
+
checks.push(exposedBits === 0
|
|
95
|
+
? check('pass', 'config_permissions', '配置权限限制为当前用户。')
|
|
96
|
+
: check('fail', 'config_permissions', `配置权限过宽(mode ${exposedBits.toString(8)})。`, `运行 chmod 600 "${configPath}"。`))
|
|
97
|
+
}
|
|
98
|
+
} catch (error) {
|
|
99
|
+
checks.push(check('fail', 'config_permissions', `配置权限检查失败:${error.message}`, '修复文件所有者或读写权限。'))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { checks, config }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function inspectApi(
|
|
106
|
+
config,
|
|
107
|
+
{ fetchImpl = fetch, timeoutMs = 8000 } = {}
|
|
108
|
+
) {
|
|
109
|
+
let response
|
|
110
|
+
try {
|
|
111
|
+
response = await fetchImpl(`${String(config.apiBaseUrl).replace(/\/+$/, '')}/api/cli.execute`, {
|
|
112
|
+
method: 'POST',
|
|
113
|
+
headers: {
|
|
114
|
+
authorization: config.apiKey,
|
|
115
|
+
'content-type': 'application/json',
|
|
116
|
+
'x-device-type': 'pc'
|
|
117
|
+
},
|
|
118
|
+
body: JSON.stringify({ json: { ability: 'membership_info' } }),
|
|
119
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
120
|
+
})
|
|
121
|
+
} catch (error) {
|
|
122
|
+
return [
|
|
123
|
+
check('fail', 'api_connectivity', `API 无法连接:${error.message}`, '检查网络、代理和 API 地址。'),
|
|
124
|
+
check('fail', 'api_auth', '未执行 API Key 鉴权。', '恢复 API 连通性后重试。'),
|
|
125
|
+
check('fail', 'membership_read', '未读取会员状态。', '恢复 API 连通性后重试。')
|
|
126
|
+
]
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const connectivity = check('pass', 'api_connectivity', `API 已响应 HTTP ${response.status}`)
|
|
130
|
+
const responseText = await response.text()
|
|
131
|
+
if (!response.ok) {
|
|
132
|
+
const expired = /expired|过期/i.test(responseText)
|
|
133
|
+
const authFailure = response.status === 401 || response.status === 403
|
|
134
|
+
return [
|
|
135
|
+
connectivity,
|
|
136
|
+
check(
|
|
137
|
+
'fail',
|
|
138
|
+
'api_auth',
|
|
139
|
+
authFailure ? `API Key ${expired ? '已过期' : '无效或无权限'}。` : `鉴权请求返回 HTTP ${response.status}。`,
|
|
140
|
+
'在 Web CLI 工具页创建有效 Key,并运行 config init --force。'
|
|
141
|
+
),
|
|
142
|
+
check('fail', 'membership_read', '未读取会员状态。', '修复鉴权后重试。')
|
|
143
|
+
]
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let payload
|
|
147
|
+
try {
|
|
148
|
+
payload = JSON.parse(responseText)
|
|
149
|
+
} catch {
|
|
150
|
+
return [
|
|
151
|
+
connectivity,
|
|
152
|
+
check('fail', 'api_auth', 'API 响应不是有效 JSON。', '确认 API 地址指向兼容的易撰服务。'),
|
|
153
|
+
check('fail', 'membership_read', '未读取会员状态。', '确认服务端已部署 CLI 接口。')
|
|
154
|
+
]
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const data = payload.result?.data?.json ?? payload
|
|
158
|
+
const membership = data?.data ?? data
|
|
159
|
+
if (!membership || (membership.isVip === undefined && membership.expiryDate === undefined)) {
|
|
160
|
+
return [
|
|
161
|
+
connectivity,
|
|
162
|
+
check('pass', 'api_auth', 'API Key 鉴权通过。'),
|
|
163
|
+
check('fail', 'membership_read', '会员状态响应缺少预期字段。', '确认服务端版本与 CLI 兼容。')
|
|
164
|
+
]
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return [
|
|
168
|
+
connectivity,
|
|
169
|
+
check('pass', 'api_auth', 'API Key 鉴权通过。'),
|
|
170
|
+
check('pass', 'membership_read', `会员状态可读(isVip=${Boolean(membership.isVip)})。`)
|
|
171
|
+
]
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function inspectSkill(
|
|
175
|
+
args,
|
|
176
|
+
{
|
|
177
|
+
exists = fs.existsSync,
|
|
178
|
+
resolveTarget = resolveSkillTarget,
|
|
179
|
+
readMetadata = readSkillMetadata,
|
|
180
|
+
loadBundle = loadOfficialSkill
|
|
181
|
+
} = {}
|
|
182
|
+
) {
|
|
183
|
+
let resolved
|
|
184
|
+
try {
|
|
185
|
+
resolved = resolveTarget({ global: Boolean(args.global), host: args.host })
|
|
186
|
+
} catch (error) {
|
|
187
|
+
return check('warn', 'skill', `无法确定正式 Skill 位置:${error.message}`, '使用 doctor --host <name> 明确宿主。')
|
|
188
|
+
}
|
|
189
|
+
if (!exists(resolved.target)) {
|
|
190
|
+
return check('warn', 'skill', `正式 Skill 未安装:${resolved.target}`, `运行 yizhuan skill sync${args.global ? ' --global' : ''}。`)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
const metadata = readMetadata(resolved.target)
|
|
195
|
+
const { manifest } = loadBundle()
|
|
196
|
+
const comparison = compareVersions(metadata.version, manifest.version)
|
|
197
|
+
if (comparison < 0) {
|
|
198
|
+
return check('warn', 'skill', `正式 Skill ${metadata.version} 已过期,当前为 ${manifest.version}。`, `运行 yizhuan skill sync${args.global ? ' --global' : ''}。`)
|
|
199
|
+
}
|
|
200
|
+
if (comparison > 0 || metadata.source !== manifest.source) {
|
|
201
|
+
return check('warn', 'skill', `正式 Skill ${metadata.version} 与当前 CLI 来源不一致。`, '安装固定 CLI 版本后重新同步 Skill。')
|
|
202
|
+
}
|
|
203
|
+
return check('pass', 'skill', `正式 Skill ${metadata.version} 来源和摘要有效:${resolved.target}`)
|
|
204
|
+
} catch (error) {
|
|
205
|
+
return check('fail', 'skill', `正式 Skill 校验失败:${error.message}`, '保留冲突内容并人工确认,或移走后重新运行 skill sync。')
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function runDoctorChecks(args, dependencies = {}) {
|
|
210
|
+
const local = inspectLocalEnvironment(
|
|
211
|
+
{ configPath: dependencies.configPath, platform: dependencies.platform, nodeVersion: dependencies.nodeVersion },
|
|
212
|
+
dependencies
|
|
213
|
+
)
|
|
214
|
+
const checks = [...local.checks]
|
|
215
|
+
const apiUrlCheck = checks.find((item) => item.id === 'api_url')
|
|
216
|
+
if (local.config && apiUrlCheck?.status !== 'fail') {
|
|
217
|
+
checks.push(...await inspectApi(local.config, dependencies))
|
|
218
|
+
} else {
|
|
219
|
+
checks.push(
|
|
220
|
+
check('fail', 'api_connectivity', '未执行 API 连通性检查。', '先修复本地配置。'),
|
|
221
|
+
check('fail', 'api_auth', '未执行 API Key 鉴权。', '先修复本地配置。'),
|
|
222
|
+
check('fail', 'membership_read', '未读取会员状态。', '先修复本地配置。')
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
checks.push(inspectSkill(args, dependencies))
|
|
226
|
+
return checks
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function summarizeDoctorChecks(checks) {
|
|
230
|
+
if (checks.some((check) => check.status === 'fail')) return 'fail'
|
|
231
|
+
if (checks.some((check) => check.status === 'warn')) return 'warn'
|
|
232
|
+
return 'pass'
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function buildDoctorReport(checks) {
|
|
236
|
+
return {
|
|
237
|
+
status: summarizeDoctorChecks(checks),
|
|
238
|
+
checks
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function formatDoctorReport(report) {
|
|
243
|
+
const lines = report.checks.flatMap((check) => {
|
|
244
|
+
const result = [`[${STATUS_LABELS[check.status]}] ${check.id}: ${check.message}`]
|
|
245
|
+
if (check.remediation) result.push(` 修复:${check.remediation}`)
|
|
246
|
+
return result
|
|
247
|
+
})
|
|
248
|
+
lines.push(`诊断结果:${STATUS_LABELS[report.status]}`)
|
|
249
|
+
return lines.join('\n')
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function printDoctorHelp() {
|
|
253
|
+
console.log(`Usage:
|
|
254
|
+
yizhuan doctor [--json] [--global] [--host <name>]
|
|
255
|
+
|
|
256
|
+
Options:
|
|
257
|
+
--json 输出稳定 JSON 结果
|
|
258
|
+
--global 检查用户级正式 Skill
|
|
259
|
+
--host 明确指定 codex、agents、trae、claude 或 cursor
|
|
260
|
+
--help 显示本帮助`)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export async function runDoctor(
|
|
264
|
+
args,
|
|
265
|
+
{
|
|
266
|
+
runChecks = runDoctorChecks,
|
|
267
|
+
log = (message) => console.log(message)
|
|
268
|
+
} = {}
|
|
269
|
+
) {
|
|
270
|
+
if (args.help) {
|
|
271
|
+
printDoctorHelp()
|
|
272
|
+
return { status: 'pass', checks: [] }
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const report = buildDoctorReport(await runChecks(args))
|
|
276
|
+
log(args.json ? JSON.stringify(report, null, 2) : formatDoctorReport(report))
|
|
277
|
+
return report
|
|
278
|
+
}
|
package/src/index.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import fs from 'node:fs'
|
|
3
|
-
import os from 'node:os'
|
|
4
3
|
import path from 'node:path'
|
|
5
4
|
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { getConfigPath, maskApiKey, runConfigInit } from './config.js'
|
|
6
|
+
import { runSkillSync } from './skill.js'
|
|
7
|
+
import { runDoctor } from './doctor.js'
|
|
6
8
|
|
|
7
9
|
const CLI_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
8
10
|
const PACKAGE_PATH = path.join(CLI_DIR, 'package.json')
|
|
9
|
-
const CONFIG_PATH =
|
|
11
|
+
const CONFIG_PATH = getConfigPath()
|
|
10
12
|
const ABILITIES = new Set([
|
|
11
13
|
'works',
|
|
12
14
|
'accounts',
|
|
@@ -52,8 +54,11 @@ Usage:
|
|
|
52
54
|
yizhuan execute --ability <ability> --json-file <params-file>
|
|
53
55
|
yizhuan query --json '<request JSON>' [--format json|table|markdown]
|
|
54
56
|
yizhuan query --json-file <path> [--format json|table|markdown]
|
|
57
|
+
yizhuan config init [--api-key-stdin] [--api-base-url <url>] [--force]
|
|
55
58
|
yizhuan config path
|
|
56
59
|
yizhuan config show
|
|
60
|
+
yizhuan skill sync [--global]
|
|
61
|
+
yizhuan doctor [--json]
|
|
57
62
|
|
|
58
63
|
Examples:
|
|
59
64
|
yizhuan execute --ability works --query "百家号昨天新增了哪些作品"
|
|
@@ -412,9 +417,28 @@ async function main() {
|
|
|
412
417
|
return
|
|
413
418
|
}
|
|
414
419
|
|
|
420
|
+
if (command === 'config' && args._[1] === 'init') {
|
|
421
|
+
assertCommandArgs(args, 'config init', new Set(['apiBaseUrl', 'apiKey', 'apiKeyStdin', 'force', 'help']), 2)
|
|
422
|
+
await runConfigInit(args)
|
|
423
|
+
return
|
|
424
|
+
}
|
|
425
|
+
|
|
415
426
|
if (command === 'config' && args._[1] === 'show') {
|
|
416
427
|
const config = readConfig()
|
|
417
|
-
console.log(JSON.stringify({ ...config, apiKey:
|
|
428
|
+
console.log(JSON.stringify({ ...config, apiKey: maskApiKey(config.apiKey) }, null, 2))
|
|
429
|
+
return
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (command === 'skill' && args._[1] === 'sync') {
|
|
433
|
+
assertCommandArgs(args, 'skill sync', new Set(['global', 'host', 'help']), 2)
|
|
434
|
+
await runSkillSync(args)
|
|
435
|
+
return
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (command === 'doctor') {
|
|
439
|
+
assertCommandArgs(args, 'doctor', new Set(['json', 'global', 'host', 'help']))
|
|
440
|
+
const report = await runDoctor(args)
|
|
441
|
+
if (report.status === 'fail') process.exitCode = 1
|
|
418
442
|
return
|
|
419
443
|
}
|
|
420
444
|
|
package/src/skill.js
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import crypto from 'node:crypto'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
|
|
7
|
+
export const OFFICIAL_SKILL_NAME = 'yizhuan-cli'
|
|
8
|
+
export const SKILL_METADATA_FILE = '.yizhuan-sync.json'
|
|
9
|
+
export const SKILL_MANAGER = '@yizhuan-cli/cli'
|
|
10
|
+
|
|
11
|
+
const CLI_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
12
|
+
const DEFAULT_RESOURCE_DIR = path.join(CLI_DIR, 'resources', OFFICIAL_SKILL_NAME)
|
|
13
|
+
|
|
14
|
+
const HOSTS = {
|
|
15
|
+
codex: { directory: '.codex', configEnv: 'CODEX_HOME' },
|
|
16
|
+
agents: { directory: '.agents' },
|
|
17
|
+
trae: { directory: '.agent' },
|
|
18
|
+
claude: { directory: '.claude', configEnv: 'CLAUDE_CONFIG_DIR' },
|
|
19
|
+
cursor: { directory: '.cursor', configEnv: 'CURSOR_CONFIG_DIR' }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function assertHost(host) {
|
|
23
|
+
if (!HOSTS[host]) {
|
|
24
|
+
throw new Error(`不支持的 Skill 宿主:${host}。可选值:${Object.keys(HOSTS).join(', ')}`)
|
|
25
|
+
}
|
|
26
|
+
return host
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function sha256File(filePath) {
|
|
30
|
+
return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseVersion(version) {
|
|
34
|
+
const matched = String(version).match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/)
|
|
35
|
+
if (!matched) throw new Error(`版本格式无效:${version}`)
|
|
36
|
+
return {
|
|
37
|
+
numbers: matched.slice(1, 4).map(Number),
|
|
38
|
+
prerelease: matched[4]
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function compareVersions(left, right) {
|
|
43
|
+
const a = parseVersion(left)
|
|
44
|
+
const b = parseVersion(right)
|
|
45
|
+
for (let index = 0; index < 3; index += 1) {
|
|
46
|
+
if (a.numbers[index] !== b.numbers[index]) return a.numbers[index] - b.numbers[index]
|
|
47
|
+
}
|
|
48
|
+
if (a.prerelease === b.prerelease) return 0
|
|
49
|
+
if (!a.prerelease) return 1
|
|
50
|
+
if (!b.prerelease) return -1
|
|
51
|
+
return a.prerelease.localeCompare(b.prerelease, 'en', { numeric: true })
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function safeResourcePath(resourceDir, relativePath) {
|
|
55
|
+
if (!relativePath || path.isAbsolute(relativePath)) throw new Error('Skill manifest 包含非法文件路径。')
|
|
56
|
+
const resolved = path.resolve(resourceDir, relativePath)
|
|
57
|
+
if (!resolved.startsWith(`${path.resolve(resourceDir)}${path.sep}`)) {
|
|
58
|
+
throw new Error('Skill manifest 文件路径越界。')
|
|
59
|
+
}
|
|
60
|
+
return resolved
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function loadOfficialSkill(resourceDir = DEFAULT_RESOURCE_DIR) {
|
|
64
|
+
const manifestPath = path.join(resourceDir, 'manifest.json')
|
|
65
|
+
let manifest
|
|
66
|
+
try {
|
|
67
|
+
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
|
|
68
|
+
} catch (error) {
|
|
69
|
+
throw new Error(`无法读取正式 Skill manifest:${error.message}`)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (
|
|
73
|
+
manifest.schemaVersion !== 1 ||
|
|
74
|
+
manifest.name !== OFFICIAL_SKILL_NAME ||
|
|
75
|
+
manifest.digestAlgorithm !== 'sha256' ||
|
|
76
|
+
manifest.source !== `npm:@yizhuan-cli/cli@${manifest.version}` ||
|
|
77
|
+
manifest.compatibleCliVersion !== manifest.version ||
|
|
78
|
+
!Array.isArray(manifest.files) ||
|
|
79
|
+
manifest.files.length === 0
|
|
80
|
+
) {
|
|
81
|
+
throw new Error('正式 Skill manifest 格式或来源无效。')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (const file of manifest.files) {
|
|
85
|
+
const sourcePath = safeResourcePath(resourceDir, file.path)
|
|
86
|
+
if (!/^[a-f0-9]{64}$/.test(file.sha256) || sha256File(sourcePath) !== file.sha256) {
|
|
87
|
+
throw new Error(`正式 Skill 内容校验失败:${file.path}`)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return { manifest, resourceDir }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function readSkillMetadata(target) {
|
|
94
|
+
const metadataPath = path.join(target, SKILL_METADATA_FILE)
|
|
95
|
+
if (!fs.existsSync(metadataPath)) {
|
|
96
|
+
throw new Error(`目标存在未受易撰管理的同名内容,已停止同步:${target}`)
|
|
97
|
+
}
|
|
98
|
+
let metadata
|
|
99
|
+
try {
|
|
100
|
+
metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8'))
|
|
101
|
+
} catch {
|
|
102
|
+
throw new Error(`同步元数据无法解析,已停止覆盖:${metadataPath}`)
|
|
103
|
+
}
|
|
104
|
+
if (
|
|
105
|
+
metadata.managedBy !== SKILL_MANAGER ||
|
|
106
|
+
metadata.name !== OFFICIAL_SKILL_NAME ||
|
|
107
|
+
!/^npm:@yizhuan-cli\/cli@/.test(metadata.source) ||
|
|
108
|
+
!Array.isArray(metadata.files)
|
|
109
|
+
) {
|
|
110
|
+
throw new Error(`同步元数据来源不可信,已停止覆盖:${metadataPath}`)
|
|
111
|
+
}
|
|
112
|
+
for (const file of metadata.files) {
|
|
113
|
+
const installedPath = safeResourcePath(target, file.path)
|
|
114
|
+
if (!fs.existsSync(installedPath) || sha256File(installedPath) !== file.sha256) {
|
|
115
|
+
throw new Error(`已安装 Skill 包含未知修改,已停止覆盖:${file.path}`)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return metadata
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function installStagedSkill(resolved, bundle, now) {
|
|
122
|
+
const { target } = resolved
|
|
123
|
+
const { manifest, resourceDir } = bundle
|
|
124
|
+
const parent = path.dirname(target)
|
|
125
|
+
const suffix = `${process.pid}.${now.getTime()}`
|
|
126
|
+
const staging = path.join(parent, `.${OFFICIAL_SKILL_NAME}.${suffix}.tmp`)
|
|
127
|
+
const backup = path.join(parent, `.${OFFICIAL_SKILL_NAME}.${suffix}.bak`)
|
|
128
|
+
fs.mkdirSync(parent, { recursive: true })
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
fs.mkdirSync(staging, { mode: 0o700 })
|
|
132
|
+
for (const file of manifest.files) {
|
|
133
|
+
const sourcePath = safeResourcePath(resourceDir, file.path)
|
|
134
|
+
const destinationPath = safeResourcePath(staging, file.path)
|
|
135
|
+
fs.mkdirSync(path.dirname(destinationPath), { recursive: true })
|
|
136
|
+
fs.copyFileSync(sourcePath, destinationPath)
|
|
137
|
+
if (sha256File(destinationPath) !== file.sha256) {
|
|
138
|
+
throw new Error(`临时 Skill 内容校验失败:${file.path}`)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const metadata = {
|
|
143
|
+
schemaVersion: 1,
|
|
144
|
+
managedBy: SKILL_MANAGER,
|
|
145
|
+
name: manifest.name,
|
|
146
|
+
version: manifest.version,
|
|
147
|
+
compatibleCliVersion: manifest.compatibleCliVersion,
|
|
148
|
+
source: manifest.source,
|
|
149
|
+
digestAlgorithm: manifest.digestAlgorithm,
|
|
150
|
+
files: manifest.files,
|
|
151
|
+
host: resolved.host,
|
|
152
|
+
scope: resolved.scope,
|
|
153
|
+
syncedAt: now.toISOString()
|
|
154
|
+
}
|
|
155
|
+
fs.writeFileSync(path.join(staging, SKILL_METADATA_FILE), `${JSON.stringify(metadata, null, 2)}\n`, {
|
|
156
|
+
encoding: 'utf8',
|
|
157
|
+
mode: 0o600
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
if (!fs.existsSync(target)) {
|
|
161
|
+
fs.renameSync(staging, target)
|
|
162
|
+
return metadata
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
fs.renameSync(target, backup)
|
|
166
|
+
try {
|
|
167
|
+
fs.renameSync(staging, target)
|
|
168
|
+
} catch (error) {
|
|
169
|
+
fs.renameSync(backup, target)
|
|
170
|
+
throw error
|
|
171
|
+
}
|
|
172
|
+
fs.rmSync(backup, { recursive: true, force: true })
|
|
173
|
+
return metadata
|
|
174
|
+
} catch (error) {
|
|
175
|
+
fs.rmSync(staging, { recursive: true, force: true })
|
|
176
|
+
throw new Error(`Skill 原子更新失败,原目录保持不变:${error.message}`)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function syncOfficialSkill(
|
|
181
|
+
resolved,
|
|
182
|
+
{
|
|
183
|
+
loadBundle = loadOfficialSkill,
|
|
184
|
+
installBundle = installStagedSkill,
|
|
185
|
+
now = () => new Date()
|
|
186
|
+
} = {}
|
|
187
|
+
) {
|
|
188
|
+
const bundle = loadBundle()
|
|
189
|
+
const { manifest } = bundle
|
|
190
|
+
let installed
|
|
191
|
+
if (fs.existsSync(resolved.target)) {
|
|
192
|
+
installed = readSkillMetadata(resolved.target)
|
|
193
|
+
const comparison = compareVersions(installed.version, manifest.version)
|
|
194
|
+
if (comparison > 0) {
|
|
195
|
+
return { action: 'newer', metadata: installed, target: resolved.target }
|
|
196
|
+
}
|
|
197
|
+
if (comparison === 0) {
|
|
198
|
+
const matches = manifest.files.every((file) =>
|
|
199
|
+
fs.existsSync(safeResourcePath(resolved.target, file.path)) &&
|
|
200
|
+
sha256File(safeResourcePath(resolved.target, file.path)) === file.sha256
|
|
201
|
+
)
|
|
202
|
+
if (matches) return { action: 'current', metadata: installed, target: resolved.target }
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const metadata = installBundle(resolved, bundle, now())
|
|
207
|
+
return { action: installed ? 'updated' : 'installed', metadata, target: resolved.target }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function resolveSkillTarget(
|
|
211
|
+
{ global = false, host },
|
|
212
|
+
{
|
|
213
|
+
cwd = process.cwd(),
|
|
214
|
+
home = os.homedir(),
|
|
215
|
+
env = process.env,
|
|
216
|
+
exists = fs.existsSync,
|
|
217
|
+
pathApi = path
|
|
218
|
+
} = {}
|
|
219
|
+
) {
|
|
220
|
+
const requestedHost = host || env.YIZHUAN_SKILL_HOST
|
|
221
|
+
let selectedHost = requestedHost ? assertHost(String(requestedHost).toLowerCase()) : undefined
|
|
222
|
+
|
|
223
|
+
if (!selectedHost) {
|
|
224
|
+
const environmentHosts = Object.entries(HOSTS).filter(
|
|
225
|
+
([, config]) => config.configEnv && env[config.configEnv]
|
|
226
|
+
)
|
|
227
|
+
if (environmentHosts.length === 1) {
|
|
228
|
+
selectedHost = environmentHosts[0][0]
|
|
229
|
+
} else if (environmentHosts.length > 1) {
|
|
230
|
+
throw new Error('检测到多个宿主环境,请使用 --host codex|agents|trae|claude|cursor 明确指定。')
|
|
231
|
+
} else {
|
|
232
|
+
const root = global ? home : cwd
|
|
233
|
+
const directoryHosts = Object.entries(HOSTS).filter(([, config]) =>
|
|
234
|
+
exists(pathApi.join(root, config.directory))
|
|
235
|
+
)
|
|
236
|
+
if (!global && directoryHosts.length > 0) {
|
|
237
|
+
selectedHost = directoryHosts[0][0]
|
|
238
|
+
} else if (directoryHosts.length === 1) {
|
|
239
|
+
selectedHost = directoryHosts[0][0]
|
|
240
|
+
} else {
|
|
241
|
+
const reason = directoryHosts.length ? '检测到多个全局宿主' : '未检测到受支持宿主'
|
|
242
|
+
throw new Error(`${reason},请使用 --host codex|agents|trae|claude|cursor 明确指定。`)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const config = HOSTS[selectedHost]
|
|
248
|
+
const configuredRoot = global && config.configEnv ? env[config.configEnv] : undefined
|
|
249
|
+
const hostRoot = configuredRoot || pathApi.join(global ? home : cwd, config.directory)
|
|
250
|
+
return {
|
|
251
|
+
host: selectedHost,
|
|
252
|
+
scope: global ? 'global' : 'project',
|
|
253
|
+
root: pathApi.join(hostRoot, 'skills'),
|
|
254
|
+
target: pathApi.join(hostRoot, 'skills', OFFICIAL_SKILL_NAME)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function printSkillSyncHelp() {
|
|
259
|
+
console.log(`Usage:
|
|
260
|
+
yizhuan skill sync [--global] [--host <name>]
|
|
261
|
+
|
|
262
|
+
Options:
|
|
263
|
+
--global 安装到当前受支持宿主的用户级 Skill 目录
|
|
264
|
+
--host 明确指定 codex、agents、trae、claude 或 cursor
|
|
265
|
+
--help 显示本帮助`)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export async function runSkillSync(args) {
|
|
269
|
+
if (args.help) {
|
|
270
|
+
printSkillSyncHelp()
|
|
271
|
+
return
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const resolved = resolveSkillTarget({ global: Boolean(args.global), host: args.host })
|
|
275
|
+
const result = syncOfficialSkill(resolved)
|
|
276
|
+
const actionText = {
|
|
277
|
+
installed: '已安装',
|
|
278
|
+
updated: '已更新',
|
|
279
|
+
current: '无需更新',
|
|
280
|
+
newer: '已安装版本较新,未降级'
|
|
281
|
+
}[result.action]
|
|
282
|
+
console.log(`${actionText}正式 Skill ${result.metadata.version}:${result.target}`)
|
|
283
|
+
}
|
package/src/params.test.js
DELETED
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
import test from 'node:test'
|
|
2
|
-
import assert from 'node:assert/strict'
|
|
3
|
-
import { rmSync, writeFileSync } from 'node:fs'
|
|
4
|
-
import { fileURLToPath } from 'node:url'
|
|
5
|
-
import { assertCommandArgs, buildExecuteParams, parseArgs, parseParams } from './index.js'
|
|
6
|
-
|
|
7
|
-
test('parseParams accepts standard JSON', () => {
|
|
8
|
-
assert.deepEqual(parseParams('{"platform":"douyin"}'), { platform: 'douyin' })
|
|
9
|
-
})
|
|
10
|
-
|
|
11
|
-
test('parseParams repairs PowerShell-mangled escaped JSON fragments', () => {
|
|
12
|
-
assert.deepEqual(parseParams('{\\platform\\:\\douyin\\}'), { platform: 'douyin' })
|
|
13
|
-
assert.deepEqual(parseParams('{"platform":"douyin"}'.replaceAll('"', '\\"')), {
|
|
14
|
-
platform: 'douyin'
|
|
15
|
-
})
|
|
16
|
-
})
|
|
17
|
-
|
|
18
|
-
test('parseParams accepts key=value and loose objects', () => {
|
|
19
|
-
assert.deepEqual(parseParams('platform=douyin'), { platform: 'douyin' })
|
|
20
|
-
assert.deepEqual(parseParams('{platform:douyin,mode:list}'), {
|
|
21
|
-
platform: 'douyin',
|
|
22
|
-
mode: 'list'
|
|
23
|
-
})
|
|
24
|
-
})
|
|
25
|
-
|
|
26
|
-
test('buildExecuteParams merges top-level flags', () => {
|
|
27
|
-
assert.deepEqual(
|
|
28
|
-
buildExecuteParams({
|
|
29
|
-
params: '{"mode":"timeline"}',
|
|
30
|
-
platform: 'douyin',
|
|
31
|
-
timeRange: '7d'
|
|
32
|
-
}),
|
|
33
|
-
{
|
|
34
|
-
mode: 'timeline',
|
|
35
|
-
platform: 'douyin',
|
|
36
|
-
timeRange: '7d'
|
|
37
|
-
}
|
|
38
|
-
)
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
test('buildExecuteParams prefers explicit params keys over flags', () => {
|
|
42
|
-
assert.deepEqual(
|
|
43
|
-
buildExecuteParams({
|
|
44
|
-
params: 'platform=weibo',
|
|
45
|
-
platform: 'douyin'
|
|
46
|
-
}),
|
|
47
|
-
{ platform: 'weibo' }
|
|
48
|
-
)
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
test('buildExecuteParams reads execute params from --json-file', (t) => {
|
|
52
|
-
const fixture = new URL(`./params-${process.pid}-${Date.now()}.json`, import.meta.url)
|
|
53
|
-
t.after(() => rmSync(fixture))
|
|
54
|
-
writeFileSync(
|
|
55
|
-
fixture,
|
|
56
|
-
JSON.stringify({
|
|
57
|
-
mode: 'follow',
|
|
58
|
-
platform: 'baijiahao',
|
|
59
|
-
extAuthorId: '1659153597622724'
|
|
60
|
-
})
|
|
61
|
-
)
|
|
62
|
-
|
|
63
|
-
assert.deepEqual(buildExecuteParams({ jsonFile: fileURLToPath(fixture) }), {
|
|
64
|
-
mode: 'follow',
|
|
65
|
-
platform: 'baijiahao',
|
|
66
|
-
extAuthorId: '1659153597622724'
|
|
67
|
-
})
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
test('buildExecuteParams rejects mixed --json-file and flag inputs', (t) => {
|
|
71
|
-
const fixture = new URL(`./params-${process.pid}-${Date.now()}.json`, import.meta.url)
|
|
72
|
-
t.after(() => rmSync(fixture))
|
|
73
|
-
writeFileSync(fixture, '{"mode":"following"}')
|
|
74
|
-
|
|
75
|
-
assert.throws(
|
|
76
|
-
() => buildExecuteParams({ jsonFile: fileURLToPath(fixture), mode: 'follow' }),
|
|
77
|
-
/不能与其他参数输入混用/
|
|
78
|
-
)
|
|
79
|
-
})
|
|
80
|
-
|
|
81
|
-
test('parseArgs preserves query JSON supplied after --json', () => {
|
|
82
|
-
assert.deepEqual(
|
|
83
|
-
parseArgs(['query', '--json', '{"intent":"author_search","platform":"douyin"}']),
|
|
84
|
-
{ _: ['query'], json: '{"intent":"author_search","platform":"douyin"}' }
|
|
85
|
-
)
|
|
86
|
-
})
|
|
87
|
-
|
|
88
|
-
test('parseArgs maps --json-file to the property used by query', () => {
|
|
89
|
-
assert.deepEqual(
|
|
90
|
-
parseArgs(['query', '--json-file', 'request.json']),
|
|
91
|
-
{ _: ['query'], jsonFile: 'request.json' }
|
|
92
|
-
)
|
|
93
|
-
})
|
|
94
|
-
|
|
95
|
-
test('parseArgs preserves query JSON split into multiple argv fragments', () => {
|
|
96
|
-
assert.deepEqual(
|
|
97
|
-
parseArgs(['query', '--json', '{"intent":', '"author_search",', '"platform":"douyin"}']),
|
|
98
|
-
{ _: ['query'], json: '{"intent":"author_search","platform":"douyin"}' }
|
|
99
|
-
)
|
|
100
|
-
})
|
|
101
|
-
|
|
102
|
-
test('assertCommandArgs rejects extra key=value positionals', () => {
|
|
103
|
-
const args = parseArgs([
|
|
104
|
-
'execute',
|
|
105
|
-
'--ability',
|
|
106
|
-
'radar',
|
|
107
|
-
'--params',
|
|
108
|
-
'mode=follow',
|
|
109
|
-
'platform=baijiahao'
|
|
110
|
-
])
|
|
111
|
-
|
|
112
|
-
assert.throws(
|
|
113
|
-
() => assertCommandArgs(args, 'execute', new Set(['ability', 'params'])),
|
|
114
|
-
/未识别的位置参数/
|
|
115
|
-
)
|
|
116
|
-
})
|
package/src/version.test.js
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import assert from 'node:assert/strict'
|
|
2
|
-
import { execFileSync } from 'node:child_process'
|
|
3
|
-
import fs from 'node:fs'
|
|
4
|
-
import path from 'node:path'
|
|
5
|
-
import test from 'node:test'
|
|
6
|
-
import { fileURLToPath } from 'node:url'
|
|
7
|
-
|
|
8
|
-
const CLI_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
9
|
-
const ENTRY_PATH = path.join(CLI_DIR, 'src', 'index.js')
|
|
10
|
-
const PACKAGE_PATH = path.join(CLI_DIR, 'package.json')
|
|
11
|
-
const expectedVersion = JSON.parse(fs.readFileSync(PACKAGE_PATH, 'utf8')).version
|
|
12
|
-
|
|
13
|
-
for (const flag of ['--version', '-v']) {
|
|
14
|
-
test(`${flag} prints the package version`, () => {
|
|
15
|
-
const output = execFileSync(process.execPath, [ENTRY_PATH, flag], {
|
|
16
|
-
encoding: 'utf8'
|
|
17
|
-
}).trim()
|
|
18
|
-
|
|
19
|
-
assert.equal(output, expectedVersion)
|
|
20
|
-
assert.notEqual(output, '')
|
|
21
|
-
})
|
|
22
|
-
}
|