@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 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, switchAllowed, switchedTo, transcriptActivity, whichCustomerError, windowsCommandLine, withHooks, withoutHooks, } from "./install.js";
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
- writeFileSync(join(CACHE, `${deployment}.md`), briefing);
343
- const entry = { deployment, at: new Date().toISOString(), login: loginKey(creds) };
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 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
+ * 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
- 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)))
437
+ if (!creds)
436
438
  return;
437
- remember(creds, got.deployment, got.briefing, hook.cwd ?? process.cwd(), hook.session_id, true);
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
- const deployment = args.deployment ?? loadedHere(callerSession(process.env), process.cwd())?.deployment;
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 \`npx -y @openscaffold/cli@latest login\`, then review again.`);
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.*__(use|create)_deployment", handler: { timeout: 2 } },
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
- /** 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
+ /**
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 (logins before 2026-10-29 lack the customer-switch hook): re-run npx @openscaffold/cli login"}`]),
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 -y @openscaffold/cli@latest <args>`, each argument single-quoted when a shell
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 -y @openscaffold/cli@latest", ...args.map((a) => /^[\w./:@=,-]+$/.test(a) ? a
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
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openscaffold/cli",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Connect Claude Code to your OpenScaffold workspace: each session loads the right customer deployment.",
5
5
  "type": "module",
6
6
  "bin": {