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 +24 -11
- package/cli/auth-prompt.mjs +16 -5
- package/cli/auth.mjs +23 -6
- package/cli/config.mjs +15 -3
- package/cli/fetch.mjs +5 -5
- package/cli/index.mjs +10 -3
- package/cli/submit.mjs +1 -1
- package/cli/tags.mjs +4 -3
- package/cli/upload.mjs +1 -1
- package/package.json +1 -1
- package/skill/SKILL.md +30 -14
- package/skill/agents/openai.yaml +4 -0
- package/skill/scripts/ensure-cli.mjs +168 -0
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
|
|
29
|
-
- `
|
|
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
|
|
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
|
package/cli/auth-prompt.mjs
CHANGED
|
@@ -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,
|
|
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({ ...
|
|
37
|
+
writePrompt({ ...promptPayload, qrCodePath }, stream);
|
|
26
38
|
return;
|
|
27
39
|
}
|
|
28
40
|
|
|
29
|
-
const qr = await qrCode.toString(
|
|
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(`授权链接:${
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
50
|
-
|
|
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 {
|
|
4
|
+
import { BETA_API_BASE } from './config.mjs';
|
|
5
5
|
|
|
6
|
-
const
|
|
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 ===
|
|
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 ===
|
|
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 ===
|
|
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
package/skill/SKILL.md
CHANGED
|
@@ -1,21 +1,37 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: redskillhub-upload
|
|
3
|
-
description:
|
|
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
|
-
|
|
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.
|
|
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`
|
|
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
|
|
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
|
|
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
|
|
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,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
|
+
}
|