Files
max-sixty__worktrunk/Taskfile.yaml
Maximilian Roos 8faeabf239 refactor(setup): run Codex Cloud as root and simplify both setup paths (#3841)
Consolidates the two remaining Codex Cloud drafts, #3835 and #3838, onto
what #3839 landed, then cuts what was left. Main's setup is a 163-line
script and a 26-line README under `scripts/codex-cloud/`; this is one
79-line file at `dev/codex.sh`, beside the repo's other development
files, with the README folded into its header. That empties `scripts/` —
the directory existed only for this.

**Codex Cloud runs as root** (from #3835). Setup used to replace
`/root/.cargo/bin/cargo` with a wrapper that re-executed cargo as a
UID-1000 `ubuntu` user under `tini`, and maintenance chowned the
checkout, the rustup home, and three cache directories to match. All of
it existed to keep the suite's permission tests from skipping, since
root can write to a read-only file.

Worth stating plainly: ten tests now skip on Codex Cloud. The pair
carrying the most weight is
`test_remove_foreground_succeeds_with_stuck_directory` and its
`_detached` twin, the only automated coverage of `wt remove` against a
directory it cannot delete. It is not a new hole — `setup-web` creates
no non-root user, so the Claude Code web environment has always skipped
them, and `tests/integration_tests/approval_pty.rs:157` carries a
standing TODO about it. Codex Cloud was the one environment buying an
exception, and a cargo wrapper, `tini`, `runuser`, and four chown passes
were the price. Both environments now agree about what the suite
observes, and that TODO is the single place to fix it for both.

Three of the ten decided that skip by reading `$USER` rather than by
probing the filesystem, which fails open: a container that runs as root
without exporting `USER` runs them and asserts an error root never gets.
They probe now, through one helper, like the other seven.

**Task, the checksums, and the retries are gone.** Nothing invoked Task
on Codex Cloud once #3839 stopped routing the launchers through it, so
it is no longer installed. The archive checksums follow the Taskfile
digest for the same reason that one went: HTTPS authenticates GitHub and
the container is disposable and secret-free, so verifying each download
bought a helper pair and a 64-char line per tool for very little. Each
install is now `curl | tar` and an `install`.

The version numbers stay. The gate runs `--all-features`, so nu and pwsh
drive PTY snapshots their own versions can move, and
`.github/actions/test-setup/action.yaml` pins cargo-insta,
cargo-nextest, and nu to the same three versions — unpinning those would
make the environment and CI disagree about snapshot output. Nothing
under `.github/` pins PowerShell, so CI runs whatever the runner image
ships and that version answers to nothing but these scripts.

**`setup-web` catches up** (from #3838). It gains `lsof`, installs
Nushell from its release archive rather than checking that one is
already present, bootstraps `uv` with pre-commit, and puts
`$HOME/.local/bin` on PATH. `wt` installs from the debug build produced
a few lines earlier instead of through a second full compile. Its `cargo
install` of cargo-insta and cargo-nextest stays unconditional, as on
main: neither form pins, and a `command -v` guard would have frozen
whatever versions the image happened to carry.

## Testing

`cargo run -- hook pre-merge --yes` passes, shellcheck is clean at
warning level, and `task --list` still parses. The `.tar.xz` and
`.tar.gz` extractions were run against the real release archives to
confirm the tar flags and the paths inside them. The three converted
tests pass unprivileged; their skip branch rests on the same probe the
other seven root-skipping tests already use.

Neither setup path is executable from a dev machine — Codex Cloud needs
Linux and root on the universal image, `setup-web` needs a web image —
so the first real exercise is the next environment build.

> _This was written by Claude Code on behalf of max-sixty_

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 16:58:55 -07:00

486 lines
17 KiB
YAML

# https://taskfile.dev
version: "3"
vars:
REPO_ROOT:
sh: pwd
STATIC_DIR: "{{.REPO_ROOT}}/docs/static"
ASSETS_DIR: "{{.STATIC_DIR}}/assets"
tasks:
# Development
coverage:
desc: Run tests with coverage report
cmd: cargo llvm-cov nextest --html --features shell-integration-tests {{.CLI_ARGS}}
profile-tests:
desc: Run the suite under CPU accounting and report where the time goes
# tests/CLAUDE.md → Profiling the Suite covers how to read the numbers.
cmds:
# Build first so the measurement covers the test run alone.
- cargo nextest run --no-run --features shell-integration-tests
# bash's `time` keyword counts every reaped child. Task's own interpreter
# (mvdan/sh) parses `time` but reports user/sys as zero, and /usr/bin/time
# is an extra package on minimal Linux; bash is present on every dev and
# CI platform (Git Bash on Windows).
- bash -c 'time "$@"' bash cargo nextest run --features shell-integration-tests {{.CLI_ARGS}}
- echo "per-test timings in target/nextest/default/junit.xml"
setup-web:
desc: Setup Claude Code web environment for development
platforms: [linux]
cmds:
- |
set -e
export PATH="$HOME/.local/bin:$HOME/bin:$PATH"
echo "========================================"
echo "Claude Code Web - Worktrunk Setup"
echo "========================================"
# Check project root
if [ ! -f "Cargo.toml" ] || ! grep -q 'name = "worktrunk"' Cargo.toml; then
echo "Error: Must be run from worktrunk project root"
exit 1
fi
echo "Found worktrunk project"
# Check/install Rust
echo ""
echo "Checking Rust toolchain..."
if ! command -v cargo &> /dev/null; then
echo "Cargo not found. Installing Rust..."
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
source "$HOME/.cargo/env"
fi
echo "Rust version: $(rustc --version | awk '{print $2}')"
# Install what the `shell-integration-tests` feature drives
echo ""
echo "Installing shell-integration test dependencies..."
if command -v apt-get &> /dev/null; then
export DEBIAN_FRONTEND=noninteractive
# An `if` rather than an `&&` chain: under `set -e` a chain
# ending false takes the whole task down, and this one ends
# false both on an unmatched glob and on a `.list` file with
# no `[` line.
for f in /etc/apt/sources.list.d/*.list; do
if [ -f "$f" ] && grep -q '^\[' "$f" 2>/dev/null; then
rm -f "$f"
fi
done
# A missing pwsh joins the condition so one refresh serves both
# installs: apt resolves the PowerShell .deb's dependencies from
# these lists too.
if ! command -v zsh &> /dev/null || ! command -v fish &> /dev/null \
|| ! command -v jq &> /dev/null || ! command -v lsof &> /dev/null \
|| ! command -v pwsh &> /dev/null; then
apt-get update -qq
apt-get install -y -qq zsh fish jq lsof
fi
# PowerShell isn't in Debian's repos. Install the release .deb
# rather than the tarball: pwsh aborts at startup without libicu,
# and only the .deb declares that dependency for apt to resolve.
if ! command -v pwsh &> /dev/null; then
PWSH_VERSION="7.6.5"
URL="https://github.com/PowerShell/PowerShell/releases/download/v${PWSH_VERSION}/powershell_${PWSH_VERSION}-1.deb_amd64.deb"
TEMP=$(mktemp -d)
curl -fsSL -o "$TEMP/powershell.deb" "$URL"
apt-get install -y -qq "$TEMP/powershell.deb"
rm -rf "$TEMP"
echo "pwsh installed"
fi
fi
# Nushell isn't in every web image, and the shell-integration suite
# drives it directly.
if ! command -v nu &> /dev/null; then
NU_VERSION="0.115.0"
DIR="nu-${NU_VERSION}-x86_64-unknown-linux-gnu"
TEMP=$(mktemp -d)
curl -fsSL "https://github.com/nushell/nushell/releases/download/${NU_VERSION}/${DIR}.tar.gz" \
| tar -xz -C "$TEMP"
install -D -m 0755 "$TEMP/$DIR/nu" "$HOME/.local/bin/nu"
rm -rf "$TEMP"
echo "nu installed"
fi
# The shells the feature drives, plus the `jq` its Claude-hook tests
# pipe the hook payload through. Run each rather than looking for it
# on PATH, because a pwsh missing its libicu is on PATH and aborts at
# startup anyway.
for tool in bash zsh fish nu pwsh jq; do
"$tool" --version > /dev/null 2>&1 || { echo "Error: $tool not usable"; exit 1; }
echo "$tool available"
done
# Install gh CLI
echo ""
echo "Installing GitHub CLI..."
if command -v gh &> /dev/null; then
echo "gh already installed"
else
GH_VERSION="2.63.2"
ARCH="linux_amd64"
URL="https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_${ARCH}.tar.gz"
mkdir -p ~/bin
TEMP=$(mktemp -d)
curl -fsSL "$URL" | tar -xz -C "$TEMP"
mv "$TEMP/gh_${GH_VERSION}_${ARCH}/bin/gh" ~/bin/gh
chmod +x ~/bin/gh
rm -rf "$TEMP"
export PATH="$HOME/bin:$PATH"
echo "gh installed to ~/bin/gh"
fi
# Build
echo ""
echo "Building worktrunk..."
cargo build 2>&1 | tail -5
echo "Build successful"
# Install dev tools
echo ""
echo "Installing development tools..."
if ! command -v uv &> /dev/null; then
curl -LsSf https://astral.sh/uv/install.sh | sh
fi
uv tool install --force pre-commit
cargo install cargo-insta cargo-nextest --quiet
install -D -m 0755 target/debug/wt "$HOME/.local/bin/wt"
echo "Installed pre-commit, cargo-insta, cargo-nextest, worktrunk"
echo ""
echo "Setup complete! Run 'wt --help' to get started."
# Assets
fetch-assets:
desc: Fetch assets from worktrunk-assets repo
cmds:
- |
set -euo pipefail
echo "Fetching assets..."
rm -rf "{{.ASSETS_DIR}}"
mkdir -p "{{.ASSETS_DIR}}"
TMPFILE=$(mktemp)
curl -fsSL "https://github.com/max-sixty/worktrunk-assets/archive/refs/heads/main.tar.gz" -o "$TMPFILE"
tar -xzf "$TMPFILE" --strip-components=2 -C "{{.ASSETS_DIR}}" "worktrunk-assets-main/assets"
rm "$TMPFILE"
echo "Done. Assets in {{.ASSETS_DIR}}/"
publish-assets:
desc: Publish assets to worktrunk-assets repo
dir: "{{.REPO_ROOT}}"
cmds:
- |
set -euo pipefail
ASSETS_REPO="../worktrunk-assets"
LOCAL_ASSETS="{{.ASSETS_DIR}}"
# Clone if needed
if [[ ! -d "$ASSETS_REPO/.git" ]]; then
if ! command -v gh &>/dev/null; then
echo "Error: gh CLI required. Install from https://cli.github.com/"
exit 1
fi
echo "Cloning assets repo..."
gh repo clone max-sixty/worktrunk-assets "$ASSETS_REPO" || {
echo "Failed to clone assets repo"
exit 1
}
fi
cd "$ASSETS_REPO"
git pull --ff-only || {
echo "Failed to update assets repo. Check for uncommitted changes."
exit 1
}
rsync -av --delete "$LOCAL_ASSETS/" "$ASSETS_REPO/assets/"
if git diff --quiet; then
echo "No changes to publish"
exit 0
fi
# Check for deletions - require manual publish if files were removed
if git status --porcelain | grep -q '^ D'; then
echo "Deletions detected:"
git status --porcelain | grep '^ D'
echo ""
echo "Publish manually in $ASSETS_REPO if this is correct"
exit 1
fi
git diff --stat
git add -A
git commit -m "Update assets"
git push
echo ""
echo "Published: https://github.com/max-sixty/worktrunk-assets"
# Social cards and logo
build-social-cards:
desc: Build social card PNGs from SVG sources
dir: "{{.STATIC_DIR}}"
preconditions:
- sh: command -v rsvg-convert
msg: "rsvg-convert required. Install with: brew install librsvg"
cmds:
- |
set -euo pipefail
FONT_DIR="{{.REPO_ROOT}}/.fonts"
OUTPUT_DIR="{{.ASSETS_DIR}}/social"
ensure_font() {
local name="$1"
local url="$2"
if fc-list | grep -qi "$name"; then
return 0
fi
echo "Downloading $name..."
mkdir -p "$FONT_DIR"
local zip="$FONT_DIR/${name// /_}.zip"
curl -fsSL "$url" -o "$zip"
unzip -qo "$zip" -d "$FONT_DIR"
rm "$zip"
}
ensure_font "Inter" "https://github.com/rsms/inter/releases/download/v4.1/Inter-4.1.zip"
ensure_font "Plus Jakarta Sans" "https://github.com/tokotype/PlusJakartaSans/releases/download/2.7.1/PlusJakartaSans-2.7.1.zip"
# Fontconfig setup
export FONTCONFIG_FILE="$FONT_DIR/fonts.conf"
cat > "$FONT_DIR/fonts.conf" << EOF
<?xml version="1.0"?>
<!DOCTYPE fontconfig SYSTEM "urn:fontconfig:fonts.dtd">
<fontconfig>
<dir>$FONT_DIR</dir>
<cachedir>$FONT_DIR/cache</cachedir>
<include ignore_missing="yes">/etc/fonts/fonts.conf</include>
</fontconfig>
EOF
mkdir -p "$FONT_DIR/cache"
fc-cache -f "$FONT_DIR" 2>/dev/null
for font in "Inter" "Plus Jakarta Sans"; do
if ! fc-list : family | grep -qi "$font"; then
echo "Error: Font '$font' not found after download" >&2
exit 1
fi
done
mkdir -p "$OUTPUT_DIR"
echo "Building social cards..."
rsvg-convert social-card.svg -o "$OUTPUT_DIR/social-card.png"
rsvg-convert github-social-card.svg -o "$OUTPUT_DIR/github-social-card.png"
echo "Done:"
ls -lh "$OUTPUT_DIR"/*.png
bench-llm-commits:
desc: Benchmark LLM commit message tools (claude, llm, aichat, codex, opencode)
silent: true
cmds:
- |
set -euo pipefail
# Test prompts
SMALL_PROMPT=$(cat <<'EOF'
Write a commit message for the staged changes below.
<format>
- Subject under 50 chars, blank line, then optional body
- Output only the commit message, no quotes or code blocks
</format>
<diffstat>
src/config.rs | 2 ++
1 file changed, 2 insertions(+)
</diffstat>
<diff>
diff --git a/src/config.rs b/src/config.rs
@@ -10,6 +10,8 @@ pub struct Config {
pub debug: bool,
+ /// Enable verbose logging
+ pub verbose: bool,
}
</diff>
<context>
Branch: feature/logging
</context>
EOF
)
MEDIUM_PROMPT=$(cat <<'EOF'
Write a commit message for the staged changes below.
<format>
- Subject under 50 chars, blank line, then optional body
- Output only the commit message, no quotes or code blocks
</format>
<style>
- Imperative mood: "Add feature" not "Added feature"
- Match recent commit style (conventional commits if used)
</style>
<diffstat>
src/auth/jwt.rs | 45 +++++++++++++++++++++++++++++++++
src/auth/mod.rs | 2 ++
src/middleware.rs | 12 +++++++++
tests/auth_test.rs | 28 +++++++++++++++++++++
4 files changed, 87 insertions(+)
</diffstat>
<diff>
diff --git a/src/auth/jwt.rs b/src/auth/jwt.rs
new file mode 100644
+use jsonwebtoken::{encode, decode, Header, Validation};
+
+pub struct Claims { pub sub: String, pub exp: usize }
+
+pub fn create_token(user_id: &str, secret: &[u8]) -> Result<String, Error> {
+ let claims = Claims { sub: user_id.to_owned(), exp: ... };
+ encode(&Header::default(), &claims, &EncodingKey::from_secret(secret))
+}
+
+pub fn validate_token(token: &str, secret: &[u8]) -> Result<Claims, Error> {
+ decode::<Claims>(token, &DecodingKey::from_secret(secret), &Validation::default())
+}
</diff>
<context>
Branch: feature/auth
<recent_commits>
- feat(api): add user registration endpoint
- fix(db): handle connection pool exhaustion
</recent_commits>
</context>
EOF
)
# Documented commands from llm-commits.md
declare -A COMMANDS
COMMANDS["claude"]='MAX_THINKING_TOKENS=0 claude -p --no-session-persistence --model=haiku --tools='"'"''"'"' --safe-mode --setting-sources='"'"'user'"'"' --system-prompt='"'"''"'"''
COMMANDS["llm"]='llm -m claude-haiku-4.5'
COMMANDS["aichat"]='aichat -m claude:claude-haiku-4.5'
COMMANDS["codex"]='codex exec -m gpt-5.6-luna -c model_reasoning_effort='"'"'low'"'"' -c system_prompt='"'"''"'"' --sandbox=read-only --json - | jq -sr '"'"'[.[] | select(.item.type? == "agent_message")] | last.item.text'"'"''
COMMANDS["opencode"]='opencode run -m anthropic/claude-haiku-4.5 --variant fast'
# Check which tools are available
echo "=== LLM Commit Message Benchmark ==="
echo ""
echo "Checking available tools..."
AVAILABLE=()
for tool in claude llm aichat codex opencode; do
if command -v "$tool" &>/dev/null; then
echo " ✓ $tool"
AVAILABLE+=("$tool")
else
echo " ✗ $tool (not installed)"
fi
done
if command -v jq &>/dev/null; then
echo " ✓ jq (required for codex)"
else
echo " ✗ jq (required for codex)"
# Drop codex from the list. String substitution (${AVAILABLE[@]/codex})
# would rewrite the element to "" rather than remove it, leaving an
# empty tool name to iterate over later — rebuild the array instead.
FILTERED=()
for tool in "${AVAILABLE[@]}"; do
[ "$tool" = codex ] || FILTERED+=("$tool")
done
AVAILABLE=("${FILTERED[@]}")
fi
echo ""
if [ ${#AVAILABLE[@]} -eq 0 ]; then
echo "No tools available. Install at least one:"
echo " claude: https://docs.anthropic.com/en/docs/claude-code"
echo " llm: uv tool install llm llm-anthropic"
echo " aichat: https://github.com/sigoden/aichat"
echo " codex: npm install -g @openai/codex"
echo " opencode: https://opencode.ai/download"
exit 1
fi
# Benchmark function
bench() {
local name="$1"
local cmd="$2"
local prompt="$3"
local start end elapsed output first_line
start=$(date +%s)
output=$(echo "$prompt" | eval "$cmd" 2>/dev/null) || output="[error]"
end=$(date +%s)
elapsed=$((end - start))
first_line=$(echo "$output" | head -1 | cut -c1-50)
printf " %-8s %3ds %s\n" "$name:" "$elapsed" "$first_line"
}
# Run benchmarks
for size in small medium; do
if [ "$size" = "small" ]; then
PROMPT="$SMALL_PROMPT"
echo "### Small diff (add verbose flag) ###"
else
PROMPT="$MEDIUM_PROMPT"
echo "### Medium diff (JWT auth module) ###"
fi
echo ""
for tool in "${AVAILABLE[@]}"; do
bench "$tool" "${COMMANDS[$tool]}" "$PROMPT"
done
echo ""
done
echo "Commands used (from docs/content/llm-commits.md):"
for tool in "${AVAILABLE[@]}"; do
echo " $tool: ${COMMANDS[$tool]}"
done
generate-logo:
desc: Generate logo using Gemini AI
dir: "{{.REPO_ROOT}}"
preconditions:
- sh: command -v gemimg
msg: "gemimg required. Install with: uv tool install gemimg"
- sh: command -v magick
msg: "imagemagick required. Install with: brew install imagemagick"
- sh: command -v rembg
msg: "rembg required. Install with: uv tool install rembg[cli]"
- sh: test -f dev/logo-prompt.json
msg: "dev/logo-prompt.json not found"
cmds:
- |
set -euo pipefail
RAW_FILE=".tmp/logo-raw.png"
SIZE_1X=512
SIZE_2X=1024
SIZE_FAVICON=32
mkdir -p .tmp
echo "Generating logo..."
gemimg "$(cat dev/logo-prompt.json)" \
--model gemini-3-pro-image-preview \
--aspect-ratio 1:1 \
-o "$RAW_FILE"
echo "Removing background..."
rembg i "$RAW_FILE" "$RAW_FILE"
echo "Processing sizes..."
magick "$RAW_FILE" -resize "${SIZE_1X}x${SIZE_1X}" "{{.STATIC_DIR}}/logo.png"
magick "$RAW_FILE" -resize "${SIZE_2X}x${SIZE_2X}" "{{.STATIC_DIR}}/logo@2x.png"
magick "$RAW_FILE" -resize "${SIZE_FAVICON}x${SIZE_FAVICON}" "{{.STATIC_DIR}}/favicon.png"
rm "$RAW_FILE"
echo "Done. Generated:"
ls -la "{{.STATIC_DIR}}"/logo.png "{{.STATIC_DIR}}"/logo@2x.png "{{.STATIC_DIR}}"/favicon.png