@yizhuan-cli/cli 0.1.5-beta.0 → 0.1.5

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 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.5
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.5 --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 --platform douyin
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.5-beta.0",
3
+ "version": "0.1.5",
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,53 @@
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}'` |
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
+ - 全网作者搜索使用 `intent=author_search`、`scope=public`,并提供 `platform` 和 `keyword`。
47
+ - 作者累计指标筛选使用 `author_query`,不得改成作品指标或擅自降低阈值。
48
+ - “我的关注作者”使用 `radar` 且 `mode=following`,不是作品收藏。
49
+ - 竞品动态使用 `radar` 且 `mode=timeline`;未给时间范围时使用 `timeRange=7d`。
50
+ - 关注或取消关注属于写操作,必须来自用户明确指令;执行前核对昵称、平台和标识,不批量猜测目标。
51
+ - 取消关注前先查询关注列表,使用匹配项的 `articleId`,不得因结果不符合预期自行取消。
52
+ - `unsupported` 表示当前口径不支持,`empty` 表示没有匹配数据;如实转述,不编造补充结果。
53
+ - CLI 报错时保留脱敏后的错误信息,引导检查 `yizhuan doctor`,不得输出完整 Key。
@@ -0,0 +1,14 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "name": "yizhuan-cli",
4
+ "version": "0.1.5",
5
+ "compatibleCliVersion": "0.1.5",
6
+ "source": "npm:@yizhuan-cli/cli@0.1.5",
7
+ "digestAlgorithm": "sha256",
8
+ "files": [
9
+ {
10
+ "path": "SKILL.md",
11
+ "sha256": "e165b200713dffc6a2c6b4c8efb25774edd73a6d3590ebec255c4903a2da5ec3"
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.5'
56
+ ? check('pass', 'cli_version', `CLI ${packageJson.version}`)
57
+ : check('fail', 'cli_version', `CLI 版本为 ${packageJson.version},预期 0.1.5`, '安装 @yizhuan-cli/cli@0.1.5。'))
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、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 = process.env.YIZHUAN_CONFIG_PATH || path.join(os.homedir(), '.config', 'yizhuan', 'config.json')
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>] [--platform <name>]
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
- - 所有 Shell 都可用 --json-file request.json,避免 JSON 引号被终端改写
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
 
