← All articles

CLI GUY: a browser-based graphical shell for any Unix box

First post on this blog. I built CLI GUY over an afternoon, pairing with pi agent, the coding agent I work on at Rational AI.

Heads-up. This is an experiment, not production code. I am pretty sure there are several security issues in here. I am leaving them as an exercise for the reader. Do not put this on the open internet without thinking hard about what you’re doing.

Where this came from

I read Marcus’s post, A native graphical shell for SSH (on Hacker News), which makes the case for running real GUI apps over SSH instead of a character grid. I liked the idea, and I enjoyed the comments even more: the usual mix of “Cockpit already does this,” “just use X11 forwarding,” and “this is overkill.”

Marcus’s design leans on a native client. I wanted to try the lazier version of the same idea: could I get a real graphical shell for any Unix box with no native client at all, just a browser already on the machine I’m sitting at? And could brainstorming it with a coding agent get me to something elegant in very few lines, rather than a big framework?

So I tried it. CLI GUY is what I ended up with.

What it is

A single remote-first GUI for any Unix box: one tunnel, one auth, one URL. It runs as one Node process, has no build step, and works in any browser already on the machine you’re at.

The idea behind it is about 300 lines: one server, one protocol, one schema-driven UI. Everything else in the repo is app-specific adapters and renderers, most of it agent-written, added a tab at a time.

How it works

Three choices do almost all the work.

One server, one port. Every “app” is a function the server can call. No per-app HTTP servers, no per-app ports, no per-app auth. You trade per-process OS isolation for much simpler everything else.

One protocol: JSON over WebSocket. Eight message types cover requests, results, streaming output, cancellation, and bidirectional input. The whole protocol fits on a screen:

// client → server
{ "id": "x", "app": "system", "op": "uptime", "args": {} }
{ "id": "x", "type": "cancel" }
{ "type": "cancel_all" }
{ "id": "x", "type": "input", "data": "ls\n" }

// server → client
{ "id": "x", "type": "result", "data": { "up": "23d 14h" } }
{ "id": "x", "type": "chunk",  "data": "bin  etc  home  var\n" }
{ "id": "x", "type": "end" }
{ "id": "x", "type": "error", "message": "unknown op" }

That input message is what makes a real PTY terminal in the browser possible.

Schema-driven UI. Each adapter declares its operations as JSON schemas. The frontend reads /api/manifest and renders a form for every op. You write zero frontend code per app, until you want to.

See it run

Here is the whole concept as a complete, runnable program: one server, three adapters (a live system monitor, an HTTP inspector, a disk view), and a frontend. About 300 lines, no build step. The full source is in the appendix; below is the heart of it.

The system tab: a live CPU / mem / load snapshot, pushed once a second over the WebSocket.

The http tab: the form is generated from the adapter’s JSON schema; press run and you get the result.

The backend loads every file in adapters/, builds the manifest, and dispatches each WebSocket message to an op, streaming or unary, with cancellation. The dispatch loop is the only interesting part:

new WebSocketServer({ server }).on('connection', ws => {
  const inflight = new Map();                 // id → AbortController
  const reply = obj => ws.send(JSON.stringify(obj));

  ws.on('message', async raw => {
    const msg = JSON.parse(raw);
    if (msg.type === 'cancel') return inflight.get(msg.id)?.abort();

    const op = apps[msg.app]?.ops?.[msg.op];
    const ac = new AbortController();
    inflight.set(msg.id, ac);
    if (op.stream) {
      await op.stream(msg.args, { signal: ac.signal, send: d => reply({ id: msg.id, type: 'chunk', data: d }) });
      reply({ id: msg.id, type: 'end' });
    } else {
      reply({ id: msg.id, type: 'result', data: await op.run(msg.args, { signal: ac.signal }) });
    }
    inflight.delete(msg.id);
  });
});

An adapter is just a name, an icon, and ops. A streaming one pushes a snapshot every second:

// adapters/system.js  (full version in the appendix)
export default {
  name: 'system', icon: '🖥️',
  ops: {
    live: {
      autoRun: true,
      stream: async (_args, { send, signal }) => {
        while (!signal.aborted) {
          send(JSON.stringify(snapshot()));   // { cpu, mem, load, ... }
          await sleep(1000, signal);
        }
      },
    },
  },
};

Drop a file in adapters/, restart, get a tab. Adapters that return a flat object render as a plain key/value readout for free; that is what both screenshots above are.

What it grows into

The full version I run is the same engine with more adapters and a few custom renderers. Eleven tabs:

