postgresai 0.15.0-dev.10 → 0.15.0-dev.11

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.
@@ -14,7 +14,7 @@ import { startMcpServer } from "../lib/mcp-server";
14
14
  import { fetchIssues, fetchIssueComments, createIssueComment, fetchIssue, createIssue, updateIssue, updateIssueComment, fetchActionItem, fetchActionItems, createActionItem, updateActionItem, type ConfigChange } from "../lib/issues";
15
15
  import { fetchReports, fetchAllReports, fetchReportFiles, fetchReportFileData, renderMarkdownForTerminal, parseFlexibleDate } from "../lib/reports";
16
16
  import { resolveBaseUrls } from "../lib/util";
17
- import { uploadFile, downloadFile, buildMarkdownLink } from "../lib/storage";
17
+ import { uploadFile, downloadFile, buildMarkdownLink, uploadAttachments, appendAttachmentsToContent } from "../lib/storage";
18
18
  import { applyInitPlan, applyUninitPlan, buildInitPlan, buildUninitPlan, checkCurrentUserPermissions, connectWithSslFallback, DEFAULT_MONITORING_USER, formatPermissionCheckMessages, KNOWN_PROVIDERS, redactPasswordsInSql, resolveAdminConnection, resolveMonitoringPassword, validateProvider, verifyInitSetup } from "../lib/init";
19
19
  import { SupabaseClient, resolveSupabaseConfig, extractProjectRefFromUrl, applyInitPlanViaSupabase, verifyInitSetupViaSupabase, fetchPoolerDatabaseUrl, type PgCompatibleError } from "../lib/supabase";
20
20
  import * as pkce from "../lib/pkce";
@@ -26,6 +26,17 @@ import { REPORT_GENERATORS, CHECK_INFO, generateAllReports } from "../lib/checku
26
26
  import { getCheckupEntry } from "../lib/checkup-dictionary";
27
27
  import { createCheckupReport, uploadCheckupReportJson, convertCheckupReportJsonToMarkdown, RpcError, formatRpcErrorForDisplay, withRetry } from "../lib/checkup-api";
28
28
  import { generateCheckSummary } from "../lib/checkup-summary";
29
+ import {
30
+ type Instance,
31
+ InstancesParseError,
32
+ loadInstances,
33
+ buildInstance,
34
+ addInstanceToFile,
35
+ removeInstanceFromFile,
36
+ buildClientConfig,
37
+ sslOptionFromConnString,
38
+ warnIfLaxSslmode,
39
+ } from "../lib/instances";
29
40
 
30
41
  // Node.js version check - require Node 18+
31
42
  // Node 14 reached EOL in April 2023, Node 16 in September 2023.
@@ -419,19 +430,6 @@ interface ConfigResult {
419
430
  apiKey: string;
420
431
  }
421
432
 
422
- /**
423
- * Instance configuration
424
- */
425
- interface Instance {
426
- name: string;
427
- conn_str?: string;
428
- preset_metrics?: string;
429
- custom_metrics?: any;
430
- is_enabled?: boolean;
431
- group?: string;
432
- custom_tags?: Record<string, any>;
433
- }
434
-
435
433
  /**
436
434
  * Path resolution result
437
435
  */
@@ -2302,6 +2300,18 @@ const mon = program.command("mon").description("monitoring services management")
2302
2300
  mon
2303
2301
  .command("local-install")
2304
2302
  .description("install local monitoring stack (generate config, start services)")
2303
+ .addHelpText(
2304
+ "after",
2305
+ [
2306
+ "",
2307
+ "Networking:",
2308
+ " Compose enables IPv6 on the project's default network so containers can",
2309
+ " reach IPv6-only databases (e.g. Supabase free-tier db.<ref>.supabase.co).",
2310
+ " Override on hosts whose Docker daemon cannot create an IPv6 network:",
2311
+ " PGAI_ENABLE_IPV6=false (accepted: true|false|yes|no, lowercase)",
2312
+ "",
2313
+ ].join("\n"),
2314
+ )
2305
2315
  .option("--demo", "demo mode with sample database", false)
2306
2316
  .option("--api-key <key>", "Postgres AI API key for automated report uploads")
