Compare commits

...

2 Commits

Author SHA1 Message Date
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
11 changed files with 184 additions and 26 deletions

4
.gitignore vendored
View File

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

View File

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

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`
## Work Queue (next steps)
1. [ ] Runtime smoke test: `bun run start --help`
2. [ ] Test with a real Mattermost config (optional — user can do this)
3. [ ] Write sample `~/.nanobot/config.json` in README or docs
1. [x] Create workspace helper module (src/workspace.ts) with ensureWorkspace() and syncTemplates()
2. [x] Create onboard command (src/cli/onboard.ts) with path argument and directory-not-empty guard
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
- Mattermost channel uses raw WebSocket + fetch (no mattermostdriver, no SSL hack)

View File

@@ -39,10 +39,19 @@
### 🔄 In Progress
- 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
- Runtime smoke test: `bun run start --help`
- Runtime smoke test: `bun run nanobot --help`
- Integration test with a real Mattermost server
- Sample `~/.nanobot/config.json` documentation
## 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.

View File

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

View File

@@ -3,16 +3,19 @@ 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';
import type { Config } from '../config/types.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('-c, --config <path>', 'Path to config.json')
.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 }) => {
console.info(pc.magenta(`workspace path: ${workspace}`));
const model = opts.model ?? config.agent.model;
const provider = makeProvider(
config.providers,

View File

@@ -1,22 +1,24 @@
import { mkdirSync } from 'node:fs';
import { Command } from 'commander';
import { loadConfig, resolveWorkspacePath } from '../config/loader.ts';
import { agentCommand } from './agent.ts';
import { gatewayCommand } from './gateway.ts';
import pc from 'picocolors';
import { onboardCommand } from './onboard.ts';
import { ensureWorkspace } from './utils.ts';
export function createCli(): Command {
const program = new Command('nanobot')
.description('nanobot — personal AI assistant')
.option('-c, --config <path>', 'Path to config.json')
.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 config = loadConfig(globalOpts.config);
const workspace = resolveWorkspacePath(config.agent.workspacePath);
console.info(pc.magenta(`workspace path: ${workspace}`));
mkdirSync(workspace, { recursive: true });
ensureWorkspace(workspace);
gatewayCommand(program, config, workspace);
agentCommand(program, config, workspace);

View File

@@ -1,19 +1,23 @@
import { ChannelManager } from '../channels/manager.ts';
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';
import type { Config } from '../config/types.ts';
export function gatewayCommand(program: Command, config: Config, workspace: string): void {
program
.command('gateway')
.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 }) => {
console.info(pc.magenta(`workspace path: ${workspace}`));
const provider = makeProvider(
config.providers,
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 ?? '~/.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

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