diff --git a/PULL_REQUEST.md b/PULL_REQUEST.md new file mode 100644 index 000000000..9736eafaa --- /dev/null +++ b/PULL_REQUEST.md @@ -0,0 +1,214 @@ +# Pull Request: Model Profile Manager (`moltbot profiles`) + +## Summary + +Add `moltbot profiles` command for fast switching between authenticated model configurations. + +**Solves**: Users managing multiple AI providers (Anthropic, OpenAI, Google, OpenRouter) need to frequently switch configurations. Currently requires manual editing of `moltbot.json` and `.env` files, which is error-prone and time-consuming. + +## Related Issues +- Partially addresses #2888 (model switching failures during sessions) +- Helps with #2883 (migration from clawdbot) +- Complements #2897 (doctor command request) + +## Changes + +### New Files + +| Target Location | Description | +|-----------------|-------------| +| `src/profiles/profiles.ts` | ProfileManager class - handles profile storage, switching, backup | +| `src/cli/profiles-cli.ts` | CLI command registration using Commander (lazy-loaded) | +| `src/profiles/profiles.test.ts` | Unit tests with vitest | + +### Modified Files + +| File | Change | +|------|--------| +| `src/cli/program/register.subclis.ts` | Add "profiles" entry for lazy-loading | + +## Commands Added + +```bash +# List all saved profiles +moltbot profiles list + +# Save current config as a named profile +moltbot profiles add [-d "description"] [-f] + +# Switch to a saved profile (creates backup) +moltbot profiles use [--restart] [--no-backup] + +# Delete a profile +moltbot profiles delete [-f] + +# Show current active profile +moltbot profiles current + +# Show profile manager paths +moltbot profiles status + +# Show profile details (or current config if no name) +moltbot profiles show [name] [--json] +``` + +## Implementation Details + +### Architecture + +This module is designed to complement (not replace) the existing `src/cli/profile.ts` which handles `--profile`/`--dev` CLI flags. The new `profiles` command manages **saved configuration snapshots** for quick switching. + +**Integration with existing CLI structure:** +- Uses lazy-loading via `register.subclis.ts` (consistent with other commands) +- Uses `@clack/prompts` for interactive confirmation (consistent with moltbot style) +- Uses `chalk` for terminal output (consistent with other commands) + +### Storage Location + +``` +~/.moltbot/ +├── moltbot.json # Active config +├── .env # Active credentials +├── backups/ # Auto-created backups before switching +│ ├── moltbot-2026-01-28T10-30-00-000Z.json +│ └── env-2026-01-28T10-30-00-000Z +└── profiles/ + ├── .meta.json # Tracks current profile and metadata + ├── claude-opus.json + ├── gemini-pro.json + └── gpt-4o.json +``` + +### Profile Format + +Each `.json` stores: +```json +{ + "name": "claude-opus", + "description": "Opus 4 for complex tasks", + "model": "anthropic/claude-opus-4", + "provider": "anthropic", + "createdAt": "2026-01-28T00:00:00.000Z", + "config": { /* full moltbot.json snapshot */ }, + "env": "ANTHROPIC_API_KEY=sk-xxx\n..." +} +``` + +### Meta File Format + +`.meta.json` tracks state across profiles: +```json +{ + "currentProfile": "claude-opus", + "profiles": { + "claude-opus": { + "model": "anthropic/claude-opus-4", + "provider": "anthropic", + "createdAt": "2026-01-28T00:00:00.000Z" + } + } +} +``` + +### Safety Features + +- **Auto-backup**: Creates timestamped backup before switching (unless `--no-backup`) +- **Confirmation prompt**: Asks before delete (unless `--force`) +- **Gateway restart**: Optional `--restart` flag after switching +- **CLAWDBOT_STATE_DIR support**: Honors environment variable for custom state directory + +## Demo + +``` +$ moltbot profiles list + +Saved Profiles: + +┌─────────────────────┬────────────────────────────────────┬──────────────────┐ +│ Name │ Model │ Provider │ +├─────────────────────┼────────────────────────────────────┼──────────────────┤ +│ ● claude-opus │ anthropic/claude-opus-4 │ anthropic │ +│ gemini-pro │ google/gemini-2.5-pro │ google │ +│ gpt-4o │ openai/gpt-4o │ openai │ +└─────────────────────┴────────────────────────────────────┴──────────────────┘ + +● Current: claude-opus + +$ moltbot profiles use gemini-pro --restart +✓ Current config backed up. +✓ Switched to profile "gemini-pro". + Model: google/gemini-2.5-pro +Restarting gateway... +✓ Gateway restarted. +``` + +## Testing + +```bash +pnpm test src/profiles/profiles.test.ts +``` + +Test coverage: +- ProfileManager unit tests (constructor, getMeta, saveMeta, listProfiles, profileExists, getProfile, getCurrentConfig, getCurrentEnv, extractModelInfo, saveProfile, useProfile, deleteProfile, getCurrentProfile, backupCurrentConfig, getStatus) +- Factory function tests (createProfileManager) +- CLI command integration tests (marked as TODO for subprocess testing) + +## Integration Guide + +### Step 1: Add source files + +```bash +cp profiles.ts /src/profiles/profiles.ts +cp profiles-cli.ts /src/cli/profiles-cli.ts +cp profiles.test.ts /src/profiles/profiles.test.ts +``` + +### Step 2: Update register.subclis.ts + +Add this entry to the `entries` array in `src/cli/program/register.subclis.ts`: + +```typescript +{ + name: "profiles", + description: "Model configuration profiles", + register: async (program) => { + const mod = await import("../profiles-cli.js"); + mod.registerProfilesCli(program); + }, +}, +``` + +### Step 3: Verify + +```bash +pnpm build +moltbot profiles --help +pnpm test src/profiles/profiles.test.ts +``` + +## Checklist + +- [x] Code follows project style (TypeScript, ESM imports) +- [x] Uses `@clack/prompts` for interactive prompts (consistent with moltbot) +- [x] Uses `chalk` for terminal output (consistent with moltbot) +- [x] Uses lazy-loading via `register.subclis.ts` (consistent with moltbot) +- [x] Unit tests added with vitest +- [x] No breaking changes to existing commands +- [x] Works with existing `~/.moltbot` directory structure +- [x] Honors `CLAWDBOT_STATE_DIR` environment variable +- [ ] Documentation update (pending review) + +## Migration from Community Tool + +This PR is based on `clawd-profile` from the [moltbot-setup](https://github.com/user/moltbot-setup) community project, rewritten in TypeScript to match moltbot's codebase style. + +Key differences from community version: +- TypeScript instead of JavaScript +- Uses `@clack/prompts` instead of `inquirer` +- Uses `chalk` instead of custom color functions +- Follows moltbot's lazy-loading command registration pattern +- Integrated with moltbot's directory structure conventions + +--- + +**Note**: Happy to adjust the implementation based on feedback. The core concept is simple - snapshot configs into named profiles for instant switching. diff --git a/src/cli/profiles-cli.ts b/src/cli/profiles-cli.ts new file mode 100644 index 000000000..7f156264a --- /dev/null +++ b/src/cli/profiles-cli.ts @@ -0,0 +1,277 @@ +/** + * Profiles CLI - Command registration for moltbot profiles + * + * This file should be placed at: src/cli/profiles-cli.ts + * + * To register, add this entry to src/cli/program/register.subclis.ts: + * + * { + * name: "profiles", + * description: "Model configuration profiles", + * register: async (program) => { + * const mod = await import("../profiles-cli.js"); + * mod.registerProfilesCli(program); + * }, + * }, + */ + +import type { Command } from "commander"; +import chalk from "chalk"; +import { createProfileManager } from "../profiles/profiles.js"; + +export function registerProfilesCli(program: Command): void { + const manager = createProfileManager(); + + const profiles = program + .command("profiles") + .description("Manage model configuration profiles for quick switching") + .addHelpText( + "after", + ` +Examples: + $ moltbot profiles list List all saved profiles + $ moltbot profiles add claude-opus Save current config as "claude-opus" + $ moltbot profiles use gemini-pro Switch to "gemini-pro" profile + $ moltbot profiles use gpt-4o --restart Switch and restart gateway + $ moltbot profiles show claude-opus Show profile details + $ moltbot profiles delete old-profile Delete a profile +`, + ); + + // profiles list + profiles + .command("list") + .alias("ls") + .description("List all saved model profiles") + .action(async () => { + const profileList = await manager.listProfiles(); + const current = await manager.getCurrentProfile(); + + if (profileList.length === 0) { + console.log(chalk.yellow("No profiles saved yet.")); + console.log(chalk.gray('Use "moltbot profiles add " to save current config.')); + return; + } + + console.log(chalk.bold("\nSaved Profiles:\n")); + console.log( + "┌─────────────────────┬────────────────────────────────────┬──────────────────┐", + ); + console.log( + "│ Name │ Model │ Provider │", + ); + console.log( + "├─────────────────────┼────────────────────────────────────┼──────────────────┤", + ); + + for (const p of profileList) { + const isActive = current === p.name; + const marker = isActive ? "● " : " "; + const nameText = (marker + p.name).padEnd(19); + const displayName = isActive ? chalk.green(nameText) : nameText; + const model = (p.model || "unknown").substring(0, 34).padEnd(34); + const provider = (p.provider || "unknown").substring(0, 16).padEnd(16); + console.log(`│ ${displayName} │ ${model} │ ${provider} │`); + } + + console.log( + "└─────────────────────┴────────────────────────────────────┴──────────────────┘", + ); + + if (current) { + console.log(chalk.green(`\n● Current: ${current}`)); + } + }); + + // profiles add + profiles + .command("add ") + .alias("save") + .description("Save current moltbot.json config as a named profile") + .option("-d, --description ", "Profile description") + .option("-f, --force", "Overwrite existing profile") + .action(async (name: string, options: { description?: string; force?: boolean }) => { + const exists = await manager.profileExists(name); + if (exists && !options.force) { + console.error(chalk.red(`Profile "${name}" already exists. Use --force to overwrite.`)); + process.exit(1); + } + + try { + await manager.saveProfile(name, options.description); + console.log(chalk.green(`✓ Profile "${name}" saved successfully.`)); + + const p = await manager.getProfile(name); + if (p) { + console.log(chalk.gray(` Model: ${p.model}`)); + console.log(chalk.gray(` Provider: ${p.provider}`)); + } + } catch (error) { + console.error(chalk.red(`Failed to save profile: ${(error as Error).message}`)); + process.exit(1); + } + }); + + // profiles use + profiles + .command("use ") + .alias("switch") + .description("Switch to a saved profile (updates moltbot.json)") + .option("--no-backup", "Skip backing up current config") + .option("--restart", "Restart gateway after switching") + .action(async (name: string, options: { backup?: boolean; restart?: boolean }) => { + const exists = await manager.profileExists(name); + if (!exists) { + console.error(chalk.red(`Profile "${name}" not found.`)); + console.log(chalk.gray('Use "moltbot profiles list" to see available profiles.')); + process.exit(1); + } + + try { + if (options.backup !== false) { + const timestamp = await manager.backupCurrentConfig(); + if (timestamp) { + console.log(chalk.gray("✓ Current config backed up.")); + } + } + + await manager.useProfile(name, { backup: false }); + console.log(chalk.green(`✓ Switched to profile "${name}".`)); + + const p = await manager.getProfile(name); + if (p) { + console.log(chalk.cyan(` Model: ${p.model}`)); + } + + if (options.restart) { + console.log(chalk.gray("Restarting gateway...")); + await manager.restartGateway(); + console.log(chalk.green("✓ Gateway restarted.")); + } else { + console.log(chalk.yellow('\nNote: Run "moltbot gateway restart" to apply changes.')); + } + } catch (error) { + console.error(chalk.red(`Failed to switch profile: ${(error as Error).message}`)); + process.exit(1); + } + }); + + // profiles delete + profiles + .command("delete ") + .alias("rm") + .description("Delete a saved profile") + .option("-f, --force", "Skip confirmation") + .action(async (name: string, options: { force?: boolean }) => { + const exists = await manager.profileExists(name); + if (!exists) { + console.error(chalk.red(`Profile "${name}" not found.`)); + process.exit(1); + } + + if (!options.force) { + // Use clack prompts for consistency with moltbot codebase + const { confirm } = await import("@clack/prompts"); + const shouldDelete = await confirm({ + message: `Delete profile "${name}"?`, + initialValue: false, + }); + + if (!shouldDelete || typeof shouldDelete === "symbol") { + console.log(chalk.gray("Cancelled.")); + return; + } + } + + try { + await manager.deleteProfile(name); + console.log(chalk.green(`✓ Profile "${name}" deleted.`)); + } catch (error) { + console.error(chalk.red(`Failed to delete profile: ${(error as Error).message}`)); + process.exit(1); + } + }); + + // profiles current + profiles + .command("current") + .description("Show which profile is currently active") + .action(async () => { + const current = await manager.getCurrentProfile(); + if (current) { + console.log(chalk.green(`Current profile: ${current}`)); + const p = await manager.getProfile(current); + if (p) { + console.log(chalk.gray(` Model: ${p.model}`)); + console.log(chalk.gray(` Provider: ${p.provider}`)); + } + } else { + console.log(chalk.yellow("No profile is currently active.")); + console.log(chalk.gray("Current config may have been manually edited.")); + } + }); + + // profiles status + profiles + .command("status") + .description("Show profile manager status and paths") + .action(async () => { + const status = manager.getStatus(); + const current = await manager.getCurrentProfile(); + const profileList = await manager.listProfiles(); + + console.log(chalk.bold("\nProfile Manager Status:\n")); + console.log(` Config dir: ${chalk.gray(status.configDir)}`); + console.log(` Config file: ${chalk.gray(status.configFile)}`); + console.log(` Profiles dir: ${chalk.gray(status.profilesDir)}`); + console.log(` Backups dir: ${chalk.gray(status.backupsDir)}`); + console.log(` Profile count: ${chalk.cyan(profileList.length.toString())}`); + + if (current) { + console.log(`\n Active profile: ${chalk.green(current)}`); + } else { + console.log(`\n Active profile: ${chalk.yellow("none")}`); + } + }); + + // profiles show [name] + profiles + .command("show [name]") + .alias("info") + .description("Show details of a profile (or current config if no name)") + .option("--json", "Output as JSON") + .action(async (name: string | undefined, options: { json?: boolean }) => { + if (name) { + const p = await manager.getProfile(name); + if (!p) { + console.error(chalk.red(`Profile "${name}" not found.`)); + process.exit(1); + } + + if (options.json) { + console.log(JSON.stringify(p, null, 2)); + } else { + console.log(chalk.bold(`\nProfile: ${name}\n`)); + console.log(` Model: ${chalk.cyan(p.model)}`); + console.log(` Provider: ${chalk.cyan(p.provider)}`); + console.log(` Description: ${p.description || chalk.gray("(none)")}`); + console.log(` Created: ${chalk.gray(p.createdAt)}`); + console.log(`\n${chalk.bold("Config:")}`); + console.log(chalk.gray(JSON.stringify(p.config, null, 2))); + } + } else { + try { + const config = await manager.getCurrentConfig(); + if (options.json) { + console.log(JSON.stringify(config, null, 2)); + } else { + console.log(chalk.bold("\nCurrent moltbot.json config:\n")); + console.log(JSON.stringify(config, null, 2)); + } + } catch (error) { + console.error(chalk.red((error as Error).message)); + process.exit(1); + } + } + }); +} diff --git a/src/cli/program/register.subclis.ts b/src/cli/program/register.subclis.ts index 97ca4508a..85ea6bc6a 100644 --- a/src/cli/program/register.subclis.ts +++ b/src/cli/program/register.subclis.ts @@ -222,6 +222,15 @@ const entries: SubCliEntry[] = [ mod.registerUpdateCli(program); }, }, + // 新增:profiles 命令配置 + { + name: "profiles", + description: "Model configuration profiles", + register: async (program) => { + const mod = await import("../profiles-cli.js"); + mod.registerProfilesCli(program); + }, + }, ]; function removeCommand(program: Command, command: Command) { diff --git a/src/profiles/profiles.test.ts b/src/profiles/profiles.test.ts new file mode 100644 index 000000000..72304e8c6 --- /dev/null +++ b/src/profiles/profiles.test.ts @@ -0,0 +1,571 @@ +/** + * Profile Manager Tests + * + * Run with: pnpm test src/profiles/profiles.test.ts + * + * This file should be placed at: src/profiles/profiles.test.ts + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import fs from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import { ProfileManager, createProfileManager } from "./profiles.js"; + +// Mock filesystem +vi.mock("node:fs/promises"); +vi.mock("node:child_process"); + +describe("ProfileManager", () => { + let manager: ProfileManager; + const mockConfigDir = path.join(os.homedir(), ".moltbot"); + const mockProfilesDir = path.join(mockConfigDir, "profiles"); + + beforeEach(() => { + manager = new ProfileManager(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("constructor", () => { + it("uses default state directory", () => { + const m = new ProfileManager(); + const status = m.getStatus(); + expect(status.configDir).toBe(mockConfigDir); + }); + + it("accepts custom state directory", () => { + const customDir = "/custom/path"; + const m = new ProfileManager(customDir); + const status = m.getStatus(); + expect(status.configDir).toBe(customDir); + }); + }); + + describe("createProfileManager", () => { + it("creates a new ProfileManager instance", () => { + const m = createProfileManager(); + expect(m).toBeInstanceOf(ProfileManager); + }); + + it("accepts custom state directory", () => { + const m = createProfileManager("/custom/dir"); + expect(m.getStatus().configDir).toBe("/custom/dir"); + }); + }); + + describe("getMeta", () => { + it("returns empty meta when file does not exist", async () => { + vi.mocked(fs.readFile).mockRejectedValue(new Error("ENOENT")); + + const meta = await manager.getMeta(); + + expect(meta).toEqual({ currentProfile: null, profiles: {} }); + }); + + it("returns parsed meta when file exists", async () => { + const mockMeta = { + currentProfile: "claude-opus", + profiles: { + "claude-opus": { + model: "anthropic/claude-opus-4", + provider: "anthropic", + createdAt: "2026-01-28T00:00:00.000Z", + }, + }, + }; + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockMeta)); + + const meta = await manager.getMeta(); + + expect(meta).toEqual(mockMeta); + }); + }); + + describe("saveMeta", () => { + it("creates directories and writes meta file", async () => { + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + const meta = { + currentProfile: "test-profile", + profiles: { + "test-profile": { + model: "anthropic/claude-opus-4", + provider: "anthropic", + createdAt: "2026-01-28T00:00:00.000Z", + }, + }, + }; + + await manager.saveMeta(meta); + + expect(fs.mkdir).toHaveBeenCalledWith( + expect.stringContaining("profiles"), + { recursive: true }, + ); + expect(fs.writeFile).toHaveBeenCalledWith( + expect.stringContaining(".meta.json"), + expect.stringContaining('"currentProfile":"test-profile"'), + ); + }); + }); + + describe("listProfiles", () => { + it("returns empty array when profiles directory is empty", async () => { + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue([]); + + const profiles = await manager.listProfiles(); + + expect(profiles).toEqual([]); + }); + + it("returns profiles list excluding .meta.json", async () => { + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue([ + "claude-opus.json", + "gemini-pro.json", + ".meta.json", + ] as unknown as Awaited>); + + const mockProfile = { + name: "claude-opus", + model: "anthropic/claude-opus-4", + provider: "anthropic", + description: "Opus for complex tasks", + createdAt: "2026-01-28T00:00:00.000Z", + config: {}, + env: "", + }; + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockProfile)); + + const profiles = await manager.listProfiles(); + + expect(profiles.length).toBe(2); + expect(profiles[0].name).toBe("claude-opus"); + }); + + it("handles read errors gracefully", async () => { + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockRejectedValue(new Error("ENOENT")); + + const profiles = await manager.listProfiles(); + + expect(profiles).toEqual([]); + }); + }); + + describe("profileExists", () => { + it("returns true when profile file exists", async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + + const exists = await manager.profileExists("claude-opus"); + + expect(exists).toBe(true); + }); + + it("returns false when profile file does not exist", async () => { + vi.mocked(fs.access).mockRejectedValue(new Error("ENOENT")); + + const exists = await manager.profileExists("nonexistent"); + + expect(exists).toBe(false); + }); + }); + + describe("getProfile", () => { + it("returns profile when file exists", async () => { + const mockProfile = { + name: "claude-opus", + model: "anthropic/claude-opus-4", + provider: "anthropic", + description: "Test", + createdAt: "2026-01-28T00:00:00.000Z", + config: {}, + env: "", + }; + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockProfile)); + + const profile = await manager.getProfile("claude-opus"); + + expect(profile).toEqual(mockProfile); + }); + + it("returns null when profile does not exist", async () => { + vi.mocked(fs.readFile).mockRejectedValue(new Error("ENOENT")); + + const profile = await manager.getProfile("nonexistent"); + + expect(profile).toBeNull(); + }); + }); + + describe("getCurrentConfig", () => { + it("returns parsed config when file exists", async () => { + const mockConfig = { agents: { defaults: { model: { primary: "test" } } } }; + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockConfig)); + + const config = await manager.getCurrentConfig(); + + expect(config).toEqual(mockConfig); + }); + + it("throws error when config file does not exist", async () => { + vi.mocked(fs.readFile).mockRejectedValue(new Error("ENOENT")); + + await expect(manager.getCurrentConfig()).rejects.toThrow( + 'moltbot.json not found. Run "moltbot onboard" first.', + ); + }); + }); + + describe("getCurrentEnv", () => { + it("returns env content when file exists", async () => { + vi.mocked(fs.readFile).mockResolvedValue("ANTHROPIC_API_KEY=sk-xxx"); + + const env = await manager.getCurrentEnv(); + + expect(env).toBe("ANTHROPIC_API_KEY=sk-xxx"); + }); + + it("returns empty string when env file does not exist", async () => { + vi.mocked(fs.readFile).mockRejectedValue(new Error("ENOENT")); + + const env = await manager.getCurrentEnv(); + + expect(env).toBe(""); + }); + }); + + describe("extractModelInfo", () => { + it("extracts model and provider from config", () => { + const config = { + agents: { + defaults: { + model: { + primary: "anthropic/claude-opus-4", + }, + }, + }, + }; + + const info = manager.extractModelInfo(config); + + expect(info.model).toBe("anthropic/claude-opus-4"); + expect(info.provider).toBe("anthropic"); + }); + + it("returns unknown when model path is missing", () => { + const config = {}; + + const info = manager.extractModelInfo(config); + + expect(info.model).toBe("unknown"); + expect(info.provider).toBe("unknown"); + }); + + it("handles openrouter model paths", () => { + const config = { + agents: { + defaults: { + model: { + primary: "openrouter/anthropic/claude-3.5-sonnet", + }, + }, + }, + }; + + const info = manager.extractModelInfo(config); + + expect(info.model).toBe("openrouter/anthropic/claude-3.5-sonnet"); + expect(info.provider).toBe("openrouter"); + }); + + it("handles google/gemini model paths", () => { + const config = { + agents: { + defaults: { + model: { + primary: "google/gemini-2.0-flash", + }, + }, + }, + }; + + const info = manager.extractModelInfo(config); + + expect(info.model).toBe("google/gemini-2.0-flash"); + expect(info.provider).toBe("google"); + }); + }); + + describe("saveProfile", () => { + it("saves profile with current config and env", async () => { + const mockConfig = { + agents: { + defaults: { + model: { primary: "anthropic/claude-opus-4" }, + }, + }, + }; + + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + vi.mocked(fs.readFile) + .mockResolvedValueOnce(JSON.stringify(mockConfig)) // getCurrentConfig + .mockResolvedValueOnce("ANTHROPIC_API_KEY=sk-xxx") // getCurrentEnv + .mockRejectedValueOnce(new Error("ENOENT")); // getMeta (no existing meta) + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + await manager.saveProfile("my-profile", "Test profile"); + + expect(fs.writeFile).toHaveBeenCalledTimes(2); // profile + meta + expect(fs.writeFile).toHaveBeenCalledWith( + expect.stringContaining("my-profile.json"), + expect.stringContaining('"name":"my-profile"'), + ); + }); + + it("updates meta with new profile info", async () => { + const mockConfig = { + agents: { defaults: { model: { primary: "anthropic/claude-opus-4" } } }, + }; + + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + vi.mocked(fs.readFile) + .mockResolvedValueOnce(JSON.stringify(mockConfig)) + .mockResolvedValueOnce("") + .mockResolvedValueOnce(JSON.stringify({ currentProfile: null, profiles: {} })); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + await manager.saveProfile("test-profile", "Description"); + + // Second writeFile call is for meta + const metaCall = vi.mocked(fs.writeFile).mock.calls[1]; + expect(metaCall[0]).toContain(".meta.json"); + expect(metaCall[1]).toContain('"currentProfile":"test-profile"'); + }); + }); + + describe("useProfile", () => { + it("throws error when profile does not exist", async () => { + vi.mocked(fs.readFile).mockRejectedValue(new Error("ENOENT")); + + await expect(manager.useProfile("nonexistent")).rejects.toThrow( + 'Profile "nonexistent" not found.', + ); + }); + + it("writes config and env files when switching profile", async () => { + const mockProfile = { + name: "claude-opus", + model: "anthropic/claude-opus-4", + provider: "anthropic", + description: "", + createdAt: "2026-01-28T00:00:00.000Z", + config: { agents: {} }, + env: "ANTHROPIC_API_KEY=sk-xxx", + }; + + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockProfile)); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + + await manager.useProfile("claude-opus"); + + expect(fs.writeFile).toHaveBeenCalledWith( + expect.stringContaining("moltbot.json"), + expect.any(String), + ); + expect(fs.writeFile).toHaveBeenCalledWith( + expect.stringContaining(".env"), + "ANTHROPIC_API_KEY=sk-xxx", + ); + }); + + it("skips backup when backup option is false", async () => { + const mockProfile = { + name: "test", + model: "test", + provider: "test", + description: "", + createdAt: "2026-01-28T00:00:00.000Z", + config: {}, + env: "", + }; + + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockProfile)); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + + await manager.useProfile("test", { backup: false }); + + // Should not have created backup directory + expect(fs.mkdir).not.toHaveBeenCalledWith( + expect.stringContaining("backups"), + expect.any(Object), + ); + }); + }); + + describe("deleteProfile", () => { + it("removes profile file and updates meta", async () => { + const mockMeta = { + currentProfile: "claude-opus", + profiles: { + "claude-opus": { + model: "anthropic/claude-opus-4", + provider: "anthropic", + createdAt: "2026-01-28T00:00:00.000Z", + }, + }, + }; + + vi.mocked(fs.unlink).mockResolvedValue(undefined); + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockMeta)); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + + await manager.deleteProfile("claude-opus"); + + expect(fs.unlink).toHaveBeenCalledWith( + expect.stringContaining("claude-opus.json"), + ); + }); + + it("clears currentProfile if deleting active profile", async () => { + const mockMeta = { + currentProfile: "active-profile", + profiles: { + "active-profile": { + model: "test", + provider: "test", + createdAt: "2026-01-28T00:00:00.000Z", + }, + }, + }; + + vi.mocked(fs.unlink).mockResolvedValue(undefined); + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockMeta)); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + + await manager.deleteProfile("active-profile"); + + const metaCall = vi.mocked(fs.writeFile).mock.calls[0]; + expect(metaCall[1]).toContain('"currentProfile":null'); + }); + }); + + describe("getCurrentProfile", () => { + it("returns current profile name from meta", async () => { + const mockMeta = { currentProfile: "my-profile", profiles: {} }; + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockMeta)); + + const current = await manager.getCurrentProfile(); + + expect(current).toBe("my-profile"); + }); + + it("returns null when no profile is active", async () => { + vi.mocked(fs.readFile).mockRejectedValue(new Error("ENOENT")); + + const current = await manager.getCurrentProfile(); + + expect(current).toBeNull(); + }); + }); + + describe("backupCurrentConfig", () => { + it("creates backup directory and copies files", async () => { + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + vi.mocked(fs.readFile) + .mockResolvedValueOnce('{"config": true}') // config + .mockResolvedValueOnce("API_KEY=xxx"); // env + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + const timestamp = await manager.backupCurrentConfig(); + + expect(fs.mkdir).toHaveBeenCalledWith( + expect.stringContaining("backups"), + { recursive: true }, + ); + expect(fs.writeFile).toHaveBeenCalledTimes(2); + expect(timestamp).not.toBeNull(); + }); + + it("returns null when no files exist to backup", async () => { + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + vi.mocked(fs.readFile).mockRejectedValue(new Error("ENOENT")); + + const timestamp = await manager.backupCurrentConfig(); + + expect(timestamp).toBeNull(); + }); + }); + + describe("getStatus", () => { + it("returns correct paths", () => { + const status = manager.getStatus(); + + expect(status.configDir).toBe(path.join(os.homedir(), ".moltbot")); + expect(status.configFile).toBe( + path.join(os.homedir(), ".moltbot", "moltbot.json"), + ); + expect(status.profilesDir).toBe( + path.join(os.homedir(), ".moltbot", "profiles"), + ); + expect(status.backupsDir).toBe( + path.join(os.homedir(), ".moltbot", "backups"), + ); + }); + }); +}); + +describe("Profile CLI Commands", () => { + // Integration tests for CLI commands would go here + // These would use subprocess execution to test actual CLI behavior + + describe("moltbot profiles list", () => { + it.todo("displays formatted table of profiles"); + it.todo("shows indicator for active profile"); + it.todo("shows helpful message when no profiles exist"); + }); + + describe("moltbot profiles add", () => { + it.todo("saves current config as named profile"); + it.todo("requires --force to overwrite existing"); + it.todo("accepts --description flag"); + }); + + describe("moltbot profiles use", () => { + it.todo("switches to specified profile"); + it.todo("creates backup by default"); + it.todo("skips backup with --no-backup"); + it.todo("restarts gateway with --restart"); + }); + + describe("moltbot profiles delete", () => { + it.todo("prompts for confirmation by default"); + it.todo("skips prompt with --force"); + }); + + describe("moltbot profiles current", () => { + it.todo("shows active profile name and model"); + it.todo("shows message when no profile is active"); + }); + + describe("moltbot profiles status", () => { + it.todo("displays all relevant paths"); + it.todo("shows profile count"); + }); + + describe("moltbot profiles show", () => { + it.todo("displays profile details"); + it.todo("outputs JSON with --json flag"); + it.todo("shows current config when no name given"); + }); +}); diff --git a/src/profiles/profiles.ts b/src/profiles/profiles.ts new file mode 100644 index 000000000..909c295cb --- /dev/null +++ b/src/profiles/profiles.ts @@ -0,0 +1,283 @@ +/** + * Profile Manager - Fast switching between authenticated model configurations + * + * This file should be placed at: src/profiles/profiles.ts + * Implements: moltbot profiles [list|add|use|delete|current|status|show] + * + * NOTE: This is distinct from src/cli/profile.ts which handles --profile/--dev CLI flags. + * This module manages saved model configuration profiles for quick switching. + */ + +import fs from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import { exec } from "node:child_process"; +import { promisify } from "node:util"; + +const execAsync = promisify(exec); + +export interface ProfileMeta { + currentProfile: string | null; + profiles: Record< + string, + { + model: string; + provider: string; + createdAt: string; + } + >; +} + +export interface Profile { + name: string; + description: string; + model: string; + provider: string; + createdAt: string; + config: Record; + env: string; +} + +export interface ProfileListItem { + name: string; + model: string; + provider: string; + description?: string; +} + +function resolveStateDir(): string { + // Honor CLAWDBOT_STATE_DIR if set (for multi-profile CLI support) + const envDir = process.env.CLAWDBOT_STATE_DIR?.trim(); + if (envDir) return envDir; + return path.join(os.homedir(), ".moltbot"); +} + +export class ProfileManager { + private readonly configDir: string; + private readonly profilesDir: string; + private readonly configFile: string; + private readonly envFile: string; + private readonly metaFile: string; + + constructor(stateDir?: string) { + this.configDir = stateDir ?? resolveStateDir(); + this.profilesDir = path.join(this.configDir, "profiles"); + this.configFile = path.join(this.configDir, "moltbot.json"); + this.envFile = path.join(this.configDir, ".env"); + this.metaFile = path.join(this.profilesDir, ".meta.json"); + } + + async ensureDirectories(): Promise { + await fs.mkdir(this.profilesDir, { recursive: true }); + } + + async getMeta(): Promise { + try { + const content = await fs.readFile(this.metaFile, "utf-8"); + return JSON.parse(content) as ProfileMeta; + } catch { + return { currentProfile: null, profiles: {} }; + } + } + + async saveMeta(meta: ProfileMeta): Promise { + await this.ensureDirectories(); + await fs.writeFile(this.metaFile, JSON.stringify(meta, null, 2)); + } + + async listProfiles(): Promise { + await this.ensureDirectories(); + const profiles: ProfileListItem[] = []; + + try { + const files = await fs.readdir(this.profilesDir); + for (const file of files) { + if (file.endsWith(".json") && file !== ".meta.json") { + const name = file.replace(".json", ""); + const profile = await this.getProfile(name); + if (profile) { + profiles.push({ + name, + model: profile.model, + provider: profile.provider, + description: profile.description, + }); + } + } + } + } catch { + return []; + } + + return profiles; + } + + async profileExists(name: string): Promise { + const profilePath = path.join(this.profilesDir, `${name}.json`); + try { + await fs.access(profilePath); + return true; + } catch { + return false; + } + } + + async getProfile(name: string): Promise { + const profilePath = path.join(this.profilesDir, `${name}.json`); + try { + const content = await fs.readFile(profilePath, "utf-8"); + return JSON.parse(content) as Profile; + } catch { + return null; + } + } + + async getCurrentConfig(): Promise> { + try { + const content = await fs.readFile(this.configFile, "utf-8"); + return JSON.parse(content) as Record; + } catch { + throw new Error('moltbot.json not found. Run "moltbot onboard" first.'); + } + } + + async getCurrentEnv(): Promise { + try { + return await fs.readFile(this.envFile, "utf-8"); + } catch { + return ""; + } + } + + extractModelInfo(config: Record): { model: string; provider: string } { + const agents = config?.agents as Record | undefined; + const defaults = agents?.defaults as Record | undefined; + const modelConfig = defaults?.model as Record | undefined; + const model = (modelConfig?.primary as string) || "unknown"; + const parts = model.split("/"); + return { + model, + provider: parts[0] || "unknown", + }; + } + + async saveProfile(name: string, description = ""): Promise { + await this.ensureDirectories(); + + const config = await this.getCurrentConfig(); + const envContent = await this.getCurrentEnv(); + const { model, provider } = this.extractModelInfo(config); + + const profile: Profile = { + name, + description, + model, + provider, + createdAt: new Date().toISOString(), + config, + env: envContent, + }; + + const profilePath = path.join(this.profilesDir, `${name}.json`); + await fs.writeFile(profilePath, JSON.stringify(profile, null, 2)); + + const meta = await this.getMeta(); + meta.profiles[name] = { model, provider, createdAt: profile.createdAt }; + meta.currentProfile = name; + await this.saveMeta(meta); + } + + async useProfile(name: string, options: { backup?: boolean } = {}): Promise { + const profile = await this.getProfile(name); + if (!profile) { + throw new Error(`Profile "${name}" not found.`); + } + + if (options.backup !== false) { + await this.backupCurrentConfig(); + } + + await fs.writeFile(this.configFile, JSON.stringify(profile.config, null, 2)); + + if (profile.env) { + await fs.writeFile(this.envFile, profile.env); + } + + const meta = await this.getMeta(); + meta.currentProfile = name; + await this.saveMeta(meta); + } + + async deleteProfile(name: string): Promise { + const profilePath = path.join(this.profilesDir, `${name}.json`); + await fs.unlink(profilePath); + + const meta = await this.getMeta(); + delete meta.profiles[name]; + if (meta.currentProfile === name) { + meta.currentProfile = null; + } + await this.saveMeta(meta); + } + + async getCurrentProfile(): Promise { + const meta = await this.getMeta(); + return meta.currentProfile; + } + + async backupCurrentConfig(): Promise { + const backupDir = path.join(this.configDir, "backups"); + await fs.mkdir(backupDir, { recursive: true }); + + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + let backedUp = false; + + try { + const config = await fs.readFile(this.configFile, "utf-8"); + const configBackupPath = path.join(backupDir, `moltbot-${timestamp}.json`); + await fs.writeFile(configBackupPath, config); + backedUp = true; + } catch { + // Config doesn't exist yet + } + + try { + const env = await fs.readFile(this.envFile, "utf-8"); + await fs.writeFile(path.join(backupDir, `env-${timestamp}`), env); + backedUp = true; + } catch { + // Env doesn't exist yet + } + + return backedUp ? timestamp : null; + } + + async restartGateway(): Promise { + try { + await execAsync("moltbot gateway restart"); + } catch (error) { + throw new Error(`Failed to restart gateway: ${(error as Error).message}`); + } + } + + getStatus(): { + configDir: string; + configFile: string; + profilesDir: string; + backupsDir: string; + } { + return { + configDir: this.configDir, + configFile: this.configFile, + profilesDir: this.profilesDir, + backupsDir: path.join(this.configDir, "backups"), + }; + } +} + +export function createProfileManager(stateDir?: string): ProfileManager { + return new ProfileManager(stateDir); +} + +// Default singleton instance +export const profileManager = new ProfileManager();