dsh-plugin-yapi 0.0.0-stage → 1.4.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.
@@ -0,0 +1,863 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * yapi-doc — read YAPI interface documentation from the terminal.
4
+ *
5
+ * Standalone on purpose: no plugin imports, no npm dependencies. Copy this one
6
+ * file anywhere and run it with Node 20+. It reads the YAPI account from
7
+ * `$DSH_HOME/dsh-plugin-yapi.json` (the panel's 账号设置 writes it) or from
8
+ * YAPI_BASE_URL / YAPI_EMAIL / YAPI_PASSWORD.
9
+ *
10
+ * Usage:
11
+ * yapi-doc 10001
12
+ * yapi-doc https://your-yapi.example.com/project/1/interface/api/10001
13
+ * yapi-doc --search warehouse --project 1
14
+ * yapi-doc 10001 --full # include the response body example
15
+ * yapi-doc 10001 --json # structured output
16
+ * yapi-doc 10001 --no-fields # skip field documentation
17
+ * yapi-doc install-skill # install SKILL.md + CLI into ~/.agents/skills/yapi-doc
18
+ * yapi-doc sync-skills # install into every detected tool skill root
19
+ */
20
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
21
+ import { homedir } from 'node:os'
22
+ import { dirname, join, resolve } from 'node:path'
23
+ import { fileURLToPath } from 'node:url'
24
+
25
+ const DSH_HOME = process.env.DSH_HOME || join(homedir(), '.dsh')
26
+ const CONFIG_FILE = join(DSH_HOME, 'dsh-plugin-yapi.json')
27
+
28
+ /* ------------------------------------------------------------------ */
29
+ /* helpers */
30
+ /* ------------------------------------------------------------------ */
31
+
32
+ function normalizeBaseUrl(value) {
33
+ return String(value === undefined || value === null ? '' : value).trim().replace(/\/+$/u, '')
34
+ }
35
+
36
+ function joinUrl() {
37
+ let out = ''
38
+ for (let index = 0; index < arguments.length; index += 1) {
39
+ const text = String(arguments[index] === undefined || arguments[index] === null ? '' : arguments[index]).trim()
40
+ if (text === '') continue
41
+ if (out === '') out = text.replace(/\/+$/u, '')
42
+ else out += '/' + text.replace(/^\/+/u, '').replace(/\/+$/u, '')
43
+ }
44
+ return out
45
+ }
46
+
47
+ function parseDocId(value) {
48
+ if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0) return value
49
+ const text = String(value === undefined || value === null ? '' : value).trim()
50
+ if (text === '') return null
51
+ if (/^\d{1,12}$/u.test(text)) return Number(text) > 0 ? Number(text) : null
52
+ const patterns = [/\/interface\/api\/(\d{1,12})(?:[/?#]|$)/u, /[?&]id=(\d{1,12})(?:[&#]|$)/u]
53
+ for (const pattern of patterns) {
54
+ const match = pattern.exec(text)
55
+ if (match && Number(match[1]) > 0) return Number(match[1])
56
+ }
57
+ const trailing = /(\d{1,12})\/?(?:[?#].*)?$/u.exec(text)
58
+ return trailing && Number(trailing[1]) > 0 ? Number(trailing[1]) : null
59
+ }
60
+
61
+ function stripHtml(html) {
62
+ return String(html === undefined || html === null ? '' : html)
63
+ .replace(/<br\s*\/?>/giu, '\n')
64
+ .replace(/<\/(?:p|div|li|h[1-6]|tr)>/giu, '\n')
65
+ .replace(/<[^>]+>/gu, '')
66
+ .replace(/&nbsp;/giu, ' ')
67
+ .replace(/&lt;/giu, '<')
68
+ .replace(/&gt;/giu, '>')
69
+ .replace(/&amp;/giu, '&')
70
+ .replace(/&quot;/giu, '"')
71
+ .replace(/&#39;/giu, "'")
72
+ .replace(/\n{3,}/gu, '\n\n')
73
+ .trim()
74
+ }
75
+
76
+ function parseJson(text) {
77
+ if (typeof text !== 'string' || text.trim() === '') return undefined
78
+ try {
79
+ return JSON.parse(text)
80
+ } catch {
81
+ return undefined
82
+ }
83
+ }
84
+
85
+ function unwrapComposite(input) {
86
+ let current = input && typeof input === 'object' ? input : {}
87
+ for (let round = 0; round < 8; round += 1) {
88
+ if (!current || typeof current !== 'object') return {}
89
+ if (Array.isArray(current.allOf) && current.allOf.length > 0) {
90
+ const merged = Object.assign({}, current)
91
+ delete merged.allOf
92
+ const properties = Object.assign({}, current.properties || {})
93
+ for (const member of current.allOf) {
94
+ if (member && typeof member === 'object' && member.properties) Object.assign(properties, member.properties)
95
+ }
96
+ merged.properties = properties
97
+ merged.type = merged.type || 'object'
98
+ return merged
99
+ }
100
+ const list = Array.isArray(current.oneOf) ? current.oneOf : Array.isArray(current.anyOf) ? current.anyOf : null
101
+ if (list && list.length > 0 && list[0] && typeof list[0] === 'object') {
102
+ current = list[0]
103
+ continue
104
+ }
105
+ return current
106
+ }
107
+ return current && typeof current === 'object' ? current : {}
108
+ }
109
+
110
+ function schemaComment(node) {
111
+ const description = unwrapComposite(node).description
112
+ return String(description === undefined || description === null ? '' : description)
113
+ .replace(/\s+/g, ' ')
114
+ .trim()
115
+ }
116
+
117
+ function typeLabel(node) {
118
+ const schema = unwrapComposite(node)
119
+ let type = schema.type
120
+ if (Array.isArray(type)) type = type.filter((item) => item !== 'null').join(' | ') || 'null'
121
+ if (type === 'array') {
122
+ const items = Array.isArray(schema.items) ? schema.items[0] : schema.items
123
+ const inner = items ? typeLabel(items) : ''
124
+ return inner ? 'array<' + inner + '>' : 'array'
125
+ }
126
+ if (!type && schema.properties) return 'object'
127
+ return type || ''
128
+ }
129
+
130
+ function exampleFromSchema(input, depth = 0) {
131
+ if (depth > 40) return null
132
+ if (!input || typeof input !== 'object') return null
133
+ const schema = unwrapComposite(input)
134
+ if (schema.example !== undefined) return schema.example
135
+ if (schema.default !== undefined) return schema.default
136
+ let type = schema.type
137
+ if (Array.isArray(type)) type = type.find((item) => item !== 'null') || 'null'
138
+ if (schema.enum && schema.enum.length > 0) return schema.enum[0]
139
+ if (type === 'object' || schema.properties) {
140
+ const out = {}
141
+ for (const [key, child] of Object.entries(schema.properties || {})) out[key] = exampleFromSchema(child, depth + 1)
142
+ return out
143
+ }
144
+ if (type === 'array') {
145
+ const items = Array.isArray(schema.items) ? schema.items[0] : schema.items
146
+ return items ? [exampleFromSchema(items, depth + 1)] : []
147
+ }
148
+ if (type === 'integer' || type === 'number') return 0
149
+ if (type === 'boolean') return false
150
+ if (type === 'string') {
151
+ if (schema.format === 'date-time') return '2024-01-01T00:00:00Z'
152
+ if (schema.format === 'date') return '2024-01-01'
153
+ return ''
154
+ }
155
+ return null
156
+ }
157
+
158
+ function isSubsequence(needle, haystack) {
159
+ let index = 0
160
+ for (let cursor = 0; cursor < haystack.length && index < needle.length; cursor += 1) {
161
+ if (haystack[cursor] === needle[index]) index += 1
162
+ }
163
+ return index === needle.length
164
+ }
165
+
166
+ function rankInterfaces(list, keyword, limit = 30) {
167
+ const query = String(keyword === undefined || keyword === null ? '' : keyword).trim().toLowerCase()
168
+ if (query === '') return []
169
+ const tokens = query.split(/\s+/u).filter(Boolean)
170
+ const ranked = []
171
+ for (const item of Array.isArray(list) ? list : []) {
172
+ if (!item || typeof item !== 'object') continue
173
+ const id = String(item._id ?? '')
174
+ const path = String(item.path ?? '')
175
+ const title = String(item.title ?? '')
176
+ const lowerPath = path.toLowerCase()
177
+ const lowerTitle = title.toLowerCase()
178
+ let score = 0
179
+ if (id === query) score = 1200
180
+ else if (lowerPath === query) score = 1100
181
+ else if (lowerPath.startsWith(query)) score = 1000
182
+ else {
183
+ const pathAt = lowerPath.indexOf(query)
184
+ if (pathAt !== -1) score = 900 - Math.min(pathAt, 80)
185
+ else {
186
+ const titleAt = lowerTitle.indexOf(query)
187
+ if (titleAt !== -1) score = 700 - Math.min(titleAt, 80)
188
+ else if (tokens.length > 1 && tokens.every((token) => `${lowerPath} ${lowerTitle}`.includes(token))) score = 500
189
+ else if (query.length >= 2 && isSubsequence(query, lowerPath)) score = 400
190
+ else if (query.length >= 2 && isSubsequence(query, lowerTitle)) score = 300
191
+ }
192
+ }
193
+ if (score > 0) ranked.push({ item, score, path: lowerPath })
194
+ }
195
+ ranked.sort(
196
+ (left, right) =>
197
+ right.score - left.score ||
198
+ left.path.length - right.path.length ||
199
+ left.path.localeCompare(right.path) ||
200
+ String(left.item._id).localeCompare(String(right.item._id)),
201
+ )
202
+ return ranked.slice(0, limit).map((entry) => entry.item)
203
+ }
204
+
205
+ function normalizeParam(item) {
206
+ const entry = item && typeof item === 'object' ? item : {}
207
+ return {
208
+ name: String(entry.name ?? '').trim(),
209
+ type: String(entry.type ?? '').trim(),
210
+ value: entry.value === undefined || entry.value === null ? '' : String(entry.value),
211
+ example: entry.example === undefined || entry.example === null ? '' : String(entry.example),
212
+ required: entry.required === '1' || entry.required === true,
213
+ desc: stripHtml(entry.desc ?? ''),
214
+ }
215
+ }
216
+
217
+ function formValue(field) {
218
+ const type = String(field.type ?? '').toLowerCase()
219
+ if (field.example !== undefined && field.example !== null && field.example !== '') {
220
+ if (type === 'integer' || type === 'number') {
221
+ const parsed = Number(field.example)
222
+ return Number.isFinite(parsed) ? parsed : field.example
223
+ }
224
+ if (type === 'boolean') return field.example === true || field.example === 'true' || field.example === '1'
225
+ return field.example
226
+ }
227
+ if (type === 'integer' || type === 'number') return 0
228
+ if (type === 'boolean') return false
229
+ return ''
230
+ }
231
+
232
+ function bodyView(rawText, type, isSchema, formRows) {
233
+ const text = typeof rawText === 'string' ? rawText : ''
234
+ if (type === 'form' || (!isSchema && Array.isArray(formRows) && formRows.length > 0 && text.trim() === '')) {
235
+ const object = {}
236
+ for (const row of formRows || []) object[row.name] = formValue(row)
237
+ const keys = Object.keys(object)
238
+ return { kind: keys.length > 0 ? 'json' : 'none', example: JSON.stringify(object, null, 2), raw: text }
239
+ }
240
+ if (text.trim() === '') return { kind: 'none', example: '', raw: '' }
241
+ if (isSchema) {
242
+ const schema = parseJson(text)
243
+ if (schema !== undefined) return { kind: 'json', example: JSON.stringify(exampleFromSchema(schema), null, 2), raw: text }
244
+ }
245
+ const parsed = parseJson(text)
246
+ return parsed !== undefined
247
+ ? { kind: 'json', example: JSON.stringify(parsed, null, 2), raw: text }
248
+ : { kind: 'text', example: text, raw: text }
249
+ }
250
+
251
+ function buildDoc(raw, project, baseUrl) {
252
+ const projectId = Number(raw.project_id)
253
+ const path = String(raw.path ?? '')
254
+ const envs = Array.isArray(project?.env) ? project.env.filter((env) => env && env.domain) : []
255
+ const basepath = typeof project?.basepath === 'string' ? project.basepath : ''
256
+ return {
257
+ id: Number(raw._id),
258
+ title: String(raw.title ?? ''),
259
+ method: String(raw.method ?? 'GET').toUpperCase(),
260
+ path,
261
+ status: String(raw.status ?? ''),
262
+ tags: Array.isArray(raw.tag) ? raw.tag.map(String) : [],
263
+ desc: stripHtml(raw.desc ?? ''),
264
+ author: String(raw.username ?? ''),
265
+ updatedAt: Number(raw.up_time) > 0 ? Number(raw.up_time) * 1000 : null,
266
+ pageUrl: joinUrl(baseUrl, '/project', String(projectId), '/interface/api', String(raw._id)),
267
+ projectId,
268
+ projectName: String(project?.name ?? ''),
269
+ basepath,
270
+ address: envs.length > 0 ? joinUrl(envs[0].domain, basepath, path) : joinUrl(basepath, path),
271
+ headers: (Array.isArray(raw.req_headers) ? raw.req_headers : []).map(normalizeParam),
272
+ query: (Array.isArray(raw.req_query) ? raw.req_query : []).map(normalizeParam),
273
+ params: (Array.isArray(raw.req_params) ? raw.req_params : []).map(normalizeParam),
274
+ form: (Array.isArray(raw.req_body_form) ? raw.req_body_form : []).map(normalizeParam),
275
+ requestBody: bodyView(raw.req_body_other, String(raw.req_body_type ?? ''), raw.req_body_is_json_schema === true, raw.req_body_form),
276
+ responseBody: bodyView(raw.res_body, String(raw.res_body_type ?? ''), raw.res_body_is_json_schema === true, []),
277
+ }
278
+ }
279
+
280
+ function collectFields(schema, path, out) {
281
+ const node = unwrapComposite(schema)
282
+ const required = Array.isArray(node.required) ? node.required : []
283
+ const properties = node.properties && typeof node.properties === 'object' ? node.properties : {}
284
+ for (const [key, child] of Object.entries(properties)) {
285
+ const childPath = path ? path + '.' + key : key
286
+ out.push({
287
+ path: childPath,
288
+ type: typeLabel(child),
289
+ required: required.includes(key),
290
+ description: schemaComment(child),
291
+ })
292
+ const childNode = unwrapComposite(child)
293
+ if (childNode.properties) collectFields(childNode, childPath, out)
294
+ else if (childNode.type === 'array') {
295
+ const items = Array.isArray(childNode.items) ? childNode.items[0] : childNode.items
296
+ const itemNode = unwrapComposite(items)
297
+ if (itemNode.properties) collectFields(itemNode, childPath + '[]', out)
298
+ }
299
+ }
300
+ return out
301
+ }
302
+
303
+ function requestFields(raw) {
304
+ if (raw.req_body_is_json_schema === true) {
305
+ const schema = parseJson(raw.req_body_other)
306
+ return schema === undefined ? [] : collectFields(schema, '', [])
307
+ }
308
+ return (Array.isArray(raw.req_body_form) ? raw.req_body_form : []).map((row) => ({
309
+ path: String(row.name ?? ''),
310
+ type: String(row.type ?? ''),
311
+ required: row.required === '1' || row.required === true,
312
+ description: stripHtml(row.desc ?? ''),
313
+ }))
314
+ }
315
+
316
+ function responseFields(raw) {
317
+ if (raw.res_body_is_json_schema !== true) return []
318
+ const schema = parseJson(raw.res_body)
319
+ return schema === undefined ? [] : collectFields(schema, '', [])
320
+ }
321
+
322
+ /* ------------------------------------------------------------------ */
323
+ /* skill installer */
324
+ /* ------------------------------------------------------------------ */
325
+
326
+ /**
327
+ * Copy this CLI and its SKILL.md into a skill root. Used outside DSH, where
328
+ * `dsh plugin add` is not available. The copied SKILL.md is rewritten so every
329
+ * command points at the new location.
330
+ * @param {string} dirArg - optional target directory; defaults to ~/.agents/skills/yapi-doc.
331
+ */
332
+ function copySkillTo(target) {
333
+ const sourceDir = dirname(fileURLToPath(import.meta.url))
334
+ const sourceSkill = join(sourceDir, 'SKILL.md')
335
+ const sourceCli = join(sourceDir, 'yapi-doc.mjs')
336
+ if (!existsSync(sourceSkill) || !existsSync(sourceCli)) {
337
+ throw new Error('找不到 skill 文件;请从完整安装包运行,或用 --dir 指定目标目录。')
338
+ }
339
+ const directory = resolve(target)
340
+ mkdirSync(directory, { recursive: true })
341
+ const targetCli = join(directory, 'yapi-doc.mjs')
342
+ if (resolve(targetCli) !== resolve(sourceCli)) copyFileSync(sourceCli, targetCli)
343
+ chmodSync(targetCli, 0o755)
344
+ const windowsTarget = directory.replace(/\//gu, '\\')
345
+ const content = readFileSync(sourceSkill, 'utf8')
346
+ .split('$HOME/.dsh/skills/yapi-doc')
347
+ .join(directory)
348
+ .split('%USERPROFILE%\\.dsh\\skills\\yapi-doc')
349
+ .join(windowsTarget)
350
+ const targetSkill = join(directory, 'SKILL.md')
351
+ if (resolve(targetSkill) !== resolve(sourceSkill)) writeFileSync(targetSkill, content, 'utf8')
352
+ return directory
353
+ }
354
+
355
+ function installSkill(dirArg) {
356
+ const target = copySkillTo(dirArg || join(homedir(), '.agents', 'skills', 'yapi-doc'))
357
+ console.log('Skill installed: ' + target)
358
+ console.log('Run: node "' + join(target, 'yapi-doc.mjs') + '" <doc-id>')
359
+ }
360
+
361
+ const HOME_AGENTS = [
362
+ ['Trae', '.trae', '.trae/skills'],
363
+ ['WorkBuddy', '.workbuddy', '.workbuddy/skills'],
364
+ ['Qoder CN', '.qoder-cn', '.qoder-cn/skills'],
365
+ ['Qoder', '.qoder', '.qoder/skills'],
366
+ ['GitHub Copilot', '.copilot', '.copilot/skills'],
367
+ ['CodeBuddy', '.codebuddy', '.codebuddy/skills'],
368
+ ['Claude Code', '.claude', '.claude/skills'],
369
+ ['Cursor', '.cursor', '.cursor/skills'],
370
+ ['Gemini CLI', '.gemini', '.gemini/skills'],
371
+ ['Droid', '.factory', '.factory/skills'],
372
+ ['fx', '.fx', '.fx/skills'],
373
+ ['Kilo Code', '.kilo', '.kilo/skills'],
374
+ ['Augment', '.augment', '.augment/skills'],
375
+ ['Continue', '.continue', '.continue/skills'],
376
+ ['AiderDesk', '.aider-desk', '.aider-desk/skills'],
377
+ ['Goose', '.goose', '.goose/skills'],
378
+ ['Kiro', '.kiro', '.kiro/skills'],
379
+ ['Qwen Code', '.qwen', '.qwen/skills'],
380
+ ['CodeArts', '.codeartsdoer', '.codeartsdoer/skills'],
381
+ ['Codemaker', '.codemaker', '.codemaker/skills'],
382
+ ['Code Studio', '.codestudio', '.codestudio/skills'],
383
+ ['Command Code', '.commandcode', '.commandcode/skills'],
384
+ ['Firebender', '.firebender', '.firebender/skills'],
385
+ ['ForgeCode', '.forge', '.forge/skills'],
386
+ ['Amp', '.config/amp', '.config/agents/skills'],
387
+ ['OpenCode', '.config/opencode', '.config/opencode/skills'],
388
+ ['Devin', '.config/devin', '.config/devin/skills'],
389
+ ['Crush', '.config/crush', '.config/crush/skills'],
390
+ ['Cortex', '.snowflake/cortex', '.snowflake/cortex/skills'],
391
+ ]
392
+
393
+ function detectedSkillTargets(extraDirs) {
394
+ const home = homedir()
395
+ const targets = []
396
+ const add = function (label, dir) {
397
+ const resolved = resolve(dir)
398
+ if (!targets.some(function (item) { return item.dir === resolved })) {
399
+ targets.push({ label, dir: resolved })
400
+ }
401
+ }
402
+ for (const [label, detect, skills] of HOME_AGENTS) {
403
+ if (existsSync(join(home, detect))) add(label, join(home, skills, 'yapi-doc'))
404
+ }
405
+ add('Agent Skills(Codex / Cline / Zed / 通用)', join(home, '.agents', 'skills', 'yapi-doc'))
406
+ if (existsSync(DSH_HOME)) add('DSH', join(DSH_HOME, 'skills', 'yapi-doc'))
407
+ for (const dir of extraDirs) add('custom', dir)
408
+ return targets
409
+ }
410
+
411
+ function syncSkills(extraDirs, dryRun) {
412
+ const targets = detectedSkillTargets(extraDirs)
413
+ console.log('yapi-doc skill 同步:')
414
+ for (const item of targets) {
415
+ if (dryRun) {
416
+ console.log(' - ' + item.label + ' ' + item.dir)
417
+ continue
418
+ }
419
+ const target = copySkillTo(item.dir)
420
+ console.log(' ✓ ' + item.label + ' ' + target)
421
+ }
422
+ if (!dryRun) {
423
+ console.log('')
424
+ console.log('重启对应应用后生效。')
425
+ }
426
+ }
427
+
428
+ /* ------------------------------------------------------------------ */
429
+ /* YAPI session */
430
+ /* ------------------------------------------------------------------ */
431
+
432
+ function readSettings() {
433
+ let stored = {}
434
+ try {
435
+ stored = JSON.parse(readFileSync(CONFIG_FILE, 'utf8'))
436
+ } catch {
437
+ stored = {}
438
+ }
439
+ const env = process.env
440
+ const timeout = Number(stored.timeoutMs)
441
+ return {
442
+ baseUrl: normalizeBaseUrl(env.YAPI_BASE_URL || stored.baseUrl),
443
+ email: String(env.YAPI_EMAIL || stored.email || '').trim(),
444
+ password: String(env.YAPI_PASSWORD || stored.password || ''),
445
+ timeoutMs: Number.isFinite(timeout) && timeout >= 1000 ? Math.min(timeout, 120000) : 20000,
446
+ }
447
+ }
448
+
449
+ async function createSession(settings) {
450
+ if (settings.baseUrl === '') {
451
+ throw new Error(
452
+ '未配置 YAPI 地址。打开 YAPI 面板 → 账号设置填写,或设置 YAPI_BASE_URL。',
453
+ )
454
+ }
455
+ if (settings.email === '' || settings.password === '') {
456
+ throw new Error(
457
+ '未配置 YAPI 账号。打开 YAPI 面板 → 账号设置填写,或编辑 ' +
458
+ CONFIG_FILE +
459
+ ',也可设置 YAPI_EMAIL / YAPI_PASSWORD 环境变量。',
460
+ )
461
+ }
462
+ let cookie = ''
463
+ async function login() {
464
+ let response
465
+ try {
466
+ response = await fetch(settings.baseUrl + '/api/user/login', {
467
+ method: 'POST',
468
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
469
+ body: JSON.stringify({ email: settings.email, password: settings.password }),
470
+ signal: AbortSignal.timeout(settings.timeoutMs),
471
+ })
472
+ } catch (error) {
473
+ throw new Error('无法连接 YAPI(' + settings.baseUrl + '):' + (error && error.message ? error.message : error))
474
+ }
475
+ const text = await response.text()
476
+ let payload
477
+ try {
478
+ payload = JSON.parse(text)
479
+ } catch {
480
+ throw new Error('YAPI 登录响应不是 JSON(HTTP ' + response.status + ')')
481
+ }
482
+ if (!payload || payload.errcode !== 0) {
483
+ throw new Error('YAPI 登录失败:' + ((payload && payload.errmsg) || '未知错误'))
484
+ }
485
+ const lines = typeof response.headers.getSetCookie === 'function' ? response.headers.getSetCookie() : []
486
+ const pairs = lines.map((line) => String(line).split(';')[0].trim()).filter((pair) => pair.startsWith('_yapi_'))
487
+ if (pairs.length === 0) throw new Error('YAPI 登录成功,但响应没有返回 Cookie')
488
+ cookie = pairs.join('; ')
489
+ }
490
+ async function get(pathname, retry = true) {
491
+ if (cookie === '') await login()
492
+ let response
493
+ try {
494
+ response = await fetch(settings.baseUrl + pathname, {
495
+ headers: { cookie, accept: 'application/json' },
496
+ signal: AbortSignal.timeout(settings.timeoutMs),
497
+ })
498
+ } catch (error) {
499
+ throw new Error('无法连接 YAPI(' + settings.baseUrl + '):' + (error && error.message ? error.message : error))
500
+ }
501
+ const text = await response.text()
502
+ let payload
503
+ try {
504
+ payload = JSON.parse(text)
505
+ } catch {
506
+ payload = null
507
+ }
508
+ if (!payload) {
509
+ if (retry) {
510
+ cookie = ''
511
+ await login()
512
+ return get(pathname, false)
513
+ }
514
+ throw new Error('YAPI 返回了非 JSON 响应(HTTP ' + response.status + ')')
515
+ }
516
+ if (payload.errcode === 40011 && retry) {
517
+ cookie = ''
518
+ await login()
519
+ return get(pathname, false)
520
+ }
521
+ if (payload.errcode !== 0) throw new Error('YAPI 错误:' + (payload.errmsg || String(payload.errcode)))
522
+ return payload.data
523
+ }
524
+ return { get }
525
+ }
526
+
527
+ /* ------------------------------------------------------------------ */
528
+ /* rendering */
529
+ /* ------------------------------------------------------------------ */
530
+
531
+ const STATUS_LABEL = {
532
+ done: '已完成',
533
+ undone: '开发中',
534
+ design: '设计中',
535
+ deprecated: '已废弃',
536
+ stub: '未完成',
537
+ }
538
+
539
+ function codeFence(text, lang) {
540
+ return ['```' + (lang || ''), text, '```'].join('\n')
541
+ }
542
+
543
+ function formatTime(timestamp) {
544
+ const date = new Date(timestamp)
545
+ if (Number.isNaN(date.getTime())) return ''
546
+ try {
547
+ return date.toLocaleString('zh-CN', {
548
+ year: 'numeric',
549
+ month: '2-digit',
550
+ day: '2-digit',
551
+ hour: '2-digit',
552
+ minute: '2-digit',
553
+ hour12: false,
554
+ })
555
+ } catch {
556
+ return date.toISOString().slice(0, 16).replace('T', ' ')
557
+ }
558
+ }
559
+
560
+ function fieldLine(field) {
561
+ const type = field.type ? field.type : '—'
562
+ const required = field.required ? '必填' : '可选'
563
+ const description = field.description ? ' — ' + field.description : ''
564
+ return '- ' + field.path + ': ' + type + ', ' + required + description
565
+ }
566
+
567
+ function paramLines(title, rows) {
568
+ if (rows.length === 0) return []
569
+ const lines = [title + ':']
570
+ for (const row of rows) {
571
+ const bits = []
572
+ if (row.type) bits.push(row.type)
573
+ bits.push(row.required ? '必填' : '可选')
574
+ if (row.example) bits.push('示例 ' + row.example)
575
+ const description = row.desc ? ' — ' + row.desc : ''
576
+ lines.push('- ' + row.name + (bits.length ? '(' + bits.join(',') + ')' : '') + description)
577
+ }
578
+ return lines
579
+ }
580
+
581
+ function renderDoc(doc, requestFieldRows, responseFieldRows, options) {
582
+ const lines = ['# ' + (doc.title || '未命名接口'), doc.method + ' ' + doc.path]
583
+ if (doc.projectName) lines.push('项目:' + doc.projectName + '(' + doc.projectId + ')')
584
+ if (doc.status) lines.push('状态:' + (STATUS_LABEL[doc.status] || doc.status))
585
+ if (doc.tags.length > 0) lines.push('标签:' + doc.tags.join(' / '))
586
+ lines.push('YAPI:' + doc.pageUrl)
587
+ if (doc.updatedAt) lines.push('更新:' + formatTime(doc.updatedAt))
588
+ if (doc.desc) lines.push('', '## 接口说明', doc.desc)
589
+
590
+ lines.push('', '## 请求地址', doc.method + ' ' + (doc.address || doc.path))
591
+
592
+ if (doc.headers.length > 0) {
593
+ lines.push('', '## 请求头')
594
+ for (const row of doc.headers) {
595
+ lines.push('- ' + row.name + ': ' + row.value + (row.required ? '(必填)' : '') + (row.desc ? ' — ' + row.desc : ''))
596
+ }
597
+ }
598
+
599
+ const paramSections = [
600
+ ...paramLines('Query', doc.query),
601
+ ...paramLines('Path', doc.params),
602
+ ...paramLines('Form', doc.form),
603
+ ]
604
+ if (paramSections.length > 0) lines.push('', '## 请求参数', ...paramSections)
605
+
606
+ if (doc.requestBody.kind !== 'none') {
607
+ lines.push('', '## 请求 Body', codeFence(doc.requestBody.example, doc.requestBody.kind === 'json' ? 'json' : ''))
608
+ }
609
+
610
+ if (!options.noFields) {
611
+ if (requestFieldRows.length > 0) {
612
+ lines.push('', '## 请求字段(' + requestFieldRows.length + ')')
613
+ for (const field of requestFieldRows) lines.push(fieldLine(field))
614
+ }
615
+ if (responseFieldRows.length > 0) {
616
+ lines.push('', '## 返回字段(' + responseFieldRows.length + ')')
617
+ for (const field of responseFieldRows) lines.push(fieldLine(field))
618
+ }
619
+ }
620
+
621
+ if (options.full && doc.responseBody.kind !== 'none') {
622
+ lines.push('', '## 返回体', codeFence(doc.responseBody.example, doc.responseBody.kind === 'json' ? 'json' : ''))
623
+ }
624
+
625
+ return lines.join('\n')
626
+ }
627
+
628
+ function renderSearch(projectId, projectName, total, keyword, matches) {
629
+ const lines = ['# YAPI 接口搜索:' + keyword]
630
+ lines.push('项目:' + projectId + (projectName ? '(' + projectName + ')' : '') + ',共 ' + total + ' 个接口,' + matches.length + ' 个匹配')
631
+ lines.push('')
632
+ for (const item of matches) {
633
+ lines.push(String(item._id) + ' ' + String(item.method || '').padEnd(6, ' ') + ' ' + String(item.path || '') + ' — ' + String(item.title || ''))
634
+ }
635
+ return lines.join('\n')
636
+ }
637
+
638
+ /* ------------------------------------------------------------------ */
639
+ /* main */
640
+ /* ------------------------------------------------------------------ */
641
+
642
+ function printHelp() {
643
+ console.log(`yapi-doc — 读取 YAPI 接口文档
644
+
645
+ 用法:
646
+ yapi-doc <文档ID|链接> [选项]
647
+ yapi-doc --search <关键字> --project <项目ID>
648
+ yapi-doc install-skill [--dir <目录>]
649
+ yapi-doc sync-skills [--dir <目录> ...] [--dry-run]
650
+
651
+ 选项:
652
+ --json 输出结构化 JSON(默认 Markdown)
653
+ --full 额外输出返回体示例(默认只输出返回字段说明)
654
+ --no-fields 不输出请求/返回字段说明
655
+ --project <id> --search 使用的项目 ID(必填)
656
+ --dir <目录> install-skill 的目标目录(默认 ~/.agents/skills/yapi-doc)
657
+ --dry-run sync-skills 只显示检测到的目标,不写入
658
+ -h, --help 显示帮助
659
+
660
+ 账号:
661
+ 读取 ${CONFIG_FILE}
662
+ 或环境变量 YAPI_BASE_URL / YAPI_EMAIL / YAPI_PASSWORD
663
+
664
+ 示例:
665
+ yapi-doc 10001
666
+ yapi-doc https://your-yapi.example.com/project/1/interface/api/10001
667
+ yapi-doc --search warehouse --project 1
668
+ yapi-doc 10001 --full --json
669
+ yapi-doc install-skill
670
+ yapi-doc install-skill --dir ~/.claude/skills/yapi-doc
671
+ yapi-doc sync-skills
672
+ yapi-doc sync-skills --dry-run`)
673
+ }
674
+
675
+ function parseArgs(argv) {
676
+ const options = {
677
+ target: '',
678
+ search: '',
679
+ projectId: 0,
680
+ json: false,
681
+ full: false,
682
+ noFields: false,
683
+ help: false,
684
+ }
685
+ for (let index = 0; index < argv.length; index += 1) {
686
+ const arg = argv[index]
687
+ if (arg === '-h' || arg === '--help') {
688
+ options.help = true
689
+ continue
690
+ }
691
+ if (arg === '--json') {
692
+ options.json = true
693
+ continue
694
+ }
695
+ if (arg === '--full') {
696
+ options.full = true
697
+ continue
698
+ }
699
+ if (arg === '--no-fields') {
700
+ options.noFields = true
701
+ continue
702
+ }
703
+ if (arg === '--project') {
704
+ options.projectId = Number(argv[++index]) || 0
705
+ continue
706
+ }
707
+ if (arg.startsWith('--project=')) {
708
+ options.projectId = Number(arg.slice('--project='.length)) || 0
709
+ continue
710
+ }
711
+ if (arg === '--search') {
712
+ options.search = String(argv[++index] || '').trim()
713
+ continue
714
+ }
715
+ if (arg.startsWith('--search=')) {
716
+ options.search = arg.slice('--search='.length).trim()
717
+ continue
718
+ }
719
+ if (arg.startsWith('-')) throw new Error('未知选项:' + arg)
720
+ if (options.target !== '') throw new Error('只能指定一个文档 ID 或链接')
721
+ options.target = arg
722
+ }
723
+ return options
724
+ }
725
+
726
+ async function main() {
727
+ if (process.argv[2] === 'sync-skills') {
728
+ const dirs = []
729
+ let dryRun = false
730
+ for (let index = 3; index < process.argv.length; index += 1) {
731
+ const arg = process.argv[index]
732
+ if (arg === '-h' || arg === '--help') {
733
+ printHelp()
734
+ return
735
+ }
736
+ if (arg === '--dry-run') {
737
+ dryRun = true
738
+ continue
739
+ }
740
+ if (arg === '--dir') {
741
+ const dir = process.argv[index + 1] || ''
742
+ if (dir === '') throw new Error('--dir 需要一个目录')
743
+ dirs.push(dir)
744
+ index += 1
745
+ continue
746
+ }
747
+ if (arg.startsWith('--dir=')) {
748
+ const dir = arg.slice('--dir='.length)
749
+ if (dir === '') throw new Error('--dir 需要一个目录')
750
+ dirs.push(dir)
751
+ continue
752
+ }
753
+ throw new Error('未知选项:' + arg)
754
+ }
755
+ syncSkills(dirs, dryRun)
756
+ return
757
+ }
758
+ if (process.argv[2] === 'install-skill') {
759
+ let target = ''
760
+ for (let index = 3; index < process.argv.length; index += 1) {
761
+ const arg = process.argv[index]
762
+ if (arg === '-h' || arg === '--help') {
763
+ printHelp()
764
+ return
765
+ }
766
+ if (arg === '--dir') {
767
+ target = process.argv[index + 1] || ''
768
+ if (target === '') throw new Error('--dir 需要一个目录')
769
+ index += 1
770
+ } else if (arg.startsWith('--dir=')) {
771
+ target = arg.slice('--dir='.length)
772
+ if (target === '') throw new Error('--dir 需要一个目录')
773
+ } else {
774
+ throw new Error('未知选项:' + arg)
775
+ }
776
+ }
777
+ installSkill(target)
778
+ return
779
+ }
780
+ const options = parseArgs(process.argv.slice(2))
781
+ if (options.help) {
782
+ printHelp()
783
+ return
784
+ }
785
+ if (options.search === '' && options.target === '') {
786
+ printHelp()
787
+ throw new Error('请提供文档 ID / 链接,或使用 --search <关键字>')
788
+ }
789
+ const settings = readSettings()
790
+ const session = await createSession(settings)
791
+
792
+ if (options.search !== '') {
793
+ if (!Number.isSafeInteger(options.projectId) || options.projectId <= 0) {
794
+ throw new Error('请用 --project <项目ID> 指定搜索范围')
795
+ }
796
+ const project = await session.get('/api/project/get?id=' + encodeURIComponent(options.projectId)).catch(() => null)
797
+ const data = await session.get(
798
+ '/api/interface/list?project_id=' + encodeURIComponent(options.projectId) + '&page=1&limit=2000',
799
+ )
800
+ const list = Array.isArray(data?.list) ? data.list : []
801
+ const matches = rankInterfaces(list, options.search, 30)
802
+ if (options.json) {
803
+ console.log(
804
+ JSON.stringify(
805
+ {
806
+ ok: true,
807
+ data: {
808
+ projectId: options.projectId,
809
+ projectName: String(project?.name ?? ''),
810
+ total: list.length,
811
+ keyword: options.search,
812
+ items: matches.map((item) => ({
813
+ id: Number(item._id),
814
+ method: String(item.method ?? '').toUpperCase(),
815
+ path: String(item.path ?? ''),
816
+ title: String(item.title ?? ''),
817
+ })),
818
+ },
819
+ },
820
+ null,
821
+ 2,
822
+ ),
823
+ )
824
+ return
825
+ }
826
+ console.log(renderSearch(options.projectId, String(project?.name ?? ''), list.length, options.search, matches))
827
+ return
828
+ }
829
+
830
+ const id = parseDocId(options.target)
831
+ if (id === null) throw new Error('无法从输入中解析文档 ID:' + options.target)
832
+ const raw = await session.get('/api/interface/get?id=' + encodeURIComponent(id))
833
+ const project =
834
+ raw && Number(raw.project_id) > 0
835
+ ? await session.get('/api/project/get?id=' + encodeURIComponent(raw.project_id)).catch(() => null)
836
+ : null
837
+ const doc = buildDoc(raw, project, settings.baseUrl)
838
+ const requestFieldRows = requestFields(raw)
839
+ const responseFieldRows = responseFields(raw)
840
+
841
+ if (options.json) {
842
+ console.log(
843
+ JSON.stringify(
844
+ {
845
+ ok: true,
846
+ data: Object.assign({}, doc, {
847
+ requestFields: requestFieldRows,
848
+ responseFields: responseFieldRows,
849
+ }),
850
+ },
851
+ null,
852
+ 2,
853
+ ),
854
+ )
855
+ return
856
+ }
857
+ console.log(renderDoc(doc, requestFieldRows, responseFieldRows, options))
858
+ }
859
+
860
+ main().catch((error) => {
861
+ console.error('yapi-doc: ' + (error && error.message ? error.message : String(error)))
862
+ process.exit(1)
863
+ })