@openscaffold/cli 0.1.5 → 0.1.6
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/dist/index.js +66 -13
- package/dist/install.js +164 -8
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
import { execFileSync, spawn } from "node:child_process";
|
|
4
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, resolve } from "node:path";
|
|
7
|
-
import { briefingFacts, briefingStatus, browserCommand, collectDiff, gitInfo, hasOpenScaffoldPlugin, hookBase, HOSTED, installedHooks, loadedEntry, loginResultLine, MCP_NAME, mcpText, nameFromDomain, callerSession, commandLine, gitTracks, rememberedCache, DEFAULT_REVIEW_TITLE, parseReviewArgs, parseSetupArgs, planSummary, pluginServes, preCommitOutput, REVIEW_HELP, REVIEW_WAIT, reviewTitle, selectFiles, SETUP_EXTENSIONS, setupPrompt, setupResultLine, skillFile, statusReport, stopOutput,
|
|
6
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
7
|
+
import { briefingFacts, briefingStatus, browserCommand, collectDiff, gitInfo, hasOpenScaffoldPlugin, hookBase, HOSTED, installedHooks, loadedEntry, loginResultLine, MCP_NAME, mcpText, nameFromDomain, cacheFromToolCall, callerSession, commandLine, editedPaths, filesModifiedSince, insideFolder, sessionFileChoice, sessionStartedAt, gitTracks, rememberedCache, DEFAULT_REVIEW_TITLE, parseReviewArgs, parseSetupArgs, planSummary, pluginServes, preCommitOutput, REVIEW_HELP, REVIEW_WAIT, reviewTitle, selectFiles, SETUP_EXTENSIONS, setupPrompt, setupResultLine, skillFile, statusReport, stopOutput, 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");
|
|
@@ -337,10 +337,12 @@ const git = (cwd, args) => {
|
|
|
337
337
|
}
|
|
338
338
|
};
|
|
339
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) {
|
|
340
|
+
function remember(creds, deployment, briefing, cwd, sessionId, switching = false, transcript) {
|
|
341
341
|
mkdirSync(CACHE, { recursive: true });
|
|
342
|
-
|
|
343
|
-
|
|
342
|
+
// No briefing (a plain tool call named the deployment): keep the cached one, if any; review works without it.
|
|
343
|
+
if (briefing !== null)
|
|
344
|
+
writeFileSync(join(CACHE, `${deployment}.md`), briefing);
|
|
345
|
+
const entry = { deployment, at: new Date().toISOString(), login: loginKey(creds), ...(transcript ? { transcript } : {}) };
|
|
344
346
|
// Per Claude session too: two sessions in one folder on different customers each keep their own guardrails.
|
|
345
347
|
const next = rememberedCache(readJson(join(CACHE, "index.json"), {}), readJson(SESSIONS, {}), cwd, sessionId, entry, switching);
|
|
346
348
|
writeJson(join(CACHE, "index.json"), next.index);
|
|
@@ -369,7 +371,7 @@ async function sessionStart() {
|
|
|
369
371
|
console.log(briefing);
|
|
370
372
|
const dep = r.body.deployment?.id;
|
|
371
373
|
if (dep)
|
|
372
|
-
remember(creds, dep, briefing, cwd, hook.session_id);
|
|
374
|
+
remember(creds, dep, briefing, cwd, hook.session_id, false, hook.transcript_path);
|
|
373
375
|
}
|
|
374
376
|
catch {
|
|
375
377
|
const last = index[cwd]?.login === loginKey(creds) ? index[cwd] : undefined; // a session started before a re-login may write late
|
|
@@ -425,16 +427,53 @@ const knownDeployments = (creds) => {
|
|
|
425
427
|
});
|
|
426
428
|
};
|
|
427
429
|
/**
|
|
428
|
-
* PostToolUse after
|
|
429
|
-
*
|
|
430
|
+
* PostToolUse after any OpenScaffold tool: use_deployment / create_deployment switch this session (and, with no session, the
|
|
431
|
+
* folder); any other call naming a deployment_id makes it this session's, so `review` finds the customer the agent loaded
|
|
432
|
+
* with get_briefing. Only this login's server and workspace, never a foreign id (cacheFromToolCall). Local files only.
|
|
430
433
|
*/
|
|
431
434
|
async function useDeployment() {
|
|
432
435
|
const hook = await hookInput();
|
|
433
436
|
const creds = credentials();
|
|
434
|
-
|
|
435
|
-
if (!creds || !got || !switchAllowed(hook.tool_name, got.deployment, creds.api_url, knownDeployments(creds).map((d) => d.id)))
|
|
437
|
+
if (!creds)
|
|
436
438
|
return;
|
|
437
|
-
|
|
439
|
+
const current = hook.session_id ? readJson(SESSIONS, {})[hook.session_id]?.deployment : undefined;
|
|
440
|
+
const got = cacheFromToolCall(hook, creds.api_url, knownDeployments(creds).map((d) => d.id), current);
|
|
441
|
+
if (got)
|
|
442
|
+
remember(creds, got.deployment, got.briefing, hook.cwd ?? process.cwd(), hook.session_id, true, hook.transcript_path);
|
|
443
|
+
}
|
|
444
|
+
/** The Claude session's transcript: the path its hooks cached, else ~/.claude/projects/<any>/<session id>.jsonl. */
|
|
445
|
+
const transcriptOf = (sessionId, entry) => {
|
|
446
|
+
if (entry?.transcript && existsSync(entry.transcript))
|
|
447
|
+
return entry.transcript;
|
|
448
|
+
const projects = join(CLAUDE, "projects");
|
|
449
|
+
const dir = existsSync(projects) ? readdirSync(projects).find((d) => existsSync(join(projects, d, `${sessionId}.jsonl`))) : undefined;
|
|
450
|
+
return dir ? join(projects, dir, `${sessionId}.jsonl`) : undefined;
|
|
451
|
+
};
|
|
452
|
+
/** Outside git with no files named: what this Claude session changed under the cwd, from its transcript, else by mtime. */
|
|
453
|
+
function sessionFiles(sessionId, cwd) {
|
|
454
|
+
if (!sessionId)
|
|
455
|
+
return null;
|
|
456
|
+
const entry = readJson(SESSIONS, {})[sessionId];
|
|
457
|
+
const transcript = transcriptOf(sessionId, entry);
|
|
458
|
+
const text = transcript ? readFileSync(transcript, "utf8") : null;
|
|
459
|
+
const edited = text === null ? null : editedPaths(text).map((p) => relative(cwd, resolve(cwd, p))).filter((p) => p && !p.startsWith("..") && !isAbsolute(p));
|
|
460
|
+
const since = sessionStartedAt(text, transcript ? statSync(transcript).birthtimeMs : null, entry?.started);
|
|
461
|
+
// Automatic picks only: links resolved, each must still be inside this folder (a path the agent names is sent as named).
|
|
462
|
+
return sessionFileChoice(edited && insideFolder(edited, cwd), since, (at) => insideFolder(filesModifiedSince(cwd, at), cwd));
|
|
463
|
+
}
|
|
464
|
+
/** OpenScaffold's own record of this Claude session's deployment (the session-start endpoint), cached for next time. */
|
|
465
|
+
async function serverSession(creds, sessionId, cwd) {
|
|
466
|
+
try {
|
|
467
|
+
// peek: learn the deployment without marking the briefing read; an existing row for this session is reused, never duplicated.
|
|
468
|
+
const r = await api(creds.api_url, "/api/sessions/start", { claude_session_id: sessionId, cwd, peek: true, ...gitInfo(git(cwd, ["remote", "get-url", "origin"]), git(cwd, ["branch", "--show-current"])) }, { token: creds.token, timeoutMs: 5000 });
|
|
469
|
+
const dep = r.status === 200 ? r.body.deployment?.id : undefined;
|
|
470
|
+
if (dep)
|
|
471
|
+
remember(creds, dep, String(r.body.briefing), cwd, sessionId, true);
|
|
472
|
+
return dep;
|
|
473
|
+
}
|
|
474
|
+
catch {
|
|
475
|
+
return undefined;
|
|
476
|
+
}
|
|
438
477
|
}
|
|
439
478
|
// --- review -------------------------------------------------------------------------------------
|
|
440
479
|
/**
|
|
@@ -453,10 +492,24 @@ async function review() {
|
|
|
453
492
|
if (!creds)
|
|
454
493
|
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
494
|
// The calling Claude session's deployment first (another session in this folder may have switched), else the folder's.
|
|
456
|
-
|
|
495
|
+
// Nothing cached (the agent loaded the customer some other way, or no hooks): ask OpenScaffold which deployment this session used.
|
|
496
|
+
const sid = callerSession(process.env);
|
|
497
|
+
const deployment = args.deployment ?? loadedHere(sid, process.cwd())?.deployment ?? (sid ? await serverSession(creds, sid, process.cwd()) : undefined);
|
|
457
498
|
if (!deployment)
|
|
458
499
|
throw new Error(whichCustomerError(again, knownDeployments(creds)));
|
|
459
500
|
const git = (a) => execFileSync("git", a, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] });
|
|
501
|
+
let repo = false;
|
|
502
|
+
try {
|
|
503
|
+
repo = git(["rev-parse", "--is-inside-work-tree"]).trim() === "true";
|
|
504
|
+
}
|
|
505
|
+
catch { }
|
|
506
|
+
if (!repo && !args.files.length) {
|
|
507
|
+
const found = sessionFiles(sid, process.cwd());
|
|
508
|
+
if (found?.files.length) {
|
|
509
|
+
args.files.push(...found.files);
|
|
510
|
+
console.log(`Not a git repo: reviewing the ${found.files.length} file${found.files.length === 1 ? "" : "s"} ${found.from}: ${found.files.join(", ")}`);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
460
513
|
const got = collectDiff(args.files, git, (f) => (lstatSync(f).isSymbolicLink() ? { link: readlinkSync(f) } : readFileSync(f, "utf8")));
|
|
461
514
|
if ("error" in got)
|
|
462
515
|
throw new Error(got.error);
|
|
@@ -479,7 +532,7 @@ async function review() {
|
|
|
479
532
|
}
|
|
480
533
|
catch (e) {
|
|
481
534
|
if (e instanceof LoggedOut)
|
|
482
|
-
throw new Error(`${e.message} Run
|
|
535
|
+
throw new Error(`${e.message} Run ${commandLine(["login"])}, then ${commandLine(again)}.`);
|
|
483
536
|
throw e;
|
|
484
537
|
}
|
|
485
538
|
}
|
package/dist/install.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
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
|
+
import { readdirSync, realpathSync, statSync } from "node:fs";
|
|
4
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
5
|
export const MCP_NAME = "openscaffold";
|
|
4
6
|
/** Our hook commands mention openscaffold and end in one of our subcommands (npx form or a local build path). */
|
|
5
7
|
const isOurCommand = (cmd) => /openscaffold/.test(cmd) && /\b(session-start|pre-commit|stop|use-deployment)"?$/.test(cmd.trim());
|
|
@@ -16,7 +18,7 @@ export const OUR_HOOKS = [
|
|
|
16
18
|
{ event: "SessionStart", sub: "session-start", matcher: HOOK_MATCHER, handler: { timeout: 10 } },
|
|
17
19
|
{ event: "PreToolUse", sub: "pre-commit", matcher: "Bash", handler: { if: "Bash(git commit *)", timeout: 2 } },
|
|
18
20
|
{ event: "Stop", sub: "stop", matcher: undefined, handler: { timeout: 5 } },
|
|
19
|
-
{ event: "PostToolUse", sub: "use-deployment", matcher: "mcp__.*openscaffold.*__
|
|
21
|
+
{ event: "PostToolUse", sub: "use-deployment", matcher: "mcp__.*openscaffold.*__.*", handler: { timeout: 2 } },
|
|
20
22
|
];
|
|
21
23
|
const withoutOurs = (groups = []) => groups.map((g) => ({ ...g, hooks: g.hooks.filter((h) => !isOurs(h)) })).filter((g) => g.hooks.length > 0);
|
|
22
24
|
/** Adds (or replaces) our hooks, `<base> <subcommand>` each; leaves everything else in settings.json as it was. */
|
|
@@ -76,8 +78,11 @@ export function gitInfo(remoteOut, branchOut) {
|
|
|
76
78
|
const branch = branchOut?.trim() || undefined;
|
|
77
79
|
return { git_remote: remote, git_branch: branch };
|
|
78
80
|
}
|
|
79
|
-
/**
|
|
80
|
-
|
|
81
|
+
/**
|
|
82
|
+
* Which of our hooks are in settings.json, e.g. ["session-start", "pre-commit", "stop"]: each with today's matcher, so an
|
|
83
|
+
* older login's (the use-deployment hook's narrow `(use|create)_deployment` matcher) reads as missing and status asks for login.
|
|
84
|
+
*/
|
|
85
|
+
export const installedHooks = (settings) => OUR_HOOKS.filter((h) => (settings.hooks?.[h.event] ?? []).some((g) => g.matcher === h.matcher && g.hooks.some((x) => isOurs(x) && x.command.trim().replace(/"$/, "").endsWith(h.sub)))).map((h) => h.sub);
|
|
81
86
|
export const hasSessionHook = (settings) => installedHooks(settings).includes("session-start");
|
|
82
87
|
/** The hosted app, which the Claude Code plugin's MCP server always points at (plugin/.mcp.json). */
|
|
83
88
|
export const HOSTED = "https://app.openscaffold.ai";
|
|
@@ -96,7 +101,7 @@ export function statusReport(s) {
|
|
|
96
101
|
`Signed in: ${s.workspace ? `yes, workspace "${s.workspace}" (${s.apiUrl}${s.server === "down" ? ", not answering" : s.server === "revoked" ? ", login revoked" : ""})` : "no"}`,
|
|
97
102
|
`MCP server: ${s.plugin ? "from the openscaffold plugin" : s.mcp === null ? "unknown (Claude Code not on PATH)" : s.mcp ? "added" : "missing"}`,
|
|
98
103
|
`Session hook: ${s.hook ? "installed" : "missing"}`,
|
|
99
|
-
...(s.guardrails === undefined ? [] : [`Guardrails: ${s.guardrails ? "pre-commit plan check and end-of-session report reminder" : "missing or out of date (
|
|
104
|
+
...(s.guardrails === undefined ? [] : [`Guardrails: ${s.guardrails ? "pre-commit plan check and end-of-session report reminder" : "missing or out of date (an older login's customer-switch hook): re-run npx @openscaffold/cli login"}`]),
|
|
100
105
|
`Commands: ${s.plugin ? "/openscaffold:* from the plugin" : commandsOk ? `${s.skills} /os-* commands` : s.skills ? `${s.skills} of ${s.skillsExpected || "?"} /os-* commands` : "missing"}`,
|
|
101
106
|
];
|
|
102
107
|
const next = !s.workspace || s.server === "revoked" || mcpOk === false || !s.hook || s.guardrails === false || !commandsOk
|
|
@@ -207,10 +212,161 @@ export function withSession(sessions, sessionId, entry, keep = 200) {
|
|
|
207
212
|
* which session called it) never picks it up; a session start, or a switch with no session, sets the folder default. Pure.
|
|
208
213
|
*/
|
|
209
214
|
export function rememberedCache(index, sessions, cwd, sessionId, entry, switching = false) {
|
|
215
|
+
const prev = sessionId ? sessions[sessionId] : undefined;
|
|
210
216
|
return {
|
|
211
|
-
index: switching && sessionId ? index : { ...index, [cwd]: entry },
|
|
212
|
-
sessions: sessionId ? withSession(sessions, sessionId, entry) : sessions,
|
|
217
|
+
index: switching && sessionId ? index : { ...index, [cwd]: { deployment: entry.deployment, at: entry.at, login: entry.login } },
|
|
218
|
+
sessions: sessionId ? withSession(sessions, sessionId, { ...entry, started: prev?.started ?? entry.at, transcript: entry.transcript ?? prev?.transcript }) : sessions,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* What the use-deployment hook (PostToolUse on every OpenScaffold tool) caches, or null. use_deployment / create_deployment:
|
|
223
|
+
* the deployment it switched to, with its briefing. Any other call with a `deployment_id` (get_briefing, get_plan, …): that
|
|
224
|
+
* deployment, for the calling session only (`sessionOnly`; no session id, nothing), no briefing. Unchanged (`current`) or
|
|
225
|
+
* not this login's server and workspace (switchAllowed): null.
|
|
226
|
+
*/
|
|
227
|
+
export function cacheFromToolCall(call, apiUrl, known, current) {
|
|
228
|
+
const tool = /^mcp__.*openscaffold.*__(\w+)$/.exec(call.tool_name ?? "")?.[1];
|
|
229
|
+
if (!tool || toolFailed(call.tool_response))
|
|
230
|
+
return null; // an error ("No active deployment…") keeps the current cache
|
|
231
|
+
let got = null;
|
|
232
|
+
if (tool === "use_deployment" || tool === "create_deployment") {
|
|
233
|
+
const s = switchedTo(call.tool_response);
|
|
234
|
+
got = s && { ...s, sessionOnly: false };
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
const id = call.tool_input?.deployment_id;
|
|
238
|
+
if (typeof id === "string" && /^dep_\w+$/.test(id) && call.session_id && id !== current)
|
|
239
|
+
got = { deployment: id, briefing: null, sessionOnly: true };
|
|
240
|
+
}
|
|
241
|
+
return got && switchAllowed(call.tool_name, got.deployment, apiUrl, known) ? got : null;
|
|
242
|
+
}
|
|
243
|
+
/** A tool_response that reports an error: MCP `isError`, an `{error}` object, or text that starts "Error" / "MCP error". */
|
|
244
|
+
const toolFailed = (r) => (Boolean(r) && typeof r === "object" && !Array.isArray(r) && (r.isError === true || "error" in r))
|
|
245
|
+
|| /^(MCP )?error\b/i.test(textOf(r).trim());
|
|
246
|
+
/**
|
|
247
|
+
* A file automatic selection never sends (a path the agent names still goes): every dot-file (.env, .npmrc, .netrc, …)
|
|
248
|
+
* and names that look like keys or credentials. ponytail: name patterns, not content scanning; extend the list as found.
|
|
249
|
+
*/
|
|
250
|
+
export const looksSecret = (path) => {
|
|
251
|
+
const name = path.split(/[\\/]/).pop() ?? path;
|
|
252
|
+
return name.startsWith(".") || /\.(pem|key|p12|pfx|jks|keystore|asc|gpg|ppk)$|\.env$|\.env\.|^id_(rsa|dsa|ecdsa|ed25519)|credential|secret/i.test(name);
|
|
253
|
+
};
|
|
254
|
+
/**
|
|
255
|
+
* Outside git with no files named: the session's edited files (transcript) when any are usable, else files modified since
|
|
256
|
+
* `since` (sessionStart); likely secrets dropped either way. `edited`: null with no transcript.
|
|
257
|
+
*/
|
|
258
|
+
export function sessionFileChoice(edited, since, modifiedSince) {
|
|
259
|
+
const safe = (fs) => fs.filter((f) => !looksSecret(f));
|
|
260
|
+
const fromTranscript = safe(edited ?? []);
|
|
261
|
+
if (fromTranscript.length)
|
|
262
|
+
return { files: fromTranscript, from: "edited in this session" };
|
|
263
|
+
return { files: safe(modifiedSince(since.at)), from: `modified ${since.label}` };
|
|
264
|
+
}
|
|
265
|
+
/** The mtime fallback's window when nothing says when the session started. */
|
|
266
|
+
export const UNKNOWN_START_WINDOW_MS = 12 * 3_600_000;
|
|
267
|
+
/**
|
|
268
|
+
* When the Claude session started, independent of our cache: the earliest `"timestamp"` in its transcript, else the
|
|
269
|
+
* transcript file's birthtime, else the cache's `started` (first cache write: late when hooks are missing), else the
|
|
270
|
+
* last 12 hours.
|
|
271
|
+
*/
|
|
272
|
+
export function sessionStartedAt(transcript, birthtimeMs, cachedStarted, now = Date.now()) {
|
|
273
|
+
let first = Infinity;
|
|
274
|
+
for (const m of (transcript ?? "").matchAll(/"timestamp":"([^"]+)"/g)) {
|
|
275
|
+
const t = Date.parse(m[1]);
|
|
276
|
+
if (t < first)
|
|
277
|
+
first = t;
|
|
278
|
+
}
|
|
279
|
+
const iso = (t) => new Date(t).toISOString();
|
|
280
|
+
if (Number.isFinite(first))
|
|
281
|
+
return { at: first, label: `since this session started (${iso(first)})` };
|
|
282
|
+
if (birthtimeMs && birthtimeMs > 0)
|
|
283
|
+
return { at: birthtimeMs, label: `since this session started (${iso(birthtimeMs)})` };
|
|
284
|
+
// Our cache's `started` is only when we first cached a deployment (after a server recovery, moments ago), never the
|
|
285
|
+
// session's start: it can widen the window, never narrow it below the last 12 hours.
|
|
286
|
+
const cached = cachedStarted ? Date.parse(cachedStarted) : NaN;
|
|
287
|
+
const window = now - UNKNOWN_START_WINDOW_MS;
|
|
288
|
+
if (Number.isFinite(cached) && cached < window)
|
|
289
|
+
return { at: cached, label: `since ${cachedStarted}` };
|
|
290
|
+
return { at: window, label: "in the last 12 hours (session start unknown)" };
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Automatic selection only: the paths (relative to `cwd`) whose real location, links resolved, is still inside the real
|
|
294
|
+
* `cwd`, so `linked/config.json` through a link to another folder is never sent. Missing or unreadable paths drop out.
|
|
295
|
+
*/
|
|
296
|
+
export function insideFolder(paths, cwd) {
|
|
297
|
+
let root;
|
|
298
|
+
try {
|
|
299
|
+
root = realpathSync(cwd);
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
return [];
|
|
303
|
+
}
|
|
304
|
+
return paths.filter((p) => {
|
|
305
|
+
try {
|
|
306
|
+
const r = relative(root, realpathSync(resolve(cwd, p)));
|
|
307
|
+
return Boolean(r) && r !== ".." && !r.startsWith(`..${sep}`) && !isAbsolute(r);
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
/** The files a session's transcript (JSONL) shows it wrote or edited, in order, each once: file_path / notebook_path. */
|
|
315
|
+
export function editedPaths(jsonl) {
|
|
316
|
+
const out = new Set();
|
|
317
|
+
for (const line of jsonl.split("\n")) {
|
|
318
|
+
if (!line.includes('"tool_use"'))
|
|
319
|
+
continue;
|
|
320
|
+
let content;
|
|
321
|
+
try {
|
|
322
|
+
content = JSON.parse(line).message?.content;
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
if (!Array.isArray(content))
|
|
328
|
+
continue;
|
|
329
|
+
for (const c of content) {
|
|
330
|
+
const path = c?.input?.file_path ?? c?.input?.notebook_path;
|
|
331
|
+
if (c?.type === "tool_use" && EDIT_TOOLS.has(c.name ?? "") && typeof path === "string")
|
|
332
|
+
out.add(path);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return [...out];
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Files under `root` modified after `since` (ms), paths relative to it, sorted: the change outside git when no transcript
|
|
339
|
+
* says what was edited. Skips node_modules, dot-folders (.git, .venv, …) and likely secrets (looksSecret); at most `cap` files.
|
|
340
|
+
*/
|
|
341
|
+
export function filesModifiedSince(root, since, cap = 200) {
|
|
342
|
+
const out = [];
|
|
343
|
+
const walk = (dir, rel) => {
|
|
344
|
+
let entries;
|
|
345
|
+
try {
|
|
346
|
+
entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
for (const e of entries) {
|
|
352
|
+
if (out.length >= cap)
|
|
353
|
+
return;
|
|
354
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
355
|
+
if (e.isDirectory()) {
|
|
356
|
+
if (e.name !== "node_modules" && !e.name.startsWith("."))
|
|
357
|
+
walk(join(dir, e.name), r);
|
|
358
|
+
}
|
|
359
|
+
else if (e.isFile() && !looksSecret(e.name)) {
|
|
360
|
+
try {
|
|
361
|
+
if (statSync(join(dir, e.name)).mtimeMs > since)
|
|
362
|
+
out.push(r);
|
|
363
|
+
}
|
|
364
|
+
catch { }
|
|
365
|
+
}
|
|
366
|
+
}
|
|
213
367
|
};
|
|
368
|
+
walk(root, "");
|
|
369
|
+
return out;
|
|
214
370
|
}
|
|
215
371
|
/** The deployment a hook's session loaded: by its Claude session_id first (two sessions can share a folder), else the folder's. */
|
|
216
372
|
export const loadedEntry = (sessions, index, sessionId, cwd) => (sessionId ? sessions[sessionId] : undefined) ?? deploymentForCwd(index, cwd);
|
|
@@ -455,10 +611,10 @@ export function parseReviewArgs(args, exists = () => true) {
|
|
|
455
611
|
return { ...out, title: out.title.trim() || DEFAULT_REVIEW_TITLE, reqs: [...new Set(out.reqs)] };
|
|
456
612
|
}
|
|
457
613
|
/**
|
|
458
|
-
* A CLI command line to copy and run: `npx
|
|
614
|
+
* A CLI command line to copy and run: `npx @openscaffold/cli <args>` (no `@latest`: the version already on this machine), each argument single-quoted when a shell
|
|
459
615
|
* would split or expand it ($, backticks, spaces), so the copy keeps the exact text: PowerShell's form on Windows.
|
|
460
616
|
*/
|
|
461
|
-
export const commandLine = (args, platform = process.platform) => ["npx
|
|
617
|
+
export const commandLine = (args, platform = process.platform) => ["npx @openscaffold/cli", ...args.map((a) => /^[\w./:@=,-]+$/.test(a) ? a
|
|
462
618
|
// PowerShell: single quotes are literal and a quote is doubled; elsewhere POSIX's '\'' escape.
|
|
463
619
|
: platform === "win32" ? `'${a.replace(/'/g, "''")}'` : `'${a.replace(/'/g, `'\\''`)}'`)].join(" ");
|
|
464
620
|
/**
|