redskillhub-upload 1.0.0-alpha.2 → 1.0.0-alpha.3

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
@@ -25,10 +25,12 @@ All paths and hosts are centralized in `cli/config.mjs` (`PATHS` + `DEFAULT_API_
25
25
  | `OAS_ACCESS_TOKEN` | `/api/sns/v1/oauth2/access_token` | `DEFAULT_OAS_BASE` | 用授权码换取 access_token |
26
26
  | `OAS_REFRESH_TOKEN` | `/api/sns/v1/oauth2/refresh_token` | `DEFAULT_OAS_BASE` | 用 refresh_token 续期 access_token |
27
27
 
28
- - `DEFAULT_API_BASE` = `https://edith-skillhub.sl.beta.xiaohongshu.com` — 业务接口
29
- - `DEFAULT_OAS_BASE` = `https://openaccount.beta.xiaohongshu.com` — OAuth2 开放平台接口
28
+ - `DEFAULT_API_BASE` = `https://edith.xiaohongshu.com` — 生产业务接口
29
+ - `BETA_API_BASE` = `https://edith.beta.xiaohongshu.com` — beta 业务接口
30
+ - `DEFAULT_OAS_BASE` = `https://openaccount.xiaohongshu.com` — OAuth2 开放平台接口
31
+ - 所有命令都支持 `--env prod|beta`,默认 `prod`;`--env beta` 时业务接口使用 `BETA_API_BASE`
30
32
  - 业务接口 host 可通过 `--api-base` 参数或 `SKILLHUB_UPLOAD_API_BASE` 环境变量覆盖
31
- - OAS 换 token 如需 confidential client 凭证,通过 `SKILLHUB_UPLOAD_APP_SECRET` 注入;不要写入仓库或命令行参数
33
+ - OAS 换 token 默认使用内置应用凭证;可通过 `SKILLHUB_UPLOAD_APP_SECRET` 覆盖
32
34
 
33
35
  ## Auth Flow
34
36
 
@@ -40,6 +42,8 @@ All paths and hosts are centralized in `cli/config.mjs` (`PATHS` + `DEFAULT_API_
40
42
 
41
43
  `login` / `whoami` 仍保留用于手动登录和状态诊断。
42
44
 
45
+ 设备码响应中的 `authorize_common_url` 专用于生成二维码,`authorize_h5_url` 专用于返回可点击/复制的授权链接;Agent 模式会同时输出二维码图片路径和 H5 链接。
46
+
43
47
  ## Local Dry Run
44
48
 
45
49
  ```bash
@@ -53,14 +57,23 @@ Dry-run examples may use numeric tag IDs because the backend contract accepts `c
53
57
  ## Commands
54
58
 
55
59
  ```bash
56
- redskillhub-upload login
57
- redskillhub-upload login --agent
58
- redskillhub-upload login --cancel
59
- redskillhub-upload publish /absolute/path/to/skill
60
- redskillhub-upload publish /absolute/path/to/skill --agent
61
- redskillhub-upload publish /absolute/path/to/skill.zip --agent
62
- redskillhub-upload whoami
63
- redskillhub-upload logout
60
+ redskillhub-upload login --env prod
61
+ redskillhub-upload login --agent --env prod
62
+ redskillhub-upload login --cancel --env prod
63
+ redskillhub-upload publish /absolute/path/to/skill --env prod
64
+ redskillhub-upload publish /absolute/path/to/skill --agent --env prod
65
+ redskillhub-upload publish /absolute/path/to/skill.zip --agent --env prod
66
+ redskillhub-upload whoami --env prod
67
+ redskillhub-upload logout --env prod
68
+ ```
69
+
70
+ 所有命令默认使用生产环境。验证 beta 环境时在命令末尾追加 `--env beta`,例如:
71
+
72
+ ```bash
73
+ redskillhub-upload login --agent --env beta
74
+ redskillhub-upload publish /absolute/path/to/skill --agent --env beta
75
+ redskillhub-upload whoami --env beta
76
+ redskillhub-upload logout --env beta
64
77
  ```
65
78
 
66
79
  ## Agent Protocol
@@ -11,22 +11,34 @@ export async function renderDeviceAuthPrompt(payload, options = {}) {
11
11
  stream = process.stdout,
12
12
  qrCode = QRCode
13
13
  } = options;
14
+ const authorizeCommonUrl = payload.authorizeCommonUrl || payload.authorizeUrl || '';
15
+ const authorizeH5Url = payload.authorizeH5Url || payload.authorizeUrl || '';
16
+ if (!authorizeCommonUrl || !authorizeH5Url) {
17
+ throw new Error('授权响应缺少 authorize_common_url 或 authorize_h5_url');
18
+ }
19
+ const promptPayload = {
20
+ ...payload,
21
+ authorizeCommonUrl,
22
+ authorizeH5Url,
23
+ // 保留 authorizeUrl 作为 Agent 协议的 H5 链接兼容字段。
24
+ authorizeUrl: authorizeH5Url
25
+ };
14
26
 
15
27
  if (agent) {
16
28
  const qrCodePath = getAuthQrPath(env);
17
29
  await fs.mkdir(path.dirname(qrCodePath), { recursive: true, mode: 0o700 });
18
- await qrCode.toFile(qrCodePath, payload.authorizeUrl, {
30
+ await qrCode.toFile(qrCodePath, authorizeCommonUrl, {
19
31
  type: 'png',
20
32
  width: 512,
21
33
  margin: 2,
22
34
  errorCorrectionLevel: 'M'
23
35
  });
24
36
  await fs.chmod(qrCodePath, 0o600);
25
- writePrompt({ ...payload, qrCodePath }, stream);
37
+ writePrompt({ ...promptPayload, qrCodePath }, stream);
26
38
  return;
27
39
  }
28
40
 
29
- const qr = await qrCode.toString(payload.authorizeUrl, {
41
+ const qr = await qrCode.toString(authorizeCommonUrl, {
30
42
  type: 'terminal',
31
43
  small: true,
32
44
  errorCorrectionLevel: 'M'
@@ -34,8 +46,7 @@ export async function renderDeviceAuthPrompt(payload, options = {}) {
34
46
  const minutes = Math.max(1, Math.ceil(Number(payload.expiresInSeconds || 0) / 60));
35
47
  stream.write(`${qr}\n`);
36
48
  stream.write(`${payload.message}\n`);
37
- stream.write(`授权链接:${payload.authorizeUrl}\n`);
49
+ stream.write(`授权链接:${authorizeH5Url}\n`);
38
50
  stream.write(`授权码:${payload.userCode}\n`);
39
51
  stream.write(`有效期:${minutes} 分钟\n`);
40
52
  }
41
-
package/cli/auth.mjs CHANGED
@@ -4,6 +4,7 @@ import path from 'node:path';
4
4
  import {
5
5
  DEFAULT_API_BASE,
6
6
  DEFAULT_APP_ID,
7
+ DEFAULT_APP_SECRET,
7
8
  DEFAULT_OAS_BASE,
8
9
  DEFAULT_SCOPE,
9
10
  DEFAULT_SCOPES,
@@ -108,7 +109,10 @@ function unwrapResponseBody(body) {
108
109
  }
109
110
 
110
111
  function resolveAppSecret(flags = {}, env = process.env) {
111
- return flags.appSecret || flags['app-secret'] || env.SKILLHUB_UPLOAD_APP_SECRET || '';
112
+ return flags.appSecret
113
+ || flags['app-secret']
114
+ || env.SKILLHUB_UPLOAD_APP_SECRET
115
+ || DEFAULT_APP_SECRET;
112
116
  }
113
117
 
114
118
  function buildTokenRequestBody(fields = {}) {
@@ -160,10 +164,19 @@ function assertTokenResponseAccepted(body, errorCode, fallbackMessage) {
160
164
 
161
165
  function normalizeDeviceCodePayload(body) {
162
166
  const data = unwrapResponseBody(body);
167
+ const legacyAuthorizeUrl = data.authorizeUrl || data.authorize_url || '';
168
+ const authorizeCommonUrl = data.authorizeCommonUrl
169
+ || data.authorize_common_url
170
+ || legacyAuthorizeUrl;
171
+ const authorizeH5Url = data.authorizeH5Url
172
+ || data.authorize_h5_url
173
+ || legacyAuthorizeUrl;
163
174
  return {
164
175
  deviceCode: data.deviceCode || data.device_code || '',
165
176
  userCode: data.userCode || data.user_code || '',
166
- authorizeUrl: data.authorizeUrl || data.authorize_url || '',
177
+ authorizeCommonUrl,
178
+ authorizeH5Url,
179
+ authorizeUrl: authorizeH5Url,
167
180
  expiresInSeconds: data.expiresInSeconds || data.expires_in_seconds,
168
181
  pollIntervalSeconds: data.pollIntervalSeconds || data.poll_interval_seconds
169
182
  };
@@ -363,7 +376,7 @@ export async function login({
363
376
  }
364
377
  }
365
378
 
366
- const apiBase = resolveApiBase(flags);
379
+ const apiBase = resolveApiBase(flags, env);
367
380
 
368
381
  // 从磁盘恢复未完成的授权状态,避免进程重启后要求用户重新授权
369
382
  let pending = await readPendingAuth(env, now);
@@ -372,7 +385,8 @@ export async function login({
372
385
  codeVerifier = pending.codeVerifier;
373
386
  await renderDeviceAuthPrompt({
374
387
  type: 'auth_device_code',
375
- authorizeUrl: pending.authorizeUrl,
388
+ authorizeCommonUrl: pending.authorizeCommonUrl || pending.authorizeUrl,
389
+ authorizeH5Url: pending.authorizeH5Url || pending.authorizeUrl,
376
390
  userCode: pending.userCode,
377
391
  expiresInSeconds: Math.round((pending.expiresAtMs - now()) / 1000),
378
392
  message: '检测到未完成的授权,继续等待中(无需重新打开授权链接)'
@@ -384,7 +398,9 @@ export async function login({
384
398
  await writePendingAuth(env, {
385
399
  deviceCode: device.deviceCode,
386
400
  codeVerifier: pkce.codeVerifier,
387
- authorizeUrl: device.authorizeUrl,
401
+ authorizeCommonUrl: device.authorizeCommonUrl,
402
+ authorizeH5Url: device.authorizeH5Url,
403
+ authorizeUrl: device.authorizeH5Url,
388
404
  userCode: device.userCode,
389
405
  pollIntervalSeconds: device.pollIntervalSeconds || 5,
390
406
  expiresAtMs: now() + (device.expiresInSeconds || 600) * 1000
@@ -392,7 +408,8 @@ export async function login({
392
408
  pending = await readPendingAuth(env, now);
393
409
  await renderDeviceAuthPrompt({
394
410
  type: 'auth_device_code',
395
- authorizeUrl: device.authorizeUrl,
411
+ authorizeCommonUrl: device.authorizeCommonUrl,
412
+ authorizeH5Url: device.authorizeH5Url,
396
413
  userCode: device.userCode,
397
414
  expiresInSeconds: device.expiresInSeconds || 600,
398
415
  message: '请用手机自带浏览器打开授权链接,页面会自动跳转至小红书 App 授权;授权页输入授权码后 CLI 会自动轮询'
package/cli/config.mjs CHANGED
@@ -2,11 +2,13 @@ import os from 'node:os';
2
2
  import path from 'node:path';
3
3
 
4
4
  export const DEFAULT_API_BASE = 'https://edith.xiaohongshu.com';
5
+ export const BETA_API_BASE = 'https://edith.beta.xiaohongshu.com';
5
6
  export const DEFAULT_OAS_BASE = 'https://openaccount.xiaohongshu.com';
6
7
  export const DEFAULT_APP_ID = 'xhsyfHfmZQAQ24o';
8
+ export const DEFAULT_APP_SECRET = 'skHlFZ8BTk2bVmsSt1aYjv1NxcJjuOwhSev4oraniDa6eyn8L9oqBWvCtsEBnmCmWG';
7
9
  export const DEFAULT_SCOPES = ['base_info', 'skill_publish'];
8
10
  export const DEFAULT_SCOPE = DEFAULT_SCOPES.join(',');
9
-
11
+
10
12
  export const PATHS = {
11
13
  UPLOAD_TOKEN: '/api/sns/v2/red_skill/upload/permit',
12
14
  SUBMIT_SKILL_VERSION: '/api/sns/v1/creator/red_skill/cli_submit_skill_version',
@@ -46,6 +48,16 @@ export function getAuthQrPath(env = process.env) {
46
48
  return path.join(getHomeDir(env), 'auth-qr.png');
47
49
  }
48
50
 
49
- export function resolveApiBase(flags = {}) {
50
- return flags.apiBase || process.env.SKILLHUB_UPLOAD_API_BASE || DEFAULT_API_BASE;
51
+ export function resolveApiEnvironment(flags = {}) {
52
+ const value = String(flags.env || 'prod').trim().toLowerCase();
53
+ if (value !== 'prod' && value !== 'beta') {
54
+ throw new TypeError(`--env 仅支持 prod 或 beta,当前值:${flags.env}`);
55
+ }
56
+ return value;
57
+ }
58
+
59
+ export function resolveApiBase(flags = {}, env = process.env) {
60
+ if (flags.apiBase) return flags.apiBase;
61
+ if (env.SKILLHUB_UPLOAD_API_BASE) return env.SKILLHUB_UPLOAD_API_BASE;
62
+ return resolveApiEnvironment(flags) === 'beta' ? BETA_API_BASE : DEFAULT_API_BASE;
51
63
  }
package/cli/fetch.mjs CHANGED
@@ -1,9 +1,9 @@
1
1
  import http from 'node:http';
2
2
  import https from 'node:https';
3
3
  import tls from 'node:tls';
4
- import { DEFAULT_API_BASE } from './config.mjs';
4
+ import { BETA_API_BASE } from './config.mjs';
5
5
 
6
- const DEFAULT_API_HOST = new URL(DEFAULT_API_BASE).hostname;
6
+ const BETA_API_HOST = new URL(BETA_API_BASE).hostname;
7
7
  const BETA_WILDCARD_SAN = 'DNS:*.beta.xiaohongshu.com';
8
8
 
9
9
  function resolveUrl(input) {
@@ -24,7 +24,7 @@ export function isDefaultBetaApiAltNameError(input, error) {
24
24
  try {
25
25
  const url = resolveUrl(input);
26
26
  return url.protocol === 'https:'
27
- && url.hostname === DEFAULT_API_HOST
27
+ && url.hostname === BETA_API_HOST
28
28
  && resolveErrorCode(error) === 'ERR_TLS_CERT_ALTNAME_INVALID';
29
29
  } catch {
30
30
  return false;
@@ -37,7 +37,7 @@ export function checkDefaultBetaApiServerIdentity(host, cert) {
37
37
  return undefined;
38
38
  }
39
39
  if (
40
- host === DEFAULT_API_HOST
40
+ host === BETA_API_HOST
41
41
  && error.code === 'ERR_TLS_CERT_ALTNAME_INVALID'
42
42
  && String(cert?.subjectaltname || '').includes(BETA_WILDCARD_SAN)
43
43
  ) {
@@ -87,7 +87,7 @@ export function nodeRequestFetch(input, init = {}) {
87
87
  method: init.method || 'GET',
88
88
  headers: normalizeHeaders(init.headers)
89
89
  };
90
- if (url.protocol === 'https:' && url.hostname === DEFAULT_API_HOST) {
90
+ if (url.protocol === 'https:' && url.hostname === BETA_API_HOST) {
91
91
  options.checkServerIdentity = checkDefaultBetaApiServerIdentity;
92
92
  }
93
93
 
package/cli/index.mjs CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  } from './submit.mjs';
13
13
  import { uploadBundle } from './upload.mjs';
14
14
  import { askAgent, confirmAgent, createPromptSession } from './prompt.mjs';
15
- import { TOKEN_REFRESH_BUFFER_MS } from './config.mjs';
15
+ import { TOKEN_REFRESH_BUFFER_MS, resolveApiEnvironment } from './config.mjs';
16
16
  import { loadContentTags } from './tags.mjs';
17
17
 
18
18
  function normalizeFlagName(name) {
@@ -186,6 +186,11 @@ export async function main(argv = process.argv.slice(2), env = process.env, io =
186
186
  ExitCodes.INVALID_ARGS
187
187
  );
188
188
  }
189
+ try {
190
+ resolveApiEnvironment(flags);
191
+ } catch (error) {
192
+ throw new SkillhubUploadError('INVALID_ARGS', error.message, ExitCodes.INVALID_ARGS);
193
+ }
189
194
  if (command === 'whoami') {
190
195
  const { readCredentials, maskCredentials } = await import('./auth.mjs');
191
196
  writeResult({ status: 'ok', command, credentials: maskCredentials(await readCredentials(env)) }, out);
@@ -278,12 +283,14 @@ export async function main(argv = process.argv.slice(2), env = process.env, io =
278
283
  fetchImpl: io.fetchImpl,
279
284
  cosFactory: io.cosFactory,
280
285
  uploadFileImpl: io.uploadFileImpl,
281
- progressStream: io.progressStream || out
286
+ progressStream: io.progressStream || out,
287
+ env
282
288
  });
283
289
  const submitted = await submitSkillVersion(attachUploadResult(confirmedPayload, upload), {
284
290
  flags: publishFlags,
285
291
  accessToken: credentials.accessToken,
286
- fetchImpl: io.fetchImpl
292
+ fetchImpl: io.fetchImpl,
293
+ env
287
294
  });
288
295
  writeResult({ status: 'submitted', response: submitted }, out);
289
296
  return;
package/cli/submit.mjs CHANGED
@@ -200,7 +200,7 @@ function isRejectedBody(body) {
200
200
  }
201
201
 
202
202
  export async function submitSkillVersion(payload, options = {}) {
203
- const apiBase = options.apiBase || resolveApiBase(options.flags || {});
203
+ const apiBase = options.apiBase || resolveApiBase(options.flags || {}, options.env || process.env);
204
204
  const fetchImpl = options.fetchImpl || compatibleFetch;
205
205
  const sensitiveValues = collectSensitiveValues(payload, options.accessToken);
206
206
  let response;
package/cli/tags.mjs CHANGED
@@ -53,9 +53,10 @@ export async function fetchContentTags({
53
53
  fetchImpl = compatibleFetch,
54
54
  apiBase,
55
55
  signal,
56
- flags = {}
56
+ flags = {},
57
+ env = process.env
57
58
  } = {}) {
58
- const base = apiBase || resolveApiBase(flags);
59
+ const base = apiBase || resolveApiBase(flags, env);
59
60
  const url = `${base}${PATHS.QUERY_CONTENT_TAG_CONFIG}?material_id=${CONTENT_TAG_MATERIAL_ID}&module_id=${CONTENT_TAG_MODULE_ID}`;
60
61
  const response = await fetchImpl(url, { method: 'GET', signal });
61
62
  if (!response.ok) {
@@ -130,7 +131,7 @@ export async function loadContentTags({
130
131
 
131
132
  const { signal, cancel } = withTimeout(timeoutMs);
132
133
  try {
133
- const tags = await fetchContentTags({ fetchImpl, flags, signal });
134
+ const tags = await fetchContentTags({ fetchImpl, flags, env, signal });
134
135
  cancel();
135
136
  await writeCachedContentTags(cachePath, tags, nowMs);
136
137
  return tags;
package/cli/upload.mjs CHANGED
@@ -176,7 +176,7 @@ export async function uploadBundle(bundle, options = {}) {
176
176
  };
177
177
  }
178
178
 
179
- const apiBase = options.apiBase || resolveApiBase(options.flags || {});
179
+ const apiBase = options.apiBase || resolveApiBase(options.flags || {}, options.env || process.env);
180
180
  const token = await requestUploadToken({
181
181
  apiBase,
182
182
  accessToken: options.accessToken,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "redskillhub-upload",
3
- "version": "1.0.0-alpha.2",
3
+ "version": "1.0.0-alpha.3",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "access": "public",
package/skill/SKILL.md CHANGED
@@ -1,21 +1,37 @@
1
1
  ---
2
2
  name: redskillhub-upload
3
- description: 当用户要求按照 upload.md 将本地 Skill 上传到小红书 SkillHub 时使用。通过 Red Skill Upload CLI 完成授权、打包、上传和提交确认。
3
+ description: 当用户要求安装或更新 redskillhub-upload、按照 upload.md 将本地 Skill 上传到小红书 SkillHub,或要求检查 CLI 版本、登录授权、取消登录时使用。固定使用 1.0 版本线中补丁号 z 最新的 CLI,完成安装更新、二维码授权、打包、上传、确认和提交。
4
4
  ---
5
5
 
6
6
  # redskillhub-upload
7
7
 
8
- ## 触发
8
+ ## 运行环境
9
9
 
10
- 当用户说“按照 https://redskill.xiaohongshu.net/upload.md <skill-name-or-path> 上传到小红书 SkillHub”或提出等价上传请求时,读取 upload.md 指令,并使用本 skill 驱动 `redskillhub-upload` CLI。
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`。
11
+
12
+ ## CLI 版本门禁
13
+
14
+ 在执行任何 `redskillhub-upload` 命令前,先从本 Skill 目录执行:
15
+
16
+ ```bash
17
+ node <SKILL_DIR>/scripts/ensure-cli.mjs --env <ENV>
18
+ ```
19
+
20
+ `<SKILL_DIR>` 是包含本文件的目录,执行前替换为其绝对路径。
21
+
22
+ 脚本固定 `x=1`、`y=0`,查询 npm 后选择最大的兼容版本:存在正式版时只选最大的 `1.0.z`;尚无正式版时才选择同一版本线的最新预发布版。脚本会检查全局安装状态,按需安装或升级,并用 `redskillhub-upload whoami --env <ENV>` 验证命令。
23
+
24
+ - 读取 `CLI_VERSION_JSON`;`status=current|installed|updated` 才继续。
25
+ - `status=error` 时如实告诉用户并停止,不得改用其他 major/minor、不得静默安装本地源码、不得使用 `sudo`。
26
+ - 不要在 Agent 中重写版本比较逻辑;以 `scripts/ensure-cli.mjs` 为唯一实现。
11
27
 
12
28
  ## 目标流程
13
29
 
14
30
  1. 回复用户:收到。我会在电脑上读取 upload.md 说明,检查 Red Skill CLI 和本地 Skill。
15
- 2. 确认 CLI 可用:优先使用当前仓库的 `tools/redskillhub-upload`,或确认 `redskillhub-upload` 已在 PATH 中。不可用时先说明缺少 CLI。
31
+ 2. 执行「CLI 版本门禁」,确保 PATH 中的 `redskillhub-upload` 是允许版本线内的最新包。
16
32
  3. 查找用户给出的本地 Skill 目录或 `.zip` 源包。用户只给名称时,先在当前工作区和常见目录查找唯一匹配目录;找不到或多匹配时询问用户绝对路径。zip 只能作为 CLI 输入源包,不能由 agent 解压或代替 CLI 重新打包。
17
33
  4. 直接执行下面的「publish 子流程」,不要预先调用 `whoami` 或 `login`。真实 `publish` 会自动复用或刷新已有凭证;首次登录或 refresh token 失效时,会在同一进程中输出授权 `PROMPT`,授权成功后原地继续发布。
18
- 5. 遇到授权 `PROMPT` 且存在 `prompt.qrCodePath` 时,先把本地 PNG 作为二维码图片展示或发送给用户,再把授权链接和 `XXXX-XXXX` 用户授权码转成聊天消息。二维码图片不计入下方文字模板;授权文字必须照抄模板,只替换 `<AUTH_URL>`、`<USER_CODE>`、`<MINUTES>`,不增删措辞。
34
+ 5. 遇到授权 `PROMPT` 时,必须同时返回二维码图片和链接:把 `prompt.qrCodePath` 的本地 PNG 展示或发送给用户;文字链接使用 `prompt.authorizeH5Url`(兼容字段 `prompt.authorizeUrl` 也是同一 H5 URL)。二维码由 CLI 使用接口的 `authorize_common_url` 生成,Agent 不得自行换字段或重新编码。二维码图片不计入下方文字模板;授权文字必须照抄模板,只替换 `<AUTH_URL>`、`<USER_CODE>`、`<MINUTES>`,不增删措辞。
19
35
 
20
36
  ````markdown
21
37
  请用手机自带浏览器打开下面的授权链接,打开后会自动跳转到小红书 App 完成授权:
@@ -31,7 +47,7 @@ description: 当用户要求按照 upload.md 将本地 Skill 上传到小红书
31
47
  ````
32
48
 
33
49
  6. 正常情况下等待 CLI 轮询完成并在同一 `publish` 进程中自动继续。用户回复”好了”只是兜底唤醒:仅当 CLI 轮询已完成但 agent 没有恢复时使用;进程中断或状态丢失时,重新执行原 `publish` 命令,CLI 会自动从磁盘恢复未完成的授权状态,无需用户重新打开授权链接。
34
- 用户明确要求取消登录等待时,执行 `redskillhub-upload login --cancel`;不要仅终止当前 shell,因为取消命令还会清理待授权状态和二维码。
50
+ 用户明确要求取消登录等待时,执行 `redskillhub-upload login --cancel --env <ENV>`;不要仅终止当前 shell,因为取消命令还会清理待授权状态和二维码。
35
51
  7. 所有发布字段值取自用户当前会话答复,不要从 SKILL.md / 目录名 / 文件结构 / 上下文对话推断。
36
52
  8. publish 子流程结束后,把 `RESULT_JSON` 的成功结果、失败原因或取消状态如实转述给用户。
37
53
 
@@ -44,10 +60,10 @@ description: 当用户要求按照 upload.md 将本地 Skill 上传到小红书
44
60
  (a) 先拉一次实时标签列表,命令:
45
61
 
46
62
  ```bash
47
- node -e "import('./cli/tags.mjs').then(({loadContentTags}) => loadContentTags().then(t => console.log(JSON.stringify(t.map(x => x.name)))))"
63
+ node -e "import('./cli/tags.mjs').then(({loadContentTags}) => loadContentTags({flags:{env:'<ENV>'}}).then(t => console.log(JSON.stringify(t.map(x => x.name)))))"
48
64
  ```
49
65
 
50
- (如果跑的是 PATH 上的 `redskillhub-upload` 而非仓库源码,没有 tags.mjs 入口,就直接 curl `https://edith-skillhub.sl.beta.xiaohongshu.com/api/sns/v1/activity_platform/config/query_config?material_id=750&module_id=811` 取 `data[].tagName`。两条路只能挑一条,**不要硬编码标签清单**。)
66
+ (如果跑的是 PATH 上的 `redskillhub-upload` 而非仓库源码,没有 tags.mjs 入口,可使用与 `<ENV>` 对应的业务 host 请求 `/api/sns/v1/activity_platform/config/query_config?material_id=750&module_id=811` 取 `data[].tagName`。两条路只能挑一条,**不要硬编码标签清单**。)
51
67
 
52
68
  (b) 用 AskUserQuestion 一次发两题:
53
69
 
@@ -60,7 +76,7 @@ node -e "import('./cli/tags.mjs').then(({loadContentTags}) => loadContentTags().
60
76
 
61
77
  ```bash
62
78
  redskillhub-upload publish <absolute-path> --dry-run --agent \
63
- --source <original|repost> --tag <中文标签名[,中文标签名...]> [--repost-source <来源名>]
79
+ --source <original|repost> --tag <中文标签名[,中文标签名...]> [--repost-source <来源名>] --env <ENV>
64
80
  ```
65
81
 
66
82
  读 `RESULT_JSON.payload`,把关键字段摘出来给用户看:`name`、Skill ID(payload 字段 `skill_identifier`)、`version`、`description`、`original`、`repost_source`、`content_tag_ids`。标签字段展示**中文名列表**,不要只甩 `tagId`。
@@ -76,7 +92,7 @@ redskillhub-upload publish <absolute-path> --dry-run --agent \
76
92
 
77
93
  ```bash
78
94
  printf 'submit\n' | redskillhub-upload publish <absolute-path> --agent \
79
- --source <original|repost> --tag <中文标签名[,中文标签名...]> [--repost-source <来源名>]
95
+ --source <original|repost> --tag <中文标签名[,中文标签名...]> [--repost-source <来源名>] --env <ENV>
80
96
  ```
81
97
 
82
98
  不要带 `--yes`,让 CLI 进 confirm 阶段;`submit\n` 从 stdin 推给它即可。用户说「取消 / cancel」就把第二条命令换成 `printf 'cancel\n' | ...`。用户回复「好了」不能当成提交触发词。
@@ -93,16 +109,16 @@ printf 'submit\n' | redskillhub-upload publish <absolute-path> --agent \
93
109
  上传入口接受本地 skill 目录或 `.zip` 源包;zip 必须由 CLI 在本地解包、过滤、校验并重新生成上传包,不能由 agent 直接上传或改包。
94
110
 
95
111
  ```bash
96
- redskillhub-upload publish /absolute/path/to/skill --agent
97
- redskillhub-upload publish /absolute/path/to/skill.zip --agent
112
+ redskillhub-upload publish /absolute/path/to/skill --agent --env <ENV>
113
+ redskillhub-upload publish /absolute/path/to/skill.zip --agent --env <ENV>
98
114
  ```
99
115
 
100
- `redskillhub-upload whoami`、`redskillhub-upload login --agent`、`redskillhub-upload login --cancel` 仅用于诊断、显式预登录或取消等待,不属于标准发布前置步骤。
116
+ `redskillhub-upload whoami --env <ENV>`、`redskillhub-upload login --agent --env <ENV>`、`redskillhub-upload login --cancel --env <ENV>` 仅用于诊断、显式预登录或取消等待,不属于标准发布前置步骤。
101
117
 
102
118
  本地验证可使用 dry-run。标签可直接传一个或多个中文名(CLI 内部完成 name→id 映射,多个用逗号分隔),也允许调试时传数字 id:
103
119
 
104
120
  ```bash
105
- node cli/index.mjs publish test/fixtures/minimal-skill --dry-run --agent --source original --tag 效率工具,内容创作 --yes
121
+ node cli/index.mjs publish test/fixtures/minimal-skill --dry-run --agent --source original --tag 效率工具,内容创作 --yes --env beta
106
122
  # 或调试用:--tag-id 1001,1002
107
123
  ```
108
124
 
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: "Red SkillHub 上传"
3
+ short_description: "安装最新兼容 CLI 并发布本地 Skill 到 SkillHub"
4
+ default_prompt: "使用 $redskillhub-upload 检查并安装 1.0 版本线的最新 CLI,然后将我指定的本地 Skill 发布到小红书 SkillHub。"
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env node
2
+ import { realpathSync } from 'node:fs';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const PACKAGE_NAME = 'redskillhub-upload';
7
+ const COMMAND_NAME = 'redskillhub-upload';
8
+ const REGISTRY = 'https://registry.npmjs.org/';
9
+ const REQUIRED_MAJOR = 1;
10
+ const REQUIRED_MINOR = 0;
11
+
12
+ function parseVersion(version) {
13
+ const match = String(version).match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/);
14
+ if (!match) return null;
15
+ return {
16
+ raw: version,
17
+ major: Number(match[1]),
18
+ minor: Number(match[2]),
19
+ patch: Number(match[3]),
20
+ prerelease: match[4] || ''
21
+ };
22
+ }
23
+
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
+ function run(command, args, options = {}) {
68
+ const result = spawnSync(command, args, {
69
+ encoding: 'utf8',
70
+ stdio: options.capture ? 'pipe' : 'inherit'
71
+ });
72
+ if (result.error) throw result.error;
73
+ if (!options.allowFailure && result.status !== 0) {
74
+ const detail = options.capture ? String(result.stderr || result.stdout || '').trim() : '';
75
+ throw new Error(`${command} 执行失败${detail ? `:${detail}` : ''}`);
76
+ }
77
+ return result;
78
+ }
79
+
80
+ function queryVersions() {
81
+ const result = run('npm', [
82
+ 'view',
83
+ PACKAGE_NAME,
84
+ 'versions',
85
+ '--json',
86
+ `--registry=${REGISTRY}`
87
+ ], { capture: true });
88
+ const versions = JSON.parse(result.stdout);
89
+ return Array.isArray(versions) ? versions : [versions];
90
+ }
91
+
92
+ function readInstalledVersion() {
93
+ const result = run('npm', [
94
+ 'list',
95
+ '--global',
96
+ PACKAGE_NAME,
97
+ '--depth=0',
98
+ '--json'
99
+ ], { capture: true, allowFailure: true });
100
+ try {
101
+ return JSON.parse(result.stdout)?.dependencies?.[PACKAGE_NAME]?.version || '';
102
+ } catch {
103
+ return '';
104
+ }
105
+ }
106
+
107
+ function installVersion(version) {
108
+ run('npm', [
109
+ 'install',
110
+ '--global',
111
+ `${PACKAGE_NAME}@${version}`,
112
+ `--registry=${REGISTRY}`
113
+ ]);
114
+ }
115
+
116
+ export function normalizeCliEnvironment(value = 'prod') {
117
+ const envName = String(value).trim().toLowerCase();
118
+ if (envName !== 'prod' && envName !== 'beta') {
119
+ throw new Error(`--env 仅支持 prod 或 beta,当前值:${value}`);
120
+ }
121
+ return envName;
122
+ }
123
+
124
+ export function ensureCli(options = {}) {
125
+ const envName = normalizeCliEnvironment(options.env);
126
+ const latestVersion = selectLatestCompatibleVersion(queryVersions());
127
+ const previousVersion = readInstalledVersion();
128
+ let status = 'current';
129
+ if (previousVersion !== latestVersion) {
130
+ installVersion(latestVersion);
131
+ status = previousVersion ? 'updated' : 'installed';
132
+ }
133
+ const installedVersion = readInstalledVersion();
134
+ if (installedVersion !== latestVersion) {
135
+ throw new Error(`版本校验失败:期望 ${latestVersion},实际 ${installedVersion || '未安装'}`);
136
+ }
137
+ run(COMMAND_NAME, ['whoami', '--env', envName]);
138
+ return { status, previousVersion: previousVersion || null, version: installedVersion };
139
+ }
140
+
141
+ function readEnvironmentArg(argv) {
142
+ const index = argv.indexOf('--env');
143
+ if (index === -1) return 'prod';
144
+ return argv[index + 1];
145
+ }
146
+
147
+ function isMainModule() {
148
+ if (!process.argv[1]) return false;
149
+ try {
150
+ return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
151
+ } catch {
152
+ return false;
153
+ }
154
+ }
155
+
156
+ if (isMainModule()) {
157
+ try {
158
+ process.stdout.write(`CLI_VERSION_JSON:${JSON.stringify(ensureCli({
159
+ env: readEnvironmentArg(process.argv.slice(2))
160
+ }))}\n`);
161
+ } catch (error) {
162
+ process.stderr.write(`CLI_VERSION_JSON:${JSON.stringify({
163
+ status: 'error',
164
+ message: error?.message || String(error)
165
+ })}\n`);
166
+ process.exit(1);
167
+ }
168
+ }