@@ -131,7 +138,7 @@ function readFlagValue(argv, startIndex, key) {
131
138
  let raw = argv[index]
132
139
  if (raw == null) return { value: true, nextIndex: startIndex - 1 }
133
140
 
134
- if (key === 'params' && /[{\[]/.test(raw)) {
141
+ if ((key === 'params' || key === 'json') && /[{\[]/.test(raw)) {
135
142
  let joined = raw
136
143
  while (!isBalancedJsonLike(joined) && index + 1 < argv.length && !String(argv[index + 1]).startsWith('--')) {
137
144
  index += 1
@@ -155,13 +162,13 @@ export function parseArgs(argv) {
155
162
  const body = value.slice(2)
156
163
  const eqIndex = body.indexOf('=')
157
164
  if (eqIndex > 0) {
158
- const key = body.slice(0, eqIndex)
165
+ const key = body.slice(0, eqIndex).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())
159
166
  const raw = body.slice(eqIndex + 1)
160
167
  args[key] = raw
161
168
  continue
162
169
  }
163
170
 
164
- const key = body
171
+ const key = body.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())
165
172
  const next = argv[index + 1]
166
173
  if (!next || next.startsWith('--')) {
167
174
  args[key] = true
@@ -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: `${config.apiKey.slice(0, 18)}...` }, null, 2))
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,282 @@
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
+ claude: { directory: '.claude', configEnv: 'CLAUDE_CONFIG_DIR' },
18
+ cursor: { directory: '.cursor', configEnv: 'CURSOR_CONFIG_DIR' }
19
+ }
20
+
21
+ function assertHost(host) {
22
+ if (!HOSTS[host]) {
23
+ throw new Error(`不支持的 Skill 宿主:${host}。可选值:${Object.keys(HOSTS).join(', ')}`)
24
+ }
25
+ return host
26
+ }
27
+
28
+ export function sha256File(filePath) {
29
+ return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex')
30
+ }
31
+
32
+ function parseVersion(version) {
33
+ const matched = String(version).match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/)
34
+ if (!matched) throw new Error(`版本格式无效:${version}`)
35
+ return {
36
+ numbers: matched.slice(1, 4).map(Number),
37
+ prerelease: matched[4]
38
+ }
39
+ }
40
+
41
+ export function compareVersions(left, right) {
42
+ const a = parseVersion(left)
43
+ const b = parseVersion(right)
44
+ for (let index = 0; index < 3; index += 1) {
45
+ if (a.numbers[index] !== b.numbers[index]) return a.numbers[index] - b.numbers[index]
46
+ }
47
+ if (a.prerelease === b.prerelease) return 0
48
+ if (!a.prerelease) return 1
49
+ if (!b.prerelease) return -1
50
+ return a.prerelease.localeCompare(b.prerelease, 'en', { numeric: true })
51
+ }
52
+
53
+ function safeResourcePath(resourceDir, relativePath) {
54
+ if (!relativePath || path.isAbsolute(relativePath)) throw new Error('Skill manifest 包含非法文件路径。')
55
+ const resolved = path.resolve(resourceDir, relativePath)
56
+ if (!resolved.startsWith(`${path.resolve(resourceDir)}${path.sep}`)) {
57
+ throw new Error('Skill manifest 文件路径越界。')
58
+ }
59
+ return resolved
60
+ }
61
+
62
+ export function loadOfficialSkill(resourceDir = DEFAULT_RESOURCE_DIR) {
63
+ const manifestPath = path.join(resourceDir, 'manifest.json')
64
+ let manifest
65
+ try {
66
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
67
+ } catch (error) {
68
+ throw new Error(`无法读取正式 Skill manifest:${error.message}`)
69
+ }
70
+
71
+ if (
72
+ manifest.schemaVersion !== 1 ||
73
+ manifest.name !== OFFICIAL_SKILL_NAME ||
74
+ manifest.digestAlgorithm !== 'sha256' ||
75
+ manifest.source !== `npm:@yizhuan-cli/cli@${manifest.version}` ||
76
+ manifest.compatibleCliVersion !== manifest.version ||
77
+ !Array.isArray(manifest.files) ||
78
+ manifest.files.length === 0
79
+ ) {
80
+ throw new Error('正式 Skill manifest 格式或来源无效。')
81
+ }
82
+
83
+ for (const file of manifest.files) {
84
+ const sourcePath = safeResourcePath(resourceDir, file.path)
85
+ if (!/^[a-f0-9]{64}$/.test(file.sha256) || sha256File(sourcePath) !== file.sha256) {
86
+ throw new Error(`正式 Skill 内容校验失败:${file.path}`)
87
+ }
88
+ }
89
+ return { manifest, resourceDir }
90
+ }
91
+
92
+ export function readSkillMetadata(target) {
93
+ const metadataPath = path.join(target, SKILL_METADATA_FILE)
94
+ if (!fs.existsSync(metadataPath)) {
95
+ throw new Error(`目标存在未受易撰管理的同名内容,已停止同步:${target}`)
96
+ }
97
+ let metadata
98
+ try {
99
+ metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8'))
100
+ } catch {
101
+ throw new Error(`同步元数据无法解析,已停止覆盖:${metadataPath}`)
102
+ }
103
+ if (
104
+ metadata.managedBy !== SKILL_MANAGER ||
105
+ metadata.name !== OFFICIAL_SKILL_NAME ||
106
+ !/^npm:@yizhuan-cli\/cli@/.test(metadata.source) ||
107
+ !Array.isArray(metadata.files)
108
+ ) {
109
+ throw new Error(`同步元数据来源不可信,已停止覆盖:${metadataPath}`)
110
+ }
111
+ for (const file of metadata.files) {
112
+ const installedPath = safeResourcePath(target, file.path)
113
+ if (!fs.existsSync(installedPath) || sha256File(installedPath) !== file.sha256) {
114
+ throw new Error(`已安装 Skill 包含未知修改,已停止覆盖:${file.path}`)
115
+ }
116
+ }
117
+ return metadata
118
+ }
119
+
120
+ function installStagedSkill(resolved, bundle, now) {
121
+ const { target } = resolved
122
+ const { manifest, resourceDir } = bundle
123
+ const parent = path.dirname(target)
124
+ const suffix = `${process.pid}.${now.getTime()}`
125
+ const staging = path.join(parent, `.${OFFICIAL_SKILL_NAME}.${suffix}.tmp`)
126
+ const backup = path.join(parent, `.${OFFICIAL_SKILL_NAME}.${suffix}.bak`)
127
+ fs.mkdirSync(parent, { recursive: true })
128
+
129
+ try {
130
+ fs.mkdirSync(staging, { mode: 0o700 })
131
+ for (const file of manifest.files) {
132
+ const sourcePath = safeResourcePath(resourceDir, file.path)
133
+ const destinationPath = safeResourcePath(staging, file.path)
134
+ fs.mkdirSync(path.dirname(destinationPath), { recursive: true })
135
+ fs.copyFileSync(sourcePath, destinationPath)
136
+ if (sha256File(destinationPath) !== file.sha256) {
137
+ throw new Error(`临时 Skill 内容校验失败:${file.path}`)
138
+ }
139
+ }
140
+
141
+ const metadata = {
142
+ schemaVersion: 1,
143
+ managedBy: SKILL_MANAGER,
144
+ name: manifest.name,
145
+ version: manifest.version,
146
+ compatibleCliVersion: manifest.compatibleCliVersion,
147
+ source: manifest.source,
148
+ digestAlgorithm: manifest.digestAlgorithm,
149
+ files: manifest.files,
150
+ host: resolved.host,
151
+ scope: resolved.scope,
152
+ syncedAt: now.toISOString()
153
+ }
154
+ fs.writeFileSync(path.join(staging, SKILL_METADATA_FILE), `${JSON.stringify(metadata, null, 2)}\n`, {
155
+ encoding: 'utf8',
156
+ mode: 0o600
157
+ })
158
+
159
+ if (!fs.existsSync(target)) {
160
+ fs.renameSync(staging, target)
161
+ return metadata
162
+ }
163
+
164
+ fs.renameSync(target, backup)
165
+ try {
166
+ fs.renameSync(staging, target)
167
+ } catch (error) {
168
+ fs.renameSync(backup, target)
169
+ throw error
170
+ }
171
+ fs.rmSync(backup, { recursive: true, force: true })
172
+ return metadata
173
+ } catch (error) {
174
+ fs.rmSync(staging, { recursive: true, force: true })
175
+ throw new Error(`Skill 原子更新失败,原目录保持不变:${error.message}`)
176
+ }
177
+ }
178
+
179
+ export function syncOfficialSkill(
180
+ resolved,
181
+ {
182
+ loadBundle = loadOfficialSkill,
183
+ installBundle = installStagedSkill,
184
+ now = () => new Date()
185
+ } = {}
186
+ ) {
187
+ const bundle = loadBundle()
188
+ const { manifest } = bundle
189
+ let installed
190
+ if (fs.existsSync(resolved.target)) {
191
+ installed = readSkillMetadata(resolved.target)
192
+ const comparison = compareVersions(installed.version, manifest.version)
193
+ if (comparison > 0) {
194
+ return { action: 'newer', metadata: installed, target: resolved.target }
195
+ }
196
+ if (comparison === 0) {
197
+ const matches = manifest.files.every((file) =>
198
+ fs.existsSync(safeResourcePath(resolved.target, file.path)) &&
199
+ sha256File(safeResourcePath(resolved.target, file.path)) === file.sha256
200
+ )
201
+ if (matches) return { action: 'current', metadata: installed, target: resolved.target }
202
+ }
203
+ }
204
+
205
+ const metadata = installBundle(resolved, bundle, now())
206
+ return { action: installed ? 'updated' : 'installed', metadata, target: resolved.target }
207
+ }
208
+
209
+ export function resolveSkillTarget(
210
+ { global = false, host },
211
+ {
212
+ cwd = process.cwd(),
213
+ home = os.homedir(),
214
+ env = process.env,
215
+ exists = fs.existsSync,
216
+ pathApi = path
217
+ } = {}
218
+ ) {
219
+ const requestedHost = host || env.YIZHUAN_SKILL_HOST
220
+ let selectedHost = requestedHost ? assertHost(String(requestedHost).toLowerCase()) : undefined
221
+
222
+ if (!selectedHost) {
223
+ const environmentHosts = Object.entries(HOSTS).filter(
224
+ ([, config]) => config.configEnv && env[config.configEnv]
225
+ )
226
+ if (environmentHosts.length === 1) {
227
+ selectedHost = environmentHosts[0][0]
228
+ } else if (environmentHosts.length > 1) {
229
+ throw new Error('检测到多个宿主环境,请使用 --host codex|agents|claude|cursor 明确指定。')
230
+ } else {
231
+ const root = global ? home : cwd
232
+ const directoryHosts = Object.entries(HOSTS).filter(([, config]) =>
233
+ exists(pathApi.join(root, config.directory))
234
+ )
235
+ if (!global && directoryHosts.length > 0) {
236
+ selectedHost = directoryHosts[0][0]
237
+ } else if (directoryHosts.length === 1) {
238
+ selectedHost = directoryHosts[0][0]
239
+ } else {
240
+ const reason = directoryHosts.length ? '检测到多个全局宿主' : '未检测到受支持宿主'
241
+ throw new Error(`${reason},请使用 --host codex|agents|claude|cursor 明确指定。`)
242
+ }
243
+ }
244
+ }
245
+
246
+ const config = HOSTS[selectedHost]
247
+ const configuredRoot = global && config.configEnv ? env[config.configEnv] : undefined
248
+ const hostRoot = configuredRoot || pathApi.join(global ? home : cwd, config.directory)
249
+ return {
250
+ host: selectedHost,
251
+ scope: global ? 'global' : 'project',
252
+ root: pathApi.join(hostRoot, 'skills'),
253
+ target: pathApi.join(hostRoot, 'skills', OFFICIAL_SKILL_NAME)
254
+ }
255
+ }
256
+
257
+ export function printSkillSyncHelp() {
258
+ console.log(`Usage:
259
+ yizhuan skill sync [--global] [--host <name>]
260
+
261
+ Options:
262
+ --global 安装到当前受支持宿主的用户级 Skill 目录
263
+ --host 明确指定 codex、agents、claude 或 cursor
264
+ --help 显示本帮助`)
265
+ }
266
+
267
+ export async function runSkillSync(args) {
268
+ if (args.help) {
269
+ printSkillSyncHelp()
270
+ return
271
+ }
272
+
273
+ const resolved = resolveSkillTarget({ global: Boolean(args.global), host: args.host })
274
+ const result = syncOfficialSkill(resolved)
275
+ const actionText = {
276
+ installed: '已安装',
277
+ updated: '已更新',
278
+ current: '无需更新',
279
+ newer: '已安装版本较新,未降级'
280
+ }[result.action]
281
+ console.log(`${actionText}正式 Skill ${result.metadata.version}:${result.target}`)
282
+ }
@@ -1,54 +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
- })
@@ -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
- }