@openscaffold/cli 0.1.2 → 0.1.4
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 +0 -4
- package/dist/index.js +7 -54
- package/dist/install.js +7 -109
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,7 +7,6 @@ start, in any repo, loads the right customer deployment's briefing, and the Open
|
|
|
7
7
|
```sh
|
|
8
8
|
npx @openscaffold/cli setup acme.com ./customer-docs # sign in if needed, create the deployment, upload the folder, print the cited plan
|
|
9
9
|
npx @openscaffold/cli login # opens your browser: click Approve. That's it.
|
|
10
|
-
npx @openscaffold/cli review --title "…" --req req_1 # review this folder's change against the customer's plan (agents run it)
|
|
11
10
|
npx @openscaffold/cli status # what's set up and the next step (--json for coding agents)
|
|
12
11
|
npx @openscaffold/cli logout # removes everything it installed and revokes the login
|
|
13
12
|
npx @openscaffold/cli help # commands and environment variables
|
|
@@ -27,9 +26,6 @@ MCP server and `/openscaffold:*` commands).
|
|
|
27
26
|
- `~/.config/openscaffold/credentials.json` (your login, mode 0600)
|
|
28
27
|
- the `openscaffold` MCP server in Claude Code (user scope)
|
|
29
28
|
- a SessionStart hook in `~/.claude/settings.json` that prints the briefing
|
|
30
|
-
- two guardrail hooks in the same file: before a `git commit`, the customer's rules and must-haves as context; at Stop,
|
|
31
|
-
once per session, if Claude edited files without a review, it's asked to run `openscaffold review` (or `request_review`) and then
|
|
32
|
-
call `report_session`
|
|
33
29
|
- `/os-requirements`, `/os-what-changed`, `/os-check-plan`, `/os-demo-check` in `~/.claude/skills/`
|
|
34
30
|
|
|
35
31
|
Requires Node 22+ and Claude Code. Set `OPENSCAFFOLD_URL` to point at a self-hosted OpenScaffold. Set
|
package/dist/index.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// `npx @openscaffold/cli setup | login | status | session-start | logout` (SPEC §8.4). Node 22+, standard library only.
|
|
3
3
|
import { execFileSync, spawn } from "node:child_process";
|
|
4
|
-
import { chmodSync, existsSync,
|
|
4
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { homedir, hostname } from "node:os";
|
|
6
6
|
import { dirname, join, resolve } from "node:path";
|
|
7
|
-
import {
|
|
7
|
+
import { briefingStatus, browserCommand, gitInfo, hasOpenScaffoldPlugin, hookBase, HOSTED, installedHooks, loadedEntry, loginResultLine, MCP_NAME, mcpText, nameFromDomain, parseSetupArgs, planSummary, pluginServes, preCommitOutput, selectFiles, SETUP_EXTENSIONS, setupPrompt, setupResultLine, skillFile, statusReport, stopOutput, transcriptActivity, windowsCommandLine, withHooks, withoutHooks, withSession, } from "./install.js";
|
|
8
8
|
const CONFIG = join(homedir(), ".config", "openscaffold");
|
|
9
9
|
const CREDENTIALS = join(CONFIG, "credentials.json");
|
|
10
10
|
const CACHE = join(CONFIG, "cache");
|
|
@@ -113,8 +113,8 @@ async function login({ next = true } = {}) {
|
|
|
113
113
|
throw new Error(`Couldn't reach OpenScaffold at ${url} (${start.status}).`);
|
|
114
114
|
const { code, login_url, poll } = start.body;
|
|
115
115
|
console.log(BY_AGENT
|
|
116
|
-
? `Tell the person: open ${login_url}
|
|
117
|
-
: `Opening ${login_url}\
|
|
116
|
+
? `Tell the person: open ${login_url} and click Approve. The page shows the code ${code}. Signing up, if they need to, happens on that page too.`
|
|
117
|
+
: `Opening ${login_url}\nClick Approve in your browser. The page should show the code ${code}.`);
|
|
118
118
|
openBrowser(login_url);
|
|
119
119
|
let creds = null;
|
|
120
120
|
const deadline = Date.now() + 10 * 60_000;
|
|
@@ -282,7 +282,6 @@ async function setup() {
|
|
|
282
282
|
console.log("\nReading the sources, then writing the plan (usually 2–4 minutes).");
|
|
283
283
|
const deadline = Date.now() + 20 * 60_000;
|
|
284
284
|
let last = "";
|
|
285
|
-
const shown = new Set(); // facts already printed, so each prints once as it's found
|
|
286
285
|
let idle = 0;
|
|
287
286
|
let nudged = false;
|
|
288
287
|
for (;;) {
|
|
@@ -297,13 +296,6 @@ async function setup() {
|
|
|
297
296
|
}
|
|
298
297
|
if (status && status !== last)
|
|
299
298
|
console.log(` ${(last = status)}`);
|
|
300
|
-
const facts = briefing ? briefingFacts(briefing).filter((f) => !shown.has(f)) : [];
|
|
301
|
-
if (facts.length && !shown.size)
|
|
302
|
-
console.log(" Found so far, quoted from their sources:");
|
|
303
|
-
for (const f of facts) {
|
|
304
|
-
console.log(` ${f}`);
|
|
305
|
-
shown.add(f);
|
|
306
|
-
}
|
|
307
299
|
idle = status?.startsWith("No plan yet") ? idle + 1 : 0;
|
|
308
300
|
if (idle >= 2) {
|
|
309
301
|
if (nudged)
|
|
@@ -396,14 +388,14 @@ async function preCommit() {
|
|
|
396
388
|
if (out)
|
|
397
389
|
console.log(out);
|
|
398
390
|
}
|
|
399
|
-
/** Stop: once per session, if it edited files and never called
|
|
391
|
+
/** Stop: once per session, if it edited files and never called report_session, ask the agent to report. */
|
|
400
392
|
async function stop() {
|
|
401
393
|
const hook = await hookInput();
|
|
402
394
|
if (!hook.session_id || hook.stop_hook_active)
|
|
403
395
|
return;
|
|
404
396
|
const marker = join(CACHE, `reminded-${hook.session_id.replace(/[^\w-]/g, "")}`);
|
|
405
397
|
const transcript = hook.transcript_path?.replace(/^~(?=[\\/])/, homedir());
|
|
406
|
-
const activity = transcript && existsSync(transcript) ? transcriptActivity(readFileSync(transcript, "utf8")) : { edited: false, reported: false
|
|
398
|
+
const activity = transcript && existsSync(transcript) ? transcriptActivity(readFileSync(transcript, "utf8")) : { edited: false, reported: false };
|
|
407
399
|
const out = stopOutput({ stopHookActive: false, reminded: existsSync(marker), loaded: Boolean(loadedHere(hook.session_id, hook.cwd ?? process.cwd())), ...activity });
|
|
408
400
|
if (!out)
|
|
409
401
|
return;
|
|
@@ -411,42 +403,6 @@ async function stop() {
|
|
|
411
403
|
writeFileSync(marker, new Date().toISOString());
|
|
412
404
|
console.log(out);
|
|
413
405
|
}
|
|
414
|
-
// --- review -------------------------------------------------------------------------------------
|
|
415
|
-
/**
|
|
416
|
-
* `review --title "…" [--req ids] [--deployment dep_…] [files…]`: the coding agent's one command for a review. It
|
|
417
|
-
* collects the change itself (git, or the listed files where git sees none), calls request_review, waits on
|
|
418
|
-
* get_review (which holds up to 50 s per call) and prints the verdict. Nothing to paste, nothing to poll.
|
|
419
|
-
*/
|
|
420
|
-
async function review() {
|
|
421
|
-
const args = parseReviewArgs(process.argv.slice(3));
|
|
422
|
-
if ("error" in args)
|
|
423
|
-
throw new Error(args.error);
|
|
424
|
-
const creds = credentials();
|
|
425
|
-
if (!creds)
|
|
426
|
-
throw new Error("Not signed in here: run `npx -y @openscaffold/cli@latest login` (one click), or send the diff with the request_review tool.");
|
|
427
|
-
const deployment = args.deployment ?? loadedHere(undefined, process.cwd())?.deployment;
|
|
428
|
-
if (!deployment)
|
|
429
|
-
throw new Error("Which customer? Add --deployment dep_… (the id in the OpenScaffold briefing).");
|
|
430
|
-
const git = (a) => execFileSync("git", a, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] });
|
|
431
|
-
const got = collectDiff(args.files, git, (f) => (lstatSync(f).isSymbolicLink() ? { link: readlinkSync(f) } : readFileSync(f, "utf8")));
|
|
432
|
-
if ("error" in got)
|
|
433
|
-
throw new Error(got.error);
|
|
434
|
-
try {
|
|
435
|
-
const queued = JSON.parse(await tool(creds, "request_review", { deployment_id: deployment, title: args.title, requirement_ids: args.reqs, diff: got.diff }, 60_000));
|
|
436
|
-
if (!queued.review_id)
|
|
437
|
-
return console.log(queued.note ?? "Nothing to review.");
|
|
438
|
-
let out = "";
|
|
439
|
-
// Each get_review waits up to 50 s on the server; a review that outlasts four of them is reported as still running.
|
|
440
|
-
for (let i = 0; i < 4 && (!out || out.includes("Still reviewing")); i++)
|
|
441
|
-
out = await tool(creds, "get_review", { deployment_id: deployment, review_id: queued.review_id }, 90_000);
|
|
442
|
-
console.log(out);
|
|
443
|
-
}
|
|
444
|
-
catch (e) {
|
|
445
|
-
if (e instanceof LoggedOut)
|
|
446
|
-
throw new Error(`${e.message} Run \`npx -y @openscaffold/cli@latest login\`, then review again.`);
|
|
447
|
-
throw e;
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
406
|
// --- status -------------------------------------------------------------------------------------
|
|
451
407
|
/** Read-only: what's set up on this machine and the next step. `--json` for agents. Always exits 0. */
|
|
452
408
|
async function status() {
|
|
@@ -508,9 +464,6 @@ Usage: npx @openscaffold/cli@latest <command>
|
|
|
508
464
|
Everything in one go: sign in if needed, create the customer's deployment, upload
|
|
509
465
|
every .vtt .srt .txt .md .csv .json in the folder (default .), wait for the plan, print it
|
|
510
466
|
login Sign in (one browser click on Approve) and connect Claude Code
|
|
511
|
-
review --title "<what it does>" [--req req_1,req_2] [--deployment dep_…] [files…]
|
|
512
|
-
Review this folder's change against the customer's plan and rules: sends the git diff
|
|
513
|
-
(or the listed files, where git sees no change) and prints the verdict
|
|
514
467
|
status [--json] What's set up on this machine, and the next step
|
|
515
468
|
logout Remove everything login added and revoke this machine's login
|
|
516
469
|
help This text
|
|
@@ -536,7 +489,7 @@ if (Number(process.versions.node.split(".")[0]) < 22) {
|
|
|
536
489
|
console.log(msg);
|
|
537
490
|
process.exit(HOOKS.has(cmd ?? "") ? 0 : 1);
|
|
538
491
|
}
|
|
539
|
-
const commands = { setup, login: () => login(),
|
|
492
|
+
const commands = { setup, login: () => login(), status, "session-start": sessionStart, "pre-commit": preCommit, stop, logout };
|
|
540
493
|
const run = commands[cmd ?? ""];
|
|
541
494
|
if (!run) {
|
|
542
495
|
console.error(HELP);
|
package/dist/install.js
CHANGED
|
@@ -8,7 +8,7 @@ const isOurs = (h) => typeof h.command === "string" && isOurCommand(h.command);
|
|
|
8
8
|
/**
|
|
9
9
|
* The hooks login installs (Claude Code docs, hooks, 2026-10-27): the briefing at session start; before a
|
|
10
10
|
* `git commit` (the handler's `if` is one permission rule, `Bash(git commit *)` also matches a bare `git commit`),
|
|
11
|
-
* the deployment's constraints and must-haves as context; at Stop, a one-time
|
|
11
|
+
* the deployment's constraints and must-haves as context; at Stop, a one-time report_session reminder.
|
|
12
12
|
* Timeouts are seconds. None ever blocks a tool call.
|
|
13
13
|
*/
|
|
14
14
|
export const OUR_HOOKS = [
|
|
@@ -209,14 +209,10 @@ export function preCommitOutput(briefing, plugin) {
|
|
|
209
209
|
const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
|
|
210
210
|
/** Our report_session tool, from the user-scope server or the plugin's (`mcp__plugin_openscaffold_openscaffold__…`). */
|
|
211
211
|
const REPORT = /^mcp__.*openscaffold.*__report_session$/;
|
|
212
|
-
|
|
213
|
-
/** `npx @openscaffold/cli review …` run through Bash counts as requesting a review. */
|
|
214
|
-
const CLI_REVIEW = /(@openscaffold\/cli(@\S+)?|\bopenscaffold)\s+review\b/;
|
|
215
|
-
/** From a session transcript (JSONL): did the agent edit files, call request_review, call report_session? */
|
|
212
|
+
/** From a session transcript (JSONL): did the agent edit files, and did it call report_session? */
|
|
216
213
|
export function transcriptActivity(jsonl) {
|
|
217
214
|
let edited = false;
|
|
218
215
|
let reported = false;
|
|
219
|
-
let reviewed = false;
|
|
220
216
|
for (const line of jsonl.split("\n")) {
|
|
221
217
|
if (!line.includes('"tool_use"'))
|
|
222
218
|
continue;
|
|
@@ -236,27 +232,19 @@ export function transcriptActivity(jsonl) {
|
|
|
236
232
|
edited = true;
|
|
237
233
|
if (REPORT.test(c.name))
|
|
238
234
|
reported = true;
|
|
239
|
-
if (REVIEW.test(c.name) || (c.name === "Bash" && typeof c.input?.command === "string" && CLI_REVIEW.test(c.input.command)))
|
|
240
|
-
reviewed = true;
|
|
241
235
|
}
|
|
242
236
|
}
|
|
243
|
-
return { edited, reported
|
|
237
|
+
return { edited, reported };
|
|
244
238
|
}
|
|
245
239
|
export const STOP_REASON = "Before you finish, call report_session with what you did.";
|
|
246
|
-
export const STOP_REVIEW_REASON = "Before you finish, review your changes against the customer's plan: run `npx -y @openscaffold/cli@latest review --title \"<what the change does>\" --req <requirement ids>` (it sends the diff and prints the verdict), then report_session.";
|
|
247
240
|
/**
|
|
248
|
-
* The Stop hook's stdout: the documented block decision, once per session, when this session loaded a deployment
|
|
249
|
-
* edited files
|
|
250
|
-
* reported; else "". Never twice, and never while Claude is already continuing for a stop hook.
|
|
241
|
+
* The Stop hook's stdout: the documented block decision, once per session, when this session loaded a deployment,
|
|
242
|
+
* edited files and hasn't reported; else "". Never while Claude is already continuing for a stop hook.
|
|
251
243
|
*/
|
|
252
244
|
export function stopOutput(s) {
|
|
253
|
-
if (s.stopHookActive || s.reminded || !s.loaded || !s.edited)
|
|
245
|
+
if (s.stopHookActive || s.reminded || !s.loaded || !s.edited || s.reported)
|
|
254
246
|
return "";
|
|
255
|
-
|
|
256
|
-
return JSON.stringify({ decision: "block", reason: STOP_REVIEW_REASON });
|
|
257
|
-
if (!s.reported)
|
|
258
|
-
return JSON.stringify({ decision: "block", reason: STOP_REASON });
|
|
259
|
-
return "";
|
|
247
|
+
return JSON.stringify({ decision: "block", reason: STOP_REASON });
|
|
260
248
|
}
|
|
261
249
|
// --- setup: one command from a folder of customer files to a cited plan (SPEC §8.4) -------------
|
|
262
250
|
/** What `setup` uploads: the upload panel's file types (§6.1). CSV/JSON become one ticket per row. */
|
|
@@ -330,10 +318,6 @@ export function briefingStatus(briefing) {
|
|
|
330
318
|
// Written for an agent: drop the sentences that tell it which tool to call next.
|
|
331
319
|
return line.split(/(?<=[.;])\s+/).filter((x) => !/`|tell the FDE/i.test(x)).join(" ").replace(/;$/, ".");
|
|
332
320
|
}
|
|
333
|
-
/** The verified facts a pre-plan briefing's Status lists ("- Goal: … [title · doc_… “quote”]"), without the document ids. */
|
|
334
|
-
export function briefingFacts(briefing) {
|
|
335
|
-
return section(briefing, "Status").filter((l) => l.startsWith("- ")).map((l) => l.replace(/ · doc_\w+/, ""));
|
|
336
|
-
}
|
|
337
321
|
/**
|
|
338
322
|
* One MCP `tools/call` response body (JSON, or a streamable-HTTP SSE frame) → the tool's text, or its error. The first
|
|
339
323
|
* content block only: a second one may carry the "Since your briefing" freshness line.
|
|
@@ -352,89 +336,3 @@ export function mcpText(raw) {
|
|
|
352
336
|
const text = msg.result?.content?.[0]?.text ?? "";
|
|
353
337
|
return msg.result?.isError ? { error: text || "OpenScaffold refused the call." } : { text };
|
|
354
338
|
}
|
|
355
|
-
// --- review: send this folder's change to OpenScaffold's reviewer (SPEC §8.1 request_review) ------
|
|
356
|
-
/** request_review's limit (web/lib/pipeline/review.ts MAX_DIFF_BYTES). */
|
|
357
|
-
export const MAX_DIFF_BYTES = 400 * 1024;
|
|
358
|
-
const REVIEW_USAGE = 'Usage: npx @openscaffold/cli@latest review --title "<what the change does>" [--req req_1,req_2] [--deployment dep_…] [files…]';
|
|
359
|
-
/** `review` arguments; files are optional (git finds the change) and needed only where git sees none. */
|
|
360
|
-
export function parseReviewArgs(args) {
|
|
361
|
-
const out = { title: "", reqs: [], deployment: undefined, files: [] };
|
|
362
|
-
for (let i = 0; i < args.length; i++) {
|
|
363
|
-
const eq = args[i].startsWith("--") ? args[i].indexOf("=") : -1;
|
|
364
|
-
const [flag, inline] = eq < 0 ? [args[i], undefined] : [args[i].slice(0, eq), args[i].slice(eq + 1)];
|
|
365
|
-
const value = () => inline ?? args[++i];
|
|
366
|
-
if (flag === "--title")
|
|
367
|
-
out.title = value() ?? "";
|
|
368
|
-
else if (flag === "--req" || flag === "--requirements")
|
|
369
|
-
out.reqs.push(...(value() ?? "").split(/[,\s]+/).filter(Boolean));
|
|
370
|
-
else if (flag === "--deployment")
|
|
371
|
-
out.deployment = value();
|
|
372
|
-
else if (args[i].startsWith("--"))
|
|
373
|
-
return { error: `Unknown option ${flag}. ${REVIEW_USAGE}` };
|
|
374
|
-
else
|
|
375
|
-
out.files.push(args[i]);
|
|
376
|
-
}
|
|
377
|
-
if (!out.title.trim())
|
|
378
|
-
return { error: `Give the change a one-line title. ${REVIEW_USAGE}` };
|
|
379
|
-
return { ...out, title: out.title.trim(), reqs: [...new Set(out.reqs)] };
|
|
380
|
-
}
|
|
381
|
-
/**
|
|
382
|
-
* A whole file as an added-file diff, as `git diff --no-index /dev/null <file>` prints it. Empty or binary: "". A
|
|
383
|
-
* symbolic link is a link (mode 120000, its target path as the content), as git records it.
|
|
384
|
-
*/
|
|
385
|
-
export function newFileDiff(path, file) {
|
|
386
|
-
const link = typeof file === "object";
|
|
387
|
-
const text = link ? file.link : file;
|
|
388
|
-
if (!text || text.includes("\0"))
|
|
389
|
-
return "";
|
|
390
|
-
const lines = link ? [text] : text.replace(/\r\n/g, "\n").split("\n");
|
|
391
|
-
const newline = !link && lines.at(-1) === "";
|
|
392
|
-
if (newline)
|
|
393
|
-
lines.pop();
|
|
394
|
-
return [`diff --git a/${path} b/${path}`, `new file mode ${link ? "120000" : "100644"}`, "--- /dev/null", `+++ b/${path}`, `@@ -0,0 +1,${lines.length} @@`,
|
|
395
|
-
...lines.map((l) => `+${l}`), ...(newline ? [] : ["\"]), ""].join("\n");
|
|
396
|
-
}
|
|
397
|
-
/**
|
|
398
|
-
* The change to review, from where the agent works: in a git repo, everything since HEAD (staged, unstaged, and
|
|
399
|
-
* untracked files as new files), narrowed to `files` when given. Where git sees nothing (not a repo, a git-ignored
|
|
400
|
-
* folder, a repo with no commit), the listed files are sent whole as new files. `git` runs one git command in the
|
|
401
|
-
* folder and throws when git fails; `read` reads a file.
|
|
402
|
-
*/
|
|
403
|
-
export function collectDiff(files, git, read) {
|
|
404
|
-
let diff = "";
|
|
405
|
-
let repo = false;
|
|
406
|
-
try {
|
|
407
|
-
repo = git(["rev-parse", "--is-inside-work-tree"]).trim() === "true";
|
|
408
|
-
}
|
|
409
|
-
catch { }
|
|
410
|
-
if (repo) {
|
|
411
|
-
const only = files.length ? ["--", ...files] : [];
|
|
412
|
-
let head = true;
|
|
413
|
-
try {
|
|
414
|
-
git(["rev-parse", "--verify", "-q", "HEAD"]);
|
|
415
|
-
}
|
|
416
|
-
catch {
|
|
417
|
-
head = false;
|
|
418
|
-
}
|
|
419
|
-
// No commit yet: compare with the empty tree, so staged and unstaged files both show.
|
|
420
|
-
const base = head ? "HEAD" : git(["hash-object", "-t", "tree", "/dev/null"]).trim();
|
|
421
|
-
// --no-ext-diff / --no-textconv: a repo's configured diff drivers and converters are commands; we never run them.
|
|
422
|
-
diff = git(["diff", "--no-color", "--no-ext-diff", "--no-textconv", base, ...only]);
|
|
423
|
-
const untracked = git(["ls-files", "--others", "--exclude-standard", "-z", ...only]).split("\0").filter(Boolean);
|
|
424
|
-
// Files the agent named that git ignores still get reviewed, even when other files changed.
|
|
425
|
-
if (files.length)
|
|
426
|
-
untracked.push(...git(["ls-files", "--others", "--ignored", "--exclude-standard", "-z", ...only]).split("\0").filter(Boolean));
|
|
427
|
-
const prefix = git(["rev-parse", "--show-prefix"]).trim();
|
|
428
|
-
diff += untracked.map((f) => newFileDiff(prefix + f, read(f))).join(""); // ls-files paths are from this folder; diff paths from the root
|
|
429
|
-
}
|
|
430
|
-
if (!diff.trim() && files.length)
|
|
431
|
-
diff = files.map((f) => newFileDiff(f.replace(/^\.\//, ""), read(f))).join("");
|
|
432
|
-
if (!diff.trim()) {
|
|
433
|
-
return { error: repo
|
|
434
|
-
? `Nothing to review: git sees no change here. If your files are in a git-ignored folder, list them: ${REVIEW_USAGE}`
|
|
435
|
-
: `This folder isn't a git repo, so list the files you changed: ${REVIEW_USAGE}` };
|
|
436
|
-
}
|
|
437
|
-
if (Buffer.byteLength(diff) > MAX_DIFF_BYTES)
|
|
438
|
-
return { error: `This change is ${Math.ceil(Buffer.byteLength(diff) / 1024)} KB; a review takes up to 400 KB. Review it in parts: list the files for one work item at a time.` };
|
|
439
|
-
return { diff };
|
|
440
|
-
}
|