⌨️ terminal (real PTY via xterm.js, Ctrl+C, vim, htop), 🖥️ system (live CPU/mem/load sparklines), ⚙️ procs (live table with filter + kill buttons), 🌐 net (streaming ping with latency sparkline, DNS lookup), 🔀 git (commit cards, status badges, colored diff), 💾 disk (clickable squarified treemap), 🐳 docker (containers, images, streaming logs), 🔗 http (request inspector with timing and inline images), 🗃️ sqlite (sortable result tables), 📁 files (image thumbnails plus a CodeMirror editor that saves with Cmd+S), 📰 hackernews (orange story cards). All sharing one tunnel, one auth, one process.

A few of them in action:

A custom renderer is an optional Preact component handed a { data, ops } handle, so a view can call other ops, tap a stream, or re-run itself with new args. That is how the disk treemap drills down on click and the process list gets kill buttons. Adapters that don’t need one fall back to the plain readout from the section above.

And the adapters don’t have to be hand-written. The Hacker News tab wraps a CLI generated by Printing Press, which turns any API into a JSON-speaking CLI. Point an agent at any CLI with structured output (gh, kubectl, docker, your own tools), let it read the --help, and you get an adapter, plus a renderer when the shape deserves one. Adding a dashboard for something you care about becomes an afternoon of conversation, not a project.

But isn’t this just Cockpit, or a reverse proxy?

Fair question, and I’ll answer it before someone does in the comments. For exposing things you already run, a reverse proxy is the right tool: Caddy with basic auth and HTTPS, or Tailscale, is simpler, and you should use it. I do.

But that is a different job, with a different focus. A reverse proxy or Tailscale solves reaching a box; the focus here is the GUI itself, giving each tool a graphical interface it never had. A reverse proxy gives you N services behind one hostname, each still its own app, its own UI, its own auth model, its own port. Cockpit gives you a fixed (and genuinely good) admin panel you don’t extend in an afternoon. CLI GUY is neither: it is one RPC layer where every tool is just a function, and the UI for it is generated from a schema. Adding kubectl, or your own internal CLI, is the small adapter from the section above, not a new service to deploy, proxy, and secure.

And there are things a reverse proxy can’t hand you uniformly across every tool: streaming output, cancellation, and bidirectional stdin. The same input message that makes the browser PTY work drives every other streaming op too. You get that once, for free, for everything you add. That uniformity is the point; the dashboard is a side effect.

Tradeoffs, and running it

Everything runs in one process, so a buggy adapter can see another adapter’s memory, and compromising the session gives you every app. Adapters are Node-specific, there are no native clients, and (as the heads-up said) there are security holes I haven’t fixed. Don’t put this on the public internet.

In exchange: zero install, zero build step, one auth, one tunnel, about 50 lines per app, the whole thing fits in your head. For a homelab dashboard, a dev-box launcher, an internal tools panel, it’s the right shape. For multi-tenant production, it isn’t.

The minimal version runs with npm i ws && node server.js. The full eleven-tab version is on GitHub at github.com/severino32/cli-guy:

git clone https://github.com/severino32/cli-guy
cd cli-guy && npm install
PASSWORD=hello npm start
# → http://localhost:7777

I wrote almost none of it. The agent wrote the code; I drove the design and pushed back on the parts that didn’t compound. That division of labor is the real reason the architecture looks the way it does: small, isolated, schema-typed adapters are exactly what lets an agent add a tab without touching anything else.

One port. One protocol. One adapter file per app. That’s the bet.

This is the kind of thing I build for fun between the bigger projects: small, self-contained experiments I can finish in an afternoon. More of them here as I go.


Appendix: the minimal source, in full

Below are the six files behind the minimal version above: the complete program, about 300 lines, no build step. Each file is collapsed so the list stays short; click to expand the ones you want to read.

To run it yourself, save the six files into one folder with this layout:

server.js
adapters/system.js
adapters/http.js
adapters/disk.js
public/app.js
public/index.html

then, from that folder (Node 20+):

npm init -y && npm pkg set type=module && npm i ws
node server.js
# → http://localhost:7777

The full eleven-tab version is on GitHub: github.com/severino32/cli-guy.

server.js (the whole backend)
// server.js
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { WebSocketServer } from 'ws';

const ROOT = import.meta.dirname;
const PORT = process.env.PORT || 7777;

