Compare commits

..

5 Commits

Author SHA1 Message Date
Joe Fleming
9ac92ed536 chore: use more conventional XDG_CONFIG_HOME for config 2026-03-13 19:22:37 -06:00
Joe Fleming
398b98393a chore: remove nested ternaries 2026-03-13 19:13:50 -06:00
Joe Fleming
2d99d17d60 feat: create onboard script 2026-03-13 18:49:15 -06:00
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
20 changed files with 310 additions and 135 deletions

4
.gitignore vendored
View File

@@ -1,6 +1,10 @@
# dependencies (bun install) # dependencies (bun install)
node_modules node_modules
# editors
.vscode
.openvscode-server
# output # output
out out
dist dist

View File

@@ -5,7 +5,8 @@
"correctness": "warn" "correctness": "warn"
}, },
"rules": { "rules": {
"eslint/no-unused-vars": "error" "eslint/no-unused-vars": "error",
"unicorn/no-nested-ternary": "error"
}, },
"options": { "options": {
"typeAware": true, "typeAware": true,

View File

@@ -21,10 +21,10 @@ bun install # or use `mise install`
**1. Create a config file** **1. Create a config file**
```bash ```bash
mkdir -p ~/.nanobot mkdir -p ~/.config/nanobot
``` ```
`~/.nanobot/config.json`: `~/.config/nanobot/config.json`:
```json ```json
{ {
@@ -42,7 +42,7 @@ mkdir -p ~/.nanobot
**2. Chat** **2. Chat**
```bash ```bash
bun run start agent bun run nanobot agent
``` ```
That's it. That's it.
@@ -54,22 +54,21 @@ That's it.
Chat with the agent from your terminal. Does not require a running gateway. 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 | | Option | Description |
|--------|-------------| |--------|-------------|
| `-c, --config <path>` | Path to `config.json` (default: `~/.nanobot/config.json`) | | `-c, --config <path>` | Path to `config.json` (default: `~/.config/nanobot/config.json`) |
| `-m, --message <text>` | Send a single message and exit (non-interactive) | | `-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 | | `-M, --model <model>` | Override the model for this session |
**Interactive mode** (default when no `-m` is given): **Interactive mode** (default when no `-m` is given):
```bash ```bash
bun run start agent bun run nanobot agent
bun run start agent -c ~/.nanobot-work/config.json bun run nanobot agent -c ~/.config/nanobot-work/config.json
bun run start agent -w /tmp/scratch bun run nanobot agent -w /tmp/scratch
``` ```
Press `Ctrl+C` to exit. Press `Ctrl+C` to exit.
@@ -77,8 +76,8 @@ Press `Ctrl+C` to exit.
**Single-shot mode:** **Single-shot mode:**
```bash ```bash
bun run start agent -m "What time is it in Tokyo?" bun run nanobot agent -m "What time is it in Tokyo?"
bun run start agent -m "Summarize the file ./notes.md" bun run nanobot agent -m "Summarize the file ./notes.md"
``` ```
### `gateway` — Mattermost bot ### `gateway` — Mattermost bot
@@ -86,23 +85,23 @@ bun run start agent -m "Summarize the file ./notes.md"
Runs the full stack: Mattermost WebSocket channel, agent loop, cron scheduler, and heartbeat. 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 | | Option | Description |
|--------|-------------| |--------|-------------|
| `-c, --config <path>` | Path to `config.json` (default: `~/.nanobot/config.json`) | | `-c, --config <path>` | Path to `config.json` (default: `~/.config/nanobot/config.json`) |
```bash ```bash
bun run start gateway bun run nanobot gateway
bun run start gateway -c ~/.nanobot-work/config.json bun run nanobot gateway -c ~/.config/nanobot-work/config.json
``` ```
Handles `SIGINT` / `SIGTERM` for graceful shutdown. Handles `SIGINT` / `SIGTERM` for graceful shutdown.
## Configuration ## Configuration
Config file: `~/.nanobot/config.json` (or pass `-c <path>` to any command). Config file: `~/.config/nanobot/config.json` (or pass `-c <path>` to any command).
Environment variable overrides: Environment variable overrides:
@@ -110,7 +109,6 @@ Environment variable overrides:
|----------|-------------------| |----------|-------------------|
| `NANOBOT_CONFIG` | path to config file | | `NANOBOT_CONFIG` | path to config file |
| `NANOBOT_MODEL` | `agent.model` | | `NANOBOT_MODEL` | `agent.model` |
| `NANOBOT_WORKSPACE` | `agent.workspacePath` |
### Full config reference ### Full config reference
@@ -118,7 +116,7 @@ Environment variable overrides:
{ {
"agent": { "agent": {
"model": "openrouter/anthropic/claude-sonnet-4-5", "model": "openrouter/anthropic/claude-sonnet-4-5",
"workspacePath": "~/.nanobot", "workspacePath": "~/.config/nanobot",
"maxTokens": 4096, "maxTokens": 4096,
"contextWindowTokens": 65536, "contextWindowTokens": 65536,
"temperature": 0.7, "temperature": 0.7,
@@ -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. `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 ```bash
# Instance A # Instance A
bun run start gateway -c ~/.nanobot-a/config.json bun run nanobot gateway -c ~/.config/nanobot-a/config.json
# Instance B # Instance B
bun run start gateway -c ~/.nanobot-b/config.json bun run nanobot gateway -c ~/.config/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: Each instance needs its own config file. Set a different `agent.workspacePath` per instance to keep memory, sessions, and cron jobs isolated:
@@ -244,7 +242,7 @@ Each instance needs its own config file. Set a different `agent.workspacePath` p
```json ```json
{ {
"agent": { "agent": {
"workspacePath": "~/.nanobot-a" "workspacePath": "~/.config/nanobot-a"
} }
} }
``` ```
@@ -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: To run a local CLI session against a specific instance:
```bash ```bash
bun run start agent -c ~/.nanobot-a/config.json -m "Hello" bun run nanobot agent -c ~/.config/nanobot-a/config.json -m "Hello"
# Temporarily override the workspace for a one-off run # 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 ~/.config/nanobot-a/config.json -w /tmp/scratch
``` ```
## Linux service (systemd) ## Linux service (systemd)

View File

@@ -123,7 +123,7 @@ Wraps Vercel AI SDK `generateText()` with:
- Normalized `LLMResponse` type - Normalized `LLMResponse` type
### SessionManager ### SessionManager
Persists conversation history to JSONL files in `~/.nanobot/sessions/`. Persists conversation history to JSONL files in `~/.config/nanobot/sessions/`.
- Key format: `{channel}:{chatId}` (e.g., `mattermost:abc123`) - Key format: `{channel}:{chatId}` (e.g., `mattermost:abc123`)
- Supports history truncation for context window limits - Supports history truncation for context window limits
@@ -136,7 +136,7 @@ When session history exceeds token limits, summarizes old messages and archives
## Configuration ## Configuration
- File: `~/.nanobot/config.json` - File: `~/.config/nanobot/config.json`
- Validation: Zod schemas in `src/config/types.ts` - Validation: Zod schemas in `src/config/types.ts`
- Env overrides: `NANOBOT_MODEL`, `NANOBOT_WORKSPACE`, `NANOBOT_CONFIG` - Env overrides: `NANOBOT_MODEL`, `NANOBOT_WORKSPACE`, `NANOBOT_CONFIG`

View File

@@ -129,7 +129,7 @@ const timeout = parseInt(strArg(args, 'timeout', '30'), 10);
## Session Persistence ## Session Persistence
- Format: JSONL (one JSON object per line) - Format: JSONL (one JSON object per line)
- Location: `~/.nanobot/sessions/{sessionKey}.jsonl` - Location: `~/.config/nanobot/sessions/{sessionKey}.jsonl`
- Tool results truncated at 16,000 characters - Tool results truncated at 16,000 characters
- Memory consolidation triggered when approaching context window limit - Memory consolidation triggered when approaching context window limit
@@ -147,5 +147,5 @@ Max 3 attempts with exponential backoff.
1. CLI flags (`-c`, `-m`, `-w`, `-M`) 1. CLI flags (`-c`, `-m`, `-w`, `-M`)
2. Environment variables (`NANOBOT_CONFIG`, `NANOBOT_MODEL`, `NANOBOT_WORKSPACE`) 2. Environment variables (`NANOBOT_CONFIG`, `NANOBOT_MODEL`, `NANOBOT_WORKSPACE`)
3. Config file (`~/.nanobot/config.json`) 3. Config file (`~/.config/nanobot/config.json`)
4. Zod schema defaults 4. Zod schema defaults

View File

@@ -19,9 +19,13 @@ Docs directory created with 4 files (PRD.md, Architecture.md, API.md, Discoverie
- **lint**: all `${err}` in template literals → `${String(err)}`; `String(args['key'] ?? '')``strArg(args, 'key')` helper; unused `onProgress` param → `_onProgress`; WebSocket `onerror` `err` type is `Event` → use `err.type` - **lint**: all `${err}` in template literals → `${String(err)}`; `String(args['key'] ?? '')``strArg(args, 'key')` helper; unused `onProgress` param → `_onProgress`; WebSocket `onerror` `err` type is `Event` → use `err.type`
## Work Queue (next steps) ## Work Queue (next steps)
1. [ ] Runtime smoke test: `bun run start --help` 1. [x] Create workspace helper module (src/workspace.ts) with ensureWorkspace() and syncTemplates()
2. [ ] Test with a real Mattermost config (optional — user can do this) 2. [x] Create onboard command (src/cli/onboard.ts) with path argument and directory-not-empty guard
3. [ ] Write sample `~/.nanobot/config.json` in README or docs 3. [x] Update src/cli/commands.ts to use ensureWorkspace() instead of inline mkdirSync
4. [x] Typecheck and lint pass (0 errors)
5. [x] Runtime smoke test: `bun run nanobot --help`
6. [x] Test onboard command: `bun run nanobot onboard [path]`
7. [ ] Test with a real Mattermost config (optional — user can do this)
## Key Decisions Made ## Key Decisions Made
- Mattermost channel uses raw WebSocket + fetch (no mattermostdriver, no SSL hack) - Mattermost channel uses raw WebSocket + fetch (no mattermostdriver, no SSL hack)

View File

@@ -12,7 +12,7 @@ A personal AI assistant that connects to Mattermost (via WebSocket) and runs an
## Key design principles (from Python codebase) ## Key design principles (from Python codebase)
- Ultra-lightweight: minimal dependencies, small codebase - Ultra-lightweight: minimal dependencies, small codebase
- Provider-agnostic: works with Anthropic, OpenAI, Google, Ollama, OpenRouter - Provider-agnostic: works with Anthropic, OpenAI, Google, Ollama, OpenRouter
- Workspace-centric: everything lives in a configurable workspace directory (`~/.nanobot/`) - Workspace-centric: everything lives in a configurable workspace directory (`~/.config/nanobot/`)
- SOUL/AGENTS/USER/TOOLS.md: workspace markdown files that define the bot's personality and rules - SOUL/AGENTS/USER/TOOLS.md: workspace markdown files that define the bot's personality and rules
- Memory is just markdown files (`MEMORY.md`, `HISTORY.md`) — no database - Memory is just markdown files (`MEMORY.md`, `HISTORY.md`) — no database

View File

@@ -39,10 +39,19 @@
### 🔄 In Progress ### 🔄 In Progress
- Nothing - Nothing
### ✅ Done
- Created src/workspace.ts with ensureWorkspace(), syncTemplates(), checkWorkspaceEmpty()
- Created src/cli/onboard.ts command with path argument
- Updated src/cli/commands.ts to use ensureWorkspace() helper
- Typecheck: 0 errors
- Lint: 0 warnings
### 🔄 In Progress
- Testing onboard command
### ⏳ Pending ### ⏳ Pending
- Runtime smoke test: `bun run start --help` - Runtime smoke test: `bun run nanobot --help`
- Integration test with a real Mattermost server - Integration test with a real Mattermost server
- Sample `~/.nanobot/config.json` documentation
## Known Issues / Risks ## Known Issues / Risks
- `ollama-ai-provider` v1.2.0 returns `LanguageModelV1` (not V2/V3 as expected by AI SDK v6) — cast used at call site. Works at runtime. - `ollama-ai-provider` v1.2.0 returns `LanguageModelV1` (not V2/V3 as expected by AI SDK v6) — cast used at call site. Works at runtime.

View File

@@ -34,7 +34,7 @@ Inbound and outbound messages are passed through a typed `AsyncQueue<T>`. The qu
- Returns a normalized `LLMResponse` type - Returns a normalized `LLMResponse` type
## Config Pattern ## Config Pattern
- Config file: `~/.nanobot/config.json` (camelCase JSON) - Config file: `~/.config/nanobot/config.json` (camelCase JSON)
- Loaded with `loadConfig()`, validated by Zod, returns inferred `Config` type - Loaded with `loadConfig()`, validated by Zod, returns inferred `Config` type
- `NANOBOT_` env vars can override fields (e.g. `NANOBOT_MODEL`) - `NANOBOT_` env vars can override fields (e.g. `NANOBOT_MODEL`)
@@ -49,6 +49,28 @@ Inbound and outbound messages are passed through a typed `AsyncQueue<T>`. The qu
## Logging Pattern ## Logging Pattern
Use `console.error` / `console.warn` / `console.info` / `console.debug` — no external logger. Color via `picocolors` in CLI output only. 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 ## File Layout
``` ```
src/ src/
@@ -67,7 +89,11 @@ src/
tools/base.ts + filesystem.ts + shell.ts + web.ts + message.ts + spawn.ts + cron.ts tools/base.ts + filesystem.ts + shell.ts + web.ts + message.ts + spawn.ts + cron.ts
channels/ channels/
base.ts + mattermost.ts + manager.ts 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 index.ts
templates/ (SOUL.md, AGENTS.md, USER.md, TOOLS.md, HEARTBEAT.md, memory/MEMORY.md) templates/ (SOUL.md, AGENTS.md, USER.md, TOOLS.md, HEARTBEAT.md, memory/MEMORY.md)
skills/ (copied from Python repo) skills/ (copied from Python repo)

View File

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

View File

@@ -27,21 +27,21 @@ npx --yes clawhub@latest search "web scraping" --limit 5
## Install ## Install
```bash ```bash
npx --yes clawhub@latest install <slug> --workdir ~/.nanobot/workspace npx --yes clawhub@latest install <slug> --workdir ~/.config/nanobot/workspace
``` ```
Replace `<slug>` with the skill name from search results. This places the skill into `~/.nanobot/workspace/skills/`, where nanobot loads workspace skills from. Always include `--workdir`. Replace `<slug>` with the skill name from search results. This places the skill into `~/.config/nanobot/workspace/skills/`, where nanobot loads workspace skills from. Always include `--workdir`.
## Update ## Update
```bash ```bash
npx --yes clawhub@latest update --all --workdir ~/.nanobot/workspace npx --yes clawhub@latest update --all --workdir ~/.config/nanobot/workspace
``` ```
## List installed ## List installed
```bash ```bash
npx --yes clawhub@latest list --workdir ~/.nanobot/workspace npx --yes clawhub@latest list --workdir ~/.config/nanobot/workspace
``` ```
## Notes ## Notes
@@ -49,5 +49,5 @@ npx --yes clawhub@latest list --workdir ~/.nanobot/workspace
- Requires Node.js (`npx` comes with it). - Requires Node.js (`npx` comes with it).
- No API key needed for search and install. - No API key needed for search and install.
- Login (`npx --yes clawhub@latest login`) is only required for publishing. - Login (`npx --yes clawhub@latest login`) is only required for publishing.
- `--workdir ~/.nanobot/workspace` is critical — without it, skills install to the current directory instead of the nanobot workspace. - `--workdir ~/.config/nanobot/workspace` is critical — without it, skills install to the current directory instead of the nanobot workspace.
- After install, remind the user to start a new session to load the skill. - After install, remind the user to start a new session to load the skill.

View File

@@ -364,13 +364,13 @@ export class AgentLoop {
if (response.content) await onProgress(response.content); if (response.content) await onProgress(response.content);
const hint = response.toolCalls const hint = response.toolCalls
.map((tc) => { .map((tc) => {
let display = ''
const firstVal = Object.values(tc.arguments)[0]; const firstVal = Object.values(tc.arguments)[0];
const display = if (typeof firstVal === 'string') {
typeof firstVal === 'string' display = `"${firstVal.slice(0, 40) + (firstVal.length > 40 ? '…' : '')}"`
? firstVal.length > 40 }
? `"${firstVal.slice(0, 40)}…"`
: `"${firstVal}"`
: '';
return `${tc.name}(${display})`; return `${tc.name}(${display})`;
}) })
.join(', '); .join(', ');

View File

@@ -1,24 +1,21 @@
import { mkdirSync } from 'node:fs';
import { createInterface } from 'node:readline'; import { createInterface } from 'node:readline';
import { Command } from 'commander'; import { Command } from 'commander';
import pc from 'picocolors'; import pc from 'picocolors';
import { AgentLoop } from '../agent/loop.ts'; import { AgentLoop } from '../agent/loop.ts';
import { MessageBus } from '../bus/queue.ts'; import { MessageBus } from '../bus/queue.ts';
import type { Config } from '../config/types.ts';
import { makeProvider } from '../provider/index.ts'; import { makeProvider } from '../provider/index.ts';
import type { Config } from '../config/types.ts';
export function agentCommand(program: Command, config: Config, workspace: string): void { export function agentCommand(program: Command, config: Config, workspace: string): void {
mkdirSync(workspace, { recursive: true });
program program
.command('agent') .command('agent')
.description('Run the agent interactively or send a single message.') .description('Run the agent interactively or send a single message.')
.option('-c, --config <path>', 'Path to config.json') .option('-c, --config <path>', 'Path to config.json')
.option('-m, --message <text>', 'Single message to process (non-interactive)') .option('-m, --message <text>', 'Single message to process (non-interactive)')
.option('-w, --workspace <path>', 'Workspace path override')
.option('-M, --model <model>', 'Model override') .option('-M, --model <model>', 'Model override')
.action( .action(async (opts: { config?: string; message?: string; model?: string }) => {
async (opts: { config?: string; message?: string; workspace?: string; model?: string }) => { console.info(pc.magenta(`workspace path: ${workspace}`));
const model = opts.model ?? config.agent.model; const model = opts.model ?? config.agent.model;
const provider = makeProvider( const provider = makeProvider(
config.providers, config.providers,
@@ -87,6 +84,5 @@ export function agentCommand(program: Command, config: Config, workspace: string
}); });
promptUser(); promptUser();
}, });
);
} }

View File

@@ -1,18 +1,24 @@
import { mkdirSync } from 'node:fs';
import { Command } from 'commander'; import { Command } from 'commander';
import { loadConfig, resolveWorkspacePath } from '../config/loader.ts'; import { loadConfig, resolveWorkspacePath } from '../config/loader.ts';
import { agentCommand } from './agent.ts'; import { agentCommand } from './agent.ts';
import { gatewayCommand } from './gateway.ts'; import { gatewayCommand } from './gateway.ts';
import { onboardCommand } from './onboard.ts';
import { ensureWorkspace } from './utils.ts';
export function createCli(): Command { export function createCli(): Command {
const program = new Command('nanobot') const program = new Command('nanobot')
.description('nanobot — personal AI assistant') .description('nanobot — personal AI assistant')
.version('1.0.0'); .version('1.0.0');
// Register onboard command first (doesn't need config/workspace)
onboardCommand(program);
// load config and get workspace
const globalOpts = program.opts(); const globalOpts = program.opts();
const config = loadConfig(globalOpts.config); const config = loadConfig(globalOpts.config);
const workspace = resolveWorkspacePath(config.agent.workspacePath); const workspace = resolveWorkspacePath(config.agent.workspacePath);
mkdirSync(workspace, { recursive: true });
ensureWorkspace(workspace);
gatewayCommand(program, config, workspace); gatewayCommand(program, config, workspace);
agentCommand(program, config, workspace); agentCommand(program, config, workspace);

View File

@@ -1,23 +1,23 @@
import { mkdirSync } from 'node:fs'; import { ChannelManager } from '../channels/manager.ts';
import { Command } from 'commander'; import { Command } from 'commander';
import pc from 'picocolors'; import pc from 'picocolors';
import { AgentLoop } from '../agent/loop.ts'; import { AgentLoop } from '../agent/loop.ts';
import { MessageBus } from '../bus/queue.ts'; import { MessageBus } from '../bus/queue.ts';
import { MattermostChannel } from '../channels/mattermost.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 { CronService } from '../cron/service.ts';
import { HeartbeatService } from '../heartbeat/service.ts'; import { HeartbeatService } from '../heartbeat/service.ts';
import { makeProvider } from '../provider/index.ts'; import { makeProvider } from '../provider/index.ts';
export function gatewayCommand(program: Command, config: Config, workspace: string): void { import type { Config } from '../config/types.ts';
mkdirSync(workspace, { recursive: true });
export function gatewayCommand(program: Command, config: Config, workspace: string): void {
program program
.command('gateway') .command('gateway')
.description('Start the full gateway: Mattermost channel, agent loop, cron, and heartbeat.')
.option('-c, --config <path>', 'Path to config.json') .option('-c, --config <path>', 'Path to config.json')
.description('Start the full gateway: Mattermost channel, agent loop, cron, and heartbeat.')
.action(async (_opts: { config?: string }) => { .action(async (_opts: { config?: string }) => {
console.info(pc.magenta(`workspace path: ${workspace}`));
const provider = makeProvider( const provider = makeProvider(
config.providers, config.providers,
config.agent.model, config.agent.model,

52
src/cli/onboard.ts Normal file
View File

@@ -0,0 +1,52 @@
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { Command } from 'commander';
import pc from 'picocolors';
import { ConfigSchema, type Config } from '../config/types.ts';
import { ensureWorkspace, resolvePath, checkWorkspaceEmpty, syncTemplates } from './utils.ts';
export function onboardCommand(program: Command): void {
program
.command('onboard [path]')
.description('Initialize a new nanobot workspace with config and templates')
.action(async (rawPath?: string) => {
try {
const targetPath = resolvePath(rawPath ?? '~/.config/nanobot');
const configPath = join(targetPath, 'config.json');
console.info(pc.blue('Initializing nanobot workspace...'));
console.info(pc.dim(`Target path: ${targetPath}`));
// Check if directory exists and is not empty
checkWorkspaceEmpty(targetPath);
// Create workspace directory
ensureWorkspace(targetPath);
console.info(pc.green('✓ Created workspace directory'));
// Write default config
const defaultConfig: Config = ConfigSchema.parse({});
writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2), 'utf8');
console.info(pc.green('✓ Created config.json'));
// Sync templates
const createdFiles = syncTemplates(targetPath);
for (const file of createdFiles) {
console.info(pc.dim(` Created ${file}`));
}
console.info();
console.info(pc.green('nanobot workspace initialized successfully!'));
console.info();
console.info(pc.bold('Next steps:'));
console.info(` 1. Edit ${pc.cyan(configPath)} to add your API keys`);
console.info(` 2. Customize ${pc.cyan(join(targetPath, 'USER.md'))} with your preferences`);
console.info(` 3. Start chatting: ${pc.cyan('bun run nanobot agent')}`);
console.info();
console.info(pc.dim('For Mattermost integration, configure the channels.mattermost section in config.json'));
} catch (err) {
console.error(pc.red(String(err)));
process.exit(1);
}
});
}

82
src/cli/utils.ts Normal file
View File

@@ -0,0 +1,82 @@
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { homedir } from 'node:os';
import pc from 'picocolors';
export function resolvePath(raw: string): string {
if (raw.startsWith('~/') || raw === '~') {
return resolve(homedir(), raw.slice(2));
}
return resolve(raw);
}
export function ensureWorkspace(rawPath: string): string {
const path = resolvePath(rawPath);
if (!existsSync(path)) {
mkdirSync(path, { recursive: true });
}
return path;
}
export function syncTemplates(workspacePath: string): string[] {
// Get project root relative to this file
const currentFile = fileURLToPath(import.meta.url);
const srcDir = dirname(currentFile);
const projectRoot = resolve(srcDir, '..');
const templatesDir = resolve(projectRoot, 'templates');
if (!existsSync(templatesDir)) {
throw new Error(`Templates directory not found at ${templatesDir}`);
}
const created: string[] = [];
function copyTemplate(src: string, dest: string) {
if (existsSync(dest)) return;
mkdirSync(dirname(dest), { recursive: true });
const content = readFileSync(src, 'utf8');
writeFileSync(dest, content, 'utf8');
created.push(dest.slice(workspacePath.length + 1));
}
function copyDir(srcDir: string, destDir: string) {
if (!existsSync(srcDir)) return;
const entries = readdirSync(srcDir, { withFileTypes: true });
for (const entry of entries) {
const srcPath = join(srcDir, entry.name);
const destPath = join(destDir, entry.name);
if (entry.isDirectory()) {
copyDir(srcPath, destPath);
} else if (entry.name.endsWith('.md')) {
copyTemplate(srcPath, destPath);
}
}
}
copyDir(templatesDir, workspacePath);
// Create empty HISTORY.md
const historyPath = join(workspacePath, 'memory', 'HISTORY.md');
if (!existsSync(historyPath)) {
mkdirSync(dirname(historyPath), { recursive: true });
writeFileSync(historyPath, '# Conversation History\n\n', 'utf8');
created.push('memory/HISTORY.md');
}
// Create skills directory
const skillsPath = join(workspacePath, 'skills');
if (!existsSync(skillsPath)) {
mkdirSync(skillsPath, { recursive: true });
}
return created;
}
export function checkWorkspaceEmpty(path: string): void {
if (!existsSync(path)) return;
const entries = readdirSync(path);
if (entries.length > 0) {
throw new Error(pc.red(`Directory not empty: ${path}`));
}
}

View File

@@ -3,7 +3,7 @@ import { homedir } from 'node:os';
import { dirname, resolve } from 'node:path'; import { dirname, resolve } from 'node:path';
import { type Config, ConfigSchema } from './types.ts'; import { type Config, ConfigSchema } from './types.ts';
const DEFAULT_CONFIG_PATH = resolve(homedir(), '.nanobot', 'config.json'); const DEFAULT_CONFIG_PATH = resolve(homedir(), '.config', 'nanobot', 'config.json');
export function getConfigPath(override?: string): string { export function getConfigPath(override?: string): string {
return override ?? process.env['NANOBOT_CONFIG'] ?? DEFAULT_CONFIG_PATH; return override ?? process.env['NANOBOT_CONFIG'] ?? DEFAULT_CONFIG_PATH;

View File

@@ -41,7 +41,7 @@ export type ChannelsConfig = z.infer<typeof ChannelsConfigSchema>;
export const AgentConfigSchema = z.object({ export const AgentConfigSchema = z.object({
model: z.string().default('anthropic/claude-sonnet-4-5'), model: z.string().default('anthropic/claude-sonnet-4-5'),
workspacePath: z.string().default('~/.nanobot'), workspacePath: z.string().default('~/.config/nanobot'),
maxTokens: z.number().int().default(4096), maxTokens: z.number().int().default(4096),
contextWindowTokens: z.number().int().default(65536), contextWindowTokens: z.number().int().default(65536),
temperature: z.number().default(0.7), temperature: z.number().default(0.7),
@@ -114,7 +114,7 @@ export type HeartbeatConfig = z.infer<typeof HeartbeatConfigSchema>;
export const ConfigSchema = z.object({ export const ConfigSchema = z.object({
agent: AgentConfigSchema.default(() => ({ agent: AgentConfigSchema.default(() => ({
model: 'anthropic/claude-sonnet-4-5', model: 'anthropic/claude-sonnet-4-5',
workspacePath: '~/.nanobot', workspacePath: '~/.config/nanobot',
maxTokens: 4096, maxTokens: 4096,
contextWindowTokens: 65536, contextWindowTokens: 65536,
temperature: 0.7, temperature: 0.7,

View File

@@ -146,17 +146,14 @@ export class LLMProvider {
: undefined; : undefined;
try { try {
let toolChoice: 'required' | 'none' | 'auto' = 'auto'
if (opts.toolChoice === 'required' || opts.toolChoice === 'none') toolChoice = opts.toolChoice
const result = await generateText({ const result = await generateText({
model, model,
messages: opts.messages as ModelMessage[], messages: opts.messages as ModelMessage[],
// biome-ignore lint/suspicious/noExplicitAny: AI SDK tools type is complex // biome-ignore lint/suspicious/noExplicitAny: AI SDK tools type is complex
tools: aiTools as any, tools: aiTools as any,
toolChoice: toolChoice,
opts.toolChoice === 'required'
? 'required'
: opts.toolChoice === 'none'
? 'none'
: 'auto',
maxOutputTokens: maxTokens, maxOutputTokens: maxTokens,
temperature, temperature,
stopWhen: stepCountIs(1), stopWhen: stepCountIs(1),