feat(electrobun): project wizard phases 1-5 (WIP)

- sanitize.ts: input sanitization (trim, control chars, path traversal)
- provider-scanner.ts: detect Claude/Codex/Ollama/Gemini availability
- model-fetcher.ts: live model lists from 4 provider APIs
- ModelConfigPanel.svelte: per-provider config (thinking, effort, sandbox, temperature)
- WizardStep1-3.svelte: split wizard into composable steps
- CustomDropdown/Checkbox/Radio: themed UI components
- provider-handlers.ts: provider.scan + provider.models RPC
- Wire providers into wizard step 3 (live detection + model lists)
- Replace native selects in 5 settings panels with CustomDropdown
This commit is contained in:
Hibryda 2026-03-23 13:05:07 +01:00
parent b7fc3a0f9b
commit d4014a193d
25 changed files with 2112 additions and 759 deletions

View file

@ -1,5 +1,5 @@
/**
* Git RPC handlers branch listing, clone operations.
* Git RPC handlers branch listing, clone, probe, and template scaffolding.
*/
import path from "path";
@ -98,5 +98,118 @@ export function createGitHandlers() {
});
});
},
"git.probe": async ({ url }: { url: string }) => {
if (!url || (!url.includes("/") && !url.includes(":"))) {
return { ok: false, branches: [], defaultBranch: '', error: "Invalid URL" };
}
return new Promise<{
ok: boolean; branches: string[]; defaultBranch: string; error?: string;
}>((resolve) => {
const proc = spawn("git", ["ls-remote", "--heads", "--symref", url], {
stdio: "pipe",
timeout: 15_000,
});
let stdout = "";
let stderr = "";
proc.stdout?.on("data", (chunk: Buffer) => { stdout += chunk.toString(); });
proc.stderr?.on("data", (chunk: Buffer) => { stderr += chunk.toString(); });
proc.on("close", (code) => {
if (code !== 0) {
resolve({ ok: false, branches: [], defaultBranch: '', error: stderr.trim() || 'Probe failed' });
return;
}
const branches: string[] = [];
let defaultBranch = 'main';
for (const line of stdout.split("\n")) {
// Parse symref for HEAD
const symMatch = line.match(/^ref: refs\/heads\/(\S+)\s+HEAD/);
if (symMatch) { defaultBranch = symMatch[1]; continue; }
// Parse branch refs
const refMatch = line.match(/\trefs\/heads\/(.+)$/);
if (refMatch && !branches.includes(refMatch[1])) {
branches.push(refMatch[1]);
}
}
resolve({ ok: true, branches, defaultBranch });
});
proc.on("error", (err) => {
resolve({ ok: false, branches: [], defaultBranch: '', error: err.message });
});
});
},
"project.createFromTemplate": async ({
templateId,
targetDir,
projectName,
}: {
templateId: string;
targetDir: string;
projectName: string;
}) => {
const resolved = path.resolve(targetDir.replace(/^~/, process.env.HOME ?? ""));
const projectDir = path.join(resolved, projectName);
// Don't overwrite existing directory
try {
fs.statSync(projectDir);
return { ok: false, path: '', error: "Directory already exists" };
} catch {
// Expected
}
try {
fs.mkdirSync(projectDir, { recursive: true });
const scaffolds: Record<string, Record<string, string>> = {
blank: {
'README.md': `# ${projectName}\n`,
'.gitignore': 'node_modules/\ndist/\n.env\n',
},
'web-app': {
'index.html': `<!DOCTYPE html>\n<html lang="en">\n<head>\n <meta charset="UTF-8" />\n <meta name="viewport" content="width=device-width, initial-scale=1.0" />\n <title>${projectName}</title>\n <link rel="stylesheet" href="style.css" />\n</head>\n<body>\n <h1>${projectName}</h1>\n <script src="main.js"></script>\n</body>\n</html>\n`,
'style.css': `body {\n font-family: system-ui, sans-serif;\n margin: 2rem;\n}\n`,
'main.js': `console.log('${projectName} loaded');\n`,
'README.md': `# ${projectName}\n\nA web application.\n`,
'.gitignore': 'node_modules/\ndist/\n.env\n',
},
'api-server': {
'index.ts': `const server = Bun.serve({\n port: 3000,\n fetch(req) {\n const url = new URL(req.url);\n if (url.pathname === '/health') {\n return Response.json({ status: 'ok' });\n }\n return Response.json({ message: 'Hello from ${projectName}' });\n },\n});\nconsole.log(\`Server running on \${server.url}\`);\n`,
'README.md': `# ${projectName}\n\nA Bun HTTP API server.\n\n## Run\n\n\`\`\`bash\nbun run index.ts\n\`\`\`\n`,
'.gitignore': 'node_modules/\ndist/\n.env\n',
'package.json': `{\n "name": "${projectName}",\n "version": "0.1.0",\n "scripts": {\n "start": "bun run index.ts",\n "dev": "bun --watch index.ts"\n }\n}\n`,
},
'cli-tool': {
'cli.ts': `#!/usr/bin/env bun\nconst args = process.argv.slice(2);\nif (args.includes('--help') || args.includes('-h')) {\n console.log('Usage: ${projectName} [options]');\n console.log(' --help, -h Show this help');\n console.log(' --version Show version');\n process.exit(0);\n}\nif (args.includes('--version')) {\n console.log('${projectName} 0.1.0');\n process.exit(0);\n}\nconsole.log('Hello from ${projectName}!');\n`,
'README.md': `# ${projectName}\n\nA command-line tool.\n\n## Run\n\n\`\`\`bash\nbun run cli.ts --help\n\`\`\`\n`,
'.gitignore': 'node_modules/\ndist/\n.env\n',
},
};
const files = scaffolds[templateId] ?? scaffolds['blank'];
for (const [name, content] of Object.entries(files)) {
fs.writeFileSync(path.join(projectDir, name), content);
}
// Initialize git repo
try {
execSync('git init', { cwd: projectDir, timeout: 5000 });
} catch {
// Non-fatal — project created without git
}
return { ok: true, path: projectDir };
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
return { ok: false, path: '', error };
}
},
};
}

