From 0051703a300169aa03fa90bdd245f7642638d1f4 Mon Sep 17 00:00:00 2001 From: shilpijc Date: Thu, 30 Jul 2026 21:33:45 +0530 Subject: [PATCH] Publish as @shilpi1958/pr-explainer with a global profile and clearer errors. Bare npm name was taken; scoped package plus pr-explainer init (~/.pr-explainer) so the CLI works from any directory, with actionable gh/claude failure messages for launch. Co-authored-by: Cursor --- DESIGN_BRIEF.md | 20 +++---- LAUNCH.md | 28 +++++++-- README.md | 60 +++++++++++++++---- action.yml | 2 +- package-lock.json | 8 +-- package.json | 27 ++++++++- src/claude.js | 35 ++++++++++- src/cli.js | 86 +++++++++++++++++++++++---- src/github.js | 51 ++++++++++++++-- templates/learning-profile.example.md | 5 +- 10 files changed, 267 insertions(+), 55 deletions(-) diff --git a/DESIGN_BRIEF.md b/DESIGN_BRIEF.md index 85a072e..4c4f2cf 100644 --- a/DESIGN_BRIEF.md +++ b/DESIGN_BRIEF.md @@ -47,11 +47,13 @@ interrupt an engineer to translate for you. ## How it actually works (for an accurate "how it works" section — don't invent steps) -1. The user writes a short `learning-profile.md` once — their role, - and what they're currently trying to understand better. Not a +1. The user runs `pr-explainer init` once — it creates + `~/.pr-explainer/learning-profile.md`. They edit it with their + role and what they're currently trying to understand better. Not a skills checklist, just a couple of sentences. (e.g. "Product analyst, don't read code, need to explain shipped changes to stakeholders" or "Backend engineer, 2 years, new to frontend.") + That one file applies everywhere they run the CLI. 2. They run one command against any merged PR — a bare number, a full GitHub URL, or `owner/repo#123`: ``` @@ -69,12 +71,10 @@ interrupt an engineer to translate for you. ## The key differentiator (must appear prominently — this is the actual hook) -**No API key, no signup, no billing.** Most AI PR-summarizer tools -require you to bring your own API key and pay per call. pr-explainer -instead shells out to the Claude Code CLI already on the user's -machine — so if they already use Claude Code (subscription or key, -either works), this is free to try immediately. Nothing new to sign up -for. +**No API key, no signup, no billing.** pr-explainer shells out to the +Claude Code CLI already on the user's machine — so if they already use +Claude Code (subscription or key, either works), this is free to try +immediately. Nothing new to sign up for, nothing to pay per call. Secondary differentiators: - Works on **any repo**, not just ones you own — point it at a URL @@ -116,7 +116,7 @@ Avoid generic robot/AI-brain iconography — this product is about ## Landing page — sections to include 1. **Hero:** tagline + one-line explanation + the install command - (`npm install -g pr-explainer`) + primary CTA linking to GitHub repo. + (`npm install -g @shilpi1958/pr-explainer`) + primary CTA linking to GitHub repo. 2. **The thesis:** short version of "code is the source of truth, this removes the translation barrier" — 2-3 sentences, not a manifesto. 3. **How it works:** the 4 steps above, ideally with a real example @@ -125,7 +125,7 @@ Avoid generic robot/AI-brain iconography — this product is about 4. **Who it's for:** the three scenarios above, as short cards/sections. 5. **Why no API key:** the differentiator, explained briefly. 6. **Install / quickstart:** copy-pasteable commands, matching the - README (`npm install -g pr-explainer`, copy the profile template, + README (`npm install -g @shilpi1958/pr-explainer`, `pr-explainer init`, run one command). 7. **Footer:** link to GitHub repo, license (MIT), maybe a link to Product Hunt listing once live. diff --git a/LAUNCH.md b/LAUNCH.md index 529ee13..273ab55 100644 --- a/LAUNCH.md +++ b/LAUNCH.md @@ -77,7 +77,7 @@ Code is the source of truth. Now anyone can read it. > and logged in (subscription or key), pr-explainer rides on that — > nothing new to sign up for, no separate billing. > -> Free, open source, self-hosted. `npm install -g pr-explainer`. +> Free, open source, self-hosted. `npm install -g @shilpi1958/pr-explainer`. **First maker comment (post immediately after launch):** @@ -139,11 +139,12 @@ Code is the source of truth. Now anyone can read it. **Thread (reply 4 — the differentiator):** -> Most PR-summarizer tools need you to bring an API key and pay -> per-call. This one shells out to your local `claude` CLI — so if -> you're already a Claude Code user, it's free to try, right now. +> No API key, no per-call billing. This shells out to your local +> `claude` CLI — so if you're already a Claude Code user, it's free +> to try, right now. > -> `npm install -g pr-explainer` +> `npm install -g @shilpi1958/pr-explainer` +> `pr-explainer init` # then edit ~/.pr-explainer/learning-profile.md **Thread (reply 5 — CTA + link):** @@ -165,3 +166,20 @@ and early Twitter traffic hitting the PH page while it's fresh helps that. - **Hacker News (Show HN):** title format `Show HN: pr-explainer – explain any GitHub PR in plain English, pitched to you`. HN audience is technical-skeptical — lead with the mechanism (shells out to `claude -p`, no separate API key) rather than the "code is source of truth" framing, which can read as overwrought to that crowd. Expect blunt feedback on the quiz feature's usefulness. - **r/programming or r/ExperiencedDevs:** only if genuinely participating, not just dropping a link — these subreddits penalize drive-by self-promotion. - **dev.to / Hashnode post:** "Code is the only source of truth that doesn't go stale — here's a tool that makes it readable by anyone" — good place for the longer narrative version of the maker comment, links back to PH/GitHub. + +--- + +## Pre-flight checklist (before PH goes live) + +Install note: the npm package is `@shilpi1958/pr-explainer` (the bare +name `pr-explainer` on npm is a different project). CLI command stays +`pr-explainer`. Profile lives at `~/.pr-explainer/learning-profile.md` +after `pr-explainer init` — one file, every directory. + +- [ ] `@shilpi1958/pr-explainer` is live on npm; `npm install -g` from a clean machine works +- [ ] `pr-explainer init` creates `~/.pr-explainer/learning-profile.md`; explaining a PR from `$HOME` works with no local profile +- [ ] GitHub Pages is serving `docs/index.html` with the scoped install + `init` commands +- [ ] PH gallery ready: install + init, a real generated explainer, quiz section visible +- [ ] PH + Twitter go live together (12:01am PT) — not Twitter-first +- [ ] Maker comment + Show HN title ready to paste +- [ ] Tag / release `v0.2.0` on GitHub so Action consumers have a clear version diff --git a/README.md b/README.md index 829512f..e25757a 100644 --- a/README.md +++ b/README.md @@ -12,28 +12,34 @@ input that changes is a one-time profile describing who's reading. ## How it works -1. You write a `learning-profile.md` once — your role and what you're - currently trying to understand better. Not a skills checklist, - just a couple of sentences. +1. Run `pr-explainer init` once — it creates + `~/.pr-explainer/learning-profile.md`. Edit it with your role and + what you're currently trying to understand better. Not a skills + checklist, just a couple of sentences. That one file applies + everywhere you run the CLI. 2. Point the CLI at any merged PR — a number, a URL, whatever `gh pr view` accepts. It reads the PR's diff and description, combines it with your profile, and asks Claude to write one plain-language explainer: what changed, why it was done this way, why it matters to you — followed by a couple of recall questions with answers. -3. The explainer lands in `docs/explainers/`, and gets added to a - running `index.md` of every PR you've explained so far. +3. The explainer lands in `~/.pr-explainer/explainers/`, and gets added + to a running `index.md` of every PR you've explained so far. ## Quick start (CLI) ```bash -npm install -g pr-explainer -cp node_modules/pr-explainer/templates/learning-profile.example.md ./learning-profile.md -# edit learning-profile.md to describe yourself +npm install -g @shilpi1958/pr-explainer +pr-explainer init +# edit ~/.pr-explainer/learning-profile.md to describe yourself -pr-explainer 42 -pr-explainer https://github.com/some-org/some-repo/pull/42 # works on any repo +pr-explainer 42 # current repo only +pr-explainer https://github.com/some-org/some-repo/pull/42 # any repo +pr-explainer some-org/some-repo#42 ``` +> **Note:** a bare number resolves against the GitHub repo of your current +> directory. To explain a PR elsewhere, pass the full URL or `owner/repo#N`. +> Only **merged** PRs are supported. No API key needed if you already have [Claude Code](https://claude.com/claude-code) installed and logged in — `pr-explainer` calls the local `claude` CLI, so it rides on whatever auth you already use there (subscription or key). Run `claude` @@ -82,14 +88,22 @@ jobs: No subscription? Pass `anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}` instead (Console API key, billed separately). -Every merge gets you a commit with a fresh explainer. +Every merge gets you a commit with a fresh explainer. Put a +`learning-profile.md` in the repo root for the Action (or pass +`profile-path`); explainers are written to `docs/explainers/` by default. ## Configuration +Profile lookup (first hit wins): + +1. `LEARNING_PROFILE` env +2. `./learning-profile.md` (repo override — useful for the Action) +3. `~/.pr-explainer/learning-profile.md` (default for local CLI) + | Env var | Default | Purpose | |---|---|---| -| `LEARNING_PROFILE` | `./learning-profile.md` | path to your profile | -| `EXPLAINER_DIR` | `./docs/explainers` | output directory | +| `LEARNING_PROFILE` | `~/.pr-explainer/learning-profile.md` | path to your profile | +| `EXPLAINER_DIR` | `~/.pr-explainer/explainers` | output directory | See [`templates/learning-profile.example.md`](templates/learning-profile.example.md) for the profile format. @@ -101,6 +115,26 @@ Anthropic API directly. If you already use Claude Code, you already have auth configured — nothing new to sign up for, no data leaving your machine except what `claude` itself sends. +## Troubleshooting + +| Symptom | Fix | +|---|---| +| `No profile found` | Run `pr-explainer init`, then edit `~/.pr-explainer/learning-profile.md` | +| `Could not resolve to a PullRequest` / PR not found | A bare number only looks in the **current** repo. Pass a URL or `owner/repo#42` | +| `PR … is not merged yet` | Only merged PRs are supported — pick one that already shipped | +| `claude -p failed` / spending cap / usage limit | Wait for the Claude Code reset, raise your cap, or retry later. Confirm `claude` works on its own | +| Claude Code CLI not found | Install from https://claude.com/claude-code and run `claude` once to log in | +| `gh` auth / forbidden errors | Run `gh auth login` | +| GitHub CLI not found | Install from https://cli.github.com/ | + +Quick sanity checks: + +```bash +gh auth status +claude -p --output-format text <<< "Say hi in one word" +pr-explainer --help +``` + ## License MIT diff --git a/action.yml b/action.yml index 890d3b7..35f89d9 100644 --- a/action.yml +++ b/action.yml @@ -39,7 +39,7 @@ runs: - name: Install pr-explainer and Claude Code CLI shell: bash run: | - npm install --no-save pr-explainer @anthropic-ai/claude-code + npm install --no-save @shilpi1958/pr-explainer @anthropic-ai/claude-code - name: Generate explainer id: generate diff --git a/package-lock.json b/package-lock.json index 4bd92e2..a8df2ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "pr-explainer", - "version": "0.2.0", + "name": "@shilpi1958/pr-explainer", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "pr-explainer", - "version": "0.2.0", + "name": "@shilpi1958/pr-explainer", + "version": "0.2.1", "license": "MIT", "bin": { "pr-explainer": "src/cli.js" diff --git a/package.json b/package.json index bb145dd..6b20749 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { - "name": "pr-explainer", - "version": "0.2.0", + "name": "@shilpi1958/pr-explainer", + "version": "0.2.1", "description": "Point it at any merged PR and get a plain explainer of what it did and why, pitched at you.", "type": "module", "bin": { - "pr-explainer": "./src/cli.js" + "pr-explainer": "src/cli.js" }, "files": [ "src", @@ -13,7 +13,28 @@ "scripts": { "start": "node src/cli.js" }, + "keywords": [ + "github", + "pull-request", + "pr", + "explainer", + "claude", + "cli", + "documentation" + ], + "author": "shilpi1958", + "repository": { + "type": "git", + "url": "git+https://github.com/shilpi1958/pr-explainer.git" + }, + "homepage": "https://github.com/shilpi1958/pr-explainer#readme", + "bugs": { + "url": "https://github.com/shilpi1958/pr-explainer/issues" + }, "license": "MIT", + "publishConfig": { + "access": "public" + }, "engines": { "node": ">=18" } diff --git a/src/claude.js b/src/claude.js index 751adc7..be3b582 100644 --- a/src/claude.js +++ b/src/claude.js @@ -1,5 +1,25 @@ import { execFile } from "node:child_process"; +function friendlyClaudeError(detail) { + const text = detail || ""; + const lower = text.toLowerCase(); + + if (/spending cap|usage limit|rate limit|quota/i.test(text)) { + return ( + `${text.trim()}\n\n` + + `Your Claude Code plan has hit its usage limit. Wait for the reset, ` + + `raise the cap in your Claude account, or try again later.` + ); + } + if (/not logged in|unauthorized|authentication|please run.*login|login required/i.test(lower)) { + return ( + `${text.trim()}\n\n` + + `Run \`claude\` once in your terminal to log in, then retry.` + ); + } + return text.trim() || "unknown error from claude CLI"; +} + export function runClaude(prompt, { model } = {}) { const args = ["-p", "--output-format", "text"]; if (model) args.push("--model", model); @@ -20,10 +40,21 @@ export function runClaude(prompt, { model } = {}) { ); return; } - reject(new Error(`claude -p failed: ${stderr || err.message}`)); + const detail = [stderr, stdout] + .map((s) => s?.trim()) + .filter(Boolean) + .join("\n"); + reject(new Error(`claude -p failed: ${friendlyClaudeError(detail || err.message)}`)); + return; + } + + const out = (stdout || "").trim(); + // Some limit/auth failures exit 0 but only print a short status line. + if (!out || (/spending cap|usage limit|rate limit|quota|not logged in/i.test(out) && !/^#\s/m.test(out))) { + reject(new Error(`claude -p failed: ${friendlyClaudeError(out || "empty response")}`)); return; } - resolve(stdout.trim()); + resolve(out); } ); child.stdin.write(prompt); diff --git a/src/cli.js b/src/cli.js index ed2d8a3..0126dbc 100755 --- a/src/cli.js +++ b/src/cli.js @@ -1,45 +1,103 @@ #!/usr/bin/env node -import { readFile, writeFile, mkdir, appendFile } from "node:fs/promises"; +import { readFile, writeFile, mkdir, appendFile, copyFile } from "node:fs/promises"; import { existsSync, readdirSync } from "node:fs"; import path from "node:path"; +import os from "node:os"; +import { fileURLToPath } from "node:url"; import { getPR, getPRDiff } from "./github.js"; import { buildPrompt } from "./prompt.js"; import { runClaude } from "./claude.js"; const MAX_DIFF_CHARS = 60_000; +const CONFIG_DIR = path.join(os.homedir(), ".pr-explainer"); +const GLOBAL_PROFILE = path.join(CONFIG_DIR, "learning-profile.md"); +const GLOBAL_EXPLAINERS = path.join(CONFIG_DIR, "explainers"); +const LOCAL_PROFILE = "learning-profile.md"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const TEMPLATE_PATH = path.join( + __dirname, + "..", + "templates", + "learning-profile.example.md" +); function usage() { console.error( `Usage: pr-explainer + pr-explainer init [--force] Explains a merged pull request in plain language, tailored to your -learning-profile.md — however technical or non-technical you are, and +learning profile — however technical or non-technical you are, and whether or not you wrote the PR yourself. Ends with a couple of quick recall questions so it's something you retain, not just read. - PR a PR number ("42"), a PR URL, or "owner/repo#42" - (a bare number resolves against the repo in your current directory) + PR a PR number ("42"), a PR URL, or "owner/repo#42" + (a bare number resolves against the repo in your current directory) + init create ~/.pr-explainer/learning-profile.md from the template + (use --force to overwrite an existing profile) Requires the Claude Code CLI ("claude") installed and logged in (subscription or API key — whatever you already use for \`claude\`), and the GitHub CLI ("gh") authenticated. +Profile lookup (first hit wins): + 1. LEARNING_PROFILE env + 2. ./learning-profile.md (repo override) + 3. ~/.pr-explainer/learning-profile.md (default) + Env: - LEARNING_PROFILE optional path to profile file (default: ./learning-profile.md) - EXPLAINER_DIR optional output dir (default: ./docs/explainers) + LEARNING_PROFILE optional path to profile file + EXPLAINER_DIR optional output dir (default: ~/.pr-explainer/explainers) ` ); } +function resolveProfilePath() { + if (process.env.LEARNING_PROFILE) return process.env.LEARNING_PROFILE; + if (existsSync(LOCAL_PROFILE)) return path.resolve(LOCAL_PROFILE); + return GLOBAL_PROFILE; +} + async function loadProfile() { - const profilePath = process.env.LEARNING_PROFILE || "learning-profile.md"; + const profilePath = resolveProfilePath(); if (!existsSync(profilePath)) { + if (process.env.LEARNING_PROFILE) { + throw new Error( + `No profile found at LEARNING_PROFILE=${profilePath}.\n` + + `Fix that path, or unset LEARNING_PROFILE and run \`pr-explainer init\`.` + ); + } + throw new Error( + `No profile found. Run \`pr-explainer init\`, then edit ` + + `${GLOBAL_PROFILE} to describe yourself.` + ); + } + const profile = await readFile(profilePath, "utf8"); + if (!profile.trim()) { throw new Error( - `No profile found at ${profilePath}. Copy templates/learning-profile.example.md ` + - `into your repo as learning-profile.md and fill it in.` + `Profile at ${profilePath} is empty. Add your role and what you're ` + + `trying to understand, then retry.` ); } - return readFile(profilePath, "utf8"); + return profile; +} + +async function initProfile(force = false) { + if (!existsSync(TEMPLATE_PATH)) { + throw new Error(`Template not found at ${TEMPLATE_PATH}`); + } + await mkdir(CONFIG_DIR, { recursive: true }); + if (existsSync(GLOBAL_PROFILE) && !force) { + console.error( + `Profile already exists at ${GLOBAL_PROFILE}\n` + + `Edit it in place, or re-run with --force to overwrite from the template.` + ); + return; + } + await copyFile(TEMPLATE_PATH, GLOBAL_PROFILE); + console.error(`Created ${GLOBAL_PROFILE}`); + console.error("Edit that file to describe your role, then run pr-explainer ."); } function nextEntryNumber(dir) { @@ -85,6 +143,12 @@ async function main() { return; } + if (arg === "init") { + const force = process.argv.includes("--force"); + await initProfile(force); + return; + } + const profile = await loadProfile(); const pr = await getPR(arg); @@ -103,7 +167,7 @@ async function main() { const titleMatch = entry.match(/^#\s+(.+)$/m); const title = titleMatch ? titleMatch[1] : pr.title; - const outDir = process.env.EXPLAINER_DIR || path.join("docs", "explainers"); + const outDir = process.env.EXPLAINER_DIR || GLOBAL_EXPLAINERS; await mkdir(outDir, { recursive: true }); const num = nextEntryNumber(outDir); const filename = `${num}-${slugify(title)}.md`; diff --git a/src/github.js b/src/github.js index 2b942ec..e5d3976 100644 --- a/src/github.js +++ b/src/github.js @@ -3,14 +3,53 @@ import { promisify } from "node:util"; const execFileAsync = promisify(execFile); +function friendlyGhError(args, err) { + const detail = (err.stderr || err.message || "").trim(); + const lower = detail.toLowerCase(); + const cmd = `gh ${args.join(" ")}`; + + if (err.code === "ENOENT") { + return ( + "GitHub CLI (`gh`) not found. Install it from https://cli.github.com/ " + + "and run `gh auth login`, then try again." + ); + } + + if (/could not resolve to a pullrequest|could not find|no pull requests found|not found/i.test(detail)) { + return ( + `No pull request found for that reference.\n` + + `A bare number (e.g. \`42\`) only looks in the repo of your current directory.\n` + + `From anywhere, pass a URL or \`owner/repo#42\`:\n` + + ` pr-explainer https://github.com/owner/repo/pull/42\n` + + ` pr-explainer owner/repo#42\n\n` + + `(${cmd})\n${detail}` + ); + } + + if (/not a git repository|failed to run git|no git remotes/i.test(lower)) { + return ( + `A bare PR number needs a git repo with a GitHub remote in the current directory.\n` + + `Pass a full URL instead, or \`cd\` into the repo first.\n\n` + + `(${cmd})\n${detail}` + ); + } + + if (/auth|login|http 401|http 403|forbidden|bad credentials/i.test(lower)) { + return ( + `GitHub auth failed. Run \`gh auth login\` and retry.\n\n` + + `(${cmd})\n${detail}` + ); + } + + return `gh command failed: ${cmd}\n${detail}`; +} + async function gh(args) { try { const { stdout } = await execFileAsync("gh", args); return stdout; } catch (err) { - throw new Error( - `gh command failed: gh ${args.join(" ")}\n${err.stderr || err.message}` - ); + throw new Error(friendlyGhError(args, err)); } } @@ -27,7 +66,11 @@ export async function getPR(prRef) { ]); const pr = JSON.parse(json); if (!pr.mergedAt) { - throw new Error(`PR ${prRef} is not merged yet.`); + throw new Error( + `PR ${prRef} is not merged yet.\n` + + `pr-explainer only explains merged PRs (past changes that actually shipped).\n` + + `Pick a merged PR, or wait until this one lands.` + ); } return pr; } diff --git a/templates/learning-profile.example.md b/templates/learning-profile.example.md index 93e0c1a..ef9cb7a 100644 --- a/templates/learning-profile.example.md +++ b/templates/learning-profile.example.md @@ -1,6 +1,7 @@