// Drop a file in ./adapters and it becomes an app at boot.
const apps = {};
for (const f of fs.readdirSync(path.join(ROOT, 'adapters'))) {
  if (!f.endsWith('.js')) continue;
  const mod = await import(pathToFileURL(path.join(ROOT, 'adapters', f)));
  apps[mod.default.name] = mod.default;
}

// The manifest is everything the frontend needs to render a form per op.
const manifest = () => Object.fromEntries(Object.entries(apps).map(([k, a]) => [k, {
  name: a.name, icon: a.icon,
  ops: Object.fromEntries(Object.entries(a.ops).map(([n, op]) => [n, {
    schema: op.schema || {}, streaming: !!op.stream, autoRun: !!op.autoRun,
  }])),
}]));

// -- HTTP: static files + the manifest ---------------------------------------
const MIME = { '.html': 'text/html', '.js': 'text/javascript' };
const PUBLIC = path.join(ROOT, 'public');
const server = http.createServer((req, res) => {
  if (req.url === '/api/manifest') {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    return res.end(JSON.stringify(manifest()));
  }
  const file = path.join(PUBLIC, req.url === '/' ? 'index.html' : req.url);
  if (!file.startsWith(PUBLIC) || !fs.existsSync(file)) { res.writeHead(404); return res.end(); }
  res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'text/plain' });
  fs.createReadStream(file).pipe(res);
});

// -- WebSocket: run ops, stream chunks, allow cancel -------------------------
// client → {id, app, op, args} | {id, type:'cancel'}
// server → {id, type:'result'|'chunk'|'end'|'error', ...}
new WebSocketServer({ server }).on('connection', ws => {
  const inflight = new Map(); // id → AbortController
  const reply = obj => { try { ws.send(JSON.stringify(obj)); } catch {} };

  ws.on('message', async raw => {
    let msg; try { msg = JSON.parse(raw); } catch { return; }
    if (msg.type === 'cancel') return inflight.get(msg.id)?.abort();

    const op = apps[msg.app]?.ops?.[msg.op];
    if (!op) return reply({ id: msg.id, type: 'error', message: 'unknown op' });

    const ac = new AbortController();
    inflight.set(msg.id, ac);
    const alive = () => !ac.signal.aborted;
    try {
      if (op.stream) {
        await op.stream(msg.args || {}, { signal: ac.signal, send: d => alive() && reply({ id: msg.id, type: 'chunk', data: d }) });
        if (alive()) reply({ id: msg.id, type: 'end' });
      } else {
        const data = await op.run(msg.args || {}, { signal: ac.signal });
        if (alive()) reply({ id: msg.id, type: 'result', data });
      }
    } catch (e) {
      if (alive()) reply({ id: msg.id, type: 'error', message: String(e.message || e) });
    } finally { inflight.delete(msg.id); }
  });

  ws.on('close', () => { for (const ac of inflight.values()) ac.abort(); });
});

server.listen(PORT, () => console.log(`mini CLI GUY → http://localhost:${PORT}  apps: ${Object.keys(apps).join(', ')}`));
adapters/system.js (a streaming adapter)
// adapters/system.js
import os from 'node:os';

let prev = null;
function cpuPercent() {
  const cur = os.cpus();
  if (!prev || prev.length !== cur.length) { prev = cur; return 0; }
  let idle = 0, total = 0;
  for (let i = 0; i < cur.length; i++) {
    const a = prev[i].times, b = cur[i].times;
    idle += b.idle - a.idle;
    total += b.user + b.nice + b.sys + b.idle + b.irq - (a.user + a.nice + a.sys + a.idle + a.irq);
  }
  prev = cur;
  return total ? Math.max(0, Math.min(100, (1 - idle / total) * 100)) : 0;
}

const sleep = (ms, signal) => new Promise(r => {
  const t = setTimeout(r, ms);
  signal?.addEventListener('abort', () => { clearTimeout(t); r(); }, { once: true });
});

