Alvin 2 months ago
parent
commit
136f6341fc

+ 63 - 43
README.md

@@ -53,6 +53,8 @@
 - [⚙️ **Configuration**](#configuration)
   - [Files You Edit](#files-you-edit)
   - [Plugin Config](#plugin-config-oh-my-opencode-slimjson)
+    - [Presets](#presets)
+    - [Option Reference](#option-reference)
 - [🗑️ **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.
 
+### Presets
+
+The installer generates presets for different provider combinations. Switch between them by changing the `preset` field.
+
 <details open>
 <summary><b>Example: Antigravity + OpenAI (Recommended)</b></summary>
 
 ```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": {
     "enabled": true,
@@ -484,55 +517,42 @@ The installer generates this file based on your providers. You can manually cust
 ```
 </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 | 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.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) |
 | `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)
     
     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)
   })
 
@@ -171,7 +172,12 @@ describe("config-io", () => {
     
     writeFileSync(configPath, JSON.stringify({ plugin: ["oh-my-opencode-slim", "opencode-antigravity-auth"] }))
     writeFileSync(litePath, JSON.stringify({ 
-      agents: { orchestrator: { model: "openai/gpt-4" } },
+      preset: "openai",
+      presets: {
+        openai: {
+          orchestrator: { model: "openai/gpt-4" }
+        }
+      },
       tmux: { enabled: true }
     }))
     

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

@@ -257,7 +257,9 @@ export function detectCurrentConfig(): DetectedConfig {
   const { config: liteConfig } = parseConfig(getLiteConfig())
   if (liteConfig && typeof liteConfig === "object") {
     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) {
       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 {
+  const liteConfig = generateLiteConfig(config)
+  const preset = (liteConfig.preset as string) || "unknown"
+
   const lines: string[] = []
   lines.push(`${BOLD}Configuration Summary${RESET}`)
   lines.push("")
+  lines.push(`  ${BOLD}Preset:${RESET} ${BLUE}${preset}${RESET}`)
   lines.push(`  ${config.hasAntigravity ? SYMBOLS.check : DIM + "○" + RESET} Antigravity`)
   lines.push(`  ${config.hasOpenAI ? SYMBOLS.check : DIM + "○" + RESET} OpenAI`)
   lines.push(`  ${SYMBOLS.check} Opencode Zen (free models)`) // Always enabled
@@ -93,11 +97,13 @@ function formatConfigSummary(config: InstallConfig): string {
 
 function printAgentModels(config: InstallConfig): void {
   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
 
-  console.log(`${BOLD}Agent Configuration:${RESET}`)
+  console.log(`${BOLD}Agent Configuration (Preset: ${BLUE}${presetName}${RESET}):${RESET}`)
   console.log()
 
   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,
     })
 
-    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({
       hasAntigravity: true,
       hasOpenAI: true,
@@ -26,9 +28,26 @@ describe("providers", () => {
       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", () => {
@@ -39,11 +58,13 @@ describe("providers", () => {
       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({
       hasAntigravity: false,
       hasOpenAI: false,
@@ -51,8 +72,10 @@ describe("providers", () => {
       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", () => {
@@ -75,7 +98,7 @@ describe("providers", () => {
       hasTmux: false,
     })
 
-    const agents = config.agents as any
+    const agents = (config.presets as any).antigravity
     expect(agents.orchestrator.skills).toContain("*")
     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
 export const MODEL_MAPPINGS = {
   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: {
-    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;
 
 export function generateLiteConfig(installConfig: InstallConfig): Record<string, unknown> {
-  // Priority: antigravity > openai > opencode (Zen free models)
+  // Determine base provider
   const baseProvider = installConfig.hasAntigravity
     ? "antigravity"
     : installConfig.hasOpenAI
       ? "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,
-        { 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) {

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

@@ -259,3 +259,308 @@ describe("deepMerge behavior", () => {
     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;
 }

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