Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added build/icon.ico
Binary file not shown.
14 changes: 14 additions & 0 deletions electron-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ extraResources:
- from: public/
to: standalone/public/
filter: ["**/*"]
- from: build/
to: .
filter: ["icon.*"]
asarUnpack:
- "**/*.node"
- "**/better-sqlite3/**"
Expand All @@ -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
78 changes: 64 additions & 14 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ const isDev = !app.isPackaged;
* and won't include vars from .zshrc/.bashrc (e.g. API keys).
*/
function loadUserShellEnv(): Record<string, string> {
// 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'], {
Expand Down Expand Up @@ -106,30 +110,61 @@ 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<string, string> = {
...userShellEnv,
...process.env as Record<string, string>,
...(process.env as Record<string, string>),
// Ensure user shell env vars override (especially API keys)
...userShellEnv,
PORT: String(port),
HOSTNAME: '127.0.0.1',
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();
Expand All @@ -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}`);

Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
70 changes: 4 additions & 66 deletions src/app/api/claude-status/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
Expand Down
18 changes: 18 additions & 0 deletions src/app/api/files/browse/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<ErrorResponse>(
Expand Down
36 changes: 35 additions & 1 deletion src/components/chat/FolderPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -23,6 +29,7 @@ interface BrowseResponse {
current: string;
parent: string | null;
directories: FolderEntry[];
drives?: string[];
}

interface FolderPickerProps {
Expand All @@ -38,6 +45,7 @@ export function FolderPicker({ open, onOpenChange, onSelect, initialPath }: Fold
const [directories, setDirectories] = useState<FolderEntry[]>([]);
const [loading, setLoading] = useState(false);
const [pathInput, setPathInput] = useState('');
const [drives, setDrives] = useState<string[]>([]);

const browse = useCallback(async (dir?: string) => {
setLoading(true);
Expand All @@ -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
Expand Down Expand Up @@ -108,7 +117,7 @@ export function FolderPicker({ open, onOpenChange, onSelect, initialPath }: Fold

{/* Directory browser */}
<div className="rounded-md border border-border">
{/* Current path + go up */}
{/* Current path + go up + drive switcher */}
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-3 py-2">
<Button
variant="ghost"
Expand All @@ -119,6 +128,31 @@ export function FolderPicker({ open, onOpenChange, onSelect, initialPath }: Fold
>
<HugeiconsIcon icon={ArrowUp01Icon} className="h-4 w-4" />
</Button>
{drives.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-6 px-1.5 text-xs font-mono shrink-0">
{currentDir.charAt(0).toUpperCase()}:
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{drives.map((drive) => {
const letter = drive.charAt(0).toUpperCase();
const isCurrent = currentDir.toUpperCase().startsWith(letter + ':');
return (
<DropdownMenuItem
key={drive}
className="font-mono text-sm gap-2"
onClick={() => browse(drive)}
>
<span className={isCurrent ? 'font-bold' : ''}>{letter}:</span>
<span className="text-muted-foreground text-xs">{drive}</span>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
)}
<span className="truncate text-xs font-mono text-muted-foreground">
{currentDir}
</span>
Expand Down
2 changes: 1 addition & 1 deletion src/components/skills/SkillsManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ export function SkillsManager() {
</span>
{pluginSkills.map((skill) => (
<SkillListItem
key={`${skill.source}:${skill.name}`}
key={skill.filePath || `${skill.source}:${skill.name}`}
skill={skill}
selected={
selected?.name === skill.name &&
Expand Down
Loading