claude-plan-review 0.1.0 β†’ 0.2.0

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.
package/README.md CHANGED
@@ -24,6 +24,9 @@ Git worktrees are scoped separately automatically.
24
24
  - πŸ”€ **Diff** current vs any previous version β€” **side-by-side or unified** (toggle)
25
25
  - βœ… **Approve / request-changes** straight from the UI, fed back to Claude
26
26
  - πŸ—‚ Per-worktree scoping; per-project opt-in (only runs where you enable the hook)
27
+ - πŸ“€ **Save a plan to a storage channel** β€” push the plan markdown to a secret
28
+ **GitHub Gist** (one gist per project, overwritten on each save; GitHub keeps
29
+ the gist's revision history). More channels can be added later.
27
30
  - ⚑ Tiny, zero-framework UI; Bun server; one dependency (`marked`)
28
31
 
29
32
  ## Requirements
@@ -32,6 +35,9 @@ Git worktrees are scoped separately automatically.
32
35
  tool is plain ESM JavaScript and runs unchanged on both. `init` auto-detects which
33
36
  you have (preferring Bun) and writes the matching hook command; override with `--runtime`.
34
37
  - Claude Code β‰₯ 2.1 (verified against 2.1.185)
38
+ - *(Optional, only for saving to a Gist)* the [GitHub CLI](https://cli.github.com)
39
+ `gh`, logged in (`gh auth login`) with the `gist` scope. The tool reuses your
40
+ existing `gh` session β€” it never asks for or stores a token of its own.
35
41
 
36
42
  ## Setup
37
43
 
@@ -54,13 +60,25 @@ any machine β€” nothing to keep in your repo but the one settings entry.
54
60
  ### Option B β€” from a local clone (for hacking on it)
55
61
 
56
62
  ```bash
57
- git clone https://github.com/pavlo-petrychenko/claude-plan-preview-local
63
+ git clone https://github.com/pavlo-petrychenko/claude-plan-review
58
64
  cd claude-plan-review
59
65
  bun install # or: npm install
60
66
  bun src/cli.js init /path/to/your/project # …or…
61
67
  node src/cli.js init /path/to/your/project # writes an absolute-path hook command
62
68
  ```
63
69
 
70
+ ### All projects at once (global)
71
+
72
+ Install once, enable everywhere β€” the hook goes in your user-level `~/.claude/settings.json`:
73
+
74
+ ```bash
75
+ npm install -g claude-plan-review # or: bun add -g claude-plan-review
76
+ claude-plan-review init --global
77
+ ```
78
+
79
+ This fires the review for plans in **every** project, with no per-project setup. Don't also
80
+ run a per-project `init` in the same project, or the hook will fire twice.
81
+
64
82
  ### Either way
65
83
 
66
84
  **Reopen the `/hooks` menu once (or restart Claude Code)** in that project so the
@@ -76,6 +94,7 @@ Use `bun` or `node` interchangeably (or `bunx`/`npx claude-plan-review …` when
76
94
  | `… cli.js init [dir] [--local] [--published] [--runtime bun\|node]` | Wire the `ExitPlanMode` hook into a project |
77
95
  | `… cli.js serve [port]` | Start the review server manually (default `4607`) |
78
96
  | `… cli.js stop` | Stop the running server |
97
+ | `… cli.js channels` | Show storage-channel readiness (is `gh` installed / authed / `gist`-scoped?) |
79
98
 
80
99
  The server auto-starts on the first plan and stays up, so you can browse history
81
100
  anytime at `http://localhost:4607`.
@@ -90,6 +109,28 @@ anytime at `http://localhost:4607`.
90
109
 
91
110
  > The hook entry sets `timeout: 1800` so Claude Code waits while you review.
92
111
 
112
+ ## Storage channels (save a plan out of the local store)
113
+
114
+ Each plan version lives only on your machine by default. A **storage channel**
115
+ lets you push the plan markdown somewhere shareable. Today there's one channel β€”
116
+ **GitHub Gist** β€” and the registry is built so more can be added later.
117
+
118
+ Click **Save to gist** in the header. The first save asks for a description and
119
+ file name and creates a **secret** (unlisted) gist from the version you're
120
+ viewing. There's **one gist per project**: saving any later version overwrites
121
+ that same gist (GitHub keeps the gist's own revision history), and you can rename
122
+ the description/file name on any save. The header then shows a link to the gist
123
+ and which version it currently holds.
124
+
125
+ Auth is delegated entirely to the GitHub CLI β€” if you've run `gh auth login`,
126
+ nothing else is needed and **no token is ever stored by this tool**. If `gh`
127
+ isn't installed, isn't logged in, or its token lacks the `gist` scope, the save
128
+ dialog tells you the exact command to fix it (e.g. `gh auth refresh -s gist`).
129
+ Run `claude-plan-review channels` to check readiness from the terminal.
130
+
131
+ > Note: the Gist API requires a **classic** `gh` token scope (`gist`);
132
+ > fine-grained tokens don't support gists. `gh auth login` handles this for you.
133
+
93
134
  ## How it works
94
135
 
95
136
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-plan-review",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Review, comment on, and approve/reject Claude Code plans in a local GitHub-style web UI β€” with persistent comments, version history, and diffs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -32,13 +32,13 @@
32
32
  "hooks",
33
33
  "exitplanmode"
34
34
  ],
35
- "homepage": "https://github.com/pavlo-petrychenko/claude-plan-preview-local#readme",
35
+ "homepage": "https://github.com/pavlo-petrychenko/claude-plan-review#readme",
36
36
  "repository": {
37
37
  "type": "git",
38
- "url": "git+https://github.com/pavlo-petrychenko/claude-plan-preview-local.git"
38
+ "url": "git+https://github.com/pavlo-petrychenko/claude-plan-review.git"
39
39
  },
40
40
  "bugs": {
41
- "url": "https://github.com/pavlo-petrychenko/claude-plan-preview-local/issues"
41
+ "url": "https://github.com/pavlo-petrychenko/claude-plan-review/issues"
42
42
  },
43
43
  "author": "pavlo.p@mate.academy",
44
44
  "license": "MIT"
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Thin wrapper around the GitHub CLI (`gh`). Runs on Bun or Node β‰₯18.
3
+ *
4
+ * We shell out to `gh` rather than holding our own token: if the user has
5
+ * already `gh auth login`'d, we store no secrets at all. All request bodies are
6
+ * piped via STDIN (never argv), so plan content / descriptions can't break
7
+ * argument parsing.
8
+ */
9
+ import { spawnSync } from "node:child_process";
10
+
11
+ function run(args, { input } = {}) {
12
+ try {
13
+ const r = spawnSync("gh", args, {
14
+ input: input ?? undefined,
15
+ encoding: "utf8",
16
+ // gh writes `auth status` to stderr; capture both streams.
17
+ });
18
+ return {
19
+ ok: r.status === 0,
20
+ status: r.status,
21
+ stdout: r.stdout || "",
22
+ stderr: r.stderr || "",
23
+ spawnError: r.error || null,
24
+ };
25
+ } catch (e) {
26
+ return { ok: false, status: null, stdout: "", stderr: "", spawnError: e };
27
+ }
28
+ }
29
+
30
+ /** Is `gh` on PATH at all? */
31
+ export function installed() {
32
+ return run(["--version"]).ok;
33
+ }
34
+
35
+ /**
36
+ * Readiness probe: { installed, authed, scopes:[...] }.
37
+ * `gh auth status` exits non-zero when not logged in; its human output (on
38
+ * stderr) carries a "Token scopes: 'gist', 'repo', …" line we parse.
39
+ */
40
+ export function status() {
41
+ if (!installed()) return { installed: false, authed: false, scopes: [] };
42
+ const r = run(["auth", "status"]);
43
+ const text = `${r.stdout}\n${r.stderr}`;
44
+ const authed = r.ok || /Logged in to/i.test(text);
45
+ let scopes = [];
46
+ const m = text.match(/Token scopes:\s*(.+)/i);
47
+ if (m) {
48
+ scopes = m[1]
49
+ .split(",")
50
+ .map((s) => s.trim().replace(/^['"]|['"]$/g, ""))
51
+ .filter(Boolean);
52
+ }
53
+ return { installed: true, authed, scopes };
54
+ }
55
+
56
+ /**
57
+ * Call the GitHub REST API through gh, sending `body` as JSON on stdin.
58
+ * Returns { ok, data, error, needsGistScope }.
59
+ */
60
+ export function api(method, path, body) {
61
+ const args = ["api", "-X", method.toUpperCase(), path];
62
+ let input;
63
+ if (body !== undefined) {
64
+ args.push("--input", "-");
65
+ input = JSON.stringify(body);
66
+ }
67
+ const r = run(args, { input });
68
+ if (r.ok) {
69
+ try {
70
+ return { ok: true, data: JSON.parse(r.stdout || "null") };
71
+ } catch {
72
+ return { ok: true, data: null };
73
+ }
74
+ }
75
+ const errText = `${r.stderr}${r.stdout}`.trim();
76
+ // gh surfaces a "(HTTP 404)"/"(HTTP 403)" + scope hint when the token can't touch gists
77
+ const needsGistScope =
78
+ /gist/i.test(errText) && /(scope|HTTP 40[34]|Not Found|requires)/i.test(errText);
79
+ return {
80
+ ok: false,
81
+ error: errText || (r.spawnError ? String(r.spawnError.message || r.spawnError) : "gh failed"),
82
+ needsGistScope,
83
+ };
84
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * GitHub Gist storage channel. Stores the plan markdown (only) in a secret
3
+ * gist owned by the user's `gh`-authenticated account. One gist per project:
4
+ * the first save creates it, later saves PATCH the same gist (GitHub keeps the
5
+ * gist's own revision history automatically).
6
+ */
7
+ import * as gh from "./gh.js";
8
+
9
+ export const id = "gist";
10
+ export const label = "GitHub Gist";
11
+
12
+ const REFRESH_CMD = "gh auth refresh -s gist";
13
+ const LOGIN_CMD = "gh auth login";
14
+
15
+ /** Readiness for the UI: can we create a gist right now, and if not, how to fix it. */
16
+ export function available() {
17
+ const s = gh.status();
18
+ if (!s.installed)
19
+ return {
20
+ ready: false,
21
+ installed: false,
22
+ authed: false,
23
+ hasGistScope: false,
24
+ reason: "GitHub CLI (gh) is not installed.",
25
+ fixCommand: null,
26
+ fixUrl: "https://cli.github.com",
27
+ };
28
+ if (!s.authed)
29
+ return {
30
+ ready: false,
31
+ installed: true,
32
+ authed: false,
33
+ hasGistScope: false,
34
+ reason: "Not logged in to GitHub.",
35
+ fixCommand: LOGIN_CMD,
36
+ };
37
+ const hasGistScope = s.scopes.includes("gist");
38
+ return {
39
+ ready: hasGistScope,
40
+ installed: true,
41
+ authed: true,
42
+ hasGistScope,
43
+ reason: hasGistScope ? null : "Your gh token is missing the 'gist' scope.",
44
+ fixCommand: hasGistScope ? null : REFRESH_CMD,
45
+ scopes: s.scopes,
46
+ };
47
+ }
48
+
49
+ function ghError(res) {
50
+ const e = new Error(
51
+ res.needsGistScope
52
+ ? `GitHub rejected the gist write β€” your gh token likely lacks the 'gist' scope. Run: ${REFRESH_CMD}`
53
+ : res.error || "gh request failed",
54
+ );
55
+ e.fixCommand = res.needsGistScope ? REFRESH_CMD : null;
56
+ return e;
57
+ }
58
+
59
+ /** Create a new secret gist. Returns the binding info we persist. */
60
+ export function create({ markdown, description, filename }) {
61
+ const file = filename || "plan.md";
62
+ const res = gh.api("POST", "/gists", {
63
+ description: description || "",
64
+ public: false,
65
+ files: { [file]: { content: markdown || "\n" } },
66
+ });
67
+ if (!res.ok) throw ghError(res);
68
+ return {
69
+ id: res.data.id,
70
+ htmlUrl: res.data.html_url,
71
+ filename: file,
72
+ description: description || "",
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Overwrite an existing gist with new content (and optionally a new
78
+ * description / filename). `oldFilename` is the name currently in the gist so
79
+ * we can rename rather than orphan it.
80
+ */
81
+ export function update({ id: gistId, markdown, description, filename, oldFilename }) {
82
+ const newName = filename || oldFilename || "plan.md";
83
+ const fileKey = oldFilename || newName;
84
+ const filePatch = { content: markdown || "\n" };
85
+ if (newName !== fileKey) filePatch.filename = newName;
86
+ const res = gh.api("PATCH", `/gists/${gistId}`, {
87
+ description: description ?? "",
88
+ files: { [fileKey]: filePatch },
89
+ });
90
+ if (!res.ok) throw ghError(res);
91
+ return {
92
+ id: res.data.id,
93
+ htmlUrl: res.data.html_url,
94
+ filename: newName,
95
+ description: res.data.description ?? description ?? "",
96
+ };
97
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Storage-channel registry. A channel lets the user push a plan out of the
3
+ * local store into some shareable location. Gist is the only channel today;
4
+ * the registry keeps adding more (S3, Drive, …) a matter of dropping in a
5
+ * module with the same shape.
6
+ *
7
+ * Channel contract:
8
+ * id, label β€” identity
9
+ * available() β†’ { ready, ...flags } β€” can we use it now, and how to fix it
10
+ * create({markdown, description, filename}) β†’ binding info
11
+ * update({id, markdown, description, filename, oldFilename}) β†’ binding info
12
+ */
13
+ import * as gist from "./gist.js";
14
+
15
+ export const channels = { [gist.id]: gist };
16
+
17
+ export function getChannel(id) {
18
+ return channels[id] || null;
19
+ }
20
+
21
+ /** Readiness of every channel, for the picker / verify UI. */
22
+ export function listChannelStatus() {
23
+ return Object.values(channels).map((ch) => ({ id: ch.id, label: ch.label, ...ch.available() }));
24
+ }
package/src/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawnSync } from "node:child_process";
3
3
  import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs";
4
+ import { homedir } from "node:os";
4
5
  import { dirname, join, resolve } from "node:path";
5
6
  import { fileURLToPath } from "node:url";
6
7
  import { DEFAULT_PORT, SERVER_FILE } from "./paths.js";
@@ -34,6 +35,14 @@ function chooseRuntime(args) {
34
35
 
35
36
  function hookCommand(args) {
36
37
  const runtime = chooseRuntime(args);
38
+ // global install β†’ use the bare `claude-plan-review` command (resolved via PATH at hook time).
39
+ // We intentionally do NOT hardcode an absolute path: version managers like fnm/nvm expose
40
+ // bins via per-shell paths that don't persist across sessions.
41
+ if (args.includes("--global")) {
42
+ if (canRun("claude-plan-review")) return "claude-plan-review hook";
43
+ // not on PATH β†’ fall back to the package runner
44
+ return runtime === "node" ? "npx claude-plan-review hook" : "bunx claude-plan-review hook";
45
+ }
37
46
  if (args.includes("--published") || args.includes("--bunx")) {
38
47
  // portable command using the runtime's package runner
39
48
  return runtime === "node" ? "npx claude-plan-review hook" : "bunx claude-plan-review hook";
@@ -50,10 +59,12 @@ function readJSON(path, fallback) {
50
59
  }
51
60
 
52
61
  function cmdInit(args) {
62
+ const global = args.includes("--global");
53
63
  const local = args.includes("--local");
54
64
  const dirArg = args.find((a) => !a.startsWith("--") && a !== chooseRuntime(args));
55
- const projectDir = resolve(dirArg || process.cwd());
56
- const settingsFile = join(projectDir, ".claude", local ? "settings.local.json" : "settings.json");
65
+ const settingsFile = global
66
+ ? join(homedir(), ".claude", "settings.json")
67
+ : join(resolve(dirArg || process.cwd()), ".claude", local ? "settings.local.json" : "settings.json");
57
68
  mkdirSync(dirname(settingsFile), { recursive: true });
58
69
 
59
70
  const settings = readJSON(settingsFile, {});
@@ -79,7 +90,7 @@ function cmdInit(args) {
79
90
  }
80
91
  console.log(`\nRuntime: ${chooseRuntime(args)}\nCommand: ${command}`);
81
92
  console.log(
82
- `\nReopen the /hooks menu once (or restart Claude Code) in this project so the new hook is picked up.\n` +
93
+ `\nReopen the /hooks menu once (or restart Claude Code) ${global ? "in any project" : "in this project"} so the new hook is picked up.\n` +
83
94
  `Then finish a plan in plan mode β€” your browser will open the review.`,
84
95
  );
85
96
  }
@@ -91,6 +102,27 @@ async function cmdServe(args) {
91
102
  console.log(`Open http://localhost:${port} (Ctrl+C to stop)`);
92
103
  }
93
104
 
105
+ async function cmdChannels() {
106
+ const { listChannelStatus } = await import("./channels/index.js");
107
+ const list = listChannelStatus();
108
+ if (!list.length) {
109
+ console.log("No storage channels registered.");
110
+ return;
111
+ }
112
+ console.log("Storage channels:\n");
113
+ for (const c of list) {
114
+ const mark = c.ready ? "βœ“" : "βœ—";
115
+ console.log(` ${mark} ${c.label} (${c.id})`);
116
+ if (!c.ready) {
117
+ if (c.reason) console.log(` ${c.reason}`);
118
+ if (c.fixCommand) console.log(` fix: ${c.fixCommand}`);
119
+ else if (c.fixUrl) console.log(` see: ${c.fixUrl}`);
120
+ } else if (c.scopes) {
121
+ console.log(` gh scopes: ${c.scopes.join(", ")}`);
122
+ }
123
+ }
124
+ }
125
+
94
126
  function cmdStop() {
95
127
  const info = readJSON(SERVER_FILE, null);
96
128
  if (!info?.pid) {
@@ -110,14 +142,16 @@ function usage() {
110
142
  console.log(`claude-plan-review β€” review Claude Code plans in your browser (runs on Bun or Node β‰₯18)
111
143
 
112
144
  Usage:
113
- claude-plan-review init [dir] [--local] [--published] [--runtime bun|node]
114
- Wire the ExitPlanMode hook into a project.
145
+ claude-plan-review init [dir] [--global] [--local] [--published] [--runtime bun|node]
146
+ Wire the ExitPlanMode hook into a project (or all projects).
115
147
  Runtime auto-detects (Bun if installed, else Node).
148
+ --global β†’ ~/.claude/settings.json (applies to ALL projects)
116
149
  --local β†’ .claude/settings.local.json
117
150
  --published β†’ portable bunx/npx command (after npm publish)
118
151
  --runtime β†’ force a runtime
119
152
  claude-plan-review serve [port] Start the review server (default ${DEFAULT_PORT})
120
153
  claude-plan-review stop Stop the running server
154
+ claude-plan-review channels Show storage-channel readiness (e.g. gh / gist)
121
155
  claude-plan-review hook (internal) the PreToolUse hook entry
122
156
  `);
123
157
  }
@@ -133,6 +167,9 @@ switch (sub) {
133
167
  case "stop":
134
168
  cmdStop();
135
169
  break;
170
+ case "channels":
171
+ await cmdChannels();
172
+ break;
136
173
  case "hook":
137
174
  await import("./hook.js");
138
175
  break;
package/src/server.js CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  deleteComment,
10
10
  getProjectMeta,
11
11
  getReview,
12
+ getStorage,
12
13
  getVersion,
13
14
  listComments,
14
15
  listProjects,
@@ -16,7 +17,9 @@ import {
16
17
  listVersions,
17
18
  pruneResolvedReviews,
18
19
  resolveReview,
20
+ setStorage,
19
21
  } from "./store.js";
22
+ import { getChannel, listChannelStatus } from "./channels/index.js";
20
23
 
21
24
  const HERE = dirname(fileURLToPath(import.meta.url));
22
25
  const UI_DIR = join(HERE, "ui");
@@ -115,6 +118,7 @@ async function handleApi(req, res, seg, url) {
115
118
 
116
119
  if (a === "health") return sendJSON(res, { ok: true });
117
120
  if (a === "version") return sendJSON(res, { version: pkg.version });
121
+ if (a === "channels" && !b) return sendJSON(res, listChannelStatus());
118
122
 
119
123
  if (a === "projects" && !b) return sendJSON(res, listProjects());
120
124
 
@@ -134,6 +138,11 @@ async function handleApi(req, res, seg, url) {
134
138
  });
135
139
  }
136
140
 
141
+ // /api/projects/:key/storage β†’ current channel bindings
142
+ if (a === "projects" && b && c === "storage" && !d) {
143
+ return sendJSON(res, getStorage(b));
144
+ }
145
+
137
146
  // /api/projects/:key/versions/:n ...
138
147
  if (a === "projects" && b && c === "versions" && d) {
139
148
  const key = b;
@@ -162,6 +171,10 @@ async function handleApi(req, res, seg, url) {
162
171
  if (e === "comments" && seg[5] && method === "DELETE") {
163
172
  return sendJSON(res, { ok: deleteComment(key, n, seg[5]) });
164
173
  }
174
+ // /api/projects/:key/versions/:n/save (POST) β†’ push this version to a storage channel
175
+ if (e === "save" && method === "POST") {
176
+ return await handleSave(req, res, key, n);
177
+ }
165
178
  }
166
179
 
167
180
  // /api/reviews ...
@@ -181,6 +194,47 @@ async function handleApi(req, res, seg, url) {
181
194
  return sendJSON(res, { error: "unknown endpoint" }, 404);
182
195
  }
183
196
 
197
+ /** POST /api/projects/:key/versions/:n/save β€” create or overwrite the channel's store. */
198
+ async function handleSave(req, res, key, n) {
199
+ const body = JSON.parse((await readBody(req)) || "{}");
200
+ const channelId = body.channel || "gist";
201
+ const channel = getChannel(channelId);
202
+ if (!channel) return sendJSON(res, { error: `unknown channel: ${channelId}` }, 400);
203
+
204
+ const v = getVersion(key, n);
205
+ if (!v) return sendJSON(res, { error: "no such version" }, 404);
206
+
207
+ const ready = channel.available();
208
+ if (!ready.ready) {
209
+ return sendJSON(
210
+ res,
211
+ { error: ready.reason || "channel not ready", fixCommand: ready.fixCommand ?? null },
212
+ 409,
213
+ );
214
+ }
215
+
216
+ const existing = getStorage(key)[channelId];
217
+ try {
218
+ const info = existing?.id
219
+ ? channel.update({
220
+ id: existing.id,
221
+ markdown: v.markdown,
222
+ description: body.description ?? existing.description ?? "",
223
+ filename: body.filename ?? existing.filename,
224
+ oldFilename: existing.filename,
225
+ })
226
+ : channel.create({
227
+ markdown: v.markdown,
228
+ description: body.description ?? "",
229
+ filename: body.filename,
230
+ });
231
+ const saved = setStorage(key, channelId, { ...info, savedVersion: n });
232
+ return sendJSON(res, { channel: channelId, savedVersion: n, ...saved });
233
+ } catch (e) {
234
+ return sendJSON(res, { error: String(e?.message || e), fixCommand: e?.fixCommand ?? null }, 502);
235
+ }
236
+ }
237
+
184
238
  // run directly: `node src/server.js [port]` (or bun)
185
239
  const invokedDirectly =
186
240
  process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
package/src/store.js CHANGED
@@ -42,6 +42,23 @@ export function getProjectMeta(key) {
42
42
  return existsSync(metaPath(key)) ? readJSON(metaPath(key), null) : null;
43
43
  }
44
44
 
45
+ // ---------- storage-channel bindings ----------
46
+ /** All channel bindings for a project, e.g. { gist: { id, htmlUrl, … } }. */
47
+ export function getStorage(key) {
48
+ return getProjectMeta(key)?.storage ?? {};
49
+ }
50
+
51
+ /** Persist (or update) the binding for one channel on a project. */
52
+ export function setStorage(key, channelId, info) {
53
+ const meta = getProjectMeta(key);
54
+ if (!meta) return null;
55
+ meta.storage ??= {};
56
+ meta.storage[channelId] = { ...meta.storage[channelId], ...info, savedAt: now() };
57
+ meta.updatedAt = now();
58
+ writeFileSync(metaPath(key), enc(meta));
59
+ return meta.storage[channelId];
60
+ }
61
+
45
62
  /**
46
63
  * Record a freshly-presented plan. De-dupes by content hash: an identical
47
64
  * re-presentation reuses the existing version (and keeps its comments),
@@ -82,6 +99,8 @@ export function recordPlan(opts) {
82
99
  updatedAt: now(),
83
100
  currentVersion: version,
84
101
  latestHash: hash,
102
+ // carry forward storage-channel bindings β€” they outlive individual versions
103
+ storage: meta?.storage ?? {},
85
104
  };
86
105
  }
87
106
  writeFileSync(metaPath(key), enc(meta));
package/src/ui/app.js CHANGED
@@ -6,7 +6,12 @@ const esc = (s) =>
6
6
 
7
7
  async function api(path, opts) {
8
8
  const res = await fetch(path, opts);
9
- if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || res.statusText);
9
+ if (!res.ok) {
10
+ const body = await res.json().catch(() => ({}));
11
+ const err = new Error(body.error || res.statusText);
12
+ err.fixCommand = body.fixCommand ?? null;
13
+ throw err;
14
+ }
10
15
  return res.status === 204 ? null : res.json();
11
16
  }
12
17
 
@@ -23,6 +28,8 @@ const state = {
23
28
  diffMode: "split",
24
29
  sel: { anchor: null, start: null, end: null }, // source line-range selection
25
30
  dragging: false,
31
+ storage: {}, // channel bindings for the current project, e.g. { gist: {...} }
32
+ channels: [], // channel readiness (loaded when the save modal opens)
26
33
  };
27
34
 
28
35
  function toast(msg) {
@@ -60,6 +67,7 @@ function showEmpty(msg) {
60
67
  $("panel-preview").classList.add("active");
61
68
  $("preview").innerHTML = `<div class="empty">${esc(msg)}</div>`;
62
69
  $("reviewActions").hidden = true;
70
+ $("saveActions").hidden = true;
63
71
  }
64
72
 
65
73
  async function loadProjects() {
@@ -80,6 +88,7 @@ async function selectProject(key, wantV) {
80
88
  $("projectSel").value = key;
81
89
  const { versions } = await api(`/api/projects/${encodeURIComponent(key)}`);
82
90
  state.versions = versions;
91
+ state.storage = await api(`/api/projects/${encodeURIComponent(key)}/storage`).catch(() => ({}));
83
92
  if (!versions.length) {
84
93
  showEmpty("This project has no plan versions yet.");
85
94
  return;
@@ -111,6 +120,7 @@ async function loadVersion(n) {
111
120
  renderSource();
112
121
  renderGeneral();
113
122
  updateCommentCount();
123
+ refreshSaveUI();
114
124
  await refreshReview();
115
125
  if (state.tab === "diff") loadDiff();
116
126
  }
@@ -358,6 +368,112 @@ async function resolve(decision) {
358
368
  toast(decision === "approve" ? "Plan approved β€” Claude will proceed." : "Sent back to Claude with your comments.");
359
369
  }
360
370
 
371
+ // ---------- storage / save ----------
372
+ function projectName() {
373
+ return state.projects?.find((p) => p.key === state.key)?.name || "plan";
374
+ }
375
+
376
+ function refreshSaveUI() {
377
+ const box = $("saveActions");
378
+ if (!state.version) {
379
+ box.hidden = true;
380
+ return;
381
+ }
382
+ box.hidden = false;
383
+ const g = state.storage?.gist;
384
+ const btn = $("saveBtn");
385
+ const link = $("savedLink");
386
+ if (g?.id) {
387
+ btn.textContent = state.version === g.savedVersion ? "Update gist" : `Save v${state.version} β†’ gist`;
388
+ link.hidden = false;
389
+ link.href = g.htmlUrl;
390
+ link.textContent = `gist Β· holds v${g.savedVersion}`;
391
+ } else {
392
+ btn.textContent = "Save to gist";
393
+ link.hidden = true;
394
+ }
395
+ }
396
+
397
+ function renderChannelStatus(ch) {
398
+ const box = $("channelStatus");
399
+ const confirm = $("saveConfirm");
400
+ if (!ch) {
401
+ box.className = "channelStatus warn";
402
+ box.textContent = "Could not read channel status.";
403
+ confirm.disabled = true;
404
+ return;
405
+ }
406
+ if (ch.ready) {
407
+ box.className = "channelStatus ok";
408
+ box.textContent = "βœ“ GitHub CLI ready β€” gist scope present.";
409
+ confirm.disabled = false;
410
+ } else {
411
+ box.className = "channelStatus warn";
412
+ const cmd = ch.fixCommand ? ` Run: ${ch.fixCommand}` : ch.fixUrl ? ` See ${ch.fixUrl}` : "";
413
+ box.textContent = `⚠ ${ch.reason || "Channel not ready."}${cmd}`;
414
+ confirm.disabled = true;
415
+ }
416
+ }
417
+
418
+ async function openSaveModal() {
419
+ const g = state.storage?.gist;
420
+ $("saveTitle").textContent = g?.id ? "Update gist" : "Save plan to a gist";
421
+ $("saveDesc").value = g?.description ?? `${projectName()} β€” plan`;
422
+ $("saveFilename").value = g?.filename ?? "plan.md";
423
+ $("saveHint").textContent = g?.id
424
+ ? `Overwrites your secret gist (currently holds v${g.savedVersion}) with v${state.version}. GitHub keeps the gist's revision history.`
425
+ : `Creates a secret (unlisted) gist with v${state.version}'s markdown. Only people with the link can see it.`;
426
+ $("saveModal").hidden = false;
427
+
428
+ const sel = $("saveChannel");
429
+ $("channelStatus").textContent = "Checking GitHub CLI…";
430
+ $("channelStatus").className = "channelStatus";
431
+ $("saveConfirm").disabled = true;
432
+ try {
433
+ state.channels = await api("/api/channels");
434
+ } catch {
435
+ state.channels = [];
436
+ }
437
+ sel.innerHTML = state.channels.map((c) => `<option value="${esc(c.id)}">${esc(c.label)}</option>`).join("");
438
+ const current = state.channels.find((c) => c.id === (sel.value || "gist")) || state.channels[0];
439
+ if (current) sel.value = current.id;
440
+ renderChannelStatus(current);
441
+ }
442
+
443
+ function closeSaveModal() {
444
+ $("saveModal").hidden = true;
445
+ }
446
+
447
+ async function doSave() {
448
+ const channel = $("saveChannel").value || "gist";
449
+ const description = $("saveDesc").value.trim();
450
+ const filename = $("saveFilename").value.trim() || "plan.md";
451
+ const btn = $("saveConfirm");
452
+ btn.disabled = true;
453
+ const prev = btn.textContent;
454
+ btn.textContent = "Saving…";
455
+ try {
456
+ const r = await api(
457
+ `/api/projects/${encodeURIComponent(state.key)}/versions/${state.version}/save`,
458
+ {
459
+ method: "POST",
460
+ headers: { "content-type": "application/json" },
461
+ body: JSON.stringify({ channel, description, filename }),
462
+ },
463
+ );
464
+ state.storage[channel] = r;
465
+ closeSaveModal();
466
+ refreshSaveUI();
467
+ toast(`Saved v${state.version} to ${channel}.`);
468
+ } catch (e) {
469
+ // surface the remediation command if the channel handed one back
470
+ renderChannelStatus({ ready: false, reason: e.message, fixCommand: e.fixCommand });
471
+ } finally {
472
+ btn.textContent = prev;
473
+ btn.disabled = false;
474
+ }
475
+ }
476
+
361
477
  // ---------- polling (reflect external resolution) ----------
362
478
  function pollLoop() {
363
479
  setInterval(async () => {
@@ -469,6 +585,12 @@ function wireUI() {
469
585
  $("generalInput").value = "";
470
586
  };
471
587
 
588
+ $("saveBtn").onclick = () => openSaveModal();
589
+ $("saveCancel").onclick = () => closeSaveModal();
590
+ $("saveConfirm").onclick = () => doSave();
591
+ $("saveChannel").onchange = () =>
592
+ renderChannelStatus(state.channels.find((c) => c.id === $("saveChannel").value));
593
+
472
594
  $("approveBtn").onclick = () => resolve("approve");
473
595
  $("rejectBtn").onclick = () => ($("rejectModal").hidden = false);
474
596
  $("rejectCancel").onclick = () => ($("rejectModal").hidden = true);
package/src/ui/index.html CHANGED
@@ -25,6 +25,10 @@
25
25
  <select id="versionSel"></select>
26
26
  </label>
27
27
  </div>
28
+ <div class="saveActions" id="saveActions" hidden>
29
+ <button class="btn" id="saveBtn">Save to gist</button>
30
+ <a class="savedlink" id="savedLink" href="#" target="_blank" rel="noopener" hidden></a>
31
+ </div>
28
32
  <div class="reviewActions" id="reviewActions" hidden>
29
33
  <span class="pill pending" id="reviewPill">● review pending</span>
30
34
  <button class="btn approve" id="approveBtn">Approve plan</button>
@@ -87,6 +91,27 @@
87
91
  </div>
88
92
  </div>
89
93
 
94
+ <div class="saveModal" id="saveModal" hidden>
95
+ <div class="box">
96
+ <h3 id="saveTitle">Save plan to a gist</h3>
97
+ <div class="channelStatus" id="channelStatus"></div>
98
+ <label class="field">Storage channel
99
+ <select id="saveChannel"></select>
100
+ </label>
101
+ <label class="field">Gist description
102
+ <input type="text" id="saveDesc" placeholder="Short description shown in your gist list" />
103
+ </label>
104
+ <label class="field">File name
105
+ <input type="text" id="saveFilename" placeholder="plan.md" />
106
+ </label>
107
+ <p class="hint" id="saveHint"></p>
108
+ <div class="row">
109
+ <button class="btn" id="saveCancel">Cancel</button>
110
+ <button class="btn primary" id="saveConfirm">Save</button>
111
+ </div>
112
+ </div>
113
+ </div>
114
+
90
115
  <div class="toast" id="toast" hidden></div>
91
116
  </div>
92
117
  <script src="/app.js"></script>
package/src/ui/style.css CHANGED
@@ -173,6 +173,25 @@ textarea {
173
173
  .rejectModal textarea { width: 100%; min-height: 80px; }
174
174
  .rejectModal .row { display: flex; justify-content: flex-end; gap: 10px; margin-top: 12px; }
175
175
 
176
+ /* save-to-channel control + modal */
177
+ .saveActions { display: flex; align-items: center; gap: 8px; }
178
+ .savedlink { font-size: 11px; color: var(--fg-muted); font-family: var(--mono); text-decoration: none; white-space: nowrap; }
179
+ .savedlink:hover { color: var(--accent); text-decoration: underline; }
180
+
181
+ .saveModal { position: fixed; inset: 0; background: #00000066; display: flex; align-items: center; justify-content: center; z-index: 50; }
182
+ .saveModal .box { background: var(--bg); border: 1px solid var(--border); border-radius: 10px; padding: 20px; width: 480px; max-width: 92vw; }
183
+ .saveModal h3 { margin-top: 0; }
184
+ .saveModal .field { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--fg-muted); margin-bottom: 12px; }
185
+ .saveModal .field input, .saveModal .field select { font-family: var(--sans); font-size: 13px; padding: 6px 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg); }
186
+ .saveModal .hint { color: var(--fg-muted); font-size: 12px; margin: 4px 0 0; }
187
+ .saveModal .row { display: flex; justify-content: flex-end; gap: 10px; margin-top: 16px; }
188
+ .saveModal .btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
189
+ .saveModal .btn.primary:hover { filter: brightness(1.08); }
190
+ .saveModal .btn:disabled { opacity: .5; cursor: not-allowed; filter: none; }
191
+ .channelStatus { font-size: 12px; padding: 8px 10px; border-radius: 6px; margin-bottom: 14px; white-space: pre-wrap; word-break: break-word; }
192
+ .channelStatus.ok { background: var(--green-bg); color: var(--green); }
193
+ .channelStatus.warn { background: #fff8c5; color: #7d4e00; font-family: var(--mono); }
194
+
176
195
  .toast { position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%); background: var(--fg); color: var(--bg);
177
196
  padding: 10px 18px; border-radius: 8px; font-size: 13px; z-index: 60; box-shadow: 0 4px 16px #0004; }
178
197