@synkro-sh/cli 1.0.9 → 1.0.11

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
@@ -667,39 +667,43 @@ case "$TOOL_NAME" in
667
667
  Write)
668
668
  # Write replaces the entire file \u2014 content IS the full post-edit file.
669
669
  PROPOSED=$(echo "$TOOL_INPUT" | jq -r '.content // ""' 2>/dev/null) ;;
670
- Edit)
670
+ Edit|MultiEdit)
671
671
  # Reconstruct the full post-edit file by applying the diff to file_before.
672
672
  # Sending only new_string (the diff hunk) blinds the local grader to
673
673
  # violations elsewhere in the file \u2014 the grader needs whole-file context
674
- # to identify multi-violation edits and cross-line patterns. Fall back
675
- # to new_string only if file_before is missing (new file) or we can't
676
- # locate old_string in it.
677
- OLD_STR=$(echo "$TOOL_INPUT" | jq -r '.old_string // ""' 2>/dev/null)
678
- NEW_STR=$(echo "$TOOL_INPUT" | jq -r '.new_string // ""' 2>/dev/null)
679
- if [ -n "$FILE_BEFORE" ] && [ -n "$OLD_STR" ]; then
680
- PROPOSED="\${FILE_BEFORE//"$OLD_STR"/"$NEW_STR"}"
681
- else
682
- PROPOSED="$NEW_STR"
674
+ # to identify multi-violation edits and cross-line patterns.
675
+ #
676
+ # We use python (already a daemon dependency) instead of bash parameter
677
+ # expansion because macOS ships bash 3.2, where the quoted-pattern form
678
+ # of \${var//PAT/REPL} leaks the quote characters into the result.
679
+ # Python's str.replace() handles arbitrary strings cleanly. Args go via
680
+ # env vars (not argv) so 64 KB file content doesn't trip ARG_MAX limits.
681
+ if [ -n "$FILE_BEFORE" ] && command -v python3 >/dev/null 2>&1; then
682
+ PROPOSED=$(FILE_BEFORE_LITERAL="$FILE_BEFORE" TOOL_INPUT_LITERAL="$TOOL_INPUT" python3 -c '
683
+ import os, json, sys
684
+ fb = os.environ.get("FILE_BEFORE_LITERAL", "")
685
+ ti = json.loads(os.environ.get("TOOL_INPUT_LITERAL", "{}"))
686
+ result = fb
687
+ if "old_string" in ti and "new_string" in ti:
688
+ if ti["old_string"]:
689
+ result = result.replace(ti["old_string"], ti["new_string"], 1)
690
+ elif "edits" in ti and isinstance(ti["edits"], list):
691
+ for e in ti["edits"]:
692
+ old = e.get("old_string", "") if isinstance(e, dict) else ""
693
+ new = e.get("new_string", "") if isinstance(e, dict) else ""
694
+ if old:
695
+ result = result.replace(old, new, 1)
696
+ sys.stdout.write(result)
697
+ ' 2>/dev/null)
683
698
  fi
684
- ;;
685
- MultiEdit)
686
- # Apply each {old,new} pair in sequence to reconstruct the full
687
- # post-edit file. Same rationale as Edit \u2014 grader needs whole file.
688
- if [ -n "$FILE_BEFORE" ]; then
689
- PROPOSED="$FILE_BEFORE"
690
- EDITS_JSON=$(echo "$TOOL_INPUT" | jq -c '.edits // []' 2>/dev/null)
691
- EDIT_COUNT=$(echo "$EDITS_JSON" | jq -r 'length' 2>/dev/null)
692
- i=0
693
- while [ "$i" -lt "$EDIT_COUNT" ]; do
694
- OLD=$(echo "$EDITS_JSON" | jq -r ".[$i].old_string // \\"\\"")
695
- NEW=$(echo "$EDITS_JSON" | jq -r ".[$i].new_string // \\"\\"")
696
- if [ -n "$OLD" ]; then
697
- PROPOSED="\${PROPOSED//"$OLD"/"$NEW"}"
698
- fi
699
- i=$((i + 1))
700
- done
701
- else
702
- PROPOSED=$(echo "$TOOL_INPUT" | jq -r '[.edits[]?.new_string // ""] | join("\\n\\n--- chunk ---\\n\\n")' 2>/dev/null)
699
+ # Fall back to the diff-hunk-only shape if reconstruction failed or
700
+ # file_before was empty (new file via Edit, etc.).
701
+ if [ -z "$PROPOSED" ]; then
702
+ if [ "$TOOL_NAME" = "MultiEdit" ]; then
703
+ PROPOSED=$(echo "$TOOL_INPUT" | jq -r '[.edits[]?.new_string // ""] | join("\\n\\n--- chunk ---\\n\\n")' 2>/dev/null)
704
+ else
705
+ PROPOSED=$(echo "$TOOL_INPUT" | jq -r '.new_string // ""' 2>/dev/null)
706
+ fi
703
707
  fi
