Every coding agent, at a glance.

Claude Code and Codex, running on your Mac or your Linux machines, on one screen in your pocket. See what each one is doing, and answer the one it is waiting on.

Download CodeConnect Remote on the App Store Free for iPhone. Then one command on your computer.

Your tailnet

Your iPhone
On cellular · 100.88.14.62
4 waiting for you5 agents · 1 done · 1 running
Jordan’s Mac StudioLive
macOS · 100.101.7.12
  • app-1Bash git push --force origin mainClaude CodeHigh1m04s
  • app-2Edit …/Sources/Router.swiftClaude Code · +1 moreMedium4m04s
  • app-3Read …/README.mdClaude Code5m04s
  • app-4Command outputCodex · done3s
  • app-5Bash ./soak/run.sh --pass 8Claude Code · running2s
build-server-eu-west-1Offline
Linux · 100.72.40.9

Everything waiting on you, counted and ranked.

Projects is the app’s home screen. Every session from every paired computer is on it, and the ones stopped on you come first.

The app's Projects screen: 4 waiting for you, 5 agents, 1 done, 1 running. Three project cards on Jordan's Mac Studio, the first waiting on a high-risk git push --force, and a bar at the bottom counting 1 high, 2 medium and 1 low with a Review button.
  • 4 waiting for youHow many agents are stopped, waiting on you.
  • Jordan’s Mac StudioEach project names the computer it runs on.
  • Bash git push --forceWhat it is waiting on, how risky, and for how long.
  • 1 HIGH · 2 MEDIUM · 1 LOWThe risk of everything waiting, counted.
  • ReviewOpens everything waiting for you, riskiest first.

The whole command, and nothing decides but you.

When an agent needs a yes, your phone shows the exact command, never shortened or faded, with what it does and how risky it is. A read is one tap. A git push --force needs a deliberate hold and Face ID or your passcode.

Say no and the call does not run, and Claude is told you said no. Claude’s questions get the same treatment: you answer on the phone, and Claude gets the answer as if you had picked it at the keyboard.

Waiting for you, 3: an approval card for Bash: git push --force origin main in storefront-api, marked HIGH. It reads: Nothing decides this but you. There is no timer on this card. Under If you deny: This call does not run, and Claude is told you said no. Verified against the daemon's hash. Buttons: Deny, Hold to allow, Deny with a reason, Come back to this.
Settings, Machines: Jordan's Mac Studio, macOS, Live. build-server-eu-west-1, Linux, Offline. Add a machine: Run codeconnect pair in Terminal on that computer, then scan its code. Below, About: Privacy policy and Support.

One iPhone, several computers.

Pair the Mac on your desk and the Linux box that runs your builds. The phone stays connected to all of them, and Projects shows their sessions together. Settings lists each computer with its system and its link, and removes one without touching the others.

Each session reads as one conversation.

What you said, what the agent said and every tool it ran, in order, under the project and its folder.

  • Say somethingType or dictate a message.
  • ReviewRead the diff and leave a note on any hunk.
  • Take overOpen the live terminal and take over completely.
A session's timeline: the bar at the top shows app-7 and /Users/dev/app-7. Below, your message Run the tests and fix whatever fails, Claude's reply I'll run the suite first, Bash npm test, Claude's note that one test fails, an Edit to parser.ts, Bash npm test again, Claude's reply that all 42 tests pass and the parser now keeps the offset, your message Now run the linter, Bash npm run lint, Turn complete, Claude is waiting for your input, and the Ask anything message box with its voice button.

Nothing changes in how you work.

Start your agent with one extra word. It works as before, and the session also shows up on your phone.

  • Claude CodeApprovals, questions, diffs, the terminal, everything.
  • CodexApprovals with Codex’s own options, stop a turn, say something.
how you start a session+2 −2
@@ -1,2 +1,2 @@-claude-codex+codeconnect claude+codeconnect codex

Every agent. Within reach.

Your agents message each other across every machine and account. CodeConnect’s MCP tool does it over your tailnet, with no login beyond your Tailscale identity.

claude · macbook-pro14:02run the linux tests?thread linux-tests · 1 of 3codex · build-server14:0542 passed. review it?thread linux-tests · 2 of 3claude · mac-studio14:06looks good, merging.thread linux-tests · 3 of 3claude · mac-studio› looks good, merging.› looks good, merging.incoming · build-server›looks good, merging.claude · macbook-pro› run the linux tests?incoming · mac-studio›run the linux tests?codex · build-server› 42 passed. review it?› 42 passed. review it?incoming · macbook-pro›42 passed. review it?claude · macbook-pro14:02run the linux tests?thread linux-tests · 1 of 3codex · build-server14:0542 passed. review it?thread linux-tests · 2 of 3claude · mac-studio14:06looks good, merging.thread linux-tests · 3 of 3claude · mac-studio› looks good, merging.› looks good, merging.incoming · build-server›looks good, merging.claude · macbook-pro› run the linux tests?incoming · mac-studio›run the linux tests?codex · build-server› 42 passed. review it?› 42 passed. review it?incoming · macbook-pro›42 passed. review it?
TailscaleBuilt to run on your tailnet

Your computers, reachable only by you.

CodeConnect uses Tailscale by default. Your iPhone reaches each computer over your own tailnet, from cellular, a coffee shop or another city. Your sessions never pass through a CodeConnect server, and there is no CodeConnect account to create.

Encrypted end to end

Traffic between phone and computer rides WireGuard, with TLS on top when your tailnet has HTTPS certificates enabled.

Private by default

Out of the box, the daemon listens on your Tailscale address and loopback, nothing else. It is never exposed to the open internet.

Works from anywhere

If your phone is on your tailnet, it can reach your computers. No port forwarding, no router settings.

Tailscale is a registered trademark of Tailscale Inc. CodeConnect is not affiliated with Tailscale. Your local network also works, with a setting on the computer, but without Tailscale’s encryption or the live terminal.

Two steps.

  1. ON YOUR PHONE

    Get the app.

    Download CodeConnect Remote on the App Store

    Free. iOS 17 or later. To use Tailscale, the default, install it on your iPhone too and sign in to the same tailnet.

  2. ON YOUR COMPUTER

    Install CodeConnect.

    codeconnect.sh - sh
    $ curl -fsSL https://codeconnect.sh | sh

    Run it and answer yes. In one run it installs the signed release, walks you through Tailscale’s sign-in, starts CodeConnect, shows a code to scan from the app and signs you in to Claude Code or Codex, skipping anything already done.

    Requirements
    SystemSupportedHow it runs
    macOS13 or later, Apple silicon or IntelInstalls signed with CodeConnect’s Developer ID.
    Linuxx86_64 or arm64The daemon runs as a systemd user unit: it starts at boot and keeps running when you log out. Updates install only releases signed with CodeConnect’s Linux key.
    No systemdAlpine and most containersYou start the daemon by hand, with the one line the installer prints.
