Skip to content
Draft
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
108 changes: 108 additions & 0 deletions .claude/skills/add-level/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
---
name: add-level
description: Write, change, move or remove a ShellGame level or section (a `Level { .. ..LEVEL }` struct literal in src/levels/sNN_<section>.rs) and verify it with the course validation and the bash and fish replay. Use when asked to add a level, fix a level's text, hints, answer, checks, cd policy or solution, reorder levels, or add a section.
---

# Add or change a level

The format and the rules (positions, Czech text, hints, no leaked answers, solutions, vocabulary) are in
`AGENTS.md`, section "Levels". Read it first; this skill is the procedure.

## 1. Find the place and the position

- Sections are `src/levels/sNN_<name>.rs`, numbered by position (`s00_welcome.rs` … `s11_search.rs`) and
listed in that order in `COURSE` in `src/levels/mod.rs`; moving a section means renaming its file. Section S works in `<workspace>/level-S`.
- Read the section's goals block (the `//!` comment at the top of its file) before touching a level. The
level must serve one of its learning objectives and must not ask the player for anything under *Leaves
for later*. A change that widens or narrows what the section teaches updates the block in the same change
(new commands go under *Introduces*, a borrowed later tool under *Relies on*).
- Read the levels around the spot and copy their tone, difficulty and use of the vocabulary. Address the
player in the formal plural like the existing levels ("Přejděte", "Vytvořte").
- Appending to a section's `levels` list shifts nothing. Inserting, moving or removing a level (or a
section) shifts the later positions "S.L" and therefore later saved games. That is accepted; say so in
your report and in the commit message.

## 2. Decide what makes the level "solved"

- A discovery the player reports (a name, a content, a count, a size): `answer:` with `exact`, `one_of`,
`int`, `suffix` or `parts`. A missing answer gets the default "Musíte zadat odpověď"; add
`.format("...")` only to show the shape of a `parts` answer or how to submit. Judging drops trailing
slashes (Tab completion adds them), so an answer never ends in one.
- Something the player must do to files or location: `check: &[..]` (`at`, `is_file`, `no_file`,
`content_is`, `moved`, `mode_is`, ...). Checks run before the answer, so a correct answer never
bypasses them. When the files are the real answer, the level has no answer: the text and the solution
say a bare `shellgame submit`, never a label.
- Both, when the player must do something and report what they found.
- `cd: &[..]` only for a level that practises one exact movement command; then add `cd_done()` to `check`.
The engine keeps a strict level from trapping the player (moving back to the start directory is always
allowed) and a course test checks that every `cd` list blocks something.

## 3. Write the literal

Copy the shape from AGENTS.md or a neighbour. Fields: `title`, `text`, `hints`, `start`, `setup`,
`answer`, `check`, `cd`, `solution`, `success`, then `..LEVEL` (a literal that sets every field leaves it
out: clippy rejects a needless one).

- `text` and `solution` are indented raw strings: content starts on the line after `r#"`, spaces only.
- Paths, `start` included, are relative to the section directory (`level-S`); a level starts there unless
`start` names a directory below it. Everything the level needs is in its own `setup`: no shared files.
- `setup`: everything the level needs; `clean` what the player may create; `.mode(..)` for anything whose
permissions the level grades. It must survive `chmod 000` and similar, because `reset` re-applies it.
Multi-line file contents are indented blocks like `text`; `file` adds the final newline.
- Text: goal, numbered steps, how to submit. Commands in backticks, navigation relative to the start
directory, no user names. A level that teaches bash-only syntax says so, and offers fish players
`bash -c '...'`.
- Hints (plain text, printed literally): the idea, then the shape of the command, then the command.
Hint 1 is never the whole solution; for discovery tasks hints explain how to find the answer.
- Replies (`.error`, `.mistakes`, `.format`, check messages): short, teach without revealing. Never
contain the answer, and a failed `at(..)` must not name the target directory.
- `success`: say what the command did, not praise.
- Custom logic (maze, generated files, stateful `cd` drills) is a plain `fn` at the bottom of the same
section file, referenced from the literal.

## 4. Write the solution

What a student would type, one command per line, from the start directory, ending in
`shellgame submit [answer]`. Derive the answer with the commands the level teaches. Keep it valid in bash
and fish (`"$(...)"`, `"$PWD"`; no `[[ ]]`, `$'..'`, `local`, `export X=1`), non-interactive, and
independent of umask (`chmod u+x`, not numbers). Bash-only syntax that the level teaches goes through
`bash -c '...'`, so the solution still runs in fish.

## 5. Verify