704
708
  ;;
705
709
  NotebookEdit)
@@ -1931,7 +1935,7 @@ __export(install_exports, {
1931
1935
  installCommand: () => installCommand,
1932
1936
  parseArgs: () => parseArgs
1933
1937
  });
1934
- import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync4, chmodSync } from "fs";
1938
+ import { existsSync as existsSync5, mkdirSync as mkdirSync4, writeFileSync as writeFileSync4, chmodSync, readFileSync as readFileSync4 } from "fs";
1935
1939
  import { homedir as homedir4 } from "os";
1936
1940
  import { join as join4 } from "path";
1937
1941
  function sanitizeGatewayCandidate(raw) {
@@ -1945,6 +1949,7 @@ function parseArgs(argv) {
1945
1949
  else if (a.startsWith("--gateway=")) opts.gatewayUrl = a.slice("--gateway=".length);
1946
1950
  else if (a === "--skip-auth") opts.skipAuth = true;
1947
1951
  else if (a === "--no-mcp") opts.noMcp = true;
1952
+ else if (a === "--force" || a === "-f") opts.force = true;
1948
1953
  }
1949
1954
  if (!opts.gatewayUrl) {
1950
1955
  const fromEnv = sanitizeGatewayCandidate(process.env.SYNKRO_GATEWAY_URL);
@@ -2016,7 +2021,7 @@ function writeConfigEnv(opts) {
2016
2021
  `SYNKRO_GATEWAY_URL=${shellQuoteSingle(safeGateway)}`,
2017
2022
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
2018
2023
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
2019
- `SYNKRO_VERSION=${shellQuoteSingle("1.0.9")}`
2024
+ `SYNKRO_VERSION=${shellQuoteSingle("1.0.11")}`
2020
2025
  ];
2021
2026
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
2022
2027
  if (safeOrgId) lines.push(`SYNKRO_ORG_ID=${shellQuoteSingle(safeOrgId)}`);
@@ -2047,6 +2052,33 @@ function assertGatewayAllowed(gatewayUrl) {
2047
2052
  throw new Error(`Gateway host not in allowlist (synkro.sh or *.synkro.sh): ${host}`);
2048
2053
  }
2049
2054
  }
2055
+ function isAlreadyInstalled() {
2056
+ const requiredScripts = [
2057
+ join4(HOOKS_DIR, "cc-bash-judge.sh"),
2058
+ join4(HOOKS_DIR, "cc-bash-followup.sh"),
2059
+ join4(HOOKS_DIR, "cc-edit-precheck.sh"),
2060
+ join4(HOOKS_DIR, "cc-edit-capture.sh"),
2061
+ join4(HOOKS_DIR, "cc-stop-summary.sh"),
2062
+ join4(HOOKS_DIR, "cc-session-start.sh")
2063
+ ];
2064
+ if (!requiredScripts.every((p) => existsSync5(p))) return false;
2065
+ if (!existsSync5(CONFIG_PATH)) return false;
2066
+ const settingsPath = join4(homedir4(), ".claude", "settings.json");
2067
+ if (!existsSync5(settingsPath)) return false;
2068
+ try {
2069
+ const settings = JSON.parse(readFileSync4(settingsPath, "utf-8"));
2070
+ const hooks = settings?.hooks;
2071
+ if (!hooks || typeof hooks !== "object") return false;
2072
+ const hasManaged = (kind) => Array.isArray(hooks[kind]) && hooks[kind].some((entry) => entry?.__synkro_managed__ === true);
2073
+ if (!hasManaged("PreToolUse")) return false;
2074
+ if (!hasManaged("PostToolUse")) return false;
2075
+ if (!hasManaged("SessionEnd")) return false;
2076
+ if (!hasManaged("SessionStart")) return false;
2077
+ } catch {
2078
+ return false;
2079
+ }
2080
+ return true;
2081
+ }
2050
2082
  async function installCommand(opts = {}) {
2051
2083
  const gatewayUrl = opts.gatewayUrl || sanitizeGatewayCandidate(process.env.SYNKRO_GATEWAY_URL) || "https://api.synkro.sh";
2052
2084
  try {
@@ -2055,6 +2087,12 @@ async function installCommand(opts = {}) {
2055
2087
  console.error(err.message);
2056
2088
  process.exit(1);
2057
2089
  }
2090
+ if (!opts.force && isAuthenticated() && isAlreadyInstalled()) {
2091
+ console.log("\u2713 Synkro is already installed and configured.");
2092
+ console.log(" Run `synkro update` to refresh hook scripts and judge prompts.");
2093
+ console.log(" Run `synkro install --force` to reinstall from scratch.");
2094
+ return;
2095
+ }
2058
2096
  console.log("Synkro install starting...\n");
2059
2097
  if (!isAuthenticated()) {
2060
2098
  console.log("Opening browser for Synkro auth...");
@@ -2108,7 +2146,7 @@ async function installCommand(opts = {}) {
2108
2146
  console.log(` ${scripts.sessionStartScript}
2109
2147
  `);
2110
2148
  writeGraderDaemon();
2111
- console.log("Wrote free-tier grader daemon:");
2149
+ console.log("Wrote local-tier grader daemon:");
2112
2150
  console.log(` ${GRADER_DAEMON_PATH}`);
2113
2151
  console.log(` ${GRADER_PRIMER_EDIT_PATH}`);
2114
2152
  console.log(` ${GRADER_PRIMER_BASH_PATH}
@@ -2126,8 +2164,6 @@ async function installCommand(opts = {}) {
2126
2164
  sessionStartScriptPath: scripts.sessionStartScript
2127
2165
  });
2128
2166
  console.log(`Configured ${agent.name} hooks at ${agent.settingsPath}`);
2129
- } else if (agent.kind === "codex") {
2130
- console.log(`Skipping ${agent.name} for now (v1.1 \u2014 Codex hook config coming soon)`);
2131
2167
  }
2132
2168
  }
2133
2169
  console.log();
@@ -2149,7 +2185,6 @@ async function installCommand(opts = {}) {
2149
2185
  const mcp = installMcpConfig({ gatewayUrl, bearerToken: minted.token });
2150
2186
  console.log(`Registered Synkro guardrails MCP server in ${mcp.path}`);
2151
2187
  console.log(` url: ${mcp.url}`);
2152
- console.log(` token: Synkro-signed JWT, scope=mcp:guardrails`);
2153
2188
  console.log(` expires: ${minted.expires_at} (~1 year)`);
2154
2189
  console.log(" (restart any running Claude Code session for it to load)");
2155
2190
  console.log();
@@ -2174,21 +2209,9 @@ async function installCommand(opts = {}) {
2174
2209
  `);
2175
2210
  console.log("\u2713 Synkro installed.");
2176
2211
  console.log();
2177
- console.log("Try it:");
2178
- console.log(" $ claude");
2179
- console.log(' > Run "echo hello" for me');
2180
- console.log(" (no warning \u2014 safe command)");
2181
- console.log();
2182
- console.log(" > Now propose: kubectl delete namespace production");
2183
- console.log(" (Synkro will block with a warning)");
2184
- console.log();
2185
2212
  console.log("Next steps:");
2186
2213
  console.log(" \u2022 synkro setup-github (enable PR scanning)");
2187
2214
  console.log(" \u2022 synkro status (check what is configured)");
2188
- if (hasClaudeCode && !opts.noMcp) {
2189
- console.log(' \u2022 Try in CC: "Add a Stripe webhook handler" \u2014 CC should call');
2190
- console.log(" synkro-guardrails.get_guardrails to fetch org-specific rules.");
2191
- }
2192
2215
  }
2193
2216
  var SYNKRO_DIR, HOOKS_DIR, BIN_DIR, CONFIG_PATH, GRADER_DAEMON_PATH, GRADER_PRIMER_EDIT_PATH, GRADER_PRIMER_BASH_PATH;
2194
2217
  var init_install = __esm({
@@ -2282,13 +2305,13 @@ var status_exports = {};
2282
2305
  __export(status_exports, {
2283
2306
  statusCommand: () => statusCommand
2284
2307
  });
2285
- import { existsSync as existsSync6, readFileSync as readFileSync4 } from "fs";
2308
+ import { existsSync as existsSync6, readFileSync as readFileSync5 } from "fs";
2286
2309
  import { homedir as homedir5 } from "os";
2287
2310
  import { join as join5 } from "path";
2288
2311
  function readConfigEnv() {
2289
2312
  if (!existsSync6(CONFIG_PATH2)) return {};
2290
2313
  const out = {};
2291
- const raw = readFileSync4(CONFIG_PATH2, "utf-8");
2314
+ const raw = readFileSync5(CONFIG_PATH2, "utf-8");
2292
2315
  for (const line of raw.split("\n")) {
2293
2316
  const trimmed = line.trim();
2294
2317
  if (!trimmed || trimmed.startsWith("#")) continue;
@@ -2555,13 +2578,13 @@ __export(setupGithub_exports, {
2555
2578
  });
2556
2579
  import { createInterface } from "readline/promises";
2557
2580
  import { stdin as input, stdout as output } from "process";
2558
- import { existsSync as existsSync8, readFileSync as readFileSync5 } from "fs";
2581
+ import { existsSync as existsSync8, readFileSync as readFileSync6 } from "fs";
2559
2582
  import { homedir as homedir6 } from "os";
2560
2583
  import { join as join7 } from "path";
2561
2584
  function readConfig() {
2562
2585
  if (!existsSync8(CONFIG_PATH3)) return {};
2563
2586
  const out = {};
2564
- for (const line of readFileSync5(CONFIG_PATH3, "utf-8").split("\n")) {
2587
+ for (const line of readFileSync6(CONFIG_PATH3, "utf-8").split("\n")) {
2565
2588
  const t = line.trim();
2566
2589
  if (!t || t.startsWith("#")) continue;
2567
2590
  const eq = t.indexOf("=");
@@ -3068,13 +3091,17 @@ function disconnectCommand(args2 = []) {
3068
3091
  const mcpRemoved = uninstallMcpConfig();
3069
3092
  console.log(`${mcpRemoved ? "\u2713" : "\xB7"} MCP guardrails server: ${mcpRemoved ? "removed entry from ~/.claude.json" : "no Synkro MCP entry found"}`);
3070
3093
  }
3071
- if (purge && existsSync9(SYNKRO_DIR4)) {
3072
- rmSync(SYNKRO_DIR4, { recursive: true, force: true });
3073
- console.log(`\u2713 Removed ${SYNKRO_DIR4} (--purge)`);
3074
- } else {
3094
+ if (purge) {
3095
+ if (existsSync9(SYNKRO_DIR4)) {
3096
+ rmSync(SYNKRO_DIR4, { recursive: true, force: true });
3097
+ console.log(`\u2713 Removed ${SYNKRO_DIR4}`);
3098
+ } else {
3099
+ console.log(`\xB7 ${SYNKRO_DIR4} already gone, nothing to remove`);
3100
+ }
3101
+ } else if (existsSync9(SYNKRO_DIR4)) {
3075
3102
  console.log(`Config preserved at ${SYNKRO_DIR4}. Run with --purge to remove.`);
3076
3103
  }
3077
- console.log("\nSynkro disconnected. Your AI agents will no longer be judged.");
3104
+ console.log("\nSynkro disconnected.");
3078
3105
  }
3079
3106
  var SYNKRO_DIR4;
3080
3107
  var init_disconnect = __esm({
@@ -3088,7 +3115,7 @@ var init_disconnect = __esm({
3088
3115
  });
3089
3116
 
3090
3117
  // cli/bootstrap.js
3091
- import { readFileSync as readFileSync6, existsSync as existsSync10 } from "fs";
3118
+ import { readFileSync as readFileSync7, existsSync as existsSync10 } from "fs";
3092
3119
  import { resolve } from "path";
3093
3120
  var envCandidates = [
3094
3121
  resolve(process.cwd(), ".env"),
@@ -3096,7 +3123,7 @@ var envCandidates = [
3096
3123
  ];
3097
3124
  for (const envPath of envCandidates) {
3098
3125
  if (!existsSync10(envPath)) continue;
3099
- const envContent = readFileSync6(envPath, "utf-8");
3126
+ const envContent = readFileSync7(envPath, "utf-8");
3100
3127
  for (const line of envContent.split("\n")) {
3101
3128
  const trimmed = line.trim();
3102
3129
  if (!trimmed || trimmed.startsWith("#")) continue;
@@ -3117,7 +3144,7 @@ Usage:
3117
3144
  synkro <command> [options]
3118
3145
 
3119
3146
  Commands:
3120
- install Install Synkro hooks for detected agents (Claude Code, etc.)
3147
+ install [--force] Install Synkro hooks for detected agents (Claude Code, etc.)
3121
3148
  login Authenticate with Synkro (browser OAuth via WorkOS)
3122
3149
  logout Clear local credentials
3123
3150
  status Show current setup state
@@ -3131,8 +3158,6 @@ Quick start:
3131
3158
  $ synkro install # one-time setup
3132
3159
  $ synkro setup-github # enable PR scanning (optional)
3133
3160
  $ claude # use Claude Code normally; Synkro judges in real time
3134
-
3135
- Docs: https://docs.synkro.sh
3136
3161
  `);
3137
3162
  }
3138
3163
  async function main() {