@synkro-sh/cli 1.2.6 → 1.3.3

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,13 +1,42 @@
1
1
  #!/usr/bin/env node
2
+ var __create = Object.create;
2
3
  var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
3
5
  var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
9
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
10
+ }) : x)(function(x) {
11
+ if (typeof require !== "undefined") return require.apply(this, arguments);
12
+ throw Error('Dynamic require of "' + x + '" is not supported');
13
+ });
4
14
  var __esm = (fn, res) => function __init() {
5
15
  return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
6
16
  };
17
+ var __commonJS = (cb, mod) => function __require2() {
18
+ return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
19
+ };
7
20
  var __export = (target, all) => {
8
21
  for (var name in all)
9
22
  __defProp(target, name, { get: all[name], enumerable: true });
10
23
  };
24
+ var __copyProps = (to, from, except, desc) => {
25
+ if (from && typeof from === "object" || typeof from === "function") {
26
+ for (let key of __getOwnPropNames(from))
27
+ if (!__hasOwnProp.call(to, key) && key !== except)
28
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
29
+ }
30
+ return to;
31
+ };
32
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
33
+ // If the importer is in node compatibility mode or this is not an ESM
34
+ // file that has been converted to a CommonJS file using a Babel-
35
+ // compatible transform (i.e. "__esModule" has not been set), then set
36
+ // "default" to the CommonJS "module.exports" for node compatibility.
37
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
38
+ mod
39
+ ));
11
40
 
12
41
  // cli/installer/agentDetect.ts
13
42
  import { existsSync } from "fs";
@@ -96,7 +125,7 @@ function removeSynkroEntries(events, eventName) {
96
125
  if (!Array.isArray(arr)) return;
97
126
  events[eventName] = arr.filter((entry) => !isSynkroEntry(entry));
98
127
  }