```bash
cargo fmt
cargo test # course validation over every level, plus all other suites
cargo test --test e2e -- solutions::3.2:: # replay one level (here 3.2) in bash and fish, fresh and re-prepared
cargo clippy --all-targets -- -D warnings
RUSTDOCFLAGS="-D warnings" cargo doc --no-deps # the goals blocks and their links between sections
```

If a validation test fails, it is usually right. Typical causes: an answer inside an error, mistake or
format text; an answer that ends in `/`; a solution that does not end in `shellgame submit`; a level
with neither answer nor check; tabs or text on the first line of an indented raw string; an absolute
or `..` path; a `level-N` or `Sekce N` mention that is not its own section's number (another section is
named by its title).

## 6. After moving, inserting or removing a level or section

Positions shift, so re-read the texts that mention them:

```bash
grep -nE 'level-[0-9]+|Sekc[ei] [0-9]+' src/levels/*.rs
```

Every hit, in texts, hints, answers (an answer can be `level-1`) and solutions, must match the position
of its own section; a test enforces this for texts. Another section is named by its title, never by
number, and a level in a comment likewise. Do not use blind search-and-replace: these literals are
shared. A new section is a new file `sNN_<name>.rs` with a `pub const SECTION: Section` (all four
fields), declared and listed at its position in `src/levels/mod.rs`. Its `intro` follows the neighbours:
the concept, a mental model, the commands introduced, pages split by `---`, and no heading at the top
(the panel title shows "Sekce N: <title>"). Every later section moves one position down: rename its
file, and its `level-N` directory changes too. A new section file starts with its goals block (copy a
neighbour's shape, link other sections as `[navigation](super::s01_navigation)`); after a rename or a
move fix those links and the `pub mod` lines, and let `cargo doc` (step 5) flag every stale one.

## 7. Look at it

Play the level the way a student does with the `playtest` skill; `shellgame resume S.L` takes you to it.
99 changes: 99 additions & 0 deletions .claude/skills/playtest/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
---
name: playtest
description: Play ShellGame by hand in a throwaway environment (fake HOME, state directory and workspace) in bash and fish, checking intro paging, hints, wrong answers, strict cd levels, resume, levels, reset and the sandbox. Use after changing levels, the UI, the shell scripts, the sandbox or the CLI, before a release, or to see a level the way a student does.
---

# Playtest in a throwaway environment

Never play against your own save or your real `$HOME`: the checks below change and delete files on
purpose. The snippets are bash. Run them from the repository root in an ordinary terminal, not inside a
game shell.

## 1. Build and set up

```bash
cargo build
BIN="$PWD/target/debug/shellgame"
T=$(mktemp -d /var/tmp/shellgame-playtest.XXXXXX) # outside /tmp: the sandbox must have something to protect
mkdir -p "$T/home" "$T/xdg"
echo precious > "$T/home/precious.txt"
W=/tmp/shellgame-playtest-$$ # the workspace must be inside /tmp
export T W BIN
run() { env HOME="$T/home" XDG_CONFIG_HOME="$T/xdg" SHELLGAME_WORKSPACE="$W" "$BIN" "$@"; }
```

Do not use `cargo run` with a changed `HOME`: cargo and rustup live there. Progress goes to
`$T/xdg/shellgame/state.json`, level files to `$W`; confirm both after the first screen.
Do not put `$T` under `/tmp`: everything there is writable inside the sandbox, so it would prove nothing.

## 2. Start the game, once per shell

```bash
run --shell bash # then run --shell fish (keep the state, or start fresh: see step 4)
```

The game needs a terminal. Without one, drive it through tmux on a private socket:

```bash
tmux -L sgplay new-session -d -s sg -x 110 -y 40 \
"env HOME=$T/home XDG_CONFIG_HOME=$T/xdg SHELLGAME_WORKSPACE=$W $BIN --shell bash"
tmux -L sgplay send-keys -t sg 'shellgame hint' Enter
tmux -L sgplay capture-pane -t sg -p
tmux -L sgplay kill-server
```

Subcommands also run outside the game shell and without a terminal: `run resume 3.2; run status`.

## 3. What to try, in both shells

- **Start.** Intro pages advance with Enter; under each level panel is the isolation line ("Izolace
zapnuta: ..."). Ctrl-C at an Enter prompt must leave the shell usable.
- **Hints.** `shellgame hint` three times: the hints escalate, and every call shows the unlocked ones
again with one more, until it says there are none left.
- **Answers.** `shellgame submit nonsense` on a level that takes an answer: a short reply that does not
reveal the answer. A bare `shellgame submit`: a reply that shows the example command.
- **Strict cd.** In a level that practises one exact `cd` (section 1), a wrong `cd` is blocked with a red
panel and the `shellgame reset` tip; `cd` back to the start directory works; `shellgame reset` puts you
in the start directory.
- **Resume.** From fresh state (see step 4): `shellgame resume 3.2` typed in an ordinary shell starts the
game shell in level 3.2 (also with `--shell fish` and for `reset` and `submit`); inside, `shellgame status`
and `shellgame exit` print "Na jiném počítači pokračujte příkazem: shellgame resume 3.2", `shellgame resume 3`
goes to the first level of section 3 after its intro, `resume` alone and `resume 99` fail (status 1) and
point to `shellgame levels`, which lists every level with "▸ zde" and "✓" (also outside the game shell,
where `exit` says you are not in it). Resuming a completed level keeps it counted in `status`.
- **Show.** `shellgame show` (the current assignment), `show 1.2` (another one) and `show 1` (the intro
of a section) print without touching the game; `show 99` and `show x` fail with a message and status 1.
- **Deleted directory.** In a level that is judged on where you stand (`shellgame resume 1.3`) run
`mkdir x && cd x && rmdir "$PWD"` (fish: `mkdir x; and cd x; and rmdir "$PWD"`), then
`shellgame submit`: it says the directory is gone, and `shellgame reset` puts you back at the start.
- **Reset after `chmod 000`.** `shellgame resume 8`, run `chmod 000` on the level's files and directories
in the workspace, then `shellgame reset`: the level is rebuilt and you are back at its start.
- **Sandbox (Linux).** Inside the game shell each of these must fail and leave the file intact:
`rm "$HOME/precious.txt"`, `echo x > "$HOME/precious.txt"`, `touch "$HOME/new"`,
`mv "$HOME/precious.txt" "$HOME/moved"`, `mkdir "$HOME/d"`, `chmod 777 "$HOME/precious.txt"` (the typed
`chmod` is refused with a Czech message, the others with "Permission denied"). These must work: files
in the workspace and in `/tmp`, and `shellgame submit`. `sudo true` failing is expected, and
`shellgame update` only tells you to run it outside. Known limits, not bugs: `command chmod`,
`/usr/bin/chmod`, `find -exec chmod` and `touch` on an existing file still work. Leave the game shell
and check that `cat "$T/home/precious.txt"` still prints `precious` and `ls -A "$T/home"` lists only
that file.
- **Opt-out.** `run --unsafe --shell bash` starts with a red warning and no isolation. Run destructive
commands there only against files under `$T`.
- **Without Landlock** (a VM or container with an old kernel) the game must refuse to start and explain
why; `--unsafe` must still start it.

Note which shell and which level each problem happened in.

## 4. Start fresh, and clean up

Section 8 leaves mode-000 files and directories, so restore permissions before deleting:

```bash
# a fresh game with the same fake home
chmod -R u+rwx "$T/xdg" "$W" 2>/dev/null; rm -rf "$T/xdg" "$W"; mkdir "$T/xdg"
# when you are done
chmod -R u+rwx "$T" "$W" 2>/dev/null; rm -rf "$T" "$W"
```

Remove only what you created here, and kill the tmux server if you started one
(`tmux -L sgplay kill-server`).
84 changes: 84 additions & 0 deletions .claude/skills/release/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
---
name: release
description: Build and publish ShellGame. Explains how CI turns a push to main into a build-N GitHub release, the dry run with workflow_dispatch, the PUBLISH_MACOS hold-back, and how scripts/install.sh and `shellgame update` pick up releases. Use when asked to release or publish, to test the release pipeline, to enable macOS assets, or to change .github/workflows/ci.yml or scripts/install.sh.
---

# Release

A push to `main` publishes to students: `scripts/install.sh` and `shellgame update` serve the latest
release. **Never push, merge or tag unless the maintainer asked for exactly that.** A dry run needs the
branch on the remote, so it needs their go-ahead too.

## How CI works (`.github/workflows/ci.yml`)

| Job | Runs on | Does |
| --- | --- | --- |
| `test` | matrix: ubuntu-24.04 and ubuntu-22.04 | `cargo test --locked` with `SHELLGAME_TEST_LANDLOCK=required` on both legs; 22.04 has the oldest supported fish (3.3) and bash (5.1). Only the 24.04 leg also runs shellcheck of every tracked `*.sh`, `cargo fmt --check`, `cargo doc` (warnings denied) and clippy `-D warnings` |
| `test-macos` | macos-latest | system bash 3.2 first on `PATH`; `cargo test --locked --test shell` blocks (bash 3.2 parity of the integration script), the full `cargo test --locked` is non-blocking |
| `build` | after `test` (both legs) and `test-macos` | on a push to the default branch and on `workflow_dispatch`: one archive per target, each holding exactly one file, `shellgame`; `SHELLGAME_BUILD=build-<run number>` is compiled in and shown by `--version`; the `Smoke test` step runs the built binary and checks that (x86_64 macOS runs under Rosetta, and only prints a notice when it is missing); on Linux, `Smoke test the game shell` checks that the binary is static and that it starts a confined game shell that runs a typed command, saves state and cannot write to the home |
| `publish` | after `build` | push to the default branch only, one run at a time: a run whose commit is no longer the tip of the default branch (overtaken, or started again later) publishes nothing; otherwise GitHub release `build-<run number>` with the archives, marked latest |

Assets are `shellgame-{linux,macos}-{x86_64,aarch64}.tar.gz` (static musl on Linux). `scripts/install.sh`
and `shellgame update` download `releases/latest/download/<asset>`, so the names are an interface: do
not rename them. The publish job tags the built commit itself; do not create tags or releases by hand.
The crate version in `Cargo.toml` is not bumped per release.

## Dry run (builds everything, publishes nothing)

```bash
BRANCH=$(git branch --show-current)
gh workflow run ci.yml --ref "$BRANCH" # the workflow file must exist on the default branch
RUN=$(gh run list -w ci.yml -b "$BRANCH" -e workflow_dispatch -L 1 --json databaseId --jq '.[0].databaseId')
gh run watch "$RUN" --exit-status
D=$(mktemp -d); gh run download "$RUN" -n shellgame-linux-x86_64.tar.gz -D "$D"
tar -tzf "$D/shellgame-linux-x86_64.tar.gz" # must list only: shellgame
```

Run `gh workflow run` only after the maintainer pushed the branch. Archives of a dry run are workflow
artifacts; the `publish` job is skipped. In the run, `test` shows two legs, `test (ubuntu-24.04)` and
`test (ubuntu-22.04)` (the 22.04 leg skips the shellcheck, formatting, docs and clippy steps), and the
`Smoke test` step of every `build` leg prints `shellgame <version> (build-<run number>)` (the x86_64
macOS leg may print a no-Rosetta notice instead).

## macOS assets: `PUBLISH_MACOS`

The macOS legs always build (non-blocking), but `publish` deletes `shellgame-macos-*` while the
`PUBLISH_MACOS` variable at the top of `ci.yml` is `"false"`. Flip it to `"true"` only after the macOS
content review (GNU-only spots in the levels: `ls --help`, `wc` padding, `file` wording, bracket globs
under locale collation) has made the macOS replay green. Do these together: make the full macOS `Test`
step blocking (drop `continue-on-error` there and on the macOS `build` legs), flip the variable, and
update the macOS sentence in `README.md`.

## Before a release

1. `cargo fmt --check && cargo clippy --all-targets --locked -- -D warnings`, then
`SHELLGAME_TEST_LANDLOCK=required cargo test --locked`.
2. Playtest in bash and fish (the `playtest` skill): resume, strict cd, reset, the sandbox.
3. Hosted runners have recent kernels, so old Landlock ABIs and kernels without Landlock (Debian 11,
RHEL 8 and 9.0 to 9.5, Ubuntu 20.04 with its original kernel, WSL 1) are checked by hand: the game
must isolate as far as the kernel allows, or refuse and explain, and `--unsafe` must start it.
4. Ask the maintainer to push (a branch for a dry run, `main` for the release).

## After the publish run

```bash
gh release view # the newest release, build-<N>: the Linux archives are attached
curl -fsSL https://raw.githubusercontent.com/jdupak/ShellGame/main/scripts/install.sh | SHELLGAME_INSTALL_DIR="$(mktemp -d)" sh
```

The script prints where it installed `shellgame`; `<that path> --version` must report `build-<N>`.
From an older install, `shellgame update` (run outside the game shell) must replace the binary and keep
the save.

## Changing the pipeline or the installer

Keep `scripts/install.sh` POSIX `sh` (students pipe it into `sh` or `bash`; it must also run in dash
and bash 3.2), with its body in functions called from the last line, and clean under `shellcheck`. The `build` job installs
no `musl-tools`: no dependency compiles C (`Cargo.lock` has no `cc` crate), so rustc's bundled musl is
enough; a crate with a C build script would need `musl-tools` in the Linux legs again. Validate edits
locally and prove them with a dry run:

```bash
git ls-files -z '*.sh' | xargs -0 shellcheck # the "Lint shell scripts" step: every tracked script
uv run --no-project --with pyyaml python -c 'import yaml; yaml.safe_load(open(".github/workflows/ci.yml"))'
```
Loading
Loading