apiskill 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,7 +17,7 @@ npm install -g apiskill
17
17
  apiskill run web
18
18
  ```
19
19
 
20
- Global npm installations store version data in the user-writable `~/.apiskill/cache` directory by default. Use `--cwd` for a project-local `<cwd>/cache`, or `--cache-dir` for a custom location. When developing this repository, you can also start it from the project root:
20
+ Web, CLI, and MCP all use the same user-writable `~/.apiskill/cache` directory by default. `--cwd` only changes the Web process working directory and never changes this shared cache. For an isolated cache, set the same absolute `APISKILL_CACHE_DIR` for every Web, CLI, and MCP process. When developing this repository, you can also start it from the project root:
21
21
 
22
22
  ```bash
23
23
  npm install
@@ -52,6 +52,95 @@ apiskill mock
52
52
 
53
53
  CLI and MCP are primarily designed for AI coding agents. Agents should check the cache first, initialize it only when needed, and query a focused endpoint instead of reading the entire OpenAPI document. Run `apiskill --help` for all CLI commands. In MCP clients, start with `apiskill_check`, use `apiskill_search_endpoints` or `apiskill_query_api` to locate an API, and call `apiskill_help` for the complete tool list.
54
54
 
55
+ ### AI Agent CLI CRUD Protocol
56
+
57
+ Use `--json` on commands that support it and parse the response instead of scraping human-readable output. For an isolated project cache, set `APISKILL_CACHE_DIR` to an absolute writable directory before every CLI call. Without it, the global installation uses `~/.apiskill/cache`.
58
+
59
+ 1. Check the cache, then create a blank document when no upstream document exists:
60
+
61
+ ```bash
62
+ apiskill check --json
63
+ apiskill document create --title "My API" --doc-version 1.0.0 --description "Local API contract" --json
64
+ ```
65
+
66
+ Read `meta.versionId` from the create response and reuse that exact value for every write. `api create` now defaults to the latest version when `--version` is omitted, but agents should still pass it explicitly so every write targets the intended project document.
67
+
68
+ 2. Save an API configuration as `api-config.json`:
69
+
70
+ ```json
71
+ {
72
+ "api": {
73
+ "method": "post",
74
+ "path": "/api/v1/users/{id}",
75
+ "summary": "Create user",
76
+ "operationId": "createUser",
77
+ "tags": ["Users"],
78
+ "parameters": [
79
+ { "name": "id", "location": "path", "required": true, "type": "string" }
80
+ ],
81
+ "requestBody": {
82
+ "required": true,
83
+ "contentType": "application/json",
84
+ "fields": [
85
+ { "name": "name", "type": "string", "required": true },
86
+ { "name": "email", "type": "string", "format": "email" }
87
+ ]
88
+ },
89
+ "responses": [
90
+ {
91
+ "status": "200",
92
+ "description": "User created",
93
+ "contentType": "application/json",
94
+ "fields": [
95
+ { "name": "success", "type": "boolean", "required": true },
96
+ { "name": "userId", "type": "string" }
97
+ ]
98
+ }
99
+ ]
100
+ }
101
+ }
102
+ ```
103
+
104
+ 3. Create, inspect, edit, and delete the operation:
105
+
106
+ ```bash
107
+ APISKILL_VERSION_ID="value-from-meta.versionId"
108
+
109
+ apiskill api create --version "$APISKILL_VERSION_ID" --file ./api-config.json --json
110
+ apiskill api list --version "$APISKILL_VERSION_ID" --query user --method POST --json
111
+ apiskill api query POST '/api/v1/users/{id}' --version "$APISKILL_VERSION_ID" --format cli
112
+
113
+ # Edit the returned CLI config and save it as api-config.updated.json.
114
+ # POST and the path below identify the original operation; the file contains its replacement.
115
+ apiskill api edit POST '/api/v1/users/{id}' --version "$APISKILL_VERSION_ID" --file ./api-config.updated.json --json
116
+
117
+ # Use the replacement method/path if the edit changed either value.
118
+ apiskill api delete PATCH '/api/v1/users/{id}' --version "$APISKILL_VERSION_ID" --json
119
+ ```
120
+
121
+ For a batch of operations, pass the same `APISKILL_VERSION_ID` to every command. For compatibility with older CLI releases, an agent should wait for each write to finish before starting the next one, then verify the final set:
122
+
123
+ ```bash
124
+ apiskill api create --version "$APISKILL_VERSION_ID" --file ./users.get.json --json
125
+ apiskill api create --version "$APISKILL_VERSION_ID" --file ./users.create.json --json
126
+ apiskill api create --version "$APISKILL_VERSION_ID" --file ./users.delete.json --json
127
+ apiskill api list --version "$APISKILL_VERSION_ID" --json
128
+ ```
129
+
130
+ The current CLI also serializes writes to the same cache across processes, so an AI tool that accidentally launches these commands in parallel will not lose earlier operations.
131
+
132
+ Agent rules:
133
+
134
+ - `api query` defaults to JSON and does not accept `--json`; use `--format cli` for a normalized configuration that can be edited and written back.
135
+ - `api edit ORIGINAL_METHOD ORIGINAL_PATH` locates the old operation. The replacement config may change its method or path.
136
+ - Quote paths containing `{id}` or other shell-sensitive characters.
137
+ - `api list --query` searches operation metadata and parameters, not response field names. Use exact `api query METHOD PATH` when method and path are known.
138
+ - A blank document with zero paths makes `check --json` return `ok: false` until at least one API is added. The document still exists; inspect `versionsCount` and `latestVersion`.
139
+ - Missing operations and invalid commands return a nonzero process exit code. Agents should treat that as failure and inspect stderr.
140
+ - `--config '<json-or-yaml>'` is equivalent to `--file`; files are safer for large or nested configurations.
141
+ - After a batch write, run `api list --version ... --json` and verify that every expected method/path exists before reporting success.
142
+ - OpenAPI identifies an operation by its method/path pair. Creating the same pair again intentionally replaces it; `meta.paths` counts distinct paths, not the total number of operations.
143
+
55
144
  ## Why This Tool Exists
56
145
 
57
146
  Since large AI models became available, the way developers use AI for coding has changed quickly. At first, many of us worked in the ChatGPT web UI by copying code, errors, and API documentation back and forth. Later, tools such as Cursor, Codex, and Claude Code made it possible for AI assistants to work inside an entire project, so the workflow moved from isolated prompts toward project-aware development.
package/README.zh.md CHANGED
@@ -17,7 +17,7 @@ npm install -g apiskill
17
17
  apiskill run web
18
18
  ```
19
19
 
20
- 通过 npm 全局安装时,Web 端默认使用用户可写的 `~/.apiskill/cache` 保存版本数据。使用 `--cwd` 可切换为项目目录下的 `<cwd>/cache`,使用 `--cache-dir` 可指定其他位置。开发本仓库时也可以在项目根目录启动:
20
+ Web、CLI MCP 默认统一使用用户可写的 `~/.apiskill/cache`,共同读写同一份文档和版本。`--cwd` 只改变 Web 进程的工作目录,不再改变共享缓存。需要隔离缓存时,必须为 Web、CLI 和 MCP 设置完全相同的绝对路径 `APISKILL_CACHE_DIR`。开发本仓库时也可以在项目根目录启动:
21
21
 
22
22
  ```bash
23
23
  npm install
@@ -52,6 +52,95 @@ apiskill mock
52
52
 
53
53
  CLI 和 MCP 主要面向 AI 编码 Agent。Agent 应先检查缓存,只在缺少文档时初始化,然后按当前任务精确查询接口,避免每次读取整份 OpenAPI 文档。运行 `apiskill --help` 可以查看全部 CLI 命令;在 MCP 客户端中先调用 `apiskill_check`,再使用 `apiskill_search_endpoints` 或 `apiskill_query_api` 定位接口,调用 `apiskill_help` 可查看完整工具列表。
54
54
 
