Skip to content

ISSUE-262: Add Linux support - #447

Open
bguidolim wants to merge 28 commits into
mainfrom
bruno/ISSUE-262-linux-support
Open

bguidolim wants to merge 28 commits into
mainfrom
bruno/ISSUE-262-linux-support

Conversation

@bguidolim

@bguidolim bguidolim commented Sep 29, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

mcs only ran on macOS, so a Linux user could not configure Claude Code with it at
all. It now builds and runs on glibc Linux with the same feature set, and the
release ships a tarball per architecture alongside the macOS universal binary.
Several bugs surfaced by the port also affected macOS: a raw-mode prompt spun at
100% CPU when stdin closed, a shellInteractive component spun (and could then
hang forever) when mcs ran without a terminal, and the process lock outlived mcs
if a component left a child running.

Replaces #395, which went stale against main.

Closes #262

Release note

  • NEW: mcs runs on Linux (x86_64 and aarch64, glibc 2.35+) — download the release tarball from the release page
  • FIX: a shellInteractive component no longer hangs or burns CPU when mcs runs without a terminal (CI, hooks, </dev/null); it runs without a PTY and its prompts see end of input
  • FIX: a prompt whose input closes mid-answer now stops the command with an error instead of applying an unconfirmed choice
  • HOME=<dir> mcs … is honoured on macOS too; mcs previously wrote to the passwd home regardless
  • When the Claude Code CLI is missing, mcs points to the native installer, which keeps itself updated, instead of offering a Homebrew install

Changes

  • Terminal, process and hashing layers build on Glibc as well as Darwin; SHA-256
    comes from swift-crypto, which re-exports CryptoKit on Apple platforms.
  • An absolute $HOME is honoured on both platforms, including for ~ in pack
    paths and doctor checks; injected environments now reach every path resolver.
  • Raw-mode pickers throw on closed input; shellInteractive gets a PTY only on a
    terminal; the PTY bridge drains output before closing on an error.
  • The Linux self-upgrade command is built only for a plain X.Y.Z tag, quotes its
    paths, and hands a sudo form to the user rather than to Claude.
  • PR checks run lint on macOS (SwiftLint's static Linux binary skips SourceKit
    rules), and test, build and smoke flows as per-platform matrices; the release
    job alone holds write access, and swiftly is pinned and checksummed.

Test plan

  • swift test passes locally
  • swiftformat --lint . and swiftlint pass without violations, on the pinned versions
  • Affected commands verified with a real pack (e.g. mcs sync, mcs doctor)
  • .github/actions/mcs-smoke passes in PR checks on ubuntu-22.04, ubuntu-22.04-arm and the macOS universal binary, in a sandboxed $HOME
  • The four Linux checks are required by the repo ruleset
Checklist for engine changes
  • Integration tests updated for new features (LifecycleIntegrationTests or DoctorRunnerIntegrationTests)
  • Docs updated if behavior changed (CLAUDE.md, docs/, techpack.yaml schema in ExternalPackManifest.swift)

https://claude.ai/code/session_014tZGxbvJUwXqd6fU1S3fXu

@bguidolim bguidolim mentioned this pull request Sep 29, 2026
5 of 6 tasks
- CryptoKit is Apple-only; swift-crypto's Crypto re-exports it on Darwin and
  builds BoringSSL only off-Darwin, so one import serves both
- Pinned known-answer digests guard the seam that .mcs-project state depends on

Claude-Session: https://claude.ai/code/session_014tZGxbvJUwXqd6fU1S3fXu
- OSAllocatedUnfairLock is Darwin-only and Synchronization.Mutex is macOS 15+,
  above this package's floor, so an NSLock-backed box serves both call sites
- Raw-mode pickers treat a hangup as No and leave a warning in scrollback;
  readByte takes the descriptor so the EOF exit is covered by a pipe test

Claude-Session: https://claude.ai/code/session_014tZGxbvJUwXqd6fU1S3fXu
- termios c_cc is indexed through the platform's VMIN/VTIME rather than literals
- The PTY bridge's poll decisions move to a pure PTYBridge so the /dev/null-stdin
  spin and drain-before-drop are table-tested; O_CLOEXEC stops a child holding
  the process lock after mcs exits
- A bridge that fails before exec now reports why instead of "see output above"

