@kirrosh/zond 0.7.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.
Files changed (102) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/LICENSE +21 -0
  3. package/README.md +130 -0
  4. package/package.json +53 -0
  5. package/src/bun-types.d.ts +5 -0
  6. package/src/cli/commands/add-api.ts +51 -0
  7. package/src/cli/commands/ai-generate.ts +106 -0
  8. package/src/cli/commands/chat.ts +43 -0
  9. package/src/cli/commands/ci-init.ts +163 -0
  10. package/src/cli/commands/collections.ts +41 -0
  11. package/src/cli/commands/compare.ts +129 -0
  12. package/src/cli/commands/coverage.ts +156 -0
  13. package/src/cli/commands/doctor.ts +127 -0
  14. package/src/cli/commands/init.ts +84 -0
  15. package/src/cli/commands/mcp.ts +16 -0
  16. package/src/cli/commands/run.ts +156 -0
  17. package/src/cli/commands/runs.ts +108 -0
  18. package/src/cli/commands/serve.ts +22 -0
  19. package/src/cli/commands/update.ts +142 -0
  20. package/src/cli/commands/validate.ts +18 -0
  21. package/src/cli/index.ts +529 -0
  22. package/src/cli/output.ts +24 -0
  23. package/src/cli/runtime.ts +7 -0
  24. package/src/core/agent/agent-loop.ts +116 -0
  25. package/src/core/agent/context-manager.ts +41 -0
  26. package/src/core/agent/system-prompt.ts +28 -0
  27. package/src/core/agent/tools/diagnose-failure.ts +51 -0
  28. package/src/core/agent/tools/explore-api.ts +40 -0
  29. package/src/core/agent/tools/index.ts +46 -0
  30. package/src/core/agent/tools/query-results.ts +40 -0
  31. package/src/core/agent/tools/run-tests.ts +38 -0
  32. package/src/core/agent/tools/send-request.ts +44 -0
  33. package/src/core/agent/tools/validate-tests.ts +23 -0
  34. package/src/core/agent/types.ts +22 -0
  35. package/src/core/diagnostics/failure-hints.ts +63 -0
  36. package/src/core/generator/ai/ai-generator.ts +61 -0
  37. package/src/core/generator/ai/llm-client.ts +159 -0
  38. package/src/core/generator/ai/output-parser.ts +307 -0
  39. package/src/core/generator/ai/prompt-builder.ts +153 -0
  40. package/src/core/generator/ai/types.ts +56 -0
  41. package/src/core/generator/chunker.ts +47 -0
  42. package/src/core/generator/coverage-scanner.ts +87 -0
  43. package/src/core/generator/data-factory.ts +115 -0
  44. package/src/core/generator/endpoint-warnings.ts +43 -0
  45. package/src/core/generator/index.ts +12 -0
  46. package/src/core/generator/openapi-reader.ts +143 -0
  47. package/src/core/generator/schema-utils.ts +52 -0
  48. package/src/core/generator/serializer.ts +189 -0
  49. package/src/core/generator/types.ts +48 -0
  50. package/src/core/parser/filter.ts +14 -0
  51. package/src/core/parser/index.ts +21 -0
  52. package/src/core/parser/schema.ts +175 -0
  53. package/src/core/parser/types.ts +52 -0
  54. package/src/core/parser/variables.ts +154 -0
  55. package/src/core/parser/yaml-parser.ts +85 -0
  56. package/src/core/reporter/console.ts +175 -0
  57. package/src/core/reporter/index.ts +23 -0
  58. package/src/core/reporter/json.ts +9 -0
  59. package/src/core/reporter/junit.ts +78 -0
  60. package/src/core/reporter/types.ts +12 -0
  61. package/src/core/runner/assertions.ts +173 -0
  62. package/src/core/runner/execute-run.ts +97 -0
  63. package/src/core/runner/executor.ts +183 -0
  64. package/src/core/runner/http-client.ts +69 -0
  65. package/src/core/runner/index.ts +12 -0
  66. package/src/core/runner/types.ts +48 -0
  67. package/src/core/setup-api.ts +113 -0
  68. package/src/core/utils.ts +9 -0
  69. package/src/db/queries.ts +774 -0
  70. package/src/db/schema.ts +159 -0
  71. package/src/mcp/descriptions.ts +88 -0
  72. package/src/mcp/server.ts +52 -0
  73. package/src/mcp/tools/ci-init.ts +54 -0
  74. package/src/mcp/tools/coverage-analysis.ts +141 -0
  75. package/src/mcp/tools/describe-endpoint.ts +241 -0
  76. package/src/mcp/tools/explore-api.ts +84 -0
  77. package/src/mcp/tools/generate-and-save.ts +129 -0
  78. package/src/mcp/tools/generate-missing-tests.ts +91 -0
  79. package/src/mcp/tools/generate-tests-guide.ts +391 -0
  80. package/src/mcp/tools/manage-server.ts +86 -0
  81. package/src/mcp/tools/query-db.ts +255 -0
  82. package/src/mcp/tools/run-tests.ts +71 -0
  83. package/src/mcp/tools/save-test-suite.ts +218 -0
  84. package/src/mcp/tools/send-request.ts +63 -0
  85. package/src/mcp/tools/set-work-dir.ts +35 -0
  86. package/src/mcp/tools/setup-api.ts +84 -0
  87. package/src/mcp/tools/validate-tests.ts +43 -0
  88. package/src/tui/chat-ui.ts +150 -0
  89. package/src/web/data/collection-state.ts +360 -0
  90. package/src/web/routes/api.ts +234 -0
  91. package/src/web/routes/dashboard.ts +313 -0
  92. package/src/web/routes/runs.ts +64 -0
  93. package/src/web/schemas.ts +121 -0
  94. package/src/web/server.ts +134 -0
  95. package/src/web/static/htmx.min.js +1 -0
  96. package/src/web/static/style.css +827 -0
  97. package/src/web/views/endpoints-tab.ts +170 -0
  98. package/src/web/views/health-strip.ts +92 -0
  99. package/src/web/views/layout.ts +48 -0
  100. package/src/web/views/results.ts +209 -0
  101. package/src/web/views/runs-tab.ts +126 -0
  102. package/src/web/views/suites-tab.ts +153 -0