55
+ ### AI Agent CLI 增删改查协议
56
+
57
+ 支持 `--json` 的命令应优先使用 JSON 输出并解析字段,不要抓取人类可读文本。需要按项目隔离缓存时,每次调用前将 `APISKILL_CACHE_DIR` 设置为绝对可写目录;未设置时,全局安装默认使用 `~/.apiskill/cache`。
58
+
59
+ 1. 检查缓存;没有上游文档时创建空白文档:
60
+
61
+ ```bash
62
+ apiskill check --json
63
+ apiskill document create --title "My API" --doc-version 1.0.0 --description "Local API contract" --json
64
+ ```
65
+
66
+ 从创建结果读取 `meta.versionId`,后续所有写操作都复用这个精确值。现在 `api create` 省略 `--version` 时会默认写入最新版本,但 Agent 仍应显式传入,确保每次都写入目标项目文档。
67
+
68
+ 2. 将接口配置保存为 `api-config.json`:
69
+
70
+ ```json
71
+ {
72
+ "api": {
73
+ "method": "post",
74
+ "path": "/api/v1/users/{id}",
75
+ "summary": "创建用户",
76
+ "operationId": "createUser",
77
+ "tags": ["Users"],
78
+ "parameters": [
79
+ { "name": "id", "location": "path", "required": true, "type": "string" }
80
+ ],
81
+ "requestBody": {
82
+ "required": true,
83
+ "contentType": "application/json",
84
+ "fields": [
85
+ { "name": "name", "type": "string", "required": true },
86
+ { "name": "email", "type": "string", "format": "email" }
87
+ ]
88
+ },
89
+ "responses": [
90
+ {
91
+ "status": "200",
92
+ "description": "用户创建成功",
93
+ "contentType": "application/json",
94
+ "fields": [
95
+ { "name": "success", "type": "boolean", "required": true },
96
+ { "name": "userId", "type": "string" }
97
+ ]
98
+ }
99
+ ]
100
+ }
101
+ }
102
+ ```
103
+
104
+ 3. 新增、读取、修改并删除接口:
105
+
106
+ ```bash
107
+ APISKILL_VERSION_ID="value-from-meta.versionId"
108
+
109
+ apiskill api create --version "$APISKILL_VERSION_ID" --file ./api-config.json --json
110
+ apiskill api list --version "$APISKILL_VERSION_ID" --query user --method POST --json
111
+ apiskill api query POST '/api/v1/users/{id}' --version "$APISKILL_VERSION_ID" --format cli
112
+
113
+ # 修改返回的 CLI 配置并保存为 api-config.updated.json。
114
+ # 下面的 POST 和路径用于定位旧接口,文件中保存替换后的新配置。
115
+ apiskill api edit POST '/api/v1/users/{id}' --version "$APISKILL_VERSION_ID" --file ./api-config.updated.json --json
116
+
117
+ # 如果修改时改变了 method 或 path,删除时使用替换后的值。
118
+ apiskill api delete PATCH '/api/v1/users/{id}' --version "$APISKILL_VERSION_ID" --json
119
+ ```
120
+
121
+ 批量写入多个接口时,每条命令都必须使用同一个 `APISKILL_VERSION_ID`。为了兼容旧版 CLI,Agent 应等待上一条写命令完成后再执行下一条,最后检查完整接口列表:
122
+
123
+ ```bash
124
+ apiskill api create --version "$APISKILL_VERSION_ID" --file ./users.get.json --json
125
+ apiskill api create --version "$APISKILL_VERSION_ID" --file ./users.create.json --json
126
+ apiskill api create --version "$APISKILL_VERSION_ID" --file ./users.delete.json --json
127
+ apiskill api list --version "$APISKILL_VERSION_ID" --json
128
+ ```
129
+
130
+ 当前版本还会在多个进程之间串行化同一缓存目录的写操作,因此 AI 工具即使意外并行执行这些命令,也不会再丢失先写入的接口。
131
+
132
+ Agent 执行规则:
133
+
134
+ - `api query` 默认输出 JSON,不支持 `--json`;需要可修改并回写的标准配置时使用 `--format cli`。
135
+ - `api edit ORIGINAL_METHOD ORIGINAL_PATH` 的前两个参数定位旧接口,新配置可以改变 method 或 path。
136
+ - 包含 `{id}` 等 shell 特殊字符的路径必须加引号。
137
+ - `api list --query` 搜索接口元数据和参数,不搜索响应字段名。已知 method 和 path 时使用精确的 `api query METHOD PATH`。
138
+ - 空白文档没有任何 path 时,`check --json` 会返回 `ok: false`,直到至少添加一个 API。文档并未丢失,可检查 `versionsCount` 和 `latestVersion`。
139
+ - 接口不存在或命令参数无效时,进程返回非零退出码。Agent 应视为失败并读取 stderr。
140
+ - `--config '<json-or-yaml>'` 与 `--file` 等效;复杂或嵌套配置优先使用文件,避免 shell 转义错误。
141
+ - 批量写入结束后,执行 `api list --version ... --json`,逐一核对预期的 method/path 均存在,再报告任务成功。
142
+ - OpenAPI 使用 method/path 组合唯一标识接口;再次创建相同组合会按预期替换原接口。`meta.paths` 统计的是不同路径数,不是接口操作总数。
143
+
55
144
  ## 为什么开发这个工具
56
145
 
57
146
  自从 AI 大模型面世这几年,开发人员使用 AI 写代码的方式一直在变化。最开始,很多人是在 ChatGPT 网页端来回复制粘贴代码、报错和接口文档;后来 Cursor、Codex、Claude Code 这类可以集成整个项目的桌面端或本地开发工具出现,AI 辅助开发逐渐从单次问答变成了围绕整个项目上下文协作。
package/docs/cli.md CHANGED
@@ -14,11 +14,12 @@ apiskill --help
14
14
  apiskill run web
15
15
  ```
16
16
 
17
- `apiskill run web` starts the web console and writes cache data to the user-writable `~/.apiskill/cache` directory by default. `--cwd` uses `<cwd>/cache`; `--cache-dir` selects an explicit location:
17
+ `apiskill run web`, CLI commands, and MCP all use `~/.apiskill/cache` by default. `--cwd` changes only the Web working directory and does not change the shared cache. For project isolation, set the same absolute `APISKILL_CACHE_DIR` for every Web, CLI, and MCP process:
18
18
 
19
19
  ```bash
20
20
  apiskill run web --port 8890
21
- apiskill run web --cwd /path/to/project --cache-dir .apiskill-cache
21
+ APISKILL_CACHE_DIR=/path/to/project/.apiskill-cache apiskill run web --cwd /path/to/project
22
+ APISKILL_CACHE_DIR=/path/to/project/.apiskill-cache apiskill check --json
22
23
  ```
23
24
 
24
25
  From the source project root:
@@ -39,17 +40,17 @@ node scripts/apiskill-cli.mjs --help
39
40
  Check whether a usable cache is available:
40
41
 
41
42
  ```bash
42
- npm run cli -- check
43
- npm run cli -- check --json
43
+ apiskill check
44
+ apiskill check --json
44
45
  ```
45
46
 
46
47
  If no cache exists, `check` prints import examples.
47
48
 
48
49
  ```bash
49
- npm run cli -- import https://example.com/openapi.json
50
- npm run cli -- crawl https://example.com/swagger
51
- npm run cli -- import-file ./openapi.yaml
52
- npm run cli -- import-curl --file ./request.curl
50
+ apiskill import https://example.com/openapi.json
51
+ apiskill crawl https://example.com/swagger
52
+ apiskill import-file ./openapi.yaml
53
+ apiskill import-curl --file ./request.curl
53
54
  ```
54
55
 
55
56
  Use `--auth username:password` with `import` or `crawl` when the document endpoint requires basic auth.
@@ -59,25 +60,27 @@ Use `--auth username:password` with `import` or `crawl` when the document endpoi
59
60
  When there is no upstream OpenAPI document yet, create a blank local document version:
60
61
 
61
62
  ```bash
