Browse Source

Clean install (#69)

* Clean cli

* Rename features to background

* Fix test

* Update src/cli/providers.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Update src/cli/system.test.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Alvin 2 months ago
parent
commit
b67ce581dd

src/features/background-manager.test.ts → src/background/background-manager.test.ts


src/features/background-manager.ts → src/background/background-manager.ts


src/features/index.ts → src/background/index.ts


src/features/tmux-session-manager.test.ts → src/background/tmux-session-manager.test.ts


src/features/tmux-session-manager.ts → src/background/tmux-session-manager.ts


+ 184 - 0
src/cli/config-io.test.ts

@@ -0,0 +1,184 @@
+/// <reference types="bun-types" />
+
+import { describe, expect, test, afterEach, beforeEach, mock } from "bun:test"
+import {
+  stripJsonComments,
+  parseConfigFile,
+  parseConfig,
+  writeConfig,
+  addPluginToOpenCodeConfig,
+  addAuthPlugins,
+  addProviderConfig,
+  writeLiteConfig,
+  disableDefaultAgents,
+  detectCurrentConfig,
+} from "./config-io"
+import { join } from "node:path"
+import { existsSync, rmSync, mkdtempSync, writeFileSync, readFileSync } from "node:fs"
+import { tmpdir } from "node:os"
+import * as paths from "./paths"
+import * as system from "./system"
+
+describe("config-io", () => {
+  let tmpDir: string
+  const originalEnv = { ...process.env }
+
+  beforeEach(() => {
+    tmpDir = mkdtempSync(join(tmpdir(), "opencode-io-test-"))
+    process.env.XDG_CONFIG_HOME = tmpDir
+  })
+
+  afterEach(() => {
+    process.env = { ...originalEnv }
+    if (tmpDir && existsSync(tmpDir)) {
+      rmSync(tmpDir, { recursive: true, force: true })
+    }
+    mock.restore()
+  })
+
+  test("stripJsonComments strips comments and trailing commas", () => {
+    const jsonc = `{
+      // comment
+      "a": 1, /* multi
+      line */
+      "b": [2,],
+    }`
+    const stripped = stripJsonComments(jsonc)
+    expect(JSON.parse(stripped)).toEqual({ a: 1, b: [2] })
+  })
+
+  test("parseConfigFile parses valid JSON", () => {
+    const path = join(tmpDir, "test.json")
+    writeFileSync(path, '{"a": 1}')
+    const result = parseConfigFile(path)
+    expect(result.config).toEqual({ a: 1 } as any)
+    expect(result.error).toBeUndefined()
+  })
+
+  test("parseConfigFile returns null for non-existent file", () => {
+    const result = parseConfigFile(join(tmpDir, "nonexistent.json"))
+    expect(result.config).toBeNull()
+  })
+
+  test("parseConfigFile returns null for empty or whitespace-only file", () => {
+    const emptyPath = join(tmpDir, "empty.json")
+    writeFileSync(emptyPath, "")
+    expect(parseConfigFile(emptyPath).config).toBeNull()
+    
+    const whitespacePath = join(tmpDir, "whitespace.json")
+    writeFileSync(whitespacePath, "   \n  ")
+    expect(parseConfigFile(whitespacePath).config).toBeNull()
+  })
+
+  test("parseConfigFile returns error for invalid JSON", () => {
+    const path = join(tmpDir, "invalid.json")
+    writeFileSync(path, '{"a": 1')
+    const result = parseConfigFile(path)
+    expect(result.config).toBeNull()
+    expect(result.error).toBeDefined()
+  })
+
+  test("parseConfig tries .jsonc if .json is missing", () => {
+    const jsoncPath = join(tmpDir, "test.jsonc")
+    writeFileSync(jsoncPath, '{"a": 1}')
+    
+    // We pass .json path, it should try .jsonc
+    const result = parseConfig(join(tmpDir, "test.json"))
+    expect(result.config).toEqual({ a: 1 } as any)
+  })
+
+  test("writeConfig writes JSON and creates backup", () => {
+    const path = join(tmpDir, "test.json")
+    writeFileSync(path, '{"old": true}')
+    
+    writeConfig(path, { new: true } as any)
+    
+    expect(JSON.parse(readFileSync(path, "utf-8"))).toEqual({ new: true })
+    expect(JSON.parse(readFileSync(path + ".bak", "utf-8"))).toEqual({ old: true })
+  })
+
+  test("addPluginToOpenCodeConfig adds plugin and removes duplicates", async () => {
+    const configPath = join(tmpDir, "opencode", "opencode.json")
+    paths.ensureConfigDir()
+    writeFileSync(configPath, JSON.stringify({ plugin: ["other", "oh-my-opencode-slim@1.0.0"] }))
+    
+    const result = await addPluginToOpenCodeConfig()
+    expect(result.success).toBe(true)
+    
+    const saved = JSON.parse(readFileSync(configPath, "utf-8"))
+    expect(saved.plugin).toContain("oh-my-opencode-slim")
+    expect(saved.plugin).not.toContain("oh-my-opencode-slim@1.0.0")
+    expect(saved.plugin.length).toBe(2)
+  })
+
+  test("addAuthPlugins adds antigravity auth plugin", async () => {
+    const configPath = join(tmpDir, "opencode", "opencode.json")
+    paths.ensureConfigDir()
+    writeFileSync(configPath, JSON.stringify({}))
+    
+    mock.module("./system", () => ({
+      fetchLatestVersion: async () => "1.2.3"
+    }))
+
+    const result = await addAuthPlugins({ hasAntigravity: true, hasOpenAI: false, hasOpencodeZen: false, hasTmux: false })
+    expect(result.success).toBe(true)
+    
+    const saved = JSON.parse(readFileSync(configPath, "utf-8"))
+    expect(saved.plugin).toContain("opencode-antigravity-auth@1.2.3")
+  })
+
+  test("addProviderConfig adds google provider config", () => {
+    const configPath = join(tmpDir, "opencode", "opencode.json")
+    paths.ensureConfigDir()
+    writeFileSync(configPath, JSON.stringify({}))
+    
+    const result = addProviderConfig({ hasAntigravity: true, hasOpenAI: false, hasOpencodeZen: false, hasTmux: false })
+    expect(result.success).toBe(true)
+    
+    const saved = JSON.parse(readFileSync(configPath, "utf-8"))
+    expect(saved.provider.google).toBeDefined()
+  })
+
+  test("writeLiteConfig writes lite config", () => {
+    const litePath = join(tmpDir, "opencode", "oh-my-opencode-slim.json")
+    paths.ensureConfigDir()
+    
+    const result = writeLiteConfig({ hasAntigravity: true, hasOpenAI: false, hasOpencodeZen: false, hasTmux: true })
+    expect(result.success).toBe(true)
+    
+    const saved = JSON.parse(readFileSync(litePath, "utf-8"))
+    expect(saved.agents).toBeDefined()
+    expect(saved.tmux.enabled).toBe(true)
+  })
+
+  test("disableDefaultAgents disables explore and general agents", () => {
+    const configPath = join(tmpDir, "opencode", "opencode.json")
+    paths.ensureConfigDir()
+    writeFileSync(configPath, JSON.stringify({}))
+    
+    const result = disableDefaultAgents()
+    expect(result.success).toBe(true)
+    
+    const saved = JSON.parse(readFileSync(configPath, "utf-8"))
+    expect(saved.agent.explore.disable).toBe(true)
+    expect(saved.agent.general.disable).toBe(true)
+  })
+
+  test("detectCurrentConfig detects installed status", () => {
+    const configPath = join(tmpDir, "opencode", "opencode.json")
+    const litePath = join(tmpDir, "opencode", "oh-my-opencode-slim.json")
+    paths.ensureConfigDir()
+    
+    writeFileSync(configPath, JSON.stringify({ plugin: ["oh-my-opencode-slim", "opencode-antigravity-auth"] }))
+    writeFileSync(litePath, JSON.stringify({ 
+      agents: { orchestrator: { model: "openai/gpt-4" } },
+      tmux: { enabled: true }
+    }))
+    
+    const detected = detectCurrentConfig()
+    expect(detected.isInstalled).toBe(true)
+    expect(detected.hasAntigravity).toBe(true)
+    expect(detected.hasOpenAI).toBe(true)
+    expect(detected.hasTmux).toBe(true)
+  })
+})

+ 276 - 0
src/cli/config-io.ts

@@ -0,0 +1,276 @@
+import { existsSync, readFileSync, writeFileSync, statSync, renameSync, copyFileSync } from "node:fs"
+import type { ConfigMergeResult, DetectedConfig, InstallConfig, OpenCodeConfig } from "./types"
+import {
+  getConfigDir,
+  getExistingConfigPath,
+  ensureConfigDir,
+  getLiteConfig,
+} from "./paths"
+import {
+  GOOGLE_PROVIDER_CONFIG,
+  generateLiteConfig,
+} from "./providers"
+import { fetchLatestVersion } from "./system"
+
+const PACKAGE_NAME = "oh-my-opencode-slim"
+
+/**
+ * Strip JSON comments (single-line // and multi-line) and trailing commas for JSONC support.
+ */
+export function stripJsonComments(json: string): string {
+  const commentPattern = /\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g
+  const trailingCommaPattern = /\\"|"(?:\\"|[^"])*"|(,)(\s*[}\]])/g
+
+  return json
+    .replace(commentPattern, (match, commentGroup) => (commentGroup ? "" : match))
+    .replace(trailingCommaPattern, (match, comma, closing) =>
+      comma ? closing : match
+    )
+}
+
+export function parseConfigFile(path: string): { config: OpenCodeConfig | null; error?: string } {
+  try {
+    if (!existsSync(path)) return { config: null }
+    const stat = statSync(path)
+    if (stat.size === 0) return { config: null }
+    const content = readFileSync(path, "utf-8")
+    if (content.trim().length === 0) return { config: null }
+    return { config: JSON.parse(stripJsonComments(content)) as OpenCodeConfig }
+  } catch (err) {
+    return { config: null, error: String(err) }
+  }
+}
+
+export function parseConfig(path: string): { config: OpenCodeConfig | null; error?: string } {
+  let result = parseConfigFile(path)
+  if (result.config || result.error) return result
+
+  if (path.endsWith(".json")) {
+    const jsoncPath = path.replace(/\.json$/, ".jsonc")
+    return parseConfigFile(jsoncPath)
+  }
+  return { config: null }
+}
+
+/**
+ * Write config to file atomically.
+ */
+export function writeConfig(configPath: string, config: OpenCodeConfig): void {
+  if (configPath.endsWith(".jsonc")) {
+    console.warn(
+      "[config-manager] Writing to .jsonc file - comments will not be preserved"
+    )
+  }
+
+  const tmpPath = `${configPath}.tmp`
+  const bakPath = `${configPath}.bak`
+  const content = JSON.stringify(config, null, 2) + "\n"
+
+  // Backup existing config if it exists
+  if (existsSync(configPath)) {
+    copyFileSync(configPath, bakPath)
+  }
+
+  // Atomic write pattern: write to tmp, then rename
+  writeFileSync(tmpPath, content)
+  renameSync(tmpPath, configPath)
+}
+
+export async function addPluginToOpenCodeConfig(): Promise<ConfigMergeResult> {
+  try {
+    ensureConfigDir()
+  } catch (err) {
+    return {
+      success: false,
+      configPath: getConfigDir(),
+      error: `Failed to create config directory: ${err}`,
+    }
+  }
+
+  const configPath = getExistingConfigPath()
+
+  try {
+    const { config: parsedConfig, error } = parseConfig(configPath)
+    if (error) {
+       return { success: false, configPath, error: `Failed to parse config: ${error}` }
+    }
+    const config = parsedConfig ?? {}
+    const plugins = config.plugin ?? []
+
+    // Remove existing oh-my-opencode-slim entries
+    const filteredPlugins = plugins.filter(
+      (p) => p !== PACKAGE_NAME && !p.startsWith(`${PACKAGE_NAME}@`)
+    )
+
+    // Add fresh entry
+    filteredPlugins.push(PACKAGE_NAME)
+    config.plugin = filteredPlugins
+
+    writeConfig(configPath, config)
+    return { success: true, configPath }
+  } catch (err) {
+    return {
+      success: false,
+      configPath,
+      error: `Failed to update opencode config: ${err}`,
+    }
+  }
+}
+
+export async function addAuthPlugins(installConfig: InstallConfig): Promise<ConfigMergeResult> {
+  const configPath = getExistingConfigPath()
+
+  try {
+    ensureConfigDir()
+    const { config: parsedConfig, error } = parseConfig(configPath)
+    if (error) {
+       return { success: false, configPath, error: `Failed to parse config: ${error}` }
+    }
+    const config = parsedConfig ?? {}
+    const plugins = config.plugin ?? []
+
+    if (installConfig.hasAntigravity) {
+      const version = await fetchLatestVersion("opencode-antigravity-auth")
+      const pluginEntry = version
+        ? `opencode-antigravity-auth@${version}`
+        : "opencode-antigravity-auth@latest"
+
+      if (!plugins.some((p) => p.startsWith("opencode-antigravity-auth"))) {
+        plugins.push(pluginEntry)
+      }
+    }
+
+    config.plugin = plugins
+    writeConfig(configPath, config)
+    return { success: true, configPath }
+  } catch (err) {
+    return {
+      success: false,
+      configPath,
+      error: `Failed to add auth plugins: ${err}`,
+    }
+  }
+}
+
+export function addProviderConfig(installConfig: InstallConfig): ConfigMergeResult {
+  const configPath = getExistingConfigPath()
+
+  try {
+    ensureConfigDir()
+    const { config: parsedConfig, error } = parseConfig(configPath)
+    if (error) {
+       return { success: false, configPath, error: `Failed to parse config: ${error}` }
+    }
+    const config = parsedConfig ?? {}
+
+    if (installConfig.hasAntigravity) {
+      const providers = (config.provider ?? {}) as Record<string, unknown>
+      providers.google = GOOGLE_PROVIDER_CONFIG.google
+      config.provider = providers
+    }
+
+    writeConfig(configPath, config)
+    return { success: true, configPath }
+  } catch (err) {
+    return {
+      success: false,
+      configPath,
+      error: `Failed to add provider config: ${err}`,
+    }
+  }
+}
+
+export function writeLiteConfig(installConfig: InstallConfig): ConfigMergeResult {
+  const configPath = getLiteConfig()
+
+  try {
+    ensureConfigDir()
+    const config = generateLiteConfig(installConfig)
+    
+    // Atomic write for lite config too
+    const tmpPath = `${configPath}.tmp`
+    const bakPath = `${configPath}.bak`
+    const content = JSON.stringify(config, null, 2) + "\n"
+
+    // Backup existing config if it exists
+    if (existsSync(configPath)) {
+      copyFileSync(configPath, bakPath)
+    }
+
+    writeFileSync(tmpPath, content)
+    renameSync(tmpPath, configPath)
+    
+    return { success: true, configPath }
+  } catch (err) {
+    return {
+      success: false,
+      configPath,
+      error: `Failed to write lite config: ${err}`,
+    }
+  }
+}
+
+export function disableDefaultAgents(): ConfigMergeResult {
+  const configPath = getExistingConfigPath()
+
+  try {
+    ensureConfigDir()
+    const { config: parsedConfig, error } = parseConfig(configPath)
+    if (error) {
+       return { success: false, configPath, error: `Failed to parse config: ${error}` }
+    }
+    const config = parsedConfig ?? {}
+
+    const agent = (config.agent ?? {}) as Record<string, unknown>
+    agent.explore = { disable: true }
+    agent.general = { disable: true }
+    config.agent = agent
+
+    writeConfig(configPath, config)
+    return { success: true, configPath }
+  } catch (err) {
+    return {
+      success: false,
+      configPath,
+      error: `Failed to disable default agents: ${err}`,
+    }
+  }
+}
+
+export function detectCurrentConfig(): DetectedConfig {
+  const result: DetectedConfig = {
+    isInstalled: false,
+    hasAntigravity: false,
+    hasOpenAI: false,
+    hasOpencodeZen: false,
+    hasTmux: false,
+  }
+
+  const { config } = parseConfig(getExistingConfigPath())
+  if (!config) return result
+
+  const plugins = config.plugin ?? []
+  result.isInstalled = plugins.some((p) => p.startsWith(PACKAGE_NAME))
+  result.hasAntigravity = plugins.some((p) => p.startsWith("opencode-antigravity-auth"))
+
+  // Try to detect from lite config
+  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
+
+    if (agents) {
+      const models = Object.values(agents)
+        .map((a) => a?.model)
+        .filter(Boolean)
+      result.hasOpenAI = models.some((m) => m?.startsWith("openai/"))
+      result.hasOpencodeZen = models.some((m) => m?.startsWith("opencode/"))
+    }
+
+    if (configObj.tmux && typeof configObj.tmux === "object") {
+      result.hasTmux = configObj.tmux.enabled === true
+    }
+  }
+
+  return result
+}

