Skip to content

Commit 8fa6a46

Browse files
authored
Scaffold Termux launcher package for Claude Code (#1)
An aarch64 .deb that runs Claude Code natively on Termux/Android. Anthropic ships Claude Code only as bun-compiled glibc ELF binaries with no android-arm64 build, so `npm i -g @anthropic-ai/claude-code` is broken on Termux. This installs a compiled launcher that downloads the official linux-arm64 build from Anthropic, patches its ELF interpreter to Termux's glibc loader, routes embedded-tool re-execs back through the launcher (argv[0]-preserving), clears LD_PRELOAD, and sets TMPDIR for Termux's missing /tmp. The binary is fetched on-device and never redistributed. Includes the bootstrap/patch engine (SHA-256 verify, optional download cache, conditional updater), a umask-robust dpkg-deb build, and CI that builds + installs + tests the .deb in termux-docker on a native arm64 runner, plus a release-watch workflow.
1 parent 886ef0e commit 8fa6a46

25 files changed

Lines changed: 1262 additions & 0 deletions

.editorconfig

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
root = true
2+
3+
[*]
4+
charset = utf-8
5+
end_of_line = lf
6+
indent_style = space
7+
indent_size = 2
8+
insert_final_newline = true
9+
trim_trailing_whitespace = true

.gitattributes

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Shell scripts ship to Termux — CRLF would break the shebang. Force LF.
2+
* text=auto eol=lf
3+
*.deb binary

.github/renovate.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
3+
"extends": ["github>gtbuchanan/tooling"]
4+
}

