mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-06 18:52:03 +08:00
Compare commits
228 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8644e6bedb | |||
| 571d72ba47 | |||
| 37e2d2b2bb | |||
| ca298d3714 | |||
| d9859fd59a | |||
| ff557092d8 | |||
| f6901e01a4 | |||
| 36a7e3cf81 | |||
| 2242f6c997 | |||
| 40b65521c0 | |||
| 81fea1d7ec | |||
| b6cb53e21c | |||
| 5a7e75f854 | |||
| d770e56596 | |||
| 08a1ac9753 | |||
| 273489badf | |||
| 504651633f | |||
| 694ab74aa1 | |||
| aae81321e9 | |||
| 6c46b1e43d | |||
| 71453707ba | |||
| 0aa9e668f6 | |||
| 023128def5 | |||
| c4a1679666 | |||
| 2e40e17155 | |||
| f896caefa0 | |||
| 55c003c5f4 | |||
| 20f2bae5cd | |||
| d6b82e6473 | |||
| fea5ac5f2c | |||
| 2b86bbef19 | |||
| 9dc0a7e62f | |||
| 2035033397 | |||
| 1c294ff9a5 | |||
| 43198310e1 | |||
| b7d6eebf4d | |||
| 803f3413ec | |||
| a8b7da7bcf | |||
| 0b6ad5550d | |||
| c1965cfcaf | |||
| 3cefbfccf4 | |||
| 6f22c73aac | |||
| 31dd37b3a8 | |||
| 0dfde3aa5b | |||
| 33d2366252 | |||
| 9bf1514f26 | |||
| be3502c23b | |||
| e3f38dbed9 | |||
| d8a6b99f0e | |||
| 8ca7564fbc | |||
| afa07f0566 | |||
| 8059ee4eec | |||
| 41c486cacc | |||
| 190a7d4f43 | |||
| e3d2d43b0e | |||
| afde23bbe6 | |||
| 776bc14ca0 | |||
| a9461afef1 | |||
| ca81558ec8 | |||
| 2f88e4fa15 | |||
| 9b0d1dd044 | |||
| 79e490dfcf | |||
| 8a3444a68c | |||
| 0c020e664d | |||
| 01b07d6652 | |||
| be20e9f723 | |||
| c02c002caa | |||
| 498b80b344 | |||
| 76c1f13ca3 | |||
| e3d156f800 | |||
| b4da018d8c | |||
| 4b11079007 | |||
| 31cc35ea32 | |||
| 22b294e233 | |||
| bf0a3d2612 | |||
| 6dc25a9b8a | |||
| cafba7fb18 | |||
| 10d400ec64 | |||
| 1a9643cfa3 | |||
| 2ba2555032 | |||
| 687d7d13ac | |||
| 2938bbf94a | |||
| d1a2b04d32 | |||
| 455014e7b9 | |||
| 5032c431ae | |||
| ac8273c698 | |||
| 79060c5407 | |||
| 0835089a51 | |||
| c82f062d57 | |||
| 3906104960 | |||
| ed9a3e330d | |||
| 102ce80fda | |||
| 0626dcec92 | |||
| 912a89bf94 | |||
| b42329551d | |||
| 855f204d8a | |||
| d4ca8a4ea6 | |||
| 7615d7d846 | |||
| f094f56bf6 | |||
| fcee39ea14 | |||
| e3b2ffce36 | |||
| 2bce84781b | |||
| f4be00e6ed | |||
| c9aa69dea9 | |||
| 3731e26769 | |||
| d472d9d32b | |||
| 6c2d26836f | |||
| b28c917997 | |||
| a00a5610bd | |||
| 05dc1b0a3c | |||
| a7b34bc872 | |||
| aecad864e6 | |||
| c87f2e55dc | |||
| 411f5e71df | |||
| cb6e3d117c | |||
| ed7e3f70da | |||
| 557e073c2f | |||
| 91ebc9def2 | |||
| 14c89e9716 | |||
| 549c424a38 | |||
| 186b9befcd | |||
| 863d413d32 | |||
| cf8478ffbb | |||
| 8be5be93ba | |||
| add3fc8f70 | |||
| 0d841bcd05 | |||
| dd1d0e5677 | |||
| 36afa40857 | |||
| 9c91c00c93 | |||
| f8214ed1d8 | |||
| 665b0dca77 | |||
| 46de9ae05a | |||
| d7d104aa0b | |||
| bd0962fd97 | |||
| d997d545cc | |||
| 8455eb0809 | |||
| 462619d287 | |||
| c249989b5a | |||
| 3b41171071 | |||
| 6f616824d6 | |||
| ab596d31d3 | |||
| 564e605900 | |||
| be0ca10630 | |||
| 0a5ffde133 | |||
| 4c4b2ecbbe | |||
| f6c13eb6f3 | |||
| f629ff25fa | |||
| 3eca5588f9 | |||
| e33fa970f5 | |||
| 3c14310928 | |||
| f387d371fd | |||
| 2f26d4ebc1 | |||
| 2afbd18233 | |||
| 953496dc37 | |||
| 797491d8b3 | |||
| 2df754459b | |||
| 0896d4089e | |||
| d153d91307 | |||
| ce55af12d5 | |||
| 545800a94e | |||
| bfb309bd0c | |||
| 38d9390eea | |||
| 8d3eca7027 | |||
| 1d6bba4d3f | |||
| 9bb69c0e50 | |||
| 520ec44aec | |||
| 8c6a243ffd | |||
| add6145f1c | |||
| 4e14d08173 | |||
| 8f2f788b6e | |||
| e96d7fd961 | |||
| 2ac7809385 | |||
| 15292ae76c | |||
| 055cf49bd5 | |||
| 8ee7915418 | |||
| e8148bc0a7 | |||
| 625264ba4b | |||
| f34e590bcd | |||
| d208fd7bf5 | |||
| 886c65725b | |||
| 9977af68d7 | |||
| 8e44f07dd4 | |||
| 8c43f13d93 | |||
| 5cf4ff1bd2 | |||
| 6e475e5503 | |||
| 0f8499b141 | |||
| 82e1dc4ae8 | |||
| e70b021cde | |||
| ea90a12846 | |||
| 3b1f4501d6 | |||
| 385fb014fc | |||
| b2160a56aa | |||
| f72658bb50 | |||
| 3180f3f881 | |||
| b0bdc57b37 | |||
| 314b91efc0 | |||
| 53ed3b77c9 | |||
| 070714ec58 | |||
| e4907c2c33 | |||
| 9a142302df | |||
| 3f39f57653 | |||
| 1b86950f10 | |||
| 8b435eb02d | |||
| 7e2004f945 | |||
| 63901d1bde | |||
| e5d96e348f | |||
| 439c24f1b7 | |||
| 2a144bb8d6 | |||
| 197faaaa14 | |||
| 3ffd6dde00 | |||
| e856e924a5 | |||
| c277deae21 | |||
| 0cc8ade34c | |||
| d33806389c | |||
| f350ed24e1 | |||
| cc07387025 | |||
| 59460e9a5c | |||
| ee7f891698 | |||
| 7fde348e2b | |||
| 122135e6dc | |||
| 8563fb0681 | |||
| 0155ab1943 | |||
| 59c4d33adc | |||
| e03c5c194a | |||
| ae48986e42 | |||
| 01433bae32 | |||
| 0516bea638 | |||
| 32f067f5bb |
Symlink
+1
@@ -0,0 +1 @@
|
||||
../.claude/skills
|
||||
@@ -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.
|
||||
@@ -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) => (
|
||||
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>"&'`), '<script>"&'');
|
||||
});
|
||||
|
||||
test('mdToHtml: raw HTML in source is escaped, never passed through', () => {
|
||||
const html = mdToHtml('a <script>alert(1)</script> b');
|
||||
assert.ok(!html.includes('<script>'));
|
||||
assert.ok(html.includes('<script>'));
|
||||
});
|
||||
|
||||
// ---- the security-sensitive part: links -----------------------------------
|
||||
|
||||
test('mdToHtml: link href comes from the URL, label from the text', () => {
|
||||
const html = mdToHtml('see [the docs](https://example.com/x)');
|
||||
assert.match(html, /<a href="https:\/\/example\.com\/x" target="_blank" rel="noopener noreferrer">the docs<\/a>/);
|
||||
});
|
||||
|
||||
test('mdToHtml: javascript: smuggled in link TEXT stays inert (never an href)', () => {
|
||||
const html = mdToHtml('[javascript:alert(1)](https://safe.com)');
|
||||
// href is the safe URL; the js string is only visible label text
|
||||
assert.match(html, /href="https:\/\/safe\.com"/);
|
||||
assert.ok(!/href="javascript:/i.test(html));
|
||||
});
|
||||
|
||||
test('mdToHtml: a non-http(s) URL is not turned into a link', () => {
|
||||
// javascript:/data: never match the (https?:...) capture, so the literal
|
||||
// (escaped) markdown is left as-is — no anchor, no executable href.
|
||||
const html = mdToHtml('[click](javascript:alert(1))');
|
||||
assert.ok(!/<a /.test(html));
|
||||
assert.ok(!/href="javascript:/i.test(html));
|
||||
});
|
||||
|
||||
test('mdToHtml: an attribute-breakout attempt in the URL cannot escape the href', () => {
|
||||
// The double-quote is escaped to " before the regex runs, so it can never
|
||||
// close an attribute. (Here the URL also has a space, so no anchor even forms.)
|
||||
// The security property: no REAL attribute (with a literal quote) is injected.
|
||||
const html = mdToHtml('[x](https://a" onmouseover="alert(1))');
|
||||
assert.ok(!/<a/.test(html), 'malformed link must not produce an anchor');
|
||||
assert.ok(!/onmouseover="/.test(html), 'no real (unescaped-quote) attribute injected');
|
||||
});
|
||||
|
||||
test('mdToHtml: an escaped quote inside a matched URL stays inside the href, inert', () => {
|
||||
// Even when a URL matches, any " in it is already " (an entity), which
|
||||
// does not terminate an HTML attribute value — so no breakout.
|
||||
const html = mdToHtml('[x](https://a"onmouseover=alert)');
|
||||
assert.ok(!/onmouseover="/.test(html));
|
||||
if (/<a/.test(html)) assert.match(html, /href="https:\/\/a"onmouseover=alert"/);
|
||||
});
|
||||
|
||||
// ---- basic rendering sanity ----------------------------------------------
|
||||
|
||||
test('mdToHtml: headings, code fences, lists render', () => {
|
||||
const html = mdToHtml('# Title\n\n```\ncode\n```\n\n- a\n- b');
|
||||
assert.match(html, /<h1>Title<\/h1>/);
|
||||
assert.match(html, /<pre class="code"><code>code<\/code><\/pre>/);
|
||||
assert.match(html, /<ul><li>a<\/li><li>b<\/li><\/ul>/);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -11,6 +11,8 @@ NanoClaw selects each group's agent backend from `container_configs.provider` (d
|
||||
|
||||
The provider runs `codex app-server` as a child process speaking JSON-RPC over stdio: native streaming, MCP tools, server-side conversation history (the continuation is a thread id, no on-disk transcript). Credentials are **vault-only**: OneCLI serves a sentinel `auth.json` stub into the container and swaps the real ChatGPT token or API key on the wire — no key in `.env`, nothing readable in the container.
|
||||
|
||||
The mechanical steps under **Install** carry `nc:` directive fences: an agent reads the prose and applies them, and a parser can apply them deterministically from the same document. Every directive is idempotent, so the whole skill is safe to re-run; anything a parser can't apply falls back to the prose beside it.
|
||||
|
||||
## Install
|
||||
|
||||
### Pre-flight
|
||||
@@ -23,92 +25,69 @@ Check whether the payload is already wired (a prior apply, or a trunk that still
|
||||
- `import './codex.js';` in `src/providers/index.ts`, `container/agent-runner/src/providers/index.ts`, and `setup/providers/index.ts`
|
||||
- an `@openai/codex` entry in `container/cli-tools.json`
|
||||
|
||||
### Fetch and copy
|
||||
### 1. Fetch and copy the payload
|
||||
|
||||
```bash
|
||||
git fetch origin providers
|
||||
Fetch the `providers` branch and copy the Codex payload into all three trees (additive — overwrite each file, never merge the branch). The host files are the provider contribution + AGENTS.md compose + their guards; the container files are the provider runtime (turn loop, JSON-RPC wrapper, per-exchange archiver) + their guards; the setup file is the picker entry + vault auth walk-through; `container/AGENTS.md` is the runtime-contract base the composed AGENTS.md embeds.
|
||||
|
||||
```nc:copy from-branch:providers
|
||||
src/providers/codex.ts
|
||||
src/providers/codex-agents-md.ts
|
||||
src/providers/codex-registration.test.ts
|
||||
src/providers/codex-host-contribution.test.ts
|
||||
src/providers/codex-agents-md.test.ts
|
||||
container/agent-runner/src/providers/codex.ts
|
||||
container/agent-runner/src/providers/codex-app-server.ts
|
||||
container/agent-runner/src/providers/exchange-archive.ts
|
||||
container/agent-runner/src/providers/exchange-archive.test.ts
|
||||
container/agent-runner/src/providers/codex-registration.test.ts
|
||||
container/agent-runner/src/providers/codex.factory.test.ts
|
||||
container/agent-runner/src/providers/codex.turns.test.ts
|
||||
container/agent-runner/src/providers/codex-app-server.test.ts
|
||||
container/agent-runner/src/providers/codex-cli-tools.test.ts
|
||||
setup/providers/codex.ts
|
||||
setup/providers/codex.test.ts
|
||||
setup/providers/codex-registration.test.ts
|
||||
container/AGENTS.md
|
||||
```
|
||||
|
||||
Copy each file with `git show origin/providers:<path> > <path>` (additive — never merge the branch):
|
||||
### 2. Wire the barrels
|
||||
|
||||
Host (`src/providers/`):
|
||||
- `codex.ts` — provider contribution: per-group `.codex-shared` state dir, AGENTS.md compose, skill links
|
||||
- `codex-agents-md.ts` — AGENTS.md composition (32KB Codex cap: degrades by dropping the largest instruction sections, never blocks a spawn)
|
||||
- `codex-registration.test.ts` — barrel-driven host registration guard
|
||||
- `codex-host-contribution.test.ts` — drives the real contribution against a real test DB (the "consumes core" leg)
|
||||
- `codex-agents-md.test.ts` — cap-degradation behavior
|
||||
Append the self-registration import to each of the three provider barrels (skipped if the line is already present). Each barrel-registration test imports its real barrel and asserts `codex` is registered — they go red the moment a barrel line is missing or drifts.
|
||||
|
||||
Container (`container/agent-runner/src/providers/`):
|
||||
- `codex.ts` — the provider (turn loop, steering, memory scaffold + `onExchangeComplete` archiving)
|
||||
- `codex-app-server.ts` — JSON-RPC child-process wrapper
|
||||
- `exchange-archive.ts` — per-exchange markdown writer the `onExchangeComplete` hook uses (provider-owned, not runner code)
|
||||
- `exchange-archive.test.ts` — writer behavior
|
||||
- `codex-registration.test.ts` — barrel-driven container registration guard
|
||||
- `codex.factory.test.ts`, `codex.turns.test.ts`, `codex-app-server.test.ts` — provider behavior
|
||||
- `codex-cli-tools.test.ts` — structural guard for the Codex entry in `container/cli-tools.json`
|
||||
|
||||
Setup (`setup/providers/`):
|
||||
- `codex.ts` — picker entry self-registration + the vault auth walk-through + install check
|
||||
- `codex.test.ts` — install-check coverage
|
||||
- `codex-registration.test.ts` — barrel-driven setup registration guard
|
||||
|
||||
Shared base (skip if present):
|
||||
- `container/AGENTS.md` — the runtime-contract base the composed AGENTS.md embeds
|
||||
|
||||
### Wire the barrels
|
||||
|
||||
Append `import './codex.js';` to each of:
|
||||
- `src/providers/index.ts`
|
||||
- `container/agent-runner/src/providers/index.ts`
|
||||
- `setup/providers/index.ts`
|
||||
|
||||
### CLI manifest
|
||||
|
||||
The agent's global Node CLIs install from `container/cli-tools.json` (a json-merge seam), not hand-edited Dockerfile layers. Add Codex by appending one entry — `@openai/codex` has no native postinstall, so no `onlyBuilt`:
|
||||
|
||||
```bash
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const file = "container/cli-tools.json";
|
||||
const tools = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
if (!tools.some((t) => t.name === "@openai/codex")) {
|
||||
tools.push({ name: "@openai/codex", version: "0.138.0" });
|
||||
const fmt = (t) => " { " + Object.entries(t).map(([k, v]) => JSON.stringify(k) + ": " + JSON.stringify(v)).join(", ") + " }";
|
||||
fs.writeFileSync(file, "[\n" + tools.map(fmt).join(",\n") + "\n]\n");
|
||||
}
|
||||
'
|
||||
```nc:append to:src/providers/index.ts
|
||||
import './codex.js';
|
||||
```
|
||||
```nc:append to:container/agent-runner/src/providers/index.ts
|
||||
import './codex.js';
|
||||
```
|
||||
```nc:append to:setup/providers/index.ts
|
||||
import './codex.js';
|
||||
```
|
||||
|
||||
The version (`0.138.0`) is the canonical pin — keep it in sync with `setup/add-codex.sh`. The Dockerfile already installs every manifest entry via pinned `pnpm install -g`; no Dockerfile edit is needed.
|
||||
### 3. CLI manifest
|
||||
|
||||
### Build
|
||||
The agent's global Node CLIs install from `container/cli-tools.json` (a json-merge seam), not hand-edited Dockerfile layers. Add Codex by appending one entry — idempotent on `name`, so a re-run is a no-op. `@openai/codex` has no native postinstall, so no `onlyBuilt`. The Dockerfile already installs every manifest entry via pinned `pnpm install -g`; no Dockerfile edit is needed.
|
||||
|
||||
```bash
|
||||
```nc:json-merge into:container/cli-tools.json key:name
|
||||
{ "name": "@openai/codex", "version": "0.138.0" }
|
||||
```
|
||||
|
||||
The version (`0.138.0`) is the canonical pin — this SKILL.md is the source of truth.
|
||||
|
||||
### 4. Build
|
||||
|
||||
```nc:run effect:build
|
||||
pnpm run build
|
||||
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit
|
||||
./container/build.sh
|
||||
```
|
||||
|
||||
### Restart the host
|
||||
### 5. Validate
|
||||
|
||||
The image rebuild does not reload the **host**. Codex's host contribution
|
||||
(`src/providers/codex.ts`) registers the `/home/node/.codex` bind mount + env
|
||||
passthrough, and the running host only picks it up on restart. Skip this and the
|
||||
first Codex turn fails with `EACCES` writing `/home/node/.codex/config.toml` —
|
||||
with no mount, Docker auto-creates the dir root-owned and the non-root container
|
||||
user can't write to it.
|
||||
|
||||
```bash
|
||||
# macOS (launchd)
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
|
||||
# Linux (systemd)
|
||||
systemctl --user restart nanoclaw
|
||||
```
|
||||
|
||||
### Validate
|
||||
|
||||
```bash
|
||||
```nc:run effect:test
|
||||
pnpm vitest run src/providers/codex-registration.test.ts src/providers/codex-host-contribution.test.ts src/providers/codex-agents-md.test.ts setup/providers/
|
||||
```
|
||||
```nc:run effect:test
|
||||
cd container/agent-runner && bun test src/providers/
|
||||
```
|
||||
|
||||
@@ -116,9 +95,7 @@ The registration tests import only the real barrels — they go red if a barrel
|
||||
|
||||
## Authenticate
|
||||
|
||||
> **Run this in a separate, real terminal — it is interactive.** It prompts for ChatGPT-subscription vs OpenAI-API-key and then drives a browser/device login, so it needs a TTY to answer prompts.
|
||||
|
||||
```bash
|
||||
```nc:run effect:external
|
||||
pnpm exec tsx setup/index.ts --step provider-auth codex
|
||||
```
|
||||
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
// Structural guard for the Codex CLI install in container/cli-tools.json.
|
||||
//
|
||||
// @openai/codex is a CLI *binary* installed from the global-CLI manifest (a
|
||||
// json-merge seam), not an importable package, so the barrel-driven
|
||||
// registration tests cannot see it. This test reads the real cli-tools.json
|
||||
// and asserts the @openai/codex entry is present and pinned to an exact
|
||||
// version. It goes red if the manifest entry is dropped or unpins.
|
||||
//
|
||||
// Runs under bun (same suite as the container registration test):
|
||||
// cd container/agent-runner && bun test src/providers/codex-cli-tools.test.ts
|
||||
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
|
||||
// container/agent-runner/src/providers/ -> container/cli-tools.json
|
||||
const MANIFEST = path.join(import.meta.dir, '..', '..', '..', 'cli-tools.json');
|
||||
const manifestPresent = existsSync(MANIFEST);
|
||||
|
||||
// Read lazily — `describe.skipIf` still runs the body to register tests, so the
|
||||
// read has to be guarded for the bare-branch (no manifest) case.
|
||||
const tools: Array<{ name: string; version: string }> = manifestPresent
|
||||
? JSON.parse(readFileSync(MANIFEST, 'utf8'))
|
||||
: [];
|
||||
const codex = tools.find((t) => t.name === '@openai/codex');
|
||||
|
||||
// cli-tools.json is a trunk file; on the bare providers branch it isn't present,
|
||||
// so skip there. In an installed tree (trunk + this payload) it must carry the
|
||||
// pinned @openai/codex entry.
|
||||
describe.skipIf(!manifestPresent)('container/cli-tools.json codex CLI install', () => {
|
||||
it('includes the @openai/codex entry', () => {
|
||||
expect(codex).toBeDefined();
|
||||
});
|
||||
|
||||
it('pins it to an exact semver (no latest, no ranges)', () => {
|
||||
expect(codex?.version).toMatch(/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/);
|
||||
});
|
||||
});
|
||||
@@ -89,7 +89,6 @@ DC_SMTP_SECURITY=2 # 2=STARTTLS (default), 1=SSL/TLS, 3=plain
|
||||
|
||||
Security settings are applied on every startup, so changing them in `.env` and restarting takes effect without wiping the account.
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
|
||||
### Optional settings
|
||||
|
||||
|
||||
@@ -5,98 +5,151 @@ description: Add Discord bot channel integration via Chat SDK.
|
||||
|
||||
# Add Discord Channel
|
||||
|
||||
Adds Discord bot support via the Chat SDK bridge.
|
||||
Adds Discord bot support via the Chat SDK bridge. NanoClaw doesn't ship channels
|
||||
in trunk — this skill copies the Discord adapter in from the `channels` branch.
|
||||
|
||||
## Install
|
||||
The mechanical steps under **Apply** carry `nc:` directive fences: an agent
|
||||
reads the prose and applies them, and a parser can apply them deterministically
|
||||
from the same document. Every directive is idempotent, so the whole skill is
|
||||
safe to re-run; anything a parser can't apply falls back to the prose beside it.
|
||||
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the Discord adapter in from the `channels` branch.
|
||||
## Apply
|
||||
|
||||
### Pre-flight (idempotent)
|
||||
### 1. Copy the adapter and its registration test
|
||||
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
Fetch the `channels` branch and copy the Discord adapter and its registration
|
||||
test into `src/channels/` (overwrite — the branch is canonical):
|
||||
|
||||
- `src/channels/discord.ts` exists
|
||||
- `src/channels/discord-registration.test.ts` exists
|
||||
- `src/channels/index.ts` contains `import './discord.js';`
|
||||
- `@chat-adapter/discord` is listed in `package.json` dependencies
|
||||
|
||||
Otherwise continue. Every step below is safe to re-run.
|
||||
|
||||
### 1. Fetch the channels branch
|
||||
|
||||
```bash
|
||||
git fetch origin channels
|
||||
```nc:copy from-branch:channels
|
||||
src/channels/discord.ts
|
||||
src/channels/discord-registration.test.ts
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and its registration test
|
||||
### 2. Register the adapter
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/discord.ts > src/channels/discord.ts
|
||||
git show origin/channels:src/channels/discord-registration.test.ts > src/channels/discord-registration.test.ts
|
||||
```
|
||||
Append the self-registration import to the channel barrel (skipped if the line
|
||||
is already present). This one line is the skill's only reach-in into core:
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
```typescript
|
||||
```nc:append to:src/channels/index.ts
|
||||
import './discord.js';
|
||||
```
|
||||
|
||||
### 4. Install the adapter package (pinned)
|
||||
### 3. Install the adapter package
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/discord@4.27.0
|
||||
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
|
||||
|
||||
```nc:dep
|
||||
@chat-adapter/discord@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
### 4. Build and validate
|
||||
|
||||
```bash
|
||||
Build first: it guards the typed `createChatSdkBridge(...)` core call and proves
|
||||
the dependency is installed. Then run the one integration test.
|
||||
|
||||
```nc:run effect:build
|
||||
pnpm run build
|
||||
```
|
||||
```nc:run effect:test
|
||||
pnpm exec vitest run src/channels/discord-registration.test.ts
|
||||
```
|
||||
|
||||
Both must be clean before proceeding. `discord-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `discord`. It goes red if the `import './discord.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/discord` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
|
||||
`discord-registration.test.ts` imports the real channel barrel and asserts the
|
||||
registry contains `discord`. It goes red if the import line is deleted or drifts,
|
||||
if the barrel fails to evaluate, or if `@chat-adapter/discord` isn't installed
|
||||
(the import throws) — so it also covers the dependency from step 3. End-to-end
|
||||
delivery against a real server is verified manually once the service runs.
|
||||
|
||||
## Credentials
|
||||
|
||||
### Create Discord Bot
|
||||
Discord app setup is human and interactive — no parser can click through the
|
||||
Discord Developer Portal. The adapter is installed and registered, but it can't
|
||||
receive a message until the bot exists, has Message Content Intent, and shares a
|
||||
server with you. Tell the user:
|
||||
|
||||
1. Go to the [Discord Developer Portal](https://discord.com/developers/applications)
|
||||
2. Click **New Application** and give it a name (e.g., "NanoClaw Assistant")
|
||||
3. From the **General Information** tab, copy the **Application ID** and **Public Key**
|
||||
4. Go to the **Bot** tab and click **Add Bot** if needed
|
||||
5. Copy the Bot Token (click **Reset Token** if you need a new one — you can only see it once)
|
||||
6. Under **Privileged Gateway Intents**, enable **Message Content Intent**
|
||||
7. Go to **OAuth2** > **URL Generator**:
|
||||
- Scopes: select `bot`
|
||||
- Bot Permissions: select `Send Messages`, `Read Message History`, `Add Reactions`, `Attach Files`, `Use Slash Commands`
|
||||
8. Copy the generated URL and open it in your browser to invite the bot to your server
|
||||
|
||||
### Configure environment
|
||||
|
||||
All three values are required — the adapter will fail to start without `DISCORD_PUBLIC_KEY` and `DISCORD_APPLICATION_ID`.
|
||||
|
||||
Add to `.env`:
|
||||
|
||||
```bash
|
||||
DISCORD_BOT_TOKEN=your-bot-token
|
||||
DISCORD_APPLICATION_ID=your-application-id
|
||||
DISCORD_PUBLIC_KEY=your-public-key
|
||||
```nc:operator
|
||||
Create the Discord bot:
|
||||
1. Go to https://discord.com/developers/applications → New Application. Name it (e.g. "NanoClaw Assistant").
|
||||
2. Bot tab → Add Bot if needed → Reset Token, then copy the Bot Token (it's shown only once).
|
||||
3. Bot tab → Privileged Gateway Intents → enable Message Content Intent.
|
||||
4. OAuth2 → URL Generator → Scopes: bot; Bot Permissions: Send Messages, Read Message History, Add Reactions, Attach Files, Use Slash Commands.
|
||||
5. Open the generated URL and invite the bot to a server you're also in (a personal server is fine) — the bot can only DM you once you share a server.
|
||||
```
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
Paste the Bot Token (it's shown only once). You don't paste the Application ID or
|
||||
the Public Key by hand — the bot's own application record carries both, so a
|
||||
single call derives them from the token:
|
||||
|
||||
```nc:prompt bot_token secret validate:^[A-Za-z0-9._-]{50,}$
|
||||
Paste the Bot Token — Bot tab. Click `Reset Token` if you need a new one.
|
||||
```
|
||||
|
||||
Read the application's own record. `GET /oauth2/applications/@me` returns the
|
||||
Application ID (`id`), the Public Key (`verify_key`), and your own account as the
|
||||
app's owner (`owner.id`) — so the App ID, the Public Key, and your Discord user ID
|
||||
all come from this one call instead of being copied by hand. A bad token fails
|
||||
here, before the restart, rather than silently later:
|
||||
|
||||
```nc:run capture:application_id=.id,public_key=.verify_key,owner_handle=.owner.id effect:fetch
|
||||
curl -sf https://discord.com/api/v10/oauth2/applications/@me -H "Authorization: Bot {{bot_token}}"
|
||||
```
|
||||
|
||||
Store the token and the two derived credentials — the adapter reads them from
|
||||
`.env` and fails to start without `DISCORD_PUBLIC_KEY` and `DISCORD_APPLICATION_ID`
|
||||
(set-if-absent, so a value you've already filled in is never overwritten):
|
||||
|
||||
```nc:env-set
|
||||
DISCORD_BOT_TOKEN={{bot_token}}
|
||||
DISCORD_APPLICATION_ID={{application_id}}
|
||||
DISCORD_PUBLIC_KEY={{public_key}}
|
||||
```
|
||||
## Restart
|
||||
|
||||
Restart the service so it loads the Discord adapter and the credentials you just
|
||||
stored, and wait for its CLI socket before resolving:
|
||||
|
||||
```nc:run effect:restart
|
||||
bash setup/lib/restart.sh
|
||||
```
|
||||
|
||||
## Invite the bot to a shared server
|
||||
|
||||
The bot can only DM you once it shares a server with you. If you didn't already
|
||||
invite it via the OAuth2 URL Generator while setting up the app, do it now: add
|
||||
the bot to a server you're also in (a personal server is fine). Tell the user:
|
||||
|
||||
```nc:operator
|
||||
Open the invite link — https://discord.com/oauth2/authorize?client_id={{application_id}}&scope=bot&permissions=2147584064 — and add the bot to a server you're also in (a personal server works fine); the bot can only DM you once you share a server. If you already invited it while setting up the app, you can skip this.
|
||||
```
|
||||
|
||||
## Resolve your DM channel
|
||||
|
||||
The agent talks to you in your direct-message channel with the bot. Your Discord
|
||||
user ID was already derived as the application's owner (`owner_handle`), so all
|
||||
that's left is to open the DM and read back its channel id.
|
||||
|
||||
Open the DM with `POST /users/@me/channels` and take the channel id it returns as
|
||||
the conversation address `discord:@me:<channelId>` (if Discord refuses, the bot
|
||||
doesn't share a server with you yet — invite it, then retry):
|
||||
|
||||
```nc:run capture:platform_id effect:fetch
|
||||
curl -s -X POST https://discord.com/api/v10/users/@me/channels -H "Authorization: Bot {{bot_token}}" -H "Content-Type: application/json" -d '{"recipient_id":"{{owner_handle}}"}' | jq -er '"discord:@me:" + .id'
|
||||
```
|
||||
|
||||
`owner_handle` and `platform_id` are what the owner-wiring step needs. The
|
||||
greeting goes out over the DM channel, which works as soon as the bot shares a
|
||||
server with you.
|
||||
|
||||
## Next Steps
|
||||
|
||||
If you're in the middle of `/setup`, return to the setup flow now.
|
||||
|
||||
Otherwise, run `/manage-channels` to wire this channel to an agent group.
|
||||
If you're in the middle of `/setup`, return to the setup flow now. Otherwise wire
|
||||
this channel with `/init-first-agent` (or `/manage-channels`).
|
||||
|
||||
## Channel Info
|
||||
|
||||
- **type**: `discord`
|
||||
- **terminology**: Discord has "servers" (also called "guilds") containing "channels." Text channels start with #. The bot can also receive direct messages.
|
||||
- **platform-id-format**: `discord:@me:{dmChannelId}` for the owner DM (e.g. `discord:@me:1399...`), `discord:{guildId}:{channelId}` for server channels — both IDs required for channels.
|
||||
- **how-to-find-id**: Enable Developer Mode in Discord (Settings > App Settings > Advanced > Developer Mode). Then right-click a server and select "Copy Server ID" for the guild ID, and right-click the text channel and select "Copy Channel ID." The platform ID format used in registration is `discord:{guildId}:{channelId}` — both IDs are required.
|
||||
- **supports-threads**: yes
|
||||
- **typical-use**: Interactive chat — server channels or direct messages
|
||||
|
||||
@@ -54,10 +54,8 @@ Remove the NanoClaw block from your Emacs config (`config.el`, `~/.spacemacs`, o
|
||||
|
||||
Reload your config or restart Emacs.
|
||||
|
||||
## 5. Remove the messaging group (optional)
|
||||
## 5. Messaging group (left intact)
|
||||
|
||||
To clean up the wired messaging group:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "DELETE FROM messaging_group_agents WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type='emacs'); DELETE FROM messaging_groups WHERE channel_type='emacs';"
|
||||
```
|
||||
Your wired messaging group and conversation history are **not** removed — you
|
||||
created them at runtime, not this skill's install. To purge them deliberately,
|
||||
delete them yourself with `ncl messaging-groups delete <id>`.
|
||||
|
||||
@@ -12,14 +12,13 @@ ncl groups config remove-mcp-server --id <group-id> --name calendar
|
||||
|
||||
## 2. Remove the `.calendar-mcp` mount from the DB (per group)
|
||||
|
||||
There is no `ncl groups config remove-mount` verb yet (tracked in [#2395](https://github.com/nanocoai/nanoclaw/issues/2395)). Until it ships, drop the entry via the in-tree wrapper (`scripts/q.ts`):
|
||||
This is a **host-only / operator** verb — run it host-side. It's idempotent (a no-op if the mount is absent):
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
|
||||
SET additional_mounts = (SELECT json_group_array(value) FROM json_each(additional_mounts) \
|
||||
WHERE json_extract(value, '\$.containerPath') != '.calendar-mcp'), \
|
||||
updated_at = datetime('now') \
|
||||
WHERE agent_group_id = '<group-id>';"
|
||||
ncl groups config remove-mount \
|
||||
--id <group-id> \
|
||||
--host "$HOME/.calendar-mcp" \
|
||||
--container .calendar-mcp
|
||||
```
|
||||
|
||||
## 3. Delete the copied test file
|
||||
|
||||
@@ -133,6 +133,8 @@ pnpm exec vitest run src/gcal-dockerfile.test.ts
|
||||
|
||||
`cp` overwrites in place, so re-running this skill is safe.
|
||||
|
||||
**This is the skill's only in-tree integration test.** The Phase 3 `ncl groups config add-mcp-server` and `add-mount` steps are runtime writes to the central DB — they leave no line in the source tree whose deletion a test could catch, so a registration test is structurally inapplicable. They're verified at runtime instead (Phase 5).
|
||||
|
||||
### Rebuild the container image
|
||||
|
||||
```bash
|
||||
@@ -160,27 +162,22 @@ Approval behaviour depends on where you run it: from inside an agent's container
|
||||
|
||||
### Add the `.calendar-mcp` mount
|
||||
|
||||
There is no `ncl groups config add-mount` verb yet (tracked in [#2395](https://github.com/nanocoai/nanoclaw/issues/2395)). Until that ships, edit the DB directly via the in-tree wrapper (`scripts/q.ts` — `setup/verify.ts:5` codifies that NanoClaw avoids depending on the `sqlite3` CLI binary, so don't shell out to it):
|
||||
This is a **host-only / operator** verb — it's rejected from inside a container at any `cli_scope`, so run it host-side when you (the operator) apply this skill via `/setup`, `/customize`, or `/manage-mounts`. It's idempotent (skips if the mount is already present).
|
||||
|
||||
```bash
|
||||
GROUP_ID='<group-id>'
|
||||
HOST_PATH="$HOME/.calendar-mcp"
|
||||
MOUNT=$(jq -cn --arg h "$HOST_PATH" '{hostPath:$h, containerPath:".calendar-mcp", readonly:false}')
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
|
||||
SET additional_mounts = json_insert(additional_mounts, '\$[#]', json('$MOUNT')), \
|
||||
updated_at = datetime('now') \
|
||||
WHERE agent_group_id = '$GROUP_ID';"
|
||||
ncl groups config add-mount \
|
||||
--id <group-id> \
|
||||
--host "$HOME/.calendar-mcp" \
|
||||
--container .calendar-mcp
|
||||
```
|
||||
|
||||
Run from your NanoClaw project root (where `data/v2.db` lives). The `$[#]` placeholder is SQLite JSON1's append-to-end notation; it's `\$`-escaped so bash doesn't arithmetic-expand it before sqlite sees it. `updated_at` is ISO-string everywhere else in the schema, so use `datetime('now')` — not `strftime('%s','now')`, which would silently mix epoch ints into a column of YYYY-MM-DD HH:MM:SS strings.
|
||||
`--container` is relative (mount-security rejects absolute paths — additional mounts land at `/workspace/extra/<relative>`). No `--ro`: the MCP server may rewrite `credentials.json` on token refresh, so the mount must be read-write.
|
||||
|
||||
**Switch to `ncl groups config add-mount` once #2395 lands.** Update this skill at that time.
|
||||
|
||||
`containerPath` is relative (mount-security rejects absolute paths — additional mounts land at `/workspace/extra/<relative>`).
|
||||
The mount also needs to be in the external mount allowlist (`~/.config/nanoclaw/mount-allowlist.json`) to take effect at spawn — see the Phase 1 "Verify mount allowlist covers the path" step. A container restart (`ncl groups restart`) is needed for the mount to apply.
|
||||
|
||||
**Why this can't be `groups/<folder>/container.json`:** post-migration `014-container-configs`, `materializeContainerJson` in `src/container-config.ts` rewrites that file from the DB on every spawn. Anything hand-edited there is silently overwritten on next restart.
|
||||
|
||||
**Same-group-as-gmail tip:** if this group already has the gmail MCP + `.gmail-mcp` mount, both coexist — `ncl groups config add-mcp-server` only updates the named entry, and `json_insert` appends to `additional_mounts` without disturbing existing entries.
|
||||
**Same-group-as-gmail tip:** if this group already has the gmail MCP + `.gmail-mcp` mount, both coexist — `ncl groups config add-mcp-server` only updates the named entry, and `add-mount` appends to `additional_mounts` without disturbing existing entries.
|
||||
|
||||
## Phase 4: Build and Restart
|
||||
|
||||
|
||||
@@ -5,63 +5,70 @@ description: Add Google Chat channel integration via Chat SDK.
|
||||
|
||||
# Add Google Chat Channel
|
||||
|
||||
Adds Google Chat support via the Chat SDK bridge.
|
||||
Adds Google Chat support via the Chat SDK bridge. NanoClaw doesn't ship channels
|
||||
in trunk — this skill copies the Google Chat adapter in from the `channels`
|
||||
branch.
|
||||
|
||||
## Install
|
||||
The mechanical steps under **Apply** carry `nc:` directive fences: an agent
|
||||
reads the prose and applies them, and a parser can apply them deterministically
|
||||
from the same document. Every directive is idempotent, so the whole skill is
|
||||
safe to re-run; anything a parser can't apply falls back to the prose beside it.
|
||||
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the Google Chat adapter in from the `channels` branch.
|
||||
## Apply
|
||||
|
||||
### Pre-flight (idempotent)
|
||||
### 1. Copy the adapter and its registration test
|
||||
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
Fetch the `channels` branch and copy the Google Chat adapter and its
|
||||
registration test into `src/channels/` (overwrite — the branch is canonical):
|
||||
|
||||
- `src/channels/gchat.ts` exists
|
||||
- `src/channels/gchat-registration.test.ts` exists
|
||||
- `src/channels/index.ts` contains `import './gchat.js';`
|
||||
- `@chat-adapter/gchat` is listed in `package.json` dependencies
|
||||
|
||||
Otherwise continue. Every step below is safe to re-run.
|
||||
|
||||
### 1. Fetch the channels branch
|
||||
|
||||
```bash
|
||||
git fetch origin channels
|
||||
```nc:copy from-branch:channels
|
||||
src/channels/gchat.ts
|
||||
src/channels/gchat-registration.test.ts
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and its registration test
|
||||
### 2. Register the adapter
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/gchat.ts > src/channels/gchat.ts
|
||||
git show origin/channels:src/channels/gchat-registration.test.ts > src/channels/gchat-registration.test.ts
|
||||
```
|
||||
Append the self-registration import to the channel barrel (skipped if the line
|
||||
is already present). This one line is the skill's only reach-in into core:
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
```typescript
|
||||
```nc:append to:src/channels/index.ts
|
||||
import './gchat.js';
|
||||
```
|
||||
|
||||
### 4. Install the adapter package (pinned)
|
||||
### 3. Install the adapter package
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/gchat@4.27.0
|
||||
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
|
||||
|
||||
```nc:dep
|
||||
@chat-adapter/gchat@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
### 4. Build and validate
|
||||
|
||||
```bash
|
||||
Build first: it guards the typed `createChatSdkBridge(...)` core call and proves
|
||||
the dependency is installed. Then run the one integration test.
|
||||
|
||||
```nc:run effect:build
|
||||
pnpm run build
|
||||
```
|
||||
```nc:run effect:test
|
||||
pnpm exec vitest run src/channels/gchat-registration.test.ts
|
||||
```
|
||||
|
||||
Both must be clean before proceeding. `gchat-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `gchat`. It goes red if the `import './gchat.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/gchat` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
|
||||
|
||||
End-to-end message delivery against a real Google Chat space is verified manually once the service is running — see Next Steps and the webhook setup above.
|
||||
`gchat-registration.test.ts` imports the real channel barrel and asserts the
|
||||
registry contains `gchat`. It goes red if the import line is deleted or drifts,
|
||||
if the barrel fails to evaluate, or if `@chat-adapter/gchat` isn't installed (the
|
||||
import throws) — so it also covers the dependency from step 3. End-to-end
|
||||
delivery against a real Google Chat space is verified manually once the service
|
||||
runs — see Credentials and Next Steps.
|
||||
|
||||
## Credentials
|
||||
|
||||
Google Cloud setup is human and interactive — these steps are prose, not
|
||||
directives (no parser can click through the Google Cloud Console). A recipe
|
||||
rebuild produces a compiling, registered adapter that cannot receive a message
|
||||
until they're done.
|
||||
|
||||
> 1. Go to [Google Cloud Console](https://console.cloud.google.com)
|
||||
> 2. Create or select a project
|
||||
> 3. Enable the **Google Chat API**
|
||||
@@ -73,21 +80,32 @@ End-to-end message delivery against a real Google Chat space is verified manuall
|
||||
> - Grant the Chat Bot role
|
||||
> - Create a JSON key and download it
|
||||
|
||||
### Configure environment
|
||||
### Store the credentials
|
||||
|
||||
Add the service account JSON as a single-line string to `.env`:
|
||||
Capture the service account JSON, then write it. `prompt` only *asks* and binds
|
||||
the answer to a name; a separate directive consumes it — so the same prompt
|
||||
could feed `ncl` or the OneCLI vault instead of `.env` by swapping only the
|
||||
consumer. Here it goes to `.env` (set-if-absent — a value you've already filled
|
||||
in is never overwritten) as a single-line string:
|
||||
|
||||
```bash
|
||||
GCHAT_CREDENTIALS={"type":"service_account","project_id":"...","private_key":"...","client_email":"..."}
|
||||
```nc:prompt gchat_credentials secret
|
||||
Paste the service account JSON as a single line — the key file you downloaded, e.g. `{"type":"service_account","project_id":"...","private_key":"...","client_email":"..."}`.
|
||||
```
|
||||
```nc:env-set
|
||||
GCHAT_CREDENTIALS={{gchat_credentials}}
|
||||
```
|
||||
### Webhook server
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
The Chat SDK bridge automatically starts a shared webhook server on port 3000
|
||||
(`WEBHOOK_PORT` to change it), handling `/webhook/gchat`. This port must be
|
||||
publicly reachable for Google Chat to deliver events — it's the HTTP endpoint
|
||||
URL you set in the Connection settings above. Running locally, expose it with
|
||||
ngrok (`ngrok http 3000`), a Cloudflare Tunnel, or a reverse proxy on a VPS.
|
||||
|
||||
## Next Steps
|
||||
|
||||
If you're in the middle of `/setup`, return to the setup flow now.
|
||||
|
||||
Otherwise, run `/manage-channels` to wire this channel to an agent group.
|
||||
If you're in the middle of `/setup`, return to the setup flow now. Otherwise run
|
||||
`/manage-channels` to wire this channel to an agent group.
|
||||
|
||||
## Channel Info
|
||||
|
||||
@@ -97,3 +115,5 @@ Otherwise, run `/manage-channels` to wire this channel to an agent group.
|
||||
- **supports-threads**: yes
|
||||
- **typical-use**: Interactive chat — team spaces or direct messages
|
||||
- **default-isolation**: Same agent group for spaces where you're the primary user. Separate agent group for spaces with different teams or sensitive contexts.
|
||||
</content>
|
||||
</invoke>
|
||||
|
||||
@@ -5,64 +5,68 @@ description: Add GitHub channel integration via Chat SDK. PR and issue comment t
|
||||
|
||||
# Add GitHub Channel
|
||||
|
||||
Adds GitHub support via the Chat SDK bridge. The agent participates in PR and issue comment threads.
|
||||
Adds GitHub support via the Chat SDK bridge. The agent participates in PR and
|
||||
issue comment threads. NanoClaw doesn't ship channels in trunk — this skill
|
||||
copies the GitHub adapter in from the `channels` branch.
|
||||
|
||||
The mechanical steps under **Apply** carry `nc:` directive fences: an agent
|
||||
reads the prose and applies them, and a parser can apply them deterministically
|
||||
from the same document. Every directive is idempotent, so the whole skill is
|
||||
safe to re-run; anything a parser can't apply falls back to the prose beside it.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
You need a **dedicated GitHub bot account** (not your personal account). The adapter uses this account to post replies and filters out its own messages to avoid loops. Create a free GitHub account for your bot (e.g. `my-org-bot`), then invite it as a collaborator with write access to the repos you want monitored.
|
||||
|
||||
## Install
|
||||
## Apply
|
||||
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the GitHub adapter in from the `channels` branch.
|
||||
### 1. Copy the adapter
|
||||
|
||||
### Pre-flight (idempotent)
|
||||
Fetch the `channels` branch and copy the GitHub adapter into `src/channels/`
|
||||
(overwrite — the branch is canonical):
|
||||
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
|
||||
- `src/channels/github.ts` exists
|
||||
- `src/channels/github-registration.test.ts` exists
|
||||
- `src/channels/index.ts` contains `import './github.js';`
|
||||
- `@chat-adapter/github` is listed in `package.json` dependencies
|
||||
|
||||
Otherwise continue. Every step below is safe to re-run.
|
||||
|
||||
### 1. Fetch the channels branch
|
||||
|
||||
```bash
|
||||
git fetch origin channels
|
||||
```nc:copy from-branch:channels
|
||||
src/channels/github.ts
|
||||
src/channels/github-registration.test.ts
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and its registration test
|
||||
### 2. Register the adapter
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/github.ts > src/channels/github.ts
|
||||
git show origin/channels:src/channels/github-registration.test.ts > src/channels/github-registration.test.ts
|
||||
```
|
||||
Append the self-registration import to the channel barrel (skipped if the line
|
||||
is already present). This one line is the skill's only reach-in into core:
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
```typescript
|
||||
```nc:append to:src/channels/index.ts
|
||||
import './github.js';
|
||||
```
|
||||
|
||||
### 4. Install the adapter package (pinned)
|
||||
### 3. Install the adapter package
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/github@4.27.0
|
||||
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
|
||||
|
||||
```nc:dep
|
||||
@chat-adapter/github@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
### 4. Build and validate
|
||||
|
||||
```bash
|
||||
The build guards the typed `createChatSdkBridge(...)` core call and proves the
|
||||
dependency is installed (the adapter import throws if `@chat-adapter/github`
|
||||
isn't present):
|
||||
|
||||
```nc:run effect:build
|
||||
pnpm run build
|
||||
```
|
||||
```nc:run effect:test
|
||||
pnpm exec vitest run src/channels/github-registration.test.ts
|
||||
```
|
||||
|
||||
Both must be clean before proceeding. `github-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `github`. It goes red if the `import './github.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/github` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
|
||||
`github-registration.test.ts` imports the real channel barrel and asserts the
|
||||
registry contains `github`. It goes red if the import line is deleted or drifts,
|
||||
if the barrel fails to evaluate, or if `@chat-adapter/github` isn't installed
|
||||
(the import throws) — so it also covers the dependency from step 3.
|
||||
|
||||
End-to-end message delivery against a real GitHub repo is verified manually once the service is running — see Next Steps and the webhook setup above.
|
||||
End-to-end message delivery against a real GitHub repo is verified manually once
|
||||
the service is running — see Next Steps and the webhook setup below.
|
||||
|
||||
## Credentials
|
||||
|
||||
@@ -88,18 +92,28 @@ On each repo (logged in as the repo owner/admin):
|
||||
|
||||
### 3. Configure environment
|
||||
|
||||
Add to `.env`:
|
||||
Capture the three values, then write them. `prompt` only *asks* and binds the
|
||||
answer to a name; a separate directive consumes it — so the same prompts could
|
||||
feed `ncl` or the OneCLI vault instead of `.env` by swapping only the consumer.
|
||||
Here they go to `.env` (set-if-absent — a value you've already filled in is
|
||||
never overwritten):
|
||||
|
||||
```bash
|
||||
GITHUB_TOKEN=github_pat_...
|
||||
GITHUB_WEBHOOK_SECRET=your-webhook-secret
|
||||
GITHUB_BOT_USERNAME=your-bot-username
|
||||
```nc:prompt github_token secret
|
||||
Paste the Fine-grained Personal Access Token for the bot account — starts with `github_pat_`.
|
||||
```
|
||||
```nc:prompt webhook_secret secret
|
||||
Paste the webhook secret you generated for the repo webhook(s).
|
||||
```
|
||||
```nc:prompt bot_username
|
||||
Enter the bot account's GitHub username exactly (used for @-mention detection).
|
||||
```
|
||||
```nc:env-set
|
||||
GITHUB_TOKEN={{github_token}}
|
||||
GITHUB_WEBHOOK_SECRET={{webhook_secret}}
|
||||
GITHUB_BOT_USERNAME={{bot_username}}
|
||||
```
|
||||
|
||||
`GITHUB_BOT_USERNAME` must match the bot account's GitHub username exactly. This is used for @-mention detection — the agent responds when someone writes `@your-bot-username` in a PR or issue comment.
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
|
||||
## Wiring
|
||||
|
||||
Ask the user: **Is this a private or public repo?**
|
||||
|
||||
@@ -19,17 +19,17 @@ ncl groups config remove-mcp-server --id <group-id> --name gmail
|
||||
|
||||
## 3. Remove the `.gmail-mcp` mount (per group)
|
||||
|
||||
There is no `ncl groups config remove-mount` verb yet ([#2395](https://github.com/nanocoai/nanoclaw/issues/2395)). Edit the central DB via the in-tree wrapper (`scripts/q.ts` — NanoClaw avoids depending on the `sqlite3` CLI, `setup/verify.ts:5`). Run from your NanoClaw project root (where `data/v2.db` lives):
|
||||
Remove the mount with the host-only `ncl groups config remove-mount` verb (operator-only; rejected from inside a container). For each group that had Gmail wired:
|
||||
|
||||
```bash
|
||||
GROUP_ID='<group-id>'
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
|
||||
SET additional_mounts = (SELECT json_group_array(value) FROM json_each(additional_mounts) \
|
||||
WHERE json_extract(value, '\$.containerPath') != '.gmail-mcp'), \
|
||||
updated_at = datetime('now') \
|
||||
WHERE agent_group_id = '$GROUP_ID';"
|
||||
ncl groups config remove-mount \
|
||||
--id <group-id> \
|
||||
--host "$HOME/.gmail-mcp" \
|
||||
--container .gmail-mcp
|
||||
```
|
||||
|
||||
The verb is idempotent — a no-op if the mount is already absent.
|
||||
|
||||
## 4. Remove the Dockerfile install
|
||||
|
||||
In `container/Dockerfile`, delete the `ARG GMAIL_MCP_VERSION=...` line and the `pnpm install -g` `RUN` block that installs `@gongrzhe/server-gmail-autoauth-mcp` and `zod-to-json-schema`.
|
||||
|
||||
@@ -181,21 +181,16 @@ Approval behaviour depends on where you run it: from inside an agent's container
|
||||
|
||||
### Add the `.gmail-mcp` mount
|
||||
|
||||
There is no `ncl groups config add-mount` verb yet (tracked in [#2395](https://github.com/nanocoai/nanoclaw/issues/2395)). Until that ships, edit the DB directly via the in-tree wrapper (`scripts/q.ts` — `setup/verify.ts:5` codifies that NanoClaw avoids depending on the `sqlite3` CLI binary, so don't shell out to it):
|
||||
Register the mount with the host-only `ncl groups config add-mount` verb. For each chosen `<group-id>`:
|
||||
|
||||
```bash
|
||||
GROUP_ID='<group-id>'
|
||||
HOST_PATH="$HOME/.gmail-mcp"
|
||||
MOUNT=$(jq -cn --arg h "$HOST_PATH" '{hostPath:$h, containerPath:".gmail-mcp", readonly:false}')
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
|
||||
SET additional_mounts = json_insert(additional_mounts, '\$[#]', json('$MOUNT')), \
|
||||
updated_at = datetime('now') \
|
||||
WHERE agent_group_id = '$GROUP_ID';"
|
||||
ncl groups config add-mount \
|
||||
--id <group-id> \
|
||||
--host "$HOME/.gmail-mcp" \
|
||||
--container .gmail-mcp
|
||||
```
|
||||
|
||||
Run from your NanoClaw project root (where `data/v2.db` lives). The `$[#]` placeholder is SQLite JSON1's append-to-end notation; it's `\$`-escaped so bash doesn't arithmetic-expand it before sqlite sees it. `updated_at` is ISO-string everywhere else in the schema, so use `datetime('now')` — not `strftime('%s','now')`, which would silently mix epoch ints into a column of YYYY-MM-DD HH:MM:SS strings.
|
||||
|
||||
**Switch to `ncl groups config add-mount` once #2395 lands.** Update this skill at that time.
|
||||
`--host` is the host path, `--container` is the in-container path (relative, lands at `/workspace/extra/.gmail-mcp`). No `--ro` — the MCP server writes refreshed token state back into the mount. The verb is idempotent (a re-run skips if the mount is already present) and operator-only (host-side; rejected from inside a container), so run it from a host operator shell when applying this skill.
|
||||
|
||||
**Why the container path is relative:** `mount-security` rejects absolute `containerPath` values. Additional mounts are prefixed with `/workspace/extra/`, so `containerPath: ".gmail-mcp"` lands at `/workspace/extra/.gmail-mcp`. The MCP server's `GMAIL_OAUTH_PATH` / `GMAIL_CREDENTIALS_PATH` env vars point at that absolute location inside the container.
|
||||
|
||||
|
||||
@@ -8,10 +8,9 @@
|
||||
* allowedTools: [ ...TOOL_ALLOWLIST, ...Object.keys(this.mcpServers).map(mcpAllowPattern) ]
|
||||
*
|
||||
* `mcpAllowPattern` is not exported and the call site lives inside the SDK query options,
|
||||
* so we assert the derivation structurally. Delete or rename the derivation and this goes
|
||||
* red — surfacing that `gmail` tools would silently be filtered out despite being registered.
|
||||
*
|
||||
* `mcpAllowPattern` itself is exercised directly to prove `gmail` -> `mcp__gmail__*`.
|
||||
* so the derivation is non-invocable from a test — we guard it structurally. Delete or
|
||||
* rename either half (the function or the spread into allowedTools) and this goes red,
|
||||
* surfacing that `gmail` tools would silently be filtered out despite being registered.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
@@ -25,11 +24,6 @@ function source(): { sf: ts.SourceFile; text: string } {
|
||||
return { sf: ts.createSourceFile(p, text, ts.ScriptTarget.Latest, true), text };
|
||||
}
|
||||
|
||||
/** Reimplement the sanitizer the provider applies, to assert the gmail name maps cleanly. */
|
||||
function expectedPattern(name: string): string {
|
||||
return `mcp__${name.replace(/[^a-zA-Z0-9_-]/g, '_')}__*`;
|
||||
}
|
||||
|
||||
describe('claude.ts derives MCP allow-patterns from the registered servers', () => {
|
||||
const { sf, text } = source();
|
||||
|
||||
@@ -48,8 +42,4 @@ describe('claude.ts derives MCP allow-patterns from the registered servers', ()
|
||||
const flat = text.replace(/\s+/g, ' ');
|
||||
expect(flat).toContain('Object.keys(this.mcpServers).map(mcpAllowPattern)');
|
||||
});
|
||||
|
||||
it('maps a gmail server name to mcp__gmail__*', () => {
|
||||
expect(expectedPattern('gmail')).toBe('mcp__gmail__*');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,110 +5,196 @@ description: Add iMessage channel integration via Chat SDK. Local (macOS) or rem
|
||||
|
||||
# Add iMessage Channel
|
||||
|
||||
Adds iMessage support via the Chat SDK bridge. Two modes: local (macOS with Full Disk Access) or remote (Photon API).
|
||||
Adds iMessage support via the Chat SDK bridge. Two modes: local (macOS with Full
|
||||
Disk Access) or remote (Photon API). NanoClaw doesn't ship channels in trunk —
|
||||
this skill copies the iMessage adapter in from the `channels` branch.
|
||||
|
||||
## Install
|
||||
The mechanical steps under **Apply** carry `nc:` directive fences: an agent reads
|
||||
the prose and applies them, and a parser can apply them deterministically from
|
||||
the same document. Every directive is idempotent, so the whole skill is safe to
|
||||
re-run; anything a parser can't apply falls back to the prose beside it.
|
||||
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the iMessage adapter in from the `channels` branch.
|
||||
## Apply
|
||||
|
||||
### Pre-flight (idempotent)
|
||||
### 1. Copy the adapter
|
||||
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
Fetch the `channels` branch and copy the iMessage adapter into `src/channels/`
|
||||
(overwrite — the branch is canonical):
|
||||
|
||||
- `src/channels/imessage.ts` exists
|
||||
- `src/channels/imessage-registration.test.ts` exists
|
||||
- `src/channels/index.ts` contains `import './imessage.js';`
|
||||
- `chat-adapter-imessage` is listed in `package.json` dependencies
|
||||
|
||||
Otherwise continue. Every step below is safe to re-run.
|
||||
|
||||
### 1. Fetch the channels branch
|
||||
|
||||
```bash
|
||||
git fetch origin channels
|
||||
```nc:copy from-branch:channels
|
||||
src/channels/imessage.ts
|
||||
src/channels/imessage-registration.test.ts
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and its registration test
|
||||
### 2. Register the adapter
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/imessage.ts > src/channels/imessage.ts
|
||||
git show origin/channels:src/channels/imessage-registration.test.ts > src/channels/imessage-registration.test.ts
|
||||
```
|
||||
Append the self-registration import to the channel barrel (skipped if the line
|
||||
is already present). This one line is the skill's only reach-in into core:
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
```typescript
|
||||
```nc:append to:src/channels/index.ts
|
||||
import './imessage.js';
|
||||
```
|
||||
|
||||
### 4. Install the adapter package (pinned)
|
||||
### 3. Install the adapter package
|
||||
|
||||
```bash
|
||||
pnpm install chat-adapter-imessage@0.1.1
|
||||
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
|
||||
|
||||
```nc:dep
|
||||
chat-adapter-imessage@0.1.1
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
### 4. Build and validate
|
||||
|
||||
```bash
|
||||
Build guards the typed `createChatSdkBridge(...)` core call and proves the
|
||||
dependency is installed (the adapter's top-level `import` from
|
||||
`chat-adapter-imessage` throws if it isn't):
|
||||
|
||||
```nc:run effect:build
|
||||
pnpm run build
|
||||
```
|
||||
```nc:run effect:test
|
||||
pnpm exec vitest run src/channels/imessage-registration.test.ts
|
||||
```
|
||||
|
||||
Both must be clean before proceeding. `imessage-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `imessage`. It goes red if the `import './imessage.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `chat-adapter-imessage` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
|
||||
`imessage-registration.test.ts` imports the real channel barrel and asserts the
|
||||
registry contains `imessage` — it goes red if the import line is deleted or
|
||||
drifts, if the barrel fails to evaluate, or if `chat-adapter-imessage` isn't
|
||||
installed (the import throws), so it also covers the dependency from step 3.
|
||||
|
||||
End-to-end message delivery against a real iMessage account is verified manually once the service is running — see Next Steps.
|
||||
End-to-end message delivery against a real iMessage account is verified manually
|
||||
once the service is running — see Next Steps.
|
||||
|
||||
## Credentials
|
||||
|
||||
iMessage runs in one of two modes:
|
||||
|
||||
- **Local (macOS)** — the bot runs on this Mac and talks via the signed-in
|
||||
iMessage account. Reading `chat.db` needs Full Disk Access granted to the
|
||||
Node binary the host runs under.
|
||||
- **Remote (Photon API)** — the bot talks to a separate Photon server that owns
|
||||
an iMessage account on another Mac. Use this off macOS, or to keep this Mac's
|
||||
chat history out of the loop.
|
||||
|
||||
Mode choice and the Full Disk Access / Photon walkthroughs are human and
|
||||
interactive. Pick the mode first (local is the macOS default; remote is the only
|
||||
option off macOS), then walk only that mode's setup — the other mode's steps are
|
||||
skipped:
|
||||
|
||||
```nc:prompt mode validate:^(local|remote)$
|
||||
How should iMessage run — `local` (this Mac, needs Full Disk Access) or `remote` (a Photon server)?
|
||||
```
|
||||
|
||||
### Local Mode (macOS)
|
||||
|
||||
Requirements: macOS with Full Disk Access granted to the Node.js binary.
|
||||
Requirements: macOS, with Full Disk Access granted to the Node binary. Without
|
||||
it the adapter can't read `chat.db` and inbound messages never arrive.
|
||||
|
||||
The Node binary path is buried deep (e.g. `~/.nvm/versions/node/v22.x.x/bin/node`). To make it easy, open the folder in Finder so the user can drag the file into System Settings:
|
||||
Local mode only works on a Mac — it reads this machine's iMessage `chat.db`
|
||||
directly, and there is no such database off macOS. On any other OS, stop here and
|
||||
use remote (Photon) mode instead; otherwise you'd write a local config that can
|
||||
never receive a message:
|
||||
|
||||
```bash
|
||||
open "$(dirname "$(which node)")"
|
||||
```nc:run effect:check when:mode=local
|
||||
[ "$(uname)" = Darwin ]
|
||||
```
|
||||
|
||||
The Node binary path is buried deep (e.g. `~/.nvm/versions/node/v22.x.x/bin/node`),
|
||||
so open its folder in Finder to make the drag-and-drop target obvious. Harmless
|
||||
off a desktop (SSH/headless) — it just no-ops:
|
||||
|
||||
```nc:run effect:external when:mode=local
|
||||
open "$(dirname "$(which node)")" 2>/dev/null || true
|
||||
```
|
||||
|
||||
Then tell the user:
|
||||
|
||||
1. Open **System Settings** > **Privacy & Security** > **Full Disk Access**
|
||||
2. Click **+**, then drag the `node` file from the Finder window that just opened
|
||||
3. Toggle it on
|
||||
```nc:operator when:mode=local
|
||||
Grant Full Disk Access to Node so iMessage can read your chat history:
|
||||
1. Open System Settings > Privacy & Security > Full Disk Access.
|
||||
2. Click +, then drag the "node" file from the Finder window that just opened.
|
||||
3. Toggle it on, then come back here.
|
||||
```
|
||||
|
||||
Stop and wait for the user to confirm before continuing.
|
||||
Stop and wait for the user to confirm Full Disk Access is granted before
|
||||
continuing.
|
||||
|
||||
### Remote Mode (Photon API)
|
||||
|
||||
1. Set up a [Photon](https://photon.codes) account
|
||||
2. Get your server URL and API key
|
||||
Photon is a separate service that owns an iMessage account and exposes it over
|
||||
HTTP; NanoClaw talks to it via its API. Tell the user:
|
||||
|
||||
```nc:operator when:mode=remote
|
||||
Set up remote iMessage via Photon:
|
||||
1. Create a Photon server: https://photon.codes
|
||||
2. Copy the server URL and API key from your Photon dashboard.
|
||||
```
|
||||
|
||||
Then collect the two values:
|
||||
|
||||
```nc:prompt server_url when:mode=remote validate:^https?:// flags:i reuse:IMESSAGE_SERVER_URL
|
||||
Your Photon server URL — starts with http:// or https:// (e.g. https://photon.example.com).
|
||||
```
|
||||
```nc:prompt api_key secret when:mode=remote reuse:IMESSAGE_API_KEY
|
||||
Your Photon API key — from the Photon dashboard.
|
||||
```
|
||||
|
||||
### Configure environment
|
||||
|
||||
**Local mode** -- add to `.env`:
|
||||
The two modes use different `.env` keys. Write only the keys for the chosen
|
||||
mode, and strip the opposite mode's keys so a stale value can't confuse the
|
||||
adapter's factory. The configure script owns this upsert-and-remove (a plain
|
||||
set-if-absent env write can neither replace a stale value nor delete a key):
|
||||
|
||||
```bash
|
||||
IMESSAGE_ENABLED=true
|
||||
IMESSAGE_LOCAL=true
|
||||
**Local mode** — writes `IMESSAGE_LOCAL=true` and `IMESSAGE_ENABLED=true`, and
|
||||
removes `IMESSAGE_SERVER_URL` / `IMESSAGE_API_KEY` if present:
|
||||
|
||||
```nc:run effect:external when:mode=local
|
||||
bash setup/channels/imessage-configure.sh local
|
||||
```
|
||||
|
||||
**Remote mode** -- add to `.env`:
|
||||
**Remote mode** — writes `IMESSAGE_LOCAL=false`, `IMESSAGE_SERVER_URL`, and
|
||||
`IMESSAGE_API_KEY`, and removes `IMESSAGE_ENABLED` if present:
|
||||
|
||||
```bash
|
||||
IMESSAGE_LOCAL=false
|
||||
IMESSAGE_SERVER_URL=https://your-photon-server.com
|
||||
IMESSAGE_API_KEY=your-api-key
|
||||
```nc:run effect:external when:mode=remote
|
||||
bash setup/channels/imessage-configure.sh remote "{{server_url}}" "{{api_key}}"
|
||||
```
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
## Restart
|
||||
|
||||
Restart the service so it loads the iMessage adapter and the credentials you
|
||||
just stored, and wait for its CLI socket before wiring:
|
||||
|
||||
```nc:run effect:restart
|
||||
bash setup/lib/restart.sh
|
||||
```
|
||||
|
||||
## Resolve your iMessage handle
|
||||
|
||||
The agent greets you in the iMessage conversation tied to the phone number or
|
||||
Apple ID email you message from — that handle is both your identity and the
|
||||
conversation address. Resolve it so the owner-wiring step can target it.
|
||||
|
||||
```nc:prompt owner_handle validate:^(\+\d{8,15}|[^\s@]+@[^\s@]+\.[^\s@]+)$
|
||||
The phone number or email you iMessage from — a +E.164 number (e.g. +14155551234) or an email / Apple ID (e.g. you@icloud.com).
|
||||
```
|
||||
|
||||
iMessage is a native adapter: it sends the raw handle as the conversation
|
||||
address, with no channel prefix — so the messaging-group platform id is that
|
||||
handle as-is.
|
||||
|
||||
```nc:run capture:platform_id
|
||||
echo "{{owner_handle}}"
|
||||
```
|
||||
|
||||
`owner_handle` and `platform_id` are what the owner-wiring step needs. The
|
||||
welcome iMessage goes out through the adapter once the service is running — in
|
||||
local mode that needs Full Disk Access granted (above); in remote mode it goes
|
||||
via your Photon server.
|
||||
|
||||
## Next Steps
|
||||
|
||||
If you're in the middle of `/setup`, return to the setup flow now.
|
||||
|
||||
Otherwise, run `/manage-channels` to wire this channel to an agent group.
|
||||
If you're in the middle of `/setup`, return to the setup flow now. Otherwise wire
|
||||
this channel with `/init-first-agent` (or `/manage-channels`).
|
||||
|
||||
## Channel Info
|
||||
|
||||
|
||||
@@ -18,16 +18,7 @@ rm -f src/channels/linear.ts src/channels/linear-registration.test.ts
|
||||
|
||||
## 2. Remove credentials
|
||||
|
||||
Remove the Linear env vars from `.env`, then re-sync to the container:
|
||||
|
||||
```bash
|
||||
LINEAR_CLIENT_ID
|
||||
LINEAR_CLIENT_SECRET
|
||||
LINEAR_API_KEY
|
||||
LINEAR_WEBHOOK_SECRET
|
||||
LINEAR_BOT_USERNAME
|
||||
LINEAR_TEAM_KEY
|
||||
```
|
||||
Remove `LINEAR_CLIENT_ID`, `LINEAR_CLIENT_SECRET`, `LINEAR_API_KEY`, `LINEAR_WEBHOOK_SECRET`, `LINEAR_BOT_USERNAME`, and `LINEAR_TEAM_KEY` from `.env`, then re-sync to the container:
|
||||
|
||||
```bash
|
||||
mkdir -p data/env && cp .env data/env/env
|
||||
|
||||
@@ -5,7 +5,15 @@ description: Add Linear channel integration via Chat SDK. Issue comment threads
|
||||
|
||||
# Add Linear Channel
|
||||
|
||||
Adds Linear support via the Chat SDK bridge. The agent participates in issue comment threads. Every comment on a Linear issue triggers the agent — no @-mention needed.
|
||||
Adds Linear support via the Chat SDK bridge. The agent participates in issue
|
||||
comment threads. Every comment on a Linear issue triggers the agent — no
|
||||
@-mention needed. NanoClaw doesn't ship channels in trunk — this skill copies the
|
||||
Linear adapter in from the `channels` branch.
|
||||
|
||||
The mechanical steps under **Apply** carry `nc:` directive fences: an agent reads
|
||||
the prose and applies them, and a parser can apply them deterministically from
|
||||
the same document. Every directive is idempotent, so the whole skill is safe to
|
||||
re-run; anything a parser can't apply falls back to the prose beside it.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -20,61 +28,65 @@ Adds Linear support via the Chat SDK bridge. The agent participates in issue com
|
||||
|
||||
**Alternative:** Use a Personal API Key (`LINEAR_API_KEY`) for simpler setup. The agent will post as you, and your own comments will be filtered (other team members' comments still work).
|
||||
|
||||
## Install
|
||||
## Apply
|
||||
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the Linear adapter in from the `channels` branch and wires it into the channel registry. Linear OAuth apps post and read comments under an app identity that can't be @-mentioned, so when you wire the channel in `/manage-channels`, pick an engage mode that responds to plain comments rather than mention-only.
|
||||
Linear OAuth apps post and read comments under an app identity that can't be
|
||||
@-mentioned, so when you wire the channel in `/manage-channels`, pick an engage
|
||||
mode that responds to plain comments rather than mention-only.
|
||||
|
||||
### Pre-flight (idempotent)
|
||||
### 1. Copy the adapter and its registration test
|
||||
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
Fetch the `channels` branch and copy the Linear adapter and its registration
|
||||
test into `src/channels/` (overwrite — the branch is canonical):
|
||||
|
||||
- `src/channels/linear.ts` exists
|
||||
- `src/channels/linear-registration.test.ts` exists
|
||||
- `src/channels/index.ts` contains `import './linear.js';`
|
||||
- `@chat-adapter/linear` is listed in `package.json` dependencies
|
||||
|
||||
Otherwise continue. Every step below is safe to re-run.
|
||||
|
||||
### 1. Fetch the channels branch
|
||||
|
||||
```bash
|
||||
git fetch origin channels
|
||||
```nc:copy from-branch:channels
|
||||
src/channels/linear.ts
|
||||
src/channels/linear-registration.test.ts
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and its registration test
|
||||
### 2. Register the adapter
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/linear.ts > src/channels/linear.ts
|
||||
git show origin/channels:src/channels/linear-registration.test.ts > src/channels/linear-registration.test.ts
|
||||
```
|
||||
Append the self-registration import to the channel barrel (skipped if the line
|
||||
is already present). This one line is the skill's only reach-in into the channel
|
||||
registry:
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
```typescript
|
||||
```nc:append to:src/channels/index.ts
|
||||
import './linear.js';
|
||||
```
|
||||
|
||||
### 4. Install the adapter package (pinned)
|
||||
### 3. Install the adapter package
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/linear@4.27.0
|
||||
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
|
||||
|
||||
```nc:dep
|
||||
@chat-adapter/linear@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
### 4. Build and validate
|
||||
|
||||
```bash
|
||||
Build first: it guards the typed `createChatSdkBridge(...)` core call and proves
|
||||
the dependency is installed. Then run the one integration test.
|
||||
|
||||
```nc:run effect:build
|
||||
pnpm run build
|
||||
```
|
||||
```nc:run effect:test
|
||||
pnpm exec vitest run src/channels/linear-registration.test.ts
|
||||
```
|
||||
|
||||
Both must be clean before proceeding. `linear-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `linear`. It goes red if the `import './linear.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/linear` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
|
||||
|
||||
End-to-end message delivery against a real Linear workspace is verified manually once the service is running — see Wiring and Next Steps.
|
||||
Both must be clean before proceeding. `linear-registration.test.ts` imports the
|
||||
real channel barrel and asserts the registry contains `linear`. It goes red if
|
||||
the `import './linear.js';` line is deleted or drifts, if the barrel fails to
|
||||
evaluate, or if `@chat-adapter/linear` isn't installed (the import throws) — so
|
||||
it also covers the dependency from step 3. End-to-end message delivery against a
|
||||
real Linear workspace is verified manually once the service is running — see
|
||||
Wiring and Next Steps.
|
||||
|
||||
## Credentials
|
||||
|
||||
Linear app and webhook setup is human and interactive — these steps are prose
|
||||
(no parser can click through the Linear UI), except the final env write.
|
||||
|
||||
### 1. Set up a webhook
|
||||
|
||||
1. Go to **Linear Settings** > **API** > **Webhooks** > **New webhook**
|
||||
@@ -86,48 +98,68 @@ End-to-end message delivery against a real Linear workspace is verified manually
|
||||
|
||||
Note: Linear webhook delivery may be delayed 1-5 minutes for new webhooks. This is normal.
|
||||
|
||||
### 2. Configure environment
|
||||
### 2. Store the credentials
|
||||
|
||||
Add to `.env`:
|
||||
Capture the values, then write them. `prompt` only *asks* and binds the answer
|
||||
to a name; a separate directive consumes it. Here they go to `.env`
|
||||
(set-if-absent — a value you've already filled in is never overwritten) and sync
|
||||
to the container.
|
||||
|
||||
Use **either** the OAuth app credentials (recommended) **or** a Personal API key.
|
||||
For the API-key path, paste `none` at the OAuth prompts and set `LINEAR_API_KEY`
|
||||
in `.env` by hand (commented in the template below). `LINEAR_BOT_USERNAME` is the
|
||||
display name for the bot, used for self-message detection when using a Personal
|
||||
API Key. `LINEAR_TEAM_KEY` is the Linear team key (e.g. `ENG`, `NAN`) — find it
|
||||
in Linear under Settings > Teams; all issues in this team route to one messaging
|
||||
group.
|
||||
|
||||
```nc:prompt linear_client_id secret
|
||||
Paste the OAuth Client ID — Linear Settings > API > OAuth Applications. Paste `none` if using a Personal API key instead.
|
||||
```
|
||||
```nc:prompt linear_client_secret secret
|
||||
Paste the OAuth Client Secret. Paste `none` if using a Personal API key instead.
|
||||
```
|
||||
```nc:prompt linear_webhook_secret secret
|
||||
Paste the webhook signing secret from the webhook you just created.
|
||||
```
|
||||
```nc:prompt linear_team_key
|
||||
Enter the Linear team key (e.g. `ENG`, `NAN`) — Settings > Teams.
|
||||
```
|
||||
```nc:prompt linear_bot_username
|
||||
Enter the bot display name (e.g. `NanoClaw Bot`).
|
||||
```
|
||||
```nc:env-set
|
||||
LINEAR_CLIENT_ID={{linear_client_id}}
|
||||
LINEAR_CLIENT_SECRET={{linear_client_secret}}
|
||||
LINEAR_WEBHOOK_SECRET={{linear_webhook_secret}}
|
||||
LINEAR_TEAM_KEY={{linear_team_key}}
|
||||
LINEAR_BOT_USERNAME={{linear_bot_username}}
|
||||
```
|
||||
If you went the Personal API key route, add this line to `.env` instead of the
|
||||
OAuth pair (agent posts as you, your own comments are filtered):
|
||||
|
||||
```bash
|
||||
# OAuth app (recommended)
|
||||
LINEAR_CLIENT_ID=your-client-id
|
||||
LINEAR_CLIENT_SECRET=your-client-secret
|
||||
|
||||
# OR Personal API key (simpler, but agent posts as you)
|
||||
# LINEAR_API_KEY=lin_api_...
|
||||
|
||||
LINEAR_WEBHOOK_SECRET=your-webhook-signing-secret
|
||||
LINEAR_BOT_USERNAME=NanoClaw Bot
|
||||
LINEAR_TEAM_KEY=ENG
|
||||
LINEAR_API_KEY=lin_api_...
|
||||
```
|
||||
|
||||
- `LINEAR_BOT_USERNAME`: display name for the bot (used for self-message detection when using a Personal API Key)
|
||||
- `LINEAR_TEAM_KEY`: the Linear team key (e.g. `ENG`, `NAN`). Find it in Linear under Settings > Teams. All issues in this team route to one messaging group.
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
|
||||
## Wiring
|
||||
|
||||
Ask the user: **Is this a private or public Linear workspace?**
|
||||
Linear is team-routed: the assistant watches one team and answers *every* comment
|
||||
on its issues (it can't be @-mentioned). Wire the team you set up to an agent —
|
||||
pick which one should answer (`ncl groups list` shows their folders):
|
||||
|
||||
- **Private workspace** — use `unknown_sender_policy: 'public'`. Only workspace members can comment.
|
||||
- **Public workspace** — use `unknown_sender_policy: 'strict'` and add trusted members (see GitHub skill for member registration example).
|
||||
|
||||
Run `/manage-channels` to wire the Linear channel to an agent group, or insert manually:
|
||||
|
||||
```sql
|
||||
-- Create messaging group (one per team)
|
||||
INSERT INTO messaging_groups (id, channel_type, platform_id, instance, name, is_group, unknown_sender_policy, created_at)
|
||||
VALUES ('mg-linear-eng', 'linear', 'linear:ENG', 'linear', 'Engineering', 1, 'public', datetime('now'));
|
||||
|
||||
-- Wire to agent group
|
||||
INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at)
|
||||
VALUES ('mga-linear-eng', 'mg-linear-eng', '<your-agent-group-id>', '', 'all', 'per-thread', 10, datetime('now'));
|
||||
```nc:prompt agent_folder
|
||||
Which agent should answer Linear comments? Enter its folder (run `ncl groups list`).
|
||||
```
|
||||
```nc:run effect:wire
|
||||
ncl messaging-groups create --channel-type linear --platform-id linear:{{linear_team_key}} --is-group 1 --unknown-sender-policy public --name {{linear_team_key}}
|
||||
ncl wirings create --channel-type linear --platform-id linear:{{linear_team_key}} --agent-group {{agent_folder}} --engage-mode pattern --engage-pattern . --session-mode per-thread
|
||||
```
|
||||
|
||||
The `platform_id` must be `linear:<TEAM_KEY>` matching the `LINEAR_TEAM_KEY` env var. Use `per-thread` session mode so each issue comment thread gets its own agent session.
|
||||
Each issue thread becomes its own conversation. There's no welcome — Linear has
|
||||
no direct message, so the assistant greets people when it first answers a comment.
|
||||
For a public-internet workspace, restrict it to people you've registered with
|
||||
`--unknown-sender-policy strict` (see the GitHub skill for adding members).
|
||||
|
||||
## Next Steps
|
||||
|
||||
|
||||
@@ -18,22 +18,7 @@ rm -f src/channels/matrix.ts src/channels/matrix-registration.test.ts
|
||||
|
||||
## 2. Remove credentials
|
||||
|
||||
Remove the `MATRIX_*` lines from `.env`:
|
||||
|
||||
```bash
|
||||
MATRIX_BASE_URL
|
||||
MATRIX_USERNAME
|
||||
MATRIX_PASSWORD
|
||||
MATRIX_USER_ID
|
||||
MATRIX_BOT_USERNAME
|
||||
MATRIX_ACCESS_TOKEN
|
||||
MATRIX_INVITE_AUTOJOIN
|
||||
MATRIX_INVITE_AUTOJOIN_ALLOWLIST
|
||||
MATRIX_RECOVERY_KEY
|
||||
MATRIX_DEVICE_ID
|
||||
```
|
||||
|
||||
Then re-sync to the container:
|
||||
Remove the Matrix env vars apply set — `MATRIX_BASE_URL`, `MATRIX_USER_ID`, `MATRIX_BOT_USERNAME`, and whichever auth path you chose (`MATRIX_USERNAME` + `MATRIX_PASSWORD`, or `MATRIX_ACCESS_TOKEN`) — from `.env`, then re-sync to the container:
|
||||
|
||||
```bash
|
||||
mkdir -p data/env && cp .env data/env/env
|
||||
|
||||
@@ -5,57 +5,53 @@ description: Add Matrix channel integration via Chat SDK. Works with any Matrix
|
||||
|
||||
# Add Matrix Channel
|
||||
|
||||
Adds Matrix support via the Chat SDK bridge.
|
||||
Adds Matrix support via the Chat SDK bridge. NanoClaw doesn't ship channels in
|
||||
trunk — this skill copies the Matrix adapter in from the `channels` branch.
|
||||
|
||||
## Install
|
||||
The mechanical steps under **Apply** carry `nc:` directive fences: an agent
|
||||
reads the prose and applies them, and a parser can apply them deterministically
|
||||
from the same document. Every directive is idempotent, so the whole skill is
|
||||
safe to re-run; anything a parser can't apply falls back to the prose beside it.
|
||||
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the Matrix adapter in from the `channels` branch.
|
||||
## Apply
|
||||
|
||||
### Pre-flight (idempotent)
|
||||
### 1. Copy the adapter
|
||||
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
Fetch the `channels` branch and copy the Matrix adapter into `src/channels/`
|
||||
(overwrite — the branch is canonical):
|
||||
|
||||
- `src/channels/matrix.ts` exists
|
||||
- `src/channels/matrix-registration.test.ts` exists
|
||||
- `src/channels/index.ts` contains `import './matrix.js';`
|
||||
- `@beeper/chat-adapter-matrix` is listed in `package.json` dependencies
|
||||
|
||||
Otherwise continue. Every step below is safe to re-run.
|
||||
|
||||
### 1. Fetch the channels branch
|
||||
|
||||
```bash
|
||||
git fetch origin channels
|
||||
```nc:copy from-branch:channels
|
||||
src/channels/matrix.ts
|
||||
src/channels/matrix-registration.test.ts
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and its registration test
|
||||
### 2. Register the adapter
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/matrix.ts > src/channels/matrix.ts
|
||||
git show origin/channels:src/channels/matrix-registration.test.ts > src/channels/matrix-registration.test.ts
|
||||
```
|
||||
Append the self-registration import to the channel barrel (skipped if the line
|
||||
is already present). This one line is the skill's only reach-in into core:
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
```typescript
|
||||
```nc:append to:src/channels/index.ts
|
||||
import './matrix.js';
|
||||
```
|
||||
|
||||
### 4. Install the adapter package (pinned)
|
||||
### 3. Install the adapter package
|
||||
|
||||
```bash
|
||||
pnpm install @beeper/chat-adapter-matrix@0.2.0
|
||||
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`.
|
||||
The Matrix adapter lives in the `@beeper/` namespace and versions on its own
|
||||
track (not the `@chat-adapter/*` family), so it carries its own pin:
|
||||
|
||||
```nc:dep
|
||||
@beeper/chat-adapter-matrix@0.2.0
|
||||
```
|
||||
|
||||
### 5. Patch matrix-js-sdk ESM imports
|
||||
### 4. Patch matrix-js-sdk ESM imports
|
||||
|
||||
The adapter's published dist references `matrix-js-sdk/lib/...` without `.js`
|
||||
extensions, which fails under Node 22 strict ESM resolution. Add the missing
|
||||
extensions (idempotent — safe to re-run):
|
||||
extensions (idempotent — safe to re-run). Re-run this after every `pnpm install`
|
||||
that touches the adapter:
|
||||
|
||||
```bash
|
||||
```nc:run effect:external
|
||||
node -e '
|
||||
const fs = require("fs"), path = require("path");
|
||||
const root = "node_modules/.pnpm";
|
||||
@@ -69,22 +65,32 @@ node -e '
|
||||
'
|
||||
```
|
||||
|
||||
Re-run this after every `pnpm install` that touches the adapter.
|
||||
### 5. Build
|
||||
|
||||
### 6. Build and validate
|
||||
Build guards the typed `createChatSdkBridge(...)` core call the adapter makes
|
||||
and proves the dependency is installed and the ESM patch took. It also fails if
|
||||
the `import './matrix.js';` line is missing or the barrel can't evaluate.
|
||||
|
||||
```bash
|
||||
```nc:run effect:build
|
||||
pnpm run build
|
||||
```
|
||||
```nc:run effect:test
|
||||
pnpm exec vitest run src/channels/matrix-registration.test.ts
|
||||
```
|
||||
|
||||
Both must be clean before proceeding. `matrix-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `matrix`. It goes red if the `import './matrix.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@beeper/chat-adapter-matrix` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
|
||||
`matrix-registration.test.ts` imports the real channel barrel and asserts the
|
||||
registry contains `matrix`. It goes red if the import line is deleted or drifts,
|
||||
if the barrel fails to evaluate, or if `@beeper/chat-adapter-matrix` isn't
|
||||
installed (the import throws) — so it also covers the dependency from step 3.
|
||||
|
||||
End-to-end message delivery against a real Matrix homeserver is verified manually once the service is running — see Next Steps.
|
||||
End-to-end message delivery against a real Matrix homeserver is verified
|
||||
manually once the service is running — see Next Steps.
|
||||
|
||||
## Credentials
|
||||
|
||||
The bot needs its own Matrix account — separate from the user's account. This is required because Matrix cannot send DMs to yourself.
|
||||
The bot needs its own Matrix account — separate from the user's account. This is
|
||||
required because Matrix cannot send DMs to yourself. These steps are human and
|
||||
interactive (no parser can click through Element), so they stay prose.
|
||||
|
||||
### Create a bot account
|
||||
|
||||
@@ -131,12 +137,47 @@ MATRIX_RECOVERY_KEY=your-recovery-key # Enable E2EE cross-signing
|
||||
MATRIX_DEVICE_ID=NANOCLAW01 # Stable device ID across restarts
|
||||
```
|
||||
|
||||
### Configure environment
|
||||
### Store the credentials
|
||||
|
||||
Add the chosen env vars to `.env`, then sync:
|
||||
Capture the values for the auth method you chose, then write them. `prompt` only
|
||||
*asks* and binds the answer to a name; a separate directive consumes it — so the
|
||||
same prompts could feed `ncl` or the OneCLI vault instead of `.env` by swapping
|
||||
only the consumer. The homeserver URL, the bot's user ID, and a display name are
|
||||
shared across both auth methods:
|
||||
|
||||
```bash
|
||||
mkdir -p data/env && cp .env data/env/env
|
||||
```nc:prompt base_url
|
||||
Paste the homeserver base URL, e.g. `https://matrix.org`.
|
||||
```
|
||||
```nc:prompt user_id
|
||||
Paste the bot's full Matrix user ID, e.g. `@andybot:matrix.org`.
|
||||
```
|
||||
```nc:prompt bot_username
|
||||
Paste a display name for the bot, e.g. `Andy`.
|
||||
```
|
||||
```nc:env-set
|
||||
MATRIX_BASE_URL={{base_url}}
|
||||
MATRIX_USER_ID={{user_id}}
|
||||
MATRIX_BOT_USERNAME={{bot_username}}
|
||||
```
|
||||
|
||||
For **Option A** capture the bot login, for **Option B** capture the access
|
||||
token — set only the block matching your chosen method:
|
||||
|
||||
```nc:prompt username
|
||||
Option A only — the bot's login username (the localpart, e.g. `andybot`).
|
||||
```
|
||||
```nc:prompt password secret
|
||||
Option A only — the bot account's password.
|
||||
```
|
||||
```nc:env-set
|
||||
MATRIX_USERNAME={{username}}
|
||||
MATRIX_PASSWORD={{password}}
|
||||
```
|
||||
```nc:prompt access_token secret
|
||||
Option B only — the access token from Element Settings > Help & About, or from the login API.
|
||||
```
|
||||
```nc:env-set
|
||||
MATRIX_ACCESS_TOKEN={{access_token}}
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
@@ -9,13 +9,13 @@ Installs [mnemon](https://github.com/mnemon-dev/mnemon) in the agent container i
|
||||
|
||||
## Provider Compatibility
|
||||
|
||||
mnemon hooks fire only under `--target claude-code`. Use this skill on agent groups that run the default Claude provider (`AGENT_PROVIDER=claude`). Confirm the provider before applying:
|
||||
mnemon hooks fire only under `--target claude-code`. Use this skill on agent groups that run the default Claude provider. The provider is the materialized `provider` key in each group's `container.json` (absent or `claude` = default Claude provider). Confirm it before applying:
|
||||
|
||||
```bash
|
||||
grep AGENT_PROVIDER .env groups/*/container.json 2>/dev/null
|
||||
grep -H '"provider"' groups/*/container.json 2>/dev/null # no match, or "provider": "claude" = Claude
|
||||
```
|
||||
|
||||
If a group uses a different provider (e.g. `AGENT_PROVIDER=opencode`), it spawns its own process and never invokes the `claude` CLI, so the hooks registered by `mnemon setup` do not run for that group.
|
||||
If a group sets a different provider (e.g. `"provider": "opencode"`), it spawns its own process and never invokes the `claude` CLI, so the hooks registered by `mnemon setup` do not run for that group.
|
||||
|
||||
## Phase 1: Pre-flight
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
name: add-opencode
|
||||
description: Use OpenCode as an agent provider (AGENT_PROVIDER=opencode). OpenRouter, OpenAI, Google, DeepSeek, etc. via OpenCode config — not the Anthropic Agent SDK. Per-session and per-group via agent_provider; host passes OPENCODE_* and XDG mount when spawning containers.
|
||||
description: Use OpenCode as an agent provider. OpenRouter, OpenAI, Google, DeepSeek, etc. via OpenCode config — not the Anthropic Agent SDK. Per group via `ncl groups config update --provider opencode`; host passes OPENCODE_* and XDG mount when spawning containers.
|
||||
---
|
||||
|
||||
# OpenCode agent provider
|
||||
|
||||
NanoClaw runs agents in a long-lived **poll loop** inside the container. The backend is selected with **`AGENT_PROVIDER`** (`claude` | `opencode` | `mock`).
|
||||
NanoClaw runs agents in a long-lived **poll loop** inside the container. The backend is selected per agent group by the **`provider`** key in that group's `container.json` (materialized from the `container_configs` table) — set it with `ncl groups config update --provider opencode`. Default is `claude`.
|
||||
|
||||
Trunk ships with only the `claude` provider baked in. This skill copies the OpenCode provider files in from the `providers` branch, wires them into the host and container barrels, installs dependencies, and rebuilds the image.
|
||||
|
||||
@@ -148,7 +148,7 @@ done
|
||||
|
||||
Set model/provider strings in the form OpenCode expects (often `provider/model-id`). **Put comments on their own lines** — a `#` inside a value is kept verbatim and breaks model IDs.
|
||||
|
||||
These variables are read **on the host** and passed into the container only when the effective provider is `opencode`. They do not switch the provider by themselves; the DB still needs `agent_provider` set (below).
|
||||
These variables are read **on the host** and passed into the container only when the effective provider is `opencode`. They do not switch the provider by themselves; the group still needs `provider` set to `opencode` (see [Select the provider](#select-the-provider) below).
|
||||
|
||||
- `OPENCODE_PROVIDER` — OpenCode provider id, e.g. `openrouter`, `anthropic`, `deepseek`.
|
||||
- `OPENCODE_MODEL` — full model id in `provider/model` form, e.g. `deepseek/deepseek-chat`.
|
||||
@@ -215,7 +215,7 @@ OPENCODE_SMALL_MODEL=anthropic/claude-haiku-4-5-20251001
|
||||
|
||||
Zen's HTTP API (e.g. `POST …/zen/v1/messages`) expects the key in the **`x-api-key`** header. If OneCLI injects **`Authorization: Bearer …`** only, Zen often returns **401 / "Missing API key"** even though the gateway is working.
|
||||
|
||||
**Naming:** NanoClaw **`AGENT_PROVIDER=opencode`** (DB `agent_provider`) means "run the **OpenCode agent provider**." Separately, **`OPENCODE_PROVIDER=opencode`** in `.env` is OpenCode's **Zen provider id** inside the OpenCode config (see [Zen docs](https://opencode.ai/docs/zen/)).
|
||||
**Naming:** NanoClaw's **`provider: opencode`** (the `container.json` key, set via `ncl groups config update --provider opencode`) means "run the **OpenCode agent provider**." Separately, **`OPENCODE_PROVIDER=opencode`** in `.env` is OpenCode's **Zen provider id** inside the OpenCode config (see [Zen docs](https://opencode.ai/docs/zen/)).
|
||||
|
||||
**Host `.env` (typical Zen shape):**
|
||||
|
||||
@@ -236,9 +236,16 @@ onecli secrets create --name "OpenCode Zen" --type generic \
|
||||
--header-name "x-api-key" --value-format "{value}"
|
||||
```
|
||||
|
||||
### Per group / per session
|
||||
### Select the provider
|
||||
|
||||
Set `"provider": "opencode"` in the group's **`container.json`** (`groups/<folder>/container.json`) — the in-container runner reads `provider` from there, not from the DB. The DB columns **`agent_groups.agent_provider`** and **`sessions.agent_provider`** (session overrides group) only drive host-side provider contribution — per-session XDG mount, `OPENCODE_*` env passthrough — and do not propagate into `container.json` at spawn time. Set both, or just edit `container.json`; if they disagree, the runner uses `container.json` and the host-side resolver falls back through session → group → `container.json` → `'claude'`.
|
||||
Per group, from the host:
|
||||
|
||||
```bash
|
||||
ncl groups config update --id <group-id> --provider opencode
|
||||
ncl groups restart --id <group-id>
|
||||
```
|
||||
|
||||
`ncl groups config update --provider` writes the `provider` value into the `container_configs` table; the host materializes it into `groups/<folder>/container.json` at spawn time and the in-container runner reads `provider` from there (defaulting to `claude`). The restart picks up the change. Switching is an operator action — run it from the host. Memory does NOT carry over automatically between providers — run `/migrate-memory` to carry it across.
|
||||
|
||||
Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config.mcpServers` on the host; the runner merges them into the same `mcpServers` object passed to **both** Claude and OpenCode providers.
|
||||
|
||||
@@ -250,6 +257,6 @@ Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config
|
||||
|
||||
## Next Steps
|
||||
|
||||
The registration and Dockerfile guards in step 7 verify the wiring. To confirm an end-to-end round-trip, set `agent_provider = 'opencode'` (or `"provider": "opencode"` in the group's `container.json`) on a test group, register the matching provider key in OneCLI, and send a message. A clean exchange returns the model's reply with no `Unknown provider: opencode` error and no UUID/session warnings in the logs.
|
||||
The registration and Dockerfile guards in step 7 verify the wiring. To confirm an end-to-end round-trip, switch a test group with `ncl groups config update --id <group-id> --provider opencode && ncl groups restart --id <group-id>`, register the matching provider key in OneCLI, and send a message. A clean exchange returns the model's reply with no `Unknown provider: opencode` error and no UUID/session warnings in the logs.
|
||||
|
||||
To remove this provider, see [REMOVE.md](REMOVE.md).
|
||||
|
||||
@@ -5,61 +5,70 @@ description: Add Resend (email) channel integration via Chat SDK.
|
||||
|
||||
# Add Resend Email Channel
|
||||
|
||||
Connect NanoClaw to email via Resend for async email conversations.
|
||||
Connect NanoClaw to email via Resend for async email conversations. NanoClaw
|
||||
doesn't ship channels in trunk — this skill copies the Resend adapter in from the
|
||||
`channels` branch.
|
||||
|
||||
## Install
|
||||
The mechanical steps under **Apply** carry `nc:` directive fences: an agent reads
|
||||
the prose and applies them, and a parser can apply them deterministically from
|
||||
the same document. Every directive is idempotent, so the whole skill is safe to
|
||||
re-run; anything a parser can't apply falls back to the prose beside it.
|
||||
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the Resend adapter in from the `channels` branch.
|
||||
## Apply
|
||||
|
||||
### Pre-flight (idempotent)
|
||||
### 1. Copy the adapter
|
||||
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
Fetch the `channels` branch and copy the Resend adapter into `src/channels/`
|
||||
(overwrite — the branch is canonical):
|
||||
|
||||
- `src/channels/resend.ts` exists
|
||||
- `src/channels/resend-registration.test.ts` exists
|
||||
- `src/channels/index.ts` contains `import './resend.js';`
|
||||
- `@resend/chat-sdk-adapter` is listed in `package.json` dependencies
|
||||
|
||||
Otherwise continue. Every step below is safe to re-run.
|
||||
|
||||
### 1. Fetch the channels branch
|
||||
|
||||
```bash
|
||||
git fetch origin channels
|
||||
```nc:copy from-branch:channels
|
||||
src/channels/resend.ts
|
||||
src/channels/resend-registration.test.ts
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and its registration test
|
||||
### 2. Register the adapter
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/resend.ts > src/channels/resend.ts
|
||||
git show origin/channels:src/channels/resend-registration.test.ts > src/channels/resend-registration.test.ts
|
||||
```
|
||||
Append the self-registration import to the channel barrel (skipped if the line
|
||||
is already present). This one line is the skill's only reach-in into core:
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
```typescript
|
||||
```nc:append to:src/channels/index.ts
|
||||
import './resend.js';
|
||||
```
|
||||
|
||||
### 4. Install the adapter package (pinned)
|
||||
### 3. Install the adapter package
|
||||
|
||||
```bash
|
||||
pnpm install @resend/chat-sdk-adapter@0.1.1
|
||||
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
|
||||
|
||||
```nc:dep
|
||||
@resend/chat-sdk-adapter@0.1.1
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
### 4. Build and validate
|
||||
|
||||
```bash
|
||||
Build guards the typed `createChatSdkBridge(...)` core call and proves the
|
||||
dependency is installed (the adapter imports `@resend/chat-sdk-adapter`; if it
|
||||
isn't installed the barrel throws). End-to-end email delivery against a real
|
||||
domain is verified manually once the service runs.
|
||||
|
||||
```nc:run effect:build
|
||||
pnpm run build
|
||||
```
|
||||
```nc:run effect:test
|
||||
pnpm exec vitest run src/channels/resend-registration.test.ts
|
||||
```
|
||||
|
||||
Both must be clean before proceeding. `resend-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `resend`. It goes red if the `import './resend.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@resend/chat-sdk-adapter` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
|
||||
`resend-registration.test.ts` imports the real channel barrel and asserts the
|
||||
registry contains `resend`. It goes red if the import line is deleted or drifts,
|
||||
if the barrel fails to evaluate, or if `@resend/chat-sdk-adapter` isn't installed
|
||||
(the import throws) — so it also covers the dependency from step 3.
|
||||
|
||||
## Credentials
|
||||
|
||||
Resend account and domain setup is human and interactive — these steps are
|
||||
prose, not directives (no parser can verify a sending domain or click through the
|
||||
Resend UI). A recipe rebuild produces a compiling, registered adapter that cannot
|
||||
receive a message until they're done.
|
||||
|
||||
1. Go to [resend.com](https://resend.com) and create an account.
|
||||
2. Add and verify your sending domain.
|
||||
3. Go to **API Keys** and create a new key.
|
||||
@@ -69,30 +78,72 @@ Both must be clean before proceeding. `resend-registration.test.ts` is the one i
|
||||
- Events: select **email.received**.
|
||||
- Copy the signing secret.
|
||||
|
||||
### Configure environment
|
||||
### Store the credentials
|
||||
|
||||
Add to `.env`:
|
||||
Capture the secrets, then write them. `prompt` only *asks* and binds the answer
|
||||
to a name; a separate directive consumes it — so the same prompts could feed
|
||||
`ncl` or the OneCLI vault instead of `.env` by swapping only the consumer. Here
|
||||
they go to `.env` (set-if-absent — a value you've already filled in is never
|
||||
overwritten):
|
||||
|
||||
```bash
|
||||
RESEND_API_KEY=re_...
|
||||
RESEND_FROM_ADDRESS=bot@yourdomain.com
|
||||
RESEND_FROM_NAME=NanoClaw
|
||||
RESEND_WEBHOOK_SECRET=your-webhook-secret
|
||||
```nc:prompt api_key secret
|
||||
Paste the Resend API key — API Keys, starts with `re_`.
|
||||
```
|
||||
```nc:prompt webhook_secret secret
|
||||
Paste the webhook signing secret — Webhooks, the value you copied above.
|
||||
```
|
||||
```nc:prompt from_address
|
||||
The bot's sending email address on your verified domain (e.g. `bot@yourdomain.com`).
|
||||
```
|
||||
```nc:prompt from_name
|
||||
The display name to send as (e.g. `NanoClaw`).
|
||||
```
|
||||
```nc:env-set
|
||||
RESEND_API_KEY={{api_key}}
|
||||
RESEND_FROM_ADDRESS={{from_address}}
|
||||
RESEND_FROM_NAME={{from_name}}
|
||||
RESEND_WEBHOOK_SECRET={{webhook_secret}}
|
||||
```
|
||||
## Connect yourself
|
||||
|
||||
Because email is direct-addressable, the bot can write to you first — so wire
|
||||
your own address as the owner and have it email you a hello. Tell it your address
|
||||
and which agent should answer your email (`ncl groups list` shows their folders):
|
||||
|
||||
```nc:prompt owner_email
|
||||
Your email address — I'll wire you as owner and email you a hello.
|
||||
```
|
||||
```nc:prompt agent_folder
|
||||
Which agent should answer your email? Enter its folder (run `ncl groups list`).
|
||||
```
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
Register yourself as the owner, wire your address so the agent answers your email,
|
||||
and send the hello:
|
||||
|
||||
```nc:run effect:wire
|
||||
ncl users create --id resend:{{owner_email}} --kind resend --display-name Owner
|
||||
ncl roles grant --user resend:{{owner_email}} --role owner
|
||||
ncl messaging-groups create --channel-type resend --platform-id resend:{{owner_email}} --is-group 0
|
||||
ncl wirings create --channel-type resend --platform-id resend:{{owner_email}} --agent-group {{agent_folder}} --engage-mode pattern --engage-pattern .
|
||||
ncl messaging-groups send --channel-type resend --platform-id resend:{{owner_email}} --sender-id resend:{{owner_email}} --sender Owner --text "Hi — I'm your NanoClaw assistant, reachable by email now. Reply to this thread anytime."
|
||||
```
|
||||
|
||||
The hello arrives as a fresh email thread; reply to keep the conversation going.
|
||||
Your own address is the conversation key (`resend:<your-address>`).
|
||||
|
||||
## Next Steps
|
||||
|
||||
If you're in the middle of `/setup`, return to the setup flow now.
|
||||
|
||||
Otherwise, run `/manage-channels` to wire this channel to an agent group.
|
||||
If you're in the middle of `/setup`, return to the setup flow now. (Answering an
|
||||
*open* inbox — anyone who emails in, not just you — is a separate, not-yet-wired
|
||||
case: email is plain-message, so the router never auto-creates a group for an
|
||||
unknown sender; each correspondent's `resend:<their-address>` must be wired
|
||||
explicitly.)
|
||||
|
||||
## Channel Info
|
||||
|
||||
- **type**: `resend`
|
||||
- **terminology**: Resend handles email. Each email thread (identified by subject/In-Reply-To headers) is a separate conversation. The "from address" is the bot's identity.
|
||||
- **how-to-find-id**: The platform ID is the from email address (e.g. `bot@yourdomain.com`). Each sender's email thread becomes its own conversation.
|
||||
- **supports-threads**: yes (via email threading headers -- replies to the same thread stay together)
|
||||
- **terminology**: Resend handles email. The bot has one fixed sending identity (`RESEND_FROM_ADDRESS`, e.g. `bot@yourdomain.com`); every *external correspondent* the bot emails with is a separate conversation, keyed by *their* address.
|
||||
- **how-to-find-id**: The platform ID is the **correspondent's** email address, prefixed — `resend:<their-address>` (e.g. `resend:you@example.com`) — **not** the bot's from-address. The adapter derives it from the reply-to party (`channelIdFromThreadId` returns `resend:<address>`); each distinct email thread from that person (by root `Message-ID`) is a sub-conversation under it.
|
||||
- **supports-threads**: no (the adapter sets `supportsThreads: false`; replies still thread via email headers, but the router does not treat threads as the primary conversation unit)
|
||||
- **typical-use**: Async communication -- email conversations with longer response expectations
|
||||
- **default-isolation**: Same agent group if you want your agent to handle email alongside other channels. Separate agent group if email contains sensitive correspondence that shouldn't be accessible from other channels.
|
||||
|
||||
@@ -4,21 +4,15 @@ Idempotent — safe to run even if some steps were never applied. Run Steps 1–
|
||||
|
||||
## 1. Remove the mount from the container config
|
||||
|
||||
Read the current mounts, drop the entry whose `containerPath` is `/usr/local/bin/rtk`, and write the rest back.
|
||||
Remove the rtk mount with the host-only `remove-mount` verb. It is idempotent — a no-op if the mount isn't present:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db \
|
||||
"SELECT additional_mounts FROM container_configs WHERE agent_group_id = '<group-id>'"
|
||||
ncl groups config remove-mount --id <group-id> \
|
||||
--host ~/.local/bin/rtk \
|
||||
--container /usr/local/bin/rtk
|
||||
```
|
||||
|
||||
Write the filtered array (omit any entry with `"containerPath":"/usr/local/bin/rtk"`):
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db \
|
||||
"UPDATE container_configs SET additional_mounts = '<filtered-json>' WHERE agent_group_id = '<group-id>'"
|
||||
```
|
||||
|
||||
If no rtk entry is present, leave the array as-is.
|
||||
This verb is operator-only and runs host-side; it is rejected from inside a container.
|
||||
|
||||
## 2. Remove the PreToolUse hook from settings.json
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ Install [rtk](https://github.com/rtk-ai/rtk) — a CLI proxy delivering 60–90%
|
||||
- `~/.local/bin/rtk` mounted read-only at `/usr/local/bin/rtk` inside the target agent group's containers
|
||||
- `PreToolUse` hook in the agent group's `settings.json` so every Bash call is automatically filtered through rtk — no CLAUDE.md instructions needed
|
||||
|
||||
## Integration tests
|
||||
|
||||
This skill has **no in-tree integration test** by design. Its only functional reach-ins are runtime operator actions — the host-only `ncl groups config add-mount` (Step 3) and the `settings.json` `PreToolUse` hook write (Step 4) — neither of which leaves a line in the source tree whose deletion a test could catch. There are no package dependencies or Dockerfile edits to guard either. Conformance is idempotent apply + `REMOVE.md`; the mount and hook are verified at runtime (see Verify).
|
||||
|
||||
## Step 1 — Install rtk on the host
|
||||
|
||||
```bash
|
||||
@@ -43,33 +47,24 @@ Note the group ID (e.g. `ag-1776342942165-ptgddd`). Repeat Steps 3–5 for each
|
||||
|
||||
## Step 3 — Mount rtk into the container config
|
||||
|
||||
`additional_mounts` is a JSON array column on `container_configs`. Read the current value, merge in the rtk entry, and write the merged array back.
|
||||
|
||||
Read current mounts first:
|
||||
Mount the host rtk binary read-only into the container with the host-only `add-mount` verb. It is idempotent — re-running skips the entry if it is already present:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db \
|
||||
"SELECT additional_mounts FROM container_configs WHERE agent_group_id = '<group-id>'"
|
||||
ncl groups config add-mount --id <group-id> \
|
||||
--host ~/.local/bin/rtk \
|
||||
--container /usr/local/bin/rtk \
|
||||
--ro
|
||||
```
|
||||
|
||||
Build the merged array: keep every existing entry, drop any entry whose `containerPath` is `/usr/local/bin/rtk` (so re-running replaces rather than duplicates), then add the rtk entry:
|
||||
This verb is operator-only and runs host-side (via `/setup`, `/customize`, or `/manage-mounts`); it is rejected from inside a container.
|
||||
|
||||
```json
|
||||
{"hostPath":"/home/<user>/.local/bin/rtk","containerPath":"/usr/local/bin/rtk","readonly":true}
|
||||
```
|
||||
|
||||
Write the merged array back:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db \
|
||||
"UPDATE container_configs SET additional_mounts = '<merged-json>' WHERE agent_group_id = '<group-id>'"
|
||||
```
|
||||
The host root (`~/.local/bin`) must also be in the external mount allowlist at `~/.config/nanoclaw/mount-allowlist.json` for the mount to take effect at spawn. Add it there if it isn't already.
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db \
|
||||
"SELECT additional_mounts FROM container_configs WHERE agent_group_id = '<group-id>'"
|
||||
ncl groups config get --id <group-id>
|
||||
# Look for the /usr/local/bin/rtk mount
|
||||
```
|
||||
|
||||
## Step 4 — Add the PreToolUse hook to settings.json
|
||||
@@ -120,9 +115,8 @@ Then ask the agent to run `git status` or any other supported command. rtk inter
|
||||
Mount wasn't applied or container wasn't restarted:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db \
|
||||
"SELECT additional_mounts FROM container_configs WHERE agent_group_id = '<group-id>'"
|
||||
# Look for entry with /usr/local/bin/rtk
|
||||
ncl groups config get --id <group-id>
|
||||
# Look for the /usr/local/bin/rtk mount
|
||||
ncl groups restart --id <group-id>
|
||||
```
|
||||
|
||||
|
||||
+154
-188
@@ -1,53 +1,168 @@
|
||||
---
|
||||
name: add-signal
|
||||
description: Add Signal channel integration via signal-cli TCP daemon. Native adapter — no Chat SDK bridge.
|
||||
description: Add Signal channel integration via signal-cli device-link. Native adapter — no Chat SDK bridge.
|
||||
---
|
||||
|
||||
# Add Signal Channel
|
||||
|
||||
Adds Signal messaging support via a native adapter that speaks JSON-RPC to a [signal-cli](https://github.com/AsamK/signal-cli) TCP daemon. No Chat SDK bridge — only Node.js builtins (`node:net`, `node:child_process`, `node:fs`).
|
||||
Adds Signal support via a native adapter that speaks JSON-RPC to a
|
||||
[signal-cli](https://github.com/AsamK/signal-cli) daemon — no Chat SDK bridge,
|
||||
only Node.js builtins. NanoClaw links to Signal as a *secondary device* on your
|
||||
existing phone: no new number, no bot API. Your assistant sends and receives as
|
||||
the number on the phone that scans the link.
|
||||
|
||||
Unlike Telegram or Discord, Signal has no bot API. NanoClaw registers as a full Signal account on a dedicated phone number (recommended) or links as a secondary device on your existing number.
|
||||
## Apply
|
||||
|
||||
## Prerequisites
|
||||
### 1. Install signal-cli
|
||||
|
||||
### Java
|
||||
NanoClaw talks to Signal through signal-cli, which has no bot API of its own.
|
||||
Install it if it isn't on PATH yet — Homebrew on macOS, the native release binary
|
||||
on Linux (neither needs Java). If it's already installed this is a no-op:
|
||||
|
||||
signal-cli requires Java 17+:
|
||||
|
||||
```bash
|
||||
java -version
|
||||
```nc:run effect:external
|
||||
command -v signal-cli >/dev/null 2>&1 || bash setup/install-signal-cli.sh
|
||||
```
|
||||
|
||||
If missing:
|
||||
- **macOS:** `brew install --cask temurin@17`
|
||||
- **Debian/Ubuntu:** `sudo apt-get install -y default-jre`
|
||||
- **RHEL/Fedora:** `sudo dnf install -y java-17-openjdk`
|
||||
### 2. Copy the adapter and its registration test
|
||||
|
||||
Java 17–25 all work.
|
||||
Fetch the `channels` branch and copy the Signal adapter and its registration test
|
||||
into `src/channels/` (overwrite — the branch is canonical):
|
||||
|
||||
### signal-cli
|
||||
|
||||
- **macOS:** `brew install signal-cli`
|
||||
- **Linux:** download the native binary from [GitHub releases](https://github.com/AsamK/signal-cli/releases):
|
||||
|
||||
```bash
|
||||
SIGNAL_CLI_VERSION=$(curl -fsSL https://api.github.com/repos/AsamK/signal-cli/releases/latest | python3 -c "import sys,json; print(json.load(sys.stdin)['tag_name'][1:])")
|
||||
curl -fsSL "https://github.com/AsamK/signal-cli/releases/download/v${SIGNAL_CLI_VERSION}/signal-cli-${SIGNAL_CLI_VERSION}-Linux-native.tar.gz" \
|
||||
| tar -xz -C ~/.local
|
||||
ln -sf ~/.local/signal-cli ~/.local/bin/signal-cli
|
||||
signal-cli --version
|
||||
```nc:copy from-branch:channels
|
||||
src/channels/signal.ts
|
||||
src/channels/signal-registration.test.ts
|
||||
```
|
||||
|
||||
> The Linux native tarball extracts a single binary directly to `~/.local/signal-cli` (not into a subdirectory). The symlink above puts it on PATH.
|
||||
### 3. Register the adapter
|
||||
|
||||
## Registration
|
||||
Append the self-registration import to the channel barrel (skipped if the line is
|
||||
already present). This one line is the skill's only reach-in into core:
|
||||
|
||||
Two paths. The new-number path is recommended and battle-tested.
|
||||
```nc:append to:src/channels/index.ts
|
||||
import './signal.js';
|
||||
```
|
||||
|
||||
### Path A: Register a new number (recommended)
|
||||
### 4. Install the QR-rendering dependency
|
||||
|
||||
Use a dedicated SIM or VoIP number. NanoClaw owns it entirely.
|
||||
The device-link step renders the linking URL as a terminal QR via `qrcode`.
|
||||
Pinned to exact versions — the supply-chain policy rejects ranges and `latest`:
|
||||
|
||||
```nc:dep
|
||||
qrcode@1.5.4
|
||||
@types/qrcode@1.5.6
|
||||
```
|
||||
|
||||
The adapter itself consumes only Node.js builtins, so there is no adapter package
|
||||
to install — `qrcode` is purely for rendering the link during setup.
|
||||
|
||||
### 5. Build and validate
|
||||
|
||||
Build first: it guards the adapter's typed core-API consumption. Then run the one
|
||||
integration test.
|
||||
|
||||
```nc:run effect:build
|
||||
pnpm run build
|
||||
```
|
||||
```nc:run effect:test
|
||||
pnpm exec vitest run src/channels/signal-registration.test.ts
|
||||
```
|
||||
|
||||
`signal-registration.test.ts` imports the real channel barrel and asserts the
|
||||
registry contains `signal`. It goes red if the `import './signal.js';` line is
|
||||
deleted or drifts, or if the barrel fails to evaluate — so the channel genuinely
|
||||
would not register. The adapter has no npm dependency to guard; its typed
|
||||
core-API consumption is covered by the build. End-to-end delivery against a real
|
||||
Signal account is verified manually once the service runs.
|
||||
|
||||
## Link your Signal account
|
||||
|
||||
This is the whole credential step. signal-cli opens a device-link handshake,
|
||||
prints a `sgnl://linkdevice…` URL, and renders it as a scannable QR. You scan it
|
||||
once from the phone that already runs Signal; that phone's number becomes the
|
||||
account NanoClaw sends and receives as — no number is registered.
|
||||
|
||||
The device-link runs signal-cli, so it must be reachable first — on `PATH`, or at
|
||||
`$SIGNAL_CLI_PATH`. If step 1's install didn't land, the link step has nothing to
|
||||
drive; confirm it's present before linking (re-run step 1 if this fails):
|
||||
|
||||
```nc:run effect:check
|
||||
command -v signal-cli >/dev/null 2>&1 || [ -x "$SIGNAL_CLI_PATH" ]
|
||||
```
|
||||
|
||||
Tell the user:
|
||||
|
||||
```nc:operator
|
||||
Link NanoClaw to your Signal account:
|
||||
1. On the phone that runs Signal, open Signal → Settings → Linked Devices → Link New Device.
|
||||
2. Scan the QR code shown below — or open the `sgnl://linkdevice…` link printed under it on that phone.
|
||||
3. Wait for confirmation. The linking URL expires after ~3 minutes; re-run this step for a fresh one.
|
||||
```
|
||||
|
||||
Run the device-link. It blocks until you scan, then reports the linked phone
|
||||
number back as the account — that number is both your owner handle and the
|
||||
conversation address the wiring step needs:
|
||||
|
||||
```nc:run effect:step capture:platform_id=ACCOUNT,owner_handle=ACCOUNT
|
||||
pnpm exec tsx setup/index.ts --step signal-auth
|
||||
```
|
||||
|
||||
`owner_handle` and `platform_id` both come back as the bare phone number (e.g.
|
||||
`+15551234567`). Your assistant reaches you through Signal's Note to Self, so the
|
||||
owner conversation is addressed by your own number — not a per-contact UUID.
|
||||
|
||||
## Persist the account
|
||||
|
||||
Store the linked number so the adapter binds the right account on start, then sync
|
||||
it into the container env:
|
||||
|
||||
```nc:env-set
|
||||
SIGNAL_ACCOUNT={{platform_id}}
|
||||
```
|
||||
## Restart
|
||||
|
||||
Restart the service so it loads the Signal adapter and binds the account you just
|
||||
linked, and wait for its CLI socket before wiring:
|
||||
|
||||
```nc:run effect:restart
|
||||
bash setup/lib/restart.sh
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
If you're in the middle of `/setup`, return to the setup flow now. Otherwise wire
|
||||
this channel with `/init-first-agent` (or `/manage-channels`).
|
||||
|
||||
## Channel Info
|
||||
|
||||
- **type**: `signal`
|
||||
- **terminology**: Signal has "chats" (1:1 DMs) and "groups." The owner reaches their own assistant through Note to Self.
|
||||
- **platform-id-format**:
|
||||
- Owner DM (Note to Self): the bare phone number `+<number>` (e.g. `+15551234567`) — your own messages route back as inbound with `isFromMe`, addressed by your number.
|
||||
- Third-party DM: `signal:{UUID}` — the sender's Signal ACI, **not** their phone number.
|
||||
- Group: `signal:{base64GroupId}` — base64-encoded GroupV2 ID.
|
||||
- **how-to-find-id**: The owner number comes back from the device-link step above. For third parties or groups, send a message to the bot, then query `messaging_groups`.
|
||||
- **supports-threads**: no
|
||||
- **typical-use**: Personal assistant via Signal DMs or small group chats
|
||||
- **default-isolation**: One agent per Signal account. Multiple chats with the same operator can share an agent group; groups with other people should typically use `isolated` session mode.
|
||||
|
||||
### Features
|
||||
|
||||
- Markdown formatting — `**bold**`, `*italic*` / `_italic_`, `` `code` ``, ` ```code fence``` `, `~~strike~~`, `||spoiler||` (converted to Signal's offset-based text styles).
|
||||
- Quoted replies — `replyTo*` fields populated from Signal quotes.
|
||||
- Typing indicators — DMs only (Signal doesn't support group typing).
|
||||
- Note to Self — messages you send to your own account from another device route to the agent as inbound with `isFromMe: true`.
|
||||
- Voice attachments — detected but not transcribed by default; the agent receives a `[Voice Message]` placeholder. Run `/add-voice-transcription` for local transcription.
|
||||
|
||||
Not supported yet: outbound file attachments (logged and dropped), edit/delete messages, reactions.
|
||||
|
||||
## Alternatives
|
||||
|
||||
### Register a dedicated number instead of linking
|
||||
|
||||
The device-link above joins Signal as a *secondary device* on an existing number.
|
||||
If you'd rather give the assistant its own number, register a dedicated SIM or
|
||||
VoIP number that NanoClaw owns entirely. This path takes a captcha, an SMS (or
|
||||
voice) verification, and an optional profile name.
|
||||
|
||||
> **VoIP numbers:** Signal requires SMS verification before voice. Some VoIP providers are blocked even for voice calls. If registration fails with an auth error, try a different provider or a physical SIM.
|
||||
|
||||
@@ -107,69 +222,12 @@ signal-cli -a +1YOURNUMBER updateProfile --name "YourBotName"
|
||||
systemctl --user start $(systemd_unit)
|
||||
```
|
||||
|
||||
### Path B: Link as secondary device
|
||||
Once registered, set `SIGNAL_ACCOUNT` to this number (as under **Persist the account** above) and restart the service.
|
||||
|
||||
Joins an existing Signal account as a secondary device. Simpler, but NanoClaw shares your personal number.
|
||||
## Optional configuration
|
||||
|
||||
```bash
|
||||
signal-cli -a +1YOURNUMBER link --name "NanoClaw"
|
||||
```
|
||||
|
||||
This prints a `tsdevice:` URI. Scan it as a QR code on your phone: **Settings → Linked Devices → Link New Device**. QR codes expire in ~30 seconds — re-run if it expires.
|
||||
|
||||
## Install
|
||||
|
||||
### Pre-flight (idempotent)
|
||||
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
|
||||
- `src/channels/signal.ts` exists
|
||||
- `src/channels/signal.test.ts` exists
|
||||
- `src/channels/signal-registration.test.ts` exists
|
||||
- `src/channels/index.ts` contains `import './signal.js';`
|
||||
|
||||
Otherwise continue. Every step below is safe to re-run.
|
||||
|
||||
### 1. Fetch the channels branch
|
||||
|
||||
```bash
|
||||
git fetch origin channels
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and tests
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/signal.ts > src/channels/signal.ts
|
||||
git show origin/channels:src/channels/signal.test.ts > src/channels/signal.test.ts
|
||||
git show origin/channels:src/channels/signal-registration.test.ts > src/channels/signal-registration.test.ts
|
||||
```
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
```typescript
|
||||
import './signal.js';
|
||||
```
|
||||
|
||||
### 4. Build and validate
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
pnpm exec vitest run src/channels/signal-registration.test.ts
|
||||
```
|
||||
|
||||
Both must be clean before proceeding. `signal-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `signal`. It goes red if the `import './signal.js';` line is deleted or drifts, or if the barrel fails to evaluate (so the channel genuinely would not register). The adapter consumes only Node.js builtins, so there is no npm dependency to guard for this channel. The adapter's typed core-API consumption is guarded by `pnpm run build`.
|
||||
|
||||
## Credentials
|
||||
|
||||
Add to `.env`:
|
||||
|
||||
```bash
|
||||
SIGNAL_ACCOUNT=+1YOURNUMBER
|
||||
```
|
||||
|
||||
### Optional settings
|
||||
These `.env` keys tune how NanoClaw talks to the signal-cli daemon. All are
|
||||
optional — the defaults work for the device-link flow above.
|
||||
|
||||
```bash
|
||||
# TCP daemon host and port (default: 127.0.0.1:7583)
|
||||
@@ -189,94 +247,6 @@ SIGNAL_DATA_DIR=~/.local/share/signal-cli
|
||||
|
||||
**Security note:** keep the TCP host on `127.0.0.1`. The daemon has no auth — binding it to a public interface would expose your full Signal account to the network.
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
|
||||
### Restart
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
source setup/lib/install-slug.sh
|
||||
|
||||
# macOS
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
|
||||
|
||||
# Linux
|
||||
systemctl --user restart $(systemd_unit)
|
||||
```
|
||||
|
||||
## Wiring
|
||||
|
||||
### DMs
|
||||
|
||||
After the service starts, send any message to the Signal number from your personal Signal app. The router auto-creates a `messaging_groups` row. Then:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db \
|
||||
"SELECT id, platform_id FROM messaging_groups WHERE channel_type='signal' ORDER BY created_at DESC LIMIT 5"
|
||||
```
|
||||
|
||||
Pass the `id` to `/init-first-agent` or `/manage-channels` to wire it to an agent group.
|
||||
|
||||
### Groups
|
||||
|
||||
Add the Signal number to a group from your phone, send any message, then wire the resulting row the same way. For isolated per-group sessions:
|
||||
|
||||
```bash
|
||||
NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "
|
||||
INSERT OR IGNORE INTO messaging_group_agents
|
||||
(id, messaging_group_id, agent_group_id, session_mode, priority, created_at)
|
||||
VALUES
|
||||
('mga-'||hex(randomblob(8)), 'mg-GROUPID', 'ag-AGENTID', 'isolated', 0, '$NOW');
|
||||
"
|
||||
```
|
||||
|
||||
### Grant user access
|
||||
|
||||
New Signal users (including the owner's Signal identity) are silently dropped with `not_member` until granted access. After the user's first message appears in `messaging_groups`:
|
||||
|
||||
```bash
|
||||
NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "
|
||||
INSERT OR REPLACE INTO user_roles (user_id, role, agent_group_id, granted_by, granted_at)
|
||||
VALUES ('signal:UUID', 'owner', NULL, 'system', '$NOW');
|
||||
INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id, added_by, added_at)
|
||||
VALUES ('signal:UUID', 'ag-AGENTID', 'system', '$NOW');
|
||||
"
|
||||
```
|
||||
|
||||
Find the UUID from `messaging_groups.platform_id` or the `users` table.
|
||||
|
||||
## Next Steps
|
||||
|
||||
If you're in the middle of `/setup`, return to the setup flow now.
|
||||
|
||||
Otherwise, run `/init-first-agent` to create an agent and wire it to your Signal DM, or `/manage-channels` to wire this channel to an existing agent group.
|
||||
|
||||
## Channel Info
|
||||
|
||||
- **type**: `signal`
|
||||
- **terminology**: Signal has "chats" (1:1 DMs) and "groups"
|
||||
- **supports-threads**: no
|
||||
- **platform-id-format**:
|
||||
- DM: `signal:{UUID}` — sender's Signal UUID (ACI), **not** their phone number
|
||||
- Group: `signal:{base64GroupId}` — base64-encoded GroupV2 ID
|
||||
- **how-to-find-id**: Send a message to the bot, then query `messaging_groups` as shown above
|
||||
- **typical-use**: Personal assistant via Signal DMs or small group chats
|
||||
- **default-isolation**: One agent per Signal account. Multiple chats with the same operator can share an agent group; groups with other people should typically use `isolated` session mode
|
||||
|
||||
### Features
|
||||
|
||||
- Markdown formatting — `**bold**`, `*italic*` / `_italic_`, `` `code` ``, ` ```code fence``` `, `~~strike~~`, `||spoiler||` (converted to Signal's offset-based text styles)
|
||||
- Quoted replies — `replyTo*` fields populated from Signal quotes
|
||||
- Typing indicators — DMs only (Signal doesn't support group typing)
|
||||
- Echo suppression — outbound messages matched on `(platformId, text)` within a 10 s TTL to avoid syncMessage loops
|
||||
- Note to Self — messages you send to your own account from another device route to the agent as inbound with `isFromMe: true`
|
||||
- Voice attachments — detected but not transcribed by default; the agent receives `[Voice Message]` placeholder text. Run `/add-voice-transcription` for local transcription via parakeet-mlx
|
||||
|
||||
Not supported yet: outbound file attachments (logged and dropped), edit/delete messages, reactions.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Daemon not reachable
|
||||
@@ -287,9 +257,9 @@ grep "Signal" logs/nanoclaw.log | tail
|
||||
|
||||
If you see `Signal daemon failed to start. Is signal-cli installed and your account linked?`:
|
||||
- Confirm `signal-cli` is on PATH (or set `SIGNAL_CLI_PATH`)
|
||||
- Confirm the account is linked: `signal-cli -a +1YOURNUMBER listIdentities` should succeed without prompting
|
||||
- Confirm the account is linked: `signal-cli -a +YOURNUMBER listIdentities` should succeed without prompting
|
||||
|
||||
If you see `Signal daemon not reachable at 127.0.0.1:7583` and `SIGNAL_MANAGE_DAEMON=false`, start the daemon yourself: `signal-cli -a +1YOURNUMBER daemon --tcp 127.0.0.1:7583`.
|
||||
If you see `Signal daemon not reachable at 127.0.0.1:7583` and `SIGNAL_MANAGE_DAEMON=false`, start the daemon yourself: `signal-cli -a +YOURNUMBER daemon --tcp 127.0.0.1:7583`.
|
||||
|
||||
### Bot not responding
|
||||
|
||||
@@ -308,7 +278,7 @@ If you see `Signal channel lost TCP connection to signal-cli daemon` in the logs
|
||||
|
||||
### Messages dropped with `not_member`
|
||||
|
||||
The Signal user hasn't been granted membership. See "Grant user access" above. This affects every new Signal user, including the owner's Signal identity — which is a separate user record from their identity on other channels even if it's the same person.
|
||||
The Signal user hasn't been granted membership. New Signal senders — including the owner's Signal identity — are gated until granted access. `/init-first-agent` grants the owner automatically; for other users, wire access with `/manage-channels` after their first message appears in `messaging_groups`. This affects every new Signal user, since their Signal identity is a separate user record from their identity on other channels even if it's the same person.
|
||||
|
||||
### Captcha required
|
||||
|
||||
@@ -326,10 +296,6 @@ signal-cli holds an exclusive lock on its data directory while the daemon is run
|
||||
|
||||
Modern Signal groups use GroupV2. The adapter must extract the group ID from `envelope?.dataMessage?.groupV2?.id` — not `groupInfo?.groupId`, which is GroupV1/legacy. If group messages are routing as DMs, check `src/channels/signal.ts` and confirm the groupId extraction falls through to `groupV2.id`.
|
||||
|
||||
### Java not found
|
||||
### QR / linking URL expired
|
||||
|
||||
Install Java 17+ — see the Prerequisites section above.
|
||||
|
||||
### QR code expired (Path B)
|
||||
|
||||
QR codes expire in ~30 seconds. Re-run the link command to generate a new one.
|
||||
The `sgnl://linkdevice…` URL (and the Path A registration captcha) expire after a few minutes. Re-run the device-link step to get a fresh QR.
|
||||
|
||||
@@ -5,114 +5,179 @@ description: Add Slack channel integration via Chat SDK.
|
||||
|
||||
# Add Slack Channel
|
||||
|
||||
Adds Slack support via the Chat SDK bridge.
|
||||
Adds Slack support via the Chat SDK bridge. NanoClaw doesn't ship channels in
|
||||
trunk — this skill copies the Slack adapter in from the `channels` branch.
|
||||
|
||||
## Install
|
||||
The mechanical steps under **Apply** carry `nc:` directive fences: an agent
|
||||
reads the prose and applies them, and a parser can apply them deterministically
|
||||
from the same document. Every directive is idempotent, so the whole skill is
|
||||
safe to re-run; anything a parser can't apply falls back to the prose beside it.
|
||||
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the Slack adapter in from the `channels` branch.
|
||||
## Apply
|
||||
|
||||
### Pre-flight (idempotent)
|
||||
### 1. Copy the adapter and its registration test
|
||||
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
Fetch the `channels` branch and copy the Slack adapter and its registration test
|
||||
into `src/channels/` (overwrite — the branch is canonical):
|
||||
|
||||
- `src/channels/slack.ts` exists
|
||||
- `src/channels/slack-registration.test.ts` exists
|
||||
- `src/channels/index.ts` contains `import './slack.js';`
|
||||
- `@chat-adapter/slack` is listed in `package.json` dependencies
|
||||
|
||||
Otherwise continue. Every step below is safe to re-run.
|
||||
|
||||
### 1. Fetch the channels branch
|
||||
|
||||
```bash
|
||||
git fetch origin channels
|
||||
```nc:copy from-branch:channels
|
||||
src/channels/slack.ts
|
||||
src/channels/slack-registration.test.ts
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and its registration test
|
||||
### 2. Register the adapter
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/slack.ts > src/channels/slack.ts
|
||||
git show origin/channels:src/channels/slack-registration.test.ts > src/channels/slack-registration.test.ts
|
||||
```
|
||||
Append the self-registration import to the channel barrel (skipped if the line
|
||||
is already present). This one line is the skill's only reach-in into core:
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
```typescript
|
||||
```nc:append to:src/channels/index.ts
|
||||
import './slack.js';
|
||||
```
|
||||
|
||||
### 4. Install the adapter package (pinned)
|
||||
### 3. Install the adapter package
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/slack@4.27.0
|
||||
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
|
||||
|
||||
```nc:dep
|
||||
@chat-adapter/slack@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
### 4. Build and validate
|
||||
|
||||
```bash
|
||||
Build first: it guards the typed `createChatSdkBridge(...)` core call and proves
|
||||
the dependency is installed. Then run the one integration test.
|
||||
|
||||
```nc:run effect:build
|
||||
pnpm run build
|
||||
```
|
||||
```nc:run effect:test
|
||||
pnpm exec vitest run src/channels/slack-registration.test.ts
|
||||
```
|
||||
|
||||
Both must be clean before proceeding. `slack-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `slack`. It goes red if the `import './slack.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/slack` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
|
||||
|
||||
End-to-end message delivery against a real Slack workspace is verified manually once the service is running — see Next Steps and the webhook setup above.
|
||||
`slack-registration.test.ts` imports the real channel barrel and asserts the
|
||||
registry contains `slack`. It goes red if the import line is deleted or drifts,
|
||||
if the barrel fails to evaluate, or if `@chat-adapter/slack` isn't installed (the
|
||||
import throws) — so it also covers the dependency from step 3. End-to-end
|
||||
delivery against a real workspace is verified manually once the service runs.
|
||||
|
||||
## Credentials
|
||||
|
||||
### Create Slack App
|
||||
Slack can deliver events two ways. Socket Mode holds an outbound WebSocket open
|
||||
— no public URL, so it works on a laptop or behind NAT — and is the right
|
||||
default. Webhook delivery needs a public HTTPS Request URL but avoids the
|
||||
long-lived socket. The adapter picks Socket Mode automatically whenever
|
||||
`SLACK_APP_TOKEN` is set; otherwise it serves the webhook.
|
||||
|
||||
1. Go to [api.slack.com/apps](https://api.slack.com/apps) and click **Create New App** > **From scratch**
|
||||
2. Name it (e.g., "NanoClaw") and select your workspace
|
||||
3. Go to **OAuth & Permissions** and add Bot Token Scopes:
|
||||
- `chat:write`, `im:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read`, `reactions:write`, `files:read`, `files:write`
|
||||
4. Click **Install to Workspace** and copy the **Bot User OAuth Token** (`xoxb-...`)
|
||||
5. Go to **Basic Information** and copy the **Signing Secret**
|
||||
|
||||
### Enable DMs
|
||||
|
||||
6. Go to **App Home** and enable the **Messages Tab**
|
||||
7. Check **"Allow users to send Slash commands and messages from the messages tab"**
|
||||
|
||||
### Event Subscriptions
|
||||
|
||||
8. Go to **Event Subscriptions** and toggle **Enable Events**
|
||||
9. Set the **Request URL** to `https://your-domain/webhook/slack` — Slack will send a verification challenge; it must pass before you can save
|
||||
10. Under **Subscribe to bot events**, add:
|
||||
- `message.channels`, `message.groups`, `message.im`, `app_mention`
|
||||
11. Click **Save Changes**
|
||||
|
||||
### Interactivity
|
||||
|
||||
12. Go to **Interactivity & Shortcuts** and toggle **Interactivity** on
|
||||
13. Set the **Request URL** to the same `https://your-domain/webhook/slack`
|
||||
14. Click **Save Changes**
|
||||
15. Slack will show a banner asking you to **reinstall the app** — click it to apply the new settings
|
||||
|
||||
### Configure environment
|
||||
|
||||
Add to `.env`:
|
||||
|
||||
```bash
|
||||
SLACK_BOT_TOKEN=xoxb-your-bot-token
|
||||
SLACK_SIGNING_SECRET=your-signing-secret
|
||||
```nc:prompt connection validate:^(socket|webhook)$
|
||||
How should Slack deliver events? `socket` (Socket Mode — no public URL, recommended for local or behind-NAT installs) or `webhook` (needs a public HTTPS Request URL).
|
||||
```
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
Walk the operator through creating the Slack app, then collect the secrets it
|
||||
hands back. The adapter is already installed and registered — it just can't
|
||||
receive a message until this is done. For Socket Mode, tell the user:
|
||||
|
||||
### Webhook server
|
||||
```nc:operator when:connection=socket
|
||||
Create the Slack app (Socket Mode):
|
||||
1. Go to api.slack.com/apps → Create New App → From scratch. Name it (e.g. "NanoClaw") and pick your workspace.
|
||||
2. OAuth & Permissions → add these Bot Token Scopes: chat:write, im:write, channels:history, groups:history, im:history, channels:read, groups:read, users:read, reactions:write, files:read, files:write.
|
||||
3. App Home → enable the Messages Tab, and check "Allow users to send Slash commands and messages from the messages tab."
|
||||
4. Basic Information → App-Level Tokens → "Generate Token and Scopes" → add the connections:write scope → copy the token (starts with xapp-).
|
||||
5. Socket Mode → toggle "Enable Socket Mode" on.
|
||||
6. Install to Workspace, then copy the Bot User OAuth Token (starts with xoxb-).
|
||||
```
|
||||
|
||||
The Chat SDK bridge automatically starts a shared webhook server on port 3000 (configurable via `WEBHOOK_PORT` env var). The server handles `/webhook/slack` for Slack and other webhook-based adapters. This port must be publicly reachable from the internet for Slack to deliver events.
|
||||
For webhook delivery, tell the user:
|
||||
|
||||
If running locally, discuss options for exposing the server — e.g. ngrok (`ngrok http 3000`), Cloudflare Tunnel, or a reverse proxy on a VPS. The resulting public URL becomes the base for `https://your-domain/webhook/slack`.
|
||||
```nc:operator when:connection=webhook
|
||||
Create the Slack app (webhook delivery):
|
||||
1. Go to api.slack.com/apps → Create New App → From scratch. Name it (e.g. "NanoClaw") and pick your workspace.
|
||||
2. OAuth & Permissions → add these Bot Token Scopes: chat:write, im:write, channels:history, groups:history, im:history, channels:read, groups:read, users:read, reactions:write, files:read, files:write.
|
||||
3. App Home → enable the Messages Tab, and check "Allow users to send Slash commands and messages from the messages tab."
|
||||
4. Install to Workspace, then copy the Bot User OAuth Token (starts with xoxb-).
|
||||
5. Basic Information → copy the Signing Secret.
|
||||
```
|
||||
|
||||
Collect the secrets and store them (the bridge reads them from `.env`; the
|
||||
app-level token doubles as the Socket Mode switch, the signing secret
|
||||
authenticates webhook requests — each mode needs only its own):
|
||||
|
||||
```nc:prompt bot_token secret validate:^xoxb-
|
||||
Paste the Bot User OAuth Token — OAuth & Permissions, starts with `xoxb-`.
|
||||
```
|
||||
```nc:prompt app_token secret validate:^xapp- reuse:SLACK_APP_TOKEN when:connection=socket
|
||||
Paste the App-Level Token — Basic Information → App-Level Tokens, starts with `xapp-`.
|
||||
```
|
||||
```nc:prompt signing_secret secret validate:^[a-fA-F0-9]{16,}$ when:connection=webhook
|
||||
Paste the Signing Secret — Basic Information.
|
||||
```
|
||||
```nc:env-set
|
||||
SLACK_BOT_TOKEN={{bot_token}}
|
||||
```
|
||||
```nc:env-set when:connection=socket
|
||||
SLACK_APP_TOKEN={{app_token}}
|
||||
```
|
||||
```nc:env-set when:connection=webhook
|
||||
SLACK_SIGNING_SECRET={{signing_secret}}
|
||||
```
|
||||
|
||||
With webhook delivery, the bridge serves port 3000 at `/webhook/slack`
|
||||
automatically; to receive replies, that port must be reachable from the internet
|
||||
and registered with Slack (Socket Mode skips all of this — events arrive over
|
||||
the socket as soon as the service restarts). Tell the user:
|
||||
|
||||
```nc:operator when:connection=webhook
|
||||
Set up event delivery (needs a public HTTPS URL for port 3000 — ngrok, a Cloudflare Tunnel, or a reverse proxy on a VPS):
|
||||
1. Event Subscriptions → Enable Events. Set the Request URL to https://<your-public-host>/webhook/slack and wait for the challenge to pass.
|
||||
2. Subscribe to bot events: message.channels, message.groups, message.im, app_mention. Save Changes.
|
||||
3. Interactivity & Shortcuts → toggle Interactivity on, set the same Request URL, Save Changes, then reinstall the app when Slack prompts.
|
||||
```
|
||||
|
||||
## Resolve your DM channel
|
||||
|
||||
The agent talks to you in your direct-message channel with the bot. Resolve its
|
||||
address so the owner-wiring step can target it. Validating the token here, before
|
||||
the restart, fast-fails a bad credential while it's still cheap to fix. You'll
|
||||
need your Slack member ID: open your profile (your avatar, bottom-left), then
|
||||
**⋮** → **Copy member ID** — it starts with `U`.
|
||||
|
||||
```nc:prompt owner_handle validate:^U[A-Z0-9]{8,}$
|
||||
Your Slack member ID (Profile → ⋮ → "Copy member ID"; starts with U).
|
||||
```
|
||||
|
||||
Confirm the bot token works and capture the bot identity — `auth.test` returns the
|
||||
bot user and workspace, and fails here if the token is bad:
|
||||
|
||||
```nc:run capture:connected_as effect:fetch
|
||||
curl -sf -X POST https://slack.com/api/auth.test -H "Authorization: Bearer {{bot_token}}" | jq -er '"@" + .user + " in " + .team'
|
||||
```
|
||||
|
||||
Open the DM with `conversations.open` and take the channel id it returns as the
|
||||
conversation address `slack:<channelId>` (if Slack returns no channel, the bot is
|
||||
missing the `im:write` scope — add it and reinstall):
|
||||
|
||||
```nc:run capture:platform_id effect:fetch
|
||||
curl -s -X POST https://slack.com/api/conversations.open -H "Authorization: Bearer {{bot_token}}" -H "Content-Type: application/json" -d '{"users":"{{owner_handle}}"}' | jq -er '"slack:" + .channel.id'
|
||||
```
|
||||
|
||||
`owner_handle` and `platform_id` are what the owner-wiring step needs. The
|
||||
greeting goes out over `chat.postMessage`, which works right away. Receiving
|
||||
replies needs the event path live: with Socket Mode that happens as soon as the
|
||||
service restarts below; with webhook delivery, finish the Event Subscriptions
|
||||
and Interactivity steps above first.
|
||||
|
||||
## Restart
|
||||
|
||||
With the credential validated, restart the service so it loads the Slack adapter
|
||||
and the secrets you just stored, and wait for its CLI socket before wiring:
|
||||
|
||||
```nc:run effect:restart
|
||||
bash setup/lib/restart.sh
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
If you're in the middle of `/setup`, return to the setup flow now.
|
||||
|
||||
Otherwise, run `/manage-channels` to wire this channel to an agent group.
|
||||
If you're in the middle of `/setup`, return to the setup flow now. Otherwise wire
|
||||
this channel with `/init-first-agent` (or `/manage-channels`).
|
||||
|
||||
## Channel Info
|
||||
|
||||
|
||||
@@ -18,26 +18,38 @@ rm -f src/channels/teams.ts src/channels/teams-registration.test.ts
|
||||
|
||||
## 2. Remove credentials
|
||||
|
||||
Remove the `TEAMS_*` lines from `.env`, then re-sync to the container:
|
||||
|
||||
```bash
|
||||
TEAMS_APP_ID
|
||||
TEAMS_APP_PASSWORD
|
||||
TEAMS_APP_TENANT_ID
|
||||
TEAMS_APP_TYPE
|
||||
```
|
||||
|
||||
```bash
|
||||
mkdir -p data/env && cp .env data/env/env
|
||||
```
|
||||
|
||||
## 3. Remove the package
|
||||
Remove `TEAMS_APP_ID`, `TEAMS_APP_PASSWORD`, `TEAMS_APP_TENANT_ID`, and `TEAMS_APP_TYPE` from `.env`.
|
||||
|
||||
## 3. Sign out the Teams CLI, then remove the packages
|
||||
|
||||
`teams login` caches a Microsoft 365 session on disk that outlives the package —
|
||||
sign out first (skip if the CLI was never installed):
|
||||
|
||||
```bash
|
||||
teams logout
|
||||
npm uninstall -g @microsoft/teams.cli
|
||||
pnpm uninstall @chat-adapter/teams
|
||||
```
|
||||
|
||||
## 4. Rebuild and restart
|
||||
## 4. Remove local artifacts
|
||||
|
||||
```bash
|
||||
rm -rf data/teams
|
||||
```
|
||||
|
||||
## 5. Clean up cloud resources
|
||||
|
||||
Uninstall the app from Teams (Apps > Manage your apps). Then, on **both**
|
||||
paths, delete the Entra app registration in Azure Portal > App registrations —
|
||||
that is the step that actually revokes the client secret. Additionally:
|
||||
|
||||
- **Teams CLI path**: delete the app listing in the Teams Developer Portal
|
||||
(https://dev.teams.microsoft.com/apps) — removing it there alone does NOT
|
||||
revoke the secret.
|
||||
- **Manual Azure path**: delete the Azure Bot resource, and the `nanoclaw-rg`
|
||||
resource group if you created one (`az group delete --name nanoclaw-rg`).
|
||||
|
||||
## 6. Rebuild and restart
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
|
||||
+374
-189
@@ -5,251 +5,436 @@ description: Add Microsoft Teams channel integration via Chat SDK.
|
||||
|
||||
# Add Microsoft Teams Channel
|
||||
|
||||
Connect NanoClaw to Microsoft Teams for interactive chat in team channels, group chats, and direct messages.
|
||||
Adds Microsoft Teams support via the Chat SDK bridge — interactive chat in team
|
||||
channels, group chats, and direct messages. NanoClaw doesn't ship channels in
|
||||
trunk — this skill copies the Teams adapter in from the `channels` branch.
|
||||
|
||||
## Install
|
||||
The mechanical steps under **Apply** carry `nc:` directive fences: an agent
|
||||
reads the prose and applies them, and a parser can apply them deterministically
|
||||
from the same document. Every directive is idempotent, so the whole skill is
|
||||
safe to re-run; anything a parser can't apply falls back to the prose beside it.
|
||||
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the Teams adapter in from the `channels` branch.
|
||||
Teams has no "paste a token" shortcut — a bot has to exist in Microsoft's cloud
|
||||
before it can receive a message. The Microsoft Teams CLI collapses that into
|
||||
one sign-in and one create command: it registers the Entra app, generates the
|
||||
client secret, registers a Teams-managed bot (through the Teams Developer
|
||||
Portal — **no Azure subscription needed**), uploads the app package, and hands
|
||||
back an install link. The old ~7-step Azure portal walk survives only as a
|
||||
fallback in [Alternatives](#alternatives) for tenants where the Developer
|
||||
Portal is blocked.
|
||||
|
||||
### Pre-flight (idempotent)
|
||||
## Apply
|
||||
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
### 1. Copy the adapter and its registration test
|
||||
|
||||
- `src/channels/teams.ts` exists
|
||||
- `src/channels/teams-registration.test.ts` exists
|
||||
- `src/channels/index.ts` contains `import './teams.js';`
|
||||
- `@chat-adapter/teams` is listed in `package.json` dependencies
|
||||
Fetch the `channels` branch and copy the Teams adapter and its registration test
|
||||
into `src/channels/` (overwrite — the branch is canonical):
|
||||
|
||||
Otherwise continue. Every step below is safe to re-run.
|
||||
|
||||
### 1. Fetch the channels branch
|
||||
|
||||
```bash
|
||||
git fetch origin channels
|
||||
```nc:copy from-branch:channels
|
||||
src/channels/teams.ts
|
||||
src/channels/teams-registration.test.ts
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and its registration test
|
||||
### 2. Register the adapter
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/teams.ts > src/channels/teams.ts
|
||||
git show origin/channels:src/channels/teams-registration.test.ts > src/channels/teams-registration.test.ts
|
||||
```
|
||||
Append the self-registration import to the channel barrel (skipped if the line
|
||||
is already present). This one line is the skill's only reach-in into core:
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
```typescript
|
||||
```nc:append to:src/channels/index.ts
|
||||
import './teams.js';
|
||||
```
|
||||
|
||||
### 4. Install the adapter package (pinned)
|
||||
### 3. Install the adapter package
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/teams@4.27.0
|
||||
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
|
||||
|
||||
```nc:dep
|
||||
@chat-adapter/teams@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
### 4. Build and validate
|
||||
|
||||
```bash
|
||||
Build first: it guards the typed `createChatSdkBridge(...)` core call and proves
|
||||
the dependency is installed. Then run the one integration test.
|
||||
|
||||
```nc:run effect:build
|
||||
pnpm run build
|
||||
```
|
||||
```nc:run effect:test
|
||||
pnpm exec vitest run src/channels/teams-registration.test.ts
|
||||
```
|
||||
|
||||
Both must be clean before proceeding. `teams-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `teams`. It goes red if the `import './teams.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/teams` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
|
||||
|
||||
End-to-end message delivery against a real Teams workspace is verified manually once the service is running — see Next Steps and the webhook setup above.
|
||||
`teams-registration.test.ts` imports the real channel barrel and asserts the
|
||||
registry contains `teams`. It goes red if the import line is deleted or drifts,
|
||||
if the barrel fails to evaluate, or if `@chat-adapter/teams` isn't installed (the
|
||||
import throws) — so it also covers the dependency from step 3. End-to-end
|
||||
delivery against a real Teams workspace is verified manually once the service
|
||||
runs.
|
||||
|
||||
## Credentials
|
||||
|
||||
Two paths — manual (Azure Portal) or auto (Teams CLI).
|
||||
The adapter is installed and registered, but it can't receive a message until a
|
||||
bot exists, points at this machine, and is installed into Teams. The Teams CLI
|
||||
does all of that below.
|
||||
|
||||
### Auto: Teams CLI
|
||||
### Check for existing credentials
|
||||
|
||||
Requires Node.js 18+, a Microsoft 365 account with sideloading permissions, and a public HTTPS endpoint (ngrok, Cloudflare Tunnel, or similar).
|
||||
Re-running `teams app create` provisions a brand-new app registration and bot
|
||||
each time — it never reuses the first one. So the flow starts with a probe:
|
||||
when `.env` already carries a Teams credential — either key; a partial pair
|
||||
means a half-finished setup that creating ANOTHER app would only corrupt —
|
||||
every step below (prompts included) is skipped and the flow drops straight
|
||||
through to [Restart](#restart). To rotate credentials or finish a partial
|
||||
configuration, see [Troubleshooting](#troubleshooting); if your tunnel URL
|
||||
changed, the fix is `teams app update`, not a re-run (also in Troubleshooting).
|
||||
|
||||
1. Install the CLI:
|
||||
|
||||
```bash
|
||||
npm install -g @microsoft/teams.cli@preview
|
||||
```
|
||||
|
||||
2. Sign in and verify:
|
||||
|
||||
```bash
|
||||
teams login
|
||||
teams status
|
||||
```
|
||||
|
||||
3. Create the Entra app, client secret, and bot registration:
|
||||
|
||||
```bash
|
||||
teams app create \
|
||||
--name "NanoClaw" \
|
||||
--endpoint "https://your-domain/api/webhooks/teams"
|
||||
```
|
||||
|
||||
The CLI prints the credentials as `CLIENT_ID`, `CLIENT_SECRET`, and `TENANT_ID`. Map them to NanoClaw's env keys:
|
||||
|
||||
- `CLIENT_ID` → `TEAMS_APP_ID`
|
||||
- `CLIENT_SECRET` → `TEAMS_APP_PASSWORD`
|
||||
- `TENANT_ID` → `TEAMS_APP_TENANT_ID`
|
||||
|
||||
4. Pick **Install in Teams** from the post-create menu and confirm in the Teams dialog.
|
||||
|
||||
Continue to [Configure environment](#configure-environment).
|
||||
|
||||
---
|
||||
|
||||
The steps below describe the **manual Azure Portal path**.
|
||||
|
||||
### Step 1: Create an Azure AD App Registration
|
||||
|
||||
1. Go to [Azure Portal](https://portal.azure.com) > **App registrations** > **New registration**
|
||||
2. Name it (e.g., "NanoClaw")
|
||||
3. Supported account types: **Single tenant** (your org only) or **Multi tenant** (any org)
|
||||
4. Click **Register**
|
||||
5. Copy the **Application (client) ID** and **Directory (tenant) ID** from the Overview page
|
||||
|
||||
### Step 2: Create a Client Secret
|
||||
|
||||
1. In the App Registration, go to **Certificates & secrets**
|
||||
2. Click **New client secret**, description "nanoclaw", expiry 180 days
|
||||
3. Click **Add** and **copy the Value immediately** (shown only once)
|
||||
|
||||
### Step 3: Create an Azure Bot
|
||||
|
||||
1. Go to Azure Portal > search **Azure Bot** > **Create**
|
||||
2. Fill in:
|
||||
- **Bot handle**: unique name (e.g., "nanoclaw-bot")
|
||||
- **Type of App**: match your app registration (Single or Multi Tenant)
|
||||
- **Creation type**: **Use existing app registration**
|
||||
- **App ID**: paste from Step 1
|
||||
- **App tenant ID**: paste from Step 1 (Single Tenant only)
|
||||
3. Click **Review + create** > **Create**
|
||||
|
||||
Or use Azure CLI:
|
||||
|
||||
```bash
|
||||
az group create --name nanoclaw-rg --location eastus
|
||||
az bot create \
|
||||
--resource-group nanoclaw-rg \
|
||||
--name nanoclaw-bot \
|
||||
--app-type SingleTenant \
|
||||
--appid YOUR_APP_ID \
|
||||
--tenant-id YOUR_TENANT_ID \
|
||||
--endpoint "https://your-domain/api/webhooks/teams"
|
||||
```nc:run capture:have_creds
|
||||
( grep -q '^TEAMS_APP_ID=.' .env 2>/dev/null || grep -q '^TEAMS_APP_PASSWORD=.' .env 2>/dev/null ) && echo yes || echo no
|
||||
```
|
||||
|
||||
### Step 4: Configure Messaging Endpoint
|
||||
Before creating anything, tell the user:
|
||||
|
||||
1. Go to your Azure Bot resource > **Configuration**
|
||||
2. Set **Messaging endpoint** to `https://your-domain/api/webhooks/teams`
|
||||
3. Click **Apply**
|
||||
|
||||
### Step 5: Enable Teams Channel
|
||||
|
||||
1. In the Azure Bot resource, go to **Channels**
|
||||
2. Click **Microsoft Teams** > Accept terms > **Apply**
|
||||
|
||||
Or via CLI:
|
||||
|
||||
```bash
|
||||
az bot msteams create --resource-group nanoclaw-rg --name nanoclaw-bot
|
||||
```nc:operator when:have_creds=no
|
||||
Confirm you have everything Teams setup needs:
|
||||
1. A Microsoft 365 account that can create Entra app registrations and upload custom apps (sideloading) — free personal Teams does NOT qualify; you need a Microsoft 365 Business / EDU / developer tenant.
|
||||
2. A way to expose an HTTPS endpoint that forwards to this machine's webhook port 3000 (ngrok, a Cloudflare Tunnel, or a reverse-proxied VPS). Start it now if it isn't running — e.g. `ngrok http 3000` — the create step needs the URL up front.
|
||||
```
|
||||
|
||||
### Step 6: Create and Sideload Teams App
|
||||
### Public URL
|
||||
|
||||
Create a `manifest.json`:
|
||||
Microsoft delivers bot messages to an HTTPS endpoint you control; it has to
|
||||
reach this machine's webhook server (port 3000, configurable via
|
||||
`WEBHOOK_PORT`) at `/webhook/teams`.
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.16/MicrosoftTeams.schema.json",
|
||||
"manifestVersion": "1.16",
|
||||
"version": "1.0.0",
|
||||
"id": "YOUR_APP_ID",
|
||||
"packageName": "com.nanoclaw.bot",
|
||||
"developer": {
|
||||
"name": "NanoClaw",
|
||||
"websiteUrl": "https://your-domain",
|
||||
"privacyUrl": "https://your-domain",
|
||||
"termsOfUseUrl": "https://your-domain"
|
||||
},
|
||||
"name": { "short": "NanoClaw", "full": "NanoClaw Assistant" },
|
||||
"description": {
|
||||
"short": "NanoClaw assistant bot",
|
||||
"full": "NanoClaw personal assistant powered by Claude."
|
||||
},
|
||||
"icons": { "outline": "outline.png", "color": "color.png" },
|
||||
"accentColor": "#4A90D9",
|
||||
"bots": [{
|
||||
"botId": "YOUR_APP_ID",
|
||||
"scopes": ["personal", "team", "groupchat"],
|
||||
"supportsFiles": false,
|
||||
"isNotificationOnly": false
|
||||
}],
|
||||
"permissions": ["identity", "messageTeamMembers"],
|
||||
"validDomains": ["your-domain"]
|
||||
}
|
||||
```nc:prompt public_url when:have_creds=no validate:^https:// normalize:rstrip-slash
|
||||
Paste the public https:// base URL that forwards to this machine's port 3000 (no trailing path) — e.g. https://abcd1234.ngrok.io from `ngrok http 3000`.
|
||||
```
|
||||
|
||||
Create two icon PNGs (32x32 `outline.png`, 192x192 `color.png`), zip all three files together.
|
||||
### App name and tenant
|
||||
|
||||
**Sideload in Teams:**
|
||||
1. Open Teams > **Apps** > **Manage your apps**
|
||||
2. Click **Upload an app** > **Upload a custom app**
|
||||
3. Select the zip file
|
||||
Two more choices belong to the human before anything is created. The name is
|
||||
used everywhere at once: the Entra app registration, the bot, and the Teams
|
||||
app are all created under it. There is no client-secret name to pick on this
|
||||
path — the CLI generates the secret itself (Entra displayName `default`,
|
||||
2-year expiry); rotating it later is in [Troubleshooting](#troubleshooting).
|
||||
|
||||
Sideloading requires Teams admin access. Free personal Teams does NOT support sideloading. Use a Microsoft 365 Business account or developer tenant.
|
||||
|
||||
### Step 7: Receive All Messages (Optional)
|
||||
|
||||
By default, the bot only receives messages when @-mentioned. To receive all messages in a channel without @-mention, add RSC permissions to `manifest.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"authorization": {
|
||||
"permissions": {
|
||||
"resourceSpecific": [
|
||||
{ "name": "ChannelMessage.Read.Group", "type": "Application" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```nc:prompt app_name when:have_creds=no validate:^[\sA-Za-z0-9._-]{1,30}$ normalize:trim
|
||||
What should the bot be called? One name covers the Entra app registration, the bot, and the Teams app (letters, digits, spaces, . _ -; max 30 characters) — e.g. NanoClaw.
|
||||
```
|
||||
|
||||
### Configure environment
|
||||
```nc:prompt tenant when:have_creds=no validate:^(single|multi)$ normalize:lower
|
||||
Who should be able to install the bot — answer "single" (only your own Microsoft 365 tenant; the safe default for a self-hosted assistant) or "multi" (any Microsoft 365 tenant).
|
||||
```
|
||||
|
||||
Add to `.env`:
|
||||
### Install the Teams CLI
|
||||
|
||||
```bash
|
||||
TEAMS_APP_ID=your-app-id
|
||||
TEAMS_APP_PASSWORD=your-client-secret
|
||||
# For Single Tenant only:
|
||||
TEAMS_APP_TENANT_ID=your-tenant-id
|
||||
Installed globally with npm — not as a workspace dependency — deliberately:
|
||||
the CLI's credential store (keytar) is a native module whose install script
|
||||
must run to fetch its prebuilt binary, and pnpm's supply-chain policy blocks
|
||||
dependency build scripts — a workspace install leaves the sign-in unable to
|
||||
persist. The global install matches Microsoft's own instruction and keeps the
|
||||
workspace policy intact. Pinned; re-running is a no-op. (If npm reports
|
||||
EACCES here, your global prefix needs root — prefer a user-level Node like
|
||||
nvm, or `npm config set prefix ~/.npm-global`.) `--loglevel=error` because
|
||||
npm runs inside a pnpm script here and warns about every pnpm config var it
|
||||
inherits — pure noise; real errors still print.
|
||||
|
||||
```nc:run effect:external when:have_creds=no
|
||||
npm install -g @microsoft/teams.cli@3.0.2 --loglevel=error
|
||||
```
|
||||
|
||||
npm's global bin directory is not reliably on PATH (custom prefixes rarely
|
||||
are), so every step below calls the CLI by its absolute path,
|
||||
`$(npm prefix -g)/bin/teams` (stderr of the prefix lookup silenced — same
|
||||
pnpm-config noise as above). Where this document says to run `teams …` by
|
||||
hand, use that path too if plain `teams` isn't found.
|
||||
|
||||
### Sign in to Microsoft 365
|
||||
|
||||
Every `teams` command is a separate process, so the sign-in must survive into
|
||||
the next one via the CLI's on-disk token cache. On Linux that cache needs the
|
||||
libsecret library — without it the session silently evaporates when the login
|
||||
process exits, and the create step fails with `AUTH_REQUIRED`. Install it
|
||||
first on Linux: `sudo apt-get install -y libsecret-1-0` (Debian/Ubuntu). A
|
||||
"libsecret not found — token cache will be stored unencrypted" warning on a
|
||||
headless box is expected and harmless even WITH libsecret installed: there is
|
||||
no keyring daemon to talk to, so the CLI uses its plaintext cache file, which
|
||||
persists fine (encryption-at-rest is all you give up). The
|
||||
step below verifies persistence by re-reading the session from a fresh
|
||||
process after login. In an interactive terminal the login opens a browser;
|
||||
on a headless box (SSH) it prints a device code — open
|
||||
microsoft.com/devicelogin on any machine and enter it. If this step fails,
|
||||
run `teams login` then `teams status` by hand: status must say logged in, or
|
||||
the cache is not persisting (see Troubleshooting).
|
||||
|
||||
```nc:run effect:step when:have_creds=no
|
||||
"$(npm prefix -g 2>/dev/null)/bin/teams" login && "$(npm prefix -g 2>/dev/null)/bin/teams" status --json 2>/dev/null | grep -q '"loggedIn": true' && printf '=== NANOCLAW SETUP: TEAMS-LOGIN ===\nSTATUS: success\n=== END ===\n'
|
||||
```
|
||||
|
||||
### Create the bot
|
||||
|
||||
One command registers the Entra app, generates a client secret (Graph can take
|
||||
~30s to see the new app — the CLI retries), registers a Teams-managed bot, and
|
||||
uploads the app package to the Teams Developer Portal. It needs the sign-in
|
||||
from the previous step (`AUTH_REQUIRED` means run that first). The tenant
|
||||
answer picks the variant — they differ only in `--sign-in-audience`, and the
|
||||
single-tenant one also captures the tenant ID (which the multi-tenant `.env`
|
||||
pairing must omit). A `when:tenant=…` guard implies a fresh create: the tenant
|
||||
prompt is only asked when the credentials probe answered no, so with existing
|
||||
credentials both variants are skipped.
|
||||
|
||||
```nc:run effect:external when:tenant=single capture:app_id=.credentials.CLIENT_ID,app_password=.credentials.CLIENT_SECRET,app_tenant_id=.credentials.TENANT_ID,teams_app_id=.teamsAppId,install_link=.installLink validate:^.+$
|
||||
"$(npm prefix -g 2>/dev/null)/bin/teams" app create --name "{{app_name}}" --endpoint "{{public_url}}/webhook/teams" --sign-in-audience myOrg --json
|
||||
```
|
||||
|
||||
```nc:run effect:external when:tenant=multi capture:app_id=.credentials.CLIENT_ID,app_password=.credentials.CLIENT_SECRET,teams_app_id=.teamsAppId,install_link=.installLink validate:^.+$
|
||||
"$(npm prefix -g 2>/dev/null)/bin/teams" app create --name "{{app_name}}" --endpoint "{{public_url}}/webhook/teams" --sign-in-audience multipleOrgs --json
|
||||
```
|
||||
|
||||
### Store the credentials
|
||||
|
||||
The adapter reads these from `.env` (set-if-absent — a value you've already
|
||||
filled in is never overwritten). The pairing matters, and the tenant branch
|
||||
encodes it: `SingleTenant` requires `TEAMS_APP_TENANT_ID`, and a multi-tenant
|
||||
app must instead set `TEAMS_APP_TYPE=MultiTenant` with **no** tenant ID — a
|
||||
mismatch makes the adapter authenticate against the wrong authority and every
|
||||
message fails with a 401 from Bot Framework.
|
||||
|
||||
```nc:env-set when:tenant=single
|
||||
TEAMS_APP_ID={{app_id}}
|
||||
TEAMS_APP_PASSWORD={{app_password}}
|
||||
TEAMS_APP_TENANT_ID={{app_tenant_id}}
|
||||
TEAMS_APP_TYPE=SingleTenant
|
||||
```
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
```nc:env-set when:tenant=multi
|
||||
TEAMS_APP_ID={{app_id}}
|
||||
TEAMS_APP_PASSWORD={{app_password}}
|
||||
TEAMS_APP_TYPE=MultiTenant
|
||||
```
|
||||
|
||||
### Webhook server
|
||||
### Install the app in Teams
|
||||
|
||||
The Chat SDK bridge automatically starts a shared webhook server on port 3000 (configurable via `WEBHOOK_PORT` env var). The server handles `/api/webhooks/teams` for Teams and other webhook-based adapters. This port must be publicly reachable from the internet for Azure Bot Service to deliver activities.
|
||||
The app package is already uploaded — no manifest zip, no manual sideload.
|
||||
Tell the user:
|
||||
|
||||
For local development without a public URL, use a tunnel (e.g., `ngrok http 3000`) and update the messaging endpoint in Azure Bot Configuration.
|
||||
```nc:operator when:have_creds=no
|
||||
Install the bot into Teams:
|
||||
1. Open {{install_link}} — Teams opens with the app's install dialog. Click Add.
|
||||
2. If you need the link again later, run: teams app get {{teams_app_id}} --install-link
|
||||
3. If Teams refuses with a custom-app-upload error, a tenant admin must enable sideloading: Teams Admin Center > Teams apps > Setup policies > Global > "Upload custom apps" = On.
|
||||
Once the app shows up in your Teams sidebar (or app list), continue.
|
||||
```
|
||||
|
||||
## Restart
|
||||
|
||||
Restart the service so it loads the Teams adapter and the credentials you just
|
||||
stored:
|
||||
|
||||
```nc:run effect:restart
|
||||
bash setup/lib/restart.sh
|
||||
```
|
||||
|
||||
## Finish wiring
|
||||
|
||||
Unlike Discord or Slack, a Teams bot's platform ID isn't known until you DM the
|
||||
bot for the first time — the adapter derives it from the inbound activity. So
|
||||
this skill installs the adapter and stops here; you finish the wiring once the
|
||||
bot has seen its first message. Tell the user:
|
||||
|
||||
```nc:operator
|
||||
The Teams adapter is live and the service is running. One thing is left: your Teams bot's platform ID (which NanoClaw needs to wire it to an agent group) only becomes known after you DM the bot for the first time. To finish:
|
||||
1. Find your bot in Teams (search by name, or via the app you just installed) and send it a message ("hi" is fine).
|
||||
2. Tail logs/nanoclaw.log for the inbound — the router auto-creates a row in messaging_groups in data/v2.db.
|
||||
3. Run scripts/init-first-agent.ts with --channel teams, the discovered platform_id, and your AAD user id — OR run /manage-channels to wire it interactively.
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
If you're in the middle of `/setup`, return to the setup flow now.
|
||||
|
||||
Otherwise, run `/manage-channels` to wire this channel to an agent group.
|
||||
If you're in the middle of `/setup`, return to the setup flow now. Otherwise,
|
||||
once you've DM'd the bot, wire this channel with `/init-first-agent` (or
|
||||
`/manage-channels`).
|
||||
|
||||
## Channel Info
|
||||
|
||||
- **type**: `teams`
|
||||
- **terminology**: Teams has "teams" containing "channels." The bot can also receive DMs (personal scope) and group chat messages. Channels support threaded replies.
|
||||
- **platform-id-format**: `teams:{base64-encoded-conversation-id}:{base64-encoded-service-url}` — auto-generated by the adapter, not human-readable. Use the auto-created messaging group ID for wiring.
|
||||
- **how-to-find-id**: Send a message to the bot in the channel. NanoClaw auto-creates a messaging group and logs the platform ID. Use that messaging group ID for wiring.
|
||||
- **platform-id-format**: `teams:{base64url-conversation-id}:{base64url-service-url}` — auto-generated by the adapter from the first inbound activity, not human-readable. Use the auto-created messaging group for wiring.
|
||||
- **how-to-find-id**: Send a message to the bot in the channel or a DM. NanoClaw auto-creates a messaging group and logs the platform ID. Use that messaging group for wiring.
|
||||
- **supports-threads**: yes (channels only; DMs and group chats are flat)
|
||||
- **typical-use**: Team collaboration with the bot in channels; personal assistant via DMs
|
||||
- **default-isolation**: Separate agent group per team. DMs can share an agent group with your main channel for unified personal memory.
|
||||
|
||||
## Alternatives
|
||||
|
||||
### Manual Azure portal path
|
||||
|
||||
For tenants where the Teams Developer Portal is blocked. Unlike the CLI path,
|
||||
the Azure Bot resource in step 3 requires an active **Azure subscription**.
|
||||
This is the classic walk; every value it produces maps onto the same `.env`
|
||||
keys. The choices are the human's here just as on the CLI path — ask before
|
||||
creating anything: the app registration name, single vs multi tenant, a
|
||||
client secret description, and (this path only) a separate Azure Bot handle.
|
||||
|
||||
1. **App registration**: in https://portal.azure.com, search "App registrations"
|
||||
→ "New registration". Name it (e.g. "NanoClaw"); Supported account types:
|
||||
Single tenant (most common for self-host) or Multi tenant. From the Overview
|
||||
page copy the **Application (client) ID** and — single tenant only — the
|
||||
**Directory (tenant) ID**.
|
||||
2. **Client secret**: in the app registration, "Certificates & secrets" → "New
|
||||
client secret" (expires 180 days or longer). **Copy the Value now** — Azure
|
||||
shows it once (the Value column, not the Secret ID).
|
||||
3. **Azure Bot resource**: search "Azure Bot" → Create. Bot handle: any unique
|
||||
name; Type of App: must match step 1; Creation type: "Use existing app
|
||||
registration" with the App ID from step 1. After creating, open the bot →
|
||||
Configuration and set **Messaging endpoint** to
|
||||
`https://your-domain/webhook/teams`, then Apply.
|
||||
4. **Enable the Teams channel**: Azure Bot resource → Channels → Microsoft
|
||||
Teams → Accept terms → Apply.
|
||||
5. **Store the credentials** in `.env` (the same 401 pairing rule applies —
|
||||
`SingleTenant` needs the tenant ID, `MultiTenant` must omit it):
|
||||
|
||||
```bash
|
||||
TEAMS_APP_ID=<Application (client) ID>
|
||||
TEAMS_APP_PASSWORD=<client secret Value>
|
||||
TEAMS_APP_TYPE=SingleTenant
|
||||
TEAMS_APP_TENANT_ID=<Directory (tenant) ID>
|
||||
```
|
||||
6. **Build the app package** (manifest + icons, written in-process to
|
||||
`data/teams/teams-app-package.zip` — no `zip` binary needed):
|
||||
|
||||
```bash
|
||||
pnpm exec tsx setup/channels/teams-manifest-build.ts --app-id YOUR_APP_ID --url https://your-domain
|
||||
```
|
||||
7. **Sideload**: Microsoft Teams → Apps → Manage your apps → Upload an app →
|
||||
"Upload a custom app" → select the zip → Add.
|
||||
8. Continue from [Restart](#restart).
|
||||
|
||||
Or create the bot resource with the Azure CLI instead of the portal:
|
||||
|
||||
```bash
|
||||
az group create --name nanoclaw-rg --location eastus
|
||||
az bot create --resource-group nanoclaw-rg --name nanoclaw-bot --app-type SingleTenant --appid YOUR_APP_ID --tenant-id YOUR_TENANT_ID --endpoint "https://your-domain/webhook/teams"
|
||||
az bot msteams create --resource-group nanoclaw-rg --name nanoclaw-bot
|
||||
```
|
||||
|
||||
## Optional configuration
|
||||
|
||||
### Receive all channel messages (without @-mention)
|
||||
|
||||
By default the bot only receives messages when @-mentioned. With a CLI-created
|
||||
bot, grant the resource-specific-consent (RSC) permissions directly — no
|
||||
manifest edit, no re-upload; the app version is bumped automatically:
|
||||
|
||||
```bash
|
||||
teams app rsc add <teams-app-id> ChannelMessage.Read.Group --type Application
|
||||
teams app rsc add <teams-app-id> ChatMessage.Read.Chat --type Application
|
||||
```
|
||||
|
||||
Then update/reinstall the app in the team so the new permissions get consented.
|
||||
(`<teams-app-id>` is the Teams App ID shown in the install step — recover it
|
||||
any time with `teams app list`, or find the app at
|
||||
https://dev.teams.microsoft.com/apps.)
|
||||
|
||||
On the manual path, regenerate the package with RSC baked in and sideload it
|
||||
again (the manifest version is bumped so the upload supersedes the original):
|
||||
|
||||
```bash
|
||||
pnpm exec tsx setup/channels/teams-manifest-build.ts --app-id YOUR_APP_ID --url https://your-domain --rsc
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Upload a custom app" is missing / sideloading blocked
|
||||
|
||||
`teams status` shows whether sideloading is enabled at both tenant
|
||||
and user level; the login output prints the same check.
|
||||
|
||||
- **Tenant level off**: Teams Admin Center → **Teams apps** → **Setup
|
||||
policies** → **Global** → **Upload custom apps** = On.
|
||||
- **"Enabled for the tenant, but your user policy blocks it"**: the per-user
|
||||
policy is the blocker — Teams Admin Center → **Users** → find the user →
|
||||
**Policies** → **App setup policy** → assign one with **Upload custom
|
||||
apps** = On. Policy changes can take a while to propagate.
|
||||
|
||||
Free personal Teams does not support sideloading at all — use a Microsoft 365
|
||||
Business / EDU / developer tenant.
|
||||
|
||||
### `teams: command not found`
|
||||
|
||||
The CLI installed fine but npm's global bin directory isn't on your PATH — a
|
||||
common state with custom npm prefixes. Find it with `npm prefix -g` (the
|
||||
binary is at `<prefix>/bin/teams`), then either add that directory to PATH or
|
||||
symlink the binary somewhere already on it. The skill's own steps are immune —
|
||||
they invoke the absolute path.
|
||||
|
||||
### Create fails immediately with `AUTH_REQUIRED` after a successful sign-in
|
||||
|
||||
The sign-in didn't persist: each `teams` command is a separate process, and
|
||||
when the CLI's credential store can't load it silently falls back to an
|
||||
in-memory cache that dies with the login process. Symptom check:
|
||||
`teams status` says logged out right after a login succeeded. Two causes:
|
||||
|
||||
- **CLI installed as a pnpm workspace dependency**: pnpm's supply-chain policy
|
||||
skips dependency build scripts, so keytar (the CLI's native credential
|
||||
store) never gets its binary and the whole store fails to load. Use the
|
||||
global npm install this skill performs — and `pnpm uninstall
|
||||
@microsoft/teams.cli` if a workspace copy lingers, so `teams` resolves to
|
||||
the global one.
|
||||
- **libsecret missing (Linux)**: keytar links against it at load time. Fix:
|
||||
`sudo apt-get install -y libsecret-1-0` (Debian/Ubuntu). On a headless box
|
||||
with no keyring daemon the CLI then falls back to a plaintext cache file
|
||||
(with a warning) — expected.
|
||||
|
||||
After fixing, sign in again and confirm `teams status` shows logged in, then
|
||||
re-run this skill.
|
||||
|
||||
### Bot never receives messages
|
||||
|
||||
1. The app is actually installed in Teams — if setup was interrupted before
|
||||
the install step, nothing got installed. Recover the install link:
|
||||
`teams app list` shows the Teams App ID, then
|
||||
`teams app get <teams-app-id> --install-link`.
|
||||
2. The tunnel is up and the messaging endpoint matches it — the endpoint must
|
||||
be `https://<your-domain>/webhook/teams`, and your tunnel (e.g.
|
||||
`ngrok http 3000`) must be forwarding to this machine's port 3000. Check
|
||||
with `teams app doctor <teams-app-id>` (CLI-created bots) or Azure
|
||||
Bot → **Configuration** (manual path).
|
||||
3. The adapter started: `grep -i teams logs/nanoclaw.log | tail`.
|
||||
4. The credentials are in `.env` (`TEAMS_APP_ID`, `TEAMS_APP_PASSWORD`,
|
||||
`TEAMS_APP_TYPE`).
|
||||
|
||||
### Tunnel URL changed
|
||||
|
||||
Point the bot at the new endpoint:
|
||||
`teams app update <teams-app-id> --endpoint "https://new-domain/webhook/teams"`
|
||||
(manual path: Azure Bot → Configuration → Messaging endpoint).
|
||||
|
||||
### `Unauthorized` / 401 from Azure Bot Service
|
||||
|
||||
Either the credential pairing is wrong, or the secret is dead:
|
||||
|
||||
- **Pairing**: `TEAMS_APP_TYPE=SingleTenant` requires `TEAMS_APP_TENANT_ID`;
|
||||
`MultiTenant` must have **no** tenant ID set. A mismatch authenticates
|
||||
against the wrong authority and every send/receive 401s.
|
||||
- **Secret**: expired or mispasted. Rotate with
|
||||
`teams app auth secret create <teams-app-id>` (or Azure portal →
|
||||
Certificates & secrets), update `TEAMS_APP_PASSWORD` in `.env`, and restart.
|
||||
|
||||
### Rotate or recreate credentials
|
||||
|
||||
The credentials flow skips creation while `.env` has `TEAMS_APP_ID` **or**
|
||||
`TEAMS_APP_PASSWORD` — deleting just one line does not make the skill
|
||||
regenerate it (that would pair a new app with stale keys). To rotate only the
|
||||
secret, use the 401 section above. To start over completely: delete **all**
|
||||
`TEAMS_*` lines from `.env`, optionally delete the old app at
|
||||
https://dev.teams.microsoft.com/apps (CLI path) or in Azure Portal → App
|
||||
registrations (manual path), then re-run this skill. Re-running
|
||||
`teams app create` with old credentials still in `.env` would otherwise create
|
||||
a second, orphaned app.
|
||||
|
||||
### Replies land in the wrong place
|
||||
|
||||
A Teams bot's platform ID is derived from the first inbound activity, so wire
|
||||
the messaging group that the router auto-creates after you DM the bot — don't
|
||||
guess the platform ID. See **Finish wiring** above.
|
||||
|
||||
@@ -5,111 +5,156 @@ description: Add Telegram channel integration via Chat SDK.
|
||||
|
||||
# Add Telegram Channel
|
||||
|
||||
Adds Telegram bot support via the Chat SDK bridge.
|
||||
Adds Telegram bot support via the Chat SDK bridge. NanoClaw doesn't ship
|
||||
channels in trunk — this skill copies the Telegram adapter, its
|
||||
formatting/pairing helpers, and their tests in from the `channels` branch. The
|
||||
`pair-telegram` setup step is maintained in trunk, so it is not copied here.
|
||||
|
||||
## Install
|
||||
The mechanical steps under **Apply** carry `nc:` directive fences: an agent
|
||||
reads the prose and applies them, and a parser can apply them deterministically
|
||||
from the same document. Every directive is idempotent, so the whole skill is
|
||||
safe to re-run; anything a parser can't apply falls back to the prose beside it.
|
||||
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the Telegram adapter, its formatting/pairing helpers, their tests, and the `pair-telegram` setup step in from the `channels` branch.
|
||||
## Apply
|
||||
|
||||
### Pre-flight (idempotent)
|
||||
### 1. Copy the adapter, helpers, and tests
|
||||
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
Fetch the `channels` branch and copy the Telegram adapter, its pairing and
|
||||
markdown-sanitize helpers (with their tests), and the registration test into
|
||||
place (overwrite — the branch is canonical):
|
||||
|
||||
- `src/channels/telegram.ts`, `telegram-pairing.ts`, `telegram-markdown-sanitize.ts` (and their `.test.ts` siblings) all exist
|
||||
- `src/channels/telegram-registration.test.ts` exists
|
||||
- `src/channels/index.ts` contains `import './telegram.js';`
|
||||
- `setup/pair-telegram.ts` exists and `setup/index.ts`'s `STEPS` map contains `'pair-telegram':`
|
||||
- `@chat-adapter/telegram` is listed in `package.json` dependencies
|
||||
|
||||
Otherwise continue. Every step below is safe to re-run.
|
||||
|
||||
### 1. Fetch the channels branch
|
||||
|
||||
```bash
|
||||
git fetch origin channels
|
||||
```nc:copy from-branch:channels
|
||||
src/channels/telegram.ts
|
||||
src/channels/telegram-pairing.ts
|
||||
src/channels/telegram-pairing.test.ts
|
||||
src/channels/telegram-markdown-sanitize.ts
|
||||
src/channels/telegram-markdown-sanitize.test.ts
|
||||
src/channels/telegram-registration.test.ts
|
||||
```
|
||||
|
||||
### 2. Copy the adapter, helpers, tests, registration test, and setup step
|
||||
### 2. Register the adapter
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/telegram.ts > src/channels/telegram.ts
|
||||
git show origin/channels:src/channels/telegram-registration.test.ts > src/channels/telegram-registration.test.ts
|
||||
git show origin/channels:src/channels/telegram-pairing.ts > src/channels/telegram-pairing.ts
|
||||
git show origin/channels:src/channels/telegram-pairing.test.ts > src/channels/telegram-pairing.test.ts
|
||||
git show origin/channels:src/channels/telegram-markdown-sanitize.ts > src/channels/telegram-markdown-sanitize.ts
|
||||
git show origin/channels:src/channels/telegram-markdown-sanitize.test.ts > src/channels/telegram-markdown-sanitize.test.ts
|
||||
git show origin/channels:setup/pair-telegram.ts > setup/pair-telegram.ts
|
||||
```
|
||||
Append the self-registration import to the channel barrel (skipped if the line
|
||||
is already present). This one line is the skill's only reach-in into core:
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if already present):
|
||||
|
||||
```typescript
|
||||
```nc:append to:src/channels/index.ts
|
||||
import './telegram.js';
|
||||
```
|
||||
|
||||
### 4. Register the setup step
|
||||
### 3. Register the pairing setup step
|
||||
|
||||
In `setup/index.ts`, add this entry to the `STEPS` map (right after the `register` line is fine; skip if already present):
|
||||
Add the `pair-telegram` loader to the `STEPS` map in `setup/index.ts`, inside the
|
||||
dormant marker region (skipped if already present — `pair-telegram` ships in core,
|
||||
so this idempotent-skips on a normal install, but is expressed for a
|
||||
clean-upstream rebuild). The pairing handshake below spawns this step:
|
||||
|
||||
```typescript
|
||||
```nc:append to:setup/index.ts at:nanoclaw:setup-steps
|
||||
'pair-telegram': () => import('./pair-telegram.js'),
|
||||
```
|
||||
|
||||
### 5. Install the adapter package (pinned)
|
||||
### 4. Install the adapter package
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/telegram@4.27.0
|
||||
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
|
||||
|
||||
```nc:dep
|
||||
@chat-adapter/telegram@4.29.0
|
||||
```
|
||||
|
||||
### 6. Build and validate
|
||||
### 5. Build and validate
|
||||
|
||||
```bash
|
||||
Build first: it guards the typed `createChatSdkBridge(...)` core call and proves
|
||||
the dependency is installed. Then run the one integration test.
|
||||
|
||||
```nc:run effect:build
|
||||
pnpm run build
|
||||
```
|
||||
```nc:run effect:test
|
||||
pnpm exec vitest run src/channels/telegram-registration.test.ts
|
||||
```
|
||||
|
||||
Both must be clean before proceeding. `telegram-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `telegram`. It goes red if the `import './telegram.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/telegram` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 5. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
|
||||
|
||||
End-to-end message delivery against a real Telegram bot is verified manually once the service is running — see Next Steps and the pairing flow in Channel Info.
|
||||
`telegram-registration.test.ts` imports the real channel barrel and asserts the
|
||||
registry contains `telegram`. It goes red if the import line is deleted or drifts,
|
||||
if the barrel fails to evaluate, or if `@chat-adapter/telegram` isn't installed
|
||||
(the import throws) — so it also covers the dependency from step 4. End-to-end
|
||||
delivery against a real bot is verified manually once the service runs.
|
||||
|
||||
## Credentials
|
||||
|
||||
### Create Telegram Bot
|
||||
Bot creation in Telegram is human and interactive — no parser can click through
|
||||
BotFather. The adapter is installed and registered, but it can't receive a
|
||||
message until the bot exists. Tell the user:
|
||||
|
||||
1. Open Telegram and search for `@BotFather`
|
||||
2. Send `/newbot` and follow the prompts:
|
||||
- Bot name: Something friendly (e.g., "NanoClaw Assistant")
|
||||
- Bot username: Must end with "bot" (e.g., "nanoclaw_bot")
|
||||
3. Copy the bot token (looks like `123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11`)
|
||||
|
||||
**Important for group chats**: By default, Telegram bots only see @mentions and commands in groups. To let the bot see all messages:
|
||||
|
||||
1. Open `@BotFather` > `/mybots` > select your bot
|
||||
2. **Bot Settings** > **Group Privacy** > **Turn off**
|
||||
|
||||
### Configure environment
|
||||
|
||||
Add to `.env`:
|
||||
|
||||
```bash
|
||||
TELEGRAM_BOT_TOKEN=your-bot-token
|
||||
```nc:operator
|
||||
Create the Telegram bot:
|
||||
1. Open Telegram and message @BotFather — Telegram's official bot for creating bots.
|
||||
2. Send /newbot and follow the prompts: a friendly name, then a username that must end in "bot".
|
||||
3. Copy the bot token it gives you (looks like 123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11).
|
||||
4. Planning to use the bot in group chats? Send /mybots → your bot → Bot Settings → Group Privacy → Turn off, so the bot can see all messages and not just @mentions.
|
||||
```
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
Collect the bot token and store it — the bridge reads it from `.env` (set-if-absent,
|
||||
so a value you've already filled in is never overwritten) and syncs it to the
|
||||
container:
|
||||
|
||||
```nc:prompt bot_token secret validate:^[0-9]+:[A-Za-z0-9_-]{35,}$
|
||||
Paste the bot token from BotFather (looks like `123456:ABC-DEF...`).
|
||||
```
|
||||
```nc:env-set
|
||||
TELEGRAM_BOT_TOKEN={{bot_token}}
|
||||
```
|
||||
Confirm the token works and capture the bot's handle — `getMe` returns the bot
|
||||
account and fails here if the token is bad. You'll use the handle to open the
|
||||
right chat just before pairing:
|
||||
|
||||
```nc:run capture:bot_username effect:fetch
|
||||
curl -sf https://api.telegram.org/bot{{bot_token}}/getMe | jq -er '.result.username'
|
||||
```
|
||||
|
||||
## Restart
|
||||
|
||||
Restart the service so it loads the Telegram adapter and the token you just
|
||||
stored, and wait for its CLI socket. The adapter must be live and polling before
|
||||
pairing — it's the thing that observes the code you send:
|
||||
|
||||
```nc:run effect:restart
|
||||
bash setup/lib/restart.sh
|
||||
```
|
||||
|
||||
## Pair your chat
|
||||
|
||||
Telegram tokens carry no user binding, so the agent proves you own the chat with
|
||||
a one-time pairing handshake: it issues a 4-digit code, you send those exact 4
|
||||
digits to the bot from the chat you want to register, and the live adapter
|
||||
matches them. Open the bot first so you're on the right screen when the code
|
||||
appears. Tell the user:
|
||||
|
||||
```nc:operator
|
||||
Open @{{bot_username}} (https://t.me/{{bot_username}}) in Telegram now and keep it on screen — a 4-digit pairing code is about to appear in this terminal. When it does, send just those 4 digits to the bot as a message (in a group chat with Group Privacy on, prefix them with @{{bot_username}}). A wrong guess is rejected and a fresh code is issued automatically.
|
||||
```
|
||||
|
||||
Run the pairing handshake. It prints the code, streams "waiting…" and wrong-code
|
||||
feedback while it watches for your message, and resolves your chat address
|
||||
`telegram:<chatId>` plus your Telegram user id once the code matches:
|
||||
|
||||
```nc:run effect:step capture:platform_id=PLATFORM_ID,owner_handle=ADMIN_USER_ID
|
||||
pnpm exec tsx setup/index.ts --step pair-telegram -- --intent main
|
||||
```
|
||||
|
||||
`owner_handle` (your Telegram user id) and `platform_id` (`telegram:<chatId>`)
|
||||
are what the owner-wiring step needs. The greeting goes out over the same chat as
|
||||
soon as pairing completes.
|
||||
|
||||
## Next Steps
|
||||
|
||||
If you're in the middle of `/setup`, return to the setup flow now.
|
||||
|
||||
Otherwise, run `/manage-channels` to wire this channel to an agent group.
|
||||
If you're in the middle of `/setup`, return to the setup flow now. Otherwise wire
|
||||
this channel with `/init-first-agent` (or `/manage-channels`).
|
||||
|
||||
## Channel Info
|
||||
|
||||
- **type**: `telegram`
|
||||
- **terminology**: Telegram calls them "groups" and "chats." A "group" has multiple members; a "chat" is a 1:1 conversation with the bot.
|
||||
- **how-to-find-id**: Do NOT ask the user for a chat ID. Telegram registration uses pairing — run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent <main|wire-to:folder|new-agent:folder>`, show the user the 4-digit `CODE` from the `PAIR_TELEGRAM_ISSUED` block (follow the `REMINDER_TO_ASSISTANT` line in that block), and tell them to send just the 4 digits as a message from the chat they want to register (DM the bot for `main`, post in the group otherwise). In groups with Group Privacy ON, prefix with the bot handle: `@<botname> CODE`. Wrong guesses invalidate the code — if a `PAIR_TELEGRAM_ATTEMPT` block arrives with a mismatched `RECEIVED_CODE`, a `PAIR_TELEGRAM_NEW_CODE` block will follow automatically (up to 5 regenerations); show the new code. On `PAIR_TELEGRAM STATUS=failed ERROR=max-regenerations-exceeded`, ask the user if they want to try again and re-invoke the step — each invocation starts a fresh 5-attempt batch. Success emits `PAIR_TELEGRAM STATUS=success` with `PLATFORM_ID`, `IS_GROUP`, and `ADMIN_USER_ID`. The service must be running for this to work (the polling adapter is what observes the code).
|
||||
- **platform-id-format**: `telegram:{chatId}` (e.g. `telegram:123456789` for a DM, `telegram:-1001234567890` for a group — negative chat IDs are groups/channels).
|
||||
- **how-to-find-id**: Do NOT ask the user for a chat ID. Telegram registration uses pairing — run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent <main|wire-to:folder|new-agent:folder>`. The step prints a 4-digit code (and re-prints a fresh one if a wrong code invalidates it, up to 5 times); tell the user to send just those 4 digits from the chat they want to register (DM the bot for `main`, post in the group otherwise; with Group Privacy ON, prefix `@<botname> CODE`). Success emits a `PAIR_TELEGRAM` block with `STATUS=success`, `PLATFORM_ID`, `IS_GROUP`, `ADMIN_USER_ID` (the bare Telegram user id) and `PAIRED_USER_ID` (the `telegram:`-prefixed form). The service must be running — the polling adapter is what observes the code.
|
||||
- **supports-threads**: no
|
||||
- **typical-use**: Interactive chat — direct messages or small groups
|
||||
- **default-isolation**: Same agent group if you're the only participant across multiple chats. Separate agent group if different people are in different groups.
|
||||
|
||||
@@ -5,85 +5,110 @@ description: Add Webex channel integration via Chat SDK.
|
||||
|
||||
# Add Webex Channel
|
||||
|
||||
Adds Cisco Webex support via the Chat SDK bridge.
|
||||
Adds Cisco Webex support via the Chat SDK bridge. NanoClaw doesn't ship channels
|
||||
in trunk — this skill copies the Webex adapter in from the `channels` branch.
|
||||
|
||||
## Install
|
||||
The mechanical steps under **Apply** carry `nc:` directive fences: an agent
|
||||
reads the prose and applies them, and a parser can apply them deterministically
|
||||
from the same document. Every directive is idempotent, so the whole skill is
|
||||
safe to re-run; anything a parser can't apply falls back to the prose beside it.
|
||||
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the Webex adapter in from the `channels` branch.
|
||||
## Apply
|
||||
|
||||
### Pre-flight (idempotent)
|
||||
### 1. Copy the adapter and its registration test
|
||||
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
Fetch the `channels` branch and copy the Webex adapter and its registration test
|
||||
into `src/channels/` (overwrite — the branch is canonical):
|
||||
|
||||
- `src/channels/webex.ts` exists
|
||||
- `src/channels/webex-registration.test.ts` exists
|
||||
- `src/channels/index.ts` contains `import './webex.js';`
|
||||
- `@bitbasti/chat-adapter-webex` is listed in `package.json` dependencies
|
||||
|
||||
Otherwise continue. Every step below is safe to re-run.
|
||||
|
||||
### 1. Fetch the channels branch
|
||||
|
||||
```bash
|
||||
git fetch origin channels
|
||||
```nc:copy from-branch:channels
|
||||
src/channels/webex.ts
|
||||
src/channels/webex-registration.test.ts
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and its registration test
|
||||
### 2. Register the adapter
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/webex.ts > src/channels/webex.ts
|
||||
git show origin/channels:src/channels/webex-registration.test.ts > src/channels/webex-registration.test.ts
|
||||
```
|
||||
Append the self-registration import to the channel barrel (skipped if the line
|
||||
is already present). This one line is the skill's only reach-in into core:
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
```typescript
|
||||
```nc:append to:src/channels/index.ts
|
||||
import './webex.js';
|
||||
```
|
||||
|
||||
### 4. Install the adapter package (pinned)
|
||||
### 3. Install the adapter package
|
||||
|
||||
```bash
|
||||
pnpm install @bitbasti/chat-adapter-webex@0.1.0
|
||||
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`.
|
||||
The Webex adapter ships under the third-party `@bitbasti/*` namespace, not
|
||||
`@chat-adapter/*`, so it carries its own version line (`0.1.0`) rather than
|
||||
tracking the chat core version:
|
||||
|
||||
```nc:dep
|
||||
@bitbasti/chat-adapter-webex@0.1.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
### 4. Build and validate
|
||||
|
||||
```bash
|
||||
Build first: it guards the typed `createChatSdkBridge(...)` core call and proves
|
||||
the dependency is installed. Then run the one integration test.
|
||||
|
||||
```nc:run effect:build
|
||||
pnpm run build
|
||||
```
|
||||
```nc:run effect:test
|
||||
pnpm exec vitest run src/channels/webex-registration.test.ts
|
||||
```
|
||||
|
||||
Both must be clean before proceeding. `webex-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `webex`. It goes red if the `import './webex.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@bitbasti/chat-adapter-webex` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
|
||||
|
||||
End-to-end message delivery against a real Webex space is verified manually once the service is running — see Next Steps and the webhook setup above.
|
||||
`webex-registration.test.ts` imports the real channel barrel and asserts the
|
||||
registry contains `webex`. It goes red if the import line is deleted or drifts,
|
||||
if the barrel fails to evaluate, or if `@bitbasti/chat-adapter-webex` isn't
|
||||
installed (the import throws) — so it also covers the dependency from step 3.
|
||||
End-to-end delivery against a real Webex space is verified manually once the
|
||||
service runs — see the webhook setup below.
|
||||
|
||||
## Credentials
|
||||
|
||||
1. Go to [developer.webex.com](https://developer.webex.com/my-apps/new/bot) and create a new bot
|
||||
2. Copy the **Bot Access Token**
|
||||
Webex bot setup is human and interactive — these steps are prose, not directives
|
||||
(no parser can click through the Webex Developer Portal). A recipe rebuild
|
||||
produces a compiling, registered adapter that cannot receive a message until
|
||||
they're done.
|
||||
|
||||
### Create the Webex bot
|
||||
|
||||
1. Go to [developer.webex.com](https://developer.webex.com/my-apps/new/bot) and create a new bot.
|
||||
2. Copy the **Bot Access Token**.
|
||||
3. Set up a webhook:
|
||||
- Use the Webex API or Developer Portal to create a webhook pointing to `https://your-domain/webhook/webex`
|
||||
- Set a webhook secret for signature verification
|
||||
- Use the Webex API or Developer Portal to create a webhook pointing to `https://your-domain/webhook/webex`.
|
||||
- Set a webhook secret for signature verification.
|
||||
|
||||
### Configure environment
|
||||
### Store the credentials
|
||||
|
||||
Add to `.env`:
|
||||
Capture the two values, then write them. `prompt` only *asks* and binds the
|
||||
answer to a name; a separate directive consumes it — so the same prompts could
|
||||
feed `ncl` or the OneCLI vault instead of `.env` by swapping only the consumer.
|
||||
Here they go to `.env` (set-if-absent — a value you've already filled in is
|
||||
never overwritten):
|
||||
|
||||
```bash
|
||||
WEBEX_BOT_TOKEN=your-bot-token
|
||||
WEBEX_WEBHOOK_SECRET=your-webhook-secret
|
||||
```nc:prompt bot_token secret
|
||||
Paste the Bot Access Token — from the Webex bot you created.
|
||||
```
|
||||
```nc:prompt webhook_secret secret
|
||||
Paste the webhook secret you set for signature verification.
|
||||
```
|
||||
```nc:env-set
|
||||
WEBEX_BOT_TOKEN={{bot_token}}
|
||||
WEBEX_WEBHOOK_SECRET={{webhook_secret}}
|
||||
```
|
||||
### Webhook server
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
The Chat SDK bridge automatically starts a shared webhook server on port 3000
|
||||
(`WEBHOOK_PORT` to change it), handling `/webhook/webex`. This port must be
|
||||
publicly reachable for Webex to deliver events. Running locally, expose it with
|
||||
ngrok (`ngrok http 3000`), a Cloudflare Tunnel, or a reverse proxy on a VPS —
|
||||
the resulting public URL is the base for the webhook URL above.
|
||||
|
||||
## Next Steps
|
||||
|
||||
If you're in the middle of `/setup`, return to the setup flow now.
|
||||
|
||||
Otherwise, run `/manage-channels` to wire this channel to an agent group.
|
||||
If you're in the middle of `/setup`, return to the setup flow now. Otherwise run
|
||||
`/manage-channels` to wire this channel to an agent group.
|
||||
|
||||
## Channel Info
|
||||
|
||||
|
||||
@@ -36,16 +36,12 @@ pnpm uninstall wechat-ilink-client
|
||||
rm -rf data/wechat
|
||||
```
|
||||
|
||||
## 5. Remove DB wiring
|
||||
The channel's messaging groups, wirings, and conversation history are **left
|
||||
intact** — you created those at runtime (wiring + use), not this skill's install,
|
||||
so removal doesn't touch them. To purge them deliberately, delete them yourself
|
||||
with `ncl messaging-groups delete <id>`.
|
||||
|
||||
```sql
|
||||
-- Remove any sessions first (foreign key)
|
||||
DELETE FROM sessions WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type = 'wechat');
|
||||
DELETE FROM messaging_group_agents WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type = 'wechat');
|
||||
DELETE FROM messaging_groups WHERE channel_type = 'wechat';
|
||||
```
|
||||
|
||||
## 6. Rebuild and restart
|
||||
## 5. Rebuild and restart
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
|
||||
@@ -85,7 +85,6 @@ Add to `.env`:
|
||||
WECHAT_ENABLED=true
|
||||
```
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
|
||||
### 2. Start the service and scan the QR
|
||||
|
||||
|
||||
@@ -6,62 +6,71 @@ description: Add WhatsApp Business Cloud API channel via Chat SDK. Official Meta
|
||||
# Add WhatsApp Cloud API Channel
|
||||
|
||||
Connect NanoClaw to WhatsApp via the official Meta WhatsApp Business Cloud API.
|
||||
NanoClaw doesn't ship channels in trunk — this skill copies the WhatsApp Cloud
|
||||
adapter in from the `channels` branch.
|
||||
|
||||
## Install
|
||||
The mechanical steps under **Apply** carry `nc:` directive fences: an agent reads
|
||||
the prose and applies them, and a parser can apply them deterministically from
|
||||
the same document. Every directive is idempotent, so the whole skill is safe to
|
||||
re-run; anything a parser can't apply falls back to the prose beside it.
|
||||
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the WhatsApp Cloud adapter in from the `channels` branch.
|
||||
## Apply
|
||||
|
||||
### Pre-flight (idempotent)
|
||||
### 1. Copy the adapter
|
||||
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
Fetch the `channels` branch and copy the WhatsApp Cloud adapter into
|
||||
`src/channels/` (overwrite — the branch is canonical):
|
||||
|
||||
- `src/channels/whatsapp-cloud.ts` exists
|
||||
- `src/channels/whatsapp-cloud-registration.test.ts` exists
|
||||
- `src/channels/index.ts` contains `import './whatsapp-cloud.js';`
|
||||
- `@chat-adapter/whatsapp` is listed in `package.json` dependencies
|
||||
|
||||
Otherwise continue. Every step below is safe to re-run.
|
||||
|
||||
### 1. Fetch the channels branch
|
||||
|
||||
```bash
|
||||
git fetch origin channels
|
||||
```nc:copy from-branch:channels
|
||||
src/channels/whatsapp-cloud.ts
|
||||
src/channels/whatsapp-cloud-registration.test.ts
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and its registration test
|
||||
### 2. Register the adapter
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/whatsapp-cloud.ts > src/channels/whatsapp-cloud.ts
|
||||
git show origin/channels:src/channels/whatsapp-cloud-registration.test.ts > src/channels/whatsapp-cloud-registration.test.ts
|
||||
```
|
||||
Append the self-registration import to the channel barrel (skipped if the line
|
||||
is already present). This one line is the skill's only reach-in into core:
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
```typescript
|
||||
```nc:append to:src/channels/index.ts
|
||||
import './whatsapp-cloud.js';
|
||||
```
|
||||
|
||||
### 4. Install the adapter package (pinned)
|
||||
### 3. Install the adapter package
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/whatsapp@4.27.0
|
||||
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
|
||||
|
||||
```nc:dep
|
||||
@chat-adapter/whatsapp@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
### 4. Build and validate
|
||||
|
||||
```bash
|
||||
Build guards the typed `createChatSdkBridge(...)` core call and proves the
|
||||
dependency is installed — the import throws at evaluation if `@chat-adapter/whatsapp`
|
||||
is missing or the barrel drifts:
|
||||
|
||||
```nc:run effect:build
|
||||
pnpm run build
|
||||
```
|
||||
```nc:run effect:test
|
||||
pnpm exec vitest run src/channels/whatsapp-cloud-registration.test.ts
|
||||
```
|
||||
|
||||
Both must be clean before proceeding. `whatsapp-cloud-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `whatsapp-cloud`. It goes red if the `import './whatsapp-cloud.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/whatsapp` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
|
||||
`whatsapp-cloud-registration.test.ts` imports the real channel barrel and asserts
|
||||
the registry contains `whatsapp-cloud` — it goes red if the import line is deleted
|
||||
or drifts, if the barrel fails to evaluate, or if `@chat-adapter/whatsapp` isn't
|
||||
installed (the import throws), so it also covers the dependency from step 3.
|
||||
|
||||
End-to-end message delivery against a real WhatsApp Business number is verified manually once the service is running — see Next Steps and the webhook setup above.
|
||||
End-to-end message delivery against a real WhatsApp Business number is verified
|
||||
manually once the service is running — see Next Steps and the webhook setup
|
||||
below.
|
||||
|
||||
## Credentials
|
||||
|
||||
Meta app setup is human and interactive — these steps are prose, not directives
|
||||
(no parser can click through the Meta dashboard). A recipe rebuild produces a
|
||||
compiling, registered adapter that cannot receive a message until they're done.
|
||||
|
||||
1. Go to [Meta for Developers](https://developers.facebook.com/apps/) and create an app (type: Business).
|
||||
2. Add the **WhatsApp** product.
|
||||
3. Go to **WhatsApp** > **API Setup**:
|
||||
@@ -73,18 +82,40 @@ End-to-end message delivery against a real WhatsApp Business number is verified
|
||||
- Subscribe to webhook fields: `messages`.
|
||||
5. Copy the **App Secret** from **Settings** > **Basic**.
|
||||
|
||||
### Configure environment
|
||||
### Store the credentials
|
||||
|
||||
Add to `.env`:
|
||||
Capture the four values, then write them. `prompt` only *asks* and binds the
|
||||
answer to a name; a separate directive consumes it — so the same prompts could
|
||||
feed `ncl` or the OneCLI vault instead of `.env` by swapping only the consumer.
|
||||
Here they go to `.env` (set-if-absent — a value you've already filled in is
|
||||
never overwritten):
|
||||
|
||||
```bash
|
||||
WHATSAPP_ACCESS_TOKEN=your-system-user-access-token
|
||||
WHATSAPP_PHONE_NUMBER_ID=your-phone-number-id
|
||||
WHATSAPP_APP_SECRET=your-app-secret
|
||||
WHATSAPP_VERIFY_TOKEN=your-verify-token
|
||||
```nc:prompt access_token secret
|
||||
Paste the System User access token — WhatsApp > API Setup, with `whatsapp_business_messaging` permission.
|
||||
```
|
||||
```nc:prompt phone_number_id
|
||||
Paste the Phone Number ID — WhatsApp > API Setup (not the phone number itself).
|
||||
```
|
||||
```nc:prompt app_secret secret
|
||||
Paste the App Secret — Settings > Basic.
|
||||
```
|
||||
```nc:prompt verify_token secret
|
||||
Paste the Verify Token — the random string you set under WhatsApp > Configuration.
|
||||
```
|
||||
```nc:env-set
|
||||
WHATSAPP_ACCESS_TOKEN={{access_token}}
|
||||
WHATSAPP_PHONE_NUMBER_ID={{phone_number_id}}
|
||||
WHATSAPP_APP_SECRET={{app_secret}}
|
||||
WHATSAPP_VERIFY_TOKEN={{verify_token}}
|
||||
```
|
||||
### Webhook server
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
The Chat SDK bridge automatically starts a shared webhook server on port 3000
|
||||
(`WEBHOOK_PORT` to change it), handling `/webhook/whatsapp`. This port must be
|
||||
publicly reachable for Meta to deliver events. Running locally, expose it with
|
||||
ngrok (`ngrok http 3000`), a Cloudflare Tunnel, or a reverse proxy on a VPS —
|
||||
the resulting public URL is the base for the webhook URL set under WhatsApp >
|
||||
Configuration above.
|
||||
|
||||
## Next Steps
|
||||
|
||||
@@ -100,3 +131,5 @@ Otherwise, run `/manage-channels` to wire this channel to an agent group.
|
||||
- **supports-threads**: no
|
||||
- **typical-use**: Interactive 1:1 chat -- direct messages only
|
||||
- **default-isolation**: Same agent group if you're the only person messaging the bot. Each additional person who messages gets their own conversation automatically, but they share the agent's workspace and memory -- use a separate agent group if you need information isolation between different contacts.
|
||||
</content>
|
||||
</invoke>
|
||||
|
||||
@@ -5,216 +5,202 @@ description: Add WhatsApp channel via native Baileys adapter. Direct connection
|
||||
|
||||
# Add WhatsApp Channel
|
||||
|
||||
Adds WhatsApp support via the native Baileys adapter (no Chat SDK bridge).
|
||||
Adds WhatsApp support via the native Baileys adapter — a direct WhatsApp Web
|
||||
connection, no Chat SDK bridge. NanoClaw doesn't ship channels in trunk — this
|
||||
skill copies the WhatsApp adapter in from the `channels` branch.
|
||||
|
||||
## Install
|
||||
The mechanical steps under **Apply** carry `nc:` directive fences: an agent
|
||||
reads the prose and applies them, and a parser can apply them deterministically
|
||||
from the same document. Every directive is idempotent, so the whole skill is
|
||||
safe to re-run; anything a parser can't apply falls back to the prose beside it.
|
||||
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the native WhatsApp (Baileys) adapter and its `whatsapp-auth` setup step in from the `channels` branch. No Chat SDK bridge.
|
||||
## Apply
|
||||
|
||||
### Pre-flight (idempotent)
|
||||
### 1. Copy the adapter and its registration test
|
||||
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
Fetch the `channels` branch and copy the WhatsApp adapter and its registration
|
||||
test into `src/channels/` (overwrite — the branch is canonical). The
|
||||
`whatsapp-auth` setup step is maintained in trunk, so it is not copied here:
|
||||
|
||||
- `src/channels/whatsapp.ts` exists
|
||||
- `src/channels/whatsapp-registration.test.ts` exists
|
||||
- `src/channels/whatsapp.test.ts` exists
|
||||
- `src/channels/index.ts` contains `import './whatsapp.js';`
|
||||
- `setup/whatsapp-auth.ts` and `setup/groups.ts` both exist
|
||||
- `setup/index.ts`'s `STEPS` map contains both `'whatsapp-auth':` and `groups:`
|
||||
- `@whiskeysockets/baileys`, `qrcode`, `pino` are listed in `package.json` dependencies
|
||||
- `.claude/skills/add-whatsapp/scripts/wa-qr-browser.ts` exists (ships with this skill)
|
||||
|
||||
Otherwise continue. Every step below is safe to re-run.
|
||||
|
||||
### 1. Fetch the channels branch
|
||||
|
||||
```bash
|
||||
git fetch origin channels
|
||||
```nc:copy from-branch:channels
|
||||
src/channels/whatsapp.ts
|
||||
src/channels/whatsapp-registration.test.ts
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and setup steps
|
||||
### 2. Register the adapter
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/whatsapp.ts > src/channels/whatsapp.ts
|
||||
git show origin/channels:src/channels/whatsapp-registration.test.ts > src/channels/whatsapp-registration.test.ts
|
||||
git show origin/channels:src/channels/whatsapp.test.ts > src/channels/whatsapp.test.ts
|
||||
git show origin/channels:setup/whatsapp-auth.ts > setup/whatsapp-auth.ts
|
||||
git show origin/channels:setup/groups.ts > setup/groups.ts
|
||||
```
|
||||
Append the self-registration import to the channel barrel (skipped if the line
|
||||
is already present). This one line is the skill's only reach-in into core:
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if already present):
|
||||
|
||||
```typescript
|
||||
```nc:append to:src/channels/index.ts
|
||||
import './whatsapp.js';
|
||||
```
|
||||
|
||||
### 4. Register the setup steps
|
||||
### 3. Install the adapter packages
|
||||
|
||||
In `setup/index.ts`, add these entries to the `STEPS` map (skip lines already present):
|
||||
Pinned to exact versions — the supply-chain policy rejects ranges and `latest`.
|
||||
Baileys is the WhatsApp Web client; `qrcode` renders the device-link QR in the
|
||||
terminal; `pino` is Baileys' logger:
|
||||
|
||||
```typescript
|
||||
groups: () => import('./groups.js'),
|
||||
'whatsapp-auth': () => import('./whatsapp-auth.js'),
|
||||
```nc:dep
|
||||
@whiskeysockets/baileys@7.0.0-rc.9
|
||||
qrcode@1.5.4
|
||||
@types/qrcode@1.5.6
|
||||
pino@9.6.0
|
||||
```
|
||||
|
||||
### 5. Install the adapter packages (pinned)
|
||||
### 4. Build and validate
|
||||
|
||||
```bash
|
||||
pnpm install @whiskeysockets/baileys@7.0.0-rc.9 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
|
||||
```
|
||||
Build first: it typechecks the adapter against core and proves the dependencies
|
||||
are installed. Then run the one integration test.
|
||||
|
||||
### 6. Build and validate
|
||||
|
||||
```bash
|
||||
```nc:run effect:build
|
||||
pnpm run build
|
||||
```
|
||||
```nc:run effect:test
|
||||
pnpm exec vitest run src/channels/whatsapp-registration.test.ts
|
||||
```
|
||||
|
||||
Both must be clean before proceeding. `whatsapp-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `whatsapp`. It goes red if the `import './whatsapp.js';` line is deleted or drifts, if the barrel fails to evaluate (so the channel genuinely would not register), or if `@whiskeysockets/baileys` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 5.
|
||||
`whatsapp-registration.test.ts` imports the real channel barrel and asserts the
|
||||
registry contains `whatsapp`. It goes red if the `import './whatsapp.js';` line
|
||||
is deleted or drifts, if the barrel fails to evaluate, or if
|
||||
`@whiskeysockets/baileys` isn't installed (the import throws) — so it also covers
|
||||
the dependency from step 3. End-to-end delivery against a real WhatsApp number is
|
||||
verified manually once the service runs.
|
||||
|
||||
End-to-end message delivery against a real WhatsApp number is verified manually once the service is running — see Credentials, Wiring, and Troubleshooting.
|
||||
## Authenticate
|
||||
|
||||
## Credentials
|
||||
WhatsApp uses linked-device authentication — no API key, just a one-time pairing
|
||||
from your phone. The adapter is installed and registered, but its factory returns
|
||||
`null` (and the channel stays dark) until `store/auth/creds.json` exists.
|
||||
|
||||
WhatsApp uses linked-device authentication — no API key, just a one-time pairing from your phone.
|
||||
Pick how to link the device. `qr` shows a rotating QR you scan with your phone's
|
||||
camera; `pairing-code` shows an 8-character code you type into WhatsApp (no camera
|
||||
needed, but it needs your phone number):
|
||||
|
||||
### Check current state
|
||||
|
||||
Check if WhatsApp is already authenticated. If `store/auth/creds.json` exists, skip to "Shared vs dedicated number".
|
||||
|
||||
```bash
|
||||
test -f store/auth/creds.json && echo "WhatsApp auth exists" || echo "No WhatsApp auth"
|
||||
```nc:prompt auth_method validate:^(qr|pairing-code)$
|
||||
How do you want to link WhatsApp? Type `qr` to scan a QR code in this terminal, or `pairing-code` to enter a code on your phone (no camera needed).
|
||||
```
|
||||
|
||||
### Detect environment
|
||||
The pairing-code method needs the number you're linking, the way WhatsApp expects
|
||||
it — digits only, country code first, no `+`, spaces, or dashes (the QR method
|
||||
skips this entirely):
|
||||
|
||||
Check whether the environment is headless (no display server):
|
||||
|
||||
```bash
|
||||
[[ -z "$DISPLAY" && -z "$WAYLAND_DISPLAY" && "$OSTYPE" != darwin* ]] && echo "IS_HEADLESS=true" || echo "IS_HEADLESS=false"
|
||||
```nc:prompt phone validate:^\d{8,15}$ when:auth_method=pairing-code
|
||||
Your WhatsApp phone number — digits only, country code first (e.g. 14155551234 for +1 415-555-1234).
|
||||
```
|
||||
|
||||
### Ask the user
|
||||
Point the user at the right screen before the code appears. For the QR method,
|
||||
tell the user:
|
||||
|
||||
Use `AskUserQuestion` to collect configuration. **Adapt auth options based on environment:**
|
||||
|
||||
If IS_HEADLESS=true AND not WSL → AskUserQuestion: How do you want to authenticate WhatsApp?
|
||||
- **Pairing code** (Recommended) - Enter a numeric code on your phone (no camera needed, requires phone number)
|
||||
- **QR code in terminal** - Displays QR code in the terminal (can be too small on some displays)
|
||||
|
||||
Otherwise (macOS, desktop Linux, or WSL) → AskUserQuestion: How do you want to authenticate WhatsApp?
|
||||
- **QR code in browser** (Recommended) - Runs a small local HTTP server that renders the rotating QR as a PNG and auto-opens your default browser
|
||||
- **Pairing code** - Enter a numeric code on your phone (no camera needed, requires phone number)
|
||||
- **QR code in terminal** - Displays QR code in the terminal (can be too small on some displays)
|
||||
|
||||
If they chose pairing code:
|
||||
|
||||
AskUserQuestion: What is your phone number? (Digits only — country code followed by your 10-digit number, no + prefix, spaces, or dashes. Example: 14155551234 where 1 is the US country code and 4155551234 is the phone number.)
|
||||
|
||||
### Clean previous auth state (if re-authenticating)
|
||||
|
||||
```bash
|
||||
rm -rf store/auth/
|
||||
```nc:operator when:auth_method=qr
|
||||
Link WhatsApp by QR:
|
||||
1. On your phone, open WhatsApp → Settings → Linked Devices → Link a Device.
|
||||
2. A QR code will appear in this terminal below and refresh every ~20 seconds. Point your phone's camera at it to scan.
|
||||
```
|
||||
|
||||
### Run WhatsApp authentication
|
||||
For the pairing-code method, tell the user:
|
||||
|
||||
For QR code in browser (recommended):
|
||||
|
||||
```bash
|
||||
pnpm exec tsx .claude/skills/add-whatsapp/scripts/wa-qr-browser.ts
|
||||
```nc:operator when:auth_method=pairing-code
|
||||
Link WhatsApp by pairing code:
|
||||
1. On your phone, open WhatsApp → Settings → Linked Devices → Link a Device → tap "Link with phone number instead".
|
||||
2. An 8-character code will appear in this terminal below. Enter it on your phone immediately — it expires in about 60 seconds.
|
||||
```
|
||||
|
||||
(Bash timeout: 150000ms)
|
||||
Now run the linked-device handshake. It streams the live QR (or the pairing-code
|
||||
card) to this terminal and, on success, reports the linked WhatsApp number. Run
|
||||
the command for the method chosen above — `qr` or `pairing-code`:
|
||||
|
||||
The wrapper spawns `setup/index.ts --step whatsapp-auth -- --method qr`, parses each rotating QR from its `WHATSAPP_AUTH_QR` status blocks, and serves the current QR as a PNG on a local HTTP server (default port `8765`, falls back to a free port). Flags: `--clean` (wipes `store/auth/` before spawning) and `--port N`.
|
||||
|
||||
Tell the user:
|
||||
|
||||
> A browser window will open with a QR code.
|
||||
>
|
||||
> 1. Open WhatsApp > **Settings** > **Linked Devices** > **Link a Device**
|
||||
> 2. Scan the QR code in the browser
|
||||
> 3. The page will show "Authenticated!" when done
|
||||
|
||||
For QR code in terminal:
|
||||
|
||||
```bash
|
||||
```nc:run effect:step capture:bot_phone=PHONE when:auth_method=qr
|
||||
pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method qr
|
||||
```
|
||||
|
||||
(Bash timeout: 150000ms)
|
||||
|
||||
The setup driver emits each rotating QR as a `WHATSAPP_AUTH_QR` status block; when run directly (not through `setup:auto`) the raw QR string is printed and your terminal must render it as ASCII. If your terminal can't render it readably, use the browser method above.
|
||||
|
||||
Tell the user:
|
||||
|
||||
> 1. Open WhatsApp > **Settings** > **Linked Devices** > **Link a Device**
|
||||
> 2. Scan the QR code displayed in the terminal
|
||||
|
||||
For pairing code:
|
||||
|
||||
Tell the user to have WhatsApp open on **Settings > Linked Devices > Link a Device**, ready to tap **"Link with phone number instead"** — the code expires in ~60 seconds and must be entered immediately.
|
||||
|
||||
Run the auth process in the background and poll `store/pairing-code.txt` for the code:
|
||||
|
||||
```bash
|
||||
rm -f store/pairing-code.txt && pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method pairing-code --phone <their-phone-number> > /tmp/wa-auth.log 2>&1 &
|
||||
```nc:run effect:step capture:bot_phone=PHONE when:auth_method=pairing-code
|
||||
pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method pairing-code --phone {{phone}}
|
||||
```
|
||||
|
||||
Then immediately poll for the code (do NOT wait for the background command to finish):
|
||||
If the handshake fails (`logged_out` or a timeout), the code expired — clear
|
||||
`store/auth/` and run the step again for a fresh one. See Troubleshooting.
|
||||
|
||||
```bash
|
||||
for i in $(seq 1 20); do [ -f store/pairing-code.txt ] && cat store/pairing-code.txt && break; sleep 1; done
|
||||
A successful link reports the number back as `bot_phone`. If it came back empty,
|
||||
the device never confirmed (an expired QR or pairing code), so don't restart or
|
||||
wire against a blank number — clear `store/auth/` and re-run the link step first:
|
||||
|
||||
```nc:run effect:check
|
||||
[ -n "{{bot_phone}}" ]
|
||||
```
|
||||
|
||||
Display the code to the user the moment it appears. Tell them:
|
||||
## Restart
|
||||
|
||||
> **Enter this code now** — it expires in ~60 seconds.
|
||||
>
|
||||
> 1. Open WhatsApp > **Settings** > **Linked Devices** > **Link a Device**
|
||||
> 2. Tap **Link with phone number instead**
|
||||
> 3. Enter the code immediately
|
||||
Restart the service so it loads the WhatsApp adapter and picks up the
|
||||
credentials you just linked, and wait for its CLI socket before resolving:
|
||||
|
||||
After the user enters the code, poll for authentication to complete:
|
||||
|
||||
```bash
|
||||
for i in $(seq 1 60); do grep -q 'STATUS: authenticated' /tmp/wa-auth.log 2>/dev/null && echo "authenticated" && break; grep -q 'STATUS: failed' /tmp/wa-auth.log 2>/dev/null && echo "failed" && break; sleep 2; done
|
||||
```nc:run effect:restart
|
||||
bash setup/lib/restart.sh
|
||||
```
|
||||
|
||||
**If failed:** logged_out → delete `store/auth/` and re-run. timeout → ask user, offer retry.
|
||||
## Resolve your DM channel
|
||||
|
||||
### Verify authentication succeeded
|
||||
The agent talks to you in your WhatsApp chat. Tell the user which number that
|
||||
chat happens on — usually the same one they just linked:
|
||||
|
||||
```bash
|
||||
test -f store/auth/creds.json && echo "Authentication successful" || echo "Authentication failed"
|
||||
```nc:operator
|
||||
You're linked to WhatsApp as +{{bot_phone}}.
|
||||
|
||||
- "shared" — you'll message the assistant from this same (personal) WhatsApp number. Replies land in your own "You" / self-chat.
|
||||
- "dedicated" — the assistant has its own separate phone/SIM, and you'll message it from a different number.
|
||||
```
|
||||
|
||||
### Shared vs dedicated number
|
||||
Pick which it is. Most people use `shared`:
|
||||
|
||||
AskUserQuestion: Is this a shared phone number (personal WhatsApp) or a dedicated number?
|
||||
- **Shared number** — your personal WhatsApp (bot prefixes messages with its name)
|
||||
- **Dedicated number** — a separate phone/SIM for the assistant
|
||||
```nc:prompt number_kind validate:^(shared|dedicated)$
|
||||
Is the assistant on a `shared` number (your personal WhatsApp) or a `dedicated` number (a separate line for the assistant)?
|
||||
```
|
||||
|
||||
If dedicated, add to `.env`:
|
||||
For a dedicated number, collect the number you'll actually chat from (skipped
|
||||
entirely for a shared number):
|
||||
|
||||
```bash
|
||||
```nc:prompt chat_phone validate:^\d{8,15}$ when:number_kind=dedicated
|
||||
The phone number you'll message the assistant from — digits only, country code first (e.g. 14155551234).
|
||||
```
|
||||
|
||||
A dedicated number means the assistant owns its own line, so outbound replies
|
||||
shouldn't be prefixed with its name. Record that (skipped for a shared number):
|
||||
|
||||
```nc:env-set when:number_kind=dedicated
|
||||
ASSISTANT_HAS_OWN_NUMBER=true
|
||||
```
|
||||
Resolve the conversation address as the WhatsApp JID for the number you chat
|
||||
from — the linked number for a shared account, or the dedicated number you just
|
||||
gave. Run the one matching the choice above:
|
||||
|
||||
```nc:run capture:platform_id effect:fetch when:number_kind=shared
|
||||
echo "{{bot_phone}}@s.whatsapp.net"
|
||||
```
|
||||
```nc:run capture:platform_id effect:fetch when:number_kind=dedicated
|
||||
echo "{{chat_phone}}@s.whatsapp.net"
|
||||
```
|
||||
|
||||
For WhatsApp, your owner handle is that same JID:
|
||||
|
||||
```nc:run capture:owner_handle effect:fetch
|
||||
echo "{{platform_id}}"
|
||||
```
|
||||
|
||||
`owner_handle` and `platform_id` are what the owner-wiring step needs. The
|
||||
greeting goes out over your WhatsApp chat as soon as the service reconnects with
|
||||
the linked credentials.
|
||||
|
||||
## Next Steps
|
||||
|
||||
If you're in the middle of `/setup`, return to the setup flow now.
|
||||
|
||||
Otherwise, run `/manage-channels` to wire this channel to an agent group.
|
||||
If you're in the middle of `/setup`, return to the setup flow now. Otherwise wire
|
||||
this channel with `/init-first-agent` (or `/manage-channels`).
|
||||
|
||||
## Channel Info
|
||||
|
||||
- **type**: `whatsapp`
|
||||
- **terminology**: WhatsApp calls them "groups" and "chats." A "chat" is a 1:1 DM; a "group" has multiple members.
|
||||
- **how-to-find-id**: DMs use `<phone>@s.whatsapp.net` (e.g. `14155551234@s.whatsapp.net`). Groups use `<id>@g.us`. To find your number: `node -e "const c=JSON.parse(require('fs').readFileSync('store/auth/creds.json','utf-8'));console.log(c.me?.id?.split(':')[0]+'@s.whatsapp.net')"`. Groups are auto-discovered — check `pnpm exec tsx scripts/q.ts data/v2.db "SELECT platform_id, name FROM messaging_groups WHERE channel_type='whatsapp' AND is_group=1"`.
|
||||
- **platform-id-format**: DMs use `<phone>@s.whatsapp.net` (e.g. `14155551234@s.whatsapp.net`). Groups use `<id>@g.us`. Native adapter — the JID is the platform ID as-is, no `whatsapp:` prefix.
|
||||
- **how-to-find-id**: To find your linked number after auth: `node -e "const c=JSON.parse(require('fs').readFileSync('store/auth/creds.json','utf-8'));console.log(c.me?.id?.split(':')[0].split('@')[0]+'@s.whatsapp.net')"`. Groups are auto-discovered — check `pnpm exec tsx scripts/q.ts data/v2.db "SELECT platform_id, name FROM messaging_groups WHERE channel_type='whatsapp' AND is_group=1"`.
|
||||
- **supports-threads**: no
|
||||
- **typical-use**: Interactive chat — direct messages or small groups
|
||||
- **default-isolation**: Same agent group if you're the only participant across multiple chats. Separate agent group if different people are in different groups.
|
||||
@@ -228,18 +214,74 @@ Otherwise, run `/manage-channels` to wire this channel to an agent group.
|
||||
- Typing indicators — composing presence updates
|
||||
- Credential requests — text fallback (WhatsApp has no modal support)
|
||||
|
||||
Not supported (WhatsApp linked device limitation): edit messages, delete messages.
|
||||
Not supported (WhatsApp linked-device limitation): edit messages, delete messages.
|
||||
|
||||
## Alternatives
|
||||
|
||||
### QR code in a browser
|
||||
|
||||
Besides the in-terminal QR and the pairing code the Apply flow uses, this skill
|
||||
ships a helper that renders the rotating QR as a PNG in your default browser —
|
||||
handy when the terminal QR is too small to scan reliably. It spawns the same
|
||||
`whatsapp-auth` step, parses each rotating QR from its `WHATSAPP_AUTH_QR` status
|
||||
blocks, and serves the current one on a local HTTP server (default port `8765`,
|
||||
falls back to a free port):
|
||||
|
||||
```bash
|
||||
pnpm exec tsx .claude/skills/add-whatsapp/scripts/wa-qr-browser.ts
|
||||
```
|
||||
|
||||
Flags: `--clean` wipes `store/auth/` before spawning, `--port N` pins the port.
|
||||
|
||||
A browser window opens with a QR code. On your phone, open WhatsApp →
|
||||
**Settings** → **Linked Devices** → **Link a Device**, scan the QR, and the page
|
||||
shows "Authenticated!" when done.
|
||||
|
||||
### Headless environments
|
||||
|
||||
On a headless host (no display server — no `$DISPLAY`/`$WAYLAND_DISPLAY`, not
|
||||
macOS), the browser method can't open a window. Detect it and fall back to the
|
||||
pairing-code method (no camera needed):
|
||||
|
||||
```bash
|
||||
[[ -z "$DISPLAY" && -z "$WAYLAND_DISPLAY" && "$OSTYPE" != darwin* ]] && echo "IS_HEADLESS=true" || echo "IS_HEADLESS=false"
|
||||
```
|
||||
|
||||
## Optional configuration
|
||||
|
||||
If the assistant runs on a dedicated number (its own phone/SIM, not your personal
|
||||
WhatsApp), tell the adapter so it doesn't prefix outbound replies with its name:
|
||||
|
||||
```bash
|
||||
ASSISTANT_HAS_OWN_NUMBER=true
|
||||
```
|
||||
|
||||
The Apply flow sets this for you when you pick a `dedicated` number; this is the
|
||||
key it writes, for reference. A shared (personal) number leaves it unset.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### QR code expired
|
||||
### QR code or pairing code expired
|
||||
|
||||
QR codes expire after ~60 seconds. The browser wrapper rotates automatically as long as it's running; if it was stopped, re-run with `--clean`:
|
||||
Codes expire after ~60 seconds. The QR rotates automatically while the auth step
|
||||
is running; if the step exited, clear the auth state and re-run it:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx .claude/skills/add-whatsapp/scripts/wa-qr-browser.ts --clean
|
||||
rm -rf store/auth/ && pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method qr
|
||||
```
|
||||
|
||||
For pairing code, ensure digits only (no `+`), the phone has internet, and
|
||||
WhatsApp is updated:
|
||||
|
||||
```bash
|
||||
rm -rf store/auth/ && pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method pairing-code --phone <phone>
|
||||
```
|
||||
|
||||
WhatsApp's pairing-code flow occasionally rejects valid codes with "Couldn't link
|
||||
device." This is a server-side rejection unrelated to the code itself. If you hit
|
||||
it more than once, switch to the QR method — it has a noticeably higher success
|
||||
rate.
|
||||
|
||||
### Pairing code not working
|
||||
|
||||
Codes expire in ~60 seconds. Delete auth and retry:
|
||||
@@ -250,7 +292,11 @@ rm -rf store/auth/ && pnpm exec tsx setup/index.ts --step whatsapp-auth -- --met
|
||||
|
||||
Ensure: digits only (no `+`), phone has internet, WhatsApp is updated.
|
||||
|
||||
WhatsApp's pairing-code flow occasionally rejects valid codes with "Couldn't link device — An error happened. Please try again." This is a server-side rejection unrelated to the code itself; we've seen it happen twice in a row on fresh dedicated numbers. If you hit it more than once, switch to QR-browser auth — it has a noticeably higher success rate:
|
||||
WhatsApp's pairing-code flow occasionally rejects valid codes with "Couldn't link
|
||||
device — An error happened. Please try again." This is a server-side rejection
|
||||
unrelated to the code itself; we've seen it happen twice in a row on fresh
|
||||
dedicated numbers. If you hit it more than once, switch to QR-browser auth — it
|
||||
has a noticeably higher success rate:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx .claude/skills/add-whatsapp/scripts/wa-qr-browser.ts --clean
|
||||
@@ -258,9 +304,8 @@ pnpm exec tsx .claude/skills/add-whatsapp/scripts/wa-qr-browser.ts --clean
|
||||
|
||||
### "waiting for this message" on reactions
|
||||
|
||||
Signal sessions corrupted from rapid restarts. Clear sessions.
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
WhatsApp sessions corrupted from rapid restarts. Clear sessions, then restart the
|
||||
service. Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
source setup/lib/install-slug.sh
|
||||
@@ -278,4 +323,5 @@ systemctl --user start $(systemd_unit)
|
||||
|
||||
### "conflict" disconnection
|
||||
|
||||
Two instances connected with same credentials. Ensure only one NanoClaw process is running.
|
||||
Two instances connected with the same credentials. Ensure only one NanoClaw
|
||||
process is running.
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
---
|
||||
name: learn
|
||||
description: "Distill a reusable skill from anything — a directory, a URL, pasted notes, or what you just did together — or refine an existing skill with new learnings. Use when the user says '/learn', 'learn this', 'turn this into a skill', 'capture this workflow', 'make a skill from <source>', or 'improve/update the <name> skill'. Produces or updates a .claude/skills/<name>/SKILL.md authored to NanoClaw's skill guidelines. (This CREATES or REFINES a skill from a source; it does not install existing skills from a registry.)"
|
||||
---
|
||||
|
||||
# Learn — Distill a Skill from Anything
|
||||
|
||||
Turn a source — a directory, a URL, pasted notes, or the work just done in this conversation — into a clean, reusable NanoClaw skill. The output is a new `.claude/skills/<name>/SKILL.md` (plus optional `scripts/`, `references/`, `templates/`) authored to the project's skill guidelines.
|
||||
|
||||
This skill is **instruction-only**: it uses the tools you already have (`Read`, `Grep`, `Glob`, `WebFetch`, `Write`) — there is no separate distillation engine and no reach-ins into core code.
|
||||
|
||||
## When to use
|
||||
|
||||
Invoke when the user wants to *capture* a workflow as a reusable skill:
|
||||
|
||||
- `/learn <path>` — read a project/dir and build a skill for working with it
|
||||
- `/learn <url>` — read docs / an API page and build a usage skill
|
||||
- `/learn what we just did` — distill the current conversation's workflow
|
||||
- `/learn` + pasted notes — turn notes into a structured skill
|
||||
|
||||
If the user instead wants to *find and install* an existing community skill, that is a different task — this skill **creates** new skills, it does not import them.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Identify the source — and whether this is a new skill or a refine
|
||||
- A **path** → read the code/files.
|
||||
- A **URL** → fetch and read the page.
|
||||
- **"what we just did" / "this"** → use the current conversation as the source.
|
||||
- **Pasted text** → use it directly.
|
||||
|
||||
Then check `.claude/skills/` for an existing skill that already covers this topic (the user may name it, e.g. *"update the wow-on-steam-deck skill"*, or the subject may obviously match one). **If one exists, this is a REFINE, not a fresh create** — go to step 4's "Refining" branch.
|
||||
|
||||
If it is ambiguous what the skill should *do*, ask one clarifying question before proceeding.
|
||||
|
||||
### 2. Gather the material
|
||||
- **Path:** `Glob` the structure, `Read` the key files, `Grep` for the important entry points. Read enough to understand the *repeatable procedure*, not every line.
|
||||
- **URL:** `WebFetch` the page; pull out the concrete commands/steps, not the prose.
|
||||
- **Conversation:** re-read what was actually done — the commands, the gotchas, the decisions — and keep the parts that generalize.
|
||||
|
||||
### 3. Distill — find the reusable procedure
|
||||
Strip the one-off specifics; keep the *repeatable* shape. A good skill answers: *"Next time someone needs to do X, what are the exact steps, files, commands, and gotchas?"* Capture:
|
||||
|
||||
- the trigger / when-to-use,
|
||||
- the step-by-step procedure (commands, file paths, decision points),
|
||||
- the non-obvious **gotchas** that were hit — usually the most valuable part,
|
||||
- any scripts or templates worth shipping alongside.
|
||||
|
||||
### 4. Author the SKILL.md
|
||||
|
||||
**Refining an existing skill?** First `Read` the current `.claude/skills/<name>/SKILL.md`, then *update it in place* — do not blindly overwrite:
|
||||
- Keep what is still correct; weave the new learnings into the right sections.
|
||||
- **Dedupe** — don't append a near-duplicate step or a second gotcha that says the same thing.
|
||||
- Correct anything the new source proves stale (a changed path, command, or flag).
|
||||
- Preserve the existing `name`/folder and overall structure; the diff should read as a focused improvement, not a rewrite.
|
||||
|
||||
**New skill?** Write `.claude/skills/<kebab-name>/SKILL.md`.
|
||||
|
||||
**Frontmatter (required):**
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: <kebab-case, matches the folder>
|
||||
description: "<what it does + when to use it + likely trigger phrases>"
|
||||
---
|
||||
```
|
||||
|
||||
`description` is what the agent reads to decide relevance — make it concrete and include the phrases a user would actually say.
|
||||
|
||||
**Body:** open with one paragraph on what the skill does, then a `## When to use` section and a `## Workflow` of numbered steps (the actual procedure). Use tables for command/file references, and add a short examples or troubleshooting section when the gotchas warrant it.
|
||||
|
||||
**House authoring rules (from `docs/skill-guidelines.md`):**
|
||||
|
||||
- **Additive, minimal reach-ins** — prefer adding files; make the *smallest possible* edit to existing code, and only via single-line calls into skill-owned functions.
|
||||
- **Instruction-only when possible** — if Claude can do it by following prose plus existing tools, ship no code. These are the easiest skills to maintain and to merge.
|
||||
- If apply leaves anything behind, ship a **`REMOVE.md`** that fully reverses every change (no soft-disabled/commented-out removals).
|
||||
- If the skill adds an integration point in core code, add a **test that goes red if the wiring is deleted or drifts**.
|
||||
- Anti-patterns to avoid: separate `VERIFY.md` files, incomplete cleanup, raw SQL against core DBs, branch merges (use additive fetch), hand-maintained duplicate copies.
|
||||
|
||||
### 5. Place and verify
|
||||
- Write into `.claude/skills/<name>/`; confirm the folder name matches the `name` frontmatter and the YAML parses.
|
||||
- If feasible, dry-run the procedure the skill describes to confirm it is correct.
|
||||
- Tell the user the skill exists and how to invoke it (`/<name>`).
|
||||
|
||||
## Example
|
||||
|
||||
`/learn what we just did` after a multi-step setup:
|
||||
|
||||
1. Re-read the conversation's commands and gotchas.
|
||||
2. Distill the repeatable procedure.
|
||||
3. Write `.claude/skills/<topic>-setup/SKILL.md` with the steps, file paths, and the gotchas hit along the way.
|
||||
4. Report: *"Created `/<topic>-setup` — invoke it next time to repeat this."*
|
||||
|
||||
## Notes
|
||||
|
||||
- Keep skills **focused** — one capability per skill (mirrors the project's "one change per PR" rule).
|
||||
- The most valuable content is the **gotchas**, not the happy path.
|
||||
- This skill is prose and safe to re-run — use it again to refine an existing skill.
|
||||
@@ -13,18 +13,18 @@ Configure which host directories NanoClaw agent containers can access. The mount
|
||||
cat ~/.config/nanoclaw/mount-allowlist.json 2>/dev/null || echo "No mount allowlist configured"
|
||||
```
|
||||
|
||||
Show the current config to the user in a readable format: which directories are allowed, whether non-main agents are read-only.
|
||||
Show the current config to the user in a readable format: which directories are allowed, and whether each is read-only or read-write.
|
||||
|
||||
## Add Directories
|
||||
|
||||
Ask which directories the user wants agents to access. For each path:
|
||||
- Validate the path exists
|
||||
- Ask if it should be read-only for non-main agents (default: yes)
|
||||
- Ask if it should be read-write (`allowReadWrite: true`) or read-only (`allowReadWrite: false`, the safer default)
|
||||
|
||||
Build the JSON config and write it:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx setup/index.ts --step mounts --force -- --json '{"allowedRoots":[{"path":"/path/to/dir","readOnly":false}],"blockedPatterns":[],"nonMainReadOnly":true}'
|
||||
pnpm exec tsx setup/index.ts --step mounts --force -- --json '{"allowedRoots":[{"path":"/path/to/dir","allowReadWrite":true}],"blockedPatterns":[]}'
|
||||
```
|
||||
|
||||
Use `--force` to overwrite the existing config.
|
||||
@@ -34,7 +34,7 @@ Use `--force` to overwrite the existing config.
|
||||
Read the current config, show it, ask which entry to remove, then write the updated config through the same write path (build the trimmed JSON and pass it to `--step mounts --force -- --json`):
|
||||
|
||||
```bash
|
||||
pnpm exec tsx setup/index.ts --step mounts --force -- --json '{"allowedRoots":[],"blockedPatterns":[],"nonMainReadOnly":true}'
|
||||
pnpm exec tsx setup/index.ts --step mounts --force -- --json '{"allowedRoots":[],"blockedPatterns":[]}'
|
||||
```
|
||||
|
||||
## Reset to Empty
|
||||
@@ -45,12 +45,10 @@ pnpm exec tsx setup/index.ts --step mounts --force -- --empty
|
||||
|
||||
## After Changes
|
||||
|
||||
Restart the service so containers pick up the new config (the unit/label names are per-install — see `setup/lib/install-slug.sh`).
|
||||
The allowlist is read fresh when a container is spawned, so new mounts apply to newly spawned containers automatically — no service restart needed.
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
To apply the new config to a group that already has a running container, restart just that group:
|
||||
|
||||
```bash
|
||||
source setup/lib/install-slug.sh
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
systemctl --user restart $(systemd_unit) # Linux
|
||||
ncl groups restart --id <group-id>
|
||||
```
|
||||
|
||||
@@ -246,30 +246,40 @@ If one or more `[BREAKING]` lines are found:
|
||||
- For each skill the user selects, invoke it using the Skill tool.
|
||||
- After all selected skills complete (or if user chose Skip), proceed to Step 7 (skill updates check).
|
||||
|
||||
# Step 7: Check for skill and channel/provider updates
|
||||
# Step 7: Skill updates (part of updating NanoClaw)
|
||||
|
||||
## 7a: Skill branches
|
||||
Check if skills are distributed as branches in this repo:
|
||||
- `git branch -r --list 'upstream/skill/*'`
|
||||
Updating your installed skills is **part of** updating NanoClaw, not an optional
|
||||
extra. Channel and provider code ships on long-lived branches (`channels`,
|
||||
`providers`) that the host merge above doesn't touch — so stopping here leaves
|
||||
that code on whatever version you installed, which is how an important upstream
|
||||
fix gets silently left behind. The default is to continue into `/update-skills`,
|
||||
which re-applies your installed channels/providers to pull their latest code.
|
||||
|
||||
If any `upstream/skill/*` branches exist:
|
||||
- Use AskUserQuestion to ask: "Upstream has skill branches. Would you like to check for skill updates?"
|
||||
- Option 1: "Yes, check for updates" (description: "Runs /update-skills to check for and apply skill branch updates")
|
||||
- Option 2: "No, skip" (description: "You can run /update-skills later any time")
|
||||
- If user selects yes, invoke `/update-skills` using the Skill tool.
|
||||
Detect whether anything is installed: read `src/channels/index.ts` and
|
||||
`src/providers/index.ts`, collecting `import './<name>.js';` lines (excluding
|
||||
`cli`).
|
||||
|
||||
## 7b: Channel and provider updates
|
||||
Detect installed channels by reading `src/channels/index.ts` and collecting all `import './<name>.js';` lines (excluding `cli`). For providers, check `src/providers/index.ts` the same way.
|
||||
- If nothing is installed: skip silently and proceed to Step 7.9.
|
||||
- If one or more are installed: continue into skill updates.
|
||||
|
||||
If any channels/providers are installed AND `upstream/channels` or `upstream/providers` branches exist:
|
||||
- List the installed channels/providers.
|
||||
- Use AskUserQuestion to ask: "Would you like to update your installed channels/providers? Re-running `/add-<name>` is safe — it only updates code files, credentials and wiring are untouched."
|
||||
- One option per installed channel/provider (e.g., "Update Slack (/add-slack)")
|
||||
- "Skip — I'll update them later"
|
||||
- Set `multiSelect: true`
|
||||
- For each selected option, invoke the corresponding `/add-<channel>` or `/add-<provider>` skill.
|
||||
**Hand-off — default in, minimal opt-out.** Use AskUserQuestion (single-select).
|
||||
Name the installed skills in the question so the choice is concrete:
|
||||
- Question: "Skill updates are part of this NanoClaw update — your installed
|
||||
channels/providers (<list the detected ones>) ride separate branches the host
|
||||
update didn't touch. Continue into `/update-skills` to bring them up to date?"
|
||||
- Option 1 (Recommended): "Continue into skill updates" — description: "Runs
|
||||
`/update-skills`, which re-applies your installed channels/providers to pull
|
||||
their latest upstream code. You pick which ones there."
|
||||
- Option 2: "Skip — I'll run `/update-skills` myself later" — description: "Your
|
||||
installed skill code stays as-is and may be behind upstream."
|
||||
|
||||
If no channels/providers are installed, skip silently.
|
||||
Keep it to these two options — the per-skill selection lives inside
|
||||
`/update-skills`, not here.
|
||||
|
||||
- On "Continue": invoke `/update-skills` using the Skill tool. (If the re-apply
|
||||
touches container code, `/update-skills` rebuilds the agent image itself — see
|
||||
its Step 4 — so nothing container-related is owed back here.)
|
||||
- On "Skip": note that `/update-skills` can be run anytime, then proceed.
|
||||
|
||||
Proceed to Step 7.9.
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ For each selected skill (process one at a time):
|
||||
After all selected skills are re-applied:
|
||||
- `pnpm run build`
|
||||
- `pnpm test` (do not fail the flow if tests are not configured)
|
||||
- If the re-apply changed any files under `container/` (`git diff --name-only -- container/` is non-empty), rebuild the agent image so new sessions pick up the new code: `./container/build.sh`. Skill code that lives in the container (e.g. a provider's runtime) keeps running the old image until this is done — the rebuild is what makes the fix live, not the file copy. If nothing under `container/` changed (e.g. only a channel adapter was re-applied), skip it.
|
||||
|
||||
Each channel/provider skill copies in its own registration test; those run as part of `pnpm test` and assert the barrel still registers the adapter against the freshly fetched code.
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@ All notable changes to NanoClaw will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **Optional per-container resource caps.** `CONTAINER_CPU_LIMIT` and `CONTAINER_MEMORY_LIMIT` pass through to `docker run` as `--cpus` / `--memory` (`container-runner.ts`). Both empty by default — no flag added, spawn args byte-identical to today — so existing installs are unaffected. Set them to cap an agent container's CPU/memory so one agent can't monopolize the host (e.g. `CONTAINER_CPU_LIMIT=2`, `CONTAINER_MEMORY_LIMIT=8g`). Swap is intentionally not managed here: `--memory` is a hard cap on a swapless host.
|
||||
- [BREAKING] **Chat SDK pinned to `4.29.0` (was `4.26.0` via `^4.24.0`).** `chat` and the `@chat-adapter/*` channel adapters are version-locked — the adapter's `ChatInstance` must match the bridge's, so a mismatched pair fails to typecheck at `createChatSdkBridge(...)`. `chat` is therefore pinned exactly, and the channel-adapter install pins move with it — the `/add-<channel>` SKILL.md steps and `setup/*.sh` scripts on `main`, plus the adapter code on the `channels` branch. Core installs with no channel (only `cli`) are unaffected. **Migration:** if any channel is installed (Slack, Discord, Telegram, Teams, …), re-run its `/add-<channel>` skill to pull the matching `4.29.0` adapter.
|
||||
- **Budget/billing-exhausted LLM turns now reach the user instead of being silently dropped.** When a turn ends in a non-retryable provider error (e.g. an Anthropic `403 billing_error`) with no `<message>` wrapping, the agent-runner delivers the provider's notice to the originating channel and stops re-nudging the failing gateway. `providers/claude.ts` now surfaces the SDK's `is_error` flag (and the error subtype's `errors[]` text); `poll-loop.ts` delivers that text and skips the re-wrap retry. Fixes the case where a spend-limit notice produced silence plus a turn-after-turn retry loop.
|
||||
- [BREAKING] **`@onecli-sh/sdk` 0.5.0 -> 2.2.1 — requires a OneCLI server with the `/v1` API** (older servers 404 every SDK call). The sanctioned gateway and CLI versions are pinned in `versions.json`. **The gateway is a separate component — updating NanoClaw does not upgrade it for you:** `/update-nanoclaw` upgrades it when the pin moves, otherwise upgrade manually. **Migration:** [docs/onecli-upgrades.md](docs/onecli-upgrades.md).
|
||||
- **New agent provider: Codex (OpenAI) — run `/add-codex`.** Full runtime via `codex app-server` (planning, MCP tools, server-side history, resume). Trunk ships the seams and the skill; the payload installs from the `providers` branch (the skill, the setup picker, or `--step provider-auth codex`). Auth is vault-only — no credential ever enters a container.
|
||||
- **Setup can now select, install, and authenticate a non-default agent provider.** A provider registry feeds the setup picker, an installer pulls the provider's payload from its branch, a vault auth walkthrough runs (`--step provider-auth`), and the picked provider is set on the first agent (a DB property) before its first spawn. Default (Claude) installs are unaffected — picking Claude changes nothing.
|
||||
|
||||
@@ -71,7 +71,7 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t
|
||||
| `src/command-gate.ts` | Router-side admin command gate — queries `user_roles` directly (no env var, no container-side check) |
|
||||
| `src/modules/approvals/onecli-approvals.ts` | OneCLI credentialed-action approval bridge |
|
||||
| `src/modules/permissions/user-dm.ts` | Cold-DM resolution + `user_dms` cache |
|
||||
| `src/group-init.ts` | Per-agent-group filesystem scaffold (CLAUDE.md, skills, agent-runner-src overlay) |
|
||||
| `src/group-init.ts` | Per-agent-group filesystem scaffold (CLAUDE.md, skills) — agent-runner source is a shared read-only mount, not copied per group |
|
||||
| `src/db/container-configs.ts` | CRUD for `container_configs` table (per-group container runtime config) |
|
||||
| `src/backfill-container-configs.ts` | Migrates legacy `container.json` files into the DB on startup |
|
||||
| `src/container-restart.ts` | Kill + on-wake respawn for agent group containers |
|
||||
@@ -79,8 +79,8 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t
|
||||
| `src/channels/` | Channel adapter infra (registry, Chat SDK bridge); specific channel adapters are skill-installed from the `channels` branch |
|
||||
| `src/providers/` | Host-side provider container-config (`claude` baked in; `opencode` etc. installed from the `providers` branch) |
|
||||
| `container/agent-runner/src/` | Agent-runner: poll loop, formatter, provider abstraction, MCP tools, destinations |
|
||||
| `container/skills/` | Container skills mounted into every agent session (`onecli-gateway`, `welcome`, `self-customize`, `agent-browser`, `slack-formatting`) |
|
||||
| `groups/<folder>/` | Per-agent-group filesystem (CLAUDE.md, skills, per-group `agent-runner-src/` overlay) |
|
||||
| `container/skills/` | Container skills mounted into every agent session (`agent-browser`, `frontend-engineer`, `onecli-gateway`, `self-customize`, `slack-formatting`, `vercel-cli`, `welcome`, `whatsapp-formatting`) |
|
||||
| `groups/<folder>/` | Per-agent-group filesystem (CLAUDE.md, skills) — agent-runner source is a shared read-only mount, not copied per group |
|
||||
| `scripts/init-first-agent.ts` | Bootstrap the first DM-wired agent (used by `/init-first-agent` skill) |
|
||||
| `migrate-v2.sh` + `setup/migrate-v2/` | v1→v2 migration. Standalone script: `bash migrate-v2.sh`. Seeds DB, copies groups/sessions, installs channels, builds container, offers service switchover, then hands off to `/migrate-from-v1` skill for owner setup and CLAUDE.md cleanup. See [docs/migration-dev.md](docs/migration-dev.md). |
|
||||
| `nanoclaw.sh --uninstall` + `setup/uninstall/` | Uninstall this copy only (slug-scoped): service, containers + image, `data/`, `logs/`, `groups/`, this copy's OneCLI agents. Confirms per group; `--dry-run` previews, `--yes` skips prompts. Other copies and the shared OneCLI app are untouched. Bypasses bootstrap entirely; `uninstall.sh` is a pointer that execs it. |
|
||||
@@ -182,7 +182,7 @@ Four types of skills. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full taxono
|
||||
- **Channel/provider install skills** — copy the relevant module(s) in from the `channels` or `providers` branch, wire imports, install pinned deps (e.g. `/add-discord`, `/add-slack`, `/add-whatsapp`, `/add-opencode`).
|
||||
- **Utility skills** — ship code files alongside `SKILL.md` (e.g. a `scripts/` CLI or helper).
|
||||
- **Operational skills** — instruction-only workflows (`/setup`, `/debug`, `/customize`, `/init-first-agent`, `/manage-channels`, `/init-onecli`, `/update-nanoclaw`).
|
||||
- **Container skills** — loaded inside agent containers at runtime (`container/skills/`: `onecli-gateway`, `welcome`, `self-customize`, `agent-browser`, `slack-formatting`).
|
||||
- **Container skills** — loaded inside agent containers at runtime (`container/skills/`: `agent-browser`, `frontend-engineer`, `onecli-gateway`, `self-customize`, `slack-formatting`, `vercel-cli`, `welcome`, `whatsapp-formatting`).
|
||||
|
||||
| Skill | When to Use |
|
||||
|-------|-------------|
|
||||
@@ -280,6 +280,7 @@ This project uses pnpm with `minimumReleaseAge: 4320` (3 days) in `pnpm-workspac
|
||||
| [docs/customizing.md](docs/customizing.md) | Short intro to customizing via skills |
|
||||
| [docs/skills-model.md](docs/skills-model.md) | The skills model in full: recipes, tests, upgrades, migrations |
|
||||
| [docs/skill-guidelines.md](docs/skill-guidelines.md) | Authoritative checklist for writing a skill |
|
||||
| [docs/templates.md](docs/templates.md) | Agent templates: what they are, stamping via `ncl groups create --template` + the setup wizard, the OneCLI/MCP-credential model, supported providers, and how to contribute one |
|
||||
|
||||
## Container Build Cache
|
||||
|
||||
|
||||
@@ -125,6 +125,10 @@ Instructions here...
|
||||
- Put code in separate files, not inline in the markdown
|
||||
- See the [skills standard](https://code.claude.com/docs/en/skills) for all available frontmatter fields
|
||||
|
||||
## Templates
|
||||
|
||||
Agent templates (reusable bundles of instructions + MCP servers + skills) ship in the separate [`nanocoai/nanoclaw-templates`](https://github.com/nanocoai/nanoclaw-templates) repo, not this one. Contribute them there via PR (its README has the anatomy and checklist). For how templates load and the OneCLI credential model, see [docs/templates.md](docs/templates.md).
|
||||
|
||||
## Testing
|
||||
|
||||
Test your contribution on a fresh clone before submitting. For skills, run the skill end-to-end and verify it works.
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<a href="https://docs.nanoclaw.dev">docs</a> •
|
||||
<a href="README_zh.md">中文</a> •
|
||||
<a href="README_ja.md">日本語</a> •
|
||||
<a href="README_ko.md">한국어</a> •
|
||||
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a> •
|
||||
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="repo tokens" valign="middle"></a>
|
||||
</p>
|
||||
@@ -79,8 +80,9 @@ See [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md) for what's different an
|
||||
- **Per-agent workspace** — each agent group has its own `CLAUDE.md`, its own memory, its own container, and only the mounts you allow. Nothing crosses the boundary unless you wire it to.
|
||||
- **Scheduled tasks** — recurring jobs that run Claude and can message you back
|
||||
- **Web access** — search and fetch content from the web
|
||||
- **Container isolation** — agents are sandboxed in Docker (macOS/Linux/WSL2), with optional [Docker Sandboxes](docs/docker-sandboxes.md) micro-VM isolation or Apple Container as a macOS-native opt-in
|
||||
- **Container isolation** — agents are sandboxed in Docker (macOS/Linux/WSL2), with optional Docker Sandboxes micro-VM isolation
|
||||
- **Credential security** — agents never hold raw API keys. Outbound requests route through [OneCLI's Agent Vault](https://github.com/onecli/onecli), which injects credentials at request time and enforces per-agent policies and rate limits.
|
||||
- **Agent templates**: stamp a ready-to-run agent (instructions + MCP tools + skills, no secrets) from a reusable bundle, via the setup wizard or `ncl groups create --template <ref>`. Load from the [public library](https://github.com/nanocoai/nanoclaw-templates), a local folder, or any git repo. See [docs/templates.md](docs/templates.md).
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -163,7 +165,7 @@ Key files:
|
||||
|
||||
**Why Docker?**
|
||||
|
||||
Docker provides cross-platform support (macOS, Linux and Windows via WSL2) and a mature ecosystem. On macOS, Apple Container is also supported as a lighter-weight native runtime. For additional isolation, [Docker Sandboxes](docs/docker-sandboxes.md) run each container inside a micro VM.
|
||||
Docker provides cross-platform support (macOS, Linux and Windows via WSL2) and a mature ecosystem. For additional isolation, Docker Sandboxes run each container inside a micro VM.
|
||||
|
||||
**Can I run this on Linux or Windows?**
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<a href="https://docs.nanoclaw.dev">ドキュメント</a> •
|
||||
<a href="README.md">English</a> •
|
||||
<a href="README_zh.md">中文</a> •
|
||||
<a href="README_ko.md">한국어</a> •
|
||||
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a> •
|
||||
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="repo tokens" valign="middle"></a>
|
||||
</p>
|
||||
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
<p align="center">
|
||||
<img src="assets/nanoclaw-logo.png" alt="NanoClaw" width="400">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
에이전트를 각자의 컨테이너에서 안전하게 실행하는 AI 어시스턴트입니다. 가볍고, 쉽게 이해할 수 있으며, 여러분의 필요에 맞게 완전히 커스터마이즈할 수 있도록 만들어졌습니다.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://nanoclaw.dev">nanoclaw.dev</a> •
|
||||
<a href="https://docs.nanoclaw.dev">문서</a> •
|
||||
<a href="README.md">English</a> •
|
||||
<a href="README_zh.md">中文</a> •
|
||||
<a href="README_ja.md">日本語</a> •
|
||||
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a> •
|
||||
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="repo tokens" valign="middle"></a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## NanoClaw를 만든 이유
|
||||
|
||||
[OpenClaw](https://github.com/openclaw/openclaw)는 인상적인 프로젝트지만, 제가 이해하지 못하는 복잡한 소프트웨어에 제 삶 전체에 대한 접근 권한을 줬다면 저는 잠을 이루지 못했을 것입니다. OpenClaw는 거의 50만 줄에 달하는 코드, 53개의 설정 파일, 70개 이상의 의존성을 가지고 있습니다. 보안은 진정한 OS 수준의 격리가 아니라 애플리케이션 수준(허용 목록, 페어링 코드)에 의존합니다. 모든 것이 메모리를 공유하는 하나의 Node 프로세스에서 실행됩니다.
|
||||
|
||||
NanoClaw는 그와 동일한 핵심 기능을 제공하지만, 이해할 수 있을 만큼 작은 코드베이스로 구현합니다. 하나의 프로세스와 몇 개의 파일뿐입니다. Claude 에이전트는 단순한 권한 검사 뒤가 아니라, 파일시스템이 격리된 각자의 Linux 컨테이너에서 실행됩니다.
|
||||
|
||||
## 빠른 시작
|
||||
|
||||
```bash
|
||||
git clone https://github.com/nanocoai/nanoclaw.git nanoclaw-v2
|
||||
cd nanoclaw-v2
|
||||
bash nanoclaw.sh
|
||||
```
|
||||
|
||||
`nanoclaw.sh`는 갓 준비한 머신에서 시작해 메시지를 보낼 수 있는 이름 붙은 에이전트까지 안내합니다. 누락된 경우 Node, pnpm, Docker를 설치하고, Anthropic 자격 증명을 OneCLI에 등록하며, 에이전트 컨테이너를 빌드하고, 첫 채널(Telegram, Discord, WhatsApp 또는 로컬 CLI)을 페어링합니다. 어떤 단계가 실패하면 Claude Code가 자동으로 호출되어 원인을 진단하고 중단된 지점부터 재개합니다.
|
||||
|
||||
<details>
|
||||
<summary><strong>NanoClaw v1에서 마이그레이션하시나요?</strong></summary>
|
||||
|
||||
기존 v1 설치 옆에 새로운 v2 체크아웃을 만들어 실행하세요:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/nanocoai/nanoclaw.git nanoclaw-v2
|
||||
cd nanoclaw-v2
|
||||
bash migrate-v2.sh
|
||||
```
|
||||
|
||||
`migrate-v2.sh`는 v1 설치(형제 디렉터리, 또는 `NANOCLAW_V1_PATH=/path/to/nanoclaw`)를 찾아 상태를 v2 체크아웃으로 마이그레이션한 다음, 판단이 필요한 부분(소유자 시딩, CLAUDE.local.md 정리, 포크 커스터마이징 재적용)을 마무리하기 위해 Claude Code로 `exec`합니다.
|
||||
|
||||
이 스크립트는 Claude 세션 내부가 아니라 직접 실행하세요. 결정론적인 부분에서 Node/pnpm 부트스트랩, Docker, OneCLI, 컨테이너 빌드를 위해 대화형 프롬프트와 실제 셸 I/O가 필요합니다.
|
||||
|
||||
**무엇을 하는가:** `.env`를 병합하고, `registered_groups`로부터 v2 DB를 시딩하며, 그룹 폴더 + 세션 데이터 + 예약 작업을 복사하고, 선택한 채널 어댑터를 설치하며, 채널 인증 상태(WhatsApp의 Baileys 키스토어 + LID 매핑 포함)를 복사하고, 에이전트 컨테이너를 빌드합니다.
|
||||
|
||||
**무엇을 하지 않는가:** 시스템 서비스를 전환하지 않습니다. 프롬프트에서 *"switch to v2"*를 선택하거나, 테스트 후 수동으로 전환하세요. 기존 v1 설치는 그대로 유지됩니다.
|
||||
|
||||
무엇이 달라졌는지는 [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md)를, 개발 노트는 [docs/migration-dev.md](docs/migration-dev.md)를 참고하세요.
|
||||
|
||||
</details>
|
||||
|
||||
## 철학
|
||||
|
||||
**이해할 수 있을 만큼 작게.** 하나의 프로세스, 몇 개의 소스 파일, 마이크로서비스 없음. NanoClaw 코드베이스 전체를 이해하고 싶다면 Claude Code에게 안내해 달라고 요청하기만 하면 됩니다.
|
||||
|
||||
**격리를 통한 보안.** 에이전트는 Linux 컨테이너에서 실행되며 명시적으로 마운트된 것만 볼 수 있습니다. 명령이 호스트가 아니라 컨테이너 안에서 실행되기 때문에 Bash 접근도 안전합니다.
|
||||
|
||||
**개별 사용자를 위해 설계.** NanoClaw는 거대한 단일 프레임워크가 아니라, 각 사용자의 정확한 필요에 맞는 소프트웨어입니다. 비대한 소프트웨어가 되는 대신, NanoClaw는 맞춤형이 되도록 설계되었습니다. 직접 포크를 만들고 Claude Code가 여러분의 필요에 맞게 수정하도록 합니다.
|
||||
|
||||
**커스터마이징 = 코드 변경.** 설정의 난립이 없습니다. 다른 동작을 원하시나요? 코드를 수정하세요. 코드베이스가 충분히 작아서 안전하게 변경할 수 있습니다.
|
||||
|
||||
**AI 네이티브, 설계상 하이브리드.** 설치와 온보딩 흐름은 최적화된 스크립트 경로로, 빠르고 결정론적입니다. 어떤 단계에 판단이 필요할 때 — 설치 실패, 안내가 필요한 결정, 커스터마이징 등 — 제어권이 Claude Code로 매끄럽게 넘어갑니다. 설정 이후에도 모니터링 대시보드나 디버깅 UI가 없습니다. 채팅으로 문제를 설명하면 Claude Code가 처리합니다.
|
||||
|
||||
**기능보다 스킬.** 트렁크는 특정 채널 어댑터나 대체 에이전트 프로바이더가 아니라 레지스트리와 인프라를 제공합니다. 채널(Discord, Slack, Telegram, WhatsApp, …)은 오래 유지되는 `channels` 브랜치에, 대체 프로바이더(OpenCode, Ollama)는 `providers` 브랜치에 있습니다. `/add-telegram`, `/add-opencode` 등을 실행하면 스킬이 여러분이 필요로 하는 모듈만 정확히 포크로 복사합니다. 요청하지 않은 기능은 없습니다.
|
||||
|
||||
**최고의 하니스, 최고의 모델.** NanoClaw는 Anthropic의 공식 Claude Agent SDK를 통해 Claude Code를 네이티브로 사용하므로, 최신 Claude 모델과 Claude Code의 전체 도구 세트를 누릴 수 있습니다. 여기에는 자신의 NanoClaw 포크를 직접 수정하고 확장하는 능력도 포함됩니다. 다른 프로바이더는 드롭인 옵션입니다. OpenAI의 Codex는 `/add-codex`(ChatGPT 구독 또는 API 키), OpenRouter·Google·DeepSeek 등은 OpenCode를 통한 `/add-opencode`, 로컬 오픈 웨이트 모델은 `/add-ollama-provider`로 추가합니다. 프로바이더는 에이전트 그룹별로 설정할 수 있습니다.
|
||||
|
||||
## 지원 기능
|
||||
|
||||
- **멀티 채널 메시징** — WhatsApp, Telegram, Discord, Slack, Microsoft Teams, iMessage, Matrix, Google Chat, Webex, Linear, GitHub, WeChat, 그리고 Resend를 통한 이메일. `/add-<channel>` 스킬로 필요할 때 설치합니다. 하나 또는 여러 개를 동시에 실행할 수 있습니다.
|
||||
- **유연한 격리** — 완전한 프라이버시를 위해 각 채널을 자체 에이전트에 연결하거나, 대화는 분리하되 메모리는 통합하기 위해 하나의 에이전트를 여러 채널에서 공유하거나, 여러 채널을 하나의 공유 세션으로 묶어 하나의 대화가 여러 채널에 걸쳐 이어지도록 할 수 있습니다. `/manage-channels`로 채널별로 선택하세요. [docs/isolation-model.md](docs/isolation-model.md)를 참고하세요.
|
||||
- **에이전트별 작업 공간** — 각 에이전트 그룹은 자체 `CLAUDE.md`, 자체 메모리, 자체 컨테이너, 그리고 여러분이 허용한 마운트만 갖습니다. 직접 연결하지 않는 한 경계를 넘는 것은 아무것도 없습니다.
|
||||
- **예약 작업** — Claude를 실행하고 여러분에게 다시 메시지를 보낼 수 있는 반복 작업
|
||||
- **웹 접근** — 웹에서 검색하고 콘텐츠를 가져오기
|
||||
- **컨테이너 격리** — 에이전트는 Docker(macOS/Linux/WSL2)에서 샌드박스화되며, 선택적으로 [Docker Sandboxes](docs/docker-sandboxes.md) 마이크로 VM 격리나 macOS 네이티브 런타임인 Apple Container를 사용할 수 있습니다
|
||||
- **자격 증명 보안** — 에이전트는 원시 API 키를 절대 보유하지 않습니다. 아웃바운드 요청은 [OneCLI의 Agent Vault](https://github.com/onecli/onecli)를 통해 라우팅되며, 요청 시점에 자격 증명을 주입하고 에이전트별 정책과 속도 제한을 적용합니다.
|
||||
|
||||
## 사용법
|
||||
|
||||
트리거 단어(기본값: `@Andy`)로 어시스턴트에게 말을 거세요:
|
||||
|
||||
```
|
||||
@Andy 매주 평일 오전 9시에 영업 파이프라인 개요를 보내줘 (내 Obsidian 보관함 폴더에 접근 가능)
|
||||
@Andy 매주 금요일에 지난 한 주간의 git 히스토리를 검토하고, 내용이 어긋나면 README를 업데이트해줘
|
||||
@Andy 매주 월요일 오전 8시에 Hacker News와 TechCrunch에서 AI 관련 소식을 모아 브리핑을 보내줘
|
||||
```
|
||||
|
||||
여러분이 소유하거나 관리하는 채널에서는 그룹과 작업을 관리할 수 있습니다:
|
||||
```
|
||||
@Andy 모든 그룹에 걸친 예약 작업을 전부 나열해줘
|
||||
@Andy 월요일 브리핑 작업을 일시 정지해줘
|
||||
@Andy Family Chat 그룹에 참여해줘
|
||||
```
|
||||
|
||||
## 커스터마이징
|
||||
|
||||
NanoClaw는 설정 파일을 사용하지 않습니다. 변경하려면 Claude Code에게 원하는 것을 말하기만 하면 됩니다:
|
||||
|
||||
- "트리거 단어를 @Bob으로 바꿔줘"
|
||||
- "앞으로는 응답을 더 짧고 직접적으로 하도록 기억해줘"
|
||||
- "내가 좋은 아침이라고 인사하면 맞춤 인사를 추가해줘"
|
||||
- "매주 대화 요약을 저장해줘"
|
||||
|
||||
또는 안내형 변경을 위해 `/customize`를 실행하세요.
|
||||
|
||||
코드베이스가 충분히 작아서 Claude가 안전하게 수정할 수 있습니다.
|
||||
|
||||
## 기여하기
|
||||
|
||||
**기능을 추가하지 마세요. 스킬을 추가하세요.**
|
||||
|
||||
새로운 채널이나 에이전트 프로바이더를 추가하고 싶다면 트렁크에 추가하지 마세요. 새 채널 어댑터는 `channels` 브랜치에, 새 에이전트 프로바이더는 `providers` 브랜치에 들어갑니다. 사용자는 `/add-<name>` 스킬로 자신의 포크에 설치하며, 이 스킬은 관련 모듈을 표준 경로로 복사하고, 등록을 연결하며, 의존성을 고정합니다.
|
||||
|
||||
이를 통해 트렁크는 순수한 레지스트리이자 인프라로 유지되고, 모든 포크는 가벼운 상태를 유지합니다. 사용자는 요청한 채널과 프로바이더만 얻고 그 외에는 아무것도 얻지 않습니다.
|
||||
|
||||
### RFS (Request for Skills)
|
||||
|
||||
저희가 보고 싶은 스킬:
|
||||
|
||||
**커뮤니케이션 채널**
|
||||
- `/add-signal` — Signal을 채널로 추가
|
||||
|
||||
## 요구 사항
|
||||
|
||||
- macOS 또는 Linux (Windows는 WSL2 경유)
|
||||
- Node.js 20+ 및 pnpm 10+ (설치 프로그램이 누락 시 둘 다 설치합니다)
|
||||
- [Docker Desktop](https://docker.com/products/docker-desktop) (macOS/Windows) 또는 Docker Engine (Linux)
|
||||
- `/customize`, `/debug`, 설정 중 오류 복구, 그리고 모든 `/add-<channel>` 스킬을 위한 [Claude Code](https://claude.ai/download)
|
||||
|
||||
## 아키텍처
|
||||
|
||||
```
|
||||
메시징 앱 → 호스트 프로세스(라우터) → inbound.db → 컨테이너(Bun, Claude Agent SDK) → outbound.db → 호스트 프로세스(전송) → 메시징 앱
|
||||
```
|
||||
|
||||
하나의 Node 호스트가 세션별 에이전트 컨테이너를 오케스트레이션합니다. 메시지가 도착하면 호스트는 엔티티 모델(사용자 → 메시징 그룹 → 에이전트 그룹 → 세션)을 통해 라우팅하고, 세션의 `inbound.db`에 기록한 뒤 컨테이너를 깨웁니다. 컨테이너 내부의 에이전트 러너는 `inbound.db`를 폴링하고, Claude를 실행하며, 응답을 `outbound.db`에 기록합니다. 호스트는 `outbound.db`를 폴링하여 채널 어댑터를 통해 다시 전송합니다.
|
||||
|
||||
세션당 두 개의 SQLite 파일이 있으며 각각 정확히 하나의 작성자만 갖습니다. 교차 마운트 경합이 없고, IPC가 없으며, stdin 파이핑이 없습니다. 채널과 대체 프로바이더는 시작 시 자체 등록됩니다. 트렁크는 레지스트리와 Chat SDK 브리지를 제공하고, 어댑터 자체는 포크별로 스킬을 통해 설치됩니다.
|
||||
|
||||
전체 아키텍처 설명은 [docs/architecture.md](docs/architecture.md)를, 3단계 격리 모델은 [docs/isolation-model.md](docs/isolation-model.md)를 참고하세요.
|
||||
|
||||
핵심 파일:
|
||||
- `src/index.ts` — 진입점: DB 초기화, 채널 어댑터, 전송 폴링, 스윕
|
||||
- `src/router.ts` — 인바운드 라우팅: 메시징 그룹 → 에이전트 그룹 → 세션 → `inbound.db`
|
||||
- `src/delivery.ts` — `outbound.db` 폴링, 어댑터를 통한 전송, 시스템 액션 처리
|
||||
- `src/host-sweep.ts` — 60초 스윕: 정체 감지, 예정 메시지 깨우기, 반복 처리
|
||||
- `src/session-manager.ts` — 세션 확인, `inbound.db` / `outbound.db` 열기
|
||||
- `src/container-runner.ts` — 에이전트 그룹별 컨테이너 생성, OneCLI 자격 증명 주입
|
||||
- `src/db/` — 중앙 DB (사용자, 역할, 에이전트 그룹, 메시징 그룹, 연결, 마이그레이션)
|
||||
- `src/channels/` — 채널 어댑터 인프라 (어댑터는 `/add-<channel>` 스킬로 설치)
|
||||
- `src/providers/` — 호스트 측 프로바이더 설정 (`claude`는 기본 내장, 그 외는 스킬 경유)
|
||||
- `container/agent-runner/` — Bun 에이전트 러너: 폴 루프, MCP 도구, 프로바이더 추상화
|
||||
- `groups/<folder>/` — 에이전트 그룹별 파일시스템 (`CLAUDE.md`, 스킬, 컨테이너 설정)
|
||||
|
||||
## FAQ
|
||||
|
||||
**왜 Docker인가요?**
|
||||
|
||||
Docker는 크로스 플랫폼 지원(macOS, Linux, 그리고 WSL2 경유 Windows)과 성숙한 생태계를 제공합니다. macOS에서는 더 가벼운 네이티브 런타임인 Apple Container도 지원됩니다. 추가 격리를 위해 [Docker Sandboxes](docs/docker-sandboxes.md)는 각 컨테이너를 마이크로 VM 안에서 실행합니다.
|
||||
|
||||
**Linux나 Windows에서 실행할 수 있나요?**
|
||||
|
||||
네. Docker가 기본 런타임이며 macOS, Linux, Windows(WSL2 경유)에서 작동합니다. `bash nanoclaw.sh`를 실행하기만 하면 됩니다.
|
||||
|
||||
**이것은 안전한가요?**
|
||||
|
||||
에이전트는 애플리케이션 수준의 권한 검사 뒤가 아니라 컨테이너에서 실행됩니다. 명시적으로 마운트된 디렉터리만 접근할 수 있습니다. 자격 증명은 컨테이너에 들어가지 않습니다. 아웃바운드 API 요청은 [OneCLI의 Agent Vault](https://github.com/onecli/onecli)를 통해 라우팅되며, 프록시 수준에서 인증을 주입하고 속도 제한과 접근 정책을 지원합니다. 여전히 실행하는 것을 검토해야 하지만, 코드베이스가 충분히 작아서 실제로 검토할 수 있습니다. 전체 보안 모델은 [보안 문서](https://docs.nanoclaw.dev/concepts/security)를 참고하세요.
|
||||
|
||||
**왜 설정 파일이 없나요?**
|
||||
|
||||
설정의 난립을 원하지 않습니다. 모든 사용자는 일반적인 시스템을 설정하는 대신, 코드가 정확히 원하는 대로 동작하도록 NanoClaw를 커스터마이즈해야 합니다. 설정 파일을 선호한다면 Claude에게 추가해 달라고 할 수 있습니다.
|
||||
|
||||
**서드파티나 오픈소스 모델을 사용할 수 있나요?**
|
||||
|
||||
네. 지원되는 경로는 `/add-opencode`(OpenCode 설정을 통한 OpenRouter, OpenAI, Google, DeepSeek 등) 또는 `/add-ollama-provider`(Ollama를 통한 로컬 오픈 웨이트 모델)입니다. 둘 다 에이전트 그룹별로 설정할 수 있으므로, 같은 설치 내에서 서로 다른 에이전트가 서로 다른 백엔드에서 실행될 수 있습니다.
|
||||
|
||||
일회성 실험의 경우, Claude API 호환 엔드포인트라면 `.env`를 통해서도 작동합니다:
|
||||
|
||||
```bash
|
||||
ANTHROPIC_BASE_URL=https://your-api-endpoint.com
|
||||
ANTHROPIC_AUTH_TOKEN=your-token-here
|
||||
```
|
||||
|
||||
**문제를 어떻게 디버깅하나요?**
|
||||
|
||||
Claude Code에게 물어보세요. "스케줄러가 왜 실행되지 않지?" "최근 로그에 뭐가 있지?" "이 메시지는 왜 응답을 받지 못했지?" 그것이 NanoClaw의 바탕에 깔린 AI 네이티브 접근 방식입니다.
|
||||
|
||||
**설정이 왜 작동하지 않나요?**
|
||||
|
||||
어떤 단계가 실패하면 `nanoclaw.sh`는 진단하고 재개하기 위해 Claude Code로 넘깁니다. 그래도 해결되지 않으면 `claude`를 실행한 뒤 `/debug`를 실행하세요. Claude가 다른 사용자에게도 영향을 줄 만한 문제를 발견하면, 관련 설정 단계나 스킬에 대한 PR을 열어주세요.
|
||||
|
||||
**NanoClaw를 어떻게 제거하나요?**
|
||||
|
||||
```bash
|
||||
bash nanoclaw.sh --uninstall
|
||||
```
|
||||
|
||||
모든 설치는 체크아웃별 ID로 태깅되므로, 제거 프로그램은 해당 사본에 속한 것만 제거합니다: 백그라운드 서비스, 컨테이너와 이미지, 앱 데이터와 로그, 에이전트 파일, 그리고 이 사본의 OneCLI 볼트 에이전트입니다. 공유되는 것 — OneCLI 앱과 여러분의 자격 증명, 머신의 다른 NanoClaw 사본 — 은 그대로 둡니다. 무엇을 발견했는지 정확히 보여주고 그룹별로 확인을 요청합니다. 여러분이 동의하기 전까지는 아무것도 삭제되지 않습니다. 변경 없이 미리 보려면 `--dry-run`을, 프롬프트를 건너뛰려면 `--yes`를 사용하세요. `.env`는 제거 전에 백업됩니다. 마무리하려면 체크아웃 폴더 자체를 삭제하세요.
|
||||
|
||||
**어떤 변경이 코드베이스에 받아들여지나요?**
|
||||
|
||||
기본 구성에는 보안 수정, 버그 수정, 명확한 개선만 받아들여집니다. 그게 전부입니다.
|
||||
|
||||
그 외의 모든 것(새로운 기능, OS 호환성, 하드웨어 지원, 향상)은 스킬로 기여해야 합니다. 채널과 프로바이더 코드는 `channels`/`providers` 레지스트리 브랜치에, 그 외에는 자체 완결형 스킬로 기여합니다. [docs/customizing.md](docs/customizing.md)와 [CONTRIBUTING.md](CONTRIBUTING.md)를 참고하세요.
|
||||
|
||||
이를 통해 기본 시스템을 최소한으로 유지하고, 모든 사용자가 원하지 않는 기능을 떠안지 않으면서 자신의 설치를 커스터마이즈할 수 있습니다.
|
||||
|
||||
## 커뮤니티
|
||||
|
||||
질문이 있나요? 아이디어가 있나요? [Discord에 참여하세요](https://discord.gg/VDdww8qS42).
|
||||
|
||||
## 변경 이력
|
||||
|
||||
호환성을 깨는 변경 사항은 [CHANGELOG.md](CHANGELOG.md)를, 또는 문서 사이트의 [전체 릴리스 히스토리](https://docs.nanoclaw.dev/changelog)를 참고하세요.
|
||||
|
||||
## 라이선스
|
||||
|
||||
MIT
|
||||
|
||||
<img referrerpolicy="no-referrer-when-downgrade" src="https://static.scarf.sh/a.png?x-pxid=47894bd5-353b-42fe-bb97-74144e6df0bf" />
|
||||
@@ -11,6 +11,7 @@
|
||||
<a href="https://docs.nanoclaw.dev">文档</a> •
|
||||
<a href="README.md">English</a> •
|
||||
<a href="README_ja.md">日本語</a> •
|
||||
<a href="README_ko.md">한국어</a> •
|
||||
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a> •
|
||||
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="repo tokens" valign="middle"></a>
|
||||
</p>
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"": {
|
||||
"name": "nanoclaw-agent-runner",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.170",
|
||||
"@anthropic-ai/sdk": "^0.100.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.197",
|
||||
"@anthropic-ai/sdk": "^0.108.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"cron-parser": "^5.0.0",
|
||||
"zod": "^4.0.0",
|
||||
@@ -19,25 +19,25 @@
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.170", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.170", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.170", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.170", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.170", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.170", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.170", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.170", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.170" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-pAvhfk+iTodXZ6RF18Kz7BEUWFjL7EcR3tKuhUNdPpE1NAYCR3mSHGbafi72JsrNwKEDIs7FU31z3fqhwy8QzA=="],
|
||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.197", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.197", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.197", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.197", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.197", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.197", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.197", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.197", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.197" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-XNIi8W1tb+QfMkcK+5kepOC6BsxG8wtupd72H+pIPzIJypVQhHy7FoX+KBMtTRYwtl+5dsjKyABhjWXebeUilw=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.170", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rwfgArIa5WI0QPNqFsRBgvtSI0mrtpynUm0oK6+l6/KX4hcgnYGEzciZR1bOeD9/7sSZlTdIgt+T9alKeZmXcg=="],
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.197", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jC6WvH5Hr6APTfbMjo4nC6LlyMMqbpCMwiHXIw7/AsQXIHQhZ+cRRMesQlV6UFI1l3O53gLZHzsG9cXwfrPHKw=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.170", "", { "os": "darwin", "cpu": "x64" }, "sha512-0e58h8UQMtsQxLGIv9r4foxfBFWKZ7NeDtoplLhuD7EwQonehomw1sBXCch77t/IfUS+q5vQ5zv+fOGmap5nLQ=="],
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.197", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZQNvGkMrTyatBlHTIQ4w2i2aLBuvq355UP/FDLnVXIH8l23RsL1x/0w9P+dqB7EmY9OZi/cPxSrpskpo+dZWLA=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.170", "", { "os": "linux", "cpu": "arm64" }, "sha512-gLbaFqcGppFJQd4DLNV4IXoeahejT/p2/M8bSSvRDbla9GOsBr1AxV5XLRyBn1e7xFGozZIAIQr3+1chp7NJgQ=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.197", "", { "os": "linux", "cpu": "arm64" }, "sha512-pWhQgCtAft4EGM4Zn24HRad1a/k2u6oA+2uM/KCdjehfKtooDiHfMNd1yzXY/n9AEBWP0RHB2Vz3mJ30X2pVAg=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.170", "", { "os": "linux", "cpu": "arm64" }, "sha512-SRYfQcsXlOq+CD/FqkQBTSHbaD++w73GnnO+NUV9adLYrca3kfetRwWT1iguY1cNS0l34dCR3rlzCPq78vg1Jg=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.197", "", { "os": "linux", "cpu": "arm64" }, "sha512-VuIGXsLGK/aqSQ0tTBqqPVNzjefWS5SWnK8mlYyQitT4s5UDzHXJm0UZBTGxRtlcS0e2+QAHKwbGBCq1ZKSXjg=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.170", "", { "os": "linux", "cpu": "x64" }, "sha512-Xl/m7TaSC3T5IDBdHrZQ9fCQYyDmPELN34CL+MoyPIf7uSmuZnjE9fUOqDh2Rv26JxWssi1M6X+BBvVuKd6Cpg=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.197", "", { "os": "linux", "cpu": "x64" }, "sha512-AUccrbdcv4Hy/GteP/gYLjG/zDP+fe2BFtDMctEfRFVz40DazYDcOyW1+nIgSTQtxf5jSTAVVf3cNuXB2CZwlw=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.170", "", { "os": "linux", "cpu": "x64" }, "sha512-m4+I0qBEk7cxRKS+pL+eoWXbXTFOAo83fQ0tQvap4z/mDMm06IWJtEPoYTaMBwsp32GJWLkHWKbZSBCHZnp2DQ=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.197", "", { "os": "linux", "cpu": "x64" }, "sha512-3Tuy7XhD4UIKE4A4RPmKJcbL7Q/3dcB1hEWQt2lKP7c/DlixeEv+tRzvpnFZKhFX2hy0tkBk3QjkozSAacMC/w=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.170", "", { "os": "win32", "cpu": "arm64" }, "sha512-IG+8isJNNJKbnnhO7m+PGhfVCg+XoQ/MDxGde5eigFI0WsEfitjuWSWwx82bT9ghxI1aa6qNvI+UPgPcZuo5Fg=="],
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.197", "", { "os": "win32", "cpu": "arm64" }, "sha512-Wx8uiAKBenDuL8lWQmrqnX5ppljaH5unQ9cKiCz2/9Kgf09dgnrwbX8n/FhndCZR8PmYw539eWwYVrSVc/bl6w=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.170", "", { "os": "win32", "cpu": "x64" }, "sha512-7cuqSKbHVItPGVwRbd3A0BEJwcNtc7Fhoh6qHN4C6yrmjSrvdYYx3MLvq/VI768/RoG7mAMDxb+j7WfEfoP9BA=="],
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.197", "", { "os": "win32", "cpu": "x64" }, "sha512-ZXJO/VvR3SI4G0gwthWeFXWdHB5RXPu3rtfGRcKZ/YgtDeW17rQ+LZIJTk2ywzbLb8EvlghR5JPgn293hC179Q=="],
|
||||
|
||||
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.100.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-cAm3aXm6qAiHIvHxyIIGd6tVmsD2gDqlc2h0R20ijNUzGgVnIN822bit4mKbF6CkuV7qIrLQIPoAepHEpanrQQ=="],
|
||||
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.108.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-XBnl7Nszpbzg0aLnOCmdBi0bOU5goAsQ/L+NPNiuUPowDj8Mbzx0vlIIc1M79BjIvmw5nUM5G3jbrCBStT/0fQ=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.170",
|
||||
"@anthropic-ai/sdk": "^0.100.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.197",
|
||||
"@anthropic-ai/sdk": "^0.108.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"cron-parser": "^5.0.0",
|
||||
"zod": "^4.0.0"
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
/**
|
||||
* Per-batch context the poll loop publishes for downstream consumers
|
||||
* (MCP tools, etc.) that don't sit on the poll-loop's call stack.
|
||||
*
|
||||
* Today the only field is `inReplyTo` — the id of the first inbound
|
||||
* message in the batch the agent is currently processing. MCP tools like
|
||||
* `send_message` and `send_file` read this and stamp it onto the outbound
|
||||
* row so the host's a2a return-path routing can correlate replies back to
|
||||
* the originating session.
|
||||
*
|
||||
* This is module-level state on purpose: the agent-runner is single-process
|
||||
* and processes one batch at a time. Poll-loop calls `setCurrentInReplyTo`
|
||||
* before invoking the provider and `clearCurrentInReplyTo` after the batch
|
||||
* completes (or errors out).
|
||||
*/
|
||||
let currentInReplyTo: string | null = null;
|
||||
|
||||
export function setCurrentInReplyTo(id: string | null): void {
|
||||
currentInReplyTo = id;
|
||||
}
|
||||
|
||||
export function clearCurrentInReplyTo(): void {
|
||||
currentInReplyTo = null;
|
||||
}
|
||||
|
||||
export function getCurrentInReplyTo(): string | null {
|
||||
return currentInReplyTo;
|
||||
}
|
||||
|
||||
@@ -48,7 +48,11 @@ export function openInboundDb(): Database {
|
||||
// so the singleton survives for the rest of the test.
|
||||
if (_testMode && _inbound) {
|
||||
const db = _inbound;
|
||||
return { prepare: (sql: string) => db.prepare(sql), exec: (sql: string) => db.exec(sql), close: () => {} } as unknown as Database;
|
||||
return {
|
||||
prepare: (sql: string) => db.prepare(sql),
|
||||
exec: (sql: string) => db.exec(sql),
|
||||
close: () => {},
|
||||
} as unknown as Database;
|
||||
}
|
||||
const db = new Database(DEFAULT_INBOUND_PATH, { readonly: true });
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
@@ -260,11 +264,3 @@ export function closeSessionDb(): void {
|
||||
_outbound?.close();
|
||||
_outbound = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use getInboundDb() / getOutboundDb() instead.
|
||||
* Kept for backward compatibility during migration.
|
||||
*/
|
||||
export function getSessionDb(): Database {
|
||||
return getInboundDb();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export {
|
||||
getInboundDb,
|
||||
getOutboundDb,
|
||||
getSessionDb,
|
||||
initTestSessionDb,
|
||||
closeSessionDb,
|
||||
touchHeartbeat,
|
||||
|
||||
@@ -56,7 +56,7 @@ function getMaxMessagesPerPrompt(): number {
|
||||
* Reads from inbound.db (read-only), filters against processing_ack in outbound.db
|
||||
* to skip messages already picked up by this or a previous container run.
|
||||
*
|
||||
* Returns the most recent `MAX_MESSAGES_PER_PROMPT` pending rows in
|
||||
* Returns the most recent `maxMessagesPerPrompt` pending rows in
|
||||
* chronological order, regardless of their `trigger` flag: accumulated
|
||||
* context (trigger=0) rides along with the wake-eligible rows so the agent
|
||||
* sees the prior context it missed. Host's countDueMessages gates waking on
|
||||
@@ -163,4 +163,3 @@ export function findQuestionResponse(questionId: string): MessageInRow | undefin
|
||||
inbound.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -77,3 +77,47 @@ export function setContinuation(providerName: string, id: string): void {
|
||||
export function clearContinuation(providerName: string): void {
|
||||
deleteValue(continuationKey(providerName));
|
||||
}
|
||||
|
||||
/**
|
||||
* The a2a reply stamp: the id of the first inbound message in the batch the
|
||||
* agent is currently processing. The poll loop publishes it at batch start;
|
||||
* MCP tools (`send_message`, `send_file`) read it and stamp it onto outbound
|
||||
* rows so the host's a2a return-path routing can correlate replies back to
|
||||
* the originating session.
|
||||
*
|
||||
* This lives in outbound.db rather than module state because the MCP server
|
||||
* runs as a separate stdio subprocess from the poll loop — module state set
|
||||
* by the poll loop is invisible to it. Both processes open outbound.db
|
||||
* (journal_mode=DELETE + busy_timeout make intra-container access safe).
|
||||
*/
|
||||
const IN_REPLY_TO_KEY = 'current_in_reply_to';
|
||||
|
||||
/**
|
||||
* Ignore a stamp older than this. The poll loop clears the stamp in a
|
||||
* finally, but a container killed mid-batch (SIGKILL) can leave one behind;
|
||||
* the guard stops a later out-of-batch read from picking up a dead stamp.
|
||||
* Generous so a long-running batch's late sends still stamp correctly.
|
||||
*/
|
||||
const IN_REPLY_TO_MAX_AGE_MS = 30 * 60 * 1000;
|
||||
|
||||
export function setCurrentInReplyTo(id: string | null): void {
|
||||
if (id === null) {
|
||||
clearCurrentInReplyTo();
|
||||
return;
|
||||
}
|
||||
setValue(IN_REPLY_TO_KEY, id);
|
||||
}
|
||||
|
||||
export function clearCurrentInReplyTo(): void {
|
||||
deleteValue(IN_REPLY_TO_KEY);
|
||||
}
|
||||
|
||||
export function getCurrentInReplyTo(): string | null {
|
||||
const row = getOutboundDb()
|
||||
.prepare('SELECT value, updated_at FROM session_state WHERE key = ?')
|
||||
.get(IN_REPLY_TO_KEY) as { value: string; updated_at: string } | undefined;
|
||||
if (!row) return null;
|
||||
const age = Date.now() - new Date(row.updated_at).getTime();
|
||||
if (!Number.isFinite(age) || age > IN_REPLY_TO_MAX_AGE_MS) return null;
|
||||
return row.value;
|
||||
}
|
||||
|
||||
@@ -4,14 +4,31 @@
|
||||
* batch in poll-loop, and outbound writes from MCP tools (send_message,
|
||||
* send_file) must pick it up so a2a return-path routing on the host can
|
||||
* correlate replies back to the originating session.
|
||||
*
|
||||
* The stamp is published through session_state in outbound.db, not module
|
||||
* state — the MCP server runs as a separate stdio subprocess from the poll
|
||||
* loop, so it can only see the stamp through the shared DB. These tests seed
|
||||
* it the same way the poll-loop process does (a direct DB write) rather than
|
||||
* via any in-memory helper, so they exercise the real process boundary.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
|
||||
import { initTestSessionDb, closeSessionDb, getInboundDb } from '../db/connection.js';
|
||||
import { initTestSessionDb, closeSessionDb, getInboundDb, getOutboundDb } from '../db/connection.js';
|
||||
import { getUndeliveredMessages } from '../db/messages-out.js';
|
||||
import { setCurrentInReplyTo, clearCurrentInReplyTo } from '../current-batch.js';
|
||||
import { sendMessage } from './core.js';
|
||||
|
||||
/**
|
||||
* Publish the a2a reply stamp the way the poll loop does: a direct write to
|
||||
* session_state in outbound.db. `ageMs` back-dates updated_at to exercise the
|
||||
* staleness guard MCP tools apply when reading it.
|
||||
*/
|
||||
function publishInReplyTo(id: string, ageMs = 0): void {
|
||||
const updatedAt = new Date(Date.now() - ageMs).toISOString();
|
||||
getOutboundDb()
|
||||
.prepare('INSERT OR REPLACE INTO session_state (key, value, updated_at) VALUES (?, ?, ?)')
|
||||
.run('current_in_reply_to', id, updatedAt);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
initTestSessionDb();
|
||||
// Seed a peer agent destination
|
||||
@@ -24,13 +41,12 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearCurrentInReplyTo();
|
||||
closeSessionDb();
|
||||
});
|
||||
|
||||
describe('send_message MCP tool — in_reply_to plumbing', () => {
|
||||
it('stamps current batch in_reply_to on outbound rows', async () => {
|
||||
setCurrentInReplyTo('inbound-msg-1');
|
||||
it('stamps the batch in_reply_to (published via the DB) on outbound rows', async () => {
|
||||
publishInReplyTo('inbound-msg-1');
|
||||
|
||||
await sendMessage.handler({ to: 'peer', text: 'hello' });
|
||||
|
||||
@@ -40,7 +56,17 @@ describe('send_message MCP tool — in_reply_to plumbing', () => {
|
||||
});
|
||||
|
||||
it('writes null when no batch is active', async () => {
|
||||
// No setCurrentInReplyTo before this call — simulates ad-hoc / out-of-batch invocation.
|
||||
// Nothing published to session_state — simulates ad-hoc / out-of-batch invocation.
|
||||
await sendMessage.handler({ to: 'peer', text: 'hello' });
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].in_reply_to).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores a stale stamp left behind by a killed container', async () => {
|
||||
publishInReplyTo('inbound-msg-1', 60 * 60 * 1000); // an hour old
|
||||
|
||||
await sendMessage.handler({ to: 'peer', text: 'hello' });
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { getCurrentInReplyTo } from '../current-batch.js';
|
||||
import { findByName, getAllDestinations } from '../destinations.js';
|
||||
import { getMessageIdBySeq, getRoutingBySeq, writeMessageOut } from '../db/messages-out.js';
|
||||
import { getCurrentInReplyTo } from '../db/session-state.js';
|
||||
import { getSessionRouting } from '../db/session-routing.js';
|
||||
import { registerTools } from './server.js';
|
||||
import type { McpToolDefinition } from './types.js';
|
||||
|
||||
@@ -4,8 +4,9 @@ import { initTestSessionDb, closeSessionDb, getInboundDb, getOutboundDb } from '
|
||||
import { getPendingMessages, markCompleted } from './db/messages-in.js';
|
||||
import { getUndeliveredMessages } from './db/messages-out.js';
|
||||
import { formatMessages, extractRouting } from './formatter.js';
|
||||
import { isCorruptionError } from './poll-loop.js';
|
||||
import { isCorruptionError, processQuery } from './poll-loop.js';
|
||||
import { MockProvider } from './providers/mock.js';
|
||||
import type { AgentQuery, ProviderEvent } from './providers/types.js';
|
||||
|
||||
beforeEach(() => {
|
||||
initTestSessionDb();
|
||||
@@ -379,6 +380,64 @@ describe('end-to-end with mock provider', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Build a one-shot stub query that yields init + a single result event, then
|
||||
* ends. `pushes` records any follow-ups the loop tried to inject (e.g. the
|
||||
* re-wrap nudge), so a test can assert the loop did NOT re-hammer.
|
||||
*/
|
||||
function makeResultQuery(result: ProviderEvent): { query: AgentQuery; pushes: string[] } {
|
||||
const pushes: string[] = [];
|
||||
async function* events(): AsyncGenerator<ProviderEvent> {
|
||||
yield { type: 'init', continuation: 'sess-1' };
|
||||
yield result;
|
||||
}
|
||||
return {
|
||||
pushes,
|
||||
query: {
|
||||
push: (m: string) => {
|
||||
pushes.push(m);
|
||||
},
|
||||
end: () => {},
|
||||
events: events(),
|
||||
abort: () => {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const ERR_ROUTING = {
|
||||
platformId: 'chan-1',
|
||||
channelType: 'discord',
|
||||
threadId: null,
|
||||
inReplyTo: 'm1',
|
||||
};
|
||||
|
||||
describe('error result with no <message> envelope', () => {
|
||||
it('delivers a budget/billing error to the triggering channel and does not nudge', async () => {
|
||||
const budgetText = 'Spending limit reached. Add your own key at https://example.com/keys';
|
||||
const { query, pushes } = makeResultQuery({ type: 'result', text: budgetText, isError: true });
|
||||
|
||||
await processQuery(query, ERR_ROUTING, ['m1'], 'claude', undefined, 'prompt', undefined);
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
expect(out).toHaveLength(1);
|
||||
expect(JSON.parse(out[0].content).text).toBe(budgetText);
|
||||
expect(out[0].platform_id).toBe('chan-1');
|
||||
expect(out[0].channel_type).toBe('discord');
|
||||
// No re-wrap nudge — an error result must not re-hammer the gateway.
|
||||
expect(pushes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('still nudges (and does not deliver) a normal unwrapped result', async () => {
|
||||
const { query, pushes } = makeResultQuery({ type: 'result', text: 'bare text, no envelope' });
|
||||
|
||||
await processQuery(query, ERR_ROUTING, ['m1'], 'claude', undefined, 'prompt', undefined);
|
||||
|
||||
expect(getUndeliveredMessages()).toHaveLength(0);
|
||||
expect(pushes).toHaveLength(1);
|
||||
expect(pushes[0]).toContain('was not delivered');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCorruptionError', () => {
|
||||
it('matches the Docker Desktop macOS torn-read symptom', () => {
|
||||
expect(isCorruptionError('database disk image is malformed')).toBe(true);
|
||||
|
||||
@@ -2,8 +2,13 @@ import { findByName, getAllDestinations, type DestinationEntry } from './destina
|
||||
import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } from './db/messages-in.js';
|
||||
import { writeMessageOut } from './db/messages-out.js';
|
||||
import { getInboundDb, touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
|
||||
import { clearContinuation, migrateLegacyContinuation, setContinuation } from './db/session-state.js';
|
||||
import { clearCurrentInReplyTo, setCurrentInReplyTo } from './current-batch.js';
|
||||
import {
|
||||
clearContinuation,
|
||||
clearCurrentInReplyTo,
|
||||
migrateLegacyContinuation,
|
||||
setContinuation,
|
||||
setCurrentInReplyTo,
|
||||
} from './db/session-state.js';
|
||||
import {
|
||||
formatMessages,
|
||||
extractRouting,
|
||||
@@ -323,7 +328,7 @@ interface QueryResult {
|
||||
continuation?: string;
|
||||
}
|
||||
|
||||
async function processQuery(
|
||||
export async function processQuery(
|
||||
query: AgentQuery,
|
||||
routing: RoutingContext,
|
||||
initialBatchIds: string[],
|
||||
@@ -482,28 +487,43 @@ async function processQuery(
|
||||
// at all — either way the turn is finished.
|
||||
markCompleted(initialBatchIds);
|
||||
if (event.text) {
|
||||
const { hasUnwrapped } = dispatchResultText(event.text, routing);
|
||||
const willRetryWrapping = hasUnwrapped && !unwrappedNudged;
|
||||
notifyExchangeComplete(onExchangeComplete, {
|
||||
prompt: archivePrompts[0] ?? initialPrompt,
|
||||
result: event.text,
|
||||
continuation: queryContinuation ?? initialContinuation,
|
||||
status: hasUnwrapped ? 'undelivered' : 'completed',
|
||||
});
|
||||
if (willRetryWrapping) {
|
||||
unwrappedNudged = true;
|
||||
const destinations = getAllDestinations();
|
||||
const names = destinations.map((d) => d.name).join(', ');
|
||||
query.push(
|
||||
`<system>Your response was not delivered — it was not wrapped in <message to="name">...</message> blocks. ` +
|
||||
`All output must be wrapped: use <message to="name"> for content to send, or <internal> for scratchpad. ` +
|
||||
`Your destinations: ${names}. ` +
|
||||
`Please re-send your response with the correct wrapping.</system>`,
|
||||
);
|
||||
const { sent, hasUnwrapped } = dispatchResultText(event.text, routing);
|
||||
if (sent === 0 && event.isError === true) {
|
||||
// Non-retryable error turn (e.g. a 403 billing_error) with no
|
||||
// <message> envelope: deliver the notice instead of dropping it as
|
||||
// scratchpad, and skip the re-wrap nudge — it would just re-hammer
|
||||
// the failing gateway turn after turn.
|
||||
deliverErrorResult(event.text, routing);
|
||||
notifyExchangeComplete(onExchangeComplete, {
|
||||
prompt: archivePrompts[0] ?? initialPrompt,
|
||||
result: event.text,
|
||||
continuation: queryContinuation ?? initialContinuation,
|
||||
status: 'error',
|
||||
});
|
||||
archivePrompts.shift();
|
||||
} else {
|
||||
const willRetryWrapping = hasUnwrapped && !unwrappedNudged;
|
||||
notifyExchangeComplete(onExchangeComplete, {
|
||||
prompt: archivePrompts[0] ?? initialPrompt,
|
||||
result: event.text,
|
||||
continuation: queryContinuation ?? initialContinuation,
|
||||
status: hasUnwrapped ? 'undelivered' : 'completed',
|
||||
});
|
||||
if (willRetryWrapping) {
|
||||
unwrappedNudged = true;
|
||||
const destinations = getAllDestinations();
|
||||
const names = destinations.map((d) => d.name).join(', ');
|
||||
query.push(
|
||||
`<system>Your response was not delivered — it was not wrapped in <message to="name">...</message> blocks. ` +
|
||||
`All output must be wrapped: use <message to="name"> for content to send, or <internal> for scratchpad. ` +
|
||||
`Your destinations: ${names}. ` +
|
||||
`Please re-send your response with the correct wrapping.</system>`,
|
||||
);
|
||||
}
|
||||
// The wrapping-retry result answers the SAME user prompt — keep it
|
||||
// queued so the retry archives against it, not the nudge text.
|
||||
if (!willRetryWrapping) archivePrompts.shift();
|
||||
}
|
||||
// The wrapping-retry result answers the SAME user prompt — keep it
|
||||
// queued so the retry archives against it, not the nudge text.
|
||||
if (!willRetryWrapping) archivePrompts.shift();
|
||||
} else {
|
||||
archivePrompts.shift();
|
||||
}
|
||||
@@ -557,6 +577,26 @@ function handleEvent(event: ProviderEvent, _routing: RoutingContext): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver a turn's text straight to the channel the batch arrived on. Used when
|
||||
* a turn ends in a provider error (e.g. a non-retryable 403 billing_error) with
|
||||
* no <message> envelope: the notice would otherwise be dropped as scratchpad.
|
||||
* This is the same user-facing write the outer catch block does, minus the
|
||||
* `Error:` prefix — the provider's text is already a user-facing message.
|
||||
*/
|
||||
function deliverErrorResult(text: string, routing: RoutingContext): void {
|
||||
log('Error result with no <message> envelope — delivering to channel');
|
||||
writeMessageOut({
|
||||
id: generateId(),
|
||||
in_reply_to: routing.inReplyTo,
|
||||
kind: 'chat',
|
||||
platform_id: routing.platformId,
|
||||
channel_type: routing.channelType,
|
||||
thread_id: routing.threadId,
|
||||
content: JSON.stringify({ text }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the agent's final text for <message to="name">...</message> blocks
|
||||
* and dispatch each one to its resolved destination. Text outside of blocks
|
||||
|
||||
@@ -440,8 +440,13 @@ export class ClaudeProvider implements AgentProvider {
|
||||
if (message.type === 'system' && message.subtype === 'init') {
|
||||
yield { type: 'init', continuation: message.session_id };
|
||||
} else if (message.type === 'result') {
|
||||
const text = 'result' in message ? (message as { result?: string }).result ?? null : null;
|
||||
yield { type: 'result', text };
|
||||
// `result` text exists only on subtype:"success"; error subtypes
|
||||
// (e.g. a non-retryable 403 billing_error) carry their message in
|
||||
// `errors[]` instead. Surface either so the poll-loop can deliver a
|
||||
// billing/quota notice to the user rather than dropping the turn.
|
||||
const m = message as { result?: string; is_error?: boolean; errors?: string[] };
|
||||
const text = m.result ?? (m.errors && m.errors.length > 0 ? m.errors.join('\n') : null);
|
||||
yield { type: 'result', text, isError: m.is_error === true };
|
||||
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'api_retry') {
|
||||
yield { type: 'error', message: 'API retry', retryable: true };
|
||||
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'rate_limit_event') {
|
||||
|
||||
@@ -3,4 +3,3 @@
|
||||
// level. Skills add a new provider by appending one import line below.
|
||||
|
||||
import './claude.js';
|
||||
import './mock.js';
|
||||
|
||||
@@ -125,7 +125,13 @@ export interface AgentQuery {
|
||||
|
||||
export type ProviderEvent =
|
||||
| { type: 'init'; continuation: string }
|
||||
| { type: 'result'; text: string | null }
|
||||
/**
|
||||
* A completed turn. `isError` is set when the underlying SDK flagged the
|
||||
* turn as an error (e.g. a non-retryable Anthropic 403 billing_error). The
|
||||
* poll-loop uses it to surface the result text to the user instead of
|
||||
* dropping it as un-wrapped scratchpad, and to skip the re-wrap nudge.
|
||||
*/
|
||||
| { type: 'result'; text: string | null; isError?: boolean }
|
||||
| { type: 'error'; message: string; retryable: boolean; classification?: string }
|
||||
| { type: 'progress'; message: string }
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[
|
||||
{ "name": "vercel", "version": "52.2.1" },
|
||||
{ "name": "agent-browser", "version": "0.27.1", "onlyBuilt": true },
|
||||
{ "name": "@anthropic-ai/claude-code", "version": "2.1.170", "onlyBuilt": true }
|
||||
{ "name": "@anthropic-ai/claude-code", "version": "2.1.197", "onlyBuilt": true }
|
||||
]
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
# Apple Container Networking Setup (macOS 26)
|
||||
|
||||
Apple Container's vmnet networking requires manual configuration for containers to access the internet. Without this, containers can communicate with the host but cannot reach external services (DNS, HTTPS, APIs).
|
||||
|
||||
## Quick Setup
|
||||
|
||||
Run these two commands (requires `sudo`):
|
||||
|
||||
```bash
|
||||
# 1. Enable IP forwarding so the host routes container traffic
|
||||
sudo sysctl -w net.inet.ip.forwarding=1
|
||||
|
||||
# 2. Enable NAT so container traffic gets masqueraded through your internet interface
|
||||
echo "nat on en0 from 192.168.64.0/24 to any -> (en0)" | sudo pfctl -ef -
|
||||
```
|
||||
|
||||
> **Note:** Replace `en0` with your active internet interface. Check with: `route get 8.8.8.8 | grep interface`
|
||||
|
||||
## Making It Persistent
|
||||
|
||||
These settings reset on reboot. To make them permanent:
|
||||
|
||||
**IP Forwarding** — add to `/etc/sysctl.conf`:
|
||||
```
|
||||
net.inet.ip.forwarding=1
|
||||
```
|
||||
|
||||
**NAT Rules** — add to `/etc/pf.conf` (before any existing rules):
|
||||
```
|
||||
nat on en0 from 192.168.64.0/24 to any -> (en0)
|
||||
```
|
||||
|
||||
Then reload: `sudo pfctl -f /etc/pf.conf`
|
||||
|
||||
## IPv6 DNS Issue
|
||||
|
||||
By default, DNS resolvers return IPv6 (AAAA) records before IPv4 (A) records. Since our NAT only handles IPv4, Node.js applications inside containers will try IPv6 first and fail.
|
||||
|
||||
The container image and runner are configured to prefer IPv4 via:
|
||||
```
|
||||
NODE_OPTIONS=--dns-result-order=ipv4first
|
||||
```
|
||||
|
||||
This is set both in the `Dockerfile` and passed via `-e` flag in `container-runner.ts`.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# Check IP forwarding is enabled
|
||||
sysctl net.inet.ip.forwarding
|
||||
# Expected: net.inet.ip.forwarding: 1
|
||||
|
||||
# Test container internet access
|
||||
container run --rm --entrypoint curl nanoclaw-agent:latest \
|
||||
-s4 --connect-timeout 5 -o /dev/null -w "%{http_code}" https://api.anthropic.com
|
||||
# Expected: 404
|
||||
|
||||
# Check bridge interface (only exists when a container is running)
|
||||
ifconfig bridge100
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---------|-------|-----|
|
||||
| `curl: (28) Connection timed out` | IP forwarding disabled | `sudo sysctl -w net.inet.ip.forwarding=1` |
|
||||
| HTTP works, HTTPS times out | IPv6 DNS resolution | Add `NODE_OPTIONS=--dns-result-order=ipv4first` |
|
||||
| `Could not resolve host` | DNS not forwarded | Check bridge100 exists, verify pfctl NAT rules |
|
||||
| Container hangs after output | Missing `process.exit(0)` in agent-runner | Rebuild container image |
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
Container VM (192.168.64.x)
|
||||
│
|
||||
├── eth0 → gateway 192.168.64.1
|
||||
│
|
||||
bridge100 (192.168.64.1) ← host bridge, created by vmnet when container runs
|
||||
│
|
||||
├── IP forwarding (sysctl) routes packets from bridge100 → en0
|
||||
│
|
||||
├── NAT (pfctl) masquerades 192.168.64.0/24 → en0's IP
|
||||
│
|
||||
en0 (your WiFi/Ethernet) → Internet
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [apple/container#469](https://github.com/apple/container/issues/469) — No network from container on macOS 26
|
||||
- [apple/container#656](https://github.com/apple/container/issues/656) — Cannot access internet URLs during building
|
||||
@@ -6,8 +6,5 @@ The files in this directory are original design documents and developer referenc
|
||||
|
||||
| This directory | Documentation site |
|
||||
|---|---|
|
||||
| [SPEC.md](SPEC.md) | [Architecture](https://docs.nanoclaw.dev/concepts/architecture) |
|
||||
| [SECURITY.md](SECURITY.md) | [Security model](https://docs.nanoclaw.dev/concepts/security) |
|
||||
| [REQUIREMENTS.md](REQUIREMENTS.md) | [Introduction](https://docs.nanoclaw.dev/introduction) |
|
||||
| [docker-sandboxes.md](docker-sandboxes.md) | [Docker Sandboxes](https://docs.nanoclaw.dev/advanced/docker-sandboxes) |
|
||||
| [APPLE-CONTAINER-NETWORKING.md](APPLE-CONTAINER-NETWORKING.md) | [Container runtime](https://docs.nanoclaw.dev/advanced/container-runtime) |
|
||||
|
||||
+106
-60
@@ -1,70 +1,106 @@
|
||||
# NanoClaw Security Model
|
||||
|
||||
> The canonical, continuously-verified version of this model lives at
|
||||
> [docs.nanoclaw.dev/concepts/security](https://docs.nanoclaw.dev/concepts/security).
|
||||
> This in-repo copy can drift; if the two disagree, verify against
|
||||
> `src/container-runner.ts` (`buildMounts`).
|
||||
|
||||
## Trust Model
|
||||
|
||||
Privilege is **user-level**, persisted in the `user_roles` table (owner /
|
||||
admin, global or scoped to an agent group) plus `agent_group_members` (the
|
||||
unprivileged access gate).
|
||||
|
||||
| Entity | Trust Level | Rationale |
|
||||
|--------|-------------|-----------|
|
||||
| Main group | Trusted | Private self-chat, admin control |
|
||||
| Non-main groups | Untrusted | Other users may be malicious |
|
||||
| Container agents | Sandboxed | Isolated execution environment |
|
||||
| Incoming messages | User input | Potential prompt injection |
|
||||
| Owners / admins (`user_roles`) | Trusted | Hold owner/admin roles; gate admin commands and approve credentialed actions |
|
||||
| Group members (`agent_group_members`) | Access-gated | Membership grants access to an agent group, but their messages are still untrusted input |
|
||||
| Unregistered senders | Untrusted | Subject to each messaging group's `unknown_sender_policy` |
|
||||
| Agent containers | Sandboxed | Long-lived per-session container; isolated by mounts, non-root, no host reach |
|
||||
| Incoming messages | User input | Potential prompt injection regardless of who sent them |
|
||||
|
||||
## Security Boundaries
|
||||
|
||||
### 1. Container Isolation (Primary Boundary)
|
||||
|
||||
Agents execute in containers (lightweight Linux VMs), providing:
|
||||
- **Process isolation** - Container processes cannot affect the host
|
||||
- **Filesystem isolation** - Only explicitly mounted directories are visible
|
||||
- **Non-root execution** - Runs as unprivileged `node` user (uid 1000)
|
||||
- **Ephemeral containers** - Fresh environment per invocation (`--rm`)
|
||||
Agents execute in containers (Docker), providing:
|
||||
- **Process isolation** — container processes cannot affect the host
|
||||
- **Filesystem isolation** — only explicitly mounted directories are visible
|
||||
- **Non-root execution** — runs as an unprivileged user (`node`, uid 1000, or the host uid remapped in)
|
||||
- **Per-session containers** — one long-lived container per session polls that session's DBs and handles many messages, then is torn down (`--rm`) when the session goes idle.
|
||||
|
||||
This is the primary security boundary. Rather than relying on application-level permission checks, the attack surface is limited by what's mounted.
|
||||
This is the primary security boundary. Rather than relying on application-level
|
||||
permission checks, the attack surface is limited by what's mounted.
|
||||
|
||||
### 2. Mount Security
|
||||
|
||||
**External Allowlist** - Mount permissions stored at `~/.config/nanoclaw/mount-allowlist.json`, which is:
|
||||
- Outside project root
|
||||
- Never mounted into containers
|
||||
- Cannot be modified by agents
|
||||
`buildMounts` (`src/container-runner.ts`) composes a fixed set of mounts per
|
||||
spawn. For the default (Claude) provider these are:
|
||||
|
||||
**Default Blocked Patterns:**
|
||||
| Container path | Host source | Mode | Purpose |
|
||||
|---|---|---|---|
|
||||
| `/workspace` | `data/v2-sessions/<group>/<session>/` | RW | Session folder — `inbound.db`, `outbound.db`, `outbox/`, `.claude/` |
|
||||
| `/workspace/agent` | `groups/<folder>/` | RW | Agent group working files + `CLAUDE.local.md` |
|
||||
| `/workspace/agent/container.json` | group `container.json` | RO | Container config — readable, not writable |
|
||||
| `/workspace/agent/CLAUDE.md` | composed `CLAUDE.md` | RO | Regenerated every spawn; agent edits would be clobbered |
|
||||
| `/workspace/agent/.claude-fragments` | group `.claude-fragments/` | RO | Composer skill/MCP fragments |
|
||||
| `/app/CLAUDE.md` | `container/CLAUDE.md` | RO | Shared base doc imported by the composed entry point |
|
||||
| `/home/node/.claude` | `data/v2-sessions/<group>/.claude-shared/` | RW | Claude state, settings, skill symlinks |
|
||||
| `/app/src` | `container/agent-runner/src/` | RO | Shared agent-runner source (same for all groups) |
|
||||
| `/app/skills` | `container/skills/` | RO | Shared container skills |
|
||||
| `/workspace/extra/<name>` | allowlisted host dir | RO (RW only if allowed) | Operator-configured additional mounts |
|
||||
|
||||
The config mounts (`container.json`, `CLAUDE.md`, `.claude-fragments`) are
|
||||
**nested read-only mounts on top of the read-write group dir** — the agent can
|
||||
read its config but cannot modify it. The project root is **never mounted**: the
|
||||
container only ever sees the paths above plus any provider-contributed mounts
|
||||
(e.g. an OpenCode XDG dir). Host application source (`src/`, `dist/`,
|
||||
`package.json`) is not reachable.
|
||||
|
||||
**Additional-mount allowlist** — extra mounts from a group's container config
|
||||
are validated against an allowlist at `~/.config/nanoclaw/mount-allowlist.json`,
|
||||
which is:
|
||||
- Outside the project root
|
||||
- Never mounted into containers
|
||||
- Not modifiable by agents
|
||||
|
||||
Its schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"allowedRoots": [
|
||||
{ "path": "~/projects", "allowReadWrite": true, "description": "Dev projects" },
|
||||
{ "path": "~/Documents/work", "allowReadWrite": false, "description": "Read-only" }
|
||||
],
|
||||
"blockedPatterns": ["password", "secret", "token"]
|
||||
}
|
||||
```
|
||||
.ssh, .gnupg, .aws, .azure, .gcloud, .kube, .docker,
|
||||
credentials, .env, .netrc, .npmrc, id_rsa, id_ed25519,
|
||||
|
||||
**Default blocked patterns** (merged with any in the file):
|
||||
```
|
||||
.ssh, .gnupg, .gpg, .aws, .azure, .gcloud, .kube, .docker,
|
||||
credentials, .env, .netrc, .npmrc, .pypirc, id_rsa, id_ed25519,
|
||||
private_key, .secret
|
||||
```
|
||||
|
||||
**Protections:**
|
||||
- Symlink resolution before validation (prevents traversal attacks)
|
||||
- Container path validation (rejects `..` and absolute paths)
|
||||
- `nonMainReadOnly` option forces read-only for non-main groups
|
||||
|
||||
**Read-Only Project Root:**
|
||||
|
||||
The main group's project root is mounted read-only. Writable paths the agent needs (store, group folder, IPC, `.claude/`) are mounted separately. This prevents the agent from modifying host application code (`src/`, `dist/`, `package.json`, etc.) which would bypass the sandbox entirely on next restart. The `store/` directory is mounted read-write so the main agent can access the SQLite database directly.
|
||||
**Enforcement** (`src/modules/mount-security/index.ts`):
|
||||
- **No allowlist file ⇒ every additional mount is blocked** — the fixed mounts above are unaffected, but nothing extra is granted until the operator creates the file.
|
||||
- Symlinks are resolved to their real path (`realpathSync`) before any check, defeating traversal via symlink.
|
||||
- The real path is rejected if it matches a blocked pattern, and rejected unless it sits under one of `allowedRoots`.
|
||||
- The container path is validated: relative, non-empty, no `..`, no leading `/`, no `:` (blocks Docker `-v` option injection). It is mounted under `/workspace/extra/`.
|
||||
- **Read-write is granted only when the mount requests it (`readonly: false`) *and* the matched root has `allowReadWrite: true`.** Otherwise the mount is forced read-only.
|
||||
|
||||
### 3. Session Isolation
|
||||
|
||||
Each group has isolated Claude sessions at `data/sessions/{group}/.claude/`:
|
||||
- Groups cannot see other groups' conversation history
|
||||
- Session data includes full message history and file contents read
|
||||
- Prevents cross-group information disclosure
|
||||
Per-session state lives under `data/v2-sessions/<agent-group>/<session>/`
|
||||
(`inbound.db`, `outbound.db`, `outbox/`, `.claude/`). Claude state
|
||||
(`.claude-shared`) and the working folder are scoped to the agent group, so:
|
||||
- Different agent groups cannot see each other's conversation history or files.
|
||||
- A group's sessions share that group's memory but keep separate message DBs.
|
||||
|
||||
### 4. IPC Authorization
|
||||
This prevents cross-group information disclosure.
|
||||
|
||||
Messages and task operations are verified against group identity:
|
||||
|
||||
| Operation | Main Group | Non-Main Group |
|
||||
|-----------|------------|----------------|
|
||||
| Send message to own chat | ✓ | ✓ |
|
||||
| Send message to other chats | ✓ | ✗ |
|
||||
| Schedule task for self | ✓ | ✓ |
|
||||
| Schedule task for others | ✓ | ✗ |
|
||||
| View all tasks | ✓ | Own only |
|
||||
| Manage other groups | ✓ | ✗ |
|
||||
|
||||
### 5. Credential Isolation (OneCLI Agent Vault)
|
||||
### 4. Credential Isolation (OneCLI Agent Vault)
|
||||
|
||||
Real API credentials **never enter containers**. NanoClaw uses [OneCLI's Agent Vault](https://github.com/onecli/onecli) to proxy outbound requests and inject credentials at the gateway level.
|
||||
|
||||
@@ -77,13 +113,12 @@ Real API credentials **never enter containers**. NanoClaw uses [OneCLI's Agent V
|
||||
**Per-agent policies:**
|
||||
Each NanoClaw group gets its own OneCLI agent identity. This allows different credential policies per group (e.g. your sales agent vs. support agent). OneCLI supports rate limits, and time-bound access and approval flows are on the roadmap.
|
||||
|
||||
**NOT Mounted:**
|
||||
- Channel auth sessions (`store/auth/`) — host only
|
||||
- Mount allowlist — external, never mounted
|
||||
- Any credentials matching blocked patterns
|
||||
- `.env` is shadowed with `/dev/null` in the project root mount
|
||||
**Never on the container filesystem:**
|
||||
- The project root and `.env` — never mounted; the container only receives the paths in the mount table above.
|
||||
- The mount allowlist — external (`~/.config/nanoclaw/…`), never mounted.
|
||||
- Real credentials — injected per request by the OneCLI gateway, never written into any mount.
|
||||
|
||||
### 6. Egress Lockdown (Forced Proxy)
|
||||
### 5. Egress Lockdown (Forced Proxy)
|
||||
|
||||
The `HTTPS_PROXY` env var only redirects *proxy-aware* clients — a tool that
|
||||
ignores it (or a raw socket) could reach the internet directly and bypass
|
||||
@@ -111,31 +146,42 @@ no `host-gateway` route).
|
||||
exception: a heal failure there is logged but not fatal, since already-running
|
||||
agents stay on the internal net (no leak) until the gateway returns.
|
||||
|
||||
**Default: egress is open.** Lockdown is **off** unless you opt in; by default
|
||||
the agent reaches the OneCLI gateway over the host-gateway path and outbound
|
||||
traffic is not confined to the internal network.
|
||||
|
||||
**Configuration:**
|
||||
|
||||
| Env | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `NANOCLAW_EGRESS_LOCKDOWN` | `false` | Set `true` to opt in (otherwise the host-gateway path is used). Enabled automatically by `/add-golden-registry`. |
|
||||
| `NANOCLAW_EGRESS_LOCKDOWN` | `false` | Set `true` to opt in (otherwise the host-gateway path is used). |
|
||||
| `NANOCLAW_EGRESS_NETWORK` | `nanoclaw-egress` | Network name. |
|
||||
| `ONECLI_GATEWAY_CONTAINER` | `onecli` | Gateway container to attach. |
|
||||
|
||||
These variables are read from the **host process** environment (the service's
|
||||
environment / `.env`), not from inside the container. The agent container is
|
||||
started with only `TZ` and any provider-declared variables — host environment
|
||||
variables, including secrets, are never forwarded into the agent.
|
||||
|
||||
**⚠ Behavior when enabled:** with lockdown on, agents have **no direct
|
||||
internet** — all traffic must go through OneCLI. Proxy-aware clients (npm, pnpm,
|
||||
pip, curl, node/bun with the proxy env) are unaffected. Any workflow that relies
|
||||
on a **non-proxy-aware** tool reaching the internet directly will fail by design.
|
||||
Lockdown is **off by default**; opt in with `NANOCLAW_EGRESS_LOCKDOWN=true`.
|
||||
|
||||
## Privilege Comparison
|
||||
## Resource Limits
|
||||
|
||||
| Capability | Main Group | Non-Main Group |
|
||||
|------------|------------|----------------|
|
||||
| Project root access | `/workspace/project` (ro) | None |
|
||||
| Store (SQLite DB) | `/workspace/project/store` (rw) | None |
|
||||
| Group folder | `/workspace/group` (rw) | `/workspace/group` (rw) |
|
||||
| Global memory | Implicit via project | `/workspace/global` (ro) |
|
||||
| Additional mounts | Configurable | Read-only unless allowed |
|
||||
| Network access | Unrestricted | Unrestricted |
|
||||
| MCP tools | All | All |
|
||||
Per-container CPU and memory caps are **opt-in and unset by default** — a runaway
|
||||
agent is not throttled unless the operator configures a limit:
|
||||
|
||||
| Env | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `CONTAINER_CPU_LIMIT` | *(empty — unbounded)* | Passed to `--cpus` when set (e.g. `2`). |
|
||||
| `CONTAINER_MEMORY_LIMIT` | *(empty — unbounded)* | Passed to `--memory` when set (e.g. `8g`). |
|
||||
|
||||
Only `--memory` is a container-level cap; whether it's a *hard* cap depends on
|
||||
the host having no swap (a deployment concern). On a swapless host a runaway is
|
||||
OOM-killed at the limit.
|
||||
|
||||
## Security Architecture Diagram
|
||||
|
||||
@@ -149,7 +195,7 @@ Lockdown is **off by default**; opt in with `NANOCLAW_EGRESS_LOCKDOWN=true`.
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ HOST PROCESS (TRUSTED) │
|
||||
│ • Message routing │
|
||||
│ • IPC authorization │
|
||||
│ • Role / access checks (user_roles, agent_group_members) │
|
||||
│ • Mount validation (external allowlist) │
|
||||
│ • Container lifecycle │
|
||||
│ • OneCLI Agent Vault (injects credentials, enforces policies) │
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# NanoClaw Specification
|
||||
|
||||
> **⚠️ Historical v1 spec.** This document describes the original NanoClaw v1 architecture — the single `store/messages.db`, the file-based IPC watcher, the `task-scheduler.ts` loop, the `MAX_CONCURRENT_CONTAINERS` cap, and the `groups/{channel}_{name}/` folder convention. **None of these exist in v2.** v2 replaced them with the two-DB session split (`inbound.db`/`outbound.db`), the entity model (users → messaging groups → agent groups → sessions), and the system-action delivery path. Kept for reference only. For the current architecture start at [architecture.md](architecture.md) and the root [CLAUDE.md](../CLAUDE.md); the v1→v2 diff is in [v1-to-v2-changes.md](v1-to-v2-changes.md).
|
||||
|
||||
A personal Claude assistant with multi-channel support, persistent memory per conversation, scheduled tasks, and container-isolated agent execution.
|
||||
|
||||
---
|
||||
@@ -341,6 +343,12 @@ export const CONTAINER_IMAGE = process.env.CONTAINER_IMAGE || 'nanoclaw-agent:la
|
||||
export const CONTAINER_TIMEOUT = parseInt(process.env.CONTAINER_TIMEOUT || '1800000', 10); // 30min default
|
||||
export const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '1800000', 10); // 30min — keep container alive after last result
|
||||
export const MAX_CONCURRENT_CONTAINERS = Math.max(1, parseInt(process.env.MAX_CONCURRENT_CONTAINERS || '5', 10) || 5);
|
||||
// Per-container resource caps → `docker run --cpus/--memory`. Empty default =
|
||||
// no flag = unbounded (today's behavior). Opt in to bound a fleet sharing one
|
||||
// host: CONTAINER_CPU_LIMIT=2, CONTAINER_MEMORY_LIMIT=8g. Swap is a host concern
|
||||
// (run the host swapless to make --memory a hard cap); not managed here.
|
||||
export const CONTAINER_CPU_LIMIT = process.env.CONTAINER_CPU_LIMIT || '';
|
||||
export const CONTAINER_MEMORY_LIMIT = process.env.CONTAINER_MEMORY_LIMIT || '';
|
||||
|
||||
export const TRIGGER_PATTERN = new RegExp(`^@${ASSISTANT_NAME}\\b`, 'i');
|
||||
```
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user