mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-12 18:11:51 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ae66624eb | |||
| 7e86f6c642 | |||
| f97cd4442a | |||
| d2e264a969 |
@@ -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,83 +0,0 @@
|
||||
# Remove Codex provider
|
||||
|
||||
Idempotent — safe to run even if some steps were never applied. Reverses both the host (`src/providers/`) and container (`container/agent-runner/src/providers/`) trees, plus the Dockerfile CLI install.
|
||||
|
||||
## 1. Delete the barrel import lines (both trees)
|
||||
|
||||
Delete (do not comment out) the `import './codex.js';` line from each barrel:
|
||||
|
||||
- `src/providers/index.ts`
|
||||
- `container/agent-runner/src/providers/index.ts`
|
||||
|
||||
This unregisters the provider from both `listProviderContainerConfigNames()` (host) and `listProviderNames()` (container).
|
||||
|
||||
## 2. Delete the copied files (both trees)
|
||||
|
||||
```bash
|
||||
rm -f src/providers/codex.ts \
|
||||
src/providers/codex-registration.test.ts \
|
||||
container/agent-runner/src/providers/codex.ts \
|
||||
container/agent-runner/src/providers/codex-app-server.ts \
|
||||
container/agent-runner/src/providers/codex.factory.test.ts \
|
||||
container/agent-runner/src/providers/codex-registration.test.ts \
|
||||
container/agent-runner/src/providers/codex-dockerfile.test.ts
|
||||
```
|
||||
|
||||
## 3. Revert the Dockerfile CLI install
|
||||
|
||||
In `container/Dockerfile`, remove both Codex edits (skip whichever is already gone):
|
||||
|
||||
**(a)** Delete the version ARG from the "Pin CLI versions" block:
|
||||
|
||||
```dockerfile
|
||||
ARG CODEX_VERSION=0.124.0
|
||||
```
|
||||
|
||||
**(b)** Delete the standalone Codex install layer:
|
||||
|
||||
```dockerfile
|
||||
RUN --mount=type=cache,target=/root/.cache/pnpm \
|
||||
pnpm install -g "@openai/codex@${CODEX_VERSION}"
|
||||
```
|
||||
|
||||
Leave the other per-CLI install layers (claude-code, agent-browser, vercel) untouched.
|
||||
|
||||
## 4. Dependency
|
||||
|
||||
Codex is a CLI binary installed via the Dockerfile — there is no agent-runner package dependency to uninstall. Step 3 removes the only install surface; no `bun remove` / `pnpm uninstall` is needed.
|
||||
|
||||
## 5. Unset Codex env vars
|
||||
|
||||
Remove any Codex-specific lines you added to `.env` (`OPENAI_API_KEY`, `OPENAI_BASE_URL`, `CODEX_MODEL`) if no other integration uses them, then re-sync to the container:
|
||||
|
||||
```bash
|
||||
mkdir -p data/env && cp .env data/env/env
|
||||
```
|
||||
|
||||
Switch any group still on Codex back to the default provider — set `"provider": "claude"` in `groups/<folder>/container.json` and clear `agent_provider` on the group/session in the DB.
|
||||
|
||||
## 6. 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, the registration guards no longer apply (their files are gone). Confirm the provider is fully unwired:
|
||||
|
||||
```bash
|
||||
grep -R "codex.js" src/providers/index.ts container/agent-runner/src/providers/index.ts # no output
|
||||
grep "@openai/codex" container/Dockerfile # no output
|
||||
```
|
||||
|
||||
In a wired agent, requesting `agent_provider = 'codex'` should fall back to the default provider since `codex` is no longer in the registry.
|
||||
@@ -1,186 +0,0 @@
|
||||
---
|
||||
name: add-codex
|
||||
description: Use Codex (CLI + AppServer) as the full agent provider — planning, tool orchestration, native compaction, MCP tools, session resume — in place of the Claude Agent SDK. ChatGPT subscription or OPENAI_API_KEY. Per-group via agent_provider. Distinct from using OpenAI as an MCP tool (where Claude remains the planner).
|
||||
---
|
||||
|
||||
# Codex agent provider
|
||||
|
||||
NanoClaw runs agents in a long-lived **poll loop** inside the container. The backend is selected with **`AGENT_PROVIDER`** (`claude` | `opencode` | `codex` | `mock`).
|
||||
|
||||
Trunk ships with only the `claude` provider baked in. This skill copies the Codex provider files in from the `providers` branch, wires them into the host and container barrels, updates the Dockerfile to install the Codex CLI, and rebuilds the image.
|
||||
|
||||
The Codex provider runs `codex app-server` as a child process and speaks JSON-RPC over stdio. That gives it native session resume, streaming events, MCP tool access, and `thread/compact/start` compaction — same feature bar as the Claude Agent SDK, without the Anthropic-only lock-in.
|
||||
|
||||
## Install
|
||||
|
||||
### Pre-flight
|
||||
|
||||
If all of the following are already present, skip to **Configuration**:
|
||||
|
||||
- `src/providers/codex.ts`
|
||||
- `src/providers/codex-registration.test.ts`
|
||||
- `container/agent-runner/src/providers/codex.ts`
|
||||
- `container/agent-runner/src/providers/codex-app-server.ts`
|
||||
- `container/agent-runner/src/providers/codex.factory.test.ts`
|
||||
- `container/agent-runner/src/providers/codex-registration.test.ts`
|
||||
- `container/agent-runner/src/providers/codex-dockerfile.test.ts`
|
||||
- `import './codex.js';` line in `src/providers/index.ts`
|
||||
- `import './codex.js';` line in `container/agent-runner/src/providers/index.ts`
|
||||
- `ARG CODEX_VERSION` and `"@openai/codex@${CODEX_VERSION}"` in the pnpm global-install block in `container/Dockerfile`
|
||||
|
||||
Missing pieces — continue below. All steps are idempotent; re-running is safe.
|
||||
|
||||
### 1. Fetch the providers branch
|
||||
|
||||
```bash
|
||||
git fetch origin providers
|
||||
```
|
||||
|
||||
### 2. Copy the Codex source files and tests
|
||||
|
||||
Wholesale copies (owned entirely by this skill — user edits to these files won't survive a re-run, as designed):
|
||||
|
||||
```bash
|
||||
git show origin/providers:src/providers/codex.ts > src/providers/codex.ts
|
||||
git show origin/providers:src/providers/codex-registration.test.ts > src/providers/codex-registration.test.ts
|
||||
git show origin/providers:container/agent-runner/src/providers/codex.ts > container/agent-runner/src/providers/codex.ts
|
||||
git show origin/providers:container/agent-runner/src/providers/codex-app-server.ts > container/agent-runner/src/providers/codex-app-server.ts
|
||||
git show origin/providers:container/agent-runner/src/providers/codex.factory.test.ts > container/agent-runner/src/providers/codex.factory.test.ts
|
||||
git show origin/providers:container/agent-runner/src/providers/codex-registration.test.ts > container/agent-runner/src/providers/codex-registration.test.ts
|
||||
```
|
||||
|
||||
The two `codex-registration.test.ts` files are the **registration guards**. Each imports only the real barrel — the host one calls `listProviderContainerConfigNames()` from `src/providers/index.ts`, the container one calls `listProviderNames()` from `container/agent-runner/src/providers/index.ts` — and asserts `codex` is present. They go red the instant a barrel import line is deleted or drifts. (`codex.factory.test.ts` imports `./codex.js` directly and self-registers, so it stays green even if the barrel line is gone — keep it as a unit test of provider behavior, but it is **not** the registration guard.)
|
||||
|
||||
If `git show origin/providers:.../codex-registration.test.ts` errors with `path ... does not exist`, the registration tests have not landed on `origin/providers` yet. Run `git fetch origin providers` again; once the branch carries them, the copies above succeed. The rest of the install proceeds regardless — the Dockerfile and factory tests still run.
|
||||
|
||||
Copy the Dockerfile structural test that ships with this skill into the container provider tree:
|
||||
|
||||
```bash
|
||||
cp .claude/skills/add-codex/codex-dockerfile.test.ts container/agent-runner/src/providers/codex-dockerfile.test.ts
|
||||
```
|
||||
|
||||
`codex-dockerfile.test.ts` reads the real `container/Dockerfile` and asserts the `ARG CODEX_VERSION=` line and the `pnpm install -g "@openai/codex@${CODEX_VERSION}"` line are both present. The Codex CLI is a binary, not an importable package, so the registration tests cannot see it — this structural test is what guards the Dockerfile edits in step 4.
|
||||
|
||||
### 3. Append the self-registration imports
|
||||
|
||||
Each barrel gets one line — alphabetical placement keeps diffs small.
|
||||
|
||||
`src/providers/index.ts`:
|
||||
|
||||
```typescript
|
||||
import './codex.js';
|
||||
```
|
||||
|
||||
`container/agent-runner/src/providers/index.ts`:
|
||||
|
||||
```typescript
|
||||
import './codex.js';
|
||||
```
|
||||
|
||||
### 4. Add the Codex CLI to the container Dockerfile
|
||||
|
||||
Two edits to `container/Dockerfile`, both idempotent (skip if already present):
|
||||
|
||||
**(a)** In the "Pin CLI versions" ARG block (around line 18), add after `ARG CLAUDE_CODE_VERSION=...`:
|
||||
|
||||
```dockerfile
|
||||
ARG CODEX_VERSION=0.124.0
|
||||
```
|
||||
|
||||
**(b)** Add a new standalone `RUN` block for the Codex CLI, after the existing per-CLI install blocks (around line 106, right after the `@anthropic-ai/claude-code` block). The Dockerfile splits each global CLI into its own layer for cache granularity — keep that pattern; do not collapse them into a single combined `pnpm install -g` call:
|
||||
|
||||
```dockerfile
|
||||
RUN --mount=type=cache,target=/root/.cache/pnpm \
|
||||
pnpm install -g "@openai/codex@${CODEX_VERSION}"
|
||||
```
|
||||
|
||||
Note: **no agent-runner package dependency** — Codex is a CLI binary, not a library. Unlike OpenCode, there's nothing to add to `container/agent-runner/package.json`.
|
||||
|
||||
### 5. Build and validate
|
||||
|
||||
```bash
|
||||
pnpm run build # host
|
||||
pnpm exec vitest run src/providers/codex-registration.test.ts # host registration guard
|
||||
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit # container typecheck
|
||||
cd container/agent-runner && bun test src/providers/codex-registration.test.ts && cd - # container registration guard
|
||||
cd container/agent-runner && bun test src/providers/codex-dockerfile.test.ts && cd - # Dockerfile structural guard
|
||||
./container/build.sh # agent image
|
||||
```
|
||||
|
||||
All must be clean before proceeding.
|
||||
|
||||
- The **host** `codex-registration.test.ts` imports the real host barrel (`src/providers/index.ts`) and asserts `listProviderContainerConfigNames()` contains `codex`. It goes red if the `import './codex.js';` line is deleted or drifts, or if the barrel fails to evaluate.
|
||||
- The **container** `codex-registration.test.ts` imports the real container barrel (`container/agent-runner/src/providers/index.ts`) and asserts `listProviderNames()` contains `codex`. Same failure surface for the container-side import line.
|
||||
- The **Dockerfile** `codex-dockerfile.test.ts` reads `container/Dockerfile` and asserts the `ARG CODEX_VERSION=` and `@openai/codex@${CODEX_VERSION}` install lines are present — red if either edit is dropped.
|
||||
|
||||
The `@openai/codex` CLI binary is guarded by the Dockerfile structural test plus the container build (`./container/build.sh` fails if the install line is bad), **not** by the registration test — Codex is a CLI binary, not an importable package, so nothing imports it for the registration guard to trip on. To confirm the binary is actually present after the image rebuild, probe it inside a running container with `docker exec <container> codex --version`.
|
||||
|
||||
The host-side provider also consumes core APIs (per-session `~/.codex` mount, env passthrough); that typed core-API consumption is guarded by `pnpm run build`.
|
||||
|
||||
## Configuration
|
||||
|
||||
Codex supports two primary auth paths and one experimental BYO-endpoint path. Pick the one that matches your setup.
|
||||
|
||||
### Option A — ChatGPT subscription (recommended for individuals)
|
||||
|
||||
On the host (not inside the container), run Codex's OAuth login:
|
||||
|
||||
```bash
|
||||
codex login
|
||||
```
|
||||
|
||||
This writes `~/.codex/auth.json` with a subscription token. The host-side Codex provider ([src/providers/codex.ts](../../../src/providers/codex.ts)) copies `auth.json` into a per-session `~/.codex` directory mounted into the container — your host's own Codex CLI is never touched.
|
||||
|
||||
No `.env` variables required for this mode.
|
||||
|
||||
### Option B — API key (recommended for CI or API billing)
|
||||
|
||||
```env
|
||||
OPENAI_API_KEY=sk-...
|
||||
CODEX_MODEL=gpt-5.4-mini
|
||||
```
|
||||
|
||||
The host forwards both variables into the container. If both subscription (`auth.json`) and `OPENAI_API_KEY` are present, Codex prefers the subscription.
|
||||
|
||||
### Option C — BYO OpenAI-compatible endpoint (experimental)
|
||||
|
||||
Codex's built-in `openai` provider honors the `OPENAI_BASE_URL` env var directly. Point it at any OpenAI-compatible endpoint — Groq, Together, self-hosted vLLM, an OpenAI proxy, etc.
|
||||
|
||||
```env
|
||||
OPENAI_API_KEY=...
|
||||
OPENAI_BASE_URL=https://api.groq.com/openai/v1
|
||||
CODEX_MODEL=llama-3.3-70b-versatile
|
||||
```
|
||||
|
||||
Codex also ships first-class local-runner flags — `codex --oss --local-provider ollama` or `--local-provider lmstudio` — that auto-detect a local server. To use those inside NanoClaw, set `CODEX_MODEL` to a model your local runner serves and add the corresponding base URL; see the Codex CLI docs for the full `model_provider = oss` configuration.
|
||||
|
||||
**Experimental caveat:** tool-calling quality depends on the model and endpoint. Not every OpenAI-compat provider implements the full function-calling spec, and smaller models (< 30B) often struggle with multi-step tool orchestration. Test before committing.
|
||||
|
||||
### Per group / per session
|
||||
|
||||
Set `"provider": "codex"` in the group's **`container.json`** (`groups/<folder>/container.json`) — the in-container runner reads `provider` from there, not from the DB. The DB columns **`agent_groups.agent_provider`** and **`sessions.agent_provider`** (session overrides group) only drive host-side provider contribution — per-session `~/.codex` mount, `OPENAI_*` / `CODEX_MODEL` env passthrough — and do not propagate into `container.json` at spawn time. Set both, or just edit `container.json`; if they disagree, the runner uses `container.json` and the host-side resolver falls back through session → group → `container.json` → `'claude'`.
|
||||
|
||||
`CODEX_MODEL` applies process-wide via `.env`; if you need different models for different groups, set them via `container_config.env` on the group.
|
||||
|
||||
Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config.mcpServers` on the host. The runner merges them into the same `mcpServers` object passed to all providers.
|
||||
|
||||
## Operational notes
|
||||
|
||||
- **Spawn-per-query:** Codex's app-server is spawned fresh per query invocation, matching the OpenCode pattern. No long-lived daemon to keep healthy across sessions.
|
||||
- **Per-session `~/.codex` isolation:** each group gets its own copy of the host's `auth.json`. The container can rewrite `config.toml` freely on every wake without touching the host's Codex config.
|
||||
- **Native compaction:** kicks in automatically at 40K cumulative input tokens between turns, via `thread/compact/start`. If compaction fails, the provider logs and continues uncompacted — no fatal error.
|
||||
- **Approvals:** auto-accepted inside the container (the container is the sandbox; same posture as Claude/OpenCode).
|
||||
- **Mid-turn input:** Codex turns don't accept mid-turn messages. Follow-up `push()` calls queue and drain between turns, matching the OpenCode pattern. The poll-loop only pushes between turns anyway, so no messages are dropped.
|
||||
- **Stale thread recovery:** `isSessionInvalid` matches on stale-thread-ID errors (`thread not found`, `unknown thread`, etc.) so a cold-started app-server can recover cleanly when it sees a stored continuation it no longer has.
|
||||
|
||||
## Next Steps
|
||||
|
||||
The registration and Dockerfile guards in **Build and validate** confirm the wiring. For a live end-to-end check, set `agent_provider = 'codex'` on a test group and send a message after the image rebuild. A successful round-trip looks like:
|
||||
|
||||
- `init` event with a stable thread ID as continuation
|
||||
- One or more `activity` / `progress` events during the turn
|
||||
- `result` event with the model's reply
|
||||
|
||||
If the agent hangs or errors, check `~/.codex/auth.json` exists on the host (Option A) or that `OPENAI_API_KEY` is forwarding correctly (Option B) — `docker exec` into a running container and `env | grep -i openai` to confirm. To confirm the CLI binary itself landed in the image, `docker exec <container> codex --version`.
|
||||
|
||||
To back this provider out, follow [REMOVE.md](REMOVE.md).
|
||||
@@ -1,30 +0,0 @@
|
||||
// Structural guard for the Codex CLI install in container/Dockerfile.
|
||||
//
|
||||
// @openai/codex is a CLI *binary* installed via the Dockerfile, not an
|
||||
// importable package, so the barrel-driven registration tests cannot see it.
|
||||
// This test reads the real Dockerfile and asserts the version ARG and the
|
||||
// `pnpm install -g` line for @openai/codex are both present. It goes red if
|
||||
// either Dockerfile edit is dropped or drifts.
|
||||
//
|
||||
// Runs under bun (same suite as the container registration test):
|
||||
// cd container/agent-runner && bun test src/providers/codex-dockerfile.test.ts
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
|
||||
// container/agent-runner/src/providers/ -> container/Dockerfile
|
||||
const DOCKERFILE = path.join(import.meta.dir, '..', '..', '..', 'Dockerfile');
|
||||
|
||||
describe('container/Dockerfile codex CLI install', () => {
|
||||
const dockerfile = readFileSync(DOCKERFILE, 'utf8');
|
||||
|
||||
it('declares the CODEX_VERSION ARG', () => {
|
||||
expect(dockerfile).toMatch(/ARG\s+CODEX_VERSION=/);
|
||||
});
|
||||
|
||||
it('installs the @openai/codex CLI pinned to that ARG', () => {
|
||||
expect(dockerfile).toMatch(/pnpm install -g\s+"@openai\/codex@\$\{CODEX_VERSION\}"/);
|
||||
});
|
||||
});
|
||||
@@ -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 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`.
|
||||
@@ -1,40 +1,7 @@
|
||||
# Remove Discord
|
||||
|
||||
Every step is idempotent — safe to re-run.
|
||||
1. Comment out `import './discord.js'` in `src/channels/index.ts`
|
||||
2. Remove `DISCORD_BOT_TOKEN` from `.env`
|
||||
3. Rebuild and restart
|
||||
|
||||
## 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)
|
||||
```
|
||||
No package to uninstall — Discord is built in.
|
||||
|
||||
@@ -16,7 +16,6 @@ NanoClaw doesn't ship channels in trunk. This skill copies the Discord adapter i
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
|
||||
- `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
|
||||
|
||||
@@ -28,11 +27,10 @@ Otherwise continue. Every step below is safe to re-run.
|
||||
git fetch origin channels
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and its registration test
|
||||
### 2. Copy the adapter
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/discord.ts > src/channels/discord.ts
|
||||
git show origin/channels:src/channels/discord-registration.test.ts > src/channels/discord-registration.test.ts
|
||||
git show origin/channels:src/channels/discord.ts > src/channels/discord.ts
|
||||
```
|
||||
|
||||
### 3. Append the self-registration import
|
||||
@@ -46,18 +44,15 @@ import './discord.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/discord@4.27.0
|
||||
pnpm install @chat-adapter/discord@4.26.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
### 5. Build
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
pnpm exec vitest run src/channels/discord-registration.test.ts
|
||||
```
|
||||
|
||||
Both must be clean before proceeding. `discord-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `discord`. It goes red if the `import './discord.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/discord` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
|
||||
|
||||
## Credentials
|
||||
|
||||
### Create Discord Bot
|
||||
|
||||
@@ -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,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';"
|
||||
```
|
||||
@@ -24,8 +24,6 @@ NanoClaw doesn't ship channels in trunk. This skill copies the Emacs adapter and
|
||||
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';`
|
||||
|
||||
@@ -41,10 +39,9 @@ git fetch origin channels
|
||||
|
||||
```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 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:emacs/nanoclaw.el > emacs/nanoclaw.el
|
||||
```
|
||||
|
||||
### 3. Append the self-registration import
|
||||
@@ -55,16 +52,13 @@ Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
import './emacs.js';
|
||||
```
|
||||
|
||||
### 4. Build and validate
|
||||
### 4. Build
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
pnpm exec vitest run src/channels/emacs-registration.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.
|
||||
|
||||
End-to-end message delivery from a real Emacs buffer is verified manually once the service is running — see Verify and Troubleshooting.
|
||||
No npm package to install — the adapter uses only Node builtins (`http`).
|
||||
|
||||
## Enable
|
||||
|
||||
@@ -168,13 +162,10 @@ If you changed `EMACS_CHANNEL_PORT` from the default:
|
||||
|
||||
## Restart NanoClaw
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```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
|
||||
# systemctl --user restart nanoclaw # Linux
|
||||
```
|
||||
|
||||
## Verify
|
||||
@@ -249,8 +240,8 @@ grep -q "import './emacs.js'" src/channels/index.ts && echo "imported" || echo "
|
||||
|
||||
### 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'"`
|
||||
1. NanoClaw running: `launchctl list | grep nanoclaw` (macOS) / `systemctl --user status nanoclaw` (Linux)
|
||||
2. Messaging group wired: `sqlite3 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`
|
||||
|
||||
If no messaging group row exists, run the `register` command above.
|
||||
@@ -291,4 +282,15 @@ If an agent outputs org-mode directly, markers get double-converted and render i
|
||||
|
||||
## Removal
|
||||
|
||||
See [REMOVE.md](REMOVE.md) to uninstall this channel.
|
||||
```bash
|
||||
rm src/channels/emacs.ts src/channels/emacs.test.ts emacs/nanoclaw.el
|
||||
# Remove the `import './emacs.js';` line from src/channels/index.ts
|
||||
# Remove EMACS_* lines from .env
|
||||
pnpm run build
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
# systemctl --user restart nanoclaw # Linux
|
||||
|
||||
# Remove the NanoClaw block from your Emacs config
|
||||
# Optionally clean up the messaging group:
|
||||
sqlite3 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';"
|
||||
```
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -1,40 +1,6 @@
|
||||
# Remove Google Chat
|
||||
# Remove Google Chat Channel
|
||||
|
||||
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. 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
|
||||
|
||||
@@ -16,7 +16,6 @@ NanoClaw doesn't ship channels in trunk. This skill copies the Google Chat adapt
|
||||
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
|
||||
|
||||
@@ -28,11 +27,10 @@ Otherwise continue. Every step below is safe to re-run.
|
||||
git fetch origin channels
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and its registration test
|
||||
### 2. Copy the adapter
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/gchat.ts > src/channels/gchat.ts
|
||||
git show origin/channels:src/channels/gchat-registration.test.ts > src/channels/gchat-registration.test.ts
|
||||
git show origin/channels:src/channels/gchat.ts > src/channels/gchat.ts
|
||||
```
|
||||
|
||||
### 3. Append the self-registration import
|
||||
@@ -46,20 +44,15 @@ import './gchat.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/gchat@4.27.0
|
||||
pnpm install @chat-adapter/gchat@4.26.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
### 5. Build
|
||||
|
||||
```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)
|
||||
|
||||
@@ -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 +1,6 @@
|
||||
# Remove GitHub
|
||||
# Remove GitHub Channel
|
||||
|
||||
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. 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
|
||||
|
||||
@@ -20,7 +20,6 @@ NanoClaw doesn't ship channels in trunk. This skill copies the GitHub adapter in
|
||||
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
|
||||
|
||||
@@ -32,11 +31,10 @@ Otherwise continue. Every step below is safe to re-run.
|
||||
git fetch origin channels
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and its registration test
|
||||
### 2. Copy the adapter
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/github.ts > src/channels/github.ts
|
||||
git show origin/channels:src/channels/github-registration.test.ts > src/channels/github-registration.test.ts
|
||||
git show origin/channels:src/channels/github.ts > src/channels/github.ts
|
||||
```
|
||||
|
||||
### 3. Append the self-registration import
|
||||
@@ -50,20 +48,15 @@ import './github.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/github@4.27.0
|
||||
pnpm install @chat-adapter/github@4.26.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
### 5. Build
|
||||
|
||||
```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
|
||||
@@ -111,8 +104,8 @@ Run `/manage-channels` to wire the GitHub channel to an agent group, or insert m
|
||||
|
||||
```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'));
|
||||
INSERT INTO messaging_groups (id, channel_type, platform_id, name, is_group, unknown_sender_policy, created_at)
|
||||
VALUES ('mg-github-myrepo', 'github', 'github:owner/repo', '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)
|
||||
@@ -143,15 +136,7 @@ Use `per-thread` session mode so each PR/issue gets its own agent session.
|
||||
|
||||
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
|
||||
```
|
||||
Otherwise, restart the service (`systemctl --user restart nanoclaw` or `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`) to pick up the new channel.
|
||||
|
||||
## Channel Info
|
||||
|
||||
|
||||
@@ -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,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);
|
||||
});
|
||||
});
|
||||
@@ -1,40 +1,6 @@
|
||||
# Remove iMessage
|
||||
# Remove iMessage Channel
|
||||
|
||||
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. 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
|
||||
|
||||
@@ -16,7 +16,6 @@ NanoClaw doesn't ship channels in trunk. This skill copies the iMessage adapter
|
||||
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
|
||||
|
||||
@@ -28,11 +27,10 @@ Otherwise continue. Every step below is safe to re-run.
|
||||
git fetch origin channels
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and its registration test
|
||||
### 2. Copy the adapter
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/imessage.ts > src/channels/imessage.ts
|
||||
git show origin/channels:src/channels/imessage-registration.test.ts > src/channels/imessage-registration.test.ts
|
||||
git show origin/channels:src/channels/imessage.ts > src/channels/imessage.ts
|
||||
```
|
||||
|
||||
### 3. Append the self-registration import
|
||||
@@ -49,17 +47,12 @@ import './imessage.js';
|
||||
pnpm install chat-adapter-imessage@0.1.1
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
### 5. Build
|
||||
|
||||
```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)
|
||||
@@ -82,7 +75,7 @@ Stop and wait for the user to confirm before continuing.
|
||||
|
||||
### Remote Mode (Photon API)
|
||||
|
||||
1. Set up a [Photon](https://photon.codes) account
|
||||
1. Set up a [Photon](https://photon.im) account
|
||||
2. Get your server URL and API key
|
||||
|
||||
### Configure environment
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Verify iMessage Channel
|
||||
|
||||
Send an iMessage to the account running NanoClaw. The bot should respond within a few seconds.
|
||||
@@ -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.
|
||||
|
||||
@@ -1,49 +1,6 @@
|
||||
# Remove Linear
|
||||
# Remove Linear Channel
|
||||
|
||||
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. 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
|
||||
|
||||
@@ -22,16 +22,16 @@ Adds Linear support via the Chat SDK bridge. The agent participates in issue com
|
||||
|
||||
## 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.
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the Linear adapter in from the `channels` branch and patches the Chat SDK bridge to support catch-all message forwarding (Linear OAuth apps can't be @-mentioned).
|
||||
|
||||
### 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
|
||||
- `src/channels/chat-sdk-bridge.ts` contains `catchAll`
|
||||
|
||||
Otherwise continue. Every step below is safe to re-run.
|
||||
|
||||
@@ -41,11 +41,10 @@ Otherwise continue. Every step below is safe to re-run.
|
||||
git fetch origin channels
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and its registration test
|
||||
### 2. Copy the adapter
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/linear.ts > src/channels/linear.ts
|
||||
git show origin/channels:src/channels/linear-registration.test.ts > src/channels/linear-registration.test.ts
|
||||
git show origin/channels:src/channels/linear.ts > src/channels/linear.ts
|
||||
```
|
||||
|
||||
### 3. Append the self-registration import
|
||||
@@ -56,23 +55,47 @@ Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
import './linear.js';
|
||||
```
|
||||
|
||||
### 4. Install the adapter package (pinned)
|
||||
### 4. Patch the Chat SDK bridge for catch-all message forwarding
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/linear@4.27.0
|
||||
Linear OAuth apps can't be @-mentioned, so the bridge's `onNewMention` handler never fires. Add `catchAll` support to `src/channels/chat-sdk-bridge.ts`:
|
||||
|
||||
**4a.** Add `catchAll?: boolean` to the `ChatSdkBridgeConfig` interface:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Forward ALL messages in unsubscribed threads, not just @-mentions.
|
||||
* Use for platforms where the bot identity can't be @-mentioned (e.g.
|
||||
* Linear OAuth apps). The thread is auto-subscribed on first message.
|
||||
*/
|
||||
catchAll?: boolean;
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
**4b.** Add this handler block right after the `chat.onNewMention(...)` block (before the DMs block):
|
||||
|
||||
```typescript
|
||||
// Catch-all for platforms where @-mention isn't possible (e.g. Linear
|
||||
// OAuth apps). Forward every unsubscribed message and auto-subscribe.
|
||||
if (config.catchAll) {
|
||||
chat.onNewMessage(/.*/, async (thread, message) => {
|
||||
const channelId = adapter.channelIdFromThreadId(thread.id);
|
||||
await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message));
|
||||
await thread.subscribe();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/linear@4.26.0
|
||||
```
|
||||
|
||||
### 6. Build
|
||||
|
||||
```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
|
||||
@@ -119,8 +142,8 @@ Run `/manage-channels` to wire the Linear channel to an agent group, or insert m
|
||||
|
||||
```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'));
|
||||
INSERT INTO messaging_groups (id, channel_type, platform_id, name, is_group, unknown_sender_policy, created_at)
|
||||
VALUES ('mg-linear-eng', 'linear', 'linear:ENG', '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)
|
||||
@@ -133,15 +156,7 @@ The `platform_id` must be `linear:<TEAM_KEY>` matching the `LINEAR_TEAM_KEY` env
|
||||
|
||||
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
|
||||
```
|
||||
Otherwise, restart the service (`systemctl --user restart nanoclaw` or `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`) to pick up the new channel.
|
||||
|
||||
## Channel Info
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Verify Linear Channel
|
||||
|
||||
@mention the bot in a Linear issue comment. The bot should respond within a few seconds.
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -1,55 +1,6 @@
|
||||
# Remove Matrix
|
||||
# Remove Matrix Channel
|
||||
|
||||
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. 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
|
||||
|
||||
@@ -16,7 +16,6 @@ NanoClaw doesn't ship channels in trunk. This skill copies the Matrix adapter in
|
||||
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
|
||||
|
||||
@@ -28,11 +27,10 @@ Otherwise continue. Every step below is safe to re-run.
|
||||
git fetch origin channels
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and its registration test
|
||||
### 2. Copy the adapter
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/matrix.ts > src/channels/matrix.ts
|
||||
git show origin/channels:src/channels/matrix-registration.test.ts > src/channels/matrix-registration.test.ts
|
||||
git show origin/channels:src/channels/matrix.ts > src/channels/matrix.ts
|
||||
```
|
||||
|
||||
### 3. Append the self-registration import
|
||||
@@ -71,17 +69,12 @@ node -e '
|
||||
|
||||
Re-run this after every `pnpm install` that touches the adapter.
|
||||
|
||||
### 6. Build and validate
|
||||
### 6. Build
|
||||
|
||||
```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.
|
||||
|
||||
@@ -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,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.
|
||||
@@ -1,177 +0,0 @@
|
||||
---
|
||||
name: add-mnemon
|
||||
description: Add persistent graph-based memory via mnemon. Agents recall past context before responding and remember insights after each turn.
|
||||
---
|
||||
|
||||
# Add Mnemon — Persistent Memory
|
||||
|
||||
Installs [mnemon](https://github.com/mnemon-dev/mnemon) in the agent container image. On each container start, `mnemon setup` registers Claude Code hooks that surface relevant memory before the agent responds and store new insights after each turn. Memory is written to the per-agent-group `.claude/` mount and survives container restarts.
|
||||
|
||||
## Provider Compatibility
|
||||
|
||||
mnemon hooks fire only under `--target claude-code`. Use this skill on agent groups that run the default Claude provider (`AGENT_PROVIDER=claude`). Confirm the provider before applying:
|
||||
|
||||
```bash
|
||||
grep AGENT_PROVIDER .env groups/*/container.json 2>/dev/null
|
||||
```
|
||||
|
||||
If a group uses a different provider (e.g. `AGENT_PROVIDER=opencode`), it spawns its own process and never invokes the `claude` CLI, so the hooks registered by `mnemon setup` do not run for that group.
|
||||
|
||||
## Phase 1: Pre-flight
|
||||
|
||||
### Check if already applied
|
||||
|
||||
```bash
|
||||
grep -q 'MNEMON_VERSION' container/Dockerfile && echo "Already applied" || echo "Not applied"
|
||||
```
|
||||
|
||||
If already applied, re-run Phase 2 anyway — every step is idempotent and skips work that is already in place — then continue to Phase 3 (Verify).
|
||||
|
||||
### Check latest mnemon version
|
||||
|
||||
```bash
|
||||
curl -fsSL https://api.github.com/repos/mnemon-dev/mnemon/releases/latest | grep '"tag_name"'
|
||||
```
|
||||
|
||||
Note the version (e.g. `v0.1.1`) — use it as `MNEMON_VERSION` in the next step.
|
||||
|
||||
## Phase 2: Apply Changes
|
||||
|
||||
### 1. Dockerfile — install mnemon binary
|
||||
|
||||
Insert the mnemon block immediately above the `# ---- Bun runtime` section of `container/Dockerfile` (skip if `grep -q 'MNEMON_VERSION' container/Dockerfile` already matches):
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
`MNEMON_DATA_DIR` points into the per-agent-group `.claude/` mount, so memory persists across container restarts.
|
||||
|
||||
### 2. Entrypoint — run mnemon setup on each container start
|
||||
|
||||
`mnemon setup` is idempotent. Run it once per `container/entrypoint.sh`. First check whether the line is already present:
|
||||
|
||||
```bash
|
||||
grep -q 'mnemon setup' container/entrypoint.sh && echo "Already wired" || echo "Wire it"
|
||||
```
|
||||
|
||||
If it prints `Wire it`, add the setup call right after `set -e`, before the `cat` that captures stdin, so the result looks like:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# NanoClaw agent container entrypoint.
|
||||
#
|
||||
# ...existing header comment...
|
||||
|
||||
set -e
|
||||
|
||||
mnemon setup --target claude-code --yes --global >/dev/stderr 2>&1
|
||||
|
||||
cat > /tmp/input.json
|
||||
|
||||
exec bun run /app/src/index.ts < /tmp/input.json
|
||||
```
|
||||
|
||||
`>/dev/stderr 2>&1` routes all mnemon output to stderr (docker logs) so it doesn't interfere with the JSON stdin handshake between host and agent-runner.
|
||||
|
||||
### 3. Copy the integration tests
|
||||
|
||||
Both reach-ins are into container build/runtime files that aren't importable or typed (a GitHub-release binary in the Dockerfile, a shell line in the entrypoint), so structural tests guard them. Copy them into the host test tree:
|
||||
|
||||
```bash
|
||||
cp .claude/skills/add-mnemon/mnemon-dockerfile.test.ts src/mnemon-dockerfile.test.ts
|
||||
cp .claude/skills/add-mnemon/mnemon-entrypoint.test.ts src/mnemon-entrypoint.test.ts
|
||||
pnpm exec vitest run src/mnemon-dockerfile.test.ts src/mnemon-entrypoint.test.ts
|
||||
```
|
||||
|
||||
`mnemon-dockerfile.test.ts` asserts the `MNEMON_VERSION` ARG and `MNEMON_DATA_DIR` ENV are present (red if the install layer is dropped on an upgrade). `mnemon-entrypoint.test.ts` asserts the entrypoint invokes `mnemon setup --target claude-code` (red if the wiring is removed).
|
||||
|
||||
### 4. Rebuild and smoke-test the image
|
||||
|
||||
```bash
|
||||
./container/build.sh
|
||||
docker run --rm --entrypoint mnemon nanoclaw-agent:latest --version
|
||||
```
|
||||
|
||||
## Phase 3: Restart and Verify
|
||||
|
||||
### Restart the service
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
source setup/lib/install-slug.sh
|
||||
systemctl --user restart $(systemd_unit) # Linux
|
||||
# launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
```
|
||||
|
||||
### Confirm mnemon hooks are registered
|
||||
|
||||
After the next container starts, check that setup ran:
|
||||
|
||||
```bash
|
||||
docker logs $(docker ps --filter name=nanoclaw-v2 --format '{{.Names}}' | head -1) 2>&1 | grep -i mnemon
|
||||
```
|
||||
|
||||
Then inspect the hooks inside the running container:
|
||||
|
||||
```bash
|
||||
docker exec $(docker ps --filter name=nanoclaw-v2 --format '{{.Names}}' | head -1) \
|
||||
cat /home/node/.claude/settings.json | grep -A5 mnemon
|
||||
```
|
||||
|
||||
### Test memory recall
|
||||
|
||||
Have a conversation with the agent, then start a new session and reference something from the earlier one. Mnemon should surface the relevant context automatically without you restating it.
|
||||
|
||||
## Memory Storage
|
||||
|
||||
Mnemon writes to `/home/node/.claude/mnemon/` inside the container, which maps to the per-agent-group `.claude/` directory on the host. To find the exact host path:
|
||||
|
||||
```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}}'
|
||||
```
|
||||
|
||||
To reset all memory for an agent, stop the container and delete the `mnemon/` subdirectory from that host path.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### `mnemon: command not found` in container
|
||||
|
||||
The image wasn't rebuilt after adding the Dockerfile layer. Run `./container/build.sh` and restart.
|
||||
|
||||
### Memory not persisting across restarts
|
||||
|
||||
Verify `MNEMON_DATA_DIR` resolves to a mounted path (not an in-container ephemeral directory):
|
||||
|
||||
```bash
|
||||
docker exec <container> sh -c 'ls -la $MNEMON_DATA_DIR'
|
||||
```
|
||||
|
||||
If the directory is empty after conversations, the mount is missing or the path is wrong. Check the host mount with the `docker inspect` command above.
|
||||
|
||||
### Agent not using past memory
|
||||
|
||||
`mnemon setup` writes hooks into `/home/node/.claude/settings.json`. Verify:
|
||||
|
||||
```bash
|
||||
docker exec <container> cat /home/node/.claude/settings.json
|
||||
```
|
||||
|
||||
If the hooks are absent, `mnemon setup` may have failed silently. Check container startup logs for errors from mnemon.
|
||||
|
||||
### Setup fails at container start
|
||||
|
||||
Run setup manually inside a running container to see the full error:
|
||||
|
||||
```bash
|
||||
docker exec -it <container> mnemon setup --target claude-code --yes --global
|
||||
```
|
||||
@@ -1,36 +0,0 @@
|
||||
/**
|
||||
* Structural guard for the mnemon Dockerfile reach-in (the dependency install).
|
||||
*
|
||||
* mnemon ships as a GitHub-release binary, not an npm package, so it can't be
|
||||
* imported or typechecked. The only red-on-drift guard is asserting the install
|
||||
* layer is present in container/Dockerfile: drop the layer on an upgrade and the
|
||||
* container starts with "mnemon: command not found", but nothing else fails.
|
||||
* This test reads the Dockerfile and asserts the MNEMON_VERSION ARG and the
|
||||
* MNEMON_DATA_DIR ENV are both present.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
function dockerfile(): string {
|
||||
// From src/ up to repo root, then into container/.
|
||||
const p = path.resolve(__dirname, '..', 'container', 'Dockerfile');
|
||||
return fs.readFileSync(p, 'utf8');
|
||||
}
|
||||
|
||||
describe('container/Dockerfile installs the mnemon binary', () => {
|
||||
const text = dockerfile();
|
||||
|
||||
it('declares the MNEMON_VERSION build arg', () => {
|
||||
expect(text).toMatch(/ARG\s+MNEMON_VERSION/);
|
||||
});
|
||||
|
||||
it('downloads the mnemon release binary', () => {
|
||||
expect(text).toContain('mnemon-dev/mnemon/releases/download');
|
||||
});
|
||||
|
||||
it('sets MNEMON_DATA_DIR into the .claude mount', () => {
|
||||
expect(text).toMatch(/ENV\s+MNEMON_DATA_DIR=/);
|
||||
});
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
/**
|
||||
* Structural guard for the mnemon entrypoint reach-in.
|
||||
*
|
||||
* container/entrypoint.sh runs on every container start; the inserted
|
||||
* `mnemon setup --target claude-code` line is what registers the Claude Code
|
||||
* memory hooks. The entrypoint is a shell script, not an invocable function, so
|
||||
* the guard is structural: assert the setup invocation is present. Drop it on an
|
||||
* upgrade and the hooks silently never register — this test goes red.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
function entrypoint(): string {
|
||||
// From src/ up to repo root, then into container/.
|
||||
const p = path.resolve(__dirname, '..', 'container', 'entrypoint.sh');
|
||||
return fs.readFileSync(p, 'utf8');
|
||||
}
|
||||
|
||||
describe('container/entrypoint.sh runs mnemon setup on start', () => {
|
||||
const text = entrypoint();
|
||||
|
||||
it('invokes mnemon setup targeting claude-code', () => {
|
||||
expect(text).toMatch(/mnemon\s+setup\s+--target\s+claude-code/);
|
||||
});
|
||||
});
|
||||
@@ -76,7 +76,7 @@ Then rebuild the container image: `./container/build.sh`
|
||||
|
||||
Ask the user (plain text, not AskUserQuestion):
|
||||
|
||||
1. **Which agent group?** List available groups: `pnpm exec tsx scripts/q.ts data/v2.db "SELECT folder, name FROM agent_groups;"`
|
||||
1. **Which agent group?** List available groups: `sqlite3 data/v2.db "SELECT folder, name FROM agent_groups;"`
|
||||
2. **Which Ollama model?** List available: `curl -s http://localhost:11434/api/tags | grep '"name"'`
|
||||
3. **Block Anthropic API?** Recommended yes — prevents accidental spend if config drifts.
|
||||
|
||||
@@ -111,7 +111,7 @@ Read the agent group's shared Claude settings:
|
||||
|
||||
```bash
|
||||
# Find the agent group ID
|
||||
AG_ID=$(pnpm exec tsx scripts/q.ts data/v2.db "SELECT id FROM agent_groups WHERE folder='<FOLDER>';")
|
||||
AG_ID=$(sqlite3 data/v2.db "SELECT id FROM agent_groups WHERE folder='<FOLDER>';")
|
||||
SETTINGS=data/v2-sessions/$AG_ID/.claude-shared/settings.json
|
||||
```
|
||||
|
||||
@@ -130,15 +130,12 @@ file, not from env vars. This file is bind-mounted into the container as `~/.cla
|
||||
|
||||
## 5. Build and restart
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
export PATH="/opt/homebrew/bin:$PATH"
|
||||
pnpm run build
|
||||
source setup/lib/install-slug.sh
|
||||
launchctl unload ~/Library/LaunchAgents/$(launchd_label).plist
|
||||
launchctl load ~/Library/LaunchAgents/$(launchd_label).plist
|
||||
# Linux: systemctl --user restart $(systemd_unit)
|
||||
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
|
||||
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
|
||||
# Linux: systemctl --user restart nanoclaw
|
||||
```
|
||||
|
||||
## 6. Verify
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
# Remove Ollama
|
||||
|
||||
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/ollama-mcp-stdio.ts \
|
||||
container/agent-runner/src/ollama-registration.test.ts \
|
||||
src/ollama-env.ts \
|
||||
src/ollama-wiring.test.ts
|
||||
```
|
||||
|
||||
## 2. Unregister the MCP server
|
||||
|
||||
In `container/agent-runner/src/index.ts`, remove the `ollama: { … }` 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 { ollamaEnvArgs } from './ollama-env.js';` import.
|
||||
- Remove the `args.push(...ollamaEnvArgs());` line that follows the `TZ` env line.
|
||||
- Remove the `[OLLAMA]` branch from the `container.stderr` logger. If `[OLLAMA]` was the only prefix branch, restore the logger to its single-line `log.debug(line, …)` form; if other local-model tools still have branches there, just drop the `[OLLAMA]` one and leave the rest intact.
|
||||
|
||||
## 4. Remove env vars
|
||||
|
||||
Remove the Ollama block from `.env.example`, and the `OLLAMA_HOST` / `OLLAMA_ADMIN_TOOLS` 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 ollama models" should report no such tool, and the logs should show no `[OLLAMA]` lines after the last restart:
|
||||
|
||||
```bash
|
||||
grep "\[OLLAMA\]" logs/nanoclaw.log | tail -5
|
||||
```
|
||||
@@ -5,19 +5,17 @@ description: Add Ollama MCP server so the container agent can call local models
|
||||
|
||||
# Add Ollama Integration
|
||||
|
||||
This skill adds a stdio-based MCP server that exposes local [Ollama](https://ollama.com) models as tools for the container agent. Claude remains the orchestrator but can offload work to local models served by the Ollama daemon on the host, and can optionally manage the model library directly. Ollama runs locally and is keyless — there are no credentials to thread; the only configuration is the daemon's base URL.
|
||||
This skill adds a stdio-based MCP server that exposes local Ollama models as tools for the container agent. Claude remains the orchestrator but can offload work to local models, and can optionally manage the model library directly.
|
||||
|
||||
Core tools (always available):
|
||||
- `ollama_list_models` — list installed models with name, size, and family (`GET /api/tags`)
|
||||
- `ollama_generate` — send a prompt to a specified model and return the response (`POST /api/generate`)
|
||||
- `ollama_list_models` — list installed Ollama models with name, size, and family
|
||||
- `ollama_generate` — send a prompt to a specified model and return the response
|
||||
|
||||
Management tools (opt-in via `OLLAMA_ADMIN_TOOLS=true`):
|
||||
- `ollama_pull_model` — pull (download) a model from the Ollama registry (`POST /api/pull`)
|
||||
- `ollama_delete_model` — delete a locally installed model to free disk space (`DELETE /api/delete`)
|
||||
- `ollama_show_model` — show model details: modelfile, parameters, and architecture info (`POST /api/show`)
|
||||
- `ollama_list_running` — list models currently loaded in memory with memory usage and processor type (`GET /api/ps`)
|
||||
|
||||
The skill ships the MCP server source (and its tests) 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__ollama__*`) is derived from the registered server name.
|
||||
- `ollama_pull_model` — pull (download) a model from the Ollama registry
|
||||
- `ollama_delete_model` — delete a locally installed model to free disk space
|
||||
- `ollama_show_model` — show model details: modelfile, parameters, and architecture info
|
||||
- `ollama_list_running` — list models currently loaded in memory with memory usage and processor type
|
||||
|
||||
## Phase 1: Pre-flight
|
||||
|
||||
@@ -27,173 +25,77 @@ Check if `container/agent-runner/src/ollama-mcp-stdio.ts` exists. If it does, sk
|
||||
|
||||
### Check prerequisites
|
||||
|
||||
Verify Ollama is installed and its daemon is reachable. On the host:
|
||||
Verify Ollama is installed and running on the host:
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:11434/api/tags | head
|
||||
ollama list
|
||||
```
|
||||
|
||||
If the request fails:
|
||||
|
||||
1. Install Ollama from https://ollama.com/download.
|
||||
2. Start it (the desktop app runs the daemon, or run `ollama serve`).
|
||||
3. Confirm the daemon answers: `curl -s http://127.0.0.1:11434/api/tags`.
|
||||
If Ollama is not installed, direct the user to https://ollama.com/download.
|
||||
|
||||
If no models are installed, suggest pulling one:
|
||||
|
||||
> You need at least one model. For example:
|
||||
> You need at least one model. I recommend:
|
||||
>
|
||||
> ```bash
|
||||
> ollama pull gemma3:1b # Small, fast (~1GB)
|
||||
> ollama pull llama3.2 # Good general purpose (~2GB)
|
||||
> ollama pull qwen3-coder:30b # Best for code tasks (~18GB)
|
||||
> ollama pull gemma3:1b # Small, fast (1GB)
|
||||
> ollama pull llama3.2 # Good general purpose (2GB)
|
||||
> ollama pull qwen3-coder:30b # Best for code tasks (18GB)
|
||||
> ```
|
||||
|
||||
## 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.
|
||||
### Ensure upstream remote
|
||||
|
||||
```bash
|
||||
S=.claude/skills/add-ollama-tool
|
||||
# Container (Bun) tree — the MCP server and the registration wiring test
|
||||
cp $S/ollama-mcp-stdio.ts container/agent-runner/src/ollama-mcp-stdio.ts
|
||||
cp $S/ollama-registration.test.ts container/agent-runner/src/ollama-registration.test.ts
|
||||
# Host (Node) tree — the env-forwarding helper and the wiring test
|
||||
cp $S/ollama-env.ts src/ollama-env.ts
|
||||
cp $S/ollama-wiring.test.ts src/ollama-wiring.test.ts
|
||||
git remote -v
|
||||
```
|
||||
|
||||
### 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 `ollama` entry alongside `nanoclaw`:
|
||||
|
||||
```ts
|
||||
const mcpServers: Record<string, { command: string; args: string[]; env: Record<string, string> }> = {
|
||||
nanoclaw: {
|
||||
command: 'bun',
|
||||
args: ['run', mcpServerPath],
|
||||
env: {},
|
||||
},
|
||||
ollama: {
|
||||
command: 'bun',
|
||||
args: ['run', path.join(__dirname, 'ollama-mcp-stdio.ts')],
|
||||
env: {
|
||||
...(process.env.OLLAMA_HOST ? { OLLAMA_HOST: process.env.OLLAMA_HOST } : {}),
|
||||
...(process.env.OLLAMA_ADMIN_TOOLS ? { OLLAMA_ADMIN_TOOLS: process.env.OLLAMA_ADMIN_TOOLS } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
`ollama-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 container receives `TZ` and OneCLI networking vars by default; any other host env
|
||||
var the MCP subprocess needs must be forwarded explicitly. The forwarding logic lives in
|
||||
the copied `src/ollama-env.ts` (`ollamaEnvArgs()`) — `OLLAMA_HOST` (the daemon base URL)
|
||||
and `OLLAMA_ADMIN_TOOLS` (the library-management opt-in flag). Both are configuration, not
|
||||
credentials, so they are passed through plainly; Ollama itself is local and keyless.
|
||||
|
||||
Import it in `src/container-runner.ts` (alongside the other local imports):
|
||||
|
||||
```ts
|
||||
import { ollamaEnvArgs } from './ollama-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(...ollamaEnvArgs());
|
||||
```
|
||||
|
||||
`ollama-wiring.test.ts` asserts this `args.push(...ollamaEnvArgs())` call exists inside `buildContainerArgs`.
|
||||
|
||||
### Surface `[OLLAMA]` log lines at info level
|
||||
|
||||
> **Shared block.** This rewrites the `container.stderr` logger, which other local-model tools (e.g. `add-atomic-chat-tool` for `[ATOMIC]`) also edit to surface their own prefix. Touch only the `[OLLAMA]` 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('[OLLAMA]')) {
|
||||
log.info(line, { container: agentGroup.folder });
|
||||
} else {
|
||||
log.debug(line, { container: agentGroup.folder });
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
If `add-atomic-chat-tool` (or another local-model tool) has already turned this into a
|
||||
multi-branch block, just add an `else if (line.includes('[OLLAMA]'))` branch instead of
|
||||
replacing it.
|
||||
|
||||
### Add env-var stubs to `.env.example`
|
||||
|
||||
Append to `.env.example`:
|
||||
If `upstream` is missing, add it:
|
||||
|
||||
```bash
|
||||
# Ollama MCP tool (.claude/skills/add-ollama-tool)
|
||||
# Override the host where the Ollama daemon listens.
|
||||
# Default: http://host.docker.internal:11434 (with fallback to localhost)
|
||||
# OLLAMA_HOST=http://host.docker.internal:11434
|
||||
git remote add upstream https://github.com/qwibitai/nanoclaw.git
|
||||
```
|
||||
|
||||
# Opt in to library-management tools (pull, delete, show, list-running).
|
||||
# Leave unset to expose only list + generate.
|
||||
# OLLAMA_ADMIN_TOOLS=true
|
||||
### Merge the skill branch
|
||||
|
||||
```bash
|
||||
git fetch upstream skill/ollama-tool
|
||||
git merge upstream/skill/ollama-tool
|
||||
```
|
||||
|
||||
This merges in:
|
||||
- `container/agent-runner/src/ollama-mcp-stdio.ts` (Ollama MCP server)
|
||||
- `scripts/ollama-watch.sh` (macOS notification watcher)
|
||||
- Ollama MCP config in `container/agent-runner/src/index.ts` (allowedTools + mcpServers)
|
||||
- `[OLLAMA]` log surfacing in `src/container-runner.ts`
|
||||
- `OLLAMA_HOST` in `.env.example`
|
||||
|
||||
If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides.
|
||||
|
||||
### Copy to per-group agent-runner
|
||||
|
||||
Existing groups have a cached copy of the agent-runner source. Copy the new files:
|
||||
|
||||
```bash
|
||||
for dir in data/sessions/*/agent-runner-src; do
|
||||
cp container/agent-runner/src/ollama-mcp-stdio.ts "$dir/"
|
||||
cp container/agent-runner/src/index.ts "$dir/"
|
||||
done
|
||||
```
|
||||
|
||||
### 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/ollama-wiring.test.ts
|
||||
# Container tree: index.ts registration
|
||||
(cd container/agent-runner && bun test src/ollama-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 the Ollama daemon is the author's build-time concern, not part of these
|
||||
tests — verify it manually in Phase 4.)
|
||||
Build must be clean before proceeding.
|
||||
|
||||
## Phase 3: Configure
|
||||
|
||||
### Enable library-management tools (optional)
|
||||
### Enable model management tools (optional)
|
||||
|
||||
Ask the user:
|
||||
|
||||
@@ -208,7 +110,7 @@ If the user wants management tools, add to `.env`:
|
||||
OLLAMA_ADMIN_TOOLS=true
|
||||
```
|
||||
|
||||
If they decline (or don't answer), leave the variable unset — only list + generate are exposed.
|
||||
If they decline (or don't answer), do not add the variable — management tools will be disabled by default.
|
||||
|
||||
### Set Ollama host (optional)
|
||||
|
||||
@@ -220,12 +122,9 @@ OLLAMA_HOST=http://your-ollama-host:11434
|
||||
|
||||
### 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)
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
# Linux: systemctl --user restart nanoclaw
|
||||
```
|
||||
|
||||
## Phase 4: Verify
|
||||
@@ -246,6 +145,14 @@ If `OLLAMA_ADMIN_TOOLS=true` was set, tell the user:
|
||||
>
|
||||
> The agent should call `ollama_pull_model` or `ollama_list_running` respectively.
|
||||
|
||||
### Monitor activity (optional)
|
||||
|
||||
Run the watcher script for macOS notifications when Ollama is used:
|
||||
|
||||
```bash
|
||||
./scripts/ollama-watch.sh
|
||||
```
|
||||
|
||||
### Check logs if needed
|
||||
|
||||
```bash
|
||||
@@ -253,45 +160,34 @@ tail -f logs/nanoclaw.log | grep -i ollama
|
||||
```
|
||||
|
||||
Look for:
|
||||
- `[OLLAMA] Listing models...` — list request started
|
||||
- `[OLLAMA] Found N models` — models discovered
|
||||
- `[OLLAMA] >>> Generating with <model>` — generation started
|
||||
- `[OLLAMA] <<< Done: <model> | Xs | N tokens | M chars` — generation completed
|
||||
- `[OLLAMA] >>> Generating` — generation started
|
||||
- `[OLLAMA] <<< Done` — generation completed
|
||||
- `[OLLAMA] Pulling model:` — pull in progress (management tools)
|
||||
- `[OLLAMA] Deleted:` — model removed (management tools)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Agent says "Ollama is not installed" or tries to run a CLI
|
||||
### Agent says "Ollama is not installed"
|
||||
|
||||
The agent is looking for an `ollama` CLI inside the container instead of using the MCP tools. This means:
|
||||
1. The MCP server wasn't copied — check `container/agent-runner/src/ollama-mcp-stdio.ts` exists
|
||||
2. The MCP server wasn't registered — check `container/agent-runner/src/index.ts` has the `ollama` entry in `mcpServers` (the allow-pattern is derived from this, so registration is the only thing to check)
|
||||
The agent is trying to run `ollama` CLI inside the container instead of using the MCP tools. This means:
|
||||
1. The MCP server wasn't registered — check `container/agent-runner/src/index.ts` has the `ollama` entry in `mcpServers`
|
||||
2. The per-group source wasn't updated — re-copy files (see Phase 2)
|
||||
3. The container wasn't rebuilt — run `./container/build.sh`
|
||||
|
||||
### "Failed to connect to Ollama"
|
||||
|
||||
1. Verify the daemon is reachable: `curl http://127.0.0.1:11434/api/tags`
|
||||
2. Confirm Ollama is running (`ollama list` on the host)
|
||||
3. Check Docker can reach the host: `docker run --rm curlimages/curl curl -s http://host.docker.internal:11434/api/tags`
|
||||
4. If using a custom host, check `OLLAMA_HOST` in `.env`
|
||||
|
||||
### `model not found` / 404 on generate
|
||||
|
||||
The model name passed to `ollama_generate` must exactly match one of the names returned by `ollama_list_models` (including any `:tag` suffix, e.g. `gemma3:1b`). Ask the agent to list models first, then pick one from that list.
|
||||
|
||||
### `ollama_pull_model` times out on large models
|
||||
|
||||
Large models (7B+) can take several minutes. The tool uses `stream: false` so it blocks until the pull completes — this is intentional. For very large pulls, use the host CLI directly: `ollama pull <model>`.
|
||||
|
||||
### Management tools not showing up
|
||||
|
||||
Ensure `OLLAMA_ADMIN_TOOLS=true` is set in `.env` and the service was restarted after adding it. The management tools are only registered when that flag is present in the container's environment.
|
||||
|
||||
### Slow first response
|
||||
|
||||
Ollama 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.
|
||||
1. Verify Ollama is running: `ollama list`
|
||||
2. Check Docker can reach the host: `docker run --rm curlimages/curl curl -s http://host.docker.internal:11434/api/tags`
|
||||
3. If using a custom host, check `OLLAMA_HOST` in `.env`
|
||||
|
||||
### Agent doesn't use Ollama tools
|
||||
|
||||
The agent may not know about the tools. Try being explicit: "use the ollama_generate tool with gemma3:1b to answer: ..."
|
||||
|
||||
### `ollama_pull_model` times out on large models
|
||||
|
||||
Large models (7B+) can take several minutes. The tool uses `stream: false` so it blocks until complete — this is intentional. For very large pulls, use the host CLI directly: `ollama pull <model>`
|
||||
|
||||
### Management tools not showing up
|
||||
|
||||
Ensure `OLLAMA_ADMIN_TOOLS=true` is set in `.env` and the service was restarted after adding it.
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
/**
|
||||
* Host-side env forwarding for the Ollama MCP tool. Returns the Docker `-e`
|
||||
* arguments that pass any `OLLAMA_*` host overrides into the container.
|
||||
*
|
||||
* Ollama is local and keyless — these are configuration, not credentials:
|
||||
* `OLLAMA_HOST` is the base URL of the host's Ollama daemon, and
|
||||
* `OLLAMA_ADMIN_TOOLS` is the opt-in flag for the library-management tools.
|
||||
*
|
||||
* Lives in its own file so the reach-in in `container-runner.ts` is a single
|
||||
* call (`args.push(...ollamaEnvArgs())`) and this logic is behavior-testable in
|
||||
* isolation, without invoking the OneCLI-entangled `buildContainerArgs`.
|
||||
*/
|
||||
export function ollamaEnvArgs(): string[] {
|
||||
const args: string[] = [];
|
||||
if (process.env.OLLAMA_HOST) {
|
||||
args.push('-e', `OLLAMA_HOST=${process.env.OLLAMA_HOST}`);
|
||||
}
|
||||
if (process.env.OLLAMA_ADMIN_TOOLS) {
|
||||
args.push('-e', `OLLAMA_ADMIN_TOOLS=${process.env.OLLAMA_ADMIN_TOOLS}`);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
@@ -1,482 +0,0 @@
|
||||
/**
|
||||
* Ollama MCP Server for NanoClaw
|
||||
* Exposes local Ollama models (native Ollama REST API, /api/*) as tools for the
|
||||
* container agent. Uses host.docker.internal to reach the host's Ollama daemon
|
||||
* from inside the container.
|
||||
*
|
||||
* Ollama runs locally and is keyless — there are no credentials to thread. The
|
||||
* only configuration is the base URL (OLLAMA_HOST) and an opt-in flag for the
|
||||
* library-management tools (OLLAMA_ADMIN_TOOLS).
|
||||
*/
|
||||
|
||||
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 OLLAMA_HOST =
|
||||
process.env.OLLAMA_HOST || 'http://host.docker.internal:11434';
|
||||
const OLLAMA_ADMIN_TOOLS = process.env.OLLAMA_ADMIN_TOOLS === 'true';
|
||||
const OLLAMA_STATUS_FILE = '/workspace/ipc/ollama_status.json';
|
||||
|
||||
function log(msg: string): void {
|
||||
console.error(`[OLLAMA] ${msg}`);
|
||||
}
|
||||
|
||||
function writeStatus(status: string, detail?: string): void {
|
||||
try {
|
||||
const data = { status, detail, timestamp: new Date().toISOString() };
|
||||
const tmpPath = `${OLLAMA_STATUS_FILE}.tmp`;
|
||||
fs.mkdirSync(path.dirname(OLLAMA_STATUS_FILE), { recursive: true });
|
||||
fs.writeFileSync(tmpPath, JSON.stringify(data));
|
||||
fs.renameSync(tmpPath, OLLAMA_STATUS_FILE);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
async function ollamaFetch(
|
||||
apiPath: string,
|
||||
options?: RequestInit,
|
||||
): Promise<Response> {
|
||||
const url = `${OLLAMA_HOST}${apiPath}`;
|
||||
try {
|
||||
return await fetch(url, options);
|
||||
} catch (err) {
|
||||
// Fallback to localhost if host.docker.internal fails
|
||||
if (OLLAMA_HOST.includes('host.docker.internal')) {
|
||||
const fallbackUrl = url.replace('host.docker.internal', 'localhost');
|
||||
return await fetch(fallbackUrl, options);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes?: number): string {
|
||||
if (bytes === undefined || bytes === null) return '?';
|
||||
const gb = bytes / 1024 / 1024 / 1024;
|
||||
if (gb >= 1) return `${gb.toFixed(1)}GB`;
|
||||
const mb = bytes / 1024 / 1024;
|
||||
return `${mb.toFixed(0)}MB`;
|
||||
}
|
||||
|
||||
const server = new McpServer({
|
||||
name: 'ollama',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
server.tool(
|
||||
'ollama_list_models',
|
||||
'List all models installed in the local Ollama daemon. Use this to see which models are available before calling ollama_generate.',
|
||||
{},
|
||||
async () => {
|
||||
log('Listing models...');
|
||||
writeStatus('listing', 'Listing installed models');
|
||||
try {
|
||||
const res = await ollamaFetch('/api/tags');
|
||||
if (!res.ok) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Ollama API error: ${res.status} ${res.statusText}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
models?: Array<{
|
||||
name: string;
|
||||
size?: number;
|
||||
details?: { family?: string; parameter_size?: string };
|
||||
}>;
|
||||
};
|
||||
const models = data.models || [];
|
||||
|
||||
if (models.length === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: 'No models installed. Pull one on the host with `ollama pull <model>` (e.g. `ollama pull llama3.2`).',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const list = models
|
||||
.map((m) => {
|
||||
const family = m.details?.family ? ` ${m.details.family}` : '';
|
||||
const params = m.details?.parameter_size
|
||||
? ` ${m.details.parameter_size}`
|
||||
: '';
|
||||
return `- ${m.name} (${formatBytes(m.size)}${family}${params})`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
log(`Found ${models.length} models`);
|
||||
return {
|
||||
content: [
|
||||
{ type: 'text' as const, text: `Installed models:\n${list}` },
|
||||
],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Failed to connect to Ollama at ${OLLAMA_HOST}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'ollama_generate',
|
||||
'Send a prompt to a local Ollama model and get a response. Good for cheaper/faster tasks like summarization, translation, or general queries. Use ollama_list_models first to see available models.',
|
||||
{
|
||||
model: z
|
||||
.string()
|
||||
.describe(
|
||||
'The model name as returned by ollama_list_models (e.g. "llama3.2" or "gemma3:1b")',
|
||||
),
|
||||
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.'),
|
||||
},
|
||||
async (args) => {
|
||||
log(`>>> Generating with ${args.model} (${args.prompt.length} chars)...`);
|
||||
writeStatus('generating', `Generating with ${args.model}`);
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
model: args.model,
|
||||
prompt: args.prompt,
|
||||
stream: false,
|
||||
};
|
||||
if (args.system) body.system = args.system;
|
||||
if (args.temperature !== undefined) {
|
||||
body.options = { temperature: args.temperature };
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
const res = await ollamaFetch('/api/generate', {
|
||||
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: `Ollama error (${res.status}): ${errorText}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
response?: string;
|
||||
eval_count?: number;
|
||||
};
|
||||
|
||||
const response = data.response ?? '';
|
||||
const elapsedSec = ((Date.now() - startedAt) / 1000).toFixed(1);
|
||||
const evalCount = data.eval_count;
|
||||
|
||||
const meta = `\n\n[${args.model} | ${elapsedSec}s${
|
||||
evalCount !== undefined ? ` | ${evalCount} tokens` : ''
|
||||
}]`;
|
||||
|
||||
log(
|
||||
`<<< Done: ${args.model} | ${elapsedSec}s | ${
|
||||
evalCount ?? '?'
|
||||
} tokens | ${response.length} chars`,
|
||||
);
|
||||
writeStatus(
|
||||
'done',
|
||||
`${args.model} | ${elapsedSec}s | ${evalCount ?? '?'} tokens`,
|
||||
);
|
||||
|
||||
return { content: [{ type: 'text' as const, text: response + meta }] };
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Failed to call Ollama: ${err instanceof Error ? err.message : String(err)}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Library-management tools — opt-in via OLLAMA_ADMIN_TOOLS=true. These mutate
|
||||
// the host's model library (pull/delete) or inspect it, so they are gated
|
||||
// behind an explicit flag rather than exposed by default.
|
||||
if (OLLAMA_ADMIN_TOOLS) {
|
||||
server.tool(
|
||||
'ollama_pull_model',
|
||||
'Pull (download) a model from the Ollama registry into the local daemon. Blocks until the download completes — large models can take several minutes.',
|
||||
{
|
||||
model: z
|
||||
.string()
|
||||
.describe('The model name to pull (e.g. "llama3.2" or "qwen3-coder:30b")'),
|
||||
},
|
||||
async (args) => {
|
||||
log(`Pulling model: ${args.model}`);
|
||||
writeStatus('pulling', `Pulling ${args.model}`);
|
||||
try {
|
||||
const res = await ollamaFetch('/api/pull', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model: args.model, stream: false }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text();
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Ollama pull error (${res.status}): ${errorText}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const data = (await res.json()) as { status?: string };
|
||||
log(`Pulled: ${args.model} (${data.status ?? 'ok'})`);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Pulled ${args.model}: ${data.status ?? 'success'}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Failed to pull ${args.model}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'ollama_delete_model',
|
||||
'Delete a locally installed model from the Ollama daemon to free disk space.',
|
||||
{
|
||||
model: z.string().describe('The model name to delete (e.g. "gemma3:1b")'),
|
||||
},
|
||||
async (args) => {
|
||||
log(`Deleting model: ${args.model}`);
|
||||
writeStatus('deleting', `Deleting ${args.model}`);
|
||||
try {
|
||||
const res = await ollamaFetch('/api/delete', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model: args.model }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text();
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Ollama delete error (${res.status}): ${errorText}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
log(`Deleted: ${args.model}`);
|
||||
return {
|
||||
content: [
|
||||
{ type: 'text' as const, text: `Deleted ${args.model}.` },
|
||||
],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Failed to delete ${args.model}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'ollama_show_model',
|
||||
'Show details for a locally installed model: modelfile, parameters, template, and architecture info.',
|
||||
{
|
||||
model: z
|
||||
.string()
|
||||
.describe('The model name to inspect (e.g. "llama3.2")'),
|
||||
},
|
||||
async (args) => {
|
||||
log(`Showing model: ${args.model}`);
|
||||
try {
|
||||
const res = await ollamaFetch('/api/show', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model: args.model }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text();
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Ollama show error (${res.status}): ${errorText}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
parameters?: string;
|
||||
template?: string;
|
||||
details?: {
|
||||
family?: string;
|
||||
parameter_size?: string;
|
||||
quantization_level?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const parts: string[] = [`Model: ${args.model}`];
|
||||
if (data.details) {
|
||||
const d = data.details;
|
||||
parts.push(
|
||||
`Family: ${d.family ?? '?'} | Params: ${d.parameter_size ?? '?'} | Quant: ${d.quantization_level ?? '?'}`,
|
||||
);
|
||||
}
|
||||
if (data.parameters) parts.push(`Parameters:\n${data.parameters}`);
|
||||
if (data.template) parts.push(`Template:\n${data.template}`);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: parts.join('\n\n') }],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Failed to show ${args.model}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'ollama_list_running',
|
||||
'List models currently loaded in memory, with memory usage and processor type (CPU/GPU). Use this to see what is warm and consuming resources.',
|
||||
{},
|
||||
async () => {
|
||||
log('Listing running models...');
|
||||
try {
|
||||
const res = await ollamaFetch('/api/ps');
|
||||
if (!res.ok) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Ollama API error: ${res.status} ${res.statusText}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
models?: Array<{
|
||||
name: string;
|
||||
size?: number;
|
||||
size_vram?: number;
|
||||
}>;
|
||||
};
|
||||
const models = data.models || [];
|
||||
|
||||
if (models.length === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: 'No models currently loaded in memory.',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const list = models
|
||||
.map((m) => {
|
||||
const vram = m.size_vram ?? 0;
|
||||
const total = m.size ?? 0;
|
||||
const processor =
|
||||
vram === 0
|
||||
? 'CPU'
|
||||
: vram >= total
|
||||
? 'GPU'
|
||||
: `${Math.round((vram / total) * 100)}% GPU`;
|
||||
return `- ${m.name} (${formatBytes(total)}, ${processor})`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
content: [
|
||||
{ type: 'text' as const, text: `Loaded models:\n${list}` },
|
||||
],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Failed to connect to Ollama at ${OLLAMA_HOST}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
@@ -1,66 +0,0 @@
|
||||
/**
|
||||
* Wiring test for the MCP-server registration integration point (container/Bun tree).
|
||||
*
|
||||
* The handlers are exercised against a live Ollama daemon at build time, but that does
|
||||
* not prove the server is registered — delete the index.ts entry and the tool simply
|
||||
* never appears, yet any handler check 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 `ollama` property whose command runs
|
||||
* `ollama-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 ollama MCP server', () => {
|
||||
const obj = mcpServersLiteral(sourceFile());
|
||||
|
||||
it('finds the mcpServers object literal', () => {
|
||||
expect(obj).toBeDefined();
|
||||
});
|
||||
|
||||
it('has an ollama entry', () => {
|
||||
expect(obj && property(obj, 'ollama')).toBeDefined();
|
||||
});
|
||||
|
||||
it('points ollama at ollama-mcp-stdio.ts', () => {
|
||||
const entry = obj && property(obj, 'ollama');
|
||||
const text = entry ? entry.getText() : '';
|
||||
expect(text).toContain('ollama-mcp-stdio.ts');
|
||||
});
|
||||
});
|
||||
@@ -1,69 +0,0 @@
|
||||
/**
|
||||
* Wiring test for the host-side env-forwarding integration point (host/vitest tree).
|
||||
*
|
||||
* The env helper is skill-owned and could be unit-tested directly, 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(...ollamaEnvArgs())` 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(...ollamaEnvArgs())`? */
|
||||
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 === 'ollamaEnvArgs',
|
||||
);
|
||||
}
|
||||
|
||||
describe('container-runner.ts wires in ollamaEnvArgs', () => {
|
||||
const sf = sourceFile();
|
||||
const fn = findFunction(sf, 'buildContainerArgs');
|
||||
|
||||
it('finds buildContainerArgs', () => {
|
||||
expect(fn).toBeDefined();
|
||||
});
|
||||
|
||||
it('calls args.push(...ollamaEnvArgs()) 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,105 +0,0 @@
|
||||
# Remove OpenCode provider
|
||||
|
||||
Idempotent — safe to run even if some steps were never applied. Reverses both the host (`src/providers/`) and container (`container/agent-runner/src/providers/`) trees, the agent-runner dependency, and the Dockerfile CLI install.
|
||||
|
||||
## 1. Delete the barrel import lines (both trees)
|
||||
|
||||
Delete (do not comment out) the `import './opencode.js';` line from each barrel:
|
||||
|
||||
- `src/providers/index.ts`
|
||||
- `container/agent-runner/src/providers/index.ts`
|
||||
|
||||
This unregisters the provider from both `listProviderContainerConfigNames()` (host) and `listProviderNames()` (container).
|
||||
|
||||
## 2. Delete the copied files (both trees)
|
||||
|
||||
```bash
|
||||
rm -f src/providers/opencode.ts \
|
||||
src/providers/opencode-registration.test.ts \
|
||||
src/opencode-dockerfile.test.ts \
|
||||
container/agent-runner/src/providers/opencode.ts \
|
||||
container/agent-runner/src/providers/mcp-to-opencode.ts \
|
||||
container/agent-runner/src/providers/mcp-to-opencode.test.ts \
|
||||
container/agent-runner/src/providers/opencode.factory.test.ts \
|
||||
container/agent-runner/src/providers/opencode-registration.test.ts
|
||||
```
|
||||
|
||||
## 3. Remove the agent-runner dependency
|
||||
|
||||
`@opencode-ai/sdk` is an importable package in the container tree (agent-runner is a Bun package, not a pnpm workspace — use `bun remove`):
|
||||
|
||||
```bash
|
||||
cd container/agent-runner && bun remove @opencode-ai/sdk && cd -
|
||||
```
|
||||
|
||||
## 4. Revert the Dockerfile CLI install
|
||||
|
||||
In `container/Dockerfile`, remove both OpenCode edits (skip whichever is already gone):
|
||||
|
||||
**(a)** Delete the version ARG from the "Pin CLI versions" block:
|
||||
|
||||
```dockerfile
|
||||
ARG OPENCODE_VERSION=1.4.17
|
||||
```
|
||||
|
||||
**(b)** Delete the standalone OpenCode install layer:
|
||||
|
||||
```dockerfile
|
||||
RUN --mount=type=cache,target=/root/.cache/pnpm \
|
||||
pnpm install -g "opencode-ai@${OPENCODE_VERSION}"
|
||||
```
|
||||
|
||||
Leave the other per-CLI install layers (claude-code, agent-browser, vercel) untouched.
|
||||
|
||||
## 5. Clean up per-group overlays
|
||||
|
||||
Any group that had the OpenCode files copied into its live source overlay still carries them — remove the OpenCode-specific files from each overlay (the barrel `index.ts` is re-synced from the cleaned tree, not deleted):
|
||||
|
||||
```bash
|
||||
for overlay in data/v2-sessions/*/agent-runner-src/providers/; do
|
||||
[ -d "$overlay" ] || continue
|
||||
rm -f "$overlay/opencode.ts" "$overlay/mcp-to-opencode.ts"
|
||||
[ -f container/agent-runner/src/providers/index.ts ] && \
|
||||
cp container/agent-runner/src/providers/index.ts "$overlay"
|
||||
echo "Cleaned: $overlay"
|
||||
done
|
||||
```
|
||||
|
||||
## 6. Unset OpenCode env vars
|
||||
|
||||
Remove any OpenCode-specific lines you added to `.env` (`OPENCODE_PROVIDER`, `OPENCODE_MODEL`, `OPENCODE_SMALL_MODEL`, and `ANTHROPIC_BASE_URL` if no other integration uses it) if no other integration needs them, then re-sync to the container:
|
||||
|
||||
```bash
|
||||
mkdir -p data/env && cp .env data/env/env
|
||||
```
|
||||
|
||||
Switch any group still on OpenCode back to the default provider — set `"provider": "claude"` in `groups/<folder>/container.json` and clear `agent_provider` on the group/session in the DB.
|
||||
|
||||
## 7. 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)
|
||||
```
|
||||
|
||||
> If the rebuild still reports OpenCode after these steps, the buildkit COPY cache may be stale. Prune the builder and rebuild: `docker builder prune -f && ./container/build.sh`.
|
||||
|
||||
## Verification
|
||||
|
||||
After removal, the registration guards no longer apply (their files are gone). Confirm the provider is fully unwired:
|
||||
|
||||
```bash
|
||||
grep -R "opencode.js" src/providers/index.ts container/agent-runner/src/providers/index.ts # no output
|
||||
grep "@opencode-ai/sdk" container/agent-runner/package.json # no output
|
||||
grep "opencode-ai" container/Dockerfile # no output
|
||||
```
|
||||
|
||||
In a wired agent, requesting `agent_provider = 'opencode'` should fall back to the default provider since `opencode` is no longer in the registry.
|
||||
@@ -17,13 +17,10 @@ If all of the following are already present, skip to **Configuration**:
|
||||
|
||||
- `src/providers/opencode.ts`
|
||||
- `container/agent-runner/src/providers/opencode.ts`
|
||||
- `src/providers/opencode-registration.test.ts`
|
||||
- `container/agent-runner/src/providers/opencode-registration.test.ts`
|
||||
- `import './opencode.js';` line in `src/providers/index.ts`
|
||||
- `import './opencode.js';` line in `container/agent-runner/src/providers/index.ts`
|
||||
- `@opencode-ai/sdk` in `container/agent-runner/package.json`
|
||||
- `ARG OPENCODE_VERSION` and `"opencode-ai@${OPENCODE_VERSION}"` in `container/Dockerfile`
|
||||
- `src/opencode-dockerfile.test.ts` (the Dockerfile install guard)
|
||||
- `opencode-ai@${OPENCODE_VERSION}` in the pnpm global-install block in `container/Dockerfile`
|
||||
|
||||
Missing pieces — continue below. All steps are idempotent; re-running is safe.
|
||||
|
||||
@@ -38,20 +35,13 @@ git fetch origin providers
|
||||
Wholesale copies (owned entirely by this skill — user edits to these files won't survive a re-run, as designed):
|
||||
|
||||
```bash
|
||||
git show origin/providers:src/providers/opencode.ts > src/providers/opencode.ts
|
||||
git show origin/providers:container/agent-runner/src/providers/opencode.ts > container/agent-runner/src/providers/opencode.ts
|
||||
git show origin/providers:container/agent-runner/src/providers/mcp-to-opencode.ts > container/agent-runner/src/providers/mcp-to-opencode.ts
|
||||
git show origin/providers:container/agent-runner/src/providers/mcp-to-opencode.test.ts > container/agent-runner/src/providers/mcp-to-opencode.test.ts
|
||||
git show origin/providers:src/providers/opencode.ts > src/providers/opencode.ts
|
||||
git show origin/providers:container/agent-runner/src/providers/opencode.ts > container/agent-runner/src/providers/opencode.ts
|
||||
git show origin/providers:container/agent-runner/src/providers/mcp-to-opencode.ts > container/agent-runner/src/providers/mcp-to-opencode.ts
|
||||
git show origin/providers:container/agent-runner/src/providers/mcp-to-opencode.test.ts > container/agent-runner/src/providers/mcp-to-opencode.test.ts
|
||||
git show origin/providers:container/agent-runner/src/providers/opencode.factory.test.ts > container/agent-runner/src/providers/opencode.factory.test.ts
|
||||
```
|
||||
|
||||
Also copy the two barrel-registration guards — one per tree. These import the real provider barrels and assert `opencode` is registered, so they go red the moment a barrel import line is deleted or drifts:
|
||||
|
||||
```bash
|
||||
git show origin/providers:src/providers/opencode-registration.test.ts > src/providers/opencode-registration.test.ts
|
||||
git show origin/providers:container/agent-runner/src/providers/opencode-registration.test.ts > container/agent-runner/src/providers/opencode-registration.test.ts
|
||||
```
|
||||
|
||||
### 3. Append the self-registration imports
|
||||
|
||||
Each barrel gets one line appended at the end — skip if the line is already present.
|
||||
@@ -80,7 +70,7 @@ cd container/agent-runner && bun add @opencode-ai/sdk@1.4.17 && cd -
|
||||
|
||||
Two edits to `container/Dockerfile`, both idempotent (skip if already present):
|
||||
|
||||
**(a)** In the "Pin CLI versions" ARG block (around line 22), add after `ARG VERCEL_VERSION=...`:
|
||||
**(a)** In the "Pin CLI versions" ARG block (around line 18), add after `ARG VERCEL_VERSION=latest`:
|
||||
|
||||
```dockerfile
|
||||
ARG OPENCODE_VERSION=1.4.17
|
||||
@@ -88,47 +78,30 @@ ARG OPENCODE_VERSION=1.4.17
|
||||
|
||||
> **Do not use `latest`** — the CLI and SDK must be the same version. `latest` silently upgrades the CLI to 1.14.x which has a breaking session API change (UUID session IDs → `ses_` prefix) incompatible with SDK 1.4.x.
|
||||
|
||||
**(b)** Add a new standalone `RUN` block for the OpenCode CLI, after the existing per-CLI install blocks (around line 111, right after the `@anthropic-ai/claude-code` block). The Dockerfile splits each global CLI into its own layer for cache granularity — keep that pattern; do not collapse them into a single combined `pnpm install -g` call:
|
||||
**(b)** In the `pnpm install -g` block (around line 80), append `"opencode-ai@${OPENCODE_VERSION}"` to the list:
|
||||
|
||||
```dockerfile
|
||||
RUN --mount=type=cache,target=/root/.cache/pnpm \
|
||||
pnpm install -g "opencode-ai@${OPENCODE_VERSION}"
|
||||
pnpm install -g \
|
||||
"@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \
|
||||
"agent-browser@${AGENT_BROWSER_VERSION}" \
|
||||
"vercel@${VERCEL_VERSION}" \
|
||||
"opencode-ai@${OPENCODE_VERSION}"
|
||||
```
|
||||
|
||||
### 6. Copy the Dockerfile install guard
|
||||
|
||||
The `opencode-ai` CLI is a globally-installed binary — not importable or typed — so a structural test guards the Dockerfile install. Copy it into the host test tree:
|
||||
### 6. Build
|
||||
|
||||
```bash
|
||||
cp .claude/skills/add-opencode/opencode-dockerfile.test.ts src/opencode-dockerfile.test.ts
|
||||
pnpm run build # host
|
||||
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit # container typecheck
|
||||
./container/build.sh # agent image
|
||||
```
|
||||
|
||||
### 7. Build and validate
|
||||
|
||||
```bash
|
||||
pnpm run build # host
|
||||
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit # container typecheck
|
||||
pnpm exec vitest run src/providers/opencode-registration.test.ts # host registration guard
|
||||
pnpm exec vitest run src/opencode-dockerfile.test.ts # Dockerfile install guard
|
||||
cd container/agent-runner && bun test src/providers/opencode-registration.test.ts && cd - # container registration guard
|
||||
./container/build.sh # agent image
|
||||
```
|
||||
|
||||
All four must be clean before proceeding. Each guards a distinct integration point:
|
||||
|
||||
- **`src/providers/opencode-registration.test.ts`** (host, vitest) imports the real host barrel (`./index.js` → `listProviderContainerConfigNames`) and asserts `opencode` is present. It goes red if the `import './opencode.js';` line in `src/providers/index.ts` is deleted or drifts, or if that barrel fails to evaluate.
|
||||
- **`container/agent-runner/src/providers/opencode-registration.test.ts`** (container, bun:test) imports the real container barrel (`./index.js` → `listProviderNames`) and asserts `opencode` is present. It goes red if the `import './opencode.js';` line in `container/agent-runner/src/providers/index.ts` is deleted or drifts. Because the barrel is imported unmocked, it also pulls in `opencode.ts`, which imports **`@opencode-ai/sdk`** — so this test implicitly guards the step-4 dependency too: if the package isn't installed, the import throws and the test goes red.
|
||||
- **`src/opencode-dockerfile.test.ts`** parses `container/Dockerfile` and asserts both the `ARG OPENCODE_VERSION=...` (rejecting `latest`) and the `pnpm install -g "opencode-ai@${OPENCODE_VERSION}"` line are present. The `opencode-ai` CLI binary is not importable, so it is guarded by this structural test plus the container build — not the registration test.
|
||||
- **`pnpm run build`** type-checks the host provider's consumption of the host-side container-config registry; the container typecheck does the same for the container provider against the agent-runner core APIs.
|
||||
|
||||
The pre-existing `opencode.factory.test.ts` imports `opencode.ts` directly and self-registers, so it stays green even if a barrel import is removed — it is a unit test of `createProvider('opencode')`, not the registration guard. Keep it; it adds factory coverage but does not stand in for the registration tests above.
|
||||
|
||||
> **Build cache gotcha:** The container buildkit caches COPY steps aggressively. If provider files were already present in the build context before, the new files may not be picked up. If you see "Unknown provider: opencode" after the build, prune the builder and rebuild:
|
||||
> ```bash
|
||||
> docker builder prune -f && ./container/build.sh
|
||||
> ```
|
||||
|
||||
### 8. Propagate to existing per-group overlays
|
||||
### 7. Propagate to existing per-group overlays
|
||||
|
||||
Each agent group has a live source overlay at `data/v2-sessions/<group-id>/agent-runner-src/providers/` that **overrides the image at runtime**. This overlay is created when the group is first wired and never auto-updated by image rebuilds. Any group that already existed before this skill ran needs the new files copied in manually.
|
||||
|
||||
@@ -159,16 +132,13 @@ Credentials: register provider API keys in OneCLI with the matching `--host-patt
|
||||
|
||||
After adding a secret, **grant the agent access** — agents in `selective` mode only receive secrets they've been explicitly assigned:
|
||||
|
||||
Use the safe merge pattern — `set-secrets` replaces the entire list, so always read first:
|
||||
|
||||
```bash
|
||||
AGENT_ID=$(onecli agents list | jq -r '.data[] | select(.identifier=="<agentGroupId>") | .id')
|
||||
CURRENT=$(onecli agents secrets --id "$AGENT_ID" | jq -r '[.data[]] | join(",")')
|
||||
MERGED=$(printf '%s' "$CURRENT,<new-secret-id>" | tr ',' '\n' | sort -u | paste -sd ',' -)
|
||||
onecli agents set-secrets --id "$AGENT_ID" --secret-ids "$MERGED"
|
||||
onecli agents secrets --id "$AGENT_ID"
|
||||
# Find the agent id and secret id, then:
|
||||
onecli agents set-secrets --id <agent-id> --secret-ids <existing-ids>,<new-secret-id>
|
||||
```
|
||||
|
||||
Always include existing secret IDs in the list — `set-secrets` replaces, not appends.
|
||||
|
||||
#### Example: DeepSeek
|
||||
|
||||
```env
|
||||
@@ -238,7 +208,7 @@ onecli secrets create --name "OpenCode Zen" --type generic \
|
||||
|
||||
### Per group / per session
|
||||
|
||||
Set `"provider": "opencode"` in the group's **`container.json`** (`groups/<folder>/container.json`) — the in-container runner reads `provider` from there, not from the DB. The DB columns **`agent_groups.agent_provider`** and **`sessions.agent_provider`** (session overrides group) only drive host-side provider contribution — per-session XDG mount, `OPENCODE_*` env passthrough — and do not propagate into `container.json` at spawn time. Set both, or just edit `container.json`; if they disagree, the runner uses `container.json` and the host-side resolver falls back through session → group → `container.json` → `'claude'`.
|
||||
Schema: **`agent_groups.agent_provider`** and **`sessions.agent_provider`**. Set to `opencode` for groups or sessions that should use OpenCode. The container receives `AGENT_PROVIDER` from the resolved value (session overrides group).
|
||||
|
||||
Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config.mcpServers` on the host; the runner merges them into the same `mcpServers` object passed to **both** Claude and OpenCode providers.
|
||||
|
||||
@@ -248,8 +218,12 @@ Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config
|
||||
- Session continuation uses UUID format (SDK 1.4.x / CLI 1.4.x). Stale sessions are cleared by `isSessionInvalid` on OpenCode-specific error patterns. If you see UUID-related errors after an accidental CLI upgrade, clear `session_state` in `outbound.db` and wipe the `opencode-xdg` directory under the session folder.
|
||||
- **`NO_PROXY`** for localhost matters when the OpenCode client talks to `127.0.0.1` inside the container while HTTP(S)_PROXY is set (e.g. OneCLI).
|
||||
|
||||
## Next Steps
|
||||
## Verify
|
||||
|
||||
The registration and Dockerfile guards in step 7 verify the wiring. To confirm an end-to-end round-trip, set `agent_provider = 'opencode'` (or `"provider": "opencode"` in the group's `container.json`) on a test group, register the matching provider key in OneCLI, and send a message. A clean exchange returns the model's reply with no `Unknown provider: opencode` error and no UUID/session warnings in the logs.
|
||||
|
||||
To remove this provider, see [REMOVE.md](REMOVE.md).
|
||||
```bash
|
||||
grep -q "./opencode.js" container/agent-runner/src/providers/index.ts && echo "container barrel: OK"
|
||||
grep -q "./opencode.js" src/providers/index.ts && echo "host barrel: OK"
|
||||
grep -q "@opencode-ai/sdk" container/agent-runner/package.json && echo "agent-runner dep: OK"
|
||||
grep -q "opencode-ai@" container/Dockerfile && echo "Dockerfile install: OK"
|
||||
cd container/agent-runner && bun test src/providers/ && cd -
|
||||
```
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
/**
|
||||
* Dependency guard for the OpenCode CLI integration point (host tree, vitest).
|
||||
*
|
||||
* add-opencode installs the `opencode-ai` CLI globally in the agent container
|
||||
* image via `container/Dockerfile`. A globally-installed CLI binary is not
|
||||
* importable or typed, so neither `tsc` nor a runtime import can catch its
|
||||
* removal — only the container image build would, and the skill's validate step
|
||||
* does not rebuild the image in CI. This structural test stands in for that
|
||||
* build leg: it parses the Dockerfile and asserts both halves of the install are
|
||||
* present — the pinned `ARG OPENCODE_VERSION=...` and the
|
||||
* `pnpm install -g "opencode-ai@${OPENCODE_VERSION}"` line. Drop or drift either
|
||||
* and this goes red.
|
||||
*
|
||||
* Pinning matters here beyond reproducibility: the `opencode-ai` CLI version
|
||||
* must match the `@opencode-ai/sdk` version the container provider imports. An
|
||||
* unpinned `latest` would silently upgrade the CLI past the SDK's compatible
|
||||
* range and break sessions. The test therefore also rejects `@latest`.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
function dockerfile(): string {
|
||||
// Walk up from this test file to the repo root (the dir holding container/Dockerfile),
|
||||
// so the test works wherever it is copied (src/ on the host, or the skill folder).
|
||||
let dir = __dirname;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const candidate = path.join(dir, 'container', 'Dockerfile');
|
||||
if (fs.existsSync(candidate)) return fs.readFileSync(candidate, 'utf8');
|
||||
dir = path.dirname(dir);
|
||||
}
|
||||
throw new Error('container/Dockerfile not found walking up from ' + __dirname);
|
||||
}
|
||||
|
||||
describe('container/Dockerfile installs the OpenCode CLI', () => {
|
||||
const text = dockerfile();
|
||||
|
||||
it('declares a pinned OPENCODE_VERSION build arg (not latest)', () => {
|
||||
expect(text).toMatch(/^ARG\s+OPENCODE_VERSION=\S+/m);
|
||||
expect(text).not.toMatch(/^ARG\s+OPENCODE_VERSION=latest\s*$/m);
|
||||
});
|
||||
|
||||
it('globally installs the pinned opencode-ai package via pnpm', () => {
|
||||
expect(text).toMatch(/pnpm install -g\s+"?opencode-ai@\$\{OPENCODE_VERSION\}"?/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,290 @@
|
||||
# Add Parallel AI Integration
|
||||
|
||||
Adds Parallel AI MCP integration to NanoClaw for advanced web research capabilities.
|
||||
|
||||
## What This Adds
|
||||
|
||||
- **Quick Search** - Fast web lookups using Parallel Search API (free to use)
|
||||
- **Deep Research** - Comprehensive analysis using Parallel Task API (asks permission)
|
||||
- **Non-blocking Design** - Uses NanoClaw scheduler for result polling (no container blocking)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
User must have:
|
||||
1. Parallel AI API key from https://platform.parallel.ai
|
||||
2. NanoClaw already set up and running
|
||||
3. Docker installed and running
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
Run all steps automatically. Only pause for user input when explicitly needed.
|
||||
|
||||
### 1. Get Parallel AI API Key
|
||||
|
||||
Use `AskUserQuestion: Do you have a Parallel AI API key, or should I help you get one?`
|
||||
|
||||
**If they have one:**
|
||||
Collect it now.
|
||||
|
||||
**If they need one:**
|
||||
Tell them:
|
||||
> 1. Go to https://platform.parallel.ai
|
||||
> 2. Sign up or log in
|
||||
> 3. Navigate to API Keys section
|
||||
> 4. Create a new API key
|
||||
> 5. Copy the key and paste it here
|
||||
|
||||
Wait for the API key.
|
||||
|
||||
### 2. Add API Key to Environment
|
||||
|
||||
Add `PARALLEL_API_KEY` to `.env`:
|
||||
|
||||
```bash
|
||||
# Check if .env exists, create if not
|
||||
if [ ! -f .env ]; then
|
||||
touch .env
|
||||
fi
|
||||
|
||||
# Add PARALLEL_API_KEY if not already present
|
||||
if ! grep -q "PARALLEL_API_KEY=" .env; then
|
||||
echo "PARALLEL_API_KEY=${API_KEY_FROM_USER}" >> .env
|
||||
echo "✓ Added PARALLEL_API_KEY to .env"
|
||||
else
|
||||
# Update existing key
|
||||
sed -i.bak "s/^PARALLEL_API_KEY=.*/PARALLEL_API_KEY=${API_KEY_FROM_USER}/" .env
|
||||
echo "✓ Updated PARALLEL_API_KEY in .env"
|
||||
fi
|
||||
```
|
||||
|
||||
Verify:
|
||||
```bash
|
||||
grep "PARALLEL_API_KEY" .env | head -c 50
|
||||
```
|
||||
|
||||
### 3. Update Container Runner
|
||||
|
||||
Add `PARALLEL_API_KEY` to allowed environment variables in `src/container-runner.ts`:
|
||||
|
||||
Find the line:
|
||||
```typescript
|
||||
const allowedVars = ['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY'];
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```typescript
|
||||
const allowedVars = ['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY', 'PARALLEL_API_KEY'];
|
||||
```
|
||||
|
||||
### 4. Configure MCP Servers in Agent Runner
|
||||
|
||||
Update `container/agent-runner/src/index.ts`:
|
||||
|
||||
Find the section where `mcpServers` is configured (around line 237-252):
|
||||
```typescript
|
||||
const mcpServers: Record<string, any> = {
|
||||
nanoclaw: ipcMcp
|
||||
};
|
||||
```
|
||||
|
||||
Add Parallel AI MCP servers after the nanoclaw server:
|
||||
```typescript
|
||||
const mcpServers: Record<string, any> = {
|
||||
nanoclaw: ipcMcp
|
||||
};
|
||||
|
||||
// Add Parallel AI MCP servers if API key is available
|
||||
const parallelApiKey = process.env.PARALLEL_API_KEY;
|
||||
if (parallelApiKey) {
|
||||
mcpServers['parallel-search'] = {
|
||||
type: 'http', // REQUIRED: Must specify type for HTTP MCP servers
|
||||
url: 'https://search-mcp.parallel.ai/mcp',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${parallelApiKey}`
|
||||
}
|
||||
};
|
||||
mcpServers['parallel-task'] = {
|
||||
type: 'http', // REQUIRED: Must specify type for HTTP MCP servers
|
||||
url: 'https://task-mcp.parallel.ai/mcp',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${parallelApiKey}`
|
||||
}
|
||||
};
|
||||
log('Parallel AI MCP servers configured');
|
||||
} else {
|
||||
log('PARALLEL_API_KEY not set, skipping Parallel AI integration');
|
||||
}
|
||||
```
|
||||
|
||||
Also update the `allowedTools` array to include Parallel MCP tools (around line 242-248):
|
||||
```typescript
|
||||
allowedTools: [
|
||||
'Bash',
|
||||
'Read', 'Write', 'Edit', 'Glob', 'Grep',
|
||||
'WebSearch', 'WebFetch',
|
||||
'mcp__nanoclaw__*',
|
||||
'mcp__parallel-search__*',
|
||||
'mcp__parallel-task__*'
|
||||
],
|
||||
```
|
||||
|
||||
### 5. Add Usage Instructions to CLAUDE.md
|
||||
|
||||
Add Parallel AI usage instructions to `groups/main/CLAUDE.md`:
|
||||
|
||||
Find the "## What You Can Do" section and add after the existing bullet points:
|
||||
```markdown
|
||||
- Use Parallel AI for web research and deep learning tasks
|
||||
```
|
||||
|
||||
Then add a new section after "## What You Can Do":
|
||||
```markdown
|
||||
## Web Research Tools
|
||||
|
||||
You have access to two Parallel AI research tools:
|
||||
|
||||
### Quick Web Search (`mcp__parallel-search__search`)
|
||||
**When to use:** Freely use for factual lookups, current events, definitions, recent information, or verifying facts.
|
||||
|
||||
**Examples:**
|
||||
- "Who invented the transistor?"
|
||||
- "What's the latest news about quantum computing?"
|
||||
- "When was the UN founded?"
|
||||
- "What are the top programming languages in 2026?"
|
||||
|
||||
**Speed:** Fast (2-5 seconds)
|
||||
**Cost:** Low
|
||||
**Permission:** Not needed - use whenever it helps answer the question
|
||||
|
||||
### Deep Research (`mcp__parallel-task__create_task_run`)
|
||||
**When to use:** Comprehensive analysis, learning about complex topics, comparing concepts, historical overviews, or structured research.
|
||||
|
||||
**Examples:**
|
||||
- "Explain the development of quantum mechanics from 1900-1930"
|
||||
- "Compare the literary styles of Hemingway and Faulkner"
|
||||
- "Research the evolution of jazz from bebop to fusion"
|
||||
- "Analyze the causes of the French Revolution"
|
||||
|
||||
**Speed:** Slower (1-20 minutes depending on depth)
|
||||
**Cost:** Higher (varies by processor tier)
|
||||
**Permission:** ALWAYS use `AskUserQuestion` before using this tool
|
||||
|
||||
**How to ask permission:**
|
||||
```
|
||||
AskUserQuestion: I can do deep research on [topic] using Parallel's Task API. This will take 2-5 minutes and provide comprehensive analysis with citations. Should I proceed?
|
||||
```
|
||||
|
||||
**After permission - DO NOT BLOCK! Use scheduler instead:**
|
||||
|
||||
1. Create the task using `mcp__parallel-task__create_task_run`
|
||||
2. Get the `run_id` from the response
|
||||
3. Create a polling scheduled task using `mcp__nanoclaw__schedule_task`:
|
||||
```
|
||||
Prompt: "Check Parallel AI task run [run_id] and send results when ready.
|
||||
|
||||
1. Use the Parallel Task MCP to check the task status
|
||||
2. If status is 'completed', extract the results
|
||||
3. Send results to user with mcp__nanoclaw__send_message
|
||||
4. Use mcp__nanoclaw__complete_scheduled_task to mark this task as done
|
||||
|
||||
If status is still 'running' or 'pending', do nothing (task will run again in 30s).
|
||||
If status is 'failed', send error message and complete the task."
|
||||
|
||||
Schedule: interval every 30 seconds
|
||||
Context mode: isolated
|
||||
```
|
||||
4. Send acknowledgment with tracking link
|
||||
5. Exit immediately - scheduler handles the rest
|
||||
|
||||
### Choosing Between Them
|
||||
|
||||
**Use Search when:**
|
||||
- Question needs a quick fact or recent information
|
||||
- Simple definition or clarification
|
||||
- Verifying specific details
|
||||
- Current events or news
|
||||
|
||||
**Use Deep Research (with permission) when:**
|
||||
- User wants to learn about a complex topic
|
||||
- Question requires analysis or comparison
|
||||
- Historical context or evolution of concepts
|
||||
- Structured, comprehensive understanding needed
|
||||
- User explicitly asks to "research" or "explain in depth"
|
||||
|
||||
**Default behavior:** Prefer search for most questions. Only suggest deep research when the topic genuinely requires comprehensive analysis.
|
||||
```
|
||||
|
||||
### 6. Rebuild Container
|
||||
|
||||
Build the container with updated agent runner:
|
||||
|
||||
```bash
|
||||
./container/build.sh
|
||||
```
|
||||
|
||||
Verify the build:
|
||||
```bash
|
||||
echo '{}' | docker run -i --entrypoint /bin/echo nanoclaw-agent:latest "Container OK"
|
||||
```
|
||||
|
||||
### 7. Restart Service
|
||||
|
||||
Rebuild the main app and restart:
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
# Linux: systemctl --user restart nanoclaw
|
||||
```
|
||||
|
||||
Wait 3 seconds for service to start, then verify:
|
||||
```bash
|
||||
sleep 3
|
||||
launchctl list | grep nanoclaw # macOS
|
||||
# Linux: systemctl --user status nanoclaw
|
||||
```
|
||||
|
||||
### 8. Test Integration
|
||||
|
||||
Tell the user to test:
|
||||
> Send a message to your assistant: `@[YourAssistantName] what's the latest news about AI?`
|
||||
>
|
||||
> The assistant should use Parallel Search API to find current information.
|
||||
>
|
||||
> Then try: `@[YourAssistantName] can you research the history of artificial intelligence?`
|
||||
>
|
||||
> The assistant should ask for permission before using the Task API.
|
||||
|
||||
Check logs to verify MCP servers loaded:
|
||||
```bash
|
||||
tail -20 logs/nanoclaw.log
|
||||
```
|
||||
|
||||
Look for: `Parallel AI MCP servers configured`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Container hangs or times out:**
|
||||
- Check that `type: 'http'` is specified in MCP server config
|
||||
- Verify API key is correct in .env
|
||||
- Check container logs: `cat groups/main/logs/container-*.log | tail -50`
|
||||
|
||||
**MCP servers not loading:**
|
||||
- Ensure PARALLEL_API_KEY is in .env
|
||||
- Verify container-runner.ts includes PARALLEL_API_KEY in allowedVars
|
||||
- Check agent-runner logs for "Parallel AI MCP servers configured" message
|
||||
|
||||
**Task polling not working:**
|
||||
- Verify scheduled task was created: `sqlite3 store/messages.db "SELECT * FROM scheduled_tasks"`
|
||||
- Check task runs: `tail -f logs/nanoclaw.log | grep "scheduled task"`
|
||||
- Ensure task prompt includes proper Parallel MCP tool names
|
||||
|
||||
## Uninstalling
|
||||
|
||||
To remove Parallel AI integration:
|
||||
|
||||
1. Remove from .env: `sed -i.bak '/PARALLEL_API_KEY/d' .env`
|
||||
2. Revert changes to container-runner.ts and agent-runner/src/index.ts
|
||||
3. Remove Web Research Tools section from groups/main/CLAUDE.md
|
||||
4. Rebuild: `./container/build.sh && pnpm run build`
|
||||
5. Restart: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux)
|
||||
@@ -1,40 +1,6 @@
|
||||
# Remove Resend Email Channel
|
||||
|
||||
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 './resend.js';
|
||||
```
|
||||
|
||||
Then delete the copied adapter and its registration test:
|
||||
|
||||
```bash
|
||||
rm -f src/channels/resend.ts src/channels/resend-registration.test.ts
|
||||
```
|
||||
|
||||
## 2. Remove credentials
|
||||
|
||||
Remove `RESEND_API_KEY`, `RESEND_FROM_ADDRESS`, `RESEND_FROM_NAME`, and `RESEND_WEBHOOK_SECRET` 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 @resend/chat-sdk-adapter
|
||||
```
|
||||
|
||||
## 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. Comment out `import './resend.js'` in `src/channels/index.ts`
|
||||
2. Remove `RESEND_API_KEY`, `RESEND_FROM_ADDRESS`, `RESEND_FROM_NAME`, `RESEND_WEBHOOK_SECRET` from `.env`
|
||||
3. `pnpm uninstall @resend/chat-sdk-adapter`
|
||||
4. Rebuild and restart
|
||||
|
||||
@@ -16,7 +16,6 @@ NanoClaw doesn't ship channels in trunk. This skill copies the Resend adapter in
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
|
||||
- `src/channels/resend.ts` exists
|
||||
- `src/channels/resend-registration.test.ts` exists
|
||||
- `src/channels/index.ts` contains `import './resend.js';`
|
||||
- `@resend/chat-sdk-adapter` is listed in `package.json` dependencies
|
||||
|
||||
@@ -28,11 +27,10 @@ Otherwise continue. Every step below is safe to re-run.
|
||||
git fetch origin channels
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and its registration test
|
||||
### 2. Copy the adapter
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/resend.ts > src/channels/resend.ts
|
||||
git show origin/channels:src/channels/resend-registration.test.ts > src/channels/resend-registration.test.ts
|
||||
git show origin/channels:src/channels/resend.ts > src/channels/resend.ts
|
||||
```
|
||||
|
||||
### 3. Append the self-registration import
|
||||
@@ -49,15 +47,12 @@ import './resend.js';
|
||||
pnpm install @resend/chat-sdk-adapter@0.1.1
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
### 5. Build
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
pnpm exec vitest run src/channels/resend-registration.test.ts
|
||||
```
|
||||
|
||||
Both must be clean before proceeding. `resend-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `resend`. It goes red if the `import './resend.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@resend/chat-sdk-adapter` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
|
||||
|
||||
## Credentials
|
||||
|
||||
1. Go to [resend.com](https://resend.com) and create an account.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Verify Resend Email Channel
|
||||
|
||||
Send an email to the configured from address. The bot should respond via email within a few seconds.
|
||||
@@ -1,47 +0,0 @@
|
||||
# Remove rtk
|
||||
|
||||
Idempotent — safe to run even if some steps were never applied. Run Steps 1–3 once per agent group that had rtk wired (`ncl groups list`).
|
||||
|
||||
## 1. Remove the mount from the container config
|
||||
|
||||
Read the current mounts, drop the entry whose `containerPath` is `/usr/local/bin/rtk`, and write the rest back.
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db \
|
||||
"SELECT additional_mounts FROM container_configs WHERE agent_group_id = '<group-id>'"
|
||||
```
|
||||
|
||||
Write the filtered array (omit any entry with `"containerPath":"/usr/local/bin/rtk"`):
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db \
|
||||
"UPDATE container_configs SET additional_mounts = '<filtered-json>' WHERE agent_group_id = '<group-id>'"
|
||||
```
|
||||
|
||||
If no rtk entry is present, leave the array as-is.
|
||||
|
||||
## 2. Remove the PreToolUse hook from settings.json
|
||||
|
||||
Delete the rtk Bash hook entry (not comment it out). This leaves any other `PreToolUse` entries intact and is safe to re-run:
|
||||
|
||||
```bash
|
||||
SETTINGS="data/v2-sessions/<group-id>/.claude-shared/settings.json"
|
||||
|
||||
jq '.hooks.PreToolUse = ((.hooks.PreToolUse // [])
|
||||
| map(select((.hooks // []) | any(.command == "rtk hook claude") | not)))' \
|
||||
"$SETTINGS" > /tmp/rtk-settings.json && mv /tmp/rtk-settings.json "$SETTINGS"
|
||||
```
|
||||
|
||||
## 3. Restart the container
|
||||
|
||||
```bash
|
||||
ncl groups restart --id <group-id>
|
||||
```
|
||||
|
||||
## 4. Remove the host binary (optional)
|
||||
|
||||
Once no group mounts rtk anymore, remove the binary:
|
||||
|
||||
```bash
|
||||
rm -f ~/.local/bin/rtk
|
||||
```
|
||||
@@ -1,143 +0,0 @@
|
||||
---
|
||||
name: add-rtk
|
||||
description: Install rtk token-compression proxy into agent containers. Routes Bash tool calls through rtk for 60–90% token savings on dev commands (git, cargo, pytest, docker, kubectl, etc.).
|
||||
---
|
||||
|
||||
# Add rtk
|
||||
|
||||
Install [rtk](https://github.com/rtk-ai/rtk) — a CLI proxy delivering 60–90% token savings on common dev commands (git, cargo, pytest, docker, kubectl, etc.) — and wire it transparently into agent containers via the Claude Code `PreToolUse` hook.
|
||||
|
||||
## What this sets up
|
||||
|
||||
- `rtk` binary at `~/.local/bin/rtk` on the host
|
||||
- `~/.local/bin/rtk` mounted read-only at `/usr/local/bin/rtk` inside the target agent group's containers
|
||||
- `PreToolUse` hook in the agent group's `settings.json` so every Bash call is automatically filtered through rtk — no CLAUDE.md instructions needed
|
||||
|
||||
## Step 1 — Install rtk on the host
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | sh
|
||||
```
|
||||
|
||||
If the script put the binary elsewhere, move it:
|
||||
|
||||
```bash
|
||||
find ~/.local ~/.cargo/bin ~/bin -name rtk 2>/dev/null
|
||||
mv "$(which rtk 2>/dev/null)" ~/.local/bin/rtk
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
~/.local/bin/rtk --version
|
||||
chmod +x ~/.local/bin/rtk # if needed
|
||||
```
|
||||
|
||||
## Step 2 — Identify the target agent group
|
||||
|
||||
```bash
|
||||
ncl groups list
|
||||
```
|
||||
|
||||
Note the group ID (e.g. `ag-1776342942165-ptgddd`). Repeat Steps 3–5 for each group.
|
||||
|
||||
## Step 3 — Mount rtk into the container config
|
||||
|
||||
`additional_mounts` is a JSON array column on `container_configs`. Read the current value, merge in the rtk entry, and write the merged array back.
|
||||
|
||||
Read current mounts first:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db \
|
||||
"SELECT additional_mounts FROM container_configs WHERE agent_group_id = '<group-id>'"
|
||||
```
|
||||
|
||||
Build the merged array: keep every existing entry, drop any entry whose `containerPath` is `/usr/local/bin/rtk` (so re-running replaces rather than duplicates), then add the rtk entry:
|
||||
|
||||
```json
|
||||
{"hostPath":"/home/<user>/.local/bin/rtk","containerPath":"/usr/local/bin/rtk","readonly":true}
|
||||
```
|
||||
|
||||
Write the merged array back:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db \
|
||||
"UPDATE container_configs SET additional_mounts = '<merged-json>' WHERE agent_group_id = '<group-id>'"
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db \
|
||||
"SELECT additional_mounts FROM container_configs WHERE agent_group_id = '<group-id>'"
|
||||
```
|
||||
|
||||
## Step 4 — Add the PreToolUse hook to settings.json
|
||||
|
||||
Each agent group has a `settings.json` at:
|
||||
|
||||
```
|
||||
data/v2-sessions/<group-id>/.claude-shared/settings.json
|
||||
```
|
||||
|
||||
This file is mounted at `/home/node/.claude/settings.json` inside the container and is read by Claude Code for hooks, env, and model config.
|
||||
|
||||
Add the `PreToolUse` entry with `jq`. This drops any existing rtk Bash hook first, then appends a fresh one, so it is safe to re-run without creating duplicates:
|
||||
|
||||
```bash
|
||||
SETTINGS="data/v2-sessions/<group-id>/.claude-shared/settings.json"
|
||||
|
||||
jq '.hooks.PreToolUse = ((.hooks.PreToolUse // [])
|
||||
| map(select((.hooks // []) | any(.command == "rtk hook claude") | not)))
|
||||
+ [{"matcher":"Bash","hooks":[{"type":"command","command":"rtk hook claude"}]}]' \
|
||||
"$SETTINGS" > /tmp/rtk-settings.json && mv /tmp/rtk-settings.json "$SETTINGS"
|
||||
```
|
||||
|
||||
## Step 5 — Restart the container
|
||||
|
||||
```bash
|
||||
ncl groups restart --id <group-id>
|
||||
```
|
||||
|
||||
## Verify
|
||||
|
||||
Confirm the binary is executable inside the container so a missing or non-executable mount surfaces immediately rather than as a silent hook failure:
|
||||
|
||||
```bash
|
||||
docker exec "$(docker ps --filter "name=<group-id>" --format '{{.Names}}' | head -1)" rtk --version
|
||||
```
|
||||
|
||||
Then ask the agent to run `git status` or any other supported command. rtk intercepts it silently. Check savings with:
|
||||
|
||||
```bash
|
||||
~/.local/bin/rtk gain
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### `rtk: command not found` inside the container
|
||||
|
||||
Mount wasn't applied or container wasn't restarted:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db \
|
||||
"SELECT additional_mounts FROM container_configs WHERE agent_group_id = '<group-id>'"
|
||||
# Look for entry with /usr/local/bin/rtk
|
||||
ncl groups restart --id <group-id>
|
||||
```
|
||||
|
||||
### Hook not firing
|
||||
|
||||
Verify the hook is in `settings.json`:
|
||||
|
||||
```bash
|
||||
jq '.hooks.PreToolUse' data/v2-sessions/<group-id>/.claude-shared/settings.json
|
||||
```
|
||||
|
||||
If missing, re-run Step 4.
|
||||
|
||||
### Binary won't execute — permission denied
|
||||
|
||||
```bash
|
||||
chmod +x ~/.local/bin/rtk
|
||||
```
|
||||
@@ -1,61 +0,0 @@
|
||||
# Remove Signal
|
||||
|
||||
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 './signal.js';
|
||||
```
|
||||
|
||||
Then delete the copied adapter and its tests:
|
||||
|
||||
```bash
|
||||
rm -f src/channels/signal.ts src/channels/signal-registration.test.ts src/channels/signal.test.ts
|
||||
```
|
||||
|
||||
## 2. Remove credentials
|
||||
|
||||
Remove the `SIGNAL_*` lines from `.env`:
|
||||
|
||||
```bash
|
||||
SIGNAL_ACCOUNT
|
||||
SIGNAL_TCP_HOST
|
||||
SIGNAL_TCP_PORT
|
||||
SIGNAL_CLI_PATH
|
||||
SIGNAL_MANAGE_DAEMON
|
||||
SIGNAL_DATA_DIR
|
||||
```
|
||||
|
||||
Then re-sync to the container:
|
||||
|
||||
```bash
|
||||
mkdir -p data/env && cp .env data/env/env
|
||||
```
|
||||
|
||||
## 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. Unlink the Signal account (optional)
|
||||
|
||||
To unlink NanoClaw's device from the Signal account:
|
||||
|
||||
```bash
|
||||
signal-cli -a +1YOURNUMBER removeDevice --deviceId <id>
|
||||
```
|
||||
|
||||
Find the device id with `signal-cli -a +1YOURNUMBER listDevices`.
|
||||
@@ -1,335 +0,0 @@
|
||||
---
|
||||
name: add-signal
|
||||
description: Add Signal channel integration via signal-cli TCP daemon. Native adapter — no Chat SDK bridge.
|
||||
---
|
||||
|
||||
# Add Signal Channel
|
||||
|
||||
Adds Signal messaging support via a native adapter that speaks JSON-RPC to a [signal-cli](https://github.com/AsamK/signal-cli) TCP daemon. No Chat SDK bridge — only Node.js builtins (`node:net`, `node:child_process`, `node:fs`).
|
||||
|
||||
Unlike Telegram or Discord, Signal has no bot API. NanoClaw registers as a full Signal account on a dedicated phone number (recommended) or links as a secondary device on your existing number.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Java
|
||||
|
||||
signal-cli requires Java 17+:
|
||||
|
||||
```bash
|
||||
java -version
|
||||
```
|
||||
|
||||
If missing:
|
||||
- **macOS:** `brew install --cask temurin@17`
|
||||
- **Debian/Ubuntu:** `sudo apt-get install -y default-jre`
|
||||
- **RHEL/Fedora:** `sudo dnf install -y java-17-openjdk`
|
||||
|
||||
Java 17–25 all work.
|
||||
|
||||
### signal-cli
|
||||
|
||||
- **macOS:** `brew install signal-cli`
|
||||
- **Linux:** download the native binary from [GitHub releases](https://github.com/AsamK/signal-cli/releases):
|
||||
|
||||
```bash
|
||||
SIGNAL_CLI_VERSION=$(curl -fsSL https://api.github.com/repos/AsamK/signal-cli/releases/latest | python3 -c "import sys,json; print(json.load(sys.stdin)['tag_name'][1:])")
|
||||
curl -fsSL "https://github.com/AsamK/signal-cli/releases/download/v${SIGNAL_CLI_VERSION}/signal-cli-${SIGNAL_CLI_VERSION}-Linux-native.tar.gz" \
|
||||
| tar -xz -C ~/.local
|
||||
ln -sf ~/.local/signal-cli ~/.local/bin/signal-cli
|
||||
signal-cli --version
|
||||
```
|
||||
|
||||
> The Linux native tarball extracts a single binary directly to `~/.local/signal-cli` (not into a subdirectory). The symlink above puts it on PATH.
|
||||
|
||||
## Registration
|
||||
|
||||
Two paths. The new-number path is recommended and battle-tested.
|
||||
|
||||
### Path A: Register a new number (recommended)
|
||||
|
||||
Use a dedicated SIM or VoIP number. NanoClaw owns it entirely.
|
||||
|
||||
> **VoIP numbers:** Signal requires SMS verification before voice. Some VoIP providers are blocked even for voice calls. If registration fails with an auth error, try a different provider or a physical SIM.
|
||||
|
||||
**Step 1: Solve the CAPTCHA**
|
||||
|
||||
Signal requires a CAPTCHA on first registration:
|
||||
|
||||
1. Open `https://signalcaptchas.org/registration/generate.html` in a browser
|
||||
2. Solve the captcha
|
||||
3. Right-click the **"Open Signal"** button → **Copy Link**
|
||||
4. The link starts with `signalcaptcha://` — the token is everything after that prefix
|
||||
|
||||
**Step 2: Request SMS verification**
|
||||
|
||||
```bash
|
||||
signal-cli -a +1YOURNUMBER register --captcha "PASTE_TOKEN_HERE"
|
||||
```
|
||||
|
||||
**Step 3: Voice call fallback (if your number can't receive SMS)**
|
||||
|
||||
Wait ~60 seconds after the SMS request, then:
|
||||
|
||||
```bash
|
||||
signal-cli -a +1YOURNUMBER register --voice --captcha "SAME_TOKEN"
|
||||
```
|
||||
|
||||
Signal calls your number and reads a 6-digit code. The same captcha token is reusable — no need to solve a new one.
|
||||
|
||||
> You must request SMS first. Requesting voice immediately fails with `Invalid verification method: Before requesting voice verification…`
|
||||
|
||||
**Step 4: Verify**
|
||||
|
||||
```bash
|
||||
signal-cli -a +1YOURNUMBER verify CODE
|
||||
```
|
||||
|
||||
No output = success.
|
||||
|
||||
**Step 5: Set profile name (optional)**
|
||||
|
||||
> ⚠ Stop NanoClaw before running signal-cli commands — the daemon holds an exclusive lock on its data directory while running.
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
source setup/lib/install-slug.sh
|
||||
|
||||
# macOS
|
||||
launchctl unload ~/Library/LaunchAgents/$(launchd_label).plist
|
||||
signal-cli -a +1YOURNUMBER updateProfile --name "YourBotName"
|
||||
# optionally: --avatar /path/to/avatar.jpg
|
||||
launchctl load ~/Library/LaunchAgents/$(launchd_label).plist
|
||||
|
||||
# Linux
|
||||
systemctl --user stop $(systemd_unit)
|
||||
signal-cli -a +1YOURNUMBER updateProfile --name "YourBotName"
|
||||
systemctl --user start $(systemd_unit)
|
||||
```
|
||||
|
||||
### Path B: Link as secondary device
|
||||
|
||||
Joins an existing Signal account as a secondary device. Simpler, but NanoClaw shares your personal number.
|
||||
|
||||
```bash
|
||||
signal-cli -a +1YOURNUMBER link --name "NanoClaw"
|
||||
```
|
||||
|
||||
This prints a `tsdevice:` URI. Scan it as a QR code on your phone: **Settings → Linked Devices → Link New Device**. QR codes expire in ~30 seconds — re-run if it expires.
|
||||
|
||||
## Install
|
||||
|
||||
### Pre-flight (idempotent)
|
||||
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
|
||||
- `src/channels/signal.ts` exists
|
||||
- `src/channels/signal.test.ts` exists
|
||||
- `src/channels/signal-registration.test.ts` exists
|
||||
- `src/channels/index.ts` contains `import './signal.js';`
|
||||
|
||||
Otherwise continue. Every step below is safe to re-run.
|
||||
|
||||
### 1. Fetch the channels branch
|
||||
|
||||
```bash
|
||||
git fetch origin channels
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and tests
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/signal.ts > src/channels/signal.ts
|
||||
git show origin/channels:src/channels/signal.test.ts > src/channels/signal.test.ts
|
||||
git show origin/channels:src/channels/signal-registration.test.ts > src/channels/signal-registration.test.ts
|
||||
```
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
```typescript
|
||||
import './signal.js';
|
||||
```
|
||||
|
||||
### 4. Build and validate
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
pnpm exec vitest run src/channels/signal-registration.test.ts
|
||||
```
|
||||
|
||||
Both must be clean before proceeding. `signal-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `signal`. It goes red if the `import './signal.js';` line is deleted or drifts, or if the barrel fails to evaluate (so the channel genuinely would not register). The adapter consumes only Node.js builtins, so there is no npm dependency to guard for this channel. The adapter's typed core-API consumption is guarded by `pnpm run build`.
|
||||
|
||||
## Credentials
|
||||
|
||||
Add to `.env`:
|
||||
|
||||
```bash
|
||||
SIGNAL_ACCOUNT=+1YOURNUMBER
|
||||
```
|
||||
|
||||
### Optional settings
|
||||
|
||||
```bash
|
||||
# TCP daemon host and port (default: 127.0.0.1:7583)
|
||||
SIGNAL_TCP_HOST=127.0.0.1
|
||||
SIGNAL_TCP_PORT=7583
|
||||
|
||||
# Path to the signal-cli binary (default: resolved on PATH)
|
||||
SIGNAL_CLI_PATH=/usr/local/bin/signal-cli
|
||||
|
||||
# Whether NanoClaw manages the daemon lifecycle (default: true).
|
||||
# Set to false if you run signal-cli daemon externally.
|
||||
SIGNAL_MANAGE_DAEMON=true
|
||||
|
||||
# signal-cli data directory (default: ~/.local/share/signal-cli)
|
||||
SIGNAL_DATA_DIR=~/.local/share/signal-cli
|
||||
```
|
||||
|
||||
**Security note:** keep the TCP host on `127.0.0.1`. The daemon has no auth — binding it to a public interface would expose your full Signal account to the network.
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
|
||||
### Restart
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
source setup/lib/install-slug.sh
|
||||
|
||||
# macOS
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
|
||||
|
||||
# Linux
|
||||
systemctl --user restart $(systemd_unit)
|
||||
```
|
||||
|
||||
## Wiring
|
||||
|
||||
### DMs
|
||||
|
||||
After the service starts, send any message to the Signal number from your personal Signal app. The router auto-creates a `messaging_groups` row. Then:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db \
|
||||
"SELECT id, platform_id FROM messaging_groups WHERE channel_type='signal' ORDER BY created_at DESC LIMIT 5"
|
||||
```
|
||||
|
||||
Pass the `id` to `/init-first-agent` or `/manage-channels` to wire it to an agent group.
|
||||
|
||||
### Groups
|
||||
|
||||
Add the Signal number to a group from your phone, send any message, then wire the resulting row the same way. For isolated per-group sessions:
|
||||
|
||||
```bash
|
||||
NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "
|
||||
INSERT OR IGNORE INTO messaging_group_agents
|
||||
(id, messaging_group_id, agent_group_id, session_mode, priority, created_at)
|
||||
VALUES
|
||||
('mga-'||hex(randomblob(8)), 'mg-GROUPID', 'ag-AGENTID', 'isolated', 0, '$NOW');
|
||||
"
|
||||
```
|
||||
|
||||
### Grant user access
|
||||
|
||||
New Signal users (including the owner's Signal identity) are silently dropped with `not_member` until granted access. After the user's first message appears in `messaging_groups`:
|
||||
|
||||
```bash
|
||||
NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "
|
||||
INSERT OR REPLACE INTO user_roles (user_id, role, agent_group_id, granted_by, granted_at)
|
||||
VALUES ('signal:UUID', 'owner', NULL, 'system', '$NOW');
|
||||
INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id, added_by, added_at)
|
||||
VALUES ('signal:UUID', 'ag-AGENTID', 'system', '$NOW');
|
||||
"
|
||||
```
|
||||
|
||||
Find the UUID from `messaging_groups.platform_id` or the `users` table.
|
||||
|
||||
## Next Steps
|
||||
|
||||
If you're in the middle of `/setup`, return to the setup flow now.
|
||||
|
||||
Otherwise, run `/init-first-agent` to create an agent and wire it to your Signal DM, or `/manage-channels` to wire this channel to an existing agent group.
|
||||
|
||||
## Channel Info
|
||||
|
||||
- **type**: `signal`
|
||||
- **terminology**: Signal has "chats" (1:1 DMs) and "groups"
|
||||
- **supports-threads**: no
|
||||
- **platform-id-format**:
|
||||
- DM: `signal:{UUID}` — sender's Signal UUID (ACI), **not** their phone number
|
||||
- Group: `signal:{base64GroupId}` — base64-encoded GroupV2 ID
|
||||
- **how-to-find-id**: Send a message to the bot, then query `messaging_groups` as shown above
|
||||
- **typical-use**: Personal assistant via Signal DMs or small group chats
|
||||
- **default-isolation**: One agent per Signal account. Multiple chats with the same operator can share an agent group; groups with other people should typically use `isolated` session mode
|
||||
|
||||
### Features
|
||||
|
||||
- Markdown formatting — `**bold**`, `*italic*` / `_italic_`, `` `code` ``, ` ```code fence``` `, `~~strike~~`, `||spoiler||` (converted to Signal's offset-based text styles)
|
||||
- Quoted replies — `replyTo*` fields populated from Signal quotes
|
||||
- Typing indicators — DMs only (Signal doesn't support group typing)
|
||||
- Echo suppression — outbound messages matched on `(platformId, text)` within a 10 s TTL to avoid syncMessage loops
|
||||
- Note to Self — messages you send to your own account from another device route to the agent as inbound with `isFromMe: true`
|
||||
- Voice attachments — detected but not transcribed by default; the agent receives `[Voice Message]` placeholder text. Run `/add-voice-transcription` for local transcription via parakeet-mlx
|
||||
|
||||
Not supported yet: outbound file attachments (logged and dropped), edit/delete messages, reactions.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Daemon not reachable
|
||||
|
||||
```bash
|
||||
grep "Signal" logs/nanoclaw.log | tail
|
||||
```
|
||||
|
||||
If you see `Signal daemon failed to start. Is signal-cli installed and your account linked?`:
|
||||
- Confirm `signal-cli` is on PATH (or set `SIGNAL_CLI_PATH`)
|
||||
- Confirm the account is linked: `signal-cli -a +1YOURNUMBER listIdentities` should succeed without prompting
|
||||
|
||||
If you see `Signal daemon not reachable at 127.0.0.1:7583` and `SIGNAL_MANAGE_DAEMON=false`, start the daemon yourself: `signal-cli -a +1YOURNUMBER daemon --tcp 127.0.0.1:7583`.
|
||||
|
||||
### Bot not responding
|
||||
|
||||
1. Channel initialized: `grep "Signal channel connected" logs/nanoclaw.log | tail -1`
|
||||
2. Channel wired: `pnpm exec tsx scripts/q.ts data/v2.db "SELECT mg.platform_id, mg.name FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id WHERE mg.channel_type='signal'"`
|
||||
3. Service running: `launchctl print gui/$(id -u)/"$(. setup/lib/install-slug.sh && launchd_label)"` (macOS) / `systemctl --user status "$(. setup/lib/install-slug.sh && systemd_unit)"` (Linux)
|
||||
4. **Check for duplicate service instances** — if `logs/nanoclaw.error.log` shows `No adapter for channel type channelType="signal"` despite the adapter starting, two NanoClaw processes are racing. See the `/debug` skill section "No adapter for channel type / Messages silently lost" for the full fix.
|
||||
|
||||
### Messages delivered but never arrive (null platformMsgId)
|
||||
|
||||
Signal responses show `platformMsgId=undefined` in the main log. This means the delivery poll ran but found no adapter — likely a duplicate service instance issue (see above). Affected messages cannot be retried; the user must resend.
|
||||
|
||||
### Lost connection mid-session
|
||||
|
||||
If you see `Signal channel lost TCP connection to signal-cli daemon` in the logs, the daemon dropped the connection. Restart the service to re-establish.
|
||||
|
||||
### Messages dropped with `not_member`
|
||||
|
||||
The Signal user hasn't been granted membership. See "Grant user access" above. This affects every new Signal user, including the owner's Signal identity — which is a separate user record from their identity on other channels even if it's the same person.
|
||||
|
||||
### Captcha required
|
||||
|
||||
Signal requires a captcha for new registrations. Go to `https://signalcaptchas.org/registration/generate.html`, solve it, right-click "Open Signal", copy the link, extract the token after `signalcaptcha://`.
|
||||
|
||||
### `Invalid verification method: Before requesting voice verification…`
|
||||
|
||||
You must request SMS first, wait ~60 seconds, then request voice. Both steps can use the same captcha token.
|
||||
|
||||
### Config file in use / daemon lock
|
||||
|
||||
signal-cli holds an exclusive lock on its data directory while the daemon is running. Stop NanoClaw before running any `signal-cli` commands directly, then restart afterward.
|
||||
|
||||
### Group replies going to DM instead of group
|
||||
|
||||
Modern Signal groups use GroupV2. The adapter must extract the group ID from `envelope?.dataMessage?.groupV2?.id` — not `groupInfo?.groupId`, which is GroupV1/legacy. If group messages are routing as DMs, check `src/channels/signal.ts` and confirm the groupId extraction falls through to `groupV2.id`.
|
||||
|
||||
### Java not found
|
||||
|
||||
Install Java 17+ — see the Prerequisites section above.
|
||||
|
||||
### QR code expired (Path B)
|
||||
|
||||
QR codes expire in ~30 seconds. Re-run the link command to generate a new one.
|
||||
@@ -1,40 +1,6 @@
|
||||
# Remove Slack
|
||||
|
||||
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 './slack.js';
|
||||
```
|
||||
|
||||
Then delete the copied adapter and its registration test:
|
||||
|
||||
```bash
|
||||
rm -f src/channels/slack.ts src/channels/slack-registration.test.ts
|
||||
```
|
||||
|
||||
## 2. Remove credentials
|
||||
|
||||
Remove `SLACK_BOT_TOKEN` and `SLACK_SIGNING_SECRET` 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/slack
|
||||
```
|
||||
|
||||
## 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. Comment out `import './slack.js'` in `src/channels/index.ts`
|
||||
2. Remove `SLACK_BOT_TOKEN` and `SLACK_SIGNING_SECRET` from `.env`
|
||||
3. `pnpm uninstall @chat-adapter/slack`
|
||||
4. Rebuild and restart
|
||||
|
||||
@@ -16,7 +16,6 @@ NanoClaw doesn't ship channels in trunk. This skill copies the Slack adapter in
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
|
||||
- `src/channels/slack.ts` exists
|
||||
- `src/channels/slack-registration.test.ts` exists
|
||||
- `src/channels/index.ts` contains `import './slack.js';`
|
||||
- `@chat-adapter/slack` is listed in `package.json` dependencies
|
||||
|
||||
@@ -28,11 +27,10 @@ Otherwise continue. Every step below is safe to re-run.
|
||||
git fetch origin channels
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and its registration test
|
||||
### 2. Copy the adapter
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/slack.ts > src/channels/slack.ts
|
||||
git show origin/channels:src/channels/slack-registration.test.ts > src/channels/slack-registration.test.ts
|
||||
git show origin/channels:src/channels/slack.ts > src/channels/slack.ts
|
||||
```
|
||||
|
||||
### 3. Append the self-registration import
|
||||
@@ -46,20 +44,15 @@ import './slack.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/slack@4.27.0
|
||||
pnpm install @chat-adapter/slack@4.26.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
### 5. Build
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
pnpm exec vitest run src/channels/slack-registration.test.ts
|
||||
```
|
||||
|
||||
Both must be clean before proceeding. `slack-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `slack`. It goes red if the `import './slack.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/slack` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
|
||||
|
||||
End-to-end message delivery against a real Slack workspace is verified manually once the service is running — see Next Steps and the webhook setup above.
|
||||
|
||||
## Credentials
|
||||
|
||||
### Create Slack App
|
||||
@@ -67,7 +60,7 @@ End-to-end message delivery against a real Slack workspace is verified manually
|
||||
1. Go to [api.slack.com/apps](https://api.slack.com/apps) and click **Create New App** > **From scratch**
|
||||
2. Name it (e.g., "NanoClaw") and select your workspace
|
||||
3. Go to **OAuth & Permissions** and add Bot Token Scopes:
|
||||
- `chat:write`, `im:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read`, `reactions:write`, `files:read`, `files:write`
|
||||
- `chat:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read`, `reactions:write`
|
||||
4. Click **Install to Workspace** and copy the **Bot User OAuth Token** (`xoxb-...`)
|
||||
5. Go to **Basic Information** and copy the **Signing Secret**
|
||||
|
||||
@@ -83,13 +76,7 @@ End-to-end message delivery against a real Slack workspace is verified manually
|
||||
10. Under **Subscribe to bot events**, add:
|
||||
- `message.channels`, `message.groups`, `message.im`, `app_mention`
|
||||
11. Click **Save Changes**
|
||||
|
||||
### Interactivity
|
||||
|
||||
12. Go to **Interactivity & Shortcuts** and toggle **Interactivity** on
|
||||
13. Set the **Request URL** to the same `https://your-domain/webhook/slack`
|
||||
14. Click **Save Changes**
|
||||
15. Slack will show a banner asking you to **reinstall the app** — click it to apply the new settings
|
||||
12. Slack will show a banner asking you to **reinstall the app** — click it to apply the new event subscriptions
|
||||
|
||||
### Configure environment
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Verify Slack
|
||||
|
||||
Add the bot to a Slack channel, then send a message or @mention the bot. The bot should respond within a few seconds.
|
||||
@@ -1,47 +1,6 @@
|
||||
# Remove Microsoft Teams
|
||||
# Remove Microsoft Teams Channel
|
||||
|
||||
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 './teams.js';
|
||||
```
|
||||
|
||||
Then delete the copied adapter and its registration test:
|
||||
|
||||
```bash
|
||||
rm -f src/channels/teams.ts src/channels/teams-registration.test.ts
|
||||
```
|
||||
|
||||
## 2. Remove credentials
|
||||
|
||||
Remove the `TEAMS_*` lines from `.env`, then re-sync to the container:
|
||||
|
||||
```bash
|
||||
TEAMS_APP_ID
|
||||
TEAMS_APP_PASSWORD
|
||||
TEAMS_APP_TENANT_ID
|
||||
TEAMS_APP_TYPE
|
||||
```
|
||||
|
||||
```bash
|
||||
mkdir -p data/env && cp .env data/env/env
|
||||
```
|
||||
|
||||
## 3. Remove the package
|
||||
|
||||
```bash
|
||||
pnpm uninstall @chat-adapter/teams
|
||||
```
|
||||
|
||||
## 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. Comment out `import './teams.js'` in `src/channels/index.ts`
|
||||
2. Remove `TEAMS_APP_ID` and `TEAMS_APP_PASSWORD` from `.env`
|
||||
3. `pnpm uninstall @chat-adapter/teams`
|
||||
4. Rebuild and restart
|
||||
|
||||
@@ -16,7 +16,6 @@ NanoClaw doesn't ship channels in trunk. This skill copies the Teams adapter in
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
|
||||
- `src/channels/teams.ts` exists
|
||||
- `src/channels/teams-registration.test.ts` exists
|
||||
- `src/channels/index.ts` contains `import './teams.js';`
|
||||
- `@chat-adapter/teams` is listed in `package.json` dependencies
|
||||
|
||||
@@ -28,11 +27,10 @@ Otherwise continue. Every step below is safe to re-run.
|
||||
git fetch origin channels
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and its registration test
|
||||
### 2. Copy the adapter
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/teams.ts > src/channels/teams.ts
|
||||
git show origin/channels:src/channels/teams-registration.test.ts > src/channels/teams-registration.test.ts
|
||||
git show origin/channels:src/channels/teams.ts > src/channels/teams.ts
|
||||
```
|
||||
|
||||
### 3. Append the self-registration import
|
||||
@@ -46,63 +44,17 @@ import './teams.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/teams@4.27.0
|
||||
pnpm install @chat-adapter/teams@4.26.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
### 5. Build
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
pnpm exec vitest run src/channels/teams-registration.test.ts
|
||||
```
|
||||
|
||||
Both must be clean before proceeding. `teams-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `teams`. It goes red if the `import './teams.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/teams` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
|
||||
|
||||
End-to-end message delivery against a real Teams workspace is verified manually once the service is running — see Next Steps and the webhook setup above.
|
||||
|
||||
## Credentials
|
||||
|
||||
Two paths — manual (Azure Portal) or auto (Teams CLI).
|
||||
|
||||
### Auto: Teams CLI
|
||||
|
||||
Requires Node.js 18+, a Microsoft 365 account with sideloading permissions, and a public HTTPS endpoint (ngrok, Cloudflare Tunnel, or similar).
|
||||
|
||||
1. Install the CLI:
|
||||
|
||||
```bash
|
||||
npm install -g @microsoft/teams.cli@preview
|
||||
```
|
||||
|
||||
2. Sign in and verify:
|
||||
|
||||
```bash
|
||||
teams login
|
||||
teams status
|
||||
```
|
||||
|
||||
3. Create the Entra app, client secret, and bot registration:
|
||||
|
||||
```bash
|
||||
teams app create \
|
||||
--name "NanoClaw" \
|
||||
--endpoint "https://your-domain/api/webhooks/teams"
|
||||
```
|
||||
|
||||
The CLI prints the credentials as `CLIENT_ID`, `CLIENT_SECRET`, and `TENANT_ID`. Map them to NanoClaw's env keys:
|
||||
|
||||
- `CLIENT_ID` → `TEAMS_APP_ID`
|
||||
- `CLIENT_SECRET` → `TEAMS_APP_PASSWORD`
|
||||
- `TENANT_ID` → `TEAMS_APP_TENANT_ID`
|
||||
|
||||
4. Pick **Install in Teams** from the post-create menu and confirm in the Teams dialog.
|
||||
|
||||
Continue to [Configure environment](#configure-environment).
|
||||
|
||||
---
|
||||
|
||||
The steps below describe the **manual Azure Portal path**.
|
||||
|
||||
### Step 1: Create an Azure AD App Registration
|
||||
|
||||
1. Go to [Azure Portal](https://portal.azure.com) > **App registrations** > **New registration**
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Verify Microsoft Teams Channel
|
||||
|
||||
Add the bot to a Teams channel or send it a direct message. The bot should respond within a few seconds.
|
||||
@@ -1,51 +1,6 @@
|
||||
# Remove Telegram
|
||||
|
||||
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 './telegram.js';
|
||||
```
|
||||
|
||||
Then delete the copied adapter, helpers, tests, registration test, and setup step:
|
||||
|
||||
```bash
|
||||
rm -f src/channels/telegram.ts src/channels/telegram-registration.test.ts \
|
||||
src/channels/telegram-pairing.ts src/channels/telegram-markdown-sanitize.ts \
|
||||
src/channels/telegram-pairing.test.ts src/channels/telegram-markdown-sanitize.test.ts \
|
||||
setup/pair-telegram.ts
|
||||
```
|
||||
|
||||
## 2. Remove the setup step
|
||||
|
||||
Delete this entry from the `STEPS` map in `setup/index.ts` (skip if already gone):
|
||||
|
||||
```typescript
|
||||
'pair-telegram': () => import('./pair-telegram.js'),
|
||||
```
|
||||
|
||||
## 3. Remove credentials
|
||||
|
||||
Remove `TELEGRAM_BOT_TOKEN` from `.env`, then re-sync to the container:
|
||||
|
||||
```bash
|
||||
mkdir -p data/env && cp .env data/env/env
|
||||
```
|
||||
|
||||
## 4. Remove the package
|
||||
|
||||
```bash
|
||||
pnpm uninstall @chat-adapter/telegram
|
||||
```
|
||||
|
||||
## 5. 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. Comment out `import './telegram.js'` in `src/channels/index.ts`
|
||||
2. Remove `TELEGRAM_BOT_TOKEN` from `.env`
|
||||
3. `pnpm uninstall @chat-adapter/telegram`
|
||||
4. Rebuild and restart
|
||||
|
||||
@@ -16,7 +16,6 @@ NanoClaw doesn't ship channels in trunk. This skill copies the Telegram adapter,
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
|
||||
- `src/channels/telegram.ts`, `telegram-pairing.ts`, `telegram-markdown-sanitize.ts` (and their `.test.ts` siblings) all exist
|
||||
- `src/channels/telegram-registration.test.ts` exists
|
||||
- `src/channels/index.ts` contains `import './telegram.js';`
|
||||
- `setup/pair-telegram.ts` exists and `setup/index.ts`'s `STEPS` map contains `'pair-telegram':`
|
||||
- `@chat-adapter/telegram` is listed in `package.json` dependencies
|
||||
@@ -29,11 +28,10 @@ Otherwise continue. Every step below is safe to re-run.
|
||||
git fetch origin channels
|
||||
```
|
||||
|
||||
### 2. Copy the adapter, helpers, tests, registration test, and setup step
|
||||
### 2. Copy the adapter, helpers, tests, and setup step
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/telegram.ts > src/channels/telegram.ts
|
||||
git show origin/channels:src/channels/telegram-registration.test.ts > src/channels/telegram-registration.test.ts
|
||||
git show origin/channels:src/channels/telegram-pairing.ts > src/channels/telegram-pairing.ts
|
||||
git show origin/channels:src/channels/telegram-pairing.test.ts > src/channels/telegram-pairing.test.ts
|
||||
git show origin/channels:src/channels/telegram-markdown-sanitize.ts > src/channels/telegram-markdown-sanitize.ts
|
||||
@@ -60,20 +58,15 @@ In `setup/index.ts`, add this entry to the `STEPS` map (right after the `registe
|
||||
### 5. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/telegram@4.27.0
|
||||
pnpm install @chat-adapter/telegram@4.26.0
|
||||
```
|
||||
|
||||
### 6. Build and validate
|
||||
### 6. Build
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
pnpm exec vitest run src/channels/telegram-registration.test.ts
|
||||
```
|
||||
|
||||
Both must be clean before proceeding. `telegram-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `telegram`. It goes red if the `import './telegram.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/telegram` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 5. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
|
||||
|
||||
End-to-end message delivery against a real Telegram bot is verified manually once the service is running — see Next Steps and the pairing flow in Channel Info.
|
||||
|
||||
## Credentials
|
||||
|
||||
### Create Telegram Bot
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Verify Telegram
|
||||
|
||||
Send a message to your bot in Telegram (search for its username), or add the bot to a group and send a message there. The bot should respond within a few seconds.
|
||||
@@ -1,47 +0,0 @@
|
||||
# Remove Vercel
|
||||
|
||||
Every step is idempotent — safe to re-run. Steps delete the files and config the apply created.
|
||||
|
||||
## 1. Remove the container skill
|
||||
|
||||
Delete the copied container skill and its per-group session copies:
|
||||
|
||||
```bash
|
||||
rm -rf container/skills/vercel-cli
|
||||
for session_dir in data/v2-sessions/ag-*; do
|
||||
rm -rf "$session_dir/.claude-shared/skills/vercel-cli"
|
||||
done
|
||||
```
|
||||
|
||||
## 2. Remove the dependency guard test
|
||||
|
||||
```bash
|
||||
rm -f src/vercel-dockerfile.test.ts
|
||||
```
|
||||
|
||||
## 3. Remove the OneCLI credential
|
||||
|
||||
Delete the Vercel secret and strip its id from every agent's assigned list. `set-secrets` replaces the whole list, so read, filter, and write back per agent:
|
||||
|
||||
```bash
|
||||
VERCEL_SECRET_ID=$(onecli secrets list | jq -r '.data[] | select(.name | test("(?i)vercel")) | .id' | head -1)
|
||||
if [ -n "$VERCEL_SECRET_ID" ]; then
|
||||
for agent in $(onecli agents list | jq -r '.data[].id'); do
|
||||
REMAINING=$(onecli agents secrets --id "$agent" | jq -r --arg id "$VERCEL_SECRET_ID" '[.data[] | select(. != $id)] | join(",")')
|
||||
onecli agents set-secrets --id "$agent" --secret-ids "$REMAINING"
|
||||
done
|
||||
onecli secrets delete --id "$VERCEL_SECRET_ID"
|
||||
fi
|
||||
```
|
||||
|
||||
## 4. The Vercel CLI in the container image
|
||||
|
||||
The Vercel CLI ships with the agent image on the NanoClaw trunk (`ARG VERCEL_VERSION` and `pnpm install -g "vercel@${VERCEL_VERSION}"` in `container/Dockerfile`). Leave those lines — they are part of the base image, not added by this skill. No rebuild is needed.
|
||||
|
||||
## 5. Restart running containers
|
||||
|
||||
So sessions stop loading the removed `vercel-cli` skill on next wake:
|
||||
|
||||
```bash
|
||||
docker ps --format "{{.ID}} {{.Names}}" | grep nanoclaw-v2 | awk '{print $1}' | xargs -r docker stop
|
||||
```
|
||||
@@ -90,43 +90,30 @@ onecli secrets list | grep -i vercel
|
||||
OneCLI uses selective secret mode — secrets must be explicitly assigned to each agent. Get the Vercel secret ID from the output above, then assign it to every agent:
|
||||
|
||||
```bash
|
||||
# set-secrets replaces the entire list — read and merge for each agent.
|
||||
VERCEL_SECRET_ID=$(onecli secrets list | jq -r '.data[] | select(.name | test("(?i)vercel")) | .id' | head -1)
|
||||
for agent in $(onecli agents list | jq -r '.data[].id'); do
|
||||
CURRENT=$(onecli agents secrets --id "$agent" | jq -r '[.data[]] | join(",")')
|
||||
MERGED=$(printf '%s' "$CURRENT,$VERCEL_SECRET_ID" | tr ',' '\n' | sort -u | paste -sd ',' -)
|
||||
onecli agents set-secrets --id "$agent" --secret-ids "$MERGED"
|
||||
# For each agent, add the Vercel secret to its assigned secrets list.
|
||||
# First get current assignments, then set them with the new secret appended.
|
||||
VERCEL_SECRET_ID=$(onecli secrets list 2>/dev/null | grep -B2 "Vercel" | grep '"id"' | head -1 | sed 's/.*"id": "//;s/".*//')
|
||||
for agent in $(onecli agents list 2>/dev/null | grep '"id"' | sed 's/.*"id": "//;s/".*//'); do
|
||||
CURRENT=$(onecli agents secrets --id "$agent" 2>/dev/null | grep '"' | grep -v hint | grep -v data | sed 's/.*"//;s/".*//' | tr '\n' ',' | sed 's/,$//')
|
||||
onecli agents set-secrets --id "$agent" --secret-ids "${CURRENT:+$CURRENT,}$VERCEL_SECRET_ID"
|
||||
done
|
||||
```
|
||||
|
||||
## Phase 4: Ensure Vercel CLI in Container Image
|
||||
|
||||
The Vercel CLI is installed globally in the agent image via `container/Dockerfile`. Check for both halves of the install — the pinned version arg and the install line:
|
||||
Check if `vercel` is already in the Dockerfile:
|
||||
|
||||
```bash
|
||||
grep -Eq '^ARG VERCEL_VERSION=' container/Dockerfile && \
|
||||
grep -Eq 'pnpm install -g "?vercel@\$\{VERCEL_VERSION\}"?' container/Dockerfile && \
|
||||
echo "PRESENT" || echo "MISSING"
|
||||
grep -q 'vercel' container/Dockerfile && echo "PRESENT" || echo "MISSING"
|
||||
```
|
||||
|
||||
If `MISSING`, add a pinned `ARG VERCEL_VERSION=52.2.1` near the other version args and a `pnpm install -g "vercel@${VERCEL_VERSION}"` step in the global-install block of `container/Dockerfile`, then rebuild the image:
|
||||
If `MISSING`, add `vercel` to the global npm install line in `container/Dockerfile`, then rebuild:
|
||||
|
||||
```bash
|
||||
./container/build.sh
|
||||
```
|
||||
|
||||
If `PRESENT`, the CLI is already in the image — skip the rebuild.
|
||||
|
||||
## Phase 4b: Copy and Run the Dependency Guard
|
||||
|
||||
The Vercel CLI is a globally-installed binary — not importable or typed — so a structural test guards the Dockerfile install. Copy it into the host test tree and run it:
|
||||
|
||||
```bash
|
||||
cp .claude/skills/add-vercel/vercel-dockerfile.test.ts src/vercel-dockerfile.test.ts
|
||||
pnpm exec vitest run src/vercel-dockerfile.test.ts
|
||||
```
|
||||
|
||||
The test parses `container/Dockerfile` and asserts both the `ARG VERCEL_VERSION=...` and the `pnpm install -g "vercel@${VERCEL_VERSION}"` line are present. It goes red if either is dropped or drifts.
|
||||
If `PRESENT`, skip — no rebuild needed.
|
||||
|
||||
## Phase 5: Sync Skills to Running Agent Groups
|
||||
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
/**
|
||||
* Dependency guard for the Vercel CLI integration point (host tree, vitest).
|
||||
*
|
||||
* add-vercel installs the `vercel` CLI globally in the agent container image via
|
||||
* `container/Dockerfile`. A globally-installed CLI binary is not importable or
|
||||
* typed, so neither `tsc` nor a runtime import can catch its removal — only the
|
||||
* container image build would, and the skill's validate step does not rebuild the
|
||||
* image in CI. This structural test stands in for that build leg: it parses the
|
||||
* Dockerfile and asserts both halves of the install are present — the pinned
|
||||
* `ARG VERCEL_VERSION=...` and the `pnpm install -g "vercel@${VERCEL_VERSION}"`
|
||||
* line. Drop or drift either and this goes red.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
function dockerfile(): string {
|
||||
// Walk up from this test file to the repo root (the dir holding container/Dockerfile),
|
||||
// so the test works wherever it is copied (src/ on the host, or the skill folder).
|
||||
let dir = __dirname;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const candidate = path.join(dir, 'container', 'Dockerfile');
|
||||
if (fs.existsSync(candidate)) return fs.readFileSync(candidate, 'utf8');
|
||||
dir = path.dirname(dir);
|
||||
}
|
||||
throw new Error('container/Dockerfile not found walking up from ' + __dirname);
|
||||
}
|
||||
|
||||
describe('container/Dockerfile installs the Vercel CLI', () => {
|
||||
const text = dockerfile();
|
||||
|
||||
it('declares a pinned VERCEL_VERSION build arg', () => {
|
||||
expect(text).toMatch(/^ARG\s+VERCEL_VERSION=\S+/m);
|
||||
});
|
||||
|
||||
it('globally installs the pinned vercel package via pnpm', () => {
|
||||
expect(text).toMatch(/pnpm install -g\s+"?vercel@\$\{VERCEL_VERSION\}"?/);
|
||||
});
|
||||
});
|
||||
@@ -1,40 +1,6 @@
|
||||
# Remove Webex
|
||||
# Remove Webex Channel
|
||||
|
||||
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 './webex.js';
|
||||
```
|
||||
|
||||
Then delete the copied adapter and its registration test:
|
||||
|
||||
```bash
|
||||
rm -f src/channels/webex.ts src/channels/webex-registration.test.ts
|
||||
```
|
||||
|
||||
## 2. Remove credentials
|
||||
|
||||
Remove `WEBEX_BOT_TOKEN` and `WEBEX_WEBHOOK_SECRET` 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 @bitbasti/chat-adapter-webex
|
||||
```
|
||||
|
||||
## 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. Comment out `import './webex.js'` in `src/channels/index.ts`
|
||||
2. Remove `WEBEX_BOT_TOKEN` and `WEBEX_WEBHOOK_SECRET` from `.env`
|
||||
3. `pnpm uninstall @bitbasti/chat-adapter-webex`
|
||||
4. Rebuild and restart
|
||||
|
||||
@@ -16,7 +16,6 @@ NanoClaw doesn't ship channels in trunk. This skill copies the Webex adapter in
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
|
||||
- `src/channels/webex.ts` exists
|
||||
- `src/channels/webex-registration.test.ts` exists
|
||||
- `src/channels/index.ts` contains `import './webex.js';`
|
||||
- `@bitbasti/chat-adapter-webex` is listed in `package.json` dependencies
|
||||
|
||||
@@ -28,11 +27,10 @@ Otherwise continue. Every step below is safe to re-run.
|
||||
git fetch origin channels
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and its registration test
|
||||
### 2. Copy the adapter
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/webex.ts > src/channels/webex.ts
|
||||
git show origin/channels:src/channels/webex-registration.test.ts > src/channels/webex-registration.test.ts
|
||||
git show origin/channels:src/channels/webex.ts > src/channels/webex.ts
|
||||
```
|
||||
|
||||
### 3. Append the self-registration import
|
||||
@@ -49,17 +47,12 @@ import './webex.js';
|
||||
pnpm install @bitbasti/chat-adapter-webex@0.1.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
### 5. Build
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
pnpm exec vitest run src/channels/webex-registration.test.ts
|
||||
```
|
||||
|
||||
Both must be clean before proceeding. `webex-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `webex`. It goes red if the `import './webex.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@bitbasti/chat-adapter-webex` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
|
||||
|
||||
End-to-end message delivery against a real Webex space is verified manually once the service is running — see Next Steps and the webhook setup above.
|
||||
|
||||
## Credentials
|
||||
|
||||
1. Go to [developer.webex.com](https://developer.webex.com/my-apps/new/bot) and create a new bot
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Verify Webex Channel
|
||||
|
||||
Add the bot to a Webex space or send it a direct message. The bot should respond within a few seconds.
|
||||
@@ -1,42 +1,36 @@
|
||||
# Remove WeChat Channel
|
||||
|
||||
Every step is idempotent — safe to re-run.
|
||||
Undo `/add-wechat`.
|
||||
|
||||
## 1. Remove the adapter
|
||||
### 1. Remove credentials
|
||||
|
||||
Delete the self-registration import from `src/channels/index.ts` (skip if already gone):
|
||||
|
||||
```typescript
|
||||
import './wechat.js';
|
||||
```
|
||||
|
||||
Then delete the copied adapter and its registration test:
|
||||
Delete WeChat lines from `.env`:
|
||||
|
||||
```bash
|
||||
rm -f src/channels/wechat.ts src/channels/wechat-registration.test.ts
|
||||
sed -i.bak '/^WECHAT_ENABLED=/d' .env && rm -f .env.bak
|
||||
cp .env data/env/env
|
||||
```
|
||||
|
||||
## 2. Remove credentials
|
||||
|
||||
Remove `WECHAT_ENABLED` from `.env`, then re-sync to the container:
|
||||
### 2. Remove adapter and import
|
||||
|
||||
```bash
|
||||
mkdir -p data/env && cp .env data/env/env
|
||||
rm -f src/channels/wechat.ts
|
||||
sed -i.bak "/import '\.\/wechat\.js';/d" src/channels/index.ts && rm -f src/channels/index.ts.bak
|
||||
```
|
||||
|
||||
## 3. Remove the package
|
||||
### 3. Uninstall the package
|
||||
|
||||
```bash
|
||||
pnpm uninstall wechat-ilink-client
|
||||
pnpm remove wechat-ilink-client
|
||||
```
|
||||
|
||||
## 4. Remove saved auth + sync state
|
||||
### 4. Remove saved auth + sync state
|
||||
|
||||
```bash
|
||||
rm -rf data/wechat
|
||||
```
|
||||
|
||||
## 5. Remove DB wiring
|
||||
### 5. Remove DB wiring
|
||||
|
||||
```sql
|
||||
-- Remove any sessions first (foreign key)
|
||||
@@ -45,13 +39,11 @@ DELETE FROM messaging_group_agents WHERE messaging_group_id IN (SELECT id FROM m
|
||||
DELETE FROM messaging_groups WHERE channel_type = 'wechat';
|
||||
```
|
||||
|
||||
## 6. Rebuild and restart
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
### 6. 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)
|
||||
systemctl --user restart nanoclaw # Linux
|
||||
# or
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
```
|
||||
|
||||
@@ -29,7 +29,6 @@ NanoClaw doesn't ship channels in trunk. This skill copies the WeChat adapter in
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
|
||||
- `src/channels/wechat.ts` exists
|
||||
- `src/channels/wechat-registration.test.ts` exists
|
||||
- `src/channels/index.ts` contains `import './wechat.js';`
|
||||
- `wechat-ilink-client` is listed in `package.json` dependencies
|
||||
|
||||
@@ -41,11 +40,10 @@ Otherwise continue. Every step below is safe to re-run.
|
||||
git fetch origin channels
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and its registration test
|
||||
### 2. Copy the adapter
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/wechat.ts > src/channels/wechat.ts
|
||||
git show origin/channels:src/channels/wechat-registration.test.ts > src/channels/wechat-registration.test.ts
|
||||
git show origin/channels:src/channels/wechat.ts > src/channels/wechat.ts
|
||||
```
|
||||
|
||||
### 3. Append the self-registration import
|
||||
@@ -62,17 +60,12 @@ import './wechat.js';
|
||||
pnpm install wechat-ilink-client@0.1.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
### 5. Build
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
pnpm exec vitest run src/channels/wechat-registration.test.ts
|
||||
```
|
||||
|
||||
Both must be clean before proceeding. `wechat-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `wechat`. It goes red if the `import './wechat.js';` line is deleted or drifts, if the barrel fails to evaluate (so the channel genuinely would not register), or if `wechat-ilink-client` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. Importing is safe: the adapter opens its long-poll connection only in `setup()` (at host startup), never at import.
|
||||
|
||||
End-to-end message delivery against a real WeChat account is verified manually once the service is running — see Credentials and Wire your first DM above.
|
||||
|
||||
## Credentials
|
||||
|
||||
Unlike most channels, WeChat requires **no pre-configured API keys**. Auth happens via QR code scan from your phone.
|
||||
@@ -89,15 +82,12 @@ Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
|
||||
### 2. Start the service and scan the QR
|
||||
|
||||
Restart NanoClaw.
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
Restart NanoClaw:
|
||||
|
||||
```bash
|
||||
source setup/lib/install-slug.sh
|
||||
systemctl --user restart $(systemd_unit) # Linux
|
||||
systemctl --user restart nanoclaw # Linux
|
||||
# or
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
```
|
||||
|
||||
The adapter will print a **QR URL** to the logs and save it to `data/wechat/qr.txt`:
|
||||
|
||||
@@ -1,40 +1,6 @@
|
||||
# Remove WhatsApp Cloud API Channel
|
||||
|
||||
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 './whatsapp-cloud.js';
|
||||
```
|
||||
|
||||
Then delete the copied adapter and its registration test:
|
||||
|
||||
```bash
|
||||
rm -f src/channels/whatsapp-cloud.ts src/channels/whatsapp-cloud-registration.test.ts
|
||||
```
|
||||
|
||||
## 2. Remove credentials
|
||||
|
||||
Remove `WHATSAPP_ACCESS_TOKEN`, `WHATSAPP_PHONE_NUMBER_ID`, `WHATSAPP_APP_SECRET`, and `WHATSAPP_VERIFY_TOKEN` 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/whatsapp
|
||||
```
|
||||
|
||||
## 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. Comment out `import './whatsapp-cloud.js'` in `src/channels/index.ts`
|
||||
2. Remove `WHATSAPP_ACCESS_TOKEN`, `WHATSAPP_PHONE_NUMBER_ID`, `WHATSAPP_APP_SECRET`, `WHATSAPP_VERIFY_TOKEN` from `.env`
|
||||
3. `pnpm uninstall @chat-adapter/whatsapp`
|
||||
4. Rebuild and restart
|
||||
|
||||
@@ -16,7 +16,6 @@ NanoClaw doesn't ship channels in trunk. This skill copies the WhatsApp Cloud ad
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
|
||||
- `src/channels/whatsapp-cloud.ts` exists
|
||||
- `src/channels/whatsapp-cloud-registration.test.ts` exists
|
||||
- `src/channels/index.ts` contains `import './whatsapp-cloud.js';`
|
||||
- `@chat-adapter/whatsapp` is listed in `package.json` dependencies
|
||||
|
||||
@@ -28,11 +27,10 @@ Otherwise continue. Every step below is safe to re-run.
|
||||
git fetch origin channels
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and its registration test
|
||||
### 2. Copy the adapter
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/whatsapp-cloud.ts > src/channels/whatsapp-cloud.ts
|
||||
git show origin/channels:src/channels/whatsapp-cloud-registration.test.ts > src/channels/whatsapp-cloud-registration.test.ts
|
||||
git show origin/channels:src/channels/whatsapp-cloud.ts > src/channels/whatsapp-cloud.ts
|
||||
```
|
||||
|
||||
### 3. Append the self-registration import
|
||||
@@ -46,20 +44,15 @@ import './whatsapp-cloud.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/whatsapp@4.27.0
|
||||
pnpm install @chat-adapter/whatsapp@4.26.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
### 5. Build
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
pnpm exec vitest run src/channels/whatsapp-cloud-registration.test.ts
|
||||
```
|
||||
|
||||
Both must be clean before proceeding. `whatsapp-cloud-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `whatsapp-cloud`. It goes red if the `import './whatsapp-cloud.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/whatsapp` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
|
||||
|
||||
End-to-end message delivery against a real WhatsApp Business number is verified manually once the service is running — see Next Steps and the webhook setup above.
|
||||
|
||||
## Credentials
|
||||
|
||||
1. Go to [Meta for Developers](https://developers.facebook.com/apps/) and create an app (type: Business).
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Verify WhatsApp Cloud API Channel
|
||||
|
||||
Send a message to your WhatsApp Business number. The bot should respond within a few seconds. Note: WhatsApp Cloud API only supports 1:1 DMs, not group chats.
|
||||
@@ -1,71 +0,0 @@
|
||||
# Remove WhatsApp
|
||||
|
||||
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 './whatsapp.js';
|
||||
```
|
||||
|
||||
Then delete the copied adapter, its registration test, and its unit test:
|
||||
|
||||
```bash
|
||||
rm -f src/channels/whatsapp.ts src/channels/whatsapp-registration.test.ts src/channels/whatsapp.test.ts
|
||||
```
|
||||
|
||||
## 2. Remove the setup steps
|
||||
|
||||
Delete these entries from the `STEPS` map in `setup/index.ts` (skip lines already gone):
|
||||
|
||||
```typescript
|
||||
groups: () => import('./groups.js'),
|
||||
'whatsapp-auth': () => import('./whatsapp-auth.js'),
|
||||
```
|
||||
|
||||
> Keep `groups: ...` if another installed channel relies on the `groups` setup step. Only the `'whatsapp-auth':` entry is WhatsApp-specific.
|
||||
|
||||
Then delete the copied setup step files:
|
||||
|
||||
```bash
|
||||
rm -f setup/whatsapp-auth.ts
|
||||
```
|
||||
|
||||
> Keep `setup/groups.ts` if another installed channel relies on it.
|
||||
|
||||
## 3. Remove credentials
|
||||
|
||||
Remove `ASSISTANT_HAS_OWN_NUMBER` from `.env` (only present if a dedicated number was configured), then re-sync to the container:
|
||||
|
||||
```bash
|
||||
mkdir -p data/env && cp .env data/env/env
|
||||
```
|
||||
|
||||
## 4. Remove the packages
|
||||
|
||||
```bash
|
||||
pnpm uninstall @whiskeysockets/baileys qrcode @types/qrcode pino
|
||||
```
|
||||
|
||||
## 5. 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)
|
||||
```
|
||||
|
||||
## 6. Remove auth state (optional)
|
||||
|
||||
To fully remove the linked-device authentication and session state:
|
||||
|
||||
```bash
|
||||
rm -rf store/auth/
|
||||
```
|
||||
|
||||
> **Warning:** This unlinks the device. Re-installing WhatsApp requires re-pairing from your phone via QR or pairing code (see SKILL.md Credentials).
|
||||
|
||||
To keep the linked device for a later reinstall, leave `store/auth/` intact.
|
||||
@@ -16,13 +16,10 @@ NanoClaw doesn't ship channels in trunk. This skill copies the native WhatsApp (
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
|
||||
- `src/channels/whatsapp.ts` exists
|
||||
- `src/channels/whatsapp-registration.test.ts` exists
|
||||
- `src/channels/whatsapp.test.ts` exists
|
||||
- `src/channels/index.ts` contains `import './whatsapp.js';`
|
||||
- `setup/whatsapp-auth.ts` and `setup/groups.ts` both exist
|
||||
- `setup/index.ts`'s `STEPS` map contains both `'whatsapp-auth':` and `groups:`
|
||||
- `@whiskeysockets/baileys`, `qrcode`, `pino` are listed in `package.json` dependencies
|
||||
- `.claude/skills/add-whatsapp/scripts/wa-qr-browser.ts` exists (ships with this skill)
|
||||
|
||||
Otherwise continue. Every step below is safe to re-run.
|
||||
|
||||
@@ -35,11 +32,9 @@ git fetch origin channels
|
||||
### 2. Copy the adapter and setup steps
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/whatsapp.ts > src/channels/whatsapp.ts
|
||||
git show origin/channels:src/channels/whatsapp-registration.test.ts > src/channels/whatsapp-registration.test.ts
|
||||
git show origin/channels:src/channels/whatsapp.test.ts > src/channels/whatsapp.test.ts
|
||||
git show origin/channels:setup/whatsapp-auth.ts > setup/whatsapp-auth.ts
|
||||
git show origin/channels:setup/groups.ts > setup/groups.ts
|
||||
git show origin/channels:src/channels/whatsapp.ts > src/channels/whatsapp.ts
|
||||
git show origin/channels:setup/whatsapp-auth.ts > setup/whatsapp-auth.ts
|
||||
git show origin/channels:setup/groups.ts > setup/groups.ts
|
||||
```
|
||||
|
||||
### 3. Append the self-registration import
|
||||
@@ -62,20 +57,15 @@ groups: () => import('./groups.js'),
|
||||
### 5. Install the adapter packages (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @whiskeysockets/baileys@7.0.0-rc.9 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
|
||||
pnpm install @whiskeysockets/baileys@6.17.16 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
|
||||
```
|
||||
|
||||
### 6. Build and validate
|
||||
### 6. Build
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
pnpm exec vitest run src/channels/whatsapp-registration.test.ts
|
||||
```
|
||||
|
||||
Both must be clean before proceeding. `whatsapp-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `whatsapp`. It goes red if the `import './whatsapp.js';` line is deleted or drifts, if the barrel fails to evaluate (so the channel genuinely would not register), or if `@whiskeysockets/baileys` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 5.
|
||||
|
||||
End-to-end message delivery against a real WhatsApp number is verified manually once the service is running — see Credentials, Wiring, and Troubleshooting.
|
||||
|
||||
## Credentials
|
||||
|
||||
WhatsApp uses linked-device authentication — no API key, just a one-time pairing from your phone.
|
||||
@@ -105,7 +95,7 @@ If IS_HEADLESS=true AND not WSL → AskUserQuestion: How do you want to authenti
|
||||
- **QR code in terminal** - Displays QR code in the terminal (can be too small on some displays)
|
||||
|
||||
Otherwise (macOS, desktop Linux, or WSL) → AskUserQuestion: How do you want to authenticate WhatsApp?
|
||||
- **QR code in browser** (Recommended) - Runs a small local HTTP server that renders the rotating QR as a PNG and auto-opens your default browser
|
||||
- **QR code in browser** (Recommended) - Opens a browser window with a large, scannable QR code
|
||||
- **Pairing code** - Enter a numeric code on your phone (no camera needed, requires phone number)
|
||||
- **QR code in terminal** - Displays QR code in the terminal (can be too small on some displays)
|
||||
|
||||
@@ -124,13 +114,11 @@ rm -rf store/auth/
|
||||
For QR code in browser (recommended):
|
||||
|
||||
```bash
|
||||
pnpm exec tsx .claude/skills/add-whatsapp/scripts/wa-qr-browser.ts
|
||||
pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method qr-browser
|
||||
```
|
||||
|
||||
(Bash timeout: 150000ms)
|
||||
|
||||
The wrapper spawns `setup/index.ts --step whatsapp-auth -- --method qr`, parses each rotating QR from its `WHATSAPP_AUTH_QR` status blocks, and serves the current QR as a PNG on a local HTTP server (default port `8765`, falls back to a free port). Flags: `--clean` (wipes `store/auth/` before spawning) and `--port N`.
|
||||
|
||||
Tell the user:
|
||||
|
||||
> A browser window will open with a QR code.
|
||||
@@ -142,13 +130,11 @@ Tell the user:
|
||||
For QR code in terminal:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method qr
|
||||
pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method qr-terminal
|
||||
```
|
||||
|
||||
(Bash timeout: 150000ms)
|
||||
|
||||
The setup driver emits each rotating QR as a `WHATSAPP_AUTH_QR` status block; when run directly (not through `setup:auto`) the raw QR string is printed and your terminal must render it as ASCII. If your terminal can't render it readably, use the browser method above.
|
||||
|
||||
Tell the user:
|
||||
|
||||
> 1. Open WhatsApp > **Settings** > **Linked Devices** > **Link a Device**
|
||||
@@ -214,7 +200,7 @@ Otherwise, run `/manage-channels` to wire this channel to an agent group.
|
||||
|
||||
- **type**: `whatsapp`
|
||||
- **terminology**: WhatsApp calls them "groups" and "chats." A "chat" is a 1:1 DM; a "group" has multiple members.
|
||||
- **how-to-find-id**: DMs use `<phone>@s.whatsapp.net` (e.g. `14155551234@s.whatsapp.net`). Groups use `<id>@g.us`. To find your number: `node -e "const c=JSON.parse(require('fs').readFileSync('store/auth/creds.json','utf-8'));console.log(c.me?.id?.split(':')[0]+'@s.whatsapp.net')"`. Groups are auto-discovered — check `pnpm exec tsx scripts/q.ts data/v2.db "SELECT platform_id, name FROM messaging_groups WHERE channel_type='whatsapp' AND is_group=1"`.
|
||||
- **how-to-find-id**: DMs use `<phone>@s.whatsapp.net` (e.g. `14155551234@s.whatsapp.net`). Groups use `<id>@g.us`. To find your number: `node -e "const c=JSON.parse(require('fs').readFileSync('store/auth/creds.json','utf-8'));console.log(c.me?.id?.split(':')[0]+'@s.whatsapp.net')"`. Groups are auto-discovered — check `sqlite3 data/v2.db "SELECT platform_id, name FROM messaging_groups WHERE channel_type='whatsapp' AND is_group=1"`.
|
||||
- **supports-threads**: no
|
||||
- **typical-use**: Interactive chat — direct messages or small groups
|
||||
- **default-isolation**: Same agent group if you're the only participant across multiple chats. Separate agent group if different people are in different groups.
|
||||
@@ -234,10 +220,10 @@ Not supported (WhatsApp linked device limitation): edit messages, delete message
|
||||
|
||||
### QR code expired
|
||||
|
||||
QR codes expire after ~60 seconds. The browser wrapper rotates automatically as long as it's running; if it was stopped, re-run with `--clean`:
|
||||
QR codes expire after ~60 seconds. Re-run the auth command:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx .claude/skills/add-whatsapp/scripts/wa-qr-browser.ts --clean
|
||||
rm -rf store/auth/ && pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method qr-browser
|
||||
```
|
||||
|
||||
### Pairing code not working
|
||||
@@ -250,31 +236,28 @@ rm -rf store/auth/ && pnpm exec tsx setup/index.ts --step whatsapp-auth -- --met
|
||||
|
||||
Ensure: digits only (no `+`), phone has internet, WhatsApp is updated.
|
||||
|
||||
WhatsApp's pairing-code flow occasionally rejects valid codes with "Couldn't link device — An error happened. Please try again." This is a server-side rejection unrelated to the code itself; we've seen it happen twice in a row on fresh dedicated numbers. If you hit it more than once, switch to QR-browser auth — it has a noticeably higher success rate:
|
||||
If pairing code keeps failing, switch to QR-browser auth instead:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx .claude/skills/add-whatsapp/scripts/wa-qr-browser.ts --clean
|
||||
rm -rf store/auth/ && pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method qr-browser
|
||||
```
|
||||
|
||||
### "waiting for this message" on reactions
|
||||
|
||||
Signal sessions corrupted from rapid restarts. Clear sessions.
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
Signal sessions corrupted from rapid restarts. Clear sessions:
|
||||
|
||||
```bash
|
||||
source setup/lib/install-slug.sh
|
||||
systemctl --user stop $(systemd_unit)
|
||||
systemctl --user stop nanoclaw
|
||||
rm store/auth/session-*.json
|
||||
systemctl --user start $(systemd_unit)
|
||||
systemctl --user start nanoclaw
|
||||
```
|
||||
|
||||
### Bot not responding
|
||||
|
||||
1. Auth exists: `test -f store/auth/creds.json`
|
||||
2. Connected: `grep "Connected to WhatsApp" logs/nanoclaw.log | tail -1`
|
||||
3. Channel wired: `pnpm exec tsx scripts/q.ts data/v2.db "SELECT mg.platform_id, mg.name FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id=mga.messaging_group_id WHERE mg.channel_type='whatsapp'"`
|
||||
4. Service running: `systemctl --user status "$(. setup/lib/install-slug.sh && systemd_unit)"`
|
||||
3. Channel wired: `sqlite3 data/v2.db "SELECT mg.platform_id, mg.name FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id=mga.messaging_group_id WHERE mg.channel_type='whatsapp'"`
|
||||
4. Service running: `systemctl --user status nanoclaw`
|
||||
|
||||
### "conflict" disconnection
|
||||
|
||||
|
||||
@@ -1,246 +0,0 @@
|
||||
/**
|
||||
* scripts/wa-qr-browser.ts — serve WhatsApp pairing QR in the browser.
|
||||
*
|
||||
* Wraps `setup/index.ts --step whatsapp-auth -- --method qr` and renders the
|
||||
* rotating QR string as a PNG in a small local HTTP page. Avoids the unreadable
|
||||
* ASCII terminal QR. macOS / desktop-Linux only — no headless support needed.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm exec tsx scripts/wa-qr-browser.ts [--clean] [--port 8765]
|
||||
*
|
||||
* --clean rm -rf store/auth/ before spawning the auth step.
|
||||
* --port N bind to port N (default 8765, falls back to a free port).
|
||||
*/
|
||||
import { spawn, exec } from 'node:child_process';
|
||||
import http from 'node:http';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import QRCode from 'qrcode';
|
||||
|
||||
type Status = 'waiting' | 'ready' | 'success' | 'failed';
|
||||
type State = {
|
||||
qr: string | null;
|
||||
status: Status;
|
||||
error?: string;
|
||||
version: number;
|
||||
};
|
||||
|
||||
const state: State = { qr: null, status: 'waiting', version: 0 };
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const clean = args.includes('--clean');
|
||||
const portIdx = args.indexOf('--port');
|
||||
const requestedPort = portIdx >= 0 ? Number(args[portIdx + 1]) : 8765;
|
||||
|
||||
if (clean) {
|
||||
fs.rmSync(path.join(process.cwd(), 'store', 'auth'), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
console.log('[wa-qr-browser] cleaned store/auth/');
|
||||
}
|
||||
|
||||
function htmlPage(): string {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>WhatsApp pairing</title>
|
||||
<style>
|
||||
body { margin: 0; min-height: 100vh; display: grid; place-items: center;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: #0b141a; color: #e9edef; }
|
||||
.card { background: #202c33; padding: 32px 40px; border-radius: 16px;
|
||||
box-shadow: 0 12px 36px rgba(0,0,0,0.4); text-align: center;
|
||||
min-width: 420px; }
|
||||
h1 { font-size: 18px; font-weight: 500; margin: 0 0 20px; color: #aebac1; }
|
||||
.qr-wrap { background: white; padding: 16px; border-radius: 12px;
|
||||
display: inline-block; }
|
||||
#qr { width: 360px; height: 360px; display: block; image-rendering: pixelated; }
|
||||
#status { margin-top: 20px; font-size: 14px; color: #8696a0; min-height: 20px; }
|
||||
#status.ok { color: #00d26a; font-size: 18px; font-weight: 500; }
|
||||
#status.err { color: #ff6b6b; }
|
||||
ol { text-align: left; color: #aebac1; font-size: 13px; line-height: 1.8;
|
||||
margin: 20px 0 0; padding-left: 20px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>Scan with WhatsApp</h1>
|
||||
<div class="qr-wrap"><img id="qr" alt="QR code" /></div>
|
||||
<div id="status">Waiting for QR…</div>
|
||||
<ol>
|
||||
<li>Open WhatsApp on your phone</li>
|
||||
<li>Settings → Linked Devices → Link a Device</li>
|
||||
<li>Point the camera at this QR code</li>
|
||||
</ol>
|
||||
</div>
|
||||
<script>
|
||||
let lastVersion = -1;
|
||||
const qr = document.getElementById('qr');
|
||||
const status = document.getElementById('status');
|
||||
async function tick() {
|
||||
try {
|
||||
const r = await fetch('/qr.json', { cache: 'no-store' });
|
||||
const s = await r.json();
|
||||
if (s.status === 'success') {
|
||||
qr.style.display = 'none';
|
||||
status.className = 'ok';
|
||||
status.textContent = '✓ Authenticated!';
|
||||
return;
|
||||
}
|
||||
if (s.status === 'failed') {
|
||||
qr.style.display = 'none';
|
||||
status.className = 'err';
|
||||
status.textContent = '✗ ' + (s.error || 'failed');
|
||||
return;
|
||||
}
|
||||
if (s.qr && s.version !== lastVersion) {
|
||||
lastVersion = s.version;
|
||||
qr.src = '/qr.png?v=' + s.version;
|
||||
status.textContent = 'QR ready — scan within ~20s';
|
||||
}
|
||||
} catch (e) { /* server closing, ignore */ }
|
||||
setTimeout(tick, 1500);
|
||||
}
|
||||
tick();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = req.url ?? '/';
|
||||
if (url === '/' || url.startsWith('/?')) {
|
||||
res.setHeader('content-type', 'text/html; charset=utf-8');
|
||||
res.end(htmlPage());
|
||||
return;
|
||||
}
|
||||
if (url === '/qr.json') {
|
||||
res.setHeader('content-type', 'application/json');
|
||||
res.setHeader('cache-control', 'no-store');
|
||||
res.end(JSON.stringify(state));
|
||||
return;
|
||||
}
|
||||
if (url.startsWith('/qr.png')) {
|
||||
if (!state.qr) {
|
||||
res.statusCode = 404;
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const buf = await QRCode.toBuffer(state.qr, { width: 360, margin: 1 });
|
||||
res.setHeader('content-type', 'image/png');
|
||||
res.setHeader('cache-control', 'no-store');
|
||||
res.end(buf);
|
||||
} catch (e) {
|
||||
res.statusCode = 500;
|
||||
res.end(String(e));
|
||||
}
|
||||
return;
|
||||
}
|
||||
res.statusCode = 404;
|
||||
res.end();
|
||||
});
|
||||
|
||||
function listen(port: number): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once('error', (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'EADDRINUSE' && port === requestedPort) {
|
||||
server.listen(0, () => {
|
||||
const addr = server.address();
|
||||
if (addr && typeof addr === 'object') resolve(addr.port);
|
||||
else reject(new Error('unexpected address'));
|
||||
});
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
server.listen(port, () => {
|
||||
const addr = server.address();
|
||||
if (addr && typeof addr === 'object') resolve(addr.port);
|
||||
else reject(new Error('unexpected address'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const port = await listen(requestedPort);
|
||||
const url = `http://localhost:${port}`;
|
||||
console.log(`[wa-qr-browser] QR server on ${url}`);
|
||||
|
||||
const opener = process.platform === 'darwin' ? 'open' : 'xdg-open';
|
||||
exec(`${opener} ${url}`, (err) => {
|
||||
if (err) console.log(`[wa-qr-browser] could not auto-open browser: ${err.message}`);
|
||||
else console.log('[wa-qr-browser] opening browser…');
|
||||
});
|
||||
|
||||
const child = spawn(
|
||||
'pnpm',
|
||||
['exec', 'tsx', 'setup/index.ts', '--step', 'whatsapp-auth', '--', '--method', 'qr'],
|
||||
{ stdio: ['inherit', 'pipe', 'inherit'] },
|
||||
);
|
||||
|
||||
let stdoutBuf = '';
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
const text = chunk.toString();
|
||||
process.stdout.write(text);
|
||||
stdoutBuf += text;
|
||||
|
||||
const blockRe = /=== NANOCLAW SETUP: (\w+) ===\n([\s\S]*?)\n=== END ===/g;
|
||||
let m: RegExpExecArray | null;
|
||||
let lastEnd = 0;
|
||||
while ((m = blockRe.exec(stdoutBuf)) !== null) {
|
||||
const [, name, body] = m;
|
||||
const fields: Record<string, string> = {};
|
||||
for (const line of body.split('\n')) {
|
||||
const kv = line.match(/^(\w+):\s*(.*)$/);
|
||||
if (kv) fields[kv[1]] = kv[2];
|
||||
}
|
||||
handleBlock(name, fields);
|
||||
lastEnd = m.index + m[0].length;
|
||||
}
|
||||
if (lastEnd > 0) stdoutBuf = stdoutBuf.slice(lastEnd);
|
||||
});
|
||||
|
||||
function handleBlock(name: string, fields: Record<string, string>): void {
|
||||
if (name === 'WHATSAPP_AUTH_QR' && fields.QR) {
|
||||
state.qr = fields.QR;
|
||||
state.status = 'ready';
|
||||
state.version++;
|
||||
return;
|
||||
}
|
||||
if (name === 'WHATSAPP_AUTH') {
|
||||
if (fields.STATUS === 'success') {
|
||||
state.status = 'success';
|
||||
console.log('[wa-qr-browser] authenticated');
|
||||
setTimeout(() => server.close(() => process.exit(0)), 3000);
|
||||
} else if (fields.STATUS === 'skipped') {
|
||||
state.status = 'success';
|
||||
state.error = `already authenticated (${fields.REASON ?? 'unknown'})`;
|
||||
console.log(`[wa-qr-browser] ${state.error}`);
|
||||
setTimeout(() => server.close(() => process.exit(0)), 3000);
|
||||
} else if (fields.STATUS === 'failed') {
|
||||
state.status = 'failed';
|
||||
state.error = fields.ERROR ?? 'unknown error';
|
||||
console.error(`[wa-qr-browser] failed: ${state.error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
child.on('exit', (code) => {
|
||||
if (state.status === 'success') return;
|
||||
if (state.status !== 'failed') {
|
||||
state.status = 'failed';
|
||||
state.error = `auth process exited (code=${code ?? 'null'})`;
|
||||
}
|
||||
setTimeout(() => {
|
||||
server.close(() => process.exit(1));
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\n[wa-qr-browser] aborting…');
|
||||
child.kill('SIGTERM');
|
||||
server.close(() => process.exit(130));
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
---
|
||||
name: claw
|
||||
description: Install the claw CLI tool — run NanoClaw agent containers from the command line without opening a chat app.
|
||||
---
|
||||
|
||||
# claw — NanoClaw CLI
|
||||
|
||||
`claw` is a Python CLI that sends prompts directly to a NanoClaw agent container from the terminal. It reads registered groups from the NanoClaw database, picks up secrets from `.env`, and pipes a JSON payload into a container run — no chat app required.
|
||||
|
||||
## What it does
|
||||
|
||||
- Send a prompt to any registered group by name, folder, or JID
|
||||
- Default target is the main group (no `-g` needed for most use)
|
||||
- Resume a previous session with `-s <session-id>`
|
||||
- Read prompts from stdin (`--pipe`) for scripting and piping
|
||||
- List all registered groups with `--list-groups`
|
||||
- Auto-detects `container` or `docker` runtime (or override with `--runtime`)
|
||||
- Prints the agent's response to stdout; session ID to stderr
|
||||
- Verbose mode (`-v`) shows the command, redacted payload, and exit code
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.8 or later
|
||||
- NanoClaw installed with a built and tagged container image (`nanoclaw-agent:latest`)
|
||||
- Either `container` (Apple Container, macOS 15+) or `docker` available in `PATH`
|
||||
|
||||
## Install
|
||||
|
||||
Run this skill from within the NanoClaw directory. The script auto-detects its location, so the symlink always points to the right place.
|
||||
|
||||
### 1. Copy the script
|
||||
|
||||
```bash
|
||||
mkdir -p scripts
|
||||
cp "${CLAUDE_SKILL_DIR}/scripts/claw" scripts/claw
|
||||
chmod +x scripts/claw
|
||||
```
|
||||
|
||||
### 2. Symlink into PATH
|
||||
|
||||
```bash
|
||||
mkdir -p ~/bin
|
||||
ln -sf "$(pwd)/scripts/claw" ~/bin/claw
|
||||
```
|
||||
|
||||
Make sure `~/bin` is in `PATH`. Add this to `~/.zshrc` or `~/.bashrc` if needed:
|
||||
|
||||
```bash
|
||||
export PATH="$HOME/bin:$PATH"
|
||||
```
|
||||
|
||||
Then reload the shell:
|
||||
|
||||
```bash
|
||||
source ~/.zshrc # or ~/.bashrc
|
||||
```
|
||||
|
||||
### 3. Verify
|
||||
|
||||
```bash
|
||||
claw --list-groups
|
||||
```
|
||||
|
||||
You should see registered groups. If NanoClaw isn't running or the database doesn't exist yet, the list will be empty — that's fine.
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Send a prompt to the main group
|
||||
claw "What's on my calendar today?"
|
||||
|
||||
# Send to a specific group by name (fuzzy match)
|
||||
claw -g "family" "Remind everyone about dinner at 7"
|
||||
|
||||
# Send to a group by exact JID
|
||||
claw -j "120363336345536173@g.us" "Hello"
|
||||
|
||||
# Resume a previous session
|
||||
claw -s abc123 "Continue where we left off"
|
||||
|
||||
# Read prompt from stdin
|
||||
echo "Summarize this" | claw --pipe -g dev
|
||||
|
||||
# Pipe a file
|
||||
cat report.txt | claw --pipe "Summarize this report"
|
||||
|
||||
# List all registered groups
|
||||
claw --list-groups
|
||||
|
||||
# Force a specific runtime
|
||||
claw --runtime docker "Hello"
|
||||
|
||||
# Use a custom image tag (e.g. after rebuilding with a new tag)
|
||||
claw --image nanoclaw-agent:dev "Hello"
|
||||
|
||||
# Verbose mode (debug info, secrets redacted)
|
||||
claw -v "Hello"
|
||||
|
||||
# Custom timeout for long-running tasks
|
||||
claw --timeout 600 "Run the full analysis"
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "neither 'container' nor 'docker' found"
|
||||
|
||||
Install Docker Desktop or Apple Container (macOS 15+), or pass `--runtime` explicitly.
|
||||
|
||||
### "no secrets found in .env"
|
||||
|
||||
The script auto-detects your NanoClaw directory and reads `.env` from it. Check that the file exists and contains at least one of: `CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`.
|
||||
|
||||
### Container times out
|
||||
|
||||
The default timeout is 300 seconds. For longer tasks, pass `--timeout 600` (or higher). If the container consistently hangs, check that your `nanoclaw-agent:latest` image is up to date by running `./container/build.sh`.
|
||||
|
||||
### "group not found"
|
||||
|
||||
Run `claw --list-groups` to see what's registered. Group lookup does a fuzzy partial match on name and folder — if your query matches multiple groups, you'll get an error listing the ambiguous matches.
|
||||
|
||||
### Container crashes mid-stream
|
||||
|
||||
Containers run with `--rm` so they are automatically removed. If the agent crashes before emitting the output sentinel, `claw` falls back to printing raw stdout. Use `-v` to see what the container produced. Rebuild the image with `./container/build.sh` if crashes are consistent.
|
||||
|
||||
### Override the NanoClaw directory
|
||||
|
||||
If `claw` can't find your database or `.env`, set the `NANOCLAW_DIR` environment variable:
|
||||
|
||||
```bash
|
||||
export NANOCLAW_DIR=/path/to/your/nanoclaw
|
||||
```
|
||||
@@ -0,0 +1,374 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
claw — NanoClaw CLI
|
||||
Run a NanoClaw agent container from the command line.
|
||||
|
||||
Usage:
|
||||
claw "What is 2+2?"
|
||||
claw -g <channel_name> "Review this code"
|
||||
claw -g "<channel name with spaces>" "What's the latest issue?"
|
||||
claw -j "<chatJid>" "Hello"
|
||||
claw -g <channel_name> -s <session-id> "Continue"
|
||||
claw --list-groups
|
||||
echo "prompt text" | claw --pipe -g <channel_name>
|
||||
cat prompt.txt | claw --pipe
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
# ── Globals ─────────────────────────────────────────────────────────────────
|
||||
|
||||
VERBOSE = False
|
||||
|
||||
def dbg(*args):
|
||||
if VERBOSE:
|
||||
print("»", *args, file=sys.stderr)
|
||||
|
||||
# ── Config ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _find_nanoclaw_dir() -> Path:
|
||||
"""Locate the NanoClaw installation directory.
|
||||
|
||||
Resolution order:
|
||||
1. NANOCLAW_DIR env var
|
||||
2. The directory containing this script (if it looks like a NanoClaw install)
|
||||
3. ~/src/nanoclaw (legacy default)
|
||||
"""
|
||||
if env := os.environ.get("NANOCLAW_DIR"):
|
||||
return Path(env).expanduser()
|
||||
# If this script lives inside the NanoClaw tree (e.g. scripts/claw), walk up
|
||||
here = Path(__file__).resolve()
|
||||
for parent in [here.parent, here.parent.parent]:
|
||||
if (parent / "store" / "messages.db").exists() or (parent / ".env").exists():
|
||||
return parent
|
||||
return Path.home() / "src" / "nanoclaw"
|
||||
|
||||
NANOCLAW_DIR = _find_nanoclaw_dir()
|
||||
DB_PATH = NANOCLAW_DIR / "store" / "messages.db"
|
||||
ENV_FILE = NANOCLAW_DIR / ".env"
|
||||
IMAGE = "nanoclaw-agent:latest"
|
||||
|
||||
SECRET_KEYS = [
|
||||
"CLAUDE_CODE_OAUTH_TOKEN",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_BASE_URL",
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"OLLAMA_HOST",
|
||||
]
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def detect_runtime(preference: str | None) -> str:
|
||||
if preference:
|
||||
dbg(f"runtime: forced to {preference}")
|
||||
return preference
|
||||
for rt in ("container", "docker"):
|
||||
result = subprocess.run(["which", rt], capture_output=True)
|
||||
if result.returncode == 0:
|
||||
dbg(f"runtime: auto-detected {rt} at {result.stdout.decode().strip()}")
|
||||
return rt
|
||||
sys.exit("error: neither 'container' nor 'docker' found. Install one or pass --runtime.")
|
||||
|
||||
|
||||
def read_secrets(env_file: Path) -> dict:
|
||||
secrets = {}
|
||||
if not env_file.exists():
|
||||
return secrets
|
||||
for line in env_file.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if "=" in line:
|
||||
key, _, val = line.partition("=")
|
||||
key = key.strip()
|
||||
if key in SECRET_KEYS:
|
||||
secrets[key] = val.strip()
|
||||
return secrets
|
||||
|
||||
|
||||
def get_groups(db: Path) -> list[dict]:
|
||||
conn = sqlite3.connect(db)
|
||||
rows = conn.execute(
|
||||
"SELECT jid, name, folder, is_main FROM registered_groups ORDER BY name"
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return [{"jid": r[0], "name": r[1], "folder": r[2], "is_main": bool(r[3])} for r in rows]
|
||||
|
||||
|
||||
def find_group(groups: list[dict], query: str) -> dict | None:
|
||||
q = query.lower()
|
||||
# Exact name match
|
||||
for g in groups:
|
||||
if g["name"].lower() == q or g["folder"].lower() == q:
|
||||
return g
|
||||
# Partial match
|
||||
matches = [g for g in groups if q in g["name"].lower() or q in g["folder"].lower()]
|
||||
if len(matches) == 1:
|
||||
return matches[0]
|
||||
if len(matches) > 1:
|
||||
names = ", ".join(f'"{g["name"]}"' for g in matches)
|
||||
sys.exit(f"error: ambiguous group '{query}'. Matches: {names}")
|
||||
return None
|
||||
|
||||
|
||||
def build_mounts(folder: str, is_main: bool) -> list[tuple[str, str, bool]]:
|
||||
"""Return list of (host_path, container_path, readonly) tuples."""
|
||||
groups_dir = NANOCLAW_DIR / "groups"
|
||||
data_dir = NANOCLAW_DIR / "data"
|
||||
sessions_dir = data_dir / "sessions" / folder
|
||||
ipc_dir = data_dir / "ipc" / folder
|
||||
|
||||
# Ensure required dirs exist
|
||||
group_dir = groups_dir / folder
|
||||
group_dir.mkdir(parents=True, exist_ok=True)
|
||||
(sessions_dir / ".claude").mkdir(parents=True, exist_ok=True)
|
||||
for sub in ("messages", "tasks", "input"):
|
||||
(ipc_dir / sub).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
agent_runner_src = sessions_dir / "agent-runner-src"
|
||||
project_agent_runner = NANOCLAW_DIR / "container" / "agent-runner" / "src"
|
||||
if not agent_runner_src.exists() and project_agent_runner.exists():
|
||||
import shutil
|
||||
shutil.copytree(project_agent_runner, agent_runner_src)
|
||||
|
||||
mounts: list[tuple[str, str, bool]] = []
|
||||
if is_main:
|
||||
mounts.append((str(NANOCLAW_DIR), "/workspace/project", True))
|
||||
mounts.append((str(group_dir), "/workspace/group", False))
|
||||
mounts.append((str(sessions_dir / ".claude"), "/home/node/.claude", False))
|
||||
mounts.append((str(ipc_dir), "/workspace/ipc", False))
|
||||
if agent_runner_src.exists():
|
||||
mounts.append((str(agent_runner_src), "/app/src", False))
|
||||
return mounts
|
||||
|
||||
|
||||
def run_container(runtime: str, image: str, payload: dict,
|
||||
folder: str | None = None, is_main: bool = False,
|
||||
timeout: int = 300) -> None:
|
||||
cmd = [runtime, "run", "-i", "--rm"]
|
||||
if folder:
|
||||
for host, container, readonly in build_mounts(folder, is_main):
|
||||
if readonly:
|
||||
cmd += ["--mount", f"type=bind,source={host},target={container},readonly"]
|
||||
else:
|
||||
cmd += ["-v", f"{host}:{container}"]
|
||||
cmd.append(image)
|
||||
dbg(f"cmd: {' '.join(cmd)}")
|
||||
|
||||
# Show payload sans secrets
|
||||
if VERBOSE:
|
||||
safe = {k: v for k, v in payload.items() if k != "secrets"}
|
||||
safe["secrets"] = {k: "***" for k in payload.get("secrets", {})}
|
||||
dbg(f"payload: {json.dumps(safe, indent=2)}")
|
||||
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
dbg(f"container pid: {proc.pid}")
|
||||
|
||||
# Write JSON payload and close stdin
|
||||
proc.stdin.write(json.dumps(payload).encode())
|
||||
proc.stdin.close()
|
||||
dbg("stdin closed, waiting for response...")
|
||||
|
||||
stdout_lines: list[str] = []
|
||||
stderr_lines: list[str] = []
|
||||
done = threading.Event()
|
||||
|
||||
def stream_stderr():
|
||||
for raw in proc.stderr:
|
||||
line = raw.decode(errors="replace").rstrip()
|
||||
if line.startswith("npm notice"):
|
||||
continue
|
||||
stderr_lines.append(line)
|
||||
print(line, file=sys.stderr)
|
||||
|
||||
def stream_stdout():
|
||||
for raw in proc.stdout:
|
||||
line = raw.decode(errors="replace").rstrip()
|
||||
stdout_lines.append(line)
|
||||
dbg(f"stdout: {line}")
|
||||
# Kill the container as soon as we see the closing sentinel —
|
||||
# the Node.js event loop often keeps the process alive indefinitely.
|
||||
if line.strip() == "---NANOCLAW_OUTPUT_END---":
|
||||
dbg("output sentinel found, terminating container")
|
||||
done.set()
|
||||
try:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
dbg("graceful stop timed out, force killing container")
|
||||
proc.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
return
|
||||
|
||||
t_err = threading.Thread(target=stream_stderr, daemon=True)
|
||||
t_out = threading.Thread(target=stream_stdout, daemon=True)
|
||||
t_err.start()
|
||||
t_out.start()
|
||||
|
||||
# Wait for sentinel or timeout
|
||||
if not done.wait(timeout=timeout):
|
||||
# Also check if process exited naturally
|
||||
t_out.join(timeout=2)
|
||||
if not done.is_set():
|
||||
proc.kill()
|
||||
sys.exit(f"error: container timed out after {timeout}s (no output sentinel received)")
|
||||
|
||||
t_err.join(timeout=5)
|
||||
t_out.join(timeout=5)
|
||||
proc.wait()
|
||||
dbg(f"container done (rc={proc.returncode}), {len(stdout_lines)} stdout lines")
|
||||
stdout = "\n".join(stdout_lines)
|
||||
|
||||
# Parse output block
|
||||
match = re.search(
|
||||
r"---NANOCLAW_OUTPUT_START---\n(.*?)\n---NANOCLAW_OUTPUT_END---",
|
||||
stdout,
|
||||
re.DOTALL,
|
||||
)
|
||||
success = False
|
||||
|
||||
if match:
|
||||
try:
|
||||
data = json.loads(match.group(1))
|
||||
status = data.get("status", "unknown")
|
||||
if status == "success":
|
||||
print(data.get("result", ""))
|
||||
session_id = data.get("newSessionId") or data.get("sessionId")
|
||||
if session_id:
|
||||
print(f"\n[session: {session_id}]", file=sys.stderr)
|
||||
success = True
|
||||
else:
|
||||
print(f"[{status}] {data.get('result', '')}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except json.JSONDecodeError:
|
||||
print(match.group(1))
|
||||
else:
|
||||
# No structured output — print raw stdout
|
||||
print(stdout)
|
||||
|
||||
if success:
|
||||
return
|
||||
|
||||
if proc.returncode not in (0, None):
|
||||
sys.exit(proc.returncode)
|
||||
|
||||
|
||||
# ── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="claw",
|
||||
description="Run a NanoClaw agent from the command line.",
|
||||
)
|
||||
parser.add_argument("prompt", nargs="?", help="Prompt to send")
|
||||
parser.add_argument("-g", "--group", help="Group name or folder (fuzzy match)")
|
||||
parser.add_argument("-j", "--jid", help="Chat JID (exact)")
|
||||
parser.add_argument("-s", "--session", help="Session ID to resume")
|
||||
parser.add_argument("-p", "--pipe", action="store_true",
|
||||
help="Read prompt from stdin (can be combined with a prompt arg as prefix)")
|
||||
parser.add_argument("--runtime", choices=["docker", "container"],
|
||||
help="Container runtime (default: auto-detect)")
|
||||
parser.add_argument("--image", default=IMAGE, help=f"Container image (default: {IMAGE})")
|
||||
parser.add_argument("--list-groups", action="store_true", help="List registered groups and exit")
|
||||
parser.add_argument("--raw", action="store_true", help="Print raw JSON output")
|
||||
parser.add_argument("--timeout", type=int, default=300, metavar="SECS",
|
||||
help="Max seconds to wait for a response (default: 300)")
|
||||
parser.add_argument("-v", "--verbose", action="store_true",
|
||||
help="Show debug info: cmd, payload (secrets redacted), stdout lines, exit code")
|
||||
args = parser.parse_args()
|
||||
|
||||
global VERBOSE
|
||||
VERBOSE = args.verbose
|
||||
|
||||
groups = get_groups(DB_PATH) if DB_PATH.exists() else []
|
||||
|
||||
if args.list_groups:
|
||||
print(f"{'NAME':<35} {'FOLDER':<30} {'JID'}")
|
||||
print("-" * 100)
|
||||
for g in groups:
|
||||
main_tag = " [main]" if g["is_main"] else ""
|
||||
print(f"{g['name']:<35} {g['folder']:<30} {g['jid']}{main_tag}")
|
||||
return
|
||||
|
||||
# Resolve prompt: --pipe reads stdin, optionally prepended with positional arg
|
||||
if args.pipe or (not sys.stdin.isatty() and not args.prompt):
|
||||
stdin_text = sys.stdin.read().strip()
|
||||
if args.prompt:
|
||||
prompt = f"{args.prompt}\n\n{stdin_text}"
|
||||
else:
|
||||
prompt = stdin_text
|
||||
else:
|
||||
prompt = args.prompt
|
||||
|
||||
if not prompt:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
# Resolve group → jid
|
||||
jid = args.jid
|
||||
group_name = None
|
||||
group_folder = None
|
||||
is_main = False
|
||||
|
||||
if args.group:
|
||||
g = find_group(groups, args.group)
|
||||
if g is None:
|
||||
sys.exit(f"error: group '{args.group}' not found. Run --list-groups to see options.")
|
||||
jid = g["jid"]
|
||||
group_name = g["name"]
|
||||
group_folder = g["folder"]
|
||||
is_main = g["is_main"]
|
||||
elif not jid:
|
||||
# Default: main group
|
||||
mains = [g for g in groups if g["is_main"]]
|
||||
if mains:
|
||||
jid = mains[0]["jid"]
|
||||
group_name = mains[0]["name"]
|
||||
group_folder = mains[0]["folder"]
|
||||
is_main = True
|
||||
else:
|
||||
sys.exit("error: no group specified and no main group found. Use -g or -j.")
|
||||
|
||||
runtime = detect_runtime(args.runtime)
|
||||
secrets = read_secrets(ENV_FILE)
|
||||
|
||||
if not secrets:
|
||||
print("warning: no secrets found in .env — agent may not be authenticated", file=sys.stderr)
|
||||
|
||||
payload: dict = {
|
||||
"prompt": prompt,
|
||||
"chatJid": jid,
|
||||
"isMain": is_main,
|
||||
"secrets": secrets,
|
||||
}
|
||||
if group_name:
|
||||
payload["groupFolder"] = group_name
|
||||
if args.session:
|
||||
payload["sessionId"] = args.session
|
||||
payload["resumeAt"] = "latest"
|
||||
|
||||
print(f"[{group_name or jid}] running via {runtime}...", file=sys.stderr)
|
||||
run_container(runtime, args.image, payload,
|
||||
folder=group_folder, is_main=is_main,
|
||||
timeout=args.timeout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,212 @@
|
||||
---
|
||||
name: convert-to-apple-container
|
||||
description: Switch from Docker to Apple Container for macOS-native container isolation. Use when the user wants Apple Container instead of Docker, or is setting up on macOS and prefers the native runtime. Triggers on "apple container", "convert to apple container", "switch to apple container", or "use apple container".
|
||||
---
|
||||
|
||||
# Convert to Apple Container
|
||||
|
||||
This skill switches NanoClaw's container runtime from Docker to Apple Container (macOS-only). It uses the skills engine for deterministic code changes, then walks through verification.
|
||||
|
||||
**What this changes:**
|
||||
- Container runtime binary: `docker` → `container`
|
||||
- Mount syntax: `-v path:path:ro` → `--mount type=bind,source=...,target=...,readonly`
|
||||
- Startup check: `docker info` → `container system status` (with auto-start)
|
||||
- Orphan detection: `docker ps --filter` → `container ls --format json`
|
||||
- Build script default: `docker` → `container`
|
||||
- Dockerfile entrypoint: `.env` shadowing via `mount --bind` inside the container (Apple Container only supports directory mounts, not file mounts like Docker's `/dev/null` overlay)
|
||||
- Container runner: main-group containers start as root for `mount --bind`, then drop privileges via `setpriv`
|
||||
|
||||
**What stays the same:**
|
||||
- Mount security/allowlist validation
|
||||
- All exported interfaces and IPC protocol
|
||||
- Non-main container behavior (still uses `--user` flag)
|
||||
- All other functionality
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Verify Apple Container is installed:
|
||||
|
||||
```bash
|
||||
container --version && echo "Apple Container ready" || echo "Install Apple Container first"
|
||||
```
|
||||
|
||||
If not installed:
|
||||
- Download from https://github.com/apple/container/releases
|
||||
- Install the `.pkg` file
|
||||
- Verify: `container --version`
|
||||
|
||||
Apple Container requires macOS. It does not work on Linux.
|
||||
|
||||
## Phase 1: Pre-flight
|
||||
|
||||
### Check if already applied
|
||||
|
||||
```bash
|
||||
grep "CONTAINER_RUNTIME_BIN" src/container-runtime.ts
|
||||
```
|
||||
|
||||
If it already shows `'container'`, the runtime is already Apple Container. Skip to Phase 4.
|
||||
|
||||
## Phase 2: Apply Code Changes
|
||||
|
||||
### Ensure upstream remote
|
||||
|
||||
```bash
|
||||
git remote -v
|
||||
```
|
||||
|
||||
If `upstream` is missing, add it:
|
||||
|
||||
```bash
|
||||
git remote add upstream https://github.com/qwibitai/nanoclaw.git
|
||||
```
|
||||
|
||||
### Merge the skill branch
|
||||
|
||||
```bash
|
||||
git fetch upstream skill/apple-container
|
||||
git merge upstream/skill/apple-container
|
||||
```
|
||||
|
||||
This merges in:
|
||||
- `src/container-runtime.ts` — Apple Container implementation (replaces Docker)
|
||||
- `src/container-runtime.test.ts` — Apple Container-specific tests
|
||||
- `src/container-runner.ts` — .env shadow mount fix and privilege dropping
|
||||
- `container/Dockerfile` — entrypoint that shadows .env via `mount --bind`
|
||||
- `container/build.sh` — default runtime set to `container`
|
||||
|
||||
If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides.
|
||||
|
||||
### Validate code changes
|
||||
|
||||
```bash
|
||||
pnpm test
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
All tests must pass and build must be clean before proceeding.
|
||||
|
||||
## Phase 3: Credential proxy network binding
|
||||
|
||||
Apple Container uses a bridge network (bridge100) that only exists while containers are running. The credential proxy must start before any container, so it cannot bind to the bridge IP. It must bind to `0.0.0.0`, which exposes port 3001 on all network interfaces — anyone on your local network could route API requests through the proxy using your credentials.
|
||||
|
||||
Use AskUserQuestion to ask the user:
|
||||
|
||||
**"The credential proxy needs to bind to all interfaces (0.0.0.0). Is this Mac on a trusted private network?"**
|
||||
|
||||
Options:
|
||||
1. **Yes, private/home network** — description: "No firewall rule needed."
|
||||
2. **No, shared/public network** — description: "Add a macOS firewall rule to block external access to port 3001."
|
||||
|
||||
For both options, add `CREDENTIAL_PROXY_HOST=0.0.0.0` to `.env`:
|
||||
|
||||
```bash
|
||||
grep -q 'CREDENTIAL_PROXY_HOST' .env 2>/dev/null || echo 'CREDENTIAL_PROXY_HOST=0.0.0.0' >> .env
|
||||
```
|
||||
|
||||
If they chose the public network option, set up and persist the firewall rule:
|
||||
|
||||
```bash
|
||||
echo "block in on en0 proto tcp to any port 3001" | sudo pfctl -ef -
|
||||
```
|
||||
|
||||
```bash
|
||||
grep -q 'nanoclaw proxy' /etc/pf.conf 2>/dev/null || echo '# nanoclaw proxy — block LAN access to credential proxy
|
||||
block in on en0 proto tcp to any port 3001' | sudo tee -a /etc/pf.conf > /dev/null
|
||||
```
|
||||
|
||||
Verify the rule is working:
|
||||
|
||||
```bash
|
||||
curl -sf http://$(ipconfig getifaddr en0):3001 && echo "EXPOSED — rule not working" || echo "BLOCKED — rule active"
|
||||
```
|
||||
|
||||
If the verification shows "EXPOSED", warn the user and retry. If "BLOCKED", confirm success and continue.
|
||||
|
||||
## Phase 4: Verify
|
||||
|
||||
### Ensure Apple Container runtime is running
|
||||
|
||||
```bash
|
||||
container system status || container system start
|
||||
```
|
||||
|
||||
### Build the container image
|
||||
|
||||
```bash
|
||||
./container/build.sh
|
||||
```
|
||||
|
||||
### Test basic execution
|
||||
|
||||
```bash
|
||||
echo '{}' | container run -i --entrypoint /bin/echo nanoclaw-agent:latest "Container OK"
|
||||
```
|
||||
|
||||
### Test readonly mounts
|
||||
|
||||
```bash
|
||||
mkdir -p /tmp/test-ro && echo "test" > /tmp/test-ro/file.txt
|
||||
container run --rm --entrypoint /bin/bash \
|
||||
--mount type=bind,source=/tmp/test-ro,target=/test,readonly \
|
||||
nanoclaw-agent:latest \
|
||||
-c "cat /test/file.txt && touch /test/new.txt 2>&1 || echo 'Write blocked (expected)'"
|
||||
rm -rf /tmp/test-ro
|
||||
```
|
||||
|
||||
Expected: Read succeeds, write fails with "Read-only file system".
|
||||
|
||||
### Test read-write mounts
|
||||
|
||||
```bash
|
||||
mkdir -p /tmp/test-rw
|
||||
container run --rm --entrypoint /bin/bash \
|
||||
-v /tmp/test-rw:/test \
|
||||
nanoclaw-agent:latest \
|
||||
-c "echo 'test write' > /test/new.txt && cat /test/new.txt"
|
||||
cat /tmp/test-rw/new.txt && rm -rf /tmp/test-rw
|
||||
```
|
||||
|
||||
Expected: Both operations succeed.
|
||||
|
||||
### Full integration test
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
|
||||
```
|
||||
|
||||
Send a message via WhatsApp and verify the agent responds.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Apple Container not found:**
|
||||
- Download from https://github.com/apple/container/releases
|
||||
- Install the `.pkg` file
|
||||
- Verify: `container --version`
|
||||
|
||||
**Runtime won't start:**
|
||||
```bash
|
||||
container system start
|
||||
container system status
|
||||
```
|
||||
|
||||
**Image build fails:**
|
||||
```bash
|
||||
# Clean rebuild — Apple Container caches aggressively
|
||||
container builder stop && container builder rm && container builder start
|
||||
./container/build.sh
|
||||
```
|
||||
|
||||
**Container can't write to mounted directories:**
|
||||
Check directory permissions on the host. The container runs as uid 1000.
|
||||
|
||||
## Summary of Changed Files
|
||||
|
||||
| File | Type of Change |
|
||||
|------|----------------|
|
||||
| `src/container-runtime.ts` | Full replacement — Docker → Apple Container API |
|
||||
| `src/container-runtime.test.ts` | Full replacement — tests for Apple Container behavior |
|
||||
| `src/container-runner.ts` | .env shadow mount removed, main containers start as root with privilege drop |
|
||||
| `container/Dockerfile` | Entrypoint: `mount --bind` for .env shadowing, `setpriv` privilege drop |
|
||||
| `container/build.sh` | Default runtime: `docker` → `container` |
|
||||
@@ -9,118 +9,102 @@ This skill helps users add capabilities or modify behavior. Use AskUserQuestion
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Understand the request** — Ask clarifying questions.
|
||||
2. **Prefer a dedicated skill** — If a skill covers the request, invoke it instead of editing core by hand:
|
||||
- Channels: `/add-telegram`, `/add-slack`, `/add-discord`, `/add-whatsapp`, `/add-signal`, `/add-imessage`, and the rest of the `/add-<channel>` family.
|
||||
- Wiring channels to agents and isolation levels: `/manage-channels`.
|
||||
- Container directory access: `/manage-mounts`.
|
||||
- Agent providers (non-default): `/add-opencode`, `/add-codex`, `/add-ollama-provider`.
|
||||
- Integrations as MCP tools: `/add-gmail-tool`, `/add-gcal-tool`, `/add-ollama-tool`, etc.
|
||||
3. **Plan the changes** — Identify the v2 surface the change belongs to (entity model in the central DB, per-agent-group container config, per-group `CLAUDE.md`, or core code).
|
||||
4. **Implement** — Make the change on the right surface.
|
||||
5. **Test guidance** — Tell the user how to verify.
|
||||
|
||||
## Entity Model
|
||||
|
||||
Customizations route through the v2 entity model: users → messaging groups → agent groups → sessions. A messaging group is one chat/channel on one platform; an agent group holds the workspace, personality, and container config; a wiring links a messaging group to an agent group with a session mode and trigger rules. Inspect and edit all of this with the `ncl` admin CLI. See `docs/isolation-model.md` for the three isolation levels.
|
||||
1. **Understand the request** - Ask clarifying questions
|
||||
3. **Plan the changes** - Identify files to modify. If a skill exists for the request (e.g., `/add-telegram` for adding Telegram), invoke it instead of implementing manually.
|
||||
4. **Implement** - Make changes directly to the code
|
||||
5. **Test guidance** - Tell user how to verify
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/index.ts` | Entry point: init DB, migrations, channel adapters, delivery polls, sweep, shutdown |
|
||||
| `src/router.ts` | Inbound routing: messaging group → agent group → session → `inbound.db` → wake |
|
||||
| `src/delivery.ts` | Polls `outbound.db`, delivers via adapter, handles system actions |
|
||||
| `src/session-manager.ts` | Resolves sessions; opens `inbound.db` / `outbound.db`; heartbeat path |
|
||||
| `src/container-runner.ts` | Spawns per-agent-group containers with session DB + outbox mounts, OneCLI `ensureAgent` |
|
||||
| `src/channels/` | Channel adapter infra (registry, Chat SDK bridge); specific adapters install from the `channels` branch |
|
||||
| `src/config.ts` | Process-level config (assistant name, paths, timeouts) read from `.env` |
|
||||
| `data/v2.db` | Central DB: users, roles, agent_groups, messaging_groups, wirings, container_configs |
|
||||
| `data/v2-sessions/<session>/` | Per-session `inbound.db` (host→container) + `outbound.db` (container→host) |
|
||||
| `groups/<folder>/CLAUDE.md` | Per-agent-group memory/persona and instructions |
|
||||
|
||||
For ad-hoc DB queries, use `pnpm exec tsx scripts/q.ts <db> "<sql>"`.
|
||||
| `src/index.ts` | Orchestrator: state, message loop, agent invocation |
|
||||
| `src/channels/whatsapp.ts` | WhatsApp connection, auth, send/receive |
|
||||
| `src/ipc.ts` | IPC watcher and task processing |
|
||||
| `src/router.ts` | Message formatting and outbound routing |
|
||||
| `src/types.ts` | TypeScript interfaces (includes Channel) |
|
||||
| `src/config.ts` | Assistant name, trigger pattern, directories |
|
||||
| `src/db.ts` | Database initialization and queries |
|
||||
| `src/whatsapp-auth.ts` | Standalone WhatsApp authentication script |
|
||||
| `groups/CLAUDE.md` | Global memory/persona |
|
||||
|
||||
## Common Customization Patterns
|
||||
|
||||
### Adding a New Input Channel (e.g., Telegram, Slack, Email)
|
||||
|
||||
Questions to ask:
|
||||
- Which channel? (Telegram, Slack, Discord, WhatsApp, Signal, email, etc.)
|
||||
- Should this channel reach an existing agent group or a new one?
|
||||
- What isolation level — share an agent group with other channels, or keep it separate?
|
||||
- Same trigger rules as other channels on that agent group, or different?
|
||||
- Which channel? (Telegram, Slack, Discord, email, SMS, etc.)
|
||||
- Same trigger word or different?
|
||||
- Same memory hierarchy or separate?
|
||||
- Should messages from this channel go to existing groups or new ones?
|
||||
|
||||
Implementation:
|
||||
1. Run the matching install skill (`/add-telegram`, `/add-slack`, …). It fetches the adapter from the `channels` branch, wires the registration import, installs the pinned package, and builds.
|
||||
2. Run `/manage-channels` (or use `ncl messaging-groups` + `ncl wirings`) to create the messaging group, choose the isolation level, and wire it to an agent group with a session mode and trigger rules.
|
||||
Implementation pattern:
|
||||
1. Create `src/channels/{name}.ts` implementing the `Channel` interface from `src/types.ts` (see `src/channels/whatsapp.ts` for reference)
|
||||
2. Add the channel instance to `main()` in `src/index.ts` and wire callbacks (`onMessage`, `onChatMetadata`)
|
||||
3. Messages are stored via the `onMessage` callback; routing is automatic via `ownsJid()`
|
||||
|
||||
### Adding a New MCP Integration
|
||||
|
||||
Questions to ask:
|
||||
- What service? (Calendar, Notion, database, etc.)
|
||||
- What operations are needed? (read, write, both)
|
||||
- Which agent group should have access?
|
||||
- What operations needed? (read, write, both)
|
||||
- Which groups should have access?
|
||||
|
||||
Implementation:
|
||||
- If an `/add-<service>-tool` skill exists (e.g. `/add-gmail-tool`, `/add-gcal-tool`), run it — it wires the MCP server and routes credentials through OneCLI so no raw keys reach the container.
|
||||
- Otherwise wire the MCP server into the agent group's container config: `ncl groups config add-mcp-server --id <group-id> --name <name> --command <cmd> [--args <json-array>] [--env <json-object>]`, then `ncl groups restart --id <group-id>` to take effect. From inside a container the agent uses the `add_mcp_server` self-mod tool, which requires one admin approval.
|
||||
1. Add MCP server config to the container settings (see `src/container-runner.ts` for how MCP servers are mounted)
|
||||
2. Document available tools in `groups/CLAUDE.md`
|
||||
|
||||
### Changing Assistant Behavior
|
||||
|
||||
Questions to ask:
|
||||
- What aspect? (persona, response style, instructions)
|
||||
- Apply to one agent group or several?
|
||||
- What aspect? (name, trigger, persona, response style)
|
||||
- Apply to all groups or specific ones?
|
||||
|
||||
Implementation:
|
||||
- Persona, instructions, and personality live per agent group in `groups/<folder>/CLAUDE.md` — edit that file for the target group.
|
||||
- Container runtime behavior (provider, model, packages, MCP servers) lives in the `container_configs` table: `ncl groups config get/update --id <group-id>`.
|
||||
Simple changes → edit `src/config.ts`
|
||||
Persona changes → edit `groups/CLAUDE.md`
|
||||
Per-group behavior → edit specific group's `CLAUDE.md`
|
||||
|
||||
### Adding New Commands
|
||||
|
||||
Questions to ask:
|
||||
- What should the command do?
|
||||
- Which agent group(s)?
|
||||
- Available in all groups or main only?
|
||||
- Does it need new MCP tools?
|
||||
|
||||
Implementation:
|
||||
- The agent interprets requests naturally — add instructions to the agent group's `groups/<folder>/CLAUDE.md`.
|
||||
- For routing or trigger changes (which messages wake which agent group), update the wiring's trigger rules: `ncl wirings update --id <wiring-id> ...`.
|
||||
1. Commands are handled by the agent naturally — add instructions to `groups/CLAUDE.md` or the group's `CLAUDE.md`
|
||||
2. For trigger-level routing changes, modify `processGroupMessages()` in `src/index.ts`
|
||||
|
||||
### Changing Deployment
|
||||
|
||||
Questions to ask:
|
||||
- Target platform? (Linux server, different Mac)
|
||||
- Service manager? (launchd, systemd)
|
||||
- Target platform? (Linux server, Docker, different Mac)
|
||||
- Service manager? (systemd, Docker, supervisord)
|
||||
|
||||
Implementation:
|
||||
1. Create the appropriate service files.
|
||||
2. Update paths in `.env` / config.
|
||||
3. Provide setup instructions.
|
||||
1. Create appropriate service files
|
||||
2. Update paths in config
|
||||
3. Provide setup instructions
|
||||
|
||||
## After Changes
|
||||
|
||||
Always tell the user.
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
Always tell the user:
|
||||
```bash
|
||||
# Rebuild and restart
|
||||
pnpm run build
|
||||
source setup/lib/install-slug.sh
|
||||
# macOS:
|
||||
launchctl unload ~/Library/LaunchAgents/$(launchd_label).plist
|
||||
launchctl load ~/Library/LaunchAgents/$(launchd_label).plist
|
||||
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
|
||||
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
|
||||
# Linux:
|
||||
# systemctl --user restart $(systemd_unit)
|
||||
# systemctl --user restart nanoclaw
|
||||
```
|
||||
|
||||
## Example Interaction
|
||||
|
||||
User: "Add Telegram as an input channel"
|
||||
|
||||
1. Run `/add-telegram` to install the adapter, wire its registration, and build.
|
||||
2. Ask: "Should Telegram reach an existing agent group, or a new one?"
|
||||
3. Ask: "Share an agent group with your other channels, or keep Telegram separate?"
|
||||
4. Run `/manage-channels` (or `ncl messaging-groups create` + `ncl wirings create`) to create the messaging group and wire it to the chosen agent group with a session mode and trigger rules.
|
||||
5. Tell the user how to authenticate and test.
|
||||
1. Ask: "Should Telegram use the same @Andy trigger, or a different one?"
|
||||
2. Ask: "Should Telegram messages create separate conversation contexts, or share with WhatsApp groups?"
|
||||
3. Create `src/channels/telegram.ts` implementing the `Channel` interface (see `src/channels/whatsapp.ts`)
|
||||
4. Add the channel to `main()` in `src/index.ts`
|
||||
5. Tell user how to authenticate and test
|
||||
|
||||
+249
-197
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: debug
|
||||
description: Debug container agent issues. Use when things aren't working, container fails, authentication problems, or to understand how the container system works. Covers logs, session DBs, mounts, and common issues.
|
||||
description: Debug container agent issues. Use when things aren't working, container fails, authentication problems, or to understand how the container system works. Covers logs, environment variables, mounts, and common issues.
|
||||
---
|
||||
|
||||
# NanoClaw Container Debugging
|
||||
@@ -9,45 +9,35 @@ This guide covers debugging the containerized agent execution system.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The host is a single Node process that orchestrates per-session agent containers. The two session DBs are the **sole** IO surface between host and container — there is no IPC, no file watcher, and no stdin piping.
|
||||
|
||||
```
|
||||
Host (Node) Container (Bun, Linux VM)
|
||||
──────────────────────────────────────────────────────────────────────
|
||||
src/container-runner.ts container/agent-runner/src/
|
||||
│ │
|
||||
│ spawns one container per session │ polls inbound.db for work,
|
||||
│ with the session folder mounted │ calls the agent provider,
|
||||
│ at /workspace │ writes replies to outbound.db
|
||||
│ │
|
||||
├── data/v2-sessions/<group>/<session>/ ──> /workspace
|
||||
│ ├── inbound.db (host writes, container reads RO)
|
||||
│ ├── outbound.db (container writes, host reads)
|
||||
│ └── .heartbeat (container touches → /workspace/.heartbeat)
|
||||
├── groups/<folder> ─────────────────────> /workspace/agent (cwd)
|
||||
├── <group>/.claude-shared ──────────────> /home/node/.claude
|
||||
└── agent-runner src + skills ───────────> /app/src, /app/skills
|
||||
Host (macOS) Container (Linux VM)
|
||||
─────────────────────────────────────────────────────────────
|
||||
src/container-runner.ts container/agent-runner/
|
||||
│ │
|
||||
│ spawns container │ runs Claude Agent SDK
|
||||
│ with volume mounts │ with MCP servers
|
||||
│ │
|
||||
├── data/env/env ──────────────> /workspace/env-dir/env
|
||||
├── groups/{folder} ───────────> /workspace/group
|
||||
├── data/ipc/{folder} ────────> /workspace/ipc
|
||||
├── data/sessions/{folder}/.claude/ ──> /home/node/.claude/ (isolated per-group)
|
||||
└── (main only) project root ──> /workspace/project
|
||||
```
|
||||
|
||||
**Message flow:** host writes a row to `inbound.db` (`messages_in`) and wakes the container; the container's poll loop picks it up, runs the agent, and writes the reply to `outbound.db` (`messages_out`); the host's delivery poll reads `messages_out` and sends it through the channel adapter. See [docs/db.md](../../../docs/db.md) and [docs/db-session.md](../../../docs/db-session.md) for the full two-DB model.
|
||||
|
||||
**Container identity:** the container runs as user `node` with `HOME=/home/node`. Per-group Claude state (settings, session history) lives in `<group>/.claude-shared` on the host, mounted to `/home/node/.claude`.
|
||||
**Important:** The container runs as user `node` with `HOME=/home/node`. Session files must be mounted to `/home/node/.claude/` (not `/root/.claude/`) for session resumption to work.
|
||||
|
||||
## Log Locations
|
||||
|
||||
| Log | Location | Content |
|
||||
|-----|----------|---------|
|
||||
| **Host errors** | `logs/nanoclaw.error.log` | Delivery failures, crash-loop backoff, warnings — check this first |
|
||||
| **Host app log** | `logs/nanoclaw.log` | Full routing chain: inbound routing, container spawn/exit, delivery |
|
||||
| **Setup logs** | `logs/setup.log`, `logs/setup-steps/*.log` | Per-step install output (bootstrap, container, onecli, mounts, service) |
|
||||
| **Session inbound** | `data/v2-sessions/<group>/<session>/inbound.db` (`messages_in`) | Did the message reach the container? |
|
||||
| **Session outbound** | `data/v2-sessions/<group>/<session>/outbound.db` (`messages_out`) | Did the agent produce a reply? |
|
||||
|
||||
Containers run with `--rm`, so the container's own filesystem is gone after it exits. The host streams container **stderr** into `logs/nanoclaw.log` at debug level, tagged with `container=<group folder>`; raise the log level (below) to see it. If the agent silently failed inside an exited container, there is no persistent in-container log — reconstruct from the session DBs and the host log.
|
||||
| **Main app logs** | `logs/nanoclaw.log` | Host-side WhatsApp, routing, container spawning |
|
||||
| **Main app errors** | `logs/nanoclaw.error.log` | Host-side errors |
|
||||
| **Container run logs** | `groups/{folder}/logs/container-*.log` | Per-run: input, mounts, stderr, stdout |
|
||||
| **Claude sessions** | `~/.claude/projects/` | Claude Code session history |
|
||||
|
||||
## Enabling Debug Logging
|
||||
|
||||
Set `LOG_LEVEL=debug` for verbose output, including streamed container stderr:
|
||||
Set `LOG_LEVEL=debug` for verbose output:
|
||||
|
||||
```bash
|
||||
# For development
|
||||
@@ -60,238 +50,300 @@ LOG_LEVEL=debug pnpm run dev
|
||||
# Environment=LOG_LEVEL=debug
|
||||
```
|
||||
|
||||
Debug level shows full mount configurations, the container spawn command, and streamed container stderr lines.
|
||||
|
||||
## Inspecting Session DBs
|
||||
|
||||
The two session DBs are where the message flow lives. Use the in-tree query wrapper (it goes through the `better-sqlite3` dep that setup already installs, avoiding a dependency on the `sqlite3` CLI):
|
||||
|
||||
```bash
|
||||
# List sessions and their agent group / messaging group from the central DB
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "SELECT id, agent_group_id, messaging_group_id, status, container_status, last_active FROM sessions"
|
||||
|
||||
# Or via the admin CLI
|
||||
ncl sessions list
|
||||
|
||||
# Did the message reach the container? (inbound.db, host writes / container reads)
|
||||
pnpm exec tsx scripts/q.ts data/v2-sessions/<group>/<session>/inbound.db \
|
||||
"SELECT seq, kind, status, timestamp FROM messages_in ORDER BY seq DESC LIMIT 10"
|
||||
|
||||
# Did the agent produce a reply? (outbound.db, container writes / host reads)
|
||||
pnpm exec tsx scripts/q.ts data/v2-sessions/<group>/<session>/outbound.db \
|
||||
"SELECT seq, kind, timestamp FROM messages_out ORDER BY seq DESC LIMIT 10"
|
||||
|
||||
# Container-side processing status for each inbound message
|
||||
pnpm exec tsx scripts/q.ts data/v2-sessions/<group>/<session>/outbound.db \
|
||||
"SELECT message_id, status, status_changed FROM processing_ack ORDER BY status_changed DESC LIMIT 10"
|
||||
```
|
||||
|
||||
Reading the flow:
|
||||
- `messages_in` has the message but no matching `messages_out` → the container never produced a reply (check `processing_ack`, then `logs/nanoclaw.log` for spawn/exit and container stderr).
|
||||
- `messages_out` has a reply but the user never received it → a delivery problem (see issue 1 below).
|
||||
- `messages_in` is empty → routing never reached this session (check the router log lines and the central wiring with `ncl wirings list`).
|
||||
Debug level shows:
|
||||
- Full mount configurations
|
||||
- Container command arguments
|
||||
- Real-time container stderr
|
||||
|
||||
## Common Issues
|
||||
|
||||
### 1. "No adapter for channel type" / Messages silently lost (null platform_message_id)
|
||||
### 1. "Claude Code process exited with code 1"
|
||||
|
||||
**Symptom:** The bot stops replying. `logs/nanoclaw.error.log` shows repeated:
|
||||
**Check the container log file** in `groups/{folder}/logs/container-*.log`
|
||||
|
||||
Common causes:
|
||||
|
||||
#### Missing Authentication
|
||||
```
|
||||
WARN No adapter for channel type channelType="telegram"
|
||||
WARN No adapter for channel type channelType="signal"
|
||||
Invalid API key · Please run /login
|
||||
```
|
||||
The main log shows "Message delivered" entries with `platformMsgId=undefined` — meaning the delivery poll ran, found no adapter, and marked the message delivered without sending it.
|
||||
|
||||
**Root cause: two NanoClaw service instances running simultaneously.**
|
||||
|
||||
When a second service instance is active with a stale binary, it has no channel adapters registered. Its delivery poll races the working instance and wins — marking outbound messages delivered without ever sending them.
|
||||
|
||||
**Diagnosis:**
|
||||
**Fix:** Ensure `.env` file exists with either OAuth token or API key:
|
||||
```bash
|
||||
# Check for duplicate running instances
|
||||
ps aux | grep 'nanoclaw/dist/index.js' | grep -v grep
|
||||
|
||||
# Check which services are active (Linux)
|
||||
systemctl --user list-units 'nanoclaw*' --all
|
||||
|
||||
# Confirm channel adapters registered by the current process
|
||||
grep "Channel adapter started" logs/nanoclaw.log | tail -10
|
||||
cat .env # Should show one of:
|
||||
# CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-... (subscription)
|
||||
# ANTHROPIC_API_KEY=sk-ant-api03-... (pay-per-use)
|
||||
```
|
||||
|
||||
**Fix:**
|
||||
1. Identify which service has the correct binary and EnvironmentFile (the one whose log shows the expected channels — e.g. `signal`, `telegram`, `cli` — all started).
|
||||
2. Stop and disable the stale duplicate service:
|
||||
```bash
|
||||
systemctl --user stop nanoclaw.service # or whichever is the old one
|
||||
systemctl --user disable nanoclaw.service
|
||||
```
|
||||
3. If the remaining service unit is missing `EnvironmentFile`, add it:
|
||||
```bash
|
||||
# Edit the service unit — add this line under [Service]:
|
||||
# EnvironmentFile=/home/[user]/nanoclaw/.env
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user restart nanoclaw-v2-<id>.service
|
||||
```
|
||||
4. Verify only one instance runs: `ps aux | grep nanoclaw/dist/index.js | grep -v grep`
|
||||
#### Root User Restriction
|
||||
```
|
||||
--dangerously-skip-permissions cannot be used with root/sudo privileges
|
||||
```
|
||||
**Fix:** Container must run as non-root user. Check Dockerfile has `USER node`.
|
||||
|
||||
Messages marked delivered with a null `platform_message_id` are not automatically retried. Ask the user to resend.
|
||||
### 2. Environment Variables Not Passing
|
||||
|
||||
### 2. Container exits immediately / agent produces no reply
|
||||
**Runtime note:** Environment variables passed via `-e` may be lost when using `-i` (interactive/piped stdin).
|
||||
|
||||
A spawned container that exits without writing to `outbound.db` shows up in `logs/nanoclaw.log` as a `Container exited` line with a non-zero `code`, often preceded by streamed `container=<folder>` stderr (at debug level).
|
||||
**Workaround:** The system extracts only authentication variables (`CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_API_KEY`) from `.env` and mounts them for sourcing inside the container. Other env vars are not exposed.
|
||||
|
||||
**Authentication errors:** secrets are injected per request by the OneCLI gateway — none are passed in env vars or chat context. A `401` from an API whose credential is in the vault usually means the agent is in `selective` secret mode and that secret was never assigned:
|
||||
To verify env vars are reaching the container:
|
||||
```bash
|
||||
onecli agents list # check secretMode
|
||||
onecli agents set-secret-mode --id <agent-id> --mode all # inject all matching secrets
|
||||
echo '{}' | docker run -i \
|
||||
-v $(pwd)/data/env:/workspace/env-dir:ro \
|
||||
--entrypoint /bin/bash nanoclaw-agent:latest \
|
||||
-c 'export $(cat /workspace/env-dir/env | xargs); echo "OAuth: ${#CLAUDE_CODE_OAUTH_TOKEN} chars, API: ${#ANTHROPIC_API_KEY} chars"'
|
||||
```
|
||||
If the gateway itself is unreachable, the container runner refuses to spawn (`OneCLI gateway not applied — refusing to spawn container without credentials` in the host log). Confirm the gateway is up at `http://127.0.0.1:10254`.
|
||||
|
||||
**MCP server failures:** a misconfigured MCP server can abort the agent run. Look for MCP initialization errors in the streamed container stderr (`LOG_LEVEL=debug`).
|
||||
|
||||
### 3. Mount Issues
|
||||
|
||||
Session and group folders are bind-mounted into the container. To see the resolved mounts for a spawn, run with `LOG_LEVEL=debug` and read the spawn command in `logs/nanoclaw.log`, or grep the mount targets directly:
|
||||
**Container mount notes:**
|
||||
- Docker supports both `-v` and `--mount` syntax
|
||||
- Use `:ro` suffix for readonly mounts:
|
||||
```bash
|
||||
# Readonly
|
||||
-v /path:/container/path:ro
|
||||
|
||||
# Read-write
|
||||
-v /path:/container/path
|
||||
```
|
||||
|
||||
To check what's mounted inside a container:
|
||||
```bash
|
||||
grep -n "containerPath" src/container-runner.ts
|
||||
docker run --rm --entrypoint /bin/bash nanoclaw-agent:latest -c 'ls -la /workspace/'
|
||||
```
|
||||
|
||||
Expected mount targets inside the container:
|
||||
Expected structure:
|
||||
```
|
||||
/workspace ← session folder (inbound.db, outbound.db, .heartbeat, inbox/, outbox/)
|
||||
/workspace/agent ← agent group folder (cwd; CLAUDE.md, skills, working files)
|
||||
/home/node/.claude ← per-group .claude-shared (Claude state, settings, history)
|
||||
/app/src ← agent-runner source (read-only)
|
||||
/app/skills ← container skills (read-only)
|
||||
/workspace/
|
||||
├── env-dir/env # Environment file (CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY)
|
||||
├── group/ # Current group folder (cwd)
|
||||
├── project/ # Project root (main channel only)
|
||||
├── global/ # Global CLAUDE.md (non-main only)
|
||||
├── ipc/ # Inter-process communication
|
||||
│ ├── messages/ # Outgoing WhatsApp messages
|
||||
│ ├── tasks/ # Scheduled task commands
|
||||
│ ├── current_tasks.json # Read-only: scheduled tasks visible to this group
|
||||
│ └── available_groups.json # Read-only: WhatsApp groups for activation (main only)
|
||||
└── extra/ # Additional custom mounts
|
||||
```
|
||||
|
||||
To inspect what a fresh container sees:
|
||||
### 4. Permission Issues
|
||||
|
||||
The container runs as user `node` (uid 1000). Check ownership:
|
||||
```bash
|
||||
docker run --rm --entrypoint /bin/bash nanoclaw-agent:latest -c 'whoami; ls -la /workspace/ /app/'
|
||||
```
|
||||
All of `/workspace/` and `/app/` should be owned by `node`. Use `:ro` on a `-v` mount for read-only.
|
||||
|
||||
### 4. Heartbeat / stale-session detection
|
||||
|
||||
Liveness is a file `touch` on `/workspace/.heartbeat` (host path: `data/v2-sessions/<group>/<session>/.heartbeat`), not a DB write. The host sweep reads its mtime plus the `processing_ack` claim age to decide whether a container is alive or stale. A session stuck "processing" with a stale `.heartbeat` mtime means the container died mid-run:
|
||||
|
||||
```bash
|
||||
stat -f '%Sm' data/v2-sessions/<group>/<session>/.heartbeat # macOS
|
||||
stat -c '%y' data/v2-sessions/<group>/<session>/.heartbeat # Linux
|
||||
```
|
||||
|
||||
## Container CLI (`ncl`) inside a session
|
||||
|
||||
The agent reaches the central DB from inside the container via `ncl`, which uses the session DB transport (`container/agent-runner/src/cli/ncl.ts`). On the host, `ncl` connects over a Unix socket (`src/cli/socket-server.ts`). If `ncl` calls fail from inside a container, check the agent group's `cli_scope` in its container config:
|
||||
|
||||
```bash
|
||||
ncl groups config get --id <group-id> # look at cli_scope: disabled | group | global
|
||||
```
|
||||
|
||||
`disabled` rejects every `cli_request`; `group` scopes the agent to its own group's `groups`/`sessions`/`destinations`/`members`; `global` is unrestricted.
|
||||
|
||||
## Restarting a session's container
|
||||
|
||||
```bash
|
||||
# Restart all containers for an agent group
|
||||
ncl groups restart --id <group-id>
|
||||
|
||||
# Restart and rebuild the image first (after package/Dockerfile changes)
|
||||
ncl groups restart --id <group-id> --rebuild
|
||||
|
||||
# Restart and wake immediately with a message
|
||||
ncl groups restart --id <group-id> --message "on_wake test"
|
||||
```
|
||||
|
||||
Without `--message`, the container comes back on the next user message. From inside a container, `--id` is auto-filled and only the calling session restarts.
|
||||
|
||||
## Manual Container Probes
|
||||
|
||||
The container's entry point is `exec bun run /app/src/index.ts`; it talks only to the mounted session DBs, so there is no JSON to pipe in. To probe the image directly:
|
||||
|
||||
```bash
|
||||
# Interactive shell in the image
|
||||
docker run --rm -it --entrypoint /bin/bash nanoclaw-agent:latest
|
||||
|
||||
# Check the image contents
|
||||
docker run --rm --entrypoint /bin/bash nanoclaw-agent:latest -c '
|
||||
node --version
|
||||
bun --version
|
||||
ls /app/src/
|
||||
whoami
|
||||
ls -la /workspace/
|
||||
ls -la /app/
|
||||
'
|
||||
```
|
||||
|
||||
## Provider SDK Options
|
||||
All of `/workspace/` and `/app/` should be owned by `node`.
|
||||
|
||||
The default provider wraps the Claude Agent SDK in `container/agent-runner/src/providers/claude.ts`. The query is configured roughly as:
|
||||
### 5. Session Not Resuming / "Claude Code process exited with code 1"
|
||||
|
||||
If sessions aren't being resumed (new session ID every time), or Claude Code exits with code 1 when resuming:
|
||||
|
||||
**Root cause:** The SDK looks for sessions at `$HOME/.claude/projects/`. Inside the container, `HOME=/home/node`, so it looks at `/home/node/.claude/projects/`.
|
||||
|
||||
**Check the mount path:**
|
||||
```bash
|
||||
# In container-runner.ts, verify mount is to /home/node/.claude/, NOT /root/.claude/
|
||||
grep -A3 "Claude sessions" src/container-runner.ts
|
||||
```
|
||||
|
||||
**Verify sessions are accessible:**
|
||||
```bash
|
||||
docker run --rm --entrypoint /bin/bash \
|
||||
-v ~/.claude:/home/node/.claude \
|
||||
nanoclaw-agent:latest -c '
|
||||
echo "HOME=$HOME"
|
||||
ls -la $HOME/.claude/projects/ 2>&1 | head -5
|
||||
'
|
||||
```
|
||||
|
||||
**Fix:** Ensure `container-runner.ts` mounts to `/home/node/.claude/`:
|
||||
```typescript
|
||||
mounts.push({
|
||||
hostPath: claudeDir,
|
||||
containerPath: '/home/node/.claude', // NOT /root/.claude
|
||||
readonly: false
|
||||
});
|
||||
```
|
||||
|
||||
### 6. MCP Server Failures
|
||||
|
||||
If an MCP server fails to start, the agent may exit. Check the container logs for MCP initialization errors.
|
||||
|
||||
## Manual Container Testing
|
||||
|
||||
### Test the full agent flow:
|
||||
```bash
|
||||
# Set up env file
|
||||
mkdir -p data/env groups/test
|
||||
cp .env data/env/env
|
||||
|
||||
# Run test query
|
||||
echo '{"prompt":"What is 2+2?","groupFolder":"test","chatJid":"test@g.us","isMain":false}' | \
|
||||
docker run -i \
|
||||
-v $(pwd)/data/env:/workspace/env-dir:ro \
|
||||
-v $(pwd)/groups/test:/workspace/group \
|
||||
-v $(pwd)/data/ipc:/workspace/ipc \
|
||||
nanoclaw-agent:latest
|
||||
```
|
||||
|
||||
### Test Claude Code directly:
|
||||
```bash
|
||||
docker run --rm --entrypoint /bin/bash \
|
||||
-v $(pwd)/data/env:/workspace/env-dir:ro \
|
||||
nanoclaw-agent:latest -c '
|
||||
export $(cat /workspace/env-dir/env | xargs)
|
||||
claude -p "Say hello" --dangerously-skip-permissions --allowedTools ""
|
||||
'
|
||||
```
|
||||
|
||||
### Interactive shell in container:
|
||||
```bash
|
||||
docker run --rm -it --entrypoint /bin/bash nanoclaw-agent:latest
|
||||
```
|
||||
|
||||
## SDK Options Reference
|
||||
|
||||
The agent-runner uses these Claude Agent SDK options:
|
||||
|
||||
```typescript
|
||||
query({
|
||||
prompt: input.prompt,
|
||||
options: {
|
||||
cwd: input.cwd, // /workspace/agent
|
||||
allowedTools: [...TOOL_ALLOWLIST, ...mcpAllowPatterns],
|
||||
disallowedTools: SDK_DISALLOWED_TOOLS,
|
||||
cwd: '/workspace/group',
|
||||
allowedTools: ['Bash', 'Read', 'Write', ...],
|
||||
permissionMode: 'bypassPermissions',
|
||||
settingSources: ['project', 'user', 'local'],
|
||||
mcpServers: { ... },
|
||||
},
|
||||
allowDangerouslySkipPermissions: true, // Required with bypassPermissions
|
||||
settingSources: ['project'],
|
||||
mcpServers: { ... }
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
Each registered MCP server's allow pattern is derived from the `mcpServers` map, so registering a server already exposes its tools.
|
||||
**Important:** `allowDangerouslySkipPermissions: true` is required when using `permissionMode: 'bypassPermissions'`. Without it, Claude Code exits with code 1.
|
||||
|
||||
## Rebuilding After Changes
|
||||
|
||||
```bash
|
||||
# Rebuild host TypeScript
|
||||
# Rebuild main app
|
||||
pnpm run build
|
||||
|
||||
# Rebuild the agent container image
|
||||
# Rebuild container (use --no-cache for clean rebuild)
|
||||
./container/build.sh
|
||||
|
||||
# Force a truly clean rebuild (the buildkit cache retains stale COPY files)
|
||||
# Or force full rebuild
|
||||
docker builder prune -af
|
||||
./container/build.sh
|
||||
```
|
||||
|
||||
## Clearing a Session
|
||||
|
||||
Conversation continuity lives in the container-owned `session_state` table in `outbound.db` (the provider's session/continuation id). The agent's `/clear` clears it. To reset a session from the host, remove the session folder so a fresh one is provisioned on the next message:
|
||||
## Checking Container Image
|
||||
|
||||
```bash
|
||||
# Inspect first
|
||||
ncl sessions get <session-id>
|
||||
# List images
|
||||
docker images
|
||||
|
||||
# Remove a single session's folder (host re-provisions both DBs on next message)
|
||||
rm -rf data/v2-sessions/<group>/<session>/
|
||||
# Check what's in the image
|
||||
docker run --rm --entrypoint /bin/bash nanoclaw-agent:latest -c '
|
||||
echo "=== Node version ==="
|
||||
node --version
|
||||
|
||||
echo "=== Claude Code version ==="
|
||||
claude --version
|
||||
|
||||
echo "=== Installed packages ==="
|
||||
ls /app/node_modules/
|
||||
'
|
||||
```
|
||||
|
||||
## Session Persistence
|
||||
|
||||
Claude sessions are stored per-group in `data/sessions/{group}/.claude/` for security isolation. Each group has its own session directory, preventing cross-group access to conversation history.
|
||||
|
||||
**Critical:** The mount path must match the container user's HOME directory:
|
||||
- Container user: `node`
|
||||
- Container HOME: `/home/node`
|
||||
- Mount target: `/home/node/.claude/` (NOT `/root/.claude/`)
|
||||
|
||||
To clear sessions:
|
||||
|
||||
```bash
|
||||
# Clear all sessions for all groups
|
||||
rm -rf data/sessions/
|
||||
|
||||
# Clear sessions for a specific group
|
||||
rm -rf data/sessions/{groupFolder}/.claude/
|
||||
|
||||
# Also clear the session ID from NanoClaw's tracking (stored in SQLite)
|
||||
sqlite3 store/messages.db "DELETE FROM sessions WHERE group_folder = '{groupFolder}'"
|
||||
```
|
||||
|
||||
To verify session resumption is working, check the logs for the same session ID across messages:
|
||||
```bash
|
||||
grep "Session initialized" logs/nanoclaw.log | tail -5
|
||||
# Should show the SAME session ID for consecutive messages in the same group
|
||||
```
|
||||
|
||||
## IPC Debugging
|
||||
|
||||
The container communicates back to the host via files in `/workspace/ipc/`:
|
||||
|
||||
```bash
|
||||
# Check pending messages
|
||||
ls -la data/ipc/messages/
|
||||
|
||||
# Check pending task operations
|
||||
ls -la data/ipc/tasks/
|
||||
|
||||
# Read a specific IPC file
|
||||
cat data/ipc/messages/*.json
|
||||
|
||||
# Check available groups (main channel only)
|
||||
cat data/ipc/main/available_groups.json
|
||||
|
||||
# Check current tasks snapshot
|
||||
cat data/ipc/{groupFolder}/current_tasks.json
|
||||
```
|
||||
|
||||
**IPC file types:**
|
||||
- `messages/*.json` - Agent writes: outgoing WhatsApp messages
|
||||
- `tasks/*.json` - Agent writes: task operations (schedule, pause, resume, cancel, refresh_groups)
|
||||
- `current_tasks.json` - Host writes: read-only snapshot of scheduled tasks
|
||||
- `available_groups.json` - Host writes: read-only list of WhatsApp groups (main only)
|
||||
|
||||
## Quick Diagnostic Script
|
||||
|
||||
```bash
|
||||
echo "=== Checking NanoClaw v2 Setup ==="
|
||||
Run this to check common issues:
|
||||
|
||||
echo -e "\n1. Container runtime running?"
|
||||
```bash
|
||||
echo "=== Checking NanoClaw Container Setup ==="
|
||||
|
||||
echo -e "\n1. Authentication configured?"
|
||||
[ -f .env ] && (grep -q "CLAUDE_CODE_OAUTH_TOKEN=sk-" .env || grep -q "ANTHROPIC_API_KEY=sk-" .env) && echo "OK" || echo "MISSING - add CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY to .env"
|
||||
|
||||
echo -e "\n2. Env file copied for container?"
|
||||
[ -f data/env/env ] && echo "OK" || echo "MISSING - will be created on first run"
|
||||
|
||||
echo -e "\n3. Container runtime running?"
|
||||
docker info &>/dev/null && echo "OK" || echo "NOT RUNNING - start Docker Desktop (macOS) or sudo systemctl start docker (Linux)"
|
||||
|
||||
echo -e "\n2. Agent image exists?"
|
||||
docker run --rm --entrypoint /bin/echo nanoclaw-agent:latest "OK" 2>/dev/null || echo "MISSING - run ./container/build.sh"
|
||||
echo -e "\n4. Container image exists?"
|
||||
echo '{}' | docker run -i --entrypoint /bin/echo nanoclaw-agent:latest "OK" 2>/dev/null || echo "MISSING - run ./container/build.sh"
|
||||
|
||||
echo -e "\n3. OneCLI gateway reachable?"
|
||||
curl -fsS http://127.0.0.1:10254/ >/dev/null 2>&1 && echo "OK" || echo "CHECK - gateway not responding on 127.0.0.1:10254"
|
||||
echo -e "\n5. Session mount path correct?"
|
||||
grep -q "/home/node/.claude" src/container-runner.ts 2>/dev/null && echo "OK" || echo "WRONG - should mount to /home/node/.claude/, not /root/.claude/"
|
||||
|
||||
echo -e "\n4. Central DB present?"
|
||||
[ -f data/v2.db ] && echo "OK" || echo "MISSING - run setup"
|
||||
echo -e "\n6. Groups directory?"
|
||||
ls -la groups/ 2>/dev/null || echo "MISSING - run setup"
|
||||
|
||||
echo -e "\n5. Mount targets in container-runner?"
|
||||
grep -q "containerPath: '/workspace'" src/container-runner.ts && echo "OK" || echo "CHECK - session mount target changed"
|
||||
echo -e "\n7. Recent container logs?"
|
||||
ls -t groups/*/logs/container-*.log 2>/dev/null | head -3 || echo "No container logs yet"
|
||||
|
||||
echo -e "\n6. Single host instance running?"
|
||||
N=$(ps aux | grep 'nanoclaw/dist/index.js' | grep -vc grep)
|
||||
[ "$N" -le 1 ] && echo "OK ($N)" || echo "DUPLICATE - $N instances; stop the stale one (see issue 1)"
|
||||
|
||||
echo -e "\n7. Recent host errors?"
|
||||
tail -n 5 logs/nanoclaw.error.log 2>/dev/null || echo "No error log yet"
|
||||
echo -e "\n8. Session continuity working?"
|
||||
SESSIONS=$(grep "Session initialized" logs/nanoclaw.log 2>/dev/null | tail -5 | awk '{print $NF}' | sort -u | wc -l)
|
||||
[ "$SESSIONS" -le 2 ] && echo "OK (recent sessions reusing IDs)" || echo "CHECK - multiple different session IDs, may indicate resumption issues"
|
||||
```
|
||||
|
||||
@@ -9,7 +9,7 @@ Stand up the first NanoClaw agent for a channel and verify end-to-end delivery b
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Service running.** Check: `launchctl list | grep "$(. setup/lib/install-slug.sh && launchd_label)"` (macOS) or `systemctl --user status "$(. setup/lib/install-slug.sh && systemd_unit)"` (Linux). If stopped, tell the user to run `/setup` first.
|
||||
- **Service running.** Check: `launchctl list | grep nanoclaw` (macOS) or `systemctl --user status nanoclaw` (Linux). If stopped, tell the user to run `/setup` first.
|
||||
- **Target channel installed.** At least one `/add-<channel>` skill has run, credentials are in `.env`, and the adapter is uncommented in `src/channels/index.ts`.
|
||||
- **Adapter connected.** Tail `logs/nanoclaw.log` — look for a recent `channel setup` / `adapter connected` line for the target channel.
|
||||
|
||||
@@ -54,7 +54,7 @@ Tell the user:
|
||||
Wait for the user's confirmation. Then look up the most recent DM messaging groups:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "SELECT id, platform_id, name, created_at FROM messaging_groups WHERE channel_type='${CHANNEL}' AND is_group=0 ORDER BY created_at DESC LIMIT 5"
|
||||
sqlite3 data/v2.db "SELECT id, platform_id, name, created_at FROM messaging_groups WHERE channel_type='${CHANNEL}' AND is_group=0 ORDER BY created_at DESC LIMIT 5"
|
||||
```
|
||||
|
||||
Show the top rows to the user and confirm which `platform_id` is theirs (usually the most recent). Record as `PLATFORM_ID`. If none appeared, check `logs/nanoclaw.log` for `unknown_sender` drops — the adapter might be rejecting inbound due to connection or permission issues.
|
||||
@@ -103,9 +103,9 @@ Wait for the user's reply. If they confirm receipt, the skill is done.
|
||||
|
||||
If they say it didn't arrive, then diagnose using the DB directly (no waiting loops required — the message either delivered or it didn't):
|
||||
|
||||
- `pnpm exec tsx scripts/q.ts data/v2-sessions/<agent-group-id>/<session-id>/outbound.db "SELECT id, status, created_at FROM messages_out ORDER BY created_at DESC LIMIT 5"` — check for stuck `pending` rows. Replace `<agent-group-id>` and `<session-id>` with the values from the script's output.
|
||||
- `sqlite3 data/v2-sessions/<agent-group-id>/sessions/<session-id>/outbound.db "SELECT id, status, created_at FROM messages_out ORDER BY created_at DESC LIMIT 5"` — check for stuck `pending` rows. Replace `<agent-group-id>` and `<session-id>` with the values from the script's output.
|
||||
- `grep -E 'Unauthorized channel destination|container.*exited|error' logs/nanoclaw.log | tail -20` — look for ACL rejections or container crashes.
|
||||
- `ls data/v2-sessions/<agent-group-id>/*/outbound.db` — confirm the session exists.
|
||||
- `ls data/v2-sessions/<agent-group-id>/sessions/*/outbound.db` — confirm the session exists.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -98,13 +98,13 @@ for i in $(seq 1 15); do
|
||||
done
|
||||
```
|
||||
|
||||
If it never becomes healthy, check the gateway containers. The gateway is a Docker Compose stack (project `onecli`, compose file at `~/.onecli/docker-compose.yml`). Inspect it through Docker rather than the host process list:
|
||||
If it never becomes healthy, check if the gateway process is running:
|
||||
|
||||
```bash
|
||||
docker ps -a --filter "label=com.docker.compose.project=onecli" --format '{{.Names}}\t{{.Status}}'
|
||||
ps aux | grep -i onecli | grep -v grep
|
||||
```
|
||||
|
||||
Both services have `restart: unless-stopped`, so they come back automatically once the Docker daemon is up. If Docker isn't running, start it (`open -a Docker` on macOS) and they'll restart on their own. To bring the stack up manually: `docker compose -f ~/.onecli/docker-compose.yml up -d`. If that fails, show the error and stop — the user needs to debug their OneCLI installation.
|
||||
If it's not running, try starting it manually: `onecli start`. If that fails, show the error and stop — the user needs to debug their OneCLI installation.
|
||||
|
||||
## Phase 3: Migrate existing credentials
|
||||
|
||||
@@ -236,12 +236,9 @@ pnpm run build
|
||||
|
||||
If build fails, diagnose and fix. Common issue: `@onecli-sh/sdk` not installed — run `pnpm install` first.
|
||||
|
||||
Restart the service.
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
- macOS (launchd): `launchctl kickstart -k gui/$(id -u)/"$(. setup/lib/install-slug.sh && launchd_label)"`
|
||||
- Linux (systemd): `systemctl --user restart "$(. setup/lib/install-slug.sh && systemd_unit)"`
|
||||
Restart the service:
|
||||
- macOS (launchd): `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`
|
||||
- Linux (systemd): `systemctl --user restart nanoclaw`
|
||||
- WSL/manual: stop and re-run `bash start-nanoclaw.sh`
|
||||
|
||||
## Phase 5: Verify
|
||||
@@ -262,44 +259,9 @@ Tell the user:
|
||||
- To manage secrets: `onecli secrets list`, or open ${ONECLI_URL}
|
||||
- To add rate limits or policies: `onecli rules create --help`
|
||||
|
||||
## Granting secrets to agents (safe merge)
|
||||
|
||||
`set-secrets` **replaces** the agent's entire secret list — it never appends. Always read the current list first and merge before calling it. This pattern is canonical across all skills that assign secrets:
|
||||
|
||||
```bash
|
||||
AGENT_ID=$(onecli agents list | jq -r '.data[] | select(.identifier=="<agentGroupId>") | .id')
|
||||
CURRENT=$(onecli agents secrets --id "$AGENT_ID" | jq -r '[.data[]] | join(",")')
|
||||
MERGED=$(printf '%s' "$CURRENT,<new-secret-id>" | tr ',' '\n' | sort -u | paste -sd ',' -)
|
||||
onecli agents set-secrets --id "$AGENT_ID" --secret-ids "$MERGED"
|
||||
onecli agents secrets --id "$AGENT_ID"
|
||||
```
|
||||
|
||||
- `<agentGroupId>` — the `agentGroupId` field in `groups/<folder>/container.json`
|
||||
- `<new-secret-id>` — the `id` from `onecli secrets list`
|
||||
- Multiple new secrets: append them comma-separated before the `printf` step
|
||||
|
||||
### git over HTTPS
|
||||
|
||||
OneCLI's proxy injects credentials proactively — `injections_applied=1` appears in `docker logs onecli` even when git sends no auth header. However, OneCLI sets `SSL_CERT_FILE` for Node/Python/Deno but not `GIT_SSL_CAINFO`. Without it, git rejects the OneCLI MITM certificate.
|
||||
|
||||
**Auth format matters**: GitHub's git smart HTTP protocol (`github.com`) requires `Basic` auth, not `Bearer`. GitHub's REST API (`api.github.com`) accepts `Bearer`. These must be configured as separate secrets with different formats — see `/add-github` for the full setup.
|
||||
|
||||
If an agent uses `git` or `gh`, add to `data/v2-sessions/<agent-group-id>/.claude-shared/settings.json`:
|
||||
|
||||
```json
|
||||
"GIT_SSL_CAINFO": "/tmp/onecli-combined-ca.pem",
|
||||
"GIT_TERMINAL_PROMPT": "0",
|
||||
"GIT_CONFIG_COUNT": "1",
|
||||
"GIT_CONFIG_KEY_0": "credential.helper",
|
||||
"GIT_CONFIG_VALUE_0": "",
|
||||
"GH_TOKEN": "ghp_onecli_proxy_replaces_this"
|
||||
```
|
||||
|
||||
**Debugging injection**: `docker logs onecli 2>&1 | grep "github.com"` shows every request with `injections_applied=N` and the HTTP status. If `injections_applied=1` but status is still 401, the injected credential value is wrong or uses the wrong auth format for that endpoint.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"OneCLI gateway not reachable" in logs:** The gateway isn't running. Check with `curl -sf ${ONECLI_URL}/health`. The most common cause is that Docker itself is down (the gateway is a Compose stack) — start Docker (`open -a Docker` on macOS) and the containers restart automatically. To bring them up manually: `docker compose -f ~/.onecli/docker-compose.yml up -d`.
|
||||
**"OneCLI gateway not reachable" in logs:** The gateway isn't running. Check with `curl -sf ${ONECLI_URL}/health`. Start it with `onecli start` if needed.
|
||||
|
||||
**Container gets no credentials:** Verify `ONECLI_URL` is set in `.env` and the gateway has an Anthropic secret (`onecli secrets list`).
|
||||
|
||||
|
||||
@@ -7,26 +7,11 @@ description: Wire channels to agent groups, manage isolation levels, add new cha
|
||||
|
||||
Wire messaging channels to agent groups. See `docs/isolation-model.md` for the full isolation model.
|
||||
|
||||
Privilege is a **user-level** concept, not a channel-level one (see `src/modules/permissions/db/user-roles.ts`, `src/modules/permissions/access.ts`). There is no "main channel" / "main group" — any user can be granted `owner` or `admin` (global or scoped to an agent group) via `grantRole()`, and messages from unknown senders are gated per-messaging-group by `unknown_sender_policy` (`strict` | `request_approval` | `public`).
|
||||
Privilege is a **user-level** concept, not a channel-level one (see `src/db/user-roles.ts`, `src/access.ts`). There is no "main channel" / "main group" — any user can be granted `owner` or `admin` (global or scoped to an agent group) via `grantRole()`, and messages from unknown senders are gated per-messaging-group by `unknown_sender_policy` (`strict` | `request_approval` | `public`).
|
||||
|
||||
## Assess Current State
|
||||
|
||||
Read the central DB (`data/v2.db`) using these canonical queries (column names match the schema, not the CLI flags — the `register` command's `--assistant-name` is stored in `agent_groups.name`).
|
||||
|
||||
Run each via the in-tree wrapper — the host setup deliberately ships no `sqlite3` CLI:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "<query>"
|
||||
```
|
||||
|
||||
```sql
|
||||
SELECT id, name AS assistant_name, folder, agent_provider FROM agent_groups;
|
||||
SELECT id, channel_type, platform_id, name, unknown_sender_policy FROM messaging_groups;
|
||||
SELECT messaging_group_id, agent_group_id, session_mode, priority FROM messaging_group_agents;
|
||||
SELECT user_id, role, agent_group_id FROM user_roles ORDER BY role='owner' DESC;
|
||||
```
|
||||
|
||||
Also check `.env` for channel tokens and `src/channels/index.ts` for uncommented imports.
|
||||
Read the central DB (`data/v2.db`) — query `agent_groups`, `messaging_groups`, `messaging_group_agents`, `users`, and `user_roles` tables. Also check `.env` for channel tokens and `src/channels/index.ts` for uncommented imports.
|
||||
|
||||
Categorize channels as: **wired** (has DB entities + messaging_group_agents row), **configured but unwired** (has credentials + barrel import, no DB entities), or **not configured**.
|
||||
|
||||
@@ -65,7 +50,7 @@ pnpm exec tsx setup/index.ts --step register -- \
|
||||
--assistant-name "<name>"
|
||||
```
|
||||
|
||||
The `register` step creates the agent group (reusing it if the folder already exists), the messaging group, and the wiring row. `createMessagingGroupAgent` auto-creates the companion `agent_destinations` row so the agent can address the channel by name.
|
||||
The `register` step creates the agent group (reusing it if the folder already exists), the messaging group, and the wiring row. `createMessagingGroupAgent` auto-creates the companion `agent_destinations` row so the agent can address the channel by name — no separate destination step needed.
|
||||
|
||||
For separate agents, also ask for a folder name and optionally a different assistant name.
|
||||
|
||||
@@ -73,7 +58,7 @@ For separate agents, also ask for a folder name and optionally a different assis
|
||||
|
||||
When adding another group/chat on an already-configured platform (e.g. a second Telegram group):
|
||||
|
||||
1. **Telegram:** ask the isolation question first to determine intent (`wire-to:<folder>` for an existing agent, `new-agent:<folder>` for a fresh one). Run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent <intent>`, show the `CODE` from the `PAIR_TELEGRAM_CODE` status block, and tell the user to post `@<botname> CODE` in the target group (or DM the bot for a private chat). Wait for the final `PAIR_TELEGRAM` block. The inbound interceptor has already created the `messaging_groups` row with `unknown_sender_policy = 'strict'` and upserted the paired user — `register` only needs to add the wiring:
|
||||
1. **Telegram:** ask the isolation question first to determine intent (`wire-to:<folder>` for an existing agent, `new-agent:<folder>` for a fresh one). Run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent <intent>`, show the CODE (follow the `REMINDER_TO_ASSISTANT` line in the `PAIR_TELEGRAM_ISSUED` block) and tell the user to post `@<botname> CODE` in the target group (or DM the bot for a private chat). Wait for the `PAIR_TELEGRAM` block. The inbound interceptor has already created the `messaging_groups` row with `unknown_sender_policy = 'strict'` and upserted the paired user — `register` only needs to add the wiring:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx setup/index.ts --step register -- \
|
||||
@@ -83,7 +68,7 @@ When adding another group/chat on an already-configured platform (e.g. a second
|
||||
--assistant-name "<name>"
|
||||
```
|
||||
|
||||
2. **Other channels:** read the channel's SKILL.md `## Channel Info` for terminology and how-to-find-id. Ask for the new group/chat ID, ask the isolation question, then register.
|
||||
2. **Other channels:** read the channel's SKILL.md `## Channel Info` for terminology and how-to-find-id. Ask for the new group/chat ID, ask the isolation question, then register. No package or credential changes needed.
|
||||
|
||||
## Change Wiring
|
||||
|
||||
|
||||
@@ -24,33 +24,24 @@ Ask which directories the user wants agents to access. For each path:
|
||||
Build the JSON config and write it:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx setup/index.ts --step mounts --force -- --json '{"allowedRoots":[{"path":"/path/to/dir","readOnly":false}],"blockedPatterns":[],"nonMainReadOnly":true}'
|
||||
npx tsx setup/index.ts --step mounts --force -- --json '{"allowedRoots":[{"path":"/path/to/dir","readOnly":false}],"blockedPatterns":[],"nonMainReadOnly":true}'
|
||||
```
|
||||
|
||||
Use `--force` to overwrite the existing config.
|
||||
|
||||
## Remove Directories
|
||||
|
||||
Read the current config, show it, ask which entry to remove, then write the updated config through the same write path (build the trimmed JSON and pass it to `--step mounts --force -- --json`):
|
||||
|
||||
```bash
|
||||
pnpm exec tsx setup/index.ts --step mounts --force -- --json '{"allowedRoots":[],"blockedPatterns":[],"nonMainReadOnly":true}'
|
||||
```
|
||||
Read the current config, show it, ask which entry to remove, write the updated config.
|
||||
|
||||
## Reset to Empty
|
||||
|
||||
```bash
|
||||
pnpm exec tsx setup/index.ts --step mounts --force -- --empty
|
||||
npx tsx setup/index.ts --step mounts --force -- --empty
|
||||
```
|
||||
|
||||
## After Changes
|
||||
|
||||
Restart the service so containers pick up the new config (the unit/label names are per-install — see `setup/lib/install-slug.sh`).
|
||||
Restart the service so containers pick up the new config:
|
||||
|
||||
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
|
||||
```
|
||||
- macOS: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`
|
||||
- Linux: `systemctl --user restart nanoclaw`
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user