Claude-Session: https://claude.ai/code/session_014tZGxbvJUwXqd6fU1S3fXu
- Linuxbrew's prefixes, and advice that names the system package manager where
  brew cannot act, so no hint points back at a command with no exit
- ensureClaudeCLI never offers a cask Linuxbrew does not have, and takes
  injectable brew/confirm seams so both macOS branches are asserted

Claude-Session: https://claude.ai/code/session_014tZGxbvJUwXqd6fU1S3fXu
- NSHomeDirectory() reads the passwd entry and ignores $HOME on Darwin too, so
  HOME=... mcs ... was silently ignored everywhere; user-typed ~ now expands
  against the same home as ~/.mcs
- brewPrefix resolves symlinks then strips a trailing Homebrew component, so a
  shim and the installer's own layout both yield the prefix formulae link into

Claude-Session: https://claude.ai/code/session_014tZGxbvJUwXqd6fU1S3fXu
- brew upgrade only exists where mcs came from the tap; Linux gets a staged,
  version-verified tarball swap at the path /proc/self/exe resolves
- With nothing runnable the hook names the releases page and asks no question,
  instead of emitting an empty "On yes, run:"

Claude-Session: https://claude.ai/code/session_014tZGxbvJUwXqd6fU1S3fXu
- PR checks and the release build run on ubuntu-22.04 and ubuntu-22.04-arm, so
  x86_64 and aarch64 ship from one release with one SHA256SUMS
- One composite action installs the toolchain for every Linux job
- A manual Linux Smoke workflow exercises whole command flows against a real
  pack; it is what the compatibility matrix rests on

Claude-Session: https://claude.ai/code/session_014tZGxbvJUwXqd6fU1S3fXu
- docs/linux-support.md records the supported configurations, the compatibility
  matrix and an ADR per platform decision; telemetry and the lockfile are gone
  from main, so neither needs a Linux entry
- CLAUDE.md, README and the docs name both architectures and the six files
  allowed to hold platform-dependent values

Claude-Session: https://claude.ai/code/session_014tZGxbvJUwXqd6fU1S3fXu
- Ubuntu 22.04 needs pkg-config and python3-lldb-13 on top of libcurl's headers,
  and the names differ by release and architecture
- Read them back from swiftly's own error instead of hardcoding a list

Claude-Session: https://claude.ai/code/session_014tZGxbvJUwXqd6fU1S3fXu
- SwiftFormat and SwiftLint both publish static Linux binaries, so linting needs
  no Swift toolchain and no macOS runner; the newest release is still what runs
- Quote the release tarball globs so a name with a leading dash cannot become an
  option (actionlint/shellcheck SC2035)

Claude-Session: https://claude.ai/code/session_014tZGxbvJUwXqd6fU1S3fXu
- The unzip directory and the install destination shared a name, so install
  resolved source and target to the same file and exited 1

Claude-Session: https://claude.ai/code/session_014tZGxbvJUwXqd6fU1S3fXu
- fileExists(atPath:isDirectory:) is @discardableResult only on Darwin, so
  discarding it fails the Linux build under -warnings-as-errors
- Gating on the return value is also what Apple documents: isDirectory is
  undefined when the path does not exist

Claude-Session: https://claude.ai/code/session_014tZGxbvJUwXqd6fU1S3fXu
- Each Linux job downloaded 959 MB and spent ~50s installing the toolchain; a
  cache keyed on version and architecture skips that on a hit
- The system packages stay outside the cache, since a cached toolchain needs
  them too; swiftly's own list remains the fallback on a miss
- The action now reports the resolved Swift version, which also proves a cache
  hit is usable before anything builds

Claude-Session: https://claude.ai/code/session_014tZGxbvJUwXqd6fU1S3fXu
- .build/checkouts and the resolver state are git clones with no absolute paths,
  so a per-runner key stored the same bytes six times and made five jobs miss
- Compiled output stays uncached: it is not portable across runner or toolchain

Claude-Session: https://claude.ai/code/session_014tZGxbvJUwXqd6fU1S3fXu
- A new upstream release could turn a PR red without a source change; the pins
  make a lint verdict depend on the repo alone
- The job asserts each binary reports its pinned version, so a resolution that
  silently drifted cannot pass unnoticed

Claude-Session: https://claude.ai/code/session_014tZGxbvJUwXqd6fU1S3fXu
- TEMPORARY trigger, reverted before merge: workflow_dispatch only reaches
  workflows already on the default branch, so a new one cannot be exercised
