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
Install
npx skills add https://github.com/nanocoai/nanoclaw/tree/main/.claude/skills/add-clidash
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install nanocoai-nanoclaw@llmmart
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 (
nclhas 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 fromtools/clidash/and make sure you copiedclidash.config.example.jsontoclidash.config.json(step 2), or setCLIDASH_CONFIG=/abs/path.json.- No
nclresources / discovery empty —bin/nclisn't built or the path is wrong. Build it (pnpm run build) or fixclis.ncl.bin. - docker tab errors — the docker daemon isn't running, or remove the
dockerCLI from config if you don't need it. - Can't reach it from another device — it binds
127.0.0.1; setBIND=<private-ip>(tailnet), never a public interface. - Empty Activity/Logs/Files — check that
activity.sessionsRoot,logs.dir, anddocs.rootresolve to your NanoClaw root (relative to where you launchnode 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) => ( { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>"&'`), '<script>"&''); }); 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('<script>')); }); // ---- 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 " 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 " (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"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.
Reviews (0)
No reviews yet.
No comments yet.