@openscaffold/cli 0.1.1 → 0.1.2

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,33 @@ 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 --title "…" --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`
18
33
  - `/os-requirements`, `/os-what-changed`, `/os-check-plan`, `/os-demo-check` in `~/.claude/skills/`
19
34
 
20
- Requires Node 22+ and Claude Code. Set `OPENSCAFFOLD_URL` to point at a self-hosted OpenScaffold.
35
+ Requires Node 22+ and Claude Code. Set `OPENSCAFFOLD_URL` to point at a self-hosted OpenScaffold. Set
36
+ `OPENSCAFFOLD_NO_BROWSER=1` to not open a browser (SSH sessions and headless Linux skip it by themselves); open the
37
+ 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, parseReviewArgs, 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");
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" };
81
+ try {
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
+ }
53
97
  try {
54
- execFileSync("claude", args, { stdio: "ignore", timeout: 30_000 });
55
- return true;
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 () => {
@@ -150,7 +360,11 @@ async function sessionStart() {
150
360
  if (dep) {
151
361
  mkdirSync(CACHE, { recursive: true });
152
362
  writeFileSync(join(CACHE, `${dep}.md`), briefing);
153
- writeJson(join(CACHE, "index.json"), { ...index, [cwd]: { deployment: dep, at: new Date().toISOString(), login: loginKey(creds) } });
363
+ const entry = { deployment: dep, at: new Date().toISOString(), login: loginKey(creds) };
364
+ writeJson(join(CACHE, "index.json"), { ...index, [cwd]: entry });
365
+ // Per Claude session too: two sessions in one folder on different customers each keep their own guardrails.
366
+ if (hook.session_id)
367
+ writeJson(SESSIONS, withSession(readJson(SESSIONS, {}), hook.session_id, entry));
154
368
  }
155
369
  }
156
370
  catch {
@@ -159,18 +373,86 @@ async function sessionStart() {
159
373
  console.log(cached ? `${cached}\n\n(offline, from ${last?.at})` : "OpenScaffold: offline");
160
374
  }
161
375
  }
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() {
376
+ // --- guardrail hooks (pre-commit, stop): local files only, no network, never block a tool --------
377
+ const loadedHere = (sessionId, cwd) => {
165
378
  const creds = credentials();
166
- let mcp = null;
379
+ const entry = creds ? loadedEntry(readJson(SESSIONS, {}), readJson(join(CACHE, "index.json"), {}), sessionId, cwd) : undefined;
380
+ return entry && entry.login === loginKey(creds) ? entry : undefined;
381
+ };
382
+ const hookInput = async () => {
167
383
  try {
168
- execFileSync("claude", ["mcp", "get", MCP_NAME], { stdio: "ignore", timeout: 15_000 });
169
- mcp = true;
384
+ return JSON.parse((await readStdin()) || "{}");
385
+ }
386
+ catch {
387
+ return {};
388
+ }
389
+ };
390
+ /** PreToolUse on `git commit`: the deployment's constraints and must-haves from the cached briefing, as context. */
391
+ async function preCommit() {
392
+ const hook = await hookInput();
393
+ const entry = loadedHere(hook.session_id, hook.cwd ?? process.cwd());
394
+ const file = entry && join(CACHE, `${entry.deployment}.md`);
395
+ const out = preCommitOutput(file && existsSync(file) ? readFileSync(file, "utf8") : null, hasOpenScaffoldPlugin(null, existsSync(PLUGINS) ? readFileSync(PLUGINS, "utf8") : null));
396
+ if (out)
397
+ console.log(out);
398
+ }
399
+ /** Stop: once per session, if it edited files and never called request_review (or report_session), ask for it. */
400
+ async function stop() {
401
+ const hook = await hookInput();
402
+ if (!hook.session_id || hook.stop_hook_active)
403
+ return;
404
+ const marker = join(CACHE, `reminded-${hook.session_id.replace(/[^\w-]/g, "")}`);
405
+ const transcript = hook.transcript_path?.replace(/^~(?=[\\/])/, homedir());
406
+ const activity = transcript && existsSync(transcript) ? transcriptActivity(readFileSync(transcript, "utf8")) : { edited: false, reported: false, reviewed: false };
407
+ const out = stopOutput({ stopHookActive: false, reminded: existsSync(marker), loaded: Boolean(loadedHere(hook.session_id, hook.cwd ?? process.cwd())), ...activity });
408
+ if (!out)
409
+ return;
410
+ mkdirSync(CACHE, { recursive: true });
411
+ writeFileSync(marker, new Date().toISOString());
412
+ console.log(out);
413
+ }
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);
170
443
  }
171
444
  catch (e) {
172
- mcp = e.code === "ENOENT" ? null : false;
445
+ if (e instanceof LoggedOut)
446
+ throw new Error(`${e.message} Run \`npx -y @openscaffold/cli@latest login\`, then review again.`);
447
+ throw e;
173
448
  }
