mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-24 18:31:31 +08:00
Compare commits
175 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a26abd6b6b | |||
| e9712d033a | |||
| cf5ac09320 | |||
| 8137440698 | |||
| 7ceb06cc8a | |||
| d011752c67 | |||
| 2e6f10cdd7 | |||
| 8906105825 | |||
| ef9e7d5f99 | |||
| 051b895b3c | |||
| 43adb1998a | |||
| 5ba4735fe9 | |||
| 3986ce0e11 | |||
| 3777a9b614 | |||
| 36fb78092c | |||
| c52591f68f | |||
| e372f05d2e | |||
| 8e91d37bc9 | |||
| bba8213cbd | |||
| 5f069221b2 | |||
| 151091f384 | |||
| 5ada950982 | |||
| 6c455330e4 | |||
| 27af41d9b0 | |||
| ea68aa810b | |||
| 5987fdc189 | |||
| 0ef8757f50 | |||
| 878d3706b4 | |||
| b52ab850b2 | |||
| 7b4dfd28c3 | |||
| 106c21a567 | |||
| 221c4948cd | |||
| c6b21e7493 | |||
| 4a8887636c | |||
| 7789fcc67a | |||
| 6ec5f06d51 | |||
| 8f4c79dcaa | |||
| 7e37b13aab | |||
| b672e8271e | |||
| f8c3d02348 | |||
| b808ab4fd2 | |||
| b9b186c9cf | |||
| b44bcf5dcf | |||
| be86bd3c2d | |||
| 6591062fbb | |||
| 26fc3ff322 | |||
| 4ebf56e2a3 | |||
| 7693a20970 | |||
| e706dcac00 | |||
| f048447ec5 | |||
| efdd05a7ef | |||
| 7de1fc1b3c | |||
| 6b431c195d | |||
| 0bc082a17c | |||
| b6be3b9bf4 | |||
| 7e99d0eaf7 | |||
| 8d8522202a | |||
| 0df647be74 | |||
| 2825f657ca | |||
| 15a6950b5b | |||
| 226fc93795 | |||
| 15e2ac7649 | |||
| f804ebf2e9 | |||
| fc375ca72b | |||
| 88d3da76c3 | |||
| 6d35c85129 | |||
| f0ebc8d6e1 | |||
| c7f8e98471 | |||
| 52f8661f0c | |||
| f37e775358 | |||
| de448ef22f | |||
| 41162517d9 | |||
| 2afcee3a4f | |||
| 9bb416c157 | |||
| beb73d792a | |||
| 8b783daa67 | |||
| 5cbfccec05 | |||
| 8637143216 | |||
| 44067e73cb | |||
| 72d0134d0a | |||
| 2b51a4e707 | |||
| 3d6837c411 | |||
| 9fd694c763 | |||
| 4fc2c4275c | |||
| 1de5a0356b | |||
| f41c162009 | |||
| fd03b89333 | |||
| 672e228876 | |||
| 81ef193e69 | |||
| 53513db5bc | |||
| 9e33274e2a | |||
| d0c608c751 | |||
| a4346f566c | |||
| 1df8dec9bd | |||
| 82baa39f20 | |||
| 5845a5a980 | |||
| ce28e7f558 | |||
| 9e480a0624 | |||
| 3fa001409e | |||
| 78b0ad68f6 | |||
| e3f4a8b0d8 | |||
| c1d0395d11 | |||
| 0eeeecf75e | |||
| 7a628bfb3c | |||
| 2fd2bf3bde | |||
| f0a0939860 | |||
| f351e46008 | |||
| 5f3bd9c880 | |||
| 5d32efbce4 | |||
| 7eda2628fa | |||
| ffd38f660a | |||
| 57eeed6cb6 | |||
| 2861009d95 | |||
| bd032c2b83 | |||
| 0e0794ca10 | |||
| 83254b12b4 | |||
| cf2b1c9755 | |||
| f3524a33bb | |||
| c6d2f45f93 | |||
| e5a7a33084 | |||
| 0ec56b732d | |||
| 97868af5a7 | |||
| ff277c0d49 | |||
| a67b4abd79 | |||
| 500353c182 | |||
| a8eb82d529 | |||
| 237876c2c6 | |||
| 209061f54f | |||
| bee80b0072 | |||
| 539af750d4 | |||
| 438dedad77 | |||
| 6475e0f0b5 | |||
| dd5bc85b02 | |||
| 97e356d243 | |||
| 94d33bcc1d | |||
| cca22e9270 | |||
| 3a9b98f1a4 | |||
| 677cc47bd1 | |||
| 40f5683c36 | |||
| 15f30682d7 | |||
| d121cd1cd6 | |||
| 61ca43d193 | |||
| 3101f65a72 | |||
| d8b1f52f2b | |||
| c84a6ba80e | |||
| 73c931594a | |||
| 2383bde80f | |||
| dee7e0be32 | |||
| 990d243dbd | |||
| 910342fd80 | |||
| 7f4583d0fe | |||
| 092f16dfaa | |||
| c91168bd74 | |||
| 68352351e4 | |||
| 22ed951f05 | |||
| 4dfc2e3a24 | |||
| 3a29674b46 | |||
| e8b01bdb07 | |||
| 6ed228f9a8 | |||
| fb2790a5d5 | |||
| 74c9c9e27a | |||
| ad97829151 | |||
| bc0b559461 | |||
| 06918f35e0 | |||
| 91400f9f66 | |||
| 46c8829f2f | |||
| 12f50281c2 | |||
| 100e556ee9 | |||
| 2444ab171f | |||
| 09a3b48dae | |||
| cec6768f4b | |||
| 303a5c7100 | |||
| 5454bae426 | |||
| 0d75ca26f4 | |||
| fbd8af618d |
@@ -0,0 +1,243 @@
|
||||
---
|
||||
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 in this folder and copies it into the agent-runner tree at install time, then wires it up with small edits to `index.ts`, `providers/claude.ts`, and `container-runner.ts`. No branch merge — all edits are additive and idempotent.
|
||||
|
||||
## 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 MCP server source
|
||||
|
||||
```bash
|
||||
cp .claude/skills/add-atomic-chat-tool/atomic-chat-mcp-stdio.ts container/agent-runner/src/atomic-chat-mcp-stdio.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 } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Add the tool glob to the allowlist
|
||||
|
||||
Edit `container/agent-runner/src/providers/claude.ts`. Find `'mcp__nanoclaw__*',` in the `TOOL_ALLOWLIST` array and add `'mcp__atomic_chat__*',` on the following line:
|
||||
|
||||
```ts
|
||||
'mcp__nanoclaw__*',
|
||||
'mcp__atomic_chat__*',
|
||||
];
|
||||
```
|
||||
|
||||
### Forward host env vars into the container
|
||||
|
||||
Edit `src/container-runner.ts` in `buildContainerArgs`. Find the `TZ` env line:
|
||||
|
||||
```ts
|
||||
args.push('-e', `TZ=${TIMEZONE}`);
|
||||
```
|
||||
|
||||
Add ATOMIC_CHAT forwarding right after it:
|
||||
|
||||
```ts
|
||||
args.push('-e', `TZ=${TIMEZONE}`);
|
||||
|
||||
// Atomic Chat MCP tool: forward host overrides if set (default is host.docker.internal:1337).
|
||||
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}`);
|
||||
}
|
||||
```
|
||||
|
||||
### Surface `[ATOMIC]` log lines at info level
|
||||
|
||||
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
|
||||
./container/build.sh
|
||||
```
|
||||
|
||||
All three must be clean before proceeding.
|
||||
|
||||
## 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
|
||||
|
||||
```bash
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
# Linux: systemctl --user restart nanoclaw
|
||||
```
|
||||
|
||||
## 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`
|
||||
3. The allowlist wasn't updated — check `container/agent-runner/src/providers/claude.ts` includes `mcp__atomic_chat__*` in `TOOL_ALLOWLIST`
|
||||
4. 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.
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* 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);
|
||||
@@ -0,0 +1,161 @@
|
||||
---
|
||||
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`
|
||||
- `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`
|
||||
- `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
|
||||
|
||||
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: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
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
```bash
|
||||
pnpm run build # host
|
||||
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit # container typecheck
|
||||
./container/build.sh # agent image
|
||||
```
|
||||
|
||||
## 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.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
grep -q "./codex.js" container/agent-runner/src/providers/index.ts && echo "container barrel: OK"
|
||||
grep -q "./codex.js" src/providers/index.ts && echo "host barrel: OK"
|
||||
grep -q "@openai/codex@" container/Dockerfile && echo "Dockerfile install: OK"
|
||||
cd container/agent-runner && bun test src/providers/codex.factory.test.ts && cd -
|
||||
```
|
||||
|
||||
After the image rebuild, set `agent_provider = 'codex'` on a test group and send a message. 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.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Remove DeltaChat
|
||||
|
||||
## 1. Disable the adapter
|
||||
|
||||
Comment out the import in `src/channels/index.ts`:
|
||||
|
||||
```typescript
|
||||
// import './deltachat.js';
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
|
||||
# Linux
|
||||
systemctl --user restart nanoclaw
|
||||
|
||||
# macOS
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
|
||||
```
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,254 @@
|
||||
---
|
||||
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/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
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/deltachat.ts > src/channels/deltachat.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
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
```bash
|
||||
# Linux
|
||||
systemctl --user restart nanoclaw
|
||||
|
||||
# macOS
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
|
||||
```
|
||||
|
||||
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
|
||||
sqlite3 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: `sqlite3 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 nanoclaw
|
||||
```
|
||||
|
||||
### 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
|
||||
sqlite3 data/v2.db "SELECT id, platform_id, name FROM messaging_groups WHERE channel_type='deltachat'"
|
||||
```
|
||||
|
||||
If the group has no entry in `messaging_group_agents`, wire it with `/manage-channels`.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Verify DeltaChat
|
||||
|
||||
## 1. Check the adapter started
|
||||
|
||||
```bash
|
||||
grep "Channel adapter started.*deltachat" logs/nanoclaw.log | tail -1
|
||||
```
|
||||
|
||||
Expected: `Channel adapter started { channel: 'deltachat', type: 'deltachat' }`
|
||||
|
||||
## 2. Check IMAP/SMTP connectivity
|
||||
|
||||
Replace with your provider's hostnames from `.env`:
|
||||
|
||||
```bash
|
||||
DC_IMAP=$(grep '^DC_IMAP_HOST=' .env | cut -d= -f2)
|
||||
DC_SMTP=$(grep '^DC_SMTP_HOST=' .env | cut -d= -f2)
|
||||
|
||||
bash -c "echo >/dev/tcp/$DC_IMAP/993" && echo "IMAP open" || echo "IMAP blocked"
|
||||
bash -c "echo >/dev/tcp/$DC_SMTP/587" && echo "SMTP open" || echo "SMTP blocked"
|
||||
```
|
||||
|
||||
## 3. End-to-end message test
|
||||
|
||||
1. Open DeltaChat on your device
|
||||
2. Add the bot email address as a contact
|
||||
3. Send a message
|
||||
4. The bot should respond within a few seconds
|
||||
|
||||
If nothing arrives, check:
|
||||
|
||||
```bash
|
||||
grep "DeltaChat" logs/nanoclaw.log | tail -20
|
||||
grep "DeltaChat" logs/nanoclaw.error.log | tail -10
|
||||
```
|
||||
|
||||
## 4. Check messaging group was created
|
||||
|
||||
```bash
|
||||
sqlite3 data/v2.db \
|
||||
"SELECT id, platform_id, name FROM messaging_groups WHERE channel_type='deltachat' ORDER BY created_at DESC LIMIT 5"
|
||||
```
|
||||
|
||||
If a row appears, the inbound routing is working. If not, the adapter isn't receiving the message — check logs for `DeltaChat: error handling incoming message`.
|
||||
|
||||
## 5. Verify user access
|
||||
|
||||
If the message arrived but the agent didn't respond, the sender may not have access:
|
||||
|
||||
```bash
|
||||
sqlite3 data/v2.db "SELECT id, display_name FROM users WHERE id LIKE 'deltachat:%'"
|
||||
```
|
||||
|
||||
Grant access as shown in the SKILL.md "Grant user access" section.
|
||||
@@ -44,7 +44,7 @@ import './discord.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/discord@4.26.0
|
||||
pnpm install @chat-adapter/discord@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
---
|
||||
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 && \
|
||||
grep -q "mcp__calendar__\*" container/agent-runner/src/providers/claude.ts && \
|
||||
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}"
|
||||
```
|
||||
|
||||
### Add tools to allowlist
|
||||
|
||||
Edit `container/agent-runner/src/providers/claude.ts`. Add `'mcp__calendar__*'` to `TOOL_ALLOWLIST` after `'mcp__nanoclaw__*'` (or after `'mcp__gmail__*'` if present).
|
||||
|
||||
### Rebuild the container image
|
||||
|
||||
```bash
|
||||
./container/build.sh
|
||||
```
|
||||
|
||||
## Phase 3: Wire Per-Agent-Group
|
||||
|
||||
For each agent group, merge into `groups/<folder>/container.json`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"mcpServers": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalMounts": [
|
||||
{
|
||||
"hostPath": "/home/<user>/.calendar-mcp",
|
||||
"containerPath": ".calendar-mcp",
|
||||
"readonly": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Substitute `<user>` with `echo $HOME`. `containerPath` is relative (mount-security rejects absolute paths — additional mounts land at `/workspace/extra/<relative>`).
|
||||
|
||||
**Same-group-as-gmail tip:** if this group already has the gmail MCP + `.gmail-mcp` mount, **merge, don't replace** — both entries coexist in `mcpServers` and `additionalMounts`.
|
||||
|
||||
## Phase 4: Build and Restart
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
systemctl --user restart nanoclaw # Linux
|
||||
# launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
```
|
||||
|
||||
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" → `mcp__calendar__*` missing from `TOOL_ALLOWLIST`, or image cache stale (`./container/build.sh` again).
|
||||
|
||||
## Removal
|
||||
|
||||
1. Delete `"calendar"` from `mcpServers` and the `.calendar-mcp` mount from `additionalMounts` in each group's `container.json`.
|
||||
2. Remove `'mcp__calendar__*'` from `TOOL_ALLOWLIST`.
|
||||
3. Remove `CALENDAR_MCP_VERSION` ARG and the calendar package from the Dockerfile install block.
|
||||
4. `pnpm run build && ./container/build.sh && systemctl --user restart nanoclaw`.
|
||||
5. Optional: `rm -rf ~/.calendar-mcp/` and `onecli apps disconnect --provider google-calendar`.
|
||||
|
||||
## 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:** earlier versions of this skill used `@gongrzhe/server-calendar-autoauth-mcp@1.0.2` which only supports the primary calendar with 5 event-level tools. The cocal server supersedes it.
|
||||
- **Skill pattern:** direct sibling of [`/add-gmail-tool`](../add-gmail-tool/SKILL.md); same OneCLI stub mechanism.
|
||||
@@ -44,7 +44,7 @@ import './gchat.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/gchat@4.26.0
|
||||
pnpm install @chat-adapter/gchat@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build
|
||||
|
||||
@@ -48,7 +48,7 @@ import './github.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/github@4.26.0
|
||||
pnpm install @chat-adapter/github@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
---
|
||||
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:
|
||||
|
||||
```bash
|
||||
onecli secrets list # find Gmail secret IDs (OneCLI creates one per connected app)
|
||||
onecli agents set-secrets --id <agent-id> --secret-ids <gmail-secret-id>
|
||||
```
|
||||
|
||||
## Phase 2: Apply Code Changes
|
||||
|
||||
### Check if already applied
|
||||
|
||||
```bash
|
||||
grep -q 'GMAIL_MCP_VERSION' container/Dockerfile && \
|
||||
grep -q "mcp__gmail__\*" container/agent-runner/src/providers/claude.ts && \
|
||||
echo "ALREADY APPLIED — skip to Phase 3"
|
||||
```
|
||||
|
||||
### Add MCP server to Dockerfile
|
||||
|
||||
Edit `container/Dockerfile`. Find the pinned-version ARG block:
|
||||
|
||||
```dockerfile
|
||||
ARG CLAUDE_CODE_VERSION=2.1.116
|
||||
ARG AGENT_BROWSER_VERSION=latest
|
||||
ARG VERCEL_VERSION=latest
|
||||
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 after it, before `# ---- Entrypoint`:
|
||||
|
||||
```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`.
|
||||
|
||||
### Add tools to allowlist
|
||||
|
||||
Edit `container/agent-runner/src/providers/claude.ts`. Find `'mcp__nanoclaw__*',` in `TOOL_ALLOWLIST` and add `'mcp__gmail__*',` after it.
|
||||
|
||||
### 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), edit `groups/<folder>/container.json` to add the mount and MCP server.
|
||||
|
||||
Merge these into the group's `container.json`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"mcpServers": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalMounts": [
|
||||
{
|
||||
"hostPath": "/home/<user>/.gmail-mcp",
|
||||
"containerPath": ".gmail-mcp",
|
||||
"readonly": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Substitute `<user>` with the host user's home (use `echo $HOME`, don't assume `~` will expand — `container-runner.ts` does expand `~` via `expandPath`, but an explicit absolute path is clearer and matches what `/manage-mounts` writes).
|
||||
|
||||
**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.
|
||||
|
||||
## Phase 4: Build and Restart
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
systemctl --user restart nanoclaw # Linux
|
||||
# launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
```
|
||||
|
||||
## 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" → `mcp__gmail__*` wasn't added to `TOOL_ALLOWLIST`, or the agent-runner wasn't rebuilt (image cache — run `./container/build.sh` again with `--no-cache` if suspicious).
|
||||
|
||||
## Removal
|
||||
|
||||
1. Delete the `"gmail"` entry from `mcpServers` and the `.gmail-mcp` entry from `additionalMounts` in each group's `container.json`.
|
||||
2. Remove `'mcp__gmail__*'` from `TOOL_ALLOWLIST` in `container/agent-runner/src/providers/claude.ts`.
|
||||
3. Remove the `GMAIL_MCP_VERSION` ARG and the `pnpm install -g @gongrzhe/server-gmail-autoauth-mcp` block from `container/Dockerfile`.
|
||||
4. `pnpm run build && ./container/build.sh && systemctl --user restart nanoclaw`.
|
||||
5. (Optional) `rm -rf ~/.gmail-mcp/` if no other host-side tool needs the stubs.
|
||||
6. (Optional) Disconnect Gmail in OneCLI: `onecli apps disconnect --provider gmail`.
|
||||
|
||||
## 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/qwibitai/nanoclaw/issues/1500) (proxy Gmail/Calendar OAuth tokens through credential proxy) for the Gmail side.
|
||||
- **Related PRs:** [#1810](https://github.com/qwibitai/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.
|
||||
@@ -87,7 +87,7 @@ Linear OAuth apps can't be @-mentioned, so the bridge's `onNewMention` handler n
|
||||
### 5. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/linear@4.26.0
|
||||
pnpm install @chat-adapter/linear@4.29.0
|
||||
```
|
||||
|
||||
### 6. Build
|
||||
|
||||
@@ -208,7 +208,7 @@ onecli secrets create --name "OpenCode Zen" --type generic \
|
||||
|
||||
### Per group / per session
|
||||
|
||||
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).
|
||||
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'`.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Remove Signal
|
||||
|
||||
1. Comment out `import './signal.js'` in `src/channels/index.ts`
|
||||
2. Remove `SIGNAL_ACCOUNT` (and any other `SIGNAL_*` vars) from `.env`
|
||||
3. Rebuild and restart
|
||||
|
||||
If you also want to unlink the Signal account from `signal-cli`:
|
||||
|
||||
```bash
|
||||
signal-cli -a +1YOURNUMBER removeDevice --deviceId <id>
|
||||
```
|
||||
|
||||
(Find the device id with `signal-cli -a +1YOURNUMBER listDevices`.)
|
||||
@@ -0,0 +1,318 @@
|
||||
---
|
||||
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.
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
|
||||
signal-cli -a +1YOURNUMBER updateProfile --name "YourBotName"
|
||||
# optionally: --avatar /path/to/avatar.jpg
|
||||
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
|
||||
|
||||
# Linux
|
||||
systemctl --user stop nanoclaw
|
||||
signal-cli -a +1YOURNUMBER updateProfile --name "YourBotName"
|
||||
systemctl --user start nanoclaw
|
||||
```
|
||||
|
||||
### 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` and `src/channels/signal.test.ts` both exist
|
||||
- `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
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
No npm packages to install — the adapter uses only Node.js builtins.
|
||||
|
||||
## 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
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
|
||||
|
||||
# Linux
|
||||
systemctl --user restart nanoclaw
|
||||
```
|
||||
|
||||
## 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
|
||||
sqlite3 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")
|
||||
sqlite3 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")
|
||||
sqlite3 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: `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='signal'"`
|
||||
3. Service running: `launchctl print gui/$(id -u)/com.nanoclaw` (macOS) / `systemctl --user status nanoclaw` (Linux)
|
||||
|
||||
### 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.
|
||||
@@ -0,0 +1,5 @@
|
||||
# Verify Signal
|
||||
|
||||
Send a message to your own Signal number (Note to Self) from another device, or have someone send your linked number a DM. The bot should respond within a few seconds.
|
||||
|
||||
If nothing happens, tail `logs/nanoclaw.log` for `Signal channel connected` and `Signal message received`.
|
||||
@@ -44,7 +44,7 @@ import './slack.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/slack@4.26.0
|
||||
pnpm install @chat-adapter/slack@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build
|
||||
@@ -72,26 +72,41 @@ pnpm run build
|
||||
### Event Subscriptions
|
||||
|
||||
8. Go to **Event Subscriptions** and toggle **Enable Events**
|
||||
9. Set the **Request URL** to `https://your-domain/webhook/slack` — Slack will send a verification challenge; it must pass before you can save
|
||||
9. **Webhook mode:** set the **Request URL** to `https://your-domain/webhook/slack` — Slack will send a verification challenge; it must pass before you can save. For **Socket Mode** (below), skip the Request URL.
|
||||
10. Under **Subscribe to bot events**, add:
|
||||
- `message.channels`, `message.groups`, `message.im`, `app_mention`
|
||||
11. Click **Save Changes**
|
||||
12. Slack will show a banner asking you to **reinstall the app** — click it to apply the new event subscriptions
|
||||
|
||||
### Socket Mode (optional — no public URL)
|
||||
|
||||
Socket Mode delivers events over an outbound WebSocket the bot opens to Slack, so the host needs **no public HTTPS endpoint** — ideal for local dev or a host behind NAT/a firewall. Setting `SLACK_APP_TOKEN` is what flips the adapter into Socket Mode; without it the adapter stays in webhook mode.
|
||||
|
||||
13. Go to **Basic Information** > **App-Level Tokens** > **Generate Token and Scopes**, add the `connections:write` scope, and copy the token (`xapp-...`)
|
||||
14. Go to **Socket Mode** and toggle **Enable Socket Mode** on
|
||||
15. Keep **Event Subscriptions** enabled with the bot events above — under Socket Mode no Request URL is required
|
||||
|
||||
### Configure environment
|
||||
|
||||
Add to `.env`:
|
||||
Add to `.env` — **webhook mode**:
|
||||
|
||||
```bash
|
||||
SLACK_BOT_TOKEN=xoxb-your-bot-token
|
||||
SLACK_SIGNING_SECRET=your-signing-secret
|
||||
```
|
||||
|
||||
…or **Socket Mode** (no public URL; signing secret optional):
|
||||
|
||||
```bash
|
||||
SLACK_BOT_TOKEN=xoxb-your-bot-token
|
||||
SLACK_APP_TOKEN=xapp-your-app-level-token
|
||||
```
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
|
||||
### Webhook server
|
||||
### Webhook server (webhook mode only)
|
||||
|
||||
The Chat SDK bridge automatically starts a shared webhook server on port 3000 (configurable via `WEBHOOK_PORT` env var). The server handles `/webhook/slack` for Slack and other webhook-based adapters. This port must be publicly reachable from the internet for Slack to deliver events.
|
||||
In **webhook mode** the Chat SDK bridge automatically starts a shared webhook server on port 3000 (configurable via `WEBHOOK_PORT` env var). The server handles `/webhook/slack` for Slack and other webhook-based adapters. This port must be publicly reachable from the internet for Slack to deliver events. **In Socket Mode this is not needed** — skip this section if you set `SLACK_APP_TOKEN`.
|
||||
|
||||
If running locally, discuss options for exposing the server — e.g. ngrok (`ngrok http 3000`), Cloudflare Tunnel, or a reverse proxy on a VPS. The resulting public URL becomes the base for `https://your-domain/webhook/slack`.
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ import './teams.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/teams@4.26.0
|
||||
pnpm install @chat-adapter/teams@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build
|
||||
|
||||
@@ -58,7 +58,7 @@ 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.26.0
|
||||
pnpm install @chat-adapter/telegram@4.29.0
|
||||
```
|
||||
|
||||
### 6. Build
|
||||
|
||||
@@ -44,7 +44,7 @@ import './whatsapp-cloud.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/whatsapp@4.26.0
|
||||
pnpm install @chat-adapter/whatsapp@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build
|
||||
|
||||
@@ -57,7 +57,7 @@ groups: () => import('./groups.js'),
|
||||
### 5. Install the adapter packages (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @whiskeysockets/baileys@6.17.16 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
|
||||
pnpm install @whiskeysockets/baileys@7.0.0-rc.9 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
|
||||
```
|
||||
|
||||
### 6. Build
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
name: Label PR
|
||||
|
||||
# SECURITY: this workflow runs with write access to the base repo on fork PRs,
|
||||
# because `pull_request_target` executes in the context of the base branch.
|
||||
# Keep it metadata-only — do NOT add actions/checkout or any step that
|
||||
# executes PR-supplied content (install scripts, build commands, etc.).
|
||||
# See https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/
|
||||
on:
|
||||
pull_request:
|
||||
pull_request_target:
|
||||
types: [opened, edited]
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { beforeEach, describe, expect, test } from 'bun:test';
|
||||
|
||||
import { getOutboundDb, initTestSessionDb } from './connection.js';
|
||||
import {
|
||||
clearContinuation,
|
||||
getContinuation,
|
||||
migrateLegacyContinuation,
|
||||
setContinuation,
|
||||
} from './session-state.js';
|
||||
|
||||
beforeEach(() => {
|
||||
initTestSessionDb();
|
||||
});
|
||||
|
||||
function seedLegacy(value: string): void {
|
||||
getOutboundDb()
|
||||
.prepare('INSERT INTO session_state (key, value, updated_at) VALUES (?, ?, ?)')
|
||||
.run('sdk_session_id', value, new Date().toISOString());
|
||||
}
|
||||
|
||||
describe('session-state — per-provider continuations', () => {
|
||||
test('set/get round-trip, case-insensitive provider key', () => {
|
||||
setContinuation('claude', 'claude-conv-1');
|
||||
expect(getContinuation('claude')).toBe('claude-conv-1');
|
||||
expect(getContinuation('Claude')).toBe('claude-conv-1');
|
||||
expect(getContinuation('CLAUDE')).toBe('claude-conv-1');
|
||||
});
|
||||
|
||||
test('providers are isolated — switching reads the right slot', () => {
|
||||
setContinuation('claude', 'claude-conv-1');
|
||||
setContinuation('codex', 'codex-thread-xyz');
|
||||
|
||||
expect(getContinuation('claude')).toBe('claude-conv-1');
|
||||
expect(getContinuation('codex')).toBe('codex-thread-xyz');
|
||||
});
|
||||
|
||||
test('clearContinuation only affects the specified provider', () => {
|
||||
setContinuation('claude', 'keep-me');
|
||||
setContinuation('codex', 'drop-me');
|
||||
|
||||
clearContinuation('codex');
|
||||
|
||||
expect(getContinuation('claude')).toBe('keep-me');
|
||||
expect(getContinuation('codex')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('unknown provider returns undefined', () => {
|
||||
expect(getContinuation('never-used')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('session-state — legacy migration', () => {
|
||||
test('adopts legacy value into current provider when current is empty', () => {
|
||||
seedLegacy('old-session-id');
|
||||
|
||||
const adopted = migrateLegacyContinuation('claude');
|
||||
|
||||
expect(adopted).toBe('old-session-id');
|
||||
expect(getContinuation('claude')).toBe('old-session-id');
|
||||
});
|
||||
|
||||
test('always deletes legacy row regardless of migration outcome', () => {
|
||||
seedLegacy('old-session-id');
|
||||
setContinuation('claude', 'existing');
|
||||
|
||||
migrateLegacyContinuation('claude');
|
||||
|
||||
// After migration the legacy key must be gone, whether or not it was adopted.
|
||||
// A subsequent migration for a different provider must not see it.
|
||||
const resultAfterSecondCall = migrateLegacyContinuation('codex');
|
||||
expect(resultAfterSecondCall).toBeUndefined();
|
||||
});
|
||||
|
||||
test('prefers existing current-provider slot over legacy', () => {
|
||||
seedLegacy('legacy-value');
|
||||
setContinuation('claude', 'claude-value');
|
||||
|
||||
const result = migrateLegacyContinuation('claude');
|
||||
|
||||
expect(result).toBe('claude-value');
|
||||
expect(getContinuation('claude')).toBe('claude-value');
|
||||
});
|
||||
|
||||
test('no legacy row — returns current provider value (possibly undefined)', () => {
|
||||
expect(migrateLegacyContinuation('claude')).toBeUndefined();
|
||||
|
||||
setContinuation('codex', 'codex-value');
|
||||
expect(migrateLegacyContinuation('codex')).toBe('codex-value');
|
||||
});
|
||||
|
||||
test('migration is idempotent on a second call (legacy already gone)', () => {
|
||||
seedLegacy('once');
|
||||
|
||||
const first = migrateLegacyContinuation('claude');
|
||||
expect(first).toBe('once');
|
||||
|
||||
const second = migrateLegacyContinuation('claude');
|
||||
expect(second).toBe('once');
|
||||
});
|
||||
});
|
||||
@@ -2,12 +2,20 @@
|
||||
* Persistent key/value state for the container. Lives in outbound.db
|
||||
* (container-owned, already scoped per channel/thread).
|
||||
*
|
||||
* Primary use: remember the SDK session ID so the agent's conversation
|
||||
* resumes across container restarts. Cleared by /clear.
|
||||
* Primary use: remember each provider's opaque continuation id so the
|
||||
* agent's conversation resumes across container restarts. Keyed per
|
||||
* provider because continuations are provider-private — a Claude
|
||||
* conversation id means nothing to Codex and vice versa. Switching
|
||||
* providers is therefore lossless: each provider's last thread stays
|
||||
* on file and resumes cleanly if the user flips back.
|
||||
*/
|
||||
import { getOutboundDb } from './connection.js';
|
||||
|
||||
const SDK_SESSION_KEY = 'sdk_session_id';
|
||||
const LEGACY_KEY = 'sdk_session_id';
|
||||
|
||||
function continuationKey(providerName: string): string {
|
||||
return `continuation:${providerName.toLowerCase()}`;
|
||||
}
|
||||
|
||||
function getValue(key: string): string | undefined {
|
||||
const row = getOutboundDb()
|
||||
@@ -18,9 +26,7 @@ function getValue(key: string): string | undefined {
|
||||
|
||||
function setValue(key: string, value: string): void {
|
||||
getOutboundDb()
|
||||
.prepare(
|
||||
'INSERT OR REPLACE INTO session_state (key, value, updated_at) VALUES (?, ?, ?)',
|
||||
)
|
||||
.prepare('INSERT OR REPLACE INTO session_state (key, value, updated_at) VALUES (?, ?, ?)')
|
||||
.run(key, value, new Date().toISOString());
|
||||
}
|
||||
|
||||
@@ -28,14 +34,46 @@ function deleteValue(key: string): void {
|
||||
getOutboundDb().prepare('DELETE FROM session_state WHERE key = ?').run(key);
|
||||
}
|
||||
|
||||
export function getStoredSessionId(): string | undefined {
|
||||
return getValue(SDK_SESSION_KEY);
|
||||
/**
|
||||
* One-time migration of the pre-per-provider continuation row.
|
||||
*
|
||||
* Before this was keyed per provider, continuations lived under the
|
||||
* single key `sdk_session_id`. On container start, if that legacy row
|
||||
* exists and the current provider has no continuation of its own, adopt
|
||||
* the legacy value into the current provider's slot (best-guess — the
|
||||
* legacy row was written by whatever provider ran last). The legacy row
|
||||
* is always deleted so future provider flips never re-read a stale id
|
||||
* through the wrong lens.
|
||||
*
|
||||
* Returns the continuation the caller should use at startup (either the
|
||||
* current provider's existing value, the adopted legacy value, or
|
||||
* undefined).
|
||||
*/
|
||||
export function migrateLegacyContinuation(providerName: string): string | undefined {
|
||||
const legacy = getValue(LEGACY_KEY);
|
||||
const currentKey = continuationKey(providerName);
|
||||
const current = getValue(currentKey);
|
||||
|
||||
if (legacy === undefined) return current;
|
||||
|
||||
// Always drop the legacy row so no future provider reads it.
|
||||
deleteValue(LEGACY_KEY);
|
||||
|
||||
// Prefer the current provider's own slot if one already exists.
|
||||
if (current !== undefined) return current;
|
||||
|
||||
setValue(currentKey, legacy);
|
||||
return legacy;
|
||||
}
|
||||
|
||||
export function setStoredSessionId(sessionId: string): void {
|
||||
setValue(SDK_SESSION_KEY, sessionId);
|
||||
export function getContinuation(providerName: string): string | undefined {
|
||||
return getValue(continuationKey(providerName));
|
||||
}
|
||||
|
||||
export function clearStoredSessionId(): void {
|
||||
deleteValue(SDK_SESSION_KEY);
|
||||
export function setContinuation(providerName: string, id: string): void {
|
||||
setValue(continuationKey(providerName), id);
|
||||
}
|
||||
|
||||
export function clearContinuation(providerName: string): void {
|
||||
deleteValue(continuationKey(providerName));
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ async function main(): Promise<void> {
|
||||
|
||||
await runPollLoop({
|
||||
provider,
|
||||
providerName,
|
||||
cwd: CWD,
|
||||
systemContext: { instructions },
|
||||
});
|
||||
|
||||
@@ -98,6 +98,7 @@ async function runPollLoopWithTimeout(provider: MockProvider, signal: AbortSigna
|
||||
return Promise.race([
|
||||
runPollLoop({
|
||||
provider,
|
||||
providerName: 'mock',
|
||||
cwd: '/tmp',
|
||||
}),
|
||||
new Promise<void>((_, reject) => {
|
||||
|
||||
@@ -2,7 +2,11 @@ import { findByName, getAllDestinations, type DestinationEntry } from './destina
|
||||
import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } from './db/messages-in.js';
|
||||
import { writeMessageOut } from './db/messages-out.js';
|
||||
import { touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
|
||||
import { getStoredSessionId, setStoredSessionId, clearStoredSessionId } from './db/session-state.js';
|
||||
import {
|
||||
clearContinuation,
|
||||
migrateLegacyContinuation,
|
||||
setContinuation,
|
||||
} from './db/session-state.js';
|
||||
import { formatMessages, extractRouting, categorizeMessage, isClearCommand, stripInternalTags, type RoutingContext } from './formatter.js';
|
||||
import type { AgentProvider, AgentQuery, ProviderEvent } from './providers/types.js';
|
||||
|
||||
@@ -19,6 +23,12 @@ function generateId(): string {
|
||||
|
||||
export interface PollLoopConfig {
|
||||
provider: AgentProvider;
|
||||
/**
|
||||
* Name of the provider (e.g. "claude", "codex", "opencode"). Used to key
|
||||
* the stored continuation per-provider so flipping providers doesn't
|
||||
* resurrect a stale id from a different backend.
|
||||
*/
|
||||
providerName: string;
|
||||
cwd: string;
|
||||
systemContext?: {
|
||||
instructions?: string;
|
||||
@@ -39,8 +49,9 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
// Resume the agent's prior session from a previous container run if one
|
||||
// was persisted. The continuation is opaque to the poll-loop — the
|
||||
// provider decides how to use it (Claude resumes a .jsonl transcript,
|
||||
// other providers may reload a thread ID, etc.).
|
||||
let continuation: string | undefined = getStoredSessionId();
|
||||
// other providers may reload a thread ID, etc.). Keyed per-provider so
|
||||
// a Codex thread id never gets handed to Claude or vice versa.
|
||||
let continuation: string | undefined = migrateLegacyContinuation(config.providerName);
|
||||
|
||||
if (continuation) {
|
||||
log(`Resuming agent session ${continuation}`);
|
||||
@@ -94,7 +105,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
if ((msg.kind === 'chat' || msg.kind === 'chat-sdk') && isClearCommand(msg)) {
|
||||
log('Clearing session (resetting continuation)');
|
||||
continuation = undefined;
|
||||
clearStoredSessionId();
|
||||
clearContinuation(config.providerName);
|
||||
writeMessageOut({
|
||||
id: generateId(),
|
||||
kind: 'chat',
|
||||
@@ -160,10 +171,10 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
const skippedSet = new Set(skipped);
|
||||
const processingIds = ids.filter((id) => !commandIds.includes(id) && !skippedSet.has(id));
|
||||
try {
|
||||
const result = await processQuery(query, routing, processingIds);
|
||||
const result = await processQuery(query, routing, processingIds, config.providerName);
|
||||
if (result.continuation && result.continuation !== continuation) {
|
||||
continuation = result.continuation;
|
||||
setStoredSessionId(continuation);
|
||||
setContinuation(config.providerName, continuation);
|
||||
}
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
@@ -175,7 +186,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
if (continuation && config.provider.isSessionInvalid(err)) {
|
||||
log(`Stale session detected (${continuation}) — clearing for next retry`);
|
||||
continuation = undefined;
|
||||
clearStoredSessionId();
|
||||
clearContinuation(config.providerName);
|
||||
}
|
||||
|
||||
// Write error response so the user knows something went wrong
|
||||
@@ -238,6 +249,7 @@ async function processQuery(
|
||||
query: AgentQuery,
|
||||
routing: RoutingContext,
|
||||
initialBatchIds: string[],
|
||||
providerName: string,
|
||||
): Promise<QueryResult> {
|
||||
let queryContinuation: string | undefined;
|
||||
let done = false;
|
||||
@@ -288,7 +300,7 @@ async function processQuery(
|
||||
// container died between `init` and `result`, the SDK session was
|
||||
// effectively orphaned and the next message started a blank
|
||||
// Claude session with no prior context.
|
||||
setStoredSessionId(event.continuation);
|
||||
setContinuation(providerName, event.continuation);
|
||||
} else if (event.type === 'result') {
|
||||
// A result — with or without text — means the turn is done. Mark
|
||||
// the initial batch completed now so the host sweep doesn't see
|
||||
|
||||
@@ -0,0 +1,504 @@
|
||||
;;; nanoclaw.el --- Emacs interface for NanoClaw AI assistant -*- lexical-binding: t -*-
|
||||
|
||||
;; Author: NanoClaw
|
||||
;; Version: 0.1.0
|
||||
;; Package-Requires: ((emacs "27.1"))
|
||||
;; Keywords: ai, assistant, chat
|
||||
;;
|
||||
;; Vanilla Emacs (init.el):
|
||||
;; (load-file "~/src/nanoclaw/emacs/nanoclaw.el")
|
||||
;; (global-set-key (kbd "C-c n c") #'nanoclaw-chat)
|
||||
;; (global-set-key (kbd "C-c n o") #'nanoclaw-org-send)
|
||||
;;
|
||||
;; Spacemacs (~/.spacemacs, in dotspacemacs/user-config):
|
||||
;; (load-file "~/src/nanoclaw/emacs/nanoclaw.el")
|
||||
;; (spacemacs/set-leader-keys "aNc" #'nanoclaw-chat)
|
||||
;; (spacemacs/set-leader-keys "aNo" #'nanoclaw-org-send)
|
||||
;;
|
||||
;; Doom Emacs (config.el):
|
||||
;; (load (expand-file-name "~/src/nanoclaw/emacs/nanoclaw.el"))
|
||||
;; (map! :leader
|
||||
;; :prefix ("N" . "NanoClaw")
|
||||
;; :desc "Chat buffer" "c" #'nanoclaw-chat
|
||||
;; :desc "Send org" "o" #'nanoclaw-org-send)
|
||||
;; ;; Evil users: teach evil about the C-c C-c send binding
|
||||
;; (after! evil
|
||||
;; (evil-define-key '(normal insert) nanoclaw-chat-mode-map
|
||||
;; (kbd "C-c C-c") #'nanoclaw-chat-send))
|
||||
|
||||
;;; Code:
|
||||
|
||||
(require 'cl-lib)
|
||||
(require 'url)
|
||||
(require 'json)
|
||||
(require 'org)
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Customization
|
||||
|
||||
(defgroup nanoclaw nil
|
||||
"NanoClaw AI assistant interface."
|
||||
:group 'tools
|
||||
:prefix "nanoclaw-")
|
||||
|
||||
(defcustom nanoclaw-host "localhost"
|
||||
"Hostname where NanoClaw is running."
|
||||
:type 'string
|
||||
:group 'nanoclaw)
|
||||
|
||||
(defcustom nanoclaw-port 8766
|
||||
"Port for the NanoClaw Emacs channel HTTP server."
|
||||
:type 'integer
|
||||
:group 'nanoclaw)
|
||||
|
||||
(defcustom nanoclaw-auth-token nil
|
||||
"Bearer token for NanoClaw authentication (matches EMACS_AUTH_TOKEN in .env).
|
||||
Leave nil if EMACS_AUTH_TOKEN is not set."
|
||||
:type '(choice (const nil) string)
|
||||
:group 'nanoclaw)
|
||||
|
||||
(defcustom nanoclaw-poll-interval 1.5
|
||||
"Seconds between response polls when waiting for a reply."
|
||||
:type 'number
|
||||
:group 'nanoclaw)
|
||||
|
||||
(defcustom nanoclaw-agent-name "Andy"
|
||||
"Display name for the NanoClaw agent (matches ASSISTANT_NAME in .env)."
|
||||
:type 'string
|
||||
:group 'nanoclaw)
|
||||
|
||||
(defcustom nanoclaw-convert-to-org t
|
||||
"When non-nil, convert agent responses to org-mode format.
|
||||
Uses pandoc when available; falls back to regex substitutions."
|
||||
:type 'boolean
|
||||
:group 'nanoclaw)
|
||||
|
||||
(defcustom nanoclaw-timestamp-format "%H:%M"
|
||||
"Format string for timestamps shown next to agent replies in the chat buffer.
|
||||
Passed to `format-time-string'. Set to nil to suppress timestamps."
|
||||
:type '(choice (const nil) string)
|
||||
:group 'nanoclaw)
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Formatting helpers
|
||||
|
||||
(defun nanoclaw--to-org (text)
|
||||
"Convert TEXT (markdown or plain) to org-mode markup.
|
||||
Tries pandoc -f gfm -t org when available; falls back to regex."
|
||||
(if (not nanoclaw-convert-to-org)
|
||||
text
|
||||
(if (executable-find "pandoc")
|
||||
(with-temp-buffer
|
||||
(insert text)
|
||||
(let* ((coding-system-for-read 'utf-8)
|
||||
(coding-system-for-write 'utf-8)
|
||||
(exit (call-process-region
|
||||
(point-min) (point-max)
|
||||
"pandoc" t t nil "-f" "gfm" "-t" "org" "--wrap=none")))
|
||||
(if (zerop exit)
|
||||
(string-trim (buffer-string))
|
||||
text)))
|
||||
(nanoclaw--md-to-org-regex text))))
|
||||
|
||||
;; NOTE: This function expects standard markdown as input (e.g. **bold**, *italic*).
|
||||
;; Agents responding on this channel must output markdown, not org-mode syntax.
|
||||
;; If the agent outputs org-mode directly, markers like *bold* will be incorrectly
|
||||
;; re-converted to /bold/ by the italic rule.
|
||||
(defun nanoclaw--md-to-org-regex (text)
|
||||
"Lightweight markdown → org conversion using regexp substitutions."
|
||||
(let ((s text))
|
||||
;; Fenced code blocks ```lang\n…\n``` → #+begin_src lang\n…\n#+end_src
|
||||
;; (must run before inline-code to avoid mangling backticks)
|
||||
(setq s (replace-regexp-in-string
|
||||
"```\\([a-zA-Z0-9_-]*\\)\n\\(\\(?:.\\|\n\\)*?\\)```"
|
||||
(lambda (m)
|
||||
(let ((lang (match-string 1 m))
|
||||
(body (match-string 2 m)))
|
||||
(concat "#+begin_src " (if (string-empty-p lang) "text" lang)
|
||||
"\n" body "#+end_src")))
|
||||
s t))
|
||||
;; Bold **text** → *text*, italic *text* → /text/
|
||||
;; Two-pass to prevent the italic regex from re-matching the bold result:
|
||||
;; 1. Mark bold spans with a placeholder (control char \x01)
|
||||
(setq s (replace-regexp-in-string "\\*\\*\\(.+?\\)\\*\\*" "\x01\\1\x01" s))
|
||||
;; 2. Convert remaining single-star spans to italic
|
||||
(setq s (replace-regexp-in-string "\\*\\(.+?\\)\\*" "/\\1/" s))
|
||||
;; 3. Resolve bold placeholders to org bold markers
|
||||
(setq s (replace-regexp-in-string "\x01\\(.+?\\)\x01" "*\\1*" s))
|
||||
;; Strikethrough ~~text~~ → +text+
|
||||
(setq s (replace-regexp-in-string "~~\\(.+?\\)~~" "+\\1+" s))
|
||||
;; Underline __text__ → _text_
|
||||
(setq s (replace-regexp-in-string "__\\(.+?\\)__" "_\\1_" s))
|
||||
;; Inline code `code` → ~code~
|
||||
(setq s (replace-regexp-in-string "`\\([^`]+\\)`" "~\\1~" s))
|
||||
;; ATX headings ## … → ** …
|
||||
(setq s (replace-regexp-in-string
|
||||
"^\\(#+\\) "
|
||||
(lambda (m) (concat (make-string (length (match-string 1 m)) ?*) " "))
|
||||
s))
|
||||
;; Links [text](url) → [[url][text]]
|
||||
(setq s (replace-regexp-in-string
|
||||
"\\[\\([^]]+\\)\\](\\([^)]+\\))" "[[\\2][\\1]]" s))
|
||||
s))
|
||||
|
||||
(defun nanoclaw--format-timestamp ()
|
||||
"Return a formatted timestamp string, or nil if disabled."
|
||||
(when nanoclaw-timestamp-format
|
||||
(format-time-string nanoclaw-timestamp-format)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Internal state
|
||||
|
||||
(defvar nanoclaw--poll-timer nil
|
||||
"Timer used to poll for responses in the chat buffer.")
|
||||
|
||||
(defvar nanoclaw--last-timestamp 0
|
||||
"Epoch ms of the most recently received message.")
|
||||
|
||||
(defvar nanoclaw--pending nil
|
||||
"Non-nil while waiting for a response.")
|
||||
|
||||
(defvar-local nanoclaw--thinking-dot-count 0
|
||||
"Dot cycle counter for the animated thinking indicator.")
|
||||
|
||||
(defvar-local nanoclaw--input-beg nil
|
||||
"Marker for the start of the current user input area.")
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; HTTP helpers
|
||||
|
||||
(defun nanoclaw--url (path)
|
||||
"Return the full URL for PATH on the NanoClaw server."
|
||||
(format "http://%s:%d%s" nanoclaw-host nanoclaw-port path))
|
||||
|
||||
(defun nanoclaw--headers ()
|
||||
"Return alist of HTTP headers for NanoClaw requests."
|
||||
(let ((hdrs '(("Content-Type" . "application/json"))))
|
||||
(when nanoclaw-auth-token
|
||||
(push (cons "Authorization" (concat "Bearer " nanoclaw-auth-token)) hdrs))
|
||||
hdrs))
|
||||
|
||||
(defun nanoclaw--post (text callback)
|
||||
"POST TEXT to NanoClaw and call CALLBACK with the response alist."
|
||||
(let* ((url-request-method "POST")
|
||||
(url-request-extra-headers (nanoclaw--headers))
|
||||
(url-request-data (encode-coding-string
|
||||
(json-encode `((text . ,text)))
|
||||
'utf-8)))
|
||||
(url-retrieve
|
||||
(nanoclaw--url "/api/message")
|
||||
(lambda (status)
|
||||
(if (plist-get status :error)
|
||||
(message "NanoClaw: POST error %s" (plist-get status :error))
|
||||
(goto-char (point-min))
|
||||
(re-search-forward "\n\n" nil t)
|
||||
(let ((data (ignore-errors (json-read))))
|
||||
(funcall callback data))))
|
||||
nil t t)))
|
||||
|
||||
(defun nanoclaw--poll (since callback)
|
||||
"GET messages newer than SINCE (epoch ms) and call CALLBACK with the list."
|
||||
(let* ((url-request-method "GET")
|
||||
(url-request-extra-headers (nanoclaw--headers)))
|
||||
(url-retrieve
|
||||
(nanoclaw--url (format "/api/messages?since=%d" since))
|
||||
(lambda (status)
|
||||
(unless (plist-get status :error)
|
||||
(goto-char (point-min))
|
||||
(re-search-forward "\n\n" nil t)
|
||||
(let* ((raw (buffer-substring-no-properties (point) (point-max)))
|
||||
(body (decode-coding-string raw 'utf-8))
|
||||
(data (ignore-errors (json-read-from-string body)))
|
||||
(msgs (cdr (assq 'messages data))))
|
||||
(when msgs (funcall callback (append msgs nil))))))
|
||||
nil t t)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Chat buffer
|
||||
|
||||
(defvar nanoclaw-chat-mode-map
|
||||
(let ((map (make-sparse-keymap)))
|
||||
(define-key map (kbd "RET") #'newline)
|
||||
(define-key map (kbd "<return>") #'newline)
|
||||
(define-key map (kbd "C-c C-c") #'nanoclaw-chat-send)
|
||||
map)
|
||||
"Keymap for `nanoclaw-chat-mode'.")
|
||||
|
||||
(define-derived-mode nanoclaw-chat-mode org-mode "NanoClaw"
|
||||
"Major mode for the NanoClaw chat buffer.
|
||||
Derives from org-mode so that org markup (headings, bold, code blocks,
|
||||
etc.) is fontified automatically. RET and <return> insert plain newlines
|
||||
for multi-line input; send with C-c C-c."
|
||||
(setq-local word-wrap t)
|
||||
(visual-line-mode 1)
|
||||
;; Disable org features that conflict with a linear chat buffer
|
||||
(setq-local org-return-follows-link nil)
|
||||
(setq-local org-cycle-emulate-tab nil)
|
||||
;; Ensure send binding beats org-mode's C-c C-c via the buffer-local map
|
||||
(local-set-key (kbd "C-c C-c") #'nanoclaw-chat-send))
|
||||
|
||||
(defun nanoclaw--advance-input-beg ()
|
||||
"Move `nanoclaw--input-beg' to point-max in the chat buffer."
|
||||
(with-current-buffer (nanoclaw--chat-buffer)
|
||||
(when nanoclaw--input-beg (set-marker nanoclaw--input-beg nil))
|
||||
(setq nanoclaw--input-beg (copy-marker (point-max)))))
|
||||
|
||||
(defun nanoclaw--chat-buffer ()
|
||||
"Return the NanoClaw chat buffer, creating it if necessary."
|
||||
(or (get-buffer "*NanoClaw*")
|
||||
(with-current-buffer (get-buffer-create "*NanoClaw*")
|
||||
(nanoclaw-chat-mode)
|
||||
(set-buffer-file-coding-system 'utf-8)
|
||||
(add-hook 'kill-buffer-hook #'nanoclaw--stop-poll nil t)
|
||||
(nanoclaw--insert-header)
|
||||
(setq nanoclaw--input-beg (copy-marker (point-max)))
|
||||
(current-buffer))))
|
||||
|
||||
(defun nanoclaw--insert-header ()
|
||||
"Insert the welcome header into the chat buffer."
|
||||
(let ((inhibit-read-only t))
|
||||
(insert (propertize
|
||||
(format "── NanoClaw (%s) ──────────────────────────────\n\n"
|
||||
nanoclaw-agent-name)
|
||||
'face 'font-lock-comment-face))))
|
||||
|
||||
(defun nanoclaw--chat-insert (speaker text)
|
||||
"Append SPEAKER: TEXT to the chat buffer."
|
||||
(with-current-buffer (nanoclaw--chat-buffer)
|
||||
(let* ((inhibit-read-only t)
|
||||
(is-agent (not (string= speaker "You")))
|
||||
(display-text (if is-agent (nanoclaw--to-org text) text))
|
||||
(ts (nanoclaw--format-timestamp))
|
||||
(label (if ts (format "%s [%s]" speaker ts) speaker))
|
||||
(face (if is-agent 'font-lock-string-face 'font-lock-keyword-face)))
|
||||
(goto-char (point-max))
|
||||
(insert (propertize (concat label ": ") 'face face))
|
||||
(insert display-text "\n\n")
|
||||
(goto-char (point-max))
|
||||
(when is-agent
|
||||
(nanoclaw--advance-input-beg)))))
|
||||
|
||||
;;;###autoload
|
||||
(defun nanoclaw-chat ()
|
||||
"Open the NanoClaw chat buffer."
|
||||
(interactive)
|
||||
(pop-to-buffer (nanoclaw--chat-buffer))
|
||||
(goto-char (point-max)))
|
||||
|
||||
(defun nanoclaw-chat-send ()
|
||||
"Send the accumulated input area as a message to NanoClaw.
|
||||
Use C-c C-c to send; RET inserts a plain newline for multi-line messages."
|
||||
(interactive)
|
||||
(when nanoclaw--pending
|
||||
(message "NanoClaw: waiting for previous response...")
|
||||
(cl-return-from nanoclaw-chat-send))
|
||||
(let* ((beg (if (and nanoclaw--input-beg (marker-buffer nanoclaw--input-beg))
|
||||
(marker-position nanoclaw--input-beg)
|
||||
(line-beginning-position)))
|
||||
(text (string-trim (buffer-substring-no-properties beg (point-max)))))
|
||||
(when (string-empty-p text)
|
||||
(user-error "Nothing to send"))
|
||||
(let ((inhibit-read-only t))
|
||||
(delete-region beg (point-max)))
|
||||
(nanoclaw--chat-insert "You" text)
|
||||
(nanoclaw--advance-input-beg)
|
||||
(setq nanoclaw--pending t)
|
||||
(nanoclaw--post text
|
||||
(lambda (data)
|
||||
(when data
|
||||
(setq nanoclaw--last-timestamp
|
||||
(or (cdr (assq 'timestamp data))
|
||||
nanoclaw--last-timestamp))
|
||||
(nanoclaw--start-thinking)
|
||||
(nanoclaw--start-poll))))))
|
||||
|
||||
(defun nanoclaw--start-poll ()
|
||||
"Start polling for new messages."
|
||||
(nanoclaw--stop-poll)
|
||||
(setq nanoclaw--poll-timer
|
||||
(run-with-timer nanoclaw-poll-interval nanoclaw-poll-interval
|
||||
#'nanoclaw--poll-tick)))
|
||||
|
||||
(defun nanoclaw--stop-poll ()
|
||||
"Stop the polling timer."
|
||||
(when nanoclaw--poll-timer
|
||||
(cancel-timer nanoclaw--poll-timer)
|
||||
(setq nanoclaw--poll-timer nil)))
|
||||
|
||||
(defun nanoclaw--start-thinking ()
|
||||
"Insert an animated thinking indicator at the end of the chat buffer."
|
||||
(with-current-buffer (nanoclaw--chat-buffer)
|
||||
(let ((inhibit-read-only t))
|
||||
(goto-char (point-max))
|
||||
(setq nanoclaw--thinking-dot-count 1)
|
||||
(insert (propertize (format "%s: .\n\n" nanoclaw-agent-name)
|
||||
'nanoclaw-thinking t
|
||||
'face 'font-lock-string-face)))))
|
||||
|
||||
(defun nanoclaw--tick-thinking ()
|
||||
"Advance the dot animation in the thinking indicator."
|
||||
(let ((buf (get-buffer "*NanoClaw*")))
|
||||
(when buf
|
||||
(with-current-buffer buf
|
||||
(when nanoclaw--pending
|
||||
(let* ((inhibit-read-only t)
|
||||
(pos (text-property-any (point-min) (point-max)
|
||||
'nanoclaw-thinking t)))
|
||||
(when pos
|
||||
(let* ((end (or (next-single-property-change
|
||||
pos 'nanoclaw-thinking) (point-max)))
|
||||
(n (1+ (mod nanoclaw--thinking-dot-count 3))))
|
||||
(setq nanoclaw--thinking-dot-count n)
|
||||
(delete-region pos end)
|
||||
(save-excursion
|
||||
(goto-char pos)
|
||||
(insert (propertize
|
||||
(format "%s: %s\n\n" nanoclaw-agent-name
|
||||
(make-string n ?.))
|
||||
'nanoclaw-thinking t
|
||||
'face 'font-lock-string-face)))))))))))
|
||||
|
||||
(defun nanoclaw--clear-thinking ()
|
||||
"Remove the thinking indicator from the chat buffer."
|
||||
(let ((buf (get-buffer "*NanoClaw*")))
|
||||
(when buf
|
||||
(with-current-buffer buf
|
||||
(let* ((inhibit-read-only t)
|
||||
(pos (text-property-any (point-min) (point-max)
|
||||
'nanoclaw-thinking t)))
|
||||
(when pos
|
||||
(delete-region pos (or (next-single-property-change
|
||||
pos 'nanoclaw-thinking) (point-max)))))))))
|
||||
|
||||
(defun nanoclaw--poll-tick ()
|
||||
"Poll for new messages and insert them into the chat buffer."
|
||||
(nanoclaw--tick-thinking)
|
||||
(nanoclaw--poll
|
||||
nanoclaw--last-timestamp
|
||||
(lambda (msgs)
|
||||
(dolist (msg msgs)
|
||||
(let ((text (cdr (assq 'text msg)))
|
||||
(ts (cdr (assq 'timestamp msg))))
|
||||
(when (and text (> ts nanoclaw--last-timestamp))
|
||||
(setq nanoclaw--last-timestamp ts)
|
||||
(nanoclaw--clear-thinking)
|
||||
(nanoclaw--chat-insert nanoclaw-agent-name text))))
|
||||
(when msgs
|
||||
(setq nanoclaw--pending nil)
|
||||
(nanoclaw--stop-poll)))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Org integration
|
||||
|
||||
;;;###autoload
|
||||
(defun nanoclaw-org-send ()
|
||||
"Send the current org subtree to NanoClaw and insert the response as a child.
|
||||
|
||||
If a region is active, send the region text instead."
|
||||
(interactive)
|
||||
(unless (derived-mode-p 'org-mode)
|
||||
(user-error "Not in an org-mode buffer"))
|
||||
(let ((text (if (use-region-p)
|
||||
(buffer-substring-no-properties (region-beginning) (region-end))
|
||||
(nanoclaw--org-subtree-text))))
|
||||
(when (string-empty-p (string-trim text))
|
||||
(user-error "Nothing to send"))
|
||||
(message "NanoClaw: sending to %s..." nanoclaw-agent-name)
|
||||
(let ((marker (point-marker))
|
||||
(buf (current-buffer)))
|
||||
(nanoclaw--post
|
||||
text
|
||||
(lambda (data)
|
||||
(let* ((ts (or (cdr (assq 'timestamp data)) (nanoclaw--now-ms)))
|
||||
(level (with-current-buffer buf
|
||||
(save-excursion (goto-char marker) (org-outline-level))))
|
||||
(ph (with-current-buffer buf
|
||||
(save-excursion
|
||||
(goto-char marker)
|
||||
(nanoclaw--org-insert-placeholder level)))))
|
||||
(nanoclaw--poll-until-response
|
||||
ts
|
||||
(lambda (response)
|
||||
(with-current-buffer buf
|
||||
(save-excursion
|
||||
(when (marker-buffer ph)
|
||||
(let* ((inhibit-read-only t)
|
||||
(beg (marker-position ph))
|
||||
(end (save-excursion
|
||||
(goto-char (1+ beg))
|
||||
(org-next-visible-heading 1)
|
||||
(point))))
|
||||
(delete-region beg end))
|
||||
(set-marker ph nil))
|
||||
(goto-char marker)
|
||||
(nanoclaw--org-insert-response response))))
|
||||
(lambda ()
|
||||
(message "NanoClaw: timed out waiting for response")
|
||||
(when (marker-buffer ph)
|
||||
(with-current-buffer (marker-buffer ph)
|
||||
(let* ((inhibit-read-only t)
|
||||
(beg (marker-position ph))
|
||||
(end (save-excursion
|
||||
(goto-char (1+ beg))
|
||||
(org-next-visible-heading 1)
|
||||
(point))))
|
||||
(delete-region beg end))
|
||||
(set-marker ph nil)))))))))))
|
||||
|
||||
(defun nanoclaw--org-insert-placeholder (level)
|
||||
"Insert a processing child heading at LEVEL+1 and return a marker at its start."
|
||||
(org-back-to-heading t)
|
||||
(org-end-of-subtree t t)
|
||||
(let ((beg (point)))
|
||||
(insert "\n" (make-string (1+ level) ?*) " "
|
||||
nanoclaw-agent-name " [processing...]\n\n")
|
||||
(copy-marker beg)))
|
||||
|
||||
(defun nanoclaw--org-subtree-text ()
|
||||
"Return the text of the org subtree at point (heading + body)."
|
||||
(org-with-wide-buffer
|
||||
(org-back-to-heading t)
|
||||
(let ((start (point))
|
||||
(end (progn (org-end-of-subtree t t) (point))))
|
||||
(buffer-substring-no-properties start end))))
|
||||
|
||||
(defun nanoclaw--org-insert-response (text)
|
||||
"Insert TEXT as a child org heading under the current subtree."
|
||||
(org-back-to-heading t)
|
||||
(let* ((level (org-outline-level))
|
||||
(child-stars (make-string (1+ level) ?*))
|
||||
(timestamp (format-time-string "[%Y-%m-%d %a %H:%M]"))
|
||||
(body (nanoclaw--to-org text)))
|
||||
(org-end-of-subtree t t)
|
||||
(insert "\n" child-stars " " nanoclaw-agent-name " " timestamp "\n"
|
||||
body "\n")))
|
||||
|
||||
(defun nanoclaw--now-ms ()
|
||||
"Return current time as milliseconds since epoch."
|
||||
(let ((time (current-time)))
|
||||
(+ (* (+ (* (car time) 65536) (cadr time)) 1000)
|
||||
(/ (caddr time) 1000))))
|
||||
|
||||
(defun nanoclaw--poll-until-response (since callback timeout-fn &optional attempts)
|
||||
"Poll until a message newer than SINCE arrives, then call CALLBACK.
|
||||
Calls TIMEOUT-FN after 60 attempts (~90s)."
|
||||
(let ((n (or attempts 0)))
|
||||
(if (>= n 60)
|
||||
(funcall timeout-fn)
|
||||
(nanoclaw--poll
|
||||
since
|
||||
(lambda (msgs)
|
||||
(let ((fresh (seq-filter (lambda (m) (> (cdr (assq 'timestamp m)) since))
|
||||
msgs)))
|
||||
(if fresh
|
||||
(let ((text (mapconcat (lambda (m) (cdr (assq 'text m)))
|
||||
fresh "\n")))
|
||||
(funcall callback text))
|
||||
(run-with-timer nanoclaw-poll-interval nil
|
||||
#'nanoclaw--poll-until-response
|
||||
since callback timeout-fn (1+ n)))))))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(provide 'nanoclaw)
|
||||
;;; nanoclaw.el ends here
|
||||
+19
-7
@@ -129,10 +129,10 @@ rm -f "$PROGRESS_LOG"
|
||||
mkdir -p "$STEPS_DIR" "$LOGS_DIR"
|
||||
write_header
|
||||
|
||||
# NanoClaw wordmark + subtitle — setup:auto will see NANOCLAW_BOOTSTRAPPED=1
|
||||
# and skip printing these again, so the flow stays visually continuous.
|
||||
printf '\n %s%s\n' "$(bold 'Nano')" "$(brand_bold 'Claw')"
|
||||
printf ' %s\n\n' "$(dim 'Setting up your personal AI assistant')"
|
||||
# NanoClaw wordmark — clack's intro carries the "let's get you set up" framing,
|
||||
# so we don't print a subtitle here. setup:auto sees NANOCLAW_BOOTSTRAPPED=1 and
|
||||
# skips re-printing the wordmark, keeping the flow visually continuous.
|
||||
printf '\n %s%s\n\n' "$(bold 'Nano')" "$(brand_bold 'Claw')"
|
||||
|
||||
# ─── pre-flight: Homebrew on macOS ─────────────────────────────────────
|
||||
# setup/install-node.sh and setup/install-docker.sh both require `brew` on
|
||||
@@ -190,7 +190,7 @@ BOOTSTRAP_START=$(date +%s)
|
||||
|
||||
# One-line "why" that teaches a differentiator while the user waits.
|
||||
printf '%s %s\n' "$(gray '│')" \
|
||||
"$(dim "NanoClaw is small and runs entirely on your machine. Yours to modify.")"
|
||||
"$(dim "Small. Runs on your machine. Yours to modify.")"
|
||||
spinner_start "$BOOTSTRAP_LABEL"
|
||||
|
||||
# Run in the background so we can tick elapsed time. Capture exit code via
|
||||
@@ -222,7 +222,7 @@ rm -f "$BOOTSTRAP_EXIT_FILE"
|
||||
BOOTSTRAP_DUR=$(( $(date +%s) - BOOTSTRAP_START ))
|
||||
|
||||
if [ "$BOOTSTRAP_RC" -eq 0 ]; then
|
||||
spinner_success "Basics installed" "$BOOTSTRAP_DUR"
|
||||
spinner_success "Basics ready" "$BOOTSTRAP_DUR"
|
||||
write_bootstrap_entry success "$BOOTSTRAP_DUR" "$BOOTSTRAP_RAW"
|
||||
else
|
||||
spinner_failure "Couldn't install the basics" "$BOOTSTRAP_DUR"
|
||||
@@ -245,7 +245,19 @@ fi
|
||||
# wipe it.
|
||||
export NANOCLAW_BOOTSTRAPPED=1
|
||||
|
||||
# setup.sh may have just installed pnpm via npm into a prefix that's not on
|
||||
# our PATH (custom `npm config set prefix`, or the default prefix missing
|
||||
# from the shell's login PATH). Its PATH mutation doesn't propagate back
|
||||
# to us — so replay the same lookup here before the exec.
|
||||
if ! command -v pnpm >/dev/null 2>&1 && command -v npm >/dev/null 2>&1; then
|
||||
NPM_PREFIX="$(npm config get prefix 2>/dev/null)"
|
||||
if [ -n "$NPM_PREFIX" ] && [ -x "$NPM_PREFIX/bin/pnpm" ]; then
|
||||
export PATH="$NPM_PREFIX/bin:$PATH"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --silent suppresses pnpm's `> nanoclaw@2.0.0 setup:auto / > tsx setup/auto.ts`
|
||||
# preamble so the flow continues visually from "Basics installed" straight
|
||||
# into setup:auto's spinner. exec so signals (Ctrl-C) propagate directly.
|
||||
exec pnpm --silent run setup:auto
|
||||
# `-- "$@"` forwards any flags (e.g. --onecli-api-host) to setup:auto.
|
||||
exec pnpm --silent run setup:auto -- "$@"
|
||||
|
||||
+21
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nanoclaw",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.14",
|
||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
@@ -24,13 +24,31 @@
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@beeper/chat-adapter-matrix": "^0.2.0",
|
||||
"@bitbasti/chat-adapter-webex": "^0.1.0",
|
||||
"@chat-adapter/discord": "4.29.0",
|
||||
"@chat-adapter/gchat": "4.29.0",
|
||||
"@chat-adapter/github": "4.29.0",
|
||||
"@chat-adapter/linear": "4.29.0",
|
||||
"@chat-adapter/slack": "4.29.0",
|
||||
"@chat-adapter/state-memory": "4.29.0",
|
||||
"@chat-adapter/teams": "4.29.0",
|
||||
"@chat-adapter/telegram": "4.29.0",
|
||||
"@chat-adapter/whatsapp": "4.29.0",
|
||||
"@clack/core": "^1.2.0",
|
||||
"@clack/prompts": "^1.2.0",
|
||||
"@onecli-sh/sdk": "^0.3.1",
|
||||
"@resend/chat-sdk-adapter": "^0.1.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@whiskeysockets/baileys": "7.0.0-rc.9",
|
||||
"better-sqlite3": "11.10.0",
|
||||
"chat": "^4.24.0",
|
||||
"chat": "4.29.0",
|
||||
"chat-adapter-imessage": "^0.1.1",
|
||||
"cron-parser": "5.5.0",
|
||||
"kleur": "^4.1.5"
|
||||
"kleur": "^4.1.5",
|
||||
"pino": "^9.6.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"wechat-ilink-client": "^0.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.35.0",
|
||||
|
||||
Generated
+3925
-4
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="128k tokens, 64% of context window">
|
||||
<title>128k tokens, 64% of context window</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="133k tokens, 66% of context window">
|
||||
<title>133k tokens, 66% of context window</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,8 +15,8 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
||||
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
|
||||
<text x="26" y="14">tokens</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">128k</text>
|
||||
<text x="71" y="14">128k</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">133k</text>
|
||||
<text x="71" y="14">133k</text>
|
||||
</g>
|
||||
</g>
|
||||
</a>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Initialize the scratch CLI agent used during `/new-setup`.
|
||||
* Initialize the scratch CLI agent used during `/setup`.
|
||||
*
|
||||
* Creates the synthetic `cli:local` user, grants owner role if no owner
|
||||
* exists yet, builds an agent group with a minimal CLAUDE.md, and wires it
|
||||
|
||||
@@ -48,6 +48,7 @@ import { addMember } from '../src/modules/permissions/db/agent-group-members.js'
|
||||
import { getUserRoles, grantRole } from '../src/modules/permissions/db/user-roles.js';
|
||||
import { upsertUser } from '../src/modules/permissions/db/users.js';
|
||||
import { initGroupFilesystem } from '../src/group-init.js';
|
||||
import { namespacedPlatformId } from '../src/platform-id.js';
|
||||
import type { AgentGroup, MessagingGroup } from '../src/types.js';
|
||||
|
||||
type Role = 'owner' | 'admin' | 'member';
|
||||
@@ -137,16 +138,6 @@ function namespacedUserId(channel: string, raw: string): string {
|
||||
return raw.includes(':') ? raw : `${channel}:${raw}`;
|
||||
}
|
||||
|
||||
function namespacedPlatformId(channel: string, raw: string): string {
|
||||
if (raw.startsWith(`${channel}:`)) return raw;
|
||||
// Adapters using native JID format (WhatsApp: <phone>@s.whatsapp.net,
|
||||
// <groupId>@g.us) store platform_id without a channel prefix. The '@' is
|
||||
// the discriminator — telegram/discord platform_ids don't contain it
|
||||
// except after a channel prefix, which is already handled above.
|
||||
if (raw.includes('@')) return raw;
|
||||
return `${channel}:${raw}`;
|
||||
}
|
||||
|
||||
function generateId(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
@@ -120,6 +120,20 @@ install_deps() {
|
||||
|| true
|
||||
fi
|
||||
|
||||
# `npm install -g` writes to npm's global prefix, which isn't always on the
|
||||
# shell PATH — common on macOS where the user has `npm config set prefix
|
||||
# ~/.npm-global` to avoid sudo, or on Linux where /usr/local/bin isn't in
|
||||
# PATH. Discover the prefix and prepend its bin dir so `command -v pnpm`
|
||||
# sees the new install.
|
||||
if ! command -v pnpm >/dev/null 2>&1 && command -v npm >/dev/null 2>&1; then
|
||||
local npm_prefix
|
||||
npm_prefix=$(npm config get prefix 2>/dev/null)
|
||||
if [ -n "$npm_prefix" ] && [ -x "$npm_prefix/bin/pnpm" ]; then
|
||||
export PATH="$npm_prefix/bin:$PATH"
|
||||
log "Prepended npm prefix bin to PATH: $npm_prefix/bin"
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! command -v pnpm >/dev/null 2>&1; then
|
||||
log "pnpm not on PATH after corepack + npm fallback"
|
||||
return
|
||||
|
||||
@@ -15,7 +15,7 @@ PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with .claude/skills/add-discord/SKILL.md.
|
||||
ADAPTER_VERSION="@chat-adapter/discord@4.26.0"
|
||||
ADAPTER_VERSION="@chat-adapter/discord@4.29.0"
|
||||
|
||||
# Resolve which remote carries the channels branch — handles forks where
|
||||
# upstream lives on a different remote than `origin`.
|
||||
|
||||
Executable
+160
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Install the iMessage adapter, persist mode/creds to .env + data/env/env,
|
||||
# and restart the service. Non-interactive — the Full Disk Access walkthrough
|
||||
# (local mode) and Photon URL/key prompts (remote mode) live in
|
||||
# setup/channels/imessage.ts. Creds come in via env vars:
|
||||
# IMESSAGE_LOCAL 'true' | 'false' (required)
|
||||
# IMESSAGE_ENABLED 'true' (required when IMESSAGE_LOCAL=true)
|
||||
# IMESSAGE_SERVER_URL (required when IMESSAGE_LOCAL=false)
|
||||
# IMESSAGE_API_KEY (required when IMESSAGE_LOCAL=false)
|
||||
#
|
||||
# Emits exactly one status block on stdout (ADD_IMESSAGE) at the end.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with .claude/skills/add-imessage/SKILL.md.
|
||||
ADAPTER_VERSION="chat-adapter-imessage@0.1.1"
|
||||
|
||||
# Resolve which remote carries the channels branch — handles forks where
|
||||
# upstream lives on a different remote than `origin`.
|
||||
# shellcheck source=setup/lib/channels-remote.sh
|
||||
source "$PROJECT_ROOT/setup/lib/channels-remote.sh"
|
||||
CHANNELS_REMOTE=$(resolve_channels_remote)
|
||||
CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels"
|
||||
|
||||
emit_status() {
|
||||
local status=$1 error=${2:-}
|
||||
local already=${ADAPTER_ALREADY_INSTALLED:-false}
|
||||
local mode=${IMESSAGE_LOCAL:-}
|
||||
echo "=== NANOCLAW SETUP: ADD_IMESSAGE ==="
|
||||
echo "STATUS: ${status}"
|
||||
echo "ADAPTER_VERSION: ${ADAPTER_VERSION}"
|
||||
echo "ADAPTER_ALREADY_INSTALLED: ${already}"
|
||||
[ -n "$mode" ] && echo "MODE: $([ "$mode" = "true" ] && echo local || echo remote)"
|
||||
[ -n "$error" ] && echo "ERROR: ${error}"
|
||||
echo "=== END ==="
|
||||
}
|
||||
|
||||
log() { echo "[add-imessage] $*" >&2; }
|
||||
|
||||
# Validate creds based on mode.
|
||||
if [ -z "${IMESSAGE_LOCAL:-}" ]; then
|
||||
emit_status failed "IMESSAGE_LOCAL env var not set (expected true|false)"
|
||||
exit 1
|
||||
fi
|
||||
if [ "${IMESSAGE_LOCAL}" = "true" ]; then
|
||||
if [ -z "${IMESSAGE_ENABLED:-}" ]; then
|
||||
emit_status failed "IMESSAGE_ENABLED env var not set for local mode"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(uname -s)" != "Darwin" ]; then
|
||||
emit_status failed "local mode requires macOS"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
if [ -z "${IMESSAGE_SERVER_URL:-}" ]; then
|
||||
emit_status failed "IMESSAGE_SERVER_URL env var not set for remote mode"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${IMESSAGE_API_KEY:-}" ]; then
|
||||
emit_status failed "IMESSAGE_API_KEY env var not set for remote mode"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
need_install() {
|
||||
[ ! -f src/channels/imessage.ts ] && return 0
|
||||
! grep -q "^import './imessage.js';" src/channels/index.ts 2>/dev/null && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
ADAPTER_ALREADY_INSTALLED=true
|
||||
if need_install; then
|
||||
ADAPTER_ALREADY_INSTALLED=false
|
||||
log "Fetching channels branch…"
|
||||
git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || {
|
||||
emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log "Copying adapter from ${CHANNELS_BRANCH}…"
|
||||
git show "${CHANNELS_BRANCH}:src/channels/imessage.ts" > src/channels/imessage.ts
|
||||
|
||||
# Append self-registration import if missing.
|
||||
if ! grep -q "^import './imessage.js';" src/channels/index.ts; then
|
||||
echo "import './imessage.js';" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
log "Installing ${ADAPTER_VERSION}…"
|
||||
pnpm install "${ADAPTER_VERSION}" >&2 2>/dev/null || {
|
||||
emit_status failed "pnpm install ${ADAPTER_VERSION} failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log "Building…"
|
||||
pnpm run build >&2 2>/dev/null || {
|
||||
emit_status failed "pnpm run build failed"
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
log "Adapter files already installed — skipping install phase."
|
||||
fi
|
||||
|
||||
touch .env
|
||||
upsert_env() {
|
||||
local key=$1 value=$2
|
||||
if grep -q "^${key}=" .env; then
|
||||
awk -v k="$key" -v v="$value" \
|
||||
'BEGIN{FS=OFS="="} $1==k {print k "=" v; next} {print}' \
|
||||
.env > .env.tmp && mv .env.tmp .env
|
||||
else
|
||||
echo "${key}=${value}" >> .env
|
||||
fi
|
||||
}
|
||||
|
||||
remove_env() {
|
||||
local key=$1
|
||||
if grep -q "^${key}=" .env 2>/dev/null; then
|
||||
grep -v "^${key}=" .env > .env.tmp && mv .env.tmp .env
|
||||
fi
|
||||
}
|
||||
|
||||
# Write the canonical keys for the chosen mode, strip the opposite mode's
|
||||
# keys so stale values can't confuse the adapter's factory.
|
||||
upsert_env IMESSAGE_LOCAL "$IMESSAGE_LOCAL"
|
||||
if [ "$IMESSAGE_LOCAL" = "true" ]; then
|
||||
upsert_env IMESSAGE_ENABLED "$IMESSAGE_ENABLED"
|
||||
remove_env IMESSAGE_SERVER_URL
|
||||
remove_env IMESSAGE_API_KEY
|
||||
else
|
||||
upsert_env IMESSAGE_SERVER_URL "$IMESSAGE_SERVER_URL"
|
||||
upsert_env IMESSAGE_API_KEY "$IMESSAGE_API_KEY"
|
||||
remove_env IMESSAGE_ENABLED
|
||||
fi
|
||||
|
||||
# Container reads from data/env/env (the host mounts it).
|
||||
mkdir -p data/env
|
||||
cp .env data/env/env
|
||||
|
||||
log "Restarting service so the new adapter picks up the creds…"
|
||||
# shellcheck source=setup/lib/install-slug.sh
|
||||
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
|
||||
case "$(uname -s)" in
|
||||
Darwin)
|
||||
launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true
|
||||
;;
|
||||
Linux)
|
||||
systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \
|
||||
|| sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \
|
||||
|| true
|
||||
;;
|
||||
esac
|
||||
|
||||
# Give the adapter a moment to open chat.db (local) or handshake with
|
||||
# Photon (remote) before emitting success.
|
||||
sleep 3
|
||||
|
||||
emit_status success
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Install the Signal adapter in an already-running NanoClaw checkout.
|
||||
# Non-interactive — the operator-facing "install signal-cli" + QR scan
|
||||
# live in setup/channels/signal.ts. This script only:
|
||||
#
|
||||
# 1. Fetches src/channels/signal.ts + signal.test.ts from the channels
|
||||
# branch.
|
||||
# 2. Appends the self-registration import to src/channels/index.ts.
|
||||
# 3. Installs qrcode (for setup-flow QR rendering — adapter itself has
|
||||
# no npm deps).
|
||||
# 4. Builds.
|
||||
#
|
||||
# SIGNAL_ACCOUNT is persisted separately by the driver once signal-cli
|
||||
# link has produced a number; that keeps this script idempotent and
|
||||
# re-runnable without re-auth.
|
||||
#
|
||||
# Emits exactly one status block on stdout (ADD_SIGNAL) at the end. All
|
||||
# chatty progress goes to stderr so setup:auto's raw-log capture sees
|
||||
# the full story without cluttering the final block for the parser.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with .claude/skills/add-signal/SKILL.md.
|
||||
QRCODE_VERSION="qrcode@1.5.4"
|
||||
QRCODE_TYPES_VERSION="@types/qrcode@1.5.6"
|
||||
|
||||
# shellcheck source=setup/lib/channels-remote.sh
|
||||
source "$PROJECT_ROOT/setup/lib/channels-remote.sh"
|
||||
CHANNELS_REMOTE=$(resolve_channels_remote)
|
||||
CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels"
|
||||
|
||||
emit_status() {
|
||||
local status=$1 error=${2:-}
|
||||
local already=${ADAPTER_ALREADY_INSTALLED:-false}
|
||||
echo "=== NANOCLAW SETUP: ADD_SIGNAL ==="
|
||||
echo "STATUS: ${status}"
|
||||
echo "ADAPTER_ALREADY_INSTALLED: ${already}"
|
||||
[ -n "$error" ] && echo "ERROR: ${error}"
|
||||
echo "=== END ==="
|
||||
}
|
||||
|
||||
log() { echo "[add-signal] $*" >&2; }
|
||||
|
||||
need_install() {
|
||||
[ ! -f src/channels/signal.ts ] && return 0
|
||||
! grep -q "^import './signal.js';" src/channels/index.ts 2>/dev/null && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
ADAPTER_ALREADY_INSTALLED=true
|
||||
if need_install; then
|
||||
ADAPTER_ALREADY_INSTALLED=false
|
||||
log "Fetching channels branch…"
|
||||
git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || {
|
||||
emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log "Copying adapter files from ${CHANNELS_BRANCH}…"
|
||||
for f in \
|
||||
src/channels/signal.ts \
|
||||
src/channels/signal.test.ts
|
||||
do
|
||||
git show "${CHANNELS_BRANCH}:$f" > "$f" || {
|
||||
emit_status failed "git show ${CHANNELS_BRANCH}:$f failed"
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
if ! grep -q "^import './signal.js';" src/channels/index.ts; then
|
||||
echo "import './signal.js';" >> src/channels/index.ts
|
||||
fi
|
||||
fi
|
||||
|
||||
# qrcode is needed by setup/signal-auth.ts to render the linking URL as a
|
||||
# terminal QR. Install idempotently — if it's already present (e.g. from a
|
||||
# prior WhatsApp install) pnpm is a no-op.
|
||||
if ! node -e "require.resolve('qrcode')" >/dev/null 2>&1; then
|
||||
log "Installing ${QRCODE_VERSION}…"
|
||||
pnpm install "${QRCODE_VERSION}" "${QRCODE_TYPES_VERSION}" >&2 2>/dev/null || {
|
||||
emit_status failed "pnpm install ${QRCODE_VERSION} failed"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
log "Building…"
|
||||
pnpm run build >&2 2>/dev/null || {
|
||||
emit_status failed "pnpm run build failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
emit_status success
|
||||
Executable
+133
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Install the Slack adapter, persist SLACK_BOT_TOKEN plus the mode-specific
|
||||
# secret (SLACK_APP_TOKEN for Socket Mode, SLACK_SIGNING_SECRET for webhook) to
|
||||
# .env + data/env/env, and restart the service. Non-interactive — the
|
||||
# operator-facing app creation walkthrough + credential paste live in
|
||||
# setup/channels/slack.ts. Credentials come in via env vars:
|
||||
# SLACK_BOT_TOKEN, and SLACK_APP_TOKEN and/or SLACK_SIGNING_SECRET.
|
||||
#
|
||||
# Emits exactly one status block on stdout (ADD_SLACK) at the end. All chatty
|
||||
# progress messages go to stderr so setup:auto's raw-log capture sees the full
|
||||
# story without cluttering the final block for the parser.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with .claude/skills/add-slack/SKILL.md.
|
||||
ADAPTER_VERSION="@chat-adapter/slack@4.29.0"
|
||||
|
||||
# Resolve which remote carries the channels branch — handles forks where
|
||||
# upstream lives on a different remote than `origin`.
|
||||
# shellcheck source=setup/lib/channels-remote.sh
|
||||
source "$PROJECT_ROOT/setup/lib/channels-remote.sh"
|
||||
CHANNELS_REMOTE=$(resolve_channels_remote)
|
||||
CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels"
|
||||
|
||||
emit_status() {
|
||||
local status=$1 error=${2:-}
|
||||
local already=${ADAPTER_ALREADY_INSTALLED:-false}
|
||||
echo "=== NANOCLAW SETUP: ADD_SLACK ==="
|
||||
echo "STATUS: ${status}"
|
||||
echo "ADAPTER_VERSION: ${ADAPTER_VERSION}"
|
||||
echo "ADAPTER_ALREADY_INSTALLED: ${already}"
|
||||
[ -n "$error" ] && echo "ERROR: ${error}"
|
||||
echo "=== END ==="
|
||||
}
|
||||
|
||||
log() { echo "[add-slack] $*" >&2; }
|
||||
|
||||
if [ -z "${SLACK_BOT_TOKEN:-}" ]; then
|
||||
emit_status failed "SLACK_BOT_TOKEN env var not set"
|
||||
exit 1
|
||||
fi
|
||||
# Socket Mode authenticates with SLACK_APP_TOKEN; webhook mode with
|
||||
# SLACK_SIGNING_SECRET. Require at least one.
|
||||
if [ -z "${SLACK_APP_TOKEN:-}" ] && [ -z "${SLACK_SIGNING_SECRET:-}" ]; then
|
||||
emit_status failed "Set SLACK_APP_TOKEN (Socket Mode) or SLACK_SIGNING_SECRET (webhook)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
need_install() {
|
||||
[ ! -f src/channels/slack.ts ] && return 0
|
||||
! grep -q "^import './slack.js';" src/channels/index.ts 2>/dev/null && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
ADAPTER_ALREADY_INSTALLED=true
|
||||
if need_install; then
|
||||
ADAPTER_ALREADY_INSTALLED=false
|
||||
log "Fetching channels branch…"
|
||||
git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || {
|
||||
emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log "Copying adapter from ${CHANNELS_BRANCH}…"
|
||||
git show "${CHANNELS_BRANCH}:src/channels/slack.ts" > src/channels/slack.ts
|
||||
|
||||
# Append self-registration import if missing.
|
||||
if ! grep -q "^import './slack.js';" src/channels/index.ts; then
|
||||
echo "import './slack.js';" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
log "Installing ${ADAPTER_VERSION}…"
|
||||
pnpm install "${ADAPTER_VERSION}" >&2 2>/dev/null || {
|
||||
emit_status failed "pnpm install ${ADAPTER_VERSION} failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log "Building…"
|
||||
pnpm run build >&2 2>/dev/null || {
|
||||
emit_status failed "pnpm run build failed"
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
log "Adapter files already installed — skipping install phase."
|
||||
fi
|
||||
|
||||
# Persist credentials. auto.ts validates via auth.test before this point, so
|
||||
# bad values here would be an internal bug rather than operator input.
|
||||
touch .env
|
||||
upsert_env() {
|
||||
local key=$1 value=$2
|
||||
if grep -q "^${key}=" .env; then
|
||||
awk -v k="$key" -v v="$value" \
|
||||
'BEGIN{FS=OFS="="} $1==k {print k "=" v; next} {print}' \
|
||||
.env > .env.tmp && mv .env.tmp .env
|
||||
else
|
||||
echo "${key}=${value}" >> .env
|
||||
fi
|
||||
}
|
||||
upsert_env SLACK_BOT_TOKEN "$SLACK_BOT_TOKEN"
|
||||
if [ -n "${SLACK_APP_TOKEN:-}" ]; then
|
||||
upsert_env SLACK_APP_TOKEN "$SLACK_APP_TOKEN"
|
||||
fi
|
||||
if [ -n "${SLACK_SIGNING_SECRET:-}" ]; then
|
||||
upsert_env SLACK_SIGNING_SECRET "$SLACK_SIGNING_SECRET"
|
||||
fi
|
||||
|
||||
# Container reads from data/env/env (the host mounts it).
|
||||
mkdir -p data/env
|
||||
cp .env data/env/env
|
||||
|
||||
log "Restarting service so the new adapter picks up the credentials…"
|
||||
# shellcheck source=setup/lib/install-slug.sh
|
||||
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
|
||||
case "$(uname -s)" in
|
||||
Darwin)
|
||||
launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true
|
||||
;;
|
||||
Linux)
|
||||
systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \
|
||||
|| sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \
|
||||
|| true
|
||||
;;
|
||||
esac
|
||||
|
||||
# Give the Slack adapter a moment to finish starting the webhook listener
|
||||
# before emitting success.
|
||||
sleep 3
|
||||
|
||||
emit_status success
|
||||
+1
-1
@@ -18,7 +18,7 @@ PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with .claude/skills/add-teams/SKILL.md.
|
||||
ADAPTER_VERSION="@chat-adapter/teams@4.26.0"
|
||||
ADAPTER_VERSION="@chat-adapter/teams@4.29.0"
|
||||
|
||||
# Resolve which remote carries the channels branch — handles forks where
|
||||
# upstream lives on a different remote than `origin`.
|
||||
|
||||
@@ -15,7 +15,7 @@ PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with .claude/skills/add-telegram/SKILL.md.
|
||||
ADAPTER_VERSION="@chat-adapter/telegram@4.26.0"
|
||||
ADAPTER_VERSION="@chat-adapter/telegram@4.29.0"
|
||||
|
||||
# Resolve which remote carries the channels branch — handles forks where
|
||||
# upstream lives on a different remote than `origin`.
|
||||
|
||||
@@ -16,7 +16,7 @@ PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with .claude/skills/add-whatsapp/SKILL.md.
|
||||
BAILEYS_VERSION="@whiskeysockets/baileys@6.17.16"
|
||||
BAILEYS_VERSION="@whiskeysockets/baileys@7.0.0-rc.9"
|
||||
QRCODE_VERSION="qrcode@1.5.4"
|
||||
QRCODE_TYPES_VERSION="@types/qrcode@1.5.6"
|
||||
PINO_VERSION="pino@9.6.0"
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@
|
||||
* already exists unless --force is passed.
|
||||
*
|
||||
* The actual user-facing prompt (subscription vs API key, paste the token)
|
||||
* stays in the /new-setup SKILL.md. This step is just the machine side:
|
||||
* stays in the /setup SKILL.md. This step is just the machine side:
|
||||
* it calls `onecli secrets list` / `onecli secrets create` and emits a
|
||||
* structured status block. The token value is never logged.
|
||||
*/
|
||||
@@ -124,7 +124,7 @@ export async function run(args: string[]): Promise<void> {
|
||||
emitStatus('AUTH', {
|
||||
STATUS: 'failed',
|
||||
ERROR: 'onecli_list_failed',
|
||||
HINT: 'Is OneCLI running? Run `/new-setup` from the onecli step.',
|
||||
HINT: 'Is OneCLI running? Run `/setup` from the onecli step.',
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
process.exit(1);
|
||||
|
||||
+334
-131
@@ -22,23 +22,33 @@
|
||||
* headless `claude -p` call for IANA-zone resolution.
|
||||
*/
|
||||
import { spawn, spawnSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import { runDiscordChannel } from './channels/discord.js';
|
||||
import { runIMessageChannel } from './channels/imessage.js';
|
||||
import { runSignalChannel } from './channels/signal.js';
|
||||
import { runSlackChannel } from './channels/slack.js';
|
||||
import { runTeamsChannel } from './channels/teams.js';
|
||||
import { runTelegramChannel } from './channels/telegram.js';
|
||||
import { runWhatsAppChannel } from './channels/whatsapp.js';
|
||||
import { pingCliAgent, type PingResult } from './lib/agent-ping.js';
|
||||
import { brightSelect } from './lib/bright-select.js';
|
||||
import { offerClaudeAssist } from './lib/claude-assist.js';
|
||||
import { runWindowedStep } from './lib/windowed-runner.js';
|
||||
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
|
||||
import {
|
||||
claudeCliAvailable,
|
||||
resolveTimezoneViaClaude,
|
||||
} from './lib/tz-from-claude.js';
|
||||
applyToEnv,
|
||||
parseFlags,
|
||||
printHelp,
|
||||
readFromEnv,
|
||||
} from './lib/setup-config-parse.js';
|
||||
import { runAdvancedScreen } from './lib/setup-config-screen.js';
|
||||
import { runWindowedStep } from './lib/windowed-runner.js';
|
||||
import { pollHealth } from './onecli.js';
|
||||
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
|
||||
import { claudeCliAvailable, resolveTimezoneViaClaude } from './lib/tz-from-claude.js';
|
||||
import * as setupLog from './logs.js';
|
||||
import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js';
|
||||
import { emit as phEmit } from './lib/diagnostics.js';
|
||||
@@ -48,11 +58,48 @@ import { isValidTimezone } from '../src/timezone.js';
|
||||
const CLI_AGENT_NAME = 'Terminal Agent';
|
||||
const RUN_START = Date.now();
|
||||
|
||||
type ChannelChoice = 'telegram' | 'discord' | 'whatsapp' | 'signal' | 'teams' | 'slack' | 'imessage' | 'skip';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
// Parse CLI flags first — `--help` short-circuits before we render anything,
|
||||
// and flag values get folded into process.env so existing step code reading
|
||||
// NANOCLAW_* sees them unchanged.
|
||||
const flagResult = parseFlags(process.argv.slice(2));
|
||||
if (flagResult.help) {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
if (flagResult.errors.length > 0) {
|
||||
for (const err of flagResult.errors) console.error(`error: ${err}`);
|
||||
console.error('');
|
||||
console.error('Run with --help for the full list of supported flags.');
|
||||
process.exit(1);
|
||||
}
|
||||
let configValues = { ...readFromEnv(), ...flagResult.values };
|
||||
applyToEnv(configValues);
|
||||
|
||||
printIntro();
|
||||
initProgressionLog();
|
||||
phEmit('auto_started');
|
||||
|
||||
// Welcome menu — default path or open advanced overrides before any setup
|
||||
// work begins. Default lands on standard so Enter is the happy path.
|
||||
const startChoice = ensureAnswer(
|
||||
await brightSelect<'default' | 'advanced'>({
|
||||
message: 'How would you like to begin?',
|
||||
options: [
|
||||
{ value: 'default', label: 'Standard setup' },
|
||||
{ value: 'advanced', label: 'Advanced', hint: 'override defaults' },
|
||||
],
|
||||
initialValue: 'default',
|
||||
}),
|
||||
) as 'default' | 'advanced';
|
||||
setupLog.userInput('start_choice', startChoice);
|
||||
if (startChoice === 'advanced') {
|
||||
configValues = await runAdvancedScreen(configValues);
|
||||
applyToEnv(configValues);
|
||||
}
|
||||
|
||||
const skip = new Set(
|
||||
(process.env.NANOCLAW_SKIP ?? '')
|
||||
.split(',')
|
||||
@@ -75,12 +122,7 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
if (!skip.has('container')) {
|
||||
p.log.message(
|
||||
dimWrap(
|
||||
'Your assistant lives in its own sandbox. It can only see what you explicitly share.',
|
||||
4,
|
||||
),
|
||||
);
|
||||
p.log.message(dimWrap('Your assistant lives in its own sandbox. It can only see what you explicitly share.', 4));
|
||||
p.log.message(
|
||||
dimWrap(
|
||||
'The first build pulls a base image and installs a few tools. On a fresh machine this usually takes 3–10 minutes.',
|
||||
@@ -125,57 +167,95 @@ async function main(): Promise<void> {
|
||||
),
|
||||
);
|
||||
|
||||
// Respect an existing OneCLI install. Re-running the installer would
|
||||
// rebind the listener and knock any other app using that gateway
|
||||
// offline — confirm with the user before doing that.
|
||||
const existing = detectExistingOnecli();
|
||||
let reuse = false;
|
||||
if (existing) {
|
||||
const choice = ensureAnswer(
|
||||
await brightSelect({
|
||||
message: `Found an existing OneCLI at ${existing.apiHost}. What would you like to do?`,
|
||||
options: [
|
||||
{
|
||||
value: 'reuse',
|
||||
label: 'Use the existing instance',
|
||||
hint: 'recommended — keeps other apps bound to this vault working',
|
||||
},
|
||||
{
|
||||
value: 'fresh',
|
||||
label: 'Install a fresh instance for NanoClaw',
|
||||
hint: 'reinstalls onecli; other apps may need to reconnect',
|
||||
},
|
||||
],
|
||||
}),
|
||||
) as 'reuse' | 'fresh';
|
||||
setupLog.userInput('onecli_choice', choice);
|
||||
reuse = choice === 'reuse';
|
||||
}
|
||||
const remoteHost = process.env.NANOCLAW_ONECLI_API_HOST?.trim();
|
||||
|
||||
const res = await runQuietStep(
|
||||
'onecli',
|
||||
{
|
||||
running: reuse
|
||||
? 'Hooking up to your existing OneCLI…'
|
||||
: "Setting up OneCLI, your agent's vault…",
|
||||
done: 'OneCLI vault ready.',
|
||||
},
|
||||
reuse ? ['--reuse'] : [],
|
||||
);
|
||||
if (!res.ok) {
|
||||
const err = res.terminal?.fields.ERROR;
|
||||
if (err === 'onecli_not_on_path_after_install') {
|
||||
if (remoteHost) {
|
||||
// Advanced-settings override: user has already named a remote vault,
|
||||
// so skip the local-vs-fresh prompt entirely. Health-check it here
|
||||
// rather than letting the step fail silently — a typo in the URL is a
|
||||
// common mistake and the answer is human-fixable.
|
||||
const s = p.spinner();
|
||||
s.start(`Checking remote OneCLI at ${remoteHost}…`);
|
||||
const healthy = await pollHealth(remoteHost, 5000);
|
||||
if (!healthy) {
|
||||
s.stop(`Couldn't reach OneCLI at ${remoteHost}.`, 1);
|
||||
await fail(
|
||||
'onecli',
|
||||
'OneCLI was installed but your shell needs to refresh to see it.',
|
||||
'Open a new shell or run `export PATH="$HOME/.local/bin:$PATH"`, then retry.',
|
||||
`Couldn't reach OneCLI at ${remoteHost}.`,
|
||||
'Check the URL and that OneCLI is running on the remote machine, then retry.',
|
||||
);
|
||||
}
|
||||
await fail(
|
||||
s.stop('Remote OneCLI is reachable.');
|
||||
|
||||
const res = await runQuietStep(
|
||||
'onecli',
|
||||
`Couldn't set up OneCLI (${err ?? 'unknown error'}).`,
|
||||
'Make sure curl is installed and ~/.local/bin is writable, then retry.',
|
||||
{
|
||||
running: `Connecting to remote OneCLI at ${remoteHost}…`,
|
||||
done: 'OneCLI vault ready.',
|
||||
},
|
||||
['--remote-url', remoteHost],
|
||||
);
|
||||
if (!res.ok) {
|
||||
const err = res.terminal?.fields.ERROR;
|
||||
await fail(
|
||||
'onecli',
|
||||
`Couldn't connect to remote OneCLI (${err ?? 'unknown error'}).`,
|
||||
'Check the URL and that OneCLI is running on the remote machine, then retry.',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Respect an existing OneCLI install. Re-running the installer would
|
||||
// rebind the listener and knock any other app using that gateway
|
||||
// offline — confirm with the user before doing that.
|
||||
const existing = detectExistingOnecli();
|
||||
let reuse = false;
|
||||
if (existing) {
|
||||
const choice = ensureAnswer(
|
||||
await brightSelect({
|
||||
message: `Found an existing OneCLI at ${existing.apiHost}. What would you like to do?`,
|
||||
options: [
|
||||
{
|
||||
value: 'reuse',
|
||||
label: 'Use the existing instance',
|
||||
hint: 'recommended — keeps other apps bound to this vault working',
|
||||
},
|
||||
{
|
||||
value: 'fresh',
|
||||
label: 'Install a fresh instance for NanoClaw',
|
||||
hint: 'reinstalls onecli; other apps may need to reconnect',
|
||||
},
|
||||
],
|
||||
}),
|
||||
) as 'reuse' | 'fresh';
|
||||
setupLog.userInput('onecli_choice', choice);
|
||||
reuse = choice === 'reuse';
|
||||
}
|
||||
|
||||
const res = await runQuietStep(
|
||||
'onecli',
|
||||
{
|
||||
running: reuse
|
||||
? 'Hooking up to your existing OneCLI…'
|
||||
: "Setting up OneCLI, your agent's vault…",
|
||||
done: 'OneCLI vault ready.',
|
||||
},
|
||||
reuse ? ['--reuse'] : [],
|
||||
);
|
||||
if (!res.ok) {
|
||||
const err = res.terminal?.fields.ERROR;
|
||||
if (err === 'onecli_not_on_path_after_install') {
|
||||
await fail(
|
||||
'onecli',
|
||||
'OneCLI was installed but your shell needs to refresh to see it.',
|
||||
'Open a new shell or run `export PATH="$HOME/.local/bin:$PATH"`, then retry.',
|
||||
);
|
||||
}
|
||||
await fail(
|
||||
'onecli',
|
||||
`Couldn't set up OneCLI (${err ?? 'unknown error'}).`,
|
||||
'Make sure curl is installed and ~/.local/bin is writable, then retry.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,21 +284,12 @@ async function main(): Promise<void> {
|
||||
done: 'NanoClaw is running.',
|
||||
});
|
||||
if (!res.ok) {
|
||||
await fail(
|
||||
'service',
|
||||
"Couldn't start NanoClaw.",
|
||||
'See logs/nanoclaw.error.log for details.',
|
||||
);
|
||||
await fail('service', "Couldn't start NanoClaw.", 'See logs/nanoclaw.error.log for details.');
|
||||
}
|
||||
if (res.terminal?.fields.DOCKER_GROUP_STALE === 'true') {
|
||||
p.log.warn(
|
||||
"NanoClaw's permissions need a tweak before it can reach Docker.",
|
||||
);
|
||||
p.log.warn("NanoClaw's permissions need a tweak before it can reach Docker.");
|
||||
p.log.message(
|
||||
k.dim(
|
||||
' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' +
|
||||
' systemctl --user restart nanoclaw',
|
||||
),
|
||||
' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' + ` systemctl --user restart ${getSystemdUnit()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -248,10 +319,33 @@ async function main(): Promise<void> {
|
||||
);
|
||||
}
|
||||
if (!skip.has('first-chat')) {
|
||||
p.log.message(
|
||||
dimWrap(
|
||||
"Your assistant runs in an isolated sandbox. I'm going to send it a quick test message (ping) and wait for a reply (pong) to confirm it's responding. First startup typically takes 30–60 seconds while the sandbox warms up.",
|
||||
4,
|
||||
),
|
||||
);
|
||||
const ping = await confirmAssistantResponds();
|
||||
if (ping === 'ok') {
|
||||
phEmit('first_chat_ready');
|
||||
await runFirstChat();
|
||||
const next = ensureAnswer(
|
||||
await p.select({
|
||||
message: 'What next?',
|
||||
options: [
|
||||
{
|
||||
value: 'continue',
|
||||
label: 'Continue with setup',
|
||||
hint: 'recommended',
|
||||
},
|
||||
{
|
||||
value: 'chat',
|
||||
label: 'Pause here and chat with your agent from the terminal',
|
||||
},
|
||||
],
|
||||
}),
|
||||
) as 'continue' | 'chat';
|
||||
setupLog.userInput('first_chat_choice', next);
|
||||
if (next === 'chat') await runFirstChat();
|
||||
} else {
|
||||
phEmit('first_chat_failed', { reason: ping });
|
||||
renderPingFailureNote(ping);
|
||||
@@ -260,7 +354,7 @@ async function main(): Promise<void> {
|
||||
msg:
|
||||
ping === 'socket_error'
|
||||
? "NanoClaw service isn't listening on its CLI socket."
|
||||
: "No reply from the assistant within 30 seconds.",
|
||||
: 'No reply from the assistant within 30 seconds.',
|
||||
hint:
|
||||
ping === 'socket_error'
|
||||
? 'Socket at data/cli.sock did not accept a connection.'
|
||||
@@ -274,8 +368,7 @@ async function main(): Promise<void> {
|
||||
await runTimezoneStep();
|
||||
}
|
||||
|
||||
let channelChoice: 'telegram' | 'discord' | 'whatsapp' | 'teams' | 'skip' =
|
||||
'skip';
|
||||
let channelChoice: ChannelChoice = 'skip';
|
||||
if (!skip.has('channel')) {
|
||||
channelChoice = await askChannelChoice();
|
||||
if (channelChoice === 'telegram') {
|
||||
@@ -284,12 +377,18 @@ async function main(): Promise<void> {
|
||||
await runDiscordChannel(displayName!);
|
||||
} else if (channelChoice === 'whatsapp') {
|
||||
await runWhatsAppChannel(displayName!);
|
||||
} else if (channelChoice === 'signal') {
|
||||
await runSignalChannel(displayName!);
|
||||
} else if (channelChoice === 'teams') {
|
||||
await runTeamsChannel(displayName!);
|
||||
} else if (channelChoice === 'slack') {
|
||||
await runSlackChannel(displayName!);
|
||||
} else if (channelChoice === 'imessage') {
|
||||
await runIMessageChannel(displayName!);
|
||||
} else {
|
||||
p.log.info(
|
||||
wrapForGutter(
|
||||
'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, or Slack).',
|
||||
'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, Slack, or iMessage).',
|
||||
4,
|
||||
),
|
||||
);
|
||||
@@ -305,7 +404,7 @@ async function main(): Promise<void> {
|
||||
if (!res.ok) {
|
||||
const notes: string[] = [];
|
||||
if (res.terminal?.fields.CREDENTIALS !== 'configured') {
|
||||
notes.push('• Your Claude account isn\'t connected. Re-run setup and try again.');
|
||||
notes.push("• Your Claude account isn't connected. Re-run setup and try again.");
|
||||
}
|
||||
const service = res.terminal?.fields.SERVICE;
|
||||
if (service === 'running_other_checkout') {
|
||||
@@ -331,7 +430,9 @@ async function main(): Promise<void> {
|
||||
}
|
||||
}
|
||||
if (!res.terminal?.fields.CONFIGURED_CHANNELS) {
|
||||
notes.push('• Want to chat from your phone? Add a messaging app with `/add-telegram`, `/add-slack`, or `/add-discord`.');
|
||||
notes.push(
|
||||
'• Want to chat from your phone? Add a messaging app with `/add-telegram`, `/add-slack`, or `/add-discord`.',
|
||||
);
|
||||
}
|
||||
if (notes.length > 0) {
|
||||
p.note(notes.join('\n'), "What's left");
|
||||
@@ -365,9 +466,7 @@ async function main(): Promise<void> {
|
||||
['Open Claude Code:', 'claude'],
|
||||
];
|
||||
const labelWidth = Math.max(...rows.map(([l]) => l.length));
|
||||
const nextSteps = rows
|
||||
.map(([l, c]) => `${k.cyan(l.padEnd(labelWidth))} ${c}`)
|
||||
.join('\n');
|
||||
const nextSteps = rows.map(([l, c]) => `${k.cyan(l.padEnd(labelWidth))} ${c}`).join('\n');
|
||||
p.note(nextSteps, 'Try these');
|
||||
|
||||
// Always-on warning goes before the "check your DMs" directive so the
|
||||
@@ -389,19 +488,14 @@ async function main(): Promise<void> {
|
||||
// that the welcome-message signal was too easy to miss. Use p.note so it
|
||||
// renders with a visible box, cyan-bold the directive line, and put it
|
||||
// as the last thing before outro.
|
||||
p.note(
|
||||
`${brandBold('→')} ${k.bold(`Check your ${dmTarget} — your assistant is saying hi.`)}`,
|
||||
'Go say hi',
|
||||
);
|
||||
p.note(`${brandBold('→')} ${k.bold(`Check your ${dmTarget} — your assistant is saying hi.`)}`, 'Go say hi');
|
||||
p.outro(k.green("You're set."));
|
||||
} else {
|
||||
p.outro(k.green("You're ready! Chat with `pnpm run chat hi`."));
|
||||
}
|
||||
}
|
||||
|
||||
function channelDmLabel(
|
||||
choice: 'telegram' | 'discord' | 'whatsapp' | 'teams' | 'skip',
|
||||
): string | null {
|
||||
function channelDmLabel(choice: ChannelChoice): string | null {
|
||||
switch (choice) {
|
||||
case 'telegram':
|
||||
return 'Telegram';
|
||||
@@ -409,8 +503,17 @@ function channelDmLabel(
|
||||
return 'Discord DMs';
|
||||
case 'whatsapp':
|
||||
return 'WhatsApp';
|
||||
case 'signal':
|
||||
return 'Signal';
|
||||
case 'teams':
|
||||
return 'Teams';
|
||||
case 'imessage':
|
||||
return 'iMessage';
|
||||
case 'slack':
|
||||
// Slack install doesn't wire an agent or send a welcome DM — the
|
||||
// driver prints its own "finish in your Slack app" note. Falling
|
||||
// through to null avoids a misleading "check your Slack DMs" banner.
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -442,13 +545,11 @@ async function confirmAssistantResponds(): Promise<PingResult> {
|
||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
||||
const suffix = ` (${elapsed}s)`;
|
||||
if (result === 'ok') {
|
||||
s.stop(`${fitToWidth('Your assistant is ready.', suffix)}${k.dim(suffix)}`);
|
||||
s.stop(`${k.bold(fitToWidth('Your assistant is ready.', suffix))}${k.dim(suffix)}`);
|
||||
} else {
|
||||
const msg =
|
||||
result === 'socket_error'
|
||||
? "Couldn't reach the NanoClaw service."
|
||||
: "Your assistant didn't reply in time.";
|
||||
s.stop(`${fitToWidth(msg, suffix)}${k.dim(suffix)}`, 1);
|
||||
result === 'socket_error' ? "Couldn't reach the NanoClaw service." : "Your assistant didn't reply in time.";
|
||||
s.stop(`${k.bold(fitToWidth(msg, suffix))}${k.dim(suffix)}`, 1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -462,8 +563,8 @@ function renderPingFailureNote(result: PingResult): void {
|
||||
6,
|
||||
),
|
||||
'',
|
||||
k.dim(` macOS: launchctl kickstart -k gui/$(id -u)/${getLaunchdLabel()}`),
|
||||
k.dim(` Linux: systemctl --user restart ${getSystemdUnit()}`),
|
||||
` macOS: launchctl kickstart -k gui/$(id -u)/${getLaunchdLabel()}`,
|
||||
` Linux: systemctl --user restart ${getSystemdUnit()}`,
|
||||
].join('\n')
|
||||
: wrapForGutter(
|
||||
'No reply from your assistant within 30 seconds. Check `logs/nanoclaw.log` for clues, then try `pnpm run chat hi`.',
|
||||
@@ -503,9 +604,7 @@ async function runFirstChat(): Promise<void> {
|
||||
message: first
|
||||
? 'Try a quick hello — or press Enter to continue setup'
|
||||
: 'Another message? Press Enter to continue setup',
|
||||
placeholder: first
|
||||
? 'e.g. "hi, what can you do?"'
|
||||
: 'press Enter to continue',
|
||||
placeholder: first ? 'e.g. "hi, what can you do?"' : 'press Enter to continue',
|
||||
}),
|
||||
);
|
||||
first = false;
|
||||
@@ -521,11 +620,9 @@ function sendChatMessage(message: string): Promise<void> {
|
||||
// agent's reply reads as a clean block under the prompt. Splitting on
|
||||
// whitespace mirrors `pnpm run chat hello world` — chat.ts joins argv
|
||||
// with spaces on the far side.
|
||||
const child = spawn(
|
||||
'pnpm',
|
||||
['--silent', 'run', 'chat', ...message.split(/\s+/)],
|
||||
{ stdio: ['ignore', 'inherit', 'inherit'] },
|
||||
);
|
||||
const child = spawn('pnpm', ['--silent', 'run', 'chat', ...message.split(/\s+/)], {
|
||||
stdio: ['ignore', 'inherit', 'inherit'],
|
||||
});
|
||||
child.on('close', () => resolve());
|
||||
child.on('error', () => resolve());
|
||||
});
|
||||
@@ -540,6 +637,16 @@ async function runAuthStep(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Custom Anthropic-compatible endpoint flow. Both URL and token must be set;
|
||||
// OneCLI stores the token as a generic Bearer secret keyed to the URL host,
|
||||
// so the container only ever sees ANTHROPIC_BASE_URL + a placeholder.
|
||||
const customBaseUrl = process.env.NANOCLAW_ANTHROPIC_BASE_URL?.trim();
|
||||
const customAuthToken = process.env.NANOCLAW_ANTHROPIC_AUTH_TOKEN?.trim();
|
||||
if (customBaseUrl && customAuthToken) {
|
||||
await runCustomEndpointAuth(customBaseUrl, customAuthToken);
|
||||
return;
|
||||
}
|
||||
|
||||
const method = ensureAnswer(
|
||||
await brightSelect({
|
||||
message: 'How would you like to connect to Claude?',
|
||||
@@ -573,15 +680,11 @@ async function runAuthStep(): Promise<void> {
|
||||
}
|
||||
|
||||
async function runSubscriptionAuth(): Promise<void> {
|
||||
p.log.step("Opening the Claude sign-in flow…");
|
||||
console.log(
|
||||
k.dim(' (a browser will open for sign-in; this part is interactive)'),
|
||||
);
|
||||
p.log.step('Opening the Claude sign-in flow…');
|
||||
console.log(k.dim(' (a browser will open for sign-in; this part is interactive)'));
|
||||
console.log();
|
||||
const start = Date.now();
|
||||
const code = await runInheritScript('bash', [
|
||||
'setup/register-claude-token.sh',
|
||||
]);
|
||||
const code = await runInheritScript('bash', ['setup/register-claude-token.sh']);
|
||||
const durationMs = Date.now() - start;
|
||||
console.log();
|
||||
if (code !== 0) {
|
||||
@@ -621,11 +724,16 @@ async function runPasteAuth(method: 'oauth' | 'api'): Promise<void> {
|
||||
'auth',
|
||||
'onecli',
|
||||
[
|
||||
'secrets', 'create',
|
||||
'--name', 'Anthropic',
|
||||
'--type', 'anthropic',
|
||||
'--value', token,
|
||||
'--host-pattern', 'api.anthropic.com',
|
||||
'secrets',
|
||||
'create',
|
||||
'--name',
|
||||
'Anthropic',
|
||||
'--type',
|
||||
'anthropic',
|
||||
'--value',
|
||||
token,
|
||||
'--host-pattern',
|
||||
'api.anthropic.com',
|
||||
],
|
||||
{
|
||||
running: `Saving your ${label} to your OneCLI vault…`,
|
||||
@@ -644,6 +752,92 @@ async function runPasteAuth(method: 'oauth' | 'api'): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up Anthropic auth for a custom endpoint. The token is stored as a
|
||||
* OneCLI generic secret with header injection so the proxy rewrites the
|
||||
* Authorization header on the wire — the container only ever sees
|
||||
* ANTHROPIC_BASE_URL + a placeholder bearer.
|
||||
*/
|
||||
async function runCustomEndpointAuth(
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
): Promise<void> {
|
||||
let host: string;
|
||||
try {
|
||||
host = new URL(baseUrl).hostname;
|
||||
} catch {
|
||||
await fail(
|
||||
'auth',
|
||||
`Invalid Anthropic base URL: ${baseUrl}`,
|
||||
'Check --anthropic-base-url and retry.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await runQuietChild(
|
||||
'auth',
|
||||
'onecli',
|
||||
[
|
||||
'secrets',
|
||||
'create',
|
||||
'--name',
|
||||
'Anthropic',
|
||||
'--type',
|
||||
'generic',
|
||||
'--value',
|
||||
token,
|
||||
'--host-pattern',
|
||||
host,
|
||||
'--header-name',
|
||||
'Authorization',
|
||||
'--value-format',
|
||||
'Bearer {value}',
|
||||
],
|
||||
{
|
||||
running: `Saving your Anthropic auth token to your OneCLI vault…`,
|
||||
done: 'Claude account connected.',
|
||||
},
|
||||
{ extraFields: { METHOD: 'custom-endpoint', HOST: host } },
|
||||
);
|
||||
if (!res.ok) {
|
||||
await fail(
|
||||
'auth',
|
||||
`Couldn't save your Anthropic auth token to the vault.`,
|
||||
'Make sure OneCLI is running (`onecli version`), then retry.',
|
||||
);
|
||||
}
|
||||
|
||||
// ANTHROPIC_BASE_URL has to be in .env so the runtime provider config
|
||||
// reads it when building container env. The token is *not* written —
|
||||
// OneCLI holds it.
|
||||
writeEnvLine('ANTHROPIC_BASE_URL', baseUrl);
|
||||
|
||||
// Register the claude provider so the runtime passes ANTHROPIC_BASE_URL
|
||||
// and the placeholder bearer into the container. Only appended when the
|
||||
// user has configured a custom endpoint; standard installs don't load
|
||||
// the file at all.
|
||||
appendProviderImport('./claude.js');
|
||||
}
|
||||
|
||||
function writeEnvLine(key: string, value: string): void {
|
||||
const envFile = path.join(process.cwd(), '.env');
|
||||
const content = fs.existsSync(envFile) ? fs.readFileSync(envFile, 'utf-8') : '';
|
||||
const re = new RegExp(`^${key}=.*$`, 'm');
|
||||
const next = re.test(content)
|
||||
? content.replace(re, `${key}=${value}`)
|
||||
: content.trimEnd() + (content ? '\n' : '') + `${key}=${value}\n`;
|
||||
fs.writeFileSync(envFile, next);
|
||||
}
|
||||
|
||||
function appendProviderImport(modulePath: string): void {
|
||||
const file = path.join(process.cwd(), 'src', 'providers', 'index.ts');
|
||||
const content = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : '';
|
||||
const line = `import '${modulePath}';`;
|
||||
if (content.includes(line)) return;
|
||||
const sep = content && !content.endsWith('\n') ? '\n' : '';
|
||||
fs.writeFileSync(file, content + sep + line + '\n');
|
||||
}
|
||||
|
||||
// ─── timezone step ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -664,10 +858,7 @@ async function runTimezoneStep(): Promise<void> {
|
||||
const fields = res.terminal?.fields ?? {};
|
||||
const resolvedTz = fields.RESOLVED_TZ;
|
||||
const needsInput = fields.NEEDS_USER_INPUT === 'true';
|
||||
const isUtc =
|
||||
resolvedTz === 'UTC' ||
|
||||
resolvedTz === 'Etc/UTC' ||
|
||||
resolvedTz === 'Universal';
|
||||
const isUtc = resolvedTz === 'UTC' || resolvedTz === 'Etc/UTC' || resolvedTz === 'Universal';
|
||||
|
||||
// Three branches:
|
||||
// - no TZ detected: ask where they are (or leave as UTC)
|
||||
@@ -689,8 +880,8 @@ async function runTimezoneStep(): Promise<void> {
|
||||
const message = needsInput
|
||||
? "Your system didn't expose a timezone. Which one are you in?"
|
||||
: !isUtc
|
||||
? "Where are you, then?"
|
||||
: "Your system reports UTC as the timezone. Is that right, or are you somewhere else?";
|
||||
? 'Where are you, then?'
|
||||
: 'Your system reports UTC as the timezone. Is that right, or are you somewhere else?';
|
||||
|
||||
// For the non-UTC "detected-but-wrong" branch we skip the select and jump
|
||||
// straight to the free-text prompt — the user already said "not that".
|
||||
@@ -717,7 +908,7 @@ async function runTimezoneStep(): Promise<void> {
|
||||
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: "Where are you? (city, region, or IANA zone)",
|
||||
message: 'Where are you? (city, region, or IANA zone)',
|
||||
placeholder: 'e.g. New York, London, Asia/Tokyo',
|
||||
validate: (v) => (v && v.trim() ? undefined : 'Required'),
|
||||
}),
|
||||
@@ -786,16 +977,30 @@ async function askDisplayName(fallback: string): Promise<string> {
|
||||
return value;
|
||||
}
|
||||
|
||||
async function askChannelChoice(): Promise<
|
||||
'telegram' | 'discord' | 'whatsapp' | 'teams' | 'skip'
|
||||
> {
|
||||
async function askChannelChoice(): Promise<ChannelChoice> {
|
||||
const isMac = process.platform === 'darwin';
|
||||
const choice = ensureAnswer(
|
||||
await brightSelect({
|
||||
await brightSelect<ChannelChoice>({
|
||||
message: 'Want to chat with your assistant from your phone?',
|
||||
options: [
|
||||
{ value: 'telegram', label: 'Yes, connect Telegram', hint: 'recommended' },
|
||||
{ value: 'discord', label: 'Yes, connect Discord' },
|
||||
{ value: 'whatsapp', label: 'Yes, connect WhatsApp' },
|
||||
{
|
||||
value: 'signal',
|
||||
label: 'Yes, connect Signal',
|
||||
hint: 'needs signal-cli installed',
|
||||
},
|
||||
{
|
||||
value: 'imessage',
|
||||
label: 'Yes, connect iMessage (experimental)',
|
||||
hint: isMac ? 'local macOS mode' : 'remote Photon only',
|
||||
},
|
||||
{
|
||||
value: 'slack',
|
||||
label: 'Yes, connect Slack (experimental)',
|
||||
hint: 'needs public URL',
|
||||
},
|
||||
{ value: 'teams', label: 'Yes, connect Microsoft Teams', hint: 'complex setup' },
|
||||
{ value: 'skip', label: 'Skip for now', hint: "I'll just use the terminal" },
|
||||
],
|
||||
@@ -803,7 +1008,7 @@ async function askChannelChoice(): Promise<
|
||||
);
|
||||
setupLog.userInput('channel_choice', String(choice));
|
||||
phEmit('channel_chosen', { channel: String(choice) });
|
||||
return choice as 'telegram' | 'discord' | 'whatsapp' | 'teams' | 'skip';
|
||||
return choice;
|
||||
}
|
||||
|
||||
// ─── interactive / env helpers ─────────────────────────────────────────
|
||||
@@ -899,17 +1104,15 @@ function printIntro(): void {
|
||||
const wordmark = `${k.bold('Nano')}${brandBold('Claw')}`;
|
||||
|
||||
if (isReexec) {
|
||||
p.intro(
|
||||
`${brandChip(' Welcome ')} ${wordmark} ${k.dim('· picking up where we left off')}`,
|
||||
);
|
||||
p.intro(`${brandChip(' Welcome ')} ${wordmark} ${k.dim('· picking up where we left off')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Always include the wordmark inside the clack intro line. When bash ran
|
||||
// first (NANOCLAW_BOOTSTRAPPED=1) it already printed its own wordmark
|
||||
// above us; the small repeat is worth it to keep the brand anchored at
|
||||
// the visible top of the clack session once the bash output scrolls away.
|
||||
p.intro(`${wordmark} ${k.dim("Let's get you set up.")}`);
|
||||
// bash already printed the wordmark above us; the clack intro carries the
|
||||
// welcome framing alone so the two don't double up. Standalone runs of
|
||||
// setup:auto still see this as the first line — fine without the wordmark
|
||||
// since the line itself signals the start of the flow.
|
||||
p.intro("Let's get you set up.");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* iMessage channel flow for setup:auto.
|
||||
*
|
||||
* `runIMessageChannel(displayName)` covers both deployment modes:
|
||||
*
|
||||
* Local (macOS): the bot runs on this Mac and talks via the signed-in
|
||||
* iMessage account. Reading chat.db needs Full Disk Access granted to
|
||||
* the Node binary — we open the directory for them so they can drag
|
||||
* the `node` file into System Settings.
|
||||
*
|
||||
* Remote (Photon API): the bot talks to a separate server (Photon)
|
||||
* that owns an iMessage account on another Mac. Used when this host
|
||||
* is Linux, or when the operator wants to keep their daily-driver
|
||||
* Mac's chat history out of the loop.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Pick mode (auto-defaults to local on macOS, remote elsewhere)
|
||||
* 2. Local: FDA walkthrough (open node bin directory, wait for ack)
|
||||
* Remote: prompt for Photon server URL + API key
|
||||
* 3. Ask for the phone or email the operator messages from — this is
|
||||
* the platform-id for first-agent wiring
|
||||
* 4. Install the adapter (setup/add-imessage.sh, non-interactive)
|
||||
* 5. Wire the agent via scripts/init-first-agent.ts — the welcome
|
||||
* iMessage goes out through the normal delivery path
|
||||
*
|
||||
* All output obeys the three-level contract. See docs/setup-flow.md.
|
||||
*/
|
||||
import { execSync } from 'child_process';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import * as setupLog from '../logs.js';
|
||||
import { brightSelect } from '../lib/bright-select.js';
|
||||
import { askOperatorRole } from '../lib/role-prompt.js';
|
||||
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
||||
import { wrapForGutter } from '../lib/theme.js';
|
||||
|
||||
const DEFAULT_AGENT_NAME = 'Nano';
|
||||
|
||||
type Mode = 'local' | 'remote';
|
||||
|
||||
interface RemoteCreds {
|
||||
serverUrl: string;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
export async function runIMessageChannel(displayName: string): Promise<void> {
|
||||
const isMac = os.platform() === 'darwin';
|
||||
|
||||
const mode = await askMode(isMac);
|
||||
let remoteCreds: RemoteCreds | null = null;
|
||||
|
||||
if (mode === 'local') {
|
||||
if (!isMac) {
|
||||
await fail(
|
||||
'imessage',
|
||||
"Local iMessage mode only works on macOS.",
|
||||
'Choose remote mode (Photon API) on Linux/WSL, or run setup from your Mac.',
|
||||
);
|
||||
}
|
||||
await walkThroughFullDiskAccess();
|
||||
} else {
|
||||
remoteCreds = await collectRemoteCreds();
|
||||
}
|
||||
|
||||
const handle = await askOperatorHandle();
|
||||
|
||||
const install = await runQuietChild(
|
||||
'imessage-install',
|
||||
'bash',
|
||||
['setup/add-imessage.sh'],
|
||||
{
|
||||
running:
|
||||
mode === 'local'
|
||||
? "Connecting the iMessage adapter to this Mac…"
|
||||
: `Connecting the iMessage adapter to ${remoteCreds!.serverUrl}…`,
|
||||
done: 'iMessage adapter installed.',
|
||||
},
|
||||
{
|
||||
env:
|
||||
mode === 'local'
|
||||
? { IMESSAGE_LOCAL: 'true', IMESSAGE_ENABLED: 'true' }
|
||||
: {
|
||||
IMESSAGE_LOCAL: 'false',
|
||||
IMESSAGE_SERVER_URL: remoteCreds!.serverUrl,
|
||||
IMESSAGE_API_KEY: remoteCreds!.apiKey,
|
||||
},
|
||||
extraFields: { MODE: mode },
|
||||
},
|
||||
);
|
||||
if (!install.ok) {
|
||||
await fail(
|
||||
'imessage-install',
|
||||
"Couldn't install the iMessage adapter.",
|
||||
'See logs/setup-steps/ for details, then retry setup.',
|
||||
);
|
||||
}
|
||||
|
||||
const role = await askOperatorRole('iMessage');
|
||||
setupLog.userInput('imessage_role', role);
|
||||
|
||||
const agentName = await resolveAgentName();
|
||||
|
||||
const init = await runQuietChild(
|
||||
'init-first-agent',
|
||||
'pnpm',
|
||||
[
|
||||
'exec', 'tsx', 'scripts/init-first-agent.ts',
|
||||
'--channel', 'imessage',
|
||||
'--user-id', handle,
|
||||
'--platform-id', handle,
|
||||
'--display-name', displayName,
|
||||
'--agent-name', agentName,
|
||||
'--role', role,
|
||||
],
|
||||
{
|
||||
running: `Connecting ${agentName} to iMessage…`,
|
||||
done: `${agentName} is ready. Check iMessage for a welcome message.`,
|
||||
},
|
||||
{
|
||||
extraFields: {
|
||||
CHANNEL: 'imessage',
|
||||
AGENT_NAME: agentName,
|
||||
PLATFORM_ID: handle,
|
||||
MODE: mode,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!init.ok) {
|
||||
await fail(
|
||||
'init-first-agent',
|
||||
`Couldn't finish connecting ${agentName}.`,
|
||||
'Double-check Full Disk Access (local mode) or Photon credentials (remote), then retry.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function askMode(isMac: boolean): Promise<Mode> {
|
||||
const choice = ensureAnswer(
|
||||
await brightSelect<Mode>({
|
||||
message: 'How should iMessage run?',
|
||||
initialValue: isMac ? 'local' : 'remote',
|
||||
options: isMac
|
||||
? [
|
||||
{
|
||||
value: 'local',
|
||||
label: 'Local (this Mac)',
|
||||
hint: "uses this machine's iMessage account",
|
||||
},
|
||||
{
|
||||
value: 'remote',
|
||||
label: 'Remote (Photon API)',
|
||||
hint: 'the bot lives on another server',
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
value: 'remote',
|
||||
label: 'Remote (Photon API)',
|
||||
hint: 'only option off macOS',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
setupLog.userInput('imessage_mode', String(choice));
|
||||
return choice;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant Full Disk Access to the Node binary the host runs under — without
|
||||
* it, the adapter can't read chat.db and inbound messages never arrive.
|
||||
* Opening the containing directory in Finder makes the drag-and-drop
|
||||
* target obvious; falling back to printing the path keeps us working in
|
||||
* SSH/headless contexts where `open` is a no-op.
|
||||
*/
|
||||
async function walkThroughFullDiskAccess(): Promise<void> {
|
||||
let nodePath = process.execPath;
|
||||
try {
|
||||
// `which node` picks up the user's shell-resolved node, which may differ
|
||||
// from process.execPath (e.g. they launched setup under a different
|
||||
// Node via `nvm`). If it succeeds and is resolvable, prefer it.
|
||||
const which = execSync('which node', { encoding: 'utf-8' }).trim();
|
||||
if (which) nodePath = which;
|
||||
} catch {
|
||||
// fall back to process.execPath
|
||||
}
|
||||
const nodeDir = path.dirname(nodePath);
|
||||
|
||||
p.note(
|
||||
wrapForGutter(
|
||||
[
|
||||
`iMessage needs Full Disk Access granted to the Node binary:`,
|
||||
'',
|
||||
` ${nodePath}`,
|
||||
'',
|
||||
' 1. System Settings → Privacy & Security → Full Disk Access',
|
||||
` 2. Click +, then drag the "node" file from the Finder window`,
|
||||
' we just opened for you',
|
||||
' 3. Toggle it on, then come back here',
|
||||
].join('\n'),
|
||||
6,
|
||||
),
|
||||
'Grant Full Disk Access',
|
||||
);
|
||||
|
||||
try {
|
||||
execSync(`open "${nodeDir}"`, { stdio: 'ignore' });
|
||||
} catch {
|
||||
// No Finder (SSH/headless) — user sees the path in the note above.
|
||||
}
|
||||
|
||||
ensureAnswer(
|
||||
await p.confirm({
|
||||
message: "Granted Full Disk Access?",
|
||||
initialValue: true,
|
||||
}),
|
||||
);
|
||||
setupLog.userInput('imessage_fda_confirmed', 'true');
|
||||
}
|
||||
|
||||
async function collectRemoteCreds(): Promise<RemoteCreds> {
|
||||
p.note(
|
||||
[
|
||||
"Photon is a separate service that owns an iMessage account and",
|
||||
"exposes it over HTTP. NanoClaw will talk to it via its API.",
|
||||
'',
|
||||
' 1. Set up a Photon server: https://photon.im',
|
||||
' 2. Copy the server URL and API key from your Photon dashboard',
|
||||
].join('\n'),
|
||||
'Remote iMessage via Photon',
|
||||
);
|
||||
|
||||
const urlAnswer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'Photon server URL',
|
||||
placeholder: 'https://photon.example.com',
|
||||
validate: (v) => {
|
||||
const t = (v ?? '').trim();
|
||||
if (!t) return 'URL is required';
|
||||
if (!/^https?:\/\//i.test(t)) return 'Must start with http:// or https://';
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const serverUrl = (urlAnswer as string).trim();
|
||||
|
||||
const keyAnswer = ensureAnswer(
|
||||
await p.password({
|
||||
message: 'Photon API key',
|
||||
validate: (v) => ((v ?? '').trim() ? undefined : 'API key is required'),
|
||||
}),
|
||||
);
|
||||
const apiKey = (keyAnswer as string).trim();
|
||||
|
||||
setupLog.userInput('imessage_server_url', serverUrl);
|
||||
setupLog.userInput(
|
||||
'imessage_api_key',
|
||||
`${apiKey.slice(0, 4)}…${apiKey.slice(-4)}`,
|
||||
);
|
||||
return { serverUrl, apiKey };
|
||||
}
|
||||
|
||||
async function askOperatorHandle(): Promise<string> {
|
||||
p.note(
|
||||
[
|
||||
"What phone number or email do you iMessage with?",
|
||||
"That's where your assistant will send its welcome message.",
|
||||
'',
|
||||
k.dim(' • Phone: full E.164, e.g. +15551234567'),
|
||||
k.dim(' • Email: whatever iMessage recognises (Apple ID, iCloud alias, …)'),
|
||||
].join('\n'),
|
||||
'Your iMessage handle',
|
||||
);
|
||||
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'Phone number or email',
|
||||
validate: (v) => {
|
||||
const t = (v ?? '').trim();
|
||||
if (!t) return 'Required';
|
||||
const isPhone = /^\+\d{8,15}$/.test(t);
|
||||
const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t);
|
||||
if (!isPhone && !isEmail) {
|
||||
return "Use a +E.164 phone number or an email address";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const handle = (answer as string).trim();
|
||||
setupLog.userInput('imessage_handle', handle);
|
||||
return handle;
|
||||
}
|
||||
|
||||
async function resolveAgentName(): Promise<string> {
|
||||
const preset = process.env.NANOCLAW_AGENT_NAME?.trim();
|
||||
if (preset) {
|
||||
setupLog.userInput('agent_name', preset);
|
||||
return preset;
|
||||
}
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'What should your assistant be called?',
|
||||
placeholder: DEFAULT_AGENT_NAME,
|
||||
defaultValue: DEFAULT_AGENT_NAME,
|
||||
}),
|
||||
);
|
||||
const value = (answer as string).trim() || DEFAULT_AGENT_NAME;
|
||||
setupLog.userInput('agent_name', value);
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* Signal channel flow for setup:auto.
|
||||
*
|
||||
* `runSignalChannel(displayName)` owns the full branch from signal-cli
|
||||
* presence check through the welcome DM:
|
||||
*
|
||||
* 1. Probe signal-cli on PATH (or SIGNAL_CLI_PATH). On macOS without it,
|
||||
* offer `brew install signal-cli` inline. On Linux, surface the
|
||||
* GitHub releases URL and bail with an actionable error.
|
||||
* 2. Install the adapter + qrcode via setup/add-signal.sh (idempotent).
|
||||
* 3. Run the signal-auth step, rendering each SIGNAL_AUTH_QR block as
|
||||
* a terminal QR the operator scans from Signal → Linked Devices.
|
||||
* 4. Persist SIGNAL_ACCOUNT to .env (+ data/env/env).
|
||||
* 5. Kick the service so the adapter picks up the new credentials.
|
||||
* 6. Ask operator role + agent name.
|
||||
* 7. Wire the agent via scripts/init-first-agent.ts; the existing welcome
|
||||
* DM path delivers the greeting through the adapter.
|
||||
*
|
||||
* Signal's `link` flow creates a *secondary* device. The phone number
|
||||
* comes from the primary (the phone that scanned the QR); this host then
|
||||
* sends/receives as that primary number. No registration of new numbers.
|
||||
*
|
||||
* Output obeys the three-level contract: clack UI for the user, structured
|
||||
* entries in logs/setup.log, full raw output in per-step files under
|
||||
* logs/setup-steps/. See docs/setup-flow.md.
|
||||
*/
|
||||
import { spawnSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import * as setupLog from '../logs.js';
|
||||
import { getLaunchdLabel, getSystemdUnit } from '../../src/install-slug.js';
|
||||
import {
|
||||
type Block,
|
||||
type StepResult,
|
||||
dumpTranscriptOnFailure,
|
||||
ensureAnswer,
|
||||
fail,
|
||||
runQuietChild,
|
||||
spawnStep,
|
||||
writeStepEntry,
|
||||
} from '../lib/runner.js';
|
||||
import { askOperatorRole } from '../lib/role-prompt.js';
|
||||
|
||||
const DEFAULT_AGENT_NAME = 'Nano';
|
||||
|
||||
export async function runSignalChannel(displayName: string): Promise<void> {
|
||||
await ensureSignalCli();
|
||||
|
||||
const install = await runQuietChild(
|
||||
'signal-install',
|
||||
'bash',
|
||||
['setup/add-signal.sh'],
|
||||
{
|
||||
running: 'Installing the Signal adapter…',
|
||||
done: 'Signal adapter installed.',
|
||||
skipped: 'Signal adapter already installed.',
|
||||
},
|
||||
);
|
||||
if (!install.ok) {
|
||||
await fail(
|
||||
'signal-install',
|
||||
"Couldn't install the Signal adapter.",
|
||||
'See logs/setup-steps/ for details, then retry setup.',
|
||||
);
|
||||
}
|
||||
|
||||
const auth = await runSignalAuth();
|
||||
if (!auth.ok) {
|
||||
const reason = auth.terminal?.fields.ERROR ?? 'unknown';
|
||||
await fail(
|
||||
'signal-auth',
|
||||
`Signal link failed (${reason}).`,
|
||||
reason === 'qr_timeout'
|
||||
? 'The code expired. Re-run setup to get a fresh one.'
|
||||
: 'Re-run setup to try again.',
|
||||
);
|
||||
}
|
||||
|
||||
const account = auth.terminal?.fields.ACCOUNT;
|
||||
if (!account) {
|
||||
await fail(
|
||||
'signal-auth',
|
||||
'Linked with Signal but couldn\'t read the phone number back.',
|
||||
'Run `signal-cli listAccounts` to confirm, then re-run setup.',
|
||||
);
|
||||
}
|
||||
|
||||
writeSignalAccount(account!);
|
||||
await restartService();
|
||||
|
||||
const role = await askOperatorRole('Signal');
|
||||
setupLog.userInput('signal_role', role);
|
||||
|
||||
const agentName = await resolveAgentName();
|
||||
|
||||
const init = await runQuietChild(
|
||||
'init-first-agent',
|
||||
'pnpm',
|
||||
[
|
||||
'exec', 'tsx', 'scripts/init-first-agent.ts',
|
||||
'--channel', 'signal',
|
||||
'--user-id', account!,
|
||||
'--platform-id', account!,
|
||||
'--display-name', displayName,
|
||||
'--agent-name', agentName,
|
||||
'--role', role,
|
||||
],
|
||||
{
|
||||
running: `Connecting ${agentName} to Signal…`,
|
||||
done: `${agentName} is ready. Check Signal for a welcome message.`,
|
||||
},
|
||||
{
|
||||
extraFields: {
|
||||
CHANNEL: 'signal',
|
||||
AGENT_NAME: agentName,
|
||||
PLATFORM_ID: account!,
|
||||
ROLE: role,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!init.ok) {
|
||||
await fail(
|
||||
'init-first-agent',
|
||||
`Couldn't finish connecting ${agentName}.`,
|
||||
'You can retry later with `/manage-channels`.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureSignalCli(): Promise<void> {
|
||||
const cli = process.env.SIGNAL_CLI_PATH || 'signal-cli';
|
||||
const probe = spawnSync(cli, ['--version'], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
if (!probe.error && probe.status === 0) return;
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
p.note(
|
||||
[
|
||||
"NanoClaw talks to Signal through signal-cli, which isn't installed yet.",
|
||||
'',
|
||||
'The quickest way on macOS is Homebrew:',
|
||||
'',
|
||||
k.cyan(' brew install signal-cli'),
|
||||
'',
|
||||
"Install it in another terminal, then re-run setup.",
|
||||
].join('\n'),
|
||||
'signal-cli not found',
|
||||
);
|
||||
} else {
|
||||
p.note(
|
||||
[
|
||||
"NanoClaw talks to Signal through signal-cli, which isn't installed yet.",
|
||||
'',
|
||||
'Grab the latest release from GitHub:',
|
||||
'',
|
||||
k.cyan(' https://github.com/AsamK/signal-cli/releases'),
|
||||
'',
|
||||
"Install it, make sure `signal-cli --version` works, then re-run setup.",
|
||||
].join('\n'),
|
||||
'signal-cli not found',
|
||||
);
|
||||
}
|
||||
await fail(
|
||||
'signal-install',
|
||||
'signal-cli is required but not installed.',
|
||||
'Install it and re-run setup.',
|
||||
);
|
||||
}
|
||||
|
||||
async function runSignalAuth(): Promise<
|
||||
StepResult & { rawLog: string; durationMs: number }
|
||||
> {
|
||||
const rawLog = setupLog.stepRawLog('signal-auth');
|
||||
const start = Date.now();
|
||||
const s = p.spinner();
|
||||
s.start('Starting Signal link…');
|
||||
let spinnerActive = true;
|
||||
|
||||
const stopSpinner = (msg: string, code?: number): void => {
|
||||
if (spinnerActive) {
|
||||
s.stop(msg, code);
|
||||
spinnerActive = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Tracks how many lines the QR block occupies so we can wipe it in-place
|
||||
// once linking succeeds (Signal's link URL doesn't rotate like WhatsApp's,
|
||||
// but we still want to erase the QR from screen once it's served).
|
||||
let qrLinesPrinted = 0;
|
||||
|
||||
const result = await spawnStep(
|
||||
'signal-auth',
|
||||
[],
|
||||
(block: Block) => {
|
||||
if (block.type === 'SIGNAL_AUTH_QR') {
|
||||
const qr = block.fields.QR ?? '';
|
||||
if (!qr) return;
|
||||
void renderQr(qr).then((lines) => {
|
||||
stopSpinner('Scan this QR from Signal → Settings → Linked Devices.');
|
||||
process.stdout.write(lines.join('\n') + '\n');
|
||||
qrLinesPrinted = lines.length;
|
||||
s.start('Waiting for you to scan…');
|
||||
spinnerActive = true;
|
||||
});
|
||||
} else if (block.type === 'SIGNAL_AUTH') {
|
||||
const status = block.fields.STATUS;
|
||||
// Wipe the QR block regardless of outcome — it's either scanned
|
||||
// and useless, or expired and misleading.
|
||||
if (qrLinesPrinted > 0) {
|
||||
process.stdout.write(`\x1b[${qrLinesPrinted}A\x1b[0J`);
|
||||
qrLinesPrinted = 0;
|
||||
}
|
||||
const account = block.fields.ACCOUNT;
|
||||
if (status === 'skipped') {
|
||||
stopSpinner(
|
||||
account
|
||||
? `Signal already linked as ${k.cyan(account)}.`
|
||||
: 'Signal already linked.',
|
||||
);
|
||||
} else if (status === 'success') {
|
||||
stopSpinner(`Signal linked as ${k.cyan(String(account ?? ''))}.`);
|
||||
} else if (status === 'failed') {
|
||||
const err = block.fields.ERROR ?? 'unknown';
|
||||
stopSpinner(`Signal link failed: ${err}`, 1);
|
||||
}
|
||||
}
|
||||
},
|
||||
rawLog,
|
||||
);
|
||||
const durationMs = Date.now() - start;
|
||||
|
||||
if (spinnerActive) {
|
||||
stopSpinner(
|
||||
result.ok ? 'Done.' : 'Signal link ended unexpectedly.',
|
||||
result.ok ? 0 : 1,
|
||||
);
|
||||
if (!result.ok) dumpTranscriptOnFailure(result.transcript);
|
||||
}
|
||||
|
||||
writeStepEntry('signal-auth', result, durationMs, rawLog);
|
||||
return { ...result, rawLog, durationMs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the raw linking URL as a block-art QR, returned line-by-line so
|
||||
* the caller can count lines for in-place cleanup. Uses small-mode so the
|
||||
* code stays scannable on 24-row terminals. If qrcode isn't installed
|
||||
* (add-signal.sh should have handled it, but we're defensive), fall back
|
||||
* to the raw URL and ask the user to paste it into an external renderer.
|
||||
*/
|
||||
async function renderQr(url: string): Promise<string[]> {
|
||||
try {
|
||||
const QRCode = await import('qrcode');
|
||||
const qrText = await QRCode.toString(url, { type: 'terminal', small: true });
|
||||
const caption = k.dim(
|
||||
' Signal → Settings → Linked Devices → Link New Device → scan.',
|
||||
);
|
||||
return [...qrText.trimEnd().split('\n'), '', caption];
|
||||
} catch {
|
||||
return [
|
||||
'Linking URL (render at https://qr.io or similar):',
|
||||
'',
|
||||
url,
|
||||
'',
|
||||
k.dim('Signal → Settings → Linked Devices → Link New Device → scan.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist SIGNAL_ACCOUNT to .env and mirror to data/env/env for the container. */
|
||||
function writeSignalAccount(account: string): void {
|
||||
const envPath = path.join(process.cwd(), '.env');
|
||||
let contents = '';
|
||||
try {
|
||||
contents = fs.readFileSync(envPath, 'utf-8');
|
||||
} catch {
|
||||
contents = '';
|
||||
}
|
||||
if (/^SIGNAL_ACCOUNT=/m.test(contents)) {
|
||||
contents = contents.replace(
|
||||
/^SIGNAL_ACCOUNT=.*$/m,
|
||||
`SIGNAL_ACCOUNT=${account}`,
|
||||
);
|
||||
} else {
|
||||
if (contents.length > 0 && !contents.endsWith('\n')) contents += '\n';
|
||||
contents += `SIGNAL_ACCOUNT=${account}\n`;
|
||||
}
|
||||
fs.writeFileSync(envPath, contents);
|
||||
|
||||
const containerEnvDir = path.join(process.cwd(), 'data', 'env');
|
||||
fs.mkdirSync(containerEnvDir, { recursive: true });
|
||||
fs.copyFileSync(envPath, path.join(containerEnvDir, 'env'));
|
||||
|
||||
setupLog.userInput('signal_account', account);
|
||||
}
|
||||
|
||||
async function restartService(): Promise<void> {
|
||||
const s = p.spinner();
|
||||
s.start('Restarting NanoClaw so it sees your Signal account…');
|
||||
const start = Date.now();
|
||||
const platform = process.platform;
|
||||
try {
|
||||
if (platform === 'darwin') {
|
||||
spawnSync(
|
||||
'launchctl',
|
||||
['kickstart', '-k', `gui/${process.getuid?.() ?? 501}/${getLaunchdLabel()}`],
|
||||
{ stdio: 'ignore' },
|
||||
);
|
||||
} else if (platform === 'linux') {
|
||||
const unit = getSystemdUnit();
|
||||
const user = spawnSync('systemctl', ['--user', 'restart', unit], {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
if (user.status !== 0) {
|
||||
spawnSync('sudo', ['systemctl', 'restart', unit], { stdio: 'ignore' });
|
||||
}
|
||||
}
|
||||
// Give the adapter a moment to connect to signal-cli before
|
||||
// init-first-agent's welcome DM hits the delivery path.
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
||||
s.stop(`NanoClaw restarted. ${k.dim(`(${elapsed}s)`)}`);
|
||||
setupLog.step('signal-restart', 'success', Date.now() - start, {
|
||||
PLATFORM: platform,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
s.stop(`Restart may have failed: ${message}`, 1);
|
||||
setupLog.step('signal-restart', 'failed', Date.now() - start, {
|
||||
ERROR: message,
|
||||
});
|
||||
// Non-fatal — the user can restart manually if init-first-agent fails.
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveAgentName(): Promise<string> {
|
||||
const preset = process.env.NANOCLAW_AGENT_NAME?.trim();
|
||||
if (preset) {
|
||||
setupLog.userInput('agent_name', preset);
|
||||
return preset;
|
||||
}
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'What should your assistant be called?',
|
||||
placeholder: DEFAULT_AGENT_NAME,
|
||||
defaultValue: DEFAULT_AGENT_NAME,
|
||||
}),
|
||||
);
|
||||
const value = (answer as string).trim() || DEFAULT_AGENT_NAME;
|
||||
setupLog.userInput('agent_name', value);
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* Slack channel flow for setup:auto.
|
||||
*
|
||||
* `runSlackChannel(displayName)` walks the operator from a bare Slack
|
||||
* workspace through a running bot, then stops before wiring an agent:
|
||||
*
|
||||
* 1. Ask the delivery mode: Socket Mode (outbound WebSocket, no public
|
||||
* URL) or a public webhook
|
||||
* 2. Walk through creating a Slack app (api.slack.com/apps) — scopes,
|
||||
* events, and the mode-specific credential (app-level token for
|
||||
* Socket Mode, signing secret for webhook)
|
||||
* 3. Paste the bot token + that credential (clack password prompts)
|
||||
* 4. Validate via auth.test → resolves workspace + bot identity
|
||||
* 5. Install the adapter (setup/add-slack.sh, non-interactive)
|
||||
* 6. Print the post-install checklist (Socket Mode: just DM the bot;
|
||||
* webhook: set the public Request URL in Event Subscriptions), then
|
||||
* `/manage-channels` to wire an agent.
|
||||
*
|
||||
* Why no welcome DM here: opening an unsolicited DM would need `im:write`
|
||||
* scope we don't force the SKILL.md to require — and in webhook mode inbound
|
||||
* events don't flow until the public Event Subscriptions URL is configured.
|
||||
* Shipping an honest "here's what's left" note is better than a welcome DM
|
||||
* the user won't receive until they finish wiring Slack up.
|
||||
*
|
||||
* All output obeys the three-level contract. See docs/setup-flow.md.
|
||||
*/
|
||||
import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import * as setupLog from '../logs.js';
|
||||
import { brightSelect } from '../lib/bright-select.js';
|
||||
import { confirmThenOpen } from '../lib/browser.js';
|
||||
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
||||
import { wrapForGutter } from '../lib/theme.js';
|
||||
|
||||
const SLACK_API = 'https://slack.com/api';
|
||||
const SLACK_APPS_URL = 'https://api.slack.com/apps';
|
||||
|
||||
interface WorkspaceInfo {
|
||||
teamName: string;
|
||||
teamId: string;
|
||||
botName: string;
|
||||
botUserId: string;
|
||||
}
|
||||
|
||||
// Socket Mode (SLACK_APP_TOKEN, xapp-…) needs no public URL; webhook mode
|
||||
// (SLACK_SIGNING_SECRET) needs a public Request URL. The adapter picks the mode
|
||||
// purely from SLACK_APP_TOKEN's presence — this choice just decides which
|
||||
// credential to collect and which post-install guidance to show.
|
||||
type SlackMode = 'socket' | 'webhook';
|
||||
|
||||
// displayName is reserved for when we start wiring the first agent here.
|
||||
// Kept to match the `run<X>Channel(displayName)` signature every other
|
||||
// channel driver uses, so auto.ts can dispatch without a branch.
|
||||
export async function runSlackChannel(_displayName: string): Promise<void> {
|
||||
const mode = await askSlackMode();
|
||||
await walkThroughAppCreation(mode);
|
||||
|
||||
const token = await collectBotToken();
|
||||
const appToken = mode === 'socket' ? await collectAppToken() : undefined;
|
||||
const signingSecret = mode === 'webhook' ? await collectSigningSecret() : undefined;
|
||||
const info = await validateSlackToken(token);
|
||||
|
||||
const env: Record<string, string> = { SLACK_BOT_TOKEN: token };
|
||||
if (appToken) env.SLACK_APP_TOKEN = appToken;
|
||||
if (signingSecret) env.SLACK_SIGNING_SECRET = signingSecret;
|
||||
|
||||
const install = await runQuietChild(
|
||||
'slack-install',
|
||||
'bash',
|
||||
['setup/add-slack.sh'],
|
||||
{
|
||||
running: `Connecting Slack to @${info.botName} (${info.teamName})…`,
|
||||
done: 'Slack adapter installed.',
|
||||
},
|
||||
{
|
||||
env,
|
||||
extraFields: {
|
||||
MODE: mode,
|
||||
BOT_NAME: info.botName,
|
||||
TEAM_NAME: info.teamName,
|
||||
TEAM_ID: info.teamId,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!install.ok) {
|
||||
await fail('slack-install', "Couldn't connect Slack.", 'See logs/setup-steps/ for details, then retry setup.');
|
||||
}
|
||||
|
||||
showPostInstallChecklist(info, mode);
|
||||
}
|
||||
|
||||
async function askSlackMode(): Promise<SlackMode> {
|
||||
const choice = ensureAnswer(
|
||||
await brightSelect<SlackMode>({
|
||||
message: 'How should Slack deliver events to NanoClaw?',
|
||||
initialValue: 'socket',
|
||||
options: [
|
||||
{
|
||||
value: 'socket',
|
||||
label: 'Socket Mode',
|
||||
hint: 'no public URL — recommended for local or behind NAT',
|
||||
},
|
||||
{
|
||||
value: 'webhook',
|
||||
label: 'Public webhook',
|
||||
hint: 'needs a public HTTPS Request URL',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
setupLog.userInput('slack_mode', String(choice));
|
||||
return choice;
|
||||
}
|
||||
|
||||
async function walkThroughAppCreation(mode: SlackMode): Promise<void> {
|
||||
const credSteps =
|
||||
mode === 'socket'
|
||||
? [
|
||||
' 4. Basic Information → App-Level Tokens → "Generate Token and',
|
||||
' Scopes" → add the connections:write scope → copy it (xapp-…)',
|
||||
' 5. Socket Mode → toggle "Enable Socket Mode" on',
|
||||
' 6. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)',
|
||||
]
|
||||
: [
|
||||
' 4. Basic Information → copy the "Signing Secret"',
|
||||
' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)',
|
||||
];
|
||||
p.note(
|
||||
[
|
||||
"You'll create a Slack app that the assistant talks through.",
|
||||
'Free and stays inside the workspaces you pick.',
|
||||
'',
|
||||
' 1. Create a new app "From scratch", name it, pick a workspace',
|
||||
' 2. OAuth & Permissions → add Bot Token Scopes:',
|
||||
' chat:write, channels:history, groups:history, im:history,',
|
||||
' channels:read, groups:read, users:read, reactions:write',
|
||||
' 3. App Home → enable "Messages Tab" and "Allow users to send',
|
||||
' slash commands and messages from the messages tab"',
|
||||
...credSteps,
|
||||
'',
|
||||
k.dim(SLACK_APPS_URL),
|
||||
].join('\n'),
|
||||
'Create a Slack app',
|
||||
);
|
||||
await confirmThenOpen(SLACK_APPS_URL, 'Press Enter to open Slack app settings');
|
||||
|
||||
ensureAnswer(
|
||||
await p.confirm({
|
||||
message: mode === 'socket' ? 'Got your bot token and app-level token?' : 'Got your bot token and signing secret?',
|
||||
initialValue: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function collectBotToken(): Promise<string> {
|
||||
const answer = ensureAnswer(
|
||||
await p.password({
|
||||
message: 'Paste your Slack bot token',
|
||||
validate: (v) => {
|
||||
const t = (v ?? '').trim();
|
||||
if (!t) return 'Token is required';
|
||||
if (!t.startsWith('xoxb-')) return 'Bot tokens start with xoxb-';
|
||||
if (t.length < 24) return "That's shorter than a real Slack bot token";
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const token = (answer as string).trim();
|
||||
setupLog.userInput('slack_bot_token', `${token.slice(0, 10)}…${token.slice(-4)}`);
|
||||
return token;
|
||||
}
|
||||
|
||||
async function collectSigningSecret(): Promise<string> {
|
||||
const answer = ensureAnswer(
|
||||
await p.password({
|
||||
message: 'Paste your Slack signing secret',
|
||||
validate: (v) => {
|
||||
const t = (v ?? '').trim();
|
||||
if (!t) return 'Signing secret is required';
|
||||
// Slack signing secrets are 32-char hex strings, but newer apps
|
||||
// sometimes emit longer variants — leniently require hex only.
|
||||
if (!/^[a-f0-9]{16,}$/i.test(t)) {
|
||||
return 'Signing secrets are a string of hex characters';
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const secret = (answer as string).trim();
|
||||
setupLog.userInput('slack_signing_secret', `${secret.slice(0, 4)}…${secret.slice(-4)}`);
|
||||
return secret;
|
||||
}
|
||||
|
||||
async function collectAppToken(): Promise<string> {
|
||||
const answer = ensureAnswer(
|
||||
await p.password({
|
||||
message: 'Paste your Slack app-level token (Socket Mode)',
|
||||
validate: (v) => {
|
||||
const t = (v ?? '').trim();
|
||||
if (!t) return 'App-level token is required for Socket Mode';
|
||||
if (!t.startsWith('xapp-')) return 'App-level tokens start with xapp-';
|
||||
if (t.length < 24) return "That's shorter than a real Slack app-level token";
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const token = (answer as string).trim();
|
||||
setupLog.userInput('slack_app_token', `${token.slice(0, 10)}…${token.slice(-4)}`);
|
||||
return token;
|
||||
}
|
||||
|
||||
async function validateSlackToken(token: string): Promise<WorkspaceInfo> {
|
||||
const s = p.spinner();
|
||||
const start = Date.now();
|
||||
s.start('Checking your bot token…');
|
||||
try {
|
||||
const res = await fetch(`${SLACK_API}/auth.test`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
});
|
||||
const data = (await res.json()) as {
|
||||
ok?: boolean;
|
||||
team?: string;
|
||||
team_id?: string;
|
||||
user?: string;
|
||||
user_id?: string;
|
||||
error?: string;
|
||||
};
|
||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
||||
if (data.ok && data.team && data.user) {
|
||||
s.stop(`Connected to ${data.team} as @${data.user}. ${k.dim(`(${elapsedS}s)`)}`);
|
||||
const info: WorkspaceInfo = {
|
||||
teamName: data.team,
|
||||
teamId: data.team_id ?? '',
|
||||
botName: data.user,
|
||||
botUserId: data.user_id ?? '',
|
||||
};
|
||||
setupLog.step('slack-validate', 'success', Date.now() - start, {
|
||||
BOT_NAME: info.botName,
|
||||
BOT_USER_ID: info.botUserId,
|
||||
TEAM_NAME: info.teamName,
|
||||
TEAM_ID: info.teamId,
|
||||
});
|
||||
return info;
|
||||
}
|
||||
const reason = data.error ?? `HTTP ${res.status}`;
|
||||
s.stop(`Slack didn't accept that token: ${reason}`, 1);
|
||||
setupLog.step('slack-validate', 'failed', Date.now() - start, {
|
||||
ERROR: reason,
|
||||
});
|
||||
await fail(
|
||||
'slack-validate',
|
||||
"Slack didn't accept that token.",
|
||||
reason === 'invalid_auth' || reason === 'token_revoked'
|
||||
? 'Copy the token again from OAuth & Permissions and retry setup.'
|
||||
: `Slack said "${reason}". Check the token scopes and workspace install, then retry.`,
|
||||
);
|
||||
} catch (err) {
|
||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
||||
s.stop(`Couldn't reach Slack. ${k.dim(`(${elapsedS}s)`)}`, 1);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setupLog.step('slack-validate', 'failed', Date.now() - start, {
|
||||
ERROR: message,
|
||||
});
|
||||
await fail('slack-validate', "Couldn't reach Slack.", 'Check your internet connection and retry setup.');
|
||||
}
|
||||
}
|
||||
|
||||
function showPostInstallChecklist(info: WorkspaceInfo, mode: SlackMode): void {
|
||||
if (mode === 'socket') {
|
||||
p.note(
|
||||
wrapForGutter(
|
||||
[
|
||||
`The Slack adapter is installed in Socket Mode and your creds are saved. No public URL needed — ${info.teamName} reaches NanoClaw over an outbound WebSocket.`,
|
||||
'',
|
||||
` 1. DM @${info.botName} from Slack once — that bootstraps the`,
|
||||
' messaging group. Then run `/manage-channels` in `claude` to',
|
||||
' wire an agent to it.',
|
||||
'',
|
||||
' Note: keep the NanoClaw host running to hold the socket open —',
|
||||
' Slack does not retry delivery while it is down.',
|
||||
].join('\n'),
|
||||
6,
|
||||
),
|
||||
'Finish setting up Slack',
|
||||
);
|
||||
return;
|
||||
}
|
||||
p.note(
|
||||
wrapForGutter(
|
||||
[
|
||||
`The Slack adapter is installed and your creds are saved. ${info.teamName} still needs two things before it can talk to you:`,
|
||||
'',
|
||||
' 1. A public URL so Slack can deliver events.',
|
||||
' NanoClaw serves a webhook on port 3000 by default — expose it',
|
||||
' via ngrok, Cloudflare Tunnel, or a reverse proxy on a VPS.',
|
||||
'',
|
||||
' 2. In your Slack app → Event Subscriptions:',
|
||||
' • Toggle "Enable Events" on',
|
||||
` • Request URL: https://<your-public-host>/webhook/slack`,
|
||||
' • Subscribe to bot events: message.channels, message.groups,',
|
||||
' message.im, app_mention',
|
||||
' • Save, then reinstall the app when Slack prompts',
|
||||
'',
|
||||
` 3. DM @${info.botName} from Slack once — that bootstraps the`,
|
||||
' messaging group. Then run `/manage-channels` in `claude` to',
|
||||
' wire an agent to it.',
|
||||
].join('\n'),
|
||||
6,
|
||||
),
|
||||
'Finish setting up Slack',
|
||||
);
|
||||
}
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Step: cli-agent — Create the scratch CLI agent for `/new-setup`.
|
||||
* Step: cli-agent — Create the scratch CLI agent for `/setup`.
|
||||
*
|
||||
* Thin wrapper around `scripts/init-cli-agent.ts`. Emits a status block so
|
||||
* /new-setup SKILL.md can parse the result without having to read the
|
||||
* /setup SKILL.md can parse the result without having to read the
|
||||
* script's plain stdout.
|
||||
*
|
||||
* Args:
|
||||
|
||||
+52
-45
@@ -1,5 +1,7 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
@@ -17,58 +19,63 @@ describe('environment detection', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('registered groups DB query', () => {
|
||||
let db: Database.Database;
|
||||
describe('detectRegisteredGroups', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS registered_groups (
|
||||
jid TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
folder TEXT NOT NULL UNIQUE,
|
||||
trigger_pattern TEXT NOT NULL,
|
||||
added_at TEXT NOT NULL,
|
||||
container_config TEXT,
|
||||
requires_trigger INTEGER DEFAULT 1
|
||||
)`);
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-env-test-'));
|
||||
fs.mkdirSync(path.join(tempDir, 'data'), { recursive: true });
|
||||
});
|
||||
|
||||
it('returns 0 for empty table', () => {
|
||||
const row = db
|
||||
.prepare('SELECT COUNT(*) as count FROM registered_groups')
|
||||
.get() as { count: number };
|
||||
expect(row.count).toBe(0);
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns correct count after inserts', () => {
|
||||
db.prepare(
|
||||
`INSERT INTO registered_groups (jid, name, folder, trigger_pattern, added_at, requires_trigger)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
'123@g.us',
|
||||
'Group 1',
|
||||
'group-1',
|
||||
'@Andy',
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
1,
|
||||
);
|
||||
it('returns false when no registration state exists', async () => {
|
||||
const { detectRegisteredGroups } = await import('./environment.js');
|
||||
expect(detectRegisteredGroups(tempDir)).toBe(false);
|
||||
});
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO registered_groups (jid, name, folder, trigger_pattern, added_at, requires_trigger)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
'456@g.us',
|
||||
'Group 2',
|
||||
'group-2',
|
||||
'@Andy',
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
1,
|
||||
);
|
||||
it('detects pre-migration registered_groups.json', async () => {
|
||||
const { detectRegisteredGroups } = await import('./environment.js');
|
||||
fs.writeFileSync(path.join(tempDir, 'data', 'registered_groups.json'), '[]');
|
||||
expect(detectRegisteredGroups(tempDir)).toBe(true);
|
||||
});
|
||||
|
||||
const row = db
|
||||
.prepare('SELECT COUNT(*) as count FROM registered_groups')
|
||||
.get() as { count: number };
|
||||
expect(row.count).toBe(2);
|
||||
it('returns false for an empty v2 central DB', async () => {
|
||||
const { detectRegisteredGroups } = await import('./environment.js');
|
||||
const db = new Database(path.join(tempDir, 'data', 'v2.db'));
|
||||
db.exec(`
|
||||
CREATE TABLE agent_groups (id TEXT PRIMARY KEY);
|
||||
CREATE TABLE messaging_group_agents (
|
||||
id TEXT PRIMARY KEY,
|
||||
messaging_group_id TEXT NOT NULL,
|
||||
agent_group_id TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
db.close();
|
||||
|
||||
expect(detectRegisteredGroups(tempDir)).toBe(false);
|
||||
});
|
||||
|
||||
it('detects wired agent groups in the v2 central DB', async () => {
|
||||
const { detectRegisteredGroups } = await import('./environment.js');
|
||||
const db = new Database(path.join(tempDir, 'data', 'v2.db'));
|
||||
db.exec(`
|
||||
CREATE TABLE agent_groups (id TEXT PRIMARY KEY);
|
||||
CREATE TABLE messaging_group_agents (
|
||||
id TEXT PRIMARY KEY,
|
||||
messaging_group_id TEXT NOT NULL,
|
||||
agent_group_id TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
db.prepare('INSERT INTO agent_groups (id) VALUES (?)').run('ag-1');
|
||||
db.prepare(
|
||||
'INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id) VALUES (?, ?, ?)',
|
||||
).run('mga-1', 'mg-1', 'ag-1');
|
||||
db.close();
|
||||
|
||||
expect(detectRegisteredGroups(tempDir)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+26
-21
@@ -7,11 +7,35 @@ import path from 'path';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
import { STORE_DIR } from '../src/config.js';
|
||||
import { log } from '../src/log.js';
|
||||
import { commandExists, getPlatform, isHeadless, isWSL } from './platform.js';
|
||||
import { emitStatus } from './status.js';
|
||||
|
||||
export function detectRegisteredGroups(projectRoot: string): boolean {
|
||||
if (fs.existsSync(path.join(projectRoot, 'data', 'registered_groups.json'))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const dbPath = path.join(projectRoot, 'data', 'v2.db');
|
||||
if (!fs.existsSync(dbPath)) return false;
|
||||
|
||||
let db: Database.Database | null = null;
|
||||
try {
|
||||
db = new Database(dbPath, { readonly: true });
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT COUNT(DISTINCT ag.id) as count FROM agent_groups ag
|
||||
JOIN messaging_group_agents mga ON mga.agent_group_id = ag.id`,
|
||||
)
|
||||
.get() as { count: number };
|
||||
return row.count > 0;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
db?.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function run(_args: string[]): Promise<void> {
|
||||
const projectRoot = process.cwd();
|
||||
|
||||
@@ -39,26 +63,7 @@ export async function run(_args: string[]): Promise<void> {
|
||||
const authDir = path.join(projectRoot, 'store', 'auth');
|
||||
const hasAuth = fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0;
|
||||
|
||||
let hasRegisteredGroups = false;
|
||||
// Check JSON file first (pre-migration)
|
||||
if (fs.existsSync(path.join(projectRoot, 'data', 'registered_groups.json'))) {
|
||||
hasRegisteredGroups = true;
|
||||
} else {
|
||||
// Check SQLite directly using better-sqlite3 (no sqlite3 CLI needed)
|
||||
const dbPath = path.join(STORE_DIR, 'messages.db');
|
||||
if (fs.existsSync(dbPath)) {
|
||||
try {
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
const row = db
|
||||
.prepare('SELECT COUNT(*) as count FROM registered_groups')
|
||||
.get() as { count: number };
|
||||
if (row.count > 0) hasRegisteredGroups = true;
|
||||
db.close();
|
||||
} catch {
|
||||
// Table might not exist yet
|
||||
}
|
||||
}
|
||||
}
|
||||
const hasRegisteredGroups = detectRegisteredGroups(projectRoot);
|
||||
|
||||
// Check for existing OpenClaw installation
|
||||
const homedir = (await import('os')).homedir();
|
||||
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* Step: groups — Fetch group metadata from messaging platforms, write to DB.
|
||||
* WhatsApp requires an upfront sync (Baileys groupFetchAllParticipating).
|
||||
* Other channels discover group names at runtime — this step auto-skips for them.
|
||||
* Replaces 05-sync-groups.sh + 05b-list-groups.sh
|
||||
*/
|
||||
import { execSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
import { STORE_DIR } from '../src/config.js';
|
||||
import { log } from '../src/log.js';
|
||||
import { emitStatus } from './status.js';
|
||||
|
||||
function parseArgs(args: string[]): { list: boolean; limit: number } {
|
||||
let list = false;
|
||||
let limit = 30;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--list') list = true;
|
||||
if (args[i] === '--limit' && args[i + 1]) {
|
||||
limit = parseInt(args[i + 1], 10);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return { list, limit };
|
||||
}
|
||||
|
||||
export async function run(args: string[]): Promise<void> {
|
||||
const projectRoot = process.cwd();
|
||||
const { list, limit } = parseArgs(args);
|
||||
|
||||
if (list) {
|
||||
await listGroups(limit);
|
||||
return;
|
||||
}
|
||||
|
||||
await syncGroups(projectRoot);
|
||||
}
|
||||
|
||||
async function listGroups(limit: number): Promise<void> {
|
||||
const dbPath = path.join(STORE_DIR, 'messages.db');
|
||||
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
console.error('ERROR: database not found');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT jid, name FROM chats
|
||||
WHERE jid LIKE '%@g.us' AND jid <> '__group_sync__' AND name <> jid
|
||||
ORDER BY last_message_time DESC
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(limit) as Array<{ jid: string; name: string }>;
|
||||
db.close();
|
||||
|
||||
for (const row of rows) {
|
||||
console.log(`${row.jid}|${row.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function syncGroups(projectRoot: string): Promise<void> {
|
||||
// Only WhatsApp needs an upfront group sync; other channels resolve names at runtime.
|
||||
// Detect WhatsApp by checking for auth credentials on disk.
|
||||
const authDir = path.join(projectRoot, 'store', 'auth');
|
||||
const hasWhatsAppAuth =
|
||||
fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0;
|
||||
|
||||
if (!hasWhatsAppAuth) {
|
||||
log.info('WhatsApp auth not found — skipping group sync');
|
||||
emitStatus('SYNC_GROUPS', {
|
||||
BUILD: 'skipped',
|
||||
SYNC: 'skipped',
|
||||
GROUPS_IN_DB: 0,
|
||||
REASON: 'whatsapp_not_configured',
|
||||
STATUS: 'success',
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Build TypeScript first
|
||||
log.info('Building TypeScript');
|
||||
let buildOk = false;
|
||||
try {
|
||||
execSync('pnpm run build', {
|
||||
cwd: projectRoot,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
buildOk = true;
|
||||
log.info('Build succeeded');
|
||||
} catch {
|
||||
log.error('Build failed');
|
||||
emitStatus('SYNC_GROUPS', {
|
||||
BUILD: 'failed',
|
||||
SYNC: 'skipped',
|
||||
GROUPS_IN_DB: 0,
|
||||
STATUS: 'failed',
|
||||
ERROR: 'build_failed',
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Run sync script via a temp file to avoid shell escaping issues with node -e
|
||||
log.info('Fetching group metadata');
|
||||
let syncOk = false;
|
||||
try {
|
||||
const syncScript = `
|
||||
import makeWASocket, { useMultiFileAuthState, makeCacheableSignalKeyStore, Browsers } from '@whiskeysockets/baileys';
|
||||
import pino from 'pino';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
const logger = pino({ level: 'silent' });
|
||||
const authDir = path.join('store', 'auth');
|
||||
const dbPath = path.join('store', 'messages.db');
|
||||
|
||||
if (!fs.existsSync(authDir)) {
|
||||
console.error('NO_AUTH');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const db = new Database(dbPath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.exec('CREATE TABLE IF NOT EXISTS chats (jid TEXT PRIMARY KEY, name TEXT, last_message_time TEXT)');
|
||||
|
||||
const upsert = db.prepare(
|
||||
'INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?) ON CONFLICT(jid) DO UPDATE SET name = excluded.name'
|
||||
);
|
||||
|
||||
const { state, saveCreds } = await useMultiFileAuthState(authDir);
|
||||
|
||||
const sock = makeWASocket({
|
||||
auth: { creds: state.creds, keys: makeCacheableSignalKeyStore(state.keys, logger) },
|
||||
printQRInTerminal: false,
|
||||
logger,
|
||||
browser: Browsers.macOS('Chrome'),
|
||||
});
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
console.error('TIMEOUT');
|
||||
process.exit(1);
|
||||
}, 30000);
|
||||
|
||||
sock.ev.on('creds.update', saveCreds);
|
||||
|
||||
sock.ev.on('connection.update', async (update) => {
|
||||
if (update.connection === 'open') {
|
||||
try {
|
||||
const groups = await sock.groupFetchAllParticipating();
|
||||
const now = new Date().toISOString();
|
||||
let count = 0;
|
||||
for (const [jid, metadata] of Object.entries(groups)) {
|
||||
if (metadata.subject) {
|
||||
upsert.run(jid, metadata.subject, now);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
console.log('SYNCED:' + count);
|
||||
} catch (err) {
|
||||
console.error('FETCH_ERROR:' + err.message);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
sock.end(undefined);
|
||||
db.close();
|
||||
process.exit(0);
|
||||
}
|
||||
} else if (update.connection === 'close') {
|
||||
clearTimeout(timeout);
|
||||
console.error('CONNECTION_CLOSED');
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
`;
|
||||
|
||||
const tmpScript = path.join(projectRoot, '.tmp-group-sync.mjs');
|
||||
fs.writeFileSync(tmpScript, syncScript, 'utf-8');
|
||||
try {
|
||||
const output = execSync(`node ${tmpScript}`, {
|
||||
cwd: projectRoot,
|
||||
encoding: 'utf-8',
|
||||
timeout: 45000,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
syncOk = output.includes('SYNCED:');
|
||||
log.info('Sync output', { output: output.trim() });
|
||||
} finally {
|
||||
try { fs.unlinkSync(tmpScript); } catch { /* ignore cleanup errors */ }
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('Sync failed', { err });
|
||||
}
|
||||
|
||||
// Count groups in DB using better-sqlite3 (no sqlite3 CLI)
|
||||
let groupsInDb = 0;
|
||||
const dbPath = path.join(STORE_DIR, 'messages.db');
|
||||
if (fs.existsSync(dbPath)) {
|
||||
try {
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
const row = db
|
||||
.prepare(
|
||||
"SELECT COUNT(*) as count FROM chats WHERE jid LIKE '%@g.us' AND jid <> '__group_sync__'",
|
||||
)
|
||||
.get() as { count: number };
|
||||
groupsInDb = row.count;
|
||||
db.close();
|
||||
} catch {
|
||||
// DB may not exist yet
|
||||
}
|
||||
}
|
||||
|
||||
const status = syncOk ? 'success' : 'failed';
|
||||
|
||||
emitStatus('SYNC_GROUPS', {
|
||||
BUILD: buildOk ? 'success' : 'failed',
|
||||
SYNC: syncOk ? 'success' : 'failed',
|
||||
GROUPS_IN_DB: groupsInDb,
|
||||
STATUS: status,
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
|
||||
if (status === 'failed') process.exit(1);
|
||||
}
|
||||
+3
-1
@@ -13,9 +13,11 @@ const STEPS: Record<
|
||||
'set-env': () => import('./set-env.js'),
|
||||
environment: () => import('./environment.js'),
|
||||
container: () => import('./container.js'),
|
||||
register: () => import('./register.js'),
|
||||
groups: () => import('./groups.js'),
|
||||
register: () => import('./register.js'),
|
||||
'pair-telegram': () => import('./pair-telegram.js'),
|
||||
'whatsapp-auth': () => import('./whatsapp-auth.js'),
|
||||
'signal-auth': () => import('./signal-auth.js'),
|
||||
mounts: () => import('./mounts.js'),
|
||||
service: () => import('./service.js'),
|
||||
verify: () => import('./verify.js'),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-discord — bundles the preflight + install commands
|
||||
# from the /add-discord skill into one idempotent script so /new-setup can
|
||||
# from the /add-discord skill into one idempotent script so /setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the Discord adapter in from the `channels` branch; appends the
|
||||
@@ -37,7 +37,7 @@ if ! grep -q "import './discord.js';" src/channels/index.ts; then
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/discord@4.26.0
|
||||
pnpm install @chat-adapter/discord@4.29.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-docker — bundles Docker install into one idempotent
|
||||
# script so /new-setup can run it without needing `curl | sh` in the allowlist
|
||||
# script so /setup can run it without needing `curl | sh` in the allowlist
|
||||
# (pipelines split at matching time, and `sh` receiving stdin can't be
|
||||
# pre-approved safely).
|
||||
#
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-gchat — bundles the preflight + install commands
|
||||
# from the /add-gchat skill into one idempotent script so /new-setup can
|
||||
# from the /add-gchat skill into one idempotent script so /setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the Google Chat adapter in from the `channels` branch; appends the
|
||||
@@ -37,7 +37,7 @@ if ! grep -q "import './gchat.js';" src/channels/index.ts; then
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/gchat@4.26.0
|
||||
pnpm install @chat-adapter/gchat@4.29.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-github — bundles the preflight + install commands
|
||||
# from the /add-github skill into one idempotent script so /new-setup can
|
||||
# from the /add-github skill into one idempotent script so /setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the GitHub adapter in from the `channels` branch; appends the
|
||||
@@ -37,7 +37,7 @@ if ! grep -q "import './github.js';" src/channels/index.ts; then
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/github@4.26.0
|
||||
pnpm install @chat-adapter/github@4.29.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-imessage — bundles the preflight + install commands
|
||||
# from the /add-imessage skill into one idempotent script so /new-setup can
|
||||
# from the /add-imessage skill into one idempotent script so /setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the iMessage adapter in from the `channels` branch; appends the
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-linear — bundles the preflight + install commands
|
||||
# from the /add-linear skill into one idempotent script so /new-setup can
|
||||
# from the /add-linear skill into one idempotent script so /setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the Linear adapter in from the `channels` branch; appends the
|
||||
@@ -86,7 +86,7 @@ if ! grep -q 'if (config.catchAll) {' src/channels/chat-sdk-bridge.ts; then
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/linear@4.26.0
|
||||
pnpm install @chat-adapter/linear@4.29.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-matrix — bundles the preflight + install commands
|
||||
# from the /add-matrix skill into one idempotent script so /new-setup can
|
||||
# from the /add-matrix skill into one idempotent script so /setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the Matrix adapter in from the `channels` branch; appends the
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-node — bundles Node 22 install into one idempotent
|
||||
# script so /new-setup can run it without needing `curl | sudo -E bash -` in
|
||||
# script so /setup can run it without needing `curl | sudo -E bash -` in
|
||||
# the allowlist (that pattern is inherently unmatchable — bash reads from
|
||||
# stdin, so pre-approval can't inspect what's being executed).
|
||||
#
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-resend — bundles the preflight + install commands
|
||||
# from the /add-resend skill into one idempotent script so /new-setup can
|
||||
# from the /add-resend skill into one idempotent script so /setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the Resend adapter in from the `channels` branch; appends the
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-slack — bundles the preflight + install commands
|
||||
# from the /add-slack skill into one idempotent script so /new-setup can
|
||||
# from the /add-slack skill into one idempotent script so /setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the Slack adapter in from the `channels` branch; appends the
|
||||
@@ -37,7 +37,7 @@ if ! grep -q "import './slack.js';" src/channels/index.ts; then
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/slack@4.26.0
|
||||
pnpm install @chat-adapter/slack@4.29.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-teams — bundles the preflight + install commands
|
||||
# from the /add-teams skill into one idempotent script so /new-setup can
|
||||
# from the /add-teams skill into one idempotent script so /setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the Teams adapter in from the `channels` branch; appends the
|
||||
@@ -37,7 +37,7 @@ if ! grep -q "import './teams.js';" src/channels/index.ts; then
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/teams@4.26.0
|
||||
pnpm install @chat-adapter/teams@4.29.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-telegram — bundles the preflight + install commands
|
||||
# from the /add-telegram skill into one idempotent script so /new-setup can
|
||||
# from the /add-telegram skill into one idempotent script so /setup can
|
||||
# run them programmatically before continuing to credentials and pairing.
|
||||
#
|
||||
# Copies the Telegram adapter, helpers, tests, and the pair-telegram setup
|
||||
@@ -63,7 +63,7 @@ if ! grep -q "'pair-telegram':" setup/index.ts; then
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/telegram@4.26.0
|
||||
pnpm install @chat-adapter/telegram@4.29.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-webex — bundles the preflight + install commands
|
||||
# from the /add-webex skill into one idempotent script so /new-setup can
|
||||
# from the /add-webex skill into one idempotent script so /setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the Webex adapter in from the `channels` branch; appends the
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-whatsapp-cloud — bundles the preflight + install
|
||||
# commands from the /add-whatsapp-cloud skill into one idempotent script so
|
||||
# /new-setup can run them programmatically before continuing to credentials.
|
||||
# /setup can run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the WhatsApp Cloud adapter in from the `channels` branch; appends the
|
||||
# self-registration import; installs the pinned @chat-adapter/whatsapp package;
|
||||
@@ -37,7 +37,7 @@ if ! grep -q "import './whatsapp-cloud.js';" src/channels/index.ts; then
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/whatsapp@4.26.0
|
||||
pnpm install @chat-adapter/whatsapp@4.29.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-whatsapp — bundles the preflight + install commands
|
||||
# from the /add-whatsapp skill into one idempotent script so /new-setup can
|
||||
# from the /add-whatsapp skill into one idempotent script so /setup can
|
||||
# run them programmatically before continuing to QR/pairing-code auth.
|
||||
#
|
||||
# Copies the native Baileys WhatsApp adapter, its whatsapp-auth and groups
|
||||
@@ -66,7 +66,7 @@ if ! grep -q "'whatsapp-auth':" setup/index.ts; then
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @whiskeysockets/baileys@6.17.16 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
|
||||
pnpm install @whiskeysockets/baileys@7.0.0-rc.9 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { classifyPingResult } from './agent-ping.js';
|
||||
|
||||
describe('classifyPingResult', () => {
|
||||
it('treats a normal text reply as ok', () => {
|
||||
expect(classifyPingResult(0, 'pong\n')).toBe('ok');
|
||||
});
|
||||
|
||||
it('detects Anthropic auth errors printed as a chat reply', () => {
|
||||
expect(
|
||||
classifyPingResult(
|
||||
0,
|
||||
'Failed to authenticate. API Error: 401 {"type":"error","error":{"type":"authentication_error","message":"Invalid bearer token"}}',
|
||||
),
|
||||
).toBe('auth_error');
|
||||
});
|
||||
|
||||
it('detects auth errors on stderr too', () => {
|
||||
expect(classifyPingResult(1, '', 'Authentication error')).toBe('auth_error');
|
||||
});
|
||||
|
||||
it('preserves socket errors', () => {
|
||||
expect(classifyPingResult(2, '')).toBe('socket_error');
|
||||
});
|
||||
|
||||
it('treats empty output as no reply', () => {
|
||||
expect(classifyPingResult(0, '')).toBe('no_reply');
|
||||
});
|
||||
});
|
||||
+20
-4
@@ -13,7 +13,21 @@
|
||||
*/
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
export type PingResult = 'ok' | 'no_reply' | 'socket_error';
|
||||
export type PingResult = 'ok' | 'no_reply' | 'socket_error' | 'auth_error';
|
||||
|
||||
export function classifyPingResult(exitCode: number | null, stdout: string, stderr = ''): PingResult {
|
||||
const output = `${stdout}\n${stderr}`;
|
||||
if (
|
||||
/Invalid bearer token/i.test(output) ||
|
||||
/authentication[_ ]error/i.test(output) ||
|
||||
/Failed to authenticate/i.test(output)
|
||||
) {
|
||||
return 'auth_error';
|
||||
}
|
||||
if (exitCode === 2) return 'socket_error';
|
||||
if (exitCode === 0 && stdout.trim().length > 0) return 'ok';
|
||||
return 'no_reply';
|
||||
}
|
||||
|
||||
export function pingCliAgent(timeoutMs = 30_000): Promise<PingResult> {
|
||||
return new Promise((resolve) => {
|
||||
@@ -21,6 +35,7 @@ export function pingCliAgent(timeoutMs = 30_000): Promise<PingResult> {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
@@ -32,13 +47,14 @@ export function pingCliAgent(timeoutMs = 30_000): Promise<PingResult> {
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
stdout += chunk.toString('utf-8');
|
||||
});
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
stderr += chunk.toString('utf-8');
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (code === 2) resolve('socket_error');
|
||||
else if (code === 0 && stdout.trim().length > 0) resolve('ok');
|
||||
else resolve('no_reply');
|
||||
resolve(classifyPingResult(code, stdout, stderr));
|
||||
});
|
||||
child.on('error', () => {
|
||||
if (settled) return;
|
||||
|
||||
+44
-15
@@ -64,6 +64,10 @@ const STEP_FILES: Record<string, string[]> = {
|
||||
'telegram-validate': ['setup/channels/telegram.ts'],
|
||||
'pair-telegram': ['setup/pair-telegram.ts', 'setup/channels/telegram.ts'],
|
||||
'discord-install': ['setup/add-discord.sh', 'setup/channels/discord.ts'],
|
||||
'slack-install': ['setup/add-slack.sh', 'setup/channels/slack.ts'],
|
||||
'slack-validate': ['setup/channels/slack.ts'],
|
||||
'imessage-install': ['setup/add-imessage.sh', 'setup/channels/imessage.ts'],
|
||||
'imessage': ['setup/channels/imessage.ts'],
|
||||
'teams-install': ['setup/add-teams.sh', 'setup/channels/teams.ts'],
|
||||
'teams-manifest': ['setup/lib/teams-manifest.ts', 'setup/channels/teams.ts'],
|
||||
'init-first-agent': [
|
||||
@@ -115,7 +119,7 @@ export async function offerClaudeAssist(
|
||||
const run = ensureAnswer(
|
||||
await p.confirm({
|
||||
message: 'Run this command? (you can edit it before executing)',
|
||||
initialValue: false,
|
||||
initialValue: true,
|
||||
}),
|
||||
);
|
||||
if (!run) return false;
|
||||
@@ -279,18 +283,24 @@ async function queryClaudeUnderSpinner(
|
||||
// No hard timeout — debugging can take a long time, and the cost of
|
||||
// cutting Claude off mid-investigation is worse than letting the
|
||||
// spinner run. The user can Ctrl-C if they want to abort.
|
||||
const child = spawn(
|
||||
'claude',
|
||||
[
|
||||
'-p',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--verbose',
|
||||
'--permission-mode',
|
||||
'bypassPermissions',
|
||||
],
|
||||
{ cwd: projectRoot, stdio: ['pipe', 'pipe', 'pipe'] },
|
||||
);
|
||||
//
|
||||
// Resume the same session on repeat invocations so Claude carries
|
||||
// context across failures in one setup run.
|
||||
const claudeArgs = [
|
||||
'-p',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--verbose',
|
||||
'--permission-mode',
|
||||
'bypassPermissions',
|
||||
];
|
||||
if (claudeSessionId) {
|
||||
claudeArgs.push('--resume', claudeSessionId);
|
||||
}
|
||||
const child = spawn('claude', claudeArgs, {
|
||||
cwd: projectRoot,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
child.stdout.on('data', (c: Buffer) => {
|
||||
lineBuf += c.toString('utf-8');
|
||||
@@ -301,6 +311,16 @@ async function queryClaudeUnderSpinner(
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const event = JSON.parse(line) as StreamEvent;
|
||||
// Capture the session id on the very first claude invocation of
|
||||
// this process so later calls can --resume it.
|
||||
if (
|
||||
!claudeSessionId &&
|
||||
event.type === 'system' &&
|
||||
event.subtype === 'init' &&
|
||||
typeof event.session_id === 'string'
|
||||
) {
|
||||
claudeSessionId = event.session_id;
|
||||
}
|
||||
handleStreamEvent(event, {
|
||||
setAction: (a) => {
|
||||
actions.push(a);
|
||||
@@ -335,10 +355,14 @@ async function queryClaudeUnderSpinner(
|
||||
}
|
||||
|
||||
// Minimal shape of the stream-json events we care about. Claude emits
|
||||
// many more, but we only read tool_use blocks (for breadcrumbs) and text
|
||||
// blocks (to reassemble the final REASON/COMMAND answer).
|
||||
// many more, but we only read tool_use blocks (for breadcrumbs), text
|
||||
// blocks (to reassemble the final REASON/COMMAND answer), and the
|
||||
// session_id on the init event so follow-up invocations can resume the
|
||||
// same conversation.
|
||||
interface StreamEvent {
|
||||
type: string;
|
||||
subtype?: string;
|
||||
session_id?: string;
|
||||
message?: {
|
||||
content?: Array<
|
||||
| { type: 'text'; text: string }
|
||||
@@ -347,6 +371,11 @@ interface StreamEvent {
|
||||
};
|
||||
}
|
||||
|
||||
// The session id from the first claude-assist invocation in this process.
|
||||
// Subsequent invocations pass `--resume <id>` so Claude sees prior failures
|
||||
// as conversation history instead of treating each failure in isolation.
|
||||
let claudeSessionId: string | null = null;
|
||||
|
||||
function handleStreamEvent(
|
||||
event: StreamEvent,
|
||||
cb: { setAction: (a: string) => void; appendText: (t: string) => void },
|
||||
|
||||
+4
-2
@@ -322,10 +322,12 @@ async function runUnderSpinner<
|
||||
if (result.ok) {
|
||||
const isSkipped = result.terminal?.fields.STATUS === 'skipped';
|
||||
const msg = isSkipped && labels.skipped ? labels.skipped : labels.done;
|
||||
s.stop(`${fitToWidth(msg, suffix)}${k.dim(suffix)}`);
|
||||
// Bold the outcome so the step's headline reads stronger than the prose
|
||||
// body copy around it. The trailing `(Ns)` timing stays dim.
|
||||
s.stop(`${k.bold(fitToWidth(msg, suffix))}${k.dim(suffix)}`);
|
||||
} else {
|
||||
const failMsg = labels.failed ?? labels.running.replace(/…$/, ' failed');
|
||||
s.stop(`${fitToWidth(failMsg, suffix)}${k.dim(suffix)}`, 1);
|
||||
s.stop(`${k.bold(fitToWidth(failMsg, suffix))}${k.dim(suffix)}`, 1);
|
||||
dumpTranscriptOnFailure(result.transcript);
|
||||
}
|
||||
return result;
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Parser/reader/writer for the advanced-config registry (setup-config.ts).
|
||||
*
|
||||
* readFromEnv() → values found in process.env
|
||||
* parseFlags() → values from argv, plus --help and any pass-through args
|
||||
* applyToEnv() → write resolved values back to process.env so existing
|
||||
* step code keeps reading env vars unchanged
|
||||
* printHelp() → render --help from the registry
|
||||
*
|
||||
* Flag parsing supports:
|
||||
* --key value space form
|
||||
* --key=value equals form
|
||||
* --key booleans only (sets true)
|
||||
* --no-key booleans only (sets false)
|
||||
*/
|
||||
import {
|
||||
CONFIG,
|
||||
envVarFor,
|
||||
flagFor,
|
||||
findByFlag,
|
||||
type Entry,
|
||||
} from './setup-config.js';
|
||||
|
||||
export type ConfigValues = Record<string, string | boolean | number>;
|
||||
|
||||
function coerce(e: Entry, raw: string): string | number | boolean | undefined {
|
||||
switch (e.type) {
|
||||
case 'boolean': {
|
||||
const v = raw.toLowerCase();
|
||||
if (['true', '1', 'yes'].includes(v)) return true;
|
||||
if (['false', '0', 'no'].includes(v)) return false;
|
||||
return undefined;
|
||||
}
|
||||
case 'integer': {
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
default:
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
export function readFromEnv(env: NodeJS.ProcessEnv = process.env): ConfigValues {
|
||||
const out: ConfigValues = {};
|
||||
for (const e of CONFIG) {
|
||||
const raw = env[envVarFor(e)];
|
||||
if (raw === undefined || raw === '') continue;
|
||||
const v = coerce(e, raw);
|
||||
if (v !== undefined) out[e.key] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export type FlagParseResult = {
|
||||
values: ConfigValues;
|
||||
rest: string[];
|
||||
help: boolean;
|
||||
errors: string[];
|
||||
};
|
||||
|
||||
export function parseFlags(argv: string[]): FlagParseResult {
|
||||
const values: ConfigValues = {};
|
||||
const rest: string[] = [];
|
||||
const errors: string[] = [];
|
||||
let help = false;
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
|
||||
if (arg === '--help' || arg === '-h') {
|
||||
help = true;
|
||||
continue;
|
||||
}
|
||||
// POSIX end-of-options. pnpm passes a bare `--` through when invoked as
|
||||
// `pnpm run script --` with nothing after it; treat the rest as
|
||||
// pass-through positional args.
|
||||
if (arg === '--') {
|
||||
rest.push(...argv.slice(i + 1));
|
||||
break;
|
||||
}
|
||||
if (!arg.startsWith('--')) {
|
||||
rest.push(arg);
|
||||
continue;
|
||||
}
|
||||
|
||||
const eq = arg.indexOf('=');
|
||||
let name = eq === -1 ? arg : arg.slice(0, eq);
|
||||
const inline: string | undefined = eq === -1 ? undefined : arg.slice(eq + 1);
|
||||
|
||||
let negated = false;
|
||||
if (name.startsWith('--no-')) {
|
||||
negated = true;
|
||||
name = `--${name.slice(5)}`;
|
||||
}
|
||||
|
||||
const entry = findByFlag(name);
|
||||
if (!entry) {
|
||||
errors.push(`Unknown flag: ${arg}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.type === 'boolean') {
|
||||
if (negated) values[entry.key] = false;
|
||||
else if (inline !== undefined) {
|
||||
const v = coerce(entry, inline);
|
||||
if (v === undefined) errors.push(`Invalid boolean for ${name}: ${inline}`);
|
||||
else values[entry.key] = v;
|
||||
} else values[entry.key] = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const raw = inline !== undefined ? inline : argv[++i];
|
||||
if (raw === undefined) {
|
||||
errors.push(`Missing value for ${name}`);
|
||||
continue;
|
||||
}
|
||||
const v = coerce(entry, raw);
|
||||
if (v === undefined) {
|
||||
errors.push(`Invalid ${entry.type} for ${name}: ${raw}`);
|
||||
continue;
|
||||
}
|
||||
if (entry.type === 'string' || entry.type === 'url') {
|
||||
const err = entry.validate?.(raw);
|
||||
if (err) {
|
||||
errors.push(`${name}: ${err}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
values[entry.key] = v;
|
||||
}
|
||||
|
||||
return { values, rest, help, errors };
|
||||
}
|
||||
|
||||
export function applyToEnv(
|
||||
values: ConfigValues,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): void {
|
||||
for (const e of CONFIG) {
|
||||
if (!(e.key in values)) continue;
|
||||
const v = values[e.key];
|
||||
env[envVarFor(e)] =
|
||||
typeof v === 'boolean' ? (v ? 'true' : 'false') : String(v);
|
||||
}
|
||||
}
|
||||
|
||||
export function printHelp(stream: NodeJS.WritableStream = process.stdout): void {
|
||||
const lines: string[] = [];
|
||||
lines.push('Usage: bash nanoclaw.sh [flags...]');
|
||||
lines.push('');
|
||||
lines.push('Flags:');
|
||||
const width = Math.max(...CONFIG.map((e) => flagFor(e).length));
|
||||
for (const e of CONFIG) {
|
||||
const flag = flagFor(e).padEnd(width + 2);
|
||||
lines.push(` ${flag}${e.help}`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('Each flag also reads from its corresponding NANOCLAW_<KEY> env var.');
|
||||
lines.push('Run without flags for the default interactive flow.');
|
||||
stream.write(lines.join('\n') + '\n');
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Advanced-settings screen — menu of UI-visible entries from the config
|
||||
* registry. The user picks one entry, edits it, returns to the menu, and
|
||||
* exits via "Done". Returns a fresh values object; the caller passes it to
|
||||
* applyToEnv() so downstream step code reads them via env vars.
|
||||
*
|
||||
* Per-entry edit contract:
|
||||
* - Blank input on text/password/integer = leave current value unchanged.
|
||||
* - Enums get a synthetic "leave unchanged" first option.
|
||||
* - Booleans use confirm with the current value as initialValue.
|
||||
* - Secret entries mask the current value as bullets in hints/labels.
|
||||
*/
|
||||
import * as p from '@clack/prompts';
|
||||
|
||||
import { brightSelect } from './bright-select.js';
|
||||
import { ensureAnswer } from './runner.js';
|
||||
import { CONFIG, type Entry } from './setup-config.js';
|
||||
import type { ConfigValues } from './setup-config-parse.js';
|
||||
|
||||
const SKIP_SENTINEL = '__leave_unchanged__';
|
||||
const DONE_SENTINEL = '__done__';
|
||||
const MASK = '••••••••';
|
||||
|
||||
export async function runAdvancedScreen(
|
||||
initial: ConfigValues,
|
||||
): Promise<ConfigValues> {
|
||||
const result: ConfigValues = { ...initial };
|
||||
const visible = CONFIG.filter((e) => e.surface === 'flag+ui');
|
||||
|
||||
while (true) {
|
||||
const options = [
|
||||
...visible.map((e) => ({
|
||||
value: e.key,
|
||||
label: e.label,
|
||||
hint: hintFor(e, result),
|
||||
})),
|
||||
{ value: DONE_SENTINEL, label: 'Done — continue with setup' },
|
||||
];
|
||||
|
||||
const choice = ensureAnswer(
|
||||
await brightSelect<string>({
|
||||
message: 'Pick a setting to override',
|
||||
options,
|
||||
initialValue: DONE_SENTINEL,
|
||||
}),
|
||||
) as string;
|
||||
|
||||
if (choice === DONE_SENTINEL) return result;
|
||||
const entry = visible.find((e) => e.key === choice);
|
||||
if (entry) await promptOne(entry, result);
|
||||
}
|
||||
}
|
||||
|
||||
function hintFor(e: Entry, values: ConfigValues): string {
|
||||
const v = values[e.key];
|
||||
if (v === undefined) return 'not set';
|
||||
if (e.secret) return MASK;
|
||||
return String(v);
|
||||
}
|
||||
|
||||
async function promptOne(e: Entry, values: ConfigValues): Promise<void> {
|
||||
if (e.type === 'boolean') {
|
||||
const init =
|
||||
typeof values[e.key] === 'boolean'
|
||||
? (values[e.key] as boolean)
|
||||
: (e.default ?? false);
|
||||
const ans = ensureAnswer(
|
||||
await p.confirm({ message: e.label, initialValue: init }),
|
||||
);
|
||||
values[e.key] = ans as boolean;
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.type === 'enum') {
|
||||
const ans = ensureAnswer(
|
||||
await brightSelect<string>({
|
||||
message: e.label,
|
||||
options: [
|
||||
{ value: SKIP_SENTINEL, label: 'Leave unchanged' },
|
||||
...e.options,
|
||||
],
|
||||
initialValue: SKIP_SENTINEL,
|
||||
}),
|
||||
);
|
||||
if (ans !== SKIP_SENTINEL) values[e.key] = ans as string;
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.type === 'integer') {
|
||||
const ans = ensureAnswer(
|
||||
await p.text({
|
||||
message: e.label,
|
||||
placeholder: e.default !== undefined ? String(e.default) : undefined,
|
||||
validate: (v) => {
|
||||
const s = (v ?? '').trim();
|
||||
if (!s) return undefined;
|
||||
const n = Number(s);
|
||||
if (!Number.isFinite(n)) return 'Must be a number';
|
||||
if (e.min !== undefined && n < e.min) return `Must be ≥ ${e.min}`;
|
||||
if (e.max !== undefined && n > e.max) return `Must be ≤ ${e.max}`;
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const trimmed = ((ans as string) ?? '').trim();
|
||||
if (trimmed) values[e.key] = Number(trimmed);
|
||||
return;
|
||||
}
|
||||
|
||||
// string | url
|
||||
const validate = (v: string | undefined): string | undefined => {
|
||||
const s = (v ?? '').trim();
|
||||
if (!s) return undefined;
|
||||
return e.validate?.(s);
|
||||
};
|
||||
const ans = ensureAnswer(
|
||||
e.secret
|
||||
? await p.password({ message: e.label, validate })
|
||||
: await p.text({
|
||||
message: e.label,
|
||||
placeholder: e.placeholder ?? e.default,
|
||||
validate,
|
||||
}),
|
||||
);
|
||||
const trimmed = ((ans as string) ?? '').trim();
|
||||
if (trimmed) values[e.key] = trimmed;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Setup-time advanced-config registry.
|
||||
*
|
||||
* One source of truth for: CLI flags, env-var names, the advanced-settings
|
||||
* screen, and `--help` output. The flag parser, env reader, and UI screen
|
||||
* all consume this list and write resolved values back to `process.env` so
|
||||
* existing step code keeps reading env vars unchanged.
|
||||
*
|
||||
* Default name conventions (overridable per entry):
|
||||
* key 'fooBar' → envVar 'NANOCLAW_FOO_BAR' → flag '--foo-bar'
|
||||
*
|
||||
* Surface levels:
|
||||
* 'flag' — CLI flag + env var only (debug/internal knobs)
|
||||
* 'flag+ui' — also shown in the advanced-settings screen
|
||||
*/
|
||||
|
||||
export type EntrySurface = 'flag' | 'flag+ui';
|
||||
|
||||
interface BaseEntry {
|
||||
/** Canonical camelCase key. */
|
||||
key: string;
|
||||
/** Override of the auto-derived NANOCLAW_<UPPER_SNAKE> env var. */
|
||||
envVar?: string;
|
||||
/** Override of the auto-derived --kebab-case flag. */
|
||||
flag?: string;
|
||||
label: string;
|
||||
help: string;
|
||||
surface: EntrySurface;
|
||||
/** UI section header. Entries without a group land in 'Other'. */
|
||||
group?: string;
|
||||
/** Mask in UI, redact in logs. */
|
||||
secret?: boolean;
|
||||
}
|
||||
|
||||
interface StringEntry extends BaseEntry {
|
||||
type: 'string' | 'url';
|
||||
default?: string;
|
||||
placeholder?: string;
|
||||
validate?: (v: string) => string | undefined;
|
||||
}
|
||||
|
||||
interface EnumEntry extends BaseEntry {
|
||||
type: 'enum';
|
||||
options: { value: string; label: string; hint?: string }[];
|
||||
default?: string;
|
||||
}
|
||||
|
||||
interface BoolEntry extends BaseEntry {
|
||||
type: 'boolean';
|
||||
default?: boolean;
|
||||
}
|
||||
|
||||
interface IntEntry extends BaseEntry {
|
||||
type: 'integer';
|
||||
default?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
export type Entry = StringEntry | EnumEntry | BoolEntry | IntEntry;
|
||||
|
||||
const httpUrl = (v: string): string | undefined =>
|
||||
/^https?:\/\/\S+/.test(v) ? undefined : 'Must be http(s)://…';
|
||||
|
||||
export const CONFIG: Entry[] = [
|
||||
{
|
||||
key: 'onecliApiHost',
|
||||
label: 'OneCLI vault URL',
|
||||
help: 'Use a remote OneCLI vault instead of installing one locally.',
|
||||
surface: 'flag+ui',
|
||||
group: 'OneCLI',
|
||||
type: 'url',
|
||||
default: 'https://app.onecli.sh',
|
||||
placeholder: 'https://app.onecli.sh',
|
||||
validate: httpUrl,
|
||||
},
|
||||
{
|
||||
key: 'onecliApiToken',
|
||||
label: 'OneCLI access token',
|
||||
help: 'Bearer token for the remote vault. Required if --onecli-api-host is set.',
|
||||
surface: 'flag+ui',
|
||||
group: 'OneCLI',
|
||||
type: 'string',
|
||||
secret: true,
|
||||
placeholder: 'oc_…',
|
||||
validate: (v) => (v.startsWith('oc_') ? undefined : 'Must start with oc_'),
|
||||
},
|
||||
{
|
||||
key: 'anthropicBaseUrl',
|
||||
label: 'Anthropic API base URL',
|
||||
help: 'Use a proxy or alternative endpoint instead of api.anthropic.com.',
|
||||
surface: 'flag+ui',
|
||||
group: 'Anthropic',
|
||||
type: 'url',
|
||||
placeholder: 'https://api.anthropic.com',
|
||||
validate: httpUrl,
|
||||
},
|
||||
{
|
||||
key: 'anthropicAuthToken',
|
||||
label: 'Anthropic auth token',
|
||||
help: 'Bearer token for the custom Anthropic endpoint. Used together with --anthropic-base-url.',
|
||||
surface: 'flag+ui',
|
||||
group: 'Anthropic',
|
||||
type: 'string',
|
||||
secret: true,
|
||||
validate: (v) => (v.trim() ? undefined : 'Required'),
|
||||
},
|
||||
|
||||
// Existing env-var knobs — flag-only so they don't clutter the UI screen.
|
||||
{
|
||||
key: 'skip',
|
||||
envVar: 'NANOCLAW_SKIP',
|
||||
label: 'Skip steps',
|
||||
help: 'Comma-separated step names to skip (debugging only).',
|
||||
surface: 'flag',
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
key: 'displayName',
|
||||
envVar: 'NANOCLAW_DISPLAY_NAME',
|
||||
label: 'Display name',
|
||||
help: 'Skip the "what should your assistant call you?" prompt.',
|
||||
surface: 'flag',
|
||||
type: 'string',
|
||||
},
|
||||
];
|
||||
|
||||
// ─── name derivation ───────────────────────────────────────────────────
|
||||
|
||||
export function envVarFor(e: Entry): string {
|
||||
if (e.envVar) return e.envVar;
|
||||
return `NANOCLAW_${e.key.replace(/[A-Z]/g, (c) => `_${c}`).toUpperCase()}`;
|
||||
}
|
||||
|
||||
export function flagFor(e: Entry): string {
|
||||
if (e.flag) return e.flag;
|
||||
return `--${e.key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`;
|
||||
}
|
||||
|
||||
export function findByFlag(flag: string): Entry | null {
|
||||
return CONFIG.find((e) => flagFor(e) === flag) ?? null;
|
||||
}
|
||||
+6
-9
@@ -58,17 +58,14 @@ export function wrapForGutter(text: string, gutter: number): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap + dim together. Needed instead of `k.dim(wrapForGutter(...))`
|
||||
* because clack resets styling at each line break when rendering
|
||||
* multi-line log content — a single outer dim envelope only colors the
|
||||
* first line. Applying dim per-line gives each wrapped row its own
|
||||
* `\x1b[2m…\x1b[0m` envelope so the whole block reads as one block.
|
||||
* Wrap multi-line explanatory prose to the clack gutter. Previously
|
||||
* dimmed its output (hence the name) — that made body copy hard to read
|
||||
* against dark terminals. Dim is now reserved for preview/debug blocks
|
||||
* (failure transcript tails, claude-assist streams); prose renders at
|
||||
* the terminal's regular weight.
|
||||
*/
|
||||
export function dimWrap(text: string, gutter: number): string {
|
||||
return wrapForGutter(text, gutter)
|
||||
.split('\n')
|
||||
.map((line) => k.dim(line))
|
||||
.join('\n');
|
||||
return wrapForGutter(text, gutter);
|
||||
}
|
||||
|
||||
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
||||
|
||||
+192
-15
@@ -86,40 +86,161 @@ function ensureShellProfilePath(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function writeEnvOnecliUrl(url: string): void {
|
||||
function writeEnvVar(name: string, value: string): void {
|
||||
const envFile = path.join(process.cwd(), '.env');
|
||||
let content = fs.existsSync(envFile) ? fs.readFileSync(envFile, 'utf-8') : '';
|
||||
if (/^ONECLI_URL=/m.test(content)) {
|
||||
content = content.replace(/^ONECLI_URL=.*$/m, `ONECLI_URL=${url}`);
|
||||
const re = new RegExp(`^${name}=.*$`, 'm');
|
||||
if (re.test(content)) {
|
||||
content = content.replace(re, `${name}=${value}`);
|
||||
} else {
|
||||
content = content.trimEnd() + (content ? '\n' : '') + `ONECLI_URL=${url}\n`;
|
||||
content = content.trimEnd() + (content ? '\n' : '') + `${name}=${value}\n`;
|
||||
}
|
||||
fs.writeFileSync(envFile, content);
|
||||
}
|
||||
|
||||
function writeEnvOnecliUrl(url: string): void {
|
||||
writeEnvVar('ONECLI_URL', url);
|
||||
}
|
||||
|
||||
// Last-known-good CLI release. Used only if BOTH the upstream installer
|
||||
// and the redirect-based version probe fail. Bump deliberately when a
|
||||
// new CLI release ships.
|
||||
const ONECLI_CLI_FALLBACK_VERSION = '1.3.0';
|
||||
const ONECLI_CLI_REPO = 'onecli/onecli-cli';
|
||||
|
||||
function installOnecliCliOnly(): { stdout: string; ok: boolean } {
|
||||
const upstream = runInstall('curl -fsSL onecli.sh/cli/install | sh');
|
||||
if (upstream.ok) return { stdout: upstream.stdout, ok: true };
|
||||
const fallback = installOnecliCliDirect();
|
||||
return { stdout: upstream.stdout + (upstream.stderr ?? '') + '\n' + fallback.stdout, ok: fallback.ok };
|
||||
}
|
||||
|
||||
function installOnecli(): { stdout: string; ok: boolean } {
|
||||
// OneCLI's own install script handles gateway + CLI + PATH.
|
||||
// We run the two canonical installers in sequence and capture stdout so
|
||||
// we can extract the printed URL as a fallback to `onecli config get`.
|
||||
let stdout = '';
|
||||
|
||||
// Gateway install (docker-compose based, no rate-limit concerns).
|
||||
const gw = runInstall('curl -fsSL onecli.sh/install | sh');
|
||||
stdout += gw.stdout;
|
||||
if (!gw.ok) {
|
||||
log.error('OneCLI gateway install failed', { stderr: gw.stderr });
|
||||
return { stdout: stdout + (gw.stderr ?? ''), ok: false };
|
||||
}
|
||||
|
||||
// CLI install. The upstream script calls the GitHub releases API
|
||||
// (api.github.com) to resolve the latest tag — which 403s anonymous
|
||||
// callers after 60 requests/hour per IP. Try upstream first; on failure
|
||||
// resolve the version ourselves (via HTTP redirect, which isn't
|
||||
// API-throttled) and download the release archive directly.
|
||||
const upstream = runInstall('curl -fsSL onecli.sh/cli/install | sh');
|
||||
stdout += upstream.stdout;
|
||||
if (upstream.ok) return { stdout, ok: true };
|
||||
|
||||
log.warn('Upstream CLI installer failed — falling back to direct download', {
|
||||
stderr: upstream.stderr,
|
||||
});
|
||||
stdout += (upstream.stderr ?? '') + '\n';
|
||||
|
||||
const fallback = installOnecliCliDirect();
|
||||
stdout += fallback.stdout;
|
||||
if (!fallback.ok) {
|
||||
log.error('OneCLI CLI install failed (both upstream and direct fallback)');
|
||||
return { stdout, ok: false };
|
||||
}
|
||||
return { stdout, ok: true };
|
||||
}
|
||||
|
||||
function runInstall(cmd: string): { stdout: string; stderr?: string; ok: boolean } {
|
||||
try {
|
||||
stdout += execSync('curl -fsSL onecli.sh/install | sh', {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
stdout += execSync('curl -fsSL onecli.sh/cli/install | sh', {
|
||||
const stdout = execSync(cmd, {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
return { stdout, ok: true };
|
||||
} catch (err) {
|
||||
const e = err as { stdout?: string; stderr?: string };
|
||||
log.error('OneCLI install failed', { stderr: e.stderr });
|
||||
return { stdout: stdout + (e.stdout ?? '') + (e.stderr ?? ''), ok: false };
|
||||
return { stdout: e.stdout ?? '', stderr: e.stderr, ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function pollHealth(url: string, timeoutMs: number): Promise<boolean> {
|
||||
/**
|
||||
* Reinstate the OneCLI CLI install without hitting GitHub's rate-limited
|
||||
* releases API. Resolves the version via the HTTP redirect from
|
||||
* /releases/latest → /releases/tag/vX.Y.Z, then downloads the archive
|
||||
* directly. Falls back to ONECLI_CLI_FALLBACK_VERSION if the redirect
|
||||
* probe also fails.
|
||||
*/
|
||||
function installOnecliCliDirect(): { stdout: string; ok: boolean } {
|
||||
const lines: string[] = [];
|
||||
const append = (s: string): void => {
|
||||
lines.push(s);
|
||||
};
|
||||
|
||||
const osName = process.platform === 'darwin' ? 'darwin' : process.platform === 'linux' ? 'linux' : null;
|
||||
if (!osName) {
|
||||
append(`Unsupported platform: ${process.platform}`);
|
||||
return { stdout: lines.join('\n'), ok: false };
|
||||
}
|
||||
const arch = process.arch === 'x64' ? 'amd64' : process.arch === 'arm64' ? 'arm64' : null;
|
||||
if (!arch) {
|
||||
append(`Unsupported arch: ${process.arch}`);
|
||||
return { stdout: lines.join('\n'), ok: false };
|
||||
}
|
||||
|
||||
let version: string | null = null;
|
||||
try {
|
||||
const redirect = execSync(
|
||||
`curl -fsSL -o /dev/null -w '%{url_effective}' https://github.com/${ONECLI_CLI_REPO}/releases/latest`,
|
||||
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] },
|
||||
).trim();
|
||||
const m = redirect.match(/\/tag\/v?([^/]+)$/);
|
||||
if (m) version = m[1];
|
||||
} catch {
|
||||
// redirect probe failed — we'll pin the fallback
|
||||
}
|
||||
if (!version) {
|
||||
version = ONECLI_CLI_FALLBACK_VERSION;
|
||||
append(`Version probe failed; installing pinned fallback ${version}.`);
|
||||
} else {
|
||||
append(`Resolved onecli CLI ${version} via release redirect.`);
|
||||
}
|
||||
|
||||
const archive = `onecli_${version}_${osName}_${arch}.tar.gz`;
|
||||
const url = `https://github.com/${ONECLI_CLI_REPO}/releases/download/v${version}/${archive}`;
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'onecli-'));
|
||||
const archivePath = path.join(tmpDir, archive);
|
||||
|
||||
try {
|
||||
append(`Downloading ${url}`);
|
||||
execSync(`curl -fsSL -o ${JSON.stringify(archivePath)} ${JSON.stringify(url)}`, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
execSync(`tar -xzf ${JSON.stringify(archivePath)} -C ${JSON.stringify(tmpDir)}`, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let installDir = '/usr/local/bin';
|
||||
try {
|
||||
fs.accessSync(installDir, fs.constants.W_OK);
|
||||
} catch {
|
||||
installDir = LOCAL_BIN;
|
||||
fs.mkdirSync(installDir, { recursive: true });
|
||||
}
|
||||
const binSrc = path.join(tmpDir, 'onecli');
|
||||
const binDest = path.join(installDir, 'onecli');
|
||||
fs.copyFileSync(binSrc, binDest);
|
||||
fs.chmodSync(binDest, 0o755);
|
||||
append(`onecli ${version} installed to ${binDest}.`);
|
||||
return { stdout: lines.join('\n'), ok: true };
|
||||
} catch (err) {
|
||||
const e = err as { stdout?: string; stderr?: string; message?: string };
|
||||
append(`Direct install failed: ${e.stderr ?? e.message ?? String(err)}`);
|
||||
return { stdout: lines.join('\n'), ok: false };
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function pollHealth(url: string, timeoutMs: number): Promise<boolean> {
|
||||
// `/api/health` matches the path probe.sh uses — keep them aligned.
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
@@ -136,8 +257,64 @@ async function pollHealth(url: string, timeoutMs: number): Promise<boolean> {
|
||||
|
||||
export async function run(args: string[]): Promise<void> {
|
||||
const reuse = args.includes('--reuse');
|
||||
const remoteUrlIdx = args.indexOf('--remote-url');
|
||||
const remoteUrl = remoteUrlIdx !== -1 ? args[remoteUrlIdx + 1] : null;
|
||||
ensureShellProfilePath();
|
||||
|
||||
if (remoteUrl) {
|
||||
// Remote-mode: install only the CLI, point it at the remote gateway, and
|
||||
// record the URL in .env. No local gateway is started.
|
||||
log.info('Installing OneCLI CLI for remote gateway', { remoteUrl });
|
||||
const res = installOnecliCliOnly();
|
||||
if (!res.ok || !onecliVersion()) {
|
||||
emitStatus('ONECLI', {
|
||||
INSTALLED: false,
|
||||
STATUS: 'failed',
|
||||
ERROR: 'cli_install_failed',
|
||||
HINT: 'CLI binary install failed. Make sure curl is installed and ~/.local/bin is writable.',
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
execFileSync('onecli', ['config', 'set', 'api-host', remoteUrl], {
|
||||
stdio: 'ignore',
|
||||
env: childEnv(),
|
||||
});
|
||||
} catch (err) {
|
||||
log.warn('onecli config set api-host failed', { err });
|
||||
}
|
||||
writeEnvOnecliUrl(remoteUrl);
|
||||
log.info('Wrote ONECLI_URL to .env', { url: remoteUrl });
|
||||
const remoteToken = process.env.NANOCLAW_ONECLI_API_TOKEN?.trim();
|
||||
if (remoteToken) {
|
||||
// Two auth surfaces: `onecli auth login` persists the key for CLI
|
||||
// calls during setup itself (e.g. detecting an existing Anthropic
|
||||
// secret via `onecli secrets list`), and ONECLI_API_KEY in .env is
|
||||
// read by the runtime SDK at request time. Both are needed.
|
||||
try {
|
||||
execFileSync('onecli', ['auth', 'login', '--api-key', remoteToken], {
|
||||
stdio: 'ignore',
|
||||
env: childEnv(),
|
||||
});
|
||||
} catch (err) {
|
||||
log.warn('onecli auth login failed', { err });
|
||||
}
|
||||
writeEnvVar('ONECLI_API_KEY', remoteToken);
|
||||
log.info('Wrote ONECLI_API_KEY to .env');
|
||||
}
|
||||
const healthy = await pollHealth(remoteUrl, 5000);
|
||||
emitStatus('ONECLI', {
|
||||
INSTALLED: true,
|
||||
REMOTE: true,
|
||||
ONECLI_URL: remoteUrl,
|
||||
HEALTHY: healthy,
|
||||
STATUS: 'success',
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (reuse) {
|
||||
// Reuse-mode: don't touch the running gateway at all. Just verify it
|
||||
// exists, read its api-host, write ONECLI_URL to .env, and move on.
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Detect and clean up unhealthy NanoClaw peer services.
|
||||
*
|
||||
* Runs as a setup preflight before we install our own service. A crash-looping
|
||||
* peer install (typically the legacy v1 `com.nanoclaw` plist) silently trashes
|
||||
* this install's containers on every respawn because its `cleanupOrphans()`
|
||||
* reaps anything matching `nanoclaw-`. We scope our reaper by label now, but
|
||||
* we still need to stop the peer from killing us on its way down.
|
||||
*
|
||||
* A peer is "unhealthy" when:
|
||||
* - launchd: `state != running` AND `runs > UNHEALTHY_RUNS_THRESHOLD`
|
||||
* - systemd: unit is in `failed` state, OR `activating` with many restarts
|
||||
*
|
||||
* Healthy peers are left alone — multiple installs can coexist fine now that
|
||||
* container-reaper is label-scoped.
|
||||
*/
|
||||
import { execFileSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
|
||||
import { log } from '../src/log.js';
|
||||
|
||||
const UNHEALTHY_RUNS_THRESHOLD = 10;
|
||||
|
||||
export interface PeerStatus {
|
||||
label: string;
|
||||
configPath: string;
|
||||
state: string;
|
||||
runs: number;
|
||||
unhealthy: boolean;
|
||||
}
|
||||
|
||||
export interface PeerCleanupResult {
|
||||
checked: PeerStatus[];
|
||||
unloaded: PeerStatus[];
|
||||
failures: Array<{ label: string; err: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan for peer NanoClaw services and unload any that are crash-looping.
|
||||
* Returns a summary suitable for emitStatus / setup-log reporting.
|
||||
*/
|
||||
export function cleanupUnhealthyPeers(projectRoot: string = process.cwd()): PeerCleanupResult {
|
||||
const platform = os.platform();
|
||||
if (platform === 'darwin') {
|
||||
return cleanupLaunchdPeers(projectRoot);
|
||||
}
|
||||
if (platform === 'linux') {
|
||||
return cleanupSystemdPeers(projectRoot);
|
||||
}
|
||||
return { checked: [], unloaded: [], failures: [] };
|
||||
}
|
||||
|
||||
// ---- launchd (macOS) --------------------------------------------------------
|
||||
|
||||
function cleanupLaunchdPeers(projectRoot: string): PeerCleanupResult {
|
||||
const ownLabel = getLaunchdLabel(projectRoot);
|
||||
const agentsDir = path.join(os.homedir(), 'Library', 'LaunchAgents');
|
||||
const result: PeerCleanupResult = { checked: [], unloaded: [], failures: [] };
|
||||
|
||||
let plists: string[];
|
||||
try {
|
||||
plists = fs
|
||||
.readdirSync(agentsDir)
|
||||
.filter((f) => /^com\.nanoclaw.*\.plist$/.test(f))
|
||||
.map((f) => path.join(agentsDir, f));
|
||||
} catch {
|
||||
return result;
|
||||
}
|
||||
|
||||
const uid = process.getuid?.() ?? 0;
|
||||
|
||||
for (const plistPath of plists) {
|
||||
const label = path.basename(plistPath, '.plist');
|
||||
if (label === ownLabel) continue;
|
||||
|
||||
const status = probeLaunchdPeer(label, plistPath, uid);
|
||||
if (!status) continue;
|
||||
result.checked.push(status);
|
||||
|
||||
if (!status.unhealthy) continue;
|
||||
|
||||
try {
|
||||
execFileSync('launchctl', ['unload', plistPath], { stdio: 'pipe' });
|
||||
log.info('Unloaded unhealthy peer launchd service', {
|
||||
label,
|
||||
state: status.state,
|
||||
runs: status.runs,
|
||||
plistPath,
|
||||
});
|
||||
result.unloaded.push(status);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
log.warn('Failed to unload peer launchd service', { label, err: message });
|
||||
result.failures.push({ label, err: message });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function probeLaunchdPeer(label: string, plistPath: string, uid: number): PeerStatus | null {
|
||||
let output: string;
|
||||
try {
|
||||
output = execFileSync('launchctl', ['print', `gui/${uid}/${label}`], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
} catch {
|
||||
// Not loaded → not currently a threat. Skip silently.
|
||||
return null;
|
||||
}
|
||||
|
||||
const state = /^\s*state\s*=\s*(.+?)\s*$/m.exec(output)?.[1] ?? 'unknown';
|
||||
const runsStr = /^\s*runs\s*=\s*(\d+)/m.exec(output)?.[1];
|
||||
const runs = runsStr ? parseInt(runsStr, 10) : 0;
|
||||
|
||||
const unhealthy = state !== 'running' && runs > UNHEALTHY_RUNS_THRESHOLD;
|
||||
return { label, configPath: plistPath, state, runs, unhealthy };
|
||||
}
|
||||
|
||||
// ---- systemd (Linux) --------------------------------------------------------
|
||||
|
||||
function cleanupSystemdPeers(projectRoot: string): PeerCleanupResult {
|
||||
const ownUnit = getSystemdUnit(projectRoot);
|
||||
const unitDir = path.join(os.homedir(), '.config', 'systemd', 'user');
|
||||
const result: PeerCleanupResult = { checked: [], unloaded: [], failures: [] };
|
||||
|
||||
let units: string[];
|
||||
try {
|
||||
units = fs
|
||||
.readdirSync(unitDir)
|
||||
.filter((f) => /^nanoclaw.*\.service$/.test(f))
|
||||
.map((f) => f.replace(/\.service$/, ''));
|
||||
} catch {
|
||||
return result;
|
||||
}
|
||||
|
||||
for (const unit of units) {
|
||||
if (unit === ownUnit) continue;
|
||||
|
||||
const status = probeSystemdPeer(unit);
|
||||
if (!status) continue;
|
||||
result.checked.push(status);
|
||||
|
||||
if (!status.unhealthy) continue;
|
||||
|
||||
try {
|
||||
execFileSync('systemctl', ['--user', 'disable', '--now', `${unit}.service`], { stdio: 'pipe' });
|
||||
log.info('Disabled unhealthy peer systemd unit', {
|
||||
unit,
|
||||
state: status.state,
|
||||
runs: status.runs,
|
||||
});
|
||||
result.unloaded.push(status);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
log.warn('Failed to disable peer systemd unit', { unit, err: message });
|
||||
result.failures.push({ label: unit, err: message });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function probeSystemdPeer(unit: string): PeerStatus | null {
|
||||
const unitPath = path.join(os.homedir(), '.config', 'systemd', 'user', `${unit}.service`);
|
||||
try {
|
||||
const output = execFileSync(
|
||||
'systemctl',
|
||||
['--user', 'show', '--property=ActiveState,NRestarts', `${unit}.service`],
|
||||
{ stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8' },
|
||||
);
|
||||
const activeState = /^ActiveState=(.+)$/m.exec(output)?.[1]?.trim() ?? 'unknown';
|
||||
const restartsStr = /^NRestarts=(\d+)/m.exec(output)?.[1];
|
||||
const runs = restartsStr ? parseInt(restartsStr, 10) : 0;
|
||||
|
||||
const unhealthy =
|
||||
activeState === 'failed' || (activeState !== 'active' && runs > UNHEALTHY_RUNS_THRESHOLD);
|
||||
return { label: unit, configPath: unitPath, state: activeState, runs, unhealthy };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
# Setup step: probe — single upfront parallel-ish scan that snapshots every
|
||||
# prerequisite and dependency for /new-setup's dynamic context injection.
|
||||
# prerequisite and dependency for /setup's dynamic context injection.
|
||||
# Rendered into the SKILL.md prompt via `!bash setup/probe.sh` so Claude sees
|
||||
# the current system state before generating its first response.
|
||||
#
|
||||
|
||||
+17
-15
@@ -20,6 +20,7 @@ import {
|
||||
import { isValidGroupFolder } from '../src/group-folder.js';
|
||||
import { initGroupFilesystem } from '../src/group-init.js';
|
||||
import { log } from '../src/log.js';
|
||||
import { namespacedPlatformId } from '../src/platform-id.js';
|
||||
import { resolveSession, writeSessionMessage } from '../src/session-manager.js';
|
||||
import { emitStatus } from './status.js';
|
||||
|
||||
@@ -112,12 +113,10 @@ export async function run(args: string[]): Promise<void> {
|
||||
process.exit(4);
|
||||
}
|
||||
|
||||
// Chat SDK adapters prefix platform IDs with the channel type
|
||||
// (e.g. "telegram:123", "discord:guild:channel"). Normalize here so
|
||||
// the stored ID always matches what the adapter sends at runtime.
|
||||
if (!parsed.platformId.startsWith(`${parsed.channel}:`)) {
|
||||
parsed.platformId = `${parsed.channel}:${parsed.platformId}`;
|
||||
}
|
||||
// Normalize platform_id to the same shape the adapter will emit at runtime,
|
||||
// so the router's (channel_type, platform_id) lookup matches what we store.
|
||||
// Chat SDK adapters prefix, native adapters (WhatsApp/iMessage/Signal) don't.
|
||||
parsed.platformId = namespacedPlatformId(parsed.channel, parsed.platformId);
|
||||
|
||||
log.info('Registering channel', parsed);
|
||||
|
||||
@@ -167,19 +166,22 @@ export async function run(args: string[]): Promise<void> {
|
||||
if (!existing) {
|
||||
newlyWired = true;
|
||||
const mgaId = generateId('mga');
|
||||
const triggerRules = parsed.trigger
|
||||
? JSON.stringify({
|
||||
pattern: parsed.trigger,
|
||||
requiresTrigger: parsed.requiresTrigger,
|
||||
})
|
||||
: null;
|
||||
// Mirrors scripts/init-first-agent.ts:wireIfMissing so both setup paths
|
||||
// create rows with the same shape. Groups default to 'mention' (bot only
|
||||
// responds when addressed); DMs default to 'pattern'/'.' (respond to
|
||||
// every message). An explicit --trigger overrides the pattern regex.
|
||||
const isGroup = messagingGroup.is_group === 1;
|
||||
const engageMode: 'pattern' | 'mention' = isGroup && !parsed.trigger ? 'mention' : 'pattern';
|
||||
const engagePattern: string | null = engageMode === 'pattern' ? parsed.trigger || '.' : null;
|
||||
createMessagingGroupAgent({
|
||||
id: mgaId,
|
||||
messaging_group_id: messagingGroup.id,
|
||||
agent_group_id: agentGroup.id,
|
||||
trigger_rules: triggerRules,
|
||||
response_scope: 'all',
|
||||
session_mode: parsed.sessionMode,
|
||||
engage_mode: engageMode,
|
||||
engage_pattern: engagePattern,
|
||||
sender_scope: 'all',
|
||||
ignored_message_policy: 'drop',
|
||||
session_mode: parsed.sessionMode as 'shared' | 'per-thread' | 'agent-shared',
|
||||
priority: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import path from 'path';
|
||||
|
||||
import { log } from '../src/log.js';
|
||||
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
|
||||
import { cleanupUnhealthyPeers } from './peer-cleanup.js';
|
||||
import {
|
||||
commandExists,
|
||||
getPlatform,
|
||||
@@ -53,6 +54,19 @@ export async function run(_args: string[]): Promise<void> {
|
||||
|
||||
fs.mkdirSync(path.join(projectRoot, 'logs'), { recursive: true });
|
||||
|
||||
// Peer preflight — a crash-looping peer install (most often the legacy v1
|
||||
// `com.nanoclaw` plist) will keep trashing this install's containers on
|
||||
// every respawn via its own cleanupOrphans. Detect and unload any peer
|
||||
// that's unhealthy before we install our service. Healthy peers are left
|
||||
// alone now that container reaping is install-label-scoped.
|
||||
const peerReport = cleanupUnhealthyPeers(projectRoot);
|
||||
if (peerReport.unloaded.length > 0) {
|
||||
log.warn('Unloaded unhealthy peer NanoClaw services', {
|
||||
count: peerReport.unloaded.length,
|
||||
labels: peerReport.unloaded.map((p) => p.label),
|
||||
});
|
||||
}
|
||||
|
||||
if (platform === 'macos') {
|
||||
setupLaunchd(projectRoot, nodePath, homeDir);
|
||||
} else if (platform === 'linux') {
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Step: signal-auth — link this host to an existing Signal account via
|
||||
* signal-cli's QR-code flow.
|
||||
*
|
||||
* signal-cli `link` opens a bi-directional handshake with the Signal
|
||||
* servers: it prints one line containing a linking URL (`sgnl://linkdevice?…`
|
||||
* or older `tsdevice://linkdevice?…`), then blocks until either the user
|
||||
* scans it from an existing Signal install, or the code expires. On
|
||||
* success, a secondary account is created under the user's signal-cli
|
||||
* data directory, associated with the phone number of the scanner.
|
||||
*
|
||||
* Methods:
|
||||
* (no args) Spawn signal-cli link, emit SIGNAL_AUTH_QR
|
||||
* with the URL, wait for completion.
|
||||
*
|
||||
* Block schema (parent parses these):
|
||||
* SIGNAL_AUTH_QR { QR: "<sgnl:// or tsdevice:// url>" } — one-shot
|
||||
* SIGNAL_AUTH { STATUS: success, ACCOUNT: +<digits> } — terminal
|
||||
* { STATUS: skipped, ACCOUNT, REASON: already-authenticated }
|
||||
* { STATUS: failed, ERROR: <reason> }
|
||||
*
|
||||
* STATUS values match the runner's vocabulary (success/skipped/failed) so
|
||||
* spawnStep recognises them and sets `ok` correctly; Signal-specific UI
|
||||
* lives in setup/channels/signal.ts.
|
||||
*
|
||||
* If one or more accounts are already linked (discovered via
|
||||
* `signal-cli -o json listAccounts`), the step emits SIGNAL_AUTH
|
||||
* STATUS=skipped with the first account so the driver can reuse it.
|
||||
* Selecting a different existing account is a driver concern.
|
||||
*/
|
||||
import { spawn, spawnSync } from 'child_process';
|
||||
|
||||
import { emitStatus } from './status.js';
|
||||
|
||||
const LINK_TIMEOUT_MS = 180_000;
|
||||
const DEFAULT_DEVICE_NAME = 'NanoClaw';
|
||||
|
||||
interface SignalAccount {
|
||||
account?: string;
|
||||
registered?: boolean;
|
||||
}
|
||||
|
||||
function cliPath(): string {
|
||||
return process.env.SIGNAL_CLI_PATH || 'signal-cli';
|
||||
}
|
||||
|
||||
/**
|
||||
* Query signal-cli for currently linked accounts. Empty array if none
|
||||
* configured, no binary, or the call fails for any other reason.
|
||||
*/
|
||||
function listAccounts(): string[] {
|
||||
const cli = cliPath();
|
||||
try {
|
||||
const res = spawnSync(cli, ['-o', 'json', 'listAccounts'], {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
if (res.status !== 0) return [];
|
||||
const parsed = JSON.parse(res.stdout || '[]') as SignalAccount[];
|
||||
return parsed
|
||||
.filter((a) => a.registered !== false)
|
||||
.map((a) => a.account ?? '')
|
||||
.filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function run(_args: string[]): Promise<void> {
|
||||
const cli = cliPath();
|
||||
|
||||
// Verify signal-cli exists before we commit to the long-running link.
|
||||
// The driver checks too, but this keeps the step honest when run alone.
|
||||
const probe = spawnSync(cli, ['--version'], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
if (probe.error || probe.status !== 0) {
|
||||
emitStatus('SIGNAL_AUTH', {
|
||||
STATUS: 'failed',
|
||||
ERROR: 'signal-cli not found. Install signal-cli first.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = listAccounts();
|
||||
if (existing.length > 0) {
|
||||
emitStatus('SIGNAL_AUTH', {
|
||||
STATUS: 'skipped',
|
||||
ACCOUNT: existing[0],
|
||||
REASON: 'already-authenticated',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
let settled = false;
|
||||
let qrEmitted = false;
|
||||
|
||||
const finish = (block: Record<string, string | number | boolean>, code: number): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
emitStatus('SIGNAL_AUTH', block);
|
||||
resolve();
|
||||
setTimeout(() => process.exit(code), 500);
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
try {
|
||||
child.kill('SIGTERM');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
finish({ STATUS: 'failed', ERROR: 'qr_timeout' }, 1);
|
||||
}, LINK_TIMEOUT_MS);
|
||||
|
||||
const child = spawn(cli, ['link', '--name', DEFAULT_DEVICE_NAME], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
// stdout carries the URL on the first line; subsequent lines may print
|
||||
// status like "Associated with: +1555…". We don't strictly need to parse
|
||||
// the number — listAccounts after exit is the source of truth — but the
|
||||
// URL match drives the QR emit, which is the whole point.
|
||||
let stdoutBuf = '';
|
||||
const handleStdout = (chunk: Buffer): void => {
|
||||
stdoutBuf += chunk.toString('utf-8');
|
||||
let idx: number;
|
||||
while ((idx = stdoutBuf.indexOf('\n')) !== -1) {
|
||||
const line = stdoutBuf.slice(0, idx).trim();
|
||||
stdoutBuf = stdoutBuf.slice(idx + 1);
|
||||
if (!line) continue;
|
||||
// Match both modern (sgnl://) and legacy (tsdevice://) schemes.
|
||||
if (/^(sgnl|tsdevice):\/\/linkdevice\?/.test(line) && !qrEmitted) {
|
||||
qrEmitted = true;
|
||||
emitStatus('SIGNAL_AUTH_QR', { QR: line });
|
||||
}
|
||||
}
|
||||
};
|
||||
child.stdout.on('data', handleStdout);
|
||||
|
||||
// Capture stderr for the transcript / log — signal-cli writes warnings
|
||||
// and errors there. We don't emit on partial stderr lines since a
|
||||
// successful link can still produce noise.
|
||||
let stderrBuf = '';
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
stderrBuf += chunk.toString('utf-8');
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
finish({ STATUS: 'failed', ERROR: `spawn error: ${err.message}` }, 1);
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
// After a successful link, signal-cli exits 0 and the newly linked
|
||||
// account shows up in listAccounts. Use that as the source of truth
|
||||
// rather than scraping stdout — more robust across signal-cli versions.
|
||||
if (code === 0) {
|
||||
const post = listAccounts();
|
||||
if (post.length === 0) {
|
||||
finish(
|
||||
{ STATUS: 'failed', ERROR: 'link exited 0 but no account registered' },
|
||||
1,
|
||||
);
|
||||
return;
|
||||
}
|
||||
finish({ STATUS: 'success', ACCOUNT: post[0] }, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-zero exit. Surface the last non-empty stderr line for context;
|
||||
// signal-cli's own error messages are usually informative.
|
||||
const lastErr =
|
||||
stderrBuf
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
.slice(-1)[0] ?? `signal-cli link exited with code ${code}`;
|
||||
finish({ STATUS: 'failed', ERROR: lastErr }, 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { determineVerifyStatus } from './verify.js';
|
||||
|
||||
const healthyBase = {
|
||||
service: 'running' as const,
|
||||
credentials: 'configured',
|
||||
anyChannelConfigured: false,
|
||||
registeredGroups: 1,
|
||||
agentPing: 'ok' as const,
|
||||
};
|
||||
|
||||
describe('determineVerifyStatus', () => {
|
||||
it('accepts a working CLI-only install', () => {
|
||||
expect(determineVerifyStatus(healthyBase)).toBe('success');
|
||||
});
|
||||
|
||||
it('accepts a messaging-channel install when CLI ping is skipped', () => {
|
||||
expect(
|
||||
determineVerifyStatus({
|
||||
...healthyBase,
|
||||
anyChannelConfigured: true,
|
||||
agentPing: 'skipped',
|
||||
}),
|
||||
).toBe('success');
|
||||
});
|
||||
|
||||
it('fails when neither CLI nor messaging channels are usable', () => {
|
||||
expect(
|
||||
determineVerifyStatus({
|
||||
...healthyBase,
|
||||
agentPing: 'skipped',
|
||||
}),
|
||||
).toBe('failed');
|
||||
});
|
||||
|
||||
it('fails when the CLI agent does not respond', () => {
|
||||
expect(
|
||||
determineVerifyStatus({
|
||||
...healthyBase,
|
||||
anyChannelConfigured: true,
|
||||
agentPing: 'no_reply',
|
||||
}),
|
||||
).toBe('failed');
|
||||
});
|
||||
|
||||
it('fails when no agent groups are registered', () => {
|
||||
expect(
|
||||
determineVerifyStatus({
|
||||
...healthyBase,
|
||||
registeredGroups: 0,
|
||||
}),
|
||||
).toBe('failed');
|
||||
});
|
||||
});
|
||||
+30
-11
@@ -14,7 +14,7 @@ import Database from 'better-sqlite3';
|
||||
import { DATA_DIR } from '../src/config.js';
|
||||
import { readEnvFile } from '../src/env.js';
|
||||
import { log } from '../src/log.js';
|
||||
import { pingCliAgent } from './lib/agent-ping.js';
|
||||
import { pingCliAgent, type PingResult } from './lib/agent-ping.js';
|
||||
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
|
||||
import {
|
||||
getPlatform,
|
||||
@@ -220,22 +220,22 @@ export async function run(_args: string[]): Promise<void> {
|
||||
|
||||
// 7. End-to-end: ping the CLI agent and confirm it replies. Only run if
|
||||
// everything upstream looks healthy, since a broken socket would just hang.
|
||||
let agentPing: 'ok' | 'no_reply' | 'socket_error' | 'skipped' = 'skipped';
|
||||
let agentPing: 'ok' | 'no_reply' | 'socket_error' | 'auth_error' | 'skipped' = 'skipped';
|
||||
if (service === 'running' && registeredGroups > 0) {
|
||||
log.info('Pinging CLI agent');
|
||||
agentPing = await pingCliAgent();
|
||||
log.info('Agent ping result', { agentPing });
|
||||
}
|
||||
|
||||
// Determine overall status
|
||||
const status =
|
||||
service === 'running' &&
|
||||
credentials !== 'missing' &&
|
||||
anyChannelConfigured &&
|
||||
registeredGroups > 0 &&
|
||||
(agentPing === 'ok' || agentPing === 'skipped')
|
||||
? 'success'
|
||||
: 'failed';
|
||||
// Determine overall status. A CLI-only install is valid when the local
|
||||
// agent round-trip succeeds; messaging app credentials are optional.
|
||||
const status = determineVerifyStatus({
|
||||
service,
|
||||
credentials,
|
||||
anyChannelConfigured,
|
||||
registeredGroups,
|
||||
agentPing,
|
||||
});
|
||||
|
||||
log.info('Verification complete', { status, channelAuth });
|
||||
|
||||
@@ -255,6 +255,25 @@ export async function run(_args: string[]): Promise<void> {
|
||||
if (status === 'failed') process.exit(1);
|
||||
}
|
||||
|
||||
export function determineVerifyStatus(input: {
|
||||
service: 'not_found' | 'stopped' | 'running' | 'running_other_checkout';
|
||||
credentials: string;
|
||||
anyChannelConfigured: boolean;
|
||||
registeredGroups: number;
|
||||
agentPing: PingResult | 'skipped';
|
||||
}): 'success' | 'failed' {
|
||||
const cliAgentResponds = input.agentPing === 'ok';
|
||||
const hasUsableChannel = input.anyChannelConfigured || cliAgentResponds;
|
||||
|
||||
return input.service === 'running' &&
|
||||
input.credentials !== 'missing' &&
|
||||
hasUsableChannel &&
|
||||
input.registeredGroups > 0 &&
|
||||
(cliAgentResponds || input.agentPing === 'skipped')
|
||||
? 'success'
|
||||
: 'failed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a PID, resolve the script path the process is executing (i.e. the
|
||||
* first `.js` / `.ts` / `.mjs` arg after `node`). Returns null on any
|
||||
|
||||
+19
-25
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Step: whatsapp-auth — standalone WhatsApp (Baileys) authentication.
|
||||
* Step: whatsapp-auth — standalone WhatsApp (Baileys v7) authentication.
|
||||
*
|
||||
* Forked from the channels-branch version so setup:auto's driver can render
|
||||
* the terminal UX itself (inside clack) instead of the step dumping a raw QR
|
||||
@@ -27,7 +27,6 @@
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { createRequire } from 'module';
|
||||
// Named import (not default) — pino's d.ts under NodeNext resolves the
|
||||
// default export to `typeof pino` (namespace), which isn't callable. The
|
||||
// named `pino` export resolves to the callable function.
|
||||
@@ -47,26 +46,23 @@ const AUTH_DIR = path.join(process.cwd(), 'store', 'auth');
|
||||
const PAIRING_CODE_FILE = path.join(process.cwd(), 'store', 'pairing-code.txt');
|
||||
const baileysLogger = pino({ level: 'silent' });
|
||||
|
||||
// Baileys v6 bug: getPlatformId sends charCode (49) instead of enum value (1).
|
||||
// Fixed in Baileys 7.x but not backported. Without this patch pairing codes
|
||||
// fail with "couldn't link device" because WhatsApp receives an invalid
|
||||
// platform id. createRequire because proto is not a named ESM export.
|
||||
const _require = createRequire(import.meta.url);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { proto } = _require('@whiskeysockets/baileys') as { proto: any };
|
||||
try {
|
||||
const _generics = _require(
|
||||
'@whiskeysockets/baileys/lib/Utils/generics',
|
||||
) as Record<string, unknown>;
|
||||
_generics.getPlatformId = (browser: string): string => {
|
||||
const platformType =
|
||||
proto.DeviceProps.PlatformType[
|
||||
browser.toUpperCase() as keyof typeof proto.DeviceProps.PlatformType
|
||||
];
|
||||
return platformType ? platformType.toString() : '1';
|
||||
};
|
||||
} catch {
|
||||
// If CJS require fails, QR auth still works; only pairing code may be affected.
|
||||
/** Fetch current WA Web version — wppconnect tracker, then Baileys sw.js scrape. */
|
||||
async function resolveWaWebVersion(): Promise<[number, number, number]> {
|
||||
try {
|
||||
const res = await fetch('https://wppconnect.io/whatsapp-versions/', {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (res.ok) {
|
||||
const html = await res.text();
|
||||
const match = html.match(/2\.3000\.(\d+)/);
|
||||
if (match) return [2, 3000, Number(match[1])];
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
try {
|
||||
const { version } = await fetchLatestWaWebVersion({});
|
||||
if (version) return version as [number, number, number];
|
||||
} catch { /* fall through */ }
|
||||
throw new Error('Could not fetch current WhatsApp Web version — cannot connect with stale version');
|
||||
}
|
||||
|
||||
type AuthMethod = 'qr' | 'pairing-code';
|
||||
@@ -139,9 +135,7 @@ export async function run(args: string[]): Promise<void> {
|
||||
|
||||
async function connectSocket(isReconnect = false): Promise<void> {
|
||||
const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR);
|
||||
const { version } = await fetchLatestWaWebVersion({}).catch(() => ({
|
||||
version: undefined,
|
||||
}));
|
||||
const version = await resolveWaWebVersion();
|
||||
|
||||
const sock = makeWASocket({
|
||||
version,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Is `name` safe to use as the last segment of a path inside an
|
||||
* attachment-staging directory? Filenames originate from untrusted sources —
|
||||
* channel messages from any chat participant, agent-to-agent forwards from
|
||||
* a possibly-compromised peer agent — and land in `path.join(dir, name)`
|
||||
* sinks on the host. Without this guard, a `..`-laden name escapes the
|
||||
* inbox and writes anywhere the host process has filesystem permission.
|
||||
*
|
||||
* Rejects:
|
||||
* - non-string / empty
|
||||
* - `.` / `..` (traversal sentinels that path.basename returns as-is)
|
||||
* - anything containing a path separator (`/` or `\`) or NUL
|
||||
* - any value where `path.basename(name) !== name`, catching OS-specific
|
||||
* separators and covering drives/prefixes on Windows runtimes
|
||||
*/
|
||||
export function isSafeAttachmentName(name: string): boolean {
|
||||
if (typeof name !== 'string' || name.length === 0) return false;
|
||||
if (name === '.' || name === '..') return false;
|
||||
if (/[\\/\0]/.test(name)) return false;
|
||||
return path.basename(name) === name;
|
||||
}
|
||||
@@ -56,6 +56,8 @@ export interface InboundEvent {
|
||||
* See InboundMessage.isMention for the full explanation.
|
||||
*/
|
||||
isMention?: boolean;
|
||||
/** True when the source is a group/channel thread, false for DMs. */
|
||||
isGroup?: boolean;
|
||||
};
|
||||
replyTo?: DeliveryAddress;
|
||||
}
|
||||
@@ -81,6 +83,8 @@ export interface InboundMessage {
|
||||
* router falls back to text-match against agent_group_name.
|
||||
*/
|
||||
isMention?: boolean;
|
||||
/** True when the source is a group/channel thread, false for DMs. */
|
||||
isGroup?: boolean;
|
||||
}
|
||||
|
||||
/** A file attachment to deliver alongside a message. */
|
||||
|
||||
@@ -81,6 +81,26 @@ export interface ChatSdkBridgeConfig {
|
||||
* chunk boundary will render as two independent blocks on the receiving
|
||||
* platform, which is the same behavior as manually re-opening a fence.
|
||||
*/
|
||||
/**
|
||||
* Decode the actual option value from a button callback. Buttons are encoded
|
||||
* with an integer index (to keep under Telegram's 64-byte callback_data cap),
|
||||
* and the real value is looked up via `getAskQuestionRender(questionId)`.
|
||||
* Falls back to treating the tail as a literal value so old in-flight cards
|
||||
* (encoded before this shortening landed) still resolve.
|
||||
*/
|
||||
function resolveSelectedOption(
|
||||
render: { options: NormalizedOption[] } | undefined,
|
||||
eventValue: string | undefined,
|
||||
tail: string | undefined,
|
||||
): string {
|
||||
const candidate = eventValue ?? tail ?? '';
|
||||
if (render && /^\d+$/.test(candidate)) {
|
||||
const idx = Number(candidate);
|
||||
if (render.options[idx]) return render.options[idx].value;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export function splitForLimit(text: string, limit: number): string[] {
|
||||
if (text.length <= limit) return [text];
|
||||
const chunks: string[] = [];
|
||||
@@ -105,7 +125,11 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
||||
let setupConfig: ChannelSetup;
|
||||
let gatewayAbort: AbortController | null = null;
|
||||
|
||||
async function messageToInbound(message: ChatMessage, isMention: boolean): Promise<InboundMessage> {
|
||||
async function messageToInbound(
|
||||
message: ChatMessage,
|
||||
isMention: boolean,
|
||||
isGroup?: boolean,
|
||||
): Promise<InboundMessage> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const serialized = message.toJSON() as Record<string, any>;
|
||||
|
||||
@@ -162,6 +186,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
||||
content: serialized,
|
||||
timestamp: message.metadata.dateSent.toISOString(),
|
||||
isMention,
|
||||
isGroup,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -195,13 +220,17 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
||||
// wirings still fire on in-thread mentions.
|
||||
chat.onSubscribedMessage(async (thread, message) => {
|
||||
const channelId = adapter.channelIdFromThreadId(thread.id);
|
||||
await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, message.isMention === true));
|
||||
await setupConfig.onInbound(
|
||||
channelId,
|
||||
thread.id,
|
||||
await messageToInbound(message, message.isMention === true, true),
|
||||
);
|
||||
});
|
||||
|
||||
// @mention in an unsubscribed thread — SDK-confirmed bot mention.
|
||||
chat.onNewMention(async (thread, message) => {
|
||||
const channelId = adapter.channelIdFromThreadId(thread.id);
|
||||
await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, true));
|
||||
await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, true, true));
|
||||
});
|
||||
|
||||
// DMs — by definition addressed to the bot. Thread id flows through
|
||||
@@ -216,7 +245,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
||||
sender: (message.author as any)?.fullName ?? (message.author as any)?.userId ?? 'unknown',
|
||||
threadId: thread.id,
|
||||
});
|
||||
await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, true));
|
||||
await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, true, false));
|
||||
});
|
||||
|
||||
// Plain messages in unsubscribed threads.
|
||||
@@ -231,7 +260,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
||||
// flood gate.
|
||||
chat.onNewMessage(/./, async (thread, message) => {
|
||||
const channelId = adapter.channelIdFromThreadId(thread.id);
|
||||
await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, false));
|
||||
await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, false, true));
|
||||
});
|
||||
|
||||
// Handle button clicks (ask_user_question)
|
||||
@@ -240,11 +269,15 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
||||
const parts = event.actionId.split(':');
|
||||
if (parts.length < 3) return;
|
||||
const questionId = parts[1];
|
||||
const selectedOption = event.value || '';
|
||||
const tail = parts.slice(2).join(':');
|
||||
const userId = event.user?.userId || '';
|
||||
|
||||
// Resolve render metadata BEFORE dispatching onAction (which deletes the row).
|
||||
const render = getAskQuestionRender(questionId);
|
||||
// New format: button id/value is an integer index into options (kept
|
||||
// short to fit Telegram's 64-byte callback_data cap). Old format:
|
||||
// the full value is embedded in actionId/value directly.
|
||||
const selectedOption = resolveSelectedOption(render, event.value, tail);
|
||||
const title = render?.title ?? '❓ Question';
|
||||
const matched = render?.options.find((o) => o.value === selectedOption);
|
||||
const selectedLabel = matched?.selectedLabel ?? selectedOption ?? '(clicked)';
|
||||
@@ -348,8 +381,13 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
||||
children: [
|
||||
CardText(question),
|
||||
Actions(
|
||||
options.map((opt) =>
|
||||
Button({ id: `ncq:${questionId}:${opt.value}`, label: opt.label, value: opt.value }),
|
||||
// Encode button id/value with the option index rather than the
|
||||
// full value. Telegram caps callback_data at 64 bytes, and
|
||||
// long values (e.g. ISO datetimes, URLs) push the JSON payload
|
||||
// well past that. The onAction handlers resolve the index back
|
||||
// to the real value via getAskQuestionRender(questionId).
|
||||
options.map((opt, idx) =>
|
||||
Button({ id: `ncq:${questionId}:${idx}`, label: opt.label, value: String(idx) }),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -501,18 +539,21 @@ async function handleForwardedEvent(
|
||||
// type 3 = MessageComponent (button/select)
|
||||
if (interaction.type === 3) {
|
||||
const customId = (interaction.data as Record<string, unknown>)?.custom_id as string;
|
||||
const user = (interaction.member as Record<string, unknown>)?.user as Record<string, string> | undefined;
|
||||
// In guilds the clicker is at interaction.member.user; in DMs it's interaction.user directly.
|
||||
const user =
|
||||
((interaction.member as Record<string, unknown>)?.user as Record<string, string> | undefined) ??
|
||||
(interaction.user as Record<string, string> | undefined);
|
||||
const interactionId = interaction.id as string;
|
||||
const interactionToken = interaction.token as string;
|
||||
|
||||
// Parse the selected option from custom_id
|
||||
let questionId: string | undefined;
|
||||
let selectedOption: string | undefined;
|
||||
let tail: string | undefined;
|
||||
if (customId?.startsWith('ncq:')) {
|
||||
const colonIdx = customId.indexOf(':', 4); // after "ncq:"
|
||||
if (colonIdx !== -1) {
|
||||
questionId = customId.slice(4, colonIdx);
|
||||
selectedOption = customId.slice(colonIdx + 1);
|
||||
tail = customId.slice(colonIdx + 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -521,6 +562,9 @@ async function handleForwardedEvent(
|
||||
((interaction.message as Record<string, unknown>)?.embeds as Array<Record<string, unknown>>) || [];
|
||||
const originalDescription = (originalEmbeds[0]?.description as string) || '';
|
||||
const render = questionId ? getAskQuestionRender(questionId) : undefined;
|
||||
// Discord custom_id mirrors the new index-based encoding (see Button
|
||||
// construction). Decode back to the real option value for downstream.
|
||||
const selectedOption = resolveSelectedOption(render, tail, tail);
|
||||
const cardTitle = render?.title ?? ((originalEmbeds[0]?.title as string) || '❓ Question');
|
||||
const matchedOpt = render?.options.find((o) => o.value === selectedOption);
|
||||
const selectedLabel = matchedOpt?.selectedLabel ?? selectedOption ?? customId;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Integration test for the deltachat channel's single reach-in: the
|
||||
* self-registration import in the `src/channels/index.ts` barrel. Importing the
|
||||
* barrel runs deltachat.ts's top-level `registerChannelAdapter('deltachat', …)`;
|
||||
* without the import the channel is silently absent.
|
||||
*
|
||||
* Behavior, not structural: it imports the real barrel and asserts the registry
|
||||
* actually contains the channel. This reflects what happens at host boot — if the
|
||||
* `import './deltachat.js';` line is deleted, or the barrel fails to evaluate for
|
||||
* any reason (so the channel genuinely would not register), this goes red. A
|
||||
* structural check of the import line would falsely pass in that second case.
|
||||
*
|
||||
* Importing the barrel is safe: registration is a pure top-level call, and
|
||||
* deltachat.ts only instantiates DeltaChatOverJsonRpc inside setup() (run at host
|
||||
* startup), never at import — so nothing spawns here. It does require the adapter
|
||||
* package to be installed, which holds in a composed install: the skill's
|
||||
* `pnpm install` step runs before this test in the apply flow.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { getRegisteredChannelNames } from './channel-registry.js';
|
||||
import './index.js'; // the real barrel — triggers every channel's self-registration
|
||||
|
||||
describe('deltachat channel registration', () => {
|
||||
it('registers deltachat via the channel barrel', () => {
|
||||
expect(getRegisteredChannelNames()).toContain('deltachat');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* DeltaChat channel adapter.
|
||||
*
|
||||
* Bridges NanoClaw with DeltaChat via the @deltachat/stdio-rpc-server JSON-RPC
|
||||
* process. Each DeltaChat chat becomes a separate NanoClaw messaging group
|
||||
* (platformId = chatId string, e.g. "12"). No thread model — supportsThreads: false.
|
||||
*
|
||||
* Required env vars (.env): DC_EMAIL, DC_PASSWORD,
|
||||
* DC_IMAP_HOST, DC_IMAP_PORT,
|
||||
* DC_SMTP_HOST, DC_SMTP_PORT
|
||||
* Optional env vars (.env): DC_IMAP_SECURITY (default: "1" = SSL/TLS),
|
||||
* DC_SMTP_SECURITY (default: "2" = STARTTLS)
|
||||
* Security values: 1=SSL/TLS, 2=STARTTLS, 3=plain
|
||||
* Optional env vars (service unit): DC_ACCOUNT_DIR (default: "dc-account"),
|
||||
* DC_DISPLAY_NAME, DC_AVATAR_PATH
|
||||
*/
|
||||
import { existsSync, mkdtempSync, writeFileSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { basename, join, resolve } from 'path';
|
||||
|
||||
import { getDb, hasTable } from '../db/connection.js';
|
||||
import { readEnvFile } from '../env.js';
|
||||
import { log } from '../log.js';
|
||||
import type { ChannelAdapter, ChannelSetup, OutboundMessage } from './adapter.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
|
||||
import { DeltaChatOverJsonRpc } from '@deltachat/stdio-rpc-server';
|
||||
|
||||
const REQUIRED_ENV = [
|
||||
'DC_EMAIL',
|
||||
'DC_PASSWORD',
|
||||
'DC_IMAP_HOST',
|
||||
'DC_IMAP_PORT',
|
||||
'DC_SMTP_HOST',
|
||||
'DC_SMTP_PORT',
|
||||
] as const;
|
||||
|
||||
const OPTIONAL_ENV = ['DC_IMAP_SECURITY', 'DC_SMTP_SECURITY'] as const;
|
||||
|
||||
type DcEnv = { [K in (typeof REQUIRED_ENV)[number]]: string } & { [K in (typeof OPTIONAL_ENV)[number]]?: string };
|
||||
|
||||
function isDcAdmin(userId: string): boolean {
|
||||
try {
|
||||
const db = getDb();
|
||||
if (!hasTable(db, 'user_roles')) return true;
|
||||
return (
|
||||
db
|
||||
.prepare(
|
||||
`SELECT 1 FROM user_roles
|
||||
WHERE user_id = ?
|
||||
AND (role = 'owner' OR role = 'admin')
|
||||
AND agent_group_id IS NULL
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(userId) != null
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function createAdapter(env: DcEnv): ChannelAdapter {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let dc: any = null;
|
||||
let accountId = 0;
|
||||
let connectivity = 0;
|
||||
let lastImapIdleTs = Date.now();
|
||||
let consecutiveBadChecks = 0;
|
||||
let watchdogTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let networkTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
async function restartIo(reason: string): Promise<void> {
|
||||
log.warn('DeltaChat: restarting IO', { reason });
|
||||
try {
|
||||
await dc.rpc.stopIo(accountId);
|
||||
await dc.rpc.startIo(accountId);
|
||||
lastImapIdleTs = Date.now();
|
||||
consecutiveBadChecks = 0;
|
||||
} catch (err) {
|
||||
log.error('DeltaChat: IO restart failed', { err });
|
||||
}
|
||||
}
|
||||
|
||||
const adapter: ChannelAdapter = {
|
||||
name: 'deltachat',
|
||||
channelType: 'deltachat',
|
||||
supportsThreads: false,
|
||||
|
||||
async setup(config: ChannelSetup): Promise<void> {
|
||||
const accountDir = process.env.DC_ACCOUNT_DIR ?? 'dc-account';
|
||||
dc = new DeltaChatOverJsonRpc(accountDir, {});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
dc.on('Error', (_: any, event: any) => log.error('DeltaChat RPC error', { msg: event.msg ?? event }));
|
||||
|
||||
const accounts = await dc.rpc.getAllAccounts();
|
||||
accountId = accounts[0]?.id;
|
||||
if (!accountId) accountId = await dc.rpc.addAccount();
|
||||
|
||||
const imapSecurity = env.DC_IMAP_SECURITY ?? '1';
|
||||
const smtpSecurity = env.DC_SMTP_SECURITY ?? '2';
|
||||
|
||||
if (!(await dc.rpc.isConfigured(accountId))) {
|
||||
await dc.rpc.setConfig(accountId, 'addr', env.DC_EMAIL);
|
||||
await dc.rpc.setConfig(accountId, 'mail_pw', env.DC_PASSWORD);
|
||||
await dc.rpc.setConfig(accountId, 'mail_server', env.DC_IMAP_HOST);
|
||||
await dc.rpc.setConfig(accountId, 'mail_port', env.DC_IMAP_PORT);
|
||||
await dc.rpc.setConfig(accountId, 'send_server', env.DC_SMTP_HOST);
|
||||
await dc.rpc.setConfig(accountId, 'send_port', env.DC_SMTP_PORT);
|
||||
await dc.rpc.configure(accountId);
|
||||
log.info('DeltaChat: account configured', { email: env.DC_EMAIL });
|
||||
} else {
|
||||
log.info('DeltaChat: account ready', { email: env.DC_EMAIL });
|
||||
}
|
||||
|
||||
await dc.rpc.setConfig(accountId, 'mail_security', imapSecurity);
|
||||
await dc.rpc.setConfig(accountId, 'send_security', smtpSecurity);
|
||||
await dc.rpc.setConfig(accountId, 'displayname', process.env.DC_DISPLAY_NAME ?? 'NanoClaw');
|
||||
const avatarPath = process.env.DC_AVATAR_PATH;
|
||||
if (avatarPath && existsSync(avatarPath)) {
|
||||
await dc.rpc.setConfig(accountId, 'selfavatar', avatarPath);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
dc.on('IncomingMsg', async (contextId: number, event: any) => {
|
||||
if (contextId !== accountId) return;
|
||||
try {
|
||||
let msg = await dc.rpc.getMessage(accountId, event.msgId);
|
||||
if (msg.isInfo) return;
|
||||
|
||||
// Wait for large-message download to complete
|
||||
if (msg.downloadState !== 'Done') {
|
||||
await dc.rpc.downloadFullMessage(accountId, event.msgId);
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
msg = await dc.rpc.getMessage(accountId, event.msgId);
|
||||
if (msg.downloadState === 'Done') break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!msg.text && !msg.file) return;
|
||||
|
||||
const contact = await dc.rpc.getContact(accountId, msg.fromId);
|
||||
const chat = await dc.rpc.getBasicChatInfo(accountId, event.chatId);
|
||||
|
||||
if (/^\/set-avatar$/i.test((msg.text || '').trim()) && msg.file) {
|
||||
const userId = `deltachat:${contact.address}`;
|
||||
try {
|
||||
if (isDcAdmin(userId)) {
|
||||
const absPath = resolve(msg.file as string);
|
||||
await dc.rpc.setConfig(accountId, 'selfavatar', absPath);
|
||||
await dc.rpc.sendMsg(accountId, event.chatId, { text: 'Avatar updated.' });
|
||||
} else {
|
||||
await dc.rpc.sendMsg(accountId, event.chatId, { text: 'Permission denied.' });
|
||||
}
|
||||
} catch (avatarErr: unknown) {
|
||||
log.error('DeltaChat: failed to set avatar', {
|
||||
err: avatarErr instanceof Error ? avatarErr.message : JSON.stringify(avatarErr),
|
||||
});
|
||||
await dc.rpc.sendMsg(accountId, event.chatId, { text: 'Failed to set avatar.' }).catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const content: Record<string, unknown> = {
|
||||
text: msg.text || '',
|
||||
sender: contact.displayName || contact.address,
|
||||
senderId: contact.address,
|
||||
};
|
||||
if (msg.file) {
|
||||
content.attachments = [
|
||||
{
|
||||
name: basename(msg.file as string),
|
||||
type: 'file',
|
||||
localPath: msg.file,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const isGroup = chat?.isGroup ?? false;
|
||||
await config.onInbound(String(event.chatId), null, {
|
||||
id: String(event.msgId),
|
||||
kind: 'chat',
|
||||
content,
|
||||
timestamp: new Date().toISOString(),
|
||||
isGroup,
|
||||
isMention: !isGroup,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
log.error('DeltaChat: error handling incoming message', {
|
||||
err: err instanceof Error ? err.message : JSON.stringify(err),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
dc.on('ImapInboxIdle', (contextId: number) => {
|
||||
if (contextId === accountId) lastImapIdleTs = Date.now();
|
||||
});
|
||||
|
||||
dc.on('ConnectivityChanged', async (contextId: number) => {
|
||||
if (contextId !== accountId) return;
|
||||
try {
|
||||
connectivity = await dc.rpc.getConnectivity(accountId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
|
||||
await dc.rpc.startIo(accountId);
|
||||
try {
|
||||
connectivity = await dc.rpc.getConnectivity(accountId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
log.info('DeltaChat: IO started', { email: env.DC_EMAIL });
|
||||
|
||||
// Log invite link on every startup so the operator can bootstrap the first contact.
|
||||
// In DeltaChat, contacts can't simply be added by email — the user must open this
|
||||
// https://i.delta.chat/ invite URL in their DeltaChat app (or scan invite-qr.svg) to initiate contact.
|
||||
try {
|
||||
// null chatId → Setup-Contact invite (not group-specific)
|
||||
const [inviteUrl, svg] = await dc.rpc.getChatSecurejoinQrCodeSvg(accountId, null);
|
||||
const accountDir = resolve(process.env.DC_ACCOUNT_DIR ?? 'dc-account');
|
||||
const svgPath = join(accountDir, 'invite-qr.svg');
|
||||
writeFileSync(svgPath, svg);
|
||||
log.info('DeltaChat: invite link — open URL in DeltaChat app or scan ' + svgPath, { url: inviteUrl });
|
||||
} catch (err: unknown) {
|
||||
log.warn('DeltaChat: could not generate invite link', {
|
||||
err: err instanceof Error ? err.message : JSON.stringify(err),
|
||||
});
|
||||
}
|
||||
|
||||
// Connectivity watchdog: restart IO if IMAP goes quiet or connectivity drops
|
||||
watchdogTimer = setInterval(
|
||||
async () => {
|
||||
try {
|
||||
const conn = await dc.rpc.getConnectivity(accountId);
|
||||
connectivity = conn;
|
||||
if (conn < 3000) {
|
||||
consecutiveBadChecks++;
|
||||
if (consecutiveBadChecks >= 2) {
|
||||
await restartIo(`connectivity=${conn} for 2 consecutive checks`);
|
||||
}
|
||||
} else {
|
||||
consecutiveBadChecks = 0;
|
||||
}
|
||||
const idleAgeMin = (Date.now() - lastImapIdleTs) / 60000;
|
||||
if (idleAgeMin > 20) {
|
||||
await restartIo(`no IMAP IDLE in ${idleAgeMin.toFixed(0)}min`);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
log.warn('DeltaChat: watchdog error', {
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
);
|
||||
|
||||
// Nudge the network stack every 10 minutes (recovers from prolonged idle)
|
||||
networkTimer = setInterval(
|
||||
async () => {
|
||||
try {
|
||||
await dc.rpc.maybeNetwork();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
10 * 60 * 1000,
|
||||
);
|
||||
},
|
||||
|
||||
async teardown(): Promise<void> {
|
||||
if (watchdogTimer) clearInterval(watchdogTimer);
|
||||
if (networkTimer) clearInterval(networkTimer);
|
||||
try {
|
||||
await dc?.rpc.stopIo(accountId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
dc?.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
|
||||
isConnected(): boolean {
|
||||
// 4000 = fully connected (IMAP), 3000 = connecting; treat ≥3000 as live
|
||||
return connectivity >= 3000;
|
||||
},
|
||||
|
||||
async deliver(platformId: string, _threadId: string | null, message: OutboundMessage): Promise<string | undefined> {
|
||||
const chatId = parseInt(platformId, 10);
|
||||
if (isNaN(chatId)) {
|
||||
log.warn('DeltaChat: invalid platformId for delivery', { platformId });
|
||||
return undefined;
|
||||
}
|
||||
const content = message.content as Record<string, unknown>;
|
||||
const text = typeof content.text === 'string' ? content.text : '';
|
||||
|
||||
if (message.files && message.files.length > 0) {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'nanoclaw-dc-'));
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let firstId: any;
|
||||
for (let i = 0; i < message.files.length; i++) {
|
||||
const f = message.files[i];
|
||||
const tempPath = join(tempDir, f.filename);
|
||||
writeFileSync(tempPath, f.data);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const params: any = { file: tempPath };
|
||||
if (i === 0 && text) params.text = text;
|
||||
const sentId = await dc.rpc.sendMsg(accountId, chatId, params);
|
||||
if (i === 0) firstId = sentId;
|
||||
}
|
||||
return firstId != null ? String(firstId) : undefined;
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (!text) return undefined;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const sentId: any = await dc.rpc.sendMsg(accountId, chatId, { text });
|
||||
return sentId != null ? String(sentId) : undefined;
|
||||
},
|
||||
};
|
||||
|
||||
return adapter;
|
||||
}
|
||||
|
||||
registerChannelAdapter('deltachat', {
|
||||
factory: () => {
|
||||
const env = readEnvFile([...REQUIRED_ENV, ...OPTIONAL_ENV]);
|
||||
if (!env.DC_EMAIL || !env.DC_PASSWORD) return null;
|
||||
return createAdapter(env as DcEnv);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Integration test for the discord channel's single reach-in: the self-registration
|
||||
* import in the `src/channels/index.ts` barrel. Importing the barrel runs discord.ts's
|
||||
* top-level `registerChannelAdapter('discord', …)`; without the import the channel is
|
||||
* silently absent.
|
||||
*
|
||||
* Behavior, not structural: it imports the real barrel and asserts the registry
|
||||
* actually contains the channel. This reflects what happens at host boot — if the
|
||||
* `import './discord.js';` line is deleted, or the barrel fails to evaluate for any
|
||||
* reason (so the channel genuinely would not register), this goes red. A structural
|
||||
* check of the import line would falsely pass in that second case.
|
||||
*
|
||||
* Importing the barrel is safe: registration is a pure top-level call, and discord.ts
|
||||
* builds the SDK adapter / bridge only inside its factory (invoked at host startup),
|
||||
* never at import. It does require the adapter package (`@chat-adapter/discord`) to be installed,
|
||||
* which holds in a composed install: the skill's `pnpm install` step runs before this
|
||||
* test — so this test also implicitly guards that dependency (an unmocked import throws
|
||||
* if the package is missing).
|
||||
*
|
||||
* discord is a Chat SDK channel: discord.ts also consumes a load-bearing *core* API —
|
||||
* `createChatSdkBridge(...)` from ./chat-sdk-bridge.js. That core-consumption is a
|
||||
* typed call, so the build/typecheck leg (`pnpm run build`) guards it against upstream
|
||||
* drift, not this test. Every Chat SDK channel follows this same shape.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { getRegisteredChannelNames } from './channel-registry.js';
|
||||
import './index.js'; // the real barrel — triggers every channel's self-registration
|
||||
|
||||
describe('discord channel registration', () => {
|
||||
it('registers discord via the channel barrel', () => {
|
||||
expect(getRegisteredChannelNames()).toContain('discord');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Discord channel adapter (v2) — uses Chat SDK bridge.
|
||||
* Self-registers on import.
|
||||
*/
|
||||
import { createDiscordAdapter } from '@chat-adapter/discord';
|
||||
|
||||
import { readEnvFile } from '../env.js';
|
||||
import { createChatSdkBridge, type ReplyContext } from './chat-sdk-bridge.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function extractReplyContext(raw: Record<string, any>): ReplyContext | null {
|
||||
if (!raw.referenced_message) return null;
|
||||
const reply = raw.referenced_message;
|
||||
return {
|
||||
text: reply.content || '',
|
||||
sender: reply.author?.global_name || reply.author?.username || 'Unknown',
|
||||
};
|
||||
}
|
||||
|
||||
registerChannelAdapter('discord', {
|
||||
factory: () => {
|
||||
const env = readEnvFile(['DISCORD_BOT_TOKEN', 'DISCORD_PUBLIC_KEY', 'DISCORD_APPLICATION_ID']);
|
||||
if (!env.DISCORD_BOT_TOKEN) return null;
|
||||
const discordAdapter = createDiscordAdapter({
|
||||
botToken: env.DISCORD_BOT_TOKEN,
|
||||
publicKey: env.DISCORD_PUBLIC_KEY,
|
||||
applicationId: env.DISCORD_APPLICATION_ID,
|
||||
});
|
||||
return createChatSdkBridge({
|
||||
adapter: discordAdapter,
|
||||
concurrency: 'concurrent',
|
||||
botToken: env.DISCORD_BOT_TOKEN,
|
||||
extractReplyContext,
|
||||
supportsThreads: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Integration test for the emacs channel's single reach-in: the self-registration
|
||||
* import in the `src/channels/index.ts` barrel. Importing the barrel runs emacs.ts's
|
||||
* top-level `registerChannelAdapter('emacs', …)`; without the import the channel is
|
||||
* silently absent.
|
||||
*
|
||||
* Behavior, not structural: it imports the real barrel and asserts the registry
|
||||
* actually contains the channel. This reflects what happens at host boot — if the
|
||||
* `import './emacs.js';` line is deleted, or the barrel fails to evaluate for any
|
||||
* reason (so the channel genuinely would not register), this goes red. A structural
|
||||
* check of the import line would falsely pass in that second case.
|
||||
*
|
||||
* emacs is a native adapter with no npm dependency (it uses the Node http builtin); it talks to an Emacs HTTP client.
|
||||
* Importing the barrel is safe: registration is a pure top-level call and emacs.ts
|
||||
* opens connections / spawns subprocesses only inside setup() (run at host startup),
|
||||
* never at import. There is no adapter package to guard here — this test guards the
|
||||
* one barrel reach-in (red if `import './emacs.js';` is deleted or the barrel fails
|
||||
* to evaluate).
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { getRegisteredChannelNames } from './channel-registry.js';
|
||||
import './index.js'; // the real barrel — triggers every channel's self-registration
|
||||
|
||||
describe('emacs channel registration', () => {
|
||||
it('registers emacs via the channel barrel', () => {
|
||||
expect(getRegisteredChannelNames()).toContain('emacs');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Tests for the v2 emacs channel adapter.
|
||||
*
|
||||
* Exercises the HTTP surface (POST /api/message, GET /api/messages) and
|
||||
* the ChannelAdapter lifecycle (setup / teardown / isConnected / deliver).
|
||||
*/
|
||||
import http from 'http';
|
||||
import type { AddressInfo } from 'net';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createEmacsAdapter } from './emacs.js';
|
||||
import type { ChannelAdapter, ChannelSetup } from './adapter.js';
|
||||
|
||||
vi.mock('../log.js', () => ({
|
||||
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
function makeSetup(overrides: Partial<ChannelSetup> = {}): ChannelSetup {
|
||||
return {
|
||||
onInbound: vi.fn(),
|
||||
onInboundEvent: vi.fn(),
|
||||
onMetadata: vi.fn(),
|
||||
onAction: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Ask the OS for a free port, then immediately release it. Small race window
|
||||
* before the adapter grabs it, but sufficient for local test use. */
|
||||
async function getFreePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const srv = http.createServer();
|
||||
srv.once('error', reject);
|
||||
srv.listen(0, '127.0.0.1', () => {
|
||||
const port = (srv.address() as AddressInfo).port;
|
||||
srv.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function req(
|
||||
port: number,
|
||||
method: string,
|
||||
path: string,
|
||||
body?: string,
|
||||
extraHeaders: Record<string, string> = {},
|
||||
): Promise<{ status: number; data: unknown }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json', ...extraHeaders };
|
||||
const request = http.request({ host: '127.0.0.1', port, method, path, headers }, (res) => {
|
||||
let raw = '';
|
||||
res.on('data', (chunk: Buffer) => (raw += chunk.toString()));
|
||||
res.on('end', () => {
|
||||
try {
|
||||
resolve({ status: res.statusCode!, data: JSON.parse(raw) });
|
||||
} catch {
|
||||
resolve({ status: res.statusCode!, data: raw });
|
||||
}
|
||||
});
|
||||
});
|
||||
request.on('error', reject);
|
||||
if (body) request.write(body);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
describe('emacs adapter', () => {
|
||||
let adapter: ChannelAdapter;
|
||||
let port: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
port = await getFreePort();
|
||||
adapter = createEmacsAdapter({ port, authToken: null, platformId: 'default' });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter.isConnected()) await adapter.teardown();
|
||||
});
|
||||
|
||||
describe('lifecycle', () => {
|
||||
it('isConnected is false before setup', () => {
|
||||
expect(adapter.isConnected()).toBe(false);
|
||||
});
|
||||
|
||||
it('isConnected is true after setup', async () => {
|
||||
await adapter.setup(makeSetup());
|
||||
expect(adapter.isConnected()).toBe(true);
|
||||
});
|
||||
|
||||
it('isConnected is false after teardown', async () => {
|
||||
await adapter.setup(makeSetup());
|
||||
await adapter.teardown();
|
||||
expect(adapter.isConnected()).toBe(false);
|
||||
});
|
||||
|
||||
it('teardown is a no-op before setup', async () => {
|
||||
await expect(adapter.teardown()).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('calls onMetadata after setup with channel name', async () => {
|
||||
const onMetadata = vi.fn();
|
||||
await adapter.setup(makeSetup({ onMetadata }));
|
||||
expect(onMetadata).toHaveBeenCalledWith('default', 'Emacs', false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/message', () => {
|
||||
let onInbound: ChannelSetup['onInbound'] & { mock: { calls: unknown[][] } };
|
||||
|
||||
beforeEach(async () => {
|
||||
onInbound = vi.fn() as unknown as typeof onInbound;
|
||||
await adapter.setup(makeSetup({ onInbound }));
|
||||
});
|
||||
|
||||
it('fires onInbound with chat kind and sender metadata', async () => {
|
||||
const { status, data } = await req(port, 'POST', '/api/message', JSON.stringify({ text: 'hello' }));
|
||||
expect(status).toBe(200);
|
||||
expect((data as { messageId: string }).messageId).toMatch(/^emacs-/);
|
||||
expect(onInbound).toHaveBeenCalledOnce();
|
||||
const [platformId, threadId, msg] = onInbound.mock.calls[0] as [string, string | null, { content: unknown }];
|
||||
expect(platformId).toBe('default');
|
||||
expect(threadId).toBeNull();
|
||||
expect(msg).toMatchObject({
|
||||
kind: 'chat',
|
||||
content: { text: 'hello', sender: 'Emacs', senderId: 'emacs:default' },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns 400 for empty text', async () => {
|
||||
const { status } = await req(port, 'POST', '/api/message', JSON.stringify({ text: '' }));
|
||||
expect(status).toBe(400);
|
||||
expect(onInbound).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 400 for whitespace-only text', async () => {
|
||||
const { status } = await req(port, 'POST', '/api/message', JSON.stringify({ text: ' ' }));
|
||||
expect(status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 for invalid JSON', async () => {
|
||||
const { status } = await req(port, 'POST', '/api/message', 'not-json');
|
||||
expect(status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 404 for unknown paths', async () => {
|
||||
const { status } = await req(port, 'POST', '/api/unknown', JSON.stringify({ text: 'hi' }));
|
||||
expect(status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/messages + deliver', () => {
|
||||
beforeEach(async () => {
|
||||
await adapter.setup(makeSetup());
|
||||
});
|
||||
|
||||
it('returns empty buffer initially', async () => {
|
||||
const { status, data } = await req(port, 'GET', '/api/messages?since=0');
|
||||
expect(status).toBe(200);
|
||||
expect(data).toEqual({ messages: [] });
|
||||
});
|
||||
|
||||
it('deliver pushes text for the poll endpoint to return', async () => {
|
||||
await adapter.deliver('default', null, { kind: 'chat', content: { text: 'reply' } });
|
||||
const { data } = await req(port, 'GET', '/api/messages?since=0');
|
||||
const messages = (data as { messages: { text: string; timestamp: number }[] }).messages;
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]?.text).toBe('reply');
|
||||
expect(typeof messages[0]?.timestamp).toBe('number');
|
||||
});
|
||||
|
||||
it('deliver accepts plain-string content', async () => {
|
||||
await adapter.deliver('default', null, { kind: 'chat', content: 'raw text' });
|
||||
const { data } = await req(port, 'GET', '/api/messages?since=0');
|
||||
expect((data as { messages: { text: string }[] }).messages[0]?.text).toBe('raw text');
|
||||
});
|
||||
|
||||
it('deliver skips empty text silently', async () => {
|
||||
await adapter.deliver('default', null, { kind: 'chat', content: { text: '' } });
|
||||
const { data } = await req(port, 'GET', '/api/messages?since=0');
|
||||
expect((data as { messages: unknown[] }).messages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('deliver rejects unknown platformId', async () => {
|
||||
const result = await adapter.deliver('other', null, { kind: 'chat', content: { text: 'x' } });
|
||||
expect(result).toBeUndefined();
|
||||
const { data } = await req(port, 'GET', '/api/messages?since=0');
|
||||
expect((data as { messages: unknown[] }).messages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('filters out messages at or before the since cutoff', async () => {
|
||||
await adapter.deliver('default', null, { kind: 'chat', content: { text: 'old' } });
|
||||
const since = Date.now();
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
await adapter.deliver('default', null, { kind: 'chat', content: { text: 'new' } });
|
||||
const { data } = await req(port, 'GET', `/api/messages?since=${since}`);
|
||||
const texts = (data as { messages: { text: string }[] }).messages.map((m) => m.text);
|
||||
expect(texts).not.toContain('old');
|
||||
expect(texts).toContain('new');
|
||||
});
|
||||
|
||||
it('caps buffer at 200 messages, evicting the oldest', async () => {
|
||||
for (let i = 0; i < 205; i++) {
|
||||
await adapter.deliver('default', null, { kind: 'chat', content: { text: `m-${i}` } });
|
||||
}
|
||||
const { data } = await req(port, 'GET', '/api/messages?since=0');
|
||||
const messages = (data as { messages: { text: string }[] }).messages;
|
||||
expect(messages).toHaveLength(200);
|
||||
expect(messages.map((m) => m.text)).not.toContain('m-0');
|
||||
expect(messages.map((m) => m.text)).toContain('m-5');
|
||||
expect(messages.map((m) => m.text)).toContain('m-204');
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth', () => {
|
||||
let authAdapter: ChannelAdapter;
|
||||
let authPort: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
authPort = await getFreePort();
|
||||
authAdapter = createEmacsAdapter({ port: authPort, authToken: 'secret', platformId: 'default' });
|
||||
await authAdapter.setup(makeSetup());
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (authAdapter.isConnected()) await authAdapter.teardown();
|
||||
});
|
||||
|
||||
it('rejects POST without Authorization header', async () => {
|
||||
const { status } = await req(authPort, 'POST', '/api/message', JSON.stringify({ text: 'hi' }));
|
||||
expect(status).toBe(401);
|
||||
});
|
||||
|
||||
it('rejects POST with wrong token', async () => {
|
||||
const { status } = await req(authPort, 'POST', '/api/message', JSON.stringify({ text: 'hi' }), {
|
||||
Authorization: 'Bearer wrong',
|
||||
});
|
||||
expect(status).toBe(401);
|
||||
});
|
||||
|
||||
it('accepts POST with correct Bearer token', async () => {
|
||||
const { status } = await req(authPort, 'POST', '/api/message', JSON.stringify({ text: 'hi' }), {
|
||||
Authorization: 'Bearer secret',
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
});
|
||||
|
||||
it('rejects GET without Authorization header', async () => {
|
||||
const { status } = await req(authPort, 'GET', '/api/messages?since=0');
|
||||
expect(status).toBe(401);
|
||||
});
|
||||
|
||||
it('accepts GET with correct Bearer token', async () => {
|
||||
const { status } = await req(authPort, 'GET', '/api/messages?since=0', undefined, {
|
||||
Authorization: 'Bearer secret',
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Emacs channel adapter (v2) — native HTTP bridge.
|
||||
*
|
||||
* Stands up a localhost HTTP server that the nanoclaw.el client talks to:
|
||||
* - POST /api/message — user typed a message in Emacs; fire onInbound
|
||||
* - GET /api/messages?since=<ms> — Emacs polls for agent replies
|
||||
*
|
||||
* Single-user, single-chat: one adapter instance = one messaging group with
|
||||
* `platform_id = "default"` (override with EMACS_PLATFORM_ID). No threads,
|
||||
* no cold DM. Self-registers on import.
|
||||
*/
|
||||
import http from 'http';
|
||||
|
||||
import { readEnvFile } from '../env.js';
|
||||
import { log } from '../log.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
import type { ChannelAdapter, ChannelSetup, InboundMessage, OutboundMessage } from './adapter.js';
|
||||
|
||||
const OUTBOUND_BUFFER_MAX = 200;
|
||||
|
||||
interface BufferedMessage {
|
||||
text: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface EmacsAdapterOptions {
|
||||
port: number;
|
||||
authToken: string | null;
|
||||
platformId: string;
|
||||
}
|
||||
|
||||
function createEmacsAdapter(opts: EmacsAdapterOptions): ChannelAdapter {
|
||||
let server: http.Server | null = null;
|
||||
let setupConfig: ChannelSetup | null = null;
|
||||
const outboundBuffer: BufferedMessage[] = [];
|
||||
|
||||
function checkAuth(req: http.IncomingMessage, res: http.ServerResponse): boolean {
|
||||
if (!opts.authToken) return true;
|
||||
if (req.headers['authorization'] === `Bearer ${opts.authToken}`) return true;
|
||||
res
|
||||
.writeHead(401, { 'Content-Type': 'application/json; charset=utf-8' })
|
||||
.end(JSON.stringify({ error: 'Unauthorized' }));
|
||||
return false;
|
||||
}
|
||||
|
||||
function handlePost(req: http.IncomingMessage, res: http.ServerResponse): void {
|
||||
let body = '';
|
||||
req.on('data', (chunk) => (body += chunk));
|
||||
req.on('end', () => {
|
||||
let text: string;
|
||||
try {
|
||||
const parsed = JSON.parse(body) as { text?: string };
|
||||
text = parsed.text ?? '';
|
||||
} catch {
|
||||
res
|
||||
.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' })
|
||||
.end(JSON.stringify({ error: 'Invalid JSON' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!text.trim()) {
|
||||
res
|
||||
.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' })
|
||||
.end(JSON.stringify({ error: 'text required' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
const id = `emacs-${Date.now()}`;
|
||||
|
||||
const inbound: InboundMessage = {
|
||||
id,
|
||||
kind: 'chat',
|
||||
content: {
|
||||
text,
|
||||
sender: 'Emacs',
|
||||
senderId: `emacs:${opts.platformId}`,
|
||||
},
|
||||
timestamp,
|
||||
};
|
||||
|
||||
try {
|
||||
setupConfig?.onInbound(opts.platformId, null, inbound);
|
||||
} catch (err) {
|
||||
log.error('Emacs onInbound failed', { err });
|
||||
}
|
||||
|
||||
res
|
||||
.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' })
|
||||
.end(JSON.stringify({ messageId: id, timestamp: Date.now() }));
|
||||
});
|
||||
}
|
||||
|
||||
function handlePoll(url: URL, res: http.ServerResponse): void {
|
||||
const since = parseInt(url.searchParams.get('since') ?? '0', 10);
|
||||
const messages = outboundBuffer.filter((m) => m.timestamp > since);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }).end(JSON.stringify({ messages }));
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'emacs',
|
||||
channelType: 'emacs',
|
||||
supportsThreads: false,
|
||||
|
||||
async setup(config: ChannelSetup): Promise<void> {
|
||||
setupConfig = config;
|
||||
|
||||
server = http.createServer((req, res) => {
|
||||
if (!checkAuth(req, res)) return;
|
||||
|
||||
const url = new URL(req.url ?? '/', `http://localhost:${opts.port}`);
|
||||
if (req.method === 'POST' && url.pathname === '/api/message') {
|
||||
handlePost(req, res);
|
||||
} else if (req.method === 'GET' && url.pathname === '/api/messages') {
|
||||
handlePoll(url, res);
|
||||
} else {
|
||||
res
|
||||
.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' })
|
||||
.end(JSON.stringify({ error: 'Not found' }));
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server!.once('error', reject);
|
||||
server!.listen(opts.port, '127.0.0.1', () => {
|
||||
log.info('Emacs channel listening', { port: opts.port, platformId: opts.platformId });
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// Stamp a human-readable name on the messaging_groups row on first boot.
|
||||
config.onMetadata(opts.platformId, 'Emacs', false);
|
||||
},
|
||||
|
||||
async teardown(): Promise<void> {
|
||||
if (!server) return;
|
||||
await new Promise<void>((resolve) => server!.close(() => resolve()));
|
||||
server = null;
|
||||
log.info('Emacs channel stopped');
|
||||
},
|
||||
|
||||
isConnected(): boolean {
|
||||
return server?.listening ?? false;
|
||||
},
|
||||
|
||||
async deliver(platformId: string, _threadId: string | null, message: OutboundMessage): Promise<string | undefined> {
|
||||
if (platformId !== opts.platformId) {
|
||||
log.warn('Emacs deliver called with unknown platformId', { platformId });
|
||||
return undefined;
|
||||
}
|
||||
const text = extractText(message.content);
|
||||
if (!text) return undefined;
|
||||
|
||||
const id = `emacs-out-${Date.now()}`;
|
||||
outboundBuffer.push({ text, timestamp: Date.now() });
|
||||
while (outboundBuffer.length > OUTBOUND_BUFFER_MAX) outboundBuffer.shift();
|
||||
return id;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function extractText(content: unknown): string {
|
||||
if (typeof content === 'string') return content;
|
||||
if (content && typeof content === 'object') {
|
||||
const c = content as { text?: unknown };
|
||||
if (typeof c.text === 'string') return c.text;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
registerChannelAdapter('emacs', {
|
||||
factory: () => {
|
||||
const env = readEnvFile(['EMACS_ENABLED', 'EMACS_CHANNEL_PORT', 'EMACS_AUTH_TOKEN', 'EMACS_PLATFORM_ID']);
|
||||
const enabled = process.env.EMACS_ENABLED || env.EMACS_ENABLED;
|
||||
if (!enabled || enabled === 'false') return null;
|
||||
|
||||
const portStr = process.env.EMACS_CHANNEL_PORT || env.EMACS_CHANNEL_PORT || '8766';
|
||||
const port = parseInt(portStr, 10);
|
||||
const authToken = process.env.EMACS_AUTH_TOKEN || env.EMACS_AUTH_TOKEN || null;
|
||||
const platformId = process.env.EMACS_PLATFORM_ID || env.EMACS_PLATFORM_ID || 'default';
|
||||
|
||||
return createEmacsAdapter({ port, authToken, platformId });
|
||||
},
|
||||
});
|
||||
|
||||
export { createEmacsAdapter };
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Integration test for the gchat channel's single reach-in: the self-registration
|
||||
* import in the `src/channels/index.ts` barrel. Importing the barrel runs gchat.ts's
|
||||
* top-level `registerChannelAdapter('gchat', …)`; without the import the channel is
|
||||
* silently absent.
|
||||
*
|
||||
* Behavior, not structural: it imports the real barrel and asserts the registry
|
||||
* actually contains the channel. This reflects what happens at host boot — if the
|
||||
* `import './gchat.js';` line is deleted, or the barrel fails to evaluate for any
|
||||
* reason (so the channel genuinely would not register), this goes red. A structural
|
||||
* check of the import line would falsely pass in that second case.
|
||||
*
|
||||
* Importing the barrel is safe: registration is a pure top-level call, and gchat.ts
|
||||
* builds the SDK adapter / bridge only inside its factory (invoked at host startup),
|
||||
* never at import. It does require the adapter package (`@chat-adapter/gchat`) to be installed,
|
||||
* which holds in a composed install: the skill's `pnpm install` step runs before this
|
||||
* test — so this test also implicitly guards that dependency (an unmocked import throws
|
||||
* if the package is missing).
|
||||
*
|
||||
* gchat is a Chat SDK channel: gchat.ts also consumes a load-bearing *core* API —
|
||||
* `createChatSdkBridge(...)` from ./chat-sdk-bridge.js. That core-consumption is a
|
||||
* typed call, so the build/typecheck leg (`pnpm run build`) guards it against upstream
|
||||
* drift, not this test. Every Chat SDK channel follows this same shape.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { getRegisteredChannelNames } from './channel-registry.js';
|
||||
import './index.js'; // the real barrel — triggers every channel's self-registration
|
||||
|
||||
describe('gchat channel registration', () => {
|
||||
it('registers gchat via the channel barrel', () => {
|
||||
expect(getRegisteredChannelNames()).toContain('gchat');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Google Chat channel adapter (v2) — uses Chat SDK bridge.
|
||||
* Self-registers on import.
|
||||
*/
|
||||
import { createGoogleChatAdapter } from '@chat-adapter/gchat';
|
||||
|
||||
import { readEnvFile } from '../env.js';
|
||||
import { createChatSdkBridge } from './chat-sdk-bridge.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
|
||||
registerChannelAdapter('gchat', {
|
||||
factory: () => {
|
||||
const env = readEnvFile(['GCHAT_CREDENTIALS']);
|
||||
if (!env.GCHAT_CREDENTIALS) return null;
|
||||
const gchatAdapter = createGoogleChatAdapter({
|
||||
credentials: JSON.parse(env.GCHAT_CREDENTIALS),
|
||||
});
|
||||
return createChatSdkBridge({ adapter: gchatAdapter, concurrency: 'concurrent', supportsThreads: true });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Integration test for the github channel's single reach-in: the self-registration
|
||||
* import in the `src/channels/index.ts` barrel. Importing the barrel runs github.ts's
|
||||
* top-level `registerChannelAdapter('github', …)`; without the import the channel is
|
||||
* silently absent.
|
||||
*
|
||||
* Behavior, not structural: it imports the real barrel and asserts the registry
|
||||
* actually contains the channel. This reflects what happens at host boot — if the
|
||||
* `import './github.js';` line is deleted, or the barrel fails to evaluate for any
|
||||
* reason (so the channel genuinely would not register), this goes red. A structural
|
||||
* check of the import line would falsely pass in that second case.
|
||||
*
|
||||
* Importing the barrel is safe: registration is a pure top-level call, and github.ts
|
||||
* builds the SDK adapter / bridge only inside its factory (invoked at host startup),
|
||||
* never at import. It does require the adapter package (`@chat-adapter/github`) to be installed,
|
||||
* which holds in a composed install: the skill's `pnpm install` step runs before this
|
||||
* test — so this test also implicitly guards that dependency (an unmocked import throws
|
||||
* if the package is missing).
|
||||
*
|
||||
* github is a Chat SDK channel: github.ts also consumes a load-bearing *core* API —
|
||||
* `createChatSdkBridge(...)` from ./chat-sdk-bridge.js. That core-consumption is a
|
||||
* typed call, so the build/typecheck leg (`pnpm run build`) guards it against upstream
|
||||
* drift, not this test. Every Chat SDK channel follows this same shape.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { getRegisteredChannelNames } from './channel-registry.js';
|
||||
import './index.js'; // the real barrel — triggers every channel's self-registration
|
||||
|
||||
describe('github channel registration', () => {
|
||||
it('registers github via the channel barrel', () => {
|
||||
expect(getRegisteredChannelNames()).toContain('github');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* GitHub channel adapter (v2) — uses Chat SDK bridge.
|
||||
* PR comment threads as conversations.
|
||||
* Self-registers on import.
|
||||
*/
|
||||
import { createGitHubAdapter } from '@chat-adapter/github';
|
||||
|
||||
import { readEnvFile } from '../env.js';
|
||||
import { createChatSdkBridge } from './chat-sdk-bridge.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
|
||||
registerChannelAdapter('github', {
|
||||
factory: () => {
|
||||
const env = readEnvFile(['GITHUB_TOKEN', 'GITHUB_WEBHOOK_SECRET', 'GITHUB_BOT_USERNAME']);
|
||||
if (!env.GITHUB_TOKEN) return null;
|
||||
const githubAdapter = createGitHubAdapter({
|
||||
token: env.GITHUB_TOKEN,
|
||||
webhookSecret: env.GITHUB_WEBHOOK_SECRET,
|
||||
userName: env.GITHUB_BOT_USERNAME,
|
||||
});
|
||||
return createChatSdkBridge({ adapter: githubAdapter, concurrency: 'queue', supportsThreads: true });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Integration test for the imessage channel's single reach-in: the self-registration
|
||||
* import in the `src/channels/index.ts` barrel. Importing the barrel runs imessage.ts's
|
||||
* top-level `registerChannelAdapter('imessage', …)`; without the import the channel is
|
||||
* silently absent.
|
||||
*
|
||||
* Behavior, not structural: it imports the real barrel and asserts the registry
|
||||
* actually contains the channel. This reflects what happens at host boot — if the
|
||||
* `import './imessage.js';` line is deleted, or the barrel fails to evaluate for any
|
||||
* reason (so the channel genuinely would not register), this goes red. A structural
|
||||
* check of the import line would falsely pass in that second case.
|
||||
*
|
||||
* Importing the barrel is safe: registration is a pure top-level call, and imessage.ts
|
||||
* builds the SDK adapter / bridge only inside its factory (invoked at host startup),
|
||||
* never at import. It does require the adapter package (`chat-adapter-imessage`) to be installed,
|
||||
* which holds in a composed install: the skill's `pnpm install` step runs before this
|
||||
* test — so this test also implicitly guards that dependency (an unmocked import throws
|
||||
* if the package is missing).
|
||||
*
|
||||
* imessage is a Chat SDK channel: imessage.ts also consumes a load-bearing *core* API —
|
||||
* `createChatSdkBridge(...)` from ./chat-sdk-bridge.js. That core-consumption is a
|
||||
* typed call, so the build/typecheck leg (`pnpm run build`) guards it against upstream
|
||||
* drift, not this test. Every Chat SDK channel follows this same shape.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { getRegisteredChannelNames } from './channel-registry.js';
|
||||
import './index.js'; // the real barrel — triggers every channel's self-registration
|
||||
|
||||
describe('imessage channel registration', () => {
|
||||
it('registers imessage via the channel barrel', () => {
|
||||
expect(getRegisteredChannelNames()).toContain('imessage');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* iMessage channel adapter (v2) — uses Chat SDK bridge.
|
||||
* Supports local mode (macOS Full Disk Access) and remote mode (Photon API).
|
||||
* Self-registers on import.
|
||||
*/
|
||||
import { createiMessageAdapter } from 'chat-adapter-imessage';
|
||||
|
||||
import { readEnvFile } from '../env.js';
|
||||
import { createChatSdkBridge } from './chat-sdk-bridge.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
|
||||
registerChannelAdapter('imessage', {
|
||||
factory: () => {
|
||||
const env = readEnvFile(['IMESSAGE_ENABLED', 'IMESSAGE_LOCAL', 'IMESSAGE_SERVER_URL', 'IMESSAGE_API_KEY']);
|
||||
const isLocal = env.IMESSAGE_LOCAL !== 'false';
|
||||
if (isLocal && !env.IMESSAGE_ENABLED) return null;
|
||||
if (!isLocal && !env.IMESSAGE_SERVER_URL) return null;
|
||||
const rawAdapter = createiMessageAdapter({
|
||||
local: isLocal,
|
||||
serverUrl: env.IMESSAGE_SERVER_URL,
|
||||
apiKey: env.IMESSAGE_API_KEY,
|
||||
});
|
||||
// Polyfill channelIdFromThreadId (community adapter doesn't implement it)
|
||||
const imessageAdapter = Object.assign(rawAdapter, {
|
||||
channelIdFromThreadId: (threadId: string) => threadId,
|
||||
});
|
||||
return createChatSdkBridge({ adapter: imessageAdapter, concurrency: 'concurrent', supportsThreads: false });
|
||||
},
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user