redskillhub-upload 1.0.2 → 1.0.3-beta.1

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,6 +4,8 @@
4
4
 
5
5
  CLI 负责完整发布链路:登录授权、本地校验、重新打包、上传、参数确认和最终提交。输入可以是 Skill 目录,也可以是 `.zip` 文件。
6
6
 
7
+ 当前“对话内发布确认与修改”能力仅面向 Beta 验证。本文发布示例都显式使用 `--env beta`;不要将本次改动用于 Prod 发布。
8
+
7
9
  ## 环境要求
8
10
 
9
11
  - Node.js 18 或更高版本
@@ -14,14 +16,26 @@ CLI 负责完整发布链路:登录授权、本地校验、重新打包、上
14
16
 
15
17
  ## 安装
16
18
 
19
+ Beta 测试安装:
20
+
17
21
  ```bash
18
- npm install -g redskillhub-upload
22
+ npm install -g redskillhub-upload@beta
19
23
  ```
20
24
 
25
+ `@beta` 与生产使用的 `@latest` 是两个独立 npm dist-tag。Beta 验收期间不得把测试版本发布到 `latest`;当前包的 `publishConfig.tag` 已固定为 `beta`,避免裸 `npm publish` 误更新生产安装通道。
26
+
27
+ Beta Apollo 使用固定版本门禁策略:
28
+
29
+ ```bash
30
+ node "<SKILL_DIR>/scripts/ensure-cli.mjs" --env beta --npm-policy beta-first
31
+ ```
32
+
33
+ 它会在目标版本更高时优先安装 `@beta`,正式 `@latest` 追平后自动切回正式版。Prod 不使用该策略,始终安装 `@latest`。
34
+
21
35
  确认安装成功:
22
36
 
23
37
  ```bash
24
- redskillhub-upload whoami
38
+ redskillhub-upload whoami --env beta
25
39
  ```
26
40
 
27
41
  ## 发布 Skill
@@ -29,19 +43,19 @@ redskillhub-upload whoami
29
43
  发布本地目录:
30
44
 
31
45
  ```bash
32
- redskillhub-upload publish "/absolute/path/to/skill"
46
+ redskillhub-upload publish "/absolute/path/to/skill" --env beta
33
47
  ```
34
48
 
35
49
  发布 ZIP:
36
50
 
37
51
  ```bash
38
- redskillhub-upload publish "/absolute/path/to/skill.zip"
52
+ redskillhub-upload publish "/absolute/path/to/skill.zip" --env beta
39
53
  ```
40
54
 
41
55
  Agent 模式:
42
56
 
43
57
  ```bash
44
- redskillhub-upload publish "/absolute/path/to/skill" --agent
58
+ redskillhub-upload publish "/absolute/path/to/skill" --agent --env beta
45
59
  ```
46
60
 
47
61
  `publish` 会自动检查登录状态,无需预先执行 `login` 或 `whoami`。
@@ -59,31 +73,31 @@ CLI 会复用有效的 access token,并在 refresh token 可用时静默续期
59
73
  手动登录:
60
74
 
61
75
  ```bash
62
- redskillhub-upload login
76
+ redskillhub-upload login --env beta
63
77
  ```
64
78
 
65
79
  Agent 模式登录:
66
80
 
67
81
  ```bash
68
- redskillhub-upload login --agent
82
+ redskillhub-upload login --agent --env beta
69
83
  ```
70
84
 
71
85
  取消正在等待的登录:
72
86
 
73
87
  ```bash
74
- redskillhub-upload login --cancel
88
+ redskillhub-upload login --cancel --env beta
75
89
  ```
76
90
 
77
91
  查看登录状态:
78
92
 
79
93
  ```bash
80
- redskillhub-upload whoami
94
+ redskillhub-upload whoami --env beta
81
95
  ```
82
96
 
83
97
  退出登录:
84
98
 
85
99
  ```bash
86
- redskillhub-upload logout
100
+ redskillhub-upload logout --env beta
87
101
  ```
88
102
 
89
103
  ## 发布参数
@@ -91,41 +105,47 @@ redskillhub-upload logout
91
105
  查看可用标签:
92
106
 
93
107
  ```bash
94
- redskillhub-upload tags
108
+ redskillhub-upload tags --env beta
95
109
  ```
96
110
 
97
111
  指定原创内容和标签:
98
112
 
99
113
  ```bash
100
- redskillhub-upload publish "/absolute/path/to/skill" --source original --tag "效率工具,内容创作"
114
+ redskillhub-upload publish "/absolute/path/to/skill" --source original --tag "效率工具,内容创作" --env beta
101
115
  ```
102
116
 
103
117
  转载内容需要提供来源:
104
118
 
105
119
  ```bash
106
- redskillhub-upload publish "/absolute/path/to/skill" --source repost --repost-source "来源名称" --tag "内容创作"
120
+ redskillhub-upload publish "/absolute/path/to/skill" --source repost --repost-source "来源名称" --tag "内容创作" --env beta
107
121
  ```
108
122
 
109
123
  只生成并检查待提交数据,不上传或提交:
110
124
 
111
125
  ```bash
112
- redskillhub-upload publish "/absolute/path/to/skill" --dry-run --agent --source original --tag "效率工具"
126
+ redskillhub-upload publish "/absolute/path/to/skill" --dry-run --agent --source original --tag "效率工具" --env beta
113
127
  ```
114
128
 
115
- 真实发布前,CLI 会展示待提交信息并要求明确确认。未收到 `submit` 时不会执行最终提交。
129
+ 真实发布前,CLI 会展示名称、Skill ID、版本、描述、Skill 介绍、来源、转载来源、场景标签、打包大小和 SHA-256。卡片后会提示作者可直接提出修改;未收到 `submit` 时不会登录、上传或执行最终提交。
130
+
131
+ Agent 展示 Skill 介绍时,会把原始 Markdown 全文放在一个显式标记为 `text` 的代码块中。不能省略 `text`,否则聊天渲染器可能自动识别 Markdown 并着色;也不能使用 `markdown/md/yaml`。这样不会渲染标题和段落,所有正文保持统一单色,以贴近平台编辑器中的最终内容。
132
+
133
+ 确认阶段可输入 `edit`,随后逐行输入 `name`、`description`、`version`、`detail`、`identifier`、`tag`、`source` 或 `repost_source` 的 `key=value`,最后输入空行。多行 `detail` 使用 JSON 字符串。所有修改只保存在当前发布会话的提交信息中,不写回本地文件;每次修改后都会展示新的完整确认卡。
134
+
135
+ 目录输入和 zip 输入都保持不变,不会生成 `-updated.zip`。由于 bundle 内容不变,修改提交信息后打包大小和 SHA-256 也保持不变。
116
136
 
117
137
  ## Windows 示例
118
138
 
119
139
  PowerShell 和 cmd 中均使用双引号包裹路径:
120
140
 
121
141
  ```powershell
122
- redskillhub-upload publish "C:\Users\name\path\to\skill" --agent
142
+ redskillhub-upload publish "C:\Users\name\path\to\skill" --agent --env beta
123
143
  ```
