@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.
- package/CHANGELOG.md +130 -0
- package/LICENSE +21 -0
- package/README.md +130 -0
- package/package.json +53 -0
- package/src/bun-types.d.ts +5 -0
- package/src/cli/commands/add-api.ts +51 -0
- package/src/cli/commands/ai-generate.ts +106 -0
- package/src/cli/commands/chat.ts +43 -0
- package/src/cli/commands/ci-init.ts +163 -0
- package/src/cli/commands/collections.ts +41 -0
- package/src/cli/commands/compare.ts +129 -0
- package/src/cli/commands/coverage.ts +156 -0
- package/src/cli/commands/doctor.ts +127 -0
- package/src/cli/commands/init.ts +84 -0
- package/src/cli/commands/mcp.ts +16 -0
- package/src/cli/commands/run.ts +156 -0
- package/src/cli/commands/runs.ts +108 -0
- package/src/cli/commands/serve.ts +22 -0
- package/src/cli/commands/update.ts +142 -0
- package/src/cli/commands/validate.ts +18 -0
- package/src/cli/index.ts +529 -0
- package/src/cli/output.ts +24 -0
- package/src/cli/runtime.ts +7 -0
- package/src/core/agent/agent-loop.ts +116 -0
- package/src/core/agent/context-manager.ts +41 -0
- package/src/core/agent/system-prompt.ts +28 -0
- package/src/core/agent/tools/diagnose-failure.ts +51 -0
- package/src/core/agent/tools/explore-api.ts +40 -0
- package/src/core/agent/tools/index.ts +46 -0
- package/src/core/agent/tools/query-results.ts +40 -0
- package/src/core/agent/tools/run-tests.ts +38 -0
- package/src/core/agent/tools/send-request.ts +44 -0
- package/src/core/agent/tools/validate-tests.ts +23 -0
- package/src/core/agent/types.ts +22 -0
- package/src/core/diagnostics/failure-hints.ts +63 -0
- package/src/core/generator/ai/ai-generator.ts +61 -0
- package/src/core/generator/ai/llm-client.ts +159 -0
- package/src/core/generator/ai/output-parser.ts +307 -0
- package/src/core/generator/ai/prompt-builder.ts +153 -0
- package/src/core/generator/ai/types.ts +56 -0
- package/src/core/generator/chunker.ts +47 -0
- package/src/core/generator/coverage-scanner.ts +87 -0
- package/src/core/generator/data-factory.ts +115 -0
- package/src/core/generator/endpoint-warnings.ts +43 -0
- package/src/core/generator/index.ts +12 -0
- package/src/core/generator/openapi-reader.ts +143 -0
- package/src/core/generator/schema-utils.ts +52 -0
- package/src/core/generator/serializer.ts +189 -0
- package/src/core/generator/types.ts +48 -0
- package/src/core/parser/filter.ts +14 -0
- package/src/core/parser/index.ts +21 -0
- package/src/core/parser/schema.ts +175 -0
- package/src/core/parser/types.ts +52 -0
- package/src/core/parser/variables.ts +154 -0
- package/src/core/parser/yaml-parser.ts +85 -0
- package/src/core/reporter/console.ts +175 -0
- package/src/core/reporter/index.ts +23 -0
- package/src/core/reporter/json.ts +9 -0
- package/src/core/reporter/junit.ts +78 -0
- package/src/core/reporter/types.ts +12 -0
- package/src/core/runner/assertions.ts +173 -0
- package/src/core/runner/execute-run.ts +97 -0
- package/src/core/runner/executor.ts +183 -0
- package/src/core/runner/http-client.ts +69 -0
- package/src/core/runner/index.ts +12 -0
- package/src/core/runner/types.ts +48 -0
- package/src/core/setup-api.ts +113 -0
- package/src/core/utils.ts +9 -0
- package/src/db/queries.ts +774 -0
- package/src/db/schema.ts +159 -0
- package/src/mcp/descriptions.ts +88 -0
- package/src/mcp/server.ts +52 -0
- package/src/mcp/tools/ci-init.ts +54 -0
- package/src/mcp/tools/coverage-analysis.ts +141 -0
- package/src/mcp/tools/describe-endpoint.ts +241 -0
- package/src/mcp/tools/explore-api.ts +84 -0
- package/src/mcp/tools/generate-and-save.ts +129 -0
- package/src/mcp/tools/generate-missing-tests.ts +91 -0
- package/src/mcp/tools/generate-tests-guide.ts +391 -0
- package/src/mcp/tools/manage-server.ts +86 -0
- package/src/mcp/tools/query-db.ts +255 -0
- package/src/mcp/tools/run-tests.ts +71 -0
- package/src/mcp/tools/save-test-suite.ts +218 -0
- package/src/mcp/tools/send-request.ts +63 -0
- package/src/mcp/tools/set-work-dir.ts +35 -0
- package/src/mcp/tools/setup-api.ts +84 -0
- package/src/mcp/tools/validate-tests.ts +43 -0
- package/src/tui/chat-ui.ts +150 -0
- package/src/web/data/collection-state.ts +360 -0
- package/src/web/routes/api.ts +234 -0
- package/src/web/routes/dashboard.ts +313 -0
- package/src/web/routes/runs.ts +64 -0
- package/src/web/schemas.ts +121 -0
- package/src/web/server.ts +134 -0
- package/src/web/static/htmx.min.js +1 -0
- package/src/web/static/style.css +827 -0
- package/src/web/views/endpoints-tab.ts +170 -0
- package/src/web/views/health-strip.ts +92 -0
- package/src/web/views/layout.ts +48 -0
- package/src/web/views/results.ts +209 -0
- package/src/web/views/runs-tab.ts +126 -0
- package/src/web/views/suites-tab.ts +153 -0
package/src/cli/index.ts
ADDED
|
@@ -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,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
|
+
}
|