install.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488
  1. import { existsSync } from 'node:fs';
  2. import { createInterface } from 'node:readline/promises';
  3. import {
  4. detectBackgroundSubagentsTarget,
  5. expandHomePath,
  6. getBackgroundSubagentsBlock,
  7. isBackgroundSubagentsEnabled,
  8. manualBackgroundSubagentsInstructions,
  9. writeBackgroundSubagentsBlock,
  10. } from './background-subagents';
  11. import { installCompanion } from './companion';
  12. import {
  13. addPluginToOpenCodeConfig,
  14. addPluginToOpenCodeTuiConfig,
  15. detectCurrentConfig,
  16. disableDefaultAgents,
  17. enableLspByDefault,
  18. generateLiteConfig,
  19. getOpenCodePath,
  20. getOpenCodeVersion,
  21. isOpenCodeInstalled,
  22. warmOpenCodePluginCache,
  23. writeLiteConfig,
  24. } from './config-manager';
  25. import { CUSTOM_SKILLS, installCustomSkill } from './custom-skills';
  26. import { getExistingLiteConfigPath } from './paths';
  27. import type { ConfigMergeResult, InstallArgs, InstallConfig } from './types';
  28. // Colors
  29. const GREEN = '\x1b[32m';
  30. const BLUE = '\x1b[34m';
  31. const YELLOW = '\x1b[33m';
  32. const RED = '\x1b[31m';
  33. const BOLD = '\x1b[1m';
  34. const DIM = '\x1b[2m';
  35. const RESET = '\x1b[0m';
  36. const SYMBOLS = {
  37. check: `${GREEN}[ok]${RESET}`,
  38. cross: `${RED}[x]${RESET}`,
  39. arrow: `${BLUE}->${RESET}`,
  40. bullet: `${DIM}-${RESET}`,
  41. info: `${BLUE}[i]${RESET}`,
  42. warn: `${YELLOW}[!]${RESET}`,
  43. star: `${YELLOW}★${RESET}`,
  44. };
  45. const GITHUB_REPO = 'alvinunreal/oh-my-opencode-slim';
  46. const GITHUB_URL = `https://github.com/${GITHUB_REPO}`;
  47. function printHeader(isUpdate: boolean): void {
  48. console.log();
  49. console.log(
  50. `${BOLD}oh-my-opencode-slim ${isUpdate ? 'Update' : 'Install'}${RESET}`,
  51. );
  52. console.log('='.repeat(30));
  53. console.log();
  54. }
  55. function printStep(step: number, total: number, message: string): void {
  56. console.log(`${DIM}[${step}/${total}]${RESET} ${message}`);
  57. }
  58. function printSuccess(message: string): void {
  59. console.log(`${SYMBOLS.check} ${message}`);
  60. }
  61. function printError(message: string): void {
  62. console.log(`${SYMBOLS.cross} ${RED}${message}${RESET}`);
  63. }
  64. function printInfo(message: string): void {
  65. console.log(`${SYMBOLS.info} ${message}`);
  66. }
  67. async function confirm(message: string, defaultYes = true): Promise<boolean> {
  68. const suffix = defaultYes ? ' (Y/n) ' : ' (y/N) ';
  69. const rl = createInterface({ input: process.stdin, output: process.stdout });
  70. try {
  71. const answer = (await rl.question(`${message}${suffix}`))
  72. .trim()
  73. .toLowerCase();
  74. if (!answer) return defaultYes;
  75. return answer === 'y' || answer === 'yes';
  76. } finally {
  77. rl.close();
  78. }
  79. }
  80. async function askToStarRepo(config: InstallConfig): Promise<void> {
  81. if (!config.promptForStar || config.dryRun || !process.stdin.isTTY) return;
  82. console.log();
  83. const shouldStar = await confirm(
  84. `${SYMBOLS.star} Star the repo on GitHub?`,
  85. true,
  86. );
  87. if (!shouldStar) return;
  88. try {
  89. const { execFileSync } = await import('node:child_process');
  90. execFileSync(
  91. 'gh',
  92. ['api', '--silent', '--method', 'PUT', `/user/starred/${GITHUB_REPO}`],
  93. { stdio: 'ignore', timeout: 10_000 },
  94. );
  95. printSuccess('Thanks for starring! ★');
  96. } catch {
  97. printInfo(
  98. `Couldn't star automatically. You can star manually:\n ${BLUE}${GITHUB_URL}${RESET}`,
  99. );
  100. }
  101. }
  102. async function checkOpenCodeInstalled(): Promise<{
  103. ok: boolean;
  104. version?: string;
  105. path?: string;
  106. }> {
  107. const installed = await isOpenCodeInstalled();
  108. if (!installed) {
  109. printError('OpenCode is not installed on this system.');
  110. printInfo('Install it with:');
  111. console.log(
  112. ` ${BLUE}curl -fsSL https://opencode.ai/install | bash${RESET}`,
  113. );
  114. console.log();
  115. printInfo('Or if already installed, add it to your PATH:');
  116. console.log(` ${BLUE}export PATH="$HOME/.local/bin:$PATH"${RESET}`);
  117. console.log(` ${BLUE}export PATH="$HOME/.opencode/bin:$PATH"${RESET}`);
  118. return { ok: false };
  119. }
  120. const version = await getOpenCodeVersion();
  121. const path = getOpenCodePath();
  122. const detectedVersion = version ?? '';
  123. const pathInfo = path ? ` (${DIM}${path}${RESET})` : '';
  124. printSuccess(`OpenCode ${detectedVersion} detected${pathInfo}`);
  125. return { ok: true, version: version ?? undefined, path: path ?? undefined };
  126. }
  127. export async function configureBackgroundSubagents(
  128. config: InstallConfig,
  129. ): Promise<{ enabledNow: boolean; configuredTarget?: string }> {
  130. if (
  131. isBackgroundSubagentsEnabled(
  132. process.env.OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS,
  133. )
  134. ) {
  135. printSuccess(
  136. 'OpenCode background subagents already enabled in environment',
  137. );
  138. return { enabledNow: true };
  139. }
  140. const target =
  141. config.backgroundSubagentsTarget !== undefined
  142. ? expandHomePath(config.backgroundSubagentsTarget)
  143. : detectBackgroundSubagentsTarget();
  144. if (config.backgroundSubagents === 'no') {
  145. printInfo('OpenCode background subagents shell setup skipped.');
  146. console.log(manualBackgroundSubagentsInstructions({ targetPath: target }));
  147. return { enabledNow: false };
  148. }
  149. if (!target) {
  150. printInfo('No safe shell startup file detected.');
  151. console.log(manualBackgroundSubagentsInstructions());
  152. return { enabledNow: false };
  153. }
  154. const block = getBackgroundSubagentsBlock(target);
  155. if (config.dryRun) {
  156. printInfo(
  157. 'Dry run mode - background subagents block that would be written:',
  158. );
  159. console.log(`Target: ${target}`);
  160. console.log(`\n${block}\n`);
  161. return { enabledNow: false, configuredTarget: target };
  162. }
  163. if (config.backgroundSubagents === 'ask') {
  164. if (!process.stdin.isTTY) {
  165. printInfo('Skipped background subagents shell setup in non-TTY mode.');
  166. console.log(
  167. manualBackgroundSubagentsInstructions({ targetPath: target }),
  168. );
  169. return { enabledNow: false };
  170. }
  171. console.log();
  172. printInfo(
  173. 'V2 requires OpenCode background subagents for default orchestration.',
  174. );
  175. printInfo(
  176. `The installer can add the required environment export to ${target}.`,
  177. );
  178. const shouldWrite = await confirm(
  179. 'Add OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true now?',
  180. true,
  181. );
  182. if (!shouldWrite) {
  183. printInfo('Skipped background subagents shell setup.');
  184. console.log(
  185. manualBackgroundSubagentsInstructions({ targetPath: target }),
  186. );
  187. return { enabledNow: false };
  188. }
  189. }
  190. try {
  191. writeBackgroundSubagentsBlock(target);
  192. } catch (error) {
  193. const message = error instanceof Error ? error.message : String(error);
  194. printError(`Could not write background subagents shell config: ${message}`);
  195. printInfo('Add the setting manually instead:');
  196. console.log(manualBackgroundSubagentsInstructions({ targetPath: target }));
  197. return { enabledNow: false };
  198. }
  199. printSuccess(
  200. `Background subagents enabled ${SYMBOLS.arrow} ${DIM}${target}${RESET}`,
  201. );
  202. return { enabledNow: false, configuredTarget: target };
  203. }
  204. export async function shouldInstallCompanion(
  205. config: InstallConfig,
  206. ): Promise<boolean> {
  207. if (config.companion === 'yes') return true;
  208. if (config.companion === 'no') return false;
  209. if (config.dryRun) {
  210. printInfo(
  211. 'Dry run mode - would ask to install the desktop companion (default: yes).',
  212. );
  213. config.companion = 'yes';
  214. return true;
  215. }
  216. if (!process.stdin.isTTY) {
  217. printInfo(
  218. 'Skipped desktop companion prompt in non-TTY mode. Use --companion=yes to install it.',
  219. );
  220. config.companion = 'no';
  221. return false;
  222. }
  223. console.log();
  224. printInfo('The optional desktop companion shows live agent activity.');
  225. const shouldInstall = await confirm(
  226. 'Install and enable the desktop companion?',
  227. true,
  228. );
  229. config.companion = shouldInstall ? 'yes' : 'no';
  230. if (!shouldInstall) {
  231. printInfo('Desktop companion install skipped.');
  232. }
  233. return shouldInstall;
  234. }
  235. function handleStepResult(
  236. result: ConfigMergeResult,
  237. successMsg: string,
  238. ): boolean {
  239. if (!result.success) {
  240. printError(`Failed: ${result.error}`);
  241. return false;
  242. }
  243. printSuccess(
  244. `${successMsg} ${SYMBOLS.arrow} ${DIM}${result.configPath}${RESET}`,
  245. );
  246. return true;
  247. }
  248. async function runInstall(config: InstallConfig): Promise<number> {
  249. const detected = detectCurrentConfig();
  250. const isUpdate = detected.isInstalled;
  251. printHeader(isUpdate);
  252. const companionInstall = await shouldInstallCompanion(config);
  253. let totalSteps = 7;
  254. if (config.installCustomSkills) totalSteps += 1;
  255. if (companionInstall) totalSteps += 1;
  256. totalSteps += 1;
  257. let step = 1;
  258. printStep(step++, totalSteps, 'Checking OpenCode installation...');
  259. if (config.dryRun) {
  260. printInfo('Dry run mode - skipping OpenCode check');
  261. } else {
  262. const { ok } = await checkOpenCodeInstalled();
  263. if (!ok) return 1;
  264. }
  265. printStep(step++, totalSteps, 'Adding oh-my-opencode-slim plugin...');
  266. if (config.dryRun) {
  267. printInfo('Dry run mode - skipping plugin installation');
  268. } else {
  269. const pluginResult = await addPluginToOpenCodeConfig();
  270. if (!handleStepResult(pluginResult, 'Plugin added')) return 1;
  271. }
  272. printStep(step++, totalSteps, 'Adding TUI version badge...');
  273. if (config.dryRun) {
  274. printInfo('Dry run mode - skipping TUI plugin installation');
  275. } else {
  276. const tuiResult = await addPluginToOpenCodeTuiConfig();
  277. if (!tuiResult.success) {
  278. printInfo(`Skipped TUI badge: ${tuiResult.error}`);
  279. } else {
  280. handleStepResult(tuiResult, 'TUI badge added');
  281. }
  282. }
  283. printStep(step++, totalSteps, 'Warming OpenCode plugin cache...');
  284. if (config.dryRun) {
  285. printInfo('Dry run mode - skipping cache warm-up');
  286. } else {
  287. const cacheResult = await warmOpenCodePluginCache();
  288. if (cacheResult === null) {
  289. printInfo('Local development install - cache warm-up not required');
  290. } else if (!cacheResult.success) {
  291. printInfo(`Skipped cache warm-up: ${cacheResult.error}`);
  292. } else {
  293. handleStepResult(cacheResult, 'OpenCode cache warmed');
  294. }
  295. }
  296. printStep(step++, totalSteps, 'Disabling OpenCode default agents...');
  297. if (config.dryRun) {
  298. printInfo('Dry run mode - skipping agent disabling');
  299. } else {
  300. const agentResult = disableDefaultAgents();
  301. if (!handleStepResult(agentResult, 'Default agents disabled')) return 1;
  302. }
  303. printStep(step++, totalSteps, 'Enabling OpenCode LSP integration...');
  304. if (config.dryRun) {
  305. printInfo('Dry run mode - skipping LSP configuration');
  306. } else {
  307. const lspResult = enableLspByDefault();
  308. if (!handleStepResult(lspResult, 'LSP enabled')) return 1;
  309. }
  310. printStep(step++, totalSteps, 'Configuring OpenCode background subagents...');
  311. const backgroundSubagents = await configureBackgroundSubagents(config);
  312. if (companionInstall) {
  313. printStep(step++, totalSteps, 'Installing desktop companion binary...');
  314. const companionResult = await installCompanion(config);
  315. if (!handleStepResult(companionResult, 'Companion installed')) return 1;
  316. }
  317. printStep(step++, totalSteps, 'Writing oh-my-opencode-slim configuration...');
  318. if (config.dryRun) {
  319. const liteConfig = generateLiteConfig(config);
  320. printInfo('Dry run mode - configuration that would be written:');
  321. console.log(`\n${JSON.stringify(liteConfig, null, 2)}\n`);
  322. } else {
  323. const configPath = getExistingLiteConfigPath();
  324. const configExists = existsSync(configPath);
  325. if (configExists && !config.reset) {
  326. printInfo(
  327. `Configuration already exists at ${configPath}. ` +
  328. 'Use --reset to overwrite.',
  329. );
  330. } else {
  331. const liteResult = writeLiteConfig(
  332. config,
  333. configExists ? configPath : undefined,
  334. );
  335. if (
  336. !handleStepResult(
  337. liteResult,
  338. configExists ? 'Config reset' : 'Config written',
  339. )
  340. )
  341. return 1;
  342. }
  343. }
  344. // Install custom skills if requested
  345. if (config.installCustomSkills) {
  346. printStep(step++, totalSteps, 'Installing custom skills...');
  347. if (config.dryRun) {
  348. printInfo('Dry run mode - would install custom skills:');
  349. for (const skill of CUSTOM_SKILLS) {
  350. printInfo(` - ${skill.name}`);
  351. }
  352. } else {
  353. let customSkillsInstalled = 0;
  354. for (const skill of CUSTOM_SKILLS) {
  355. printInfo(`Installing ${skill.name}...`);
  356. if (installCustomSkill(skill)) {
  357. printSuccess(`Installed: ${skill.name}`);
  358. customSkillsInstalled++;
  359. } else {
  360. printInfo(`Skipped: ${skill.name} (already installed)`);
  361. }
  362. }
  363. const totalCustom = CUSTOM_SKILLS.length;
  364. printSuccess(
  365. `${customSkillsInstalled}/${totalCustom} custom skills processed`,
  366. );
  367. }
  368. }
  369. const statusMsg = isUpdate
  370. ? 'Configuration updated!'
  371. : 'Installation complete!';
  372. console.log(`${SYMBOLS.star} ${BOLD}${GREEN}${statusMsg}${RESET}`);
  373. console.log();
  374. console.log(`${BOLD}Next steps:${RESET}`);
  375. console.log();
  376. const configPath = getExistingLiteConfigPath();
  377. console.log(' 1. Log in to the provider(s) you want to use:');
  378. console.log(` ${BLUE}$ opencode auth login${RESET}`);
  379. console.log();
  380. console.log(' 2. Refresh the models OpenCode can see:');
  381. console.log(` ${BLUE}$ opencode models --refresh${RESET}`);
  382. console.log();
  383. console.log(' 3. Review your generated config:');
  384. console.log(` ${BLUE}${configPath}${RESET}`);
  385. console.log();
  386. console.log(' 4. Start OpenCode:');
  387. if (backgroundSubagents.enabledNow) {
  388. console.log(` ${BLUE}$ opencode${RESET}`);
  389. } else if (backgroundSubagents.configuredTarget) {
  390. console.log(
  391. ` ${BLUE}$ source ${backgroundSubagents.configuredTarget}${RESET}`,
  392. );
  393. console.log(` ${BLUE}$ opencode${RESET}`);
  394. console.log(
  395. ` ${DIM}Or restart your terminal before running opencode.${RESET}`,
  396. );
  397. } else {
  398. console.log(
  399. ` ${BLUE}$ OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true opencode${RESET}`,
  400. );
  401. }
  402. console.log();
  403. console.log(' 5. Verify the agents are responding:');
  404. console.log(` ${BLUE}> ping all agents${RESET}`);
  405. console.log();
  406. const modelsInfo =
  407. config.preset && config.preset !== 'openai'
  408. ? `Generated OpenAI and OpenCode Go presets; ${config.preset} is active.`
  409. : 'Generated OpenAI and OpenCode Go presets; OpenAI is active by default.';
  410. console.log(`${modelsInfo}`);
  411. const altProviders = 'For the full configuration reference, see:';
  412. console.log(altProviders);
  413. const docsUrl =
  414. 'https://github.com/alvinunreal/oh-my-opencode-slim/' +
  415. 'blob/master/docs/configuration.md';
  416. console.log(` ${BLUE}${docsUrl}${RESET}`);
  417. console.log();
  418. await askToStarRepo(config);
  419. return 0;
  420. }
  421. export async function install(args: InstallArgs): Promise<number> {
  422. const config: InstallConfig = {
  423. hasTmux: false,
  424. installCustomSkills: args.skills === 'yes',
  425. preset: args.preset,
  426. promptForStar: args.tui,
  427. dryRun: args.dryRun,
  428. reset: args.reset ?? false,
  429. backgroundSubagents: args.backgroundSubagents ?? 'ask',
  430. backgroundSubagentsTarget: args.backgroundSubagentsTarget,
  431. companion: args.companion,
  432. };
  433. return runInstall(config);
  434. }