@synkro-sh/cli 1.6.46 → 1.6.48

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/bootstrap.js CHANGED
@@ -804,7 +804,10 @@ synkro_post_with_retry() {
804
804
  });
805
805
 
806
806
  // cli/installer/hookScriptsTs.ts
807
- var SYNKRO_COMMON_TS, EDIT_PRECHECK_TS, CWE_PRECHECK_TS, CVE_PRECHECK_TS, INSTALL_SCAN_TS, BASH_JUDGE_TS, AGENT_JUDGE_TS, PLAN_JUDGE_TS, STOP_SUMMARY_TS, SESSION_START_TS, BASH_FOLLOWUP_TS, TRANSCRIPT_SYNC_TS, USER_PROMPT_SUBMIT_TS, CURSOR_BASH_JUDGE_TS, CURSOR_EDIT_CAPTURE_TS, CURSOR_AGENT_CAPTURE_TS;
807
+ function stubHook(surface, optsLiteral) {
808
+ return "#!/usr/bin/env bun\nimport { runStub } from './_synkro-stub-common.ts';\nrunStub(" + JSON.stringify(surface) + ", " + optsLiteral + ");\n";
809
+ }
810
+ var SYNKRO_COMMON_TS, EDIT_PRECHECK_TS, CWE_PRECHECK_TS, CVE_PRECHECK_TS, INSTALL_SCAN_TS, BASH_JUDGE_TS, AGENT_JUDGE_TS, PLAN_JUDGE_TS, STOP_SUMMARY_TS, SESSION_START_TS, BASH_FOLLOWUP_TS, TRANSCRIPT_SYNC_TS, USER_PROMPT_SUBMIT_TS, CURSOR_BASH_JUDGE_TS, CURSOR_EDIT_CAPTURE_TS, CURSOR_AGENT_CAPTURE_TS, STUB_COMMON_TS, STUB_EDIT_PRECHECK_TS, STUB_CWE_PRECHECK_TS, STUB_CVE_PRECHECK_TS, STUB_BASH_JUDGE_TS, STUB_INSTALL_SCAN_TS, STUB_AGENT_JUDGE_TS, STUB_PLAN_JUDGE_TS, STUB_STOP_SUMMARY_TS, STUB_SESSION_START_TS, STUB_TRANSCRIPT_SYNC_TS, STUB_USER_PROMPT_SUBMIT_TS, STUB_BASH_FOLLOWUP_TS, STUB_CURSOR_BASH_JUDGE_TS, STUB_CURSOR_EDIT_CAPTURE_TS, STUB_CURSOR_AGENT_CAPTURE_TS;
808
811
  var init_hookScriptsTs = __esm({
809
812
  "cli/installer/hookScriptsTs.ts"() {
810
813
  "use strict";
@@ -6149,6 +6152,149 @@ async function main() {
6149
6152
 
6150
6153
  main();
6151
6154
  `;
6155
+ STUB_COMMON_TS = String.raw`import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
6156
+ import { execSync } from 'node:child_process';
6157
+ import { homedir } from 'node:os';
6158
+ import { join } from 'node:path';
6159
+
6160
+ const HOME = homedir();
6161
+ const PORT = process.env.SYNKRO_MCP_PORT || '18931';
6162
+
6163
+ function loadMcpJwt(): string {
6164
+ try { return readFileSync(join(HOME, '.synkro', '.mcp-jwt'), 'utf-8').trim(); } catch { return ''; }
6165
+ }
6166
+
6167
+ async function readStdin(): Promise<string> {
6168
+ const chunks: Buffer[] = [];
6169
+ for await (const chunk of process.stdin) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
6170
+ return Buffer.concat(chunks).toString('utf-8');
6171
+ }
6172
+
6173
+ function isCursor(harnessOpt?: string): boolean {
6174
+ return harnessOpt === 'cursor' || process.argv.includes('--cursor') || process.env.SYNKRO_HOOK_FORMAT === 'cursor';
6175
+ }
6176
+
6177
+ function failOpen(harness: string): string {
6178
+ return harness === 'cursor' ? '{"permission":"allow"}' : '{}';
6179
+ }
6180
+
6181
+ function out(s: string): void { try { process.stdout.write(s + '\n'); } catch {} }
6182
+
6183
+ function gitRoot(cwd: string): string {
6184
+ if (!cwd) return '';
6185
+ try {
6186
+ return execSync('git rev-parse --show-toplevel', { cwd, timeout: 2000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
6187
+ } catch { return ''; }
6188
+ }
6189
+
6190
+ // Once-per-session onboarding hint for repos with no .synkro file.
6191
+ function noSynkroHint(sessionId: string): string | null {
6192
+ const dir = join(HOME, '.synkro', '.no-synkro-hint');
6193
+ try {
6194
+ mkdirSync(dir, { recursive: true });
6195
+ const key = (sessionId || 'nosession').replace(/[^a-zA-Z0-9_-]/g, '_');
6196
+ const marker = join(dir, key);
6197
+ if (existsSync(marker)) return null;
6198
+ writeFileSync(marker, '', { flag: 'w' });
6199
+ } catch {}
6200
+ return '[synkro] No .synkro config in this repo — grading skipped. Run synkro install here to enable Synkro.';
6201
+ }
6202
+
6203
+ function filePathFromToolInput(ti: any): string {
6204
+ if (!ti || typeof ti !== 'object') return '';
6205
+ return ti.file_path || ti.notebook_path || ti.path || ti.target_file || '';
6206
+ }
6207
+
6208
+ interface StubOpts {
6209
+ needsFile?: boolean;
6210
+ needsTranscript?: boolean;
6211
+ // Ship the WHOLE transcript (not just the 200KB tail). Sync surfaces need it
6212
+ // so the server can compute stable absolute message indices + total usage.
6213
+ fullTranscript?: boolean;
6214
+ needsPlan?: boolean;
6215
+ telemetry?: boolean;
6216
+ harness?: string;
6217
+ }
6218
+
6219
+ export async function runStub(surface: string, opts: StubOpts = {}): Promise<void> {
6220
+ const harness = isCursor(opts.harness) ? 'cursor' : 'cc';
6221
+ try {
6222
+ const input = await readStdin();
6223
+ if (!input.trim()) { out(failOpen(harness)); return; }
6224
+ const payload = JSON.parse(input);
6225
+
6226
+ const workspaceRoots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots : [];
6227
+ const cwd = (typeof payload.cwd === 'string' && payload.cwd) || workspaceRoots[0] || '';
6228
+ const sessionId = String(payload.session_id || payload.conversation_id || '');
6229
+ const root = gitRoot(cwd);
6230
+
6231
+ // Dormancy: a repo is onboarded only if it has a .synkro at its git root.
6232
+ let synkroFileText = '';
6233
+ if (root && existsSync(join(root, '.synkro'))) {
6234
+ try { synkroFileText = readFileSync(join(root, '.synkro'), 'utf-8'); } catch {}
6235
+ }
6236
+ if (!synkroFileText) {
6237
+ if (opts.telemetry) { out(failOpen(harness)); return; }
6238
+ const hint = noSynkroHint(sessionId);
6239
+ if (!hint) { out(failOpen(harness)); return; }
6240
+ if (harness === 'cursor') out(JSON.stringify({ permission: 'allow', user_message: hint }));
6241
+ else out(JSON.stringify({ systemMessage: hint, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: hint } }));
6242
+ return;
6243
+ }
6244
+
6245
+ // Gather host-only inputs the container can't read.
6246
+ const envelope: any = { payload, harness, cwd: root || cwd, sessionId, synkroFileText };
6247
+
6248
+ if (opts.needsFile) {
6249
+ const fp = filePathFromToolInput(payload.tool_input || {});
6250
+ if (fp && existsSync(fp)) {
6251
+ try { envelope.baseContent = readFileSync(fp, 'utf-8').slice(0, 65536); } catch {}
6252
+ }
6253
+ }
6254
+ if (opts.needsTranscript) {
6255
+ const tp = typeof payload.transcript_path === 'string' ? payload.transcript_path : '';
6256
+ if (tp && existsSync(tp)) {
6257
+ try {
6258
+ const t = readFileSync(tp, 'utf-8');
6259
+ envelope.transcript = (opts.fullTranscript || t.length <= 200000) ? t : t.slice(t.length - 200000);
6260
+ } catch {}
6261
+ }
6262
+ }
6263
+ if (opts.needsPlan) {
6264
+ const ti = payload.tool_input || {};
6265
+ envelope.planText = String(ti.plan || ti.content || payload.plan || '');
6266
+ }
6267
+
6268
+ const timeoutMs = opts.telemetry ? 6000 : 28000;
6269
+ const url = 'http://127.0.0.1:' + PORT + '/api/scan/' + surface + (harness === 'cursor' ? '?harness=cursor' : '');
6270
+ const resp = await fetch(url, {
6271
+ method: 'POST',
6272
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + loadMcpJwt() },
6273
+ body: JSON.stringify(envelope),
6274
+ signal: AbortSignal.timeout(timeoutMs),
6275
+ });
6276
+ const text = await resp.text();
6277
+ out(resp.ok ? (text.trim() || failOpen(harness)) : failOpen(harness));
6278
+ } catch {
6279
+ out(failOpen(harness));
6280
+ }
6281
+ }
6282
+ `;
6283
+ STUB_EDIT_PRECHECK_TS = stubHook("edit-precheck", "{ needsFile: true, needsTranscript: true }");
6284
+ STUB_CWE_PRECHECK_TS = stubHook("cwe-precheck", "{ needsFile: true, needsTranscript: true }");
6285
+ STUB_CVE_PRECHECK_TS = stubHook("cve-precheck", "{ needsFile: true, needsTranscript: true }");
6286
+ STUB_BASH_JUDGE_TS = stubHook("bash-judge", "{ needsTranscript: true }");
6287
+ STUB_INSTALL_SCAN_TS = stubHook("install-scan", "{ needsTranscript: true }");
6288
+ STUB_AGENT_JUDGE_TS = stubHook("agent-judge", "{ needsTranscript: true }");
6289
+ STUB_PLAN_JUDGE_TS = stubHook("plan-judge", "{ needsPlan: true }");
6290
+ STUB_STOP_SUMMARY_TS = stubHook("stop-summary", "{ needsTranscript: true, fullTranscript: true, telemetry: true }");
6291
+ STUB_SESSION_START_TS = stubHook("session-start", "{ telemetry: true }");
6292
+ STUB_TRANSCRIPT_SYNC_TS = stubHook("transcript-sync", "{ needsTranscript: true, fullTranscript: true, telemetry: true }");
6293
+ STUB_USER_PROMPT_SUBMIT_TS = stubHook("prompt-submit", "{ telemetry: true }");
6294
+ STUB_BASH_FOLLOWUP_TS = stubHook("bash-followup", "{ telemetry: true }");
6295
+ STUB_CURSOR_BASH_JUDGE_TS = stubHook("bash-judge", "{ needsTranscript: true, harness: 'cursor' }");
6296
+ STUB_CURSOR_EDIT_CAPTURE_TS = stubHook("edit-precheck", "{ needsFile: true, needsTranscript: true, harness: 'cursor' }");
6297
+ STUB_CURSOR_AGENT_CAPTURE_TS = stubHook("agent-judge", "{ needsTranscript: true, harness: 'cursor' }");
6152
6298
  }
6153
6299
  });
6154
6300
 
@@ -7494,10 +7640,20 @@ async function dockerInstall(opts = {}) {
7494
7640
  } else {
7495
7641
  mkdirSync7(join6(homedir6(), ".claude"), { recursive: true });
7496
7642
  }
7497
- console.log(` Pulling ${image}...`);
7498
- const pull = spawnSync2("docker", ["pull", image], { encoding: "utf-8", stdio: "inherit", timeout: 6e5 });
7499
- if (pull.status !== 0) {
7500
- throw new DockerInstallError(`docker pull ${image} failed`);
7643
+ const imageExistsLocally = () => spawnSync2("docker", ["image", "inspect", image], { stdio: "ignore", timeout: 3e4 }).status === 0;
7644
+ const skipPull = process.env.SYNKRO_SKIP_PULL === "1" || process.env.SYNKRO_SKIP_PULL === "true";
7645
+ if (skipPull && imageExistsLocally()) {
7646
+ console.log(` Using local image ${image} (SYNKRO_SKIP_PULL set \u2014 pull skipped).`);
7647
+ } else {
7648
+ console.log(` Pulling ${image}...`);
7649
+ const pull = spawnSync2("docker", ["pull", image], { encoding: "utf-8", stdio: "inherit", timeout: 6e5 });
7650
+ if (pull.status !== 0) {
7651
+ if (imageExistsLocally()) {
7652
+ console.warn(` \u26A0 docker pull ${image} failed \u2014 falling back to the existing local image.`);
7653
+ } else {
7654
+ throw new DockerInstallError(`docker pull ${image} failed`);
7655
+ }
7656
+ }
7501
7657
  }
7502
7658
  const existing = dockerStatus();
7503
7659
  if (existing.running) {
@@ -8168,11 +8324,21 @@ __export(install_exports, {
8168
8324
  syncSkillFiles: () => syncSkillFiles,
8169
8325
  writeHookScripts: () => writeHookScripts
8170
8326
  });
8171
- import { existsSync as existsSync10, mkdirSync as mkdirSync8, writeFileSync as writeFileSync7, chmodSync as chmodSync2, readFileSync as readFileSync8, readdirSync as readdirSync3 } from "fs";
8327
+ import { existsSync as existsSync10, mkdirSync as mkdirSync8, writeFileSync as writeFileSync7, chmodSync as chmodSync2, readFileSync as readFileSync8, readdirSync as readdirSync3, unlinkSync as unlinkSync4 } from "fs";
8172
8328
  import { homedir as homedir8 } from "os";
8173
8329
  import { join as join8 } from "path";
8174
8330
  import { execSync as execSync6 } from "child_process";
8175
8331
  import { createInterface as createInterface3 } from "readline";
8332
+ function resolvePersistedHookMode() {
8333
+ if (process.env.SYNKRO_HOOK_MODE === "full") return "full";
8334
+ if (process.env.SYNKRO_HOOK_MODE === "stub") return "stub";
8335
+ try {
8336
+ const env = readFileSync8(CONFIG_PATH2, "utf-8");
8337
+ if (/^SYNKRO_HOOK_MODE=['"]?full['"]?\s*$/m.test(env)) return "full";
8338
+ } catch {
8339
+ }
8340
+ return "stub";
8341
+ }
8176
8342
  function sanitizeGatewayCandidate(raw) {
8177
8343
  if (!raw) return void 0;
8178
8344
  return /^https?:\/\//.test(raw) ? raw : void 0;
@@ -8187,7 +8353,14 @@ function parseArgs(argv) {
8187
8353
  else if (a === "--no-mcp") opts.noMcp = true;
8188
8354
  else if (a === "--force" || a === "-f") opts.force = true;
8189
8355
  else if (a === "--link-repo") opts.linkRepo = true;
8190
- }
8356
+ else if (a === "--stub" || a === "--mode=stub") opts.hookMode = "stub";
8357
+ else if (a === "--legacy" || a === "--mode=full") opts.hookMode = "full";
8358
+ }
8359
+ const modeIdx = argv.indexOf("--mode");
8360
+ if (modeIdx >= 0 && argv[modeIdx + 1] === "stub") opts.hookMode = "stub";
8361
+ if (modeIdx >= 0 && argv[modeIdx + 1] === "full") opts.hookMode = "full";
8362
+ if (!opts.hookMode && process.env.SYNKRO_HOOK_MODE === "full") opts.hookMode = "full";
8363
+ if (!opts.hookMode && process.env.SYNKRO_HOOK_MODE === "stub") opts.hookMode = "stub";
8191
8364
  if (!opts.gatewayUrl) {
8192
8365
  const fromEnv = sanitizeGatewayCandidate(process.env.SYNKRO_GATEWAY_URL);
8193
8366
  if (fromEnv) opts.gatewayUrl = fromEnv;
@@ -8298,7 +8471,7 @@ function ensureSynkroDir() {
8298
8471
  mkdirSync8(OFFSETS_DIR, { recursive: true });
8299
8472
  mkdirSync8(join8(SYNKRO_DIR4, "sessions"), { recursive: true });
8300
8473
  }
8301
- function writeHookScripts() {
8474
+ function writeHookScripts(mode = "stub") {
8302
8475
  const installExtractCorePath = join8(HOOKS_DIR, "installExtractCore.ts");
8303
8476
  const bashScriptPath = join8(HOOKS_DIR, "cc-bash-judge.ts");
8304
8477
  const bashFollowupScriptPath = join8(HOOKS_DIR, "cc-bash-followup.ts");
@@ -8318,6 +8491,56 @@ function writeHookScripts() {
8318
8491
  const cursorEditCapturePath = join8(HOOKS_DIR, "cursor-edit-capture.ts");
8319
8492
  const cursorAgentCapturePath = join8(HOOKS_DIR, "cursor-agent-capture.ts");
8320
8493
  const mcpStdioProxyPath = join8(HOOKS_DIR, "mcp-stdio-proxy.ts");
8494
+ if (mode === "stub") {
8495
+ const stubCommonPath = join8(HOOKS_DIR, "_synkro-stub-common.ts");
8496
+ const stubFiles = [
8497
+ [stubCommonPath, STUB_COMMON_TS],
8498
+ [bashScriptPath, STUB_BASH_JUDGE_TS],
8499
+ [bashFollowupScriptPath, STUB_BASH_FOLLOWUP_TS],
8500
+ [editPrecheckScriptPath, STUB_EDIT_PRECHECK_TS],
8501
+ [cwePrecheckScriptPath, STUB_CWE_PRECHECK_TS],
8502
+ [cvePrecheckScriptPath, STUB_CVE_PRECHECK_TS],
8503
+ [planJudgeScriptPath, STUB_PLAN_JUDGE_TS],
8504
+ [agentJudgeScriptPath, STUB_AGENT_JUDGE_TS],
8505
+ [stopSummaryScriptPath, STUB_STOP_SUMMARY_TS],
8506
+ [sessionStartScriptPath, STUB_SESSION_START_TS],
8507
+ [transcriptSyncScriptPath, STUB_TRANSCRIPT_SYNC_TS],
8508
+ [userPromptSubmitScriptPath, STUB_USER_PROMPT_SUBMIT_TS],
8509
+ [installScanScriptPath, STUB_INSTALL_SCAN_TS],
8510
+ [cursorBashJudgePath, STUB_CURSOR_BASH_JUDGE_TS],
8511
+ [cursorEditCapturePath, STUB_CURSOR_EDIT_CAPTURE_TS],
8512
+ [cursorAgentCapturePath, STUB_CURSOR_AGENT_CAPTURE_TS]
8513
+ ];
8514
+ for (const [p, content] of stubFiles) {
8515
+ writeFileSync7(p, content, "utf-8");
8516
+ chmodSync2(p, 493);
8517
+ }
8518
+ writeFileSync7(mcpStdioProxyPath, MCP_STDIO_PROXY_SRC, "utf-8");
8519
+ chmodSync2(mcpStdioProxyPath, 493);
8520
+ for (const stale of ["_synkro-common.ts", "_synkro-common.sh", "installExtractCore.ts"]) {
8521
+ try {
8522
+ unlinkSync4(join8(HOOKS_DIR, stale));
8523
+ } catch {
8524
+ }
8525
+ }
8526
+ return {
8527
+ bashScript: bashScriptPath,
8528
+ bashFollowupScript: bashFollowupScriptPath,
8529
+ editPrecheckScript: editPrecheckScriptPath,
8530
+ cwePrecheckScript: cwePrecheckScriptPath,
8531
+ cvePrecheckScript: cvePrecheckScriptPath,
8532
+ planJudgeScript: planJudgeScriptPath,
8533
+ agentJudgeScript: agentJudgeScriptPath,
8534
+ stopSummaryScript: stopSummaryScriptPath,
8535
+ sessionStartScript: sessionStartScriptPath,
8536
+ transcriptSyncScript: transcriptSyncScriptPath,
8537
+ userPromptSubmitScript: userPromptSubmitScriptPath,
8538
+ installScanScript: installScanScriptPath,
8539
+ cursorBashJudgeScript: cursorBashJudgePath,
8540
+ cursorEditCaptureScript: cursorEditCapturePath,
8541
+ cursorAgentCaptureScript: cursorAgentCapturePath
8542
+ };
8543
+ }
8321
8544
  writeFileSync7(bashScriptPath, BASH_JUDGE_TS, "utf-8");
8322
8545
  writeFileSync7(bashFollowupScriptPath, BASH_FOLLOWUP_TS, "utf-8");
8323
8546
  writeFileSync7(editPrecheckScriptPath, EDIT_PRECHECK_TS, "utf-8");
@@ -8336,7 +8559,7 @@ function writeHookScripts() {
8336
8559
  writeFileSync7(cursorEditCapturePath, CURSOR_EDIT_CAPTURE_TS, "utf-8");
8337
8560
  writeFileSync7(cursorAgentCapturePath, CURSOR_AGENT_CAPTURE_TS, "utf-8");
8338
8561
  writeFileSync7(mcpStdioProxyPath, MCP_STDIO_PROXY_SRC, "utf-8");
8339
- writeFileSync7(installExtractCorePath, "/**\n * Deterministic install-command extraction \u2014 no LLM, no network.\n * Shared by the API pkg-scan route and the hook scripts (copied to ~/.synkro/hooks/).\n */\nimport { parse as shellParse } from 'shell-quote';\n\nexport interface DeterministicPkgRequest {\n name: string;\n version: string;\n ecosystem: string;\n}\n\ninterface RawInstall {\n ecosystem: string;\n name: string;\n versionSpec: string | null;\n source: 'registry' | 'git' | 'local' | 'url' | 'unknown';\n}\n\nconst SEPARATOR_OPS = new Set(['&&', '||', ';', '|', '&', '\\n']);\n\n/** Split a shell command into command segments (each an argv string array). */\nexport function segmentCommand(command: string): string[][] {\n let tokens: unknown[];\n try {\n tokens = shellParse(command) as unknown[];\n } catch {\n return [command.split(/\\s+/).filter(Boolean)];\n }\n const segments: string[][] = [];\n let current: string[] = [];\n for (const tok of tokens) {\n if (typeof tok === 'string') {\n current.push(tok);\n continue;\n }\n if (tok && typeof tok === 'object') {\n const op = (tok as { op?: string }).op;\n const pattern = (tok as { pattern?: string }).pattern;\n if (op && SEPARATOR_OPS.has(op)) {\n if (current.length) segments.push(current);\n current = [];\n } else if (typeof pattern === 'string') {\n current.push(pattern);\n }\n }\n }\n if (current.length) segments.push(current);\n return segments;\n}\n\nconst PM_TABLE: Record<string, { subs: Set<string>; ecosystem: string }> = {\n npm: { subs: new Set(['install', 'i', 'add', 'ci']), ecosystem: 'npm' },\n pnpm: { subs: new Set(['add', 'install', 'i', 'dlx']), ecosystem: 'npm' },\n yarn: { subs: new Set(['add', 'install']), ecosystem: 'npm' },\n bun: { subs: new Set(['add', 'install', 'i']), ecosystem: 'npm' },\n pip: { subs: new Set(['install']), ecosystem: 'PyPI' },\n pip3: { subs: new Set(['install']), ecosystem: 'PyPI' },\n cargo: { subs: new Set(['add', 'install']), ecosystem: 'crates.io' },\n go: { subs: new Set(['get', 'install']), ecosystem: 'Go' },\n gem: { subs: new Set(['install']), ecosystem: 'RubyGems' },\n composer: { subs: new Set(['require']), ecosystem: 'Packagist' },\n};\nconst SYSTEM_PMS = new Set(['apt', 'apt-get', 'apk', 'brew', 'dnf', 'yum', 'pacman']);\nconst SYSTEM_SUBS = new Set(['install', 'add']);\n\nconst WRAPPERS = new Set(['sudo', 'doas', 'command', 'env', 'xargs', 'nice', 'time']);\nconst VALUE_FLAGS = new Set([\n '--filter', '-F', '-C', '--dir', '--prefix', '--registry', '--tag', '--features',\n '-v', '--version', '--index-url', '--extra-index-url', '--target', '-t',\n]);\n\nfunction basename(p: string): string {\n const i = p.lastIndexOf('/');\n return i >= 0 ? p.slice(i + 1) : p;\n}\n\nfunction stripPrefixes(argv: string[]): string[] {\n let i = 0;\n while (i < argv.length) {\n const t = argv[i];\n if (WRAPPERS.has(basename(t).toLowerCase())) { i++; continue; }\n if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(t)) { i++; continue; }\n break;\n }\n return argv.slice(i);\n}\n\nfunction looksLikePath(tok: string): boolean {\n return tok === '.' || tok === '..' || /^\\.{0,2}\\//.test(tok) || tok.startsWith('~/') || tok.startsWith('file:');\n}\n\n/** Shell redirect fragments (e.g. `2>&1` \u2192 argv `2`, `1`) \u2014 not package names. */\nfunction isRedirectFragment(tok: string): boolean {\n if (/^\\d+$/.test(tok)) return true;\n if (/^[<>]|[<>]$/.test(tok)) return true;\n if (tok === '&' || tok === '|') return true;\n if (/^\\d*[<>]/.test(tok)) return true;\n return false;\n}\n\nfunction parsePackageToken(tok: string, ecosystem: string): RawInstall | null {\n if (/^(https?:)?\\/\\//.test(tok) || tok.startsWith('git+') || tok.startsWith('git:')) {\n return { ecosystem, name: tok, versionSpec: null, source: tok.includes('git') ? 'git' : 'url' };\n }\n if (looksLikePath(tok)) {\n return { ecosystem, name: basename(tok.replace(/\\/+$/, '')) || tok, versionSpec: null, source: 'local' };\n }\n if (ecosystem === 'PyPI') {\n const noExtras = tok.replace(/\\[[^\\]]*\\]/g, '');\n const m = noExtras.match(/^([A-Za-z0-9_.-]+)\\s*([=~!<>].*)?$/);\n if (!m) return null;\n return { ecosystem, name: m[1], versionSpec: m[2] ? m[2].trim() : null, source: 'registry' };\n }\n const at = tok.lastIndexOf('@');\n if (at > 0) {\n return { ecosystem, name: tok.slice(0, at), versionSpec: tok.slice(at + 1) || null, source: 'registry' };\n }\n return { ecosystem, name: tok, versionSpec: null, source: 'registry' };\n}\n\n/** Deterministic extraction for a single command segment. */\nexport function extractSegment(rawArgv: string[]): RawInstall[] {\n let argv = stripPrefixes(rawArgv);\n if (argv.length < 2) return [];\n let bin = basename(argv[0]).toLowerCase();\n\n if (bin === 'uv' && argv[1] === 'pip') { argv = argv.slice(1); bin = 'pip'; }\n if ((bin === 'python' || bin === 'python3') && argv.includes('-m')) {\n const mi = argv.indexOf('-m');\n if (argv[mi + 1] === 'pip') { argv = ['pip', ...argv.slice(mi + 2)]; bin = 'pip'; }\n }\n\n const isSystem = SYSTEM_PMS.has(bin);\n const entry = PM_TABLE[bin];\n if (!entry && !isSystem) return [];\n const ecosystem = entry ? entry.ecosystem : 'system';\n const subs = entry ? entry.subs : SYSTEM_SUBS;\n\n let subIdx = -1;\n for (let i = 1; i < argv.length; i++) {\n if (subs.has(argv[i].toLowerCase())) { subIdx = i; break; }\n }\n if (subIdx === -1) return [];\n\n const installs: RawInstall[] = [];\n for (let i = subIdx + 1; i < argv.length; i++) {\n const tok = argv[i];\n if (isRedirectFragment(tok)) break;\n if (tok.startsWith('-')) {\n if (VALUE_FLAGS.has(tok)) i++;\n continue;\n }\n const parsed = parsePackageToken(tok, ecosystem);\n if (parsed) installs.push(parsed);\n }\n return installs;\n}\n\nconst ECO_TO_OSV: Record<string, string | null> = {\n npm: 'npm',\n pypi: 'PyPI', PyPI: 'PyPI',\n cargo: 'crates.io', 'crates.io': 'crates.io',\n go: 'Go', Go: 'Go',\n rubygems: 'RubyGems', RubyGems: 'RubyGems',\n packagist: 'Packagist', Packagist: 'Packagist',\n maven: 'Maven', Maven: 'Maven',\n nuget: 'NuGet', NuGet: 'NuGet',\n apt: null, brew: null, system: null, other: null,\n};\n\nfunction normalizeName(name: string, osvEco: string): string {\n const n = name.trim();\n if (osvEco === 'npm') return n.toLowerCase();\n if (osvEco === 'PyPI') return n.toLowerCase().replace(/[-_.]+/g, '-');\n return n;\n}\n\nfunction concretePin(spec: string | null): string | null {\n if (!spec) return null;\n const c = spec.trim().replace(/^[v=\\s]+/, '');\n if (c.toLowerCase() === 'latest' || c === '') return null;\n if (/[\\^~><|*\\sx]/i.test(c)) return null;\n return /^\\d[\\w.\\-+]*$/.test(c) ? c : null;\n}\n\nconst PKG_JSON_DEP_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];\n\nfunction safeParseObject(text: string): Record<string, any> | null {\n try {\n const v = JSON.parse(text);\n return v && typeof v === 'object' && !Array.isArray(v) ? v : null;\n } catch {\n return null;\n }\n}\n\n/**\n * Diff two package.json contents and return the registry packages that are\n * newly added or whose version spec changed in the new content. The caller\n * scans these against the vuln DB before letting the edit land \u2014 so a bare\n * `pnpm install` afterwards has nothing left to vet. Non-registry sources\n * (file:, link:, workspace:, git, http, relative paths) are skipped.\n */\nexport function extractPackageJsonDelta(oldText: string, newText: string): DeterministicPkgRequest[] {\n const newJson = safeParseObject(newText);\n if (!newJson) return [];\n const oldJson = safeParseObject(oldText) || {};\n\n const out = new Map<string, DeterministicPkgRequest>();\n for (const field of PKG_JSON_DEP_FIELDS) {\n const oldDeps = (oldJson[field] && typeof oldJson[field] === 'object') ? oldJson[field] : {};\n const newDeps = (newJson[field] && typeof newJson[field] === 'object') ? newJson[field] : {};\n for (const [rawName, version] of Object.entries(newDeps)) {\n if (typeof version !== 'string') continue;\n if (oldDeps[rawName] === version) continue;\n const spec = version.trim();\n if (\n spec.startsWith('file:') || spec.startsWith('link:') ||\n spec.startsWith('http') || spec.startsWith('git') ||\n spec.startsWith('workspace:') || spec.startsWith('catalog:') ||\n spec.startsWith('npm:') ||\n spec.startsWith('./') || spec.startsWith('../') ||\n spec === '' || spec === '*'\n ) continue;\n const name = rawName.toLowerCase();\n out.set(name, {\n name,\n version: concretePin(spec) ?? '*',\n ecosystem: 'npm',\n });\n }\n }\n return [...out.values()];\n}\n\n/**\n * Parse registry installs from a shell command without LLM/network.\n * Unpinned versions use '*' so OSV scans the full advisory history.\n */\nexport function extractDeterministicPkgRequests(command: string): DeterministicPkgRequest[] {\n const merged = new Map<string, DeterministicPkgRequest>();\n for (const r of segmentCommand(command).flatMap(extractSegment)) {\n if (r.source !== 'registry') continue;\n const osvEco = ECO_TO_OSV[r.ecosystem] ?? ECO_TO_OSV[r.ecosystem.toLowerCase()] ?? null;\n if (!osvEco) continue;\n const name = normalizeName(r.name, osvEco);\n if (!name) continue;\n const key = osvEco + '|' + name.toLowerCase();\n const version = concretePin(r.versionSpec) ?? '*';\n const prev = merged.get(key);\n if (!prev || (prev.version === '*' && version !== '*')) {\n merged.set(key, { name, version, ecosystem: osvEco });\n }\n }\n return [...merged.values()];\n}\n", "utf-8");
8562
+ writeFileSync7(installExtractCorePath, "/**\n * Deterministic install-command extraction \u2014 no LLM, no network.\n * Shared by the API pkg-scan route and the hook scripts (copied to ~/.synkro/hooks/).\n */\nimport { parse as shellParse } from 'shell-quote';\n\nexport interface DeterministicPkgRequest {\n name: string;\n version: string;\n ecosystem: string;\n}\n\ninterface RawInstall {\n ecosystem: string;\n name: string;\n versionSpec: string | null;\n source: 'registry' | 'git' | 'local' | 'url' | 'unknown';\n}\n\nconst SEPARATOR_OPS = new Set(['&&', '||', ';', '|', '&', '\\n']);\n\n/** Split a shell command into command segments (each an argv string array). */\nexport function segmentCommand(command: string): string[][] {\n let tokens: unknown[];\n try {\n tokens = shellParse(command) as unknown[];\n } catch {\n return [command.split(/\\s+/).filter(Boolean)];\n }\n const segments: string[][] = [];\n let current: string[] = [];\n for (const tok of tokens) {\n if (typeof tok === 'string') {\n current.push(tok);\n continue;\n }\n if (tok && typeof tok === 'object') {\n const op = (tok as { op?: string }).op;\n const pattern = (tok as { pattern?: string }).pattern;\n if (op && SEPARATOR_OPS.has(op)) {\n if (current.length) segments.push(current);\n current = [];\n } else if (typeof pattern === 'string') {\n current.push(pattern);\n }\n }\n }\n if (current.length) segments.push(current);\n return segments;\n}\n\nconst PM_TABLE: Record<string, { subs: Set<string>; ecosystem: string }> = {\n npm: { subs: new Set(['install', 'i', 'add', 'ci']), ecosystem: 'npm' },\n pnpm: { subs: new Set(['add', 'install', 'i', 'dlx']), ecosystem: 'npm' },\n yarn: { subs: new Set(['add', 'install']), ecosystem: 'npm' },\n bun: { subs: new Set(['add', 'install', 'i']), ecosystem: 'npm' },\n pip: { subs: new Set(['install']), ecosystem: 'PyPI' },\n pip3: { subs: new Set(['install']), ecosystem: 'PyPI' },\n cargo: { subs: new Set(['add', 'install']), ecosystem: 'crates.io' },\n go: { subs: new Set(['get', 'install']), ecosystem: 'Go' },\n gem: { subs: new Set(['install']), ecosystem: 'RubyGems' },\n composer: { subs: new Set(['require']), ecosystem: 'Packagist' },\n};\nconst SYSTEM_PMS = new Set(['apt', 'apt-get', 'apk', 'brew', 'dnf', 'yum', 'pacman']);\nconst SYSTEM_SUBS = new Set(['install', 'add']);\n\nconst WRAPPERS = new Set(['sudo', 'doas', 'command', 'env', 'xargs', 'nice', 'time']);\nconst VALUE_FLAGS = new Set([\n '--filter', '-F', '-C', '--dir', '--prefix', '--registry', '--tag', '--features',\n '-v', '--version', '--index-url', '--extra-index-url', '--target', '-t',\n]);\n\nfunction basename(p: string): string {\n const i = p.lastIndexOf('/');\n return i >= 0 ? p.slice(i + 1) : p;\n}\n\nfunction stripPrefixes(argv: string[]): string[] {\n let i = 0;\n while (i < argv.length) {\n const t = argv[i];\n if (WRAPPERS.has(basename(t).toLowerCase())) { i++; continue; }\n if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(t)) { i++; continue; }\n break;\n }\n return argv.slice(i);\n}\n\nfunction looksLikePath(tok: string): boolean {\n return tok === '.' || tok === '..' || /^\\.{0,2}\\//.test(tok) || tok.startsWith('~/') || tok.startsWith('file:');\n}\n\n/** Shell redirect fragments (e.g. `2>&1` \u2192 argv `2`, `1`) \u2014 not package names. */\nfunction isRedirectFragment(tok: string): boolean {\n if (/^\\d+$/.test(tok)) return true;\n if (/^[<>]|[<>]$/.test(tok)) return true;\n if (tok === '&' || tok === '|') return true;\n if (/^\\d*[<>]/.test(tok)) return true;\n return false;\n}\n\nfunction parsePackageToken(tok: string, ecosystem: string): RawInstall | null {\n if (/^(https?:)?\\/\\//.test(tok) || tok.startsWith('git+') || tok.startsWith('git:')) {\n return { ecosystem, name: tok, versionSpec: null, source: tok.includes('git') ? 'git' : 'url' };\n }\n if (looksLikePath(tok)) {\n return { ecosystem, name: basename(tok.replace(/\\/+$/, '')) || tok, versionSpec: null, source: 'local' };\n }\n if (ecosystem === 'PyPI') {\n const noExtras = tok.replace(/\\[[^\\]]*\\]/g, '');\n const m = noExtras.match(/^([A-Za-z0-9_.-]+)\\s*([=~!<>].*)?$/);\n if (!m) return null;\n return { ecosystem, name: m[1], versionSpec: m[2] ? m[2].trim() : null, source: 'registry' };\n }\n if (ecosystem === 'Packagist') {\n // composer uses vendor/package:version-constraint (e.g. monolog/monolog:1.0.0).\n // Split on the first ':' after the vendor/ slash; never on the '/'.\n const ci = tok.indexOf(':');\n if (ci > 0) return { ecosystem, name: tok.slice(0, ci), versionSpec: tok.slice(ci + 1) || null, source: 'registry' };\n return { ecosystem, name: tok, versionSpec: null, source: 'registry' };\n }\n const at = tok.lastIndexOf('@');\n if (at > 0) {\n return { ecosystem, name: tok.slice(0, at), versionSpec: tok.slice(at + 1) || null, source: 'registry' };\n }\n return { ecosystem, name: tok, versionSpec: null, source: 'registry' };\n}\n\n/** Deterministic extraction for a single command segment. */\nexport function extractSegment(rawArgv: string[]): RawInstall[] {\n let argv = stripPrefixes(rawArgv);\n if (argv.length < 2) return [];\n let bin = basename(argv[0]).toLowerCase();\n\n if (bin === 'uv' && argv[1] === 'pip') { argv = argv.slice(1); bin = 'pip'; }\n if ((bin === 'python' || bin === 'python3') && argv.includes('-m')) {\n const mi = argv.indexOf('-m');\n if (argv[mi + 1] === 'pip') { argv = ['pip', ...argv.slice(mi + 2)]; bin = 'pip'; }\n }\n\n const isSystem = SYSTEM_PMS.has(bin);\n const entry = PM_TABLE[bin];\n if (!entry && !isSystem) return [];\n const ecosystem = entry ? entry.ecosystem : 'system';\n const subs = entry ? entry.subs : SYSTEM_SUBS;\n\n let subIdx = -1;\n for (let i = 1; i < argv.length; i++) {\n if (subs.has(argv[i].toLowerCase())) { subIdx = i; break; }\n }\n if (subIdx === -1) return [];\n\n const installs: RawInstall[] = [];\n // gem pins the version with a separate `-v`/`--version` flag rather than an\n // inline spec; capture it and apply to the package(s) in this segment.\n let flagVersion: string | null = null;\n for (let i = subIdx + 1; i < argv.length; i++) {\n const tok = argv[i];\n if (isRedirectFragment(tok)) break;\n if (tok.startsWith('-')) {\n const lower = tok.toLowerCase();\n if (ecosystem === 'RubyGems' && (lower === '-v' || lower === '--version')) {\n flagVersion = (argv[i + 1] || '').replace(/^[v=\\s]+/, '') || null;\n i++;\n continue;\n }\n if (VALUE_FLAGS.has(tok)) i++;\n continue;\n }\n const parsed = parsePackageToken(tok, ecosystem);\n if (parsed) installs.push(parsed);\n }\n if (flagVersion) {\n for (const ins of installs) {\n if (ins.source === 'registry' && !ins.versionSpec) ins.versionSpec = flagVersion;\n }\n }\n return installs;\n}\n\nconst ECO_TO_OSV: Record<string, string | null> = {\n npm: 'npm',\n pypi: 'PyPI', PyPI: 'PyPI',\n cargo: 'crates.io', 'crates.io': 'crates.io',\n go: 'Go', Go: 'Go',\n rubygems: 'RubyGems', RubyGems: 'RubyGems',\n packagist: 'Packagist', Packagist: 'Packagist',\n maven: 'Maven', Maven: 'Maven',\n nuget: 'NuGet', NuGet: 'NuGet',\n apt: null, brew: null, system: null, other: null,\n};\n\nfunction normalizeName(name: string, osvEco: string): string {\n const n = name.trim();\n if (osvEco === 'npm') return n.toLowerCase();\n if (osvEco === 'PyPI') return n.toLowerCase().replace(/[-_.]+/g, '-');\n return n;\n}\n\nfunction concretePin(spec: string | null): string | null {\n if (!spec) return null;\n const c = spec.trim().replace(/^[v=\\s]+/, '');\n if (c.toLowerCase() === 'latest' || c === '') return null;\n if (/[\\^~><|*\\sx]/i.test(c)) return null;\n return /^\\d[\\w.\\-+]*$/.test(c) ? c : null;\n}\n\nconst PKG_JSON_DEP_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];\n\nfunction safeParseObject(text: string): Record<string, any> | null {\n try {\n const v = JSON.parse(text);\n return v && typeof v === 'object' && !Array.isArray(v) ? v : null;\n } catch {\n return null;\n }\n}\n\n/**\n * Diff two package.json contents and return the registry packages that are\n * newly added or whose version spec changed in the new content. The caller\n * scans these against the vuln DB before letting the edit land \u2014 so a bare\n * `pnpm install` afterwards has nothing left to vet. Non-registry sources\n * (file:, link:, workspace:, git, http, relative paths) are skipped.\n */\nexport function extractPackageJsonDelta(oldText: string, newText: string): DeterministicPkgRequest[] {\n const newJson = safeParseObject(newText);\n if (!newJson) return [];\n const oldJson = safeParseObject(oldText) || {};\n\n const out = new Map<string, DeterministicPkgRequest>();\n for (const field of PKG_JSON_DEP_FIELDS) {\n const oldDeps = (oldJson[field] && typeof oldJson[field] === 'object') ? oldJson[field] : {};\n const newDeps = (newJson[field] && typeof newJson[field] === 'object') ? newJson[field] : {};\n for (const [rawName, version] of Object.entries(newDeps)) {\n if (typeof version !== 'string') continue;\n if (oldDeps[rawName] === version) continue;\n const spec = version.trim();\n if (\n spec.startsWith('file:') || spec.startsWith('link:') ||\n spec.startsWith('http') || spec.startsWith('git') ||\n spec.startsWith('workspace:') || spec.startsWith('catalog:') ||\n spec.startsWith('npm:') ||\n spec.startsWith('./') || spec.startsWith('../') ||\n spec === '' || spec === '*'\n ) continue;\n const name = rawName.toLowerCase();\n out.set(name, {\n name,\n version: concretePin(spec) ?? '*',\n ecosystem: 'npm',\n });\n }\n }\n return [...out.values()];\n}\n\n/**\n * Parse registry installs from a shell command without LLM/network.\n * Unpinned versions use '*' so OSV scans the full advisory history.\n */\nexport function extractDeterministicPkgRequests(command: string): DeterministicPkgRequest[] {\n const merged = new Map<string, DeterministicPkgRequest>();\n for (const r of segmentCommand(command).flatMap(extractSegment)) {\n if (r.source !== 'registry') continue;\n const osvEco = ECO_TO_OSV[r.ecosystem] ?? ECO_TO_OSV[r.ecosystem.toLowerCase()] ?? null;\n if (!osvEco) continue;\n const name = normalizeName(r.name, osvEco);\n if (!name) continue;\n const key = osvEco + '|' + name.toLowerCase();\n const version = concretePin(r.versionSpec) ?? '*';\n const prev = merged.get(key);\n if (!prev || (prev.version === '*' && version !== '*')) {\n merged.set(key, { name, version, ecosystem: osvEco });\n }\n }\n return [...merged.values()];\n}\n", "utf-8");
8340
8563
  chmodSync2(bashScriptPath, 493);
8341
8564
  chmodSync2(bashFollowupScriptPath, 493);
8342
8565
  chmodSync2(editPrecheckScriptPath, 493);
@@ -8356,6 +8579,10 @@ function writeHookScripts() {
8356
8579
  chmodSync2(cursorAgentCapturePath, 493);
8357
8580
  chmodSync2(mcpStdioProxyPath, 493);
8358
8581
  chmodSync2(installExtractCorePath, 493);
8582
+ try {
8583
+ unlinkSync4(join8(HOOKS_DIR, "_synkro-stub-common.ts"));
8584
+ } catch {
8585
+ }
8359
8586
  return {
8360
8587
  bashScript: bashScriptPath,
8361
8588
  bashFollowupScript: bashFollowupScriptPath,
@@ -8403,7 +8630,7 @@ function writeConfigEnv(opts) {
8403
8630
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
8404
8631
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
8405
8632
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
8406
- `SYNKRO_VERSION=${shellQuoteSingle("1.6.46")}`
8633
+ `SYNKRO_VERSION=${shellQuoteSingle("1.6.48")}`
8407
8634
  ];
8408
8635
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
8409
8636
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
@@ -8417,6 +8644,7 @@ function writeConfigEnv(opts) {
8417
8644
  lines.push(`SYNKRO_DEPLOYMENT_MODE=${shellQuoteSingle(safeMode)}`);
8418
8645
  lines.push(`SYNKRO_GRADING_MODE=${shellQuoteSingle(sanitizeConfigValue(opts.gradingMode ?? "local", 16))}`);
8419
8646
  lines.push(`SYNKRO_STORAGE_MODE=${shellQuoteSingle(sanitizeConfigValue(opts.storageMode ?? "local", 16))}`);
8647
+ lines.push(`SYNKRO_HOOK_MODE=${shellQuoteSingle(sanitizeConfigValue(opts.hookMode ?? "stub", 8))}`);
8420
8648
  lines.push("");
8421
8649
  writeFileSync7(CONFIG_PATH2, lines.join("\n"), "utf-8");
8422
8650
  chmodSync2(CONFIG_PATH2, 384);
@@ -8635,7 +8863,8 @@ async function installCommand(opts = {}) {
8635
8863
  }
8636
8864
  }
8637
8865
  ensureSynkroDir();
8638
- const scripts = writeHookScripts();
8866
+ const hookMode = opts.hookMode || resolvePersistedHookMode();
8867
+ const scripts = writeHookScripts(hookMode);
8639
8868
  console.log("Wrote hook scripts to ~/.synkro/hooks/\n");
8640
8869
  for (const mode of ["edit", "bash"]) {
8641
8870
  const pidFile = join8(SYNKRO_DIR4, "daemon", mode, "daemon.pid");
@@ -8806,7 +9035,7 @@ async function installCommand(opts = {}) {
8806
9035
  }
8807
9036
  const synkroBundle = resolveSynkroBundle();
8808
9037
  const persistedMode = resolveDeploymentMode();
8809
- writeConfigEnv({ gatewayUrl, userId, orgId, email, tier: profile.tier, inference: profile.inference, synkroBin: synkroBundle, transcriptConsent, localInference: profile.localInference, deploymentMode: persistedMode, gradingMode, storageMode });
9038
+ writeConfigEnv({ gatewayUrl, userId, orgId, email, tier: profile.tier, inference: profile.inference, synkroBin: synkroBundle, transcriptConsent, localInference: profile.localInference, deploymentMode: persistedMode, gradingMode, storageMode, hookMode });
8810
9039
  console.log(`Wrote config to ${CONFIG_PATH2}`);
8811
9040
  console.log(` inference: ${profile.inference} (server-side grading)`);
8812
9041
  if (profile.localInference) console.log(` local inference: enabled (gradingProvider=claude-code)`);
@@ -9110,7 +9339,7 @@ function reconcileHarness() {
9110
9339
  const wantCC = sf.harness.includes("claude-code");
9111
9340
  const wantCursor = sf.harness.includes("cursor");
9112
9341
  console.log(`.synkro: harness=[${sf.harness.join(", ")}] pool=${sf.grader.pool} mode=${sf.grader.mode}`);
9113
- const scripts = writeHookScripts();
9342
+ const scripts = writeHookScripts(resolvePersistedHookMode());
9114
9343
  console.log("Wrote hook scripts to ~/.synkro/hooks/");
9115
9344
  const ccSettings = join8(homedir8(), ".claude", "settings.json");
9116
9345
  if (wantCC) {
@@ -9468,6 +9697,7 @@ var init_install = __esm({
9468
9697
  init_skillParser();
9469
9698
  init_hookScripts();
9470
9699
  init_hookScriptsTs();
9700
+ init_hookScriptsTs();
9471
9701
  init_stub();
9472
9702
  init_repoConnect();
9473
9703
  init_projects();
@@ -9528,7 +9758,7 @@ rl.on('line', async (line) => {
9528
9758
  });
9529
9759
 
9530
9760
  // cli/local-cc/install.ts
9531
- import { existsSync as existsSync11, mkdirSync as mkdirSync9, writeFileSync as writeFileSync8, readFileSync as readFileSync9, chmodSync as chmodSync3, copyFileSync as copyFileSync2, renameSync as renameSync4, unlinkSync as unlinkSync4, openSync, fsyncSync, closeSync } from "fs";
9761
+ import { existsSync as existsSync11, mkdirSync as mkdirSync9, writeFileSync as writeFileSync8, readFileSync as readFileSync9, chmodSync as chmodSync3, copyFileSync as copyFileSync2, renameSync as renameSync4, unlinkSync as unlinkSync5, openSync, fsyncSync, closeSync } from "fs";
9532
9762
  import { join as join9 } from "path";
9533
9763
  import { homedir as homedir9 } from "os";
9534
9764
  import { spawnSync as spawnSync4 } from "child_process";
@@ -9609,7 +9839,7 @@ function safelyMutateClaudeJson(mutator) {
9609
9839
  renameSync4(tmpPath, CLAUDE_JSON_PATH);
9610
9840
  } catch (err) {
9611
9841
  try {
9612
- unlinkSync4(tmpPath);
9842
+ unlinkSync5(tmpPath);
9613
9843
  } catch {
9614
9844
  }
9615
9845
  try {
@@ -11488,7 +11718,7 @@ var args = process.argv.slice(2);
11488
11718
  var cmd = args[0] || "";
11489
11719
  var subArgs = args.slice(1);
11490
11720
  function printVersion() {
11491
- console.log("1.6.46");
11721
+ console.log("1.6.48");
11492
11722
  }
11493
11723
  function printHelp2() {
11494
11724
  console.log(`Synkro CLI \u2014 runtime safety for AI coding agents