postgresai 0.12.0-beta.6 → 0.14.0-dev.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,8 +13,9 @@ 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
+ import { applyInitPlan, buildInitPlan, resolveAdminConnection, resolveMonitoringPassword } from "../lib/init";
18
19
 
19
20
  const execPromise = promisify(exec);
20
21
  const execFilePromise = promisify(execFile);
@@ -59,14 +60,6 @@ interface PathResolution {
59
60
  instancesFile: string;
60
61
  }
61
62
 
62
- /**
63
- * Health check service
64
- */
65
- interface HealthService {
66
- name: string;
67
- url: string;
68
- }
69
-
70
63
  /**
71
64
  * Get configuration from various sources
72
65
  * @param opts - Command line options
@@ -90,6 +83,24 @@ function getConfig(opts: CliOptions): ConfigResult {
90
83
  return { apiKey };
91
84
  }
92
85
 
86
+ // Human-friendly output helper: YAML for TTY by default, JSON when --json or non-TTY
87
+ function printResult(result: unknown, json?: boolean): void {
88
+ if (typeof result === "string") {
89
+ process.stdout.write(result);
90
+ if (!/\n$/.test(result)) console.log();
91
+ return;
92
+ }
93
+ if (json || !process.stdout.isTTY) {
94
+ console.log(JSON.stringify(result, null, 2));
95
+ } else {
96
+ let text = yaml.dump(result as any);
97
+ if (Array.isArray(result)) {
98
+ text = text.replace(/\n- /g, "\n\n- ");
99
+ }
100
+ console.log(text);
101
+ }
102
+ }
103
+
93
104
  const program = new Command();
94
105
 
95
106
  program
@@ -106,6 +117,124 @@ program
106
117
  "UI base URL for browser routes (overrides PGAI_UI_BASE_URL)"
107
118
  );
108
119
 
120
+ program
121
+ .command("init [conn]")
122
+ .description("Create a monitoring user and grant all required permissions (idempotent)")
123
+ .option("--db-url <url>", "PostgreSQL connection URL (admin) to run the setup against (deprecated; pass it as positional arg)")
124
+ .option("-h, --host <host>", "PostgreSQL host (psql-like)")
125
+ .option("-p, --port <port>", "PostgreSQL port (psql-like)")
126
+ .option("-U, --username <username>", "PostgreSQL user (psql-like)")
127
+ .option("-d, --dbname <dbname>", "PostgreSQL database name (psql-like)")
128
+ .option("--admin-password <password>", "Admin connection password (otherwise uses PGPASSWORD if set)")
129
+ .option("--monitoring-user <name>", "Monitoring role name to create/update", "postgres_ai_mon")
130
+ .option("--password <password>", "Monitoring role password (overrides PGAI_MON_PASSWORD)")
131
+ .option("--skip-optional-permissions", "Skip optional permissions (RDS/self-managed extras)", false)
132
+ .action(async (conn: string | undefined, opts: {
133
+ dbUrl?: string;
134
+ host?: string;
135
+ port?: string;
136
+ username?: string;
137
+ dbname?: string;
138
+ adminPassword?: string;
139
+ monitoringUser: string;
140
+ password?: string;
141
+ skipOptionalPermissions?: boolean;
142
+ }) => {
143
+ let adminConn;
144
+ try {
145
+ adminConn = resolveAdminConnection({
146
+ conn,
147
+ dbUrlFlag: opts.dbUrl,
148
+ host: opts.host,
149
+ port: opts.port,
150
+ username: opts.username,
151
+ dbname: opts.dbname,
152
+ adminPassword: opts.adminPassword,
153
+ envPassword: process.env.PGPASSWORD,
154
+ });
155
+ } catch (e) {
156
+ const msg = e instanceof Error ? e.message : String(e);
157
+ console.error(`✗ ${msg}`);
158
+ process.exitCode = 1;
159
+ return;
160
+ }
161
+
162
+ let monPassword: string;
163
+ try {
164
+ monPassword = await resolveMonitoringPassword({
165
+ passwordFlag: opts.password,
166
+ passwordEnv: process.env.PGAI_MON_PASSWORD,
167
+ monitoringUser: opts.monitoringUser,
168
+ });
169
+ } catch (e) {
170
+ const msg = e instanceof Error ? e.message : String(e);
171
+ console.error(`✗ ${msg}`);
172
+ process.exitCode = 1;
173
+ return;
174
+ }
175
+
176
+ const includeOptionalPermissions = !opts.skipOptionalPermissions;
177
+
178
+ console.log(`Connecting to: ${adminConn.display}`);
179
+ console.log(`Monitoring user: ${opts.monitoringUser}`);
180
+ console.log(`Optional permissions: ${includeOptionalPermissions ? "enabled" : "skipped"}`);
181
+
182
+ // Use native pg client instead of requiring psql to be installed
183
+ const { Client } = require("pg");
184
+ const client = new Client(adminConn.clientConfig);
185
+
186
+ try {
187
+ await client.connect();
188
+
189
+ const roleRes = await client.query("select 1 from pg_catalog.pg_roles where rolname = $1", [
190
+ opts.monitoringUser,
191
+ ]);
192
+ const roleExists = roleRes.rowCount > 0;
193
+
194
+ const dbRes = await client.query("select current_database() as db");
195
+ const database = dbRes.rows?.[0]?.db;
196
+ if (typeof database !== "string" || !database) {
197
+ throw new Error("Failed to resolve current database name");
198
+ }
199
+
200
+ const plan = await buildInitPlan({
201
+ database,
202
+ monitoringUser: opts.monitoringUser,
203
+ monitoringPassword: monPassword,
204
+ includeOptionalPermissions,
205
+ roleExists,
206
+ });
207
+
208
+ const { applied, skippedOptional } = await applyInitPlan({ client, plan });
209
+
210
+ console.log("✓ init completed");
211
+ if (skippedOptional.length > 0) {
212
+ console.log("⚠ Some optional steps were skipped (not supported or insufficient privileges):");
213
+ for (const s of skippedOptional) console.log(`- ${s}`);
214
+ }
215
+ // Keep output compact but still useful
216
+ if (process.stdout.isTTY) {
217
+ console.log(`Applied ${applied.length} steps`);
218
+ }
219
+ } catch (error) {
220
+ const errAny = error as any;
221
+ const message = error instanceof Error ? error.message : String(error);
222
+ console.error(`✗ init failed: ${message}`);
223
+ if (errAny && typeof errAny === "object" && typeof errAny.code === "string") {
224
+ if (errAny.code === "42501") {
225
+ console.error("Hint: connect as a superuser (or a role with CREATEROLE and sufficient GRANT privileges).");
226
+ }
227
+ }
228
+ process.exitCode = 1;
229
+ } finally {
230
+ try {
231
+ await client.end();
232
+ } catch {
233
+ // ignore
234
+ }
235
+ }
236
+ });
237
+
109
238
  /**
110
239
  * Stub function for not implemented commands
111
240
  */