449
+ }
450
+ // --- status -------------------------------------------------------------------------------------
451
+ /** Read-only: what's set up on this machine and the next step. `--json` for agents. Always exits 0. */
452
+ async function status() {
453
+ const creds = credentials();
454
+ const got = runClaude(["mcp", "get", MCP_NAME], { timeout: 15_000 }).result;
455
+ const mcp = got === "missing" ? null : got === "ok";
174
456
  // An authenticated request, so a revoked token isn't reported as ready: the MCP endpoint answers 401 to a bad token.
175
457
  const server = creds
176
458
  ? await fetch(new URL("/api/mcp/mcp", creds.api_url), {
@@ -186,17 +468,21 @@ async function status() {
186
468
  : null;
187
469
  const onDisk = existsSync(SKILLS) ? readdirSync(SKILLS).filter((d) => d.startsWith("os-") && existsSync(join(SKILLS, d, "SKILL.md"))) : [];
188
470
  const names = expected ?? onDisk;
471
+ const hooks = installedHooks(readJson(SETTINGS, {}));
189
472
  const state = {
190
473
  workspace: creds?.workspace.name ?? null,
191
474
  apiUrl: creds?.api_url ?? null,
192
475
  server,
193
476
  mcp,
194
- hook: hasSessionHook(readJson(SETTINGS, {})),
477
+ hook: hooks.includes("session-start"),
478
+ guardrails: hooks.includes("pre-commit") && hooks.includes("stop"),
195
479
  skills: names.filter((n) => onDisk.includes(n)).length,
196
480
  skillsExpected: names.length,
481
+ plugin: mcp === null ? false : pluginInstalled() && pluginServes(creds?.api_url),
197
482
  };
198
483
  const report = statusReport(state);
199
484
  console.log(process.argv.includes("--json") ? JSON.stringify({ ...state, ready: report.ready, next: report.next }, null, 2) : report.text);
485
+ return report.ready;
200
486
  }
201
487
  // --- logout -------------------------------------------------------------------------------------
202
488
  async function logout() {
@@ -205,27 +491,68 @@ async function logout() {
205
491
  await api(creds.api_url, "/api/connect/revoke", {}, { token: creds.token, timeoutMs: 5000 }).catch(() => null);
206
492
  claude(["mcp", "remove", "--scope", "user", MCP_NAME]);
207
493
  if (existsSync(SETTINGS))
208
- writeJson(SETTINGS, withoutSessionHook(readJson(SETTINGS, {})));
494
+ writeJson(SETTINGS, withoutHooks(readJson(SETTINGS, {})));
209
495
  if (existsSync(SKILLS))
210
496
  for (const d of readdirSync(SKILLS))
211
497
  if (d.startsWith("os-"))
212
498
  rmSync(join(SKILLS, d), { recursive: true, force: true });
213
499
  rmSync(CONFIG, { recursive: true, force: true });
214
- console.log("Logged out: token revoked, MCP server, hook, /os-* commands and local credentials removed.");
500
+ console.log("Logged out: token revoked, MCP server, hooks, /os-* commands and local credentials removed.");
215
501
  }
216
502
  // --- main ---------------------------------------------------------------------------------------
503
+ const HELP = `OpenScaffold CLI: connect Claude Code to your OpenScaffold workspace. Docs: https://docs.openscaffold.ai/cli
504
+
505
+ Usage: npx @openscaffold/cli@latest <command>
506
+
507
+ setup <domain> [folder]
508
+ Everything in one go: sign in if needed, create the customer's deployment, upload
509
+ every .vtt .srt .txt .md .csv .json in the folder (default .), wait for the plan, print it
510
+ 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
+ status [--json] What's set up on this machine, and the next step
515
+ logout Remove everything login added and revoke this machine's login
516
+ help This text
517
+ (session-start, pre-commit and stop are run by Claude Code's hooks)
518
+
519
+ Environment:
520
+ OPENSCAFFOLD_URL Server to sign in to (default ${DEFAULT_URL}; e.g. http://localhost:3000)
521
+ OPENSCAFFOLD_NO_BROWSER Set to 1 to not open a browser; open the printed link on any device instead
522
+ (SSH and headless Linux are detected and skip the browser by themselves)`;
217
523
  const [cmd] = process.argv.slice(2);
218
- const commands = { login, status, "session-start": sessionStart, logout };
524
+ /** Run by a hook: must never break the session or block a tool, so any problem exits 0 quietly. */
525
+ const HOOKS = new Set(["session-start", "pre-commit", "stop"]);
526
+ if (cmd === "help" || cmd === "--help" || cmd === "-h") {
527
+ console.log(HELP);
528
+ process.exit(0);
529
+ }
530
+ // npx doesn't enforce "engines": say it plainly instead of failing on a missing API later.
531
+ if (Number(process.versions.node.split(".")[0]) < 22) {
532
+ 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.`;
533
+ if (cmd === "session-start")
534
+ console.log(`OpenScaffold: ${msg}`);
535
+ else if (!HOOKS.has(cmd ?? ""))
536
+ console.log(msg);
537
+ process.exit(HOOKS.has(cmd ?? "") ? 0 : 1);
538
+ }
539
+ const commands = { setup, login: () => login(), review, status, "session-start": sessionStart, "pre-commit": preCommit, stop, logout };
219
540
  const run = commands[cmd ?? ""];
220
541
  if (!run) {
221
- console.error("Usage: npx @openscaffold/cli <login | status | logout>\n(session-start is run by Claude Code's SessionStart hook)");
542
+ console.error(HELP);
222
543
  process.exit(1);
223
544
  }
224
- run().then(() => process.exit(0), (e) => {
225
- if (cmd === "session-start") {
226
- console.log("OpenScaffold: offline"); // never break a session
545
+ run().then(() => process.exit(), // process.exitCode, if a command set one (setup with failed uploads)
546
+ (e) => {
547
+ if (HOOKS.has(cmd ?? "")) {
548
+ if (cmd === "session-start")
549
+ console.log("OpenScaffold: offline"); // never break a session
227
550
  process.exit(0);
228
551
  }
229
552
  console.error(e instanceof Error ? e.message : e);
553
+ if (cmd === "login")
554
+ console.log(loginResultLine("error", { message: e instanceof Error ? e.message : String(e) }));
555
+ if (cmd === "setup")
556
+ console.log(JSON.stringify({ openscaffold_setup: "error", message: e instanceof Error ? e.message : String(e) }));
230
557
  process.exit(1);
231
558
  });
package/dist/install.js CHANGED
@@ -1,49 +1,65 @@
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)"?$/.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
+ * Timeouts are seconds. None ever blocks a tool call.
13
+ */
14
+ export const OUR_HOOKS = [
15
+ { event: "SessionStart", sub: "session-start", matcher: HOOK_MATCHER, handler: { timeout: 10 } },
16
+ { event: "PreToolUse", sub: "pre-commit", matcher: "Bash", handler: { if: "Bash(git commit *)", timeout: 2 } },
17
+ { event: "Stop", sub: "stop", matcher: undefined, handler: { timeout: 5 } },
18
+ ];
19
+ const withoutOurs = (groups = []) => groups.map((g) => ({ ...g, hooks: g.hooks.filter((h) => !isOurs(h)) })).filter((g) => g.hooks.length > 0);
20
+ /** Adds (or replaces) our hooks, `<base> <subcommand>` each; leaves everything else in settings.json as it was. */
21
+ export function withHooks(settings, base) {
10
22
  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 };
23
+ const hooks = { ...out.hooks };
24
+ for (const h of OUR_HOOKS) {
25
+ 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 }] };
26
+ hooks[h.event] = [...withoutOurs(hooks[h.event]), group];
27
+ }
28
+ out.hooks = hooks;
16
29
  return out;
17
30
  }
18
- /** Removes our SessionStart hook (and empty leftovers); leaves other hooks alone. */
19
- export function withoutSessionHook(settings) {
31
+ /** Removes our hooks (and empty leftovers) from every event; leaves other hooks alone. */
32
+ export function withoutHooks(settings) {
20
33
  const out = structuredClone(settings);
21
- if (!out.hooks?.SessionStart)
34
+ if (!out.hooks)
22
35
  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;
36
+ for (const event of Object.keys(out.hooks)) {
37
+ const groups = withoutOurs(out.hooks[event]);
38
+ if (groups.length)
39
+ out.hooks[event] = groups;
40
+ else
41
+ delete out.hooks[event];
42
+ }
28
43
  if (!Object.keys(out.hooks).length)
29
44
  delete out.hooks;
30
45
  return out;
31
46
  }
32
47
  /**
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.
48
+ * The command our hooks run, without the subcommand. Published installs run through npx; a local checkout
49
+ * (development) runs its own built file, so the hooks work before the package is on npm.
35
50
  */
36
- export function hookCommand(selfPath, execPath) {
51
+ export function hookBase(selfPath, execPath) {
37
52
  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`;
53
+ return "npx -y @openscaffold/cli";
54
+ return `${JSON.stringify(execPath)} ${JSON.stringify(selfPath)}`;
40
55
  }
41
- /** A personal skill file for one default command: /os-<name>. */
42
- export function skillFile(c) {
56
+ /** A skill file for one default command: /os-<name> as a personal skill, or /openscaffold:<name> in the plugin. */
57
+ export function skillFile(c, name = `os-${c.name}`) {
43
58
  return [
44
59
  "---",
45
- `name: os-${c.name}`,
46
- `description: ${c.description.replace(/\n/g, " ")} (OpenScaffold)`,
60
+ `name: ${name}`,
61
+ // JSON-quoted: a valid YAML string whatever the text holds (a ": " or "#" would break a plain value).
62
+ `description: ${JSON.stringify(`${c.description.replace(/\n/g, " ")} (OpenScaffold)`)}`,
47
63
  "---",
48
64
  "",
49
65
  c.prompt,
@@ -58,28 +74,367 @@ export function gitInfo(remoteOut, branchOut) {
58
74
  const branch = branchOut?.trim() || undefined;
59
75
  return { git_remote: remote, git_branch: branch };
60
76
  }
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";
77
+ /** Which of our hooks are in settings.json, e.g. ["session-start", "pre-commit", "stop"]. */
78
+ 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);
79
+ export const hasSessionHook = (settings) => installedHooks(settings).includes("session-start");
80
+ /** The hosted app, which the Claude Code plugin's MCP server always points at (plugin/.mcp.json). */
81
+ export const HOSTED = "https://app.openscaffold.ai";
82
+ /** The plugin's MCP server is this login's server: only then can login leave the MCP server and commands to it. */
83
+ export const pluginServes = (apiUrl) => (apiUrl ?? "").replace(/\/+$/, "") === HOSTED;
64
84
  const RELOGIN = (apiUrl) => `npx @openscaffold/cli login${apiUrl && apiUrl !== HOSTED ? ` (or OPENSCAFFOLD_URL=${HOSTED} npx @openscaffold/cli login to use the hosted app)` : ""}`;
65
85
  /**
66
86
  * `openscaffold status`: one line per piece of the setup plus the single next step, in words a person or a
67
87
  * coding agent can act on (the agent onboarding playbook reads it). `mcp: null` = Claude Code isn't on PATH.
68
88
  */
69
89
  export function statusReport(s) {
70
- const commandsOk = s.skillsExpected > 0 && s.skills >= s.skillsExpected;
90
+ // With the plugin, the MCP server and commands come from it; login installs only the hook.
91
+ const commandsOk = s.plugin || (s.skillsExpected > 0 && s.skills >= s.skillsExpected);
92
+ const mcpOk = s.plugin ? true : s.mcp;
71
93
  const lines = [
72
94
  `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"}`,
95
+ `MCP server: ${s.plugin ? "from the openscaffold plugin" : s.mcp === null ? "unknown (Claude Code not on PATH)" : s.mcp ? "added" : "missing"}`,
74
96
  `Session hook: ${s.hook ? "installed" : "missing"}`,
75
- `Commands: ${commandsOk ? `${s.skills} /os-* commands` : s.skills ? `${s.skills} of ${s.skillsExpected || "?"} /os-* commands` : "missing"}`,
97
+ ...(s.guardrails === undefined ? [] : [`Guardrails: ${s.guardrails ? "pre-commit plan check and end-of-session report reminder" : "missing"}`]),
98
+ `Commands: ${s.plugin ? "/openscaffold:* from the plugin" : commandsOk ? `${s.skills} /os-* commands` : s.skills ? `${s.skills} of ${s.skillsExpected || "?"} /os-* commands` : "missing"}`,
76
99
  ];
77
- const next = !s.workspace || s.server === "revoked" || s.mcp === false || !s.hook || !commandsOk
100
+ const next = !s.workspace || s.server === "revoked" || mcpOk === false || !s.hook || s.guardrails === false || !commandsOk
78
101
  ? `Run: ${RELOGIN(s.apiUrl)} (one browser click to approve).`
79
102
  : s.server !== "ok"
80
103
  ? `${s.apiUrl} isn't answering. If it's a local dev server, start it; otherwise run: ${RELOGIN(s.apiUrl)}.`
81
- : s.mcp === null
104
+ : mcpOk === null
82
105
  ? "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
106
  : "Ready. Start a new Claude Code session (or restart this one) to load OpenScaffold.";
84
107
  return { ready: next.startsWith("Ready"), next, text: `OpenScaffold setup\n${lines.map((l) => ` ${l}`).join("\n")}\n\nNext: ${next}` };
85
108
  }
109
+ /**
110
+ * Whether the Claude Code plugin "openscaffold" is installed (and enabled), from `claude plugin list --json`, else
111
+ * from ~/.claude/plugins/installed_plugins.json. Then login adds no second MCP server and no /os-* skills.
112
+ */
113
+ export function hasOpenScaffoldPlugin(listJson, installedJson) {
114
+ const ours = (id) => typeof id === "string" && id.startsWith(`${MCP_NAME}@`);
115
+ try {
116
+ const list = JSON.parse(listJson ?? "null");
117
+ if (Array.isArray(list))
118
+ return list.some((p) => ours(p.id) && p.enabled !== false);
119
+ }
120
+ catch { }
121
+ try {
122
+ const installed = JSON.parse(installedJson ?? "null");
123
+ return Object.keys(installed?.plugins ?? {}).some(ours);
124
+ }
125
+ catch {
126
+ return false;
127
+ }
128
+ }
129
+ /**
130
+ * How to open a URL here, or null when there's no browser to open (a person opens the printed link on any device):
131
+ * OPENSCAFFOLD_NO_BROWSER, an SSH session, or Linux without a display (headless, WSL without a bridge, containers).
132
+ */
133
+ export function browserCommand(url, platform, env) {
134
+ if (env.OPENSCAFFOLD_NO_BROWSER || env.SSH_CONNECTION || env.SSH_TTY)
135
+ return null;
136
+ if (platform === "darwin")
137
+ return ["open", [url]];
138
+ if (platform === "win32")
139
+ return ["cmd", ["/c", "start", "", url]];
140
+ if (env.WSL_DISTRO_NAME)
141
+ return ["wslview", [url]];
142
+ if (!env.DISPLAY && !env.WAYLAND_DISPLAY)
143
+ return null;
144
+ return ["xdg-open", [url]];
145
+ }
146
+ /** A cmd.exe command line for `claude.cmd` (Windows npm installs), which execFile can't run without a shell. */
147
+ export const windowsCommandLine = (args) => args.map((a) => (/^[\w./:@=-]+$/.test(a) ? a : `"${a.replace(/"/g, '\\"')}"`)).join(" ");
148
+ /** The one prompt an FDE pastes into their coding agent to set up a customer (docs: agent-quickstart). */
149
+ 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.`;
150
+ /** The last line `login` prints, for a coding agent running it: one JSON object. */
151
+ export const loginResultLine = (result, extra = {}) => JSON.stringify({ openscaffold_login: result, ...extra });
152
+ /** setup's last line (§8.4). A file that didn't upload turns an otherwise-ok run into "partial", naming the files. */
153
+ export const setupResultLine = (result, failed, extra = {}) => JSON.stringify({ openscaffold_setup: failed.length && result === "ok" ? "partial" : result, ...extra, ...(failed.length ? { failed_uploads: failed } : {}) });
154
+ // --- Guardrail hooks (pre-commit, stop): pure, from local files only, never a network call ---------
155
+ /** The deployment this cwd's session loaded (cache index.json from session-start), checking parent folders too. */
156
+ export function deploymentForCwd(index, cwd) {
157
+ for (let dir = cwd;;) {
158
+ if (index[dir])
159
+ return index[dir];
160
+ const up = dir.replace(/[\\/][^\\/]*$/, "");
161
+ if (!up || up === dir)
162
+ return index[up || "/"];
163
+ dir = up;
164
+ }
165
+ }
166
+ /** session-start's per-Claude-session record (cache sessions.json): this session's entry added, only the newest `keep` kept. */
167
+ export function withSession(sessions, sessionId, entry, keep = 200) {
168
+ const all = Object.entries({ ...sessions, [sessionId]: entry }).sort(([, a], [, b]) => b.at.localeCompare(a.at));
169
+ return Object.fromEntries(all.slice(0, keep));
170
+ }
171
+ /** The deployment a hook's session loaded: by its Claude session_id first (two sessions can share a folder), else the folder's. */
172
+ export const loadedEntry = (sessions, index, sessionId, cwd) => (sessionId ? sessions[sessionId] : undefined) ?? deploymentForCwd(index, cwd);
173
+ /** A briefing section's list lines (§8.3), citations dropped: `- On-prem only` from `- On-prem only [Ticket · doc_… “…”]`. */
174
+ function sectionLines(md, heading, max = 10) {
175
+ const lines = md.split("\n");
176
+ const at = lines.findIndex((l) => l.trim() === `## ${heading}`);
177
+ if (at < 0)
178
+ return [];
179
+ const out = [];
180
+ for (const l of lines.slice(at + 1)) {
181
+ if (l.startsWith("## ") || l.startsWith("# "))
182
+ break;
183
+ if (l.startsWith("- ") && !l.startsWith("- …"))
184
+ out.push(l.replace(/\s\[[^\]]*\]$/, ""));
185
+ }
186
+ return out.slice(0, max);
187
+ }
188
+ /**
189
+ * The PreToolUse hook's stdout before a `git commit`: JSON with `hookSpecificOutput.additionalContext` naming the
190
+ * deployment's constraints and must-haves from the cached briefing, or "" when there's nothing to say.
191
+ */
192
+ export function preCommitOutput(briefing, plugin) {
193
+ if (!briefing)
194
+ return "";
195
+ const name = /^# OpenScaffold — (.+)$/m.exec(briefing)?.[1]?.trim();
196
+ const constraints = sectionLines(briefing, "Constraints");
197
+ const musts = sectionLines(briefing, "In scope (must)");
198
+ if (!constraints.length && !musts.length)
199
+ return "";
200
+ const command = plugin ? "/openscaffold:check-plan" : "/os-check-plan";
201
+ const context = [
202
+ `OpenScaffold: before this commit, check it against ${name ?? "the customer"}'s plan.`,
203
+ ...(constraints.length ? ["Constraints:", ...constraints] : []),
204
+ ...(musts.length ? ["Must-haves:", ...musts] : []),
205
+ `Run ${command} if this change touches them.`,
206
+ ].join("\n");
207
+ return JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", additionalContext: context } });
208
+ }
209
+ const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
210
+ /** Our report_session tool, from the user-scope server or the plugin's (`mcp__plugin_openscaffold_openscaffold__…`). */
211
+ const REPORT = /^mcp__.*openscaffold.*__report_session$/;
212
+ const REVIEW = /^mcp__.*openscaffold.*__request_review$/;
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? */
216
+ export function transcriptActivity(jsonl) {
217
+ let edited = false;
218
+ let reported = false;
219
+ let reviewed = false;
220
+ for (const line of jsonl.split("\n")) {
221
+ if (!line.includes('"tool_use"'))
222
+ continue;
223
+ let content;
224
+ try {
225
+ content = JSON.parse(line).message?.content;
226
+ }
227
+ catch {
228
+ continue;
229
+ }
230
+ if (!Array.isArray(content))
231
+ continue;
232
+ for (const c of content) {
233
+ if (c?.type !== "tool_use" || typeof c.name !== "string")
234
+ continue;
235
+ if (EDIT_TOOLS.has(c.name))
236
+ edited = true;
237
+ if (REPORT.test(c.name))
238
+ 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
+ }
242
+ }
243
+ return { edited, reported, reviewed };
244
+ }
245
+ 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
+ /**
248
+ * The Stop hook's stdout: the documented block decision, once per session, when this session loaded a deployment and
249
+ * edited files: first ask for a review (§8.4) if it never called request_review, else for report_session if it never
250
+ * reported; else "". Never twice, and never while Claude is already continuing for a stop hook.
251
+ */
252
+ export function stopOutput(s) {
253
+ if (s.stopHookActive || s.reminded || !s.loaded || !s.edited)
254
+ return "";
255
+ if (!s.reviewed)
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 "";
260
+ }
261
+ // --- setup: one command from a folder of customer files to a cited plan (SPEC §8.4) -------------
262
+ /** What `setup` uploads: the upload panel's file types (§6.1). CSV/JSON become one ticket per row. */
263
+ export const SETUP_EXTENSIONS = [".vtt", ".srt", ".txt", ".md", ".csv", ".json"];
264
+ /** Under Vercel's request limit, like the upload panel. */
265
+ export const MAX_FILE_BYTES = 4.2 * 1024 * 1024;
266
+ /** `setup <domain> [folder]`: "acme.com", "@acme.com" or "dana@acme.com" → acme.com; folder defaults to ".". */
267
+ export function parseSetupArgs(args) {
268
+ const [raw, folder = ".", ...extra] = args.filter((a) => !a.startsWith("--"));
269
+ const usage = "Usage: npx @openscaffold/cli@latest setup <customer domain> [folder], e.g. setup acme.com ./customer-docs";
270
+ if (!raw)
271
+ return { error: usage };
272
+ if (extra.length)
273
+ return { error: `Too many arguments. ${usage}` };
274
+ const domain = raw.trim().toLowerCase().replace(/^.*@/, "").replace(/^https?:\/\//, "").replace(/\/.*$/, "").replace(/^www\./, "");
275
+ if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(domain))
276
+ return { error: `"${raw}" isn't an email domain. ${usage}` };
277
+ return { domain, folder };
278
+ }
279
+ /** acme-logistics.com → "Acme Logistics" (the workspace step's rule, §9). */
280
+ export const nameFromDomain = (domain) => domain.split(".")[0].split("-").filter(Boolean).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
281
+ /** The folder's files (top level only) → what to upload, sorted, and what's skipped with why. Hidden files are ignored. */
282
+ export function selectFiles(entries) {
283
+ const upload = [];
284
+ const skipped = [];
285
+ for (const e of [...entries].sort((a, b) => a.name.localeCompare(b.name))) {
286
+ if (!e.file || e.name.startsWith("."))
287
+ continue;
288
+ const ext = e.name.slice(e.name.lastIndexOf(".")).toLowerCase();
289
+ if (!e.name.includes(".") || !SETUP_EXTENSIONS.includes(ext))
290
+ skipped.push({ name: e.name, why: "not a supported type" });
291
+ else if (e.size === 0)
292
+ skipped.push({ name: e.name, why: "empty" });
293
+ else if (e.size > MAX_FILE_BYTES)
294
+ skipped.push({ name: e.name, why: "over 4.2 MB; split it or add it in the app" });
295
+ else
296
+ upload.push(e.name);
297
+ }
298
+ return { upload, skipped };
299
+ }
300
+ /** A markdown section's non-empty lines (the heading excluded), up to the next heading. */
301
+ function section(md, heading) {
302
+ const out = [];
303
+ let on = false;
304
+ for (const line of md.split("\n")) {
305
+ if (/^#{1,2} /.test(line))
306
+ on = line === `## ${heading}`;
307
+ else if (on && line.trim() && !/^\(plan v\d+\)$/.test(line.trim()))
308
+ out.push(line);
309
+ }
310
+ return out;
311
+ }
312
+ /**
313
+ * The plan in a few cited lines, from get_plan and get_profile (full lists, every item with its quotes, unlike the
314
+ * session briefing): Goal, In scope (must), Constraints, Open questions. Empty sections are left out.
315
+ */
316
+ export function planSummary(plan, profile) {
317
+ const parts = [
318
+ ["Goal", section(plan, "Goal")],
319
+ ["In scope (must)", section(plan, "In scope").filter((l) => /^- \S+ \[must,/.test(l))],
320
+ ["Constraints", section(profile, "Constraints")],
321
+ ["Open questions", section(plan, "Open questions")],
322
+ ];
323
+ return parts.filter(([, lines]) => lines.length).map(([h, lines]) => [`## ${h}`, ...lines].join("\n")).join("\n");
324
+ }
325
+ /** The Status line of a briefing written before the plan exists, if there is one. */
326
+ export function briefingStatus(briefing) {
327
+ const line = /^## Status\n(.+)$/m.exec(briefing)?.[1]?.trim();
328
+ if (!line)
329
+ return null;
330
+ // Written for an agent: drop the sentences that tell it which tool to call next.
331
+ return line.split(/(?<=[.;])\s+/).filter((x) => !/`|tell the FDE/i.test(x)).join(" ").replace(/;$/, ".");
332
+ }
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
+ /**
338
+ * One MCP `tools/call` response body (JSON, or a streamable-HTTP SSE frame) → the tool's text, or its error. The first
339
+ * content block only: a second one may carry the "Since your briefing" freshness line.
340
+ */
341
+ export function mcpText(raw) {
342
+ const data = raw.split("\n").find((l) => l.startsWith("data: "))?.slice(6) ?? raw;
343
+ let msg;
344
+ try {
345
+ msg = JSON.parse(data);
346
+ }
347
+ catch {
348
+ return { error: "OpenScaffold sent an answer this CLI can't read. Try `npx @openscaffold/cli@latest`." };
349
+ }
350
+ if (msg.error)
351
+ return { error: msg.error.message ?? "OpenScaffold refused the call." };
352
+ const text = msg.result?.content?.[0]?.text ?? "";
353
+ return msg.result?.isError ? { error: text || "OpenScaffold refused the call." } : { text };
354
+ }
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openscaffold/cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
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
  }