diff --git a/.github/screenshots/issue-6-fix.png b/.github/screenshots/issue-6-fix.png new file mode 100644 index 0000000..17e82cd Binary files /dev/null and b/.github/screenshots/issue-6-fix.png differ diff --git a/daemon/agents.js b/daemon/agents.js index 1b35b32..08d81c6 100644 --- a/daemon/agents.js +++ b/daemon/agents.js @@ -7,7 +7,12 @@ import path from 'node:path'; const execFileP = promisify(execFile); // Each entry defines how to invoke the agent in non-interactive "one-shot" mode. -// `buildArgs(prompt, imagePaths)` returns argv for the child process. +// `buildArgs(prompt, imagePaths, extraAllowedDirs)` returns argv for the child +// process. `extraAllowedDirs` is a list of absolute directories the agent must +// be permitted to read files from (skill seeds, design-system specs) that live +// outside the project cwd. Currently only Claude Code wires this through +// (`--add-dir`); other agents either inherit broader access or run with cwd +// boundaries we can't widen via flags. // `streamFormat` hints to the daemon how to interpret stdout: // - 'claude-stream-json' : line-delimited JSON emitted by Claude Code's // `--output-format stream-json`. Daemon parses it into typed events @@ -19,14 +24,23 @@ export const AGENT_DEFS = [ name: 'Claude Code', bin: 'claude', versionArgs: ['--version'], - buildArgs: (prompt) => [ - '-p', - prompt, - '--output-format', - 'stream-json', - '--verbose', - '--include-partial-messages', - ], + buildArgs: (prompt, _imagePaths, extraAllowedDirs = []) => { + const args = [ + '-p', + prompt, + '--output-format', + 'stream-json', + '--verbose', + '--include-partial-messages', + ]; + const dirs = (extraAllowedDirs || []).filter( + (d) => typeof d === 'string' && d.length > 0, + ); + if (dirs.length > 0) { + args.push('--add-dir', ...dirs); + } + return args; + }, streamFormat: 'claude-stream-json', }, { diff --git a/daemon/server.js b/daemon/server.js index f3a6246..c12311f 100644 --- a/daemon/server.js +++ b/daemon/server.js @@ -769,7 +769,17 @@ export async function startServer({ port = 7456 } = {}) { safeImages.length ? `\n\n${safeImages.map((p) => `@${p}`).join(' ')}` : '', ].join(''); - const args = def.buildArgs(composed, safeImages); + // Skill seeds (`skills//assets/template.html`) and design-system + // specs (`design-systems//DESIGN.md`) live outside the project cwd. + // The composed system prompt asks the agent to Read them via absolute + // paths in the skill-root preamble — without an explicit allowlist, + // Claude Code blocks those reads (issue #6: "no permission to read + // skills template"). We surface both roots so any agent that honours + // `--add-dir` can resolve those side files. + const extraAllowedDirs = [SKILLS_DIR, DESIGN_SYSTEMS_DIR].filter( + (d) => fs.existsSync(d), + ); + const args = def.buildArgs(composed, safeImages, extraAllowedDirs); res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache, no-transform'); diff --git a/package.json b/package.json index 9febc36..82846f0 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "scripts": { "daemon": "node daemon/cli.js --no-open", "dev": "vite", - "dev:all": "concurrently -k -n daemon,web -c cyan,magenta \"npm:daemon\" \"npm:dev\"", + "dev:all": "node scripts/dev-all.mjs", "build": "tsc -b && vite build", "preview": "vite preview", "typecheck": "tsc -b --noEmit", diff --git a/scripts/dev-all.mjs b/scripts/dev-all.mjs new file mode 100755 index 0000000..32b4076 --- /dev/null +++ b/scripts/dev-all.mjs @@ -0,0 +1,84 @@ +#!/usr/bin/env node +// Launcher for `npm run dev:all`. +// +// Probes for free ports for the daemon (OD_PORT, default 7456) and the Vite +// dev server (VITE_PORT, default 5173) before spawning `concurrently`, so a +// stray process holding either port doesn't kill the whole boot. The +// resolved ports are exported into the child env, which means: +// * the daemon's cli.js sees the new OD_PORT and binds to it +// * vite.config.ts reads the same OD_PORT and points its /api proxy at +// the daemon's actual port +// * Vite itself binds to VITE_PORT +// +// If a port is busy we walk forward up to PORT_SEARCH_RANGE steps and log +// the switch so the user notices. + +import { spawn } from 'node:child_process'; +import net from 'node:net'; + +const HOST = '127.0.0.1'; +const PORT_SEARCH_RANGE = 50; + +function isPortFree(port, host = HOST) { + return new Promise((resolve) => { + const server = net.createServer(); + server.unref(); + server.once('error', () => resolve(false)); + server.listen({ port, host, exclusive: true }, () => { + server.close(() => resolve(true)); + }); + }); +} + +async function findFreePort(start, label) { + for (let port = start; port < start + PORT_SEARCH_RANGE; port++) { + if (await isPortFree(port)) return port; + } + throw new Error( + `[dev:all] could not find a free ${label} port near ${start} (tried ${PORT_SEARCH_RANGE})`, + ); +} + +const desiredDaemon = Number(process.env.OD_PORT) || 7456; +const desiredVite = Number(process.env.VITE_PORT) || 5173; + +const daemonPort = await findFreePort(desiredDaemon, 'daemon'); +const vitePort = await findFreePort(desiredVite, 'vite'); + +if (daemonPort !== desiredDaemon) { + console.log( + `[dev:all] daemon port ${desiredDaemon} is busy, switching to ${daemonPort}`, + ); +} +if (vitePort !== desiredVite) { + console.log( + `[dev:all] vite port ${desiredVite} is busy, switching to ${vitePort}`, + ); +} + +const env = { + ...process.env, + OD_PORT: String(daemonPort), + VITE_PORT: String(vitePort), +}; + +// We spawn the local `concurrently` bin via shell so Windows .cmd shims +// resolve correctly. The `npm:daemon` / `npm:dev` shorthand runs the +// matching package.json scripts, so any future tweak to those scripts is +// picked up automatically. +const child = spawn( + 'concurrently', + ['-k', '-n', 'daemon,web', '-c', 'cyan,magenta', 'npm:daemon', 'npm:dev'], + { env, stdio: 'inherit', shell: true }, +); + +child.on('exit', (code, signal) => { + if (signal) process.kill(process.pid, signal); + else process.exit(code ?? 0); +}); + +for (const sig of ['SIGINT', 'SIGTERM']) { + process.on(sig, () => { + if (!child.killed) child.kill(sig); + }); +} diff --git a/src/App.tsx b/src/App.tsx index 46876ed..b1afbc7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -116,6 +116,7 @@ export function App() { const withOnboarding: AppConfig = { ...next, onboardingCompleted: true }; saveConfig(withOnboarding); setConfig(withOnboarding); + setSettingsOpen(false); }, []); const handleModeChange = useCallback( diff --git a/src/components/SettingsDialog.tsx b/src/components/SettingsDialog.tsx index 68dd61c..e14a07f 100644 --- a/src/components/SettingsDialog.tsx +++ b/src/components/SettingsDialog.tsx @@ -276,10 +276,7 @@ export function SettingsDialog({ type="button" className="primary" disabled={!canSave} - onClick={() => { - onSave(cfg); - onClose(); - }} + onClick={() => onSave(cfg)} > {welcome ? t('settings.getStarted') : t('common.save')} diff --git a/vite.config.ts b/vite.config.ts index 00a3fa1..8547202 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,11 +2,12 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; const DAEMON_PORT = Number(process.env.OD_PORT) || 7456; +const VITE_PORT = Number(process.env.VITE_PORT) || 5173; export default defineConfig({ plugins: [react()], server: { - port: 5173, + port: VITE_PORT, proxy: { '/api': { target: `http://127.0.0.1:${DAEMON_PORT}`,