@@ -0,0 +1,529 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { runCommand } from "./commands/run.ts";
4
+ import { validateCommand } from "./commands/validate.ts";
5
+ import { serveCommand } from "./commands/serve.ts";
6
+ import { collectionsCommand } from "./commands/collections.ts";
7
+ import { aiGenerateCommand } from "./commands/ai-generate.ts";
8
+ import { mcpCommand } from "./commands/mcp.ts";
9
+ import { initCommand } from "./commands/init.ts";
10
+ import { updateCommand } from "./commands/update.ts";
11
+ import { chatCommand } from "./commands/chat.ts";
12
+ import { runsCommand } from "./commands/runs.ts";
13
+ import { coverageCommand } from "./commands/coverage.ts";
14
+ import { doctorCommand } from "./commands/doctor.ts";
15
+ import { addApiCommand } from "./commands/add-api.ts";
16
+ import { ciInitCommand } from "./commands/ci-init.ts";
17
+ import { compareCommand } from "./commands/compare.ts";
18
+ import { printError } from "./output.ts";
19
+ import { getRuntimeInfo } from "./runtime.ts";
20
+ import { getDb } from "../db/schema.ts";
21
+ import { findCollectionByNameOrId } from "../db/queries.ts";
22
+ import type { ReporterName } from "../core/reporter/types.ts";
23
+
24
+ import { version as pkgVersion } from "../../package.json";
25
+ export const VERSION = pkgVersion;
26
+
27
+ export interface ParsedArgs {
28
+ command: string | undefined;
29
+ positional: string[];
30
+ flags: Record<string, string | boolean>;
31
+ }
32
+
33
+ export function parseArgs(argv: string[]): ParsedArgs {
34
+ // argv: [bunPath, scriptPath, ...userArgs]
35
+ const args = argv.slice(2);
36
+ let command: string | undefined;
37
+ const positional: string[] = [];
38
+ const flags: Record<string, string | boolean> = {};
39
+
40
+ let i = 0;
41
+ while (i < args.length) {
42
+ const arg = args[i]!;
43
+
44
+ if (arg.startsWith("--")) {
45
+ const eqIndex = arg.indexOf("=");
46
+ if (eqIndex !== -1) {
47
+ // --flag=value
48
+ flags[arg.slice(2, eqIndex)] = arg.slice(eqIndex + 1);
49
+ } else {
50
+ const key = arg.slice(2);
51
+ const next = args[i + 1];
52
+ if (next !== undefined && !next.startsWith("-")) {
53
+ flags[key] = next;
54
+ i++;
55
+ } else {
56
+ flags[key] = true;
57
+ }
58
+ }
59
+ } else if (arg.startsWith("-") && arg.length === 2) {
60
+ // Short flag: -h, -v
61
+ flags[arg.slice(1)] = true;
62
+ } else if (command === undefined) {
63
+ command = arg;
64
+ } else {
65
+ positional.push(arg);
66
+ }
67
+
68
+ i++;
69
+ }
70
+
71
+ return { command, positional, flags };
72
+ }
73
+
74
+ function printUsage(): void {
75
+ console.log(`zond - API Testing Platform
76
+
77
+ Usage:
78
+ zond add-api <name> Register a new API (collection)
79
+ zond run <path> Run API tests
80
+ zond validate <path> Validate test files without running
81
+ zond ai-generate --from <spec> --prompt "..." Generate tests with AI
82
+ zond runs [id] View test run history
83
+ zond coverage --spec <path> --tests <dir> Analyze API test coverage
84
+ zond collections List test collections
85
+ zond serve Start web dashboard
86
+ zond init Initialize a new zond project
87
+ zond ci init Generate CI/CD workflow (GitHub Actions, GitLab CI)
88
+ zond mcp Start MCP server (stdio transport for AI agents)
89
+ --dir <path> Set working directory (relative paths resolve here)
90
+ zond chat Start interactive AI chat for API testing
91
+ zond compare <runA> <runB> Compare two test runs (regressions/fixes)
92
+ zond doctor Run diagnostic checks
93
+ zond update Update to latest version
94
+
95
+ Options for 'add-api':
96
+ --spec <path-or-url> OpenAPI spec (extracts base_url from servers[0])
97
+ --dir <directory> Base directory (default: ./apis/<name>/)
98
+ --env key=value Set environment variables (repeatable)
99
+
100
+ Options for 'chat':
101
+ --provider <name> LLM provider: ollama, openai, anthropic, custom (default: ollama)
102
+ --model <name> Model name (default: provider-specific)
103
+ --api-key <key> API key (or set ZOND_AI_KEY env var)
104
+ --base-url <url> Provider base URL override
105
+ --safe Only allow running GET tests (read-only mode)
106
+
107
+ Options for 'runs':
108
+ runs List recent test runs
109
+ runs <id> Show run details with step results
110
+ --limit <n> Number of runs to show (default: 20)
111
+
112
+ Options for 'compare':
113
+ compare <runA> <runB> Compare two run IDs
114
+ Exit code 1 if regressions found, 0 otherwise
115
+
116
+ Options for 'coverage':
117
+ --api <name> Use API collection (auto-resolves spec and tests dir)
118
+ --spec <path> Path to OpenAPI spec (required unless --api used)
119
+ --tests <dir> Path to test files directory (required unless --api used)
120
+ --fail-on-coverage N Exit 1 when coverage percentage is below N (0–100)
121
+ --run-id <number> Cross-reference with a test run for pass/fail/5xx breakdown
122
+
123
+ Options for 'run':
124
+ --dry-run Show requests without sending them (exit code always 0)
125
+ --env-var KEY=VALUE Inject env variable (repeatable, overrides env file)
126
+ --api <name> Use API collection (resolves test path automatically)
127
+ --env <name> Use environment file (.env.<name>.yaml)
128
+ --report <format> Output format: console, json, junit (default: console)
129
+ --timeout <ms> Override request timeout
130
+ --bail Stop on first suite failure
131
+ --no-db Do not save results to zond.db
132
+ --db <path> Path to SQLite database file (default: zond.db)
133
+ --auth-token <token> Auth token injected as {{auth_token}} variable
134
+ --safe Run only GET tests (read-only, safe mode)
135
+ --tag <tag> Filter suites by tag (repeatable, comma-separated, OR logic)
136
+
137
+ Options for 'ai-generate':
138
+ --api <name> Use API collection (auto-resolves spec and output dir)
139
+ --from <spec> Path to OpenAPI spec (required unless --api used)
140
+ --prompt <text> Test scenario description (required)
141
+ --provider <name> LLM provider: ollama, openai, anthropic, custom (default: ollama)
142
+ --model <name> Model name (default: provider-specific)
143
+ --api-key <key> API key (or set ZOND_AI_KEY env var)
144
+ --base-url <url> Provider base URL override
145
+ --output <dir> Output directory (default: ./generated/ai/)
146
+
147
+ Options for 'serve':
148
+ --port <port> Server port (default: 8080)
149
+ --host <host> Server host (default: 0.0.0.0)
150
+ --openapi <spec> Path to OpenAPI spec for Explorer
151
+ --db <path> Path to SQLite database file (default: zond.db)
152
+ --watch Enable dev mode with hot reload (auto-refresh browser on file changes)
153
+
154
+ Options for 'ci init':
155
+ --github Generate GitHub Actions workflow
156
+ --gitlab Generate GitLab CI config
157
+ --dir <path> Project root directory (default: current directory)
158
+ --force Overwrite existing CI config
159
+
160
+ General:
161
+ --help, -h Show this help
162
+ --version, -v Show version`);
163
+ }
164
+
165
+ const VALID_REPORTERS = new Set<string>(["console", "json", "junit"]);
166
+
167
+ async function main(): Promise<number> {
168
+ const { command, positional, flags } = parseArgs(process.argv);
169
+
170
+ // Help
171
+ if (command === "help" || command === "--help" || flags["help"] === true || flags["h"] === true) {
172
+ printUsage();
173
+ return 0;
174
+ }
175
+
176
+ // Version
177
+ if (command === "--version" || flags["version"] === true || flags["v"] === true) {
178
+ console.log(`zond ${VERSION} (${getRuntimeInfo()})`);
179
+ return 0;
180
+ }
181
+
182
+ if (!command) {
183
+ printUsage();
184
+ return 0;
185
+ }
186
+
187
+ switch (command) {
188
+ case "add-api": {
189
+ const name = positional[0];
190
+ if (!name) {
191
+ printError("Missing name argument. Usage: zond add-api <name> [--spec <path>] [--dir <dir>]");
192
+ return 2;
193
+ }
194
+
195
+ // Collect all --env flags (parseArgs only stores last one, so re-parse)
196
+ const envValues: string[] = [];
197
+ const rawArgs = process.argv.slice(2);
198
+ for (let i = 0; i < rawArgs.length; i++) {
199
+ if (rawArgs[i] === "--env" && rawArgs[i + 1] && rawArgs[i + 1]!.includes("=")) {
200
+ envValues.push(rawArgs[i + 1]!);
201
+ i++;
202
+ } else if (rawArgs[i]?.startsWith("--env=") && rawArgs[i]!.slice(6).includes("=")) {
203
+ envValues.push(rawArgs[i]!.slice(6));
204
+ }
205
+ }
206
+
207
+ return addApiCommand({
208
+ name,
209
+ spec: typeof flags["spec"] === "string" ? flags["spec"] : undefined,
210
+ dir: typeof flags["dir"] === "string" ? flags["dir"] : undefined,
211
+ envPairs: envValues.length > 0 ? envValues : undefined,
212
+ dbPath: typeof flags["db"] === "string" ? flags["db"] : undefined,
213
+ });
214
+ }
215
+
216
+ case "run": {
217
+ let path = positional[0];
218
+ const apiFlag = typeof flags["api"] === "string" ? flags["api"] : undefined;
219
+ if (!path && apiFlag) {
220
+ try {
221
+ getDb(typeof flags["db"] === "string" ? flags["db"] : undefined);
222
+ const col = findCollectionByNameOrId(apiFlag);
223
+ if (!col) { printError(`API '${apiFlag}' not found`); return 1; }
224
+ path = col.test_path;
225
+ } catch (err) {
226
+ printError(`Failed to resolve --api: ${(err as Error).message}`);
227
+ return 2;
228
+ }
229
+ }
230
+ if (!path) {
231
+ printError("Missing path argument. Usage: zond run <path> or zond run --api <name>");
232
+ return 2;
233
+ }
234
+
235
+ const report = (flags["report"] as string) ?? "console";
236
+ if (!VALID_REPORTERS.has(report)) {
237
+ printError(`Unknown reporter: ${report}. Available: console, json, junit`);
238
+ return 2;
239
+ }
240
+
241
+ const timeoutRaw = flags["timeout"];
242
+ let timeout: number | undefined;
243
+ if (typeof timeoutRaw === "string") {
244
+ timeout = parseInt(timeoutRaw, 10);
245
+ if (isNaN(timeout) || timeout <= 0) {
246
+ printError(`Invalid timeout value: ${timeoutRaw}`);
247
+ return 2;
248
+ }
249
+ }
250
+
251
+ // Collect all --tag and --env-var flags (parseArgs only stores last one, so re-parse)
252
+ const tagValues: string[] = [];
253
+ const envVarValues: string[] = [];
254
+ const rawRunArgs = process.argv.slice(2);
255
+ for (let i = 0; i < rawRunArgs.length; i++) {
256
+ const arg = rawRunArgs[i]!;
257
+ if (arg === "--tag" && rawRunArgs[i + 1]) {
258
+ tagValues.push(rawRunArgs[i + 1]!);
259
+ i++;
260
+ } else if (arg.startsWith("--tag=")) {
261
+ tagValues.push(arg.slice("--tag=".length));
262
+ } else if (arg === "--env-var" && rawRunArgs[i + 1]) {
263
+ envVarValues.push(rawRunArgs[i + 1]!);
264
+ i++;
265
+ } else if (arg.startsWith("--env-var=")) {
266
+ envVarValues.push(arg.slice("--env-var=".length));
267
+ }
268
+ }
269
+ // Support comma-separated: --tag smoke,crud → ["smoke", "crud"]
270
+ const tags = tagValues.flatMap(v => v.split(",")).filter(Boolean);
271
+
272
+ return runCommand({
273
+ path,
274
+ env: typeof flags["env"] === "string" ? flags["env"] : undefined,
275
+ report: report as ReporterName,
276
+ timeout,
277
+ bail: flags["bail"] === true,
278
+ noDb: flags["no-db"] === true,
279
+ dbPath: typeof flags["db"] === "string" ? flags["db"] : undefined,
280
+ authToken: typeof flags["auth-token"] === "string" ? flags["auth-token"] : undefined,
281
+ safe: flags["safe"] === true,
282
+ tag: tags.length > 0 ? tags : undefined,
283
+ envVars: envVarValues.length > 0 ? envVarValues : undefined,
284
+ dryRun: flags["dry-run"] === true,
285
+ });
286
+ }
287
+
288
+ case "validate": {
289
+ const path = positional[0];
290
+ if (!path) {
291
+ printError("Missing path argument. Usage: zond validate <path>");
292
+ return 2;
293
+ }
294
+
295
+ return validateCommand({ path });
296
+ }
297
+
298
+ case "ai-generate": {
299
+ let from = flags["from"] as string | undefined;
300
+ let output = typeof flags["output"] === "string" ? flags["output"] : undefined;
301
+ const aiGenApiFlag = typeof flags["api"] === "string" ? flags["api"] : undefined;
302
+
303
+ // Resolve --api to spec and output dir from collection
304
+ if (aiGenApiFlag) {
305
+ try {
306
+ getDb(typeof flags["db"] === "string" ? flags["db"] : undefined);
307
+ const col = findCollectionByNameOrId(aiGenApiFlag);
308
+ if (!col) { printError(`API '${aiGenApiFlag}' not found`); return 1; }
309
+ if (!from && col.openapi_spec) from = col.openapi_spec;
310
+ if (!output && col.test_path) output = col.test_path;
311
+ } catch (err) {
312
+ printError(`Failed to resolve --api: ${(err as Error).message}`);
313
+ return 2;
314
+ }
315
+ }
316
+
317
+ if (typeof from !== "string") {
318
+ printError("Missing --from <spec>. Usage: zond ai-generate --from <spec> --prompt \"...\"");
319
+ return 2;
320
+ }
321
+ const prompt = flags["prompt"];
322
+ if (typeof prompt !== "string") {
323
+ printError("Missing --prompt <text>. Usage: zond ai-generate --from <spec> --prompt \"...\"");
324
+ return 2;
325
+ }
326
+ return aiGenerateCommand({
327
+ from,
328
+ prompt,
329
+ provider: typeof flags["provider"] === "string" ? flags["provider"] : "ollama",
330
+ model: typeof flags["model"] === "string" ? flags["model"] : undefined,
331
+ apiKey: typeof flags["api-key"] === "string" ? flags["api-key"] : undefined,
332
+ baseUrl: typeof flags["base-url"] === "string" ? flags["base-url"] : undefined,
333
+ output,
334
+ });
335
+ }
336
+
337
+ case "collections": {
338
+ return collectionsCommand(
339
+ typeof flags["db"] === "string" ? flags["db"] : undefined,
340
+ );
341
+ }
342
+
343
+ case "serve": {
344
+ const portRaw = flags["port"];
345
+ let port: number | undefined;
346
+ if (typeof portRaw === "string") {
347
+ port = parseInt(portRaw, 10);
348
+ if (isNaN(port) || port <= 0) {
349
+ printError(`Invalid port value: ${portRaw}`);
350
+ return 2;
351
+ }
352
+ }
353
+ return serveCommand({
354
+ port,
355
+ host: typeof flags["host"] === "string" ? flags["host"] : undefined,
356
+ openapiSpec: typeof flags["openapi"] === "string" ? flags["openapi"] : undefined,
357
+ dbPath: typeof flags["db"] === "string" ? flags["db"] : undefined,
358
+ watch: flags["watch"] === true,
359
+ });
360
+ }
361
+
362
+ case "init": {
363
+ return initCommand({
364
+ force: flags["force"] === true,
365
+ });
366
+ }
367
+
368
+ case "mcp": {
369
+ return mcpCommand({
370
+ dbPath: typeof flags["db"] === "string" ? flags["db"] : undefined,
371
+ dir: typeof flags["dir"] === "string" ? flags["dir"] : undefined,
372
+ });
373
+ }
374
+
375
+ case "chat": {
376
+ return chatCommand({
377
+ provider: typeof flags["provider"] === "string" ? flags["provider"] : undefined,
378
+ model: typeof flags["model"] === "string" ? flags["model"] : undefined,
379
+ apiKey: typeof flags["api-key"] === "string" ? flags["api-key"] : undefined,
380
+ baseUrl: typeof flags["base-url"] === "string" ? flags["base-url"] : undefined,
381
+ safe: flags["safe"] === true,
382
+ dbPath: typeof flags["db"] === "string" ? flags["db"] : undefined,
383
+ });
384
+ }
385
+
386
+ case "update": {
387
+ return updateCommand({ force: flags["force"] === true });
388
+ }
389
+
390
+ case "runs": {
391
+ const idRaw = positional[0];
392
+ let runId: number | undefined;
393
+ if (idRaw) {
394
+ runId = parseInt(idRaw, 10);
395
+ if (isNaN(runId)) {
396
+ printError(`Invalid run ID: ${idRaw}`);
397
+ return 2;
398
+ }
399
+ }
400
+
401
+ const limitRaw = flags["limit"];
402
+ let limit: number | undefined;
403
+ if (typeof limitRaw === "string") {
404
+ limit = parseInt(limitRaw, 10);
405
+ if (isNaN(limit) || limit <= 0) {
406
+ printError(`Invalid limit value: ${limitRaw}`);
407
+ return 2;
408
+ }
409
+ }
410
+
411
+ return runsCommand({
412
+ runId,
413
+ limit,
414
+ dbPath: typeof flags["db"] === "string" ? flags["db"] : undefined,
415
+ });
416
+ }
417
+
418
+ case "ci": {
419
+ const ciSub = positional[0];
420
+ if (ciSub !== "init") {
421
+ printError("Usage: zond ci init [--github|--gitlab] [--force]");
422
+ return 2;
423
+ }
424
+ let platform: "github" | "gitlab" | undefined;
425
+ if (flags["github"] === true) platform = "github";
426
+ else if (flags["gitlab"] === true) platform = "gitlab";
427
+ return ciInitCommand({
428
+ platform,
429
+ force: flags["force"] === true,
430
+ dir: typeof flags["dir"] === "string" ? flags["dir"] : undefined,
431
+ });
432
+ }
433
+
434
+ case "compare": {
435
+ const rawA = positional[0];
436
+ const rawB = positional[1];
437
+ if (!rawA || !rawB) {
438
+ printError("Usage: zond compare <runA> <runB>");
439
+ return 2;
440
+ }
441
+ const runA = parseInt(rawA, 10);
442
+ const runB = parseInt(rawB, 10);
443
+ if (isNaN(runA) || isNaN(runB)) {
444
+ printError("Run IDs must be integers");
445
+ return 2;
446
+ }
447
+ return compareCommand({
448
+ runA,
449
+ runB,
450
+ dbPath: typeof flags["db"] === "string" ? flags["db"] : undefined,
451
+ });
452
+ }
453
+
454
+ case "doctor": {
455
+ return doctorCommand({
456
+ dbPath: typeof flags["db"] === "string" ? flags["db"] : undefined,
457
+ });
458
+ }
459
+
460
+ case "coverage": {
461
+ let spec = flags["spec"] as string | undefined;
462
+ let tests = flags["tests"] as string | undefined;
463
+ const coverageApiFlag = typeof flags["api"] === "string" ? flags["api"] : undefined;
464
+
465
+ if (coverageApiFlag) {
466
+ try {
467
+ getDb(typeof flags["db"] === "string" ? flags["db"] : undefined);
468
+ const col = findCollectionByNameOrId(coverageApiFlag);
469
+ if (!col) { printError(`API '${coverageApiFlag}' not found`); return 1; }
470
+ if (!spec && col.openapi_spec) spec = col.openapi_spec;
471
+ if (!tests && col.test_path) tests = col.test_path;
472
+ } catch (err) {
473
+ printError(`Failed to resolve --api: ${(err as Error).message}`);
474
+ return 2;
475
+ }
476
+ }
477
+
478
+ if (typeof spec !== "string") {
479
+ printError("Missing --spec <path>. Usage: zond coverage --spec <path> --tests <dir>");
480
+ return 2;
481
+ }
482
+ if (typeof tests !== "string") {
483
+ printError("Missing --tests <dir>. Usage: zond coverage --spec <path> --tests <dir>");
484
+ return 2;
485
+ }
486
+ const failOnCoverageRaw = flags["fail-on-coverage"];
487
+ let failOnCoverage: number | undefined;
488
+ if (typeof failOnCoverageRaw === "string") {
489
+ failOnCoverage = parseInt(failOnCoverageRaw, 10);
490
+ if (isNaN(failOnCoverage) || failOnCoverage < 0 || failOnCoverage > 100) {
491
+ printError(`Invalid --fail-on-coverage value: ${failOnCoverageRaw} (must be 0–100)`);
492
+ return 2;
493
+ }
494
+ }
495
+ const runIdRaw = flags["run-id"];
496
+ let runId: number | undefined;
497
+ if (typeof runIdRaw === "string") {
498
+ runId = parseInt(runIdRaw, 10);
499
+ if (isNaN(runId)) {
500
+ printError(`Invalid --run-id value: ${runIdRaw} (must be a number)`);
501
+ return 2;
502
+ }
503
+ }
504
+ return coverageCommand({ spec, tests, failOnCoverage, runId });
505
+ }
506
+
507
+ default: {
508
+ printError(`Unknown command: ${command}`);
509
+ printUsage();
510
+ return 2;
511
+ }
512
+ }
513
+ }
514
+
515
+ // Only run when executed directly, not when imported
516
+ const scriptPath = process.argv[1]?.replaceAll("\\", "/") ?? "";
517
+ const metaFile = import.meta.filename?.replaceAll("\\", "/") ?? "";
518
+ const isMain = scriptPath === metaFile
519
+ || scriptPath.endsWith("cli/index.ts")
520
+ || import.meta.main === true;
521
+ if (isMain) {
522
+ try {
523
+ const code = await main();
524
+ process.exitCode = code;
525
+ } catch (err) {
526
+ printError(err instanceof Error ? err.message : String(err));
527
+ process.exitCode = 2;
528
+ }
529
+ }
@@ -0,0 +1,24 @@
1
+ const RESET = "\x1b[0m";
2
+ const RED = "\x1b[31m";
3
+ const GREEN = "\x1b[32m";
4
+ const YELLOW = "\x1b[33m";
5
+
6
+ function useColor(): boolean {
7
+ return process.stderr.isTTY ?? false;
8
+ }
9
+
10
+ export function printError(message: string): void {
11
+ const msg = useColor() ? `${RED}Error: ${message}${RESET}` : `Error: ${message}`;
12
+ process.stderr.write(msg + "\n");
13
+ }
14
+
15
+ export function printSuccess(message: string): void {
16
+ const color = process.stdout.isTTY ?? false;
17
+ const msg = color ? `${GREEN}${message}${RESET}` : message;
18
+ process.stdout.write(msg + "\n");
19
+ }
20
+
21
+ export function printWarning(message: string): void {
22
+ const msg = useColor() ? `${YELLOW}Warning: ${message}${RESET}` : `Warning: ${message}`;
23
+ process.stderr.write(msg + "\n");
24
+ }
@@ -0,0 +1,7 @@
1
+ export function isCompiledBinary(): boolean {
2
+ return process.argv[0] === "bun" && import.meta.path.includes("~BUN");
3
+ }
4
+
5
+ export function getRuntimeInfo(): string {
6
+ return isCompiledBinary() ? "standalone" : "bun";
7
+ }
@@ -0,0 +1,116 @@
1
+ // Suppress AI SDK v2 spec compatibility warnings for Ollama (cosmetic, tool calling works fine)
2
+ (globalThis as any).AI_SDK_LOG_WARNINGS = false;
3
+
4
+ import { generateText, stepCountIs } from "ai";
5
+ import { createOpenAI } from "@ai-sdk/openai";
6
+ import { createAnthropic } from "@ai-sdk/anthropic";
7
+ import { AGENT_SYSTEM_PROMPT } from "./system-prompt.ts";
8
+ import { buildAgentTools } from "./tools/index.ts";
9
+ import type { AgentConfig, AgentTurnResult, ToolEvent } from "./types.ts";
10
+ import type { ModelMessage } from "ai";
11
+
12
+ export function buildProvider(config: AgentConfig) {
13
+ const { provider } = config.provider;
14
+
15
+ if (provider === "anthropic") {
16
+ return createAnthropic({
17
+ apiKey: config.provider.apiKey,
18
+ baseURL: config.provider.baseUrl || undefined,
19
+ });
20
+ }
21
+
22
+ // openai, ollama, custom all use OpenAI-compatible API
23
+ return createOpenAI({
24
+ apiKey: config.provider.apiKey ?? "ollama",
25
+ baseURL: config.provider.baseUrl,
26
+ });
27
+ }
28
+
29
+ function buildModel(config: AgentConfig) {
30
+ const provider = buildProvider(config);
31
+ const { provider: providerType } = config.provider;
32
+
33
+ // For ollama/custom, use .chat() to avoid the responses API which they don't support.
34
+ if (providerType === "ollama" || providerType === "custom") {
35
+ return (provider as ReturnType<typeof createOpenAI>).chat(config.provider.model);
36
+ }
37
+
38
+ return provider(config.provider.model);
39
+ }
40
+
41
+ /**
42
+ * Prepare messages with system prompt.
43
+ * Some small/local models (e.g. qwen3 thinking mode via Ollama) break tool calling
44
+ * when a separate `system` message is present. For ollama/custom providers, we inject
45
+ * the system prompt into the first user message instead.
46
+ */
47
+ function prepareMessages(
48
+ messages: ModelMessage[],
49
+ config: AgentConfig,
50
+ ): { system?: string; messages: ModelMessage[] } {
51
+ const { provider } = config.provider;
52
+
53
+ if (provider === "ollama" || provider === "custom") {
54
+ // Inject system prompt into first user message to avoid breaking tool calling
55
+ const prepared = [...messages];
56
+ const firstUserIdx = prepared.findIndex(
57
+ (m) => m.role === "user" && typeof m.content === "string",
58
+ );
59
+
60
+ if (firstUserIdx >= 0) {
61
+ const msg = prepared[firstUserIdx] as { role: "user"; content: string };
62
+ prepared[firstUserIdx] = {
63
+ ...msg,
64
+ content: `[System instructions]\n${AGENT_SYSTEM_PROMPT}\n[End instructions]\n\n${msg.content}`,
65
+ };
66
+ }
67
+
68
+ return { messages: prepared };
69
+ }
70
+
71
+ // For OpenAI/Anthropic, use the standard system parameter
72
+ return { system: AGENT_SYSTEM_PROMPT, messages };
73
+ }
74
+
75
+ export async function runAgentTurn(
76
+ messages: ModelMessage[],
77
+ config: AgentConfig,
78
+ onToolEvent?: (event: ToolEvent) => void,
79
+ ): Promise<AgentTurnResult> {
80
+ const model = buildModel(config);
81
+ const tools = buildAgentTools(config);
82
+ const { system, messages: prepared } = prepareMessages(messages, config);
83
+ const toolEvents: ToolEvent[] = [];
84
+
85
+ const result = await generateText({
86
+ model,
87
+ system,
88
+ messages: prepared,
89
+ tools,
90
+ stopWhen: stepCountIs(config.maxSteps ?? 10),
91
+ maxOutputTokens: config.provider.maxTokens ?? 4096,
92
+ onStepFinish: ({ toolCalls, toolResults }) => {
93
+ if (toolCalls) {
94
+ for (let i = 0; i < toolCalls.length; i++) {
95
+ const call = toolCalls[i]!;
96
+ const toolResult = toolResults?.[i];
97
+ const event: ToolEvent = {
98
+ toolName: call.toolName,
99
+ args: ("input" in call ? call.input : {}) as Record<string, unknown>,
100
+ result: toolResult ?? null,
101
+ timestamp: new Date().toISOString(),
102
+ };
103
+ toolEvents.push(event);
104
+ onToolEvent?.(event);
105
+ }
106
+ }
107
+ },
108
+ });
109
+
110
+ return {
111
+ text: result.text,
112
+ toolEvents,
113
+ inputTokens: result.usage?.inputTokens ?? 0,
114
+ outputTokens: result.usage?.outputTokens ?? 0,
115
+ };
116
+ }