feat: add moltbot-profiles module to support saving and switching model configurations (preserve common model selections)
This commit is contained in:
parent
0b1c8db0ca
commit
49f972a881
214
PULL_REQUEST.md
Normal file
214
PULL_REQUEST.md
Normal 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
277
src/cli/profiles-cli.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
571
src/profiles/profiles.test.ts
Normal file
571
src/profiles/profiles.test.ts
Normal 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
283
src/profiles/profiles.ts
Normal 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();
|
||||
Loading…
Reference in New Issue
Block a user