62
- npm run cli -- document create --title "My API" --doc-version 1.0.0
63
- npm run cli -- document create --title "My API" --doc-version 1.0.0 --description "Internal service contract" --json
63
+ apiskill document create --title "My API" --doc-version 1.0.0
64
+ apiskill document create --title "My API" --doc-version 1.0.0 --description "Internal service contract" --json
64
65
  ```
65
66
 
66
- The created document is saved as the latest cached version. Add endpoints to it with `api create --version <versionId>` or omit `--version` when the blank document is already latest.
67
+ The created document is saved as the latest cached version. Read `meta.versionId` from the JSON response and pass it explicitly to later writes. `api create` defaults to the latest version when `--version` is omitted, but explicit version IDs keep agent workflows deterministic.
67
68
 
68
69
  ## Query Versions And APIs
69
70
 
70
71
  ```bash
71
- npm run cli -- versions
72
- npm run cli -- versions --json
73
- npm run cli -- query /admin/api/v1/user/list --method GET
74
- npm run cli -- query user --method POST --limit 10
75
- npm run cli -- api list --query user --method post
76
- npm run cli -- api query GET /api/v1/user --format cli
72
+ apiskill versions
73
+ apiskill versions --json
74
+ apiskill query /admin/api/v1/user/list --method GET
75
+ apiskill query user --method POST --limit 10
76
+ apiskill api list --query user --method post
77
+ apiskill api query GET /api/v1/user --format cli
77
78
  ```
78
79
 
79
80
  `query` behaves like the MCP `apiskill_query_api` tool: exact single matches return one API config, while multiple matches return candidates.
80
81
 
82
+ `api query` defaults to JSON and does not accept `--json`. Use `--format cli` to get a normalized config that can be edited and sent back to `api edit`.
83
+
81
84
  ## Start A MOCK Server
82
85
 
83
86
  After an API document is cached, start a local MOCK API server:
@@ -93,11 +96,18 @@ The MOCK server generates local API routes from the current OpenAPI paths, HTTP
93
96
  ## Create, Edit, And Delete Manual APIs
94
97
 
95
98
  ```bash
96
- npm run cli -- api create --file ./api-config.yaml
97
- npm run cli -- api edit GET /api/v1/user --file ./api-config.json --version 20260429T000000Z-manual-user
98
- npm run cli -- api delete GET /api/v1/user --version 20260429T000000Z-manual-user
99
+ APISKILL_VERSION_ID="value-from-meta.versionId"
100
+ apiskill api create --version "$APISKILL_VERSION_ID" --file ./api-config.yaml --json
101
+ apiskill api edit GET /api/v1/user --version "$APISKILL_VERSION_ID" --file ./api-config.json --json
102
+ apiskill api delete GET /api/v1/user --version "$APISKILL_VERSION_ID" --json
99
103
  ```
100
104
 
105
+ The method and path passed to `api edit` identify the original operation. The replacement file may contain a different method or path. Quote paths that contain `{id}` or other shell-sensitive characters.
106
+
107
+ When adding several APIs, reuse the exact same version ID for every command. Agents should run writes sequentially for compatibility with older releases and finish with `apiskill api list --version "$APISKILL_VERSION_ID" --json` to verify every expected method/path. The current CLI also uses a cross-process cache write lock, so accidentally parallel commands are serialized instead of overwriting one another.
108
+
109
+ An OpenAPI operation is unique by method/path. Creating the same pair again replaces that operation. `meta.paths` is the number of distinct paths, so use `api list` when validating the total operation count.
110
+
101
111
  CLI config can be JSON or YAML with root key `api`, `config`, or `operation`.
102
112
 
103
113
  Minimal JSON example:
package/docs/cli.zh.md CHANGED
@@ -14,11 +14,12 @@ apiskill --help
14
14
  apiskill run web
15
15
  ```
16
16
 
17
- `apiskill run web` 会启动 Web 控制台,默认把缓存写入用户可写的 `~/.apiskill/cache`。使用 `--cwd` 时缓存位于 `<cwd>/cache`,也可以用 `--cache-dir` 指定其他目录:
17
+ `apiskill run web`、其他 CLI 命令和 MCP 默认统一使用 `~/.apiskill/cache`。`--cwd` 只改变 Web 工作目录,不会改变共享缓存。需要按项目隔离时,必须为 Web、CLI 和 MCP 设置相同的绝对路径 `APISKILL_CACHE_DIR`:
18
18
 
19
19
  ```bash
20
20
  apiskill run web --port 8890
21
- apiskill run web --cwd /path/to/project --cache-dir .apiskill-cache
21
+ APISKILL_CACHE_DIR=/path/to/project/.apiskill-cache apiskill run web --cwd /path/to/project
22
+ APISKILL_CACHE_DIR=/path/to/project/.apiskill-cache apiskill check --json
22
23
  ```
23
24
 
24
25
  在源码项目根目录运行:
@@ -39,17 +40,17 @@ node scripts/apiskill-cli.mjs --help
39
40
  先检查当前是否有可用缓存:
40
41
 
41
42
  ```bash
42
- npm run cli -- check
43
- npm run cli -- check --json
43
+ apiskill check
44
+ apiskill check --json
44
45
  ```
45
46
 
46
47
  如果没有可用缓存,`check` 会输出导入文档的示例。
47
48
 
48
49
  ```bash
49
- npm run cli -- import https://example.com/openapi.json
50
- npm run cli -- crawl https://example.com/swagger
51
- npm run cli -- import-file ./openapi.yaml
52
- npm run cli -- import-curl --file ./request.curl
50
+ apiskill import https://example.com/openapi.json
51
+ apiskill crawl https://example.com/swagger
52
+ apiskill import-file ./openapi.yaml
53
+ apiskill import-curl --file ./request.curl
53
54
  ```
54
55
 
55
56
  如果文档地址需要 basic auth,`import` 和 `crawl` 可以加 `--auth username:password`。
@@ -59,25 +60,27 @@ npm run cli -- import-curl --file ./request.curl
59
60
  如果还没有上游 OpenAPI 文档,可以先创建一份本地空白文档版本:
60
61
 
61
62
  ```bash
62
- npm run cli -- document create --title "My API" --doc-version 1.0.0
63
- npm run cli -- document create --title "My API" --doc-version 1.0.0 --description "Internal service contract" --json
63
+ apiskill document create --title "My API" --doc-version 1.0.0
64
+ apiskill document create --title "My API" --doc-version 1.0.0 --description "Internal service contract" --json
64
65
  ```
65
66
 
66
- 创建后的文档会保存为最新缓存版本。后续可以用 `api create --version <versionId>` 追加接口;如果空白文档已经是最新版本,也可以省略 `--version`。
67
+ 创建后的文档会保存为最新缓存版本。从 JSON 结果读取 `meta.versionId`,后续写操作都显式传入这个值。`api create` 省略 `--version` 时会默认写入最新版本,但显式版本 ID 能让 Agent 的操作目标保持确定。
67
68
 
68
69
  ## 查询版本和接口
69
70
 
70
71
  ```bash
71
- npm run cli -- versions
72
- npm run cli -- versions --json
73
- npm run cli -- query /admin/api/v1/user/list --method GET
74
- npm run cli -- query user --method POST --limit 10
75
- npm run cli -- api list --query user --method post
76
- npm run cli -- api query GET /api/v1/user --format cli
72
+ apiskill versions
73
+ apiskill versions --json
74
+ apiskill query /admin/api/v1/user/list --method GET
75
+ apiskill query user --method POST --limit 10
76
+ apiskill api list --query user --method post
77
+ apiskill api query GET /api/v1/user --format cli
77
78
  ```
78
79
 
79
80
  `query` 和 MCP 的 `apiskill_query_api` 行为一致:精确单条匹配时返回一个接口配置,多条匹配时返回候选列表。