+ 110 - 163
src/cli/config-manager.test.ts

@@ -1,190 +1,137 @@
+/// <reference types="bun-types" />
+
 import { describe, expect, test } from "bun:test"
 import { describe, expect, test } from "bun:test"
 import { stripJsonComments } from "./config-manager"
 import { stripJsonComments } from "./config-manager"
 
 
-describe("stripJsonComments", () => {
-  test("returns unchanged JSON without comments", () => {
-    const json = '{"key": "value"}'
-    expect(stripJsonComments(json)).toBe(json)
-  })
+describe("config-manager (barrel)", () => {
+  describe("stripJsonComments", () => {
+    test("returns unchanged JSON without comments", () => {
+      const json = '{"key": "value"}'
+      expect(stripJsonComments(json)).toBe(json)
+    })
 
 
-  test("strips single-line comments", () => {
-    const json = `{
-  "key": "value" // this is a comment
-}`
-    expect(JSON.parse(stripJsonComments(json))).toEqual({ key: "value" })
-  })
+    test("strips single-line comments", () => {
+      const json = `{
+    "key": "value" // this is a comment
+  }`
+      expect(JSON.parse(stripJsonComments(json))).toEqual({ key: "value" })
+    })
 
 
-  test("strips multi-line comments", () => {
-    const json = `{
-  /* this is a
-     multi-line comment */
-  "key": "value"
-}`
-    expect(JSON.parse(stripJsonComments(json))).toEqual({ key: "value" })
-  })
+    test("strips multi-line comments", () => {
+      const json = `{
+    /* this is a
+       multi-line comment */
+    "key": "value"
+  }`
+      expect(JSON.parse(stripJsonComments(json))).toEqual({ key: "value" })
+    })
 
 
-  test("strips trailing commas", () => {
-    const json = `{
-  "key": "value",
-}`
-    expect(JSON.parse(stripJsonComments(json))).toEqual({ key: "value" })
-  })
+    test("strips trailing commas", () => {
+      const json = `{
+    "key": "value",
+  }`
+      expect(JSON.parse(stripJsonComments(json))).toEqual({ key: "value" })
+    })
 
 
-  test("strips trailing commas in arrays", () => {
-    const json = `{
-  "arr": [1, 2, 3,]
-}`
-    expect(JSON.parse(stripJsonComments(json))).toEqual({ arr: [1, 2, 3] })
-  })
+    test("strips trailing commas in arrays", () => {
+      const json = `{
+    "arr": [1, 2, 3,]
+  }`
+      expect(JSON.parse(stripJsonComments(json))).toEqual({ arr: [1, 2, 3] })
+    })
 
 
-  test("preserves URLs with double slashes", () => {
-    const json = '{"url": "https://example.com"}'
-    expect(JSON.parse(stripJsonComments(json))).toEqual({ url: "https://example.com" })
-  })
+    test("preserves URLs with double slashes", () => {
+      const json = '{"url": "https://example.com"}'
+      expect(JSON.parse(stripJsonComments(json))).toEqual({ url: "https://example.com" })
+    })
 
 
-  test("preserves strings containing comment-like patterns", () => {
-    const json = '{"code": "// not a comment", "block": "/* also not */"}'
-    expect(JSON.parse(stripJsonComments(json))).toEqual({
-      code: "// not a comment",
-      block: "/* also not */",
+    test("preserves strings containing comment-like patterns", () => {
+      const json = '{"code": "// not a comment", "block": "/* also not */"}'
+      expect(JSON.parse(stripJsonComments(json))).toEqual({
+        code: "// not a comment",
+        block: "/* also not */",
+      })
     })
     })
-  })
 
 
-  test("handles complex JSONC with mixed comments and trailing commas", () => {
-    const json = `{
-  // Configuration for the plugin
-  "plugin": ["oh-my-opencode-slim"],
-  /* Provider settings
-     with multiple lines */
-  "provider": {
-    "google": {
-      "name": "Google", // inline comment
+    test("handles complex JSONC with mixed comments and trailing commas", () => {
+      const json = `{
+    // Configuration for the plugin
+    "plugin": ["oh-my-opencode-slim"],
+    /* Provider settings
+       with multiple lines */
+    "provider": {
+      "google": {
+        "name": "Google", // inline comment
+      },
     },
     },
-  },
-}`
-    const result = JSON.parse(stripJsonComments(json))
-    expect(result).toEqual({
-      plugin: ["oh-my-opencode-slim"],
-      provider: {
-        google: {
-          name: "Google",
+  }`
+      const result = JSON.parse(stripJsonComments(json))
+      expect(result).toEqual({
+        plugin: ["oh-my-opencode-slim"],
+        provider: {
+          google: {
+            name: "Google",
+          },
         },
         },
-      },
+      })
     })
     })