@@ -249,23 +378,308 @@ const mon = program.command("mon").description("monitoring services management")
249
378
  mon
250
379
  .command("quickstart")
251
380
  .description("complete setup (generate config, start monitoring services)")
252
- .option("--demo", "demo mode", false)
253
- .action(async () => {
381
+ .option("--demo", "demo mode with sample database", false)
382
+ .option("--api-key <key>", "Postgres AI API key for automated report uploads")
383
+ .option("--db-url <url>", "PostgreSQL connection URL to monitor")
384
+ .option("-y, --yes", "accept all defaults and skip interactive prompts", false)
385
+ .action(async (opts: { demo: boolean; apiKey?: string; dbUrl?: string; yes: boolean }) => {
386
+ console.log("\n=================================");
387
+ console.log(" PostgresAI Monitoring Quickstart");
388
+ console.log("=================================\n");
389
+ console.log("This will install, configure, and start the monitoring system\n");
390
+
391
+ // Validate conflicting options
392
+ if (opts.demo && opts.dbUrl) {
393
+ console.log("⚠ Both --demo and --db-url provided. Demo mode includes its own database.");
394
+ console.log("⚠ The --db-url will be ignored in demo mode.\n");
395
+ opts.dbUrl = undefined;
396
+ }
397
+
398
+ if (opts.demo && opts.apiKey) {
399
+ console.error("✗ Cannot use --api-key with --demo mode");
400
+ console.error("✗ Demo mode is for testing only and does not support API key integration");
401
+ console.error("\nUse demo mode without API key: postgres-ai mon quickstart --demo");
402
+ console.error("Or use production mode with API key: postgres-ai mon quickstart --api-key=your_key");
403
+ process.exitCode = 1;
404
+ return;
405
+ }
406
+
254
407
  // Check if containers are already running
255
408
  const { running, containers } = checkRunningContainers();
256
409
  if (running) {
257
- console.log(`Monitoring services are already running: ${containers.join(", ")}`);
258
- console.log("Use 'postgres-ai mon restart' to restart them");
410
+ console.log(`⚠ Monitoring services are already running: ${containers.join(", ")}`);
411
+ console.log("Use 'postgres-ai mon restart' to restart them\n");
259
412
  return;
260
413
  }
261
414
 
415
+ // Step 1: API key configuration (only in production mode)
416
+ if (!opts.demo) {
417
+ console.log("Step 1: Postgres AI API Configuration (Optional)");
418
+ console.log("An API key enables automatic upload of PostgreSQL reports to Postgres AI\n");
419
+
420
+ if (opts.apiKey) {
421
+ console.log("Using API key provided via --api-key parameter");
422
+ config.writeConfig({ apiKey: opts.apiKey });
423
+ console.log("✓ API key saved\n");
424
+ } else if (opts.yes) {
425
+ // Auto-yes mode without API key - skip API key setup
426
+ console.log("Auto-yes mode: no API key provided, skipping API key setup");
427
+ console.log("⚠ Reports will be generated locally only");
428
+ console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
429
+ } else {
430
+ const rl = readline.createInterface({
431
+ input: process.stdin,
432
+ output: process.stdout
433
+ });
434
+
435
+ const question = (prompt: string): Promise<string> =>
436
+ new Promise((resolve) => rl.question(prompt, resolve));
437
+
438
+ try {
439
+ const answer = await question("Do you have a Postgres AI API key? (Y/n): ");
440
+ const proceedWithApiKey = !answer || answer.toLowerCase() === "y";
441
+
442
+ if (proceedWithApiKey) {
443
+ while (true) {
444
+ const inputApiKey = await question("Enter your Postgres AI API key: ");
445
+ const trimmedKey = inputApiKey.trim();
446
+
447
+ if (trimmedKey) {
448
+ config.writeConfig({ apiKey: trimmedKey });
449
+ console.log("✓ API key saved\n");
450
+ break;
451
+ }
452
+
453
+ console.log("⚠ API key cannot be empty");
454
+ const retry = await question("Try again or skip API key setup, retry? (Y/n): ");
455
+ if (retry.toLowerCase() === "n") {
456
+ console.log("⚠ Skipping API key setup - reports will be generated locally only");
457
+ console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
458
+ break;
459
+ }
460
+ }
461
+ } else {
462
+ console.log("⚠ Skipping API key setup - reports will be generated locally only");
463
+ console.log("You can add an API key later with: postgres-ai add-key <api_key>\n");
464
+ }
465
+ } finally {
466
+ rl.close();
467
+ }
468
+ }
469
+ } else {
470
+ console.log("Step 1: Demo mode - API key configuration skipped");
471
+ console.log("Demo mode is for testing only and does not support API key integration\n");
472
+ }
473
+
474
+ // Step 2: Add PostgreSQL instance (if not demo mode)
475
+ if (!opts.demo) {
476
+ console.log("Step 2: Add PostgreSQL Instance to Monitor\n");
477
+
478
+ // Clear instances.yml in production mode (start fresh)
479
+ const instancesPath = path.resolve(process.cwd(), "instances.yml");
480
+ const emptyInstancesContent = "# PostgreSQL instances to monitor\n# Add your instances using: postgres-ai mon targets add\n\n";
481
+ fs.writeFileSync(instancesPath, emptyInstancesContent, "utf8");
482
+
483
+ if (opts.dbUrl) {
484
+ console.log("Using database URL provided via --db-url parameter");
485
+ console.log(`Adding PostgreSQL instance from: ${opts.dbUrl}\n`);
486
+
487
+ const match = opts.dbUrl.match(/^postgresql:\/\/[^@]+@([^:/]+)/);
488
+ const autoInstanceName = match ? match[1] : "db-instance";
489
+
490
+ const connStr = opts.dbUrl;
491
+ const m = connStr.match(/^postgresql:\/\/([^:]+):([^@]+)@([^:\/]+)(?::(\d+))?\/(.+)$/);
492
+
493
+ if (!m) {
494
+ console.error("✗ Invalid connection string format");
495
+ process.exitCode = 1;
496
+ return;
497
+ }
498
+
499
+ const host = m[3];
500
+ const db = m[5];
501
+ const instanceName = `${host}-${db}`.replace(/[^a-zA-Z0-9-]/g, "-");
502
+
503
+ 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`;
504
+ fs.appendFileSync(instancesPath, body, "utf8");
505
+ console.log(`✓ Monitoring target '${instanceName}' added\n`);
506
+
507
+ // Test connection
508
+ console.log("Testing connection to the added instance...");
509
+ try {
510
+ const { Client } = require("pg");
511
+ const client = new Client({ connectionString: connStr });
512
+ await client.connect();
513
+ const result = await client.query("select version();");
514
+ console.log("✓ Connection successful");
515
+ console.log(`${result.rows[0].version}\n`);
516
+ await client.end();
517
+ } catch (error) {
518
+ const message = error instanceof Error ? error.message : String(error);
519
+ console.error(`✗ Connection failed: ${message}\n`);
520
+ }
521
+ } else if (opts.yes) {
522
+ // Auto-yes mode without database URL - skip database setup
523
+ console.log("Auto-yes mode: no database URL provided, skipping database setup");
524
+ console.log("⚠ No PostgreSQL instance added");
525
+ console.log("You can add one later with: postgres-ai mon targets add\n");
526
+ } else {
527
+ const rl = readline.createInterface({
528
+ input: process.stdin,
529
+ output: process.stdout
530
+ });
531
+
532
+ const question = (prompt: string): Promise<string> =>
533
+ new Promise((resolve) => rl.question(prompt, resolve));
534
+
535
+ try {
536
+ console.log("You need to add at least one PostgreSQL instance to monitor");
537
+ const answer = await question("Do you want to add a PostgreSQL instance now? (Y/n): ");
538
+ const proceedWithInstance = !answer || answer.toLowerCase() === "y";
539
+
540
+ if (proceedWithInstance) {
541
+ console.log("\nYou can provide either:");
542
+ console.log(" 1. A full connection string: postgresql://user:pass@host:port/database");
543
+ console.log(" 2. Press Enter to skip for now\n");
544
+
545
+ const connStr = await question("Enter connection string (or press Enter to skip): ");
546
+
547
+ if (connStr.trim()) {
548
+ const m = connStr.match(/^postgresql:\/\/([^:]+):([^@]+)@([^:\/]+)(?::(\d+))?\/(.+)$/);
549
+ if (!m) {
550
+ console.error("✗ Invalid connection string format");
551
+ console.log("⚠ Continuing without adding instance\n");
552
+ } else {
553
+ const host = m[3];
554
+ const db = m[5];
555
+ const instanceName = `${host}-${db}`.replace(/[^a-zA-Z0-9-]/g, "-");
556
+
557
+ 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`;
558
+ fs.appendFileSync(instancesPath, body, "utf8");
559
+ console.log(`✓ Monitoring target '${instanceName}' added\n`);
560
+
561
+ // Test connection
562
+ console.log("Testing connection to the added instance...");
563
+ try {
564
+ const { Client } = require("pg");
565
+ const client = new Client({ connectionString: connStr });
566
+ await client.connect();
567
+ const result = await client.query("select version();");
568
+ console.log("✓ Connection successful");
569
+ console.log(`${result.rows[0].version}\n`);
570
+ await client.end();
571
+ } catch (error) {
572
+ const message = error instanceof Error ? error.message : String(error);
573
+ console.error(`✗ Connection failed: ${message}\n`);
574
+ }
575
+ }
576
+ } else {
577
+ console.log("⚠ No PostgreSQL instance added - you can add one later with: postgres-ai mon targets add\n");
578
+ }
579
+ } else {
580
+ console.log("⚠ No PostgreSQL instance added - you can add one later with: postgres-ai mon targets add\n");
581
+ }
582
+ } finally {
583
+ rl.close();
584
+ }
585
+ }
586
+ } else {
587
+ console.log("Step 2: Demo mode enabled - using included demo PostgreSQL database\n");
588
+ }
589
+
590
+ // Step 3: Update configuration
591
+ console.log(opts.demo ? "Step 3: Updating configuration..." : "Step 3: Updating configuration...");
262
592
  const code1 = await runCompose(["run", "--rm", "sources-generator"]);
