- Add ui.json configs for Main (purple) and Ejecutor (red) agents - AgentBar: fused arc-shaped dock at bottom with dynamic glow - Quick press opens styled terminal frame mockup - Hold opens voice modal with Web Speech API streaming transcription - Responsive: full-width mobile, max-width on tablet/desktop/4K - Agents API: serve uiConfig from ui.json in agent directories - Agents page: route, store, toolbar integration
240 lines
7.0 KiB
TypeScript
240 lines
7.0 KiB
TypeScript
import { jsonResponse, errorResponse } from '../utils/cors'
|
|
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
|
|
import { join, resolve, basename } from 'path'
|
|
|
|
const PROJECT_ROOT = resolve(import.meta.dir, '../..')
|
|
|
|
// Allowed config file patterns (for path validation on read/write)
|
|
const ALLOWED_PATTERNS = [
|
|
/^\.claude\/.*$/,
|
|
/^\.claude-[^/]+\/.*$/,
|
|
/^CLAUDE\.md$/,
|
|
/^\.mcp\.json$/
|
|
]
|
|
|
|
// Skip .git internals (hundreds of binary/pack files, useless for debugging)
|
|
const SKIP_DIRS = ['.git']
|
|
|
|
// Sensitive files blocked from read/write via API
|
|
const BLOCKED_FILES = ['.credentials.json']
|
|
|
|
type FileCategory = 'config' | 'instructions' | 'plugins' | 'history' | 'debug' | 'cache' | 'sessions' | 'backups' | 'other'
|
|
|
|
interface AgentFile {
|
|
name: string
|
|
path: string
|
|
type: 'json' | 'markdown' | 'text' | 'jsonl'
|
|
category: FileCategory
|
|
size: number
|
|
}
|
|
|
|
interface UiConfig {
|
|
label: string
|
|
shortLabel: string
|
|
color: string
|
|
gradient: string
|
|
terminalBg: string
|
|
terminalBorder: string
|
|
enabled: boolean
|
|
}
|
|
|
|
interface Agent {
|
|
id: string
|
|
name: string
|
|
directory: string
|
|
files: AgentFile[]
|
|
uiConfig: UiConfig | null
|
|
}
|
|
|
|
function getFileType(filename: string): AgentFile['type'] {
|
|
if (filename.endsWith('.jsonl')) return 'jsonl'
|
|
if (filename.endsWith('.json')) return 'json'
|
|
if (filename.endsWith('.md')) return 'markdown'
|
|
return 'text'
|
|
}
|
|
|
|
function categorizeFile(relPath: string, filename: string): FileCategory {
|
|
// Backups first (most specific)
|
|
if (filename.includes('.backup')) return 'backups'
|
|
|
|
// By directory
|
|
if (relPath.includes('/plugins/')) return 'plugins'
|
|
if (relPath.includes('/debug/')) return 'debug'
|
|
if (relPath.includes('/cache/')) return 'cache'
|
|
if (relPath.includes('/session-env/') || relPath.includes('/shell-snapshots/') || relPath.includes('/todos/') || relPath.includes('/projects/')) return 'sessions'
|
|
|
|
// By filename
|
|
if (filename === 'history.jsonl') return 'history'
|
|
if (filename.endsWith('.md')) return 'instructions'
|
|
if (filename === 'settings.json' || filename === '.claude.json' || filename === '.mcp.json') return 'config'
|
|
if (filename.endsWith('.json')) return 'config'
|
|
|
|
return 'other'
|
|
}
|
|
|
|
function isBlocked(filename: string): boolean {
|
|
return BLOCKED_FILES.includes(filename)
|
|
}
|
|
|
|
function isAllowedPath(relativePath: string): boolean {
|
|
const normalized = relativePath.replace(/\\/g, '/')
|
|
return ALLOWED_PATTERNS.some(p => p.test(normalized))
|
|
}
|
|
|
|
function scanDirectory(dir: string, relBase: string): AgentFile[] {
|
|
const files: AgentFile[] = []
|
|
if (!existsSync(dir)) return files
|
|
|
|
try {
|
|
const entries = readdirSync(dir)
|
|
for (const entry of entries) {
|
|
const fullPath = join(dir, entry)
|
|
const relPath = `${relBase}/${entry}`
|
|
|
|
let stat
|
|
try { stat = statSync(fullPath) } catch { continue }
|
|
|
|
if (isBlocked(entry)) continue
|
|
|
|
if (stat.isFile()) {
|
|
files.push({
|
|
name: entry,
|
|
path: relPath,
|
|
type: getFileType(entry),
|
|
category: categorizeFile(relPath, entry),
|
|
size: stat.size
|
|
})
|
|
} else if (stat.isDirectory()) {
|
|
if (SKIP_DIRS.includes(entry)) continue
|
|
files.push(...scanDirectory(fullPath, relPath))
|
|
}
|
|
}
|
|
} catch { /* permission errors */ }
|
|
|
|
return files
|
|
}
|
|
|
|
function readUiConfig(agentDir: string): UiConfig | null {
|
|
const uiPath = join(agentDir, 'ui.json')
|
|
if (!existsSync(uiPath)) return null
|
|
try {
|
|
const raw = readFileSync(uiPath, 'utf-8')
|
|
const data = JSON.parse(raw)
|
|
return {
|
|
label: data.label || '',
|
|
shortLabel: data.shortLabel || '',
|
|
color: data.color || '#6366f1',
|
|
gradient: data.gradient || '',
|
|
terminalBg: data.terminalBg || '#0f0a1a',
|
|
terminalBorder: data.terminalBorder || '#6366f1',
|
|
enabled: data.enabled !== false
|
|
}
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function discoverAgents(): Agent[] {
|
|
const agents: Agent[] = []
|
|
|
|
// Main agent (.claude/ + root files)
|
|
const claudeDir = join(PROJECT_ROOT, '.claude')
|
|
if (existsSync(claudeDir)) {
|
|
const mainAgent: Agent = {
|
|
id: 'main',
|
|
name: 'Claude Code (main)',
|
|
directory: '.claude',
|
|
files: scanDirectory(claudeDir, '.claude'),
|
|
uiConfig: readUiConfig(claudeDir)
|
|
}
|
|
|
|
// Root CLAUDE.md
|
|
const claudeMd = join(PROJECT_ROOT, 'CLAUDE.md')
|
|
if (existsSync(claudeMd)) {
|
|
const stat = statSync(claudeMd)
|
|
mainAgent.files.unshift({
|
|
name: 'CLAUDE.md', path: 'CLAUDE.md', type: 'markdown',
|
|
category: 'instructions', size: stat.size
|
|
})
|
|
}
|
|
|
|
// Root .mcp.json
|
|
const mcpJson = join(PROJECT_ROOT, '.mcp.json')
|
|
if (existsSync(mcpJson)) {
|
|
const stat = statSync(mcpJson)
|
|
mainAgent.files.push({
|
|
name: '.mcp.json', path: '.mcp.json', type: 'json',
|
|
category: 'config', size: stat.size
|
|
})
|
|
}
|
|
|
|
agents.push(mainAgent)
|
|
}
|
|
|
|
// Other agents (.claude-*/)
|
|
try {
|
|
const rootEntries = readdirSync(PROJECT_ROOT)
|
|
for (const entry of rootEntries) {
|
|
if (!entry.startsWith('.claude-')) continue
|
|
const fullPath = join(PROJECT_ROOT, entry)
|
|
try { if (!statSync(fullPath).isDirectory()) continue } catch { continue }
|
|
|
|
const agentId = entry.replace('.claude-', '')
|
|
agents.push({
|
|
id: agentId,
|
|
name: agentId.charAt(0).toUpperCase() + agentId.slice(1),
|
|
directory: entry,
|
|
files: scanDirectory(fullPath, entry),
|
|
uiConfig: readUiConfig(fullPath)
|
|
})
|
|
}
|
|
} catch { /* ignore */ }
|
|
|
|
return agents
|
|
}
|
|
|
|
export async function handleAgents(req: Request): Promise<Response | null> {
|
|
if (req.method !== 'GET') return null
|
|
return jsonResponse(discoverAgents())
|
|
}
|
|
|
|
export async function handleAgentsFile(req: Request, url: URL): Promise<Response | null> {
|
|
const filePath = url.searchParams.get('path')
|
|
if (!filePath) return errorResponse('Missing "path" query parameter')
|
|
|
|
const normalized = filePath.replace(/\\/g, '/')
|
|
if (!isAllowedPath(normalized)) return errorResponse('Access denied: path not allowed', 403)
|
|
if (isBlocked(basename(normalized))) return errorResponse('Access denied: sensitive file', 403)
|
|
|
|
const absolutePath = resolve(PROJECT_ROOT, normalized)
|
|
if (!absolutePath.startsWith(PROJECT_ROOT)) return errorResponse('Access denied: path traversal', 403)
|
|
|
|
if (req.method === 'GET') {
|
|
if (!existsSync(absolutePath)) return errorResponse('File not found', 404)
|
|
try {
|
|
const content = await Bun.file(absolutePath).text()
|
|
return jsonResponse({ path: normalized, content })
|
|
} catch (e: any) {
|
|
return errorResponse(`Failed to read file: ${e.message}`, 500)
|
|
}
|
|
}
|
|
|
|
if (req.method === 'POST') {
|
|
try {
|
|
const body = await req.json()
|
|
if (typeof body.content !== 'string') return errorResponse('Missing "content" field')
|
|
|
|
if (normalized.endsWith('.json')) {
|
|
try { JSON.parse(body.content) } catch { return errorResponse('Invalid JSON content') }
|
|
}
|
|
|
|
await Bun.write(absolutePath, body.content)
|
|
return jsonResponse({ success: true, path: normalized })
|
|
} catch (e: any) {
|
|
return errorResponse(`Failed to write file: ${e.message}`, 500)
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|