@synkro-sh/cli 1.2.6 → 1.3.2

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
@@ -1,6 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  var __defProp = Object.defineProperty;
3
3
  var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
5
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
6
+ }) : x)(function(x) {
7
+ if (typeof require !== "undefined") return require.apply(this, arguments);
8
+ throw Error('Dynamic require of "' + x + '" is not supported');
9
+ });
4
10
  var __esm = (fn, res) => function __init() {
5
11
  return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
6
12
  };
@@ -96,7 +102,7 @@ function removeSynkroEntries(events, eventName) {
96
102
  if (!Array.isArray(arr)) return;
97
103
  events[eventName] = arr.filter((entry) => !isSynkroEntry(entry));
98
104
  }
99
- function installCCHooks(settingsPath, config) {
105
+ function installCCHooks(settingsPath, config2) {
100
106
  const settings = readSettings(settingsPath);
101
107
  settings.hooks = settings.hooks ?? {};
102
108
  removeSynkroEntries(settings.hooks, "PreToolUse");
@@ -109,11 +115,11 @@ function installCCHooks(settingsPath, config) {
109
115
  settings.hooks.SessionEnd = settings.hooks.SessionEnd ?? [];
110
116
  settings.hooks.SessionStart = settings.hooks.SessionStart ?? [];
111
117
  settings.hooks.PreToolUse.push({
112
- matcher: "Bash",
118
+ matcher: "Bash|Read|Grep|Glob",
113
119
  hooks: [
114
120
  {
115
121
  type: "command",
116
- command: config.bashJudgeScriptPath,
122
+ command: config2.bashJudgeScriptPath,
117
123
  timeout: 15
118
124
  }
119
125
  ],
@@ -124,7 +130,7 @@ function installCCHooks(settingsPath, config) {
124
130
  hooks: [
125
131
  {
126
132
  type: "command",
127
- command: config.editPrecheckScriptPath,
133
+ command: config2.editPrecheckScriptPath,
128
134
  timeout: 15
129
135
  }
130
136
  ],
@@ -135,7 +141,7 @@ function installCCHooks(settingsPath, config) {
135
141
  hooks: [
136
142
  {
137
143
  type: "command",
138
- command: config.editCaptureScriptPath,
144
+ command: config2.editCaptureScriptPath,
139
145
  timeout: 20
140
146
  }
141
147
  ],
@@ -146,7 +152,7 @@ function installCCHooks(settingsPath, config) {
146
152
  hooks: [
147
153
  {
148
154
  type: "command",
149
- command: config.bashFollowupScriptPath
155
+ command: config2.bashFollowupScriptPath
150
156
  }
151
157
  ],
152
158
  [SYNKRO_MARKER]: true
@@ -155,7 +161,7 @@ function installCCHooks(settingsPath, config) {
155
161
  hooks: [
156
162
  {
157
163
  type: "command",
158
- command: config.stopSummaryScriptPath
164
+ command: config2.stopSummaryScriptPath
159
165
  }
160
166
  ],
161
167
  [SYNKRO_MARKER]: true
@@ -164,7 +170,7 @@ function installCCHooks(settingsPath, config) {
164
170
  hooks: [
165
171
  {
166
172
  type: "command",
167
- command: config.sessionStartScriptPath
173
+ command: config2.sessionStartScriptPath
168
174
  }
169
175
  ],
170
176
  [SYNKRO_MARKER]: true
@@ -232,50 +238,50 @@ function readClaudeJson() {
232
238
  throw new Error(`Failed to parse ${CC_CONFIG_PATH}: ${err.message}`);
233
239
  }
234
240
  }
235
- function writeClaudeJsonAtomic(config) {
241
+ function writeClaudeJsonAtomic(config2) {
236
242
  mkdirSync2(dirname2(CC_CONFIG_PATH), { recursive: true });
237
243
  const tmpPath = `${CC_CONFIG_PATH}.synkro.tmp`;
238
- writeFileSync2(tmpPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
244
+ writeFileSync2(tmpPath, JSON.stringify(config2, null, 2) + "\n", "utf-8");
239
245
  renameSync2(tmpPath, CC_CONFIG_PATH);
240
246
  }
241
247
  function installMcpConfig(opts) {
242
- const config = readClaudeJson();
243
- config.mcpServers = config.mcpServers ?? {};
244
- for (const [name, entry] of Object.entries(config.mcpServers)) {
245
- if (entry?.[SYNKRO_MARKER2] === true) delete config.mcpServers[name];
248
+ const config2 = readClaudeJson();
249
+ config2.mcpServers = config2.mcpServers ?? {};
250
+ for (const [name, entry] of Object.entries(config2.mcpServers)) {
251
+ if (entry?.[SYNKRO_MARKER2] === true) delete config2.mcpServers[name];
246
252
  }
247
253
  const url = `${opts.gatewayUrl.replace(/\/$/, "")}/api/v1/mcp/guardrails`;
248
- config.mcpServers[SYNKRO_SERVER_NAME] = {
254
+ config2.mcpServers[SYNKRO_SERVER_NAME] = {
249
255
  type: "http",
250
256
  url,
251
257
  headers: { Authorization: `Bearer ${opts.bearerToken}` },
252
258
  [SYNKRO_MARKER2]: true
253
259
  };
254
- writeClaudeJsonAtomic(config);
260
+ writeClaudeJsonAtomic(config2);
255
261
  return { path: CC_CONFIG_PATH, url };
256
262
  }
257
263
  function uninstallMcpConfig() {
258
264
  if (!existsSync3(CC_CONFIG_PATH)) return false;
259
- const config = readClaudeJson();
260
- if (!config.mcpServers || Object.keys(config.mcpServers).length === 0) return false;
265
+ const config2 = readClaudeJson();
266
+ if (!config2.mcpServers || Object.keys(config2.mcpServers).length === 0) return false;
261
267
  let removed = false;
262
- for (const [name, entry] of Object.entries(config.mcpServers)) {
268
+ for (const [name, entry] of Object.entries(config2.mcpServers)) {
263
269
  if (entry?.[SYNKRO_MARKER2] === true) {
264
- delete config.mcpServers[name];
270
+ delete config2.mcpServers[name];
265
271
  removed = true;
266
272
  }
267
273
  }
268
274
  if (!removed) return false;
269
- if (Object.keys(config.mcpServers).length === 0) delete config.mcpServers;
270
- writeClaudeJsonAtomic(config);
275
+ if (Object.keys(config2.mcpServers).length === 0) delete config2.mcpServers;
276
+ writeClaudeJsonAtomic(config2);
271
277
  return true;
272
278
  }
273
279
  function inspectMcpConfig() {
274
280
  if (!existsSync3(CC_CONFIG_PATH)) {
275
281
  return { installed: false, configPath: CC_CONFIG_PATH };
276
282
  }
277
- const config = readClaudeJson();
278
- const entry = config.mcpServers?.[SYNKRO_SERVER_NAME];
283
+ const config2 = readClaudeJson();
284
+ const entry = config2.mcpServers?.[SYNKRO_SERVER_NAME];
279
285
  if (!entry || entry[SYNKRO_MARKER2] !== true) {
280
286
  return { installed: false, configPath: CC_CONFIG_PATH };
281
287
  }
@@ -336,14 +342,30 @@ if [ -z "$PAYLOAD" ]; then
336
342
  exit 0
337
343
  fi
338
344
 
339
- # Only run on Bash tool calls
345
+ # Translate tool calls into a command string for the judge
340
346
  TOOL_NAME=$(echo "$PAYLOAD" | jq -r '.tool_name // empty' 2>/dev/null)
341
- if [ "$TOOL_NAME" != "Bash" ]; then
342
- echo '{}'
343
- exit 0
344
- fi
345
-
346
- COMMAND=$(echo "$PAYLOAD" | jq -r '.tool_input.command // empty' 2>/dev/null)
347
+ case "$TOOL_NAME" in
348
+ Bash)
349
+ COMMAND=$(echo "$PAYLOAD" | jq -r '.tool_input.command // empty' 2>/dev/null)
350
+ ;;
351
+ Read)
352
+ FILE_PATH=$(echo "$PAYLOAD" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
353
+ COMMAND="cat \${FILE_PATH}"
354
+ ;;
355
+ Grep)
356
+ PATTERN=$(echo "$PAYLOAD" | jq -r '.tool_input.pattern // empty' 2>/dev/null)
357
+ GREP_PATH=$(echo "$PAYLOAD" | jq -r '.tool_input.path // "."' 2>/dev/null)
358
+ COMMAND="grep -r '\${PATTERN}' \${GREP_PATH}"
359
+ ;;
360
+ Glob)
361
+ PATTERN=$(echo "$PAYLOAD" | jq -r '.tool_input.pattern // empty' 2>/dev/null)
362
+ COMMAND="find . -name '\${PATTERN}'"
363
+ ;;
364
+ *)
365
+ echo '{}'
366
+ exit 0
367
+ ;;
368
+ esac
347
369
  if [ -z "$COMMAND" ]; then
348
370
  echo '{}'
349
371
  exit 0
@@ -364,7 +386,7 @@ TRANSCRIPT_PATH=$(echo "$PAYLOAD" | jq -r '.transcript_path // empty' 2>/dev/nul
364
386
  SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null)
365
387
  TOOL_USE_ID=$(echo "$PAYLOAD" | jq -r '.tool_use_id // empty' 2>/dev/null)
366
388
  CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
367
- TOOL_INPUT=$(echo "$PAYLOAD" | jq -c '.tool_input // {}' 2>/dev/null)
389
+ TOOL_INPUT=$(echo "$PAYLOAD" | jq -c --arg cmd "$COMMAND" '.tool_input // {} | . + {command: $cmd}' 2>/dev/null)
368
390
  # Detect git remote origin \u2192 repo identity (e.g. "owner/repo")
369
391
  GIT_REPO=""
370
392
  if command -v git >/dev/null 2>&1; then
@@ -384,11 +406,9 @@ if [ "\${SYNKRO_HEADLESS:-0}" = "1" ]; then IS_HEADLESS=1; fi
384
406
 
385
407
  USER_INTENT=""
386
408
  RECENT_USER_MESSAGES="[]"
409
+ RECENT_MESSAGES="[]"
387
410
  RECENT_ACTIONS="[]"
388
411
  if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
389
- # Last 5 user-role messages, oldest first. Lets the grader see consent
390
- # that carried over from a recent prior turn \u2014 saying "i consent" two
391
- # turns ago should not require re-prompting on this turn's command.
392
412
  RECENT_USER_MESSAGES=$(tail -400 "$TRANSCRIPT_PATH" | jq -c -s '
393
413
  [.[]
394
414
  | select(.type == "user")
@@ -399,16 +419,78 @@ if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
399
419
  | select(. != null and . != "")
400
420
  ] | .[-5:]' 2>/dev/null || echo "[]")
401
421
  USER_INTENT=$(echo "$RECENT_USER_MESSAGES" | jq -r '.[-1] // ""' 2>/dev/null || echo "")
402
- # Recent agent actions (last 5 tool_use blocks)
403
- RECENT_ACTIONS=$(tail -200 "$TRANSCRIPT_PATH" | jq -c -s '
422
+ # Interleaved assistant+user messages \u2014 lets the grader see what question
423
+ # each "yes" was answering (assistant text before user reply).
424
+ RECENT_MESSAGES=$(tail -400 "$TRANSCRIPT_PATH" | jq -c -s '
404
425
  [.[]
426
+ | select(.type == "assistant" or .type == "user")
427
+ | {
428
+ role: .type,
429
+ text: (
430
+ if .type == "assistant" then
431
+ [.message.content[]? | select(type == "object" and .type == "text") | .text // ""] | join(" ") | .[0:500]
432
+ else
433
+ (.message.content
434
+ | if type == "string" then .[0:500]
435
+ else ([.[]? | if type == "string" then . elif (type == "object" and .type == "text") then (.text // "") else "" end] | join(" ") | .[0:500])
436
+ end)
437
+ end
438
+ )
439
+ }
440
+ | select(.text != "" and .text != null and (.text | length) > 0)
441
+ ] | .[-10:]' 2>/dev/null || echo "[]")
442
+ # Recent agent actions (last 5 tool_use blocks paired with results)
443
+ RECENT_ACTIONS=$(tail -400 "$TRANSCRIPT_PATH" | jq -c -s '
444
+ # tool_result blocks live in USER messages (Anthropic API format)
445
+ ([ .[]
446
+ | select(.type == "user")
447
+ | .message.content[]?
448
+ | select(type == "object" and .type == "tool_result")
449
+ | { (.tool_use_id): (.content // "" | tostring | .[0:300]) }
450
+ ] | add // {}) as $results
451
+ |
452
+ [ .[]
405
453
  | select(.type == "assistant")
406
454
  | .message.content[]?
407
455
  | select(.type == "tool_use")
408
- | { tool: .name, input: (.input // {} | tostring | .[0:200]) }
456
+ | {
457
+ tool: .name,
458
+ input: (.input // {} | tostring | .[0:200]),
459
+ result: ($results[.id] // null)
460
+ }
409
461
  ] | .[-5:]' 2>/dev/null || echo "[]")
410
462
  fi
411
463
 
464
+ CC_MODEL=""
465
+ CC_USAGE="{}"
466
+ if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
467
+ _LAST_ASSISTANT=$(tail -50 "$TRANSCRIPT_PATH" | jq -c 'select(.type == "assistant")' 2>/dev/null | tail -1)
468
+ if [ -n "$_LAST_ASSISTANT" ]; then
469
+ CC_MODEL=$(echo "$_LAST_ASSISTANT" | jq -r '.message.model // empty' 2>/dev/null)
470
+ CC_USAGE=$(echo "$_LAST_ASSISTANT" | jq -c '{
471
+ input_tokens: .message.usage.input_tokens,
472
+ output_tokens: .message.usage.output_tokens,
473
+ cache_creation_input_tokens: .message.usage.cache_creation_input_tokens,
474
+ cache_read_input_tokens: .message.usage.cache_read_input_tokens,
475
+ service_tier: .message.usage.service_tier,
476
+ speed: .message.usage.speed
477
+ }' 2>/dev/null || echo "{}")
478
+ fi
479
+ fi
480
+
481
+ # Extract session summary from CC compaction (free broad context)
482
+ SESSION_SUMMARY=""
483
+ if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
484
+ _SUMMARY_LINE=$(grep -n '"This session is being continued' "$TRANSCRIPT_PATH" 2>/dev/null | tail -1 | cut -d: -f1)
485
+ if [ -n "$_SUMMARY_LINE" ]; then
486
+ SESSION_SUMMARY=$(sed -n "\${_SUMMARY_LINE}p" "$TRANSCRIPT_PATH" | jq -r '
487
+ .message.content
488
+ | if type == "string" then .[0:2000]
489
+ else ([.[]? | if type == "string" then . elif (type == "object" and .type == "text") then (.text // "") else "" end] | join(" ") | .[0:2000])
490
+ end' 2>/dev/null || echo "")
491
+ fi
492
+ fi
493
+
412
494
  # Build POST body \u2014 always emit all fields (use null for empty optionals)
413
495
  # Earlier version used \`select(length > 0)\` which made the entire object
414
496
  # evaluate to nothing when any optional was empty. Don't do that.
@@ -416,21 +498,29 @@ BODY=$(jq -n \\
416
498
  --argjson tool_input "$TOOL_INPUT" \\
417
499
  --arg user_intent "$USER_INTENT" \\
418
500
  --argjson recent_user_messages "$RECENT_USER_MESSAGES" \\
501
+ --argjson recent_messages "$RECENT_MESSAGES" \\
419
502
  --argjson recent_actions "$RECENT_ACTIONS" \\
420
503
  --arg session_id "$SESSION_ID" \\
421
504
  --arg tool_use_id "$TOOL_USE_ID" \\
422
505
  --arg cwd "$CWD" \\
423
506
  --arg repo "$GIT_REPO" \\
507
+ --arg cc_model "$CC_MODEL" \\
508
+ --argjson cc_usage "$CC_USAGE" \\
509
+ --arg session_summary "$SESSION_SUMMARY" \\
424
510
  '{
425
511
  kind: "bash_judge",
426
512
  tool_input: $tool_input,
427
513
  user_intent: (if ($user_intent | length) > 0 then $user_intent else null end),
428
514
  recent_user_messages: $recent_user_messages,
515
+ recent_messages: $recent_messages,
429
516
  recent_actions: $recent_actions,
430
517
  session_id: (if ($session_id | length) > 0 then $session_id else null end),
431
518
  tool_use_id: (if ($tool_use_id | length) > 0 then $tool_use_id else null end),
432
519
  cwd: (if ($cwd | length) > 0 then $cwd else null end),
433
- repo: (if ($repo | length) > 0 then $repo else null end)
520
+ repo: (if ($repo | length) > 0 then $repo else null end),
521
+ cc_model: (if ($cc_model | length) > 0 then $cc_model else null end),
522
+ cc_usage: $cc_usage,
523
+ session_summary: (if ($session_summary | length) > 0 then $session_summary else null end)
434
524
  }')
435
525
 
436
526
  # Helper: refresh JWT via /api/auth/refresh and rewrite credentials.json.
@@ -546,23 +636,15 @@ if [ -z "$VERDICT" ]; then
546
636
  fi
547
637
 
548
638
  # Parse verdict \u2014 fail open on any parse error
549
- SEVERITY=$(echo "$VERDICT" | jq -r '.severity // "medium"' 2>/dev/null)
639
+ SEVERITY=$(echo "$VERDICT" | jq -r '.severity // "audit"' 2>/dev/null)
550
640
  VERDICT_KIND=$(echo "$VERDICT" | jq -r '.verdict // "warn"' 2>/dev/null)
551
641
  REASONING=$(echo "$VERDICT" | jq -r '.reasoning // "matched dangerous-verb regex"' 2>/dev/null)
552
642
  ALTERNATIVE=$(echo "$VERDICT" | jq -r '.alternative // ""' 2>/dev/null)
553
643
  CATEGORY=$(echo "$VERDICT" | jq -r '.category // "destructive_command"' 2>/dev/null)
554
644
 
555
645
  # Severity-driven surfacing:
556
- # critical \u2192 permissionDecision: "deny" \u2014 block outright
557
- # high \u2192 permissionDecision: "ask" \u2014 force user confirmation
558
- # medium \u2192 silent allow (echo {}) \u2014 Cerebras saw it, judged the
559
- # intent fits, no surface
560
- # low \u2192 silent allow (echo {}) \u2014 same
561
- #
562
- # The grader is fully context-aware now (intent + recent_actions + shape
563
- # labels), so its severity grade is trustworthy. Low/medium decisions don't
564
- # need to interrupt the user \u2014 surfacing them creates alert fatigue and
565
- # trains the user to click-through on warnings that turn out to be benign.
646
+ # block \u2192 permissionDecision: "ask" (interactive) or "deny" (headless)
647
+ # audit \u2192 silent allow \u2014 logged but no interruption
566
648
 
567
649
  ALT_SUFFIX=""
568
650
  if [ -n "$ALTERNATIVE" ] && [ "$ALTERNATIVE" != "null" ]; then
@@ -570,26 +652,9 @@ if [ -n "$ALTERNATIVE" ] && [ "$ALTERNATIVE" != "null" ]; then
570
652
  fi
571
653
 
572
654
  case "$SEVERITY" in
573
- critical)
574
- synkro_log "bashGuard $CMD_SHORT \u2192 BLOCKED ($CATEGORY)"
575
- PERMISSION_REASON="[synkro] BLOCKED \u2014 \${REASONING}\${ALT_SUFFIX}"
576
- ADDITIONAL_CTX="Synkro safety judge (severity: critical, category: \${CATEGORY}).\${ALT_SUFFIX}"
577
- jq -n \\
578
- --arg ctx "$ADDITIONAL_CTX" \\
579
- --arg reason "$PERMISSION_REASON" \\
580
- '{
581
- hookSpecificOutput: {
582
- hookEventName: "PreToolUse",
583
- permissionDecision: "deny",
584
- permissionDecisionReason: $reason,
585
- additionalContext: $ctx
586
- }
587
- }'
588
- ;;
589
- high)
590
- synkro_log "bashGuard $CMD_SHORT \u2192 FLAGGED ($CATEGORY)"
655
+ block)
591
656
  PERMISSION_REASON="[synkro] \${REASONING}\${ALT_SUFFIX}"
592
- ADDITIONAL_CTX="Synkro safety judge (severity: high, category: \${CATEGORY}).\${ALT_SUFFIX}"
657
+ ADDITIONAL_CTX="Synkro safety judge (severity: \${SEVERITY}, category: \${CATEGORY}). Reasoning: \${REASONING}.\${ALT_SUFFIX}"
593
658
  if [ "$IS_HEADLESS" = "1" ]; then DECISION="deny"; else DECISION="ask"; fi
594
659
  jq -n \\
595
660
  --arg ctx "$ADDITIONAL_CTX" \\
@@ -604,7 +669,7 @@ case "$SEVERITY" in
604
669
  }
605
670
  }'
606
671
  ;;
607
- *)
672
+ audit)
608
673
  synkro_log "bashGuard $CMD_SHORT \u2192 pass ($CATEGORY): $REASONING"
609
674
  case "$CATEGORY" in
610
675
  trivial_utility)
@@ -615,6 +680,21 @@ case "$SEVERITY" in
615
680
  jq -n --arg m "[synkro] bashGuard \u2192 pass ($CATEGORY): $REASONING" '{systemMessage: $m}' ;;
616
681
  esac
617
682
  ;;
683
+ *)
684
+ synkro_log "bashGuard $CMD_SHORT \u2192 UNEXPECTED SEVERITY ($SEVERITY), blocking by default"
685
+ if [ "$IS_HEADLESS" = "1" ]; then DECISION="deny"; else DECISION="ask"; fi
686
+ jq -n \\
687
+ --arg decision "$DECISION" \\
688
+ --arg reason "[synkro] unexpected severity '\${SEVERITY}' \u2014 blocking by default. Please email team@synkro.sh to report this issue." \\
689
+ '{
690
+ hookSpecificOutput: {
691
+ hookEventName: "PreToolUse",
692
+ permissionDecision: $decision,
693
+ permissionDecisionReason: $reason,
694
+ additionalContext: "Synkro safety judge returned an unexpected severity value. This command has been blocked as a precaution. Please email team@synkro.sh with details of the command you were running."
695
+ }
696
+ }'
697
+ ;;
618
698
  esac
619
699
 
620
700
  exit 0
@@ -983,8 +1063,14 @@ if [ "$DECISION" = "deny" ]; then
983
1063
  synkro_log "editGuard $FILE_SHORT \u2192 BLOCKED: $DENY_REASON"
984
1064
  echo "$RESP"
985
1065
  else
986
- synkro_log "editGuard $FILE_SHORT \u2192 pass"
987
- RESP_WITH_MSG=$(echo "$RESP" | jq --arg m "[synkro] editGuard $FILE_SHORT \u2192 pass" '. + {systemMessage: $m}')
1066
+ VERDICT_REASON=$(echo "$RESP" | jq -r '.reason // empty' 2>/dev/null)
1067
+ if [ -n "$VERDICT_REASON" ]; then
1068
+ synkro_log "editGuard $FILE_SHORT \u2192 pass: $VERDICT_REASON"
1069
+ RESP_WITH_MSG=$(echo "$RESP" | jq --arg m "[synkro] editGuard $FILE_SHORT \u2192 pass: $VERDICT_REASON" '. + {systemMessage: $m}')
1070
+ else
1071
+ synkro_log "editGuard $FILE_SHORT \u2192 pass"
1072
+ RESP_WITH_MSG=$(echo "$RESP" | jq --arg m "[synkro] editGuard $FILE_SHORT \u2192 pass" '. + {systemMessage: $m}')
1073
+ fi
988
1074
  echo "$RESP_WITH_MSG"
989
1075
  fi
990
1076
 
@@ -1232,8 +1318,13 @@ if [ "$OK" = "false" ] && [ -n "$REASON" ]; then
1232
1318
  exit 0
1233
1319
  fi
1234
1320
 
1235
- synkro_log "editScan $BASENAME \u2192 pass"
1236
- jq -n --arg m "[synkro] editScan $BASENAME \u2192 pass" '{systemMessage: $m}'
1321
+ if [ -n "$REASON" ]; then
1322
+ synkro_log "editScan $BASENAME \u2192 pass ($CATEGORY): $REASON"
1323
+ jq -n --arg m "[synkro] editScan $BASENAME \u2192 pass ($CATEGORY): $REASON" '{systemMessage: $m}'
1324
+ else
1325
+ synkro_log "editScan $BASENAME \u2192 pass"
1326
+ jq -n --arg m "[synkro] editScan $BASENAME \u2192 pass" '{systemMessage: $m}'
1327
+ fi
1237
1328
  exit 0
1238
1329
  `;
1239
1330
  CC_STOP_SUMMARY_SCRIPT = `#!/bin/bash
@@ -1335,8 +1426,17 @@ if [ -z "$CWD" ]; then
1335
1426
  exit 0
1336
1427
  fi
1337
1428
 
1429
+ GIT_REPO=""
1430
+ if command -v git >/dev/null 2>&1; then
1431
+ _REMOTE=$(git -C "\${CWD:-.}" remote get-url origin 2>/dev/null || true)
1432
+ if [ -n "$_REMOTE" ]; then
1433
+ GIT_REPO=$(echo "$_REMOTE" | sed -E 's|^git@[^:]+:||; s|^https?://[^/]+/||; s|\\.git$||')
1434
+ fi
1435
+ fi
1436
+
1338
1437
  RESP=$(curl -sS -G "\${GATEWAY_URL}/api/v1/cli/session-context" \\
1339
1438
  --data-urlencode "cwd=$CWD" \\
1439
+ --data-urlencode "repo=$GIT_REPO" \\
1340
1440
  -H "Authorization: Bearer $JWT" \\
1341
1441
  --max-time 2 2>/dev/null || echo "")
1342
1442
 
@@ -2075,12 +2175,64 @@ function getAccessToken() {
2075
2175
  const creds = loadCredentials();
2076
2176
  return creds?.access_token || null;
2077
2177
  }
2178
+ function isTokenExpired() {
2179
+ const creds = loadCredentials();
2180
+ if (!creds) return true;
2181
+ try {
2182
+ const decoded = jwt.decode(creds.access_token);
2183
+ if (!decoded?.exp) return true;
2184
+ const expiresAt = decoded.exp * 1e3;
2185
+ const buffer = 5 * 60 * 1e3;
2186
+ return Date.now() > expiresAt - buffer;
2187
+ } catch {
2188
+ return true;
2189
+ }
2190
+ }
2191
+ async function refreshToken() {
2192
+ const creds = loadCredentials();
2193
+ if (!creds?.refresh_token) return false;
2194
+ try {
2195
+ const response = await fetch(`${SYNKRO_WEB_AUTH_URL}/api/auth/refresh`, {
2196
+ method: "POST",
2197
+ headers: { "Content-Type": "application/json" },
2198
+ body: JSON.stringify({ refresh_token: creds.refresh_token })
2199
+ });
2200
+ if (!response.ok) return false;
2201
+ const data = await response.json();
2202
+ if (data.access_token) {
2203
+ saveCredentials({
2204
+ access_token: data.access_token,
2205
+ refresh_token: data.refresh_token || creds.refresh_token
2206
+ });
2207
+ return true;
2208
+ }
2209
+ return false;
2210
+ } catch {
2211
+ return false;
2212
+ }
2213
+ }
2214
+ async function ensureValidToken() {
2215
+ if (!isAuthenticated()) return false;
2216
+ if (isTokenExpired()) {
2217
+ if (!refreshPromise) {
2218
+ refreshPromise = refreshToken().finally(() => {
2219
+ refreshPromise = null;
2220
+ });
2221
+ }
2222
+ const refreshed = await refreshPromise;
2223
+ if (!refreshed) {
2224
+ clearCredentials();
2225
+ return false;
2226
+ }
2227
+ }
2228
+ return true;
2229
+ }
2078
2230
  function clearCredentials() {
2079
2231
  if (existsSync4(AUTH_FILE)) {
2080
2232
  unlinkSync2(AUTH_FILE);
2081
2233
  }
2082
2234
  }
2083
- var PORT, RAW_WEB_AUTH_URL, SYNKRO_WEB_AUTH_URL, AUTH_FILE, ERROR_HTML;
2235
+ var PORT, RAW_WEB_AUTH_URL, SYNKRO_WEB_AUTH_URL, AUTH_FILE, ERROR_HTML, refreshPromise;
2084
2236
  var init_stub = __esm({
2085
2237
  "cli/auth/stub.ts"() {
2086
2238
  "use strict";
@@ -2128,147 +2280,550 @@ var init_stub = __esm({
2128
2280
  </body>
2129
2281
  </html>
2130
2282
  `;
2283
+ refreshPromise = null;
2131
2284
  }
2132
2285
  });
2133
2286
 
2134
- // cli/commands/install.ts
2135
- var install_exports = {};
2136
- __export(install_exports, {
2137
- installCommand: () => installCommand,
2138
- parseArgs: () => parseArgs
2139
- });
2140
- import { existsSync as existsSync5, mkdirSync as mkdirSync4, writeFileSync as writeFileSync4, chmodSync, readFileSync as readFileSync4 } from "fs";
2141
- import { homedir as homedir4 } from "os";
2142
- import { join as join4 } from "path";
2143
- function sanitizeGatewayCandidate(raw) {
2144
- if (!raw) return void 0;
2145
- return /^https?:\/\//.test(raw) ? raw : void 0;
2146
- }
2147
- function parseArgs(argv) {
2148
- const opts = {};
2149
- for (const a of argv) {
2150
- if (a.startsWith("--api-key=")) opts.apiKey = a.slice("--api-key=".length);
2151
- else if (a.startsWith("--gateway=")) opts.gatewayUrl = a.slice("--gateway=".length);
2152
- else if (a === "--skip-auth") opts.skipAuth = true;
2153
- else if (a === "--no-mcp") opts.noMcp = true;
2154
- else if (a === "--force" || a === "-f") opts.force = true;
2287
+ // cli/auth/index.ts
2288
+ var init_auth = __esm({
2289
+ "cli/auth/index.ts"() {
2290
+ "use strict";
2291
+ init_stub();
2155
2292
  }
2156
- if (!opts.gatewayUrl) {
2157
- const fromEnv = sanitizeGatewayCandidate(process.env.SYNKRO_GATEWAY_URL);
2158
- if (fromEnv) opts.gatewayUrl = fromEnv;
2293
+ });
2294
+
2295
+ // cli/api/projects.ts
2296
+ import { config } from "dotenv";
2297
+ async function callApi(method, endpoint, body) {
2298
+ if (!API_URL) {
2299
+ throw new Error("SYNKRO_CRUD_URL (or SYNKRO_API_URL) is not set. Add it to your .env file.");
2159
2300
  }
2160
- return opts;
2161
- }
2162
- function ensureSynkroDir() {
2163
- mkdirSync4(SYNKRO_DIR, { recursive: true });
2164
- mkdirSync4(HOOKS_DIR, { recursive: true });
2165
- mkdirSync4(BIN_DIR, { recursive: true });
2166
- }
2167
- function writeGraderDaemon() {
2168
- writeFileSync4(GRADER_DAEMON_PATH, GRADER_DAEMON_PY, "utf-8");
2169
- chmodSync(GRADER_DAEMON_PATH, 493);
2170
- writeFileSync4(GRADER_PRIMER_EDIT_PATH, GRADER_PRIMER_EDIT, "utf-8");
2171
- chmodSync(GRADER_PRIMER_EDIT_PATH, 420);
2172
- writeFileSync4(GRADER_PRIMER_BASH_PATH, GRADER_PRIMER_BASH, "utf-8");
2173
- chmodSync(GRADER_PRIMER_BASH_PATH, 420);
2174
- }
2175
- function writeHookScripts() {
2176
- const bashScriptPath = join4(HOOKS_DIR, "cc-bash-judge.sh");
2177
- const bashFollowupScriptPath = join4(HOOKS_DIR, "cc-bash-followup.sh");
2178
- const editCaptureScriptPath = join4(HOOKS_DIR, "cc-edit-capture.sh");
2179
- const editPrecheckScriptPath = join4(HOOKS_DIR, "cc-edit-precheck.sh");
2180
- const stopSummaryScriptPath = join4(HOOKS_DIR, "cc-stop-summary.sh");
2181
- const sessionStartScriptPath = join4(HOOKS_DIR, "cc-session-start.sh");
2182
- writeFileSync4(bashScriptPath, CC_BASH_JUDGE_SCRIPT, "utf-8");
2183
- writeFileSync4(bashFollowupScriptPath, CC_BASH_FOLLOWUP_SCRIPT, "utf-8");
2184
- writeFileSync4(editCaptureScriptPath, CC_EDIT_CAPTURE_SCRIPT, "utf-8");
2185
- writeFileSync4(editPrecheckScriptPath, CC_EDIT_PRECHECK_SCRIPT, "utf-8");
2186
- writeFileSync4(stopSummaryScriptPath, CC_STOP_SUMMARY_SCRIPT, "utf-8");
2187
- writeFileSync4(sessionStartScriptPath, CC_SESSION_START_SCRIPT, "utf-8");
2188
- chmodSync(bashScriptPath, 493);
2189
- chmodSync(bashFollowupScriptPath, 493);
2190
- chmodSync(editCaptureScriptPath, 493);
2191
- chmodSync(editPrecheckScriptPath, 493);
2192
- chmodSync(stopSummaryScriptPath, 493);
2193
- chmodSync(sessionStartScriptPath, 493);
2194
- return {
2195
- bashScript: bashScriptPath,
2196
- bashFollowupScript: bashFollowupScriptPath,
2197
- editCaptureScript: editCaptureScriptPath,
2198
- editPrecheckScript: editPrecheckScriptPath,
2199
- stopSummaryScript: stopSummaryScriptPath,
2200
- sessionStartScript: sessionStartScriptPath
2301
+ const url = `${API_URL}${endpoint}`;
2302
+ const accessToken = getAccessToken();
2303
+ const headers = {
2304
+ "Content-Type": "application/json"
2201
2305
  };
2306
+ if (accessToken) {
2307
+ headers["Authorization"] = `Bearer ${accessToken}`;
2308
+ }
2309
+ const response = await fetch(url, {
2310
+ method,
2311
+ headers,
2312
+ body: body ? JSON.stringify(body) : void 0
2313
+ });
2314
+ if (!response.ok) {
2315
+ const error = await response.json().catch(() => ({ detail: response.statusText }));
2316
+ throw new Error(error.detail || `API error: ${response.status}`);
2317
+ }
2318
+ return response.json();
2202
2319
  }
2203
- function sanitizeConfigValue(raw, maxLen = 256) {
2204
- if (!raw) return "";
2205
- return raw.replace(/[^\x20-\x7E]/g, "").slice(0, maxLen);
2320
+ async function createProject(name, repos) {
2321
+ const body = { name };
2322
+ if (repos && repos.length > 0) body.repos = repos;
2323
+ return callApi("POST", "/projects", body);
2206
2324
  }
2207
- function shellQuoteSingle(value) {
2208
- return `'${value.replace(/'/g, "'\\''")}'`;
2325
+ async function listProjects() {
2326
+ return callApi("GET", "/projects");
2209
2327
  }
2210
- function writeConfigEnv(opts) {
2211
- const credsPath = join4(SYNKRO_DIR, "credentials.json");
2212
- const safeGateway = sanitizeConfigValue(opts.gatewayUrl);
2213
- const safeUserId = sanitizeConfigValue(opts.userId);
2214
- const safeOrgId = sanitizeConfigValue(opts.orgId);
2215
- const safeEmail = sanitizeConfigValue(opts.email);
2216
- const safeTier = sanitizeConfigValue(opts.tier ?? "pro", 32);
2217
- const lines = [
2218
- "# Synkro CLI config (managed by synkro install)",
2219
- "# JWT auth \u2014 the hook scripts read SYNKRO_CREDENTIALS_PATH at runtime",
2220
- "# and send Authorization: Bearer <access_token> on every gateway call.",
2221
- `SYNKRO_GATEWAY_URL=${shellQuoteSingle(safeGateway)}`,
2222
- `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
2223
- `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
2224
- `SYNKRO_VERSION=${shellQuoteSingle("1.2.6")}`
2225
- ];
2226
- if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
2227
- if (safeOrgId) lines.push(`SYNKRO_ORG_ID=${shellQuoteSingle(safeOrgId)}`);
2228
- if (safeEmail) lines.push(`SYNKRO_EMAIL=${shellQuoteSingle(safeEmail)}`);
2229
- lines.push("");
2230
- writeFileSync4(CONFIG_PATH, lines.join("\n"), "utf-8");
2231
- chmodSync(CONFIG_PATH, 384);
2328
+ async function unlinkRepo(projectId, repoId) {
2329
+ return callApi("DELETE", `/projects/${projectId}/repos/${repoId}`);
2232
2330
  }
2233
- function assertGatewayAllowed(gatewayUrl) {
2234
- let parsed;
2235
- try {
2236
- parsed = new URL(gatewayUrl);
2237
- } catch {
2238
- throw new Error(`Invalid gateway URL: ${gatewayUrl}`);
2239
- }
2240
- const proto = parsed.protocol;
2241
- const host = parsed.hostname;
2242
- if (proto !== "http:" && proto !== "https:") {
2243
- throw new Error(`Gateway URL must be http(s); got ${proto}`);
2244
- }
2245
- const isLocalhost = host === "localhost" || host === "127.0.0.1" || host === "::1";
2246
- const isSynkro = host === "synkro.sh" || host.endsWith(".synkro.sh");
2247
- if (proto === "http:" && !isLocalhost) {
2248
- throw new Error(`Gateway URL must be HTTPS for non-localhost hosts; got ${gatewayUrl}`);
2331
+ var API_URL;
2332
+ var init_projects = __esm({
2333
+ "cli/api/projects.ts"() {
2334
+ "use strict";
2335
+ init_auth();
2336
+ config({ quiet: true });
2337
+ API_URL = process.env.SYNKRO_CRUD_URL || process.env.SYNKRO_API_URL;
2249
2338
  }
2250
- if (!isLocalhost && !isSynkro) {
2251
- throw new Error(`Gateway host not in allowlist (synkro.sh or *.synkro.sh): ${host}`);
2339
+ });
2340
+
2341
+ // cli/installer/workflowTemplate.ts
2342
+ var SYNKRO_WORKFLOW_YAML, WORKFLOW_PATH;
2343
+ var init_workflowTemplate = __esm({
2344
+ "cli/installer/workflowTemplate.ts"() {
2345
+ "use strict";
2346
+ SYNKRO_WORKFLOW_YAML = `name: Synkro Security Review
2347
+ on:
2348
+ pull_request:
2349
+ types: [opened, synchronize, reopened]
2350
+
2351
+ jobs:
2352
+ scan:
2353
+ runs-on: ubuntu-latest
2354
+ permissions:
2355
+ contents: read
2356
+ pull-requests: write
2357
+ checks: write
2358
+ steps:
2359
+ - uses: actions/checkout@v4
2360
+ with:
2361
+ fetch-depth: 0
2362
+
2363
+ - name: Cache npm globals
2364
+ id: cache-npm-global
2365
+ uses: actions/cache@v4
2366
+ with:
2367
+ path: ~/.npm-global
2368
+ key: synkro-cli-\${{ runner.os }}-v1
2369
+
2370
+ - name: Install Synkro CLI + Claude Code CLI
2371
+ run: |
2372
+ npm config set prefix ~/.npm-global
2373
+ npm install -g @synkro-sh/cli @anthropic-ai/claude-code
2374
+ echo "~/.npm-global/bin" >> $GITHUB_PATH
2375
+
2376
+ - name: Run Synkro PR scan
2377
+ run: synkro-cli scan-pr
2378
+ env:
2379
+ CLAUDE_CODE_OAUTH_TOKEN: \${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
2380
+ SYNKRO_API_KEY: \${{ secrets.SYNKRO_API_KEY }}
2381
+ GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
2382
+ SYNKRO_PR_NUMBER: \${{ github.event.pull_request.number }}
2383
+ SYNKRO_REPO: \${{ github.repository }}
2384
+ SYNKRO_SHA: \${{ github.event.pull_request.head.sha }}
2385
+ SYNKRO_GATEWAY_URL: \${{ vars.SYNKRO_GATEWAY_URL || 'https://api.synkro.sh' }}
2386
+ `;
2387
+ WORKFLOW_PATH = ".github/workflows/synkro.yml";
2252
2388
  }
2389
+ });
2390
+
2391
+ // cli/installer/githubSetup.ts
2392
+ import { existsSync as existsSync5, mkdirSync as mkdirSync4, writeFileSync as writeFileSync4 } from "fs";
2393
+ import { join as join4 } from "path";
2394
+ async function encryptSecret(publicKeyBase64, secret) {
2395
+ const sodium = await import("libsodium-wrappers").then((m) => m.default ?? m);
2396
+ await sodium.ready;
2397
+ const keyBytes = sodium.from_base64(publicKeyBase64, sodium.base64_variants.ORIGINAL);
2398
+ const messageBytes = sodium.from_string(secret);
2399
+ const encryptedBytes = sodium.crypto_box_seal(messageBytes, keyBytes);
2400
+ return sodium.to_base64(encryptedBytes, sodium.base64_variants.ORIGINAL);
2253
2401
  }
2254
- function isAlreadyInstalled() {
2255
- const requiredScripts = [
2256
- join4(HOOKS_DIR, "cc-bash-judge.sh"),
2257
- join4(HOOKS_DIR, "cc-bash-followup.sh"),
2258
- join4(HOOKS_DIR, "cc-edit-precheck.sh"),
2259
- join4(HOOKS_DIR, "cc-edit-capture.sh"),
2260
- join4(HOOKS_DIR, "cc-stop-summary.sh"),
2261
- join4(HOOKS_DIR, "cc-session-start.sh")
2262
- ];
2263
- if (!requiredScripts.every((p) => existsSync5(p))) return false;
2264
- if (!existsSync5(CONFIG_PATH)) return false;
2265
- const settingsPath = join4(homedir4(), ".claude", "settings.json");
2266
- if (!existsSync5(settingsPath)) return false;
2267
- try {
2268
- const settings = JSON.parse(readFileSync4(settingsPath, "utf-8"));
2269
- const hooks = settings?.hooks;
2270
- if (!hooks || typeof hooks !== "object") return false;
2271
- const hasManaged = (kind) => Array.isArray(hooks[kind]) && hooks[kind].some((entry) => entry?.__synkro_managed__ === true);
2402
+ async function getRepoPublicKey(opts, owner, repo) {
2403
+ const url = `https://api.github.com/repos/${owner}/${repo}/actions/secrets/public-key`;
2404
+ const resp = await fetch(url, {
2405
+ headers: {
2406
+ Authorization: `Bearer ${opts.token}`,
2407
+ Accept: "application/vnd.github+json",
2408
+ "X-GitHub-Api-Version": "2022-11-28"
2409
+ }
2410
+ });
2411
+ if (!resp.ok) {
2412
+ const text = await resp.text().catch(() => "");
2413
+ throw new Error(`GitHub API ${resp.status} fetching public key for ${owner}/${repo}: ${text.slice(0, 200)}`);
2414
+ }
2415
+ return await resp.json();
2416
+ }
2417
+ async function putRepoSecret(opts, owner, repo, secretName, secretValue, publicKey) {
2418
+ const encryptedValue = await encryptSecret(publicKey.key, secretValue);
2419
+ const url = `https://api.github.com/repos/${owner}/${repo}/actions/secrets/${encodeURIComponent(secretName)}`;
2420
+ const resp = await fetch(url, {
2421
+ method: "PUT",
2422
+ headers: {
2423
+ Authorization: `Bearer ${opts.token}`,
2424
+ Accept: "application/vnd.github+json",
2425
+ "X-GitHub-Api-Version": "2022-11-28",
2426
+ "Content-Type": "application/json"
2427
+ },
2428
+ body: JSON.stringify({
2429
+ encrypted_value: encryptedValue,
2430
+ key_id: publicKey.key_id
2431
+ })
2432
+ });
2433
+ if (!resp.ok) {
2434
+ const text = await resp.text().catch(() => "");
2435
+ throw new Error(`GitHub API ${resp.status} setting secret ${secretName}: ${text.slice(0, 200)}`);
2436
+ }
2437
+ }
2438
+ async function listAccessibleRepos(opts) {
2439
+ const repos = [];
2440
+ let page = 1;
2441
+ while (page <= 5) {
2442
+ const url = `https://api.github.com/user/repos?per_page=100&page=${page}&affiliation=owner,collaborator`;
2443
+ const resp = await fetch(url, {
2444
+ headers: {
2445
+ Authorization: `Bearer ${opts.token}`,
2446
+ Accept: "application/vnd.github+json",
2447
+ "X-GitHub-Api-Version": "2022-11-28"
2448
+ }
2449
+ });
2450
+ if (!resp.ok) {
2451
+ throw new Error(`GitHub API ${resp.status} listing repos`);
2452
+ }
2453
+ const data = await resp.json();
2454
+ if (data.length === 0) break;
2455
+ for (const r of data) {
2456
+ repos.push({ owner: r.owner.login, repo: r.name, full_name: r.full_name });
2457
+ }
2458
+ if (data.length < 100) break;
2459
+ page++;
2460
+ }
2461
+ return repos;
2462
+ }
2463
+ async function pushSecretsToRepo(opts, owner, repo, secrets) {
2464
+ const pubkey = await getRepoPublicKey(opts, owner, repo);
2465
+ await putRepoSecret(opts, owner, repo, "CLAUDE_CODE_OAUTH_TOKEN", secrets.claudeCodeOauthToken, pubkey);
2466
+ await putRepoSecret(opts, owner, repo, "SYNKRO_API_KEY", secrets.synkroApiKey, pubkey);
2467
+ }
2468
+ function writeWorkflowFile(repoRootPath) {
2469
+ const workflowDir = join4(repoRootPath, ".github", "workflows");
2470
+ mkdirSync4(workflowDir, { recursive: true });
2471
+ const workflowFile = join4(workflowDir, "synkro.yml");
2472
+ writeFileSync4(workflowFile, SYNKRO_WORKFLOW_YAML, "utf-8");
2473
+ return workflowFile;
2474
+ }
2475
+ function findGitRoot(startCwd) {
2476
+ let cur = startCwd;
2477
+ while (cur && cur !== "/") {
2478
+ if (existsSync5(join4(cur, ".git"))) return cur;
2479
+ const parent = join4(cur, "..");
2480
+ if (parent === cur) break;
2481
+ cur = parent;
2482
+ }
2483
+ return null;
2484
+ }
2485
+ var SECRET_NAMES, WORKFLOW_RELATIVE_PATH;
2486
+ var init_githubSetup = __esm({
2487
+ "cli/installer/githubSetup.ts"() {
2488
+ "use strict";
2489
+ init_workflowTemplate();
2490
+ SECRET_NAMES = {
2491
+ CLAUDE_OAUTH: "CLAUDE_CODE_OAUTH_TOKEN",
2492
+ SYNKRO_API_KEY: "SYNKRO_API_KEY"
2493
+ };
2494
+ WORKFLOW_RELATIVE_PATH = WORKFLOW_PATH;
2495
+ }
2496
+ });
2497
+
2498
+ // cli/commands/repoConnect.ts
2499
+ import { execSync as execSync2 } from "child_process";
2500
+ import { createServer as createServer2 } from "http";
2501
+ import { createInterface } from "readline";
2502
+ function detectGitRepo() {
2503
+ try {
2504
+ const remoteUrl = execSync2("git remote get-url origin", { encoding: "utf-8", timeout: 5e3 }).trim();
2505
+ const match = remoteUrl.match(/(?:github\.com|gitlab\.com|bitbucket\.org)[:/](.+?)(?:\.git)?$/);
2506
+ if (!match) return null;
2507
+ const fullName = match[1];
2508
+ return { fullName, shortName: fullName.split("/").pop() || fullName };
2509
+ } catch {
2510
+ return null;
2511
+ }
2512
+ }
2513
+ function ask(rl, question) {
2514
+ return new Promise((resolve2) => rl.question(question, resolve2));
2515
+ }
2516
+ function waitForGithubToken() {
2517
+ return new Promise((resolve2, reject) => {
2518
+ const server = createServer2((req, res) => {
2519
+ if (req.method === "OPTIONS") {
2520
+ res.writeHead(204, {
2521
+ "Access-Control-Allow-Origin": SYNKRO_WEB_AUTH_URL2,
2522
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
2523
+ "Access-Control-Allow-Headers": "Content-Type"
2524
+ });
2525
+ res.end();
2526
+ return;
2527
+ }
2528
+ if (req.url !== "/auth" || req.method !== "POST") {
2529
+ res.writeHead(404);
2530
+ res.end();
2531
+ return;
2532
+ }
2533
+ let body = "";
2534
+ req.on("data", (chunk) => {
2535
+ body += chunk;
2536
+ });
2537
+ req.on("end", () => {
2538
+ try {
2539
+ const parsed = JSON.parse(body);
2540
+ if (!parsed.github_token) {
2541
+ res.writeHead(400, {
2542
+ "Content-Type": "application/json",
2543
+ "Access-Control-Allow-Origin": SYNKRO_WEB_AUTH_URL2
2544
+ });
2545
+ res.end(JSON.stringify({ error: "missing github_token" }));
2546
+ return;
2547
+ }
2548
+ res.writeHead(200, {
2549
+ "Content-Type": "application/json",
2550
+ "Access-Control-Allow-Origin": SYNKRO_WEB_AUTH_URL2
2551
+ });
2552
+ res.end(JSON.stringify({ ok: true }));
2553
+ setTimeout(() => server.close(), 200);
2554
+ resolve2(parsed.github_token);
2555
+ } catch {
2556
+ res.writeHead(400, { "Content-Type": "application/json" });
2557
+ res.end(JSON.stringify({ error: "invalid json" }));
2558
+ }
2559
+ });
2560
+ });
2561
+ server.on("error", (err) => {
2562
+ if (err.code === "EADDRINUSE") {
2563
+ reject(new Error(`Port ${GITHUB_PORT} is in use. Close other processes and try again.`));
2564
+ } else {
2565
+ reject(err);
2566
+ }
2567
+ });
2568
+ server.listen(GITHUB_PORT);
2569
+ });
2570
+ }
2571
+ function openBrowser2(url) {
2572
+ const { execFile: execFile2 } = __require("child_process");
2573
+ const plat = process.platform;
2574
+ if (plat === "darwin") execFile2("open", [url]);
2575
+ else if (plat === "win32") execFile2("cmd", ["/c", "start", "", url]);
2576
+ else execFile2("xdg-open", [url]);
2577
+ }
2578
+ async function connectGithubAndSelectRepos() {
2579
+ const url = `${SYNKRO_WEB_AUTH_URL2}/cli-github?port=${GITHUB_PORT}`;
2580
+ console.log(" Opening browser for GitHub authorization...");
2581
+ openBrowser2(url);
2582
+ console.log(" Waiting for GitHub authorization...");
2583
+ const ghToken = await waitForGithubToken();
2584
+ console.log(" \u2713 GitHub connected\n");
2585
+ const repos = await listAccessibleRepos({ token: ghToken });
2586
+ if (repos.length === 0) {
2587
+ console.log(" No accessible repos found on GitHub.");
2588
+ return [];
2589
+ }
2590
+ console.log(` Found ${repos.length} repos:
2591
+ `);
2592
+ repos.forEach((r, i) => {
2593
+ console.log(` ${String(i + 1).padStart(3)}. ${r.full_name}`);
2594
+ });
2595
+ console.log();
2596
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
2597
+ try {
2598
+ const selection = await ask(rl, " Select repos (comma-separated numbers, e.g. 1,3,5): ");
2599
+ const indices = selection.split(",").map((s) => parseInt(s.trim(), 10) - 1).filter((n) => !isNaN(n) && n >= 0 && n < repos.length);
2600
+ if (indices.length === 0) {
2601
+ console.log(" No repos selected.");
2602
+ return [];
2603
+ }
2604
+ return indices.map((i) => ({ full_name: repos[i].full_name }));
2605
+ } finally {
2606
+ rl.close();
2607
+ }
2608
+ }
2609
+ async function promptRepoConnection() {
2610
+ const localRepo = detectGitRepo();
2611
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
2612
+ try {
2613
+ console.log("Connect repos to Synkro:\n");
2614
+ const options = [];
2615
+ if (localRepo) {
2616
+ options.push(`Link this repo (${localRepo.fullName})`);
2617
+ }
2618
+ options.push("Connect GitHub to select repos");
2619
+ options.push("Skip for now");
2620
+ options.forEach((opt, i) => {
2621
+ console.log(` ${i + 1}. ${opt}`);
2622
+ });
2623
+ console.log();
2624
+ const choice = await ask(rl, " Choose (number): ");
2625
+ const choiceNum = parseInt(choice.trim(), 10);
2626
+ console.log();
2627
+ rl.close();
2628
+ const localIdx = localRepo ? 1 : -1;
2629
+ const githubIdx = localRepo ? 2 : 1;
2630
+ const skipIdx = localRepo ? 3 : 2;
2631
+ if (choiceNum === localIdx && localRepo) {
2632
+ try {
2633
+ const existing = await listProjects();
2634
+ const alreadyLinked = existing.some(
2635
+ (p) => p.repos?.some((r) => r.full_name === localRepo.fullName)
2636
+ );
2637
+ if (!alreadyLinked) {
2638
+ await createProject(localRepo.shortName, [{ full_name: localRepo.fullName }]);
2639
+ console.log(` \u2713 Created project "${localRepo.shortName}" linked to ${localRepo.fullName}`);
2640
+ } else {
2641
+ console.log(` \u2713 ${localRepo.fullName} is already linked to a Synkro project.`);
2642
+ }
2643
+ } catch (err) {
2644
+ console.warn(` \u26A0 Could not link repo: ${err.message}`);
2645
+ }
2646
+ } else if (choiceNum === githubIdx) {
2647
+ const selectedRepos = await connectGithubAndSelectRepos();
2648
+ if (selectedRepos.length > 0) {
2649
+ try {
2650
+ const existing = await listProjects();
2651
+ const existingFullNames = new Set(
2652
+ existing.flatMap((p) => (p.repos || []).map((r) => r.full_name))
2653
+ );
2654
+ const newRepos = selectedRepos.filter((r) => !existingFullNames.has(r.full_name));
2655
+ if (newRepos.length === 0) {
2656
+ console.log(" \u2713 All selected repos are already linked.");
2657
+ } else {
2658
+ const projectName = newRepos.length === 1 ? newRepos[0].full_name.split("/").pop() || "Project" : "Multi-Repo Project";
2659
+ await createProject(projectName, newRepos);
2660
+ console.log(` \u2713 Linked ${newRepos.length} repo(s) to project "${projectName}"`);
2661
+ }
2662
+ } catch (err) {
2663
+ console.warn(` \u26A0 Could not link repos: ${err.message}`);
2664
+ }
2665
+ }
2666
+ } else if (choiceNum === skipIdx) {
2667
+ console.log(" Skipped. Run `synkro link` later to connect repos.");
2668
+ } else {
2669
+ console.log(" Invalid choice. Skipping repo connection.");
2670
+ }
2671
+ } catch {
2672
+ rl.close();
2673
+ }
2674
+ console.log();
2675
+ }
2676
+ var RAW_WEB_AUTH_URL2, SYNKRO_WEB_AUTH_URL2, GITHUB_PORT;
2677
+ var init_repoConnect = __esm({
2678
+ "cli/commands/repoConnect.ts"() {
2679
+ "use strict";
2680
+ init_projects();
2681
+ init_githubSetup();
2682
+ RAW_WEB_AUTH_URL2 = process.env.SYNKRO_WEB_AUTH_URL;
2683
+ SYNKRO_WEB_AUTH_URL2 = RAW_WEB_AUTH_URL2 && /^https?:\/\//.test(RAW_WEB_AUTH_URL2) ? RAW_WEB_AUTH_URL2 : "https://app.synkro.sh";
2684
+ GITHUB_PORT = 8101;
2685
+ }
2686
+ });
2687
+
2688
+ // cli/commands/install.ts
2689
+ var install_exports = {};
2690
+ __export(install_exports, {
2691
+ installCommand: () => installCommand,
2692
+ parseArgs: () => parseArgs
2693
+ });
2694
+ import { existsSync as existsSync6, mkdirSync as mkdirSync5, writeFileSync as writeFileSync5, chmodSync, readFileSync as readFileSync4, readdirSync } from "fs";
2695
+ import { homedir as homedir4 } from "os";
2696
+ import { join as join5 } from "path";
2697
+ import { execSync as execSync3 } from "child_process";
2698
+ function sanitizeGatewayCandidate(raw) {
2699
+ if (!raw) return void 0;
2700
+ return /^https?:\/\//.test(raw) ? raw : void 0;
2701
+ }
2702
+ function parseArgs(argv) {
2703
+ const opts = {};
2704
+ for (const a of argv) {
2705
+ if (a.startsWith("--api-key=")) opts.apiKey = a.slice("--api-key=".length);
2706
+ else if (a.startsWith("--gateway=")) opts.gatewayUrl = a.slice("--gateway=".length);
2707
+ else if (a === "--skip-auth") opts.skipAuth = true;
2708
+ else if (a === "--no-mcp") opts.noMcp = true;
2709
+ else if (a === "--force" || a === "-f") opts.force = true;
2710
+ }
2711
+ if (!opts.gatewayUrl) {
2712
+ const fromEnv = sanitizeGatewayCandidate(process.env.SYNKRO_GATEWAY_URL);
2713
+ if (fromEnv) opts.gatewayUrl = fromEnv;
2714
+ }
2715
+ return opts;
2716
+ }
2717
+ function ensureSynkroDir() {
2718
+ mkdirSync5(SYNKRO_DIR, { recursive: true });
2719
+ mkdirSync5(HOOKS_DIR, { recursive: true });
2720
+ mkdirSync5(BIN_DIR, { recursive: true });
2721
+ }
2722
+ function writeGraderDaemon() {
2723
+ writeFileSync5(GRADER_DAEMON_PATH, GRADER_DAEMON_PY, "utf-8");
2724
+ chmodSync(GRADER_DAEMON_PATH, 493);
2725
+ writeFileSync5(GRADER_PRIMER_EDIT_PATH, GRADER_PRIMER_EDIT, "utf-8");
2726
+ chmodSync(GRADER_PRIMER_EDIT_PATH, 420);
2727
+ writeFileSync5(GRADER_PRIMER_BASH_PATH, GRADER_PRIMER_BASH, "utf-8");
2728
+ chmodSync(GRADER_PRIMER_BASH_PATH, 420);
2729
+ }
2730
+ function writeHookScripts() {
2731
+ const bashScriptPath = join5(HOOKS_DIR, "cc-bash-judge.sh");
2732
+ const bashFollowupScriptPath = join5(HOOKS_DIR, "cc-bash-followup.sh");
2733
+ const editCaptureScriptPath = join5(HOOKS_DIR, "cc-edit-capture.sh");
2734
+ const editPrecheckScriptPath = join5(HOOKS_DIR, "cc-edit-precheck.sh");
2735
+ const stopSummaryScriptPath = join5(HOOKS_DIR, "cc-stop-summary.sh");
2736
+ const sessionStartScriptPath = join5(HOOKS_DIR, "cc-session-start.sh");
2737
+ writeFileSync5(bashScriptPath, CC_BASH_JUDGE_SCRIPT, "utf-8");
2738
+ writeFileSync5(bashFollowupScriptPath, CC_BASH_FOLLOWUP_SCRIPT, "utf-8");
2739
+ writeFileSync5(editCaptureScriptPath, CC_EDIT_CAPTURE_SCRIPT, "utf-8");
2740
+ writeFileSync5(editPrecheckScriptPath, CC_EDIT_PRECHECK_SCRIPT, "utf-8");
2741
+ writeFileSync5(stopSummaryScriptPath, CC_STOP_SUMMARY_SCRIPT, "utf-8");
2742
+ writeFileSync5(sessionStartScriptPath, CC_SESSION_START_SCRIPT, "utf-8");
2743
+ chmodSync(bashScriptPath, 493);
2744
+ chmodSync(bashFollowupScriptPath, 493);
2745
+ chmodSync(editCaptureScriptPath, 493);
2746
+ chmodSync(editPrecheckScriptPath, 493);
2747
+ chmodSync(stopSummaryScriptPath, 493);
2748
+ chmodSync(sessionStartScriptPath, 493);
2749
+ return {
2750
+ bashScript: bashScriptPath,
2751
+ bashFollowupScript: bashFollowupScriptPath,
2752
+ editCaptureScript: editCaptureScriptPath,
2753
+ editPrecheckScript: editPrecheckScriptPath,
2754
+ stopSummaryScript: stopSummaryScriptPath,
2755
+ sessionStartScript: sessionStartScriptPath
2756
+ };
2757
+ }
2758
+ function sanitizeConfigValue(raw, maxLen = 256) {
2759
+ if (!raw) return "";
2760
+ return raw.replace(/[^\x20-\x7E]/g, "").slice(0, maxLen);
2761
+ }
2762
+ function shellQuoteSingle(value) {
2763
+ return `'${value.replace(/'/g, "'\\''")}'`;
2764
+ }
2765
+ function writeConfigEnv(opts) {
2766
+ const credsPath = join5(SYNKRO_DIR, "credentials.json");
2767
+ const safeGateway = sanitizeConfigValue(opts.gatewayUrl);
2768
+ const safeUserId = sanitizeConfigValue(opts.userId);
2769
+ const safeOrgId = sanitizeConfigValue(opts.orgId);
2770
+ const safeEmail = sanitizeConfigValue(opts.email);
2771
+ const safeTier = sanitizeConfigValue(opts.tier ?? "pro", 32);
2772
+ const lines = [
2773
+ "# Synkro CLI config (managed by synkro install)",
2774
+ "# JWT auth \u2014 the hook scripts read SYNKRO_CREDENTIALS_PATH at runtime",
2775
+ "# and send Authorization: Bearer <access_token> on every gateway call.",
2776
+ `SYNKRO_GATEWAY_URL=${shellQuoteSingle(safeGateway)}`,
2777
+ `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
2778
+ `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
2779
+ `SYNKRO_VERSION=${shellQuoteSingle("1.3.2")}`
2780
+ ];
2781
+ if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
2782
+ if (safeOrgId) lines.push(`SYNKRO_ORG_ID=${shellQuoteSingle(safeOrgId)}`);
2783
+ if (safeEmail) lines.push(`SYNKRO_EMAIL=${shellQuoteSingle(safeEmail)}`);
2784
+ lines.push("");
2785
+ writeFileSync5(CONFIG_PATH, lines.join("\n"), "utf-8");
2786
+ chmodSync(CONFIG_PATH, 384);
2787
+ }
2788
+ function assertGatewayAllowed(gatewayUrl) {
2789
+ let parsed;
2790
+ try {
2791
+ parsed = new URL(gatewayUrl);
2792
+ } catch {
2793
+ throw new Error(`Invalid gateway URL: ${gatewayUrl}`);
2794
+ }
2795
+ const proto = parsed.protocol;
2796
+ const host = parsed.hostname;
2797
+ if (proto !== "http:" && proto !== "https:") {
2798
+ throw new Error(`Gateway URL must be http(s); got ${proto}`);
2799
+ }
2800
+ const isLocalhost = host === "localhost" || host === "127.0.0.1" || host === "::1";
2801
+ const isSynkro = host === "synkro.sh" || host.endsWith(".synkro.sh");
2802
+ if (proto === "http:" && !isLocalhost) {
2803
+ throw new Error(`Gateway URL must be HTTPS for non-localhost hosts; got ${gatewayUrl}`);
2804
+ }
2805
+ if (!isLocalhost && !isSynkro) {
2806
+ throw new Error(`Gateway host not in allowlist (synkro.sh or *.synkro.sh): ${host}`);
2807
+ }
2808
+ }
2809
+ function isAlreadyInstalled() {
2810
+ const requiredScripts = [
2811
+ join5(HOOKS_DIR, "cc-bash-judge.sh"),
2812
+ join5(HOOKS_DIR, "cc-bash-followup.sh"),
2813
+ join5(HOOKS_DIR, "cc-edit-precheck.sh"),
2814
+ join5(HOOKS_DIR, "cc-edit-capture.sh"),
2815
+ join5(HOOKS_DIR, "cc-stop-summary.sh"),
2816
+ join5(HOOKS_DIR, "cc-session-start.sh")
2817
+ ];
2818
+ if (!requiredScripts.every((p) => existsSync6(p))) return false;
2819
+ if (!existsSync6(CONFIG_PATH)) return false;
2820
+ const settingsPath = join5(homedir4(), ".claude", "settings.json");
2821
+ if (!existsSync6(settingsPath)) return false;
2822
+ try {
2823
+ const settings = JSON.parse(readFileSync4(settingsPath, "utf-8"));
2824
+ const hooks = settings?.hooks;
2825
+ if (!hooks || typeof hooks !== "object") return false;
2826
+ const hasManaged = (kind) => Array.isArray(hooks[kind]) && hooks[kind].some((entry) => entry?.__synkro_managed__ === true);
2272
2827
  if (!hasManaged("PreToolUse")) return false;
2273
2828
  if (!hasManaged("PostToolUse")) return false;
2274
2829
  if (!hasManaged("SessionEnd")) return false;
@@ -2324,6 +2879,7 @@ async function installCommand(opts = {}) {
2324
2879
  console.error("No access token available after auth.");
2325
2880
  process.exit(1);
2326
2881
  }
2882
+ await promptRepoConnection();
2327
2883
  const agents = detectAgents();
2328
2884
  if (agents.length === 0) {
2329
2885
  console.error("No AI coding agents detected. Install Claude Code first: https://docs.claude.com/claude-code");
@@ -2346,7 +2902,7 @@ async function installCommand(opts = {}) {
2346
2902
  `);
2347
2903
  writeGraderDaemon();
2348
2904
  for (const mode of ["edit", "bash"]) {
2349
- const pidFile = join4(SYNKRO_DIR, "daemon", mode, "daemon.pid");
2905
+ const pidFile = join5(SYNKRO_DIR, "daemon", mode, "daemon.pid");
2350
2906
  try {
2351
2907
  const pid = parseInt(readFileSync4(pidFile, "utf-8").trim(), 10);
2352
2908
  if (pid > 0) {
@@ -2417,12 +2973,115 @@ async function installCommand(opts = {}) {
2417
2973
  writeConfigEnv({ gatewayUrl, userId, orgId, email });
2418
2974
  console.log(`Wrote config to ${CONFIG_PATH}
2419
2975
  `);
2976
+ try {
2977
+ const repo = detectGitRepo2();
2978
+ if (repo) {
2979
+ const ingested = await ingestSessionTranscripts(gatewayUrl, token, repo);
2980
+ if (ingested > 0) {
2981
+ console.log(`Indexed ${ingested} session insights from Claude Code history for ${repo}.`);
2982
+ console.log(" This helps the safety judge understand your workflow.\n");
2983
+ }
2984
+ }
2985
+ } catch (err) {
2986
+ console.warn(` \u26A0 Session indexing skipped: ${err.message}
2987
+ `);
2988
+ }
2420
2989
  console.log("\u2713 Synkro installed.");
2421
2990
  console.log();
2422
2991
  console.log("Next steps:");
2423
2992
  console.log(" \u2022 synkro-cli setup-github (enable PR scanning)");
2424
2993
  console.log(" \u2022 synkro-cli status (check what is configured)");
2425
2994
  }
2995
+ function detectGitRepo2() {
2996
+ try {
2997
+ const remoteUrl = execSync3("git remote get-url origin", { encoding: "utf-8", timeout: 5e3 }).trim();
2998
+ const match = remoteUrl.match(/(?:github\.com|gitlab\.com|bitbucket\.org)[:/](.+?)(?:\.git)?$/);
2999
+ return match ? match[1] : null;
3000
+ } catch {
3001
+ return null;
3002
+ }
3003
+ }
3004
+ function getClaudeProjectsFolder() {
3005
+ const cwd = process.cwd();
3006
+ const sanitized = "-" + cwd.replace(/\//g, "-");
3007
+ const projectsDir = join5(homedir4(), ".claude", "projects", sanitized);
3008
+ return existsSync6(projectsDir) ? projectsDir : null;
3009
+ }
3010
+ function extractSessionInsights(projectsDir) {
3011
+ const insights = [];
3012
+ const files = readdirSync(projectsDir).filter((f) => f.endsWith(".jsonl"));
3013
+ for (const file of files) {
3014
+ const sessionId = file.replace(".jsonl", "");
3015
+ const filePath = join5(projectsDir, file);
3016
+ try {
3017
+ const content = readFileSync4(filePath, "utf-8");
3018
+ const lines = content.split("\n").filter(Boolean);
3019
+ for (let i = 0; i < lines.length; i++) {
3020
+ try {
3021
+ const entry = JSON.parse(lines[i]);
3022
+ if (entry.type === "user" && typeof entry.message?.content === "string" && entry.message.content.startsWith("This session is being continued")) {
3023
+ insights.push({
3024
+ session_id: sessionId,
3025
+ insight_type: "summary",
3026
+ content: entry.message.content.slice(0, 4e3),
3027
+ metadata: { source: "compaction_summary" }
3028
+ });
3029
+ }
3030
+ } catch {
3031
+ }
3032
+ }
3033
+ const userMessages = [];
3034
+ for (let i = lines.length - 1; i >= 0 && userMessages.length < 20; i--) {
3035
+ try {
3036
+ const entry = JSON.parse(lines[i]);
3037
+ if (entry.type === "user") {
3038
+ const text = typeof entry.message?.content === "string" ? entry.message.content : Array.isArray(entry.message?.content) ? entry.message.content.map((b) => b.text ?? b).filter((t) => typeof t === "string").join(" ") : null;
3039
+ if (text && text.length > 10 && text.length < 2e3 && !text.startsWith("This session is being continued")) {
3040
+ userMessages.push(text);
3041
+ }
3042
+ }
3043
+ } catch {
3044
+ }
3045
+ }
3046
+ for (const msg of userMessages.reverse()) {
3047
+ insights.push({
3048
+ session_id: sessionId,
3049
+ insight_type: "user_message",
3050
+ content: msg.slice(0, 2e3)
3051
+ });
3052
+ }
3053
+ } catch {
3054
+ }
3055
+ }
3056
+ return insights;
3057
+ }
3058
+ async function ingestSessionTranscripts(gatewayUrl, token, repo) {
3059
+ const projectsDir = getClaudeProjectsFolder();
3060
+ if (!projectsDir) return 0;
3061
+ const insights = extractSessionInsights(projectsDir);
3062
+ if (insights.length === 0) return 0;
3063
+ console.log(`Found ${insights.length} session insights from Claude Code history...`);
3064
+ let total = 0;
3065
+ for (let i = 0; i < insights.length; i += 100) {
3066
+ const batch = insights.slice(i, i + 100);
3067
+ try {
3068
+ const resp = await fetch(`${gatewayUrl}/api/v1/cli/ingest-sessions`, {
3069
+ method: "POST",
3070
+ headers: {
3071
+ "Authorization": `Bearer ${token}`,
3072
+ "Content-Type": "application/json"
3073
+ },
3074
+ body: JSON.stringify({ repo, sessions: batch })
3075
+ });
3076
+ if (resp.ok) {
3077
+ const result = await resp.json();
3078
+ total += result.ingested;
3079
+ }
3080
+ } catch {
3081
+ }
3082
+ }
3083
+ return total;
3084
+ }
2426
3085
  var SYNKRO_DIR, HOOKS_DIR, BIN_DIR, CONFIG_PATH, GRADER_DAEMON_PATH, GRADER_PRIMER_EDIT_PATH, GRADER_PRIMER_BASH_PATH;
2427
3086
  var init_install = __esm({
2428
3087
  "cli/commands/install.ts"() {
@@ -2433,13 +3092,14 @@ var init_install = __esm({
2433
3092
  init_hookScripts();
2434
3093
  init_graderDaemon();
2435
3094
  init_stub();
2436
- SYNKRO_DIR = join4(homedir4(), ".synkro");
2437
- HOOKS_DIR = join4(SYNKRO_DIR, "hooks");
2438
- BIN_DIR = join4(SYNKRO_DIR, "bin");
2439
- CONFIG_PATH = join4(SYNKRO_DIR, "config.env");
2440
- GRADER_DAEMON_PATH = join4(BIN_DIR, "grader_daemon.py");
2441
- GRADER_PRIMER_EDIT_PATH = join4(SYNKRO_DIR, "grader-primer-edit.txt");
2442
- GRADER_PRIMER_BASH_PATH = join4(SYNKRO_DIR, "grader-primer-bash.txt");
3095
+ init_repoConnect();
3096
+ SYNKRO_DIR = join5(homedir4(), ".synkro");
3097
+ HOOKS_DIR = join5(SYNKRO_DIR, "hooks");
3098
+ BIN_DIR = join5(SYNKRO_DIR, "bin");
3099
+ CONFIG_PATH = join5(SYNKRO_DIR, "config.env");
3100
+ GRADER_DAEMON_PATH = join5(BIN_DIR, "grader_daemon.py");
3101
+ GRADER_PRIMER_EDIT_PATH = join5(SYNKRO_DIR, "grader-primer-edit.txt");
3102
+ GRADER_PRIMER_BASH_PATH = join5(SYNKRO_DIR, "grader-primer-bash.txt");
2443
3103
  }
2444
3104
  });
2445
3105
 
@@ -2515,11 +3175,11 @@ var status_exports = {};
2515
3175
  __export(status_exports, {
2516
3176
  statusCommand: () => statusCommand
2517
3177
  });
2518
- import { existsSync as existsSync6, readFileSync as readFileSync5 } from "fs";
3178
+ import { existsSync as existsSync7, readFileSync as readFileSync5 } from "fs";
2519
3179
  import { homedir as homedir5 } from "os";
2520
- import { join as join5 } from "path";
3180
+ import { join as join6 } from "path";
2521
3181
  function readConfigEnv() {
2522
- if (!existsSync6(CONFIG_PATH2)) return {};
3182
+ if (!existsSync7(CONFIG_PATH2)) return {};
2523
3183
  const out = {};
2524
3184
  const raw = readFileSync5(CONFIG_PATH2, "utf-8");
2525
3185
  for (const line of raw.split("\n")) {
@@ -2545,21 +3205,21 @@ function statusCommand() {
2545
3205
  console.log("Authentication: \u2717 not logged in (run: synkro-cli login)");
2546
3206
  }
2547
3207
  console.log();
2548
- const config = readConfigEnv();
3208
+ const config2 = readConfigEnv();
2549
3209
  console.log("Config:");
2550
- console.log(` gateway: ${config.SYNKRO_GATEWAY_URL ?? "(unset)"}`);
2551
- console.log(` credentials: ${config.SYNKRO_CREDENTIALS_PATH ?? "(unset)"}`);
2552
- console.log(` tier: ${config.SYNKRO_TIER ?? "(unset)"}`);
3210
+ console.log(` gateway: ${config2.SYNKRO_GATEWAY_URL ?? "(unset)"}`);
3211
+ console.log(` credentials: ${config2.SYNKRO_CREDENTIALS_PATH ?? "(unset)"}`);
3212
+ console.log(` tier: ${config2.SYNKRO_TIER ?? "(unset)"}`);
2553
3213
  const info2 = getUserInfo();
2554
- const userId = info2?.id ?? config.SYNKRO_USER_ID ?? "default";
2555
- const tierCacheFile = join5(SYNKRO_DIR2, `.tier-cache-${userId}`);
2556
- let inferenceTier = config.SYNKRO_INFERENCE_TIER || null;
2557
- if (!inferenceTier && existsSync6(tierCacheFile)) {
3214
+ const userId = info2?.id ?? config2.SYNKRO_USER_ID ?? "default";
3215
+ const tierCacheFile = join6(SYNKRO_DIR2, `.tier-cache-${userId}`);
3216
+ let inferenceTier = config2.SYNKRO_INFERENCE_TIER || null;
3217
+ if (!inferenceTier && existsSync7(tierCacheFile)) {
2558
3218
  inferenceTier = readFileSync5(tierCacheFile, "utf-8").trim() || null;
2559
3219
  }
2560
3220
  const tierLabel = inferenceTier === "fast" ? "'fast' (server-side grading)" : inferenceTier === "free" ? "'free' (local daemon grading)" : "(unknown \u2014 fires on next hook)";
2561
3221
  console.log(` inference: ${tierLabel}`);
2562
- console.log(` version: ${config.SYNKRO_VERSION ?? "(unset)"}`);
3222
+ console.log(` version: ${config2.SYNKRO_VERSION ?? "(unset)"}`);
2563
3223
  console.log();
2564
3224
  const agents = detectAgents();
2565
3225
  console.log("Detected agents:");
@@ -2582,19 +3242,19 @@ function statusCommand() {
2582
3242
  }
2583
3243
  }
2584
3244
  console.log();
2585
- const bashScript = join5(SYNKRO_DIR2, "hooks", "cc-bash-judge.sh");
2586
- const bashFollowupScript = join5(SYNKRO_DIR2, "hooks", "cc-bash-followup.sh");
2587
- const editPrecheckScript = join5(SYNKRO_DIR2, "hooks", "cc-edit-precheck.sh");
2588
- const editCaptureScript = join5(SYNKRO_DIR2, "hooks", "cc-edit-capture.sh");
2589
- const stopSummaryScript = join5(SYNKRO_DIR2, "hooks", "cc-stop-summary.sh");
2590
- const sessionStartScript = join5(SYNKRO_DIR2, "hooks", "cc-session-start.sh");
3245
+ const bashScript = join6(SYNKRO_DIR2, "hooks", "cc-bash-judge.sh");
3246
+ const bashFollowupScript = join6(SYNKRO_DIR2, "hooks", "cc-bash-followup.sh");
3247
+ const editPrecheckScript = join6(SYNKRO_DIR2, "hooks", "cc-edit-precheck.sh");
3248
+ const editCaptureScript = join6(SYNKRO_DIR2, "hooks", "cc-edit-capture.sh");
3249
+ const stopSummaryScript = join6(SYNKRO_DIR2, "hooks", "cc-stop-summary.sh");
3250
+ const sessionStartScript = join6(SYNKRO_DIR2, "hooks", "cc-session-start.sh");
2591
3251
  console.log("Hook scripts:");
2592
- console.log(` ${existsSync6(bashScript) ? "\u2713" : "\u2717"} ${bashScript}`);
2593
- console.log(` ${existsSync6(bashFollowupScript) ? "\u2713" : "\u2717"} ${bashFollowupScript}`);
2594
- console.log(` ${existsSync6(editPrecheckScript) ? "\u2713" : "\u2717"} ${editPrecheckScript}`);
2595
- console.log(` ${existsSync6(editCaptureScript) ? "\u2713" : "\u2717"} ${editCaptureScript}`);
2596
- console.log(` ${existsSync6(stopSummaryScript) ? "\u2713" : "\u2717"} ${stopSummaryScript}`);
2597
- console.log(` ${existsSync6(sessionStartScript) ? "\u2713" : "\u2717"} ${sessionStartScript}`);
3252
+ console.log(` ${existsSync7(bashScript) ? "\u2713" : "\u2717"} ${bashScript}`);
3253
+ console.log(` ${existsSync7(bashFollowupScript) ? "\u2713" : "\u2717"} ${bashFollowupScript}`);
3254
+ console.log(` ${existsSync7(editPrecheckScript) ? "\u2713" : "\u2717"} ${editPrecheckScript}`);
3255
+ console.log(` ${existsSync7(editCaptureScript) ? "\u2713" : "\u2717"} ${editCaptureScript}`);
3256
+ console.log(` ${existsSync7(stopSummaryScript) ? "\u2713" : "\u2717"} ${stopSummaryScript}`);
3257
+ console.log(` ${existsSync7(sessionStartScript) ? "\u2713" : "\u2717"} ${sessionStartScript}`);
2598
3258
  console.log();
2599
3259
  const mcp = inspectMcpConfig();
2600
3260
  console.log("Guardrails MCP server (Claude Code):");
@@ -2614,165 +3274,88 @@ var init_status = __esm({
2614
3274
  init_agentDetect();
2615
3275
  init_ccHookConfig();
2616
3276
  init_mcpConfig();
2617
- SYNKRO_DIR2 = join5(homedir5(), ".synkro");
2618
- CONFIG_PATH2 = join5(SYNKRO_DIR2, "config.env");
3277
+ SYNKRO_DIR2 = join6(homedir5(), ".synkro");
3278
+ CONFIG_PATH2 = join6(SYNKRO_DIR2, "config.env");
2619
3279
  }
2620
3280
  });
2621
3281
 
2622
- // cli/installer/workflowTemplate.ts
2623
- var SYNKRO_WORKFLOW_YAML, WORKFLOW_PATH;
2624
- var init_workflowTemplate = __esm({
2625
- "cli/installer/workflowTemplate.ts"() {
3282
+ // cli/commands/link.ts
3283
+ var link_exports = {};
3284
+ __export(link_exports, {
3285
+ linkCommand: () => linkCommand
3286
+ });
3287
+ async function linkCommand() {
3288
+ if (!isAuthenticated()) {
3289
+ console.error("Not authenticated. Run `synkro install` or `synkro login` first.");
3290
+ process.exit(1);
3291
+ }
3292
+ await ensureValidToken();
3293
+ await promptRepoConnection();
3294
+ }
3295
+ var init_link = __esm({
3296
+ "cli/commands/link.ts"() {
2626
3297
  "use strict";
2627
- SYNKRO_WORKFLOW_YAML = `name: Synkro Security Review
2628
- on:
2629
- pull_request:
2630
- types: [opened, synchronize, reopened]
2631
-
2632
- jobs:
2633
- scan:
2634
- runs-on: ubuntu-latest
2635
- permissions:
2636
- contents: read
2637
- pull-requests: write
2638
- checks: write
2639
- steps:
2640
- - uses: actions/checkout@v4
2641
- with:
2642
- fetch-depth: 0
2643
-
2644
- - name: Cache npm globals
2645
- id: cache-npm-global
2646
- uses: actions/cache@v4
2647
- with:
2648
- path: ~/.npm-global
2649
- key: synkro-cli-\${{ runner.os }}-v1
2650
-
2651
- - name: Install Synkro CLI + Claude Code CLI
2652
- run: |
2653
- npm config set prefix ~/.npm-global
2654
- npm install -g @synkro-sh/cli @anthropic-ai/claude-code
2655
- echo "~/.npm-global/bin" >> $GITHUB_PATH
2656
-
2657
- - name: Run Synkro PR scan
2658
- run: synkro-cli scan-pr
2659
- env:
2660
- CLAUDE_CODE_OAUTH_TOKEN: \${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
2661
- SYNKRO_API_KEY: \${{ secrets.SYNKRO_API_KEY }}
2662
- GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
2663
- SYNKRO_PR_NUMBER: \${{ github.event.pull_request.number }}
2664
- SYNKRO_REPO: \${{ github.repository }}
2665
- SYNKRO_SHA: \${{ github.event.pull_request.head.sha }}
2666
- SYNKRO_GATEWAY_URL: \${{ vars.SYNKRO_GATEWAY_URL || 'https://api.synkro.sh' }}
2667
- `;
2668
- WORKFLOW_PATH = ".github/workflows/synkro.yml";
3298
+ init_stub();
3299
+ init_repoConnect();
2669
3300
  }
2670
3301
  });
2671
3302
 
2672
- // cli/installer/githubSetup.ts
2673
- import { existsSync as existsSync7, mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "fs";
2674
- import { join as join6 } from "path";
2675
- async function encryptSecret(publicKeyBase64, secret) {
2676
- const sodium = await import("libsodium-wrappers").then((m) => m.default ?? m);
2677
- await sodium.ready;
2678
- const keyBytes = sodium.from_base64(publicKeyBase64, sodium.base64_variants.ORIGINAL);
2679
- const messageBytes = sodium.from_string(secret);
2680
- const encryptedBytes = sodium.crypto_box_seal(messageBytes, keyBytes);
2681
- return sodium.to_base64(encryptedBytes, sodium.base64_variants.ORIGINAL);
3303
+ // cli/commands/unlink.ts
3304
+ var unlink_exports = {};
3305
+ __export(unlink_exports, {
3306
+ unlinkCommand: () => unlinkCommand
3307
+ });
3308
+ import { createInterface as createInterface2 } from "readline";
3309
+ function ask2(rl, question) {
3310
+ return new Promise((resolve2) => rl.question(question, resolve2));
2682
3311
  }
2683
- async function getRepoPublicKey(opts, owner, repo) {
2684
- const url = `https://api.github.com/repos/${owner}/${repo}/actions/secrets/public-key`;
2685
- const resp = await fetch(url, {
2686
- headers: {
2687
- Authorization: `Bearer ${opts.token}`,
2688
- Accept: "application/vnd.github+json",
2689
- "X-GitHub-Api-Version": "2022-11-28"
3312
+ async function unlinkCommand() {
3313
+ if (!isAuthenticated()) {
3314
+ console.error("Not authenticated. Run `synkro install` or `synkro login` first.");
3315
+ process.exit(1);
3316
+ }
3317
+ await ensureValidToken();
3318
+ const projects = await listProjects();
3319
+ const linked = [];
3320
+ for (const p of projects) {
3321
+ for (const r of p.repos || []) {
3322
+ if (r.full_name && r.id) {
3323
+ linked.push({ projectId: p.id, projectName: p.name, repoId: r.id, fullName: r.full_name });
3324
+ }
2690
3325
  }
2691
- });
2692
- if (!resp.ok) {
2693
- const text = await resp.text().catch(() => "");
2694
- throw new Error(`GitHub API ${resp.status} fetching public key for ${owner}/${repo}: ${text.slice(0, 200)}`);
2695
3326
  }
2696
- return await resp.json();
2697
- }
2698
- async function putRepoSecret(opts, owner, repo, secretName, secretValue, publicKey) {
2699
- const encryptedValue = await encryptSecret(publicKey.key, secretValue);
2700
- const url = `https://api.github.com/repos/${owner}/${repo}/actions/secrets/${encodeURIComponent(secretName)}`;
2701
- const resp = await fetch(url, {
2702
- method: "PUT",
2703
- headers: {
2704
- Authorization: `Bearer ${opts.token}`,
2705
- Accept: "application/vnd.github+json",
2706
- "X-GitHub-Api-Version": "2022-11-28",
2707
- "Content-Type": "application/json"
2708
- },
2709
- body: JSON.stringify({
2710
- encrypted_value: encryptedValue,
2711
- key_id: publicKey.key_id
2712
- })
2713
- });
2714
- if (!resp.ok) {
2715
- const text = await resp.text().catch(() => "");
2716
- throw new Error(`GitHub API ${resp.status} setting secret ${secretName}: ${text.slice(0, 200)}`);
3327
+ if (linked.length === 0) {
3328
+ console.log("No linked repos found.");
3329
+ return;
2717
3330
  }
2718
- }
2719
- async function listAccessibleRepos(opts) {
2720
- const repos = [];
2721
- let page = 1;
2722
- while (page <= 5) {
2723
- const url = `https://api.github.com/user/repos?per_page=100&page=${page}&affiliation=owner,collaborator`;
2724
- const resp = await fetch(url, {
2725
- headers: {
2726
- Authorization: `Bearer ${opts.token}`,
2727
- Accept: "application/vnd.github+json",
2728
- "X-GitHub-Api-Version": "2022-11-28"
2729
- }
2730
- });
2731
- if (!resp.ok) {
2732
- throw new Error(`GitHub API ${resp.status} listing repos`);
3331
+ console.log("\nLinked repos:\n");
3332
+ linked.forEach((r, i) => {
3333
+ console.log(` ${i + 1}. ${r.fullName} (${r.projectName})`);
3334
+ });
3335
+ console.log();
3336
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
3337
+ try {
3338
+ const selection = await ask2(rl, " Select repos to unlink (comma-separated numbers): ");
3339
+ const indices = selection.split(",").map((s) => parseInt(s.trim(), 10) - 1).filter((n) => !isNaN(n) && n >= 0 && n < linked.length);
3340
+ if (indices.length === 0) {
3341
+ console.log(" No repos selected.");
3342
+ return;
2733
3343
  }
2734
- const data = await resp.json();
2735
- if (data.length === 0) break;
2736
- for (const r of data) {
2737
- repos.push({ owner: r.owner.login, repo: r.name, full_name: r.full_name });
3344
+ for (const idx of indices) {
3345
+ const r = linked[idx];
3346
+ await unlinkRepo(r.projectId, r.repoId);
3347
+ console.log(` \u2713 Unlinked ${r.fullName} from ${r.projectName}`);
2738
3348
  }
2739
- if (data.length < 100) break;
2740
- page++;
2741
- }
2742
- return repos;
2743
- }
2744
- async function pushSecretsToRepo(opts, owner, repo, secrets) {
2745
- const pubkey = await getRepoPublicKey(opts, owner, repo);
2746
- await putRepoSecret(opts, owner, repo, "CLAUDE_CODE_OAUTH_TOKEN", secrets.claudeCodeOauthToken, pubkey);
2747
- await putRepoSecret(opts, owner, repo, "SYNKRO_API_KEY", secrets.synkroApiKey, pubkey);
2748
- }
2749
- function writeWorkflowFile(repoRootPath) {
2750
- const workflowDir = join6(repoRootPath, ".github", "workflows");
2751
- mkdirSync5(workflowDir, { recursive: true });
2752
- const workflowFile = join6(workflowDir, "synkro.yml");
2753
- writeFileSync5(workflowFile, SYNKRO_WORKFLOW_YAML, "utf-8");
2754
- return workflowFile;
2755
- }
2756
- function findGitRoot(startCwd) {
2757
- let cur = startCwd;
2758
- while (cur && cur !== "/") {
2759
- if (existsSync7(join6(cur, ".git"))) return cur;
2760
- const parent = join6(cur, "..");
2761
- if (parent === cur) break;
2762
- cur = parent;
3349
+ } finally {
3350
+ rl.close();
2763
3351
  }
2764
- return null;
3352
+ console.log();
2765
3353
  }
2766
- var SECRET_NAMES, WORKFLOW_RELATIVE_PATH;
2767
- var init_githubSetup = __esm({
2768
- "cli/installer/githubSetup.ts"() {
3354
+ var init_unlink = __esm({
3355
+ "cli/commands/unlink.ts"() {
2769
3356
  "use strict";
2770
- init_workflowTemplate();
2771
- SECRET_NAMES = {
2772
- CLAUDE_OAUTH: "CLAUDE_CODE_OAUTH_TOKEN",
2773
- SYNKRO_API_KEY: "SYNKRO_API_KEY"
2774
- };
2775
- WORKFLOW_RELATIVE_PATH = WORKFLOW_PATH;
3357
+ init_stub();
3358
+ init_projects();
2776
3359
  }
2777
3360
  });
2778
3361
 
@@ -2781,7 +3364,7 @@ var setupGithub_exports = {};
2781
3364
  __export(setupGithub_exports, {
2782
3365
  setupGithubCommand: () => setupGithubCommand
2783
3366
  });
2784
- import { createInterface } from "readline/promises";
3367
+ import { createInterface as createInterface3 } from "readline/promises";
2785
3368
  import { stdin as input, stdout as output } from "process";
2786
3369
  import { existsSync as existsSync8, readFileSync as readFileSync6 } from "fs";
2787
3370
  import { homedir as homedir6 } from "os";
@@ -2832,8 +3415,8 @@ async function setupGithubCommand() {
2832
3415
  console.error("Not authenticated. Run `synkro-cli login` first.");
2833
3416
  process.exit(1);
2834
3417
  }
2835
- const config = readConfig();
2836
- const gatewayUrl = (config.SYNKRO_GATEWAY_URL || process.env.SYNKRO_GATEWAY_URL || "https://api.synkro.sh").replace(/\/$/, "");
3418
+ const config2 = readConfig();
3419
+ const gatewayUrl = (config2.SYNKRO_GATEWAY_URL || process.env.SYNKRO_GATEWAY_URL || "https://api.synkro.sh").replace(/\/$/, "");
2837
3420
  const jwt2 = getAccessToken();
2838
3421
  if (!jwt2) {
2839
3422
  console.error("Could not load access token from ~/.synkro/credentials.json. Run `synkro-cli login`.");
@@ -2862,7 +3445,7 @@ async function setupGithubCommand() {
2862
3445
  console.error(`Failed to mint CI API key: ${err.message}`);
2863
3446
  process.exit(1);
2864
3447
  }
2865
- const rl = createInterface({ input, output });
3448
+ const rl = createInterface3({ input, output });
2866
3449
  console.log("Synkro PR scan setup\n");
2867
3450
  console.log("Requirements:");
2868
3451
  console.log(" \u2022 Claude Code Pro or Max subscription (for `claude setup-token`)");
@@ -2969,7 +3552,7 @@ var scanPr_exports = {};
2969
3552
  __export(scanPr_exports, {
2970
3553
  scanPrCommand: () => scanPrCommand
2971
3554
  });
2972
- import { execSync as execSync2, spawn } from "child_process";
3555
+ import { execSync as execSync4, spawn } from "child_process";
2973
3556
  function parseMatchSpec(condition) {
2974
3557
  if (!condition.startsWith("match_spec:")) return null;
2975
3558
  try {
@@ -3075,7 +3658,7 @@ function shouldSkipFile(filename) {
3075
3658
  return SKIP_FILE_PATTERNS.some((p) => p.test(filename));
3076
3659
  }
3077
3660
  function ghJson(args2) {
3078
- const out = execSync2(`gh ${args2.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(" ")}`, {
3661
+ const out = execSync4(`gh ${args2.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(" ")}`, {
3079
3662
  encoding: "utf-8",
3080
3663
  maxBuffer: 16 * 1024 * 1024
3081
3664
  });
@@ -3191,7 +3774,7 @@ ${finding.description}
3191
3774
 
3192
3775
  **Fix:** ${finding.fix}`;
3193
3776
  try {
3194
- execSync2(
3777
+ execSync4(
3195
3778
  `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`,
3196
3779
  { encoding: "utf-8", stdio: ["ignore", "ignore", "pipe"] }
3197
3780
  );
@@ -3213,7 +3796,7 @@ function postCheckRun(repo, sha, conclusion, findings) {
3213
3796
  }
3214
3797
  });
3215
3798
  try {
3216
- execSync2(`gh api -X POST /repos/${repo}/check-runs --input -`, {
3799
+ execSync4(`gh api -X POST /repos/${repo}/check-runs --input -`, {
3217
3800
  encoding: "utf-8",
3218
3801
  input: body,
3219
3802
  stdio: ["pipe", "ignore", "pipe"]
@@ -3431,6 +4014,43 @@ var init_disconnect = __esm({
3431
4014
  }
3432
4015
  });
3433
4016
 
4017
+ // cli/commands/uninstall.ts
4018
+ var uninstall_exports = {};
4019
+ __export(uninstall_exports, {
4020
+ uninstallCommand: () => uninstallCommand
4021
+ });
4022
+ function uninstallCommand() {
4023
+ console.log("Uninstalling Synkro...\n");
4024
+ disconnectCommand(["--purge"]);
4025
+ console.log("\nTo reinstall later: synkro install");
4026
+ }
4027
+ var init_uninstall = __esm({
4028
+ "cli/commands/uninstall.ts"() {
4029
+ "use strict";
4030
+ init_disconnect();
4031
+ }
4032
+ });
4033
+
4034
+ // cli/commands/reinstall.ts
4035
+ var reinstall_exports = {};
4036
+ __export(reinstall_exports, {
4037
+ reinstallCommand: () => reinstallCommand
4038
+ });
4039
+ async function reinstallCommand() {
4040
+ console.log("Reinstalling Synkro...\n");
4041
+ disconnectCommand(["--purge"]);
4042
+ console.log("");
4043
+ await installCommand({ force: true });
4044
+ console.log("\n\u2713 Synkro reinstalled.");
4045
+ }
4046
+ var init_reinstall = __esm({
4047
+ "cli/commands/reinstall.ts"() {
4048
+ "use strict";
4049
+ init_disconnect();
4050
+ init_install();
4051
+ }
4052
+ });
4053
+
3434
4054
  // cli/bootstrap.js
3435
4055
  import { readFileSync as readFileSync7, existsSync as existsSync10 } from "fs";
3436
4056
  import { resolve } from "path";
@@ -3466,10 +4086,14 @@ Commands:
3466
4086
  login Authenticate with Synkro (browser OAuth via WorkOS)
3467
4087
  logout Clear local credentials
3468
4088
  status Show current setup state
4089
+ link Link repos to a Synkro project (local git or GitHub OAuth)
4090
+ unlink Remove repo links from Synkro projects
3469
4091
  setup-github Configure GitHub PR scanning (push secrets + workflow file)
3470
4092
  scan-pr Run a PR scan (used by GitHub Actions, not for direct invocation)
3471
4093
  update Refresh hook configs and judge prompts
3472
4094
  disconnect [--purge] Remove Synkro hooks from agents (--purge also removes ~/.synkro)
4095
+ uninstall Fully remove Synkro from this machine
4096
+ reinstall Clean uninstall + fresh install
3473
4097
  help Show this message
3474
4098
 
3475
4099
  Quick start:
@@ -3502,6 +4126,16 @@ async function main() {
3502
4126
  statusCommand2();
3503
4127
  break;
3504
4128
  }
4129
+ case "link": {
4130
+ const { linkCommand: linkCommand2 } = await Promise.resolve().then(() => (init_link(), link_exports));
4131
+ await linkCommand2();
4132
+ break;
4133
+ }
4134
+ case "unlink": {
4135
+ const { unlinkCommand: unlinkCommand2 } = await Promise.resolve().then(() => (init_unlink(), unlink_exports));
4136
+ await unlinkCommand2();
4137
+ break;
4138
+ }
3505
4139
  case "setup-github": {
3506
4140
  const { setupGithubCommand: setupGithubCommand2 } = await Promise.resolve().then(() => (init_setupGithub(), setupGithub_exports));
3507
4141
  await setupGithubCommand2();
@@ -3522,6 +4156,16 @@ async function main() {
3522
4156
  disconnectCommand2(subArgs);
3523
4157
  break;
3524
4158
  }
4159
+ case "uninstall": {
4160
+ const { uninstallCommand: uninstallCommand2 } = await Promise.resolve().then(() => (init_uninstall(), uninstall_exports));
4161
+ uninstallCommand2();
4162
+ break;
4163
+ }
4164
+ case "reinstall": {
4165
+ const { reinstallCommand: reinstallCommand2 } = await Promise.resolve().then(() => (init_reinstall(), reinstall_exports));
4166
+ await reinstallCommand2();
4167
+ break;
4168
+ }
3525
4169
  case "help":
3526
4170
  case "--help":
3527
4171
  case "-h":