Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions .claude/skills/change-the-image/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
---
name: change-the-image
description: Add, upgrade or remove a tool in the sandbox image, or change its layout. Covers where versions live, both architectures, the Lima VM that provision.sh also provisions, what fctl and derived images rely on, the image's size, and verifying a change with a local build or the pull request's Image workflow. Use for any change to Dockerfile, provision.sh or check.sh.
---

# Changing the image

- `provision.sh` installs everything, as root; the Dockerfile only runs it
and sets the user and the PATH. The versions are at the top, as
`NAME="${NAME:-default}"`, so a build can override one.
- `check.sh` is the layout fctl and images built `FROM` this one rely on,
checked in every build.
- README.md lists the tools with their versions, and the layout.

## Adding or upgrading a tool

1. **Install it in `provision.sh`.** Prefer Debian's package: add it to the
`apt-get install` list. Otherwise use the project's own installer or
release archive, with the version pinned at the top of the script, and
check the download against the checksum the project publishes, when it
publishes one. A toolchain agents extend (more versions, targets, global
tools) goes in the agent's home through `as_agent`, like rustup and Bun;
anything else system-wide.
2. **Make it work from the PATH alone.** fctl-agent passes Claude the PATH,
HOME and the locale, nothing else from the image. A tool that needs an
environment variable gets a wrapper or a config file instead. A new PATH
entry is a layout change (below).
3. **Check for it.** Add the command to `check.sh`'s list of tools, and to
the `--version` runs when it could be there but broken (a proxy without
its toolchain).
4. **List it** in README.md's "What's inside", with its version.
5. **Say what it costs.** The Image workflow's summary lists each
architecture's size; put the difference in the pull request.

An upgrade changes the version at the top of `provision.sh` and in README.md.
Rust follows fctl's `rust-toolchain.toml`; Claude Code and fctl aren't pinned
here (the workflow passes the versions it builds with).

Removing a tool breaks images and sessions that use it: a breaking change
(the `commit` skill) with a reason.

## What bites

- **Two architectures.** Every download works on amd64 and arm64: pick the
file with `dpkg --print-architecture` (`amd64`, `arm64`) or `uname -m`
(`x86_64`, `aarch64`).
- **The Lima VM.** fctl's Lima driver runs `provision.sh` in a Debian 12 VM
(Lima's cloud image, not `bookworm-slim`) as root, with `AGENT_USER` set
to the VM's user, who already exists and may not be uid 1000. Use
`$AGENT_USER` and `$AGENT_HOME`, never `agent` or `/home/agent`, and don't
rely on what only the Dockerfile sets.
- **Login shells.** Setup scripts and the Lima driver run `bash -l`, which
reads `/etc/profile.d/fctl.sh`. Keep its PATH and the Dockerfile's
`ENV PATH` the same.
- **apt's lists** are removed after the first installs; a later
`apt-get install` needs its own `apt-get update` and cleanup.
- **Python is externally managed.** No `pip install` into the system: use
`uv tool install` (as the agent) or a Debian package.
- **No auto-updates and no daemons.** fctl turns off Claude Code's
auto-updater, and sessions start only fctl-agent; a tool that expects a
service running won't find one.

## Changing the layout

The user, its home, `~/workspace`, `/opt/fctl/bin` and the PATH are an
interface: installed fctl hosts and images built `FROM` this one rely on
them. Look for a way that keeps them first. If there is none:

1. Make it a breaking change (`feat!:` with a `BREAKING CHANGE:` paragraph).
2. Change `check.sh` and README.md's Layout with it.
3. Change fctl's Sandbox images page (`docs/sandbox-images.md` in
hyperstructure/fctl) to match, and fctl itself when hosts must adapt,
released before this merges. An fctl session's token covers only its own
repository: name what hyperstructure/fctl needs, and tell the person.

The newest fctl release's tag is rebuilt from `main`, so its hosts get the
change the next time they pull the image.

## Verifying

