#!/bin/sh # consensor installer for Claude Code # https://consensor.io/install · built by Bluelabs Pty Ltd · protected by HOURGLASS # # Read this before you run it. What it does, in order: # 1. Checks the `claude` CLI is installed. # 2. Backs up ~/.claude/settings.json and ~/.claude.json (timestamped, under # ~/.claude/consensor/backups/). # 3. Adds the consensor connector: # claude mcp add --transport http consensor https://consensor.io/mcp --scope user # (skipped if a server named "consensor" already exists). # 4. Merges a short list of recommended permission rules into # ~/.claude/settings.json. It only ADDS rules you don't already have; # a rule you already have, in any list, is left exactly where it is. # 5. Asks (default: No) whether to install the optional timing add-on, a # status line script that sends five numbers (usage percentages, token # totals, cost) and a random install ID to consensor. Never prompts or code. # 6. Prints the uninstall command. Running this installer again is safe. # # Non-interactive use: CONSENSOR_TIMING=yes|no (default no when there is no terminal) # shellcheck shell=sh set -eu umask 077 CONSENSOR_VERSION="0.129.0" MCP_URL="${CONSENSOR_MCP_URL:-https://consensor.io/mcp}" CLAUDE_DIR="${HOME}/.claude" DIR="${CLAUDE_DIR}/consensor" STATE="${DIR}/state" SETTINGS="${CLAUDE_DIR}/settings.json" CLAUDE_JSON="${HOME}/.claude.json" # Literal "~": Claude Code runs the statusLine command through a shell, which expands it. # shellcheck disable=SC2088 STATUSLINE_CMD="~/.claude/consensor/statusline.sh" say() { printf '%s\n' "$*"; } warn() { printf 'consensor: %s\n' "$*" >&2; } die() { printf 'consensor: %s\n' "$*" >&2; exit 1; } pick_interp() { if [ -n "${CONSENSOR_INTERP:-}" ]; then printf '%s' "$CONSENSOR_INTERP" elif command -v node >/dev/null 2>&1 && node -e '' >/dev/null 2>&1; then printf 'node' elif command -v python3 >/dev/null 2>&1 && python3 -c 'import json' >/dev/null 2>&1; then printf 'python' else printf 'none' fi } run_merge() { case "$INTERP" in node) node "${DIR}/lib/merge.cjs" "$@" ;; python) python3 "${DIR}/lib/merge.py" "$@" ;; *) return 2 ;; esac } ask_timing() { case "${CONSENSOR_TIMING:-}" in y|Y|yes|YES|Yes) return 0 ;; n|N|no|NO|No) return 1 ;; esac if (exec /dev/null; then printf 'Install the optional timing add-on? It sends only usage numbers, never prompts or code. [y/N] ' >/dev/tty answer="" read -r answer "${bdir}/${name}.absent" fi done if [ ! -f "${DIR}/backups/ORIGINAL" ]; then printf '%s\n' "$ts" > "${DIR}/backups/ORIGINAL" fi say " backed up to ${bdir}" } write_files() { mkdir -p "${DIR}/lib" "$STATE" "${DIR}/backups" cat > "${DIR}/lib/merge.cjs" <<'CONSENSOR_EOF_MERGE_JS' // consensor settings merge helper (node). Mirrors merge.py exactly. // Installed as ~/.claude/consensor/lib/merge.cjs (the .cjs extension keeps it // CommonJS even if a parent folder's package.json says "type": "module"). // Merges, never overwrites: a rule the user already has, in ANY list, is left alone. "use strict"; const fs = require("fs"); const path = require("path"); const RULES = { allow: ["Read", "Edit", "Bash(git status:*)", "Bash(git diff:*)", "Bash(git log:*)", "Bash(npm run test:*)", "mcp__consensor__*"], ask: ["Bash(git push:*)", "Bash(wrangler deploy:*)"], deny: ["Bash(rm -rf:*)", "Read(./.env)", "Read(./.env.*)"], }; const LISTS = ["allow", "ask", "deny"]; function load(file, fallback) { let text; try { text = fs.readFileSync(file, "utf8"); } catch (e) { if (e.code === "ENOENT") return fallback; throw e; } if (text.trim() === "") return fallback; const v = JSON.parse(text); if (!v || typeof v !== "object" || Array.isArray(v)) throw new Error(file + " is not a JSON object"); return v; } function save(file, obj) { const tmp = path.join(path.dirname(file), "." + path.basename(file) + ".consensor-tmp"); fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + "\n", { mode: 0o600 }); fs.renameSync(tmp, file); } function blankManifest() { return { version: 1, added: { allow: [], ask: [], deny: [] }, created: {}, statusLine: null, skipped: [] }; } function same(a, b) { return JSON.stringify(a) === JSON.stringify(b); } function apply(settingsFile, manifestFile) { const s = load(settingsFile, {}); const m = load(manifestFile, blankManifest()); if (s.permissions === undefined) { s.permissions = {}; m.created.permissions = true; } if (!s.permissions || typeof s.permissions !== "object" || Array.isArray(s.permissions)) throw new Error("permissions is not an object"); for (const list of LISTS) { if (s.permissions[list] === undefined) { s.permissions[list] = []; m.created[list] = true; } if (!Array.isArray(s.permissions[list])) throw new Error("permissions." + list + " is not a list"); } let added = 0; for (const list of LISTS) { for (const rule of RULES[list]) { const home = LISTS.find((l) => s.permissions[l].includes(rule)); if (home === undefined) { s.permissions[list].push(rule); if (!m.added[list].includes(rule)) m.added[list].push(rule); added++; } else if (home !== list && !m.added[home].includes(rule)) { if (!m.skipped.includes(rule)) m.skipped.push(rule); process.stdout.write(" kept your rule: " + rule + " stays in " + home + "\n"); } } } save(settingsFile, s); save(manifestFile, m); process.stdout.write(" permission rules added: " + added + "\n"); } function statuslineSet(settingsFile, manifestFile, command) { const s = load(settingsFile, {}); const m = load(manifestFile, blankManifest()); const ours = { type: "command", command: command }; if (s.statusLine !== undefined && !same(s.statusLine, ours)) { process.stdout.write(" you already have a statusLine; it was left unchanged\n"); return 10; } s.statusLine = ours; m.statusLine = ours; save(settingsFile, s); save(manifestFile, m); return 0; } function revert(settingsFile, manifestFile) { const m = load(manifestFile, null); if (!m) return 0; let s; try { s = load(settingsFile, null); } catch (e) { process.stderr.write(" settings.json is not valid JSON; left unchanged\n"); return 3; } if (!s) return 0; const p = s.permissions; if (p && typeof p === "object" && !Array.isArray(p)) { for (const list of LISTS) { if (!Array.isArray(p[list])) continue; p[list] = p[list].filter((r) => !(m.added[list] || []).includes(r)); if (m.created[list] && p[list].length === 0) delete p[list]; } if (m.created.permissions && Object.keys(p).length === 0) delete s.permissions; } if (m.statusLine && same(s.statusLine, m.statusLine)) delete s.statusLine; save(settingsFile, s); if (Object.keys(s).length === 0) process.stdout.write("EMPTY\n"); return 0; } function hasMcp(claudeJson, name) { let j; try { j = load(claudeJson, {}); } catch (e) { return 1; } return j.mcpServers && typeof j.mcpServers === "object" && Object.prototype.hasOwnProperty.call(j.mcpServers, name) ? 0 : 1; } function main(argv) { const [cmd, a, b, c] = argv; try { if (cmd === "apply") { apply(a, b); return 0; } if (cmd === "statusline-set") return statuslineSet(a, b, c); if (cmd === "revert") return revert(a, b); if (cmd === "has-mcp") return hasMcp(a, b); if (cmd === "selftest") return 0; } catch (e) { process.stderr.write(" consensor merge: " + e.message + "\n"); return 3; } process.stderr.write("usage: merge.cjs apply|statusline-set|revert|has-mcp ...\n"); return 2; } process.exitCode = main(process.argv.slice(2)); CONSENSOR_EOF_MERGE_JS cat > "${DIR}/lib/merge.py" <<'CONSENSOR_EOF_MERGE_PY' # consensor settings merge helper (python3). Mirrors merge.js exactly. # Merges, never overwrites: a rule the user already has, in ANY list, is left alone. import json import os import sys RULES = { "allow": ["Read", "Edit", "Bash(git status:*)", "Bash(git diff:*)", "Bash(git log:*)", "Bash(npm run test:*)", "mcp__consensor__*"], "ask": ["Bash(git push:*)", "Bash(wrangler deploy:*)"], "deny": ["Bash(rm -rf:*)", "Read(./.env)", "Read(./.env.*)"], } LISTS = ["allow", "ask", "deny"] class Bad(Exception): pass def load(path, fallback): try: with open(path, "r", encoding="utf-8") as f: text = f.read() except FileNotFoundError: return fallback if text.strip() == "": return fallback v = json.loads(text) if not isinstance(v, dict): raise Bad(path + " is not a JSON object") return v def save(path, obj): tmp = os.path.join(os.path.dirname(path), "." + os.path.basename(path) + ".consensor-tmp") fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) with os.fdopen(fd, "w", encoding="utf-8") as f: f.write(json.dumps(obj, indent=2, ensure_ascii=False) + "\n") os.replace(tmp, path) def blank_manifest(): return {"version": 1, "added": {"allow": [], "ask": [], "deny": []}, "created": {}, "statusLine": None, "skipped": []} def same(a, b): return json.dumps(a, sort_keys=False) == json.dumps(b, sort_keys=False) def apply(settings_file, manifest_file): s = load(settings_file, {}) m = load(manifest_file, blank_manifest()) if "permissions" not in s: s["permissions"] = {} m["created"]["permissions"] = True if not isinstance(s["permissions"], dict): raise Bad("permissions is not an object") p = s["permissions"] for lst in LISTS: if lst not in p: p[lst] = [] m["created"][lst] = True if not isinstance(p[lst], list): raise Bad("permissions." + lst + " is not a list") added = 0 for lst in LISTS: for rule in RULES[lst]: home = next((l for l in LISTS if rule in p[l]), None) if home is None: p[lst].append(rule) if rule not in m["added"][lst]: m["added"][lst].append(rule) added += 1 elif home != lst and rule not in m["added"][home]: if rule not in m["skipped"]: m["skipped"].append(rule) sys.stdout.write(" kept your rule: " + rule + " stays in " + home + "\n") save(settings_file, s) save(manifest_file, m) sys.stdout.write(" permission rules added: " + str(added) + "\n") return 0 def statusline_set(settings_file, manifest_file, command): s = load(settings_file, {}) m = load(manifest_file, blank_manifest()) ours = {"type": "command", "command": command} if "statusLine" in s and not same(s["statusLine"], ours): sys.stdout.write(" you already have a statusLine; it was left unchanged\n") return 10 s["statusLine"] = ours m["statusLine"] = ours save(settings_file, s) save(manifest_file, m) return 0 def revert(settings_file, manifest_file): m = load(manifest_file, None) if not m: return 0 try: s = load(settings_file, None) except (ValueError, Bad): sys.stderr.write(" settings.json is not valid JSON; left unchanged\n") return 3 if s is None: return 0 p = s.get("permissions") if isinstance(p, dict): for lst in LISTS: if not isinstance(p.get(lst), list): continue ours = m["added"].get(lst, []) p[lst] = [r for r in p[lst] if r not in ours] if m["created"].get(lst) and len(p[lst]) == 0: del p[lst] if m["created"].get("permissions") and len(p) == 0: del s["permissions"] if m.get("statusLine") and same(s.get("statusLine"), m["statusLine"]): del s["statusLine"] save(settings_file, s) if len(s) == 0: sys.stdout.write("EMPTY\n") return 0 def has_mcp(claude_json, name): try: j = load(claude_json, {}) except (ValueError, Bad): return 1 servers = j.get("mcpServers") return 0 if isinstance(servers, dict) and name in servers else 1 def main(argv): cmd = argv[0] if argv else "" try: if cmd == "apply": return apply(argv[1], argv[2]) if cmd == "statusline-set": return statusline_set(argv[1], argv[2], argv[3]) if cmd == "revert": return revert(argv[1], argv[2]) if cmd == "has-mcp": return has_mcp(argv[1], argv[2]) if cmd == "selftest": return 0 except (ValueError, Bad, OSError) as e: sys.stderr.write(" consensor merge: " + str(e) + "\n") return 3 sys.stderr.write("usage: merge.py apply|statusline-set|revert|has-mcp ...\n") return 2 if __name__ == "__main__": sys.exit(main(sys.argv[1:])) CONSENSOR_EOF_MERGE_PY cat > "${DIR}/uninstall.sh" <<'CONSENSOR_EOF_UNINSTALL' #!/bin/sh # consensor uninstaller for Claude Code. Reverses install.sh. # # sh ~/.claude/consensor/uninstall.sh reverse consensor's changes # sh ~/.claude/consensor/uninstall.sh --restore-backups put ~/.claude/settings.json and # ~/.claude.json back byte-for-byte # as they were before the first install # # Default behaviour: # - settings.json: if it has not changed since consensor last touched it, the # pre-install backup is restored byte-for-byte. If you have edited it since, # only the rules and status line consensor added are removed; your edits stay. # - ~/.claude.json: `claude mcp remove consensor --scope user`, only if consensor # added it. (This file holds live Claude Code state, so it is not rolled back # unless you pass --restore-backups.) # - Before anything changes, the current files are snapshotted to # ~/.claude/consensor/backups/