diff --git a/memory-bank/activeContext.md b/memory-bank/activeContext.md index 9f04d42..79517c8 100644 --- a/memory-bank/activeContext.md +++ b/memory-bank/activeContext.md @@ -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) diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 5d383d1..a4a5014 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -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. diff --git a/src/cli/commands.ts b/src/cli/commands.ts index b0a3549..85b651b 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -1,8 +1,9 @@ -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 { onboardCommand } from './onboard.ts'; +import { ensureWorkspace } from '../workspace.ts'; import pc from 'picocolors'; export function createCli(): Command { @@ -11,12 +12,15 @@ export function createCli(): Command { .option('-c, --config ', 'Path to config.json') .version('1.0.0'); + // Register onboard command first (doesn't need config/workspace) + onboardCommand(program); + 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); diff --git a/src/cli/onboard.ts b/src/cli/onboard.ts new file mode 100644 index 0000000..afb525a --- /dev/null +++ b/src/cli/onboard.ts @@ -0,0 +1,57 @@ +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 '../workspace.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); + } + }); +} diff --git a/src/workspace.ts b/src/workspace.ts new file mode 100644 index 0000000..208d61a --- /dev/null +++ b/src/workspace.ts @@ -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}`)); + } +}