Forge — a browser-accessible Claude Code workstation (NixOS module)
  • HTML 57.5%
  • Python 22.2%
  • Nix 19.2%
  • JavaScript 1.1%
Find a file
Pedro Araújo 7e7bf71c4e
All checks were successful
CI / check (push) Successful in 6s
Support GitHub repos in the issues/PRs panel drawer (#46) (#47)
Teaches the per-channel panel drawer to speak GitHub as a second provider, so a channel can point at a GitHub repo and get the same Issues/PRs tabs and `#` palette that Forgejo channels have. Closes #46.

## What changed

- **`web/panel.py`** — the substantive work. Refactored the hardcoded-Forgejo sidecar into a `Provider` base class with `ForgejoProvider`/`GitHubProvider` subclasses, isolating the four real divergences the issue identified:
  - **Auth**: `token <t>` vs `Bearer <t>` (+ `X-GitHub-Api-Version`).
  - **API host**: Forgejo serves API+web from one host; GitHub splits `api.github.com` from `github.com` (or `/api/v3` for Enterprise), derived from the web base.
  - **Markdown**: Forgejo's capitalised `{Text,Mode,Context}` vs GitHub's lowercase `{text,mode,context}`.
  - **Attachments**: Forgejo proxies auth-gated images; GitHub user-content is public, so the rewrite/proxy is a passthrough.
  - Endpoints stay provider-agnostic and reuse the existing JSON thinning. GitHub's `/issues` returns PRs too, so they're filtered out to match the issues-only tab.
- **`modules/forge.nix`** — `github` added to the `type` enum; generalised option docs; a build-time assertion that a panel carries `repo`/`tokenFile` (and `baseUrl` for Forgejo).
- **`modules/launcher.nix`** — `panelUrl` accepts `github` and defaults its web base to `github.com`.
- **`web/index.html`** — `isRepoPanel()`/`forgeLabel()` helpers replace the three per-type checks and make user-visible strings ("Open in GitHub ↗", palette tooltip) provider-aware. Renderer and palette are otherwise unchanged.
- **`flake.nix`** — the `eval` check now configures both a Forgejo and a GitHub panel, so both providers' config paths are exercised in CI.

## Design note

The two-host question resolves without a new config field: `baseUrl` stays the **web** host (what the drawer links to), and each provider derives its own API host. For public GitHub, `baseUrl` is optional and defaults to `https://github.com`.

## Verification

- `nix flake check` passes; the built `channels.json` shows a GitHub panel resolving to `url: https://github.com/<owner>/<repo>` with `repo`/`tokenFile` kept server-side.
- `python3 -m py_compile web/panel.py` clean; `nixpkgs-fmt` clean.

## Not covered

- Untested against a live GitHub repo — the eval is static. The markdown passthrough for relative image paths (rare in issue bodies) is the one spot worth eyeballing on a real repo.

Reviewed-on: #47
2026-09-20 12:00:06 +01:00
.forgejo ci: add Forgejo Actions workflow (nix flake check + launcher parse) (#9) 2026-07-17 11:47:23 +01:00
modules Support GitHub repos in the issues/PRs panel drawer (#46) (#47) 2026-09-20 12:00:06 +01:00
web Support GitHub repos in the issues/PRs panel drawer (#46) (#47) 2026-09-20 12:00:06 +01:00
.gitignore Forge: browser Claude Code workstation as a reusable NixOS flake 2026-07-09 14:51:09 +01:00
CLAUDE.md docs: add CLAUDE.md — track work as Forgejo issues 2026-07-15 09:30:28 +01:00
flake.lock Forge: browser Claude Code workstation as a reusable NixOS flake 2026-07-09 14:51:09 +01:00
flake.nix Support GitHub repos in the issues/PRs panel drawer (#46) (#47) 2026-09-20 12:00:06 +01:00
LICENSE Forge: browser Claude Code workstation as a reusable NixOS flake 2026-07-09 14:51:09 +01:00
README.md Let a single channel override sessionCommand (#29) 2026-08-24 10:29:43 +01:00

Forge

A browser-accessible Claude Code workstation for NixOS.

Forge turns a headless NixOS box into a web app where each of your repos gets its own persistent terminal ("channel") running Claude Code — reachable from any browser, phone included. Close the tab, come back later, the session is still there.

channels in a sidebar, a terminal in an iframe

What you get

  • One channel per repo — each is a ttyd web shell exec'ing into a persistent tmux session that runs Claude Code (or any command you choose). Detach/reattach across browser disconnects for free.
  • A launcher served by nginx on port 80: a sidebar of channels, an Alt+K command palette (switch channels, split panes, open lazygit), a JuiceSSH-style keybar for touch devices (Esc/Tab/arrows/Ctrl-chords), and an upload dropzone (drop/paste a file → it lands on the box and its path is typed into the active session).
  • Tuned for the browser terminal: extended keys (Shift+Enter, Ctrl-chords), mouse scroll, and OSC 52 clipboard all work through xterm.js.
  • One route to expose: everything is same-origin behind nginx:80, so you put a single reverse-proxy route (+ your auth of choice) in front of it.

Requirements

  • NixOS with flakes enabled.
  • nixpkgs unstable — the default sessionCommand uses pkgs.claude-code, which lives in unstable. On stable nixpkgs, set sessionCommand to your own binary/wrapper.
  • A real login user on the host to own the sessions (Forge does not create it).

Usage

Add Forge as a flake input and enable the module on the host:

{
  inputs.forge.url = "git+https://git.rdbytes.pt/pedromcaraujo/forge?ref=main";
  # (or github:<you>/forge once mirrored)

  outputs = { self, nixpkgs, forge, ... }: {
    nixosConfigurations.my-workstation = nixpkgs.lib.nixosSystem {
      modules = [
        forge.nixosModules.forge
        ({ ... }: {
          myServices.forge = {
            enable = true;
            user = "me";                       # existing login user
            order = [ "app" "infra" ];         # sidebar order + 1..9 hotkeys
            channels = {
              app   = { cwd = "/home/me/code/app"; };
              infra = { cwd = "/home/me/code/infra"; };
            };
          };
        })
      ];
    };
  };
}

Then front http://<host>:80 with your reverse proxy + authentication. ttyd, the upload sidecar, and the palette sidecar all bind loopback only.

Using the launcher

Once it's up, open the launcher in a browser. The active channel fills the screen; everything else is chrome around it.

Switching channels

  • Click a channel in the sidebar (desktop) or the pill row (mobile).
  • Command paletteAlt+K (Option+K on Mac), or tap the Forge brand on a phone. Type to filter, / to move, Enter to activate, Esc to close. The palette also lists actions (see below).
  • The status dot on each channel shows what it is doing now: hollow = no session, grey = a session nobody has prompted yet, green = a turn running right now, amber = it is waiting on you. All of it is read from the terminal title Claude Code sets on itself — ⠂ … while working, ✳ … when waiting, and the text stays the literal Claude Code until there is a conversation to summarise, which is what tells an untouched session apart. Claude's own answer rather than a guess, and nothing to install for it.
  • Ctrl/Cmd/middle-click a channel opens it in a new browser tab.

Host vitals — CPU and RAM gauges for the whole box sit at the foot of the sidebar (a compact pill next to the channel chips on mobile), refreshed every 5s. They turn amber past 75% and red past 90%; the second line adds absolute memory and disk use, and the tooltip has load average, swap, and uptime. This is the box, not a per-channel breakdown — put that in Claude Code's own status line if you want it.

Light / dark theme — the ☀️/🌙 button in the sidebar header toggles a high-contrast light theme (chrome and terminal) tuned for e-ink readers like a Boox: black-on-white, no cursor blink, no glow/shadows. The choice is per-device (saved in localStorage), so one browser can run light while another stays dark. The dark default follows the deploy's theme option.

Palette actions (run against the current channel's tmux session)

  • Split a second Claude pane right/below, or a plain shell split.
  • Zoom (toggle fullscreen) the active pane.
  • Kill the side pane (the lone main pane is protected).
  • Open lazygit in a popup.

Sending files — drop a file anywhere on the page, paste an image, or tap the dropzone to pick one. It uploads to the box under uploadDir/<channel>/ and its path is typed into the active session, ready for Claude to read.

On a phone / tablet

  • A keybar sits at the bottom: Esc · Tab · Enter · Mode · ↑ ↓ ← → · Home · End · PgUp · PgDn, plus (palette) and (reconnect a stale session). These go straight to the session, so they work even where the soft keyboard's own keys don't. Mode sends Shift+Tab — the chord you can't type on a phone — to cycle Claude Code's permission/plan mode.
  • A compose box lets you type a line into a normal input and send it with one tap — this sidesteps Android IME quirks that duplicate characters when typing straight into the terminal.
  • The soft keyboard stays closed when you switch channels. Tap the terminal itself when you actually want to type; use the keybar/compose box otherwise.
  • (or reselecting the channel) reconnects a session that went stale after the phone slept — the tmux session and its scrollback are still there.

Persistence — every channel runs inside tmux new-session -A, so closing the tab or losing the connection never kills your work. The next visit re-attaches.

Add Forge to your home screen (it's an installable PWA) for an app-like, full-screen shell.

Options (myServices.forge)

Option Default Description
enable false Turn Forge on.
user "pma" Login user that owns the ttyd/tmux sessions and palette sidecar.
channels {} Attrset name -> { cwd; basePath?; command?; }. One channel per entry. command overrides sessionCommand for that channel alone.
order [] Explicit channel order (sidebar, 1..9 hotkeys, default channel). Omitted channels append alphabetically.
basePort 7681 First loopback port; channel N binds basePort + N. Sidecars take the two ports above the last channel.
sessionCommand ${pkgs.claude-code}/bin/claude Command run inside each tmux session, unless that channel sets its own command.
sessionEnvFiles [] Files sourced (if readable) before sessionCommand — inject tokens without baking secrets into the module.
theme Tokyo Night xterm.js colour theme.
branding "Forge" Product name in the launcher/PWA.
uploadDir /var/lib/forge-uploads Where the upload sidecar writes files.
maxUploadBytes 26214400 Per-upload cap (also nginx client_max_body_size).
nginx.default true Claim the default vhost + bind :80 + open the firewall. Disable to front Forge yourself.

Injecting secrets

Forge never reads secrets directly. To give sessions API tokens, write them to a shell file (e.g. via sops-nix) and point sessionEnvFiles at it:

myServices.forge.sessionEnvFiles = [ "/etc/profile.d/forge-tokens.sh" ];

Each channel sources those files before launching, and re-sources them inside the tmux session so freshly-rotated tokens reach already-running sessions.

Layout

modules/forge.nix      options + wiring
modules/ttyd.nix       one ttyd web shell + tmux session per channel
modules/launcher.nix   nginx launcher, /channels.json, upload + palette sidecars
modules/shell.nix      system tmux.conf tuned for the browser terminal
web/                   launcher page, sidecars (upload.py, tmux_ctl.py), PWA assets

License

MIT.