@openscaffold/cli 0.1.1 → 0.1.3

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
@@ -5,16 +5,35 @@ start, in any repo, loads the right customer deployment's briefing, and the Open
5
5
  (search the customer's calls, tickets and emails; read the cited plan; propose plan changes).
6
6
 
7
7
  ```sh
8
+ npx @openscaffold/cli setup acme.com ./customer-docs # sign in if needed, create the deployment, upload the folder, print the cited plan
8
9
  npx @openscaffold/cli login # opens your browser: click Approve. That's it.
10
+ npx @openscaffold/cli review --req req_1 # review this folder's change against the customer's plan (agents run it)
9
11
  npx @openscaffold/cli status # what's set up and the next step (--json for coding agents)
10
12
  npx @openscaffold/cli logout # removes everything it installed and revokes the login
13
+ npx @openscaffold/cli help # commands and environment variables
11
14
  ```
12
15
 
16
+ Then start a new Claude Code session and say:
17
+
18
+ ```text
19
+ Set up OpenScaffold for my customer <Name> (<domain>). Follow https://docs.openscaffold.ai/agent-quickstart.md. Use the files in ./customer-docs.
20
+ ```
21
+
22
+ If the Claude Code plugin `openscaffold` is installed, `login` adds only the session hook (the plugin already brings the
23
+ MCP server and `/openscaffold:*` commands).
24
+
13
25
  `login` writes only to your user config (never into a repo):
14
26
 
15
27
  - `~/.config/openscaffold/credentials.json` (your login, mode 0600)
16
28
  - the `openscaffold` MCP server in Claude Code (user scope)
17
29
  - 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`; and after Claude switches or creates a customer (`use_deployment`, `create_deployment`), a hook that makes `review` and the guardrails follow it
33
+
34
+ Logged in before 2026-10-29? Run `npx @openscaffold/cli login` again to add the customer-switch hook (`status` says so).
18
35
  - `/os-requirements`, `/os-what-changed`, `/os-check-plan`, `/os-demo-check` in `~/.claude/skills/`
19
36
 
20
- Requires Node 22+ and Claude Code. Set `OPENSCAFFOLD_URL` to point at a self-hosted OpenScaffold.
37
+ Requires Node 22+ and Claude Code. Set `OPENSCAFFOLD_URL` to point at a self-hosted OpenScaffold. Set
38
+ `OPENSCAFFOLD_NO_BROWSER=1` to not open a browser (SSH sessions and headless Linux skip it by themselves); open the
39
+ printed link on any device. Docs: https://docs.openscaffold.ai/cli
package/dist/index.js CHANGED
@@ -1,18 +1,23 @@
1
1
  #!/usr/bin/env node
2
- // `npx @openscaffold/cli login | status | session-start | logout` (SPEC §8.4). Node 22+, standard library only.
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, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, statSync, writeFileSync } from "node:fs";
5
5
  import { homedir, hostname } from "node:os";
6
- import { dirname, join } from "node:path";
7
- import { gitInfo, hasSessionHook, hookCommand, MCP_NAME, skillFile, statusReport, withoutSessionHook, withSessionHook } from "./install.js";
6
+ import { dirname, join, resolve } from "node:path";
7
+ import { briefingFacts, briefingStatus, browserCommand, collectDiff, gitInfo, hasOpenScaffoldPlugin, hookBase, HOSTED, installedHooks, loadedEntry, loginResultLine, MCP_NAME, mcpText, nameFromDomain, callerSession, commandLine, rememberedCache, DEFAULT_REVIEW_TITLE, parseReviewArgs, parseSetupArgs, planSummary, pluginServes, preCommitOutput, REVIEW_HELP, REVIEW_WAIT, reviewTitle, selectFiles, SETUP_EXTENSIONS, setupPrompt, setupResultLine, skillFile, statusReport, stopOutput, switchAllowed, switchedTo, transcriptActivity, whichCustomerError, windowsCommandLine, withHooks, withoutHooks, } 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");
11
+ /** Which deployment each recent Claude Code session loaded (session-start writes it; the guardrail hooks read it). */
12
+ const SESSIONS = join(CACHE, "sessions.json");
11
13
  const CLAUDE = join(homedir(), ".claude");
12
14
  const SETTINGS = join(CLAUDE, "settings.json");
13
15
  const SKILLS = join(CLAUDE, "skills");
16
+ const PLUGINS = join(CLAUDE, "plugins", "installed_plugins.json");
17
+ /** Run by a coding agent (Claude Code sets CLAUDECODE; others run us without a terminal): talk to it, never prompt. */
18
+ const BY_AGENT = Boolean(process.env.CLAUDECODE) || !process.stdout.isTTY;
14
19
  // OPENSCAFFOLD_URL overrides (local dev: http://localhost:3000).
15
- const DEFAULT_URL = "https://app.openscaffold.ai";
20
+ const DEFAULT_URL = HOSTED;
16
21
  const readJson = (path, fallback) => {
17
22
  try {
18
23
  return JSON.parse(readFileSync(path, "utf8"));
@@ -39,37 +44,86 @@ async function api(url, path, body, { token, timeoutMs = 15_000 } = {}) {
39
44
  });
40
45
  return { status: res.status, body: (await res.json().catch(() => ({}))) };
41
46
  }
47
+ /** One file as raw text (not JSON), so the request is the file's own size and fits the 4.5 MB request limit. */
48
+ async function uploadFile(creds, deploymentId, filename, text) {
49
+ const url = new URL("/api/upload", creds.api_url);
50
+ url.search = new URLSearchParams({ deployment_id: deploymentId, filename }).toString();
51
+ const res = await fetch(url, {
52
+ method: "POST",
53
+ headers: { "content-type": "text/plain; charset=utf-8", authorization: `Bearer ${creds.token}` },
54
+ body: text,
55
+ signal: AbortSignal.timeout(120_000),
56
+ });
57
+ return { status: res.status, body: (await res.json().catch(() => ({}))) };
58
+ }
59
+ /** Opens the login page if there's a browser here; the URL is always printed too, so any device can open it. */
42
60
  function openBrowser(url) {
43
- if (process.env.OPENSCAFFOLD_NO_BROWSER)
44
- return; // tests and headless machines: the URL is printed
45
- const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
61
+ const cmd = browserCommand(url, process.platform, process.env);
62
+ const noBrowser = () => console.log(`No browser to open here. Open this link on any device where you can sign in:\n ${url}`);
63
+ if (!cmd)
64
+ return noBrowser();
46
65
  try {
47
- spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
66
+ // A missing opener (headless Linux, WSL, SSH) fails asynchronously: without this listener it would crash the login.
67
+ const child = spawn(cmd[0], cmd[1], { stdio: "ignore", detached: true });
68
+ child.on("error", noBrowser);
69
+ child.unref();
70
+ }
71
+ catch {
72
+ noBrowser();
48
73
  }
49
- catch { }
50
74
  }
51
- /** Runs `claude …`; returns false if Claude Code isn't on PATH or the command failed. */
52
- function claude(args) {
75
+ /**
76
+ * Runs `claude …`: "ok", "missing" (not on PATH) or "failed". On Windows an npm install is `claude.cmd`, which
77
+ * execFile can't start (ENOENT, or EINVAL on newer Node), so it falls back to a shell.
78
+ */
79
+ function runClaude(args, { timeout = 30_000, capture = false } = {}) {
80
+ const opts = { stdio: (capture ? ["ignore", "pipe", "ignore"] : "ignore"), timeout, encoding: "utf8" };
53
81
  try {
54
- execFileSync("claude", args, { stdio: "ignore", timeout: 30_000 });
55
- return true;
82
+ return { result: "ok", out: execFileSync("claude", args, opts) ?? undefined };
83
+ }
84
+ catch (e) {
85
+ const code = e.code;
86
+ if (code !== "ENOENT" && code !== "EINVAL")
87
+ return { result: "failed" };
88
+ if (process.platform !== "win32")
89
+ return { result: "missing" };
90
+ }
91
+ try {
92
+ execFileSync("where", ["claude"], { stdio: "ignore", timeout: 5000 });
93
+ }
94
+ catch {
95
+ return { result: "missing" };
96
+ }
97
+ try {
98
+ return { result: "ok", out: execFileSync(windowsCommandLine(["claude", ...args]), { ...opts, shell: true }) ?? undefined };
56
99
  }
57
100
  catch {
58
- return false;
101
+ return { result: "failed" };
59
102
  }
60
103
  }
104
+ const claude = (args) => runClaude(args).result === "ok";
105
+ /** The Claude Code plugin "openscaffold" is installed: it already brings the MCP server and commands. */
106
+ const pluginInstalled = () => hasOpenScaffoldPlugin(runClaude(["plugin", "list", "--json"], { capture: true, timeout: 15_000 }).out ?? null, existsSync(PLUGINS) ? readFileSync(PLUGINS, "utf8") : null);
61
107
  // --- login --------------------------------------------------------------------------------------
62
- async function login() {
108
+ /** `next: false` (from setup): skip the "now say this to Claude Code" step, setup does it. */
109
+ async function login({ next = true } = {}) {
63
110
  const url = (process.env.OPENSCAFFOLD_URL ?? credentials()?.api_url ?? DEFAULT_URL).replace(/\/$/, "");
64
111
  const start = await api(url, "/api/connect/start", { client_label: hostname() });
65
112
  if (start.status !== 200)
66
113
  throw new Error(`Couldn't reach OpenScaffold at ${url} (${start.status}).`);
67
114
  const { code, login_url, poll } = start.body;
68
- console.log(`Opening ${login_url}\nClick Approve in your browser. The page should show the code ${code}.`);
115
+ console.log(BY_AGENT
116
+ ? `Tell the person: open ${login_url}, check the workspace on the page, then click Approve. The page shows the code ${code}. Signing up, if they need to, happens on that page too.`
117
+ : `Opening ${login_url}\nCheck the workspace on the page, then click Approve. The page should show the code ${code}.`);
69
118
  openBrowser(login_url);
70
119
  let creds = null;
71
- for (const deadline = Date.now() + 10 * 60_000; Date.now() < deadline;) {
120
+ const deadline = Date.now() + 10 * 60_000;
121
+ for (let lastNote = Date.now(); Date.now() < deadline;) {
72
122
  await new Promise((r) => setTimeout(r, 2000));
123
+ if (Date.now() - lastNote >= 60_000) {
124
+ lastNote = Date.now();
125
+ console.log(`Waiting for you to click Approve at ${login_url} (code ${code})… ${Math.ceil((deadline - Date.now()) / 60_000)} min left.`);
126
+ }
73
127
  const r = await api(url, "/api/connect/exchange", { poll }).catch(() => null);
74
128
  if (r?.status === 200) {
75
129
  creds = { api_url: String(r.body.api_url ?? url), token: String(r.body.token), workspace: r.body.workspace };
@@ -78,25 +132,43 @@ async function login() {
78
132
  if (r && r.status !== 202)
79
133
  throw new Error(String(r.body.error ?? "Login failed. Run `npx @openscaffold/cli login` again."));
80
134
  }
81
- if (!creds)
82
- throw new Error("Timed out waiting for approval. Run `npx @openscaffold/cli login` again.");
135
+ if (!creds) {
136
+ // Not an error for an agent to retry blindly: nobody clicked. Exit 0 with what to do.
137
+ console.log("Nobody clicked Approve within 10 minutes, so nothing was changed. Run `npx @openscaffold/cli@latest login` again when you're at your browser.");
138
+ console.log(loginResultLine("timeout"));
139
+ return;
140
+ }
83
141
  writeJson(CREDENTIALS, creds, 0o600);
84
142
  // Offline fallback briefings belong to the previous login (maybe another server or workspace): start fresh.
85
143
  rmSync(CACHE, { recursive: true, force: true });
86
144
  const written = [`${CREDENTIALS} (your login, readable only by you)`];
145
+ // The plugin already brings the MCP server and /openscaffold:* commands: a second server or the os-* copies would
146
+ // show every tool and command twice. Then only the hook (which the plugin can't do: pick the customer) is added.
147
+ // The plugin always points at the hosted app: logged in elsewhere (local, self-hosted), the CLI's own server is kept,
148
+ // or tools would hit the hosted app while the hooks read this server.
149
+ const pluginHere = pluginInstalled();
150
+ const plugin = pluginHere && pluginServes(creds.api_url);
151
+ if (pluginHere && !plugin)
152
+ console.log(`\nThe Claude Code plugin "${MCP_NAME}" points at ${HOSTED}, not ${creds.api_url}, so login adds its own MCP server and /os-* commands for ${creds.api_url}.`);
87
153
  // 1. MCP server at user scope (replace any earlier login's).
88
154
  const mcpUrl = `${creds.api_url}/api/mcp/mcp`;
89
155
  const addArgs = ["mcp", "add", "--transport", "http", "--scope", "user", MCP_NAME, mcpUrl, "--header", `Authorization: Bearer ${creds.token}`];
90
156
  claude(["mcp", "remove", "--scope", "user", MCP_NAME]);
91
- if (claude(addArgs))
157
+ if (plugin)
158
+ console.log(`\nThe Claude Code plugin "${MCP_NAME}" is installed, so it provides the MCP server and /openscaffold:* commands. Adding only the hooks: the one that loads the right customer, and the pre-commit and end-of-session guardrails.`);
159
+ else if (claude(addArgs))
92
160
  written.push(`Claude Code MCP server "${MCP_NAME}" (user scope)`);
93
161
  else
94
162
  console.log(`\nClaude Code wasn't found on PATH. Once it is, run:\n claude ${addArgs.map((a) => (/\s/.test(a) ? JSON.stringify(a) : a)).join(" ")}\n`);
95
- // 2. SessionStart hook: loads the right deployment's briefing in every session.
96
- writeJson(SETTINGS, withSessionHook(readJson(SETTINGS, {}), hookCommand(process.argv[1], process.execPath)));
97
- written.push(`${SETTINGS} (SessionStart hook)`);
163
+ // 2. Hooks: the SessionStart briefing in every session, plus the guardrails (pre-commit plan check, report reminder).
164
+ writeJson(SETTINGS, withHooks(readJson(SETTINGS, {}), hookBase(process.argv[1], process.execPath)));
165
+ written.push(`${SETTINGS} (hooks: SessionStart briefing, pre-commit plan check, end-of-session report reminder)`);
98
166
  // 3. Commands as personal skills: /os-requirements, /os-what-changed, /os-check-plan, /os-demo-check.
99
- const commands = await fetch(`${creds.api_url}/api/commands`).then((r) => r.json()).catch(() => []);
167
+ const commands = plugin ? [] : await fetch(`${creds.api_url}/api/commands`).then((r) => r.json()).catch(() => []);
168
+ if (plugin && existsSync(SKILLS))
169
+ for (const d of readdirSync(SKILLS))
170
+ if (d.startsWith("os-"))
171
+ rmSync(join(SKILLS, d), { recursive: true, force: true });
100
172
  for (const c of commands) {
101
173
  const dir = join(SKILLS, `os-${c.name}`);
102
174
  mkdirSync(dir, { recursive: true });
@@ -104,10 +176,148 @@ async function login() {
104
176
  }
105
177
  if (commands.length)
106
178
  written.push(`${SKILLS}/os-* (${commands.map((c) => `/os-${c.name}`).join(", ")})`);
107
- const self = hookCommand(process.argv[1], process.execPath).replace(/ session-start$/, "");
108
- console.log(`\nLogged in to ${creds.workspace.name}.\nWrote:\n${written.map((w) => ` - ${w}`).join("\n")}\nNothing was written into any repo.\n\nStart Claude Code in any repo; it loads the right customer automatically.\nTo undo everything: ${self} logout\n`);
109
- // End on the status report, so a person or agent needn't run (and download) status separately.
110
- await status();
179
+ const self = hookBase(process.argv[1], process.execPath);
180
+ console.log(`\nLogged in to ${creds.workspace.name}.\nWrote:\n${written.map((w) => ` - ${w}`).join("\n")}\nNothing was written into any repo.\n` +
181
+ (next ? `\nStart a new Claude Code session (tools load when a session starts), then say:\n ${setupPrompt()}\n` +
182
+ `(Already have the customer in OpenScaffold? Say "load <customer>" instead.)\n` : "") +
183
+ `To undo everything: ${self} logout\n`);
184
+ // End on the status report, so a person or agent needn't run (and download) status separately. Inside `setup`
185
+ // (next: false) setup carries on and ends on its own result, so login's status and "Next:" would only mislead.
186
+ if (!next)
187
+ return;
188
+ const ready = await status();
189
+ console.log(loginResultLine("ok", { workspace: creds.workspace.name, ready, restart_needed: true }));
190
+ }
191
+ // --- setup: login if needed, deployment, files, wait for the plan --------------------------------
192
+ class LoggedOut extends Error {
193
+ }
194
+ /** One MCP tool call with the stored token: the same server and tools Claude Code uses. Throws the tool's error text. */
195
+ async function tool(creds, name, args, timeoutMs = 30_000) {
196
+ const res = await fetch(new URL("/api/mcp/mcp", creds.api_url), {
197
+ method: "POST",
198
+ headers: { authorization: `Bearer ${creds.token}`, "content-type": "application/json", accept: "application/json, text/event-stream", "mcp-protocol-version": "2025-06-18" },
199
+ body: JSON.stringify({ jsonrpc: "2.0", id: Date.now(), method: "tools/call", params: { name, arguments: args } }),
200
+ signal: AbortSignal.timeout(timeoutMs),
201
+ });
202
+ if (res.status === 401)
203
+ throw new LoggedOut("Your login expired or was revoked.");
204
+ const r = mcpText(await res.text());
205
+ if ("error" in r)
206
+ throw new Error(r.error);
207
+ return r.text;
208
+ }
209
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
210
+ /**
211
+ * `setup <domain> [folder]`: one command from a folder of customer files to a cited plan. Logs in if needed (the login
212
+ * flow, which also connects Claude Code), creates the deployment (or reuses the one for that domain), uploads every
213
+ * supported file through the upload panel's path, waits for the plan (it starts on its own once the files are read,
214
+ * §7.1) and prints it with its quotes. Ends on one JSON line for agents.
215
+ */
216
+ async function setup() {
217
+ const args = parseSetupArgs(process.argv.slice(3));
218
+ if ("error" in args)
219
+ throw new Error(args.error);
220
+ const folder = resolve(args.folder);
221
+ if (!existsSync(folder) || !statSync(folder).isDirectory())
222
+ throw new Error(`No folder at ${folder}.`);
223
+ const entries = readdirSync(folder, { withFileTypes: true }).map((d) => ({ name: d.name, file: d.isFile(), size: d.isFile() ? statSync(join(folder, d.name)).size : 0 }));
224
+ const { upload, skipped } = selectFiles(entries);
225
+ if (!upload.length)
226
+ throw new Error(`No customer files in ${folder} (looked for ${SETUP_EXTENSIONS.join(" ")}). Point setup at the folder with their calls, tickets and emails.`);
227
+ // 1. Logged in, with a token that still works? Else the login flow.
228
+ let creds = credentials();
229
+ let deps = null;
230
+ if (creds)
231
+ deps = await tool(creds, "list_deployments", {}).then((t) => JSON.parse(t), (e) => { if (e instanceof LoggedOut)
232
+ return null; throw e; });
233
+ if (!creds || !deps) {
234
+ await login({ next: false });
235
+ creds = credentials();
236
+ if (!creds)
237
+ return console.log(JSON.stringify({ openscaffold_setup: "not_logged_in" })); // login said what to do
238
+ deps = JSON.parse(await tool(creds, "list_deployments", {}));
239
+ }
240
+ // 2. The customer's deployment: the one with this domain, else a new one named from it.
241
+ let dep = deps.find((d) => d.customer_domains?.includes(args.domain));
242
+ if (dep)
243
+ console.log(`\nUsing ${dep.name} (already set up for ${args.domain}).`);
244
+ else {
245
+ const r = JSON.parse(await tool(creds, "create_deployment", { name: nameFromDomain(args.domain), customer_domains: [args.domain] }));
246
+ dep = { ...r.deployment, harness_version: null };
247
+ console.log(`\nCreated ${dep.name} in ${creds.workspace.name}.`);
248
+ }
249
+ // 3. Every file, one request each (the upload panel's parse path; a CSV's tickets stay one batch).
250
+ console.log(`Uploading ${upload.length} file${upload.length === 1 ? "" : "s"} from ${folder}`);
251
+ let added = 0;
252
+ const failed = []; // the rest still upload; the run ends "partial" naming these
253
+ for (const name of upload) {
254
+ const r = await uploadFile(creds, dep.id, name, readFileSync(join(folder, name), "utf8"))
255
+ .catch((e) => ({ status: 0, body: { error: e instanceof Error ? e.message : String(e) } }));
256
+ if (r.status === 200)
257
+ added += Number(r.body.added ?? 0);
258
+ else
259
+ failed.push(name);
260
+ console.log(` ${name}: ${r.status === 200 ? (Number(r.body.added) ? `${r.body.added} source${Number(r.body.added) === 1 ? "" : "s"}` : "already uploaded") : `not uploaded: ${r.body.error ?? r.status}`}`);
261
+ }
262
+ for (const s of skipped)
263
+ console.log(` ${s.name}: skipped, ${s.why}`);
264
+ if (failed.length === upload.length)
265
+ throw new Error(`None of the files uploaded (${failed.join(", ")}). Add them in ${dep.name}'s Sources in OpenScaffold (${creds.api_url}).`);
266
+ const { id: depId, name: depName } = dep;
267
+ const app = creds.api_url;
268
+ /** The last lines: failed uploads named (non-zero exit for a person; an agent reads the JSON line), then the result. */
269
+ const finish = (result, extra) => {
270
+ if (failed.length) {
271
+ console.log(`\nNot uploaded: ${failed.join(", ")}. Add ${failed.length === 1 ? "it" : "them"} in ${depName}'s Sources in OpenScaffold (${app}); a CSV whose columns weren't recognized can be mapped there.`);
272
+ if (!BY_AGENT)
273
+ process.exitCode = 1;
274
+ }
275
+ console.log(setupResultLine(result, failed, { deployment: depId, added, ...extra }));
276
+ };
277
+ if (dep.harness_version != null) {
278
+ console.log(`\n${dep.name} already has a plan. New sources become plan changes in its Inbox, for you to approve: ${creds.api_url}`);
279
+ return finish("ok", { plan: "existing" });
280
+ }
281
+ // 4. Wait for the plan; nudge it once if nothing is reading or building (e.g. the automatic build failed).
282
+ console.log("\nReading the sources, then writing the plan (usually 2–4 minutes).");
283
+ const deadline = Date.now() + 20 * 60_000;
284
+ let last = "";
285
+ const shown = new Set(); // facts already printed, so each prints once as it's found
286
+ let idle = 0;
287
+ let nudged = false;
288
+ for (;;) {
289
+ const briefing = await tool(creds, "get_briefing", { deployment_id: dep.id }, 20_000).catch(() => "");
290
+ const status = briefing && briefingStatus(briefing);
291
+ if (briefing && !status) {
292
+ // The cited plan and profile, not the briefing: it shortens the lists and leaves some quotes out.
293
+ const [plan, profile] = await Promise.all(["get_plan", "get_profile"].map((t) => tool(creds, t, { deployment_id: dep.id })));
294
+ console.log(`\nThe plan for ${dep.name} is ready. Every line is quoted from their sources:\n\n${planSummary(plan, profile) || "(No goal or must-haves in the sources yet.)"}\n`);
295
+ console.log(`Review it and mark what the customer agreed to: ${dep.url ?? creds.api_url}\nThen open Claude Code in your repo and say: load ${dep.name}`);
296
+ return finish("ok", { plan: "ready" });
297
+ }
298
+ if (status && status !== last)
299
+ 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
+ idle = status?.startsWith("No plan yet") ? idle + 1 : 0;
308
+ if (idle >= 2) {
309
+ if (nudged)
310
+ throw new Error(`The plan didn't build. Open ${dep.name} in OpenScaffold (${creds.api_url}) to see why, and Retry there.`);
311
+ nudged = true;
312
+ idle = 0;
313
+ await tool(creds, "generate_harness", { deployment_id: dep.id });
314
+ }
315
+ if (Date.now() > deadline) {
316
+ console.log(`Still going after 20 minutes; it keeps running without this window. See ${dep.name}'s Overview in OpenScaffold (${creds.api_url}).`);
317
+ return finish("waiting", {});
318
+ }
319
+ await sleep(10_000);
320
+ }
111
321
  }
112
322
  // --- session-start (the SessionStart hook) ------------------------------------------------------
113
323
  const readStdin = async () => {
@@ -126,6 +336,17 @@ const git = (cwd, args) => {
126
336
  return null;
127
337
  }
128
338
  };
339
+ /** Caches a deployment's briefing as this folder's (and this Claude session's) deployment, for the guardrail hooks and review. */
340
+ function remember(creds, deployment, briefing, cwd, sessionId, switching = false) {
341
+ mkdirSync(CACHE, { recursive: true });
342
+ writeFileSync(join(CACHE, `${deployment}.md`), briefing);
343
+ const entry = { deployment, at: new Date().toISOString(), login: loginKey(creds) };
344
+ // Per Claude session too: two sessions in one folder on different customers each keep their own guardrails.
345
+ const next = rememberedCache(readJson(join(CACHE, "index.json"), {}), readJson(SESSIONS, {}), cwd, sessionId, entry, switching);
346
+ writeJson(join(CACHE, "index.json"), next.index);
347
+ if (sessionId)
348
+ writeJson(SESSIONS, next.sessions);
349
+ }
129
350
  /** Prints the briefing. Never breaks a session: on any problem, a cached briefing or one line, exit 0. */
130
351
  async function sessionStart() {
131
352
  let hook = {};
@@ -147,11 +368,8 @@ async function sessionStart() {
147
368
  const briefing = String(r.body.briefing);
148
369
  console.log(briefing);
149
370
  const dep = r.body.deployment?.id;
150
- if (dep) {
151
- mkdirSync(CACHE, { recursive: true });
152
- writeFileSync(join(CACHE, `${dep}.md`), briefing);
153
- writeJson(join(CACHE, "index.json"), { ...index, [cwd]: { deployment: dep, at: new Date().toISOString(), login: loginKey(creds) } });
154
- }
371
+ if (dep)
372
+ remember(creds, dep, briefing, cwd, hook.session_id);
155
373
  }
156
374
  catch {
157
375
  const last = index[cwd]?.login === loginKey(creds) ? index[cwd] : undefined; // a session started before a re-login may write late
@@ -159,18 +377,118 @@ async function sessionStart() {
159
377
  console.log(cached ? `${cached}\n\n(offline, from ${last?.at})` : "OpenScaffold: offline");
160
378
  }
161
379
  }
162
- // --- status -------------------------------------------------------------------------------------
163
- /** Read-only: what's set up on this machine and the next step. `--json` for agents. Always exits 0. */
164
- async function status() {
380
+ // --- guardrail hooks (pre-commit, stop): local files only, no network, never block a tool --------
381
+ const loadedHere = (sessionId, cwd) => {
382
+ const creds = credentials();
383
+ const entry = creds ? loadedEntry(readJson(SESSIONS, {}), readJson(join(CACHE, "index.json"), {}), sessionId, cwd) : undefined;
384
+ return entry && entry.login === loginKey(creds) ? entry : undefined;
385
+ };
386
+ const hookInput = async () => {
387
+ try {
388
+ return JSON.parse((await readStdin()) || "{}");
389
+ }
390
+ catch {
391
+ return {};
392
+ }
393
+ };
394
+ /** PreToolUse on `git commit`: the deployment's constraints and must-haves from the cached briefing, as context. */
395
+ async function preCommit() {
396
+ const hook = await hookInput();
397
+ const entry = loadedHere(hook.session_id, hook.cwd ?? process.cwd());
398
+ const file = entry && join(CACHE, `${entry.deployment}.md`);
399
+ const out = preCommitOutput(file && existsSync(file) ? readFileSync(file, "utf8") : null, hasOpenScaffoldPlugin(null, existsSync(PLUGINS) ? readFileSync(PLUGINS, "utf8") : null));
400
+ if (out)
401
+ console.log(out);
402
+ }
403
+ /** Stop: once per session, if it edited files and never called request_review (or report_session), ask for it. */
404
+ async function stop() {
405
+ const hook = await hookInput();
406
+ if (!hook.session_id || hook.stop_hook_active)
407
+ return;
408
+ const marker = join(CACHE, `reminded-${hook.session_id.replace(/[^\w-]/g, "")}`);
409
+ const transcript = hook.transcript_path?.replace(/^~(?=[\\/])/, homedir());
410
+ const activity = transcript && existsSync(transcript) ? transcriptActivity(readFileSync(transcript, "utf8")) : { edited: false, reported: false, reviewed: false };
411
+ const out = stopOutput({ stopHookActive: false, reminded: existsSync(marker), loaded: Boolean(loadedHere(hook.session_id, hook.cwd ?? process.cwd())), ...activity });
412
+ if (!out)
413
+ return;
414
+ mkdirSync(CACHE, { recursive: true });
415
+ writeFileSync(marker, new Date().toISOString());
416
+ console.log(out);
417
+ }
418
+ /** The deployments this login has cached (session-start or an earlier switch), newest first, with their names when cached. */
419
+ const knownDeployments = (creds) => {
420
+ const entries = [...Object.values(readJson(SESSIONS, {})), ...Object.values(readJson(join(CACHE, "index.json"), {}))];
421
+ const ids = [...new Set(entries.filter((e) => e.login === loginKey(creds)).sort((a, b) => b.at.localeCompare(a.at)).map((e) => e.deployment))];
422
+ return ids.map((id) => {
423
+ const file = join(CACHE, `${id}.md`);
424
+ return { id, name: existsSync(file) ? /^# OpenScaffold — (.+)$/m.exec(readFileSync(file, "utf8"))?.[1]?.trim() ?? null : null };
425
+ });
426
+ };
427
+ /**
428
+ * PostToolUse after use_deployment / create_deployment: the new deployment becomes this session's and folder's, so review
429
+ * and the hooks follow it; only when it is this login's server and workspace (switchAllowed), never a foreign id.
430
+ */
431
+ async function useDeployment() {
432
+ const hook = await hookInput();
433
+ const creds = credentials();
434
+ const got = switchedTo(hook.tool_response);
435
+ if (!creds || !got || !switchAllowed(hook.tool_name, got.deployment, creds.api_url, knownDeployments(creds).map((d) => d.id)))
436
+ return;
437
+ remember(creds, got.deployment, got.briefing, hook.cwd ?? process.cwd(), hook.session_id, true);
438
+ }
439
+ // --- review -------------------------------------------------------------------------------------
440
+ /**
441
+ * `review [--req ids] [--title "…"] [--deployment dep_…] [files…]`: the coding agent's one command for a review. It
442
+ * collects the change itself (git, or the listed files where git sees none), calls request_review, waits on
443
+ * get_review (which holds up to 50 s per call) and prints the verdict. Nothing to paste, nothing to poll.
444
+ */
445
+ async function review() {
446
+ const args = parseReviewArgs(process.argv.slice(3), existsSync);
447
+ if ("help" in args)
448
+ return console.log(REVIEW_HELP);
449
+ if ("error" in args)
450
+ throw new Error(args.error);
165
451
  const creds = credentials();
166
- let mcp = null;
452
+ const again = process.argv.slice(2);
453
+ if (!creds)
454
+ throw new Error(`Not signed in here. Run ${commandLine(["login"])} (one click in the browser), then ${commandLine(again)}. With the plugin or OAuth only, send the diff with the request_review tool instead.`);
455
+ // The calling Claude session's deployment first (another session in this folder may have switched), else the folder's.
456
+ const deployment = args.deployment ?? loadedHere(callerSession(process.env), process.cwd())?.deployment;
457
+ if (!deployment)
458
+ throw new Error(whichCustomerError(again, knownDeployments(creds)));
459
+ const git = (a) => execFileSync("git", a, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] });
460
+ const got = collectDiff(args.files, git, (f) => (lstatSync(f).isSymbolicLink() ? { link: readlinkSync(f) } : readFileSync(f, "utf8")));
461
+ if ("error" in got)
462
+ throw new Error(got.error);
463
+ const cached = join(CACHE, `${deployment}.md`);
464
+ const briefing = existsSync(cached) ? readFileSync(cached, "utf8") : null;
465
+ const name = briefing && /^# OpenScaffold — (.+)$/m.exec(briefing)?.[1]?.trim();
466
+ const title = args.title === DEFAULT_REVIEW_TITLE ? reviewTitle(args.reqs, briefing) : args.title;
467
+ // No --deployment and no Claude session id in this shell: the folder's default, said out loud.
468
+ const which = !args.deployment && !callerSession(process.env) ? " (this folder's default customer; pass --deployment to choose)" : "";
469
+ console.log(`Reviewing "${title}" for ${name ? `${name} (${deployment})` : deployment}${which}. ${REVIEW_WAIT}`);
167
470
  try {
168
- execFileSync("claude", ["mcp", "get", MCP_NAME], { stdio: "ignore", timeout: 15_000 });
169
- mcp = true;
471
+ const queued = JSON.parse(await tool(creds, "request_review", { deployment_id: deployment, title, requirement_ids: args.reqs, diff: got.diff }, 60_000));
472
+ if (!queued.review_id)
473
+ return console.log(queued.note ?? "Nothing to review.");
474
+ let out = "";
475
+ // Each get_review waits up to 50 s on the server; a review that outlasts four of them is reported as still running.
476
+ for (let i = 0; i < 4 && (!out || out.includes("Still reviewing")); i++)
477
+ out = await tool(creds, "get_review", { deployment_id: deployment, review_id: queued.review_id }, 90_000);
478
+ console.log(out);
170
479
  }
171
480
  catch (e) {
172
- mcp = e.code === "ENOENT" ? null : false;
481
+ if (e instanceof LoggedOut)
482
+ throw new Error(`${e.message} Run \`npx -y @openscaffold/cli@latest login\`, then review again.`);
483
+ throw e;
173
484
  }
485
+ }
486
+ // --- status -------------------------------------------------------------------------------------
487
+ /** Read-only: what's set up on this machine and the next step. `--json` for agents. Always exits 0. */
488
+ async function status() {
489
+ const creds = credentials();
490
+ const got = runClaude(["mcp", "get", MCP_NAME], { timeout: 15_000 }).result;
491
+ const mcp = got === "missing" ? null : got === "ok";
174
492
  // An authenticated request, so a revoked token isn't reported as ready: the MCP endpoint answers 401 to a bad token.
175
493
  const server = creds
176
494
  ? await fetch(new URL("/api/mcp/mcp", creds.api_url), {
@@ -186,17 +504,21 @@ async function status() {
186
504
  : null;
187
505
  const onDisk = existsSync(SKILLS) ? readdirSync(SKILLS).filter((d) => d.startsWith("os-") && existsSync(join(SKILLS, d, "SKILL.md"))) : [];
188
506
  const names = expected ?? onDisk;
507
+ const hooks = installedHooks(readJson(SETTINGS, {}));
189
508
  const state = {
190
509
  workspace: creds?.workspace.name ?? null,
191
510
  apiUrl: creds?.api_url ?? null,
192
511
  server,
193
512
  mcp,
194
- hook: hasSessionHook(readJson(SETTINGS, {})),
513
+ hook: hooks.includes("session-start"),
514
+ guardrails: hooks.includes("pre-commit") && hooks.includes("stop") && hooks.includes("use-deployment"),
195
515
  skills: names.filter((n) => onDisk.includes(n)).length,
196
516
  skillsExpected: names.length,
517
+ plugin: mcp === null ? false : pluginInstalled() && pluginServes(creds?.api_url),
197
518
  };
198
519
  const report = statusReport(state);
199
520
  console.log(process.argv.includes("--json") ? JSON.stringify({ ...state, ready: report.ready, next: report.next }, null, 2) : report.text);
521
+ return report.ready;
200
522
  }
201
523
  // --- logout -------------------------------------------------------------------------------------
202
524
  async function logout() {
@@ -205,27 +527,68 @@ async function logout() {
205
527
  await api(creds.api_url, "/api/connect/revoke", {}, { token: creds.token, timeoutMs: 5000 }).catch(() => null);
206
528
  claude(["mcp", "remove", "--scope", "user", MCP_NAME]);
207
529
  if (existsSync(SETTINGS))
208
- writeJson(SETTINGS, withoutSessionHook(readJson(SETTINGS, {})));
530
+ writeJson(SETTINGS, withoutHooks(readJson(SETTINGS, {})));
209
531
  if (existsSync(SKILLS))
210
532
  for (const d of readdirSync(SKILLS))
211
533
  if (d.startsWith("os-"))
212
534
  rmSync(join(SKILLS, d), { recursive: true, force: true });
213
535
  rmSync(CONFIG, { recursive: true, force: true });
214
- console.log("Logged out: token revoked, MCP server, hook, /os-* commands and local credentials removed.");
536
+ console.log("Logged out: token revoked, MCP server, hooks, /os-* commands and local credentials removed.");
215
537
  }
216
538
  // --- main ---------------------------------------------------------------------------------------
539
+ const HELP = `OpenScaffold CLI: connect Claude Code to your OpenScaffold workspace. Docs: https://docs.openscaffold.ai/cli
540
+
541
+ Usage: npx @openscaffold/cli@latest <command>
542
+
543
+ setup <domain> [folder]
544
+ Everything in one go: sign in if needed, create the customer's deployment, upload
545
+ every .vtt .srt .txt .md .csv .json in the folder (default .), wait for the plan, print it
546
+ login Sign in (one browser click on Approve) and connect Claude Code
547
+ review [--req req_1,req_2] [--title "…"] [--deployment dep_…] [files…]
548
+ Review this folder's change against the customer's plan and rules: sends the git diff
549
+ (or the listed files, where git sees no change) and prints the verdict. review --help for more
550
+ status [--json] What's set up on this machine, and the next step
551
+ logout Remove everything login added and revoke this machine's login
552
+ help This text
553
+ (session-start, pre-commit, stop and use-deployment are run by Claude Code's hooks)
554
+
555
+ Environment:
556
+ OPENSCAFFOLD_URL Server to sign in to (default ${DEFAULT_URL}; e.g. http://localhost:3000)
557
+ OPENSCAFFOLD_NO_BROWSER Set to 1 to not open a browser; open the printed link on any device instead
558
+ (SSH and headless Linux are detected and skip the browser by themselves)`;
217
559
  const [cmd] = process.argv.slice(2);
218
- const commands = { login, status, "session-start": sessionStart, logout };
560
+ /** Run by a hook: must never break the session or block a tool, so any problem exits 0 quietly. */
561
+ const HOOKS = new Set(["session-start", "pre-commit", "stop", "use-deployment"]);
562
+ if (cmd === "help" || cmd === "--help" || cmd === "-h") {
563
+ console.log(HELP);
564
+ process.exit(0);
565
+ }
566
+ // npx doesn't enforce "engines": say it plainly instead of failing on a missing API later.
567
+ if (Number(process.versions.node.split(".")[0]) < 22) {
568
+ const msg = `OpenScaffold CLI needs Node 22 or newer; this is Node ${process.versions.node}. Install it from https://nodejs.org (or \`nvm install 22\`), then run the command again.`;
569
+ if (cmd === "session-start")
570
+ console.log(`OpenScaffold: ${msg}`);
571
+ else if (!HOOKS.has(cmd ?? ""))
572
+ console.log(msg);
573
+ process.exit(HOOKS.has(cmd ?? "") ? 0 : 1);
574
+ }
575
+ const commands = { setup, login: () => login(), review, status, "session-start": sessionStart, "pre-commit": preCommit, stop, "use-deployment": useDeployment, logout };
219
576
  const run = commands[cmd ?? ""];
220
577
  if (!run) {
221
- console.error("Usage: npx @openscaffold/cli <login | status | logout>\n(session-start is run by Claude Code's SessionStart hook)");
578
+ console.error(HELP);
222
579
  process.exit(1);
223
580
  }
224
- run().then(() => process.exit(0), (e) => {
225
- if (cmd === "session-start") {
226
- console.log("OpenScaffold: offline"); // never break a session
581
+ run().then(() => process.exit(), // process.exitCode, if a command set one (setup with failed uploads)
582
+ (e) => {
583
+ if (HOOKS.has(cmd ?? "")) {
584
+ if (cmd === "session-start")
585
+ console.log("OpenScaffold: offline"); // never break a session
227
586
  process.exit(0);
228
587
  }
229
588
  console.error(e instanceof Error ? e.message : e);
589
+ if (cmd === "login")
590
+ console.log(loginResultLine("error", { message: e instanceof Error ? e.message : String(e) }));
591
+ if (cmd === "setup")
592
+ console.log(JSON.stringify({ openscaffold_setup: "error", message: e instanceof Error ? e.message : String(e) }));
230
593
  process.exit(1);
231
594
  });
package/dist/install.js CHANGED
@@ -1,49 +1,67 @@
1
1
  // Pure helpers for what `openscaffold login`/`logout` write into Claude Code (SPEC §8.4).
2
2
  // Formats checked against code.claude.com docs (hooks, mcp, skills), 2026-10-23.
3
3
  export const MCP_NAME = "openscaffold";
4
- /** Our hook command mentions openscaffold and ends in `session-start` (npx form or a local build path). */
5
- const isOurCommand = (cmd) => /openscaffold/.test(cmd) && /\bsession-start"?$/.test(cmd.trim());
4
+ /** Our hook commands mention openscaffold and end in one of our subcommands (npx form or a local build path). */
5
+ const isOurCommand = (cmd) => /openscaffold/.test(cmd) && /\b(session-start|pre-commit|stop|use-deployment)"?$/.test(cmd.trim());
6
6
  const HOOK_MATCHER = "startup|resume|clear|compact";
7
7
  const isOurs = (h) => typeof h.command === "string" && isOurCommand(h.command);
8
- /** Adds (or replaces) our SessionStart hook; leaves everything else in settings.json as it was. */
9
- export function withSessionHook(settings, command) {
8
+ /**
9
+ * The hooks login installs (Claude Code docs, hooks, 2026-10-27): the briefing at session start; before a
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 ask to review the changes (request_review) and report_session;
12
+ * after the agent's use_deployment or create_deployment, the local cache follows the switch (so review, pre-commit and Stop use the new customer).
13
+ * Timeouts are seconds. None ever blocks a tool call.
14
+ */
15
+ export const OUR_HOOKS = [
16
+ { event: "SessionStart", sub: "session-start", matcher: HOOK_MATCHER, handler: { timeout: 10 } },
17
+ { event: "PreToolUse", sub: "pre-commit", matcher: "Bash", handler: { if: "Bash(git commit *)", timeout: 2 } },
18
+ { event: "Stop", sub: "stop", matcher: undefined, handler: { timeout: 5 } },
19
+ { event: "PostToolUse", sub: "use-deployment", matcher: "mcp__.*openscaffold.*__(use|create)_deployment", handler: { timeout: 2 } },
20
+ ];
21
+ const withoutOurs = (groups = []) => groups.map((g) => ({ ...g, hooks: g.hooks.filter((h) => !isOurs(h)) })).filter((g) => g.hooks.length > 0);
22
+ /** Adds (or replaces) our hooks, `<base> <subcommand>` each; leaves everything else in settings.json as it was. */
23
+ export function withHooks(settings, base) {
10
24
  const out = structuredClone(settings);
11
- const groups = (out.hooks?.SessionStart ?? [])
12
- .map((g) => ({ ...g, hooks: g.hooks.filter((h) => !isOurs(h)) }))
13
- .filter((g) => g.hooks.length > 0);
14
- groups.push({ matcher: HOOK_MATCHER, hooks: [{ type: "command", command, timeout: 10 }] });
15
- out.hooks = { ...out.hooks, SessionStart: groups };
25
+ const hooks = { ...out.hooks };
26
+ for (const h of OUR_HOOKS) {
27
+ const group = { ...(h.matcher ? { matcher: h.matcher } : {}), hooks: [{ type: "command", ...("if" in h.handler ? { if: h.handler.if } : {}), command: `${base} ${h.sub}`, timeout: h.handler.timeout }] };
28
+ hooks[h.event] = [...withoutOurs(hooks[h.event]), group];
29
+ }
30
+ out.hooks = hooks;
16
31
  return out;
17
32
  }
18
- /** Removes our SessionStart hook (and empty leftovers); leaves other hooks alone. */
19
- export function withoutSessionHook(settings) {
33
+ /** Removes our hooks (and empty leftovers) from every event; leaves other hooks alone. */
34
+ export function withoutHooks(settings) {
20
35
  const out = structuredClone(settings);
21
- if (!out.hooks?.SessionStart)
36
+ if (!out.hooks)
22
37
  return out;
23
- const groups = out.hooks.SessionStart.map((g) => ({ ...g, hooks: g.hooks.filter((h) => !isOurs(h)) })).filter((g) => g.hooks.length > 0);
24
- if (groups.length)
25
- out.hooks.SessionStart = groups;
26
- else
27
- delete out.hooks.SessionStart;
38
+ for (const event of Object.keys(out.hooks)) {
39
+ const groups = withoutOurs(out.hooks[event]);
40
+ if (groups.length)
41
+ out.hooks[event] = groups;
42
+ else
43
+ delete out.hooks[event];
44
+ }
28
45
  if (!Object.keys(out.hooks).length)
29
46
  delete out.hooks;
30
47
  return out;
31
48
  }
32
49
  /**
33
- * The hook command. Published installs run through npx; a local checkout (development) runs its
34
- * own built file, so the hook works before the package is on npm.
50
+ * The command our hooks run, without the subcommand. Published installs run through npx; a local checkout
51
+ * (development) runs its own built file, so the hooks work before the package is on npm.
35
52
  */
36
- export function hookCommand(selfPath, execPath) {
53
+ export function hookBase(selfPath, execPath) {
37
54
  if (/[\\/]_npx[\\/]|[\\/]node_modules[\\/]@openscaffold[\\/]cli[\\/]/.test(selfPath))
38
- return `npx -y @openscaffold/cli session-start`;
39
- return `${JSON.stringify(execPath)} ${JSON.stringify(selfPath)} session-start`;
55
+ return "npx -y @openscaffold/cli";
56
+ return `${JSON.stringify(execPath)} ${JSON.stringify(selfPath)}`;
40
57
  }
41
- /** A personal skill file for one default command: /os-<name>. */
42
- export function skillFile(c) {
58
+ /** A skill file for one default command: /os-<name> as a personal skill, or /openscaffold:<name> in the plugin. */
59
+ export function skillFile(c, name = `os-${c.name}`) {
43
60
  return [
44
61
  "---",
45
- `name: os-${c.name}`,
46
- `description: ${c.description.replace(/\n/g, " ")} (OpenScaffold)`,
62
+ `name: ${name}`,
63
+ // JSON-quoted: a valid YAML string whatever the text holds (a ": " or "#" would break a plain value).
64
+ `description: ${JSON.stringify(`${c.description.replace(/\n/g, " ")} (OpenScaffold)`)}`,
47
65
  "---",
48
66
  "",
49
67
  c.prompt,
@@ -58,28 +76,456 @@ export function gitInfo(remoteOut, branchOut) {
58
76
  const branch = branchOut?.trim() || undefined;
59
77
  return { git_remote: remote, git_branch: branch };
60
78
  }
61
- /** Whether our SessionStart hook is in settings.json. */
62
- export const hasSessionHook = (settings) => (settings.hooks?.SessionStart ?? []).some((g) => g.hooks.some(isOurs));
63
- const HOSTED = "https://app.openscaffold.ai";
79
+ /** Which of our hooks are in settings.json, e.g. ["session-start", "pre-commit", "stop"]. */
80
+ export const installedHooks = (settings) => OUR_HOOKS.filter((h) => (settings.hooks?.[h.event] ?? []).some((g) => g.hooks.some((x) => isOurs(x) && x.command.trim().replace(/"$/, "").endsWith(h.sub)))).map((h) => h.sub);
81
+ export const hasSessionHook = (settings) => installedHooks(settings).includes("session-start");
82
+ /** The hosted app, which the Claude Code plugin's MCP server always points at (plugin/.mcp.json). */
83
+ export const HOSTED = "https://app.openscaffold.ai";
84
+ /** The plugin's MCP server is this login's server: only then can login leave the MCP server and commands to it. */
85
+ export const pluginServes = (apiUrl) => (apiUrl ?? "").replace(/\/+$/, "") === HOSTED;
64
86
  const RELOGIN = (apiUrl) => `npx @openscaffold/cli login${apiUrl && apiUrl !== HOSTED ? ` (or OPENSCAFFOLD_URL=${HOSTED} npx @openscaffold/cli login to use the hosted app)` : ""}`;
65
87
  /**
66
88
  * `openscaffold status`: one line per piece of the setup plus the single next step, in words a person or a
67
89
  * coding agent can act on (the agent onboarding playbook reads it). `mcp: null` = Claude Code isn't on PATH.
68
90
  */
69
91
  export function statusReport(s) {
70
- const commandsOk = s.skillsExpected > 0 && s.skills >= s.skillsExpected;
92
+ // With the plugin, the MCP server and commands come from it; login installs only the hook.
93
+ const commandsOk = s.plugin || (s.skillsExpected > 0 && s.skills >= s.skillsExpected);
94
+ const mcpOk = s.plugin ? true : s.mcp;
71
95
  const lines = [
72
96
  `Signed in: ${s.workspace ? `yes, workspace "${s.workspace}" (${s.apiUrl}${s.server === "down" ? ", not answering" : s.server === "revoked" ? ", login revoked" : ""})` : "no"}`,
73
- `MCP server: ${s.mcp === null ? "unknown (Claude Code not on PATH)" : s.mcp ? "added" : "missing"}`,
97
+ `MCP server: ${s.plugin ? "from the openscaffold plugin" : s.mcp === null ? "unknown (Claude Code not on PATH)" : s.mcp ? "added" : "missing"}`,
74
98
  `Session hook: ${s.hook ? "installed" : "missing"}`,
75
- `Commands: ${commandsOk ? `${s.skills} /os-* commands` : s.skills ? `${s.skills} of ${s.skillsExpected || "?"} /os-* commands` : "missing"}`,
99
+ ...(s.guardrails === undefined ? [] : [`Guardrails: ${s.guardrails ? "pre-commit plan check and end-of-session report reminder" : "missing or out of date (logins before 2026-10-29 lack the customer-switch hook): re-run npx @openscaffold/cli login"}`]),
100
+ `Commands: ${s.plugin ? "/openscaffold:* from the plugin" : commandsOk ? `${s.skills} /os-* commands` : s.skills ? `${s.skills} of ${s.skillsExpected || "?"} /os-* commands` : "missing"}`,
76
101
  ];
77
- const next = !s.workspace || s.server === "revoked" || s.mcp === false || !s.hook || !commandsOk
102
+ const next = !s.workspace || s.server === "revoked" || mcpOk === false || !s.hook || s.guardrails === false || !commandsOk
78
103
  ? `Run: ${RELOGIN(s.apiUrl)} (one browser click to approve).`
79
104
  : s.server !== "ok"
80
105
  ? `${s.apiUrl} isn't answering. If it's a local dev server, start it; otherwise run: ${RELOGIN(s.apiUrl)}.`
81
- : s.mcp === null
106
+ : mcpOk === null
82
107
  ? "Claude Code isn't on PATH, so the MCP server can't be checked or added. Put `claude` on PATH, then run: npx @openscaffold/cli login."
83
108
  : "Ready. Start a new Claude Code session (or restart this one) to load OpenScaffold.";
84
109
  return { ready: next.startsWith("Ready"), next, text: `OpenScaffold setup\n${lines.map((l) => ` ${l}`).join("\n")}\n\nNext: ${next}` };
85
110
  }
111
+ /**
112
+ * Whether the Claude Code plugin "openscaffold" is installed (and enabled), from `claude plugin list --json`, else
113
+ * from ~/.claude/plugins/installed_plugins.json. Then login adds no second MCP server and no /os-* skills.
114
+ */
115
+ export function hasOpenScaffoldPlugin(listJson, installedJson) {
116
+ const ours = (id) => typeof id === "string" && id.startsWith(`${MCP_NAME}@`);
117
+ try {
118
+ const list = JSON.parse(listJson ?? "null");
119
+ if (Array.isArray(list))
120
+ return list.some((p) => ours(p.id) && p.enabled !== false);
121
+ }
122
+ catch { }
123
+ try {
124
+ const installed = JSON.parse(installedJson ?? "null");
125
+ return Object.keys(installed?.plugins ?? {}).some(ours);
126
+ }
127
+ catch {
128
+ return false;
129
+ }
130
+ }
131
+ /**
132
+ * How to open a URL here, or null when there's no browser to open (a person opens the printed link on any device):
133
+ * OPENSCAFFOLD_NO_BROWSER, an SSH session, or Linux without a display (headless, WSL without a bridge, containers).
134
+ */
135
+ export function browserCommand(url, platform, env) {
136
+ if (env.OPENSCAFFOLD_NO_BROWSER || env.SSH_CONNECTION || env.SSH_TTY)
137
+ return null;
138
+ if (platform === "darwin")
139
+ return ["open", [url]];
140
+ if (platform === "win32")
141
+ return ["cmd", ["/c", "start", "", url]];
142
+ if (env.WSL_DISTRO_NAME)
143
+ return ["wslview", [url]];
144
+ if (!env.DISPLAY && !env.WAYLAND_DISPLAY)
145
+ return null;
146
+ return ["xdg-open", [url]];
147
+ }
148
+ /** A cmd.exe command line for `claude.cmd` (Windows npm installs), which execFile can't run without a shell. */
149
+ export const windowsCommandLine = (args) => args.map((a) => (/^[\w./:@=-]+$/.test(a) ? a : `"${a.replace(/"/g, '\\"')}"`)).join(" ");
150
+ /** The one prompt an FDE pastes into their coding agent to set up a customer (docs: agent-quickstart). */
151
+ export const setupPrompt = (customer = "<Name>", domain = "<domain>") => `Set up OpenScaffold for my customer ${customer} (${domain}). Follow https://docs.openscaffold.ai/agent-quickstart.md. Use the files in ./customer-docs.`;
152
+ /** The last line `login` prints, for a coding agent running it: one JSON object. */
153
+ export const loginResultLine = (result, extra = {}) => JSON.stringify({ openscaffold_login: result, ...extra });
154
+ /** setup's last line (§8.4). A file that didn't upload turns an otherwise-ok run into "partial", naming the files. */
155
+ export const setupResultLine = (result, failed, extra = {}) => JSON.stringify({ openscaffold_setup: failed.length && result === "ok" ? "partial" : result, ...extra, ...(failed.length ? { failed_uploads: failed } : {}) });
156
+ // --- Guardrail hooks (pre-commit, stop): pure, from local files only, never a network call ---------
157
+ /** The deployment this cwd's session loaded (cache index.json from session-start), checking parent folders too. */
158
+ export function deploymentForCwd(index, cwd) {
159
+ for (let dir = cwd;;) {
160
+ if (index[dir])
161
+ return index[dir];
162
+ const up = dir.replace(/[\\/][^\\/]*$/, "");
163
+ if (!up || up === dir)
164
+ return index[up || "/"];
165
+ dir = up;
166
+ }
167
+ }
168
+ /** A tool's output as text: a string, MCP content blocks, or `{content}` / `{text}` objects (transcripts, hook input). */
169
+ const textOf = (r) => typeof r === "string" ? r : Array.isArray(r) ? r.map(textOf).join("\n") : r && typeof r === "object" ? textOf(r.text ?? r.content) : "";
170
+ /**
171
+ * A use_deployment or create_deployment result (PostToolUse `tool_response`) → the deployment it switched to and a briefing
172
+ * to cache: use_deployment's own, or for create_deployment (JSON `{deployment: {id, name}}`, no briefing yet) just its name line.
173
+ */
174
+ export function switchedTo(response) {
175
+ const briefing = textOf(response);
176
+ const deployment = /^Deployment id: (dep_\w+)/m.exec(briefing)?.[1];
177
+ if (deployment)
178
+ return { deployment, briefing };
179
+ try {
180
+ const created = JSON.parse(briefing).deployment;
181
+ if (typeof created?.id === "string" && /^dep_\w+$/.test(created.id))
182
+ return { deployment: created.id, briefing: `# OpenScaffold — ${String(created.name ?? created.id)}\n` };
183
+ }
184
+ catch { }
185
+ return null;
186
+ }
187
+ /**
188
+ * Whether a switch seen by the use-deployment hook may replace the CLI's cache. The user-scope server (`mcp__openscaffold__…`)
189
+ * is the one login added with this CLI's token: same server and workspace. The plugin's server is the hosted app signed in
190
+ * by its own OAuth, maybe another workspace: only a deployment this login already cached (`known`) on the hosted app. Else no.
191
+ */
192
+ export function switchAllowed(toolName, deployment, apiUrl, known) {
193
+ if (toolName?.startsWith(`mcp__${MCP_NAME}__`))
194
+ return true;
195
+ return Boolean(toolName?.startsWith("mcp__plugin_")) && pluginServes(apiUrl) && new Set(known).has(deployment);
196
+ }
197
+ /** The Claude Code session a CLI command runs in, when Claude Code says (env), so `review` follows that session's deployment. */
198
+ export const callerSession = (env) => env.CLAUDE_SESSION_ID || env.CLAUDE_CODE_SESSION_ID || undefined;
199
+ /** session-start's per-Claude-session record (cache sessions.json): this session's entry added, only the newest `keep` kept. */
200
+ export function withSession(sessions, sessionId, entry, keep = 200) {
201
+ const all = Object.entries({ ...sessions, [sessionId]: entry }).sort(([, a], [, b]) => b.at.localeCompare(a.at));
202
+ return Object.fromEntries(all.slice(0, keep));
203
+ }
204
+ /**
205
+ * The cache after loading a deployment: the Claude session's own entry, and the folder's default. A mid-session switch
206
+ * with a known session moves only that session's entry, so another session in the folder (or a `review` that can't tell
207
+ * which session called it) never picks it up; a session start, or a switch with no session, sets the folder default. Pure.
208
+ */
209
+ export function rememberedCache(index, sessions, cwd, sessionId, entry, switching = false) {
210
+ return {
211
+ index: switching && sessionId ? index : { ...index, [cwd]: entry },
212
+ sessions: sessionId ? withSession(sessions, sessionId, entry) : sessions,
213
+ };
214
+ }
215
+ /** The deployment a hook's session loaded: by its Claude session_id first (two sessions can share a folder), else the folder's. */
216
+ export const loadedEntry = (sessions, index, sessionId, cwd) => (sessionId ? sessions[sessionId] : undefined) ?? deploymentForCwd(index, cwd);
217
+ /** A briefing section's list lines (§8.3), citations dropped: `- On-prem only` from `- On-prem only [Ticket · doc_… “…”]`. */
218
+ function sectionLines(md, heading, max = 10) {
219
+ const lines = md.split("\n");
220
+ const at = lines.findIndex((l) => l.trim() === `## ${heading}`);
221
+ if (at < 0)
222
+ return [];
223
+ const out = [];
224
+ for (const l of lines.slice(at + 1)) {
225
+ if (l.startsWith("## ") || l.startsWith("# "))
226
+ break;
227
+ if (l.startsWith("- ") && !l.startsWith("- …"))
228
+ out.push(l.replace(/\s\[[^\]]*\]$/, ""));
229
+ }
230
+ return out.slice(0, max);
231
+ }
232
+ /**
233
+ * The PreToolUse hook's stdout before a `git commit`: JSON with `hookSpecificOutput.additionalContext` naming the
234
+ * deployment's constraints and must-haves from the cached briefing, or "" when there's nothing to say.
235
+ */
236
+ export function preCommitOutput(briefing, plugin) {
237
+ if (!briefing)
238
+ return "";
239
+ const name = /^# OpenScaffold — (.+)$/m.exec(briefing)?.[1]?.trim();
240
+ const constraints = sectionLines(briefing, "Constraints");
241
+ const musts = sectionLines(briefing, "In scope (must)");
242
+ if (!constraints.length && !musts.length)
243
+ return "";
244
+ const command = plugin ? "/openscaffold:check-plan" : "/os-check-plan";
245
+ const context = [
246
+ `OpenScaffold: before this commit, check it against ${name ?? "the customer"}'s plan.`,
247
+ ...(constraints.length ? ["Constraints:", ...constraints] : []),
248
+ ...(musts.length ? ["Must-haves:", ...musts] : []),
249
+ `Run ${command} if this change touches them.`,
250
+ ].join("\n");
251
+ return JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", additionalContext: context } });
252
+ }
253
+ const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
254
+ /** Our report_session tool, from the user-scope server or the plugin's (`mcp__plugin_openscaffold_openscaffold__…`). */
255
+ const REPORT = /^mcp__.*openscaffold.*__report_session$/;
256
+ /** The calls whose output can be a review: our get_review (user-scope or plugin), or the CLI's `review` through Bash. */
257
+ const GET_REVIEW = /^mcp__.*openscaffold.*__get_review$/;
258
+ const CLI_REVIEW = /(@openscaffold\/cli(@\S+)?|\bopenscaffold)\s+review\b/;
259
+ /** A finished review's verdict line (get_review's Markdown, which the CLI prints). Running and failed reviews have none. */
260
+ const VERDICT = "\nVerdict: ";
261
+ /**
262
+ * From a session transcript (JSONL): did the agent edit files, get a finished review back, call report_session? A review
263
+ * counts only as the tool_result of a get_review or Bash `… review` call (matched by tool_use_id) holding a verdict, so a
264
+ * failed or still-running review, or reading a file that contains one, doesn't.
265
+ */
266
+ export function transcriptActivity(jsonl) {
267
+ let edited = false;
268
+ let reported = false;
269
+ let reviewed = false;
270
+ const reviewCalls = new Set();
271
+ for (const line of jsonl.split("\n")) {
272
+ if (!line.includes('"tool_use"') && !(line.includes('"tool_result"') && line.includes("Verdict: ")))
273
+ continue;
274
+ let content;
275
+ try {
276
+ content = JSON.parse(line).message?.content;
277
+ }
278
+ catch {
279
+ continue;
280
+ }
281
+ if (!Array.isArray(content))
282
+ continue;
283
+ for (const c of content) {
284
+ if (c?.type === "tool_result" && c.tool_use_id && reviewCalls.has(c.tool_use_id) && textOf(c.content).includes(VERDICT))
285
+ reviewed = true;
286
+ if (c?.type !== "tool_use" || typeof c.name !== "string")
287
+ continue;
288
+ if (EDIT_TOOLS.has(c.name))
289
+ edited = true;
290
+ if (REPORT.test(c.name))
291
+ reported = true;
292
+ if (c.id && (GET_REVIEW.test(c.name) || (c.name === "Bash" && typeof c.input?.command === "string" && CLI_REVIEW.test(c.input.command))))
293
+ reviewCalls.add(c.id);
294
+ }
295
+ }
296
+ return { edited, reported, reviewed };
297
+ }
298
+ export const STOP_REASON = "Before you finish, call report_session with what you did.";
299
+ export const STOP_REVIEW_REASON = "Before you finish, review your changes against the customer's plan: run `npx -y @openscaffold/cli@latest review --req req_1,req_2` with the requirement ids you worked on (it sends the diff and prints the verdict; usually 30–90 s, up to ~7 min; don't wrap it in `timeout`), then report_session.";
300
+ /**
301
+ * The Stop hook's stdout: the documented block decision, once per session, when this session loaded a deployment and
302
+ * edited files: first ask for a review (§8.4) if no review result came back, else for report_session if it never
303
+ * reported; else "". Never twice, and never while Claude is already continuing for a stop hook.
304
+ */
305
+ export function stopOutput(s) {
306
+ if (s.stopHookActive || s.reminded || !s.loaded || !s.edited)
307
+ return "";
308
+ if (!s.reviewed)
309
+ return JSON.stringify({ decision: "block", reason: STOP_REVIEW_REASON });
310
+ if (!s.reported)
311
+ return JSON.stringify({ decision: "block", reason: STOP_REASON });
312
+ return "";
313
+ }
314
+ // --- setup: one command from a folder of customer files to a cited plan (SPEC §8.4) -------------
315
+ /** What `setup` uploads: the upload panel's file types (§6.1). CSV/JSON become one ticket per row. */
316
+ export const SETUP_EXTENSIONS = [".vtt", ".srt", ".txt", ".md", ".csv", ".json"];
317
+ /** Under Vercel's request limit, like the upload panel. */
318
+ export const MAX_FILE_BYTES = 4.2 * 1024 * 1024;
319
+ /** `setup <domain> [folder]`: "acme.com", "@acme.com" or "dana@acme.com" → acme.com; folder defaults to ".". */
320
+ export function parseSetupArgs(args) {
321
+ const [raw, folder = ".", ...extra] = args.filter((a) => !a.startsWith("--"));
322
+ const usage = "Usage: npx @openscaffold/cli@latest setup <customer domain> [folder], e.g. setup acme.com ./customer-docs";
323
+ if (!raw)
324
+ return { error: usage };
325
+ if (extra.length)
326
+ return { error: `Too many arguments. ${usage}` };
327
+ const domain = raw.trim().toLowerCase().replace(/^.*@/, "").replace(/^https?:\/\//, "").replace(/\/.*$/, "").replace(/^www\./, "");
328
+ if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(domain))
329
+ return { error: `"${raw}" isn't an email domain. ${usage}` };
330
+ return { domain, folder };
331
+ }
332
+ /** acme-logistics.com → "Acme Logistics" (the workspace step's rule, §9). */
333
+ export const nameFromDomain = (domain) => domain.split(".")[0].split("-").filter(Boolean).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
334
+ /** The folder's files (top level only) → what to upload, sorted, and what's skipped with why. Hidden files are ignored. */
335
+ export function selectFiles(entries) {
336
+ const upload = [];
337
+ const skipped = [];
338
+ for (const e of [...entries].sort((a, b) => a.name.localeCompare(b.name))) {
339
+ if (!e.file || e.name.startsWith("."))
340
+ continue;
341
+ const ext = e.name.slice(e.name.lastIndexOf(".")).toLowerCase();
342
+ if (!e.name.includes(".") || !SETUP_EXTENSIONS.includes(ext))
343
+ skipped.push({ name: e.name, why: "not a supported type" });
344
+ else if (e.size === 0)
345
+ skipped.push({ name: e.name, why: "empty" });
346
+ else if (e.size > MAX_FILE_BYTES)
347
+ skipped.push({ name: e.name, why: "over 4.2 MB; split it or add it in the app" });
348
+ else
349
+ upload.push(e.name);
350
+ }
351
+ return { upload, skipped };
352
+ }
353
+ /** A markdown section's non-empty lines (the heading excluded), up to the next heading. */
354
+ function section(md, heading) {
355
+ const out = [];
356
+ let on = false;
357
+ for (const line of md.split("\n")) {
358
+ if (/^#{1,2} /.test(line))
359
+ on = line === `## ${heading}`;
360
+ else if (on && line.trim() && !/^\(plan v\d+\)$/.test(line.trim()))
361
+ out.push(line);
362
+ }
363
+ return out;
364
+ }
365
+ /**
366
+ * The plan in a few cited lines, from get_plan and get_profile (full lists, every item with its quotes, unlike the
367
+ * session briefing): Goal, In scope (must), Constraints, Open questions. Empty sections are left out.
368
+ */
369
+ export function planSummary(plan, profile) {
370
+ const parts = [
371
+ ["Goal", section(plan, "Goal")],
372
+ ["In scope (must)", section(plan, "In scope").filter((l) => /^- \S+ \[must,/.test(l))],
373
+ ["Constraints", section(profile, "Constraints")],
374
+ ["Open questions", section(plan, "Open questions")],
375
+ ];
376
+ return parts.filter(([, lines]) => lines.length).map(([h, lines]) => [`## ${h}`, ...lines].join("\n")).join("\n");
377
+ }
378
+ /** The Status line of a briefing written before the plan exists, if there is one. */
379
+ export function briefingStatus(briefing) {
380
+ const line = /^## Status\n(.+)$/m.exec(briefing)?.[1]?.trim();
381
+ if (!line)
382
+ return null;
383
+ // Written for an agent: drop the sentences that tell it which tool to call next.
384
+ return line.split(/(?<=[.;])\s+/).filter((x) => !/`|tell the FDE/i.test(x)).join(" ").replace(/;$/, ".");
385
+ }
386
+ /** The verified facts a pre-plan briefing's Status lists ("- Goal: … [title · doc_… “quote”]"), without the document ids. */
387
+ export function briefingFacts(briefing) {
388
+ return section(briefing, "Status").filter((l) => l.startsWith("- ")).map((l) => l.replace(/ · doc_\w+/, ""));
389
+ }
390
+ /**
391
+ * One MCP `tools/call` response body (JSON, or a streamable-HTTP SSE frame) → the tool's text, or its error. The first
392
+ * content block only: a second one may carry the "Since your briefing" freshness line.
393
+ */
394
+ export function mcpText(raw) {
395
+ const data = raw.split("\n").find((l) => l.startsWith("data: "))?.slice(6) ?? raw;
396
+ let msg;
397
+ try {
398
+ msg = JSON.parse(data);
399
+ }
400
+ catch {
401
+ return { error: "OpenScaffold sent an answer this CLI can't read. Try `npx @openscaffold/cli@latest`." };
402
+ }
403
+ if (msg.error)
404
+ return { error: msg.error.message ?? "OpenScaffold refused the call." };
405
+ const text = msg.result?.content?.[0]?.text ?? "";
406
+ return msg.result?.isError ? { error: text || "OpenScaffold refused the call." } : { text };
407
+ }
408
+ // --- review: send this folder's change to OpenScaffold's reviewer (SPEC §8.1 request_review) ------
409
+ /** request_review's limit (web/lib/pipeline/review.ts MAX_DIFF_BYTES). */
410
+ export const MAX_DIFF_BYTES = 400 * 1024;
411
+ const REVIEW_CMD = "npx -y @openscaffold/cli@latest review";
412
+ const REVIEW_USAGE = `Usage: ${REVIEW_CMD} [--req req_1,req_2] [--title "…"] [--deployment dep_…] [files…], e.g. ${REVIEW_CMD} --req req_1`;
413
+ export const REVIEW_WAIT = "Usually 30–90 s, up to ~7 min; don't wrap it in `timeout`.";
414
+ export const REVIEW_HELP = [
415
+ REVIEW_USAGE,
416
+ "",
417
+ "Reviews this folder's change against the customer's plan and rules: sends the git diff (or the listed files, where git",
418
+ "sees no change) and prints the verdict.",
419
+ " --req ids The plan's requirement ids this change works on (from the briefing or get_plan)",
420
+ ' --title "…" One line: what the change does (default: those requirements\' titles); a bare phrase works too',
421
+ " --deployment id The customer (default: the one this folder's session loaded)",
422
+ " files… Narrow the change to these paths; outside git, the files to send whole",
423
+ REVIEW_WAIT,
424
+ ].join("\n");
425
+ /** The title when none is given and the cached briefing names none of the claimed requirements. */
426
+ export const DEFAULT_REVIEW_TITLE = "Review of current changes";
427
+ /**
428
+ * `review` arguments; files are optional (git finds the change) and needed only where git sees none. A bare argument
429
+ * that isn't a file (`exists`) is the title, so `review "Fix X"` works. With no title, DEFAULT_REVIEW_TITLE (see reviewTitle).
430
+ */
431
+ export function parseReviewArgs(args, exists = () => true) {
432
+ const out = { title: "", reqs: [], deployment: undefined, files: [] };
433
+ for (let i = 0; i < args.length; i++) {
434
+ const eq = args[i].startsWith("--") ? args[i].indexOf("=") : -1;
435
+ const [flag, inline] = eq < 0 ? [args[i], undefined] : [args[i].slice(0, eq), args[i].slice(eq + 1)];
436
+ const value = () => inline ?? args[++i];
437
+ if (flag === "--help" || flag === "-h")
438
+ return { help: true };
439
+ if (flag === "--title")
440
+ out.title = value() ?? "";
441
+ else if (flag === "--req" || flag === "--requirements")
442
+ out.reqs.push(...(value() ?? "").split(/[,\s]+/).filter(Boolean));
443
+ else if (flag === "--deployment")
444
+ out.deployment = value();
445
+ else if (/^-./.test(args[i]))
446
+ return { error: `Unknown option ${flag}. ${REVIEW_USAGE}` };
447
+ else if (/^req_\w+$/.test(args[i]) && !exists(args[i]))
448
+ out.reqs.push(args[i]); // `--req req_1 req_2`
449
+ // ponytail: a deleted file named alone reads as the title; then the whole change is reviewed, which still includes it.
450
+ else if (!out.title && !exists(args[i]))
451
+ out.title = args[i];
452
+ else
453
+ out.files.push(args[i]);
454
+ }
455
+ return { ...out, title: out.title.trim() || DEFAULT_REVIEW_TITLE, reqs: [...new Set(out.reqs)] };
456
+ }
457
+ /** A CLI command line to copy and run: `npx -y @openscaffold/cli@latest <args>`, quoting what a shell would split. */
458
+ export const commandLine = (args) => ["npx -y @openscaffold/cli@latest", ...args.map((a) => (/^[\w./:@=,-]+$/.test(a) ? a : JSON.stringify(a)))].join(" ");
459
+ /** `review` with no --deployment and none cached for this session or folder: the same command with each cached customer's id. */
460
+ export function whichCustomerError(args, known) {
461
+ const head = "Which customer? This session has no OpenScaffold deployment cached here.";
462
+ if (!known.length)
463
+ return `${head} Add the id from the OpenScaffold briefing ("Deployment id: …", or call list_deployments), e.g. ${commandLine([...args, "--deployment", "dep_01EXAMPLE"])}`;
464
+ return [`${head} Run one of:`, ...known.map((d) => ` ${commandLine([...args, "--deployment", d.id])}${d.name ? ` # ${d.name}` : ""}`)].join("\n");
465
+ }
466
+ /** The default title: the claimed requirements' titles from the cached briefing's must-haves (`- req_1 Title — status`). */
467
+ export function reviewTitle(reqs, briefing) {
468
+ const line = (id) => briefing?.split("\n").find((l) => l.startsWith(`- ${id} `));
469
+ const titles = reqs.map((id) => line(id)?.slice(id.length + 3).replace(/^\[[^\]]*\] /, "").replace(/ — \S+$/, "")).filter(Boolean);
470
+ return titles.length ? titles.join("; ").slice(0, 300) : DEFAULT_REVIEW_TITLE;
471
+ }
472
+ /**
473
+ * A whole file as an added-file diff, as `git diff --no-index /dev/null <file>` prints it. Empty or binary: "". A
474
+ * symbolic link is a link (mode 120000, its target path as the content), as git records it.
475
+ */
476
+ export function newFileDiff(path, file) {
477
+ const link = typeof file === "object";
478
+ const text = link ? file.link : file;
479
+ if (!text || text.includes("\0"))
480
+ return "";
481
+ const lines = link ? [text] : text.replace(/\r\n/g, "\n").split("\n");
482
+ const newline = !link && lines.at(-1) === "";
483
+ if (newline)
484
+ lines.pop();
485
+ return [`diff --git a/${path} b/${path}`, `new file mode ${link ? "120000" : "100644"}`, "--- /dev/null", `+++ b/${path}`, `@@ -0,0 +1,${lines.length} @@`,
486
+ ...lines.map((l) => `+${l}`), ...(newline ? [] : ["\"]), ""].join("\n");
487
+ }
488
+ /**
489
+ * The change to review, from where the agent works: in a git repo, everything since HEAD (staged, unstaged, and
490
+ * untracked files as new files), narrowed to `files` when given. Where git sees nothing (not a repo, a git-ignored
491
+ * folder, a repo with no commit), the listed files are sent whole as new files. `git` runs one git command in the
492
+ * folder and throws when git fails; `read` reads a file.
493
+ */
494
+ export function collectDiff(files, git, read) {
495
+ let diff = "";
496
+ let repo = false;
497
+ try {
498
+ repo = git(["rev-parse", "--is-inside-work-tree"]).trim() === "true";
499
+ }
500
+ catch { }
501
+ if (repo) {
502
+ const only = files.length ? ["--", ...files] : [];
503
+ let head = true;
504
+ try {
505
+ git(["rev-parse", "--verify", "-q", "HEAD"]);
506
+ }
507
+ catch {
508
+ head = false;
509
+ }
510
+ // No commit yet: compare with the empty tree, so staged and unstaged files both show.
511
+ const base = head ? "HEAD" : git(["hash-object", "-t", "tree", "/dev/null"]).trim();
512
+ // --no-ext-diff / --no-textconv: a repo's configured diff drivers and converters are commands; we never run them.
513
+ diff = git(["diff", "--no-color", "--no-ext-diff", "--no-textconv", base, ...only]);
514
+ const untracked = git(["ls-files", "--others", "--exclude-standard", "-z", ...only]).split("\0").filter(Boolean);
515
+ // Files the agent named that git ignores still get reviewed, even when other files changed.
516
+ if (files.length)
517
+ untracked.push(...git(["ls-files", "--others", "--ignored", "--exclude-standard", "-z", ...only]).split("\0").filter(Boolean));
518
+ const prefix = git(["rev-parse", "--show-prefix"]).trim();
519
+ diff += untracked.map((f) => newFileDiff(prefix + f, read(f))).join(""); // ls-files paths are from this folder; diff paths from the root
520
+ }
521
+ if (!diff.trim() && files.length)
522
+ diff = files.map((f) => newFileDiff(f.replace(/^\.\//, ""), read(f))).join("");
523
+ if (!diff.trim()) {
524
+ return { error: repo
525
+ ? `Nothing to review: git sees no change here. If your files are in a git-ignored folder, list them: ${REVIEW_USAGE}`
526
+ : `This folder isn't a git repo, so list the files you changed (each is sent whole as a new file), e.g. ${REVIEW_CMD} --req req_1 src/report.py. Or send \`git diff --no-index /dev/null <file>\` for each file with the request_review tool.` };
527
+ }
528
+ if (Buffer.byteLength(diff) > MAX_DIFF_BYTES)
529
+ 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.` };
530
+ return { diff };
531
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openscaffold/cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Connect Claude Code to your OpenScaffold workspace: each session loads the right customer deployment.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -30,13 +30,11 @@
30
30
  "forward-deployed-engineering"
31
31
  ],
32
32
  "license": "UNLICENSED",
33
- "repository": {
34
- "type": "git",
35
- "url": "git+https://github.com/Sigma-M-A/openscaffold.git",
36
- "directory": "cli"
37
- },
38
- "homepage": "https://github.com/Sigma-M-A/openscaffold",
33
+ "homepage": "https://docs.openscaffold.ai",
39
34
  "publishConfig": {
40
35
  "access": "public"
36
+ },
37
+ "bugs": {
38
+ "url": "https://docs.openscaffold.ai/troubleshooting"
41
39
  }
42
40
  }