2307
2317
  .option("--db-url <url>", "PostgreSQL connection URL to monitor")
@@ -2487,8 +2497,7 @@ mon
2487
2497
  const db = m[5];
2488
2498
  const instanceName = `${host}-${db}`.replace(/[^a-zA-Z0-9-]/g, "-");
2489
2499
 
2490
- 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`;
2491
- fs.appendFileSync(instancesPath, body, "utf8");
2500
+ addInstanceToFile(instancesPath, buildInstance(instanceName, connStr));
2492
2501
  console.log(`✓ Monitoring target '${instanceName}' added\n`);
2493
2502
 
2494
2503
  // Test connection
@@ -2496,7 +2505,8 @@ mon
2496
2505
  {
2497
2506
  let testClient: InstanceType<typeof Client> | null = null;
2498
2507
  try {
2499
- testClient = new Client({ connectionString: connStr, connectionTimeoutMillis: 10000 });
2508
+ warnIfLaxSslmode(connStr);
2509
+ testClient = new Client(buildClientConfig(connStr, { connectionTimeoutMillis: 10000 }));
2500
2510
  await testClient.connect();
2501
2511
  const result = await testClient.query("select version();");
2502
2512
  console.log("✓ Connection successful");
@@ -2535,8 +2545,7 @@ mon
2535
2545
  const db = m[5];
2536
2546
  const instanceName = `${host}-${db}`.replace(/[^a-zA-Z0-9-]/g, "-");
2537
2547
 
2538
- 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`;
2539
- fs.appendFileSync(instancesPath, body, "utf8");
2548
+ addInstanceToFile(instancesPath, buildInstance(instanceName, connStr));
2540
2549
  console.log(`✓ Monitoring target '${instanceName}' added\n`);
2541
2550
 
2542
2551
  // Test connection
@@ -2544,7 +2553,8 @@ mon
2544
2553
  {
2545
2554
  let testClient: InstanceType<typeof Client> | null = null;
2546
2555
  try {
2547
- testClient = new Client({ connectionString: connStr, connectionTimeoutMillis: 10000 });
2556
+ warnIfLaxSslmode(connStr);
2557
+ testClient = new Client(buildClientConfig(connStr, { connectionTimeoutMillis: 10000 }));
2548
2558
  await testClient.connect();
2549
2559
  const result = await testClient.query("select version();");
2550
2560
  console.log("✓ Connection successful");
@@ -3173,42 +3183,32 @@ targets
3173
3183
  return;
3174
3184
  }
3175
3185
 
3186
+ let instances: Instance[];
3176
3187
  try {
3177
- const content = fs.readFileSync(instancesPath, "utf8");
3178
- const instances = yaml.load(content) as Instance[] | null;
3179
-
3180
- if (!instances || !Array.isArray(instances) || instances.length === 0) {
3181
- console.log("No monitoring targets configured");
3182
- console.log("");
3183
- console.log("To add a monitoring target:");
3184
- console.log(" postgres-ai mon targets add <connection-string> <name>");
3185
- console.log("");
3186
- console.log("Example:");
3187
- console.log(" postgres-ai mon targets add 'postgresql://user:pass@host:5432/db' my-db");
3188
- return;
3189
- }
3190
-
3191
- // Filter out disabled instances (e.g., demo placeholders)
3192
- const filtered = instances.filter((inst) => inst.name && inst.is_enabled !== false);
3193
-
3194
- if (filtered.length === 0) {
3195
- console.log("No monitoring targets configured");
3196
- console.log("");
3197
- console.log("To add a monitoring target:");
3198
- console.log(" postgres-ai mon targets add <connection-string> <name>");
3199
- console.log("");
3200
- console.log("Example:");
3201
- console.log(" postgres-ai mon targets add 'postgresql://user:pass@host:5432/db' my-db");
3202
- return;
3203
- }
3204
-
3205
- for (const inst of filtered) {
3206
- console.log(`Target: ${inst.name}`);
3207
- }
3188
+ instances = loadInstances(instancesPath);
3208
3189
  } catch (err) {
3209
3190
  const message = err instanceof Error ? err.message : String(err);
3210
3191
  console.error(`Error parsing instances.yml: ${message}`);
3211
3192
  process.exitCode = 1;
3193
+ return;
3194
+ }
3195
+
3196
+ // Filter out disabled instances (e.g., demo placeholders)
3197
+ const filtered = instances.filter((inst) => inst.name && inst.is_enabled !== false);
3198
+
3199
+ if (filtered.length === 0) {
3200
+ console.log("No monitoring targets configured");
3201
+ console.log("");
3202
+ console.log("To add a monitoring target:");
3203
+ console.log(" postgres-ai mon targets add <connection-string> <name>");
3204
+ console.log("");
3205
+ console.log("Example:");
3206
+ console.log(" postgres-ai mon targets add 'postgresql://user:pass@host:5432/db' my-db");
3207
+ return;
3208
+ }
3209
+
3210
+ for (const inst of filtered) {
3211
+ console.log(`Target: ${inst.name}`);
3212
3212
  }
3213
3213
  });
