From 5b9f1ad8cbb9894240c80b72fc3d0e056bbb3743 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 07:39:49 +0000 Subject: [PATCH 1/8] Add SessionStart hook installing Swift via swiftly for web sessions Installs the swift.org Ubuntu 24.04 system deps, then swiftly and the toolchain pinned by .swift-version, and persists the Swift PATH to CLAUDE_ENV_FILE. Skips entirely when swift is already installed or the session is not remote. Not yet verified end-to-end: download.swift.org is currently blocked by the environment network policy and must be allowlisted first. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012gBcWa6xeuaMwdvzSfruns --- .claude/hooks/session-start.sh | 81 ++++++++++++++++++++++++++++++++++ .claude/settings.json | 14 ++++++ 2 files changed, 95 insertions(+) create mode 100755 .claude/hooks/session-start.sh create mode 100644 .claude/settings.json diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh new file mode 100755 index 0000000..1304d1f --- /dev/null +++ b/.claude/hooks/session-start.sh @@ -0,0 +1,81 @@ +#!/bin/bash +set -euo pipefail + +# SessionStart hook: install a Swift toolchain for Claude Code on the web +# (Linux). Only runs in remote sessions; local sessions are untouched. +if [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then + exit 0 +fi + +SWIFTLY_ENV="$HOME/.local/share/swiftly/env.sh" + +persist_path() { + # Make swift available to later Bash commands in the session. + if [ -n "${CLAUDE_ENV_FILE:-}" ]; then + { + echo "export SWIFTLY_HOME_DIR=\"$HOME/.local/share/swiftly\"" + echo "export SWIFTLY_BIN_DIR=\"$HOME/.local/share/swiftly/bin\"" + echo "export PATH=\"$HOME/.local/share/swiftly/bin:\$PATH\"" + } >> "$CLAUDE_ENV_FILE" + fi +} + +# Pick up a swiftly install from a previous (cached) hook run. +if [ -f "$SWIFTLY_ENV" ]; then + # shellcheck disable=SC1090 + . "$SWIFTLY_ENV" +fi + +if command -v swift > /dev/null 2>&1; then + echo "Swift already installed: $(swift --version 2>&1 | head -1)" + if [ -f "$SWIFTLY_ENV" ]; then + persist_path + fi + exit 0 +fi + +# System dependencies for Swift on Ubuntu 24.04 (per swift.org Linux +# instructions), plus curl for fetching swiftly. +export DEBIAN_FRONTEND=noninteractive +apt-get update -qq +apt-get install -y -qq \ + binutils \ + curl \ + git \ + gnupg2 \ + libc6-dev \ + libcurl4-openssl-dev \ + libedit2 \ + libgcc-13-dev \ + libncurses-dev \ + libpython3-dev \ + libsqlite3-0 \ + libstdc++-13-dev \ + libxml2-dev \ + libz3-dev \ + pkg-config \ + tzdata \ + unzip \ + zlib1g-dev + +# Install swiftly non-interactively, then the toolchain pinned by the +# repo's .swift-version (falling back to latest if no pin resolves). +WORKDIR="$(mktemp -d)" +trap 'rm -rf "$WORKDIR"' EXIT +cd "$WORKDIR" +curl -fsSLO "https://download.swift.org/swiftly/linux/swiftly-$(uname -m).tar.gz" +tar zxf "swiftly-$(uname -m).tar.gz" +./swiftly init -y --skip-install + +# shellcheck disable=SC1090 +. "$SWIFTLY_ENV" + +cd "${CLAUDE_PROJECT_DIR:-$PWD}" +if ! swiftly install -y; then + echo "Pinned toolchain install failed; falling back to latest." >&2 + swiftly install -y latest + swiftly use -y latest +fi + +persist_path +swift --version diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..e06b033 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh" + } + ] + } + ] + } +} From 9ee5643121106d4d5474b085b767a33250169839 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 07:48:18 +0000 Subject: [PATCH 2/8] Document Linux/SPM-only builds and web-session Swift setup in CLAUDE.md Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012gBcWa6xeuaMwdvzSfruns --- CLAUDE.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 4b071e9..b00e1cb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,6 +36,10 @@ Both store the same three fields: `baseKey`, a `styles` map (`ConfigKeySource -> - Every source file carries the MIT license header (copyright "Leo Dion" / "BrightDigit"); `Scripts/header.sh` enforces it. New files need it. - `periphery.yml` sets `retain_public: true`, so public API is never flagged as dead code. +## Linux builds + +This repo builds on Linux via SPM only — no Xcode, no Apple SDKs. The `platforms:` list in `Package.swift` applies to Apple platforms only and is ignored on Linux. **No targets are excluded on Linux**: both `ConfigKeyKit` and `ConfigKeyKitTests` build and test there (CI runs them in `swift:` containers). In Claude Code on the web, the SessionStart hook `.claude/hooks/session-start.sh` installs the toolchain via swiftly, pinned by `.swift-version` (requires `download.swift.org` on the environment's network allowlist). Lint tooling (`make lint` via mise) is not installed by the hook. + ## Note `ConfigKeyKit.git/` in the working tree is a bare git repo (a mirror clone), not part of the package — leave it alone. From 44bb0974f2ef4919e99c1065cc650c0d223881b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 07:59:49 +0000 Subject: [PATCH 3/8] Install mise lint tooling from the SessionStart hook Adds a soft-failing mise section after the Swift install: installs mise via mise.run, trusts the repo config, and runs mise install for the pinned swift-format, SwiftLint, and periphery. Lint tooling failure warns loudly but leaves the session able to build and test. Not yet verified end-to-end: mise.run and mise.jdx.dev are blocked by the environment network policy and must be allowlisted first. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012gBcWa6xeuaMwdvzSfruns --- .claude/hooks/session-start.sh | 121 ++++++++++++++++++++------------- CLAUDE.md | 2 +- 2 files changed, 73 insertions(+), 50 deletions(-) diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh index 1304d1f..2f098a7 100755 --- a/.claude/hooks/session-start.sh +++ b/.claude/hooks/session-start.sh @@ -1,25 +1,86 @@ #!/bin/bash set -euo pipefail -# SessionStart hook: install a Swift toolchain for Claude Code on the web -# (Linux). Only runs in remote sessions; local sessions are untouched. +# SessionStart hook: install a Swift toolchain and lint tooling for Claude +# Code on the web (Linux). Only runs in remote sessions; local sessions are +# untouched. if [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then exit 0 fi SWIFTLY_ENV="$HOME/.local/share/swiftly/env.sh" +PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$PWD}" persist_path() { - # Make swift available to later Bash commands in the session. + # Make swift and mise available to later Bash commands in the session. if [ -n "${CLAUDE_ENV_FILE:-}" ]; then { echo "export SWIFTLY_HOME_DIR=\"$HOME/.local/share/swiftly\"" echo "export SWIFTLY_BIN_DIR=\"$HOME/.local/share/swiftly/bin\"" - echo "export PATH=\"$HOME/.local/share/swiftly/bin:\$PATH\"" + echo "export PATH=\"$HOME/.local/share/swiftly/bin:$HOME/.local/bin:\$PATH\"" } >> "$CLAUDE_ENV_FILE" fi } +install_swift() { + # System dependencies for Swift on Ubuntu 24.04 (per swift.org Linux + # instructions), plus curl for fetching swiftly. + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + apt-get install -y -qq \ + binutils \ + curl \ + git \ + gnupg2 \ + libc6-dev \ + libcurl4-openssl-dev \ + libedit2 \ + libgcc-13-dev \ + libncurses-dev \ + libpython3-dev \ + libsqlite3-0 \ + libstdc++-13-dev \ + libxml2-dev \ + libz3-dev \ + pkg-config \ + tzdata \ + unzip \ + zlib1g-dev + + # Install swiftly non-interactively, then the toolchain pinned by the + # repo's .swift-version (falling back to latest if no pin resolves). + local workdir + workdir="$(mktemp -d)" + pushd "$workdir" > /dev/null + curl -fsSLO "https://download.swift.org/swiftly/linux/swiftly-$(uname -m).tar.gz" + tar zxf "swiftly-$(uname -m).tar.gz" + ./swiftly init -y --skip-install + popd > /dev/null + rm -rf "$workdir" + + # shellcheck disable=SC1090 + . "$SWIFTLY_ENV" + + cd "$PROJECT_DIR" + if ! swiftly install -y; then + echo "Pinned toolchain install failed; falling back to latest." >&2 + swiftly install -y latest + swiftly use -y latest + fi +} + +install_lint_tools() { + # Lint tooling (swift-format, SwiftLint, periphery) pinned via mise.toml. + # The spm-backend tools compile from source, so the first run is slow; + # container caching makes later sessions instant. + export PATH="$HOME/.local/bin:$PATH" + if ! command -v mise > /dev/null 2>&1; then + curl -fsSL https://mise.run | sh + fi + mise trust --yes "$PROJECT_DIR/mise.toml" + mise --cd "$PROJECT_DIR" install --yes +} + # Pick up a swiftly install from a previous (cached) hook run. if [ -f "$SWIFTLY_ENV" ]; then # shellcheck disable=SC1090 @@ -28,53 +89,15 @@ fi if command -v swift > /dev/null 2>&1; then echo "Swift already installed: $(swift --version 2>&1 | head -1)" - if [ -f "$SWIFTLY_ENV" ]; then - persist_path - fi - exit 0 +else + install_swift fi -# System dependencies for Swift on Ubuntu 24.04 (per swift.org Linux -# instructions), plus curl for fetching swiftly. -export DEBIAN_FRONTEND=noninteractive -apt-get update -qq -apt-get install -y -qq \ - binutils \ - curl \ - git \ - gnupg2 \ - libc6-dev \ - libcurl4-openssl-dev \ - libedit2 \ - libgcc-13-dev \ - libncurses-dev \ - libpython3-dev \ - libsqlite3-0 \ - libstdc++-13-dev \ - libxml2-dev \ - libz3-dev \ - pkg-config \ - tzdata \ - unzip \ - zlib1g-dev - -# Install swiftly non-interactively, then the toolchain pinned by the -# repo's .swift-version (falling back to latest if no pin resolves). -WORKDIR="$(mktemp -d)" -trap 'rm -rf "$WORKDIR"' EXIT -cd "$WORKDIR" -curl -fsSLO "https://download.swift.org/swiftly/linux/swiftly-$(uname -m).tar.gz" -tar zxf "swiftly-$(uname -m).tar.gz" -./swiftly init -y --skip-install - -# shellcheck disable=SC1090 -. "$SWIFTLY_ENV" - -cd "${CLAUDE_PROJECT_DIR:-$PWD}" -if ! swiftly install -y; then - echo "Pinned toolchain install failed; falling back to latest." >&2 - swiftly install -y latest - swiftly use -y latest +# Lint tooling is secondary to the toolchain: warn loudly on failure but +# leave the session usable for building and testing. +if ! install_lint_tools; then + echo "WARNING: lint tooling install failed; make lint will not work." >&2 + echo "WARNING: swift build/test are unaffected. See errors above." >&2 fi persist_path diff --git a/CLAUDE.md b/CLAUDE.md index b00e1cb..36538f2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,7 +38,7 @@ Both store the same three fields: `baseKey`, a `styles` map (`ConfigKeySource -> ## Linux builds -This repo builds on Linux via SPM only — no Xcode, no Apple SDKs. The `platforms:` list in `Package.swift` applies to Apple platforms only and is ignored on Linux. **No targets are excluded on Linux**: both `ConfigKeyKit` and `ConfigKeyKitTests` build and test there (CI runs them in `swift:` containers). In Claude Code on the web, the SessionStart hook `.claude/hooks/session-start.sh` installs the toolchain via swiftly, pinned by `.swift-version` (requires `download.swift.org` on the environment's network allowlist). Lint tooling (`make lint` via mise) is not installed by the hook. +This repo builds on Linux via SPM only — no Xcode, no Apple SDKs. The `platforms:` list in `Package.swift` applies to Apple platforms only and is ignored on Linux. **No targets are excluded on Linux**: both `ConfigKeyKit` and `ConfigKeyKitTests` build and test there (CI runs them in `swift:` containers). In Claude Code on the web, the SessionStart hook `.claude/hooks/session-start.sh` installs the toolchain via swiftly, pinned by `.swift-version` (requires `download.swift.org` on the environment's network allowlist), plus mise and the pinned lint tools so `make lint` works too. The first container build is slow — swift-format and periphery compile from source — but the result is cached for later sessions. ## Note From f7223c69fd74f7e242b15f7dd7eb2b1021c46bf7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 12:53:11 +0000 Subject: [PATCH 4/8] Install lint tools directly and make the SessionStart hook async mise install cannot run in Claude Code web sessions: the session's GitHub gateway scopes api.github.com to session-attached repos, so mise's release lookups 403 on the tool repos. Install the same pinned versions (parsed from mise.toml) via anonymous public git clones and release-asset downloads instead: SwiftLint as a prebuilt binary, swift-format and periphery built from source. The hook now runs async so the session starts immediately; progress logs to ~/.claude-session-setup.log and ~/.claude-session-setup.done marks completion. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012gBcWa6xeuaMwdvzSfruns --- .claude/hooks/session-start.sh | 95 +++++++++++++++++++++++++++------- CLAUDE.md | 4 +- 2 files changed, 77 insertions(+), 22 deletions(-) diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh index 2f098a7..348a3b7 100755 --- a/.claude/hooks/session-start.sh +++ b/.claude/hooks/session-start.sh @@ -3,24 +3,32 @@ set -euo pipefail # SessionStart hook: install a Swift toolchain and lint tooling for Claude # Code on the web (Linux). Only runs in remote sessions; local sessions are -# untouched. +# untouched. Runs async so the session starts immediately: progress lands in +# ~/.claude-session-setup.log and ~/.claude-session-setup.done marks the end. if [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then exit 0 fi +echo '{"async": true, "asyncTimeout": 2400000}' + +SETUP_LOG="$HOME/.claude-session-setup.log" +SETUP_DONE="$HOME/.claude-session-setup.done" +rm -f "$SETUP_DONE" +exec >> "$SETUP_LOG" 2>&1 + SWIFTLY_ENV="$HOME/.local/share/swiftly/env.sh" PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$PWD}" +TOOLS_BIN="$HOME/.local/bin" -persist_path() { - # Make swift and mise available to later Bash commands in the session. - if [ -n "${CLAUDE_ENV_FILE:-}" ]; then - { - echo "export SWIFTLY_HOME_DIR=\"$HOME/.local/share/swiftly\"" - echo "export SWIFTLY_BIN_DIR=\"$HOME/.local/share/swiftly/bin\"" - echo "export PATH=\"$HOME/.local/share/swiftly/bin:$HOME/.local/bin:\$PATH\"" - } >> "$CLAUDE_ENV_FILE" - fi -} +# Make swift and the lint tools reachable for the session up front; entries +# pointing at not-yet-populated directories are harmless. +if [ -n "${CLAUDE_ENV_FILE:-}" ]; then + { + echo "export SWIFTLY_HOME_DIR=\"$HOME/.local/share/swiftly\"" + echo "export SWIFTLY_BIN_DIR=\"$HOME/.local/share/swiftly/bin\"" + echo "export PATH=\"$HOME/.local/share/swiftly/bin:$TOOLS_BIN:\$PATH\"" + } >> "$CLAUDE_ENV_FILE" +fi install_swift() { # System dependencies for Swift on Ubuntu 24.04 (per swift.org Linux @@ -69,16 +77,63 @@ install_swift() { fi } +# Read a tool's pinned version out of mise.toml so the pins have one source +# of truth shared with CI and local dev. +mise_pin() { + sed -n "s|.*$1\" *= *\"\([^\"]*\)\".*|\1|p" "$PROJECT_DIR/mise.toml" +} + +# Build a SwiftPM executable from a public GitHub repo at a pinned tag and +# drop the binary into TOOLS_BIN. Web sessions cannot use `mise install` +# for this: the session's GitHub gateway scopes api.github.com to repos +# attached to the session, and mise's version resolution 403s on the tool +# repos. Anonymous public git clones and release-asset downloads do work, +# so the hook installs the same pinned versions through those paths. +build_spm_tool() { + local repo="$1" tag="$2" binary="$3" workdir + workdir="$(mktemp -d)" + git clone --depth 1 --branch "$tag" "https://github.com/$repo.git" "$workdir/src" + swift build --package-path "$workdir/src" -c release --product "$binary" + install -m 755 "$workdir/src/.build/release/$binary" "$TOOLS_BIN/$binary" + rm -rf "$workdir" +} + install_lint_tools() { - # Lint tooling (swift-format, SwiftLint, periphery) pinned via mise.toml. - # The spm-backend tools compile from source, so the first run is slow; - # container caching makes later sessions instant. - export PATH="$HOME/.local/bin:$PATH" - if ! command -v mise > /dev/null 2>&1; then - curl -fsSL https://mise.run | sh + local swiftlint_version swift_format_version periphery_version workdir + swiftlint_version="$(mise_pin 'aqua:realm/SwiftLint')" + swift_format_version="$(mise_pin 'spm:swiftlang/swift-format')" + periphery_version="$(mise_pin 'spm:peripheryapp/periphery')" + mkdir -p "$TOOLS_BIN" + export PATH="$TOOLS_BIN:$PATH" + + if command -v swiftlint > /dev/null 2>&1 \ + && [ "$(swiftlint --version)" = "$swiftlint_version" ]; then + echo "SwiftLint $swiftlint_version already installed." + else + workdir="$(mktemp -d)" + curl -fsSL -o "$workdir/swiftlint.zip" \ + "https://github.com/realm/SwiftLint/releases/download/$swiftlint_version/swiftlint_linux_amd64.zip" + unzip -q -o "$workdir/swiftlint.zip" -d "$workdir" + install -m 755 "$workdir/swiftlint" "$TOOLS_BIN/swiftlint" + rm -rf "$workdir" + echo "SwiftLint $swiftlint_version installed." + fi + + if command -v swift-format > /dev/null 2>&1 \ + && swift-format --version | grep -q "$swift_format_version"; then + echo "swift-format $swift_format_version already installed." + else + build_spm_tool "swiftlang/swift-format" "$swift_format_version" "swift-format" + echo "swift-format $swift_format_version installed." + fi + + if command -v periphery > /dev/null 2>&1 \ + && periphery version | grep -q "$periphery_version"; then + echo "periphery $periphery_version already installed." + else + build_spm_tool "peripheryapp/periphery" "$periphery_version" "periphery" + echo "periphery $periphery_version installed." fi - mise trust --yes "$PROJECT_DIR/mise.toml" - mise --cd "$PROJECT_DIR" install --yes } # Pick up a swiftly install from a previous (cached) hook run. @@ -100,5 +155,5 @@ if ! install_lint_tools; then echo "WARNING: swift build/test are unaffected. See errors above." >&2 fi -persist_path swift --version +touch "$SETUP_DONE" diff --git a/CLAUDE.md b/CLAUDE.md index 36538f2..346ae76 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ ConfigKeyKit is a tiny, **dependency-free, Foundation-only** Swift 6.2 library ( - `make lint` — runs `Scripts/lint.sh`: swift-format, SwiftLint, license-header check, and `periphery` dead-code scan - `make clean` -Lint/format tooling is pinned via **mise** (`mise.toml`): swift-format 602.0.0, SwiftLint 0.62.2, periphery 3.7.4. Run `mise install` once so `Scripts/lint.sh` can find them outside CI. `Scripts/lint.sh` is env-driven: `LINT_MODE` (`STRICT` adds `--strict`/`--configuration`; `NONE`/`INSTALL` short-circuit), `FORMAT_ONLY=1` skips lint+build, and outside CI it auto-formats in place before linting. +Lint/format tooling is pinned via **mise** (`mise.toml`): swift-format 602.0.0, SwiftLint 0.62.2, periphery 3.7.4. Run `mise install` once so `Scripts/lint.sh` can find them outside CI (not in Claude Code web sessions — there the SessionStart hook installs the same pinned versions directly, see "Linux builds"). `Scripts/lint.sh` is env-driven: `LINT_MODE` (`STRICT` adds `--strict`/`--configuration`; `NONE`/`INSTALL` short-circuit), `FORMAT_ONLY=1` skips lint+build, and outside CI it auto-formats in place before linting. ## Architecture @@ -38,7 +38,7 @@ Both store the same three fields: `baseKey`, a `styles` map (`ConfigKeySource -> ## Linux builds -This repo builds on Linux via SPM only — no Xcode, no Apple SDKs. The `platforms:` list in `Package.swift` applies to Apple platforms only and is ignored on Linux. **No targets are excluded on Linux**: both `ConfigKeyKit` and `ConfigKeyKitTests` build and test there (CI runs them in `swift:` containers). In Claude Code on the web, the SessionStart hook `.claude/hooks/session-start.sh` installs the toolchain via swiftly, pinned by `.swift-version` (requires `download.swift.org` on the environment's network allowlist), plus mise and the pinned lint tools so `make lint` works too. The first container build is slow — swift-format and periphery compile from source — but the result is cached for later sessions. +This repo builds on Linux via SPM only — no Xcode, no Apple SDKs. The `platforms:` list in `Package.swift` applies to Apple platforms only and is ignored on Linux. **No targets are excluded on Linux**: both `ConfigKeyKit` and `ConfigKeyKitTests` build and test there (CI runs them in `swift:` containers). In Claude Code on the web, the SessionStart hook `.claude/hooks/session-start.sh` installs the toolchain via swiftly, pinned by `.swift-version` (requires `download.swift.org` on the environment's network allowlist), plus the lint tools at the versions pinned in `mise.toml` so `make lint` works too. It installs them directly (SwiftLint prebuilt binary; swift-format and periphery built from source) because `mise install` cannot work in web sessions — the session's GitHub gateway scopes `api.github.com` to session-attached repos, so mise's release lookups 403; mise stays the install path for CI and local dev. The hook runs **async**: the session starts immediately while installs continue in the background, so on a brand-new container `swift` and the lint tools can take several minutes to appear — progress is in `~/.claude-session-setup.log`, and `~/.claude-session-setup.done` marks completion; wait for it before treating a missing tool as an error. Cached containers have everything instantly. ## Note From 275c92fd3ce473b9443b365856d8be7e84a7379a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 13:19:54 +0000 Subject: [PATCH 5/8] Set LINUX_SOURCEKIT_LIB_PATH so SwiftLint works in web sessions SwiftLint dlopens libsourcekitdInProc.so from the Swift toolchain and locates it via LINUX_SOURCEKIT_LIB_PATH; without it, swiftlint crashes on Linux. Resolve the toolchain lib dir after install and persist the export to the session env file. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012gBcWa6xeuaMwdvzSfruns --- .claude/hooks/session-start.sh | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh index 348a3b7..45e8e9c 100755 --- a/.claude/hooks/session-start.sh +++ b/.claude/hooks/session-start.sh @@ -155,5 +155,13 @@ if ! install_lint_tools; then echo "WARNING: swift build/test are unaffected. See errors above." >&2 fi +# SwiftLint on Linux dlopens libsourcekitdInProc.so and finds it through +# LINUX_SOURCEKIT_LIB_PATH; resolve it now that the toolchain exists. +sourcekit_lib="$(find "$HOME/.local/share/swiftly/toolchains" \ + -name libsourcekitdInProc.so -exec dirname {} \; 2> /dev/null | head -1)" +if [ -n "$sourcekit_lib" ] && [ -n "${CLAUDE_ENV_FILE:-}" ]; then + echo "export LINUX_SOURCEKIT_LIB_PATH=\"$sourcekit_lib\"" >> "$CLAUDE_ENV_FILE" +fi + swift --version touch "$SETUP_DONE" From e39698a00712efe67809597a59fbf64d9d87507e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 13:39:27 +0000 Subject: [PATCH 6/8] Shorten web-session cold start: bundled swift-format, no periphery Stop compiling swift-format and periphery from source in the SessionStart hook (~15 min of the cold start). SwiftLint stays eager via its prebuilt release binary; swift-format now resolves to the binary bundled with the Swift toolchain via the swiftly shims; the periphery scan is skipped in web sessions by Scripts/lint.sh (CLAUDE_CODE_REMOTE guard) since it has no Linux binaries and is not worth building there. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012gBcWa6xeuaMwdvzSfruns --- .claude/hooks/session-start.sh | 44 ++++++++-------------------------- CLAUDE.md | 4 ++-- Scripts/lint.sh | 7 +++++- 3 files changed, 18 insertions(+), 37 deletions(-) diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh index 45e8e9c..fd9c989 100755 --- a/.claude/hooks/session-start.sh +++ b/.claude/hooks/session-start.sh @@ -83,26 +83,18 @@ mise_pin() { sed -n "s|.*$1\" *= *\"\([^\"]*\)\".*|\1|p" "$PROJECT_DIR/mise.toml" } -# Build a SwiftPM executable from a public GitHub repo at a pinned tag and -# drop the binary into TOOLS_BIN. Web sessions cannot use `mise install` -# for this: the session's GitHub gateway scopes api.github.com to repos -# attached to the session, and mise's version resolution 403s on the tool -# repos. Anonymous public git clones and release-asset downloads do work, -# so the hook installs the same pinned versions through those paths. -build_spm_tool() { - local repo="$1" tag="$2" binary="$3" workdir - workdir="$(mktemp -d)" - git clone --depth 1 --branch "$tag" "https://github.com/$repo.git" "$workdir/src" - swift build --package-path "$workdir/src" -c release --product "$binary" - install -m 755 "$workdir/src/.build/release/$binary" "$TOOLS_BIN/$binary" - rm -rf "$workdir" -} - +# Install SwiftLint from its prebuilt Linux release binary. Web sessions +# cannot use `mise install` for this: the session's GitHub gateway scopes +# api.github.com to repos attached to the session, and mise's version +# resolution 403s on the tool repos. Anonymous release-asset downloads do +# work, so the hook installs the same pinned version through that path. +# The other lint tools are deliberately NOT installed here: swift-format +# ships inside the Swift toolchain (swiftly proxies it), and periphery is +# skipped in web sessions entirely (Scripts/lint.sh omits the scan when +# CLAUDE_CODE_REMOTE is set), keeping session cold-start fast. install_lint_tools() { - local swiftlint_version swift_format_version periphery_version workdir + local swiftlint_version workdir swiftlint_version="$(mise_pin 'aqua:realm/SwiftLint')" - swift_format_version="$(mise_pin 'spm:swiftlang/swift-format')" - periphery_version="$(mise_pin 'spm:peripheryapp/periphery')" mkdir -p "$TOOLS_BIN" export PATH="$TOOLS_BIN:$PATH" @@ -118,22 +110,6 @@ install_lint_tools() { rm -rf "$workdir" echo "SwiftLint $swiftlint_version installed." fi - - if command -v swift-format > /dev/null 2>&1 \ - && swift-format --version | grep -q "$swift_format_version"; then - echo "swift-format $swift_format_version already installed." - else - build_spm_tool "swiftlang/swift-format" "$swift_format_version" "swift-format" - echo "swift-format $swift_format_version installed." - fi - - if command -v periphery > /dev/null 2>&1 \ - && periphery version | grep -q "$periphery_version"; then - echo "periphery $periphery_version already installed." - else - build_spm_tool "peripheryapp/periphery" "$periphery_version" "periphery" - echo "periphery $periphery_version installed." - fi } # Pick up a swiftly install from a previous (cached) hook run. diff --git a/CLAUDE.md b/CLAUDE.md index 346ae76..25340ad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ ConfigKeyKit is a tiny, **dependency-free, Foundation-only** Swift 6.2 library ( - `make lint` — runs `Scripts/lint.sh`: swift-format, SwiftLint, license-header check, and `periphery` dead-code scan - `make clean` -Lint/format tooling is pinned via **mise** (`mise.toml`): swift-format 602.0.0, SwiftLint 0.62.2, periphery 3.7.4. Run `mise install` once so `Scripts/lint.sh` can find them outside CI (not in Claude Code web sessions — there the SessionStart hook installs the same pinned versions directly, see "Linux builds"). `Scripts/lint.sh` is env-driven: `LINT_MODE` (`STRICT` adds `--strict`/`--configuration`; `NONE`/`INSTALL` short-circuit), `FORMAT_ONLY=1` skips lint+build, and outside CI it auto-formats in place before linting. +Lint/format tooling is pinned via **mise** (`mise.toml`): swift-format 602.0.0, SwiftLint 0.62.2, periphery 3.7.4. Run `mise install` once so `Scripts/lint.sh` can find them outside CI (not in Claude Code web sessions — see "Linux builds" for how tooling works there). `Scripts/lint.sh` is env-driven: `LINT_MODE` (`STRICT` adds `--strict`/`--configuration`; `NONE`/`INSTALL` short-circuit), `FORMAT_ONLY=1` skips lint+build, and outside CI it auto-formats in place before linting. ## Architecture @@ -38,7 +38,7 @@ Both store the same three fields: `baseKey`, a `styles` map (`ConfigKeySource -> ## Linux builds -This repo builds on Linux via SPM only — no Xcode, no Apple SDKs. The `platforms:` list in `Package.swift` applies to Apple platforms only and is ignored on Linux. **No targets are excluded on Linux**: both `ConfigKeyKit` and `ConfigKeyKitTests` build and test there (CI runs them in `swift:` containers). In Claude Code on the web, the SessionStart hook `.claude/hooks/session-start.sh` installs the toolchain via swiftly, pinned by `.swift-version` (requires `download.swift.org` on the environment's network allowlist), plus the lint tools at the versions pinned in `mise.toml` so `make lint` works too. It installs them directly (SwiftLint prebuilt binary; swift-format and periphery built from source) because `mise install` cannot work in web sessions — the session's GitHub gateway scopes `api.github.com` to session-attached repos, so mise's release lookups 403; mise stays the install path for CI and local dev. The hook runs **async**: the session starts immediately while installs continue in the background, so on a brand-new container `swift` and the lint tools can take several minutes to appear — progress is in `~/.claude-session-setup.log`, and `~/.claude-session-setup.done` marks completion; wait for it before treating a missing tool as an error. Cached containers have everything instantly. +This repo builds on Linux via SPM only — no Xcode, no Apple SDKs. The `platforms:` list in `Package.swift` applies to Apple platforms only and is ignored on Linux. **No targets are excluded on Linux**: both `ConfigKeyKit` and `ConfigKeyKitTests` build and test there (CI runs them in `swift:` containers). In Claude Code on the web, the SessionStart hook `.claude/hooks/session-start.sh` installs the toolchain via swiftly, pinned by `.swift-version` (requires `download.swift.org` on the environment's network allowlist), so `make lint` works too — with web-specific tooling: SwiftLint comes from its prebuilt Linux binary at the `mise.toml` pin (`mise install` cannot work in web sessions — the session's GitHub gateway scopes `api.github.com` to session-attached repos, so mise's release lookups 403; mise stays the install path for CI and local dev); swift-format is the one bundled with the Swift toolchain (its version tracks the toolchain, not the `mise.toml` pin — CI strict-lints with the pin, so if formatting disagrees with CI, that drift is why); periphery is not installed, and `Scripts/lint.sh` skips its scan when `CLAUDE_CODE_REMOTE` is set — run periphery locally to catch dead code. The hook runs **async**: the session starts immediately while installs continue in the background, so on a brand-new container `swift` can take a few minutes to appear — progress is in `~/.claude-session-setup.log`, and `~/.claude-session-setup.done` marks completion; wait for it before treating a missing tool as an error. Cached containers have everything instantly. ## Note diff --git a/Scripts/lint.sh b/Scripts/lint.sh index e2e602b..322167c 100755 --- a/Scripts/lint.sh +++ b/Scripts/lint.sh @@ -52,8 +52,13 @@ fi $PACKAGE_DIR/Scripts/header.sh -d $PACKAGE_DIR/Sources -c "Leo Dion" -o "BrightDigit" -p "ConfigKeyKit" -if [ -z "$CI" ]; then +# Periphery does not run in Claude Code web sessions: it would have to be +# built from source there (no Linux binaries, and the session's GitHub +# gateway rules out mise), which is not worth the cold-start cost. +if [ -z "$CI" ] && [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then run_command periphery scan $PERIPHERY_OPTIONS --disable-update-check +elif [ "${CLAUDE_CODE_REMOTE:-}" = "true" ]; then + echo "Skipping periphery scan (Claude Code web session)." fi popd From 29ff8e313a5a2becf2913be9793732aaea137ad1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 13:17:07 +0000 Subject: [PATCH 7/8] Drop lint tooling from the SessionStart hook to cut cold start The hook now installs only the Swift toolchain. SwiftLint was an 89 MB release-asset download on every cold container, and the LINUX_SOURCEKIT_LIB_PATH lookup existed solely to make that binary usable, so both are gone. swift-format still ships inside the toolchain, so formatting and the header check keep working in web sessions. Scripts/lint.sh now skips SwiftLint when CLAUDE_CODE_REMOTE is set, the same way it already skips periphery, so `make lint` stays green in web sessions instead of failing on a missing binary. Run `make lint` locally, where mise supplies the pinned versions, for full coverage. Also: - Quote the hook path in .claude/settings.json so a project directory containing whitespace no longer breaks the SessionStart command. - Only invoke apt when a required package is actually absent, and pass --no-install-recommends. All 18 packages were already in the base image, yet `apt-get update` alone cost ~10s and the install pulled ~20 incidental upgrades. - Drop unzip from the package list; it was only needed for the SwiftLint archive. Removing the unverified SwiftLint download also resolves the CodeRabbit finding about installing that asset without a checksum. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MbfxYJroqogqtp45Zvd5Pn --- .claude/hooks/session-start.sh | 127 +++++++++++++-------------------- .claude/settings.json | 2 +- CLAUDE.md | 2 +- Scripts/lint.sh | 20 +++++- 4 files changed, 69 insertions(+), 82 deletions(-) diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh index fd9c989..34f4daa 100755 --- a/.claude/hooks/session-start.sh +++ b/.claude/hooks/session-start.sh @@ -1,10 +1,16 @@ #!/bin/bash set -euo pipefail -# SessionStart hook: install a Swift toolchain and lint tooling for Claude -# Code on the web (Linux). Only runs in remote sessions; local sessions are -# untouched. Runs async so the session starts immediately: progress lands in +# SessionStart hook: install a Swift toolchain for Claude Code on the web +# (Linux). Only runs in remote sessions; local sessions are untouched. Runs +# async so the session starts immediately: progress lands in # ~/.claude-session-setup.log and ~/.claude-session-setup.done marks the end. +# +# The toolchain is all this installs. Lint tooling is deliberately left out to +# keep cold start short: swift-format ships inside the toolchain, and both +# SwiftLint and periphery are skipped in web sessions (Scripts/lint.sh omits +# them when CLAUDE_CODE_REMOTE is set). Run `make lint` locally, where mise +# provides the pinned versions, to get full coverage. if [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then exit 0 fi @@ -18,42 +24,57 @@ exec >> "$SETUP_LOG" 2>&1 SWIFTLY_ENV="$HOME/.local/share/swiftly/env.sh" PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$PWD}" -TOOLS_BIN="$HOME/.local/bin" -# Make swift and the lint tools reachable for the session up front; entries -# pointing at not-yet-populated directories are harmless. +# Make swift reachable for the session up front; an entry pointing at a +# not-yet-populated directory is harmless. if [ -n "${CLAUDE_ENV_FILE:-}" ]; then { echo "export SWIFTLY_HOME_DIR=\"$HOME/.local/share/swiftly\"" echo "export SWIFTLY_BIN_DIR=\"$HOME/.local/share/swiftly/bin\"" - echo "export PATH=\"$HOME/.local/share/swiftly/bin:$TOOLS_BIN:\$PATH\"" + echo "export PATH=\"$HOME/.local/share/swiftly/bin:\$PATH\"" } >> "$CLAUDE_ENV_FILE" fi install_swift() { # System dependencies for Swift on Ubuntu 24.04 (per swift.org Linux - # instructions), plus curl for fetching swiftly. - export DEBIAN_FRONTEND=noninteractive - apt-get update -qq - apt-get install -y -qq \ - binutils \ - curl \ - git \ - gnupg2 \ - libc6-dev \ - libcurl4-openssl-dev \ - libedit2 \ - libgcc-13-dev \ - libncurses-dev \ - libpython3-dev \ - libsqlite3-0 \ - libstdc++-13-dev \ - libxml2-dev \ - libz3-dev \ - pkg-config \ - tzdata \ - unzip \ + # instructions), plus curl for fetching swiftly. Most are already in the + # base image, so only reach for apt when something is genuinely missing -- + # `apt-get update` alone costs ~10s. + local packages missing pkg + packages=( + binutils + curl + git + gnupg2 + libc6-dev + libcurl4-openssl-dev + libedit2 + libgcc-13-dev + libncurses-dev + libpython3-dev + libsqlite3-0 + libstdc++-13-dev + libxml2-dev + libz3-dev + pkg-config + tzdata zlib1g-dev + ) + missing=() + for pkg in "${packages[@]}"; do + if [ "$(dpkg-query -W -f='${db:Status-Status}' "$pkg" 2> /dev/null)" != "installed" ]; then + missing+=("$pkg") + fi + done + + if [ "${#missing[@]}" -gt 0 ]; then + echo "Installing missing system packages: ${missing[*]}" + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + apt-get install -y -qq --no-install-recommends "${missing[@]}" + else + echo "All system packages already present; skipping apt." + fi # Install swiftly non-interactively, then the toolchain pinned by the # repo's .swift-version (falling back to latest if no pin resolves). @@ -77,41 +98,6 @@ install_swift() { fi } -# Read a tool's pinned version out of mise.toml so the pins have one source -# of truth shared with CI and local dev. -mise_pin() { - sed -n "s|.*$1\" *= *\"\([^\"]*\)\".*|\1|p" "$PROJECT_DIR/mise.toml" -} - -# Install SwiftLint from its prebuilt Linux release binary. Web sessions -# cannot use `mise install` for this: the session's GitHub gateway scopes -# api.github.com to repos attached to the session, and mise's version -# resolution 403s on the tool repos. Anonymous release-asset downloads do -# work, so the hook installs the same pinned version through that path. -# The other lint tools are deliberately NOT installed here: swift-format -# ships inside the Swift toolchain (swiftly proxies it), and periphery is -# skipped in web sessions entirely (Scripts/lint.sh omits the scan when -# CLAUDE_CODE_REMOTE is set), keeping session cold-start fast. -install_lint_tools() { - local swiftlint_version workdir - swiftlint_version="$(mise_pin 'aqua:realm/SwiftLint')" - mkdir -p "$TOOLS_BIN" - export PATH="$TOOLS_BIN:$PATH" - - if command -v swiftlint > /dev/null 2>&1 \ - && [ "$(swiftlint --version)" = "$swiftlint_version" ]; then - echo "SwiftLint $swiftlint_version already installed." - else - workdir="$(mktemp -d)" - curl -fsSL -o "$workdir/swiftlint.zip" \ - "https://github.com/realm/SwiftLint/releases/download/$swiftlint_version/swiftlint_linux_amd64.zip" - unzip -q -o "$workdir/swiftlint.zip" -d "$workdir" - install -m 755 "$workdir/swiftlint" "$TOOLS_BIN/swiftlint" - rm -rf "$workdir" - echo "SwiftLint $swiftlint_version installed." - fi -} - # Pick up a swiftly install from a previous (cached) hook run. if [ -f "$SWIFTLY_ENV" ]; then # shellcheck disable=SC1090 @@ -124,20 +110,5 @@ else install_swift fi -# Lint tooling is secondary to the toolchain: warn loudly on failure but -# leave the session usable for building and testing. -if ! install_lint_tools; then - echo "WARNING: lint tooling install failed; make lint will not work." >&2 - echo "WARNING: swift build/test are unaffected. See errors above." >&2 -fi - -# SwiftLint on Linux dlopens libsourcekitdInProc.so and finds it through -# LINUX_SOURCEKIT_LIB_PATH; resolve it now that the toolchain exists. -sourcekit_lib="$(find "$HOME/.local/share/swiftly/toolchains" \ - -name libsourcekitdInProc.so -exec dirname {} \; 2> /dev/null | head -1)" -if [ -n "$sourcekit_lib" ] && [ -n "${CLAUDE_ENV_FILE:-}" ]; then - echo "export LINUX_SOURCEKIT_LIB_PATH=\"$sourcekit_lib\"" >> "$CLAUDE_ENV_FILE" -fi - swift --version touch "$SETUP_DONE" diff --git a/.claude/settings.json b/.claude/settings.json index e06b033..6738f06 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh" + "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh\"" } ] } diff --git a/CLAUDE.md b/CLAUDE.md index 25340ad..646f1d7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,7 +38,7 @@ Both store the same three fields: `baseKey`, a `styles` map (`ConfigKeySource -> ## Linux builds -This repo builds on Linux via SPM only — no Xcode, no Apple SDKs. The `platforms:` list in `Package.swift` applies to Apple platforms only and is ignored on Linux. **No targets are excluded on Linux**: both `ConfigKeyKit` and `ConfigKeyKitTests` build and test there (CI runs them in `swift:` containers). In Claude Code on the web, the SessionStart hook `.claude/hooks/session-start.sh` installs the toolchain via swiftly, pinned by `.swift-version` (requires `download.swift.org` on the environment's network allowlist), so `make lint` works too — with web-specific tooling: SwiftLint comes from its prebuilt Linux binary at the `mise.toml` pin (`mise install` cannot work in web sessions — the session's GitHub gateway scopes `api.github.com` to session-attached repos, so mise's release lookups 403; mise stays the install path for CI and local dev); swift-format is the one bundled with the Swift toolchain (its version tracks the toolchain, not the `mise.toml` pin — CI strict-lints with the pin, so if formatting disagrees with CI, that drift is why); periphery is not installed, and `Scripts/lint.sh` skips its scan when `CLAUDE_CODE_REMOTE` is set — run periphery locally to catch dead code. The hook runs **async**: the session starts immediately while installs continue in the background, so on a brand-new container `swift` can take a few minutes to appear — progress is in `~/.claude-session-setup.log`, and `~/.claude-session-setup.done` marks completion; wait for it before treating a missing tool as an error. Cached containers have everything instantly. +This repo builds on Linux via SPM only — no Xcode, no Apple SDKs. The `platforms:` list in `Package.swift` applies to Apple platforms only and is ignored on Linux. **No targets are excluded on Linux**: both `ConfigKeyKit` and `ConfigKeyKitTests` build and test there (CI runs them in `swift:` containers). In Claude Code on the web, the SessionStart hook `.claude/hooks/session-start.sh` installs the Swift toolchain via swiftly, pinned by `.swift-version` (requires `download.swift.org` on the environment's network allowlist). The toolchain is **all** it installs — no lint tooling — to keep cold start short. `make lint` still runs there, but only partially: swift-format is the one bundled with the Swift toolchain (its version tracks the toolchain, not the `mise.toml` pin — CI strict-lints with the pin, so if formatting disagrees with CI, that drift is why) and the license-header check runs as usual, while SwiftLint and periphery are not installed and `Scripts/lint.sh` skips both when `CLAUDE_CODE_REMOTE` is set. Run `make lint` locally, where mise supplies the pinned versions, to catch SwiftLint violations and dead code before CI does. (`mise install` cannot work in web sessions anyway — the session's GitHub gateway scopes `api.github.com` to session-attached repos, so mise's release lookups 403; mise stays the install path for CI and local dev.) The hook runs **async**: the session starts immediately while installs continue in the background, so on a brand-new container `swift` can take a few minutes to appear — progress is in `~/.claude-session-setup.log`, and `~/.claude-session-setup.done` marks completion; wait for it before treating a missing tool as an error. Cached containers have everything instantly. ## Note diff --git a/Scripts/lint.sh b/Scripts/lint.sh index 322167c..188b5a0 100755 --- a/Scripts/lint.sh +++ b/Scripts/lint.sh @@ -37,16 +37,32 @@ else SWIFTLINT_OPTIONS="" fi +# SwiftLint is not installed in Claude Code web sessions: the SessionStart +# hook installs only the Swift toolchain so cold start stays short. swift-format +# ships inside that toolchain, so formatting, the header check and the build +# still run there. +if [ "${CLAUDE_CODE_REMOTE:-}" = "true" ]; then + RUN_SWIFTLINT=0 +else + RUN_SWIFTLINT=1 +fi + pushd $PACKAGE_DIR if [ -z "$CI" ]; then run_command swift-format format $SWIFTFORMAT_OPTIONS --recursive --parallel --in-place Sources Tests - run_command swiftlint --fix + if [ "$RUN_SWIFTLINT" -eq 1 ]; then + run_command swiftlint --fix + fi fi if [ -z "$FORMAT_ONLY" ]; then run_command swift-format lint --configuration .swift-format --recursive --parallel $SWIFTFORMAT_OPTIONS Sources Tests - run_command swiftlint lint $SWIFTLINT_OPTIONS + if [ "$RUN_SWIFTLINT" -eq 1 ]; then + run_command swiftlint lint $SWIFTLINT_OPTIONS + else + echo "Skipping SwiftLint (Claude Code web session)." + fi run_command swift build --build-tests fi From 272e83b7d5e8b4f484bb8e71a3a1606ccdfeb594 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 13:56:48 +0000 Subject: [PATCH 8/8] Move the Swift toolchain install into a cached setup script Cloud environments run a setup script once, snapshot the filesystem, and reuse that snapshot for later sessions, which skip the script entirely. SessionStart hooks get no such caching -- they re-run on every session. The toolchain is a ~1 GB download, so installing it from the hook meant paying for it on every cold container. Add Scripts/cloud-setup.sh as the first tier: it is pasted into the environment's Setup script field, and is committed here so the content stays reviewable and versioned even though the platform reads it from that dialog. It cannot assume the repository is checked out, so it resolves the pinned version from .swift-version across the likely checkout paths and falls back to a literal kept in sync with that file. It always exits zero, since a non-zero exit stops the session from starting, and it drops a /etc/profile.d entry so plain login shells resolve swift too. The SessionStart hook stays as the second tier. It is still needed on every session: a snapshot restores files but not environment variables, so PATH has to be re-exported into CLAUDE_ENV_FILE each time, and an environment with no setup script configured still needs the toolchain installed from somewhere. With the snapshot in place its `command -v swift` check short-circuits in ~240ms. Also correct two claims in CLAUDE.md. The lint job in ConfigKeyKit.yml sets no LINT_MODE, so Scripts/lint.sh takes its default branch in CI and SwiftLint runs *without* --strict -- the previous "CI strict-lints with the pin" was wrong. Document the two-tier setup and the download.swift.org allowlist requirement. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MbfxYJroqogqtp45Zvd5Pn --- .claude/hooks/session-start.sh | 13 +++ CLAUDE.md | 2 +- Scripts/cloud-setup.sh | 187 +++++++++++++++++++++++++++++++++ 3 files changed, 201 insertions(+), 1 deletion(-) create mode 100755 Scripts/cloud-setup.sh diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh index 34f4daa..90ff739 100755 --- a/.claude/hooks/session-start.sh +++ b/.claude/hooks/session-start.sh @@ -11,6 +11,19 @@ set -euo pipefail # SwiftLint and periphery are skipped in web sessions (Scripts/lint.sh omits # them when CLAUDE_CODE_REMOTE is set). Run `make lint` locally, where mise # provides the pinned versions, to get full coverage. +# +# This hook is the second tier of a two-tier setup. The first tier is +# Scripts/cloud-setup.sh, pasted into the cloud environment's "Setup script" +# field: it runs once, then the filesystem is snapshotted and later sessions +# reuse it, so the ~1 GB toolchain download happens once per environment +# instead of once per container. When that snapshot exists, the `command -v +# swift` check below short-circuits and this hook finishes in about a second. +# +# The hook is still required on every session for two reasons: a snapshot +# restores files but not environment variables, so PATH has to be re-exported +# into CLAUDE_ENV_FILE each time; and an environment with no setup script +# configured (a fresh clone, another contributor) still needs the toolchain +# installed from here. if [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then exit 0 fi diff --git a/CLAUDE.md b/CLAUDE.md index 646f1d7..4fcf486 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,7 +38,7 @@ Both store the same three fields: `baseKey`, a `styles` map (`ConfigKeySource -> ## Linux builds -This repo builds on Linux via SPM only — no Xcode, no Apple SDKs. The `platforms:` list in `Package.swift` applies to Apple platforms only and is ignored on Linux. **No targets are excluded on Linux**: both `ConfigKeyKit` and `ConfigKeyKitTests` build and test there (CI runs them in `swift:` containers). In Claude Code on the web, the SessionStart hook `.claude/hooks/session-start.sh` installs the Swift toolchain via swiftly, pinned by `.swift-version` (requires `download.swift.org` on the environment's network allowlist). The toolchain is **all** it installs — no lint tooling — to keep cold start short. `make lint` still runs there, but only partially: swift-format is the one bundled with the Swift toolchain (its version tracks the toolchain, not the `mise.toml` pin — CI strict-lints with the pin, so if formatting disagrees with CI, that drift is why) and the license-header check runs as usual, while SwiftLint and periphery are not installed and `Scripts/lint.sh` skips both when `CLAUDE_CODE_REMOTE` is set. Run `make lint` locally, where mise supplies the pinned versions, to catch SwiftLint violations and dead code before CI does. (`mise install` cannot work in web sessions anyway — the session's GitHub gateway scopes `api.github.com` to session-attached repos, so mise's release lookups 403; mise stays the install path for CI and local dev.) The hook runs **async**: the session starts immediately while installs continue in the background, so on a brand-new container `swift` can take a few minutes to appear — progress is in `~/.claude-session-setup.log`, and `~/.claude-session-setup.done` marks completion; wait for it before treating a missing tool as an error. Cached containers have everything instantly. +This repo builds on Linux via SPM only — no Xcode, no Apple SDKs. The `platforms:` list in `Package.swift` applies to Apple platforms only and is ignored on Linux. **No targets are excluded on Linux**: both `ConfigKeyKit` and `ConfigKeyKitTests` build and test there (CI runs them in `swift:` containers). In Claude Code on the web, Swift is installed in two tiers. **Tier 1** is `Scripts/cloud-setup.sh`, pasted into the cloud environment's **Setup script** field at claude.ai/code: it runs once per environment, after which the filesystem is snapshotted and later sessions skip it, so the ~1 GB toolchain download happens once per environment rather than once per container. (That cache rebuilds when the script or the allowed-domains list changes, or after roughly seven days.) **Tier 2** is the SessionStart hook `.claude/hooks/session-start.sh`, which re-exports `PATH` into `CLAUDE_ENV_FILE` on every session — a snapshot restores files, not environment variables — and installs the toolchain itself when an environment has no setup script configured. Both tiers resolve the version from `.swift-version`, and both need `download.swift.org` on the environment's allowed-domains list (**Network access: Custom**, with the default package-manager list included). The toolchain is **all** either tier installs — no lint tooling — to keep cold start short. `make lint` still runs there, but only partially: swift-format is the one bundled with the Swift toolchain (its version tracks the toolchain, not the `mise.toml` pin, so if formatting disagrees with CI that drift is why) and the license-header check runs as usual, while SwiftLint and periphery are not installed and `Scripts/lint.sh` skips both when `CLAUDE_CODE_REMOTE` is set. Run `make lint` locally, where mise supplies the pinned versions, to catch SwiftLint violations and dead code before CI does. (`mise install` cannot work in web sessions anyway — the session's GitHub gateway scopes `api.github.com` to session-attached repos, so mise's release lookups 403; mise stays the install path for CI and local dev.) Note CI's `lint` job sets no `LINT_MODE`, so `Scripts/lint.sh` takes its default branch there: swift-format runs with `--configuration .swift-format` and SwiftLint runs **without** `--strict`. The hook runs **async**: the session starts immediately while installs continue in the background, so on a brand-new container `swift` can take a few minutes to appear — progress is in `~/.claude-session-setup.log`, and `~/.claude-session-setup.done` marks completion; wait for it before treating a missing tool as an error. Cached containers have everything instantly. ## Note diff --git a/Scripts/cloud-setup.sh b/Scripts/cloud-setup.sh new file mode 100755 index 0000000..75fb2ae --- /dev/null +++ b/Scripts/cloud-setup.sh @@ -0,0 +1,187 @@ +#!/bin/bash + +# Setup script for Claude Code on the web (cloud environments). +# +# Paste this into the environment dialog's "Setup script" field at +# claude.ai/code. It is committed here so the content stays reviewable and +# versioned, but the platform reads it from that dialog, not from the repo. +# +# Why here and not in the SessionStart hook: a setup script runs once per +# environment, then Anthropic snapshots the filesystem and reuses that snapshot +# for later sessions, which skip the script entirely. SessionStart hooks re-run +# on every session and get no such caching. The Swift toolchain is a ~1 GB +# download, so it belongs in the snapshot. +# +# .claude/hooks/session-start.sh stays as the fallback: it installs the same +# toolchain when an environment has no setup script configured, and on every +# session it wires PATH into CLAUDE_ENV_FILE (a filesystem snapshot restores +# files, not environment variables). +# +# Requirements this script is written around: +# * Must exit 0 -- a non-zero exit makes the session fail to start. +# * Must finish inside ~5 minutes or the environment cache will not build. +# Measured cold install is ~2 minutes. +# * Runs as root on Ubuntu 24.04, before Claude Code launches. +# * Needs download.swift.org on the environment's allowed-domains list +# (Network access: Custom, with the default package-manager list included). + +# No `set -e`: every failure path has to fall through to `exit 0` so a bad +# install degrades to the SessionStart hook rather than bricking the session. +set -uo pipefail + +# Used only when no .swift-version can be found on disk. Keep in sync with the +# repo's .swift-version. +FALLBACK_SWIFT_VERSION="6.3.2" + +SWIFTLY_ENV="$HOME/.local/share/swiftly/env.sh" + +log() { + # stderr, not stdout: resolve_swift_version's value is read via command + # substitution, so any stdout chatter would be captured into the version. + echo "[cloud-setup] $*" >&2 +} + +# The setup script may run before the repository is checked out, and the +# checkout path is not contractual, so look in the likely places and fall back +# to the pinned literal above rather than failing. +resolve_swift_version() { + local candidate + for candidate in \ + "${CLAUDE_PROJECT_DIR:-/nonexistent}/.swift-version" \ + "$PWD/.swift-version" \ + /home/user/*/.swift-version \ + /workspace/*/.swift-version \ + /root/*/.swift-version; do + if [ -f "$candidate" ]; then + local version + version="$(tr -d '[:space:]' < "$candidate")" + if [ -n "$version" ]; then + log "Using Swift $version pinned by $candidate" + printf '%s' "$version" + return 0 + fi + fi + done + log "No .swift-version found; falling back to Swift $FALLBACK_SWIFT_VERSION" + printf '%s' "$FALLBACK_SWIFT_VERSION" +} + +# System dependencies for Swift on Ubuntu 24.04 (per swift.org's Linux +# instructions), plus curl for fetching swiftly. Most are already in the base +# image, so only reach for apt when something is genuinely missing: apt-get +# update alone costs ~10s and pulls in unrelated upgrades. +install_system_packages() { + local packages missing pkg + packages=( + binutils + curl + git + gnupg2 + libc6-dev + libcurl4-openssl-dev + libedit2 + libgcc-13-dev + libncurses-dev + libpython3-dev + libsqlite3-0 + libstdc++-13-dev + libxml2-dev + libz3-dev + pkg-config + tzdata + zlib1g-dev + ) + missing=() + for pkg in "${packages[@]}"; do + if [ "$(dpkg-query -W -f='${db:Status-Status}' "$pkg" 2> /dev/null)" != "installed" ]; then + missing+=("$pkg") + fi + done + + if [ "${#missing[@]}" -eq 0 ]; then + log "All system packages already present; skipping apt." + return 0 + fi + + log "Installing missing system packages: ${missing[*]}" + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq || return 1 + apt-get install -y -qq --no-install-recommends "${missing[@]}" || return 1 +} + +install_swiftly() { + local workdir + workdir="$(mktemp -d)" || return 1 + ( + cd "$workdir" || exit 1 + curl -fsSLO "https://download.swift.org/swiftly/linux/swiftly-$(uname -m).tar.gz" || exit 1 + tar zxf "swiftly-$(uname -m).tar.gz" || exit 1 + ./swiftly init -y --skip-install || exit 1 + ) + local status=$? + rm -rf "$workdir" + return "$status" +} + +# Make swift resolvable for plain login shells too. This is a file, so the +# environment snapshot carries it; the SessionStart hook still handles +# CLAUDE_ENV_FILE for Claude Code's own process. +write_profile_entry() { + cat > /etc/profile.d/swiftly.sh <<'PROFILE' +# Added by ConfigKeyKit Scripts/cloud-setup.sh +export SWIFTLY_HOME_DIR="$HOME/.local/share/swiftly" +export SWIFTLY_BIN_DIR="$HOME/.local/share/swiftly/bin" +case ":$PATH:" in + *":$SWIFTLY_BIN_DIR:"*) ;; + *) export PATH="$SWIFTLY_BIN_DIR:$PATH" ;; +esac +PROFILE +} + +main() { + if [ -f "$SWIFTLY_ENV" ]; then + # shellcheck disable=SC1090 + . "$SWIFTLY_ENV" + fi + + if command -v swift > /dev/null 2>&1; then + log "Swift already installed: $(swift --version 2>&1 | head -1)" + write_profile_entry + return 0 + fi + + local version + version="$(resolve_swift_version)" + + install_system_packages || { + log "WARNING: system package install failed; continuing anyway." + } + + install_swiftly || { + log "ERROR: swiftly install failed." + return 1 + } + + # shellcheck disable=SC1090 + . "$SWIFTLY_ENV" || return 1 + + if ! swiftly install -y "$version"; then + log "Pinned toolchain $version failed to install; falling back to latest." + swiftly install -y latest || return 1 + swiftly use -y latest || return 1 + else + swiftly use -y "$version" || return 1 + fi + + write_profile_entry + swift --version +} + +if main; then + log "Setup complete." +else + log "Setup did not complete; the SessionStart hook will install Swift instead." +fi + +# Always succeed: a non-zero exit here stops the session from starting. +exit 0