- The runner becomes a matrix so a pull_request event covers both architectures

Claude-Session: https://claude.ai/code/session_014tZGxbvJUwXqd6fU1S3fXu
- Seven rows for the flows that landed after the original Linux verification,
  each observed on x86_64 and aarch64
- The legend now names the workflow the rows come from, so a stale table is
  re-dispatchable rather than guesswork

Claude-Session: https://claude.ai/code/session_014tZGxbvJUwXqd6fU1S3fXu
- The pull_request trigger existed to produce the matrix evidence while the
  workflow was not yet on the default branch; that run is recorded

Claude-Session: https://claude.ai/code/session_014tZGxbvJUwXqd6fU1S3fXu
- The matrix in the Linux docs cites a run, so a new command, flag contract or
  manifest key that never reaches the smoke job leaves it asserting untested
  behaviour
- Stated once under Testing, with a checkbox in the engine-changes checklist

Claude-Session: https://claude.ai/code/session_014tZGxbvJUwXqd6fU1S3fXu
- One composite action holds the flows; the jobs that already build a release
  binary call it, so the coverage costs seconds instead of another build
- macOS runs them against the universal binary users install, which nothing
  exercised end to end before
- The manual workflow calls the same action, for reproducing a failure on a
  runner PR checks does not cover

Claude-Session: https://claude.ai/code/session_014tZGxbvJUwXqd6fU1S3fXu
- The two are independent, and serialising them made Linux the critical path of
  PR checks; the smoke flows ride with the release build they need
- Drop the unconditional apt-get update, which had grown to cost more than the
  toolchain download, and fall back to it only when a package will not resolve

Claude-Session: https://claude.ai/code/session_014tZGxbvJUwXqd6fU1S3fXu
- A shellInteractive command gets a PTY only on a terminal; a picker that loses its input throws instead of answering
- The Linux upgrade command runs only for a plain X.Y.Z tag, quotes its paths, and hands a sudo form to the user
- Injected environments reach bootstrap's pack resolver and Homebrew prefixes; $HOME must be absolute, zsh is checked
- Lint uses pinned portable binaries on macOS, so SourceKit rules are enforced; release jobs hold write access only when publishing
- Test and build run as per-platform matrices that keep the required check names; the manual Linux smoke workflow is gone
- swiftly is pinned and checksummed, and smoke runs in a sandboxed HOME with a shellInteractive component under a time limit
- A missing Claude CLI prints the self-updating native installer on every platform; mcs never installs it itself
- Allocation checks before fork collapse into one guard; the termios reader and Homebrew test seams leave production code
- Tests mirror their sources and drop cases that only restated language behavior
- Comments state their reason once, without narration; helper comments move inside bodies so they stay plain comments
- CLAUDE.md and the Linux doc list five platform files, describe the upgrade and Claude CLI behavior, and mark smoke-backed rows
- Docs cover shellInteractive off a terminal and how to recover a tag whose release failed
@bguidolim
bguidolim force-pushed the bruno/ISSUE-262-linux-support branch from bf34547 to ba03f39 Compare October 9, 2026 17:08
- Raw mode cleared ICANON and ECHO but not ISIG, so Ctrl-C killed the process
  before the restore ran and left the terminal with no echo and no cursor; the
  pickers' existing Ctrl-C branch was unreachable and returned the default,
  which would have read as consent once the byte arrived
- A failing brew check without Homebrew is no longer planned for a re-sync, so
  doctor --fix shows the manual hint instead of repairing and failing again
- A hook-interpreter test names `node` when it is missing; the Swift 6.4 link
  failure is recorded as a known limitation

Claude-Session: https://claude.ai/code/session_014tZGxbvJUwXqd6fU1S3fXu
- AnyObject.isEqual resolves only where Foundation bridges to NSObject, so the
  per-sub-key merge did not compile on Glibc
- Canonical JSON bytes settle it instead, which also stops `true` and `1`
  counting as the same value; either way an unequal result keeps the user's

Claude-Session: https://claude.ai/code/session_014tZGxbvJUwXqd6fU1S3fXu
- AnyObject.isEqual compiled on macOS and failed only on Linux, so the macOS
  jobs could not catch it; the rule now says where that class of call goes wrong
  and what replaces it for JSON values

Claude-Session: https://claude.ai/code/session_014tZGxbvJUwXqd6fU1S3fXu
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Linux support

1 participant