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
3 changes: 0 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,3 @@ CLAUDE_BIN=claude

# Backend server port (default: 9998)
PORT=9998

# Allowed CORS origin for the Vite dev server (dev only; not needed in production)
FRONTEND_ORIGIN=http://localhost:9999
1 change: 0 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@ cp .env.example backend/.env
| `CLAUDE_BIN` | `claude` | Path to the `claude` binary |
| `PORT` | `9998` | Backend listen port |
| `HOST` | `0.0.0.0` | Backend listen address |
| `FRONTEND_ORIGIN` | `http://localhost:9999` | CORS allowed origin (dev only) |

## Dev ports

Expand Down
8 changes: 1 addition & 7 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,16 +1,10 @@
.PHONY: dev dev-backend dev-frontend build lint test install clean run service-install service-uninstall service-test

# Fedora / RHEL hosts lack the Google Trust Services intermediate CA that
# Anthropic's API uses. Set NODE_EXTRA_CA_CERTS only when the bundle exists
# so the fix is a no-op on macOS, Ubuntu, Alpine, etc.
CA_BUNDLE := /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem
CA_ENV := $(if $(wildcard $(CA_BUNDLE)),NODE_EXTRA_CA_CERTS=$(CA_BUNDLE),)

dev:
@make -j2 dev-backend dev-frontend

dev-backend:
cd backend && $(CA_ENV) npm run dev
cd backend && npm run dev

dev-frontend:
cd frontend && npm run dev
Expand Down
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@ cp .env.example backend/.env
| `CLAUDE_BIN` | `claude` | Path to the `claude` binary |
| `PORT` | `9998` | Backend listen port |
| `HOST` | `0.0.0.0` | Backend listen address |
| `FRONTEND_ORIGIN` | `http://localhost:9999` | Allowed CORS origin (dev only) |

## Development

Expand Down
5 changes: 2 additions & 3 deletions backend/src/routes/account.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import type { FastifyInstance } from 'fastify'
import { getAccountInfo } from '../services/claudeAccount'
import { getCachedAccount } from '../services/accountCache'

export async function accountRoutes(fastify: FastifyInstance) {
fastify.get('/api/account', async (_req, reply) => {
const info = await getAccountInfo()
return reply.send(info)
return reply.send(getCachedAccount())
})
}
17 changes: 17 additions & 0 deletions backend/src/routes/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,23 @@ export async function sessionRoutes(fastify: FastifyInstance) {
}
})

fastify.get<{ Params: { id: string } }>('/api/sessions/:id', async (req, reply) => {
const { id } = req.params
const row = db
.prepare(
`SELECT s.*,
CASE WHEN s.ended_at IS NULL THEN 1 ELSE 0 END as is_active,
COUNT(m.id) as message_count
FROM sessions s
LEFT JOIN messages m ON m.session_id = s.id
WHERE s.id = ?
GROUP BY s.id`,
)
.get(id)
if (!row) return reply.status(404).send({ error: 'Session not found' })
return reply.send(row)
})

