Alvin 2 months ago
parent
commit
136f6341fc

+ 63 - 43
README.md

@@ -53,6 +53,8 @@
 - [⚙️ **Configuration**](#configuration)
 - [⚙️ **Configuration**](#configuration)
   - [Files You Edit](#files-you-edit)
   - [Files You Edit](#files-you-edit)
   - [Plugin Config](#plugin-config-oh-my-opencode-slimjson)
   - [Plugin Config](#plugin-config-oh-my-opencode-slimjson)
+    - [Presets](#presets)
+    - [Option Reference](#option-reference)
 - [🗑️ **Uninstallation**](#uninstallation)
 - [🗑️ **Uninstallation**](#uninstallation)
 
 
 ---
 ---
@@ -462,18 +464,49 @@ You can disable specific MCP servers by adding them to the `disabled_mcps` array
 
 
 The installer generates this file based on your providers. You can manually customize it to mix and match models.
 The installer generates this file based on your providers. You can manually customize it to mix and match models.
 
 
+### Presets
+
+The installer generates presets for different provider combinations. Switch between them by changing the `preset` field.
+
 <details open>
 <details open>
 <summary><b>Example: Antigravity + OpenAI (Recommended)</b></summary>
 <summary><b>Example: Antigravity + OpenAI (Recommended)</b></summary>
 
 
 ```json
 ```json
 {
 {
-  "agents": {
-    "orchestrator": { "model": "google/claude-opus-4-5-thinking", "skills": ["*"] },
-    "oracle": { "model": "openai/gpt-5.2-codex", "skills": [] },
-    "librarian": { "model": "google/gemini-3-flash", "skills": [] },
-    "explorer": { "model": "google/gemini-3-flash", "skills": [] },
-    "designer": { "model": "google/gemini-3-flash", "skills": ["playwright"] },
-    "fixer": { "model": "google/gemini-3-flash", "skills": [] }
+  "preset": "antigravity-openai",
+  "presets": {
+    "antigravity": {
+      "orchestrator": { "model": "google/claude-opus-4-5-thinking", "skills": ["*"] },
+      "oracle": { "model": "google/claude-opus-4-5-thinking", "variant": "high", "skills": [] },
+      "librarian": { "model": "google/gemini-3-flash", "variant": "low", "skills": [] },
+      "explorer": { "model": "google/gemini-3-flash", "variant": "low", "skills": [] },
+      "designer": { "model": "google/gemini-3-flash", "variant": "medium", "skills": ["playwright"] },
+      "fixer": { "model": "google/gemini-3-flash", "variant": "low", "skills": [] }
+    },
+    "openai": {
+      "orchestrator": { "model": "openai/gpt-5.2-codex", "skills": ["*"] },
+      "oracle": { "model": "openai/gpt-5.2-codex", "variant": "high", "skills": [] },
+      "librarian": { "model": "openai/gpt-5.1-codex-mini", "variant": "low", "skills": [] },
+      "explorer": { "model": "openai/gpt-5.1-codex-mini", "variant": "low", "skills": [] },
+      "designer": { "model": "openai/gpt-5.1-codex-mini", "variant": "medium", "skills": ["playwright"] },
+      "fixer": { "model": "openai/gpt-5.1-codex-mini", "variant": "low", "skills": [] }
+    },
+    "zen-free": {
+      "orchestrator": { "model": "opencode/glm-4.7-free", "skills": ["*"] },
+      "oracle": { "model": "opencode/glm-4.7-free", "variant": "high", "skills": [] },
+      "librarian": { "model": "opencode/grok-code", "variant": "low", "skills": [] },
+      "explorer": { "model": "opencode/grok-code", "variant": "low", "skills": [] },
+      "designer": { "model": "opencode/grok-code", "variant": "medium", "skills": ["playwright"] },
+      "fixer": { "model": "opencode/grok-code", "variant": "low", "skills": [] }
+    },
+    "antigravity-openai": {
+      "orchestrator": { "model": "google/claude-opus-4-5-thinking", "skills": ["*"] },
+      "oracle": { "model": "openai/gpt-5.2-codex", "variant": "high", "skills": [] },
+      "librarian": { "model": "google/gemini-3-flash", "variant": "low", "skills": [] },
+      "explorer": { "model": "google/gemini-3-flash", "variant": "low", "skills": [] },
+      "designer": { "model": "google/gemini-3-flash", "variant": "medium", "skills": ["playwright"] },
+      "fixer": { "model": "google/gemini-3-flash", "variant": "low", "skills": [] }
+    }
   },
   },
   "tmux": {
   "tmux": {
     "enabled": true,
     "enabled": true,
@@ -484,55 +517,42 @@ The installer generates this file based on your providers. You can manually cust
 ```
 ```
 </details>
 </details>
 
 
-<details>
-<summary><b>Example: Antigravity Only</b></summary>
+**Available Presets:**
 
 
-```json
-{
-  "agents": {
-    "orchestrator": { "model": "google/claude-opus-4-5-thinking", "skills": ["*"] },
-    "oracle": { "model": "google/claude-opus-4-5-thinking", "skills": [] },
-    "librarian": { "model": "google/gemini-3-flash", "skills": [] },
-    "explorer": { "model": "google/gemini-3-flash", "skills": [] },
-    "designer": { "model": "google/gemini-3-flash", "skills": ["playwright"] },
-    "fixer": { "model": "google/gemini-3-flash", "skills": [] }
-  }
-}
-```
-</details>
+| Preset | Description |
+|--------|-------------|
+| `antigravity` | Google models (Claude Opus + Gemini Flash) |
+| `openai` | OpenAI models (GPT-5.2 + GPT-5.1-mini) |
+| `zen-free` | Free models (GLM-4.7 + Grok Code) |
+| `antigravity-openai` | Mixed: Antigravity for most agents, OpenAI for Oracle |
 
 
-<details>
-<summary><b>Example: OpenAI Only</b></summary>
+**Environment Variable Override:**
 
 
-```json
-{
-  "agents": {
-    "orchestrator": { "model": "openai/gpt-5.2-codex", "skills": ["*"] },
-    "oracle": { "model": "openai/gpt-5.2-codex", "skills": [] },
-    "librarian": { "model": "openai/gpt-5.1-codex-mini", "skills": [] },
-    "explorer": { "model": "openai/gpt-5.1-codex-mini", "skills": [] },
-    "designer": { "model": "openai/gpt-5.1-codex-mini", "skills": ["playwright"] },
-    "fixer": { "model": "openai/gpt-5.1-codex-mini", "skills": [] }
-  }
-}
+You can override the preset using an environment variable:
+
+```bash
+export OH_MY_OPENCODE_SLIM_PRESET=openai
+opencode
 ```
 ```
-</details>
+
+The environment variable takes precedence over the `preset` field in the config file.
 
 
 #### Option Reference
 #### Option Reference
 
 
 | Option | Type | Default | Description |
 | Option | Type | Default | Description |
 |--------|------|---------|-------------|
 |--------|------|---------|-------------|
+| `preset` | string | - | Name of the preset to use (e.g., `"antigravity"`, `"openai"`) |
+| `presets` | object | - | Named preset configurations containing agent mappings |
+| `presets.<name>.<agent>.model` | string | - | Model ID for the agent (e.g., `"google/claude-opus-4-5-thinking"`) |
+| `presets.<name>.<agent>.temperature` | number | - | Temperature setting (0-2) for the agent |
+| `presets.<name>.<agent>.variant` | string | - | Agent variant for reasoning effort (e.g., `"low"`, `"medium"`, `"high"`) |
+| `presets.<name>.<agent>.skills` | string[] | - | Array of skill names the agent can use (`"*"` for all) |
 | `tmux.enabled` | boolean | `false` | Enable tmux pane spawning for sub-agents |
 | `tmux.enabled` | boolean | `false` | Enable tmux pane spawning for sub-agents |
 | `tmux.layout` | string | `"main-vertical"` | Layout preset: `main-vertical`, `main-horizontal`, `tiled`, `even-horizontal`, `even-vertical` |
 | `tmux.layout` | string | `"main-vertical"` | Layout preset: `main-vertical`, `main-horizontal`, `tiled`, `even-horizontal`, `even-vertical` |
 | `tmux.main_pane_size` | number | `60` | Main pane size as percentage (20-80) |
 | `tmux.main_pane_size` | number | `60` | Main pane size as percentage (20-80) |
 | `disabled_mcps` | string[] | `[]` | MCP server IDs to disable (e.g., `"websearch"`) |
 | `disabled_mcps` | string[] | `[]` | MCP server IDs to disable (e.g., `"websearch"`) |
-| `agents.<name>.model` | string |  -  | Override the LLM for a specific agent |
-| `agents.<name>.variant` | string |  -  | Reasoning effort: `"low"`, `"medium"`, `"high"` |
-| `agents.<name>.skills` | string[] |  -  | Skills this agent can use (`"*"` = all) |
-| `agents.<name>.temperature` | number |  -  | Temperature for this agent (0.0 to 2.0) |
-| `agents.<name>.prompt` | string |  -  | Base prompt override for this agent |
-| `agents.<name>.prompt_append` | string |  -  | Text to append to the base prompt |
-| `agents.<name>.disable` | boolean |  -  | Disable this specific agent |
+
+> **Note:** Agent configuration should be defined within `presets`. The root-level `agents` field is deprecated.
 
 
 ---
 ---
 
 

+ 8 - 2
src/cli/config-io.test.ts

@@ -147,7 +147,8 @@ describe("config-io", () => {
     expect(result.success).toBe(true)
     expect(result.success).toBe(true)
     
     
     const saved = JSON.parse(readFileSync(litePath, "utf-8"))
     const saved = JSON.parse(readFileSync(litePath, "utf-8"))
-    expect(saved.agents).toBeDefined()
+    expect(saved.preset).toBe("antigravity")
+    expect(saved.presets.antigravity).toBeDefined()
     expect(saved.tmux.enabled).toBe(true)
     expect(saved.tmux.enabled).toBe(true)
   })
   })
 
 
@@ -171,7 +172,12 @@ describe("config-io", () => {
     
     
     writeFileSync(configPath, JSON.stringify({ plugin: ["oh-my-opencode-slim", "opencode-antigravity-auth"] }))
     writeFileSync(configPath, JSON.stringify({ plugin: ["oh-my-opencode-slim", "opencode-antigravity-auth"] }))
     writeFileSync(litePath, JSON.stringify({ 
     writeFileSync(litePath, JSON.stringify({ 
-      agents: { orchestrator: { model: "openai/gpt-4" } },
+      preset: "openai",
+      presets: {
+        openai: {
+          orchestrator: { model: "openai/gpt-4" }
+        }
+      },
       tmux: { enabled: true }
       tmux: { enabled: true }
     }))
     }))
     
     

+ 3 - 1
src/cli/config-io.ts

@@ -257,7 +257,9 @@ export function detectCurrentConfig(): DetectedConfig {
   const { config: liteConfig } = parseConfig(getLiteConfig())
   const { config: liteConfig } = parseConfig(getLiteConfig())
   if (liteConfig && typeof liteConfig === "object") {
   if (liteConfig && typeof liteConfig === "object") {
     const configObj = liteConfig as Record<string, any>
     const configObj = liteConfig as Record<string, any>
-    const agents = configObj.agents as Record<string, { model?: string }> | undefined
+    const presetName = configObj.preset as string
+    const presets = configObj.presets as Record<string, any>
+    const agents = presets?.[presetName] as Record<string, { model?: string }> | undefined
 
 
     if (agents) {
     if (agents) {
       const models = Object.values(agents)
       const models = Object.values(agents)

+ 8 - 2
src/cli/install.ts

@@ -81,9 +81,13 @@ function handleStepResult(result: ConfigMergeResult, successMsg: string): boolea
 }
 }
 
 
 function formatConfigSummary(config: InstallConfig): string {
 function formatConfigSummary(config: InstallConfig): string {
+  const liteConfig = generateLiteConfig(config)
+  const preset = (liteConfig.preset as string) || "unknown"
+
   const lines: string[] = []
   const lines: string[] = []
   lines.push(`${BOLD}Configuration Summary${RESET}`)
   lines.push(`${BOLD}Configuration Summary${RESET}`)
   lines.push("")
   lines.push("")
+  lines.push(`  ${BOLD}Preset:${RESET} ${BLUE}${preset}${RESET}`)
   lines.push(`  ${config.hasAntigravity ? SYMBOLS.check : DIM + "○" + RESET} Antigravity`)
   lines.push(`  ${config.hasAntigravity ? SYMBOLS.check : DIM + "○" + RESET} Antigravity`)
   lines.push(`  ${config.hasOpenAI ? SYMBOLS.check : DIM + "○" + RESET} OpenAI`)
   lines.push(`  ${config.hasOpenAI ? SYMBOLS.check : DIM + "○" + RESET} OpenAI`)
   lines.push(`  ${SYMBOLS.check} Opencode Zen (free models)`) // Always enabled
   lines.push(`  ${SYMBOLS.check} Opencode Zen (free models)`) // Always enabled
@@ -93,11 +97,13 @@ function formatConfigSummary(config: InstallConfig): string {
 
 
 function printAgentModels(config: InstallConfig): void {
 function printAgentModels(config: InstallConfig): void {
   const liteConfig = generateLiteConfig(config)
   const liteConfig = generateLiteConfig(config)
-  const agents = liteConfig.agents as Record<string, { model: string; skills: string[] }>
+  const presetName = (liteConfig.preset as string) || "unknown"
+  const presets = liteConfig.presets as Record<string, any>
+  const agents = presets?.[presetName] as Record<string, { model: string; skills: string[] }>
 
 
   if (!agents || Object.keys(agents).length === 0) return
   if (!agents || Object.keys(agents).length === 0) return
 
 
-  console.log(`${BOLD}Agent Configuration:${RESET}`)
+  console.log(`${BOLD}Agent Configuration (Preset: ${BLUE}${presetName}${RESET}):${RESET}`)
   console.log()
   console.log()
 
 
   const maxAgentLen = Math.max(...Object.keys(agents).map((a) => a.length))
   const maxAgentLen = Math.max(...Object.keys(agents).map((a) => a.length))

+ 37 - 14
src/cli/providers.test.ts

@@ -12,13 +12,15 @@ describe("providers", () => {
       hasTmux: false,
       hasTmux: false,
     })
     })
 
 
-    expect(config.agents).toBeDefined()
-    const agents = config.agents as any
-    expect(agents.orchestrator.model).toBe(MODEL_MAPPINGS.antigravity.orchestrator)
-    expect(agents.fixer.model).toBe(MODEL_MAPPINGS.antigravity.fixer)
+    expect(config.preset).toBe("antigravity")
+    const agents = (config.presets as any).antigravity
+    expect(agents.orchestrator.model).toBe(MODEL_MAPPINGS.antigravity.orchestrator.model)
+    expect(agents.orchestrator.variant).toBeUndefined()
+    expect(agents.fixer.model).toBe(MODEL_MAPPINGS.antigravity.fixer.model)
+    expect(agents.fixer.variant).toBe(MODEL_MAPPINGS.antigravity.fixer.variant)
   })
   })
 
 
-  test("generateLiteConfig overrides oracle with openai if available and antigravity is used", () => {
+  test("generateLiteConfig always includes antigravity-openai preset", () => {
     const config = generateLiteConfig({
     const config = generateLiteConfig({
       hasAntigravity: true,
       hasAntigravity: true,
       hasOpenAI: true,
       hasOpenAI: true,
@@ -26,9 +28,26 @@ describe("providers", () => {
       hasTmux: false,
       hasTmux: false,
     })
     })
 
 
-    const agents = config.agents as any
-    expect(agents.orchestrator.model).toBe(MODEL_MAPPINGS.antigravity.orchestrator)
-    expect(agents.oracle.model).toBe(MODEL_MAPPINGS.openai.oracle)
+    expect(config.preset).toBe("antigravity-openai")
+    const agents = (config.presets as any)["antigravity-openai"]
+    expect(agents.orchestrator.model).toBe(MODEL_MAPPINGS.antigravity.orchestrator.model)
+    expect(agents.orchestrator.variant).toBeUndefined()
+    expect(agents.oracle.model).toBe("openai/gpt-5.2-codex")
+    expect(agents.oracle.variant).toBe("high")
+  })
+
+  test("generateLiteConfig includes antigravity-openai preset even with only antigravity", () => {
+    const config = generateLiteConfig({
+      hasAntigravity: true,
+      hasOpenAI: false,
+      hasOpencodeZen: false,
+      hasTmux: false,
+    })
+
+    expect(config.preset).toBe("antigravity")
+    const agents = (config.presets as any)["antigravity-openai"]
+    expect(agents).toBeDefined()
+    expect(agents.oracle.model).toBe("openai/gpt-5.2-codex")
   })
   })
 
 
   test("generateLiteConfig uses openai if no antigravity", () => {
   test("generateLiteConfig uses openai if no antigravity", () => {
@@ -39,11 +58,13 @@ describe("providers", () => {
       hasTmux: false,
       hasTmux: false,
     })
     })
 
 
-    const agents = config.agents as any
-    expect(agents.orchestrator.model).toBe(MODEL_MAPPINGS.openai.orchestrator)
+    expect(config.preset).toBe("openai")
+    const agents = (config.presets as any).openai
+    expect(agents.orchestrator.model).toBe(MODEL_MAPPINGS.openai.orchestrator.model)
+    expect(agents.orchestrator.variant).toBeUndefined()
   })
   })
 
 
-  test("generateLiteConfig uses opencode zen if no antigravity or openai", () => {
+  test("generateLiteConfig uses zen-free if no antigravity or openai", () => {
     const config = generateLiteConfig({
     const config = generateLiteConfig({
       hasAntigravity: false,
       hasAntigravity: false,
       hasOpenAI: false,
       hasOpenAI: false,
@@ -51,8 +72,10 @@ describe("providers", () => {
       hasTmux: false,
       hasTmux: false,
     })
     })
 
 
-    const agents = config.agents as any
-    expect(agents.orchestrator.model).toBe(MODEL_MAPPINGS.opencode.orchestrator)
+    expect(config.preset).toBe("zen-free")
+    const agents = (config.presets as any)["zen-free"]
+    expect(agents.orchestrator.model).toBe(MODEL_MAPPINGS["zen-free"].orchestrator.model)
+    expect(agents.orchestrator.variant).toBeUndefined()
   })
   })
 
 
   test("generateLiteConfig enables tmux when requested", () => {
   test("generateLiteConfig enables tmux when requested", () => {
@@ -75,7 +98,7 @@ describe("providers", () => {
       hasTmux: false,
       hasTmux: false,
     })
     })
 
 
-    const agents = config.agents as any
+    const agents = (config.presets as any).antigravity
     expect(agents.orchestrator.skills).toContain("*")
     expect(agents.orchestrator.skills).toContain("*")
     expect(agents.fixer.skills).toBeDefined()
     expect(agents.fixer.skills).toBeDefined()
   })
   })

+ 54 - 36
src/cli/providers.ts

@@ -40,59 +40,77 @@ export const GOOGLE_PROVIDER_CONFIG = {
 // Model mappings by provider priority
 // Model mappings by provider priority
 export const MODEL_MAPPINGS = {
 export const MODEL_MAPPINGS = {
   antigravity: {
   antigravity: {
-    orchestrator: "google/claude-opus-4-5-thinking",
-    oracle: "google/claude-opus-4-5-thinking",
-    librarian: "google/gemini-3-flash",
-    explorer: "google/gemini-3-flash",
-    designer: "google/gemini-3-flash",
-    fixer: "google/gemini-3-flash",
+    orchestrator: { model: "google/claude-opus-4-5-thinking" },
+    oracle: { model: "google/claude-opus-4-5-thinking", variant: "high" },
+    librarian: { model: "google/gemini-3-flash", variant: "low" },
+    explorer: { model: "google/gemini-3-flash", variant: "low" },
+    designer: { model: "google/gemini-3-flash", variant: "medium" },
+    fixer: { model: "google/gemini-3-flash", variant: "low" },
   },
   },
   openai: {
   openai: {
-    orchestrator: "openai/gpt-5.2-codex",
-    oracle: "openai/gpt-5.2-codex",
-    librarian: "openai/gpt-5.1-codex-mini",
-    explorer: "openai/gpt-5.1-codex-mini",
-    designer: "openai/gpt-5.1-codex-mini",
-    fixer: "openai/gpt-5.1-codex-mini",
+    orchestrator: { model: "openai/gpt-5.2-codex" },
+    oracle: { model: "openai/gpt-5.2-codex", variant: "high" },
+    librarian: { model: "openai/gpt-5.1-codex-mini", variant: "low" },
+    explorer: { model: "openai/gpt-5.1-codex-mini", variant: "low" },
+    designer: { model: "openai/gpt-5.1-codex-mini", variant: "medium" },
+    fixer: { model: "openai/gpt-5.1-codex-mini", variant: "low" },
   },
   },
-  opencode: {
-    orchestrator: "opencode/glm-4.7-free",
-    oracle: "opencode/glm-4.7-free",
-    librarian: "opencode/glm-4.7-free",
-    explorer: "opencode/glm-4.7-free",
-    designer: "opencode/glm-4.7-free",
-    fixer: "opencode/glm-4.7-free",
+  "zen-free": {
+    orchestrator: { model: "opencode/glm-4.7-free" },
+    oracle: { model: "opencode/glm-4.7-free", variant: "high" },
+    librarian: { model: "opencode/grok-code", variant: "low" },
+    explorer: { model: "opencode/grok-code", variant: "low" },
+    designer: { model: "opencode/grok-code", variant: "medium" },
+    fixer: { model: "opencode/grok-code", variant: "low" },
   },
   },
 } as const;
 } as const;
 
 
 export function generateLiteConfig(installConfig: InstallConfig): Record<string, unknown> {
 export function generateLiteConfig(installConfig: InstallConfig): Record<string, unknown> {
-  // Priority: antigravity > openai > opencode (Zen free models)
+  // Determine base provider
   const baseProvider = installConfig.hasAntigravity
   const baseProvider = installConfig.hasAntigravity
     ? "antigravity"
     ? "antigravity"
     : installConfig.hasOpenAI
     : installConfig.hasOpenAI
       ? "openai"
       ? "openai"
-      : installConfig.hasOpencodeZen
-        ? "opencode"
-        : "opencode"; // Default to Zen free models
+      : "zen-free";
 
 
-  const config: Record<string, unknown> = { agents: {} };
+  const config: Record<string, unknown> = {
+    preset: baseProvider,
+    presets: {},
+  };
 
 
-  if (baseProvider) {
-    // Start with base provider models and include default skills
-    const agents: Record<string, { model: string; skills: string[] }> = Object.fromEntries(
-      Object.entries(MODEL_MAPPINGS[baseProvider]).map(([k, v]) => [
+  // Generate all presets
+  for (const [providerName, models] of Object.entries(MODEL_MAPPINGS)) {
+    const agents: Record<string, { model: string; variant?: string; skills: string[] }> = Object.fromEntries(
+      Object.entries(models).map(([k, v]) => [
         k,
         k,
-        { model: v, skills: DEFAULT_AGENT_SKILLS[k as keyof typeof DEFAULT_AGENT_SKILLS] ?? [] },
+        {
+          model: v.model,
+          variant: v.variant,
+          skills: DEFAULT_AGENT_SKILLS[k as keyof typeof DEFAULT_AGENT_SKILLS] ?? [],
+        },
       ])
       ])
     );
     );
+    (config.presets as Record<string, unknown>)[providerName] = agents;
+  }
+
+  // Always add antigravity-openai preset
+  const mixedAgents: Record<string, { model: string; variant?: string }> = { ...MODEL_MAPPINGS.antigravity };
+  mixedAgents.oracle = { model: "openai/gpt-5.2-codex", variant: "high" };
+  const agents: Record<string, { model: string; variant?: string; skills: string[] }> = Object.fromEntries(
+    Object.entries(mixedAgents).map(([k, v]) => [
+      k,
+      {
+        model: v.model,
+        variant: v.variant,
+        skills: DEFAULT_AGENT_SKILLS[k as keyof typeof DEFAULT_AGENT_SKILLS] ?? [],
+      },
+    ])
+  );
+  (config.presets as Record<string, unknown>)["antigravity-openai"] = agents;
 
 
-    // Apply provider-specific overrides for mixed configurations
-    if (installConfig.hasAntigravity) {
-      if (installConfig.hasOpenAI) {
-        agents["oracle"] = { model: "openai/gpt-5.2-codex", skills: DEFAULT_AGENT_SKILLS["oracle"] ?? [] };
-      }
-    }
-    config.agents = agents;
+  // Set default preset based on user choice
+  if (installConfig.hasAntigravity && installConfig.hasOpenAI) {
+    config.preset = "antigravity-openai";
   }
   }
 
 
   if (installConfig.hasTmux) {
   if (installConfig.hasTmux) {

+ 305 - 0
src/config/loader.test.ts

@@ -259,3 +259,308 @@ describe("deepMerge behavior", () => {
     expect(config.agents?.oracle?.model).toBe("user/model")
     expect(config.agents?.oracle?.model).toBe("user/model")
   })
   })
 })
 })
+
+describe("preset resolution", () => {
+  let tempDir: string
+  let originalEnv: typeof process.env
+
+  beforeEach(() => {
+    tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "preset-test-"))
+    originalEnv = { ...process.env }
+    process.env.XDG_CONFIG_HOME = path.join(tempDir, "user-config")
+  })
+
+  afterEach(() => {
+    fs.rmSync(tempDir, { recursive: true, force: true })
+    process.env = originalEnv
+  })
+
+  test("backward compatibility: config with only agents works unchanged", () => {
+    const projectDir = path.join(tempDir, "project")
+    const projectConfigDir = path.join(projectDir, ".opencode")
+    fs.mkdirSync(projectConfigDir, { recursive: true })
+    fs.writeFileSync(
+      path.join(projectConfigDir, "oh-my-opencode-slim.json"),
+      JSON.stringify({
+        agents: { oracle: { model: "direct-model" } }
+      })
+    )
+
+    const config = loadPluginConfig(projectDir)
+    expect(config.agents?.oracle?.model).toBe("direct-model")
+    expect(config.preset).toBeUndefined()
+  })
+
+  test("preset applied: preset + presets returns preset's agents", () => {
+    const projectDir = path.join(tempDir, "project")
+    const projectConfigDir = path.join(projectDir, ".opencode")
+    fs.mkdirSync(projectConfigDir, { recursive: true })
+    fs.writeFileSync(
+      path.join(projectConfigDir, "oh-my-opencode-slim.json"),
+      JSON.stringify({
+        preset: "fast",
+        presets: {
+          fast: { oracle: { model: "fast-model" } }
+        }
+      })
+    )
+
+    const config = loadPluginConfig(projectDir)
+    expect(config.agents?.oracle?.model).toBe("fast-model")
+  })
+
+  test("root agents override preset agents", () => {
+    const projectDir = path.join(tempDir, "project")
+    const projectConfigDir = path.join(projectDir, ".opencode")
+    fs.mkdirSync(projectConfigDir, { recursive: true })
+    fs.writeFileSync(
+      path.join(projectConfigDir, "oh-my-opencode-slim.json"),
+      JSON.stringify({
+        preset: "fast",
+        presets: {
+          fast: { 
+            oracle: { model: "fast-model", temperature: 0.1 },
+            explorer: { model: "explorer-model" }
+          }
+        },
+        agents: {
+          oracle: { temperature: 0.9 } // Should override preset temperature
+        }
+      })
+    )
+
+    const config = loadPluginConfig(projectDir)
+    expect(config.agents?.oracle?.model).toBe("fast-model")
+    expect(config.agents?.oracle?.temperature).toBe(0.9)
+    expect(config.agents?.explorer?.model).toBe("explorer-model")
+  })
+
+  test("missing preset: preset set but not in presets -> returns empty/root agents", () => {
+    const projectDir = path.join(tempDir, "project")
+    const projectConfigDir = path.join(projectDir, ".opencode")
+    fs.mkdirSync(projectConfigDir, { recursive: true })
+    fs.writeFileSync(
+      path.join(projectConfigDir, "oh-my-opencode-slim.json"),
+      JSON.stringify({
+        preset: "nonexistent",
+        presets: {
+          other: { oracle: { model: "other" } }
+        },
+        agents: { oracle: { model: "root" } }
+      })
+    )
+
+    const config = loadPluginConfig(projectDir)
+    expect(config.agents?.oracle?.model).toBe("root")
+  })
+
+  test("preset only: no root agents, just preset works", () => {
+    const projectDir = path.join(tempDir, "project")
+    const projectConfigDir = path.join(projectDir, ".opencode")
+    fs.mkdirSync(projectConfigDir, { recursive: true })
+    fs.writeFileSync(
+      path.join(projectConfigDir, "oh-my-opencode-slim.json"),
+      JSON.stringify({
+        preset: "dev",
+        presets: {
+          dev: { oracle: { model: "dev-model" } }
+        }
+      })
+    )
+
+    const config = loadPluginConfig(projectDir)
+    expect(config.agents?.oracle?.model).toBe("dev-model")
+  })
+
+  test("invalid preset shape: bad agent config in preset fails schema validation", () => {
+    const projectDir = path.join(tempDir, "project")
+    const projectConfigDir = path.join(projectDir, ".opencode")
+    fs.mkdirSync(projectConfigDir, { recursive: true })
+    
+    // preset agents with invalid temperature
+    fs.writeFileSync(
+      path.join(projectConfigDir, "oh-my-opencode-slim.json"),
+      JSON.stringify({
+        preset: "invalid",
+        presets: {
+          invalid: { oracle: { temperature: 5 } }
+        }
+      })
+    )
+
+    // Should return empty config due to validation failure
+    expect(loadPluginConfig(projectDir)).toEqual({})
+  })
+
+  test("nonexistent preset from config warns and falls back to root agents", () => {
+    const projectDir = path.join(tempDir, "project")
+    const projectConfigDir = path.join(projectDir, ".opencode")
+    fs.mkdirSync(projectConfigDir, { recursive: true })
+    fs.writeFileSync(
+      path.join(projectConfigDir, "oh-my-opencode-slim.json"),
+      JSON.stringify({
+        preset: "nonexistent",
+        presets: {
+          other: { oracle: { model: "other" } }
+        },
+        agents: { oracle: { model: "root" } }
+      })
+    )
+
+    const consoleWarnSpy = spyOn(console, "warn")
+    const config = loadPluginConfig(projectDir)
+    expect(config.agents?.oracle?.model).toBe("root")
+    expect(consoleWarnSpy).toHaveBeenCalled()
+    const warningMessage = consoleWarnSpy.mock.calls[0][0] as string
+    expect(warningMessage).toContain('Preset "nonexistent" not found')
+    expect(warningMessage).toContain('Available presets: other')
+  })
+
+  test("nonexistent preset with no root agents returns empty agents", () => {
+    const projectDir = path.join(tempDir, "project")
+    const projectConfigDir = path.join(projectDir, ".opencode")
+    fs.mkdirSync(projectConfigDir, { recursive: true })
+    fs.writeFileSync(
+      path.join(projectConfigDir, "oh-my-opencode-slim.json"),
+      JSON.stringify({
+        preset: "nonexistent",
+        presets: {
+          other: { oracle: { model: "other" } }
+        }
+      })
+    )
+
+    const consoleWarnSpy = spyOn(console, "warn")
+    const config = loadPluginConfig(projectDir)
+    expect(config.agents).toBeUndefined()
+    expect(consoleWarnSpy).toHaveBeenCalled()
+    const warningMessage = consoleWarnSpy.mock.calls[0][0] as string
+    expect(warningMessage).toContain('Preset "nonexistent" not found')
+  })
+})
+
+describe("environment variable preset override", () => {
+  let tempDir: string
+  let originalEnv: typeof process.env
+
+  beforeEach(() => {
+    tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "env-preset-test-"))
+    originalEnv = { ...process.env }
+    process.env.XDG_CONFIG_HOME = path.join(tempDir, "user-config")
+  })
+
+  afterEach(() => {
+    fs.rmSync(tempDir, { recursive: true, force: true })
+    process.env = originalEnv
+  })
+
+  test("Env var overrides preset from config file", () => {
+    const projectDir = path.join(tempDir, "project")
+    const projectConfigDir = path.join(projectDir, ".opencode")
+    fs.mkdirSync(projectConfigDir, { recursive: true })
+    fs.writeFileSync(
+      path.join(projectConfigDir, "oh-my-opencode-slim.json"),
+      JSON.stringify({
+        preset: "config-preset",
+        presets: {
+          "config-preset": { oracle: { model: "config-model" } },
+          "env-preset": { oracle: { model: "env-model" } }
+        }
+      })
+    )
+
+    process.env.OH_MY_OPENCODE_SLIM_PRESET = "env-preset"
+    const config = loadPluginConfig(projectDir)
+    expect(config.preset).toBe("env-preset")
+    expect(config.agents?.oracle?.model).toBe("env-model")
+  })
+
+  test("Env var works when config has no preset", () => {
+    const projectDir = path.join(tempDir, "project")
+    const projectConfigDir = path.join(projectDir, ".opencode")
+    fs.mkdirSync(projectConfigDir, { recursive: true })
+    fs.writeFileSync(
+      path.join(projectConfigDir, "oh-my-opencode-slim.json"),
+      JSON.stringify({
+        presets: {
+          "env-preset": { oracle: { model: "env-model" } }
+        }
+      })
+    )
+
+    process.env.OH_MY_OPENCODE_SLIM_PRESET = "env-preset"
+    const config = loadPluginConfig(projectDir)
+    expect(config.preset).toBe("env-preset")
+    expect(config.agents?.oracle?.model).toBe("env-model")
+  })
+
+  test("Env var is ignored if empty string", () => {
+    const projectDir = path.join(tempDir, "project")
+    const projectConfigDir = path.join(projectDir, ".opencode")
+    fs.mkdirSync(projectConfigDir, { recursive: true })
+    fs.writeFileSync(
+      path.join(projectConfigDir, "oh-my-opencode-slim.json"),
+      JSON.stringify({
+        preset: "config-preset",
+        presets: {
+          "config-preset": { oracle: { model: "config-model" } }
+        }
+      })
+    )
+
+    process.env.OH_MY_OPENCODE_SLIM_PRESET = ""
+    const config = loadPluginConfig(projectDir)
+    expect(config.preset).toBe("config-preset")
+    expect(config.agents?.oracle?.model).toBe("config-model")
+  })
+
+  test("Env var is ignored if undefined", () => {
+    const projectDir = path.join(tempDir, "project")
+    const projectConfigDir = path.join(projectDir, ".opencode")
+    fs.mkdirSync(projectConfigDir, { recursive: true })
+    fs.writeFileSync(
+      path.join(projectConfigDir, "oh-my-opencode-slim.json"),
+      JSON.stringify({
+        preset: "config-preset",
+        presets: {
+          "config-preset": { oracle: { model: "config-model" } }
+        }
+      })
+    )
+
+    delete process.env.OH_MY_OPENCODE_SLIM_PRESET
+    const config = loadPluginConfig(projectDir)
+    expect(config.preset).toBe("config-preset")
+    expect(config.agents?.oracle?.model).toBe("config-model")
+  })
+
+  test("Env var with nonexistent preset warns and falls back", () => {
+    const projectDir = path.join(tempDir, "project")
+    const projectConfigDir = path.join(projectDir, ".opencode")
+    fs.mkdirSync(projectConfigDir, { recursive: true })
+    fs.writeFileSync(
+      path.join(projectConfigDir, "oh-my-opencode-slim.json"),
+      JSON.stringify({
+        preset: "config-preset",
+        presets: {
+          "config-preset": { oracle: { model: "config-model" } }
+        },
+        agents: { oracle: { model: "fallback" } }
+      })
+    )
+
+    process.env.OH_MY_OPENCODE_SLIM_PRESET = "typo-preset"
+    const consoleWarnSpy = spyOn(console, "warn")
+    const config = loadPluginConfig(projectDir)
+    expect(config.preset).toBe("typo-preset")
+    expect(config.agents?.oracle?.model).toBe("fallback")
+    expect(consoleWarnSpy).toHaveBeenCalled()
+    const calls = consoleWarnSpy.mock.calls as string[][]
+    const warningMessage = calls.find(call => 
+      call[0]?.includes("typo-preset")
+    )?.[0] || ""
+    expect(warningMessage).toContain('Preset "typo-preset" not found')
+    expect(warningMessage).toContain('environment variable')
+    expect(warningMessage).toContain('config-preset')
+  })
+})

+ 20 - 0
src/config/loader.ts

@@ -112,5 +112,25 @@ export function loadPluginConfig(directory: string): PluginConfig {
     };
     };
   }
   }
 
 
+  // Override preset from environment variable if set
+  const envPreset = process.env.OH_MY_OPENCODE_SLIM_PRESET;
+  if (envPreset) {
+    config.preset = envPreset;
+  }
+
+  // Resolve preset and merge with root agents
+  if (config.preset) {
+    const preset = config.presets?.[config.preset];
+    if (preset) {
+      // Merge preset agents with root agents (root overrides)
+      config.agents = deepMerge(preset, config.agents);
+    } else {
+      // Preset name specified but doesn't exist - warn user
+      const presetSource = envPreset === config.preset ? "environment variable" : "config file";
+      const availablePresets = config.presets ? Object.keys(config.presets).join(", ") : "none";
+      console.warn(`[oh-my-opencode-slim] Preset "${config.preset}" not found (from ${presetSource}). Available presets: ${availablePresets}`);
+    }
+  }
+
   return config;
   return config;
 }
 }

+ 6 - 0
src/config/schema.ts

@@ -30,12 +30,18 @@ export type TmuxConfig = z.infer<typeof TmuxConfigSchema>;
 
 
 export type AgentOverrideConfig = z.infer<typeof AgentOverrideConfigSchema>;
 export type AgentOverrideConfig = z.infer<typeof AgentOverrideConfigSchema>;
 
 
+export const PresetSchema = z.record(z.string(), AgentOverrideConfigSchema);
+
+export type Preset = z.infer<typeof PresetSchema>;
+
 // MCP names
 // MCP names
 export const McpNameSchema = z.enum(["websearch", "context7", "grep_app"]);
 export const McpNameSchema = z.enum(["websearch", "context7", "grep_app"]);
 export type McpName = z.infer<typeof McpNameSchema>;
 export type McpName = z.infer<typeof McpNameSchema>;
 
 
 // Main plugin config
 // Main plugin config
 export const PluginConfigSchema = z.object({
 export const PluginConfigSchema = z.object({
+  preset: z.string().optional(),
+  presets: z.record(z.string(), PresetSchema).optional(),
   agents: z.record(z.string(), AgentOverrideConfigSchema).optional(),
   agents: z.record(z.string(), AgentOverrideConfigSchema).optional(),
   disabled_mcps: z.array(z.string()).optional(),
   disabled_mcps: z.array(z.string()).optional(),
   tmux: TmuxConfigSchema.optional(),
   tmux: TmuxConfigSchema.optional(),