@kirrosh/apitool 0.4.3

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.
Files changed (191) hide show
  1. package/.github/workflows/ci.yml +27 -0
  2. package/.github/workflows/release.yml +97 -0
  3. package/.mcp.json +9 -0
  4. package/APITOOL.md +195 -0
  5. package/BACKLOG.md +62 -0
  6. package/CHANGELOG.md +88 -0
  7. package/LICENSE +21 -0
  8. package/README.md +105 -0
  9. package/bun.lock +291 -0
  10. package/docs/GLOSSARY.md +182 -0
  11. package/docs/INDEX.md +21 -0
  12. package/docs/agent.md +135 -0
  13. package/docs/archive/APITOOL-pre-M22.md +831 -0
  14. package/docs/archive/BACKLOG-AI-NATIVE.md +56 -0
  15. package/docs/archive/M1-M2-parser-runner.md +216 -0
  16. package/docs/archive/M4-M7-reporter-cli.md +179 -0
  17. package/docs/archive/M5-M7-storage-junit.md +300 -0
  18. package/docs/archive/M6-webui.md +339 -0
  19. package/docs/ci.md +274 -0
  20. package/docs/generation-issues.md +67 -0
  21. package/generated/.env.yaml +3 -0
  22. package/install.ps1 +80 -0
  23. package/install.sh +113 -0
  24. package/package.json +46 -0
  25. package/scripts/run-mocked-tests.ts +45 -0
  26. package/seed-demo.ts +53 -0
  27. package/self-tests/auth.yaml +18 -0
  28. package/self-tests/collections-crud.yaml +46 -0
  29. package/self-tests/environments-crud.yaml +48 -0
  30. package/self-tests/export.yaml +32 -0
  31. package/self-tests/runs.yaml +16 -0
  32. package/src/bun-types.d.ts +5 -0
  33. package/src/cli/commands/add-api.ts +51 -0
  34. package/src/cli/commands/ai-generate.ts +106 -0
  35. package/src/cli/commands/chat.ts +43 -0
  36. package/src/cli/commands/ci-init.ts +126 -0
  37. package/src/cli/commands/collections.ts +41 -0
  38. package/src/cli/commands/coverage.ts +65 -0
  39. package/src/cli/commands/doctor.ts +127 -0
  40. package/src/cli/commands/envs.ts +218 -0
  41. package/src/cli/commands/init.ts +84 -0
  42. package/src/cli/commands/mcp.ts +16 -0
  43. package/src/cli/commands/run.ts +137 -0
  44. package/src/cli/commands/runs.ts +108 -0
  45. package/src/cli/commands/serve.ts +22 -0
  46. package/src/cli/commands/update.ts +142 -0
  47. package/src/cli/commands/validate.ts +18 -0
  48. package/src/cli/index.ts +500 -0
  49. package/src/cli/output.ts +24 -0
  50. package/src/cli/runtime.ts +7 -0
  51. package/src/core/agent/agent-loop.ts +116 -0
  52. package/src/core/agent/context-manager.ts +41 -0
  53. package/src/core/agent/system-prompt.ts +33 -0
  54. package/src/core/agent/tools/diagnose-failure.ts +51 -0
  55. package/src/core/agent/tools/explore-api.ts +40 -0
  56. package/src/core/agent/tools/index.ts +48 -0
  57. package/src/core/agent/tools/manage-environment.ts +40 -0
  58. package/src/core/agent/tools/query-results.ts +40 -0
  59. package/src/core/agent/tools/run-tests.ts +38 -0
  60. package/src/core/agent/tools/send-request.ts +44 -0
  61. package/src/core/agent/tools/validate-tests.ts +23 -0
  62. package/src/core/agent/types.ts +22 -0
  63. package/src/core/generator/ai/ai-generator.ts +61 -0
  64. package/src/core/generator/ai/llm-client.ts +159 -0
  65. package/src/core/generator/ai/output-parser.ts +307 -0
  66. package/src/core/generator/ai/prompt-builder.ts +153 -0
  67. package/src/core/generator/ai/types.ts +56 -0
  68. package/src/core/generator/coverage-scanner.ts +87 -0
  69. package/src/core/generator/data-factory.ts +115 -0
  70. package/src/core/generator/index.ts +10 -0
  71. package/src/core/generator/openapi-reader.ts +142 -0
  72. package/src/core/generator/schema-utils.ts +52 -0
  73. package/src/core/generator/serializer.ts +189 -0
  74. package/src/core/generator/types.ts +47 -0
  75. package/src/core/parser/filter.ts +14 -0
  76. package/src/core/parser/index.ts +21 -0
  77. package/src/core/parser/schema.ts +175 -0
  78. package/src/core/parser/types.ts +50 -0
  79. package/src/core/parser/variables.ts +146 -0
  80. package/src/core/parser/yaml-parser.ts +85 -0
  81. package/src/core/reporter/console.ts +175 -0
  82. package/src/core/reporter/index.ts +23 -0
  83. package/src/core/reporter/json.ts +9 -0
  84. package/src/core/reporter/junit.ts +78 -0
  85. package/src/core/reporter/types.ts +12 -0
  86. package/src/core/runner/assertions.ts +172 -0
  87. package/src/core/runner/execute-run.ts +75 -0
  88. package/src/core/runner/executor.ts +150 -0
  89. package/src/core/runner/http-client.ts +69 -0
  90. package/src/core/runner/index.ts +12 -0
  91. package/src/core/runner/types.ts +48 -0
  92. package/src/core/setup-api.ts +97 -0
  93. package/src/core/utils.ts +9 -0
  94. package/src/db/queries.ts +868 -0
  95. package/src/db/schema.ts +215 -0
  96. package/src/mcp/server.ts +47 -0
  97. package/src/mcp/tools/ci-init.ts +57 -0
  98. package/src/mcp/tools/coverage-analysis.ts +58 -0
  99. package/src/mcp/tools/explore-api.ts +84 -0
  100. package/src/mcp/tools/generate-missing-tests.ts +80 -0
  101. package/src/mcp/tools/generate-tests-guide.ts +353 -0
  102. package/src/mcp/tools/manage-environment.ts +123 -0
  103. package/src/mcp/tools/manage-server.ts +87 -0
  104. package/src/mcp/tools/query-db.ts +141 -0
  105. package/src/mcp/tools/run-tests.ts +66 -0
  106. package/src/mcp/tools/save-test-suite.ts +164 -0
  107. package/src/mcp/tools/send-request.ts +53 -0
  108. package/src/mcp/tools/setup-api.ts +49 -0
  109. package/src/mcp/tools/validate-tests.ts +42 -0
  110. package/src/tui/chat-ui.ts +150 -0
  111. package/src/web/routes/api.ts +234 -0
  112. package/src/web/routes/dashboard.ts +348 -0
  113. package/src/web/routes/runs.ts +64 -0
  114. package/src/web/schemas.ts +121 -0
  115. package/src/web/server.ts +134 -0
  116. package/src/web/static/htmx.min.js +1 -0
  117. package/src/web/static/style.css +265 -0
  118. package/src/web/views/layout.ts +46 -0
  119. package/src/web/views/results.ts +209 -0
  120. package/tests/agent/agent-loop.test.ts +61 -0
  121. package/tests/agent/context-manager.test.ts +59 -0
  122. package/tests/agent/system-prompt.test.ts +42 -0
  123. package/tests/agent/tools/diagnose-failure.test.ts +85 -0
  124. package/tests/agent/tools/explore-api.test.ts +59 -0
  125. package/tests/agent/tools/manage-environment.test.ts +78 -0
  126. package/tests/agent/tools/query-results.test.ts +77 -0
  127. package/tests/agent/tools/run-tests.test.ts +89 -0
  128. package/tests/agent/tools/send-request.test.ts +78 -0
  129. package/tests/agent/tools/validate-tests.test.ts +59 -0
  130. package/tests/ai/ai-generator.integration.test.ts +131 -0
  131. package/tests/ai/llm-client.test.ts +145 -0
  132. package/tests/ai/output-parser.test.ts +132 -0
  133. package/tests/ai/prompt-builder.test.ts +67 -0
  134. package/tests/ai/types.test.ts +55 -0
  135. package/tests/cli/args.test.ts +63 -0
  136. package/tests/cli/chat.test.ts +38 -0
  137. package/tests/cli/ci-init.test.ts +112 -0
  138. package/tests/cli/commands.test.ts +316 -0
  139. package/tests/cli/coverage.test.ts +58 -0
  140. package/tests/cli/doctor.test.ts +39 -0
  141. package/tests/cli/envs.test.ts +181 -0
  142. package/tests/cli/init.test.ts +80 -0
  143. package/tests/cli/runs.test.ts +94 -0
  144. package/tests/cli/safe-run.test.ts +103 -0
  145. package/tests/cli/update.test.ts +32 -0
  146. package/tests/core/generator/schema-utils.test.ts +108 -0
  147. package/tests/core/parser/nested-assertions.test.ts +80 -0
  148. package/tests/core/runner/root-body-assertions.test.ts +70 -0
  149. package/tests/db/chat-queries.test.ts +88 -0
  150. package/tests/db/chat-schema.test.ts +37 -0
  151. package/tests/db/environments.test.ts +131 -0
  152. package/tests/db/queries.test.ts +409 -0
  153. package/tests/db/schema.test.ts +141 -0
  154. package/tests/fixtures/.env.yaml +3 -0
  155. package/tests/fixtures/auth-token-test.yaml +8 -0
  156. package/tests/fixtures/bail/suite-a.yaml +6 -0
  157. package/tests/fixtures/bail/suite-b.yaml +6 -0
  158. package/tests/fixtures/crud.yaml +35 -0
  159. package/tests/fixtures/invalid-missing-name.yaml +5 -0
  160. package/tests/fixtures/invalid-no-method.yaml +6 -0
  161. package/tests/fixtures/petstore-auth.json +295 -0
  162. package/tests/fixtures/petstore-simple.json +151 -0
  163. package/tests/fixtures/post-only.yaml +12 -0
  164. package/tests/fixtures/simple.yaml +6 -0
  165. package/tests/fixtures/valid/.env.yaml +1 -0
  166. package/tests/fixtures/valid/a.yaml +5 -0
  167. package/tests/fixtures/valid/b.yml +5 -0
  168. package/tests/generator/coverage-scanner.test.ts +129 -0
  169. package/tests/generator/data-factory.test.ts +133 -0
  170. package/tests/generator/openapi-reader.test.ts +131 -0
  171. package/tests/integration/auth-flow.test.ts +217 -0
  172. package/tests/mcp/coverage-analysis.test.ts +64 -0
  173. package/tests/mcp/explore-api-schemas.test.ts +105 -0
  174. package/tests/mcp/explore-api.test.ts +49 -0
  175. package/tests/mcp/generate-missing-tests.test.ts +69 -0
  176. package/tests/mcp/manage-environment.test.ts +89 -0
  177. package/tests/mcp/save-test-suite.test.ts +116 -0
  178. package/tests/mcp/send-request.test.ts +79 -0
  179. package/tests/mcp/setup-api.test.ts +106 -0
  180. package/tests/mcp/tools.test.ts +248 -0
  181. package/tests/parser/schema.test.ts +134 -0
  182. package/tests/parser/variables.test.ts +227 -0
  183. package/tests/parser/yaml-parser.test.ts +69 -0
  184. package/tests/reporter/console.test.ts +256 -0
  185. package/tests/reporter/json.test.ts +98 -0
  186. package/tests/reporter/junit.test.ts +284 -0
  187. package/tests/runner/assertions.test.ts +262 -0
  188. package/tests/runner/executor.test.ts +310 -0
  189. package/tests/runner/http-client.test.ts +138 -0
  190. package/tests/web/routes.test.ts +160 -0
  191. package/tsconfig.json +31 -0
