@synkro-sh/cli 1.6.46 → 1.6.47

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 === "stub") return "stub";
8334
+ if (process.env.SYNKRO_HOOK_MODE === "full") return "full";
8335
+ try {
8336
+ const env = readFileSync8(CONFIG_PATH2, "utf-8");
8337
+ if (/^SYNKRO_HOOK_MODE=['"]?stub['"]?\s*$/m.test(env)) return "stub";
8338
+ } catch {
8339
+ }
8340
+ return "full";
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,13 @@ 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;
8356
+ else if (a === "--stub" || a === "--mode=stub") opts.hookMode = "stub";
8357
+ else if (a === "--mode=full") opts.hookMode = "full";
8190
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 === "stub") opts.hookMode = "stub";
8191
8363
  if (!opts.gatewayUrl) {
8192
8364
  const fromEnv = sanitizeGatewayCandidate(process.env.SYNKRO_GATEWAY_URL);
8193
8365
  if (fromEnv) opts.gatewayUrl = fromEnv;
@@ -8298,7 +8470,7 @@ function ensureSynkroDir() {
8298
8470
  mkdirSync8(OFFSETS_DIR, { recursive: true });
8299
8471
  mkdirSync8(join8(SYNKRO_DIR4, "sessions"), { recursive: true });
8300
8472
  }
8301
- function writeHookScripts() {
8473
+ function writeHookScripts(mode = "full") {
8302
8474
  const installExtractCorePath = join8(HOOKS_DIR, "installExtractCore.ts");
8303
8475
  const bashScriptPath = join8(HOOKS_DIR, "cc-bash-judge.ts");
8304
8476
  const bashFollowupScriptPath = join8(HOOKS_DIR, "cc-bash-followup.ts");
@@ -8318,6 +8490,56 @@ function writeHookScripts() {
8318
8490
  const cursorEditCapturePath = join8(HOOKS_DIR, "cursor-edit-capture.ts");
8319
8491
  const cursorAgentCapturePath = join8(HOOKS_DIR, "cursor-agent-capture.ts");
8320
8492
  const mcpStdioProxyPath = join8(HOOKS_DIR, "mcp-stdio-proxy.ts");
8493
+ if (mode === "stub") {
8494
+ const stubCommonPath = join8(HOOKS_DIR, "_synkro-stub-common.ts");
8495
+ const stubFiles = [
8496
+ [stubCommonPath, STUB_COMMON_TS],
8497
+ [bashScriptPath, STUB_BASH_JUDGE_TS],
8498
+ [bashFollowupScriptPath, STUB_BASH_FOLLOWUP_TS],
8499
+ [editPrecheckScriptPath, STUB_EDIT_PRECHECK_TS],
8500
+ [cwePrecheckScriptPath, STUB_CWE_PRECHECK_TS],
8501
+ [cvePrecheckScriptPath, STUB_CVE_PRECHECK_TS],
8502
+ [planJudgeScriptPath, STUB_PLAN_JUDGE_TS],
8503
+ [agentJudgeScriptPath, STUB_AGENT_JUDGE_TS],
8504
+ [stopSummaryScriptPath, STUB_STOP_SUMMARY_TS],
8505
+ [sessionStartScriptPath, STUB_SESSION_START_TS],
8506
+ [transcriptSyncScriptPath, STUB_TRANSCRIPT_SYNC_TS],
8507
+ [userPromptSubmitScriptPath, STUB_USER_PROMPT_SUBMIT_TS],
8508
+ [installScanScriptPath, STUB_INSTALL_SCAN_TS],
8509
+ [cursorBashJudgePath, STUB_CURSOR_BASH_JUDGE_TS],
8510
+ [cursorEditCapturePath, STUB_CURSOR_EDIT_CAPTURE_TS],
8511
+ [cursorAgentCapturePath, STUB_CURSOR_AGENT_CAPTURE_TS]
8512
+ ];
8513
+ for (const [p, content] of stubFiles) {
8514
+ writeFileSync7(p, content, "utf-8");
8515
+ chmodSync2(p, 493);
8516
+ }
8517
+ writeFileSync7(mcpStdioProxyPath, MCP_STDIO_PROXY_SRC, "utf-8");
8518
+ chmodSync2(mcpStdioProxyPath, 493);
8519
+ for (const stale of ["_synkro-common.ts", "_synkro-common.sh", "installExtractCore.ts"]) {
8520
+ try {
8521
+ unlinkSync4(join8(HOOKS_DIR, stale));
8522
+ } catch {
8523
+ }
8524
+ }
8525
+ return {
8526
+ bashScript: bashScriptPath,
8527
+ bashFollowupScript: bashFollowupScriptPath,
8528
+ editPrecheckScript: editPrecheckScriptPath,
8529
+ cwePrecheckScript: cwePrecheckScriptPath,
8530
+ cvePrecheckScript: cvePrecheckScriptPath,
8531
+ planJudgeScript: planJudgeScriptPath,
8532
+ agentJudgeScript: agentJudgeScriptPath,
8533
+ stopSummaryScript: stopSummaryScriptPath,
8534
+ sessionStartScript: sessionStartScriptPath,
8535
+ transcriptSyncScript: transcriptSyncScriptPath,
8536
+ userPromptSubmitScript: userPromptSubmitScriptPath,
8537
+ installScanScript: installScanScriptPath,
8538
+ cursorBashJudgeScript: cursorBashJudgePath,
8539
+ cursorEditCaptureScript: cursorEditCapturePath,
8540
+ cursorAgentCaptureScript: cursorAgentCapturePath
8541
+ };
8542
+ }
8321
8543
  writeFileSync7(bashScriptPath, BASH_JUDGE_TS, "utf-8");
8322
8544
  writeFileSync7(bashFollowupScriptPath, BASH_FOLLOWUP_TS, "utf-8");
8323
8545
  writeFileSync7(editPrecheckScriptPath, EDIT_PRECHECK_TS, "utf-8");
@@ -8336,7 +8558,7 @@ function writeHookScripts() {
8336
8558
  writeFileSync7(cursorEditCapturePath, CURSOR_EDIT_CAPTURE_TS, "utf-8");
8337
8559
  writeFileSync7(cursorAgentCapturePath, CURSOR_AGENT_CAPTURE_TS, "utf-8");
8338
8560
  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");
8561
+ 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
8562
  chmodSync2(bashScriptPath, 493);
8341
8563
  chmodSync2(bashFollowupScriptPath, 493);
8342
8564
  chmodSync2(editPrecheckScriptPath, 493);
@@ -8356,6 +8578,10 @@ function writeHookScripts() {
8356
8578
  chmodSync2(cursorAgentCapturePath, 493);
8357
8579
  chmodSync2(mcpStdioProxyPath, 493);
8358
8580
  chmodSync2(installExtractCorePath, 493);
8581
+ try {
8582
+ unlinkSync4(join8(HOOKS_DIR, "_synkro-stub-common.ts"));
8583
+ } catch {
8584
+ }
8359
8585
  return {
8360
8586
  bashScript: bashScriptPath,
8361
8587
  bashFollowupScript: bashFollowupScriptPath,
@@ -8403,7 +8629,7 @@ function writeConfigEnv(opts) {
8403
8629
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
8404
8630
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
8405
8631
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
8406
- `SYNKRO_VERSION=${shellQuoteSingle("1.6.46")}`
8632
+ `SYNKRO_VERSION=${shellQuoteSingle("1.6.47")}`
8407
8633
  ];
8408
8634
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
8409
8635
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
@@ -8417,6 +8643,7 @@ function writeConfigEnv(opts) {
8417
8643
  lines.push(`SYNKRO_DEPLOYMENT_MODE=${shellQuoteSingle(safeMode)}`);
8418
8644
  lines.push(`SYNKRO_GRADING_MODE=${shellQuoteSingle(sanitizeConfigValue(opts.gradingMode ?? "local", 16))}`);
8419
8645
  lines.push(`SYNKRO_STORAGE_MODE=${shellQuoteSingle(sanitizeConfigValue(opts.storageMode ?? "local", 16))}`);
8646
+ lines.push(`SYNKRO_HOOK_MODE=${shellQuoteSingle(sanitizeConfigValue(opts.hookMode ?? "full", 8))}`);
8420
8647
  lines.push("");
8421
8648
  writeFileSync7(CONFIG_PATH2, lines.join("\n"), "utf-8");
8422
8649
  chmodSync2(CONFIG_PATH2, 384);
@@ -8635,7 +8862,8 @@ async function installCommand(opts = {}) {
8635
8862
  }
8636
8863
  }
8637
8864
  ensureSynkroDir();
8638
- const scripts = writeHookScripts();
8865
+ const hookMode = opts.hookMode || resolvePersistedHookMode();
8866
+ const scripts = writeHookScripts(hookMode);
8639
8867
  console.log("Wrote hook scripts to ~/.synkro/hooks/\n");
8640
8868
  for (const mode of ["edit", "bash"]) {
8641
8869
  const pidFile = join8(SYNKRO_DIR4, "daemon", mode, "daemon.pid");
@@ -8806,7 +9034,7 @@ async function installCommand(opts = {}) {
8806
9034
  }
8807
9035
  const synkroBundle = resolveSynkroBundle();
8808
9036
  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 });
9037
+ writeConfigEnv({ gatewayUrl, userId, orgId, email, tier: profile.tier, inference: profile.inference, synkroBin: synkroBundle, transcriptConsent, localInference: profile.localInference, deploymentMode: persistedMode, gradingMode, storageMode, hookMode });
8810
9038
  console.log(`Wrote config to ${CONFIG_PATH2}`);
8811
9039
  console.log(` inference: ${profile.inference} (server-side grading)`);
8812
9040
  if (profile.localInference) console.log(` local inference: enabled (gradingProvider=claude-code)`);
@@ -9110,7 +9338,7 @@ function reconcileHarness() {
9110
9338
  const wantCC = sf.harness.includes("claude-code");
9111
9339
  const wantCursor = sf.harness.includes("cursor");
9112
9340
  console.log(`.synkro: harness=[${sf.harness.join(", ")}] pool=${sf.grader.pool} mode=${sf.grader.mode}`);
9113
- const scripts = writeHookScripts();
9341
+ const scripts = writeHookScripts(resolvePersistedHookMode());
9114
9342
  console.log("Wrote hook scripts to ~/.synkro/hooks/");
9115
9343
  const ccSettings = join8(homedir8(), ".claude", "settings.json");
9116
9344
  if (wantCC) {
@@ -9468,6 +9696,7 @@ var init_install = __esm({
9468
9696
  init_skillParser();
9469
9697
  init_hookScripts();
9470
9698
  init_hookScriptsTs();
9699
+ init_hookScriptsTs();
9471
9700
  init_stub();
9472
9701
  init_repoConnect();
9473
9702
  init_projects();
@@ -9528,7 +9757,7 @@ rl.on('line', async (line) => {
9528
9757
  });
9529
9758
 
9530
9759
  // 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";
9760
+ 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
9761
  import { join as join9 } from "path";
9533
9762
  import { homedir as homedir9 } from "os";
9534
9763
  import { spawnSync as spawnSync4 } from "child_process";
@@ -9609,7 +9838,7 @@ function safelyMutateClaudeJson(mutator) {
9609
9838
  renameSync4(tmpPath, CLAUDE_JSON_PATH);
9610
9839
  } catch (err) {
9611
9840
  try {
9612
- unlinkSync4(tmpPath);
9841
+ unlinkSync5(tmpPath);
9613
9842
  } catch {
9614
9843
  }
9615
9844
  try {
@@ -11488,7 +11717,7 @@ var args = process.argv.slice(2);
11488
11717
  var cmd = args[0] || "";
11489
11718
  var subArgs = args.slice(1);
11490
11719
  function printVersion() {
11491
- console.log("1.6.46");
11720
+ console.log("1.6.47");
11492
11721
  }
11493
11722
  function printHelp2() {
11494
11723
  console.log(`Synkro CLI \u2014 runtime safety for AI coding agents