View file

@ -0,0 +1,30 @@
/**
* Provider RPC handlers scanning + model fetching.
*/
import { scanAllProviders } from '../provider-scanner.ts';
import { fetchModelsForProvider } from '../model-fetcher.ts';
export function createProviderHandlers() {
return {
'provider.scan': async () => {
try {
const providers = await scanAllProviders();
return { providers };
} catch (err) {
console.error('[provider.scan]', err);
return { providers: [] };
}
},
'provider.models': async ({ provider }: { provider: string }) => {
try {
const models = await fetchModelsForProvider(provider);
return { models };
} catch (err) {
console.error('[provider.models]', err);
return { models: [] };
}
},
};
}

View file

@ -33,6 +33,7 @@ import { createSearchHandlers } from "./handlers/search-handlers.ts";
import { createPluginHandlers } from "./handlers/plugin-handlers.ts";
import { createRemoteHandlers } from "./handlers/remote-handlers.ts";
import { createGitHandlers } from "./handlers/git-handlers.ts";
import { createProviderHandlers } from "./handlers/provider-handlers.ts";
/** Current app version — sourced from electrobun.config.ts at build time. */
const APP_VERSION = "0.0.1";
@ -101,6 +102,7 @@ const searchHandlers = createSearchHandlers(searchDb);
const pluginHandlers = createPluginHandlers();
const remoteHandlers = createRemoteHandlers(relayClient, settingsDb);
const gitHandlers = createGitHandlers();
const providerHandlers = createProviderHandlers();
// ── RPC definition ─────────────────────────────────────────────────────────
@ -127,6 +129,8 @@ const rpc = BrowserView.defineRPC<PtyRPCSchema>({
...remoteHandlers,
// Git
...gitHandlers,
// Providers
...providerHandlers,
// Native folder picker dialog via zenity (proper GTK folder chooser)
"files.pickDirectory": async ({ startingFolder }) => {

View file

@ -0,0 +1,107 @@
/**
* Model fetcher retrieves available models from each provider's API.
*
* Each function returns a sorted list of model IDs.
* Network errors return empty arrays (non-fatal).
*/
export interface ModelInfo {
id: string;
name: string;
provider: string;
}
const TIMEOUT = 8000;
export async function fetchClaudeModels(): Promise<ModelInfo[]> {
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) return [];
try {
const res = await fetch('https://api.anthropic.com/v1/models', {
headers: {
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
},
signal: AbortSignal.timeout(TIMEOUT),
});
if (!res.ok) return [];
const data = await res.json() as { data?: Array<{ id: string; display_name?: string }> };
return (data.data ?? [])
.map(m => ({ id: m.id, name: m.display_name ?? m.id, provider: 'claude' }))
.sort((a, b) => a.id.localeCompare(b.id));
} catch {
return [];
}
}
export async function fetchCodexModels(): Promise<ModelInfo[]> {
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) return [];
try {
const res = await fetch('https://api.openai.com/v1/models', {
headers: { 'Authorization': `Bearer ${apiKey}` },
signal: AbortSignal.timeout(TIMEOUT),
});
if (!res.ok) return [];
const data = await res.json() as { data?: Array<{ id: string }> };
return (data.data ?? [])
.map(m => ({ id: m.id, name: m.id, provider: 'codex' }))
.sort((a, b) => a.id.localeCompare(b.id));
} catch {
return [];
}
}
export async function fetchOllamaModels(): Promise<ModelInfo[]> {
try {
const res = await fetch('http://localhost:11434/api/tags', {
signal: AbortSignal.timeout(TIMEOUT),
});
if (!res.ok) return [];
const data = await res.json() as { models?: Array<{ name: string; model?: string }> };
return (data.models ?? [])
.map(m => ({ id: m.name, name: m.name, provider: 'ollama' }))
.sort((a, b) => a.id.localeCompare(b.id));
} catch {
return [];
}
}
export async function fetchGeminiModels(): Promise<ModelInfo[]> {
const apiKey = process.env.GEMINI_API_KEY;
if (!apiKey) return [];
try {
const res = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`,
{ signal: AbortSignal.timeout(TIMEOUT) },
);
if (!res.ok) return [];
const data = await res.json() as {
models?: Array<{ name: string; displayName?: string }>;
};
return (data.models ?? [])
.map(m => ({
id: m.name.replace('models/', ''),
name: m.displayName ?? m.name,
provider: 'gemini',
}))
.sort((a, b) => a.id.localeCompare(b.id));
} catch {
return [];
}
}
/**
* Fetch models for a specific provider.
*/
export async function fetchModelsForProvider(
provider: string,
): Promise<ModelInfo[]> {
switch (provider) {
case 'claude': return fetchClaudeModels();
case 'codex': return fetchCodexModels();
case 'ollama': return fetchOllamaModels();
case 'gemini': return fetchGeminiModels();
default: return [];
}
}

View file

@ -0,0 +1,107 @@
/**
* Provider scanner detects which AI providers are available on this machine.
*
* Checks environment variables and CLI tool availability.
*/
import { execSync } from 'child_process';
export interface ProviderScanResult {
id: string;
available: boolean;
hasApiKey: boolean;
hasCli: boolean;
cliPath: string | null;
version: string | null;
}
function whichSync(bin: string): string | null {
try {
return execSync(`which ${bin}`, { encoding: 'utf8', timeout: 3000 }).trim() || null;
} catch {
return null;
}
}
function getVersion(bin: string): string | null {
try {
const out = execSync(`${bin} --version`, { encoding: 'utf8', timeout: 5000 }).trim();
// Extract first version-like string
const match = out.match(/(\d+\.\d+[\w.-]*)/);
return match ? match[1] : out.slice(0, 40);
} catch {
return null;
}
}
export async function scanClaude(): Promise<ProviderScanResult> {
const hasApiKey = !!process.env.ANTHROPIC_API_KEY;
const cliPath = whichSync('claude');
return {
id: 'claude',
available: hasApiKey || !!cliPath,
hasApiKey,
hasCli: !!cliPath,
cliPath,
version: cliPath ? getVersion('claude') : null,
};
}
export async function scanCodex(): Promise<ProviderScanResult> {
const hasApiKey = !!process.env.OPENAI_API_KEY;
const cliPath = whichSync('codex');
return {
id: 'codex',
available: hasApiKey || !!cliPath,
hasApiKey,
hasCli: !!cliPath,
cliPath,
version: cliPath ? getVersion('codex') : null,
};
}
export async function scanOllama(): Promise<ProviderScanResult> {
const cliPath = whichSync('ollama');
let serverUp = false;
try {
const res = await fetch('http://localhost:11434/api/version', {
signal: AbortSignal.timeout(2000),
});
serverUp = res.ok;
} catch {
// ECONNREFUSED or timeout — server not running
}
return {
id: 'ollama',
available: serverUp || !!cliPath,
hasApiKey: false,
hasCli: !!cliPath,
cliPath,
version: cliPath ? getVersion('ollama') : null,
};
}
export async function scanGemini(): Promise<ProviderScanResult> {
const hasApiKey = !!process.env.GEMINI_API_KEY;
return {
id: 'gemini',
available: hasApiKey,
hasApiKey,
hasCli: false,
cliPath: null,
version: null,
};
}
/**
* Scan all known providers. Returns results for each.
*/
export async function scanAllProviders(): Promise<ProviderScanResult[]> {
const [claude, codex, ollama, gemini] = await Promise.all([
scanClaude(),
scanCodex(),
scanOllama(),
scanGemini(),
]);
return [claude, codex, ollama, gemini];
}