feat: add moltbot-profiles module to support saving and switching model configurations (preserve common model selections)

This commit is contained in:
feng123-new 2026-01-28 07:24:22 +08:00
parent 0b1c8db0ca
commit 49f972a881
5 changed files with 1354 additions and 0 deletions

214
PULL_REQUEST.md Normal file
View File

@ -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 <name> [-d "description"] [-f]
# Switch to a saved profile (creates backup)
moltbot profiles use <name> [--restart] [--no-backup]
# Delete a profile
moltbot profiles delete <name> [-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 `<profile>.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 <moltbot>/src/profiles/profiles.ts
cp profiles-cli.ts <moltbot>/src/cli/profiles-cli.ts
cp profiles.test.ts <moltbot>/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.

277
src/cli/profiles-cli.ts Normal file
View File

@ -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 <name>" 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 <name>
profiles
.command("add <name>")
.alias("save")
.description("Save current moltbot.json config as a named profile")
.option("-d, --description <desc>", "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 <name>
profiles
.command("use <name>")
.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 <name>
profiles
.command("delete <name>")
.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);
}
}
});
}

View File

@ -222,6 +222,15 @@ const entries: SubCliEntry[] = [
mod.registerUpdateCli(program); 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) { function removeCommand(program: Command, command: Command) {

View File

@ -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<ReturnType<typeof fs.readdir>>);
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");
});
});

283
src/profiles/profiles.ts Normal file
View File

@ -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<string, unknown>;
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<void> {
await fs.mkdir(this.profilesDir, { recursive: true });
}
async getMeta(): Promise<ProfileMeta> {
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<void> {
await this.ensureDirectories();
await fs.writeFile(this.metaFile, JSON.stringify(meta, null, 2));
}
async listProfiles(): Promise<ProfileListItem[]> {
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<boolean> {
const profilePath = path.join(this.profilesDir, `${name}.json`);
try {
await fs.access(profilePath);
return true;
} catch {
return false;
}
}
async getProfile(name: string): Promise<Profile | null> {
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<Record<string, unknown>> {
try {
const content = await fs.readFile(this.configFile, "utf-8");
return JSON.parse(content) as Record<string, unknown>;
} catch {
throw new Error('moltbot.json not found. Run "moltbot onboard" first.');
}
}
async getCurrentEnv(): Promise<string> {
try {
return await fs.readFile(this.envFile, "utf-8");
} catch {
return "";
}
}
extractModelInfo(config: Record<string, unknown>): { model: string; provider: string } {
const agents = config?.agents as Record<string, unknown> | undefined;
const defaults = agents?.defaults as Record<string, unknown> | undefined;
const modelConfig = defaults?.model as Record<string, unknown> | undefined;
const model = (modelConfig?.primary as string) || "unknown";
const parts = model.split("/");
return {
model,
provider: parts[0] || "unknown",
};
}
async saveProfile(name: string, description = ""): Promise<void> {
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<void> {
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<void> {
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<string | null> {
const meta = await this.getMeta();
return meta.currentProfile;
}
async backupCurrentConfig(): Promise<string | null> {
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<void> {
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();