@freeasinbird/gh-imgup
v0.1.3
Published
Securely upload images to GitHub issues & PRs via the Release Assets API — zero deps, no browser.
Maintainers
Readme
gh-imgup
A CLI tool that uploads images to GitHub issues and pull requests using the documented Release Assets API. Designed for agents and CI workflows that need to attach screenshots (particularly before/after UI images) to PRs for human reviewers.
Quick Start
# Upload an image and get Markdown to drop into a PR/issue body
npx -y @freeasinbird/gh-imgup screenshot.png --repo owner/repo
# → 
# Or post it straight to a PR (or issue) as a comment
npx -y @freeasinbird/gh-imgup screenshot.png --repo owner/repo --pr 42 -m "Login screen"Needs a GITHUB_TOKEN with contents:write (add issues:write for --pr/--issue), or a logged-in gh CLI. Run it inside the target repo and --repo is inferred from the git remote.
Using an agent (Claude Code, Cursor, Codex)? Also add the skill: npx -y skills add freeasinbird/gh-imgup gives the agent the usage guidance and the mandatory pre-upload image review. It does not install the CLI; the agent still runs gh-imgup from one of the options in Installation. For the smoothest agent runs, pre-install a pinned version (npm i -g @freeasinbird/[email protected]) and pre-authorize the bare gh-imgup; see Pre-authorize for agents, which covers both Claude and Codex.
Why This Exists
GitHub has no public API for image attachments. The drag-and-drop upload in the web UI uses an internal endpoint that requires browser session cookies and has been explicitly denied as a public API for over five years (cli/cli#1895).
That's a real gap for automated workflows. When an agent or CI job implements a UI change, the most useful artifact for review is a screenshot: a CSS diff doesn't tell a reviewer whether a layout looks correct, but a before/after pair answers that in seconds. Without a programmatic path, PRs describe visual changes in text and leave reviewers to check out the branch and see for themselves. Capturing those screenshots by hand is enough friction that it often gets skipped.
gh-imgup uploads through the documented Release Assets API to the same repository the PR or issue lives in, so the repo's existing access controls apply to the images. It automates the upload-and-link step; the only manual part left is capturing the screenshots, which an agent with browser access can also do. (The alternatives considered and the security rationale are in the Security Model and docs/design.md.)
Other use cases
- Visual regression evidence in a PR description or comment
- Error screenshots in bug reports
- Test result images (charts, rendered components) in issues
- Architecture diagrams or design mockups embedded in discussions
- Any image a human reviewer would benefit from seeing alongside code
Installation
npm
While you can run gh-imgup zero-install with npx -y @freeasinbird/gh-imgup …, for repeated use (or for any agent whose approval reviewer refuses to run unpinned downloaded code, such as Codex; see Pre-authorize for agents) install a pinned version once and invoke the bare gh-imgup:
npm i -g @freeasinbird/[email protected] # pin the current version; `npm view @freeasinbird/gh-imgup version`
gh-imgup screenshot.png --repo owner/repoPin a version in CI the same way, e.g. npx -y @freeasinbird/[email protected] …. When you run it from a different repo's checkout (e.g. the gh-imgup source) rather than your project's, pass --repo owner/repo; otherwise it infers the repo from that checkout's git remote.
For the npx form, keep the -y (it skips npx's first-run prompt, which would otherwise hang a non-interactive agent or CI job) and the @freeasinbird/ scope (a bare npx gh-imgup is a different, unscoped package).
gh CLI extension
gh extension install freeasinbird/gh-imgup
gh imgup screenshot.pngThe extension is compiled from source, so on first run it prints a one-time build command (npm ci --include=dev && npm run build in the extension directory). Run it once. This is the only step that touches the npm registry; afterward the tool contacts GitHub only and works offline, and later upgrades rebuild automatically.
Agent skill (Claude Code, Cursor, Codex)
The skill definition lives at skills/gh-imgup/SKILL.md. Install it with the skills CLI:
npx -y skills add freeasinbird/gh-imgup # install
npx -y skills update # update installed skills to the latestskills add reads the repository's default branch, so it works once the skill is merged there. You can also copy SKILL.md into your agent's skills directory by hand. Either way, the agent picks up the tool and its mandatory pre-upload image-review step together.
Pre-authorize for agents
To let an agent reach for the tool without a per-run approval prompt, pick one of two forms and allowlist that exact string:
- Zero-install:
npx -y @freeasinbird/gh-imgup …. Convenient, but every run re-downloads and executes freshly-resolved package code. Some agents' approval reviewers (Codex) refuse to auto-approve that,-yor not (see below). - Pinned pre-installed (recommended for repeat use / strict reviewers): install once (
npm i -g @freeasinbird/[email protected]) and allowlist the baregh-imgup. Auditable once, no per-run download, and it passes stricter reviewers.
Claude Code. Auto-running a shell command without a prompt is an allowlist decision (a skill can't self-authorize), delivered any of these ways:
Add an allow rule to
~/.claude/settings.json(covers every repo you work in). For the recommended pinned form:{ "permissions": { "allow": ["Bash(gh-imgup *)"] } }For the zero-install form instead, use
"Bash(npx -y @freeasinbird/gh-imgup *)", but only add the rule for the form you actually run: the npx rule re-opens the unpinned download path the pinned form avoids. Put the rule in a repo's checked-in.claude/settings.jsonto share it with a team.Or, on the first approval prompt, choose "Yes, and don't ask again…", which writes the matching rule for the command you just ran.
Or pass it per session:
--allowedTools 'Bash(gh-imgup *)'(or the npx rule).
Scope the allow rule to this package: a blanket Bash(npx *) would auto-approve any package (a supply-chain risk). If you pin in CI, the trailing * won't match …@0.1.0, so add Bash(npx -y @freeasinbird/gh-imgup@*) too.
Codex doesn't read Claude settings, and its approval reviewer won't auto-run npx (it sees unpinned downloaded code with credential access). Use the pinned form:
- Pin:
npm view @freeasinbird/gh-imgup version. - Install trusted:
npm i -g @freeasinbird/[email protected]. - Approve a narrow persistent prefix for the installed command:
["gh-imgup"]. Choose Codex's persistent "always allow" when prompted.
Don't blanket-allow npx, npm exec --package, or the unpinned scope: that grants whatever those resolve to next full access to your files and GitHub credentials. On Codex Cloud, put the install line in the environment setup script and allow the api.github.com and uploads.github.com domains.
Usage
gh-imgup <file...> [options]
Options:
--repo <owner/repo> Target repository (default: inferred from git remote)
--pr <number> Comment on a pull request
--issue <number> Comment on an issue
-m, --message <text> Caption to include in a posted comment
--json JSON output to stdout
--raw Raw URL(s) only
--tag <name> Release tag (default: _gh-imgup, must start with _)
--max-size <MB> Max file size in MB (default: 25)
--cleanup Interactively delete unreferenced assets
-h, --help Show help
-v, --version Show version
Environment:
GITHUB_TOKEN GitHub token with contents:write scope (add issues:write
for --pr/--issue). Optional: if unset, falls back to the
gh CLI token (gh auth token), warning on stderr that its
scope is broader. In GitHub Actions it is provided
automatically (add a permissions block).The agent workflow
An agent with headless browser access (Playwright MCP, Chrome DevTools MCP, or shell access to run a script) can automate the full cycle:
1. Check out main, start dev server, screenshot the component → before.png
2. Check out PR branch, restart, screenshot the same component → after.png
3. gh-imgup before.png after.png → Markdown links
4. Put those Markdown links in the PR description or issue bodyThe reviewer opens the PR and sees the images inline in the body, before any follow-up discussion: no branch checkout, no local dev server, no context switching. Capturing the screenshots and composing the PR/issue body are the agent's responsibility; this tool handles only upload and Markdown output (see Output below for composing bodies and posting comments).
Output
Stdout receives only machine-parseable output. Stderr receives progress, warnings, and errors.
# Default: markdown image reference, suitable for a PR/issue body
$ gh-imgup screenshot.png

# JSON: always an array (one object per file), for piping into other tools
$ gh-imgup screenshot.png --json
[{"url":"https://...","markdown":"","filename":"screenshot.png","repo":"owner/repo","digest":"sha256:abc123..."}]
# Multiple images for body composition
$ gh-imgup before.png after.png

For an agent creating or editing a PR/issue body, compose the stdout Markdown into that body:
{
printf '## Screenshots\n\n'
gh-imgup before.png after.png --repo owner/repo
} > pr-body.md && gh pr create --body-file pr-body.mdFor an already-open thread where a follow-up comment is desired, use comment mode:
gh-imgup before.png after.png --pr 42 -m "Button component: visual diff"GitHub Actions Example
name: Visual Diff
on: pull_request
permissions:
contents: write
issues: write
jobs:
screenshot:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Capture screenshots
run: |
npx playwright screenshot http://localhost:3000/component before.png
# ... (build PR branch, screenshot again as after.png)
- name: Upload to PR comment
run: npx -y @freeasinbird/gh-imgup before.png after.png --pr ${{ github.event.pull_request.number }} -m "Visual diff"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}This example uses comment mode because the PR already exists by the time a pull_request workflow runs. Agents creating or editing the PR body should use the stdout-composition flow above instead.
How This Works
Upload mechanism
Images are uploaded as Release Assets on the same repository where the PR or issue lives, under a prerelease tagged _gh-imgup. The GitHub REST API returns a browser_download_url that renders in any GitHub markdown context: issue bodies, PR descriptions, comments.
https://github.com/{owner}/{repo}/releases/download/_gh-imgup/{filename}On private repos, this URL is only accessible to users with repo access. The access model is inherited automatically, with no separate hosting configuration needed.
Authentication
The tool uses GITHUB_TOKEN, the standard mechanism for GitHub API access: provided automatically in GitHub Actions, or locally a fine-grained PAT scoped to a single repository or the token stored by the gh CLI. Required scopes: contents:write for uploading, plus issues:write if commenting on a PR or issue.
The token is read from the environment (or the gh CLI), held in memory, sent only to api.github.com / uploads.github.com over HTTPS, and never written to disk; error output is sanitized to strip token values. See the Security Model for the full boundary.
Upload flow
- Ensure the
_gh-imgupprerelease exists on the target repo (create if missing, with a race-condition-safe create-or-get pattern) - Validate the file: check existence, size (via
stat()before reading), and the extension against a strict allowlist (.png,.jpg,.jpeg,.gif,.webp), each mapped to a fixed MIME type (SVG is excluded as an active-content format) - Upload as a release asset via
POST https://uploads.github.com/...with a collision-safe filename ({stem}-{8-char-hex}.{ext}) - Verify integrity: compare local SHA-256 against the API response digest; delete and fail on mismatch
- Return Markdown on stdout for the caller to embed; optionally, with
--pr/--issue, post the Markdown as a comment via the Issues API
All GitHub interaction uses fetch(). The compiled CLI makes exactly two subprocess calls ever (gh auth token for fallback auth and git remote get-url origin for repo inference), both via execFileSync with array arguments (no shell, no string interpolation, no user input in the array). (The gh-extension wrapper is a thin bootstrap shell script that builds/locates dist/ and forwards arguments to node.)
Security Model
These choices came out of a security review of the problem and of existing tools; the full write-up is in docs/design.md.
What the tool does
- Reads image files from local disk
- Sends them to
api.github.comanduploads.github.com(GitHub-owned, documented API) - Optionally comments on a PR or issue
- Outputs URLs/markdown to stdout
What the tool does not do
- Read, decrypt, or access browser cookies or sessions
- Open or automate a browser
- Contact any third-party services (no fallbacks, no analytics, no telemetry)
- Store credentials (token is read from env, used in-memory, never written to disk)
- Execute shell commands with user-provided input
Credential scope
With a fine-grained PAT scoped to contents:write + issues:write on one repo:
Can: create prereleases, upload assets, and comment on issues/PRs (that repo only).
Cannot: access other repos, change account settings, manage SSH keys, read private repos not in scope.
Honest limitation: contents:write also permits pushing commits; there is no releases:write-only scope in GitHub's current permission model. Use a short-lived, single-repo fine-grained PAT to minimize blast radius.
Agent image safety
In agentic workflows, the agent decides what to screenshot. The SKILL.md includes a mandatory pre-upload review instruction: examine every image for API keys, tokens, secrets, internal URLs, PII, or other sensitive content before uploading, and refuse the upload if any are found. This is the highest-impact security control in the system: the upload mechanism is secure, so the risk is in what gets uploaded.
Tradeoffs
These are inherent to the Release Assets approach, accepted with open documentation rather than hidden.
- Images are browsable on public repos. Anyone can visit
https://github.com/{owner}/{repo}/releases/tag/_gh-imgupand see every uploaded image. That is worse than GitHub's nativeuser-attachments/assets/{uuid}model, which has no public index. On private repos this is a non-issue (only users with repo access can see the releases page). Draft releases would remove the public index but break image rendering (GitHub 404s when resolving draft releases by tag), so a prerelease is the only viable option. contents:writeis broader than ideal (see Credential scope above): the same token that uploads images could push code. Use a fine-grained PAT with the shortest practical expiration, scoped to one repository.- Images persist until deleted. Release assets don't auto-expire.
gh-imgup --cleanupinteractively removes unreferenced assets (scanning issue/PR bodies and comments); full release deletion is a manualgh release delete, intentionally not automated because it breaks every previously-embedded image. The prerelease is labeled⚠️ Image assets — do not deletewith a description explaining the consequences. - Not the same URL format as drag-and-drop. GitHub's web UI produces
user-attachments/assets/{uuid}URLs; this tool producesreleases/download/_gh-imgup/{filename}URLs. Both render identically in GitHub markdown. The only functional difference:user-attachmentsare GitHub-managed and eventually cleaned up, while release assets persist until explicitly deleted.
Versioning
gh-imgup is 0.x while real-world usage accrues: the CLI flags and the machine-output contract (--json / --raw / exit codes) are stable by intent, but 0.x means they are not yet a frozen semver promise. 1.0.0 will freeze them, cut once usage justifies committing to that guarantee.
Design Process
This tool was designed through adversarial security iteration: three existing tools were audited to map the problem space, the proposed solution went through two more rounds of security audit with all findings addressed, and the architecture was refined on each pass. The full spec and the rationale behind each decision live in docs/design.md.
Repo Layout
gh-imgup/
├── src/
│ ├── index.ts # CLI arg parsing, orchestration
│ ├── auth.ts # Token resolution, scope warnings, error sanitization
│ ├── apierr.ts # API error formatting + token-decode redaction
│ ├── release.ts # Create-or-get release, upload asset, verify digest
│ ├── github.ts # Comment on PR/issue
│ ├── validate.ts # Repo, tag, number, file, MIME, remote URL parsing
│ ├── cleanup.ts # Scan issues/PRs for references, interactive deletion
│ ├── markdown.ts # Rendered-inline Markdown decode + alt-text escaping
│ └── upload.ts # Types, MIME allowlist, output formatters
├── dist/ # Compiled JS
├── skills/gh-imgup/
│ └── SKILL.md # Agent skill definition
├── gh-imgup # gh extension wrapper (shell script)
├── package.json
├── tsconfig.json
├── LICENSE
├── README.md
├── SECURITY.md
└── CHANGELOG.mdZero runtime dependencies: the entire audit surface is the src/ TypeScript plus Node.js built-ins.
Contributing
Development conventions (branches, pull requests, commits, build commands, and the security invariants that define the project) live in AGENTS.md. Human contribution guidance is in CONTRIBUTING.md. The reasoning trail is in the devlog, and the full design spec is in docs/design.md.
License
This work is licensed under GPL-3.0-or-later.
See LICENSING-PHILOSOPHY.md for why we chose this license.
A Free as in Bird project.