124
144
 
125
145
  向 Agent 模式写入确认指令:
126
146
 
127
147
  ```powershell
128
- node -e "process.stdout.write('submit\n')" | redskillhub-upload publish "C:\Users\name\path\to\skill" --agent --source original --tag "效率工具"
148
+ node -e "process.stdout.write('submit\n')" | redskillhub-upload publish "C:\Users\name\path\to\skill" --agent --source original --tag "效率工具" --env beta
129
149
  ```
130
150
 
131
151
  ## Agent 输出协议
@@ -161,5 +181,5 @@ npm pack --dry-run
161
181
  执行 dry-run:
162
182
 
163
183
  ```bash
164
- node cli/index.mjs publish "/absolute/path/to/skill" --dry-run --agent --source original --tag-id 101,102 --yes
184
+ node cli/index.mjs publish "/absolute/path/to/skill" --dry-run --agent --source original --tag-id 101,102 --env beta
165
185
  ```
package/cli/config.mjs CHANGED
@@ -5,7 +5,7 @@ export const DEFAULT_API_BASE = 'https://edith.xiaohongshu.com';
5
5
  export const BETA_API_BASE = 'https://edith-redskill.sl.beta.xiaohongshu.com';
6
6
  export const DEFAULT_SCOPES = ['base_info', 'skill_publish'];
7
7
  export const DEFAULT_SCOPE = DEFAULT_SCOPES.join(',');