- **With Docker:** `docker build -t sandbox:dev .`, then
`docker run --rm -i sandbox:dev sh -s < check.sh`, and try the tool:
`docker run --rm sandbox:dev bash -lc 'psql --version'`. Add
`--platform linux/amd64` (or `arm64`) to try the other architecture under
emulation, slowly.
- **In an fctl session** (no Docker): open the pull request. The Image
workflow builds both architectures with the latest fctl and Claude Code
and runs `check.sh` in each: `gh pr checks N --watch --fail-fast`, then
the run's summary for the sizes.
- **With fctl:** on a host, `docker build -t sandbox:dev .` and set it as a
project's image (Settings → Sandbox → Image), or
`fctl host image build --source .` from a checkout, which builds it under
the host's image name (with Lima: provisions the base VM). Then start a
session and use the tool.
107 changes: 107 additions & 0 deletions .claude/skills/commit/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
---
name: commit
description: Commit and open pull requests in hyperstructure/sandbox. Covers Conventional Commit titles, breaking changes, what goes in the description, branch names, waiting for the checks, answering reviews, and what happens after the merge. Use when committing, naming a branch, writing a pull request title or description, or getting a change merged.
---

# Commits and pull requests

Every change reaches `main` as one squash-merged pull request. Its **title
becomes the commit subject** and its description the commit body. Merging a
change to the image publishes it (the `publish` skill), so `main` is always
what's published.

## The title

`type(scope): what changed`, at most 72 characters, no full stop, in the
imperative. The Commits workflow checks every title, and again when you
edit it.

| Type | For |
|---|---|
| `feat` | a tool the image didn't have, or a new major version of one |
| `fix` | something broken in the image or in publishing it |
| `perf` | a smaller or faster image with the same tools |
| `docs` | documentation only |
| `build` | other version upgrades |
| `refactor`, `test`, `ci`, `chore`, `revert` | restructuring; checks; workflows; upkeep; reverts |

The scope is optional: `image`, `ci`, `deps`, `docs`.

- `feat: add PostgreSQL's client`
- `build: upgrade Bun to 1.5.0`
- `fix: put Bun on login shells' PATH`
- `ci: rebuild the image when Claude Code moves on`

A **breaking change** changes what fctl or images built `FROM` this one rely
on (the layout in README.md, a tool removed, a file fctl downloads). It gets
a `!` and a `BREAKING CHANGE:` paragraph in the description that says what
people must do: `feat!: drop Node.js 20`. Check with AGENTS.md whether fctl
must change first.

## The description