80
81
 
82
+ `api query` 默认输出 JSON,不支持 `--json`。需要可修改并传给 `api edit` 的标准配置时使用 `--format cli`。
83
+
81
84
  ## 启动 MOCK 服务
82
85
 
83
86
  已有 API 文档缓存后,可以启动本地 MOCK API 服务:
@@ -93,11 +96,18 @@ MOCK 服务会根据当前 OpenAPI 文档里的接口路径、HTTP 方法和响
93
96
  ## 创建、编辑、删除手动接口
94
97
 
95
98
  ```bash
96
- npm run cli -- api create --file ./api-config.yaml
97
- npm run cli -- api edit GET /api/v1/user --file ./api-config.json --version 20260429T000000Z-manual-user
98
- npm run cli -- api delete GET /api/v1/user --version 20260429T000000Z-manual-user
99
+ APISKILL_VERSION_ID="value-from-meta.versionId"
100
+ apiskill api create --version "$APISKILL_VERSION_ID" --file ./api-config.yaml --json
101
+ apiskill api edit GET /api/v1/user --version "$APISKILL_VERSION_ID" --file ./api-config.json --json
102
+ apiskill api delete GET /api/v1/user --version "$APISKILL_VERSION_ID" --json
99
103
  ```
100
104
 
105
+ `api edit` 后面的 method 和 path 用于定位旧接口,替换文件可以包含不同的 method 或 path。路径中包含 `{id}` 等 shell 特殊字符时应加引号。
106
+
107
+ 批量新增多个接口时,每条命令必须复用同一个版本 ID。为了兼容旧版本,Agent 应串行执行写命令,并在结束后运行 `apiskill api list --version "$APISKILL_VERSION_ID" --json`,核对所有预期的 method/path。当前 CLI 也使用跨进程缓存写锁,意外并行的命令会被串行处理,不再互相覆盖。
108
+
109
+ OpenAPI 使用 method/path 组合唯一标识接口;再次创建相同组合会替换原接口。`meta.paths` 是不同路径的数量,校验接口操作总数时应以 `api list` 为准。
110
+
101
111
  CLI 配置可以是 JSON 或 YAML,根字段支持 `api`、`config` 或 `operation`。
102
112
 
103
113
  最小 JSON 示例:
package/docs/mcp.ja.md CHANGED
@@ -20,12 +20,10 @@ cwd = "/Users/dobby/dev/apiskill"
20
20
  startup_timeout_sec = 10
21
21
  tool_timeout_sec = 60
22
22
  enabled = true
23
-
24
- [mcp_servers.apiskill.env]
25
- APISKILL_ROOT = "/Users/dobby/dev/apiskill"
26
- APISKILL_CACHE_DIR = "/Users/dobby/dev/apiskill/cache"
27
23
  ```
28
24
 
25
+ キャッシュ環境変数を設定しない場合、MCP、Web、CLI は同じ `~/.apiskill/cache` を使用します。プロジェクトごとに分離する場合は、3 つのプロセスすべてに同じ絶対パスの `APISKILL_CACHE_DIR` を設定してください。
26
+
29
27
  Codex を再起動して `/mcp` を実行します。`apiskill` と以下のツールが表示されるはずです。
30
28
 
31
29
  ## 読み取りツール
package/docs/mcp.ko.md CHANGED
@@ -20,12 +20,10 @@ cwd = "/Users/dobby/dev/apiskill"
20
20
  startup_timeout_sec = 10
21
21
  tool_timeout_sec = 60
22
22
  enabled = true
23
-
24
- [mcp_servers.apiskill.env]
25
- APISKILL_ROOT = "/Users/dobby/dev/apiskill"
26
- APISKILL_CACHE_DIR = "/Users/dobby/dev/apiskill/cache"
27
23
  ```
28
24
 
25
+ 캐시 환경 변수를 설정하지 않으면 MCP, Web, CLI는 모두 같은 `~/.apiskill/cache`를 사용합니다. 프로젝트별로 분리할 때는 세 프로세스에 동일한 절대 경로의 `APISKILL_CACHE_DIR`를 설정하세요.
26
+
29
27
  Codex를 재시작하고 `/mcp`를 실행합니다. `apiskill`과 아래 도구들이 보여야 합니다.
30
28
 
31
29
  ## 읽기 도구
package/docs/mcp.md CHANGED
@@ -22,12 +22,10 @@ cwd = "/Users/dobby/dev/apiskill"
22
22
  startup_timeout_sec = 10
23
23
  tool_timeout_sec = 60
24
24
  enabled = true
25
-
26
- [mcp_servers.apiskill.env]
27
- APISKILL_ROOT = "/Users/dobby/dev/apiskill"
28
- APISKILL_CACHE_DIR = "/Users/dobby/dev/apiskill/cache"
29
25
  ```
30
26
 
27
+ With no cache environment configured, MCP uses the same `~/.apiskill/cache` default as Web and CLI. For project isolation, set one absolute `APISKILL_CACHE_DIR` value and use that exact value for all three processes.
28
+
31
29
  Restart Codex and run `/mcp`. You should see `apiskill` with the tools below.
32
30
 
33
31
  ## Read Tools
package/docs/mcp.zh.md CHANGED
@@ -22,12 +22,10 @@ cwd = "/Users/dobby/dev/apiskill"
22
22
  startup_timeout_sec = 10
23
23
  tool_timeout_sec = 60
24
24
  enabled = true
25
-
26
- [mcp_servers.apiskill.env]
27
- APISKILL_ROOT = "/Users/dobby/dev/apiskill"
28
- APISKILL_CACHE_DIR = "/Users/dobby/dev/apiskill/cache"
29
25
  ```
30
26
 
27
+ 不配置缓存环境变量时,MCP 与 Web、CLI 一样默认使用 `~/.apiskill/cache`。需要按项目隔离时,设置一个绝对路径 `APISKILL_CACHE_DIR`,并确保三个进程使用完全相同的值。
28
+
31
29
  重启 Codex 后运行 `/mcp`,应该能看到 `apiskill` 和下面的工具。
32
30
 
33
31
  ## 只读工具
package/docs/web.md CHANGED
@@ -11,7 +11,7 @@ npm install -g apiskill
11
11
  apiskill run web
12
12
  ```
13
13
 
14
- Global npm installations use the user-writable `~/.apiskill/cache` directory by default. Use `--cwd` for a project-local `<cwd>/cache`, or `--cache-dir` for an explicit path. When developing this repository, you can also use:
14
+ Web, CLI, and MCP use the same user-writable `~/.apiskill/cache` directory by default. `--cwd` changes only the working directory. For an isolated cache, set the same absolute `APISKILL_CACHE_DIR` for all three processes. When developing this repository, you can also use:
15
15
 
16
16
  ```bash
17
17
  npm install
package/docs/web.zh.md CHANGED
@@ -11,7 +11,7 @@ npm install -g apiskill
11
11
  apiskill run web
12
12
  ```
13
13
 
14
- 通过 npm 全局安装时,默认缓存目录是用户可写的 `~/.apiskill/cache`。使用 `--cwd` 可切换为项目目录下的 `<cwd>/cache`,使用 `--cache-dir` 可指定其他位置。开发本仓库时也可以使用:
14
+ Web、CLI MCP 默认统一使用用户可写的 `~/.apiskill/cache`,共享同一份文档和版本。`--cwd` 只改变工作目录。需要隔离缓存时,必须为三个进程设置相同的绝对路径 `APISKILL_CACHE_DIR`。开发本仓库时也可以使用:
15
15
 
16
16
  ```bash
17
17
  npm install
@@ -3,11 +3,7 @@
3
3
  "apiskill": {
4
4
  "command": "node",
5
5
  "args": ["/Users/dobby/dev/apiskill/scripts/mcp-server.mjs"],
6
- "cwd": "/Users/dobby/dev/apiskill",
7
- "env": {
8
- "APISKILL_ROOT": "/Users/dobby/dev/apiskill",
9
- "APISKILL_CACHE_DIR": "/Users/dobby/dev/apiskill/cache"
10
- }
6
+ "cwd": "/Users/dobby/dev/apiskill"
11
7
  }