@@ -0,0 +1,127 @@
1
+ import { existsSync } from "fs";
2
+ import { resolve } from "path";
3
+ import { getDb, closeDb } from "../../db/schema.ts";
4
+
5
+ export interface DoctorOptions {
6
+ dbPath?: string;
7
+ }
8
+
9
+ interface Check {
10
+ label: string;
11
+ ok: boolean;
12
+ detail: string;
13
+ }
14
+
15
+ export async function doctorCommand(options: DoctorOptions): Promise<number> {
16
+ const checks: Check[] = [];
17
+
18
+ // 1. Database
19
+ checks.push(checkDatabase(options.dbPath));
20
+
21
+ // 2. Test files
22
+ checks.push(checkTestFiles());
23
+
24
+ // 3. OpenAPI spec
25
+ checks.push(checkOpenApiSpec());
26
+
27
+ // 4. Environment files
28
+ checks.push(checkEnvFiles());
29
+
30
+ // 5. Ollama
31
+ checks.push(await checkOllama());
32
+
33
+ // Print results
34
+ console.log("\napitool doctor\n");
35
+
36
+ let hasFailure = false;
37
+ for (const check of checks) {
38
+ const icon = check.ok ? "\u2713" : "\u2717";
39
+ console.log(` ${icon} ${check.label}: ${check.detail}`);
40
+ if (!check.ok) hasFailure = true;
41
+ }
42
+
43
+ console.log("");
44
+ if (hasFailure) {
45
+ console.log("Some checks failed. See details above.");
46
+ } else {
47
+ console.log("All checks passed.");
48
+ }
49
+
50
+ return hasFailure ? 1 : 0;
51
+ }
52
+
53
+ function checkDatabase(dbPath?: string): Check {
54
+ const path = dbPath ? resolve(dbPath) : resolve(process.cwd(), "apitool.db");
55
+ try {
56
+ const db = getDb(path);
57
+ const runs = (db.query("SELECT COUNT(*) as cnt FROM runs").get() as { cnt: number }).cnt;
58
+ const envs = (db.query("SELECT COUNT(*) as cnt FROM environments").get() as { cnt: number }).cnt;
59
+ closeDb();
60
+ return { label: "Database", ok: true, detail: `${path} (${runs} runs, ${envs} environments)` };
61
+ } catch (err) {
62
+ return { label: "Database", ok: false, detail: `Cannot open ${path}: ${(err as Error).message}` };
63
+ }
64
+ }
65
+
66
+ function checkTestFiles(): Check {
67
+ const dirs = [".", "tests", "test"];
68
+ const found: string[] = [];
69
+
70
+ for (const dir of dirs) {
71
+ const full = resolve(process.cwd(), dir);
72
+ if (!existsSync(full)) continue;
73
+ try {
74
+ const glob = new Bun.Glob("**/*.yaml");
75
+ for (const file of glob.scanSync({ cwd: full, absolute: false })) {
76
+ if (!file.startsWith(".env.")) {
77
+ found.push(`${dir}/${file}`);
78
+ }
79
+ }
80
+ } catch { /* ignore */ }
81
+ }
82
+
83
+ if (found.length > 0) {
84
+ return { label: "Test files", ok: true, detail: `${found.length} YAML file(s) found` };
85
+ }
86
+ return { label: "Test files", ok: false, detail: "No YAML test files found in cwd or tests/" };
87
+ }
88
+
89
+ function checkOpenApiSpec(): Check {
90
+ const candidates = ["openapi.yaml", "openapi.json", "openapi.yml", "swagger.yaml", "swagger.json"];
91
+ for (const name of candidates) {
92
+ const full = resolve(process.cwd(), name);
93
+ if (existsSync(full)) {
94
+ return { label: "OpenAPI spec", ok: true, detail: name };
95
+ }
96
+ }
97
+ return { label: "OpenAPI spec", ok: false, detail: "No openapi.yaml/json found (optional)" };
98
+ }
99
+
100
+ function checkEnvFiles(): Check {
101
+ const found: string[] = [];
102
+ try {
103
+ const glob = new Bun.Glob(".env.*.yaml");
104
+ for (const file of glob.scanSync({ cwd: process.cwd(), absolute: false })) {
105
+ found.push(file);
106
+ }
107
+ } catch { /* ignore */ }
108
+
109
+ if (found.length > 0) {
110
+ return { label: "Environment files", ok: true, detail: found.join(", ") };
111
+ }
112
+ return { label: "Environment files", ok: false, detail: "No .env.*.yaml files found (optional)" };
113
+ }
114
+
115
+ async function checkOllama(): Promise<Check> {
116
+ try {
117
+ const res = await fetch("http://localhost:11434/api/tags", { signal: AbortSignal.timeout(3000) });
118
+ if (res.ok) {
119
+ const data = await res.json() as { models?: { name: string }[] };
120
+ const count = data.models?.length ?? 0;
121
+ return { label: "Ollama", ok: true, detail: `Running (${count} model(s) available)` };
122
+ }
123
+ return { label: "Ollama", ok: false, detail: `Responded with status ${res.status}` };
124
+ } catch {
125
+ return { label: "Ollama", ok: false, detail: "Not reachable at localhost:11434 (optional, needed for chat)" };
126
+ }
127
+ }
@@ -0,0 +1,218 @@
1
+ import { existsSync, readFileSync } from "fs";
2
+ import { resolve } from "path";
3
+ import { getDb } from "../../db/schema.ts";
4
+ import {
5
+ listEnvironmentRecords,
6
+ getEnvironment,
7
+ upsertEnvironment,
8
+ deleteEnvironment,
9
+ getEnvironmentById,
10
+ findCollectionByNameOrId,
11
+ } from "../../db/queries.ts";
12
+ import { printError, printSuccess } from "../output.ts";
13
+
14
+ export interface EnvsOptions {
15
+ action: "list" | "get" | "set" | "delete" | "import" | "export";
16
+ name?: string;
17
+ pairs?: string[];
18
+ file?: string;
19
+ api?: string;
20
+ dbPath?: string;
21
+ }
22
+
23
+ export function parseKeyValuePairs(pairs: string[]): Record<string, string> {
24
+ const result: Record<string, string> = {};
25
+ for (const pair of pairs) {
26
+ const idx = pair.indexOf("=");
27
+ if (idx === -1) continue;
28
+ const key = pair.slice(0, idx).trim();
29
+ const value = pair.slice(idx + 1).trim();
30
+ if (key) result[key] = value;
31
+ }
32
+ return result;
33
+ }
34
+
35
+ export function envsCommand(options: EnvsOptions): number {
36
+ const { action, name, pairs, dbPath } = options;
37
+
38
+ try {
39
+ getDb(dbPath);
40
+ } catch (err) {
41
+ printError(`Failed to open database: ${(err as Error).message}`);
42
+ return 2;
43
+ }
44
+
45
+ // Resolve --api to collection_id
46
+ let collectionId: number | undefined;
47
+ if (options.api) {
48
+ const col = findCollectionByNameOrId(options.api);
49
+ if (!col) {
50
+ printError(`API '${options.api}' not found`);
51
+ return 1;
52
+ }
53
+ collectionId = col.id;
54
+ }
55
+
56
+ switch (action) {
57
+ case "list": {
58
+ const envs = listEnvironmentRecords(collectionId);
59
+ if (envs.length === 0) {
60
+ console.log("No environments found.");
61
+ return 0;
62
+ }
63
+
64
+ // Print table
65
+ const nameWidth = Math.max(4, ...envs.map(e => e.name.length));
66
+ const scopeWidth = 8;
67
+ const header = `${"NAME".padEnd(nameWidth)} ${"SCOPE".padEnd(scopeWidth)} VARIABLES`;
68
+ console.log(header);
69
+ console.log("-".repeat(header.length + 10));
70
+ for (const env of envs) {
71
+ const scope = env.collection_id ? `api:${env.collection_id}` : "global";
72
+ const varKeys = Object.keys(env.variables).join(", ");
73
+ console.log(`${env.name.padEnd(nameWidth)} ${scope.padEnd(scopeWidth)} ${varKeys}`);
74
+ }
75
+ return 0;
76
+ }
77
+
78
+ case "get": {
79
+ if (!name) {
80
+ printError("Missing environment name. Usage: apitool envs get <name>");
81
+ return 2;
82
+ }
83
+ const variables = getEnvironment(name, collectionId);
84
+ if (!variables) {
85
+ printError(`Environment '${name}' not found`);
86
+ return 1;
87
+ }
88
+
89
+ const keyWidth = Math.max(3, ...Object.keys(variables).map(k => k.length));
90
+ for (const [k, v] of Object.entries(variables)) {
91
+ console.log(`${k.padEnd(keyWidth)} ${v}`);
92
+ }
93
+ return 0;
94
+ }
95
+
96
+ case "set": {
97
+ if (!name) {
98
+ printError("Missing environment name. Usage: apitool envs set <name> KEY=VALUE ...");
99
+ return 2;
100
+ }
101
+ if (!pairs || pairs.length === 0) {
102
+ printError("Missing KEY=VALUE pairs. Usage: apitool envs set <name> KEY=VALUE ...");
103
+ return 2;
104
+ }
105
+ const variables = parseKeyValuePairs(pairs);
106
+ if (Object.keys(variables).length === 0) {
107
+ printError("No valid KEY=VALUE pairs provided");
108
+ return 2;
109
+ }
110
+
111
+ // Merge with existing (scoped if --api provided)
112
+ const existing = getEnvironment(name, collectionId) ?? {};
113
+ const merged = { ...existing, ...variables };
114
+ upsertEnvironment(name, merged, collectionId);
115
+ const scope = collectionId ? ` (scoped to api:${collectionId})` : "";
116
+ printSuccess(`Environment '${name}' updated${scope} (${Object.keys(variables).length} variable(s) set)`);
117
+ return 0;
118
+ }
119
+
120
+ case "delete": {
121
+ if (!name) {
122
+ printError("Missing environment name. Usage: apitool envs delete <name>");
123
+ return 2;
124
+ }
125
+ // Find by name (and scope) to get ID
126
+ const envs = listEnvironmentRecords(collectionId);
127
+ const env = collectionId
128
+ ? envs.find(e => e.name === name && e.collection_id === collectionId)
129
+ : envs.find(e => e.name === name && e.collection_id === null);
130
+ if (!env) {
131
+ printError(`Environment '${name}' not found`);
132
+ return 1;
133
+ }
134
+ deleteEnvironment(env.id);
135
+ printSuccess(`Environment '${name}' deleted`);
136
+ return 0;
137
+ }
138
+
139
+ case "import": {
140
+ if (!name) {
141
+ printError("Missing environment name. Usage: apitool envs import <name> <file>");
142
+ return 2;
143
+ }
144
+ const file = options.file;
145
+ if (!file) {
146
+ printError("Missing file path. Usage: apitool envs import <name> <file>");
147
+ return 2;
148
+ }
149
+ const filePath = resolve(file);
150
+ if (!existsSync(filePath)) {
151
+ printError(`File not found: ${filePath}`);
152
+ return 1;
153
+ }
154
+ try {
155
+ const content = readFileSync(filePath, "utf-8");
156
+ const parsed = parseYamlEnv(content);
157
+ if (!parsed || Object.keys(parsed).length === 0) {
158
+ printError("No variables found in YAML file");
159
+ return 1;
160
+ }
161
+ upsertEnvironment(name, parsed, collectionId);
162
+ printSuccess(`Environment '${name}' imported (${Object.keys(parsed).length} variable(s))`);
163
+ return 0;
164
+ } catch (err) {
165
+ printError(`Failed to import: ${(err as Error).message}`);
166
+ return 1;
167
+ }
168
+ }
169
+
170
+ case "export": {
171
+ if (!name) {
172
+ printError("Missing environment name. Usage: apitool envs export <name>");
173
+ return 2;
174
+ }
175
+ const variables = getEnvironment(name, collectionId);
176
+ if (!variables) {
177
+ printError(`Environment '${name}' not found`);
178
+ return 1;
179
+ }
180
+ console.log(toYaml(variables));
181
+ return 0;
182
+ }
183
+
184
+ default:
185
+ printError(`Unknown action: ${action}`);
186
+ return 2;
187
+ }
188
+ }
189
+
190
+ /** Parse a simple YAML key:value file into a flat Record */
191
+ export function parseYamlEnv(content: string): Record<string, string> {
192
+ const result: Record<string, string> = {};
193
+ for (const line of content.split("\n")) {
194
+ const trimmed = line.trim();
195
+ if (!trimmed || trimmed.startsWith("#")) continue;
196
+ const idx = trimmed.indexOf(":");
197
+ if (idx === -1) continue;
198
+ const key = trimmed.slice(0, idx).trim();
199
+ let value = trimmed.slice(idx + 1).trim();
200
+ // Strip surrounding quotes
201
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
202
+ value = value.slice(1, -1);
203
+ }
204
+ if (key) result[key] = value;
205
+ }
206
+ return result;
207
+ }
208
+
209
+ /** Serialize a flat Record as simple YAML */
210
+ export function toYaml(vars: Record<string, string>): string {
211
+ const lines: string[] = [];
212
+ for (const [k, v] of Object.entries(vars)) {
213
+ // Quote values that contain special chars
214
+ const needsQuote = /[:#\[\]{}&*!|>'"@`,%]/.test(v) || v.includes(" ") || v === "";
215
+ lines.push(`${k}: ${needsQuote ? `"${v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` : v}`);
216
+ }
217
+ return lines.join("\n");
218
+ }
@@ -0,0 +1,84 @@
1
+ import { resolve, dirname } from "path";
2
+ import { existsSync, mkdirSync, writeFileSync } from "fs";
3
+ import { printSuccess } from "../output.ts";
4
+
5
+ export interface InitCommandOptions {
6
+ force: boolean;
7
+ }
8
+
9
+ const EXAMPLE_TEST = `name: Example Smoke Test
10
+ base_url: "{{base_url}}"
11
+
12
+ tests:
13
+ - name: "List posts"
14
+ GET: /posts
15
+ expect:
16
+ status: 200
17
+ body:
18
+ id: { type: integer }
19
+
20
+ - name: "Get single post"
21
+ GET: /posts/1
22
+ expect:
23
+ status: 200
24
+ body:
25
+ id: { equals: 1 }
26
+ title: { type: string }
27
+ `;
28
+
29
+ const ENV_DEV = `base_url: https://jsonplaceholder.typicode.com
30
+ `;
31
+
32
+ const MCP_CONFIG = `{
33
+ "mcpServers": {
34
+ "apitool": {
35
+ "command": "apitool",
36
+ "args": ["mcp"]
37
+ }
38
+ }
39
+ }
40
+ `;
41
+
42
+ function writeIfMissing(filePath: string, content: string, force: boolean): boolean {
43
+ if (!force && existsSync(filePath)) {
44
+ console.log(` Skipped ${filePath} (already exists)`);
45
+ return false;
46
+ }
47
+ const dir = dirname(filePath);
48
+ if (!existsSync(dir)) {
49
+ mkdirSync(dir, { recursive: true });
50
+ }
51
+ writeFileSync(filePath, content, "utf-8");
52
+ console.log(` Created ${filePath}`);
53
+ return true;
54
+ }
55
+
56
+ function isClaudeCodeAvailable(): boolean {
57
+ try {
58
+ const result = Bun.spawnSync(["claude", "--version"], {
59
+ stdout: "ignore",
60
+ stderr: "ignore",
61
+ });
62
+ return result.exitCode === 0;
63
+ } catch {
64
+ return false;
65
+ }
66
+ }
67
+
68
+ export async function initCommand(options: InitCommandOptions): Promise<number> {
69
+ const cwd = process.cwd();
70
+
71
+ console.log("Initializing apitool project...\n");
72
+
73
+ writeIfMissing(resolve(cwd, "tests/example.yaml"), EXAMPLE_TEST, options.force);
74
+ writeIfMissing(resolve(cwd, ".env.dev.yaml"), ENV_DEV, options.force);
75
+
76
+ // Create .mcp.json if Claude Code is detected
77
+ if (isClaudeCodeAvailable()) {
78
+ writeIfMissing(resolve(cwd, ".mcp.json"), MCP_CONFIG, options.force);
79
+ printSuccess("Claude Code detected — .mcp.json created");
80
+ }
81
+
82
+ console.log("\nReady! Run: apitool run tests/");
83
+ return 0;
84
+ }
@@ -0,0 +1,16 @@
1
+ import { startMcpServer } from "../../mcp/server.ts";
2
+ import { resolve } from "node:path";
3
+
4
+ export interface McpCommandOptions {
5
+ dbPath?: string;
6
+ dir?: string;
7
+ }
8
+
9
+ export async function mcpCommand(options: McpCommandOptions): Promise<number> {
10
+ if (options.dir) {
11
+ process.chdir(resolve(options.dir));
12
+ }
13
+ await startMcpServer({ dbPath: options.dbPath });
14
+ // Server runs until stdin closes — this promise never resolves during normal operation
15
+ return 0;
16
+ }
@@ -0,0 +1,137 @@
1
+ import { dirname } from "path";
2
+ import { parse } from "../../core/parser/yaml-parser.ts";
3
+ import { loadEnvironment } from "../../core/parser/variables.ts";
4
+ import { filterSuitesByTags } from "../../core/parser/filter.ts";
5
+ import { runSuite } from "../../core/runner/executor.ts";
6
+ import { getReporter } from "../../core/reporter/index.ts";
7
+ import type { ReporterName } from "../../core/reporter/types.ts";
8
+ import type { TestSuite } from "../../core/parser/types.ts";
9
+ import type { TestRunResult } from "../../core/runner/types.ts";
10
+ import { printError, printWarning } from "../output.ts";
11
+ import { getDb } from "../../db/schema.ts";
12
+ import { createRun, finalizeRun, saveResults, findCollectionByTestPath } from "../../db/queries.ts";
13
+
14
+ export interface RunOptions {
15
+ path: string;
16
+ env?: string;
17
+ report: ReporterName;
18
+ timeout?: number;
19
+ bail: boolean;
20
+ noDb?: boolean;
21
+ dbPath?: string;
22
+ authToken?: string;
23
+ safe?: boolean;
24
+ tag?: string[];
25
+ }
26
+
27
+ export async function runCommand(options: RunOptions): Promise<number> {
28
+ // 1. Parse test files
29
+ let suites: TestSuite[];
30
+ try {
31
+ suites = await parse(options.path);
32
+ } catch (err) {
33
+ printError(err instanceof Error ? err.message : String(err));
34
+ return 2;
35
+ }
36
+
37
+ if (suites.length === 0) {
38
+ printWarning(`No test files found in ${options.path}`);
39
+ return 0;
40
+ }
41
+
42
+ // 1b. Tag filter
43
+ if (options.tag && options.tag.length > 0) {
44
+ suites = filterSuitesByTags(suites, options.tag);
45
+ if (suites.length === 0) {
46
+ printWarning("No suites match the specified tags");
47
+ return 0;
48
+ }
49
+ }
50
+
51
+ // 1c. Safe mode: filter to GET-only tests
52
+ if (options.safe) {
53
+ for (const suite of suites) {
54
+ suite.tests = suite.tests.filter(t => t.method === "GET");
55
+ }
56
+ suites = suites.filter(s => s.tests.length > 0);
57
+ if (suites.length === 0) {
58
+ printWarning("No GET tests found. Nothing to run in safe mode.");
59
+ return 0;
60
+ }
61
+ }
62
+
63
+ // 2. Load environment (resolve collection for scoped envs)
64
+ const searchDir = dirname(options.path);
65
+ let collectionForEnv: { id: number } | null = null;
66
+ try {
67
+ getDb(options.dbPath);
68
+ collectionForEnv = findCollectionByTestPath(options.path);
69
+ } catch { /* DB not available — OK */ }
70
+
71
+ let env: Record<string, string> = {};
72
+ try {
73
+ env = await loadEnvironment(options.env, searchDir, collectionForEnv?.id);
74
+ } catch (err) {
75
+ printError(`Failed to load environment: ${(err as Error).message}`);
76
+ return 2;
77
+ }
78
+
79
+ // Inject CLI auth token — overrides env file value
80
+ if (options.authToken) {
81
+ env.auth_token = options.authToken;
82
+ }
83
+
84
+ // Warn if --env was explicitly set but file was not found (empty env)
85
+ if (options.env && Object.keys(env).length === 0) {
86
+ printWarning(`Environment file .env.${options.env}.yaml not found in ${searchDir}`);
87
+ }
88
+
89
+ // 3. Apply timeout override
90
+ if (options.timeout !== undefined) {
91
+ for (const suite of suites) {
92
+ suite.config.timeout = options.timeout;
93
+ }
94
+ }
95
+
96
+ // 4. Run suites
97
+ const results: TestRunResult[] = [];
98
+ if (options.bail) {
99
+ // Sequential with bail at suite level
100
+ for (const suite of suites) {
101
+ const result = await runSuite(suite, env);
102
+ results.push(result);
103
+ if (result.failed > 0 || result.steps.some((s) => s.status === "error")) {
104
+ break;
105
+ }
106
+ }
107
+ } else {
108
+ // Parallel
109
+ const all = await Promise.all(suites.map((suite) => runSuite(suite, env)));
110
+ results.push(...all);
111
+ }
112
+
113
+ // 5. Report
114
+ const reporter = getReporter(options.report);
115
+ reporter.report(results);
116
+
117
+ // 6. Save to DB
118
+ if (!options.noDb) {
119
+ try {
120
+ getDb(options.dbPath);
121
+ const collection = findCollectionByTestPath(options.path);
122
+ const runId = createRun({
123
+ started_at: results[0]?.started_at ?? new Date().toISOString(),
124
+ environment: options.env,
125
+ collection_id: collection?.id,
126
+ });
127
+ finalizeRun(runId, results);
128
+ saveResults(runId, results);
129
+ } catch (err) {
130
+ printWarning(`Failed to save results to DB: ${(err as Error).message}`);
131
+ }
132
+ }
133
+
134
+ // 7. Exit code
135
+ const hasFailures = results.some((r) => r.failed > 0 || r.steps.some((s) => s.status === "error"));
136
+ return hasFailures ? 1 : 0;
137
+ }
@@ -0,0 +1,108 @@
1
+ import { getDb } from "../../db/schema.ts";
2
+ import { listRuns, getRunById, getResultsByRunId } from "../../db/queries.ts";
3
+ import { printError } from "../output.ts";
4
+
5
+ export interface RunsOptions {
6
+ runId?: number;
7
+ limit?: number;
8
+ dbPath?: string;
9
+ }
10
+
11
+ const RESET = "\x1b[0m";
12
+ const GREEN = "\x1b[32m";
13
+ const RED = "\x1b[31m";
14
+ const YELLOW = "\x1b[33m";
15
+
16
+ function useColor(): boolean {
17
+ return process.stdout.isTTY ?? false;
18
+ }
19
+
20
+ function statusIcon(passed: number, failed: number): string {
21
+ const color = useColor();
22
+ if (failed === 0) return color ? `${GREEN}PASS${RESET}` : "PASS";
23
+ return color ? `${RED}FAIL${RESET}` : "FAIL";
24
+ }
25
+
26
+ export function runsCommand(options: RunsOptions): number {
27
+ const { runId, limit = 20, dbPath } = options;
28
+
29
+ try {
30
+ getDb(dbPath);
31
+ } catch (err) {
32
+ printError(`Failed to open database: ${(err as Error).message}`);
33
+ return 2;
34
+ }
35
+
36
+ if (runId !== undefined) {
37
+ return showRunDetail(runId);
38
+ }
39
+ return showRunList(limit);
40
+ }
41
+
42
+ function showRunList(limit: number): number {
43
+ const runs = listRuns(limit);
44
+
45
+ if (runs.length === 0) {
46
+ console.log("No runs found.");
47
+ return 0;
48
+ }
49
+
50
+ // Print table
51
+ const header = "ID STATUS TOTAL PASS FAIL ENV DURATION STARTED";
52
+ console.log(header);
53
+ console.log("-".repeat(header.length));
54
+
55
+ for (const run of runs) {
56
+ const status = statusIcon(run.passed, run.failed);
57
+ const env = (run.environment ?? "-").slice(0, 10).padEnd(10);
58
+ const duration = run.duration_ms != null ? `${run.duration_ms}ms` : "-";
59
+ const started = run.started_at.slice(0, 19).replace("T", " ");
60
+ console.log(
61
+ `${String(run.id).padEnd(6)} ${status.padEnd(useColor() ? 14 : 6)} ${String(run.total).padEnd(5)} ${String(run.passed).padEnd(4)} ${String(run.failed).padEnd(4)} ${env} ${duration.padEnd(8)} ${started}`,
62
+ );
63
+ }
64
+
65
+ return 0;
66
+ }
67
+
68
+ function showRunDetail(runId: number): number {
69
+ const run = getRunById(runId);
70
+ if (!run) {
71
+ printError(`Run #${runId} not found`);
72
+ return 1;
73
+ }
74
+
75
+ const color = useColor();
76
+
77
+ console.log(`Run #${run.id}`);
78
+ console.log(` Started: ${run.started_at}`);
79
+ if (run.finished_at) console.log(` Finished: ${run.finished_at}`);
80
+ if (run.environment) console.log(` Environment: ${run.environment}`);
81
+ if (run.duration_ms != null) console.log(` Duration: ${run.duration_ms}ms`);
82
+ console.log(` Total: ${run.total} Passed: ${run.passed} Failed: ${run.failed} Skipped: ${run.skipped}`);
83
+
84
+ const results = getResultsByRunId(runId);
85
+ if (results.length === 0) {
86
+ console.log("\nNo step results recorded.");
87
+ return 0;
88
+ }
89
+
90
+ console.log("\nSteps:");
91
+ for (const r of results) {
92
+ let statusStr: string;
93
+ if (r.status === "pass") {
94
+ statusStr = color ? `${GREEN}PASS${RESET}` : "PASS";
95
+ } else if (r.status === "fail" || r.status === "error") {
96
+ statusStr = color ? `${RED}${r.status.toUpperCase()}${RESET}` : r.status.toUpperCase();
97
+ } else {
98
+ statusStr = color ? `${YELLOW}SKIP${RESET}` : "SKIP";
99
+ }
100
+
101
+ console.log(` ${statusStr} ${r.test_name} (${r.duration_ms}ms)`);
102
+ if (r.error_message) {
103
+ console.log(` ${color ? RED : ""}${r.error_message}${color ? RESET : ""}`);
104
+ }
105
+ }
106
+
107
+ return 0;
108
+ }