@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 +20 -1
- package/dist/index.js +412 -49
- package/dist/install.js +480 -34
- package/package.json +5 -7
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,
|
|
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 =
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
52
|
-
|
|
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,
|
|
55
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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.
|
|
96
|
-
writeJson(SETTINGS,
|
|
97
|
-
written.push(`${SETTINGS} (SessionStart
|
|
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 =
|
|
108
|
-
console.log(`\nLogged in to ${creds.workspace.name}.\nWrote:\n${written.map((w) => ` - ${w}`).join("\n")}\nNothing was written into any repo.\n
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
// ---
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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:
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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(
|
|
578
|
+
console.error(HELP);
|
|
222
579
|
process.exit(1);
|
|
223
580
|
}
|
|
224
|
-
run().then(() => process.exit(
|
|
225
|
-
|
|
226
|
-
|
|
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
|
|
5
|
-
const isOurCommand = (cmd) => /openscaffold/.test(cmd) && /\
|
|
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
|
-
/**
|
|
9
|
-
|
|
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
|
|
12
|
-
|
|
13
|
-
.
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
19
|
-
export function
|
|
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
|
|
36
|
+
if (!out.hooks)
|
|
22
37
|
return out;
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
34
|
-
* own built file, so the
|
|
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
|
|
53
|
+
export function hookBase(selfPath, execPath) {
|
|
37
54
|
if (/[\\/]_npx[\\/]|[\\/]node_modules[\\/]@openscaffold[\\/]cli[\\/]/.test(selfPath))
|
|
38
|
-
return
|
|
39
|
-
return `${JSON.stringify(execPath)} ${JSON.stringify(selfPath)}
|
|
55
|
+
return "npx -y @openscaffold/cli";
|
|
56
|
+
return `${JSON.stringify(execPath)} ${JSON.stringify(selfPath)}`;
|
|
40
57
|
}
|
|
41
|
-
/** A
|
|
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:
|
|
46
|
-
|
|
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
|
-
/**
|
|
62
|
-
export const
|
|
63
|
-
const
|
|
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
|
-
|
|
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
|
-
`
|
|
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" ||
|
|
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
|
-
:
|
|
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.
|
|
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
|
-
"
|
|
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
|
}
|