dsh-plugin-yapi 0.0.0-stage → 1.4.2

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,877 @@
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
+ function copySyncSkillTo(root) {
362
+ const sourceDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'yapi-sync')
363
+ const source = join(sourceDir, 'SKILL.md')
364
+ if (!existsSync(source)) return undefined
365
+ const directory = join(root, 'yapi-sync')
366
+ mkdirSync(directory, { recursive: true })
367
+ copyFileSync(source, join(directory, 'SKILL.md'))
368
+ return directory
369
+ }
370
+
371
+ const HOME_AGENTS = [
372
+ ['Trae', '.trae', '.trae/skills'],
373
+ ['WorkBuddy', '.workbuddy', '.workbuddy/skills'],
374
+ ['Qoder CN', '.qoder-cn', '.qoder-cn/skills'],
375
+ ['Qoder', '.qoder', '.qoder/skills'],
376
+ ['GitHub Copilot', '.copilot', '.copilot/skills'],
377
+ ['CodeBuddy', '.codebuddy', '.codebuddy/skills'],
378
+ ['Claude Code', '.claude', '.claude/skills'],
379
+ ['Cursor', '.cursor', '.cursor/skills'],
380
+ ['Gemini CLI', '.gemini', '.gemini/skills'],
381
+ ['Droid', '.factory', '.factory/skills'],
382
+ ['fx', '.fx', '.fx/skills'],
383
+ ['Kilo Code', '.kilo', '.kilo/skills'],
384
+ ['Augment', '.augment', '.augment/skills'],
385
+ ['Continue', '.continue', '.continue/skills'],
386
+ ['AiderDesk', '.aider-desk', '.aider-desk/skills'],
387
+ ['Goose', '.goose', '.goose/skills'],
388
+ ['Kiro', '.kiro', '.kiro/skills'],
389
+ ['Qwen Code', '.qwen', '.qwen/skills'],
390
+ ['CodeArts', '.codeartsdoer', '.codeartsdoer/skills'],
391
+ ['Codemaker', '.codemaker', '.codemaker/skills'],
392
+ ['Code Studio', '.codestudio', '.codestudio/skills'],
393
+ ['Command Code', '.commandcode', '.commandcode/skills'],
394
+ ['Firebender', '.firebender', '.firebender/skills'],
395
+ ['ForgeCode', '.forge', '.forge/skills'],
396
+ ['Amp', '.config/amp', '.config/agents/skills'],
397
+ ['OpenCode', '.config/opencode', '.config/opencode/skills'],
398
+ ['Devin', '.config/devin', '.config/devin/skills'],
399
+ ['Crush', '.config/crush', '.config/crush/skills'],
400
+ ['Cortex', '.snowflake/cortex', '.snowflake/cortex/skills'],
401
+ ]
402
+
403
+ function detectedSkillTargets(extraDirs) {
404
+ const home = homedir()
405
+ const targets = []
406
+ const add = function (label, dir) {
407
+ const resolved = resolve(dir)
408
+ if (!targets.some(function (item) { return item.dir === resolved })) {
409
+ targets.push({ label, dir: resolved })
410
+ }
411
+ }
412
+ for (const [label, detect, skills] of HOME_AGENTS) {
413
+ if (existsSync(join(home, detect))) add(label, join(home, skills))
414
+ }
415
+ add('Agent Skills(Codex / Cline / Zed / 通用)', join(home, '.agents', 'skills'))
416
+ if (existsSync(DSH_HOME)) add('DSH', join(DSH_HOME, 'skills'))
417
+ for (const dir of extraDirs) add('custom', dir)
418
+ return targets
419
+ }
420
+
421
+ function syncSkills(extraDirs, dryRun) {
422
+ const targets = detectedSkillTargets(extraDirs)
423
+ console.log('yapi-doc / yapi-sync skill 同步:')
424
+ for (const item of targets) {
425
+ if (dryRun) {
426
+ console.log(' - ' + item.label + ' ' + item.dir)
427
+ continue
428
+ }
429
+ copySkillTo(join(item.dir, 'yapi-doc'))
430
+ copySyncSkillTo(item.dir)
431
+ console.log(' ✓ ' + item.label + ' ' + item.dir)
432
+ }
433
+ if (!dryRun) {
434
+ console.log('')
435
+ console.log('重启对应应用后生效。')
436
+ }
437
+ }
438
+
439
+ /* ------------------------------------------------------------------ */
440
+ /* YAPI session */
441
+ /* ------------------------------------------------------------------ */
442
+
443
+ function readSettings() {
444
+ let stored = {}
445
+ try {
446
+ stored = JSON.parse(readFileSync(CONFIG_FILE, 'utf8'))
447
+ } catch {
448
+ stored = {}
449
+ }
450
+ const env = process.env
451
+ const timeout = Number(stored.timeoutMs)
452
+ return {
453
+ baseUrl: normalizeBaseUrl(env.YAPI_BASE_URL || stored.baseUrl),
454
+ email: String(env.YAPI_EMAIL || stored.email || '').trim(),
455
+ password: String(env.YAPI_PASSWORD || stored.password || ''),
456
+ authType: String(env.YAPI_AUTH_TYPE || stored.authType || 'normal').trim().toLowerCase() === 'ldap' ? 'ldap' : 'normal',
457
+ timeoutMs: Number.isFinite(timeout) && timeout >= 1000 ? Math.min(timeout, 120000) : 20000,
458
+ }
459
+ }
460
+
461
+ async function createSession(settings) {
462
+ if (settings.baseUrl === '') {
463
+ throw new Error(
464
+ '未配置 YAPI 地址。打开 YAPI 面板 → 账号设置填写,或设置 YAPI_BASE_URL。',
465
+ )
466
+ }
467
+ if (settings.email === '' || settings.password === '') {
468
+ throw new Error(
469
+ '未配置 YAPI 账号。打开 YAPI 面板 → 账号设置填写,或编辑 ' +
470
+ CONFIG_FILE +
471
+ ',也可设置 YAPI_EMAIL / YAPI_PASSWORD 环境变量。',
472
+ )
473
+ }
474
+ let cookie = ''
475
+ async function login() {
476
+ let response
477
+ try {
478
+ const loginPath = settings.authType === 'ldap' ? '/api/user/login_by_ldap' : '/api/user/login'
479
+ response = await fetch(settings.baseUrl + loginPath, {
480
+ method: 'POST',
481
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
482
+ body: JSON.stringify({ email: settings.email, password: settings.password }),
483
+ signal: AbortSignal.timeout(settings.timeoutMs),
484
+ })
485
+ } catch (error) {
486
+ throw new Error('无法连接 YAPI(' + settings.baseUrl + '):' + (error && error.message ? error.message : error))
487
+ }
488
+ const text = await response.text()
489
+ let payload
490
+ try {
491
+ payload = JSON.parse(text)
492
+ } catch {
493
+ throw new Error('YAPI 登录响应不是 JSON(HTTP ' + response.status + ')')
494
+ }
495
+ if (!payload || payload.errcode !== 0) {
496
+ throw new Error('YAPI 登录失败:' + ((payload && payload.errmsg) || '未知错误'))
497
+ }
498
+ const lines = typeof response.headers.getSetCookie === 'function' ? response.headers.getSetCookie() : []
499
+ const pairs = lines.map((line) => String(line).split(';')[0].trim()).filter((pair) => pair.startsWith('_yapi_'))
500
+ if (pairs.length === 0) throw new Error('YAPI 登录成功,但响应没有返回 Cookie')
501
+ cookie = pairs.join('; ')
502
+ }
503
+ async function get(pathname, retry = true) {
504
+ if (cookie === '') await login()
505
+ let response
506
+ try {
507
+ response = await fetch(settings.baseUrl + pathname, {
508
+ headers: { cookie, accept: 'application/json' },
509
+ signal: AbortSignal.timeout(settings.timeoutMs),
510
+ })
511
+ } catch (error) {
512
+ throw new Error('无法连接 YAPI(' + settings.baseUrl + '):' + (error && error.message ? error.message : error))
513
+ }
514
+ const text = await response.text()
515
+ let payload
516
+ try {
517
+ payload = JSON.parse(text)
518
+ } catch {
519
+ payload = null
520
+ }
521
+ if (!payload) {
522
+ if (retry) {
523
+ cookie = ''
524
+ await login()
525
+ return get(pathname, false)
526
+ }
527
+ throw new Error('YAPI 返回了非 JSON 响应(HTTP ' + response.status + ')')
528
+ }
529
+ if (payload.errcode === 40011 && retry) {
530
+ cookie = ''
531
+ await login()
532
+ return get(pathname, false)
533
+ }
534
+ if (payload.errcode !== 0) throw new Error('YAPI 错误:' + (payload.errmsg || String(payload.errcode)))
535
+ return payload.data
536
+ }
537
+ return { get }
538
+ }
539
+
540
+ /* ------------------------------------------------------------------ */
541
+ /* rendering */
542
+ /* ------------------------------------------------------------------ */
543
+
544
+ const STATUS_LABEL = {
545
+ done: '已完成',
546
+ undone: '开发中',
547
+ design: '设计中',
548
+ deprecated: '已废弃',
549
+ stub: '未完成',
550
+ }
551
+
552
+ function codeFence(text, lang) {
553
+ return ['```' + (lang || ''), text, '```'].join('\n')
554
+ }
555
+
556
+ function formatTime(timestamp) {
557
+ const date = new Date(timestamp)
558
+ if (Number.isNaN(date.getTime())) return ''
559
+ try {
560
+ return date.toLocaleString('zh-CN', {
561
+ year: 'numeric',
562
+ month: '2-digit',
563
+ day: '2-digit',
564
+ hour: '2-digit',
565
+ minute: '2-digit',
566
+ hour12: false,
567
+ })
568
+ } catch {
569
+ return date.toISOString().slice(0, 16).replace('T', ' ')
570
+ }
571
+ }
572
+
573
+ function fieldLine(field) {
574
+ const type = field.type ? field.type : '—'
575
+ const required = field.required ? '必填' : '可选'
576
+ const description = field.description ? ' — ' + field.description : ''
577
+ return '- ' + field.path + ': ' + type + ', ' + required + description
578
+ }
579
+
580
+ function paramLines(title, rows) {
581
+ if (rows.length === 0) return []
582
+ const lines = [title + ':']
583
+ for (const row of rows) {
584
+ const bits = []
585
+ if (row.type) bits.push(row.type)
586
+ bits.push(row.required ? '必填' : '可选')
587
+ if (row.example) bits.push('示例 ' + row.example)
588
+ const description = row.desc ? ' — ' + row.desc : ''
589
+ lines.push('- ' + row.name + (bits.length ? '(' + bits.join(',') + ')' : '') + description)
590
+ }
591
+ return lines
592
+ }
593
+
594
+ function renderDoc(doc, requestFieldRows, responseFieldRows, options) {
595
+ const lines = ['# ' + (doc.title || '未命名接口'), doc.method + ' ' + doc.path]
596
+ if (doc.projectName) lines.push('项目:' + doc.projectName + '(' + doc.projectId + ')')
597
+ if (doc.status) lines.push('状态:' + (STATUS_LABEL[doc.status] || doc.status))
598
+ if (doc.tags.length > 0) lines.push('标签:' + doc.tags.join(' / '))
599
+ lines.push('YAPI:' + doc.pageUrl)
600
+ if (doc.updatedAt) lines.push('更新:' + formatTime(doc.updatedAt))
601
+ if (doc.desc) lines.push('', '## 接口说明', doc.desc)
602
+
603
+ lines.push('', '## 请求地址', doc.method + ' ' + (doc.address || doc.path))
604
+
605
+ if (doc.headers.length > 0) {
606
+ lines.push('', '## 请求头')
607
+ for (const row of doc.headers) {
608
+ lines.push('- ' + row.name + ': ' + row.value + (row.required ? '(必填)' : '') + (row.desc ? ' — ' + row.desc : ''))
609
+ }
610
+ }
611
+
612
+ const paramSections = [
613
+ ...paramLines('Query', doc.query),
614
+ ...paramLines('Path', doc.params),
615
+ ...paramLines('Form', doc.form),
616
+ ]
617
+ if (paramSections.length > 0) lines.push('', '## 请求参数', ...paramSections)
618
+
619
+ if (doc.requestBody.kind !== 'none') {
620
+ lines.push('', '## 请求 Body', codeFence(doc.requestBody.example, doc.requestBody.kind === 'json' ? 'json' : ''))
621
+ }
622
+
623
+ if (!options.noFields) {
624
+ if (requestFieldRows.length > 0) {
625
+ lines.push('', '## 请求字段(' + requestFieldRows.length + ')')
626
+ for (const field of requestFieldRows) lines.push(fieldLine(field))
627
+ }
628
+ if (responseFieldRows.length > 0) {
629
+ lines.push('', '## 返回字段(' + responseFieldRows.length + ')')
630
+ for (const field of responseFieldRows) lines.push(fieldLine(field))
631
+ }
632
+ }
633
+
634
+ if (options.full && doc.responseBody.kind !== 'none') {
635
+ lines.push('', '## 返回体', codeFence(doc.responseBody.example, doc.responseBody.kind === 'json' ? 'json' : ''))
636
+ }
637
+
638
+ return lines.join('\n')
639
+ }
640
+
641
+ function renderSearch(projectId, projectName, total, keyword, matches) {
642
+ const lines = ['# YAPI 接口搜索:' + keyword]
643
+ lines.push('项目:' + projectId + (projectName ? '(' + projectName + ')' : '') + ',共 ' + total + ' 个接口,' + matches.length + ' 个匹配')
644
+ lines.push('')
645
+ for (const item of matches) {
646
+ lines.push(String(item._id) + ' ' + String(item.method || '').padEnd(6, ' ') + ' ' + String(item.path || '') + ' — ' + String(item.title || ''))
647
+ }
648
+ return lines.join('\n')
649
+ }
650
+
651
+ /* ------------------------------------------------------------------ */
652
+ /* main */
653
+ /* ------------------------------------------------------------------ */
654
+
655
+ function printHelp() {
656
+ console.log(`yapi-doc — 读取 YAPI 接口文档
657
+
658
+ 用法:
659
+ yapi-doc <文档ID|链接> [选项]
660
+ yapi-doc --search <关键字> --project <项目ID>
661
+ yapi-doc install-skill [--dir <目录>]
662
+ yapi-doc sync-skills [--dir <目录> ...] [--dry-run]
663
+
664
+ 选项:
665
+ --json 输出结构化 JSON(默认 Markdown)
666
+ --full 额外输出返回体示例(默认只输出返回字段说明)
667
+ --no-fields 不输出请求/返回字段说明
668
+ --project <id> --search 使用的项目 ID(必填)
669
+ --dir <目录> install-skill 的目标目录(默认 ~/.agents/skills/yapi-doc)
670
+ --dry-run sync-skills 只显示检测到的目标,不写入
671
+ -h, --help 显示帮助
672
+
673
+ 账号:
674
+ 读取 ${CONFIG_FILE}
675
+ 或环境变量 YAPI_BASE_URL / YAPI_EMAIL / YAPI_PASSWORD / YAPI_AUTH_TYPE
676
+ (YAPI_AUTH_TYPE=ldap 使用 LDAP 登录,默认普通登录)
677
+
678
+ 示例:
679
+ yapi-doc 10001
680
+ yapi-doc https://your-yapi.example.com/project/1/interface/api/10001
681
+ yapi-doc --search warehouse --project 1
682
+ yapi-doc 10001 --full --json
683
+ yapi-doc install-skill
684
+ yapi-doc install-skill --dir ~/.claude/skills/yapi-doc
685
+ yapi-doc sync-skills
686
+ yapi-doc sync-skills --dry-run`)
687
+ }
688
+
689
+ function parseArgs(argv) {
690
+ const options = {
691
+ target: '',
692
+ search: '',
693
+ projectId: 0,
694
+ json: false,
695
+ full: false,
696
+ noFields: false,
697
+ help: false,
698
+ }
699
+ for (let index = 0; index < argv.length; index += 1) {
700
+ const arg = argv[index]
701
+ if (arg === '-h' || arg === '--help') {
702
+ options.help = true
703
+ continue
704
+ }
705
+ if (arg === '--json') {
706
+ options.json = true
707
+ continue
708
+ }
709
+ if (arg === '--full') {
710
+ options.full = true
711
+ continue
712
+ }
713
+ if (arg === '--no-fields') {
714
+ options.noFields = true
715
+ continue
716
+ }
717
+ if (arg === '--project') {
718
+ options.projectId = Number(argv[++index]) || 0
719
+ continue
720
+ }
721
+ if (arg.startsWith('--project=')) {
722
+ options.projectId = Number(arg.slice('--project='.length)) || 0
723
+ continue
724
+ }
725
+ if (arg === '--search') {
726
+ options.search = String(argv[++index] || '').trim()
727
+ continue
728
+ }
729
+ if (arg.startsWith('--search=')) {
730
+ options.search = arg.slice('--search='.length).trim()
731
+ continue
732
+ }
733
+ if (arg.startsWith('-')) throw new Error('未知选项:' + arg)
734
+ if (options.target !== '') throw new Error('只能指定一个文档 ID 或链接')
735
+ options.target = arg
736
+ }
737
+ return options
738
+ }
739
+
740
+ async function main() {
741
+ if (process.argv[2] === 'sync-skills') {
742
+ const dirs = []
743
+ let dryRun = false
744
+ for (let index = 3; index < process.argv.length; index += 1) {
745
+ const arg = process.argv[index]
746
+ if (arg === '-h' || arg === '--help') {
747
+ printHelp()
748
+ return
749
+ }
750
+ if (arg === '--dry-run') {
751
+ dryRun = true
752
+ continue
753
+ }
754
+ if (arg === '--dir') {
755
+ const dir = process.argv[index + 1] || ''
756
+ if (dir === '') throw new Error('--dir 需要一个目录')
757
+ dirs.push(dir)
758
+ index += 1
759
+ continue
760
+ }
761
+ if (arg.startsWith('--dir=')) {
762
+ const dir = arg.slice('--dir='.length)
763
+ if (dir === '') throw new Error('--dir 需要一个目录')
764
+ dirs.push(dir)
765
+ continue
766
+ }
767
+ throw new Error('未知选项:' + arg)
768
+ }
769
+ syncSkills(dirs, dryRun)
770
+ return
771
+ }
772
+ if (process.argv[2] === 'install-skill') {
773
+ let target = ''
774
+ for (let index = 3; index < process.argv.length; index += 1) {
775
+ const arg = process.argv[index]
776
+ if (arg === '-h' || arg === '--help') {
777
+ printHelp()
778
+ return
779
+ }
780
+ if (arg === '--dir') {
781
+ target = process.argv[index + 1] || ''
782
+ if (target === '') throw new Error('--dir 需要一个目录')
783
+ index += 1
784
+ } else if (arg.startsWith('--dir=')) {
785
+ target = arg.slice('--dir='.length)
786
+ if (target === '') throw new Error('--dir 需要一个目录')
787
+ } else {
788
+ throw new Error('未知选项:' + arg)
789
+ }
790
+ }
791
+ installSkill(target)
792
+ return
793
+ }
794
+ const options = parseArgs(process.argv.slice(2))
795
+ if (options.help) {
796
+ printHelp()
797
+ return
798
+ }
799
+ if (options.search === '' && options.target === '') {
800
+ printHelp()
801
+ throw new Error('请提供文档 ID / 链接,或使用 --search <关键字>')
802
+ }
803
+ const settings = readSettings()
804
+ const session = await createSession(settings)
805
+
806
+ if (options.search !== '') {
807
+ if (!Number.isSafeInteger(options.projectId) || options.projectId <= 0) {
808
+ throw new Error('请用 --project <项目ID> 指定搜索范围')
809
+ }
810
+ const project = await session.get('/api/project/get?id=' + encodeURIComponent(options.projectId)).catch(() => null)
811
+ const data = await session.get(
812
+ '/api/interface/list?project_id=' + encodeURIComponent(options.projectId) + '&page=1&limit=2000',
813
+ )
814
+ const list = Array.isArray(data?.list) ? data.list : []
815
+ const matches = rankInterfaces(list, options.search, 30)
816
+ if (options.json) {
817
+ console.log(
818
+ JSON.stringify(
819
+ {
820
+ ok: true,
821
+ data: {
822
+ projectId: options.projectId,
823
+ projectName: String(project?.name ?? ''),
824
+ total: list.length,
825
+ keyword: options.search,
826
+ items: matches.map((item) => ({
827
+ id: Number(item._id),
828
+ method: String(item.method ?? '').toUpperCase(),
829
+ path: String(item.path ?? ''),
830
+ title: String(item.title ?? ''),
831
+ })),
832
+ },
833
+ },
834
+ null,
835
+ 2,
836
+ ),
837
+ )
838
+ return
839
+ }
840
+ console.log(renderSearch(options.projectId, String(project?.name ?? ''), list.length, options.search, matches))
841
+ return
842
+ }
843
+
844
+ const id = parseDocId(options.target)
845
+ if (id === null) throw new Error('无法从输入中解析文档 ID:' + options.target)
846
+ const raw = await session.get('/api/interface/get?id=' + encodeURIComponent(id))
847
+ const project =
848
+ raw && Number(raw.project_id) > 0
849
+ ? await session.get('/api/project/get?id=' + encodeURIComponent(raw.project_id)).catch(() => null)
850
+ : null
851
+ const doc = buildDoc(raw, project, settings.baseUrl)
852
+ const requestFieldRows = requestFields(raw)
853
+ const responseFieldRows = responseFields(raw)
854
+
855
+ if (options.json) {
856
+ console.log(
857
+ JSON.stringify(
858
+ {
859
+ ok: true,
860
+ data: Object.assign({}, doc, {
861
+ requestFields: requestFieldRows,
862
+ responseFields: responseFieldRows,
863
+ }),
864
+ },
865
+ null,
866
+ 2,
867
+ ),
868
+ )
869
+ return
870
+ }
871
+ console.log(renderDoc(doc, requestFieldRows, responseFieldRows, options))
872
+ }
873
+
874
+ main().catch((error) => {
875
+ console.error('yapi-doc: ' + (error && error.message ? error.message : String(error)))
876
+ process.exit(1)
877
+ })