@cnbcool/cnb-api-generate 2.19.0 → 2.20.0
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.
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { printJSON } from './print-json';
|
|
2
2
|
import { summarizeResponse } from './summary-extractors';
|
|
3
|
+
import { normalizeErrorBody } from '../utils/normalize-error-body';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* 精简响应对象(非 verbose 模式使用)
|
|
@@ -42,6 +43,20 @@ export function isStandardResponse(response: any): boolean {
|
|
|
42
43
|
return response && typeof response === 'object' && typeof response.status === 'number' && 'data' in response;
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
/**
|
|
47
|
+
* 失败响应归一化:非 2xx 且响应体是 HTML/纯文本时,换成一句可读原因。
|
|
48
|
+
* 详见 cnb/skills/cnb-skill#562(不再把第三方整页 HTML 打给用户)。
|
|
49
|
+
* 精简/verbose 两条路径共用,保证任何模式下都看不到整页 HTML。
|
|
50
|
+
*/
|
|
51
|
+
function normalizeFailure(response: any): any {
|
|
52
|
+
if (!isStandardResponse(response)) return response;
|
|
53
|
+
|
|
54
|
+
const info = normalizeErrorBody(response.status, response.data, response.contentType);
|
|
55
|
+
if (!info) return response;
|
|
56
|
+
|
|
57
|
+
return { ...response, data: info };
|
|
58
|
+
}
|
|
59
|
+
|
|
45
60
|
/**
|
|
46
61
|
* 格式化 CLI 输出
|
|
47
62
|
* - verbose 模式:完整 JSON(含 trace、header 等全部字段)
|
|
@@ -53,24 +68,27 @@ export function isStandardResponse(response: any): boolean {
|
|
|
53
68
|
* @param toolKey 当前 tool 标识,格式为 "module/tool",用于匹配摘要配置
|
|
54
69
|
*/
|
|
55
70
|
export function formatOutput(response: any, verbose: boolean, summary: boolean, toolKey: string): string {
|
|
56
|
-
//
|
|
71
|
+
// 失败响应先收敛成可读原因,再走各自的输出策略
|
|
72
|
+
const target = normalizeFailure(response);
|
|
73
|
+
|
|
74
|
+
// --verbose 模式:输出完整原始信息(失败响应已归一化,不打印整页 HTML)
|
|
57
75
|
if (verbose) {
|
|
58
|
-
return printJSON(
|
|
76
|
+
return printJSON(target, verbose);
|
|
59
77
|
}
|
|
60
78
|
|
|
61
79
|
// converter 可能返回裸 data(没有标准 {status, data} 结构),直接输出
|
|
62
|
-
if (!isStandardResponse(
|
|
80
|
+
if (!isStandardResponse(target)) {
|
|
63
81
|
if (summary) {
|
|
64
|
-
const summarized = summarizeResponse(
|
|
82
|
+
const summarized = summarizeResponse(target, toolKey);
|
|
65
83
|
if (summarized !== null) {
|
|
66
84
|
return printJSON(summarized);
|
|
67
85
|
}
|
|
68
86
|
}
|
|
69
|
-
return printJSON(
|
|
87
|
+
return printJSON(target);
|
|
70
88
|
}
|
|
71
89
|
|
|
72
90
|
// 精简响应
|
|
73
|
-
const compact = compactResponse(
|
|
91
|
+
const compact = compactResponse(target);
|
|
74
92
|
|
|
75
93
|
// summary 模式:对特定 tool 应用摘要提取(仅成功响应)
|
|
76
94
|
if (summary && compact.status >= 200 && compact.status < 300) {
|
|
@@ -3,7 +3,7 @@ import { trimSummary } from './trim-summary';
|
|
|
3
3
|
import { helpData } from './help-data';
|
|
4
4
|
import { flatOptionsData } from './flat-options-data';
|
|
5
5
|
import { executeAction } from './execute-action';
|
|
6
|
-
import {
|
|
6
|
+
import { getModuleShortcuts, SKILLS_SHORTCUTS, type ShortcutDefinition } from '../shortcuts';
|
|
7
7
|
|
|
8
8
|
interface FlatOption {
|
|
9
9
|
optKey: string;
|
|
@@ -106,16 +106,6 @@ function registerToolOptions(toolCmd: Command, moduleName: string, toolName: str
|
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
/**
|
|
110
|
-
* 获取指定模块的快捷命令列表
|
|
111
|
-
*/
|
|
112
|
-
function getShortcutsForModule(moduleName: string): ShortcutDefinition[] {
|
|
113
|
-
if (moduleName === 'issues') return ISSUE_SHORTCUTS;
|
|
114
|
-
if (moduleName === 'pulls') return PR_SHORTCUTS;
|
|
115
|
-
if (moduleName === 'skills') return SKILLS_SHORTCUTS;
|
|
116
|
-
return [];
|
|
117
|
-
}
|
|
118
|
-
|
|
119
109
|
/**
|
|
120
110
|
* 模块下未匹配到任何子命令时的兜底处理。
|
|
121
111
|
*
|
|
@@ -192,7 +182,7 @@ export function registerModuleCommands(program: Command): void {
|
|
|
192
182
|
|
|
193
183
|
// 为快捷命令注册 Commander 子命令(别名)
|
|
194
184
|
// 复用对应真实 tool 的选项定义,但跳过 path 参数(由环境变量自动注入)
|
|
195
|
-
const shortcuts =
|
|
185
|
+
const shortcuts = getModuleShortcuts(moduleName);
|
|
196
186
|
for (const shortcut of shortcuts) {
|
|
197
187
|
// 如果快捷命令名与真实 tool 名重复,跳过(真实 tool 优先)
|
|
198
188
|
if (registeredNames.has(shortcut.shortName)) continue;
|
package/client/shortcuts.ts
CHANGED
|
@@ -24,7 +24,7 @@ export interface ShortcutDefinition {
|
|
|
24
24
|
custom?: boolean;
|
|
25
25
|
/** 显示用的中文说明 */
|
|
26
26
|
description: string;
|
|
27
|
-
/** 自动注入的 data 参数(如 close/open 的状态值、create 的 work_mode) */
|
|
27
|
+
/** 自动注入的 data 参数(如 close/open 的状态值、create-issue 的 work_mode) */
|
|
28
28
|
autoData?: Record<string, any>;
|
|
29
29
|
/** --data 的使用提示(展示用) */
|
|
30
30
|
dataTip?: string;
|
|
@@ -233,6 +233,17 @@ function buildAutoPath(moduleName: string, repoOnly: boolean): Record<string, st
|
|
|
233
233
|
return { repo, number };
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
+
/**
|
|
237
|
+
* 按模块名取快捷命令列表;非快捷命令模块(含 undefined)返回空数组。
|
|
238
|
+
* 本文件与 lib/register-modules.ts 共用,避免两处各留一份模块名映射。
|
|
239
|
+
*/
|
|
240
|
+
export function getModuleShortcuts(moduleName: string | undefined): ShortcutDefinition[] {
|
|
241
|
+
if (moduleName === 'issues') return ISSUE_SHORTCUTS;
|
|
242
|
+
if (moduleName === 'pulls') return PR_SHORTCUTS;
|
|
243
|
+
if (moduleName === 'skills') return SKILLS_SHORTCUTS;
|
|
244
|
+
return [];
|
|
245
|
+
}
|
|
246
|
+
|
|
236
247
|
/**
|
|
237
248
|
* 尝试将用户输入的 module + tool 解析为快捷命令
|
|
238
249
|
*
|
|
@@ -244,16 +255,8 @@ export function resolveShortcut(
|
|
|
244
255
|
): ResolvedShortcut | null {
|
|
245
256
|
if (!moduleName || !toolName) return null;
|
|
246
257
|
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
: moduleName === 'pulls'
|
|
250
|
-
? PR_SHORTCUTS
|
|
251
|
-
: moduleName === 'skills'
|
|
252
|
-
? SKILLS_SHORTCUTS
|
|
253
|
-
: null;
|
|
254
|
-
if (!shortcuts) return null;
|
|
255
|
-
|
|
256
|
-
const matched = shortcuts.find((s) => s.shortName === toolName);
|
|
258
|
+
const matched = getModuleShortcuts(moduleName)
|
|
259
|
+
.find((s) => s.shortName === toolName);
|
|
257
260
|
if (!matched) return null;
|
|
258
261
|
|
|
259
262
|
let realTool = matched.realTool;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 失败响应归一化(见 cnb/skills/cnb-skill#562)
|
|
3
|
+
*
|
|
4
|
+
* 问题:网关/反向代理出错时返回的是一整页 HTML(502 Bad Gateway、登录页等),
|
|
5
|
+
* 而 `text/html` 命中 core.ts 的 isText 分支,原样进入 response.data,
|
|
6
|
+
* 于是 CLI 把整页 HTML 打印给用户——几百行噪音,看不到失败原因。
|
|
7
|
+
*
|
|
8
|
+
* 策略:仅当 HTTP 非 2xx 且响应体不是 JSON 时,把正文换成一句可读原因。
|
|
9
|
+
* - 结构化的 JSON 错误体(errmsg/message/...)原样保留,不丢后端信息
|
|
10
|
+
* - 成功响应完全不动
|
|
11
|
+
* - HTML 里能认出来的标题(<title>、<h1>)会带上,通常是 "502 Bad Gateway" 这类关键信息
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** 归一化后放进 data 的结构 */
|
|
15
|
+
export interface ApiErrorInfo {
|
|
16
|
+
/** 一句话可读原因:失败原因 + 状态码 */
|
|
17
|
+
message: string;
|
|
18
|
+
/** 从 HTML 标题里认出来的站点提示,如 "502 Bad Gateway" */
|
|
19
|
+
detail?: string;
|
|
20
|
+
/** 原始正文类型,帮助判断是谁挡在了前面 */
|
|
21
|
+
contentType?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const MAX_DETAIL_LENGTH = 120;
|
|
25
|
+
|
|
26
|
+
/** 原始响应体的最大保留长度(兜底截断,避免整页灌进上下文) */
|
|
27
|
+
const MAX_RAW_LENGTH = 300;
|
|
28
|
+
|
|
29
|
+
/** 常见网关/服务错误页的状态码描述,兜底用 */
|
|
30
|
+
const STATUS_TEXT: Record<number, string> = {
|
|
31
|
+
400: '请求参数不合法',
|
|
32
|
+
401: '未登录或登录态已失效',
|
|
33
|
+
403: '没有操作权限',
|
|
34
|
+
404: '资源不存在',
|
|
35
|
+
405: '接口不支持该请求方法',
|
|
36
|
+
408: '请求超时',
|
|
37
|
+
413: '请求体过大',
|
|
38
|
+
429: '请求过于频繁,请稍后重试',
|
|
39
|
+
500: '服务端内部错误',
|
|
40
|
+
502: '网关错误,上游服务不可用',
|
|
41
|
+
503: '服务暂不可用',
|
|
42
|
+
504: '网关超时,上游服务未响应',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function isHtml(contentType: string | undefined, text: string): boolean {
|
|
46
|
+
const type = (contentType || '').toLowerCase();
|
|
47
|
+
if (type.includes('text/html') || type.includes('application/xhtml')) return true;
|
|
48
|
+
return /^\s*(<!doctype\s+html|<html[\s>])/i.test(text);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function decodeEntities(raw: string): string {
|
|
52
|
+
return raw
|
|
53
|
+
.replace(/ /gi, ' ')
|
|
54
|
+
.replace(/</gi, '<')
|
|
55
|
+
.replace(/>/gi, '>')
|
|
56
|
+
.replace(/"/gi, '"')
|
|
57
|
+
.replace(/'/gi, "'")
|
|
58
|
+
.replace(/&/gi, '&');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function squeeze(raw: string): string {
|
|
62
|
+
return decodeEntities(raw.replace(/<[^>]*>/g, ' ')).replace(/\s+/g, ' ').trim();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 从 HTML 中提取标题与正文首段,作为失败原因的补充 */
|
|
66
|
+
export function extractHtmlHint(html: string): string | undefined {
|
|
67
|
+
const title = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1];
|
|
68
|
+
const h1 = html.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i)?.[1];
|
|
69
|
+
const center = html.match(/<center[^>]*>([\s\S]*?)<\/center>/i)?.[1];
|
|
70
|
+
|
|
71
|
+
const candidates = [title, h1, center, html.replace(/<(script|style)[\s\S]*?<\/\1>/gi, ' ')]
|
|
72
|
+
.map((item) => (item ? squeeze(item) : ''))
|
|
73
|
+
.filter(Boolean);
|
|
74
|
+
|
|
75
|
+
const hint = candidates.find((item) => item.length > 0);
|
|
76
|
+
if (!hint) return undefined;
|
|
77
|
+
return hint.length > MAX_DETAIL_LENGTH ? `${hint.slice(0, MAX_DETAIL_LENGTH)}…` : hint;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function looksLikeStructuredJson(text: string): boolean {
|
|
81
|
+
const trimmed = text.trim();
|
|
82
|
+
if (!/^[[{]/.test(trimmed)) return false;
|
|
83
|
+
try {
|
|
84
|
+
const parsed = JSON.parse(trimmed);
|
|
85
|
+
return !!parsed && typeof parsed === 'object';
|
|
86
|
+
} catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 非 JSON 的正文统一收敛成短字符串:
|
|
93
|
+
* - HTML 只保留能认出来的标题
|
|
94
|
+
* - 其它文本(纯文本、XML、截断的 JSON)压成一行并截断
|
|
95
|
+
*/
|
|
96
|
+
function shortenRawBody(text: string, contentType: string | undefined): { detail?: string; body: string } {
|
|
97
|
+
if (isHtml(contentType, text) || /\[cnb-api\] formatResponse error/i.test(text)) {
|
|
98
|
+
return { detail: extractHtmlHint(text), body: '' };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const flat = squeeze(text);
|
|
102
|
+
if (!flat) return { body: '' };
|
|
103
|
+
return { body: flat.length > MAX_RAW_LENGTH ? `${flat.slice(0, MAX_RAW_LENGTH)}…` : flat };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function describeStatus(status: number): string {
|
|
107
|
+
return STATUS_TEXT[status] || (status >= 500 ? '服务端错误' : '请求失败');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 响应体是不是结构体(string/number/boolean 之外的都算)。
|
|
112
|
+
* 结构体说明后端已给出机器可读错误,原样保留;null 视为「没有错误体」。
|
|
113
|
+
*/
|
|
114
|
+
function isStructuredBody(data: unknown): boolean {
|
|
115
|
+
if (data === null) return true;
|
|
116
|
+
return typeof data === 'object';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 归一化一次失败响应。
|
|
121
|
+
*
|
|
122
|
+
* @param status HTTP 状态码(拿不到时不处理,返回 null)
|
|
123
|
+
* @param data 原始响应体
|
|
124
|
+
* @param contentType 响应 Content-Type
|
|
125
|
+
* @returns 可读的错误数据;成功响应或结构化错误体返回 null(保持原样)
|
|
126
|
+
*/
|
|
127
|
+
export function normalizeErrorBody(
|
|
128
|
+
status: number | undefined,
|
|
129
|
+
data: unknown,
|
|
130
|
+
contentType?: string,
|
|
131
|
+
): ApiErrorInfo | null {
|
|
132
|
+
if (typeof status !== 'number' || (status >= 200 && status < 300)) return null;
|
|
133
|
+
if (isStructuredBody(data)) return null;
|
|
134
|
+
|
|
135
|
+
const text = typeof data === 'string' ? data : '';
|
|
136
|
+
|
|
137
|
+
// 文本其实是 JSON 错误体(只是被当成 text/* 处理了),交回原始数据,别覆盖
|
|
138
|
+
if (text && looksLikeStructuredJson(text)) return null;
|
|
139
|
+
|
|
140
|
+
const { detail, body } = shortenRawBody(text, contentType);
|
|
141
|
+
const reason = detail || body;
|
|
142
|
+
const label = `${status} ${describeStatus(status)}`;
|
|
143
|
+
|
|
144
|
+
const info: ApiErrorInfo = {
|
|
145
|
+
message: reason ? `${label}:${reason}` : label,
|
|
146
|
+
};
|
|
147
|
+
if (detail && body && body !== detail) info.detail = body;
|
|
148
|
+
if (contentType) info.contentType = contentType;
|
|
149
|
+
|
|
150
|
+
return info;
|
|
151
|
+
}
|
package/package.json
CHANGED
package/quick-commands.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"issues": [
|
|
3
|
-
{ "shortName": "create",
|
|
3
|
+
{ "shortName": "create-issue", "realTool": "create-issue", "description": "创建 issue", "autoData": { "work_mode": true }, "hideInSkill": true, "dataTip": "--title 标题" },
|
|
4
4
|
{ "shortName": "get", "realTool": "get-issue", "description": "获取详情" },
|
|
5
5
|
{ "shortName": "list-comments", "realTool": "list-issue-comments", "description": "获取评论列表" },
|
|
6
6
|
{ "shortName": "comment", "realTool": "post-issue-comment", "description": "评论", "dataTip": "--body 内容" },
|
package/skills-template/SKILL.md
CHANGED
|
@@ -13,8 +13,6 @@ description: CNB 平台交互命令,支持代码仓库、Issue、PR、CI、制
|
|
|
13
13
|
|
|
14
14
|
- **默认摘要省流**:默认精简输出,加 `--verbose` 输出完整数据。
|
|
15
15
|
- **多行用单引号**:bash 参数为多行文本时用单引号,降低命令注入风险。
|
|
16
|
-
- **创建 issue 默认带 `--work-mode`**:`cnb issues create` 已默认开启工作模式,不用再显式传。
|
|
17
|
-
用真实 tool 名 `cnb issues create-issue` 时则需自己加 `--work-mode`,否则创建出来的是普通 issue。
|
|
18
16
|
|
|
19
17
|
## issue/pr 访问说明
|
|
20
18
|
|