chore: break up command handlers
This commit is contained in:
@@ -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,7 +89,11 @@ 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
|
||||
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)
|
||||
|
||||
92
src/cli/agent.ts
Normal file
92
src/cli/agent.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
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 type { Config } from '../config/types.ts';
|
||||
import { makeProvider } from '../provider/index.ts';
|
||||
|
||||
export function agentCommand(program: Command, config: Config, workspace: string): void {
|
||||
mkdirSync(workspace, { recursive: true });
|
||||
|
||||
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 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();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,178 +1,21 @@
|
||||
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';
|
||||
|
||||
export function createCli(): Command {
|
||||
const program = new Command('nanobot').description('nanobot — personal AI assistant').version('1.0.0');
|
||||
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.')
|
||||
.option('-c, --config <path>', 'Path to config.json')
|
||||
.action(async (opts: { config?: string }) => {
|
||||
const config = loadConfig(opts.config);
|
||||
const globalOpts = program.opts();
|
||||
const config = loadConfig(globalOpts.config);
|
||||
const workspace = resolveWorkspacePath(config.agent.workspacePath);
|
||||
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;
|
||||
}
|
||||
|
||||
104
src/cli/gateway.ts
Normal file
104
src/cli/gateway.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { mkdirSync } from 'node:fs';
|
||||
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 {
|
||||
mkdirSync(workspace, { recursive: true });
|
||||
|
||||
program
|
||||
.command('gateway')
|
||||
.description('Start the full gateway: Mattermost channel, agent loop, cron, and heartbeat.')
|
||||
.option('-c, --config <path>', 'Path to config.json')
|
||||
.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
4
src/cli/types.ts
Normal 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;
|
||||
Reference in New Issue
Block a user