postgresai 0.12.0-beta.5 → 0.12.0-beta.7

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.
@@ -13,7 +13,7 @@ import * as readline from "readline";
13
13
  import * as http from "https";
14
14
  import { URL } from "url";
15
15
  import { startMcpServer } from "../lib/mcp-server";
16
- import { fetchIssues } from "../lib/issues";
16
+ import { fetchIssues, fetchIssueComments, createIssueComment, fetchIssue } from "../lib/issues";
17
17
  import { resolveBaseUrls } from "../lib/util";
18
18
 
19
19
  const execPromise = promisify(exec);
@@ -59,14 +59,6 @@ interface PathResolution {
59
59
  instancesFile: string;
60
60
  }
61
61
 
62
- /**
63
- * Health check service
64
- */
65
- interface HealthService {
66
- name: string;
67
- url: string;
68
- }
69
-
70
62
  /**
71
63
  * Get configuration from various sources
72
64
  * @param opts - Command line options
@@ -90,6 +82,24 @@ function getConfig(opts: CliOptions): ConfigResult {
90
82
  return { apiKey };
91
83
  }
92
84
 
85
+ // Human-friendly output helper: YAML for TTY by default, JSON when --json or non-TTY
86
+ function printResult(result: unknown, json?: boolean): void {
87
+ if (typeof result === "string") {
88
+ process.stdout.write(result);
89
+ if (!/\n$/.test(result)) console.log();
90
+ return;
91
+ }
92
+ if (json || !process.stdout.isTTY) {
93
+ console.log(JSON.stringify(result, null, 2));
94
+ } else {
95
+ let text = yaml.dump(result as any);
96
+ if (Array.isArray(result)) {
97
+ text = text.replace(/\n- /g, "\n\n- ");
98
+ }
99
+ console.log(text);
100
+ }
101
+ }
102
+
93
103
  const program = new Command();
94
104
 
95
105
  program
@@ -172,7 +182,7 @@ function checkRunningContainers(): { running: boolean; containers: string[] } {
172
182
  ["ps", "--filter", "name=grafana-with-datasources", "--filter", "name=pgwatch", "--format", "{{.Names}}"],
173
183
  { stdio: "pipe", encoding: "utf8" }
174
184
  );
175
-
185
+
176
186
  if (result.status === 0 && result.stdout) {
177
187
  const containers = result.stdout.trim().split("\n").filter(Boolean);
178
188
  return { running: containers.length > 0, containers };
@@ -188,30 +198,53 @@ function checkRunningContainers(): { running: boolean; containers: string[] } {
188
198
  */
189
199
  async function runCompose(args: string[]): Promise<number> {
190
200
  let composeFile: string;
201
+ let projectDir: string;
191
202
  try {
192
- ({ composeFile } = resolvePaths());
203
+ ({ composeFile, projectDir } = resolvePaths());
193
204
  } catch (error) {
194
205
  const message = error instanceof Error ? error.message : String(error);
195
206
  console.error(message);
196
207
  process.exitCode = 1;
197
208
  return 1;
198
209
  }
199
-
210
+
200
211
  // Check if Docker daemon is running
201
212
  if (!isDockerRunning()) {
202
213
  console.error("Docker is not running. Please start Docker and try again");
203
214
  process.exitCode = 1;
204
215
  return 1;
205
216
  }
206
-
217
+
207
218
  const cmd = getComposeCmd();
208
219
  if (!cmd) {
209
220
  console.error("docker compose not found (need docker-compose or docker compose)");
210
221
  process.exitCode = 1;
211
222
  return 1;
212
223
  }
224
+
225
+ // Read Grafana password from .pgwatch-config and pass to Docker Compose
226
+ const env = { ...process.env };
227
+ const cfgPath = path.resolve(projectDir, ".pgwatch-config");
228
+ if (fs.existsSync(cfgPath)) {
229
+ try {
230
+ const stats = fs.statSync(cfgPath);
231
+ if (!stats.isDirectory()) {
232
+ const content = fs.readFileSync(cfgPath, "utf8");
233
+ const match = content.match(/^grafana_password=([^\r\n]+)/m);
234
+ if (match) {
235
+ env.GF_SECURITY_ADMIN_PASSWORD = match[1].trim();
236
+ }
237
+ }
238
+ } catch (err) {
239
+ // If we can't read the config, continue without setting the password
240
+ }
241
+ }
242
+
213
243
  return new Promise<number>((resolve) => {
214
- const child = spawn(cmd[0], [...cmd.slice(1), "-f", composeFile, ...args], { stdio: "inherit" });
244
+ const child = spawn(cmd[0], [...cmd.slice(1), "-f", composeFile, ...args], {
245
+ stdio: "inherit",
246
+ env: env
247
+ });
215
248
  child.on("close", (code) => resolve(code || 0));
216
249
  });
217
250
  }
@@ -226,23 +259,308 @@ const mon = program.command("mon").description("monitoring services management")
226
259
  mon
227
260
  .command("quickstart")
228
261
  .description("complete setup (generate config, start monitoring services)")
