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);
|
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) {
|
||||||
|
|||||||
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