export default {
  name: 'system', icon: '🖥️',
  ops: {
    live: {
      autoRun: true,
      stream: async (_args, { send, signal }) => {
        cpuPercent();                       // prime: first sample has no delta
        await sleep(300, signal);
        while (!signal.aborted) {
          const total = os.totalmem(), free = os.freemem();
          send(JSON.stringify({
            cpu: +cpuPercent().toFixed(1),
            mem: +(((total - free) / total) * 100).toFixed(1),
            load: +os.loadavg()[0].toFixed(2),
            uptime_h: +(os.uptime() / 3600).toFixed(1),
            host: os.hostname(),
          }));
          await sleep(1000, signal);
        }
      },
    },
  },
};
adapters/http.js (a unary adapter with a schema)
// adapters/http.js
export default {
  name: 'http', icon: '🌐',
  ops: {
    fetch: {
      schema: {
        url:    { type: 'string', default: 'https://api.github.com/zen', label: 'URL' },
        method: { type: 'string', default: 'GET', label: 'Method' },
      },
      run: async ({ url, method }, { signal }) => {
        const t0 = performance.now();
        const r = await fetch(url, { method, signal, redirect: 'follow' });
        const body = (await r.text()).slice(0, 2000);
        return { status: r.status, ms: Math.round(performance.now() - t0), body };
      },
    },
  },
};
adapters/disk.js (wraps a CLI)
// adapters/disk.js
import { promisify } from 'node:util';
import { execFile } from 'node:child_process';
const run = promisify(execFile);

export default {
  name: 'disk', icon: '💾',
  ops: {
    usage: {
      schema: {},
      run: async (_args, { signal }) => {
        const { stdout } = await run('df', ['-h'], { signal });
        return stdout.trim().split('\n').slice(1).map(l => {
          const c = l.split(/\s+/);
          return { fs: c[0], size: c[1], used: c[2], avail: c[3], use: c[4], mount: c[c.length - 1] };
        }).filter(r => r.fs && r.fs !== 'map' && r.size !== '0Bi');
      },
    },
  },
};
public/app.js (the frontend)
// public/app.js
import { h, render } from 'preact';
import { useState, useEffect, useRef } from 'preact/hooks';
import htm from 'htm';
const html = htm.bind(h);

// Tiny WebSocket RPC: call (unary), stream (chunks), cancel.
function connect(url) {
  const ws = new WebSocket(url);
  const pending = new Map(), streams = new Map();
  ws.onmessage = ev => {
    const m = JSON.parse(ev.data);
    if (streams.has(m.id)) {
      const s = streams.get(m.id);
      if (m.type === 'chunk') return s.onChunk(m.data);
      (m.type === 'error' ? s.reject : s.resolve)(m.message);
      streams.delete(m.id);
    } else if (pending.has(m.id)) {
      const p = pending.get(m.id);
      (m.type === 'error' ? p.reject : p.resolve)(m.data);
      pending.delete(m.id);
    }
  };
  const send = o => ws.send(JSON.stringify(o));
  return {
    ready: new Promise(r => (ws.onopen = r)),
    call: (id, app, op, args) => { send({ id, app, op, args }); return new Promise((res, rej) => pending.set(id, { resolve: res, reject: rej })); },
    stream: (id, app, op, args, onChunk) => { send({ id, app, op, args }); return new Promise((res, rej) => streams.set(id, { onChunk, resolve: res, reject: rej })); },
    cancel: id => send({ id, type: 'cancel' }),
  };
}

const rid = () => Math.random().toString(36).slice(2);
let rpc;

// One operation: a form built from its schema, plus run/stop and output.
function Op({ app, name, spec }) {
  const [args, setArgs] = useState(() =>
    Object.fromEntries(Object.entries(spec.schema).map(([k, s]) => [k, s.default ?? ''])));
  const [out, setOut] = useState('');
  const [running, setRunning] = useState(false);
  const idRef = useRef(null);

  const run = async () => {
    setOut(''); setRunning(true);
    const id = rid(); idRef.current = id;
    try {
      if (spec.streaming) {
        await rpc.stream(id, app, name, args, chunk => idRef.current === id && setOut(chunk));
      } else {
        setOut(JSON.stringify(await rpc.call(id, app, name, args), null, 2));
      }
    } catch (e) { setOut('error: ' + e.message); }
    finally { if (idRef.current === id) { setRunning(false); idRef.current = null; } }
  };
  const stop = () => { if (idRef.current) { rpc.cancel(idRef.current); idRef.current = null; setRunning(false); } };

  useEffect(() => { if (spec.autoRun) run(); return () => idRef.current && rpc.cancel(idRef.current); }, []);

  // Pretty-print a flat object (the stream snapshot, the http result) as
  // key: value lines; arrays and nested data fall back to the JSON dump.
  let view = out;
  try {
    const o = JSON.parse(out);
    if (o && typeof o === 'object' && !Array.isArray(o) && Object.values(o).every(v => typeof v !== 'object'))
      view = Object.entries(o).map(([k, v]) => `${k.padEnd(9)} ${v}`).join('\n');
  } catch {}

  return html`
    <div class=op>
      <h3>${app}.${name}${spec.streaming ? html` <span class=tag>stream</span>` : ''}${running ? html` <span class=dot></span>` : ''}</h3>
      ${Object.entries(spec.schema).map(([k, s]) => html`
        <label class=field><span>${s.label || k}</span>
          <input value=${args[k]} onInput=${e => setArgs({ ...args, [k]: e.target.value })} /></label>`)}
      <div class=actions>
        <button onClick=${run} disabled=${running}>${running ? 'running…' : 'run'}</button>
        ${running && html`<button class=secondary onClick=${stop}>stop</button>`}
      </div>
      ${view && html`<pre>${view}</pre>`}
    </div>`;
}