229
- .option("--demo", "demo mode", false)
230
- .action(async () => {
262
+ .option("--demo", "demo mode with sample database", false)
263
+ .option("--api-key <key>", "Postgres AI API key for automated report uploads")
264
+ .option("--db-url <url>", "PostgreSQL connection URL to monitor")
265
+ .option("-y, --yes", "accept all defaults and skip interactive prompts", false)
266
+ .action(async (opts: { demo: boolean; apiKey?: string; dbUrl?: string; yes: boolean }) => {
267
+ console.log("\n=================================");
268
+ console.log(" PostgresAI Monitoring Quickstart");
269
+ console.log("=================================\n");
270
+ console.log("This will install, configure, and start the monitoring system\n");
271
+
272
+ // Validate conflicting options
273
+ if (opts.demo && opts.dbUrl) {
274
+ console.log("⚠ Both --demo and --db-url provided. Demo mode includes its own database.");
275
+ console.log("⚠ The --db-url will be ignored in demo mode.\n");
276
+ opts.dbUrl = undefined;
277
+ }
278
+
279
+ if (opts.demo && opts.apiKey) {
280
+ console.error("✗ Cannot use --api-key with --demo mode");
281
+ console.error("✗ Demo mode is for testing only and does not support API key integration");
282
+ console.error("\nUse demo mode without API key: postgres-ai mon quickstart --demo");
283
+ console.error("Or use production mode with API key: postgres-ai mon quickstart --api-key=your_key");
284
+ process.exitCode = 1;
285
+ return;
286
+ }
287
+
231
288
  // Check if containers are already running
232
289
  const { running, containers } = checkRunningContainers();
233
290
  if (running) {
234
- console.log(`Monitoring services are already running: ${containers.join(", ")}`);
235
- console.log("Use 'postgres-ai mon restart' to restart them");
291
+ console.log(`⚠ Monitoring services are already running: ${containers.join(", ")}`);
292
+ console.log("Use 'postgres-ai mon restart' to restart them\n");
236
293
  return;
237
294
  }
238
-
295
+
296
+ // Step 1: API key configuration (only in production mode)
297
+ if (!opts.demo) {
298
+ console.log("Step 1: Postgres AI API Configuration (Optional)");
299
+ console.log("An API key enables automatic upload of PostgreSQL reports to Postgres AI\n");
300
+
301
+ if (opts.apiKey) {
302
+ console.log("Using API key provided via --api-key parameter");
303
+ config.writeConfig({ apiKey: opts.apiKey });
304
+ console.log("✓ API key saved\n");
305
+ } else if (opts.yes) {
306
+ // Auto-yes mode without API key - skip API key setup
307
+ console.log("Auto-yes mode: no API key provided, skipping API key setup");
308
+ console.log("⚠ Reports will be generated locally only");
309
+ console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
310
+ } else {
311
+ const rl = readline.createInterface({
312
+ input: process.stdin,
313
+ output: process.stdout
314
+ });
315
+
316
+ const question = (prompt: string): Promise<string> =>
317
+ new Promise((resolve) => rl.question(prompt, resolve));
318
+
319
+ try {
320
+ const answer = await question("Do you have a Postgres AI API key? (Y/n): ");
321
+ const proceedWithApiKey = !answer || answer.toLowerCase() === "y";
322
+
323
+ if (proceedWithApiKey) {
324
+ while (true) {
325
+ const inputApiKey = await question("Enter your Postgres AI API key: ");
326
+ const trimmedKey = inputApiKey.trim();
327
+
328
+ if (trimmedKey) {
329
+ config.writeConfig({ apiKey: trimmedKey });
330
+ console.log("✓ API key saved\n");
331
+ break;
332
+ }
333
+
334
+ console.log("⚠ API key cannot be empty");
335
+ const retry = await question("Try again or skip API key setup, retry? (Y/n): ");
336
+ if (retry.toLowerCase() === "n") {
337
+ console.log("⚠ Skipping API key setup - reports will be generated locally only");
338
+ console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
339
+ break;
340
+ }
341
+ }
342
+ } else {
343
+ console.log("⚠ Skipping API key setup - reports will be generated locally only");
344
+ console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
345
+ }
346
+ } finally {
347
+ rl.close();
348
+ }
349
+ }
350
+ } else {
351
+ console.log("Step 1: Demo mode - API key configuration skipped");
352
+ console.log("Demo mode is for testing only and does not support API key integration\n");
353
+ }
354
+
355
+ // Step 2: Add PostgreSQL instance (if not demo mode)
356
+ if (!opts.demo) {
357
+ console.log("Step 2: Add PostgreSQL Instance to Monitor\n");
358
+
359
+ // Clear instances.yml in production mode (start fresh)
360
+ const instancesPath = path.resolve(process.cwd(), "instances.yml");
361
+ const emptyInstancesContent = "# PostgreSQL instances to monitor\n# Add your instances using: postgres-ai mon targets add\n\n";
362
+ fs.writeFileSync(instancesPath, emptyInstancesContent, "utf8");
363
+
364
+ if (opts.dbUrl) {
365
+ console.log("Using database URL provided via --db-url parameter");
366
+ console.log(`Adding PostgreSQL instance from: ${opts.dbUrl}\n`);
367
+
368
+ const match = opts.dbUrl.match(/^postgresql:\/\/[^@]+@([^:/]+)/);
369
+ const autoInstanceName = match ? match[1] : "db-instance";
370
+
371
+ const connStr = opts.dbUrl;
372
+ const m = connStr.match(/^postgresql:\/\/([^:]+):([^@]+)@([^:\/]+)(?::(\d+))?\/(.+)$/);
373
+
374
+ if (!m) {
375
+ console.error("✗ Invalid connection string format");
376
+ process.exitCode = 1;
377
+ return;
378
+ }
379
+
380
+ const host = m[3];
381
+ const db = m[5];
382
+ const instanceName = `${host}-${db}`.replace(/[^a-zA-Z0-9-]/g, "-");
383
+
384
+ const body = `- name: ${instanceName}\n conn_str: ${connStr}\n preset_metrics: full\n custom_metrics:\n is_enabled: true\n group: default\n custom_tags:\n env: production\n cluster: default\n node_name: ${instanceName}\n sink_type: ~sink_type~\n`;
385
+ fs.appendFileSync(instancesPath, body, "utf8");
386
+ console.log(`✓ Monitoring target '${instanceName}' added\n`);
387
+
388
+ // Test connection
389
+ console.log("Testing connection to the added instance...");
390
+ try {
391
+ const { Client } = require("pg");
392
+ const client = new Client({ connectionString: connStr });
393
+ await client.connect();
394
+ const result = await client.query("select version();");
395
+ console.log("✓ Connection successful");
396
+ console.log(`${result.rows[0].version}\n`);
397
+ await client.end();
398
+ } catch (error) {
399
+ const message = error instanceof Error ? error.message : String(error);
400
+ console.error(`✗ Connection failed: ${message}\n`);
401
+ }
402
+ } else if (opts.yes) {
403
+ // Auto-yes mode without database URL - skip database setup
404
+ console.log("Auto-yes mode: no database URL provided, skipping database setup");
405
+ console.log("⚠ No PostgreSQL instance added");
406
+ console.log("You can add one later with: postgres-ai mon targets add\n");
407
+ } else {
408
+ const rl = readline.createInterface({
409
+ input: process.stdin,
410
+ output: process.stdout
411
+ });
412
+
413
+ const question = (prompt: string): Promise<string> =>
414
+ new Promise((resolve) => rl.question(prompt, resolve));
415
+
416
+ try {
417
+ console.log("You need to add at least one PostgreSQL instance to monitor");
418
+ const answer = await question("Do you want to add a PostgreSQL instance now? (Y/n): ");
419
+ const proceedWithInstance = !answer || answer.toLowerCase() === "y";
420
+
421
+ if (proceedWithInstance) {
422
+ console.log("\nYou can provide either:");
423
+ console.log(" 1. A full connection string: postgresql://user:pass@host:port/database");
424
+ console.log(" 2. Press Enter to skip for now\n");
425
+
426
+ const connStr = await question("Enter connection string (or press Enter to skip): ");
427
+
428
+ if (connStr.trim()) {
429
+ const m = connStr.match(/^postgresql:\/\/([^:]+):([^@]+)@([^:\/]+)(?::(\d+))?\/(.+)$/);
430
+ if (!m) {
431
+ console.error("✗ Invalid connection string format");
432
+ console.log("⚠ Continuing without adding instance\n");
433
+ } else {
434
+ const host = m[3];
435
+ const db = m[5];
436
+ const instanceName = `${host}-${db}`.replace(/[^a-zA-Z0-9-]/g, "-");
437
+
438
+ const body = `- name: ${instanceName}\n conn_str: ${connStr}\n preset_metrics: full\n custom_metrics:\n is_enabled: true\n group: default\n custom_tags:\n env: production\n cluster: default\n node_name: ${instanceName}\n sink_type: ~sink_type~\n`;
439
+ fs.appendFileSync(instancesPath, body, "utf8");
440
+ console.log(`✓ Monitoring target '${instanceName}' added\n`);
441
+
442
+ // Test connection
443
+ console.log("Testing connection to the added instance...");
444
+ try {
445
+ const { Client } = require("pg");
446
+ const client = new Client({ connectionString: connStr });
447
+ await client.connect();
448
+ const result = await client.query("select version();");
449
+ console.log("✓ Connection successful");
450
+ console.log(`${result.rows[0].version}\n`);
451
+ await client.end();
452
+ } catch (error) {
453
+ const message = error instanceof Error ? error.message : String(error);
454
+ console.error(`✗ Connection failed: ${message}\n`);
455
+ }
456
+ }
457
+ } else {
458
+ console.log("⚠ No PostgreSQL instance added - you can add one later with: postgres-ai mon targets add\n");
459
+ }
460
+ } else {
461
+ console.log("⚠ No PostgreSQL instance added - you can add one later with: postgres-ai mon targets add\n");
462
+ }
463
+ } finally {
464
+ rl.close();
465
+ }
466
+ }
467
+ } else {
468
+ console.log("Step 2: Demo mode enabled - using included demo PostgreSQL database\n");
469
+ }
470
+
471
+ // Step 3: Update configuration
472
+ console.log(opts.demo ? "Step 3: Updating configuration..." : "Step 3: Updating configuration...");
239
473
  const code1 = await runCompose(["run", "--rm", "sources-generator"]);
240
474
  if (code1 !== 0) {
241
475
  process.exitCode = code1;
242
476
  return;
243
477
  }
244
- const code2 = await runCompose(["up", "-d"]);
245
- if (code2 !== 0) process.exitCode = code2;
478
+ console.log(" Configuration updated\n");
479
+
480
+ // Step 4: Ensure Grafana password is configured
481
+ console.log(opts.demo ? "Step 4: Configuring Grafana security..." : "Step 4: Configuring Grafana security...");
482
+ const cfgPath = path.resolve(process.cwd(), ".pgwatch-config");
483
+ let grafanaPassword = "";
484
+
485
+ try {
486
+ if (fs.existsSync(cfgPath)) {
487
+ const stats = fs.statSync(cfgPath);
488
+ if (!stats.isDirectory()) {
489
+ const content = fs.readFileSync(cfgPath, "utf8");
490
+ const match = content.match(/^grafana_password=([^\r\n]+)/m);
491
+ if (match) {
492
+ grafanaPassword = match[1].trim();
493
+ }
494
+ }
495
+ }
496
+
497
+ if (!grafanaPassword) {
498
+ console.log("Generating secure Grafana password...");
499
+ const { stdout: password } = await execPromise("openssl rand -base64 12 | tr -d '\n'");
500
+ grafanaPassword = password.trim();
501
+
502
+ let configContent = "";
503
+ if (fs.existsSync(cfgPath)) {
504
+ const stats = fs.statSync(cfgPath);
505
+ if (!stats.isDirectory()) {
506
+ configContent = fs.readFileSync(cfgPath, "utf8");
507
+ }
508
+ }
509
+
510
+ const lines = configContent.split(/\r?\n/).filter((l) => !/^grafana_password=/.test(l));
511
+ lines.push(`grafana_password=${grafanaPassword}`);
512
+ fs.writeFileSync(cfgPath, lines.filter(Boolean).join("\n") + "\n", "utf8");
513
+ }
514
+
515
+ console.log("✓ Grafana password configured\n");
516
+ } catch (error) {
517
+ console.log("⚠ Could not generate Grafana password automatically");
518
+ console.log("Using default password: demo\n");
519
+ grafanaPassword = "demo";
520
+ }
521
+
522
+ // Step 5: Start services
523
+ console.log(opts.demo ? "Step 5: Starting monitoring services..." : "Step 5: Starting monitoring services...");
524
+ const code2 = await runCompose(["up", "-d", "--force-recreate"]);
525
+ if (code2 !== 0) {
526
+ process.exitCode = code2;
527
+ return;
528
+ }
529
+ console.log("✓ Services started\n");
530
+
531
+ // Final summary
532
+ console.log("=================================");
533
+ console.log(" 🎉 Quickstart setup completed!");
534
+ console.log("=================================\n");
535
+
536
+ console.log("What's running:");
537
+ if (opts.demo) {
538
+ console.log(" ✅ Demo PostgreSQL database (monitoring target)");
539
+ }
540
+ console.log(" ✅ PostgreSQL monitoring infrastructure");
541
+ console.log(" ✅ Grafana dashboards (with secure password)");
542
+ console.log(" ✅ Prometheus metrics storage");
543
+ console.log(" ✅ Flask API backend");
544
+ console.log(" ✅ Automated report generation (every 24h)");
545
+ console.log(" ✅ Host stats monitoring (CPU, memory, disk, I/O)\n");
546
+
547
+ if (!opts.demo) {
548
+ console.log("Next steps:");
549
+ console.log(" • Add more PostgreSQL instances: postgres-ai mon targets add");
550
+ console.log(" • View configured instances: postgres-ai mon targets list");
551
+ console.log(" • Check service health: postgres-ai mon health\n");
552
+ } else {
553
+ console.log("Demo mode next steps:");
554
+ console.log(" • Explore Grafana dashboards at http://localhost:3000");
555
+ console.log(" • Connect to demo database: postgresql://postgres:postgres@localhost:55432/target_database");
556
+ console.log(" • Generate some load on the demo database to see metrics\n");
557
+ }
558
+
559
+ console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
560
+ console.log("🚀 MAIN ACCESS POINT - Start here:");
561
+ console.log(" Grafana Dashboard: http://localhost:3000");
562
+ console.log(` Login: monitor / ${grafanaPassword}`);
563
+ console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
246
564
  });
247
565
 
248
566
  mon
@@ -256,7 +574,7 @@ mon
256
574
  console.log("Use 'postgres-ai mon restart' to restart them");
257
575
  return;
258
576
  }
259
-
577
+
260
578
  const code = await runCompose(["up", "-d"]);
261
579
  if (code !== 0) process.exitCode = code;
262
580
  });
