Compare commits

...

36 Commits

Author SHA1 Message Date
gavrielc 79e490dfcf Merge branch 'main' into feat/approval-button-styles 2026-07-04 19:29:58 +03:00
gavrielc 01b07d6652 Merge pull request #2795 from leetwito/feat/add-clidash-skill
feat: add /add-clidash — read-only CLI-derived dashboard skill
2026-07-04 16:53:44 +03:00
gavrielc be20e9f723 Merge branch 'main' into feat/add-clidash-skill 2026-07-04 16:53:25 +03:00
gavrielc e3d156f800 test(channels): cover ask_question option style normalization
Reviewers on #2933 asked for tests on the button-style plumbing. Two layers:

- src/channels/ask-question.test.ts (new): normalizeOption/normalizeOptions
  style whitelist — primary/danger/default pass through; unknown strings,
  case variants, and non-string values drop to undefined; string-shorthand
  options carry no style; style coexists with the label/selectedLabel/value
  defaulting. This is the load-bearing gate: an invalid style reaching
  Slack Block Kit fails the whole card with invalid_blocks, which in the
  approval flow is an effective auto-deny.

- src/channels/chat-sdk-bridge.test.ts: ask_question delivery passes each
  normalized option style into Button() and omits it when unset; an invalid
  style in the raw payload is stripped before the card is built.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 16:39:04 +03:00
github-actions[bot] b4da018d8c chore: bump version to 2.1.30 2026-07-04 13:33:48 +00:00
gavrielc 4b11079007 Merge pull request #2932 from nanocoai/cleanup/dispatch-longest-prefix
Fix ncl positional IDs for generated (dashed) identifiers
2026-07-04 16:33:36 +03:00
gavrielc 31cc35ea32 Merge branch 'main' into cleanup/dispatch-longest-prefix 2026-07-04 16:33:27 +03:00
github-actions[bot] bf0a3d2612 chore: bump version to 2.1.29 2026-07-04 13:31:46 +00:00
gavrielc 6dc25a9b8a Merge pull request #2930 from nanocoai/cleanup/command-gate-start-and-failopen
command-gate: restore the /start filter and remove the fail-open admin check
2026-07-04 16:31:32 +03:00
gavrielc cafba7fb18 Merge branch 'main' into cleanup/command-gate-start-and-failopen 2026-07-04 16:31:05 +03:00
github-actions[bot] 10d400ec64 chore: bump version to 2.1.28 2026-07-04 13:29:49 +00:00
gavrielc 1a9643cfa3 Merge pull request #2929 from nanocoai/feat/onecli-approval-card-summary
feat(approvals): render OneCLI approval requests from the gateway's structured summary
2026-07-04 16:29:38 +03:00
gavrielc 2ba2555032 Merge branch 'main' into feat/onecli-approval-card-summary 2026-07-04 16:28:01 +03:00
github-actions[bot] 687d7d13ac chore: bump version to 2.1.27 2026-07-04 13:26:56 +00:00
gavrielc 2938bbf94a Merge pull request #2928 from nanocoai/cleanup/remove-dead-global-mount
Remove the dead /workspace/global mount and untrack v1 group seed files
2026-07-04 16:26:42 +03:00
gavrielc d1a2b04d32 Merge branch 'main' into cleanup/remove-dead-global-mount 2026-07-04 16:26:10 +03:00
github-actions[bot] 455014e7b9 chore: bump version to 2.1.26 2026-07-04 13:25:56 +00:00
gavrielc 5032c431ae Merge pull request #2927 from nanocoai/cleanup/unregister-mock-provider
Unregister the mock provider from the production container barrel
2026-07-04 16:25:40 +03:00
gavrielc ac8273c698 Merge branch 'main' into cleanup/unregister-mock-provider 2026-07-04 16:24:37 +03:00
gavrielc f094f56bf6 Fix ncl positional IDs for generated (dashed) identifiers
The dispatcher trimmed exactly one trailing dash-segment to recover the
target id, so any generated id containing dashes (UUIDs, sess-*, appr-*)
never matched a command and failed unknown-command; only --id worked.
Resolve by longest registered prefix instead: split the dash-joined
command, take the longest prefix that lookup() resolves as the command,
and treat the re-joined remainder as args.id. Server-side only, no wire
or client change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:05:02 +03:00
gavrielc fcee39ea14 command-gate: restore the /start filter and remove the fail-open admin check
Add '/start' back to the host FILTERED set (a Telegram fix from 866b791
was silently undone when host gating was added) so it is dropped instead
of reaching the agent as a normal message. Replace the inline isAdmin
query and its hasTable('user_roles') fail-open guard with a call to
hasAdminPrivilege; user_roles always exists (core migration 001-initial),
so the guard only ever masked a missing check. Add a focused command-gate
test covering both.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:04:58 +03:00
gavrielc 2bce84781b feat(approvals): colored buttons on approval cards (Slack primary/danger)
Approval cards render every button with the same neutral style, so Approve
and Reject read identically at a glance. The chat SDK's Button already
accepts style ('primary' | 'danger' | 'default') and @chat-adapter/slack
maps primary→green and danger→red Block Kit styles (Telegram ignores it),
but the ask_question pipeline dropped the field.

- ask-question.ts: add OptionStyle and an optional style field to
  OptionInput/NormalizedOption; normalizeOption whitelists the value.
  Bare-string options stay unstyled.
- chat-sdk-bridge.ts: forward opt.style into Button() on ask_question
  cards. Persisted options_json tolerates the extra key
  (getAskQuestionRender only reads label/selectedLabel/value).
- Producers: module approvals (Approve/Reject), sender approvals
  (Allow/Deny), channel approvals (Connect/Reject), and OneCLI credential
  cards (Approve/Reject) annotate primary/danger. Multi-choice picker
  options stay unstyled — a list of equals.

Purely additive: options without style render exactly as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 16:04:24 +03:00
gavrielc f4be00e6ed Remove dead /workspace/global mount and untrack v1 group seed files
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:04:14 +03:00
gavrielc c9aa69dea9 Unregister the mock provider from the production container barrel
The container self-registration barrel imported mock.js, so every container
registered a 'mock' provider returning canned text. A typo'd --provider mock
would silently 'work' instead of failing loudly. Drop the import (mock.ts stays
for direct test use) and tidy the now-stale host comments and docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:04:09 +03:00
gavrielc 3731e26769 feat(approvals): render OneCLI approval requests from the gateway's structured summary
The hosted OneCLI gateway (api.onecli.sh) sends a structured summary field
on ApprovalRequest — { action, details: [{label, value}] } — that the SDK's
TypeScript type doesn't declare yet. When present, render it as the approval
card body (*Action:* plus labeled fields, fencing multi-line values) instead
of the raw METHOD host/path + bodyPreview, so approvers see what the agent
is doing (e.g. To / Subject / Body of an email send) rather than an HTTP
trace.

