ghastly β Build Playbook
> Step-by-step guide for building ghastly from zero to PyPI π»
ghastly β Build Playbook
Step-by-step guide for building ghastly from zero to PyPI π» Hand this to Claude Code. It has everything it needs.
How to use this playbook
Each phase has a goal, a task list, and acceptance criteria. A phase is done when all acceptance criteria pass β not before. Phases build on each other; don't skip ahead.
The companion document ghastly-brief.md is the source of truth for all
design decisions, schemas, keybinds, and UX behavior. When in doubt, brief wins.
Phase 1 β Walking Skeleton
Goal: A real running TUI that polls one repo and shows green or red. Nothing fancy. Just proof it works end to end.
Tasks
- Init project with
uv init,direnv,.venv,pyproject.toml - Set up
src/ghastly/package structure (all files as stubs) - Implement
ghastly initwizard- prompt for PAT
- validate PAT via
GET /user(checks auth + returns login) - prompt for first repo URL
- write
~/.config/ghastly/config.toml - create
~/.local/share/ghastly/directory
- Implement
config.pyβ load and validate config.toml - Implement
api.pyβ single method:get_latest_run(owner, repo)viaGET /repos/{owner}/{repo}/actions/runs?per_page=1 - Implement minimal Textual app in
app.py- static list of repo rows from config
- each row shows: alias Β· status (colored) Β· last updated timestamp
- polling loop at configured interval (default 60s)
- Implement
repo_row.pywidget β bare minimum, status + alias only -
ghastly add <url>CLI command appends repo to config
Acceptance Criteria
ghastly initruns cleanly, writes valid config, exits with helpful messageghastlylaunches and shows a row per configured repo- Status updates after each poll cycle
successrows are green,failurerows are red,in_progressis yellowqquits cleanly- App does not crash if GitHub returns an unexpected status code
Key decisions for this phase
- Use
typerfor CLI commands (init,add) alongside the TUI entry point - PAT stored as plaintext in config.toml β document that users should
chmod 600it - Polling is a simple
asyncioloop withawait asyncio.sleep(interval)
Phase 2 β Core Loop
Goal: The daily driver experience. Notifications, age column, state diffing, ETag support. This is the phase where ghastly becomes genuinely useful.
Tasks
- Add age column to repo rows
- format:
build completed 01:00:25 ago/build failed 00:25:33 ago - live update every second via Textual timer
- color-matched to last status (green/red)
- format:
- Implement state diffing in
api.py- persist last known run ID per repo to
~/.local/share/ghastly/state.json - detect transitions: any status β
success, any status βfailure, etc. - only fire notifications on actual change, not every poll
- persist last known run ID per repo to
- Implement ETag caching
- send
If-None-Matchheader with stored ETag on every request - on
304 Not Modified: skip update, no rate limit cost - persist ETags to
~/.local/share/ghastly/etags.json
- send
- Implement
notifications.py- TUI toast via Textual
notify() - system notification via
notify-send(subprocess, optional) - configurable per event type via
[notifications]config section
- TUI toast via Textual
- Add row highlight animation on status change (Textual CSS transition)
- Add status bar at bottom of app
- shows: last poll time Β· next poll in Xs Β· repo count Β· rate limit remaining
- shows
[offline]if last request failed with network error - shows rate limit reset time if 403/429 received
- Implement config file watching via
watchfiles- new repos added to config appear in TUI without restart
- removed repos disappear gracefully
Acceptance Criteria
- Age column updates live every second without UI flicker
- Notification fires exactly once per state transition, not on every poll
- ETag requests return 304 for unchanged repos β verify via debug log
notify-sendfires on failure ifsystem_notify = true- Status bar shows accurate rate limit info
- Adding a repo via
ghastly addwhile TUI is running adds the row live - App recovers gracefully after network outage (resumes polling, shows [offline])
Key decisions for this phase
- Age formatted as
HH:MM:SSβ switch toXd HH:MMfor runs older than 24h - Persist state/etags as JSON, not SQLite β keeps it simple and inspectable
notify-sendfailure (e.g. not installed) should log a warning, never crash
Phase 3 β Artifact Extraction
Goal: Show what was built. The ghastly/v1 schema, releases fallback,
and the detail panel. This is the feature that makes cor.energy colleagues
want it too.
Tasks
- Implement
schema.pyβghastly/v1extractor- fetch step summary via
GET /repos/{owner}/{repo}/actions/runs/{run_id}thenGET /repos/{owner}/{repo}/actions/runs/{run_id}/jobsfor summary URL - regex extract
<!-- ghastly:artifacts ... -->block - parse and validate JSON against schema
- return typed
ArtifactManifestdataclass
- fetch step summary via
- Implement releases API fallback
GET /repos/{owner}/{repo}/releases/latest- extract tag name as version, build minimal artifact list
- only used when
ghastly/v1block not found
- Add artifacts column to repo rows
- show artifact count if
ghastly/v1found:3 artifacts - show latest release tag if releases fallback:
v2.3.1 - show
βif no artifact data available
- show artifact count if
- Implement
detail_panel.pywidget- renders full Step Summary markdown (Textual
Markdownwidget) - shows
ghastly/v1artifact table above markdown if available:name type version ref heating-service docker 2.3.1-pre.847 ghcr.io/... common-lib nuget 1.0.4-pre.847 https://... - falls back to log tail snippet if no summary available
- renders full Step Summary markdown (Textual
- Wire
Enterkey to open detail panel - Wire
okey to openhtml_urlin browser (webbrowser.open())
Acceptance Criteria
ghastly/v1block correctly parsed from a real workflow step summary- Artifact table renders correctly for docker + nuget types
- Releases fallback works for a public repo with releases
βshown cleanly for repos with no artifact data (no error, no empty box)Enteropens detail panel,Esccloses itoopens correct GitHub run URL in default browser- Schema version mismatch (
ghastly/v2on a v1 parser) logs warning, shows raw JSON
Key decisions for this phase
- Step summary fetching adds 1 extra API call per run β only fetch on detail open, not during polling, to preserve rate limit budget
artifact_hint = "latest"in config skips all extraction attempts, shows run status only β escape hatch for repos where extraction is noisy
Phase 4 β UX Polish
Goal: The experience that makes people switch from their browser. Adaptive layout, full navigation, fuzzy filter, group view, rerun support.
Tasks
- Implement adaptive layout
- detect terminal width on launch and on resize (
on_resizein Textual) < 120 cols: detail opens as modal overlayβ₯ 120 cols: detail opens as right-side split panel- respect
[display] detail_layoutconfig override
- detect terminal width on launch and on resize (
- Implement full keyboard navigation
β/kandβ/jmove between rowsβ/hcollapses group or closes detail panelβ/lexpands group or opens detail panel- all navigation works identically in both modal and split modes
- Implement group view
gtoggles between flat list and grouped-by-groupview- groups are collapsible with
h/l - group header shows aggregate status (worst status of members)
- Implement fuzzy filter bar (
filter_bar.py)/opens filter input at bottom of screen- live filtering as user types β no enter required
- matches against: alias, group name, status string, artifact name
- fuzzy:
heamatchesheating-service,wrkmatcheswork Escclears filter and closes bar- matched substring highlighted in row
- Implement
?help overlay- full keybind reference rendered as Textual overlay
- dismissable with
?orEsc
- Implement rerun support (requires
actions:writePAT scope)rβPOST /repos/{owner}/{repo}/actions/runs/{run_id}/rerun-failed-jobsShift+RβPOST /repos/{owner}/{repo}/actions/runs/{run_id}/rerun- show confirmation prompt before firing
- if PAT lacks
actions:write: show inline warning message, don't crash
- Sort options β cycle with
skey: last run time Β· status Β· alias - Add duration column β how long the last run took (from API
run_started_at+updated_at)
Acceptance Criteria
- At 100 cols: detail opens as modal,
Escdismisses - At 140 cols: detail opens as split panel,
Esccollapses it - Resizing terminal mid-session switches layout correctly
hjkland arrow keys both navigate rows without any config/hea+ typing filters to heating-service live, match highlightedEscfrom filter restores full list instantly- Group view shows aggregate status in group header
rrerun fires only after confirmation, shows toast on successrwith read-only PAT shows warning, does NOT attempt the API call?overlay shows all keybinds, closes cleanly
Key decisions for this phase
- Fuzzy matching: implement simple substring score (no external dep like
fzf). Score = position of match (earlier = better) + consecutive match bonus.rapidfuzzis acceptable if simple scoring feels insufficient. - Confirmation prompt for rerun: single keypress
y/n, not a modal dialog
Phase 5 β Distribution
Goal: Anyone can install ghastly in 30 seconds. PyPI, AUR, docs.
Tasks
- Finalize
pyproject.toml- entry points:
ghastlyCLI + TUI - all dependencies pinned with lower bounds
- metadata: description, license (GPLv3), homepage, keywords
- entry points:
- Write
README.md- install instructions (pip, uv, AUR)
- quickstart: init β add repo β launch
- screenshot (record with
vhsor similar) - link to
INTEGRATION.md
- Write
INTEGRATION.md- quickstart workflow snippet (static)
- accumulator pattern (dynamic / monorepo)
ghastly/v1schema reference table- "no integration needed" section
- Write
PKGBUILDfor AUR- source from PyPI tarball
depends = ('python' 'python-pip')- install to standard Arch paths
- include
.installfile with post-install note aboutghastly init
- Write systemd user service snippet for docs
[Unit] Description=ghastly GitHub Actions watcher [Service] ExecStart=%h/.local/bin/ghastly Restart=on-failure [Install] WantedBy=default.target - Publish to PyPI via
uv publish/hatch publish - Submit AUR package
Acceptance Criteria
pip install ghastlyworks on a clean Python 3.11+ environmentuv tool install ghastlyworks- AUR:
yay -S ghastlyinstalls and runs correctly on Arch - README screenshot shows real running TUI
INTEGRATION.mdcopy-paste workflow snippet produces validghastly/v1JSON- systemd service starts ghastly and restarts it on crash
Cross-cutting Concerns
These apply to every phase, not just one.
Error handling philosophy
- Never crash on a single repo's API failure β log it, mark the row, continue
- Never show raw Python tracebacks to the user β catch at app boundary
- Always preserve last known state when API is unavailable
Logging
- Use Python
loggingmodule, not print statements - Log file:
~/.local/share/ghastly/ghastly.log - Default level:
WARNING. Setlog_level = "DEBUG"in config for verbose mode - Debug mode logs every API request + response code (useful for ETag verification)
Testing
- Unit tests for
schema.pyextractor β test valid JSON, missing block, malformed JSON, version mismatch - Unit tests for
config.pyβ test missing file, invalid TOML, missing required fields - Integration tests for
api.pyβ userespxto mockhttpxcalls - No tests required for Textual widgets in early phases β they're hard to test and the framework is visual
Code style
- All comments, docstrings, and variable names in English
- Type hints everywhere β
mypy --strictshould pass rufffor formatting and linting
Reference β Key API Endpoints
| purpose | endpoint |
|---|---|
| validate PAT | GET /user |
| latest run for repo | GET /repos/{owner}/{repo}/actions/runs?per_page=1&branch={branch} |
| run detail (for html_url) | GET /repos/{owner}/{repo}/actions/runs/{run_id} |
| step summary | GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs β job steps[].summary_url |
| latest release | GET /repos/{owner}/{repo}/releases/latest |
| rerun failed jobs | POST /repos/{owner}/{repo}/actions/runs/{run_id}/rerun-failed-jobs |
| rerun all | POST /repos/{owner}/{repo}/actions/runs/{run_id}/rerun |
All requests use Authorization: Bearer {PAT} header.
All GET requests send If-None-Match: {etag} when a cached ETag exists.
Reference β Config Schema
[auth]
pat = "ghp_xxxxxxxxxxxx"
[display]
detail_layout = "auto" # "auto" | "modal" | "split"
poll_interval = 60 # seconds
[notifications]
on_success = true
on_failure = true
on_cancelled = false
system_notify = true
[[repos]]
url = "https://github.com/owner/repo"
alias = "my-service"
group = "work"
watch_branch = "main"
artifact_hint = "auto" # "auto" | "latest" | "releases"
ghastly β because watching CI in a browser tab is haunting you π»
Related Documents
Visual Truth Engine: Product-Market Fit & Go-to-Market Strategy
**Date:** January 22, 2026 | **Status:** Early-Stage Launch Strategy
Media Handling Playbook - ZyeutΓ© v3
**Last Updated:** December 15, 2025
Trader ROI Playbook (Codex + CI)
Purpose: increase engineering output per hour while keeping quality stable or better.
OSCP Attack Playbook
**Author:** Brad Turner