fastify.get('/api/sessions', async (_req, reply) => {
const rows = db
.prepare(
Expand Down
2 changes: 1 addition & 1 deletion backend/src/routes/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ describe('settings routes', () => {
expect(res.statusCode).toBe(200)
const body = res.json()
expect(body).toMatchObject({
bypass_permissions: 'true',
bypass_permissions: 'false',
session_mode: 'terminal',
})
})
Expand Down
2 changes: 1 addition & 1 deletion backend/src/routes/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { FastifyInstance } from 'fastify'
import { db } from '../db/schema'

const DEFAULTS: Record<string, string> = {
bypass_permissions: 'true',
bypass_permissions: 'false',
session_mode: 'terminal',
}

Expand Down
13 changes: 7 additions & 6 deletions backend/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { sessionRoutes } from './routes/sessions'
import { sessionWsRoutes } from './ws/session'
import { terminalWsRoutes } from './ws/terminal'
import { settingsRoutes } from './routes/settings'
import { initAccountCache } from './services/accountCache'

// TODO: add bearer token auth — add @fastify/bearer-auth plugin here
// and set token via DASHBOARD_TOKEN env var
Expand All @@ -18,15 +19,16 @@ import { settingsRoutes } from './routes/settings'
const fastify = Fastify({ logger: true })

async function start() {
const devOrigin = process.env.FRONTEND_ORIGIN
const PORT = Number(process.env.PORT ?? 9998)
const HOST = process.env.HOST ?? '0.0.0.0'
await fastify.register(cors, {
methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE'],
origin: (origin, cb) => {
if (!origin) return cb(null, true) // same-origin / curl / no-CORS
try {
if (new URL(origin).port === '9999') return cb(null, true)
const { port } = new URL(origin)
if (port === '9999' || port === String(PORT)) return cb(null, true)
} catch { /* ignore malformed */ }
if (devOrigin && origin === devOrigin) return cb(null, true)
cb(new Error('Not allowed by CORS'), false)
},
})
Expand Down Expand Up @@ -57,15 +59,14 @@ async function start() {
})
}

const PORT = Number(process.env.PORT ?? 9998)
const HOST = process.env.HOST ?? '0.0.0.0'

try {
await fastify.listen({ port: PORT, host: HOST })
} catch (err) {
fastify.log.error(err)
process.exit(1)
}

initAccountCache().catch((err) => fastify.log.error(err))
}

start()
17 changes: 17 additions & 0 deletions backend/src/services/accountCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { getAccountInfo } from './claudeAccount'
import type { AccountInfo } from './claudeAccount'

const REFRESH_MS = 60_000

let cached: AccountInfo | null = null

export async function initAccountCache(): Promise<void> {
cached = await getAccountInfo()
setInterval(async () => {
cached = await getAccountInfo()
}, REFRESH_MS).unref()
}

export function getCachedAccount(): AccountInfo | null {
return cached
}
7 changes: 5 additions & 2 deletions backend/src/services/claudeAccount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,11 @@ export async function getAccountInfo(): Promise<AccountInfo> {
authStatus: 'unknown',
}

const versionResult = await execFileNoThrow(claudeBin(), ['--version'])
const [versionResult, authResult] = await Promise.all([
execFileNoThrow(claudeBin(), ['--version']),
execFileNoThrow(claudeBin(), ['config', 'get', 'oauthToken']),
])

if (versionResult.status !== 0) return base

base.claudeInstalled = true
Expand All @@ -42,7 +46,6 @@ export async function getAccountInfo(): Promise<AccountInfo> {
// settings.json absent or malformed — model stays null
}

const authResult = await execFileNoThrow(claudeBin(), ['config', 'get', 'oauthToken'])
base.authStatus = authResult.status === 0 && authResult.stdout.trim().length > 0
? 'authenticated'
: 'unauthenticated'
Expand Down
70 changes: 70 additions & 0 deletions backend/src/utils/resolveBin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'

const existsSyncMock = vi.hoisted(() => vi.fn<(p: string) => boolean>())

vi.mock('fs', async (importOriginal) => {
const original = await importOriginal<typeof import('fs')>()
return { ...original, existsSync: existsSyncMock, default: { ...original, existsSync: existsSyncMock } }
})

const { resolveBin } = await import('./resolveBin')

const originalPlatform = process.platform
const originalPath = process.env.PATH
const originalPathExt = process.env.PATHEXT

function setPlatform(p: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', { value: p, configurable: true })
}

describe('resolveBin', () => {
beforeEach(() => {
existsSyncMock.mockReset()
})

afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true })
process.env.PATH = originalPath
process.env.PATHEXT = originalPathExt
})

it('returns input unchanged on POSIX', () => {
setPlatform('linux')
expect(resolveBin('claude')).toBe('claude')
expect(resolveBin('/usr/local/bin/claude')).toBe('/usr/local/bin/claude')
expect(existsSyncMock).not.toHaveBeenCalled()
})

it('on Windows, resolves a bare name to <dir>\\<name>.CMD when only .cmd exists', () => {
setPlatform('win32')
process.env.PATH = 'C:\\bin;C:\\other'
process.env.PATHEXT = '.COM;.EXE;.CMD'
existsSyncMock.mockImplementation((p) => p === 'C:\\bin\\claude.CMD')

expect(resolveBin('claude')).toBe('C:\\bin\\claude.CMD')
})

it('on Windows, falls back to input when nothing matches on PATH', () => {
setPlatform('win32')
process.env.PATH = 'C:\\bin'
process.env.PATHEXT = '.EXE;.CMD'
existsSyncMock.mockReturnValue(false)

expect(resolveBin('claude')).toBe('claude')
})

it('on Windows, an absolute path with an existing extension is returned as-is', () => {
setPlatform('win32')
existsSyncMock.mockImplementation((p) => p === 'C:\\tools\\claude.cmd')

expect(resolveBin('C:\\tools\\claude.cmd')).toBe('C:\\tools\\claude.cmd')
})

it('on Windows, an absolute path without extension gets PATHEXT appended', () => {
setPlatform('win32')
process.env.PATHEXT = '.EXE;.CMD'
existsSyncMock.mockImplementation((p) => p === 'C:\\tools\\claude.CMD')

expect(resolveBin('C:\\tools\\claude')).toBe('C:\\tools\\claude.CMD')
})
})
39 changes: 39 additions & 0 deletions backend/src/utils/resolveBin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import * as fs from 'fs'
import * as path from 'path'

