Claude Skill

add-clidash

Add clidash — a zero-dependency, read-only web dashboard that derives its tabs and tables at runtime from any CLI that lists resources as JSON. Ships pre-wired for NanoClaw's ncl CLI (agent groups, sessions, channels, users, roles), plus message-activity charts, a log tail, and a

LLM Mart · 0 points · 14 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download nanocoai-nanoclaw-.claude_skills_add-clidash-ad8837c.zip · 85 KB
Part of nanocoai/nanoclaw — 49 skills

Install

skills CLI npx skills add https://github.com/nanocoai/nanoclaw/tree/main/.claude/skills/add-clidash
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install nanocoai-nanoclaw@llmmart
Git git clone https://github.com/nanocoai/nanoclaw.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole nanocoai/nanoclaw collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

/add-clidash — CLI-derived read-only dashboard

clidash is a small, read-only web dashboard. You point it at any CLI that can list resources as JSON (NanoClaw's ncl, docker, kubectl, …) and it builds the dashboard at runtime: one tab per resource, a generic table over whatever columns the rows have. A new ncl resource becomes a new tab and a new column becomes a new table column with zero code changes.

It ships pre-wired for NanoClaw's ncl CLI and adds three NanoClaw-aware panels driven entirely by config:

  • Agents overview — status cards joining groups + sessions + messaging groups + wirings (green <15m / amber <2h / red older).
  • Activity — per-session inbound/outbound message totals and a daily series, read directly from the session DBs (ncl has no messages resource).
  • Logs — last N lines of allowlisted host log files.
  • Files — a read-only viewer for group skills, CLAUDE.md, and profiles.

Why it's safe

clidash is read-only by construction: the server can only execFile the argv templates in its config. {resource} is the sole substitution and is allowlist-validated against the discovered/static resource set before exec — never a shell, no free-form input reaches argv. There is no auth; the network is the auth boundary — it binds 127.0.0.1 by default. Only ever bind a private interface (e.g. a tailnet IP), never a public one.

It's distinct from /add-dashboard (which pushes JSON snapshots to a separate @nanoco/nanoclaw-dashboard npm package): clidash has zero dependencies, no build step, no push pipeline, and no edits to NanoClaw source — it just reads ncl and the session DBs.

Steps

1. Copy the tool into place

clidash is fully self-contained — copy the whole directory in:

tools/ is not a standard NanoClaw directory and cp -R won't create it, so make it first:

mkdir -p tools
cp -R .claude/skills/add-clidash/add/tools/clidash tools/clidash

That is the only file change this skill makes. Nothing in NanoClaw src/ is touched, no dependency is added.

2. Create the config

The example config is pre-wired for NanoClaw with paths relative to the repo root, so it works as-is when you run clidash from tools/clidash/:

cd tools/clidash
cp clidash.config.example.json clidash.config.json

clidash.config.json is your local config — add it to .gitignore if you don't want to commit install-specific paths:

echo 'tools/clidash/clidash.config.json' >> ../../.gitignore

The example assumes ncl is built at bin/ncl. If bin/ncl doesn't exist, build it first (pnpm run build) or point clis.ncl.bin at the right path.

3. Test

Tests use a stub CLI — no real ncl or docker needed:

npm test

All tests should pass (Node ≥ 22.5, node:test, zero dependencies).

4. Run and verify

node server.js          # serves http://127.0.0.1:4690

In another shell, confirm it's live and that ncl discovery worked:

curl -s http://127.0.0.1:4690/api/clis | head -c 400      # CLIs + discovered resources
curl -s http://127.0.0.1:4690/api/r/ncl/groups | head -c 400   # a real resource table

Then open http://127.0.0.1:4690/ in a browser. You should see the Agents overview plus a tab per ncl resource.

5. (Optional) Run as a service

clidash binds 127.0.0.1 by default. To reach it from other devices, bind a private (e.g. tailnet) IP via the BIND env var or bind in config — never a public interface.

# ~/.config/systemd/user/clidash.service   (Linux)
[Unit]
Description=clidash read-only CLI dashboard

[Service]
WorkingDirectory=%h/nanoclaw/tools/clidash
ExecStart=/usr/bin/node %h/nanoclaw/tools/clidash/server.js
Environment=BIND=127.0.0.1
Restart=on-failure

[Install]
WantedBy=default.target
systemctl --user enable --now clidash

On macOS, wrap node server.js (with WorkingDirectory = tools/clidash) in a launchd plist the same way the main NanoClaw service is configured.

Configuration reference

clidash.config.json keys (see tools/clidash/README.md and clidash.config.example.json for the full shape):

Key Purpose
port, bind, refreshSeconds server bind + UI auto-refresh cadence
clis.<name>.bin / cwd / env how to invoke the CLI (bin is relative to cwd)
clis.<name>.discover or resources runtime discovery (ncl help) vs a static resource list
clis.<name>.list argv template; {resource} is the only substitution
clis.<name>.output json or jsonlines (docker/kubectl style)
clis.<name>.unwrap dot-path into a response envelope (e.g. data)
clis.<name>.enrich/badges/summary table decorations (ID→name joins, status colors, summary cards)
activity sessionsRoot + days for the message-activity charts
logs dir, tailLines, and an allowlist of files to tail
docs file viewer: root, a deny glob list, and collections of glob patterns

Adding a second CLI is config-only — e.g. docker is included as a jsonlines example. View plugins (views/<cli>-<view>.js) are the only per-CLI code and are optional.

Troubleshooting

  • ENOENT / config not found — run from tools/clidash/ and make sure you copied clidash.config.example.json to clidash.config.json (step 2), or set CLIDASH_CONFIG=/abs/path.json.
  • No ncl resources / discovery empty — bin/ncl isn't built or the path is wrong. Build it (pnpm run build) or fix clis.ncl.bin.
  • docker tab errors — the docker daemon isn't running, or remove the docker CLI from config if you don't need it.
  • Can't reach it from another device — it binds 127.0.0.1; set BIND=<private-ip> (tailnet), never a public interface.
  • Empty Activity/Logs/Files — check that activity.sessionsRoot, logs.dir, and docs.root resolve to your NanoClaw root (relative to where you launch node server.js).

Removal

See REMOVE.md.

Files (nanoclaw)
  • add
    • tools
      • clidash
        • public
          • app.js 32.6 KB
            // clidash frontend — vanilla JS, no build step.
            //
            // Layout: a left sidebar with top-level items (Overview, Activity) and grouped
            // sections (one per CLI — ncl, docker — and a Files section for on-disk docs).
            // Each page shows the exact command that produced it. Tables auto-derive from
            // `ncl <resource> list --json`; rows drill into their `get` detail.
            //
            // Refresh UX: on first load every resource of every CLI is prefetched so nav is
            // instant. 60s auto-refresh + a manual button. Background refreshes diff-and-
            // inject (the data DOM rebuilds only when the data signature changes).
            
            import { mdToHtml } from './md.js';
            
            const $ = (id) => document.getElementById(id);
            
            const state = {
              clis: [],
              docCollections: [],
              activeView: 'overview',   // 'overview' | 'activity' | 'r:<cli>:<resource>' | 'doc:<collection>'
              paused: false,
              refreshSeconds: 60,
              lastUpdated: null,
              refreshing: false,
              snapshots: new Map(),     // "cli/resource" -> { rows, fetchedAt, command }
              errors: new Map(),
              activity: null,           // { sessions, series }
              activityConfigured: false,
              activityCommand: null,
              logs: [],                 // [{ name, label }]
              logCache: new Map(),      // name -> { text, command }
              activeDocPath: null,
              openDocGroups: new Set(), // which doc groups (e.g. agents) are expanded
              docCache: new Map(),
              configCache: new Map(),   // groupId -> container config (for the overview page)
              helpCache: new Map(),     // "cli/resource" -> help text | null (prefetched each cycle)
              detail: null,
              sidebarOpen: false,
              renderedSig: null,
            };
            
            const SVG_NS = 'http://www.w3.org/2000/svg';
            function svg(tag, attrs = {}, children = []) {
              const node = document.createElementNS(SVG_NS, tag);
              for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v);
              for (const c of [].concat(children)) if (c != null) node.append(c);
              return node;
            }
            
            // Lucide-style inline icons (static trusted markup) — crisp, themeable via currentColor.
            const ICONS = {
              overview: '<rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/>',
              activity: '<path d="M3 3v18h18"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/>',
              terminal: '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="m6 9 3 3-3 3"/><path d="M13 15h4"/>',
              box: '<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>',
              folder: '<path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z"/>',
              logs: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v5h5"/><path d="M8 13h8"/><path d="M8 17h5"/>',
            };
            function icon(name) {
              const s = document.createElementNS(SVG_NS, 'svg');
              s.setAttribute('viewBox', '0 0 24 24');
              s.setAttribute('fill', 'none');
              s.setAttribute('stroke', 'currentColor');
              s.setAttribute('stroke-width', '1.8');
              s.setAttribute('stroke-linecap', 'round');
              s.setAttribute('stroke-linejoin', 'round');
              s.innerHTML = ICONS[name] ?? '';
              return s;
            }
            
            // ---------------------------------------------------------------- helpers
            
            const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/;
            
            // Local wall time "YYYY-MM-DD HH:mm" — the raw ISO string is UTC; slicing it
            // would display UTC wall time with no marker, masquerading as local.
            function absTime(iso) {
              const d = new Date(iso);
              if (Number.isNaN(d.getTime())) return iso;
              return d.toLocaleString('sv-SE', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false });
            }
            
            function relTime(iso) {
              const ms = Date.now() - new Date(iso).getTime();
              if (Number.isNaN(ms)) return iso;
              const s = Math.round(ms / 1000);
              if (s < 0) return new Date(iso).toLocaleString();
              if (s < 60) return `${s}s ago`;
              const m = Math.round(s / 60);
              if (m < 60) return `${m}m ago`;
              const h = Math.round(m / 60);
              if (h < 48) return `${h}h ago`;
              return `${Math.round(h / 24)}d ago`;
            }
            
            function coarseAgo(date) {
              const s = (Date.now() - date.getTime()) / 1000;
              if (s < 60) return 'less than a minute ago';
              const m = Math.floor(s / 60);
              if (m < 60) return m === 1 ? '1 minute ago' : `${m} minutes ago`;
              const h = Math.floor(m / 60);
              if (h < 24) return h === 1 ? '1 hour ago' : `${h} hours ago`;
              const d = Math.floor(h / 24);
              return d === 1 ? '1 day ago' : `${d} days ago`;
            }
            
            function staleness(lastActive) {
              if (!lastActive) return 'gray';
              const min = (Date.now() - new Date(lastActive).getTime()) / 60000;
              if (Number.isNaN(min)) return 'gray';
              return min < 15 ? 'green' : min < 120 ? 'amber' : 'red';
            }
            
            function el(tag, attrs = {}, children = []) {
              const node = document.createElement(tag);
              for (const [k, v] of Object.entries(attrs)) {
                if (k === 'class') node.className = v;
                else if (k.startsWith('on')) node.addEventListener(k.slice(2), v);
                else node.setAttribute(k, v);
              }
              for (const child of [].concat(children)) {
                if (child == null) continue;
                node.append(child instanceof Node ? child : document.createTextNode(String(child)));
              }
              return node;
            }
            
            function fmtValue(value) {
              if (value === null || value === undefined) return { text: 'null', cls: 'null' };
              if (typeof value === 'string' && ISO_RE.test(value)) return { iso: value };
              return { text: typeof value === 'object' ? JSON.stringify(value) : String(value) };
            }
            
            function cellFor(value) {
              const f = fmtValue(value);
              if (f.cls === 'null') return el('td', { class: 'null' }, 'null');
              if (f.iso) {
                return el('td', {}, el('span', { class: 'reltime', title: f.iso }, [
                  relTime(f.iso), el('span', { class: 'abs' }, absTime(f.iso)),
                ]));
              }
              if (f.text.length > 42) {
                const span = el('span', { class: 'trunc', title: f.text }, f.text.slice(0, 39) + '…');
                span.addEventListener('click', (e) => { e.stopPropagation(); span.textContent = f.text; span.classList.remove('trunc'); });
                return el('td', {}, span);
              }
              return el('td', {}, f.text);
            }
            
            function kvRows(obj) {
              return Object.entries(obj ?? {}).map(([k, v]) => {
                let valEl;
                if (v && typeof v === 'object') valEl = el('pre', { class: 'kv-json' }, JSON.stringify(v, null, 2));
                else if (typeof v === 'string' && ISO_RE.test(v)) valEl = el('span', { class: 'reltime', title: v }, `${relTime(v)}  (${absTime(v)})`);
                else if (v === null || v === undefined) valEl = el('span', { class: 'null' }, 'null');
                else valEl = el('span', {}, String(v));
                return el('div', { class: 'kv-row' }, [el('span', { class: 'kv-key' }, k), valEl]);
              });
            }
            
            function resolveRef(cliName, ref, id) {
              const snap = state.snapshots.get(`${cliName}/${ref.ref}`);
              const row = snap?.rows?.find((r) => String(r.id) === String(id));
              return row ? (row[ref.label] ?? null) : null;
            }
            
            function badgeChip(value, colorMap) {
              const color = colorMap[String(value).toLowerCase()] ?? 'gray';
              return el('span', { class: `badge-status ${color}` }, [el('span', { class: `dot ${color}` }), String(value)]);
            }
            
            function buildCell(value, column, ctx) {
              if (ctx.badges?.[column] && value != null && typeof value !== 'object') {
                return el('td', {}, badgeChip(value, ctx.badges[column]));
              }
              if (ctx.enrich?.[column] && value != null) {
                const name = resolveRef(ctx.cliName, ctx.enrich[column], value);
                if (name != null) {
                  return el('td', { class: 'enriched', title: String(value) }, [
                    el('span', {}, String(name)), el('span', { class: 'raw-id' }, String(value)),
                  ]);
                }
              }
              return cellFor(value);
            }
            
            function summaryBar(resource, rows, col, cli) {
              let label = resource.replace(/-/g, ' ');
              if (rows.length === 1 && label.endsWith('s')) label = label.slice(0, -1);
              const bits = [el('span', { class: 'sum-count' }, `${rows.length} ${label}`)];
              if (col && rows.some((r) => col in r)) {
                const counts = new Map();
                for (const r of rows) { const v = r[col] ?? '—'; counts.set(v, (counts.get(v) ?? 0) + 1); }
                const colorMap = cli.badges?.[col];
                for (const [v, n] of [...counts.entries()].sort((a, b) => b[1] - a[1])) {
                  bits.push(el('span', { class: 'sum-sep' }, '·'));
                  const c = colorMap?.[String(v).toLowerCase()] ?? null;
                  bits.push(c
                    ? el('span', { class: `badge-status ${c}` }, [el('span', { class: `dot ${c}` }), `${v} ×${n}`])
                    : el('span', { class: 'sum-chip' }, `${v} ×${n}`));
                }
              }
              return el('div', { class: 'summary-bar' }, bits);
            }
            
            // ---------------------------------------------------------------- views
            
            const nclCli = () => state.clis.find((c) => c.name === 'ncl') ?? state.clis[0];
            function currentView() {
              const v = state.activeView;
              if (v === 'overview' || v === 'activity') return { type: v };
              const m = v.match(/^r:([^:]+):(.+)$/);
              if (m) return { type: 'resource', cli: m[1], resource: m[2] };
              if (v.startsWith('doc:')) return { type: 'doc', collection: v.slice(4) };
              if (v.startsWith('log:')) return { type: 'log', name: v.slice(4) };
              return { type: 'overview' };
            }
            const activeCollection = () => {
              const v = currentView();
              return v.type === 'doc' ? state.docCollections.find((c) => c.name === v.collection) : null;
            };
            
            // ---------------------------------------------------------------- fetching
            
            async function fetchJson(url) {
              const res = await fetch(url);
              return res.json().catch(() => ({ ok: false, error: `Bad response from ${url}` }));
            }
            
            async function refresh(force = false) {
              state.refreshing = true;
              if (force) renderControls();
            
              const [cliList, docList, logList] = await Promise.all([
                fetchJson('/api/clis').catch(() => null),
                fetchJson('/api/docs').catch(() => null),
                fetchJson('/api/logs').catch(() => null),
              ]);
              if (cliList?.clis) {
                state.clis = cliList.clis;
                state.refreshSeconds = cliList.clis[0]?.refreshSeconds ?? state.refreshSeconds;
              }
              if (docList?.collections) state.docCollections = docList.collections;
              if (logList?.files) state.logs = logList.files;
            
              render(); // paint sidebar + active view's loading state immediately
            
              const jobs = [];
              jobs.push(fetchJson('/api/activity').then((body) => {
                if (body.ok && body.configured) {
                  state.activity = { sessions: body.sessions, series: body.series };
                  state.activityConfigured = true; state.activityCommand = body.command ?? null;
                } else state.activityConfigured = false;
                render();
              }));
              for (const lg of state.logs) {
                jobs.push(fetchJson(`/api/log/${encodeURIComponent(lg.name)}`).then((body) => {
                  if (body.ok) state.logCache.set(lg.name, { text: body.text, command: body.command });
                  render();
                }));
              }
              for (const c of state.clis) {
                for (const r of c.resources ?? []) {
                  const key = `${c.name}/${r.name}`;
                  jobs.push(fetchJson(`/api/r/${c.name}/${encodeURIComponent(r.name)}`).then((body) => {
                    if (body.ok) { state.snapshots.set(key, { rows: body.rows, fetchedAt: body.fetchedAt, command: body.command }); state.errors.set(key, null); }
                    else state.errors.set(key, body.raw ? `${body.error}\n\n${body.raw}` : body.error);
                    render();
                  }));
                  if (c.help) {
                    jobs.push(fetchJson(`/api/help/${c.name}/${encodeURIComponent(r.name)}`).then((body) => {
                      state.helpCache.set(key, body.ok ? body.text : null);
                      render();
                    }));
                  }
                }
              }
              await Promise.all(jobs);
            
              // per-group container config (for the Overview page) — small, refetched each cycle
              const groups = state.snapshots.get('ncl/groups')?.rows ?? [];
              await Promise.all(groups.map(async (g) => {
                const c = await fetchJson(`/api/cmd/ncl/config-get?id=${encodeURIComponent(g.id)}`);
                if (c.ok) state.configCache.set(g.id, c.data);
              }));
            
              state.lastUpdated = new Date();
              state.refreshing = false;
              render();
            }
            
            async function openDoc(collectionName, path) {
              state.activeDocPath = path;
              const key = `${collectionName}\0${path}`;
              if (!state.docCache.has(key)) {
                const body = await fetchJson(`/api/doc?c=${encodeURIComponent(collectionName)}&p=${encodeURIComponent(path)}`);
                state.docCache.set(key, body.ok ? { lang: body.lang, content: body.content } : { lang: 'error', content: body.error || 'Failed to load' });
              }
              state.renderedSig = null;
              render();
            }
            
            async function openDetail(cliName, resource, id) {
              state.detail = { cli: cliName, resource, id, loading: true };
              state.renderedSig = null;
              render();
              const rec = await fetchJson(`/api/cmd/${cliName}/get?resource=${encodeURIComponent(resource)}&id=${encodeURIComponent(id)}`);
              let config = null;
              if (resource === 'groups') {
                const cg = await fetchJson(`/api/cmd/${cliName}/config-get?id=${encodeURIComponent(id)}`);
                if (cg.ok) config = cg.data;
              }
              if (!state.detail || state.detail.id !== id) return;
              state.detail = { cli: cliName, resource, id, record: rec.ok ? rec.data : null, error: rec.ok ? null : rec.error, config };
              state.renderedSig = null;
              render();
            }
            
            function closeDetail() { state.detail = null; state.renderedSig = null; render(); }
            
            // Help panel: the description (first paragraph) is always visible; the verbs +
            // fields (everything after the first blank line) sit behind a collapse.
            function helpPanel(text) {
              if (text === null) return null; // explicitly no help
              if (text === undefined) return el('div', { class: 'help-panel' }, el('div', { class: 'help-head dim' }, 'loading help…'));
              const idx = text.indexOf('\n\n');
              const head = (idx >= 0 ? text.slice(0, idx) : text).trim();
              const body = idx >= 0 ? text.slice(idx + 2).trim() : '';
              return el('div', { class: 'help-panel' }, [
                el('div', { class: 'help-head' }, head),
                body ? el('details', { class: 'help-more' }, [
                  el('summary', {}, 'verbs & fields'),
                  el('pre', { class: 'help-text' }, body),
                ]) : null,
              ]);
            }
            
            function go(view) {
              state.activeView = view;
              state.detail = null;
              state.sidebarOpen = false;
              state.renderedSig = null;
              const v = currentView();
              if (v.type === 'doc') {
                const coll = state.docCollections.find((c) => c.name === v.collection);
                const first = coll && (coll.name === 'conversations' ? coll.files.at(-1) : coll.files[0]); // newest conversation
                state.activeDocPath = state.activeDocPath && coll?.files.some((f) => f.path === state.activeDocPath)
                  ? state.activeDocPath : (first?.path ?? null);
                // expand only the group holding the active doc; the user picks the rest
                const activeFile = coll?.files.find((f) => f.path === state.activeDocPath);
                state.openDocGroups = new Set(activeFile ? [activeFile.group] : []);
                render();
                if (state.activeDocPath) openDoc(coll.name, state.activeDocPath);
                return;
              }
              render();
            }
            
            // ---------------------------------------------------------------- rendering
            
            function dataSignature() {
              const v = currentView();
              const key = v.type === 'resource' ? `${v.cli}/${v.resource}` : null;
              const coll = activeCollection();
              return JSON.stringify({
                view: state.activeView, clis: state.clis.map((c) => `${c.name}:${(c.resources || []).length}`),
                activityConfigured: state.activityConfigured,
                rows: key ? state.snapshots.get(key)?.rows ?? null : null,
                rowsError: key ? state.errors.get(key) ?? null : null,
                command: key ? state.snapshots.get(key)?.command ?? null : null,
                help: key ? state.helpCache.get(key) ?? null : null,
                overview: v.type === 'overview' ? {
                  groups: state.snapshots.get('ncl/groups')?.rows ?? null,
                  sessions: state.snapshots.get('ncl/sessions')?.rows ?? null,
                  configs: [...state.configCache.entries()],
                  activity: state.activity?.sessions ?? null,
                } : null,
                activity: v.type === 'activity' ? state.activity : null,
                log: v.type === 'log' ? state.logCache.get(v.name)?.text ?? null : null,
                docFiles: coll ? coll.files.map((f) => f.path) : null,
                docPath: state.activeDocPath,
                docGroupsOpen: coll ? [...state.openDocGroups] : null,
                docContent: coll ? state.docCache.get(`${coll.name}\0${state.activeDocPath}`)?.content ?? null : null,
                detail: state.detail, paused: state.paused, sidebarOpen: state.sidebarOpen,
              });
            }
            
            function renderControls() {
              $('updated').textContent = state.lastUpdated
                ? `updated ${coarseAgo(state.lastUpdated)}${state.paused ? ' · paused' : ''}` : '';
              $('refresh').classList.toggle('spinning', state.refreshing);
            }
            
            function render() {
              renderControls();
              const sig = dataSignature();
              if (sig === state.renderedSig) return;
              state.renderedSig = sig;
            
              $('sidebar').classList.toggle('open', state.sidebarOpen);
              $('scrim').hidden = !state.sidebarOpen;
            
              renderNav();
            
              const v = currentView();
              const banner = $('banner');
              const tabError = v.type === 'resource' ? state.errors.get(`${v.cli}/${v.resource}`) : null;
              const cli = v.type === 'resource' ? state.clis.find((c) => c.name === v.cli) : null;
              const bannerMsg = cli?.error ? `Discovery failed for ${v.cli}: ${cli.error}`
                : (tabError ? `CLI unreachable — showing last good snapshot. ${tabError.split('\n')[0]}` : null);
              banner.hidden = !bannerMsg;
              banner.textContent = bannerMsg ?? '';
            
              renderCmdline(v);
              if (v.type === 'overview') renderOverviewPage();
              else if (v.type === 'activity') renderActivity();
              else if (v.type === 'doc') renderDocs();
              else if (v.type === 'log') renderLogPage(v.name);
              else renderTable(v.cli, v.resource);
              renderDetail();
            }
            
            function navItem(label, view, cls = '', iconName = null) {
              return el('button', {
                class: `nav-item ${cls}` + (state.activeView === view ? ' active' : ''),
                onclick: () => go(view),
              }, [iconName ? icon(iconName) : null, el('span', {}, label)]);
            }
            
            function renderNav() {
              const nav = $('nav');
              const items = [navItem('Overview', 'overview', '', 'overview')];
              if (state.activityConfigured) items.push(navItem('Activity', 'activity', '', 'activity'));
            
              for (const cli of state.clis) {
                items.push(el('div', { class: 'nav-section' }, [icon(cli.name === 'docker' ? 'box' : 'terminal'), el('span', {}, cli.name)]));
                for (const r of cli.resources ?? []) {
                  items.push(navItem(r.name, `r:${cli.name}:${r.name}`, 'nav-sub'));
                }
              }
              if (state.docCollections.length) {
                items.push(el('div', { class: 'nav-section' }, [icon('folder'), el('span', {}, 'Files')]));
                for (const coll of state.docCollections) {
                  items.push(navItem(coll.label, `doc:${coll.name}`, 'nav-sub'));
                }
              }
              if (state.logs.length) {
                items.push(el('div', { class: 'nav-section' }, [icon('logs'), el('span', {}, 'Logs')]));
                for (const lg of state.logs) {
                  items.push(navItem(lg.label, `log:${lg.name}`, 'nav-sub'));
                }
              }
              nav.replaceChildren(...items);
            }
            
            function renderCmdline(v) {
              const bar = $('cmdline');
              let cmd = null;
              if (v.type === 'resource') cmd = state.snapshots.get(`${v.cli}/${v.resource}`)?.command;
              else if (v.type === 'activity') cmd = state.activityCommand;
              else if (v.type === 'doc') cmd = state.activeDocPath ? `file · ${state.activeDocPath}` : null;
              else if (v.type === 'log') cmd = state.logCache.get(v.name)?.command ?? null;
              else if (v.type === 'overview') cmd = 'derived · ncl groups/sessions/messaging-groups/wirings + config-get + activity';
              bar.hidden = !cmd;
              bar.textContent = cmd ? `$ ${cmd}` : '';
            }
            
            // ---- Overview page (rich agent cards) ----
            
            function renderOverviewPage() {
              const content = $('content');
              const groups = state.snapshots.get('ncl/groups')?.rows;
              if (!groups) { content.replaceChildren(el('div', { class: 'empty' }, 'Loading…')); return; }
              const sessions = state.snapshots.get('ncl/sessions')?.rows ?? [];
              const wirings = state.snapshots.get('ncl/wirings')?.rows ?? [];
              const mgs = state.snapshots.get('ncl/messaging-groups')?.rows ?? [];
              const act = state.activity?.sessions ?? [];
              const mgName = (id) => mgs.find((m) => m.id === id)?.name ?? mgs.find((m) => m.id === id)?.platform_id ?? id;
            
              const field = (k, v, cls = '') => el('div', { class: 'ov-field' }, [el('span', { class: 'k' }, k), el('span', { class: `v ${cls}` }, v)]);
            
              const cards = groups.map((g) => {
                const gs = sessions.filter((s) => s.agent_group_id === g.id);
                const lastActive = gs.map((s) => s.last_active).filter(Boolean).sort().at(-1) ?? null;
                const container = gs.some((s) => s.container_status === 'running') ? 'running' : (gs[0]?.container_status ?? 'none');
                const ga = act.filter((a) => a.agent_group_id === g.id);
                const msgIn = ga.reduce((a, s) => a + s.in, 0), msgOut = ga.reduce((a, s) => a + s.out, 0);
                const cfg = state.configCache.get(g.id);
                const chans = wirings.filter((w) => w.agent_group_id === g.id).map((w) => `${mgs.find((m) => m.id === w.messaging_group_id)?.channel_type ?? '?'}: ${mgName(w.messaging_group_id)}`);
                const status = staleness(lastActive);
                const containerColor = container === 'running' ? 'green' : container === 'idle' ? 'green' : container === 'none' ? 'gray' : 'gray';
            
                const fields = [
                  el('div', { class: 'ov-field' }, [el('span', { class: 'k' }, 'container'), badgeChip(container, { running: 'green', idle: 'green', stopped: 'gray', none: 'gray' })]),
                  field('sessions', String(gs.length)),
                  field('messages', `${msgIn} in · ${msgOut} out`),
                  field('last active', lastActive ? relTime(lastActive) : '—', lastActive ? '' : 'dim'),
                ];
                if (cfg) {
                  fields.push(field('provider / model', `${cfg.provider ?? 'claude'} / ${cfg.model ?? 'default'}`));
                  fields.push(el('div', { class: 'ov-field' }, [el('span', { class: 'k' }, 'cli scope'), badgeChip(cfg.cli_scope ?? 'group', { global: 'amber', group: 'green', disabled: 'gray' })]));
                  const pkgs = (cfg.packages_apt?.length ?? 0) + (cfg.packages_npm?.length ?? 0);
                  const mcp = Object.keys(cfg.mcp_servers ?? {}).length;
                  if (pkgs || mcp) fields.push(field('extras', `${pkgs} pkgs · ${mcp} mcp`));
                }
            
                return el('div', { class: 'ov-card' }, [
                  el('div', { class: 'ov-head' }, [
                    el('span', { class: `dot ${status}` }),
                    el('span', { class: 'ov-name' }, g.name),
                    el('span', { class: 'ov-folder' }, g.folder),
                  ]),
                  el('div', { class: 'ov-fields' }, fields),
                  el('div', { class: 'ov-chans' }, chans.map((c) => el('span', { class: 'badge' }, c))),
                ]);
              });
            
              content.replaceChildren(
                el('h2', { class: 'page-title' }, 'Agents overview'),
                el('div', { class: 'ov-cards' }, cards),
              );
            }
            
            // ---- Activity ----
            
            function renderActivity() {
              const content = $('content');
              const data = state.activity;
              if (!data) { content.replaceChildren(el('div', { class: 'empty' }, 'Loading…')); return; }
              const { series, sessions } = data;
              const totalIn = series.reduce((a, d) => a + d.in, 0);
              const totalOut = series.reduce((a, d) => a + d.out, 0);
            
              const W = 720, H = 220, padL = 34, padB = 28, padT = 10;
              const max = Math.max(1, ...series.map((d) => Math.max(d.in, d.out)));
              const slot = (W - padL) / series.length;
              const bw = Math.max(3, slot / 2 - 2);
              const yOf = (vv) => padT + (H - padT - padB) * (1 - vv / max);
              const chart = svg('svg', { viewBox: `0 0 ${W} ${H}`, class: 'activity-chart', preserveAspectRatio: 'none' });
              for (const frac of [0, 0.5, 1]) {
                const y = yOf(max * frac);
                chart.append(svg('line', { x1: padL, y1: y, x2: W, y2: y, class: 'grid' }));
                chart.append(svg('text', { x: padL - 6, y: y + 3, class: 'axis', 'text-anchor': 'end' }, String(Math.round(max * frac))));
              }
              series.forEach((d, i) => {
                const x = padL + i * slot;
                chart.append(svg('rect', { x: x + 1, y: yOf(d.in), width: bw, height: yOf(0) - yOf(d.in), class: 'bar-in' }, [svg('title', {}, `${d.date}: ${d.in} in`)]));
                chart.append(svg('rect', { x: x + 1 + bw, y: yOf(d.out), width: bw, height: yOf(0) - yOf(d.out), class: 'bar-out' }, [svg('title', {}, `${d.date}: ${d.out} out`)]));
                if (i % 2 === 0) chart.append(svg('text', { x: x + bw, y: H - 8, class: 'axis', 'text-anchor': 'middle' }, d.date.slice(5)));
              });
              const legend = el('div', { class: 'activity-legend' }, [
                el('span', {}, [el('span', { class: 'lg in' }), `inbound (${totalIn})`]),
                el('span', {}, [el('span', { class: 'lg out' }), `outbound (${totalOut})`]),
                el('span', { class: 'dim' }, `last ${series.length} days`),
              ]);
              const sessRows = [...sessions].sort((a, b) => (b.lastActivity || '').localeCompare(a.lastActivity || '')).map((s) => {
                const groupName = resolveRef('ncl', { ref: 'groups', label: 'name' }, s.agent_group_id) ?? s.agent_group_id;
                return el('tr', {}, [
                  el('td', {}, groupName),
                  el('td', {}, el('span', { class: 'trunc', title: s.session_id }, s.session_id.slice(0, 22) + '…')),
                  el('td', { class: 'num' }, String(s.in)),
                  el('td', { class: 'num' }, String(s.out)),
                  el('td', {}, s.lastActivity ? el('span', { class: 'reltime', title: s.lastActivity }, relTime(s.lastActivity)) : el('span', { class: 'null' }, '—')),
                ]);
              });
              content.replaceChildren(
                el('h2', { class: 'page-title' }, 'Message activity'),
                el('div', { class: 'activity-wrap' }, [
                  legend,
                  el('div', { class: 'chart-box' }, chart),
                  el('div', { class: 'table-wrap' }, el('table', { class: 'activity-table' }, [
                    el('thead', {}, el('tr', {}, ['agent', 'session', 'in', 'out', 'last activity'].map((h) => el('th', {}, h)))),
                    el('tbody', {}, sessRows),
                  ])),
                ]),
              );
            }
            
            // ---- Logs (tail of a log file) ----
            
            function renderLogPage(name) {
              const content = $('content');
              const label = state.logs.find((l) => l.name === name)?.label ?? name;
              const cached = state.logCache.get(name);
              if (!cached) { content.replaceChildren(el('h2', { class: 'page-title' }, label), el('div', { class: 'empty' }, 'Loading…')); return; }
              const view = el('div', { class: 'log-view' });
              for (const line of cached.text.split('\n')) {
                const lvl = /\bERROR\b/i.test(line) ? 'err' : /\bWARN(ING)?\b/i.test(line) ? 'warn' : '';
                view.append(el('div', { class: `log-line ${lvl}` }, line || ' '));
              }
              content.replaceChildren(el('h2', { class: 'page-title' }, label), el('div', { class: 'log-box' }, view));
              // follow the tail — scroll to the newest line
              requestAnimationFrame(() => { const b = content.querySelector('.log-box'); if (b) b.scrollTop = b.scrollHeight; });
            }
            
            // ---- Files (doc viewer) ----
            
            function renderDocs() {
              const coll = activeCollection();
              const content = $('content');
              if (!coll) { content.replaceChildren(el('div', { class: 'empty' }, 'No documents.')); return; }
              if (!coll.files.length) { content.replaceChildren(el('div', { class: 'empty' }, `No ${coll.label.toLowerCase()}.`)); return; }
              // display name: drop the group prefix, the `/SKILL.md` tail (show the skill
              // dir), and the .md extension — leaving e.g. "meeting-tagger" or "2026-06-13-…"
              const itemName = (label) => {
                let n = label.includes('/') ? label.split('/').slice(1).join('/').trim() : label;
                return n.replace(/\/SKILL\.md$/, '').replace(/\.md$/, '') || label;
              };
              const newestFirst = coll.name === 'conversations';
              const groups = new Map();
              for (const f of coll.files) { if (!groups.has(f.group)) groups.set(f.group, []); groups.get(f.group).push(f); }
              const toggleGroup = (g) => {
                state.openDocGroups.has(g) ? state.openDocGroups.delete(g) : state.openDocGroups.add(g);
                state.renderedSig = null; render();
              };
              const list = el('div', { class: 'doc-list' });
              for (const [group, files] of groups) {
                const open = state.openDocGroups.has(group);
                list.append(el('button', { class: 'doc-group-toggle' + (open ? ' open' : ''), onclick: () => toggleGroup(group) }, [
                  el('span', { class: 'chev' }, open ? '▾' : '▸'),
                  el('span', { class: 'g-name' }, group || '—'),
                  el('span', { class: 'g-count' }, String(files.length)),
                ]));
                if (open) {
                  const ordered = newestFirst ? [...files].reverse() : files;
                  for (const f of ordered) {
                    list.append(el('button', { class: 'doc-item' + (f.path === state.activeDocPath ? ' active' : ''), title: f.path, onclick: () => openDoc(coll.name, f.path) }, itemName(f.label) || f.path));
                  }
                }
              }
              const pane = el('div', { class: 'doc-content' });
              const cached = state.activeDocPath ? state.docCache.get(`${coll.name}\0${state.activeDocPath}`) : null;
              if (!state.activeDocPath) pane.append(el('div', { class: 'empty' }, 'Select a document.'));
              else if (!cached) pane.append(el('div', { class: 'empty' }, 'Loading…'));
              else if (cached.lang === 'error') pane.append(el('div', { class: 'tab-error' }, cached.content));
              else if (cached.lang === 'json') {
                let pretty = cached.content;
                try { pretty = JSON.stringify(JSON.parse(cached.content), null, 2); } catch { /* keep raw */ }
                pane.append(el('pre', { class: 'code json' }, pretty));
              } else if (cached.lang === 'markdown') {
                const md = el('div', { class: 'markdown' }); md.innerHTML = mdToHtml(cached.content); pane.append(md);
              } else pane.append(el('pre', { class: 'code' }, cached.content));
              content.replaceChildren(el('h2', { class: 'page-title' }, coll.label), el('div', { class: 'doc-viewer' }, [list, pane]));
            }
            
            // ---- resource table ----
            
            function renderTable(cliName, resource) {
              const content = $('content');
              const cli = state.clis.find((c) => c.name === cliName);
              if (!cli) { content.replaceChildren(el('div', { class: 'empty' }, 'No such CLI.')); return; }
              const key = `${cliName}/${resource}`;
              const snapshot = state.snapshots.get(key);
              const error = state.errors.get(key);
              const canDrill = (cli.commands || []).includes('get');
              const parts = [el('h2', { class: 'page-title' }, resource)];
              if (cli.help) parts.push(helpPanel(state.helpCache.get(key)));
              if (error && snapshot) parts.push(el('div', { class: 'stale-note' }, `⚠ live fetch failing — snapshot from ${new Date(snapshot.fetchedAt).toLocaleTimeString()}`));
              if (!snapshot) {
                parts.push(error ? el('div', { class: 'tab-error' }, [`Failed to load ${resource}.`, el('pre', {}, error)]) : el('div', { class: 'empty' }, 'Loading…'));
                content.replaceChildren(...parts); return;
              }
              const rows = snapshot.rows;
              parts.push(summaryBar(resource, rows, cli.summary?.[resource], cli));
              if (rows.length === 0) { parts.push(el('div', { class: 'empty' }, `No ${resource}.`)); content.replaceChildren(...parts); return; }
              const columns = [];
              for (const row of rows) for (const k of Object.keys(row)) if (!columns.includes(k)) columns.push(k);
              const ctx = { cliName, enrich: cli.enrich?.[resource], badges: cli.badges };
              const body = rows.map((row) => {
                const id = row.id; const canRow = canDrill && id != null;
                return el('tr', { class: canRow ? 'drillable' : '', ...(canRow ? { onclick: () => openDetail(cliName, resource, String(id)) } : {}) },
                  columns.map((c) => buildCell(row[c], c, ctx)));
              });
              parts.push(el('div', { class: 'table-wrap' }, el('table', {}, [
                el('thead', {}, el('tr', {}, columns.map((c) => el('th', {}, c)))),
                el('tbody', {}, body),
              ])));
              content.replaceChildren(...parts);
            }
            
            // ---- drill-down detail overlay ----
            
            function renderDetail() {
              const overlay = $('detail');
              if (!state.detail) { overlay.hidden = true; overlay.replaceChildren(); return; }
              overlay.hidden = false;
              const d = state.detail;
              const panel = el('div', { class: 'detail-panel' });
              panel.append(el('div', { class: 'detail-head' }, [
                el('div', {}, [el('span', { class: 'detail-res' }, d.resource), ' ', el('span', { class: 'detail-id' }, d.id)]),
                el('button', { class: 'detail-close', onclick: closeDetail, title: 'Close' }, '✕'),
              ]));
              const sub = el('div', { class: 'detail-body' });
              if (d.loading) sub.append(el('div', { class: 'empty' }, 'Loading…'));
              else if (d.error) sub.append(el('div', { class: 'tab-error' }, d.error));
              else if (d.record) sub.append(el('div', { class: 'kv' }, kvRows(d.record)));
              if (d.config) {
                sub.append(el('div', { class: 'detail-section' }, 'Container config'));
                sub.append(el('div', { class: 'kv' }, kvRows(d.config)));
              }
              panel.append(sub);
              overlay.replaceChildren(panel);
            }
            
            // ---------------------------------------------------------------- boot
            
            $('pause').addEventListener('click', () => {
              state.paused = !state.paused;
              $('pause').textContent = state.paused ? '▶ resume' : '⏸ pause';
              $('pause').classList.toggle('paused', state.paused);
              state.renderedSig = null; render();
            });
            $('refresh').addEventListener('click', () => { if (!state.refreshing) refresh(true); });
            $('hamburger').addEventListener('click', () => { state.sidebarOpen = !state.sidebarOpen; state.renderedSig = null; render(); });
            $('scrim').addEventListener('click', () => { state.sidebarOpen = false; state.renderedSig = null; render(); });
            $('detail').addEventListener('click', (e) => { if (e.target === $('detail')) closeDetail(); });
            document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { if (state.detail) closeDetail(); else if (state.sidebarOpen) { state.sidebarOpen = false; state.renderedSig = null; render(); } } });
            
            async function tick() {
              if (!state.paused) { try { await refresh(); } catch { /* keep snapshots; retry next tick */ } }
              else renderControls();
              setTimeout(tick, state.refreshSeconds * 1000);
            }
            tick();
            
          • apple-touch-icon.png 5.3 KB · in bundle
          • favicon.ico 547 B · in bundle
          • favicon.svg 570 B · in bundle
          • icon-192.png 6.5 KB · in bundle
          • icon-512.png 18.5 KB · in bundle
          • index.html 1.9 KB · in bundle
          • md.js 2.7 KB
            // Minimal, dependency-free, XSS-safe markdown → HTML for clidash's file viewer
            // (SKILL.md / CLAUDE.md). Pure string functions, no DOM — importable in both the
            // browser (app.js) and node tests.
            //
            // Safety model: the ENTIRE source is HTML-escaped first, so no raw markup from a
            // file can reach innerHTML. Markdown transforms then emit only tags this module
            // generates. Link hrefs are taken from the URL capture group and gated to an
            // http(s) scheme, so a `javascript:`/`data:` URL (or one smuggled via link text)
            // can never become an executable href.
            
            export function escapeHtml(s) {
              return String(s).replace(/[&<>"']/g, (c) => (
                { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
              ));
            }
            
            export function mdToHtml(src) {
              const lines = escapeHtml(src).split('\n');
              const out = [];
              let i = 0;
              const inline = (t) => t
                .replace(/`([^`]+)`/g, '<code>$1</code>')
                .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
                .replace(/\*([^*]+)\*/g, '<em>$1</em>')
                .replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g, (m, text, url) =>
                  /^https?:\/\//i.test(url) ? `<a href="${url}" target="_blank" rel="noopener noreferrer">${text}</a>` : m);
              while (i < lines.length) {
                const line = lines[i];
                if (/^```/.test(line)) {
                  const buf = [];
                  i++;
                  while (i < lines.length && !/^```/.test(lines[i])) buf.push(lines[i++]);
                  i++;
                  out.push(`<pre class="code"><code>${buf.join('\n')}</code></pre>`);
                  continue;
                }
                const h = line.match(/^(#{1,6})\s+(.*)$/);
                if (h) { out.push(`<h${h[1].length}>${inline(h[2])}</h${h[1].length}>`); i++; continue; }
                if (/^\s*([-*])\s+/.test(line)) {
                  const items = [];
                  while (i < lines.length && /^\s*([-*])\s+/.test(lines[i])) {
                    items.push(`<li>${inline(lines[i].replace(/^\s*([-*])\s+/, ''))}</li>`);
                    i++;
                  }
                  out.push(`<ul>${items.join('')}</ul>`);
                  continue;
                }
                if (/^\s*\d+\.\s+/.test(line)) {
                  const items = [];
                  while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) {
                    items.push(`<li>${inline(lines[i].replace(/^\s*\d+\.\s+/, ''))}</li>`);
                    i++;
                  }
                  out.push(`<ol>${items.join('')}</ol>`);
                  continue;
                }
                if (/^\s*(---+|\*\*\*+)\s*$/.test(line)) { out.push('<hr>'); i++; continue; }
                if (/^\s*>\s?/.test(line)) { out.push(`<blockquote>${inline(line.replace(/^\s*>\s?/, ''))}</blockquote>`); i++; continue; }
                if (line.trim() === '') { i++; continue; }
                const para = [line];
                i++;
                while (i < lines.length && lines[i].trim() !== '' && !/^(#{1,6}\s|```|\s*[-*]\s|\s*\d+\.\s|\s*>)/.test(lines[i])) {
                  para.push(lines[i++]);
                }
                out.push(`<p>${inline(para.join(' '))}</p>`);
              }
              return out.join('\n');
            }
            
          • site.webmanifest 298 B · in bundle
          • style.css 18 KB · in bundle
        • test
          • fixtures
            • ncl-help.txt 4.1 KB
              Resources:
                approvals            Pending approval — in-flight approval cards waiting for an admin response. Created by requestApproval() (self-mod install_packages/add_mcp_server) and OneCLI credential approval flow. Rows are deleted after the admin approves/rejects or the request expires.
                                     verbs: list, get
                destinations         Agent destination — per-agent routing entry and ACL. Each row authorizes an agent to send messages to a target (channel or another agent) and assigns a local name the agent uses to address it. Names are scoped to the source agent — two agents can have different local names for the same target. Created automatically when wiring channels or when agents create child agents.
                                     verbs: list, add, remove
                dropped-messages     Dropped message log — tracks messages that were dropped by the router or access gate. Aggregates by (channel_type, platform_id) with a running count. Reasons include: no_agent_wired (no wiring exists), no_agent_engaged (wiring exists but engage rules didn't fire), unknown_sender_strict (sender not recognized, strict policy), unknown_sender_request_approval (sender not recognized, approval requested).
                                     verbs: list
                groups               Agent group — a logical agent identity. Each group has its own workspace folder (CLAUDE.md, skills, container config), conversation history, and container image. Multiple messaging groups can be wired to one agent group.
                                     verbs: list, get, create, update, delete, restart, config get, config update, config add-mcp-server, config remove-mcp-server, config add-package, config remove-package
                members              Agent group member — grants an unprivileged user permission to interact with an agent group. Users with admin or owner roles on the group are implicitly members and do not need a separate membership row. Membership is checked by the router when sender_scope is "known".
                                     verbs: list, add, remove
                messaging-groups     Messaging group — one chat or channel on one platform (a Telegram DM, a Discord channel, a Slack thread root, an email address). Identity is the (channel_type, platform_id) pair, which must be unique.
                                     verbs: list, get, create, update, delete
                roles                User role — privilege grant. "owner" is always global and has full control. "admin" can be global (agent_group_id null) or scoped to a specific agent group. Admin at a group implies membership. Approval routing prefers admins/owners reachable on the same messaging platform as the request origin (e.g. a Telegram request routes the approval card to an admin on Telegram when possible).
                                     verbs: list, grant, revoke
                sessions             Session — the runtime unit. Maps one (agent_group, messaging_group, thread) combination to a container with its own inbound.db and outbound.db. Created automatically by the router when a message arrives.
                                     verbs: list, get
                user-dms             User DM cache — maps (user, channel_type) to the messaging group used for DM delivery. Populated lazily by ensureUserDm() when the host needs to cold-DM a user (approvals, pairing). For direct-addressable channels (Telegram, WhatsApp) the handle IS the DM chat ID. For resolution-required channels (Discord, Slack) the adapter's openDM resolves it.
                                     verbs: list
                users                User — a messaging-platform identity. Each row is one sender on one channel. A single human may have multiple user rows across channels (no cross-channel linking yet).
                                     verbs: list, get, create, update
                wirings              Wiring — connects a messaging group to an agent group. Determines which agent handles messages from which chat. The same messaging group can be wired to multiple agents; the same agent can be wired to multiple messaging groups.
                                     verbs: list, get, create, update, delete
              
              Commands:
                help                 List available resources and commands.
              
              Run `ncl <resource> help` for detailed field information.
              
            • stub-cli.js 1.8 KB
              #!/usr/bin/env node
              // Stub CLI for clidash tests. Impersonates ncl (envelope json) or a
              // jsonlines CLI, with failure/slowness/garbage modes driven by env vars.
              import { readFileSync, appendFileSync } from 'node:fs';
              import { fileURLToPath } from 'node:url';
              
              const args = process.argv.slice(2);
              
              if (process.env.STUB_COUNT_FILE) {
                appendFileSync(process.env.STUB_COUNT_FILE, args.join(' ') + '\n');
              }
              
              const sleepMs = Number(process.env.STUB_SLEEP_MS || 0);
              
              setTimeout(() => {
                if (process.env.STUB_FAIL) {
                  process.stderr.write('boom: socket down\n');
                  process.exit(2);
                }
                if (args[0] === 'help') {
                  process.stdout.write(
                    readFileSync(fileURLToPath(new URL('./ncl-help.txt', import.meta.url)), 'utf8'),
                  );
                  process.exit(0);
                }
                if (args[1] === 'help') { // `<resource> help` → raw per-resource help text
                  process.stdout.write(`${args[0]}: help for ${args[0]}\n\nVerbs:\n  list\n  get <id>\n`);
                  process.exit(0);
                }
                if (process.env.STUB_RAW) {
                  process.stdout.write(process.env.STUB_RAW + '\n');
                  process.exit(0);
                }
                const resource = args[0];
                // `get`/detail commands → single-object envelope
                if (args.includes('get') || args.includes('config')) {
                  process.stdout.write(JSON.stringify({
                    id: 'req-1', ok: true,
                    data: { id: `${resource}-detail`, args: args.join(' '), extra: 'field' },
                  }) + '\n');
                  process.exit(0);
                }
                if (process.env.STUB_JSONLINES) {
                  process.stdout.write(JSON.stringify({ id: `${resource}-1`, name: 'row one' }) + '\n');
                  process.stdout.write(JSON.stringify({ id: `${resource}-2`, name: 'row two' }) + '\n');
                  process.exit(0);
                }
                process.stdout.write(JSON.stringify({
                  id: 'req-1',
                  ok: true,
                  data: [
                    { id: `${resource}-1`, name: 'row one' },
                    { id: `${resource}-2`, name: 'row two' },
                  ],
                }) + '\n');
                process.exit(0);
              }, sleepMs);
              
          • activity-server.test.js 2.3 KB
            import { test, before, after } from 'node:test';
            import assert from 'node:assert/strict';
            import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
            import { tmpdir } from 'node:os';
            import { join } from 'node:path';
            import { DatabaseSync } from 'node:sqlite';
            import { createApp } from '../server.js';
            
            let root;
            before(() => {
              root = mkdtempSync(join(tmpdir(), 'clidash-actsrv-'));
              mkdirSync(join(root, 'ag-1', 'sess-1'), { recursive: true });
              const mk = (p, t, ts) => { const db = new DatabaseSync(p); db.exec(`CREATE TABLE ${t}(id TEXT, timestamp TEXT)`); const i = db.prepare(`INSERT INTO ${t} VALUES (?,?)`); ts.forEach((x, n) => i.run(String(n), x)); db.close(); };
              // Seed with instants moments ago: they land in the LOCAL-today bucket
              // (series.at(-1)) regardless of the machine's timezone.
              const now = Date.now();
              mk(join(root, 'ag-1', 'sess-1', 'inbound.db'), 'messages_in', [
                new Date(now - 120_000).toISOString(),
                new Date(now - 60_000).toISOString(),
              ]);
              mk(join(root, 'ag-1', 'sess-1', 'outbound.db'), 'messages_out', [new Date(now - 90_000).toISOString()]);
            });
            after(() => rmSync(root, { recursive: true, force: true }));
            
            async function withServer(config, fn) {
              const server = createApp({ port: 0, bind: '127.0.0.1', clis: {}, ...config });
              await new Promise((r) => server.listen(0, '127.0.0.1', r));
              const base = `http://127.0.0.1:${server.address().port}`;
              try { return await fn(base); } finally { await new Promise((r) => server.close(r)); }
            }
            
            test('/api/activity: returns per-session totals + a daily series', async () => {
              await withServer({ activity: { sessionsRoot: root, days: 14 } }, async (base) => {
                const body = await (await fetch(`${base}/api/activity`)).json();
                assert.equal(body.ok, true);
                assert.equal(body.configured, true);
                assert.equal(body.series.length, 14);
                assert.equal(body.sessions[0].in, 2);
                assert.equal(body.sessions[0].out, 1);
                assert.equal(body.series.at(-1).in, 2); // today
                assert.equal(body.series.at(-1).out, 1);
              });
            });
            
            test('/api/activity: not configured → configured:false, no crash', async () => {
              await withServer({}, async (base) => {
                const body = await (await fetch(`${base}/api/activity`)).json();
                assert.equal(body.ok, true);
                assert.equal(body.configured, false);
              });
            });
            
          • activity.test.js 3.8 KB
            import { test, before, after } from 'node:test';
            import assert from 'node:assert/strict';
            import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
            import { tmpdir } from 'node:os';
            import { join } from 'node:path';
            import { DatabaseSync } from 'node:sqlite';
            import { collectActivity } from '../activity.js';
            
            let root;
            const NOW = new Date('2026-06-14T12:00:00Z');
            
            function makeDb(path, table, timestamps) {
              const db = new DatabaseSync(path);
              db.exec(`CREATE TABLE ${table} (id TEXT, timestamp TEXT)`);
              const ins = db.prepare(`INSERT INTO ${table} (id, timestamp) VALUES (?, ?)`);
              timestamps.forEach((t, i) => ins.run(String(i), t));
              db.close();
            }
            
            before(() => {
              root = mkdtempSync(join(tmpdir(), 'clidash-act-'));
              // session 1 (group ag-1): 3 inbound across 2 days, 2 outbound today
              mkdirSync(join(root, 'ag-1', 'sess-1'), { recursive: true });
              makeDb(join(root, 'ag-1', 'sess-1', 'inbound.db'), 'messages_in',
                ['2026-06-14 09:01:23', '2026-06-14 10:00:00', '2026-06-13 08:00:00']);
              makeDb(join(root, 'ag-1', 'sess-1', 'outbound.db'), 'messages_out',
                ['2026-06-14 09:05:00', '2026-06-14 10:05:00']);
              // session 2 (group ag-2): 1 inbound 20 days ago (outside 14d window), 0 outbound
              mkdirSync(join(root, 'ag-2', 'sess-2'), { recursive: true });
              makeDb(join(root, 'ag-2', 'sess-2', 'inbound.db'), 'messages_in', ['2026-05-25 08:00:00']);
              makeDb(join(root, 'ag-2', 'sess-2', 'outbound.db'), 'messages_out', []);
            });
            
            after(() => rmSync(root, { recursive: true, force: true }));
            
            test('collectActivity: per-session in/out totals + last activity', () => {
              const { sessions } = collectActivity(root, 14, NOW);
              const s1 = sessions.find((s) => s.session_id === 'sess-1');
              assert.equal(s1.agent_group_id, 'ag-1');
              assert.equal(s1.in, 3);
              assert.equal(s1.out, 2);
              assert.equal(s1.lastActivity, '2026-06-14T10:05:00Z'); // normalized to ISO
              const s2 = sessions.find((s) => s.session_id === 'sess-2');
              assert.equal(s2.in, 1);
              assert.equal(s2.out, 0);
            });
            
            // Buckets are LOCAL calendar days — derive expectations with the same
            // mapping so the assertions hold in any machine timezone.
            const day = (t) => new Date(t).toLocaleDateString('sv-SE');
            
            test('collectActivity: series has one bucket per day for `days`, newest last', () => {
              const { series } = collectActivity(root, 14, NOW);
              assert.equal(series.length, 14);
              assert.equal(series[0].date, day(new Date(NOW.getTime() - 13 * 86_400_000)));
              assert.equal(series[13].date, day(NOW));
            });
            
            test('collectActivity: counts land in the right day buckets', () => {
              const { series } = collectActivity(root, 14, NOW);
              const byDate = Object.fromEntries(series.map((d) => [d.date, d]));
              const expIn = {};
              const expOut = {};
              for (const t of ['2026-06-14T09:01:23Z', '2026-06-14T10:00:00Z', '2026-06-13T08:00:00Z']) {
                expIn[day(t)] = (expIn[day(t)] ?? 0) + 1;
              }
              for (const t of ['2026-06-14T09:05:00Z', '2026-06-14T10:05:00Z']) {
                expOut[day(t)] = (expOut[day(t)] ?? 0) + 1;
              }
              for (const [d, n] of Object.entries(expIn)) assert.equal(byDate[d].in, n);
              for (const [d, n] of Object.entries(expOut)) assert.equal(byDate[d].out, n);
            });
            
            test('collectActivity: messages outside the window are counted in totals but not the series', () => {
              const { series, sessions } = collectActivity(root, 14, NOW);
              const total = series.reduce((a, d) => a + d.in + d.out, 0);
              assert.equal(total, 5);              // the 20-day-old message is excluded from series
              assert.equal(sessions.find((s) => s.session_id === 'sess-2').in, 1); // but still in the total count
            });
            
            test('collectActivity: a dir with no message DBs is not a session (skipped)', () => {
              mkdirSync(join(root, 'ag-1', '.claude-shared'), { recursive: true }); // scaffolding, no db files
              const { sessions } = collectActivity(root, 14, NOW);
              assert.ok(!sessions.some((s) => s.session_id === '.claude-shared'));
            });
            
          • cmd.test.js 3.4 KB
            import { test, after } from 'node:test';
            import assert from 'node:assert/strict';
            import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
            import { tmpdir } from 'node:os';
            import { join } from 'node:path';
            import { fileURLToPath } from 'node:url';
            import { createApp } from '../server.js';
            
            const STUB = fileURLToPath(new URL('./fixtures/stub-cli.js', import.meta.url));
            const tmp = mkdtempSync(join(tmpdir(), 'clidash-cmd-'));
            after(() => rmSync(tmp, { recursive: true, force: true }));
            
            function cli(extra = {}) {
              return {
                bin: process.execPath,
                discover: { args: [STUB, 'help'], parser: 'ncl-help' },
                list: [STUB, '{resource}', 'list', '--json'],
                output: 'json',
                unwrap: 'data',
                commands: {
                  get: [STUB, '{resource}', 'get', '{id}', '--json'],
                  'config-get': [STUB, 'groups', 'config', 'get', '--id', '{id}', '--json'],
                },
                ...extra,
              };
            }
            
            async function withServer(clis, fn, extra = {}) {
              const server = createApp({ port: 0, bind: '127.0.0.1', execTimeoutMs: 2000, clis, ...extra });
              await new Promise((r) => server.listen(0, '127.0.0.1', r));
              const base = `http://127.0.0.1:${server.address().port}`;
              try { return await fn(base); } finally { await new Promise((r) => server.close(r)); }
            }
            
            test('/api/cmd: runs an allowlisted command with {resource} + {id}', async () => {
              await withServer({ ncl: cli() }, async (base) => {
                const body = await (await fetch(`${base}/api/cmd/ncl/get?resource=sessions&id=sess-123`)).json();
                assert.equal(body.ok, true);
                assert.equal(body.data.id, 'sessions-detail');
                assert.match(body.data.args, /sessions get sess-123/);
              });
            });
            
            test('/api/cmd: config-get needs no resource', async () => {
              await withServer({ ncl: cli() }, async (base) => {
                const body = await (await fetch(`${base}/api/cmd/ncl/config-get?id=ag-1`)).json();
                assert.equal(body.ok, true);
                assert.match(body.data.args, /groups config get --id ag-1/);
              });
            });
            
            test('/api/cmd: unknown command name → 404 (allowlist)', async () => {
              await withServer({ ncl: cli() }, async (base) => {
                const res = await fetch(`${base}/api/cmd/ncl/delete?resource=groups&id=ag-1`);
                assert.equal(res.status, 404);
              });
            });
            
            test('/api/cmd: a {resource} not in the discovered set is rejected without exec', async () => {
              const countFile = join(tmp, 'cmd-count.txt');
              const c = cli();
              c.env = { STUB_COUNT_FILE: countFile };
              await withServer({ ncl: c }, async (base) => {
                const res = await fetch(`${base}/api/cmd/ncl/get?resource=evil&id=x`);
                assert.equal(res.status, 404);
                // only discovery ran, never a get for the bogus resource
                const calls = readFileSync(countFile, 'utf8').trim().split('\n');
                assert.deepEqual(calls, ['help']);
              });
            });
            
            test('/api/cmd: an id with illegal characters is rejected', async () => {
              await withServer({ ncl: cli() }, async (base) => {
                const res = await fetch(`${base}/api/cmd/ncl/get?resource=sessions&id=${encodeURIComponent('a b;rm -rf')}`);
                assert.equal(res.status, 400);
              });
            });
            
            test('/api/cmd: unknown cli → 404', async () => {
              await withServer({ ncl: cli() }, async (base) => {
                assert.equal((await fetch(`${base}/api/cmd/nope/get?resource=sessions&id=x`)).status, 404);
              });
            });
            
            test('/api/cmd: a cli without a commands map → 404', async () => {
              const c = cli();
              delete c.commands;
              await withServer({ ncl: c }, async (base) => {
                assert.equal((await fetch(`${base}/api/cmd/ncl/get?resource=sessions&id=x`)).status, 404);
              });
            });
            
          • css.test.js 991 B
            import { test } from 'node:test';
            import assert from 'node:assert/strict';
            import { readFileSync } from 'node:fs';
            import { fileURLToPath } from 'node:url';
            
            const css = readFileSync(fileURLToPath(new URL('../public/style.css', import.meta.url)), 'utf8');
            
            // Regression: the `hidden` attribute must override author `display` rules.
            // `.detail-overlay` and `.cli-switcher` set `display:flex`, which beats the
            // browser's default `[hidden]{display:none}` — without this reset a hidden
            // overlay stays on top of the page and silently eats every click.
            test('style.css forces [hidden] to display:none with !important', () => {
              assert.match(css, /\[hidden\]\s*\{\s*display:\s*none\s*!important;?\s*\}/);
            });
            
            // Guard the premise: if these stop using display:flex the reset is less load-
            // bearing, but this documents WHY the reset exists.
            test('the overlays that motivated the reset still use display:flex', () => {
              assert.match(css, /\.detail-overlay\s*\{[^}]*display:\s*flex/);
            });
            
          • docs-server.test.js 4 KB
            import { test, before, after } from 'node:test';
            import assert from 'node:assert/strict';
            import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
            import { tmpdir } from 'node:os';
            import { join } from 'node:path';
            import { createApp } from '../server.js';
            
            let root;
            
            before(() => {
              root = mkdtempSync(join(tmpdir(), 'clidash-docsrv-'));
              const w = (rel, body) => {
                const abs = join(root, rel);
                mkdirSync(join(abs, '..'), { recursive: true });
                writeFileSync(abs, body);
              };
              w('groups/alpha/skills/tagger/SKILL.md', '# tagger\nhello');
              w('container/skills/welcome/SKILL.md', '# welcome');
              w('groups/alpha/profile.json', '{"name":"Alpha"}');
              w('groups/alpha/.env', 'SECRET=nope');
            });
            
            after(() => rmSync(root, { recursive: true, force: true }));
            
            function docsConfig() {
              return {
                port: 0,
                bind: '127.0.0.1',
                clis: {},
                docs: {
                  root,
                  deny: ['node_modules', '.env', '*token*', '*secret*', '*.pem', '*.key'],
                  collections: [
                    { name: 'skills', label: 'Skills', lang: 'markdown', patterns: ['groups/*/skills/*/SKILL.md', 'container/skills/*/SKILL.md'] },
                    { name: 'profiles', label: 'Profiles', lang: 'json', patterns: ['groups/*/profile.json'] },
                  ],
                },
              };
            }
            
            async function withServer(config, fn) {
              const server = createApp(config);
              await new Promise((r) => server.listen(0, '127.0.0.1', r));
              const base = `http://127.0.0.1:${server.address().port}`;
              try {
                return await fn(base);
              } finally {
                await new Promise((r) => server.close(r));
              }
            }
            
            test('/api/docs: lists collections with their files', async () => {
              await withServer(docsConfig(), async (base) => {
                const body = await (await fetch(`${base}/api/docs`)).json();
                const skills = body.collections.find((c) => c.name === 'skills');
                assert.equal(skills.label, 'Skills');
                assert.equal(skills.lang, 'markdown');
                const paths = skills.files.map((f) => f.path);
                assert.ok(paths.includes('groups/alpha/skills/tagger/SKILL.md'));
                assert.ok(paths.includes('container/skills/welcome/SKILL.md'));
                // each file carries a readable label + group
                const f = skills.files.find((x) => x.path.includes('tagger'));
                assert.equal(f.group, 'alpha');
                assert.match(f.label, /tagger/);
              });
            });
            
            test('/api/doc: returns file content + lang', async () => {
              await withServer(docsConfig(), async (base) => {
                const url = `${base}/api/doc?c=skills&p=${encodeURIComponent('groups/alpha/skills/tagger/SKILL.md')}`;
                const body = await (await fetch(url)).json();
                assert.equal(body.ok, true);
                assert.equal(body.lang, 'markdown');
                assert.match(body.content, /# tagger/);
              });
            });
            
            test('/api/doc: a denied file is not readable even though it sits under root', async () => {
              await withServer(docsConfig(), async (base) => {
                // .env is excluded by the deny-list and not in any collection pattern
                const coll = docsConfig();
                coll.docs.collections.push({ name: 'all', label: 'All', lang: 'text', patterns: ['groups/*/*'] });
                await withServer(coll, async (base2) => {
                  const res = await fetch(`${base2}/api/doc?c=all&p=${encodeURIComponent('groups/alpha/.env')}`);
                  assert.equal(res.status, 404);
                  assert.equal((await res.json()).ok, false);
                });
              });
            });
            
            test('/api/doc: path traversal is rejected', async () => {
              await withServer(docsConfig(), async (base) => {
                const res = await fetch(`${base}/api/doc?c=skills&p=${encodeURIComponent('../../../../etc/passwd')}`);
                assert.equal(res.status, 404);
                assert.equal((await res.json()).ok, false);
              });
            });
            
            test('/api/doc: unknown collection → 404', async () => {
              await withServer(docsConfig(), async (base) => {
                const res = await fetch(`${base}/api/doc?c=nope&p=x`);
                assert.equal(res.status, 404);
              });
            });
            
            test('/api/docs: absent docs config → empty collections, no crash', async () => {
              await withServer({ port: 0, bind: '127.0.0.1', clis: {} }, async (base) => {
                const body = await (await fetch(`${base}/api/docs`)).json();
                assert.deepEqual(body.collections, []);
              });
            });
            
          • docs.test.js 4.4 KB
            import { test, before, after } from 'node:test';
            import assert from 'node:assert/strict';
            import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
            import { tmpdir } from 'node:os';
            import { join } from 'node:path';
            import { globFiles, describeFile, resolveDoc } from '../docs.js';
            
            let root;
            
            before(() => {
              root = mkdtempSync(join(tmpdir(), 'clidash-docs-'));
              const w = (rel, body = 'x') => {
                const abs = join(root, rel);
                mkdirSync(join(abs, '..'), { recursive: true });
                writeFileSync(abs, body);
              };
              w('groups/alpha/skills/example-skill/SKILL.md', '# example-skill\nbody');
              w('groups/alpha/skills/tagger/SKILL.md');
              w('groups/alpha/CLAUDE.md', '# Alpha');
              w('groups/alpha/CLAUDE.local.md');
              w('groups/alpha/profile.json', '{"name":"Alpha"}');
              w('groups/alpha/conversations/2026-06-01.md');
              w('groups/bravo/skills/tagger/SKILL.md');
              w('groups/bravo/profile.json');
              w('container/skills/agent-browser/SKILL.md');
              w('container/skills/welcome/SKILL.md');
              // things that must NEVER be served
              w('groups/alpha/.env', 'SECRET=1');
              w('groups/alpha/skills/example-skill/node_modules/dep/SKILL.md');
              w('groups/alpha/notion-token.txt', 'ntn_xxx');
            });
            
            after(() => rmSync(root, { recursive: true, force: true }));
            
            const DENY = ['node_modules', '.env', '*token*', '*secret*', '*.pem', '*.key'];
            
            // --------------------------------------------------------------- globFiles
            
            test('globFiles: matches a nested *-segment pattern', () => {
              const files = globFiles(root, ['groups/*/skills/*/SKILL.md'], DENY);
              assert.deepEqual(files, [
                'groups/alpha/skills/example-skill/SKILL.md',
                'groups/alpha/skills/tagger/SKILL.md',
                'groups/bravo/skills/tagger/SKILL.md',
              ]);
            });
            
            test('globFiles: multiple patterns union, sorted', () => {
              const files = globFiles(root, ['groups/*/skills/*/SKILL.md', 'container/skills/*/SKILL.md'], DENY);
              assert.ok(files.includes('container/skills/agent-browser/SKILL.md'));
              assert.ok(files.includes('groups/alpha/skills/example-skill/SKILL.md'));
            });
            
            test('globFiles: wildcard inside a filename segment', () => {
              const files = globFiles(root, ['groups/*/CLAUDE*.md'], DENY);
              assert.deepEqual(files, ['groups/alpha/CLAUDE.local.md', 'groups/alpha/CLAUDE.md']);
            });
            
            test('globFiles: deny list excludes node_modules and secret-ish files', () => {
              const files = globFiles(root, ['groups/*/skills/*/**', 'groups/*/*'], DENY);
              assert.ok(!files.some((f) => f.includes('node_modules')));
              assert.ok(!files.some((f) => f.endsWith('.env')));
              assert.ok(!files.some((f) => f.includes('token')));
            });
            
            test('globFiles: no match returns empty array', () => {
              assert.deepEqual(globFiles(root, ['nope/*/x.md'], DENY), []);
            });
            
            // ------------------------------------------------------------- describeFile
            
            test('describeFile: per-group skill → group + readable label', () => {
              const d = describeFile('groups/alpha/skills/tagger/SKILL.md');
              assert.equal(d.group, 'alpha');
              assert.match(d.label, /alpha/);
              assert.match(d.label, /tagger/);
            });
            
            test('describeFile: container skill → shared', () => {
              const d = describeFile('container/skills/agent-browser/SKILL.md');
              assert.equal(d.group, 'shared');
              assert.match(d.label, /agent-browser/);
            });
            
            // --------------------------------------------------------------- resolveDoc
            
            const SKILLS = { name: 'skills', patterns: ['groups/*/skills/*/SKILL.md', 'container/skills/*/SKILL.md'] };
            
            test('resolveDoc: returns an absolute path for an allowed file', () => {
              const abs = resolveDoc(root, SKILLS, 'groups/alpha/skills/example-skill/SKILL.md', DENY);
              assert.ok(abs.endsWith('/groups/alpha/skills/example-skill/SKILL.md'));
              assert.ok(abs.startsWith(root));
            });
            
            test('resolveDoc: rejects a path not matching the collection patterns', () => {
              assert.throws(() => resolveDoc(root, SKILLS, 'groups/alpha/profile.json', DENY), /not allowed/i);
            });
            
            test('resolveDoc: rejects path traversal', () => {
              assert.throws(() => resolveDoc(root, SKILLS, '../../etc/passwd', DENY), /not allowed/i);
              assert.throws(() => resolveDoc(root, SKILLS, 'groups/alpha/skills/../../../.env', DENY), /not allowed/i);
            });
            
            test('resolveDoc: rejects an absolute path', () => {
              assert.throws(() => resolveDoc(root, SKILLS, '/etc/passwd', DENY), /not allowed/i);
            });
            
            test('resolveDoc: a denied file is not resolvable even if pattern-shaped', () => {
              const coll = { name: 'all', patterns: ['groups/*/*'] };
              assert.throws(() => resolveDoc(root, coll, 'groups/alpha/.env', DENY), /not allowed/i);
            });
            
          • help.test.js 2.2 KB
            import { test } from 'node:test';
            import assert from 'node:assert/strict';
            import { fileURLToPath } from 'node:url';
            import { createApp } from '../server.js';
            
            const STUB = fileURLToPath(new URL('./fixtures/stub-cli.js', import.meta.url));
            
            function cli(extra = {}) {
              return {
                bin: process.execPath,
                discover: { args: [STUB, 'help'], parser: 'ncl-help' },
                list: [STUB, '{resource}', 'list', '--json'],
                output: 'json', unwrap: 'data',
                help: [STUB, '{resource}', 'help'],
                ...extra,
              };
            }
            
            async function withServer(clis, fn) {
              const server = createApp({ port: 0, bind: '127.0.0.1', execTimeoutMs: 2000, clis });
              await new Promise((r) => server.listen(0, '127.0.0.1', r));
              const base = `http://127.0.0.1:${server.address().port}`;
              try { return await fn(base); } finally { await new Promise((r) => server.close(r)); }
            }
            
            test('/api/help: returns raw per-resource help text', async () => {
              await withServer({ ncl: cli() }, async (base) => {
                const body = await (await fetch(`${base}/api/help/ncl/sessions`)).json();
                assert.equal(body.ok, true);
                assert.match(body.text, /sessions: help for sessions/);
                assert.match(body.text, /Verbs:/);
              });
            });
            
            test('/api/help: undiscovered resource → 404', async () => {
              await withServer({ ncl: cli() }, async (base) => {
                assert.equal((await fetch(`${base}/api/help/ncl/evil`)).status, 404);
              });
            });
            
            test('/api/help: a cli without a help template → 404', async () => {
              const c = cli(); delete c.help;
              await withServer({ ncl: c }, async (base) => {
                assert.equal((await fetch(`${base}/api/help/ncl/sessions`)).status, 404);
              });
            });
            
            test('/api/help: unknown cli → 404', async () => {
              await withServer({ ncl: cli() }, async (base) => {
                assert.equal((await fetch(`${base}/api/help/nope/sessions`)).status, 404);
              });
            });
            
            test('/api/clis: reports help availability per cli', async () => {
              const noHelp = cli(); delete noHelp.help;
              await withServer({ ncl: cli(), docker: noHelp }, async (base) => {
                const body = await (await fetch(`${base}/api/clis`)).json();
                assert.equal(body.clis.find((c) => c.name === 'ncl').help, true);
                assert.equal(body.clis.find((c) => c.name === 'docker').help, false);
              });
            });
            
          • logs.test.js 2.9 KB
            import { test, before, after } from 'node:test';
            import assert from 'node:assert/strict';
            import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
            import { tmpdir } from 'node:os';
            import { join } from 'node:path';
            import { tailFile } from '../logs.js';
            import { createApp } from '../server.js';
            
            let dir;
            before(() => {
              dir = mkdtempSync(join(tmpdir(), 'clidash-logs-'));
              // 10 lines, some with ANSI color codes
              const lines = Array.from({ length: 10 }, (_, i) =>
                `[12:00:0${i}] \x1b[32mINFO\x1b[39m line ${i}`);
              writeFileSync(join(dir, 'app.log'), lines.join('\n') + '\n');
              writeFileSync(join(dir, 'error.log'), 'boom\n');
            });
            after(() => rmSync(dir, { recursive: true, force: true }));
            
            test('tailFile: returns the last N lines, ANSI stripped, no trailing blank', async () => {
              const { lines, text } = await tailFile(join(dir, 'app.log'), 3);
              assert.equal(lines.length, 3);
              assert.deepEqual(lines, ['[12:00:07] INFO line 7', '[12:00:08] INFO line 8', '[12:00:09] INFO line 9']);
              assert.ok(!text.includes('\x1b'));
            });
            
            test('tailFile: maxLines larger than file returns all lines', async () => {
              const { lines } = await tailFile(join(dir, 'app.log'), 100);
              assert.equal(lines.length, 10);
            });
            
            // ---- server endpoints ----
            
            function cfg() {
              return {
                port: 0, bind: '127.0.0.1', clis: {},
                logs: { dir, tailLines: 5, files: [{ name: 'app.log', label: 'app' }, { name: 'error.log', label: 'errors' }] },
              };
            }
            async function withServer(config, fn) {
              const server = createApp(config);
              await new Promise((r) => server.listen(0, '127.0.0.1', r));
              const base = `http://127.0.0.1:${server.address().port}`;
              try { return await fn(base); } finally { await new Promise((r) => server.close(r)); }
            }
            
            test('/api/logs: lists the configured log files', async () => {
              await withServer(cfg(), async (base) => {
                const body = await (await fetch(`${base}/api/logs`)).json();
                assert.deepEqual(body.files.map((f) => f.name), ['app.log', 'error.log']);
              });
            });
            
            test('/api/logs: absent logs config → empty list', async () => {
              await withServer({ port: 0, bind: '127.0.0.1', clis: {} }, async (base) => {
                assert.deepEqual((await (await fetch(`${base}/api/logs`)).json()).files, []);
              });
            });
            
            test('/api/log: returns the tail text + a tail command', async () => {
              await withServer(cfg(), async (base) => {
                const body = await (await fetch(`${base}/api/log/app.log`)).json();
                assert.equal(body.ok, true);
                assert.match(body.text, /line 9$/);
                assert.equal(body.text.split('\n').length, 5); // tailLines
                assert.match(body.command, /tail -n 5 .*app\.log/);
              });
            });
            
            test('/api/log: a name not in the allowlist is rejected (no traversal)', async () => {
              await withServer(cfg(), async (base) => {
                assert.equal((await fetch(`${base}/api/log/${encodeURIComponent('../../etc/passwd')}`)).status, 404);
                assert.equal((await fetch(`${base}/api/log/secrets.log`)).status, 404);
              });
            });
            
          • md.test.js 2.9 KB
            import { test } from 'node:test';
            import assert from 'node:assert/strict';
            import { escapeHtml, mdToHtml } from '../public/md.js';
            
            // ---- escaping -------------------------------------------------------------
            
            test('escapeHtml: neutralizes all HTML metacharacters', () => {
              assert.equal(escapeHtml(`<script>"&'`), '&lt;script&gt;&quot;&amp;&#39;');
            });
            
            test('mdToHtml: raw HTML in source is escaped, never passed through', () => {
              const html = mdToHtml('a <script>alert(1)</script> b');
              assert.ok(!html.includes('<script>'));
              assert.ok(html.includes('&lt;script&gt;'));
            });
            
            // ---- the security-sensitive part: links -----------------------------------
            
            test('mdToHtml: link href comes from the URL, label from the text', () => {
              const html = mdToHtml('see [the docs](https://example.com/x)');
              assert.match(html, /<a href="https:\/\/example\.com\/x" target="_blank" rel="noopener noreferrer">the docs<\/a>/);
            });
            
            test('mdToHtml: javascript: smuggled in link TEXT stays inert (never an href)', () => {
              const html = mdToHtml('[javascript:alert(1)](https://safe.com)');
              // href is the safe URL; the js string is only visible label text
              assert.match(html, /href="https:\/\/safe\.com"/);
              assert.ok(!/href="javascript:/i.test(html));
            });
            
            test('mdToHtml: a non-http(s) URL is not turned into a link', () => {
              // javascript:/data: never match the (https?:...) capture, so the literal
              // (escaped) markdown is left as-is — no anchor, no executable href.
              const html = mdToHtml('[click](javascript:alert(1))');
              assert.ok(!/<a /.test(html));
              assert.ok(!/href="javascript:/i.test(html));
            });
            
            test('mdToHtml: an attribute-breakout attempt in the URL cannot escape the href', () => {
              // The double-quote is escaped to &quot; before the regex runs, so it can never
              // close an attribute. (Here the URL also has a space, so no anchor even forms.)
              // The security property: no REAL attribute (with a literal quote) is injected.
              const html = mdToHtml('[x](https://a" onmouseover="alert(1))');
              assert.ok(!/<a/.test(html), 'malformed link must not produce an anchor');
              assert.ok(!/onmouseover="/.test(html), 'no real (unescaped-quote) attribute injected');
            });
            
            test('mdToHtml: an escaped quote inside a matched URL stays inside the href, inert', () => {
              // Even when a URL matches, any " in it is already &quot; (an entity), which
              // does not terminate an HTML attribute value — so no breakout.
              const html = mdToHtml('[x](https://a"onmouseover=alert)');
              assert.ok(!/onmouseover="/.test(html));
              if (/<a/.test(html)) assert.match(html, /href="https:\/\/a&quot;onmouseover=alert"/);
            });
            
            // ---- basic rendering sanity ----------------------------------------------
            
            test('mdToHtml: headings, code fences, lists render', () => {
              const html = mdToHtml('# Title\n\n```\ncode\n```\n\n- a\n- b');
              assert.match(html, /<h1>Title<\/h1>/);
              assert.match(html, /<pre class="code"><code>code<\/code><\/pre>/);
              assert.match(html, /<ul><li>a<\/li><li>b<\/li><\/ul>/);
            });
            
          • ncl-overview.test.js 3.5 KB
            import { test } from 'node:test';
            import assert from 'node:assert/strict';
            import overview from '../views/ncl-overview.js';
            
            const minutesAgo = (m) => new Date(Date.now() - m * 60_000).toISOString();
            
            // Shapes mirror real `ncl <resource> list --json` output.
            function makeFixtures({ alphaLastActive, bravoLastActive }) {
              return {
                groups: [
                  { id: 'ag-1', name: 'Alpha', folder: 'alpha', created_at: '2026-05-31T11:14:48.793Z' },
                  { id: 'ag-2', name: 'Bravo Team', folder: 'bravo', created_at: '2026-05-31T11:14:48.796Z' },
                  { id: 'ag-3', name: 'Orphan', folder: 'orphan', created_at: '2026-05-31T11:14:48.799Z' },
                ],
                sessions: [
                  { id: 'sess-1', agent_group_id: 'ag-1', messaging_group_id: 'mg-1', thread_id: null, status: 'active', container_status: 'stopped', last_active: alphaLastActive, created_at: '2026-05-31T11:14:51.911Z' },
                  { id: 'sess-2', agent_group_id: 'ag-2', messaging_group_id: 'mg-2', thread_id: null, status: 'active', container_status: 'running', last_active: bravoLastActive, created_at: '2026-05-31T11:14:51.973Z' },
                ],
                'messaging-groups': [
                  { id: 'mg-1', channel_type: 'telegram', platform_id: 'telegram:1', name: 'Alpha', is_group: 0 },
                  { id: 'mg-2', channel_type: 'telegram', platform_id: 'telegram:2', name: 'Bravo Team', is_group: 0 },
                ],
                wirings: [
                  { id: 'mga-1', messaging_group_id: 'mg-1', agent_group_id: 'ag-1', session_mode: 'shared' },
                  { id: 'mga-2', messaging_group_id: 'mg-2', agent_group_id: 'ag-2', session_mode: 'shared' },
                ],
              };
            }
            
            function fetchFrom(fixtures) {
              return async (resource) => {
                if (!(resource in fixtures)) throw new Error(`unexpected fetch: ${resource}`);
                return fixtures[resource];
              };
            }
            
            test('overview: one card per agent group with joined session + wiring data', async () => {
              const fixtures = makeFixtures({ alphaLastActive: minutesAgo(5), bravoLastActive: minutesAgo(30) });
              const result = await overview({ fetch: fetchFrom(fixtures) });
              assert.equal(result.cards.length, 3);
            
              const alpha = result.cards.find((c) => c.title === 'Alpha');
              assert.equal(alpha.subtitle, 'alpha');
              assert.equal(alpha.fields.container, 'stopped');
              assert.equal(alpha.fields.sessions, 1);
              assert.deepEqual(alpha.badges, ['telegram: Alpha']);
            
              const bravo = result.cards.find((c) => c.title === 'Bravo Team');
              assert.equal(bravo.fields.container, 'running');
              assert.deepEqual(bravo.badges, ['telegram: Bravo Team']);
            });
            
            test('overview: staleness thresholds — green <15m, amber <2h, red older, gray never', async () => {
              const fixtures = makeFixtures({ alphaLastActive: minutesAgo(5), bravoLastActive: minutesAgo(30) });
              const result = await overview({ fetch: fetchFrom(fixtures) });
              assert.equal(result.cards.find((c) => c.title === 'Alpha').status, 'green');
              assert.equal(result.cards.find((c) => c.title === 'Bravo Team').status, 'amber');
              assert.equal(result.cards.find((c) => c.title === 'Orphan').status, 'gray');
            
              const stale = makeFixtures({ alphaLastActive: minutesAgo(300), bravoLastActive: minutesAgo(30) });
              const result2 = await overview({ fetch: fetchFrom(stale) });
              assert.equal(result2.cards.find((c) => c.title === 'Alpha').status, 'red');
            });
            
            test('overview: last_active is exposed for relative-time rendering', async () => {
              const ts = minutesAgo(5);
              const fixtures = makeFixtures({ alphaLastActive: ts, bravoLastActive: minutesAgo(30) });
              const result = await overview({ fetch: fetchFrom(fixtures) });
              assert.equal(result.cards.find((c) => c.title === 'Alpha').fields['last active'], ts);
            });
            
          • parsers.test.js 4 KB
            import { test } from 'node:test';
            import assert from 'node:assert/strict';
            import { readFileSync } from 'node:fs';
            import { fileURLToPath } from 'node:url';
            import { discoveryParsers, parseOutput, unwrapPath } from '../parsers.js';
            
            const fixture = readFileSync(
              fileURLToPath(new URL('./fixtures/ncl-help.txt', import.meta.url)),
              'utf8',
            );
            
            // ---------------------------------------------------------------- ncl-help
            
            test('ncl-help: parses all listable resources from real captured output', () => {
              const resources = discoveryParsers['ncl-help'](fixture);
              assert.deepEqual(
                resources.map((r) => r.name),
                [
                  'approvals', 'destinations', 'dropped-messages', 'groups', 'members',
                  'messaging-groups', 'roles', 'sessions', 'user-dms', 'users', 'wirings',
                ],
              );
            });
            
            test('ncl-help: every parsed resource has a non-empty description and a list verb', () => {
              const resources = discoveryParsers['ncl-help'](fixture);
              for (const r of resources) {
                assert.ok(r.description.length > 0, `${r.name} has empty description`);
                assert.ok(r.verbs.includes('list'), `${r.name} missing list verb`);
              }
            });
            
            test('ncl-help: parses verbs correctly, including multi-word verbs', () => {
              const resources = discoveryParsers['ncl-help'](fixture);
              const groups = resources.find((r) => r.name === 'groups');
              assert.deepEqual(groups.verbs, [
                'list', 'get', 'create', 'update', 'delete', 'restart',
                'config get', 'config update', 'config add-mcp-server',
                'config remove-mcp-server', 'config add-package', 'config remove-package',
              ]);
            });
            
            test('ncl-help: excludes resources without a list verb', () => {
              const input = [
                'Resources:',
                '  alpha                Has list.',
                '                       verbs: list, get',
                '  beta                 No list here.',
                '                       verbs: grant, revoke',
                '',
              ].join('\n');
              const resources = discoveryParsers['ncl-help'](input);
              assert.deepEqual(resources.map((r) => r.name), ['alpha']);
            });
            
            test('ncl-help: ignores the Commands section (help is not a resource)', () => {
              const resources = discoveryParsers['ncl-help'](fixture);
              assert.ok(!resources.some((r) => r.name === 'help'));
            });
            
            test('ncl-help: throws loudly on unrecognized format', () => {
              assert.throws(() => discoveryParsers['ncl-help']('totally not help output'), /Resources/);
              assert.throws(() => discoveryParsers['ncl-help'](''), /Resources/);
            });
            
            // ------------------------------------------------------------- parseOutput
            
            test('parseOutput json: parses a single document', () => {
              assert.deepEqual(parseOutput('{"a": 1}', 'json'), { a: 1 });
            });
            
            test('parseOutput json: throws on malformed input with raw output preserved', () => {
              assert.throws(() => parseOutput('not json', 'json'), (err) => {
                assert.match(err.message, /JSON/i);
                assert.equal(err.raw, 'not json');
                return true;
              });
            });
            
            test('parseOutput jsonlines: one object per line, blank lines skipped', () => {
              const text = '{"id":1}\n\n{"id":2}\n{"id":3}\n';
              assert.deepEqual(parseOutput(text, 'jsonlines'), [{ id: 1 }, { id: 2 }, { id: 3 }]);
            });
            
            test('parseOutput jsonlines: throws on a malformed line', () => {
              assert.throws(() => parseOutput('{"ok":1}\ngarbage\n', 'jsonlines'), /line 2/i);
            });
            
            test('parseOutput: rejects unknown format', () => {
              assert.throws(() => parseOutput('{}', 'xml'), /format/i);
            });
            
            // -------------------------------------------------------------- unwrapPath
            
            test('unwrapPath: extracts the ncl {id, ok, data} envelope', () => {
              const doc = { id: 'x', ok: true, data: [{ id: 'sess-1' }] };
              assert.deepEqual(unwrapPath(doc, 'data'), [{ id: 'sess-1' }]);
            });
            
            test('unwrapPath: supports nested dot paths', () => {
              assert.deepEqual(unwrapPath({ a: { b: [1, 2] } }, 'a.b'), [1, 2]);
            });
            
            test('unwrapPath: throws when the path is missing', () => {
              assert.throws(() => unwrapPath({ ok: true }, 'data'), /data/);
            });
            
            test('unwrapPath: no path returns the value unchanged', () => {
              const rows = [{ id: 1 }];
              assert.equal(unwrapPath(rows, undefined), rows);
            });
            
          • server.test.js 8.9 KB
            import { test, before, after } from 'node:test';
            import assert from 'node:assert/strict';
            import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
            import { tmpdir } from 'node:os';
            import { join } from 'node:path';
            import { fileURLToPath } from 'node:url';
            import { createApp } from '../server.js';
            
            const STUB = fileURLToPath(new URL('./fixtures/stub-cli.js', import.meta.url));
            const tmp = mkdtempSync(join(tmpdir(), 'clidash-test-'));
            
            function stubCli(extra = {}) {
              return {
                bin: process.execPath,
                discover: { args: [STUB, 'help'], parser: 'ncl-help' },
                list: [STUB, '{resource}', 'list', '--json'],
                output: 'json',
                unwrap: 'data',
                ...extra,
              };
            }
            
            function makeConfig(clis, extra = {}) {
              return { port: 0, bind: '127.0.0.1', execTimeoutMs: 2000, refreshSeconds: 10, clis, ...extra };
            }
            
            async function withServer(config, fn) {
              const server = createApp(config);
              await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
              const base = `http://127.0.0.1:${server.address().port}`;
              try {
                return await fn(base);
              } finally {
                await new Promise((resolve) => server.close(resolve));
              }
            }
            
            after(() => rmSync(tmp, { recursive: true, force: true }));
            
            // ----------------------------------------------------------------- /api/clis
            
            test('/api/clis: lists configured CLIs with discovered resources', async () => {
              await withServer(makeConfig({ stub: stubCli() }), async (base) => {
                const res = await fetch(`${base}/api/clis`);
                assert.equal(res.status, 200);
                const body = await res.json();
                assert.equal(body.clis.length, 1);
                assert.equal(body.clis[0].name, 'stub');
                assert.equal(body.clis[0].refreshSeconds, 10);
                const names = body.clis[0].resources.map((r) => r.name);
                assert.ok(names.includes('sessions'));
                assert.ok(names.includes('groups'));
                assert.equal(names.length, 11);
              });
            });
            
            test('/api/clis: static resource list needs no discovery', async () => {
              const cli = stubCli({ resources: ['alpha', 'beta'] });
              delete cli.discover;
              await withServer(makeConfig({ stub: cli }), async (base) => {
                const body = await (await fetch(`${base}/api/clis`)).json();
                assert.deepEqual(body.clis[0].resources.map((r) => r.name), ['alpha', 'beta']);
              });
            });
            
            test('/api/clis: discovery failure reports a loud error', async () => {
              const cli = stubCli();
              cli.env = { STUB_FAIL: '1' };
              await withServer(makeConfig({ stub: cli }), async (base) => {
                const body = await (await fetch(`${base}/api/clis`)).json();
                assert.equal(body.clis[0].resources.length, 0);
                assert.match(body.clis[0].error, /boom/);
              });
            });
            
            // ------------------------------------------------------------ /api/r/cli/res
            
            test('/api/r: returns unwrapped rows with fetchedAt', async () => {
              await withServer(makeConfig({ stub: stubCli() }), async (base) => {
                const res = await fetch(`${base}/api/r/stub/sessions`);
                assert.equal(res.status, 200);
                const body = await res.json();
                assert.equal(body.ok, true);
                assert.deepEqual(body.rows.map((r) => r.id), ['sessions-1', 'sessions-2']);
                assert.ok(body.fetchedAt);
              });
            });
            
            test('/api/r: rejects a resource not in the discovered set without exec', async () => {
              const countFile = join(tmp, 'count-reject.txt');
              const cli = stubCli();
              cli.env = { STUB_COUNT_FILE: countFile };
              await withServer(makeConfig({ stub: cli }), async (base) => {
                const res = await fetch(`${base}/api/r/stub/evil%20--rm`);
                assert.equal(res.status, 404);
                const body = await res.json();
                assert.equal(body.ok, false);
                // only the discovery exec ran — never a list exec for the bogus resource
                const calls = readFileSync(countFile, 'utf8').trim().split('\n');
                assert.deepEqual(calls, ['help']);
              });
            });
            
            test('/api/r: unknown cli → 404', async () => {
              await withServer(makeConfig({ stub: stubCli() }), async (base) => {
                const res = await fetch(`${base}/api/r/nope/sessions`);
                assert.equal(res.status, 404);
              });
            });
            
            test('/api/r: jsonlines CLI with static resources works', async () => {
              const cli = {
                bin: process.execPath,
                resources: ['ps'],
                list: [STUB, '{resource}'],
                output: 'jsonlines',
                env: { STUB_JSONLINES: '1' },
              };
              await withServer(makeConfig({ docker: cli }), async (base) => {
                const body = await (await fetch(`${base}/api/r/docker/ps`)).json();
                assert.equal(body.ok, true);
                assert.deepEqual(body.rows.map((r) => r.id), ['ps-1', 'ps-2']);
              });
            });
            
            test('/api/r: exec failure returns ok:false with stderr', async () => {
              const cli = stubCli({ resources: ['sessions'] });
              delete cli.discover;
              cli.env = { STUB_FAIL: '1' };
              await withServer(makeConfig({ stub: cli }), async (base) => {
                const res = await fetch(`${base}/api/r/stub/sessions`);
                assert.equal(res.status, 502);
                const body = await res.json();
                assert.equal(body.ok, false);
                assert.match(body.error, /boom: socket down/);
              });
            });
            
            test('/api/r: exec timeout returns ok:false naming the resource', async () => {
              const cli = stubCli({ resources: ['sessions'] });
              delete cli.discover;
              cli.env = { STUB_SLEEP_MS: '5000' };
              await withServer(makeConfig({ stub: cli }, { execTimeoutMs: 200 }), async (base) => {
                const body = await (await fetch(`${base}/api/r/stub/sessions`)).json();
                assert.equal(body.ok, false);
                assert.match(body.error, /sessions/);
                assert.match(body.error, /timed out/i);
              });
            });
            
            test('/api/r: malformed CLI output returns the raw output', async () => {
              const cli = stubCli({ resources: ['sessions'] });
              delete cli.discover;
              cli.env = { STUB_RAW: 'this is not json' };
              await withServer(makeConfig({ stub: cli }), async (base) => {
                const body = await (await fetch(`${base}/api/r/stub/sessions`)).json();
                assert.equal(body.ok, false);
                assert.match(body.raw, /this is not json/);
              });
            });
            
            test('/api/r: concurrent requests for the same resource coalesce into one exec', async () => {
              const countFile = join(tmp, 'count-coalesce.txt');
              const cli = stubCli({ resources: ['sessions'] });
              delete cli.discover;
              cli.env = { STUB_COUNT_FILE: countFile, STUB_SLEEP_MS: '150' };
              await withServer(makeConfig({ stub: cli }), async (base) => {
                const bodies = await Promise.all(
                  Array.from({ length: 5 }, () => fetch(`${base}/api/r/stub/sessions`).then((r) => r.json())),
                );
                for (const body of bodies) assert.equal(body.ok, true);
                const calls = readFileSync(countFile, 'utf8').trim().split('\n');
                assert.equal(calls.length, 1);
              });
            });
            
            // ------------------------------------------------------------- /api/view
            
            test('/api/view: runs a view plugin with a bound fetch helper', async () => {
              const viewsDir = join(tmp, 'views');
              writeFileSync(join(viewsDir, '..', 'placeholder'), ''); // ensure tmp exists
              const { mkdirSync } = await import('node:fs');
              mkdirSync(viewsDir, { recursive: true });
              writeFileSync(
                join(viewsDir, 'stub-overview.js'),
                'export default async function ({ fetch }) {\n' +
                '  const rows = await fetch("sessions");\n' +
                '  return { count: rows.length, first: rows[0].id };\n' +
                '}\n',
              );
              await withServer(makeConfig({ stub: stubCli() }, { viewsDir }), async (base) => {
                const res = await fetch(`${base}/api/view/stub/overview`);
                assert.equal(res.status, 200);
                const body = await res.json();
                assert.equal(body.ok, true);
                assert.deepEqual(body.result, { count: 2, first: 'sessions-1' });
              });
            });
            
            test('/api/view: missing view → 404; bad view name → 404', async () => {
              await withServer(makeConfig({ stub: stubCli() }, { viewsDir: join(tmp, 'views') }), async (base) => {
                assert.equal((await fetch(`${base}/api/view/stub/nope`)).status, 404);
                assert.equal((await fetch(`${base}/api/view/stub/..%2F..%2Fserver`)).status, 404);
              });
            });
            
            // ------------------------------------------------------------- static files
            
            test('GET /: serves the dashboard index.html', async () => {
              await withServer(makeConfig({ stub: stubCli() }), async (base) => {
                const res = await fetch(`${base}/`);
                assert.equal(res.status, 200);
                assert.match(res.headers.get('content-type'), /text\/html/);
                assert.match(await res.text(), /clidash/i);
              });
            });
            
            test('static: path traversal outside public/ is rejected', async () => {
              await withServer(makeConfig({ stub: stubCli() }), async (base) => {
                const res = await fetch(`${base}/..%2Fserver.js`);
                assert.notEqual(res.status, 200);
              });
            });
            
            test('/api/r: {resource} substitutes inside a larger argv string (ssh-remote pattern)', async () => {
              const cli = {
                bin: process.execPath,
                resources: ['sessions'],
                list: [STUB, 'wrapped-{resource}-arg', 'list'],
                output: 'json',
                unwrap: 'data',
                env: { STUB_COUNT_FILE: join(tmp, 'count-embed.txt') },
              };
              await withServer(makeConfig({ stub: cli }), async (base) => {
                const body = await (await fetch(`${base}/api/r/stub/sessions`)).json();
                assert.equal(body.ok, true);
                const calls = readFileSync(join(tmp, 'count-embed.txt'), 'utf8').trim();
                assert.equal(calls, 'wrapped-sessions-arg list');
              });
            });
            
          • smoke.sh 747 B
            #!/usr/bin/env bash
            # Smoke test against a running clidash instance (run on the VM after deploy).
            # Usage: ./test/smoke.sh [base-url]   (default http://127.0.0.1:4690)
            set -euo pipefail
            BASE="${1:-http://127.0.0.1:4690}"
            
            check() {
              local label="$1" url="$2" pattern="$3"
              if curl -fsS --max-time 15 "$url" | grep -q "$pattern"; then
                echo "OK   $label"
              else
                echo "FAIL $label ($url did not match $pattern)"
                exit 1
              fi
            }
            
            check "/api/clis"             "$BASE/api/clis"             '"resources"'
            check "/api/r/ncl/sessions"   "$BASE/api/r/ncl/sessions"   '"ok":true'
            check "/api/view/ncl/overview" "$BASE/api/view/ncl/overview" '"ok":true'
            check "GET / (static UI)"     "$BASE/"                     'clidash'
            echo "smoke: all good"
            
        • views
          • ncl-overview.js 1.9 KB
            // Curated "Agents overview" view for ncl: joins groups + sessions +
            // messaging-groups + wirings into per-agent cards. Returns the generic
            // card shape the frontend renders, so the UI itself stays CLI-agnostic:
            //   { title, cards: [{ title, subtitle, status, fields, badges }] }
            // status: green <15m since last_active, amber <2h, red older, gray never.
            
            const GREEN_MAX_MIN = 15;
            const AMBER_MAX_MIN = 120;
            
            function staleness(lastActive) {
              if (!lastActive) return 'gray';
              const ageMin = (Date.now() - new Date(lastActive).getTime()) / 60_000;
              if (ageMin < GREEN_MAX_MIN) return 'green';
              if (ageMin < AMBER_MAX_MIN) return 'amber';
              return 'red';
            }
            
            export default async function overview({ fetch }) {
              const [groups, sessions, messagingGroups, wirings] = await Promise.all([
                fetch('groups'),
                fetch('sessions'),
                fetch('messaging-groups'),
                fetch('wirings'),
              ]);
            
              const mgById = new Map(messagingGroups.map((mg) => [mg.id, mg]));
            
              const cards = groups.map((group) => {
                const groupSessions = sessions.filter((s) => s.agent_group_id === group.id);
                const lastActive = groupSessions
                  .map((s) => s.last_active)
                  .filter(Boolean)
                  .sort()
                  .at(-1) ?? null;
                const container = groupSessions.some((s) => s.container_status === 'running')
                  ? 'running'
                  : groupSessions[0]?.container_status ?? 'none';
            
                const badges = wirings
                  .filter((w) => w.agent_group_id === group.id)
                  .map((w) => {
                    const mg = mgById.get(w.messaging_group_id);
                    return mg ? `${mg.channel_type}: ${mg.name ?? mg.platform_id}` : w.messaging_group_id;
                  });
            
                return {
                  title: group.name,
                  subtitle: group.folder,
                  status: staleness(lastActive),
                  fields: {
                    container,
                    sessions: groupSessions.length,
                    'last active': lastActive,
                  },
                  badges,
                };
              });
            
              return { title: 'Agents overview', cards };
            }
            
        • activity.js 3.5 KB
          // Message-activity reader for clidash.
          //
          // ncl has no `messages` resource — message data lives in the per-session SQLite
          // DBs (`data/v2-sessions/<group>/<session>/{inbound,outbound}.db`). We read them
          // read-only with Node's built-in `node:sqlite` (no new dependency) and aggregate
          // per-session in/out totals + a daily time-series for charting.
          
          import { readdirSync, existsSync } from 'node:fs';
          import { join } from 'node:path';
          import { DatabaseSync } from 'node:sqlite';
          
          // Timestamps come in two shapes across tables: SQLite "YYYY-MM-DD HH:MM:SS" (UTC)
          // and already-ISO "YYYY-MM-DDTHH:MM:SS.sssZ". Normalize to a comparable ISO form
          // so date-bucketing and max("last") work regardless of which a row used.
          function normTs(ts) {
            if (typeof ts !== 'string' || ts.length < 10) return null;
            if (ts.includes('T')) return ts; // already ISO
            return `${ts.replace(' ', 'T')}Z`;
          }
          
          // Local calendar day "YYYY-MM-DD" — chart labels are read by a human, so
          // bucket by the server's local day, not the UTC date prefix.
          function localDay(date) {
            return date.toLocaleDateString('sv-SE');
          }
          
          function readTable(dbPath, table) {
            let db;
            try {
              db = new DatabaseSync(dbPath, { readOnly: true });
              const rows = db.prepare(`SELECT timestamp FROM ${table}`).all();
              const byDay = new Map();
              let last = null;
              for (const r of rows) {
                const ts = normTs(r.timestamp);
                if (!ts) continue;
                const day = localDay(new Date(ts));
                byDay.set(day, (byDay.get(day) ?? 0) + 1);
                if (last === null || ts > last) last = ts;
              }
              return { total: rows.length, byDay, last };
            } catch {
              return { total: 0, byDay: new Map(), last: null }; // missing/locked/corrupt → skip
            } finally {
              try { db?.close(); } catch { /* already closed */ }
            }
          }
          
          function listDirs(path) {
            try {
              return readdirSync(path, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
            } catch {
              return [];
            }
          }
          
          /**
           * Aggregate message activity across all session DBs under `sessionsRoot`.
           * @returns {{ sessions: Array, series: Array<{date,in,out}> }}
           *   sessions — per session: { agent_group_id, session_id, in, out, lastActivity }
           *   series   — one bucket per day for the last `days` days (local time, newest last)
           */
          export function collectActivity(sessionsRoot, days, now) {
            const dates = [];
            for (let i = days - 1; i >= 0; i--) {
              dates.push(localDay(new Date(now.getTime() - i * 86_400_000)));
            }
            const series = new Map(dates.map((d) => [d, { date: d, in: 0, out: 0 }]));
            const sessions = [];
          
            for (const group of listDirs(sessionsRoot)) {
              for (const session of listDirs(join(sessionsRoot, group))) {
                const base = join(sessionsRoot, group, session);
                // a real session dir has at least one of the two message DBs; skip shared
                // scaffolding dirs like `.claude-shared` that don't.
                if (!existsSync(join(base, 'inbound.db')) && !existsSync(join(base, 'outbound.db'))) continue;
                const inb = readTable(join(base, 'inbound.db'), 'messages_in');
                const out = readTable(join(base, 'outbound.db'), 'messages_out');
                const lastActivity = [inb.last, out.last].filter(Boolean).sort().at(-1) ?? null;
                sessions.push({ agent_group_id: group, session_id: session, in: inb.total, out: out.total, lastActivity });
                for (const [day, n] of inb.byDay) series.get(day)?.in !== undefined && (series.get(day).in += n);
                for (const [day, n] of out.byDay) series.get(day)?.out !== undefined && (series.get(day).out += n);
              }
            }
            return { sessions, series: dates.map((d) => series.get(d)) };
          }
          
        • clidash.config.example.json 3.3 KB
          {
            "port": 4690,
            "bind": "127.0.0.1",
            "refreshSeconds": 60,
            "clis": {
              "ncl": {
                "bin": "bin/ncl",
                "cwd": "../..",
                "discover": { "args": ["help"], "parser": "ncl-help" },
                "list": ["{resource}", "list", "--json"],
                "output": "json",
                "unwrap": "data",
                "commands": {
                  "get": ["{resource}", "get", "--id", "{id}", "--json"],
                  "config-get": ["groups", "config", "get", "--id", "{id}", "--json"]
                },
                "help": ["{resource}", "help"],
                "enrich": {
                  "sessions": {
                    "agent_group_id": { "ref": "groups", "label": "name" },
                    "messaging_group_id": { "ref": "messaging-groups", "label": "name" }
                  },
                  "wirings": {
                    "agent_group_id": { "ref": "groups", "label": "name" },
                    "messaging_group_id": { "ref": "messaging-groups", "label": "name" }
                  },
                  "roles": {
                    "agent_group_id": { "ref": "groups", "label": "name" },
                    "user_id": { "ref": "users", "label": "display_name" },
                    "granted_by": { "ref": "users", "label": "display_name" }
                  },
                  "members": {
                    "agent_group_id": { "ref": "groups", "label": "name" },
                    "user_id": { "ref": "users", "label": "display_name" }
                  },
                  "destinations": {
                    "agent_group_id": { "ref": "groups", "label": "name" }
                  },
                  "user-dms": {
                    "user_id": { "ref": "users", "label": "display_name" },
                    "messaging_group_id": { "ref": "messaging-groups", "label": "name" }
                  }
                },
                "badges": {
                  "container_status": { "running": "green", "idle": "green", "starting": "amber", "stopped": "gray", "error": "red" },
                  "status": { "active": "green", "stopped": "gray", "error": "red", "pending": "amber" }
                },
                "summary": {
                  "sessions": "container_status",
                  "messaging-groups": "channel_type",
                  "roles": "role",
                  "users": "kind",
                  "destinations": "target_type",
                  "dropped-messages": "reason"
                }
              },
              "docker": {
                "bin": "docker",
                "resources": ["ps", "images"],
                "list": ["{resource}", "--format", "{{json .}}"],
                "output": "jsonlines"
              }
            },
            "activity": {
              "sessionsRoot": "../../data/v2-sessions",
              "days": 14
            },
            "logs": {
              "dir": "../../logs",
              "tailLines": 500,
              "files": [
                { "name": "nanoclaw.log", "label": "host log" },
                { "name": "nanoclaw.error.log", "label": "errors" }
              ]
            },
            "docs": {
              "root": "../..",
              "deny": ["node_modules", ".env", "*token*", "*secret*", "*.pem", "*.key", "*.lock", "pnpm-lock.yaml"],
              "collections": [
                {
                  "name": "skills",
                  "label": "Skills",
                  "lang": "markdown",
                  "patterns": ["groups/*/skills/*/SKILL.md", "container/skills/*/SKILL.md"]
                },
                {
                  "name": "claude-md",
                  "label": "CLAUDE.md",
                  "lang": "markdown",
                  "patterns": ["groups/*/CLAUDE.md", "groups/*/CLAUDE.local.md"]
                },
                {
                  "name": "profiles",
                  "label": "Profiles",
                  "lang": "json",
                  "patterns": ["groups/*/profile.json"]
                },
                {
                  "name": "conversations",
                  "label": "Conversations",
                  "lang": "markdown",
                  "patterns": ["groups/*/conversations/*.md"]
                }
              ]
            }
          }
          
        • docs.js 3.7 KB
          // Read-only file viewer for clidash.
          //
          // Surfaces on-disk documents (skills, CLAUDE.md, profile.json, conversations)
          // that are NOT ncl resources. Same security posture as the rest of clidash:
          // only files matching a configured collection's glob patterns are listable or
          // readable; a deny-list blocks secrets; path traversal is impossible because a
          // requested path must be a member of the freshly-globbed allow-set.
          
          import { readdirSync, realpathSync } from 'node:fs';
          import { join, resolve, sep } from 'node:path';
          
          // Convert one glob segment to an anchored regex. `*` matches any run of
          // non-slash chars (so it works both as a whole segment and inside a filename,
          // e.g. `CLAUDE*.md`). All other regex metacharacters are escaped.
          function segToRegExp(seg) {
            const esc = seg.replace(/[.+^${}()|[\]\\?]/g, '\\$&').replace(/\*/g, '[^/]*');
            return new RegExp('^' + esc + '$');
          }
          
          // A path is denied if any of its segments matches any deny glob.
          function isDenied(relPath, deny) {
            const segs = relPath.split('/');
            return deny.some((d) => {
              const re = segToRegExp(d);
              return segs.some((s) => re.test(s));
            });
          }
          
          // Directed walk: descend only entries matching each successive pattern segment.
          function walk(root, rel, segs, depth, out, deny) {
            if (depth >= segs.length) return;
            let entries;
            try {
              entries = readdirSync(join(root, rel), { withFileTypes: true });
            } catch {
              return;
            }
            const re = segToRegExp(segs[depth]);
            const last = depth === segs.length - 1;
            for (const e of entries) {
              if (e.name === '.' || e.name === '..') continue;
              if (!re.test(e.name)) continue;
              const childRel = rel ? `${rel}/${e.name}` : e.name;
              if (isDenied(childRel, deny)) continue;
              if (last) {
                if (e.isFile()) out.add(childRel);
              } else if (e.isDirectory()) {
                walk(root, childRel, segs, depth + 1, out, deny);
              }
            }
          }
          
          /**
           * Relative paths under `root` matching any of `patterns`, minus `deny` matches.
           * Sorted, de-duplicated. Patterns use `*` per the segment rules above; no `**`.
           */
          export function globFiles(root, patterns, deny = []) {
            const out = new Set();
            for (const pattern of patterns) {
              walk(root, '', pattern.split('/'), 0, out, deny);
            }
            return [...out].sort();
          }
          
          /**
           * Human-friendly grouping/label for a relative path.
           * `groups/<g>/...` → group `<g>`; `container/...` → group `shared`.
           */
          const CONTAINER_SEGS = new Set(['skills', 'conversations']); // redundant grouping dirs
          export function describeFile(relPath) {
            const parts = relPath.split('/');
            if (parts[0] === 'groups' && parts.length > 2) {
              const rest = parts.slice(2).filter((s) => !CONTAINER_SEGS.has(s)).join('/');
              return { group: parts[1], label: `${parts[1]} / ${rest}` };
            }
            if (parts[0] === 'container') {
              const rest = parts.slice(2).filter((s) => !CONTAINER_SEGS.has(s)).join('/');
              return { group: 'shared', label: `shared / ${rest}` };
            }
            return { group: '', label: relPath };
          }
          
          /**
           * Validate a requested doc path against a collection and return its absolute
           * path, or throw. A path is allowed only if it is a member of the collection's
           * freshly-globbed allow-set — this single check enforces the patterns, the
           * deny-list, and traversal safety at once.
           */
          export function resolveDoc(root, collection, relPath, deny = []) {
            const allowed = new Set(globFiles(root, collection.patterns, deny));
            if (!allowed.has(relPath)) {
              throw new Error(`Path not allowed: ${relPath}`);
            }
            // Defence in depth: the resolved real path must still live under root.
            const abs = resolve(root, relPath);
            const rootReal = realpathSync(root);
            const absReal = realpathSync(abs);
            if (absReal !== rootReal && !absReal.startsWith(rootReal + sep)) {
              throw new Error(`Path not allowed: ${relPath}`);
            }
            return abs;
          }
          
        • logs.js 667 B
          // Log tailing for clidash — reads the last N lines of an allowlisted log file
          // and strips ANSI color codes (the host logger writes colored output).
          
          import { readFile } from 'node:fs/promises';
          
          const ANSI_RE = /\x1b\[[0-9;]*m/g;
          
          /**
           * Last `maxLines` lines of a log file, ANSI-stripped.
           * @returns {{ lines: string[], text: string }}
           */
          export async function tailFile(path, maxLines) {
            const raw = (await readFile(path, 'utf8')).replace(ANSI_RE, '');
            const all = raw.split('\n');
            if (all.length && all.at(-1) === '') all.pop(); // drop trailing newline's empty field
            const lines = all.slice(-maxLines);
            return { lines, text: lines.join('\n') };
          }
          
        • package.json 347 B
          {
            "name": "clidash",
            "version": "0.1.0",
            "description": "CLI-agnostic read-only web dashboard — derives tabs and tables from any CLI that lists resources as JSON",
            "type": "module",
            "private": true,
            "scripts": {
              "start": "node server.js",
              "test": "node --test 'test/*.test.js'"
            },
            "engines": {
              "node": ">=22.5"
            }
          }
          
        • parsers.js 3.3 KB
          // Pluggable parsers for clidash.
          //
          // discoveryParsers — turn a CLI's "help"-style output into a resource list.
          // parseOutput / unwrapPath — turn a CLI's list output into rows.
          // All per-CLI knowledge beyond these small functions lives in clidash.config.json.
          
          /**
           * Discovery parsers, keyed by the `discover.parser` name in config.
           * Each receives the raw discovery output and returns
           * [{ name, description, verbs }] for resources that support `list`.
           * They must throw loudly on unrecognized formats — silent empty results
           * would render as silently-stale tabs.
           */
          export const discoveryParsers = {
            /**
             * Parses ncl's two-column help format:
             *
             *   Resources:
             *     sessions             Session — the runtime unit. ...
             *                          verbs: list, get
             *   Commands:
             *     help                 ...
             */
            'ncl-help'(text) {
              const lines = String(text).split('\n');
              const start = lines.findIndex((l) => l.trim() === 'Resources:');
              if (start === -1) {
                throw new Error('ncl-help parser: no "Resources:" section in output — format may have changed');
              }
              const resources = [];
              let current = null;
              for (let i = start + 1; i < lines.length; i++) {
                const line = lines[i];
                if (line.trim() === '') continue;
                if (/^\S/.test(line)) break; // next top-level section, e.g. "Commands:"
                const verbsMatch = line.match(/^\s+verbs:\s*(.+)$/);
                if (verbsMatch && current) {
                  current.verbs = verbsMatch[1].split(',').map((v) => v.trim()).filter(Boolean);
                  continue;
                }
                const resMatch = line.match(/^  (\S+)\s{2,}(\S.*)$/);
                if (resMatch) {
                  current = { name: resMatch[1], description: resMatch[2].trim(), verbs: [] };
                  resources.push(current);
                }
              }
              return resources.filter((r) => r.verbs.includes('list'));
            },
          };
          
          /**
           * Parses a CLI's list output per the config's `output` field.
           * - 'json'      — one JSON document.
           * - 'jsonlines' — one JSON object per line (docker/kubectl style).
           * Thrown errors carry the raw output on `err.raw` so the UI can show it.
           */
          export function parseOutput(text, format) {
            if (format === 'json') {
              try {
                return JSON.parse(text);
              } catch (e) {
                const err = new Error(`Invalid JSON output: ${e.message}`);
                err.raw = text;
                throw err;
              }
            }
            if (format === 'jsonlines') {
              const rows = [];
              const lines = String(text).split('\n');
              for (let i = 0; i < lines.length; i++) {
                const line = lines[i].trim();
                if (!line) continue;
                try {
                  rows.push(JSON.parse(line));
                } catch (e) {
                  const err = new Error(`Invalid JSON on line ${i + 1}: ${e.message}`);
                  err.raw = text;
                  throw err;
                }
              }
              return rows;
            }
            throw new Error(`Unknown output format: ${format}`);
          }
          
          /**
           * Follows a dot-path into a response envelope (e.g. 'data' for ncl's
           * {id, ok, data} frame). No path → value passes through unchanged.
           * Missing path throws — a changed envelope must fail loudly.
           */
          export function unwrapPath(value, path) {
            if (!path) return value;
            let cur = value;
            for (const key of path.split('.')) {
              if (cur === null || typeof cur !== 'object' || !(key in cur)) {
                throw new Error(`Unwrap path "${path}" not found in CLI output (missing "${key}")`);
              }
              cur = cur[key];
            }
            return cur;
          }
          
        • README.md 4 KB
          # clidash
          
          CLI-agnostic **read-only** web dashboard. Point it at any CLI that can list
          resources as JSON and it derives the dashboard at runtime: one tab per
          resource, a generic table over whatever columns the rows have. New resource →
          new tab; new column → new table column; **zero code changes**.
          
          It ships pre-wired for NanoClaw's `ncl` CLI (agent groups, sessions, messaging
          groups, wirings, users, roles, …) plus `docker`, but the same config shape
          works for any list-as-JSON CLI.
          
          - **Zero dependencies** — Node built-ins only (Node ≥ 22.5, for `node:sqlite`),
            no build step,
            vanilla-JS frontend.
          - **Read-only by construction** — the server can only `execFile` the configured
            argv templates; `{resource}` is the sole substitution and is validated
            against the discovered/static resource allowlist. Never a shell.
          - **Standalone** — no imports from NanoClaw source; the core is extractable to
            its own repo. The NanoClaw-specific knowledge lives entirely in the config
            and in the `views/ncl-overview.js` view plugin.
          
          ## Run
          
          ```bash
          cp clidash.config.example.json clidash.config.json   # then edit paths if needed
          node server.js                                        # uses ./clidash.config.json
          CLIDASH_CONFIG=/path/to.json node server.js
          PORT=4690 BIND=127.0.0.1 node server.js               # env overrides
          ```
          
          Run it from `tools/clidash/`; the example config uses paths relative to the
          NanoClaw root two levels up, so it works out of the box once `ncl` is built.
          
          ## Configure (`clidash.config.json`)
          
          ```jsonc
          {
            "port": 4690,
            "bind": "127.0.0.1",          // never a public interface; a tailnet IP at most
            "refreshSeconds": 60,
            "clis": {
              "ncl": {
                "bin": "bin/ncl",                                        // relative to cwd below
                "cwd": "../..",                                           // the NanoClaw root
                "discover": { "args": ["help"], "parser": "ncl-help" },   // runtime resource discovery
                "list": ["{resource}", "list", "--json"],                 // argv template
                "output": "json",          // or "jsonlines" (docker/kubectl style)
                "unwrap": "data"           // dot-path into a response envelope
              },
              "docker": {
                "bin": "docker",
                "resources": ["ps", "images"],          // static alternative to discover
                "list": ["{resource}", "--format", "{{json .}}"],
                "output": "jsonlines"
              }
            }
          }
          ```
          
          `{resource}` may appear as a whole argv element or inside one — e.g. a remote
          CLI via ssh: `"list": ["-i", "key.pem", "user@host", "ncl {resource} list --json"]`.
          
          Per-CLI `env` (merged over the server's env) and `cwd` are supported. See
          `clidash.config.example.json` for the full NanoClaw config, including the
          `enrich`/`badges`/`summary` table decorations and the `activity`/`logs`/`docs`
          sections.
          
          ## API
          
          | Route | Returns |
          |---|---|
          | `GET /api/clis` | configured CLIs + discovered/static resources (discovery cached 60s) |
          | `GET /api/r/<cli>/<resource>` | `{ok, rows, fetchedAt}` — coalesced, 10s exec timeout |
          | `GET /api/view/<cli>/<view>` | curated view plugin from `views/<cli>-<view>.js` |
          
          View plugins are the only per-CLI *code*, and optional: a default-exported
          async function receiving `{ fetch }` (bound to that CLI) returning JSON.
          `views/ncl-overview.js` joins groups + sessions + messaging-groups + wirings
          into per-agent status cards (green <15m / amber <2h / red older).
          
          ## Test
          
          ```bash
          npm test            # unit + integration (node:test, stub CLI — no real CLI needed)
          ./test/smoke.sh     # against a running instance
          ```
          
          ## Deploy as a service
          
          clidash binds `127.0.0.1` by default. To reach it from other devices, bind a
          private (e.g. tailnet) IP — **never a public interface**; the network is the
          auth boundary. Example systemd user service:
          
          ```ini
          # ~/.config/systemd/user/clidash.service
          [Unit]
          Description=clidash read-only CLI dashboard
          
          [Service]
          WorkingDirectory=%h/nanoclaw/tools/clidash
          ExecStart=/usr/bin/node %h/nanoclaw/tools/clidash/server.js
          Environment=BIND=127.0.0.1
          Restart=on-failure
          
          [Install]
          WantedBy=default.target
          ```
          
          Then `systemctl --user enable --now clidash`.
          
        • server.js 17.2 KB
          // clidash — CLI-agnostic read-only web dashboard.
          // Node built-ins only. All per-CLI knowledge lives in clidash.config.json;
          // the only per-CLI code is optional view plugins (views/) and discovery
          // parsers (parsers.js).
          //
          // Security model: the server can only exec the configured argv templates.
          // `{resource}` is the sole substitution and is validated against the
          // discovered/static resource set before exec. execFile, never a shell.
          
          import { createServer } from 'node:http';
          import { execFile } from 'node:child_process';
          import { readFile, readdir } from 'node:fs/promises';
          import { readFileSync } from 'node:fs';
          import { dirname, join, resolve, sep, basename } from 'node:path';
          import { fileURLToPath, pathToFileURL } from 'node:url';
          import { discoveryParsers, parseOutput, unwrapPath } from './parsers.js';
          import { globFiles, describeFile, resolveDoc } from './docs.js';
          import { collectActivity } from './activity.js';
          import { tailFile } from './logs.js';
          
          const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
          const MAX_DOC_BYTES = 2 * 1024 * 1024; // cap a single served document at 2 MB
          
          const DEFAULTS = {
            bind: '127.0.0.1',
            port: 4690,
            refreshSeconds: 60,
            execTimeoutMs: 10_000,
            discoveryTtlMs: 60_000,
          };
          
          const CONTENT_TYPES = {
            '.html': 'text/html; charset=utf-8',
            '.js': 'text/javascript; charset=utf-8',
            '.css': 'text/css; charset=utf-8',
            '.json': 'application/json; charset=utf-8',
            '.svg': 'image/svg+xml',
            '.png': 'image/png',
            '.ico': 'image/x-icon',
            '.webmanifest': 'application/manifest+json',
          };
          
          export function createApp(userConfig) {
            const config = { ...DEFAULTS, ...userConfig };
            const publicDir = resolve(config.publicDir ?? join(MODULE_DIR, 'public'));
            const viewsDir = resolve(config.viewsDir ?? join(MODULE_DIR, 'views'));
          
            // Human-readable form of a command, for display in the UI ("the command run").
            const displayCmd = (bin, args) => `${basename(bin)} ${args.join(' ')}`;
          
            // ---- exec --------------------------------------------------------------
          
            function execCli(cliCfg, args, label) {
              return new Promise((resolvePromise, rejectPromise) => {
                execFile(cliCfg.bin, args, {
                  cwd: cliCfg.cwd,
                  timeout: config.execTimeoutMs,
                  maxBuffer: 32 * 1024 * 1024,
                  env: { ...process.env, ...cliCfg.env },
                }, (error, stdout, stderr) => {
                  if (error) {
                    const timedOut = error.killed || error.signal === 'SIGTERM';
                    const detail = stderr.trim() || error.message;
                    const msg = timedOut
                      ? `${label} timed out after ${config.execTimeoutMs}ms`
                      : `${label} failed: ${detail}`;
                    rejectPromise(new Error(msg));
                    return;
                  }
                  resolvePromise(stdout);
                });
              });
            }
          
            // ---- resource discovery (cached, coalesced, keeps last good) -----------
          
            const discoveryCache = new Map(); // cli -> { at, resources }
            const discoveryInflight = new Map(); // cli -> Promise
          
            async function discoverResources(cliName) {
              const cliCfg = config.clis[cliName];
              if (cliCfg.resources) {
                return cliCfg.resources.map((name) =>
                  typeof name === 'string' ? { name, description: '' } : name,
                );
              }
              const cached = discoveryCache.get(cliName);
              if (cached && Date.now() - cached.at < config.discoveryTtlMs) return cached.resources;
              if (discoveryInflight.has(cliName)) return discoveryInflight.get(cliName);
          
              const parser = discoveryParsers[cliCfg.discover.parser];
              if (!parser) throw new Error(`Unknown discovery parser: ${cliCfg.discover.parser}`);
              const promise = execCli(cliCfg, cliCfg.discover.args, `${cliName} discovery`)
                .then((stdout) => {
                  const resources = parser(stdout);
                  discoveryCache.set(cliName, { at: Date.now(), resources });
                  return resources;
                })
                .finally(() => discoveryInflight.delete(cliName));
              discoveryInflight.set(cliName, promise);
              return promise;
            }
          
            // ---- row fetching (coalesced per cli+resource) --------------------------
          
            const listInflight = new Map(); // "cli\0resource" -> Promise
          
            async function fetchRows(cliName, resourceName) {
              const cliCfg = config.clis[cliName];
              const resources = await discoverResources(cliName);
              if (!resources.some((r) => r.name === resourceName)) {
                const err = new Error(`Unknown resource "${resourceName}" for CLI "${cliName}"`);
                err.statusCode = 404;
                throw err;
              }
              const key = `${cliName}\0${resourceName}`;
              if (listInflight.has(key)) return listInflight.get(key);
          
              // {resource} may appear as a whole arg or inside one (e.g. an ssh remote
              // command). Safe either way — the value is allowlist-validated above.
              const args = cliCfg.list.map((a) => a.replaceAll('{resource}', resourceName));
              const promise = execCli(cliCfg, args, `${cliName} ${resourceName} list`)
                .then((stdout) => {
                  const parsed = parseOutput(stdout, cliCfg.output ?? 'json');
                  const rows = unwrapPath(parsed, cliCfg.unwrap);
                  if (!Array.isArray(rows)) {
                    const err = new Error(`${cliName} ${resourceName}: expected an array of rows`);
                    err.raw = stdout;
                    throw err;
                  }
                  return rows;
                })
                .finally(() => listInflight.delete(key));
              listInflight.set(key, promise);
              return promise;
            }
          
            // ---- detail commands (drill-down: get, config-get, …) -------------------
          
            const cmdInflight = new Map();
            const ID_RE = /^[A-Za-z0-9:_.-]+$/; // ncl ids / uuids; no shell metas (and execFile never shells)
          
            async function runCommand(cliName, cmdName, resourceName, id) {
              const cliCfg = config.clis[cliName];
              const template = cliCfg.commands?.[cmdName];
              if (!template) {
                const err = new Error(`Unknown command "${cmdName}"`);
                err.statusCode = 404;
                throw err;
              }
              const needsResource = template.includes('{resource}');
              if (needsResource) {
                const resources = await discoverResources(cliName);
                if (!resources.some((r) => r.name === resourceName)) {
                  const err = new Error(`Unknown resource "${resourceName}"`);
                  err.statusCode = 404;
                  throw err;
                }
              }
              if (template.includes('{id}') && !ID_RE.test(id ?? '')) {
                const err = new Error('Invalid id');
                err.statusCode = 400;
                throw err;
              }
              const key = `${cliName}\0${cmdName}\0${resourceName}\0${id}`;
              if (cmdInflight.has(key)) return cmdInflight.get(key);
              const args = template.map((a) => a.replaceAll('{resource}', resourceName ?? '').replaceAll('{id}', id ?? ''));
              const promise = execCli(cliCfg, args, `${cliName} ${cmdName}`)
                .then((stdout) => unwrapPath(parseOutput(stdout, cliCfg.output ?? 'json'), cliCfg.unwrap))
                .finally(() => cmdInflight.delete(key));
              cmdInflight.set(key, promise);
              return promise;
            }
          
            // ---- per-resource help (raw text from `<cli> <resource> help`) -----------
          
            const helpInflight = new Map();
            async function runHelp(cliName, resourceName) {
              const cliCfg = config.clis[cliName];
              if (!cliCfg.help) { const e = new Error(`No help for "${cliName}"`); e.statusCode = 404; throw e; }
              const resources = await discoverResources(cliName);
              if (!resources.some((r) => r.name === resourceName)) {
                const e = new Error(`Unknown resource "${resourceName}"`); e.statusCode = 404; throw e;
              }
              const key = `${cliName}\0${resourceName}`;
              if (helpInflight.has(key)) return helpInflight.get(key);
              const args = cliCfg.help.map((a) => a.replaceAll('{resource}', resourceName));
              const promise = execCli(cliCfg, args, `${cliName} ${resourceName} help`).finally(() => helpInflight.delete(key));
              helpInflight.set(key, promise);
              return promise;
            }
          
            // ---- view plugins --------------------------------------------------------
          
            async function listViews(cliName) {
              try {
                const files = await readdir(viewsDir);
                return files
                  .filter((f) => f.startsWith(`${cliName}-`) && f.endsWith('.js'))
                  .map((f) => f.slice(cliName.length + 1, -3));
              } catch {
                return [];
              }
            }
          
            async function runView(cliName, viewName) {
              if (!/^[a-zA-Z0-9_-]+$/.test(viewName)) {
                const err = new Error(`Invalid view name`);
                err.statusCode = 404;
                throw err;
              }
              const file = join(viewsDir, `${cliName}-${viewName}.js`);
              let mod;
              try {
                mod = await import(pathToFileURL(file).href);
              } catch (e) {
                if (e.code === 'ERR_MODULE_NOT_FOUND') {
                  const err = new Error(`No view "${viewName}" for CLI "${cliName}"`);
                  err.statusCode = 404;
                  throw err;
                }
                throw e;
              }
              return mod.default({ fetch: (resource) => fetchRows(cliName, resource) });
            }
          
            // ---- http ----------------------------------------------------------------
          
            function sendJson(res, status, body) {
              const payload = JSON.stringify(body);
              res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
              res.end(payload);
            }
          
            function sendError(res, err) {
              const status = err.statusCode ?? 502;
              const body = { ok: false, error: err.message };
              if (err.raw !== undefined) body.raw = String(err.raw).slice(0, 64 * 1024);
              sendJson(res, status, body);
            }
          
            async function serveStatic(res, urlPath) {
              const relative = urlPath === '/' ? 'index.html' : decodeURIComponent(urlPath.slice(1));
              const file = resolve(publicDir, relative);
              if (file !== publicDir && !file.startsWith(publicDir + sep)) {
                sendJson(res, 403, { ok: false, error: 'Forbidden' });
                return;
              }
              try {
                const content = await readFile(file);
                const ext = file.slice(file.lastIndexOf('.'));
                // always revalidate so a redeploy is picked up immediately (no stale JS/CSS)
                res.writeHead(200, { 'Content-Type': CONTENT_TYPES[ext] ?? 'application/octet-stream', 'Cache-Control': 'no-cache' });
                res.end(content);
              } catch {
                sendJson(res, 404, { ok: false, error: 'Not found' });
              }
            }
          
            return createServer(async (req, res) => {
              try {
                if (req.method !== 'GET') {
                  sendJson(res, 405, { ok: false, error: 'Read-only dashboard: GET only' });
                  return;
                }
                const urlPath = req.url.split('?')[0];
                const segments = urlPath.split('/').map((s) => decodeURIComponent(s));
          
                if (urlPath === '/api/clis') {
                  const clis = await Promise.all(Object.keys(config.clis).map(async (name) => {
                    const entry = {
                      name,
                      refreshSeconds: config.refreshSeconds,
                      views: await listViews(name),
                      commands: Object.keys(config.clis[name].commands ?? {}),
                      enrich: config.clis[name].enrich ?? null,
                      badges: config.clis[name].badges ?? null,
                      summary: config.clis[name].summary ?? null,
                      help: !!config.clis[name].help,
                    };
                    try {
                      entry.resources = await discoverResources(name);
                    } catch (e) {
                      // keep last good discovery (≤TTL old) if we have one; always surface the error
                      entry.resources = discoveryCache.get(name)?.resources ?? [];
                      entry.error = e.message;
                    }
                    return entry;
                  }));
                  sendJson(res, 200, { clis });
                  return;
                }
          
                if (segments[1] === 'api' && segments[2] === 'r' && segments.length === 5) {
                  const [, , , cliName, resourceName] = segments;
                  if (!config.clis[cliName]) {
                    sendJson(res, 404, { ok: false, error: `Unknown CLI "${cliName}"` });
                    return;
                  }
                  const rows = await fetchRows(cliName, resourceName);
                  const cliCfg = config.clis[cliName];
                  const command = displayCmd(cliCfg.bin, cliCfg.list.map((a) => a.replaceAll('{resource}', resourceName)));
                  sendJson(res, 200, { ok: true, rows, command, fetchedAt: new Date().toISOString() });
                  return;
                }
          
                if (segments[1] === 'api' && segments[2] === 'cmd' && segments.length === 5) {
                  const [, , , cliName, cmdName] = segments;
                  if (!config.clis[cliName]) {
                    sendJson(res, 404, { ok: false, error: `Unknown CLI "${cliName}"` });
                    return;
                  }
                  const q = new URL(req.url, 'http://localhost').searchParams;
                  const data = await runCommand(cliName, cmdName, q.get('resource'), q.get('id'));
                  const tmpl = config.clis[cliName].commands?.[cmdName] ?? [];
                  const command = displayCmd(config.clis[cliName].bin,
                    tmpl.map((a) => a.replaceAll('{resource}', q.get('resource') ?? '').replaceAll('{id}', q.get('id') ?? '')));
                  sendJson(res, 200, { ok: true, data, command, fetchedAt: new Date().toISOString() });
                  return;
                }
          
                if (segments[1] === 'api' && segments[2] === 'help' && segments.length === 5) {
                  const [, , , cliName, resourceName] = segments;
                  if (!config.clis[cliName]) {
                    sendJson(res, 404, { ok: false, error: `Unknown CLI "${cliName}"` });
                    return;
                  }
                  const text = await runHelp(cliName, resourceName);
                  sendJson(res, 200, { ok: true, text });
                  return;
                }
          
                if (segments[1] === 'api' && segments[2] === 'view' && segments.length === 5) {
                  const [, , , cliName, viewName] = segments;
                  if (!config.clis[cliName]) {
                    sendJson(res, 404, { ok: false, error: `Unknown CLI "${cliName}"` });
                    return;
                  }
                  const result = await runView(cliName, viewName);
                  sendJson(res, 200, { ok: true, result, fetchedAt: new Date().toISOString() });
                  return;
                }
          
                // Log tails (allowlisted files under logs.dir).
                if (urlPath === '/api/logs') {
                  sendJson(res, 200, { files: (config.logs?.files ?? []).map((f) => ({ name: f.name, label: f.label ?? f.name })) });
                  return;
                }
                if (segments[1] === 'api' && segments[2] === 'log' && segments.length === 4) {
                  const name = segments[3];
                  const file = config.logs?.files?.find((f) => f.name === name);
                  if (!file) { sendJson(res, 404, { ok: false, error: `Unknown log "${name}"` }); return; }
                  const lines = config.logs.tailLines ?? 400;
                  const { text } = await tailFile(join(config.logs.dir, name), lines);
                  sendJson(res, 200, { ok: true, text, command: `tail -n ${lines} ${join(config.logs.dir, name)}`, fetchedAt: new Date().toISOString() });
                  return;
                }
          
                // Message activity (read per-session DBs; ncl has no messages resource).
                if (urlPath === '/api/activity') {
                  if (!config.activity) { sendJson(res, 200, { ok: true, configured: false }); return; }
                  const days = config.activity.days ?? 14;
                  const { sessions, series } = collectActivity(config.activity.sessionsRoot, days, new Date());
                  const command = `node:sqlite · ${config.activity.sessionsRoot}/*/*/{inbound,outbound}.db (last ${days}d)`;
                  sendJson(res, 200, { ok: true, configured: true, sessions, series, command, fetchedAt: new Date().toISOString() });
                  return;
                }
          
                // Read-only file viewer (skills, CLAUDE.md, profiles, conversations).
                if (urlPath === '/api/docs') {
                  const docs = config.docs;
                  const collections = (docs?.collections ?? []).map((coll) => ({
                    name: coll.name,
                    label: coll.label ?? coll.name,
                    lang: coll.lang ?? 'text',
                    files: globFiles(docs.root, coll.patterns, docs.deny ?? []).map((path) => ({
                      path,
                      ...describeFile(path),
                    })),
                  }));
                  sendJson(res, 200, { collections });
                  return;
                }
          
                if (urlPath === '/api/doc') {
                  const docs = config.docs;
                  const query = new URL(req.url, 'http://localhost').searchParams;
                  const collName = query.get('c');
                  const relPath = query.get('p') ?? '';
                  const collection = docs?.collections?.find((c) => c.name === collName);
                  if (!collection) {
                    sendJson(res, 404, { ok: false, error: `Unknown collection "${collName}"` });
                    return;
                  }
                  let abs;
                  try {
                    abs = resolveDoc(docs.root, collection, relPath, docs.deny ?? []);
                  } catch {
                    sendJson(res, 404, { ok: false, error: 'Not found' });
                    return;
                  }
                  const content = await readFile(abs, 'utf8');
                  sendJson(res, 200, {
                    ok: true,
                    path: relPath,
                    lang: collection.lang ?? 'text',
                    content: content.length > MAX_DOC_BYTES ? content.slice(0, MAX_DOC_BYTES) : content,
                  });
                  return;
                }
          
                if (urlPath.startsWith('/api/')) {
                  sendJson(res, 404, { ok: false, error: 'Not found' });
                  return;
                }
          
                await serveStatic(res, urlPath);
              } catch (err) {
                sendError(res, err);
              }
            });
          }
          
          // ---- standalone entry point ------------------------------------------------
          
          const isMain = process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href;
          if (isMain) {
            const configPath = process.env.CLIDASH_CONFIG ?? join(MODULE_DIR, 'clidash.config.json');
            const config = JSON.parse(readFileSync(configPath, 'utf8'));
            if (process.env.PORT) config.port = Number(process.env.PORT);
            if (process.env.BIND) config.bind = process.env.BIND;
            const finalConfig = { ...DEFAULTS, ...config };
            const server = createApp(finalConfig);
            server.listen(finalConfig.port, finalConfig.bind, () => {
              console.log(`clidash listening on http://${finalConfig.bind}:${finalConfig.port}`);
            });
          }
          
  • REMOVE.md 544 B
    # Remove /add-clidash
    
    clidash is fully self-contained, so removal is a single directory delete. It
    made no edits to NanoClaw `src/`, added no dependency, and wired into nothing.
    
    ```bash
    # Stop the service first if you set one up:
    systemctl --user disable --now clidash 2>/dev/null || true
    rm -f ~/.config/systemd/user/clidash.service
    
    # Remove the tool:
    rm -rf tools/clidash
    ```
    
    If you added the config to `.gitignore` in step 2 of the install, remove that
    line too:
    
    ```
    tools/clidash/clidash.config.json
    ```
    
    Nothing else needs reverting.
    
  • SKILL.md 6.3 KB
    ---
    name: add-clidash
    description: Add clidash — a zero-dependency, read-only web dashboard that derives its tabs and tables at runtime from any CLI that lists resources as JSON. Ships pre-wired for NanoClaw's ncl CLI (agent groups, sessions, channels, users, roles), plus message-activity charts, a log tail, and a read-only file viewer for group skills/CLAUDE.md/profiles.
    ---
    
    # /add-clidash — CLI-derived read-only dashboard
    
    clidash is a small, read-only web dashboard. You point it at any CLI that can
    list resources as JSON (NanoClaw's `ncl`, `docker`, `kubectl`, …) and it builds
    the dashboard at runtime: one tab per resource, a generic table over whatever
    columns the rows have. A new `ncl` resource becomes a new tab and a new column
    becomes a new table column with **zero code changes**.
    
    It ships pre-wired for NanoClaw's `ncl` CLI and adds three NanoClaw-aware
    panels driven entirely by config:
    
    - **Agents overview** — status cards joining groups + sessions + messaging
      groups + wirings (green <15m / amber <2h / red older).
    - **Activity** — per-session inbound/outbound message totals and a daily series,
      read directly from the session DBs (`ncl` has no messages resource).
    - **Logs** — last N lines of allowlisted host log files.
    - **Files** — a read-only viewer for group skills, `CLAUDE.md`, and profiles.
    
    ## Why it's safe
    
    clidash is **read-only by construction**: the server can only `execFile` the
    argv templates in its config. `{resource}` is the sole substitution and is
    allowlist-validated against the discovered/static resource set before exec —
    never a shell, no free-form input reaches argv. There is no auth; **the network
    is the auth boundary** — it binds `127.0.0.1` by default. Only ever bind a
    private interface (e.g. a tailnet IP), never a public one.
    
    It's distinct from `/add-dashboard` (which pushes JSON snapshots to a separate
    `@nanoco/nanoclaw-dashboard` npm package): clidash has **zero dependencies**, no
    build step, no push pipeline, and no edits to NanoClaw source — it just reads
    `ncl` and the session DBs.
    
    ## Steps
    
    ### 1. Copy the tool into place
    
    clidash is fully self-contained — copy the whole directory in:
    
    `tools/` is not a standard NanoClaw directory and `cp -R` won't create it, so
    make it first:
    
    ```bash
    mkdir -p tools
    cp -R .claude/skills/add-clidash/add/tools/clidash tools/clidash
    ```
    
    That is the only file change this skill makes. Nothing in NanoClaw `src/` is
    touched, no dependency is added.
    
    ### 2. Create the config
    
    The example config is pre-wired for NanoClaw with paths relative to the repo
    root, so it works as-is when you run clidash from `tools/clidash/`:
    
    ```bash
    cd tools/clidash
    cp clidash.config.example.json clidash.config.json
    ```
    
    `clidash.config.json` is your local config — add it to `.gitignore` if you
    don't want to commit install-specific paths:
    
    ```bash
    echo 'tools/clidash/clidash.config.json' >> ../../.gitignore
    ```
    
    The example assumes `ncl` is built at `bin/ncl`. If `bin/ncl` doesn't exist,
    build it first (`pnpm run build`) or point `clis.ncl.bin` at the right path.
    
    ### 3. Test
    
    Tests use a stub CLI — no real `ncl` or `docker` needed:
    
    ```bash
    npm test
    ```
    
    All tests should pass (Node ≥ 22.5, `node:test`, zero dependencies).
    
    ### 4. Run and verify
    
    ```bash
    node server.js          # serves http://127.0.0.1:4690
    ```
    
    In another shell, confirm it's live and that `ncl` discovery worked:
    
    ```bash
    curl -s http://127.0.0.1:4690/api/clis | head -c 400      # CLIs + discovered resources
    curl -s http://127.0.0.1:4690/api/r/ncl/groups | head -c 400   # a real resource table
    ```
    
    Then open `http://127.0.0.1:4690/` in a browser. You should see the Agents
    overview plus a tab per `ncl` resource.
    
    ### 5. (Optional) Run as a service
    
    clidash binds `127.0.0.1` by default. To reach it from other devices, bind a
    private (e.g. tailnet) IP via the `BIND` env var or `bind` in config — never a
    public interface.
    
    ```ini
    # ~/.config/systemd/user/clidash.service   (Linux)
    [Unit]
    Description=clidash read-only CLI dashboard
    
    [Service]
    WorkingDirectory=%h/nanoclaw/tools/clidash
    ExecStart=/usr/bin/node %h/nanoclaw/tools/clidash/server.js
    Environment=BIND=127.0.0.1
    Restart=on-failure
    
    [Install]
    WantedBy=default.target
    ```
    
    ```bash
    systemctl --user enable --now clidash
    ```
    
    On macOS, wrap `node server.js` (with `WorkingDirectory` = `tools/clidash`) in a
    launchd plist the same way the main NanoClaw service is configured.
    
    ## Configuration reference
    
    `clidash.config.json` keys (see `tools/clidash/README.md` and
    `clidash.config.example.json` for the full shape):
    
    | Key | Purpose |
    |-----|---------|
    | `port`, `bind`, `refreshSeconds` | server bind + UI auto-refresh cadence |
    | `clis.<name>.bin` / `cwd` / `env` | how to invoke the CLI (`bin` is relative to `cwd`) |
    | `clis.<name>.discover` or `resources` | runtime discovery (`ncl help`) vs a static resource list |
    | `clis.<name>.list` | argv template; `{resource}` is the only substitution |
    | `clis.<name>.output` | `json` or `jsonlines` (docker/kubectl style) |
    | `clis.<name>.unwrap` | dot-path into a response envelope (e.g. `data`) |
    | `clis.<name>.enrich`/`badges`/`summary` | table decorations (ID→name joins, status colors, summary cards) |
    | `activity` | `sessionsRoot` + `days` for the message-activity charts |
    | `logs` | `dir`, `tailLines`, and an allowlist of `files` to tail |
    | `docs` | file viewer: `root`, a `deny` glob list, and `collections` of glob patterns |
    
    Adding a second CLI is config-only — e.g. `docker` is included as a `jsonlines`
    example. View plugins (`views/<cli>-<view>.js`) are the only per-CLI code and
    are optional.
    
    ## Troubleshooting
    
    - **`ENOENT` / config not found** — run from `tools/clidash/` and make sure you
      copied `clidash.config.example.json` to `clidash.config.json` (step 2), or set
      `CLIDASH_CONFIG=/abs/path.json`.
    - **No `ncl` resources / discovery empty** — `bin/ncl` isn't built or the path
      is wrong. Build it (`pnpm run build`) or fix `clis.ncl.bin`.
    - **docker tab errors** — the docker daemon isn't running, or remove the
      `docker` CLI from config if you don't need it.
    - **Can't reach it from another device** — it binds `127.0.0.1`; set
      `BIND=<private-ip>` (tailnet), never a public interface.
    - **Empty Activity/Logs/Files** — check that `activity.sessionsRoot`,
      `logs.dir`, and `docs.root` resolve to your NanoClaw root (relative to where
      you launch `node server.js`).
    
    ## Removal
    
    See [REMOVE.md](REMOVE.md).
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related