-  })
 
 
-  test("handles escaped quotes in strings", () => {
-    const json = '{"message": "He said \\"hello\\""}'
-    expect(JSON.parse(stripJsonComments(json))).toEqual({ message: 'He said "hello"' })
-  })
-
-  test("handles empty input", () => {
-    expect(stripJsonComments("")).toBe("")
-  })
-
-  test("handles whitespace-only input", () => {
-    expect(stripJsonComments("   ")).toBe("   ")
-  })
+    test("handles escaped quotes in strings", () => {
+      const json = '{"message": "He said \\"hello\\""}'
+      expect(JSON.parse(stripJsonComments(json))).toEqual({ message: 'He said "hello"' })
+    })
 
 
-  test("handles single-line comment at start of file", () => {
-    const json = `// comment at start
-{"key": "value"}`
-    expect(JSON.parse(stripJsonComments(json))).toEqual({ key: "value" })
-  })
+    test("handles empty input", () => {
+      expect(stripJsonComments("")).toBe("")
+    })
 
 
-  test("handles comment-only lines between properties", () => {
-    const json = `{
-  "a": 1,
-  // comment line
-  "b": 2
-}`
-    expect(JSON.parse(stripJsonComments(json))).toEqual({ a: 1, b: 2 })
-  })
+    test("handles whitespace-only input", () => {
+      expect(stripJsonComments("   ")).toBe("   ")
+    })
 
 
-  test("handles multiple trailing commas in nested structures", () => {
-    const json = `{"nested": {"a": 1,},}`
-    expect(JSON.parse(stripJsonComments(json))).toEqual({ nested: { a: 1 } })
-  })
+    test("handles single-line comment at start of file", () => {
+      const json = `// comment at start
+  {"key": "value"}`
+      expect(JSON.parse(stripJsonComments(json))).toEqual({ key: "value" })
+    })
 
 
-  test("handles unclosed string gracefully without throwing", () => {
-    const json = '{"key": "unclosed'
-    expect(() => stripJsonComments(json)).not.toThrow()
-  })
+    test("handles comment-only lines between properties", () => {
+      const json = `{
+    "a": 1,
+    // comment line
+    "b": 2
+  }`
+      expect(JSON.parse(stripJsonComments(json))).toEqual({ a: 1, b: 2 })
+    })
 
 
-  test("preserves comma-bracket patterns inside strings", () => {
-    const json = '{"script": "test [,]", "json": "{,}"}'
-    const result = JSON.parse(stripJsonComments(json))
-    expect(result.script).toBe("test [,]")
-    expect(result.json).toBe("{,}")
-  })
+    test("handles multiple trailing commas in nested structures", () => {
+      const json = `{"nested": {"a": 1,},}`
+      expect(JSON.parse(stripJsonComments(json))).toEqual({ nested: { a: 1 } })
+    })
 
 
-  test("preserves comma-brace patterns inside strings", () => {
-    const json = '{"glob": "*.{js,ts}", "arr": "[a,]"}'
-    const result = JSON.parse(stripJsonComments(json))
-    expect(result.glob).toBe("*.{js,ts}")
-    expect(result.arr).toBe("[a,]")
-  })
+    test("handles unclosed string gracefully without throwing", () => {
+      const json = '{"key": "unclosed'
+      expect(() => stripJsonComments(json)).not.toThrow()
+    })
 
 
-  test("handles Windows CRLF line endings", () => {
-    const json = '{\r\n  "key": "value", // comment\r\n}'
-    const result = JSON.parse(stripJsonComments(json))
-    expect(result).toEqual({ key: "value" })
-  })
-})
+    test("preserves comma-bracket patterns inside strings", () => {
+      const json = '{"script": "test [,]", "json": "{,}"}'
+      const result = JSON.parse(stripJsonComments(json))
+      expect(result.script).toBe("test [,]")
+      expect(result.json).toBe("{,}")
+    })
 
 
-describe("JSONC file handling", () => {
-  test("stripJsonComments enables JSONC to be parsed as JSON", () => {
-    const jsoncContent = `{
-  // Single-line comment
-  "plugin": ["oh-my-opencode-slim"],
-  /* Multi-line
-     comment */
-  "provider": {
-    "google": {
-      "name": "Google", // inline comment
-    },
-  },
-}`
-
-    const result = JSON.parse(stripJsonComments(jsoncContent))
-    
-    expect(result).toEqual({
-      plugin: ["oh-my-opencode-slim"],
-      provider: {
-        google: {
-          name: "Google",
-        },
-      },
+    test("preserves comma-brace patterns inside strings", () => {
+      const json = '{"glob": "*.{js,ts}", "arr": "[a,]"}'
+      const result = JSON.parse(stripJsonComments(json))
+      expect(result.glob).toBe("*.{js,ts}")
+      expect(result.arr).toBe("[a,]")
     })
     })
-  })
 
 
-  test("stripJsonComments handles real-world JSONC config format", () => {
-    const jsoncConfig = `{
-  // OpenCode plugin configuration
-  "plugin": [
-    "oh-my-opencode-slim"
-  ],
-  
-  /* Provider settings for Antigravity
-     with model definitions */
-  "provider": {
-    "google": {
-      "name": "Google",
-      "models": ["claude-opus-4-5", "gemini-3-flash"],
-    }
-  },
-  
-  // Server configuration for tmux integration
-  "server": {
-    "port": 4096, // default port
-  }
-}`
-
-    const result = JSON.parse(stripJsonComments(jsoncConfig))
-    
-    expect(result.plugin).toEqual(["oh-my-opencode-slim"])
-    expect(result.provider.google.name).toBe("Google")
-    expect(result.provider.google.models).toEqual(["claude-opus-4-5", "gemini-3-flash"])
-    expect(result.server.port).toBe(4096)
+    test("handles Windows CRLF line endings", () => {
+      const json = '{\r\n  "key": "value", // comment\r\n}'
+      const result = JSON.parse(stripJsonComments(json))
+      expect(result).toEqual({ key: "value" })
+    })
   })
   })
 })
 })

