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 +16 -4
- package/cli/auth-prompt.mjs +2 -1
- package/cli/auth.mjs +34 -34
- package/cli/config.mjs +15 -10
- package/cli/index.mjs +18 -8
- package/cli/pack.mjs +7 -6
- package/cli/tags.mjs +4 -4
- package/package.json +1 -1
- package/skill/SKILL.md +27 -13
- package/skill/scripts/ensure-cli.mjs +7 -1
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
|
|
64
|
-
redskillhub-upload publish /absolute/path/to/skill --
|
|
65
|
-
redskillhub-upload publish /absolute/path/to/skill
|
|
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
|
|
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:
|
package/cli/auth-prompt.mjs
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
|
32
|
-
|
|
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
|
|
36
|
-
return path.join(
|
|
36
|
+
export function getCredentialsPath(env = process.env, flags = {}) {
|
|
37
|
+
return path.join(getEnvironmentHomeDir(env, flags), 'credentials.json');
|
|
37
38
|
}
|
|
38
39
|
|
|
39
|
-
export function
|
|
40
|
-
return path.join(
|
|
40
|
+
export function getTmpDir(env = process.env, flags = {}) {
|
|
41
|
+
return path.join(getEnvironmentHomeDir(env, flags), 'tmp');
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
export function
|
|
44
|
-
return path.join(
|
|
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
|
|
48
|
-
return path.join(
|
|
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
|
-
|
|
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
|
|
148
|
+
export function getContentTagsHomeDir(env = process.env, flags = {}) {
|
|
149
|
+
return getEnvironmentHomeDir(env, flags);
|
|
150
150
|
}
|
package/package.json
CHANGED
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
|
|
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
|
-
|
|
79
|
+
redskillhub-upload tags --env <ENV>
|
|
64
80
|
```
|
|
65
81
|
|
|
66
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
113
|
-
redskillhub-upload publish
|
|
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) {
|