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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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
@@ -29,6 +31,7 @@ All paths and hosts are centralized in `cli/config.mjs` (`PATHS` + `DEFAULT_API_
29
31
  - `BETA_API_BASE` = `https://edith.beta.xiaohongshu.com` — beta 业务接口
30
32
  - `DEFAULT_OAS_BASE` = `https://openaccount.xiaohongshu.com` — OAuth2 开放平台接口
31
33
  - 所有命令都支持 `--env prod|beta`,默认 `prod`;`--env beta` 时业务接口使用 `BETA_API_BASE`
34
+ - prod 沿用 `~/.skillhub-upload`,beta 使用 `~/.skillhub-upload/beta`,凭证、待授权、二维码、标签缓存和临时包相互隔离
32
35
  - 业务接口 host 可通过 `--api-base` 参数或 `SKILLHUB_UPLOAD_API_BASE` 环境变量覆盖
33
36
  - OAS 换 token 默认使用内置应用凭证;可通过 `SKILLHUB_UPLOAD_APP_SECRET` 覆盖
34
37
 
@@ -60,9 +63,10 @@ Dry-run examples may use numeric tag IDs because the backend contract accepts `c
60
63
  redskillhub-upload login --env prod
61
64
  redskillhub-upload login --agent --env prod
62
65
  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 tags --env prod
67
+ redskillhub-upload publish "/absolute/path/to/skill" --env prod
68
+ redskillhub-upload publish "/absolute/path/to/skill" --agent --env prod
69
+ redskillhub-upload publish "/absolute/path/to/skill.zip" --agent --env prod
66
70
  redskillhub-upload whoami --env prod
67
71
  redskillhub-upload logout --env prod
68
72
  ```
@@ -71,11 +75,19 @@ redskillhub-upload logout --env prod
71
75
 
72
76
  ```bash
73
77
  redskillhub-upload login --agent --env beta
74
- redskillhub-upload publish /absolute/path/to/skill --agent --env beta
78
+ redskillhub-upload tags --env beta
79
+ redskillhub-upload publish "/absolute/path/to/skill" --agent --env beta
75
80
  redskillhub-upload whoami --env beta
76
81
  redskillhub-upload logout --env beta
77
82
  ```
78
83
 
84
+ Windows 路径示例:
85
+
86
+ ```powershell
87
+ redskillhub-upload publish "C:\Users\name\path\to\skill" --agent --env prod
88
+ node -e "process.stdout.write('submit\n')" | redskillhub-upload publish "C:\Users\name\path\to\skill" --agent --source original --tag "效率工具" --env prod
89
+ ```
90
+
79
91
  ## Agent Protocol
80
92
 
81
93
  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
@@ -12,7 +12,7 @@ import {
12
12
  TOKEN_REFRESH_BUFFER_MS,
13
13
  getAuthQrPath,
14
14
  getCredentialsPath,
15
- getHomeDir,
15
+ getEnvironmentHomeDir,
16
16
  getPendingAuthPath,
17
17
  resolveApiBase
18
18
  } from './config.mjs';
@@ -46,9 +46,9 @@ function canRefreshCredentials(credentials, nowMs = Date.now()) {
46
46
  return Number.isFinite(refreshExpireTimeMs) && refreshExpireTimeMs > nowMs + TOKEN_REFRESH_BUFFER_MS;
47
47
  }
48
48
 
49
- export async function readCredentials(env = process.env) {
49
+ export async function readCredentials(env = process.env, flags = {}) {
50
50
  try {
51
- const file = getCredentialsPath(env);
51
+ const file = getCredentialsPath(env, flags);
52
52
  await fs.chmod(file, 0o600).catch(() => {});
53
53
  return JSON.parse(await fs.readFile(file, 'utf8'));
54
54
  } catch (error) {
@@ -57,9 +57,9 @@ export async function readCredentials(env = process.env) {
57
57
  }
58
58
  }
59
59
 
60
- export async function writeCredentials(credentials, env = process.env) {
61
- const home = getHomeDir(env);
62
- const file = getCredentialsPath(env);
60
+ export async function writeCredentials(credentials, env = process.env, flags = {}) {
61
+ const home = getEnvironmentHomeDir(env, flags);
62
+ const file = getCredentialsPath(env, flags);
63
63
  const tmp = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${crypto.randomUUID()}.tmp`);
64
64
  await fs.mkdir(home, { recursive: true, mode: 0o700 });
65
65
  try {
@@ -73,8 +73,8 @@ export async function writeCredentials(credentials, env = process.env) {
73
73
  }
74
74
  }
75
75
 
76
- export async function removeCredentials(env = process.env) {
77
- await fs.rm(getCredentialsPath(env), { force: true });
76
+ export async function removeCredentials(env = process.env, flags = {}) {
77
+ await fs.rm(getCredentialsPath(env, flags), { force: true });
78
78
  }
79
79
 
80
80
  export function maskCredentials(credentials) {
@@ -306,9 +306,9 @@ function buildCredentialsFromToken(token, nowMs, errorCode) {
306
306
  return credentials;
307
307
  }
308
308
 
309
- async function readPendingAuth(env, now) {
309
+ async function readPendingAuth(env, flags, now) {
310
310
  try {
311
- const raw = JSON.parse(await fs.readFile(getPendingAuthPath(env), 'utf8'));
311
+ const raw = JSON.parse(await fs.readFile(getPendingAuthPath(env, flags), 'utf8'));
312
312
  if (raw?.expiresAtMs && raw.expiresAtMs > now() && raw.deviceCode && raw.codeVerifier) {
313
313
  return raw;
314
314
  }
@@ -318,26 +318,26 @@ async function readPendingAuth(env, now) {
318
318
  return null;
319
319
  }
320
320
 
321
- async function writePendingAuth(env, state) {
322
- const file = getPendingAuthPath(env);
321
+ async function writePendingAuth(env, flags, state) {
322
+ const file = getPendingAuthPath(env, flags);
323
323
  await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
324
324
  await fs.writeFile(file, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
325
325
  }
326
326
 
327
- async function clearPendingAuth(env) {
328
- await fs.rm(getPendingAuthPath(env), { force: true });
329
- await fs.rm(getAuthQrPath(env), { force: true });
327
+ async function clearPendingAuth(env, flags) {
328
+ await fs.rm(getPendingAuthPath(env, flags), { force: true });
329
+ await fs.rm(getAuthQrPath(env, flags), { force: true });
330
330
  }
331
331
 
332
- export async function cancelLogin(env = process.env) {
332
+ export async function cancelLogin(env = process.env, flags = {}) {
333
333
  let hadPending = true;
334
334
  try {
335
- await fs.access(getPendingAuthPath(env));
335
+ await fs.access(getPendingAuthPath(env, flags));
336
336
  } catch (error) {
337
337
  if (error.code !== 'ENOENT') throw error;
338
338
  hadPending = false;
339
339
  }
340
- await clearPendingAuth(env);
340
+ await clearPendingAuth(env, flags);
341
341
  return { hadPending };
342
342
  }
343
343
 
@@ -349,7 +349,7 @@ export async function login({
349
349
  now = () => Date.now(),
350
350
  promptStream = process.stdout
351
351
  } = {}) {
352
- const cachedCredentials = await readCredentials(env);
352
+ const cachedCredentials = await readCredentials(env, flags);
353
353
  if (isValidCredentials(cachedCredentials, now())) {
354
354
  return maskCredentials(cachedCredentials);
355
355
  }
@@ -365,8 +365,8 @@ export async function login({
365
365
  tokenUrl: flags.tokenUrl || `${DEFAULT_OAS_BASE}${PATHS.OAS_REFRESH_TOKEN}`,
366
366
  fetchImpl
367
367
  });
368
- await writeCredentials(buildCredentialsFromToken(token, now(), 'OAS_TOKEN_REFRESH_INVALID_RESPONSE'), env);
369
- return maskCredentials(await readCredentials(env));
368
+ await writeCredentials(buildCredentialsFromToken(token, now(), 'OAS_TOKEN_REFRESH_INVALID_RESPONSE'), env, flags);
369
+ return maskCredentials(await readCredentials(env, flags));
370
370
  } catch (error) {
371
371
  if (error instanceof SkillhubUploadError) {
372
372
  // 刷新失败,降级走设备码
@@ -379,7 +379,7 @@ export async function login({
379
379
  const apiBase = resolveApiBase(flags, env);
380
380
 
381
381
  // 从磁盘恢复未完成的授权状态,避免进程重启后要求用户重新授权
382
- let pending = await readPendingAuth(env, now);
382
+ let pending = await readPendingAuth(env, flags, now);
383
383
  let codeVerifier;
384
384
  if (pending) {
385
385
  codeVerifier = pending.codeVerifier;
@@ -390,12 +390,12 @@ export async function login({
390
390
  userCode: pending.userCode,
391
391
  expiresInSeconds: Math.round((pending.expiresAtMs - now()) / 1000),
392
392
  message: '检测到未完成的授权,继续等待中(无需重新打开授权链接)'
393
- }, { agent: Boolean(flags.agent), env, stream: promptStream });
393
+ }, { agent: Boolean(flags.agent), env, flags, stream: promptStream });
394
394
  } else {
395
395
  const pkce = await createPkcePair();
396
396
  codeVerifier = pkce.codeVerifier;
397
397
  const device = await requestDeviceCode({ apiBase, pkce, fetchImpl });
398
- await writePendingAuth(env, {
398
+ await writePendingAuth(env, flags, {
399
399
  deviceCode: device.deviceCode,
400
400
  codeVerifier: pkce.codeVerifier,
401
401
  authorizeCommonUrl: device.authorizeCommonUrl,
@@ -405,7 +405,7 @@ export async function login({
405
405
  pollIntervalSeconds: device.pollIntervalSeconds || 5,
406
406
  expiresAtMs: now() + (device.expiresInSeconds || 600) * 1000
407
407
  });
408
- pending = await readPendingAuth(env, now);
408
+ pending = await readPendingAuth(env, flags, now);
409
409
  await renderDeviceAuthPrompt({
410
410
  type: 'auth_device_code',
411
411
  authorizeCommonUrl: device.authorizeCommonUrl,
@@ -413,7 +413,7 @@ export async function login({
413
413
  userCode: device.userCode,
414
414
  expiresInSeconds: device.expiresInSeconds || 600,
415
415
  message: '请用手机自带浏览器打开授权链接,页面会自动跳转至小红书 App 授权;授权页输入授权码后 CLI 会自动轮询'
416
- }, { agent: Boolean(flags.agent), env, stream: promptStream });
416
+ }, { agent: Boolean(flags.agent), env, flags, stream: promptStream });
417
417
  }
418
418
 
419
419
  const deadline = pending.expiresAtMs;
@@ -422,7 +422,7 @@ export async function login({
422
422
  while (now() < deadline) {
423
423
  await sleep((pending.pollIntervalSeconds || 5) * 1000);
424
424
  // 这里只检测跨进程取消,不把自然过期误判为取消。
425
- const activePending = await readPendingAuth(env, () => 0);
425
+ const activePending = await readPendingAuth(env, flags, () => 0);
426
426
  if (!activePending || activePending.deviceCode !== pending.deviceCode) {
427
427
  throw new SkillhubUploadError(
428
428
  'AUTH_CANCELLED',
@@ -437,21 +437,21 @@ export async function login({
437
437
  break;
438
438
  }
439
439
  if (authStatus === 'expired') {
440
- await clearPendingAuth(env);
440
+ await clearPendingAuth(env, flags);
441
441
  throw new SkillhubUploadError('AUTH_TIMEOUT', '授权超时,请重新发起', ExitCodes.AUTH);
442
442
  }
443
443
  if (authStatus === 'cancelled' || authStatus === 'denied' || authStatus === 'rejected') {
444
- await clearPendingAuth(env);
444
+ await clearPendingAuth(env, flags);
445
445
  throw new SkillhubUploadError('AUTH_DENIED', '用户拒绝授权', ExitCodes.AUTH);
446
446
  }
447
447
  }
448
448
  if (!authorizationCode) {
449
- await clearPendingAuth(env);
449
+ await clearPendingAuth(env, flags);
450
450
  throw new SkillhubUploadError('AUTH_TIMEOUT', '授权超时,请重新发起', ExitCodes.AUTH);
451
451
  }
452
452
  } catch (error) {
453
453
  if (!(error instanceof SkillhubUploadError)) {
454
- await clearPendingAuth(env);
454
+ await clearPendingAuth(env, flags);
455
455
  }
456
456
  throw error;
457
457
  }
@@ -464,7 +464,7 @@ export async function login({
464
464
  tokenUrl: flags.tokenUrl || `${DEFAULT_OAS_BASE}${PATHS.OAS_ACCESS_TOKEN}`,
465
465
  fetchImpl
466
466
  });
467
- await writeCredentials(buildCredentialsFromToken(token, now(), 'OAS_TOKEN_EXCHANGE_INVALID_RESPONSE'), env);
468
- await clearPendingAuth(env);
469
- return maskCredentials(await readCredentials(env));
467
+ await writeCredentials(buildCredentialsFromToken(token, now(), 'OAS_TOKEN_EXCHANGE_INVALID_RESPONSE'), env, flags);
468
+ await clearPendingAuth(env, flags);
469
+ return maskCredentials(await readCredentials(env, flags));
470
470
  }
package/cli/config.mjs CHANGED
@@ -28,24 +28,29 @@ export function getHomeDir(env = process.env) {
28
28
  return env.SKILLHUB_UPLOAD_HOME || path.join(os.homedir(), '.skillhub-upload');
29
29
  }
30
30
 
31
- export function getCredentialsPath(env = process.env) {
32
- return path.join(getHomeDir(env), 'credentials.json');
31
+ export function getEnvironmentHomeDir(env = process.env, flags = {}) {
32
+ const home = getHomeDir(env);
33
+ return resolveApiEnvironment(flags) === 'beta' ? path.join(home, 'beta') : home;
33
34
  }
34
35
 
35
- export function getTmpDir(env = process.env) {
36
- return path.join(getHomeDir(env), 'tmp');
36
+ export function getCredentialsPath(env = process.env, flags = {}) {
37
+ return path.join(getEnvironmentHomeDir(env, flags), 'credentials.json');
37
38
  }
38
39
 
39
- export function getContentTagCachePath(env = process.env) {
40
- return path.join(getHomeDir(env), 'content-tags.json');
40
+ export function getTmpDir(env = process.env, flags = {}) {
41
+ return path.join(getEnvironmentHomeDir(env, flags), 'tmp');
41
42
  }
42
43
 
43
- export function getPendingAuthPath(env = process.env) {
44
- return path.join(getHomeDir(env), 'pending-auth.json');
44
+ export function getContentTagCachePath(env = process.env, flags = {}) {
45
+ return path.join(getEnvironmentHomeDir(env, flags), 'content-tags.json');
45
46
  }
46
47
 
47
- export function getAuthQrPath(env = process.env) {
48
- return path.join(getHomeDir(env), 'auth-qr.png');
48
+ export function getPendingAuthPath(env = process.env, flags = {}) {
49
+ return path.join(getEnvironmentHomeDir(env, flags), 'pending-auth.json');
50
+ }
51
+
52
+ export function getAuthQrPath(env = process.env, flags = {}) {
53
+ return path.join(getEnvironmentHomeDir(env, flags), 'auth-qr.png');
49
54
  }
50
55
 
51
56
  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.5",
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) {