diff --git a/.claude/skills/add-clidash/REMOVE.md b/.claude/skills/add-clidash/REMOVE.md new file mode 100644 index 000000000..9a571da9d --- /dev/null +++ b/.claude/skills/add-clidash/REMOVE.md @@ -0,0 +1,22 @@ +# 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. diff --git a/.claude/skills/add-clidash/SKILL.md b/.claude/skills/add-clidash/SKILL.md new file mode 100644 index 000000000..93214fa9e --- /dev/null +++ b/.claude/skills/add-clidash/SKILL.md @@ -0,0 +1,168 @@ +--- +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..bin` / `cwd` / `env` | how to invoke the CLI (`bin` is relative to `cwd`) | +| `clis..discover` or `resources` | runtime discovery (`ncl help`) vs a static resource list | +| `clis..list` | argv template; `{resource}` is the only substitution | +| `clis..output` | `json` or `jsonlines` (docker/kubectl style) | +| `clis..unwrap` | dot-path into a response envelope (e.g. `data`) | +| `clis..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/-.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=` (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). diff --git a/.claude/skills/add-clidash/add/tools/clidash/README.md b/.claude/skills/add-clidash/add/tools/clidash/README.md new file mode 100644 index 000000000..7fd89eff2 --- /dev/null +++ b/.claude/skills/add-clidash/add/tools/clidash/README.md @@ -0,0 +1,109 @@ +# 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//` | `{ok, rows, fetchedAt}` — coalesced, 10s exec timeout | +| `GET /api/view//` | curated view plugin from `views/-.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`. diff --git a/.claude/skills/add-clidash/add/tools/clidash/activity.js b/.claude/skills/add-clidash/add/tools/clidash/activity.js new file mode 100644 index 000000000..1b4c11009 --- /dev/null +++ b/.claude/skills/add-clidash/add/tools/clidash/activity.js @@ -0,0 +1,80 @@ +// Message-activity reader for clidash. +// +// ncl has no `messages` resource — message data lives in the per-session SQLite +// DBs (`data/v2-sessions///{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`; +} + +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 = ts.slice(0, 10); // ISO date prefix + 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 (UTC, newest last) + */ +export function collectActivity(sessionsRoot, days, now) { + const dates = []; + for (let i = days - 1; i >= 0; i--) { + dates.push(new Date(now.getTime() - i * 86_400_000).toISOString().slice(0, 10)); + } + 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)) }; +} diff --git a/.claude/skills/add-clidash/add/tools/clidash/clidash.config.example.json b/.claude/skills/add-clidash/add/tools/clidash/clidash.config.example.json new file mode 100644 index 000000000..e50f2ab59 --- /dev/null +++ b/.claude/skills/add-clidash/add/tools/clidash/clidash.config.example.json @@ -0,0 +1,106 @@ +{ + "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"] + } + ] + } +} diff --git a/.claude/skills/add-clidash/add/tools/clidash/docs.js b/.claude/skills/add-clidash/add/tools/clidash/docs.js new file mode 100644 index 000000000..b40ce2260 --- /dev/null +++ b/.claude/skills/add-clidash/add/tools/clidash/docs.js @@ -0,0 +1,102 @@ +// 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//...` → group ``; `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; +} diff --git a/.claude/skills/add-clidash/add/tools/clidash/logs.js b/.claude/skills/add-clidash/add/tools/clidash/logs.js new file mode 100644 index 000000000..8123fa2a2 --- /dev/null +++ b/.claude/skills/add-clidash/add/tools/clidash/logs.js @@ -0,0 +1,18 @@ +// 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') }; +} diff --git a/.claude/skills/add-clidash/add/tools/clidash/package.json b/.claude/skills/add-clidash/add/tools/clidash/package.json new file mode 100644 index 000000000..347d277f3 --- /dev/null +++ b/.claude/skills/add-clidash/add/tools/clidash/package.json @@ -0,0 +1,14 @@ +{ + "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" + } +} diff --git a/.claude/skills/add-clidash/add/tools/clidash/parsers.js b/.claude/skills/add-clidash/add/tools/clidash/parsers.js new file mode 100644 index 000000000..8f3b5bbf4 --- /dev/null +++ b/.claude/skills/add-clidash/add/tools/clidash/parsers.js @@ -0,0 +1,101 @@ +// 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; +} diff --git a/.claude/skills/add-clidash/add/tools/clidash/public/app.js b/.claude/skills/add-clidash/add/tools/clidash/public/app.js new file mode 100644 index 000000000..7a3813de3 --- /dev/null +++ b/.claude/skills/add-clidash/add/tools/clidash/public/app.js @@ -0,0 +1,715 @@ +// 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 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::' | 'doc:' + 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: '', + activity: '', + terminal: '', + box: '', + folder: '', + logs: '', +}; +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}/; + +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' }, f.iso.slice(0, 16).replace('T', ' ')), + ])); + } + 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)} (${v.slice(0, 16).replace('T', ' ')})`); + 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(); diff --git a/.claude/skills/add-clidash/add/tools/clidash/public/apple-touch-icon.png b/.claude/skills/add-clidash/add/tools/clidash/public/apple-touch-icon.png new file mode 100644 index 000000000..54cba3532 Binary files /dev/null and b/.claude/skills/add-clidash/add/tools/clidash/public/apple-touch-icon.png differ diff --git a/.claude/skills/add-clidash/add/tools/clidash/public/favicon.ico b/.claude/skills/add-clidash/add/tools/clidash/public/favicon.ico new file mode 100644 index 000000000..50736c811 Binary files /dev/null and b/.claude/skills/add-clidash/add/tools/clidash/public/favicon.ico differ diff --git a/.claude/skills/add-clidash/add/tools/clidash/public/favicon.svg b/.claude/skills/add-clidash/add/tools/clidash/public/favicon.svg new file mode 100644 index 000000000..698bb3836 --- /dev/null +++ b/.claude/skills/add-clidash/add/tools/clidash/public/favicon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + ncl + diff --git a/.claude/skills/add-clidash/add/tools/clidash/public/icon-192.png b/.claude/skills/add-clidash/add/tools/clidash/public/icon-192.png new file mode 100644 index 000000000..892138ed1 Binary files /dev/null and b/.claude/skills/add-clidash/add/tools/clidash/public/icon-192.png differ diff --git a/.claude/skills/add-clidash/add/tools/clidash/public/icon-512.png b/.claude/skills/add-clidash/add/tools/clidash/public/icon-512.png new file mode 100644 index 000000000..7b48b7150 Binary files /dev/null and b/.claude/skills/add-clidash/add/tools/clidash/public/icon-512.png differ diff --git a/.claude/skills/add-clidash/add/tools/clidash/public/index.html b/.claude/skills/add-clidash/add/tools/clidash/public/index.html new file mode 100644 index 000000000..19279cfce --- /dev/null +++ b/.claude/skills/add-clidash/add/tools/clidash/public/index.html @@ -0,0 +1,45 @@ + + + + + + clidash + + + + + + + + + + + + + + +
+ + +
+ + +
+
+
+ + + + + + diff --git a/.claude/skills/add-clidash/add/tools/clidash/public/md.js b/.claude/skills/add-clidash/add/tools/clidash/public/md.js new file mode 100644 index 000000000..d15042157 --- /dev/null +++ b/.claude/skills/add-clidash/add/tools/clidash/public/md.js @@ -0,0 +1,68 @@ +// 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, '$1') + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/\*([^*]+)\*/g, '$1') + .replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g, (m, text, url) => + /^https?:\/\//i.test(url) ? `${text}` : 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(`
${buf.join('\n')}
`); + continue; + } + const h = line.match(/^(#{1,6})\s+(.*)$/); + if (h) { out.push(`${inline(h[2])}`); i++; continue; } + if (/^\s*([-*])\s+/.test(line)) { + const items = []; + while (i < lines.length && /^\s*([-*])\s+/.test(lines[i])) { + items.push(`
  • ${inline(lines[i].replace(/^\s*([-*])\s+/, ''))}
  • `); + i++; + } + out.push(`
      ${items.join('')}
    `); + continue; + } + if (/^\s*\d+\.\s+/.test(line)) { + const items = []; + while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) { + items.push(`
  • ${inline(lines[i].replace(/^\s*\d+\.\s+/, ''))}
  • `); + i++; + } + out.push(`
      ${items.join('')}
    `); + continue; + } + if (/^\s*(---+|\*\*\*+)\s*$/.test(line)) { out.push('
    '); i++; continue; } + if (/^\s*>\s?/.test(line)) { out.push(`
    ${inline(line.replace(/^\s*>\s?/, ''))}
    `); 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(`

    ${inline(para.join(' '))}

    `); + } + return out.join('\n'); +} diff --git a/.claude/skills/add-clidash/add/tools/clidash/public/site.webmanifest b/.claude/skills/add-clidash/add/tools/clidash/public/site.webmanifest new file mode 100644 index 000000000..d19794911 --- /dev/null +++ b/.claude/skills/add-clidash/add/tools/clidash/public/site.webmanifest @@ -0,0 +1,11 @@ +{ + "name": "clidash", + "short_name": "ncl", + "icons": [ + { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" } + ], + "theme_color": "#0a0c11", + "background_color": "#0a0c11", + "display": "standalone" +} diff --git a/.claude/skills/add-clidash/add/tools/clidash/public/style.css b/.claude/skills/add-clidash/add/tools/clidash/public/style.css new file mode 100644 index 000000000..132a559c2 --- /dev/null +++ b/.claude/skills/add-clidash/add/tools/clidash/public/style.css @@ -0,0 +1,328 @@ +/* clidash — refined terminal-ops console. + IBM Plex superfamily (Sans for UI, Mono for the CLI identity), a deep layered + dark palette, real depth, and precise micro-interactions. */ + +:root { + /* layered surfaces */ + --bg: #0a0c11; + --bg-grad: #10141d; + --panel: #13171f; + --panel-2: #1a1f2a; + --border: #222834; + --border-strong: #2e3644; + /* text */ + --text: #e8edf5; + --dim: #98a2b3; + --faint: #5c6675; + /* accent + semantics */ + --accent: #5b9dff; + --accent-soft: rgba(91, 157, 255, 0.14); + --green: #4cc97a; + --amber: #e0a93a; + --red: #f76d6d; + --purple: #c08cff; + --gray: #6b7585; + /* shape + motion */ + --radius: 11px; + --radius-sm: 8px; + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35); + --shadow: 0 6px 22px -8px rgba(0, 0, 0, 0.5); + --shadow-lg: 0 18px 44px -12px rgba(0, 0, 0, 0.6); + --ease: 160ms cubic-bezier(0.2, 0.6, 0.2, 1); + --font-sans: "IBM Plex Sans", -apple-system, system-ui, Segoe UI, sans-serif; + --font-mono: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, monospace; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } +[hidden] { display: none !important; } + +html { scrollbar-color: var(--border-strong) transparent; } +body { + background: + radial-gradient(120% 80% at 50% -10%, var(--bg-grad) 0%, transparent 55%), + var(--bg); + background-attachment: fixed; + color: var(--text); + font-family: var(--font-sans); + font-size: 14px; + line-height: 1.5; + -webkit-text-size-adjust: 100%; + -webkit-font-smoothing: antialiased; +} + +::selection { background: var(--accent-soft); } +:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; } + +h1 { + font-family: var(--font-mono); font-size: 17px; font-weight: 600; + letter-spacing: -0.3px; color: var(--text); +} +h1::before { content: "▍"; color: var(--accent); margin-right: 4px; } + +/* ---- layout ---- */ + +.app { display: flex; align-items: stretch; min-height: 100vh; min-height: 100dvh; } + +.sidebar { + width: 236px; flex-shrink: 0; height: 100vh; height: 100dvh; + position: sticky; top: 0; align-self: flex-start; overflow-y: auto; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.015), transparent 200px), var(--bg); + border-right: 1px solid var(--border); + padding: 18px 12px 40px; display: flex; flex-direction: column; gap: 10px; +} +.sidebar .brand { padding: 2px 8px 6px; } +.sidebar .controls { display: flex; gap: 8px; padding: 0 4px; } +.sidebar .updated { color: var(--faint); font-size: 11.5px; padding: 0 8px; min-height: 16px; font-family: var(--font-mono); } + +.pause, .refresh { + flex: 1; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius-sm); + color: var(--dim); padding: 6px 10px; font-size: 12.5px; font-family: var(--font-mono); + cursor: pointer; transition: color var(--ease), border-color var(--ease), background var(--ease); +} +.pause:hover, .refresh:hover { color: var(--text); border-color: var(--border-strong); background: var(--panel-2); } +.pause.paused { color: var(--amber); border-color: var(--amber); } +.refresh.spinning { color: var(--accent); border-color: var(--accent); animation: pulse 0.85s ease-in-out infinite; } +@keyframes pulse { 50% { opacity: 0.5; } } + +.nav { display: flex; flex-direction: column; gap: 1px; margin-top: 6px; } +.nav-section { + color: var(--faint); font-size: 10.5px; text-transform: uppercase; letter-spacing: 1px; + font-weight: 600; margin: 16px 10px 5px; font-family: var(--font-mono); + display: flex; align-items: center; gap: 7px; +} +.nav-section svg { width: 13px; height: 13px; opacity: 0.7; } +.nav-item { + display: flex; align-items: center; gap: 9px; text-align: left; + background: none; border: none; border-radius: var(--radius-sm); + color: var(--dim); padding: 7px 10px; font-size: 13.5px; cursor: pointer; width: 100%; + font-family: var(--font-sans); position: relative; + transition: color var(--ease), background var(--ease); +} +.nav-item svg { width: 16px; height: 16px; flex-shrink: 0; opacity: 0.85; } +.nav-item:hover { background: var(--panel); color: var(--text); } +.nav-item.active { background: var(--accent-soft); color: var(--text); } +.nav-item.active::before { + content: ""; position: absolute; left: -12px; top: 50%; transform: translateY(-50%); + width: 3px; height: 18px; background: var(--accent); border-radius: 0 3px 3px 0; +} +.nav-item.nav-sub { padding-left: 16px; font-size: 13px; } +.nav-item.nav-sub.active { color: var(--accent); } + +.hamburger { + display: none; position: fixed; top: 12px; left: 12px; z-index: 60; + background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius-sm); + color: var(--text); width: 40px; height: 40px; cursor: pointer; align-items: center; justify-content: center; +} +.hamburger svg { width: 20px; height: 20px; } +.scrim { display: none; } + +.main { flex: 1; min-width: 0; padding: 26px 28px 64px; max-width: 1500px; } +.page-title { + font-size: 20px; font-weight: 600; letter-spacing: -0.4px; margin-bottom: 16px; + animation: fadeUp 0.3s var(--ease) both; +} + +.banner { + background: rgba(247, 109, 109, 0.1); border: 1px solid rgba(247, 109, 109, 0.4); + border-radius: var(--radius-sm); color: var(--red); padding: 9px 14px; font-size: 13.5px; margin-bottom: 16px; +} +.cmdline { + font-family: var(--font-mono); font-size: 12px; color: var(--dim); + background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius-sm); + padding: 9px 14px; margin-bottom: 18px; overflow-x: auto; white-space: nowrap; box-shadow: var(--shadow-sm); +} +.cmdline::first-letter { color: var(--green); } + +@keyframes fadeUp { from { opacity: 0; transform: translateY(7px); } } + +/* ---- overview cards ---- */ + +.ov-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(310px, 1fr)); gap: 16px; } +.ov-card { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.018), transparent 40%), var(--panel); + border: 1px solid var(--border); border-radius: var(--radius); padding: 17px 19px; + box-shadow: var(--shadow); transition: border-color var(--ease), transform var(--ease); + animation: fadeUp 0.4s var(--ease) both; +} +.ov-card:nth-child(2) { animation-delay: 0.05s; } +.ov-card:nth-child(3) { animation-delay: 0.1s; } +.ov-card:nth-child(4) { animation-delay: 0.15s; } +.ov-card:hover { border-color: var(--border-strong); transform: translateY(-2px); } +.ov-head { display: flex; align-items: center; gap: 9px; margin-bottom: 14px; } +.ov-head .ov-name { font-weight: 600; font-size: 15px; } +.ov-head .ov-folder { color: var(--faint); font-size: 12px; font-family: var(--font-mono); } +.ov-fields { display: flex; flex-direction: column; gap: 7px; } +.ov-field { display: flex; justify-content: space-between; align-items: center; font-size: 13px; } +.ov-field .k { color: var(--dim); } +.ov-field .v { color: var(--text); font-variant-numeric: tabular-nums; } .ov-field .v.dim { color: var(--faint); } +.ov-chans { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 14px; } + +.dot, .ov-head .dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; } +.dot.green { background: var(--green); box-shadow: 0 0 8px -1px var(--green); } +.dot.amber { background: var(--amber); box-shadow: 0 0 8px -1px var(--amber); } +.dot.red { background: var(--red); box-shadow: 0 0 8px -1px var(--red); } +.dot.gray { background: var(--gray); } + +.badge { + font-size: 11.5px; border: 1px solid var(--border-strong); border-radius: 99px; + padding: 2px 10px; color: var(--dim); background: var(--panel-2); font-family: var(--font-mono); +} + +/* ---- status badges + enriched cells ---- */ +.badge-status { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; } +.badge-status .dot { width: 7px; height: 7px; } +.badge-status.green { color: var(--green); } .badge-status.amber { color: var(--amber); } +.badge-status.red { color: var(--red); } .badge-status.gray { color: var(--gray); } +td.enriched span:first-child { color: var(--text); } +td.enriched .raw-id { display: block; color: var(--faint); font-size: 11px; font-family: var(--font-mono); } + +/* ---- summary bar ---- */ +.summary-bar { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin: 2px 0 16px; font-size: 13px; } +.summary-bar .sum-count { color: var(--text); font-weight: 600; } +.summary-bar .sum-sep { color: var(--border-strong); } +.summary-bar .sum-chip { color: var(--dim); font-family: var(--font-mono); font-size: 12px; } + +/* ---- per-resource help panel ---- */ +.help-panel { margin-bottom: 18px; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 13px 16px; box-shadow: var(--shadow-sm); } +.help-head { color: var(--dim); font-size: 13px; line-height: 1.6; } +.help-head.dim { color: var(--faint); } +.help-more { margin-top: 9px; } +.help-more > summary { cursor: pointer; color: var(--accent); font-size: 12px; list-style: none; user-select: none; font-family: var(--font-mono); } +.help-more > summary::-webkit-details-marker { display: none; } +.help-more > summary::before { content: "▸ "; } +.help-more[open] > summary::before { content: "▾ "; } +.help-text { + margin: 9px 0 0; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm); + padding: 13px 15px; font-size: 12px; line-height: 1.6; color: var(--dim); + white-space: pre-wrap; overflow-x: auto; font-family: var(--font-mono); +} + +/* ---- document viewer ---- */ +.doc-viewer { display: grid; grid-template-columns: 264px 1fr; gap: 20px; align-items: start; } +.doc-list { display: flex; flex-direction: column; gap: 2px; max-height: 76vh; overflow-y: auto; padding-right: 4px; } +.doc-group-toggle { + flex-shrink: 0; display: flex; align-items: center; gap: 7px; width: 100%; + background: none; border: none; cursor: pointer; text-align: left; + color: var(--dim); font-size: 11px; text-transform: uppercase; letter-spacing: 1px; + font-weight: 600; font-family: var(--font-mono); margin: 12px 0 4px; padding: 5px 8px; + border-radius: var(--radius-sm); transition: color var(--ease), background var(--ease); +} +.doc-group-toggle:first-child { margin-top: 0; } +.doc-group-toggle:hover { color: var(--text); background: var(--panel); } +.doc-group-toggle.open { color: var(--text); } +.doc-group-toggle .chev { color: var(--accent); width: 10px; } +.doc-group-toggle .g-name { flex: 1; } +.doc-group-toggle .g-count { + color: var(--faint); background: var(--panel-2); border-radius: 99px; + padding: 1px 8px; font-size: 10.5px; letter-spacing: 0; +} +.doc-item { + flex-shrink: 0; text-align: left; background: none; border: none; border-radius: var(--radius-sm); + color: var(--dim); padding: 6px 11px; font-size: 13px; line-height: 1.5; cursor: pointer; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: color var(--ease), background var(--ease); +} +.doc-item:hover { background: var(--panel); color: var(--text); } +.doc-item.active { background: var(--accent-soft); color: var(--accent); } +.doc-content { + background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); + padding: 22px 26px; min-height: 220px; max-height: 78vh; overflow-y: auto; box-shadow: var(--shadow); +} + +.markdown { font-size: 14px; line-height: 1.7; color: var(--text); } +.markdown h1, .markdown h2, .markdown h3, .markdown h4 { margin: 20px 0 8px; line-height: 1.3; font-family: var(--font-mono); } +.markdown h1 { font-size: 21px; } .markdown h2 { font-size: 17px; color: var(--accent); } .markdown h3 { font-size: 15px; } +.markdown h1:first-child, .markdown h2:first-child { margin-top: 0; } +.markdown p { color: var(--text); margin: 8px 0; } +.markdown ul, .markdown ol { margin: 8px 0 8px 22px; } +.markdown li { margin: 3px 0; } +.markdown a { color: var(--accent); } +.markdown hr { border: none; border-top: 1px solid var(--border); margin: 18px 0; } +.markdown blockquote { border-left: 3px solid var(--border-strong); padding-left: 12px; color: var(--dim); margin: 8px 0; } +.markdown code { background: var(--bg); border: 1px solid var(--border); border-radius: 5px; padding: 1px 6px; font-size: 12.5px; font-family: var(--font-mono); } +.markdown pre.code, .doc-content pre.code { + background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm); + padding: 14px; overflow-x: auto; font-size: 12.5px; line-height: 1.6; margin: 12px 0; font-family: var(--font-mono); +} +.markdown pre.code code { background: none; border: none; padding: 0; } +.doc-content pre.json { color: var(--text); white-space: pre; font-family: var(--font-mono); } + +/* ---- activity ---- */ +.activity-wrap { max-width: 780px; } +.activity-legend { display: flex; gap: 18px; align-items: center; margin-bottom: 12px; font-size: 13px; color: var(--dim); } +.activity-legend .lg { display: inline-block; width: 11px; height: 11px; border-radius: 3px; margin-right: 6px; vertical-align: -1px; } +.activity-legend .lg.in { background: var(--accent); } .activity-legend .lg.out { background: var(--purple); } +.chart-box { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; margin-bottom: 20px; box-shadow: var(--shadow); } +.activity-chart { width: 100%; height: 220px; display: block; } +.activity-chart .bar-in { fill: var(--accent); } .activity-chart .bar-out { fill: var(--purple); } +.activity-chart .grid { stroke: var(--border); stroke-width: 1; } +.activity-chart .axis { fill: var(--faint); font-size: 9px; font-family: var(--font-mono); } +.activity-table td.num, .activity-table th:nth-child(3), .activity-table th:nth-child(4) { text-align: right; } +.activity-table td.num { font-variant-numeric: tabular-nums; } + +/* ---- log viewer ---- */ +.log-box { + background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); + max-height: 78vh; overflow: auto; padding: 12px 0; box-shadow: var(--shadow); +} +.log-view { font-family: var(--font-mono); font-size: 12px; line-height: 1.65; min-width: max-content; } +.log-line { padding: 0 16px; white-space: pre; color: var(--dim); } +.log-line:hover { background: var(--panel); } +.log-line.err { color: var(--red); background: rgba(247, 109, 109, 0.06); } +.log-line.warn { color: var(--amber); } + +/* ---- tables ---- */ +.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-sm); } +table { border-collapse: collapse; width: 100%; font-size: 13px; } +th, td { text-align: left; padding: 9px 14px; border-bottom: 1px solid var(--border); white-space: nowrap; } +tbody tr:last-child td { border-bottom: none; } +th { color: var(--dim); font-weight: 600; font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.6px; position: sticky; top: 0; background: var(--panel-2); font-family: var(--font-mono); } +td { font-variant-numeric: tabular-nums; } +td.null { color: var(--faint); font-style: italic; } +tr.drillable { cursor: pointer; transition: background var(--ease); } +tr.drillable:hover td { background: var(--panel); } +.reltime .abs { color: var(--faint); font-size: 11.5px; margin-left: 6px; font-family: var(--font-mono); } +td .trunc { cursor: pointer; border-bottom: 1px dotted var(--faint); } +.stale-note { color: var(--amber); font-size: 12.5px; margin-bottom: 8px; } +.empty, .tab-error { color: var(--dim); padding: 26px 2px; font-size: 14px; } +.tab-error { color: var(--red); } +.tab-error pre { margin-top: 10px; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 12px; color: var(--dim); font-size: 12px; overflow-x: auto; white-space: pre-wrap; font-family: var(--font-mono); } + +/* ---- drill-down detail overlay ---- */ +.detail-overlay { position: fixed; inset: 0; background: rgba(2, 4, 8, 0.62); display: flex; justify-content: flex-end; z-index: 50; backdrop-filter: blur(2px); animation: fade 0.18s ease; } +@keyframes fade { from { opacity: 0; } } +.detail-panel { width: min(580px, 100%); height: 100%; background: var(--bg); border-left: 1px solid var(--border-strong); overflow-y: auto; box-shadow: var(--shadow-lg); animation: slideIn 0.22s var(--ease); } +@keyframes slideIn { from { transform: translateX(24px); opacity: 0.6; } } +.detail-head { display: flex; justify-content: space-between; align-items: center; padding: 17px 22px; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--bg); } +.detail-res { font-weight: 600; font-size: 15px; } +.detail-id { color: var(--dim); font-family: var(--font-mono); font-size: 13px; } +.detail-close { background: none; border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--dim); width: 30px; height: 30px; cursor: pointer; font-size: 14px; transition: color var(--ease), border-color var(--ease); } +.detail-close:hover { color: var(--text); border-color: var(--accent); } +.detail-body { padding: 17px 22px 44px; } +.detail-section { margin: 24px 0 8px; color: var(--faint); font-size: 10.5px; text-transform: uppercase; letter-spacing: 1px; font-weight: 600; border-top: 1px solid var(--border); padding-top: 17px; font-family: var(--font-mono); } +.kv { display: flex; flex-direction: column; gap: 2px; } +.kv-row { display: grid; grid-template-columns: 168px 1fr; gap: 12px; padding: 6px 0; border-bottom: 1px solid var(--border); font-size: 13px; } +.kv-key { color: var(--dim); font-family: var(--font-mono); font-size: 12.5px; } +.kv-json { background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 8px 10px; font-size: 12px; overflow-x: auto; margin: 0; font-family: var(--font-mono); } + +/* ---- mobile ---- */ +@media (max-width: 640px) { + .hamburger { display: flex; } + .sidebar { + position: fixed; top: 0; left: 0; z-index: 70; transform: translateX(-100%); + transition: transform 0.22s var(--ease); box-shadow: var(--shadow-lg); + } + .sidebar.open { transform: translateX(0); } + .scrim { display: block; position: fixed; inset: 0; background: rgba(2, 4, 8, 0.55); z-index: 65; backdrop-filter: blur(1px); } + .scrim[hidden] { display: none; } + .main { padding: 58px 16px 48px; } + .ov-cards { grid-template-columns: 1fr; } + .doc-viewer { grid-template-columns: 1fr; gap: 12px; } + .doc-list { max-height: 220px; flex-direction: row; flex-wrap: wrap; } + .doc-content { max-height: none; padding: 18px; } + .detail-panel { width: 100%; } + .kv-row { grid-template-columns: 1fr; gap: 2px; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation: none !important; transition: none !important; } +} diff --git a/.claude/skills/add-clidash/add/tools/clidash/server.js b/.claude/skills/add-clidash/add/tools/clidash/server.js new file mode 100644 index 000000000..f2f4c671d --- /dev/null +++ b/.claude/skills/add-clidash/add/tools/clidash/server.js @@ -0,0 +1,437 @@ +// 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 ` 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}`); + }); +} diff --git a/.claude/skills/add-clidash/add/tools/clidash/test/activity-server.test.js b/.claude/skills/add-clidash/add/tools/clidash/test/activity-server.test.js new file mode 100644 index 000000000..7ac9d7802 --- /dev/null +++ b/.claude/skills/add-clidash/add/tools/clidash/test/activity-server.test.js @@ -0,0 +1,46 @@ +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(); }; + const today = new Date().toISOString().slice(0, 10); + mk(join(root, 'ag-1', 'sess-1', 'inbound.db'), 'messages_in', [`${today} 09:00:00`, `${today} 10:00:00`]); + mk(join(root, 'ag-1', 'sess-1', 'outbound.db'), 'messages_out', [`${today} 09:05:00`]); +}); +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); + }); +}); diff --git a/.claude/skills/add-clidash/add/tools/clidash/test/activity.test.js b/.claude/skills/add-clidash/add/tools/clidash/test/activity.test.js new file mode 100644 index 000000000..ba169e980 --- /dev/null +++ b/.claude/skills/add-clidash/add/tools/clidash/test/activity.test.js @@ -0,0 +1,75 @@ +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); +}); + +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, '2026-06-01'); + assert.equal(series[13].date, '2026-06-14'); +}); + +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])); + assert.equal(byDate['2026-06-14'].in, 2); + assert.equal(byDate['2026-06-14'].out, 2); + assert.equal(byDate['2026-06-13'].in, 1); + assert.equal(byDate['2026-06-13'].out, 0); +}); + +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')); +}); diff --git a/.claude/skills/add-clidash/add/tools/clidash/test/cmd.test.js b/.claude/skills/add-clidash/add/tools/clidash/test/cmd.test.js new file mode 100644 index 000000000..e6880aa45 --- /dev/null +++ b/.claude/skills/add-clidash/add/tools/clidash/test/cmd.test.js @@ -0,0 +1,91 @@ +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); + }); +}); diff --git a/.claude/skills/add-clidash/add/tools/clidash/test/css.test.js b/.claude/skills/add-clidash/add/tools/clidash/test/css.test.js new file mode 100644 index 000000000..add22463f --- /dev/null +++ b/.claude/skills/add-clidash/add/tools/clidash/test/css.test.js @@ -0,0 +1,20 @@ +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/); +}); diff --git a/.claude/skills/add-clidash/add/tools/clidash/test/docs-server.test.js b/.claude/skills/add-clidash/add/tools/clidash/test/docs-server.test.js new file mode 100644 index 000000000..4dd3701d5 --- /dev/null +++ b/.claude/skills/add-clidash/add/tools/clidash/test/docs-server.test.js @@ -0,0 +1,111 @@ +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, []); + }); +}); diff --git a/.claude/skills/add-clidash/add/tools/clidash/test/docs.test.js b/.claude/skills/add-clidash/add/tools/clidash/test/docs.test.js new file mode 100644 index 000000000..dc49adac9 --- /dev/null +++ b/.claude/skills/add-clidash/add/tools/clidash/test/docs.test.js @@ -0,0 +1,111 @@ +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); +}); diff --git a/.claude/skills/add-clidash/add/tools/clidash/test/fixtures/ncl-help.txt b/.claude/skills/add-clidash/add/tools/clidash/test/fixtures/ncl-help.txt new file mode 100644 index 000000000..1d9550b2a --- /dev/null +++ b/.claude/skills/add-clidash/add/tools/clidash/test/fixtures/ncl-help.txt @@ -0,0 +1,28 @@ +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 help` for detailed field information. diff --git a/.claude/skills/add-clidash/add/tools/clidash/test/fixtures/stub-cli.js b/.claude/skills/add-clidash/add/tools/clidash/test/fixtures/stub-cli.js new file mode 100644 index 000000000..afd8f1765 --- /dev/null +++ b/.claude/skills/add-clidash/add/tools/clidash/test/fixtures/stub-cli.js @@ -0,0 +1,57 @@ +#!/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') { // ` help` → raw per-resource help text + process.stdout.write(`${args[0]}: help for ${args[0]}\n\nVerbs:\n list\n get \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); diff --git a/.claude/skills/add-clidash/add/tools/clidash/test/help.test.js b/.claude/skills/add-clidash/add/tools/clidash/test/help.test.js new file mode 100644 index 000000000..71b206d5d --- /dev/null +++ b/.claude/skills/add-clidash/add/tools/clidash/test/help.test.js @@ -0,0 +1,61 @@ +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); + }); +}); diff --git a/.claude/skills/add-clidash/add/tools/clidash/test/logs.test.js b/.claude/skills/add-clidash/add/tools/clidash/test/logs.test.js new file mode 100644 index 000000000..5e1da3529 --- /dev/null +++ b/.claude/skills/add-clidash/add/tools/clidash/test/logs.test.js @@ -0,0 +1,75 @@ +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); + }); +}); diff --git a/.claude/skills/add-clidash/add/tools/clidash/test/md.test.js b/.claude/skills/add-clidash/add/tools/clidash/test/md.test.js new file mode 100644 index 000000000..8bd5a01d8 --- /dev/null +++ b/.claude/skills/add-clidash/add/tools/clidash/test/md.test.js @@ -0,0 +1,63 @@ +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(` b'); + assert.ok(!html.includes('