author: GeneralD
GitHub 自身に画像を置く道具を作りました
GitHub のや
本文に、
を一枚置きたいときがあります。
、
、
した一枚。
<img> でさえすればいいので、まずはどこかに
を置いて URL を
必要があります。
ここで、ひとつだけない
がありました。imgur のような
の
には、
に上げたくない。一度
に乗った
は、
ても
ません。
され、
され、
から
なくなる。だから「どこに置くか」を、URL を
に行く前に
ておきたかった。
いちばんきれいな置き場所は、GitHub 自身だった
てみて
たのは、GitHub には
の
がある、という一点でした。
欄に
を
すると
れる、あの
https://github.com/user-attachments/assets/... です。
これがのは、その
が repo の
に従う からです。private repo の issue や PR に
た
は、その repo を
にしか
ません。実際、
していない
で private repo の
URL を
と 404 が
ます。つまり user-attachments は、
の
ではない。GitHub という、もともとその
が
れている
の中に、置いたものが
だけ。
なかった
を、いちばん
に
置き場所でした。
は、ここに置くための
API が
ないことでした。
の
は
POST /upload/policies/assets というなのですが、これは web の UI
で、Personal Access Token を
て
と 422 が
てきます。
user_session の cookie と CSRF が
。
の
gh api からは、には
ません。
なら、ログイン済みのブラウザに置いてもらう
API がなら、UI に
もらえばいい。すでに GitHub に
している
を一つ
して、その
欄に
を
させる。GitHub が
を
て URL を
でくれたら、その URL だけ
て、
は
ずに
。
それだけをするを
gh img というで
gh のに置きました。
は、playwright-cli で headless の Chromium を一つ
して、
を
、
欄を
、
を
し、
れた URL を
て
、というだけの
です。
# 一度だけ、対話的にログイン(headed なブラウザが開く)
gh img --login
# 以後はこれだけ。URL が標準出力に返る
gh img ./shot.png https://github.com/owner/repo/issues/123
gh img ./shot.png owner/repo # その repo の新規 issue 作成画面で代用
これは前に書いた もじえもじ との
でもあります。あちらは「GitHub に
を置く」
で、
が
でした。こちらは「GitHub に
を置く」
で、
が
です。置きたいものが
だけで、
ている
は
でした。
主役は、引き算の三つ
てみて
たのは、この
の
は「どう
か」ではなく「
た
の
を、どう
に
か」にある、という一点でした。
済みの
は、それ自体が
とほぼ
な
です。その
を
以上、
ではなく、
ないものを
ておくほうが
ます。
では、三つ
ました。
一つめ。
は一行に絞る
は、
された
の
欄に
で
されます。
も、
が
している
meta[name=user-login] をだけ。つまり、
てしまった
がもし
あるものなら、
を
て
がその
に
うる。
だからは一箇所しか置けません。
を
前 に、
の
を
すること。
case "$target" in
https://github.com/*) page_url="$target" ;;
http://*|https://*) die "target host must be github.com: $target" 2 ;;
*/*) page_url="https://github.com/${target}/issues/new" ;;
*) die "target must be a github.com issue/PR URL or owner/repo" 2 ;;
esac
ているのは、
の
にある一本の
でした。
https://github.com/ までて
を
と、
github.com.evil.com や github.com@evil.com のようなの
が
れます。
自体は
の
なので、ここで一行ぶん
に
を
ことが、
の
になります。
は
にいくらでも
ますが、
のはこの一行でした。
二つめ。
は、XDG の正しい棚へ
た
の cookie は、どこに置くかで
が
ます。XDG Base Directory の
に
て、
ごとに
を
ました。
| 置くもの | 棚 | 権限 |
|---|---|---|
| ログイン cookie(アカウント相当の機密) | $XDG_STATE_HOME/gh-img/userdata | 0700 |
| ブラウザ選択の設定 | $XDG_CONFIG_HOME/playwright-cli/cli.config.json | 0600 |
| 一時的なスナップショット置き場 | $XDG_CACHE_HOME/gh-img | — |
は state、
は config、
は cache。これは
に
た
です。
その上で、もう一段だけで
た
がありました。cookie profile の
を cwd に
させず
する ことです。これは playwright の
に
ますが、「
た
の
を、
する
から
ておく」という、もう少し
の一例でもありました。
export PLAYWRIGHT_MCP_USER_DATA_DIR="${PLAYWRIGHT_MCP_USER_DATA_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/gh-img/userdata}"
playwright は persistent profile を、した
の
で
にします。
しないと、ある
で
た
が、
が scratch
に
て
ときには
なくなる。一度
て、
したはずなのに「
していない」と
れて、ようやく
た
でした。
した
は、cwd がどこであっても
を
ます。
三つめ。送らずに帰る、という
これがいちばんで、いちばん
でした。URL を
たら、
欄に
た
を
して、
を
。
しない。
は URL を
に来ただけだからです。
cleanup() { pw close >/dev/null 2>&1; rm -rf "$scratch/.playwright-cli"; }
trap cleanup EXIT INT TERM
trap を EXIT INT TERM のどれにもてあるので、
の
で Ctrl-C を
ても、
の
だけは
ます。
のは
で、cookie profile は
。次に
だときに、また
--login させずにようにです。「
けれど
ない」「
けれど
ない」。
た
を
ときの
は、
この
に
ていました。
道具はこれで全部
ていったら、
たのはこの一枚でした。
た
を
に
ための
は、
この
に
ます。
~/.config/gh/bin/gh-img に置いて、gh の config から img というで
でいるだけです。
はすべて
$HOME と $XDG_* でてあるので、
の
にそのまま
ていっても
ます。
の
をするなら、
は
こそ
です。さっきの三つの
は、この一枚のどこに
ているか。
でしまうと、
が
で
を
ための
が
ます。だから
まず、ここにそのまま置きます。180
、
でそのまま
です。
#!/usr/bin/env bash
#
# gh-img: upload an image to GitHub user-attachments and print its URL.
#
# Usage:
# gh img <image> <issue-or-pr-url> # upload; prints the user-attachments URL
# gh img <image> <owner/repo> # same, via that repo's new-issue composer
# gh img --login [url] # one-time interactive (headed) login
#
# Drives a persistent, authenticated headless Chromium (via playwright-cli) to
# drop the image onto a real comment composer on the given page, then reads the
# https://github.com/user-attachments/assets/<uuid> URL that GitHub inserts —
# WITHOUT submitting any comment. The asset inherits the visibility of the repo
# whose composer it is dropped on, so private-repo uploads stay auth-gated. This
# is GitHub's OWN storage, not a third-party public host.
#
# Why a real browser and not raw HTTP: the upload-policy endpoint
# (POST /upload/policies/assets) is web-UI only — a PAT returns 422; it needs the
# user_session cookie + CSRF token. Letting the live composer perform the upload
# lets the browser mint/send the correct token; we just read back the URL. If an
# official upload API ever ships, migrate this to "gh api" and drop playwright.
#
# Why a config file (not the PLAYWRIGHT_MCP_BROWSER env var) selects the browser:
# the "chromium" channel keeps the persistent login profile, whereas
# PLAYWRIGHT_MCP_BROWSER=chromium resolves to chrome-for-testing, which uses a
# DIFFERENT cookie store and silently drops the login. So browser selection is
# the one setting that genuinely needs the config file; everything else is env.
#
# Requires:
# - playwright-cli (brew install playwright-cli)
# - a Chromium under PLAYWRIGHT_BROWSERS_PATH (playwright-cli install-browser chromium)
# - the "gh img" alias (shipped in this repo's gh/config.yml); or call by path
# Session cookies live in a dedicated profile under XDG_STATE_HOME (see
# PLAYWRIGHT_MCP_USER_DATA_DIR below); they are account-equivalent secrets —
# never committed, 0700. Run "gh img --login" once per machine. Single-run tool:
# a fixed session/scratch means do not invoke it concurrently with itself (a
# second run's close would kill the first's browser).
set -u
set -o pipefail
session="ghimg"
# Keep playwright XDG-clean even when invoked from a context lacking the env vars.
export PLAYWRIGHT_DAEMON_SESSION_DIR="${PLAYWRIGHT_DAEMON_SESSION_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/playwright-cli}"
export PLAYWRIGHT_BROWSERS_PATH="${PLAYWRIGHT_BROWSERS_PATH:-${XDG_DATA_HOME:-$HOME/.local/share}/ms-playwright}"
# Pin the login cookie store to a fixed, cwd-independent path (a dedicated gh-img
# profile). Without this, playwright keys the persistent profile by the workspace
# (cwd) hash, so a login done from one directory would be invisible when the
# script later runs from its scratch dir. STATE tier; account-equivalent secret.
export PLAYWRIGHT_MCP_USER_DATA_DIR="${PLAYWRIGHT_MCP_USER_DATA_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/gh-img/userdata}"
mkdir -p "$PLAYWRIGHT_MCP_USER_DATA_DIR" 2>/dev/null && chmod 700 "$PLAYWRIGHT_MCP_USER_DATA_DIR" 2>/dev/null
# Browser-selection config — kept in XDG_CONFIG_HOME (never $HOME), self-created
# so the script is portable to a fresh machine. Referenced via --config because
# its name isn't the cwd-walk default (.playwright/cli.config.json).
pw_config="${XDG_CONFIG_HOME:-$HOME/.config}/playwright-cli/cli.config.json"
# Contain playwright's per-call ".playwright-cli/" snapshot dir so it never lands
# in the caller's cwd (e.g. a git repo). Cache dir, outside any repo.
scratch="${XDG_CACHE_HOME:-$HOME/.cache}/gh-img"
pw() { playwright-cli -s="$session" "$@"; }
die() { printf 'gh-img: %s\n' "$1" >&2; exit "${2:-1}"; }
# Read one scalar value from a --raw eval (value is always the last output line).
pweval() { pw --raw eval "$1" 2>/dev/null | tail -1; }
# Closes the browser (keeps the persistent cookie profile) and clears snapshots.
# Wired to EXIT/INT/TERM so a Ctrl-C mid-poll never leaks the browser daemon.
cleanup() { pw close >/dev/null 2>&1; rm -rf "$scratch/.playwright-cli"; }
# GitHub has two composer shapes and no single attribute covers both:
# 1. The new-issue React body editor advertises "Markdown" via its aria-label
# ("Markdown value") or placeholder ("Use Markdown to format your comment").
# 2. The classic issue/PR *comment* box (conversation pages, incl. merged/closed
# PRs) advertises Markdown NOWHERE — its placeholder is a bare " " — but it
# carries the stable id="new_comment_field". The owner/repo route only ever
# hits shape 1, so shape 2 went unnoticed until an image was dropped onto a
# PR/issue URL directly (then: "no comment composer found", exit 4).
# Match on either signal. The drop CSS selector below mirrors this exact set with
# the same first-in-DOM-order pick, so drop and poll always target the SAME
# element (the hidden survey "feedback" and inline-diff "comment[body]" textareas
# advertise none of these signals and are excluded).
finder="[...document.querySelectorAll('textarea')].find(x => /Markdown/i.test(x.placeholder||'') || /Markdown/i.test(x.getAttribute('aria-label')||'') || x.id === 'new_comment_field')"
command -v playwright-cli >/dev/null 2>&1 || die "playwright-cli not found (brew install playwright-cli)" 127
ensure_config() {
[ -f "$pw_config" ] && return 0
mkdir -p "$(dirname "$pw_config")" || return 1
printf '%s\n' '{ "browser": { "browserName": "chromium", "launchOptions": { "channel": "chromium", "headless": true } } }' > "$pw_config"
chmod 600 "$pw_config"
}
# --- login mode -------------------------------------------------------------
if [ "${1:-}" = "--login" ]; then
url="${2:-https://github.com/login}"
ensure_config || die "could not write $pw_config" 1
mkdir -p "$scratch"; cd "$scratch" || die "cannot enter scratch dir $scratch" 1
trap cleanup EXIT INT TERM
pw open --persistent --headed --config "$pw_config" "$url" || die "could not open browser" 1
printf 'gh-img: a browser window is open. Log in to GitHub (incl. 2FA), then press Enter here... ' >&2
read -r _
printf 'gh-img: session saved.\n' >&2
exit 0
fi
# --- argument parsing -------------------------------------------------------
image="${1:-}"
target="${2:-}"
{ [ -n "$image" ] && [ -n "$target" ]; } || die "usage: gh img <image> <issue-or-pr-url> | gh img --login" 2
[ -f "$image" ] || die "image not found: $image" 2
# resolve absolute image path (relative to caller's cwd, BEFORE we cd to scratch)
img_dir=$(cd "$(dirname "$image")" 2>/dev/null && pwd) || die "cannot resolve directory of: $image" 2
abs_image="$img_dir/$(basename "$image")"
# normalize target: a github.com URL as-is, or owner/repo -> that repo's new-issue
# page. Reject every non-github host: the image is dropped onto whatever page
# loads, and the auth check only reads a page-controlled meta[name=user-login], so
# an untrusted URL carrying that meta + a "Markdown" textarea could receive the
# local file via the drop. Uploads are only meaningful on github.com
# (user-attachments), so gate on host BEFORE opening the page. The leading-slash
# in the accept pattern also rejects look-alikes (github.com.evil.com, github.com@evil.com).
case "$target" in
https://github.com/*) page_url="$target" ;;
http://*|https://*) die "target host must be github.com: $target" 2 ;;
*/*) page_url="https://github.com/${target}/issues/new" ;;
*) die "target must be a github.com issue/PR URL or owner/repo: $target" 2 ;;
esac
ensure_config || die "could not write $pw_config" 1
mkdir -p "$scratch"; cd "$scratch" || die "cannot enter scratch dir $scratch" 1
trap cleanup EXIT INT TERM
# --- open + auth check (retry: a slow load must not read as logged-out) ------
pw open --persistent --config "$pw_config" "$page_url" >/dev/null 2>&1 || die "could not open $page_url" 1
login=""
for _ in 1 2 3 4 5 6 7 8 9 10; do
login=$(pweval "() => (document.querySelector('meta[name=user-login]') || {}).content || ''" | tr -d '"')
[ -n "$login" ] && break
sleep 1
done
[ -n "$login" ] || die "not logged in (or session expired) — run: gh img --login" 3
# --- wait for the composer to hydrate (React) -------------------------------
ready=""
for _ in 1 2 3 4 5 6 7 8 9 10; do
[ "$(pweval "() => ($finder) ? '1' : ''" | tr -d '"')" = "1" ] && { ready=1; break; }
sleep 1
done
[ -n "$ready" ] || die "no comment composer found on $page_url" 4
# --- drop the image onto the composer (selector mirrors $finder: Markdown
# placeholder/aria-label OR id="new_comment_field", first-in-DOM-order) ---
# Note: "drop --path" reads the local file in playwright-cli's own Node process
# and hands it to the page as a synthesized DataTransfer — it is NOT gated by the
# allowUnrestrictedFileAccess config flag (that flag gates the @playwright/mcp
# server's browser_file_upload tool, a different mechanism). So $abs_image may
# live anywhere the caller can read, including outside this script's scratch cwd —
# which is exactly why the github.com host gate above (not a workspace boundary)
# is what protects against an untrusted target page receiving the file.
pw drop 'textarea[placeholder*="Markdown" i], textarea[aria-label*="Markdown" i], textarea#new_comment_field' --path "$abs_image" >/dev/null 2>&1 || die "drop onto composer failed" 5
# --- poll the composer for the inserted user-attachments URL ----------------
# Rely on the timeout, not a substring heuristic: GitHub shows an
# "![Uploading <filename>…]()" placeholder mid-upload, so matching on words like
# "failed" would misfire on a file literally named e.g. build-failed.png.
url=""
for _ in {1..20}; do
val=$(pweval "() => { const t = $finder; return t ? t.value : ''; }")
match=$(printf '%s' "$val" | grep -oiE 'https://github\.com/user-attachments/assets/[0-9a-f-]+' | head -1)
[ -n "$match" ] && { url="$match"; break; }
sleep 2
done
# Discard the unsent draft text; the EXIT trap then closes the browser.
pw --raw eval "() => { const t = $finder; if (t) { const s = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set; s.call(t, ''); t.dispatchEvent(new Event('input', { bubbles: true })); } }" >/dev/null 2>&1
[ -n "$url" ] || die "upload did not return a user-attachments URL (timed out or rejected)" 6
printf '%s\n' "$url"
三つのは、この一枚に
ばっています。
の
は
case "$target" in の。
の
の
は
の
export PLAYWRIGHT_* 。
の
ずに
は、
の
cleanup との処理です。
は、
欄が
ある
を
する finder と、React の
を
です。
ブラウザの選び方だけは、設定ファイルが要る
だけ、
では
ない
があります。
の
の
です。
channel: "chromium" をで
と、persistent な
をそのまま
ます。ところが
PLAYWRIGHT_MCP_BROWSER=chromium というで
と、chrome-for-testing に
され、
の cookie store を
で、
が
に
ます。だからこの一点だけは、
でなければ
ません。
、
の
ensure_config が、ば
の
を
で
ます。だから
には、
で
なくても
ます。とはいえ
では、viewport や timeouts も
せたかったので、
のものを
~/.config/playwright-cli/cli.config.json に置いています。ている
は、やはり
channel: "chromium" です。
{
"$schema": "./cli.config.schema.json",
"browser": {
"browserName": "chromium",
"launchOptions": {
"channel": "chromium",
"headless": true
},
"contextOptions": {
"viewport": { "width": 1280, "height": 800 }
}
},
"timeouts": {
"action": 10000,
"navigation": 60000
}
}
手元に置くまでの手順
に
ものは、
一つと
一つ、そして
一行です。
に
ていけば、
で
が
ます。
まず playwright-cli をます。
brew install playwright-cli
に、
の
全文を
~/.config/gh/bin/gh-img にて、
を
ます。
mkdir -p ~/.config/gh/bin
# (上のスクリプト全文を ~/.config/gh/bin/gh-img に保存してから)
chmod +x ~/.config/gh/bin/gh-img
は、
の json を
~/.config/playwright-cli/cli.config.json にます。
なくても
ensure_config が版を
てくれますが、viewport まで
せたいなら
で
ほうが
です。
mkdir -p ~/.config/playwright-cli
# (上の cli.config.json を ~/.config/playwright-cli/cli.config.json に保存)
を
gh img というで
るように、
gh の config にを一行
ます。
~/.config/gh/config.yml の aliases: のに、こう書きます。
aliases:
img: '!~/.config/gh/bin/gh-img "$@"'
に、一度だけ
的に
します。headed な
が
ので、GitHub に
(2FA も)して、
で Enter を
だけです。
gh img --login
これでは
です。
の
は、
と
を
だけ。
gh img <画像のパス> <issue または PR の URL>
gh img ./shot.png owner/repo # その repo の新規 issue 作成画面で代用
だときのために、
も
を
せてあります。
die のがそのまま exit code です。
| code | 意味 |
|---|---|
2 | 引数不正・ファイル無し・github.com 以外のホスト |
3 | 未ログイン(またはセッション切れ)→ gh img --login |
4 | そのページにコメント欄が見つからない |
5 | コメント欄へのドロップに失敗 |
6 | user-attachments の URL が返らなかった(タイムアウト等) |
127 | playwright-cli が見つからない(brew install playwright-cli) |
やってみて分かったこと
には、まだ
があります。
欄が
あって、
issue の React
と、
の
id="new_comment_field" のどちらにも finder を
があったり。
の
を
ではなく
で
ないと、
の cookie store に
て
が
たり。そういう
は、
を
だけでは
てこなくて、一度
て
でから
ました。
ただ、て
のは、
の
ではありませんでした。
てみて
たのは、
済みの
を
は、
ことよりも
ことで
が
、という一点だった
がします。
を
一行。
を
に
こと。
だら
ずに
こと。この三つを
ておけば、
た
は
に
る。
とはいえ、これは gh img という一つので
がそう
た、というだけのことです。
た
をどう
かは、
ものによって
の
も
はず。
を
の
に
ときは、
の
でもう一度、いちばん
から
てみてもいいかもしれません。
ちなみにこのブログ自身、CSS とクライアント側の JavaScript を原則持たないというで
ています。
の
も、
の
も、
ないものを
に
ておくと、
たものの
がはっきりする。
では、そんなふうに
ました。