CODECONNECT_HTML_END # CodeConnect installer — https://codeconnect.sh # # Usage: # curl -fsSL https://codeconnect.sh | sh # # **Why this file starts with HTML.** It is one file with two readers: a # browser renders the page above; `sh` skips it, because the whole block sits # inside the quoted heredoc opened on line 1 — `:` is a no-op handed the HTML # on descriptor 3, which it never reads. The line starts with `` would close the page's early, leaving # the title and the link-preview tags in , where X does not look for # them. One URL, one set of bytes, no User-Agent sniffing — # there is no cache or spoofing path on which a browser gets shell or a shell # gets served something it wasn't shown. The sentinel line above is the only # other top-level statement this file is allowed; everything else stays # behind the function gate described below. A download truncated anywhere in # the HTML leaves the heredoc unterminated, and sh executes nothing at all. # # **Why every action lives inside a function.** # # `curl | sh` hands bytes to the shell as they arrive, so the shell can begin # executing line 1 while line 200 is still in flight. A dropped connection # halfway through an ordinary streaming script leaves the first half already # executed — and the first half is where the deleting happens. `set -e` cannot # help: the mutation already ran. # # So: this file defines functions and does nothing else, and the only # invocation is the literal last line. Truncate anywhere inside a function and # `sh` fails to parse the file at all, before running a single command. # Truncate after a function's closing brace and you get a defined function # nobody calls. Neither outcome touches the disk. # # The consequence for maintainers: **no top-level command may be added above # that final line.** Not an `echo`, not a variable assignment that shells out, # not a "quick" trap. The test suite enforces this. # # The other consequence: success must be *stated*, never inferred from silence. # A truncated run exits 0 having done nothing, so "no error" is not evidence of # an install. Look for the final summary block. CODECONNECT_INSTALLER_VERSION="1.7.7" CODECONNECT_REPO="faisalmumtaz89/CodeConnect" CODECONNECT_REPO_URL="https://github.com/faisalmumtaz89/CodeConnect.git" CODECONNECT_API="https://api.github.com/repos/faisalmumtaz89/CodeConnect/releases/latest" # The Apple Team ID whose signature is accepted on release binaries. # # Empty means the release path is not configured, and the installer refuses it # rather than falling back to an unverified install. Fail closed: an installer # that quietly stops checking signatures is worse than one that does not run. CODECONNECT_TEAM_ID="2XL2264MC8" # **Copied verbatim from `signature_requirement()` in # `mac/codeconnect/src/update_install.rs`, and it must stay that way.** The # installer and `codeconnect update` are the two doors into the same # directory; a machine that accepts a binary at install time which the updater # would later reject — or the reverse — is worse than either rule alone. # # Three clauses, and dropping any one makes it meaningless: # # * `anchor apple generic` — the chain ends at Apple. Without it the whole # expression is satisfiable by a certificate anybody can mint. # * the two OIDs — the issuing CA is Apple's *Developer ID* CA and the leaf # is a *Developer ID Application* certificate. Without them any Apple # certificate qualifies, including the free Apple Development certificate # every developer account can issue in a minute. # * `subject.OU` — that certificate belongs to *this* team. CODECONNECT_REQUIREMENT="anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] exists and certificate leaf[field.1.2.840.113635.100.6.1.13] exists and certificate leaf[subject.OU] = \"$CODECONNECT_TEAM_ID\"" # Fixed, and not a matter of taste — see the note in `install_release`. # The Linux release key, as the base64 of a PEM public key: the public half # CodeConnect's own updater pins (RELEASE_KEY in # mac/codeconnect/src/update_install.rs). A Linux binary carries no signature # of its own, so each release checksum file is signed with it instead, and a # Linux install is believed only through that. CODECONNECT_RELEASE_KEY="MCowBQYDK2VwAyEAosGlDpj+m0C3vUJs4/X/FWstbu5TIajXPAyvg2U29L0=" CODECONNECT_PREFIX="${CODECONNECT_HOME:-$HOME/.codeconnect}" # ------------------------------------------------------------------ output # # Everything goes to stderr. stdout is reserved so a caller can pipe this # script's *result* somewhere without status chatter contaminating it. cc_say() { printf '%s\n' "$*" >&2; } cc_step() { if [ -t 2 ]; then printf '\n\033[1m%s\033[0m\n' "$*" >&2 else printf '\n%s\n' "$*" >&2 fi } cc_warn() { printf 'warning: %s\n' "$*" >&2; } # Failure is loud, names what did and did not happen, and gives exactly one # next action. It never speculates about a cause it did not observe. cc_die() { printf '\nerror: %s\n' "$1" >&2 shift for line in "$@"; do printf '%s\n' "$line" >&2; done exit 1 } cc_usage() { cat >&2 <<'USAGE' CodeConnect installer curl -fsSL https://codeconnect.sh | sh Options: --reinstall Replace an existing install. Normally a run on an existing install downloads nothing and only finishes its setup; moving it to a newer release is `codeconnect update`'s job. --source On macOS, build from a source checkout instead of installing a release. Requires a Rust toolchain and takes several minutes. --path-yes Add ~/.codeconnect/bin to your PATH without asking. --path-no Leave your PATH alone without asking. --version Print the installer version and exit. --help Print this message and exit. Installs to ~/.codeconnect/bin, with CodeConnect's own tmux. Asks before adding a marked block to your shell's startup file so `codeconnect` is on your PATH (deleting that block undoes it), then once before the setup, which does only what is still missing: connects Tailscale (on Linux with `sudo tailscale up --qr`), starts the daemon now and at login (on Linux, at boot, with `sudo loginctl enable-linger` when systemd refuses it), shows the code to pair your iPhone, and signs in Claude Code and Codex with their own commands. sudo is used for nothing else. With no terminal to ask on, it enables nothing. Run again, it skips the download and picks up where the setup stopped. Audit before running: curl -fsSL https://codeconnect.sh -o codeconnect-install.sh less codeconnect-install.sh sh codeconnect-install.sh USAGE } # ------------------------------------------------------------------- tools # Children never inherit the pipe. stdin *is* this script when invoked as # `curl | sh`, so any child that reads stdin would eat the installer's own # remaining bytes — and `git` and `cargo` both read stdin under some # conditions. cc_run() { "$@" /dev/null 2>&1; } # A file's size and permission bits. macOS's own `stat`, by path: a GNU one # first on PATH reads `-f` differently. Linux's, GNU or busybox, reads `-c`. cc_is_linux() { [ "$(uname -s)" = "Linux" ]; } # What this machine is called, and when a started daemon starts again: at # login under launchd, at boot under systemd with linger. cc_machine() { if cc_is_linux; then echo "computer"; else echo "Mac"; fi } cc_daemon_when() { if cc_is_linux; then echo "at boot"; else echo "at login"; fi } cc_file_size() { if cc_is_linux; then stat -c %s "$1" else /usr/bin/stat -f %z "$1" fi } cc_file_mode() { if cc_is_linux; then stat -c %a "$1" else /usr/bin/stat -f %Lp "$1" fi } # `-q` first and always: without it curl reads ~/.curlrc, which can add URLs, # attach headers or redirect output. # # `--proto '=https'` governs the *initial* request and `--proto-redir '=https'` # governs every hop after it — without the second, a redirect can walk the # download down to plaintext, which is exactly the hop worth attacking. # `--max-filesize` bounds a response that lies about its size. cc_fetch() { cc_run curl -q --proto '=https' --proto-redir '=https' --tlsv1.2 \ -fsSL --max-time 300 --max-filesize "$3" "$1" -o "$2" } cc_fetch_stdout() { cc_run curl -q --proto '=https' --tlsv1.2 -fsSL --max-time 30 --max-filesize 262144 "$1" } # One string field out of a JSON object, without requiring jq. Deliberately # narrow: it matches `"key": "value"` and nothing cleverer, because the only # document it reads is a GitHub release object and a parser that guesses is a # parser that lies. cc_json_string() { sed -n 's/.*"'"$2"'"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$1" | head -1 } # --------------------------------------------------------------- preflight # Collected in one pass rather than one-at-a-time: a user missing three tools # should learn that once, not across three failed runs. # Built with a command substitution rather than an embedded literal newline: # a definition that spans lines is one a truncated download can split in half. cc_missing="" cc_need() { cc_missing="$(printf '%s\n - %s' "$cc_missing" "$1")"; } # Written as a full `if` rather than `[ ... ] && return 0`. Under `set -e` an # AND-OR list whose result is non-zero aborts the shell, so the short form # would exit silently on the branch that most needs to print something. cc_preflight_common() { if [ "$(id -u)" = "0" ]; then cc_die "do not run this installer as root." \ "" \ "CodeConnect installs into your own home directory and needs no" \ "elevated privileges. Run it as your own user, without sudo. With no" \ "user but root yet, install and connect Tailscale as root first" \ "(curl -fsSL https://tailscale.com/install.sh | sh, then tailscale up --qr)," \ "then create a user, switch to it and run the install line again:" \ " useradd -m -s /bin/bash dev && su - dev" fi } cc_preflight_platform() { os="$(uname -s)" arch="$(uname -m)" case "$os:$arch" in Darwin:arm64 | Darwin:x86_64 | Linux:x86_64 | Linux:aarch64) ;; Darwin:* | Linux:*) cc_die "unrecognised architecture '$arch'." \ "" \ "CodeConnect ships for arm64 and x86_64 (aarch64 and x86_64 on Linux). Nothing was installed." ;; *) cc_die "CodeConnect runs on macOS and Linux; this machine reports '$os'." \ "" \ "Nothing was installed." ;; esac if [ -z "${HOME:-}" ] || [ ! -d "$HOME" ]; then cc_die "\$HOME is not set to a directory that exists." \ "" \ "Nothing was installed." fi } # A release installs CodeConnect's own tmux; a source build does not, and # runs on whatever tmux the Mac has. tmux is a *runtime* dependency, not a # build one: the daemon installs fine without it and then cannot host a single # session. So this warns rather than refuses — refusing would overreach on an # install that is genuinely valid, and staying silent would let the user # discover it at the worst moment. cc_check_tmux() { if cc_have tmux; then return 0 fi cc_warn "tmux is not installed." cc_say " CodeConnect hosts every session in a private tmux server, so" cc_say " \`codeconnect claude\` will not start one until tmux exists." if cc_have brew; then cc_say " Install it with: brew install tmux" else cc_say " Install it via Homebrew (https://brew.sh) or your package manager." fi } # ---------------------------------------------------------------- staging # Each run downloads into a directory of its own, so two installers never # share one. What they install is serialized by `codeconnect install-staged`, # which holds the same kernel lock as `codeconnect update` — released by the # kernel however its holder dies, so there is never a stale one to clear. cc_stage_dir="" # A startup file's new contents while they are being written beside it. cc_path_new="" # Full `if`s, not `[ … ] && …` chains. `set -e` is in force inside a trap # handler too, so a chain whose first test is false aborts the handler — which # would have skipped the removal below on every early exit. cc_cleanup() { if [ -n "$cc_stage_dir" ] && [ -d "$cc_stage_dir" ]; then rm -rf "$cc_stage_dir" fi if [ -n "$cc_path_new" ]; then rm -f "$cc_path_new" fi return 0 } # Installers before 1.5.0 locked with a `.install-lock` directory. Nothing # reads it any more, and it is left where it is: one may belong to an older # installer still running, which it alone keeps from a second one like it. cc_begin_staging() { # A signal ends the run — through the EXIT trap, which cleans up. A trap # that only cleaned up would let the shell carry on after Ctrl-C. trap cc_cleanup EXIT trap 'exit 129' HUP trap 'exit 130' INT trap 'exit 143' TERM cc_stage_dir="$(mktemp -d "$CODECONNECT_PREFIX/.install-stage.XXXXXX")" } cc_prepare_prefix() { if [ -e "$CODECONNECT_PREFIX" ] && [ ! -d "$CODECONNECT_PREFIX" ]; then cc_die "$CODECONNECT_PREFIX exists and is not a directory." \ "" \ "Nothing was changed." fi if [ -L "$CODECONNECT_PREFIX" ]; then cc_die "$CODECONNECT_PREFIX is a symlink." \ "" \ "This installer will not write through a symlinked prefix." \ "Nothing was changed." fi mkdir -p "$CODECONNECT_PREFIX" } # ------------------------------------------------------- existing installs # An existing install is not replaced here: it is set up the rest of the way. # # This installer is a bootstrap. `codeconnect update` is the product's own # updater: it compares versions across all three binaries, refuses a # downgrade, exchanges the whole directory in one step so a power cut cannot # leave a mixed set, and restarts a managed daemon. Reimplementing a worse # version of that on the upgrade path would be the wrong trade. So a run on an # existing install downloads nothing and asks only for the setup still missing # — which is what someone who stopped at a question and ran the same command # again needs. # # `--reinstall` is the escape hatch for a set that is broken badly enough that # its own updater cannot run. On an existing install, `--path-yes` and # `--path-no` answer the PATH question — `--path-yes` is the way past a # remembered "no" — and the rest of the setup carries on as on any rerun. # Taking a block out is deleting it. cc_resume="no" cc_guard_existing() { if [ ! -x "$CODECONNECT_PREFIX/bin/codeconnect" ]; then return 0 fi if [ "$cc_reinstall" = "yes" ]; then return 0 fi cc_resume="yes" } # -------------------------------------------------------- release resolve cc_resolve_release() { cc_step "Resolving the latest CodeConnect release…" body="$cc_stage_dir/release.json" if ! cc_fetch_stdout "$CODECONNECT_API" >"$body" 2>/dev/null; then cc_die "could not reach the GitHub Releases API." \ "" \ "This means one of: no network, GitHub is unreachable, the API" \ "rate-limited this IP, or the repository is not public. The" \ "request does not distinguish them, so this message will not" \ "guess." \ "" \ "Nothing was installed." fi cc_tag="$(cc_json_string "$body" tag_name)" if [ -z "$cc_tag" ]; then cc_die "the Releases API returned no tag for a latest release." \ "" \ "CodeConnect may not have published one yet." \ "Nothing was installed." fi # `v0.4.0` -> `0.4.0`. The tag is the contract; the bare version is what # asset names are built from. cc_version="${cc_tag#v}" case "$cc_version" in *[!0-9.]* | "") cc_die "the latest release tag '$cc_tag' is not a plain version." \ "" \ "Nothing was installed." ;; esac cc_say " latest release: $cc_tag" } # --------------------------------------------------------- release install # Runs before anything is created, so that "nothing was changed" is literally # true rather than true-about-binaries. An empty directory left behind by a # refusal is a small lie, and this codebase does not get to tell small ones. cc_preflight_release() { if cc_is_linux; then cc_preflight_linux_release return 0 fi if [ -z "$CODECONNECT_TEAM_ID" ]; then cc_die "this installer has no signing identity configured." \ "" \ "Release installs verify an Apple Developer ID signature, and" \ "the Team ID to check against is not set in this build of the" \ "installer. It will not install an unverified binary." \ "" \ "Build from source instead, if you have a Rust toolchain:" \ "" \ " curl -fsSL https://codeconnect.sh | sh -s -- --source" \ "" \ "Nothing was changed." fi } # What a Linux release install needs before anything is written: the tools it # runs, and an openssl that can check an Ed25519 signature at all — OpenSSL 3 # or later. Told apart here, so a machine that cannot check is never told the # release is forged. cc_preflight_linux_release() { cc_missing="" cc_have curl || cc_need "curl" cc_have tar || cc_need "tar" cc_have sha256sum || cc_need "sha256sum (coreutils)" cc_have openssl || cc_need "openssl, version 3 or later" if [ -n "$cc_missing" ]; then cc_die "installing CodeConnect needs tools this computer does not have:$cc_missing" \ "" \ "Install them with your package manager — for example" \ " sudo apt install curl openssl (Debian, Ubuntu)" \ " sudo dnf install curl openssl (Fedora)" \ " sudo apk add curl openssl (Alpine)" \ "then run the installer again. Nothing was changed." fi openssl_version="$(cc_run openssl version 2>/dev/null || true)" case "$openssl_version" in "OpenSSL "[3-9].* | "OpenSSL "[1-9][0-9].*) ;; *) cc_die "this computer's openssl cannot check CodeConnect's release signature." \ "" \ "It reports '$openssl_version'; checking an Ed25519 signature needs" \ "OpenSSL 3 or later. Install it with your package manager and run the" \ "installer again. Nothing was changed." ;; esac } cc_install_release() { cc_resolve_release if cc_is_linux; then cc_install_linux_release return 0 fi archive="codeconnect-${cc_version}-macos-universal.tar.gz" base="https://github.com/${CODECONNECT_REPO}/releases/download/${cc_tag}" cc_step "Downloading ${archive}…" cc_fetch "$base/$archive.sha256" "$cc_stage_dir/$archive.sha256" 4096 || cc_die "could not download the checksum for $cc_tag." "" "Nothing was installed." cc_fetch "$base/$archive" "$cc_stage_dir/$archive" 104857600 || cc_die "could not download $archive." "" "Nothing was installed." # Integrity, not authenticity. Checksum and archive share an origin, so # this catches a truncated or corrupted download and nothing more. The # signature check below is the one that survives a hostile origin. cc_step "Verifying the download…" (cd "$cc_stage_dir" && cc_run shasum -a 256 -c "$archive.sha256" >/dev/null 2>&1) || cc_die "$archive failed its checksum." \ "" \ "The download is corrupt or was tampered with in transit." \ "Nothing was installed." cc_say " checksum ok" cc_run tar -xzf "$cc_stage_dir/$archive" -C "$cc_stage_dir" || cc_die "could not unpack $archive." "" "Nothing was installed." unpacked="$cc_stage_dir/codeconnect-${cc_version}" [ -d "$unpacked" ] || cc_die "$archive did not contain codeconnect-${cc_version}/." "" "Nothing was installed." # **The check that matters.** A bare `codesign --verify` only proves a # signature is internally intact, which an attacker's own signature also # satisfies. The pinned requirement is what makes this a statement about # *who* signed it. # # `--all-architectures` is not optional here. A universal binary carries # one signature per slice, and codesign otherwise checks only the slice # this Mac would run: the x86_64 half of a universal file could be # replaced wholesale and an arm64 Mac would never look at it. # # There is no `--require-timestamp` verify flag — passing one makes # codesign exit on a usage error, which a `|| cc_die` then reports as a # bad signature. The release workflow checks the timestamp separately, by # reading `codesign --display` output, and that is the right place for it. # # `-R` rather than `spctl`: spctl assesses against system policy, whose # behaviour on a non-quarantined bare Mach-O depends on state this script # does not control, and which needs the network. An explicit requirement # is deterministic and offline. cc_step "Verifying the signature…" for binary in codeconnect ccd cc-hook; do cc_run codesign --verify --strict --all-architectures \ -R="$CODECONNECT_REQUIREMENT" \ "$unpacked/$binary" >/dev/null 2>&1 || cc_die "$binary is not signed by CodeConnect (team $CODECONNECT_TEAM_ID)." \ "" \ "Refusing to install it. Nothing was installed." done cc_say " Developer ID signature verified ($CODECONNECT_TEAM_ID)" # **The verified release installs itself.** Its own `codeconnect` fetches # and proves the release's tmux, then puts the binaries and tmux in place # as one set with the same exchange, smoke test, rollback and daemon # restart `codeconnect update` uses — so the two doors into this # directory are one implementation, not two that must agree. The prefix # is passed explicitly: it is the directory this run prepared. cc_step "Installing to ${CODECONNECT_PREFIX}/bin…" cc_run env CODECONNECT_HOME="$CODECONNECT_PREFIX" \ "$unpacked/codeconnect" install-staged "$unpacked" >&2 || cc_die "the release did not finish installing." \ "" \ "Read the error above: it says what is installed now." cc_installed_from="release $cc_tag" } # The same install on Linux, with the release key in place of codesign: its # signature over the exact checksum bytes is checked before the checksum is # believed, the archive unpacked, or anything in it run. cc_install_linux_release() { archive="codeconnect-${cc_version}-linux-${arch}.tar.gz" base="https://github.com/${CODECONNECT_REPO}/releases/download/${cc_tag}" cc_step "Downloading ${archive}…" cc_fetch "$base/$archive.sha256" "$cc_stage_dir/$archive.sha256" 4096 || cc_die "could not download $archive.sha256 for $cc_tag." \ "" \ "A release from before CodeConnect ran on Linux has no Linux build." \ "Nothing was installed." cc_fetch "$base/$archive.sha256.sig" "$cc_stage_dir/$archive.sha256.sig" 64 || cc_die "could not download the signature for $cc_tag." "" "Nothing was installed." cc_fetch "$base/$archive" "$cc_stage_dir/$archive" 104857600 || cc_die "could not download $archive." "" "Nothing was installed." cc_step "Verifying the signature…" printf -- '-----BEGIN PUBLIC KEY-----\n%s\n-----END PUBLIC KEY-----\n' \ "$CODECONNECT_RELEASE_KEY" >"$cc_stage_dir/release-key.pem" if ! said="$(cc_run openssl pkeyutl -verify -rawin -pubin \ -inkey "$cc_stage_dir/release-key.pem" \ -in "$cc_stage_dir/$archive.sha256" \ -sigfile "$cc_stage_dir/$archive.sha256.sig" 2>&1)"; then cc_die "$archive is not signed by CodeConnect's release key." \ "" \ "openssl said: $said" \ "Refusing to install it. Nothing was installed." fi cc_say " release signature verified" # Now that the checksum is CodeConnect's, the archive is held to it. (cd "$cc_stage_dir" && cc_run sha256sum -c "$archive.sha256" >/dev/null 2>&1) || cc_die "$archive failed its checksum." \ "" \ "The download is corrupt or was tampered with in transit." \ "Nothing was installed." cc_say " checksum ok" cc_run tar -xzf "$cc_stage_dir/$archive" -C "$cc_stage_dir" || cc_die "could not unpack $archive." "" "Nothing was installed." unpacked="$cc_stage_dir/codeconnect-${cc_version}" [ -d "$unpacked" ] || cc_die "$archive did not contain codeconnect-${cc_version}/." "" "Nothing was installed." # The release's own `codeconnect` installs it: on Linux it fetches the # release's archives again and proves each by the same key before # anything is put in place. cc_step "Installing to ${CODECONNECT_PREFIX}/bin…" cc_run env CODECONNECT_HOME="$CODECONNECT_PREFIX" \ "$unpacked/codeconnect" install-staged "$unpacked" >&2 || cc_die "the release did not finish installing." \ "" \ "Read the error above: it says what is installed now." cc_installed_from="release $cc_tag" } # ---------------------------------------------------------- source install cc_install_source() { cc_step "Checking build prerequisites…" cc_missing="" cc_have git || cc_need "git" cc_have cargo || cc_need "a Rust toolchain (https://rustup.rs)" cc_run xcode-select -p >/dev/null 2>&1 || cc_need "Xcode command line tools (xcode-select --install)" if [ -n "$cc_missing" ]; then cc_die "building from source needs tools this Mac does not have:" \ "$cc_missing" \ "" \ "Install them and rerun. Nothing was installed." fi cc_say " git, cargo and the command line tools are present" src="$CODECONNECT_PREFIX/src" if [ -e "$src" ]; then cc_die "$src already exists." \ "" \ "This installer will not build over a directory it did not" \ "create. Inspect it, move it aside, and rerun." \ "" \ "Nothing was changed." fi cc_step "Cloning ${CODECONNECT_REPO}…" GIT_TERMINAL_PROMPT=0 cc_run git -c credential.helper= \ clone --branch main "$CODECONNECT_REPO_URL" "$cc_stage_dir/src" || cc_die "could not clone $CODECONNECT_REPO." \ "" \ "Either the network is unavailable or the repository is not" \ "public. This message will not guess which." \ "" \ "Nothing was installed." # The clone moves out of staging; the (now empty) staging directory stays # registered so the exit trap still removes it. mv "$cc_stage_dir/src" "$src" cc_step "Building — this takes a few minutes…" # The repository's own installer does the build, the ad-hoc signing, the # inode replacement and the `source-checkout` record. Reimplementing any # of that here would be a second copy of logic that has already been paid # for in bugs. cc_run /bin/bash "$src/mac/install.sh" || cc_die "the CodeConnect installer did not complete." \ "" \ "The checkout is at $src and some binaries may have been" \ "replaced. Read the error above, fix it, and rerun:" \ "" \ " cd $src && ./mac/install.sh" cc_installed_from="source ($src)" } # -------------------------------------------------------------------- PATH # A child process cannot change the PATH of the shell that ran it, so a new # terminal finds `codeconnect` only if the shell's startup file puts it there. # This asks once, then adds one block between markers that are the # installer's only claim on the file: the block is found, replaced and — by # deleting it — undone by those markers alone, and nothing outside them is # ever touched. cc_path_begin="# >>> codeconnect >>>" cc_path_end="# <<< codeconnect <<<" cc_path_choice="ask" # Whether new terminals will find codeconnect: the summary may promise that # only when the PATH step made it true or found it so. cc_path_set="no" # The startup files a new terminal of the user's login shell reads. bash # reads its login file in a login shell and `.bashrc` otherwise, and many # login files source `.bashrc` as well, so both get the block and the block # itself adds the directory only when it is missing. An unknown shell gets # none, and the instruction instead. cc_path_files() { case "${SHELL##*/}" in zsh) printf '%s\n' "${ZDOTDIR:-$HOME}/.zshrc" ;; bash) login="$HOME/.bash_profile" for candidate in "$HOME/.bash_profile" "$HOME/.bash_login" "$HOME/.profile"; do if [ -e "$candidate" ]; then login="$candidate" break fi done printf '%s\n%s\n' "$login" "$HOME/.bashrc" ;; fish) printf '%s\n' "${XDG_CONFIG_HOME:-$HOME/.config}/fish/conf.d/codeconnect.fish" ;; esac } # The directory as one quoted word: sh single quotes take nothing literally # but the quote itself; fish single quotes also take a backslash. cc_sh_word() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; } cc_fish_word() { printf "'%s'" "$(printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e "s/'/\\\\'/g")"; } # The block for one file, markers included. cc_path_block() { case "$1" in *.fish) line="if not contains -- $(cc_fish_word "$2") \$PATH; set -gx PATH $(cc_fish_word "$2") \$PATH; end" ;; *) line="case \":\$PATH:\" in *:$(cc_sh_word "$2"):*) ;; *) PATH=$(cc_sh_word "$2"):\"\$PATH\"; export PATH ;; esac" ;; esac printf '%s\n%s\n%s\n' "$cc_path_begin" "$line" "$cc_path_end" } # What to type, for a user who keeps their own startup files. cc_path_instruction() { case "${SHELL##*/}" in fish) printf " set -gx PATH %s %s\n" "$(cc_fish_word "$1")" "\$PATH" ;; *) printf " export PATH=%s:\"%s\"\n" "$(cc_sh_word "$1")" "\$PATH" ;; esac } # The state of a file's markers: `none`, `one` (exactly one block, opened # before it closes), or `broken` (anything else). cc_path_markers() { if [ ! -f "$1" ]; then printf 'none\n' return 0 fi # Byte by byte (`LC_ALL=C`) here and below: a startup file is not # promised to be valid text in the user's locale, and a tool that stops at # a byte it cannot decode would report our block as missing. # `-a`: a NUL byte anywhere would otherwise turn the answer into "Binary # file matches". lines="$(LC_ALL=C grep -anxF -e "$cc_path_begin" -e "$cc_path_end" "$1" || true)" if [ -z "$lines" ]; then printf 'none\n' elif [ "$(printf '%s\n' "$lines" | LC_ALL=C sed 's/^[0-9]*://' | tr '\n' '|')" = "$cc_path_begin|$cc_path_end|" ]; then printf 'one\n' else printf 'broken\n' fi } # Whether a file already holds exactly this block. A block for another # directory, or edited by hand, is not the block, and is replaced. cc_path_present() { if [ "$(cc_path_markers "$1")" != "one" ]; then return 1 fi held="$(LC_ALL=C sed -n "/^$cc_path_begin\$/,/^$cc_path_end\$/p" "$1")" [ "$held" = "$(cc_path_block "$1" "$2")" ] } # The file a path finally names, through any chain of symlinks — the file # whose directory the new copy is written beside, and whose mode it keeps. # A chain longer than any real one is a loop, and nothing is written into it. cc_path_target() { target="$1" hops=0 while [ -L "$target" ]; do hops=$((hops + 1)) if [ "$hops" -gt 32 ]; then return 1 fi # Read whole: `$( )` drops trailing newlines, and a link whose # target ends in one would otherwise lead to a different file. `-n`, # because without it macOS's readlink adds a newline only sometimes. link="$(readlink -n "$target" && printf x)" || return 1 link="${link%x}" case "$link" in *"$newline"*) return 1 ;; esac case "$link" in /*) target="$link" ;; *) target="$(dirname "$target")/$link" ;; esac done printf '%s\n' "$target" } # Put the block in one file. A file without one gets it appended, so the file # keeps everything it had — its other links, owner, ACLs and attributes — and # an append that fails part way is cut back to the file's old length, so a # full disk never leaves half a block for every new shell to trip on. A file holding # an old block has it replaced where it stands: the new contents are written # to a file created beside it and renamed over it in one step, keeping its # mode; that rename makes it a new file, which is why only a replacement does # it. A file that is not writable, or whose markers do not pair up, is left # alone: the first was made read-only on purpose, and what lies between # unpaired markers cannot be known to be ours. cc_path_write() { file="$1" # Again here, not only before the question: two startup files can be one # file, and the block written through the first is already in the second. if cc_path_present "$file" "$2"; then return 0 fi block="$(cc_path_block "$file" "$2")" markers="$(cc_path_markers "$file")" if [ "$markers" = "broken" ]; then cc_warn "$file has CodeConnect markers that do not pair up; it was left alone." return 1 fi target="$(cc_path_target "$file")" || { cc_warn "$file is a symlink that leads nowhere; it was left alone." return 1 } if [ -e "$target" ] && { [ ! -f "$target" ] || [ ! -w "$target" ]; }; then cc_warn "$file is not a file this installer can write; it was left alone." return 1 fi mkdir -p "$(dirname "$target")" || return 1 if [ "$markers" = "none" ]; then # On a line of its own: the file's last line may have no newline. separator="" length=0 if [ -e "$target" ]; then length="$(cc_file_size "$target")" || return 1 fi # Counted, not captured: a command substitution drops a newline and # a NUL alike. if [ "$length" -gt 0 ] && [ "$(tail -c 1 "$target" | wc -l)" -eq 0 ]; then separator="$newline" fi # In a subshell writing only to the file: bash keeps whatever a # failed write could not write and would hand it to the next command # substitution, so it must go with the subshell. if (printf '%s%s\n' "$separator" "$block") >>"$target"; then return 0 fi # `dd` with a seek and nothing to copy cuts the file to that length. dd if=/dev/null of="$target" bs=1 seek="$length" 2>/dev/null return 1 fi cc_path_new="$(mktemp "$target.codeconnect.XXXXXX")" || return 1 # Through the environment, not `awk -v`, which would read the backslashes # a quoted directory can hold as escapes. if CC_BEGIN="$cc_path_begin" CC_END="$cc_path_end" CC_BLOCK="$block" LC_ALL=C awk ' $0 == ENVIRON["CC_BEGIN"] { print ENVIRON["CC_BLOCK"]; skipping = 1; next } skipping { if ($0 == ENVIRON["CC_END"]) skipping = 0; next } { print } ' "$target" >"$cc_path_new" && chmod "$(cc_file_mode "$target")" "$cc_path_new" && mv -f "$cc_path_new" "$target"; then cc_path_new="" return 0 fi rm -f "$cc_path_new" cc_path_new="" return 1 } # A "no" is remembered beside the install, so the next run does not ask again. cc_path_declined() { grep -qx 'path=no' "$CODECONNECT_PREFIX/installer-state" 2>/dev/null } cc_path_remember() { if [ "$1" = "no" ]; then printf 'path=no\n' >"$CODECONNECT_PREFIX/installer-state" else rm -f "$CODECONNECT_PREFIX/installer-state" fi } cc_offer_path() { bin="$CODECONNECT_PREFIX/bin" # PATH splits at every colon, whatever the quoting. case "$bin" in *:*) cc_say "" cc_say "$bin cannot go on PATH: PATH splits it at its colon." cc_say "Run $bin/codeconnect by its full path, or install to a directory without a colon." return 0 ;; esac # One file per line, and only newlines split them: a home directory may # hold spaces. A directory holding a newline — the install's, or one a # startup file is found under — cannot be carried that way or written as # a one-line block, so it gets the instruction instead. newline=' ' files="$(cc_path_files)" case "$bin$HOME${ZDOTDIR:-}${XDG_CONFIG_HOME:-}" in *"$newline"*) files="" ;; esac if [ -z "$files" ]; then cc_say "" cc_say "To find codeconnect, run this here, and add it to your shell's startup file for new terminals:" cc_path_instruction "$bin" >&2 return 0 fi # Split on newlines alone, and never expanded as a pattern: a startup # file's directory may hold spaces or a `*`. saved_ifs="$IFS" IFS="$newline" set -f missing="" for file in $files; do if ! cc_path_present "$file" "$bin"; then missing="$missing$file$newline" fi done set +f IFS="$saved_ifs" if [ -z "$missing" ]; then cc_path_set="yes" cc_say "" cc_say "New terminals already find codeconnect." return 0 fi shown="$(printf '%s' "$missing" | tr '\n' ' ')" answer="$cc_path_choice" if [ "$answer" = "ask" ]; then if cc_path_declined; then answer="declined" # A subshell: dash and busybox ash end the whole script when a # redirection on `:` fails. elif ! (: /dev/null; then answer="no-terminal" else printf '\nAdd %s to your PATH? This adds a marked block to %s[Y/n] ' \ "$bin" "$shown" >/dev/tty if read -r reply &2 return 0 fi if [ "$answer" = "no" ]; then cc_path_remember no fi cc_say "" cc_say "Your PATH was left alone. To find codeconnect, run this here, and add it to ${shown% } for new terminals:" cc_path_instruction "$bin" >&2 if [ "$answer" = "declined" ] || [ "$answer" = "no" ]; then cc_say "Or rerun the installer with --path-yes:" cc_say "" cc_say " curl -fsSL https://codeconnect.sh | sh -s -- --path-yes" fi } # ---------------------------------------------------------------- summary # `codeconnect` as the next steps may name it: the bare word once new terminals # find it — the PATH step put it in their startup files — and the full path # otherwise: the PATH answered no, a shell with no startup file, a directory # with a colon. This terminal's own PATH says nothing about new ones. cc_command() { if [ "$cc_path_set" = "yes" ]; then echo "codeconnect" else echo "$CODECONNECT_PREFIX/bin/codeconnect" fi } # Whether this terminal finds the command as named: always for a full path, and # for the bare word only once its PATH has the directory — a child process # cannot change its parent's environment, so the PATH step did not. cc_runs_here() { if [ "$1" != "codeconnect" ]; then return 0 fi case ":$PATH:" in *":$CODECONNECT_PREFIX/bin:"*) return 0 ;; esac return 1 } cc_summary() { bin="$CODECONNECT_PREFIX/bin" version="$(cc_run "$bin/codeconnect" --version 2>/dev/null || echo 'unknown')" cc_step "CodeConnect is installed." cc_say " $version" cc_say " from: $cc_installed_from" cc_say " at: $bin" if [ "$cc_resume" = "yes" ]; then cc="$(cc_command)" if cc_runs_here "$cc"; then cc_say " \`$cc update\` moves it to the latest release." else cc_say " \`$cc update\`, in a new terminal, moves it to the latest release." fi fi } # ------------------------------------------------------------------ setup # A yes or no from the person at the terminal, asked there and never on stdin, # which under `curl | sh` is this script. Return means yes. With no terminal # the answer is "no-terminal", and nothing is enabled on anybody's behalf. cc_ask() { # A subshell: dash and busybox ash end the whole script when a redirection # on `:` fails. if ! (: /dev/null; then echo "no-terminal" return 0 fi printf '\n%s [Y/n] ' "$1" >/dev/tty if read -r reply /dev/null 2>&1 return fi case "$(systemctl --user show -p ActiveState --value com.codeconnect.ccd.service 2>/dev/null)" in active | activating | reloading) return 0 ;; esac return 1 } # Linux with no systemd user manager (a container, a cloud sandbox): nothing # here can run the daemon as a service, so it is started by hand, with the same # line `codeconnect daemon install` gives there. cc_no_systemd() { cc_is_linux && ! systemctl --user show-environment >/dev/null 2>&1 } cc_hand_run_line() { echo "mkdir -p $CODECONNECT_PREFIX/logs && nohup $CODECONNECT_PREFIX/bin/ccd >>$CODECONNECT_PREFIX/logs/ccd.out.log 2>>$CODECONNECT_PREFIX/logs/ccd.err.log 1 && $(NF - 1) == "active" { found = 1 } END { exit !found }'; then echo "paired" else echo "unpaired" fi } # Whether a daemon is up: one launchd or systemd holds, or, with no systemd, # one that answers — started by hand, so only it can say. cc_daemon_up() { if cc_no_systemd; then [ "$(cc_pairing)" != "unknown" ] return fi cc_daemon_loaded } # Waits on the terminal for Enter. Ctrl-C ends the run like any other. cc_wait_enter() { printf '\n%s ' "$1" >/dev/tty read -r _ /dev/null | sed -n 's/.*"'"$2"'"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1 } cc_tailscale_running() { ts="$(cc_tailscale_bin)" && [ "$(cc_tailscale_field "$ts" BackendState)" = "Running" ] } cc_wait_tailscale() { cc_say "" cc_say "Waiting for Tailscale to connect. Ctrl-C stops here; running the" cc_say "installer again picks up where it stopped." while [ "$(cc_tailscale_field "$1" BackendState)" != "Running" ]; do sleep 2 done cc_say "Tailscale is connected." } # First, because the daemon listens on the address Tailscale gives this # computer. On Linux Tailscale's own login runs here — its link and its QR, for # a phone signed in to Tailscale — and returns once that is approved; where # sudo cannot run it, or on a Mac, where the menu bar app signs in, this says # what to do and waits until Tailscale reports itself connected. cc_tailscale() { if ! ts="$(cc_tailscale_bin)"; then cc_step "Install Tailscale, so your phone can reach this $(cc_machine):" if cc_is_linux; then cc_say " curl -fsSL https://tailscale.com/install.sh | sh" else cc_say " https://tailscale.com/download" fi cc_wait_enter "Press Enter once it is installed." if ! ts="$(cc_tailscale_bin)"; then cc_warn "Tailscale is still not installed, so the daemon and your iPhone were not set up." cc_say " Install it, then run this installer again; it picks up here." return 1 fi fi state="$(cc_tailscale_field "$ts" BackendState)" if [ "$state" = "Running" ]; then return 0 fi cc_step "Connect Tailscale, so your phone can reach this $(cc_machine)." # shellcheck disable=SC2024 # sudo's redirections are the user's terminal, on purpose if ! cc_is_linux; then cc_run open -a Tailscale 2>/dev/null || true cc_say "Log in from Tailscale's icon in the menu bar (or choose Connect there)." url="$(cc_tailscale_field "$ts" AuthURL)" if [ -n "$url" ]; then cc_say "Or open: $url" fi elif [ -z "$state" ]; then cc_say "Tailscale is installed but its service is not running. In a root shell:" if cc_no_systemd; then cc_say " mkdir -p /var/lib/tailscale && nohup tailscaled --tun=userspace-networking --state=/var/lib/tailscale/tailscaled.state >/var/log/tailscaled.log 2>&1 /dev/tty 2>&1; then : else cc_say "In a root shell, run:" cc_say " tailscale up --qr" cc_say "Then scan its QR with your phone, signed in to Tailscale, and approve it there." fi cc_wait_tailscale "$ts" } # launchd or systemd, as `daemon install` sets it up; under systemd, linger is # turned on first, with sudo only where systemd refuses it to a normal user. # With no systemd, the hand-run line, started here and given a moment to answer. cc_start_daemon() { if cc_daemon_up; then return 0 fi if cc_no_systemd; then mkdir -p "$CODECONNECT_PREFIX/logs" # A session of its own: in this script's process group a Ctrl-C at a # later step would reach the daemon too, and it stops on SIGINT. CODECONNECT_HOME="$CODECONNECT_PREFIX" setsid nohup "$CODECONNECT_PREFIX/bin/ccd" \ >>"$CODECONNECT_PREFIX/logs/ccd.out.log" 2>>"$CODECONNECT_PREFIX/logs/ccd.err.log" /dev/null)" = "no" ] && ! loginctl --no-ask-password enable-linger "$(id -un)" /dev/null 2>&1 && cc_have sudo; then cc_say "Turning on linger with sudo, so the daemon keeps running when you log out:" # shellcheck disable=SC2024 # sudo's redirections are the user's terminal, on purpose sudo loginctl enable-linger "$(id -un)" /dev/tty 2>&1 || true fi if cc_codeconnect daemon install; then return 0 fi cc_warn "the daemon did not start; the message above says why." return 1 } # A phone, while none is paired: the code, then a pause until it is scanned. cc_pair_shown="no" cc_pair_failed="no" cc_pair() { if [ "$(cc_pairing)" != "unpaired" ]; then return 0 fi if ! cc_codeconnect pair; then cc_pair_failed="yes" return 0 fi cc_wait_enter "Scan the code with the CodeConnect app, then press Enter." if [ "$(cc_pairing)" = "paired" ]; then cc_say "Your iPhone is paired." else cc_pair_shown="yes" fi } # Whether an agent this computer has is signed out, asked of the agent itself, # and the command that signs it in: each agent's own, nothing of CodeConnect's. cc_signed_out() { case "$1" in claude) cc_have claude && ! cc_run claude auth status >/dev/null 2>&1 ;; codex) cc_have codex && ! cc_run codex login status >/dev/null 2>&1 ;; esac } cc_login_line() { case "$1" in claude) echo "claude auth login" ;; codex) if cc_is_linux; then echo "codex login --device-auth"; else echo "codex login"; fi ;; esac } cc_login() { case "$1" in claude) claude auth login ;; codex) if cc_is_linux; then codex login --device-auth; else codex login; fi ;; esac } # Ctrl-C ends only the sign-in it interrupts: Claude Code reads it as a key and # exits; Codex's device login is ended by the signal, which here leaves the # setup running. cc_agent_logins() { trap ':' INT for agent in claude codex; do if cc_signed_out "$agent"; then case "$agent" in claude) name="Claude Code" ;; codex) name="Codex" ;; esac cc_step "Sign in to $name (Ctrl-C skips it)." # Asked again rather than read from the exit status: Codex's # device login exits 0 when Ctrl-C stops it. cc_login "$agent" /dev/tty 2>&1 || true if cc_signed_out "$agent"; then cc_warn "$name is not signed in; \`$(cc_login_line "$agent")\` signs it in later." fi fi done trap 'exit 130' INT } # A daemon that does not answer is not taken for one with no phone: the setup # could do nothing for it, and the next steps say how to find out why. cc_setup_done() { cc_tailscale_running && cc_daemon_up && [ "$(cc_pairing)" != "unpaired" ] && ! cc_signed_out claude && ! cc_signed_out codex } # macOS asks once whether the daemon may open Desktop, Documents and Downloads, # with a dialog on this Mac's screen that the daemon waits on. The daemon asks # itself, so the dialog names it and the answer is kept for it, and only while # someone is here to answer: at a terminal that is not an SSH login. Asked on # every run, since an answer already given is not asked again. An installed # release older than 0.17.0, rerun here without an update, has no such question; # its first typed start after updating asks it. cc_folders() { if cc_is_linux || ! cc_daemon_up; then return 0 fi if ! cc_codeconnect daemon 2>&1 | grep -q "daemon folders"; then return 0 fi # A subshell, as in cc_ask. if [ -n "${SSH_CONNECTION:-}" ] || ! (: /dev/null; then cc_say "" cc_say "To let your phone open Desktop, Documents and Downloads, run this at the Mac:" cc_say " $(cc_command) daemon folders" return 0 fi cc_step "Folder permissions for your phone." if ! cc_codeconnect daemon folders; then cc_warn "the folders were not asked about; \`$(cc_command) daemon folders\` asks again." fi } # Everything still missing, after one question, in the order each needs the # one before: Tailscale, the daemon, its folders, a phone, then the agents' own # sign-ins. Each step is skipped once done, so a run stopped anywhere picks up # there. cc_offer_setup() { if cc_setup_done; then cc_folders return 0 fi if [ "$(cc_ask "Set up CodeConnect now?")" != "yes" ]; then return 0 fi if cc_tailscale && cc_start_daemon; then cc_folders cc_pair fi cc_agent_logins } # ------------------------------------------------------------------- next cc_next() { bin="$CODECONNECT_PREFIX/bin" cc="$(cc_command)" here="" if cc_daemon_up; then pairing="$(cc_pairing)" # A code scanned after its Enter, during a sign-in, is used up. if [ "$pairing" = "paired" ]; then cc_pair_shown="no" fi if [ "$cc_pair_shown" = "yes" ]; then cc_step "Scan the code above with the CodeConnect app." if cc_runs_here "$cc"; then cc_say "If it expires, \`$cc pair\` makes a new one." else cc_say "If it expires, \`$cc pair\` in a new terminal makes a new one." fi elif [ "$pairing" = "unknown" ]; then here=" $cc daemon status # the daemon is not answering; this names the log that says why $cc pair # once it is running" elif [ "$cc_pair_failed" = "yes" ]; then here=" $cc pair # once the message above is dealt with" elif [ "$pairing" = "unpaired" ]; then here=" $cc pair # QR code to scan with the iPhone app" fi elif cc_no_systemd; then cc_say "" cc_say "This computer has no systemd user manager, so nothing starts the daemon for" cc_say "you. Start it with the first line below, after Tailscale is connected" cc_say "(\`sudo tailscale up --qr\`, then scan its QR with your phone, signed in to" cc_say "Tailscale, and approve it there), and again whenever it stops." here=" $(cc_hand_run_line) Then, a second later, once it has started: $cc pair # QR code to scan with the iPhone app" else if cc_is_linux && ! cc_tailscale_running; then cc_say "" cc_say "Before the steps below, connect Tailscale" cc_say "(\`sudo tailscale up --qr\`, then scan its QR with your phone, signed in to" cc_say "Tailscale, and approve it there)." fi here=" $cc daemon install # start the daemon now and $(cc_daemon_when) $cc pair # QR code to scan with the iPhone app" fi for agent in claude codex; do if cc_signed_out "$agent"; then here="${here:+$here } $(cc_login_line "$agent")" fi done if [ -z "$here" ]; then cc_say "" if [ "$cc_pair_shown" = "yes" ]; then cc_say "Then open a new terminal in your project's folder and run:" else cc_say "Open a new terminal in your project's folder and run:" fi cc_say " $cc claude # or: $cc codex" return 0 fi # The terminal that ran this keeps the PATH it started with, whatever the # startup files now say. Only the bare name needs saying so; a full path # runs anywhere. cc_say "" if ! cc_runs_here "$cc"; then cc_say "In this terminal, until you open a new one:" cc_path_instruction "$bin" >&2 cc_say "" fi cc_say "Next:" cc_say "$here" cc_say " $cc claude # in your project's folder (or: $cc codex)" } # ------------------------------------------------------------------- main codeconnect_install_main() { set -eu umask 077 mode="release" cc_reinstall="no" for argument in "$@"; do case "$argument" in --source) mode="source" ;; --reinstall) cc_reinstall="yes" ;; --path-yes) cc_path_choice="yes" ;; --path-no) cc_path_choice="no" ;; --version) printf 'codeconnect-installer %s\n' "$CODECONNECT_INSTALLER_VERSION" return 0 ;; --help | -h) cc_usage return 0 ;; *) cc_say "unknown option: $argument" cc_usage return 2 ;; esac done cc_say "CodeConnect installer $CODECONNECT_INSTALLER_VERSION" # Absolute, as `codeconnect` resolves it: a relative prefix written into a # startup file would put whatever directory a shell is in on its PATH. case "$CODECONNECT_PREFIX" in /*) ;; *) CODECONNECT_PREFIX="$PWD/$CODECONNECT_PREFIX" ;; esac # Everything that can refuse runs before anything that can write, and the # more specific refusal is ordered first: someone who already has # CodeConnect should be told that, not told the installer lacks a signing # identity. Same ranking CodeConnect itself uses when a checkout advisory # and a release advisory both apply. cc_preflight_common cc_preflight_platform cc_guard_existing if [ "$cc_resume" = "yes" ]; then cc_begin_staging cc_installed_from="this $(cc_machine)'s existing install" cc_offer_path cc_summary cc_offer_setup cc_next return 0 fi if [ "$mode" = "source" ] && cc_is_linux; then cc_die "--source builds on macOS only." \ "" \ "On Linux, install the release:" \ "" \ " curl -fsSL https://codeconnect.sh | sh" \ "" \ "Nothing was changed." fi if [ "$mode" = "release" ]; then cc_preflight_release fi # First write happens here. cc_prepare_prefix cc_begin_staging if [ "$mode" = "source" ]; then cc_install_source cc_check_tmux else cc_install_release fi cc_offer_path cc_summary cc_offer_setup cc_next } codeconnect_install_main "$@"