redskillhub-upload 1.0.0-alpha.4 → 1.0.0-alpha.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  Node.js CLI for uploading local Skills to 小红书 SkillHub.
4
4
 
5
+ 支持 macOS 和 Windows(PowerShell / cmd)。要求 Node.js 18 或更高版本;包含空格或中文的本地路径应使用双引号包裹。
6
+
5
7
  Install from the public npm registry:
6
8
 
7
9
  ```bash
@@ -14,7 +16,7 @@ Publish input can be a local Skill directory or a `.zip` file. User-provided zip
14
16
 
15
17
  ## API Endpoints
16
18
 
17
- All paths and hosts are centralized in `cli/config.mjs` (`PATHS` + `DEFAULT_API_BASE` / `DEFAULT_OAS_BASE`).
19
+ All paths and hosts are centralized in `cli/config.mjs` (`PATHS` + `DEFAULT_API_BASE` / `BETA_API_BASE`).
18
20
 
19
21
  | Path Key | Path | Host | Description |
20
22
  |----------|------|------|-------------|
@@ -22,15 +24,15 @@ All paths and hosts are centralized in `cli/config.mjs` (`PATHS` + `DEFAULT_API_
22
24
  | `SUBMIT_SKILL_VERSION` | `/api/sns/v1/openapi/skillhub/submit_skill_version` | `DEFAULT_API_BASE` | 提交 skill 版本 |
23
25
  | `CREATE_CLI_OAUTH_DEVICE_CODE` | `/api/sns/v1/creator/red_skill/create_cli_oauth_device_code` | `DEFAULT_API_BASE` | 创建 CLI OAuth 设备码 |
24
26
  | `POLL_CLI_OAUTH_DEVICE_TOKEN` | `/api/sns/v1/creator/red_skill/poll_cli_oauth_device_token` | `DEFAULT_API_BASE` | 轮询 CLI OAuth 授权状态 |
25
- | `OAS_ACCESS_TOKEN` | `/api/sns/v1/oauth2/access_token` | `DEFAULT_OAS_BASE` | 用授权码换取 access_token |
26
- | `OAS_REFRESH_TOKEN` | `/api/sns/v1/oauth2/refresh_token` | `DEFAULT_OAS_BASE` | 用 refresh_token 续期 access_token |
27
+ | `EXCHANGE_CLI_OAUTH_TOKEN` | `/api/sns/v1/creator/red_skill/exchange_cli_oauth_token` | `DEFAULT_API_BASE` / `BETA_API_BASE` | 通过 SkillHub 服务端换取 access_token |
28
+ | `REFRESH_CLI_OAUTH_TOKEN` | `/api/sns/v1/creator/red_skill/refresh_cli_oauth_token` | `DEFAULT_API_BASE` / `BETA_API_BASE` | 通过 SkillHub 服务端续期 access_token |
27
29
 
28
30
  - `DEFAULT_API_BASE` = `https://edith.xiaohongshu.com` — 生产业务接口
29
31
  - `BETA_API_BASE` = `https://edith.beta.xiaohongshu.com` — beta 业务接口
30
- - `DEFAULT_OAS_BASE` = `https://openaccount.xiaohongshu.com` — OAuth2 开放平台接口
31
32
  - 所有命令都支持 `--env prod|beta`,默认 `prod`;`--env beta` 时业务接口使用 `BETA_API_BASE`
33
+ - prod 沿用 `~/.skillhub-upload`,beta 使用 `~/.skillhub-upload/beta`,凭证、待授权、二维码、标签缓存和临时包相互隔离
32
34
  - 业务接口 host 可通过 `--api-base` 参数或 `SKILLHUB_UPLOAD_API_BASE` 环境变量覆盖
33
- - OAS 换 token 默认使用内置应用凭证;可通过 `SKILLHUB_UPLOAD_APP_SECRET` 覆盖
35
+ - OAuth token 换取与刷新统一走 SkillHub 服务端 broker,CLI 不保存 appSecret
34
36
 
35
37
  ## Auth Flow
36
38
 
@@ -60,9 +62,10 @@ Dry-run examples may use numeric tag IDs because the backend contract accepts `c
60
62
  redskillhub-upload login --env prod
61
63
  redskillhub-upload login --agent --env prod
62
64
  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
65
+ redskillhub-upload tags --env prod
66
+ redskillhub-upload publish "/absolute/path/to/skill" --env prod
67
+ redskillhub-upload publish "/absolute/path/to/skill" --agent --env prod
68
+ redskillhub-upload publish "/absolute/path/to/skill.zip" --agent --env prod
66
69
  redskillhub-upload whoami --env prod
67
70
  redskillhub-upload logout --env prod
68
71
  ```
@@ -71,11 +74,19 @@ redskillhub-upload logout --env prod
71
74
 
72
75
  ```bash
73
76
  redskillhub-upload login --agent --env beta
74
- redskillhub-upload publish /absolute/path/to/skill --agent --env beta
77
+ redskillhub-upload tags --env beta
78
+ redskillhub-upload publish "/absolute/path/to/skill" --agent --env beta
75
79
  redskillhub-upload whoami --env beta
76
80
  redskillhub-upload logout --env beta
77
81
  ```
78
82
 
83
+ Windows 路径示例:
84
+
85
+ ```powershell
86
+ redskillhub-upload publish "C:\Users\name\path\to\skill" --agent --env prod
87
+ node -e "process.stdout.write('submit\n')" | redskillhub-upload publish "C:\Users\name\path\to\skill" --agent --source original --tag "效率工具" --env prod
88
+ ```
89
+
79
90
  ## Agent Protocol
