mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-06 18:52:03 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 60fab764e3 | |||
| e38e63234e |
@@ -1 +0,0 @@
|
||||
../.claude/skills
|
||||
@@ -1,5 +1,41 @@
|
||||
{
|
||||
"sandbox": {
|
||||
"enabled": false
|
||||
},
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(bash setup.sh*)",
|
||||
"Bash(git remote *)",
|
||||
"Bash(pnpm exec tsx setup/index.ts*)",
|
||||
"Bash(pnpm exec tsx scripts/init-first-agent.ts*)",
|
||||
"Bash(pnpm install @chat-adapter/*)",
|
||||
"Bash(pnpm install chat-adapter-imessage*)",
|
||||
"Bash(pnpm install @bitbasti/chat-adapter-webex*)",
|
||||
"Bash(pnpm install @resend/chat-sdk-adapter*)",
|
||||
"Bash(pnpm install @whiskeysockets/baileys*)",
|
||||
"Bash(pnpm install @beeper/chat-adapter-matrix*)",
|
||||
"Bash(pnpm install @nanoco/nanoclaw-dashboard*)",
|
||||
"Bash(pnpm install --frozen-lockfile*)",
|
||||
"Bash(pnpm run build*)",
|
||||
"Bash(curl -fsSL onecli.sh*)",
|
||||
"Bash(onecli *)",
|
||||
"Bash(grep -q *)",
|
||||
"Bash(echo *>> .env)",
|
||||
"Bash(ls *)",
|
||||
"Bash(cat ~/.config/nanoclaw/*)",
|
||||
"Bash(tail *logs/*)",
|
||||
"Bash(launchctl *nanoclaw*)",
|
||||
"Bash(sqlite3 data/*)",
|
||||
"Bash(docker info*)",
|
||||
"Bash(docker logs *)",
|
||||
"Bash(mkdir -p *)",
|
||||
"Bash(cp .env *)",
|
||||
"Bash(rsync -a .claude/skills/*)",
|
||||
"Bash(head *)",
|
||||
"Bash(xattr *)",
|
||||
"Bash(find ~/.npm *)",
|
||||
"Bash(which onecli*)",
|
||||
"Bash(./container/build.sh*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
# Remove Atomic Chat
|
||||
|
||||
Idempotent — safe to run even if some steps were never applied.
|
||||
|
||||
## 1. Delete the copied files (both trees)
|
||||
|
||||
```bash
|
||||
rm -f container/agent-runner/src/atomic-chat-mcp-stdio.ts \
|
||||
container/agent-runner/src/atomic-chat-registration.test.ts \
|
||||
src/atomic-chat-env.ts \
|
||||
src/atomic-chat-wiring.test.ts
|
||||
```
|
||||
|
||||
## 2. Unregister the MCP server
|
||||
|
||||
In `container/agent-runner/src/index.ts`, remove the `atomic_chat: { … }` entry from the `mcpServers` object (leave `nanoclaw` and any other entries).
|
||||
|
||||
## 3. Revert the host-side edits in `src/container-runner.ts`
|
||||
|
||||
- Remove the `import { atomicChatEnvArgs } from './atomic-chat-env.js';` import.
|
||||
- Remove the `args.push(...atomicChatEnvArgs());` line that follows the `TZ` env line.
|
||||
- Restore the `container.stderr` logger to its single-line `log.debug(line, …)` form (remove the `[ATOMIC]` info-level branch).
|
||||
|
||||
## 4. Remove env vars
|
||||
|
||||
Remove the Atomic Chat block from `.env.example`, and the `ATOMIC_CHAT_*` lines from `.env` if you set them.
|
||||
|
||||
## 5. Rebuild and restart
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
pnpm run build && ./container/build.sh
|
||||
source setup/lib/install-slug.sh
|
||||
|
||||
# macOS
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
|
||||
|
||||
# Linux
|
||||
systemctl --user restart $(systemd_unit)
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
After removal, confirm the tool is gone — in a wired agent, asking it to "list atomic chat models" should report no such tool, and the logs should show no `[ATOMIC]` lines after the last restart:
|
||||
|
||||
```bash
|
||||
grep "\[ATOMIC\]" logs/nanoclaw.log | tail -5
|
||||
```
|
||||
@@ -1,253 +0,0 @@
|
||||
---
|
||||
name: add-atomic-chat-tool
|
||||
description: Add Atomic Chat MCP server so the container agent can call local models served by the Atomic Chat desktop app via its OpenAI-compatible API.
|
||||
---
|
||||
|
||||
# Add Atomic Chat Integration
|
||||
|
||||
This skill adds a stdio-based MCP server that exposes models running in the local [Atomic Chat](https://github.com/AtomicBot-ai/Atomic-Chat) desktop app as tools for the container agent. Claude remains the orchestrator but can offload work to local models served by Atomic Chat on `http://127.0.0.1:1337/v1` (OpenAI-compatible).
|
||||
|
||||
Tools exposed:
|
||||
- `atomic_chat_list_models` — list models currently available in Atomic Chat (`GET /v1/models`)
|
||||
- `atomic_chat_generate` — send a prompt to a specified model and return the response (`POST /v1/chat/completions`)
|
||||
|
||||
Model management (download, delete) is done through the **Atomic Chat desktop UI** — the app is a fork of Jan and manages its own model library.
|
||||
|
||||
The skill ships the MCP server source (and its test) in this folder and copies them into the agent-runner tree at install time, then registers the server in `index.ts` and forwards host env vars in `container-runner.ts`. Registering the server is enough to expose its tools — the agent's allow-pattern (`mcp__atomic_chat__*`) is derived from the registered server name.
|
||||
|
||||
## Phase 1: Pre-flight
|
||||
|
||||
### Check if already applied
|
||||
|
||||
Check if `container/agent-runner/src/atomic-chat-mcp-stdio.ts` exists. If it does, skip to Phase 3 (Configure).
|
||||
|
||||
### Check prerequisites
|
||||
|
||||
Verify Atomic Chat is installed and its local API server is running. On the host:
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:1337/v1/models | head
|
||||
```
|
||||
|
||||
If the request fails:
|
||||
|
||||
1. Install Atomic Chat from the [latest release](https://github.com/AtomicBot-ai/Atomic-Chat/releases) (macOS only for now — `atomic-chat.dmg`).
|
||||
2. Open the app.
|
||||
3. Open **Settings → Local API Server** and make sure it's enabled on port `1337`.
|
||||
4. Go to the **Hub** (or **Models**) tab and download at least one model (e.g. Llama 3.2 3B, Qwen 2.5 Coder 7B).
|
||||
5. Load the model once by sending any message in Atomic Chat's UI to warm it up.
|
||||
|
||||
## Phase 2: Apply Code Changes
|
||||
|
||||
### Copy the skill's source and tests into both trees
|
||||
|
||||
This skill reaches into both the container (Bun) tree and the host (Node) tree, so its
|
||||
files go into both, alongside the integration points they cover.
|
||||
|
||||
```bash
|
||||
S=.claude/skills/add-atomic-chat-tool
|
||||
# Container (Bun) tree — the MCP server and the registration wiring test
|
||||
cp $S/atomic-chat-mcp-stdio.ts container/agent-runner/src/atomic-chat-mcp-stdio.ts
|
||||
cp $S/atomic-chat-registration.test.ts container/agent-runner/src/atomic-chat-registration.test.ts
|
||||
# Host (Node) tree — the env-forwarding helper and the wiring test
|
||||
cp $S/atomic-chat-env.ts src/atomic-chat-env.ts
|
||||
cp $S/atomic-chat-wiring.test.ts src/atomic-chat-wiring.test.ts
|
||||
```
|
||||
|
||||
### Register the MCP server in the agent-runner
|
||||
|
||||
Edit `container/agent-runner/src/index.ts`. Find the `mcpServers` object that currently looks like this:
|
||||
|
||||
```ts
|
||||
const mcpServers: Record<string, { command: string; args: string[]; env: Record<string, string> }> = {
|
||||
nanoclaw: {
|
||||
command: 'bun',
|
||||
args: ['run', mcpServerPath],
|
||||
env: {},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Add an `atomic_chat` entry alongside `nanoclaw`:
|
||||
|
||||
```ts
|
||||
const mcpServers: Record<string, { command: string; args: string[]; env: Record<string, string> }> = {
|
||||
nanoclaw: {
|
||||
command: 'bun',
|
||||
args: ['run', mcpServerPath],
|
||||
env: {},
|
||||
},
|
||||
atomic_chat: {
|
||||
command: 'bun',
|
||||
args: ['run', path.join(__dirname, 'atomic-chat-mcp-stdio.ts')],
|
||||
env: {
|
||||
...(process.env.ATOMIC_CHAT_HOST ? { ATOMIC_CHAT_HOST: process.env.ATOMIC_CHAT_HOST } : {}),
|
||||
...(process.env.ATOMIC_CHAT_API_KEY ? { ATOMIC_CHAT_API_KEY: process.env.ATOMIC_CHAT_API_KEY } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
`atomic-chat-registration.test.ts` asserts this entry is present and points at the server module — the tool only appears to the agent if it is registered here.
|
||||
|
||||
### Forward host env vars into the container
|
||||
|
||||
The env-forwarding logic lives in the copied `src/atomic-chat-env.ts` (`atomicChatEnvArgs()`), so the reach-in into `buildContainerArgs` is a single call.
|
||||
|
||||
Import it in `src/container-runner.ts` (alongside the other local imports):
|
||||
|
||||
```ts
|
||||
import { atomicChatEnvArgs } from './atomic-chat-env.js';
|
||||
```
|
||||
|
||||
Then, in `buildContainerArgs`, find the `TZ` env line and add the call right after it:
|
||||
|
||||
```ts
|
||||
args.push('-e', `TZ=${TIMEZONE}`);
|
||||
args.push(...atomicChatEnvArgs());
|
||||
```
|
||||
|
||||
`atomic-chat-wiring.test.ts` asserts this `args.push(...atomicChatEnvArgs())` call exists inside `buildContainerArgs`.
|
||||
|
||||
### Surface `[ATOMIC]` log lines at info level
|
||||
|
||||
> **Shared block.** This rewrites the `container.stderr` logger, which other local-model tools (e.g. `add-ollama-tool` for `[OLLAMA]`) also edit to surface their own prefix. Touch only the `[ATOMIC]` branch and leave the rest of the block intact, so the edits coexist and removal restores it cleanly.
|
||||
|
||||
In the same file, find the stderr logger:
|
||||
|
||||
```ts
|
||||
container.stderr?.on('data', (data) => {
|
||||
for (const line of data.toString().trim().split('\n')) {
|
||||
if (line) log.debug(line, { container: agentGroup.folder });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Replace it with:
|
||||
|
||||
```ts
|
||||
container.stderr?.on('data', (data) => {
|
||||
for (const line of data.toString().trim().split('\n')) {
|
||||
if (!line) continue;
|
||||
if (line.includes('[ATOMIC]')) {
|
||||
log.info(line, { container: agentGroup.folder });
|
||||
} else {
|
||||
log.debug(line, { container: agentGroup.folder });
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Add env-var stubs to `.env.example`
|
||||
|
||||
Append to `.env.example`:
|
||||
|
||||
```bash
|
||||
# Atomic Chat MCP tool (.claude/skills/add-atomic-chat-tool)
|
||||
# Override the host where Atomic Chat exposes its OpenAI-compatible API.
|
||||
# Default: http://host.docker.internal:1337 (with fallback to localhost)
|
||||
# ATOMIC_CHAT_HOST=http://host.docker.internal:1337
|
||||
|
||||
# Optional API key. Leave unset for a local Atomic Chat install — it does not require auth.
|
||||
# ATOMIC_CHAT_API_KEY=
|
||||
```
|
||||
|
||||
### Validate code changes
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit
|
||||
# Host tree: buildContainerArgs wiring
|
||||
pnpm exec vitest run src/atomic-chat-wiring.test.ts
|
||||
# Container tree: index.ts registration
|
||||
(cd container/agent-runner && bun test src/atomic-chat-registration.test.ts)
|
||||
./container/build.sh
|
||||
```
|
||||
|
||||
All must be clean before proceeding. The wiring and registration tests confirm the two
|
||||
integration points — the `buildContainerArgs` call and the `index.ts` registration — are
|
||||
actually in place; a failure means one drifted. (The MCP server's own request/response
|
||||
behavior against Atomic Chat is the author's build-time concern, not part of these tests —
|
||||
verify it manually in Phase 4.)
|
||||
|
||||
## Phase 3: Configure
|
||||
|
||||
### Set Atomic Chat host (optional)
|
||||
|
||||
By default, the MCP server connects to `http://host.docker.internal:1337` (Docker Desktop) with a fallback to `localhost`. To use a custom host, add to `.env`:
|
||||
|
||||
```bash
|
||||
ATOMIC_CHAT_HOST=http://your-atomic-chat-host:1337
|
||||
```
|
||||
|
||||
### Set API key (optional)
|
||||
|
||||
Atomic Chat does **not require authentication** when running locally — leave this unset. Only set it if you've put Atomic Chat behind a reverse proxy that enforces auth:
|
||||
|
||||
```bash
|
||||
ATOMIC_CHAT_API_KEY=sk-...
|
||||
```
|
||||
|
||||
### Restart the service
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
source setup/lib/install-slug.sh
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
# Linux: systemctl --user restart $(systemd_unit)
|
||||
```
|
||||
|
||||
## Phase 4: Verify
|
||||
|
||||
### Test inference
|
||||
|
||||
Tell the user:
|
||||
|
||||
> Send a message like: "use atomic chat to tell me the capital of France"
|
||||
>
|
||||
> The agent should use `atomic_chat_list_models` to find available models, then `atomic_chat_generate` to get a response.
|
||||
|
||||
### Check logs if needed
|
||||
|
||||
```bash
|
||||
tail -f logs/nanoclaw.log | grep -i atomic
|
||||
```
|
||||
|
||||
Look for:
|
||||
- `[ATOMIC] Listing models...` — list request started
|
||||
- `[ATOMIC] Found N models` — models discovered
|
||||
- `[ATOMIC] >>> Generating with <model>` — generation started
|
||||
- `[ATOMIC] <<< Done: <model> | Xs | N tokens | M chars` — generation completed
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Agent says "Atomic Chat is not installed" or tries to run a CLI
|
||||
|
||||
The agent is looking for a CLI that doesn't exist instead of using the MCP tools. This means:
|
||||
1. The MCP server wasn't copied — check `container/agent-runner/src/atomic-chat-mcp-stdio.ts` exists
|
||||
2. The MCP server wasn't registered — check `container/agent-runner/src/index.ts` has the `atomic_chat` entry in `mcpServers` (the allow-pattern is derived from this, so registration is the only thing to check)
|
||||
3. The container wasn't rebuilt — run `./container/build.sh`
|
||||
|
||||
### "Failed to connect to Atomic Chat"
|
||||
|
||||
1. Verify the host API is reachable: `curl http://127.0.0.1:1337/v1/models`
|
||||
2. Confirm the Local API Server is enabled in Atomic Chat's settings
|
||||
3. Check Docker can reach the host: `docker run --rm curlimages/curl curl -s http://host.docker.internal:1337/v1/models`
|
||||
4. If using a custom host, check `ATOMIC_CHAT_HOST` in `.env`
|
||||
|
||||
### `model not found` / 404 on generate
|
||||
|
||||
The model ID passed to `atomic_chat_generate` must exactly match one of the IDs returned by `atomic_chat_list_models`. Ask the agent to list models first, then pick one from that list.
|
||||
|
||||
### Slow first response
|
||||
|
||||
Atomic Chat lazy-loads models into memory on first use. The initial call may take longer while the model warms up. Subsequent calls against the same model are fast.
|
||||
|
||||
### Agent doesn't use Atomic Chat tools
|
||||
|
||||
The agent may not know about the tools. Try being explicit: "use the atomic_chat_generate tool with llama3.2-3b-instruct to answer: ..."
|
||||
|
||||
### Context window or output size issues
|
||||
|
||||
Atomic Chat respects each model's native context length. If you hit limits, pass `max_tokens` explicitly when calling `atomic_chat_generate`, or switch to a model with a larger context window in the Atomic Chat UI.
|
||||
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* Host-side env forwarding for the Atomic Chat MCP tool. Returns the Docker `-e`
|
||||
* arguments that pass any `ATOMIC_CHAT_*` host overrides into the container.
|
||||
*
|
||||
* Lives in its own file so the reach-in in `container-runner.ts` is a single call
|
||||
* (`args.push(...atomicChatEnvArgs())`) and this logic is behavior-testable in
|
||||
* isolation, without invoking the OneCLI-entangled `buildContainerArgs`.
|
||||
*/
|
||||
export function atomicChatEnvArgs(): string[] {
|
||||
const args: string[] = [];
|
||||
if (process.env.ATOMIC_CHAT_HOST) {
|
||||
args.push('-e', `ATOMIC_CHAT_HOST=${process.env.ATOMIC_CHAT_HOST}`);
|
||||
}
|
||||
if (process.env.ATOMIC_CHAT_API_KEY) {
|
||||
args.push('-e', `ATOMIC_CHAT_API_KEY=${process.env.ATOMIC_CHAT_API_KEY}`);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
/**
|
||||
* Atomic Chat MCP Server for NanoClaw
|
||||
* Exposes local Atomic Chat models (OpenAI-compatible, /v1) as tools for the container agent.
|
||||
* Uses host.docker.internal to reach the host's Atomic Chat desktop app from Docker.
|
||||
*/
|
||||
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const ATOMIC_CHAT_HOST =
|
||||
process.env.ATOMIC_CHAT_HOST || 'http://host.docker.internal:1337';
|
||||
const ATOMIC_CHAT_API_KEY = process.env.ATOMIC_CHAT_API_KEY || '';
|
||||
const ATOMIC_CHAT_STATUS_FILE = '/workspace/ipc/atomic_chat_status.json';
|
||||
|
||||
function log(msg: string): void {
|
||||
console.error(`[ATOMIC] ${msg}`);
|
||||
}
|
||||
|
||||
function writeStatus(status: string, detail?: string): void {
|
||||
try {
|
||||
const data = { status, detail, timestamp: new Date().toISOString() };
|
||||
const tmpPath = `${ATOMIC_CHAT_STATUS_FILE}.tmp`;
|
||||
fs.mkdirSync(path.dirname(ATOMIC_CHAT_STATUS_FILE), { recursive: true });
|
||||
fs.writeFileSync(tmpPath, JSON.stringify(data));
|
||||
fs.renameSync(tmpPath, ATOMIC_CHAT_STATUS_FILE);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
async function atomicFetch(
|
||||
apiPath: string,
|
||||
options?: RequestInit,
|
||||
): Promise<Response> {
|
||||
const url = `${ATOMIC_CHAT_HOST}${apiPath}`;
|
||||
const headers: Record<string, string> = {
|
||||
...((options?.headers as Record<string, string>) || {}),
|
||||
};
|
||||
if (ATOMIC_CHAT_API_KEY) {
|
||||
headers.Authorization = `Bearer ${ATOMIC_CHAT_API_KEY}`;
|
||||
}
|
||||
const finalOptions: RequestInit = { ...options, headers };
|
||||
try {
|
||||
return await fetch(url, finalOptions);
|
||||
} catch (err) {
|
||||
// Fallback to localhost if host.docker.internal fails
|
||||
if (ATOMIC_CHAT_HOST.includes('host.docker.internal')) {
|
||||
const fallbackUrl = url.replace('host.docker.internal', 'localhost');
|
||||
return await fetch(fallbackUrl, finalOptions);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const server = new McpServer({
|
||||
name: 'atomic_chat',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
server.tool(
|
||||
'atomic_chat_list_models',
|
||||
'List all models available in the local Atomic Chat desktop app. Use this to see which models are loaded before calling atomic_chat_generate.',
|
||||
{},
|
||||
async () => {
|
||||
log('Listing models...');
|
||||
writeStatus('listing', 'Listing available models');
|
||||
try {
|
||||
const res = await atomicFetch('/v1/models');
|
||||
if (!res.ok) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Atomic Chat API error: ${res.status} ${res.statusText}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
data?: Array<{ id: string; owned_by?: string }>;
|
||||
};
|
||||
const models = data.data || [];
|
||||
|
||||
if (models.length === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: 'No models available. Open Atomic Chat on the host and download a model from the Hub.',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const list = models
|
||||
.map((m) => `- ${m.id}${m.owned_by ? ` (${m.owned_by})` : ''}`)
|
||||
.join('\n');
|
||||
|
||||
log(`Found ${models.length} models`);
|
||||
return {
|
||||
content: [
|
||||
{ type: 'text' as const, text: `Available models:\n${list}` },
|
||||
],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Failed to connect to Atomic Chat at ${ATOMIC_CHAT_HOST}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'atomic_chat_generate',
|
||||
'Send a prompt to a local Atomic Chat model and get a response. Good for cheaper/faster tasks like summarization, translation, or general queries. Use atomic_chat_list_models first to see available models.',
|
||||
{
|
||||
model: z
|
||||
.string()
|
||||
.describe(
|
||||
'The model ID as returned by atomic_chat_list_models (e.g. "llama3.2-3b-instruct")',
|
||||
),
|
||||
prompt: z.string().describe('The prompt to send to the model'),
|
||||
system: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Optional system prompt to set model behavior'),
|
||||
temperature: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe('Sampling temperature (0.0–2.0). Defaults to model default.'),
|
||||
max_tokens: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe('Maximum number of tokens to generate in the response.'),
|
||||
},
|
||||
async (args) => {
|
||||
log(`>>> Generating with ${args.model} (${args.prompt.length} chars)...`);
|
||||
writeStatus('generating', `Generating with ${args.model}`);
|
||||
try {
|
||||
const messages: Array<{ role: string; content: string }> = [];
|
||||
if (args.system) {
|
||||
messages.push({ role: 'system', content: args.system });
|
||||
}
|
||||
messages.push({ role: 'user', content: args.prompt });
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
model: args.model,
|
||||
messages,
|
||||
stream: false,
|
||||
};
|
||||
if (args.temperature !== undefined) body.temperature = args.temperature;
|
||||
if (args.max_tokens !== undefined) body.max_tokens = args.max_tokens;
|
||||
|
||||
const startedAt = Date.now();
|
||||
const res = await atomicFetch('/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text();
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Atomic Chat error (${res.status}): ${errorText}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
choices?: Array<{ message?: { content?: string } }>;
|
||||
usage?: {
|
||||
prompt_tokens?: number;
|
||||
completion_tokens?: number;
|
||||
total_tokens?: number;
|
||||
};
|
||||
};
|
||||
|
||||
const response = data.choices?.[0]?.message?.content ?? '';
|
||||
const elapsedSec = ((Date.now() - startedAt) / 1000).toFixed(1);
|
||||
const completionTokens = data.usage?.completion_tokens;
|
||||
|
||||
const meta = `\n\n[${args.model} | ${elapsedSec}s${
|
||||
completionTokens !== undefined ? ` | ${completionTokens} tokens` : ''
|
||||
}]`;
|
||||
|
||||
log(
|
||||
`<<< Done: ${args.model} | ${elapsedSec}s | ${
|
||||
completionTokens ?? '?'
|
||||
} tokens | ${response.length} chars`,
|
||||
);
|
||||
writeStatus(
|
||||
'done',
|
||||
`${args.model} | ${elapsedSec}s | ${completionTokens ?? '?'} tokens`,
|
||||
);
|
||||
|
||||
return { content: [{ type: 'text' as const, text: response + meta }] };
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Failed to call Atomic Chat: ${err instanceof Error ? err.message : String(err)}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
@@ -1,65 +0,0 @@
|
||||
/**
|
||||
* Wiring test for the MCP-server registration integration point (container/Bun tree).
|
||||
*
|
||||
* The handlers are behavior-tested in atomic-chat-mcp-stdio.test.ts, but that does not
|
||||
* prove the server is registered — delete the index.ts entry and the tool simply never
|
||||
* appears, yet the handler test stays green. index.ts is the container boot entry and is
|
||||
* not cheaply invocable, so we assert the registration structurally: the `mcpServers`
|
||||
* object literal has an `atomic_chat` property whose command runs `atomic-chat-mcp-stdio.ts`.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import ts from 'typescript';
|
||||
|
||||
function sourceFile(): ts.SourceFile {
|
||||
const p = path.join(import.meta.dir, 'index.ts');
|
||||
return ts.createSourceFile(p, fs.readFileSync(p, 'utf8'), ts.ScriptTarget.Latest, true);
|
||||
}
|
||||
|
||||
/** Find the object literal assigned to `const mcpServers = { ... }`. */
|
||||
function mcpServersLiteral(sf: ts.SourceFile): ts.ObjectLiteralExpression | undefined {
|
||||
let found: ts.ObjectLiteralExpression | undefined;
|
||||
const visit = (node: ts.Node) => {
|
||||
if (
|
||||
ts.isVariableDeclaration(node) &&
|
||||
ts.isIdentifier(node.name) &&
|
||||
node.name.text === 'mcpServers' &&
|
||||
node.initializer &&
|
||||
ts.isObjectLiteralExpression(node.initializer)
|
||||
) {
|
||||
found = node.initializer;
|
||||
}
|
||||
if (!found) ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(sf);
|
||||
return found;
|
||||
}
|
||||
|
||||
function property(obj: ts.ObjectLiteralExpression, name: string): ts.PropertyAssignment | undefined {
|
||||
return obj.properties.find(
|
||||
(p): p is ts.PropertyAssignment =>
|
||||
ts.isPropertyAssignment(p) &&
|
||||
((ts.isIdentifier(p.name) && p.name.text === name) ||
|
||||
(ts.isStringLiteral(p.name) && p.name.text === name)),
|
||||
);
|
||||
}
|
||||
|
||||
describe('index.ts registers the atomic_chat MCP server', () => {
|
||||
const obj = mcpServersLiteral(sourceFile());
|
||||
|
||||
it('finds the mcpServers object literal', () => {
|
||||
expect(obj).toBeDefined();
|
||||
});
|
||||
|
||||
it('has an atomic_chat entry', () => {
|
||||
expect(obj && property(obj, 'atomic_chat')).toBeDefined();
|
||||
});
|
||||
|
||||
it('points atomic_chat at atomic-chat-mcp-stdio.ts', () => {
|
||||
const entry = obj && property(obj, 'atomic_chat');
|
||||
const text = entry ? entry.getText() : '';
|
||||
expect(text).toContain('atomic-chat-mcp-stdio.ts');
|
||||
});
|
||||
});
|
||||
@@ -1,69 +0,0 @@
|
||||
/**
|
||||
* Wiring test for the host-side env-forwarding integration point (host/vitest tree).
|
||||
*
|
||||
* The env helper is behavior-tested in atomic-chat-env.test.ts, but that does not prove
|
||||
* buildContainerArgs actually uses it — a direct unit test stays green even if the reach-in
|
||||
* is deleted. buildContainerArgs is entangled with OneCLI and not cheaply invocable, so we
|
||||
* assert the integration structurally: inside buildContainerArgs there is an
|
||||
* `args.push(...atomicChatEnvArgs())` call. Delete the reach-in and this goes red.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import ts from 'typescript';
|
||||
|
||||
function sourceFile(): ts.SourceFile {
|
||||
const p = path.resolve(process.cwd(), 'src/container-runner.ts');
|
||||
return ts.createSourceFile(p, fs.readFileSync(p, 'utf8'), ts.ScriptTarget.Latest, true);
|
||||
}
|
||||
|
||||
function findFunction(sf: ts.SourceFile, name: string): ts.FunctionDeclaration | undefined {
|
||||
let found: ts.FunctionDeclaration | undefined;
|
||||
const visit = (node: ts.Node) => {
|
||||
if (ts.isFunctionDeclaration(node) && node.name?.text === name) found = node;
|
||||
if (!found) ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(sf);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Is this node `args.push(...atomicChatEnvArgs())`? */
|
||||
function isSpreadPushOfEnvArgs(node: ts.Node): boolean {
|
||||
if (!ts.isCallExpression(node)) return false;
|
||||
const callee = node.expression;
|
||||
if (
|
||||
!ts.isPropertyAccessExpression(callee) ||
|
||||
callee.name.text !== 'push' ||
|
||||
!ts.isIdentifier(callee.expression) ||
|
||||
callee.expression.text !== 'args'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return node.arguments.some(
|
||||
(arg) =>
|
||||
ts.isSpreadElement(arg) &&
|
||||
ts.isCallExpression(arg.expression) &&
|
||||
ts.isIdentifier(arg.expression.expression) &&
|
||||
arg.expression.expression.text === 'atomicChatEnvArgs',
|
||||
);
|
||||
}
|
||||
|
||||
describe('container-runner.ts wires in atomicChatEnvArgs', () => {
|
||||
const sf = sourceFile();
|
||||
const fn = findFunction(sf, 'buildContainerArgs');
|
||||
|
||||
it('finds buildContainerArgs', () => {
|
||||
expect(fn).toBeDefined();
|
||||
});
|
||||
|
||||
it('calls args.push(...atomicChatEnvArgs()) inside buildContainerArgs', () => {
|
||||
let wired = false;
|
||||
const visit = (node: ts.Node) => {
|
||||
if (isSpreadPushOfEnvArgs(node)) wired = true;
|
||||
if (!wired) ts.forEachChild(node, visit);
|
||||
};
|
||||
if (fn?.body) visit(fn.body);
|
||||
expect(wired).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,168 +0,0 @@
|
||||
---
|
||||
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).
|
||||
@@ -1,109 +0,0 @@
|
||||
# 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`.
|
||||
@@ -1,80 +0,0 @@
|
||||
// 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)) };
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
// 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') };
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -1,715 +0,0 @@
|
||||
// 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.
|
Before Width: | Height: | Size: 5.3 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 547 B |
@@ -1,11 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 570 B |
Binary file not shown.
|
Before Width: | Height: | Size: 6.5 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 18 KiB |
@@ -1,45 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,68 +0,0 @@
|
||||
// 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');
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -1,328 +0,0 @@
|
||||
/* 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; }
|
||||
}
|
||||
@@ -1,437 +0,0 @@
|
||||
// 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}`);
|
||||
});
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,75 +0,0 @@
|
||||
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'));
|
||||
});
|
||||
@@ -1,91 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
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/);
|
||||
});
|
||||
@@ -1,111 +0,0 @@
|
||||
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, []);
|
||||
});
|
||||
});
|
||||
@@ -1,111 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
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.
|
||||
@@ -1,57 +0,0 @@
|
||||
#!/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);
|
||||
@@ -1,61 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,75 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
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>/);
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,111 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,240 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
#!/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"
|
||||
@@ -1,60 +0,0 @@
|
||||
// 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 };
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
# Remove the Codex agent provider
|
||||
|
||||
Reverses every change `/add-codex` makes and returns every group to the default provider. Safe to run when partially installed — skip any step whose target is already absent.
|
||||
|
||||
## 1. Switch codex groups back to the default
|
||||
|
||||
List groups still on codex and switch each one (each group's `memory/` tree stays on disk and readable; run `/migrate-memory` per group if its memory should carry back to Claude — see [docs/provider-migration.md](../../docs/provider-migration.md)):
|
||||
|
||||
```bash
|
||||
ncl groups list
|
||||
# for each group whose config shows provider=codex:
|
||||
ncl groups config update --id <group-id> --provider claude
|
||||
ncl groups restart --id <group-id>
|
||||
```
|
||||
|
||||
## 2. Delete the barrel imports
|
||||
|
||||
Delete (do not comment out) the `import './codex.js';` line from each of:
|
||||
|
||||
- `src/providers/index.ts`
|
||||
- `container/agent-runner/src/providers/index.ts`
|
||||
- `setup/providers/index.ts`
|
||||
|
||||
## 3. Delete every copied file
|
||||
|
||||
```bash
|
||||
rm -f 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
|
||||
```
|
||||
|
||||
This skill itself (`.claude/skills/add-codex/`) stays — it ships with trunk so the provider can be re-added later.
|
||||
|
||||
`container/AGENTS.md` stays only if another installed provider uses agent surfaces; otherwise remove it too.
|
||||
|
||||
## 4. Remove the CLI manifest entry
|
||||
|
||||
Delete the `@openai/codex` entry from `container/cli-tools.json`:
|
||||
|
||||
```bash
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const file = "container/cli-tools.json";
|
||||
const tools = JSON.parse(fs.readFileSync(file, "utf8")).filter((t) => t.name !== "@openai/codex");
|
||||
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");
|
||||
'
|
||||
```
|
||||
|
||||
## 5. Vault secret (optional)
|
||||
|
||||
The ChatGPT/OpenAI secret in the OneCLI vault grants nothing once the provider is gone. To remove it: `onecli secrets list`, then `onecli secrets delete --id <id>` for the `chatgpt.com` / `api.openai.com` entry.
|
||||
|
||||
## 6. Rebuild and verify
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit
|
||||
./container/build.sh
|
||||
pnpm test
|
||||
cd container/agent-runner && bun test
|
||||
```
|
||||
|
||||
All suites green and `ncl groups list` showing no codex groups means the removal is complete. Restart the service (`launchctl kickstart -k gui/$(id -u)/<label>` on macOS, `systemctl --user restart <unit>` on Linux).
|
||||
@@ -1,144 +0,0 @@
|
||||
---
|
||||
name: add-codex
|
||||
description: Use Codex (OpenAI's codex app-server) as a full agent provider — planning, tool orchestration, MCP tools, server-side history, session resume — alongside or instead of Claude. ChatGPT subscription or OpenAI API key, vault-only via OneCLI. Per-group via `ncl groups config update --provider codex`. Distinct from using OpenAI as an MCP tool (where Claude remains the planner).
|
||||
---
|
||||
|
||||
# Codex agent provider
|
||||
|
||||
> Shortcut: `pnpm exec tsx setup/index.ts --step provider-auth codex` performs this whole install (manifest-driven from the providers branch: files, barrels, CLI manifest entry, image rebuild) plus auth in one command. The steps below are the same operations, for agent-driven or manual application.
|
||||
|
||||
NanoClaw selects each group's agent backend from `container_configs.provider` (default `claude`). This skill installs the Codex provider: copy the payload from the `providers` branch, append one import to each of the three provider barrels, add the pinned Codex CLI to the container manifest (`container/cli-tools.json`), rebuild, then run the vault auth walk-through.
|
||||
|
||||
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.
|
||||
|
||||
## Install
|
||||
|
||||
### Pre-flight
|
||||
|
||||
Check whether the payload is already wired (a prior apply, or a trunk that still carries it). All of these present means installed — skip to **Authenticate**:
|
||||
|
||||
- `src/providers/codex.ts` and `src/providers/codex-agents-md.ts`
|
||||
- `container/agent-runner/src/providers/codex.ts` and `codex-app-server.ts`
|
||||
- `setup/providers/codex.ts`
|
||||
- `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
|
||||
|
||||
```bash
|
||||
git fetch origin providers
|
||||
```
|
||||
|
||||
Copy each file with `git show origin/providers:<path> > <path>` (additive — never merge the branch):
|
||||
|
||||
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
|
||||
|
||||
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");
|
||||
}
|
||||
'
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit
|
||||
./container/build.sh
|
||||
```
|
||||
|
||||
### Restart the host
|
||||
|
||||
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
|
||||
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/
|
||||
cd container/agent-runner && bun test src/providers/
|
||||
```
|
||||
|
||||
The registration tests import only the real barrels — they go red if a barrel line is missing, a barrel fails to evaluate, or the payload is broken.
|
||||
|
||||
## 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
|
||||
pnpm exec tsx setup/index.ts --step provider-auth codex
|
||||
```
|
||||
|
||||
The same walk-through fresh installs get from the setup picker: ChatGPT subscription (browser login or device pairing) or an OpenAI API key, landed in the OneCLI vault. Idempotent — it short-circuits when a matching secret already exists. It finishes with the install check.
|
||||
|
||||
## Use it
|
||||
|
||||
Per group:
|
||||
|
||||
```bash
|
||||
ncl groups config update --id <group-id> --provider codex
|
||||
ncl groups restart --id <group-id>
|
||||
```
|
||||
|
||||
Switching is an operator action — run it from the host. Memory does NOT carry over automatically — each provider keeps its own store; run `/migrate-memory` to carry it across. See [docs/provider-migration.md](../../docs/provider-migration.md) for the carry-over table and rollback.
|
||||
|
||||
There is no install-wide default provider. Setup's provider picker sets codex on the first agent it creates; creation itself is provider-agnostic (no `--provider` flag — provider is a DB property). Any group switches afterward via `ncl groups config update --provider` as above.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Container dies at boot, channel silent:** `grep 'Container exited non-zero' logs/nanoclaw.error.log` — the `stderrTail` carries the reason (e.g. `Unknown provider: codex. Registered: claude` means the barrels aren't wired in the running build).
|
||||
- **In-channel `Error: spawn codex ENOENT` on every message:** the image predates the manifest entry — re-run `./container/build.sh`.
|
||||
- **Auth errors mid-conversation:** the vault secret is missing or stale — re-run `pnpm exec tsx setup/index.ts --step provider-auth codex` (subscription re-login updates the vault copy).
|
||||
@@ -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.-]+)?$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
---
|
||||
name: add-compact
|
||||
description: Add /compact command for manual context compaction. Solves context rot in long sessions by forwarding the SDK's built-in /compact slash command. Main-group or trusted sender only.
|
||||
---
|
||||
|
||||
# Add /compact Command
|
||||
|
||||
Adds a `/compact` session command that compacts conversation history to fight context rot in long-running sessions. Uses the Claude Agent SDK's built-in `/compact` slash command — no synthetic system prompts.
|
||||
|
||||
**Session contract:** `/compact` keeps the same logical session alive. The SDK returns a new session ID after compaction (via the `init` system message), which the agent-runner forwards to the orchestrator as `newSessionId`. No destructive reset occurs — the agent retains summarized context.
|
||||
|
||||
## Phase 1: Pre-flight
|
||||
|
||||
Check if `src/session-commands.ts` exists:
|
||||
|
||||
```bash
|
||||
test -f src/session-commands.ts && echo "Already applied" || echo "Not applied"
|
||||
```
|
||||
|
||||
If already applied, skip to Phase 3 (Verify).
|
||||
|
||||
## Phase 2: Apply Code Changes
|
||||
|
||||
Merge the skill branch:
|
||||
|
||||
```bash
|
||||
git fetch upstream skill/compact
|
||||
git merge upstream/skill/compact
|
||||
```
|
||||
|
||||
> **Note:** `upstream` is the remote pointing to `qwibitai/nanoclaw`. If using a different remote name, substitute accordingly.
|
||||
|
||||
This adds:
|
||||
- `src/session-commands.ts` (extract and authorize session commands)
|
||||
- `src/session-commands.test.ts` (unit tests for command parsing and auth)
|
||||
- Session command interception in `src/index.ts` (both `processGroupMessages` and `startMessageLoop`)
|
||||
- Slash command handling in `container/agent-runner/src/index.ts`
|
||||
|
||||
### Validate
|
||||
|
||||
```bash
|
||||
pnpm test
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
### Rebuild container
|
||||
|
||||
```bash
|
||||
./container/build.sh
|
||||
```
|
||||
|
||||
### Restart service
|
||||
|
||||
```bash
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
# Linux: systemctl --user restart nanoclaw
|
||||
```
|
||||
|
||||
## Phase 3: Verify
|
||||
|
||||
### Integration Test
|
||||
|
||||
1. Start NanoClaw in dev mode: `pnpm run dev`
|
||||
2. From the **main group** (self-chat), send exactly: `/compact`
|
||||
3. Verify:
|
||||
- The agent acknowledges compaction (e.g., "Conversation compacted.")
|
||||
- The session continues — send a follow-up message and verify the agent responds coherently
|
||||
- A conversation archive is written to `groups/{folder}/conversations/` (by the PreCompact hook)
|
||||
- Container logs show `Compact boundary observed` (confirms SDK actually compacted)
|
||||
- If `compact_boundary` was NOT observed, the response says "compact_boundary was not observed"
|
||||
4. From a **non-main group** as a non-admin user, send: `@<assistant> /compact`
|
||||
5. Verify:
|
||||
- The bot responds with "Session commands require admin access."
|
||||
- No compaction occurs, no container is spawned for the command
|
||||
6. From a **non-main group** as the admin (device owner / `is_from_me`), send: `@<assistant> /compact`
|
||||
7. Verify:
|
||||
- Compaction proceeds normally (same behavior as main group)
|
||||
8. While an **active container** is running for the main group, send `/compact`
|
||||
9. Verify:
|
||||
- The active container is signaled to close (authorized senders only — untrusted senders cannot kill in-flight work)
|
||||
- Compaction proceeds via a new container once the active one exits
|
||||
- The command is not dropped (no cursor race)
|
||||
10. Send a normal message, then `/compact`, then another normal message in quick succession (same polling batch):
|
||||
11. Verify:
|
||||
- Pre-compact messages are sent to the agent first (check container logs for two `runAgent` calls)
|
||||
- Compaction proceeds after pre-compact messages are processed
|
||||
- Messages **after** `/compact` in the batch are preserved (cursor advances to `/compact`'s timestamp only) and processed on the next poll cycle
|
||||
12. From a **non-main group** as a non-admin user, send `@<assistant> /compact`:
|
||||
13. Verify:
|
||||
- Denial message is sent ("Session commands require admin access.")
|
||||
- The `/compact` is consumed (cursor advanced) — it does NOT replay on future polls
|
||||
- Other messages in the same batch are also consumed (cursor is a high-water mark — this is an accepted tradeoff for the narrow edge case of denied `/compact` + other messages in the same polling interval)
|
||||
- No container is killed or interrupted
|
||||
14. From a **non-main group** (with `requiresTrigger` enabled) as a non-admin user, send bare `/compact` (no trigger prefix):
|
||||
15. Verify:
|
||||
- No denial message is sent (trigger policy prevents untrusted bot responses)
|
||||
- The `/compact` is consumed silently
|
||||
- Note: in groups where `requiresTrigger` is `false`, a denial message IS sent because the sender is considered reachable
|
||||
16. After compaction, verify **no auto-compaction** behavior — only manual `/compact` triggers it
|
||||
|
||||
### Validation on Fresh Clone
|
||||
|
||||
```bash
|
||||
git clone <your-fork> /tmp/nanoclaw-test
|
||||
cd /tmp/nanoclaw-test
|
||||
claude # then run /add-compact
|
||||
pnpm run build
|
||||
pnpm test
|
||||
./container/build.sh
|
||||
# Manual: send /compact from main group, verify compaction + continuation
|
||||
# Manual: send @<assistant> /compact from non-main as non-admin, verify denial
|
||||
# Manual: send @<assistant> /compact from non-main as admin, verify allowed
|
||||
# Manual: verify no auto-compaction behavior
|
||||
```
|
||||
|
||||
## Security Constraints
|
||||
|
||||
- **Main-group or trusted/admin sender only.** The main group is the user's private self-chat and is trusted (see `docs/SECURITY.md`). Non-main groups are untrusted — a careless or malicious user could wipe the agent's short-term memory. However, the device owner (`is_from_me`) is always trusted and can compact from any group.
|
||||
- **No auto-compaction.** This skill implements manual compaction only. Automatic threshold-based compaction is a separate concern and should be a separate skill.
|
||||
- **No config file.** NanoClaw's philosophy is customization through code changes, not configuration sprawl.
|
||||
- **Transcript archived before compaction.** The existing `PreCompact` hook in the agent-runner archives the full transcript to `conversations/` before the SDK compacts it.
|
||||
- **Session continues after compaction.** This is not a destructive reset. The conversation continues with summarized context.
|
||||
|
||||
## What This Does NOT Do
|
||||
|
||||
- No automatic compaction threshold (add separately if desired)
|
||||
- No `/clear` command (separate skill, separate semantics — `/clear` is a destructive reset)
|
||||
- No cross-group compaction (each group's session is isolated)
|
||||
- No changes to the container image, Dockerfile, or build script
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"Session commands require admin access"**: Only the device owner (`is_from_me`) or main-group senders can use `/compact`. Other users are denied.
|
||||
- **No compact_boundary in logs**: The SDK may not emit this event in all versions. Check the agent-runner logs for the warning message. Compaction may still have succeeded.
|
||||
- **Pre-compact failure**: If messages before `/compact` fail to process, the error message says "Failed to process messages before /compact." The cursor advances past sent output to prevent duplicates; `/compact` remains pending for the next attempt.
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: add-dashboard
|
||||
description: Add a monitoring dashboard to NanoClaw. Installs @nanoco/nanoclaw-dashboard and a pusher that sends periodic JSON snapshots.
|
||||
description: Add a monitoring dashboard to NanoClaw v2. Installs @nanoco/nanoclaw-dashboard and a pusher that sends periodic JSON snapshots.
|
||||
---
|
||||
|
||||
# /add-dashboard — NanoClaw Dashboard
|
||||
@@ -28,34 +28,61 @@ NanoClaw (pusher) Dashboard (npm package)
|
||||
pnpm install @nanoco/nanoclaw-dashboard
|
||||
```
|
||||
|
||||
### 2. Copy the pusher module and its tests
|
||||
### 2. Copy the pusher module
|
||||
|
||||
Copy all three resource files into `src/`. The tests ship with the skill and run against the composed project — they're how you confirm the skill works and is wired in correctly.
|
||||
Copy the resource file into src:
|
||||
|
||||
```
|
||||
.claude/skills/add-dashboard/resources/dashboard-pusher.ts → src/dashboard-pusher.ts
|
||||
.claude/skills/add-dashboard/resources/dashboard-pusher.test.ts → src/dashboard-pusher.test.ts
|
||||
.claude/skills/add-dashboard/resources/dashboard-wiring.test.ts → src/dashboard-wiring.test.ts
|
||||
.claude/skills/add-dashboard/resources/dashboard-pusher.ts → src/dashboard-pusher.ts
|
||||
```
|
||||
|
||||
- `dashboard-pusher.test.ts` — behavior: starts the pusher, posts a real snapshot to a fake dashboard.
|
||||
- `dashboard-wiring.test.ts` — the code edit in step 3: asserts (via the TS AST) that `index.ts` dynamically imports `./dashboard-pusher.js` and `await`s `startDashboard()` as colocated statements of `main()`, after DB init and before the boot-complete log. Delete or misplace the edit and this goes red.
|
||||
### 3. Add exports to src/db/index.ts
|
||||
|
||||
### 3. Wire into src/index.ts
|
||||
|
||||
This is the skill's one integration point, and it's deliberately minimal and self-contained: all the startup logic lives in `dashboard-pusher.ts`, and the import is **colocated** with the call so the whole edit is a single block in one place — there's no separate top-of-file import to add (or to remember to remove).
|
||||
|
||||
Add this block inside `main()`, just before the `log.info('NanoClaw running')` line:
|
||||
Add these two export blocks if not already present:
|
||||
|
||||
```typescript
|
||||
// Dashboard (optional; no-ops without DASHBOARD_SECRET)
|
||||
const { startDashboard } = await import('./dashboard-pusher.js');
|
||||
await startDashboard();
|
||||
// After the messaging-groups exports, add:
|
||||
export {
|
||||
getMessagingGroupsByAgentGroup,
|
||||
} from './messaging-groups.js';
|
||||
|
||||
// Before the credentials exports, add:
|
||||
export {
|
||||
createDestination,
|
||||
getDestinations,
|
||||
getDestinationByName,
|
||||
getDestinationByTarget,
|
||||
hasDestination,
|
||||
deleteDestination,
|
||||
} from './agent-destinations.js';
|
||||
```
|
||||
|
||||
`startDashboard()` reads `DASHBOARD_SECRET`/`DASHBOARD_PORT` itself and no-ops if the secret is unset, so nothing else in core needs to change.
|
||||
### 4. Wire into src/index.ts
|
||||
|
||||
### 4. Add environment variables to .env
|
||||
Add the `readEnvFile` import at the top if not already present:
|
||||
|
||||
```typescript
|
||||
import { readEnvFile } from './env.js';
|
||||
```
|
||||
|
||||
Add after step 7 (OneCLI approval handler), before the `log.info('NanoClaw v2 running')` line:
|
||||
|
||||
```typescript
|
||||
// 8. Dashboard (optional)
|
||||
const dashboardEnv = readEnvFile(['DASHBOARD_SECRET', 'DASHBOARD_PORT']);
|
||||
const dashboardSecret = process.env.DASHBOARD_SECRET || dashboardEnv.DASHBOARD_SECRET;
|
||||
const dashboardPort = parseInt(process.env.DASHBOARD_PORT || dashboardEnv.DASHBOARD_PORT || '3100', 10);
|
||||
if (dashboardSecret) {
|
||||
const { startDashboard } = await import('@nanoco/nanoclaw-dashboard');
|
||||
const { startDashboardPusher } = await import('./dashboard-pusher.js');
|
||||
startDashboard({ port: dashboardPort, secret: dashboardSecret });
|
||||
startDashboardPusher({ port: dashboardPort, secret: dashboardSecret, intervalMs: 60000 });
|
||||
} else {
|
||||
log.info('Dashboard disabled (no DASHBOARD_SECRET)');
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Add environment variables to .env
|
||||
|
||||
```
|
||||
DASHBOARD_SECRET=<generate-a-random-secret>
|
||||
@@ -64,23 +91,15 @@ DASHBOARD_PORT=3100
|
||||
|
||||
Generate the secret: `node -e "console.log('nc-' + require('crypto').randomBytes(16).toString('hex'))"`
|
||||
|
||||
### 5. Build, test, and restart
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
### 6. Build and restart
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
pnpm exec vitest run src/dashboard-pusher.test.ts src/dashboard-wiring.test.ts # behavior + wiring
|
||||
source setup/lib/install-slug.sh
|
||||
systemctl --user restart $(systemd_unit) # Linux
|
||||
# or: launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
systemctl --user restart nanoclaw # Linux
|
||||
# or: launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
```
|
||||
|
||||
Run `build` **before** the tests: it's what guards the `@nanoco/nanoclaw-dashboard` dependency. `dashboard-pusher.ts` reaches the package through `await import('@nanoco/nanoclaw-dashboard')`, so if step 4 was skipped, `pnpm run build` fails with `TS2307: Cannot find module`. The behavior test deliberately *mocks* that package — its `startDashboard` binds a real dashboard port, a side effect we don't want in a test — so the test alone would pass with the dependency missing. Build is therefore the leg that verifies the dependency is installed; keep it ahead of the tests in the validate step.
|
||||
|
||||
### 6. Verify (runtime smoke check)
|
||||
|
||||
Once the service is restarted, confirm the dashboard is live:
|
||||
### 7. Verify
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:3100/api/status
|
||||
@@ -110,15 +129,10 @@ Open `http://localhost:3100/dashboard` in a browser.
|
||||
|
||||
## Removal
|
||||
|
||||
Reverse the apply steps. Safe to re-run even if some pieces are already gone.
|
||||
|
||||
```bash
|
||||
rm -f src/dashboard-pusher.ts src/dashboard-pusher.test.ts src/dashboard-wiring.test.ts
|
||||
pnpm uninstall @nanoco/nanoclaw-dashboard 2>/dev/null || true
|
||||
```
|
||||
|
||||
Then, by hand, remove the single dashboard block the skill added to `main()` in `src/index.ts` (the `// Dashboard (optional…)` comment, the `await import('./dashboard-pusher.js')` line, and the `await startDashboard();` call), and remove `DASHBOARD_SECRET` and `DASHBOARD_PORT` from `.env`.
|
||||
|
||||
```bash
|
||||
pnpm uninstall @nanoco/nanoclaw-dashboard
|
||||
rm src/dashboard-pusher.ts
|
||||
# Remove the dashboard block from src/index.ts
|
||||
# Remove DASHBOARD_SECRET and DASHBOARD_PORT from .env
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
/**
|
||||
* Integration test for the add-dashboard skill's integration point —
|
||||
* `startDashboard()`, the single call wired into src/index.ts.
|
||||
*
|
||||
* Archetype: in-process seam. It drives the *real* entry point against a
|
||||
* *real* (in-memory) central DB and a *fake* dashboard HTTP endpoint. The
|
||||
* only things stubbed are the external dashboard package (not needed to prove
|
||||
* the wiring) and env-file reads (so the test doesn't depend on the real
|
||||
* .env). This proves the skill works once applied: with a secret set it
|
||||
* collects a DB snapshot and posts it; with no secret it does nothing.
|
||||
*
|
||||
* Ships with the add-dashboard skill; apply copies it to src/ alongside the
|
||||
* pusher so it runs against the composed project.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import http from 'http';
|
||||
import type { AddressInfo } from 'net';
|
||||
|
||||
vi.mock('./config.js', async () => {
|
||||
const actual = await vi.importActual<typeof import('./config.js')>('./config.js');
|
||||
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-dashboard', ASSISTANT_NAME: 'TestBot' };
|
||||
});
|
||||
// The dashboard server package isn't needed to prove the integration point.
|
||||
vi.mock('@nanoco/nanoclaw-dashboard', () => ({ startDashboard: vi.fn() }));
|
||||
// Don't read the real .env — the test controls config via process.env only.
|
||||
vi.mock('./env.js', () => ({ readEnvFile: () => ({}) }));
|
||||
|
||||
const TEST_DIR = '/tmp/nanoclaw-test-dashboard';
|
||||
|
||||
import { initTestDb, closeDb, runMigrations, createAgentGroup } from './db/index.js';
|
||||
import { startDashboard, stopDashboardPusher } from './dashboard-pusher.js';
|
||||
|
||||
function now(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
interface CapturedPost {
|
||||
path: string;
|
||||
auth: string | undefined;
|
||||
body: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** A fake dashboard server that captures the bodies the pusher POSTs. */
|
||||
function startFakeDashboard(): Promise<{ port: number; posts: CapturedPost[]; close: () => Promise<void> }> {
|
||||
const posts: CapturedPost[] = [];
|
||||
const server = http.createServer((req, res) => {
|
||||
let raw = '';
|
||||
req.on('data', (c) => { raw += c; });
|
||||
req.on('end', () => {
|
||||
let body: Record<string, unknown> = {};
|
||||
try { body = JSON.parse(raw); } catch { /* leave empty */ }
|
||||
posts.push({ path: req.url || '', auth: req.headers.authorization, body });
|
||||
res.writeHead(200);
|
||||
res.end('ok');
|
||||
});
|
||||
});
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const port = (server.address() as AddressInfo).port;
|
||||
resolve({ port, posts, close: () => new Promise<void>((r) => server.close(() => r())) });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function waitFor(pred: () => boolean, timeoutMs = 2000): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (!pred()) {
|
||||
if (Date.now() - start > timeoutMs) throw new Error('timed out waiting for condition');
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
}
|
||||
}
|
||||
|
||||
describe('add-dashboard integration point (startDashboard)', () => {
|
||||
beforeEach(() => {
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stopDashboardPusher();
|
||||
closeDb();
|
||||
delete process.env.DASHBOARD_SECRET;
|
||||
delete process.env.DASHBOARD_PORT;
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
it('posts a snapshot of the seeded state when DASHBOARD_SECRET is set', async () => {
|
||||
createAgentGroup({ id: 'ag-1', name: 'Test Agent', folder: 'test-agent', agent_provider: null, created_at: now() });
|
||||
|
||||
const dash = await startFakeDashboard();
|
||||
process.env.DASHBOARD_SECRET = 'test-secret';
|
||||
process.env.DASHBOARD_PORT = String(dash.port);
|
||||
|
||||
await startDashboard();
|
||||
|
||||
await waitFor(() => dash.posts.some((p) => p.path === '/api/ingest'));
|
||||
|
||||
const ingest = dash.posts.find((p) => p.path === '/api/ingest')!;
|
||||
expect(ingest.auth).toBe('Bearer test-secret');
|
||||
expect(ingest.body.assistant_name).toBe('TestBot');
|
||||
|
||||
const groups = ingest.body.agent_groups as Array<{ id: string }>;
|
||||
expect(groups.map((g) => g.id)).toContain('ag-1');
|
||||
|
||||
for (const key of ['timestamp', 'sessions', 'channels', 'users', 'tokens', 'context_windows', 'activity', 'messages']) {
|
||||
expect(ingest.body).toHaveProperty(key);
|
||||
}
|
||||
|
||||
await dash.close();
|
||||
});
|
||||
|
||||
it('does nothing when DASHBOARD_SECRET is not set', async () => {
|
||||
const dash = await startFakeDashboard();
|
||||
// no DASHBOARD_SECRET in env, and readEnvFile is stubbed to {}
|
||||
|
||||
await startDashboard();
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
expect(dash.posts).toHaveLength(0);
|
||||
await dash.close();
|
||||
});
|
||||
});
|
||||
@@ -10,17 +10,15 @@ import Database from 'better-sqlite3';
|
||||
import { getAllAgentGroups, getAgentGroup } from './db/agent-groups.js';
|
||||
import { getSessionsByAgentGroup } from './db/sessions.js';
|
||||
import { getAllMessagingGroups, getMessagingGroupAgents } from './db/messaging-groups.js';
|
||||
import { getDestinations } from './modules/agent-to-agent/db/agent-destinations.js';
|
||||
import { getMembers } from './modules/permissions/db/agent-group-members.js';
|
||||
import { getAllUsers, getUser } from './modules/permissions/db/users.js';
|
||||
import { getUserRoles, getAdminsOfAgentGroup } from './modules/permissions/db/user-roles.js';
|
||||
import { getUserDmsForUser } from './modules/permissions/db/user-dms.js';
|
||||
import { getDestinations } from './db/agent-destinations.js';
|
||||
import { getMembers } from './db/agent-group-members.js';
|
||||
import { getAllUsers, getUser } from './db/users.js';
|
||||
import { getUserRoles, getAdminsOfAgentGroup } from './db/user-roles.js';
|
||||
import { getUserDmsForUser } from './db/user-dms.js';
|
||||
import { getActiveAdapters, getRegisteredChannelNames } from './channels/channel-registry.js';
|
||||
import { DATA_DIR, ASSISTANT_NAME } from './config.js';
|
||||
import { getDb } from './db/connection.js';
|
||||
import { getContainerConfig } from './db/container-configs.js';
|
||||
import { log } from './log.js';
|
||||
import { readEnvFile } from './env.js';
|
||||
|
||||
interface PusherConfig {
|
||||
port: number;
|
||||
@@ -58,26 +56,6 @@ export function stopDashboardPusher(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill entry point — the single call wired into the host boot sequence.
|
||||
*
|
||||
* All of the dashboard's startup logic lives here, in the skill's own file,
|
||||
* so the integration point in src/index.ts is just `await startDashboard()`.
|
||||
* No-ops (and says so) when DASHBOARD_SECRET is unset.
|
||||
*/
|
||||
export async function startDashboard(): Promise<void> {
|
||||
const env = readEnvFile(['DASHBOARD_SECRET', 'DASHBOARD_PORT']);
|
||||
const secret = process.env.DASHBOARD_SECRET || env.DASHBOARD_SECRET;
|
||||
const port = parseInt(process.env.DASHBOARD_PORT || env.DASHBOARD_PORT || '3100', 10);
|
||||
if (!secret) {
|
||||
log.info('Dashboard disabled (no DASHBOARD_SECRET)');
|
||||
return;
|
||||
}
|
||||
const { startDashboard: startServer } = await import('@nanoco/nanoclaw-dashboard');
|
||||
startServer({ port, secret });
|
||||
startDashboardPusher({ port, secret, intervalMs: 60000 });
|
||||
}
|
||||
|
||||
/** Fire-and-forget POST to the dashboard. */
|
||||
function postJson(config: PusherConfig, urlPath: string, data: unknown): void {
|
||||
const body = JSON.stringify(data);
|
||||
@@ -179,7 +157,7 @@ function collectAgentGroups() {
|
||||
name: g.name,
|
||||
folder: g.folder,
|
||||
agent_provider: g.agent_provider,
|
||||
container_config: getContainerConfig(g.id) ?? null,
|
||||
container_config: g.container_config ? JSON.parse(g.container_config) : null,
|
||||
sessionCount: sessions.length,
|
||||
runningSessions: running.length,
|
||||
wirings,
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
/**
|
||||
* Wiring test for the add-dashboard skill's code-edit integration point.
|
||||
*
|
||||
* The skill inserts one colocated block into src/index.ts (a dynamic
|
||||
* `import('./dashboard-pusher.js')` + `await startDashboard()` in main()). A
|
||||
* behavioral test of the pusher can't see whether that edit is actually
|
||||
* present and correctly placed — booting the real host is too heavy — so this
|
||||
* asserts the edit *structurally*, via the TypeScript AST. It verifies not
|
||||
* just that the call exists, but that:
|
||||
* - the pusher module is dynamically imported by its correct path,
|
||||
* - startDashboard() is awaited,
|
||||
* - both are DIRECT statements of main()'s body (right scope/level, not
|
||||
* nested or stranded in another function),
|
||||
* - the import precedes the call, and the whole block sits after DB init
|
||||
* and before the boot-complete log (right place).
|
||||
*
|
||||
* Delete or misplace the edit and this goes red. Combined with the unit test
|
||||
* (behavior of startDashboard) and the build (the call still type-checks),
|
||||
* the three together cover deletion, misplacement, drift, and behavior — for
|
||||
* a true code edit, with no registry required.
|
||||
*
|
||||
* Ships with the skill; apply copies it to src/.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import ts from 'typescript';
|
||||
|
||||
const indexPath = path.resolve(process.cwd(), 'src/index.ts');
|
||||
const source = fs.readFileSync(indexPath, 'utf8');
|
||||
const sf = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, true);
|
||||
|
||||
function mainBody(): ts.NodeArray<ts.Statement> {
|
||||
let body: ts.NodeArray<ts.Statement> | undefined;
|
||||
sf.forEachChild((n) => {
|
||||
if (ts.isFunctionDeclaration(n) && n.name?.text === 'main' && n.body) {
|
||||
body = n.body.statements;
|
||||
}
|
||||
});
|
||||
if (!body) throw new Error('main() not found in src/index.ts');
|
||||
return body;
|
||||
}
|
||||
|
||||
function isAwaitedStartDashboard(s: ts.Statement): boolean {
|
||||
return (
|
||||
ts.isExpressionStatement(s) &&
|
||||
ts.isAwaitExpression(s.expression) &&
|
||||
ts.isCallExpression(s.expression.expression) &&
|
||||
ts.isIdentifier(s.expression.expression.expression) &&
|
||||
s.expression.expression.expression.text === 'startDashboard'
|
||||
);
|
||||
}
|
||||
|
||||
/** `const { ... } = await import('./dashboard-pusher.js')` as a statement. */
|
||||
function isDynamicImportOfPusher(s: ts.Statement): boolean {
|
||||
if (!ts.isVariableStatement(s)) return false;
|
||||
const init = s.declarationList.declarations[0]?.initializer;
|
||||
if (!init || !ts.isAwaitExpression(init) || !ts.isCallExpression(init.expression)) return false;
|
||||
const call = init.expression;
|
||||
if (call.expression.kind !== ts.SyntaxKind.ImportKeyword) return false;
|
||||
const arg = call.arguments[0];
|
||||
return !!arg && ts.isStringLiteral(arg) && arg.text === './dashboard-pusher.js';
|
||||
}
|
||||
|
||||
describe('add-dashboard wiring in src/index.ts', () => {
|
||||
it('dynamically imports the pusher and awaits startDashboard(), colocated in main(), after DB init and before the boot-complete log', () => {
|
||||
const stmts = mainBody();
|
||||
const importIdx = stmts.findIndex(isDynamicImportOfPusher);
|
||||
const callIdx = stmts.findIndex(isAwaitedStartDashboard);
|
||||
const migrateIdx = stmts.findIndex((s) => s.getText(sf).includes('runMigrations('));
|
||||
const runningIdx = stmts.findIndex((s) => s.getText(sf).includes("log.info('NanoClaw running')"));
|
||||
|
||||
expect(importIdx, "dynamic import('./dashboard-pusher.js') must be a statement of main()").toBeGreaterThanOrEqual(0);
|
||||
expect(callIdx, 'await startDashboard() must be a statement of main()').toBeGreaterThanOrEqual(0);
|
||||
expect(migrateIdx, 'runMigrations() anchor not found').toBeGreaterThanOrEqual(0);
|
||||
expect(runningIdx, 'boot-complete log anchor not found').toBeGreaterThanOrEqual(0);
|
||||
expect(importIdx, 'the dynamic import must come after DB init').toBeGreaterThan(migrateIdx);
|
||||
expect(callIdx, 'the call must come after its import (colocated)').toBeGreaterThan(importIdx);
|
||||
expect(callIdx, 'startDashboard() must run before the boot-complete log').toBeLessThan(runningIdx);
|
||||
});
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
# Remove DeltaChat
|
||||
|
||||
## 1. Remove the adapter
|
||||
|
||||
Delete the self-registration import from `src/channels/index.ts` (skip if already gone):
|
||||
|
||||
```typescript
|
||||
import './deltachat.js';
|
||||
```
|
||||
|
||||
Then delete the copied adapter and its registration test:
|
||||
|
||||
```bash
|
||||
rm -f src/channels/deltachat.ts src/channels/deltachat-registration.test.ts
|
||||
```
|
||||
|
||||
## 2. Remove credentials
|
||||
|
||||
Remove the `DC_*` lines from `.env`:
|
||||
|
||||
```bash
|
||||
DC_EMAIL
|
||||
DC_PASSWORD
|
||||
DC_IMAP_HOST
|
||||
DC_IMAP_PORT
|
||||
DC_SMTP_HOST
|
||||
DC_SMTP_PORT
|
||||
```
|
||||
|
||||
## 3. Rebuild and restart
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
source setup/lib/install-slug.sh
|
||||
|
||||
# Linux
|
||||
systemctl --user restart $(systemd_unit)
|
||||
|
||||
# macOS
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
|
||||
```
|
||||
|
||||
## 4. Remove account data (optional)
|
||||
|
||||
To fully remove all account data including DeltaChat encryption keys:
|
||||
|
||||
```bash
|
||||
rm -rf dc-account/
|
||||
```
|
||||
|
||||
> **Warning:** This deletes the Autocrypt keys. Contacts who have verified your bot's key will need to re-verify if the same email address is re-used with a new account.
|
||||
|
||||
To keep the account for later reinstall, leave `dc-account/` intact.
|
||||
|
||||
## 5. Remove the package (optional)
|
||||
|
||||
```bash
|
||||
pnpm remove @deltachat/stdio-rpc-server
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
After removal, confirm the adapter is no longer starting:
|
||||
|
||||
```bash
|
||||
grep "deltachat" logs/nanoclaw.log | tail -5
|
||||
```
|
||||
|
||||
Expected: no `Channel adapter started` entry after the last restart.
|
||||
@@ -1,265 +0,0 @@
|
||||
---
|
||||
name: add-deltachat
|
||||
description: Add DeltaChat channel integration via @deltachat/stdio-rpc-server. Native adapter — no Chat SDK bridge. Email-based messaging with end-to-end encryption.
|
||||
---
|
||||
|
||||
# Add DeltaChat Channel
|
||||
|
||||
The adapter drives the `@deltachat/stdio-rpc-server` JSON-RPC subprocess directly — pure Node.js against the DeltaChat core library. Messages are delivered over email with Autocrypt/OpenPGP encryption.
|
||||
|
||||
## Install
|
||||
|
||||
### Pre-flight (idempotent)
|
||||
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
|
||||
- `src/channels/deltachat.ts` exists
|
||||
- `src/channels/deltachat-registration.test.ts` exists
|
||||
- `src/channels/index.ts` contains `import './deltachat.js';`
|
||||
- `@deltachat/stdio-rpc-server` 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
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and its registration test
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/deltachat.ts > src/channels/deltachat.ts
|
||||
git show origin/channels:src/channels/deltachat-registration.test.ts > src/channels/deltachat-registration.test.ts
|
||||
```
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if already present):
|
||||
|
||||
```typescript
|
||||
import './deltachat.js';
|
||||
```
|
||||
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @deltachat/stdio-rpc-server@2.49.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
pnpm exec vitest run src/channels/deltachat-registration.test.ts
|
||||
```
|
||||
|
||||
Both must be clean before proceeding. `deltachat-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `deltachat`. It goes red if the `import './deltachat.js';` line is deleted or drifts, if the barrel fails to evaluate (so the channel genuinely would not register), or if `@deltachat/stdio-rpc-server` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. Importing is safe: deltachat instantiates the rpc client only in `setup()` (at host startup), never at import.
|
||||
|
||||
End-to-end message delivery against a real email account is verified manually once the service is running — see Wiring and Troubleshooting.
|
||||
|
||||
## Account Setup
|
||||
|
||||
A dedicated email account is strongly recommended — it will accumulate DeltaChat-formatted messages and store encryption keys. Not all providers work well with DeltaChat; check https://providers.delta.chat/ before picking one.
|
||||
|
||||
**Default security modes:** IMAP uses SSL/TLS (port 993), SMTP uses STARTTLS (port 587). Both are configurable via `.env` — see Credentials below.
|
||||
|
||||
To find the correct hostnames for a domain:
|
||||
|
||||
```bash
|
||||
node -e "require('dns').resolveMx('example.com', (e,r) => console.log(r))"
|
||||
```
|
||||
|
||||
Most providers publish their IMAP/SMTP hostnames in their help docs under "manual setup" or "IMAP access."
|
||||
|
||||
## Credentials
|
||||
|
||||
Add to `.env`:
|
||||
|
||||
```bash
|
||||
DC_EMAIL=bot@example.com
|
||||
DC_PASSWORD=your-app-password
|
||||
DC_IMAP_HOST=imap.example.com
|
||||
DC_IMAP_PORT=993
|
||||
DC_IMAP_SECURITY=1 # 1=SSL/TLS (default), 2=STARTTLS, 3=plain
|
||||
DC_SMTP_HOST=smtp.example.com
|
||||
DC_SMTP_PORT=587
|
||||
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
|
||||
|
||||
The following are read from the process environment (not `.env`). To override them, add `Environment=` lines to the systemd service unit or your launchd plist:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `DC_ACCOUNT_DIR` | `dc-account` | Directory for DeltaChat account data (IMAP state, keys, blobs) |
|
||||
| `DC_DISPLAY_NAME` | `NanoClaw` | Bot display name shown in DeltaChat |
|
||||
| `DC_AVATAR_PATH` | _(none)_ | Absolute path to avatar image; set at startup only |
|
||||
|
||||
The `/set-avatar` command (send an image with that caption) is the easiest way to set the avatar at runtime without modifying the service file. Only users with `owner` or global `admin` role can use it.
|
||||
|
||||
### Restart
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
source setup/lib/install-slug.sh
|
||||
|
||||
# Linux
|
||||
systemctl --user restart $(systemd_unit)
|
||||
|
||||
# macOS
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
|
||||
```
|
||||
|
||||
On first start the adapter configures the email account (IMAP/SMTP credentials, calls `configure()`). Subsequent starts skip straight to `startIo()`. Account data is stored in `dc-account/` in the project root (or your `DC_ACCOUNT_DIR`).
|
||||
|
||||
## Wiring
|
||||
|
||||
### DMs
|
||||
|
||||
**DeltaChat contacts cannot be added by email alone** — to start a chat, the user must open the bot's invite link in their DeltaChat app or scan its QR code. This triggers the SecureJoin handshake.
|
||||
|
||||
#### Step 1 — Get the invite link
|
||||
|
||||
After the service starts, the adapter logs the invite URL and writes a QR SVG:
|
||||
|
||||
```bash
|
||||
grep "invite link" logs/nanoclaw.log | tail -1
|
||||
# url field contains the https://i.delta.chat/... invite link
|
||||
# also written to dc-account/invite-qr.svg (or $DC_ACCOUNT_DIR/invite-qr.svg)
|
||||
```
|
||||
|
||||
The invite URL is stable (tied to the bot's email and encryption keys) so it stays valid across restarts.
|
||||
|
||||
#### Step 2 — Add the bot in DeltaChat
|
||||
|
||||
Two options for the user to connect:
|
||||
|
||||
- **Link**: Copy the `https://i.delta.chat/...` URL and open it on the device running DeltaChat. The app recognises it and shows a "Start chat" prompt.
|
||||
- **QR code**: Open `dc-account/invite-qr.svg` in a browser or image viewer, display it on screen, and scan it from the DeltaChat app using the QR-scan button on the new-chat screen.
|
||||
|
||||
After accepting, DeltaChat exchanges keys and creates the chat automatically.
|
||||
|
||||
#### Step 3 — Wire the chat to an agent
|
||||
|
||||
Once the first message arrives the router auto-creates a `messaging_groups` row. Look up the chat ID:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db \
|
||||
"SELECT platform_id, name FROM messaging_groups WHERE channel_type='deltachat' AND is_group=0 ORDER BY created_at DESC LIMIT 5"
|
||||
```
|
||||
|
||||
Then run `/init-first-agent` — it creates the agent group, grants the user owner access, and wires the messaging group in one step:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/init-first-agent.ts \
|
||||
--channel deltachat \
|
||||
--user-id deltachat:user@example.com \
|
||||
--platform-id <platform_id from above> \
|
||||
--display-name "Your Name"
|
||||
```
|
||||
|
||||
### Groups
|
||||
|
||||
Add the bot email to a DeltaChat group. When any member sends a message, the router creates a `messaging_groups` row with `is_group = 1`. Run `/manage-channels` to wire it to an agent group.
|
||||
|
||||
## 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 DeltaChat DM (see Wiring above), or `/manage-channels` to wire this channel to an existing agent group.
|
||||
|
||||
## Channel Info
|
||||
|
||||
- **type**: `deltachat`
|
||||
- **terminology**: DeltaChat calls them "chats" (1:1 DMs) and "groups"
|
||||
- **supports-threads**: no — DeltaChat has no thread model
|
||||
- **platform-id-format**: numeric chat ID as a string (e.g. `"12"`) — the DeltaChat core's internal chat identifier
|
||||
- **user-id-format**: `deltachat:{email}` — the contact's email address
|
||||
- **how-to-find-id**: Send a message from DeltaChat to the bot email, then query `messaging_groups` as shown above
|
||||
- **typical-use**: Personal assistant over DeltaChat DMs; small groups where participants use DeltaChat
|
||||
- **default-isolation**: One agent per bot identity. Multiple chats with the same operator can share an agent group; groups with other people should typically use `isolated` session mode
|
||||
|
||||
### Features
|
||||
|
||||
- File attachments — inbound and outbound; inbound waits up to 30 seconds for large-message download to complete
|
||||
- Invite link logged on every startup — URL + QR SVG written to `dc-account/invite-qr.svg`; see Wiring for the bootstrap flow
|
||||
- `/set-avatar` — send an image with this caption to change the bot's DeltaChat avatar (admin/owner only)
|
||||
- Connectivity watchdog — restarts IO if IMAP goes quiet for 20 minutes or connectivity drops below threshold for two consecutive 5-minute checks
|
||||
- Network nudge — `maybeNetwork()` called every 10 minutes to recover from prolonged idle
|
||||
|
||||
Not supported: DeltaChat reactions, message editing/deletion, read receipts.
|
||||
|
||||
### Connectivity model
|
||||
|
||||
`isConnected()` returns `true` when the internal connectivity value is ≥ 3000:
|
||||
|
||||
| Range | Meaning |
|
||||
|-------|---------|
|
||||
| 1000–1999 | Not connected |
|
||||
| 2000–2999 | Connecting |
|
||||
| 3000–3999 | Working (IMAP fetching) |
|
||||
| ≥ 4000 | Fully connected (IMAP IDLE) |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Adapter not starting — credentials missing
|
||||
|
||||
```bash
|
||||
grep "Channel credentials missing" logs/nanoclaw.log | grep deltachat
|
||||
```
|
||||
|
||||
All six required vars (`DC_EMAIL`, `DC_PASSWORD`, `DC_IMAP_HOST`, `DC_IMAP_PORT`, `DC_SMTP_HOST`, `DC_SMTP_PORT`) must be present in `.env`.
|
||||
|
||||
### Account configure fails
|
||||
|
||||
```bash
|
||||
grep "DeltaChat" logs/nanoclaw.log | tail -20
|
||||
```
|
||||
|
||||
Common causes:
|
||||
- Wrong IMAP/SMTP hostnames — double-check provider docs
|
||||
- App password not generated — Gmail and some others require this when 2FA is enabled
|
||||
- Port/security mismatch — defaults are port 993 + SSL/TLS for IMAP and port 587 + STARTTLS for SMTP; override with `DC_IMAP_PORT`/`DC_IMAP_SECURITY` or `DC_SMTP_PORT`/`DC_SMTP_SECURITY` in `.env`
|
||||
|
||||
### Provider uses SMTP port 465 (SSL/TLS) instead of 587
|
||||
|
||||
Set `DC_SMTP_SECURITY=1` and `DC_SMTP_PORT=465` in `.env`, then restart.
|
||||
|
||||
### Messages not arriving
|
||||
|
||||
1. Check the service is running and the adapter started: `grep "Channel adapter started.*deltachat" logs/nanoclaw.log`
|
||||
2. Check connectivity: `grep "DeltaChat: IO started" logs/nanoclaw.log`
|
||||
3. Check the sender has been granted access — run `/init-first-agent` to create their user record and wire the chat
|
||||
4. Verify the messaging group is wired: `pnpm exec tsx scripts/q.ts data/v2.db "SELECT mg.platform_id, mga.agent_group_id FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id WHERE mg.channel_type='deltachat'"`
|
||||
|
||||
### Stale lock file after crash
|
||||
|
||||
```bash
|
||||
rm -f dc-account/accounts.lock
|
||||
systemctl --user restart "$(. setup/lib/install-slug.sh && systemd_unit)"
|
||||
```
|
||||
|
||||
### Bot not responding after restart
|
||||
|
||||
The account is already configured — IO restarts automatically on service start. If the RPC subprocess is stuck, restart the service. Check for errors:
|
||||
|
||||
```bash
|
||||
grep "DeltaChat" logs/nanoclaw.error.log | tail -20
|
||||
```
|
||||
|
||||
### Messages received but agent not responding
|
||||
|
||||
The messaging group exists but may not be wired to an agent group. Run:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "SELECT id, platform_id, name FROM messaging_groups WHERE channel_type='deltachat'"
|
||||
```
|
||||
|
||||
If the group has no entry in `messaging_group_agents`, wire it with `/manage-channels`.
|
||||
@@ -0,0 +1,7 @@
|
||||
# Remove Discord
|
||||
|
||||
1. Comment out `import './discord.js'` in `src/channels/index.ts`
|
||||
2. Remove `DISCORD_BOT_TOKEN` from `.env`
|
||||
3. Rebuild and restart
|
||||
|
||||
No package to uninstall — Discord is built in.
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
name: add-discord-v2
|
||||
description: Add Discord bot channel integration to NanoClaw v2 via Chat SDK.
|
||||
---
|
||||
|
||||
# Add Discord Channel
|
||||
|
||||
Adds Discord bot support to NanoClaw v2. Discord is built in — no adapter package to install.
|
||||
|
||||
## Pre-flight
|
||||
|
||||
Check if `src/channels/discord.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Credentials.
|
||||
|
||||
## Install
|
||||
|
||||
Discord support is bundled with NanoClaw — there is no separate package to install.
|
||||
|
||||
### Enable the channel
|
||||
|
||||
Uncomment the Discord import in `src/channels/index.ts`:
|
||||
|
||||
```typescript
|
||||
import './discord.js';
|
||||
```
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
## Credentials
|
||||
|
||||
### Create Discord Bot
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
|
||||
## 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.
|
||||
|
||||
## Channel Info
|
||||
|
||||
- **type**: `discord`
|
||||
- **terminology**: Discord has "servers" (also called "guilds") containing "channels." Text channels start with #. The bot can also receive direct messages.
|
||||
- **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
|
||||
- **default-isolation**: Same agent group for your personal server. Separate agent group for servers with different communities or where different members have different information boundaries.
|
||||
@@ -0,0 +1,3 @@
|
||||
# Verify Discord
|
||||
|
||||
Send a message in a channel where the bot has access, or DM the bot directly. The bot should respond within a few seconds.
|
||||
@@ -1,40 +0,0 @@
|
||||
# Remove Discord
|
||||
|
||||
Every step is idempotent — safe to re-run.
|
||||
|
||||
## 1. Remove the adapter
|
||||
|
||||
Delete the self-registration import from `src/channels/index.ts` (skip if already gone):
|
||||
|
||||
```typescript
|
||||
import './discord.js';
|
||||
```
|
||||
|
||||
Then delete the copied adapter and its registration test:
|
||||
|
||||
```bash
|
||||
rm -f src/channels/discord.ts src/channels/discord-registration.test.ts
|
||||
```
|
||||
|
||||
## 2. Remove credentials
|
||||
|
||||
Remove `DISCORD_BOT_TOKEN`, `DISCORD_APPLICATION_ID`, and `DISCORD_PUBLIC_KEY` from `.env`, then re-sync to the container:
|
||||
|
||||
```bash
|
||||
mkdir -p data/env && cp .env data/env/env
|
||||
```
|
||||
|
||||
## 3. Remove the package
|
||||
|
||||
```bash
|
||||
pnpm uninstall @chat-adapter/discord
|
||||
```
|
||||
|
||||
## 4. Rebuild and restart
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
source setup/lib/install-slug.sh
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
# Linux: systemctl --user restart $(systemd_unit)
|
||||
```
|
||||
@@ -1,103 +1,203 @@
|
||||
---
|
||||
name: add-discord
|
||||
description: Add Discord bot channel integration via Chat SDK.
|
||||
description: Add Discord bot channel integration to NanoClaw.
|
||||
---
|
||||
|
||||
# Add Discord Channel
|
||||
|
||||
Adds Discord bot support via the Chat SDK bridge.
|
||||
This skill adds Discord support to NanoClaw, then walks through interactive setup.
|
||||
|
||||
## Install
|
||||
## Phase 1: Pre-flight
|
||||
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the Discord adapter in from the `channels` branch.
|
||||
### Check if already applied
|
||||
|
||||
### Pre-flight (idempotent)
|
||||
Check if `src/channels/discord.ts` exists. If it does, skip to Phase 3 (Setup). The code changes are already in place.
|
||||
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
### Ask the user
|
||||
|
||||
- `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
|
||||
Use `AskUserQuestion` to collect configuration:
|
||||
|
||||
Otherwise continue. Every step below is safe to re-run.
|
||||
AskUserQuestion: Do you have a Discord bot token, or do you need to create one?
|
||||
|
||||
### 1. Fetch the channels branch
|
||||
If they have one, collect it now. If not, we'll create one in Phase 3.
|
||||
|
||||
## Phase 2: Apply Code Changes
|
||||
|
||||
### Ensure channel remote
|
||||
|
||||
```bash
|
||||
git fetch origin channels
|
||||
git remote -v
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and its registration test
|
||||
If `discord` is missing, add it:
|
||||
|
||||
```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
|
||||
git remote add discord https://github.com/qwibitai/nanoclaw-discord.git
|
||||
```
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
```typescript
|
||||
import './discord.js';
|
||||
```
|
||||
|
||||
### 4. Install the adapter package (pinned)
|
||||
### Merge the skill branch
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/discord@4.29.0
|
||||
git fetch discord main
|
||||
git merge discord/main || {
|
||||
git checkout --theirs pnpm-lock.yaml
|
||||
git add pnpm-lock.yaml
|
||||
git merge --continue
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
This merges in:
|
||||
- `src/channels/discord.ts` (DiscordChannel class with self-registration via `registerChannel`)
|
||||
- `src/channels/discord.test.ts` (unit tests with discord.js mock)
|
||||
- `import './discord.js'` appended to the channel barrel file `src/channels/index.ts`
|
||||
- `discord.js` npm dependency in `package.json`
|
||||
- `DISCORD_BOT_TOKEN` in `.env.example`
|
||||
|
||||
If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides.
|
||||
|
||||
### Validate code changes
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm run build
|
||||
pnpm exec vitest run src/channels/discord-registration.test.ts
|
||||
pnpm exec vitest run src/channels/discord.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`.
|
||||
All tests must pass (including the new Discord tests) and build must be clean before proceeding.
|
||||
|
||||
## Credentials
|
||||
## Phase 3: Setup
|
||||
|
||||
### Create Discord Bot
|
||||
### Create Discord Bot (if needed)
|
||||
|
||||
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
|
||||
If the user doesn't have a bot token, tell them:
|
||||
|
||||
> I need you to create a Discord bot:
|
||||
>
|
||||
> 1. Go to the [Discord Developer Portal](https://discord.com/developers/applications)
|
||||
> 2. Click **New Application** and give it a name (e.g., "Andy Assistant")
|
||||
> 3. Go to the **Bot** tab on the left sidebar
|
||||
> 4. Click **Reset Token** to generate a new bot token — copy it immediately (you can only see it once)
|
||||
> 5. Under **Privileged Gateway Intents**, enable:
|
||||
> - **Message Content Intent** (required to read message text)
|
||||
> - **Server Members Intent** (optional, for member display names)
|
||||
> 6. Go to **OAuth2** > **URL Generator**:
|
||||
> - Scopes: select `bot`
|
||||
> - Bot Permissions: select `Send Messages`, `Read Message History`, `View Channels`
|
||||
> - Copy the generated URL and open it in your browser to invite the bot to your server
|
||||
|
||||
Wait for the user to provide the token.
|
||||
|
||||
### 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
|
||||
DISCORD_BOT_TOKEN=<their-token>
|
||||
```
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
Channels auto-enable when their credentials are present — no extra configuration needed.
|
||||
|
||||
## Next Steps
|
||||
Sync to container environment:
|
||||
|
||||
If you're in the middle of `/setup`, return to the setup flow now.
|
||||
```bash
|
||||
mkdir -p data/env && cp .env data/env/env
|
||||
```
|
||||
|
||||
Otherwise, run `/manage-channels` to wire this channel to an agent group.
|
||||
The container reads environment from `data/env/env`, not `.env` directly.
|
||||
|
||||
## Channel Info
|
||||
### Build and restart
|
||||
|
||||
- **type**: `discord`
|
||||
- **terminology**: Discord has "servers" (also called "guilds") containing "channels." Text channels start with #. The bot can also receive direct messages.
|
||||
- **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
|
||||
- **default-isolation**: Same agent group for your personal server. Separate agent group for servers with different communities or where different members have different information boundaries.
|
||||
```bash
|
||||
pnpm run build
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
|
||||
```
|
||||
|
||||
## Phase 4: Registration
|
||||
|
||||
### Get Channel ID
|
||||
|
||||
Tell the user:
|
||||
|
||||
> To get the channel ID for registration:
|
||||
>
|
||||
> 1. In Discord, go to **User Settings** > **Advanced** > Enable **Developer Mode**
|
||||
> 2. Right-click the text channel you want the bot to respond in
|
||||
> 3. Click **Copy Channel ID**
|
||||
>
|
||||
> The channel ID will be a long number like `1234567890123456`.
|
||||
|
||||
Wait for the user to provide the channel ID (format: `dc:1234567890123456`).
|
||||
|
||||
### Register the channel
|
||||
|
||||
The channel ID, name, and folder name are needed. Use `pnpm exec tsx setup/index.ts --step register` with the appropriate flags.
|
||||
|
||||
For a main channel (responds to all messages):
|
||||
|
||||
```bash
|
||||
pnpm exec tsx setup/index.ts --step register -- --jid "dc:<channel-id>" --name "<server-name> #<channel-name>" --folder "discord_main" --trigger "@${ASSISTANT_NAME}" --channel discord --no-trigger-required --is-main
|
||||
```
|
||||
|
||||
For additional channels (trigger-only):
|
||||
|
||||
```bash
|
||||
pnpm exec tsx setup/index.ts --step register -- --jid "dc:<channel-id>" --name "<server-name> #<channel-name>" --folder "discord_<channel-name>" --trigger "@${ASSISTANT_NAME}" --channel discord
|
||||
```
|
||||
|
||||
## Phase 5: Verify
|
||||
|
||||
### Test the connection
|
||||
|
||||
Tell the user:
|
||||
|
||||
> Send a message in your registered Discord channel:
|
||||
> - For main channel: Any message works
|
||||
> - For non-main: @mention the bot in Discord
|
||||
>
|
||||
> The bot should respond within a few seconds.
|
||||
|
||||
### Check logs if needed
|
||||
|
||||
```bash
|
||||
tail -f logs/nanoclaw.log
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Bot not responding
|
||||
|
||||
1. Check `DISCORD_BOT_TOKEN` is set in `.env` AND synced to `data/env/env`
|
||||
2. Check channel is registered: `sqlite3 store/messages.db "SELECT * FROM registered_groups WHERE jid LIKE 'dc:%'"`
|
||||
3. For non-main channels: message must include trigger pattern (@mention the bot)
|
||||
4. Service is running: `launchctl list | grep nanoclaw`
|
||||
5. Verify the bot has been invited to the server (check OAuth2 URL was used)
|
||||
|
||||
### Bot only responds to @mentions
|
||||
|
||||
This is the default behavior for non-main channels (`requiresTrigger: true`). To change:
|
||||
- Update the registered group's `requiresTrigger` to `false`
|
||||
- Or register the channel as the main channel
|
||||
|
||||
### Message Content Intent not enabled
|
||||
|
||||
If the bot connects but can't read messages, ensure:
|
||||
1. Go to [Discord Developer Portal](https://discord.com/developers/applications)
|
||||
2. Select your application > **Bot** tab
|
||||
3. Under **Privileged Gateway Intents**, enable **Message Content Intent**
|
||||
4. Restart NanoClaw
|
||||
|
||||
### Getting Channel ID
|
||||
|
||||
If you can't copy the channel ID:
|
||||
- Ensure **Developer Mode** is enabled: User Settings > Advanced > Developer Mode
|
||||
- Right-click the channel name in the server sidebar > Copy Channel ID
|
||||
|
||||
## After Setup
|
||||
|
||||
The Discord bot supports:
|
||||
- Text messages in registered channels
|
||||
- Attachment descriptions (images, videos, files shown as placeholders)
|
||||
- Reply context (shows who the user is replying to)
|
||||
- @mention translation (Discord `<@botId>` → NanoClaw trigger format)
|
||||
- Message splitting for responses over 2000 characters
|
||||
- Typing indicators while the agent processes
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
# Remove Emacs
|
||||
|
||||
Every step is idempotent — safe to re-run.
|
||||
|
||||
## 1. Remove the adapter
|
||||
|
||||
Delete the self-registration import from `src/channels/index.ts` (skip if already gone):
|
||||
|
||||
```typescript
|
||||
import './emacs.js';
|
||||
```
|
||||
|
||||
Then delete the copied adapter, its tests, and the Lisp client:
|
||||
|
||||
```bash
|
||||
rm -f src/channels/emacs.ts src/channels/emacs.test.ts src/channels/emacs-registration.test.ts emacs/nanoclaw.el
|
||||
```
|
||||
|
||||
## 2. Remove credentials
|
||||
|
||||
Remove the `EMACS_*` lines from `.env`:
|
||||
|
||||
```bash
|
||||
EMACS_ENABLED
|
||||
EMACS_CHANNEL_PORT
|
||||
EMACS_AUTH_TOKEN
|
||||
EMACS_PLATFORM_ID
|
||||
```
|
||||
|
||||
## 3. Rebuild and restart
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
source setup/lib/install-slug.sh
|
||||
|
||||
# Linux
|
||||
systemctl --user restart $(systemd_unit)
|
||||
|
||||
# macOS
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
|
||||
```
|
||||
|
||||
## 4. Remove the Emacs config (optional)
|
||||
|
||||
Remove the NanoClaw block from your Emacs config (`config.el`, `~/.spacemacs`, or `init.el`):
|
||||
|
||||
```elisp
|
||||
;; NanoClaw — personal AI assistant channel
|
||||
(load-file "~/src/nanoclaw/emacs/nanoclaw.el")
|
||||
;; ...and the associated keybindings / nanoclaw-auth-token / nanoclaw-port settings
|
||||
```
|
||||
|
||||
Reload your config or restart Emacs.
|
||||
|
||||
## 5. Remove the messaging group (optional)
|
||||
|
||||
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';"
|
||||
```
|
||||
+127
-132
@@ -1,11 +1,12 @@
|
||||
---
|
||||
name: add-emacs
|
||||
description: Add Emacs as a channel. Opens an interactive chat buffer and org-mode integration so you can talk to NanoClaw from within Emacs (Doom, Spacemacs, or vanilla). Local HTTP bridge — no bot token or external service needed.
|
||||
description: Add Emacs as a channel. Opens an interactive chat buffer and org-mode integration so you can talk to NanoClaw from within Emacs (Doom, Spacemacs, or vanilla). Uses a local HTTP bridge — no bot token or external service needed.
|
||||
---
|
||||
|
||||
# Add Emacs Channel
|
||||
|
||||
Adds Emacs support via a local HTTP bridge. Works with Doom Emacs, Spacemacs, and vanilla Emacs 27.1+.
|
||||
This skill adds Emacs support to NanoClaw, then walks through interactive setup.
|
||||
Works with Doom Emacs, Spacemacs, and vanilla Emacs 27.1+.
|
||||
|
||||
## What you can do with this
|
||||
|
||||
@@ -14,105 +15,95 @@ Adds Emacs support via a local HTTP bridge. Works with Doom Emacs, Spacemacs, an
|
||||
- **Meeting notes** — send an org agenda entry; get a summary or action item list back as a child node
|
||||
- **Draft writing** — send org prose; receive revisions or continuations in place
|
||||
- **Research capture** — ask a question directly in your org notes; the answer lands exactly where you need it
|
||||
- **Schedule tasks** — ask Andy to set a reminder or create a scheduled NanoClaw task (e.g. "remind me tomorrow to review the PR")
|
||||
|
||||
## Install
|
||||
## Phase 1: Pre-flight
|
||||
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the Emacs adapter and the Lisp client in from the `channels` branch. Native HTTP bridge — no Chat SDK, no adapter package.
|
||||
### Check if already applied
|
||||
|
||||
### Pre-flight (idempotent)
|
||||
|
||||
Skip to **Enable** if all of these are already in place:
|
||||
|
||||
- `src/channels/emacs.ts` exists
|
||||
- `src/channels/emacs.test.ts` exists
|
||||
- `src/channels/emacs-registration.test.ts` exists
|
||||
- `emacs/nanoclaw.el` exists
|
||||
- `src/channels/index.ts` contains `import './emacs.js';`
|
||||
|
||||
Otherwise continue. Every step below is safe to re-run.
|
||||
|
||||
### 1. Fetch the channels branch
|
||||
Check if `src/channels/emacs.ts` exists:
|
||||
|
||||
```bash
|
||||
git fetch origin channels
|
||||
test -f src/channels/emacs.ts && echo "already applied" || echo "not applied"
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and Lisp client
|
||||
If it exists, skip to Phase 3 (Setup). The code changes are already in place.
|
||||
|
||||
## Phase 2: Apply Code Changes
|
||||
|
||||
### Ensure the upstream remote
|
||||
|
||||
```bash
|
||||
mkdir -p emacs
|
||||
git show origin/channels:src/channels/emacs.ts > src/channels/emacs.ts
|
||||
git show origin/channels:src/channels/emacs.test.ts > src/channels/emacs.test.ts
|
||||
git show origin/channels:src/channels/emacs-registration.test.ts > src/channels/emacs-registration.test.ts
|
||||
git show origin/channels:emacs/nanoclaw.el > emacs/nanoclaw.el
|
||||
git remote -v
|
||||
```
|
||||
|
||||
### 3. Append the self-registration import
|
||||
If an `upstream` remote pointing to `https://github.com/qwibitai/nanoclaw.git` is missing,
|
||||
add it:
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
```typescript
|
||||
import './emacs.js';
|
||||
```bash
|
||||
git remote add upstream https://github.com/qwibitai/nanoclaw.git
|
||||
```
|
||||
|
||||
### 4. Build and validate
|
||||
### Merge the skill branch
|
||||
|
||||
```bash
|
||||
git fetch upstream skill/emacs
|
||||
git merge upstream/skill/emacs
|
||||
```
|
||||
|
||||
If there are merge conflicts on `pnpm-lock.yaml`, resolve them by accepting the incoming
|
||||
version and continuing:
|
||||
|
||||
```bash
|
||||
git checkout --theirs pnpm-lock.yaml
|
||||
git add pnpm-lock.yaml
|
||||
git merge --continue
|
||||
```
|
||||
|
||||
For any other conflict, read the conflicted file and reconcile both sides manually.
|
||||
|
||||
This adds:
|
||||
- `src/channels/emacs.ts` — `EmacsBridgeChannel` HTTP server (port 8766)
|
||||
- `src/channels/emacs.test.ts` — unit tests
|
||||
- `emacs/nanoclaw.el` — Emacs Lisp package (`nanoclaw-chat`, `nanoclaw-org-send`)
|
||||
- `import './emacs.js'` appended to `src/channels/index.ts`
|
||||
|
||||
If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides.
|
||||
|
||||
### Validate code changes
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
pnpm exec vitest run src/channels/emacs-registration.test.ts
|
||||
pnpm exec vitest run src/channels/emacs.test.ts
|
||||
```
|
||||
|
||||
Both must be clean before proceeding. `emacs-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `emacs`. It goes red if the `import './emacs.js';` line is deleted or drifts, or if the barrel fails to evaluate (so the channel genuinely would not register). The adapter uses only Node builtins (`http`), so there is no npm dependency to guard for this channel.
|
||||
Build must be clean and tests must pass before proceeding.
|
||||
|
||||
End-to-end message delivery from a real Emacs buffer is verified manually once the service is running — see Verify and Troubleshooting.
|
||||
## Phase 3: Setup
|
||||
|
||||
## Enable
|
||||
### Configure environment (optional)
|
||||
|
||||
The adapter is gated by `EMACS_ENABLED` so the HTTP port isn't opened on hosts that aren't running Emacs. Add to `.env`:
|
||||
The channel works out of the box with defaults. Add to `.env` only if you need non-defaults:
|
||||
|
||||
```bash
|
||||
EMACS_ENABLED=true
|
||||
EMACS_CHANNEL_PORT=8766 # optional — change only if 8766 is taken
|
||||
EMACS_AUTH_TOKEN= # optional — set to a random string to lock the endpoint
|
||||
EMACS_PLATFORM_ID=default # optional — only change if you want a non-default chat id
|
||||
EMACS_CHANNEL_PORT=8766 # default — change if 8766 is already in use
|
||||
EMACS_AUTH_TOKEN=<random> # optional — locks the endpoint to Emacs only
|
||||
```
|
||||
|
||||
Generate an auth token (recommended even on single-user machines — prevents other local processes from poking the endpoint):
|
||||
If you change or add values, sync to the container environment:
|
||||
|
||||
```bash
|
||||
node -e "console.log(require('crypto').randomBytes(16).toString('hex'))"
|
||||
mkdir -p data/env && cp .env data/env/env
|
||||
```
|
||||
|
||||
## Wire the channel
|
||||
### Configure Emacs
|
||||
|
||||
Emacs is a single-user, single-chat channel. One host = one messaging group with `platform_id = "default"`.
|
||||
|
||||
### If this is your first agent group
|
||||
|
||||
Run `/init-first-agent` — pick **Emacs** as the channel, use any short handle as the "user id" (e.g. your OS username), and the skill will create the agent group, wire the channel, and write a welcome message that the agent delivers back to your Emacs buffer.
|
||||
|
||||
### Otherwise — wire to an existing agent group
|
||||
|
||||
Run the `register` step directly. The `EMACS_PLATFORM_ID` (default `default`) becomes the messaging group's platform id:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx setup/index.ts --step register -- \
|
||||
--platform-id "default" --name "Emacs" \
|
||||
--folder "<existing-folder>" --channel "emacs" \
|
||||
--session-mode "agent-shared" \
|
||||
--assistant-name "<existing-assistant-name>"
|
||||
```
|
||||
|
||||
`agent-shared` puts Emacs messages in the same session as any other channel wired to the same agent group — so a conversation you started in Telegram continues in Emacs. Use `shared` to keep an independent Emacs thread with the same workspace, or a new `--folder` for a dedicated Emacs-only agent.
|
||||
|
||||
## Configure Emacs
|
||||
|
||||
`nanoclaw.el` needs only Emacs 27.1+ builtins (`url`, `json`, `org`) — no package manager.
|
||||
The `nanoclaw.el` package requires only Emacs 27.1+ built-in libraries (`url`, `json`, `org`) — no package manager setup needed.
|
||||
|
||||
AskUserQuestion: Which Emacs distribution are you using?
|
||||
- **Doom Emacs** — `config.el` with `map!` keybindings
|
||||
- **Spacemacs** — `dotspacemacs/user-config` in `~/.spacemacs`
|
||||
- **Vanilla Emacs / other** — `init.el` with `global-set-key`
|
||||
- **Doom Emacs** - config.el with map! keybindings
|
||||
- **Spacemacs** - dotspacemacs/user-config in ~/.spacemacs
|
||||
- **Vanilla Emacs / other** - init.el with global-set-key
|
||||
|
||||
**Doom Emacs** — add to `~/.config/doom/config.el` (or `~/.doom.d/config.el`):
|
||||
|
||||
@@ -126,7 +117,7 @@ AskUserQuestion: Which Emacs distribution are you using?
|
||||
:desc "Send org" "o" #'nanoclaw-org-send)
|
||||
```
|
||||
|
||||
Reload: `M-x doom/reload`
|
||||
Then reload: `M-x doom/reload`
|
||||
|
||||
**Spacemacs** — add to `dotspacemacs/user-config` in `~/.spacemacs`:
|
||||
|
||||
@@ -138,9 +129,9 @@ Reload: `M-x doom/reload`
|
||||
(spacemacs/set-leader-keys "aNo" #'nanoclaw-org-send)
|
||||
```
|
||||
|
||||
Reload: `M-x dotspacemacs/sync-configuration-layers` or restart Emacs.
|
||||
Then reload: `M-x dotspacemacs/sync-configuration-layers` or restart Emacs.
|
||||
|
||||
**Vanilla Emacs** — add to `~/.emacs.d/init.el`:
|
||||
**Vanilla Emacs** — add to `~/.emacs.d/init.el` (or `~/.emacs`):
|
||||
|
||||
```elisp
|
||||
;; NanoClaw — personal AI assistant channel
|
||||
@@ -150,78 +141,61 @@ Reload: `M-x dotspacemacs/sync-configuration-layers` or restart Emacs.
|
||||
(global-set-key (kbd "C-c n o") #'nanoclaw-org-send)
|
||||
```
|
||||
|
||||
Reload: `M-x eval-buffer` or restart Emacs.
|
||||
Then reload: `M-x eval-buffer` or restart Emacs.
|
||||
|
||||
Replace `~/src/nanoclaw/emacs/nanoclaw.el` with your actual NanoClaw checkout path.
|
||||
|
||||
If `EMACS_AUTH_TOKEN` is set, also add (any distribution):
|
||||
If `EMACS_AUTH_TOKEN` was set, also add (any distribution):
|
||||
|
||||
```elisp
|
||||
(setq nanoclaw-auth-token "<your-token>")
|
||||
```
|
||||
|
||||
If you changed `EMACS_CHANNEL_PORT` from the default:
|
||||
If `EMACS_CHANNEL_PORT` was changed from the default, also add:
|
||||
|
||||
```elisp
|
||||
(setq nanoclaw-port <your-port>)
|
||||
```
|
||||
|
||||
## Restart NanoClaw
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
### Restart NanoClaw
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
source setup/lib/install-slug.sh
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
# systemctl --user restart $(systemd_unit) # Linux
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
# Linux: systemctl --user restart nanoclaw
|
||||
```
|
||||
|
||||
## Verify
|
||||
## Phase 4: Verify
|
||||
|
||||
### HTTP endpoint
|
||||
### Test the HTTP endpoint
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8766/api/messages?since=0
|
||||
curl -s "http://localhost:8766/api/messages?since=0"
|
||||
```
|
||||
|
||||
Expected: `{"messages":[]}`. With an auth token:
|
||||
Expected: `{"messages":[]}`
|
||||
|
||||
If you set `EMACS_AUTH_TOKEN`:
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: Bearer <token>" http://localhost:8766/api/messages?since=0
|
||||
curl -s -H "Authorization: Bearer <token>" "http://localhost:8766/api/messages?since=0"
|
||||
```
|
||||
|
||||
### From Emacs
|
||||
### Test from Emacs
|
||||
|
||||
Tell the user:
|
||||
|
||||
> 1. Open the chat buffer with your keybinding (`SPC N c`, `SPC a N c`, or `C-c n c`)
|
||||
> 2. Type a message and press `C-c C-c` to send (RET inserts newlines)
|
||||
> 3. A response should appear within a few seconds
|
||||
> 2. Type a message and press `RET`
|
||||
> 3. A response from Andy should appear within a few seconds
|
||||
>
|
||||
> For org-mode: open any `.org` file, position the cursor on a heading, and use `SPC N o` / `SPC a N o` / `C-c n o`
|
||||
|
||||
### Log line
|
||||
### Check logs if needed
|
||||
|
||||
`tail -f logs/nanoclaw.log` should show `Emacs channel listening` at startup.
|
||||
```bash
|
||||
tail -f logs/nanoclaw.log
|
||||
```
|
||||
|
||||
## Channel Info
|
||||
|
||||
- **type**: `emacs`
|
||||
- **terminology**: Single local buffer. There are no "groups" or separate chats — one host = one chat, addressed by a `platform_id` string (default `default`).
|
||||
- **how-to-find-id**: The platform id is whatever you set in `EMACS_PLATFORM_ID` (default `default`). User handles are arbitrary; your OS username or first name is fine (e.g. `emacs:<username>`).
|
||||
- **supports-threads**: no
|
||||
- **typical-use**: Single developer talking to the assistant from within Emacs, alongside whatever other channel they use (Slack, Telegram, Discord).
|
||||
- **default-isolation**: Same agent group as the primary DM, with `session-mode = agent-shared` so a conversation started elsewhere continues in Emacs. Pick a separate folder only if you specifically want an Emacs-only persona.
|
||||
|
||||
### Features
|
||||
|
||||
- Interactive chat buffer (`nanoclaw-chat`) with markdown → org-mode rendering
|
||||
- Org integration (`nanoclaw-org-send`) — sends the current subtree or region; reply lands as a child heading
|
||||
- Optional bearer-token auth for the local endpoint
|
||||
- Single-user: the adapter exposes exactly one messaging group per host
|
||||
|
||||
Not applicable (design): multi-user channels, threads, cold DM initiation, typing indicators, attachments.
|
||||
Look for `Emacs channel listening` at startup and `Emacs message received` when a message is sent.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -231,53 +205,66 @@ Not applicable (design): multi-user channels, threads, cold DM initiation, typin
|
||||
Error: listen EADDRINUSE: address already in use :::8766
|
||||
```
|
||||
|
||||
Either a stale NanoClaw is running or another app has the port. Kill stale process or change port:
|
||||
Either a stale NanoClaw process is running, or 8766 is taken by another app.
|
||||
|
||||
Find and kill the stale process:
|
||||
|
||||
```bash
|
||||
lsof -ti :8766 | xargs kill -9
|
||||
# or set EMACS_CHANNEL_PORT in .env and mirror in Emacs config (nanoclaw-port)
|
||||
```
|
||||
|
||||
### Adapter not starting
|
||||
|
||||
If `grep "Emacs channel listening" logs/nanoclaw.log` returns nothing, check that `EMACS_ENABLED=true` is in `.env` and that the adapter import is present:
|
||||
|
||||
```bash
|
||||
grep -q '^EMACS_ENABLED=true' .env && echo "enabled" || echo "not enabled"
|
||||
grep -q "import './emacs.js'" src/channels/index.ts && echo "imported" || echo "not imported"
|
||||
```
|
||||
Or change the port in `.env` (`EMACS_CHANNEL_PORT=8767`) and update `nanoclaw-port` in Emacs config.
|
||||
|
||||
### No response from agent
|
||||
|
||||
1. NanoClaw running: `launchctl list | grep "$(. setup/lib/install-slug.sh && launchd_label)"` (macOS) / `systemctl --user status "$(. setup/lib/install-slug.sh && systemd_unit)"` (Linux)
|
||||
2. Messaging group wired: `pnpm exec tsx scripts/q.ts data/v2.db "SELECT mg.platform_id, ag.folder FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id JOIN agent_groups ag ON ag.id = mga.agent_group_id WHERE mg.channel_type = 'emacs'"`
|
||||
3. Logs show inbound: `grep 'channel_type=emacs\|Emacs' logs/nanoclaw.log | tail -20`
|
||||
Check:
|
||||
1. NanoClaw is running: `launchctl list | grep nanoclaw` (macOS) or `systemctl --user status nanoclaw` (Linux)
|
||||
2. Emacs group is registered: `sqlite3 store/messages.db "SELECT * FROM registered_groups WHERE jid = 'emacs:default'"`
|
||||
3. Logs show activity: `tail -50 logs/nanoclaw.log`
|
||||
|
||||
If no messaging group row exists, run the `register` command above.
|
||||
If the group is not registered, it will be created automatically on the next NanoClaw restart.
|
||||
|
||||
### Auth token mismatch (401 Unauthorized)
|
||||
|
||||
```elisp
|
||||
M-x describe-variable RET nanoclaw-auth-token RET
|
||||
```
|
||||
|
||||
Must match `EMACS_AUTH_TOKEN` in `.env`. If you didn't set one server-side, clear it in Emacs too:
|
||||
Verify the token in Emacs matches `.env`:
|
||||
|
||||
```elisp
|
||||
(setq nanoclaw-auth-token nil)
|
||||
;; M-x describe-variable RET nanoclaw-auth-token RET
|
||||
```
|
||||
|
||||
Must exactly match `EMACS_AUTH_TOKEN` in `.env`.
|
||||
|
||||
### nanoclaw.el not loading
|
||||
|
||||
Check the path is correct:
|
||||
|
||||
```bash
|
||||
ls ~/src/nanoclaw/emacs/nanoclaw.el
|
||||
```
|
||||
|
||||
If NanoClaw is cloned elsewhere, update the `load`/`load-file` path in your Emacs config.
|
||||
|
||||
## After Setup
|
||||
|
||||
If running `pnpm run dev` while the service is active:
|
||||
|
||||
```bash
|
||||
# macOS:
|
||||
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
|
||||
pnpm run dev
|
||||
# When done testing:
|
||||
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
|
||||
|
||||
# Linux:
|
||||
# systemctl --user stop nanoclaw
|
||||
# pnpm run dev
|
||||
# systemctl --user start nanoclaw
|
||||
```
|
||||
|
||||
## Agent Formatting
|
||||
|
||||
The Emacs bridge converts markdown → org-mode automatically. Agents should output standard markdown, **not** org-mode syntax:
|
||||
The Emacs bridge converts markdown → org-mode automatically. Agents should
|
||||
output standard markdown — **not** org-mode syntax. The conversion handles:
|
||||
|
||||
| Markdown | Org-mode |
|
||||
|----------|----------|
|
||||
@@ -287,8 +274,16 @@ The Emacs bridge converts markdown → org-mode automatically. Agents should out
|
||||
| `` `code` `` | `~code~` |
|
||||
| ` ```lang ` | `#+begin_src lang` |
|
||||
|
||||
If an agent outputs org-mode directly, markers get double-converted and render incorrectly.
|
||||
If an agent outputs org-mode directly, bold/italic/etc. will be double-converted
|
||||
and render incorrectly.
|
||||
|
||||
## Removal
|
||||
|
||||
See [REMOVE.md](REMOVE.md) to uninstall this channel.
|
||||
To remove the Emacs channel:
|
||||
|
||||
1. Delete `src/channels/emacs.ts`, `src/channels/emacs.test.ts`, and `emacs/nanoclaw.el`
|
||||
2. Remove `import './emacs.js'` from `src/channels/index.ts`
|
||||
3. Remove the NanoClaw block from your Emacs config file
|
||||
4. Remove Emacs registration from SQLite: `sqlite3 store/messages.db "DELETE FROM registered_groups WHERE jid = 'emacs:default'"`
|
||||
5. Remove `EMACS_CHANNEL_PORT` and `EMACS_AUTH_TOKEN` from `.env` if set
|
||||
6. Rebuild: `pnpm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `pnpm run build && systemctl --user restart nanoclaw` (Linux)
|
||||
@@ -1,67 +0,0 @@
|
||||
# Remove Google Calendar Tool
|
||||
|
||||
Idempotent — safe to run even if some steps were never applied.
|
||||
|
||||
## 1. Unregister the MCP server (per group)
|
||||
|
||||
For each group that had Calendar wired (`ncl groups list` to enumerate):
|
||||
|
||||
```bash
|
||||
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`):
|
||||
|
||||
```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>';"
|
||||
```
|
||||
|
||||
## 3. Delete the copied test file
|
||||
|
||||
```bash
|
||||
rm -f src/gcal-dockerfile.test.ts
|
||||
```
|
||||
|
||||
## 4. Revert the Dockerfile edits
|
||||
|
||||
Remove the `ARG CALENDAR_MCP_VERSION=...` line and the `@cocal/google-calendar-mcp@${CALENDAR_MCP_VERSION}` entry from the pnpm global-install block in `container/Dockerfile`. If Calendar shared the gmail install block, leave the gmail entry intact; if it had a standalone `RUN ... pnpm install -g "@cocal/google-calendar-mcp@..."` block, delete that whole `RUN` line.
|
||||
|
||||
## 5. Rebuild and restart
|
||||
|
||||
```bash
|
||||
pnpm run build && ./container/build.sh
|
||||
source setup/lib/install-slug.sh
|
||||
|
||||
# macOS
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
|
||||
|
||||
# Linux
|
||||
systemctl --user restart $(systemd_unit)
|
||||
```
|
||||
|
||||
Kill any running agent containers so they respawn without the `calendar` MCP server:
|
||||
|
||||
```bash
|
||||
docker ps -q --filter 'name=nanoclaw-v2-' | xargs -r docker kill
|
||||
```
|
||||
|
||||
## 6. Optional: remove stubs and disconnect OneCLI
|
||||
|
||||
```bash
|
||||
rm -rf ~/.calendar-mcp/
|
||||
onecli apps disconnect --provider google-calendar
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
After removal, in a wired agent asking it to "list my calendars" should report no calendar tool, and the dependency-guard test is gone:
|
||||
|
||||
```bash
|
||||
ls src/gcal-dockerfile.test.ts 2>&1 # No such file or directory
|
||||
```
|
||||
@@ -1,233 +0,0 @@
|
||||
---
|
||||
name: add-gcal-tool
|
||||
description: Add Google Calendar as an MCP tool (list calendars, list/search/create events, free/busy queries) using OneCLI-managed OAuth. Multi-calendar and multi-account supported. Mirrors /add-gmail-tool's stub pattern — no raw credentials ever reach the container; OneCLI injects real tokens at request time.
|
||||
---
|
||||
|
||||
# Add Google Calendar Tool (OneCLI-native)
|
||||
|
||||
This skill wires [`@cocal/google-calendar-mcp`](https://github.com/cocal-com/google-calendar-mcp) into selected agent groups. The MCP server reads stub credentials containing the `onecli-managed` placeholder; the OneCLI gateway intercepts outbound calls to `calendar.googleapis.com` / `oauth2.googleapis.com` and swaps the bearer for the real OAuth token from its vault.
|
||||
|
||||
**Why this package (and not gongrzhe's):** `@gongrzhe/server-calendar-autoauth-mcp` only supports the `primary` calendar and exposes 5 tools (no `list_calendars`). `@cocal/google-calendar-mcp` explicitly supports multi-calendar and multi-account, and is actively maintained.
|
||||
|
||||
Tools exposed (surfaced as `mcp__calendar__<name>`, exact set depends on version — run `tools/list` against the MCP server to enumerate): `list-calendars`, `list-events`, `search-events`, `create-event`, `update-event`, `delete-event`, `get-event`, `list-colors`, `get-freebusy`, `get-current-time`, plus multi-account management tools.
|
||||
|
||||
**Why this pattern:** v2's invariant is that containers never receive raw API keys (CHANGELOG 2.0.0). Same stub pattern `/add-gmail-tool` uses. This skill is deliberately a sibling, not a combined "Google Workspace" skill — installs independently and removes cleanly.
|
||||
|
||||
## Phase 1: Pre-flight
|
||||
|
||||
### Verify OneCLI has Google Calendar connected
|
||||
|
||||
```bash
|
||||
onecli apps get --provider google-calendar
|
||||
```
|
||||
|
||||
Expected: `"connection": { "status": "connected" }` with scopes including `calendar.readonly` and `calendar.events`.
|
||||
|
||||
If not connected, tell the user:
|
||||
|
||||
> Open the OneCLI web UI at http://127.0.0.1:10254, go to Apps → Google Calendar, and click Connect. Sign in with the Google account the agent should act as. `calendar.readonly` + `calendar.events` are the minimum useful scopes.
|
||||
|
||||
### Verify stub credentials exist
|
||||
|
||||
The stub lives at `~/.calendar-mcp/` by convention (shared with `/add-gmail-tool`'s sibling). cocal doesn't default to this path (it uses `~/.config/google-calendar-mcp/tokens.json`) — we override via env vars below so it reads our stubs instead.
|
||||
|
||||
```bash
|
||||
ls -la ~/.calendar-mcp/gcp-oauth.keys.json ~/.calendar-mcp/credentials.json 2>&1
|
||||
```
|
||||
|
||||
If both exist with `onecli-managed`:
|
||||
|
||||
```bash
|
||||
grep -l onecli-managed ~/.calendar-mcp/gcp-oauth.keys.json ~/.calendar-mcp/credentials.json
|
||||
```
|
||||
|
||||
...skip to Phase 2. If either file has real credentials (no `onecli-managed`), **STOP** — back up and delete before proceeding.
|
||||
|
||||
If absent, write them:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.calendar-mcp
|
||||
cat > ~/.calendar-mcp/gcp-oauth.keys.json <<'EOF'
|
||||
{
|
||||
"installed": {
|
||||
"client_id": "onecli-managed.apps.googleusercontent.com",
|
||||
"client_secret": "onecli-managed",
|
||||
"redirect_uris": ["http://localhost:3000/oauth2callback"]
|
||||
}
|
||||
}
|
||||
EOF
|
||||
cat > ~/.calendar-mcp/credentials.json <<'EOF'
|
||||
{
|
||||
"access_token": "onecli-managed",
|
||||
"refresh_token": "onecli-managed",
|
||||
"token_type": "Bearer",
|
||||
"expiry_date": 99999999999999,
|
||||
"scope": "https://www.googleapis.com/auth/calendar.readonly https://www.googleapis.com/auth/calendar.events"
|
||||
}
|
||||
EOF
|
||||
chmod 600 ~/.calendar-mcp/*.json
|
||||
```
|
||||
|
||||
### Verify mount allowlist covers the path
|
||||
|
||||
```bash
|
||||
cat ~/.config/nanoclaw/mount-allowlist.json
|
||||
```
|
||||
|
||||
`~/.calendar-mcp` must sit under an `allowedRoots` entry.
|
||||
|
||||
### Check agent secret-mode
|
||||
|
||||
For each target agent group, confirm OneCLI will inject the Google Calendar token:
|
||||
|
||||
```bash
|
||||
onecli agents list
|
||||
```
|
||||
|
||||
`secretMode: all` is sufficient. If `selective`, explicitly assign the Calendar secret.
|
||||
|
||||
## Phase 2: Apply Code Changes
|
||||
|
||||
### Check if already applied
|
||||
|
||||
```bash
|
||||
grep -q 'CALENDAR_MCP_VERSION' container/Dockerfile && \
|
||||
echo "ALREADY APPLIED — skip to Phase 3"
|
||||
```
|
||||
|
||||
### Add MCP server to Dockerfile
|
||||
|
||||
Edit `container/Dockerfile`. Find the pinned-version ARG block and add:
|
||||
|
||||
```dockerfile
|
||||
ARG CALENDAR_MCP_VERSION=2.6.1
|
||||
```
|
||||
|
||||
If `/add-gmail-tool` has already been applied, the pnpm global-install block already exists with its `zod-to-json-schema@3.22.5` pin. Just append the calendar package — **the calendar-mcp uses `zod@4.x` and does NOT need that pin**, but it's harmless to share the block:
|
||||
|
||||
```dockerfile
|
||||
RUN --mount=type=cache,target=/root/.cache/pnpm \
|
||||
pnpm install -g \
|
||||
"@gongrzhe/server-gmail-autoauth-mcp@${GMAIL_MCP_VERSION}" \
|
||||
"@cocal/google-calendar-mcp@${CALENDAR_MCP_VERSION}" \
|
||||
"zod-to-json-schema@3.22.5"
|
||||
```
|
||||
|
||||
If `/add-gmail-tool` hasn't been applied, install Calendar standalone:
|
||||
|
||||
```dockerfile
|
||||
RUN --mount=type=cache,target=/root/.cache/pnpm \
|
||||
pnpm install -g "@cocal/google-calendar-mcp@${CALENDAR_MCP_VERSION}"
|
||||
```
|
||||
|
||||
`container/agent-runner/src/providers/claude.ts` derives the allow-pattern dynamically from each group's `mcpServers` map (`Object.keys(this.mcpServers).map(mcpAllowPattern)`), so registering `calendar` in Phase 3 automatically allows `mcp__calendar__*`.
|
||||
|
||||
### Install the dependency-guard test
|
||||
|
||||
`@cocal/google-calendar-mcp` is a stdio CLI installed in the image, not an imported module, so `tsc` and the runtime tests never reference it — only the Dockerfile edit above proves it is present. Copy the guard test into the host test tree (vitest) so the Dockerfile `ARG` + install line stay covered:
|
||||
|
||||
```bash
|
||||
cp .claude/skills/add-gcal-tool/gcal-dockerfile.test.ts src/gcal-dockerfile.test.ts
|
||||
pnpm exec vitest run src/gcal-dockerfile.test.ts
|
||||
```
|
||||
|
||||
`cp` overwrites in place, so re-running this skill is safe.
|
||||
|
||||
### Rebuild the container image
|
||||
|
||||
```bash
|
||||
./container/build.sh
|
||||
```
|
||||
|
||||
## Phase 3: Wire Per-Agent-Group
|
||||
|
||||
For each agent group, persist two changes to the **central DB** (`data/v2.db`): the `mcpServers.calendar` entry and an `additionalMounts` entry for `.calendar-mcp`. Both flow through `materializeContainerJson` on every spawn, so editing `groups/<folder>/container.json` by hand does **not** stick — that file is regenerated from the DB.
|
||||
|
||||
### Register the MCP server
|
||||
|
||||
For each chosen `<group-id>` (use `ncl groups list` to enumerate):
|
||||
|
||||
```bash
|
||||
ncl groups config add-mcp-server \
|
||||
--id <group-id> \
|
||||
--name calendar \
|
||||
--command google-calendar-mcp \
|
||||
--args '[]' \
|
||||
--env '{"GOOGLE_OAUTH_CREDENTIALS":"/workspace/extra/.calendar-mcp/gcp-oauth.keys.json","GOOGLE_CALENDAR_MCP_TOKEN_PATH":"/workspace/extra/.calendar-mcp/credentials.json"}'
|
||||
```
|
||||
|
||||
Approval behaviour depends on where you run it: from inside an agent's container `ncl` write verbs are approval-gated (admin approves before it lands); from a host operator shell with full scope, it executes immediately. Either way, the response tells you which path it took.
|
||||
|
||||
### 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):
|
||||
|
||||
```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';"
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
`containerPath` is relative (mount-security rejects absolute paths — additional mounts land at `/workspace/extra/<relative>`).
|
||||
|
||||
**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.
|
||||
|
||||
## Phase 4: Build and Restart
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
source setup/lib/install-slug.sh
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
systemctl --user restart $(systemd_unit) # Linux
|
||||
```
|
||||
|
||||
Kill any existing agent containers so they respawn with the new mcpServers config:
|
||||
|
||||
```bash
|
||||
docker ps -q --filter 'name=nanoclaw-v2-' | xargs -r docker kill
|
||||
```
|
||||
|
||||
## Phase 5: Verify
|
||||
|
||||
### Test from a wired agent
|
||||
|
||||
> Send: **"list my calendars"** or **"what's on my work calendar next Monday?"**.
|
||||
>
|
||||
> First call takes 2–3s while the MCP server starts and OneCLI does the token exchange.
|
||||
|
||||
### Check logs if the tool isn't working
|
||||
|
||||
```bash
|
||||
tail -100 logs/nanoclaw.log | grep -iE 'calendar|mcp'
|
||||
```
|
||||
|
||||
Common signals:
|
||||
- `command not found: google-calendar-mcp` → image not rebuilt.
|
||||
- `ENOENT ...credentials.json` → mount missing. Check the mount allowlist.
|
||||
- `401 Unauthorized` from `*.googleapis.com` → OneCLI isn't injecting; verify agent's secret mode and that Google Calendar is connected.
|
||||
- Agent says "I don't have calendar tools" → the `calendar` MCP server isn't registered in this group's `mcpServers` (re-run the `ncl groups config add-mcp-server` step in Phase 3 for that group and restart it), or the agent-runner image is stale (`./container/build.sh`, `--no-cache` if suspicious).
|
||||
|
||||
## Removal
|
||||
|
||||
See [REMOVE.md](REMOVE.md) — unregisters the MCP server, drops the `.calendar-mcp` mount, deletes the copied test, reverts the Dockerfile edits, and rebuilds.
|
||||
|
||||
## Credits & references
|
||||
|
||||
- **MCP server:** [`@cocal/google-calendar-mcp`](https://github.com/cocal-com/google-calendar-mcp) — MIT-licensed, actively maintained, multi-account and multi-calendar.
|
||||
- **Why not gongrzhe:** `@gongrzhe/server-calendar-autoauth-mcp` only supports the primary calendar with 5 event-level tools. The cocal server supports multi-account and multi-calendar with the full tool surface.
|
||||
- **Skill pattern:** direct sibling of [`/add-gmail-tool`](../add-gmail-tool/SKILL.md); same OneCLI stub mechanism.
|
||||
@@ -1,36 +0,0 @@
|
||||
/**
|
||||
* Dependency guard for the Google Calendar MCP server (host/vitest tree).
|
||||
*
|
||||
* `@cocal/google-calendar-mcp` is a stdio CLI installed globally in the image,
|
||||
* not an imported module, so no behavior test can drive it and `tsc` never sees
|
||||
* it. The only in-tree footprint of this skill is the Dockerfile edit, so the
|
||||
* guard is structural: assert the pinned `ARG` and the pnpm global-install line
|
||||
* both exist. Drop either Phase 2 Dockerfile edit and this goes red.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
function dockerfile(): string {
|
||||
const p = path.resolve(process.cwd(), 'container/Dockerfile');
|
||||
return fs.readFileSync(p, 'utf8');
|
||||
}
|
||||
|
||||
describe('container/Dockerfile installs @cocal/google-calendar-mcp', () => {
|
||||
const text = dockerfile();
|
||||
|
||||
it('pins the version via an ARG', () => {
|
||||
expect(text).toMatch(/^\s*ARG\s+CALENDAR_MCP_VERSION=/m);
|
||||
});
|
||||
|
||||
it('installs the package pinned to that ARG in a pnpm global-install block', () => {
|
||||
// Match `pnpm install -g ... "@cocal/google-calendar-mcp@${CALENDAR_MCP_VERSION}"`,
|
||||
// tolerating line continuations between `install -g` and the package.
|
||||
const installsCalendar =
|
||||
/pnpm\s+install\s+-g[\s\S]*?@cocal\/google-calendar-mcp@\$\{CALENDAR_MCP_VERSION\}/.test(
|
||||
text,
|
||||
);
|
||||
expect(installsCalendar).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
# Remove Google Chat Channel
|
||||
|
||||
1. Comment out `import './gchat.js'` in `src/channels/index.ts`
|
||||
2. Remove `GCHAT_CREDENTIALS` from `.env`
|
||||
3. `pnpm uninstall @chat-adapter/gchat`
|
||||
4. Rebuild and restart
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
name: add-gchat-v2
|
||||
description: Add Google Chat channel integration to NanoClaw v2 via Chat SDK.
|
||||
---
|
||||
|
||||
# Add Google Chat Channel
|
||||
|
||||
Adds Google Chat support to NanoClaw v2 using the Chat SDK bridge.
|
||||
|
||||
## Pre-flight
|
||||
|
||||
Check if `src/channels/gchat.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Credentials.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/gchat
|
||||
```
|
||||
|
||||
Uncomment the Google Chat import in `src/channels/index.ts`:
|
||||
|
||||
```typescript
|
||||
import './gchat.js';
|
||||
```
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
## Credentials
|
||||
|
||||
> 1. Go to [Google Cloud Console](https://console.cloud.google.com)
|
||||
> 2. Create or select a project
|
||||
> 3. Enable the **Google Chat API**
|
||||
> 4. Go to **Google Chat API** > **Configuration**:
|
||||
> - App name and description
|
||||
> - Connection settings: select **HTTP endpoint URL** and set to `https://your-domain/webhook/gchat`
|
||||
> 5. Create a **Service Account**:
|
||||
> - Go to **IAM & Admin** > **Service Accounts** > **Create Service Account**
|
||||
> - Grant the Chat Bot role
|
||||
> - Create a JSON key and download it
|
||||
|
||||
### Configure environment
|
||||
|
||||
Add the service account JSON as a single-line string to `.env`:
|
||||
|
||||
```bash
|
||||
GCHAT_CREDENTIALS={"type":"service_account","project_id":"...","private_key":"...","client_email":"..."}
|
||||
```
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
|
||||
## 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.
|
||||
|
||||
## Channel Info
|
||||
|
||||
- **type**: `gchat`
|
||||
- **terminology**: Google Chat has "spaces." A space can be a group conversation or a direct message with the bot.
|
||||
- **how-to-find-id**: Open the space in Google Chat, look at the URL — the space ID is the segment after `/space/` (e.g. `spaces/AAAA...`). Or use the Google Chat API to list spaces.
|
||||
- **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.
|
||||
@@ -0,0 +1,3 @@
|
||||
# Verify Google Chat Channel
|
||||
|
||||
Add the bot to a Google Chat space, then send a message or @mention the bot. The bot should respond within a few seconds.
|
||||
@@ -1,40 +0,0 @@
|
||||
# Remove Google Chat
|
||||
|
||||
Every step is idempotent — safe to re-run.
|
||||
|
||||
## 1. Remove the adapter
|
||||
|
||||
Delete the self-registration import from `src/channels/index.ts` (skip if already gone):
|
||||
|
||||
```typescript
|
||||
import './gchat.js';
|
||||
```
|
||||
|
||||
Then delete the copied adapter and its registration test:
|
||||
|
||||
```bash
|
||||
rm -f src/channels/gchat.ts src/channels/gchat-registration.test.ts
|
||||
```
|
||||
|
||||
## 2. Remove credentials
|
||||
|
||||
Remove `GCHAT_CREDENTIALS` from `.env`, then re-sync to the container:
|
||||
|
||||
```bash
|
||||
mkdir -p data/env && cp .env data/env/env
|
||||
```
|
||||
|
||||
## 3. Remove the package
|
||||
|
||||
```bash
|
||||
pnpm uninstall @chat-adapter/gchat
|
||||
```
|
||||
|
||||
## 4. Rebuild and restart
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
source setup/lib/install-slug.sh
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
# Linux: systemctl --user restart $(systemd_unit)
|
||||
```
|
||||
@@ -1,99 +0,0 @@
|
||||
---
|
||||
name: add-gchat
|
||||
description: Add Google Chat channel integration via Chat SDK.
|
||||
---
|
||||
|
||||
# Add Google Chat Channel
|
||||
|
||||
Adds Google Chat support via the Chat SDK bridge.
|
||||
|
||||
## Install
|
||||
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the Google Chat adapter in from the `channels` branch.
|
||||
|
||||
### Pre-flight (idempotent)
|
||||
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
|
||||
- `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
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and its registration test
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
```typescript
|
||||
import './gchat.js';
|
||||
```
|
||||
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/gchat@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
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.
|
||||
|
||||
## Credentials
|
||||
|
||||
> 1. Go to [Google Cloud Console](https://console.cloud.google.com)
|
||||
> 2. Create or select a project
|
||||
> 3. Enable the **Google Chat API**
|
||||
> 4. Go to **Google Chat API** > **Configuration**:
|
||||
> - App name and description
|
||||
> - Connection settings: select **HTTP endpoint URL** and set to `https://your-domain/webhook/gchat`
|
||||
> 5. Create a **Service Account**:
|
||||
> - Go to **IAM & Admin** > **Service Accounts** > **Create Service Account**
|
||||
> - Grant the Chat Bot role
|
||||
> - Create a JSON key and download it
|
||||
|
||||
### Configure environment
|
||||
|
||||
Add the service account JSON as a single-line string to `.env`:
|
||||
|
||||
```bash
|
||||
GCHAT_CREDENTIALS={"type":"service_account","project_id":"...","private_key":"...","client_email":"..."}
|
||||
```
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
|
||||
## 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.
|
||||
|
||||
## Channel Info
|
||||
|
||||
- **type**: `gchat`
|
||||
- **terminology**: Google Chat has "spaces." A space can be a group conversation or a direct message with the bot.
|
||||
- **how-to-find-id**: Open the space in Google Chat, look at the URL — the space ID is the segment after `/space/` (e.g. `spaces/AAAA...`). Or use the Google Chat API to list spaces.
|
||||
- **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.
|
||||
@@ -0,0 +1,6 @@
|
||||
# Remove GitHub Channel
|
||||
|
||||
1. Comment out `import './github.js'` in `src/channels/index.ts`
|
||||
2. Remove `GITHUB_TOKEN` and `GITHUB_WEBHOOK_SECRET` from `.env`
|
||||
3. `pnpm uninstall @chat-adapter/github`
|
||||
4. Rebuild and restart
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
name: add-github-v2
|
||||
description: Add GitHub channel integration to NanoClaw v2 via Chat SDK. PR and issue comment threads as conversations.
|
||||
---
|
||||
|
||||
# Add GitHub Channel
|
||||
|
||||
Adds GitHub support to NanoClaw v2 using the Chat SDK bridge. The agent participates in PR and issue comment threads.
|
||||
|
||||
## Pre-flight
|
||||
|
||||
Check if `src/channels/github.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Credentials.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/github
|
||||
```
|
||||
|
||||
Uncomment the GitHub import in `src/channels/index.ts`:
|
||||
|
||||
```typescript
|
||||
import './github.js';
|
||||
```
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
## Credentials
|
||||
|
||||
> 1. Go to [GitHub Settings > Developer Settings > Personal Access Tokens](https://github.com/settings/tokens)
|
||||
> 2. Create a **Fine-grained token** with:
|
||||
> - Repository access: select the repos you want the bot to monitor
|
||||
> - Permissions: **Pull requests** (Read & Write), **Issues** (Read & Write)
|
||||
> 3. Copy the token
|
||||
> 4. Set up a webhook on your repo(s):
|
||||
> - Go to **Settings** > **Webhooks** > **Add webhook**
|
||||
> - Payload URL: `https://your-domain/webhook/github`
|
||||
> - Content type: `application/json`
|
||||
> - Secret: generate a random string
|
||||
> - Events: select **Issue comments**, **Pull request review comments**
|
||||
|
||||
### Configure environment
|
||||
|
||||
Add to `.env`:
|
||||
|
||||
```bash
|
||||
GITHUB_TOKEN=github_pat_...
|
||||
GITHUB_WEBHOOK_SECRET=your-webhook-secret
|
||||
```
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
|
||||
## 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.
|
||||
|
||||
## Channel Info
|
||||
|
||||
- **type**: `github`
|
||||
- **terminology**: GitHub has "repositories" containing "pull requests" and "issues." Each PR or issue comment thread is a separate conversation.
|
||||
- **how-to-find-id**: The platform ID is `owner/repo` (e.g. `acme/backend`). Each PR/issue becomes its own thread automatically.
|
||||
- **supports-threads**: yes (PR and issue comment threads are native conversations)
|
||||
- **typical-use**: Webhook/notification — the agent receives PR and issue events and responds in comment threads
|
||||
- **default-isolation**: Typically shares a session with a chat channel (e.g. Slack) so the agent can summarize PRs and respond to reviews in the same context. Use a separate agent group if the repo contains sensitive code that other channels shouldn't access.
|
||||
@@ -0,0 +1,3 @@
|
||||
# Verify GitHub Channel
|
||||
|
||||
@mention the bot in a PR comment or issue comment. The bot should respond within a few seconds.
|
||||
@@ -1,40 +0,0 @@
|
||||
# Remove GitHub
|
||||
|
||||
Every step is idempotent — safe to re-run.
|
||||
|
||||
## 1. Remove the adapter
|
||||
|
||||
Delete the self-registration import from `src/channels/index.ts` (skip if already gone):
|
||||
|
||||
```typescript
|
||||
import './github.js';
|
||||
```
|
||||
|
||||
Then delete the copied adapter and its registration test:
|
||||
|
||||
```bash
|
||||
rm -f src/channels/github.ts src/channels/github-registration.test.ts
|
||||
```
|
||||
|
||||
## 2. Remove credentials
|
||||
|
||||
Remove `GITHUB_TOKEN`, `GITHUB_WEBHOOK_SECRET`, and `GITHUB_BOT_USERNAME` from `.env`, then re-sync to the container:
|
||||
|
||||
```bash
|
||||
mkdir -p data/env && cp .env data/env/env
|
||||
```
|
||||
|
||||
## 3. Remove the package
|
||||
|
||||
```bash
|
||||
pnpm uninstall @chat-adapter/github
|
||||
```
|
||||
|
||||
## 4. Rebuild and restart
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
source setup/lib/install-slug.sh
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
# Linux: systemctl --user restart $(systemd_unit)
|
||||
```
|
||||
@@ -1,163 +0,0 @@
|
||||
---
|
||||
name: add-github
|
||||
description: Add GitHub channel integration via Chat SDK. PR and issue comment threads as conversations.
|
||||
---
|
||||
|
||||
# Add GitHub Channel
|
||||
|
||||
Adds GitHub support via the Chat SDK bridge. The agent participates in PR and issue comment threads.
|
||||
|
||||
## 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
|
||||
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the GitHub adapter in from the `channels` branch.
|
||||
|
||||
### Pre-flight (idempotent)
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and its registration test
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
```typescript
|
||||
import './github.js';
|
||||
```
|
||||
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/github@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
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`.
|
||||
|
||||
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.
|
||||
|
||||
## Credentials
|
||||
|
||||
### 1. Create a Personal Access Token for the bot account
|
||||
|
||||
Log in as your **bot account**, then:
|
||||
|
||||
1. Go to [Settings > Developer Settings > Personal Access Tokens](https://github.com/settings/tokens)
|
||||
2. Create a **Fine-grained token** with:
|
||||
- Repository access: select the repos you want the bot to monitor
|
||||
- Permissions: **Pull requests** (Read & Write), **Issues** (Read & Write)
|
||||
3. Copy the token
|
||||
|
||||
### 2. Set up a webhook on each repo
|
||||
|
||||
On each repo (logged in as the repo owner/admin):
|
||||
|
||||
1. Go to **Settings** > **Webhooks** > **Add webhook**
|
||||
2. Payload URL: `https://your-domain/webhook/github` (the shared webhook server, default port 3000)
|
||||
3. Content type: `application/json`
|
||||
4. Secret: generate a random string (e.g. `openssl rand -hex 20`)
|
||||
5. Events: select **Issue comments** and **Pull request review comments**
|
||||
|
||||
### 3. Configure environment
|
||||
|
||||
Add to `.env`:
|
||||
|
||||
```bash
|
||||
GITHUB_TOKEN=github_pat_...
|
||||
GITHUB_WEBHOOK_SECRET=your-webhook-secret
|
||||
GITHUB_BOT_USERNAME=your-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?**
|
||||
|
||||
- **Private repo** — use `unknown_sender_policy: 'public'`. Only collaborators can comment anyway, so it's safe to let all comments through.
|
||||
- **Public repo** — use `unknown_sender_policy: 'strict'`. Only registered members can trigger the agent, preventing strangers from consuming agent resources. Add trusted collaborators as members (see below).
|
||||
|
||||
Run `/manage-channels` to wire the GitHub channel to an agent group, or insert manually:
|
||||
|
||||
```sql
|
||||
-- Create messaging group (one per repo)
|
||||
INSERT INTO messaging_groups (id, channel_type, platform_id, instance, name, is_group, unknown_sender_policy, created_at)
|
||||
VALUES ('mg-github-myrepo', 'github', 'github:owner/repo', 'github', 'owner/repo', 1, '<policy>', 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-github-myrepo', 'mg-github-myrepo', '<your-agent-group-id>', '', 'all', 'per-thread', 10, datetime('now'));
|
||||
```
|
||||
|
||||
Replace `<policy>` with `public` or `strict` based on the user's choice above.
|
||||
|
||||
### Adding members (for strict mode)
|
||||
|
||||
When using `strict`, add each GitHub user who should be able to trigger the agent:
|
||||
|
||||
```sql
|
||||
-- Add user (kind = 'github', id = 'github:<numeric-user-id>')
|
||||
INSERT OR IGNORE INTO users (id, kind, display_name, created_at)
|
||||
VALUES ('github:<user-id>', 'github', '<username>', datetime('now'));
|
||||
|
||||
-- Grant membership to the agent group
|
||||
INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id)
|
||||
VALUES ('github:<user-id>', '<agent-group-id>');
|
||||
```
|
||||
|
||||
To find a GitHub user's numeric ID: `gh api users/<username> --jq .id`
|
||||
|
||||
Use `per-thread` session mode so each PR/issue gets its own agent session.
|
||||
|
||||
## Next Steps
|
||||
|
||||
If you're in the middle of `/setup`, return to the setup flow now.
|
||||
|
||||
Otherwise, restart the service to pick up the new channel.
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
source setup/lib/install-slug.sh
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
systemctl --user restart $(systemd_unit) # Linux
|
||||
```
|
||||
|
||||
## Channel Info
|
||||
|
||||
- **type**: `github`
|
||||
- **terminology**: GitHub has "repositories" containing "pull requests" and "issues." Each PR or issue comment thread is a separate conversation.
|
||||
- **how-to-find-id**: The platform ID is `github:owner/repo` (e.g. `github:acme/backend`). Each PR/issue becomes its own thread automatically.
|
||||
- **supports-threads**: yes (PR and issue comment threads are native conversations)
|
||||
- **typical-use**: Webhook-driven — the agent receives PR and issue comment events and responds in comment threads when @-mentioned. After the first mention, the thread is subscribed and the agent responds to all follow-up comments.
|
||||
- **default-isolation**: Use `per-thread` session mode. Each PR or issue gets its own isolated agent session. Typically wire to a dedicated agent group if the repo contains sensitive code.
|
||||
@@ -1,57 +0,0 @@
|
||||
# Remove Gmail Tool
|
||||
|
||||
Idempotent — safe to run even if some steps were never applied.
|
||||
|
||||
## 1. Delete the copied tests
|
||||
|
||||
```bash
|
||||
rm -f container/agent-runner/src/providers/gmail-dockerfile.test.ts \
|
||||
container/agent-runner/src/providers/gmail-allow-pattern.test.ts
|
||||
```
|
||||
|
||||
## 2. Unregister the MCP server (per group)
|
||||
|
||||
`ncl groups list` shows the groups. For each group that had Gmail wired:
|
||||
|
||||
```bash
|
||||
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):
|
||||
|
||||
```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';"
|
||||
```
|
||||
|
||||
## 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`.
|
||||
|
||||
## 5. Rebuild and restart
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
pnpm run build && ./container/build.sh
|
||||
source setup/lib/install-slug.sh
|
||||
|
||||
# macOS
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
|
||||
|
||||
# Linux
|
||||
systemctl --user restart $(systemd_unit)
|
||||
```
|
||||
|
||||
## 6. (Optional) Drop the host stubs and disconnect
|
||||
|
||||
```bash
|
||||
rm -rf ~/.gmail-mcp/ # only if no other host tool needs the stubs
|
||||
onecli apps disconnect --provider gmail # revoke the OneCLI Gmail connection
|
||||
```
|
||||
@@ -1,262 +0,0 @@
|
||||
---
|
||||
name: add-gmail-tool
|
||||
description: Add Gmail as an MCP tool (read, search, send, label, draft) using OneCLI-managed OAuth. The agent gets Gmail tools in every enabled group; OneCLI injects real tokens at request time so no raw credentials are ever in the container or on disk in usable form.
|
||||
---
|
||||
|
||||
# Add Gmail Tool (OneCLI-native)
|
||||
|
||||
This skill wires the [`@gongrzhe/server-gmail-autoauth-mcp`](https://www.npmjs.com/package/@gongrzhe/server-gmail-autoauth-mcp) stdio MCP server into selected agent groups. The MCP server reads stub credentials containing the `onecli-managed` placeholder; the OneCLI gateway intercepts outbound calls to `gmail.googleapis.com` and injects the real OAuth bearer from its vault.
|
||||
|
||||
Tools exposed (from `gmail-mcp@1.1.11`, surfaced to the agent as `mcp__gmail__<name>`): `search_emails`, `read_email`, `send_email`, `draft_email`, `delete_email`, `modify_email`, `batch_modify_emails`, `batch_delete_emails`, `download_attachment`, `list_email_labels`, `create_label`, `update_label`, `delete_label`, `get_or_create_label`, `list_filters`, `get_filter`, `create_filter`, `create_filter_from_template`, `delete_filter`.
|
||||
|
||||
**Why this pattern:** v2's invariant is that containers never receive raw API keys — OneCLI is the sole credential path (see CHANGELOG v2.0.0). The stub-file pattern satisfies this: the container sees `"onecli-managed"` placeholders, the gateway swaps them in flight.
|
||||
|
||||
## Phase 1: Pre-flight
|
||||
|
||||
### Verify OneCLI has Gmail connected
|
||||
|
||||
```bash
|
||||
onecli apps get --provider gmail
|
||||
```
|
||||
|
||||
Expected: `"connection": { "status": "connected" }` with scopes including `gmail.readonly`, `gmail.modify`, `gmail.send`.
|
||||
|
||||
If not connected, tell the user:
|
||||
|
||||
> Open the OneCLI web UI at http://127.0.0.1:10254, go to Apps → Gmail, and click Connect. Sign in with the Google account you want the agent to act as.
|
||||
|
||||
### Verify stub credentials exist
|
||||
|
||||
```bash
|
||||
ls -la ~/.gmail-mcp/gcp-oauth.keys.json ~/.gmail-mcp/credentials.json 2>&1
|
||||
```
|
||||
|
||||
If both exist and contain `"onecli-managed"`:
|
||||
|
||||
```bash
|
||||
grep -l onecli-managed ~/.gmail-mcp/gcp-oauth.keys.json ~/.gmail-mcp/credentials.json
|
||||
```
|
||||
|
||||
...skip to Phase 2.
|
||||
|
||||
If either file exists but does **not** contain `onecli-managed`, **STOP** and tell the user — these are real OAuth credentials from a previous non-OneCLI install. Back them up, then delete before proceeding. The OneCLI migration normally handles this; if it didn't, something is wrong.
|
||||
|
||||
If both files are absent, write them now:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.gmail-mcp
|
||||
cat > ~/.gmail-mcp/gcp-oauth.keys.json <<'EOF'
|
||||
{
|
||||
"installed": {
|
||||
"client_id": "onecli-managed.apps.googleusercontent.com",
|
||||
"client_secret": "onecli-managed",
|
||||
"redirect_uris": ["http://localhost:3000/oauth2callback"]
|
||||
}
|
||||
}
|
||||
EOF
|
||||
cat > ~/.gmail-mcp/credentials.json <<'EOF'
|
||||
{
|
||||
"access_token": "onecli-managed",
|
||||
"refresh_token": "onecli-managed",
|
||||
"token_type": "Bearer",
|
||||
"expiry_date": 99999999999999,
|
||||
"scope": "https://www.googleapis.com/auth/gmail.readonly https://www.googleapis.com/auth/gmail.modify https://www.googleapis.com/auth/gmail.send"
|
||||
}
|
||||
EOF
|
||||
chmod 600 ~/.gmail-mcp/gcp-oauth.keys.json ~/.gmail-mcp/credentials.json
|
||||
```
|
||||
|
||||
### Verify mount allowlist covers the path
|
||||
|
||||
```bash
|
||||
cat ~/.config/nanoclaw/mount-allowlist.json
|
||||
```
|
||||
|
||||
`~/.gmail-mcp` must sit under an `allowedRoots` entry (e.g. `/home/<user>`). If it doesn't, tell the user to run `/manage-mounts` first or add their home directory.
|
||||
|
||||
### Check agent secret-mode
|
||||
|
||||
For each target agent group, confirm OneCLI will inject Gmail secrets into its container. Find the OneCLI agent ID that matches the group's `agentGroupId`:
|
||||
|
||||
```bash
|
||||
onecli agents list
|
||||
```
|
||||
|
||||
If that agent's `secretMode` is `all`, you're done — Gmail secrets (identified by OneCLI's Gmail hostPattern) will auto-inject. If it's `selective`, explicitly assign the Gmail secrets using the safe merge pattern (`set-secrets` replaces the entire list — always read first):
|
||||
|
||||
```bash
|
||||
GMAIL_IDS=$(onecli secrets list | jq -r '[.data[] | select(.name | test("(?i)gmail")) | .id] | join(",")')
|
||||
CURRENT=$(onecli agents secrets --id <agent-id> | jq -r '[.data[]] | join(",")')
|
||||
MERGED=$(printf '%s' "$CURRENT,$GMAIL_IDS" | tr ',' '\n' | sort -u | paste -sd ',' -)
|
||||
onecli agents set-secrets --id <agent-id> --secret-ids "$MERGED"
|
||||
onecli agents secrets --id <agent-id>
|
||||
```
|
||||
|
||||
## Phase 2: Apply Code Changes
|
||||
|
||||
### Check if already applied
|
||||
|
||||
```bash
|
||||
grep -q 'GMAIL_MCP_VERSION' container/Dockerfile && \
|
||||
echo "ALREADY APPLIED — skip to Phase 3"
|
||||
```
|
||||
|
||||
### Copy the skill's tests into the container tree
|
||||
|
||||
Both integration points this skill relies on live in the container (Bun) tree — the Dockerfile package install and the dynamic allow-pattern derivation in `claude.ts` — so the guards go there. `cp` overwrites, so re-running is safe.
|
||||
|
||||
```bash
|
||||
S=.claude/skills/add-gmail-tool
|
||||
cp $S/gmail-dockerfile.test.ts container/agent-runner/src/providers/gmail-dockerfile.test.ts
|
||||
cp $S/gmail-allow-pattern.test.ts container/agent-runner/src/providers/gmail-allow-pattern.test.ts
|
||||
```
|
||||
|
||||
- `gmail-dockerfile.test.ts` asserts the `GMAIL_MCP_VERSION` ARG and the pinned `pnpm install -g` line are present — the `gmail-mcp` binary is a Dockerfile-installed CLI, not importable or typed, so this structural guard is what goes red if the install is dropped.
|
||||
- `gmail-allow-pattern.test.ts` asserts `claude.ts` still spreads `Object.keys(this.mcpServers).map(mcpAllowPattern)` into `allowedTools` — the derivation that makes registering `gmail` (Phase 3) enough to expose `mcp__gmail__*`.
|
||||
|
||||
### Add MCP server to Dockerfile
|
||||
|
||||
Edit `container/Dockerfile`. Find the pinned-version ARG block:
|
||||
|
||||
```dockerfile
|
||||
ARG CLAUDE_CODE_VERSION=2.1.154
|
||||
ARG AGENT_BROWSER_VERSION=latest
|
||||
ARG VERCEL_VERSION=52.2.1
|
||||
ARG BUN_VERSION=1.3.12
|
||||
```
|
||||
|
||||
Add a new line:
|
||||
|
||||
```dockerfile
|
||||
ARG GMAIL_MCP_VERSION=1.1.11
|
||||
```
|
||||
|
||||
Then find the last pnpm global-install `RUN` block (the one that installs `@anthropic-ai/claude-code`) and add a new block directly after it (before the `# ---- ncl CLI wrapper` section):
|
||||
|
||||
```dockerfile
|
||||
RUN --mount=type=cache,target=/root/.cache/pnpm \
|
||||
pnpm install -g \
|
||||
"@gongrzhe/server-gmail-autoauth-mcp@${GMAIL_MCP_VERSION}" \
|
||||
"zod-to-json-schema@3.22.5"
|
||||
```
|
||||
|
||||
Pinned version matters — `minimumReleaseAge` in `pnpm-workspace.yaml` gates trunk installs, and CLAUDE.md requires a fixed ARG version for all Node CLIs installed into the image.
|
||||
|
||||
**Why the `zod-to-json-schema` pin:** `@gongrzhe/server-gmail-autoauth-mcp@1.1.11` has loose deps (`zod-to-json-schema: ^3.22.1`, `zod: ^3.22.4`). pnpm resolves `zod-to-json-schema` to the latest 3.25.x, which imports `zod/v3` — a subpath that only exists in `zod>=3.25`. But `zod` resolves to `3.24.x` (highest satisfying `^3.22.4` without breaking peer ranges). Result: `ERR_PACKAGE_PATH_NOT_EXPORTED` at import time. Pinning `zod-to-json-schema` to a pre-v3-subpath version avoids it. Re-check if you bump `GMAIL_MCP_VERSION`.
|
||||
|
||||
The Gmail allow-pattern is derived automatically. `container/agent-runner/src/providers/claude.ts` builds `allowedTools` from each group's `mcpServers` map (`Object.keys(this.mcpServers).map(mcpAllowPattern)`), so registering `gmail` in Phase 3 exposes `mcp__gmail__*` to the agent.
|
||||
|
||||
### Rebuild the container image
|
||||
|
||||
```bash
|
||||
./container/build.sh
|
||||
```
|
||||
|
||||
Must complete cleanly. The new `pnpm install -g` layer is ~60s first time (cached on rebuild).
|
||||
|
||||
## Phase 3: Wire Per-Agent-Group
|
||||
|
||||
For each agent group that should have Gmail (ask the user — typically their personal DM and CLI agents, sometimes shared household agents), persist two changes to the **central DB** (`data/v2.db`): the `mcpServers.gmail` entry and an `additionalMounts` entry for `.gmail-mcp`. Both flow through `materializeContainerJson` on every spawn, so editing `groups/<folder>/container.json` by hand does **not** stick — that file is regenerated from the DB.
|
||||
|
||||
### List groups, pick which ones get Gmail
|
||||
|
||||
```bash
|
||||
ncl groups list
|
||||
```
|
||||
|
||||
### Register the MCP server
|
||||
|
||||
For each chosen `<group-id>`:
|
||||
|
||||
```bash
|
||||
ncl groups config add-mcp-server \
|
||||
--id <group-id> \
|
||||
--name gmail \
|
||||
--command gmail-mcp \
|
||||
--args '[]' \
|
||||
--env '{"GMAIL_OAUTH_PATH":"/workspace/extra/.gmail-mcp/gcp-oauth.keys.json","GMAIL_CREDENTIALS_PATH":"/workspace/extra/.gmail-mcp/credentials.json"}'
|
||||
```
|
||||
|
||||
Approval behaviour depends on where you run it: from inside an agent's container `ncl` write verbs are approval-gated (admin approves before it lands); from a host operator shell with full scope, it executes immediately. Either way, the response tells you which path it took.
|
||||
|
||||
### 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):
|
||||
|
||||
```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';"
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
**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.
|
||||
|
||||
**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.
|
||||
|
||||
## Phase 4: Build, Validate, Restart
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit
|
||||
(cd container/agent-runner && bun test src/providers/gmail-dockerfile.test.ts src/providers/gmail-allow-pattern.test.ts)
|
||||
```
|
||||
|
||||
All must be clean before proceeding. `gmail-dockerfile.test.ts` confirms the package install is wired into the image; `gmail-allow-pattern.test.ts` confirms the allow-pattern derivation that exposes `mcp__gmail__*`. A failure means one drifted.
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
source setup/lib/install-slug.sh
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
systemctl --user restart $(systemd_unit) # Linux
|
||||
```
|
||||
|
||||
## Phase 5: Verify
|
||||
|
||||
### Test from the wired agent
|
||||
|
||||
Tell the user:
|
||||
|
||||
> In your `<agent-name>` chat, send: **"list my gmail labels"** or **"search my inbox for invoices from last month"**.
|
||||
>
|
||||
> The agent should use `mcp__gmail__list_labels` / `mcp__gmail__search`. The first call may take a second or two while the MCP server starts and OneCLI does the token exchange.
|
||||
|
||||
### Check logs if the tool isn't working
|
||||
|
||||
```bash
|
||||
tail -100 logs/nanoclaw.log logs/nanoclaw.error.log | grep -iE 'gmail|mcp'
|
||||
# Per-container logs — session-scoped:
|
||||
ls data/v2-sessions/*/stderr.log | head
|
||||
```
|
||||
|
||||
Common signals:
|
||||
- `command not found: gmail-mcp` → image wasn't rebuilt or PATH doesn't include `/pnpm` (should — `ENV PATH="$PNPM_HOME:$PATH"` in Dockerfile).
|
||||
- `ENOENT: no such file or directory, open '/workspace/extra/.gmail-mcp/credentials.json'` → mount is missing. Check `~/.config/nanoclaw/mount-allowlist.json` includes a parent of `~/.gmail-mcp`.
|
||||
- `401 Unauthorized` from `gmail.googleapis.com` → OneCLI isn't injecting. Check the agent's secret mode (`onecli agents secrets --id <agent-id>`) and that the Gmail app is connected (`onecli apps get --provider gmail`).
|
||||
- Agent says "I don't have Gmail tools" → the `gmail` MCP server isn't registered in this group's `mcpServers` (re-run the `ncl groups config add-mcp-server` step in Phase 3 for that group and restart it), or the agent-runner image is stale (rebuild with `./container/build.sh`, with `--no-cache` if suspicious).
|
||||
|
||||
## Removal
|
||||
|
||||
See [REMOVE.md](REMOVE.md) for the idempotent removal procedure (delete the copied tests, unregister the MCP server per group, drop the mount, remove the Dockerfile install, rebuild, and optionally drop the stubs and disconnect OneCLI).
|
||||
|
||||
## Notes
|
||||
|
||||
- **Stub format is OneCLI-prescribed.** The `access_token: "onecli-managed"` pattern with `expiry_date: 99999999999999` tells the Google auth client the token is valid; OneCLI intercepts the outgoing Gmail API call and rewrites `Authorization: Bearer onecli-managed` to the real token. `expiry_date: 0` (refresh-interception) is an alternative the OneCLI docs describe — both work but OneCLI's own `migrate` command writes the far-future variant, which is what this skill assumes.
|
||||
- **Scopes are set at OAuth connect time.** If the agent needs scopes beyond what's currently connected (e.g. the user later wants `calendar.readonly` for combined email/calendar workflows), disconnect and reconnect Gmail in the OneCLI web UI with the expanded scope set.
|
||||
- **This is tool-only.** Inbound email as a channel (emails trigger the agent) is a separate piece of work — it needs a `src/channels/gmail.ts` adapter that polls the inbox and routes to a messaging group. The pre-v2 qwibitai skill had this; it has not been ported to v2's channel architecture as of v2.0.0.
|
||||
|
||||
## Credits & references
|
||||
|
||||
- **MCP server:** [`@gongrzhe/server-gmail-autoauth-mcp`](https://github.com/GongRzhe/Gmail-MCP-Server) by GongRzhe — MIT-licensed.
|
||||
- **OneCLI credential stubs:** pattern documented at `https://onecli.sh/docs/guides/credential-stubs/gmail.md`.
|
||||
- **Skill pattern:** modeled on [`add-atomic-chat-tool`](../add-atomic-chat-tool/SKILL.md) and [`add-vercel`](../add-vercel/SKILL.md).
|
||||
- **Addresses:** [issue #1500](https://github.com/nanocoai/nanoclaw/issues/1500) (proxy Gmail/Calendar OAuth tokens through credential proxy) for the Gmail side.
|
||||
- **Related PRs:** [#1810](https://github.com/nanocoai/nanoclaw/pull/1810) (pre-install Gmail/Notion MCP) overlaps on the "install the MCP server in the image" idea but bundles many unrelated changes; this skill is the focused OneCLI-native version.
|
||||
@@ -1,55 +0,0 @@
|
||||
/**
|
||||
* Guard for the dynamic MCP allow-pattern derivation this skill depends on.
|
||||
*
|
||||
* Registering `gmail` in a group's mcpServers map is the *only* wiring needed to expose
|
||||
* `mcp__gmail__*` to the agent — there is no static TOOL_ALLOWLIST edit. That holds solely
|
||||
* because `claude.ts` derives the allow-pattern from the registered servers at query time:
|
||||
*
|
||||
* 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__*`.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import ts from 'typescript';
|
||||
|
||||
function source(): { sf: ts.SourceFile; text: string } {
|
||||
const p = path.join(import.meta.dir, 'claude.ts');
|
||||
const text = fs.readFileSync(p, 'utf8');
|
||||
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();
|
||||
|
||||
it('defines an mcpAllowPattern function', () => {
|
||||
let found = false;
|
||||
const visit = (node: ts.Node) => {
|
||||
if (ts.isFunctionDeclaration(node) && node.name?.text === 'mcpAllowPattern') found = true;
|
||||
if (!found) ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(sf);
|
||||
expect(found).toBe(true);
|
||||
});
|
||||
|
||||
it('spreads Object.keys(this.mcpServers).map(mcpAllowPattern) into allowedTools', () => {
|
||||
// Normalize whitespace so formatting changes don't break the assertion.
|
||||
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__*');
|
||||
});
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
/**
|
||||
* Structural guard for the Gmail MCP package-install integration point (container image).
|
||||
*
|
||||
* `@gongrzhe/server-gmail-autoauth-mcp` is a CLI binary installed into the image via the
|
||||
* Dockerfile — it is not importable or typed from this tree, so the build leg can't catch
|
||||
* its removal and there's no runtime seam to behavior-test. This asserts the Dockerfile
|
||||
* still carries the ARG and the pinned pnpm global-install line. Drop either and this goes
|
||||
* red, signalling the agent would boot without the `gmail-mcp` binary on PATH.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
|
||||
function dockerfile(): string {
|
||||
// container/agent-runner/src/providers/ -> ../../../Dockerfile == container/Dockerfile
|
||||
const p = path.join(import.meta.dir, '..', '..', '..', 'Dockerfile');
|
||||
return fs.readFileSync(p, 'utf8');
|
||||
}
|
||||
|
||||
describe('container/Dockerfile installs the Gmail MCP server', () => {
|
||||
const text = dockerfile();
|
||||
|
||||
it('declares the GMAIL_MCP_VERSION ARG', () => {
|
||||
expect(/ARG\s+GMAIL_MCP_VERSION=/.test(text)).toBe(true);
|
||||
});
|
||||
|
||||
it('pnpm-installs @gongrzhe/server-gmail-autoauth-mcp pinned to the ARG', () => {
|
||||
expect(text).toContain('pnpm install -g');
|
||||
expect(/@gongrzhe\/server-gmail-autoauth-mcp@\$\{GMAIL_MCP_VERSION\}/.test(text)).toBe(true);
|
||||
});
|
||||
|
||||
it('pins the zod-to-json-schema workaround version', () => {
|
||||
expect(/zod-to-json-schema@3\.22\.5/.test(text)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
---
|
||||
name: add-gmail
|
||||
description: Add Gmail integration to NanoClaw. Can be configured as a tool (agent reads/sends emails when triggered from WhatsApp) or as a full channel (emails can trigger the agent, schedule tasks, and receive replies). Guides through GCP OAuth setup and implements the integration.
|
||||
---
|
||||
|
||||
# Add Gmail Integration
|
||||
|
||||
This skill adds Gmail support to NanoClaw — either as a tool (read, send, search, draft) or as a full channel that polls the inbox.
|
||||
|
||||
## Phase 1: Pre-flight
|
||||
|
||||
### Check if already applied
|
||||
|
||||
Check if `src/channels/gmail.ts` exists. If it does, skip to Phase 3 (Setup). The code changes are already in place.
|
||||
|
||||
### Ask the user
|
||||
|
||||
Use `AskUserQuestion`:
|
||||
|
||||
AskUserQuestion: Should incoming emails be able to trigger the agent?
|
||||
|
||||
- **Yes** — Full channel mode: the agent listens on Gmail and responds to incoming emails automatically
|
||||
- **No** — Tool-only: the agent gets full Gmail tools (read, send, search, draft) but won't monitor the inbox. No channel code is added.
|
||||
|
||||
## Phase 2: Apply Code Changes
|
||||
|
||||
### Ensure channel remote
|
||||
|
||||
```bash
|
||||
git remote -v
|
||||
```
|
||||
|
||||
If `gmail` is missing, add it:
|
||||
|
||||
```bash
|
||||
git remote add gmail https://github.com/qwibitai/nanoclaw-gmail.git
|
||||
```
|
||||
|
||||
### Merge the skill branch
|
||||
|
||||
```bash
|
||||
git fetch gmail main
|
||||
git merge gmail/main || {
|
||||
git checkout --theirs pnpm-lock.yaml
|
||||
git add pnpm-lock.yaml
|
||||
git merge --continue
|
||||
}
|
||||
```
|
||||
|
||||
This merges in:
|
||||
- `src/channels/gmail.ts` (GmailChannel class with self-registration via `registerChannel`)
|
||||
- `src/channels/gmail.test.ts` (unit tests)
|
||||
- `import './gmail.js'` appended to the channel barrel file `src/channels/index.ts`
|
||||
- Gmail credentials mount (`~/.gmail-mcp`) in `src/container-runner.ts`
|
||||
- Gmail MCP server (`@gongrzhe/server-gmail-autoauth-mcp`) and `mcp__gmail__*` allowed tool in `container/agent-runner/src/index.ts`
|
||||
- `googleapis` npm dependency in `package.json`
|
||||
|
||||
If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides.
|
||||
|
||||
### Add email handling instructions (Channel mode only)
|
||||
|
||||
If the user chose channel mode, append the following to `groups/main/CLAUDE.md` (before the formatting section):
|
||||
|
||||
```markdown
|
||||
## Email Notifications
|
||||
|
||||
When you receive an email notification (messages starting with `[Email from ...`), inform the user about it but do NOT reply to the email unless specifically asked. You have Gmail tools available — use them only when the user explicitly asks you to reply, forward, or take action on an email.
|
||||
```
|
||||
|
||||
### Validate code changes
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm run build
|
||||
pnpm exec vitest run src/channels/gmail.test.ts
|
||||
```
|
||||
|
||||
All tests must pass (including the new Gmail tests) and build must be clean before proceeding.
|
||||
|
||||
## Phase 3: Setup
|
||||
|
||||
### Check existing Gmail credentials
|
||||
|
||||
```bash
|
||||
ls -la ~/.gmail-mcp/ 2>/dev/null || echo "No Gmail config found"
|
||||
```
|
||||
|
||||
If `credentials.json` already exists with real tokens (not `onecli-managed` values), skip to "Build and restart" below.
|
||||
|
||||
### GCP Project Setup
|
||||
|
||||
Check if OneCLI is configured:
|
||||
|
||||
```bash
|
||||
grep -q 'ONECLI_URL=.' .env 2>/dev/null && echo "onecli" || echo "manual"
|
||||
```
|
||||
|
||||
**If OneCLI:** Tell the user to open `${ONECLI_URL}/connections?connect=gmail` to set up their Gmail connection. The dashboard walks them through creating a Google Cloud OAuth app and authorizing it. Ask them to let you know when done.
|
||||
|
||||
Once the user confirms, run:
|
||||
|
||||
```bash
|
||||
onecli apps get --provider gmail
|
||||
```
|
||||
|
||||
Check that `config.hasCredentials` is `true` or `connection` is not null. The response `hint` field has instructions and a docs URL for what stub credential files to create under `~/.gmail-mcp/`. Follow the hint — never overwrite existing files that don't contain `onecli-managed` values.
|
||||
|
||||
**If manual:** Tell the user:
|
||||
|
||||
> I need you to set up Google Cloud OAuth credentials:
|
||||
>
|
||||
> 1. Open https://console.cloud.google.com — create a new project or select existing
|
||||
> 2. Go to **APIs & Services > Library**, search "Gmail API", click **Enable**
|
||||
> 3. Go to **APIs & Services > Credentials**, click **+ CREATE CREDENTIALS > OAuth client ID**
|
||||
> - If prompted for consent screen: choose "External", fill in app name and email, save
|
||||
> - Application type: **Desktop app**, name: anything (e.g., "NanoClaw Gmail")
|
||||
> 4. Click **DOWNLOAD JSON** and save as `gcp-oauth.keys.json`
|
||||
>
|
||||
> Where did you save the file? (Give me the full path, or paste the file contents here)
|
||||
|
||||
If user provides a path, copy it:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.gmail-mcp
|
||||
cp "/path/user/provided/gcp-oauth.keys.json" ~/.gmail-mcp/gcp-oauth.keys.json
|
||||
```
|
||||
|
||||
If user pastes JSON content, write it to `~/.gmail-mcp/gcp-oauth.keys.json`.
|
||||
|
||||
### OAuth Authorization
|
||||
|
||||
Tell the user:
|
||||
|
||||
> I'm going to run Gmail authorization. A browser window will open — sign in and grant access. If you see an "app isn't verified" warning, click "Advanced" then "Go to [app name] (unsafe)" — this is normal for personal OAuth apps.
|
||||
|
||||
Run the authorization:
|
||||
|
||||
```bash
|
||||
pnpm dlx @gongrzhe/server-gmail-autoauth-mcp auth
|
||||
```
|
||||
|
||||
If that fails (some versions don't have an auth subcommand), try `timeout 60 pnpm dlx @gongrzhe/server-gmail-autoauth-mcp || true`. Verify with `ls ~/.gmail-mcp/credentials.json`.
|
||||
|
||||
### Build and restart
|
||||
|
||||
Clear stale per-group agent-runner copies (they only get re-created if missing, so existing copies won't pick up the new Gmail server):
|
||||
|
||||
```bash
|
||||
rm -r data/sessions/*/agent-runner-src 2>/dev/null || true
|
||||
```
|
||||
|
||||
Rebuild the container (agent-runner changed):
|
||||
|
||||
```bash
|
||||
cd container && ./build.sh
|
||||
```
|
||||
|
||||
Then compile and restart:
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
# Linux: systemctl --user restart nanoclaw
|
||||
```
|
||||
|
||||
## Phase 4: Verify
|
||||
|
||||
### Test tool access (both modes)
|
||||
|
||||
Tell the user:
|
||||
|
||||
> Gmail is connected! Send this in your main channel:
|
||||
>
|
||||
> `@Andy check my recent emails` or `@Andy list my Gmail labels`
|
||||
|
||||
### Test channel mode (Channel mode only)
|
||||
|
||||
Tell the user to send themselves a test email. The agent should pick it up within a minute. Monitor: `tail -f logs/nanoclaw.log | grep -iE "(gmail|email)"`.
|
||||
|
||||
Once verified, offer filter customization via `AskUserQuestion` — by default, only emails in the Primary inbox trigger the agent (Promotions, Social, Updates, and Forums are excluded). The user can keep this default or narrow further by sender, label, or keywords. No code changes needed for filters.
|
||||
|
||||
### Check logs if needed
|
||||
|
||||
```bash
|
||||
tail -f logs/nanoclaw.log
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Gmail connection not responding
|
||||
|
||||
Test directly:
|
||||
|
||||
```bash
|
||||
pnpm dlx @gongrzhe/server-gmail-autoauth-mcp
|
||||
```
|
||||
|
||||
### OAuth token expired
|
||||
|
||||
Re-authorize:
|
||||
|
||||
```bash
|
||||
rm ~/.gmail-mcp/credentials.json
|
||||
pnpm dlx @gongrzhe/server-gmail-autoauth-mcp
|
||||
```
|
||||
|
||||
### Container can't access Gmail
|
||||
|
||||
- Verify `~/.gmail-mcp` is mounted: check `src/container-runner.ts` for the `.gmail-mcp` mount
|
||||
- Check container logs: `cat groups/main/logs/container-*.log | tail -50`
|
||||
|
||||
### Emails not being detected (Channel mode only)
|
||||
|
||||
- By default, the channel polls unread Primary inbox emails (`is:unread category:primary`)
|
||||
- Check logs for Gmail polling errors
|
||||
|
||||
## Removal
|
||||
|
||||
### Tool-only mode
|
||||
|
||||
1. Remove `~/.gmail-mcp` mount from `src/container-runner.ts`
|
||||
2. Remove `gmail` MCP server and `mcp__gmail__*` from `container/agent-runner/src/index.ts`
|
||||
3. Rebuild and restart
|
||||
4. Clear stale agent-runner copies: `rm -r data/sessions/*/agent-runner-src 2>/dev/null || true`
|
||||
5. Rebuild: `cd container && ./build.sh && cd .. && pnpm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux)
|
||||
|
||||
### Channel mode
|
||||
|
||||
1. Delete `src/channels/gmail.ts` and `src/channels/gmail.test.ts`
|
||||
2. Remove `import './gmail.js'` from `src/channels/index.ts`
|
||||
3. Remove `~/.gmail-mcp` mount from `src/container-runner.ts`
|
||||
4. Remove `gmail` MCP server and `mcp__gmail__*` from `container/agent-runner/src/index.ts`
|
||||
5. Uninstall: `pnpm uninstall googleapis`
|
||||
6. Rebuild and restart
|
||||
7. Clear stale agent-runner copies: `rm -r data/sessions/*/agent-runner-src 2>/dev/null || true`
|
||||
8. Rebuild: `cd container && ./build.sh && cd .. && pnpm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux)
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
name: add-image-vision
|
||||
description: Add image vision to NanoClaw agents. Resizes and processes WhatsApp image attachments, then sends them to Claude as multimodal content blocks.
|
||||
---
|
||||
|
||||
# Image Vision Skill
|
||||
|
||||
Adds the ability for NanoClaw agents to see and understand images sent via WhatsApp. Images are downloaded, resized with sharp, saved to the group workspace, and passed to the agent as base64-encoded multimodal content blocks.
|
||||
|
||||
## Phase 1: Pre-flight
|
||||
|
||||
1. Check if `src/image.ts` exists — skip to Phase 3 if already applied
|
||||
2. Confirm `sharp` is installable (native bindings require build tools)
|
||||
|
||||
**Prerequisite:** WhatsApp must be installed first (`skill/whatsapp` merged). This skill modifies WhatsApp channel files.
|
||||
|
||||
## Phase 2: Apply Code Changes
|
||||
|
||||
### Ensure WhatsApp fork remote
|
||||
|
||||
```bash
|
||||
git remote -v
|
||||
```
|
||||
|
||||
If `whatsapp` is missing, add it:
|
||||
|
||||
```bash
|
||||
git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git
|
||||
```
|
||||
|
||||
### Merge the skill branch
|
||||
|
||||
```bash
|
||||
git fetch whatsapp skill/image-vision
|
||||
git merge whatsapp/skill/image-vision || {
|
||||
git checkout --theirs pnpm-lock.yaml
|
||||
git add pnpm-lock.yaml
|
||||
git merge --continue
|
||||
}
|
||||
```
|
||||
|
||||
This merges in:
|
||||
- `src/image.ts` (image download, resize via sharp, base64 encoding)
|
||||
- `src/image.test.ts` (8 unit tests)
|
||||
- Image attachment handling in `src/channels/whatsapp.ts`
|
||||
- Image passing to agent in `src/index.ts` and `src/container-runner.ts`
|
||||
- Image content block support in `container/agent-runner/src/index.ts`
|
||||
- `sharp` npm dependency in `package.json`
|
||||
|
||||
If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides.
|
||||
|
||||
### Validate code changes
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm run build
|
||||
pnpm exec vitest run src/image.test.ts
|
||||
```
|
||||
|
||||
All tests must pass and build must be clean before proceeding.
|
||||
|
||||
## Phase 3: Configure
|
||||
|
||||
1. Rebuild the container (agent-runner changes need a rebuild):
|
||||
```bash
|
||||
./container/build.sh
|
||||
```
|
||||
|
||||
2. Sync agent-runner source to group caches:
|
||||
```bash
|
||||
for dir in data/sessions/*/agent-runner-src/; do
|
||||
cp container/agent-runner/src/*.ts "$dir"
|
||||
done
|
||||
```
|
||||
|
||||
3. Restart the service:
|
||||
```bash
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
|
||||
```
|
||||
|
||||
## Phase 4: Verify
|
||||
|
||||
1. Send an image in a registered WhatsApp group
|
||||
2. Check the agent responds with understanding of the image content
|
||||
3. Check logs for "Processed image attachment":
|
||||
```bash
|
||||
tail -50 groups/*/logs/container-*.log
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"Image - download failed"**: Check WhatsApp connection stability. The download may timeout on slow connections.
|
||||
- **"Image - processing failed"**: Sharp may not be installed correctly. Run `pnpm ls sharp` to verify.
|
||||
- **Agent doesn't mention image content**: Check container logs for "Loaded image" messages. If missing, ensure agent-runner source was synced to group caches.
|
||||
@@ -0,0 +1,6 @@
|
||||
# Remove iMessage Channel
|
||||
|
||||
1. Comment out `import './imessage.js'` in `src/channels/index.ts`
|
||||
2. Remove iMessage env vars (`IMESSAGE_ENABLED`, `IMESSAGE_LOCAL`, `IMESSAGE_SERVER_URL`, `IMESSAGE_API_KEY`) from `.env`
|
||||
3. `pnpm uninstall chat-adapter-imessage`
|
||||
4. Rebuild and restart
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
name: add-imessage-v2
|
||||
description: Add iMessage channel integration to NanoClaw v2 via Chat SDK. Local (macOS) or remote (Photon API) mode.
|
||||
---
|
||||
|
||||
# Add iMessage Channel
|
||||
|
||||
Adds iMessage support to NanoClaw v2 using the Chat SDK bridge. Two modes: local (macOS with Full Disk Access) or remote (Photon API).
|
||||
|
||||
## Pre-flight
|
||||
|
||||
Check if `src/channels/imessage.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Credentials.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pnpm install chat-adapter-imessage
|
||||
```
|
||||
|
||||
Uncomment the iMessage import in `src/channels/index.ts`:
|
||||
|
||||
```typescript
|
||||
import './imessage.js';
|
||||
```
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
## Credentials
|
||||
|
||||
### Local Mode (macOS)
|
||||
|
||||
Requirements: macOS with Full Disk Access granted to the Node.js binary.
|
||||
|
||||
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:
|
||||
|
||||
```bash
|
||||
open "$(dirname "$(which node)")"
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
Stop and wait for the user to confirm before continuing.
|
||||
|
||||
### Remote Mode (Photon API)
|
||||
|
||||
1. Set up a [Photon](https://photon.im) account
|
||||
2. Get your server URL and API key
|
||||
|
||||
### Configure environment
|
||||
|
||||
**Local mode** -- add to `.env`:
|
||||
|
||||
```bash
|
||||
IMESSAGE_ENABLED=true
|
||||
IMESSAGE_LOCAL=true
|
||||
```
|
||||
|
||||
**Remote mode** -- add to `.env`:
|
||||
|
||||
```bash
|
||||
IMESSAGE_LOCAL=false
|
||||
IMESSAGE_SERVER_URL=https://your-photon-server.com
|
||||
IMESSAGE_API_KEY=your-api-key
|
||||
```
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
|
||||
## 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.
|
||||
|
||||
## Channel Info
|
||||
|
||||
- **type**: `imessage`
|
||||
- **terminology**: iMessage has "conversations." Each conversation is with a contact identified by phone number or email address. Group chats are also supported.
|
||||
- **how-to-find-id**: The platform ID is the contact's phone number (e.g. `+15551234567`) or email address. For group chats, the ID is assigned by iMessage internally.
|
||||
- **supports-threads**: no
|
||||
- **typical-use**: Interactive 1:1 chat — personal messaging
|
||||
- **default-isolation**: Same agent group if you're the only person messaging the bot across iMessage and other channels. Separate agent group if different contacts should have information isolation.
|
||||
@@ -0,0 +1,3 @@
|
||||
# Verify iMessage Channel
|
||||
|
||||
Send an iMessage to the account running NanoClaw. The bot should respond within a few seconds.
|
||||
@@ -1,40 +0,0 @@
|
||||
# Remove iMessage
|
||||
|
||||
Every step is idempotent — safe to re-run.
|
||||
|
||||
## 1. Remove the adapter
|
||||
|
||||
Delete the self-registration import from `src/channels/index.ts` (skip if already gone):
|
||||
|
||||
```typescript
|
||||
import './imessage.js';
|
||||
```
|
||||
|
||||
Then delete the copied adapter and its registration test:
|
||||
|
||||
```bash
|
||||
rm -f src/channels/imessage.ts src/channels/imessage-registration.test.ts
|
||||
```
|
||||
|
||||
## 2. Remove credentials
|
||||
|
||||
Remove `IMESSAGE_ENABLED`, `IMESSAGE_LOCAL`, `IMESSAGE_SERVER_URL`, and `IMESSAGE_API_KEY` from `.env`, then re-sync to the container:
|
||||
|
||||
```bash
|
||||
mkdir -p data/env && cp .env data/env/env
|
||||
```
|
||||
|
||||
## 3. Remove the package
|
||||
|
||||
```bash
|
||||
pnpm uninstall chat-adapter-imessage
|
||||
```
|
||||
|
||||
## 4. Rebuild and restart
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
source setup/lib/install-slug.sh
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
# Linux: systemctl --user restart $(systemd_unit)
|
||||
```
|
||||
@@ -1,120 +0,0 @@
|
||||
---
|
||||
name: add-imessage
|
||||
description: Add iMessage channel integration via Chat SDK. Local (macOS) or remote (Photon API) mode.
|
||||
---
|
||||
|
||||
# Add iMessage Channel
|
||||
|
||||
Adds iMessage support via the Chat SDK bridge. Two modes: local (macOS with Full Disk Access) or remote (Photon API).
|
||||
|
||||
## Install
|
||||
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the iMessage adapter in from the `channels` branch.
|
||||
|
||||
### Pre-flight (idempotent)
|
||||
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
|
||||
- `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
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and its registration test
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
```typescript
|
||||
import './imessage.js';
|
||||
```
|
||||
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install chat-adapter-imessage@0.1.1
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
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`.
|
||||
|
||||
End-to-end message delivery against a real iMessage account is verified manually once the service is running — see Next Steps.
|
||||
|
||||
## Credentials
|
||||
|
||||
### Local Mode (macOS)
|
||||
|
||||
Requirements: macOS with Full Disk Access granted to the Node.js binary.
|
||||
|
||||
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:
|
||||
|
||||
```bash
|
||||
open "$(dirname "$(which node)")"
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
Stop and wait for the user to confirm before continuing.
|
||||
|
||||
### Remote Mode (Photon API)
|
||||
|
||||
1. Set up a [Photon](https://photon.codes) account
|
||||
2. Get your server URL and API key
|
||||
|
||||
### Configure environment
|
||||
|
||||
**Local mode** -- add to `.env`:
|
||||
|
||||
```bash
|
||||
IMESSAGE_ENABLED=true
|
||||
IMESSAGE_LOCAL=true
|
||||
```
|
||||
|
||||
**Remote mode** -- add to `.env`:
|
||||
|
||||
```bash
|
||||
IMESSAGE_LOCAL=false
|
||||
IMESSAGE_SERVER_URL=https://your-photon-server.com
|
||||
IMESSAGE_API_KEY=your-api-key
|
||||
```
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
|
||||
## 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.
|
||||
|
||||
## Channel Info
|
||||
|
||||
- **type**: `imessage`
|
||||
- **terminology**: iMessage has "conversations." Each conversation is with a contact identified by phone number or email address. Group chats are also supported.
|
||||
- **how-to-find-id**: The platform ID is the contact's phone number (e.g. `+15551234567`) or email address. For group chats, the ID is assigned by iMessage internally.
|
||||
- **supports-threads**: no
|
||||
- **typical-use**: Interactive 1:1 chat — personal messaging
|
||||
- **default-isolation**: Same agent group if you're the only person messaging the bot across iMessage and other channels. Separate agent group if different contacts should have information isolation.
|
||||
@@ -1,38 +0,0 @@
|
||||
# Remove Karpathy LLM Wiki
|
||||
|
||||
Every step is idempotent — safe to re-run.
|
||||
|
||||
## 1. Remove the shared container skill
|
||||
|
||||
The wiki container skill lives in the shared `container/skills/` mount, which is auto-discovered and symlinked into every agent group. Delete it so it stops appearing in all containers:
|
||||
|
||||
```bash
|
||||
rm -rf container/skills/wiki
|
||||
```
|
||||
|
||||
## 2. Remove the wiki section from the group CLAUDE.md
|
||||
|
||||
The wiki section is wrapped in marker comments. Delete the block (markers included) from the group's CLAUDE.md — find it under `groups/<folder>/CLAUDE.md`:
|
||||
|
||||
```bash
|
||||
# Replace <folder> with the group folder you set up the wiki for.
|
||||
perl -0pi -e 's/\n?<!-- BEGIN karpathy-llm-wiki -->.*?<!-- END karpathy-llm-wiki -->\n?//s' groups/<folder>/CLAUDE.md
|
||||
```
|
||||
|
||||
If the markers are absent, nothing is removed (the block was already gone or never added).
|
||||
|
||||
## 3. Restart so containers drop the skill
|
||||
|
||||
```bash
|
||||
source setup/lib/install-slug.sh
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
# Linux: systemctl --user restart $(systemd_unit)
|
||||
```
|
||||
|
||||
## User content is preserved
|
||||
|
||||
The per-group `groups/<folder>/wiki/` and `groups/<folder>/sources/` directories hold the user's own knowledge base and ingested sources. They are left in place. Delete them by hand only if the user explicitly wants their wiki content gone:
|
||||
|
||||
```bash
|
||||
rm -rf groups/<folder>/wiki groups/<folder>/sources
|
||||
```
|
||||
@@ -7,8 +7,6 @@ description: Add a persistent wiki knowledge base to a NanoClaw group. Based on
|
||||
|
||||
Set up a persistent wiki knowledge base on NanoClaw, based on Karpathy's LLM Wiki pattern.
|
||||
|
||||
Each step is safe to re-run: directory creation uses `mkdir -p`, initial wiki files are created only if absent, the container skill is preserved unless the user opts to update it, and the group CLAUDE.md section is replaced in place via marker comments rather than duplicated.
|
||||
|
||||
## Step 1: Read the pattern
|
||||
|
||||
Read `${CLAUDE_SKILL_DIR}/llm-wiki.md` — this is the full LLM Wiki idea as written by Karpathy. Understand it thoroughly before proceeding. Summarize the core idea to the user briefly, then discuss what they want to build.
|
||||
@@ -35,26 +33,15 @@ Based on this discussion, create three things:
|
||||
|
||||
### 3a. Directory structure
|
||||
|
||||
Create `wiki/` and `sources/` directories in the group folder (`mkdir -p` — safe if they already exist). Create initial `index.md` and `log.md` per the pattern's Indexing and Logging section, adapted to the user's domain. Skip any of these files that already exist so a populated wiki is never clobbered on re-run.
|
||||
Create `wiki/` and `sources/` directories in the group folder. Create initial `index.md` and `log.md` per the pattern's Indexing and Logging section. Adapt to the user's domain.
|
||||
|
||||
### 3b. Container skill
|
||||
|
||||
Create `container/skills/wiki/SKILL.md` tailored to this user's wiki. This is the schema layer from the pattern — it tells the agent how to maintain the wiki. Base it on the pattern's Operations section (ingest, query, lint) and the conventions you agreed on with the user. Don't over-prescribe — the pattern says "your LLM figures out the rest."
|
||||
|
||||
If `container/skills/wiki/SKILL.md` already exists, ask the user whether to update it before overwriting, so an existing tailored schema is preserved on re-run.
|
||||
Create a `container/skills/wiki/SKILL.md` tailored to this user's wiki. This is the schema layer from the pattern — it tells the agent how to maintain the wiki. Base it on the pattern's Operations section (ingest, query, lint) and the conventions you agreed on with the user. Don't over-prescribe — the pattern says "your LLM figures out the rest."
|
||||
|
||||
### 3c. Group CLAUDE.md
|
||||
|
||||
Edit the group's CLAUDE.md to add a wiki section, wrapped in marker comments so it can be located and replaced on re-run:
|
||||
|
||||
```markdown
|
||||
<!-- BEGIN karpathy-llm-wiki -->
|
||||
## Wiki
|
||||
...section body...
|
||||
<!-- END karpathy-llm-wiki -->
|
||||
```
|
||||
|
||||
If a `<!-- BEGIN karpathy-llm-wiki -->` block already exists, replace it in place rather than appending a second copy. This is critical — it's what turns the agent into a wiki maintainer. The section should:
|
||||
Edit the group's CLAUDE.md to add a wiki section. This is critical — it's what turns the agent into a wiki maintainer. It should:
|
||||
|
||||
- Explain the wiki system concisely: what it is, the three layers (sources, wiki, schema), the three operations (ingest, query, lint)
|
||||
- Index the key files and folders (`wiki/`, `sources/`, `wiki/index.md`, `wiki/log.md`)
|
||||
@@ -84,16 +71,40 @@ AskUserQuestion: "Want periodic wiki health checks?"
|
||||
2. **Monthly**
|
||||
3. **Skip** — lint manually
|
||||
|
||||
If yes, ask the agent to schedule the lint task using the `schedule_task` MCP tool in conversation.
|
||||
|
||||
## Step 6: Restart
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
If yes, create a NanoClaw scheduled task that runs in the wiki group. This is NOT a Claude Code cron job — it's a NanoClaw group task that runs in the agent container. Insert it into the SQLite database:
|
||||
|
||||
```bash
|
||||
source setup/lib/install-slug.sh
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
systemctl --user restart $(systemd_unit) # Linux
|
||||
pnpm exec tsx -e "
|
||||
const Database = require('better-sqlite3');
|
||||
const { CronExpressionParser } = require('cron-parser');
|
||||
const db = new Database('store/messages.db');
|
||||
const interval = CronExpressionParser.parse('<cron-expr>', { tz: process.env.TZ || 'UTC' });
|
||||
const nextRun = interval.next().toISOString();
|
||||
db.prepare('INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, schedule_type, schedule_value, context_mode, next_run, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)').run(
|
||||
'wiki-lint',
|
||||
'<group_folder>',
|
||||
'<chat_jid>',
|
||||
'Run a wiki lint pass per the wiki container skill. Check for contradictions, orphan pages, stale content, missing cross-references, and gaps. Report findings and offer to fix issues.',
|
||||
'cron',
|
||||
'<cron-expr>',
|
||||
'group',
|
||||
nextRun,
|
||||
'active',
|
||||
new Date().toISOString()
|
||||
);
|
||||
db.close();
|
||||
"
|
||||
```
|
||||
|
||||
Use the group's `folder` and `chat_jid` from the registered groups table. Cron expressions: `0 10 * * 0` (weekly Sunday 10am) or `0 10 1 * *` (monthly 1st at 10am).
|
||||
|
||||
## Step 6: Build and restart
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
./container/build.sh
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
# Linux: systemctl --user restart nanoclaw
|
||||
```
|
||||
|
||||
Tell the user to test by sending a source to the wiki group.
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Remove Linear Channel
|
||||
|
||||
1. Comment out `import './linear.js'` in `src/channels/index.ts`
|
||||
2. Remove `LINEAR_API_KEY` and `LINEAR_WEBHOOK_SECRET` from `.env`
|
||||
3. `pnpm uninstall @chat-adapter/linear`
|
||||
4. Rebuild and restart
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
name: add-linear-v2
|
||||
description: Add Linear channel integration to NanoClaw v2 via Chat SDK. Issue comment threads as conversations.
|
||||
---
|
||||
|
||||
# Add Linear Channel
|
||||
|
||||
Adds Linear support to NanoClaw v2 using the Chat SDK bridge. The agent participates in issue comment threads.
|
||||
|
||||
## Pre-flight
|
||||
|
||||
Check if `src/channels/linear.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Credentials.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/linear
|
||||
```
|
||||
|
||||
Uncomment the Linear import in `src/channels/index.ts`:
|
||||
|
||||
```typescript
|
||||
import './linear.js';
|
||||
```
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
## Credentials
|
||||
|
||||
> 1. Go to [Linear Settings > API Keys](https://linear.app/settings/account/security/api-keys/new)
|
||||
> 2. Create a **Personal API Key** (or use an OAuth application for team-wide access)
|
||||
> 3. Copy the API key
|
||||
> 4. Set up a webhook:
|
||||
> - Go to **Settings** > **API** > **Webhooks** > **New webhook**
|
||||
> - URL: `https://your-domain/webhook/linear`
|
||||
> - Select events: **Comment** (created, updated)
|
||||
> - Copy the signing secret
|
||||
|
||||
### Configure environment
|
||||
|
||||
Add to `.env`:
|
||||
|
||||
```bash
|
||||
LINEAR_API_KEY=lin_api_...
|
||||
LINEAR_WEBHOOK_SECRET=your-webhook-secret
|
||||
```
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
|
||||
## 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.
|
||||
|
||||
## Channel Info
|
||||
|
||||
- **type**: `linear`
|
||||
- **terminology**: Linear has "teams" containing "issues." Each issue's comment thread is a separate conversation.
|
||||
- **how-to-find-id**: The platform ID is your team key (e.g. `ENG`). Find it in Linear under Settings > Teams. Each issue becomes its own thread automatically.
|
||||
- **supports-threads**: yes (issue comment threads are native conversations)
|
||||
- **typical-use**: Webhook/notification — the agent receives issue comment events and responds in threads
|
||||
- **default-isolation**: Typically shares a session with a chat channel (e.g. Slack) so the agent can discuss issues in the same context as team chat. Use a separate agent group if the Linear team tracks sensitive work.
|
||||
@@ -0,0 +1,3 @@
|
||||
# Verify Linear Channel
|
||||
|
||||
@mention the bot in a Linear issue comment. The bot should respond within a few seconds.
|
||||
@@ -1,49 +0,0 @@
|
||||
# Remove Linear
|
||||
|
||||
Every step is idempotent — safe to re-run.
|
||||
|
||||
## 1. Remove the adapter
|
||||
|
||||
Delete the self-registration import from `src/channels/index.ts` (skip if already gone):
|
||||
|
||||
```typescript
|
||||
import './linear.js';
|
||||
```
|
||||
|
||||
Then delete the copied adapter and its registration test:
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
```bash
|
||||
mkdir -p data/env && cp .env data/env/env
|
||||
```
|
||||
|
||||
## 3. Remove the package
|
||||
|
||||
```bash
|
||||
pnpm uninstall @chat-adapter/linear
|
||||
```
|
||||
|
||||
## 4. Rebuild and restart
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
source setup/lib/install-slug.sh
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
# Linux: systemctl --user restart $(systemd_unit)
|
||||
```
|
||||
@@ -1,153 +0,0 @@
|
||||
---
|
||||
name: add-linear
|
||||
description: Add Linear channel integration via Chat SDK. Issue comment threads as conversations.
|
||||
---
|
||||
|
||||
# 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.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
**Recommended:** Create a Linear **OAuth application** so the agent posts as an app identity, not as you. This prevents the adapter from filtering your own comments as self-messages.
|
||||
|
||||
1. Go to [Linear Settings > API > OAuth Applications](https://linear.app/settings/api/applications/new)
|
||||
2. Create an app (e.g. "NanoClaw Bot")
|
||||
- Developer URL: your repo URL (e.g. `https://github.com/your-org/nanoclaw`)
|
||||
- Callback URL: `http://localhost`
|
||||
3. After creating, click the app and enable **Client credentials** under grant types
|
||||
4. Copy the **Client ID** and **Client Secret**
|
||||
|
||||
**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
|
||||
|
||||
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.
|
||||
|
||||
### Pre-flight (idempotent)
|
||||
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
|
||||
- `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
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and its registration test
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
```typescript
|
||||
import './linear.js';
|
||||
```
|
||||
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/linear@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
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.
|
||||
|
||||
## Credentials
|
||||
|
||||
### 1. Set up a webhook
|
||||
|
||||
1. Go to **Linear Settings** > **API** > **Webhooks** > **New webhook**
|
||||
2. Label: `NanoClaw`
|
||||
3. URL: `https://your-domain/webhook/linear` (the shared webhook server, default port 3000)
|
||||
4. Team: select the team you want to monitor
|
||||
5. Events: check **Comment**
|
||||
6. Save — copy the **signing secret**
|
||||
|
||||
Note: Linear webhook delivery may be delayed 1-5 minutes for new webhooks. This is normal.
|
||||
|
||||
### 2. Configure environment
|
||||
|
||||
Add to `.env`:
|
||||
|
||||
```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_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?**
|
||||
|
||||
- **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'));
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Next Steps
|
||||
|
||||
If you're in the middle of `/setup`, return to the setup flow now.
|
||||
|
||||
Otherwise, restart the service to pick up the new channel.
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
source setup/lib/install-slug.sh
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
systemctl --user restart $(systemd_unit) # Linux
|
||||
```
|
||||
|
||||
## Channel Info
|
||||
|
||||
- **type**: `linear`
|
||||
- **terminology**: Linear has "teams" containing "issues." Each issue's comment thread is a separate conversation.
|
||||
- **how-to-find-id**: The platform ID is `linear:<TEAM_KEY>` (e.g. `linear:ENG`). Find your team key in Linear under Settings > Teams. Each issue becomes its own thread automatically.
|
||||
- **supports-threads**: yes (issue comment threads are native conversations)
|
||||
- **typical-use**: Webhook-driven — the agent receives all issue comment events and responds automatically. No @-mention needed (Linear OAuth apps can't be @-mentioned).
|
||||
- **default-isolation**: Use `per-thread` session mode. Each issue comment thread gets its own isolated agent session.
|
||||
@@ -1,22 +0,0 @@
|
||||
# Remove macOS Menu Bar Status Indicator
|
||||
|
||||
Every step is idempotent — safe to re-run.
|
||||
|
||||
## 1. Unload the launchd service
|
||||
|
||||
```bash
|
||||
launchctl bootout gui/$(id -u)/com.nanoclaw.statusbar 2>/dev/null \
|
||||
|| launchctl unload ~/Library/LaunchAgents/com.nanoclaw.statusbar.plist 2>/dev/null \
|
||||
|| true
|
||||
```
|
||||
|
||||
## 2. Delete the produced files
|
||||
|
||||
```bash
|
||||
rm -f ~/Library/LaunchAgents/com.nanoclaw.statusbar.plist \
|
||||
dist/statusbar \
|
||||
logs/statusbar.log \
|
||||
logs/statusbar.error.log
|
||||
```
|
||||
|
||||
The menu bar icon disappears once the service is unloaded.
|
||||
@@ -124,4 +124,10 @@ Tell the user:
|
||||
>
|
||||
> Use **Restart** after making code changes, and **View Logs** to open the log file directly.
|
||||
|
||||
To uninstall, follow [REMOVE.md](REMOVE.md).
|
||||
## Removal
|
||||
|
||||
```bash
|
||||
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.statusbar.plist
|
||||
rm ~/Library/LaunchAgents/com.nanoclaw.statusbar.plist
|
||||
rm dist/statusbar
|
||||
```
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Remove Matrix Channel
|
||||
|
||||
1. Comment out `import './matrix.js'` in `src/channels/index.ts`
|
||||
2. Remove `MATRIX_BASE_URL`, `MATRIX_ACCESS_TOKEN`, `MATRIX_USER_ID`, `MATRIX_BOT_USERNAME` from `.env`
|
||||
3. `pnpm uninstall @beeper/chat-adapter-matrix`
|
||||
4. Rebuild and restart
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
name: add-matrix-v2
|
||||
description: Add Matrix channel integration to NanoClaw v2 via Chat SDK. Works with any Matrix homeserver.
|
||||
---
|
||||
|
||||
# Add Matrix Channel
|
||||
|
||||
Adds Matrix support to NanoClaw v2 using the Chat SDK bridge.
|
||||
|
||||
## Pre-flight
|
||||
|
||||
Check if `src/channels/matrix.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Credentials.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pnpm install @beeper/chat-adapter-matrix
|
||||
```
|
||||
|
||||
Uncomment the Matrix import in `src/channels/index.ts`:
|
||||
|
||||
```typescript
|
||||
import './matrix.js';
|
||||
```
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
## Credentials
|
||||
|
||||
1. Register a bot account on your Matrix homeserver (e.g., via Element)
|
||||
2. Get the homeserver URL (e.g., `https://matrix.org` or your self-hosted URL)
|
||||
3. Get an access token:
|
||||
- In Element: **Settings** > **Help & About** > **Access Token** (advanced)
|
||||
- Or via API: `curl -XPOST 'https://matrix.org/_matrix/client/r0/login' -d '{"type":"m.login.password","user":"botuser","password":"..."}'`
|
||||
4. Note the bot's user ID (e.g., `@botuser:matrix.org`)
|
||||
|
||||
### Configure environment
|
||||
|
||||
Add to `.env`:
|
||||
|
||||
```bash
|
||||
MATRIX_BASE_URL=https://matrix.org
|
||||
MATRIX_ACCESS_TOKEN=your-access-token
|
||||
MATRIX_USER_ID=@botuser:matrix.org
|
||||
MATRIX_BOT_USERNAME=botuser
|
||||
```
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
|
||||
## 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.
|
||||
|
||||
## Channel Info
|
||||
|
||||
- **type**: `matrix`
|
||||
- **terminology**: Matrix has "rooms." A room can be a group chat or a direct message. Rooms have internal IDs (like `!abc123:matrix.org`) and optional aliases (like `#general:matrix.org`).
|
||||
- **how-to-find-id**: In Element, click the room name > Settings > Advanced — the "Internal room ID" is the platform ID (starts with `!`). Or use a room alias like `#general:matrix.org`.
|
||||
- **supports-threads**: partial (some clients support threads, but not all — treat as no for reliability)
|
||||
- **typical-use**: Interactive chat — rooms or direct messages
|
||||
- **default-isolation**: Same agent group for rooms where you're the primary user. Separate agent group for rooms with different communities or sensitive contexts.
|
||||
@@ -0,0 +1,3 @@
|
||||
# Verify Matrix Channel
|
||||
|
||||
Invite the bot to a Matrix room and send a message. The bot should respond within a few seconds.
|
||||
@@ -1,55 +0,0 @@
|
||||
# Remove Matrix
|
||||
|
||||
Every step is idempotent — safe to re-run.
|
||||
|
||||
## 1. Remove the adapter
|
||||
|
||||
Delete the self-registration import from `src/channels/index.ts` (skip if already gone):
|
||||
|
||||
```typescript
|
||||
import './matrix.js';
|
||||
```
|
||||
|
||||
Then delete the copied adapter and its registration test:
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```bash
|
||||
mkdir -p data/env && cp .env data/env/env
|
||||
```
|
||||
|
||||
## 3. Remove the package
|
||||
|
||||
```bash
|
||||
pnpm uninstall @beeper/chat-adapter-matrix
|
||||
```
|
||||
|
||||
## 4. Rebuild and restart
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
source setup/lib/install-slug.sh
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
# Linux: systemctl --user restart $(systemd_unit)
|
||||
```
|
||||
@@ -1,155 +0,0 @@
|
||||
---
|
||||
name: add-matrix
|
||||
description: Add Matrix channel integration via Chat SDK. Works with any Matrix homeserver.
|
||||
---
|
||||
|
||||
# Add Matrix Channel
|
||||
|
||||
Adds Matrix support via the Chat SDK bridge.
|
||||
|
||||
## Install
|
||||
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the Matrix adapter in from the `channels` branch.
|
||||
|
||||
### Pre-flight (idempotent)
|
||||
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
|
||||
- `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
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and its registration test
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
```typescript
|
||||
import './matrix.js';
|
||||
```
|
||||
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @beeper/chat-adapter-matrix@0.2.0
|
||||
```
|
||||
|
||||
### 5. 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):
|
||||
|
||||
```bash
|
||||
node -e '
|
||||
const fs = require("fs"), path = require("path");
|
||||
const root = "node_modules/.pnpm";
|
||||
const dir = fs.readdirSync(root).find(d => d.startsWith("@beeper+chat-adapter-matrix@"));
|
||||
if (!dir) { console.log("Matrix adapter not installed"); process.exit(0); }
|
||||
const f = path.join(root, dir, "node_modules/@beeper/chat-adapter-matrix/dist/index.js");
|
||||
fs.writeFileSync(f, fs.readFileSync(f, "utf8").replace(
|
||||
/from "(matrix-js-sdk\/lib\/[^"]+?)(?<!\.js)"/g, "from \"$1.js\""
|
||||
));
|
||||
console.log("Patched", f);
|
||||
'
|
||||
```
|
||||
|
||||
Re-run this after every `pnpm install` that touches the adapter.
|
||||
|
||||
### 6. Build and validate
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
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`.
|
||||
|
||||
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.
|
||||
|
||||
### Create a bot account
|
||||
|
||||
1. Open [app.element.io](https://app.element.io) in a private/incognito window (or sign out first)
|
||||
2. Register a new account for the bot (e.g. `andybot` on matrix.org)
|
||||
3. Note the bot's user ID (e.g. `@andybot:matrix.org`)
|
||||
|
||||
### Choose an auth method
|
||||
|
||||
**Option A: Username + Password (simpler)**
|
||||
|
||||
No extra steps — just use the bot account's credentials directly. The adapter logs in automatically.
|
||||
|
||||
```bash
|
||||
MATRIX_BASE_URL=https://matrix.org
|
||||
MATRIX_USERNAME=andybot
|
||||
MATRIX_PASSWORD=your-bot-password
|
||||
MATRIX_USER_ID=@andybot:matrix.org
|
||||
MATRIX_BOT_USERNAME=Andy
|
||||
```
|
||||
|
||||
**Option B: Access Token (recommended for production)**
|
||||
|
||||
Get an access token from Element: sign into the bot account → **Settings** > **Help & About** > **Access Token** (under Advanced). Or via API:
|
||||
|
||||
```bash
|
||||
curl -XPOST 'https://matrix.org/_matrix/client/r0/login' \
|
||||
-d '{"type":"m.login.password","user":"andybot","password":"..."}'
|
||||
```
|
||||
|
||||
```bash
|
||||
MATRIX_BASE_URL=https://matrix.org
|
||||
MATRIX_ACCESS_TOKEN=your-access-token
|
||||
MATRIX_USER_ID=@andybot:matrix.org
|
||||
MATRIX_BOT_USERNAME=Andy
|
||||
```
|
||||
|
||||
### Optional settings
|
||||
|
||||
```bash
|
||||
MATRIX_INVITE_AUTOJOIN=true # Auto-accept room invites (default: true)
|
||||
MATRIX_INVITE_AUTOJOIN_ALLOWLIST=@you:matrix.org # Only accept invites from these users
|
||||
MATRIX_RECOVERY_KEY=your-recovery-key # Enable E2EE cross-signing
|
||||
MATRIX_DEVICE_ID=NANOCLAW01 # Stable device ID across restarts
|
||||
```
|
||||
|
||||
### Configure environment
|
||||
|
||||
Add the chosen env vars to `.env`, then sync:
|
||||
|
||||
```bash
|
||||
mkdir -p data/env && cp .env data/env/env
|
||||
```
|
||||
|
||||
## 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.
|
||||
|
||||
## Channel Info
|
||||
|
||||
- **type**: `matrix`
|
||||
- **terminology**: Matrix has "rooms." A room can be a group chat or a direct message. Rooms have internal IDs (like `!abc123:matrix.org`) and optional aliases (like `#general:matrix.org`).
|
||||
- **how-to-find-id**: For DMs, use the bot's `openDM` to resolve the room automatically. For group rooms, in Element click the room name > Settings > Advanced — the "Internal room ID" is the platform ID (starts with `!`). Or use a room alias like `#general:matrix.org`.
|
||||
- **supports-threads**: partial (some clients support threads, but not all — treat as no for reliability)
|
||||
- **typical-use**: Interactive chat — rooms or direct messages. Requires a separate bot account (the agent cannot DM users from their own account).
|
||||
- **default-isolation**: Same agent group for rooms where you're the primary user. Separate agent group for rooms with different communities or sensitive contexts.
|
||||
@@ -1,60 +0,0 @@
|
||||
# Remove Mnemon
|
||||
|
||||
Every step is idempotent — safe to run even if some steps were never applied.
|
||||
|
||||
## 1. Strip the Dockerfile install layer
|
||||
|
||||
Open `container/Dockerfile` and delete the mnemon block (the `# ---- mnemon` comment, the `ARG MNEMON_VERSION`, the `RUN` that downloads the binary, and the `ENV MNEMON_DATA_DIR` line):
|
||||
|
||||
```dockerfile
|
||||
# ---- mnemon — persistent agent memory ----------------------------------------
|
||||
ARG MNEMON_VERSION=0.1.1
|
||||
RUN ARCH=$(dpkg --print-architecture) && \
|
||||
curl -fsSL "https://github.com/mnemon-dev/mnemon/releases/download/v${MNEMON_VERSION}/mnemon_${MNEMON_VERSION}_linux_${ARCH}.tar.gz" \
|
||||
| tar -xz -C /usr/local/bin mnemon && \
|
||||
chmod +x /usr/local/bin/mnemon
|
||||
|
||||
ENV MNEMON_DATA_DIR=/home/node/.claude/mnemon
|
||||
```
|
||||
|
||||
If the block is already gone, skip this step.
|
||||
|
||||
## 2. Strip the entrypoint setup line
|
||||
|
||||
Open `container/entrypoint.sh` and delete the `mnemon setup` line that follows `set -e`:
|
||||
|
||||
```bash
|
||||
mnemon setup --target claude-code --yes --global >/dev/stderr 2>&1
|
||||
```
|
||||
|
||||
If the line is already gone, skip this step.
|
||||
|
||||
## 3. Delete the copied test files
|
||||
|
||||
```bash
|
||||
rm -f src/mnemon-dockerfile.test.ts src/mnemon-entrypoint.test.ts
|
||||
```
|
||||
|
||||
## 4. Rebuild and restart
|
||||
|
||||
```bash
|
||||
pnpm run build && ./container/build.sh
|
||||
source setup/lib/install-slug.sh
|
||||
|
||||
# macOS
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
|
||||
|
||||
# Linux
|
||||
systemctl --user restart $(systemd_unit)
|
||||
```
|
||||
|
||||
## 5. Delete stored memory (optional)
|
||||
|
||||
Mnemon's graph lives at `/home/node/.claude/mnemon/` in each container, which maps to the per-agent-group `.claude/` directory on the host. To find the host path and clear it:
|
||||
|
||||
```bash
|
||||
docker inspect $(docker ps --filter name=nanoclaw-v2 --format '{{.Names}}' | head -1) \
|
||||
--format '{{range .Mounts}}{{if eq .Destination "/home/node/.claude"}}{{.Source}}{{end}}{{end}}'
|
||||
```
|
||||
|
||||
Stop the container, then delete the `mnemon/` subdirectory from that path.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user