install.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  1. import * as readline from 'node:readline/promises';
  2. import {
  3. addPluginToOpenCodeConfig,
  4. addProviderConfig,
  5. detectCurrentConfig,
  6. disableDefaultAgents,
  7. generateLiteConfig,
  8. getOpenCodeVersion,
  9. isOpenCodeInstalled,
  10. writeLiteConfig,
  11. } from './config-manager';
  12. import { RECOMMENDED_SKILLS, installSkill } from './skills';
  13. import type {
  14. BooleanArg,
  15. ConfigMergeResult,
  16. DetectedConfig,
  17. InstallArgs,
  18. InstallConfig,
  19. } from './types';
  20. // Colors
  21. const GREEN = '\x1b[32m';
  22. const BLUE = '\x1b[34m';
  23. const YELLOW = '\x1b[33m';
  24. const RED = '\x1b[31m';
  25. const BOLD = '\x1b[1m';
  26. const DIM = '\x1b[2m';
  27. const RESET = '\x1b[0m';
  28. const SYMBOLS = {
  29. check: `${GREEN}✓${RESET}`,
  30. cross: `${RED}✗${RESET}`,
  31. arrow: `${BLUE}→${RESET}`,
  32. bullet: `${DIM}•${RESET}`,
  33. info: `${BLUE}ℹ${RESET}`,
  34. warn: `${YELLOW}⚠${RESET}`,
  35. star: `${YELLOW}★${RESET}`,
  36. };
  37. function printHeader(isUpdate: boolean): void {
  38. console.log();
  39. console.log(
  40. `${BOLD}oh-my-opencode-slim ${isUpdate ? 'Update' : 'Install'}${RESET}`,
  41. );
  42. console.log('='.repeat(30));
  43. console.log();
  44. }
  45. function printStep(step: number, total: number, message: string): void {
  46. console.log(`${DIM}[${step}/${total}]${RESET} ${message}`);
  47. }
  48. function printSuccess(message: string): void {
  49. console.log(`${SYMBOLS.check} ${message}`);
  50. }
  51. function printError(message: string): void {
  52. console.log(`${SYMBOLS.cross} ${RED}${message}${RESET}`);
  53. }
  54. function printInfo(message: string): void {
  55. console.log(`${SYMBOLS.info} ${message}`);
  56. }
  57. function printWarning(message: string): void {
  58. console.log(`${SYMBOLS.warn} ${YELLOW}${message}${RESET}`);
  59. }
  60. async function checkOpenCodeInstalled(): Promise<{
  61. ok: boolean;
  62. version?: string;
  63. }> {
  64. const installed = await isOpenCodeInstalled();
  65. if (!installed) {
  66. printError('OpenCode is not installed on this system.');
  67. printInfo('Install it with:');
  68. console.log(
  69. ` ${BLUE}curl -fsSL https://opencode.ai/install | bash${RESET}`,
  70. );
  71. return { ok: false };
  72. }
  73. const version = await getOpenCodeVersion();
  74. printSuccess(`OpenCode ${version ?? ''} detected`);
  75. return { ok: true, version: version ?? undefined };
  76. }
  77. function handleStepResult(
  78. result: ConfigMergeResult,
  79. successMsg: string,
  80. ): boolean {
  81. if (!result.success) {
  82. printError(`Failed: ${result.error}`);
  83. return false;
  84. }
  85. printSuccess(
  86. `${successMsg} ${SYMBOLS.arrow} ${DIM}${result.configPath}${RESET}`,
  87. );
  88. return true;
  89. }
  90. function formatConfigSummary(config: InstallConfig): string {
  91. const liteConfig = generateLiteConfig(config);
  92. const preset = (liteConfig.preset as string) || 'unknown';
  93. const lines: string[] = [];
  94. lines.push(`${BOLD}Configuration Summary${RESET}`);
  95. lines.push('');
  96. lines.push(` ${BOLD}Preset:${RESET} ${BLUE}${preset}${RESET}`);
  97. lines.push(
  98. ` ${config.hasAntigravity ? SYMBOLS.check : `${DIM}○${RESET}`} Antigravity`,
  99. );
  100. lines.push(
  101. ` ${config.hasOpenAI ? SYMBOLS.check : `${DIM}○${RESET}`} OpenAI`,
  102. );
  103. lines.push(` ${SYMBOLS.check} Opencode Zen (Big Pickle)`); // Always enabled
  104. lines.push(
  105. ` ${config.hasTmux ? SYMBOLS.check : `${DIM}○${RESET}`} Tmux Integration`,
  106. );
  107. return lines.join('\n');
  108. }
  109. function printAgentModels(config: InstallConfig): void {
  110. const liteConfig = generateLiteConfig(config);
  111. const presetName = (liteConfig.preset as string) || 'unknown';
  112. const presets = liteConfig.presets as Record<string, unknown>;
  113. const agents = presets?.[presetName] as Record<
  114. string,
  115. { model: string; skills: string[] }
  116. >;
  117. if (!agents || Object.keys(agents).length === 0) return;
  118. console.log(
  119. `${BOLD}Agent Configuration (Preset: ${BLUE}${presetName}${RESET}):${RESET}`,
  120. );
  121. console.log();
  122. const maxAgentLen = Math.max(...Object.keys(agents).map((a) => a.length));
  123. for (const [agent, info] of Object.entries(agents)) {
  124. const padding = ' '.repeat(maxAgentLen - agent.length);
  125. const skillsStr =
  126. info.skills.length > 0
  127. ? ` ${DIM}[${info.skills.join(', ')}]${RESET}`
  128. : '';
  129. console.log(
  130. ` ${DIM}${agent}${RESET}${padding} ${SYMBOLS.arrow} ${BLUE}${info.model}${RESET}${skillsStr}`,
  131. );
  132. }
  133. console.log();
  134. }
  135. function argsToConfig(args: InstallArgs): InstallConfig {
  136. return {
  137. hasAntigravity: args.antigravity === 'yes',
  138. hasOpenAI: args.openai === 'yes',
  139. hasOpencodeZen: true, // Always enabled - free models available to all users
  140. hasTmux: args.tmux === 'yes',
  141. installSkills: args.skills === 'yes',
  142. };
  143. }
  144. async function askYesNo(
  145. rl: readline.Interface,
  146. prompt: string,
  147. defaultValue: BooleanArg = 'no',
  148. ): Promise<BooleanArg> {
  149. const hint = defaultValue === 'yes' ? '[Y/n]' : '[y/N]';
  150. const answer = (await rl.question(`${BLUE}${prompt}${RESET} ${hint}: `))
  151. .trim()
  152. .toLowerCase();
  153. if (answer === '') return defaultValue;
  154. if (answer === 'y' || answer === 'yes') return 'yes';
  155. if (answer === 'n' || answer === 'no') return 'no';
  156. return defaultValue;
  157. }
  158. async function runInteractiveMode(
  159. detected: DetectedConfig,
  160. ): Promise<InstallConfig> {
  161. const rl = readline.createInterface({
  162. input: process.stdin,
  163. output: process.stdout,
  164. });
  165. // TODO: tmux has a bug, disabled for now
  166. // const tmuxInstalled = await isTmuxInstalled()
  167. // const totalQuestions = tmuxInstalled ? 3 : 2
  168. const totalQuestions = 2;
  169. try {
  170. console.log(`${BOLD}Question 1/${totalQuestions}:${RESET}`);
  171. const antigravity = await askYesNo(
  172. rl,
  173. 'Do you have an Antigravity subscription (via cliproxy)?',
  174. 'yes',
  175. );
  176. console.log();
  177. console.log(`${BOLD}Question 2/${totalQuestions}:${RESET}`);
  178. const openai = await askYesNo(
  179. rl,
  180. 'Do you have access to OpenAI API?',
  181. detected.hasOpenAI ? 'yes' : 'no',
  182. );
  183. console.log();
  184. // TODO: tmux has a bug, disabled for now
  185. // let tmux: BooleanArg = "no"
  186. // if (tmuxInstalled) {
  187. // console.log(`${BOLD}Question 3/3:${RESET}`)
  188. // printInfo(`${BOLD}Tmux detected!${RESET} We can enable tmux integration for you.`)
  189. // printInfo("This will spawn new panes for sub-agents, letting you watch them work in real-time.")
  190. // tmux = await askYesNo(rl, "Enable tmux integration?", detected.hasTmux ? "yes" : "no")
  191. // console.log()
  192. // }
  193. // Skills prompt
  194. console.log(`${BOLD}Recommended Skills:${RESET}`);
  195. for (const skill of RECOMMENDED_SKILLS) {
  196. console.log(` ${SYMBOLS.bullet} ${BOLD}${skill.name}${RESET}: ${skill.description}`);
  197. }
  198. console.log();
  199. const skills = await askYesNo(
  200. rl,
  201. 'Install recommended skills?',
  202. 'yes',
  203. );
  204. console.log();
  205. return {
  206. hasAntigravity: antigravity === 'yes',
  207. hasOpenAI: openai === 'yes',
  208. hasOpencodeZen: true,
  209. hasTmux: false,
  210. installSkills: skills === 'yes',
  211. };
  212. } finally {
  213. rl.close();
  214. }
  215. }
  216. async function runInstall(config: InstallConfig): Promise<number> {
  217. const detected = detectCurrentConfig();
  218. const isUpdate = detected.isInstalled;
  219. printHeader(isUpdate);
  220. // Calculate total steps dynamically
  221. let totalSteps = 4; // Base: check opencode, add plugin, disable default agents, write lite config
  222. if (config.hasAntigravity) totalSteps += 1; // provider config only (no auth plugin needed)
  223. if (config.installSkills) totalSteps += 1; // skills installation
  224. let step = 1;
  225. printStep(step++, totalSteps, 'Checking OpenCode installation...');
  226. const { ok } = await checkOpenCodeInstalled();
  227. if (!ok) return 1;
  228. printStep(step++, totalSteps, 'Adding oh-my-opencode-slim plugin...');
  229. const pluginResult = await addPluginToOpenCodeConfig();
  230. if (!handleStepResult(pluginResult, 'Plugin added')) return 1;
  231. printStep(step++, totalSteps, 'Disabling OpenCode default agents...');
  232. const agentResult = disableDefaultAgents();
  233. if (!handleStepResult(agentResult, 'Default agents disabled')) return 1;
  234. if (config.hasAntigravity) {
  235. printStep(step++, totalSteps, 'Adding cliproxy provider configuration...');
  236. const providerResult = addProviderConfig(config);
  237. if (!handleStepResult(providerResult, 'Cliproxy provider configured'))
  238. return 1;
  239. }
  240. printStep(step++, totalSteps, 'Writing oh-my-opencode-slim configuration...');
  241. const liteResult = writeLiteConfig(config);
  242. if (!handleStepResult(liteResult, 'Config written')) return 1;
  243. // Install skills if requested
  244. if (config.installSkills) {
  245. printStep(step++, totalSteps, 'Installing recommended skills...');
  246. let skillsInstalled = 0;
  247. for (const skill of RECOMMENDED_SKILLS) {
  248. printInfo(`Installing ${skill.name}...`);
  249. if (installSkill(skill)) {
  250. printSuccess(`Installed: ${skill.name}`);
  251. skillsInstalled++;
  252. } else {
  253. printWarning(`Failed to install: ${skill.name}`);
  254. }
  255. }
  256. printSuccess(`${skillsInstalled}/${RECOMMENDED_SKILLS.length} skills installed`);
  257. }
  258. // Summary
  259. console.log();
  260. console.log(formatConfigSummary(config));
  261. console.log();
  262. printAgentModels(config);
  263. if (!config.hasAntigravity && !config.hasOpenAI) {
  264. printWarning(
  265. 'No providers configured. Zen Big Pickle models will be used as fallback.',
  266. );
  267. }
  268. console.log(
  269. `${SYMBOLS.star} ${BOLD}${GREEN}${isUpdate ? 'Configuration updated!' : 'Installation complete!'}${RESET}`,
  270. );
  271. console.log();
  272. console.log(`${BOLD}Next steps:${RESET}`);
  273. console.log();
  274. let nextStep = 1;
  275. if (config.hasAntigravity) {
  276. console.log(` ${nextStep++}. Install cliproxy:`);
  277. console.log(` ${DIM}macOS:${RESET}`);
  278. console.log(` ${BLUE}$ brew install cliproxyapi${RESET}`);
  279. console.log(` ${BLUE}$ brew services start cliproxyapi${RESET}`);
  280. console.log(` ${DIM}Linux:${RESET}`);
  281. console.log(
  282. ` ${BLUE}$ curl -fsSL https://raw.githubusercontent.com/brokechubb/cliproxyapi-installer/refs/heads/master/cliproxyapi-installer | bash${RESET}`,
  283. );
  284. console.log();
  285. console.log(` ${nextStep++}. Authenticate with Antigravity via OAuth:`);
  286. console.log(` ${BLUE}$ ./cli-proxy-api --antigravity-login${RESET}`);
  287. console.log(
  288. ` ${DIM}(Add --no-browser to print login URL instead of opening browser)${RESET}`,
  289. );
  290. console.log();
  291. }
  292. if (config.hasOpenAI || !config.hasAntigravity) {
  293. console.log(` ${nextStep++}. Authenticate with your providers:`);
  294. console.log(` ${BLUE}$ opencode auth login${RESET}`);
  295. console.log();
  296. }
  297. // TODO: tmux has a bug, disabled for now
  298. // if (config.hasTmux) {
  299. // console.log(` ${nextStep++}. Run OpenCode inside tmux:`)
  300. // console.log(` ${BLUE}$ tmux${RESET}`)
  301. // console.log(` ${BLUE}$ opencode${RESET}`)
  302. // } else {
  303. console.log(` ${nextStep++}. Start OpenCode:`);
  304. console.log(` ${BLUE}$ opencode${RESET}`);
  305. // }
  306. console.log();
  307. return 0;
  308. }
  309. export async function install(args: InstallArgs): Promise<number> {
  310. // Non-interactive mode: all args must be provided
  311. if (!args.tui) {
  312. const requiredArgs = ['antigravity', 'openai', 'tmux'] as const;
  313. const errors = requiredArgs.filter((key) => {
  314. const value = args[key];
  315. return value === undefined || !['yes', 'no'].includes(value);
  316. });
  317. if (errors.length > 0) {
  318. printHeader(false);
  319. printError('Missing or invalid arguments:');
  320. for (const key of errors) {
  321. console.log(` ${SYMBOLS.bullet} --${key}=<yes|no>`);
  322. }
  323. console.log();
  324. printInfo(
  325. 'Usage: bunx oh-my-opencode-slim install --no-tui --antigravity=<yes|no> --openai=<yes|no> --tmux=<yes|no>',
  326. );
  327. console.log();
  328. return 1;
  329. }
  330. return runInstall(argsToConfig(args));
  331. }
  332. // Interactive mode
  333. const detected = detectCurrentConfig();
  334. printHeader(detected.isInstalled);
  335. printStep(1, 1, 'Checking OpenCode installation...');
  336. const { ok } = await checkOpenCodeInstalled();
  337. if (!ok) return 1;
  338. console.log();
  339. const config = await runInteractiveMode(detected);
  340. return runInstall(config);
  341. }