263
593
  if (code1 !== 0) {
264
594
  process.exitCode = code1;
265
595
  return;
266
596
  }
267
- const code2 = await runCompose(["up", "-d"]);
268
- if (code2 !== 0) process.exitCode = code2;
597
+ console.log(" Configuration updated\n");
598
+
599
+ // Step 4: Ensure Grafana password is configured
600
+ console.log(opts.demo ? "Step 4: Configuring Grafana security..." : "Step 4: Configuring Grafana security...");
601
+ const cfgPath = path.resolve(process.cwd(), ".pgwatch-config");
602
+ let grafanaPassword = "";
603
+
604
+ try {
605
+ if (fs.existsSync(cfgPath)) {
606
+ const stats = fs.statSync(cfgPath);
607
+ if (!stats.isDirectory()) {
608
+ const content = fs.readFileSync(cfgPath, "utf8");
609
+ const match = content.match(/^grafana_password=([^\r\n]+)/m);
610
+ if (match) {
611
+ grafanaPassword = match[1].trim();
612
+ }
613
+ }
614
+ }
615
+
616
+ if (!grafanaPassword) {
617
+ console.log("Generating secure Grafana password...");
618
+ const { stdout: password } = await execPromise("openssl rand -base64 12 | tr -d '\n'");
619
+ grafanaPassword = password.trim();
620
+
621
+ let configContent = "";
622
+ if (fs.existsSync(cfgPath)) {
623
+ const stats = fs.statSync(cfgPath);
624
+ if (!stats.isDirectory()) {
625
+ configContent = fs.readFileSync(cfgPath, "utf8");
626
+ }
627
+ }
628
+
629
+ const lines = configContent.split(/\r?\n/).filter((l) => !/^grafana_password=/.test(l));
630
+ lines.push(`grafana_password=${grafanaPassword}`);
631
+ fs.writeFileSync(cfgPath, lines.filter(Boolean).join("\n") + "\n", "utf8");
632
+ }
633
+
634
+ console.log("✓ Grafana password configured\n");
635
+ } catch (error) {
636
+ console.log("⚠ Could not generate Grafana password automatically");
637
+ console.log("Using default password: demo\n");
638
+ grafanaPassword = "demo";
639
+ }
640
+
641
+ // Step 5: Start services
642
+ console.log(opts.demo ? "Step 5: Starting monitoring services..." : "Step 5: Starting monitoring services...");
643
+ const code2 = await runCompose(["up", "-d", "--force-recreate"]);
644
+ if (code2 !== 0) {
645
+ process.exitCode = code2;
646
+ return;
647
+ }
648
+ console.log("✓ Services started\n");
649
+
650
+ // Final summary
651
+ console.log("=================================");
652
+ console.log(" 🎉 Quickstart setup completed!");
653
+ console.log("=================================\n");
654
+
655
+ console.log("What's running:");
656
+ if (opts.demo) {
657
+ console.log(" ✅ Demo PostgreSQL database (monitoring target)");
658
+ }
659
+ console.log(" ✅ PostgreSQL monitoring infrastructure");
660
+ console.log(" ✅ Grafana dashboards (with secure password)");
661
+ console.log(" ✅ Prometheus metrics storage");
662
+ console.log(" ✅ Flask API backend");
663
+ console.log(" ✅ Automated report generation (every 24h)");
664
+ console.log(" ✅ Host stats monitoring (CPU, memory, disk, I/O)\n");
665
+
666
+ if (!opts.demo) {
667
+ console.log("Next steps:");
668
+ console.log(" • Add more PostgreSQL instances: postgres-ai mon targets add");
669
+ console.log(" • View configured instances: postgres-ai mon targets list");
670
+ console.log(" • Check service health: postgres-ai mon health\n");
671
+ } else {
672
+ console.log("Demo mode next steps:");
673
+ console.log(" • Explore Grafana dashboards at http://localhost:3000");
674
+ console.log(" • Connect to demo database: postgresql://postgres:postgres@localhost:55432/target_database");
675
+ console.log(" • Generate some load on the demo database to see metrics\n");
676
+ }
677
+
678
+ console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
679
+ console.log("🚀 MAIN ACCESS POINT - Start here:");
680
+ console.log(" Grafana Dashboard: http://localhost:3000");
681
+ console.log(` Login: monitor / ${grafanaPassword}`);
682
+ console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
269
683
  });
