@blogic-cz/agent-tools 0.1.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 (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +236 -0
  3. package/package.json +70 -0
  4. package/schemas/agent-tools.schema.json +319 -0
  5. package/src/az-tool/build.ts +295 -0
  6. package/src/az-tool/config.ts +33 -0
  7. package/src/az-tool/errors.ts +26 -0
  8. package/src/az-tool/extract-option-value.ts +12 -0
  9. package/src/az-tool/index.ts +181 -0
  10. package/src/az-tool/security.ts +130 -0
  11. package/src/az-tool/service.ts +292 -0
  12. package/src/az-tool/types.ts +67 -0
  13. package/src/config/index.ts +12 -0
  14. package/src/config/loader.ts +170 -0
  15. package/src/config/types.ts +82 -0
  16. package/src/credential-guard/claude-hook.ts +28 -0
  17. package/src/credential-guard/index.ts +435 -0
  18. package/src/db-tool/config-service.ts +38 -0
  19. package/src/db-tool/errors.ts +40 -0
  20. package/src/db-tool/index.ts +91 -0
  21. package/src/db-tool/schema.ts +69 -0
  22. package/src/db-tool/security.ts +116 -0
  23. package/src/db-tool/service.ts +605 -0
  24. package/src/db-tool/types.ts +33 -0
  25. package/src/gh-tool/config.ts +7 -0
  26. package/src/gh-tool/errors.ts +47 -0
  27. package/src/gh-tool/index.ts +140 -0
  28. package/src/gh-tool/issue.ts +361 -0
  29. package/src/gh-tool/pr/commands.ts +432 -0
  30. package/src/gh-tool/pr/core.ts +497 -0
  31. package/src/gh-tool/pr/helpers.ts +84 -0
  32. package/src/gh-tool/pr/index.ts +19 -0
  33. package/src/gh-tool/pr/review.ts +571 -0
  34. package/src/gh-tool/repo.ts +147 -0
  35. package/src/gh-tool/service.ts +192 -0
  36. package/src/gh-tool/types.ts +97 -0
  37. package/src/gh-tool/workflow.ts +542 -0
  38. package/src/index.ts +1 -0
  39. package/src/k8s-tool/errors.ts +21 -0
  40. package/src/k8s-tool/index.ts +151 -0
  41. package/src/k8s-tool/service.ts +227 -0
  42. package/src/k8s-tool/types.ts +9 -0
  43. package/src/logs-tool/errors.ts +29 -0
  44. package/src/logs-tool/index.ts +176 -0
  45. package/src/logs-tool/service.ts +323 -0
  46. package/src/logs-tool/types.ts +40 -0
  47. package/src/session-tool/config.ts +55 -0
  48. package/src/session-tool/errors.ts +38 -0
  49. package/src/session-tool/index.ts +270 -0
  50. package/src/session-tool/service.ts +210 -0
  51. package/src/session-tool/types.ts +28 -0
  52. package/src/shared/bun.ts +59 -0
  53. package/src/shared/cli.ts +38 -0
  54. package/src/shared/error-renderer.ts +42 -0
  55. package/src/shared/exec.ts +62 -0
  56. package/src/shared/format.ts +27 -0
  57. package/src/shared/index.ts +16 -0
  58. package/src/shared/throttle.ts +35 -0
  59. package/src/shared/types.ts +25 -0
@@ -0,0 +1,435 @@
1
+ /**
2
+ * Credential Guard
3
+ *
4
+ * Security patterns and functions for detecting sensitive files and secrets.
5
+ * Used by AI coding agent hooks/plugins.
6
+ *
7
+ * Security layers:
8
+ * 1. Path-based blocking (files that should never be read)
9
+ * 2. Content scanning (detect secrets in write operations)
10
+ * 3. Dangerous bash command detection
11
+ * 4. CLI tool blocking (must use wrapper tools)
12
+ *
13
+ * Note: This is a convenience layer. Real security should be enforced
14
+ * at infrastructure level (K8s RBAC, file permissions, etc.)
15
+ */
16
+
17
+ import type { CliToolOverride, CredentialGuardConfig } from "../config/types.ts";
18
+
19
+ // ============================================================================
20
+ // TYPES
21
+ // ============================================================================
22
+
23
+ /** Input format received by hooks/plugins. */
24
+ export type HookInput = {
25
+ tool: string;
26
+ };
27
+
28
+ /** Output format received by hooks/plugins. */
29
+ export type HookOutput = {
30
+ args: Record<string, unknown>;
31
+ };
32
+
33
+ type BlockedCliTool = {
34
+ pattern: RegExp;
35
+ name: string;
36
+ wrapper: string;
37
+ };
38
+
39
+ /** Object returned by createCredentialGuard */
40
+ export type CredentialGuard = {
41
+ handleToolExecuteBefore: (input: HookInput, output: HookOutput) => void;
42
+ detectSecrets: (content: string) => { name: string; match: string } | null;
43
+ isPathAllowed: (filePath: string) => boolean;
44
+ isPathBlocked: (filePath: string) => boolean;
45
+ isDangerousBashCommand: (command: string) => boolean;
46
+ getBlockedCliTool: (command: string) => { name: string; wrapper: string } | null;
47
+ isGhCommandAllowed: (command: string) => boolean;
48
+ };
49
+
50
+ // ============================================================================
51
+ // DEFAULT PATTERNS
52
+ // ============================================================================
53
+
54
+ /**
55
+ * Paths that should NEVER be accessed by AI agents.
56
+ * These patterns match files containing credentials, keys, and secrets.
57
+ */
58
+ const DEFAULT_BLOCKED_PATH_PATTERNS: RegExp[] = [
59
+ /\.env$/,
60
+ /\.env\.[^.]+$/, // .env.local, .env.production, etc.
61
+ /\.pem$/,
62
+ /\.key$/,
63
+ /\.p12$/,
64
+ /\.pfx$/,
65
+ /\/secrets?\//i,
66
+ /^secrets?\//i,
67
+ /\/credentials?\//i,
68
+ /^credentials?\//i,
69
+ /\.aws\//,
70
+ /\.ssh\//,
71
+ /\.kube\//,
72
+ /kubeconfig/i,
73
+ /\.sentryclirc$/,
74
+ ];
75
+
76
+ /**
77
+ * Exceptions - files that match blocked patterns but are safe to access.
78
+ * Only truly generic defaults (no project-specific paths).
79
+ */
80
+ const DEFAULT_ALLOWED_PATH_PATTERNS: RegExp[] = [
81
+ /\.env\.example$/,
82
+ /\.env\.template$/,
83
+ /\.env\.sample$/,
84
+ ];
85
+
86
+ /** Patterns to detect secrets in content. */
87
+ const SECRET_PATTERNS = [
88
+ {
89
+ name: "AWS Access Key",
90
+ pattern: /(?:AKIA|ABIA|ACCA|ASIA)[A-Z0-9]{16}/,
91
+ },
92
+ {
93
+ name: "GitHub Token",
94
+ pattern: /gh[ps]_[A-Za-z0-9]{36}/,
95
+ },
96
+ {
97
+ name: "GitHub PAT",
98
+ pattern: /github_pat_[A-Za-z0-9]{22}_[A-Za-z0-9]{59}/,
99
+ },
100
+ { name: "OpenAI Key", pattern: /sk-[A-Za-z0-9]{48}/ },
101
+ {
102
+ name: "Generic API Key",
103
+ pattern: /(?:api[_-]?key|apikey)["\s:=]+["']?([A-Za-z0-9_-]{20,})["']?/i,
104
+ },
105
+ {
106
+ name: "Generic Secret",
107
+ pattern:
108
+ /(?:secret|token|password|passwd|pwd)[" \t:=]+["']?(?!\$\{|process\.env|z\.|generate|create|read|get|fetch|import|export|const|function|return|Schema)[^\s"']{32,}["']?/i,
109
+ },
110
+ {
111
+ name: "Priv" + "ate Key",
112
+ pattern: new RegExp("-----BEGIN.*PRIVATE KEY-----"),
113
+ },
114
+ {
115
+ name: "JWT Token",
116
+ pattern: /(?:["'=:\s]|^)eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/,
117
+ },
118
+ {
119
+ name: "Azure SAS Token",
120
+ pattern: /[?&]sig=[A-Za-z0-9%+/=]{20,}/,
121
+ },
122
+ {
123
+ name: "GCP Service Account Key",
124
+ pattern: /"type"\s*:\s*"service_account"/,
125
+ },
126
+ {
127
+ name: "Slack Webhook URL",
128
+ pattern: /https:\/\/hooks\.slack\.com\/services\/T[A-Z0-9]+\/B[A-Z0-9]+\/[A-Za-z0-9]+/,
129
+ },
130
+ {
131
+ name: "Discord Webhook URL",
132
+ pattern: /https:\/\/discord(?:app)?\.com\/api\/webhooks\/\d+\/[A-Za-z0-9_-]+/,
133
+ },
134
+ {
135
+ name: "Database URL",
136
+ pattern: /(?:postgres(?:ql)?|mysql|mongodb):\/\/(?!\$\{)[^:]+:(?!\$\{)[^@]+@/,
137
+ },
138
+ ];
139
+
140
+ /**
141
+ * Dangerous bash patterns that might expose secrets.
142
+ */
143
+ const DEFAULT_DANGEROUS_BASH_PATTERNS: RegExp[] = [
144
+ /printenv/i,
145
+ /(?:^|&&|\||;)\s*env(?:\s|$)/i,
146
+ /\bcat\s+\S*\.env/i,
147
+ /\bcat\s+\S*\.pem/i,
148
+ /\bcat\s+\S*\.key/i,
149
+ /\bcat\s+\S*secret/i,
150
+ /\bcat\s+\S*credential/i,
151
+ /\bcat\s+\S*\/\.ssh\//i,
152
+ /\bcat\s+\S*\/\.aws\//i,
153
+ ];
154
+
155
+ /**
156
+ * CLI tools that must use wrapper tools for security and audit.
157
+ */
158
+ const DEFAULT_BLOCKED_CLI_TOOLS: BlockedCliTool[] = [
159
+ {
160
+ pattern: /(?:^|[;&|]\s*)gh\s/,
161
+ name: "gh",
162
+ wrapper: "agent-tools-gh",
163
+ },
164
+ {
165
+ pattern: /(?:^|[;&|]\s*)kubectl\s/,
166
+ name: "kubectl",
167
+ wrapper: "agent-tools-k8s",
168
+ },
169
+ {
170
+ pattern: /(?:^|[;&|]\s*)psql\s/,
171
+ name: "psql",
172
+ wrapper: "agent-tools-db",
173
+ },
174
+ {
175
+ pattern: /(?:^|[;&|]\s*)az\s/,
176
+ name: "az",
177
+ wrapper: "agent-tools-az",
178
+ },
179
+ {
180
+ pattern: /(?:^|[;&|]\s*)curl\s.*dev\.azure\.com/,
181
+ name: "curl (Azure DevOps)",
182
+ wrapper: "agent-tools-az",
183
+ },
184
+ ];
185
+
186
+ /**
187
+ * Read-only gh subcommands safe on external repos with -R flag.
188
+ */
189
+ const GH_ALLOWED_READONLY_SUBCOMMANDS = [
190
+ "issue list",
191
+ "issue view",
192
+ "issue search",
193
+ "pr list",
194
+ "pr view",
195
+ "pr diff",
196
+ "pr checks",
197
+ "release list",
198
+ "release view",
199
+ "repo view",
200
+ "search issues",
201
+ "search prs",
202
+ "search repos",
203
+ ];
204
+
205
+ // ============================================================================
206
+ // HELPERS
207
+ // ============================================================================
208
+
209
+ function escapeRegex(s: string): string {
210
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
211
+ }
212
+
213
+ /** Extract file path from hook arguments. */
214
+ export function extractFilePath(args: Record<string, unknown>): string {
215
+ return (args.filePath as string) || (args.file_path as string) || (args.path as string) || "";
216
+ }
217
+
218
+ /** Extract content from hook arguments. */
219
+ export function extractContent(args: Record<string, unknown>): string {
220
+ return (args.content as string) || (args.newString as string) || "";
221
+ }
222
+
223
+ /** Extract command from hook arguments. */
224
+ export function extractCommand(args: Record<string, unknown>): string {
225
+ return (args.command as string) || "";
226
+ }
227
+
228
+ // ============================================================================
229
+ // FACTORY
230
+ // ============================================================================
231
+
232
+ /**
233
+ * Create a credential guard with optional extra patterns merged into defaults.
234
+ *
235
+ * @param config - Optional overrides. Arrays are concatenated with defaults (not replaced).
236
+ * @returns Object with all guard functions bound to the merged pattern sets.
237
+ */
238
+ export function createCredentialGuard(config?: CredentialGuardConfig): CredentialGuard {
239
+ const blockedPathPatterns = [
240
+ ...DEFAULT_BLOCKED_PATH_PATTERNS,
241
+ ...(config?.additionalBlockedPaths ?? []).map((p) => new RegExp(p)),
242
+ ];
243
+
244
+ const allowedPathPatterns = [
245
+ ...DEFAULT_ALLOWED_PATH_PATTERNS,
246
+ ...(config?.additionalAllowedPaths ?? []).map((p) => new RegExp(p)),
247
+ ];
248
+
249
+ const dangerousBashPatterns = [
250
+ ...DEFAULT_DANGEROUS_BASH_PATTERNS,
251
+ ...(config?.additionalDangerousBashPatterns ?? []).map((p) => new RegExp(p)),
252
+ ];
253
+
254
+ const blockedCliTools: BlockedCliTool[] = [
255
+ ...DEFAULT_BLOCKED_CLI_TOOLS,
256
+ ...(config?.additionalBlockedCliTools ?? []).map(
257
+ (override: CliToolOverride): BlockedCliTool => ({
258
+ pattern: new RegExp(`(?:^|[;&|]\\s*)${escapeRegex(override.tool)}\\s`),
259
+ name: override.tool,
260
+ wrapper: override.suggestion,
261
+ }),
262
+ ),
263
+ ];
264
+
265
+ function isPathAllowed(filePath: string): boolean {
266
+ const normalizedPath = filePath.replace(/\\/g, "/");
267
+ return allowedPathPatterns.some((pattern) => pattern.test(normalizedPath));
268
+ }
269
+
270
+ function isPathBlocked(filePath: string): boolean {
271
+ const normalizedPath = filePath.replace(/\\/g, "/");
272
+
273
+ for (const pattern of allowedPathPatterns) {
274
+ if (pattern.test(normalizedPath)) {
275
+ return false;
276
+ }
277
+ }
278
+
279
+ for (const pattern of blockedPathPatterns) {
280
+ if (pattern.test(normalizedPath)) {
281
+ return true;
282
+ }
283
+ }
284
+
285
+ return false;
286
+ }
287
+
288
+ function detectSecrets(content: string): { name: string; match: string } | null {
289
+ for (const { name, pattern } of SECRET_PATTERNS) {
290
+ const match = content.match(pattern);
291
+ if (match) {
292
+ const redacted = match[0].substring(0, 8) + "..." + match[0].substring(match[0].length - 4);
293
+ return { name, match: redacted };
294
+ }
295
+ }
296
+ return null;
297
+ }
298
+
299
+ function isDangerousBashCommand(command: string): boolean {
300
+ return dangerousBashPatterns.some((pattern) => pattern.test(command));
301
+ }
302
+
303
+ function isGhCommandAllowed(command: string): boolean {
304
+ if (!/ -R\s+\S+/.test(command) && !/ --repo\s+\S+/.test(command)) {
305
+ return false;
306
+ }
307
+
308
+ const ghMatch = command.match(/(?:^|[;&|]\s*)gh\s+(\S+(?:\s+\S+)?)/);
309
+ if (!ghMatch) {
310
+ return false;
311
+ }
312
+
313
+ const subcommand = ghMatch[1];
314
+
315
+ return GH_ALLOWED_READONLY_SUBCOMMANDS.some(
316
+ (allowed) => subcommand === allowed || subcommand.startsWith(`${allowed} `),
317
+ );
318
+ }
319
+
320
+ function allGhCommandsAllowed(command: string): boolean {
321
+ const segments = command.split(/[;&|\n]+/);
322
+ const ghSegments = segments.filter((s) => /\bgh\s/.test(s));
323
+ if (ghSegments.length === 0) return false;
324
+ return ghSegments.every((segment) => isGhCommandAllowed(segment.trim()));
325
+ }
326
+
327
+ function getBlockedCliTool(command: string): { name: string; wrapper: string } | null {
328
+ for (const { pattern, name, wrapper } of blockedCliTools) {
329
+ if (pattern.test(command)) {
330
+ if (name === "gh" && allGhCommandsAllowed(command)) {
331
+ return null;
332
+ }
333
+ return { name, wrapper };
334
+ }
335
+ }
336
+ return null;
337
+ }
338
+
339
+ function handleToolExecuteBefore(input: HookInput, output: HookOutput): void {
340
+ const tool = input.tool;
341
+ const args = output.args;
342
+
343
+ const filePath = extractFilePath(args);
344
+
345
+ if ((tool === "read" || tool === "write" || tool === "edit") && filePath) {
346
+ if (isPathBlocked(filePath)) {
347
+ throw new Error(
348
+ `\u{1F6AB} Access blocked: "${filePath}" is a sensitive file.\n\n` +
349
+ `This file may contain credentials or secrets.\n` +
350
+ `If you need this file's content, ask the user to provide relevant parts.\n\n` +
351
+ `Think this should be allowed? See https://github.com/blogic-cz/agent-tools — fork, extend the guard, and submit a PR.`,
352
+ );
353
+ }
354
+ }
355
+
356
+ if (tool === "write" || tool === "edit") {
357
+ if (!isPathAllowed(filePath)) {
358
+ const content = extractContent(args);
359
+
360
+ if (content) {
361
+ const detected = detectSecrets(content);
362
+ if (detected) {
363
+ throw new Error(
364
+ `\u{1F6AB} Secret detected: Potential ${detected.name} found in content.\n\n` +
365
+ `Matched: ${detected.match}\n\n` +
366
+ `Never commit secrets to code. Use environment variables or secret managers.\n\n` +
367
+ `Think this is a false positive? See https://github.com/blogic-cz/agent-tools — fork, fix the pattern, and submit a PR.`,
368
+ );
369
+ }
370
+ }
371
+ }
372
+ }
373
+
374
+ if (tool === "bash") {
375
+ const command = extractCommand(args);
376
+
377
+ if (isDangerousBashCommand(command)) {
378
+ throw new Error(
379
+ `\u{1F6AB} Command blocked: This command might expose secrets.\n\n` +
380
+ `Command: ${command}\n\n` +
381
+ `If you need environment info, ask the user directly.\n\n` +
382
+ `Think this is wrong? See https://github.com/blogic-cz/agent-tools — fork, adjust the patterns, and submit a PR.`,
383
+ );
384
+ }
385
+
386
+ const blockedTool = getBlockedCliTool(command);
387
+ if (blockedTool) {
388
+ throw new Error(
389
+ `\u{1F6AB} Direct ${blockedTool.name} usage blocked.\n\n` +
390
+ `AI agents must use wrapper tools for security and audit.\n\n` +
391
+ `Use instead: ${blockedTool.wrapper}\n\n` +
392
+ `Run with --help for documentation.\n\n` +
393
+ `Think this tool should be allowed? See https://github.com/blogic-cz/agent-tools — fork, extend the whitelist, and submit a PR.`,
394
+ );
395
+ }
396
+ }
397
+ }
398
+
399
+ return {
400
+ handleToolExecuteBefore,
401
+ detectSecrets,
402
+ isPathAllowed,
403
+ isPathBlocked,
404
+ isDangerousBashCommand,
405
+ getBlockedCliTool,
406
+ isGhCommandAllowed,
407
+ };
408
+ }
409
+
410
+ // ============================================================================
411
+ // TOP-LEVEL EXPORTS (default guard, no config)
412
+ // ============================================================================
413
+
414
+ const defaultGuard = createCredentialGuard();
415
+
416
+ /** Handle tool execution with default guard (no extra config). */
417
+ export const handleToolExecuteBefore = defaultGuard.handleToolExecuteBefore;
418
+
419
+ /** Detect secrets in content with default guard. */
420
+ export const detectSecrets = defaultGuard.detectSecrets;
421
+
422
+ /** Check if a path is in the allowed exceptions list (default guard). */
423
+ export const isPathAllowed = defaultGuard.isPathAllowed;
424
+
425
+ /** Check if a path should be blocked (default guard). */
426
+ export const isPathBlocked = defaultGuard.isPathBlocked;
427
+
428
+ /** Check if a bash command might expose secrets (default guard). */
429
+ export const isDangerousBashCommand = defaultGuard.isDangerousBashCommand;
430
+
431
+ /** Get blocked CLI tool info (default guard). */
432
+ export const getBlockedCliTool = defaultGuard.getBlockedCliTool;
433
+
434
+ /** Check if a gh command is allowed (default guard). */
435
+ export const isGhCommandAllowed = defaultGuard.isGhCommandAllowed;
@@ -0,0 +1,38 @@
1
+ import { Effect, Layer, ServiceMap } from "effect";
2
+
3
+ import { ConfigService, getToolConfig } from "../config";
4
+ import type { DatabaseConfig } from "../config";
5
+
6
+ /**
7
+ * DbConfigService wraps the resolved DatabaseConfig for the selected profile.
8
+ * Returns undefined when no database config is available — the service layer
9
+ * returns a no-op implementation that produces clear error messages.
10
+ *
11
+ * Usage:
12
+ * const dbConfig = yield* DbConfigService;
13
+ * if (!dbConfig) { // no config }
14
+ */
15
+ export class DbConfigService extends ServiceMap.Service<
16
+ DbConfigService,
17
+ DatabaseConfig | undefined
18
+ >()("@agent-tools/DbConfigService") {}
19
+
20
+ /**
21
+ * Creates a DbConfigService layer that resolves the database config
22
+ * from agent-tools.json5 using the given profile.
23
+ * Succeeds with undefined when no config is found (allows --help to work).
24
+ */
25
+ export function makeDbConfigLayer(profile?: string) {
26
+ return Layer.effect(
27
+ DbConfigService,
28
+ Effect.gen(function* () {
29
+ const config = yield* ConfigService;
30
+ const dbConfig = getToolConfig<DatabaseConfig>(config, "database", profile);
31
+ return dbConfig;
32
+ }),
33
+ );
34
+ }
35
+
36
+ export const DbConfigServiceLayer = makeDbConfigLayer();
37
+
38
+ export const TUNNEL_CHECK_INTERVAL_MS = 100;
@@ -0,0 +1,40 @@
1
+ import { Schema } from "effect";
2
+
3
+ export class DbConnectionError extends Schema.TaggedErrorClass<DbConnectionError>()(
4
+ "DbConnectionError",
5
+ {
6
+ message: Schema.String,
7
+ environment: Schema.String,
8
+ },
9
+ ) {}
10
+
11
+ export class DbQueryError extends Schema.TaggedErrorClass<DbQueryError>()("DbQueryError", {
12
+ message: Schema.String,
13
+ sql: Schema.String,
14
+ stderr: Schema.optionalKey(Schema.String),
15
+ }) {}
16
+
17
+ export class DbTunnelError extends Schema.TaggedErrorClass<DbTunnelError>()("DbTunnelError", {
18
+ message: Schema.String,
19
+ port: Schema.Number,
20
+ }) {}
21
+
22
+ export class DbParseError extends Schema.TaggedErrorClass<DbParseError>()("DbParseError", {
23
+ message: Schema.String,
24
+ rawOutput: Schema.String,
25
+ }) {}
26
+
27
+ export class DbMutationBlockedError extends Schema.TaggedErrorClass<DbMutationBlockedError>()(
28
+ "DbMutationBlockedError",
29
+ {
30
+ message: Schema.String,
31
+ environment: Schema.String,
32
+ },
33
+ ) {}
34
+
35
+ export type DbError =
36
+ | DbConnectionError
37
+ | DbMutationBlockedError
38
+ | DbParseError
39
+ | DbQueryError
40
+ | DbTunnelError;
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env bun
2
+ import { Command, Flag } from "effect/unstable/cli";
3
+ import { BunRuntime, BunServices } from "@effect/platform-bun";
4
+ import { Console, Effect, Layer, Option } from "effect";
5
+
6
+ import type { SchemaMode } from "./types";
7
+
8
+ import { formatOption, formatOutput, renderCauseToStderr, VERSION } from "../shared";
9
+ import { ConfigServiceLayer } from "../config";
10
+ import { makeDbConfigLayer } from "./config-service";
11
+ import { DbService } from "./service";
12
+
13
+ // Extract --profile from argv before @effect/cli parsing
14
+ // so we can build the correct config layer.
15
+ const profileIndex = process.argv.indexOf("--profile");
16
+ const profileArg = profileIndex !== -1 ? process.argv[profileIndex + 1] : undefined;
17
+
18
+ const sqlCommand = Command.make(
19
+ "sql",
20
+ {
21
+ env: Flag.string("env").pipe(
22
+ Flag.withDescription("Target database environment name (e.g. local, test, prod)"),
23
+ ),
24
+ sql: Flag.string("sql").pipe(Flag.withDescription("SQL query to execute")),
25
+ format: formatOption,
26
+ profile: Flag.optional(Flag.string("profile")).pipe(
27
+ Flag.withDescription("Database profile name from agent-tools.json5 (if multiple configured)"),
28
+ ),
29
+ },
30
+ ({ env, sql, format }) =>
31
+ Effect.gen(function* () {
32
+ const db = yield* DbService;
33
+ const result = yield* db.executeQuery(env, sql);
34
+ yield* Console.log(formatOutput(result, format));
35
+ }),
36
+ ).pipe(Command.withDescription("Execute a SQL query"));
37
+
38
+ const schemaCommand = Command.make(
39
+ "schema",
40
+ {
41
+ env: Flag.string("env").pipe(
42
+ Flag.withDescription("Target database environment name (e.g. local, test, prod)"),
43
+ ),
44
+ mode: Flag.choice("mode", ["tables", "columns", "full", "relationships"]).pipe(
45
+ Flag.withDescription(
46
+ "Schema introspection mode: tables (list all), columns (show columns for --table), full (all tables with columns), relationships (foreign keys)",
47
+ ),
48
+ ),
49
+ table: Flag.string("table").pipe(
50
+ Flag.withDescription("Table name (required for --mode columns)"),
51
+ Flag.optional,
52
+ ),
53
+ format: formatOption,
54
+ profile: Flag.optional(Flag.string("profile")).pipe(
55
+ Flag.withDescription("Database profile name from agent-tools.json5 (if multiple configured)"),
56
+ ),
57
+ },
58
+ ({ env, mode, table, format }) =>
59
+ Effect.gen(function* () {
60
+ const db = yield* DbService;
61
+ const result = yield* db.executeSchemaQuery(
62
+ env,
63
+ mode as SchemaMode,
64
+ Option.getOrUndefined(table),
65
+ );
66
+ yield* Console.log(formatOutput(result, format));
67
+ }),
68
+ ).pipe(Command.withDescription("Introspect database schema (tables, columns, relationships)"));
69
+
70
+ const mainCommand = Command.make("db-tool", {}).pipe(
71
+ Command.withDescription("Database Query Tool for Coding Agents"),
72
+ Command.withSubcommands([sqlCommand, schemaCommand]),
73
+ );
74
+
75
+ const cli = Command.run(mainCommand, {
76
+ version: VERSION,
77
+ });
78
+
79
+ const dbConfigLayer = makeDbConfigLayer(profileArg);
80
+
81
+ const MainLayer = DbService.layer.pipe(
82
+ Layer.provide(dbConfigLayer),
83
+ Layer.provideMerge(ConfigServiceLayer),
84
+ Layer.provideMerge(BunServices.layer),
85
+ );
86
+
87
+ const program = cli.pipe(Effect.provide(MainLayer), Effect.tapCause(renderCauseToStderr));
88
+
89
+ BunRuntime.runMain(program, {
90
+ disableErrorReporting: true,
91
+ });
@@ -0,0 +1,69 @@
1
+ /**
2
+ * SQL queries for schema introspection.
3
+ *
4
+ * NOTE: The `columns` query uses string interpolation for the table name.
5
+ * This is a known limitation — callers must validate the table name
6
+ * via `isValidTableName()` before calling `getColumns()`.
7
+ */
8
+ export const SCHEMA_QUERIES = {
9
+ tables: `
10
+ SELECT tablename as name
11
+ FROM pg_tables
12
+ WHERE schemaname = 'public'
13
+ ORDER BY tablename
14
+ `,
15
+ columns: (tableName: string) => {
16
+ const escapedTableName = tableName.replaceAll("'", "''");
17
+
18
+ return `
19
+ SELECT
20
+ c.column_name as name,
21
+ c.data_type as type,
22
+ c.is_nullable = 'YES' as nullable,
23
+ c.column_default as default_value,
24
+ COALESCE(
25
+ (SELECT true FROM information_schema.table_constraints tc
26
+ JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name
27
+ WHERE tc.table_name = c.table_name
28
+ AND tc.table_schema = c.table_schema
29
+ AND kcu.column_name = c.column_name
30
+ AND tc.constraint_type = 'PRIMARY KEY'),
31
+ false
32
+ ) as is_primary_key
33
+ FROM information_schema.columns c
34
+ WHERE c.table_name = '${escapedTableName}'
35
+ AND c.table_schema = 'public'
36
+ ORDER BY c.ordinal_position
37
+ `;
38
+ },
39
+ relationships: `
40
+ SELECT
41
+ tc.table_name as from_table,
42
+ kcu.column_name as from_column,
43
+ ccu.table_name as to_table,
44
+ ccu.column_name as to_column,
45
+ tc.constraint_name
46
+ FROM information_schema.table_constraints tc
47
+ JOIN information_schema.key_column_usage kcu
48
+ ON tc.constraint_name = kcu.constraint_name
49
+ AND tc.table_schema = kcu.table_schema
50
+ JOIN information_schema.constraint_column_usage ccu
51
+ ON ccu.constraint_name = tc.constraint_name
52
+ AND ccu.table_schema = tc.table_schema
53
+ WHERE tc.constraint_type = 'FOREIGN KEY'
54
+ AND tc.table_schema = 'public'
55
+ ORDER BY tc.table_name, kcu.column_name
56
+ `,
57
+ };
58
+
59
+ export function getTableNames(): string {
60
+ return SCHEMA_QUERIES.tables;
61
+ }
62
+
63
+ export function getColumns(tableName: string): string {
64
+ return SCHEMA_QUERIES.columns(tableName);
65
+ }
66
+
67
+ export function getRelationships(): string {
68
+ return SCHEMA_QUERIES.relationships;
69
+ }