Plain prose for someone reading `git log` in a year: what was missing or
wrong, what changed and why this way, what it adds to the image's size, and
how you verified it (the Image workflow's run, `check.sh`, a local build).
Link the issue: `Fixes #12` only when the pull request does everything it
asks, `Refs #12` otherwise.

The description ends with the content: no "Generated with …" footers. A link
to the fctl session that made the change is welcome.

## The flow

1. Branch from an up-to-date `main`: `<type>/<topic>` (`feat/postgres`), or
`fctl/<number>-<topic>` when an fctl session makes the branch.
2. Commit as you go; the commits are squashed, so the title is what lasts.
3. Run `shellcheck provision.sh check.sh` and `actionlint` if you have them;
the Lint workflow runs both anyway.
4. Open the pull request with a conventional title and a description. One
change per pull request.
5. Wait for the checks in the same turn and fix failures (below).
6. A person merges it (squash) when the checks pass, unless they asked you
to merge it yourself.

Never push to `main` directly, and never commit secrets: the repository and
the image are public.

## The checks

```sh
gh pr checks N --watch --fail-fast
```

- **Image** builds the pull request's image for amd64 and arm64 with the
latest fctl and Claude Code and runs `check.sh` in each. It runs when the
image's files or the workflow change, and takes a few minutes.
- **Lint** runs ShellCheck and actionlint.
- **Commits** checks the title. When only it fails, fix the title:
`gh pr edit N --title "fix: …"`.

Read a failure with `gh run view ID --log-failed`. Something that looks like
infrastructure (a timeout, a registry or download error) gets one rerun
(`gh run rerun ID --failed`); if it fails again, treat it as real. Don't ask
anyone to merge a pull request with failing checks, and don't weaken a check
to make it pass.

## Reviews

Read the conversation and the comments on lines:

```sh
gh pr view N --comments
gh api repos/hyperstructure/sandbox/pulls/N/comments --jq '.[] | {id, path, line, user: .user.login, body}'
```

Answer every comment in its own thread: fix it, push, and reply with what
changed, or say plainly why not. Leave resolving threads to the reviewer.
After changes, update the description so it still says what the pull
request does: it becomes the commit message.

## After the merge

- Don't push to the merged branch; follow-ups go in a new pull request from
the updated `main`.
- A change to the image starts the Image workflow on `main`. Check that it
published (the `publish` skill), and say so where the change was asked
for.
105 changes: 105 additions & 0 deletions .claude/skills/publish/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
---
name: publish
description: How the sandbox image reaches ghcr.io/hyperstructure/sandbox, and how to rebuild or repair it. Covers what starts the Image workflow (fctl releases, merges, Claude Code releases, the weekly rebuild), its tags and labels, running it by hand, checking a published image with or without Docker, and what to do when a run fails or the schedule stops. Use when asked to publish, rebuild or check the image, after merging a change to it, or when an fctl release has no image.
---

# Publishing the image

The Image workflow (`.github/workflows/image.yml`) publishes
`ghcr.io/hyperstructure/sandbox` from `main` only. Its first job, "What to
build", decides whether there is anything to do and says why in the run's
summary; then each architecture builds on a runner of its own (amd64 on
`ubuntu-24.04`, arm64 on `ubuntu-24.04-arm`), is pushed by digest and
checked with `check.sh`, and the last job tags the two as one image.

## When it runs

| Trigger | Builds the newest fctl release's image when |
|---|---|
| A push to `main` that changes the image | the published one predates the change |
| Every hour (minute 11) | there is none yet (a new fctl release), or it predates a change |
| Every day (05:41 UTC) | as hourly, or Claude Code has a newer release, or the image is a week old |
| `gh workflow run image.yml` | as daily; `-f version=…` for another release, `-f force=true` always |
| A pull request that changes the image | always, with the latest fctl, without publishing |

Every build installs that fctl release from https://fctl.sh/releases and
the newest Claude Code (https://downloads.claude.ai), and passes both
versions to `check.sh`.

## Tags and labels

- `<version>` (`0.4.0`): the release's image. Hosts of that version pull it.
- `<major>.<minor>` (`0.4`): moves only when the build is the newest patch
of its minor. fctl's releases count up one patch at a time and fctl.sh
keeps them all, so the workflow asks fctl.sh for the next patch.
- `latest`: moves only for the newest release.

The image and its index carry `org.opencontainers.image.version` (fctl's
version), `sh.fctl.claude-code.version`, `org.opencontainers.image.revision`
(the commit built) and `org.opencontainers.image.created`. The workflow reads
them back to decide whether an image is current.

## Running it by hand

It takes write access to Actions on this repository (people have it; fctl
sessions don't, so ask a person):

```sh
gh workflow run image.yml -R hyperstructure/sandbox # the newest release, if needed
gh workflow run image.yml -R hyperstructure/sandbox -f version=0.3.0 # another release
gh workflow run image.yml -R hyperstructure/sandbox -f force=true # even when current
gh run list -R hyperstructure/sandbox --workflow image.yml --limit 3
gh run watch -R hyperstructure/sandbox ID --exit-status
```

## Checking what's published

With Docker:

```sh
docker buildx imagetools inspect ghcr.io/hyperstructure/sandbox:0.3.0 # linux/amd64 and linux/arm64
docker buildx imagetools inspect --raw ghcr.io/hyperstructure/sandbox:0.3.0 | jq .annotations
docker run --rm -i ghcr.io/hyperstructure/sandbox:0.3.0 sh -s < check.sh
```

Without Docker (an fctl session), from the registry's API:

```sh
token=$(curl -fsS "https://ghcr.io/token?scope=repository:hyperstructure/sandbox:pull" | jq -r .token)
curl -fsS -H "Authorization: Bearer $token" \
-H "Accept: application/vnd.oci.image.index.v1+json" \
https://ghcr.io/v2/hyperstructure/sandbox/manifests/latest |
jq '{annotations, platforms: [.manifests[].platform | "\(.os)/\(.architecture)"]}'
curl -fsS -H "Authorization: Bearer $token" https://ghcr.io/v2/hyperstructure/sandbox/tags/list
```

## When something fails

Publishing happens only after both architectures passed `check.sh`, so a
failed run leaves the published tags as they were (and an untagged digest
behind, which is harmless).

- **"What to build" fails**: fctl.sh or downloads.claude.ai didn't answer
as expected; the error says what came back. Rerun it later.
- **`check.sh` says the Claude Code version differs**: a Claude Code release
landed between planning and building. Rerun the run.
- **`check.sh` fails otherwise, or the build does**: the image is broken,
often by a change upstream (an installer or a package moved). Fix it in a
pull request (the `change-the-image` skill); its own Image run shows the
fix works.
- **The push is denied**: the package must give this repository write
access (the package's settings → Manage Actions access). It has it when
this workflow created the package.
- **Hosts can't pull it** ("unauthorized", "denied"): the package must be
public. A package starts private when the workflow first creates it; a
person makes it public in the package's settings (Danger zone → Change
visibility), and the organization must allow public packages.
- **The schedule stopped**: GitHub turns off scheduled workflows in a public
repository after 60 days without activity, and emails a warning first.
A person turns it back on with
`gh workflow enable image.yml -R hyperstructure/sandbox`, then runs it by
hand. Meanwhile a new fctl release has no image of its own, and its hosts
start sessions from `latest`.

Never delete a published version that has a tag: hosts pull by tag, and a
broken one is fixed by a rebuild.
3 changes: 3 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# The build needs only provision.sh, which the Dockerfile copies in.
*
!provision.sh
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* text=auto eol=lf
14 changes: 14 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Keeps the workflows' actions current: one pull request a month for all of
# them, titled as a Conventional Commit (ci(deps): …).
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: monthly
groups:
actions:
patterns: ["*"]
commit-message:
prefix: ci
include: scope
37 changes: 37 additions & 0 deletions .github/workflows/commits.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Pull request titles must be Conventional Commits: they become the
# squash-merged commits on main (.claude/skills/commit/SKILL.md). The rules
# are fctl's (xtask/src/release.rs in hyperstructure/fctl); keep them in step.
# Separate from the other checks, so that editing a title reruns only this.
name: Commits

on:
pull_request:
types: [opened, edited, synchronize, reopened]

concurrency:
group: commits-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
conventional:
name: Conventional Commits
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: The pull request title (it becomes the commit on main)
env:
TITLE: ${{ github.event.pull_request.title }}
run: |
types="feat fix perf docs refactor test build ci chore revert"
fail() { echo "::error::$1"; exit 1; }
title=$(sed -E 's/ \(#[0-9]+\)$//' <<<"$TITLE")
shape='^([a-z0-9/-]+)(\([a-z0-9/-]+\))?!?: (.*[^[:space:]].*)$'
[[ $title =~ $shape ]] || fail "\`$title\` is not \`type(scope): what changed\`; types: ${types// /, }"
kind=${BASH_REMATCH[1]} text=${BASH_REMATCH[3]}
[[ " $types " == *" $kind "* ]] || fail "\`$kind\` is not a commit type; use one of: ${types// /, }"
[[ $text != *. ]] || fail "drop the full stop at the end"
(( ${#title} <= 72 )) || fail "keep it to 72 characters (${#title} now); details go in the body"
echo "\`$title\` is a Conventional Commit." >> "$GITHUB_STEP_SUMMARY"
Loading