@@ -305,42 +623,40 @@ mon
305
623
  .description("health check for monitoring services")
306
624
  .option("--wait <seconds>", "wait time in seconds for services to become healthy", parseInt, 0)
307
625
  .action(async (opts: { wait: number }) => {
308
- const services: HealthService[] = [
309
- { name: "Grafana", url: "http://localhost:3000/api/health" },
310
- { name: "Prometheus", url: "http://localhost:59090/-/healthy" },
311
- { name: "PGWatch (Postgres)", url: "http://localhost:58080/health" },
312
- { name: "PGWatch (Prometheus)", url: "http://localhost:58089/health" },
626
+ const services = [
627
+ { name: "Grafana", container: "grafana-with-datasources" },
628
+ { name: "Prometheus", container: "sink-prometheus" },
629
+ { name: "PGWatch (Postgres)", container: "pgwatch-postgres" },
630
+ { name: "PGWatch (Prometheus)", container: "pgwatch-prometheus" },
631
+ { name: "Target DB", container: "target-db" },
632
+ { name: "Sink Postgres", container: "sink-postgres" },
313
633
  ];
314
-
634
+
315
635
  const waitTime = opts.wait || 0;
316
636
  const maxAttempts = waitTime > 0 ? Math.ceil(waitTime / 5) : 1;
317
-
637
+
318
638
  console.log("Checking service health...\n");
319
-
639
+
320
640
  let allHealthy = false;
321
641
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
322
642
  if (attempt > 1) {
323
643
  console.log(`Retrying (attempt ${attempt}/${maxAttempts})...\n`);
324
644
  await new Promise(resolve => setTimeout(resolve, 5000));
325
645
  }
326
-
646
+
327
647
  allHealthy = true;
328
648
  for (const service of services) {
329
649
  try {
330
- // Use native fetch instead of requiring curl to be installed
331
- const controller = new AbortController();
332
- const timeoutId = setTimeout(() => controller.abort(), 5000);
333
-
334
- const response = await fetch(service.url, {
335
- signal: controller.signal,
336
- method: 'GET',
337
- });
338
- clearTimeout(timeoutId);
339
-
340
- if (response.status === 200) {
650
+ const { execSync } = require("child_process");
651
+ const status = execSync(`docker inspect -f '{{.State.Status}}' ${service.container} 2>/dev/null`, {
652
+ encoding: 'utf8',
653
+ stdio: ['pipe', 'pipe', 'pipe']
654
+ }).trim();
655
+
656
+ if (status === 'running') {
341
657
  console.log(`✓ ${service.name}: healthy`);
342
658
  } else {
343
- console.log(`✗ ${service.name}: unhealthy (HTTP ${response.status})`);
659
+ console.log(`✗ ${service.name}: unhealthy (status: ${status})`);
344
660
  allHealthy = false;
345
661
  }
346
662
  } catch (error) {
@@ -348,12 +664,12 @@ mon
348
664
  allHealthy = false;
349
665
  }
350
666
  }
351
-
667
+
352
668
  if (allHealthy) {
353
669
  break;
354
670
  }
355
671
  }
356
-
672
+
357
673
  console.log("");
358
674
  if (allHealthy) {
359
675
  console.log("All services are healthy");
@@ -399,7 +715,7 @@ mon
399
715
  .description("update monitoring stack")
400
716
  .action(async () => {
401
717
  console.log("Updating PostgresAI monitoring stack...\n");
402
-
718
+
403
719
  try {
404
720
  // Check if we're in a git repo
405
721
  const gitDir = path.resolve(process.cwd(), ".git");
@@ -408,25 +724,25 @@ mon
408
724
  process.exitCode = 1;
409
725
  return;
410
726
  }
411
-
727
+
412
728
  // Fetch latest changes
413
729
  console.log("Fetching latest changes...");
414
730
  await execPromise("git fetch origin");
415
-
731
+
416
732
  // Check current branch
417
733
  const { stdout: branch } = await execPromise("git rev-parse --abbrev-ref HEAD");
418
734
  const currentBranch = branch.trim();
419
735
  console.log(`Current branch: ${currentBranch}`);
420
-
736
+
421
737
  // Pull latest changes
422
738
  console.log("Pulling latest changes...");
423
739
  const { stdout: pullOut } = await execPromise("git pull origin " + currentBranch);
424
740
  console.log(pullOut);
425
-
741
+
426
742
  // Update Docker images
427
743
  console.log("\nUpdating Docker images...");
428
744
  const code = await runCompose(["pull"]);
429
-
745
+
430
746
  if (code === 0) {
431
747
  console.log("\n✓ Update completed successfully");
432
748
  console.log("\nTo apply updates, restart monitoring services:");
@@ -449,32 +765,32 @@ mon
449
765
  input: process.stdin,
450
766
  output: process.stdout,
451
767
  });
452
-
768
+
453
769
  const question = (prompt: string): Promise<string> =>
454
770
  new Promise((resolve) => rl.question(prompt, resolve));
455
-
771
+
456
772
  try {
457
773
  if (service) {
458
774
  // Reset specific service
459
775
  console.log(`\nThis will stop '${service}', remove its volume, and restart it.`);
460
776
  console.log("All data for this service will be lost!\n");
461
-
777
+
462
778
  const answer = await question("Continue? (y/N): ");
463
779
  if (answer.toLowerCase() !== "y") {
464
780
  console.log("Cancelled");
465
781
  rl.close();
466
782
  return;
467
783
  }
468
-
784
+
469
785
  console.log(`\nStopping ${service}...`);
470
786
  await runCompose(["stop", service]);
471
-
787
+
472
788
  console.log(`Removing volume for ${service}...`);
473
789
  await runCompose(["rm", "-f", "-v", service]);
474
-
790
+
475
791
  console.log(`Restarting ${service}...`);
476
792
  const code = await runCompose(["up", "-d", service]);
477
-
793
+
478
794
  if (code === 0) {
479
795
  console.log(`\n✓ Service '${service}' has been reset`);
480
796
  } else {
@@ -485,17 +801,17 @@ mon
485
801
  // Reset all services
486
802
  console.log("\nThis will stop all services and remove all data!");
487
803
  console.log("Volumes, networks, and containers will be deleted.\n");
488
-
804
+
489
805
  const answer = await question("Continue? (y/N): ");
490
806
  if (answer.toLowerCase() !== "y") {
491
807
  console.log("Cancelled");
492
808
  rl.close();
493
809
  return;
494
810
  }
495
-
811
+
496
812
  console.log("\nStopping services and removing data...");
497
813
  const downCode = await runCompose(["down", "-v"]);
498
-
814
+
499
815
  if (downCode === 0) {
500
816
  console.log("✓ Environment reset completed - all containers and data removed");
501
817
  } else {
@@ -503,7 +819,7 @@ mon
503
819
  process.exitCode = 1;
504
820
  }
505
821
  }
506
-
822
+
507
823
  rl.close();
508
824
  } catch (error) {
509
825
  rl.close();
@@ -517,7 +833,7 @@ mon
517
833
  .description("cleanup monitoring services artifacts")
518
834
  .action(async () => {
519
835
  console.log("Cleaning up Docker resources...\n");
520
-
836
+
521
837
  try {
522
838
  // Remove stopped containers
523
839
  const { stdout: containers } = await execFilePromise("docker", ["ps", "-aq", "--filter", "status=exited"]);
@@ -528,19 +844,19 @@ mon
528
844
  } else {
529
845
  console.log("✓ No stopped containers to remove");
530
846
  }
531
-
847
+
532
848
  // Remove unused volumes
533
849
  await execFilePromise("docker", ["volume", "prune", "-f"]);
534
850
  console.log("✓ Removed unused volumes");
535
-
851
+
536
852
  // Remove unused networks
537
853
  await execFilePromise("docker", ["network", "prune", "-f"]);
538
854
  console.log("✓ Removed unused networks");
539
-
855
+
540
856
  // Remove dangling images
541
857
  await execFilePromise("docker", ["image", "prune", "-f"]);
542
858
  console.log("✓ Removed dangling images");
543
-
859
+
544
860
  console.log("\nCleanup completed");
545
861
  } catch (error) {
546
862
  const message = error instanceof Error ? error.message : String(error);
@@ -576,11 +892,11 @@ targets
576
892
  process.exitCode = 1;
577
893
  return;
578
894
  }
579
-
895
+
580
896
  try {
581
897
  const content = fs.readFileSync(instancesPath, "utf8");
582
898
  const instances = yaml.load(content) as Instance[] | null;
583
-
899
+
584
900
  if (!instances || !Array.isArray(instances) || instances.length === 0) {
585
901
  console.log("No monitoring targets configured");
586
902
  console.log("");
@@ -591,10 +907,10 @@ targets
591
907
  console.log(" postgres-ai mon targets add 'postgresql://user:pass@host:5432/db' my-db");
592
908
  return;
593
909
  }
594
-
910
+
595
911
  // Filter out disabled instances (e.g., demo placeholders)
596
912
  const filtered = instances.filter((inst) => inst.name && inst.is_enabled !== false);
597
-
913
+
598
914
  if (filtered.length === 0) {
599
915
  console.log("No monitoring targets configured");
600
916
  console.log("");
@@ -605,7 +921,7 @@ targets
605
921
  console.log(" postgres-ai mon targets add 'postgresql://user:pass@host:5432/db' my-db");
606
922
  return;
607
923
  }
608
-
924
+
609
925
  for (const inst of filtered) {
610
926
  console.log(`Target: ${inst.name}`);
611
927
  }
@@ -634,7 +950,7 @@ targets
634
950
  const host = m[3];
635
951
  const db = m[5];
636
952
  const instanceName = name && name.trim() ? name.trim() : `${host}-${db}`.replace(/[^a-zA-Z0-9-]/g, "-");
637
-
953
+
638
954
  // Check if instance already exists
639
955
  try {
640
956
  if (fs.existsSync(file)) {
@@ -658,7 +974,7 @@ targets
658
974
  return;
659
975
  }
660
976
  }
661
-
977
+
662
978
  // Add new instance
663
979
  const body = `- name: ${instanceName}\n conn_str: ${connStr}\n preset_metrics: full\n custom_metrics:\n is_enabled: true\n group: default\n custom_tags:\n env: production\n cluster: default\n node_name: ${instanceName}\n sink_type: ~sink_type~\n`;
664
980
  const content = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
@@ -675,25 +991,25 @@ targets
675
991
  process.exitCode = 1;
676
992
  return;
677
993
  }
678
-
994
+
679
995
  try {
680
996
  const content = fs.readFileSync(file, "utf8");
681
997
  const instances = yaml.load(content) as Instance[] | null;
682
-
998
+
683
999
  if (!instances || !Array.isArray(instances)) {
684
1000
  console.error("Invalid instances.yml format");
685
1001
  process.exitCode = 1;
686
1002
  return;
687
1003
  }
688
-
1004
+
689
1005
  const filtered = instances.filter((inst) => inst.name !== name);
690
-
1006
+
691
1007
  if (filtered.length === instances.length) {
692
1008
  console.error(`Monitoring target '${name}' not found`);
693
1009
  process.exitCode = 1;
694
1010
  return;
695
1011
  }
696
-
1012
+
697
1013
  fs.writeFileSync(file, yaml.dump(filtered), "utf8");
698
1014
  console.log(`Monitoring target '${name}' removed`);
699
1015
  } catch (err) {
@@ -712,37 +1028,37 @@ targets
712
1028
  process.exitCode = 1;
713
1029
  return;
714
1030
  }
715
-
1031
+
716
1032
  try {
717
1033
  const content = fs.readFileSync(instancesPath, "utf8");
718
1034
  const instances = yaml.load(content) as Instance[] | null;
719
-
1035
+
720
1036
  if (!instances || !Array.isArray(instances)) {
721
1037
  console.error("Invalid instances.yml format");
722
1038
  process.exitCode = 1;
723
1039
  return;
724
1040
  }
725
-
1041
+
726
1042
  const instance = instances.find((inst) => inst.name === name);
727
-
1043
+
728
1044
  if (!instance) {
729
1045
  console.error(`Monitoring target '${name}' not found`);
730
1046
  process.exitCode = 1;
731
1047
  return;
732
1048
  }
733
-
1049
+
734
1050
  if (!instance.conn_str) {
735
1051
  console.error(`Connection string not found for monitoring target '${name}'`);
736
1052
  process.exitCode = 1;
737
1053
  return;
738
1054
  }
739
-
1055
+
740
1056
  console.log(`Testing connection to monitoring target '${name}'...`);
741
-
1057
+
742
1058
  // Use native pg client instead of requiring psql to be installed
743
1059
  const { Client } = require('pg');
744
1060
  const client = new Client({ connectionString: instance.conn_str });
745
-
1061
+
746
1062
  try {
747
1063
  await client.connect();
748
1064
  const result = await client.query('select version();');
@@ -767,34 +1083,34 @@ program
767
1083
  .action(async (opts: { port?: number; debug?: boolean }) => {
768
1084
  const pkce = require("../lib/pkce");
769
1085
  const authServer = require("../lib/auth-server");
770
-
1086
+
771
1087
  console.log("Starting authentication flow...\n");
772
-
1088
+
773
1089
  // Generate PKCE parameters
774
1090
  const params = pkce.generatePKCEParams();
775
1091
 
776
1092
  const rootOpts = program.opts<CliOptions>();
777
1093
  const cfg = config.readConfig();
778
1094
  const { apiBaseUrl, uiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
779
-
1095
+
780
1096
  if (opts.debug) {
781
1097
  console.log(`Debug: Resolved API base URL: ${apiBaseUrl}`);
782
1098
  console.log(`Debug: Resolved UI base URL: ${uiBaseUrl}`);
783
1099
  }
784
-
1100
+
785
1101
  try {
786
1102
  // Step 1: Start local callback server FIRST to get actual port
787
1103
  console.log("Starting local callback server...");
788
1104
  const requestedPort = opts.port || 0; // 0 = OS assigns available port
789
1105
  const callbackServer = authServer.createCallbackServer(requestedPort, params.state, 120000); // 2 minute timeout
790
-
1106
+
791
1107
  // Wait a bit for server to start and get port
792
1108
  await new Promise(resolve => setTimeout(resolve, 100));
793
1109
  const actualPort = callbackServer.getPort();
794
1110
  const redirectUri = `http://localhost:${actualPort}/callback`;
795
-
1111
+
796
1112
  console.log(`Callback server listening on port ${actualPort}`);
797
-
1113
+
798
1114
  // Step 2: Initialize OAuth session on backend
799
1115
  console.log("Initializing authentication session...");
800
1116
  const initData = JSON.stringify({
@@ -804,15 +1120,15 @@ program
804
1120
  code_challenge_method: params.codeChallengeMethod,
805
1121
  redirect_uri: redirectUri,
806
1122
  });
807
-
1123
+
808
1124
  // Build init URL by appending to the API base path (keep /api/general)
809
1125
  const initUrl = new URL(`${apiBaseUrl}/rpc/oauth_init`);
810
-
1126
+
811
1127
  if (opts.debug) {
812
1128
  console.log(`Debug: Trying to POST to: ${initUrl.toString()}`);
813
1129
  console.log(`Debug: Request data: ${initData}`);
814
1130
  }
815
-
1131
+
816
1132
  const initReq = http.request(
817
1133
  initUrl,
818
1134
  {
@@ -828,7 +1144,7 @@ program
828
1144
  res.on("end", async () => {
829
1145
  if (res.statusCode !== 200) {
830
1146
  console.error(`Failed to initialize auth session: ${res.statusCode}`);
831
-
1147
+
832
1148
  // Check if response is HTML (common for 404 pages)
833
1149
  if (data.trim().startsWith("<!") || data.trim().startsWith("<html")) {
834
1150
  console.error("Error: Received HTML response instead of JSON. This usually means:");
@@ -839,31 +1155,31 @@ program
839
1155
  } else {
840
1156
  console.error(data);
841
1157
  }
842
-
1158
+
843
1159
  callbackServer.server.close();
844
1160
  process.exit(1);
845
1161
  }
846
-
1162
+
847
1163
  // Step 3: Open browser
848
1164
  const authUrl = `${uiBaseUrl}/cli/auth?state=${encodeURIComponent(params.state)}&code_challenge=${encodeURIComponent(params.codeChallenge)}&code_challenge_method=S256&redirect_uri=${encodeURIComponent(redirectUri)}`;
849
-
1165
+
850
1166
  if (opts.debug) {
851
1167
  console.log(`Debug: Auth URL: ${authUrl}`);
852
1168
  }
853
-
1169
+
854
1170
  console.log(`\nOpening browser for authentication...`);
855
1171
  console.log(`If browser does not open automatically, visit:\n${authUrl}\n`);
856
-
1172
+
857
1173
  // Open browser (cross-platform)
858
1174
  const openCommand = process.platform === "darwin" ? "open" :
859
1175
  process.platform === "win32" ? "start" :
860
1176
  "xdg-open";
861
1177
  spawn(openCommand, [authUrl], { detached: true, stdio: "ignore" }).unref();
862
-
1178
+
863
1179
  // Step 4: Wait for callback
864
1180
  console.log("Waiting for authorization...");
865
1181
  console.log("(Press Ctrl+C to cancel)\n");
866
-
1182
+
867
1183
  // Handle Ctrl+C gracefully
868
1184
  const cancelHandler = () => {
869
1185
  console.log("\n\nAuthentication cancelled by user.");
@@ -871,13 +1187,13 @@ program
871
1187
  process.exit(130); // Standard exit code for SIGINT
872
1188
  };
873
1189
  process.on("SIGINT", cancelHandler);
874
-
1190
+
875
1191
  try {
876
1192
  const { code } = await callbackServer.promise;
877
-
1193
+
878
1194
  // Remove the cancel handler after successful auth
879
1195
  process.off("SIGINT", cancelHandler);
880
-
1196
+
881
1197
  // Step 5: Exchange code for token
882
1198
  console.log("\nExchanging authorization code for API token...");
883
1199
  const exchangeData = JSON.stringify({
@@ -901,7 +1217,7 @@ program
901
1217
  exchangeRes.on("end", () => {
902
1218
  if (exchangeRes.statusCode !== 200) {
903
1219
  console.error(`Failed to exchange code for token: ${exchangeRes.statusCode}`);
904
-
1220
+
905
1221
  // Check if response is HTML (common for 404 pages)
906
1222
  if (exchangeBody.trim().startsWith("<!") || exchangeBody.trim().startsWith("<html")) {
907
1223
  console.error("Error: Received HTML response instead of JSON. This usually means:");
@@ -912,23 +1228,23 @@ program
912
1228
  } else {
913
1229
  console.error(exchangeBody);
914
1230
  }
915
-
1231
+
916
1232
  process.exit(1);
917
1233
  return;
918
1234
  }
919
-
1235
+
920
1236
  try {
921
1237
  const result = JSON.parse(exchangeBody);
922
1238
  const apiToken = result.api_token || result?.[0]?.result?.api_token; // There is a bug with PostgREST Caching that may return an array, not single object, it's a workaround to support both cases.
923
1239
  const orgId = result.org_id || result?.[0]?.result?.org_id; // There is a bug with PostgREST Caching that may return an array, not single object, it's a workaround to support both cases.
924
-
1240
+
925
1241
  // Step 6: Save token to config
926
1242
  config.writeConfig({
927
1243
  apiKey: apiToken,
928
1244
  baseUrl: apiBaseUrl,
929
1245
  orgId: orgId,
930
1246
  });
931
-
1247
+
932
1248
  console.log("\nAuthentication successful!");
933
1249
  console.log(`API key saved to: ${config.getConfigPath()}`);
934
1250
  console.log(`Organization ID: ${orgId}`);
@@ -942,21 +1258,21 @@ program
942
1258
  });
943
1259
  }
944
1260
  );
945
-
1261
+
946
1262
  exchangeReq.on("error", (err: Error) => {
947
1263
  console.error(`Exchange request failed: ${err.message}`);
948
1264
  process.exit(1);
949
1265
  });
950
-
1266
+
951
1267
  exchangeReq.write(exchangeData);
952
1268
  exchangeReq.end();
953
-
1269
+
954
1270
  } catch (err) {
955
1271
  // Remove the cancel handler in error case too
956
1272
  process.off("SIGINT", cancelHandler);
957
-
1273
+
958
1274
  const message = err instanceof Error ? err.message : String(err);
959
-
1275
+
960
1276
  // Provide more helpful error messages
961
1277
  if (message.includes("timeout")) {
962
1278
  console.error(`\nAuthentication timed out.`);
@@ -965,22 +1281,22 @@ program
965
1281
  } else {
966
1282
  console.error(`\nAuthentication failed: ${message}`);
967
1283
  }
968
-
1284
+
969
1285
  process.exit(1);
970
1286
  }
971
1287
  });
972
1288
  }
973
1289
  );
974
-
1290
+
975
1291
  initReq.on("error", (err: Error) => {
976
1292
  console.error(`Failed to connect to API: ${err.message}`);
977
1293
  callbackServer.server.close();
978
1294
  process.exit(1);
979
1295
  });
980
-
1296
+
981
1297
  initReq.write(initData);
982
1298
  initReq.end();
983
-
1299
+
984
1300
  } catch (err) {
985
1301
  const message = err instanceof Error ? err.message : String(err);
986
1302
  console.error(`Authentication error: ${message}`);
@@ -1023,17 +1339,17 @@ program
1023
1339
  const hasNewConfig = fs.existsSync(newConfigPath);
1024
1340
  const legacyPath = path.resolve(process.cwd(), ".pgwatch-config");
1025
1341
  const hasLegacyConfig = fs.existsSync(legacyPath) && fs.statSync(legacyPath).isFile();
1026
-
1342
+
1027
1343
  if (!hasNewConfig && !hasLegacyConfig) {
1028
1344
  console.log("No API key configured");
1029
1345
  return;
1030
1346
  }
1031
-
1347
+
1032
1348
  // Remove from new config
1033
1349
  if (hasNewConfig) {
1034
1350
  config.deleteConfigKeys(["apiKey", "orgId"]);
1035
1351
  }
1036
-
1352
+
1037
1353
  // Remove from legacy config
1038
1354
  if (hasLegacyConfig) {
1039
1355
  try {
@@ -1049,7 +1365,7 @@ program
1049
1365
  console.warn(`Warning: Could not update legacy config: ${err instanceof Error ? err.message : String(err)}`);
1050
1366
  }
1051
1367
  }
1052
-
1368
+
1053
1369
  console.log("API key removed");
1054
1370
  console.log(`\nTo authenticate again, run: pgai auth`);
1055
1371
  });
@@ -1058,20 +1374,20 @@ mon
1058
1374
  .description("generate Grafana password for monitoring services")
1059
1375
  .action(async () => {
1060
1376
  const cfgPath = path.resolve(process.cwd(), ".pgwatch-config");
1061
-
1377
+
1062
1378
  try {
1063
1379
  // Generate secure password using openssl
1064
1380
  const { stdout: password } = await execPromise(
1065
1381
  "openssl rand -base64 12 | tr -d '\n'"
1066
1382
  );
1067
1383
  const newPassword = password.trim();
1068
-
1384
+
1069
1385
  if (!newPassword) {
1070
1386
  console.error("Failed to generate password");
1071
1387
  process.exitCode = 1;
1072
1388
  return;
1073
1389
  }
1074
-
1390
+
1075
1391
  // Read existing config
1076
1392
  let configContent = "";
1077
1393
  if (fs.existsSync(cfgPath)) {
@@ -1082,14 +1398,14 @@ mon
1082
1398
  configContent = fs.readFileSync(cfgPath, "utf8");
1083
1399
  }
1084
1400
  }
1085
-
1401
+
1086
1402
  // Update or add grafana_password
1087
1403
  const lines = configContent.split(/\r?\n/).filter((l) => !/^grafana_password=/.test(l));
1088
1404
  lines.push(`grafana_password=${newPassword}`);
1089
-
1405
+
1090
1406
  // Write back
1091
1407
  fs.writeFileSync(cfgPath, lines.filter(Boolean).join("\n") + "\n", "utf8");
1092
-
1408
+
1093
1409
  console.log("✓ New Grafana password generated and saved");
1094
1410
  console.log("\nNew credentials:");
1095
1411
  console.log(" URL: http://localhost:3000");
@@ -1114,14 +1430,14 @@ mon
1114
1430
  process.exitCode = 1;
1115
1431
  return;
1116
1432
  }
1117
-
1433
+
1118
1434
  const stats = fs.statSync(cfgPath);
1119
1435
  if (stats.isDirectory()) {
1120
1436
  console.error(".pgwatch-config is a directory, expected a file. Cannot read credentials.");
1121
1437
  process.exitCode = 1;
1122
1438
  return;
1123
1439
  }
1124
-
1440
+
1125
1441
  const content = fs.readFileSync(cfgPath, "utf8");
1126
1442
  const lines = content.split(/\r?\n/);
1127
1443
  let password = "";
@@ -1144,6 +1460,24 @@ mon
1144
1460
  console.log("");
1145
1461
  });
1146
1462
 
1463
+ /**
1464
+ * Interpret escape sequences in a string (e.g., \n -> newline)
1465
+ * Note: In regex, to match literal backslash-n, we need \\n in the pattern
1466
+ * which requires \\\\n in the JavaScript string literal
1467
+ */
1468
+ function interpretEscapes(str: string): string {
1469
+ // First handle double backslashes by temporarily replacing them
1470
+ // Then handle other escapes, then restore double backslashes as single
1471
+ return str
1472
+ .replace(/\\\\/g, '\x00') // Temporarily mark double backslashes
1473
+ .replace(/\\n/g, '\n') // Match literal backslash-n (\\\\n in JS string -> \\n in regex -> matches \n)
1474
+ .replace(/\\t/g, '\t')
1475
+ .replace(/\\r/g, '\r')
1476
+ .replace(/\\"/g, '"')
1477
+ .replace(/\\'/g, "'")
1478
+ .replace(/\x00/g, '\\'); // Restore double backslashes as single
1479
+ }
1480
+
1147
1481
  // Issues management
1148
1482
  const issues = program.command("issues").description("issues management");
1149
1483
 
@@ -1151,7 +1485,8 @@ issues
1151
1485
  .command("list")
1152
1486
  .description("list issues")
1153
1487
  .option("--debug", "enable debug output")
1154
- .action(async (opts: { debug?: boolean }) => {
1488
+ .option("--json", "output raw JSON")
1489
+ .action(async (opts: { debug?: boolean; json?: boolean }) => {
1155
1490
  try {
1156
1491
  const rootOpts = program.opts<CliOptions>();
1157
1492
  const cfg = config.readConfig();
@@ -1165,12 +1500,96 @@ issues
1165
1500
  const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
1166
1501
 
1167
1502
  const result = await fetchIssues({ apiKey, apiBaseUrl, debug: !!opts.debug });
1168
- if (typeof result === "string") {
1169
- process.stdout.write(result);
1170
- if (!/\n$/.test(result)) console.log();
1171
- } else {
1172
- console.log(JSON.stringify(result, null, 2));
1503
+ const trimmed = Array.isArray(result)
1504
+ ? (result as any[]).map((r) => ({
1505
+ id: (r as any).id,
1506
+ title: (r as any).title,
1507
+ status: (r as any).status,
1508
+ created_at: (r as any).created_at,
1509
+ }))
1510
+ : result;
1511
+ printResult(trimmed, opts.json);
1512
+ } catch (err) {
1513
+ const message = err instanceof Error ? err.message : String(err);
1514
+ console.error(message);
1515
+ process.exitCode = 1;
1516
+ }
1517
+ });
1518
+
1519
+ issues
1520
+ .command("view <issueId>")
1521
+ .description("view issue details and comments")
1522
+ .option("--debug", "enable debug output")
1523
+ .option("--json", "output raw JSON")
1524
+ .action(async (issueId: string, opts: { debug?: boolean; json?: boolean }) => {
1525
+ try {
1526
+ const rootOpts = program.opts<CliOptions>();
1527
+ const cfg = config.readConfig();
1528
+ const { apiKey } = getConfig(rootOpts);
1529
+ if (!apiKey) {
1530
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
1531
+ process.exitCode = 1;
1532
+ return;
1173
1533
  }
1534
+
1535
+ const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
1536
+
1537
+ const issue = await fetchIssue({ apiKey, apiBaseUrl, issueId, debug: !!opts.debug });
1538
+ if (!issue) {
1539
+ console.error("Issue not found");
1540
+ process.exitCode = 1;
1541
+ return;
1542
+ }
1543
+
1544
+ const comments = await fetchIssueComments({ apiKey, apiBaseUrl, issueId, debug: !!opts.debug });
1545
+ const combined = { issue, comments };
1546
+ printResult(combined, opts.json);
1547
+ } catch (err) {
1548
+ const message = err instanceof Error ? err.message : String(err);
1549
+ console.error(message);
1550
+ process.exitCode = 1;
1551
+ }
1552
+ });
1553
+
1554
+ issues
1555
+ .command("post_comment <issueId> <content>")
1556
+ .description("post a new comment to an issue")
1557
+ .option("--parent <uuid>", "parent comment id")
1558
+ .option("--debug", "enable debug output")
1559
+ .option("--json", "output raw JSON")
1560
+ .action(async (issueId: string, content: string, opts: { parent?: string; debug?: boolean; json?: boolean }) => {
1561
+ try {
1562
+ // Interpret escape sequences in content (e.g., \n -> newline)
1563
+ if (opts.debug) {
1564
+ // eslint-disable-next-line no-console
1565
+ console.log(`Debug: Original content: ${JSON.stringify(content)}`);
1566
+ }
1567
+ content = interpretEscapes(content);
1568
+ if (opts.debug) {
1569
+ // eslint-disable-next-line no-console
1570
+ console.log(`Debug: Interpreted content: ${JSON.stringify(content)}`);
1571
+ }
1572
+
1573
+ const rootOpts = program.opts<CliOptions>();
1574
+ const cfg = config.readConfig();
1575
+ const { apiKey } = getConfig(rootOpts);
1576
+ if (!apiKey) {
1577
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
1578
+ process.exitCode = 1;
1579
+ return;
1580
+ }
1581
+
1582
+ const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
1583
+
1584
+ const result = await createIssueComment({
1585
+ apiKey,
1586
+ apiBaseUrl,
1587
+ issueId,
1588
+ content,
1589
+ parentCommentId: opts.parent,
1590
+ debug: !!opts.debug,
1591
+ });
1592
+ printResult(result, opts.json);
1174
1593
  } catch (err) {
1175
1594
  const message = err instanceof Error ? err.message : String(err);
1176
1595
  console.error(message);
@@ -1195,7 +1614,7 @@ mcp
1195
1614
  .description("install MCP server configuration for AI coding tool")
1196
1615
  .action(async (client?: string) => {
1197
1616
  const supportedClients = ["cursor", "claude-code", "windsurf", "codex"];
1198
-
1617
+
1199
1618
  // If no client specified, prompt user to choose
1200
1619
  if (!client) {
1201
1620
  console.log("Available AI coding tools:");
@@ -1204,24 +1623,24 @@ mcp
1204
1623
  console.log(" 3. Windsurf");
1205
1624
  console.log(" 4. Codex");
1206
1625
  console.log("");
1207
-
1626
+
1208
1627
  const rl = readline.createInterface({
1209
1628
  input: process.stdin,
1210
1629
  output: process.stdout
1211
1630
  });
1212
-
1631
+
1213
1632
  const answer = await new Promise<string>((resolve) => {
1214
1633
  rl.question("Select your AI coding tool (1-4): ", resolve);
1215
1634
  });
1216
1635
  rl.close();
1217
-
1636
+
1218
1637
  const choices: Record<string, string> = {
1219
1638
  "1": "cursor",
1220
1639
  "2": "claude-code",
1221
1640
  "3": "windsurf",
1222
1641
  "4": "codex"
1223
1642
  };
1224
-
1643
+
1225
1644
  client = choices[answer.trim()];
1226
1645
  if (!client) {
1227
1646
  console.error("Invalid selection");
@@ -1229,54 +1648,90 @@ mcp
1229
1648
  return;
1230
1649
  }
1231
1650
  }
1232
-
1651
+
1233
1652
  client = client.toLowerCase();
1234
-
1653
+
1235
1654
  if (!supportedClients.includes(client)) {
1236
1655
  console.error(`Unsupported client: ${client}`);
1237
1656
  console.error(`Supported clients: ${supportedClients.join(", ")}`);
1238
1657
  process.exitCode = 1;
1239
1658
  return;
1240
1659
  }
1241
-
1660
+
1242
1661
  try {
1662
+ // Get the path to the current pgai executable
1663
+ let pgaiPath: string;
1664
+ try {
1665
+ const execPath = await execPromise("which pgai");
1666
+ pgaiPath = execPath.stdout.trim();
1667
+ } catch {
1668
+ // Fallback to just "pgai" if which fails
1669
+ pgaiPath = "pgai";
1670
+ }
1671
+
1672
+ // Claude Code uses its own CLI to manage MCP servers
1673
+ if (client === "claude-code") {
1674
+ console.log("Installing PostgresAI MCP server for Claude Code...");
1675
+
1676
+ try {
1677
+ const { stdout, stderr } = await execPromise(
1678
+ `claude mcp add -s user postgresai ${pgaiPath} mcp start`
1679
+ );
1680
+
1681
+ if (stdout) console.log(stdout);
1682
+ if (stderr) console.error(stderr);
1683
+
1684
+ console.log("");
1685
+ console.log("Successfully installed PostgresAI MCP server for Claude Code");
1686
+ console.log("");
1687
+ console.log("Next steps:");
1688
+ console.log(" 1. Restart Claude Code to load the new configuration");
1689
+ console.log(" 2. The PostgresAI MCP server will be available as 'postgresai'");
1690
+ } catch (err) {
1691
+ const message = err instanceof Error ? err.message : String(err);
1692
+ console.error("Failed to install MCP server using Claude CLI");
1693
+ console.error(message);
1694
+ console.error("");
1695
+ console.error("Make sure the 'claude' CLI tool is installed and in your PATH");
1696
+ console.error("See: https://docs.anthropic.com/en/docs/build-with-claude/mcp");
1697
+ process.exitCode = 1;
1698
+ }
1699
+ return;
1700
+ }
1701
+
1702
+ // For other clients (Cursor, Windsurf, Codex), use JSON config editing
1243
1703
  const homeDir = os.homedir();
1244
1704
  let configPath: string;
1245
1705
  let configDir: string;
1246
-
1706
+
1247
1707
  // Determine config file location based on client
1248
1708
  switch (client) {
1249
1709
  case "cursor":
1250
1710
  configPath = path.join(homeDir, ".cursor", "mcp.json");
1251
1711
  configDir = path.dirname(configPath);
1252
1712
  break;
1253
-
1254
- case "claude-code":
1255
- configPath = path.join(homeDir, ".claude-code", "mcp.json");
1256
- configDir = path.dirname(configPath);
1257
- break;
1258
-
1713
+
1259
1714
  case "windsurf":
1260
1715
  configPath = path.join(homeDir, ".windsurf", "mcp.json");
1261
1716
  configDir = path.dirname(configPath);
1262
1717
  break;
1263
-
1718
+
1264
1719
  case "codex":
1265
1720
  configPath = path.join(homeDir, ".codex", "mcp.json");
1266
1721
  configDir = path.dirname(configPath);
1267
1722
  break;
1268
-
1723
+
1269
1724
  default:
1270
1725
  console.error(`Configuration not implemented for: ${client}`);
1271
1726
  process.exitCode = 1;
1272
1727
  return;
1273
1728
  }
1274
-
1729
+
1275
1730
  // Ensure config directory exists
1276
1731
  if (!fs.existsSync(configDir)) {
1277
1732
  fs.mkdirSync(configDir, { recursive: true });
1278
1733
  }
1279
-
1734
+
1280
1735
  // Read existing config or create new one
1281
1736
  let config: any = { mcpServers: {} };
1282
1737
  if (fs.existsSync(configPath)) {
@@ -1290,21 +1745,21 @@ mcp
1290
1745
  console.error(`Warning: Could not parse existing config, creating new one`);
1291
1746
  }
1292
1747
  }
1293
-
1748
+
1294
1749
  // Add or update PostgresAI MCP server configuration
1295
1750
  config.mcpServers.postgresai = {
1296
- command: "pgai",
1751
+ command: pgaiPath,
1297
1752
  args: ["mcp", "start"]
1298
1753
  };
1299
-
1754
+
1300
1755
  // Write updated config
1301
1756
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf8");
1302
-
1757
+
1303
1758
  console.log(`✓ PostgresAI MCP server configured for ${client}`);
1304
1759
  console.log(` Config file: ${configPath}`);
1305
1760
  console.log("");
1306
1761
  console.log("Please restart your AI coding tool to activate the MCP server");
1307
-
1762
+
1308
1763
  } catch (error) {
1309
1764
  const message = error instanceof Error ? error.message : String(error);
1310
1765
  console.error(`Failed to install MCP server: ${message}`);