diff --git a/build/icon.ico b/build/icon.ico new file mode 100644 index 00000000..af7fa09c Binary files /dev/null and b/build/icon.ico differ diff --git a/electron-builder.yml b/electron-builder.yml index 5fd069db..4dd2b95c 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -30,6 +30,9 @@ extraResources: - from: public/ to: standalone/public/ filter: ["**/*"] + - from: build/ + to: . + filter: ["icon.*"] asarUnpack: - "**/*.node" - "**/better-sqlite3/**" @@ -39,3 +42,14 @@ mac: target: - target: dmg arch: [arm64] +win: + icon: build/icon.ico + target: + - target: nsis + arch: [x64, arm64] +nsis: + oneClick: false + perMachine: false + allowToChangeInstallationDirectory: true + createDesktopShortcut: true + createStartMenuShortcut: true diff --git a/electron/main.ts b/electron/main.ts index 4bbf332a..df2b0d38 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -18,6 +18,10 @@ const isDev = !app.isPackaged; * and won't include vars from .zshrc/.bashrc (e.g. API keys). */ function loadUserShellEnv(): Record { + // Only macOS needs login-shell env loading; Windows/Linux GUI apps inherit full env + if (process.platform !== 'darwin') { + return {}; + } try { const shell = process.env.SHELL || '/bin/zsh'; const result = execFileSync(shell, ['-ilc', 'env'], { @@ -106,12 +110,32 @@ function startServer(port: number): ChildProcess { serverErrors = []; const home = os.homedir(); - const basePath = `/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin`; const shellPath = userShellEnv.PATH || process.env.PATH || ''; + const sep = path.delimiter; // ';' on Windows, ':' on Unix + + let constructedPath: string; + if (process.platform === 'win32') { + const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming'); + const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'); + const winExtra = [ + path.join(appData, 'npm'), + path.join(localAppData, 'npm'), + path.join(home, '.npm-global', 'bin'), + path.join(home, '.local', 'bin'), + path.join(home, '.claude', 'bin'), + ]; + const allParts = [shellPath, ...winExtra].join(sep).split(sep).filter(Boolean); + constructedPath = [...new Set(allParts)].join(sep); + } else { + const basePath = `/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin`; + const raw = `${basePath}:${home}/.npm-global/bin:${home}/.local/bin:${home}/.claude/bin:${shellPath}`; + const allParts = raw.split(':').filter(Boolean); + constructedPath = [...new Set(allParts)].join(':'); + } const env: Record = { ...userShellEnv, - ...process.env as Record, + ...(process.env as Record), // Ensure user shell env vars override (especially API keys) ...userShellEnv, PORT: String(port), @@ -119,17 +143,28 @@ function startServer(port: number): ChildProcess { CLAUDE_GUI_DATA_DIR: path.join(home, '.codepilot'), ELECTRON_RUN_AS_NODE: '1', HOME: home, - PATH: `${basePath}:${home}/.npm-global/bin:${home}/.local/bin:${home}/.claude/bin:${shellPath}`, + USERPROFILE: home, + PATH: constructedPath, }; - // Spawn via /bin/sh to prevent the Electron binary from appearing - // as a separate Dock icon on macOS (even with ELECTRON_RUN_AS_NODE=1, - // macOS may show a Dock entry for the Electron Framework binary). - const child = spawn('/bin/sh', ['-c', `exec "${nodePath}" "${serverPath}"`], { - env, - stdio: 'pipe', - cwd: standaloneDir, - }); + // On Windows, spawn Node.js directly with windowsHide to prevent console flash. + // On macOS, spawn via /bin/sh to prevent the Electron binary from appearing + // as a separate Dock icon (even with ELECTRON_RUN_AS_NODE=1). + let child: ChildProcess; + if (process.platform === 'win32') { + child = spawn(nodePath, [serverPath], { + env, + stdio: 'pipe', + cwd: standaloneDir, + windowsHide: true, + }); + } else { + child = spawn('/bin/sh', ['-c', `exec "${nodePath}" "${serverPath}"`], { + env, + stdio: 'pipe', + cwd: standaloneDir, + }); + } child.stdout?.on('data', (data: Buffer) => { const msg = data.toString().trim(); @@ -155,23 +190,38 @@ function getIconPath(): string { if (isDev) { return path.join(process.cwd(), 'build', 'icon.png'); } + if (process.platform === 'win32') { + return path.join(process.resourcesPath, 'icon.ico'); + } return path.join(process.resourcesPath, 'icon.icns'); } function createWindow(port: number) { - mainWindow = new BrowserWindow({ + const windowOptions: Electron.BrowserWindowConstructorOptions = { width: 1280, height: 860, minWidth: 800, minHeight: 600, icon: getIconPath(), - titleBarStyle: 'hiddenInset', webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false, }, - }); + }; + + if (process.platform === 'darwin') { + windowOptions.titleBarStyle = 'hiddenInset'; + } else if (process.platform === 'win32') { + windowOptions.titleBarStyle = 'hidden'; + windowOptions.titleBarOverlay = { + color: '#00000000', + symbolColor: '#888888', + height: 44, + }; + } + + mainWindow = new BrowserWindow(windowOptions); mainWindow.loadURL(`http://127.0.0.1:${port}`); diff --git a/package.json b/package.json index 51402898..9b207162 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,9 @@ "lint": "eslint", "electron:dev": "concurrently -k \"next dev\" \"wait-on http://localhost:3000 && electron .\"", "electron:build": "next build && node scripts/build-electron.mjs", - "electron:pack": "npm run electron:build && electron-builder --mac --config electron-builder.yml" + "electron:pack": "npm run electron:build && electron-builder --config electron-builder.yml", + "electron:pack:mac": "npm run electron:build && electron-builder --mac --config electron-builder.yml", + "electron:pack:win": "npm run electron:build && electron-builder --win --config electron-builder.yml" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.33", diff --git a/src/app/api/claude-status/route.ts b/src/app/api/claude-status/route.ts index fe36ad16..16ee488d 100644 --- a/src/app/api/claude-status/route.ts +++ b/src/app/api/claude-status/route.ts @@ -1,76 +1,14 @@ import { NextResponse } from 'next/server'; -import { execFile, execFileSync } from 'child_process'; -import { promisify } from 'util'; -import os from 'os'; -import path from 'path'; - -const execFileAsync = promisify(execFile); - -function getExpandedPath(): string { - const home = os.homedir(); - const extra = [ - '/usr/local/bin', - '/opt/homebrew/bin', - '/usr/bin', - '/bin', - path.join(home, '.npm-global', 'bin'), - path.join(home, '.nvm', 'current', 'bin'), - path.join(home, '.local', 'bin'), - path.join(home, '.claude', 'bin'), - ]; - const current = process.env.PATH || ''; - const parts = current.split(':'); - for (const p of extra) { - if (!parts.includes(p)) parts.push(p); - } - return parts.join(':'); -} - -function findClaudePath(): string | null { - const home = os.homedir(); - const candidates = [ - '/usr/local/bin/claude', - '/opt/homebrew/bin/claude', - path.join(home, '.npm-global', 'bin', 'claude'), - path.join(home, '.local', 'bin', 'claude'), - path.join(home, '.claude', 'bin', 'claude'), - ]; - - for (const p of candidates) { - try { - execFileSync(p, ['--version'], { timeout: 3000, stdio: 'pipe' }); - return p; - } catch { - // not found, try next - } - } - - // Fallback: use `which claude` with expanded PATH - try { - const result = execFileSync('/usr/bin/which', ['claude'], { - timeout: 3000, - stdio: 'pipe', - env: { ...process.env, PATH: getExpandedPath() }, - }); - return result.toString().trim() || null; - } catch { - return null; - } -} +import { findClaudeBinary, getClaudeVersion } from '@/lib/platform'; export async function GET() { try { - const claudePath = findClaudePath(); + const claudePath = findClaudeBinary(); if (!claudePath) { return NextResponse.json({ connected: false, version: null }); } - - const { stdout } = await execFileAsync(claudePath, ['--version'], { - timeout: 5000, - env: { ...process.env, PATH: getExpandedPath() }, - }); - const version = stdout.trim(); - return NextResponse.json({ connected: true, version }); + const version = await getClaudeVersion(claudePath); + return NextResponse.json({ connected: !!version, version }); } catch { return NextResponse.json({ connected: false, version: null }); } diff --git a/src/app/api/files/browse/route.ts b/src/app/api/files/browse/route.ts index a264bd45..d51674b2 100644 --- a/src/app/api/files/browse/route.ts +++ b/src/app/api/files/browse/route.ts @@ -4,6 +4,21 @@ import path from 'path'; import os from 'os'; import type { ErrorResponse } from '@/types'; +function getWindowsDrives(): string[] { + if (process.platform !== 'win32') return []; + const drives: string[] = []; + for (let i = 65; i <= 90; i++) { + const drive = String.fromCharCode(i) + ':\\'; + try { + fs.accessSync(drive); + drives.push(drive); + } catch { + // drive not available + } + } + return drives; +} + // List only directories for folder browsing (no safety restriction since user is choosing where to work) export async function GET(request: NextRequest) { const { searchParams } = request.nextUrl; @@ -28,10 +43,13 @@ export async function GET(request: NextRequest) { })) .sort((a, b) => a.name.localeCompare(b.name)); + const drives = getWindowsDrives(); + return NextResponse.json({ current: resolvedDir, parent: path.dirname(resolvedDir) !== resolvedDir ? path.dirname(resolvedDir) : null, directories, + drives, }); } catch { return NextResponse.json( diff --git a/src/components/chat/FolderPicker.tsx b/src/components/chat/FolderPicker.tsx index d56bb8dd..bf61862a 100644 --- a/src/components/chat/FolderPicker.tsx +++ b/src/components/chat/FolderPicker.tsx @@ -11,6 +11,12 @@ import { DialogTitle, DialogFooter, } from '@/components/ui/dialog'; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, +} from '@/components/ui/dropdown-menu'; import { Input } from '@/components/ui/input'; import { ScrollArea } from '@/components/ui/scroll-area'; @@ -23,6 +29,7 @@ interface BrowseResponse { current: string; parent: string | null; directories: FolderEntry[]; + drives?: string[]; } interface FolderPickerProps { @@ -38,6 +45,7 @@ export function FolderPicker({ open, onOpenChange, onSelect, initialPath }: Fold const [directories, setDirectories] = useState([]); const [loading, setLoading] = useState(false); const [pathInput, setPathInput] = useState(''); + const [drives, setDrives] = useState([]); const browse = useCallback(async (dir?: string) => { setLoading(true); @@ -52,6 +60,7 @@ export function FolderPicker({ open, onOpenChange, onSelect, initialPath }: Fold setParentDir(data.parent); setDirectories(data.directories); setPathInput(data.current); + setDrives(data.drives || []); } } catch { // silently fail @@ -108,7 +117,7 @@ export function FolderPicker({ open, onOpenChange, onSelect, initialPath }: Fold {/* Directory browser */}
- {/* Current path + go up */} + {/* Current path + go up + drive switcher */}
+ {drives.length > 0 && ( + + + + + + {drives.map((drive) => { + const letter = drive.charAt(0).toUpperCase(); + const isCurrent = currentDir.toUpperCase().startsWith(letter + ':'); + return ( + browse(drive)} + > + {letter}: + {drive} + + ); + })} + + + )} {currentDir} diff --git a/src/components/skills/SkillsManager.tsx b/src/components/skills/SkillsManager.tsx index b2fc3f9c..61303b07 100644 --- a/src/components/skills/SkillsManager.tsx +++ b/src/components/skills/SkillsManager.tsx @@ -210,7 +210,7 @@ export function SkillsManager() { {pluginSkills.map((skill) => ( = { ...process.env as Record }; - // Ensure HOME is explicitly set so Claude Code can find ~/.claude/commands/ - if (!sdkEnv.HOME) { - sdkEnv.HOME = os.homedir(); - } + // Ensure HOME/USERPROFILE are set so Claude Code can find ~/.claude/commands/ + if (!sdkEnv.HOME) sdkEnv.HOME = os.homedir(); + if (!sdkEnv.USERPROFILE) sdkEnv.USERPROFILE = os.homedir(); + // Ensure SDK subprocess has expanded PATH (consistent with Electron mode) + sdkEnv.PATH = getExpandedPath(); const appToken = getSetting('anthropic_auth_token'); const appBaseUrl = getSetting('anthropic_base_url'); diff --git a/src/lib/platform.ts b/src/lib/platform.ts new file mode 100644 index 00000000..de0a5d10 --- /dev/null +++ b/src/lib/platform.ts @@ -0,0 +1,156 @@ +import { execFileSync, execFile } from 'child_process'; +import { promisify } from 'util'; +import os from 'os'; +import path from 'path'; + +const execFileAsync = promisify(execFile); + +export const isWindows = process.platform === 'win32'; +export const isMac = process.platform === 'darwin'; + +/** + * Whether the given binary path requires shell execution. + * On Windows, .cmd/.bat files cannot be executed directly by execFileSync. + */ +function needsShell(binPath: string): boolean { + return isWindows && /\.(cmd|bat)$/i.test(binPath); +} + +/** + * Extra PATH directories to search for Claude CLI and other tools. + */ +export function getExtraPathDirs(): string[] { + const home = os.homedir(); + if (isWindows) { + const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming'); + const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'); + return [ + path.join(appData, 'npm'), + path.join(localAppData, 'npm'), + path.join(home, '.npm-global', 'bin'), + path.join(home, '.claude', 'bin'), + path.join(home, '.local', 'bin'), + path.join(home, '.nvm', 'current', 'bin'), + ]; + } + return [ + '/usr/local/bin', + '/opt/homebrew/bin', + '/usr/bin', + '/bin', + path.join(home, '.npm-global', 'bin'), + path.join(home, '.nvm', 'current', 'bin'), + path.join(home, '.local', 'bin'), + path.join(home, '.claude', 'bin'), + ]; +} + +/** + * Claude CLI candidate installation paths. + */ +export function getClaudeCandidatePaths(): string[] { + const home = os.homedir(); + if (isWindows) { + const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming'); + const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'); + return [ + path.join(appData, 'npm', 'claude.cmd'), + path.join(localAppData, 'npm', 'claude.cmd'), + path.join(home, '.npm-global', 'bin', 'claude.cmd'), + path.join(home, '.claude', 'bin', 'claude.exe'), + path.join(home, '.local', 'bin', 'claude.cmd'), + ]; + } + return [ + '/usr/local/bin/claude', + '/opt/homebrew/bin/claude', + path.join(home, '.npm-global', 'bin', 'claude'), + path.join(home, '.local', 'bin', 'claude'), + path.join(home, '.claude', 'bin', 'claude'), + ]; +} + +/** + * Build an expanded PATH string with extra directories, deduped and filtered. + */ +export function getExpandedPath(): string { + const current = process.env.PATH || ''; + const parts = current.split(path.delimiter).filter(Boolean); + const seen = new Set(parts); + for (const p of getExtraPathDirs()) { + if (p && !seen.has(p)) { + parts.push(p); + seen.add(p); + } + } + return parts.join(path.delimiter); +} + +/** + * Find and validate the Claude CLI binary. + * Tests each candidate with --version before returning. + */ +export function findClaudeBinary(): string | undefined { + // Try known candidate paths first + for (const p of getClaudeCandidatePaths()) { + try { + execFileSync(p, ['--version'], { + timeout: 3000, + stdio: 'pipe', + shell: needsShell(p), + }); + return p; + } catch { + // not found, try next + } + } + + // Fallback: use `where` (Windows) or `which` (Unix) with expanded PATH + try { + const cmd = isWindows ? 'where' : '/usr/bin/which'; + const args = isWindows ? ['claude'] : ['claude']; + const result = execFileSync(cmd, args, { + timeout: 3000, + stdio: 'pipe', + env: { ...process.env, PATH: getExpandedPath() }, + shell: isWindows, + }); + // where.exe may return multiple lines; try each with --version validation + const lines = result.toString().trim().split(/\r?\n/); + for (const line of lines) { + const candidate = line.trim(); + if (!candidate) continue; + try { + execFileSync(candidate, ['--version'], { + timeout: 3000, + stdio: 'pipe', + shell: needsShell(candidate), + }); + return candidate; + } catch { + continue; + } + } + } catch { + // not found + } + + return undefined; +} + +/** + * Execute claude --version and return the version string. + * Handles .cmd shell execution on Windows. + */ +export async function getClaudeVersion(claudePath: string): Promise { + try { + const { stdout } = await execFileAsync(claudePath, ['--version'], { + timeout: 5000, + env: { ...process.env, PATH: getExpandedPath() }, + shell: needsShell(claudePath), + }); + return stdout.trim() || null; + } catch { + return null; + } +}