+ 4 - 463
src/cli/config-manager.ts

@@ -1,463 +1,4 @@
-import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from "node:fs"
-import { homedir } from "node:os"
-import { join } from "node:path"
-import type { ConfigMergeResult, DetectedConfig, InstallConfig } from "./types"
-import { DEFAULT_AGENT_SKILLS } from "../tools/skill/builtin"
-
-const PACKAGE_NAME = "oh-my-opencode-slim"
-
-function getConfigDir(): string {
-  if (process.platform === "win32") {
-    const homedirPath = homedir()
-    const crossPlatformDir = join(homedirPath, ".config")
-    const appdataDir = process.env.APPDATA ?? join(homedirPath, "AppData", "Roaming")
-
-    const crossPlatformConfig = join(crossPlatformDir, "opencode", "opencode.json")
-    const crossPlatformConfigJsonc = join(crossPlatformDir, "opencode", "opencode.jsonc")
-
-    if (existsSync(crossPlatformConfig) || existsSync(crossPlatformConfigJsonc)) {
-      return crossPlatformDir
-    }
-
-    return appdataDir
-  }
-
-  return process.env.XDG_CONFIG_HOME
-    ? join(process.env.XDG_CONFIG_HOME, "opencode")
-    : join(homedir(), ".config", "opencode")
-}
-
-export function getOpenCodeConfigPaths(): string[] {
-  const configDir = getConfigDir()
-  return [
-    join(configDir, "opencode", "opencode.json"),
-    join(configDir, "opencode", "opencode.jsonc"),
-  ]
-}
-
-function getConfigJson(): string {
-  return join(getConfigDir(), "opencode.json")
-}
-
-function getConfigJsonc(): string {
-  return join(getConfigDir(), "opencode.jsonc")
-}
-
-function getLiteConfig(): string {
-  return join(getConfigDir(), "oh-my-opencode-slim.json")
-}
-
-function ensureConfigDir(): void {
-  const configDir = getConfigDir()
-  if (!existsSync(configDir)) {
-    mkdirSync(configDir, { recursive: true })
-  }
-}
-
-/**
- * Strip JSON comments (single-line // and multi-line) and trailing commas for JSONC support.
- * Note: When config files are read and written back, any comments will be lost as
- * JSON.stringify produces standard JSON without comments.
- */
-export function stripJsonComments(json: string): string {
-  // Regex matches three alternatives (in order):
-  //   1. \\\" - Escaped quotes (preserve these)
-  //   2. \"(?:\\\"|[^\"])*\" - Complete quoted strings (preserve content including // or /*)
-  //   3. (\/\/.*|\/\*[\s\S]*?\*\/) - Single-line or multi-line comments (capture group 1 - strip these)
-  //
-  // The replace callback: if group 1 exists (comment), replace with empty string; otherwise keep match
-  const commentPattern = /\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g
-
-  // Remove trailing commas before closing braces or brackets
-  // Uses same string-aware pattern to avoid corrupting strings containing ,} or ,]
-  const trailingCommaPattern = /\\"|"(?:\\"|[^"])*"|(,)(\s*[}\]])/g
-
-  return json
-    .replace(commentPattern, (match, commentGroup) => (commentGroup ? "" : match))
-    .replace(trailingCommaPattern, (match, comma, closing) =>
-      comma ? closing : match
-    )
-}
-
-interface OpenCodeConfig {
-  plugin?: string[]
-  provider?: Record<string, unknown>
-  [key: string]: unknown
-}
-
-function parseConfigFile(path: string): OpenCodeConfig | null {
-  try {
-    if (!existsSync(path)) return null
-    const stat = statSync(path)
-    if (stat.size === 0) return null
-    const content = readFileSync(path, "utf-8")
-    if (content.trim().length === 0) return null
-    return JSON.parse(stripJsonComments(content)) as OpenCodeConfig
-  } catch {
-    return null
-  }
-}
-
-function parseConfig(path: string): OpenCodeConfig | null {
-  const config = parseConfigFile(path)
-  if (config) return config
-
-  if (path.endsWith(".json")) {
-    const jsoncPath = path.replace(/\.json$/, ".jsonc")
-    return parseConfigFile(jsoncPath)
-  }
-  return null
-}
-
-function getExistingConfigPath(): string {
-  const jsonPath = getConfigJson()
-  if (existsSync(jsonPath)) return jsonPath
-
-  const jsoncPath = getConfigJsonc()
-  if (existsSync(jsoncPath)) return jsoncPath
-
-  return jsonPath
-}
-
-/**
- * Write config to file with proper warning if writing to .jsonc file.
- * Note: Comments in JSONC files will be lost as JSON.stringify produces standard JSON.
- */
-function writeConfig(configPath: string, config: OpenCodeConfig): void {
-  if (configPath.endsWith(".jsonc")) {
-    console.warn(
-      "[config-manager] Writing to .jsonc file - comments will not be preserved"
-    )
-  }
-  writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n")
-}
-
-export async function isOpenCodeInstalled(): Promise<boolean> {
-  try {
-    const proc = Bun.spawn(["opencode", "--version"], {
-      stdout: "pipe",
-      stderr: "pipe",
-    })
-    await proc.exited
-    return proc.exitCode === 0
-  } catch {
-    return false
-  }
-}
-
-export async function isTmuxInstalled(): Promise<boolean> {
-  try {
-    const proc = Bun.spawn(["tmux", "-V"], {
-      stdout: "pipe",
-      stderr: "pipe",
-    })
-    await proc.exited
-    return proc.exitCode === 0
-  } catch {
-    return false
-  }
-}
-
-export async function getOpenCodeVersion(): Promise<string | null> {
-  try {
-    const proc = Bun.spawn(["opencode", "--version"], {
-      stdout: "pipe",
-      stderr: "pipe",
-    })
-    const output = await new Response(proc.stdout).text()
-    await proc.exited
-    return proc.exitCode === 0 ? output.trim() : null
-  } catch {
-    return null
-  }
-}
-
-export async function fetchLatestVersion(packageName: string): Promise<string | null> {
-  try {
-    const res = await fetch(`https://registry.npmjs.org/${packageName}/latest`)
-    if (!res.ok) return null
-    const data = (await res.json()) as { version: string }
-    return data.version
-  } catch {
-    return null
-  }
-}
-
-export async function addPluginToOpenCodeConfig(): Promise<ConfigMergeResult> {
-  try {
-    ensureConfigDir()
-  } catch (err) {
-    return {
-      success: false,
-      configPath: getConfigDir(),
-      error: `Failed to create config directory: ${err}`,
-    }
-  }
-
-  const configPath = getExistingConfigPath()
-
-  try {
-    let config = parseConfig(configPath) ?? {}
-    const plugins = config.plugin ?? []
-
-
-    // Remove existing oh-my-opencode-slim entries
-    const filteredPlugins = plugins.filter(
-      (p) => p !== PACKAGE_NAME && !p.startsWith(`${PACKAGE_NAME}@`)
-    )
-
-    // Add fresh entry
-    filteredPlugins.push(PACKAGE_NAME)
-    config.plugin = filteredPlugins
-
-    writeConfig(configPath, config)
-    return { success: true, configPath }
-  } catch (err) {
-    return {
-      success: false,
-      configPath,
-      error: `Failed to update opencode config: ${err}`,
-    }
-  }
-}
-
-export async function addAuthPlugins(installConfig: InstallConfig): Promise<ConfigMergeResult> {
-  const configPath = getExistingConfigPath()
-
-  try {
-    ensureConfigDir()
-    let config = parseConfig(configPath) ?? {}
-    const plugins = config.plugin ?? []
-
-    if (installConfig.hasAntigravity) {
-      const version = await fetchLatestVersion("opencode-antigravity-auth")
-      const pluginEntry = version
-        ? `opencode-antigravity-auth@${version}`
-        : "opencode-antigravity-auth@latest"
-
-      if (!plugins.some((p) => p.startsWith("opencode-antigravity-auth"))) {
-        plugins.push(pluginEntry)
-      }
-    }
-
-    config.plugin = plugins
-    writeConfig(configPath, config)
-    return { success: true, configPath }
-  } catch (err) {
-    return {
-      success: false,
-      configPath,
-      error: `Failed to add auth plugins: ${err}`,
-    }
-  }
-}
-
-/**
- * Provider configurations for Google models (via Antigravity auth plugin)
- */
-const GOOGLE_PROVIDER_CONFIG = {
-  google: {
-    name: "Google",
-    models: {
-      "gemini-3-pro-high": {
-        name: "Gemini 3 Pro High",
-        thinking: true,
-        attachment: true,
-        limit: { context: 1048576, output: 65535 },
-        modalities: { input: ["text", "image", "pdf"], output: ["text"] },
-      },
-      "gemini-3-flash": {
-        name: "Gemini 3 Flash",
-        attachment: true,
-        limit: { context: 1048576, output: 65536 },
-        modalities: { input: ["text", "image", "pdf"], output: ["text"] },
-      },
-      "claude-opus-4-5-thinking": {
-        name: "Claude Opus 4.5 Thinking",
-        attachment: true,
-        limit: { context: 200000, output: 32000 },
-        modalities: { input: ["text", "image", "pdf"], output: ["text"] },
-      },
-      "claude-sonnet-4-5-thinking": {
-        name: "Claude Sonnet 4.5 Thinking",
-        attachment: true,
-        limit: { context: 200000, output: 32000 },
-        modalities: { input: ["text", "image", "pdf"], output: ["text"] },
-      },
-    },
-  },
-}
-
-export function addProviderConfig(installConfig: InstallConfig): ConfigMergeResult {
-  const configPath = getExistingConfigPath()
-
-  try {
-    ensureConfigDir()
-    let config = parseConfig(configPath) ?? {}
-
-    if (installConfig.hasAntigravity) {
-      const providers = (config.provider ?? {}) as Record<string, unknown>
-      providers.google = GOOGLE_PROVIDER_CONFIG.google
-      config.provider = providers
-    }
-
-    writeConfig(configPath, config)
-    return { success: true, configPath }
-  } catch (err) {
-    return {
-      success: false,
-      configPath,
-      error: `Failed to add provider config: ${err}`,
-    }
-  }
-}
-
-
-// Model mappings by provider priority
-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",
-  },
-  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",
-  },
-  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",
-  },
-} as const;
-
-export function generateLiteConfig(installConfig: InstallConfig): Record<string, unknown> {
-  // Priority: antigravity > openai > opencode (Zen free models)
-  const baseProvider = installConfig.hasAntigravity
-    ? "antigravity"
-    : installConfig.hasOpenAI
-      ? "openai"
-      : installConfig.hasOpencodeZen
-        ? "opencode"
-        : "opencode"; // Default to Zen free models
-
-  const config: Record<string, unknown> = { agents: {} };
-
-  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]) => [
-        k,
-        { model: v, skills: DEFAULT_AGENT_SKILLS[k as keyof typeof DEFAULT_AGENT_SKILLS] ?? [] },
-      ])
-    );
-
-    // 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;
-  }
-
-  if (installConfig.hasTmux) {
-    config.tmux = {
-      enabled: true,
-      layout: "main-vertical",
-      main_pane_size: 60,
-    };
-  }
-
-  return config;
-}
-
-export function writeLiteConfig(installConfig: InstallConfig): ConfigMergeResult {
-  const configPath = getLiteConfig()
-
-  try {
-    ensureConfigDir()
-    const config = generateLiteConfig(installConfig)
-    writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n")
-    return { success: true, configPath }
-  } catch (err) {
-    return {
-      success: false,
-      configPath,
-      error: `Failed to write lite config: ${err}`,
-    }
-  }
-}
-
-/**
- * Disable OpenCode's default subagents since the plugin provides its own
- */
-export function disableDefaultAgents(): ConfigMergeResult {
-  const configPath = getExistingConfigPath()
-
-  try {
-    ensureConfigDir()
-    let config = parseConfig(configPath) ?? {}
-
-    const agent = (config.agent ?? {}) as Record<string, unknown>
-    agent.explore = { disable: true }
-    agent.general = { disable: true }
-    config.agent = agent
-
-    writeConfig(configPath, config)
-    return { success: true, configPath }
-  } catch (err) {
-    return {
-      success: false,
-      configPath,
-      error: `Failed to disable default agents: ${err}`,
-    }
-  }
-}
-
-export function detectCurrentConfig(): DetectedConfig {
-  const result: DetectedConfig = {
-    isInstalled: false,
-    hasAntigravity: false,
-    hasOpenAI: false,
-    hasOpencodeZen: false,
-    hasTmux: false,
-  }
-
-  const config = parseConfig(getExistingConfigPath())
-  if (!config) return result
-
-  const plugins = config.plugin ?? []
-  result.isInstalled = plugins.some((p) => p.startsWith(PACKAGE_NAME))
-  result.hasAntigravity = plugins.some((p) => p.startsWith("opencode-antigravity-auth"))
-
-  // Try to detect from lite config
-  const 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
-
-    if (agents) {
-      const models = Object.values(agents)
-        .map((a) => a?.model)
-        .filter(Boolean)
-      result.hasOpenAI = models.some((m) => m?.startsWith("openai/"))
-      result.hasOpencodeZen = models.some((m) => m?.startsWith("opencode/"))
-    }
-
-    if (configObj.tmux && typeof configObj.tmux === "object") {
-      result.hasTmux = configObj.tmux.enabled === true
-    }
-  }
-
-  return result
-}
+export * from "./paths"
+export * from "./providers"
+export * from "./system"
+export * from "./config-io"