80
91
 
81
92
  See `docs/protocol.md` for the line protocol:
@@ -8,6 +8,7 @@ export async function renderDeviceAuthPrompt(payload, options = {}) {
8
8
  const {
9
9
  agent = false,
10
10
  env = process.env,
11
+ flags = {},
11
12
  stream = process.stdout,
12
13
  qrCode = QRCode
13
14
  } = options;
@@ -25,7 +26,7 @@ export async function renderDeviceAuthPrompt(payload, options = {}) {
25
26
  };
26
27
 
27
28
  if (agent) {
28
- const qrCodePath = getAuthQrPath(env);
29
+ const qrCodePath = getAuthQrPath(env, flags);
29
30
  await fs.mkdir(path.dirname(qrCodePath), { recursive: true, mode: 0o700 });
30
31
  await qrCode.toFile(qrCodePath, authorizeCommonUrl, {
31
32
  type: 'png',
package/cli/auth.mjs CHANGED
@@ -3,16 +3,13 @@ import fs from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import {
5
5
  DEFAULT_API_BASE,
6
- DEFAULT_APP_ID,
7
- DEFAULT_APP_SECRET,
8
- DEFAULT_OAS_BASE,
9
6
  DEFAULT_SCOPE,
10
7
  DEFAULT_SCOPES,
11
8
  PATHS,
12
9
  TOKEN_REFRESH_BUFFER_MS,
13
10
  getAuthQrPath,
14
11
  getCredentialsPath,
15
- getHomeDir,
12
+ getEnvironmentHomeDir,
16
13
  getPendingAuthPath,
17
14
  resolveApiBase
18
15
  } from './config.mjs';
@@ -46,9 +43,9 @@ function canRefreshCredentials(credentials, nowMs = Date.now()) {
46
43
  return Number.isFinite(refreshExpireTimeMs) && refreshExpireTimeMs > nowMs + TOKEN_REFRESH_BUFFER_MS;
47
44
  }
48
45
 
49
- export async function readCredentials(env = process.env) {
46
+ export async function readCredentials(env = process.env, flags = {}) {
50
47
  try {
51
- const file = getCredentialsPath(env);
48
+ const file = getCredentialsPath(env, flags);
52
49
  await fs.chmod(file, 0o600).catch(() => {});
53
50
  return JSON.parse(await fs.readFile(file, 'utf8'));
54
51
  } catch (error) {
@@ -57,9 +54,9 @@ export async function readCredentials(env = process.env) {
57
54
  }
58
55
  }
59
56
 
60
- export async function writeCredentials(credentials, env = process.env) {
61
- const home = getHomeDir(env);
62
- const file = getCredentialsPath(env);
57
+ export async function writeCredentials(credentials, env = process.env, flags = {}) {
58
+ const home = getEnvironmentHomeDir(env, flags);
59
+ const file = getCredentialsPath(env, flags);
63
60
  const tmp = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${crypto.randomUUID()}.tmp`);
64
61
  await fs.mkdir(home, { recursive: true, mode: 0o700 });
65
62
  try {
@@ -73,8 +70,8 @@ export async function writeCredentials(credentials, env = process.env) {
73
70
  }
74
71
  }
75
72
 
76
- export async function removeCredentials(env = process.env) {
77
- await fs.rm(getCredentialsPath(env), { force: true });
73
+ export async function removeCredentials(env = process.env, flags = {}) {
74
+ await fs.rm(getCredentialsPath(env, flags), { force: true });
78
75
  }
79
76
 
80
77
  export function maskCredentials(credentials) {
@@ -108,34 +105,17 @@ function unwrapResponseBody(body) {
108
105
  return body?.data && typeof body.data === 'object' ? body.data : body;
109
106
  }
110
107
 
111
- function resolveAppSecret(flags = {}, env = process.env) {
112
- return flags.appSecret
113
- || flags['app-secret']
114
- || env.SKILLHUB_UPLOAD_APP_SECRET
115
- || DEFAULT_APP_SECRET;
116
- }
117
-
118
108
  function buildTokenRequestBody(fields = {}) {
119
- const body = {
120
- app_id: fields.appId,
121
- code: fields.authorizationCode,
109
+ return {
110
+ authorization_code: fields.authorizationCode,
122
111
  code_verifier: fields.codeVerifier
123
112
  };
124
- if (fields.appSecret) {
125
- body.app_secret = fields.appSecret;
126
- }
127
- return body;
128
113
  }
129
114
 
130
115
  function buildRefreshRequestBody(fields = {}) {
131
- const body = {
132
- app_id: fields.appId,
116
+ return {
133
117
  refresh_token: fields.refreshToken
134
118
  };
135
- if (fields.appSecret) {
136
- body.app_secret = fields.appSecret;
137
- }
138
- return body;
139
119
  }
140
120
 
141
121
  function getRejectMessage(body, fallback) {
@@ -231,14 +211,12 @@ export async function pollDeviceToken({ apiBase, deviceCode, fetchImpl = compati
231
211
  }
232
212
 
233
213
  export async function exchangeToken({
234
- tokenUrl = `${DEFAULT_OAS_BASE}${PATHS.OAS_ACCESS_TOKEN}`,
235
- appId = DEFAULT_APP_ID,
236
- appSecret = '',
214
+ tokenUrl = `${DEFAULT_API_BASE}${PATHS.EXCHANGE_CLI_OAUTH_TOKEN}`,
237
215
  authorizationCode,
238
216
  codeVerifier,
239
217
  fetchImpl = compatibleFetch
240
218
  }) {
241
- const body = buildTokenRequestBody({ appId, appSecret, authorizationCode, codeVerifier });
219
+ const body = buildTokenRequestBody({ authorizationCode, codeVerifier });
242
220
  const response = await fetchImpl(tokenUrl, {
243
221
  method: 'POST',
244
222
  headers: { 'content-type': 'application/json' },
@@ -257,13 +235,11 @@ export async function exchangeToken({
257
235
  }
258
236
 
259
237
  export async function refreshToken({
260
- tokenUrl = `${DEFAULT_OAS_BASE}${PATHS.OAS_REFRESH_TOKEN}`,
261
- appId = DEFAULT_APP_ID,
262
- appSecret = '',
238
+ tokenUrl = `${DEFAULT_API_BASE}${PATHS.REFRESH_CLI_OAUTH_TOKEN}`,
263
239
  refreshToken: rt,
264
240
  fetchImpl = compatibleFetch
265
241
  }) {
266
- const body = buildRefreshRequestBody({ appId, appSecret, refreshToken: rt });
242
+ const body = buildRefreshRequestBody({ refreshToken: rt });
267
243
  const response = await fetchImpl(tokenUrl, {
268
244
  method: 'POST',
269
245
  headers: { 'content-type': 'application/json' },
@@ -283,7 +259,6 @@ export async function refreshToken({
283
259
 
284
260
  function buildCredentialsFromToken(token, nowMs, errorCode) {
285
261
  const credentials = {
286
- appId: token.app_id || token.appId || DEFAULT_APP_ID,
287
262
  accessToken: token.access_token || token.accessToken || '',
288
263
  refreshToken: token.refresh_token || token.refreshToken || '',
289
264
  expireTimeMs: String(token.expire_time_ms || token.expireTimeMs || nowMs + Number(token.expires_in || 7200) * 1000),
@@ -306,9 +281,9 @@ function buildCredentialsFromToken(token, nowMs, errorCode) {
306
281
  return credentials;
307
282
  }
308
283
 
309
- async function readPendingAuth(env, now) {
284
+ async function readPendingAuth(env, flags, now) {
310
285
  try {
311
- const raw = JSON.parse(await fs.readFile(getPendingAuthPath(env), 'utf8'));
286
+ const raw = JSON.parse(await fs.readFile(getPendingAuthPath(env, flags), 'utf8'));
312
287
  if (raw?.expiresAtMs && raw.expiresAtMs > now() && raw.deviceCode && raw.codeVerifier) {
313
288
  return raw;
314
289
  }
@@ -318,26 +293,26 @@ async function readPendingAuth(env, now) {
318
293
  return null;
319
294
  }
320
295
 
321
- async function writePendingAuth(env, state) {
322
- const file = getPendingAuthPath(env);
296
+ async function writePendingAuth(env, flags, state) {
297
+ const file = getPendingAuthPath(env, flags);
323
298
  await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
324
299
  await fs.writeFile(file, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
325
300
  }
326
301
 
327
- async function clearPendingAuth(env) {
328
- await fs.rm(getPendingAuthPath(env), { force: true });
329
- await fs.rm(getAuthQrPath(env), { force: true });
302
+ async function clearPendingAuth(env, flags) {
303
+ await fs.rm(getPendingAuthPath(env, flags), { force: true });
304
+ await fs.rm(getAuthQrPath(env, flags), { force: true });
330
305
  }
331
306
 
332
- export async function cancelLogin(env = process.env) {
307
+ export async function cancelLogin(env = process.env, flags = {}) {
333
308
  let hadPending = true;
334
309
  try {
335
- await fs.access(getPendingAuthPath(env));
310
+ await fs.access(getPendingAuthPath(env, flags));
336
311
  } catch (error) {
337
312
  if (error.code !== 'ENOENT') throw error;
338
313
  hadPending = false;
339
314
  }
340
- await clearPendingAuth(env);
315
+ await clearPendingAuth(env, flags);
341
316
  return { hadPending };
342
317
  }
343
318
 
@@ -349,7 +324,8 @@ export async function login({
349
324
  now = () => Date.now(),
350
325
  promptStream = process.stdout
351
326
  } = {}) {
352
- const cachedCredentials = await readCredentials(env);
327
+ const apiBase = resolveApiBase(flags, env);
328
+ const cachedCredentials = await readCredentials(env, flags);
353
329
  if (isValidCredentials(cachedCredentials, now())) {
354
330
  return maskCredentials(cachedCredentials);
355
331
  }
@@ -357,16 +333,13 @@ export async function login({
357
333
  // access_token 过期但 refresh_token 仍有效,静默续期
358
334
  if (canRefreshCredentials(cachedCredentials, now())) {
359
335
  try {
360
- const appSecret = resolveAppSecret(flags, env);
361
336
  const token = await refreshToken({
362
- appId: cachedCredentials.appId || DEFAULT_APP_ID,
363
- appSecret,
364
337
  refreshToken: cachedCredentials.refreshToken,
365
- tokenUrl: flags.tokenUrl || `${DEFAULT_OAS_BASE}${PATHS.OAS_REFRESH_TOKEN}`,
338
+ tokenUrl: `${apiBase}${PATHS.REFRESH_CLI_OAUTH_TOKEN}`,
366
339
  fetchImpl
367
340
  });
368
- await writeCredentials(buildCredentialsFromToken(token, now(), 'OAS_TOKEN_REFRESH_INVALID_RESPONSE'), env);
369
- return maskCredentials(await readCredentials(env));
341
+ await writeCredentials(buildCredentialsFromToken(token, now(), 'OAS_TOKEN_REFRESH_INVALID_RESPONSE'), env, flags);
342
+ return maskCredentials(await readCredentials(env, flags));
370
343
  } catch (error) {
371
344
  if (error instanceof SkillhubUploadError) {
372
345
  // 刷新失败,降级走设备码
@@ -376,10 +349,8 @@ export async function login({
376
349
  }
377
350
  }
378
351
 
379
- const apiBase = resolveApiBase(flags, env);
380
-
381
352
  // 从磁盘恢复未完成的授权状态,避免进程重启后要求用户重新授权
382
- let pending = await readPendingAuth(env, now);
353
+ let pending = await readPendingAuth(env, flags, now);
383
354
  let codeVerifier;
384
355
  if (pending) {
385
356
  codeVerifier = pending.codeVerifier;
@@ -390,12 +361,12 @@ export async function login({
390
361
  userCode: pending.userCode,
391
362
  expiresInSeconds: Math.round((pending.expiresAtMs - now()) / 1000),
392
363
  message: '检测到未完成的授权,继续等待中(无需重新打开授权链接)'
393
- }, { agent: Boolean(flags.agent), env, stream: promptStream });
364
+ }, { agent: Boolean(flags.agent), env, flags, stream: promptStream });
394
365
  } else {
395
366
  const pkce = await createPkcePair();
396
367
  codeVerifier = pkce.codeVerifier;
397
368
  const device = await requestDeviceCode({ apiBase, pkce, fetchImpl });
398
- await writePendingAuth(env, {
369
+ await writePendingAuth(env, flags, {
399
370
  deviceCode: device.deviceCode,
400
371
  codeVerifier: pkce.codeVerifier,
401
372
  authorizeCommonUrl: device.authorizeCommonUrl,
@@ -405,7 +376,7 @@ export async function login({
405
376
  pollIntervalSeconds: device.pollIntervalSeconds || 5,
406
377
  expiresAtMs: now() + (device.expiresInSeconds || 600) * 1000
407
378
  });
408
- pending = await readPendingAuth(env, now);
379
+ pending = await readPendingAuth(env, flags, now);
409
380
  await renderDeviceAuthPrompt({
410
381
  type: 'auth_device_code',
411
382
  authorizeCommonUrl: device.authorizeCommonUrl,
@@ -413,7 +384,7 @@ export async function login({
413
384
  userCode: device.userCode,
414
385
  expiresInSeconds: device.expiresInSeconds || 600,
415
386
  message: '请用手机自带浏览器打开授权链接,页面会自动跳转至小红书 App 授权;授权页输入授权码后 CLI 会自动轮询'
416
- }, { agent: Boolean(flags.agent), env, stream: promptStream });
387
+ }, { agent: Boolean(flags.agent), env, flags, stream: promptStream });
417
388
  }
418
389
 
419
390
  const deadline = pending.expiresAtMs;
@@ -422,7 +393,7 @@ export async function login({
422
393
  while (now() < deadline) {
423
394
  await sleep((pending.pollIntervalSeconds || 5) * 1000);
424
395
  // 这里只检测跨进程取消,不把自然过期误判为取消。
425
- const activePending = await readPendingAuth(env, () => 0);
396
+ const activePending = await readPendingAuth(env, flags, () => 0);
426
397
  if (!activePending || activePending.deviceCode !== pending.deviceCode) {
427
398
  throw new SkillhubUploadError(
428
399
  'AUTH_CANCELLED',
@@ -437,34 +408,32 @@ export async function login({
437
408
  break;
438
409
  }
439
410
  if (authStatus === 'expired') {
440
- await clearPendingAuth(env);
411
+ await clearPendingAuth(env, flags);
441
412
  throw new SkillhubUploadError('AUTH_TIMEOUT', '授权超时,请重新发起', ExitCodes.AUTH);
442
413
  }
443
414
  if (authStatus === 'cancelled' || authStatus === 'denied' || authStatus === 'rejected') {
444
- await clearPendingAuth(env);
415
+ await clearPendingAuth(env, flags);
445
416
  throw new SkillhubUploadError('AUTH_DENIED', '用户拒绝授权', ExitCodes.AUTH);
446
417
  }
447
418
  }
448
419
  if (!authorizationCode) {
449
- await clearPendingAuth(env);
420
+ await clearPendingAuth(env, flags);
450
421
  throw new SkillhubUploadError('AUTH_TIMEOUT', '授权超时,请重新发起', ExitCodes.AUTH);
451
422
  }
452
423
  } catch (error) {
453
424
  if (!(error instanceof SkillhubUploadError)) {
454
- await clearPendingAuth(env);
425
+ await clearPendingAuth(env, flags);
455
426
  }
456
427
  throw error;
457
428
  }
458
429
 
459
430
  const token = await exchangeToken({
460
- appId: DEFAULT_APP_ID,
461
- appSecret: resolveAppSecret(flags, env),
462
431
  authorizationCode,
463
432
  codeVerifier,
464
- tokenUrl: flags.tokenUrl || `${DEFAULT_OAS_BASE}${PATHS.OAS_ACCESS_TOKEN}`,
433
+ tokenUrl: `${apiBase}${PATHS.EXCHANGE_CLI_OAUTH_TOKEN}`,
465
434
  fetchImpl
466
435
  });
467
- await writeCredentials(buildCredentialsFromToken(token, now(), 'OAS_TOKEN_EXCHANGE_INVALID_RESPONSE'), env);
468
- await clearPendingAuth(env);
469
- return maskCredentials(await readCredentials(env));
436
+ await writeCredentials(buildCredentialsFromToken(token, now(), 'OAS_TOKEN_EXCHANGE_INVALID_RESPONSE'), env, flags);
437
+ await clearPendingAuth(env, flags);
438
+ return maskCredentials(await readCredentials(env, flags));
470
439
  }
package/cli/config.mjs CHANGED
@@ -3,9 +3,6 @@ import path from 'node:path';
3
3
 
4
4
  export const DEFAULT_API_BASE = 'https://edith.xiaohongshu.com';
5
5
  export const BETA_API_BASE = 'https://edith-redskill.sl.beta.xiaohongshu.com';
6
- export const DEFAULT_OAS_BASE = 'https://openaccount.xiaohongshu.com';
7
- export const DEFAULT_APP_ID = 'xhsyfHfmZQAQ24o';
8
- export const DEFAULT_APP_SECRET = 'skHlFZ8BTk2bVmsSt1aYjv1NxcJjuOwhSev4oraniDa6eyn8L9oqBWvCtsEBnmCmWG';
9
6
  export const DEFAULT_SCOPES = ['base_info', 'skill_publish'];
10
7
  export const DEFAULT_SCOPE = DEFAULT_SCOPES.join(',');
11
8
 
@@ -14,8 +11,8 @@ export const PATHS = {
14
11
  SUBMIT_SKILL_VERSION: '/api/sns/v1/creator/red_skill/cli_submit_skill_version',
15
12
  CREATE_CLI_OAUTH_DEVICE_CODE: '/api/sns/v1/creator/red_skill/create_cli_oauth_device_code',
16
13
  POLL_CLI_OAUTH_DEVICE_TOKEN: '/api/sns/v1/creator/red_skill/poll_cli_oauth_device_token',
17
- OAS_ACCESS_TOKEN: '/api/sns/v1/oauth2/access_token',
18
- OAS_REFRESH_TOKEN: '/api/sns/v1/oauth2/refresh_token',
14
+ EXCHANGE_CLI_OAUTH_TOKEN: '/api/sns/v1/creator/red_skill/exchange_cli_oauth_token',
15
+ REFRESH_CLI_OAUTH_TOKEN: '/api/sns/v1/creator/red_skill/refresh_cli_oauth_token',
19
16
  QUERY_CONTENT_TAG_CONFIG: '/api/sns/v1/activity_platform/config/query_config'
20
17
  };
21
18
  export const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000;
@@ -28,24 +25,29 @@ export function getHomeDir(env = process.env) {
28
25
  return env.SKILLHUB_UPLOAD_HOME || path.join(os.homedir(), '.skillhub-upload');
29
26
  }
30
27
 
31
- export function getCredentialsPath(env = process.env) {
32
- return path.join(getHomeDir(env), 'credentials.json');
28
+ export function getEnvironmentHomeDir(env = process.env, flags = {}) {
29
+ const home = getHomeDir(env);
30
+ return resolveApiEnvironment(flags) === 'beta' ? path.join(home, 'beta') : home;
33
31
  }
34
32
 
35
- export function getTmpDir(env = process.env) {
36
- return path.join(getHomeDir(env), 'tmp');
33
+ export function getCredentialsPath(env = process.env, flags = {}) {
34
+ return path.join(getEnvironmentHomeDir(env, flags), 'credentials.json');
37
35
  }
38
36
 
39
- export function getContentTagCachePath(env = process.env) {
40
- return path.join(getHomeDir(env), 'content-tags.json');
37
+ export function getTmpDir(env = process.env, flags = {}) {
38
+ return path.join(getEnvironmentHomeDir(env, flags), 'tmp');
41
39
  }
42
40
 
43
- export function getPendingAuthPath(env = process.env) {
44
- return path.join(getHomeDir(env), 'pending-auth.json');
41
+ export function getContentTagCachePath(env = process.env, flags = {}) {
42
+ return path.join(getEnvironmentHomeDir(env, flags), 'content-tags.json');
45
43
  }
46
44
 
47
- export function getAuthQrPath(env = process.env) {
48
- return path.join(getHomeDir(env), 'auth-qr.png');
45
+ export function getPendingAuthPath(env = process.env, flags = {}) {
46
+ return path.join(getEnvironmentHomeDir(env, flags), 'pending-auth.json');
47
+ }
48
+
49
+ export function getAuthQrPath(env = process.env, flags = {}) {
50
+ return path.join(getEnvironmentHomeDir(env, flags), 'auth-qr.png');
49
51
  }
50
52
 
51
53
  export function resolveApiEnvironment(flags = {}) {
package/cli/index.mjs CHANGED
@@ -93,7 +93,7 @@ export async function ensurePublishCredentials({
93
93
  readCredentials,
94
94
  login
95
95
  }) {
96
- let credentials = await readCredentials(env);
96
+ let credentials = await readCredentials(env, flags);
97
97
  try {
98
98
  assertUsableCredentials(credentials, nowMs);
99
99
  return credentials;
@@ -111,7 +111,7 @@ export async function ensurePublishCredentials({
111
111
  now: io.now,
112
112
  promptStream: io.out || process.stdout
113
113
  });
114
- credentials = await readCredentials(env);
114
+ credentials = await readCredentials(env, flags);
115
115
  assertUsableCredentials(credentials, io.now ? io.now() : nowMs);
116
116
  return credentials;
117
117
  }
@@ -182,7 +182,7 @@ export async function main(argv = process.argv.slice(2), env = process.env, io =
182
182
  if (!command) {
183
183
  throw new SkillhubUploadError(
184
184
  'INVALID_ARGS',
185
- '请指定要执行的操作:login、publish、whoami 或 logout',
185
+ '请指定要执行的操作:login、publish、tags、whoami 或 logout',
186
186
  ExitCodes.INVALID_ARGS
187
187
  );
188
188
  }
@@ -193,19 +193,29 @@ export async function main(argv = process.argv.slice(2), env = process.env, io =
193
193
  }
194
194
  if (command === 'whoami') {
195
195
  const { readCredentials, maskCredentials } = await import('./auth.mjs');
196
- writeResult({ status: 'ok', command, credentials: maskCredentials(await readCredentials(env)) }, out);
196
+ writeResult({ status: 'ok', command, credentials: maskCredentials(await readCredentials(env, flags)) }, out);
197
197
  return;
198
198
  }
199
199
  if (command === 'logout') {
200
200
  const { removeCredentials } = await import('./auth.mjs');
201
- await removeCredentials(env);
201
+ await removeCredentials(env, flags);
202
202
  writeResult({ status: 'ok', command }, out);
203
203
  return;
204
204
  }
205
+ if (command === 'tags') {
206
+ const tags = await loadContentTags({
207
+ fetchImpl: io.fetchImpl,
208
+ env,
209
+ now: io.now,
210
+ flags
211
+ });
212
+ writeResult({ status: 'ok', command, tags }, out);
213
+ return;
214
+ }
205
215
  if (command === 'login') {
206
216
  if (flags.cancel) {
207
217
  const { cancelLogin } = await import('./auth.mjs');
208
- const cancelled = await cancelLogin(env);
218
+ const cancelled = await cancelLogin(env, flags);
209
219
  writeResult({ status: 'cancelled', command, ...cancelled }, out);
210
220
  return;
211
221
  }
@@ -240,7 +250,7 @@ export async function main(argv = process.argv.slice(2), env = process.env, io =
240
250
  });
241
251
  }
242
252
 
243
- const bundle = await prepareBundle(pathArg, { env });
253
+ const bundle = await prepareBundle(pathArg, { env, flags });
244
254
  const bundleMetadata = {
245
255
  bundleSha256: bundle.bundleSha256,
246
256
  bundleSizeBytes: bundle.bundleSizeBytes
@@ -298,7 +308,7 @@ export async function main(argv = process.argv.slice(2), env = process.env, io =
298
308
  promptSession.close();
299
309
  }
300
310
  }
301
- throw new SkillhubUploadError('INVALID_ARGS', `不支持的操作:${command},可用操作:login、publish、whoami、logout`, ExitCodes.INVALID_ARGS);
311
+ throw new SkillhubUploadError('INVALID_ARGS', `不支持的操作:${command},可用操作:login、publish、tags、whoami、logout`, ExitCodes.INVALID_ARGS);
302
312
  }
303
313
 
304
314
  function isMainModule() {
package/cli/pack.mjs CHANGED
@@ -267,8 +267,8 @@ function isAllowedExtension(file) {
267
267
  return ALLOWED_EXTENSIONS.has(path.extname(file).toLowerCase());
268
268
  }
269
269
 
270
- async function createTempZipPath(env) {
271
- const dir = await fs.mkdtemp(path.join(getTmpDir(env), `bundle-${process.pid}-`));
270
+ async function createTempZipPath(env, flags) {
271
+ const dir = await fs.mkdtemp(path.join(getTmpDir(env, flags), `bundle-${process.pid}-`));
272
272
  return path.join(dir, `${crypto.randomUUID()}.zip`);
273
273
  }
274
274
 
@@ -441,14 +441,14 @@ async function prepareZipSource(inputPath) {
441
441
  };
442
442
  }
443
443
 
444
- async function buildBundle(source, env) {
444
+ async function buildBundle(source, env, flags) {
445
445
  const parsed = parseSkillMarkdown(source.markdown);
446
446
  const zip = new AdmZip();
447
447
  for (const file of source.files) {
448
448
  zip.addFile(file.entryName, file.data);
449
449
  }
450
450
 
451
- const zipPath = await createTempZipPath(env);
451
+ const zipPath = await createTempZipPath(env, flags);
452
452
  zip.writeZip(zipPath);
453
453
  const zipStat = await fs.stat(zipPath);
454
454
 
@@ -468,6 +468,7 @@ async function buildBundle(source, env) {
468
468
 
469
469
  export async function prepareBundle(inputPath, options = {}) {
470
470
  const env = options.env || process.env;
471
+ const flags = options.flags || {};
471
472
  const stat = await fs.lstat(inputPath).catch(() => null);
472
473
  if (!stat) {
473
474
  throw new SkillhubUploadError('LOCAL_VALIDATION_FAILED', `路径不存在:${inputPath},请输入正确的 skill 目录或 zip 文件`, ExitCodes.LOCAL_VALIDATION);
@@ -475,7 +476,7 @@ export async function prepareBundle(inputPath, options = {}) {
475
476
  if (stat.isSymbolicLink()) {
476
477
  throw new SkillhubUploadError('LOCAL_VALIDATION_FAILED', '输入路径是符号链接,请输入真实的 skill 目录或 zip 文件', ExitCodes.LOCAL_VALIDATION);
477
478
  }
478
- await fs.mkdir(getTmpDir(env), { recursive: true, mode: 0o700 });
479
+ await fs.mkdir(getTmpDir(env, flags), { recursive: true, mode: 0o700 });
479
480
 
480
481
  let source;
481
482
  if (stat.isDirectory()) {
@@ -490,5 +491,5 @@ export async function prepareBundle(inputPath, options = {}) {
490
491
  );
491
492
  }
492
493
 
493
- return await buildBundle(source, env);
494
+ return await buildBundle(source, env, flags);
494
495
  }
package/cli/tags.mjs CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  CONTENT_TAG_MODULE_ID,
8
8
  PATHS,
9
9
  getContentTagCachePath,
10
- getHomeDir,
10
+ getEnvironmentHomeDir,
11
11
  resolveApiBase
12
12
  } from './config.mjs';
13
13
  import { compatibleFetch } from './fetch.mjs';
@@ -122,7 +122,7 @@ export async function loadContentTags({
122
122
  if (env.SKILLHUB_UPLOAD_SKIP_REMOTE_TAGS === '1') {
123
123
  return FALLBACK_CONTENT_TAGS.map((tag) => ({ ...tag }));
124
124
  }
125
- const cachePath = getContentTagCachePath(env);
125
+ const cachePath = getContentTagCachePath(env, flags);
126
126
  const nowMs = typeof now === 'function' ? now() : Number(now) || Date.now();
127
127
  const cached = await readCachedContentTags(cachePath);
128
128
  if (cached && nowMs - cached.fetchedAtMs < cacheTtlMs) {
@@ -145,6 +145,6 @@ export async function loadContentTags({
145
145
  }
146
146
  }
147
147
 
148
- export function getContentTagsHomeDir(env = process.env) {
149
- return getHomeDir(env);
148
+ export function getContentTagsHomeDir(env = process.env, flags = {}) {
149
+ return getEnvironmentHomeDir(env, flags);
150
150
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "redskillhub-upload",
3
- "version": "1.0.0-alpha.4",
3
+ "version": "1.0.0-alpha.6",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "access": "public",
package/skill/SKILL.md CHANGED
@@ -9,12 +9,28 @@ description: 当用户要求安装或更新 redskillhub-upload、按照 upload.m
9
9
 
10
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
11
 
12
+ CLI 按环境隔离本地状态:prod 保留在 `~/.skillhub-upload`,beta 存放在其 `beta` 子目录。凭证、待授权状态、二维码、标签缓存和临时包不会跨环境复用。设置 `SKILLHUB_UPLOAD_HOME` 时,该路径作为环境目录的共同根目录。
13
+
14
+ ## 操作系统兼容
15
+
16
+ 本 Skill 必须同时支持 macOS 和 Windows。执行前通过 Node.js 判断系统,不依赖 `uname`:
17
+
18
+ ```bash
19
+ node -p "process.platform"
20
+ ```
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')"`,不要按系统拼两套管道命令。
27
+
12
28
  ## CLI 版本门禁
13
29
 
14
30
  在执行任何 `redskillhub-upload` 命令前,先从本 Skill 目录执行:
15
31
 
16
32
  ```bash
17
- node <SKILL_DIR>/scripts/ensure-cli.mjs --env <ENV>
33
+ node "<SKILL_DIR>/scripts/ensure-cli.mjs" --env <ENV>
18
34
  ```
19
35
 
20
36
  `<SKILL_DIR>` 是包含本文件的目录,执行前替换为其绝对路径。
@@ -53,17 +69,17 @@ node <SKILL_DIR>/scripts/ensure-cli.mjs --env <ENV>
53
69
 
54
70
  ## publish 子流程
55
71
 
56
- 固定 4 步,按顺序执行;步骤 1 用 AskUserQuestion 一次性收齐参数,步骤 2-3 把参数全部以 CLI flag 传入,避开 stdin piping。
72
+ 固定 4 步,按顺序执行;步骤 1 用 AskUserQuestion 一次性收齐参数,步骤 2 把参数全部以 CLI flag 传入,步骤 3 仅通过跨平台 Node 管道写入提交或取消指令。
57
73
 
58
74
  ### Step 1 — 一次性收齐 publish 参数
59
75
 
60
- (a) 先拉一次实时标签列表,命令:
76
+ (a) 通过已安装的 CLI 拉一次当前环境标签列表,命令:
61
77
 
62
78
  ```bash
63
- node -e "import('./cli/tags.mjs').then(({loadContentTags}) => loadContentTags({flags:{env:'<ENV>'}}).then(t => console.log(JSON.stringify(t.map(x => x.name)))))"
79
+ redskillhub-upload tags --env <ENV>
64
80
  ```
65
81
 
66
- (如果跑的是 PATH 上的 `redskillhub-upload` 而非仓库源码,没有 tags.mjs 入口,可使用与 `<ENV>` 对应的业务 host 请求 `/api/sns/v1/activity_platform/config/query_config?material_id=750&module_id=811` 取 `data[].tagName`。两条路只能挑一条,**不要硬编码标签清单**。)
82
+ 读取 `RESULT_JSON.tags` 中的 `name` 和 `tagId`。命令失败时如实报告并停止,不直接 curl 内部接口、不导入仓库源码,也**不硬编码标签清单**。
67
83
 
68
84
  (b) 用 AskUserQuestion 一次发两题:
69
85
 
@@ -75,8 +91,7 @@ node -e "import('./cli/tags.mjs').then(({loadContentTags}) => loadContentTags({f
75
91
  ### Step 2 — dry-run 出待提交载荷给用户审阅
76
92
 
77
93
  ```bash
78
- redskillhub-upload publish <absolute-path> --dry-run --agent \
79
- --source <original|repost> --tag <中文标签名[,中文标签名...]> [--repost-source <来源名>] --env <ENV>
94
+ redskillhub-upload publish "<absolute-path>" --dry-run --agent --source <original-or-repost> --tag "<中文标签名[,中文标签名...]>" [--repost-source "<来源名>"] --env <ENV>
80
95
  ```
81
96
 
82
97
  读 `RESULT_JSON.payload`,把关键字段摘出来给用户看:`name`、Skill ID(payload 字段 `skill_identifier`)、`version`、`description`、`original`、`repost_source`、`content_tag_ids`。标签字段展示**中文名列表**,不要只甩 `tagId`。
@@ -91,11 +106,10 @@ redskillhub-upload publish <absolute-path> --dry-run --agent \
91
106
  ### Step 3 — 用户明确说「提交 / 确认 / submit」后真实提交
92
107
 
93
108
  ```bash
94
- printf 'submit\n' | redskillhub-upload publish <absolute-path> --agent \
95
- --source <original|repost> --tag <中文标签名[,中文标签名...]> [--repost-source <来源名>] --env <ENV>
109
+ node -e "process.stdout.write('submit\n')" | redskillhub-upload publish "<absolute-path>" --agent --source <original-or-repost> --tag "<中文标签名[,中文标签名...]>" [--repost-source "<来源名>"] --env <ENV>
96
110
  ```
97
111
 
98
- 不要带 `--yes`,让 CLI 进 confirm 阶段;`submit\n` 从 stdin 推给它即可。用户说「取消 / cancel」就把第二条命令换成 `printf 'cancel\n' | ...`。用户回复「好了」不能当成提交触发词。
112
+ 不要带 `--yes`,让 CLI 进 confirm 阶段;`submit\n` 从 stdin 推给它即可。用户说「取消 / cancel」就把输入命令换成 `node -e "process.stdout.write('cancel\n')"`。用户回复「好了」不能当成提交触发词。
99
113
 
100
114
  该命令会自动检查并恢复登录:凭证有效时直接发布,access token 过期时尝试静默刷新,必须重新授权时输出设备码 `PROMPT`;授权成功后仍由当前进程继续上传和提交,不要另起 `login` 命令。
101
115
 
@@ -109,8 +123,8 @@ printf 'submit\n' | redskillhub-upload publish <absolute-path> --agent \
109
123
  上传入口接受本地 skill 目录或 `.zip` 源包;zip 必须由 CLI 在本地解包、过滤、校验并重新生成上传包,不能由 agent 直接上传或改包。
110
124
 
111
125
  ```bash
112
- redskillhub-upload publish /absolute/path/to/skill --agent --env <ENV>
113
- redskillhub-upload publish /absolute/path/to/skill.zip --agent --env <ENV>
126
+ redskillhub-upload publish "<absolute-path-to-skill>" --agent --env <ENV>
127
+ redskillhub-upload publish "<absolute-path-to-skill.zip>" --agent --env <ENV>
114
128
  ```
115
129
 
116
130
  `redskillhub-upload whoami --env <ENV>`、`redskillhub-upload login --agent --env <ENV>`、`redskillhub-upload login --cancel --env <ENV>` 仅用于诊断、显式预登录或取消等待,不属于标准发布前置步骤。
@@ -118,7 +132,7 @@ redskillhub-upload publish /absolute/path/to/skill.zip --agent --env <ENV>
118
132
  本地验证可使用 dry-run。标签可直接传一个或多个中文名(CLI 内部完成 name→id 映射,多个用逗号分隔),也允许调试时传数字 id:
119
133
 
120
134
  ```bash
121
- node cli/index.mjs publish test/fixtures/minimal-skill --dry-run --agent --source original --tag 效率工具,内容创作 --yes --env beta
135
+ node cli/index.mjs publish "test/fixtures/minimal-skill" --dry-run --agent --source original --tag "效率工具,内容创作" --yes --env beta
122
136
  # 或调试用:--tag-id 1001,1002
123
137
  ```
124
138
 
@@ -64,10 +64,16 @@ export function selectLatestCompatibleVersion(versions, options = {}) {
64
64
  return candidates.at(-1).raw;
65
65
  }
66
66
 
67
+ export function shouldUseSystemShell(platform = process.platform) {
68
+ return platform === 'win32';
69
+ }
70
+
67
71
  function run(command, args, options = {}) {
68
72
  const result = spawnSync(command, args, {
69
73
  encoding: 'utf8',
70
- stdio: options.capture ? 'pipe' : 'inherit'
74
+ stdio: options.capture ? 'pipe' : 'inherit',
75
+ // npm 与全局 CLI 在 Windows 上是 .cmd shim,需要通过系统 shell 启动。
76
+ shell: shouldUseSystemShell(options.platform)
71
77
  });
72
78
  if (result.error) throw result.error;
73
79
  if (!options.allowFailure && result.status !== 0) {