12
8
  }
13
9
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apiskill",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
@@ -21,6 +21,8 @@
21
21
  "scripts": {
22
22
  "dev": "vite --host 0.0.0.0 && node scripts/mcp-server.mjs",
23
23
  "build": "tsc -b && vite build",
24
+ "test:cli-concurrency": "node scripts/test-cli-concurrency.mjs",
25
+ "test:shared-cache": "node scripts/test-shared-cache.mjs",
24
26
  "prepack": "npm run build",
25
27
  "preview": "vite preview --host 0.0.0.0",
26
28
  "mcp": "node scripts/mcp-server.mjs",
@@ -32,13 +34,13 @@
32
34
  "lucide-react": "^0.468.0",
33
35
  "react": "^18.3.1",
34
36
  "react-dom": "^18.3.1",
35
- "typescript": "^5.8.3",
36
37
  "vite": "^6.0.7",
37
38
  "yaml": "^2.8.3"
38
39
  },
39
40
  "devDependencies": {
40
41
  "@types/node": "^22.10.2",
41
42
  "@types/react": "^18.3.18",
42
- "@types/react-dom": "^18.3.5"
43
+ "@types/react-dom": "^18.3.5",
44
+ "typescript": "^7.0.2"
43
45
  }
44
46
  }
@@ -49,7 +49,7 @@ Examples:
49
49
  apiskill query /admin/api/v1/activity --method GET
50
50
  apiskill api list --query user --method post
51
51
  apiskill api query GET /api/v1/user --format cli
52
- apiskill api create --file ./api-config.yaml
52
+ apiskill api create --version <versionId> --file ./api-config.yaml
53
53
  apiskill api edit GET /api/v1/user --file ./api-config.json --version 20260429T000000Z-manual-user
54
54
  apiskill api delete GET /api/v1/user --version 20260429T000000Z-manual-user
55
55
 
@@ -67,8 +67,7 @@ run
67
67
  .option('-p, --port <number>', 'Web server port', '8888')
68
68
  .option('--host <host>', 'Host to bind', '127.0.0.1')
69
69
  .option('--strict-port', 'Fail if the requested port is already in use')
70
- .option('--cwd <dir>', 'Project directory. Uses <cwd>/cache unless --cache-dir is provided')
71
- .option('--cache-dir <dir>', 'Cache directory. Defaults to ~/.apiskill/cache')
70
+ .option('--cwd <dir>', 'Project working directory. Does not change the shared cache directory')
72
71
  .action(async (options) => {
73
72
  await runWeb(options);
74
73
  });
@@ -239,8 +238,8 @@ api
239
238
 
240
239
  api
241
240
  .command('create')
242
- .description('Create an API operation. If --version is omitted, a new manual version is created.')
243
- .option('-v, --version <versionId>', 'Version id to append to')
241
+ .description('Create an API operation in a cached version.')
242
+ .option('-v, --version <versionId>', 'Version id. Defaults to latest; creates a manual version only if the cache is empty.')
244
243
  .option('-f, --file <file>', 'JSON/YAML/CLI config file')
245
244
  .option('-c, --config <text>', 'Inline JSON/YAML/CLI config')
246
245
  .option('-j, --json', 'Print JSON')
