Idempotent shell-script installers that bootstrap a fresh Ubuntu 22.04 VPS for one of its AI-agent profiles, with a one-profile-per-host mutex, shared base tooling (incl.
gh), a headless browser, opt-in DB clients and MCP servers, and an opt-in plugin layer shared across every CLI agent.
You rent a Lightsail/Hetzner/DO box. You want one of these stacks on it, fully configured, in a single command:
| Profile | What you get |
|---|---|
cli-bundle |
Claude Code + OpenAI Codex + Google Antigravity + Cursor + OpenCode CLIs (any combination) |
hermes |
Hermes Agent + dashboard + WebUI — Docker stack behind Traefik + Cloudflare (or local Python+uv) |
paperclip |
Paperclip — Node API + embedded Postgres :3100 |
Plus, on every profile:
- Common Linux toolkit (
tmux,git,vim,jq,ripgrep,fd,fzf,htop, …) gh(GitHub CLI) from the official apt repo- Headless browser (
chromiumvia apt — falls back to Playwright's bundled Chromium) - DB clients
psql+clickhouse-client— opt-in (off by default; setINSTALL_DB_CLIENTS=true) - Timezone, swap file, npm user-global prefix,
~/.bashrcPATH - A mutex marker so you don't accidentally stack two agent runtimes on one host
cli-bundle additionally bundles:
- MCP servers for Claude — all opt-in, none on by default (Context7, Linear, Slack, GitHub, Supabase, Sentry, Playwright, Filesystem, Firecrawl)
- Plugins — graphify (default, per CLI), superpowers, OpenSpec, agent-skills, agent-browser, gbrain, gstack, plus the official Anthropic marketplace (Linear/Slack/GitHub/Notion/Atlassian/Asana/Figma/Sentry/Supabase/Vercel)
harness/
├── install.sh # top-level launcher (menu, arg, --status, --force)
├── lib/
│ ├── common.sh # mutex_check, mutex_set, load_env, banner
│ ├── base-packages.sh # install_base_packages, install_github_cli,
│ │ # install_db_clients, install_headless_browser,
│ │ # install_node, install_pnpm, install_uv
│ └── plugins.sh # claude_headless, install_official_claude_plugin,
│ # install_claude_plugin, install_openspec,
│ # install_agent_skills, install_agent_browser,
│ # install_gbrain, install_gstack_for,
│ # print_manual_install_hint
├── docker/
│ ├── gbrain-supabase/ # local Postgres+pgvector for gbrain (127.0.0.1 only)
│ ├── hermes-stack/ # agent + dashboard + WebUI,
│ │ # Traefik + Cloudflare (live; one shared edge)
│ └── paperclip-stack/ # Paperclip (built from source), Traefik + Cloudflare
└── profiles/
├── cli-bundle/ # 01-system → 02-claude → 03-codex → 04-antigravity
│ # → 05-cursor → 05b-opencode
│ # → 06-mcp → 09-plugins
├── hermes/ # 01-system → 02-hermes (local) | see docker/hermes-stack (hosted)
└── paperclip/ # 01-system → 02-paperclip
Per-profile docs live in each profiles/<name>/README.md. Architecture +
rationale in .claude/PLAN.md. Decision log in
CHANGELOG.md. Open design decisions in
.claude/RECOMMENDATIONS.md. Open work items
in .claude/TODO.md.
Operational runbooks + references live in doc/:
- Access map — index of every credential/access, where the
real value lives, and how to set it (mirror it locally in the gitignored
.env_secrets). - Hermes stack (Traefik + Cloudflare) — the
hosted agent + dashboard + WebUI deployment (live on
*.code42.dev). - Paperclip stack (Traefik + Cloudflare) — the hosted Paperclip deployment (built from source; same edge pattern as Hermes).
- gbrain + Supabase / Postgres — back the shared brain with the local Docker Postgres or an external Supabase (Cloud/self-hosted).
- Claude Code CLI on a headless VPS (interactive mode)
— authenticate Claude Code with a Pro/Max subscription on a GUI-less server,
including the onboarding-bypass (
~/.claude.json) that unblocks the interactive REPL.
git clone https://github.com/govtech42/harness.git ~/harness
cd ~/harness
cp profiles/<profile>/.env.example profiles/<profile>/.env
nano profiles/<profile>/.env # fill provider keys, toggles, etc.
./install.sh # interactive menu
# or jump straight in:
./install.sh cli-bundleExample menu:
No profile installed yet.
Pick a profile to install:
1) cli-bundle
2) hermes
3) paperclip
4) Quit
#?
./install.sh # interactive picker
./install.sh <profile> # install named profile
./install.sh <profile> --force # override mutex (see below)
./install.sh --status # show installed profile, if any
./install.sh --help # usage<profile> is one of: cli-bundle, hermes, paperclip.
cli-bundle installs any subset of its six CLIs — all at once, or 1, 2, 3… at
a time. Pass a selection on the command line, use the interactive menu, or
fall back to the .env toggles:
./install.sh cli-bundle --all # all six
./install.sh cli-bundle --clis claude,codex # just these two
./install.sh cli-bundle claude codex antigravity # bare names (three)
./install.sh cli-bundle --env # use .env toggles, no prompt
./install.sh cli-bundle # interactive multi-select menuPrecedence: CLI args → interactive menu (TTY) → .env toggles. A passed
selection overrides the INSTALL_* toggles for that run. Re-running with a
different selection is idempotent, so you can install claude now and add
codex later without reinstalling anything else.
The launcher writes the profile name to ~/.harness-profile (chmod 600) when
an install completes. Any subsequent run for a different profile refuses
with exit code 2:
ERROR: profile 'cli-bundle' already installed on this host.
Refusing to install 'hermes'.
Override: --force (not recommended; profiles are not designed to coexist)
Why: these stacks compete for PATH entries, ports, systemd unit names, and
memory on small VPSes. Coexistence isn't supported. If you really know
better, --force bypasses the check.
The cli-bundle profile is the exception: it intentionally stacks five
thin CLI clients (Claude, Codex, Antigravity, Cursor, OpenCode)
which have disjoint config dirs and no port binds.
| Requirement | Detail |
|---|---|
| OS | Ubuntu 22.04 LTS (other Debian-likes may work but are untested) |
| RAM | 2 GB minimum; 4 GB recommended for cli-bundle with MCPs + plugins |
| Disk | 10 GB free |
| Network | Outbound HTTPS to npm, GitHub, ClickHouse repo, each agent provider |
| Inbound | Static IP / SSH on :22; OAuth callbacks for some agents |
| User | ubuntu (or any user with sudo and a writable $HOME) |
Five CLIs, installable in any combination (all at once or a few at a time — see
Selecting which CLIs). The .env toggles
below are the default selection; a command-line selection overrides them.
Defaults: all five true (Claude, Codex, Antigravity, Cursor, OpenCode).
| Toggle | CLI | Install path |
|---|---|---|
INSTALL_CLAUDE |
Claude Code | npm i -g @anthropic-ai/claude-code |
INSTALL_CODEX |
OpenAI Codex | npm i -g @openai/codex |
INSTALL_ANTIGRAVITY |
Google Antigravity | upstream installer (curl … | bash) |
INSTALL_CURSOR |
Cursor agent | upstream installer (curl … | bash) |
INSTALL_OPENCODE |
OpenCode | upstream installer (curl -fsSL https://opencode.ai/install | bash) |
Plus:
- MCP servers registered for Claude (
06-mcp.sh) — all off by default: Context7, Linear, Slack, GitHub, Supabase, Sentry, Playwright, Filesystem, Firecrawl. - Plugins (
09-plugins.sh) — see Plugins below.
Full docs: profiles/cli-bundle/README.md.
Two ways to run Hermes:
-
Hosted (current deployment) —
docker/hermes-stack/. Dockerized nesquena/hermes-webui three-container layout: official agent + official dashboard + custom WebUI, behind a Traefik edge and Cloudflare (proxied DNS + Zero Trust Access), with Let's Encrypt certs via DNS-01. This is what runs live (chat.code42.devhermes.code42.dev). Config indocker/hermes-stack/.env.local; bring up with./deploy.sh.
-
Local install —
profiles/hermes(./install.sh hermes). Upstreamcurl|bashinstaller (HERMES_USE_UPSTREAM_INSTALLER=true) sets up Python 3.11 viauvand putshermeson PATH. Also installs gbrain and wires it into Hermes by default (03-gbrain.sh,INSTALL_GBRAIN=true) as a local stdio MCP server in~/.hermes/config.yaml. Opt-inINSTALL_GBRAIN_SUPABASE=true(04-gbrain-db.sh) turns the VPS into a brain-host (Dockerized Supabase/Postgres +gbrain serve --httpOAuth). See the Hermes README.
Two ways to run Paperclip:
- Local install —
profiles/paperclip(./install.sh paperclip).git clone + pnpm install + pnpm build. Embedded PostgreSQL is provisioned by the app on first run; no external DB needed. Optional systemd unit for the API server on port3100. - Hosted (Docker) —
docker/paperclip-stack/. Builds Paperclip from source and runs it behind a Traefik edge and Cloudflare (proxied DNS + Zero Trust Access), the same pattern as the Hermes stack. Embedded PostgreSQL + state persist on a Docker volume. Config indocker/paperclip-stack/.env.local; bring up with./deploy.sh.
Available in cli-bundle via 09-plugins.sh. Headless install where the CLI
supports it; printed manual hint otherwise.
| Plugin | Toggle | How it installs |
|---|---|---|
| graphify | INSTALL_GRAPHIFY (default on) |
uv tool install graphifyy, then graphify install registered for every installed CLI |
| superpowers | INSTALL_SUPERPOWERS |
Claude headless; Codex/Cursor/OpenCode manual hints; Antigravity not documented |
| OpenSpec | INSTALL_OPENSPEC |
universal npm-global (/opsx:* slash commands from any CLI) |
| agent-skills | INSTALL_AGENT_SKILLS |
non-interactive CLI install across claude-code / codex / cursor / opencode |
| agent-browser | INSTALL_AGENT_BROWSER |
npm i -g agent-browser + cross-CLI skill (npx skills add vercel-labs/agent-browser) |
| gbrain | INSTALL_GBRAIN |
bun install -g github:garrytan/gbrain (bun bootstrapped if missing) |
| gstack | INSTALL_GSTACK |
git-clone garrytan/gstack into each GSTACK_TARGETS host's skills dir |
Firecrawl is wired as an MCP server (INSTALL_FIRECRAWL in 06-mcp.sh,
package firecrawl-mcp, needs FIRECRAWL_API_KEY) since it is an MCP, not a
plugin.
agent-skills is a curated,
security-validated skill registry. INSTALL_AGENT_SKILLS=true installs a
default set tuned for web/mobile product work (Next.js + React Native, NestJS +
Nx monorepo, accessibility + security) globally to every installed CLI that
supports it. Override the skill list with AGENT_SKILLS_LIST and the target
CLIs with AGENT_SKILLS_AGENTS in .env. Browse the catalog with
npx @tech-leads-club/agent-skills list.
Plus the official Anthropic marketplace (Claude-only, bundles pre-configured MCP + skills + slash commands):
INSTALL_LINEAR_PLUGIN, INSTALL_SLACK_PLUGIN, INSTALL_GITHUB_PLUGIN,
INSTALL_NOTION_PLUGIN, INSTALL_ATLASSIAN_PLUGIN, INSTALL_ASANA_PLUGIN,
INSTALL_FIGMA_PLUGIN, INSTALL_SENTRY_PLUGIN, INSTALL_SUPABASE_PLUGIN,
INSTALL_VERCEL_PLUGIN.
These coexist with the raw MCP toggles in 06-mcp.sh (e.g. INSTALL_LINEAR=true
registers the bare MCP endpoint); the official plugin is the richer path.
Every profile's 01-system.sh installs gh via install_github_cli()
(lib/base-packages.sh). gh is not in the stock Ubuntu repos, so it's wired
from the official GitHub apt repo with a signed keyring at
/etc/apt/keyrings/githubcli-keyring.gpg — the same pattern as the ClickHouse
client. Toggle off with INSTALL_GH=false.
Off by default — set INSTALL_DB_CLIENTS=true in the profile's .env to
have 01-system.sh install:
postgresql-client(Ubuntu apt) →psqlclickhouse-clientfrom the official ClickHouse deb repo (signed keyring at/etc/apt/keyrings/clickhouse-keyring.gpg)
Granular control: INSTALL_POSTGRES_CLIENT, INSTALL_CLICKHOUSE_CLIENT
(both also default false).
install_headless_browser() in lib/base-packages.sh tries chromium-browser
then chromium via apt; warns (does not fail) if no apt package resolves.
Used for general agent shell work. Default INSTALL_HEADLESS_BROWSER=true.
Playwright is a separate concern: INSTALL_PLAYWRIGHT=true in 06-mcp.sh
does its own playwright install-deps + bundled-Chromium download +
@playwright/mcp@latest MCP registration.
- Each profile's
.env(copied from.env.example) holds provider keys and toggles. The loaderchmod 600s it on first run. ANTHROPIC_API_KEY(and friends) get appended to~/.bashrc; the file is re-chmoded to600after the write.~/.harness-profileischmod 600.- Tokens (OAuth, etc.) live under
~/.claude/,~/.codex/, etc. — back these up before destroying the VPS:tar czf harness-backup.tgz ~/.claude ~/.opencode \ ~/.codex ~/.cursor ~/.antigravity ~/.harness-profile
Secret-handling rules and how to report a vulnerability live in
SECURITY.md.
# show installed profile
./install.sh --status
# re-run a profile (idempotent — re-applies .env changes)
./install.sh <profile>
# wipe the marker if you intend a clean swap (combine with --force)
rm ~/.harness-profile && ./install.sh <other-profile>
# update an npm-global CLI
npm update -g @anthropic-ai/claude-code
npm update -g @openai/codex
npm update -g @fission-ai/openspec # if INSTALL_OPENSPEC=trueLocal test harness in tests/:
# install deps (Ubuntu/Debian)
sudo apt-get install -y shellcheck bats
# install deps (macOS)
brew install shellcheck bats-core
bash tests/lint.sh
bash tests/check_env_completeness.sh
bats tests/GitHub Actions:
ci.yml— runslint,env-completeness, andbatsjobs in parallel on every push and PR.release.yml— onv*tag, reruns CI then publishes a GitHub release with notes extracted fromCHANGELOG.md.deploy.yml— on push tomain(or manualworkflow_dispatch), rsyncs the repo to the VPS at/opt/harness, owned by theubuntuuser. See Deploy.
deploy.yml ships the repo to a server over SSH and lands it at /opt/harness,
owned by ubuntu (it sudo-creates the dir once, then syncs unprivileged so
files land with the login user's ownership). It runs on every push to main
and can be triggered manually from the Actions tab.
Credentials are GitHub Actions secrets — never committed (this repo is public). Set them once (Settings → Secrets and variables → Actions, or via CLI):
printf '<server-ip>' | gh secret set DEPLOY_HOST
printf 'ubuntu' | gh secret set DEPLOY_USER
printf '22' | gh secret set DEPLOY_PORT
gh secret set DEPLOY_SSH_KEY < ~/.ssh/your_key.pem # full PEM, unprivileged user with passwordless sudo.env_secrets is a gitignored local note of these same values; it is never
pushed. The deploy uses SSH only — no GitHub PAT is involved. rsync excludes
server-side runtime state (.env, .env_secrets, .harness-profile) so a
deploy never overwrites a host's real config.
| Symptom | Fix |
|---|---|
command not found: claude (or any CLI) |
source ~/.bashrc (PATH update lives there) |
Refusing to install '<other>' |
Expected — see Mutex |
| MCP OAuth callback never returns | Open the URL on your laptop, not on the VPS |
| Playwright MCP install fails | sudo npx playwright install-deps |
| Out of RAM mid-install | Bump SWAP_SIZE_GB in .env, re-run |
| ClickHouse repo signature error | Delete /etc/apt/keyrings/clickhouse-keyring.gpg and re-run |
| GitHub CLI repo signature error | Delete /etc/apt/keyrings/githubcli-keyring.gpg and re-run |
| Firecrawl MCP returns 401 | Set FIRECRAWL_API_KEY in .env (key from firecrawl.dev) |
Plugin install hangs in claude -p |
Check ~/.claude/logs/ and retry with --dangerously-skip-permissions |
chromium package missing |
Rely on Playwright's bundled Chromium (set INSTALL_PLAYWRIGHT=true) |
# syntax-check every shell script
find . -name '*.sh' -exec bash -n {} \; -print
# shellcheck (optional)
find . -name '*.sh' -exec shellcheck -x -S warning -e SC1090,SC1091 {} \;Conventions baked into every script:
set -euo pipefail- Idempotent (
mkdir -p,grep -qguards,git pull --ff-only,claude mcp removebeforeadd) - Pure bash, no perl/python dependencies for the launcher itself
.envalways sourced via the sharedload_envhelper
| Profile | Install path | Real-VPS verified |
|---|---|---|
| cli-bundle | npm global + upstream installers (5 CLIs + plugins) | ✅ (partial) |
| hermes (stack) | Docker 3-container + Traefik + Cloudflare Access | ✅ live (*.code42.dev) |
| hermes (local) | upstream curl | bash |
|
| paperclip (local) | git clone + pnpm build |
|
| paperclip (stack) | Docker (built from source) + Traefik + Cloudflare |
Releases: v1.0.5 (latest) — see CHANGELOG.md for the full reverse-chrono history.
Open design decisions awaiting review: .claude/RECOMMENDATIONS.md.
Open work items: .claude/TODO.md.
MIT — see LICENSE.md.