// node-pty on Windows uses CreateProcessW, which does not honor PATHEXT.
// Spawning a bare name like "claude" fails with "File not found: claude"
// even though `claude.cmd` is on PATH. Resolve PATH + PATHEXT explicitly here.
// On POSIX this is a no-op — node-pty's own PATH search works fine there.
export function resolveBin(bin: string): string {
if (process.platform !== 'win32') return bin

const exts = (process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD')
.split(';')
.map((e) => e.trim())
.filter(Boolean)

if (path.win32.isAbsolute(bin)) {
if (path.win32.extname(bin) && fs.existsSync(bin)) return bin
for (const ext of exts) {
const candidate = bin + ext
if (fs.existsSync(candidate)) return candidate
}
return bin
}

const dirs = (process.env.PATH ?? '').split(';').filter(Boolean)
const hasExt = !!path.win32.extname(bin)
for (const dir of dirs) {
if (hasExt) {
const candidate = path.win32.join(dir, bin)
if (fs.existsSync(candidate)) return candidate
} else {
for (const ext of exts) {
const candidate = path.win32.join(dir, bin + ext)
if (fs.existsSync(candidate)) return candidate
}
}
}
return bin
}
3 changes: 2 additions & 1 deletion backend/src/ws/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as path from 'path'
import type { WebSocket } from 'ws'
import type { FastifyInstance } from 'fastify'
import { db } from '../db/schema'
import { resolveBin } from '../utils/resolveBin'

const IDLE_TIMEOUT_MS = 30 * 60 * 1000

Expand Down Expand Up @@ -128,7 +129,7 @@ class ActiveSession {
).run(this.id, 'user', text, Date.now())
}

const claudeBin = process.env.CLAUDE_BIN ?? 'claude'
const claudeBin = resolveBin(process.env.CLAUDE_BIN?.trim() || 'claude')
const bypassPermissions = getBypassPermissions()

// --print + -p: non-interactive print mode with the prompt passed as a CLI
Expand Down
3 changes: 2 additions & 1 deletion backend/src/ws/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as path from 'path'
import type { WebSocket } from 'ws'
import type { FastifyInstance } from 'fastify'
import { db } from '../db/schema'
import { resolveBin } from '../utils/resolveBin'

const IDLE_TIMEOUT_MS = 30 * 60 * 1000

Expand Down Expand Up @@ -52,7 +53,7 @@ class ActiveTerminalSession {
}

private spawnPty(cols: number, rows: number) {
const claudeBin = process.env.CLAUDE_BIN ?? 'claude'
const claudeBin = resolveBin(process.env.CLAUDE_BIN?.trim() || 'claude')
const bypassPermissions = getBypassPermissions()
const args: string[] = []
if (bypassPermissions) args.push('--dangerously-skip-permissions')
Expand Down
9 changes: 6 additions & 3 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import { HashRouter, Routes, Route, Navigate } from 'react-router-dom'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import DashboardView from './views/DashboardView'
import SessionRoute from './views/SessionRoute'
import SettingsView from './views/SettingsView'
import { SessionProvider } from './context/SessionContext'

export default function App() {
return (
<HashRouter>
<BrowserRouter>
<SessionProvider>
<div className="flex h-screen bg-bg-base text-text-primary overflow-hidden font-mono">
<main className="flex-1 overflow-hidden flex flex-col">
<Routes>
<Route path="/" element={<DashboardView />} />
<Route path="/session/:sessionId" element={<SessionRoute />} />
<Route path="/new" element={<Navigate to="/" state={{ openModal: true }} replace />} />
<Route path="/settings" element={<SettingsView />} />
<Route path="/account" element={<Navigate to="/" replace />} />
<Route path="/usage" element={<Navigate to="/" replace />} />
Expand All @@ -19,6 +22,6 @@ export default function App() {
</main>
</div>
</SessionProvider>
</HashRouter>
</BrowserRouter>
)
}
8 changes: 5 additions & 3 deletions frontend/src/components/SessionHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react'
import { Plus, List, Square, Pencil } from 'lucide-react'
import { Link } from 'react-router-dom'
import { useSession } from '../context/SessionContext'
import { formatModelName, formatTokens, formatDuration } from '../utils/format'

Expand Down Expand Up @@ -164,13 +165,14 @@ export default function SessionHeader({
</div>

<div className="flex items-center gap-2 ml-auto">
<button
onClick={onNewSession}
<Link
to="/new"
onClick={(e) => { e.preventDefault(); onNewSession() }}
className="flex items-center gap-1.5 text-text-muted hover:text-accent text-xs bg-bg-elevated border border-border-subtle hover:border-accent px-2 py-1 rounded transition-colors"
>
<Plus size={11} />
New session
</button>
</Link>
<button
onClick={onStopSession}
className="flex items-center gap-1.5 text-text-muted hover:text-status-red text-xs bg-bg-elevated border border-border-subtle hover:border-status-red px-2 py-1 rounded transition-colors"
Expand Down
Loading
Loading