@@ -297,11 +296,7 @@ function registerMockCommand(command) {
297
296
 
298
297
  async function runWeb(options) {
299
298
  const projectRoot = resolve(options.cwd || process.cwd());
300
- const cacheDir = options.cacheDir
301
- ? resolve(projectRoot, options.cacheDir)
302
- : options.cwd
303
- ? resolve(projectRoot, 'cache')
304
- : getDefaultCacheDir();
299
+ const cacheDir = getDefaultCacheDir();
305
300
  const port = Number(options.port) || 8888;
306
301
  const host = options.host || '127.0.0.1';
307
302
 
@@ -13,6 +13,7 @@ import {
13
13
  saveManualOperation,
14
14
  } from './openapi-store.mjs';
15
15
  import { crawlOpenApi, importFromCurl, importFromLocalFile, importFromUrl } from './openapi-importer.mjs';
16
+ import { getDefaultCacheDir } from './cache-paths.mjs';
16
17
 
17
18
  const MAX_DEPTH = 12;
18
19
 
@@ -22,10 +23,11 @@ export const importExamples = {
22
23
  'Open the local URL, then import a direct OpenAPI JSON/YAML URL, crawl Swagger UI / Knife4j / Redoc, upload a file, or paste a curl command.',
23
24
  ],
24
25
  cli: [
25
- 'npm run cli -- import https://example.com/openapi.json',
26
- 'npm run cli -- crawl https://example.com/swagger',
27
- 'npm run cli -- import-file ./openapi.yaml',
28
- 'npm run cli -- import-curl --file ./request.curl',
26
+ 'apiskill import https://example.com/openapi.json',
27
+ 'apiskill crawl https://example.com/swagger',
28
+ 'apiskill import-file ./openapi.yaml',
29
+ 'apiskill import-curl --file ./request.curl',
30
+ 'apiskill document create --title "My API" --doc-version 1.0.0 --json',
29
31
  ],
30
32
  mcp: [
31
33
  { tool: 'apiskill_import_url', arguments: { url: 'https://example.com/openapi.json' } },
@@ -36,6 +38,7 @@ export const importExamples = {
36
38
  };
37
39
 
38
40
  export async function checkCache() {
41
+ const cacheDir = getDefaultCacheDir();
39
42
  const versions = await listCachedVersions();
40
43
  try {
41
44
  const { document, meta } = await readCachedDocument();
@@ -45,6 +48,7 @@ export async function checkCache() {
45
48
  return {
46
49
  ok: false,
47
50
  message: 'No usable cached OpenAPI document found. The latest cache exists, but it has no paths.',
51
+ cacheDir,
48
52
  versionsCount: versions.length,
49
53
  latestVersion: metaSummary(meta),
50
54
  importExamples,
@@ -53,6 +57,7 @@ export async function checkCache() {
53
57
  return {
54
58
  ok: true,
55
59
  message: 'API Skill cache is ready.',
60
+ cacheDir,
56
61
  versionsCount: versions.length,
57
62
  latestVersion: {
58
63
  ...metaSummary(meta),
@@ -65,6 +70,7 @@ export async function checkCache() {
65
70
  return {
66
71
  ok: false,
67
72
  message: 'No usable cached OpenAPI document found. Import or crawl a document first.',
73
+ cacheDir,
68
74
  error: error instanceof Error ? error.message : 'Unknown cache read error',
69
75
  versionsCount: versions.length,
70
76
  latestVersion: versions.find((version) => version.latest) ?? versions[0],
@@ -75,6 +81,7 @@ export async function checkCache() {
75
81
 
76
82
  export function formatCheckText(result) {
77
83
  const lines = [];
84
+ if (result.cacheDir) lines.push(`Cache: ${result.cacheDir}`);
78
85
  if (result.ok) {
79
86
  lines.push('OK: API Skill cache is ready.');
80
87
  if (result.latestVersion?.versionId) lines.push(`Latest version: ${result.latestVersion.versionId}`);
@@ -3,6 +3,5 @@ import { resolve } from 'node:path';
3
3
 
4
4
  export function getDefaultCacheDir(env = process.env) {
5
5
  if (env.APISKILL_CACHE_DIR) return resolve(env.APISKILL_CACHE_DIR);
6
- if (env.APISKILL_ROOT) return resolve(env.APISKILL_ROOT, 'cache');
7
6
  return resolve(homedir(), '.apiskill', 'cache');
8
7
  }
@@ -1,5 +1,5 @@
1
1
  import { existsSync } from 'node:fs';
2
- import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
2
+ import { mkdir, open, readFile, readdir, stat, unlink, writeFile } from 'node:fs/promises';
3
3
  import { basename, dirname, resolve } from 'node:path';
4
4
  import { parse as parseYaml } from 'yaml';
5
5
  import { getDefaultCacheDir } from './cache-paths.mjs';
@@ -11,6 +11,9 @@ const versionsDir = resolve(cacheDir, 'versions');
11
11
  const latestMetaPath = resolve(cacheDir, 'latest-import.json');
12
12
  const legacyCachePath = resolve(cacheDir, 'openapi-cache.json');
13
13
  const legacyCacheMetaPath = resolve(cacheDir, 'import-meta.json');
14
+ const writeLockPath = resolve(cacheDir, '.write.lock');
15
+ const writeLockTimeoutMs = 15_000;
16
+ const malformedLockStaleMs = 30_000;
14
17
 
15
18
  export function parseOpenApiText(text) {
16
19
  const cleaned = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
@@ -94,20 +97,22 @@ export async function listCachedVersions() {
94
97
  }
95
98
 
96
99
  export async function saveImportedDocument({ document, mode, inputUrl, resolvedUrl, versionId }) {
97
- if (!isOpenApiDocument(document)) throw new Error('不是有效的 OpenAPI/Swagger 文档');
98
- const existing = versionId ? await readCachedDocument(versionId) : undefined;
99
- const savedAt = new Date();
100
- const nextVersionId = existing?.meta?.versionId || `${formatVersionDate(savedAt)}-${mode}-${slugify(sourceName(resolvedUrl || inputUrl))}`;
101
- return writeVersion(document, {
102
- ...(existing?.meta ?? {}),
103
- savedPath: existing?.meta?.savedPath,
104
- versionId: nextVersionId,
105
- mode,
106
- inputUrl,
107
- resolvedUrl: resolvedUrl || inputUrl,
108
- title: document.info?.title || '',
109
- version: document.info?.version || '',
110
- savedAt: savedAt.toISOString(),
100
+ return withCacheWriteLock(async () => {
101
+ if (!isOpenApiDocument(document)) throw new Error('不是有效的 OpenAPI/Swagger 文档');
102
+ const existing = versionId ? await readCachedDocument(versionId) : undefined;
103
+ const savedAt = new Date();
104
+ const nextVersionId = existing?.meta?.versionId || `${formatVersionDate(savedAt)}-${mode}-${slugify(sourceName(resolvedUrl || inputUrl))}`;
105
+ return writeVersion(document, {
106
+ ...(existing?.meta ?? {}),
107
+ savedPath: existing?.meta?.savedPath,
108
+ versionId: nextVersionId,
109
+ mode,
110
+ inputUrl,
111
+ resolvedUrl: resolvedUrl || inputUrl,
112
+ title: document.info?.title || '',
113
+ version: document.info?.version || '',
114
+ savedAt: savedAt.toISOString(),
115
+ });
111
116
  });
112
117
  }
113
118
 
@@ -118,50 +123,61 @@ export async function createBlankDocument({
118
123
  environmentName = '',
119
124
  environmentBaseUrl = '',
120
125
  } = {}) {
121
- const document = createManualDocument(text(title) || 'API Skill Document', text(version) || '1.0.0', text(description));
122
- const savedAt = new Date();
123
- const versionId = `${formatVersionDate(savedAt)}-document-${slugify(document.info?.title || 'api-skill-document')}`;
124
- return writeVersion(document, {
125
- versionId,
126
- mode: 'document',
127
- inputUrl: 'manual-document',
128
- resolvedUrl: 'manual-document',
129
- title: document.info?.title || '',
130
- version: document.info?.version || '',
131
- environmentName: text(environmentName).slice(0, 80),
132
- environmentBaseUrl: text(environmentBaseUrl),
133
- savedAt: savedAt.toISOString(),
126
+ return withCacheWriteLock(async () => {
127
+ const document = createManualDocument(text(title) || 'API Skill Document', text(version) || '1.0.0', text(description));
128
+ const savedAt = new Date();
129
+ const versionId = `${formatVersionDate(savedAt)}-document-${slugify(document.info?.title || 'api-skill-document')}`;
130
+ return writeVersion(document, {
131
+ versionId,
132
+ mode: 'document',
133
+ inputUrl: 'manual-document',
134
+ resolvedUrl: 'manual-document',
135
+ title: document.info?.title || '',
136
+ version: document.info?.version || '',
137
+ environmentName: text(environmentName).slice(0, 80),
138
+ environmentBaseUrl: text(environmentBaseUrl),
139
+ savedAt: savedAt.toISOString(),
140
+ });
134
141
  });
135
142
  }
136
143
 
137
144
  export async function saveManualOperation({ versionId, config, replaceTarget }) {
138
- const normalized = normalizeManualConfig(config);
139
- const existing = versionId ? await readCachedDocument(versionId) : undefined;
140
- let document = existing?.document ?? createManualDocument();
141
- if (replaceTarget?.method && replaceTarget?.path) {
142
- document = deleteOperation(document, replaceTarget.method, replaceTarget.path);
143
- }
144
- document = applyOperation(document, normalized);
145
- const savedAt = new Date();
146
- const nextVersionId = existing?.meta?.versionId || `${formatVersionDate(savedAt)}-manual-${slugify(normalized.operationId || normalized.summary || normalized.path)}`;
147
- return writeVersion(document, {
148
- ...(existing?.meta ?? {}),
149
- versionId: nextVersionId,
150
- mode: existing?.meta?.mode || 'manual',
151
- inputUrl: existing?.meta?.inputUrl || 'manual-api-config',
152
- resolvedUrl: existing?.meta?.resolvedUrl || 'manual-api-config',
153
- title: document.info?.title || 'Manual API Config',
154
- version: document.info?.version || 'manual',
155
- savedAt: savedAt.toISOString(),
145
+ return withCacheWriteLock(async () => {
146
+ const normalized = normalizeManualConfig(config);
147
+ const existing = versionId ? await readCachedDocument(versionId) : await readLatestCachedDocumentIfExists();
148
+ let document = existing?.document ?? createManualDocument();
149
+ if (replaceTarget?.method && replaceTarget?.path) {
150
+ document = deleteOperation(document, replaceTarget.method, replaceTarget.path);
151
+ }
152
+ document = applyOperation(document, normalized);
153
+ const savedAt = new Date();
154
+ const nextVersionId = existing?.meta?.versionId || `${formatVersionDate(savedAt)}-manual-${slugify(normalized.operationId || normalized.summary || normalized.path)}`;
155
+ return writeVersion(document, {
156
+ ...(existing?.meta ?? {}),
157
+ versionId: nextVersionId,
158
+ mode: existing?.meta?.mode || 'manual',
159
+ inputUrl: existing?.meta?.inputUrl || 'manual-api-config',
160
+ resolvedUrl: existing?.meta?.resolvedUrl || 'manual-api-config',
161
+ title: document.info?.title || 'Manual API Config',
162
+ version: document.info?.version || 'manual',
163
+ savedAt: savedAt.toISOString(),
164
+ });
156
165
  });
157
166
  }
158
167
 
168
+ async function readLatestCachedDocumentIfExists() {
169
+ if (!existsSync(latestMetaPath) && !existsSync(legacyCachePath)) return undefined;
170
+ return readCachedDocument();
171
+ }
172
+
159
173
  export async function deleteManualOperation({ versionId, method, path }) {
160
- const existing = await readCachedDocument(required(versionId, '必须指定 --version'));
161
- const document = deleteOperation(existing.document, method, path);
162
- return writeVersion(document, {
163
- ...existing.meta,
164
- savedAt: new Date().toISOString(),
174
+ return withCacheWriteLock(async () => {
175
+ const existing = await readCachedDocument(required(versionId, '必须指定 --version'));
176
+ const document = deleteOperation(existing.document, method, path);
177
+ return writeVersion(document, {
178
+ ...existing.meta,
179
+ savedAt: new Date().toISOString(),
180
+ });
165
181
  });
166
182
  }
167
183
 
@@ -494,6 +510,81 @@ async function writeVersion(document, metaInput) {
494
510
  return { document, meta };
495
511
  }
496
512
 
513
+ async function withCacheWriteLock(task) {
514
+ await mkdir(cacheDir, { recursive: true });
515
+ const startedAt = Date.now();
516
+ const token = `${process.pid}-${startedAt}-${Math.random().toString(36).slice(2)}`;
517
+
518
+ while (true) {
519
+ try {
520
+ const handle = await open(writeLockPath, 'wx');
521
+ let initialized = false;
522
+ try {
523
+ await handle.writeFile(JSON.stringify({ pid: process.pid, token, createdAt: new Date().toISOString() }));
524
+ initialized = true;
525
+ } finally {
526
+ await handle.close().catch(() => {});
527
+ if (!initialized) await unlink(writeLockPath).catch(() => {});
528
+ }
529
+ break;
530
+ } catch (error) {
531
+ if (error?.code !== 'EEXIST') throw error;
532
+ await removeAbandonedWriteLock();
533
+ if (Date.now() - startedAt >= writeLockTimeoutMs) {
534
+ throw new Error('等待缓存写入锁超时,请确认没有卡住的 API Skill 进程后重试');
535
+ }
536
+ await sleep(15 + Math.floor(Math.random() * 25));
537
+ }
538
+ }
539
+
540
+ try {
541
+ return await task();
542
+ } finally {
543
+ await releaseWriteLock(token);
544
+ }
545
+ }
546
+
547
+ async function removeAbandonedWriteLock() {
548
+ let lock;
549
+ try {
550
+ lock = JSON.parse(await readFile(writeLockPath, 'utf8'));
551
+ } catch (error) {
552
+ if (error?.code === 'ENOENT') return;
553
+ try {
554
+ const lockStat = await stat(writeLockPath);
555
+ if (Date.now() - lockStat.mtimeMs >= malformedLockStaleMs) await unlink(writeLockPath);
556
+ } catch {}
557
+ return;
558
+ }
559
+
560
+ if (isProcessAlive(lock?.pid)) return;
561
+ try {
562
+ const current = JSON.parse(await readFile(writeLockPath, 'utf8'));
563
+ if (current?.token === lock?.token) await unlink(writeLockPath);
564
+ } catch {}
565
+ }
566
+
567
+ async function releaseWriteLock(token) {
568
+ try {
569
+ const current = JSON.parse(await readFile(writeLockPath, 'utf8'));
570
+ if (current?.token === token) await unlink(writeLockPath);
571
+ } catch {}
572
+ }
573
+
574
+ function isProcessAlive(pid) {
575
+ if (!Number.isInteger(pid) || pid <= 0) return false;
576
+ try {
577
+ process.kill(pid, 0);
578
+ return true;
579
+ } catch (error) {
580
+ return error?.code === 'EPERM';
581
+ }
582
+ }
583
+
584
+ function sleep(milliseconds) {
585
+ return new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds));
586
+ }
587
+
497
588
  function assertSafeVersionId(versionId) {
498
589
  if (!/^[a-zA-Z0-9_.-]+$/.test(versionId)) throw new Error('versionId 不合法');
499
590
  }
@@ -178,11 +178,11 @@ const tools = [
178
178
  },
179
179
  {
180
180
  name: 'apiskill_create_api',
181
- description: 'Create a manual API operation. If versionId is omitted, this writes a new manual version.',
181
+ description: 'Create a manual API operation. If versionId is omitted, this appends to the latest version or creates a manual version when the cache is empty.',
182
182
  inputSchema: {
183
183
  type: 'object',
184
184
  properties: {
185
- versionId: { type: 'string', description: 'Optional version id to append to. Defaults to a new manual version.' },
185
+ versionId: { type: 'string', description: 'Optional version id. Defaults to latest, or a new manual version when the cache is empty.' },
186
186
  configText: { type: 'string', description: 'JSON/YAML/CLI config text. Use root key api, config, or operation.' },
187
187
  config: { type: 'object', description: 'Manual API config object.' },
188
188
  },
@@ -0,0 +1,110 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtemp, rm } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { spawn } from 'node:child_process';
7
+
8
+ const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
9
+ const cliPath = resolve(projectRoot, 'scripts/apiskill-cli.mjs');
10
+ const cacheDir = await mkdtemp(join(tmpdir(), 'apiskill-cli-concurrency-'));
11
+ const endpointCount = 16;
12
+ const implicitVersionEndpointCount = 8;
13
+
14
+ try {
15
+ const created = JSON.parse(
16
+ await runCli(['document', 'create', '--title', 'Concurrent CLI Test', '--doc-version', '1.0.0', '--json']),
17
+ );
18
+ const versionId = created.meta.versionId;
19
+
20
+ await Promise.all(
21
+ Array.from({ length: endpointCount }, (_, index) => {
22
+ const number = index + 1;
23
+ return runCli([
24
+ 'api',
25
+ 'create',
26
+ '--version',
27
+ versionId,
28
+ '--config',
29
+ JSON.stringify({
30
+ api: {
31
+ method: 'get',
32
+ path: `/api/v1/concurrent/${number}`,
33
+ summary: `Concurrent endpoint ${number}`,
34
+ responses: [{ status: '200', description: 'Success' }],
35
+ },
36
+ }),
37
+ '--json',
38
+ ]);
39
+ }),
40
+ );
41
+
42
+ const endpoints = JSON.parse(await runCli(['api', 'list', '--version', versionId, '--json']));
43
+ assert.equal(endpoints.length, endpointCount, `expected ${endpointCount} endpoints, received ${endpoints.length}`);
44
+ assert.deepEqual(
45
+ new Set(endpoints.map((endpoint) => endpoint.path)),
46
+ new Set(Array.from({ length: endpointCount }, (_, index) => `/api/v1/concurrent/${index + 1}`)),
47
+ );
48
+
49
+ const implicitCreated = JSON.parse(
50
+ await runCli(['document', 'create', '--title', 'Implicit Latest Version Test', '--doc-version', '1.0.0', '--json']),
51
+ );
52
+ const implicitVersionId = implicitCreated.meta.versionId;
53
+ await Promise.all(
54
+ Array.from({ length: implicitVersionEndpointCount }, (_, index) => {
55
+ const number = index + 1;
56
+ return runCli([
57
+ 'api',
58
+ 'create',
59
+ '--config',
60
+ JSON.stringify({
61
+ api: {
62
+ method: 'post',
63
+ path: `/api/v1/implicit/${number}`,
64
+ summary: `Implicit latest endpoint ${number}`,
65
+ responses: [{ status: '201', description: 'Created' }],
66
+ },
67
+ }),
68
+ '--json',
69
+ ]);
70
+ }),
71
+ );
72
+ const implicitEndpoints = JSON.parse(await runCli(['api', 'list', '--version', implicitVersionId, '--json']));
73
+ assert.equal(
74
+ implicitEndpoints.length,
75
+ implicitVersionEndpointCount,
76
+ `expected ${implicitVersionEndpointCount} implicit-version endpoints, received ${implicitEndpoints.length}`,
77
+ );
78
+ assert.ok(implicitEndpoints.every((endpoint) => endpoint.path.startsWith('/api/v1/implicit/')));
79
+
80
+ console.log(
81
+ `CLI concurrency test passed: retained ${endpointCount}/${endpointCount} explicit-version and ${implicitVersionEndpointCount}/${implicitVersionEndpointCount} latest-version parallel writes.`,
82
+ );
83
+ } finally {
84
+ await rm(cacheDir, { recursive: true, force: true });
85
+ }
86
+
87
+ function runCli(args) {
88
+ return new Promise((resolvePromise, rejectPromise) => {
89
+ const child = spawn(process.execPath, [cliPath, ...args], {
90
+ cwd: projectRoot,
91
+ env: { ...process.env, APISKILL_CACHE_DIR: cacheDir },
92
+ stdio: ['ignore', 'pipe', 'pipe'],
93
+ });
94
+ let stdout = '';
95
+ let stderr = '';
96
+ child.stdout.setEncoding('utf8');
97
+ child.stderr.setEncoding('utf8');
98
+ child.stdout.on('data', (chunk) => {
99
+ stdout += chunk;
100
+ });
101
+ child.stderr.on('data', (chunk) => {
102
+ stderr += chunk;
103
+ });
104
+ child.on('error', rejectPromise);
105
+ child.on('close', (code) => {
106
+ if (code === 0) resolvePromise(stdout);
107
+ else rejectPromise(new Error(`apiskill ${args.join(' ')} failed (${code}): ${stderr || stdout}`));
108
+ });
109
+ });
110
+ }
@@ -0,0 +1,140 @@
1
+ import assert from 'node:assert/strict';
2
+ import { createServer } from 'node:net';
3
+ import { mkdtemp, rm } from 'node:fs/promises';
4
+ import { homedir, tmpdir } from 'node:os';
5
+ import { dirname, join, resolve } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { spawn } from 'node:child_process';
8
+ import { getDefaultCacheDir } from './lib/cache-paths.mjs';
9
+
10
+ const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
11
+ const cliPath = resolve(projectRoot, 'scripts/apiskill-cli.mjs');
12
+ const cacheDir = await mkdtemp(join(tmpdir(), 'apiskill-shared-cache-'));
13
+ const port = await findFreePort();
14
+ let webProcess;
15
+
16
+ try {
17
+ assert.equal(getDefaultCacheDir({ APISKILL_ROOT: '/tmp/another-project' }), resolve(homedir(), '.apiskill', 'cache'));
18
+
19
+ const created = JSON.parse(
20
+ await runCli(['document', 'create', '--title', 'Shared Cache Test', '--doc-version', '1.0.0', '--json']),
21
+ );
22
+ const versionId = created.meta.versionId;
23
+ await runCli([
24
+ 'api',
25
+ 'create',
26
+ '--version',
27
+ versionId,
28
+ '--config',
29
+ JSON.stringify({
30
+ api: {
31
+ method: 'get',
32
+ path: '/api/v1/from-cli',
33
+ summary: 'Created from CLI',
34
+ responses: [{ status: '200', description: 'Success' }],
35
+ },
36
+ }),
37
+ '--json',
38
+ ]);
39
+
40
+ webProcess = spawn(
41
+ process.execPath,
42
+ [cliPath, 'run', 'web', '--host', '127.0.0.1', '--port', String(port), '--strict-port', '--cwd', projectRoot],
43
+ {
44
+ cwd: projectRoot,
45
+ env: { ...process.env, APISKILL_CACHE_DIR: cacheDir },
46
+ stdio: ['ignore', 'pipe', 'pipe'],
47
+ },
48
+ );
49
+ const webOutput = collectProcessOutput(webProcess);
50
+ await waitForWeb(port, webProcess, webOutput);
51
+
52
+ const webCacheResponse = await fetch(`http://127.0.0.1:${port}/api/openapi/cache`);
53
+ assert.equal(webCacheResponse.status, 200);
54
+ const webCache = await webCacheResponse.json();
55
+ assert.equal(webCache.meta.versionId, versionId);
56
+ assert.ok(webCache.document.paths['/api/v1/from-cli']?.get, 'Web did not read the API created by CLI');
57
+
58
+ const webWriteResponse = await fetch(`http://127.0.0.1:${port}/api/openapi/custom-operation`, {
59
+ method: 'POST',
60
+ headers: { 'content-type': 'application/json' },
61
+ body: JSON.stringify({
62
+ versionId,
63
+ config: {
64
+ method: 'post',
65
+ path: '/api/v1/from-web',
66
+ summary: 'Created from Web',
67
+ responses: [{ status: '201', description: 'Created' }],
68
+ },
69
+ }),
70
+ });
71
+ assert.equal(webWriteResponse.status, 200, await webWriteResponse.text());
72
+
73
+ const cliRead = JSON.parse(await runCli(['api', 'query', 'POST', '/api/v1/from-web', '--version', versionId]));
74
+ assert.equal(cliRead.path, '/api/v1/from-web');
75
+ assert.equal(cliRead.method, 'post');
76
+
77
+ const check = JSON.parse(await runCli(['check', '--json']));
78
+ assert.equal(check.cacheDir, cacheDir);
79
+ console.log(`Shared cache test passed: CLI and Web both read and wrote ${cacheDir}.`);
80
+ } finally {
81
+ if (webProcess && webProcess.exitCode === null) {
82
+ webProcess.kill('SIGTERM');
83
+ await new Promise((resolvePromise) => webProcess.once('close', resolvePromise));
84
+ }
85
+ await rm(cacheDir, { recursive: true, force: true });
86
+ }
87
+
88
+ function runCli(args) {
89
+ return new Promise((resolvePromise, rejectPromise) => {
90
+ const child = spawn(process.execPath, [cliPath, ...args], {
91
+ cwd: projectRoot,
92
+ env: { ...process.env, APISKILL_CACHE_DIR: cacheDir },
93
+ stdio: ['ignore', 'pipe', 'pipe'],
94
+ });
95
+ const output = collectProcessOutput(child);
96
+ child.on('error', rejectPromise);
97
+ child.on('close', (code) => {
98
+ if (code === 0) resolvePromise(output.stdout);
99
+ else rejectPromise(new Error(`apiskill ${args.join(' ')} failed (${code}): ${output.stderr || output.stdout}`));
100
+ });
101
+ });
102
+ }
103
+
104
+ function collectProcessOutput(child) {
105
+ const output = { stdout: '', stderr: '' };
106
+ child.stdout.setEncoding('utf8');
107
+ child.stderr.setEncoding('utf8');
108
+ child.stdout.on('data', (chunk) => {
109
+ output.stdout += chunk;
110
+ });
111
+ child.stderr.on('data', (chunk) => {
112
+ output.stderr += chunk;
113
+ });
114
+ return output;
115
+ }
116
+
117
+ async function waitForWeb(webPort, child, output) {
118
+ const deadline = Date.now() + 15_000;
119
+ while (Date.now() < deadline) {
120
+ if (child.exitCode !== null) throw new Error(`Web process exited early (${child.exitCode}): ${output.stderr || output.stdout}`);
121
+ try {
122
+ const response = await fetch(`http://127.0.0.1:${webPort}/api/openapi/versions`);
123
+ if (response.ok) return;
124
+ } catch {}
125
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, 100));
126
+ }
127
+ throw new Error(`Timed out waiting for Web: ${output.stderr || output.stdout}`);
128
+ }
129
+
130
+ function findFreePort() {
131
+ return new Promise((resolvePromise, rejectPromise) => {
132
+ const server = createServer();
133
+ server.once('error', rejectPromise);
134
+ server.listen(0, '127.0.0.1', () => {
135
+ const address = server.address();
136
+ const freePort = typeof address === 'object' && address ? address.port : 0;
137
+ server.close((error) => (error ? rejectPromise(error) : resolvePromise(freePort)));
138
+ });
139
+ });
140
+ }
package/tsconfig.json CHANGED
@@ -10,12 +10,12 @@
10
10
  "strict": true,
11
11
  "forceConsistentCasingInFileNames": true,
12
12
  "module": "ESNext",
13
- "moduleResolution": "Node",
13
+ "moduleResolution": "bundler", // ✅ 改为 bundler
14
14
  "resolveJsonModule": true,
15
15
  "isolatedModules": true,
16
16
  "noEmit": true,
17
17
  "jsx": "react-jsx"
18
18
  },
19
- "include": ["src"],
19
+ "include": ["src", "vite-env.d.ts"],
20
20
  "references": []
21
- }
21
+ }