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 +42 -1
- package/package.json +4 -4
- package/src/channels/gh.js +84 -0
- package/src/channels/gist.js +97 -0
- package/src/channels/index.js +24 -0
- package/src/cli.js +42 -5
- package/src/server.js +54 -0
- package/src/store.js +19 -0
- package/src/ui/app.js +123 -1
- package/src/ui/index.html +25 -0
- package/src/ui/style.css +19 -0
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-
|
|
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.
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
|
56
|
-
|
|
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)
|
|
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
|
|