3214
3214
  targets
@@ -3231,40 +3231,17 @@ targets
3231
3231
  const db = m[5];
3232
3232
  const instanceName = name && name.trim() ? name.trim() : `${host}-${db}`.replace(/[^a-zA-Z0-9-]/g, "-");
3233
3233
 
3234
- // Check if instance already exists
3235
3234
  try {
3236
- if (fs.existsSync(file) && !fs.lstatSync(file).isDirectory()) {
3237
- const content = fs.readFileSync(file, "utf8");
3238
- const instances = yaml.load(content) as Instance[] | null || [];
3239
- if (Array.isArray(instances)) {
3240
- const exists = instances.some((inst) => inst.name === instanceName);
3241
- if (exists) {
3242
- console.error(`Monitoring target '${instanceName}' already exists`);
3243
- process.exitCode = 1;
3244
- return;
3245
- }
3246
- }
3247
- }
3235
+ addInstanceToFile(file, buildInstance(instanceName, connStr));
3236
+ console.log(`Monitoring target '${instanceName}' added`);
3248
3237
  } catch (err) {
3249
- // If YAML parsing fails, fall back to simple check
3250
- const isFile = fs.existsSync(file) && !fs.lstatSync(file).isDirectory();
3251
- const content = isFile ? fs.readFileSync(file, "utf8") : "";
3252
- const escapedName = instanceName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3253
- if (new RegExp(`^- name: ${escapedName}$`, "m").test(content)) {
3254
- console.error(`Monitoring target '${instanceName}' already exists`);
3255
- process.exitCode = 1;
3256
- return;
3257
- }
3258
- }
3259
-
3260
- // Add new instance — if instances.yml is a directory (Docker artifact), replace it with a file
3261
- if (fs.existsSync(file) && fs.lstatSync(file).isDirectory()) {
3262
- fs.rmSync(file, { recursive: true, force: true });
3238
+ // Surface InstancesParseError as-is so we don't silently overwrite a
3239
+ // corrupted file (which could discard several targets, including the
3240
+ // credentials in their conn_str values).
3241
+ const message = err instanceof Error ? err.message : String(err);
3242
+ console.error(message);
3243
+ process.exitCode = 1;
3263
3244
  }
