Compare commits

...

2 Commits

Author SHA1 Message Date
Joe Fleming
3893d88365 chore: change run command, remove workspace cli argument 2026-03-13 15:42:16 -06:00
Joe Fleming
4f54c9837f chore: break up command handlers 2026-03-13 14:52:51 -06:00
7 changed files with 246 additions and 186 deletions

View File

@@ -42,7 +42,7 @@ mkdir -p ~/.nanobot
**2. Chat**
```bash
bun run start agent
bun run nanobot agent
```
That's it.
@@ -54,22 +54,21 @@ That's it.
Chat with the agent from your terminal. Does not require a running gateway.
```
bun run start agent [options]
bun run nanobot agent [options]
```
| Option | Description |
|--------|-------------|
| `-c, --config <path>` | Path to `config.json` (default: `~/.nanobot/config.json`) |
| `-m, --message <text>` | Send a single message and exit (non-interactive) |
| `-w, --workspace <path>` | Override the workspace directory |
| `-M, --model <model>` | Override the model for this session |
**Interactive mode** (default when no `-m` is given):
```bash
bun run start agent
bun run start agent -c ~/.nanobot-work/config.json
bun run start agent -w /tmp/scratch
bun run nanobot agent
bun run nanobot agent -c ~/.nanobot-work/config.json
bun run nanobot agent -w /tmp/scratch
```
Press `Ctrl+C` to exit.
@@ -77,8 +76,8 @@ Press `Ctrl+C` to exit.
**Single-shot mode:**
```bash
bun run start agent -m "What time is it in Tokyo?"
bun run start agent -m "Summarize the file ./notes.md"
bun run nanobot agent -m "What time is it in Tokyo?"
bun run nanobot agent -m "Summarize the file ./notes.md"
```
### `gateway` — Mattermost bot
@@ -86,7 +85,7 @@ bun run start agent -m "Summarize the file ./notes.md"
Runs the full stack: Mattermost WebSocket channel, agent loop, cron scheduler, and heartbeat.
```
bun run start gateway [options]
bun run nanobot gateway [options]
```
| Option | Description |
@@ -94,8 +93,8 @@ bun run start gateway [options]
| `-c, --config <path>` | Path to `config.json` (default: `~/.nanobot/config.json`) |
```bash
bun run start gateway
bun run start gateway -c ~/.nanobot-work/config.json
bun run nanobot gateway
bun run nanobot gateway -c ~/.nanobot-work/config.json
```
Handles `SIGINT` / `SIGTERM` for graceful shutdown.
@@ -110,7 +109,6 @@ Environment variable overrides:
|----------|-------------------|
| `NANOBOT_CONFIG` | path to config file |
| `NANOBOT_MODEL` | `agent.model` |
| `NANOBOT_WORKSPACE` | `agent.workspacePath` |
### Full config reference
@@ -198,7 +196,7 @@ For Ollama, set `providers.ollama.apiBase` (default: `http://localhost:11434/api
}
```
4. Run `bun run start gateway`
4. Run `bun run nanobot gateway`
`allowFrom` controls which users the bot responds to. Use `["*"]` to allow all users.
@@ -233,10 +231,10 @@ Run separate instances with different configs — useful for isolated workspaces
```bash
# Instance A
bun run start gateway -c ~/.nanobot-a/config.json
bun run nanobot gateway -c ~/.nanobot-a/config.json
# Instance B
bun run start gateway -c ~/.nanobot-b/config.json
bun run nanobot gateway -c ~/.nanobot-b/config.json
```
Each instance needs its own config file. Set a different `agent.workspacePath` per instance to keep memory, sessions, and cron jobs isolated:
@@ -252,10 +250,10 @@ Each instance needs its own config file. Set a different `agent.workspacePath` p
To run a local CLI session against a specific instance:
```bash
bun run start agent -c ~/.nanobot-a/config.json -m "Hello"
bun run nanobot agent -c ~/.nanobot-a/config.json -m "Hello"
# Temporarily override the workspace for a one-off run
bun run start agent -c ~/.nanobot-a/config.json -w /tmp/scratch
bun run nanobot agent -c ~/.nanobot-a/config.json -w /tmp/scratch
```
## Linux service (systemd)

View File

@@ -49,6 +49,28 @@ Inbound and outbound messages are passed through a typed `AsyncQueue<T>`. The qu
## Logging Pattern
Use `console.error` / `console.warn` / `console.info` / `console.debug` — no external logger. Color via `picocolors` in CLI output only.
## CLI Command Pattern
Each command is in its own file with a registration function:
```ts
// src/cli/agent.ts
export function agentCommand(program: Command, config: Config, workspace: string): void {
program.command('agent')
.description('...')
.option('-m, --message <text>', 'Single message to process')
.action(async (opts) => { /* ... */ })
}
// src/cli/commands.ts (bootstrap)
export function createCli(): Command {
const program = new Command('nanobot')...
const config = loadConfig(opts.config);
const workspace = resolveWorkspacePath(config.agent.workspacePath);
gatewayCommand(program, config, workspace);
agentCommand(program, config, workspace);
return program;
}
```
## File Layout
```
src/
@@ -67,8 +89,12 @@ src/
tools/base.ts + filesystem.ts + shell.ts + web.ts + message.ts + spawn.ts + cron.ts
channels/
base.ts + mattermost.ts + manager.ts
cli/commands.ts
index.ts
cli/
types.ts # CommandHandler type
commands.ts # Bootstrap - loads config, registers commands
agent.ts # agentCommand() - interactive/single-shot mode
gateway.ts # gatewayCommand() - full runtime with Mattermost
index.ts
templates/ (SOUL.md, AGENTS.md, USER.md, TOOLS.md, HEARTBEAT.md, memory/MEMORY.md)
skills/ (copied from Python repo)
```

View File

@@ -4,7 +4,7 @@
"type": "module",
"module": "index.ts",
"scripts": {
"start": "bun run index.ts",
"nanobot": "bun run index.ts",
"dev": "bun --watch run index.ts",
"typecheck": "tsc --noEmit",
"fmt": "oxfmt --check",

85
src/cli/agent.ts Normal file
View File

@@ -0,0 +1,85 @@
import { createInterface } from 'node:readline';
import { Command } from 'commander';
import pc from 'picocolors';
import { AgentLoop } from '../agent/loop.ts';
import { MessageBus } from '../bus/queue.ts';
import type { Config } from '../config/types.ts';
import { makeProvider } from '../provider/index.ts';
export function agentCommand(program: Command, config: Config, workspace: string): void {
program
.command('agent')
.description('Run the agent interactively or send a single message.')
.option('-m, --message <text>', 'Single message to process (non-interactive)')
.option('-M, --model <model>', 'Model override')
.action(async (opts: { config?: string; message?: string; model?: string }) => {
const model = opts.model ?? config.agent.model;
const provider = makeProvider(
config.providers,
model,
config.agent.maxTokens,
config.agent.temperature,
);
const bus = new MessageBus();
const agentLoop = new AgentLoop({
bus,
provider,
workspace,
model,
maxIterations: config.agent.maxToolIterations,
contextWindowTokens: config.agent.contextWindowTokens,
braveApiKey: config.tools.web.braveApiKey,
webProxy: config.tools.web.proxy,
execConfig: config.tools.exec,
restrictToWorkspace: config.tools.restrictToWorkspace,
});
// Single-shot mode
if (opts.message) {
const result = await agentLoop.processDirect(opts.message);
console.log(result);
return;
}
// Interactive mode
console.info(pc.green('nanobot interactive mode. Type your message, Ctrl+C to exit.'));
const rl = createInterface({ input: process.stdin, output: process.stdout });
const promptUser = () => {
rl.question(pc.cyan('You: '), async (input) => {
const text = input.trim();
if (!text) {
promptUser();
return;
}
const onProgress = async (content: string, opts?: { toolHint?: boolean }) => {
if (opts?.toolHint) {
process.stdout.write(pc.dim(` [${content}]\n`));
} else {
process.stdout.write(pc.dim(` ${content}\n`));
}
};
const result = await agentLoop.processDirect(
text,
'cli:interactive',
'cli',
'interactive',
onProgress,
);
console.log(pc.bold('Bot:'), result);
promptUser();
});
};
rl.on('close', () => {
agentLoop.stop();
process.exit(0);
});
promptUser();
});
}

View File

@@ -1,178 +1,25 @@
import { mkdirSync } from 'node:fs';
import { createInterface } from 'node:readline';
import { Command } from 'commander';
import pc from 'picocolors';
import { AgentLoop } from '../agent/loop.ts';
import { MessageBus } from '../bus/queue.ts';
import { MattermostChannel } from '../channels/mattermost.ts';
import { ChannelManager } from '../channels/manager.ts';
import { loadConfig, resolveWorkspacePath } from '../config/loader.ts';
import { CronService } from '../cron/service.ts';
import { HeartbeatService } from '../heartbeat/service.ts';
import { makeProvider } from '../provider/index.ts';
import { agentCommand } from './agent.ts';
import { gatewayCommand } from './gateway.ts';
import pc from 'picocolors';
export function createCli(): Command {
const program = new Command('nanobot').description('nanobot — personal AI assistant').version('1.0.0');
// ---------------------------------------------------------------------------
// gateway — full runtime: Mattermost + cron + heartbeat
// ---------------------------------------------------------------------------
program
.command('gateway')
.description('Start the full gateway: Mattermost channel, agent loop, cron, and heartbeat.')
const program = new Command('nanobot')
.description('nanobot — personal AI assistant')
.option('-c, --config <path>', 'Path to config.json')
.action(async (opts: { config?: string }) => {
const config = loadConfig(opts.config);
.version('1.0.0');
const globalOpts = program.opts();
const config = loadConfig(globalOpts.config);
const workspace = resolveWorkspacePath(config.agent.workspacePath);
console.info(pc.magenta(`workspace path: ${workspace}`));
mkdirSync(workspace, { recursive: true });
const provider = makeProvider(config.providers, config.agent.model, config.agent.maxTokens, config.agent.temperature);
const bus = new MessageBus();
const channelManager = new ChannelManager(bus);
// Cron service
const cronService = new CronService(workspace, async (job) => {
bus.publishInbound({
channel: 'system',
senderId: 'cron',
chatId: `cli:cron_${job.id}`,
content: job.payload.message || `Cron job "${job.name}" triggered.`,
metadata: { cronJobId: job.id },
});
});
const agentLoop = new AgentLoop({
bus,
provider,
workspace,
model: config.agent.model,
maxIterations: config.agent.maxToolIterations,
contextWindowTokens: config.agent.contextWindowTokens,
braveApiKey: config.tools.web.braveApiKey,
webProxy: config.tools.web.proxy,
execConfig: config.tools.exec,
cronService,
restrictToWorkspace: config.tools.restrictToWorkspace,
sendProgress: config.channels.sendProgress,
sendToolHints: config.channels.sendToolHints,
});
// Mattermost
if (config.channels.mattermost) {
const mm = new MattermostChannel(bus, config.channels.mattermost);
channelManager.register(mm);
} else {
console.warn(pc.yellow('[gateway] No Mattermost config found. Running without channels.'));
}
// Heartbeat
let heartbeat: HeartbeatService | null = null;
if (config.heartbeat.enabled) {
heartbeat = new HeartbeatService({
workspace,
provider,
model: config.agent.model,
intervalMinutes: config.heartbeat.intervalMinutes,
onExecute: async (tasks) => {
const content = tasks.length > 0 ? `Heartbeat tasks:\n${tasks.map((t, i) => `${i + 1}. ${t}`).join('\n')}` : 'Heartbeat tick — check for anything to do.';
return agentLoop.processDirect(content, 'system:heartbeat', 'system', 'heartbeat');
},
onNotify: async (_result) => {
// Result already delivered via processDirect / message tool
},
});
}
// Graceful shutdown
const shutdown = () => {
console.info('\n[gateway] Shutting down...');
agentLoop.stop();
channelManager.stopAll();
heartbeat?.stop();
cronService.stop();
process.exit(0);
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
console.info(pc.green('[gateway] Starting...'));
cronService.start();
heartbeat?.start();
await Promise.all([agentLoop.run(), channelManager.startAll()]);
});
// ---------------------------------------------------------------------------
// agent — interactive CLI or single-shot mode
// ---------------------------------------------------------------------------
program
.command('agent')
.description('Run the agent interactively or send a single message.')
.option('-c, --config <path>', 'Path to config.json')
.option('-m, --message <text>', 'Single message to process (non-interactive)')
.option('-w, --workspace <path>', 'Workspace path override')
.option('-M, --model <model>', 'Model override')
.action(async (opts: { config?: string; message?: string; workspace?: string; model?: string }) => {
const config = loadConfig(opts.config);
const workspaceRaw = opts.workspace ?? config.agent.workspacePath;
const workspace = resolveWorkspacePath(workspaceRaw);
mkdirSync(workspace, { recursive: true });
const model = opts.model ?? config.agent.model;
const provider = makeProvider(config.providers, model, config.agent.maxTokens, config.agent.temperature);
const bus = new MessageBus();
const agentLoop = new AgentLoop({
bus,
provider,
workspace,
model,
maxIterations: config.agent.maxToolIterations,
contextWindowTokens: config.agent.contextWindowTokens,
braveApiKey: config.tools.web.braveApiKey,
webProxy: config.tools.web.proxy,
execConfig: config.tools.exec,
restrictToWorkspace: config.tools.restrictToWorkspace,
});
// Single-shot mode
if (opts.message) {
const result = await agentLoop.processDirect(opts.message);
console.log(result);
return;
}
// Interactive mode
console.info(pc.green('nanobot interactive mode. Type your message, Ctrl+C to exit.'));
const rl = createInterface({ input: process.stdin, output: process.stdout });
const promptUser = () => {
rl.question(pc.cyan('You: '), async (input) => {
const text = input.trim();
if (!text) { promptUser(); return; }
const onProgress = async (content: string, opts?: { toolHint?: boolean }) => {
if (opts?.toolHint) {
process.stdout.write(pc.dim(` [${content}]\n`));
} else {
process.stdout.write(pc.dim(` ${content}\n`));
}
};
const result = await agentLoop.processDirect(text, 'cli:interactive', 'cli', 'interactive', onProgress);
console.log(pc.bold('Bot:'), result);
promptUser();
});
};
rl.on('close', () => {
agentLoop.stop();
process.exit(0);
});
promptUser();
});
gatewayCommand(program, config, workspace);
agentCommand(program, config, workspace);
return program;
}

100
src/cli/gateway.ts Normal file
View File

@@ -0,0 +1,100 @@
import { Command } from 'commander';
import pc from 'picocolors';
import { AgentLoop } from '../agent/loop.ts';
import { MessageBus } from '../bus/queue.ts';
import { MattermostChannel } from '../channels/mattermost.ts';
import { ChannelManager } from '../channels/manager.ts';
import type { Config } from '../config/types.ts';
import { CronService } from '../cron/service.ts';
import { HeartbeatService } from '../heartbeat/service.ts';
import { makeProvider } from '../provider/index.ts';
export function gatewayCommand(program: Command, config: Config, workspace: string): void {
program
.command('gateway')
.description('Start the full gateway: Mattermost channel, agent loop, cron, and heartbeat.')
.action(async (_opts: { config?: string }) => {
const provider = makeProvider(
config.providers,
config.agent.model,
config.agent.maxTokens,
config.agent.temperature,
);
const bus = new MessageBus();
const channelManager = new ChannelManager(bus);
// Cron service
const cronService = new CronService(workspace, async (job) => {
bus.publishInbound({
channel: 'system',
senderId: 'cron',
chatId: `cli:cron_${job.id}`,
content: job.payload.message || `Cron job "${job.name}" triggered.`,
metadata: { cronJobId: job.id },
});
});
const agentLoop = new AgentLoop({
bus,
provider,
workspace,
model: config.agent.model,
maxIterations: config.agent.maxToolIterations,
contextWindowTokens: config.agent.contextWindowTokens,
braveApiKey: config.tools.web.braveApiKey,
webProxy: config.tools.web.proxy,
execConfig: config.tools.exec,
cronService,
restrictToWorkspace: config.tools.restrictToWorkspace,
sendProgress: config.channels.sendProgress,
sendToolHints: config.channels.sendToolHints,
});
// Mattermost
if (config.channels.mattermost) {
const mm = new MattermostChannel(bus, config.channels.mattermost);
channelManager.register(mm);
} else {
console.warn(pc.yellow('[gateway] No Mattermost config found. Running without channels.'));
}
// Heartbeat
let heartbeat: HeartbeatService | null = null;
if (config.heartbeat.enabled) {
heartbeat = new HeartbeatService({
workspace,
provider,
model: config.agent.model,
intervalMinutes: config.heartbeat.intervalMinutes,
onExecute: async (tasks) => {
const content =
tasks.length > 0
? `Heartbeat tasks:\n${tasks.map((t, i) => `${i + 1}. ${t}`).join('\n')}`
: 'Heartbeat tick — check for anything to do.';
return agentLoop.processDirect(content, 'system:heartbeat', 'system', 'heartbeat');
},
onNotify: async (_result) => {
// Result already delivered via processDirect / message tool
},
});
}
// Graceful shutdown
const shutdown = () => {
console.info('\n[gateway] Shutting down...');
agentLoop.stop();
channelManager.stopAll();
heartbeat?.stop();
cronService.stop();
process.exit(0);
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
console.info(pc.green('[gateway] Starting...'));
cronService.start();
heartbeat?.start();
await Promise.all([agentLoop.run(), channelManager.startAll()]);
});
}

4
src/cli/types.ts Normal file
View File

@@ -0,0 +1,4 @@
import type { Command } from 'commander';
import type { Config } from '../config/types.ts';
export type CommandHandler = (program: Command, config: Config, workspace: string) => void;