diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh new file mode 100755 index 0000000..90ff739 --- /dev/null +++ b/.claude/hooks/session-start.sh @@ -0,0 +1,127 @@ +#!/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. 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. +# +# 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 + +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}" + +# 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:\$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. 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). + 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 +} + +# 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)" +else + install_swift +fi + +swift --version +touch "$SETUP_DONE" diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..6738f06 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh\"" + } + ] + } + ] + } +} diff --git a/CLAUDE.md b/CLAUDE.md index 4b071e9..4fcf486 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 — 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 @@ -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, 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 `ConfigKeyKit.git/` in the working tree is a bare git repo (a mirror clone), not part of the package — leave it alone. 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 diff --git a/Scripts/lint.sh b/Scripts/lint.sh index e2e602b..188b5a0 100755 --- a/Scripts/lint.sh +++ b/Scripts/lint.sh @@ -37,23 +37,44 @@ 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 $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