8
-
8
+
9
9
  export const PATHS = {
10
10
  UPLOAD_TOKEN: '/api/sns/v2/red_skill/upload/permit',
11
11
  SUBMIT_SKILL_VERSION: '/api/sns/v1/creator/red_skill/cli_submit_skill_version',
package/cli/index.mjs CHANGED
@@ -6,6 +6,7 @@ import { ExitCodes, SkillhubUploadError, toResultError } from './errors.mjs';
6
6
  import { prepareBundle } from './pack.mjs';
7
7
  import {
8
8
  applyConfirmEdit,
9
+ buildConfirmationCard,
9
10
  buildDraftPayload,
10
11
  formatContentTagOptions,
11
12
  submitSkillVersion
@@ -64,17 +65,6 @@ function buildValidatedDraftPayload(input) {
64
65
  }
65
66
  }
66
67
 
67
- function wrapLocalValidation(error) {
68
- if (error instanceof SkillhubUploadError) {
69
- return error;
70
- }
71
- return new SkillhubUploadError(
72
- 'LOCAL_VALIDATION_FAILED',
73
- error?.message || String(error),
74
- ExitCodes.LOCAL_VALIDATION
75
- );
76
- }
77
-
78
68
  function assertUsableCredentials(credentials, nowMs = Date.now()) {
79
69
  if (!credentials?.accessToken) {
80
70
  throw new SkillhubUploadError('NEED_LOGIN', '请先执行 redskillhub-upload login', ExitCodes.NEED_LOGIN);
@@ -117,18 +107,27 @@ export async function ensurePublishCredentials({
117
107
  return credentials;
118
108
  }
119
109
 
120
- async function confirmBeforeSubmit(payload, io = {}, { tagOptions } = {}) {
121
- let current = payload;
110
+ async function confirmBeforeSubmit(payload, bundle, io = {}, options = {}) {
111
+ const { tagOptions, showConfirmationCard = false } = options;
112
+ let currentPayload = payload;
122
113
  while (true) {
123
- const answer = await confirmAgent(current, io);
114
+ const answer = await confirmAgent(currentPayload, io, {
115
+ card: showConfirmationCard ? buildConfirmationCard(currentPayload, tagOptions) : undefined
116
+ });
124
117
  if (answer.action === 'submit') {
125
- return current;
118
+ return { payload: currentPayload, bundle };
126
119
  }
127
120
  if (answer.action === 'edit') {
128
121
  try {
129
- current = applyConfirmEdit(current, answer.values || {}, { tagOptions });
122
+ currentPayload = applyConfirmEdit(currentPayload, answer.values || {}, { tagOptions });
130
123
  } catch (error) {
131
- throw wrapLocalValidation(error);
124
+ throw error instanceof SkillhubUploadError
125
+ ? error
126
+ : new SkillhubUploadError(
127
+ 'LOCAL_VALIDATION_FAILED',
128
+ error?.message || String(error),
129
+ ExitCodes.LOCAL_VALIDATION
130
+ );
132
131
  }
133
132
  continue;
134
133
  }
@@ -187,8 +186,9 @@ export async function main(argv = process.argv.slice(2), env = process.env, io =
187
186
  ExitCodes.INVALID_ARGS
188
187
  );
189
188
  }
189
+ let apiEnvironment;
190
190
  try {
191
- resolveApiEnvironment(flags);
191
+ apiEnvironment = resolveApiEnvironment(flags);
192
192
  } catch (error) {
193
193
  throw new SkillhubUploadError('INVALID_ARGS', error.message, ExitCodes.INVALID_ARGS);
194
194
  }
@@ -239,7 +239,7 @@ export async function main(argv = process.argv.slice(2), env = process.env, io =
239
239
 
240
240
  const dryRun = Boolean(flags.dryRun);
241
241
  let credentials = null;
242
- if (!dryRun) {
242
+ if (!dryRun && apiEnvironment !== 'beta') {
243
243
  const { readCredentials, login } = await import('./auth.mjs');
244
244
  credentials = await ensurePublishCredentials({
245
245
  flags,
@@ -250,7 +250,6 @@ export async function main(argv = process.argv.slice(2), env = process.env, io =
250
250
  login
251
251
  });
252
252
  }
253
-
254
253
  const bundle = await prepareBundle(pathArg, { env, flags });
255
254
  const bundleMetadata = {
256
255
  bundleSha256: bundle.bundleSha256,
@@ -277,17 +276,40 @@ export async function main(argv = process.argv.slice(2), env = process.env, io =
277
276
 
278
277
  if (dryRun) {
279
278
  const upload = await uploadBundle(bundle, { dryRun: true });
280
- writeResult({ status: 'dry_run', payload: attachUploadResult(draftPayload, upload) }, out);
279
+ const payload = attachUploadResult(draftPayload, upload);
280
+ const result = {
281
+ status: 'dry_run',
282
+ payload
283
+ };
284
+ if (apiEnvironment === 'beta') {
285
+ result.confirmationCard = buildConfirmationCard(payload, tagOptions);
286
+ }
287
+ writeResult(result, out);
281
288
  return;
282
289
  }
283
290
 
284
- const confirmedPayload = await confirmBeforeSubmit(draftPayload, promptSession, { tagOptions });
285
- if (!confirmedPayload) {
291
+ const confirmed = await confirmBeforeSubmit(draftPayload, bundle, promptSession, {
292
+ tagOptions,
293
+ showConfirmationCard: apiEnvironment === 'beta'
294
+ });
295
+ if (!confirmed) {
286
296
  writeResult({ status: 'cancelled' }, out);
287
297
  return;
288
298
  }
289
299
 
290
- const upload = await uploadBundle(bundle, {
300
+ if (!credentials) {
301
+ const { readCredentials, login } = await import('./auth.mjs');
302
+ credentials = await ensurePublishCredentials({
303
+ flags,
304
+ env,
305
+ io: { ...io, out },
306
+ nowMs: io.now ? io.now() : Date.now(),
307
+ readCredentials,
308
+ login
309
+ });
310
+ }
311
+
312
+ const upload = await uploadBundle(confirmed.bundle, {
291
313
  dryRun: false,
292
314
  flags: publishFlags,
293
315
  accessToken: credentials.accessToken,
@@ -297,7 +319,7 @@ export async function main(argv = process.argv.slice(2), env = process.env, io =
297
319
  progressStream: io.progressStream || out,
298
320
  env
299
321
  });
300
- const submitted = await submitSkillVersion(attachUploadResult(confirmedPayload, upload), {
322
+ const submitted = await submitSkillVersion(attachUploadResult(confirmed.payload, upload), {
301
323
  flags: publishFlags,
302
324
  accessToken: credentials.accessToken,
303
325
  fetchImpl: io.fetchImpl,
package/cli/pack.mjs CHANGED
@@ -506,5 +506,5 @@ export async function prepareBundle(inputPath, options = {}) {
506
506
  );
507
507
  }
508
508
 
509
- return await buildBundle(source, env, flags);
509
+ return buildBundle(source, env, flags);
510
510
  }
package/cli/prompt.mjs CHANGED
@@ -10,6 +10,31 @@ async function readLine(iterator) {
10
10
  return next.done ? '' : String(next.value).trim();
11
11
  }
12
12
 
13
+ function parseEditValue(rawValue, line) {
14
+ const value = rawValue.trim();
15
+ if (!value.startsWith('"')) {
16
+ return value;
17
+ }
18
+ try {
19
+ return JSON.parse(value);
20
+ } catch {
21
+ throw new Error(`确认信息修改格式不正确,JSON 字符串无法解析:${line}`);
22
+ }
23
+ }
24
+
25
+ function parseEditLine(line) {
26
+ const idx = line.indexOf('=');
27
+ if (idx < 0) {
28
+ throw new Error(`确认信息修改格式不正确,请使用 key=value 格式:${line}`);
29
+ }
30
+
31
+ const key = line.slice(0, idx).trim();
32
+ if (!key) {
33
+ throw new Error(`确认信息修改格式不正确,请使用 key=value 格式:${line}`);
34
+ }
35
+ return [key, parseEditValue(line.slice(idx + 1), line)];
36
+ }
37
+
13
38
  export function createPromptSession(io = {}) {
14
39
  const input = io.input || process.stdin;
15
40
  const out = io.out || process.stdout;
@@ -40,10 +65,10 @@ export async function askAgent(prompt, io = {}) {
40
65
  }
41
66
  }
42
67
 
43
- export async function confirmAgent(payload, io = {}) {
68
+ export async function confirmAgent(payload, io = {}, options = {}) {
44
69
  const input = io.input || process.stdin;
45
70
  const out = io.out || process.stdout;
46
- writePrompt({ type: 'confirm', payload }, out);
71
+ writePrompt({ type: 'confirm', payload, card: options.card }, out);
47
72
 
48
73
  if (io.readLine) {
49
74
  const first = await io.readLine();
@@ -61,16 +86,8 @@ export async function confirmAgent(payload, io = {}) {
61
86
  break;
62
87
  }
63
88
 
64
- const idx = trimmed.indexOf('=');
65
- if (idx < 0) {
66
- throw new Error(`确认信息修改格式不正确,请使用 key=value 格式:${trimmed}`);
67
- }
68
-
69
- const key = trimmed.slice(0, idx).trim();
70
- if (!key) {
71
- throw new Error(`确认信息修改格式不正确,请使用 key=value 格式:${trimmed}`);
72
- }
73
- values[key] = trimmed.slice(idx + 1).trim();
89
+ const [key, value] = parseEditLine(trimmed);
90
+ values[key] = value;
74
91
  }
75
92
  return { action: 'edit', values };
76
93
  }
@@ -95,16 +112,8 @@ export async function confirmAgent(payload, io = {}) {
95
112
  break;
96
113
  }
97
114
 
98
- const idx = trimmed.indexOf('=');
99
- if (idx < 0) {
100
- throw new Error(`确认信息修改格式不正确,请使用 key=value 格式:${trimmed}`);
101
- }
102
-
103
- const key = trimmed.slice(0, idx).trim();
104
- if (!key) {
105
- throw new Error(`确认信息修改格式不正确,请使用 key=value 格式:${trimmed}`);
106
- }
107
- values[key] = trimmed.slice(idx + 1).trim();
115
+ const [key, value] = parseEditLine(trimmed);
116
+ values[key] = value;
108
117
  }
109
118
  return { action: 'edit', values };
110
119
  }
package/cli/submit.mjs CHANGED
@@ -5,7 +5,16 @@ import { compatibleFetch } from './fetch.mjs';
5
5
  import { redactMessage } from './redact.mjs';
6
6
  import { FALLBACK_CONTENT_TAGS } from './tags.mjs';
7
7
 
8
- const EDITABLE_FIELDS = new Set(['name', 'identifier', 'version', 'description', 'detail', 'tag']);
8
+ const EDITABLE_FIELDS = new Set([
9
+ 'name',
10
+ 'identifier',
11
+ 'version',
12
+ 'description',
13
+ 'detail',
14
+ 'tag',
15
+ 'source',
16
+ 'repost_source'
17
+ ]);
9
18
  const VALID_SOURCES = new Set(['original', 'repost']);
10
19
  const SENSITIVE_PAYLOAD_FIELDS = new Set([
11
20
  'access_token',
@@ -47,6 +56,28 @@ export function formatContentTagOptions(tagOptions = FALLBACK_CONTENT_TAGS) {
47
56
  return tagOptions.map((tag) => tag.name).join(' / ');
48
57
  }
49
58
 
59
+ export function buildConfirmationCard(payload, tagOptions = FALLBACK_CONTENT_TAGS) {
60
+ const contentTags = (payload.content_tag_ids || []).map((tagId) => {
61
+ const matched = tagOptions.find((tag) => String(tag.tagId) === String(tagId));
62
+ return {
63
+ tagId: String(tagId),
64
+ name: matched?.name || String(tagId)
65
+ };
66
+ });
67
+ return {
68
+ skillName: payload.name || '',
69
+ skillId: payload.skill_identifier || '',
70
+ version: payload.version || '',
71
+ description: payload.description || '',
72
+ skillIntroduction: payload.skill_md_content || '',
73
+ original: payload.original,
74
+ repostSource: payload.repost_source || '',
75
+ contentTags,
76
+ bundleSizeBytes: payload.bundle_size_bytes,
77
+ bundleSha256: payload.bundle_sha256 || ''
78
+ };
79
+ }
80
+
50
81
  function resolveTagIds(flags, tagOptions = FALLBACK_CONTENT_TAGS) {
51
82
  const rawTagId = flags.tagId ?? flags['tag-id'] ?? flags.tag;
52
83
  const rawTags = splitTagValues(rawTagId);
@@ -137,6 +168,19 @@ export function applyConfirmEdit(payload, values, { tagOptions = FALLBACK_CONTEN
137
168
  }
138
169
  }
139
170
 
171
+ const hasSourceEdit = Object.prototype.hasOwnProperty.call(values, 'source');
172
+ const hasRepostSourceEdit = Object.prototype.hasOwnProperty.call(values, 'repost_source');
173
+ let sourceFields = {};
174
+ if (hasSourceEdit || hasRepostSourceEdit) {
175
+ const source = hasSourceEdit
176
+ ? values.source
177
+ : payload.original === false ? 'repost' : 'original';
178
+ sourceFields = resolveSourceFields({
179
+ source,
180
+ repostSource: hasRepostSourceEdit ? values.repost_source : payload.repost_source
181
+ });
182
+ }
183
+
140
184
  return {
141
185
  ...payload,
142
186
  skill_identifier: values.identifier || payload.skill_identifier,
@@ -144,7 +188,8 @@ export function applyConfirmEdit(payload, values, { tagOptions = FALLBACK_CONTEN
144
188
  name: values.name || payload.name,
145
189
  description: values.description || payload.description,
146
190
  skill_md_content: values.detail || payload.skill_md_content,
147
- content_tag_ids: values.tag ? resolveTagIds({ tag: values.tag }, tagOptions) : payload.content_tag_ids
191
+ content_tag_ids: values.tag ? resolveTagIds({ tag: values.tag }, tagOptions) : payload.content_tag_ids,
192
+ ...sourceFields
148
193
  };
149
194
  }
150
195
 
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "redskillhub-upload",
3
- "version": "1.0.2",
3
+ "version": "1.0.3-beta.1",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "access": "public",
7
- "registry": "https://registry.npmjs.org/"
7
+ "registry": "https://registry.npmjs.org/",
8
+ "tag": "beta"
8
9
  },
9
10
  "bin": {
10
11
  "redskillhub-upload": "cli/index.mjs"
@@ -16,7 +17,7 @@
16
17
  ],
17
18
  "scripts": {
18
19
  "test": "node --test test/*.test.mjs",
19
- "dry-run": "node cli/index.mjs publish test/fixtures/minimal-skill --dry-run --agent --yes"
20
+ "dry-run": "node cli/index.mjs publish test/fixtures/minimal-skill --dry-run --agent --source original --tag-id 1001 --env beta"
20
21
  },
21
22
  "dependencies": {
22
23
  "adm-zip": "^0.5.16",
package/skill/SKILL.md CHANGED
@@ -1,149 +1,169 @@
1
1
  ---
2
2
  name: redskillhub-upload
3
- description: 当用户要求安装或更新 redskillhub-upload、按照 upload.md 将本地 Skill 上传到小红书 SkillHub,或要求检查 CLI 版本、登录授权、取消登录时使用。固定使用 1.0 版本线中补丁号 z 最新的 CLI,完成安装更新、二维码授权、打包、上传、确认和提交。
3
+ description: 当用户要求在 Beta 环境按照 upload.md 将本地 Skill 上传到小红书 SkillHub,或要求检查 CLI 版本、登录授权、取消登录时使用。发布前必须在对话内展示完整确认卡;修改只更新本次提交信息,不改本地文件;只有用户明确确认后才提交。
4
4
  ---
5
5
 
6
- # redskillhub-upload
6
+ # redskillhub-upload(Beta)
7
7
 
8
- ## 运行环境
8
+ ## 环境硬门禁
9
9
 
10
- 所有 CLI 命令都必须显式带上同一个环境参数:生产环境用 `--env prod`(也是 CLI 默认值),测试环境用 `--env beta`。用户未指定时选择 `prod`;用户明确说 beta、测试环境或联调环境时选择 `beta`。确定后记为 `<ENV>`,本次流程中的标签、dry-run、真实 publish、login、whoami、logout 和取消等待命令均追加 `--env <ENV>`,不得混用。beta 业务接口由 CLI 自动选择 `https://edith.beta.xiaohongshu.com`,Agent 不自行传 `--api-base`。
10
+ 本需求当前只开放 **Beta 环境**。本流程的每一条 CLI 命令都必须显式追加 `--env beta`,禁止省略,禁止使用 `--env prod`,也不要通过 `--api-base` 绕过环境选择。
11
11
 
12
- CLI 按环境隔离本地状态:prod 保留在 `~/.skillhub-upload`,beta 存放在其 `beta` 子目录。凭证、待授权状态、二维码、标签缓存和临时包不会跨环境复用。设置 `SKILLHUB_UPLOAD_HOME` 时,该路径作为环境目录的共同根目录。
12
+ Beta 凭证、待授权状态、二维码、标签缓存和临时包位于 `~/.skillhub-upload/beta`,不得与 Prod 状态混用。
13
13
 
14
14
  ## 操作系统兼容
15
15
 
16
- Skill 必须同时支持 macOS 和 Windows。执行前通过 Node.js 判断系统,不依赖 `uname`:
16
+ 执行前通过 Node.js 判断系统,不依赖 `uname`:
17
17
 
18
18
  ```bash
19
19
  node -p "process.platform"
20
20
  ```
21
21
 
22
- - 输出 `darwin`:按 macOS 处理,路径形如 `/Users/name/path/to/skill`。
23
- - 输出 `win32`:按 Windows 处理,优先使用 PowerShell,路径形如 `C:\Users\name\path\to\skill`。
24
- - 所有传给 CLI 的本地路径都使用双引号包裹,兼容空格和中文。
25
- - 命令必须写成单行,不使用 Bash `\` 续行、`printf`、`export`,也不使用 PowerShell 反引号续行或 `$env:`。
26
- - 需要向 stdin 写入确认值时,统一使用 `node -e "process.stdout.write('submit\n')"` 或 `node -e "process.stdout.write('cancel\n')"`,不要按系统拼两套管道命令。
22
+ - `darwin` `win32` 都受支持。
23
+ - 所有本地路径使用双引号包裹,兼容空格和中文。
24
+ - 命令写成单行,不使用 shell 专属续行语法。
25
+ - 不由 Agent 解压、过滤或重新打包 zip;这些操作全部交给 CLI。
27
26
 
28
27
  ## CLI 版本门禁
29
28
 
30
- 在执行任何 `redskillhub-upload` 命令前,先从本 Skill 目录执行:
29
+ 执行任何发布相关命令前,从本 Skill 目录运行:
31
30
 
32
31
  ```bash
33
- node "<SKILL_DIR>/scripts/ensure-cli.mjs" --env <ENV>
32
+ node "<SKILL_DIR>/scripts/ensure-cli.mjs" --env beta
34
33
  ```
35
34
 
36
- `<SKILL_DIR>` 是包含本文件的目录,执行前替换为其绝对路径。
35
+ `--env beta` 只决定 Beta 接口和凭证,不决定 npm 包版本。npm 默认安装 `latest` tag 指向的 `1.0.z` 正式版本。可信的外层 Beta 测试口令应把上述命令替换为固定的自动择优策略:
37
36
 
38
- 脚本固定 `x=1`、`y=0`,查询 npm 后选择最大的兼容版本:存在正式版时只选最大的 `1.0.z`;尚无正式版时才选择同一版本线的最新预发布版。脚本会检查全局安装状态,按需安装或升级,并用 `redskillhub-upload whoami --env <ENV>` 验证命令。
39
-
40
- - 读取 `CLI_VERSION_JSON`;`status=current|installed|updated` 才继续。
41
- - `status=error` 时如实告诉用户并停止,不得改用其他 major/minor、不得静默安装本地源码、不得使用 `sudo`。
42
- - 不要在 Agent 中重写版本比较逻辑;以 `scripts/ensure-cli.mjs` 为唯一实现。
37
+ ```bash
38
+ node "<SKILL_DIR>/scripts/ensure-cli.mjs" --env beta --npm-policy beta-first
39
+ ```
43
40
 
44
- ## 目标流程
41
+ 该策略同时读取 `latest` 和 `beta`:仅当 `beta` 对应的目标正式版本高于当前 `latest` 时安装预发布版;正式版追平或超过预发布版后自动回到 `latest`;没有 `beta` tag 时也使用 `latest`。`--npm-policy beta-first` 只允许搭配 `--env beta`,Prod 禁止使用。不得根据 `--env beta` 自行推断使用预发布包。读取 `CLI_VERSION_JSON`;只有 `status=current|installed|updated` 才继续。失败时如实告知用户并停止,不改用其他版本线、不使用本地源码替代、不使用 `sudo`。
45
42
 
46
- 1. 回复用户:收到。我会在电脑上读取 upload.md 说明,检查 Red Skill CLI 和本地 Skill。
47
- 2. 执行「CLI 版本门禁」,确保 PATH 中的 `redskillhub-upload` 是允许版本线内的最新包。
48
- 3. 查找用户给出的本地 Skill 目录或 `.zip` 源包。用户只给名称时,先在当前工作区和常见目录查找唯一匹配目录;找不到或多匹配时询问用户绝对路径。zip 只能作为 CLI 输入源包,不能由 agent 解压或代替 CLI 重新打包。
49
- 4. 直接执行下面的「publish 子流程」,不要预先调用 `whoami` 或 `login`。真实 `publish` 会自动复用或刷新已有凭证;首次登录或 refresh token 失效时,会在同一进程中输出授权 `PROMPT`,授权成功后原地继续发布。
50
- 5. 遇到授权 `PROMPT` 时,必须按以下顺序展示:先提示用户使用小红书 App 扫码,再把 `prompt.qrCodePath` 的本地 PNG 展示或发送给用户,最后才提供手机浏览器兜底链接。文字链接使用 `prompt.authorizeH5Url`(兼容字段 `prompt.authorizeUrl` 也是同一 H5 URL)。二维码由 CLI 使用接口的 `authorize_common_url` 生成,Agent 不得自行换字段或重新编码。授权内容必须照抄模板,只替换 `<QR_CODE_IMAGE>`、`<AUTH_URL>`、`<USER_CODE>`、`<MINUTES>`,不增删措辞;不得在二维码之前引导用户打开链接。
43
+ ## 发布流程
51
44
 
52
- ````markdown
53
- 请使用小红书 App 扫描下面的二维码完成授权:
45
+ ### 1. 定位输入并收集会话参数
54
46
 
55
- <QR_CODE_IMAGE>
47
+ 输入可以是本地 Skill 目录或 `.zip` 文件。用户只给名称时,在当前工作区和常见目录定位唯一匹配;找不到或多匹配时询问绝对路径。
56
48
 
57
- 如果无法扫码,请使用手机自带浏览器打开下面的授权链接,打开后会自动跳转到小红书 App 完成授权:
49
+ 先查询 Beta 标签:
58
50
 
59
- ```
60
- <AUTH_URL>
51
+ ```bash
52
+ redskillhub-upload tags --env beta
61
53
  ```
62
54
 
63
- 授权码:<USER_CODE>
64
- 有效期:<MINUTES> 分钟
55
+ 从 `RESULT_JSON.tags` 取得中文标签后,必须沿用线上现有交互,一次性按下面的固定结构询问。只替换 `<标签列表>` 和 `<absolute-path>`,不得压缩、省略或改写转载来源提示:
65
56
 
66
- 我会继续等待 CLI 自动完成授权。如果你已经在手机上完成授权但我没有自动响应,你再回复「好了」。
67
- ````
57
+ ```text
58
+ CLI 版本门禁已通过,实时标签已获取。请一次回复:
68
59
 
69
- 6. 正常情况下等待 CLI 轮询完成并在同一 `publish` 进程中自动继续。用户回复”好了”只是兜底唤醒:仅当 CLI 轮询已完成但 agent 没有恢复时使用;进程中断或状态丢失时,重新执行原 `publish` 命令,CLI 会自动从磁盘恢复未完成的授权状态,无需用户重新打开授权链接。
70
- 用户明确要求取消登录等待时,执行 `redskillhub-upload login --cancel --env <ENV>`;不要仅终止当前 shell,因为取消命令还会清理待授权状态和二维码。
71
- 7. 所有发布字段值取自用户当前会话答复,不要从 SKILL.md / 目录名 / 文件结构 / 上下文对话推断。
72
- 8. publish 子流程结束后,把 `RESULT_JSON` 的成功结果、失败原因或取消状态如实转述给用户。
60
+ 1. source:原创 转载
61
+ 2. tag:可多选,用逗号分隔
73
62
 
74
- ## publish 子流程
63
+ 可选标签:<标签列表>。
75
64
 
76
- 固定 4 步,按顺序执行;步骤 1 用 AskUserQuestion 一次性收齐参数,步骤 2 把参数全部以 CLI flag 传入,步骤 3 仅通过跨平台 Node 管道写入提交或取消指令。
65
+ 例如:原创;编程开发、学习成长
77
66
 
78
- ### Step 1 — 一次性收齐 publish 参数
67
+ 若选择转载,还请附上不超过 15 个字符的转载来源。待上传目录为 <absolute-path>。
68
+ ```
69
+
70
+ 用户选择转载但没有提供来源时,再单独追问 `repost_source`;选择原创时不追问。
79
71
 
80
- (a) 通过已安装的 CLI 拉一次当前环境标签列表,命令:
72
+ ### 2. 启动发布会话并展示确认卡
73
+
74
+ 直接启动真实 publish,但保持进程等待输入;不要预先执行 login,也不要提前向 stdin 写入 submit:
81
75
 
82
76
  ```bash
83
- redskillhub-upload tags --env <ENV>
77
+ redskillhub-upload publish "<absolute-path>" --agent --source <original-or-repost> --tag "<中文标签名[,中文标签名...]>" [--repost-source "<来源名>"] --env beta
84
78
  ```
85
79
 
86
- 读取 `RESULT_JSON.tags` 中的 `name` 和 `tagId`。命令失败时如实报告并停止,不直接 curl 内部接口、不导入仓库源码,也**不硬编码标签清单**。
80
+ CLI 会先在本地解析、执行安全校验并打包,然后输出 `PROMPT.type=confirm`。此时尚未上传,也未调用 submit。
87
81
 
88
- (b) AskUserQuestion 一次发两题:
82
+ 必须把 `PROMPT.card` 完整回显为确认卡,不得省略字段:
89
83
 
90
- - 「source:原创 / 转载」二选一
91
- - 「tag:&lt;上一步拉到的中文名数组&gt;」多选,用户可选一个或多个
84
+ - Skill 名称:`skillName`
85
+ - Skill ID:`skillId`
86
+ - 版本:`version`
87
+ - 简介:`description`
88
+ - Skill 介绍:`skillIntroduction`
89
+ - 来源:`original=true` 显示“原创”,`original=false` 显示“转载”
90
+ - 转载来源:`repostSource`,原创时显示“无”
91
+ - 场景标签:`contentTags[].name`
92
+ - 打包大小:`bundleSizeBytes` bytes
93
+ - SHA-256:`bundleSha256`
92
94
 
93
- (c) 用户 source 选 `转载` 才追问 `repost_source`(自由文本,最多 15 字符);选 `原创` 直接跳过。
95
+ Skill 介绍的展示方式必须与平台编辑器尽量一致,首次确认和每轮修改后的确认卡都遵守以下规则:
94
96
 
95
- ### Step 2 dry-run 出待提交载荷给用户审阅
97
+ - 在“Skill 介绍:”后,把 `skillIntroduction` 全文原样放进一个显式标记为 `text` 的围栏代码块;不得省略 `text` 标记,避免聊天渲染器自动猜测为 Markdown 并着色。
98
+ - 外层围栏的反引号数量必须多于正文中最长的连续反引号;正文含三个反引号时,外层至少使用四个,并在起始围栏后紧跟 `text`,确保内部 Markdown 围栏仍在同一个展示块内。
99
+ - 不得把 Skill 介绍渲染成聊天标题、段落、列表或多个代码块。
100
+ - 不得使用 `markdown`、`md`、`yaml` 等会触发语法高亮的标记,只能使用 `text`。
101
+ - 所有正文字符使用代码块统一的字号、字重和颜色;`#`、`##`、`**` 等 Markdown 标记只能作为普通原始字符显示,不能产生标题字号或粗体差异。
102
+ - 不改写、不美化、不截断正文;用户看到的原始 Markdown 必须与当前待提交的 `skill_md_content` 一致。
96
103
 
97
- ```bash
98
- redskillhub-upload publish "<absolute-path>" --dry-run --agent --source <original-or-repost> --tag "<中文标签名[,中文标签名...]>" [--repost-source "<来源名>"] --env <ENV>
99
- ```
104
+ 确认卡后必须直接提示:作者如需修改,可告诉 Agent 要修改的字段和新内容,修改后会重新展示完整确认卡;如无需修改,明确回复“确认”“提交”或 `submit`;如需终止,回复“取消”。不要等用户追问“可以修改吗”才说明。
100
105
 
101
- `RESULT_JSON.payload`,把关键字段摘出来给用户看:`name`、Skill ID(payload 字段 `skill_identifier`)、`version`、`description`、`original`、`repost_source`、`content_tag_ids`。标签字段展示**中文名列表**,不要只甩 `tagId`。
106
+ 如果进程或对话会话失效,重新运行原 publish 命令、重新解析并回显完整确认卡;不得沿用一张失效卡直接提交。
102
107
 
103
- **Skill ID 派生失败的兜底**:如果 dry-run 抛 `Skill ID 为空,无法从名称...或目录名...自动生成,请输入 Skill ID` 错误(名称和目录名都派生不出合法 kebab-case),按以下顺序处理:
108
+ ### 3. 在同一会话中修改
104
109
 
105
- 1. 基于 skill `name` / `description` 语义推一个 kebab-case Skill ID(如「微信读书」→ `weread`,「飞书文档助手」→ `feishu-docs`)
106
- 2. 用 `AskUserQuestion` 给用户「采纳推荐 `<推荐值>` / 自定义其他名字」两个选项
107
- 3. 必须告知用户:**Skill ID 是平台上的 skill 主键,提交后跨版本不可改名**,请慎重
108
- 4. 拿到最终 Skill ID 后追加 `--identifier <值>` 重跑 Step 2
110
+ 用户提出修改时,只向当前 publish 进程写入一次 `edit` 块;不要由 Agent 直接编辑文件:
109
111
 
110
- ### Step 3 — 用户明确说「提交 / 确认 / submit」后真实提交
112
+ ```text
113
+ edit
114
+ name=<新名称>
115
+ description=<新简介>
116
+ version=<新版本号>
117
+ detail=<新的 Skill 介绍;多行内容使用 JSON 字符串>
118
+ identifier=<新的 Skill ID>
119
+ tag=<中文标签名[,中文标签名...]>
120
+ source=<original|repost>
121
+ repost_source=<转载来源>
111
122
 
112
- ```bash
113
- node -e "process.stdout.write('submit\n')" | redskillhub-upload publish "<absolute-path>" --agent --source <original-or-repost> --tag "<中文标签名[,中文标签名...]>" [--repost-source "<来源名>"] --env <ENV>
114
123
  ```
115
124
 
116
- 不要带 `--yes`,让 CLI confirm 阶段;`submit\n` stdin 推给它即可。用户说「取消 / cancel」就把输入命令换成 `node -e "process.stdout.write('cancel\n')"`。用户回复「好了」不能当成提交触发词。
125
+ 只发送用户明确要求修改的键。用户改为转载但未提供转载来源时,先在对话中追问,收齐后把 `source` `repost_source` 放在同一个 edit 块中。多行 Skill 介绍必须编码为 JSON 字符串,例如 `detail="# 标题\n\n正文"`。
117
126
 
118
- 该命令会自动检查并恢复登录:凭证有效时直接发布,access token 过期时尝试静默刷新,必须重新授权时输出设备码 `PROMPT`;授权成功后仍由当前进程继续上传和提交,不要另起 `login` 命令。
127
+ - `name`、`description`、`version`、`detail`、`identifier`、`tag`、`source`、`repost_source`:所有修改字段只更新本次发布会话参数。
128
+ - 不得修改本地 `SKILL.md/skill.md` 或原 zip,也不会生成 `-updated.zip`。
129
+ - 其他键禁止修改。
119
130
 
120
- ### Step 4 转述结果
131
+ 每次 edit 后,CLI 都会基于最新提交信息输出新的 `PROMPT.type=confirm`。每次都要重新展示完整确认卡,不能只展示差异。bundle 没有变化,因此打包大小和 SHA-256 保持不变。
121
132
 
122
- 把最终 `RESULT_JSON` 的 `status`(submitted / cancelled / error)+ 关键回执(`skillId` 或错误码 + 文案)发给用户。
133
+ ### 4. 明确确认或取消
123
134
 
124
- ## CLI 调用约定
135
+ 只有用户明确回复“确认”“提交”或“submit”时,才向仍在等待的同一进程写入:
125
136
 
126
- 本 skill 只负责把聊天请求翻译成 CLI 调用,不复制打包、上传、提交逻辑。
127
- 上传入口接受本地 skill 目录或 `.zip` 源包;zip 必须由 CLI 在本地解包、过滤、校验并重新生成上传包,不能由 agent 直接上传或改包。
137
+ ```text
138
+ submit
139
+ ```
128
140
 
129
- ```bash
130
- redskillhub-upload publish "<absolute-path-to-skill>" --agent --env <ENV>
131
- redskillhub-upload publish "<absolute-path-to-skill.zip>" --agent --env <ENV>
141
+ 任何近义表达、用户回复“好了”或授权完成都不能当作提交确认。
142
+
143
+ 用户回复“取消”或“cancel”时写入:
144
+
145
+ ```text
146
+ cancel
132
147
  ```
133
148
 
134
- `redskillhub-upload whoami --env <ENV>`、`redskillhub-upload login --agent --env <ENV>`、`redskillhub-upload login --cancel --env <ENV>` 仅用于诊断、显式预登录或取消等待,不属于标准发布前置步骤。
149
+ 取消后应得到 `RESULT_JSON.status=cancelled`;此路径不得上传、不得调用 submit、不得创建 Version 或审核单。
150
+
151
+ ### 5. 授权与结果
135
152
 
136
- 本地验证可使用 dry-run。标签可直接传一个或多个中文名(CLI 内部完成 name→id 映射,多个用逗号分隔),也允许调试时传数字 id:
153
+ CLI 只会在明确 submit 之后检查或恢复登录。需要授权时,按 `PROMPT.type=auth_device_code` 先展示二维码,再提供手机浏览器兜底链接。二维码路径 `qrCodePath` 必须作为图片发给用户,不能只显示路径。
154
+
155
+ 用户要求取消登录等待时执行:
137
156
 
138
157
  ```bash
139
- node cli/index.mjs publish "test/fixtures/minimal-skill" --dry-run --agent --source original --tag "效率工具,内容创作" --yes --env beta
140
- # 或调试用:--tag-id 1001,1002
158
+ redskillhub-upload login --cancel --env beta
141
159
  ```
142
160
 
161
+ 最终如实转述 `RESULT_JSON` 的 `submitted / cancelled / error` 状态及关键回执。不得访问或修改 Prod,不得执行 npm publish、部署或远端提交。
162
+
143
163
  ## 禁止事项
144
164
 
145
- - 不自行拼授权链接。
146
- - 不要求用户复制 token、cookie 或 authorization code。
147
- - 不展示 access token / refresh token。
148
- - 不代替用户或 CLI 解压、过滤、zip/tar 打包;只把本地 skill 目录或 `.zip` 源包交给 CLI。
149
- - 不使用浏览器自动化或 `/tmp/skillhub-*` 信号文件。
165
+ - 用户明确确认前,禁止上传 bundle、禁止调用 submit。
166
+ - 不自行拼授权链接,不要求用户复制 token、cookie 或 authorization code。
167
+ - 不显示 access token / refresh token。
168
+ - 不修改本地 SKILL.md zip;所有确认阶段修改只写入等待中的 CLI 会话。
169
+ - 不使用浏览器自动化或临时信号文件绕过确认门禁。
@@ -1,4 +1,4 @@
1
1
  interface:
2
2
  display_name: "Red SkillHub 上传"
3
- short_description: "安装最新兼容 CLI 并发布本地 Skill 到 SkillHub"
4
- default_prompt: "使用 $redskillhub-upload 检查并安装 1.0 版本线的最新 CLI,然后将我指定的本地 Skill 发布到小红书 SkillHub。"
3
+ short_description: " Beta 对话中确认、修改并发布本地 Skill"
4
+ default_prompt: "使用 $redskillhub-upload Beta 环境解析我的本地 Skill,展示含 Skill 介绍的完整确认卡并提示我可修改;修改只更新提交信息,只有我明确确认后才提交。"
@@ -8,6 +8,9 @@ const COMMAND_NAME = 'redskillhub-upload';
8
8
  const REGISTRY = 'https://registry.npmjs.org/';
9
9
  const REQUIRED_MAJOR = 1;
10
10
  const REQUIRED_MINOR = 0;
11
+ const BETA_RELEASE_TAG = 'beta';
12
+ const PROD_RELEASE_TAG = 'latest';
13
+ const BETA_FIRST_POLICY = 'beta-first';
11
14
 
12
15
  function parseVersion(version) {
13
16
  const match = String(version).match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/);
@@ -21,49 +24,6 @@ function parseVersion(version) {
21
24
  };
22
25
  }
23
26
 
24
- function comparePrerelease(a, b) {
25
- if (!a && !b) return 0;
26
- if (!a) return 1;
27
- if (!b) return -1;
28
- const left = a.split('.');
29
- const right = b.split('.');
30
- const length = Math.max(left.length, right.length);
31
- for (let index = 0; index < length; index += 1) {
32
- if (left[index] === undefined) return -1;
33
- if (right[index] === undefined) return 1;
34
- const leftNumber = /^\d+$/.test(left[index]) ? Number(left[index]) : null;
35
- const rightNumber = /^\d+$/.test(right[index]) ? Number(right[index]) : null;
36
- if (leftNumber !== null && rightNumber !== null && leftNumber !== rightNumber) {
37
- return leftNumber - rightNumber;
38
- }
39
- if (leftNumber !== null && rightNumber === null) return -1;
40
- if (leftNumber === null && rightNumber !== null) return 1;
41
- const compared = left[index].localeCompare(right[index]);
42
- if (compared) return compared;
43
- }
44
- return 0;
45
- }
46
-
47
- function compareVersions(a, b) {
48
- if (a.patch !== b.patch) return a.patch - b.patch;
49
- return comparePrerelease(a.prerelease, b.prerelease);
50
- }
51
-
52
- export function selectLatestCompatibleVersion(versions, options = {}) {
53
- const major = options.major ?? REQUIRED_MAJOR;
54
- const minor = options.minor ?? REQUIRED_MINOR;
55
- const compatible = versions
56
- .map(parseVersion)
57
- .filter((version) => version && version.major === major && version.minor === minor);
58
- if (!compatible.length) {
59
- throw new Error(`npm 中没有找到 ${major}.${minor} 版本线的 ${PACKAGE_NAME}`);
60
- }
61
- const stable = compatible.filter((version) => !version.prerelease);
62
- const candidates = stable.length ? stable : compatible;
63
- candidates.sort(compareVersions);
64
- return candidates.at(-1).raw;
65
- }
66
-
67
27
  export function shouldUseSystemShell(platform = process.platform) {
68
28
  return platform === 'win32';
69
29
  }
@@ -83,16 +43,18 @@ function run(command, args, options = {}) {
83
43
  return result;
84
44
  }
85
45
 
86
- function queryVersions() {
46
+ function queryTaggedVersion(tag, options = {}) {
87
47
  const result = run('npm', [
88
48
  'view',
89
- PACKAGE_NAME,
90
- 'versions',
49
+ `${PACKAGE_NAME}@${tag}`,
50
+ 'version',
91
51
  '--json',
92
52
  `--registry=${REGISTRY}`
93
- ], { capture: true });
94
- const versions = JSON.parse(result.stdout);
95
- return Array.isArray(versions) ? versions : [versions];
53
+ ], { capture: true, allowFailure: options.optional });
54
+ if (result.status !== 0 && options.optional) return '';
55
+ const version = JSON.parse(result.stdout);
56
+ if (Array.isArray(version)) return version.at(-1) || '';
57
+ return String(version || '');
96
58
  }
97
59
 
98
60
  function readInstalledVersion() {
@@ -127,9 +89,73 @@ export function normalizeCliEnvironment(value = 'prod') {
127
89
  return envName;
128
90
  }
129
91
 
92
+ export function normalizeNpmTag(value = PROD_RELEASE_TAG) {
93
+ const tag = String(value).trim().toLowerCase();
94
+ if (tag !== BETA_RELEASE_TAG && tag !== PROD_RELEASE_TAG) {
95
+ throw new Error(`--npm-tag 仅支持 beta 或 latest,当前值:${value}`);
96
+ }
97
+ return tag;
98
+ }
99
+
100
+ export function resolveCliRelease(npmTag, version) {
101
+ const tag = normalizeNpmTag(npmTag);
102
+ const parsed = parseVersion(version);
103
+ if (!parsed || parsed.major !== REQUIRED_MAJOR || parsed.minor !== REQUIRED_MINOR) {
104
+ throw new Error(`${tag} tag 仅支持 ${REQUIRED_MAJOR}.${REQUIRED_MINOR} 版本线,当前值:${version || '缺失'}`);
105
+ }
106
+ if (tag === BETA_RELEASE_TAG && !parsed.prerelease) {
107
+ throw new Error(`beta tag 必须指向预发布版本,当前值:${version}`);
108
+ }
109
+ if (tag === PROD_RELEASE_TAG && parsed.prerelease) {
110
+ throw new Error(`latest tag 必须指向正式版本,当前值:${version}`);
111
+ }
112
+ return { tag, version: parsed.raw };
113
+ }
114
+
115
+ function compareCoreVersion(left, right) {
116
+ if (left.major !== right.major) return left.major - right.major;
117
+ if (left.minor !== right.minor) return left.minor - right.minor;
118
+ return left.patch - right.patch;
119
+ }
120
+
121
+ export function selectBetaFirstRelease(latestVersion, betaVersion) {
122
+ const latest = resolveCliRelease(PROD_RELEASE_TAG, latestVersion);
123
+ if (!betaVersion) return latest;
124
+
125
+ const beta = resolveCliRelease(BETA_RELEASE_TAG, betaVersion);
126
+ return compareCoreVersion(parseVersion(beta.version), parseVersion(latest.version)) > 0
127
+ ? beta
128
+ : latest;
129
+ }
130
+
131
+ export function normalizeNpmPolicy(value = '') {
132
+ const policy = String(value || '').trim().toLowerCase();
133
+ if (policy && policy !== BETA_FIRST_POLICY) {
134
+ throw new Error(`--npm-policy 仅支持 beta-first,当前值:${value}`);
135
+ }
136
+ return policy;
137
+ }
138
+
130
139
  export function ensureCli(options = {}) {
131
140
  const envName = normalizeCliEnvironment(options.env);
132
- const latestVersion = selectLatestCompatibleVersion(queryVersions());
141
+ const npmPolicy = normalizeNpmPolicy(options.npmPolicy);
142
+ if (npmPolicy && options.npmTag !== undefined) {
143
+ throw new Error('--npm-policy 与 --npm-tag 不能同时使用');
144
+ }
145
+ if (npmPolicy === BETA_FIRST_POLICY && envName !== 'beta') {
146
+ throw new Error('--npm-policy beta-first 仅允许与 --env beta 一起使用');
147
+ }
148
+
149
+ const release = npmPolicy === BETA_FIRST_POLICY
150
+ ? selectBetaFirstRelease(
151
+ queryTaggedVersion(PROD_RELEASE_TAG),
152
+ queryTaggedVersion(BETA_RELEASE_TAG, { optional: true })
153
+ )
154
+ : (() => {
155
+ const releaseTag = normalizeNpmTag(options.npmTag);
156
+ return resolveCliRelease(releaseTag, queryTaggedVersion(releaseTag));
157
+ })();
158
+ const latestVersion = release.version;
133
159
  const previousVersion = readInstalledVersion();
134
160
  let status = 'current';
135
161
  if (previousVersion !== latestVersion) {
@@ -141,7 +167,14 @@ export function ensureCli(options = {}) {
141
167
  throw new Error(`版本校验失败:期望 ${latestVersion},实际 ${installedVersion || '未安装'}`);
142
168
  }
143
169
  run(COMMAND_NAME, ['whoami', '--env', envName]);
144
- return { status, previousVersion: previousVersion || null, version: installedVersion };
170
+ return {
171
+ status,
172
+ previousVersion: previousVersion || null,
173
+ version: installedVersion,
174
+ tag: release.tag,
175
+ policy: npmPolicy || null,
176
+ env: envName
177
+ };
145
178
  }
146
179
 
147
180
  function readEnvironmentArg(argv) {
@@ -150,6 +183,18 @@ function readEnvironmentArg(argv) {
150
183
  return argv[index + 1];
151
184
  }
152
185
 
186
+ function readNpmTagArg(argv) {
187
+ const index = argv.indexOf('--npm-tag');
188
+ if (index === -1) return undefined;
189
+ return argv[index + 1];
190
+ }
191
+
192
+ function readNpmPolicyArg(argv) {
193
+ const index = argv.indexOf('--npm-policy');
194
+ if (index === -1) return undefined;
195
+ return argv[index + 1];
196
+ }
197
+
153
198
  function isMainModule() {
154
199
  if (!process.argv[1]) return false;
155
200
  try {
@@ -162,7 +207,9 @@ function isMainModule() {
162
207
  if (isMainModule()) {
163
208
  try {
164
209
  process.stdout.write(`CLI_VERSION_JSON:${JSON.stringify(ensureCli({
165
- env: readEnvironmentArg(process.argv.slice(2))
210
+ env: readEnvironmentArg(process.argv.slice(2)),
211
+ npmTag: readNpmTagArg(process.argv.slice(2)),
212
+ npmPolicy: readNpmPolicyArg(process.argv.slice(2))
166
213
  }))}\n`);
167
214
  } catch (error) {
168
215
  process.stderr.write(`CLI_VERSION_JSON:${JSON.stringify({