@synkro-sh/cli 1.3.16 → 1.3.18

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
@@ -102,7 +102,7 @@ function removeSynkroEntries(events, eventName) {
102
102
  if (!Array.isArray(arr)) return;
103
103
  events[eventName] = arr.filter((entry) => !isSynkroEntry(entry));
104
104
  }
105
- function installCCHooks(settingsPath, config2) {
105
+ function installCCHooks(settingsPath, config) {
106
106
  const settings = readSettings(settingsPath);
107
107
  settings.hooks = settings.hooks ?? {};
108
108
  removeSynkroEntries(settings.hooks, "PreToolUse");
@@ -119,7 +119,7 @@ function installCCHooks(settingsPath, config2) {
119
119
  hooks: [
120
120
  {
121
121
  type: "command",
122
- command: config2.bashJudgeScriptPath,
122
+ command: config.bashJudgeScriptPath,
123
123
  timeout: 15
124
124
  }
125
125
  ],
@@ -130,7 +130,7 @@ function installCCHooks(settingsPath, config2) {
130
130
  hooks: [
131
131
  {
132
132
  type: "command",
133
- command: config2.editPrecheckScriptPath,
133
+ command: config.editPrecheckScriptPath,
134
134
  timeout: 15
135
135
  }
136
136
  ],
@@ -141,7 +141,7 @@ function installCCHooks(settingsPath, config2) {
141
141
  hooks: [
142
142
  {
143
143
  type: "command",
144
- command: config2.editCaptureScriptPath,
144
+ command: config.editCaptureScriptPath,
145
145
  timeout: 20
146
146
  }
147
147
  ],
@@ -152,7 +152,7 @@ function installCCHooks(settingsPath, config2) {
152
152
  hooks: [
153
153
  {
154
154
  type: "command",
155
- command: config2.bashFollowupScriptPath
155
+ command: config.bashFollowupScriptPath
156
156
  }
157
157
  ],
158
158
  [SYNKRO_MARKER]: true
@@ -161,7 +161,7 @@ function installCCHooks(settingsPath, config2) {
161
161
  hooks: [
162
162
  {
163
163
  type: "command",
164
- command: config2.stopSummaryScriptPath
164
+ command: config.stopSummaryScriptPath
165
165
  }
166
166
  ],
167
167
  [SYNKRO_MARKER]: true
@@ -170,23 +170,25 @@ function installCCHooks(settingsPath, config2) {
170
170
  hooks: [
171
171
  {
172
172
  type: "command",
173
- command: config2.sessionStartScriptPath
174
- }
175
- ],
176
- [SYNKRO_MARKER]: true
177
- });
178
- settings.hooks.Stop = settings.hooks.Stop ?? [];
179
- removeSynkroEntries(settings.hooks, "Stop");
180
- settings.hooks.Stop.push({
181
- hooks: [
182
- {
183
- type: "command",
184
- command: config2.transcriptSyncScriptPath,
185
- timeout: 3
173
+ command: config.sessionStartScriptPath
186
174
  }
187
175
  ],
188
176
  [SYNKRO_MARKER]: true
189
177
  });
178
+ if (!config.skipTranscriptSync) {
179
+ settings.hooks.Stop = settings.hooks.Stop ?? [];
180
+ removeSynkroEntries(settings.hooks, "Stop");
181
+ settings.hooks.Stop.push({
182
+ hooks: [
183
+ {
184
+ type: "command",
185
+ command: config.transcriptSyncScriptPath,
186
+ timeout: 3
187
+ }
188
+ ],
189
+ [SYNKRO_MARKER]: true
190
+ });
191
+ }
190
192
  writeSettingsAtomic(settingsPath, settings);
191
193
  }
192
194
  function uninstallCCHooks(settingsPath) {
@@ -250,50 +252,50 @@ function readClaudeJson() {
250
252
  throw new Error(`Failed to parse ${CC_CONFIG_PATH}: ${err.message}`);
251
253
  }
252
254
  }
253
- function writeClaudeJsonAtomic(config2) {
255
+ function writeClaudeJsonAtomic(config) {
254
256
  mkdirSync2(dirname2(CC_CONFIG_PATH), { recursive: true });
255
257
  const tmpPath = `${CC_CONFIG_PATH}.synkro.tmp`;
256
- writeFileSync2(tmpPath, JSON.stringify(config2, null, 2) + "\n", "utf-8");
258
+ writeFileSync2(tmpPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
257
259
  renameSync2(tmpPath, CC_CONFIG_PATH);
258
260
  }
259
261
  function installMcpConfig(opts) {
260
- const config2 = readClaudeJson();
261
- config2.mcpServers = config2.mcpServers ?? {};
262
- for (const [name, entry] of Object.entries(config2.mcpServers)) {
263
- if (entry?.[SYNKRO_MARKER2] === true) delete config2.mcpServers[name];
262
+ const config = readClaudeJson();
263
+ config.mcpServers = config.mcpServers ?? {};
264
+ for (const [name, entry] of Object.entries(config.mcpServers)) {
265
+ if (entry?.[SYNKRO_MARKER2] === true) delete config.mcpServers[name];
264
266
  }
265
267
  const url = `${opts.gatewayUrl.replace(/\/$/, "")}/api/v1/mcp/guardrails`;
266
- config2.mcpServers[SYNKRO_SERVER_NAME] = {
268
+ config.mcpServers[SYNKRO_SERVER_NAME] = {
267
269
  type: "http",
268
270
  url,
269
271
  headers: { Authorization: `Bearer ${opts.bearerToken}` },
270
272
  [SYNKRO_MARKER2]: true
271
273
  };
272
- writeClaudeJsonAtomic(config2);
274
+ writeClaudeJsonAtomic(config);
273
275
  return { path: CC_CONFIG_PATH, url };
274
276
  }
275
277
  function uninstallMcpConfig() {
276
278
  if (!existsSync3(CC_CONFIG_PATH)) return false;
277
- const config2 = readClaudeJson();
278
- if (!config2.mcpServers || Object.keys(config2.mcpServers).length === 0) return false;
279
+ const config = readClaudeJson();
280
+ if (!config.mcpServers || Object.keys(config.mcpServers).length === 0) return false;
279
281
  let removed = false;
280
- for (const [name, entry] of Object.entries(config2.mcpServers)) {
282
+ for (const [name, entry] of Object.entries(config.mcpServers)) {
281
283
  if (entry?.[SYNKRO_MARKER2] === true) {
282
- delete config2.mcpServers[name];
284
+ delete config.mcpServers[name];
283
285
  removed = true;
284
286
  }
285
287
  }
286
288
  if (!removed) return false;
287
- if (Object.keys(config2.mcpServers).length === 0) delete config2.mcpServers;
288
- writeClaudeJsonAtomic(config2);
289
+ if (Object.keys(config.mcpServers).length === 0) delete config.mcpServers;
290
+ writeClaudeJsonAtomic(config);
289
291
  return true;
290
292
  }
291
293
  function inspectMcpConfig() {
292
294
  if (!existsSync3(CC_CONFIG_PATH)) {
293
295
  return { installed: false, configPath: CC_CONFIG_PATH };
294
296
  }
295
- const config2 = readClaudeJson();
296
- const entry = config2.mcpServers?.[SYNKRO_SERVER_NAME];
297
+ const config = readClaudeJson();
298
+ const entry = config.mcpServers?.[SYNKRO_SERVER_NAME];
297
299
  if (!entry || entry[SYNKRO_MARKER2] !== true) {
298
300
  return { installed: false, configPath: CC_CONFIG_PATH };
299
301
  }
@@ -1535,6 +1537,7 @@ GATEWAY_URL="\${SYNKRO_GATEWAY_URL:-https://api.synkro.sh}"
1535
1537
  CREDS_PATH="\${SYNKRO_CREDENTIALS_PATH:-$HOME/.synkro/credentials.json}"
1536
1538
 
1537
1539
  if [ ! -f "$CREDS_PATH" ]; then echo '{}'; exit 0; fi
1540
+ if [ "\${SYNKRO_TRANSCRIPT_CONSENT:-yes}" = "no" ]; then echo '{}'; exit 0; fi
1538
1541
  JWT=$(jq -r '.access_token // empty' "$CREDS_PATH" 2>/dev/null)
1539
1542
  if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
1540
1543
 
@@ -2437,10 +2440,12 @@ var init_auth = __esm({
2437
2440
  });
2438
2441
 
2439
2442
  // cli/api/projects.ts
2440
- import { config } from "dotenv";
2443
+ function setApiBaseUrl(url) {
2444
+ API_URL = url;
2445
+ }
2441
2446
  async function callApi(method, endpoint, body) {
2442
2447
  if (!API_URL) {
2443
- throw new Error("SYNKRO_CRUD_URL (or SYNKRO_API_URL) is not set. Add it to your .env file.");
2448
+ throw new Error("API URL not configured. Run `synkro install` first.");
2444
2449
  }
2445
2450
  const url = `${API_URL}${endpoint}`;
2446
2451
  const accessToken = getAccessToken();
@@ -2477,8 +2482,7 @@ var init_projects = __esm({
2477
2482
  "cli/api/projects.ts"() {
2478
2483
  "use strict";
2479
2484
  init_auth();
2480
- config({ quiet: true });
2481
- API_URL = process.env.SYNKRO_CRUD_URL || process.env.SYNKRO_API_URL;
2485
+ API_URL = "https://api.synkro.sh/api";
2482
2486
  }
2483
2487
  });
2484
2488
 
@@ -2606,7 +2610,9 @@ async function listAccessibleRepos(opts) {
2606
2610
  }
2607
2611
  async function pushSecretsToRepo(opts, owner, repo, secrets) {
2608
2612
  const pubkey = await getRepoPublicKey(opts, owner, repo);
2609
- await putRepoSecret(opts, owner, repo, "CLAUDE_CODE_OAUTH_TOKEN", secrets.claudeCodeOauthToken, pubkey);
2613
+ if (secrets.claudeCodeOauthToken) {
2614
+ await putRepoSecret(opts, owner, repo, "CLAUDE_CODE_OAUTH_TOKEN", secrets.claudeCodeOauthToken, pubkey);
2615
+ }
2610
2616
  await putRepoSecret(opts, owner, repo, "SYNKRO_API_KEY", secrets.synkroApiKey, pubkey);
2611
2617
  }
2612
2618
  function writeWorkflowFile(repoRootPath) {
@@ -2750,8 +2756,27 @@ async function connectGithubAndSelectRepos() {
2750
2756
  rl.close();
2751
2757
  }
2752
2758
  }
2753
- async function promptRepoConnection() {
2759
+ async function promptRepoConnection(opts) {
2754
2760
  const localRepo = detectGitRepo();
2761
+ if (opts?.linkRepo && localRepo) {
2762
+ console.log("Connect repos to Synkro:\n");
2763
+ try {
2764
+ const existing = await listProjects();
2765
+ const alreadyLinked = existing.some(
2766
+ (p) => p.repos?.some((r) => r.full_name === localRepo.fullName)
2767
+ );
2768
+ if (!alreadyLinked) {
2769
+ await createProject(localRepo.shortName, [{ full_name: localRepo.fullName }]);
2770
+ console.log(` \u2713 Created project "${localRepo.shortName}" linked to ${localRepo.fullName}`);
2771
+ } else {
2772
+ console.log(` \u2713 ${localRepo.fullName} is already linked to a Synkro project.`);
2773
+ }
2774
+ } catch (err) {
2775
+ console.warn(` \u26A0 Could not link repo: ${err.message}`);
2776
+ }
2777
+ console.log();
2778
+ return;
2779
+ }
2755
2780
  const rl = createInterface({ input: process.stdin, output: process.stdout });
2756
2781
  try {
2757
2782
  console.log("Connect repos to Synkro:\n");
@@ -2829,16 +2854,282 @@ var init_repoConnect = __esm({
2829
2854
  }
2830
2855
  });
2831
2856
 
2857
+ // cli/commands/setupGithub.ts
2858
+ var setupGithub_exports = {};
2859
+ __export(setupGithub_exports, {
2860
+ setupGithubCommand: () => setupGithubCommand
2861
+ });
2862
+ import { createInterface as createInterface2 } from "readline/promises";
2863
+ import { stdin as input, stdout as output } from "process";
2864
+ import { execSync as execSync3, spawn as nodeSpawn } from "child_process";
2865
+ import { existsSync as existsSync6, readFileSync as readFileSync4 } from "fs";
2866
+ import { homedir as homedir4 } from "os";
2867
+ import { join as join5 } from "path";
2868
+ function readConfig() {
2869
+ if (!existsSync6(CONFIG_PATH)) return {};
2870
+ const out = {};
2871
+ for (const line of readFileSync4(CONFIG_PATH, "utf-8").split("\n")) {
2872
+ const t = line.trim();
2873
+ if (!t || t.startsWith("#")) continue;
2874
+ const eq = t.indexOf("=");
2875
+ if (eq > 0) out[t.slice(0, eq).trim()] = t.slice(eq + 1).trim().replace(/^['"]|['"]$/g, "");
2876
+ }
2877
+ return out;
2878
+ }
2879
+ async function prompt(rl, q, opts = {}) {
2880
+ if (opts.silent) {
2881
+ process.stdout.write(q);
2882
+ const wasRaw = process.stdin.isRaw;
2883
+ if (process.stdin.setRawMode) process.stdin.setRawMode(true);
2884
+ return await new Promise((resolve2) => {
2885
+ let chunk = "";
2886
+ const onData = (data) => {
2887
+ const s = data.toString("utf-8");
2888
+ if (s === "\r" || s === "\n" || s === "\r\n") {
2889
+ process.stdin.removeListener("data", onData);
2890
+ if (process.stdin.setRawMode) process.stdin.setRawMode(wasRaw ?? false);
2891
+ process.stdout.write("\n");
2892
+ resolve2(chunk);
2893
+ return;
2894
+ }
2895
+ if (s === "") {
2896
+ process.exit(130);
2897
+ }
2898
+ if (s === "\x7F" || s === "\b") {
2899
+ chunk = chunk.slice(0, -1);
2900
+ return;
2901
+ }
2902
+ chunk += s;
2903
+ };
2904
+ process.stdin.on("data", onData);
2905
+ });
2906
+ }
2907
+ return await rl.question(q);
2908
+ }
2909
+ function captureClaudeSetupToken() {
2910
+ return new Promise((resolve2, reject) => {
2911
+ const proc = nodeSpawn("script", ["-q", "/dev/null", "claude", "setup-token"], {
2912
+ stdio: ["inherit", "pipe", "inherit"]
2913
+ });
2914
+ let stdout = "";
2915
+ proc.stdout.on("data", (chunk) => {
2916
+ stdout += chunk.toString();
2917
+ });
2918
+ proc.on("error", (err) => reject(new Error(`Failed to spawn claude setup-token: ${err.message}`)));
2919
+ proc.on("close", (code) => {
2920
+ if (code !== 0) {
2921
+ reject(new Error(`claude setup-token exited with code ${code}`));
2922
+ return;
2923
+ }
2924
+ const stripped = stdout.replace(/\r/g, "").replace(/\x1b\[[0-9;]*[A-Za-z]/g, "").replace(/\x1b\][^\x07]*\x07/g, "").replace(/\x1b[^[\]]*/, "").replace(/\n/g, "");
2925
+ const match = stripped.match(/sk-ant-oat01-[A-Za-z0-9_-]+AA/);
2926
+ if (!match) {
2927
+ reject(new Error("Could not find token in claude setup-token output"));
2928
+ return;
2929
+ }
2930
+ resolve2(match[0]);
2931
+ });
2932
+ });
2933
+ }
2934
+ async function setupGithubCommand(opts = {}) {
2935
+ if (!isAuthenticated()) {
2936
+ console.error("Not authenticated. Run `synkro-cli login` first.");
2937
+ process.exit(1);
2938
+ }
2939
+ const config = readConfig();
2940
+ const gatewayUrl = (config.SYNKRO_GATEWAY_URL || process.env.SYNKRO_GATEWAY_URL || "https://api.synkro.sh").replace(/\/$/, "");
2941
+ const jwt2 = getAccessToken();
2942
+ if (!jwt2) {
2943
+ console.error("Could not load access token from ~/.synkro/credentials.json. Run `synkro-cli login`.");
2944
+ process.exit(1);
2945
+ }
2946
+ console.log("Requesting CI API key from Synkro...");
2947
+ let synkroCiApiKey;
2948
+ try {
2949
+ const resp = await fetch(`${gatewayUrl}/api/v1/cli/ci-api-key`, {
2950
+ method: "POST",
2951
+ headers: {
2952
+ "Authorization": `Bearer ${jwt2}`,
2953
+ "Content-Type": "application/json"
2954
+ },
2955
+ body: "{}"
2956
+ });
2957
+ if (!resp.ok) {
2958
+ const errText = await resp.text().catch(() => "");
2959
+ console.error(`Failed to mint CI API key (${resp.status}): ${errText.slice(0, 200)}`);
2960
+ process.exit(1);
2961
+ }
2962
+ const minted = await resp.json();
2963
+ synkroCiApiKey = minted.api_key;
2964
+ console.log(` \u2713 Issued CI key (${synkroCiApiKey.slice(0, 18)}\u2026), expires ${minted.expires_at.slice(0, 10)}`);
2965
+ } catch (err) {
2966
+ console.error(`Failed to mint CI API key: ${err.message}`);
2967
+ process.exit(1);
2968
+ }
2969
+ let ghToken;
2970
+ if (opts.githubToken) {
2971
+ ghToken = opts.githubToken;
2972
+ } else if (opts.nonInteractive) {
2973
+ try {
2974
+ ghToken = execSync3("gh auth token", { encoding: "utf-8", timeout: 5e3 }).trim();
2975
+ } catch {
2976
+ console.error("Could not get GitHub token from `gh auth token`. Run `gh auth login` first.");
2977
+ return;
2978
+ }
2979
+ } else {
2980
+ const rl = createInterface2({ input, output });
2981
+ console.log("Synkro PR scan setup\n");
2982
+ console.log("Requirements:");
2983
+ console.log(" \u2022 Claude Code installed and logged in (Pro, Max, Teams, or Enterprise)");
2984
+ console.log(" \u2022 A GitHub personal access token with `repo` scope");
2985
+ console.log(" (create at https://github.com/settings/tokens?type=beta)\n");
2986
+ ghToken = (await prompt(rl, "GitHub token (paste): ", { silent: true })).trim();
2987
+ rl.close();
2988
+ if (!ghToken || !ghToken.startsWith("ghp_") && !ghToken.startsWith("github_pat_")) {
2989
+ console.error("Invalid GitHub token format. Expected ghp_... or github_pat_...");
2990
+ process.exit(1);
2991
+ }
2992
+ }
2993
+ let claudeToken;
2994
+ if (!opts.skipClaudeToken) {
2995
+ console.log("Generating Claude Code OAuth token...");
2996
+ console.log(" A browser window will open \u2014 authorize with your Claude account.\n");
2997
+ try {
2998
+ claudeToken = await captureClaudeSetupToken();
2999
+ } catch (err) {
3000
+ console.error(`Failed to get Claude token: ${err instanceof Error ? err.message : String(err)}`);
3001
+ if (opts.nonInteractive) return;
3002
+ process.exit(1);
3003
+ }
3004
+ if (!claudeToken.startsWith("sk-ant-oat01-")) {
3005
+ console.error("Invalid token received from `claude setup-token`. Expected sk-ant-oat01-...");
3006
+ if (opts.nonInteractive) return;
3007
+ process.exit(1);
3008
+ }
3009
+ console.log(" Validating token...");
3010
+ try {
3011
+ const validateResult = execSync3(
3012
+ 'claude --print --output-format json "say ok"',
3013
+ { env: { ...process.env, CLAUDE_CODE_OAUTH_TOKEN: claudeToken }, encoding: "utf-8", timeout: 3e4, stdio: ["ignore", "pipe", "pipe"] }
3014
+ );
3015
+ const result = JSON.parse(validateResult);
3016
+ if (result.is_error) throw new Error(result.result || "auth failed");
3017
+ console.log(" \u2713 Token validated.\n");
3018
+ } catch (err) {
3019
+ console.error(`Token validation failed: ${err instanceof Error ? err.message : String(err)}`);
3020
+ if (opts.nonInteractive) return;
3021
+ process.exit(1);
3022
+ }
3023
+ }
3024
+ let selected;
3025
+ if (opts.nonInteractive) {
3026
+ let currentFullName = null;
3027
+ try {
3028
+ const remoteUrl = execSync3("git remote get-url origin", { encoding: "utf-8", timeout: 5e3 }).trim();
3029
+ const m = remoteUrl.match(/(?:github\.com)[:/](.+?)(?:\.git)?$/);
3030
+ if (m) currentFullName = m[1];
3031
+ } catch {
3032
+ }
3033
+ if (!currentFullName) {
3034
+ console.warn(" \u26A0 Not in a GitHub repo. Skipping PR scan setup.");
3035
+ return;
3036
+ }
3037
+ const [owner, repo] = currentFullName.split("/");
3038
+ selected = [{ owner, repo, full_name: currentFullName }];
3039
+ console.log(` Auto-selected repo: ${currentFullName}`);
3040
+ } else {
3041
+ console.log("\nFetching accessible repos...");
3042
+ const repos = await listAccessibleRepos({ token: ghToken });
3043
+ if (repos.length === 0) {
3044
+ console.error("No accessible repos found. Verify the GitHub token has `repo` scope.");
3045
+ process.exit(1);
3046
+ }
3047
+ console.log(`
3048
+ Found ${repos.length} accessible repo(s):
3049
+ `);
3050
+ repos.slice(0, 100).forEach((r, i) => {
3051
+ console.log(` ${String(i + 1).padStart(3)}. ${r.full_name}`);
3052
+ });
3053
+ console.log();
3054
+ const rl2 = createInterface2({ input, output });
3055
+ const selectionRaw = await prompt(rl2, "Select repos to enable (comma-separated numbers, e.g. 1,3,5): ");
3056
+ const selectedIdx = selectionRaw.split(",").map((s) => parseInt(s.trim(), 10) - 1).filter((n) => !isNaN(n) && n >= 0 && n < repos.length);
3057
+ if (selectedIdx.length === 0) {
3058
+ console.error("No valid selections.");
3059
+ rl2.close();
3060
+ process.exit(1);
3061
+ }
3062
+ selected = selectedIdx.map((i) => repos[i]);
3063
+ console.log(`
3064
+ Will push secrets to ${selected.length} repo(s):`);
3065
+ for (const r of selected) console.log(` \u2022 ${r.full_name}`);
3066
+ console.log();
3067
+ const confirm = (await prompt(rl2, "Continue? (yes/no): ")).trim().toLowerCase();
3068
+ if (confirm !== "yes" && confirm !== "y") {
3069
+ console.log("Cancelled.");
3070
+ rl2.close();
3071
+ process.exit(0);
3072
+ }
3073
+ rl2.close();
3074
+ }
3075
+ console.log();
3076
+ for (const r of selected) {
3077
+ process.stdout.write(`Pushing secrets to ${r.full_name}... `);
3078
+ try {
3079
+ await pushSecretsToRepo(
3080
+ { token: ghToken },
3081
+ r.owner,
3082
+ r.repo,
3083
+ {
3084
+ claudeCodeOauthToken: claudeToken,
3085
+ synkroApiKey: synkroCiApiKey
3086
+ }
3087
+ );
3088
+ console.log("\u2713");
3089
+ } catch (err) {
3090
+ console.log(`\u2717 (${err.message})`);
3091
+ }
3092
+ }
3093
+ console.log();
3094
+ const gitRoot = findGitRoot(process.cwd());
3095
+ if (gitRoot) {
3096
+ const written = writeWorkflowFile(gitRoot);
3097
+ if (written) {
3098
+ console.log(`Wrote workflow: ${written}`);
3099
+ console.log("Commit and push it to enable PR scanning.");
3100
+ }
3101
+ } else {
3102
+ console.log("Not in a git repo. To enable scanning, add this file to your repo:");
3103
+ console.log(` Path: ${WORKFLOW_RELATIVE_PATH}`);
3104
+ console.log(` Content: run \`synkro-cli setup-github\` from inside a repo to write it automatically`);
3105
+ }
3106
+ console.log();
3107
+ console.log("\u2713 PR scan setup complete.");
3108
+ console.log(`Secrets pushed: ${SECRET_NAMES.CLAUDE_OAUTH}, ${SECRET_NAMES.SYNKRO_API_KEY}`);
3109
+ console.log("Open a PR on any selected repo to trigger your first Synkro scan.");
3110
+ }
3111
+ var SYNKRO_DIR, CONFIG_PATH;
3112
+ var init_setupGithub = __esm({
3113
+ "cli/commands/setupGithub.ts"() {
3114
+ "use strict";
3115
+ init_githubSetup();
3116
+ init_stub();
3117
+ SYNKRO_DIR = join5(homedir4(), ".synkro");
3118
+ CONFIG_PATH = join5(SYNKRO_DIR, "config.env");
3119
+ }
3120
+ });
3121
+
2832
3122
  // cli/commands/install.ts
2833
3123
  var install_exports = {};
2834
3124
  __export(install_exports, {
2835
3125
  installCommand: () => installCommand,
2836
3126
  parseArgs: () => parseArgs
2837
3127
  });
2838
- import { existsSync as existsSync6, mkdirSync as mkdirSync5, writeFileSync as writeFileSync5, chmodSync, readFileSync as readFileSync4, readdirSync } from "fs";
2839
- import { homedir as homedir4 } from "os";
2840
- import { join as join5 } from "path";
2841
- import { execSync as execSync3 } from "child_process";
3128
+ import { existsSync as existsSync7, mkdirSync as mkdirSync5, writeFileSync as writeFileSync5, chmodSync, readFileSync as readFileSync5, readdirSync } from "fs";
3129
+ import { homedir as homedir5 } from "os";
3130
+ import { join as join6 } from "path";
3131
+ import { execSync as execSync4 } from "child_process";
3132
+ import { createInterface as createInterface3 } from "readline";
2842
3133
  function sanitizeGatewayCandidate(raw) {
2843
3134
  if (!raw) return void 0;
2844
3135
  return /^https?:\/\//.test(raw) ? raw : void 0;
@@ -2851,6 +3142,8 @@ function parseArgs(argv) {
2851
3142
  else if (a === "--skip-auth") opts.skipAuth = true;
2852
3143
  else if (a === "--no-mcp") opts.noMcp = true;
2853
3144
  else if (a === "--force" || a === "-f") opts.force = true;
3145
+ else if (a === "--link-repo") opts.linkRepo = true;
3146
+ else if (a === "--pr-scan") opts.prScan = true;
2854
3147
  }
2855
3148
  if (!opts.gatewayUrl) {
2856
3149
  const fromEnv = sanitizeGatewayCandidate(process.env.SYNKRO_GATEWAY_URL);
@@ -2858,8 +3151,21 @@ function parseArgs(argv) {
2858
3151
  }
2859
3152
  return opts;
2860
3153
  }
3154
+ async function promptTranscriptConsent() {
3155
+ const rl = createInterface3({ input: process.stdin, output: process.stdout });
3156
+ return new Promise((resolve2) => {
3157
+ rl.question(
3158
+ "Would you like Synkro to use Claude Code session transcripts\nto generate guardrail rules and policies for your team? (Y/n) ",
3159
+ (answer) => {
3160
+ rl.close();
3161
+ const trimmed = answer.trim().toLowerCase();
3162
+ resolve2(trimmed === "" || trimmed === "y" || trimmed === "yes");
3163
+ }
3164
+ );
3165
+ });
3166
+ }
2861
3167
  function ensureSynkroDir() {
2862
- mkdirSync5(SYNKRO_DIR, { recursive: true });
3168
+ mkdirSync5(SYNKRO_DIR2, { recursive: true });
2863
3169
  mkdirSync5(HOOKS_DIR, { recursive: true });
2864
3170
  mkdirSync5(BIN_DIR, { recursive: true });
2865
3171
  mkdirSync5(OFFSETS_DIR, { recursive: true });
@@ -2873,13 +3179,13 @@ function writeGraderDaemon() {
2873
3179
  chmodSync(GRADER_PRIMER_BASH_PATH, 420);
2874
3180
  }
2875
3181
  function writeHookScripts() {
2876
- const bashScriptPath = join5(HOOKS_DIR, "cc-bash-judge.sh");
2877
- const bashFollowupScriptPath = join5(HOOKS_DIR, "cc-bash-followup.sh");
2878
- const editCaptureScriptPath = join5(HOOKS_DIR, "cc-edit-capture.sh");
2879
- const editPrecheckScriptPath = join5(HOOKS_DIR, "cc-edit-precheck.sh");
2880
- const stopSummaryScriptPath = join5(HOOKS_DIR, "cc-stop-summary.sh");
2881
- const sessionStartScriptPath = join5(HOOKS_DIR, "cc-session-start.sh");
2882
- const transcriptSyncScriptPath = join5(HOOKS_DIR, "cc-transcript-sync.sh");
3182
+ const bashScriptPath = join6(HOOKS_DIR, "cc-bash-judge.sh");
3183
+ const bashFollowupScriptPath = join6(HOOKS_DIR, "cc-bash-followup.sh");
3184
+ const editCaptureScriptPath = join6(HOOKS_DIR, "cc-edit-capture.sh");
3185
+ const editPrecheckScriptPath = join6(HOOKS_DIR, "cc-edit-precheck.sh");
3186
+ const stopSummaryScriptPath = join6(HOOKS_DIR, "cc-stop-summary.sh");
3187
+ const sessionStartScriptPath = join6(HOOKS_DIR, "cc-session-start.sh");
3188
+ const transcriptSyncScriptPath = join6(HOOKS_DIR, "cc-transcript-sync.sh");
2883
3189
  writeFileSync5(bashScriptPath, CC_BASH_JUDGE_SCRIPT, "utf-8");
2884
3190
  writeFileSync5(bashFollowupScriptPath, CC_BASH_FOLLOWUP_SCRIPT, "utf-8");
2885
3191
  writeFileSync5(editCaptureScriptPath, CC_EDIT_CAPTURE_SCRIPT, "utf-8");
@@ -2912,7 +3218,7 @@ function shellQuoteSingle(value) {
2912
3218
  return `'${value.replace(/'/g, "'\\''")}'`;
2913
3219
  }
2914
3220
  function writeConfigEnv(opts) {
2915
- const credsPath = join5(SYNKRO_DIR, "credentials.json");
3221
+ const credsPath = join6(SYNKRO_DIR2, "credentials.json");
2916
3222
  const safeGateway = sanitizeConfigValue(opts.gatewayUrl);
2917
3223
  const safeUserId = sanitizeConfigValue(opts.userId);
2918
3224
  const safeOrgId = sanitizeConfigValue(opts.orgId);
@@ -2925,14 +3231,17 @@ function writeConfigEnv(opts) {
2925
3231
  `SYNKRO_GATEWAY_URL=${shellQuoteSingle(safeGateway)}`,
2926
3232
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
2927
3233
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
2928
- `SYNKRO_VERSION=${shellQuoteSingle("1.3.16")}`
3234
+ `SYNKRO_VERSION=${shellQuoteSingle("1.3.18")}`
2929
3235
  ];
2930
3236
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
2931
3237
  if (safeOrgId) lines.push(`SYNKRO_ORG_ID=${shellQuoteSingle(safeOrgId)}`);
2932
3238
  if (safeEmail) lines.push(`SYNKRO_EMAIL=${shellQuoteSingle(safeEmail)}`);
3239
+ if (opts.transcriptConsent !== void 0) {
3240
+ lines.push(`SYNKRO_TRANSCRIPT_CONSENT=${shellQuoteSingle(opts.transcriptConsent ? "yes" : "no")}`);
3241
+ }
2933
3242
  lines.push("");
2934
- writeFileSync5(CONFIG_PATH, lines.join("\n"), "utf-8");
2935
- chmodSync(CONFIG_PATH, 384);
3243
+ writeFileSync5(CONFIG_PATH2, lines.join("\n"), "utf-8");
3244
+ chmodSync(CONFIG_PATH2, 384);
2936
3245
  }
2937
3246
  function assertGatewayAllowed(gatewayUrl) {
2938
3247
  let parsed;
@@ -2957,19 +3266,19 @@ function assertGatewayAllowed(gatewayUrl) {
2957
3266
  }
2958
3267
  function isAlreadyInstalled() {
2959
3268
  const requiredScripts = [
2960
- join5(HOOKS_DIR, "cc-bash-judge.sh"),
2961
- join5(HOOKS_DIR, "cc-bash-followup.sh"),
2962
- join5(HOOKS_DIR, "cc-edit-precheck.sh"),
2963
- join5(HOOKS_DIR, "cc-edit-capture.sh"),
2964
- join5(HOOKS_DIR, "cc-stop-summary.sh"),
2965
- join5(HOOKS_DIR, "cc-session-start.sh")
3269
+ join6(HOOKS_DIR, "cc-bash-judge.sh"),
3270
+ join6(HOOKS_DIR, "cc-bash-followup.sh"),
3271
+ join6(HOOKS_DIR, "cc-edit-precheck.sh"),
3272
+ join6(HOOKS_DIR, "cc-edit-capture.sh"),
3273
+ join6(HOOKS_DIR, "cc-stop-summary.sh"),
3274
+ join6(HOOKS_DIR, "cc-session-start.sh")
2966
3275
  ];
2967
- if (!requiredScripts.every((p) => existsSync6(p))) return false;
2968
- if (!existsSync6(CONFIG_PATH)) return false;
2969
- const settingsPath = join5(homedir4(), ".claude", "settings.json");
2970
- if (!existsSync6(settingsPath)) return false;
3276
+ if (!requiredScripts.every((p) => existsSync7(p))) return false;
3277
+ if (!existsSync7(CONFIG_PATH2)) return false;
3278
+ const settingsPath = join6(homedir5(), ".claude", "settings.json");
3279
+ if (!existsSync7(settingsPath)) return false;
2971
3280
  try {
2972
- const settings = JSON.parse(readFileSync4(settingsPath, "utf-8"));
3281
+ const settings = JSON.parse(readFileSync5(settingsPath, "utf-8"));
2973
3282
  const hooks = settings?.hooks;
2974
3283
  if (!hooks || typeof hooks !== "object") return false;
2975
3284
  const hasManaged = (kind) => Array.isArray(hooks[kind]) && hooks[kind].some((entry) => entry?.__synkro_managed__ === true);
@@ -2991,6 +3300,24 @@ async function installCommand(opts = {}) {
2991
3300
  process.exit(1);
2992
3301
  }
2993
3302
  if (!opts.force && isAuthenticated() && isAlreadyInstalled()) {
3303
+ setApiBaseUrl(`${gatewayUrl}/api`);
3304
+ await ensureValidToken();
3305
+ const currentRepo = detectGitRepo2();
3306
+ if (currentRepo) {
3307
+ try {
3308
+ const projects = await listProjects();
3309
+ const alreadyLinked = projects.some(
3310
+ (p) => p.repos?.some((r) => r.full_name === currentRepo)
3311
+ );
3312
+ if (!alreadyLinked) {
3313
+ console.log(`Synkro is installed. This repo (${currentRepo}) is not linked yet.
3314
+ `);
3315
+ await promptRepoConnection();
3316
+ return;
3317
+ }
3318
+ } catch {
3319
+ }
3320
+ }
2994
3321
  console.log("\u2713 Synkro is already installed and configured.");
2995
3322
  console.log(" Run `synkro-cli update` to refresh hook scripts and judge prompts.");
2996
3323
  console.log(" Run `synkro-cli install --force` to reinstall from scratch.");
@@ -3028,7 +3355,8 @@ async function installCommand(opts = {}) {
3028
3355
  console.error("No access token available after auth.");
3029
3356
  process.exit(1);
3030
3357
  }
3031
- await promptRepoConnection();
3358
+ setApiBaseUrl(`${gatewayUrl}/api`);
3359
+ await promptRepoConnection({ linkRepo: opts.linkRepo });
3032
3360
  const agents = detectAgents();
3033
3361
  if (agents.length === 0) {
3034
3362
  console.error("No AI coding agents detected. Install Claude Code first: https://docs.claude.com/claude-code");
@@ -3052,9 +3380,9 @@ async function installCommand(opts = {}) {
3052
3380
  `);
3053
3381
  writeGraderDaemon();
3054
3382
  for (const mode of ["edit", "bash"]) {
3055
- const pidFile = join5(SYNKRO_DIR, "daemon", mode, "daemon.pid");
3383
+ const pidFile = join6(SYNKRO_DIR2, "daemon", mode, "daemon.pid");
3056
3384
  try {
3057
- const pid = parseInt(readFileSync4(pidFile, "utf-8").trim(), 10);
3385
+ const pid = parseInt(readFileSync5(pidFile, "utf-8").trim(), 10);
3058
3386
  if (pid > 0) {
3059
3387
  process.kill(pid, "SIGTERM");
3060
3388
  console.log(`Stopped stale ${mode} daemon (pid ${pid})`);
@@ -3067,6 +3395,15 @@ async function installCommand(opts = {}) {
3067
3395
  console.log(` ${GRADER_PRIMER_EDIT_PATH}`);
3068
3396
  console.log(` ${GRADER_PRIMER_BASH_PATH}
3069
3397
  `);
3398
+ let transcriptConsent = true;
3399
+ if (process.stdin.isTTY) {
3400
+ transcriptConsent = await promptTranscriptConsent();
3401
+ if (transcriptConsent) {
3402
+ console.log(" \u2713 Transcript collection enabled\n");
3403
+ } else {
3404
+ console.log(" \u2717 Transcript collection disabled \u2014 skipping transcript sync\n");
3405
+ }
3406
+ }
3070
3407
  let hasClaudeCode = false;
3071
3408
  for (const agent of agents) {
3072
3409
  if (agent.kind === "claude_code") {
@@ -3078,7 +3415,8 @@ async function installCommand(opts = {}) {
3078
3415
  editPrecheckScriptPath: scripts.editPrecheckScript,
3079
3416
  stopSummaryScriptPath: scripts.stopSummaryScript,
3080
3417
  sessionStartScriptPath: scripts.sessionStartScript,
3081
- transcriptSyncScriptPath: scripts.transcriptSyncScript
3418
+ transcriptSyncScriptPath: scripts.transcriptSyncScript,
3419
+ skipTranscriptSync: !transcriptConsent
3082
3420
  });
3083
3421
  console.log(`Configured ${agent.name} hooks at ${agent.settingsPath}`);
3084
3422
  }
@@ -3121,44 +3459,53 @@ async function installCommand(opts = {}) {
3121
3459
  email = info.email;
3122
3460
  } catch {
3123
3461
  }
3124
- writeConfigEnv({ gatewayUrl, userId, orgId, email });
3125
- console.log(`Wrote config to ${CONFIG_PATH}
3462
+ writeConfigEnv({ gatewayUrl, userId, orgId, email, transcriptConsent });
3463
+ console.log(`Wrote config to ${CONFIG_PATH2}
3126
3464
  `);
3127
- try {
3128
- const repo = detectGitRepo2();
3129
- if (repo) {
3130
- const ingested = await ingestSessionTranscripts(gatewayUrl, token, repo);
3131
- if (ingested > 0) {
3132
- console.log(`Indexed ${ingested} session insights from Claude Code history for ${repo}.`);
3133
- console.log(" This helps the safety judge understand your workflow.\n");
3465
+ if (transcriptConsent) {
3466
+ try {
3467
+ const repo = detectGitRepo2();
3468
+ if (repo) {
3469
+ const ingested = await ingestSessionTranscripts(gatewayUrl, token, repo);
3470
+ if (ingested > 0) {
3471
+ console.log(`Indexed ${ingested} session insights from Claude Code history for ${repo}.`);
3472
+ console.log(" This helps the safety judge understand your workflow.\n");
3473
+ }
3134
3474
  }
3135
- }
3136
- } catch (err) {
3137
- console.warn(` \u26A0 Session indexing skipped: ${err.message}
3475
+ } catch (err) {
3476
+ console.warn(` \u26A0 Session indexing skipped: ${err.message}
3138
3477
  `);
3139
- }
3140
- try {
3141
- const repo = detectGitRepo2();
3142
- if (repo) {
3143
- const result = await syncTranscriptsBulk(gatewayUrl, token, repo);
3144
- if (result.messages > 0) {
3145
- console.log(`Synced ${result.sessions} sessions (${result.messages} messages) from Claude Code history.`);
3146
- console.log(" This data will be used to suggest guardrail rules.\n");
3147
- }
3148
3478
  }
3149
- } catch (err) {
3150
- console.warn(` \u26A0 Transcript sync skipped: ${err.message}
3479
+ try {
3480
+ const repo = detectGitRepo2();
3481
+ if (repo) {
3482
+ const result = await syncTranscriptsBulk(gatewayUrl, token, repo);
3483
+ if (result.messages > 0) {
3484
+ console.log(`Synced ${result.sessions} sessions (${result.messages} messages) from Claude Code history.`);
3485
+ console.log(" This data will be used to suggest guardrail rules.\n");
3486
+ }
3487
+ }
3488
+ } catch (err) {
3489
+ console.warn(` \u26A0 Transcript sync skipped: ${err.message}
3151
3490
  `);
3491
+ }
3492
+ }
3493
+ if (opts.prScan) {
3494
+ console.log();
3495
+ const { setupGithubCommand: setupGithubCommand2 } = await Promise.resolve().then(() => (init_setupGithub(), setupGithub_exports));
3496
+ await setupGithubCommand2({ nonInteractive: true });
3152
3497
  }
3153
3498
  console.log("\u2713 Synkro installed.");
3154
3499
  console.log();
3155
- console.log("Next steps:");
3156
- console.log(" \u2022 synkro-cli setup-github (enable PR scanning)");
3157
- console.log(" \u2022 synkro-cli status (check what is configured)");
3500
+ if (!opts.prScan) {
3501
+ console.log("Next steps:");
3502
+ console.log(" \u2022 synkro-cli setup-github (enable PR scanning)");
3503
+ console.log(" \u2022 synkro-cli status (check what is configured)");
3504
+ }
3158
3505
  }
3159
3506
  function detectGitRepo2() {
3160
3507
  try {
3161
- const remoteUrl = execSync3("git remote get-url origin", { encoding: "utf-8", timeout: 5e3 }).trim();
3508
+ const remoteUrl = execSync4("git remote get-url origin", { encoding: "utf-8", timeout: 5e3 }).trim();
3162
3509
  const match = remoteUrl.match(/(?:github\.com|gitlab\.com|bitbucket\.org)[:/](.+?)(?:\.git)?$/);
3163
3510
  return match ? match[1] : null;
3164
3511
  } catch {
@@ -3168,17 +3515,17 @@ function detectGitRepo2() {
3168
3515
  function getClaudeProjectsFolder() {
3169
3516
  const cwd = process.cwd();
3170
3517
  const sanitized = "-" + cwd.replace(/\//g, "-");
3171
- const projectsDir = join5(homedir4(), ".claude", "projects", sanitized);
3172
- return existsSync6(projectsDir) ? projectsDir : null;
3518
+ const projectsDir = join6(homedir5(), ".claude", "projects", sanitized);
3519
+ return existsSync7(projectsDir) ? projectsDir : null;
3173
3520
  }
3174
3521
  function extractSessionInsights(projectsDir) {
3175
3522
  const insights = [];
3176
3523
  const files = readdirSync(projectsDir).filter((f) => f.endsWith(".jsonl"));
3177
3524
  for (const file of files) {
3178
3525
  const sessionId = file.replace(".jsonl", "");
3179
- const filePath = join5(projectsDir, file);
3526
+ const filePath = join6(projectsDir, file);
3180
3527
  try {
3181
- const content = readFileSync4(filePath, "utf-8");
3528
+ const content = readFileSync5(filePath, "utf-8");
3182
3529
  const lines = content.split("\n").filter(Boolean);
3183
3530
  for (let i = 0; i < lines.length; i++) {
3184
3531
  try {
@@ -3254,7 +3601,7 @@ function extractTextContent(content) {
3254
3601
  return "";
3255
3602
  }
3256
3603
  function parseTranscriptFile(filePath) {
3257
- const content = readFileSync4(filePath, "utf-8");
3604
+ const content = readFileSync5(filePath, "utf-8");
3258
3605
  const lines = content.split("\n").filter(Boolean);
3259
3606
  const messages = [];
3260
3607
  for (let i = 0; i < lines.length; i++) {
@@ -3305,7 +3652,7 @@ async function syncTranscriptsBulk(gatewayUrl, token, repo) {
3305
3652
  const sessions = [];
3306
3653
  for (const file of batch) {
3307
3654
  const sessionId = file.replace(".jsonl", "");
3308
- const filePath = join5(projectsDir, file);
3655
+ const filePath = join6(projectsDir, file);
3309
3656
  try {
3310
3657
  const allMessages = parseTranscriptFile(filePath);
3311
3658
  const messages = allMessages.length > maxMessagesPerSession ? allMessages.slice(-maxMessagesPerSession) : allMessages;
@@ -3334,18 +3681,18 @@ async function syncTranscriptsBulk(gatewayUrl, token, repo) {
3334
3681
  }
3335
3682
  for (const file of batch) {
3336
3683
  const sessionId = file.replace(".jsonl", "");
3337
- const filePath = join5(projectsDir, file);
3684
+ const filePath = join6(projectsDir, file);
3338
3685
  try {
3339
- const content = readFileSync4(filePath, "utf-8");
3686
+ const content = readFileSync5(filePath, "utf-8");
3340
3687
  const lineCount = content.split("\n").filter(Boolean).length;
3341
- writeFileSync5(join5(OFFSETS_DIR, sessionId), String(lineCount), "utf-8");
3688
+ writeFileSync5(join6(OFFSETS_DIR, sessionId), String(lineCount), "utf-8");
3342
3689
  } catch {
3343
3690
  }
3344
3691
  }
3345
3692
  }
3346
3693
  return { sessions: totalSessions, messages: totalMessages };
3347
3694
  }
3348
- var SYNKRO_DIR, HOOKS_DIR, BIN_DIR, CONFIG_PATH, GRADER_DAEMON_PATH, GRADER_PRIMER_EDIT_PATH, GRADER_PRIMER_BASH_PATH, OFFSETS_DIR;
3695
+ var SYNKRO_DIR2, HOOKS_DIR, BIN_DIR, CONFIG_PATH2, GRADER_DAEMON_PATH, GRADER_PRIMER_EDIT_PATH, GRADER_PRIMER_BASH_PATH, OFFSETS_DIR;
3349
3696
  var init_install = __esm({
3350
3697
  "cli/commands/install.ts"() {
3351
3698
  "use strict";
@@ -3356,14 +3703,15 @@ var init_install = __esm({
3356
3703
  init_graderDaemon();
3357
3704
  init_stub();
3358
3705
  init_repoConnect();
3359
- SYNKRO_DIR = join5(homedir4(), ".synkro");
3360
- HOOKS_DIR = join5(SYNKRO_DIR, "hooks");
3361
- BIN_DIR = join5(SYNKRO_DIR, "bin");
3362
- CONFIG_PATH = join5(SYNKRO_DIR, "config.env");
3363
- GRADER_DAEMON_PATH = join5(BIN_DIR, "grader_daemon.py");
3364
- GRADER_PRIMER_EDIT_PATH = join5(SYNKRO_DIR, "grader-primer-edit.txt");
3365
- GRADER_PRIMER_BASH_PATH = join5(SYNKRO_DIR, "grader-primer-bash.txt");
3366
- OFFSETS_DIR = join5(SYNKRO_DIR, ".transcript-offsets");
3706
+ init_projects();
3707
+ SYNKRO_DIR2 = join6(homedir5(), ".synkro");
3708
+ HOOKS_DIR = join6(SYNKRO_DIR2, "hooks");
3709
+ BIN_DIR = join6(SYNKRO_DIR2, "bin");
3710
+ CONFIG_PATH2 = join6(SYNKRO_DIR2, "config.env");
3711
+ GRADER_DAEMON_PATH = join6(BIN_DIR, "grader_daemon.py");
3712
+ GRADER_PRIMER_EDIT_PATH = join6(SYNKRO_DIR2, "grader-primer-edit.txt");
3713
+ GRADER_PRIMER_BASH_PATH = join6(SYNKRO_DIR2, "grader-primer-bash.txt");
3714
+ OFFSETS_DIR = join6(SYNKRO_DIR2, ".transcript-offsets");
3367
3715
  }
3368
3716
  });
3369
3717
 
@@ -3439,13 +3787,13 @@ var status_exports = {};
3439
3787
  __export(status_exports, {
3440
3788
  statusCommand: () => statusCommand
3441
3789
  });
3442
- import { existsSync as existsSync7, readFileSync as readFileSync5 } from "fs";
3443
- import { homedir as homedir5 } from "os";
3444
- import { join as join6 } from "path";
3790
+ import { existsSync as existsSync8, readFileSync as readFileSync6 } from "fs";
3791
+ import { homedir as homedir6 } from "os";
3792
+ import { join as join7 } from "path";
3445
3793
  function readConfigEnv() {
3446
- if (!existsSync7(CONFIG_PATH2)) return {};
3794
+ if (!existsSync8(CONFIG_PATH3)) return {};
3447
3795
  const out = {};
3448
- const raw = readFileSync5(CONFIG_PATH2, "utf-8");
3796
+ const raw = readFileSync6(CONFIG_PATH3, "utf-8");
3449
3797
  for (const line of raw.split("\n")) {
3450
3798
  const trimmed = line.trim();
3451
3799
  if (!trimmed || trimmed.startsWith("#")) continue;
@@ -3469,21 +3817,21 @@ function statusCommand() {
3469
3817
  console.log("Authentication: \u2717 not logged in (run: synkro-cli login)");
3470
3818
  }
3471
3819
  console.log();
3472
- const config2 = readConfigEnv();
3820
+ const config = readConfigEnv();
3473
3821
  console.log("Config:");
3474
- console.log(` gateway: ${config2.SYNKRO_GATEWAY_URL ?? "(unset)"}`);
3475
- console.log(` credentials: ${config2.SYNKRO_CREDENTIALS_PATH ?? "(unset)"}`);
3476
- console.log(` tier: ${config2.SYNKRO_TIER ?? "(unset)"}`);
3822
+ console.log(` gateway: ${config.SYNKRO_GATEWAY_URL ?? "(unset)"}`);
3823
+ console.log(` credentials: ${config.SYNKRO_CREDENTIALS_PATH ?? "(unset)"}`);
3824
+ console.log(` tier: ${config.SYNKRO_TIER ?? "(unset)"}`);
3477
3825
  const info2 = getUserInfo();
3478
- const userId = info2?.id ?? config2.SYNKRO_USER_ID ?? "default";
3479
- const tierCacheFile = join6(SYNKRO_DIR2, `.tier-cache-${userId}`);
3480
- let inferenceTier = config2.SYNKRO_INFERENCE_TIER || null;
3481
- if (!inferenceTier && existsSync7(tierCacheFile)) {
3482
- inferenceTier = readFileSync5(tierCacheFile, "utf-8").trim() || null;
3826
+ const userId = info2?.id ?? config.SYNKRO_USER_ID ?? "default";
3827
+ const tierCacheFile = join7(SYNKRO_DIR3, `.tier-cache-${userId}`);
3828
+ let inferenceTier = config.SYNKRO_INFERENCE_TIER || null;
3829
+ if (!inferenceTier && existsSync8(tierCacheFile)) {
3830
+ inferenceTier = readFileSync6(tierCacheFile, "utf-8").trim() || null;
3483
3831
  }
3484
3832
  const tierLabel = inferenceTier === "fast" ? "'fast' (server-side grading)" : inferenceTier === "free" ? "'free' (local daemon grading)" : "(unknown \u2014 fires on next hook)";
3485
3833
  console.log(` inference: ${tierLabel}`);
3486
- console.log(` version: ${config2.SYNKRO_VERSION ?? "(unset)"}`);
3834
+ console.log(` version: ${config.SYNKRO_VERSION ?? "(unset)"}`);
3487
3835
  console.log();
3488
3836
  const agents = detectAgents();
3489
3837
  console.log("Detected agents:");
@@ -3506,19 +3854,19 @@ function statusCommand() {
3506
3854
  }
3507
3855
  }
3508
3856
  console.log();
3509
- const bashScript = join6(SYNKRO_DIR2, "hooks", "cc-bash-judge.sh");
3510
- const bashFollowupScript = join6(SYNKRO_DIR2, "hooks", "cc-bash-followup.sh");
3511
- const editPrecheckScript = join6(SYNKRO_DIR2, "hooks", "cc-edit-precheck.sh");
3512
- const editCaptureScript = join6(SYNKRO_DIR2, "hooks", "cc-edit-capture.sh");
3513
- const stopSummaryScript = join6(SYNKRO_DIR2, "hooks", "cc-stop-summary.sh");
3514
- const sessionStartScript = join6(SYNKRO_DIR2, "hooks", "cc-session-start.sh");
3857
+ const bashScript = join7(SYNKRO_DIR3, "hooks", "cc-bash-judge.sh");
3858
+ const bashFollowupScript = join7(SYNKRO_DIR3, "hooks", "cc-bash-followup.sh");
3859
+ const editPrecheckScript = join7(SYNKRO_DIR3, "hooks", "cc-edit-precheck.sh");
3860
+ const editCaptureScript = join7(SYNKRO_DIR3, "hooks", "cc-edit-capture.sh");
3861
+ const stopSummaryScript = join7(SYNKRO_DIR3, "hooks", "cc-stop-summary.sh");
3862
+ const sessionStartScript = join7(SYNKRO_DIR3, "hooks", "cc-session-start.sh");
3515
3863
  console.log("Hook scripts:");
3516
- console.log(` ${existsSync7(bashScript) ? "\u2713" : "\u2717"} ${bashScript}`);
3517
- console.log(` ${existsSync7(bashFollowupScript) ? "\u2713" : "\u2717"} ${bashFollowupScript}`);
3518
- console.log(` ${existsSync7(editPrecheckScript) ? "\u2713" : "\u2717"} ${editPrecheckScript}`);
3519
- console.log(` ${existsSync7(editCaptureScript) ? "\u2713" : "\u2717"} ${editCaptureScript}`);
3520
- console.log(` ${existsSync7(stopSummaryScript) ? "\u2713" : "\u2717"} ${stopSummaryScript}`);
3521
- console.log(` ${existsSync7(sessionStartScript) ? "\u2713" : "\u2717"} ${sessionStartScript}`);
3864
+ console.log(` ${existsSync8(bashScript) ? "\u2713" : "\u2717"} ${bashScript}`);
3865
+ console.log(` ${existsSync8(bashFollowupScript) ? "\u2713" : "\u2717"} ${bashFollowupScript}`);
3866
+ console.log(` ${existsSync8(editPrecheckScript) ? "\u2713" : "\u2717"} ${editPrecheckScript}`);
3867
+ console.log(` ${existsSync8(editCaptureScript) ? "\u2713" : "\u2717"} ${editCaptureScript}`);
3868
+ console.log(` ${existsSync8(stopSummaryScript) ? "\u2713" : "\u2717"} ${stopSummaryScript}`);
3869
+ console.log(` ${existsSync8(sessionStartScript) ? "\u2713" : "\u2717"} ${sessionStartScript}`);
3522
3870
  console.log();
3523
3871
  const mcp = inspectMcpConfig();
3524
3872
  console.log("Guardrails MCP server (Claude Code):");
@@ -3530,7 +3878,7 @@ function statusCommand() {
3530
3878
  console.log(` expected at ${mcp.configPath} \u2192 mcpServers.synkro-guardrails`);
3531
3879
  }
3532
3880
  }
3533
- var SYNKRO_DIR2, CONFIG_PATH2;
3881
+ var SYNKRO_DIR3, CONFIG_PATH3;
3534
3882
  var init_status = __esm({
3535
3883
  "cli/commands/status.ts"() {
3536
3884
  "use strict";
@@ -3538,8 +3886,8 @@ var init_status = __esm({
3538
3886
  init_agentDetect();
3539
3887
  init_ccHookConfig();
3540
3888
  init_mcpConfig();
3541
- SYNKRO_DIR2 = join6(homedir5(), ".synkro");
3542
- CONFIG_PATH2 = join6(SYNKRO_DIR2, "config.env");
3889
+ SYNKRO_DIR3 = join7(homedir6(), ".synkro");
3890
+ CONFIG_PATH3 = join7(SYNKRO_DIR3, "config.env");
3543
3891
  }
3544
3892
  });
3545
3893
 
@@ -3569,7 +3917,7 @@ var unlink_exports = {};
3569
3917
  __export(unlink_exports, {
3570
3918
  unlinkCommand: () => unlinkCommand
3571
3919
  });
3572
- import { createInterface as createInterface2 } from "readline";
3920
+ import { createInterface as createInterface4 } from "readline";
3573
3921
  function ask2(rl, question) {
3574
3922
  return new Promise((resolve2) => rl.question(question, resolve2));
3575
3923
  }
@@ -3597,7 +3945,7 @@ async function unlinkCommand() {
3597
3945
  console.log(` ${i + 1}. ${r.fullName} (${r.projectName})`);
3598
3946
  });
3599
3947
  console.log();
3600
- const rl = createInterface2({ input: process.stdin, output: process.stdout });
3948
+ const rl = createInterface4({ input: process.stdin, output: process.stdout });
3601
3949
  try {
3602
3950
  const selection = await ask2(rl, " Select repos to unlink (comma-separated numbers): ");
3603
3951
  const indices = selection.split(",").map((s) => parseInt(s.trim(), 10) - 1).filter((n) => !isNaN(n) && n >= 0 && n < linked.length);
@@ -3623,200 +3971,12 @@ var init_unlink = __esm({
3623
3971
  }
3624
3972
  });
3625
3973
 
3626
- // cli/commands/setupGithub.ts
3627
- var setupGithub_exports = {};
3628
- __export(setupGithub_exports, {
3629
- setupGithubCommand: () => setupGithubCommand
3630
- });
3631
- import { createInterface as createInterface3 } from "readline/promises";
3632
- import { stdin as input, stdout as output } from "process";
3633
- import { existsSync as existsSync8, readFileSync as readFileSync6 } from "fs";
3634
- import { homedir as homedir6 } from "os";
3635
- import { join as join7 } from "path";
3636
- function readConfig() {
3637
- if (!existsSync8(CONFIG_PATH3)) return {};
3638
- const out = {};
3639
- for (const line of readFileSync6(CONFIG_PATH3, "utf-8").split("\n")) {
3640
- const t = line.trim();
3641
- if (!t || t.startsWith("#")) continue;
3642
- const eq = t.indexOf("=");
3643
- if (eq > 0) out[t.slice(0, eq).trim()] = t.slice(eq + 1).trim();
3644
- }
3645
- return out;
3646
- }
3647
- async function prompt(rl, q, opts = {}) {
3648
- if (opts.silent) {
3649
- process.stdout.write(q);
3650
- const wasRaw = process.stdin.isRaw;
3651
- if (process.stdin.setRawMode) process.stdin.setRawMode(true);
3652
- return await new Promise((resolve2) => {
3653
- let chunk = "";
3654
- const onData = (data) => {
3655
- const s = data.toString("utf-8");
3656
- if (s === "\r" || s === "\n" || s === "\r\n") {
3657
- process.stdin.removeListener("data", onData);
3658
- if (process.stdin.setRawMode) process.stdin.setRawMode(wasRaw ?? false);
3659
- process.stdout.write("\n");
3660
- resolve2(chunk);
3661
- return;
3662
- }
3663
- if (s === "") {
3664
- process.exit(130);
3665
- }
3666
- if (s === "\x7F" || s === "\b") {
3667
- chunk = chunk.slice(0, -1);
3668
- return;
3669
- }
3670
- chunk += s;
3671
- };
3672
- process.stdin.on("data", onData);
3673
- });
3674
- }
3675
- return await rl.question(q);
3676
- }
3677
- async function setupGithubCommand() {
3678
- if (!isAuthenticated()) {
3679
- console.error("Not authenticated. Run `synkro-cli login` first.");
3680
- process.exit(1);
3681
- }
3682
- const config2 = readConfig();
3683
- const gatewayUrl = (config2.SYNKRO_GATEWAY_URL || process.env.SYNKRO_GATEWAY_URL || "https://api.synkro.sh").replace(/\/$/, "");
3684
- const jwt2 = getAccessToken();
3685
- if (!jwt2) {
3686
- console.error("Could not load access token from ~/.synkro/credentials.json. Run `synkro-cli login`.");
3687
- process.exit(1);
3688
- }
3689
- console.log("Requesting CI API key from Synkro...");
3690
- let synkroCiApiKey;
3691
- try {
3692
- const resp = await fetch(`${gatewayUrl}/api/v1/cli/ci-api-key`, {
3693
- method: "POST",
3694
- headers: {
3695
- "Authorization": `Bearer ${jwt2}`,
3696
- "Content-Type": "application/json"
3697
- },
3698
- body: "{}"
3699
- });
3700
- if (!resp.ok) {
3701
- const errText = await resp.text().catch(() => "");
3702
- console.error(`Failed to mint CI API key (${resp.status}): ${errText.slice(0, 200)}`);
3703
- process.exit(1);
3704
- }
3705
- const minted = await resp.json();
3706
- synkroCiApiKey = minted.api_key;
3707
- console.log(` \u2713 Issued CI key (${synkroCiApiKey.slice(0, 18)}\u2026), expires ${minted.expires_at.slice(0, 10)}`);
3708
- } catch (err) {
3709
- console.error(`Failed to mint CI API key: ${err.message}`);
3710
- process.exit(1);
3711
- }
3712
- const rl = createInterface3({ input, output });
3713
- console.log("Synkro PR scan setup\n");
3714
- console.log("Requirements:");
3715
- console.log(" \u2022 Claude Code Pro or Max subscription (for `claude setup-token`)");
3716
- console.log(" \u2022 A GitHub personal access token with `repo` scope");
3717
- console.log(" (create at https://github.com/settings/tokens?type=beta)\n");
3718
- const ghToken = (await prompt(rl, "GitHub token (paste): ", { silent: true })).trim();
3719
- if (!ghToken || !ghToken.startsWith("ghp_") && !ghToken.startsWith("github_pat_")) {
3720
- console.error("Invalid GitHub token format. Expected ghp_... or github_pat_...");
3721
- rl.close();
3722
- process.exit(1);
3723
- }
3724
- console.log("\nNow get your Claude Code OAuth token:");
3725
- console.log(" 1. In another terminal, run: claude setup-token");
3726
- console.log(" 2. Complete the browser flow");
3727
- console.log(" 3. Copy the resulting sk-ant-oat01-... token\n");
3728
- const claudeToken = (await prompt(rl, "Claude Code OAuth token (paste): ", { silent: true })).trim();
3729
- if (!claudeToken.startsWith("sk-ant-oat01-")) {
3730
- console.error("Invalid token. Expected sk-ant-oat01-... \u2014 generate one with `claude setup-token`.");
3731
- rl.close();
3732
- process.exit(1);
3733
- }
3734
- console.log("\nFetching accessible repos...");
3735
- const repos = await listAccessibleRepos({ token: ghToken });
3736
- if (repos.length === 0) {
3737
- console.error("No accessible repos found. Verify the GitHub token has `repo` scope.");
3738
- rl.close();
3739
- process.exit(1);
3740
- }
3741
- console.log(`
3742
- Found ${repos.length} accessible repo(s):
3743
- `);
3744
- repos.slice(0, 100).forEach((r, i) => {
3745
- console.log(` ${String(i + 1).padStart(3)}. ${r.full_name}`);
3746
- });
3747
- console.log();
3748
- const selectionRaw = await prompt(rl, "Select repos to enable (comma-separated numbers, e.g. 1,3,5): ");
3749
- const selectedIdx = selectionRaw.split(",").map((s) => parseInt(s.trim(), 10) - 1).filter((n) => !isNaN(n) && n >= 0 && n < repos.length);
3750
- if (selectedIdx.length === 0) {
3751
- console.error("No valid selections.");
3752
- rl.close();
3753
- process.exit(1);
3754
- }
3755
- const selected = selectedIdx.map((i) => repos[i]);
3756
- console.log(`
3757
- Will push secrets to ${selected.length} repo(s):`);
3758
- for (const r of selected) console.log(` \u2022 ${r.full_name}`);
3759
- console.log();
3760
- const confirm = (await prompt(rl, "Continue? (yes/no): ")).trim().toLowerCase();
3761
- if (confirm !== "yes" && confirm !== "y") {
3762
- console.log("Cancelled.");
3763
- rl.close();
3764
- process.exit(0);
3765
- }
3766
- rl.close();
3767
- console.log();
3768
- for (const r of selected) {
3769
- process.stdout.write(`Pushing secrets to ${r.full_name}... `);
3770
- try {
3771
- await pushSecretsToRepo(
3772
- { token: ghToken },
3773
- r.owner,
3774
- r.repo,
3775
- {
3776
- claudeCodeOauthToken: claudeToken,
3777
- synkroApiKey: synkroCiApiKey
3778
- }
3779
- );
3780
- console.log("\u2713");
3781
- } catch (err) {
3782
- console.log(`\u2717 (${err.message})`);
3783
- }
3784
- }
3785
- console.log();
3786
- const gitRoot = findGitRoot(process.cwd());
3787
- if (gitRoot) {
3788
- const written = writeWorkflowFile(gitRoot);
3789
- if (written) {
3790
- console.log(`Wrote workflow: ${written}`);
3791
- console.log("Commit and push it to enable PR scanning.");
3792
- }
3793
- } else {
3794
- console.log("Not in a git repo. To enable scanning, add this file to your repo:");
3795
- console.log(` Path: ${WORKFLOW_RELATIVE_PATH}`);
3796
- console.log(` Content: run \`synkro-cli setup-github\` from inside a repo to write it automatically`);
3797
- }
3798
- console.log();
3799
- console.log("\u2713 PR scan setup complete.");
3800
- console.log(`Secrets pushed: ${SECRET_NAMES.CLAUDE_OAUTH}, ${SECRET_NAMES.SYNKRO_API_KEY}`);
3801
- console.log("Open a PR on any selected repo to trigger your first Synkro scan.");
3802
- }
3803
- var SYNKRO_DIR3, CONFIG_PATH3;
3804
- var init_setupGithub = __esm({
3805
- "cli/commands/setupGithub.ts"() {
3806
- "use strict";
3807
- init_githubSetup();
3808
- init_stub();
3809
- SYNKRO_DIR3 = join7(homedir6(), ".synkro");
3810
- CONFIG_PATH3 = join7(SYNKRO_DIR3, "config.env");
3811
- }
3812
- });
3813
-
3814
3974
  // cli/commands/scanPr.ts
3815
3975
  var scanPr_exports = {};
3816
3976
  __export(scanPr_exports, {
3817
3977
  scanPrCommand: () => scanPrCommand
3818
3978
  });
3819
- import { execSync as execSync4, spawn } from "child_process";
3979
+ import { execSync as execSync5, spawn } from "child_process";
3820
3980
  function parseMatchSpec(condition) {
3821
3981
  if (!condition.startsWith("match_spec:")) return null;
3822
3982
  try {
@@ -3922,7 +4082,7 @@ function shouldSkipFile(filename) {
3922
4082
  return SKIP_FILE_PATTERNS.some((p) => p.test(filename));
3923
4083
  }
3924
4084
  function ghJson(args2) {
3925
- const out = execSync4(`gh ${args2.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(" ")}`, {
4085
+ const out = execSync5(`gh ${args2.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(" ")}`, {
3926
4086
  encoding: "utf-8",
3927
4087
  maxBuffer: 16 * 1024 * 1024
3928
4088
  });
@@ -3978,9 +4138,9 @@ ${hunks}`;
3978
4138
  const t0 = Date.now();
3979
4139
  const proc = spawn(
3980
4140
  "claude",
3981
- ["--print", "--model", "claude-sonnet-4-6", "--output-format", "json", "--no-session-persistence", fullPrompt],
4141
+ ["--print", "--model", "claude-sonnet-4-6", "--output-format", "json", "--no-session-persistence"],
3982
4142
  {
3983
- stdio: ["ignore", "pipe", "pipe"],
4143
+ stdio: ["pipe", "pipe", "pipe"],
3984
4144
  env: {
3985
4145
  ...process.env,
3986
4146
  CLAUDE_CODE_OAUTH_TOKEN: claudeToken
@@ -3988,6 +4148,8 @@ ${hunks}`;
3988
4148
  timeout: 12e4
3989
4149
  }
3990
4150
  );
4151
+ proc.stdin.write(fullPrompt);
4152
+ proc.stdin.end();
3991
4153
  let stdout = "";
3992
4154
  let stderr = "";
3993
4155
  proc.stdout.on("data", (chunk) => {
@@ -4036,19 +4198,174 @@ async function processInBatches(items, batchSize, fn) {
4036
4198
  }
4037
4199
  return results;
4038
4200
  }
4039
- function postPrComment(repo, prNumber, sha, finding) {
4040
- const body = `\u{1F512} **Synkro [${finding.severity}]: ${finding.category}**
4201
+ function buildConsolidationPrompt(findings) {
4202
+ return `You are a senior security reviewer writing the final PR review. You received raw findings from an automated detector. Your job:
4041
4203
 
4042
- ${finding.description}
4204
+ 1. VERIFY \u2014 remove false positives or findings that are clearly wrong.
4205
+ 2. CONSOLIDATE \u2014 if multiple findings describe the same underlying issue in the same file (e.g. 8 hardcoded secrets), merge them into ONE comment pinned to the first line, listing all affected lines. If findings are genuinely different issues (e.g. SQL injection on line 5 AND a hardcoded secret on line 12), keep them separate.
4206
+ 3. WRITE \u2014 produce concise, actionable review comments. No fluff. Each comment should name the issue and say what to do. One sentence max for description, one for fix.
4043
4207
 
4044
- **Fix:** ${finding.fix}`;
4045
- try {
4046
- execSync4(
4047
- `gh api -X POST /repos/${repo}/pulls/${prNumber}/comments -f body=${JSON.stringify(body)} -f commit_id=${sha} -f path=${JSON.stringify(finding.file)} -F line=${finding.line} -f side=RIGHT`,
4048
- { encoding: "utf-8", stdio: ["ignore", "ignore", "pipe"] }
4208
+ Output ONLY a JSON object (no prose, no markdown fences):
4209
+ {
4210
+ "summary": "<2-3 sentence overview for the review body \u2014 total issues, severity, what to do>",
4211
+ "comments": [
4212
+ {
4213
+ "path": "<file path>",
4214
+ "line": <integer, first affected line number \u2014 REQUIRED, never null>,
4215
+ "body": "<markdown comment \u2014 include affected lines if consolidated, e.g. 'Lines 1-8: ...'>"
4216
+ }
4217
+ ]
4218
+ }
4219
+
4220
+ Rules:
4221
+ - Each comment body should start with a severity emoji+label: \u{1F534} critical, \u{1F7E0} high, \u{1F7E1} medium, \u{1F535} low
4222
+ - If consolidating, mention all affected line numbers in the body
4223
+ - Keep comments short \u2014 developers read these in a PR, not a report
4224
+ - Maximum 15 comments. If more, pick the most critical and mention the rest in the summary.
4225
+ - If all findings are legitimate and distinct, keep them all (up to 15)
4226
+ - If you determine ALL findings are false positives, return {"summary": "No issues found after verification.", "comments": []}
4227
+
4228
+ Raw findings from detector:
4229
+ ${JSON.stringify(findings, null, 2)}
4230
+ `;
4231
+ }
4232
+ function spawnOpusConsolidator(findings, claudeToken) {
4233
+ return new Promise((resolve2) => {
4234
+ const prompt2 = buildConsolidationPrompt(findings);
4235
+ const proc = spawn(
4236
+ "claude",
4237
+ ["--print", "--model", "claude-opus-4-7", "--output-format", "json", "--no-session-persistence"],
4238
+ {
4239
+ stdio: ["pipe", "pipe", "pipe"],
4240
+ env: {
4241
+ ...process.env,
4242
+ CLAUDE_CODE_OAUTH_TOKEN: claudeToken
4243
+ },
4244
+ timeout: 12e4
4245
+ }
4049
4246
  );
4050
- } catch (err) {
4051
- console.warn(`Failed to post comment on ${finding.file}:${finding.line}:`, err.message);
4247
+ proc.stdin.write(prompt2);
4248
+ proc.stdin.end();
4249
+ let stdout = "";
4250
+ let stderr = "";
4251
+ proc.stdout.on("data", (chunk) => {
4252
+ stdout += chunk.toString();
4253
+ });
4254
+ proc.stderr.on("data", (chunk) => {
4255
+ stderr += chunk.toString();
4256
+ });
4257
+ proc.on("close", (code) => {
4258
+ if (code !== 0) {
4259
+ console.warn(` opus consolidation exited ${code}: ${(stderr || stdout).slice(0, 300)}`);
4260
+ resolve2(fallbackReview(findings));
4261
+ return;
4262
+ }
4263
+ try {
4264
+ const wrapper = JSON.parse(stdout);
4265
+ const responseText = (wrapper.result || wrapper.response || wrapper.text || "").trim();
4266
+ let txt = responseText;
4267
+ if (txt.startsWith("```")) {
4268
+ txt = txt.replace(/^```(?:json)?\n?/, "").replace(/\n?```\s*$/, "").trim();
4269
+ }
4270
+ const review = JSON.parse(txt);
4271
+ const comments = (review.comments || []).map((c) => ({
4272
+ path: c.path,
4273
+ line: c.line ?? 1,
4274
+ side: "RIGHT",
4275
+ body: c.body
4276
+ }));
4277
+ const maxSeverity = findings.reduce((max, f) => {
4278
+ const order = ["low", "medium", "high", "critical"];
4279
+ return order.indexOf(f.severity) > order.indexOf(max) ? f.severity : max;
4280
+ }, "low");
4281
+ resolve2({ summary: review.summary || "", comments, severity: maxSeverity });
4282
+ } catch {
4283
+ console.warn(` failed to parse opus response, using fallback`);
4284
+ resolve2(fallbackReview(findings));
4285
+ }
4286
+ });
4287
+ });
4288
+ }
4289
+ function fallbackReview(findings) {
4290
+ const grouped = /* @__PURE__ */ new Map();
4291
+ for (const f of findings) {
4292
+ const key = `${f.file}::${f.category}`;
4293
+ if (!grouped.has(key)) grouped.set(key, []);
4294
+ grouped.get(key).push(f);
4295
+ }
4296
+ const comments = [];
4297
+ for (const [, group] of grouped) {
4298
+ const first = group[0];
4299
+ const lines = group.map((f) => f.line);
4300
+ const linesStr = lines.length > 1 ? `Lines ${lines.join(", ")}` : `Line ${lines[0]}`;
4301
+ const severityEmoji = first.severity === "critical" ? "\u{1F534}" : first.severity === "high" ? "\u{1F7E0}" : first.severity === "medium" ? "\u{1F7E1}" : "\u{1F535}";
4302
+ comments.push({
4303
+ path: first.file,
4304
+ line: first.line,
4305
+ side: "RIGHT",
4306
+ body: `${severityEmoji} **${first.severity}: ${first.category}**
4307
+
4308
+ ${linesStr}: ${first.description}
4309
+
4310
+ **Fix:** ${first.fix}`
4311
+ });
4312
+ }
4313
+ const maxSeverity = findings.reduce((max, f) => {
4314
+ const order = ["low", "medium", "high", "critical"];
4315
+ return order.indexOf(f.severity) > order.indexOf(max) ? f.severity : max;
4316
+ }, "low");
4317
+ return {
4318
+ summary: `${findings.length} security finding(s) detected.`,
4319
+ comments: comments.slice(0, 15),
4320
+ severity: maxSeverity
4321
+ };
4322
+ }
4323
+ function postPrReview(repo, prNumber, sha, review) {
4324
+ const preferredEvent = review.severity === "critical" || review.severity === "high" ? "REQUEST_CHANGES" : "COMMENT";
4325
+ function tryPost(event) {
4326
+ const body = JSON.stringify({
4327
+ commit_id: sha,
4328
+ body: `## \u{1F512} Synkro Security Review
4329
+
4330
+ ${review.summary}`,
4331
+ event,
4332
+ comments: review.comments
4333
+ });
4334
+ try {
4335
+ execSync5(`gh api -X POST /repos/${repo}/pulls/${prNumber}/reviews --input -`, {
4336
+ encoding: "utf-8",
4337
+ input: body,
4338
+ stdio: ["pipe", "pipe", "pipe"]
4339
+ });
4340
+ console.log(` \u2713 Posted PR review (${event}).`);
4341
+ return true;
4342
+ } catch (err) {
4343
+ const stderr = err.stderr?.toString() || "";
4344
+ const stdout = err.stdout?.toString() || "";
4345
+ const combined = stderr + stdout;
4346
+ if (combined.includes("own pull request") && event === "REQUEST_CHANGES") {
4347
+ return false;
4348
+ }
4349
+ console.warn(`Failed to post review: ${(stderr || stdout || err.message).slice(0, 200)}`);
4350
+ return false;
4351
+ }
4352
+ }
4353
+ if (tryPost(preferredEvent)) return;
4354
+ if (preferredEvent === "REQUEST_CHANGES" && tryPost("COMMENT")) return;
4355
+ try {
4356
+ const fallbackBody = `## \u{1F512} Synkro Security Review
4357
+
4358
+ ${review.summary}
4359
+
4360
+ ` + review.comments.map((c) => `**${c.path}:${c.line}** \u2014 ${c.body}`).join("\n\n");
4361
+ execSync5(`gh api -X POST /repos/${repo}/issues/${prNumber}/comments --input -`, {
4362
+ encoding: "utf-8",
4363
+ input: JSON.stringify({ body: fallbackBody }),
4364
+ stdio: ["pipe", "ignore", "pipe"]
4365
+ });
4366
+ console.log(" \u2713 Posted fallback issue comment.");
4367
+ } catch (err2) {
4368
+ console.warn("Failed to post fallback comment:", err2.message);
4052
4369
  }
4053
4370
  }
4054
4371
  function postCheckRun(repo, sha, conclusion, findings) {
@@ -4065,7 +4382,7 @@ function postCheckRun(repo, sha, conclusion, findings) {
4065
4382
  }
4066
4383
  });
4067
4384
  try {
4068
- execSync4(`gh api -X POST /repos/${repo}/check-runs --input -`, {
4385
+ execSync5(`gh api -X POST /repos/${repo}/check-runs --input -`, {
4069
4386
  encoding: "utf-8",
4070
4387
  input: body,
4071
4388
  stdio: ["pipe", "ignore", "pipe"]
@@ -4173,8 +4490,13 @@ async function scanPrCommand() {
4173
4490
  console.log(`
4174
4491
  Total: ${allFindings.length} finding(s) across ${eligible.length} file(s) in ${totalLatencyMs}ms
4175
4492
  `);
4176
- for (const finding of allFindings) {
4177
- postPrComment(repo, prNumber, sha, finding);
4493
+ if (allFindings.length > 0) {
4494
+ console.log("Consolidating findings with Opus 4.7...");
4495
+ const review = await spawnOpusConsolidator(allFindings, claudeToken);
4496
+ console.log(` \u2192 ${review.comments.length} review comment(s), severity: ${review.severity}`);
4497
+ if (review.comments.length > 0) {
4498
+ postPrReview(repo, prNumber, sha, review);
4499
+ }
4178
4500
  }
4179
4501
  const conclusion = shouldFail(allFindings, failThreshold) ? "failure" : "success";
4180
4502
  postCheckRun(repo, sha, conclusion, allFindings);
@@ -4336,8 +4658,8 @@ for (const envPath of envCandidates) {
4336
4658
  const eqIndex = trimmed.indexOf("=");
4337
4659
  if (eqIndex <= 0) continue;
4338
4660
  const key = trimmed.slice(0, eqIndex).trim();
4339
- const value = trimmed.slice(eqIndex + 1).trim();
4340
- if (!process.env[key]) process.env[key] = value;
4661
+ const value = trimmed.slice(eqIndex + 1).trim().replace(/^['"]|['"]$/g, "");
4662
+ if (!process.env[key] && !value.startsWith("op://")) process.env[key] = value;
4341
4663
  }
4342
4664
  }
4343
4665
  var args = process.argv.slice(2);
@@ -4358,7 +4680,6 @@ Commands:
4358
4680
  link Link repos to a Synkro project (local git or GitHub OAuth)
4359
4681
  unlink Remove repo links from Synkro projects
4360
4682
  setup-github Configure GitHub PR scanning (push secrets + workflow file)
4361
- scan-pr Run a PR scan (used by GitHub Actions, not for direct invocation)
4362
4683
  update Refresh hook configs and judge prompts
4363
4684
  disconnect [--purge] Remove Synkro hooks from agents (--purge also removes ~/.synkro)
4364
4685
  uninstall Fully remove Synkro from this machine
@@ -4407,7 +4728,13 @@ async function main() {
4407
4728
  }
4408
4729
  case "setup-github": {
4409
4730
  const { setupGithubCommand: setupGithubCommand2 } = await Promise.resolve().then(() => (init_setupGithub(), setupGithub_exports));
4410
- await setupGithubCommand2();
4731
+ const ghOpts = {};
4732
+ for (const a of subArgs) {
4733
+ if (a === "--non-interactive") ghOpts.nonInteractive = true;
4734
+ else if (a === "--skip-claude-token") ghOpts.skipClaudeToken = true;
4735
+ else if (a.startsWith("--github-token=")) ghOpts.githubToken = a.slice("--github-token=".length);
4736
+ }
4737
+ await setupGithubCommand2(ghOpts);
4411
4738
  break;
4412
4739
  }
4413
4740
  case "scan-pr": {