@yizhuan-cli/cli 0.1.6-beta.0 → 0.1.6
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 +11 -5
- package/package.json +15 -10
- package/resources/yizhuan-cli/SKILL.md +57 -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 +69 -5
- package/src/skill.js +283 -0
- package/src/params.test.js +0 -68
- 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.6
|
|
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.6 --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
|
```
|
|
@@ -40,6 +42,7 @@ yizhuan execute --ability accounts --params '{"mode":"public_search","platform":
|
|
|
40
42
|
yizhuan execute --ability radar --params '{"mode":"following"}'
|
|
41
43
|
yizhuan execute --ability radar --mode timeline --timeRange 7d
|
|
42
44
|
yizhuan execute --ability radar --params '{"mode":"unfollow","articleId":"205169870"}'
|
|
45
|
+
yizhuan execute --ability radar --json-file params.json
|
|
43
46
|
```
|
|
44
47
|
|
|
45
48
|
### PowerShell 参数说明
|
|
@@ -47,8 +50,8 @@ yizhuan execute --ability radar --params '{"mode":"unfollow","articleId":"205169
|
|
|
47
50
|
PowerShell 对 JSON 双引号转义不友好。推荐以下写法:
|
|
48
51
|
|
|
49
52
|
```powershell
|
|
50
|
-
#
|
|
51
|
-
yizhuan execute --ability hot_topics --
|
|
53
|
+
# Agent 和跨 Shell 最稳妥:参数文件是唯一输入源
|
|
54
|
+
yizhuan execute --ability hot_topics --json-file params.json
|
|
52
55
|
|
|
53
56
|
# 单引号包 JSON
|
|
54
57
|
yizhuan execute --ability hot_topics --params '{"platform":"douyin"}'
|
|
@@ -57,6 +60,8 @@ yizhuan execute --ability hot_topics --params '{"platform":"douyin"}'
|
|
|
57
60
|
yizhuan execute --ability hot_topics --params platform=douyin
|
|
58
61
|
```
|
|
59
62
|
|
|
63
|
+
`execute --json-file` 不能与 `--params`、`--query` 或独立参数混用。CLI 会拒绝未知参数和游离的 `key=value`,避免参数丢失后执行默认能力。
|
|
64
|
+
|
|
60
65
|
请避免 bash 风格的:
|
|
61
66
|
|
|
62
67
|
```powershell
|
|
@@ -68,6 +73,7 @@ yizhuan execute --ability hot_topics --params "{\"platform\":\"douyin\"}"
|
|
|
68
73
|
|
|
69
74
|
- 作品查询:`yizhuan execute --ability works --query "AI 相关的热门作品有哪些"`
|
|
70
75
|
- 作者查询:`yizhuan execute --ability accounts --query "查询张三这个作者"`
|
|
76
|
+
- 作者指标筛选:`yizhuan execute --ability author_query --params '{"platform_id":2,"min_follower":500000,"min_total_likes":100000}'`
|
|
71
77
|
- 作者全网搜索:`yizhuan execute --ability accounts --params '{"mode":"public_search","platform":"toutiao","keyword":"张三"}'`
|
|
72
78
|
- 关注全网作者:`yizhuan execute --ability accounts --params '{"mode":"follow_public_author","platform":"toutiao","keyword":"张三","extAuthorId":"author-1"}'`
|
|
73
79
|
- 热点查询:`yizhuan execute --ability hot_topics --platform douyin`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yizhuan-cli/cli",
|
|
3
|
-
"version": "0.1.6
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "易撰命令行工具,用于通过 API Key 查询易撰真实数据。",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
@@ -8,9 +8,21 @@
|
|
|
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
|
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"start": "node src/index.js",
|
|
20
|
+
"test": "node --test \"src/*.test.js\"",
|
|
21
|
+
"pack:dry-run": "npm pack --dry-run",
|
|
22
|
+
"smoke": "node src/index.js --help && node --test src/version.test.js",
|
|
23
|
+
"test:params": "node --test src/params.test.js",
|
|
24
|
+
"test:version": "node --test src/version.test.js"
|
|
25
|
+
},
|
|
14
26
|
"engines": {
|
|
15
27
|
"node": ">=22"
|
|
16
28
|
},
|
|
@@ -22,12 +34,5 @@
|
|
|
22
34
|
"license": "UNLICENSED",
|
|
23
35
|
"publishConfig": {
|
|
24
36
|
"access": "public"
|
|
25
|
-
},
|
|
26
|
-
"scripts": {
|
|
27
|
-
"start": "node src/index.js",
|
|
28
|
-
"pack:dry-run": "npm pack --dry-run",
|
|
29
|
-
"smoke": "node src/index.js --help && node --test src/version.test.js",
|
|
30
|
-
"test:params": "node --test src/params.test.js",
|
|
31
|
-
"test:version": "node --test src/version.test.js"
|
|
32
37
|
}
|
|
33
|
-
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
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 execute --ability accounts --query "张三"` |
|
|
32
|
+
| 作者指标筛选 | `yizhuan execute --ability author_query --params '{"platform_id":2,"min_follower":500000,"min_total_likes":100000}'` |
|
|
33
|
+
| 作者全网搜索 | `yizhuan query --json '{"intent":"author_search","scope":"public","platform":"douyin","keyword":"张三"}'` |
|
|
34
|
+
| 热点 | `yizhuan query --json '{"intent":"hot_topics","scope":"platform","platform":"douyin","timeRange":"today"}'` |
|
|
35
|
+
| 我的关注 | `yizhuan execute --ability radar --params '{"mode":"following"}'` |
|
|
36
|
+
| 竞品动态 | `yizhuan execute --ability radar --mode timeline --timeRange 7d` |
|
|
37
|
+
| 会员 | `yizhuan execute --ability membership` |
|
|
38
|
+
| 我的灵感 / 灵感库 / 收藏作品 | `yizhuan execute --ability favorites` |
|
|
39
|
+
|
|
40
|
+
## 调用规则
|
|
41
|
+
|
|
42
|
+
- 需要真实易撰数据时必须调用 CLI,不直接猜测结果。
|
|
43
|
+
- PowerShell 使用单引号包裹 JSON。终端可能改写引号时,将不含密钥的请求对象写入文件,再使用 `--json-file`。
|
|
44
|
+
- `execute --json-file` 是完整参数对象的唯一输入源,不与 `--params`、`--query` 或独立参数混用。
|
|
45
|
+
- 热点查询使用 `query` 统一协议并明确 `platform`;缺少平台时先澄清,不默认猜测。
|
|
46
|
+
- 平台热点必须严格按 CLI 返回的 `data.items` / `rank` 顺序展示,该顺序与 Web 端平台榜单一致;`hot` 仅为展示指标,禁止按 `hot` 或格式化后的“万”值二次排序。
|
|
47
|
+
- 使用 Markdown 表格展示热点时,必须将标题中的 `|` 转义为 `\|`,不得让标题内容错位到热度列。
|
|
48
|
+
- 全网作者搜索使用 `intent=author_search`、`scope=public`,并提供 `platform` 和 `keyword`。
|
|
49
|
+
- 作者累计指标筛选使用 `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`。
|
|
50
|
+
- 作者指标查询返回后,必须核对 `data.items` 中所有已请求指标。指标缺失、显示“暂未返回”或低于/高于用户阈值的项目不得作为符合条件的结果展示;一旦发现此类项目,必须先检查实际请求参数是否遗漏对应的 min/max 字段并使用完整参数重新查询,禁止直接报告原总数。
|
|
51
|
+
- “我的关注作者”使用 `radar` 且 `mode=following`,不是作品收藏。
|
|
52
|
+
- “我的灵感”“灵感库”“灵感作品”“我的收藏”和“收藏作品”是同一业务概念,均直接使用 `favorites`;不得尝试 `inspiration` 等不存在的 ability,也不要反问用户是不是指收藏作品。
|
|
53
|
+
- 竞品动态使用 `radar` 且 `mode=timeline`;未给时间范围时使用 `timeRange=7d`。
|
|
54
|
+
- 关注或取消关注属于写操作,必须来自用户明确指令;执行前核对昵称、平台和标识,不批量猜测目标。
|
|
55
|
+
- 取消关注前先查询关注列表,使用匹配项的 `articleId`,不得因结果不符合预期自行取消。
|
|
56
|
+
- `unsupported` 表示当前口径不支持,`empty` 表示没有匹配数据;如实转述,不编造补充结果。
|
|
57
|
+
- CLI 报错时保留脱敏后的错误信息,引导检查 `yizhuan doctor`,不得输出完整 Key。
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"name": "yizhuan-cli",
|
|
4
|
+
"version": "0.1.6",
|
|
5
|
+
"compatibleCliVersion": "0.1.6",
|
|
6
|
+
"source": "npm:@yizhuan-cli/cli@0.1.6",
|
|
7
|
+
"digestAlgorithm": "sha256",
|
|
8
|
+
"files": [
|
|
9
|
+
{
|
|
10
|
+
"path": "SKILL.md",
|
|
11
|
+
"sha256": "66fa2442fe0624b5e5425ab753412a5d153fdda11b69e70a2defcad78dfc7665"
|
|
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.6'
|
|
56
|
+
? check('pass', 'cli_version', `CLI ${packageJson.version}`)
|
|
57
|
+
: check('fail', 'cli_version', `CLI 版本为 ${packageJson.version},预期 0.1.6`, '安装 @yizhuan-cli/cli@0.1.6。'))
|
|
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',
|
|
@@ -48,11 +50,15 @@ function printHelp() {
|
|
|
48
50
|
Usage:
|
|
49
51
|
yizhuan --help
|
|
50
52
|
yizhuan --version
|
|
51
|
-
yizhuan execute --ability <ability> [--query <text>] [--params <json>]
|
|
53
|
+
yizhuan execute --ability <ability> [--query <text>] [--params <json>]
|
|
54
|
+
yizhuan execute --ability <ability> --json-file <params-file>
|
|
52
55
|
yizhuan query --json '<request JSON>' [--format json|table|markdown]
|
|
53
56
|
yizhuan query --json-file <path> [--format json|table|markdown]
|
|
57
|
+
yizhuan config init [--api-key-stdin] [--api-base-url <url>] [--force]
|
|
54
58
|
yizhuan config path
|
|
55
59
|
yizhuan config show
|
|
60
|
+
yizhuan skill sync [--global]
|
|
61
|
+
yizhuan doctor [--json]
|
|
56
62
|
|
|
57
63
|
Examples:
|
|
58
64
|
yizhuan execute --ability works --query "百家号昨天新增了哪些作品"
|
|
@@ -65,7 +71,8 @@ Examples:
|
|
|
65
71
|
|
|
66
72
|
Notes:
|
|
67
73
|
- PowerShell 推荐优先使用 --platform / --mode 等独立参数,或单引号 JSON:--params '{"platform":"douyin"}'
|
|
68
|
-
-
|
|
74
|
+
- Agent 和所有 Shell 推荐使用 execute --json-file params.json,避免 JSON 引号被终端改写
|
|
75
|
+
- --json-file 是唯一参数源,不能与 --params、--query 或独立参数混用
|
|
69
76
|
- bash/zsh 也可用:--params '{"platform":"douyin"}' 或 --platform douyin
|
|
70
77
|
- 兼容 key=value:--params platform=douyin
|
|
71
78
|
|
|
@@ -287,6 +294,22 @@ function assignFlagParams(params, args) {
|
|
|
287
294
|
}
|
|
288
295
|
|
|
289
296
|
export function buildExecuteParams(args) {
|
|
297
|
+
if (args.jsonFile) {
|
|
298
|
+
const conflictingKeys = ['params', 'query', ...PARAM_FLAG_KEYS].filter(
|
|
299
|
+
(key) => args[key] != null && args[key] !== false
|
|
300
|
+
)
|
|
301
|
+
if (conflictingKeys.length > 0) {
|
|
302
|
+
throw new Error(
|
|
303
|
+
`execute --json-file 不能与其他参数输入混用:${conflictingKeys.map((key) => `--${key}`).join(', ')}`
|
|
304
|
+
)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const jsonText = fs.readFileSync(path.resolve(String(args.jsonFile)), 'utf8')
|
|
308
|
+
const fileParams = parseParams(jsonText)
|
|
309
|
+
if (!fileParams) throw new Error('execute --json-file 必须包含 JSON 参数对象')
|
|
310
|
+
return fileParams
|
|
311
|
+
}
|
|
312
|
+
|
|
290
313
|
const params = {
|
|
291
314
|
...(parseParams(args.params) || {})
|
|
292
315
|
}
|
|
@@ -294,7 +317,28 @@ export function buildExecuteParams(args) {
|
|
|
294
317
|
return Object.keys(params).length > 0 ? params : undefined
|
|
295
318
|
}
|
|
296
319
|
|
|
320
|
+
export function assertCommandArgs(args, command, allowedFlags, positionalCount = 1) {
|
|
321
|
+
const extraPositionals = args._.slice(positionalCount)
|
|
322
|
+
if (extraPositionals.length > 0) {
|
|
323
|
+
throw new Error(
|
|
324
|
+
`${command} 存在未识别的位置参数:${extraPositionals.join(' ')}。多个参数必须写入同一个 JSON 对象`
|
|
325
|
+
)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const unknownFlags = Object.keys(args).filter(
|
|
329
|
+
(key) => key !== '_' && !allowedFlags.has(key)
|
|
330
|
+
)
|
|
331
|
+
if (unknownFlags.length > 0) {
|
|
332
|
+
throw new Error(`${command} 存在未知参数:${unknownFlags.map((key) => `--${key}`).join(', ')}`)
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
297
336
|
async function execute(args) {
|
|
337
|
+
assertCommandArgs(
|
|
338
|
+
args,
|
|
339
|
+
'execute',
|
|
340
|
+
new Set(['ability', 'query', 'params', 'jsonFile', ...PARAM_FLAG_KEYS])
|
|
341
|
+
)
|
|
298
342
|
const ability = args.ability
|
|
299
343
|
if (!ABILITIES.has(ability)) {
|
|
300
344
|
throw new Error(`未知 ability:${ability || ''},请执行 yizhuan --help 查看可用能力`)
|
|
@@ -336,6 +380,7 @@ function renderQuery(payload, format) {
|
|
|
336
380
|
}
|
|
337
381
|
|
|
338
382
|
async function query(args) {
|
|
383
|
+
assertCommandArgs(args, 'query', new Set(['json', 'jsonFile', 'params', 'format']))
|
|
339
384
|
const jsonText = args.jsonFile
|
|
340
385
|
? fs.readFileSync(path.resolve(String(args.jsonFile)), 'utf8')
|
|
341
386
|
: args.json === '-'
|
|
@@ -372,9 +417,28 @@ async function main() {
|
|
|
372
417
|
return
|
|
373
418
|
}
|
|
374
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
|
+
|
|
375
426
|
if (command === 'config' && args._[1] === 'show') {
|
|
376
427
|
const config = readConfig()
|
|
377
|
-
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
|
|
378
442
|
return
|
|
379
443
|
}
|
|
380
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,68 +0,0 @@
|
|
|
1
|
-
import test from 'node:test'
|
|
2
|
-
import assert from 'node:assert/strict'
|
|
3
|
-
import { buildExecuteParams, parseArgs, parseParams } from './index.js'
|
|
4
|
-
|
|
5
|
-
test('parseParams accepts standard JSON', () => {
|
|
6
|
-
assert.deepEqual(parseParams('{"platform":"douyin"}'), { platform: 'douyin' })
|
|
7
|
-
})
|
|
8
|
-
|
|
9
|
-
test('parseParams repairs PowerShell-mangled escaped JSON fragments', () => {
|
|
10
|
-
assert.deepEqual(parseParams('{\\platform\\:\\douyin\\}'), { platform: 'douyin' })
|
|
11
|
-
assert.deepEqual(parseParams('{"platform":"douyin"}'.replaceAll('"', '\\"')), {
|
|
12
|
-
platform: 'douyin'
|
|
13
|
-
})
|
|
14
|
-
})
|
|
15
|
-
|
|
16
|
-
test('parseParams accepts key=value and loose objects', () => {
|
|
17
|
-
assert.deepEqual(parseParams('platform=douyin'), { platform: 'douyin' })
|
|
18
|
-
assert.deepEqual(parseParams('{platform:douyin,mode:list}'), {
|
|
19
|
-
platform: 'douyin',
|
|
20
|
-
mode: 'list'
|
|
21
|
-
})
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
test('buildExecuteParams merges top-level flags', () => {
|
|
25
|
-
assert.deepEqual(
|
|
26
|
-
buildExecuteParams({
|
|
27
|
-
params: '{"mode":"timeline"}',
|
|
28
|
-
platform: 'douyin',
|
|
29
|
-
timeRange: '7d'
|
|
30
|
-
}),
|
|
31
|
-
{
|
|
32
|
-
mode: 'timeline',
|
|
33
|
-
platform: 'douyin',
|
|
34
|
-
timeRange: '7d'
|
|
35
|
-
}
|
|
36
|
-
)
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
test('buildExecuteParams prefers explicit params keys over flags', () => {
|
|
40
|
-
assert.deepEqual(
|
|
41
|
-
buildExecuteParams({
|
|
42
|
-
params: 'platform=weibo',
|
|
43
|
-
platform: 'douyin'
|
|
44
|
-
}),
|
|
45
|
-
{ platform: 'weibo' }
|
|
46
|
-
)
|
|
47
|
-
})
|
|
48
|
-
|
|
49
|
-
test('parseArgs preserves query JSON supplied after --json', () => {
|
|
50
|
-
assert.deepEqual(
|
|
51
|
-
parseArgs(['query', '--json', '{"intent":"author_search","platform":"douyin"}']),
|
|
52
|
-
{ _: ['query'], json: '{"intent":"author_search","platform":"douyin"}' }
|
|
53
|
-
)
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
test('parseArgs maps --json-file to the property used by query', () => {
|
|
57
|
-
assert.deepEqual(
|
|
58
|
-
parseArgs(['query', '--json-file', 'request.json']),
|
|
59
|
-
{ _: ['query'], jsonFile: 'request.json' }
|
|
60
|
-
)
|
|
61
|
-
})
|
|
62
|
-
|
|
63
|
-
test('parseArgs preserves query JSON split into multiple argv fragments', () => {
|
|
64
|
-
assert.deepEqual(
|
|
65
|
-
parseArgs(['query', '--json', '{"intent":', '"author_search",', '"platform":"douyin"}']),
|
|
66
|
-
{ _: ['query'], json: '{"intent":"author_search","platform":"douyin"}' }
|
|
67
|
-
)
|
|
68
|
-
})
|
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
|
-
}
|