specleaf 1.1.2 → 1.2.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.
package/lib/cli.js CHANGED
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { mkdir, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
2
+ import { lstat, mkdir, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
3
3
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
4
  import { fileURLToPath, pathToFileURL } from "node:url";
5
5
  import $RefParser, { getJsonSchemaRefParserDefaultOptions } from "@apidevtools/json-schema-ref-parser";
6
6
  import { randomUUID } from "node:crypto";
7
+ import { homedir } from "node:os";
7
8
  // src/lib/openapi.ts
8
9
  var pointerEscape = (value) => value.replace(/~/g, "~0").replace(/\//g, "~1");
9
10
  // build/references.ts
@@ -309,8 +310,132 @@ async function generate(input, destination) {
309
310
  if (temporary) await rm(temporary, { force: true });
310
311
  }
311
312
  }
313
+ // build/skills.ts
314
+ var skillAgents = [
315
+ "codex",
316
+ "claude",
317
+ "opencode"
318
+ ];
319
+ var skillHelp = `用法:specleaf install skill [选项]
320
+
321
+ 安装随包提供的 specleaf skill,默认面向 Codex、Claude Code 和 OpenCode。
322
+ 默认安装到调用目录;--global 安装到用户目录。安装过程无需联网。
323
+ Codex 与 OpenCode 共用 .agents/skills/specleaf,Claude Code 使用 .claude/skills/specleaf。
324
+
325
+ 选项:
326
+ --agent <名称> 选择 codex、claude、opencode 或 all;可重复或用逗号分隔
327
+ -g, --global 安装到用户目录,供所有项目使用
328
+ --force 替换内容不同的 skill 文件,保留其他文件
329
+ -h, --help 显示帮助
330
+ `;
331
+ function parseSkillArguments(args) {
332
+ const options = {
333
+ agents: [],
334
+ global: false,
335
+ force: false
336
+ };
337
+ for (let index = 0; index < args.length; index++) {
338
+ const argument = args[index];
339
+ if (argument === "--help" || argument === "-h") return {
340
+ ...options,
341
+ help: true
342
+ };
343
+ if (argument === "--global" || argument === "-g") options.global = true;
344
+ else if (argument === "--force") options.force = true;
345
+ else if (argument === "--agent" || argument.startsWith("--agent=")) {
346
+ const value = argument === "--agent" ? args[++index] : argument.slice(8);
347
+ if (!value || value.startsWith("-")) throw new Error("--agent 缺少工具名称。");
348
+ for (const name of value.split(",").map((name) => name.trim())) if (name === "all") options.agents.push(...skillAgents);
349
+ else if (skillAgents.includes(name)) options.agents.push(name);
350
+ else throw new Error(`未知工具:${name || "(空)"}。支持 codex、claude、opencode 或 all。`);
351
+ } else throw new Error(`未知 skill 安装参数:${argument}`);
352
+ }
353
+ options.agents = options.agents.length ? [...new Set(options.agents)] : [...skillAgents];
354
+ return options;
355
+ }
356
+ var skillDirectories = {
357
+ codex: ".agents",
358
+ claude: ".claude",
359
+ opencode: ".agents"
360
+ };
361
+ var statIfPresent = (path) => lstat(path).catch((error) => {
362
+ if (error.code !== "ENOENT") throw error;
363
+ });
364
+ async function readSkillFiles(directory, prefix = "") {
365
+ const files = /* @__PURE__ */ new Map();
366
+ const entries = await readdir(join(directory, prefix), { withFileTypes: true });
367
+ for (const entry of entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) {
368
+ const path = join(prefix, entry.name);
369
+ if (entry.isDirectory()) for (const [name, content] of await readSkillFiles(directory, path)) files.set(name, content);
370
+ else if (entry.isFile()) files.set(path, await readFile(join(directory, path)));
371
+ else throw new Error(`skill 资源必须为普通文件或目录:${join(directory, path)}`);
372
+ }
373
+ return files;
374
+ }
375
+ async function checkDirectories(base, relative) {
376
+ let path = base;
377
+ for (const part of relative.split(sep)) {
378
+ if (!part || part === ".") continue;
379
+ path = join(path, part);
380
+ const existing = await statIfPresent(path);
381
+ if (existing && (!existing.isDirectory() || existing.isSymbolicLink())) throw new Error(`skill 安装目录不能是符号链接或非目录:${path}`);
382
+ }
383
+ }
384
+ async function installSkill(options, context) {
385
+ const files = await readSkillFiles(fileURLToPath(new URL("../skills/specleaf/", import.meta.url)));
386
+ const { version } = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
387
+ files.set("specleaf.json", Buffer.from(`${JSON.stringify({ version }, null, 2)}\n`));
388
+ const base = resolve(options.global ? context.homeDirectory ?? homedir() : context.cwd);
389
+ const targets = /* @__PURE__ */ new Map();
390
+ for (const agent of options.agents) {
391
+ const relative = join(skillDirectories[agent], "skills", "specleaf");
392
+ targets.set(relative, [...targets.get(relative) ?? [], agent]);
393
+ }
394
+ const plans = [];
395
+ for (const [relative, agents] of targets) {
396
+ await checkDirectories(base, relative);
397
+ const directory = join(base, relative);
398
+ const changes = [];
399
+ let hasFiles = false;
400
+ for (const [name, content] of files) {
401
+ await checkDirectories(base, join(relative, dirname(name)));
402
+ const path = join(directory, name);
403
+ const existing = await statIfPresent(path);
404
+ if (existing) {
405
+ if (!existing.isFile() || existing.isSymbolicLink()) throw new Error(`skill 文件不能是符号链接或非普通文件:${path}`);
406
+ hasFiles = true;
407
+ if ((await readFile(path)).equals(content)) continue;
408
+ if (!options.force) throw new Error(`已有 skill 文件内容不同:${path}\n使用 --force 替换 skill 文件;其他文件会保留。`);
409
+ }
410
+ changes.push({
411
+ path,
412
+ content
413
+ });
414
+ }
415
+ plans.push({
416
+ result: {
417
+ agents,
418
+ directory,
419
+ status: changes.length ? hasFiles ? "updated" : "installed" : "unchanged"
420
+ },
421
+ changes
422
+ });
423
+ }
424
+ for (const { changes } of plans) for (const { path, content } of changes) {
425
+ await mkdir(dirname(path), { recursive: true });
426
+ const temporary = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`);
427
+ try {
428
+ await writeFile(temporary, content, { flag: "wx" });
429
+ await rename(temporary, path);
430
+ } finally {
431
+ await rm(temporary, { force: true });
432
+ }
433
+ }
434
+ return plans.map(({ result }) => result);
435
+ }
312
436
  // build/cli.ts
313
437
  var help = `用法:specleaf <input> [-o <output.html>]
438
+ specleaf install skill [--agent <名称>] [--global] [--force]
314
439
 
315
440
  把本地 OpenAPI 3.0 / 3.1 YAML / JSON 文件或模块目录转为单个离线 HTML。
316
441
  目录递归发现 openapi.yaml、openapi.yml、openapi.json,各模块独立浏览。
@@ -320,6 +445,8 @@ var help = `用法:specleaf <input> [-o <output.html>]
320
445
  -o, --output <文件> 指定输出文件
321
446
  -h, --help 显示帮助
322
447
  -v, --version 显示版本
448
+
449
+ 运行 specleaf install skill --help 查看 Codex、Claude Code、OpenCode 的 skill 安装用法。
323
450
  `;
324
451
  function parseArguments(args) {
325
452
  let input;
@@ -357,15 +484,30 @@ function parseArguments(args) {
357
484
  };
358
485
  }
359
486
  try {
360
- const args = parseArguments(process.argv.slice(2));
361
- if (args.help) process.stdout.write(help);
362
- else if (args.version) {
363
- const { version } = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
364
- process.stdout.write(`${version}\n`);
487
+ const argv = process.argv.slice(2);
488
+ const cwd = fileURLToPath(import.meta.url) === resolve(projectRoot, "build/cli.ts") && process.env.npm_lifecycle_event === "generate" ? process.env.INIT_CWD || process.cwd() : process.cwd();
489
+ if (argv[0] === "install" && argv[1] === "skill") {
490
+ const options = parseSkillArguments(argv.slice(2));
491
+ if (options.help) process.stdout.write(skillHelp);
492
+ else {
493
+ const labels = {
494
+ installed: "已安装",
495
+ updated: "已更新",
496
+ unchanged: "已是相同内容,跳过"
497
+ };
498
+ for (const result of await installSkill(options, { cwd })) process.stdout.write(`${labels[result.status]} specleaf skill(${result.agents.join("、")}):${result.directory}\n`);
499
+ process.stdout.write("可在对应工具的下一轮会话中使用 specleaf skill;若未显示,请重新打开会话。\n");
500
+ }
365
501
  } else {
366
- const cwd = fileURLToPath(import.meta.url) === resolve(projectRoot, "build/cli.ts") && process.env.npm_lifecycle_event === "generate" ? process.env.INIT_CWD || process.cwd() : process.cwd();
367
- const output = await generate(contractEntry(cwd, args.input), resolve(cwd, args.output));
368
- process.stdout.write(`已生成离线 HTML:${output}\n`);
502
+ const args = parseArguments(argv);
503
+ if (args.help) process.stdout.write(help);
504
+ else if (args.version) {
505
+ const { version } = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
506
+ process.stdout.write(`${version}\n`);
507
+ } else {
508
+ const output = await generate(contractEntry(cwd, args.input), resolve(cwd, args.output));
509
+ process.stdout.write(`已生成离线 HTML:${output}\n`);
510
+ }
369
511
  }
370
512
  } catch (error) {
371
513
  process.stderr.write(`错误:${error.message}\n运行 specleaf --help 查看用法。\n`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specleaf",
3
- "version": "1.1.2",
3
+ "version": "1.2.0",
4
4
  "description": "将本地 OpenAPI 3.0 / 3.1 YAML / JSON 文件或模块目录转为按模块分组、离线阅读的单文件 HTML 文档。",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -18,6 +18,7 @@
18
18
  "files": [
19
19
  "lib/cli.js",
20
20
  "lib/assets/",
21
+ "skills/specleaf/",
21
22
  "README.md",
22
23
  "LICENSE"
23
24
  ],
@@ -0,0 +1,41 @@
1
+ ---
2
+ name: specleaf
3
+ description: 将本地 OpenAPI 契约生成为 SpecLeaf 单文件离线 HTML 文档。用于生成、更新、汇总 API 文档,或诊断 SpecLeaf 生成失败。
4
+ ---
5
+
6
+ # SpecLeaf
7
+
8
+ 将现有 OpenAPI 3.0 / 3.1 YAML / JSON 文件或模块目录转换为可离线阅读的 HTML。沿用项目现有契约,生成时保持入口及引用文件只读。
9
+
10
+ ## 选择输入和输出
11
+
12
+ - 优先使用用户指定路径,其次检查项目已有文档命令和约定,再在相关目录中寻找入口。区分包含 `openapi`、`info`、`paths` 的契约与被引用的模型片段。存在多个无明确关系的候选时,澄清生成范围。
13
+ - 单个契约使用文件输入;汇总多个模块使用其公共目录。目录模式递归识别准确命名的 `openapi.yaml`、`openapi.yml`、`openapi.json`,同一目录只能有一个入口。跳过隐藏目录、`node_modules`、`dist`、`build`、`coverage` 及目录符号链接。
14
+ - 各模块保留独立版本、服务地址、鉴权和引用作用域;无需手动合并契约或重命名同名模型。
15
+ - 输出遵循用户路径或项目约定;没有约定时可用 `./openapi.html`,显式传入 `-o`。已有文件时确认它是本任务要更新的产物,避免覆盖无关文件。
16
+
17
+ ## 调用 CLI
18
+
19
+ 需要 Node.js 22.19.0+。优先复用项目已有文档命令或已安装的 `specleaf`:
20
+
21
+ ```bash
22
+ specleaf "./docs/apis" -o "./docs/api.html"
23
+ ```
24
+
25
+ 输入、输出相对调用目录解析;`$ref` 相对包含它的文件解析。不要切换到 skill 目录执行生成。
26
+
27
+ 若只有项目内安装的 CLI,可通过 `npm exec --offline -- specleaf` 调用。若 CLI 不可用,读取本 skill 同目录安装记录 `specleaf.json` 的 `version`,用该版本执行 `npx --yes specleaf@版本 输入 -o 输出`,替换其中的版本和路径。没有安装记录时,先核实可用版本。获取 npm 包可能需要联网,应遵循当前环境的网络权限;包与依赖齐备后,本地生成无需联网。
28
+
29
+ ## 处理失败
30
+
31
+ - 缺失引用:根据报错的来源文件和 JSON Pointer 检查相对路径、目标文件和目标字段。
32
+ - 重复入口:报告具体目录及候选入口,不擅自删除或重命名契约。
33
+ - 无模块入口:检查范围和入口命名,避免将模型目录作为模块集合。
34
+ - 远程入口、远程 `$ref`、Swagger 2.0 不受支持;说明限制和所需的本地 OpenAPI 3.0 / 3.1 输入。
35
+ - 输出与入口或引用文件冲突时更换输出路径。修复契约内容应属于用户请求的范围,不能为生成成功编造接口、字段或示例。
36
+
37
+ ## 验收与交付
38
+
39
+ 确认命令退出码为零、输出 HTML 存在且非空。需要浏览器验收且环境具备条件时,检查标题、搜索和接口导航;只在实际验证后报告这些结果。
40
+
41
+ 交付 HTML 文件链接、输入范围、使用版本和可复现命令。产物包含脚本、样式和契约,可以单独复制并在浏览器离线打开。页面仅供阅读,不执行接口请求。生成成功表示完成解析与渲染,不代表完整的 OpenAPI / JSON Schema 语义校验。
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: "SpecLeaf"
3
+ short_description: "Generate offline HTML from local OpenAPI contracts"
4
+ default_prompt: "Use $specleaf to generate an offline HTML document from this project’s OpenAPI contracts."