.github/workflows/cd.yml

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
name: CD
2+
3+
on:
4+
push:
5+
branches: [main]
6+
workflow_dispatch:
7+
8+
permissions:
9+
contents: read
10+
11+
jobs:
12+
# Build + test on every push to main (reuses the PR workflow) and upload the
13+
# .deb the release job publishes — built and tested once, no rebuild.
14+
ci:
15+
name: CI
16+
uses: ./.github/workflows/ci.yml
17+
18+
# Gated by the `release` environment (configure required reviewers in repo
19+
# settings → Environments → release). Publishes the exact .deb the ci job
20+
# built; the version is read from its filename (CalVer YYYY.MM.<run_number>).
21+
release:
22+
name: Release
23+
needs: ci
24+
environment: release
25+
runs-on: ubuntu-latest
26+
permissions:
27+
contents: write
28+
steps:
29+
- name: Download the built .deb
30+
uses: actions/download-artifact@v8
31+
with:
32+
name: deb
33+
path: artifacts/packages
34+
35+
- name: Publish release
36+
env:
37+
GH_TOKEN: ${{ github.token }}
38+
run: |
39+
set -eu
40+
deb=$(ls artifacts/packages/*.deb)
41+
version=$(basename "$deb" | sed -E 's/^claude-code-termux_(.+)_aarch64\.deb$/\1/')
42+
# --generate-notes builds the changelog from PRs/commits since the
43+
# previous tag; --notes is pre-pended to it (the .deb is attached here,
44+
# so the redistribution note belongs on the release page).
45+
gh release create "v${version}" \
46+
--repo "$GITHUB_REPOSITORY" \
47+
--target "$GITHUB_SHA" \
48+
--title "claude-code-termux v${version}" \
49+
--notes 'The Claude Code binary is downloaded directly from Anthropic and is not redistributed by this project.' \
50+
--generate-notes \
51+
"$deb"

.github/workflows/ci.yml

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
name: CI
2+
3+
on:
4+
pull_request:
5+
branches: [main]
6+
workflow_call:
7+
inputs:
8+
claude_version:
9+
description: Claude Code version to test (empty = latest).
10+
type: string
11+
required: false
12+
default: ''
13+
14+
permissions:
15+
contents: read
16+
17+
jobs:
18+
shellcheck:
19+
runs-on: ubuntu-latest
20+
steps:
21+
- uses: actions/checkout@v6
22+
# Auto-discovers every shell script (by shebang + extension), including
23+
# the extension-less maintainer scripts (postinst, postrm, …).
24+
- uses: ludeeus/action-shellcheck@2.0.0
25+
26+
termux:
27+
# Native arm64 runner (free for public repos): the aarch64 container runs
28+
# at native speed, no QEMU emulation needed.
29+
runs-on: ubuntu-24.04-arm
30+
steps:
31+
- uses: actions/checkout@v6
32+
- name: Build, install + test in termux-docker
33+
env:
34+
CLAUDE_CODE_VERSION: ${{ inputs.claude_version }}
35+
run: |
36+
set -eu
37+
mkdir -p artifacts/packages && chmod 777 artifacts/packages
38+
# termux-docker's entrypoint drops privileges and sanitizes the
39+
# environment, so `docker run -e` never reaches the command. Pass the
40+
# run number (version.sh's CalVer input) and the Claude Code version
41+
# to test (empty = latest) as positional args to a single-quoted
42+
# script instead — nothing from the host is interpolated into the
43+
# container command.
44+
docker run --rm --privileged \
45+
-v "$PWD:/src:ro" -v "$PWD/artifacts/packages:/out" \
46+
termux/termux-docker:aarch64 \
47+
bash -c '
48+
set -eu
49+
cp -r /src ~/work && cd ~/work
50+
# Ephemeral cache dir: exercises the CLAUDE_CODE_CACHE_DIR path
51+
# (the forced-update cache-hit assertion) within this run, at no
52+
# extra download. Not persisted — CI re-downloads each run.
53+
GITHUB_RUN_NUMBER="$1" CLAUDE_CODE_VERSION="$2" \
54+
CLAUDE_CODE_CACHE_DIR="$HOME/cc-cache" bash scripts/test.sh
55+
cp ~/work/artifacts/packages/*.deb /out/
56+
' termux-test "$GITHUB_RUN_NUMBER" "$CLAUDE_CODE_VERSION"
57+
- name: Upload .deb
58+
uses: actions/upload-artifact@v7
59+
with:
60+
name: deb
61+
path: artifacts/packages/*.deb
62+
if-no-files-found: error
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
name: Release watch
2+
3+
# Polls Anthropic's Claude Code release channel and runs the full build/test
4+
# against any newly-released version. The patch pipeline — notably the
5+
# patch-execpath.py byte-anchor, which bun's minifier can rotate out from under
6+
# us between releases — is the fragile surface; this surfaces a break within a
7+
# day of publication instead of when a user next installs. An actions/cache key
8+
# per version gates the e2e so it runs once per release, not every day.
9+
on:
10+
schedule:
11+
- cron: '0 13 * * *' # daily, ~13:00 UTC
12+
# Manual re-check, and keeps the schedule from being auto-disabled after 60
13+
# days of repository inactivity.
14+
workflow_dispatch:
15+
16+
permissions:
17+
contents: read
18+
19+
jobs:
20+
detect:
21+
runs-on: ubuntu-latest
22+
outputs:
23+
version: ${{ steps.resolve.outputs.version }}
24+
fresh: ${{ steps.gate.outputs.fresh }}
25+
steps:
26+
- name: Resolve latest Claude Code version
27+
id: resolve
28+
run: |
29+
set -eu
30+
# Same source of truth bootstrap.sh's resolve_version() uses.
31+
v=$(curl -fsSL --retry 3 \
32+
https://downloads.claude.ai/claude-code-releases/latest \
33+
| tr -d '[:space:]')
34+
case "$v" in
35+
[0-9]*.[0-9]*.[0-9]*) ;;
36+
*) echo "unexpected version string: '$v'" >&2; exit 1 ;;
37+
esac
38+
echo "version=$v" >> "$GITHUB_OUTPUT"
39+
40+
- name: Check tested-version cache
41+
id: cache
42+
uses: actions/cache/restore@v4
43+
with:
44+
path: .claude-tested
45+
key: claude-tested-${{ steps.resolve.outputs.version }}
46+
lookup-only: true
47+
48+
- name: Decide whether to verify
49+
id: gate
50+
env:
51+
VERSION: ${{ steps.resolve.outputs.version }}
52+
HIT: ${{ steps.cache.outputs.cache-hit }}
53+
run: |
54+
set -eu
55+
if [ "$HIT" = 'true' ]; then
56+
echo "Claude Code $VERSION already verified — skipping."
57+
echo "fresh=false" >> "$GITHUB_OUTPUT"
58+
else
59+
echo "Claude Code $VERSION is new — verifying."
60+
echo "fresh=true" >> "$GITHUB_OUTPUT"
61+
fi
62+
63+
# Reuse the exact PR/CD pipeline, pinned to the detected version so we record
64+
# what we actually tested (not whatever "latest" resolves to mid-run).
65+
verify:
66+
needs: detect
67+
if: needs.detect.outputs.fresh == 'true'
68+
uses: ./.github/workflows/ci.yml
69+
with:
70+
claude_version: ${{ needs.detect.outputs.version }}
71+
72+
# Record the pass so tomorrow's run skips this version (saved only on success,
73+
# so a failure is retried daily until fixed).
74+
record:
75+
needs: [detect, verify]
76+
if: needs.detect.outputs.fresh == 'true' && needs.verify.result == 'success'
77+
runs-on: ubuntu-latest
78+
steps:
79+
- name: Mark version verified
80+
run: mkdir -p .claude-tested && echo "${{ needs.detect.outputs.version }}" > .claude-tested/version
81+
- uses: actions/cache/save@v4
82+
with:
83+
path: .claude-tested
84+
key: claude-tested-${{ needs.detect.outputs.version }}
85+
86+
# File a tracked, deduplicated issue when a new release breaks the pipeline.
87+
notify:
88+
needs: [detect, verify]
89+
if: needs.detect.outputs.fresh == 'true' && needs.verify.result == 'failure'
90+
runs-on: ubuntu-latest
91+
permissions:
92+
contents: read
93+
issues: write
94+
steps:
95+
- name: Open an issue (deduped by title)
96+
env:
97+
GH_TOKEN: ${{ github.token }}
98+
VERSION: ${{ needs.detect.outputs.version }}
99+
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
100+
run: |
101+
set -eu
102+
title="Claude Code $VERSION fails the Termux patch pipeline"
103+
open=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \
104+
--search "in:title \"$title\"" --json number --jq 'length')
105+
if [ "$open" -gt 0 ]; then
106+
echo "An open issue with this title already exists — not duplicating."
107+
exit 0
108+
fi
109+
gh issue create --repo "$GITHUB_REPOSITORY" --title "$title" --body \
110+
"The scheduled build/test against Claude Code \`$VERSION\` failed.
111+
112+
The most likely cause is \`package/payload/libexec/patch-execpath.py\`:
113+
bun's minifier rotates identifiers between releases, so the byte
114+
anchor (the trailing \`).TMUX=\`) may no longer match exactly once.
115+
The patch fails loudly (match count != 1) and the failure output now
116+
includes a byte window around each \`process.execPath\` occurrence to
117+
show the new surrounding shape.
118+
119+
Failed run: $RUN_URL"

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
/artifacts/
2+
__pycache__/
3+
*.pyc

AGENTS.md

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
# claude-code-termux — Agent Guide
2+
3+
An aarch64 `.deb` that runs [Claude Code](https://github.com/anthropics/claude-code)
4+
natively on Termux/Android. Anthropic ships Claude Code only as bun-compiled
5+
**glibc** ELF binaries with no android-arm64 build, so `npm i -g
6+
@anthropic-ai/claude-code` is broken on Termux
7+
([claude-code#50270](https://github.com/anthropics/claude-code/issues/50270)).
8+
This package installs a launcher that downloads the official `linux-arm64`
9+
build from Anthropic, patches its ELF interpreter to Termux's glibc loader,
10+
and routes Claude's embedded-tool re-execs back through the launcher.
11+
12+
## Legal / redistribution
13+
14+
This project ships **only its own code**. The Claude Code binary is downloaded
15+
directly from Anthropic at install time and patched locally on-device — it is
16+
**never rehosted** (Anthropic's `LICENSE.md` is "all rights reserved"; their
17+
Commercial Terms bar duplicating/reselling the Services). Never commit or
18+
publish the binary; the `.deb` contains no Anthropic bytes.
19+
20+
## How it works
21+
22+
1. `install.sh` enables the `glibc-repo` apt source and `apt install`s the
23+
`.deb`; `apt` pulls the runtime deps.
24+
1. `postinst` downloads the `linux-arm64` binary from Anthropic, SHA-256-verifies
25+
it, runs `patchelf --set-interpreter` to point it at Termux's glibc loader,
26+
and runs `patch-execpath.py` to blank the subprocess `CLAUDE_CODE_EXECPATH`
27+
assignment.
28+
1. The compiled C launcher (`bin/claude`) execs the patched binary —
29+
`execv` **preserves `argv[0]`** (so Claude's embedded `grep`/`find`/`rg`
30+
dispatch works), it **clears `LD_PRELOAD`** (so the glibc binary's `ld.so`
31+
doesn't choke on termux-exec's text-script `libc.so`), and it **sets
32+
`TMPDIR`/`CLAUDE_CODE_TMPDIR`** to the Termux prefix when unset (Termux has no
33+
writable `/tmp`; see `src/claude-wrapper.c` for the env-vs-hardcoded-`/tmp`
34+
analysis and the MCP-browser-bridge limitation, `claude-code#15637`).
35+
1. `postinst` also merges two keys into `~/.claude/settings.json`:
36+
`env.LD_PRELOAD` (re-arms termux-exec for subprocess `/usr/bin/env` shebangs)
37+
and `autoUpdates: false` (so the in-session updater can't clobber the patched
38+
binary).
39+
40+
## Layout
41+
42+
| Path | Role |
43+
|---|---|
44+
| `install.sh` | Single install path. Downloads the latest release `.deb` (or installs a local one via `CLAUDE_CODE_DEB=<path>`), enables `glibc-repo`, `apt install`s it. |
45+
| `package/control` | Metadata. `Depends: bash, curl, jq, python3, glibc-runner, patchelf-glibc`. |
46+
| `package/postinst` | Settings merge (skip via `CLAUDE_CODE_SKIP_SETTINGS`) + fetch the binary. |
47+
| `package/postrm` | Removes the fetched binary on uninstall. |
48+
| `package/payload/bin/claude-code-termux-update` | Re-patch only if the version changed (`bootstrap.sh update`; schedulable). `--force` to re-apply. |
49+
| `package/payload/libexec/bootstrap.sh` | Resolve / download (`curl --retry`, optional `CLAUDE_CODE_CACHE_DIR`) / verify / `patchelf` / execPath-patch engine. |
50+
| `package/payload/libexec/patch-execpath.py` | The `CLAUDE_CODE_EXECPATH` patch. |
51+
| `package/payload/etc/claude-code-termux.conf` | `CLAUDE_CODE_VERSION` pin + `CLAUDE_CODE_CACHE_DIR` (both empty by default). |
52+
| `src/claude-wrapper.c` | The C launcher (`-DBINARY=` baked in at compile). |
53+
| `scripts/build-wrapper.sh` | Compile the wrapper with Termux's clang. |
54+
| `scripts/build-deb.sh` | Stage + `dpkg-deb --build``artifacts/packages/`. |
55+
| `scripts/test.sh` | Build + install + assert the fixes, inside termux-docker. |
56+
| `scripts/test-docker.sh` | Run `test.sh` on your machine via Docker (QEMU off-arm64). |
57+
| `scripts/version.sh` | Prints the version (see Versioning). |
58+
59+
## Versioning
60+
61+
CalVer `YYYY.M.<counter>` via `scripts/version.sh`, where `<counter>` is
62+
`GITHUB_RUN_NUMBER` (unique + monotonic) or `0` locally. No manual version file.
63+
Unpadded month (leading zeros read oddly / break some parsers).
64+
65+
## Building & packaging
66+
67+
Hand-assembled and **must run inside `termux/termux-docker:aarch64`** (needs
68+
Termux's clang + bionic for the wrapper; runs natively on arm64 hosts, under
69+
QEMU on x86 dev hosts): `build-wrapper.sh` compiles the
70+
launcher → `build-deb.sh` stages the payload under the Termux prefix and runs
71+
`dpkg-deb --build`. Output goes to `artifacts/{build,packages}` (git-ignored).
72+
The version is passed in by the caller; `build-deb.sh`/`test.sh` default to
73+
`scripts/version.sh`.
74+
75+
## Testing locally
76+
77+
`scripts/test-docker.sh [version]` pulls `termux/termux-docker:aarch64` and runs
78+
`scripts/test.sh` in it — natively on arm64 hosts, under QEMU elsewhere (needs
79+
Docker + `binfmt`). `test.sh` builds the
80+
`.deb`, installs it via `install.sh`, and asserts the real fixes: `argv[0]=rg`/
81+
`bfs` dispatch to the embedded ripgrep/bfs, `LD_PRELOAD`-cleared startup,
82+
`TMPDIR` injection, the `settings.json` merge, and the conditional/cached update
83+
paths.
84+
85+
For local speed, `test-docker.sh` mounts gitignored host caches under
86+
`artifacts/cache/` — the Termux **apt archive** cache (so build/runtime `.deb`s
87+
aren't re-downloaded; apt still fully installs + resolves) and a **`claude`**
88+
binary cache via `CLAUDE_CODE_CACHE_DIR` (raw pre-patch bytes; `patchelf` + the
89+
execPath patch still run). CI skips the caches (native arm64 + fast link) but
90+
sets an ephemeral `CLAUDE_CODE_CACHE_DIR` so the cache-hit path is still tested.
91+
92+
**termux-docker gotcha:** its entrypoint drops privileges and **sanitizes the
93+
environment**, so `docker run -e VAR=…` does **not** reach the command — set env
94+
vars **inline** in the `bash -c` string instead.
95+
96+
## CI/CD
97+
98+
- **`ci.yml`** (`pull_request` + `workflow_call`): lints the shell scripts and
99+
runs the termux build/test job, which builds + installs + tests the `.deb`
100+
once and uploads it as an artifact. Runs on a **native arm64** runner
101+
(`ubuntu-24.04-arm`) — no QEMU, so the aarch64 container runs natively. The
102+
`workflow_call` `claude_version` input (empty = latest) pins which Claude
103+
Code version the test installs.
104+
- **`release-watch.yml`** (`schedule: daily` + `workflow_dispatch`): polls
105+
Anthropic's release channel (the endpoint `bootstrap.sh` resolves against),
106+
and on a **new** version — gated by an `actions/cache` key per version so it
107+
runs once per release — calls `ci.yml` pinned to that version. The cache is
108+
saved only on success (a break is retried daily); a failure opens a
109+
deduplicated issue. This catches `patch-execpath.py` anchor breaks (bun
110+
minifier identifier rotation) within a day of release.
111+
- **`cd.yml`** (`push: main`): reuses `ci.yml`, then a `release` job **gated by
112+
the `release` GitHub Environment** (configure required reviewers in repo
113+
settings for the gate to pause) downloads the built artifact and publishes a
114+
GitHub release. It does **not** rebuild — it ships the exact bytes that were
115+
tested; the version is read from the `.deb` filename.
116+
117+
## Conventions
118+
119+
- Scripts that run inside termux-docker use the absolute Termux bash shebang
120+
(`#!/data/data/com.termux/files/usr/bin/bash`); dev-host scripts use
121+
`#!/usr/bin/env bash`.
122+
- Releases are CalVer and cut by approving the `release` environment gate on a
123+
push to `main`; there is no version file to bump.

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
@AGENTS.md

0 commit comments

Comments
 (0)