feat(cli, mcp): first-class image & file attachments on issues + comments

Closes #173 (closed).

Problem

The CLI's four issue/comment commands (create, update, post-comment, update-comment) accept text only. Adding a screenshot to a comment is a 2-step manual dance: upload, copy markdown, paste into a follow-up command. The MCP server is worse — it has zero file tools, so AI agents using the postgresai MCP can post text but cannot attach screenshots, EXPLAIN diagrams, flame graphs, or any other file.

The plumbing existed (uploadFile, downloadFile, buildMarkdownLink in lib/storage.ts) but was not wired into the comment/issue surfaces.

What this MR does

CLI: new repeatable --attach <path> flag

Command Behavior
pgai issues create --attach foo.png Upload, append link to --description (or set description to just the link if --description omitted).
pgai issues post-comment <id> "msg" --attach foo.png Upload, append to the comment body.
pgai issues update-comment <id> "new content" --attach foo.png Upload, append to <content>.
pgai issues update <id> --attach foo.png If --description present, append to it. If omitted, fetch the existing description and append — so "add a screenshot to issue X" is one round-trip.

Repeatable: pass --attach multiple times; links go on consecutive lines preserving order. Image extensions (.png/.jpg/.jpeg/.gif/.webp/.svg/.bmp/.ico) render inline (![]()); other types render as plain links ([]()).

MCP: new tools + attachments parameter

  • New upload_file({path}) tool — returns URL + ready-to-paste markdown link.
  • New download_file({url, output_path?}) tool — symmetric download.
  • New optional attachments: string[] (local file paths) on post_issue_comment, create_issue, update_issue, update_issue_comment. Server-side does upload-then-append. For update_issue with attachments and no description, fetches existing description and appends.

Shared helpers (CLI ↔️ MCP)

lib/storage.ts gets two new helpers used by both surfaces:

  • uploadAttachments({apiKey, storageBaseUrl, attachmentPaths}) — sequential upload, returns per-file {path, url, markdown, metadata}. Sequential (not parallel) so error messages can pinpoint which file failed.
  • appendAttachmentsToContent(content, attachments) — appends one link per line with a two-newline separator. No-op on empty input. Doesn't strip user newlines.

Both surfaces calling the same helper means the CLI flag and the MCP attachments parameter cannot drift in output format.

Tests

30 new tests added (all passing locally):

  • storage.test.ts (10 new) — uploadAttachments round-trip with mock fetch + real tmp files, appendAttachmentsToContent edge cases (empty content, whitespace-only, multi-link ordering, trailing newlines), partial-upload error semantics.
  • issues.cli.test.ts (9 new) — fake storage /upload endpoint added to the existing fake-API harness; covers single-file, multi-file, image-vs-non-image rendering, update --attach fetch-and-append vs append-to-supplied-description, missing-file abort (no comment-create reaches the server), plain-flow regression check.
  • mcp-server.test.ts (11 new) — upload_file, download_file, attachments on each issue/comment tool, attachments-only-no-content (allowed), no-fields validation rejects (mentions attachments), update_issue fetch-then-append ordering.

Existing assertions touched: two error messages widened from "content is required" to "content or attachments is required" to reflect the new "attachments alone is fine" semantics.

bun test final tally on this branch: 820 pass, 32 skip, 8 fail — the 8 failures all pre-exist on origin/main (Docker/network-dependent mon local-install tests + one checkup-api HTTP-allow test); confirmed by stashing my changes and re-running.

Live smoke tests

Tested end-to-end against the postgres-ai team org on production (orgId=4). Test issue 019ddad3-6249-7aaa-a000-b093a5af1094 (now closed) covered:

Test Result
create --attach <png> --attach <log> description has ![]() (inline image) + []() (plain link)
post-comment --attach <png> comment body shows inline image
update-comment --attach <png> --attach <log> is_edited=True, content shows new text + 2 links in order
update --attach <log> (no --description) existing description preserved, new link appended
issues files upload standalone returns URL + ready-to-paste markdown
issues files download round-trip cmp confirms downloaded PNG bytes match original

Test plan

  • bun test test/storage.test.ts test/issues.cli.test.ts test/mcp-server.test.ts passes locally
  • CI (test:fast) passes; the pre-existing 8 failures are unrelated to this MR
  • After merge: pgai issues post-comment <id> \"hi\" --attach screenshot.png works end-to-end against staging
  • After merge: an MCP-using agent (e.g. Claude Code with postgresai MCP) sees upload_file / download_file in tools/list

Compatibility

  • All flags / parameters are additive and optional. Existing scripts and MCP clients keep working unchanged.
  • Two error messages widened (now mention attachments). Anyone matching the exact string \"content is required\" from the MCP tool error path will need to update — unlikely, but flagging.

🤖 Generated with Claude Code

Merge request reports

Loading