- HTML 57.5%
- Python 22.2%
- Nix 19.2%
- JavaScript 1.1%
|
All checks were successful
CI / check (push) Successful in 6s
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 |
||
|---|---|---|
| .forgejo | ||
| modules | ||
| web | ||
| .gitignore | ||
| CLAUDE.md | ||
| flake.lock | ||
| flake.nix | ||
| LICENSE | ||
| README.md | ||
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.
What you get
- One channel per repo — each is a ttyd
web shell exec'ing into a persistent
tmuxsession 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
sessionCommandusespkgs.claude-code, which lives in unstable. On stable nixpkgs, setsessionCommandto 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 palette —
Alt+K(Option+Kon Mac), or tap the Forge brand on a phone. Type to filter,↑/↓to move,Enterto activate,Escto 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 literalClaude Codeuntil 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.Modesends 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.