+ 0 - 3
src/cli/index.ts

@@ -10,8 +10,6 @@ function parseArgs(args: string[]): InstallArgs {
   for (const arg of args) {
   for (const arg of args) {
     if (arg === "--no-tui") {
     if (arg === "--no-tui") {
       result.tui = false
       result.tui = false
-    } else if (arg === "--skip-auth") {
-      result.skipAuth = true
     } else if (arg.startsWith("--antigravity=")) {
     } else if (arg.startsWith("--antigravity=")) {
       result.antigravity = arg.split("=")[1] as BooleanArg
       result.antigravity = arg.split("=")[1] as BooleanArg
     } else if (arg.startsWith("--openai=")) {
     } else if (arg.startsWith("--openai=")) {
@@ -38,7 +36,6 @@ Options:
   --openai=yes|no        OpenAI API access (yes/no)
   --openai=yes|no        OpenAI API access (yes/no)
   --tmux=yes|no          Enable tmux integration (yes/no)
   --tmux=yes|no          Enable tmux integration (yes/no)
   --no-tui               Non-interactive mode (requires all flags)
   --no-tui               Non-interactive mode (requires all flags)
-  --skip-auth            Skip authentication reminder
   -h, --help             Show this help message
   -h, --help             Show this help message
 
 
 Examples:
 Examples:

+ 2 - 5
src/cli/install.ts

@@ -1,4 +1,4 @@
-import type { InstallArgs, InstallConfig, BooleanArg, DetectedConfig } from "./types"
+import type { InstallArgs, InstallConfig, BooleanArg, DetectedConfig, ConfigMergeResult } from "./types"
 import * as readline from "readline/promises"
 import * as readline from "readline/promises"
 import {
 import {
   addPluginToOpenCodeConfig,
   addPluginToOpenCodeConfig,
@@ -9,7 +9,6 @@ import {
   addProviderConfig,
   addProviderConfig,
   disableDefaultAgents,
   disableDefaultAgents,
   detectCurrentConfig,
   detectCurrentConfig,
-  isTmuxInstalled,
   generateLiteConfig,
   generateLiteConfig,
 } from "./config-manager"
 } from "./config-manager"
 
 
@@ -72,9 +71,7 @@ async function checkOpenCodeInstalled(): Promise<{ ok: boolean; version?: string
   return { ok: true, version: version ?? undefined }
   return { ok: true, version: version ?? undefined }
 }
 }
 
 
-type StepResult = { success: boolean; error?: string; configPath?: string }
-
-function handleStepResult(result: StepResult, successMsg: string): boolean {
+function handleStepResult(result: ConfigMergeResult, successMsg: string): boolean {
   if (!result.success) {
   if (!result.success) {
     printError(`Failed: ${result.error}`)
     printError(`Failed: ${result.error}`)
     return false
     return false

+ 114 - 0
src/cli/paths.test.ts

@@ -0,0 +1,114 @@
+/// <reference types="bun-types" />
+
+import { describe, expect, test, afterEach } from "bun:test"
+import {
+  getConfigDir,
+  getOpenCodeConfigPaths,
+  getConfigJson,
+  getConfigJsonc,
+  getLiteConfig,
+  getExistingConfigPath,
+  ensureConfigDir,
+} from "./paths"
+import { homedir } from "node:os"
+import { join } from "node:path"
+import { existsSync, rmSync, mkdtempSync, writeFileSync } from "node:fs"
+import { tmpdir } from "node:os"
+
+describe("paths", () => {
+  const originalEnv = { ...process.env }
+
+  afterEach(() => {
+    process.env = { ...originalEnv }
+  })
+
+  test("getConfigDir() uses XDG_CONFIG_HOME when set", () => {
+    process.env.XDG_CONFIG_HOME = "/tmp/xdg-config"
+    expect(getConfigDir()).toBe("/tmp/xdg-config/opencode")
+  })
+
+  test("getConfigDir() falls back to ~/.config when XDG_CONFIG_HOME is unset", () => {
+    delete process.env.XDG_CONFIG_HOME
+    const expected = join(homedir(), ".config", "opencode")
+    expect(getConfigDir()).toBe(expected)
+  })
+
+  test("getOpenCodeConfigPaths() returns both json and jsonc paths", () => {
+    process.env.XDG_CONFIG_HOME = "/tmp/xdg-config"
+    expect(getOpenCodeConfigPaths()).toEqual([
+      "/tmp/xdg-config/opencode/opencode.json",
+      "/tmp/xdg-config/opencode/opencode.jsonc",
+    ])
+  })
+
+  test("getConfigJson() returns correct path", () => {
+    process.env.XDG_CONFIG_HOME = "/tmp/xdg-config"
+    expect(getConfigJson()).toBe("/tmp/xdg-config/opencode/opencode.json")
+  })
+
+  test("getConfigJsonc() returns correct path", () => {
+    process.env.XDG_CONFIG_HOME = "/tmp/xdg-config"
+    expect(getConfigJsonc()).toBe("/tmp/xdg-config/opencode/opencode.jsonc")
+  })
+
+  test("getLiteConfig() returns correct path", () => {
+    process.env.XDG_CONFIG_HOME = "/tmp/xdg-config"
+    expect(getLiteConfig()).toBe("/tmp/xdg-config/opencode/oh-my-opencode-slim.json")
+  })
+
+  describe("getExistingConfigPath()", () => {
+    let tmpDir: string
+
+    afterEach(() => {
+      if (tmpDir && existsSync(tmpDir)) {
+        rmSync(tmpDir, { recursive: true, force: true })
+      }
+    })
+
+    test("returns .json if it exists", () => {
+      tmpDir = mkdtempSync(join(tmpdir(), "opencode-test-"))
+      process.env.XDG_CONFIG_HOME = tmpDir
+      
+      const configDir = join(tmpDir, "opencode")
+      ensureConfigDir()
+      
+      const jsonPath = join(configDir, "opencode.json")
+      writeFileSync(jsonPath, "{}")
+      
+      expect(getExistingConfigPath()).toBe(jsonPath)
+    })
+
+    test("returns .jsonc if .json doesn't exist but .jsonc does", () => {
+      tmpDir = mkdtempSync(join(tmpdir(), "opencode-test-"))
+      process.env.XDG_CONFIG_HOME = tmpDir
+      
+      const configDir = join(tmpDir, "opencode")
+      ensureConfigDir()
+      
+      const jsoncPath = join(configDir, "opencode.jsonc")
+      writeFileSync(jsoncPath, "{}")
+      
+      expect(getExistingConfigPath()).toBe(jsoncPath)
+    })
+
+    test("returns default .json if neither exists", () => {
+      tmpDir = mkdtempSync(join(tmpdir(), "opencode-test-"))
+      process.env.XDG_CONFIG_HOME = tmpDir
+      
+      const jsonPath = join(tmpDir, "opencode", "opencode.json")
+      expect(getExistingConfigPath()).toBe(jsonPath)
+    })
+  })
+
+  test("ensureConfigDir() creates directory if it doesn't exist", () => {
+    const tmpDir = mkdtempSync(join(tmpdir(), "opencode-test-"))
+    process.env.XDG_CONFIG_HOME = tmpDir
+    const configDir = join(tmpDir, "opencode")
+    
+    expect(existsSync(configDir)).toBe(false)
+    ensureConfigDir()
+    expect(existsSync(configDir)).toBe(true)
+    
+    rmSync(tmpDir, { recursive: true, force: true })
+  })
+})

+ 50 - 0
src/cli/paths.ts

@@ -0,0 +1,50 @@
+import { existsSync, mkdirSync } from "node:fs"
+import { homedir } from "node:os"
+import { join } from "node:path"
+
+export function getConfigDir(): string {
+  // Keep this aligned with OpenCode itself and the plugin config loader:
+  // base dir is $XDG_CONFIG_HOME (if set) else ~/.config, and OpenCode config lives under /opencode.
+  const userConfigDir = process.env.XDG_CONFIG_HOME
+    ? process.env.XDG_CONFIG_HOME
+    : join(homedir(), ".config")
+
+  return join(userConfigDir, "opencode")
+}
+
+export function getOpenCodeConfigPaths(): string[] {
+  const configDir = getConfigDir()
+  return [
+    join(configDir, "opencode.json"),
+    join(configDir, "opencode.jsonc"),
+  ]
+}
+
+export function getConfigJson(): string {
+  return join(getConfigDir(), "opencode.json")
+}
+
+export function getConfigJsonc(): string {
+  return join(getConfigDir(), "opencode.jsonc")
+}
+
+export function getLiteConfig(): string {
+  return join(getConfigDir(), "oh-my-opencode-slim.json")
+}
+
+export function getExistingConfigPath(): string {
+  const jsonPath = getConfigJson()
+  if (existsSync(jsonPath)) return jsonPath
+
+  const jsoncPath = getConfigJsonc()
+  if (existsSync(jsoncPath)) return jsoncPath
+
+  return jsonPath
+}
+
+export function ensureConfigDir(): void {
+  const configDir = getConfigDir()
+  if (!existsSync(configDir)) {
+    mkdirSync(configDir, { recursive: true })
+  }
+}

+ 82 - 0
src/cli/providers.test.ts

@@ -0,0 +1,82 @@
+/// <reference types="bun-types" />
+
+import { describe, expect, test } from "bun:test"
+import { generateLiteConfig, MODEL_MAPPINGS } from "./providers"
+
+describe("providers", () => {
+  test("generateLiteConfig generates antigravity config by default", () => {
+    const config = generateLiteConfig({
+      hasAntigravity: true,
+      hasOpenAI: false,
+      hasOpencodeZen: 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)
+  })
+
+  test("generateLiteConfig overrides oracle with openai if available and antigravity is used", () => {
+    const config = generateLiteConfig({
+      hasAntigravity: true,
+      hasOpenAI: true,
+      hasOpencodeZen: 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)
+  })
+
+  test("generateLiteConfig uses openai if no antigravity", () => {
+    const config = generateLiteConfig({
+      hasAntigravity: false,
+      hasOpenAI: true,
+      hasOpencodeZen: false,
+      hasTmux: false,
+    })
+
+    const agents = config.agents as any
+    expect(agents.orchestrator.model).toBe(MODEL_MAPPINGS.openai.orchestrator)
+  })
+
+  test("generateLiteConfig uses opencode zen if no antigravity or openai", () => {
+    const config = generateLiteConfig({
+      hasAntigravity: false,
+      hasOpenAI: false,
+      hasOpencodeZen: true,
+      hasTmux: false,
+    })
+
+    const agents = config.agents as any
+    expect(agents.orchestrator.model).toBe(MODEL_MAPPINGS.opencode.orchestrator)
+  })
+
+  test("generateLiteConfig enables tmux when requested", () => {
+    const config = generateLiteConfig({
+      hasAntigravity: false,
+      hasOpenAI: false,
+      hasOpencodeZen: false,
+      hasTmux: true,
+    })
+
+    expect(config.tmux).toBeDefined()
+    expect((config.tmux as any).enabled).toBe(true)
+  })
+
+  test("generateLiteConfig includes default skills", () => {
+    const config = generateLiteConfig({
+      hasAntigravity: true,
+      hasOpenAI: false,
+      hasOpencodeZen: false,
+      hasTmux: false,
+    })
+
+    const agents = config.agents as any
+    expect(agents.orchestrator.skills).toContain("*")
+    expect(agents.fixer.skills).toBeDefined()
+  })
+})

+ 107 - 0
src/cli/providers.ts

@@ -0,0 +1,107 @@
+import type { InstallConfig } from "./types"
+import { DEFAULT_AGENT_SKILLS } from "../tools/skill/builtin"
+
+/**
+ * Provider configurations for Google models (via Antigravity auth plugin)
+ */
+export const GOOGLE_PROVIDER_CONFIG = {
+  google: {
+    name: "Google",
+    models: {
+      "gemini-3-pro-high": {
+        name: "Gemini 3 Pro High",
+        thinking: true,
+        attachment: true,
+        limit: { context: 1048576, output: 65535 },
+        modalities: { input: ["text", "image", "pdf"], output: ["text"] },
+      },
+      "gemini-3-flash": {
+        name: "Gemini 3 Flash",
+        attachment: true,
+        limit: { context: 1048576, output: 65536 },
+        modalities: { input: ["text", "image", "pdf"], output: ["text"] },
+      },
+      "claude-opus-4-5-thinking": {
+        name: "Claude Opus 4.5 Thinking",
+        attachment: true,
+        limit: { context: 200000, output: 32000 },
+        modalities: { input: ["text", "image", "pdf"], output: ["text"] },
+      },
+      "claude-sonnet-4-5-thinking": {
+        name: "Claude Sonnet 4.5 Thinking",
+        attachment: true,
+        limit: { context: 200000, output: 32000 },
+        modalities: { input: ["text", "image", "pdf"], output: ["text"] },
+      },
+    },
+  },
+}
+
+// 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",
+  },
+  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",
+  },
+  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",
+  },
+} as const;
+
+export function generateLiteConfig(installConfig: InstallConfig): Record<string, unknown> {
+  // Priority: antigravity > openai > opencode (Zen free models)
+  const baseProvider = installConfig.hasAntigravity
+    ? "antigravity"
+    : installConfig.hasOpenAI
+      ? "openai"
+      : installConfig.hasOpencodeZen
+        ? "opencode"
+        : "opencode"; // Default to Zen free models
+
+  const config: Record<string, unknown> = { agents: {} };
+
+  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]) => [
+        k,
+        { model: v, skills: DEFAULT_AGENT_SKILLS[k as keyof typeof DEFAULT_AGENT_SKILLS] ?? [] },
+      ])
+    );
+
+    // 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;
+  }
+
+  if (installConfig.hasTmux) {
+    config.tmux = {
+      enabled: true,
+      layout: "main-vertical",
+      main_pane_size: 60,
+    };
+  }
+
+  return config;
+}

+ 61 - 0
src/cli/system.test.ts

@@ -0,0 +1,61 @@
+/// <reference types="bun-types" />
+
+import { describe, expect, test, mock, spyOn } from "bun:test"
+import { isOpenCodeInstalled, isTmuxInstalled, getOpenCodeVersion, fetchLatestVersion } from "./system"
+
+describe("system", () => {
+  test("isOpenCodeInstalled returns boolean", async () => {
+    // We don't necessarily want to depend on the host system
+    // but for a basic test we can just check it returns a boolean
+    const result = await isOpenCodeInstalled()
+    expect(typeof result).toBe("boolean")
+  })
+
+  test("isTmuxInstalled returns boolean", async () => {
+    const result = await isTmuxInstalled()
+    expect(typeof result).toBe("boolean")
+  })
+
+  test("fetchLatestVersion returns version string or null", async () => {
+    // Mock global fetch
+    const originalFetch = globalThis.fetch
+    globalThis.fetch = mock(async () => {
+      return {
+        ok: true,
+        json: async () => ({ version: "1.2.3" })
+      }
+    }) as any
+
+    try {
+      const version = await fetchLatestVersion("any-package")
+      expect(version).toBe("1.2.3")
+    } finally {
+      globalThis.fetch = originalFetch
+    }
+  })
+
+  test("fetchLatestVersion returns null on error", async () => {
+    const originalFetch = globalThis.fetch
+    try {
+      globalThis.fetch = mock(async () => {
+        return {
+          ok: false
+        }
+      }) as any
+
+      const version = await fetchLatestVersion("any-package")
+      expect(version).toBeNull()
+    } finally {
+      globalThis.fetch = originalFetch
+    }
+  })
+
+  test("getOpenCodeVersion returns string or null", async () => {
+    const version = await getOpenCodeVersion()
+    if (version !== null) {
+      expect(typeof version).toBe("string")
+    } else {
+      expect(version).toBeNull()
+    }
+  })
+})

+ 50 - 0
src/cli/system.ts

@@ -0,0 +1,50 @@
+export async function isOpenCodeInstalled(): Promise<boolean> {
+  try {
+    const proc = Bun.spawn(["opencode", "--version"], {
+      stdout: "pipe",
+      stderr: "pipe",
+    })
+    await proc.exited
+    return proc.exitCode === 0
+  } catch {
+    return false
+  }
+}
+
+export async function isTmuxInstalled(): Promise<boolean> {
+  try {
+    const proc = Bun.spawn(["tmux", "-V"], {
+      stdout: "pipe",
+      stderr: "pipe",
+    })
+    await proc.exited
+    return proc.exitCode === 0
+  } catch {
+    return false
+  }
+}
+
+export async function getOpenCodeVersion(): Promise<string | null> {
+  try {
+    const proc = Bun.spawn(["opencode", "--version"], {
+      stdout: "pipe",
+      stderr: "pipe",
+    })
+    const output = await new Response(proc.stdout).text()
+    await proc.exited
+    return proc.exitCode === 0 ? output.trim() : null
+  } catch {
+    return null
+  }
+}
+
+export async function fetchLatestVersion(packageName: string): Promise<string | null> {
+  try {
+    const res = await fetch(`https://registry.npmjs.org/${packageName}/latest`)
+    if (!res.ok) return null
+    const data = (await res.json()) as { version: string }
+    return data.version
+  } catch {
+    return null
+  }
+}

+ 7 - 1
src/cli/types.ts

@@ -5,7 +5,13 @@ export interface InstallArgs {
   antigravity?: BooleanArg
   antigravity?: BooleanArg
   openai?: BooleanArg
   openai?: BooleanArg
   tmux?: BooleanArg
   tmux?: BooleanArg
-  skipAuth?: boolean
+}
+
+export interface OpenCodeConfig {
+  plugin?: string[]
+  provider?: Record<string, unknown>
+  agent?: Record<string, unknown>
+  [key: string]: unknown
 }
 }
 
 
 export interface InstallConfig {
 export interface InstallConfig {

+ 1 - 1
src/index.ts

@@ -1,6 +1,6 @@
 import type { Plugin } from "@opencode-ai/plugin";
 import type { Plugin } from "@opencode-ai/plugin";
 import { getAgentConfigs } from "./agents";
 import { getAgentConfigs } from "./agents";
-import { BackgroundTaskManager, TmuxSessionManager } from "./features";
+import { BackgroundTaskManager, TmuxSessionManager } from "./background";
 import {
 import {
   createBackgroundTools,
   createBackgroundTools,
   lsp_goto_definition,
   lsp_goto_definition,

+ 3 - 4
src/tools/background.test.ts

@@ -7,7 +7,7 @@ import {
   pollSession, 
   pollSession, 
   extractResponseText 
   extractResponseText 
 } from "./background.ts";
 } from "./background.ts";
-import { BackgroundTaskManager } from "../features/background-manager";
+import { BackgroundTaskManager } from "../background/background-manager";
 import type { PluginInput } from "@opencode-ai/plugin";
 import type { PluginInput } from "@opencode-ai/plugin";
 import { 
 import { 
   POLL_INTERVAL_MS, 
   POLL_INTERVAL_MS, 
@@ -353,10 +353,9 @@ describe("Background Tools", () => {
     });
     });
 
 
     test("respects tmux enabled delay", async () => {
     test("respects tmux enabled delay", async () => {
-        const start = Date.now();
+        const setTimeoutSpy = spyOn(global, "setTimeout");
         await createSession(ctx, { sessionID: "p1" } as any, "desc", "agent", { enabled: true } as any);
         await createSession(ctx, { sessionID: "p1" } as any, "desc", "agent", { enabled: true } as any);
-        const duration = Date.now() - start;
-        expect(duration).toBeGreaterThanOrEqual(500);
+        expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 500);
     });
     });
   });
   });
 
 

+ 1 - 1
src/tools/background.ts

@@ -1,5 +1,5 @@
 import { tool, type PluginInput, type ToolDefinition } from "@opencode-ai/plugin";
 import { tool, type PluginInput, type ToolDefinition } from "@opencode-ai/plugin";
-import type { BackgroundTaskManager } from "../features";
+import type { BackgroundTaskManager } from "../background";
 import { SUBAGENT_NAMES } from "../config";
 import { SUBAGENT_NAMES } from "../config";
 import {
 import {
   POLL_INTERVAL_MS,
   POLL_INTERVAL_MS,