diff --git a/README.md b/README.md index 8aaa52b..5a490fc 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,32 @@ If you don't want all this, as mentioned, you can just pick and choose specific ### For Humans -Let the LLM Agent handle the grunt work. Paste this into a fresh opencode session: +**Option 1: CLI Installer (Recommended)** + +The easiest way to install. Interactive prompts guide you through setup: + +```bash +bunx oh-my-opencode install +``` + +Or use non-interactive mode for automation: + +```bash +bunx oh-my-opencode install --no-tui --claude=max20 --chatgpt=yes --gemini=yes +``` + +**CLI Options:** +| Option | Values | Description | +|--------|--------|-------------| +| `--claude` | `no`, `yes`, `max20` | Claude Pro/Max subscription | +| `--chatgpt` | `no`, `yes` | ChatGPT Plus/Pro subscription | +| `--gemini` | `no`, `yes` | Google Gemini integration | +| `--no-tui` | - | Non-interactive mode (requires all options) | +| `--skip-auth` | - | Skip authentication setup hints | + +**Option 2: Let an LLM Agent do it** + +Paste this into a fresh opencode session: ``` Install and configure by following the instructions here https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/refs/heads/master/README.md ``` diff --git a/bun.lock b/bun.lock index de4aa23..9217d57 100644 --- a/bun.lock +++ b/bun.lock @@ -7,10 +7,13 @@ "dependencies": { "@ast-grep/cli": "^0.40.0", "@ast-grep/napi": "^0.40.0", + "@clack/prompts": "^0.11.0", "@code-yeongyu/comment-checker": "^0.6.0", "@openauthjs/openauth": "^0.4.3", "@opencode-ai/plugin": "^1.0.162", + "commander": "^14.0.2", "hono": "^4.10.4", + "picocolors": "^1.1.1", "picomatch": "^4.0.2", "xdg-basedir": "^5.1.0", "zod": "^4.1.8", @@ -64,6 +67,10 @@ "@ast-grep/napi-win32-x64-msvc": ["@ast-grep/napi-win32-x64-msvc@0.40.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Hk2IwfPqMFGZt5SRxsoWmGLxBXxprow4LRp1eG6V8EEiJCNHxZ9ZiEaIc5bNvMDBjHVSnqZAXT22dROhrcSKQg=="], + "@clack/core": ["@clack/core@0.5.0", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow=="], + + "@clack/prompts": ["@clack/prompts@0.11.0", "", { "dependencies": { "@clack/core": "0.5.0", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw=="], + "@code-yeongyu/comment-checker": ["@code-yeongyu/comment-checker@0.6.0", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "comment-checker": "bin/comment-checker" } }, "sha512-VtDPrhbUJcb5BIS18VMcY/N/xSLbMr6dpU9MO1NYQyEDhI4pSIx07K4gOlCutG/nHVCjO+HEarn8rttODP+5UA=="], "@openauthjs/openauth": ["@openauthjs/openauth@0.4.3", "", { "dependencies": { "@standard-schema/spec": "1.0.0-beta.3", "aws4fetch": "1.0.20", "jose": "5.9.6" }, "peerDependencies": { "arctic": "^2.2.2", "hono": "^4.0.0" } }, "sha512-RlnjqvHzqcbFVymEwhlUEuac4utA5h4nhSK/i2szZuQmxTIqbGUxZ+nM+avM+VV4Ing+/ZaNLKILoXS3yrkOOw=="], @@ -94,14 +101,20 @@ "bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="], + "commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "hono": ["hono@4.10.8", "", {}, "sha512-DDT0A0r6wzhe8zCGoYOmMeuGu3dyTAE40HHjwUsWFTEy5WxK1x2WDSsBPlEXgPbRIFY6miDualuUDbasPogIww=="], "jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], diff --git a/package.json b/package.json index 1763a82..0f7868b 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,9 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "type": "module", + "bin": { + "oh-my-opencode": "./dist/cli/index.js" + }, "files": [ "dist" ], @@ -20,7 +23,7 @@ "./schema.json": "./dist/oh-my-opencode.schema.json" }, "scripts": { - "build": "bun build src/index.ts src/google-auth.ts --outdir dist --target bun --format esm --external @ast-grep/napi && tsc --emitDeclarationOnly && bun run build:schema", + "build": "bun build src/index.ts src/google-auth.ts --outdir dist --target bun --format esm --external @ast-grep/napi && tsc --emitDeclarationOnly && bun build src/cli/index.ts --outdir dist/cli --target bun --format esm && bun run build:schema", "build:schema": "bun run script/build-schema.ts", "clean": "rm -rf dist", "prepublishOnly": "bun run clean && bun run build", @@ -49,10 +52,13 @@ "dependencies": { "@ast-grep/cli": "^0.40.0", "@ast-grep/napi": "^0.40.0", + "@clack/prompts": "^0.11.0", "@code-yeongyu/comment-checker": "^0.6.0", "@openauthjs/openauth": "^0.4.3", "@opencode-ai/plugin": "^1.0.162", + "commander": "^14.0.2", "hono": "^4.10.4", + "picocolors": "^1.1.1", "picomatch": "^4.0.2", "xdg-basedir": "^5.1.0", "zod": "^4.1.8" diff --git a/src/cli/config-manager.ts b/src/cli/config-manager.ts new file mode 100644 index 0000000..8bc5cdb --- /dev/null +++ b/src/cli/config-manager.ts @@ -0,0 +1,484 @@ +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs" +import { join } from "node:path" +import { homedir } from "node:os" +import type { ConfigMergeResult, InstallConfig, DetectedConfig } from "./types" + +const OPENCODE_CONFIG_DIR = join(homedir(), ".config", "opencode") +const OPENCODE_JSON = join(OPENCODE_CONFIG_DIR, "opencode.json") +const OPENCODE_JSONC = join(OPENCODE_CONFIG_DIR, "opencode.jsonc") +const OPENCODE_PACKAGE_JSON = join(OPENCODE_CONFIG_DIR, "package.json") +const OMO_CONFIG = join(OPENCODE_CONFIG_DIR, "oh-my-opencode.json") + +const CHATGPT_HOTFIX_REPO = "code-yeongyu/opencode-openai-codex-auth#fix/orphaned-function-call-output-with-tools" + +export async function fetchLatestVersion(packageName: string): Promise { + 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 + } +} + +type ConfigFormat = "json" | "jsonc" | "none" + +interface OpenCodeConfig { + plugin?: string[] + [key: string]: unknown +} + +export function detectConfigFormat(): { format: ConfigFormat; path: string } { + if (existsSync(OPENCODE_JSONC)) { + return { format: "jsonc", path: OPENCODE_JSONC } + } + if (existsSync(OPENCODE_JSON)) { + return { format: "json", path: OPENCODE_JSON } + } + return { format: "none", path: OPENCODE_JSON } +} + +function stripJsoncComments(content: string): string { + let result = "" + let i = 0 + let inString = false + let escape = false + + while (i < content.length) { + const char = content[i] + + if (escape) { + result += char + escape = false + i++ + continue + } + + if (char === "\\") { + result += char + escape = true + i++ + continue + } + + if (char === '"' && !inString) { + inString = true + result += char + i++ + continue + } + + if (char === '"' && inString) { + inString = false + result += char + i++ + continue + } + + if (inString) { + result += char + i++ + continue + } + + // Outside string - check for comments + if (char === "/" && content[i + 1] === "/") { + // Line comment - skip to end of line + while (i < content.length && content[i] !== "\n") { + i++ + } + continue + } + + if (char === "/" && content[i + 1] === "*") { + // Block comment - skip to */ + i += 2 + while (i < content.length - 1 && !(content[i] === "*" && content[i + 1] === "/")) { + i++ + } + i += 2 + continue + } + + result += char + i++ + } + + return result.replace(/,(\s*[}\]])/g, "$1") +} + +function parseConfig(path: string, isJsonc: boolean): OpenCodeConfig | null { + try { + const content = readFileSync(path, "utf-8") + const cleaned = isJsonc ? stripJsoncComments(content) : content + return JSON.parse(cleaned) as OpenCodeConfig + } catch { + return null + } +} + +function ensureConfigDir(): void { + if (!existsSync(OPENCODE_CONFIG_DIR)) { + mkdirSync(OPENCODE_CONFIG_DIR, { recursive: true }) + } +} + +export function addPluginToOpenCodeConfig(): ConfigMergeResult { + ensureConfigDir() + + const { format, path } = detectConfigFormat() + const pluginName = "oh-my-opencode" + + try { + if (format === "none") { + const config: OpenCodeConfig = { plugin: [pluginName] } + writeFileSync(path, JSON.stringify(config, null, 2) + "\n") + return { success: true, configPath: path } + } + + const config = parseConfig(path, format === "jsonc") + if (!config) { + return { success: false, configPath: path, error: "Failed to parse config" } + } + + const plugins = config.plugin ?? [] + if (plugins.some((p) => p.startsWith(pluginName))) { + return { success: true, configPath: path } + } + + config.plugin = [...plugins, pluginName] + + if (format === "jsonc") { + const content = readFileSync(path, "utf-8") + const pluginArrayRegex = /"plugin"\s*:\s*\[([\s\S]*?)\]/ + const match = content.match(pluginArrayRegex) + + if (match) { + const arrayContent = match[1].trim() + const newArrayContent = arrayContent + ? `${arrayContent},\n "${pluginName}"` + : `"${pluginName}"` + const newContent = content.replace(pluginArrayRegex, `"plugin": [\n ${newArrayContent}\n ]`) + writeFileSync(path, newContent) + } else { + const newContent = content.replace(/^(\s*\{)/, `$1\n "plugin": ["${pluginName}"],`) + writeFileSync(path, newContent) + } + } else { + writeFileSync(path, JSON.stringify(config, null, 2) + "\n") + } + + return { success: true, configPath: path } + } catch (err) { + return { success: false, configPath: path, error: String(err) } + } +} + +function deepMerge>(target: T, source: Partial): T { + const result = { ...target } + + for (const key of Object.keys(source) as Array) { + const sourceValue = source[key] + const targetValue = result[key] + + if ( + sourceValue !== null && + typeof sourceValue === "object" && + !Array.isArray(sourceValue) && + targetValue !== null && + typeof targetValue === "object" && + !Array.isArray(targetValue) + ) { + result[key] = deepMerge( + targetValue as Record, + sourceValue as Record + ) as T[keyof T] + } else if (sourceValue !== undefined) { + result[key] = sourceValue as T[keyof T] + } + } + + return result +} + +export function generateOmoConfig(installConfig: InstallConfig): Record { + const config: Record = { + $schema: "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json", + } + + if (installConfig.hasGemini) { + config.google_auth = false + } + + const agents: Record> = {} + + if (!installConfig.hasClaude) { + agents["Sisyphus"] = { model: "opencode/big-pickle" } + agents["librarian"] = { model: "opencode/big-pickle" } + } else if (!installConfig.isMax20) { + agents["librarian"] = { model: "opencode/big-pickle" } + } + + if (!installConfig.hasChatGPT) { + agents["oracle"] = { + model: installConfig.hasClaude ? "anthropic/claude-opus-4-5" : "opencode/big-pickle", + } + } + + if (installConfig.hasGemini) { + agents["frontend-ui-ux-engineer"] = { model: "google/gemini-3-pro-high" } + agents["document-writer"] = { model: "google/gemini-3-flash" } + agents["multimodal-looker"] = { model: "google/gemini-3-flash" } + } else { + const fallbackModel = installConfig.hasClaude ? "anthropic/claude-opus-4-5" : "opencode/big-pickle" + agents["frontend-ui-ux-engineer"] = { model: fallbackModel } + agents["document-writer"] = { model: fallbackModel } + agents["multimodal-looker"] = { model: fallbackModel } + } + + if (Object.keys(agents).length > 0) { + config.agents = agents + } + + return config +} + +export function writeOmoConfig(installConfig: InstallConfig): ConfigMergeResult { + ensureConfigDir() + + try { + const newConfig = generateOmoConfig(installConfig) + + if (existsSync(OMO_CONFIG)) { + const content = readFileSync(OMO_CONFIG, "utf-8") + const cleaned = stripJsoncComments(content) + const existing = JSON.parse(cleaned) as Record + delete existing.agents + const merged = deepMerge(existing, newConfig) + writeFileSync(OMO_CONFIG, JSON.stringify(merged, null, 2) + "\n") + } else { + writeFileSync(OMO_CONFIG, JSON.stringify(newConfig, null, 2) + "\n") + } + + return { success: true, configPath: OMO_CONFIG } + } catch (err) { + return { success: false, configPath: OMO_CONFIG, error: String(err) } + } +} + +export async function isOpenCodeInstalled(): Promise { + try { + const proc = Bun.spawn(["opencode", "--version"], { + stdout: "pipe", + stderr: "pipe", + }) + await proc.exited + return proc.exitCode === 0 + } catch { + return false + } +} + +export async function getOpenCodeVersion(): Promise { + 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 addAuthPlugins(config: InstallConfig): Promise { + ensureConfigDir() + const { format, path } = detectConfigFormat() + + try { + const existingConfig = format !== "none" ? parseConfig(path, format === "jsonc") : null + const plugins: string[] = existingConfig?.plugin ?? [] + + if (config.hasGemini) { + const version = await fetchLatestVersion("opencode-antigravity-auth") + const pluginEntry = version ? `opencode-antigravity-auth@${version}` : "opencode-antigravity-auth" + if (!plugins.some((p) => p.startsWith("opencode-antigravity-auth"))) { + plugins.push(pluginEntry) + } + } + + if (config.hasChatGPT) { + if (!plugins.some((p) => p.startsWith("opencode-openai-codex-auth"))) { + plugins.push("opencode-openai-codex-auth") + } + } + + const newConfig = { ...(existingConfig ?? {}), plugin: plugins } + writeFileSync(path, JSON.stringify(newConfig, null, 2) + "\n") + return { success: true, configPath: path } + } catch (err) { + return { success: false, configPath: path, error: String(err) } + } +} + +export function setupChatGPTHotfix(): ConfigMergeResult { + ensureConfigDir() + + try { + let packageJson: Record = {} + if (existsSync(OPENCODE_PACKAGE_JSON)) { + const content = readFileSync(OPENCODE_PACKAGE_JSON, "utf-8") + packageJson = JSON.parse(content) + } + + const deps = (packageJson.dependencies ?? {}) as Record + deps["opencode-openai-codex-auth"] = CHATGPT_HOTFIX_REPO + packageJson.dependencies = deps + + writeFileSync(OPENCODE_PACKAGE_JSON, JSON.stringify(packageJson, null, 2) + "\n") + return { success: true, configPath: OPENCODE_PACKAGE_JSON } + } catch (err) { + return { success: false, configPath: OPENCODE_PACKAGE_JSON, error: String(err) } + } +} + +export async function runBunInstall(): Promise { + try { + const proc = Bun.spawn(["bun", "install"], { + cwd: OPENCODE_CONFIG_DIR, + stdout: "pipe", + stderr: "pipe", + }) + await proc.exited + return proc.exitCode === 0 + } catch { + return false + } +} + +const ANTIGRAVITY_PROVIDER_CONFIG = { + google: { + name: "Google", + api: "antigravity", + models: { + "gemini-3-pro-high": { name: "Gemini 3 Pro (High)", thinking: true, attachment: true }, + "gemini-3-pro-medium": { name: "Gemini 3 Pro (Medium)", thinking: true, attachment: true }, + "gemini-3-pro-low": { name: "Gemini 3 Pro (Low)", thinking: true, attachment: true }, + "gemini-3-flash": { name: "Gemini 3 Flash", attachment: true }, + "gemini-3-flash-lite": { name: "Gemini 3 Flash Lite", attachment: true }, + }, + }, +} + +const CODEX_PROVIDER_CONFIG = { + openai: { + name: "OpenAI", + api: "codex", + models: { + "gpt-5.2": { name: "GPT-5.2" }, + "o3": { name: "o3", thinking: true }, + "o4-mini": { name: "o4-mini", thinking: true }, + "codex-1": { name: "Codex-1" }, + }, + }, +} + +export function addProviderConfig(config: InstallConfig): ConfigMergeResult { + ensureConfigDir() + const { format, path } = detectConfigFormat() + + try { + const existingConfig = format !== "none" ? parseConfig(path, format === "jsonc") : null + const newConfig = { ...(existingConfig ?? {}) } + + const providers = (newConfig.provider ?? {}) as Record + + if (config.hasGemini) { + providers.google = ANTIGRAVITY_PROVIDER_CONFIG.google + } + + if (config.hasChatGPT) { + providers.openai = CODEX_PROVIDER_CONFIG.openai + } + + if (Object.keys(providers).length > 0) { + newConfig.provider = providers + } + + writeFileSync(path, JSON.stringify(newConfig, null, 2) + "\n") + return { success: true, configPath: path } + } catch (err) { + return { success: false, configPath: path, error: String(err) } + } +} + +interface OmoConfigData { + google_auth?: boolean + agents?: Record +} + +export function detectCurrentConfig(): DetectedConfig { + const result: DetectedConfig = { + isInstalled: false, + hasClaude: true, + isMax20: true, + hasChatGPT: true, + hasGemini: false, + } + + const { format, path } = detectConfigFormat() + if (format === "none") { + return result + } + + const openCodeConfig = parseConfig(path, format === "jsonc") + if (!openCodeConfig) { + return result + } + + const plugins = openCodeConfig.plugin ?? [] + result.isInstalled = plugins.some((p) => p.startsWith("oh-my-opencode")) + + if (!result.isInstalled) { + return result + } + + result.hasGemini = plugins.some((p) => p.startsWith("opencode-antigravity-auth")) + result.hasChatGPT = plugins.some((p) => p.startsWith("opencode-openai-codex-auth")) + + if (!existsSync(OMO_CONFIG)) { + return result + } + + try { + const content = readFileSync(OMO_CONFIG, "utf-8") + const omoConfig = JSON.parse(stripJsoncComments(content)) as OmoConfigData + + const agents = omoConfig.agents ?? {} + + if (agents["Sisyphus"]?.model === "opencode/big-pickle") { + result.hasClaude = false + result.isMax20 = false + } else if (agents["librarian"]?.model === "opencode/big-pickle") { + result.hasClaude = true + result.isMax20 = false + } + + if (agents["oracle"]?.model?.startsWith("anthropic/")) { + result.hasChatGPT = false + } else if (agents["oracle"]?.model === "opencode/big-pickle") { + result.hasChatGPT = false + } + + if (omoConfig.google_auth === false) { + result.hasGemini = plugins.some((p) => p.startsWith("opencode-antigravity-auth")) + } + } catch { + /* intentionally empty - malformed config returns defaults */ + } + + return result +} diff --git a/src/cli/index.ts b/src/cli/index.ts new file mode 100644 index 0000000..f2cfb14 --- /dev/null +++ b/src/cli/index.ts @@ -0,0 +1,54 @@ +#!/usr/bin/env bun +import { Command } from "commander" +import { install } from "./install" +import type { InstallArgs } from "./types" + +const packageJson = await import("../../package.json") +const VERSION = packageJson.version + +const program = new Command() + +program + .name("oh-my-opencode") + .description("The ultimate OpenCode plugin - multi-model orchestration, LSP tools, and more") + .version(VERSION, "-v, --version", "Show version number") + +program + .command("install") + .description("Install and configure oh-my-opencode with interactive setup") + .option("--no-tui", "Run in non-interactive mode (requires all options)") + .option("--claude ", "Claude subscription: no, yes, max20") + .option("--chatgpt ", "ChatGPT subscription: no, yes") + .option("--gemini ", "Gemini integration: no, yes") + .option("--skip-auth", "Skip authentication setup hints") + .addHelpText("after", ` +Examples: + $ bunx oh-my-opencode install + $ bunx oh-my-opencode install --no-tui --claude=max20 --chatgpt=yes --gemini=yes + $ bunx oh-my-opencode install --no-tui --claude=no --chatgpt=no --gemini=no + +Model Providers: + Claude Required for Sisyphus (main orchestrator) and Librarian agents + ChatGPT Powers the Oracle agent for debugging and architecture + Gemini Powers frontend, documentation, and multimodal agents +`) + .action(async (options) => { + const args: InstallArgs = { + tui: options.tui !== false, + claude: options.claude, + chatgpt: options.chatgpt, + gemini: options.gemini, + skipAuth: options.skipAuth ?? false, + } + const exitCode = await install(args) + process.exit(exitCode) + }) + +program + .command("version") + .description("Show version information") + .action(() => { + console.log(`oh-my-opencode v${VERSION}`) + }) + +program.parse() diff --git a/src/cli/install.ts b/src/cli/install.ts new file mode 100644 index 0000000..4489b38 --- /dev/null +++ b/src/cli/install.ts @@ -0,0 +1,456 @@ +import * as p from "@clack/prompts" +import color from "picocolors" +import type { InstallArgs, InstallConfig, ClaudeSubscription, BooleanArg, DetectedConfig } from "./types" +import { + addPluginToOpenCodeConfig, + writeOmoConfig, + isOpenCodeInstalled, + getOpenCodeVersion, + addAuthPlugins, + setupChatGPTHotfix, + runBunInstall, + addProviderConfig, + detectCurrentConfig, +} from "./config-manager" + +const SYMBOLS = { + check: color.green("✓"), + cross: color.red("✗"), + arrow: color.cyan("→"), + bullet: color.dim("•"), + info: color.blue("ℹ"), + warn: color.yellow("⚠"), + star: color.yellow("★"), +} + +function formatProvider(name: string, enabled: boolean, detail?: string): string { + const status = enabled ? SYMBOLS.check : color.dim("○") + const label = enabled ? color.white(name) : color.dim(name) + const suffix = detail ? color.dim(` (${detail})`) : "" + return ` ${status} ${label}${suffix}` +} + +function formatConfigSummary(config: InstallConfig): string { + const lines: string[] = [] + + lines.push(color.bold(color.white("Configuration Summary"))) + lines.push("") + + const claudeDetail = config.hasClaude ? (config.isMax20 ? "max20" : "standard") : undefined + lines.push(formatProvider("Claude", config.hasClaude, claudeDetail)) + lines.push(formatProvider("ChatGPT", config.hasChatGPT)) + lines.push(formatProvider("Gemini", config.hasGemini)) + + lines.push("") + lines.push(color.dim("─".repeat(40))) + lines.push("") + + lines.push(color.bold(color.white("Agent Configuration"))) + lines.push("") + + const sisyphusModel = config.hasClaude ? "claude-opus-4-5" : "big-pickle" + const oracleModel = config.hasChatGPT ? "gpt-5.2" : (config.hasClaude ? "claude-opus-4-5" : "big-pickle") + const librarianModel = config.hasClaude && config.isMax20 ? "claude-sonnet-4-5" : "big-pickle" + const frontendModel = config.hasGemini ? "gemini-3-pro-high" : (config.hasClaude ? "claude-opus-4-5" : "big-pickle") + + lines.push(` ${SYMBOLS.bullet} Sisyphus ${SYMBOLS.arrow} ${color.cyan(sisyphusModel)}`) + lines.push(` ${SYMBOLS.bullet} Oracle ${SYMBOLS.arrow} ${color.cyan(oracleModel)}`) + lines.push(` ${SYMBOLS.bullet} Librarian ${SYMBOLS.arrow} ${color.cyan(librarianModel)}`) + lines.push(` ${SYMBOLS.bullet} Frontend ${SYMBOLS.arrow} ${color.cyan(frontendModel)}`) + + return lines.join("\n") +} + +function printHeader(isUpdate: boolean): void { + const mode = isUpdate ? "Update" : "Install" + console.log() + console.log(color.bgMagenta(color.white(` oMoMoMoMo... ${mode} `))) + console.log() +} + +function printStep(step: number, total: number, message: string): void { + const progress = color.dim(`[${step}/${total}]`) + console.log(`${progress} ${message}`) +} + +function printSuccess(message: string): void { + console.log(`${SYMBOLS.check} ${message}`) +} + +function printError(message: string): void { + console.log(`${SYMBOLS.cross} ${color.red(message)}`) +} + +function printInfo(message: string): void { + console.log(`${SYMBOLS.info} ${message}`) +} + +function printWarning(message: string): void { + console.log(`${SYMBOLS.warn} ${color.yellow(message)}`) +} + +function printBox(content: string, title?: string): void { + const lines = content.split("\n") + const maxWidth = Math.max(...lines.map(l => l.replace(/\x1b\[[0-9;]*m/g, "").length), title?.length ?? 0) + 4 + const border = color.dim("─".repeat(maxWidth)) + + console.log() + if (title) { + console.log(color.dim("┌─") + color.bold(` ${title} `) + color.dim("─".repeat(maxWidth - title.length - 4)) + color.dim("┐")) + } else { + console.log(color.dim("┌") + border + color.dim("┐")) + } + + for (const line of lines) { + const stripped = line.replace(/\x1b\[[0-9;]*m/g, "") + const padding = maxWidth - stripped.length + console.log(color.dim("│") + ` ${line}${" ".repeat(padding - 1)}` + color.dim("│")) + } + + console.log(color.dim("└") + border + color.dim("┘")) + console.log() +} + +function validateNonTuiArgs(args: InstallArgs): { valid: boolean; errors: string[] } { + const errors: string[] = [] + + if (args.claude === undefined) { + errors.push("--claude is required (values: no, yes, max20)") + } else if (!["no", "yes", "max20"].includes(args.claude)) { + errors.push(`Invalid --claude value: ${args.claude} (expected: no, yes, max20)`) + } + + if (args.chatgpt === undefined) { + errors.push("--chatgpt is required (values: no, yes)") + } else if (!["no", "yes"].includes(args.chatgpt)) { + errors.push(`Invalid --chatgpt value: ${args.chatgpt} (expected: no, yes)`) + } + + if (args.gemini === undefined) { + errors.push("--gemini is required (values: no, yes)") + } else if (!["no", "yes"].includes(args.gemini)) { + errors.push(`Invalid --gemini value: ${args.gemini} (expected: no, yes)`) + } + + return { valid: errors.length === 0, errors } +} + +function argsToConfig(args: InstallArgs): InstallConfig { + return { + hasClaude: args.claude !== "no", + isMax20: args.claude === "max20", + hasChatGPT: args.chatgpt === "yes", + hasGemini: args.gemini === "yes", + } +} + +function detectedToInitialValues(detected: DetectedConfig): { claude: ClaudeSubscription; chatgpt: BooleanArg; gemini: BooleanArg } { + let claude: ClaudeSubscription = "no" + if (detected.hasClaude) { + claude = detected.isMax20 ? "max20" : "yes" + } + + return { + claude, + chatgpt: detected.hasChatGPT ? "yes" : "no", + gemini: detected.hasGemini ? "yes" : "no", + } +} + +async function runTuiMode(detected: DetectedConfig): Promise { + const initial = detectedToInitialValues(detected) + + const claude = await p.select({ + message: "Do you have a Claude Pro/Max subscription?", + options: [ + { value: "no" as const, label: "No", hint: "Will use opencode/big-pickle as fallback" }, + { value: "yes" as const, label: "Yes (standard)", hint: "Claude Opus 4.5 for orchestration" }, + { value: "max20" as const, label: "Yes (max20 mode)", hint: "Full power with Claude Sonnet 4.5 for Librarian" }, + ], + initialValue: initial.claude, + }) + + if (p.isCancel(claude)) { + p.cancel("Installation cancelled.") + return null + } + + const chatgpt = await p.select({ + message: "Do you have a ChatGPT Plus/Pro subscription?", + options: [ + { value: "no" as const, label: "No", hint: "Oracle will use fallback model" }, + { value: "yes" as const, label: "Yes", hint: "GPT-5.2 for debugging and architecture" }, + ], + initialValue: initial.chatgpt, + }) + + if (p.isCancel(chatgpt)) { + p.cancel("Installation cancelled.") + return null + } + + const gemini = await p.select({ + message: "Will you integrate Google Gemini?", + options: [ + { value: "no" as const, label: "No", hint: "Frontend/docs agents will use fallback" }, + { value: "yes" as const, label: "Yes", hint: "Beautiful UI generation with Gemini 3 Pro" }, + ], + initialValue: initial.gemini, + }) + + if (p.isCancel(gemini)) { + p.cancel("Installation cancelled.") + return null + } + + return { + hasClaude: claude !== "no", + isMax20: claude === "max20", + hasChatGPT: chatgpt === "yes", + hasGemini: gemini === "yes", + } +} + +async function runNonTuiInstall(args: InstallArgs): Promise { + const validation = validateNonTuiArgs(args) + if (!validation.valid) { + printHeader(false) + printError("Validation failed:") + for (const err of validation.errors) { + console.log(` ${SYMBOLS.bullet} ${err}`) + } + console.log() + printInfo("Usage: bunx oh-my-opencode install --no-tui --claude= --chatgpt= --gemini=") + console.log() + return 1 + } + + const detected = detectCurrentConfig() + const isUpdate = detected.isInstalled + + printHeader(isUpdate) + + const totalSteps = 6 + let step = 1 + + printStep(step++, totalSteps, "Checking OpenCode installation...") + const installed = await isOpenCodeInstalled() + if (!installed) { + printError("OpenCode is not installed on this system.") + printInfo("Visit https://opencode.ai/docs for installation instructions") + return 1 + } + + const version = await getOpenCodeVersion() + printSuccess(`OpenCode ${version ?? ""} detected`) + + if (isUpdate) { + const initial = detectedToInitialValues(detected) + printInfo(`Current config: Claude=${initial.claude}, ChatGPT=${initial.chatgpt}, Gemini=${initial.gemini}`) + } + + const config = argsToConfig(args) + + printStep(step++, totalSteps, "Adding oh-my-opencode plugin...") + const pluginResult = addPluginToOpenCodeConfig() + if (!pluginResult.success) { + printError(`Failed: ${pluginResult.error}`) + return 1 + } + printSuccess(`Plugin ${isUpdate ? "verified" : "added"} ${SYMBOLS.arrow} ${color.dim(pluginResult.configPath)}`) + + if (config.hasGemini || config.hasChatGPT) { + printStep(step++, totalSteps, "Adding auth plugins...") + const authResult = await addAuthPlugins(config) + if (!authResult.success) { + printError(`Failed: ${authResult.error}`) + return 1 + } + printSuccess(`Auth plugins configured ${SYMBOLS.arrow} ${color.dim(authResult.configPath)}`) + + printStep(step++, totalSteps, "Adding provider configurations...") + const providerResult = addProviderConfig(config) + if (!providerResult.success) { + printError(`Failed: ${providerResult.error}`) + return 1 + } + printSuccess(`Providers configured ${SYMBOLS.arrow} ${color.dim(providerResult.configPath)}`) + } else { + step += 2 + } + + if (config.hasChatGPT) { + printStep(step++, totalSteps, "Setting up ChatGPT hotfix...") + const hotfixResult = setupChatGPTHotfix() + if (!hotfixResult.success) { + printError(`Failed: ${hotfixResult.error}`) + return 1 + } + printSuccess(`Hotfix configured ${SYMBOLS.arrow} ${color.dim(hotfixResult.configPath)}`) + + printInfo("Installing dependencies with bun...") + const bunSuccess = await runBunInstall() + if (bunSuccess) { + printSuccess("Dependencies installed") + } else { + printWarning("bun install failed - run manually: cd ~/.config/opencode && bun i") + } + } else { + step++ + } + + printStep(step++, totalSteps, "Writing oh-my-opencode configuration...") + const omoResult = writeOmoConfig(config) + if (!omoResult.success) { + printError(`Failed: ${omoResult.error}`) + return 1 + } + printSuccess(`Config written ${SYMBOLS.arrow} ${color.dim(omoResult.configPath)}`) + + printBox(formatConfigSummary(config), isUpdate ? "Updated Configuration" : "Installation Complete") + + if (!config.hasClaude && !config.hasChatGPT && !config.hasGemini) { + printWarning("No model providers configured. Using opencode/big-pickle as fallback.") + } + + if ((config.hasClaude || config.hasChatGPT || config.hasGemini) && !args.skipAuth) { + console.log(color.bold("Next Steps - Authenticate your providers:")) + console.log() + if (config.hasClaude) { + console.log(` ${SYMBOLS.arrow} ${color.dim("opencode auth login")} ${color.gray("(select Anthropic → Claude Pro/Max)")}`) + } + if (config.hasChatGPT) { + console.log(` ${SYMBOLS.arrow} ${color.dim("opencode auth login")} ${color.gray("(select OpenAI → ChatGPT Plus/Pro)")}`) + } + if (config.hasGemini) { + console.log(` ${SYMBOLS.arrow} ${color.dim("opencode auth login")} ${color.gray("(select Google → OAuth with Antigravity)")}`) + } + console.log() + } + + console.log(`${SYMBOLS.star} ${color.bold(color.green(isUpdate ? "Configuration updated!" : "Installation complete!"))}`) + console.log(` Run ${color.cyan("opencode")} to start!`) + console.log() + console.log(color.dim("oMoMoMoMo... Enjoy!")) + console.log() + + return 0 +} + +export async function install(args: InstallArgs): Promise { + if (!args.tui) { + return runNonTuiInstall(args) + } + + const detected = detectCurrentConfig() + const isUpdate = detected.isInstalled + + p.intro(color.bgMagenta(color.white(isUpdate ? " oMoMoMoMo... Update " : " oMoMoMoMo... "))) + + if (isUpdate) { + const initial = detectedToInitialValues(detected) + p.log.info(`Existing configuration detected: Claude=${initial.claude}, ChatGPT=${initial.chatgpt}, Gemini=${initial.gemini}`) + } + + const s = p.spinner() + s.start("Checking OpenCode installation") + + const installed = await isOpenCodeInstalled() + if (!installed) { + s.stop("OpenCode is not installed") + p.log.error("OpenCode is not installed on this system.") + p.note("Visit https://opencode.ai/docs for installation instructions", "Installation Guide") + p.outro(color.red("Please install OpenCode first.")) + return 1 + } + + const version = await getOpenCodeVersion() + s.stop(`OpenCode ${version ?? "installed"} ${color.green("✓")}`) + + const config = await runTuiMode(detected) + if (!config) return 1 + + s.start("Adding oh-my-opencode to OpenCode config") + const pluginResult = addPluginToOpenCodeConfig() + if (!pluginResult.success) { + s.stop(`Failed to add plugin: ${pluginResult.error}`) + p.outro(color.red("Installation failed.")) + return 1 + } + s.stop(`Plugin added to ${color.cyan(pluginResult.configPath)}`) + + if (config.hasGemini || config.hasChatGPT) { + s.start("Adding auth plugins (fetching latest versions)") + const authResult = await addAuthPlugins(config) + if (!authResult.success) { + s.stop(`Failed to add auth plugins: ${authResult.error}`) + p.outro(color.red("Installation failed.")) + return 1 + } + s.stop(`Auth plugins added to ${color.cyan(authResult.configPath)}`) + + s.start("Adding provider configurations") + const providerResult = addProviderConfig(config) + if (!providerResult.success) { + s.stop(`Failed to add provider config: ${providerResult.error}`) + p.outro(color.red("Installation failed.")) + return 1 + } + s.stop(`Provider config added to ${color.cyan(providerResult.configPath)}`) + } + + if (config.hasChatGPT) { + s.start("Setting up ChatGPT hotfix") + const hotfixResult = setupChatGPTHotfix() + if (!hotfixResult.success) { + s.stop(`Failed to setup hotfix: ${hotfixResult.error}`) + p.outro(color.red("Installation failed.")) + return 1 + } + s.stop(`Hotfix configured in ${color.cyan(hotfixResult.configPath)}`) + + s.start("Installing dependencies with bun") + const bunSuccess = await runBunInstall() + if (bunSuccess) { + s.stop("Dependencies installed") + } else { + s.stop(color.yellow("bun install failed - run manually: cd ~/.config/opencode && bun i")) + } + } + + s.start("Writing oh-my-opencode configuration") + const omoResult = writeOmoConfig(config) + if (!omoResult.success) { + s.stop(`Failed to write config: ${omoResult.error}`) + p.outro(color.red("Installation failed.")) + return 1 + } + s.stop(`Config written to ${color.cyan(omoResult.configPath)}`) + + if (!config.hasClaude && !config.hasChatGPT && !config.hasGemini) { + p.log.warn("No model providers configured. Using opencode/big-pickle as fallback.") + } + + p.note(formatConfigSummary(config), isUpdate ? "Updated Configuration" : "Installation Complete") + + if ((config.hasClaude || config.hasChatGPT || config.hasGemini) && !args.skipAuth) { + const steps: string[] = [] + if (config.hasClaude) { + steps.push(`${color.dim("opencode auth login")} ${color.gray("(select Anthropic → Claude Pro/Max)")}`) + } + if (config.hasChatGPT) { + steps.push(`${color.dim("opencode auth login")} ${color.gray("(select OpenAI → ChatGPT Plus/Pro)")}`) + } + if (config.hasGemini) { + steps.push(`${color.dim("opencode auth login")} ${color.gray("(select Google → OAuth with Antigravity)")}`) + } + p.note(steps.join("\n"), "Next Steps - Authenticate your providers") + } + + p.log.success(color.bold(isUpdate ? "Configuration updated!" : "Installation complete!")) + p.log.message(`Run ${color.cyan("opencode")} to start!`) + + p.outro(color.green("oMoMoMoMo... Enjoy!")) + + return 0 +} diff --git a/src/cli/types.ts b/src/cli/types.ts new file mode 100644 index 0000000..8876796 --- /dev/null +++ b/src/cli/types.ts @@ -0,0 +1,31 @@ +export type ClaudeSubscription = "no" | "yes" | "max20" +export type BooleanArg = "no" | "yes" + +export interface InstallArgs { + tui: boolean + claude?: ClaudeSubscription + chatgpt?: BooleanArg + gemini?: BooleanArg + skipAuth?: boolean +} + +export interface InstallConfig { + hasClaude: boolean + isMax20: boolean + hasChatGPT: boolean + hasGemini: boolean +} + +export interface ConfigMergeResult { + success: boolean + configPath: string + error?: string +} + +export interface DetectedConfig { + isInstalled: boolean + hasClaude: boolean + isMax20: boolean + hasChatGPT: boolean + hasGemini: boolean +}