From 0987ad3efd30a730df50840d00aa06a81b1b404c Mon Sep 17 00:00:00 2001 From: Arne Roomann-Kurrik Date: Thu, 1 Oct 2026 17:04:06 -0700 Subject: [PATCH] feat: add Claude Code cloud installer --- .github/workflows/installer-smoke-test.yml | 3 + README.md | 25 ++++ RELEASE_CHECKLIST.md | 33 ++++++ install-cloud.sh | 70 +++++++++++ scripts/test-cloud-installer.py | 130 +++++++++++++++++++++ 5 files changed, 261 insertions(+) create mode 100644 RELEASE_CHECKLIST.md create mode 100755 install-cloud.sh create mode 100644 scripts/test-cloud-installer.py diff --git a/.github/workflows/installer-smoke-test.yml b/.github/workflows/installer-smoke-test.yml index 1dc8f58..f864ac8 100644 --- a/.github/workflows/installer-smoke-test.yml +++ b/.github/workflows/installer-smoke-test.yml @@ -25,6 +25,9 @@ jobs: completion_file: .config/fish/completions/archdev.fish steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Cloud installer process contracts + if: matrix.shell_name == 'bash' + run: python3 scripts/test-cloud-installer.py - name: Build fixture release run: | chmod +x install.sh scripts/create-unix-fixtures.sh diff --git a/README.md b/README.md index 0b812dd..6f11511 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,31 @@ won't commit or push setup files without permission. See the [installation guide](https://docs.archdev.ai/docs/start-here/install) for what to expect, then ask your agent to review your changes with ArchDev. +## Claude Code cloud environments + +In a personal Claude Code web environment, select **Custom** network access, +keep the default package-manager hosts, and allow `platform.archastro.ai` and +`archdev.ai`. Set `ARCHDEV_TOKEN=` in **Environment variables**. +Anyone using this environment can read that token, so do not share the environment. +Paste this into **Setup script**: + +```bash +curl -fsSL https://raw.githubusercontent.com/ArchAstro/archdev/main/install-cloud.sh | bash +``` + +The script installs the latest CLI to `/usr/local/bin` and configures Claude +hooks in the setup user's home directory (`/root` in Claude's cloud VM). +Setup runs before environment variables are available; a temporary SessionStart +hook signs in when the session starts. Installation errors are reported without +preventing the session from starting. The initial download itself must succeed. +Claude runs SessionStart hooks in parallel: until direct `ARCHDEV_TOKEN` auth +is released, the first presence update can race login. Later CLI commands use +the stored login. +Use a repository with the ArchDev skill committed or enable it in your Claude +account. Snapshot refreshes pick up installer and CLI updates. + +Maintainers: follow the [cloud release checklist](RELEASE_CHECKLIST.md). + ## Distribution sources This repository owns public installers, release metadata, downloadable diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md new file mode 100644 index 0000000..18c8d3e --- /dev/null +++ b/RELEASE_CHECKLIST.md @@ -0,0 +1,33 @@ +# Cloud installer release check + +Run this when publishing `install-cloud.sh` or changing a CLI release's cloud +installation, hooks, or authentication behavior. + +- [ ] `bash -n install-cloud.sh` and `python3 scripts/test-cloud-installer.py` pass. + The Installer Smoke Test workflow runs the process contracts on Linux for + pull requests, pushes to main, and manual dispatch. Its existing fixture + tests cover archive installation separately. These are local contract tests, + not proof of Claude's network proxy or hook dispatch. +- [ ] After the script is published at the public main-branch URL, configure a + personal Claude Code web environment using the README's one-line snippet, + network hosts, and token variable. Rebuild the snapshot so setup runs. +- [ ] Start a cloud session with the ArchDev skill available. Confirm setup + installed the CLI, the SessionStart contract appeared, and `archdev auth + status` and `archdev log messages --limit 1` succeed without a manual login. + Confirm presence appears and a requested team-room post succeeds in auto + mode without a credential appearing in the transcript. Record the CLI + version, date, and cloud session reference in the release evidence. +- [ ] When section 3.1's direct `ARCHDEV_TOKEN` resolution is in the released + CLI, remove the temporary login-hook block and verify the refreshed cloud + environment authenticates without creating `credentials.json`. Also remove + the exact legacy login hook from existing settings if the refresh retains + them; preserve all unrelated hooks. + +Known temporary limitation: Claude dispatches SessionStart hooks in parallel. +The login hook does not guarantee authentication before the first presence +update. Section 3.1's direct token resolution closes this gap. Do not wrap the +stock startup command to impose ordering: CLI hook self-repair restores it. +Check the first presence update explicitly in the live proof. + +The live cloud check must be completed after publication; a passing local +fixture run does not complete it. diff --git a/install-cloud.sh b/install-cloud.sh new file mode 100755 index 0000000..59fd0e7 --- /dev/null +++ b/install-cloud.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Claude Code cloud environment setup, before session variables are available. +# Setup failures must not prevent the environment from starting. +set +e +set +u +set -o pipefail + +warn() { printf 'ArchDev cloud setup: %s; continuing without blocking the session.\n' "$1" >&2; } + +work_dir="$(mktemp -d)" +if [[ -z "$work_dir" ]]; then + warn 'could not create temporary directory' + exit 0 +fi +settings_tmp='' +trap 'rm -rf "$work_dir"; [[ -z "$settings_tmp" ]] || rm -f "$settings_tmp"' EXIT + +# Download first so a failed or partial transfer cannot execute half a script. +if ! curl -fsSL https://raw.githubusercontent.com/ArchAstro/archdev/main/install.sh -o "$work_dir/install.sh"; then + warn 'could not download install.sh' + exit 0 +fi +install_dir="${ARCHDEV_INSTALL_DIR:-/usr/local/bin}" +if ! ARCHDEV_INSTALL_DIR="$install_dir" ARCHDEV_INSTALL_SKIP_PATH_UPDATE=true \ + ARCHDEV_INSTALL_SKIP_COMPLETIONS=true bash "$work_dir/install.sh" --version latest; then + warn 'CLI installation failed' + exit 0 +fi +if ! "$install_dir/archdev" repo hook setup --harness claude --force; then + warn 'Claude hook installation failed' + exit 0 +fi + +# Temporary bridge until released CLI auth resolves ARCHDEV_TOKEN directly. +# Keep the variable literal: setup has no token. Login happens at session start, +# alongside other SessionStart hooks. Claude runs these in parallel, so the +# first presence update can race login; direct env auth (section 3.1) fixes it. +if ! command -v jq >/dev/null 2>&1; then + warn 'jq is required to install the session login hook' + exit 0 +fi +settings_dir="$HOME/.claude" +settings="$settings_dir/settings.json" +if ! mkdir -p "$settings_dir"; then + warn 'could not create Claude settings directory' + exit 0 +fi +input="$settings" +if [[ ! -f "$input" ]]; then + printf '{}\n' >"$work_dir/settings.json" + input="$work_dir/settings.json" +fi +settings_tmp="$(mktemp "$settings_dir/.cloud-settings.XXXXXX")" +if [[ -z "$settings_tmp" ]]; then + warn 'could not create temporary Claude settings' + exit 0 +fi +login='[ -z "${ARCHDEV_TOKEN:-}" ] || archdev auth login --token "$ARCHDEV_TOKEN" >/dev/null 2>&1 || true' +if jq --arg login "$login" ' + .hooks.SessionStart = ( + (.hooks.SessionStart // []) | + if any(.[]; any(.hooks[]?; .command == $login)) then . + else [{hooks: [{type: "command", command: $login, timeout: 30}]}] + . end + ) +' "$input" >"$settings_tmp" && mv "$settings_tmp" "$settings"; then + printf 'ArchDev cloud setup complete. Authentication runs when a session starts.\n' +else + warn 'could not update Claude settings with the session login hook' +fi +exit 0 diff --git a/scripts/test-cloud-installer.py b/scripts/test-cloud-installer.py new file mode 100644 index 0000000..5ae31dd --- /dev/null +++ b/scripts/test-cloud-installer.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Local process contract tests; the release checklist owns the live cloud proof.""" +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parent.parent + + +class CloudInstallerTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + self.bin = self.root / "bin" + self.bin.mkdir() + self.settings = self.root / ".claude/settings.json" + self.env = dict(os.environ, HOME=str(self.root), PATH=f"{self.bin}:{os.environ['PATH']}", + TEST_ROOT=str(self.root), ARCHDEV_INSTALL_DIR=str(self.root / "installed-bin"), + ARCHDEV_PRESENCE_DISABLED="1") + self.env.pop("ARCHDEV_TOKEN", None) + self.env.pop("BASH_ENV", None) + self.write_command("curl", ''' +[ "${FAIL_DOWNLOAD:-}" != 1 ] || exit 22 +[ "$1" = -fsSL ] && [ "$2" = https://raw.githubusercontent.com/ArchAstro/archdev/main/install.sh ] && [ "$3" = -o ] || exit 1 +cp "$TEST_ROOT/installer" "$4" +''') + (self.root / "installer").write_text(''' +set -eu +[ "$ARCHDEV_INSTALL_DIR" = "$TEST_ROOT/installed-bin" ] +[ "$ARCHDEV_INSTALL_SKIP_PATH_UPDATE" = true ] +[ "$ARCHDEV_INSTALL_SKIP_COMPLETIONS" = true ] +[ "$*" = "--version latest" ] +[ "${FAIL_INSTALL:-}" != 1 ] +mkdir -p "$ARCHDEV_INSTALL_DIR" +cp "$TEST_ROOT/bin/archdev" "$ARCHDEV_INSTALL_DIR/archdev" +printf installed > "$TEST_ROOT/installed" +''') + self.write_command("archdev", ''' +case "$*" in + 'repo hook setup --harness claude --force') + [ "${FAIL_HOOKS:-}" != 1 ] || exit 1 + printf hooks > "$TEST_ROOT/hooks" + ;; + 'auth login --token '*) + printf '%s' "$4" > "$TEST_ROOT/login-token" + echo 'must not appear in session output' + exit "${FAIL_LOGIN:-0}" + ;; + *) exit 1 ;; +esac +''') + + def write_command(self, name, body): + path = self.bin / name + path.write_text("#!/usr/bin/env bash\nset -eu\n" + body) + path.chmod(0o755) + + def run_setup(self, **env): + # The cloud runner has errexit enabled. A setup error must still return 0. + result = subprocess.run(["bash", "-e", str(ROOT / "install-cloud.sh")], + env=dict(self.env, **env), text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + return result + + def test_setup_then_session_login_preserves_settings_and_is_repeatable(self): + # Setup: an existing user hook and preferences must survive cloud setup. + existing = {"hooks": {"SessionStart": [{"hooks": [{"type": "command", "command": "echo existing"}]}]}, + "permissions": {"allow": ["Bash(echo *)"]}} + self.settings.parent.mkdir() + self.settings.write_text(json.dumps(existing)) + self.run_setup() + first = self.settings.read_text() + self.run_setup() + self.assertEqual(first, self.settings.read_text()) + data = json.loads(first) + self.assertEqual(data["permissions"], existing["permissions"]) + self.assertEqual(data["hooks"]["SessionStart"][1:], existing["hooks"]["SessionStart"]) + self.assertTrue((self.root / "installed").exists()) + self.assertTrue((self.root / "hooks").exists()) + self.assertFalse((self.root / "login-token").exists()) + # Session boundary: execute the saved hook with a token absent at setup. + command = data["hooks"]["SessionStart"][0]["hooks"][0]["command"] + for extra in ({}, {"ARCHDEV_TOKEN": "fixture-token"}, + {"ARCHDEV_TOKEN": "fixture-token", "FAIL_LOGIN": "1"}): + result = subprocess.run(["bash", "-c", command], env=dict(self.env, **extra), + text=True, capture_output=True) + self.assertEqual(result.returncode, 0) + self.assertEqual(result.stdout + result.stderr, "") + self.assertEqual((self.root / "login-token").read_text(), "fixture-token") + self.assertNotIn("fixture-token", first) + + def test_setup_uses_installed_cli_even_when_path_has_another(self): + # Retain the executable in the fixture release, then poison PATH. + fixture = self.root / "released-archdev" + fixture.write_bytes((self.bin / "archdev").read_bytes()) + fixture.chmod(0o755) + installer = self.root / "installer" + installer.write_text(installer.read_text().replace( + '$TEST_ROOT/bin/archdev', '$TEST_ROOT/released-archdev')) + self.write_command("archdev", 'exit 99\n') + result = self.run_setup() + self.assertIn("setup complete", result.stdout) + self.assertTrue((self.root / "hooks").exists()) + + def test_fresh_settings(self): + self.run_setup() + self.assertEqual(len(json.loads(self.settings.read_text())["hooks"]["SessionStart"]), 1) + + def test_failures_report_without_blocking_startup(self): + for failure in ("FAIL_DOWNLOAD", "FAIL_INSTALL", "FAIL_HOOKS"): + with self.subTest(failure=failure): + result = self.run_setup(**{failure: "1"}) + self.assertIn("continuing without blocking", result.stderr) + self.assertFalse(self.settings.exists()) + + def test_invalid_settings_are_not_overwritten(self): + self.settings.parent.mkdir() + self.settings.write_text("invalid json") + result = self.run_setup() + self.assertIn("could not update Claude settings", result.stderr) + self.assertEqual(self.settings.read_text(), "invalid json") + self.assertEqual(list(self.settings.parent.glob(".cloud-settings.*")), []) + + +if __name__ == "__main__": + unittest.main()