install.ts 17 KB

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