function App() {
  const [manifest, setManifest] = useState(null);
  const [active, setActive] = useState(null);
  useEffect(() => { (async () => {
    const m = await (await fetch('/api/manifest')).json();
    rpc = connect(`ws://${location.host}/`);
    await rpc.ready;
    setManifest(m); setActive(Object.keys(m)[0]);
  })(); }, []);

  if (!manifest) return html`<p class=muted>loading…</p>`;
  return html`
    <h1>mini <b>CLI GUY</b></h1>
    <div class=tabs>
      ${Object.entries(manifest).map(([k, a]) => html`
        <button class="tab ${active === k ? 'on' : ''}" onClick=${() => setActive(k)}>${a.icon} ${a.name}</button>`)}
    </div>
    ${Object.entries(manifest[active].ops).map(([n, spec]) =>
      html`<${Op} key=${active + n} app=${active} name=${n} spec=${spec} />`)}
  `;
}

render(html`<${App} />`, document.getElementById('root'));
public/index.html (shell and styles)
<!-- public/index.html -->
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>mini CLI GUY</title>
<style>
  :root { --fg:#1a1a1a; --muted:#888; --line:#eee; --accent:#ff6600; }
  * { box-sizing: border-box; }
  body { font: 14px/1.5 ui-sans-serif, system-ui, sans-serif; max-width: 760px; margin: 2rem auto; padding: 0 1.25rem; color: var(--fg); background: #fafafa; }
  h1 { font-size: 1.4rem; letter-spacing: -0.02em; }
  h1 b { color: var(--accent); }
  .tabs { display: flex; gap: 0.4rem; margin: 1rem 0 1.25rem; }
  .tab { padding: 0.45rem 0.85rem; border: 1px solid #ddd; border-radius: 999px; cursor: pointer; background: #fff; color: var(--fg); font: inherit; }
  .tab.on { background: var(--fg); color: #fff; border-color: var(--fg); }
  .op { padding: 0.85rem 1rem; border: 1px solid var(--line); border-radius: 10px; margin: 0.75rem 0; background: #fff; }
  .op h3 { margin: 0 0 0.6rem; font: 600 0.85rem ui-monospace, Menlo, monospace; color: #333; }
  .tag { color: var(--accent); font-weight: 600; }
  .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); animation: pulse 1s infinite; }
  @keyframes pulse { 0%,100% { opacity: 1 } 50% { opacity: 0.3 } }
  .field { display: flex; gap: 0.5rem; align-items: center; margin: 0.3rem 0; }
  .field span { width: 70px; color: var(--muted); font-size: 0.82rem; }
  input { flex: 1; padding: 0.35rem 0.55rem; border: 1px solid #ccc; border-radius: 5px; font: inherit; }
  input:focus { outline: none; border-color: var(--accent); }
  .actions { margin-top: 0.6rem; display: flex; gap: 0.5rem; }
  button { padding: 0.4rem 0.85rem; border: 1px solid var(--fg); background: var(--fg); color: #fff; border-radius: 5px; cursor: pointer; font: inherit; }
  button.secondary { background: #fff; color: var(--fg); }
  button:disabled { opacity: 0.5; cursor: default; }
  pre { background: #f6f6f6; padding: 0.75rem; border-radius: 6px; overflow: auto; max-height: 360px; font-size: 12px; margin: 0.6rem 0 0; white-space: pre-wrap; }
  .muted { color: var(--muted); }
</style>
</head>
<body>
<script type="importmap">
{ "imports": {
  "preact": "https://esm.sh/preact@10.22.0",
  "preact/hooks": "https://esm.sh/preact@10.22.0/hooks",
  "htm": "https://esm.sh/htm@3.1.1"
}}
</script>
<div id="root"></div>
<script type="module" src="/app.js"></script>
</body>
</html>

The code above really runs. The security holes really exist. Have fun.