#!/bin/sh # Ramble installer: connects the coding agents on this machine to Ramble with one command and one click. # # Read it first, then run it. To see what it would do without changing anything or contacting anything: # curl -fsSL https://ramble.sh/install.sh -o install.sh # sh install.sh --dry-run # # What it does: # 1. Asks Ramble for a one-time code, opens https://ramble.sh/link in your browser (or prints the # link), and waits for you to click Connect. That click hands this script an agent token: it can # read your rambles, add notes and statuses, and ask you to record; it can never upload, record or # delete your account. It lasts a year and you can revoke it under Connected agents on /mine. # 2. Writes that token into each agent it finds, as a bearer header in that agent's own MCP config: # the agent's own "mcp add" command (Claude Code, Gemini CLI), one entry merged into # ~/.cursor/mcp.json (Cursor), one table appended to ~/.codex/config.toml after "codex mcp add" # (Codex). Files this script writes with the token in them are mode 600. It prints every path and # command before it acts, and it never prints the token. It cannot keep the token out of the # process list for the moment an agent's own command runs: Claude Code and Gemini CLI take it as # an argument. # 3. Copies one file, SKILL.md, into skills folders under your home directory. # What it never does: download anything except SKILL.md, use sudo, pipe text into a shell or an # interpreter, evaluate downloaded text, or ask you to paste a token. # # --oauth skip step 1: add each agent by address only, and sign in from inside the agent instead. # --no-browser print the link instead of opening it (an ssh session or a machine with no browser). # --dry-run print the plan and stop. Nothing is written and nothing is contacted. # # RAMBLE_INSTALL_BASE changes the address (default https://ramble.sh), for a self-hosted Ramble. # # Layout: everything is a function, and the only statement that runs anything is the last line, inside # a brace group. A download cut short is therefore a syntax error (a shell does not start a brace group # it has not finished reading) and runs nothing, whichever byte it stops at. set -eu die() { printf 'ramble install: %s\n' "$*" >&2; exit 1; } say() { printf '%s\n' "$*"; } have() { command -v "$1" >/dev/null 2>&1; } add_dir() { case " $SKILL_DIRS " in *" $1 "*) ;; *) SKILL_DIRS="$SKILL_DIRS $1" ;; esac } show() { printf ' $' for a in "$@"; do case $a in ''|*[!A-Za-z0-9_@%+=:,./-]*) printf " '%s'" "$a" ;; *) printf ' %s' "$a" ;; esac done printf '\n' } # need_tmp: one private temp directory per run, removed on every way out (finish, die, Ctrl-C). need_tmp() { [ -z "$TMP" ] || return 0 TMP=$(mktemp -d 2>/dev/null || { t="${TMPDIR:-/tmp}/ramble-install.$$"; mkdir -m 700 "$t" && printf '%s' "$t"; }) [ -n "$TMP" ] && [ -d "$TMP" ] || die "could not create a temporary directory" trap 'rm -rf "$TMP"' EXIT trap 'exit 130' INT TERM } # try COMMAND...: print it (plan) or run it (run). A failure is reported and never stops the rest. try() { if [ "$PASS" = plan ]; then show "$@"; return 0; fi # stdin is closed so an agent's own CLI can never read (or wait on) what is piped into this script. if "$@" "$out" ); fi ( umask 077; exec "$@" ) "$out" 2>/dev/null & cmd_pid=$! ( i=0 while [ "$i" -lt "$secs" ] && kill -0 "$cmd_pid" 2>/dev/null; do sleep 1; i=$((i + 1)); done kill "$cmd_pid" 2>/dev/null ) >/dev/null 2>&1 & watch_pid=$! cmd_rc=0 wait "$cmd_pid" 2>/dev/null || cmd_rc=$? kill "$watch_pid" 2>/dev/null || true wait "$watch_pid" 2>/dev/null || true return "$cmd_rc" } # bounded SECONDS COMMAND...: bounded_to, with the command's output dropped. bounded() { secs=$1; shift bounded_to /dev/null "$secs" "$@" } # try_secret COMMAND...: like try, for a command whose arguments carry the token. In the plan the # command is printed with a placeholder. When it runs, its own output is not shown, because an agent's # "mcp add" can echo the headers it was given back to the terminal, and the token is never printed. # Returns the command's status in run (and 0 in plan). try_secret() { if [ "$PASS" = plan ]; then show "$@"; return 0; fi bounded "$AGENT_TIMEOUT" "$@" } # manual TEXT: something this script does not do for you. manual() { say " $*"; } # by_hand: what a person does by hand for an agent this script could not configure itself. by_hand() { manual "Make a token in $BASE/mine (Connected agents, New agent token), or re-run this script with --oauth." } # plain_dir DIR: true when DIR does not exist yet, or is a real directory reached without a symlink: resolved # with cd -P it is exactly where its parent, resolved the same way, says it is. An agent's config folder that # is a link into a dotfiles repository would get the token written into a git working tree, so such a folder # is never given one. (Only the folder is judged: a symlink above the parent resolves the same on both sides.) plain_dir() { if [ ! -e "$1" ] && [ ! -L "$1" ]; then return 0; fi if [ -L "$1" ]; then return 1; fi pd_parent=$(cd -P "$(dirname "$1")" 2>/dev/null && pwd -P) || return 1 pd_actual=$(cd -P "$1" 2>/dev/null && pwd -P) || return 1 [ "$pd_actual" = "$pd_parent/${1##*/}" ] } # refused WHAT: an agent whose config sits behind a symlink gets no token from this script. refused() { say " $1 is a symlink, or sits behind one, so this script will not put a token there (it could end up in a repository)." by_hand } # merge_cursor FILE: add the ramble entry to a Cursor-style JSON file, keeping every other entry. With a # token (AUTH=device) the entry carries it as an Authorization header and the file ends up mode 600; the # token reaches the interpreter through its environment, never its arguments. merge_cursor() { file=$1 if [ "$AUTH" = device ]; then entry="{\"url\": \"$MCP\", \"headers\": {\"Authorization\": \"Bearer \"}}" else entry="{\"url\": \"$MCP\"}"; fi # A config folder behind a symlink is never given a token (R_CURSOR is set in main). if [ "$AUTH" = device ] && [ "$R_CURSOR" = 1 ]; then say " $(dirname "$file") is a symlink, or sits behind one, so this script will not put a token there. Add this entry to the real file:" say " {\"mcpServers\": {\"ramble\": $entry}}" if [ "$PASS" = run ]; then by_hand; fi return 0 fi # A symlinked config (a dotfiles repo, a shared file) is never replaced with a regular file, and this # script does not guess which file it points to: it says what to add and leaves the link as it is. if [ -L "$file" ]; then if [ "$PASS" = plan ]; then say " $file is a symlink, so this script will not touch it. Add this entry to the file it points to:" say " {\"mcpServers\": {\"ramble\": $entry}}" else say " skipped $file (it is a symlink); add the entry shown above by hand" if [ "$AUTH" = device ]; then by_hand; fi fi return 0 fi if [ "$PASS" = plan ]; then say " add this entry to $file, keeping everything already there:" say " {\"mcpServers\": {\"ramble\": $entry}}" return 0 fi mkdir -p "$(dirname "$file")" rc=0 if have node; then RAMBLE_TOKEN="$TOKEN" node -e ' const fs = require("fs"); const file = process.argv[1], url = process.argv[2], token = process.env.RAMBLE_TOKEN || ""; let data = {}; if (fs.existsSync(file)) { try { data = JSON.parse(fs.readFileSync(file, "utf8")); } catch (e) { process.exit(4); } } if (data === null || typeof data !== "object" || Array.isArray(data)) process.exit(4); if (data.mcpServers === undefined) data.mcpServers = {}; const s = data.mcpServers; if (s === null || typeof s !== "object" || Array.isArray(s)) process.exit(4); let replaced = false; if (s.ramble) { // Without a token an existing entry is never touched. With one, only an entry that already // points at this address is ours to replace (it may hold an old or revoked token). if (!token || s.ramble === null || typeof s.ramble !== "object" || s.ramble.url !== url) process.exit(3); replaced = true; } s.ramble = token ? { url: url, headers: { Authorization: "Bearer " + token } } : { url: url }; const tmp = file + ".ramble-tmp-" + process.pid; fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 }); fs.renameSync(tmp, file); if (token) fs.chmodSync(file, 0o600); process.exit(replaced ? 6 : 0); ' "$file" "$MCP" || rc=$? elif have python3; then # -I (isolated): no current directory on the import path, so a json.py lying in the folder this runs from # cannot stand in for the standard library, and no PYTHON* variable changes what is imported. RAMBLE_TOKEN="$TOKEN" python3 -I -c ' import json, os, sys file, url = sys.argv[1], sys.argv[2] token = os.environ.get("RAMBLE_TOKEN", "") data = {} if os.path.exists(file): try: data = json.load(open(file)) except Exception: sys.exit(4) if not isinstance(data, dict): sys.exit(4) s = data.setdefault("mcpServers", {}) if not isinstance(s, dict): sys.exit(4) replaced = False if s.get("ramble"): if not token or not isinstance(s["ramble"], dict) or s["ramble"].get("url") != url: sys.exit(3) replaced = True s["ramble"] = {"url": url, "headers": {"Authorization": "Bearer " + token}} if token else {"url": url} tmp = file + ".ramble-tmp-" + str(os.getpid()) fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) with os.fdopen(fd, "w") as f: json.dump(data, f, indent=2) f.write("\n") os.rename(tmp, file) if token: os.chmod(file, 0o600) sys.exit(6 if replaced else 0) ' "$file" "$MCP" || rc=$? else rc=5 fi case $rc in 0) say " done: $file" ;; 6) say " replaced the ramble entry in $file (mode 600)" ;; 3) say " ramble is already in $file; left as it was" ;; 4) say " $file is not plain JSON this script can edit; left untouched. Add the entry above by hand." if [ "$AUTH" = device ]; then by_hand; fi ;; *) say " neither node nor python3 is available; add the entry above to $file by hand." if [ "$AUTH" = device ]; then by_hand; fi ;; esac } # claude_add: Claude Code takes the header as an argument of its own "mcp add". A ramble server that is # already there is replaced only when it is this address's (its URL, from "mcp get", is exactly $MCP); one # for another address is somebody's own and is left alone. claude_add() { if [ "$AUTH" != device ]; then if have claude; then try claude mcp add --transport http --scope user ramble "$MCP" else manual "claude is not on PATH. Run: claude mcp add --transport http --scope user ramble $MCP"; fi return 0 fi if ! have claude; then manual "claude is not on PATH, so it was left alone. Run this once it is, or re-run this script:" manual " claude mcp add --transport http --scope user ramble $MCP --header 'Authorization: Bearer TOKEN'" by_hand return 0 fi if [ "$R_CLAUDE" = 1 ]; then refused "$HOME/.claude.json"; return 0; fi hdr="Authorization: Bearer $TOKEN" if [ "$PASS" = plan ]; then show claude mcp get ramble say " a ramble server for this address that is already there is replaced (the new token is in hand);" say " one for another address is left alone:" show claude mcp remove ramble -s user show claude mcp add --transport http --scope user ramble "$MCP" --header "Authorization: Bearer " return 0 fi need_tmp existing=none if bounded_to "$TMP/claude-get.txt" "$AGENT_TIMEOUT" claude mcp get ramble; then if grep -Fxq " URL: $MCP" "$TMP/claude-get.txt"; then existing=ours; else existing=other; fi fi rm -f "$TMP/claude-get.txt" if [ "$existing" = other ]; then say " a ramble server for another address is already in Claude Code, so it was left as it was." manual "To use this one instead, run: claude mcp remove ramble and then this script again." return 0 fi if [ "$existing" = ours ] && ! try_secret claude mcp remove ramble -s user; then say " the ramble server that was there could not be removed, so it was left as it was." by_hand return 0 fi if try_secret claude mcp add --transport http --scope user ramble "$MCP" --header "$hdr"; then if [ "$existing" = ours ]; then say " done (replaced the ramble server that was there)"; else say " done"; fi else say " that command failed. Its output is not shown, because it can contain the token." by_hand fi } # gemini_add: Gemini CLI takes -H after the positionals (-H is an array option and would swallow them). gemini_add() { if [ "$AUTH" != device ]; then if have gemini; then try gemini mcp add --transport http -s user ramble "$MCP" else manual "gemini is not on PATH. Run: gemini mcp add --transport http -s user ramble $MCP"; fi return 0 fi if ! have gemini; then manual "gemini is not on PATH, so it was left alone. Run this once it is, or re-run this script:" manual " gemini mcp add --transport http -s user ramble $MCP -H 'Authorization: Bearer TOKEN'" by_hand return 0 fi if [ "$R_GEMINI" = 1 ]; then refused "$HOME/.gemini"; return 0; fi if [ "$PASS" = plan ]; then show gemini mcp add --transport http -s user ramble "$MCP" -H "Authorization: Bearer " return 0 fi # Gemini CLI keeps whatever mode its settings file already has, so an existing one is tightened first. if [ -f "$HOME/.gemini/settings.json" ] && [ ! -L "$HOME/.gemini/settings.json" ]; then chmod 600 "$HOME/.gemini/settings.json" || true; fi # "mcp add" for a server that exists updates it in place. if try_secret gemini mcp add --transport http -s user ramble "$MCP" -H "Authorization: Bearer $TOKEN"; then # Gemini CLI writes its settings file readable by others; the token is in it now. if [ -f "$HOME/.gemini/settings.json" ] && [ ! -L "$HOME/.gemini/settings.json" ]; then chmod 600 "$HOME/.gemini/settings.json" || true; fi say " done" else say " that command failed. Its output is not shown, because it can contain the token." by_hand fi } # codex_add: "codex mcp add" would start a browser sign-in on a server that offers one and wait for it, and # it has no flag for a static header, so with a token this script writes Codex's own documented tables, # [mcp_servers.ramble] and [mcp_servers.ramble.http_headers], into config.toml. The file is copied first, # before anything is removed; a ramble server that is already there is replaced only when it is this # address's (its url, from "codex mcp get", is exactly $MCP), and one for another address is left alone. It # is only written when no ramble table is left (a second table of the same name would break Codex's # config), the file is not a symlink, and "codex mcp get" still reads the result; otherwise the copy is put # back. codex_add() { cfg="${CODEX_HOME:-$HOME/.codex}/config.toml" if [ "$AUTH" != device ]; then if have codex; then try codex mcp add ramble --url "$MCP" else manual "codex is not on PATH. Run: codex mcp add ramble --url $MCP"; fi return 0 fi if ! have codex; then manual "codex is not on PATH, so it was left alone. Run this once it is, or re-run this script:" manual " codex mcp add ramble --url $MCP then codex mcp login ramble" return 0 fi if [ "$R_CODEX" = 1 ]; then refused "$(dirname "$cfg")"; return 0; fi if [ "$PASS" = plan ]; then show codex mcp get ramble say " a ramble server for this address that is already there is replaced; one for another address is left alone:" show codex mcp remove ramble say " then append these tables to $cfg (mode 600), after copying it. \"codex mcp add\" is not used: it starts a browser sign-in." say " [mcp_servers.ramble]" say " url = \"$MCP\"" say "" say " [mcp_servers.ramble.http_headers]" say " Authorization = \"Bearer \"" say " and checks that Codex reads the result:" show codex mcp get ramble return 0 fi if [ -L "$cfg" ]; then say " skipped $cfg (it is a symlink). Run: codex mcp add ramble --url $MCP then codex mcp login ramble" return 0 fi need_tmp had=0 if [ -f "$cfg" ]; then had=1; ( umask 077; cp "$cfg" "$TMP/codex-config.toml" ); else mkdir -p "$(dirname "$cfg")"; fi if bounded_to "$TMP/codex-get.txt" "$AGENT_TIMEOUT" codex mcp get ramble; then if grep -Fxq " url: $MCP" "$TMP/codex-get.txt"; then bounded "$AGENT_TIMEOUT" codex mcp remove ramble || true else rm -f "$TMP/codex-get.txt" say " a ramble server for another address is already in Codex, so it was left as it was." manual "To use this one instead, run: codex mcp remove ramble and then this script again." return 0 fi fi rm -f "$TMP/codex-get.txt" if [ -f "$cfg" ] && grep -Eq '^[[:space:]]*\[[[:space:]]*mcp_servers[[:space:]]*\.[[:space:]]*"?ramble"?[[:space:]]*(\.|\])' "$cfg"; then say " $cfg still has a ramble table after codex mcp remove, so it is not in the shape this script expects; left as it was." manual "Run: codex mcp login ramble" return 0 fi if ( umask 077 { if [ "$had" = 1 ]; then cat "$cfg"; fi printf '\n[mcp_servers.ramble]\nurl = "%s"\n\n[mcp_servers.ramble.http_headers]\nAuthorization = "Bearer %s"\n' "$MCP" "$TOKEN"; } > "$cfg.ramble-tmp-$$" \ && mv "$cfg.ramble-tmp-$$" "$cfg" ) \ && bounded "$AGENT_TIMEOUT" codex mcp get ramble; then say " done: $cfg (mode 600)" else rm -f "$cfg.ramble-tmp-$$" if [ "$had" = 1 ]; then cp "$TMP/codex-config.toml" "$cfg" || true; else rm -f "$cfg"; fi say " Codex did not accept the tables, so $cfg was put back as it was. Run: codex mcp add ramble --url $MCP then codex mcp login ramble" fi } do_mcp() { if [ $D_CLAUDE = 1 ]; then say "Claude Code: add the MCP server"; claude_add; fi if [ $D_CURSOR = 1 ]; then say "Cursor: add the MCP server"; merge_cursor "$HOME/.cursor/mcp.json"; fi if [ $D_CODE = 1 ]; then # VS Code's "--add-mcp" is documented for a command or an address; whether it takes a header is not # confirmed, so VS Code keeps the sign-in-from-inside route in both modes and the script says so. say "VS Code (Copilot): add the MCP server (by address; you sign in from its MCP list)" if have code; then try code --add-mcp "{\"name\":\"ramble\",\"type\":\"http\",\"url\":\"$MCP\"}" else manual "code is not on PATH. Run: code --add-mcp '{\"name\":\"ramble\",\"type\":\"http\",\"url\":\"$MCP\"}'"; fi fi if [ $D_CODEX = 1 ]; then say "Codex: add the MCP server"; codex_add; fi if [ $D_GEMINI = 1 ]; then say "Gemini CLI: add the MCP server"; gemini_add; fi if [ $D_CLINE = 1 ]; then say "Cline: needs a personal token, so this script leaves it to you" manual "Sign in at $BASE/mine, open Connected agents, make a New agent token, and run the add command shown there in your own terminal." fi if [ $D_ZED = 1 ]; then say "Zed: its settings file may hold comments, so this script does not edit it" manual "Add this under context_servers in your Zed settings: {\"ramble\": {\"url\": \"$MCP\"}}" fi return 0 } do_skill() { if [ "$PASS" = plan ]; then say "Download $SKILL_URL (the only download) and copy it to:" while IFS= read -r d; do [ -n "$d" ] || continue say " $d/SKILL.md" done < process.exit(2); let d; try { if (fs.statSync(file).size > 4096) fail(); d = JSON.parse(fs.readFileSync(file, "utf8")); } catch (e) { fail(); } if (d === null || typeof d !== "object" || Array.isArray(d)) fail(); const only = (keys) => { for (const k of Object.keys(d)) if (!keys.includes(k)) fail(); }; const int = (v, lo, hi) => Number.isInteger(v) && v >= lo && v <= hi; if (kind === "start") { only(["device_code", "user_code", "verification_uri", "verification_uri_complete", "interval", "expires_in"]); if (typeof d.device_code !== "string" || !/^[A-Za-z0-9_-]{20,128}$/.test(d.device_code)) fail(); if (typeof d.user_code !== "string" || !/^[A-Z0-9]{4}-[A-Z0-9]{4}$/.test(d.user_code)) fail(); if (!int(d.interval, 1, 30) || !int(d.expires_in, 30, 1800)) fail(); console.log(d.device_code + " " + d.user_code + " " + d.interval + " " + d.expires_in); } else if (d.error !== undefined) { only(["error", "interval"]); if (typeof d.error !== "string" || !/^[a-z_]{1,40}$/.test(d.error)) fail(); if (d.interval !== undefined && !int(d.interval, 1, 120)) fail(); console.log("error " + d.error); } else { only(["access_token", "token_type", "scope", "expires_in"]); if (typeof d.access_token !== "string" || !/^rmb_[A-Za-z0-9_-]{20,128}$/.test(d.access_token)) fail(); if (d.token_type !== "Bearer") fail(); if (d.scope !== "rambles:read rambles:annotate rambles:request") fail(); if (!int(d.expires_in, 1, 400 * 86400)) fail(); console.log("token " + d.access_token); } ' "$1" "$2" elif have python3; then python3 -I -c ' import json, os, re, sys kind, path = sys.argv[1], sys.argv[2] def fail(): sys.exit(2) try: if os.path.getsize(path) > 4096: fail() d = json.load(open(path)) except Exception: fail() if not isinstance(d, dict): fail() def only(keys): for k in d: if k not in keys: fail() def isint(v, lo, hi): return isinstance(v, int) and not isinstance(v, bool) and lo <= v <= hi def matches(pattern, v): return isinstance(v, str) and re.fullmatch(pattern, v) is not None if kind == "start": only(["device_code", "user_code", "verification_uri", "verification_uri_complete", "interval", "expires_in"]) if not matches(r"[A-Za-z0-9_-]{20,128}", d.get("device_code")): fail() if not matches(r"[A-Z0-9]{4}-[A-Z0-9]{4}", d.get("user_code")): fail() if not isint(d.get("interval"), 1, 30) or not isint(d.get("expires_in"), 30, 1800): fail() print("%s %s %d %d" % (d["device_code"], d["user_code"], d["interval"], d["expires_in"])) elif "error" in d: only(["error", "interval"]) if not matches(r"[a-z_]{1,40}", d["error"]): fail() if "interval" in d and not isint(d["interval"], 1, 120): fail() print("error " + d["error"]) else: only(["access_token", "token_type", "scope", "expires_in"]) if not matches(r"rmb_[A-Za-z0-9_-]{20,128}", d.get("access_token")): fail() if d.get("token_type") != "Bearer": fail() if d.get("scope") != "rambles:read rambles:annotate rambles:request": fail() if not isint(d.get("expires_in"), 1, 400 * 86400): fail() print("token " + d["access_token"]) ' "$1" "$2" else return 5 fi } # http_post URL BODY OUTFILE: POST a JSON body, write the answer to OUTFILE, print the HTTP status. # The body goes in on stdin, so a device code never appears in the process list. http_post() { printf '%s' "$2" | curl -sS --max-time 20 --max-filesize 16384 -X POST -H 'Content-Type: application/json' \ --data-binary @- -o "$3" -w '%{http_code}' "$1" 2>/dev/null } host_name() { h="" if have hostname; then h=$(hostname 2>/dev/null | tr -cd 'A-Za-z0-9._-' | cut -c1-60) || h=""; fi printf '%s' "$h" } # open_browser URL: try to open it for the person; fail quietly when there is nobody to see it. open_browser() { if [ "$NO_BROWSER" = 1 ]; then return 1; fi if [ -n "${SSH_CONNECTION:-}${SSH_TTY:-}" ]; then return 1; fi if [ "$(uname -s 2>/dev/null || true)" = Darwin ]; then if have open; then open "$1" /dev/null 2>&1; return $?; fi return 1 fi if [ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ] && have xdg-open; then xdg-open "$1" /dev/null 2>&1; return $?; fi return 1 } # connect_device: ask for a code, send the person to the browser, wait for the click. Sets TOKEN. # Returns 0 with a token; 2 when this server has no one-step connect (the caller falls back to the # address-only install); anything else is a die, and by then nothing has been written. connect_device() { need_tmp body=$(printf '{"agents":"%s","host":"%s"}' "$AGENTS_CSV" "$(host_name)") code=$(http_post "$BASE/api/connect/device" "$body" "$TMP/start.json") || code=000 case $code in 200) ;; 404) return 2 ;; 429) die "too many attempts from this address; wait a few minutes and run it again" ;; 000) die "could not reach $BASE, or its answer was too large; nothing was written" ;; *) die "$BASE answered $code where a code was expected; nothing was written" ;; esac line=$(read_reply start "$TMP/start.json") || die "the answer from $BASE was not what this script accepts; nothing was written" read -r DEVICE_CODE USER_CODE INTERVAL EXPIRES <