From 588c5281a3ebab537e84b80d77343eff8243561e Mon Sep 17 00:00:00 2001 From: Aykahshi Date: Wed, 28 Jan 2026 14:59:19 +0800 Subject: [PATCH 1/8] fix(cli): update default template to align latest omo config --- shared/assets/default-template.json | 33 ++++++++++++++++++----------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/shared/assets/default-template.json b/shared/assets/default-template.json index b530c57..0c029c9 100644 --- a/shared/assets/default-template.json +++ b/shared/assets/default-template.json @@ -1,26 +1,35 @@ { "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json", "agents": { - "Sisyphus": { - "model": "opencode/glm-4.7-free" + "sisyphus": { + "model": "opencode/big-pickle" }, - "oracle": { - "model": "opencode/glm-4.7-free" + "sisyphus-junior": { + "model": "opencode/big-pickle" + }, + "atlas": { + "model": "opencode/big-pickle" + }, + "metis": { + "model": "opencode/big-pickle" + }, + "momus": { + "model": "opencode/big-pickle" }, - "frontend-ui-ux-engineer": { - "model": "opencode/glm-4.7-free" + "prometheus": { + "model": "opencode/big-pickle" + }, + "oracle": { + "model": "opencode/big-pickle" }, "librarian": { - "model": "opencode/glm-4.7-free" + "model": "opencode/big-pickle" }, "explore": { - "model": "opencode/glm-4.7-free" - }, - "document-writer": { - "model": "opencode/glm-4.7-free" + "model": "opencode/big-pickle" }, "multimodal-looker": { - "model": "opencode/glm-4.7-free" + "model": "opencode/big-pickle" } } } From 80e336ce9fd161555be33e2495fcd806bb8bdc55 Mon Sep 17 00:00:00 2001 From: Aykahshi Date: Wed, 28 Jan 2026 15:02:14 +0800 Subject: [PATCH 2/8] feat(cli): add omos cli alias --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 55435a4..909928c 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "description": "CLI tool for managing oh-my-opencode profiles", "main": "dist/index.js", "bin": { - "omo-switch": "dist/index.js" + "omo-switch": "dist/index.js", + "omos": "dist/index.js" }, "engines": { "node": ">=22" From 60eb403ccef343d6f7280f9c823b6b83ad93df9c Mon Sep 17 00:00:00 2001 From: Aykahshi Date: Wed, 28 Jan 2026 15:32:38 +0800 Subject: [PATCH 3/8] feat(cli): support omos - support oh-my-opencode-slim - add backup cleanup logic --- README.md | 121 ++++- shared/assets/default-template-slim.json | 13 + src/commands/add.test.ts | 45 +- src/commands/add.ts | 432 +++++++++++------- src/commands/apply.ts | 348 +++++++++------ src/commands/list.test.ts | 14 +- src/commands/list.ts | 301 +++++++++---- src/commands/rm.test.ts | 124 +++--- src/commands/rm.ts | 545 +++++++++++++++-------- src/commands/show.ts | 431 ++++++++++++------ src/commands/type.test.ts | 267 +++++++++++ src/commands/type.ts | 124 ++++++ src/index.ts | 2 + src/store/index.ts | 7 + src/store/omos-config.ts | 204 +++++++++ src/store/project-store.ts | 5 + src/store/settings-manager.test.ts | 177 ++++++++ src/store/settings-manager.ts | 98 ++++ src/store/types.ts | 59 +++ src/utils/backup-cleaner.test.ts | 150 +++++++ src/utils/backup-cleaner.ts | 52 +++ src/utils/omos-config-path.ts | 82 ++++ src/utils/omos-validator.test.ts | 223 ++++++++++ src/utils/omos-validator.ts | 122 +++++ src/utils/settings.ts | 73 +++ 25 files changed, 3224 insertions(+), 795 deletions(-) create mode 100644 shared/assets/default-template-slim.json create mode 100644 src/commands/type.test.ts create mode 100644 src/commands/type.ts create mode 100644 src/store/omos-config.ts create mode 100644 src/store/settings-manager.test.ts create mode 100644 src/store/settings-manager.ts create mode 100644 src/utils/backup-cleaner.test.ts create mode 100644 src/utils/backup-cleaner.ts create mode 100644 src/utils/omos-config-path.ts create mode 100644 src/utils/omos-validator.test.ts create mode 100644 src/utils/omos-validator.ts create mode 100644 src/utils/settings.ts diff --git a/README.md b/README.md index a4fa88a..f093c3c 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,11 @@ A CLI tool for managing [oh-my-opencode](https://github.com/code-yeongyu/oh-my-opencode) configuration profiles. +## Why omo-switch? + +**Why not just use `ocx`?** +When you need a simple, lightweight tool dedicated purely to switching between different configurations for `oh-my-opencode` or `oh-my-opencode-slim`, `omo-switch` is your best friend. It doesn't try to do everything; it focuses on making config management as easy as a single command. It's built for developers who want a straightforward way to swap setups without the overhead of more complex orchestration tools. + ## Features - 🔄 **Profile Switching** - Seamlessly switch between multiple `oh-my-opencode` configurations @@ -17,6 +22,8 @@ A CLI tool for managing [oh-my-opencode](https://github.com/code-yeongyu/oh-my-o - 💾 **Automatic Backups** - Your configuration is always backed up before changes - 🖥️ **Cross-Platform** - Works on Windows (PowerShell/CMD), Linux, and macOS (XDG compatible) +- 🔀 **Dual Mode Support** - Seamlessly switch between `oh-my-opencode` (OMO) and `oh-my-opencode-slim` (SLIM) configurations + ## Requirements - **Node.js** >= 22.0.0 @@ -29,6 +36,8 @@ A CLI tool for managing [oh-my-opencode](https://github.com/code-yeongyu/oh-my-o npm install -g omo-switch-cli ``` +**Note:** You can also use the shorter alias `omos` after installation. + ### From Source ```bash @@ -72,6 +81,45 @@ omo-switch init --- +### `type [type]` + +Get or set the active configuration type (`omo` or `slim`). This determines how `omo-switch` interprets your configuration and where it applies changes. + +```bash +# Show current type and scope overrides +omo-switch type + +# Set global type to SLIM (uses oh-my-opencode-slim.json) +omo-switch type slim + +# Set global type to OMO (uses oh-my-opencode.jsonc) +omo-switch type omo + +# Set project-specific type override (creates .opencode/settings.json) +omo-switch type slim --scope project + +# Clear project-specific override +omo-switch type --clear-project + +# Interactive selection +omo-switch type --select +``` + +**Options:** +| Option | Description | +|--------|-------------| +| `--scope ` | Target scope: `user` (global) or `project` (local) | +| `--select` | Interactively select from available types | +| `--clear-project` | Remove project-level override to use global default | + +**Configuration Types:** +| Type | Description | File Pattern | +|------|-------------|--------------| +| `omo` | **Classic Mode** (Default) - Uses multiple profile files. | `oh-my-opencode.jsonc` | +| `slim` | **Slim Mode** - Uses a single file with multiple presets. | `oh-my-opencode-slim.json` | + +--- + ### `add ` Imports a configuration file as a new profile. @@ -89,9 +137,6 @@ omo-switch add ./config.json --id dev --name "Dev Config" # Add to project scope instead of global omo-switch add ./config.jsonc --scope project -# Import and immediately activate -omo-switch add ./config.jsonc --activate - # Overwrite existing profile with same ID omo-switch add ./config.jsonc --id existing-id --force ``` @@ -102,9 +147,11 @@ omo-switch add ./config.jsonc --id existing-id --force | `--id ` | Custom profile ID (defaults to derived from name or filename) | | `--name ` | Custom display name (defaults to ID) | | `--scope ` | Target scope: `user` (global) or `project` (local). Prompts if not specified | -| `--activate` | Apply the profile immediately after adding | | `--force` | Overwrite if a profile with the same ID exists | +OMOS behavior: +- When the active type is `slim`, `add` will insert the imported configuration as a new preset under the `presets` object inside `oh-my-opencode-slim.json` instead of creating a separate profile file. + **Profile ID and filename:** - If neither `--id` nor `--name` is provided: ID is derived from the input filename - If only `--name` is provided: ID is derived from the name (lowercase, hyphenated) @@ -133,6 +180,9 @@ omo-switch list --scope project |--------|-------------| | `--scope ` | Filter by scope: `user`, `project`, or `all` (default: `all`) | +OMOS behavior: +- In `slim` mode, `list` displays available presets from the `presets` object inside `oh-my-opencode-slim.json` and indicates the currently selected `preset`. + --- ### `show [identifier]` @@ -160,6 +210,9 @@ omo-switch show dev --scope project - `user`: Shows a profile from the global store - `project`: Shows a profile from the project store +OMOS behavior: +- When in `slim` mode, `show` will display the OMOS single-file configuration (`oh-my-opencode-slim.json`) or a specific preset from its `presets` object when an identifier is provided. + --- ### `apply ` @@ -184,6 +237,9 @@ omo-switch apply dev --scope project **Note:** A backup is automatically created before any changes. +OMOS behavior: +- In `slim` mode, `apply` sets the `preset` field inside `oh-my-opencode-slim.json` to the selected preset ID instead of copying a profile file. + --- ### `rm ` @@ -211,6 +267,9 @@ omo-switch rm dev --scope project - Checks project scope first, then global scope - Prompts for confirmation before deletion +OMOS behavior: +- When in `slim` mode, `rm` removes the corresponding preset from the `presets` object inside `oh-my-opencode-slim.json`. + --- ### `schema refresh` @@ -232,14 +291,47 @@ omo-switch schema refresh --offline --- +## OMO vs OMOS Modes + +`omo-switch` supports two configuration formats: + +### OMO Mode (oh-my-opencode) +- **Multi-file architecture**: Each profile is stored as a separate `.json` or `.jsonc` file +- **Profile switching**: Copy selected profile to target configuration path +- **Best for**: Users who want isolated, independent profile files + +### OMOS Mode (oh-my-opencode-slim) +- **Single-file architecture**: All presets are managed within a single `oh-my-opencode-slim.json` file. +- **Preset switching**: Switching is done by modifying the `preset` field at the top level of the file. +- **Best for**: Users who prefer a unified configuration and want to switch "on-the-fly" without copying files. +- **Usage**: Set your type to `slim` using `omo-switch type slim`. + +#### Working with Slim Mode: +1. **List Presets**: `omo-switch list` shows all presets defined in your slim config. +2. **Add Preset**: `omo-switch add ./my-preset.json` reads a JSON file containing a preset definition and appends it to the `presets` object in your slim config. +3. **Apply Preset**: `omo-switch apply preset-id` updates the `preset` field in the slim config to point to `preset-id`. + +### Command Behavior by Mode + +| Command | OMO Mode | OMOS Mode | +|---------|----------|-----------| +| `type` | Show/set current mode | Same | +| `list` | List profile files | List presets in `presets` object | +| `add` | Import file as new profile | Add preset to `presets` object | +| `apply` | Copy profile to target path | Set `preset` field | +| `rm` | Delete profile file | Remove preset from `presets` | +| `show` | Display profile content | Display OMOS config | + + ## Understanding Scopes `omo-switch` supports two scopes for profile management: | Scope | Storage Location | Target Config Path | Use Case | |-------|------------------|-------------------|----------| -| `user` | `~/.config/omo-switch/` | `~/.config/opencode/oh-my-opencode.jsonc` | Global profiles shared across all projects | -| `project` | `/.opencode/` | `/.opencode/oh-my-opencode.jsonc` | Project-specific profiles, ideal for team sharing via Git | +| `user` | `~/.config/omo-switch/` | `~/.config/opencode/oh-my-opencode.jsonc` (OMO) / `~/.config/opencode/oh-my-opencode-slim.json` (OMOS) | Global profiles/presets shared across all projects | +| `project` | `/.opencode/` | `/.opencode/oh-my-opencode.jsonc` (OMO) / `/.opencode/oh-my-opencode-slim.json` (OMOS) | Project-specific profiles/presets, ideal for team sharing via Git | + ## Storage Structure @@ -247,8 +339,8 @@ omo-switch schema refresh --offline ``` ~/.config/omo-switch/ -├── index.json # Profile registry with active profile ID -├── configs/ # Profile configuration files (*.json, *.jsonc) +├── index.json # Profile registry with active profile ID (OMO) +├── configs/ # Profile configuration files (*.json, *.jsonc) (OMO) ├── cache/ │ └── schema/ # Cached oh-my-opencode.schema.json └── backups/ # Timestamped configuration backups @@ -258,9 +350,10 @@ omo-switch schema refresh --offline ``` /.opencode/ -├── .omorc # Project-specific active profile -├── omo-configs/ # Project-specific profiles -└── oh-my-opencode.jsonc # Applied project config (target) +├── .omorc # Project-specific active profile (OMO) +├── omo-configs/ # Project-specific profiles (OMO) +├── oh-my-opencode.jsonc # Applied project config (target) (OMO) +└── oh-my-opencode-slim.json # Applied project config (target) (OMOS) ``` ## Target Configuration Paths @@ -269,9 +362,9 @@ When applying a profile, `omo-switch` writes to: | Scope | Platform | Primary Path | Fallback Path | |-------|----------|--------------|---------------| -| `user` | Windows | `%USERPROFILE%\.config\opencode\oh-my-opencode.jsonc` | `%APPDATA%\opencode\oh-my-opencode.json` | -| `user` | Linux/macOS | `$XDG_CONFIG_HOME/opencode/oh-my-opencode.jsonc` | `~/.config/opencode/oh-my-opencode.jsonc` | -| `project` | All | `/.opencode/oh-my-opencode.jsonc` | - | +| `user` | Windows | `%USERPROFILE%\.config\opencode\oh-my-opencode.jsonc` (OMO) / `%USERPROFILE%\.config\opencode\oh-my-opencode-slim.json` (OMOS) | `%APPDATA%\opencode\oh-my-opencode.json` | +| `user` | Linux/macOS | `$XDG_CONFIG_HOME/opencode/oh-my-opencode.jsonc` (OMO) / `$XDG_CONFIG_HOME/opencode/oh-my-opencode-slim.json` (OMOS) | `~/.config/opencode/oh-my-opencode.jsonc` | +| `project` | All | `/.opencode/oh-my-opencode.jsonc` (OMO) / `/.opencode/oh-my-opencode-slim.json` (OMOS) | - | ## Local Development diff --git a/shared/assets/default-template-slim.json b/shared/assets/default-template-slim.json new file mode 100644 index 0000000..6f79d32 --- /dev/null +++ b/shared/assets/default-template-slim.json @@ -0,0 +1,13 @@ +{ + "preset": "zen-free", + "presets": { + "zen-free": { + "orchestrator": { "model": "opencode/big-pickle" }, + "oracle": { "model": "opencode/big-pickle" }, + "librarian": { "model": "opencode/big-pickle" }, + "explorer": { "model": "opencode/big-pickle" }, + "designer": { "model": "opencode/big-pickle" }, + "fixer": { "model": "opencode/big-pickle" } + } + } +} \ No newline at end of file diff --git a/src/commands/add.test.ts b/src/commands/add.test.ts index 1f27965..dc22433 100644 --- a/src/commands/add.test.ts +++ b/src/commands/add.test.ts @@ -117,6 +117,31 @@ vi.mock("../store", () => { return []; } }, + SettingsManager: class { + getEffectiveType() { + return "omo"; + } + loadSettings() { + return { type: "omo" }; + } + isProjectOverride() { + return false; + } + }, + OmosConfigManager: class { + constructor() {} + getPreset() { + return null; + } + addPreset() {} + setActivePreset() {} + getActivePreset() { + return null; + } + createBackup() { + return null; + } + }, __createdStoreInstances, __createdProjectStoreInstances, }; @@ -228,7 +253,7 @@ describe("addCommand", () => { // Helper function to invoke the add command with mocked action handler async function runAdd( file: string, - opts: { scope?: string; force?: boolean; activate?: boolean; id?: string } = {} + opts: { scope?: string; force?: boolean; id?: string } = {} ) { // Use the addCommand from scope (which has mocks applied) const cmd = addCommand as any; @@ -246,10 +271,6 @@ describe("addCommand", () => { cmd._optionValues.force = true; cmd._optionValueSources.force = 'cli'; } - if (opts.activate) { - cmd._optionValues.activate = true; - cmd._optionValueSources.activate = 'cli'; - } if (opts.id) { cmd._optionValues.id = opts.id; cmd._optionValueSources.id = 'cli'; @@ -360,20 +381,6 @@ describe("addCommand", () => { expect(chalk.red).toHaveBeenCalledWith(expect.stringContaining("Only .json and .jsonc")); }); - it("activates profile when --activate flag is set", async () => { - vi.mocked(select).mockResolvedValue("user" as any); - vi.spyOn(StoreManagerClass.prototype, "loadIndex").mockReturnValue({ - profiles: [], - activeProfileId: null, - }); - - await runAdd("/path/to/config.json", { activate: true, scope: "user" }); - - const inst3 = await lastStoreInstance(); - const saveCall = vi.mocked(inst3.saveIndex).mock.calls[0][0]; - expect(saveCall.activeProfileId).toBeTruthy(); - }); - it("overwrites existing profile when --force flag is set", async () => { vi.mocked(select).mockResolvedValue("user" as any); vi.spyOn(StoreManagerClass.prototype, "loadIndex").mockReturnValue({ diff --git a/src/commands/add.ts b/src/commands/add.ts index 3894257..be24c1d 100644 --- a/src/commands/add.ts +++ b/src/commands/add.ts @@ -5,14 +5,15 @@ import * as fs from "fs"; import * as path from "path"; import JSON5 from "json5"; import { select } from "@inquirer/prompts"; -import { StoreManager, ProjectStoreManager, Scope, Profile } from "../store"; +import { StoreManager, ProjectStoreManager, Scope, Profile, OmosConfigManager, SettingsManager, OmosPresetConfig } from "../store"; import { Validator } from "../utils/validator"; +import { OmosValidator } from "../utils/omos-validator"; import { downloadFile, readBundledAsset } from "../utils/downloader"; -import { resolveProjectRoot } from "../utils/scope-resolver"; +import { resolveProjectRoot, findProjectRoot } from "../utils/scope-resolver"; -const SCHEMA_URL = "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json"; +const OMO_SCHEMA_URL = "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json"; -async function ensureSchemaAvailable(store: StoreManager): Promise { +async function ensureOmoSchemaAvailable(store: StoreManager): Promise { const schemaPath = path.join(store.getCacheSchemaPath(), "oh-my-opencode.schema.json"); if (fs.existsSync(schemaPath)) { @@ -21,7 +22,7 @@ async function ensureSchemaAvailable(store: StoreManager): Promise { try { await downloadFile( - SCHEMA_URL, + OMO_SCHEMA_URL, store.getCacheSchemaPath(), "oh-my-opencode.schema.json", { source: "github" } @@ -37,6 +38,23 @@ async function ensureSchemaAvailable(store: StoreManager): Promise { } } +async function ensureOmosSchemaAvailable(store: StoreManager): Promise { + const schemaPath = path.join(store.getCacheSchemaPath(), "oh-my-opencode-slim.schema.json"); + + if (fs.existsSync(schemaPath)) { + return schemaPath; + } + + // For OMOS, we use the bundled schema directly (no remote URL currently) + const bundledSchema = readBundledAsset("oh-my-opencode-slim.schema.json"); + if (bundledSchema) { + store.saveCacheFile(store.getCacheSchemaPath(), "oh-my-opencode-slim.schema.json", bundledSchema, { source: "bundled" }); + return schemaPath; + } + + throw new Error("OMOS schema not found in bundled assets"); +} + function deriveIdFromName(name: string): string { return name .toLowerCase() @@ -48,186 +66,288 @@ function deriveIdFromName(name: string): string { interface AddOptions { id?: string; name?: string; - activate?: boolean; force?: boolean; scope?: Scope; } -export const addCommand = new Command("add") - .description("Add a profile from a configuration file") - .argument("", "Path to the configuration file to import (supports JSON and JSONC)") - .option("--id ", "Profile ID (defaults to derived from name)") - .option("--name ", "Profile name (defaults to derived from id)") - .option("--activate", "Activate this profile after adding it") - .option("--force", "Overwrite existing profile with the same id") - .option("--scope ", "Target scope (user or project)") - .action(async (file: string, options: AddOptions) => { - const spinner = ora().start(); +/** + * Handle adding a preset in OMOS mode + */ +async function handleOmosAdd( + file: string, + options: AddOptions, + spinner: ReturnType +): Promise { + const globalStore = new StoreManager(); + globalStore.ensureDirectories(); + const filePath = path.resolve(file); + + spinner.text = "Reading preset file..."; + if (!fs.existsSync(filePath)) { + spinner.fail(`File not found: ${filePath}`); + process.exit(1); + } - try { - const globalStore = new StoreManager(); - globalStore.ensureDirectories(); - const filePath = path.resolve(file); + // OMOS only supports .json files + const fileExt = path.extname(filePath).toLowerCase(); + if (fileExt !== ".json") { + spinner.fail(`Invalid file extension: ${fileExt}`); + console.error(chalk.red("OMOS mode only supports .json files (no .jsonc)")); + process.exit(1); + } - spinner.text = "Reading input file..."; - if (!fs.existsSync(filePath)) { - spinner.fail(`File not found: ${filePath}`); - process.exit(1); - } + const fileContent = fs.readFileSync(filePath, "utf-8"); + let presetConfig: OmosPresetConfig; - const fileContent = fs.readFileSync(filePath, "utf-8"); - let config: Record; + try { + presetConfig = JSON.parse(fileContent) as OmosPresetConfig; + } catch (err) { + spinner.fail(`Failed to parse file as JSON: ${err instanceof Error ? err.message : "Unknown error"}`); + process.exit(1); + } - try { - config = JSON5.parse(fileContent) as Record; - } catch (err) { - spinner.fail(`Failed to parse file as JSON/JSONC: ${err instanceof Error ? err.message : "Unknown error"}`); - process.exit(1); - } + // Validate preset + spinner.text = "Validating preset configuration..."; + const schemaPath = await ensureOmosSchemaAvailable(globalStore); + const validator = new OmosValidator(schemaPath); + const validation = validator.validatePreset(presetConfig); + + if (!validation.valid) { + spinner.fail("Preset validation failed"); + console.error(chalk.red("Validation errors:")); + for (const err of validation.errors) { + console.error(chalk.red(` - ${err}`)); + } + process.exit(1); + } - spinner.text = "Ensuring schema availability..."; - const schemaPath = await ensureSchemaAvailable(globalStore); + // Determine scope + let scope: Scope = options.scope as Scope; + if (!scope) { + spinner.stop(); + const answer = await select({ + message: "Where do you want to add this preset?", + choices: [ + { name: "Global (user)", value: "user" }, + { name: "Project", value: "project" }, + ], + }); + scope = answer as Scope; + spinner.start(); + } - spinner.text = "Validating configuration..."; - const validator = new Validator(schemaPath); - const validation = validator.validate(config); + if (scope !== "user" && scope !== "project") { + spinner.fail(`Invalid scope: ${scope}. Use 'user' or 'project'.`); + process.exit(1); + } - if (!validation.valid) { - spinner.fail("Configuration validation failed"); - console.error(chalk.red("Validation errors:")); - for (const err of validation.errors) { - console.error(chalk.red(` - ${err}`)); - } - process.exit(1); - } + // Derive preset name + let presetName = options.name || options.id; + if (!presetName) { + presetName = deriveIdFromName(path.basename(filePath, path.extname(filePath))); + } - // Determine scope: if not provided, prompt user - let scope: Scope = options.scope as Scope; - if (!scope) { - spinner.stop(); - const answer = await select({ - message: "Where do you want to store this profile?", - choices: [ - { name: "Global (user)", value: "user" }, - { name: "Project", value: "project" }, - ], - }); - scope = answer as Scope; - spinner.start(); - } + // Get the appropriate OMOS manager + const omosManager = scope === "project" + ? new OmosConfigManager("project", resolveProjectRoot()) + : new OmosConfigManager("user"); - if (scope !== "user" && scope !== "project") { - spinner.fail(`Invalid scope: ${scope}. Use 'user' or 'project'.`); - process.exit(1); - } + // Check if preset exists + const existingPreset = omosManager.getPreset(presetName); + if (existingPreset && !options.force) { + spinner.fail(`Preset '${presetName}' already exists. Use --force to overwrite.`); + process.exit(1); + } - let profileId = options.id; - let profileName = options.name; + // Create backup before modification + spinner.text = "Creating backup..."; + omosManager.createBackup(); - if (!profileName && !profileId) { - profileId = deriveIdFromName(path.basename(filePath, path.extname(filePath))); - profileName = profileId; - } else if (profileName && !profileId) { - profileId = deriveIdFromName(profileName); - } else if (!profileName && profileId) { - profileName = profileId; - } + // Add preset + spinner.text = "Adding preset..."; + omosManager.addPreset(presetName, presetConfig); - const fileExt = path.extname(filePath).toLowerCase() as ".json" | ".jsonc"; + const action = existingPreset ? "Updated" : "Added"; + spinner.succeed(`${action} preset '${presetName}' [${scope}]`); + console.log(chalk.gray(` Target: ${omosManager.getTargetPath()}`)); +} - if (fileExt !== ".json" && fileExt !== ".jsonc") { - spinner.fail(`Invalid file extension: ${fileExt}`); - console.error(chalk.red("Only .json and .jsonc files are supported")); - process.exit(1); - } +/** + * Handle adding a profile in OMO mode (original behavior) + */ +async function handleOmoAdd( + file: string, + options: AddOptions, + spinner: ReturnType +): Promise { + const globalStore = new StoreManager(); + globalStore.ensureDirectories(); + const filePath = path.resolve(file); + + spinner.text = "Reading input file..."; + if (!fs.existsSync(filePath)) { + spinner.fail(`File not found: ${filePath}`); + process.exit(1); + } - // profileId and profileName are guaranteed to be set at this point - const finalProfileId = profileId as string; - const finalProfileName = profileName as string; + const fileContent = fs.readFileSync(filePath, "utf-8"); + let config: Record; - if (scope === "project") { - // Project scope: use ProjectStoreManager - const projectRoot = resolveProjectRoot(); - const projectStore = new ProjectStoreManager(projectRoot); - projectStore.ensureDirectories(); + try { + config = JSON5.parse(fileContent) as Record; + } catch (err) { + spinner.fail(`Failed to parse file as JSON/JSONC: ${err instanceof Error ? err.message : "Unknown error"}`); + process.exit(1); + } + + spinner.text = "Ensuring schema availability..."; + const schemaPath = await ensureOmoSchemaAvailable(globalStore); + + spinner.text = "Validating configuration..."; + const validator = new Validator(schemaPath); + const validation = validator.validate(config); + + if (!validation.valid) { + spinner.fail("Configuration validation failed"); + console.error(chalk.red("Validation errors:")); + for (const err of validation.errors) { + console.error(chalk.red(` - ${err}`)); + } + process.exit(1); + } + + // Determine scope: if not provided, prompt user + let scope: Scope = options.scope as Scope; + if (!scope) { + spinner.stop(); + const answer = await select({ + message: "Where do you want to store this profile?", + choices: [ + { name: "Global (user)", value: "user" }, + { name: "Project", value: "project" }, + ], + }); + scope = answer as Scope; + spinner.start(); + } + + if (scope !== "user" && scope !== "project") { + spinner.fail(`Invalid scope: ${scope}. Use 'user' or 'project'.`); + process.exit(1); + } + + let profileId = options.id; + let profileName = options.name; + + if (!profileName && !profileId) { + profileId = deriveIdFromName(path.basename(filePath, path.extname(filePath))); + profileName = profileId; + } else if (profileName && !profileId) { + profileId = deriveIdFromName(profileName); + } else if (!profileName && profileId) { + profileName = profileId; + } + + const fileExt = path.extname(filePath).toLowerCase() as ".json" | ".jsonc"; + + if (fileExt !== ".json" && fileExt !== ".jsonc") { + spinner.fail(`Invalid file extension: ${fileExt}`); + console.error(chalk.red("Only .json and .jsonc files are supported")); + process.exit(1); + } + + // profileId and profileName are guaranteed to be set at this point + const finalProfileId = profileId as string; + const finalProfileName = profileName as string; + + if (scope === "project") { + // Project scope: use ProjectStoreManager + const projectRoot = resolveProjectRoot(); + const projectStore = new ProjectStoreManager(projectRoot); + projectStore.ensureDirectories(); - const existingConfig = projectStore.configExists(finalProfileId); + const existingConfig = projectStore.configExists(finalProfileId); - if (existingConfig && !options.force) { - spinner.fail(`Profile with id '${finalProfileId}' already exists in project. Use --force to overwrite.`); - process.exit(1); - } + if (existingConfig && !options.force) { + spinner.fail(`Profile with id '${finalProfileId}' already exists in project. Use --force to overwrite.`); + process.exit(1); + } + + if (existingConfig && options.force) { + projectStore.deleteProfileConfig(finalProfileId); + } + + projectStore.saveProfileConfigRaw(finalProfileId, fileContent, fileExt); + + const action = existingConfig ? "Updated" : "Added"; + spinner.succeed(`${action} profile '${finalProfileName}' (${finalProfileId}) [project]`); + console.log(chalk.gray(` Config: ${projectStore.getConfigsPath()}/${finalProfileId}${fileExt}`)); + } else { + // User scope: use StoreManager (existing behavior) + const index = globalStore.loadIndex(); + const existingProfile = index.profiles.find((p) => p.id === finalProfileId); + + if (existingProfile) { + if (!options.force) { + spinner.fail(`Profile with id '${finalProfileId}' already exists. Use --force to overwrite.`); + console.error(chalk.gray(` Name: ${existingProfile.name}`)); + console.error(chalk.gray(` Updated: ${existingProfile.updatedAt}`)); + process.exit(1); + } + existingProfile.name = finalProfileName; + existingProfile.config = config; + existingProfile.updatedAt = new Date().toISOString(); + } else { + const profile: Profile = { + id: finalProfileId, + name: finalProfileName, + config: config, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + index.profiles.push(profile); + } - if (existingConfig && options.force) { - projectStore.deleteProfileConfig(finalProfileId); - } + globalStore.saveIndex(index); - projectStore.saveProfileConfigRaw(finalProfileId, fileContent, fileExt); + if (existingProfile) { + const existingConfigPath = globalStore.getProfileConfigPath(finalProfileId); + if (existingConfigPath) { + fs.unlinkSync(existingConfigPath); + } + } - if (options.activate) { - projectStore.saveRc({ - activeProfileId: finalProfileId, - }); - } + globalStore.saveProfileConfigRaw(finalProfileId, fileContent, fileExt); - const action = existingConfig ? "Updated" : "Added"; - spinner.succeed(`${action} profile '${finalProfileName}' (${finalProfileId}) [project]`); - console.log(chalk.gray(` Config: ${projectStore.getConfigsPath()}/${finalProfileId}${fileExt}`)); + const action = existingProfile ? "Updated" : "Added"; + spinner.succeed(`${action} profile '${finalProfileName}' (${finalProfileId}) [user]`); + console.log(chalk.gray(` Config: ${globalStore.getConfigsPath()}/${finalProfileId}${fileExt}`)); + } +} + +export const addCommand = new Command("add") + .description("Add a profile or preset from a configuration file") + .argument("", "Path to the configuration file to import") + .option("--id ", "Profile/Preset ID (defaults to derived from name)") + .option("--name ", "Profile/Preset name (defaults to derived from id)") + .option("--force", "Overwrite existing profile/preset with the same id") + .option("--scope ", "Target scope (user or project)") + .action(async (file: string, options: AddOptions) => { + const spinner = ora().start(); + + try { + // Determine active type + const settings = new SettingsManager(); + const projectRoot = findProjectRoot(); + const activeType = settings.getEffectiveType(projectRoot ?? undefined); - if (options.activate) { - console.log(chalk.green(` Profile activated`)); - } + if (activeType === "slim") { + await handleOmosAdd(file, options, spinner); } else { - // User scope: use StoreManager (existing behavior) - const index = globalStore.loadIndex(); - const existingProfile = index.profiles.find((p) => p.id === finalProfileId); - - if (existingProfile) { - if (!options.force) { - spinner.fail(`Profile with id '${finalProfileId}' already exists. Use --force to overwrite.`); - console.error(chalk.gray(` Name: ${existingProfile.name}`)); - console.error(chalk.gray(` Updated: ${existingProfile.updatedAt}`)); - process.exit(1); - } - existingProfile.name = finalProfileName; - existingProfile.config = config; - existingProfile.updatedAt = new Date().toISOString(); - } else { - const profile: Profile = { - id: finalProfileId, - name: finalProfileName, - config: config, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - index.profiles.push(profile); - } - - if (options.activate) { - index.activeProfileId = finalProfileId; - } - - globalStore.saveIndex(index); - - if (existingProfile) { - const existingConfigPath = globalStore.getProfileConfigPath(finalProfileId); - if (existingConfigPath) { - fs.unlinkSync(existingConfigPath); - } - } - - globalStore.saveProfileConfigRaw(finalProfileId, fileContent, fileExt); - - const action = existingProfile ? "Updated" : "Added"; - spinner.succeed(`${action} profile '${finalProfileName}' (${finalProfileId}) [user]`); - console.log(chalk.gray(` Config: ${globalStore.getConfigsPath()}/${finalProfileId}${fileExt}`)); - - if (options.activate) { - console.log(chalk.green(` Profile activated`)); - } + await handleOmoAdd(file, options, spinner); } } catch (err) { - spinner.fail(`Failed to add profile: ${err instanceof Error ? err.message : "Unknown error"}`); + spinner.fail(`Failed to add: ${err instanceof Error ? err.message : "Unknown error"}`); process.exit(1); } }); diff --git a/src/commands/apply.ts b/src/commands/apply.ts index 5465756..15125ce 100644 --- a/src/commands/apply.ts +++ b/src/commands/apply.ts @@ -4,11 +4,11 @@ import ora from "ora"; import * as fs from "fs"; import * as path from "path"; import JSON5 from "json5"; -import { StoreManager, ProjectStoreManager, Scope } from "../store"; +import { StoreManager, ProjectStoreManager, Scope, OmosConfigManager, SettingsManager } from "../store"; import { Validator } from "../utils/validator"; import { getConfigTargetDir, ensureConfigDir } from "../utils/config-path"; import { downloadFile, readBundledAsset } from "../utils/downloader"; -import { resolveProjectRoot } from "../utils/scope-resolver"; +import { resolveProjectRoot, findProjectRoot } from "../utils/scope-resolver"; const SCHEMA_URL = "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json"; @@ -41,162 +41,234 @@ interface ApplyOptions { scope: Scope; } -export const applyCommand = new Command("apply") - .description("Apply profile configuration") - .argument("", "Profile ID or name") - .option("--scope ", "Target scope (user or project)", "user") - .action(async (identifier: string, options: ApplyOptions) => { - const spinner = ora().start(); - const scope = options.scope as Scope; +/** + * Handle applying a preset in OMOS mode. + * Unlike OMO, this just changes the `preset` field - no file copy needed. + */ +async function handleOmosApply( + identifier: string, + options: ApplyOptions, + spinner: ReturnType +): Promise { + const scope = options.scope as Scope; - if (scope !== "user" && scope !== "project") { - spinner.fail(`Invalid scope: ${scope}. Use 'user' or 'project'.`); + if (scope !== "user" && scope !== "project") { + spinner.fail(`Invalid scope: ${scope}. Use 'user' or 'project'.`); + process.exit(1); + } + + // Get the appropriate OMOS manager + const omosManager = scope === "project" + ? new OmosConfigManager("project", resolveProjectRoot()) + : new OmosConfigManager("user"); + + // Check if preset exists + const presets = omosManager.listPresets(); + if (!presets.includes(identifier)) { + // Try case-insensitive match + const matchedPreset = presets.find( + (p) => p.toLowerCase() === identifier.toLowerCase() + ); + if (!matchedPreset) { + spinner.fail(`Preset '${identifier}' not found`); + console.log(chalk.gray(`Available presets: ${presets.join(", ") || "(none)"}`)); process.exit(1); } + // Use the matched preset name + identifier = matchedPreset; + } - try { - const globalStore = new StoreManager(); - globalStore.syncProfiles(); - const globalIndex = globalStore.loadIndex(); - - let projectStore: ProjectStoreManager | null = null; - if (scope === "project") { - const projectRoot = resolveProjectRoot(); - projectStore = new ProjectStoreManager(projectRoot); - projectStore.ensureDirectories(); - } + // Create backup before modification + spinner.text = "Creating backup..."; + const backupPath = omosManager.createBackup(); - // Find profile: first search in target scope, then fallback to other scope - let profile = globalIndex.profiles.find( - (p) => p.id === identifier || p.name.toLowerCase() === identifier.toLowerCase() - ); - let configRaw: { path: string; content: string } | null = null; - let profileSource: "user" | "project" = "user"; - - if (scope === "project" && projectStore) { - // For project scope, first check project profiles - const projectProfiles = projectStore.listProfiles(); - const projectProfileId = projectProfiles.find( - (id) => id === identifier || id.toLowerCase() === identifier.toLowerCase() - ); - if (projectProfileId) { - configRaw = projectStore.getProfileConfigRaw(projectProfileId); - profileSource = "project"; - // Create a temporary profile object for project-only profiles - if (!profile) { - profile = { - id: projectProfileId, - name: projectProfileId, - config: {}, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - } - } - } + // Set active preset + spinner.text = "Setting active preset..."; + omosManager.setActivePreset(identifier); - // If not found in project, try global - if (!configRaw && profile) { - configRaw = globalStore.getProfileConfigRaw(profile.id); - profileSource = "user"; - } + spinner.succeed(`Applied preset '${identifier}' [${scope}]`); + console.log(chalk.gray(` Target: ${omosManager.getTargetPath()}`)); + if (backupPath) { + console.log(chalk.gray(` Backup: ${backupPath}`)); + } +} + +/** + * Handle applying a profile in OMO mode (original behavior). + */ +async function handleOmoApply( + identifier: string, + options: ApplyOptions, + spinner: ReturnType +): Promise { + const scope = options.scope as Scope; + if (scope !== "user" && scope !== "project") { + spinner.fail(`Invalid scope: ${scope}. Use 'user' or 'project'.`); + process.exit(1); + } + + const globalStore = new StoreManager(); + globalStore.syncProfiles(); + const globalIndex = globalStore.loadIndex(); + + let projectStore: ProjectStoreManager | null = null; + if (scope === "project") { + const projectRoot = resolveProjectRoot(); + projectStore = new ProjectStoreManager(projectRoot); + projectStore.ensureDirectories(); + } + + // Find profile: first search in target scope, then fallback to other scope + let profile = globalIndex.profiles.find( + (p) => p.id === identifier || p.name.toLowerCase() === identifier.toLowerCase() + ); + let configRaw: { path: string; content: string } | null = null; + let profileSource: "user" | "project" = "user"; + + if (scope === "project" && projectStore) { + // For project scope, first check project profiles + const projectProfiles = projectStore.listProfiles(); + const projectProfileId = projectProfiles.find( + (id) => id === identifier || id.toLowerCase() === identifier.toLowerCase() + ); + if (projectProfileId) { + configRaw = projectStore.getProfileConfigRaw(projectProfileId); + profileSource = "project"; + // Create a temporary profile object for project-only profiles if (!profile) { - spinner.fail(`Profile not found: ${identifier}`); - process.exit(1); + profile = { + id: projectProfileId, + name: projectProfileId, + config: {}, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; } + } + } - spinner.text = "Loading profile configuration..."; - if (!configRaw) { - spinner.fail("Profile config file not found"); - console.error(chalk.red(`Profile '${identifier}' exists in index but config file is missing.`)); - console.error(chalk.gray(`Source: ${profileSource}`)); - process.exit(1); - } + // If not found in project, try global + if (!configRaw && profile) { + configRaw = globalStore.getProfileConfigRaw(profile.id); + profileSource = "user"; + } - spinner.text = "Validating configuration..."; - let config: Record; - try { - config = JSON5.parse(configRaw.content) as Record; - } catch (err) { - spinner.fail("Failed to parse config as JSON/JSONC"); - console.error(chalk.red(`Error: ${err instanceof Error ? err.message : "Unknown error"}`)); - process.exit(1); - } + if (!profile) { + spinner.fail(`Profile not found: ${identifier}`); + process.exit(1); + } - spinner.text = "Ensuring schema availability..."; - const schemaPath = await ensureSchemaAvailable(globalStore); - - spinner.text = "Validating configuration..."; - const validator = new Validator(schemaPath); - const validation = validator.validate(config); - - if (!validation.valid) { - spinner.fail("Configuration validation failed"); - console.error(chalk.red("Validation errors:")); - for (const err of validation.errors) { - console.error(chalk.red(` - ${err}`)); - } - process.exit(1); - } + spinner.text = "Loading profile configuration..."; + if (!configRaw) { + spinner.fail("Profile config file not found"); + console.error(chalk.red(`Profile '${identifier}' exists in index but config file is missing.`)); + console.error(chalk.gray(`Source: ${profileSource}`)); + process.exit(1); + } - // Determine target path based on scope - let targetPath: string; - let backupPath: string | null = null; + spinner.text = "Validating configuration..."; + let config: Record; + try { + config = JSON5.parse(configRaw.content) as Record; + } catch (err) { + spinner.fail("Failed to parse config as JSON/JSONC"); + console.error(chalk.red(`Error: ${err instanceof Error ? err.message : "Unknown error"}`)); + process.exit(1); + } - if (scope === "project" && projectStore) { - targetPath = projectStore.getTargetPath(); - ensureConfigDir(targetPath); + spinner.text = "Ensuring schema availability..."; + const schemaPath = await ensureSchemaAvailable(globalStore); - spinner.text = "Creating backup..."; - backupPath = projectStore.createBackup(targetPath); - } else { - // User scope: always write to .jsonc file - spinner.text = "Determining target path..."; - const targetDir = getConfigTargetDir(); - targetPath = path.join(targetDir.dir, "oh-my-opencode.jsonc"); - ensureConfigDir(targetPath); - - // Create backup of existing config (could be .json or .jsonc) - spinner.text = "Creating backup..."; - const existingJsonc = path.join(targetDir.dir, "oh-my-opencode.jsonc"); - const existingJson = path.join(targetDir.dir, "oh-my-opencode.json"); - if (fs.existsSync(existingJsonc)) { - backupPath = globalStore.createBackup(existingJsonc); - } else if (fs.existsSync(existingJson)) { - backupPath = globalStore.createBackup(existingJson); - } - } + spinner.text = "Validating configuration..."; + const validator = new Validator(schemaPath); + const validation = validator.validate(config); - if (backupPath) { - spinner.text = `Backup created: ${backupPath}`; - } + if (!validation.valid) { + spinner.fail("Configuration validation failed"); + console.error(chalk.red("Validation errors:")); + for (const err of validation.errors) { + console.error(chalk.red(` - ${err}`)); + } + process.exit(1); + } - spinner.text = `Writing to ${targetPath}...`; - const headerComment = `// Profile Name: ${profile.name}, edited by omo-switch`; - // Issue 5 fix: Always use new content with header, don't try to preserve old content - const targetContent = `${headerComment}\n${configRaw.content}`; - - fs.writeFileSync(targetPath, targetContent, "utf-8"); - - // Update state based on scope - if (scope === "project" && projectStore) { - projectStore.saveRc({ - activeProfileId: profile.id, - }); - } else { - globalIndex.activeProfileId = profile.id; - globalStore.saveIndex(globalIndex); - } + // Determine target path based on scope + let targetPath: string; + let backupPath: string | null = null; + + if (scope === "project" && projectStore) { + targetPath = projectStore.getTargetPath(); + ensureConfigDir(targetPath); + + spinner.text = "Creating backup..."; + backupPath = projectStore.createBackup(targetPath); + } else { + // User scope: always write to .jsonc file + spinner.text = "Determining target path..."; + const targetDir = getConfigTargetDir(); + targetPath = path.join(targetDir.dir, "oh-my-opencode.jsonc"); + ensureConfigDir(targetPath); + + // Create backup of existing config (could be .json or .jsonc) + spinner.text = "Creating backup..."; + const existingJsonc = path.join(targetDir.dir, "oh-my-opencode.jsonc"); + const existingJson = path.join(targetDir.dir, "oh-my-opencode.json"); + if (fs.existsSync(existingJsonc)) { + backupPath = globalStore.createBackup(existingJsonc); + } else if (fs.existsSync(existingJson)) { + backupPath = globalStore.createBackup(existingJson); + } + } + + if (backupPath) { + spinner.text = `Backup created: ${backupPath}`; + } - spinner.succeed(`Applied profile '${profile.name}' (${profile.id}) [${scope}]`); - console.log(chalk.gray(` Source: ${profileSource}`)); - console.log(chalk.gray(` Target: ${targetPath}`)); - if (backupPath) { - console.log(chalk.gray(` Backup: ${backupPath}`)); + spinner.text = `Writing to ${targetPath}...`; + const headerComment = `// Profile Name: ${profile.name}, edited by omo-switch`; + const targetContent = `${headerComment}\n${configRaw.content}`; + + fs.writeFileSync(targetPath, targetContent, "utf-8"); + + // Update state based on scope + if (scope === "project" && projectStore) { + projectStore.saveRc({ + activeProfileId: profile.id, + }); + } else { + globalIndex.activeProfileId = profile.id; + globalStore.saveIndex(globalIndex); + } + + spinner.succeed(`Applied profile '${profile.name}' (${profile.id}) [${scope}]`); + console.log(chalk.gray(` Source: ${profileSource}`)); + console.log(chalk.gray(` Target: ${targetPath}`)); + if (backupPath) { + console.log(chalk.gray(` Backup: ${backupPath}`)); + } +} + +export const applyCommand = new Command("apply") + .description("Apply profile or preset configuration") + .argument("", "Profile ID, profile name, or preset name") + .option("--scope ", "Target scope (user or project)", "user") + .action(async (identifier: string, options: ApplyOptions) => { + const spinner = ora().start(); + + try { + // Determine active type + const settings = new SettingsManager(); + const projectRoot = findProjectRoot(); + const activeType = settings.getEffectiveType(projectRoot ?? undefined); + + if (activeType === "slim") { + await handleOmosApply(identifier, options, spinner); + } else { + await handleOmoApply(identifier, options, spinner); } } catch (err) { - spinner.fail(`Failed to apply profile: ${err instanceof Error ? err.message : "Unknown error"}`); + spinner.fail(`Failed to apply: ${err instanceof Error ? err.message : "Unknown error"}`); process.exit(1); } }); diff --git a/src/commands/list.test.ts b/src/commands/list.test.ts index 6f9f02c..cdc57fd 100644 --- a/src/commands/list.test.ts +++ b/src/commands/list.test.ts @@ -5,6 +5,17 @@ vi.mock("../store", async () => { const actual = await vi.importActual("../store"); return { ...actual, + SettingsManager: class { + getEffectiveType() { + return "omo"; + } + loadSettings() { + return { type: "omo" }; + } + isProjectOverride() { + return false; + } + }, }; }); @@ -107,7 +118,8 @@ describe("listCommand", () => { runList({ scope: "user" }); expect(Table).toHaveBeenCalled(); - expect(findProjectRoot).not.toHaveBeenCalled(); + // findProjectRoot is now called for SettingsManager.getEffectiveType check + expect(findProjectRoot).toHaveBeenCalled(); }); it("lists project profiles when .opencode directory exists", () => { diff --git a/src/commands/list.ts b/src/commands/list.ts index 5c5f004..ca23ec5 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -1,8 +1,8 @@ import { Command } from "commander"; import chalk from "chalk"; import Table from "cli-table3"; -import { StoreManager, ProjectStoreManager } from "../store"; -import { resolveProjectRoot, findProjectRoot } from "../utils/scope-resolver"; +import { StoreManager, ProjectStoreManager, OmosConfigManager, SettingsManager } from "../store"; +import { findProjectRoot } from "../utils/scope-resolver"; type ListScope = "user" | "project" | "all"; @@ -19,8 +19,200 @@ interface ProfileRow { scope: "user" | "project"; } +interface OmosPresetRow { + name: string; + isActive: boolean; + agentCount: number; + scope: "user" | "project"; +} + +/** + * List OMO profiles (original behavior) + */ +function listOmoProfiles(scope: ListScope): void { + const rows: ProfileRow[] = []; + + // Collect global profiles + if (scope === "user" || scope === "all") { + const globalStore = new StoreManager(); + const { added } = globalStore.syncProfiles(); + + if (added.length > 0) { + console.log(chalk.gray(`Discovered ${added.length} new global profile(s): ${added.join(", ")}`)); + } + + const globalIndex = globalStore.loadIndex(); + + for (const profile of globalIndex.profiles) { + rows.push({ + id: profile.id, + name: profile.name, + isActive: profile.id === globalIndex.activeProfileId, + updatedAt: profile.updatedAt, + configExists: globalStore.configExists(profile.id), + scope: "user", + }); + } + } + + // Collect project profiles + if (scope === "project" || scope === "all") { + const projectRoot = findProjectRoot(); + if (projectRoot) { + const projectStore = new ProjectStoreManager(projectRoot); + const projectProfiles = projectStore.listProfiles(); + const projectRc = projectStore.loadRc(); + + for (const profileId of projectProfiles) { + const isActive = projectRc?.activeProfileId === profileId; + rows.push({ + id: profileId, + name: profileId, + isActive, + updatedAt: "-", + configExists: projectStore.configExists(profileId), + scope: "project", + }); + } + } else if (scope === "project") { + console.log(chalk.yellow("No .opencode/ directory found in parent directories.")); + console.log(chalk.gray("Run in a project directory or use --scope user to list global profiles.")); + return; + } + } + + if (rows.length === 0) { + if (scope === "all") { + console.log(chalk.yellow("No profiles found. Run 'omo-switch init' to get started.")); + } else if (scope === "user") { + console.log(chalk.yellow("No global profiles found. Run 'omo-switch init' to get started.")); + } else { + console.log(chalk.yellow("No project profiles found.")); + } + return; + } + + // Determine table columns based on scope + const showScopeColumn = scope === "all"; + + const headColumns = showScopeColumn + ? [chalk.cyan("ID"), chalk.cyan("Name"), chalk.cyan("Scope"), chalk.cyan("Active"), chalk.cyan("Updated"), chalk.cyan("Config")] + : [chalk.cyan("ID"), chalk.cyan("Name"), chalk.cyan("Active"), chalk.cyan("Updated"), chalk.cyan("Config")]; + + const colWidths = showScopeColumn + ? [20, 20, 10, 10, 25, 10] + : [20, 20, 10, 25, 10]; + + const table = new Table({ + head: headColumns, + colWidths, + }); + + for (const row of rows) { + const activeMarker = row.isActive ? chalk.green("*") : " "; + const updatedDate = row.updatedAt !== "-" ? new Date(row.updatedAt).toLocaleString() : "-"; + const configMarker = row.configExists ? chalk.green("OK") : chalk.red("MISSING"); + const scopeMarker = row.scope === "project" ? chalk.blue("project") : chalk.gray("user"); + + if (showScopeColumn) { + table.push([row.id, row.name, scopeMarker, activeMarker, updatedDate, configMarker]); + } else { + table.push([row.id, row.name, activeMarker, updatedDate, configMarker]); + } + } + + console.log(table.toString()); +} + +/** + * List OMOS presets + */ +function listOmosPresets(scope: ListScope): void { + const rows: OmosPresetRow[] = []; + + // Collect user presets + if (scope === "user" || scope === "all") { + const omosManager = new OmosConfigManager("user"); + const presets = omosManager.listPresets(); + const active = omosManager.getActivePreset(); + + for (const presetName of presets) { + rows.push({ + name: presetName, + isActive: presetName === active, + agentCount: omosManager.getPresetAgentCount(presetName), + scope: "user", + }); + } + } + + // Collect project presets + if (scope === "project" || scope === "all") { + const projectRoot = findProjectRoot(); + if (projectRoot) { + const omosManager = new OmosConfigManager("project", projectRoot); + const presets = omosManager.listPresets(); + const active = omosManager.getActivePreset(); + + for (const presetName of presets) { + rows.push({ + name: presetName, + isActive: presetName === active, + agentCount: omosManager.getPresetAgentCount(presetName), + scope: "project", + }); + } + } else if (scope === "project") { + console.log(chalk.yellow("No .opencode/ directory found in parent directories.")); + console.log(chalk.gray("Run in a project directory or use --scope user to list global presets.")); + return; + } + } + + if (rows.length === 0) { + if (scope === "all") { + console.log(chalk.yellow("No OMOS presets found. Create a config with 'omo-switch add '.")); + } else if (scope === "user") { + console.log(chalk.yellow("No global OMOS presets found.")); + } else { + console.log(chalk.yellow("No project OMOS presets found.")); + } + return; + } + + // Determine table columns based on scope + const showScopeColumn = scope === "all"; + + const headColumns = showScopeColumn + ? [chalk.cyan("Preset"), chalk.cyan("Scope"), chalk.cyan("Active"), chalk.cyan("Agents")] + : [chalk.cyan("Preset"), chalk.cyan("Active"), chalk.cyan("Agents")]; + + const colWidths = showScopeColumn + ? [25, 10, 10, 10] + : [25, 10, 10]; + + const table = new Table({ + head: headColumns, + colWidths, + }); + + for (const row of rows) { + const activeMarker = row.isActive ? chalk.green("*") : " "; + const scopeMarker = row.scope === "project" ? chalk.blue("project") : chalk.gray("user"); + const agentCountStr = row.agentCount > 0 ? String(row.agentCount) : "-"; + + if (showScopeColumn) { + table.push([row.name, scopeMarker, activeMarker, agentCountStr]); + } else { + table.push([row.name, activeMarker, agentCountStr]); + } + } + + console.log(table.toString()); +} + export const listCommand = new Command("list") - .description("List all profiles") + .description("List all profiles or presets") .option("--scope ", "Filter by scope (user, project, all)", "all") .action((options: ListOptions) => { try { @@ -31,100 +223,23 @@ export const listCommand = new Command("list") process.exit(1); } - const rows: ProfileRow[] = []; - - // Collect global profiles - if (scope === "user" || scope === "all") { - const globalStore = new StoreManager(); - const { added } = globalStore.syncProfiles(); - - if (added.length > 0) { - console.log(chalk.gray(`Discovered ${added.length} new global profile(s): ${added.join(", ")}`)); - } - - const globalIndex = globalStore.loadIndex(); - - for (const profile of globalIndex.profiles) { - rows.push({ - id: profile.id, - name: profile.name, - isActive: profile.id === globalIndex.activeProfileId, - updatedAt: profile.updatedAt, - configExists: globalStore.configExists(profile.id), - scope: "user", - }); - } - } + // Determine active type + const settings = new SettingsManager(); + const projectRoot = findProjectRoot(); + const activeType = settings.getEffectiveType(projectRoot ?? undefined); - // Collect project profiles - if (scope === "project" || scope === "all") { - const projectRoot = findProjectRoot(); - if (projectRoot) { - const projectStore = new ProjectStoreManager(projectRoot); - const projectProfiles = projectStore.listProfiles(); - const projectRc = projectStore.loadRc(); - - for (const profileId of projectProfiles) { - const isActive = projectRc?.activeProfileId === profileId; - rows.push({ - id: profileId, - name: profileId, - isActive, - updatedAt: "-", - configExists: projectStore.configExists(profileId), - scope: "project", - }); - } - } else if (scope === "project") { - console.log(chalk.yellow("No .opencode/ directory found in parent directories.")); - console.log(chalk.gray("Run in a project directory or use --scope user to list global profiles.")); - return; - } - } + // Show type indicator + const typeLabel = activeType === "slim" ? chalk.cyan("[SLIM]") : chalk.green("[OMO]"); + console.log(chalk.gray(`Mode: ${typeLabel}`)); + console.log(); - if (rows.length === 0) { - if (scope === "all") { - console.log(chalk.yellow("No profiles found. Run 'omo-switch init' to get started.")); - } else if (scope === "user") { - console.log(chalk.yellow("No global profiles found. Run 'omo-switch init' to get started.")); - } else { - console.log(chalk.yellow("No project profiles found.")); - } - return; + if (activeType === "slim") { + listOmosPresets(scope); + } else { + listOmoProfiles(scope); } - - // Determine table columns based on scope - const showScopeColumn = scope === "all"; - - const headColumns = showScopeColumn - ? [chalk.cyan("ID"), chalk.cyan("Name"), chalk.cyan("Scope"), chalk.cyan("Active"), chalk.cyan("Updated"), chalk.cyan("Config")] - : [chalk.cyan("ID"), chalk.cyan("Name"), chalk.cyan("Active"), chalk.cyan("Updated"), chalk.cyan("Config")]; - - const colWidths = showScopeColumn - ? [20, 20, 10, 10, 25, 10] - : [20, 20, 10, 25, 10]; - - const table = new Table({ - head: headColumns, - colWidths, - }); - - for (const row of rows) { - const activeMarker = row.isActive ? chalk.green("*") : " "; - const updatedDate = row.updatedAt !== "-" ? new Date(row.updatedAt).toLocaleString() : "-"; - const configMarker = row.configExists ? chalk.green("OK") : chalk.red("MISSING"); - const scopeMarker = row.scope === "project" ? chalk.blue("project") : chalk.gray("user"); - - if (showScopeColumn) { - table.push([row.id, row.name, scopeMarker, activeMarker, updatedDate, configMarker]); - } else { - table.push([row.id, row.name, activeMarker, updatedDate, configMarker]); - } - } - - console.log(table.toString()); } catch (err) { - console.error(chalk.red(`Failed to list profiles: ${err instanceof Error ? err.message : "Unknown error"}`)); + console.error(chalk.red(`Failed to list: ${err instanceof Error ? err.message : "Unknown error"}`)); process.exit(1); } }); diff --git a/src/commands/rm.test.ts b/src/commands/rm.test.ts index 0d65b8e..1fdf1a0 100644 --- a/src/commands/rm.test.ts +++ b/src/commands/rm.test.ts @@ -50,56 +50,80 @@ vi.mock("../utils/scope-resolver", async () => { const __createdStoreInstances: any[] = []; const __createdProjectStoreInstances: any[] = []; -vi.mock("../store", () => { - return { - StoreManager: class { - constructor() { - __createdStoreInstances.push(this); - } - ensureDirectories() {} - loadIndex() { - return { profiles: [], activeProfileId: null }; - } - saveIndex() {} - deleteProfile() { - return true; - } - getProfileConfigPath() { - return null; - } - configExists() { - return false; - } - getConfigsPath() { - return "/configs"; - } - }, - ProjectStoreManager: class { - constructor(_projectRoot: string) { - __createdProjectStoreInstances.push(this); - } - ensureDirectories() {} - configExists() { - return false; - } - deleteProfileConfig() { - return true; - } - getConfigsPath() { - return "/project/.opencode/omo-configs"; - } - loadRc() { - return { activeProfileId: null }; - } - saveRc() {} - listProfiles() { - return []; - } - }, - __createdStoreInstances, - __createdProjectStoreInstances, - }; -}); +vi.mock("../store", () => { + return { + StoreManager: class { + constructor() { + __createdStoreInstances.push(this); + } + ensureDirectories() {} + loadIndex() { + return { profiles: [], activeProfileId: null }; + } + saveIndex() {} + deleteProfile() { + return true; + } + getProfileConfigPath() { + return null; + } + configExists() { + return false; + } + getConfigsPath() { + return "/configs"; + } + }, + ProjectStoreManager: class { + constructor(_projectRoot: string) { + __createdProjectStoreInstances.push(this); + } + ensureDirectories() {} + configExists() { + return false; + } + deleteProfileConfig() { + return true; + } + getConfigsPath() { + return "/project/.opencode/omo-configs"; + } + loadRc() { + return { activeProfileId: null }; + } + saveRc() {} + listProfiles() { + return []; + } + }, + SettingsManager: class { + getEffectiveType() { + return "omo"; + } + loadSettings() { + return { type: "omo" }; + } + isProjectOverride() { + return false; + } + }, + OmosConfigManager: class { + constructor() {} + getPreset() { + return null; + } + removePreset() { + return true; + } + getActivePreset() { + return null; + } + setActivePreset() {} + }, + __createdStoreInstances, + __createdProjectStoreInstances, + }; +}); import * as fs from "fs"; import ora from "ora"; diff --git a/src/commands/rm.ts b/src/commands/rm.ts index c8bbb89..2c8e005 100644 --- a/src/commands/rm.ts +++ b/src/commands/rm.ts @@ -1,180 +1,367 @@ -import { Command } from "commander"; -import chalk from "chalk"; -import ora from "ora"; -import { confirm } from "@inquirer/prompts"; -import { StoreManager, ProjectStoreManager, Scope } from "../store"; -import { resolveProjectRoot, findProjectRoot } from "../utils/scope-resolver"; +import { Command } from "commander"; +import chalk from "chalk"; +import ora, { Ora } from "ora"; +import { confirm } from "@inquirer/prompts"; +import { StoreManager, ProjectStoreManager, Scope, SettingsManager, OmosConfigManager } from "../store"; +import { findProjectRoot } from "../utils/scope-resolver"; -interface RmOptions { - scope?: Scope; - force?: boolean; -} - -export const rmCommand = new Command("rm") - .description("Remove a profile by ID") - .argument("", "Profile ID to remove") - .option("--scope ", "Target scope (user or project)") - .option("--force", "Skip confirmation prompt") - .action(async (profileId: string, options: RmOptions) => { - const spinner = ora().start(); - - try { - const scope = options.scope as Scope | undefined; - - if (scope && scope !== "user" && scope !== "project") { - spinner.fail(`Invalid scope: ${scope}. Use 'user' or 'project'.`); - process.exit(1); - } - - let found = false; - let deletedFrom: "user" | "project" | null = null; - - // If scope is specified, only check that scope - if (scope === "user") { - spinner.text = "Checking global store..."; - const globalStore = new StoreManager(); - const index = globalStore.loadIndex(); - const profile = index.profiles.find((p) => p.id === profileId); - - if (!profile) { - spinner.fail(`Profile '${profileId}' not found in global store.`); - process.exit(1); - } - - if (!options.force) { - spinner.stop(); - const confirmed = await confirm({ - message: `Are you sure you want to delete profile '${profileId}'?`, - default: false, - }); - - if (!confirmed) { - console.log(chalk.gray("Operation cancelled.")); - return; - } - spinner.start(); - } - - spinner.text = "Deleting profile..."; - globalStore.deleteProfile(profileId); - found = true; - deletedFrom = "user"; - } else if (scope === "project") { - spinner.text = "Checking project store..."; - const projectRoot = findProjectRoot(); - - if (!projectRoot) { - spinner.fail("No .opencode/ directory found in parent directories."); - console.log(chalk.gray("Run in a project directory or use --scope user.")); - process.exit(1); - } - - const projectStore = new ProjectStoreManager(projectRoot); - - if (!projectStore.configExists(profileId)) { - spinner.fail(`Profile '${profileId}' not found in project.`); - process.exit(1); - } - - if (!options.force) { - spinner.stop(); - const confirmed = await confirm({ - message: `Are you sure you want to delete profile '${profileId}'?`, - default: false, - }); - - if (!confirmed) { - console.log(chalk.gray("Operation cancelled.")); - return; - } - spinner.start(); - } - - spinner.text = "Deleting profile..."; - projectStore.deleteProfileConfig(profileId); - - // Reset active profile if needed - const rc = projectStore.loadRc(); - if (rc && rc.activeProfileId === profileId) { - projectStore.saveRc({ activeProfileId: null }); - } - - found = true; - deletedFrom = "project"; - } else { - // No scope specified: try to find in both stores - spinner.text = "Searching for profile..."; - - // Check project first - const projectRoot = findProjectRoot(); - if (projectRoot) { - const projectStore = new ProjectStoreManager(projectRoot); - if (projectStore.configExists(profileId)) { - if (!options.force) { - spinner.stop(); - const confirmed = await confirm({ - message: `Delete profile '${profileId}' from project?`, - default: false, - }); - - if (!confirmed) { - console.log(chalk.gray("Operation cancelled.")); - return; - } - spinner.start(); - } - - spinner.text = "Deleting profile from project..."; - projectStore.deleteProfileConfig(profileId); - - const rc = projectStore.loadRc(); - if (rc && rc.activeProfileId === profileId) { - projectStore.saveRc({ activeProfileId: null }); - } - - found = true; - deletedFrom = "project"; - } - } - - // Check global store if not found in project - if (!found) { - const globalStore = new StoreManager(); - const index = globalStore.loadIndex(); - const profile = index.profiles.find((p) => p.id === profileId); - - if (profile) { - if (!options.force) { - spinner.stop(); - const confirmed = await confirm({ - message: `Delete profile '${profileId}' from global store?`, - default: false, - }); - - if (!confirmed) { - console.log(chalk.gray("Operation cancelled.")); - return; - } - spinner.start(); - } - - spinner.text = "Deleting profile from global store..."; - globalStore.deleteProfile(profileId); - found = true; - deletedFrom = "user"; - } - } - } - - if (!found) { - spinner.fail(`Profile '${profileId}' not found.`); - process.exit(1); - } - - const scopeLabel = deletedFrom === "project" ? "[project]" : "[user]"; - spinner.succeed(`Deleted profile '${profileId}' ${scopeLabel}`); - } catch (err) { - spinner.fail(`Failed to remove profile: ${err instanceof Error ? err.message : "Unknown error"}`); - process.exit(1); - } - }); +interface RmOptions { + scope?: Scope; + force?: boolean; +} + +/** + * Handle OMOS preset removal. + * Removes a preset from the OMOS config file. + */ +async function handleOmosRm( + presetName: string, + options: RmOptions, + spinner: Ora, + projectRoot: string | null +): Promise { + const scope = options.scope as Scope | undefined; + + if (scope && scope !== "user" && scope !== "project") { + spinner.fail(`Invalid scope: ${scope}. Use 'user' or 'project'.`); + process.exit(1); + } + + let found = false; + let deletedFrom: "user" | "project" | null = null; + + // If scope is specified, only check that scope + if (scope === "user") { + spinner.text = "Checking global OMOS config..."; + const omosManager = new OmosConfigManager("user"); + + if (!omosManager.getPreset(presetName)) { + spinner.fail(`Preset '${presetName}' not found in global OMOS config.`); + process.exit(1); + } + + if (!options.force) { + spinner.stop(); + const confirmed = await confirm({ + message: `Are you sure you want to delete preset '${presetName}'?`, + default: false, + }); + + if (!confirmed) { + console.log(chalk.gray("Operation cancelled.")); + return; + } + spinner.start(); + } + + spinner.text = "Deleting preset..."; + omosManager.removePreset(presetName); + + // Clear active preset if it was the deleted one + if (omosManager.getActivePreset() === presetName) { + omosManager.setActivePreset(null); + } + + found = true; + deletedFrom = "user"; + } else if (scope === "project") { + spinner.text = "Checking project OMOS config..."; + + if (!projectRoot) { + spinner.fail("No .opencode/ directory found in parent directories."); + console.log(chalk.gray("Run in a project directory or use --scope user.")); + process.exit(1); + } + + const omosManager = new OmosConfigManager("project", projectRoot); + + if (!omosManager.getPreset(presetName)) { + spinner.fail(`Preset '${presetName}' not found in project OMOS config.`); + process.exit(1); + } + + if (!options.force) { + spinner.stop(); + const confirmed = await confirm({ + message: `Are you sure you want to delete preset '${presetName}'?`, + default: false, + }); + + if (!confirmed) { + console.log(chalk.gray("Operation cancelled.")); + return; + } + spinner.start(); + } + + spinner.text = "Deleting preset..."; + omosManager.removePreset(presetName); + + // Clear active preset if it was the deleted one + if (omosManager.getActivePreset() === presetName) { + omosManager.setActivePreset(null); + } + + found = true; + deletedFrom = "project"; + } else { + // No scope specified: try to find in both scopes + spinner.text = "Searching for preset..."; + + // Check project first + if (projectRoot) { + const omosManager = new OmosConfigManager("project", projectRoot); + if (omosManager.getPreset(presetName)) { + if (!options.force) { + spinner.stop(); + const confirmed = await confirm({ + message: `Delete preset '${presetName}' from project OMOS config?`, + default: false, + }); + + if (!confirmed) { + console.log(chalk.gray("Operation cancelled.")); + return; + } + spinner.start(); + } + + spinner.text = "Deleting preset from project..."; + omosManager.removePreset(presetName); + + if (omosManager.getActivePreset() === presetName) { + omosManager.setActivePreset(null); + } + + found = true; + deletedFrom = "project"; + } + } + + // Check global if not found in project + if (!found) { + const omosManager = new OmosConfigManager("user"); + if (omosManager.getPreset(presetName)) { + if (!options.force) { + spinner.stop(); + const confirmed = await confirm({ + message: `Delete preset '${presetName}' from global OMOS config?`, + default: false, + }); + + if (!confirmed) { + console.log(chalk.gray("Operation cancelled.")); + return; + } + spinner.start(); + } + + spinner.text = "Deleting preset from global config..."; + omosManager.removePreset(presetName); + + if (omosManager.getActivePreset() === presetName) { + omosManager.setActivePreset(null); + } + + found = true; + deletedFrom = "user"; + } + } + } + + if (!found) { + spinner.fail(`Preset '${presetName}' not found.`); + process.exit(1); + } + + const scopeLabel = deletedFrom === "project" ? "[project]" : "[user]"; + spinner.succeed(`Deleted OMOS preset '${presetName}' ${scopeLabel}`); +} + +/** + * Handle OMO profile removal. + * Original OMO removal logic. + */ +async function handleOmoRm( + profileId: string, + options: RmOptions, + spinner: Ora, + projectRoot: string | null +): Promise { + const scope = options.scope as Scope | undefined; + + if (scope && scope !== "user" && scope !== "project") { + spinner.fail(`Invalid scope: ${scope}. Use 'user' or 'project'.`); + process.exit(1); + } + + let found = false; + let deletedFrom: "user" | "project" | null = null; + + // If scope is specified, only check that scope + if (scope === "user") { + spinner.text = "Checking global store..."; + const globalStore = new StoreManager(); + const index = globalStore.loadIndex(); + const profile = index.profiles.find((p) => p.id === profileId); + + if (!profile) { + spinner.fail(`Profile '${profileId}' not found in global store.`); + process.exit(1); + } + + if (!options.force) { + spinner.stop(); + const confirmed = await confirm({ + message: `Are you sure you want to delete profile '${profileId}'?`, + default: false, + }); + + if (!confirmed) { + console.log(chalk.gray("Operation cancelled.")); + return; + } + spinner.start(); + } + + spinner.text = "Deleting profile..."; + globalStore.deleteProfile(profileId); + found = true; + deletedFrom = "user"; + } else if (scope === "project") { + spinner.text = "Checking project store..."; + + if (!projectRoot) { + spinner.fail("No .opencode/ directory found in parent directories."); + console.log(chalk.gray("Run in a project directory or use --scope user.")); + process.exit(1); + } + + const projectStore = new ProjectStoreManager(projectRoot); + + if (!projectStore.configExists(profileId)) { + spinner.fail(`Profile '${profileId}' not found in project.`); + process.exit(1); + } + + if (!options.force) { + spinner.stop(); + const confirmed = await confirm({ + message: `Are you sure you want to delete profile '${profileId}'?`, + default: false, + }); + + if (!confirmed) { + console.log(chalk.gray("Operation cancelled.")); + return; + } + spinner.start(); + } + + spinner.text = "Deleting profile..."; + projectStore.deleteProfileConfig(profileId); + + // Reset active profile if needed + const rc = projectStore.loadRc(); + if (rc && rc.activeProfileId === profileId) { + projectStore.saveRc({ activeProfileId: null }); + } + + found = true; + deletedFrom = "project"; + } else { + // No scope specified: try to find in both stores + spinner.text = "Searching for profile..."; + + // Check project first + if (projectRoot) { + const projectStore = new ProjectStoreManager(projectRoot); + if (projectStore.configExists(profileId)) { + if (!options.force) { + spinner.stop(); + const confirmed = await confirm({ + message: `Delete profile '${profileId}' from project?`, + default: false, + }); + + if (!confirmed) { + console.log(chalk.gray("Operation cancelled.")); + return; + } + spinner.start(); + } + + spinner.text = "Deleting profile from project..."; + projectStore.deleteProfileConfig(profileId); + + const rc = projectStore.loadRc(); + if (rc && rc.activeProfileId === profileId) { + projectStore.saveRc({ activeProfileId: null }); + } + + found = true; + deletedFrom = "project"; + } + } + + // Check global store if not found in project + if (!found) { + const globalStore = new StoreManager(); + const index = globalStore.loadIndex(); + const profile = index.profiles.find((p) => p.id === profileId); + + if (profile) { + if (!options.force) { + spinner.stop(); + const confirmed = await confirm({ + message: `Delete profile '${profileId}' from global store?`, + default: false, + }); + + if (!confirmed) { + console.log(chalk.gray("Operation cancelled.")); + return; + } + spinner.start(); + } + + spinner.text = "Deleting profile from global store..."; + globalStore.deleteProfile(profileId); + found = true; + deletedFrom = "user"; + } + } + } + + if (!found) { + spinner.fail(`Profile '${profileId}' not found.`); + process.exit(1); + } + + const scopeLabel = deletedFrom === "project" ? "[project]" : "[user]"; + spinner.succeed(`Deleted profile '${profileId}' ${scopeLabel}`); +} + +export const rmCommand = new Command("rm") + .description("Remove a profile or preset by ID") + .argument("", "Profile ID (OMO) or preset name (OMOS) to remove") + .option("--scope ", "Target scope (user or project)") + .option("--force", "Skip confirmation prompt") + .action(async (identifier: string, options: RmOptions) => { + const spinner = ora().start(); + + try { + const projectRoot = findProjectRoot(); + const settings = new SettingsManager(); + const activeType = settings.getEffectiveType(projectRoot ?? undefined); + + if (activeType === "slim") { + await handleOmosRm(identifier, options, spinner, projectRoot); + } else { + await handleOmoRm(identifier, options, spinner, projectRoot); + } + } catch (err) { + spinner.fail(`Failed to remove: ${err instanceof Error ? err.message : "Unknown error"}`); + process.exit(1); + } + }); diff --git a/src/commands/show.ts b/src/commands/show.ts index eb3135e..b4a3a40 100644 --- a/src/commands/show.ts +++ b/src/commands/show.ts @@ -1,9 +1,8 @@ import { Command } from "commander"; import chalk from "chalk"; import * as fs from "fs"; -import * as path from "path"; import JSON5 from "json5"; -import { StoreManager, ProjectStoreManager } from "../store"; +import { StoreManager, ProjectStoreManager, SettingsManager, OmosConfigManager } from "../store"; import { findProjectRoot, getProjectTargetPath } from "../utils/scope-resolver"; import { deepMerge, generateDiffOutput } from "../utils/merge-config"; import { findExistingConfigPath } from "../utils/config-path"; @@ -14,170 +13,312 @@ interface ShowOptions { scope: ShowScope; } -export const showCommand = new Command("show") - .description("Show profile configuration") - .argument("[identifier]", "Profile ID or name (optional for merged view)") - .option("--scope ", "View scope (user, project, merged)", "merged") - .action((identifier: string | undefined, options: ShowOptions) => { - try { - const scope = options.scope as ShowScope; +/** + * Handle OMOS config/preset display. + * Shows active preset or specific preset from OMOS config. + */ +function handleOmosShow( + identifier: string | undefined, + scope: ShowScope, + projectRoot: string | null +): void { + if (scope === "user") { + // Show global OMOS config/preset + const omosManager = new OmosConfigManager("user"); + const config = omosManager.loadConfig(); - if (scope !== "user" && scope !== "project" && scope !== "merged") { - console.error(chalk.red(`Invalid scope: ${scope}. Use 'user', 'project', or 'merged'.`)); + if (!config) { + console.error(chalk.red("No global OMOS config found.")); + console.error(chalk.gray("Run 'omo-switch add ' to add a preset first.")); + process.exit(1); + } + + const activePreset = omosManager.getActivePreset(); + + if (!identifier) { + // Show full config or active preset + console.log(chalk.cyan("OMOS Configuration [user]")); + console.log(chalk.gray(`Target: ${omosManager.getTargetPath()}`)); + console.log(chalk.gray(`Active Preset: ${activePreset ?? "(none)"}`)); + console.log(chalk.gray("-".repeat(40))); + console.log(JSON.stringify(config, null, 2)); + } else { + // Show specific preset + const preset = omosManager.getPreset(identifier); + if (!preset) { + console.error(chalk.red(`Preset '${identifier}' not found in global OMOS config.`)); process.exit(1); } - const globalStore = new StoreManager(); - globalStore.syncProfiles(); - const globalIndex = globalStore.loadIndex(); + const isActive = activePreset === identifier; + console.log(chalk.cyan(`Preset: ${identifier} [user]${isActive ? chalk.green(" (active)") : ""}`)); + console.log(chalk.gray("-".repeat(40))); + console.log(JSON.stringify(preset, null, 2)); + } + } else if (scope === "project") { + // Show project OMOS config/preset + if (!projectRoot) { + console.error(chalk.red("No .opencode/ directory found in parent directories.")); + process.exit(1); + } - const projectRoot = findProjectRoot(); - let projectStore: ProjectStoreManager | null = null; - if (projectRoot) { - projectStore = new ProjectStoreManager(projectRoot); + const omosManager = new OmosConfigManager("project", projectRoot); + const config = omosManager.loadConfig(); + + if (!config) { + console.error(chalk.red("No project OMOS config found.")); + console.error(chalk.gray("Run 'omo-switch add --scope project' first.")); + process.exit(1); + } + + const activePreset = omosManager.getActivePreset(); + + if (!identifier) { + console.log(chalk.cyan("OMOS Configuration [project]")); + console.log(chalk.gray(`Project: ${projectRoot}`)); + console.log(chalk.gray(`Target: ${omosManager.getTargetPath()}`)); + console.log(chalk.gray(`Active Preset: ${activePreset ?? "(none)"}`)); + console.log(chalk.gray("-".repeat(40))); + console.log(JSON.stringify(config, null, 2)); + } else { + const preset = omosManager.getPreset(identifier); + if (!preset) { + console.error(chalk.red(`Preset '${identifier}' not found in project OMOS config.`)); + process.exit(1); } - if (scope === "user") { - // Show global profile only - if (!identifier) { - // Use active global profile - if (!globalIndex.activeProfileId) { - console.error(chalk.red("No active global profile. Specify a profile ID or name.")); - process.exit(1); - } - identifier = globalIndex.activeProfileId; - } + const isActive = activePreset === identifier; + console.log(chalk.cyan(`Preset: ${identifier} [project]${isActive ? chalk.green(" (active)") : ""}`)); + console.log(chalk.gray("-".repeat(40))); + console.log(JSON.stringify(preset, null, 2)); + } + } else { + // Merged view: show both global and project OMOS configs + const globalManager = new OmosConfigManager("user"); + const globalConfig = globalManager.loadConfig(); - const profile = globalIndex.profiles.find( - (p) => p.id === identifier || p.name.toLowerCase() === identifier!.toLowerCase() - ); + let projectConfig = null; + if (projectRoot) { + const projectManager = new OmosConfigManager("project", projectRoot); + projectConfig = projectManager.loadConfig(); + } - if (!profile) { - console.error(chalk.red(`Profile not found: ${identifier}`)); - process.exit(1); - } + if (!globalConfig && !projectConfig) { + console.error(chalk.red("No OMOS config found in either scope.")); + console.error(chalk.gray("Use 'omo-switch add ' to add a preset first.")); + process.exit(1); + } - console.log(chalk.cyan(`Profile: ${profile.name} (${profile.id}) [user]`)); - console.log(chalk.gray(`Created: ${profile.createdAt}`)); - console.log(chalk.gray(`Updated: ${profile.updatedAt}`)); - console.log(chalk.gray("-".repeat(40))); - - const configRaw = globalStore.getProfileConfigRaw(profile.id); - if (configRaw) { - console.log(configRaw.content); - } else { - console.error(chalk.red(`Profile config file not found`)); - process.exit(1); - } - } else if (scope === "project") { - // Show project profile only - if (!projectStore) { - console.error(chalk.red("No .opencode/ directory found in parent directories.")); - process.exit(1); - } + console.log(chalk.cyan("OMOS Merged Configuration View")); + console.log(chalk.gray(`Global: ${globalManager.getTargetPath()}`)); + if (projectRoot) { + const projectManager = new OmosConfigManager("project", projectRoot); + console.log(chalk.gray(`Project: ${projectManager.getTargetPath()}`)); + } else { + console.log(chalk.gray("Project: (none)")); + } + console.log(chalk.gray("-".repeat(50))); - const projectRc = projectStore.loadRc(); - let profileId = identifier; + if (globalConfig && projectConfig) { + // Both exist - show with indicators + console.log(chalk.yellow("// Global config:")); + console.log(JSON.stringify(globalConfig, null, 2)); + console.log(); + console.log(chalk.green("// Project config (takes precedence):")); + console.log(JSON.stringify(projectConfig, null, 2)); + } else if (projectConfig) { + console.log(chalk.gray("// Project config only (no global config)")); + console.log(JSON.stringify(projectConfig, null, 2)); + } else if (globalConfig) { + console.log(chalk.gray("// Global config only (no project config)")); + console.log(JSON.stringify(globalConfig, null, 2)); + } + } +} - if (!profileId) { - // Use active project profile - if (!projectRc?.activeProfileId) { - console.error(chalk.red("No active project profile. Specify a profile ID.")); - process.exit(1); - } - profileId = projectRc.activeProfileId; - } +/** + * Handle OMO profile display. + * Original OMO show logic. + */ +function handleOmoShow( + identifier: string | undefined, + scope: ShowScope, + projectRoot: string | null +): void { + const globalStore = new StoreManager(); + globalStore.syncProfiles(); + const globalIndex = globalStore.loadIndex(); - const configRaw = projectStore.getProfileConfigRaw(profileId); - if (!configRaw) { - console.error(chalk.red(`Project profile not found: ${profileId}`)); - process.exit(1); - } + let projectStore: ProjectStoreManager | null = null; + if (projectRoot) { + projectStore = new ProjectStoreManager(projectRoot); + } - console.log(chalk.cyan(`Profile: ${profileId} (${profileId}) [project]`)); - console.log(chalk.gray(`Project: ${projectRoot}`)); - console.log(chalk.gray("-".repeat(40))); - console.log(configRaw.content); - } else { - // Merged view: read actual TARGET configs, not profile store configs - let globalConfig: Record | null = null; - let projectConfig: Record | null = null; - let globalConfigPath = ""; - let projectConfigPath = ""; - - // Get global TARGET config from ~/.config/opencode/oh-my-opencode.jsonc (or .json) - const globalTargetResult = findExistingConfigPath(); - if (globalTargetResult && globalTargetResult.exists) { - globalConfigPath = globalTargetResult.path; - try { - const content = fs.readFileSync(globalConfigPath, "utf-8"); - globalConfig = JSON5.parse(content) as Record; - } catch { - // Ignore parse errors - } - } + if (scope === "user") { + // Show global profile only + if (!identifier) { + // Use active global profile + if (!globalIndex.activeProfileId) { + console.error(chalk.red("No active global profile. Specify a profile ID or name.")); + process.exit(1); + } + identifier = globalIndex.activeProfileId; + } - // Get project TARGET config from /.opencode/oh-my-opencode.jsonc (or .json) - if (projectRoot) { - const projectTargetJsonc = getProjectTargetPath(projectRoot); - const projectTargetJson = projectTargetJsonc.replace(/\.jsonc$/, ".json"); - - if (fs.existsSync(projectTargetJsonc)) { - projectConfigPath = projectTargetJsonc; - try { - const content = fs.readFileSync(projectTargetJsonc, "utf-8"); - projectConfig = JSON5.parse(content) as Record; - } catch { - // Ignore parse errors - } - } else if (fs.existsSync(projectTargetJson)) { - projectConfigPath = projectTargetJson; - try { - const content = fs.readFileSync(projectTargetJson, "utf-8"); - projectConfig = JSON5.parse(content) as Record; - } catch { - // Ignore parse errors - } - } - } + const profile = globalIndex.profiles.find( + (p) => p.id === identifier || p.name.toLowerCase() === identifier!.toLowerCase() + ); - if (!globalConfig && !projectConfig) { - console.error(chalk.red("No applied config found in either scope.")); - console.error(chalk.gray("Use 'omo-switch apply ' to apply a config first.")); - process.exit(1); - } + if (!profile) { + console.error(chalk.red(`Profile not found: ${identifier}`)); + process.exit(1); + } - // Display merged view - console.log(chalk.cyan("Merged Configuration View (Applied Configs)")); - if (globalConfigPath) { - console.log(chalk.gray(`Global: ${globalConfigPath}`)); - } else { - console.log(chalk.gray(`Global: (none)`)); - } - if (projectConfigPath) { - console.log(chalk.gray(`Project: ${projectConfigPath}`)); - } else { - console.log(chalk.gray(`Project: (none)`)); + console.log(chalk.cyan(`Profile: ${profile.name} (${profile.id}) [user]`)); + console.log(chalk.gray(`Created: ${profile.createdAt}`)); + console.log(chalk.gray(`Updated: ${profile.updatedAt}`)); + console.log(chalk.gray("-".repeat(40))); + + const configRaw = globalStore.getProfileConfigRaw(profile.id); + if (configRaw) { + console.log(configRaw.content); + } else { + console.error(chalk.red(`Profile config file not found`)); + process.exit(1); + } + } else if (scope === "project") { + // Show project profile only + if (!projectStore) { + console.error(chalk.red("No .opencode/ directory found in parent directories.")); + process.exit(1); + } + + const projectRc = projectStore.loadRc(); + let profileId = identifier; + + if (!profileId) { + // Use active project profile + if (!projectRc?.activeProfileId) { + console.error(chalk.red("No active project profile. Specify a profile ID.")); + process.exit(1); + } + profileId = projectRc.activeProfileId; + } + + const configRaw = projectStore.getProfileConfigRaw(profileId); + if (!configRaw) { + console.error(chalk.red(`Project profile not found: ${profileId}`)); + process.exit(1); + } + + console.log(chalk.cyan(`Profile: ${profileId} (${profileId}) [project]`)); + console.log(chalk.gray(`Project: ${projectRoot}`)); + console.log(chalk.gray("-".repeat(40))); + console.log(configRaw.content); + } else { + // Merged view: read actual TARGET configs, not profile store configs + let globalConfig: Record | null = null; + let projectConfig: Record | null = null; + let globalConfigPath = ""; + let projectConfigPath = ""; + + // Get global TARGET config from ~/.config/opencode/oh-my-opencode.jsonc (or .json) + const globalTargetResult = findExistingConfigPath(); + if (globalTargetResult && globalTargetResult.exists) { + globalConfigPath = globalTargetResult.path; + try { + const content = fs.readFileSync(globalConfigPath, "utf-8"); + globalConfig = JSON5.parse(content) as Record; + } catch { + // Ignore parse errors + } + } + + // Get project TARGET config from /.opencode/oh-my-opencode.jsonc (or .json) + if (projectRoot) { + const projectTargetJsonc = getProjectTargetPath(projectRoot); + const projectTargetJson = projectTargetJsonc.replace(/\.jsonc$/, ".json"); + + if (fs.existsSync(projectTargetJsonc)) { + projectConfigPath = projectTargetJsonc; + try { + const content = fs.readFileSync(projectTargetJsonc, "utf-8"); + projectConfig = JSON5.parse(content) as Record; + } catch { + // Ignore parse errors } - console.log(chalk.gray("-".repeat(50))); - - if (globalConfig && projectConfig) { - // Both exist - show diff - const merged = deepMerge(globalConfig, projectConfig); - const diffOutput = generateDiffOutput(globalConfig, merged, projectConfig); - console.log(diffOutput); - } else if (projectConfig) { - // Only project config - console.log(chalk.gray("// Project config only (no global config applied)")); - console.log(JSON.stringify(projectConfig, null, 2)); - } else if (globalConfig) { - // Only global config - console.log(chalk.gray("// Global config only (no project config applied)")); - console.log(JSON.stringify(globalConfig, null, 2)); + } else if (fs.existsSync(projectTargetJson)) { + projectConfigPath = projectTargetJson; + try { + const content = fs.readFileSync(projectTargetJson, "utf-8"); + projectConfig = JSON5.parse(content) as Record; + } catch { + // Ignore parse errors } } + } + + if (!globalConfig && !projectConfig) { + console.error(chalk.red("No applied config found in either scope.")); + console.error(chalk.gray("Use 'omo-switch apply ' to apply a config first.")); + process.exit(1); + } + + // Display merged view + console.log(chalk.cyan("Merged Configuration View (Applied Configs)")); + if (globalConfigPath) { + console.log(chalk.gray(`Global: ${globalConfigPath}`)); + } else { + console.log(chalk.gray(`Global: (none)`)); + } + if (projectConfigPath) { + console.log(chalk.gray(`Project: ${projectConfigPath}`)); + } else { + console.log(chalk.gray(`Project: (none)`)); + } + console.log(chalk.gray("-".repeat(50))); + + if (globalConfig && projectConfig) { + // Both exist - show diff + const merged = deepMerge(globalConfig, projectConfig); + const diffOutput = generateDiffOutput(globalConfig, merged, projectConfig); + console.log(diffOutput); + } else if (projectConfig) { + // Only project config + console.log(chalk.gray("// Project config only (no global config applied)")); + console.log(JSON.stringify(projectConfig, null, 2)); + } else if (globalConfig) { + // Only global config + console.log(chalk.gray("// Global config only (no project config applied)")); + console.log(JSON.stringify(globalConfig, null, 2)); + } + } +} + +export const showCommand = new Command("show") + .description("Show profile or OMOS preset configuration") + .argument("[identifier]", "Profile ID/name (OMO) or preset name (OMOS), optional for merged view") + .option("--scope ", "View scope (user, project, merged)", "merged") + .action((identifier: string | undefined, options: ShowOptions) => { + try { + const scope = options.scope as ShowScope; + + if (scope !== "user" && scope !== "project" && scope !== "merged") { + console.error(chalk.red(`Invalid scope: ${scope}. Use 'user', 'project', or 'merged'.`)); + process.exit(1); + } + + const projectRoot = findProjectRoot(); + const settings = new SettingsManager(); + const activeType = settings.getEffectiveType(projectRoot ?? undefined); + + if (activeType === "slim") { + handleOmosShow(identifier, scope, projectRoot); + } else { + handleOmoShow(identifier, scope, projectRoot); + } } catch (err) { - console.error(chalk.red(`Failed to show profile: ${err instanceof Error ? err.message : "Unknown error"}`)); + console.error(chalk.red(`Failed to show config: ${err instanceof Error ? err.message : "Unknown error"}`)); process.exit(1); } }); diff --git a/src/commands/type.test.ts b/src/commands/type.test.ts new file mode 100644 index 0000000..c7de776 --- /dev/null +++ b/src/commands/type.test.ts @@ -0,0 +1,267 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { mockProcessExit } from "../test-setup"; + +vi.mock("fs", () => ({ + existsSync: vi.fn(), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), +})); + +vi.mock("ora", () => { + const mockSpinner: any = { + start: vi.fn(function (this: any) { + this.running = true; + return this; + }), + text: "", + succeed: vi.fn(function (this: any) { + this.running = false; + return this; + }), + fail: vi.fn(function (this: any) { + this.running = false; + return this; + }), + info: vi.fn(function (this: any) { + this.running = false; + return this; + }), + stop: vi.fn(function (this: any) { + this.running = false; + return this; + }), + }; + return { + default: vi.fn(() => mockSpinner), + }; +}); + +vi.mock("chalk", () => ({ + default: { + cyan: vi.fn((s: string) => s), + gray: vi.fn((s: string) => s), + red: vi.fn((s: string) => s), + green: vi.fn((s: string) => s), + yellow: vi.fn((s: string) => s), + }, +})); + +vi.mock("@inquirer/prompts", () => ({ + select: vi.fn(), +})); + +vi.mock("../store", () => ({ + SettingsManager: class { + loadSettings() { + return { activeType: "omo" }; + } + saveSettings() {} + getEffectiveType() { + return "omo"; + } + isProjectOverride() { + return false; + } + setActiveType() {} + }, + ProjectStoreManager: class { + constructor(_projectRoot: string) {} + ensureDirectories() {} + loadRc() { + return { activeProfileId: null }; + } + saveRc() {} + }, +})); + +vi.mock("../utils/scope-resolver", () => ({ + findProjectRoot: vi.fn(() => null), + loadProjectRc: vi.fn(() => null), + saveProjectRc: vi.fn(), +})); + +import * as fs from "fs"; +import ora from "ora"; +import { select } from "@inquirer/prompts"; +import { findProjectRoot, loadProjectRc } from "../utils/scope-resolver"; + +describe("typeCommand", () => { + let mockSpinner: any; + let typeCommand: any; + let SettingsManagerClass: any; + let ProjectStoreManagerClass: any; + + beforeEach(async () => { + vi.clearAllMocks(); + mockProcessExit(); + + mockSpinner = vi.mocked(ora)(); + + const storeModule = await import("../store"); + SettingsManagerClass = storeModule.SettingsManager; + ProjectStoreManagerClass = storeModule.ProjectStoreManager; + + const mod = await import("./type"); + typeCommand = mod.typeCommand; + }); + + async function runType( + type?: string, + opts: { scope?: string; select?: boolean; clearProject?: boolean } = {} + ) { + const cmd = typeCommand as any; + cmd._optionValues = {}; + cmd._optionValueSources = {}; + + if (opts.scope) { + cmd._optionValues.scope = opts.scope; + cmd._optionValueSources.scope = "cli"; + } + if (opts.select) { + cmd._optionValues.select = true; + cmd._optionValueSources.select = "cli"; + } + if (opts.clearProject) { + cmd._optionValues.clearProject = true; + cmd._optionValueSources.clearProject = "cli"; + } + + cmd.processedArgs = type ? [type] : []; + + try { + await cmd._actionHandler(cmd.processedArgs); + } catch (err: any) { + if (err.message?.includes?.("process.exit")) { + return; + } + throw err; + } + } + + describe("show current type", () => { + it("shows current type when no argument provided", async () => { + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(SettingsManagerClass.prototype, "getEffectiveType").mockReturnValue("omo"); + vi.spyOn(SettingsManagerClass.prototype, "isProjectOverride").mockReturnValue(false); + vi.spyOn(SettingsManagerClass.prototype, "loadSettings").mockReturnValue({ activeType: "omo" }); + + await runType(); + + expect(mockSpinner.stop).toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Current type:")); + consoleSpy.mockRestore(); + }); + + it("shows project override indicator when project type is set", async () => { + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + vi.mocked(findProjectRoot).mockReturnValue("/project"); + vi.spyOn(SettingsManagerClass.prototype, "getEffectiveType").mockReturnValue("slim"); + vi.spyOn(SettingsManagerClass.prototype, "isProjectOverride").mockReturnValue(true); + vi.spyOn(SettingsManagerClass.prototype, "loadSettings").mockReturnValue({ activeType: "omo" }); + vi.mocked(loadProjectRc).mockReturnValue({ activeProfileId: null, type: "slim" }); + + await runType(); + + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("project override")); + consoleSpy.mockRestore(); + }); + }); + + describe("set global type", () => { + it("sets global type to omo", async () => { + const setActiveTypeSpy = vi.spyOn(SettingsManagerClass.prototype, "setActiveType"); + + await runType("omo"); + + expect(setActiveTypeSpy).toHaveBeenCalledWith("omo"); + expect(mockSpinner.succeed).toHaveBeenCalledWith(expect.stringContaining("omo")); + }); + + it("sets global type to slim", async () => { + const setActiveTypeSpy = vi.spyOn(SettingsManagerClass.prototype, "setActiveType"); + + await runType("slim"); + + expect(setActiveTypeSpy).toHaveBeenCalledWith("slim"); + expect(mockSpinner.succeed).toHaveBeenCalledWith(expect.stringContaining("slim")); + }); + + it("errors on invalid type", async () => { + await runType("invalid"); + + expect(mockSpinner.fail).toHaveBeenCalledWith(expect.stringContaining("Invalid type")); + }); + }); + + describe("set project type", () => { + it("sets project type with --scope project", async () => { + vi.mocked(findProjectRoot).mockReturnValue("/project"); + const saveRcSpy = vi.spyOn(ProjectStoreManagerClass.prototype, "saveRc"); + + await runType("slim", { scope: "project" }); + + expect(saveRcSpy).toHaveBeenCalledWith( + expect.objectContaining({ type: "slim" }) + ); + expect(mockSpinner.succeed).toHaveBeenCalledWith(expect.stringContaining("project")); + }); + + it("errors when setting project type without project", async () => { + vi.mocked(findProjectRoot).mockReturnValue(null); + + await runType("slim", { scope: "project" }); + + expect(mockSpinner.fail).toHaveBeenCalledWith(expect.stringContaining("No project found")); + }); + }); + + describe("clear project type", () => { + it("clears project type with --clear-project", async () => { + vi.mocked(findProjectRoot).mockReturnValue("/project"); + vi.spyOn(ProjectStoreManagerClass.prototype, "loadRc").mockReturnValue({ + activeProfileId: null, + type: "slim", + }); + const saveRcSpy = vi.spyOn(ProjectStoreManagerClass.prototype, "saveRc"); + + await runType(undefined, { clearProject: true }); + + expect(saveRcSpy).toHaveBeenCalled(); + const savedRc = saveRcSpy.mock.calls[0][0] as { activeProfileId: string | null; type?: string }; + expect(savedRc.type).toBeUndefined(); + expect(mockSpinner.succeed).toHaveBeenCalledWith(expect.stringContaining("Cleared")); + }); + + it("shows info when no project type to clear", async () => { + vi.mocked(findProjectRoot).mockReturnValue("/project"); + vi.spyOn(ProjectStoreManagerClass.prototype, "loadRc").mockReturnValue({ + activeProfileId: null, + }); + + await runType(undefined, { clearProject: true }); + + expect(mockSpinner.info).toHaveBeenCalledWith(expect.stringContaining("No project type override")); + }); + + it("errors when clearing without project", async () => { + vi.mocked(findProjectRoot).mockReturnValue(null); + + await runType(undefined, { clearProject: true }); + + expect(mockSpinner.fail).toHaveBeenCalledWith(expect.stringContaining("No project found")); + }); + }); + + describe("interactive selection", () => { + it("uses select prompt with --select flag", async () => { + vi.mocked(select).mockResolvedValue("slim"); + const setActiveTypeSpy = vi.spyOn(SettingsManagerClass.prototype, "setActiveType"); + + await runType(undefined, { select: true }); + + expect(select).toHaveBeenCalled(); + expect(setActiveTypeSpy).toHaveBeenCalledWith("slim"); + }); + }); +}); diff --git a/src/commands/type.ts b/src/commands/type.ts new file mode 100644 index 0000000..b351516 --- /dev/null +++ b/src/commands/type.ts @@ -0,0 +1,124 @@ +import { Command } from "commander"; +import chalk from "chalk"; +import ora from "ora"; +import { select } from "@inquirer/prompts"; +import { SettingsManager, ConfigType, ProjectStoreManager } from "../store"; +import { findProjectRoot, loadProjectRc, saveProjectRc } from "../utils/scope-resolver"; + +interface TypeOptions { + scope?: "user" | "project"; + select?: boolean; + clearProject?: boolean; +} + +export const typeCommand = new Command("type") + .description("Get or set the active configuration type (omo or slim)") + .argument("[type]", "Type to set (omo or slim)") + .option("--scope ", "Set for user (global) or project scope", "user") + .option("--select", "Interactively select type") + .option("--clear-project", "Remove project-level type override") + .action(async (type?: string, options?: TypeOptions) => { + const spinner = ora().start(); + + try { + const settings = new SettingsManager(); + const projectRoot = findProjectRoot(); + + // Handle --clear-project flag + if (options?.clearProject) { + if (!projectRoot) { + spinner.fail("No project found. Not in a project directory."); + process.exit(1); + } + + const projectStore = new ProjectStoreManager(projectRoot); + const rc = projectStore.loadRc() || { activeProfileId: null }; + + if (rc.type === undefined) { + spinner.info("No project type override to clear."); + return; + } + + delete rc.type; + projectStore.saveRc(rc); + spinner.succeed("Cleared project type override. Now using global setting."); + return; + } + + // Handle --select flag (interactive) + if (options?.select) { + spinner.stop(); + const selectedType = await select({ + message: "Select configuration type:", + choices: [ + { name: "omo (oh-my-opencode)", value: "omo" }, + { name: "slim (oh-my-opencode-slim)", value: "slim" }, + ], + }); + type = selectedType; + spinner.start(); + } + + // If no type argument, show current type + if (!type) { + const effectiveType = settings.getEffectiveType(projectRoot ?? undefined); + const isOverride = settings.isProjectOverride(projectRoot ?? undefined); + + const typeLabel = effectiveType === "slim" + ? chalk.cyan("slim") + chalk.gray(" (oh-my-opencode-slim)") + : chalk.green("omo") + chalk.gray(" (oh-my-opencode)"); + + const overrideIndicator = isOverride + ? chalk.yellow(" (project override)") + : ""; + + spinner.stop(); + console.log(`Current type: ${typeLabel}${overrideIndicator}`); + + // Show additional info + const globalSettings = settings.loadSettings(); + console.log(chalk.gray(` Global default: ${globalSettings.activeType}`)); + + if (projectRoot) { + const projectRc = loadProjectRc(projectRoot); + const projectType = projectRc?.type; + console.log(chalk.gray(` Project override: ${projectType ?? "(none)"}`)); + } + return; + } + + // Validate type + if (type !== "omo" && type !== "slim") { + spinner.fail(`Invalid type: ${type}. Use 'omo' or 'slim'.`); + process.exit(1); + } + + const configType = type as ConfigType; + const scope = options?.scope || "user"; + + if (scope === "project") { + // Set project-level type + if (!projectRoot) { + spinner.fail("No project found. Not in a project directory."); + console.log(chalk.gray("Use --scope user to set global type.")); + process.exit(1); + } + + const projectStore = new ProjectStoreManager(projectRoot); + projectStore.ensureDirectories(); + const rc = projectStore.loadRc() || { activeProfileId: null }; + rc.type = configType; + projectStore.saveRc(rc); + + spinner.succeed(`Set project type to '${configType}'`); + console.log(chalk.gray(` Project: ${projectRoot}`)); + } else { + // Set global type + settings.setActiveType(configType); + spinner.succeed(`Set global type to '${configType}'`); + } + } catch (err) { + spinner.fail(`Failed to set type: ${err instanceof Error ? err.message : "Unknown error"}`); + process.exit(1); + } + }); diff --git a/src/index.ts b/src/index.ts index 501a34f..eba06b4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ import { applyCommand } from "./commands/apply"; import { schemaCommand } from "./commands/schema"; import { addCommand } from "./commands/add"; import { rmCommand } from "./commands/rm"; +import { typeCommand } from "./commands/type"; const program = new Command(); @@ -23,5 +24,6 @@ program.addCommand(applyCommand); program.addCommand(schemaCommand); program.addCommand(addCommand); program.addCommand(rmCommand); +program.addCommand(typeCommand); program.parse(process.argv); diff --git a/src/store/index.ts b/src/store/index.ts index 0153581..93a229a 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -2,9 +2,12 @@ import * as fs from "fs"; import * as path from "path"; import * as os from "os"; import { Profile, StoreIndex, STORE_VERSION } from "./types"; +import { cleanOldBackups } from "../utils/backup-cleaner"; export * from "./types"; export { ProjectStoreManager } from "./project-store"; +export { OmosConfigManager } from "./omos-config"; +export { SettingsManager } from "./settings-manager"; export class StoreManager { private readonly storePath: string; @@ -117,6 +120,10 @@ export class StoreManager { if (!fs.existsSync(configPath)) { return null; } + + // Clean up old backups before creating new one + cleanOldBackups(this.backupsPath); + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const backupFileName = `${timestamp}__${path.basename(configPath)}`; const backupPath = path.join(this.backupsPath, backupFileName); diff --git a/src/store/omos-config.ts b/src/store/omos-config.ts new file mode 100644 index 0000000..e34e9d4 --- /dev/null +++ b/src/store/omos-config.ts @@ -0,0 +1,204 @@ +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; +import { + Scope, + OmosConfig, + OmosPresetConfig, + DEFAULT_OMOS_CONFIG, +} from "./types"; +import { cleanOldBackups } from "../utils/backup-cleaner"; +import { + getOmosConfigTargetPath, + getOmosProjectTargetPath, + ensureOmosConfigDir, +} from "../utils/omos-config-path"; + +/** + * Manages OMOS preset configurations. + * Unlike OMO which uses multiple profile files, OMOS stores all presets + * in a single config file with a `preset` field indicating the active preset. + */ +export class OmosConfigManager { + private readonly scope: Scope; + private readonly projectRoot: string | undefined; + private readonly targetPath: string; + private readonly backupsPath: string; + + constructor(scope: Scope, projectRoot?: string) { + this.scope = scope; + this.projectRoot = projectRoot; + + if (scope === "project") { + if (!projectRoot) { + throw new Error("projectRoot is required for project scope"); + } + this.targetPath = getOmosProjectTargetPath(projectRoot); + this.backupsPath = path.join(projectRoot, ".opencode", "backups"); + } else { + this.targetPath = getOmosConfigTargetPath().path; + // Use global omo-switch backups directory + const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"); + this.backupsPath = path.join(configHome, "omo-switch", "backups"); + } + } + + /** + * Get the target config file path. + */ + getTargetPath(): string { + return this.targetPath; + } + + /** + * Check if the OMOS config file exists. + */ + configExists(): boolean { + return fs.existsSync(this.targetPath); + } + + /** + * Load the OMOS config. Returns null if file doesn't exist. + */ + loadConfig(): OmosConfig | null { + if (!this.configExists()) { + return null; + } + + try { + const content = fs.readFileSync(this.targetPath, "utf-8"); + return JSON.parse(content) as OmosConfig; + } catch { + return null; + } + } + + /** + * Load the OMOS config, or create a default one if it doesn't exist. + */ + loadOrCreateConfig(): OmosConfig { + const existing = this.loadConfig(); + if (existing) { + return existing; + } + + // Create default config + const defaultConfig = { ...DEFAULT_OMOS_CONFIG }; + this.saveConfig(defaultConfig); + return defaultConfig; + } + + /** + * Save the OMOS config. + * IMPORTANT: Always writes as .json, never .jsonc + */ + saveConfig(config: OmosConfig): void { + ensureOmosConfigDir(this.targetPath); + fs.writeFileSync(this.targetPath, JSON.stringify(config, null, 2), "utf-8"); + } + + /** + * List all preset names. + */ + listPresets(): string[] { + const config = this.loadConfig(); + if (!config?.presets) { + return []; + } + return Object.keys(config.presets); + } + + /** + * Get the currently active preset name. + */ + getActivePreset(): string | null { + const config = this.loadConfig(); + return config?.preset ?? null; + } + + /** + * Set the active preset. + */ + setActivePreset(presetName: string | null): void { + const config = this.loadOrCreateConfig(); + config.preset = presetName; + this.saveConfig(config); + } + + /** + * Get a specific preset configuration. + */ + getPreset(name: string): OmosPresetConfig | null { + const config = this.loadConfig(); + return config?.presets?.[name] ?? null; + } + + /** + * Add a new preset. + * If preset with same name exists, it will be overwritten. + */ + addPreset(name: string, presetConfig: OmosPresetConfig): void { + const config = this.loadOrCreateConfig(); + + if (!config.presets) { + config.presets = {}; + } + + config.presets[name] = presetConfig; + this.saveConfig(config); + } + + /** + * Remove a preset. + * Returns true if preset was removed, false if it didn't exist. + */ + removePreset(name: string): boolean { + const config = this.loadConfig(); + + if (!config?.presets?.[name]) { + return false; + } + + delete config.presets[name]; + this.saveConfig(config); + return true; + } + + /** + * Create a backup of the current config file. + * Returns the backup file path, or null if no config exists. + */ + createBackup(): string | null { + if (!this.configExists()) { + return null; + } + + // Ensure backups directory exists + if (!fs.existsSync(this.backupsPath)) { + fs.mkdirSync(this.backupsPath, { recursive: true }); + } + + // Clean up old backups before creating new one + cleanOldBackups(this.backupsPath); + + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const backupFileName = `${timestamp}__oh-my-opencode-slim.json`; + const backupPath = path.join(this.backupsPath, backupFileName); + + fs.copyFileSync(this.targetPath, backupPath); + return backupPath; + } + + /** + * Get the number of agents configured in a preset. + */ + getPresetAgentCount(name: string): number { + const preset = this.getPreset(name); + if (!preset) { + return 0; + } + + const agents = ["orchestrator", "oracle", "librarian", "explorer", "designer", "fixer"] as const; + return agents.filter((agent) => preset[agent] !== undefined).length; + } +} diff --git a/src/store/project-store.ts b/src/store/project-store.ts index cdcce01..c7bb5d2 100644 --- a/src/store/project-store.ts +++ b/src/store/project-store.ts @@ -1,6 +1,7 @@ import * as fs from "fs"; import * as path from "path"; import { ProjectRc } from "./types"; +import { cleanOldBackups } from "../utils/backup-cleaner"; import { getProjectConfigsPath, getProjectTargetPath, @@ -149,6 +150,10 @@ export class ProjectStoreManager { if (!fs.existsSync(this.backupsPath)) { fs.mkdirSync(this.backupsPath, { recursive: true }); } + + // Clean up old backups before creating new one + cleanOldBackups(this.backupsPath); + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const backupFileName = `${timestamp}__${path.basename(configPath)}`; const backupPath = path.join(this.backupsPath, backupFileName); diff --git a/src/store/settings-manager.test.ts b/src/store/settings-manager.test.ts new file mode 100644 index 0000000..807a995 --- /dev/null +++ b/src/store/settings-manager.test.ts @@ -0,0 +1,177 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as path from "path"; + +vi.mock("fs", () => ({ + existsSync: vi.fn(), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), +})); + +vi.mock("../utils/scope-resolver", () => ({ + loadProjectRc: vi.fn(), +})); + +import * as fs from "fs"; +import { loadProjectRc } from "../utils/scope-resolver"; +import { SettingsManager } from "./settings-manager"; + +describe("SettingsManager", () => { + let manager: SettingsManager; + + beforeEach(() => { + vi.clearAllMocks(); + manager = new SettingsManager(); + }); + + describe("loadSettings", () => { + it("returns default settings when file does not exist", () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + const settings = manager.loadSettings(); + + expect(settings.activeType).toBe("omo"); + }); + + it("returns settings from file when it exists", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ activeType: "slim" })); + + const settings = manager.loadSettings(); + + expect(settings.activeType).toBe("slim"); + }); + + it("returns default settings when file is invalid JSON", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue("invalid json"); + + const settings = manager.loadSettings(); + + expect(settings.activeType).toBe("omo"); + }); + + it("uses default activeType when field is missing", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({})); + + const settings = manager.loadSettings(); + + expect(settings.activeType).toBe("omo"); + }); + }); + + describe("saveSettings", () => { + it("creates directory if it does not exist", () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + manager.saveSettings({ activeType: "slim" }); + + expect(fs.mkdirSync).toHaveBeenCalledWith( + expect.any(String), + { recursive: true } + ); + }); + + it("writes settings to file", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + + manager.saveSettings({ activeType: "slim" }); + + expect(fs.writeFileSync).toHaveBeenCalledWith( + expect.stringContaining("settings.json"), + expect.stringContaining('"activeType": "slim"'), + "utf-8" + ); + }); + }); + + describe("getEffectiveType", () => { + it("returns project type when set in project .omorc", () => { + vi.mocked(loadProjectRc).mockReturnValue({ + activeProfileId: null, + type: "slim", + }); + vi.mocked(fs.existsSync).mockReturnValue(false); + + const type = manager.getEffectiveType("/project"); + + expect(type).toBe("slim"); + }); + + it("falls back to global settings when project type is not set", () => { + vi.mocked(loadProjectRc).mockReturnValue({ + activeProfileId: null, + }); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ activeType: "slim" })); + + const type = manager.getEffectiveType("/project"); + + expect(type).toBe("slim"); + }); + + it("returns omo when nothing is set", () => { + vi.mocked(loadProjectRc).mockReturnValue(null); + vi.mocked(fs.existsSync).mockReturnValue(false); + + const type = manager.getEffectiveType("/project"); + + expect(type).toBe("omo"); + }); + + it("returns global type when no project root is provided", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ activeType: "slim" })); + + const type = manager.getEffectiveType(); + + expect(type).toBe("slim"); + expect(loadProjectRc).not.toHaveBeenCalled(); + }); + }); + + describe("isProjectOverride", () => { + it("returns true when project has type override", () => { + vi.mocked(loadProjectRc).mockReturnValue({ + activeProfileId: null, + type: "slim", + }); + + const isOverride = manager.isProjectOverride("/project"); + + expect(isOverride).toBe(true); + }); + + it("returns false when project has no type override", () => { + vi.mocked(loadProjectRc).mockReturnValue({ + activeProfileId: null, + }); + + const isOverride = manager.isProjectOverride("/project"); + + expect(isOverride).toBe(false); + }); + + it("returns false when no project root provided", () => { + const isOverride = manager.isProjectOverride(); + + expect(isOverride).toBe(false); + }); + }); + + describe("setActiveType", () => { + it("persists type to settings file", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ activeType: "omo" })); + + manager.setActiveType("slim"); + + expect(fs.writeFileSync).toHaveBeenCalledWith( + expect.stringContaining("settings.json"), + expect.stringContaining('"activeType": "slim"'), + "utf-8" + ); + }); + }); +}); diff --git a/src/store/settings-manager.ts b/src/store/settings-manager.ts new file mode 100644 index 0000000..e07f4be --- /dev/null +++ b/src/store/settings-manager.ts @@ -0,0 +1,98 @@ +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; +import { ConfigType, GlobalSettings, DEFAULT_SETTINGS } from "./types"; +import { loadProjectRc } from "../utils/scope-resolver"; + +/** + * Manages global settings for omo-switch, including the active configuration type. + * Supports per-project type override via .omorc file. + */ +export class SettingsManager { + private readonly settingsPath: string; + + constructor() { + const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"); + const storePath = path.join(configHome, "omo-switch"); + this.settingsPath = path.join(storePath, "settings.json"); + } + + /** + * Get the path to the settings file. + */ + getSettingsPath(): string { + return this.settingsPath; + } + + /** + * Load global settings from disk. + * Returns default settings if file doesn't exist. + */ + loadSettings(): GlobalSettings { + if (!fs.existsSync(this.settingsPath)) { + return { ...DEFAULT_SETTINGS }; + } + + try { + const content = fs.readFileSync(this.settingsPath, "utf-8"); + const parsed = JSON.parse(content) as Partial; + return { + activeType: parsed.activeType ?? DEFAULT_SETTINGS.activeType, + }; + } catch { + return { ...DEFAULT_SETTINGS }; + } + } + + /** + * Save global settings to disk. + */ + saveSettings(settings: GlobalSettings): void { + const dir = path.dirname(this.settingsPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(this.settingsPath, JSON.stringify(settings, null, 2), "utf-8"); + } + + /** + * Get the effective configuration type, considering project override. + * Resolution priority: + * 1. Project .omorc "type" field (if projectRoot provided and type is set) + * 2. Global settings.json "activeType" + * 3. Default to "omo" + */ + getEffectiveType(projectRoot?: string): ConfigType { + // Check project-level override first + if (projectRoot) { + const projectRc = loadProjectRc(projectRoot); + if (projectRc?.type) { + return projectRc.type; + } + } + + // Fall back to global settings + const settings = this.loadSettings(); + return settings.activeType ?? "omo"; + } + + /** + * Check if the effective type is from a project override. + */ + isProjectOverride(projectRoot?: string): boolean { + if (!projectRoot) { + return false; + } + const projectRc = loadProjectRc(projectRoot); + return projectRc?.type !== undefined; + } + + /** + * Set the global active configuration type. + */ + setActiveType(type: ConfigType): void { + const settings = this.loadSettings(); + settings.activeType = type; + this.saveSettings(settings); + } +} diff --git a/src/store/types.ts b/src/store/types.ts index c3cd866..8438aa4 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -21,6 +21,65 @@ export type Scope = "user" | "project"; export interface ProjectRc { activeProfileId: string | null; + type?: ConfigType; } export const STORE_VERSION = "1.0.0"; + +// ============ Config Type (OMO vs OMOS) ============ + +export type ConfigType = "omo" | "slim"; + +export interface GlobalSettings { + activeType: ConfigType; +} + +export const DEFAULT_SETTINGS: GlobalSettings = { + activeType: "omo", +}; + +// ============ OMOS Types ============ + +export interface OmosAgentConfig { + model: string; + temperature?: number; + variant?: "low" | "medium" | "high"; + skills?: string[]; + mcps?: string[]; +} + +export interface OmosPresetConfig { + orchestrator?: OmosAgentConfig; + oracle?: OmosAgentConfig; + librarian?: OmosAgentConfig; + explorer?: OmosAgentConfig; + designer?: OmosAgentConfig; + fixer?: OmosAgentConfig; +} + +export interface OmosTmuxConfig { + enabled?: boolean; + layout?: "main-vertical" | "main-horizontal" | "tiled" | "even-horizontal" | "even-vertical"; + main_pane_size?: number; +} + +export interface OmosConfig { + preset?: string | null; + presets?: Record; + tmux?: OmosTmuxConfig; + disabled_mcps?: string[]; +} + +export const DEFAULT_OMOS_CONFIG: OmosConfig = { + preset: "zen-free", + presets: { + "zen-free": { + orchestrator: { model: "opencode/big-pickle" }, + oracle: { model: "opencode/big-pickle" }, + librarian: { model: "opencode/big-pickle" }, + explorer: { model: "opencode/big-pickle" }, + designer: { model: "opencode/big-pickle" }, + fixer: { model: "opencode/big-pickle" }, + }, + }, +}; diff --git a/src/utils/backup-cleaner.test.ts b/src/utils/backup-cleaner.test.ts new file mode 100644 index 0000000..1eab7b8 --- /dev/null +++ b/src/utils/backup-cleaner.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import * as path from "path"; + +// Mock fs module +vi.mock("fs", () => ({ + existsSync: vi.fn(), + readdirSync: vi.fn(), + statSync: vi.fn(), + unlinkSync: vi.fn(), +})); + +import * as fs from "fs"; +import { cleanOldBackups } from "./backup-cleaner"; + +describe("backup-cleaner", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("cleanOldBackups", () => { + const backupsPath = path.normalize("/test/backups"); + + it("returns 0 if backups directory does not exist", () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + const result = cleanOldBackups(backupsPath); + + expect(result).toBe(0); + expect(fs.readdirSync).not.toHaveBeenCalled(); + }); + + it("returns 0 if backups directory is empty", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readdirSync).mockReturnValue([]); + + const result = cleanOldBackups(backupsPath); + + expect(result).toBe(0); + }); + + it("does not delete files newer than 30 days", () => { + const now = Date.now(); + const twentyDaysAgo = now - (20 * 24 * 60 * 60 * 1000); + + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readdirSync).mockReturnValue(["recent-backup.json"] as any); + vi.mocked(fs.statSync).mockReturnValue({ + isDirectory: () => false, + mtimeMs: twentyDaysAgo, + } as fs.Stats); + + const result = cleanOldBackups(backupsPath); + + expect(result).toBe(0); + expect(fs.unlinkSync).not.toHaveBeenCalled(); + }); + + it("deletes files older than 30 days", () => { + const now = Date.now(); + const fortyDaysAgo = now - (40 * 24 * 60 * 60 * 1000); + + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readdirSync).mockReturnValue(["old-backup.json"] as any); + vi.mocked(fs.statSync).mockReturnValue({ + isDirectory: () => false, + mtimeMs: fortyDaysAgo, + } as fs.Stats); + + const result = cleanOldBackups(backupsPath); + + expect(result).toBe(1); + expect(fs.unlinkSync).toHaveBeenCalledWith(path.join(backupsPath, "old-backup.json")); + }); + + it("skips directories", () => { + const now = Date.now(); + const fortyDaysAgo = now - (40 * 24 * 60 * 60 * 1000); + + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readdirSync).mockReturnValue(["subdir"] as any); + vi.mocked(fs.statSync).mockReturnValue({ + isDirectory: () => true, + mtimeMs: fortyDaysAgo, + } as fs.Stats); + + const result = cleanOldBackups(backupsPath); + + expect(result).toBe(0); + expect(fs.unlinkSync).not.toHaveBeenCalled(); + }); + + it("deletes multiple old files and counts correctly", () => { + const now = Date.now(); + const fortyDaysAgo = now - (40 * 24 * 60 * 60 * 1000); + const fiftyDaysAgo = now - (50 * 24 * 60 * 60 * 1000); + const tenDaysAgo = now - (10 * 24 * 60 * 60 * 1000); + + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readdirSync).mockReturnValue([ + "old1.json", + "old2.json", + "recent.json", + ] as any); + + vi.mocked(fs.statSync) + .mockReturnValueOnce({ isDirectory: () => false, mtimeMs: fortyDaysAgo } as fs.Stats) + .mockReturnValueOnce({ isDirectory: () => false, mtimeMs: fiftyDaysAgo } as fs.Stats) + .mockReturnValueOnce({ isDirectory: () => false, mtimeMs: tenDaysAgo } as fs.Stats); + + const result = cleanOldBackups(backupsPath); + + expect(result).toBe(2); + expect(fs.unlinkSync).toHaveBeenCalledTimes(2); + }); + + it("handles deletion errors gracefully", () => { + const now = Date.now(); + const fortyDaysAgo = now - (40 * 24 * 60 * 60 * 1000); + + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readdirSync).mockReturnValue([ + "old1.json", + "old2.json", + ] as any); + vi.mocked(fs.statSync).mockReturnValue({ + isDirectory: () => false, + mtimeMs: fortyDaysAgo, + } as fs.Stats); + + // First delete succeeds, second fails + vi.mocked(fs.unlinkSync) + .mockImplementationOnce(() => {}) + .mockImplementationOnce(() => { throw new Error("Permission denied"); }); + + const result = cleanOldBackups(backupsPath); + + // Only counts successful deletions + expect(result).toBe(1); + }); + + it("handles readdirSync errors gracefully", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readdirSync).mockImplementation(() => { throw new Error("EACCES"); }); + + const result = cleanOldBackups(backupsPath); + + expect(result).toBe(0); + }); + }); +}); diff --git a/src/utils/backup-cleaner.ts b/src/utils/backup-cleaner.ts new file mode 100644 index 0000000..c8aa35e --- /dev/null +++ b/src/utils/backup-cleaner.ts @@ -0,0 +1,52 @@ +import * as fs from "fs"; +import * as path from "path"; + +const BACKUP_RETENTION_DAYS = 30; + +/** + * Clean up old backup files in the specified directory. + * Removes files older than BACKUP_RETENTION_DAYS (30 days). + * + * Backup files are expected to have ISO timestamp prefix format: + * YYYY-MM-DDTHH-MM-SS-sssZ__filename.json + */ +export function cleanOldBackups(backupsPath: string): number { + if (!fs.existsSync(backupsPath)) { + return 0; + } + + const now = Date.now(); + const retentionMs = BACKUP_RETENTION_DAYS * 24 * 60 * 60 * 1000; + const cutoffTime = now - retentionMs; + + let deletedCount = 0; + + try { + const files = fs.readdirSync(backupsPath); + + for (const file of files) { + const filePath = path.join(backupsPath, file); + + // Skip directories + const stat = fs.statSync(filePath); + if (stat.isDirectory()) { + continue; + } + + // Check file modification time + const mtime = stat.mtimeMs; + if (mtime < cutoffTime) { + try { + fs.unlinkSync(filePath); + deletedCount++; + } catch { + // Ignore deletion errors for individual files + } + } + } + } catch { + // Ignore errors reading the directory + } + + return deletedCount; +} diff --git a/src/utils/omos-config-path.ts b/src/utils/omos-config-path.ts new file mode 100644 index 0000000..e99f08c --- /dev/null +++ b/src/utils/omos-config-path.ts @@ -0,0 +1,82 @@ +import * as os from "os"; +import * as path from "path"; +import * as fs from "fs"; + +const OMOS_CONFIG_FILENAME = "oh-my-opencode-slim.json"; + +/** + * Get the directory path for OMOS user config. + * Returns the opencode config directory. + */ +export function getOmosConfigDir(): string { + const isWindows = os.platform() === "win32"; + + if (isWindows) { + const userProfile = process.env.USERPROFILE || os.homedir(); + return path.join(userProfile, ".config", "opencode"); + } + + const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"); + return path.join(configHome, "opencode"); +} + +/** + * Get the target path for OMOS user config. + * OMOS uses .json ONLY - never .jsonc! + */ +export function getOmosConfigTargetPath(): { path: string; isPreferred: boolean } { + const configDir = getOmosConfigDir(); + return { + path: path.join(configDir, OMOS_CONFIG_FILENAME), + isPreferred: true, + }; +} + +/** + * Get the target path for OMOS project config. + * Returns: /.opencode/oh-my-opencode-slim.json + */ +export function getOmosProjectTargetPath(projectRoot: string): string { + return path.join(projectRoot, ".opencode", OMOS_CONFIG_FILENAME); +} + +/** + * Find existing OMOS config file at user config location. + * OMOS only supports .json (NO .jsonc) + * Returns null if doesn't exist. + */ +export function findExistingOmosConfigPath(): { path: string; exists: boolean } | null { + const targetPath = getOmosConfigTargetPath().path; + + if (fs.existsSync(targetPath)) { + return { path: targetPath, exists: true }; + } + + return null; +} + +/** + * Check if OMOS config exists at the given scope. + */ +export function omosConfigExists(scope: "user" | "project", projectRoot?: string): boolean { + if (scope === "user") { + return findExistingOmosConfigPath() !== null; + } + + if (scope === "project" && projectRoot) { + const projectPath = getOmosProjectTargetPath(projectRoot); + return fs.existsSync(projectPath); + } + + return false; +} + +/** + * Ensure the OMOS config directory exists. + */ +export function ensureOmosConfigDir(configPath: string): void { + const dir = path.dirname(configPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +} diff --git a/src/utils/omos-validator.test.ts b/src/utils/omos-validator.test.ts new file mode 100644 index 0000000..eaf910a --- /dev/null +++ b/src/utils/omos-validator.test.ts @@ -0,0 +1,223 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("fs", () => ({ + readFileSync: vi.fn(), +})); + +import * as fs from "fs"; + +// Sample schema that matches the structure of oh-my-opencode-slim.schema.json +const mockSchema = { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "preset": { "type": "string" }, + "presets": { + "type": "object", + "additionalProperties": { "$ref": "#/definitions/presetConfig" }, + }, + "tmux": { "$ref": "#/definitions/tmuxConfig" }, + "disabled_mcps": { + "type": "array", + "items": { "type": "string" }, + }, + }, + "definitions": { + "presetConfig": { + "type": "object", + "properties": { + "orchestrator": { "$ref": "#/definitions/agentConfig" }, + "oracle": { "$ref": "#/definitions/agentConfig" }, + "librarian": { "$ref": "#/definitions/agentConfig" }, + "explorer": { "$ref": "#/definitions/agentConfig" }, + "designer": { "$ref": "#/definitions/agentConfig" }, + "fixer": { "$ref": "#/definitions/agentConfig" }, + }, + "additionalProperties": false, + }, + "agentConfig": { + "type": "object", + "properties": { + "model": { "type": "string" }, + "temperature": { "type": "number", "minimum": 0, "maximum": 2 }, + "variant": { "type": "string", "enum": ["low", "medium", "high"] }, + "skills": { "type": "array", "items": { "type": "string" } }, + "mcps": { "type": "array", "items": { "type": "string" } }, + }, + "required": ["model"], + "additionalProperties": false, + }, + "tmuxConfig": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "layout": { "type": "string" }, + }, + }, + }, + "additionalProperties": false, +}; + +describe("OmosValidator", () => { + let OmosValidator: any; + + beforeEach(async () => { + vi.clearAllMocks(); + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockSchema)); + + // Dynamically import to get fresh instance with mocked fs + const mod = await import("./omos-validator"); + OmosValidator = mod.OmosValidator; + }); + + describe("validate (full config)", () => { + it("validates a correct full config", () => { + const validator = new OmosValidator("/path/to/schema.json"); + + const result = validator.validate({ + preset: "default", + presets: { + default: { + orchestrator: { model: "test/model" }, + }, + }, + }); + + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it("rejects invalid full config", () => { + const validator = new OmosValidator("/path/to/schema.json"); + + const result = validator.validate({ + preset: 123, // Should be string + } as any); + + expect(result.valid).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it("rejects additional properties in full config", () => { + const validator = new OmosValidator("/path/to/schema.json"); + + const result = validator.validate({ + preset: "default", + unknownField: "value", + } as any); + + expect(result.valid).toBe(false); + }); + }); + + describe("validatePreset", () => { + it("validates a correct preset config", () => { + const validator = new OmosValidator("/path/to/schema.json"); + + const result = validator.validatePreset({ + orchestrator: { model: "test/model" }, + oracle: { model: "another/model", temperature: 0.5 }, + }); + + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it("validates preset with all agent types", () => { + const validator = new OmosValidator("/path/to/schema.json"); + + const result = validator.validatePreset({ + orchestrator: { model: "m1" }, + oracle: { model: "m2" }, + librarian: { model: "m3" }, + explorer: { model: "m4" }, + designer: { model: "m5" }, + fixer: { model: "m6" }, + }); + + expect(result.valid).toBe(true); + }); + + it("rejects preset with invalid agent config", () => { + const validator = new OmosValidator("/path/to/schema.json"); + + const result = validator.validatePreset({ + orchestrator: { temperature: 0.5 }, // Missing required 'model' + } as any); + + expect(result.valid).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it("rejects preset with unknown agent type", () => { + const validator = new OmosValidator("/path/to/schema.json"); + + const result = validator.validatePreset({ + orchestrator: { model: "test/model" }, + unknownAgent: { model: "test/model" }, + } as any); + + expect(result.valid).toBe(false); + }); + }); + + describe("validateAgentConfig", () => { + it("validates a correct agent config", () => { + const validator = new OmosValidator("/path/to/schema.json"); + + const result = validator.validateAgentConfig({ + model: "test/model", + temperature: 0.7, + variant: "medium", + skills: ["skill1", "skill2"], + mcps: ["mcp1"], + }); + + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it("validates minimal agent config (model only)", () => { + const validator = new OmosValidator("/path/to/schema.json"); + + const result = validator.validateAgentConfig({ + model: "test/model", + }); + + expect(result.valid).toBe(true); + }); + + it("rejects agent config without model", () => { + const validator = new OmosValidator("/path/to/schema.json"); + + const result = validator.validateAgentConfig({ + temperature: 0.5, + } as any); + + expect(result.valid).toBe(false); + expect(result.errors).toContainEqual(expect.stringContaining("model")); + }); + + it("rejects agent config with invalid temperature", () => { + const validator = new OmosValidator("/path/to/schema.json"); + + const result = validator.validateAgentConfig({ + model: "test/model", + temperature: 3, // Max is 2 + }); + + expect(result.valid).toBe(false); + }); + + it("rejects agent config with invalid variant", () => { + const validator = new OmosValidator("/path/to/schema.json"); + + const result = validator.validateAgentConfig({ + model: "test/model", + variant: "invalid", + } as any); + + expect(result.valid).toBe(false); + }); + }); +}); diff --git a/src/utils/omos-validator.ts b/src/utils/omos-validator.ts new file mode 100644 index 0000000..0a87437 --- /dev/null +++ b/src/utils/omos-validator.ts @@ -0,0 +1,122 @@ +import * as fs from "fs"; +import Ajv from "ajv"; +import { OmosConfig, OmosPresetConfig, OmosAgentConfig } from "../store"; + +export interface ValidationResult { + valid: boolean; + errors: string[]; +} + +/** + * Validates OMOS configurations against the oh-my-opencode-slim schema. + * Supports validation of full configs, individual presets, and agent configs. + */ +export class OmosValidator { + private readonly ajv: Ajv; + private readonly schema: Record; + private readonly validateFullConfig: ReturnType; + private readonly validatePresetConfig: ReturnType; + private readonly validateAgentConfigFn: ReturnType; + + constructor(schemaPath: string) { + this.ajv = new Ajv({ allErrors: true, strict: false }); + + const schemaContent = fs.readFileSync(schemaPath, "utf-8"); + this.schema = JSON.parse(schemaContent); + + // Compile full config validator + this.validateFullConfig = this.ajv.compile(this.schema); + + // Extract and compile preset config validator + const presetConfigDef = (this.schema as any).definitions?.presetConfig; + if (presetConfigDef) { + // Create a standalone schema for preset validation + const presetSchema = { + $schema: "http://json-schema.org/draft-07/schema#", + ...presetConfigDef, + definitions: (this.schema as any).definitions, + }; + this.validatePresetConfig = this.ajv.compile(presetSchema); + } else { + // Fallback: create a permissive preset validator + this.validatePresetConfig = this.ajv.compile({ + type: "object", + additionalProperties: true, + }); + } + + // Extract and compile agent config validator + const agentConfigDef = (this.schema as any).definitions?.agentConfig; + if (agentConfigDef) { + const agentSchema = { + $schema: "http://json-schema.org/draft-07/schema#", + ...agentConfigDef, + }; + this.validateAgentConfigFn = this.ajv.compile(agentSchema); + } else { + // Fallback: require at least a model field + this.validateAgentConfigFn = this.ajv.compile({ + type: "object", + properties: { + model: { type: "string" }, + }, + required: ["model"], + }); + } + } + + /** + * Validate a full OMOS configuration. + */ + validate(config: OmosConfig): ValidationResult { + const valid = this.validateFullConfig(config); + + if (valid) { + return { valid: true, errors: [] }; + } + + const errors = (this.validateFullConfig.errors || []).map((err) => { + const path = err.instancePath || "/"; + return `${path}: ${err.message}`; + }); + + return { valid: false, errors }; + } + + /** + * Validate a single preset configuration. + * This validates the structure that would go into presets[presetName]. + */ + validatePreset(preset: OmosPresetConfig): ValidationResult { + const valid = this.validatePresetConfig(preset); + + if (valid) { + return { valid: true, errors: [] }; + } + + const errors = (this.validatePresetConfig.errors || []).map((err) => { + const path = err.instancePath || "/"; + return `${path}: ${err.message}`; + }); + + return { valid: false, errors }; + } + + /** + * Validate a single agent configuration. + */ + validateAgentConfig(agent: OmosAgentConfig): ValidationResult { + const valid = this.validateAgentConfigFn(agent); + + if (valid) { + return { valid: true, errors: [] }; + } + + const errors = (this.validateAgentConfigFn.errors || []).map((err) => { + const path = err.instancePath || "/"; + return `${path}: ${err.message}`; + }); + + return { valid: false, errors }; + } +} diff --git a/src/utils/settings.ts b/src/utils/settings.ts new file mode 100644 index 0000000..fda5ad0 --- /dev/null +++ b/src/utils/settings.ts @@ -0,0 +1,73 @@ +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; +import { GlobalSettings, DEFAULT_SETTINGS, ConfigType } from "../store/types"; + +/** + * Get the path to the global settings file. + */ +export function getSettingsPath(): string { + const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"); + return path.join(configHome, "omo-switch", "settings.json"); +} + +/** + * Load global settings. Returns default settings if file doesn't exist. + */ +export function loadGlobalSettings(): GlobalSettings { + const settingsPath = getSettingsPath(); + + if (!fs.existsSync(settingsPath)) { + return { ...DEFAULT_SETTINGS }; + } + + try { + const content = fs.readFileSync(settingsPath, "utf-8"); + const parsed = JSON.parse(content) as Partial; + + // Merge with defaults to ensure all fields exist + return { + ...DEFAULT_SETTINGS, + ...parsed, + }; + } catch { + return { ...DEFAULT_SETTINGS }; + } +} + +/** + * Save global settings. + */ +export function saveGlobalSettings(settings: GlobalSettings): void { + const settingsPath = getSettingsPath(); + const dir = path.dirname(settingsPath); + + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), "utf-8"); +} + +/** + * Get the current active config type. + */ +export function getActiveConfigType(): ConfigType { + return loadGlobalSettings().activeType; +} + +/** + * Set the active config type. + */ +export function setActiveConfigType(type: ConfigType): void { + const settings = loadGlobalSettings(); + settings.activeType = type; + saveGlobalSettings(settings); +} + +/** + * Check if current mode is OMOS. + */ +export function isOmosMode(): boolean { + return getActiveConfigType() === "slim"; +} From ffe14deb553ed277e35f0d6c89e42535c7a8119d Mon Sep 17 00:00:00 2001 From: Aykahshi Date: Wed, 28 Jan 2026 15:51:57 +0800 Subject: [PATCH 4/8] ci: add pr-check action --- .github/workflows/pr-check.yml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .github/workflows/pr-check.yml diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml new file mode 100644 index 0000000..8530a67 --- /dev/null +++ b/.github/workflows/pr-check.yml @@ -0,0 +1,29 @@ +name: PR Check + +on: + pull_request: + branches: [ main, master ] + types: [ opened, synchronize, reopened ] + +jobs: + verify: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build project + run: npm run build + + - name: Run tests + run: npm test From 9b97487b54b8a634984212b63e02b0aeff03aae0 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 28 Jan 2026 08:05:08 +0000 Subject: [PATCH 5/8] Version, CHANGELOG, docs missing Co-authored-by: Aykahshi --- package-lock.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2ad71f0..2ee4c58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "omo-switch-cli", - "version": "0.1.0", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omo-switch-cli", - "version": "0.1.0", + "version": "0.1.1", "license": "MIT", "dependencies": { "@inquirer/prompts": "^7.0.0", @@ -19,7 +19,8 @@ "ora": "^5.4.1" }, "bin": { - "omo-switch": "dist/index.js" + "omo-switch": "dist/index.js", + "omos": "dist/index.js" }, "devDependencies": { "@types/node": "^22.10.2", From 2e0c779764c4db85bd6dcbb8cdfcb975a0588b78 Mon Sep 17 00:00:00 2001 From: Aykahshi Date: Wed, 28 Jan 2026 16:15:56 +0800 Subject: [PATCH 6/8] feat(cli): add custom backup period in settings.json --- README.md | 19 +++++++++++++++++-- src/store/index.ts | 5 ++++- src/store/omos-config.ts | 5 ++++- src/store/project-store.ts | 5 ++++- src/store/settings-manager.ts | 1 + src/store/types.ts | 2 ++ src/utils/backup-cleaner.ts | 8 +++----- 7 files changed, 35 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index f093c3c..6ffa2fa 100644 --- a/README.md +++ b/README.md @@ -448,9 +448,24 @@ Ensure your terminal has write permissions to: ### Finding Backups If something goes wrong, find your original configuration in: +- **Global**: `~/.config/omo-switch/backups/__oh-my-opencode.jsonc` +- **Project**: `/.opencode/backups/__oh-my-opencode.jsonc` + +### Backup Retention Policy + +By default, `omo-switch` keeps backups for **30 days**. Old backup files are automatically scanned and removed whenever a new backup is created (e.g., when running `apply`). + +You can customize the retention period by editing your global `settings.json` file: + +**File**: `~/.config/omo-switch/settings.json` +```json +{ + "activeType": "omo", + "backupRetentionDays": 14 +} ``` -~/.config/omo-switch/backups/__oh-my-opencode.jsonc -``` + +Set `backupRetentionDays` to a larger number to keep backups longer, or a smaller number to save space. ## License diff --git a/src/store/index.ts b/src/store/index.ts index 93a229a..20f12ef 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -3,6 +3,7 @@ import * as path from "path"; import * as os from "os"; import { Profile, StoreIndex, STORE_VERSION } from "./types"; import { cleanOldBackups } from "../utils/backup-cleaner"; +import { SettingsManager } from "./settings-manager"; export * from "./types"; export { ProjectStoreManager } from "./project-store"; @@ -122,7 +123,9 @@ export class StoreManager { } // Clean up old backups before creating new one - cleanOldBackups(this.backupsPath); + const settings = new SettingsManager(); + const retentionDays = settings.loadSettings().backupRetentionDays; + cleanOldBackups(this.backupsPath, retentionDays); const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const backupFileName = `${timestamp}__${path.basename(configPath)}`; diff --git a/src/store/omos-config.ts b/src/store/omos-config.ts index e34e9d4..13712f5 100644 --- a/src/store/omos-config.ts +++ b/src/store/omos-config.ts @@ -8,6 +8,7 @@ import { DEFAULT_OMOS_CONFIG, } from "./types"; import { cleanOldBackups } from "../utils/backup-cleaner"; +import { SettingsManager } from "./settings-manager"; import { getOmosConfigTargetPath, getOmosProjectTargetPath, @@ -179,7 +180,9 @@ export class OmosConfigManager { } // Clean up old backups before creating new one - cleanOldBackups(this.backupsPath); + const settings = new SettingsManager(); + const retentionDays = settings.loadSettings().backupRetentionDays; + cleanOldBackups(this.backupsPath, retentionDays); const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const backupFileName = `${timestamp}__oh-my-opencode-slim.json`; diff --git a/src/store/project-store.ts b/src/store/project-store.ts index c7bb5d2..81daa36 100644 --- a/src/store/project-store.ts +++ b/src/store/project-store.ts @@ -2,6 +2,7 @@ import * as fs from "fs"; import * as path from "path"; import { ProjectRc } from "./types"; import { cleanOldBackups } from "../utils/backup-cleaner"; +import { SettingsManager } from "./settings-manager"; import { getProjectConfigsPath, getProjectTargetPath, @@ -152,7 +153,9 @@ export class ProjectStoreManager { } // Clean up old backups before creating new one - cleanOldBackups(this.backupsPath); + const settings = new SettingsManager(); + const retentionDays = settings.loadSettings().backupRetentionDays; + cleanOldBackups(this.backupsPath, retentionDays); const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const backupFileName = `${timestamp}__${path.basename(configPath)}`; diff --git a/src/store/settings-manager.ts b/src/store/settings-manager.ts index e07f4be..3b4ff18 100644 --- a/src/store/settings-manager.ts +++ b/src/store/settings-manager.ts @@ -38,6 +38,7 @@ export class SettingsManager { const parsed = JSON.parse(content) as Partial; return { activeType: parsed.activeType ?? DEFAULT_SETTINGS.activeType, + backupRetentionDays: parsed.backupRetentionDays ?? DEFAULT_SETTINGS.backupRetentionDays, }; } catch { return { ...DEFAULT_SETTINGS }; diff --git a/src/store/types.ts b/src/store/types.ts index 8438aa4..2017a24 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -32,10 +32,12 @@ export type ConfigType = "omo" | "slim"; export interface GlobalSettings { activeType: ConfigType; + backupRetentionDays?: number; } export const DEFAULT_SETTINGS: GlobalSettings = { activeType: "omo", + backupRetentionDays: 30, }; // ============ OMOS Types ============ diff --git a/src/utils/backup-cleaner.ts b/src/utils/backup-cleaner.ts index c8aa35e..463adac 100644 --- a/src/utils/backup-cleaner.ts +++ b/src/utils/backup-cleaner.ts @@ -1,22 +1,20 @@ import * as fs from "fs"; import * as path from "path"; -const BACKUP_RETENTION_DAYS = 30; - /** * Clean up old backup files in the specified directory. - * Removes files older than BACKUP_RETENTION_DAYS (30 days). + * Removes files older than retentionDays (defaults to 30 days). * * Backup files are expected to have ISO timestamp prefix format: * YYYY-MM-DDTHH-MM-SS-sssZ__filename.json */ -export function cleanOldBackups(backupsPath: string): number { +export function cleanOldBackups(backupsPath: string, retentionDays: number = 30): number { if (!fs.existsSync(backupsPath)) { return 0; } const now = Date.now(); - const retentionMs = BACKUP_RETENTION_DAYS * 24 * 60 * 60 * 1000; + const retentionMs = retentionDays * 24 * 60 * 60 * 1000; const cutoffTime = now - retentionMs; let deletedCount = 0; From cbc15ae1a3b50c9e2ea73c5bfb06e166007cff56 Mon Sep 17 00:00:00 2001 From: Aykahshi Date: Wed, 28 Jan 2026 16:16:58 +0800 Subject: [PATCH 7/8] chore: bump package version --- package.json | 2 +- src/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 909928c..cc8658f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "omo-switch-cli", - "version": "0.1.1", + "version": "0.2.0", "description": "CLI tool for managing oh-my-opencode profiles", "main": "dist/index.js", "bin": { diff --git a/src/index.ts b/src/index.ts index eba06b4..cfb3986 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,7 +15,7 @@ const program = new Command(); program .name("omo-switch") .description("CLI tool for managing oh-my-opencode profiles") - .version("0.1.1"); + .version("0.2.0"); program.addCommand(initCommand); program.addCommand(listCommand); From 53ba59ca1fab96e1a5dac8668f6e02e344e7cde1 Mon Sep 17 00:00:00 2001 From: Aykahshi Date: Wed, 28 Jan 2026 16:17:36 +0800 Subject: [PATCH 8/8] chore: update lock file --- package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2ee4c58..b648da5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "omo-switch-cli", - "version": "0.1.1", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omo-switch-cli", - "version": "0.1.1", + "version": "0.2.0", "license": "MIT", "dependencies": { "@inquirer/prompts": "^7.0.0",