@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 +18 -1
- package/dist/index.js +372 -45
- package/dist/install.js +389 -34
- package/package.json +5 -7
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,
|
|
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 =
|
|
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" };
|
|
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, {
|
|
55
|
-
return true;
|
|
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 () => {
|
|
@@ -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
|
-
|
|
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
|
-
// ---
|
|
163
|
-
|
|
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
|
-
|
|
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
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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:
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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(
|
|
542
|
+
console.error(HELP);
|
|
222
543
|
process.exit(1);
|
|
223
544
|
}
|
|
224
|
-
run().then(() => process.exit(
|
|
225
|
-
|
|
226
|
-
|
|
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
|
|
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)"?$/.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
|
+
* 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
|
|
12
|
-
|
|
13
|
-
.
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
19
|
-
export function
|
|
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
|
|
34
|
+
if (!out.hooks)
|
|
22
35
|
return out;
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
34
|
-
* own built file, so the
|
|
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
|
|
51
|
+
export function hookBase(selfPath, execPath) {
|
|
37
52
|
if (/[\\/]_npx[\\/]|[\\/]node_modules[\\/]@openscaffold[\\/]cli[\\/]/.test(selfPath))
|
|
38
|
-
return
|
|
39
|
-
return `${JSON.stringify(execPath)} ${JSON.stringify(selfPath)}
|
|
53
|
+
return "npx -y @openscaffold/cli";
|
|
54
|
+
return `${JSON.stringify(execPath)} ${JSON.stringify(selfPath)}`;
|
|
40
55
|
}
|
|
41
|
-
/** A
|
|
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:
|
|
46
|
-
|
|
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
|
-
/**
|
|
62
|
-
export const
|
|
63
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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" ||
|
|
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
|
-
:
|
|
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.
|
|
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
|
-
"
|
|
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
|
}
|