|
1 | 1 | import { spawn } from 'node:child_process'; |
2 | | -import { readFile, realpath, stat } from 'node:fs/promises'; |
| 2 | +import { constants as fsConstants } from 'node:fs'; |
| 3 | +import { access, readFile, realpath, stat } from 'node:fs/promises'; |
3 | 4 | import { homedir } from 'node:os'; |
4 | | -import { isAbsolute, join, relative, sep } from 'node:path'; |
| 5 | +import { delimiter, dirname, isAbsolute, join, relative, sep } from 'node:path'; |
5 | 6 | import { setTimeout as delay } from 'node:timers/promises'; |
6 | 7 |
|
7 | 8 | import Database from 'better-sqlite3'; |
@@ -651,10 +652,56 @@ const EXTERNAL_CLI_COMMAND: Record<ExternalSpawnCli, string> = { |
651 | 652 | omp: 'omp', |
652 | 653 | }; |
653 | 654 |
|
| 655 | +type ExternalCliExecutableResolverOptions = { |
| 656 | + path?: string; |
| 657 | + pathExt?: string; |
| 658 | + platform?: NodeJS.Platform; |
| 659 | + isExecutable?: (candidate: string) => Promise<boolean>; |
| 660 | +}; |
| 661 | + |
| 662 | +export function withoutNodeModulesBins(pathValue: string): string { |
| 663 | + return pathValue |
| 664 | + .split(delimiter) |
| 665 | + .filter((entry) => entry && !(dirname(entry).endsWith(`${sep}node_modules`) && entry.endsWith(`${sep}.bin`))) |
| 666 | + .join(delimiter); |
| 667 | +} |
| 668 | + |
| 669 | +/** Resolves user-installed agents without letting ChatMux's npm scripts shadow them. */ |
| 670 | +export async function resolveExternalCliExecutable( |
| 671 | + cli: ExternalSpawnCli, |
| 672 | + options: ExternalCliExecutableResolverOptions = {}, |
| 673 | +): Promise<string> { |
| 674 | + const command = EXTERNAL_CLI_COMMAND[cli]; |
| 675 | + const platform = options.platform ?? process.platform; |
| 676 | + const searchPath = withoutNodeModulesBins(options.path ?? process.env.PATH ?? ''); |
| 677 | + const extensions = platform === 'win32' |
| 678 | + ? (options.pathExt ?? process.env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM').split(';') |
| 679 | + : ['']; |
| 680 | + const isExecutable = options.isExecutable ?? (async (candidate: string) => { |
| 681 | + try { |
| 682 | + await access(candidate, fsConstants.X_OK); |
| 683 | + return (await stat(candidate)).isFile(); |
| 684 | + } catch { |
| 685 | + return false; |
| 686 | + } |
| 687 | + }); |
| 688 | + |
| 689 | + for (const directory of searchPath.split(delimiter).filter(Boolean)) { |
| 690 | + for (const extension of extensions) { |
| 691 | + const candidate = join(directory, `${command}${extension}`); |
| 692 | + if (await isExecutable(candidate)) { |
| 693 | + return candidate; |
| 694 | + } |
| 695 | + } |
| 696 | + } |
| 697 | + return command; |
| 698 | +} |
| 699 | + |
654 | 700 | /** Boots and tags a native CLI in a fresh detached tmux session. */ |
655 | 701 | export async function spawnExternalCliSession(cli: ExternalSpawnCli, tmuxName: string, cwd: string): Promise<void> { |
| 702 | + const executable = await resolveExternalCliExecutable(cli); |
656 | 703 | await runCommand('tmux', [ |
657 | | - 'new-session', '-d', '-s', tmuxName, '-c', cwd, EXTERNAL_CLI_COMMAND[cli], |
| 704 | + 'new-session', '-d', '-s', tmuxName, '-c', cwd, executable, |
658 | 705 | ]); |
659 | 706 | try { |
660 | 707 | await runCommand('tmux', ['set-option', '-t', tmuxName, '@chatmux_cli_kind', cli]); |
|
0 commit comments