openpencil/apps/web/server/utils/cli-resolver-helpers.ts
Kayshen-X a7d73ebb62 feat(ai): pencil-style agentic design tool-loop, multi-chat tabs, #27 panel restyle
Built-in design generation now runs as an agentic MCP tool-loop (reusing the
agent-rs BuiltInProvider), gated behind OPENPENCIL_DESIGN_AGENT_LOOP / the
Settings experimental toggle; the orchestrator stays the default.

- design-agent system prompt + in-process design toolset (parity-locked with
  the MCP surface) + flag-gated Intent::Design routing
- spawn_agents execution as sequential sub-loops + live creation-mode badges
  (per-agent glow + 'N/M designing...' header)
- new MCP tools: get_guidelines, ToolSearch, get_screenshot, get_editor_state,
  export_nodes, spawn_agents; style-guide local audit
- #27 AI panel restyle: rounded tool cards + green check-rings, gray user
  bubbles, model-pill bottom toolbar, header, empty-state pills, the
  PARALLEL AGENTS (agent_team_size) 1x-6x chip dropdown
- multi-chat tabs: ChatSessions model (Deref-to-active) + tab row UI
  (switch / close / + / Cmd+T) with each run bound to its tab

Large checkpoint commit spanning the working tree (Rust shell crates).
2026-07-02 21:21:06 +08:00

153 lines
5.3 KiB
TypeScript

/**
* Shared helpers for resolving local CLI binaries across the builtin agent
* providers. Electron on macOS does NOT inherit the user's login-shell PATH,
* so a bare `which <cli>` from Nitro often fails even when the CLI works
* in the user's terminal. Common install locations like ~/.bun/bin,
* ~/.nvm/versions/node/<ver>/bin, ~/Library/pnpm, ~/.volta/bin,
* ~/.asdf/shims, ~/.local/share/mise/shims are invisible to the server.
*
* Two primitives here:
* - posixUserBinDirs(): enumerate the standard user-local / package-manager
* bin directories, so resolvers can scan them as concrete candidates.
* - probeViaLoginShell(binary): ask the user's configured shell for the
* resolved path of a binary, picking up nvm / pnpm / bun / mise / asdf
* shims without having to enumerate every possible install layout.
*
* Everything logs through serverLog so the server-YYYY-MM-DD.log file gets
* the full resolution trace for remote diagnosis.
*/
import { execSync } from 'node:child_process';
import { existsSync, readdirSync } from 'node:fs';
import { homedir, platform } from 'node:os';
import { join } from 'node:path';
import { serverLog } from './server-logger';
const isWindows = platform() === 'win32';
/** Enumerate the standard macOS/Linux user-local install directories. */
export function posixUserBinDirs(): string[] {
const home = homedir();
const dirs = [
join(home, '.bun', 'bin'),
join(home, '.volta', 'bin'),
join(home, '.local', 'bin'),
join(home, '.local', 'share', 'mise', 'shims'),
join(home, '.asdf', 'shims'),
join(home, 'Library', 'pnpm'),
join(home, '.pnpm-global', 'bin'),
join(home, '.cargo', 'bin'),
'/usr/local/bin',
'/opt/homebrew/bin',
];
// nvm: enumerate installed node versions best-effort (just readdir)
try {
const nvmNodeRoot = join(home, '.nvm', 'versions', 'node');
if (existsSync(nvmNodeRoot)) {
for (const ver of readdirSync(nvmNodeRoot)) {
dirs.push(join(nvmNodeRoot, ver, 'bin'));
}
}
} catch {
/* best effort */
}
// fnm
try {
const fnmRoot = join(home, '.fnm', 'node-versions');
if (existsSync(fnmRoot)) {
for (const ver of readdirSync(fnmRoot)) {
dirs.push(join(fnmRoot, ver, 'installation', 'bin'));
}
}
} catch {
/* best effort */
}
return dirs;
}
/**
* Standard locations where fish is typically installed on macOS/Linux.
* Homebrew Apple Silicon uses /opt/homebrew, Homebrew Intel + most distros
* ship to /usr/local, MacPorts uses /opt/local.
*/
const FISH_FALLBACK_PATHS = [
'/opt/homebrew/bin/fish',
'/usr/local/bin/fish',
'/opt/local/bin/fish',
'/usr/bin/fish',
];
/**
* Probe the user's login shell for the resolved path of a binary.
* Runs `<shell> -ilc 'command -v <binary>'` to source the user's rc + profile
* so nvm / pnpm / bun / mise / asdf shims wired there are visible. `prefix`
* is used only for the log lines to match the caller's existing namespace.
*
* Supports zsh, bash, and fish — fish users configure their PATH in
* `~/.config/fish/config.fish` (often via nvm.fish / mise / bass), which
* neither zsh nor bash will source. `command -v` and `2>/dev/null` both
* work in fish 3.x, so the invocation is identical across shells.
*/
export function probeViaLoginShell(binary: string, prefix: string): string | undefined {
if (isWindows) return undefined;
const userShell = process.env.SHELL;
const shells: string[] = [];
// User's declared shell wins — the one that sources their real rc/profile.
if (userShell && existsSync(userShell)) shells.push(userShell);
// Fish fallback: many users run fish but Electron may launch with SHELL
// unset (e.g. Dock-launched apps inherit a scrubbed environment). Without
// this, fish-only PATH entries (nvm.fish / mise / bass) stay invisible.
if (!shells.some((s) => s.endsWith('/fish'))) {
for (const p of FISH_FALLBACK_PATHS) {
if (existsSync(p)) {
shells.push(p);
break;
}
}
}
// zsh / bash catch-all — most POSIX CLIs wire their shims through at least
// one of these even on fish-primary systems.
if (!shells.some((s) => s.endsWith('/zsh')) && existsSync('/bin/zsh')) {
shells.push('/bin/zsh');
}
if (!shells.some((s) => s.endsWith('/bash')) && existsSync('/bin/bash')) {
shells.push('/bin/bash');
}
for (const shell of shells) {
try {
const cmd = `${shell} -ilc 'command -v ${binary} 2>/dev/null' 2>/dev/null`;
serverLog.info(`[${prefix}] login-shell probe: ${cmd}`);
const raw = execSync(cmd, {
encoding: 'utf-8',
timeout: 6000,
// Start from a minimal env — inheriting Electron's env can suppress
// the login-shell side effects we rely on (nvm's __NVM_DIR etc).
env: { HOME: homedir(), USER: process.env.USER ?? '' },
}).trim();
const path = raw.split(/\r?\n/).filter(Boolean).pop();
if (path && existsSync(path)) {
serverLog.info(`[${prefix}] login-shell probe hit via ${shell}: "${path}"`);
return path;
}
if (path) {
serverLog.info(
`[${prefix}] login-shell (${shell}) returned "${path}" but file does not exist`,
);
}
} catch (err) {
serverLog.info(
`[${prefix}] login-shell probe via ${shell} failed: ${err instanceof Error ? err.message : err}`,
);
}
}
return undefined;
}