Rendering is defensive: non-string values are coerced via JSON.stringify (a
render throw would turn into a deny via handleRequest's catch), each value is
capped at 900 chars, and a 2600-char running budget keeps the card under
Slack's 3000-char section block limit — overflow is reported with an
explicit "…N field(s) omitted for length" line instead of a failed
delivery. Without a summary, the old bodyPreview fallback remains, now
capped and with the method/host/path as a footnote.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 16:04:08 +03:00
leetwito d472d9d32b Merge remote-tracking branch 'upstream/main' into feat/add-clidash-skill 2026-07-04 15:38:41 +03:00
leetwito 6c2d26836f fix(add-clidash): mkdir -p tools in install step; require Node >=22.5
Address review feedback on PR #2795:
- SKILL.md Step 1 now creates tools/ before cp -R (cp does not create
  intermediate parents; tools/ is not a standard NanoClaw dir, so the copy
  failed on a fresh checkout).
- package.json engines bumped to >=22.5 — server.js statically imports
  activity.js -> node:sqlite (DatabaseSync), which lands in Node 22.5, so
  the server crashed at module load on Node 20/21 LTS.
- Doc references (SKILL.md, README.md) updated to Node >=22.5 for consistency.

Verified 87/87 tests pass on Node 22.14.
2026-07-04 15:38:38 +03:00
github-actions[bot] b28c917997 docs: update token count to 207k tokens · 104% of context window 2026-07-04 08:08:53 +00:00
github-actions[bot] a00a5610bd chore: bump version to 2.1.25 2026-07-04 08:08:50 +00:00
gavrielc 05dc1b0a3c Merge pull request #2611 from Hinotoi-agent/fix/approval-cli-caller-context
[security] fix(cli): preserve caller context after approval
2026-07-04 11:08:39 +03:00
glifocat a7b34bc872 Merge branch 'main' into fix/approval-cli-caller-context 2026-07-04 09:11:27 +02:00
Lee Twito 8ee7915418 Merge branch 'main' into feat/add-clidash-skill 2026-06-21 16:19:48 +03:00
gavrielc 9a142302df Merge branch 'main' into feat/add-clidash-skill 2026-06-18 09:18:10 +03:00
leetwito e856e924a5 feat: add /add-clidash — read-only CLI-derived dashboard skill
clidash is 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 plus docker, with message-activity charts, a
log tail, and a read-only file viewer for group skills/CLAUDE.md/profiles.

Packaged as a utility skill per CONTRIBUTING.md: code under the skill dir,
copied into tools/clidash on install. No edits to NanoClaw src, no new deps.
2026-06-17 15:40:39 +03:00
Hinotobi 0516bea638 Merge branch 'main' into fix/approval-cli-caller-context 2026-06-11 11:29:11 +08:00
hinotoi-agent 32f067f5bb fix(cli): preserve caller context after approval 2026-05-25 19:51:54 +08:00
57 changed files with 4019 additions and 538 deletions
+22
View File
@@ -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.
+168
View File
@@ -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.<name>.bin` / `cwd` / `env` | how to invoke the CLI (`bin` is relative to `cwd`) |
| `clis.<name>.discover` or `resources` | runtime discovery (`ncl help`) vs a static resource list |
| `clis.<name>.list` | argv template; `{resource}` is the only substitution |
| `clis.<name>.output` | `json` or `jsonlines` (docker/kubectl style) |
| `clis.<name>.unwrap` | dot-path into a response envelope (e.g. `data`) |
| `clis.<name>.enrich`/`badges`/`summary` | table decorations (ID→name joins, status colors, summary cards) |
| `activity` | `sessionsRoot` + `days` for the message-activity charts |
| `logs` | `dir`, `tailLines`, and an allowlist of `files` to tail |
| `docs` | file viewer: `root`, a `deny` glob list, and `collections` of glob patterns |
Adding a second CLI is config-only — e.g. `docker` is included as a `jsonlines`
example. View plugins (`views/<cli>-<view>.js`) are the only per-CLI code and
are optional.
## Troubleshooting
- **`ENOENT` / config not found** — run from `tools/clidash/` and make sure you
copied `clidash.config.example.json` to `clidash.config.json` (step 2), or set
`CLIDASH_CONFIG=/abs/path.json`.
- **No `ncl` resources / discovery empty** — `bin/ncl` isn't built or the path
is wrong. Build it (`pnpm run build`) or fix `clis.ncl.bin`.
- **docker tab errors** — the docker daemon isn't running, or remove the
`docker` CLI from config if you don't need it.
- **Can't reach it from another device** — it binds `127.0.0.1`; set
`BIND=<private-ip>` (tailnet), never a public interface.
- **Empty Activity/Logs/Files** — check that `activity.sessionsRoot`,
`logs.dir`, and `docs.root` resolve to your NanoClaw root (relative to where
you launch `node server.js`).
## Removal
See [REMOVE.md](REMOVE.md).
@@ -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/<cli>/<resource>` | `{ok, rows, fetchedAt}` — coalesced, 10s exec timeout |
| `GET /api/view/<cli>/<view>` | curated view plugin from `views/<cli>-<view>.js` |
View plugins are the only per-CLI *code*, and optional: a default-exported
async function receiving `{ fetch }` (bound to that CLI) returning JSON.
`views/ncl-overview.js` joins groups + sessions + messaging-groups + wirings
into per-agent status cards (green <15m / amber <2h / red older).
## Test
```bash
npm test # unit + integration (node:test, stub CLI — no real CLI needed)
./test/smoke.sh # against a running instance
```
## Deploy as a service
clidash binds `127.0.0.1` by default. To reach it from other devices, bind a
private (e.g. tailnet) IP — **never a public interface**; the network is the
auth boundary. Example systemd user service:
```ini
# ~/.config/systemd/user/clidash.service
[Unit]
Description=clidash read-only CLI dashboard
[Service]
WorkingDirectory=%h/nanoclaw/tools/clidash
ExecStart=/usr/bin/node %h/nanoclaw/tools/clidash/server.js
Environment=BIND=127.0.0.1
Restart=on-failure
[Install]
WantedBy=default.target
```
Then `systemctl --user enable --now clidash`.
@@ -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/<group>/<session>/{inbound,outbound}.db`). We read them
// read-only with Node's built-in `node:sqlite` (no new dependency) and aggregate
// per-session in/out totals + a daily time-series for charting.
import { readdirSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
// Timestamps come in two shapes across tables: SQLite "YYYY-MM-DD HH:MM:SS" (UTC)
// and already-ISO "YYYY-MM-DDTHH:MM:SS.sssZ". Normalize to a comparable ISO form
// so date-bucketing and max("last") work regardless of which a row used.
function normTs(ts) {
if (typeof ts !== 'string' || ts.length < 10) return null;
if (ts.includes('T')) return ts; // already ISO
return `${ts.replace(' ', 'T')}Z`;
}
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)) };
}
@@ -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"]
}
]
}
}
@@ -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/<g>/...` → group `<g>`; `container/...` → group `shared`.
*/
const CONTAINER_SEGS = new Set(['skills', 'conversations']); // redundant grouping dirs
export function describeFile(relPath) {
const parts = relPath.split('/');
if (parts[0] === 'groups' && parts.length > 2) {
const rest = parts.slice(2).filter((s) => !CONTAINER_SEGS.has(s)).join('/');
return { group: parts[1], label: `${parts[1]} / ${rest}` };
}
if (parts[0] === 'container') {
const rest = parts.slice(2).filter((s) => !CONTAINER_SEGS.has(s)).join('/');
return { group: 'shared', label: `shared / ${rest}` };
}
return { group: '', label: relPath };
}
/**
* Validate a requested doc path against a collection and return its absolute
* path, or throw. A path is allowed only if it is a member of the collection's
* freshly-globbed allow-set — this single check enforces the patterns, the
* deny-list, and traversal safety at once.
*/
export function resolveDoc(root, collection, relPath, deny = []) {
const allowed = new Set(globFiles(root, collection.patterns, deny));
if (!allowed.has(relPath)) {
throw new Error(`Path not allowed: ${relPath}`);
}
// Defence in depth: the resolved real path must still live under root.
const abs = resolve(root, relPath);
const rootReal = realpathSync(root);
const absReal = realpathSync(abs);
if (absReal !== rootReal && !absReal.startsWith(rootReal + sep)) {
throw new Error(`Path not allowed: ${relPath}`);
}
return abs;
}
@@ -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') };
}
@@ -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"
}
}
@@ -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;
}
@@ -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 <resource> list --json`; rows drill into their `get` detail.
//
// Refresh UX: on first load every resource of every CLI is prefetched so nav is
// instant. 60s auto-refresh + a manual button. Background refreshes diff-and-
// inject (the data DOM rebuilds only when the data signature changes).
import { mdToHtml } from './md.js';
const $ = (id) => document.getElementById(id);
const state = {
clis: [],
docCollections: [],
activeView: 'overview', // 'overview' | 'activity' | 'r:<cli>:<resource>' | 'doc:<collection>'
paused: false,
refreshSeconds: 60,
lastUpdated: null,
refreshing: false,
snapshots: new Map(), // "cli/resource" -> { rows, fetchedAt, command }
errors: new Map(),
activity: null, // { sessions, series }
activityConfigured: false,
activityCommand: null,
logs: [], // [{ name, label }]
logCache: new Map(), // name -> { text, command }
activeDocPath: null,
openDocGroups: new Set(), // which doc groups (e.g. agents) are expanded
docCache: new Map(),
configCache: new Map(), // groupId -> container config (for the overview page)
helpCache: new Map(), // "cli/resource" -> help text | null (prefetched each cycle)
detail: null,
sidebarOpen: false,
renderedSig: null,
};
const SVG_NS = 'http://www.w3.org/2000/svg';
function svg(tag, attrs = {}, children = []) {
const node = document.createElementNS(SVG_NS, tag);
for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v);
for (const c of [].concat(children)) if (c != null) node.append(c);
return node;
}
// Lucide-style inline icons (static trusted markup) — crisp, themeable via currentColor.
const ICONS = {
overview: '<rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/>',
activity: '<path d="M3 3v18h18"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/>',
terminal: '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="m6 9 3 3-3 3"/><path d="M13 15h4"/>',
box: '<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>',
folder: '<path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z"/>',
logs: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v5h5"/><path d="M8 13h8"/><path d="M8 17h5"/>',
};
function icon(name) {
const s = document.createElementNS(SVG_NS, 'svg');
s.setAttribute('viewBox', '0 0 24 24');
s.setAttribute('fill', 'none');
s.setAttribute('stroke', 'currentColor');
s.setAttribute('stroke-width', '1.8');
s.setAttribute('stroke-linecap', 'round');
s.setAttribute('stroke-linejoin', 'round');
s.innerHTML = ICONS[name] ?? '';
return s;
}
// ---------------------------------------------------------------- helpers
const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/;
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();
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 547 B

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#141a24"/>
<stop offset="1" stop-color="#0b0e14"/>
</linearGradient>
</defs>
<rect width="32" height="32" rx="7.5" fill="url(#bg)"/>
<rect x="0.5" y="0.5" width="31" height="31" rx="7" fill="none" stroke="#2b3342" stroke-width="1"/>
<text x="16.2" y="20.8" font-family="monospace" font-size="15" font-weight="700" letter-spacing="-0.5" text-anchor="middle" fill="#5b9dff">ncl</text>
</svg>

After

Width:  |  Height:  |  Size: 570 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>clidash</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="manifest" href="/site.webmanifest">
<meta name="theme-color" content="#0a0c11">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
</head>
<body>
<button id="hamburger" class="hamburger" title="Menu" aria-label="Toggle menu">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="4" x2="20" y1="6" y2="6"/><line x1="4" x2="20" y1="12" y2="12"/><line x1="4" x2="20" y1="18" y2="18"/></svg>
</button>
<div id="scrim" class="scrim" hidden></div>
<div class="app">
<aside id="sidebar" class="sidebar">
<div class="brand"><h1>clidash</h1></div>
<div class="controls">
<button id="refresh" class="refresh" title="Refresh now">↻ refresh</button>
<button id="pause" class="pause" title="Pause auto-refresh">⏸ pause</button>
</div>
<div id="updated" class="updated"></div>
<nav id="nav" class="nav"></nav>
</aside>
<main class="main">
<div id="banner" class="banner" hidden></div>
<div id="cmdline" class="cmdline" hidden></div>
<section id="content" class="content"></section>
</main>
</div>
<div id="detail" class="detail-overlay" hidden></div>
<script type="module" src="app.js"></script>
</body>
</html>
@@ -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) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
));
}
export function mdToHtml(src) {
const lines = escapeHtml(src).split('\n');
const out = [];
let i = 0;
const inline = (t) => t
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
.replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g, (m, text, url) =>
/^https?:\/\//i.test(url) ? `<a href="${url}" target="_blank" rel="noopener noreferrer">${text}</a>` : m);
while (i < lines.length) {
const line = lines[i];
if (/^```/.test(line)) {
const buf = [];
i++;
while (i < lines.length && !/^```/.test(lines[i])) buf.push(lines[i++]);
i++;
out.push(`<pre class="code"><code>${buf.join('\n')}</code></pre>`);
continue;
}
const h = line.match(/^(#{1,6})\s+(.*)$/);
if (h) { out.push(`<h${h[1].length}>${inline(h[2])}</h${h[1].length}>`); i++; continue; }
if (/^\s*([-*])\s+/.test(line)) {
const items = [];
while (i < lines.length && /^\s*([-*])\s+/.test(lines[i])) {
items.push(`<li>${inline(lines[i].replace(/^\s*([-*])\s+/, ''))}</li>`);
i++;
}
out.push(`<ul>${items.join('')}</ul>`);
continue;
}
if (/^\s*\d+\.\s+/.test(line)) {
const items = [];
while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) {
items.push(`<li>${inline(lines[i].replace(/^\s*\d+\.\s+/, ''))}</li>`);
i++;
}
out.push(`<ol>${items.join('')}</ol>`);
continue;
}
if (/^\s*(---+|\*\*\*+)\s*$/.test(line)) { out.push('<hr>'); i++; continue; }
if (/^\s*>\s?/.test(line)) { out.push(`<blockquote>${inline(line.replace(/^\s*>\s?/, ''))}</blockquote>`); i++; continue; }
if (line.trim() === '') { i++; continue; }
const para = [line];
i++;
while (i < lines.length && lines[i].trim() !== '' && !/^(#{1,6}\s|```|\s*[-*]\s|\s*\d+\.\s|\s*>)/.test(lines[i])) {
para.push(lines[i++]);
}
out.push(`<p>${inline(para.join(' '))}</p>`);
}
return out.join('\n');
}
@@ -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"
}
@@ -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; }
}
@@ -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 `<cli> <resource> help`) -----------
const helpInflight = new Map();
async function runHelp(cliName, resourceName) {
const cliCfg = config.clis[cliName];
if (!cliCfg.help) { const e = new Error(`No help for "${cliName}"`); e.statusCode = 404; throw e; }
const resources = await discoverResources(cliName);
if (!resources.some((r) => r.name === resourceName)) {
const e = new Error(`Unknown resource "${resourceName}"`); e.statusCode = 404; throw e;
}
const key = `${cliName}\0${resourceName}`;
if (helpInflight.has(key)) return helpInflight.get(key);
const args = cliCfg.help.map((a) => a.replaceAll('{resource}', resourceName));
const promise = execCli(cliCfg, args, `${cliName} ${resourceName} help`).finally(() => helpInflight.delete(key));
helpInflight.set(key, promise);
return promise;
}
// ---- view plugins --------------------------------------------------------
async function listViews(cliName) {
try {
const files = await readdir(viewsDir);
return files
.filter((f) => f.startsWith(`${cliName}-`) && f.endsWith('.js'))
.map((f) => f.slice(cliName.length + 1, -3));
} catch {
return [];
}
}
async function runView(cliName, viewName) {
if (!/^[a-zA-Z0-9_-]+$/.test(viewName)) {
const err = new Error(`Invalid view name`);
err.statusCode = 404;
throw err;
}
const file = join(viewsDir, `${cliName}-${viewName}.js`);
let mod;
try {
mod = await import(pathToFileURL(file).href);
} catch (e) {
if (e.code === 'ERR_MODULE_NOT_FOUND') {
const err = new Error(`No view "${viewName}" for CLI "${cliName}"`);
err.statusCode = 404;
throw err;
}
throw e;
}
return mod.default({ fetch: (resource) => fetchRows(cliName, resource) });
}
// ---- http ----------------------------------------------------------------
function sendJson(res, status, body) {
const payload = JSON.stringify(body);
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(payload);
}
function sendError(res, err) {
const status = err.statusCode ?? 502;
const body = { ok: false, error: err.message };
if (err.raw !== undefined) body.raw = String(err.raw).slice(0, 64 * 1024);
sendJson(res, status, body);
}
async function serveStatic(res, urlPath) {
const relative = urlPath === '/' ? 'index.html' : decodeURIComponent(urlPath.slice(1));
const file = resolve(publicDir, relative);
if (file !== publicDir && !file.startsWith(publicDir + sep)) {
sendJson(res, 403, { ok: false, error: 'Forbidden' });
return;
}
try {
const content = await readFile(file);
const ext = file.slice(file.lastIndexOf('.'));
// always revalidate so a redeploy is picked up immediately (no stale JS/CSS)
res.writeHead(200, { 'Content-Type': CONTENT_TYPES[ext] ?? 'application/octet-stream', 'Cache-Control': 'no-cache' });
res.end(content);
} catch {
sendJson(res, 404, { ok: false, error: 'Not found' });
}
}
return createServer(async (req, res) => {
try {
if (req.method !== 'GET') {
sendJson(res, 405, { ok: false, error: 'Read-only dashboard: GET only' });
return;
}
const urlPath = req.url.split('?')[0];
const segments = urlPath.split('/').map((s) => decodeURIComponent(s));
if (urlPath === '/api/clis') {
const clis = await Promise.all(Object.keys(config.clis).map(async (name) => {
const entry = {
name,
refreshSeconds: config.refreshSeconds,
views: await listViews(name),
commands: Object.keys(config.clis[name].commands ?? {}),
enrich: config.clis[name].enrich ?? null,
badges: config.clis[name].badges ?? null,
summary: config.clis[name].summary ?? null,
help: !!config.clis[name].help,
};
try {
entry.resources = await discoverResources(name);
} catch (e) {
// keep last good discovery (≤TTL old) if we have one; always surface the error
entry.resources = discoveryCache.get(name)?.resources ?? [];
entry.error = e.message;
}
return entry;
}));
sendJson(res, 200, { clis });
return;
}
if (segments[1] === 'api' && segments[2] === 'r' && segments.length === 5) {
const [, , , cliName, resourceName] = segments;
if (!config.clis[cliName]) {
sendJson(res, 404, { ok: false, error: `Unknown CLI "${cliName}"` });
return;
}
const rows = await fetchRows(cliName, resourceName);
const cliCfg = config.clis[cliName];
const command = displayCmd(cliCfg.bin, cliCfg.list.map((a) => a.replaceAll('{resource}', resourceName)));
sendJson(res, 200, { ok: true, rows, command, fetchedAt: new Date().toISOString() });
return;
}
if (segments[1] === 'api' && segments[2] === 'cmd' && segments.length === 5) {
const [, , , cliName, cmdName] = segments;
if (!config.clis[cliName]) {
sendJson(res, 404, { ok: false, error: `Unknown CLI "${cliName}"` });
return;
}
const q = new URL(req.url, 'http://localhost').searchParams;
const data = await runCommand(cliName, cmdName, q.get('resource'), q.get('id'));
const tmpl = config.clis[cliName].commands?.[cmdName] ?? [];
const command = displayCmd(config.clis[cliName].bin,
tmpl.map((a) => a.replaceAll('{resource}', q.get('resource') ?? '').replaceAll('{id}', q.get('id') ?? '')));
sendJson(res, 200, { ok: true, data, command, fetchedAt: new Date().toISOString() });
return;
}
if (segments[1] === 'api' && segments[2] === 'help' && segments.length === 5) {
const [, , , cliName, resourceName] = segments;
if (!config.clis[cliName]) {
sendJson(res, 404, { ok: false, error: `Unknown CLI "${cliName}"` });
return;
}
const text = await runHelp(cliName, resourceName);
sendJson(res, 200, { ok: true, text });
return;
}
if (segments[1] === 'api' && segments[2] === 'view' && segments.length === 5) {
const [, , , cliName, viewName] = segments;
if (!config.clis[cliName]) {
sendJson(res, 404, { ok: false, error: `Unknown CLI "${cliName}"` });
return;
}
const result = await runView(cliName, viewName);
sendJson(res, 200, { ok: true, result, fetchedAt: new Date().toISOString() });
return;
}
// Log tails (allowlisted files under logs.dir).
if (urlPath === '/api/logs') {
sendJson(res, 200, { files: (config.logs?.files ?? []).map((f) => ({ name: f.name, label: f.label ?? f.name })) });
return;
}
if (segments[1] === 'api' && segments[2] === 'log' && segments.length === 4) {
const name = segments[3];
const file = config.logs?.files?.find((f) => f.name === name);
if (!file) { sendJson(res, 404, { ok: false, error: `Unknown log "${name}"` }); return; }
const lines = config.logs.tailLines ?? 400;
const { text } = await tailFile(join(config.logs.dir, name), lines);
sendJson(res, 200, { ok: true, text, command: `tail -n ${lines} ${join(config.logs.dir, name)}`, fetchedAt: new Date().toISOString() });
return;
}
// Message activity (read per-session DBs; ncl has no messages resource).
if (urlPath === '/api/activity') {
if (!config.activity) { sendJson(res, 200, { ok: true, configured: false }); return; }
const days = config.activity.days ?? 14;
const { sessions, series } = collectActivity(config.activity.sessionsRoot, days, new Date());
const command = `node:sqlite · ${config.activity.sessionsRoot}/*/*/{inbound,outbound}.db (last ${days}d)`;
sendJson(res, 200, { ok: true, configured: true, sessions, series, command, fetchedAt: new Date().toISOString() });
return;
}
// Read-only file viewer (skills, CLAUDE.md, profiles, conversations).
if (urlPath === '/api/docs') {
const docs = config.docs;
const collections = (docs?.collections ?? []).map((coll) => ({
name: coll.name,
label: coll.label ?? coll.name,
lang: coll.lang ?? 'text',
files: globFiles(docs.root, coll.patterns, docs.deny ?? []).map((path) => ({
path,
...describeFile(path),
})),
}));
sendJson(res, 200, { collections });
return;
}
if (urlPath === '/api/doc') {
const docs = config.docs;
const query = new URL(req.url, 'http://localhost').searchParams;
const collName = query.get('c');
const relPath = query.get('p') ?? '';
const collection = docs?.collections?.find((c) => c.name === collName);
if (!collection) {
sendJson(res, 404, { ok: false, error: `Unknown collection "${collName}"` });
return;
}
let abs;
try {
abs = resolveDoc(docs.root, collection, relPath, docs.deny ?? []);
} catch {
sendJson(res, 404, { ok: false, error: 'Not found' });
return;
}
const content = await readFile(abs, 'utf8');
sendJson(res, 200, {
ok: true,
path: relPath,
lang: collection.lang ?? 'text',
content: content.length > MAX_DOC_BYTES ? content.slice(0, MAX_DOC_BYTES) : content,
});
return;
}
if (urlPath.startsWith('/api/')) {
sendJson(res, 404, { ok: false, error: 'Not found' });
return;
}
await serveStatic(res, urlPath);
} catch (err) {
sendError(res, err);
}
});
}
// ---- standalone entry point ------------------------------------------------
const isMain = process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href;
if (isMain) {
const configPath = process.env.CLIDASH_CONFIG ?? join(MODULE_DIR, 'clidash.config.json');
const config = JSON.parse(readFileSync(configPath, 'utf8'));
if (process.env.PORT) config.port = Number(process.env.PORT);
if (process.env.BIND) config.bind = process.env.BIND;
const finalConfig = { ...DEFAULTS, ...config };
const server = createApp(finalConfig);
server.listen(finalConfig.port, finalConfig.bind, () => {
console.log(`clidash listening on http://${finalConfig.bind}:${finalConfig.port}`);
});
}
@@ -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);
});
});
@@ -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'));
});
@@ -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);
});
});
@@ -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/);
});
@@ -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, []);
});
});
@@ -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);
});
@@ -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 <resource> help` for detailed field information.
@@ -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') { // `<resource> help` → raw per-resource help text
process.stdout.write(`${args[0]}: help for ${args[0]}\n\nVerbs:\n list\n get <id>\n`);
process.exit(0);
}
if (process.env.STUB_RAW) {
process.stdout.write(process.env.STUB_RAW + '\n');
process.exit(0);
}
const resource = args[0];
// `get`/detail commands → single-object envelope
if (args.includes('get') || args.includes('config')) {
process.stdout.write(JSON.stringify({
id: 'req-1', ok: true,
data: { id: `${resource}-detail`, args: args.join(' '), extra: 'field' },
}) + '\n');
process.exit(0);
}
if (process.env.STUB_JSONLINES) {
process.stdout.write(JSON.stringify({ id: `${resource}-1`, name: 'row one' }) + '\n');
process.stdout.write(JSON.stringify({ id: `${resource}-2`, name: 'row two' }) + '\n');
process.exit(0);
}
process.stdout.write(JSON.stringify({
id: 'req-1',
ok: true,
data: [
{ id: `${resource}-1`, name: 'row one' },
{ id: `${resource}-2`, name: 'row two' },
],
}) + '\n');
process.exit(0);
}, sleepMs);
@@ -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);
});
});
@@ -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);
});
});
@@ -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(`<script>"&'`), '&lt;script&gt;&quot;&amp;&#39;');
});
test('mdToHtml: raw HTML in source is escaped, never passed through', () => {
const html = mdToHtml('a <script>alert(1)</script> b');
assert.ok(!html.includes('<script>'));
assert.ok(html.includes('&lt;script&gt;'));
});
// ---- the security-sensitive part: links -----------------------------------
test('mdToHtml: link href comes from the URL, label from the text', () => {
const html = mdToHtml('see [the docs](https://example.com/x)');
assert.match(html, /<a href="https:\/\/example\.com\/x" target="_blank" rel="noopener noreferrer">the docs<\/a>/);
});
test('mdToHtml: javascript: smuggled in link TEXT stays inert (never an href)', () => {
const html = mdToHtml('[javascript:alert(1)](https://safe.com)');
// href is the safe URL; the js string is only visible label text
assert.match(html, /href="https:\/\/safe\.com"/);
assert.ok(!/href="javascript:/i.test(html));
});
test('mdToHtml: a non-http(s) URL is not turned into a link', () => {
// javascript:/data: never match the (https?:...) capture, so the literal
// (escaped) markdown is left as-is — no anchor, no executable href.
const html = mdToHtml('[click](javascript:alert(1))');
assert.ok(!/<a /.test(html));
assert.ok(!/href="javascript:/i.test(html));
});
test('mdToHtml: an attribute-breakout attempt in the URL cannot escape the href', () => {
// The double-quote is escaped to &quot; before the regex runs, so it can never
// close an attribute. (Here the URL also has a space, so no anchor even forms.)
// The security property: no REAL attribute (with a literal quote) is injected.
const html = mdToHtml('[x](https://a" onmouseover="alert(1))');
assert.ok(!/<a/.test(html), 'malformed link must not produce an anchor');
assert.ok(!/onmouseover="/.test(html), 'no real (unescaped-quote) attribute injected');
});
test('mdToHtml: an escaped quote inside a matched URL stays inside the href, inert', () => {
// Even when a URL matches, any " in it is already &quot; (an entity), which
// does not terminate an HTML attribute value — so no breakout.
const html = mdToHtml('[x](https://a"onmouseover=alert)');
assert.ok(!/onmouseover="/.test(html));
if (/<a/.test(html)) assert.match(html, /href="https:\/\/a&quot;onmouseover=alert"/);
});
// ---- basic rendering sanity ----------------------------------------------
test('mdToHtml: headings, code fences, lists render', () => {
const html = mdToHtml('# Title\n\n```\ncode\n```\n\n- a\n- b');
assert.match(html, /<h1>Title<\/h1>/);
assert.match(html, /<pre class="code"><code>code<\/code><\/pre>/);
assert.match(html, /<ul><li>a<\/li><li>b<\/li><\/ul>/);
});
@@ -0,0 +1,70 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import overview from '../views/ncl-overview.js';
const minutesAgo = (m) => new Date(Date.now() - m * 60_000).toISOString();
// Shapes mirror real `ncl <resource> list --json` output.
function makeFixtures({ alphaLastActive, bravoLastActive }) {
return {
groups: [
{ id: 'ag-1', name: 'Alpha', folder: 'alpha', created_at: '2026-05-31T11:14:48.793Z' },
{ id: 'ag-2', name: 'Bravo Team', folder: 'bravo', created_at: '2026-05-31T11:14:48.796Z' },
{ id: 'ag-3', name: 'Orphan', folder: 'orphan', created_at: '2026-05-31T11:14:48.799Z' },
],
sessions: [
{ id: 'sess-1', agent_group_id: 'ag-1', messaging_group_id: 'mg-1', thread_id: null, status: 'active', container_status: 'stopped', last_active: alphaLastActive, created_at: '2026-05-31T11:14:51.911Z' },
{ id: 'sess-2', agent_group_id: 'ag-2', messaging_group_id: 'mg-2', thread_id: null, status: 'active', container_status: 'running', last_active: bravoLastActive, created_at: '2026-05-31T11:14:51.973Z' },
],
'messaging-groups': [
{ id: 'mg-1', channel_type: 'telegram', platform_id: 'telegram:1', name: 'Alpha', is_group: 0 },
{ id: 'mg-2', channel_type: 'telegram', platform_id: 'telegram:2', name: 'Bravo Team', is_group: 0 },
],
wirings: [
{ id: 'mga-1', messaging_group_id: 'mg-1', agent_group_id: 'ag-1', session_mode: 'shared' },
{ id: 'mga-2', messaging_group_id: 'mg-2', agent_group_id: 'ag-2', session_mode: 'shared' },
],
};
}
function fetchFrom(fixtures) {
return async (resource) => {
if (!(resource in fixtures)) throw new Error(`unexpected fetch: ${resource}`);
return fixtures[resource];
};
}
test('overview: one card per agent group with joined session + wiring data', async () => {
const fixtures = makeFixtures({ alphaLastActive: minutesAgo(5), bravoLastActive: minutesAgo(30) });
const result = await overview({ fetch: fetchFrom(fixtures) });
assert.equal(result.cards.length, 3);
const alpha = result.cards.find((c) => c.title === 'Alpha');
assert.equal(alpha.subtitle, 'alpha');
assert.equal(alpha.fields.container, 'stopped');
assert.equal(alpha.fields.sessions, 1);
assert.deepEqual(alpha.badges, ['telegram: Alpha']);
const bravo = result.cards.find((c) => c.title === 'Bravo Team');
assert.equal(bravo.fields.container, 'running');
assert.deepEqual(bravo.badges, ['telegram: Bravo Team']);
});
test('overview: staleness thresholds — green <15m, amber <2h, red older, gray never', async () => {
const fixtures = makeFixtures({ alphaLastActive: minutesAgo(5), bravoLastActive: minutesAgo(30) });
const result = await overview({ fetch: fetchFrom(fixtures) });
assert.equal(result.cards.find((c) => c.title === 'Alpha').status, 'green');
assert.equal(result.cards.find((c) => c.title === 'Bravo Team').status, 'amber');
assert.equal(result.cards.find((c) => c.title === 'Orphan').status, 'gray');
const stale = makeFixtures({ alphaLastActive: minutesAgo(300), bravoLastActive: minutesAgo(30) });
const result2 = await overview({ fetch: fetchFrom(stale) });
assert.equal(result2.cards.find((c) => c.title === 'Alpha').status, 'red');
});
test('overview: last_active is exposed for relative-time rendering', async () => {
const ts = minutesAgo(5);
const fixtures = makeFixtures({ alphaLastActive: ts, bravoLastActive: minutesAgo(30) });
const result = await overview({ fetch: fetchFrom(fixtures) });
assert.equal(result.cards.find((c) => c.title === 'Alpha').fields['last active'], ts);
});
@@ -0,0 +1,111 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { discoveryParsers, parseOutput, unwrapPath } from '../parsers.js';
const fixture = readFileSync(
fileURLToPath(new URL('./fixtures/ncl-help.txt', import.meta.url)),
'utf8',
);
// ---------------------------------------------------------------- ncl-help
test('ncl-help: parses all listable resources from real captured output', () => {
const resources = discoveryParsers['ncl-help'](fixture);
assert.deepEqual(
resources.map((r) => r.name),
[
'approvals', 'destinations', 'dropped-messages', 'groups', 'members',
'messaging-groups', 'roles', 'sessions', 'user-dms', 'users', 'wirings',
],
);
});
test('ncl-help: every parsed resource has a non-empty description and a list verb', () => {
const resources = discoveryParsers['ncl-help'](fixture);
for (const r of resources) {
assert.ok(r.description.length > 0, `${r.name} has empty description`);
assert.ok(r.verbs.includes('list'), `${r.name} missing list verb`);
}
});
test('ncl-help: parses verbs correctly, including multi-word verbs', () => {
const resources = discoveryParsers['ncl-help'](fixture);
const groups = resources.find((r) => r.name === 'groups');
assert.deepEqual(groups.verbs, [
'list', 'get', 'create', 'update', 'delete', 'restart',
'config get', 'config update', 'config add-mcp-server',
'config remove-mcp-server', 'config add-package', 'config remove-package',
]);
});
test('ncl-help: excludes resources without a list verb', () => {
const input = [
'Resources:',
' alpha Has list.',
' verbs: list, get',
' beta No list here.',
' verbs: grant, revoke',
'',
].join('\n');
const resources = discoveryParsers['ncl-help'](input);
assert.deepEqual(resources.map((r) => r.name), ['alpha']);
});
test('ncl-help: ignores the Commands section (help is not a resource)', () => {
const resources = discoveryParsers['ncl-help'](fixture);
assert.ok(!resources.some((r) => r.name === 'help'));
});
test('ncl-help: throws loudly on unrecognized format', () => {
assert.throws(() => discoveryParsers['ncl-help']('totally not help output'), /Resources/);
assert.throws(() => discoveryParsers['ncl-help'](''), /Resources/);
});
// ------------------------------------------------------------- parseOutput
test('parseOutput json: parses a single document', () => {
assert.deepEqual(parseOutput('{"a": 1}', 'json'), { a: 1 });
});
test('parseOutput json: throws on malformed input with raw output preserved', () => {
assert.throws(() => parseOutput('not json', 'json'), (err) => {
assert.match(err.message, /JSON/i);
assert.equal(err.raw, 'not json');
return true;
});
});
test('parseOutput jsonlines: one object per line, blank lines skipped', () => {
const text = '{"id":1}\n\n{"id":2}\n{"id":3}\n';
assert.deepEqual(parseOutput(text, 'jsonlines'), [{ id: 1 }, { id: 2 }, { id: 3 }]);
});
test('parseOutput jsonlines: throws on a malformed line', () => {
assert.throws(() => parseOutput('{"ok":1}\ngarbage\n', 'jsonlines'), /line 2/i);
});
test('parseOutput: rejects unknown format', () => {
assert.throws(() => parseOutput('{}', 'xml'), /format/i);
});
// -------------------------------------------------------------- unwrapPath
test('unwrapPath: extracts the ncl {id, ok, data} envelope', () => {
const doc = { id: 'x', ok: true, data: [{ id: 'sess-1' }] };
assert.deepEqual(unwrapPath(doc, 'data'), [{ id: 'sess-1' }]);
});
test('unwrapPath: supports nested dot paths', () => {
assert.deepEqual(unwrapPath({ a: { b: [1, 2] } }, 'a.b'), [1, 2]);
});
test('unwrapPath: throws when the path is missing', () => {
assert.throws(() => unwrapPath({ ok: true }, 'data'), /data/);
});
test('unwrapPath: no path returns the value unchanged', () => {
const rows = [{ id: 1 }];
assert.equal(unwrapPath(rows, undefined), rows);
});
@@ -0,0 +1,240 @@
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createApp } from '../server.js';
const STUB = fileURLToPath(new URL('./fixtures/stub-cli.js', import.meta.url));
const tmp = mkdtempSync(join(tmpdir(), 'clidash-test-'));
function stubCli(extra = {}) {
return {
bin: process.execPath,
discover: { args: [STUB, 'help'], parser: 'ncl-help' },
list: [STUB, '{resource}', 'list', '--json'],
output: 'json',
unwrap: 'data',
...extra,
};
}
function makeConfig(clis, extra = {}) {
return { port: 0, bind: '127.0.0.1', execTimeoutMs: 2000, refreshSeconds: 10, clis, ...extra };
}
async function withServer(config, fn) {
const server = createApp(config);
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const base = `http://127.0.0.1:${server.address().port}`;
try {
return await fn(base);
} finally {
await new Promise((resolve) => server.close(resolve));
}
}
after(() => rmSync(tmp, { recursive: true, force: true }));
// ----------------------------------------------------------------- /api/clis
test('/api/clis: lists configured CLIs with discovered resources', async () => {
await withServer(makeConfig({ stub: stubCli() }), async (base) => {
const res = await fetch(`${base}/api/clis`);
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.clis.length, 1);
assert.equal(body.clis[0].name, 'stub');
assert.equal(body.clis[0].refreshSeconds, 10);
const names = body.clis[0].resources.map((r) => r.name);
assert.ok(names.includes('sessions'));
assert.ok(names.includes('groups'));
assert.equal(names.length, 11);
});
});
test('/api/clis: static resource list needs no discovery', async () => {
const cli = stubCli({ resources: ['alpha', 'beta'] });
delete cli.discover;
await withServer(makeConfig({ stub: cli }), async (base) => {
const body = await (await fetch(`${base}/api/clis`)).json();
assert.deepEqual(body.clis[0].resources.map((r) => r.name), ['alpha', 'beta']);
});
});
test('/api/clis: discovery failure reports a loud error', async () => {
const cli = stubCli();
cli.env = { STUB_FAIL: '1' };
await withServer(makeConfig({ stub: cli }), async (base) => {
const body = await (await fetch(`${base}/api/clis`)).json();
assert.equal(body.clis[0].resources.length, 0);
assert.match(body.clis[0].error, /boom/);
});
});
// ------------------------------------------------------------ /api/r/cli/res
test('/api/r: returns unwrapped rows with fetchedAt', async () => {
await withServer(makeConfig({ stub: stubCli() }), async (base) => {
const res = await fetch(`${base}/api/r/stub/sessions`);
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.ok, true);
assert.deepEqual(body.rows.map((r) => r.id), ['sessions-1', 'sessions-2']);
assert.ok(body.fetchedAt);
});
});
test('/api/r: rejects a resource not in the discovered set without exec', async () => {
const countFile = join(tmp, 'count-reject.txt');
const cli = stubCli();
cli.env = { STUB_COUNT_FILE: countFile };
await withServer(makeConfig({ stub: cli }), async (base) => {
const res = await fetch(`${base}/api/r/stub/evil%20--rm`);
assert.equal(res.status, 404);
const body = await res.json();
assert.equal(body.ok, false);
// only the discovery exec ran — never a list exec for the bogus resource
const calls = readFileSync(countFile, 'utf8').trim().split('\n');
assert.deepEqual(calls, ['help']);
});
});
test('/api/r: unknown cli → 404', async () => {
await withServer(makeConfig({ stub: stubCli() }), async (base) => {
const res = await fetch(`${base}/api/r/nope/sessions`);
assert.equal(res.status, 404);
});
});
test('/api/r: jsonlines CLI with static resources works', async () => {
const cli = {
bin: process.execPath,
resources: ['ps'],
list: [STUB, '{resource}'],
output: 'jsonlines',
env: { STUB_JSONLINES: '1' },
};
await withServer(makeConfig({ docker: cli }), async (base) => {
const body = await (await fetch(`${base}/api/r/docker/ps`)).json();
assert.equal(body.ok, true);
assert.deepEqual(body.rows.map((r) => r.id), ['ps-1', 'ps-2']);
});
});
test('/api/r: exec failure returns ok:false with stderr', async () => {
const cli = stubCli({ resources: ['sessions'] });
delete cli.discover;
cli.env = { STUB_FAIL: '1' };
await withServer(makeConfig({ stub: cli }), async (base) => {
const res = await fetch(`${base}/api/r/stub/sessions`);
assert.equal(res.status, 502);
const body = await res.json();
assert.equal(body.ok, false);
assert.match(body.error, /boom: socket down/);
});
});
test('/api/r: exec timeout returns ok:false naming the resource', async () => {
const cli = stubCli({ resources: ['sessions'] });
delete cli.discover;
cli.env = { STUB_SLEEP_MS: '5000' };
await withServer(makeConfig({ stub: cli }, { execTimeoutMs: 200 }), async (base) => {
const body = await (await fetch(`${base}/api/r/stub/sessions`)).json();
assert.equal(body.ok, false);
assert.match(body.error, /sessions/);
assert.match(body.error, /timed out/i);
});
});
test('/api/r: malformed CLI output returns the raw output', async () => {
const cli = stubCli({ resources: ['sessions'] });
delete cli.discover;
cli.env = { STUB_RAW: 'this is not json' };
await withServer(makeConfig({ stub: cli }), async (base) => {
const body = await (await fetch(`${base}/api/r/stub/sessions`)).json();
assert.equal(body.ok, false);
assert.match(body.raw, /this is not json/);
});
});
test('/api/r: concurrent requests for the same resource coalesce into one exec', async () => {
const countFile = join(tmp, 'count-coalesce.txt');
const cli = stubCli({ resources: ['sessions'] });
delete cli.discover;
cli.env = { STUB_COUNT_FILE: countFile, STUB_SLEEP_MS: '150' };
await withServer(makeConfig({ stub: cli }), async (base) => {
const bodies = await Promise.all(
Array.from({ length: 5 }, () => fetch(`${base}/api/r/stub/sessions`).then((r) => r.json())),
);
for (const body of bodies) assert.equal(body.ok, true);
const calls = readFileSync(countFile, 'utf8').trim().split('\n');
assert.equal(calls.length, 1);
});
});
// ------------------------------------------------------------- /api/view
test('/api/view: runs a view plugin with a bound fetch helper', async () => {
const viewsDir = join(tmp, 'views');
writeFileSync(join(viewsDir, '..', 'placeholder'), ''); // ensure tmp exists
const { mkdirSync } = await import('node:fs');
mkdirSync(viewsDir, { recursive: true });
writeFileSync(
join(viewsDir, 'stub-overview.js'),
'export default async function ({ fetch }) {\n' +
' const rows = await fetch("sessions");\n' +
' return { count: rows.length, first: rows[0].id };\n' +
'}\n',
);
await withServer(makeConfig({ stub: stubCli() }, { viewsDir }), async (base) => {
const res = await fetch(`${base}/api/view/stub/overview`);
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.ok, true);
assert.deepEqual(body.result, { count: 2, first: 'sessions-1' });
});
});
test('/api/view: missing view → 404; bad view name → 404', async () => {
await withServer(makeConfig({ stub: stubCli() }, { viewsDir: join(tmp, 'views') }), async (base) => {
assert.equal((await fetch(`${base}/api/view/stub/nope`)).status, 404);
assert.equal((await fetch(`${base}/api/view/stub/..%2F..%2Fserver`)).status, 404);
});
});
// ------------------------------------------------------------- static files
test('GET /: serves the dashboard index.html', async () => {
await withServer(makeConfig({ stub: stubCli() }), async (base) => {
const res = await fetch(`${base}/`);
assert.equal(res.status, 200);
assert.match(res.headers.get('content-type'), /text\/html/);
assert.match(await res.text(), /clidash/i);
});
});
test('static: path traversal outside public/ is rejected', async () => {
await withServer(makeConfig({ stub: stubCli() }), async (base) => {
const res = await fetch(`${base}/..%2Fserver.js`);
assert.notEqual(res.status, 200);
});
});
test('/api/r: {resource} substitutes inside a larger argv string (ssh-remote pattern)', async () => {
const cli = {
bin: process.execPath,
resources: ['sessions'],
list: [STUB, 'wrapped-{resource}-arg', 'list'],
output: 'json',
unwrap: 'data',
env: { STUB_COUNT_FILE: join(tmp, 'count-embed.txt') },
};
await withServer(makeConfig({ stub: cli }), async (base) => {
const body = await (await fetch(`${base}/api/r/stub/sessions`)).json();
assert.equal(body.ok, true);
const calls = readFileSync(join(tmp, 'count-embed.txt'), 'utf8').trim();
assert.equal(calls, 'wrapped-sessions-arg list');
});
});
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Smoke test against a running clidash instance (run on the VM after deploy).
# Usage: ./test/smoke.sh [base-url] (default http://127.0.0.1:4690)
set -euo pipefail
BASE="${1:-http://127.0.0.1:4690}"
check() {
local label="$1" url="$2" pattern="$3"
if curl -fsS --max-time 15 "$url" | grep -q "$pattern"; then
echo "OK $label"
else
echo "FAIL $label ($url did not match $pattern)"
exit 1
fi
}
check "/api/clis" "$BASE/api/clis" '"resources"'
check "/api/r/ncl/sessions" "$BASE/api/r/ncl/sessions" '"ok":true'
check "/api/view/ncl/overview" "$BASE/api/view/ncl/overview" '"ok":true'
check "GET / (static UI)" "$BASE/" 'clidash'
echo "smoke: all good"
@@ -0,0 +1,60 @@
// Curated "Agents overview" view for ncl: joins groups + sessions +
// messaging-groups + wirings into per-agent cards. Returns the generic
// card shape the frontend renders, so the UI itself stays CLI-agnostic:
// { title, cards: [{ title, subtitle, status, fields, badges }] }
// status: green <15m since last_active, amber <2h, red older, gray never.
const GREEN_MAX_MIN = 15;
const AMBER_MAX_MIN = 120;
function staleness(lastActive) {
if (!lastActive) return 'gray';
const ageMin = (Date.now() - new Date(lastActive).getTime()) / 60_000;
if (ageMin < GREEN_MAX_MIN) return 'green';
if (ageMin < AMBER_MAX_MIN) return 'amber';
return 'red';
}
export default async function overview({ fetch }) {
const [groups, sessions, messagingGroups, wirings] = await Promise.all([
fetch('groups'),
fetch('sessions'),
fetch('messaging-groups'),
fetch('wirings'),
]);
const mgById = new Map(messagingGroups.map((mg) => [mg.id, mg]));
const cards = groups.map((group) => {
const groupSessions = sessions.filter((s) => s.agent_group_id === group.id);
const lastActive = groupSessions
.map((s) => s.last_active)
.filter(Boolean)
.sort()
.at(-1) ?? null;
const container = groupSessions.some((s) => s.container_status === 'running')
? 'running'
: groupSessions[0]?.container_status ?? 'none';
const badges = wirings
.filter((w) => w.agent_group_id === group.id)
.map((w) => {
const mg = mgById.get(w.messaging_group_id);
return mg ? `${mg.channel_type}: ${mg.name ?? mg.platform_id}` : w.messaging_group_id;
});
return {
title: group.name,
subtitle: group.folder,
status: staleness(lastActive),
fields: {
container,
sessions: groupSessions.length,
'last active': lastActive,
},
badges,
};
});
return { title: 'Agents overview', cards };
}
@@ -3,4 +3,3 @@
// level. Skills add a new provider by appending one import line below.
import './claude.js';
import './mock.js';
+1 -1
View File
@@ -31,7 +31,7 @@ flowchart TB
subgraph Session["Per-Session Container (Docker / Apple Container)"]
direction TB
PollLoop["Poll Loop<br/>(container/agent-runner)"]
Provider["Agent providers<br/>(claude, opencode, mock; todo: codex)"]
Provider["Agent providers<br/>(claude, opencode; todo: codex)"]
MCP["MCP Tools<br/>send_message, send_file, edit_message,<br/>add_reaction, send_card, ask_user_question,<br/>schedule_task, create_agent,<br/>install_packages, add_mcp_server"]
Skills["Container Skills<br/>(container/skills/)"]
InDB[("inbound.db<br/>host writes<br/>even seq<br/>messages_in<br/>destinations<br/>processing_ack")]
-166
View File
@@ -1,166 +0,0 @@
# Main
You are Main, a personal assistant. You help with tasks, answer questions, and can schedule reminders.
## What You Can Do
- Answer questions and have conversations
- Search the web and fetch content from URLs
- **Browse the web** with `agent-browser` — open pages, click, fill forms, take screenshots, extract data (run `agent-browser open <url>` to start, then `agent-browser snapshot -i` to see interactive elements)
- Read and write files in your workspace
- Run bash commands in your sandbox
- Schedule tasks to run later or on a recurring basis
- Send messages back to the chat
## Communication
Be concise — every message costs the reader's attention.
### Destinations
Each turn, your system prompt lists the destinations available to you. If you only have one destination, just write your response directly — it goes there automatically. If you have multiple, wrap each message in a `<message to="name">...</message>` block:
```
<message to="family">On my way home, 15 minutes</message>
<message to="worker-1">kick off the pipeline</message>
```
Inbound messages are labeled with `from="name"` so you can tell which destination they came from and reply using that same name.
### Mid-turn updates
Use the `mcp__nanoclaw__send_message` tool to send a message mid-work (before your final output). If you have one destination, `to` is optional; with multiple, specify it. Pace your updates to the length of the work:
- **Short work (a few seconds, ≤2 quick tool calls):** Don't narrate. Just do it and put the result in your final response.
- **Longer work (many tool calls, web searches, installs, sub-agents):** Send a short acknowledgment right away ("On it — checking the logs now") so the user knows you got the message.
- **Long-running work (many minutes, multi-step tasks):** Send periodic updates at natural milestones, and especially **before** slow operations like spinning up an explore sub-agent, downloading large files, or installing packages.
**Never narrate micro-steps.** "I'm going to read the file now… okay, I'm reading it… now I'm parsing it…" is noise. Updates should mark meaningful transitions, not every tool call.
**Outcomes, not play-by-play.** When the work is done, the final message should be about the result, not a transcript of what you did.
### Internal thoughts
Wrap reasoning in `<internal>...</internal>` tags to mark it as scratchpad — logged but not sent. With multiple destinations, any text outside of `<message>` blocks is also treated as scratchpad. With a single destination, only explicit `<internal>` tags are scratchpad; the rest of your response is sent.
```
<internal>Compiled all three reports, ready to summarize.</internal>
Here are the key findings from the research…
```
### Sub-agents and teammates
When working as a sub-agent or teammate, only use `send_message` if instructed to by the main agent.
## Your Workspace
Files you create are saved in `/workspace/group/`. Use this for notes, research, or anything that should persist.
## Memory
The `conversations/` folder contains searchable history of past conversations. Use this to recall context from previous sessions.
When you learn something important:
- Create files for structured data (e.g., `customers.md`, `preferences.md`)
- Split files larger than 500 lines into folders
- Keep an index in your memory for the files you create
## Message Formatting
Format messages based on the channel you're responding to. Check your group folder name:
### Slack channels (folder starts with `slack_`)
Use Slack mrkdwn syntax. Run `/slack-formatting` for the full reference. Key rules:
- `*bold*` (single asterisks)
- `_italic_` (underscores)
- `<https://url|link text>` for links (NOT `[text](url)`)
- `•` bullets (no numbered lists)
- `:emoji:` shortcodes
- `>` for block quotes
- No `##` headings — use `*Bold text*` instead
### WhatsApp/Telegram channels (folder starts with `whatsapp_` or `telegram_`)
- `*bold*` (single asterisks, NEVER **double**)
- `_italic_` (underscores)
- `•` bullet points
- ` ``` ` code blocks
No `##` headings. No `[links](url)`. No `**double stars**`.
### Discord channels (folder starts with `discord_`)
Standard Markdown works: `**bold**`, `*italic*`, `[links](url)`, `# headings`.
---
## Installing Packages & Tools
Your container is ephemeral — anything installed via `apt-get` or `pnpm install -g` is lost on restart. To install packages that persist, use the self-modification tools:
1. **`install_packages`** — request system (apt) or global npm packages. Requires admin approval.
2. **`request_rebuild`** — rebuild your container image so approved packages are baked in. Always call this after `install_packages` to apply the changes.
Example flow:
```
install_packages({ apt: ["ffmpeg"], npm: ["@xenova/transformers"], reason: "Audio transcription" })
# → Admin gets an approval card → approves
request_rebuild({ reason: "Apply ffmpeg + transformers" })
# → Admin approves → image rebuilt with the packages
```
**When to use this vs workspace pnpm install:**
- `pnpm install` in `/workspace/agent/` persists on disk (it's mounted) but isn't on the global PATH — use it for project-level dependencies
- `install_packages` is for system tools (ffmpeg, imagemagick) and global npm packages that need to be on PATH
### MCP Servers
Use **`add_mcp_server`** to add an MCP server to your configuration, then **`request_rebuild`** to apply. Browse available servers at https://mcp.so — it's a curated directory of high-quality MCP servers. Most Node.js servers run via `pnpm dlx`, e.g.:
```
add_mcp_server({ name: "memory", command: "pnpm", args: ["dlx", "@modelcontextprotocol/server-memory"] })
request_rebuild({ reason: "Add memory MCP server" })
```
## Task Scripts
For any recurring task, use `schedule_task`. This is the scheduling path — tasks persist across sessions and restarts, and support the pre-task `script` hook described below. Other scheduling tools you might discover (e.g. `CronCreate`, `ScheduleWakeup`) are session-scoped SDK builtins and won't behave the way NanoClaw users expect, so stick with `schedule_task`.
To inspect or change existing tasks, use `list_tasks` (returns one row per series with the stable id) and `update_task` / `cancel_task` / `pause_task` / `resume_task`. Prefer `update_task` over cancel + reschedule — it preserves the series id the user already knows.
Frequent agent invocations — especially multiple times a day — consume API credits and can risk account restrictions. If a simple check can determine whether action is needed, add a `script` — it runs first, and the agent is only called when the check passes. This keeps invocations to a minimum.
### How it works
1. You provide a bash `script` alongside the `prompt` when scheduling
2. When the task fires, the script runs first (30-second timeout)
3. Script prints JSON to stdout: `{ "wakeAgent": true/false, "data": {...} }`
4. If `wakeAgent: false` — nothing happens, task waits for next run
5. If `wakeAgent: true` — you wake up and receive the script's data + prompt
### Always test your script first
Before scheduling, run the script in your sandbox to verify it works:
```bash
bash -c 'node --input-type=module -e "
const r = await fetch(\"https://api.github.com/repos/owner/repo/pulls?state=open\");
const prs = await r.json();
console.log(JSON.stringify({ wakeAgent: prs.length > 0, data: prs.slice(0, 5) }));
"'
```
### When NOT to use scripts
If a task requires your judgment every time (daily briefings, reminders, reports), skip the script — just use a regular prompt.
### Frequent task guidance
If a user wants tasks running more than ~2x daily and a script can't reduce agent wake-ups:
- Explain that each wake-up uses API credits and risks rate limits
- Suggest restructuring with a script that checks the condition first
- If the user needs an LLM to evaluate data, suggest using an API key with direct Anthropic API calls inside the script
- Help the user find the minimum viable frequency
-312
View File
@@ -1,312 +0,0 @@
@./.claude-global.md
# Main
You are Main, a personal assistant. You help with tasks, answer questions, and can schedule reminders.
## What You Can Do
- Answer questions and have conversations
- Search the web and fetch content from URLs
- **Browse the web** with `agent-browser` — open pages, click, fill forms, take screenshots, extract data (run `agent-browser open <url>` to start, then `agent-browser snapshot -i` to see interactive elements)
- Read and write files in your workspace
- Run bash commands in your sandbox
- Schedule tasks to run later or on a recurring basis
- Send messages back to the chat
## Communication
Your output is sent to the user or group.
You also have `mcp__nanoclaw__send_message` which sends a message immediately while you're still working. This is useful when you want to acknowledge a request before starting longer work.
### Internal thoughts
If part of your output is internal reasoning rather than something for the user, wrap it in `<internal>` tags:
```
<internal>Compiled all three reports, ready to summarize.</internal>
Here are the key findings from the research...
```
Text inside `<internal>` tags is logged but not sent to the user. If you've already sent the key information via `send_message`, you can wrap the recap in `<internal>` to avoid sending it again.
### Sub-agents and teammates
When working as a sub-agent or teammate, only use `send_message` if instructed to by the main agent.
## Memory
The `conversations/` folder contains searchable history of past conversations. Use this to recall context from previous sessions.
When you learn something important:
- Create files for structured data (e.g., `customers.md`, `preferences.md`)
- Split files larger than 500 lines into folders
- Keep an index in your memory for the files you create
## Message Formatting
Format messages based on the channel. Check the group folder name prefix:
### Slack channels (folder starts with `slack_`)
Use Slack mrkdwn syntax. Run `/slack-formatting` for the full reference. Key rules:
- `*bold*` (single asterisks)
- `_italic_` (underscores)
- `<https://url|link text>` for links (NOT `[text](url)`)
- `•` bullets (no numbered lists)
- `:emoji:` shortcodes like `:white_check_mark:`, `:rocket:`
- `>` for block quotes
- No `##` headings — use `*Bold text*` instead
### WhatsApp/Telegram (folder starts with `whatsapp_` or `telegram_`)
- `*bold*` (single asterisks, NEVER **double**)
- `_italic_` (underscores)
- `•` bullet points
- ` ``` ` code blocks
No `##` headings. No `[links](url)`. No `**double stars**`.
### Discord (folder starts with `discord_`)
Standard Markdown: `**bold**`, `*italic*`, `[links](url)`, `# headings`.
---
## Admin Context
This is the **main channel**, which has elevated privileges.
## Authentication
Anthropic credentials must be either an API key from console.anthropic.com (`ANTHROPIC_API_KEY`) or a long-lived OAuth token from `claude setup-token` (`CLAUDE_CODE_OAUTH_TOKEN`). Short-lived tokens from the system keychain or `~/.claude/.credentials.json` expire within hours and can cause recurring container 401s. The `/setup` skill walks through this. OneCLI manages credentials (including Anthropic auth) — run `onecli --help`.
## Container Mounts
Main has read-only access to the project, read-write access to the store (SQLite DB), and read-write access to its group folder:
| Container Path | Host Path | Access |
|----------------|-----------|--------|
| `/workspace/project` | Project root | read-only |
| `/workspace/project/store` | `store/` | read-write |
| `/workspace/group` | `groups/main/` | read-write |
Key paths inside the container:
- `/workspace/project/store/messages.db` - SQLite database (read-write)
- `/workspace/project/store/messages.db` (registered_groups table) - Group config
- `/workspace/project/groups/` - All group folders
---
## Managing Groups
### Finding Available Groups
Available groups are provided in `/workspace/ipc/available_groups.json`:
```json
{
"groups": [
{
"jid": "120363336345536173@g.us",
"name": "Family Chat",
"lastActivity": "2026-01-31T12:00:00.000Z",
"isRegistered": false
}
],
"lastSync": "2026-01-31T12:00:00.000Z"
}
```
Groups are ordered by most recent activity. The list is synced from WhatsApp daily.
If a group the user mentions isn't in the list, request a fresh sync:
```bash
echo '{"type": "refresh_groups"}' > /workspace/ipc/tasks/refresh_$(date +%s).json
```
Then wait a moment and re-read `available_groups.json`.
**Fallback**: Query the SQLite database directly:
```bash
sqlite3 /workspace/project/store/messages.db "
SELECT jid, name, last_message_time
FROM chats
WHERE jid LIKE '%@g.us' AND jid != '__group_sync__'
ORDER BY last_message_time DESC
LIMIT 10;
"
```
### Registered Groups Config
Groups are registered in the SQLite `registered_groups` table:
```json
{
"1234567890-1234567890@g.us": {
"name": "Family Chat",
"folder": "whatsapp_family-chat",
"trigger": "@Andy",
"added_at": "2024-01-31T12:00:00.000Z"
}
}
```
Fields:
- **Key**: The chat JID (unique identifier — WhatsApp, Telegram, Slack, Discord, etc.)
- **name**: Display name for the group
- **folder**: Channel-prefixed folder name under `groups/` for this group's files and memory
- **trigger**: The trigger word (usually same as global, but could differ)
- **requiresTrigger**: Whether `@trigger` prefix is needed (default: `true`). Set to `false` for solo/personal chats where all messages should be processed
- **isMain**: Whether this is the main control group (elevated privileges, no trigger required)
- **added_at**: ISO timestamp when registered
### Trigger Behavior
- **Main group** (`isMain: true`): No trigger needed — all messages are processed automatically
- **Groups with `requiresTrigger: false`**: No trigger needed — all messages processed (use for 1-on-1 or solo chats)
- **Other groups** (default): Messages must start with `@AssistantName` to be processed
### Adding a Group
1. Query the database to find the group's JID
2. Ask the user whether the group should require a trigger word before registering
3. Use the `register_group` MCP tool with the JID, name, folder, trigger, and the chosen `requiresTrigger` setting
4. Optionally include `containerConfig` for additional mounts
5. The group folder is created automatically: `/workspace/project/groups/{folder-name}/`
6. Optionally create an initial `CLAUDE.md` for the group
Folder naming convention — channel prefix with underscore separator:
- WhatsApp "Family Chat" → `whatsapp_family-chat`
- Telegram "Dev Team" → `telegram_dev-team`
- Discord "General" → `discord_general`
- Slack "Engineering" → `slack_engineering`
- Use lowercase, hyphens for the group name part
#### Adding Additional Directories for a Group
Groups can have extra directories mounted. Add `containerConfig` to their entry:
```json
{
"1234567890@g.us": {
"name": "Dev Team",
"folder": "dev-team",
"trigger": "@Andy",
"added_at": "2026-01-31T12:00:00Z",
"containerConfig": {
"additionalMounts": [
{
"hostPath": "~/projects/webapp",
"containerPath": "webapp",
"readonly": false
}
]
}
}
}
```
The directory will appear at `/workspace/extra/webapp` in that group's container.
#### Sender Allowlist
After registering a group, explain the sender allowlist feature to the user:
> This group can be configured with a sender allowlist to control who can interact with me. There are two modes:
>
> - **Trigger mode** (default): Everyone's messages are stored for context, but only allowed senders can trigger me with @{AssistantName}.
> - **Drop mode**: Messages from non-allowed senders are not stored at all.
>
> For closed groups with trusted members, I recommend setting up an allow-only list so only specific people can trigger me. Want me to configure that?
If the user wants to set up an allowlist, edit `~/.config/nanoclaw/sender-allowlist.json` on the host:
```json
{
"default": { "allow": "*", "mode": "trigger" },
"chats": {
"<chat-jid>": {
"allow": ["sender-id-1", "sender-id-2"],
"mode": "trigger"
}
},
"logDenied": true
}
```
Notes:
- Your own messages (`is_from_me`) explicitly bypass the allowlist in trigger checks. Bot messages are filtered out by the database query before trigger evaluation, so they never reach the allowlist.
- If the config file doesn't exist or is invalid, all senders are allowed (fail-open)
- The config file is on the host at `~/.config/nanoclaw/sender-allowlist.json`, not inside the container
### Removing a Group
1. Read `/workspace/project/data/registered_groups.json`
2. Remove the entry for that group
3. Write the updated JSON back
4. The group folder and its files remain (don't delete them)
### Listing Groups
Read `/workspace/project/data/registered_groups.json` and format it nicely.
---
## Global Memory
You can read and write to `/workspace/global/CLAUDE.md` for facts that should apply to all groups. Only update global memory when explicitly asked to "remember this globally" or similar.
---
## Scheduling for Other Groups
When scheduling tasks for other groups, use the `target_group_jid` parameter with the group's JID from `registered_groups.json`:
- `schedule_task(prompt: "...", schedule_type: "cron", schedule_value: "0 9 * * 1", target_group_jid: "120363336345536173@g.us")`
The task will run in that group's context with access to their files and memory.
---
## Task Scripts
For any recurring task, use `schedule_task`. Frequent agent invocations — especially multiple times a day — consume API credits and can risk account restrictions. If a simple check can determine whether action is needed, add a `script` — it runs first, and the agent is only called when the check passes. This keeps invocations to a minimum.
Use `list_tasks` to see existing tasks (one row per series with the stable id), and `update_task` / `cancel_task` / `pause_task` / `resume_task` to modify them. Prefer `update_task` over cancel + reschedule when adjusting an existing task.
### How it works
1. You provide a bash `script` alongside the `prompt` when scheduling
2. When the task fires, the script runs first (30-second timeout)
3. Script prints JSON to stdout: `{ "wakeAgent": true/false, "data": {...} }`
4. If `wakeAgent: false` — nothing happens, task waits for next run
5. If `wakeAgent: true` — you wake up and receive the script's data + prompt
### Always test your script first
Before scheduling, run the script in your sandbox to verify it works:
```bash
bash -c 'node --input-type=module -e "
const r = await fetch(\"https://api.github.com/repos/owner/repo/pulls?state=open\");
const prs = await r.json();
console.log(JSON.stringify({ wakeAgent: prs.length > 0, data: prs.slice(0, 5) }));
"'
```
### When NOT to use scripts
If a task requires your judgment every time (daily briefings, reminders, reports), skip the script — just use a regular prompt.
### Frequent task guidance
If a user wants tasks running more than ~2x daily and a script can't reduce agent wake-ups:
- Explain that each wake-up uses API credits and risks rate limits
- Suggest restructuring with a script that checks the condition first
- If the user needs an LLM to evaluate data, suggest using an API key with direct Anthropic API calls inside the script
- Help the user find the minimum viable frequency
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
"version": "2.1.24",
"version": "2.1.30",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"packageManager": "pnpm@10.33.0",
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="204k tokens, 102% of context window">
<title>204k tokens, 102% of context window</title>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="207k tokens, 104% of context window">
<title>207k tokens, 104% of context window</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,8 +15,8 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
<text x="26" y="14">tokens</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">204k</text>
<text x="71" y="14">204k</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">207k</text>
<text x="71" y="14">207k</text>
</g>
</g>
</a>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+76
View File
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest';
import { normalizeOption, normalizeOptions } from './ask-question.js';
describe('normalizeOption — style whitelist', () => {
// The style value flows straight into the Chat SDK Button() and from there
// into Slack Block Kit. Slack rejects the *entire* message with
// invalid_blocks if a button carries an unknown style, which in the
// approval flow means the card never renders — an effective auto-deny.
// So anything outside the whitelist must drop to undefined here.
it.each(['primary', 'danger', 'default'] as const)('passes through the known style %j', (style) => {
expect(normalizeOption({ label: 'Approve', style }).style).toBe(style);
});
it('drops unknown style strings to undefined', () => {
for (const bad of ['success', 'warning', 'PRIMARY', 'Danger', ' primary', 'primary ', '', 'red']) {
const opt = normalizeOption({ label: 'Approve', style: bad as never });
expect(opt.style, `style ${JSON.stringify(bad)} should be dropped`).toBeUndefined();
}
});
it('drops non-string style values to undefined', () => {
for (const bad of [1, true, null, {}, ['primary']]) {
const opt = normalizeOption({ label: 'Approve', style: bad as never });
expect(opt.style, `style ${JSON.stringify(bad)} should be dropped`).toBeUndefined();
}
});
it('leaves style undefined when the object option omits it', () => {
expect(normalizeOption({ label: 'Approve' }).style).toBeUndefined();
});
it('gives string-shorthand options no style', () => {
const opt = normalizeOption('Approve');
expect(opt).toEqual({ label: 'Approve', selectedLabel: 'Approve', value: 'Approve' });
expect('style' in opt && opt.style !== undefined).toBe(false);
});
it('style coexists with the label/selectedLabel/value defaulting', () => {
// Defaults still fill in around an explicit style…
expect(normalizeOption({ label: 'Approve', style: 'primary' })).toEqual({
label: 'Approve',
selectedLabel: 'Approve',
value: 'Approve',
style: 'primary',
});
// …and explicit fields are untouched by the style whitelist.
expect(normalizeOption({ label: 'Deny', selectedLabel: 'Denied', value: 'deny-1', style: 'danger' })).toEqual({
label: 'Deny',
selectedLabel: 'Denied',
value: 'deny-1',
style: 'danger',
});
// An invalid style must not disturb the rest of the normalization.
expect(normalizeOption({ label: 'Deny', value: 'deny-1', style: 'bogus' as never })).toEqual({
label: 'Deny',
selectedLabel: 'Deny',
value: 'deny-1',
style: undefined,
});
});
});
describe('normalizeOptions', () => {
it('normalizes mixed string and object options, preserving order and per-option styles', () => {
const out = normalizeOptions([
'Skip',
{ label: 'Approve', style: 'primary' },
{ label: 'Deny', style: 'danger' },
{ label: 'Later', style: 'lime' as never },
]);
expect(out.map((o) => o.label)).toEqual(['Skip', 'Approve', 'Deny', 'Later']);
expect(out.map((o) => o.style)).toEqual([undefined, 'primary', 'danger', undefined]);
});
});
+7
View File
@@ -7,10 +7,15 @@
* and rendering.
*/
/** Chat SDK button styles Slack maps primarygreen, dangerred; platforms
* without button colors (Telegram) ignore it. */
export type OptionStyle = 'primary' | 'danger' | 'default';
export interface OptionInput {
label: string;
selectedLabel?: string;
value?: string;
style?: OptionStyle;
}
export type RawOption = string | OptionInput;
@@ -19,6 +24,7 @@ export interface NormalizedOption {
label: string;
selectedLabel: string;
value: string;
style?: OptionStyle;
}
export function normalizeOption(raw: RawOption): NormalizedOption {
@@ -30,6 +36,7 @@ export function normalizeOption(raw: RawOption): NormalizedOption {
label,
selectedLabel: raw.selectedLabel ?? label,
value: raw.value ?? label,
style: raw.style === 'primary' || raw.style === 'danger' || raw.style === 'default' ? raw.style : undefined,
};
}
+71
View File
@@ -238,6 +238,77 @@ describe('createChatSdkBridge.setup — webhook route and state namespace', () =
});
});
describe('createChatSdkBridge.deliver — ask_question cards (button styles)', () => {
// Approval cards color their buttons (Slack: primary→green, danger→red).
// The bridge must forward the normalized option style into Button() and
// omit it when unset — an invalid style surviving to Block Kit would fail
// the whole card with invalid_blocks (effective auto-deny).
interface CapturedButton {
type?: string;
id?: string;
label?: string;
value?: string;
style?: string;
}
function buttonsFrom(calls: PostCall[]): CapturedButton[] {
const msg = calls[0].message as {
card?: { children?: Array<{ type?: string; children?: CapturedButton[] }> };
};
const actionsRow = msg.card?.children?.find((c) => c.type === 'actions');
expect(actionsRow).toBeDefined();
return actionsRow?.children ?? [];
}
it('passes each option style through to the Button, and omits it when unset', async () => {
const { calls, postMessage } = makePostCapture();
const bridge = createChatSdkBridge({
adapter: stubAdapter({ postMessage }),
supportsThreads: false,
});
await bridge.deliver('slack:C1', null, {
kind: 'chat-sdk',
content: {
type: 'ask_question',
questionId: 'q-1',
title: 'Approval needed',
question: 'Allow the tool call?',
options: [
{ label: 'Approve', style: 'primary' },
{ label: 'Deny', style: 'danger' },
'Skip', // string shorthand — never styled
],
},
});
expect(calls).toHaveLength(1);
const buttons = buttonsFrom(calls);
expect(buttons.map((b) => b.label)).toEqual(['Approve', 'Deny', 'Skip']);
expect(buttons.map((b) => b.style)).toEqual(['primary', 'danger', undefined]);
});
it('drops invalid styles before they reach the Button (delivery goes through normalizeOptions)', async () => {
const { calls, postMessage } = makePostCapture();
const bridge = createChatSdkBridge({
adapter: stubAdapter({ postMessage }),
supportsThreads: false,
});
await bridge.deliver('slack:C1', null, {
kind: 'chat-sdk',
content: {
type: 'ask_question',
questionId: 'q-2',
title: 'Approval needed',
question: 'Allow the tool call?',
options: [{ label: 'Approve', style: 'chartreuse' }],
},
});
const buttons = buttonsFrom(calls);
expect(buttons).toHaveLength(1);
expect(buttons[0].style).toBeUndefined();
});
});
describe('createChatSdkBridge.deliver — display cards (send_card)', () => {
// The send_card MCP tool writes outbound rows with `{ type: 'card', card, fallbackText }`.
// Before this branch existed the bridge silently dropped them: cards have no
+1 -1
View File
@@ -439,7 +439,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
// well past that. The onAction handlers resolve the index back
// to the real value via getAskQuestionRender(questionId).
options.map((opt, idx) =>
Button({ id: `ncq:${questionId}:${idx}`, label: opt.label, value: String(idx) }),
Button({ id: `ncq:${questionId}:${idx}`, label: opt.label, value: String(idx), style: opt.style }),
),
),
],
+100 -2
View File
@@ -2,6 +2,32 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
// --- Mocks ---
const approvalState = vi.hoisted(() => ({
requestApproval: vi.fn(),
approvalHandler: null as
| null
| ((args: {
session: unknown;
payload: Record<string, unknown>;
userId: string;
notify: (text: string) => void;
}) => Promise<void>),
registerApprovalHandler: vi.fn(
(
action: string,
handler: (args: {
session: unknown;
payload: Record<string, unknown>;
userId: string;
notify: (text: string) => void;
}) => Promise<void>,
) => {
if (action === 'cli_command') approvalState.approvalHandler = handler;
},
),
observedContexts: [] as CallerContext[],
}));
vi.mock('../log.js', () => ({
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
@@ -29,8 +55,8 @@ vi.mock('./crud.js', () => ({
}));
vi.mock('../modules/approvals/index.js', () => ({
registerApprovalHandler: vi.fn(),
requestApproval: vi.fn(),
registerApprovalHandler: approvalState.registerApprovalHandler,
requestApproval: approvalState.requestApproval,
}));
// Register a test command so dispatch has something to find
@@ -98,6 +124,18 @@ register({
handler: async (args) => ({ echo: args }),
});
register({
name: 'approval-context-command',
description: 'approval command that records caller context',
resource: 'groups',
access: 'approval',
parseArgs: (raw) => raw,
handler: async (_args, ctx) => {
approvalState.observedContexts.push(ctx);
return { caller: ctx.caller };
},
});
// Commands that return data shaped like real resources (for post-handler filtering tests)
register({
name: 'groups-list-data',
@@ -147,11 +185,22 @@ register({
handler: async (args) => ({ id: (args as Record<string, unknown>).id, agent_group_id: 'g1' }),
});
// Echoes args back — used to assert dash-joined positional id resolution.
register({
name: 'groups-get',
description: 'test command (groups get)',
resource: 'groups',
access: 'open',
parseArgs: (raw) => raw,
handler: async (args) => ({ echo: args }),
});
import { dispatch } from './dispatch.js';
import type { CallerContext } from './frame.js';
beforeEach(() => {
vi.clearAllMocks();
approvalState.observedContexts.length = 0;
// Default: the four CLI-whitelisted resources with their real scopeFields.
const scopeFields: Record<string, string> = {
groups: 'id',
@@ -391,6 +440,39 @@ describe('CLI scope enforcement', () => {
expect(mockGetContainerConfig).not.toHaveBeenCalled();
});
it('approval replay preserves the original agent caller context', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
mockGetSession.mockReturnValue({ id: 's1', agent_group_id: 'g1', messaging_group_id: 'mg1' });
mockGetAgentGroup.mockReturnValue({ id: 'g1', name: 'Group One' });
const ctx = agentCtx();
const resp = await dispatch({ id: '1', command: 'approval-context-command', args: {} }, ctx);
expect(resp.ok).toBe(false);
expect(approvalState.requestApproval).toHaveBeenCalledTimes(1);
const approval = approvalState.requestApproval.mock.calls[0][0] as { payload: Record<string, unknown> };
expect(approval.payload).toEqual({
frame: {
id: '1',
command: 'approval-context-command',
args: { agent_group_id: 'g1', group: 'g1', id: 'g1' },
},
callerContext: ctx,
});
expect(approvalState.approvalHandler).toBeTypeOf('function');
await approvalState.approvalHandler!({
session: { id: 's1', agent_group_id: 'g1', messaging_group_id: 'mg1' },
payload: approval.payload,
userId: 'telegram:admin',
notify: vi.fn(),
});
expect(approvalState.observedContexts).toEqual([ctx]);
expect(approvalState.requestApproval).toHaveBeenCalledTimes(1);
});
// --- Post-handler filtering ---
it('group: groups list filters out other groups', async () => {
@@ -512,3 +594,19 @@ describe('CLI scope enforcement', () => {
}
});
});
// --- Dash-joined positional id resolution (generated ids contain dashes) ---
describe('dash-joined positional id resolution', () => {
it('resolves `groups-get-<uuid-with-dashes>` to (groups get, id=<uuid>)', async () => {
const uuid = '550e8400-e29b-41d4-a716-446655440000';
const resp = await dispatch({ id: '1', command: `groups-get-${uuid}`, args: {} }, { caller: 'host' });
expect(resp.ok).toBe(true);
if (resp.ok) {
const data = resp.data as { echo: Record<string, unknown> };
expect(data.echo.id).toBe(uuid);
}
});
});
+48 -13
View File
@@ -14,22 +14,36 @@ import type { CallerContext, ErrorCode, RequestFrame, ResponseFrame } from './fr
import { getResource } from './crud.js';
import { lookup } from './registry.js';
export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise<ResponseFrame> {
type DispatchOptions = {
/** True when a command is being replayed after approval. */
approved?: boolean;
};
export async function dispatch(
req: RequestFrame,
ctx: CallerContext,
opts: DispatchOptions = {},
): Promise<ResponseFrame> {
let cmd = lookup(req.command);
// Fallback: if the full command isn't registered, trim the last
// dash-segment and treat it as the target ID. This lets clients join
// all positional args with dashes (e.g. `ncl groups get abc123`
// → command "groups-get-abc123"trim → "groups-get" + id "abc123").
// Fallback: if the full command isn't registered, split the dash-joined
// command and treat the longest registered prefix as the command, with the
// re-joined remainder as the target ID. Clients join all positional args
// with dashes (e.g. `ncl groups get abc123`command "groups-get-abc123"),
// and generated ids (UUIDs, `sess-…`, `appr-…`) themselves contain dashes,
// so trimming a single trailing segment isn't enough — walk prefixes from
// longest to shortest so `groups-get-<uuid-with-dashes>` still resolves to
// "groups-get" + id "<uuid-with-dashes>".
if (!cmd) {
const idx = req.command.lastIndexOf('-');
if (idx > 0) {
const shortened = req.command.slice(0, idx);
const tail = req.command.slice(idx + 1);
const parts = req.command.split('-');
for (let i = parts.length - 1; i > 0; i--) {
const shortened = parts.slice(0, i).join('-');
const fallback = lookup(shortened);
if (fallback) {
const tail = parts.slice(i).join('-');
cmd = fallback;
req = { ...req, command: shortened, args: { ...req.args, id: req.args.id ?? tail } };
break;
}
}
}
@@ -101,7 +115,7 @@ export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise<R
}
}
if (ctx.caller !== 'host' && cmd.access === 'approval') {
if (ctx.caller !== 'host' && cmd.access === 'approval' && !opts.approved) {
const session = getSession(ctx.sessionId);
if (!session) {
return err(req.id, 'handler-error', 'Session not found.');
@@ -117,7 +131,7 @@ export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise<R
session,
agentName,
action: 'cli_command',
payload: { frame: { id: req.id, command: req.command, args: req.args } },
payload: { frame: { id: req.id, command: req.command, args: req.args }, callerContext: ctx },
title: `CLI: ${req.command}`,
question: `Agent "${agentName}" wants to run:\n\`ncl ${req.command}${argSummary ? ' ' + argSummary : ''}\``,
});
@@ -178,9 +192,10 @@ export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise<R
}
}
registerApprovalHandler('cli_command', async ({ session, payload, userId, notify }) => {
registerApprovalHandler('cli_command', async ({ payload, notify }) => {
const frame = payload.frame as RequestFrame;
const response = await dispatch(frame, { caller: 'host' });
const callerContext = parseCallerContext(payload.callerContext) ?? { caller: 'host' };
const response = await dispatch(frame, callerContext, { approved: true });
if (response.ok) {
const data = typeof response.data === 'string' ? response.data : JSON.stringify(response.data, null, 2);
@@ -190,6 +205,26 @@ registerApprovalHandler('cli_command', async ({ session, payload, userId, notify
}
});
function parseCallerContext(value: unknown): CallerContext | undefined {
if (!value || typeof value !== 'object') return undefined;
const record = value as Record<string, unknown>;
if (record.caller === 'host') return { caller: 'host' };
if (
record.caller === 'agent' &&
typeof record.sessionId === 'string' &&
typeof record.agentGroupId === 'string' &&
typeof record.messagingGroupId === 'string'
) {
return {
caller: 'agent',
sessionId: record.sessionId,
agentGroupId: record.agentGroupId,
messagingGroupId: record.messagingGroupId,
};
}
return undefined;
}
function err(id: string, code: ErrorCode, message: string): ResponseFrame {
return { id, ok: false, error: { code, message } };
}
+83
View File
@@ -0,0 +1,83 @@
/**
* Tests for the host-side command gate filtered commands are dropped
* before reaching the container, and admin commands are gated against
* the user_roles table.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { gateCommand } from './command-gate.js';
import { closeDb, createAgentGroup, initTestDb, runMigrations } from './db/index.js';
import { createUser } from './modules/permissions/db/users.js';
import { grantRole } from './modules/permissions/db/user-roles.js';
function now(): string {
return new Date().toISOString();
}
function seedAgentGroup(id: string): void {
createAgentGroup({ id, name: id.toUpperCase(), folder: id, agent_provider: null, created_at: now() });
}
function seedUser(id: string): void {
createUser({ id, kind: 'telegram', display_name: null, created_at: now() });
}
beforeEach(() => {
const db = initTestDb();
runMigrations(db);
seedAgentGroup('ag-1');
seedAgentGroup('ag-2');
});
afterEach(() => {
closeDb();
});
describe('filtered commands', () => {
it('drops /start before it reaches the container', () => {
expect(gateCommand('/start', 'telegram:1', 'ag-1')).toEqual({ action: 'filter' });
});
it('drops /start regardless of sender', () => {
expect(gateCommand('/start', null, 'ag-1')).toEqual({ action: 'filter' });
});
});
describe('admin gating goes through roles', () => {
it('denies an admin command from a non-admin user', () => {
expect(gateCommand('/clear', 'telegram:nobody', 'ag-1')).toEqual({ action: 'deny', command: '/clear' });
});
it('denies an admin command with no sender', () => {
expect(gateCommand('/clear', null, 'ag-1')).toEqual({ action: 'deny', command: '/clear' });
});
it('allows an admin command from an owner', () => {
seedUser('telegram:owner');
grantRole({ user_id: 'telegram:owner', role: 'owner', agent_group_id: null, granted_by: null, granted_at: now() });
expect(gateCommand('/clear', 'telegram:owner', 'ag-1')).toEqual({ action: 'pass' });
});
it('allows an admin command from a scoped admin of the group', () => {
seedUser('telegram:admin');
grantRole({
user_id: 'telegram:admin',
role: 'admin',
agent_group_id: 'ag-1',
granted_by: null,
granted_at: now(),
});
expect(gateCommand('/clear', 'telegram:admin', 'ag-1')).toEqual({ action: 'pass' });
expect(gateCommand('/clear', 'telegram:admin', 'ag-2')).toEqual({ action: 'deny', command: '/clear' });
});
});
describe('normal messages pass through', () => {
it('passes a plain message', () => {
expect(gateCommand('hello there', 'telegram:1', 'ag-1')).toEqual({ action: 'pass' });
});
it('passes an unknown slash command', () => {
expect(gateCommand('/whatever', 'telegram:1', 'ag-1')).toEqual({ action: 'pass' });
});
});
+3 -14
View File
@@ -7,11 +7,11 @@
* "Permission denied" response written directly to messages_out
* - Normal messages: pass through unchanged
*/
import { getDb, hasTable } from './db/connection.js';
import { hasAdminPrivilege } from './modules/permissions/db/user-roles.js';
export type GateResult = { action: 'pass' } | { action: 'filter' } | { action: 'deny'; command: string };
const FILTERED_COMMANDS = new Set(['/help', '/login', '/logout', '/doctor', '/config', '/remote-control']);
const FILTERED_COMMANDS = new Set(['/start', '/help', '/login', '/logout', '/doctor', '/config', '/remote-control']);
const ADMIN_COMMANDS = new Set(['/clear', '/compact', '/context', '/cost', '/files', '/upload-trace']);
/**
@@ -48,16 +48,5 @@ export function gateCommand(content: string, userId: string | null, agentGroupId
function isAdmin(userId: string | null, agentGroupId: string): boolean {
if (!userId) return false;
if (!hasTable(getDb(), 'user_roles')) return true; // no permissions module = allow all
const db = getDb();
const row = db
.prepare(
`SELECT 1 FROM user_roles
WHERE user_id = ?
AND (role = 'owner' OR role = 'admin')
AND (agent_group_id IS NULL OR agent_group_id = ?)
LIMIT 1`,
)
.get(userId, agentGroupId);
return row != null;
return hasAdminPrivilege(userId, agentGroupId);
}
-6
View File
@@ -320,12 +320,6 @@ export function buildMounts(
mounts.push({ hostPath: fragmentsDir, containerPath: '/workspace/agent/.claude-fragments', readonly: true });
}
// Global memory directory — always read-only.
const globalDir = path.join(GROUPS_DIR, 'global');
if (fs.existsSync(globalDir)) {
mounts.push({ hostPath: globalDir, containerPath: '/workspace/global', readonly: true });
}
// Shared CLAUDE.md — read-only, imported by the composed entry point via
// the `.claude-shared.md` symlink inside the group dir.
const sharedClaudeMd = path.join(process.cwd(), 'container', 'CLAUDE.md');
+41 -11
View File
@@ -147,8 +147,8 @@ async function handleRequest(request: ApprovalRequest): Promise<Decision> {
const onecliTitle = 'Credentials Request';
const onecliOptions = [
{ label: 'Approve', selectedLabel: '✅ Approved', value: 'approve' },
{ label: 'Reject', selectedLabel: '❌ Rejected', value: 'reject' },
{ label: 'Approve', selectedLabel: '✅ Approved', value: 'approve', style: 'primary' as const },
{ label: 'Reject', selectedLabel: '❌ Rejected', value: 'reject', style: 'danger' as const },
];
let platformMessageId: string | undefined;
try {
@@ -254,16 +254,46 @@ async function sweepStaleApprovals(): Promise<void> {
}
}
/** The hosted gateway's structured request summary not yet in the SDK's
* ApprovalRequest type (observed on api.onecli.sh, 2026-07): the action being
* performed plus labeled fields (To / Subject / Body for email sends). */
interface ApprovalSummary {
action?: string;
details?: { label: string; value: string }[];
}
const SUMMARY_VALUE_EXCERPT_CHARS = 900;
function buildQuestion(request: ApprovalRequest, agentName: string): string {
const lines = [
'Credential access request',
`Agent: ${agentName}`,
'```',
`${request.method} ${request.host}${request.path}`,
'```',
];
if (request.bodyPreview) {
lines.push('Body:', '```', request.bodyPreview, '```');
const lines = [`*Agent:* ${agentName}`];
const summary = (request as ApprovalRequest & { summary?: ApprovalSummary }).summary;
if (summary?.details?.length) {
if (summary.action) lines.push(`*Action:* ${summary.action}`);
// A render bug here must never decide the request: handleRequest's catch
// returns 'deny', so stay defensive — coerce non-string values instead of
// assuming the gateway's shape, and keep the card under Slack's 3000-char
// section limit or delivery itself fails.
let budget = 2600;
for (const { label, value } of summary.details) {
const raw = typeof value === 'string' ? value : (JSON.stringify(value) ?? String(value));
const cap = Math.min(SUMMARY_VALUE_EXCERPT_CHARS, Math.max(0, budget));
if (cap === 0) {
lines.push(`_…${summary.details.length} field(s) omitted for length — see the audit payload._`);
break;
}
const v = raw.length > cap ? `${raw.slice(0, cap)}` : raw;
budget -= v.length + String(label).length + 8;
// Multi-line values (message bodies) read better fenced; short labeled
// fields (To, Subject) inline.
if (v.includes('\n')) lines.push(`*${label}:*`, '```', v, '```');
else lines.push(`*${label}:* ${v}`);
}
} else if (request.bodyPreview) {
lines.push('```', request.bodyPreview.slice(0, SUMMARY_VALUE_EXCERPT_CHARS * 2), '```');
lines.push(`_${request.method} ${request.host}${request.path}_`);
} else {
lines.push(`_${request.method} ${request.host}${request.path}_`);
}
return lines.join('\n');
}
+2 -2
View File
@@ -46,8 +46,8 @@ export const REJECT_WITH_REASON_VALUE = 'reject_with_reason';
* keep their own two-button set in onecli-approvals.ts.
*/
const APPROVAL_OPTIONS: RawOption[] = [
{ label: 'Approve', selectedLabel: '✅ Approved', value: 'approve' },
{ label: 'Reject', selectedLabel: '❌ Rejected', value: 'reject' },
{ label: 'Approve', selectedLabel: '✅ Approved', value: 'approve', style: 'primary' },
{ label: 'Reject', selectedLabel: '❌ Rejected', value: 'reject', style: 'danger' },
{ label: 'Reject with reason…', selectedLabel: '📝 Rejected (awaiting reason)', value: REJECT_WITH_REASON_VALUE },
];
@@ -93,6 +93,7 @@ function buildApprovalOptions(agentGroups: AgentGroup[], approverUserId?: string
label: `Connect to ${visibleAgentGroups[0].name}`,
selectedLabel: `✅ Connected to ${visibleAgentGroups[0].name}`,
value: `${CONNECT_PREFIX}${visibleAgentGroups[0].id}`,
style: 'primary',
});
} else if (visibleAgentGroups.length > 1) {
options.push({
@@ -110,6 +111,7 @@ function buildApprovalOptions(agentGroups: AgentGroup[], approverUserId?: string
label: 'Reject',
selectedLabel: '🙅 Rejected',
value: REJECT_VALUE,
style: 'danger',
});
return options;
}
+2 -2
View File
@@ -35,8 +35,8 @@ import { pickApprovalDelivery, pickApprover } from '../approvals/primitive.js';
import { createPendingSenderApproval, hasInFlightSenderApproval } from './db/pending-sender-approvals.js';
const APPROVAL_OPTIONS: RawOption[] = [
{ label: 'Allow', selectedLabel: '✅ Allowed', value: 'approve' },
{ label: 'Deny', selectedLabel: '❌ Denied', value: 'reject' },
{ label: 'Allow', selectedLabel: '✅ Allowed', value: 'approve', style: 'primary' },
{ label: 'Deny', selectedLabel: '❌ Denied', value: 'reject', style: 'danger' },
];
function generateId(): string {
+1 -1
View File
@@ -1,6 +1,6 @@
// Host-side provider container-config barrel.
// Providers that need host-side container setup (extra mounts, env passthrough,
// per-session directories) self-register on import. Providers with no host
// needs (claude, mock) don't appear here.
// needs (claude) don't appear here.
//
// Skills add a new provider by appending one import line below.
+1 -1
View File
@@ -7,7 +7,7 @@
* the registered config fn, and merges the returned mounts/env into the spawn
* args.
*
* Providers without host-side needs (e.g. `claude`, `mock`) don't appear in
* Providers without host-side needs (e.g. `claude`) don't appear in
* this registry at all the lookup returns `undefined` and the spawn path
* proceeds with only the default mounts and env.
*