270
684
 
271
685
  mon
@@ -328,11 +742,13 @@ mon
328
742
  .description("health check for monitoring services")
329
743
  .option("--wait <seconds>", "wait time in seconds for services to become healthy", parseInt, 0)
330
744
  .action(async (opts: { wait: number }) => {
331
- const services: HealthService[] = [
332
- { name: "Grafana", url: "http://localhost:3000/api/health" },
333
- { name: "Prometheus", url: "http://localhost:59090/-/healthy" },
334
- { name: "PGWatch (Postgres)", url: "http://localhost:58080/health" },
335
- { name: "PGWatch (Prometheus)", url: "http://localhost:58089/health" },
745
+ const services = [
746
+ { name: "Grafana", container: "grafana-with-datasources" },
747
+ { name: "Prometheus", container: "sink-prometheus" },
748
+ { name: "PGWatch (Postgres)", container: "pgwatch-postgres" },
749
+ { name: "PGWatch (Prometheus)", container: "pgwatch-prometheus" },
750
+ { name: "Target DB", container: "target-db" },
751
+ { name: "Sink Postgres", container: "sink-postgres" },
336
752
  ];
337
753
 
338
754
  const waitTime = opts.wait || 0;
@@ -350,20 +766,16 @@ mon
350
766
  allHealthy = true;
351
767
  for (const service of services) {
352
768
  try {
353
- // Use native fetch instead of requiring curl to be installed
354
- const controller = new AbortController();
355
- const timeoutId = setTimeout(() => controller.abort(), 5000);
769
+ const { execSync } = require("child_process");
770
+ const status = execSync(`docker inspect -f '{{.State.Status}}' ${service.container} 2>/dev/null`, {
771
+ encoding: 'utf8',
772
+ stdio: ['pipe', 'pipe', 'pipe']
773
+ }).trim();
356
774
 
357
- const response = await fetch(service.url, {
358
- signal: controller.signal,
359
- method: 'GET',
360
- });
361
- clearTimeout(timeoutId);
362
-
363
- if (response.status === 200) {
775
+ if (status === 'running') {
364
776
  console.log(`✓ ${service.name}: healthy`);
365
777
  } else {
366
- console.log(`✗ ${service.name}: unhealthy (HTTP ${response.status})`);
778
+ console.log(`✗ ${service.name}: unhealthy (status: ${status})`);
367
779
  allHealthy = false;
368
780
  }
369
781
  } catch (error) {
@@ -1167,6 +1579,24 @@ mon
1167
1579
  console.log("");
1168
1580
  });
1169
1581
 
1582
+ /**
1583
+ * Interpret escape sequences in a string (e.g., \n -> newline)
1584
+ * Note: In regex, to match literal backslash-n, we need \\n in the pattern
1585
+ * which requires \\\\n in the JavaScript string literal
1586
+ */
1587
+ function interpretEscapes(str: string): string {
1588
+ // First handle double backslashes by temporarily replacing them
1589
+ // Then handle other escapes, then restore double backslashes as single
1590
+ return str
1591
+ .replace(/\\\\/g, '\x00') // Temporarily mark double backslashes
1592
+ .replace(/\\n/g, '\n') // Match literal backslash-n (\\\\n in JS string -> \\n in regex -> matches \n)
1593
+ .replace(/\\t/g, '\t')
1594
+ .replace(/\\r/g, '\r')
1595
+ .replace(/\\"/g, '"')
1596
+ .replace(/\\'/g, "'")
1597
+ .replace(/\x00/g, '\\'); // Restore double backslashes as single
1598
+ }
1599
+
1170
1600
  // Issues management
1171
1601
  const issues = program.command("issues").description("issues management");
1172
1602
 
@@ -1174,7 +1604,8 @@ issues
1174
1604
  .command("list")
1175
1605
  .description("list issues")
1176
1606
  .option("--debug", "enable debug output")
1177
- .action(async (opts: { debug?: boolean }) => {
1607
+ .option("--json", "output raw JSON")
1608
+ .action(async (opts: { debug?: boolean; json?: boolean }) => {
1178
1609
  try {
1179
1610
  const rootOpts = program.opts<CliOptions>();
1180
1611
  const cfg = config.readConfig();
@@ -1188,12 +1619,96 @@ issues
1188
1619
  const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
1189
1620
 
1190
1621
  const result = await fetchIssues({ apiKey, apiBaseUrl, debug: !!opts.debug });
1191
- if (typeof result === "string") {
1192
- process.stdout.write(result);
1193
- if (!/\n$/.test(result)) console.log();
1194
- } else {
1195
- console.log(JSON.stringify(result, null, 2));
1622
+ const trimmed = Array.isArray(result)
1623
+ ? (result as any[]).map((r) => ({
1624
+ id: (r as any).id,
1625
+ title: (r as any).title,
1626
+ status: (r as any).status,
1627
+ created_at: (r as any).created_at,
1628
+ }))
1629
+ : result;
1630
+ printResult(trimmed, opts.json);
1631
+ } catch (err) {
1632
+ const message = err instanceof Error ? err.message : String(err);
1633
+ console.error(message);
1634
+ process.exitCode = 1;
1635
+ }
1636
+ });
1637
+
1638
+ issues
1639
+ .command("view <issueId>")
1640
+ .description("view issue details and comments")
1641
+ .option("--debug", "enable debug output")
1642
+ .option("--json", "output raw JSON")
1643
+ .action(async (issueId: string, opts: { debug?: boolean; json?: boolean }) => {
1644
+ try {
1645
+ const rootOpts = program.opts<CliOptions>();
1646
+ const cfg = config.readConfig();
1647
+ const { apiKey } = getConfig(rootOpts);
1648
+ if (!apiKey) {
1649
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
1650
+ process.exitCode = 1;
1651
+ return;
1652
+ }
1653
+
1654
+ const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
1655
+
1656
+ const issue = await fetchIssue({ apiKey, apiBaseUrl, issueId, debug: !!opts.debug });
1657
+ if (!issue) {
1658
+ console.error("Issue not found");
1659
+ process.exitCode = 1;
1660
+ return;
1196
1661
  }
1662
+
1663
+ const comments = await fetchIssueComments({ apiKey, apiBaseUrl, issueId, debug: !!opts.debug });
1664
+ const combined = { issue, comments };
1665
+ printResult(combined, opts.json);
1666
+ } catch (err) {
1667
+ const message = err instanceof Error ? err.message : String(err);
1668
+ console.error(message);
1669
+ process.exitCode = 1;
1670
+ }
1671
+ });
1672
+
1673
+ issues
1674
+ .command("post_comment <issueId> <content>")
1675
+ .description("post a new comment to an issue")
1676
+ .option("--parent <uuid>", "parent comment id")
1677
+ .option("--debug", "enable debug output")
1678
+ .option("--json", "output raw JSON")
1679
+ .action(async (issueId: string, content: string, opts: { parent?: string; debug?: boolean; json?: boolean }) => {
1680
+ try {
1681
+ // Interpret escape sequences in content (e.g., \n -> newline)
1682
+ if (opts.debug) {
1683
+ // eslint-disable-next-line no-console
1684
+ console.log(`Debug: Original content: ${JSON.stringify(content)}`);
1685
+ }
1686
+ content = interpretEscapes(content);
1687
+ if (opts.debug) {
1688
+ // eslint-disable-next-line no-console
1689
+ console.log(`Debug: Interpreted content: ${JSON.stringify(content)}`);
1690
+ }
1691
+
1692
+ const rootOpts = program.opts<CliOptions>();
1693
+ const cfg = config.readConfig();
1694
+ const { apiKey } = getConfig(rootOpts);
1695
+ if (!apiKey) {
1696
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
1697
+ process.exitCode = 1;
1698
+ return;
1699
+ }
1700
+
1701
+ const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
1702
+
1703
+ const result = await createIssueComment({
1704
+ apiKey,
1705
+ apiBaseUrl,
1706
+ issueId,
1707
+ content,
1708
+ parentCommentId: opts.parent,
1709
+ debug: !!opts.debug,
1710
+ });
1711
+ printResult(result, opts.json);
1197
1712
  } catch (err) {
1198
1713
  const message = err instanceof Error ? err.message : String(err);
1199
1714
  console.error(message);