99
- function installCCHooks(settingsPath, config) {
128
+ function installCCHooks(settingsPath, config2) {
100
129
  const settings = readSettings(settingsPath);
101
130
  settings.hooks = settings.hooks ?? {};
102
131
  removeSynkroEntries(settings.hooks, "PreToolUse");
@@ -109,11 +138,11 @@ function installCCHooks(settingsPath, config) {
109
138
  settings.hooks.SessionEnd = settings.hooks.SessionEnd ?? [];
110
139
  settings.hooks.SessionStart = settings.hooks.SessionStart ?? [];
111
140
  settings.hooks.PreToolUse.push({
112
- matcher: "Bash",
141
+ matcher: "Bash|Read|Grep|Glob",
113
142
  hooks: [
114
143
  {
115
144
  type: "command",
116
- command: config.bashJudgeScriptPath,
145
+ command: config2.bashJudgeScriptPath,
117
146
  timeout: 15
118
147
  }
119
148
  ],
@@ -124,7 +153,7 @@ function installCCHooks(settingsPath, config) {
124
153
  hooks: [
125
154
  {
126
155
  type: "command",
127
- command: config.editPrecheckScriptPath,
156
+ command: config2.editPrecheckScriptPath,
128
157
  timeout: 15
129
158
  }
130
159
  ],
@@ -135,7 +164,7 @@ function installCCHooks(settingsPath, config) {
135
164
  hooks: [
136
165
  {
137
166
  type: "command",
138
- command: config.editCaptureScriptPath,
167
+ command: config2.editCaptureScriptPath,
139
168
  timeout: 20
140
169
  }
141
170
  ],
@@ -146,7 +175,7 @@ function installCCHooks(settingsPath, config) {
146
175
  hooks: [
147
176
  {
148
177
  type: "command",
149
- command: config.bashFollowupScriptPath
178
+ command: config2.bashFollowupScriptPath
150
179
  }
151
180
  ],
152
181
  [SYNKRO_MARKER]: true
@@ -155,7 +184,7 @@ function installCCHooks(settingsPath, config) {
155
184
  hooks: [
156
185
  {
157
186
  type: "command",
158
- command: config.stopSummaryScriptPath
187
+ command: config2.stopSummaryScriptPath
159
188
  }
160
189
  ],
161
190
  [SYNKRO_MARKER]: true
@@ -164,7 +193,7 @@ function installCCHooks(settingsPath, config) {
164
193
  hooks: [
165
194
  {
166
195
  type: "command",
167
- command: config.sessionStartScriptPath
196
+ command: config2.sessionStartScriptPath
168
197
  }
169
198
  ],
170
199
  [SYNKRO_MARKER]: true
@@ -232,50 +261,50 @@ function readClaudeJson() {
232
261
  throw new Error(`Failed to parse ${CC_CONFIG_PATH}: ${err.message}`);
233
262
  }
234
263
  }
235
- function writeClaudeJsonAtomic(config) {
264
+ function writeClaudeJsonAtomic(config2) {
236
265
  mkdirSync2(dirname2(CC_CONFIG_PATH), { recursive: true });
237
266
  const tmpPath = `${CC_CONFIG_PATH}.synkro.tmp`;
238
- writeFileSync2(tmpPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
267
+ writeFileSync2(tmpPath, JSON.stringify(config2, null, 2) + "\n", "utf-8");
239
268
  renameSync2(tmpPath, CC_CONFIG_PATH);
240
269
  }
241
270
  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];
271
+ const config2 = readClaudeJson();
272
+ config2.mcpServers = config2.mcpServers ?? {};
273
+ for (const [name, entry] of Object.entries(config2.mcpServers)) {
274
+ if (entry?.[SYNKRO_MARKER2] === true) delete config2.mcpServers[name];
246
275
  }
247
276
  const url = `${opts.gatewayUrl.replace(/\/$/, "")}/api/v1/mcp/guardrails`;
248
- config.mcpServers[SYNKRO_SERVER_NAME] = {
277
+ config2.mcpServers[SYNKRO_SERVER_NAME] = {
249
278
  type: "http",
250
279
  url,
251
280
  headers: { Authorization: `Bearer ${opts.bearerToken}` },
252
281
  [SYNKRO_MARKER2]: true
253
282
  };
254
- writeClaudeJsonAtomic(config);
283
+ writeClaudeJsonAtomic(config2);
255
284
  return { path: CC_CONFIG_PATH, url };
256
285
  }
257
286
  function uninstallMcpConfig() {
258
287
  if (!existsSync3(CC_CONFIG_PATH)) return false;
259
- const config = readClaudeJson();
260
- if (!config.mcpServers || Object.keys(config.mcpServers).length === 0) return false;
288
+ const config2 = readClaudeJson();
289
+ if (!config2.mcpServers || Object.keys(config2.mcpServers).length === 0) return false;
261
290
  let removed = false;
262
- for (const [name, entry] of Object.entries(config.mcpServers)) {
291
+ for (const [name, entry] of Object.entries(config2.mcpServers)) {
263
292
  if (entry?.[SYNKRO_MARKER2] === true) {
264
- delete config.mcpServers[name];
293
+ delete config2.mcpServers[name];
265
294
  removed = true;
266
295
  }
267
296
  }
268
297
  if (!removed) return false;
269
- if (Object.keys(config.mcpServers).length === 0) delete config.mcpServers;
270
- writeClaudeJsonAtomic(config);
298
+ if (Object.keys(config2.mcpServers).length === 0) delete config2.mcpServers;
299
+ writeClaudeJsonAtomic(config2);
271
300
  return true;
272
301
  }
273
302
  function inspectMcpConfig() {
274
303
  if (!existsSync3(CC_CONFIG_PATH)) {
275
304
  return { installed: false, configPath: CC_CONFIG_PATH };
276
305
  }
277
- const config = readClaudeJson();
278
- const entry = config.mcpServers?.[SYNKRO_SERVER_NAME];
306
+ const config2 = readClaudeJson();
307
+ const entry = config2.mcpServers?.[SYNKRO_SERVER_NAME];
279
308
  if (!entry || entry[SYNKRO_MARKER2] !== true) {
280
309
  return { installed: false, configPath: CC_CONFIG_PATH };
281
310
  }
@@ -336,14 +365,30 @@ if [ -z "$PAYLOAD" ]; then
336
365
  exit 0
337
366
  fi
338
367
 
339
- # Only run on Bash tool calls
368
+ # Translate tool calls into a command string for the judge
340
369
  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)
370
+ case "$TOOL_NAME" in
371
+ Bash)
372
+ COMMAND=$(echo "$PAYLOAD" | jq -r '.tool_input.command // empty' 2>/dev/null)
373
+ ;;
374
+ Read)
375
+ FILE_PATH=$(echo "$PAYLOAD" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
376
+ COMMAND="cat \${FILE_PATH}"
377
+ ;;
378
+ Grep)
379
+ PATTERN=$(echo "$PAYLOAD" | jq -r '.tool_input.pattern // empty' 2>/dev/null)
380
+ GREP_PATH=$(echo "$PAYLOAD" | jq -r '.tool_input.path // "."' 2>/dev/null)
381
+ COMMAND="grep -r '\${PATTERN}' \${GREP_PATH}"
382
+ ;;
383
+ Glob)
384
+ PATTERN=$(echo "$PAYLOAD" | jq -r '.tool_input.pattern // empty' 2>/dev/null)
385
+ COMMAND="find . -name '\${PATTERN}'"
386
+ ;;
387
+ *)
388
+ echo '{}'
389
+ exit 0
390
+ ;;
391
+ esac
347
392
  if [ -z "$COMMAND" ]; then
348
393
  echo '{}'
349
394
  exit 0
@@ -364,7 +409,7 @@ TRANSCRIPT_PATH=$(echo "$PAYLOAD" | jq -r '.transcript_path // empty' 2>/dev/nul
364
409
  SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null)
365
410
  TOOL_USE_ID=$(echo "$PAYLOAD" | jq -r '.tool_use_id // empty' 2>/dev/null)
366
411
  CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
367
- TOOL_INPUT=$(echo "$PAYLOAD" | jq -c '.tool_input // {}' 2>/dev/null)
412
+ TOOL_INPUT=$(echo "$PAYLOAD" | jq -c --arg cmd "$COMMAND" '.tool_input // {} | . + {command: $cmd}' 2>/dev/null)
368
413
  # Detect git remote origin \u2192 repo identity (e.g. "owner/repo")
369
414
  GIT_REPO=""
370
415
  if command -v git >/dev/null 2>&1; then
@@ -384,11 +429,9 @@ if [ "\${SYNKRO_HEADLESS:-0}" = "1" ]; then IS_HEADLESS=1; fi
384
429
 
385
430
  USER_INTENT=""
386
431
  RECENT_USER_MESSAGES="[]"
432
+ RECENT_MESSAGES="[]"
387
433
  RECENT_ACTIONS="[]"
388
434
  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
435
  RECENT_USER_MESSAGES=$(tail -400 "$TRANSCRIPT_PATH" | jq -c -s '
393
436
  [.[]
394
437
  | select(.type == "user")
@@ -399,16 +442,78 @@ if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
399
442
  | select(. != null and . != "")
400
443
  ] | .[-5:]' 2>/dev/null || echo "[]")
401
444
  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 '
445
+ # Interleaved assistant+user messages \u2014 lets the grader see what question
446
+ # each "yes" was answering (assistant text before user reply).
447
+ RECENT_MESSAGES=$(tail -400 "$TRANSCRIPT_PATH" | jq -c -s '
404
448
  [.[]
449
+ | select(.type == "assistant" or .type == "user")
450
+ | {
451
+ role: .type,
452
+ text: (
453
+ if .type == "assistant" then
454
+ [.message.content[]? | select(type == "object" and .type == "text") | .text // ""] | join(" ") | .[0:500]
455
+ else
456
+ (.message.content
457
+ | if type == "string" then .[0:500]
458
+ else ([.[]? | if type == "string" then . elif (type == "object" and .type == "text") then (.text // "") else "" end] | join(" ") | .[0:500])
459
+ end)
460
+ end
461
+ )
462
+ }
463
+ | select(.text != "" and .text != null and (.text | length) > 0)
464
+ ] | .[-10:]' 2>/dev/null || echo "[]")
465
+ # Recent agent actions (last 5 tool_use blocks paired with results)
466
+ RECENT_ACTIONS=$(tail -400 "$TRANSCRIPT_PATH" | jq -c -s '
467
+ # tool_result blocks live in USER messages (Anthropic API format)
468
+ ([ .[]
469
+ | select(.type == "user")
470
+ | .message.content[]?
471
+ | select(type == "object" and .type == "tool_result")
472
+ | { (.tool_use_id): (.content // "" | tostring | .[0:300]) }
473
+ ] | add // {}) as $results
474
+ |
475
+ [ .[]
405
476
  | select(.type == "assistant")
406
477
  | .message.content[]?
407
478
  | select(.type == "tool_use")
408
- | { tool: .name, input: (.input // {} | tostring | .[0:200]) }
479
+ | {
480
+ tool: .name,
481
+ input: (.input // {} | tostring | .[0:200]),
482
+ result: ($results[.id] // null)
483
+ }
409
484
  ] | .[-5:]' 2>/dev/null || echo "[]")
410
485
  fi
411
486
 
487
+ CC_MODEL=""
488
+ CC_USAGE="{}"
489
+ if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
490
+ _LAST_ASSISTANT=$(tail -50 "$TRANSCRIPT_PATH" | jq -c 'select(.type == "assistant")' 2>/dev/null | tail -1)
491
+ if [ -n "$_LAST_ASSISTANT" ]; then
492
+ CC_MODEL=$(echo "$_LAST_ASSISTANT" | jq -r '.message.model // empty' 2>/dev/null)
493
+ CC_USAGE=$(echo "$_LAST_ASSISTANT" | jq -c '{
494
+ input_tokens: .message.usage.input_tokens,
495
+ output_tokens: .message.usage.output_tokens,
496
+ cache_creation_input_tokens: .message.usage.cache_creation_input_tokens,
497
+ cache_read_input_tokens: .message.usage.cache_read_input_tokens,
498
+ service_tier: .message.usage.service_tier,
499
+ speed: .message.usage.speed
500
+ }' 2>/dev/null || echo "{}")
501
+ fi
502
+ fi
503
+
504
+ # Extract session summary from CC compaction (free broad context)
505
+ SESSION_SUMMARY=""
506
+ if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
507
+ _SUMMARY_LINE=$(grep -n '"This session is being continued' "$TRANSCRIPT_PATH" 2>/dev/null | tail -1 | cut -d: -f1)
508
+ if [ -n "$_SUMMARY_LINE" ]; then
509
+ SESSION_SUMMARY=$(sed -n "\${_SUMMARY_LINE}p" "$TRANSCRIPT_PATH" | jq -r '
510
+ .message.content
511
+ | if type == "string" then .[0:2000]
512
+ else ([.[]? | if type == "string" then . elif (type == "object" and .type == "text") then (.text // "") else "" end] | join(" ") | .[0:2000])
513
+ end' 2>/dev/null || echo "")
514
+ fi
515
+ fi
516
+
412
517
  # Build POST body \u2014 always emit all fields (use null for empty optionals)
413
518
  # Earlier version used \`select(length > 0)\` which made the entire object
414
519
  # evaluate to nothing when any optional was empty. Don't do that.
@@ -416,21 +521,29 @@ BODY=$(jq -n \\
416
521
  --argjson tool_input "$TOOL_INPUT" \\
417
522
  --arg user_intent "$USER_INTENT" \\
418
523
  --argjson recent_user_messages "$RECENT_USER_MESSAGES" \\
524
+ --argjson recent_messages "$RECENT_MESSAGES" \\
419
525
  --argjson recent_actions "$RECENT_ACTIONS" \\
420
526
  --arg session_id "$SESSION_ID" \\
421
527
  --arg tool_use_id "$TOOL_USE_ID" \\
422
528
  --arg cwd "$CWD" \\
423
529
  --arg repo "$GIT_REPO" \\
530
+ --arg cc_model "$CC_MODEL" \\
531
+ --argjson cc_usage "$CC_USAGE" \\
532
+ --arg session_summary "$SESSION_SUMMARY" \\
424
533
  '{
425
534
  kind: "bash_judge",
426
535
  tool_input: $tool_input,
427
536
  user_intent: (if ($user_intent | length) > 0 then $user_intent else null end),
428
537
  recent_user_messages: $recent_user_messages,
538
+ recent_messages: $recent_messages,
429
539
  recent_actions: $recent_actions,
430
540
  session_id: (if ($session_id | length) > 0 then $session_id else null end),
431
541
  tool_use_id: (if ($tool_use_id | length) > 0 then $tool_use_id else null end),
432
542
  cwd: (if ($cwd | length) > 0 then $cwd else null end),
433
- repo: (if ($repo | length) > 0 then $repo else null end)
543
+ repo: (if ($repo | length) > 0 then $repo else null end),
544
+ cc_model: (if ($cc_model | length) > 0 then $cc_model else null end),
545
+ cc_usage: $cc_usage,
546
+ session_summary: (if ($session_summary | length) > 0 then $session_summary else null end)
434
547
  }')
435
548
 
436
549
  # Helper: refresh JWT via /api/auth/refresh and rewrite credentials.json.
@@ -546,23 +659,15 @@ if [ -z "$VERDICT" ]; then
546
659
  fi
547
660
 
548
661
  # Parse verdict \u2014 fail open on any parse error
549
- SEVERITY=$(echo "$VERDICT" | jq -r '.severity // "medium"' 2>/dev/null)
662
+ SEVERITY=$(echo "$VERDICT" | jq -r '.severity // "audit"' 2>/dev/null)
550
663
  VERDICT_KIND=$(echo "$VERDICT" | jq -r '.verdict // "warn"' 2>/dev/null)
551
664
  REASONING=$(echo "$VERDICT" | jq -r '.reasoning // "matched dangerous-verb regex"' 2>/dev/null)
552
665
  ALTERNATIVE=$(echo "$VERDICT" | jq -r '.alternative // ""' 2>/dev/null)
553
666
  CATEGORY=$(echo "$VERDICT" | jq -r '.category // "destructive_command"' 2>/dev/null)
554
667
 
555
668
  # 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.
669
+ # block \u2192 permissionDecision: "ask" (interactive) or "deny" (headless)
670
+ # audit \u2192 silent allow \u2014 logged but no interruption
566
671
 
567
672
  ALT_SUFFIX=""
568
673
  if [ -n "$ALTERNATIVE" ] && [ "$ALTERNATIVE" != "null" ]; then
@@ -570,26 +675,9 @@ if [ -n "$ALTERNATIVE" ] && [ "$ALTERNATIVE" != "null" ]; then
570
675
  fi
571
676
 
572
677
  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)"
678
+ block)
591
679
  PERMISSION_REASON="[synkro] \${REASONING}\${ALT_SUFFIX}"
592
- ADDITIONAL_CTX="Synkro safety judge (severity: high, category: \${CATEGORY}).\${ALT_SUFFIX}"
680
+ ADDITIONAL_CTX="Synkro safety judge (severity: \${SEVERITY}, category: \${CATEGORY}). Reasoning: \${REASONING}.\${ALT_SUFFIX}"
593
681
  if [ "$IS_HEADLESS" = "1" ]; then DECISION="deny"; else DECISION="ask"; fi
594
682
  jq -n \\
595
683
  --arg ctx "$ADDITIONAL_CTX" \\
@@ -604,7 +692,7 @@ case "$SEVERITY" in
604
692
  }
605
693
  }'
606
694
  ;;
607
- *)
695
+ audit)
608
696
  synkro_log "bashGuard $CMD_SHORT \u2192 pass ($CATEGORY): $REASONING"
609
697
  case "$CATEGORY" in
610
698
  trivial_utility)
@@ -615,6 +703,21 @@ case "$SEVERITY" in
615
703
  jq -n --arg m "[synkro] bashGuard \u2192 pass ($CATEGORY): $REASONING" '{systemMessage: $m}' ;;
616
704
  esac
617
705
  ;;
706
+ *)
707
+ synkro_log "bashGuard $CMD_SHORT \u2192 UNEXPECTED SEVERITY ($SEVERITY), blocking by default"
708
+ if [ "$IS_HEADLESS" = "1" ]; then DECISION="deny"; else DECISION="ask"; fi
709
+ jq -n \\
710
+ --arg decision "$DECISION" \\
711
+ --arg reason "[synkro] unexpected severity '\${SEVERITY}' \u2014 blocking by default. Please email team@synkro.sh to report this issue." \\
712
+ '{
713
+ hookSpecificOutput: {
714
+ hookEventName: "PreToolUse",
715
+ permissionDecision: $decision,
716
+ permissionDecisionReason: $reason,
717
+ 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."
718
+ }
719
+ }'
720
+ ;;
618
721
  esac
619
722
 
620
723
  exit 0
@@ -983,8 +1086,14 @@ if [ "$DECISION" = "deny" ]; then
983
1086
  synkro_log "editGuard $FILE_SHORT \u2192 BLOCKED: $DENY_REASON"
984
1087
  echo "$RESP"
985
1088
  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}')
1089
+ VERDICT_REASON=$(echo "$RESP" | jq -r '.reason // empty' 2>/dev/null)
1090
+ if [ -n "$VERDICT_REASON" ]; then
1091
+ synkro_log "editGuard $FILE_SHORT \u2192 pass: $VERDICT_REASON"
1092
+ RESP_WITH_MSG=$(echo "$RESP" | jq --arg m "[synkro] editGuard $FILE_SHORT \u2192 pass: $VERDICT_REASON" '. + {systemMessage: $m}')
1093
+ else
1094
+ synkro_log "editGuard $FILE_SHORT \u2192 pass"
1095
+ RESP_WITH_MSG=$(echo "$RESP" | jq --arg m "[synkro] editGuard $FILE_SHORT \u2192 pass" '. + {systemMessage: $m}')
1096
+ fi
988
1097
  echo "$RESP_WITH_MSG"
989
1098
  fi
990
1099
 
@@ -1232,8 +1341,13 @@ if [ "$OK" = "false" ] && [ -n "$REASON" ]; then
1232
1341
  exit 0
1233
1342
  fi
1234
1343
 
1235
- synkro_log "editScan $BASENAME \u2192 pass"
1236
- jq -n --arg m "[synkro] editScan $BASENAME \u2192 pass" '{systemMessage: $m}'
1344
+ if [ -n "$REASON" ]; then
1345
+ synkro_log "editScan $BASENAME \u2192 pass ($CATEGORY): $REASON"
1346
+ jq -n --arg m "[synkro] editScan $BASENAME \u2192 pass ($CATEGORY): $REASON" '{systemMessage: $m}'
1347
+ else
1348
+ synkro_log "editScan $BASENAME \u2192 pass"
1349
+ jq -n --arg m "[synkro] editScan $BASENAME \u2192 pass" '{systemMessage: $m}'
1350
+ fi
1237
1351
  exit 0
1238
1352
  `;
1239
1353
  CC_STOP_SUMMARY_SCRIPT = `#!/bin/bash
@@ -1335,8 +1449,17 @@ if [ -z "$CWD" ]; then
1335
1449
  exit 0
1336
1450
  fi
1337
1451
 
1452
+ GIT_REPO=""
1453
+ if command -v git >/dev/null 2>&1; then
1454
+ _REMOTE=$(git -C "\${CWD:-.}" remote get-url origin 2>/dev/null || true)
1455
+ if [ -n "$_REMOTE" ]; then
1456
+ GIT_REPO=$(echo "$_REMOTE" | sed -E 's|^git@[^:]+:||; s|^https?://[^/]+/||; s|\\.git$||')
1457
+ fi
1458
+ fi
1459
+
1338
1460
  RESP=$(curl -sS -G "\${GATEWAY_URL}/api/v1/cli/session-context" \\
1339
1461
  --data-urlencode "cwd=$CWD" \\
1462
+ --data-urlencode "repo=$GIT_REPO" \\
1340
1463
  -H "Authorization: Bearer $JWT" \\
1341
1464
  --max-time 2 2>/dev/null || echo "")
1342
1465
 
@@ -2075,12 +2198,64 @@ function getAccessToken() {
2075
2198
  const creds = loadCredentials();
2076
2199
  return creds?.access_token || null;
2077
2200
  }
2201
+ function isTokenExpired() {
2202
+ const creds = loadCredentials();
2203
+ if (!creds) return true;
2204
+ try {
2205
+ const decoded = jwt.decode(creds.access_token);
2206
+ if (!decoded?.exp) return true;
2207
+ const expiresAt = decoded.exp * 1e3;
2208
+ const buffer = 5 * 60 * 1e3;
2209
+ return Date.now() > expiresAt - buffer;
2210
+ } catch {
2211
+ return true;
2212
+ }
2213
+ }
2214
+ async function refreshToken() {
2215
+ const creds = loadCredentials();
2216
+ if (!creds?.refresh_token) return false;
2217
+ try {
2218
+ const response = await fetch(`${SYNKRO_WEB_AUTH_URL}/api/auth/refresh`, {
2219
+ method: "POST",
2220
+ headers: { "Content-Type": "application/json" },
2221
+ body: JSON.stringify({ refresh_token: creds.refresh_token })
2222
+ });
2223
+ if (!response.ok) return false;
2224
+ const data = await response.json();
2225
+ if (data.access_token) {
2226
+ saveCredentials({
2227
+ access_token: data.access_token,
2228
+ refresh_token: data.refresh_token || creds.refresh_token
2229
+ });
2230
+ return true;
2231
+ }
2232
+ return false;
2233
+ } catch {
2234
+ return false;
2235
+ }
2236
+ }
2237
+ async function ensureValidToken() {
2238
+ if (!isAuthenticated()) return false;
2239
+ if (isTokenExpired()) {
2240
+ if (!refreshPromise) {
2241
+ refreshPromise = refreshToken().finally(() => {
2242
+ refreshPromise = null;
2243
+ });
2244
+ }
2245
+ const refreshed = await refreshPromise;
2246
+ if (!refreshed) {
2247
+ clearCredentials();
2248
+ return false;
2249
+ }
2250
+ }
2251
+ return true;
2252
+ }
2078
2253
  function clearCredentials() {
2079
2254
  if (existsSync4(AUTH_FILE)) {
2080
2255
  unlinkSync2(AUTH_FILE);
2081
2256
  }
2082
2257
  }
2083
- var PORT, RAW_WEB_AUTH_URL, SYNKRO_WEB_AUTH_URL, AUTH_FILE, ERROR_HTML;
2258
+ var PORT, RAW_WEB_AUTH_URL, SYNKRO_WEB_AUTH_URL, AUTH_FILE, ERROR_HTML, refreshPromise;
2084
2259
  var init_stub = __esm({
2085
2260
  "cli/auth/stub.ts"() {
2086
2261
  "use strict";
@@ -2128,112 +2303,913 @@ var init_stub = __esm({
2128
2303
  </body>
2129
2304
  </html>
2130
2305
  `;
2306
+ refreshPromise = null;
2131
2307
  }
2132
2308
  });
2133
2309
 
2134
- // cli/commands/install.ts
2135
- var install_exports = {};
2136
- __export(install_exports, {
2137
- installCommand: () => installCommand,
2138
- parseArgs: () => parseArgs
2310
+ // ../../node_modules/.pnpm/dotenv@17.2.4/node_modules/dotenv/package.json
2311
+ var require_package = __commonJS({
2312
+ "../../node_modules/.pnpm/dotenv@17.2.4/node_modules/dotenv/package.json"(exports, module) {
2313
+ module.exports = {
2314
+ name: "dotenv",
2315
+ version: "17.2.4",
2316
+ description: "Loads environment variables from .env file",
2317
+ main: "lib/main.js",
2318
+ types: "lib/main.d.ts",
2319
+ exports: {
2320
+ ".": {
2321
+ types: "./lib/main.d.ts",
2322
+ require: "./lib/main.js",
2323
+ default: "./lib/main.js"
2324
+ },
2325
+ "./config": "./config.js",
2326
+ "./config.js": "./config.js",
2327
+ "./lib/env-options": "./lib/env-options.js",
2328
+ "./lib/env-options.js": "./lib/env-options.js",
2329
+ "./lib/cli-options": "./lib/cli-options.js",
2330
+ "./lib/cli-options.js": "./lib/cli-options.js",
2331
+ "./package.json": "./package.json"
2332
+ },
2333
+ scripts: {
2334
+ "dts-check": "tsc --project tests/types/tsconfig.json",
2335
+ lint: "standard",
2336
+ pretest: "npm run lint && npm run dts-check",
2337
+ test: "tap run tests/**/*.js --allow-empty-coverage --disable-coverage --timeout=60000",
2338
+ "test:coverage": "tap run tests/**/*.js --show-full-coverage --timeout=60000 --coverage-report=text --coverage-report=lcov",
2339
+ prerelease: "npm test",
2340
+ release: "standard-version"
2341
+ },
2342
+ repository: {
2343
+ type: "git",
2344
+ url: "git://github.com/motdotla/dotenv.git"
2345
+ },
2346
+ homepage: "https://github.com/motdotla/dotenv#readme",
2347
+ funding: "https://dotenvx.com",
2348
+ keywords: [
2349
+ "dotenv",
2350
+ "env",
2351
+ ".env",
2352
+ "environment",
2353
+ "variables",
2354
+ "config",
2355
+ "settings"
2356
+ ],
2357
+ readmeFilename: "README.md",
2358
+ license: "BSD-2-Clause",
2359
+ devDependencies: {
2360
+ "@types/node": "^18.11.3",
2361
+ decache: "^4.6.2",
2362
+ sinon: "^14.0.1",
2363
+ standard: "^17.0.0",
2364
+ "standard-version": "^9.5.0",
2365
+ tap: "^19.2.0",
2366
+ typescript: "^4.8.4"
2367
+ },
2368
+ engines: {
2369
+ node: ">=12"
2370
+ },
2371
+ browser: {
2372
+ fs: false
2373
+ }
2374
+ };
2375
+ }
2139
2376
  });
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;
2377
+
2378
+ // ../../node_modules/.pnpm/dotenv@17.2.4/node_modules/dotenv/lib/main.js
2379
+ var require_main = __commonJS({
2380
+ "../../node_modules/.pnpm/dotenv@17.2.4/node_modules/dotenv/lib/main.js"(exports, module) {
2381
+ "use strict";
2382
+ var fs = __require("fs");
2383
+ var path = __require("path");
2384
+ var os = __require("os");
2385
+ var crypto = __require("crypto");
2386
+ var packageJson = require_package();
2387
+ var version = packageJson.version;
2388
+ var TIPS = [
2389
+ "\u{1F510} encrypt with Dotenvx: https://dotenvx.com",
2390
+ "\u{1F510} prevent committing .env to code: https://dotenvx.com/precommit",
2391
+ "\u{1F510} prevent building .env in docker: https://dotenvx.com/prebuild",
2392
+ "\u{1F4E1} add observability to secrets: https://dotenvx.com/ops",
2393
+ "\u{1F465} sync secrets across teammates & machines: https://dotenvx.com/ops",
2394
+ "\u{1F5C2}\uFE0F backup and recover secrets: https://dotenvx.com/ops",
2395
+ "\u2705 audit secrets and track compliance: https://dotenvx.com/ops",
2396
+ "\u{1F504} add secrets lifecycle management: https://dotenvx.com/ops",
2397
+ "\u{1F511} add access controls to secrets: https://dotenvx.com/ops",
2398
+ "\u{1F6E0}\uFE0F run anywhere with `dotenvx run -- yourcommand`",
2399
+ "\u2699\uFE0F specify custom .env file path with { path: '/custom/path/.env' }",
2400
+ "\u2699\uFE0F enable debug logging with { debug: true }",
2401
+ "\u2699\uFE0F override existing env vars with { override: true }",
2402
+ "\u2699\uFE0F suppress all logs with { quiet: true }",
2403
+ "\u2699\uFE0F write to custom object with { processEnv: myObject }",
2404
+ "\u2699\uFE0F load multiple .env files with { path: ['.env.local', '.env'] }"
2405
+ ];
2406
+ function _getRandomTip() {
2407
+ return TIPS[Math.floor(Math.random() * TIPS.length)];
2408
+ }
2409
+ function parseBoolean(value) {
2410
+ if (typeof value === "string") {
2411
+ return !["false", "0", "no", "off", ""].includes(value.toLowerCase());
2412
+ }
2413
+ return Boolean(value);
2414
+ }
2415
+ function supportsAnsi() {
2416
+ return process.stdout.isTTY;
2417
+ }
2418
+ function dim(text) {
2419
+ return supportsAnsi() ? `\x1B[2m${text}\x1B[0m` : text;
2420
+ }
2421
+ var LINE = /(?:^|^)\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?(?:$|$)/mg;
2422
+ function parse(src) {
2423
+ const obj = {};
2424
+ let lines = src.toString();
2425
+ lines = lines.replace(/\r\n?/mg, "\n");
2426
+ let match;
2427
+ while ((match = LINE.exec(lines)) != null) {
2428
+ const key = match[1];
2429
+ let value = match[2] || "";
2430
+ value = value.trim();
2431
+ const maybeQuote = value[0];
2432
+ value = value.replace(/^(['"`])([\s\S]*)\1$/mg, "$2");
2433
+ if (maybeQuote === '"') {
2434
+ value = value.replace(/\\n/g, "\n");
2435
+ value = value.replace(/\\r/g, "\r");
2436
+ }
2437
+ obj[key] = value;
2438
+ }
2439
+ return obj;
2440
+ }
2441
+ function _parseVault(options) {
2442
+ options = options || {};
2443
+ const vaultPath = _vaultPath(options);
2444
+ options.path = vaultPath;
2445
+ const result = DotenvModule.configDotenv(options);
2446
+ if (!result.parsed) {
2447
+ const err = new Error(`MISSING_DATA: Cannot parse ${vaultPath} for an unknown reason`);
2448
+ err.code = "MISSING_DATA";
2449
+ throw err;
2450
+ }
2451
+ const keys = _dotenvKey(options).split(",");
2452
+ const length = keys.length;
2453
+ let decrypted;
2454
+ for (let i = 0; i < length; i++) {
2455
+ try {
2456
+ const key = keys[i].trim();
2457
+ const attrs = _instructions(result, key);
2458
+ decrypted = DotenvModule.decrypt(attrs.ciphertext, attrs.key);
2459
+ break;
2460
+ } catch (error) {
2461
+ if (i + 1 >= length) {
2462
+ throw error;
2463
+ }
2464
+ }
2465
+ }
2466
+ return DotenvModule.parse(decrypted);
2467
+ }
2468
+ function _warn(message) {
2469
+ console.error(`[dotenv@${version}][WARN] ${message}`);
2470
+ }
2471
+ function _debug(message) {
2472
+ console.log(`[dotenv@${version}][DEBUG] ${message}`);
2473
+ }
2474
+ function _log(message) {
2475
+ console.log(`[dotenv@${version}] ${message}`);
2476
+ }
2477
+ function _dotenvKey(options) {
2478
+ if (options && options.DOTENV_KEY && options.DOTENV_KEY.length > 0) {
2479
+ return options.DOTENV_KEY;
2480
+ }
2481
+ if (process.env.DOTENV_KEY && process.env.DOTENV_KEY.length > 0) {
2482
+ return process.env.DOTENV_KEY;
2483
+ }
2484
+ return "";
2485
+ }
2486
+ function _instructions(result, dotenvKey) {
2487
+ let uri;
2488
+ try {
2489
+ uri = new URL(dotenvKey);
2490
+ } catch (error) {
2491
+ if (error.code === "ERR_INVALID_URL") {
2492
+ const err = new Error("INVALID_DOTENV_KEY: Wrong format. Must be in valid uri format like dotenv://:key_1234@dotenvx.com/vault/.env.vault?environment=development");
2493
+ err.code = "INVALID_DOTENV_KEY";
2494
+ throw err;
2495
+ }
2496
+ throw error;
2497
+ }
2498
+ const key = uri.password;
2499
+ if (!key) {
2500
+ const err = new Error("INVALID_DOTENV_KEY: Missing key part");
2501
+ err.code = "INVALID_DOTENV_KEY";
2502
+ throw err;
2503
+ }
2504
+ const environment = uri.searchParams.get("environment");
2505
+ if (!environment) {
2506
+ const err = new Error("INVALID_DOTENV_KEY: Missing environment part");
2507
+ err.code = "INVALID_DOTENV_KEY";
2508
+ throw err;
2509
+ }
2510
+ const environmentKey = `DOTENV_VAULT_${environment.toUpperCase()}`;
2511
+ const ciphertext = result.parsed[environmentKey];
2512
+ if (!ciphertext) {
2513
+ const err = new Error(`NOT_FOUND_DOTENV_ENVIRONMENT: Cannot locate environment ${environmentKey} in your .env.vault file.`);
2514
+ err.code = "NOT_FOUND_DOTENV_ENVIRONMENT";
2515
+ throw err;
2516
+ }
2517
+ return { ciphertext, key };
2518
+ }
2519
+ function _vaultPath(options) {
2520
+ let possibleVaultPath = null;
2521
+ if (options && options.path && options.path.length > 0) {
2522
+ if (Array.isArray(options.path)) {
2523
+ for (const filepath of options.path) {
2524
+ if (fs.existsSync(filepath)) {
2525
+ possibleVaultPath = filepath.endsWith(".vault") ? filepath : `${filepath}.vault`;
2526
+ }
2527
+ }
2528
+ } else {
2529
+ possibleVaultPath = options.path.endsWith(".vault") ? options.path : `${options.path}.vault`;
2530
+ }
2531
+ } else {
2532
+ possibleVaultPath = path.resolve(process.cwd(), ".env.vault");
2533
+ }
2534
+ if (fs.existsSync(possibleVaultPath)) {
2535
+ return possibleVaultPath;
2536
+ }
2537
+ return null;
2538
+ }
2539
+ function _resolveHome(envPath) {
2540
+ return envPath[0] === "~" ? path.join(os.homedir(), envPath.slice(1)) : envPath;
2541
+ }
2542
+ function _configVault(options) {
2543
+ const debug = parseBoolean(process.env.DOTENV_CONFIG_DEBUG || options && options.debug);
2544
+ const quiet = parseBoolean(process.env.DOTENV_CONFIG_QUIET || options && options.quiet);
2545
+ if (debug || !quiet) {
2546
+ _log("Loading env from encrypted .env.vault");
2547
+ }
2548
+ const parsed = DotenvModule._parseVault(options);
2549
+ let processEnv = process.env;
2550
+ if (options && options.processEnv != null) {
2551
+ processEnv = options.processEnv;
2552
+ }
2553
+ DotenvModule.populate(processEnv, parsed, options);
2554
+ return { parsed };
2555
+ }
2556
+ function configDotenv(options) {
2557
+ const dotenvPath = path.resolve(process.cwd(), ".env");
2558
+ let encoding = "utf8";
2559
+ let processEnv = process.env;
2560
+ if (options && options.processEnv != null) {
2561
+ processEnv = options.processEnv;
2562
+ }
2563
+ let debug = parseBoolean(processEnv.DOTENV_CONFIG_DEBUG || options && options.debug);
2564
+ let quiet = parseBoolean(processEnv.DOTENV_CONFIG_QUIET || options && options.quiet);
2565
+ if (options && options.encoding) {
2566
+ encoding = options.encoding;
2567
+ } else {
2568
+ if (debug) {
2569
+ _debug("No encoding is specified. UTF-8 is used by default");
2570
+ }
2571
+ }
2572
+ let optionPaths = [dotenvPath];
2573
+ if (options && options.path) {
2574
+ if (!Array.isArray(options.path)) {
2575
+ optionPaths = [_resolveHome(options.path)];
2576
+ } else {
2577
+ optionPaths = [];
2578
+ for (const filepath of options.path) {
2579
+ optionPaths.push(_resolveHome(filepath));
2580
+ }
2581
+ }
2582
+ }
2583
+ let lastError;
2584
+ const parsedAll = {};
2585
+ for (const path2 of optionPaths) {
2586
+ try {
2587
+ const parsed = DotenvModule.parse(fs.readFileSync(path2, { encoding }));
2588
+ DotenvModule.populate(parsedAll, parsed, options);
2589
+ } catch (e) {
2590
+ if (debug) {
2591
+ _debug(`Failed to load ${path2} ${e.message}`);
2592
+ }
2593
+ lastError = e;
2594
+ }
2595
+ }
2596
+ const populated = DotenvModule.populate(processEnv, parsedAll, options);
2597
+ debug = parseBoolean(processEnv.DOTENV_CONFIG_DEBUG || debug);
2598
+ quiet = parseBoolean(processEnv.DOTENV_CONFIG_QUIET || quiet);
2599
+ if (debug || !quiet) {
2600
+ const keysCount = Object.keys(populated).length;
2601
+ const shortPaths = [];
2602
+ for (const filePath of optionPaths) {
2603
+ try {
2604
+ const relative = path.relative(process.cwd(), filePath);
2605
+ shortPaths.push(relative);
2606
+ } catch (e) {
2607
+ if (debug) {
2608
+ _debug(`Failed to load ${filePath} ${e.message}`);
2609
+ }
2610
+ lastError = e;
2611
+ }
2612
+ }
2613
+ _log(`injecting env (${keysCount}) from ${shortPaths.join(",")} ${dim(`-- tip: ${_getRandomTip()}`)}`);
2614
+ }
2615
+ if (lastError) {
2616
+ return { parsed: parsedAll, error: lastError };
2617
+ } else {
2618
+ return { parsed: parsedAll };
2619
+ }
2620
+ }
2621
+ function config2(options) {
2622
+ if (_dotenvKey(options).length === 0) {
2623
+ return DotenvModule.configDotenv(options);
2624
+ }
2625
+ const vaultPath = _vaultPath(options);
2626
+ if (!vaultPath) {
2627
+ _warn(`You set DOTENV_KEY but you are missing a .env.vault file at ${vaultPath}. Did you forget to build it?`);
2628
+ return DotenvModule.configDotenv(options);
2629
+ }
2630
+ return DotenvModule._configVault(options);
2631
+ }
2632
+ function decrypt(encrypted, keyStr) {
2633
+ const key = Buffer.from(keyStr.slice(-64), "hex");
2634
+ let ciphertext = Buffer.from(encrypted, "base64");
2635
+ const nonce = ciphertext.subarray(0, 12);
2636
+ const authTag = ciphertext.subarray(-16);
2637
+ ciphertext = ciphertext.subarray(12, -16);
2638
+ try {
2639
+ const aesgcm = crypto.createDecipheriv("aes-256-gcm", key, nonce);
2640
+ aesgcm.setAuthTag(authTag);
2641
+ return `${aesgcm.update(ciphertext)}${aesgcm.final()}`;
2642
+ } catch (error) {
2643
+ const isRange = error instanceof RangeError;
2644
+ const invalidKeyLength = error.message === "Invalid key length";
2645
+ const decryptionFailed = error.message === "Unsupported state or unable to authenticate data";
2646
+ if (isRange || invalidKeyLength) {
2647
+ const err = new Error("INVALID_DOTENV_KEY: It must be 64 characters long (or more)");
2648
+ err.code = "INVALID_DOTENV_KEY";
2649
+ throw err;
2650
+ } else if (decryptionFailed) {
2651
+ const err = new Error("DECRYPTION_FAILED: Please check your DOTENV_KEY");
2652
+ err.code = "DECRYPTION_FAILED";
2653
+ throw err;
2654
+ } else {
2655
+ throw error;
2656
+ }
2657
+ }
2658
+ }
2659
+ function populate(processEnv, parsed, options = {}) {
2660
+ const debug = Boolean(options && options.debug);
2661
+ const override = Boolean(options && options.override);
2662
+ const populated = {};
2663
+ if (typeof parsed !== "object") {
2664
+ const err = new Error("OBJECT_REQUIRED: Please check the processEnv argument being passed to populate");
2665
+ err.code = "OBJECT_REQUIRED";
2666
+ throw err;
2667
+ }
2668
+ for (const key of Object.keys(parsed)) {
2669
+ if (Object.prototype.hasOwnProperty.call(processEnv, key)) {
2670
+ if (override === true) {
2671
+ processEnv[key] = parsed[key];
2672
+ populated[key] = parsed[key];
2673
+ }
2674
+ if (debug) {
2675
+ if (override === true) {
2676
+ _debug(`"${key}" is already defined and WAS overwritten`);
2677
+ } else {
2678
+ _debug(`"${key}" is already defined and was NOT overwritten`);
2679
+ }
2680
+ }
2681
+ } else {
2682
+ processEnv[key] = parsed[key];
2683
+ populated[key] = parsed[key];
2684
+ }
2685
+ }
2686
+ return populated;
2687
+ }
2688
+ var DotenvModule = {
2689
+ configDotenv,
2690
+ _configVault,
2691
+ _parseVault,
2692
+ config: config2,
2693
+ decrypt,
2694
+ parse,
2695
+ populate
2696
+ };
2697
+ module.exports.configDotenv = DotenvModule.configDotenv;
2698
+ module.exports._configVault = DotenvModule._configVault;
2699
+ module.exports._parseVault = DotenvModule._parseVault;
2700
+ module.exports.config = DotenvModule.config;
2701
+ module.exports.decrypt = DotenvModule.decrypt;
2702
+ module.exports.parse = DotenvModule.parse;
2703
+ module.exports.populate = DotenvModule.populate;
2704
+ module.exports = DotenvModule;
2155
2705
  }
2156
- if (!opts.gatewayUrl) {
2157
- const fromEnv = sanitizeGatewayCandidate(process.env.SYNKRO_GATEWAY_URL);
2158
- if (fromEnv) opts.gatewayUrl = fromEnv;
2706
+ });
2707
+
2708
+ // cli/auth/index.ts
2709
+ var init_auth = __esm({
2710
+ "cli/auth/index.ts"() {
2711
+ "use strict";
2712
+ init_stub();
2159
2713
  }
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
2714
+ });
2715
+
2716
+ // cli/api/projects.ts
2717
+ async function callApi(method, endpoint, body) {
2718
+ if (!API_URL) {
2719
+ throw new Error("SYNKRO_CRUD_URL (or SYNKRO_API_URL) is not set. Add it to your .env file.");
2720
+ }
2721
+ const url = `${API_URL}${endpoint}`;
2722
+ const accessToken = getAccessToken();
2723
+ const headers = {
2724
+ "Content-Type": "application/json"
2201
2725
  };
2726
+ if (accessToken) {
2727
+ headers["Authorization"] = `Bearer ${accessToken}`;
2728
+ }
2729
+ const response = await fetch(url, {
2730
+ method,
2731
+ headers,
2732
+ body: body ? JSON.stringify(body) : void 0
2733
+ });
2734
+ if (!response.ok) {
2735
+ const error = await response.json().catch(() => ({ detail: response.statusText }));
2736
+ throw new Error(error.detail || `API error: ${response.status}`);
2737
+ }
2738
+ return response.json();
2202
2739
  }
2203
- function sanitizeConfigValue(raw, maxLen = 256) {
2204
- if (!raw) return "";
2205
- return raw.replace(/[^\x20-\x7E]/g, "").slice(0, maxLen);
2740
+ async function createProject(name, repos) {
2741
+ const body = { name };
2742
+ if (repos && repos.length > 0) body.repos = repos;
2743
+ return callApi("POST", "/projects", body);
2206
2744
  }
2207
- function shellQuoteSingle(value) {
2208
- return `'${value.replace(/'/g, "'\\''")}'`;
2745
+ async function listProjects() {
2746
+ return callApi("GET", "/projects");
2209
2747
  }
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);
2748
+ async function unlinkRepo(projectId, repoId) {
2749
+ return callApi("DELETE", `/projects/${projectId}/repos/${repoId}`);
2232
2750
  }
2233
- function assertGatewayAllowed(gatewayUrl) {
2234
- let parsed;
2235
- try {
2236
- parsed = new URL(gatewayUrl);
2751
+ var import_dotenv, API_URL;
2752
+ var init_projects = __esm({
2753
+ "cli/api/projects.ts"() {
2754
+ "use strict";
2755
+ import_dotenv = __toESM(require_main(), 1);
2756
+ init_auth();
2757
+ (0, import_dotenv.config)({ quiet: true });
2758
+ API_URL = process.env.SYNKRO_CRUD_URL || process.env.SYNKRO_API_URL;
2759
+ }
2760
+ });
2761
+
2762
+ // cli/installer/workflowTemplate.ts
2763
+ var SYNKRO_WORKFLOW_YAML, WORKFLOW_PATH;
2764
+ var init_workflowTemplate = __esm({
2765
+ "cli/installer/workflowTemplate.ts"() {
2766
+ "use strict";
2767
+ SYNKRO_WORKFLOW_YAML = `name: Synkro Security Review
2768
+ on:
2769
+ pull_request:
2770
+ types: [opened, synchronize, reopened]
2771
+
2772
+ jobs:
2773
+ scan:
2774
+ runs-on: ubuntu-latest
2775
+ permissions:
2776
+ contents: read
2777
+ pull-requests: write
2778
+ checks: write
2779
+ steps:
2780
+ - uses: actions/checkout@v4
2781
+ with:
2782
+ fetch-depth: 0
2783
+
2784
+ - name: Cache npm globals
2785
+ id: cache-npm-global
2786
+ uses: actions/cache@v4
2787
+ with:
2788
+ path: ~/.npm-global
2789
+ key: synkro-cli-\${{ runner.os }}-v1
2790
+
2791
+ - name: Install Synkro CLI + Claude Code CLI
2792
+ run: |
2793
+ npm config set prefix ~/.npm-global
2794
+ npm install -g @synkro-sh/cli @anthropic-ai/claude-code
2795
+ echo "~/.npm-global/bin" >> $GITHUB_PATH
2796
+
2797
+ - name: Run Synkro PR scan
2798
+ run: synkro-cli scan-pr
2799
+ env:
2800
+ CLAUDE_CODE_OAUTH_TOKEN: \${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
2801
+ SYNKRO_API_KEY: \${{ secrets.SYNKRO_API_KEY }}
2802
+ GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
2803
+ SYNKRO_PR_NUMBER: \${{ github.event.pull_request.number }}
2804
+ SYNKRO_REPO: \${{ github.repository }}
2805
+ SYNKRO_SHA: \${{ github.event.pull_request.head.sha }}
2806
+ SYNKRO_GATEWAY_URL: \${{ vars.SYNKRO_GATEWAY_URL || 'https://api.synkro.sh' }}
2807
+ `;
2808
+ WORKFLOW_PATH = ".github/workflows/synkro.yml";
2809
+ }
2810
+ });
2811
+
2812
+ // cli/installer/githubSetup.ts
2813
+ import { existsSync as existsSync5, mkdirSync as mkdirSync4, writeFileSync as writeFileSync4 } from "fs";
2814
+ import { join as join4 } from "path";
2815
+ async function encryptSecret(publicKeyBase64, secret) {
2816
+ const sodium = await import("libsodium-wrappers").then((m) => m.default ?? m);
2817
+ await sodium.ready;
2818
+ const keyBytes = sodium.from_base64(publicKeyBase64, sodium.base64_variants.ORIGINAL);
2819
+ const messageBytes = sodium.from_string(secret);
2820
+ const encryptedBytes = sodium.crypto_box_seal(messageBytes, keyBytes);
2821
+ return sodium.to_base64(encryptedBytes, sodium.base64_variants.ORIGINAL);
2822
+ }
2823
+ async function getRepoPublicKey(opts, owner, repo) {
2824
+ const url = `https://api.github.com/repos/${owner}/${repo}/actions/secrets/public-key`;
2825
+ const resp = await fetch(url, {
2826
+ headers: {
2827
+ Authorization: `Bearer ${opts.token}`,
2828
+ Accept: "application/vnd.github+json",
2829
+ "X-GitHub-Api-Version": "2022-11-28"
2830
+ }
2831
+ });
2832
+ if (!resp.ok) {
2833
+ const text = await resp.text().catch(() => "");
2834
+ throw new Error(`GitHub API ${resp.status} fetching public key for ${owner}/${repo}: ${text.slice(0, 200)}`);
2835
+ }
2836
+ return await resp.json();
2837
+ }
2838
+ async function putRepoSecret(opts, owner, repo, secretName, secretValue, publicKey) {
2839
+ const encryptedValue = await encryptSecret(publicKey.key, secretValue);
2840
+ const url = `https://api.github.com/repos/${owner}/${repo}/actions/secrets/${encodeURIComponent(secretName)}`;
2841
+ const resp = await fetch(url, {
2842
+ method: "PUT",
2843
+ headers: {
2844
+ Authorization: `Bearer ${opts.token}`,
2845
+ Accept: "application/vnd.github+json",
2846
+ "X-GitHub-Api-Version": "2022-11-28",
2847
+ "Content-Type": "application/json"
2848
+ },
2849
+ body: JSON.stringify({
2850
+ encrypted_value: encryptedValue,
2851
+ key_id: publicKey.key_id
2852
+ })
2853
+ });
2854
+ if (!resp.ok) {
2855
+ const text = await resp.text().catch(() => "");
2856
+ throw new Error(`GitHub API ${resp.status} setting secret ${secretName}: ${text.slice(0, 200)}`);
2857
+ }
2858
+ }
2859
+ async function listAccessibleRepos(opts) {
2860
+ const repos = [];
2861
+ let page = 1;
2862
+ while (page <= 5) {
2863
+ const url = `https://api.github.com/user/repos?per_page=100&page=${page}&affiliation=owner,collaborator`;
2864
+ const resp = await fetch(url, {
2865
+ headers: {
2866
+ Authorization: `Bearer ${opts.token}`,
2867
+ Accept: "application/vnd.github+json",
2868
+ "X-GitHub-Api-Version": "2022-11-28"
2869
+ }
2870
+ });
2871
+ if (!resp.ok) {
2872
+ throw new Error(`GitHub API ${resp.status} listing repos`);
2873
+ }
2874
+ const data = await resp.json();
2875
+ if (data.length === 0) break;
2876
+ for (const r of data) {
2877
+ repos.push({ owner: r.owner.login, repo: r.name, full_name: r.full_name });
2878
+ }
2879
+ if (data.length < 100) break;
2880
+ page++;
2881
+ }
2882
+ return repos;
2883
+ }
2884
+ async function pushSecretsToRepo(opts, owner, repo, secrets) {
2885
+ const pubkey = await getRepoPublicKey(opts, owner, repo);
2886
+ await putRepoSecret(opts, owner, repo, "CLAUDE_CODE_OAUTH_TOKEN", secrets.claudeCodeOauthToken, pubkey);
2887
+ await putRepoSecret(opts, owner, repo, "SYNKRO_API_KEY", secrets.synkroApiKey, pubkey);
2888
+ }
2889
+ function writeWorkflowFile(repoRootPath) {
2890
+ const workflowDir = join4(repoRootPath, ".github", "workflows");
2891
+ mkdirSync4(workflowDir, { recursive: true });
2892
+ const workflowFile = join4(workflowDir, "synkro.yml");
2893
+ writeFileSync4(workflowFile, SYNKRO_WORKFLOW_YAML, "utf-8");
2894
+ return workflowFile;
2895
+ }
2896
+ function findGitRoot(startCwd) {
2897
+ let cur = startCwd;
2898
+ while (cur && cur !== "/") {
2899
+ if (existsSync5(join4(cur, ".git"))) return cur;
2900
+ const parent = join4(cur, "..");
2901
+ if (parent === cur) break;
2902
+ cur = parent;
2903
+ }
2904
+ return null;
2905
+ }
2906
+ var SECRET_NAMES, WORKFLOW_RELATIVE_PATH;
2907
+ var init_githubSetup = __esm({
2908
+ "cli/installer/githubSetup.ts"() {
2909
+ "use strict";
2910
+ init_workflowTemplate();
2911
+ SECRET_NAMES = {
2912
+ CLAUDE_OAUTH: "CLAUDE_CODE_OAUTH_TOKEN",
2913
+ SYNKRO_API_KEY: "SYNKRO_API_KEY"
2914
+ };
2915
+ WORKFLOW_RELATIVE_PATH = WORKFLOW_PATH;
2916
+ }
2917
+ });
2918
+
2919
+ // cli/commands/repoConnect.ts
2920
+ import { execSync as execSync2 } from "child_process";
2921
+ import { createServer as createServer2 } from "http";
2922
+ import { createInterface } from "readline";
2923
+ function detectGitRepo() {
2924
+ try {
2925
+ const remoteUrl = execSync2("git remote get-url origin", { encoding: "utf-8", timeout: 5e3 }).trim();
2926
+ const match = remoteUrl.match(/(?:github\.com|gitlab\.com|bitbucket\.org)[:/](.+?)(?:\.git)?$/);
2927
+ if (!match) return null;
2928
+ const fullName = match[1];
2929
+ return { fullName, shortName: fullName.split("/").pop() || fullName };
2930
+ } catch {
2931
+ return null;
2932
+ }
2933
+ }
2934
+ function ask(rl, question) {
2935
+ return new Promise((resolve2) => rl.question(question, resolve2));
2936
+ }
2937
+ function waitForGithubToken() {
2938
+ return new Promise((resolve2, reject) => {
2939
+ const server = createServer2((req, res) => {
2940
+ if (req.method === "OPTIONS") {
2941
+ res.writeHead(204, {
2942
+ "Access-Control-Allow-Origin": SYNKRO_WEB_AUTH_URL2,
2943
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
2944
+ "Access-Control-Allow-Headers": "Content-Type"
2945
+ });
2946
+ res.end();
2947
+ return;
2948
+ }
2949
+ if (req.url !== "/auth" || req.method !== "POST") {
2950
+ res.writeHead(404);
2951
+ res.end();
2952
+ return;
2953
+ }
2954
+ let body = "";
2955
+ req.on("data", (chunk) => {
2956
+ body += chunk;
2957
+ });
2958
+ req.on("end", () => {
2959
+ try {
2960
+ const parsed = JSON.parse(body);
2961
+ if (!parsed.github_token) {
2962
+ res.writeHead(400, {
2963
+ "Content-Type": "application/json",
2964
+ "Access-Control-Allow-Origin": SYNKRO_WEB_AUTH_URL2
2965
+ });
2966
+ res.end(JSON.stringify({ error: "missing github_token" }));
2967
+ return;
2968
+ }
2969
+ res.writeHead(200, {
2970
+ "Content-Type": "application/json",
2971
+ "Access-Control-Allow-Origin": SYNKRO_WEB_AUTH_URL2
2972
+ });
2973
+ res.end(JSON.stringify({ ok: true }));
2974
+ setTimeout(() => server.close(), 200);
2975
+ resolve2(parsed.github_token);
2976
+ } catch {
2977
+ res.writeHead(400, { "Content-Type": "application/json" });
2978
+ res.end(JSON.stringify({ error: "invalid json" }));
2979
+ }
2980
+ });
2981
+ });
2982
+ server.on("error", (err) => {
2983
+ if (err.code === "EADDRINUSE") {
2984
+ reject(new Error(`Port ${GITHUB_PORT} is in use. Close other processes and try again.`));
2985
+ } else {
2986
+ reject(err);
2987
+ }
2988
+ });
2989
+ server.listen(GITHUB_PORT);
2990
+ });
2991
+ }
2992
+ function openBrowser2(url) {
2993
+ const { execFile: execFile2 } = __require("child_process");
2994
+ const plat = process.platform;
2995
+ if (plat === "darwin") execFile2("open", [url]);
2996
+ else if (plat === "win32") execFile2("cmd", ["/c", "start", "", url]);
2997
+ else execFile2("xdg-open", [url]);
2998
+ }
2999
+ async function connectGithubAndSelectRepos() {
3000
+ const url = `${SYNKRO_WEB_AUTH_URL2}/cli-github?port=${GITHUB_PORT}`;
3001
+ console.log(" Opening browser for GitHub authorization...");
3002
+ openBrowser2(url);
3003
+ console.log(" Waiting for GitHub authorization...");
3004
+ const ghToken = await waitForGithubToken();
3005
+ console.log(" \u2713 GitHub connected\n");
3006
+ const repos = await listAccessibleRepos({ token: ghToken });
3007
+ if (repos.length === 0) {
3008
+ console.log(" No accessible repos found on GitHub.");
3009
+ return [];
3010
+ }
3011
+ console.log(` Found ${repos.length} repos:
3012
+ `);
3013
+ repos.forEach((r, i) => {
3014
+ console.log(` ${String(i + 1).padStart(3)}. ${r.full_name}`);
3015
+ });
3016
+ console.log();
3017
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
3018
+ try {
3019
+ const selection = await ask(rl, " Select repos (comma-separated numbers, e.g. 1,3,5): ");
3020
+ const indices = selection.split(",").map((s) => parseInt(s.trim(), 10) - 1).filter((n) => !isNaN(n) && n >= 0 && n < repos.length);
3021
+ if (indices.length === 0) {
3022
+ console.log(" No repos selected.");
3023
+ return [];
3024
+ }
3025
+ return indices.map((i) => ({ full_name: repos[i].full_name }));
3026
+ } finally {
3027
+ rl.close();
3028
+ }
3029
+ }
3030
+ async function promptRepoConnection() {
3031
+ const localRepo = detectGitRepo();
3032
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
3033
+ try {
3034
+ console.log("Connect repos to Synkro:\n");
3035
+ const options = [];
3036
+ if (localRepo) {
3037
+ options.push(`Link this repo (${localRepo.fullName})`);
3038
+ }
3039
+ options.push("Connect GitHub to select repos");
3040
+ options.push("Skip for now");
3041
+ options.forEach((opt, i) => {
3042
+ console.log(` ${i + 1}. ${opt}`);
3043
+ });
3044
+ console.log();
3045
+ const choice = await ask(rl, " Choose (number): ");
3046
+ const choiceNum = parseInt(choice.trim(), 10);
3047
+ console.log();
3048
+ rl.close();
3049
+ const localIdx = localRepo ? 1 : -1;
3050
+ const githubIdx = localRepo ? 2 : 1;
3051
+ const skipIdx = localRepo ? 3 : 2;
3052
+ if (choiceNum === localIdx && localRepo) {
3053
+ try {
3054
+ const existing = await listProjects();
3055
+ const alreadyLinked = existing.some(
3056
+ (p) => p.repos?.some((r) => r.full_name === localRepo.fullName)
3057
+ );
3058
+ if (!alreadyLinked) {
3059
+ await createProject(localRepo.shortName, [{ full_name: localRepo.fullName }]);
3060
+ console.log(` \u2713 Created project "${localRepo.shortName}" linked to ${localRepo.fullName}`);
3061
+ } else {
3062
+ console.log(` \u2713 ${localRepo.fullName} is already linked to a Synkro project.`);
3063
+ }
3064
+ } catch (err) {
3065
+ console.warn(` \u26A0 Could not link repo: ${err.message}`);
3066
+ }
3067
+ } else if (choiceNum === githubIdx) {
3068
+ const selectedRepos = await connectGithubAndSelectRepos();
3069
+ if (selectedRepos.length > 0) {
3070
+ try {
3071
+ const existing = await listProjects();
3072
+ const existingFullNames = new Set(
3073
+ existing.flatMap((p) => (p.repos || []).map((r) => r.full_name))
3074
+ );
3075
+ const newRepos = selectedRepos.filter((r) => !existingFullNames.has(r.full_name));
3076
+ if (newRepos.length === 0) {
3077
+ console.log(" \u2713 All selected repos are already linked.");
3078
+ } else {
3079
+ const projectName = newRepos.length === 1 ? newRepos[0].full_name.split("/").pop() || "Project" : "Multi-Repo Project";
3080
+ await createProject(projectName, newRepos);
3081
+ console.log(` \u2713 Linked ${newRepos.length} repo(s) to project "${projectName}"`);
3082
+ }
3083
+ } catch (err) {
3084
+ console.warn(` \u26A0 Could not link repos: ${err.message}`);
3085
+ }
3086
+ }
3087
+ } else if (choiceNum === skipIdx) {
3088
+ console.log(" Skipped. Run `synkro link` later to connect repos.");
3089
+ } else {
3090
+ console.log(" Invalid choice. Skipping repo connection.");
3091
+ }
3092
+ } catch {
3093
+ rl.close();
3094
+ }
3095
+ console.log();
3096
+ }
3097
+ var RAW_WEB_AUTH_URL2, SYNKRO_WEB_AUTH_URL2, GITHUB_PORT;
3098
+ var init_repoConnect = __esm({
3099
+ "cli/commands/repoConnect.ts"() {
3100
+ "use strict";
3101
+ init_projects();
3102
+ init_githubSetup();
3103
+ RAW_WEB_AUTH_URL2 = process.env.SYNKRO_WEB_AUTH_URL;
3104
+ SYNKRO_WEB_AUTH_URL2 = RAW_WEB_AUTH_URL2 && /^https?:\/\//.test(RAW_WEB_AUTH_URL2) ? RAW_WEB_AUTH_URL2 : "https://app.synkro.sh";
3105
+ GITHUB_PORT = 8101;
3106
+ }
3107
+ });
3108
+
3109
+ // cli/commands/install.ts
3110
+ var install_exports = {};
3111
+ __export(install_exports, {
3112
+ installCommand: () => installCommand,
3113
+ parseArgs: () => parseArgs
3114
+ });
3115
+ import { existsSync as existsSync6, mkdirSync as mkdirSync5, writeFileSync as writeFileSync5, chmodSync, readFileSync as readFileSync4, readdirSync } from "fs";
3116
+ import { homedir as homedir4 } from "os";
3117
+ import { join as join5 } from "path";
3118
+ import { execSync as execSync3 } from "child_process";
3119
+ function sanitizeGatewayCandidate(raw) {
3120
+ if (!raw) return void 0;
3121
+ return /^https?:\/\//.test(raw) ? raw : void 0;
3122
+ }
3123
+ function parseArgs(argv) {
3124
+ const opts = {};
3125
+ for (const a of argv) {
3126
+ if (a.startsWith("--api-key=")) opts.apiKey = a.slice("--api-key=".length);
3127
+ else if (a.startsWith("--gateway=")) opts.gatewayUrl = a.slice("--gateway=".length);
3128
+ else if (a === "--skip-auth") opts.skipAuth = true;
3129
+ else if (a === "--no-mcp") opts.noMcp = true;
3130
+ else if (a === "--force" || a === "-f") opts.force = true;
3131
+ }
3132
+ if (!opts.gatewayUrl) {
3133
+ const fromEnv = sanitizeGatewayCandidate(process.env.SYNKRO_GATEWAY_URL);
3134
+ if (fromEnv) opts.gatewayUrl = fromEnv;
3135
+ }
3136
+ return opts;
3137
+ }
3138
+ function ensureSynkroDir() {
3139
+ mkdirSync5(SYNKRO_DIR, { recursive: true });
3140
+ mkdirSync5(HOOKS_DIR, { recursive: true });
3141
+ mkdirSync5(BIN_DIR, { recursive: true });
3142
+ }
3143
+ function writeGraderDaemon() {
3144
+ writeFileSync5(GRADER_DAEMON_PATH, GRADER_DAEMON_PY, "utf-8");
3145
+ chmodSync(GRADER_DAEMON_PATH, 493);
3146
+ writeFileSync5(GRADER_PRIMER_EDIT_PATH, GRADER_PRIMER_EDIT, "utf-8");
3147
+ chmodSync(GRADER_PRIMER_EDIT_PATH, 420);
3148
+ writeFileSync5(GRADER_PRIMER_BASH_PATH, GRADER_PRIMER_BASH, "utf-8");
3149
+ chmodSync(GRADER_PRIMER_BASH_PATH, 420);
3150
+ }
3151
+ function writeHookScripts() {
3152
+ const bashScriptPath = join5(HOOKS_DIR, "cc-bash-judge.sh");
3153
+ const bashFollowupScriptPath = join5(HOOKS_DIR, "cc-bash-followup.sh");
3154
+ const editCaptureScriptPath = join5(HOOKS_DIR, "cc-edit-capture.sh");
3155
+ const editPrecheckScriptPath = join5(HOOKS_DIR, "cc-edit-precheck.sh");
3156
+ const stopSummaryScriptPath = join5(HOOKS_DIR, "cc-stop-summary.sh");
3157
+ const sessionStartScriptPath = join5(HOOKS_DIR, "cc-session-start.sh");
3158
+ writeFileSync5(bashScriptPath, CC_BASH_JUDGE_SCRIPT, "utf-8");
3159
+ writeFileSync5(bashFollowupScriptPath, CC_BASH_FOLLOWUP_SCRIPT, "utf-8");
3160
+ writeFileSync5(editCaptureScriptPath, CC_EDIT_CAPTURE_SCRIPT, "utf-8");
3161
+ writeFileSync5(editPrecheckScriptPath, CC_EDIT_PRECHECK_SCRIPT, "utf-8");
3162
+ writeFileSync5(stopSummaryScriptPath, CC_STOP_SUMMARY_SCRIPT, "utf-8");
3163
+ writeFileSync5(sessionStartScriptPath, CC_SESSION_START_SCRIPT, "utf-8");
3164
+ chmodSync(bashScriptPath, 493);
3165
+ chmodSync(bashFollowupScriptPath, 493);
3166
+ chmodSync(editCaptureScriptPath, 493);
3167
+ chmodSync(editPrecheckScriptPath, 493);
3168
+ chmodSync(stopSummaryScriptPath, 493);
3169
+ chmodSync(sessionStartScriptPath, 493);
3170
+ return {
3171
+ bashScript: bashScriptPath,
3172
+ bashFollowupScript: bashFollowupScriptPath,
3173
+ editCaptureScript: editCaptureScriptPath,
3174
+ editPrecheckScript: editPrecheckScriptPath,
3175
+ stopSummaryScript: stopSummaryScriptPath,
3176
+ sessionStartScript: sessionStartScriptPath
3177
+ };
3178
+ }
3179
+ function sanitizeConfigValue(raw, maxLen = 256) {
3180
+ if (!raw) return "";
3181
+ return raw.replace(/[^\x20-\x7E]/g, "").slice(0, maxLen);
3182
+ }
3183
+ function shellQuoteSingle(value) {
3184
+ return `'${value.replace(/'/g, "'\\''")}'`;
3185
+ }
3186
+ function writeConfigEnv(opts) {
3187
+ const credsPath = join5(SYNKRO_DIR, "credentials.json");
3188
+ const safeGateway = sanitizeConfigValue(opts.gatewayUrl);
3189
+ const safeUserId = sanitizeConfigValue(opts.userId);
3190
+ const safeOrgId = sanitizeConfigValue(opts.orgId);
3191
+ const safeEmail = sanitizeConfigValue(opts.email);
3192
+ const safeTier = sanitizeConfigValue(opts.tier ?? "pro", 32);
3193
+ const lines = [
3194
+ "# Synkro CLI config (managed by synkro install)",
3195
+ "# JWT auth \u2014 the hook scripts read SYNKRO_CREDENTIALS_PATH at runtime",
3196
+ "# and send Authorization: Bearer <access_token> on every gateway call.",
3197
+ `SYNKRO_GATEWAY_URL=${shellQuoteSingle(safeGateway)}`,
3198
+ `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
3199
+ `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
3200
+ `SYNKRO_VERSION=${shellQuoteSingle("1.3.3")}`
3201
+ ];
3202
+ if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
3203
+ if (safeOrgId) lines.push(`SYNKRO_ORG_ID=${shellQuoteSingle(safeOrgId)}`);
3204
+ if (safeEmail) lines.push(`SYNKRO_EMAIL=${shellQuoteSingle(safeEmail)}`);
3205
+ lines.push("");
3206
+ writeFileSync5(CONFIG_PATH, lines.join("\n"), "utf-8");
3207
+ chmodSync(CONFIG_PATH, 384);
3208
+ }
3209
+ function assertGatewayAllowed(gatewayUrl) {
3210
+ let parsed;
3211
+ try {
3212
+ parsed = new URL(gatewayUrl);
2237
3213
  } catch {
2238
3214
  throw new Error(`Invalid gateway URL: ${gatewayUrl}`);
2239
3215
  }
@@ -2253,17 +3229,17 @@ function assertGatewayAllowed(gatewayUrl) {
2253
3229
  }
2254
3230
  function isAlreadyInstalled() {
2255
3231
  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")
3232
+ join5(HOOKS_DIR, "cc-bash-judge.sh"),
3233
+ join5(HOOKS_DIR, "cc-bash-followup.sh"),
3234
+ join5(HOOKS_DIR, "cc-edit-precheck.sh"),
3235
+ join5(HOOKS_DIR, "cc-edit-capture.sh"),
3236
+ join5(HOOKS_DIR, "cc-stop-summary.sh"),
3237
+ join5(HOOKS_DIR, "cc-session-start.sh")
2262
3238
  ];
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;
3239
+ if (!requiredScripts.every((p) => existsSync6(p))) return false;
3240
+ if (!existsSync6(CONFIG_PATH)) return false;
3241
+ const settingsPath = join5(homedir4(), ".claude", "settings.json");
3242
+ if (!existsSync6(settingsPath)) return false;
2267
3243
  try {
2268
3244
  const settings = JSON.parse(readFileSync4(settingsPath, "utf-8"));
2269
3245
  const hooks = settings?.hooks;
@@ -2324,6 +3300,7 @@ async function installCommand(opts = {}) {
2324
3300
  console.error("No access token available after auth.");
2325
3301
  process.exit(1);
2326
3302
  }
3303
+ await promptRepoConnection();
2327
3304
  const agents = detectAgents();
2328
3305
  if (agents.length === 0) {
2329
3306
  console.error("No AI coding agents detected. Install Claude Code first: https://docs.claude.com/claude-code");
@@ -2346,7 +3323,7 @@ async function installCommand(opts = {}) {
2346
3323
  `);
2347
3324
  writeGraderDaemon();
2348
3325
  for (const mode of ["edit", "bash"]) {
2349
- const pidFile = join4(SYNKRO_DIR, "daemon", mode, "daemon.pid");
3326
+ const pidFile = join5(SYNKRO_DIR, "daemon", mode, "daemon.pid");
2350
3327
  try {
2351
3328
  const pid = parseInt(readFileSync4(pidFile, "utf-8").trim(), 10);
2352
3329
  if (pid > 0) {
@@ -2417,12 +3394,115 @@ async function installCommand(opts = {}) {
2417
3394
  writeConfigEnv({ gatewayUrl, userId, orgId, email });
2418
3395
  console.log(`Wrote config to ${CONFIG_PATH}
2419
3396
  `);
3397
+ try {
3398
+ const repo = detectGitRepo2();
3399
+ if (repo) {
3400
+ const ingested = await ingestSessionTranscripts(gatewayUrl, token, repo);
3401
+ if (ingested > 0) {
3402
+ console.log(`Indexed ${ingested} session insights from Claude Code history for ${repo}.`);
3403
+ console.log(" This helps the safety judge understand your workflow.\n");
3404
+ }
3405
+ }
3406
+ } catch (err) {
3407
+ console.warn(` \u26A0 Session indexing skipped: ${err.message}
3408
+ `);
3409
+ }
2420
3410
  console.log("\u2713 Synkro installed.");
2421
3411
  console.log();
2422
3412
  console.log("Next steps:");
2423
3413
  console.log(" \u2022 synkro-cli setup-github (enable PR scanning)");
2424
3414
  console.log(" \u2022 synkro-cli status (check what is configured)");
2425
3415
  }
3416
+ function detectGitRepo2() {
3417
+ try {
3418
+ const remoteUrl = execSync3("git remote get-url origin", { encoding: "utf-8", timeout: 5e3 }).trim();
3419
+ const match = remoteUrl.match(/(?:github\.com|gitlab\.com|bitbucket\.org)[:/](.+?)(?:\.git)?$/);
3420
+ return match ? match[1] : null;
3421
+ } catch {
3422
+ return null;
3423
+ }
3424
+ }
3425
+ function getClaudeProjectsFolder() {
3426
+ const cwd = process.cwd();
3427
+ const sanitized = "-" + cwd.replace(/\//g, "-");
3428
+ const projectsDir = join5(homedir4(), ".claude", "projects", sanitized);
3429
+ return existsSync6(projectsDir) ? projectsDir : null;
3430
+ }
3431
+ function extractSessionInsights(projectsDir) {
3432
+ const insights = [];
3433
+ const files = readdirSync(projectsDir).filter((f) => f.endsWith(".jsonl"));
3434
+ for (const file of files) {
3435
+ const sessionId = file.replace(".jsonl", "");
3436
+ const filePath = join5(projectsDir, file);
3437
+ try {
3438
+ const content = readFileSync4(filePath, "utf-8");
3439
+ const lines = content.split("\n").filter(Boolean);
3440
+ for (let i = 0; i < lines.length; i++) {
3441
+ try {
3442
+ const entry = JSON.parse(lines[i]);
3443
+ if (entry.type === "user" && typeof entry.message?.content === "string" && entry.message.content.startsWith("This session is being continued")) {
3444
+ insights.push({
3445
+ session_id: sessionId,
3446
+ insight_type: "summary",
3447
+ content: entry.message.content.slice(0, 4e3),
3448
+ metadata: { source: "compaction_summary" }
3449
+ });
3450
+ }
3451
+ } catch {
3452
+ }
3453
+ }
3454
+ const userMessages = [];
3455
+ for (let i = lines.length - 1; i >= 0 && userMessages.length < 20; i--) {
3456
+ try {
3457
+ const entry = JSON.parse(lines[i]);
3458
+ if (entry.type === "user") {
3459
+ 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;
3460
+ if (text && text.length > 10 && text.length < 2e3 && !text.startsWith("This session is being continued")) {
3461
+ userMessages.push(text);
3462
+ }
3463
+ }
3464
+ } catch {
3465
+ }
3466
+ }
3467
+ for (const msg of userMessages.reverse()) {
3468
+ insights.push({
3469
+ session_id: sessionId,
3470
+ insight_type: "user_message",
3471
+ content: msg.slice(0, 2e3)
3472
+ });
3473
+ }
3474
+ } catch {
3475
+ }
3476
+ }
3477
+ return insights;
3478
+ }
3479
+ async function ingestSessionTranscripts(gatewayUrl, token, repo) {
3480
+ const projectsDir = getClaudeProjectsFolder();
3481
+ if (!projectsDir) return 0;
3482
+ const insights = extractSessionInsights(projectsDir);
3483
+ if (insights.length === 0) return 0;
3484
+ console.log(`Found ${insights.length} session insights from Claude Code history...`);
3485
+ let total = 0;
3486
+ for (let i = 0; i < insights.length; i += 100) {
3487
+ const batch = insights.slice(i, i + 100);
3488
+ try {
3489
+ const resp = await fetch(`${gatewayUrl}/api/v1/cli/ingest-sessions`, {
3490
+ method: "POST",
3491
+ headers: {
3492
+ "Authorization": `Bearer ${token}`,
3493
+ "Content-Type": "application/json"
3494
+ },
3495
+ body: JSON.stringify({ repo, sessions: batch })
3496
+ });
3497
+ if (resp.ok) {
3498
+ const result = await resp.json();
3499
+ total += result.ingested;
3500
+ }
3501
+ } catch {
3502
+ }
3503
+ }
3504
+ return total;
3505
+ }
2426
3506
  var SYNKRO_DIR, HOOKS_DIR, BIN_DIR, CONFIG_PATH, GRADER_DAEMON_PATH, GRADER_PRIMER_EDIT_PATH, GRADER_PRIMER_BASH_PATH;
2427
3507
  var init_install = __esm({
2428
3508
  "cli/commands/install.ts"() {
@@ -2433,13 +3513,14 @@ var init_install = __esm({
2433
3513
  init_hookScripts();
2434
3514
  init_graderDaemon();
2435
3515
  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");
3516
+ init_repoConnect();
3517
+ SYNKRO_DIR = join5(homedir4(), ".synkro");
3518
+ HOOKS_DIR = join5(SYNKRO_DIR, "hooks");
3519
+ BIN_DIR = join5(SYNKRO_DIR, "bin");
3520
+ CONFIG_PATH = join5(SYNKRO_DIR, "config.env");
3521
+ GRADER_DAEMON_PATH = join5(BIN_DIR, "grader_daemon.py");
3522
+ GRADER_PRIMER_EDIT_PATH = join5(SYNKRO_DIR, "grader-primer-edit.txt");
3523
+ GRADER_PRIMER_BASH_PATH = join5(SYNKRO_DIR, "grader-primer-bash.txt");
2443
3524
  }
2444
3525
  });
2445
3526
 
@@ -2515,11 +3596,11 @@ var status_exports = {};
2515
3596
  __export(status_exports, {
2516
3597
  statusCommand: () => statusCommand
2517
3598
  });
2518
- import { existsSync as existsSync6, readFileSync as readFileSync5 } from "fs";
3599
+ import { existsSync as existsSync7, readFileSync as readFileSync5 } from "fs";
2519
3600
  import { homedir as homedir5 } from "os";
2520
- import { join as join5 } from "path";
3601
+ import { join as join6 } from "path";
2521
3602
  function readConfigEnv() {
2522
- if (!existsSync6(CONFIG_PATH2)) return {};
3603
+ if (!existsSync7(CONFIG_PATH2)) return {};
2523
3604
  const out = {};
2524
3605
  const raw = readFileSync5(CONFIG_PATH2, "utf-8");
2525
3606
  for (const line of raw.split("\n")) {
@@ -2545,21 +3626,21 @@ function statusCommand() {
2545
3626
  console.log("Authentication: \u2717 not logged in (run: synkro-cli login)");
2546
3627
  }
2547
3628
  console.log();
2548
- const config = readConfigEnv();
3629
+ const config2 = readConfigEnv();
2549
3630
  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)"}`);
3631
+ console.log(` gateway: ${config2.SYNKRO_GATEWAY_URL ?? "(unset)"}`);
3632
+ console.log(` credentials: ${config2.SYNKRO_CREDENTIALS_PATH ?? "(unset)"}`);
3633
+ console.log(` tier: ${config2.SYNKRO_TIER ?? "(unset)"}`);
2553
3634
  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)) {
3635
+ const userId = info2?.id ?? config2.SYNKRO_USER_ID ?? "default";
3636
+ const tierCacheFile = join6(SYNKRO_DIR2, `.tier-cache-${userId}`);
3637
+ let inferenceTier = config2.SYNKRO_INFERENCE_TIER || null;
3638
+ if (!inferenceTier && existsSync7(tierCacheFile)) {
2558
3639
  inferenceTier = readFileSync5(tierCacheFile, "utf-8").trim() || null;
2559
3640
  }
2560
3641
  const tierLabel = inferenceTier === "fast" ? "'fast' (server-side grading)" : inferenceTier === "free" ? "'free' (local daemon grading)" : "(unknown \u2014 fires on next hook)";
2561
3642
  console.log(` inference: ${tierLabel}`);
2562
- console.log(` version: ${config.SYNKRO_VERSION ?? "(unset)"}`);
3643
+ console.log(` version: ${config2.SYNKRO_VERSION ?? "(unset)"}`);
2563
3644
  console.log();
2564
3645
  const agents = detectAgents();
2565
3646
  console.log("Detected agents:");
@@ -2582,19 +3663,19 @@ function statusCommand() {
2582
3663
  }
2583
3664
  }
2584
3665
  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");
3666
+ const bashScript = join6(SYNKRO_DIR2, "hooks", "cc-bash-judge.sh");
3667
+ const bashFollowupScript = join6(SYNKRO_DIR2, "hooks", "cc-bash-followup.sh");
3668
+ const editPrecheckScript = join6(SYNKRO_DIR2, "hooks", "cc-edit-precheck.sh");
3669
+ const editCaptureScript = join6(SYNKRO_DIR2, "hooks", "cc-edit-capture.sh");
3670
+ const stopSummaryScript = join6(SYNKRO_DIR2, "hooks", "cc-stop-summary.sh");
3671
+ const sessionStartScript = join6(SYNKRO_DIR2, "hooks", "cc-session-start.sh");
2591
3672
  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}`);
3673
+ console.log(` ${existsSync7(bashScript) ? "\u2713" : "\u2717"} ${bashScript}`);
3674
+ console.log(` ${existsSync7(bashFollowupScript) ? "\u2713" : "\u2717"} ${bashFollowupScript}`);
3675
+ console.log(` ${existsSync7(editPrecheckScript) ? "\u2713" : "\u2717"} ${editPrecheckScript}`);
3676
+ console.log(` ${existsSync7(editCaptureScript) ? "\u2713" : "\u2717"} ${editCaptureScript}`);
3677
+ console.log(` ${existsSync7(stopSummaryScript) ? "\u2713" : "\u2717"} ${stopSummaryScript}`);
3678
+ console.log(` ${existsSync7(sessionStartScript) ? "\u2713" : "\u2717"} ${sessionStartScript}`);
2598
3679
  console.log();
2599
3680
  const mcp = inspectMcpConfig();
2600
3681
  console.log("Guardrails MCP server (Claude Code):");
@@ -2614,165 +3695,88 @@ var init_status = __esm({
2614
3695
  init_agentDetect();
2615
3696
  init_ccHookConfig();
2616
3697
  init_mcpConfig();
2617
- SYNKRO_DIR2 = join5(homedir5(), ".synkro");
2618
- CONFIG_PATH2 = join5(SYNKRO_DIR2, "config.env");
3698
+ SYNKRO_DIR2 = join6(homedir5(), ".synkro");
3699
+ CONFIG_PATH2 = join6(SYNKRO_DIR2, "config.env");
2619
3700
  }
2620
3701
  });
2621
3702
 
2622
- // cli/installer/workflowTemplate.ts
2623
- var SYNKRO_WORKFLOW_YAML, WORKFLOW_PATH;
2624
- var init_workflowTemplate = __esm({
2625
- "cli/installer/workflowTemplate.ts"() {
3703
+ // cli/commands/link.ts
3704
+ var link_exports = {};
3705
+ __export(link_exports, {
3706
+ linkCommand: () => linkCommand
3707
+ });
3708
+ async function linkCommand() {
3709
+ if (!isAuthenticated()) {
3710
+ console.error("Not authenticated. Run `synkro install` or `synkro login` first.");
3711
+ process.exit(1);
3712
+ }
3713
+ await ensureValidToken();
3714
+ await promptRepoConnection();
3715
+ }
3716
+ var init_link = __esm({
3717
+ "cli/commands/link.ts"() {
2626
3718
  "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";
3719
+ init_stub();
3720
+ init_repoConnect();
2669
3721
  }
2670
3722
  });
2671
3723
 
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);
3724
+ // cli/commands/unlink.ts
3725
+ var unlink_exports = {};
3726
+ __export(unlink_exports, {
3727
+ unlinkCommand: () => unlinkCommand
3728
+ });
3729
+ import { createInterface as createInterface2 } from "readline";
3730
+ function ask2(rl, question) {
3731
+ return new Promise((resolve2) => rl.question(question, resolve2));
2682
3732
  }
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"
3733
+ async function unlinkCommand() {
3734
+ if (!isAuthenticated()) {
3735
+ console.error("Not authenticated. Run `synkro install` or `synkro login` first.");
3736
+ process.exit(1);
3737
+ }
3738
+ await ensureValidToken();
3739
+ const projects = await listProjects();
3740
+ const linked = [];
3741
+ for (const p of projects) {
3742
+ for (const r of p.repos || []) {
3743
+ if (r.full_name && r.id) {
3744
+ linked.push({ projectId: p.id, projectName: p.name, repoId: r.id, fullName: r.full_name });
3745
+ }
2690
3746
  }
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
3747
  }
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)}`);
3748
+ if (linked.length === 0) {
3749
+ console.log("No linked repos found.");
3750
+ return;
2717
3751
  }
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`);
3752
+ console.log("\nLinked repos:\n");
3753
+ linked.forEach((r, i) => {
3754
+ console.log(` ${i + 1}. ${r.fullName} (${r.projectName})`);
3755
+ });
3756
+ console.log();
3757
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
3758
+ try {
3759
+ const selection = await ask2(rl, " Select repos to unlink (comma-separated numbers): ");
3760
+ const indices = selection.split(",").map((s) => parseInt(s.trim(), 10) - 1).filter((n) => !isNaN(n) && n >= 0 && n < linked.length);
3761
+ if (indices.length === 0) {
3762
+ console.log(" No repos selected.");
3763
+ return;
2733
3764
  }
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 });
3765
+ for (const idx of indices) {
3766
+ const r = linked[idx];
3767
+ await unlinkRepo(r.projectId, r.repoId);
3768
+ console.log(` \u2713 Unlinked ${r.fullName} from ${r.projectName}`);
2738
3769
  }
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;
3770
+ } finally {
3771
+ rl.close();
2763
3772
  }
2764
- return null;
3773
+ console.log();
2765
3774
  }
2766
- var SECRET_NAMES, WORKFLOW_RELATIVE_PATH;
2767
- var init_githubSetup = __esm({
2768
- "cli/installer/githubSetup.ts"() {
3775
+ var init_unlink = __esm({
3776
+ "cli/commands/unlink.ts"() {
2769
3777
  "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;
3778
+ init_stub();
3779
+ init_projects();
2776
3780
  }
2777
3781
  });
2778
3782
 
@@ -2781,7 +3785,7 @@ var setupGithub_exports = {};
2781
3785
  __export(setupGithub_exports, {
2782
3786
  setupGithubCommand: () => setupGithubCommand
2783
3787
  });
2784
- import { createInterface } from "readline/promises";
3788
+ import { createInterface as createInterface3 } from "readline/promises";
2785
3789
  import { stdin as input, stdout as output } from "process";
2786
3790
  import { existsSync as existsSync8, readFileSync as readFileSync6 } from "fs";
2787
3791
  import { homedir as homedir6 } from "os";
@@ -2832,8 +3836,8 @@ async function setupGithubCommand() {
2832
3836
  console.error("Not authenticated. Run `synkro-cli login` first.");
2833
3837
  process.exit(1);
2834
3838
  }
2835
- const config = readConfig();
2836
- const gatewayUrl = (config.SYNKRO_GATEWAY_URL || process.env.SYNKRO_GATEWAY_URL || "https://api.synkro.sh").replace(/\/$/, "");
3839
+ const config2 = readConfig();
3840
+ const gatewayUrl = (config2.SYNKRO_GATEWAY_URL || process.env.SYNKRO_GATEWAY_URL || "https://api.synkro.sh").replace(/\/$/, "");
2837
3841
  const jwt2 = getAccessToken();
2838
3842
  if (!jwt2) {
2839
3843
  console.error("Could not load access token from ~/.synkro/credentials.json. Run `synkro-cli login`.");
@@ -2862,7 +3866,7 @@ async function setupGithubCommand() {
2862
3866
  console.error(`Failed to mint CI API key: ${err.message}`);
2863
3867
  process.exit(1);
2864
3868
  }
2865
- const rl = createInterface({ input, output });
3869
+ const rl = createInterface3({ input, output });
2866
3870
  console.log("Synkro PR scan setup\n");
2867
3871
  console.log("Requirements:");
2868
3872
  console.log(" \u2022 Claude Code Pro or Max subscription (for `claude setup-token`)");
@@ -2969,7 +3973,7 @@ var scanPr_exports = {};
2969
3973
  __export(scanPr_exports, {
2970
3974
  scanPrCommand: () => scanPrCommand
2971
3975
  });
2972
- import { execSync as execSync2, spawn } from "child_process";
3976
+ import { execSync as execSync4, spawn } from "child_process";
2973
3977
  function parseMatchSpec(condition) {
2974
3978
  if (!condition.startsWith("match_spec:")) return null;
2975
3979
  try {
@@ -3075,7 +4079,7 @@ function shouldSkipFile(filename) {
3075
4079
  return SKIP_FILE_PATTERNS.some((p) => p.test(filename));
3076
4080
  }
3077
4081
  function ghJson(args2) {
3078
- const out = execSync2(`gh ${args2.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(" ")}`, {
4082
+ const out = execSync4(`gh ${args2.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(" ")}`, {
3079
4083
  encoding: "utf-8",
3080
4084
  maxBuffer: 16 * 1024 * 1024
3081
4085
  });
@@ -3191,7 +4195,7 @@ ${finding.description}
3191
4195
 
3192
4196
  **Fix:** ${finding.fix}`;
3193
4197
  try {
3194
- execSync2(
4198
+ execSync4(
3195
4199
  `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
4200
  { encoding: "utf-8", stdio: ["ignore", "ignore", "pipe"] }
3197
4201
  );
@@ -3213,7 +4217,7 @@ function postCheckRun(repo, sha, conclusion, findings) {
3213
4217
  }
3214
4218
  });
3215
4219
  try {
3216
- execSync2(`gh api -X POST /repos/${repo}/check-runs --input -`, {
4220
+ execSync4(`gh api -X POST /repos/${repo}/check-runs --input -`, {
3217
4221
  encoding: "utf-8",
3218
4222
  input: body,
3219
4223
  stdio: ["pipe", "ignore", "pipe"]
@@ -3431,6 +4435,43 @@ var init_disconnect = __esm({
3431
4435
  }
3432
4436
  });
3433
4437
 
4438
+ // cli/commands/uninstall.ts
4439
+ var uninstall_exports = {};
4440
+ __export(uninstall_exports, {
4441
+ uninstallCommand: () => uninstallCommand
4442
+ });
4443
+ function uninstallCommand() {
4444
+ console.log("Uninstalling Synkro...\n");
4445
+ disconnectCommand(["--purge"]);
4446
+ console.log("\nTo reinstall later: synkro install");
4447
+ }
4448
+ var init_uninstall = __esm({
4449
+ "cli/commands/uninstall.ts"() {
4450
+ "use strict";
4451
+ init_disconnect();
4452
+ }
4453
+ });
4454
+
4455
+ // cli/commands/reinstall.ts
4456
+ var reinstall_exports = {};
4457
+ __export(reinstall_exports, {
4458
+ reinstallCommand: () => reinstallCommand
4459
+ });
4460
+ async function reinstallCommand() {
4461
+ console.log("Reinstalling Synkro...\n");
4462
+ disconnectCommand(["--purge"]);
4463
+ console.log("");
4464
+ await installCommand({ force: true });
4465
+ console.log("\n\u2713 Synkro reinstalled.");
4466
+ }
4467
+ var init_reinstall = __esm({
4468
+ "cli/commands/reinstall.ts"() {
4469
+ "use strict";
4470
+ init_disconnect();
4471
+ init_install();
4472
+ }
4473
+ });
4474
+
3434
4475
  // cli/bootstrap.js
3435
4476
  import { readFileSync as readFileSync7, existsSync as existsSync10 } from "fs";
3436
4477
  import { resolve } from "path";
@@ -3466,10 +4507,14 @@ Commands:
3466
4507
  login Authenticate with Synkro (browser OAuth via WorkOS)
3467
4508
  logout Clear local credentials
3468
4509
  status Show current setup state
4510
+ link Link repos to a Synkro project (local git or GitHub OAuth)
4511
+ unlink Remove repo links from Synkro projects
3469
4512
  setup-github Configure GitHub PR scanning (push secrets + workflow file)
3470
4513
  scan-pr Run a PR scan (used by GitHub Actions, not for direct invocation)
3471
4514
  update Refresh hook configs and judge prompts
3472
4515
  disconnect [--purge] Remove Synkro hooks from agents (--purge also removes ~/.synkro)
4516
+ uninstall Fully remove Synkro from this machine
4517
+ reinstall Clean uninstall + fresh install
3473
4518
  help Show this message
3474
4519
 
3475
4520
  Quick start:
@@ -3502,6 +4547,16 @@ async function main() {
3502
4547
  statusCommand2();
3503
4548
  break;
3504
4549
  }
4550
+ case "link": {
4551
+ const { linkCommand: linkCommand2 } = await Promise.resolve().then(() => (init_link(), link_exports));
4552
+ await linkCommand2();
4553
+ break;
4554
+ }
4555
+ case "unlink": {
4556
+ const { unlinkCommand: unlinkCommand2 } = await Promise.resolve().then(() => (init_unlink(), unlink_exports));
4557
+ await unlinkCommand2();
4558
+ break;
4559
+ }
3505
4560
  case "setup-github": {
3506
4561
  const { setupGithubCommand: setupGithubCommand2 } = await Promise.resolve().then(() => (init_setupGithub(), setupGithub_exports));
3507
4562
  await setupGithubCommand2();
@@ -3522,6 +4577,16 @@ async function main() {
3522
4577
  disconnectCommand2(subArgs);
3523
4578
  break;
3524
4579
  }
4580
+ case "uninstall": {
4581
+ const { uninstallCommand: uninstallCommand2 } = await Promise.resolve().then(() => (init_uninstall(), uninstall_exports));
4582
+ uninstallCommand2();
4583
+ break;
4584
+ }
4585
+ case "reinstall": {
4586
+ const { reinstallCommand: reinstallCommand2 } = await Promise.resolve().then(() => (init_reinstall(), reinstall_exports));
4587
+ await reinstallCommand2();
4588
+ break;
4589
+ }
3525
4590
  case "help":
3526
4591
  case "--help":
3527
4592
  case "-h":