3264
- 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`;
3265
- const content = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
3266
- fs.appendFileSync(file, (content && !/\n$/.test(content) ? "\n" : "") + body, "utf8");
3267
- console.log(`Monitoring target '${instanceName}' added`);
3268
3245
  });
3269
3246
  targets
3270
3247
  .command("remove <name>")
@@ -3278,24 +3255,12 @@ targets
3278
3255
  }
3279
3256
 
3280
3257
  try {
3281
- const content = fs.readFileSync(file, "utf8");
3282
- const instances = yaml.load(content) as Instance[] | null;
3283
-
3284
- if (!instances || !Array.isArray(instances)) {
3285
- console.error("Invalid instances.yml format");
3286
- process.exitCode = 1;
3287
- return;
3288
- }
3289
-
3290
- const filtered = instances.filter((inst) => inst.name !== name);
3291
-
3292
- if (filtered.length === instances.length) {
3258
+ const removed = removeInstanceFromFile(file, name);
3259
+ if (!removed) {
3293
3260
  console.error(`Monitoring target '${name}' not found`);
3294
3261
  process.exitCode = 1;
3295
3262
  return;
3296
3263
  }
3297
-
3298
- fs.writeFileSync(file, yaml.dump(filtered), "utf8");
3299
3264
  console.log(`Monitoring target '${name}' removed`);
3300
3265
  } catch (err) {
3301
3266
  const message = err instanceof Error ? err.message : String(err);
@@ -3314,35 +3279,35 @@ targets
3314
3279
  return;
3315
3280
  }
3316
3281
 
3282
+ let instances: Instance[];
3317
3283
  try {
3318
- const content = fs.readFileSync(instancesPath, "utf8");
3319
- const instances = yaml.load(content) as Instance[] | null;
3320
-
3321
- if (!instances || !Array.isArray(instances)) {
3322
- console.error("Invalid instances.yml format");
3323
- process.exitCode = 1;
3324
- return;
3325
- }
3326
-
3327
- const instance = instances.find((inst) => inst.name === name);
3284
+ instances = loadInstances(instancesPath);
3285
+ } catch (err) {
3286
+ const message = err instanceof Error ? err.message : String(err);
3287
+ console.error(`Error parsing instances.yml: ${message}`);
3288
+ process.exitCode = 1;
3289
+ return;
3290
+ }
3291
+ const instance = instances.find((inst) => inst.name === name);
3328
3292
 
3329
- if (!instance) {
3330
- console.error(`Monitoring target '${name}' not found`);
3331
- process.exitCode = 1;
3332
- return;
3333
- }
3293
+ if (!instance) {
3294
+ console.error(`Monitoring target '${name}' not found`);
3295
+ process.exitCode = 1;
3296
+ return;
3297
+ }
3334
3298
 
3335
- if (!instance.conn_str) {
3336
- console.error(`Connection string not found for monitoring target '${name}'`);
3337
- process.exitCode = 1;
3338
- return;
3339
- }
3299
+ if (!instance.conn_str) {
3300
+ console.error(`Connection string not found for monitoring target '${name}'`);
3301
+ process.exitCode = 1;
3302
+ return;
3303
+ }
3340
3304
 
3341
- console.log(`Testing connection to monitoring target '${name}'...`);
3305
+ console.log(`Testing connection to monitoring target '${name}'...`);
3342
3306
 
3343
- // Use native pg client instead of requiring psql to be installed
3344
- const client = new Client({ connectionString: instance.conn_str, connectionTimeoutMillis: 10000 });
3307
+ warnIfLaxSslmode(instance.conn_str);
3308
+ const client = new Client(buildClientConfig(instance.conn_str, { connectionTimeoutMillis: 10000 }));
3345
3309
 
3310
+ try {
3346
3311
  try {
3347
3312
  await client.connect();
3348
3313
  const result = await client.query('select version();');
@@ -3376,17 +3341,17 @@ auth
3376
3341
  process.exitCode = 1;
3377
3342
  return;
3378
3343
  }
3379
-
3344
+
3380
3345
  // Read existing config to check for defaultProject before updating
3381
3346
  const existingConfig = config.readConfig();
3382
3347
  const existingProject = existingConfig.defaultProject;
3383
-
3348
+
3384
3349
  config.writeConfig({ apiKey: trimmedKey });
3385
3350
  // When API key is set directly, only clear orgId (org selection may differ).
3386
3351
  // Preserve defaultProject to avoid orphaning historical reports.
3387
3352
  // If the new key lacks access to the project, upload will fail with a clear error.
3388
3353
  config.deleteConfigKeys(["orgId"]);
3389
-
3354
+
3390
3355
  console.log(`API key saved to ${config.getConfigPath()}`);
3391
3356
  if (existingProject) {
3392
3357
  console.log(`Note: Your default project "${existingProject}" has been preserved.`);
@@ -3570,13 +3535,13 @@ auth
3570
3535
  const existingOrgId = existingConfig.orgId;
3571
3536
  const existingProject = existingConfig.defaultProject;
3572
3537
  const orgChanged = existingOrgId && existingOrgId !== orgId;
3573
-
3538
+
3574
3539
  config.writeConfig({
3575
3540
  apiKey: apiToken,
3576
3541
  baseUrl: apiBaseUrl,
3577
3542
  orgId: orgId,
3578
3543
  });
3579
-
3544
+
3580
3545
  // Only clear defaultProject if org actually changed
3581
3546
  if (orgChanged && existingProject) {
3582
3547
  config.deleteConfigKeys(["defaultProject"]);
@@ -3915,9 +3880,18 @@ issues
3915
3880
  .command("post-comment <issueId> <content>")
3916
3881
  .description("post a new comment to an issue")
3917
3882
  .option("--parent <uuid>", "parent comment id")
3883
+ .option(
3884
+ "--attach <path>",
3885
+ "attach a file (uploads to storage and appends a markdown link; repeatable)",
3886
+ (value: string, previous: string[]) => {
3887
+ previous.push(value);
3888
+ return previous;
3889
+ },
3890
+ [] as string[]
3891
+ )
3918
3892
  .option("--debug", "enable debug output")
3919
3893
  .option("--json", "output raw JSON")
3920
- .action(async (issueId: string, content: string, opts: { parent?: string; debug?: boolean; json?: boolean }) => {
3894
+ .action(async (issueId: string, content: string, opts: { parent?: string; attach?: string[]; debug?: boolean; json?: boolean }) => {
3921
3895
  // Interpret escape sequences in content (e.g., \n -> newline)
3922
3896
  if (opts.debug) {
3923
3897
  // eslint-disable-next-line no-console
@@ -3929,7 +3903,11 @@ issues
3929
3903
  console.error(`Debug: Interpreted content: ${JSON.stringify(content)}`);
3930
3904
  }
3931
3905
 
3932
- const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Posting comment...");
3906
+ const attachPaths = Array.isArray(opts.attach) ? opts.attach : [];
3907
+ const spinner = createTtySpinner(
3908
+ process.stdout.isTTY ?? false,
3909
+ attachPaths.length > 0 ? "Uploading attachments..." : "Posting comment..."
3910
+ );
3933
3911
  try {
3934
3912
  const rootOpts = program.opts<CliOptions>();
3935
3913
  const cfg = config.readConfig();
@@ -3941,13 +3919,25 @@ issues
3941
3919
  return;
3942
3920
  }
3943
3921
 
3944
- const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
3922
+ const { apiBaseUrl, storageBaseUrl } = resolveBaseUrls(rootOpts, cfg);
3923
+
3924
+ let augmentedContent = content;
3925
+ if (attachPaths.length > 0) {
3926
+ const uploaded = await uploadAttachments({
3927
+ apiKey,
3928
+ storageBaseUrl,
3929
+ attachmentPaths: attachPaths,
3930
+ debug: !!opts.debug,
3931
+ });
3932
+ augmentedContent = appendAttachmentsToContent(content, uploaded);
3933
+ spinner.update("Posting comment...");
3934
+ }
3945
3935
 
3946
3936
  const result = await createIssueComment({
3947
3937
  apiKey,
3948
3938
  apiBaseUrl,
3949
3939
  issueId,
3950
- content,
3940
+ content: augmentedContent,
3951
3941
  parentCommentId: opts.parent,
3952
3942
  debug: !!opts.debug,
3953
3943
  });
@@ -3976,9 +3966,18 @@ issues
3976
3966
  },
3977
3967
  [] as string[]
3978
3968
  )
3969
+ .option(
3970
+ "--attach <path>",
3971
+ "attach a file (uploads to storage and appends a markdown link to the description; repeatable)",
3972
+ (value: string, previous: string[]) => {
3973
+ previous.push(value);
3974
+ return previous;
3975
+ },
3976
+ [] as string[]
3977
+ )
3979
3978
  .option("--debug", "enable debug output")
3980
3979
  .option("--json", "output raw JSON")
3981
- .action(async (rawTitle: string, opts: { orgId?: number; projectId?: number; description?: string; label?: string[]; debug?: boolean; json?: boolean }) => {
3980
+ .action(async (rawTitle: string, opts: { orgId?: number; projectId?: number; description?: string; label?: string[]; attach?: string[]; debug?: boolean; json?: boolean }) => {
3982
3981
  const rootOpts = program.opts<CliOptions>();
3983
3982
  const cfg = config.readConfig();
3984
3983
  const { apiKey } = getConfig(rootOpts);
@@ -4005,16 +4004,33 @@ issues
4005
4004
  const description = opts.description !== undefined ? interpretEscapes(String(opts.description)) : undefined;
4006
4005
  const labels = Array.isArray(opts.label) && opts.label.length > 0 ? opts.label.map(String) : undefined;
4007
4006
  const projectId = typeof opts.projectId === "number" && !Number.isNaN(opts.projectId) ? opts.projectId : undefined;
4007
+ const attachPaths = Array.isArray(opts.attach) ? opts.attach : [];
4008
4008
 
4009
- const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Creating issue...");
4009
+ const spinner = createTtySpinner(
4010
+ process.stdout.isTTY ?? false,
4011
+ attachPaths.length > 0 ? "Uploading attachments..." : "Creating issue..."
4012
+ );
4010
4013
  try {
4011
- const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
4014
+ const { apiBaseUrl, storageBaseUrl } = resolveBaseUrls(rootOpts, cfg);
4015
+
4016
+ let augmentedDescription = description;
4017
+ if (attachPaths.length > 0) {
4018
+ const uploaded = await uploadAttachments({
4019
+ apiKey,
4020
+ storageBaseUrl,
4021
+ attachmentPaths: attachPaths,
4022
+ debug: !!opts.debug,
4023
+ });
4024
+ augmentedDescription = appendAttachmentsToContent(description ?? "", uploaded);
4025
+ spinner.update("Creating issue...");
4026
+ }
4027
+
4012
4028
  const result = await createIssue({
4013
4029
  apiKey,
4014
4030
  apiBaseUrl,
4015
4031
  title,
4016
4032
  orgId,
4017
- description,
4033
+ description: augmentedDescription,
4018
4034
  projectId,
4019
4035
  labels,
4020
4036
  debug: !!opts.debug,
@@ -4045,9 +4061,18 @@ issues
4045
4061
  [] as string[]
4046
4062
  )
4047
4063
  .option("--clear-labels", "set labels to an empty list")
4064
+ .option(
4065
+ "--attach <path>",
4066
+ "attach a file (uploads and appends a markdown link to --description; if --description is omitted the existing description is fetched and appended to; repeatable)",
4067
+ (value: string, previous: string[]) => {
4068
+ previous.push(value);
4069
+ return previous;
4070
+ },
4071
+ [] as string[]
4072
+ )
4048
4073
  .option("--debug", "enable debug output")
4049
4074
  .option("--json", "output raw JSON")
4050
- .action(async (issueId: string, opts: { title?: string; description?: string; status?: string; label?: string[]; clearLabels?: boolean; debug?: boolean; json?: boolean }) => {
4075
+ .action(async (issueId: string, opts: { title?: string; description?: string; status?: string; label?: string[]; clearLabels?: boolean; attach?: string[]; debug?: boolean; json?: boolean }) => {
4051
4076
  const rootOpts = program.opts<CliOptions>();
4052
4077
  const cfg = config.readConfig();
4053
4078
  const { apiKey } = getConfig(rootOpts);
@@ -4057,10 +4082,10 @@ issues
4057
4082
  return;
4058
4083
  }
4059
4084
 
4060
- const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
4085
+ const { apiBaseUrl, storageBaseUrl } = resolveBaseUrls(rootOpts, cfg);
4061
4086
 
4062
4087
  const title = opts.title !== undefined ? interpretEscapes(String(opts.title)) : undefined;
4063
- const description = opts.description !== undefined ? interpretEscapes(String(opts.description)) : undefined;
4088
+ let description = opts.description !== undefined ? interpretEscapes(String(opts.description)) : undefined;
4064
4089
 
4065
4090
  let status: number | undefined = undefined;
4066
4091
  if (opts.status !== undefined) {
@@ -4090,8 +4115,38 @@ issues
4090
4115
  labels = opts.label.map(String);
4091
4116
  }
4092
4117
 
4093
- const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Updating issue...");
4118
+ const attachPaths = Array.isArray(opts.attach) ? opts.attach : [];
4119
+ const spinner = createTtySpinner(
4120
+ process.stdout.isTTY ?? false,
4121
+ attachPaths.length > 0 ? "Uploading attachments..." : "Updating issue..."
4122
+ );
4094
4123
  try {
4124
+ if (attachPaths.length > 0) {
4125
+ // If the caller did not supply a new description, fetch the existing one
4126
+ // and append to it. This makes "add a screenshot to issue X" a one-step
4127
+ // operation rather than forcing the caller to copy-paste the existing
4128
+ // description first. Small race window if someone else updates
4129
+ // concurrently, which is acceptable for an interactive CLI / agent.
4130
+ if (description === undefined) {
4131
+ const existing = await fetchIssue({ apiKey, apiBaseUrl, issueId, debug: !!opts.debug });
4132
+ if (!existing) {
4133
+ spinner.stop();
4134
+ console.error(`Issue not found: ${issueId}`);
4135
+ process.exitCode = 1;
4136
+ return;
4137
+ }
4138
+ description = (existing as { description?: string | null }).description ?? "";
4139
+ }
4140
+ const uploaded = await uploadAttachments({
4141
+ apiKey,
4142
+ storageBaseUrl,
4143
+ attachmentPaths: attachPaths,
4144
+ debug: !!opts.debug,
4145
+ });
4146
+ description = appendAttachmentsToContent(description ?? "", uploaded);
4147
+ spinner.update("Updating issue...");
4148
+ }
4149
+
4095
4150
  const result = await updateIssue({
4096
4151
  apiKey,
4097
4152
  apiBaseUrl,
@@ -4115,9 +4170,18 @@ issues
4115
4170
  issues
4116
4171
  .command("update-comment <commentId> <content>")
4117
4172
  .description("update an existing issue comment")
4173
+ .option(
4174
+ "--attach <path>",
4175
+ "attach a file (uploads and appends a markdown link to <content>; repeatable)",
4176
+ (value: string, previous: string[]) => {
4177
+ previous.push(value);
4178
+ return previous;
4179
+ },
4180
+ [] as string[]
4181
+ )
4118
4182
  .option("--debug", "enable debug output")
4119
4183
  .option("--json", "output raw JSON")
4120
- .action(async (commentId: string, content: string, opts: { debug?: boolean; json?: boolean }) => {
4184
+ .action(async (commentId: string, content: string, opts: { attach?: string[]; debug?: boolean; json?: boolean }) => {
4121
4185
  if (opts.debug) {
4122
4186
  // eslint-disable-next-line no-console
4123
4187
  console.error(`Debug: Original content: ${JSON.stringify(content)}`);
@@ -4137,15 +4201,31 @@ issues
4137
4201
  return;
4138
4202
  }
4139
4203
 
4140
- const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Updating comment...");
4204
+ const attachPaths = Array.isArray(opts.attach) ? opts.attach : [];
4205
+ const spinner = createTtySpinner(
4206
+ process.stdout.isTTY ?? false,
4207
+ attachPaths.length > 0 ? "Uploading attachments..." : "Updating comment..."
4208
+ );
4141
4209
  try {
4142
- const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
4210
+ const { apiBaseUrl, storageBaseUrl } = resolveBaseUrls(rootOpts, cfg);
4211
+
4212
+ let augmentedContent = content;
4213
+ if (attachPaths.length > 0) {
4214
+ const uploaded = await uploadAttachments({
4215
+ apiKey,
4216
+ storageBaseUrl,
4217
+ attachmentPaths: attachPaths,
4218
+ debug: !!opts.debug,
4219
+ });
4220
+ augmentedContent = appendAttachmentsToContent(content, uploaded);
4221
+ spinner.update("Updating comment...");
4222
+ }
4143
4223
 
4144
4224
  const result = await updateIssueComment({
4145
4225
  apiKey,
4146
4226
  apiBaseUrl,
4147
4227
  commentId,
4148
- content,
4228
+ content: augmentedContent,
4149
4229
  debug: !!opts.debug,
4150
4230
  });
4151
4231
  spinner.stop();
@@ -4856,4 +4936,3 @@ mcp
4856
4936
  program.parseAsync(process.argv).finally(() => {
4857
4937
  closeReadline();
4858
4938
  });
4859
-