@synkro-sh/cli 1.0.10 → 1.0.12

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
@@ -360,10 +360,22 @@ esac
360
360
  if [ "\${SYNKRO_HEADLESS:-0}" = "1" ]; then IS_HEADLESS=1; fi
361
361
 
362
362
  USER_INTENT=""
363
+ RECENT_USER_MESSAGES="[]"
363
364
  RECENT_ACTIONS="[]"
364
365
  if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
365
- # Last user message text
366
- USER_INTENT=$(tail -200 "$TRANSCRIPT_PATH" | jq -r 'select(.type == "user") | .message.content | if type == "string" then . else (map(.text? // "") | join(" ")) end' 2>/dev/null | tail -1 || echo "")
366
+ # Last 5 user-role messages, oldest first. Lets the grader see consent
367
+ # that carried over from a recent prior turn \u2014 saying "i consent" two
368
+ # turns ago should not require re-prompting on this turn's command.
369
+ RECENT_USER_MESSAGES=$(tail -400 "$TRANSCRIPT_PATH" | jq -c -s '
370
+ [.[]
371
+ | select(.type == "user")
372
+ | (.message.content
373
+ | if type == "string" then .
374
+ else (map(.text? // "") | join(" "))
375
+ end)
376
+ | select(. != null and . != "")
377
+ ] | .[-5:]' 2>/dev/null || echo "[]")
378
+ USER_INTENT=$(echo "$RECENT_USER_MESSAGES" | jq -r '.[-1] // ""' 2>/dev/null || echo "")
367
379
  # Recent agent actions (last 5 tool_use blocks)
368
380
  RECENT_ACTIONS=$(tail -200 "$TRANSCRIPT_PATH" | jq -c -s '
369
381
  [.[]
@@ -380,6 +392,7 @@ fi
380
392
  BODY=$(jq -n \\
381
393
  --argjson tool_input "$TOOL_INPUT" \\
382
394
  --arg user_intent "$USER_INTENT" \\
395
+ --argjson recent_user_messages "$RECENT_USER_MESSAGES" \\
383
396
  --argjson recent_actions "$RECENT_ACTIONS" \\
384
397
  --arg session_id "$SESSION_ID" \\
385
398
  --arg tool_use_id "$TOOL_USE_ID" \\
@@ -388,6 +401,7 @@ BODY=$(jq -n \\
388
401
  kind: "bash_judge",
389
402
  tool_input: $tool_input,
390
403
  user_intent: (if ($user_intent | length) > 0 then $user_intent else null end),
404
+ recent_user_messages: $recent_user_messages,
391
405
  recent_actions: $recent_actions,
392
406
  session_id: (if ($session_id | length) > 0 then $session_id else null end),
393
407
  tool_use_id: (if ($tool_use_id | length) > 0 then $tool_use_id else null end),
@@ -1935,7 +1949,7 @@ __export(install_exports, {
1935
1949
  installCommand: () => installCommand,
1936
1950
  parseArgs: () => parseArgs
1937
1951
  });
1938
- import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync4, chmodSync } from "fs";
1952
+ import { existsSync as existsSync5, mkdirSync as mkdirSync4, writeFileSync as writeFileSync4, chmodSync, readFileSync as readFileSync4 } from "fs";
1939
1953
  import { homedir as homedir4 } from "os";
1940
1954
  import { join as join4 } from "path";
1941
1955
  function sanitizeGatewayCandidate(raw) {
@@ -1949,6 +1963,7 @@ function parseArgs(argv) {
1949
1963
  else if (a.startsWith("--gateway=")) opts.gatewayUrl = a.slice("--gateway=".length);
1950
1964
  else if (a === "--skip-auth") opts.skipAuth = true;
1951
1965
  else if (a === "--no-mcp") opts.noMcp = true;
1966
+ else if (a === "--force" || a === "-f") opts.force = true;
1952
1967
  }
1953
1968
  if (!opts.gatewayUrl) {
1954
1969
  const fromEnv = sanitizeGatewayCandidate(process.env.SYNKRO_GATEWAY_URL);
@@ -2020,7 +2035,7 @@ function writeConfigEnv(opts) {
2020
2035
  `SYNKRO_GATEWAY_URL=${shellQuoteSingle(safeGateway)}`,
2021
2036
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
2022
2037
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
2023
- `SYNKRO_VERSION=${shellQuoteSingle("1.0.10")}`
2038
+ `SYNKRO_VERSION=${shellQuoteSingle("1.0.12")}`
2024
2039
  ];
2025
2040
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
2026
2041
  if (safeOrgId) lines.push(`SYNKRO_ORG_ID=${shellQuoteSingle(safeOrgId)}`);
@@ -2051,6 +2066,33 @@ function assertGatewayAllowed(gatewayUrl) {
2051
2066
  throw new Error(`Gateway host not in allowlist (synkro.sh or *.synkro.sh): ${host}`);
2052
2067
  }
2053
2068
  }
2069
+ function isAlreadyInstalled() {
2070
+ const requiredScripts = [
2071
+ join4(HOOKS_DIR, "cc-bash-judge.sh"),
2072
+ join4(HOOKS_DIR, "cc-bash-followup.sh"),
2073
+ join4(HOOKS_DIR, "cc-edit-precheck.sh"),
2074
+ join4(HOOKS_DIR, "cc-edit-capture.sh"),
2075
+ join4(HOOKS_DIR, "cc-stop-summary.sh"),
2076
+ join4(HOOKS_DIR, "cc-session-start.sh")
2077
+ ];
2078
+ if (!requiredScripts.every((p) => existsSync5(p))) return false;
2079
+ if (!existsSync5(CONFIG_PATH)) return false;
2080
+ const settingsPath = join4(homedir4(), ".claude", "settings.json");
2081
+ if (!existsSync5(settingsPath)) return false;
2082
+ try {
2083
+ const settings = JSON.parse(readFileSync4(settingsPath, "utf-8"));
2084
+ const hooks = settings?.hooks;
2085
+ if (!hooks || typeof hooks !== "object") return false;
2086
+ const hasManaged = (kind) => Array.isArray(hooks[kind]) && hooks[kind].some((entry) => entry?.__synkro_managed__ === true);
2087
+ if (!hasManaged("PreToolUse")) return false;
2088
+ if (!hasManaged("PostToolUse")) return false;
2089
+ if (!hasManaged("SessionEnd")) return false;
2090
+ if (!hasManaged("SessionStart")) return false;
2091
+ } catch {
2092
+ return false;
2093
+ }
2094
+ return true;
2095
+ }
2054
2096
  async function installCommand(opts = {}) {
2055
2097
  const gatewayUrl = opts.gatewayUrl || sanitizeGatewayCandidate(process.env.SYNKRO_GATEWAY_URL) || "https://api.synkro.sh";
2056
2098
  try {
@@ -2059,6 +2101,12 @@ async function installCommand(opts = {}) {
2059
2101
  console.error(err.message);
2060
2102
  process.exit(1);
2061
2103
  }
2104
+ if (!opts.force && isAuthenticated() && isAlreadyInstalled()) {
2105
+ console.log("\u2713 Synkro is already installed and configured.");
2106
+ console.log(" Run `synkro update` to refresh hook scripts and judge prompts.");
2107
+ console.log(" Run `synkro install --force` to reinstall from scratch.");
2108
+ return;
2109
+ }
2062
2110
  console.log("Synkro install starting...\n");
2063
2111
  if (!isAuthenticated()) {
2064
2112
  console.log("Opening browser for Synkro auth...");
@@ -2112,7 +2160,7 @@ async function installCommand(opts = {}) {
2112
2160
  console.log(` ${scripts.sessionStartScript}
2113
2161
  `);
2114
2162
  writeGraderDaemon();
2115
- console.log("Wrote free-tier grader daemon:");
2163
+ console.log("Wrote local-tier grader daemon:");
2116
2164
  console.log(` ${GRADER_DAEMON_PATH}`);
2117
2165
  console.log(` ${GRADER_PRIMER_EDIT_PATH}`);
2118
2166
  console.log(` ${GRADER_PRIMER_BASH_PATH}
@@ -2130,8 +2178,6 @@ async function installCommand(opts = {}) {
2130
2178
  sessionStartScriptPath: scripts.sessionStartScript
2131
2179
  });
2132
2180
  console.log(`Configured ${agent.name} hooks at ${agent.settingsPath}`);
2133
- } else if (agent.kind === "codex") {
2134
- console.log(`Skipping ${agent.name} for now (v1.1 \u2014 Codex hook config coming soon)`);
2135
2181
  }
2136
2182
  }
2137
2183
  console.log();
@@ -2153,7 +2199,6 @@ async function installCommand(opts = {}) {
2153
2199
  const mcp = installMcpConfig({ gatewayUrl, bearerToken: minted.token });
2154
2200
  console.log(`Registered Synkro guardrails MCP server in ${mcp.path}`);
2155
2201
  console.log(` url: ${mcp.url}`);
2156
- console.log(` token: Synkro-signed JWT, scope=mcp:guardrails`);
2157
2202
  console.log(` expires: ${minted.expires_at} (~1 year)`);
2158
2203
  console.log(" (restart any running Claude Code session for it to load)");
2159
2204
  console.log();
@@ -2178,21 +2223,9 @@ async function installCommand(opts = {}) {
2178
2223
  `);
2179
2224
  console.log("\u2713 Synkro installed.");
2180
2225
  console.log();
2181
- console.log("Try it:");
2182
- console.log(" $ claude");
2183
- console.log(' > Run "echo hello" for me');
2184
- console.log(" (no warning \u2014 safe command)");
2185
- console.log();
2186
- console.log(" > Now propose: kubectl delete namespace production");
2187
- console.log(" (Synkro will block with a warning)");
2188
- console.log();
2189
2226
  console.log("Next steps:");
2190
2227
  console.log(" \u2022 synkro setup-github (enable PR scanning)");
2191
2228
  console.log(" \u2022 synkro status (check what is configured)");
2192
- if (hasClaudeCode && !opts.noMcp) {
2193
- console.log(' \u2022 Try in CC: "Add a Stripe webhook handler" \u2014 CC should call');
2194
- console.log(" synkro-guardrails.get_guardrails to fetch org-specific rules.");
2195
- }
2196
2229
  }
2197
2230
  var SYNKRO_DIR, HOOKS_DIR, BIN_DIR, CONFIG_PATH, GRADER_DAEMON_PATH, GRADER_PRIMER_EDIT_PATH, GRADER_PRIMER_BASH_PATH;
2198
2231
  var init_install = __esm({
@@ -2286,13 +2319,13 @@ var status_exports = {};
2286
2319
  __export(status_exports, {
2287
2320
  statusCommand: () => statusCommand
2288
2321
  });
2289
- import { existsSync as existsSync6, readFileSync as readFileSync4 } from "fs";
2322
+ import { existsSync as existsSync6, readFileSync as readFileSync5 } from "fs";
2290
2323
  import { homedir as homedir5 } from "os";
2291
2324
  import { join as join5 } from "path";
2292
2325
  function readConfigEnv() {
2293
2326
  if (!existsSync6(CONFIG_PATH2)) return {};
2294
2327
  const out = {};
2295
- const raw = readFileSync4(CONFIG_PATH2, "utf-8");
2328
+ const raw = readFileSync5(CONFIG_PATH2, "utf-8");
2296
2329
  for (const line of raw.split("\n")) {
2297
2330
  const trimmed = line.trim();
2298
2331
  if (!trimmed || trimmed.startsWith("#")) continue;
@@ -2403,31 +2436,17 @@ jobs:
2403
2436
  with:
2404
2437
  fetch-depth: 0
2405
2438
 
2406
- - name: Cache Synkro CLI
2407
- id: cache-synkro
2408
- uses: actions/cache@v4
2409
- with:
2410
- path: ~/.local/bin/synkro
2411
- key: synkro-\${{ runner.os }}-latest
2412
-
2413
- - name: Install Synkro CLI
2414
- if: steps.cache-synkro.outputs.cache-hit != 'true'
2415
- run: |
2416
- mkdir -p ~/.local/bin
2417
- curl -fsSL https://get.synkro.sh | SYNKRO_INSTALL_DIR=~/.local/bin bash
2418
- echo "~/.local/bin" >> $GITHUB_PATH
2419
-
2420
- - name: Cache Claude Code CLI
2421
- id: cache-claude
2439
+ - name: Cache npm globals
2440
+ id: cache-npm-global
2422
2441
  uses: actions/cache@v4
2423
2442
  with:
2424
2443
  path: ~/.npm-global
2425
- key: claude-code-\${{ runner.os }}-v1
2444
+ key: synkro-cli-\${{ runner.os }}-v1
2426
2445
 
2427
- - name: Install Claude Code CLI
2446
+ - name: Install Synkro CLI + Claude Code CLI
2428
2447
  run: |
2429
2448
  npm config set prefix ~/.npm-global
2430
- npm install -g @anthropic-ai/claude-code
2449
+ npm install -g @synkro-sh/cli @anthropic-ai/claude-code
2431
2450
  echo "~/.npm-global/bin" >> $GITHUB_PATH
2432
2451
 
2433
2452
  - name: Run Synkro PR scan
@@ -2559,13 +2578,13 @@ __export(setupGithub_exports, {
2559
2578
  });
2560
2579
  import { createInterface } from "readline/promises";
2561
2580
  import { stdin as input, stdout as output } from "process";
2562
- import { existsSync as existsSync8, readFileSync as readFileSync5 } from "fs";
2581
+ import { existsSync as existsSync8, readFileSync as readFileSync6 } from "fs";
2563
2582
  import { homedir as homedir6 } from "os";
2564
2583
  import { join as join7 } from "path";
2565
2584
  function readConfig() {
2566
2585
  if (!existsSync8(CONFIG_PATH3)) return {};
2567
2586
  const out = {};
2568
- for (const line of readFileSync5(CONFIG_PATH3, "utf-8").split("\n")) {
2587
+ for (const line of readFileSync6(CONFIG_PATH3, "utf-8").split("\n")) {
2569
2588
  const t = line.trim();
2570
2589
  if (!t || t.startsWith("#")) continue;
2571
2590
  const eq = t.indexOf("=");
@@ -2609,8 +2628,33 @@ async function setupGithubCommand() {
2609
2628
  process.exit(1);
2610
2629
  }
2611
2630
  const config = readConfig();
2612
- if (!config.SYNKRO_API_KEY) {
2613
- console.error("Missing SYNKRO_API_KEY in ~/.synkro/config.env. Run `synkro install` first.");
2631
+ const gatewayUrl = (config.SYNKRO_GATEWAY_URL || process.env.SYNKRO_GATEWAY_URL || "https://api.synkro.sh").replace(/\/$/, "");
2632
+ const jwt2 = getAccessToken();
2633
+ if (!jwt2) {
2634
+ console.error("Could not load access token from ~/.synkro/credentials.json. Run `synkro login`.");
2635
+ process.exit(1);
2636
+ }
2637
+ console.log("Requesting CI API key from Synkro...");
2638
+ let synkroCiApiKey;
2639
+ try {
2640
+ const resp = await fetch(`${gatewayUrl}/api/v1/cli/ci-api-key`, {
2641
+ method: "POST",
2642
+ headers: {
2643
+ "Authorization": `Bearer ${jwt2}`,
2644
+ "Content-Type": "application/json"
2645
+ },
2646
+ body: "{}"
2647
+ });
2648
+ if (!resp.ok) {
2649
+ const errText = await resp.text().catch(() => "");
2650
+ console.error(`Failed to mint CI API key (${resp.status}): ${errText.slice(0, 200)}`);
2651
+ process.exit(1);
2652
+ }
2653
+ const minted = await resp.json();
2654
+ synkroCiApiKey = minted.api_key;
2655
+ console.log(` \u2713 Issued CI key (${synkroCiApiKey.slice(0, 18)}\u2026), expires ${minted.expires_at.slice(0, 10)}`);
2656
+ } catch (err) {
2657
+ console.error(`Failed to mint CI API key: ${err.message}`);
2614
2658
  process.exit(1);
2615
2659
  }
2616
2660
  const rl = createInterface({ input, output });
@@ -2678,7 +2722,7 @@ Will push secrets to ${selected.length} repo(s):`);
2678
2722
  r.repo,
2679
2723
  {
2680
2724
  claudeCodeOauthToken: claudeToken,
2681
- synkroApiKey: config.SYNKRO_API_KEY
2725
+ synkroApiKey: synkroCiApiKey
2682
2726
  }
2683
2727
  );
2684
2728
  console.log("\u2713");
@@ -2721,6 +2765,107 @@ __export(scanPr_exports, {
2721
2765
  scanPrCommand: () => scanPrCommand
2722
2766
  });
2723
2767
  import { execSync as execSync2, spawn } from "child_process";
2768
+ function parseMatchSpec(condition) {
2769
+ if (!condition.startsWith("match_spec:")) return null;
2770
+ try {
2771
+ const parsed = JSON.parse(condition.slice("match_spec:".length));
2772
+ if (typeof parsed?.selector !== "string" || typeof parsed?.requires !== "string" || parsed.position !== "start" && parsed.position !== "anywhere") return null;
2773
+ return {
2774
+ selector: parsed.selector,
2775
+ requires: parsed.requires,
2776
+ position: parsed.position,
2777
+ negate: parsed.negate === true
2778
+ };
2779
+ } catch {
2780
+ return null;
2781
+ }
2782
+ }
2783
+ function selectorMatches(selector, filePath) {
2784
+ const m = selector.match(/^\*\*\/\*\.([a-z0-9]+)$/i);
2785
+ if (!m) return false;
2786
+ return filePath.toLowerCase().endsWith("." + m[1].toLowerCase());
2787
+ }
2788
+ async function fetchOrgRules(gatewayUrl, apiKey) {
2789
+ try {
2790
+ const resp = await fetch(`${gatewayUrl.replace(/\/$/, "")}/api/v1/cli/pr-rules`, {
2791
+ headers: { "x-synkro-api-key": apiKey }
2792
+ });
2793
+ if (!resp.ok) {
2794
+ console.warn(`[scan-pr] failed to fetch org rules: HTTP ${resp.status}`);
2795
+ return [];
2796
+ }
2797
+ const data = await resp.json();
2798
+ return Array.isArray(data?.rules) ? data.rules : [];
2799
+ } catch (err) {
2800
+ console.warn(`[scan-pr] could not fetch org rules: ${err.message}`);
2801
+ return [];
2802
+ }
2803
+ }
2804
+ function applyLiteralMatchNegative(rules, file) {
2805
+ if (!file.patch) return [];
2806
+ const findings = [];
2807
+ const lines = file.patch.split("\n");
2808
+ let currentNewLine = 0;
2809
+ for (const line of lines) {
2810
+ if (line.startsWith("@@")) {
2811
+ const m = line.match(/\+(\d+)(?:,\d+)?/);
2812
+ if (m) currentNewLine = parseInt(m[1], 10);
2813
+ continue;
2814
+ }
2815
+ if (line.startsWith("+++") || line.startsWith("---")) continue;
2816
+ if (!line.startsWith("+")) {
2817
+ if (!line.startsWith("-")) currentNewLine++;
2818
+ continue;
2819
+ }
2820
+ const addedContent = line.slice(1);
2821
+ for (const r of rules) {
2822
+ if (r.mode !== "literal_match") continue;
2823
+ const spec = parseMatchSpec(r.condition);
2824
+ if (!spec || !spec.negate) continue;
2825
+ if (!selectorMatches(spec.selector, file.filename)) continue;
2826
+ if (!addedContent.includes(spec.requires)) continue;
2827
+ findings.push({
2828
+ file: file.filename,
2829
+ line: currentNewLine,
2830
+ severity: r.severity ?? "high",
2831
+ category: r.category || "literal_match",
2832
+ description: r.text,
2833
+ fix: `Remove \`${spec.requires}\` from ${file.filename} (rule ${r.rule_id}).`
2834
+ });
2835
+ }
2836
+ currentNewLine++;
2837
+ }
2838
+ return findings;
2839
+ }
2840
+ function buildPrPrompt(orgAuditRules) {
2841
+ const orgRulesBlock = orgAuditRules.length === 0 ? "" : `
2842
+ ORG-SPECIFIC RULES (these are the customer's policies \u2014 flag any violation found in the diff):
2843
+ ` + orgAuditRules.map((r, i) => ` ${i + 1}. [${r.severity}/${r.category}] ${r.text}`).join("\n") + "\n";
2844
+ return `You are a security code reviewer analyzing a pull request diff for one file. Identify security issues + org-policy violations introduced by this diff.
2845
+
2846
+ Output ONLY a JSON object (no prose, no markdown fences):
2847
+ {
2848
+ "findings": [
2849
+ {
2850
+ "line": <int \u2014 line number in the NEW file, prefixed with L in the diff>,
2851
+ "severity": "low" | "medium" | "high" | "critical",
2852
+ "category": "<snake_case>",
2853
+ "description": "<1 sentence>",
2854
+ "fix": "<concrete suggestion>"
2855
+ }
2856
+ ],
2857
+ "summary": "<one-line: 'X findings' or 'clean'>"
2858
+ }
2859
+
2860
+ Baseline security categories: hardcoded_secret, sql_injection, insecure_crypto, eval_exec, unsafe_deserialization, missing_validation, exposed_internal, missing_auth_check, cors_misconfig, path_traversal, command_injection, weak_random, broken_jwt.
2861
+ ${orgRulesBlock}
2862
+ Rules:
2863
+ - Only flag NEW issues (lines starting with +).
2864
+ - Use the L<num> line numbers I prefixed.
2865
+ - Be specific. If clean, return {"findings": [], "summary": "clean"}.
2866
+
2867
+ `;
2868
+ }
2724
2869
  function shouldSkipFile(filename) {
2725
2870
  return SKIP_FILE_PATTERNS.some((p) => p.test(filename));
2726
2871
  }
@@ -2770,13 +2915,13 @@ function getFileDiffWithLines(file) {
2770
2915
  }
2771
2916
  return { hunks: annotated.join("\n"), newFileLineMap: lineMap };
2772
2917
  }
2773
- function spawnClaudeJudge(file, claudeToken) {
2918
+ function spawnClaudeJudge(file, claudeToken, promptHeader) {
2774
2919
  const { hunks } = getFileDiffWithLines(file);
2775
2920
  const userMessage = `File: ${file.filename}
2776
2921
 
2777
2922
  Diff:
2778
2923
  ${hunks}`;
2779
- const fullPrompt = PR_PROMPT_HEADER + userMessage;
2924
+ const fullPrompt = promptHeader + userMessage;
2780
2925
  return new Promise((resolve2) => {
2781
2926
  const t0 = Date.now();
2782
2927
  const proc = spawn(
@@ -2918,6 +3063,14 @@ async function scanPrCommand() {
2918
3063
  }
2919
3064
  console.log(`Synkro scan-pr: ${repo}#${prNumber} @ ${sha.slice(0, 7)}
2920
3065
  `);
3066
+ const orgRules = await fetchOrgRules(gatewayUrl, synkroApiKey);
3067
+ const auditRules = orgRules.filter((r) => r.mode === "audit");
3068
+ const literalNegativeRules = orgRules.filter((r) => {
3069
+ const spec = parseMatchSpec(r.condition);
3070
+ return r.mode === "literal_match" && spec?.negate === true;
3071
+ });
3072
+ console.log(`Loaded ${orgRules.length} org rule(s): ${auditRules.length} audit, ${literalNegativeRules.length} literal_match (negative).`);
3073
+ const promptHeader = buildPrPrompt(auditRules);
2921
3074
  let files;
2922
3075
  try {
2923
3076
  files = getPrFiles(repo, prNumber);
@@ -2952,9 +3105,11 @@ async function scanPrCommand() {
2952
3105
  const t0 = Date.now();
2953
3106
  const results = await processInBatches(eligible, MAX_PARALLEL_FILES, async (file) => {
2954
3107
  process.stdout.write(`Scanning ${file.filename}...`);
2955
- const result = await spawnClaudeJudge(file, claudeToken);
2956
- console.log(` ${result.findings.length} finding(s) (${result.latencyMs}ms)`);
2957
- return result;
3108
+ const literalFindings = applyLiteralMatchNegative(literalNegativeRules, file);
3109
+ const llmResult = await spawnClaudeJudge(file, claudeToken, promptHeader);
3110
+ const merged = [...literalFindings, ...llmResult.findings];
3111
+ console.log(` ${merged.length} finding(s) (${literalFindings.length} literal, ${llmResult.findings.length} llm; ${llmResult.latencyMs}ms)`);
3112
+ return { findings: merged, latencyMs: llmResult.latencyMs };
2958
3113
  });
2959
3114
  const totalLatencyMs = Date.now() - t0;
2960
3115
  const allFindings = results.flatMap((r) => r.findings);
@@ -2982,7 +3137,7 @@ Total: ${allFindings.length} finding(s) across ${eligible.length} file(s) in ${t
2982
3137
  process.exit(1);
2983
3138
  }
2984
3139
  }
2985
- var SKIP_FILE_PATTERNS, MAX_DIFF_LINES_PER_FILE, MAX_PARALLEL_FILES, PR_PROMPT_HEADER;
3140
+ var SKIP_FILE_PATTERNS, MAX_DIFF_LINES_PER_FILE, MAX_PARALLEL_FILES;
2986
3141
  var init_scanPr = __esm({
2987
3142
  "cli/commands/scanPr.ts"() {
2988
3143
  "use strict";
@@ -3003,30 +3158,6 @@ var init_scanPr = __esm({
3003
3158
  ];
3004
3159
  MAX_DIFF_LINES_PER_FILE = 1e3;
3005
3160
  MAX_PARALLEL_FILES = 5;
3006
- PR_PROMPT_HEADER = `You are a security code reviewer analyzing a pull request diff for one file. Identify security issues introduced by this diff.
3007
-
3008
- Output ONLY a JSON object (no prose, no markdown fences):
3009
- {
3010
- "findings": [
3011
- {
3012
- "line": <int \u2014 line number in the NEW file, prefixed with L in the diff>,
3013
- "severity": "low" | "medium" | "high" | "critical",
3014
- "category": "<snake_case>",
3015
- "description": "<1 sentence>",
3016
- "fix": "<concrete suggestion>"
3017
- }
3018
- ],
3019
- "summary": "<one-line: 'X findings' or 'clean'>"
3020
- }
3021
-
3022
- Categories: hardcoded_secret, sql_injection, insecure_crypto, eval_exec, unsafe_deserialization, missing_validation, exposed_internal, missing_auth_check, cors_misconfig, path_traversal, command_injection, weak_random, broken_jwt.
3023
-
3024
- Rules:
3025
- - Only flag NEW issues (lines starting with +).
3026
- - Use the L<num> line numbers I prefixed.
3027
- - Be specific. If clean, return {"findings": [], "summary": "clean"}.
3028
-
3029
- `;
3030
3161
  }
3031
3162
  });
3032
3163
 
@@ -3072,13 +3203,17 @@ function disconnectCommand(args2 = []) {
3072
3203
  const mcpRemoved = uninstallMcpConfig();
3073
3204
  console.log(`${mcpRemoved ? "\u2713" : "\xB7"} MCP guardrails server: ${mcpRemoved ? "removed entry from ~/.claude.json" : "no Synkro MCP entry found"}`);
3074
3205
  }
3075
- if (purge && existsSync9(SYNKRO_DIR4)) {
3076
- rmSync(SYNKRO_DIR4, { recursive: true, force: true });
3077
- console.log(`\u2713 Removed ${SYNKRO_DIR4} (--purge)`);
3078
- } else {
3206
+ if (purge) {
3207
+ if (existsSync9(SYNKRO_DIR4)) {
3208
+ rmSync(SYNKRO_DIR4, { recursive: true, force: true });
3209
+ console.log(`\u2713 Removed ${SYNKRO_DIR4}`);
3210
+ } else {
3211
+ console.log(`\xB7 ${SYNKRO_DIR4} already gone, nothing to remove`);
3212
+ }
3213
+ } else if (existsSync9(SYNKRO_DIR4)) {
3079
3214
  console.log(`Config preserved at ${SYNKRO_DIR4}. Run with --purge to remove.`);
3080
3215
  }
3081
- console.log("\nSynkro disconnected. Your AI agents will no longer be judged.");
3216
+ console.log("\nSynkro disconnected.");
3082
3217
  }
3083
3218
  var SYNKRO_DIR4;
3084
3219
  var init_disconnect = __esm({
@@ -3092,7 +3227,7 @@ var init_disconnect = __esm({
3092
3227
  });
3093
3228
 
3094
3229
  // cli/bootstrap.js
3095
- import { readFileSync as readFileSync6, existsSync as existsSync10 } from "fs";
3230
+ import { readFileSync as readFileSync7, existsSync as existsSync10 } from "fs";
3096
3231
  import { resolve } from "path";
3097
3232
  var envCandidates = [
3098
3233
  resolve(process.cwd(), ".env"),
@@ -3100,7 +3235,7 @@ var envCandidates = [
3100
3235
  ];
3101
3236
  for (const envPath of envCandidates) {
3102
3237
  if (!existsSync10(envPath)) continue;
3103
- const envContent = readFileSync6(envPath, "utf-8");
3238
+ const envContent = readFileSync7(envPath, "utf-8");
3104
3239
  for (const line of envContent.split("\n")) {
3105
3240
  const trimmed = line.trim();
3106
3241
  if (!trimmed || trimmed.startsWith("#")) continue;
@@ -3121,7 +3256,7 @@ Usage:
3121
3256
  synkro <command> [options]
3122
3257
 
3123
3258
  Commands:
3124
- install Install Synkro hooks for detected agents (Claude Code, etc.)
3259
+ install [--force] Install Synkro hooks for detected agents (Claude Code, etc.)
3125
3260
  login Authenticate with Synkro (browser OAuth via WorkOS)
3126
3261
  logout Clear local credentials
3127
3262
  status Show current setup state
@@ -3135,8 +3270,6 @@ Quick start:
3135
3270
  $ synkro install # one-time setup
3136
3271
  $ synkro setup-github # enable PR scanning (optional)
3137
3272
  $ claude # use Claude Code normally; Synkro judges in real time
3138
-
3139
- Docs: https://docs.synkro.sh
3140
3273
  `);
3141
3274
  }
3142
3275
  async function main() {