mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 16:39:42 +02:00
feat: OpenCode integration Phases 1-3 — types, CLI resolver, tmux spawn
Phase 1: Type system - Add SessionMode = 'claude' | 'shell' | 'opencode' to types.ts (single source of truth) - Add OpenCodeConfig interface (model, autoAllowTools, continueSession, etc.) - Add openCodeConfig to SessionState - Import SessionMode in session.ts, mux-interface.ts (remove duplicates) - Refactor createSession/respawnPane to options objects (CreateSessionOptions, RespawnPaneOptions) Phase 2: CLI resolver - New opencode-cli-resolver.ts mirroring claude-cli-resolver.ts - Searches ~/.opencode/bin, ~/.local/bin, /usr/local/bin, ~/go/bin, etc. - Exported via utils/index.ts Phase 3: Tmux spawn - Extract buildSpawnCommand() shared helper (eliminates duplication) - Add buildOpenCodeCommand() for opencode CLI flags - Add setOpenCodeEnvVars() — API keys via tmux setenv (not visible in ps) - Add setOpenCodeConfigContent() — JSON config via tmux setenv (prevents shell injection) - Gate _processExpensiveParsers() for opencode mode (skip Claude-specific parsers) - OpenCode-specific TUI ready detection (timeout-based, no ❯ prompt scanning) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+31
-15
@@ -7,7 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { EventEmitter } from 'node:events';
|
import type { EventEmitter } from 'node:events';
|
||||||
import type { ProcessStats, PersistedRespawnConfig, NiceConfig, ClaudeMode } from './types.js';
|
import type { ProcessStats, PersistedRespawnConfig, NiceConfig, ClaudeMode, SessionMode, OpenCodeConfig } from './types.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Multiplexer session metadata.
|
* Multiplexer session metadata.
|
||||||
@@ -23,8 +23,8 @@ export interface MuxSession {
|
|||||||
createdAt: number;
|
createdAt: number;
|
||||||
/** Working directory */
|
/** Working directory */
|
||||||
workingDir: string;
|
workingDir: string;
|
||||||
/** Session mode: claude or shell */
|
/** Session mode */
|
||||||
mode: 'claude' | 'shell';
|
mode: SessionMode;
|
||||||
/** Whether webserver is attached to this session */
|
/** Whether webserver is attached to this session */
|
||||||
attached: boolean;
|
attached: boolean;
|
||||||
/** Session display name (tab name) */
|
/** Session display name (tab name) */
|
||||||
@@ -43,6 +43,31 @@ export interface MuxSessionWithStats extends MuxSession {
|
|||||||
stats?: ProcessStats;
|
stats?: ProcessStats;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Options for creating a new multiplexer session. */
|
||||||
|
export interface CreateSessionOptions {
|
||||||
|
sessionId: string;
|
||||||
|
workingDir: string;
|
||||||
|
mode: SessionMode;
|
||||||
|
name?: string;
|
||||||
|
niceConfig?: NiceConfig;
|
||||||
|
model?: string;
|
||||||
|
claudeMode?: ClaudeMode;
|
||||||
|
allowedTools?: string;
|
||||||
|
openCodeConfig?: OpenCodeConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options for respawning a dead pane. */
|
||||||
|
export interface RespawnPaneOptions {
|
||||||
|
sessionId: string;
|
||||||
|
workingDir: string;
|
||||||
|
mode: SessionMode;
|
||||||
|
niceConfig?: NiceConfig;
|
||||||
|
model?: string;
|
||||||
|
claudeMode?: ClaudeMode;
|
||||||
|
allowedTools?: string;
|
||||||
|
openCodeConfig?: OpenCodeConfig;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Terminal multiplexer interface.
|
* Terminal multiplexer interface.
|
||||||
*
|
*
|
||||||
@@ -62,18 +87,9 @@ export interface TerminalMultiplexer extends EventEmitter {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new multiplexer session.
|
* Create a new multiplexer session.
|
||||||
* The session runs the appropriate command (claude or shell) in detached mode.
|
* The session runs the appropriate command (claude, opencode, or shell) in detached mode.
|
||||||
*/
|
*/
|
||||||
createSession(
|
createSession(options: CreateSessionOptions): Promise<MuxSession>;
|
||||||
sessionId: string,
|
|
||||||
workingDir: string,
|
|
||||||
mode: 'claude' | 'shell',
|
|
||||||
name?: string,
|
|
||||||
niceConfig?: NiceConfig,
|
|
||||||
model?: string,
|
|
||||||
claudeMode?: ClaudeMode,
|
|
||||||
allowedTools?: string,
|
|
||||||
): Promise<MuxSession>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kill a session and all its child processes.
|
* Kill a session and all its child processes.
|
||||||
@@ -165,5 +181,5 @@ export interface TerminalMultiplexer extends EventEmitter {
|
|||||||
isPaneDead(muxName: string): boolean;
|
isPaneDead(muxName: string): boolean;
|
||||||
|
|
||||||
/** Respawn a dead pane with a fresh command. Returns the new PID or null on failure. */
|
/** Respawn a dead pane with a fresh command. Returns the new PID or null on failure. */
|
||||||
respawnPane(sessionId: string, workingDir: string, mode: 'claude' | 'shell', niceConfig?: NiceConfig, model?: string, claudeMode?: ClaudeMode, allowedTools?: string): Promise<number | null>;
|
respawnPane(options: RespawnPaneOptions): Promise<number | null>;
|
||||||
}
|
}
|
||||||
|
|||||||
+72
-39
@@ -18,7 +18,7 @@
|
|||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import * as pty from 'node-pty';
|
import * as pty from 'node-pty';
|
||||||
import { SessionState, SessionStatus, SessionConfig, RalphTrackerState, RalphTodoItem, ActiveBashTool, NiceConfig, DEFAULT_NICE_CONFIG, type ClaudeMode } from './types.js';
|
import { SessionState, SessionStatus, SessionConfig, RalphTrackerState, RalphTodoItem, ActiveBashTool, NiceConfig, DEFAULT_NICE_CONFIG, type ClaudeMode, type SessionMode, type OpenCodeConfig } from './types.js';
|
||||||
import type { TerminalMultiplexer, MuxSession } from './mux-interface.js';
|
import type { TerminalMultiplexer, MuxSession } from './mux-interface.js';
|
||||||
import { TaskTracker, type BackgroundTask } from './task-tracker.js';
|
import { TaskTracker, type BackgroundTask } from './task-tracker.js';
|
||||||
import { RalphTracker } from './ralph-tracker.js';
|
import { RalphTracker } from './ralph-tracker.js';
|
||||||
@@ -196,12 +196,9 @@ export interface SessionEvents {
|
|||||||
cliInfoUpdated: (info: { version: string | null; model: string | null; accountType: string | null; latestVersion: string | null }) => void;
|
cliInfoUpdated: (info: { version: string | null; model: string | null; accountType: string | null; latestVersion: string | null }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// SessionMode is imported from types.ts (single source of truth)
|
||||||
* Session operation mode.
|
// Re-export for backwards compatibility with any external consumers
|
||||||
* - `'claude'`: Runs Claude CLI for AI interactions (default)
|
export type { SessionMode } from './types.js';
|
||||||
* - `'shell'`: Runs a plain bash shell for debugging/testing
|
|
||||||
*/
|
|
||||||
export type SessionMode = 'claude' | 'shell';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Core session class that wraps a PTY process running Claude CLI or a shell.
|
* Core session class that wraps a PTY process running Claude CLI or a shell.
|
||||||
@@ -325,6 +322,9 @@ export class Session extends EventEmitter {
|
|||||||
private _claudeMode: ClaudeMode = 'dangerously-skip-permissions';
|
private _claudeMode: ClaudeMode = 'dangerously-skip-permissions';
|
||||||
private _allowedTools: string | undefined;
|
private _allowedTools: string | undefined;
|
||||||
|
|
||||||
|
// OpenCode configuration (only for mode === 'opencode')
|
||||||
|
private _openCodeConfig: OpenCodeConfig | undefined;
|
||||||
|
|
||||||
// Session color for visual differentiation
|
// Session color for visual differentiation
|
||||||
private _color: import('./types.js').SessionColor = 'default';
|
private _color: import('./types.js').SessionColor = 'default';
|
||||||
|
|
||||||
@@ -382,6 +382,8 @@ export class Session extends EventEmitter {
|
|||||||
claudeMode?: ClaudeMode;
|
claudeMode?: ClaudeMode;
|
||||||
/** Comma-separated allowed tools (for 'allowedTools' mode) */
|
/** Comma-separated allowed tools (for 'allowedTools' mode) */
|
||||||
allowedTools?: string;
|
allowedTools?: string;
|
||||||
|
/** OpenCode configuration (only for mode === 'opencode') */
|
||||||
|
openCodeConfig?: OpenCodeConfig;
|
||||||
}) {
|
}) {
|
||||||
super();
|
super();
|
||||||
this.setMaxListeners(25);
|
this.setMaxListeners(25);
|
||||||
@@ -425,6 +427,11 @@ export class Session extends EventEmitter {
|
|||||||
this._allowedTools = config.allowedTools;
|
this._allowedTools = config.allowedTools;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply OpenCode configuration
|
||||||
|
if (config.openCodeConfig) {
|
||||||
|
this._openCodeConfig = config.openCodeConfig;
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize task tracker and forward events (store handlers for cleanup)
|
// Initialize task tracker and forward events (store handlers for cleanup)
|
||||||
this._taskTracker = new TaskTracker();
|
this._taskTracker = new TaskTracker();
|
||||||
this._taskTrackerHandlers = {
|
this._taskTrackerHandlers = {
|
||||||
@@ -812,6 +819,7 @@ export class Session extends EventEmitter {
|
|||||||
cliModel: this._cliModel || undefined,
|
cliModel: this._cliModel || undefined,
|
||||||
cliAccountType: this._cliAccountType || undefined,
|
cliAccountType: this._cliAccountType || undefined,
|
||||||
cliLatestVersion: this._cliLatestVersion || undefined,
|
cliLatestVersion: this._cliLatestVersion || undefined,
|
||||||
|
openCodeConfig: this._openCodeConfig,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -902,7 +910,8 @@ export class Session extends EventEmitter {
|
|||||||
this._lineBuffer = '';
|
this._lineBuffer = '';
|
||||||
this._lastActivityAt = Date.now();
|
this._lastActivityAt = Date.now();
|
||||||
|
|
||||||
console.log('[Session] Starting interactive Claude session' + (this._useMux ? ` (with ${this._mux!.backend})` : ''));
|
const modeLabel = this.mode === 'opencode' ? 'OpenCode' : 'Claude';
|
||||||
|
console.log(`[Session] Starting interactive ${modeLabel} session` + (this._useMux ? ` (with ${this._mux!.backend})` : ''));
|
||||||
|
|
||||||
// If mux wrapping is enabled, create or attach to a mux session
|
// If mux wrapping is enabled, create or attach to a mux session
|
||||||
if (this._useMux && this._mux) {
|
if (this._useMux && this._mux) {
|
||||||
@@ -918,7 +927,11 @@ export class Session extends EventEmitter {
|
|||||||
let needsNewSession = false;
|
let needsNewSession = false;
|
||||||
if (this._muxSession && this._mux.isPaneDead(this._muxSession.muxName)) {
|
if (this._muxSession && this._mux.isPaneDead(this._muxSession.muxName)) {
|
||||||
console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName);
|
console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName);
|
||||||
const newPid = await this._mux.respawnPane(this.id, this.workingDir, 'claude', this._niceConfig, this._model, this._claudeMode, this._allowedTools);
|
const newPid = await this._mux.respawnPane({
|
||||||
|
sessionId: this.id, workingDir: this.workingDir, mode: this.mode,
|
||||||
|
niceConfig: this._niceConfig, model: this._model, claudeMode: this._claudeMode,
|
||||||
|
allowedTools: this._allowedTools, openCodeConfig: this._openCodeConfig,
|
||||||
|
});
|
||||||
if (!newPid) {
|
if (!newPid) {
|
||||||
console.error('[Session] Failed to respawn pane, will create new session');
|
console.error('[Session] Failed to respawn pane, will create new session');
|
||||||
needsNewSession = true;
|
needsNewSession = true;
|
||||||
@@ -934,7 +947,12 @@ export class Session extends EventEmitter {
|
|||||||
console.log('[Session] Attaching to existing mux session:', this._muxSession!.muxName);
|
console.log('[Session] Attaching to existing mux session:', this._muxSession!.muxName);
|
||||||
} else {
|
} else {
|
||||||
// Create a new mux session
|
// Create a new mux session
|
||||||
this._muxSession = await this._mux.createSession(this.id, this.workingDir, 'claude', this._name, this._niceConfig, this._model, this._claudeMode, this._allowedTools);
|
this._muxSession = await this._mux.createSession({
|
||||||
|
sessionId: this.id, workingDir: this.workingDir, mode: this.mode,
|
||||||
|
name: this._name, niceConfig: this._niceConfig, model: this._model,
|
||||||
|
claudeMode: this._claudeMode, allowedTools: this._allowedTools,
|
||||||
|
openCodeConfig: this._openCodeConfig,
|
||||||
|
});
|
||||||
console.log('[Session] Created mux session:', this._muxSession.muxName);
|
console.log('[Session] Created mux session:', this._muxSession.muxName);
|
||||||
// No extra sleep — createSession() already waits for tmux readiness
|
// No extra sleep — createSession() already waits for tmux readiness
|
||||||
}
|
}
|
||||||
@@ -961,39 +979,50 @@ export class Session extends EventEmitter {
|
|||||||
throw spawnErr;
|
throw spawnErr;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For NEW mux sessions: wait for prompt to appear then clean buffer
|
// For NEW mux sessions: wait for readiness then clean buffer
|
||||||
// For RESTORED mux sessions: don't do anything - client will fetch buffer on tab switch
|
// For RESTORED mux sessions: don't do anything - client will fetch buffer on tab switch
|
||||||
if (!isRestoredSession) {
|
if (!isRestoredSession) {
|
||||||
this._promptCheckInterval = setInterval(() => {
|
if (this.mode === 'opencode') {
|
||||||
// Wait for the prompt character (❯) which means Claude is fully initialized
|
// OpenCode uses Bubble Tea TUI — no ❯ prompt to detect.
|
||||||
const bufferValue = this._terminalBuffer.value;
|
// Wait for TUI to stabilize (output stops changing), then mark ready.
|
||||||
if (bufferValue.includes('❯') || bufferValue.includes('\u276f')) {
|
// Don't clear the buffer — the TUI's initial render IS the useful content.
|
||||||
|
this._promptCheckTimeout = setTimeout(() => {
|
||||||
|
this._promptCheckTimeout = null;
|
||||||
|
this._status = 'idle';
|
||||||
|
}, 3000);
|
||||||
|
} else {
|
||||||
|
// Claude mode: wait for ❯ prompt
|
||||||
|
this._promptCheckInterval = setInterval(() => {
|
||||||
|
// Wait for the prompt character (❯) which means Claude is fully initialized
|
||||||
|
const bufferValue = this._terminalBuffer.value;
|
||||||
|
if (bufferValue.includes('❯') || bufferValue.includes('\u276f')) {
|
||||||
|
if (this._promptCheckInterval) {
|
||||||
|
clearInterval(this._promptCheckInterval);
|
||||||
|
this._promptCheckInterval = null;
|
||||||
|
}
|
||||||
|
if (this._promptCheckTimeout) {
|
||||||
|
clearTimeout(this._promptCheckTimeout);
|
||||||
|
this._promptCheckTimeout = null;
|
||||||
|
}
|
||||||
|
// Clean the buffer - remove mux init junk before actual content
|
||||||
|
// Strip: cursor movement (\x1b[nA/B/C/D), positioning (\x1b[n;nH),
|
||||||
|
// clear screen (\x1b[2J), scroll region (\x1b[n;nr), and whitespace
|
||||||
|
this._terminalBuffer.set(
|
||||||
|
bufferValue.replace(LEADING_ANSI_WHITESPACE_PATTERN, '')
|
||||||
|
);
|
||||||
|
// Signal client to refresh
|
||||||
|
this.emit('clearTerminal');
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
// Timeout after 5 seconds if prompt not found
|
||||||
|
this._promptCheckTimeout = setTimeout(() => {
|
||||||
if (this._promptCheckInterval) {
|
if (this._promptCheckInterval) {
|
||||||
clearInterval(this._promptCheckInterval);
|
clearInterval(this._promptCheckInterval);
|
||||||
this._promptCheckInterval = null;
|
this._promptCheckInterval = null;
|
||||||
}
|
}
|
||||||
if (this._promptCheckTimeout) {
|
this._promptCheckTimeout = null;
|
||||||
clearTimeout(this._promptCheckTimeout);
|
}, 5000);
|
||||||
this._promptCheckTimeout = null;
|
}
|
||||||
}
|
|
||||||
// Clean the buffer - remove mux init junk before actual content
|
|
||||||
// Strip: cursor movement (\x1b[nA/B/C/D), positioning (\x1b[n;nH),
|
|
||||||
// clear screen (\x1b[2J), scroll region (\x1b[n;nr), and whitespace
|
|
||||||
this._terminalBuffer.set(
|
|
||||||
bufferValue.replace(LEADING_ANSI_WHITESPACE_PATTERN, '')
|
|
||||||
);
|
|
||||||
// Signal client to refresh
|
|
||||||
this.emit('clearTerminal');
|
|
||||||
}
|
|
||||||
}, 50);
|
|
||||||
// Timeout after 5 seconds if prompt not found
|
|
||||||
this._promptCheckTimeout = setTimeout(() => {
|
|
||||||
if (this._promptCheckInterval) {
|
|
||||||
clearInterval(this._promptCheckInterval);
|
|
||||||
this._promptCheckInterval = null;
|
|
||||||
}
|
|
||||||
this._promptCheckTimeout = null;
|
|
||||||
}, 5000);
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Session] Failed to create mux session, falling back to direct PTY:', err);
|
console.error('[Session] Failed to create mux session, falling back to direct PTY:', err);
|
||||||
@@ -1173,6 +1202,10 @@ export class Session extends EventEmitter {
|
|||||||
* PTY data chunk. Receives accumulated raw data to process in one batch.
|
* PTY data chunk. Receives accumulated raw data to process in one batch.
|
||||||
*/
|
*/
|
||||||
private _processExpensiveParsers(rawData: string): void {
|
private _processExpensiveParsers(rawData: string): void {
|
||||||
|
// Skip Claude-specific parsers for OpenCode sessions — Ralph tracker, BashToolParser,
|
||||||
|
// token parsing, and CLI info parsing all depend on Claude's output format.
|
||||||
|
if (this.mode === 'opencode') return;
|
||||||
|
|
||||||
// Lazy ANSI strip: only compute cleanData when a consumer actually needs it.
|
// Lazy ANSI strip: only compute cleanData when a consumer actually needs it.
|
||||||
let _cleanData: string | null = null;
|
let _cleanData: string | null = null;
|
||||||
const getCleanData = (): string => {
|
const getCleanData = (): string => {
|
||||||
@@ -1267,7 +1300,7 @@ export class Session extends EventEmitter {
|
|||||||
let needsNewSession = false;
|
let needsNewSession = false;
|
||||||
if (this._muxSession && this._mux.isPaneDead(this._muxSession.muxName)) {
|
if (this._muxSession && this._mux.isPaneDead(this._muxSession.muxName)) {
|
||||||
console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName);
|
console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName);
|
||||||
const newPid = await this._mux.respawnPane(this.id, this.workingDir, 'shell', this._niceConfig);
|
const newPid = await this._mux.respawnPane({ sessionId: this.id, workingDir: this.workingDir, mode: 'shell', niceConfig: this._niceConfig });
|
||||||
if (!newPid) {
|
if (!newPid) {
|
||||||
console.error('[Session] Failed to respawn pane, will create new session');
|
console.error('[Session] Failed to respawn pane, will create new session');
|
||||||
needsNewSession = true;
|
needsNewSession = true;
|
||||||
@@ -1282,7 +1315,7 @@ export class Session extends EventEmitter {
|
|||||||
console.log('[Session] Attaching to existing mux session:', this._muxSession!.muxName);
|
console.log('[Session] Attaching to existing mux session:', this._muxSession!.muxName);
|
||||||
} else {
|
} else {
|
||||||
// Create a new mux session
|
// Create a new mux session
|
||||||
this._muxSession = await this._mux.createSession(this.id, this.workingDir, 'shell', this._name, this._niceConfig);
|
this._muxSession = await this._mux.createSession({ sessionId: this.id, workingDir: this.workingDir, mode: 'shell', name: this._name, niceConfig: this._niceConfig });
|
||||||
console.log('[Session] Created mux session:', this._muxSession.muxName);
|
console.log('[Session] Created mux session:', this._muxSession.muxName);
|
||||||
// No extra sleep — createSession() already waits for tmux readiness
|
// No extra sleep — createSession() already waits for tmux readiness
|
||||||
}
|
}
|
||||||
|
|||||||
+166
-37
@@ -30,13 +30,15 @@ import { existsSync, readFileSync, mkdirSync } from 'node:fs';
|
|||||||
import { writeFile, rename } from 'node:fs/promises';
|
import { writeFile, rename } from 'node:fs/promises';
|
||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { ProcessStats, PersistedRespawnConfig, getErrorMessage, NiceConfig, DEFAULT_NICE_CONFIG, type PaneInfo, type ClaudeMode } from './types.js';
|
import { ProcessStats, PersistedRespawnConfig, getErrorMessage, DEFAULT_NICE_CONFIG, type PaneInfo, type ClaudeMode, type SessionMode, type OpenCodeConfig } from './types.js';
|
||||||
import { wrapWithNice } from './utils/nice-wrapper.js';
|
import { wrapWithNice } from './utils/nice-wrapper.js';
|
||||||
import { SAFE_PATH_PATTERN } from './utils/regex-patterns.js';
|
import { SAFE_PATH_PATTERN } from './utils/regex-patterns.js';
|
||||||
import type { TerminalMultiplexer, MuxSession, MuxSessionWithStats } from './mux-interface.js';
|
import type { TerminalMultiplexer, MuxSession, MuxSessionWithStats, CreateSessionOptions, RespawnPaneOptions } from './mux-interface.js';
|
||||||
|
|
||||||
// Claude CLI PATH resolution — shared utility
|
// Claude CLI PATH resolution — shared utility
|
||||||
import { findClaudeDir } from './utils/claude-cli-resolver.js';
|
import { findClaudeDir } from './utils/claude-cli-resolver.js';
|
||||||
|
// OpenCode CLI PATH resolution
|
||||||
|
import { resolveOpenCodeDir } from './utils/opencode-cli-resolver.js';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Timing Constants
|
// Timing Constants
|
||||||
@@ -136,6 +138,116 @@ function buildClaudePermissionFlags(claudeMode?: ClaudeMode, allowedTools?: stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the opencode CLI command with appropriate flags.
|
||||||
|
*/
|
||||||
|
function buildOpenCodeCommand(config?: OpenCodeConfig): string {
|
||||||
|
const parts = ['opencode'];
|
||||||
|
|
||||||
|
// Model selection — allow provider/model format (alphanumeric, dots, hyphens, slashes)
|
||||||
|
if (config?.model) {
|
||||||
|
const safeModel = /^[a-zA-Z0-9._\-/]+$/.test(config.model) ? config.model : undefined;
|
||||||
|
if (safeModel) parts.push('--model', safeModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continue existing session
|
||||||
|
if (config?.continueSession) {
|
||||||
|
const safeId = /^[a-zA-Z0-9_-]+$/.test(config.continueSession) ? config.continueSession : undefined;
|
||||||
|
if (safeId) parts.push('--session', safeId);
|
||||||
|
if (config.forkSession) parts.push('--fork');
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the spawn command for any session mode.
|
||||||
|
* Shared by createSession() and respawnPane() to avoid duplication.
|
||||||
|
*/
|
||||||
|
function buildSpawnCommand(options: {
|
||||||
|
mode: SessionMode;
|
||||||
|
sessionId: string;
|
||||||
|
model?: string;
|
||||||
|
claudeMode?: ClaudeMode;
|
||||||
|
allowedTools?: string;
|
||||||
|
openCodeConfig?: OpenCodeConfig;
|
||||||
|
}): string {
|
||||||
|
if (options.mode === 'claude') {
|
||||||
|
// Validate model to prevent command injection
|
||||||
|
const safeModel = (options.model && /^[a-zA-Z0-9._-]+$/.test(options.model)) ? options.model : undefined;
|
||||||
|
const modelFlag = safeModel ? ` --model ${safeModel}` : '';
|
||||||
|
return `claude${buildClaudePermissionFlags(options.claudeMode, options.allowedTools)} --session-id "${options.sessionId}"${modelFlag}`;
|
||||||
|
}
|
||||||
|
if (options.mode === 'opencode') {
|
||||||
|
return buildOpenCodeCommand(options.openCodeConfig);
|
||||||
|
}
|
||||||
|
return '$SHELL';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set sensitive environment variables on a tmux session via setenv.
|
||||||
|
* These are inherited by panes but not visible in ps output or tmux history.
|
||||||
|
*/
|
||||||
|
function setOpenCodeEnvVars(muxName: string): void {
|
||||||
|
const sensitiveVars = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'GOOGLE_API_KEY'];
|
||||||
|
for (const key of sensitiveVars) {
|
||||||
|
const val = process.env[key];
|
||||||
|
if (val) {
|
||||||
|
// Shell-escape: wrap in single quotes, escape any inner single quotes
|
||||||
|
const escaped = val.replace(/'/g, "'\\''");
|
||||||
|
try {
|
||||||
|
execSync(`tmux setenv -t '${muxName}' ${key} '${escaped}'`, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
} catch { /* Non-critical — key may not be needed */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set OPENCODE_CONFIG_CONTENT on a tmux session via setenv.
|
||||||
|
* Uses tmux setenv to avoid shell metacharacter injection from user-supplied JSON.
|
||||||
|
*/
|
||||||
|
function setOpenCodeConfigContent(muxName: string, config?: OpenCodeConfig): void {
|
||||||
|
if (!config) return;
|
||||||
|
|
||||||
|
let jsonContent: string | undefined;
|
||||||
|
|
||||||
|
if (config.autoAllowTools) {
|
||||||
|
const permConfig: Record<string, unknown> = { permission: { '*': 'allow' } };
|
||||||
|
if (config.configContent) {
|
||||||
|
try {
|
||||||
|
const existing = JSON.parse(config.configContent) as Record<string, unknown>;
|
||||||
|
Object.assign(permConfig, existing);
|
||||||
|
permConfig.permission = { '*': 'allow' };
|
||||||
|
} catch { /* invalid JSON, use default permConfig */ }
|
||||||
|
}
|
||||||
|
jsonContent = JSON.stringify(permConfig);
|
||||||
|
} else if (config.configContent) {
|
||||||
|
// Validate JSON to prevent garbage config
|
||||||
|
try {
|
||||||
|
JSON.parse(config.configContent);
|
||||||
|
jsonContent = config.configContent;
|
||||||
|
} catch {
|
||||||
|
console.error('[TmuxManager] Invalid JSON in openCodeConfig.configContent, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jsonContent) {
|
||||||
|
const escaped = jsonContent.replace(/'/g, "'\\''");
|
||||||
|
try {
|
||||||
|
execSync(`tmux setenv -t '${muxName}' OPENCODE_CONFIG_CONTENT '${escaped}'`, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
} catch { /* Non-critical */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages tmux sessions that wrap Claude CLI or shell processes.
|
* Manages tmux sessions that wrap Claude CLI or shell processes.
|
||||||
*
|
*
|
||||||
@@ -222,16 +334,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
* Creates a new tmux session wrapping Claude CLI or a shell.
|
* Creates a new tmux session wrapping Claude CLI or a shell.
|
||||||
* In test mode: creates an in-memory session only (no real tmux session).
|
* In test mode: creates an in-memory session only (no real tmux session).
|
||||||
*/
|
*/
|
||||||
async createSession(
|
async createSession(options: CreateSessionOptions): Promise<MuxSession> {
|
||||||
sessionId: string,
|
const { sessionId, workingDir, mode, name, niceConfig, model, claudeMode, allowedTools, openCodeConfig } = options;
|
||||||
workingDir: string,
|
|
||||||
mode: 'claude' | 'shell',
|
|
||||||
name?: string,
|
|
||||||
niceConfig?: NiceConfig,
|
|
||||||
model?: string,
|
|
||||||
claudeMode?: ClaudeMode,
|
|
||||||
allowedTools?: string,
|
|
||||||
): Promise<MuxSession> {
|
|
||||||
const muxName = `claudeman-${sessionId.slice(0, 8)}`;
|
const muxName = `claudeman-${sessionId.slice(0, 8)}`;
|
||||||
|
|
||||||
if (!isValidMuxName(muxName)) {
|
if (!isValidMuxName(muxName)) {
|
||||||
@@ -258,36 +362,43 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
const claudeDir = findClaudeDir();
|
// Resolve CLI binary directory based on mode
|
||||||
if (!claudeDir && mode === 'claude') {
|
let pathExport = '';
|
||||||
throw new Error('Claude CLI not found. Install it with: curl -fsSL https://claude.ai/install.sh | bash');
|
if (mode === 'claude') {
|
||||||
|
const claudeDir = findClaudeDir();
|
||||||
|
if (!claudeDir) {
|
||||||
|
throw new Error('Claude CLI not found. Install it with: curl -fsSL https://claude.ai/install.sh | bash');
|
||||||
|
}
|
||||||
|
pathExport = `export PATH="${claudeDir}:$PATH" && `;
|
||||||
|
} else if (mode === 'opencode') {
|
||||||
|
const openCodeDir = resolveOpenCodeDir();
|
||||||
|
if (!openCodeDir) {
|
||||||
|
throw new Error('OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash');
|
||||||
|
}
|
||||||
|
pathExport = `export PATH="${openCodeDir}:$PATH" && `;
|
||||||
}
|
}
|
||||||
const pathExport = claudeDir ? `export PATH="${claudeDir}:$PATH" && ` : '';
|
|
||||||
|
|
||||||
const envExports = [
|
const envExports = [
|
||||||
'export LANG=en_US.UTF-8',
|
'export LANG=en_US.UTF-8',
|
||||||
'export LC_ALL=en_US.UTF-8',
|
'export LC_ALL=en_US.UTF-8',
|
||||||
'unset CLAUDECODE',
|
|
||||||
'unset COLORTERM',
|
'unset COLORTERM',
|
||||||
'export CLAUDEMAN_MUX=1',
|
'export CLAUDEMAN_MUX=1',
|
||||||
`export CLAUDEMAN_SESSION_ID=${sessionId}`,
|
`export CLAUDEMAN_SESSION_ID=${sessionId}`,
|
||||||
`export CLAUDEMAN_MUX_NAME=${muxName}`,
|
`export CLAUDEMAN_MUX_NAME=${muxName}`,
|
||||||
`export CLAUDEMAN_API_URL=${process.env.CLAUDEMAN_API_URL || 'http://localhost:3000'}`,
|
`export CLAUDEMAN_API_URL=${process.env.CLAUDEMAN_API_URL || 'http://localhost:3000'}`,
|
||||||
].join(' && ');
|
];
|
||||||
|
// Only unset CLAUDECODE for Claude sessions
|
||||||
|
if (mode === 'claude') envExports.splice(2, 0, 'unset CLAUDECODE');
|
||||||
|
const envExportsStr = envExports.join(' && ');
|
||||||
|
|
||||||
// Validate model to prevent command injection (only allow alphanumeric, hyphens, dots)
|
const baseCmd = buildSpawnCommand({ mode, sessionId, model, claudeMode, allowedTools, openCodeConfig });
|
||||||
const safeModel = (model && /^[a-zA-Z0-9._-]+$/.test(model)) ? model : undefined;
|
|
||||||
const modelFlag = (mode === 'claude' && safeModel) ? ` --model ${safeModel}` : '';
|
|
||||||
const baseCmd = mode === 'claude'
|
|
||||||
? `claude${buildClaudePermissionFlags(claudeMode, allowedTools)} --session-id "${sessionId}"${modelFlag}`
|
|
||||||
: '$SHELL';
|
|
||||||
|
|
||||||
const config = niceConfig || DEFAULT_NICE_CONFIG;
|
const config = niceConfig || DEFAULT_NICE_CONFIG;
|
||||||
const cmd = wrapWithNice(baseCmd, config);
|
const cmd = wrapWithNice(baseCmd, config);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Build the full command to run inside tmux
|
// Build the full command to run inside tmux
|
||||||
const fullCmd = `${pathExport}${envExports} && ${cmd}`;
|
const fullCmd = `${pathExport}${envExportsStr} && ${cmd}`;
|
||||||
|
|
||||||
// Create tmux session in three steps to handle cold-start (no server running)
|
// Create tmux session in three steps to handle cold-start (no server running)
|
||||||
// and avoid the race where the command exits before remain-on-exit is set:
|
// and avoid the race where the command exits before remain-on-exit is set:
|
||||||
@@ -304,6 +415,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
execSync(`tmux set-option -t "${muxName}" remain-on-exit on`, { timeout: EXEC_TIMEOUT_MS, stdio: 'ignore' });
|
execSync(`tmux set-option -t "${muxName}" remain-on-exit on`, { timeout: EXEC_TIMEOUT_MS, stdio: 'ignore' });
|
||||||
} catch { /* Non-critical */ }
|
} catch { /* Non-critical */ }
|
||||||
|
|
||||||
|
// For OpenCode: set sensitive env vars and config via tmux setenv
|
||||||
|
// (not visible in ps output or tmux history, inherited by panes)
|
||||||
|
if (mode === 'opencode') {
|
||||||
|
setOpenCodeEnvVars(muxName);
|
||||||
|
setOpenCodeConfigContent(muxName, openCodeConfig);
|
||||||
|
}
|
||||||
|
|
||||||
// Replace the shell with the actual command (no echo in terminal)
|
// Replace the shell with the actual command (no echo in terminal)
|
||||||
execSync(
|
execSync(
|
||||||
`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`,
|
`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`,
|
||||||
@@ -423,37 +541,48 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
* Uses `tmux respawn-pane -k` to restart the command in the same pane,
|
* Uses `tmux respawn-pane -k` to restart the command in the same pane,
|
||||||
* preserving the session and its scrollback buffer.
|
* preserving the session and its scrollback buffer.
|
||||||
*/
|
*/
|
||||||
async respawnPane(sessionId: string, workingDir: string, mode: 'claude' | 'shell', niceConfig?: NiceConfig, model?: string, claudeMode?: ClaudeMode, allowedTools?: string): Promise<number | null> {
|
async respawnPane(options: RespawnPaneOptions): Promise<number | null> {
|
||||||
|
const { sessionId, workingDir, mode, niceConfig, model, claudeMode, allowedTools, openCodeConfig } = options;
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (!session) return null;
|
if (!session) return null;
|
||||||
const muxName = session.muxName;
|
const muxName = session.muxName;
|
||||||
|
|
||||||
if (!isValidMuxName(muxName) || !isValidPath(workingDir)) return null;
|
if (!isValidMuxName(muxName) || !isValidPath(workingDir)) return null;
|
||||||
|
|
||||||
const claudeDir = findClaudeDir();
|
// Resolve CLI binary directory based on mode
|
||||||
const pathExport = claudeDir ? `export PATH="${claudeDir}:$PATH" && ` : '';
|
let pathExport = '';
|
||||||
|
if (mode === 'claude') {
|
||||||
|
const claudeDir = findClaudeDir();
|
||||||
|
pathExport = claudeDir ? `export PATH="${claudeDir}:$PATH" && ` : '';
|
||||||
|
} else if (mode === 'opencode') {
|
||||||
|
const openCodeDir = resolveOpenCodeDir();
|
||||||
|
pathExport = openCodeDir ? `export PATH="${openCodeDir}:$PATH" && ` : '';
|
||||||
|
}
|
||||||
|
|
||||||
const envExports = [
|
const envExports = [
|
||||||
'export LANG=en_US.UTF-8',
|
'export LANG=en_US.UTF-8',
|
||||||
'export LC_ALL=en_US.UTF-8',
|
'export LC_ALL=en_US.UTF-8',
|
||||||
'unset CLAUDECODE',
|
|
||||||
'unset COLORTERM',
|
'unset COLORTERM',
|
||||||
'export CLAUDEMAN_MUX=1',
|
'export CLAUDEMAN_MUX=1',
|
||||||
`export CLAUDEMAN_SESSION_ID=${sessionId}`,
|
`export CLAUDEMAN_SESSION_ID=${sessionId}`,
|
||||||
`export CLAUDEMAN_MUX_NAME=${muxName}`,
|
`export CLAUDEMAN_MUX_NAME=${muxName}`,
|
||||||
`export CLAUDEMAN_API_URL=${process.env.CLAUDEMAN_API_URL || 'http://localhost:3000'}`,
|
`export CLAUDEMAN_API_URL=${process.env.CLAUDEMAN_API_URL || 'http://localhost:3000'}`,
|
||||||
].join(' && ');
|
];
|
||||||
|
if (mode === 'claude') envExports.splice(2, 0, 'unset CLAUDECODE');
|
||||||
const safeModel = (model && /^[a-zA-Z0-9._-]+$/.test(model)) ? model : undefined;
|
const envExportsStr = envExports.join(' && ');
|
||||||
const modelFlag = (mode === 'claude' && safeModel) ? ` --model ${safeModel}` : '';
|
|
||||||
const baseCmd = mode === 'claude'
|
|
||||||
? `claude${buildClaudePermissionFlags(claudeMode, allowedTools)} --session-id "${sessionId}"${modelFlag}`
|
|
||||||
: '$SHELL';
|
|
||||||
|
|
||||||
|
const baseCmd = buildSpawnCommand({ mode, sessionId, model, claudeMode, allowedTools, openCodeConfig });
|
||||||
const config = niceConfig || DEFAULT_NICE_CONFIG;
|
const config = niceConfig || DEFAULT_NICE_CONFIG;
|
||||||
const cmd = wrapWithNice(baseCmd, config);
|
const cmd = wrapWithNice(baseCmd, config);
|
||||||
const fullCmd = `${pathExport}${envExports} && ${cmd}`;
|
const fullCmd = `${pathExport}${envExportsStr} && ${cmd}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// For OpenCode: set sensitive env vars via tmux setenv before respawn
|
||||||
|
if (mode === 'opencode') {
|
||||||
|
setOpenCodeEnvVars(muxName);
|
||||||
|
setOpenCodeConfigContent(muxName, openCodeConfig);
|
||||||
|
}
|
||||||
|
|
||||||
await execAsync(
|
await execAsync(
|
||||||
`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`,
|
`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`,
|
||||||
{ timeout: EXEC_TIMEOUT_MS }
|
{ timeout: EXEC_TIMEOUT_MS }
|
||||||
|
|||||||
+21
-2
@@ -113,6 +113,23 @@ export interface LifecycleEntry {
|
|||||||
*/
|
*/
|
||||||
export type ClaudeMode = 'dangerously-skip-permissions' | 'normal' | 'allowedTools';
|
export type ClaudeMode = 'dangerously-skip-permissions' | 'normal' | 'allowedTools';
|
||||||
|
|
||||||
|
/** Session mode: which CLI backend a session runs */
|
||||||
|
export type SessionMode = 'claude' | 'shell' | 'opencode';
|
||||||
|
|
||||||
|
/** OpenCode session configuration */
|
||||||
|
export interface OpenCodeConfig {
|
||||||
|
/** Model identifier (e.g., "anthropic/claude-sonnet-4-5", "openai/gpt-5.2", "ollama/codellama") */
|
||||||
|
model?: string;
|
||||||
|
/** Whether to auto-allow all tool executions (sets permission.* = allow) */
|
||||||
|
autoAllowTools?: boolean;
|
||||||
|
/** Session ID to continue from */
|
||||||
|
continueSession?: string;
|
||||||
|
/** Whether to fork when continuing (branch the conversation) */
|
||||||
|
forkSession?: boolean;
|
||||||
|
/** Custom inline config JSON (passed via OPENCODE_CONFIG_CONTENT) */
|
||||||
|
configContent?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration for creating a new session
|
* Configuration for creating a new session
|
||||||
*/
|
*/
|
||||||
@@ -158,8 +175,8 @@ export interface SessionState {
|
|||||||
lastActivityAt: number;
|
lastActivityAt: number;
|
||||||
/** Session display name */
|
/** Session display name */
|
||||||
name?: string;
|
name?: string;
|
||||||
/** Session mode: 'claude' or 'shell' */
|
/** Session mode */
|
||||||
mode?: 'claude' | 'shell';
|
mode?: SessionMode;
|
||||||
/** Auto-clear enabled */
|
/** Auto-clear enabled */
|
||||||
autoClearEnabled?: boolean;
|
autoClearEnabled?: boolean;
|
||||||
/** Auto-clear token threshold */
|
/** Auto-clear token threshold */
|
||||||
@@ -208,6 +225,8 @@ export interface SessionState {
|
|||||||
cliAccountType?: string;
|
cliAccountType?: string;
|
||||||
/** Latest CLI version available (parsed from version check) */
|
/** Latest CLI version available (parsed from version check) */
|
||||||
cliLatestVersion?: string;
|
cliLatestVersion?: string;
|
||||||
|
/** OpenCode-specific configuration (only for mode === 'opencode') */
|
||||||
|
openCodeConfig?: OpenCodeConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Global Stats Types ==========
|
// ========== Global Stats Types ==========
|
||||||
|
|||||||
@@ -33,3 +33,4 @@ export {
|
|||||||
export { assertNever } from './type-safety.js';
|
export { assertNever } from './type-safety.js';
|
||||||
export { wrapWithNice } from './nice-wrapper.js';
|
export { wrapWithNice } from './nice-wrapper.js';
|
||||||
export { findClaudeDir, getAugmentedPath } from './claude-cli-resolver.js';
|
export { findClaudeDir, getAugmentedPath } from './claude-cli-resolver.js';
|
||||||
|
export { resolveOpenCodeDir, isOpenCodeAvailable, getOpenCodeAugmentedPath } from './opencode-cli-resolver.js';
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Resolve the OpenCode CLI binary across common install paths.
|
||||||
|
*
|
||||||
|
* Mirrors claude-cli-resolver.ts pattern. Finds the `opencode` binary
|
||||||
|
* and provides an augmented PATH string for tmux sessions.
|
||||||
|
*
|
||||||
|
* @module utils/opencode-cli-resolver
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { delimiter, dirname, join } from 'node:path';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
|
||||||
|
/** Timeout for exec commands (5 seconds) */
|
||||||
|
const EXEC_TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
|
/** Common directories where the OpenCode CLI binary may be installed */
|
||||||
|
const OPENCODE_SEARCH_DIRS = [
|
||||||
|
join(homedir(), '.opencode', 'bin'), // Default install location
|
||||||
|
join(homedir(), '.local', 'bin'), // Alternative install location
|
||||||
|
'/usr/local/bin', // Homebrew / system
|
||||||
|
join(homedir(), 'go', 'bin'), // Go install
|
||||||
|
join(homedir(), '.bun', 'bin'), // Bun global
|
||||||
|
join(homedir(), '.npm-global', 'bin'), // npm global
|
||||||
|
join(homedir(), 'bin'), // User bin
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Cached directory containing the opencode binary (empty string = searched but not found) */
|
||||||
|
let _openCodeDir: string | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the directory containing the `opencode` binary.
|
||||||
|
* Checks `which opencode` first, then falls back to common install locations.
|
||||||
|
* Result is cached for subsequent calls.
|
||||||
|
*
|
||||||
|
* @returns Directory path, or null if not found
|
||||||
|
*/
|
||||||
|
export function resolveOpenCodeDir(): string | null {
|
||||||
|
if (_openCodeDir !== null) return _openCodeDir || null;
|
||||||
|
|
||||||
|
// Try `which` first (respects current PATH)
|
||||||
|
try {
|
||||||
|
const result = execSync('which opencode', { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }).trim();
|
||||||
|
if (result && existsSync(result)) {
|
||||||
|
_openCodeDir = dirname(result);
|
||||||
|
return _openCodeDir;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// OpenCode not in PATH, will check common locations
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: check common installation directories
|
||||||
|
for (const dir of OPENCODE_SEARCH_DIRS) {
|
||||||
|
if (existsSync(join(dir, 'opencode'))) {
|
||||||
|
_openCodeDir = dir;
|
||||||
|
return _openCodeDir;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_openCodeDir = ''; // mark as searched, not found
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if OpenCode CLI is available on the system.
|
||||||
|
*/
|
||||||
|
export function isOpenCodeAvailable(): boolean {
|
||||||
|
return resolveOpenCodeDir() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a PATH string that includes the directory containing `opencode`.
|
||||||
|
*
|
||||||
|
* Finds the opencode binary (via `which` or common install locations), then
|
||||||
|
* prepends its directory to the current PATH if not already present.
|
||||||
|
* Result is cached for subsequent calls.
|
||||||
|
*/
|
||||||
|
export function getOpenCodeAugmentedPath(): string {
|
||||||
|
const currentPath = process.env.PATH || '';
|
||||||
|
const dir = resolveOpenCodeDir();
|
||||||
|
|
||||||
|
if (dir && !currentPath.split(delimiter).includes(dir)) {
|
||||||
|
return `${dir}${delimiter}${currentPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset cached resolution (for testing).
|
||||||
|
*/
|
||||||
|
export function resetOpenCodeCache(): void {
|
||||||
|
_openCodeDir = null;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user