|
| 1 | +#!/bin/sh |
| 2 | +# claude-remote-control-off - turn Claude Code "Remote Control" off, for good. |
| 3 | +# |
| 4 | +# What it does (and nothing else): |
| 5 | +# 1. Locates your Claude Code user settings file (~/.claude/settings.json, |
| 6 | +# or $CLAUDE_CONFIG_DIR/settings.json if that variable is set). |
| 7 | +# 2. Makes a timestamped backup next to it. |
| 8 | +# 3. Sets two documented keys: |
| 9 | +# "disableRemoteControl": true -> the kill-switch (feature off entirely) |
| 10 | +# "remoteControlAtStartup": false -> no auto-connect at startup |
| 11 | +# 4. Re-reads the result and proves the keys are there BEFORE replacing the file. |
| 12 | +# |
| 13 | +# It never uses sudo, never phones home, never touches any other key, |
| 14 | +# and never writes a file it cannot parse back. Safe to run twice. |
| 15 | +# Close Claude Code first: the setting is read at launch, and a running |
| 16 | +# instance could overwrite the file at the same moment. |
| 17 | +# |
| 18 | +# Docs for the keys: |
| 19 | +# https://code.claude.com/docs/en/settings-reference#disableremotecontrol |
| 20 | +# https://code.claude.com/docs/en/settings-reference#remotecontrolatstartup |
| 21 | +# |
| 22 | +# The whole script is a function called on the LAST line, so a download that |
| 23 | +# is cut short can never run a half-script. |
| 24 | + |
| 25 | +set -eu |
| 26 | +umask 077 |
| 27 | + |
| 28 | +usage() { |
| 29 | + cat <<'TXT' |
| 30 | +usage: sh install.sh [option] |
| 31 | + (none) apply: disableRemoteControl=true, remoteControlAtStartup=false |
| 32 | + --dry-run show what would change, write nothing |
| 33 | + --status show current state, write nothing |
| 34 | + --uninstall remove the two keys (Remote Control available again) |
| 35 | + -h, --help this text |
| 36 | +settings file: $CLAUDE_CONFIG_DIR/settings.json, default ~/.claude/settings.json |
| 37 | +TXT |
| 38 | +} |
| 39 | + |
| 40 | +say() { printf '%s\n' "$*"; } |
| 41 | +die() { printf 'error: %s\n' "$*" >&2; exit 1; } |
| 42 | + |
| 43 | +# read_state FILE -> "<disableRemoteControl> <remoteControlAtStartup>", each true|false|unset, or "INVALID INVALID" |
| 44 | +read_state() { |
| 45 | + f="$1" |
| 46 | + [ -f "$f" ] || { say "unset unset"; return; } |
| 47 | + case "$JSON_TOOL" in |
| 48 | + python3) python3 - "$f" <<'PY' |
| 49 | +import json,sys |
| 50 | +try: |
| 51 | + d=json.load(open(sys.argv[1],encoding="utf-8")) |
| 52 | + if not isinstance(d,dict): raise ValueError |
| 53 | +except Exception: |
| 54 | + print("INVALID INVALID"); sys.exit(0) |
| 55 | +g=lambda k: json.dumps(d[k],separators=(",",":")) if k in d else "unset" |
| 56 | +print(g("disableRemoteControl"), g("remoteControlAtStartup")) |
| 57 | +PY |
| 58 | + ;; |
| 59 | + node) node -e ' |
| 60 | +const fs=require("fs");let d; |
| 61 | +try{d=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));if(typeof d!=="object"||d===null||Array.isArray(d))throw 0} |
| 62 | +catch(e){console.log("INVALID INVALID");process.exit(0)} |
| 63 | +const g=k=>k in d?JSON.stringify(d[k]):"unset"; |
| 64 | +console.log(g("disableRemoteControl"),g("remoteControlAtStartup"))' "$f" ;; |
| 65 | + jq) [ -s "$f" ] || { say "INVALID INVALID"; return; } |
| 66 | + jq -r 'if type!="object" then error("x") else . end |
| 67 | + | def g(k): if has(k) then (.[k]|tojson) else "unset" end; |
| 68 | + "\(g("disableRemoteControl")) \(g("remoteControlAtStartup"))"' "$f" 2>/dev/null || say "INVALID INVALID" ;; |
| 69 | + esac |
| 70 | +} |
| 71 | + |
| 72 | +# write_state SRC DST MODE (apply|uninstall): merge SRC (or {} if absent) into DST |
| 73 | +write_state() { |
| 74 | + src="$1"; dst="$2"; m="$3" |
| 75 | + case "$JSON_TOOL" in |
| 76 | + python3) python3 - "$src" "$dst" "$m" <<'PY' |
| 77 | +import json,sys,os |
| 78 | +src,dst,mode=sys.argv[1:4] |
| 79 | +d=json.load(open(src,encoding="utf-8")) if os.path.exists(src) else {} |
| 80 | +if not isinstance(d,dict): raise SystemExit("settings.json top level is not an object") |
| 81 | +if mode=="apply": |
| 82 | + d["disableRemoteControl"]=True; d["remoteControlAtStartup"]=False |
| 83 | +else: |
| 84 | + d.pop("disableRemoteControl",None); d.pop("remoteControlAtStartup",None) |
| 85 | +with open(dst,"w",encoding="utf-8",newline="\n") as fh: json.dump(d,fh,indent=2,ensure_ascii=False); fh.write("\n") |
| 86 | +PY |
| 87 | + ;; |
| 88 | + node) node -e ' |
| 89 | +const fs=require("fs");const [src,dst,mode]=process.argv.slice(1); |
| 90 | +let d=fs.existsSync(src)?JSON.parse(fs.readFileSync(src,"utf8")):{}; |
| 91 | +if(typeof d!=="object"||Array.isArray(d)||d===null) throw new Error("settings.json top level is not an object"); |
| 92 | +if(mode==="apply"){d.disableRemoteControl=true;d.remoteControlAtStartup=false} |
| 93 | +else{delete d.disableRemoteControl;delete d.remoteControlAtStartup} |
| 94 | +fs.writeFileSync(dst,JSON.stringify(d,null,2)+"\n")' "$src" "$dst" "$m" ;; |
| 95 | + jq) if [ -f "$src" ]; then in="$src"; else in=/dev/null; fi |
| 96 | + if [ "$m" = apply ]; then |
| 97 | + jq --null-input --rawfile _ "$in" '(if $_=="" then {} else ($_|fromjson) end) | if type!="object" then error("top level is not an object") else . end | . + {disableRemoteControl:true, remoteControlAtStartup:false}' > "$dst" |
| 98 | + else |
| 99 | + jq --null-input --rawfile _ "$in" '(if $_=="" then {} else ($_|fromjson) end) | if type!="object" then error("top level is not an object") else . end | del(.disableRemoteControl, .remoteControlAtStartup)' > "$dst" |
| 100 | + fi ;; |
| 101 | + esac |
| 102 | +} |
| 103 | + |
| 104 | +main() { |
| 105 | + MODE=apply |
| 106 | + case "${1:-}" in |
| 107 | + "") ;; |
| 108 | + --dry-run) MODE=dry ;; |
| 109 | + --status) MODE=status ;; |
| 110 | + --uninstall) MODE=uninstall ;; |
| 111 | + -h|--help) usage; exit 0 ;; |
| 112 | + *) printf 'unknown option: %s\n' "$1" >&2; usage >&2; exit 2 ;; |
| 113 | + esac |
| 114 | + |
| 115 | + CONF_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}" |
| 116 | + case "$CONF_DIR" in -*) die "CLAUDE_CONFIG_DIR must not start with '-'" ;; esac |
| 117 | + SETTINGS="$CONF_DIR/settings.json" |
| 118 | + |
| 119 | + JSON_TOOL="" |
| 120 | + # probe, don't just locate: a Windows Store "python3" alias exists on PATH but exits 9009 |
| 121 | + if python3 -c 'pass' >/dev/null 2>&1; then JSON_TOOL=python3 |
| 122 | + elif node -e '' >/dev/null 2>&1; then JSON_TOOL=node |
| 123 | + elif jq -n '.' >/dev/null 2>&1; then JSON_TOOL=jq |
| 124 | + fi |
| 125 | + |
| 126 | + say "claude-remote-control-off" |
| 127 | + say " settings file : $SETTINGS" |
| 128 | + say " json tool : ${JSON_TOOL:-none}" |
| 129 | + [ -n "$JSON_TOOL" ] || die "need python3, node or jq to edit JSON safely (none found)" |
| 130 | + |
| 131 | + if [ -L "$SETTINGS" ]; then |
| 132 | + die "$SETTINGS is a symlink (dotfile manager?) - point CLAUDE_CONFIG_DIR at the real directory, or edit the target yourself; nothing was changed" |
| 133 | + fi |
| 134 | + if [ -e "$SETTINGS" ] && [ ! -f "$SETTINGS" ]; then |
| 135 | + die "$SETTINGS exists but is not a regular file; nothing was changed" |
| 136 | + fi |
| 137 | + |
| 138 | + if [ -f "$SETTINGS" ]; then |
| 139 | + st=$(read_state "$SETTINGS") || die "could not read $SETTINGS with $JSON_TOOL" |
| 140 | + case "$st" in INVALID*) die "$SETTINGS is not a valid JSON object - fix it (or move it aside) first; nothing was changed" ;; esac |
| 141 | + else |
| 142 | + st="unset unset" |
| 143 | + say " (no settings file yet - one will be created)" |
| 144 | + fi |
| 145 | + case "$st" in *" "*) ;; *) die "unexpected reader output: '$st'" ;; esac |
| 146 | + cur_disable=${st%% *}; cur_startup=${st##* } |
| 147 | + say " current : disableRemoteControl=$cur_disable remoteControlAtStartup=$cur_startup" |
| 148 | + |
| 149 | + case "$MODE" in |
| 150 | + status) exit 0 ;; |
| 151 | + dry) |
| 152 | + say " would set : disableRemoteControl=true remoteControlAtStartup=false" |
| 153 | + say " (dry run - nothing written)"; exit 0 ;; |
| 154 | + apply) |
| 155 | + if [ "$cur_disable" = true ] && [ "$cur_startup" = false ]; then say " already off - nothing to do."; exit 0; fi ;; |
| 156 | + uninstall) |
| 157 | + if [ "$cur_disable" = unset ] && [ "$cur_startup" = unset ]; then say " keys not present - nothing to do."; exit 0; fi ;; |
| 158 | + esac |
| 159 | + |
| 160 | + mkdir -p -- "$CONF_DIR" |
| 161 | + [ -w "$CONF_DIR" ] || die "$CONF_DIR is not writable" |
| 162 | + |
| 163 | + # temp file in the SAME directory (atomic rename), unpredictable name, mode 600 (umask 077) |
| 164 | + tmp=$(mktemp -- "$SETTINGS.tmp.XXXXXX") || die "cannot create temp file in $CONF_DIR" |
| 165 | + trap 'rm -f -- "$tmp"' EXIT INT TERM |
| 166 | + |
| 167 | + write_state "$SETTINGS" "$tmp" "$MODE" || die "could not build new settings - nothing changed" |
| 168 | + |
| 169 | + # verify the CANDIDATE before it replaces anything |
| 170 | + new=$(read_state "$tmp") || die "could not re-read candidate - nothing changed" |
| 171 | + case "$MODE" in |
| 172 | + apply) want="true false" ;; |
| 173 | + uninstall) want="unset unset" ;; |
| 174 | + esac |
| 175 | + [ "$new" = "$want" ] || die "verification failed (got: $new) - nothing changed" |
| 176 | + |
| 177 | + if [ -f "$SETTINGS" ]; then |
| 178 | + bak="$SETTINGS.bak.$(date +%Y%m%d-%H%M%S).$$" |
| 179 | + [ -e "$bak" ] && die "backup $bak already exists - nothing changed" |
| 180 | + cp -- "$SETTINGS" "$bak" |
| 181 | + chmod 600 "$bak" 2>/dev/null || true |
| 182 | + say " backup : $bak" |
| 183 | + fi |
| 184 | + |
| 185 | + mv -- "$tmp" "$SETTINGS" |
| 186 | + trap - EXIT INT TERM |
| 187 | + chmod 600 "$SETTINGS" 2>/dev/null || true |
| 188 | + |
| 189 | + st=$(read_state "$SETTINGS") || die "post-write read failed - restore from the backup above" |
| 190 | + [ "$st" = "$want" ] || die "post-write check failed (got: $st) - restore from the backup above" |
| 191 | + say " verified : disableRemoteControl=${st%% *} remoteControlAtStartup=${st##* }" |
| 192 | + say "" |
| 193 | + if [ "$MODE" = apply ]; then |
| 194 | + say "Done. Restart Claude Code (terminal, VS Code, desktop) - the setting is read at launch." |
| 195 | + say "Undo any time: sh install.sh --uninstall" |
| 196 | + else |
| 197 | + say "Done. Remote Control is available again after you restart Claude Code." |
| 198 | + fi |
| 199 | +} |
| 200 | + |
| 201 | +main "$@" |
0 commit comments