diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..b591e47 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,14 @@ +{ + "name": "offworld", + "owner": { + "name": "Offworld Labs" + }, + "description": "Org-wide Claude Code plugins for Offworld Labs.", + "plugins": [ + { + "name": "core", + "source": "./plugins/core", + "description": "Core Offworld Labs skills, commands, agents, and hooks shared across all repos." + } + ] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5000dfc --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +# Superpowers planning artifacts and subagent-driven-development scratch +docs/superpowers/ +.superpowers/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..cde3e7b --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +# claude-shared + +Offworld Labs' org-wide Claude Code resource: a **plugin marketplace** (`offworld`) +plus **shared reference docs** used across every repo in the organisation. + +- `plugins/core` — the `core` plugin; its `setup-repo` skill bundles the shared rules, `.claude/settings.json`, `CLAUDE.md`, and CI workflow templates used to scaffold new repos. +- `docs/` — on-demand org-wide reference docs (see [Documentation](#documentation)). + +## Install + +Install once per machine, at user scope: + +``` +/plugin marketplace add offworldlabs/claude-shared +/plugin install core@offworld +``` + +## Adopting in a consuming repo + +Adoption is driven by Claude Code, not manual copying. With `core` installed +(see Install above), open any new repo, start Claude Code, and ask: + +> set this repo up per `claude-shared` + +Claude invokes the `core:setup-repo` skill, which writes `.claude/settings.json` +(registering the marketplace and enabling `core`), a `CLAUDE.md`, the shared rules, +the Claude review workflows, and your stack's tooling — then installs deps and +helps you flesh out `CLAUDE.md`. See `docs/runbooks/github-actions-claude-review.md` +for the one manual follow-up (the `CLAUDE_CODE_OAUTH_TOKEN` secret). + +## Documentation + +Org-wide reference docs live under `docs/`. A consuming repo's `CLAUDE.md` should +point at these rather than duplicating them, so there's one source of truth. + +- **`docs/architecture.md`** — the org-wide system architecture: the RETINA + passive-radar network's tiers, signal chain, components, and deployment/fleet + lifecycle. Start here to understand how the repos fit together. + +And each subdirectory: + +- **`docs/contracts/`** — the source of truth for cross-service interfaces: API + schemas, event/message formats, and shared data structures. When two services + communicate, the contract lives here and consuming repos reference it instead of + copying it, so it can't drift. +- **`docs/decisions/`** — Architecture Decision Records (ADRs). One append-only + record per significant, org-wide technical decision: its context, the options + considered, the decision made, and the consequences. +- **`docs/runbooks/`** — operational procedures: deployments, incident response, + rollbacks, and recovery playbooks. Concrete enough to follow under pressure — + exact commands, expected output, and escalation paths. + +## Contributing + +Skills graduate from personal experimentation into the shared `core` plugin +through review: + +1. **Develop personally first.** Iterate on the skill in your own `~/.claude/` + until it works. +2. **Never merge a skill you haven't run.** A skill that hasn't been exercised + end-to-end does not go in. +3. **Test locally before opening the PR.** Add this checkout as a local + marketplace and reload: + ``` + /plugin marketplace add ./path/to/claude-shared + /plugin install core@offworld + /reload-plugins + ``` +4. **Open a PR** moving the skill into `plugins/core/skills//SKILL.md`. +5. **Bump the plugin version** in `plugins/core/.claude-plugin/plugin.json` on + every merged change. The version is the update signal for the entire org — + without a bump, no one receives the change. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..1e9a9e3 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,223 @@ +# RETINA System Architecture + +RETINA — *Radio Echo Tracking by Inter-Node Analysis* — is an open-source +**distributed passive radar network**. Edge nodes listen for reflections of +existing broadcast transmitters (digital TV / FM illuminators of opportunity) +off aircraft, extract delay-Doppler detections locally, and forward them to a +central server that fuses detections from multiple nodes into tracked, +geolocated aircraft shown on live web maps. + +> **Status of this document.** Derived from a survey of the `offworldlabs` +> repositories as of 2026-07-15, with interfaces read from code and config. +> Ports, endpoints, and component roles are cited from source; links that could +> not be confirmed from the repos are marked **(inferred)** or **(unverified)**. +> Update it as the system evolves — it is the org-wide reference the individual +> repos should point at rather than re-describe. Known gaps and inconsistencies +> found during the survey are tracked in separate tickets rather than listed here. + +## 1. System context + +Three tiers plus external inputs and an out-of-band control plane: + +``` + External illuminators (broadcast TV/FM towers) ─ reflections ─┐ + ADS-B (readsb / adsb.lol / tar1090) ── ground-truth ──┐ │ + ▼ ▼ + ┌──────────────────────── EDGE RADAR NODE (Raspberry Pi 5) ───────────────┐ + │ owl-os (Mender-managed OS) + retina-node docker-compose stack │ + │ blah2 (C++ SDR DSP) → detections adsb2dd (truth) tar1090 (ADS-B) │ + │ retina-gui (node mgmt UI) [retina-spectrum: illuminator survey]│ + └──────────────────────────────┬──────────────────────────────────────────┘ + │ detections over TCP (per-node token) + ▼ + ┌──────────────────────── CENTRAL SERVER (cloud droplet) ─────────────────┐ + │ "Tower-Finder" monorepo = the RETINA server │ + │ FastAPI backend (TCP ingest + tracker + geolocator + analytics) │ + │ nginx + live-map SPA + admin dashboard → map/dash/api.retina.fm │ + │ tower-finder-service (illuminator site-survey utility, adjacent) │ + └──────────────────────────────┬──────────────────────────────────────────┘ + │ /ws/aircraft* WebSocket + REST + ▼ + Web clients (live map, dashboard) + + Control plane (out of band): hosted.mender.io ← fleet OTA for OS + app stack +``` + +- **Edge radar node** — a Raspberry Pi 5 running `owl-os` with the `retina-node` + Docker Compose stack. Captures IQ from an SDR (SDRplay RSPduo), computes + delay-Doppler detections, and forwards them to the central server. +- **Central server** — the `Tower-Finder` monorepo (the repo name is historical; + it is now the full RETINA server). Ingests detections from all nodes, runs + multi-target tracking and multi-node geolocation, and serves the live maps. +- **Web clients** — the live map (`map.retina.fm`) and admin dashboard + (`dash`/`admin.retina.fm`), served as static SPAs by the central server. +- **Control plane** — `hosted.mender.io` delivers OS and application updates to + the fleet over the air; it is deliberately separate from the data plane. + +## 2. Signal chain (data flow) + +The production flow runs from an SDR at the edge to the live map, with the +tracker and geolocator running as libraries **inside** the central server: + +``` +SDR (RSPduo) → blah2 C++ processor → blah2 Node API (:3000 /api/detection …) + ↑ ADS-B truth from adsb2dd (:49155 /api/dd) + ↑ adsb2dd polls a tar1090 /data/aircraft.json + → detections forwarded over TCP to the central server + → central tracker (Kalman + GNN) → central geolocator (Levenberg-Marquardt, multi-node) + → in-memory track state → /ws/aircraft* WebSocket → live map SPA +``` + +**Caveat — the node→central forward is config-gated.** The "forwarded over TCP to +the central server" step depends on each node's merged `tracker_forward` config. +On the one production node inspected, forwarding was **disabled** (`enabled: false`, +base default `blah2_tracker:3012`, not the `retina` profile's +`tracker.retnode.com:30050`) — so this hop is not necessarily live fleet-wide. +Such a node still runs the full local pipeline (`blah2` + ADS-B truth) but doesn't +feed the central server. + +**Ordering: tracker before geolocator.** A common summary of the pipeline lists +the geolocator before the tracker; the code is unambiguous the other way. The +**tracker runs first** (it turns detections into tracks) and the **geolocator +runs on the tracker's output** (it solves each track's geographic position) — +`retina-geolocator` consumes `retina-tracker`'s track output. Both run as +libraries inside the central server (see §3), not as separate services. + +**Testing & simulation.** The pipeline is exercised without radio hardware by the +`retina-simulation` load harness, which streams synthetic detections for many +nodes to the central server's ingest port, alongside the server's own +synthetic-node handling. + +**Live map feed.** Geolocated tracks reach the map *inside* the central server, +not via a file or tar1090's `aircraft.json`. Geolocation runs in-process +(`_run_geolocation()` during frame processing, updating an in-memory geolocated- +aircraft store in `backend/core/state.py`), and that state is broadcast over the +`/ws/aircraft*` WebSocket endpoints (`backend/routes/streaming.py`) to the live-map +SPA (`frontend/src/components/map/hooks.ts`). The standalone `retina-geolocator`'s +JSONL output is the offline/batch path, not the live feed. + +## 3. Component catalogue + +### Edge / on-node +- **blah2-arm** (C++ + Node API) — the passive-radar DSP engine (Raspberry Pi 5 + fork of blah2). Captures 2-channel IQ, computes delay-Doppler maps and + in-processor tracks. REST API on `:3000` (`/api/detection`, `/api/map`, + `/api/tracker`, …); web UI on `:49152`. Can forward detections to the central + server. The core of the on-node stack. +- **adsb2dd** (Node/Express) — converts ADS-B aircraft positions into bistatic + delay-Doppler "truth" for a given rx/tx/frequency. Polls a tar1090 + `/data/aircraft.json`; serves `/api/dd` and `/api/synthetic-detections` on + `:49155`. +- **tar1090-node** (readsb + tar1090 + Node proxy) — ADS-B decode and map. A Node + proxy (`:3005`) serves an enriched `aircraft.json` (adds anomaly / Mach flags) + and disables readsb so synthetic data can drive the map; tar1090 renders on + `:8504`. +- **retina-spectrum** (C++) — standalone RF spectrum-survey tool to pick + illuminators; HTTP/SSE UI on `:3020`. Shares the single RSPduo with `blah2`, so + it runs *instead of* the radar stack (opt-in `spectrum` compose profile). +- **retina-gui** (Python/Flask) — per-node management / onboarding UI baked into + `owl-os` (systemd, `:80`, `owl.local`). Management plane, not data plane. + +### Central server +- **Tower-Finder** (Python FastAPI + React/Vite SPAs) — the RETINA central server. + One container (nginx + uvicorn) hosting: TCP detection ingest (`:3012`), the + multi-target **tracker** (Kalman + GNN) and node associator, the multi-node + **geolocator** (Levenberg-Marquardt), auth/admin/analytics, the live-map SPA, + and the admin dashboard. Exposes REST `/api/*` and `/ws/aircraft*` WebSocket + feeds behind `map`/`dash`/`api`/`testmap.retina.fm`. The tracking, geolocation, + and analytics algorithms are **vendored as git submodules under `libs/`** + (`retina-tracker`, `retina-geolocator`, `retina-custody`, `retina-simulation`, + `retina-analytics`) and pip-installed into the image — those repos run *inside* + this server, not as separate services. +- **tower-finder-service** (Python FastAPI) — the illuminator site-survey feature + extracted into a standalone microservice (2026-05-20). Given a lat/lon it ranks + nearby FM/VHF/UHF broadcast towers as candidate illuminators, querying external + databases (Maprad.io, FCC). Fronted by the monorepo's nginx at + `tower-finder.retina.fm`. Currently duplicates the tower code still present in + the monorepo (deduplication pending). + +### Tooling / simulation +- **retina-tracker** (Python library) — the multi-target tracker (Kalman/GNN). In + production it is **not deployed standalone**: the central server vendors it as a + `libs/retina-tracker` git submodule and imports it directly (e.g. + `frame_processor`, `passive_radar`). Its own Dockerfile (a TCP service on + `:30100`) is used only by `retina-tracker`'s integration-test compose. +- **retina-geolocator** (Python library) — LM delay/Doppler → lat/lon/alt/velocity + solver (single- and multi-node). No network service; vendored into the central + server as a `libs/` git submodule and also usable as a pip-installed batch tool + for offline scripts. +- **retina-custody** (Python library) — cryptographic chain-of-custody for node + data: node identity (`NodeIdentity`), signature verification (`SignatureVerifier`, + `SoftwareCryptoBackend`), and tamper-evident hash chains (`HashChainBuilder`/ + `Verifier`). Makes each node's detections authenticated and tamper-evident. + Vendored into the central server as a `libs/` submodule; imported by + `backend/core/state.py`. *(Role derived from the central server's imports — the + submodule isn't checked out locally.)* +- **retina-analytics** (Python library) — per-node analytics and trust: inter-node + detection association (`InterNodeAssociator`), node reputation and trust scoring + (`NodeReputation`, `TrustScoreState`, `AdsReportEntry`), coordinated by a + `NodeAnalyticsManager`. Vendored into the central server as a `libs/` submodule; + drives live state and the `/api/analytics` route. *(Role derived from the central + server's imports — the submodule isn't checked out locally.)* +- **retina-simulation** (Python) — fleet load-test harness; streams detection + frames for 100–1000 synthetic nodes to a RETINA server over TCP (`:3012`). +- **radar-replay** (Python/Flask) — records a live node's API to JSONL and replays + it through the same API (`:8090`) for offline debugging. + +## 4. Deployment & fleet lifecycle + +**On each edge node:** +- **OS layer** — `owl-os`: a Mender-enabled Debian bookworm arm64 image for the + Pi 5, built with EDI. A/B-partitioned for safe rollback; ships Docker, the + SDRplay API, Chrony, Cloudflared, Avahi (`owl.local`), a WiFi captive portal, + and the Mender client. +- **Application layer** — the `retina-node` Docker Compose stack (images from + `ghcr.io/offworldlabs/*`): `config-merger` (runs once to merge + `default → user → forced` config into `config.yml` + `.env`), then `blah2`, + `blah2_web/api/host`, `tar1090`, `adsb2dd`, and optional `retina-spectrum`. A + node's data-plane target (central collector host + token, ADS-B source) is + selected by a network *profile* applied at "forced" precedence so it can't be + overridden by local edits. + +**Build → provision → update:** +1. **Build** — tagged CI builds produce Mender artifacts: `owl-os` (`os-v*`) builds + the full OS image + `.mender`; `retina-node` (`v*`) builds the compose bundle + into a `.mender` artifact plus the `config-merger` GHCR image. +2. **Provision** — flash the OS image → WiFi captive-portal onboarding → the node + registers as *pending* on `hosted.mender.io` → `node-infra/mender-auto-accept` + (a 30-second systemd timer on the central server) auto-approves nodes matching + an ID prefix → the `retina-node` stack is deployed via Mender OTA → + `config-merger` applies location/network config. +3. **Update** — push new `.mender` artifacts (app bundle and/or full A/B OS image) + through Mender; A/B partitioning + verified reboot gives safe rollback. + Switching the *data-plane* network is automated; switching the *OTA control* + plane is intentionally manual. + +**Central / cloud:** the `Tower-Finder` monorepo container + `tower-finder-service` +run on a DigitalOcean droplet, joined by a shared `retina-edge` Docker network and +fronted by Cloudflare; both deploy via `git reset --hard origin/main` + +`docker compose up -d --build` from GitHub Actions on push to `main`. The public +marketing site is a separate static repo (`landing-page-retina`). + +## 5. Repository map + +| Repo | Role | Stack | +| --- | --- | --- | +| `blah2-arm` | On-node SDR DSP engine + API | C++, Node | +| `adsb2dd` | ADS-B → delay-Doppler truth | Node/Express | +| `tar1090-node` | ADS-B decode + map + proxy | readsb, nginx, Node | +| `retina-spectrum` | Illuminator spectrum survey | C++ | +| `retina-gui` | Node management UI | Python/Flask | +| `Tower-Finder` | Central RETINA server (ingest, track, geolocate, maps) | Python/FastAPI, React/Vite | +| `tower-finder-service` | Illuminator site-survey microservice | Python/FastAPI | +| `retina-tracker` | Multi-target tracker (Kalman/GNN) — library vendored into central server | Python | +| `retina-geolocator` | LM delay/Doppler → position solver — library vendored into central server | Python | +| `retina-custody` | Node identity + signature/hash-chain custody — library vendored into central server | Python | +| `retina-analytics` | Inter-node association + node reputation/trust — library vendored into central server | Python | +| `retina-simulation` | Fleet load-test harness — library vendored into central server | Python | +| `radar-replay` | Record/replay debug tool | Python/Flask | +| `retina-node` | On-device compose bundle + OTA packaging | Compose, Python | +| `owl-os` | Pi 5 OS image builder (Mender/EDI) | EDI, Ansible | +| `node-infra` | Central fleet automation (Mender auto-accept) | Python | +| `landing-page-retina` | RETINA public marketing site | Static HTML | +| `landing-page-owl` | Owl product landing page (placeholder/template at last survey) | Static HTML | diff --git a/docs/contracts/.gitkeep b/docs/contracts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/decisions/.gitkeep b/docs/decisions/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/runbooks/github-actions-claude-review.md b/docs/runbooks/github-actions-claude-review.md new file mode 100644 index 0000000..8baf1d1 --- /dev/null +++ b/docs/runbooks/github-actions-claude-review.md @@ -0,0 +1,94 @@ +# Runbook: Claude Code review workflow runs green but posts no comment + +## Symptom + +You set up the Claude GitHub Actions in a new repo (via `/install-github-app`). +The "Claude Code Review" job shows a green check on every PR, but **no review +comment ever appears**. + +## Cause + +The scaffold that `/install-github-app` generates grants the workflow a +**read-only** token: + +```yaml +permissions: + contents: read + pull-requests: read # can read the diff, cannot post + issues: read + id-token: write +``` + +The review runs and analyses the diff, then is **denied** when it tries to post +the comment. The job still exits green because a failed post is not a job +failure. Confirm in the run log: + +```bash +gh run view --log | grep -iE 'permission_denials_count|PullRequests:' +# "permission_denials_count": 1 ← the denied action was posting the comment +# PullRequests: read ← the read-only grant +``` + +> Note: "green check, no comment" is genuinely ambiguous between this and "clean +> code, nothing to flag." Always read the run log rather than assuming. + +## Fix + +Grant write access in **both** workflow files: + +```yaml +permissions: + contents: read + pull-requests: write + issues: write + id-token: write +``` + +The fastest path in a new repo is to skip the broken scaffold entirely and run +the `core:setup-repo` skill, which installs the corrected workflows. Ask +Claude: + +> set this repo up per `claude-shared` + +If you need to copy the workflows manually, they live in the `core` plugin's +assets and copy as-is into `.github/workflows/` (no leading-dot rename needed): + +```bash +mkdir -p .github/workflows +cp path/to/claude-shared/plugins/core/skills/setup-repo/assets/ci/claude-code-review.yml .github/workflows/ +cp path/to/claude-shared/plugins/core/skills/setup-repo/assets/ci/claude.yml .github/workflows/ +``` + +## Critical gotcha: the fix must land on the default branch first + +`claude-code-action` refuses to run when the workflow file on a PR branch +differs from the copy on the default branch — a security guard so a PR can't +rewrite the review workflow to exfiltrate secrets. Log message: + +``` +Workflow validation failed. The workflow file must exist and have identical +content to the version on the repository's default branch. +``` + +Consequences: + +- You **cannot** validate a workflow change on the PR that makes the change. + Editing the workflow on a feature branch makes the review go *silent* on that + PR. +- Merge the workflow change to `main` (default branch) first. It takes effect on + the **next** normal PR opened afterward. +- A PR opened *before* the change, whose workflow now differs from the updated + default, is also skipped until rebased onto the new default. + +## Verify + +After the fix is on `main`, open a normal PR (one that does **not** touch +`.github/workflows/`) and confirm a `claude[bot]` comment appears. + +## Security note + +`pull-requests: write` on a `pull_request` trigger is sensitive only if the repo +accepts **fork** PRs — untrusted fork code could request the write token. For +private, internal-only org repos with no forks the risk is negligible. If you +expect external forks, use `pull_request_target` with author restrictions +instead. diff --git a/plugins/core/.claude-plugin/plugin.json b/plugins/core/.claude-plugin/plugin.json new file mode 100644 index 0000000..de115c2 --- /dev/null +++ b/plugins/core/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "core", + "version": "0.2.0", + "description": "Core Offworld Labs skills, commands, agents, and hooks shared across all repos.", + "author": { + "name": "Offworld Labs" + } +} diff --git a/plugins/core/agents/.gitkeep b/plugins/core/agents/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/plugins/core/commands/.gitkeep b/plugins/core/commands/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/plugins/core/hooks/.gitkeep b/plugins/core/hooks/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/plugins/core/skills/.gitkeep b/plugins/core/skills/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/plugins/core/skills/pr-description/SKILL.md b/plugins/core/skills/pr-description/SKILL.md new file mode 100644 index 0000000..cfe6ae8 --- /dev/null +++ b/plugins/core/skills/pr-description/SKILL.md @@ -0,0 +1,24 @@ +--- +name: pr-description +description: Use when opening a pull request or when the user asks to write, draft, or generate a PR description, PR summary, or PR body. Reads the branch diff and produces a review-ready description. +--- + +# PR Description + +Write a clear, review-ready pull request description from the current branch's changes. + +## Steps + +1. Determine the base branch (usually `main`) and gather the diff: + - `git merge-base HEAD main` to find the fork point. + - `git diff ...HEAD --stat` for the file-level overview. + - `git diff ...HEAD` for the full change, and `git log ..HEAD` for commit context. +2. Read the changes closely enough to explain *what* changed and *why*, not just which files moved. +3. Produce the description with these sections: + - **Summary** — 1-3 sentences on what this PR does and the motivation behind it. + - **Changes** — bulleted list of the notable changes, grouped by area or concern. + - **Test coverage** — what tests were added or updated, what was run to verify, and any gaps. + - **Review notes** — anything reviewers should scrutinise: risky areas, trade-offs, follow-ups, migrations, or intentionally deferred work. +4. Keep it concise and factual. Do not invent testing that wasn't done — if coverage is thin, say so under Review notes. + +Output the description as Markdown ready to paste into the PR body. diff --git a/plugins/core/skills/setup-repo/SKILL.md b/plugins/core/skills/setup-repo/SKILL.md new file mode 100644 index 0000000..4bc3bc4 --- /dev/null +++ b/plugins/core/skills/setup-repo/SKILL.md @@ -0,0 +1,49 @@ +--- +name: setup-repo +description: Use when setting up a new or blank Offworld Labs repository, or when the user asks to "set up this repo per claude-shared", scaffold a repo to org standards, or add the standard Claude/CI/tooling setup. Scaffolds Claude Code enablement, the Claude review workflows, and stack-specific tooling for the repo. +--- + +# Set up a repo per claude-shared + +Scaffold the current repository to Offworld Labs standards: Claude Code +enablement, the Claude review workflows, and the chosen stack's tooling. The +mechanical file copying is done by the bundled engine; you handle the +interactive parts and the report. + +`ENGINE="${CLAUDE_PLUGIN_ROOT}/skills/setup-repo/scripts/scaffold-repo.sh"` + +## Procedure + +1. **Confirm the target.** Ensure the working directory is a git repo + (`git rev-parse --is-inside-work-tree`). If it is not, offer to run + `git init`; abort if the user declines. + +2. **Determine the stack.** Detect from existing files: `pyproject.toml` or + `requirements*.txt` → `python`. If ambiguous or empty, ask the user to choose + `python` or `none` (ts-frontend / ts-backend arrive in a later phase). + +3. **Scaffold the files.** Run the engine, which never overwrites existing files: + `bash "$ENGINE" . ` + Relay its `WRITTEN` / `SKIPPED` output to the user. + +4. **Install dependencies.** For `python`, use `uv` (the org standard, a fast + drop-in for pip that reads the same `requirements.txt`): create a venv and + install with + `uv venv && uv pip install -r requirements.txt -r requirements-dev.txt`. + If `uv` isn't installed, fall back to + `pip install -r requirements.txt -r requirements-dev.txt` in an active + virtualenv. Report the command and result; if the toolchain is unavailable, + skip and note it rather than failing. + +5. **Flesh out CLAUDE.md.** The stub was just written. Ask the user for a + one-or-two-line description of what this repo does, then fill in the + `Project Overview`, `Build & Test Commands`, and `Local Architecture` + sections from their answer plus what was scaffolded (stack, `ruff check .`, + `ruff format --check .`, `pytest`). If they skip, leave the stub as-is. Keep + CLAUDE.md under the 200-line ceiling noted in the template. + +6. **Report and follow-ups.** Summarise files written vs skipped, then list the + manual steps: add the `CLAUDE_CODE_OAUTH_TOKEN` repo secret, and commit the + workflows to the default branch before Claude review runs (see + `docs/runbooks/github-actions-claude-review.md` for why). Do not commit on the + user's behalf unless asked. diff --git a/plugins/core/skills/setup-repo/assets/ci/ci-python.yml b/plugins/core/skills/setup-repo/assets/ci/ci-python.yml new file mode 100644 index 0000000..18424b7 --- /dev/null +++ b/plugins/core/skills/setup-repo/assets/ci/ci-python.yml @@ -0,0 +1,33 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + lint-and-test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Install dependencies + run: uv pip install --system -r requirements.txt -r requirements-dev.txt + + - name: Ruff lint + run: ruff check . + + - name: Ruff format check + run: ruff format --check . + + - name: Pytest + run: pytest diff --git a/plugins/core/skills/setup-repo/assets/ci/claude-code-review.yml b/plugins/core/skills/setup-repo/assets/ci/claude-code-review.yml new file mode 100644 index 0000000..a2ed2cf --- /dev/null +++ b/plugins/core/skills/setup-repo/assets/ci/claude-code-review.yml @@ -0,0 +1,49 @@ +# Drop-in replacement for the workflow that `/install-github-app` generates. +# The generated scaffold ships pull-requests/issues as `read`, so the review +# runs but is denied when it tries to post — the job goes green with no comment. +# This copy grants `write` so comments post. Copy to .github/workflows/ in a new +# repo (see docs/runbooks/github-actions-claude-review.md). Requires the +# CLAUDE_CODE_OAUTH_TOKEN secret and the Claude GitHub App installed on the repo. +name: Claude Code Review + +on: + pull_request: + types: [opened, synchronize, ready_for_review, reopened] + # Optional: Only run on specific file changes + # paths: + # - "src/**/*.ts" + # - "src/**/*.tsx" + # - "src/**/*.js" + # - "src/**/*.jsx" + +jobs: + claude-review: + # Optional: Filter by PR author + # if: | + # github.event.pull_request.user.login == 'external-contributor' || + # github.event.pull_request.user.login == 'new-developer' || + # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' + + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + issues: write + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code Review + id: claude-review + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' + plugins: 'code-review@claude-code-plugins' + prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://code.claude.com/docs/en/cli-reference for available options diff --git a/plugins/core/skills/setup-repo/assets/ci/claude.yml b/plugins/core/skills/setup-repo/assets/ci/claude.yml new file mode 100644 index 0000000..33c456d --- /dev/null +++ b/plugins/core/skills/setup-repo/assets/ci/claude.yml @@ -0,0 +1,55 @@ +# Drop-in replacement for the workflow that `/install-github-app` generates. +# The generated scaffold ships pull-requests/issues as `read`, so @claude +# responses are denied when posting. This copy grants `write`. Copy to +# .github/workflows/ in a new repo (see +# docs/runbooks/github-actions-claude-review.md). Requires the +# CLAUDE_CODE_OAUTH_TOKEN secret and the Claude GitHub App installed on the repo. +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + issues: write + id-token: write + actions: read # Required for Claude to read CI results on PRs + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + + # This is an optional setting that allows Claude to read CI results on PRs + additional_permissions: | + actions: read + + # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. + # prompt: 'Update the pull request description to include a summary of changes.' + + # Optional: Add claude_args to customize behavior and configuration + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://code.claude.com/docs/en/cli-reference for available options + # claude_args: '--allowed-tools Bash(gh pr *)' diff --git a/plugins/core/skills/setup-repo/assets/claude/CLAUDE.md b/plugins/core/skills/setup-repo/assets/claude/CLAUDE.md new file mode 100644 index 0000000..dfa96b1 --- /dev/null +++ b/plugins/core/skills/setup-repo/assets/claude/CLAUDE.md @@ -0,0 +1,32 @@ + + +# CLAUDE.md + +## Project Overview + + + +## Build & Test Commands + + + +```bash +# install +# build +# test +# lint +``` + +## Local Architecture + + + +## Org-Wide Context + +For shared architecture, cross-service contracts, decisions, and runbooks, see +the Offworld Labs shared docs: https://github.com/offworldlabs/claude-shared/tree/main/docs diff --git a/plugins/core/skills/setup-repo/assets/claude/settings.json b/plugins/core/skills/setup-repo/assets/claude/settings.json new file mode 100644 index 0000000..3ada541 --- /dev/null +++ b/plugins/core/skills/setup-repo/assets/claude/settings.json @@ -0,0 +1,13 @@ +{ + "extraKnownMarketplaces": { + "offworld": { + "source": { + "source": "github", + "repo": "offworldlabs/claude-shared" + } + } + }, + "enabledPlugins": { + "core@offworld": true + } +} diff --git a/plugins/core/skills/setup-repo/assets/editorconfig b/plugins/core/skills/setup-repo/assets/editorconfig new file mode 100644 index 0000000..664ce3c --- /dev/null +++ b/plugins/core/skills/setup-repo/assets/editorconfig @@ -0,0 +1,17 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space + +[*.py] +indent_size = 4 + +[*.{js,jsx,ts,tsx,json,yml,yaml}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/plugins/core/skills/setup-repo/assets/rules/code-style.md b/plugins/core/skills/setup-repo/assets/rules/code-style.md new file mode 100644 index 0000000..c782223 --- /dev/null +++ b/plugins/core/skills/setup-repo/assets/rules/code-style.md @@ -0,0 +1,13 @@ + + +# Code Style Rules + +- TODO: Write minimal, self-documenting code; prefer clarity over cleverness. +- TODO: Match the existing patterns and conventions of the file you are editing. +- TODO: Cover all business logic with tests before marking work complete. diff --git a/plugins/core/skills/setup-repo/assets/rules/security.md b/plugins/core/skills/setup-repo/assets/rules/security.md new file mode 100644 index 0000000..c22a3d5 --- /dev/null +++ b/plugins/core/skills/setup-repo/assets/rules/security.md @@ -0,0 +1,13 @@ + + +# Security Rules + +- TODO: Never commit secrets, credentials, or API keys — use environment variables or a secrets manager. +- TODO: Validate and sanitise all external input at trust boundaries. +- TODO: Keep dependencies patched and pinned; review new dependencies before adding them. diff --git a/plugins/core/skills/setup-repo/assets/stack/python/gitignore b/plugins/core/skills/setup-repo/assets/stack/python/gitignore new file mode 100644 index 0000000..f375b27 --- /dev/null +++ b/plugins/core/skills/setup-repo/assets/stack/python/gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ +.ruff_cache/ +.venv/ +venv/ +*.egg-info/ +build/ +dist/ +.env diff --git a/plugins/core/skills/setup-repo/assets/stack/python/pyproject.toml b/plugins/core/skills/setup-repo/assets/stack/python/pyproject.toml new file mode 100644 index 0000000..4393d70 --- /dev/null +++ b/plugins/core/skills/setup-repo/assets/stack/python/pyproject.toml @@ -0,0 +1,12 @@ +[tool.ruff] +target-version = "py312" +line-length = 120 + +[tool.ruff.lint] +select = ["E", "F", "W"] + +[tool.ruff.format] +quote-style = "double" + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/plugins/core/skills/setup-repo/assets/stack/python/requirements-dev.txt b/plugins/core/skills/setup-repo/assets/stack/python/requirements-dev.txt new file mode 100644 index 0000000..9490314 --- /dev/null +++ b/plugins/core/skills/setup-repo/assets/stack/python/requirements-dev.txt @@ -0,0 +1,2 @@ +ruff>=0.8.0 +pytest>=8.0.0 diff --git a/plugins/core/skills/setup-repo/assets/stack/python/requirements.txt b/plugins/core/skills/setup-repo/assets/stack/python/requirements.txt new file mode 100644 index 0000000..aa4e3b2 --- /dev/null +++ b/plugins/core/skills/setup-repo/assets/stack/python/requirements.txt @@ -0,0 +1 @@ +# Runtime dependencies for this project. Add them below, pinned where practical. diff --git a/plugins/core/skills/setup-repo/assets/stack/python/tests/.gitkeep b/plugins/core/skills/setup-repo/assets/stack/python/tests/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/plugins/core/skills/setup-repo/scripts/scaffold-repo.sh b/plugins/core/skills/setup-repo/scripts/scaffold-repo.sh new file mode 100755 index 0000000..f0c7452 --- /dev/null +++ b/plugins/core/skills/setup-repo/scripts/scaffold-repo.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Deterministic file-scaffolding engine for the core:setup-repo skill. +# Copies bundled assets into a target repo without clobbering existing files. +# Usage: scaffold-repo.sh +# : python | none (ts-frontend / ts-backend land in Phase 2) +set -euo pipefail + +TARGET="${1:?target dir required}" +STACK="${2:-none}" +ASSETS="$(cd "$(dirname "${BASH_SOURCE[0]}")/../assets" && pwd)" + +case "$STACK" in + python|none) ;; + *) echo "unknown stack: $STACK" >&2; exit 2 ;; +esac + +written=() +skipped=() + +copy() { # copy + local src="$1" dest="$2" + mkdir -p "$(dirname "$dest")" + if [[ -e "$dest" ]]; then + skipped+=("$dest") + else + cp "$src" "$dest" + written+=("$dest") + fi +} + +# Claude enablement (always) +copy "$ASSETS/claude/settings.json" "$TARGET/.claude/settings.json" +copy "$ASSETS/claude/CLAUDE.md" "$TARGET/CLAUDE.md" +copy "$ASSETS/rules/security.md" "$TARGET/.claude/rules/security.md" +copy "$ASSETS/rules/code-style.md" "$TARGET/.claude/rules/code-style.md" + +# Claude review workflows (always) +copy "$ASSETS/ci/claude-code-review.yml" "$TARGET/.github/workflows/claude-code-review.yml" +copy "$ASSETS/ci/claude.yml" "$TARGET/.github/workflows/claude.yml" + +# Shared +copy "$ASSETS/editorconfig" "$TARGET/.editorconfig" + +# Stack +case "$STACK" in + python) + copy "$ASSETS/stack/python/pyproject.toml" "$TARGET/pyproject.toml" + copy "$ASSETS/stack/python/requirements.txt" "$TARGET/requirements.txt" + copy "$ASSETS/stack/python/requirements-dev.txt" "$TARGET/requirements-dev.txt" + copy "$ASSETS/stack/python/gitignore" "$TARGET/.gitignore" + copy "$ASSETS/stack/python/tests/.gitkeep" "$TARGET/tests/.gitkeep" + copy "$ASSETS/ci/ci-python.yml" "$TARGET/.github/workflows/ci.yml" + ;; + none) ;; +esac + +echo "WRITTEN:" +printf ' %s\n' "${written[@]:-(none)}" +echo "SKIPPED (already present, left untouched):" +printf ' %s\n' "${skipped[@]:-(none)}" diff --git a/tests/setup-repo/test-ci-assets.sh b/tests/setup-repo/test-ci-assets.sh new file mode 100755 index 0000000..36faf01 --- /dev/null +++ b/tests/setup-repo/test-ci-assets.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(git rev-parse --show-toplevel)" +CI="$ROOT/plugins/core/skills/setup-repo/assets/ci/ci-python.yml" +EC="$ROOT/plugins/core/skills/setup-repo/assets/editorconfig" + +python3 - "$CI" <<'EOF' +import sys +try: + import yaml +except ModuleNotFoundError: + print("pyyaml missing; skipping YAML parse"); sys.exit(0) +doc = yaml.safe_load(open(sys.argv[1])) +# GitHub Actions `on:` is parsed as the boolean True by PyYAML (YAML 1.1), so accept either key +on = doc.get("on", doc.get(True)) +assert on is not None, doc +assert on["push"]["branches"] == ["main"], on +assert "pull_request" in on, on +steps = doc["jobs"]["lint-and-test"]["steps"] +runs = "\n".join(s.get("run", "") for s in steps) +assert "ruff check ." in runs, runs +assert "ruff format --check ." in runs, runs +assert "pytest" in runs, runs +assert "requirements.txt" in runs and "requirements-dev.txt" in runs, runs +# org standard: install with uv, not pip +assert "uv pip install" in runs, runs +uses = [str(s.get("uses", "")) for s in steps] +assert any(u.startswith("astral-sh/setup-uv") for u in uses), uses +setup_py = [s for s in steps if str(s.get("uses", "")).startswith("actions/setup-python")] +assert setup_py and setup_py[0]["with"]["python-version"] == "3.12", setup_py +print("ci-python.yml OK") +EOF + +grep -q "root = true" "$EC" +grep -q "indent_size = 4" "$EC" # python +grep -q "indent_size = 2" "$EC" # js/ts/yaml +echo "ci assets OK" diff --git a/tests/setup-repo/test-python-assets.sh b/tests/setup-repo/test-python-assets.sh new file mode 100755 index 0000000..d734dfa --- /dev/null +++ b/tests/setup-repo/test-python-assets.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(git rev-parse --show-toplevel)" +PY="$ROOT/plugins/core/skills/setup-repo/assets/stack/python" + +python3 - "$PY/pyproject.toml" <<'EOF' +import sys, tomllib +data = tomllib.load(open(sys.argv[1], "rb")) +ruff = data["tool"]["ruff"] +assert ruff["target-version"] == "py312", ruff +assert ruff["line-length"] == 120, ruff +assert data["tool"]["ruff"]["lint"]["select"] == ["E", "F", "W"], ruff +assert data["tool"]["ruff"]["format"]["quote-style"] == "double", ruff +assert data["tool"]["pytest"]["ini_options"]["testpaths"] == ["tests"], data +print("pyproject.toml OK") +EOF + +grep -qE 'ruff>=0\.8' "$PY/requirements-dev.txt" +grep -qE 'pytest>=8' "$PY/requirements-dev.txt" +test -f "$PY/gitignore" && grep -q "__pycache__" "$PY/gitignore" +test -f "$PY/tests/.gitkeep" +echo "python assets OK" diff --git a/tests/setup-repo/test-scaffold.sh b/tests/setup-repo/test-scaffold.sh new file mode 100755 index 0000000..4b3847e --- /dev/null +++ b/tests/setup-repo/test-scaffold.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(git rev-parse --show-toplevel)" +ENGINE="$ROOT/plugins/core/skills/setup-repo/scripts/scaffold-repo.sh" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +git -C "$TMP" init -q + +bash "$ENGINE" "$TMP" python + +for f in .claude/settings.json CLAUDE.md .claude/rules/security.md \ + .claude/rules/code-style.md .github/workflows/claude.yml \ + .github/workflows/claude-code-review.yml .github/workflows/ci.yml \ + .editorconfig pyproject.toml requirements.txt requirements-dev.txt \ + .gitignore tests/.gitkeep; do + test -e "$TMP/$f" || { echo "MISSING: $f" >&2; exit 1; } +done + +python3 -c "import json; json.load(open('$TMP/.claude/settings.json'))" +python3 -c "import tomllib; tomllib.load(open('$TMP/pyproject.toml','rb'))" + +# non-clobber: second run skips everything and reports it, leaving files byte-identical +before="$(shasum "$TMP/pyproject.toml")" +out="$(bash "$ENGINE" "$TMP" python)" +echo "$out" | grep -q "SKIPPED" || { echo "expected SKIPPED report" >&2; exit 1; } +echo "$out" | grep -q "pyproject.toml" || { echo "expected skipped file listed" >&2; exit 1; } +after="$(shasum "$TMP/pyproject.toml")" +[ "$before" = "$after" ] || { echo "skipped file was modified" >&2; exit 1; } + +# none stack: writes the always-files, omits all python-only files +TMP_NONE="$(mktemp -d)" +trap 'rm -rf "$TMP" "$TMP_NONE"' EXIT +git -C "$TMP_NONE" init -q +bash "$ENGINE" "$TMP_NONE" none +for f in .claude/settings.json CLAUDE.md .claude/rules/security.md \ + .claude/rules/code-style.md .github/workflows/claude.yml \ + .github/workflows/claude-code-review.yml .editorconfig; do + test -e "$TMP_NONE/$f" || { echo "MISSING (none stack): $f" >&2; exit 1; } +done +for f in pyproject.toml requirements.txt requirements-dev.txt .gitignore \ + tests/.gitkeep .github/workflows/ci.yml; do + test -e "$TMP_NONE/$f" && { echo "UNEXPECTED (none stack): $f" >&2; exit 1; } +done + +# scaffolded python defaults are internally consistent +mkdir -p "$TMP/src" +cat > "$TMP/src/example.py" <<'PY' +def add(a: int, b: int) -> int: + return a + b +PY +cat > "$TMP/tests/test_example.py" <<'PY' +from src.example import add + + +def test_add() -> None: + assert add(2, 3) == 5 +PY +if command -v ruff >/dev/null 2>&1; then + ( cd "$TMP" && ruff format . >/dev/null && ruff check . && ruff format --check . ) + echo "ruff OK" +else + echo "ruff not installed; skipped" +fi +if command -v pytest >/dev/null 2>&1; then + ( cd "$TMP" && PYTHONPATH=. pytest -q ) + echo "pytest OK" +else + echo "pytest not installed; skipped" +fi + +# unknown stack exits 2 and writes nothing +BOGUS="$(mktemp -d)" +trap 'rm -rf "$TMP" "$TMP_NONE" "$BOGUS"' EXIT +git -C "$BOGUS" init -q +set +e; bash "$ENGINE" "$BOGUS" bogus >/dev/null 2>&1; rc=$?; set -e +[ "$rc" -eq 2 ] || { echo "expected exit 2 for unknown stack, got $rc" >&2; exit 1; } +[ -z "$(ls -A "$BOGUS" | grep -v '^.git$' || true)" ] || { echo "unknown stack wrote files" >&2; exit 1; } +echo "ALL CHECKS PASSED"