diff options
| author | Adam Malczewski <[email protected]> | 2026-05-22 15:24:13 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-22 15:24:13 +0900 |
| commit | 9ecaabd87c0e51b8a7408dabb0133a9344586859 (patch) | |
| tree | 4b5809ab23948a5e3f558f3aa34c52d5038f4e19 /packages/core/src | |
| parent | 8f1dd855f0c4c877bff8d3a4ba193432b268b1c2 (diff) | |
| download | dispatch-9ecaabd87c0e51b8a7408dabb0133a9344586859.tar.gz dispatch-9ecaabd87c0e51b8a7408dabb0133a9344586859.zip | |
feat: agent builder, CWD support, auto-save, UI polish, unavailable tool handling
- Agent Builder: full CRUD with card grid, drag-and-drop model reorder, edit/delete
- Auto-save on edit with 600ms debounce, AbortController for concurrency, fieldset disabled until name entered
- Agent definitions stored as TOML with cwd field, loaded from global/project dirs
- Working directory: per-tab CWD override in Chat Settings, agent default CWD, auto-create on first message
- CWD validation: check-dir endpoint with ~ expansion, real-time validity indicator
- Subagent CWD validated against parent's effective CWD using path.relative
- Unavailable tool calls: caught gracefully, shown as tool call with error badge, model retries
- UI: tab bar border radius, sidebar border removed, chat input ghost style, scroll-to-bottom rectangle
- Skills dir collapse uses CSS rotation, Model Choice renamed to Chat Settings, System Prompt view removed
- Reusable SkillsBrowser/ToolPermissions with external mode for Agent Builder
- ModelSelector: Agent/Manual toggle, agent list, Agent Settings link
- Page router, skills recursive scanning, bin/up gopass removed, docker volume mounts
Diffstat (limited to 'packages/core/src')
23 files changed, 629 insertions, 280 deletions
diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts index 006ee1b..c2c5880 100644 --- a/packages/core/src/agent/agent.ts +++ b/packages/core/src/agent/agent.ts @@ -1,11 +1,11 @@ +import { realpathSync } from "node:fs"; +import { dirname, isAbsolute, relative, resolve } from "node:path"; import type { CoreMessage } from "ai"; import { streamText } from "ai"; -import { dirname, isAbsolute, relative, resolve } from "node:path"; -import { realpathSync } from "node:fs"; +import { buildBillingHeaderValue, SYSTEM_IDENTITY } from "../credentials/claude.js"; import { createProvider, prefixToolName, unprefixToolName } from "../llm/provider.js"; import { createToolRegistry } from "../tools/registry.js"; import { analyzeCommand } from "../tools/shell-analyze.js"; -import { buildBillingHeaderValue, SYSTEM_IDENTITY } from "../credentials/claude.js"; import type { AgentConfig, AgentEvent, @@ -21,7 +21,10 @@ function toCoreMessages(messages: ChatMessage[], isAnthropic?: boolean): CoreMes if (msg.role === "user") { result.push({ role: "user", content: msg.content }); } else if (msg.role === "assistant") { - const parts: Array<{ type: "text"; text: string } | { type: "tool-call"; toolCallId: string; toolName: string; args: Record<string, unknown> }> = [{ type: "text", text: msg.content }]; + const parts: Array< + | { type: "text"; text: string } + | { type: "tool-call"; toolCallId: string; toolName: string; args: Record<string, unknown> } + > = [{ type: "text", text: msg.content }]; for (const tc of msg.toolCalls ?? []) { const toolName = isAnthropic ? prefixToolName(tc.name) : tc.name; parts.push({ type: "tool-call", toolCallId: tc.id, toolName, args: tc.arguments }); @@ -29,7 +32,12 @@ function toCoreMessages(messages: ChatMessage[], isAnthropic?: boolean): CoreMes result.push({ role: "assistant", content: parts }); for (const tr of msg.toolResults ?? []) { const toolName = isAnthropic ? prefixToolName(tr.toolName) : tr.toolName; - result.push({ role: "tool", content: [{ type: "tool-result", toolCallId: tr.toolCallId, toolName, result: tr.result }] }); + result.push({ + role: "tool", + content: [ + { type: "tool-result", toolCallId: tr.toolCallId, toolName, result: tr.result }, + ], + }); } } } @@ -76,7 +84,12 @@ export class Agent { const registry = createToolRegistry(this.config.tools); const tool = registry.getTool(tc.name); if (!tool) { - return { toolCallId: tc.id, toolName: tc.name, result: `Unknown tool: ${tc.name}`, isError: true }; + return { + toolCallId: tc.id, + toolName: tc.name, + result: `Unknown tool: ${tc.name}`, + isError: true, + }; } // Permission check for shell commands — only prompt for external directory access. @@ -133,7 +146,8 @@ export class Agent { // Check if outside workspace const rel = relative(this.config.workingDirectory, resolvedPath); - const isOutside = rel.startsWith("../") || rel.startsWith("..\\") || rel === ".." || isAbsolute(rel); + const isOutside = + rel.startsWith("../") || rel.startsWith("..\\") || rel === ".." || isAbsolute(rel); if (isOutside) { const permissionType = @@ -194,7 +208,10 @@ export class Agent { } } - async *run(userMessage: string, options?: { reasoningEffort?: "none" | "low" | "medium" | "high" | "max" }): AsyncGenerator<AgentEvent> { + async *run( + userMessage: string, + options?: { reasoningEffort?: "none" | "low" | "medium" | "high" | "max" }, + ): AsyncGenerator<AgentEvent> { this.status = "running"; yield { type: "status", status: "running" }; @@ -213,8 +230,8 @@ export class Agent { const aiTools = registry.getAISDKTools(); const tools = isAnthropic ? Object.fromEntries( - Object.entries(aiTools).map(([name, tool]) => [prefixToolName(name), tool]), - ) + Object.entries(aiTools).map(([name, tool]) => [prefixToolName(name), tool]), + ) : aiTools; // Build system prompt @@ -246,8 +263,19 @@ export class Agent { }; if (isAnthropic && effort !== "none") { - const budgetTokens = effort === "max" ? 16000 : effort === "high" ? 10000 : effort === "medium" ? 5000 : effort === "low" ? 2000 : 0; - streamOptions.providerOptions = { anthropic: { thinking: { type: "enabled" as const, budgetTokens } } }; + const budgetTokens = + effort === "max" + ? 16000 + : effort === "high" + ? 10000 + : effort === "medium" + ? 5000 + : effort === "low" + ? 2000 + : 0; + streamOptions.providerOptions = { + anthropic: { thinking: { type: "enabled" as const, budgetTokens } }, + }; streamOptions.maxTokens = budgetTokens + 8000; } else if (!isAnthropic && effort !== "none") { streamOptions.providerOptions = { openaiCompatible: { reasoningEffort: effort } }; @@ -258,31 +286,64 @@ export class Agent { let stepText = ""; const stepToolCalls: ToolCall[] = []; - for await (const event of result.fullStream) { - if (event.type === "text-delta") { - stepText += event.textDelta; - finalText += event.textDelta; - yield { type: "text-delta", delta: event.textDelta }; - } else if (event.type === "reasoning") { - yield { type: "reasoning-delta", delta: event.textDelta }; - } else if (event.type === "tool-call") { - const rawName = event.toolName; - const toolName = isAnthropic ? unprefixToolName(rawName) : rawName; - const toolCall: ToolCall = { - id: event.toolCallId, - name: toolName, - arguments: event.args as Record<string, unknown>, - }; - stepToolCalls.push(toolCall); - allToolCalls.push(toolCall); - yield { type: "tool-call", toolCall }; - } else if (event.type === "error") { - const errorMsg = formatError(event.error, this.config); - yield { type: "error", error: errorMsg }; - this.status = "error"; - yield { type: "status", status: "error" }; - return; + try { + for await (const event of result.fullStream) { + if (event.type === "text-delta") { + stepText += event.textDelta; + finalText += event.textDelta; + yield { type: "text-delta", delta: event.textDelta }; + } else if (event.type === "reasoning") { + yield { type: "reasoning-delta", delta: event.textDelta }; + } else if (event.type === "tool-call") { + const rawName = event.toolName; + const toolName = isAnthropic ? unprefixToolName(rawName) : rawName; + const toolCall: ToolCall = { + id: event.toolCallId, + name: toolName, + arguments: event.args as Record<string, unknown>, + }; + stepToolCalls.push(toolCall); + allToolCalls.push(toolCall); + yield { type: "tool-call", toolCall }; + } else if (event.type === "error") { + const errorMsg = formatError(event.error, this.config); + yield { type: "error", error: errorMsg }; + this.status = "error"; + yield { type: "status", status: "error" }; + return; + } } + } catch (streamErr) { + const errMsg = streamErr instanceof Error ? streamErr.message : String(streamErr); + const unavailMatch = errMsg.match( + /tried to call unavailable tool '([^']+)'/i, + ); + if (!unavailMatch) throw streamErr; + + // Model tried to call an unavailable tool. + // Add a synthetic tool call + error result for the bad tool. + const badToolName = unavailMatch[1]; + const fakeId = `unavail_${crypto.randomUUID().slice(0, 8)}`; + const availableTools = Object.keys(aiTools).join(", "); + const errorResult = `Tool "${badToolName}" is not available. Available tools: ${availableTools}. Please use only available tools.`; + + const badToolCall: ToolCall = { + id: fakeId, + name: badToolName, + arguments: {}, + }; + stepToolCalls.push(badToolCall); + allToolCalls.push(badToolCall); + yield { type: "tool-call", toolCall: badToolCall }; + + const badToolResult: ToolResult = { + toolCallId: fakeId, + toolName: badToolName, + result: errorResult, + isError: true, + }; + allToolResults.push(badToolResult); + yield { type: "tool-result", toolResult: badToolResult }; } // No tool calls means the agent is done @@ -304,7 +365,17 @@ export class Agent { // Execute tool calls manually const stepToolResults: ToolResult[] = []; + // Track tool calls that already have results (e.g. synthetic unavailable-tool errors) + const alreadyResolved = new Set(allToolResults.map((r) => r.toolCallId)); + for (const tc of stepToolCalls) { + // Skip execution for tool calls that already have synthetic results + if (alreadyResolved.has(tc.id)) { + const existing = allToolResults.find((r) => r.toolCallId === tc.id); + if (existing) stepToolResults.push(existing); + continue; + } + const shellOutputQueue: Array<{ data: string; stream: "stdout" | "stderr" }> = []; const execPromise = this.executeToolWithStreaming(tc, shellOutputQueue); @@ -321,7 +392,9 @@ export class Agent { } const raceResult = await Promise.race([ execPromise.then((r) => ({ done: true as const, value: r })), - new Promise<{ done: false }>((resolve) => setImmediate(() => resolve({ done: false }))), + new Promise<{ done: false }>((resolve) => + setImmediate(() => resolve({ done: false })), + ), ]); if (raceResult.done) { toolResult = raceResult.value; diff --git a/packages/core/src/agents/index.ts b/packages/core/src/agents/index.ts new file mode 100644 index 0000000..13f6244 --- /dev/null +++ b/packages/core/src/agents/index.ts @@ -0,0 +1 @@ +export { deleteAgent, getAgentDirs, loadAgents, saveAgent } from "./loader.js"; diff --git a/packages/core/src/agents/loader.ts b/packages/core/src/agents/loader.ts new file mode 100644 index 0000000..ec29983 --- /dev/null +++ b/packages/core/src/agents/loader.ts @@ -0,0 +1,212 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { parse as parseTOML, stringify as stringifyTOML } from "smol-toml"; +import type { AgentDefinition, AgentModelEntry } from "../types/index.js"; + +// ─── Helpers ───────────────────────────────────────────────────── + +/** Sanitize a slug to prevent path traversal */ +function sanitizeSlug(slug: string): string { + // Strip directory components and ensure only safe characters + const base = path.basename(slug); + const clean = base + .replace(/[^a-zA-Z0-9_-]/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); + if (!clean) throw new Error("Invalid agent slug"); + return clean; +} + +// ─── Constants ─────────────────────────────────────────────────── + +const GLOBAL_AGENTS_DIR = path.join(os.homedir(), ".config", "dispatch", "agents"); + +function getProjectAgentsDir(projectDir: string): string { + return path.join(projectDir, ".dispatch", "agents"); +} + +// ─── Public API ────────────────────────────────────────────────── + +/** + * Returns the agent directories that exist or could exist. + * Always includes global. Includes project dir if projectDir is provided. + */ +export function getAgentDirs( + projectDir?: string, +): Array<{ label: string; path: string; scope: string }> { + const dirs: Array<{ label: string; path: string; scope: string }> = [ + { label: "Global (~/.config/dispatch/agents)", path: GLOBAL_AGENTS_DIR, scope: "global" }, + ]; + if (projectDir) { + dirs.push({ + label: `.dispatch/agents (${path.basename(projectDir)})`, + path: getProjectAgentsDir(projectDir), + scope: projectDir, + }); + } + return dirs; +} + +/** + * Ensure the default global agent exists. Creates it if missing. + */ +function ensureDefaultAgent(): void { + const filePath = path.join(GLOBAL_AGENTS_DIR, "default.toml"); + if (fs.existsSync(filePath)) return; + + const defaultAgent: AgentDefinition = { + name: "Default", + description: "Default agent with all tools enabled", + skills: [], + tools: ["read", "edit", "bash", "summon"], + models: [], + scope: "global", + slug: "default", + }; + saveAgent(defaultAgent); +} + +/** + * Load all agent definitions from global + project directories. + * Auto-generates the default global agent if it doesn't exist. + */ +export function loadAgents(projectDir?: string): AgentDefinition[] { + ensureDefaultAgent(); + + const agents: AgentDefinition[] = []; + + // Global agents + agents.push(...loadAgentsFromDir(GLOBAL_AGENTS_DIR, "global")); + + // Project-scoped agents + if (projectDir) { + agents.push(...loadAgentsFromDir(getProjectAgentsDir(projectDir), projectDir)); + } + + return agents; +} + +/** + * Save (create or update) an agent definition to a TOML file. + * The scope determines which directory: + * - "global" -> ~/.config/dispatch/agents/ + * - any other string -> that directory path + /.dispatch/agents/ + */ +export function saveAgent(agent: AgentDefinition): void { + if (agent.scope !== "global" && agent.scope.includes("..")) { + throw new Error("Invalid agent scope"); + } + const dir = agent.scope === "global" ? GLOBAL_AGENTS_DIR : getProjectAgentsDir(agent.scope); + + fs.mkdirSync(dir, { recursive: true }); + + const tomlContent: Record<string, unknown> = { + name: agent.name, + description: agent.description, + skills: agent.skills, + tools: agent.tools, + }; + + if (agent.cwd) { + tomlContent.cwd = agent.cwd; + } + + // smol-toml handles [[models]] array-of-tables + if (agent.models.length > 0) { + tomlContent.models = agent.models.map((m) => ({ + key_id: m.key_id, + model_id: m.model_id, + })); + } + + const content = stringifyTOML(tomlContent); + const safeSlug = sanitizeSlug(agent.slug); + const filePath = path.join(dir, `${safeSlug}.toml`); + fs.writeFileSync(filePath, content, "utf-8"); +} + +/** + * Delete an agent TOML file. + */ +export function deleteAgent(slug: string, scope: string): boolean { + if (scope !== "global" && scope.includes("..")) { + throw new Error("Invalid agent scope"); + } + const dir = scope === "global" ? GLOBAL_AGENTS_DIR : getProjectAgentsDir(scope); + + const safeSlug = sanitizeSlug(slug); + const filePath = path.join(dir, `${safeSlug}.toml`); + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + return true; + } + return false; +} + +// ─── Internal ──────────────────────────────────────────────────── + +function loadAgentsFromDir(dir: string, scope: string): AgentDefinition[] { + if (!fs.existsSync(dir)) return []; + + const results: AgentDefinition[] = []; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return []; + } + + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith(".toml")) continue; + + const filePath = path.join(dir, entry.name); + const slug = entry.name.slice(0, -5); // remove .toml + + try { + const raw = fs.readFileSync(filePath, "utf-8"); + const parsed = parseTOML(raw); + + const models: AgentModelEntry[] = []; + if (Array.isArray(parsed.models)) { + for (const m of parsed.models) { + if (m && typeof m === "object" && "key_id" in m && "model_id" in m) { + models.push({ + key_id: String((m as Record<string, unknown>).key_id), + model_id: String((m as Record<string, unknown>).model_id), + }); + } + } + } + + const skills: string[] = []; + if (Array.isArray(parsed.skills)) { + for (const s of parsed.skills) { + if (typeof s === "string") skills.push(s); + } + } + + const tools: string[] = []; + if (Array.isArray(parsed.tools)) { + for (const t of parsed.tools) { + if (typeof t === "string") tools.push(t); + } + } + + results.push({ + name: typeof parsed.name === "string" ? parsed.name : slug, + description: typeof parsed.description === "string" ? parsed.description : "", + skills, + tools, + models, + scope, + slug, + ...(typeof parsed.cwd === "string" && parsed.cwd ? { cwd: parsed.cwd } : {}), + }); + } catch { + // Skip unparseable files + } + } + + return results; +} diff --git a/packages/core/src/config/loader.ts b/packages/core/src/config/loader.ts index 3b4d733..bccbb8f 100644 --- a/packages/core/src/config/loader.ts +++ b/packages/core/src/config/loader.ts @@ -28,7 +28,9 @@ export function loadConfig(dir: string): DispatchConfig { // File doesn't exist — return empty default return DEFAULT_CONFIG; } - console.warn(`dispatch: failed to parse dispatch.toml: ${err instanceof Error ? err.message : String(err)}`); + console.warn( + `dispatch: failed to parse dispatch.toml: ${err instanceof Error ? err.message : String(err)}`, + ); throw err; } diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts index 2cd8d55..ef10b65 100644 --- a/packages/core/src/config/schema.ts +++ b/packages/core/src/config/schema.ts @@ -1,8 +1,4 @@ -import type { - ConfigError, - DispatchConfig, - KeyDefinition, -} from "../types/index.js"; +import type { ConfigError, DispatchConfig, KeyDefinition } from "../types/index.js"; function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -33,19 +29,28 @@ function validatePermissions( const result: Record<string, string | Record<string, string>> = {}; for (const [key, value] of Object.entries(raw)) { if (!isPermissionsValue(value)) { - errors.push({ path: `${path}.${key}`, message: "must be a string or a flat string-keyed object" }); + errors.push({ + path: `${path}.${key}`, + message: "must be a string or a flat string-keyed object", + }); continue; } if (typeof value === "string") { if (!isValidAction(value)) { - errors.push({ path: `${path}.${key}`, message: `invalid action "${value}"; must be "allow", "deny", or "ask"` }); + errors.push({ + path: `${path}.${key}`, + message: `invalid action "${value}"; must be "allow", "deny", or "ask"`, + }); continue; } } else { let hasError = false; for (const [pattern, action] of Object.entries(value)) { if (!isValidAction(action)) { - errors.push({ path: `${path}.${key}.${pattern}`, message: `invalid action "${action}"; must be "allow", "deny", or "ask"` }); + errors.push({ + path: `${path}.${key}.${pattern}`, + message: `invalid action "${action}"; must be "allow", "deny", or "ask"`, + }); hasError = true; } } @@ -80,7 +85,9 @@ function validateKey(raw: unknown, path: string, errors: ConfigError[]): KeyDefi id: raw["id"] as string, provider: raw["provider"] as string, base_url: raw["base_url"] as string, - ...(typeof raw["credentials_file"] === "string" ? { credentials_file: raw["credentials_file"] } as Pick<KeyDefinition, "credentials_file"> : {}), + ...(typeof raw["credentials_file"] === "string" + ? ({ credentials_file: raw["credentials_file"] } as Pick<KeyDefinition, "credentials_file">) + : {}), }; } diff --git a/packages/core/src/config/watcher.ts b/packages/core/src/config/watcher.ts index 42b2f87..70821ed 100644 --- a/packages/core/src/config/watcher.ts +++ b/packages/core/src/config/watcher.ts @@ -1,5 +1,5 @@ -import { watch } from "chokidar"; import { join } from "node:path"; +import { watch } from "chokidar"; import type { DispatchConfig } from "../types/index.js"; import { loadConfig } from "./loader.js"; @@ -26,7 +26,9 @@ export function createConfigWatcher( const config = loadConfig(dir); onChange(config); } catch (err) { - console.warn(`dispatch: retaining last known config due to parse error: ${err instanceof Error ? err.message : String(err)}`); + console.warn( + `dispatch: retaining last known config due to parse error: ${err instanceof Error ? err.message : String(err)}`, + ); } }, 300); }; @@ -36,7 +38,9 @@ export function createConfigWatcher( watcher.on("unlink", handleChange); watcher.on("error", (err) => { - console.warn(`dispatch: config watcher error: ${err instanceof Error ? err.message : String(err)}`); + console.warn( + `dispatch: config watcher error: ${err instanceof Error ? err.message : String(err)}`, + ); }); return { @@ -46,7 +50,9 @@ export function createConfigWatcher( debounceTimer = null; } watcher.close().catch((err) => { - console.warn(`dispatch: error closing config watcher: ${err instanceof Error ? err.message : String(err)}`); + console.warn( + `dispatch: error closing config watcher: ${err instanceof Error ? err.message : String(err)}`, + ); }); }, }; diff --git a/packages/core/src/credentials/api-keys.ts b/packages/core/src/credentials/api-keys.ts index 5f92ffa..af5aa0e 100644 --- a/packages/core/src/credentials/api-keys.ts +++ b/packages/core/src/credentials/api-keys.ts @@ -33,9 +33,9 @@ export function setApiKey(keyId: string, provider: string, apiKey: string): void */ export function getApiKey(keyId: string): string | null { const db = getDatabase(); - const row = db.query( - "SELECT api_key FROM api_keys WHERE key_id = $keyId", - ).get({ $keyId: keyId }) as { api_key: string } | null; + const row = db + .query("SELECT api_key FROM api_keys WHERE key_id = $keyId") + .get({ $keyId: keyId }) as { api_key: string } | null; return row?.api_key ?? null; } @@ -57,11 +57,16 @@ export function deleteApiKey(keyId: string): void { /** * List all stored API keys with metadata (key value excluded for security). */ -export function listApiKeys(): Array<{ keyId: string; provider: string; importedAt: number; updatedAt: number }> { +export function listApiKeys(): Array<{ + keyId: string; + provider: string; + importedAt: number; + updatedAt: number; +}> { const db = getDatabase(); - const rows = db.query( - "SELECT key_id, provider, imported_at, updated_at FROM api_keys ORDER BY key_id", - ).all() as Array<Record<string, unknown>>; + const rows = db + .query("SELECT key_id, provider, imported_at, updated_at FROM api_keys ORDER BY key_id") + .all() as Array<Record<string, unknown>>; return rows.map((row) => ({ keyId: row.key_id as string, provider: row.provider as string, diff --git a/packages/core/src/credentials/claude.ts b/packages/core/src/credentials/claude.ts index 1b9d148..6018207 100644 --- a/packages/core/src/credentials/claude.ts +++ b/packages/core/src/credentials/claude.ts @@ -1,9 +1,16 @@ -import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, readdirSync } from "node:fs"; -import { getStoredCredentials, updateStoredTokens, listStoredCredentials } from "./store.js"; -import { getDatabase } from "../db/index.js"; -import { dirname, join, basename } from "node:path"; -import { homedir } from "node:os"; import { createHash } from "node:crypto"; +import { + chmodSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { basename, dirname, join } from "node:path"; +import { getDatabase } from "../db/index.js"; +import { getStoredCredentials, listStoredCredentials, updateStoredTokens } from "./store.js"; export interface ClaudeCredentials { accessToken: string; @@ -41,7 +48,10 @@ function parseCredentialsFile(raw: string): ClaudeCredentials | null { const data = (parsed as Record<string, unknown>).claudeAiOauth ?? parsed; const creds = data as Record<string, unknown>; - if ((creds as Record<string, unknown>).mcpOAuth && !(creds as Record<string, unknown>).accessToken) { + if ( + (creds as Record<string, unknown>).mcpOAuth && + !(creds as Record<string, unknown>).accessToken + ) { return null; } @@ -57,7 +67,8 @@ function parseCredentialsFile(raw: string): ClaudeCredentials | null { accessToken: creds.accessToken as string, refreshToken: creds.refreshToken as string, expiresAt: creds.expiresAt as number, - subscriptionType: typeof creds.subscriptionType === "string" ? creds.subscriptionType : undefined, + subscriptionType: + typeof creds.subscriptionType === "string" ? creds.subscriptionType : undefined, }; } @@ -122,7 +133,7 @@ async function refreshViaOAuth(refreshToken: string): Promise<ClaudeCredentials return null; } - const data = await response.json() as Record<string, unknown>; + const data = (await response.json()) as Record<string, unknown>; if (!data.access_token || typeof data.access_token !== "string") { return null; } @@ -131,7 +142,8 @@ async function refreshViaOAuth(refreshToken: string): Promise<ClaudeCredentials accessToken: data.access_token as string, refreshToken: (data.refresh_token as string) ?? refreshToken, expiresAt: Date.now() + ((data.expires_in as number) ?? 36_000) * 1000, - subscriptionType: typeof data.subscriptionType === "string" ? data.subscriptionType : undefined, + subscriptionType: + typeof data.subscriptionType === "string" ? data.subscriptionType : undefined, }; } catch { return null; @@ -220,7 +232,11 @@ export function discoverClaudeAccounts(): ClaudeAccount[] { export function refreshAccountCredentials(account: ClaudeAccount): ClaudeCredentials | null { const cached = accountCacheMap.get(account.id); const now = Date.now(); - if (cached && now - cached.cachedAt < CREDENTIAL_CACHE_TTL_MS && cached.creds.expiresAt > now + 60_000) { + if ( + cached && + now - cached.cachedAt < CREDENTIAL_CACHE_TTL_MS && + cached.creds.expiresAt > now + 60_000 + ) { return cached.creds; } @@ -257,10 +273,16 @@ export function refreshAccountCredentials(account: ClaudeAccount): ClaudeCredent return null; } -export async function refreshAccountCredentialsAsync(account: ClaudeAccount): Promise<ClaudeCredentials | null> { +export async function refreshAccountCredentialsAsync( + account: ClaudeAccount, +): Promise<ClaudeCredentials | null> { const cached = accountCacheMap.get(account.id); const now = Date.now(); - if (cached && now - cached.cachedAt < CREDENTIAL_CACHE_TTL_MS && cached.creds.expiresAt > now + 60_000) { + if ( + cached && + now - cached.cachedAt < CREDENTIAL_CACHE_TTL_MS && + cached.creds.expiresAt > now + 60_000 + ) { return cached.creds; } @@ -294,7 +316,12 @@ export async function refreshAccountCredentialsAsync(account: ClaudeAccount): Pr account.credentials = refreshed; // Update DB if this is a DB-backed account, otherwise write to file if (account.source.startsWith("db:")) { - updateStoredTokens(account.id, refreshed.accessToken, refreshed.refreshToken, refreshed.expiresAt); + updateStoredTokens( + account.id, + refreshed.accessToken, + refreshed.refreshToken, + refreshed.expiresAt, + ); } else { writeCredentialsFile(account.source, refreshed); } @@ -323,14 +350,14 @@ function computeCch(messageText: string): string { } function computeVersionSuffix(messageText: string, version: string): string { - const sampled = [4, 7, 20] - .map((i) => (i < messageText.length ? messageText[i] : "0")) - .join(""); + const sampled = [4, 7, 20].map((i) => (i < messageText.length ? messageText[i] : "0")).join(""); const input = `${BILLING_SALT}${sampled}${version}`; return createHash("sha256").update(input).digest("hex").slice(0, 3); } -export function buildBillingHeaderValue(messages: Array<{ role: string; content: string }>): string { +export function buildBillingHeaderValue( + messages: Array<{ role: string; content: string }>, +): string { const text = extractFirstUserMessageText(messages); const version = process.env.ANTHROPIC_CLI_VERSION ?? CC_VERSION; const suffix = computeVersionSuffix(text, version); @@ -404,11 +431,16 @@ export async function fetchAnthropicModels(accessToken: string): Promise<string[ return []; } - const data = (await response.json()) as { data?: Array<{ id: string }>; models?: Array<{ id: string }> }; + const data = (await response.json()) as { + data?: Array<{ id: string }>; + models?: Array<{ id: string }>; + }; const entries = data.data ?? data.models ?? []; return entries.map((m) => m.id).filter(Boolean); } catch (err) { - console.warn(`dispatch: failed to fetch Anthropic models: ${err instanceof Error ? err.message : String(err)}`); + console.warn( + `dispatch: failed to fetch Anthropic models: ${err instanceof Error ? err.message : String(err)}`, + ); return []; } } @@ -434,7 +466,9 @@ export interface ClaudeProfile { * Validate that Claude credentials are usable by hitting the OAuth profile endpoint. * Returns the profile info if valid, or null if the token is dead. */ -export async function validateAccountCredentials(account: ClaudeAccount): Promise<ClaudeProfile | null> { +export async function validateAccountCredentials( + account: ClaudeAccount, +): Promise<ClaudeProfile | null> { const creds = await refreshAccountCredentialsAsync(account); if (!creds) return null; @@ -487,7 +521,8 @@ async function fetchClaudeUsage(accessToken: string): Promise<ClaudeUsageReport // API returns utilization as 0-100 percentage; normalize to 0-1 fraction const rawUtil = typeof b.utilization === "number" ? b.utilization : undefined; const utilization = rawUtil !== undefined ? rawUtil / 100 : undefined; - const resetsAt = typeof b.resets_at === "string" ? Date.parse(b.resets_at as string) : undefined; + const resetsAt = + typeof b.resets_at === "string" ? Date.parse(b.resets_at as string) : undefined; if (utilization === undefined && resetsAt === undefined) return undefined; return { utilization, resetsAt }; }; @@ -503,9 +538,13 @@ async function fetchClaudeUsage(accessToken: string): Promise<ClaudeUsageReport // Try to extract identity const accountId = - typeof data.account_id === "string" ? data.account_id : - typeof data.user_id === "string" ? data.user_id : - typeof data.org_id === "string" ? data.org_id : undefined; + typeof data.account_id === "string" + ? data.account_id + : typeof data.user_id === "string" + ? data.user_id + : typeof data.org_id === "string" + ? data.org_id + : undefined; if (accountId) report.accountId = accountId; const email = typeof data.email === "string" ? data.email : undefined; @@ -520,9 +559,9 @@ async function fetchClaudeUsage(accessToken: string): Promise<ClaudeUsageReport function getCachedUsage(keyId: string): ClaudeUsageReport | null { try { const db = getDatabase(); - const row = db.query( - "SELECT report_json FROM usage_cache WHERE key_id = $keyId", - ).get({ $keyId: keyId }) as { report_json: string } | null; + const row = db + .query("SELECT report_json FROM usage_cache WHERE key_id = $keyId") + .get({ $keyId: keyId }) as { report_json: string } | null; if (!row) return null; return JSON.parse(row.report_json) as ClaudeUsageReport; } catch { @@ -559,4 +598,4 @@ export async function getAccountUsage(account: ClaudeAccount): Promise<ClaudeUsa return report; } return getCachedUsage(account.id); -}
\ No newline at end of file +} diff --git a/packages/core/src/credentials/index.ts b/packages/core/src/credentials/index.ts index 8cbedd9..e7f8a12 100644 --- a/packages/core/src/credentials/index.ts +++ b/packages/core/src/credentials/index.ts @@ -1,4 +1,12 @@ export { + deleteApiKey, + getApiKey, + listApiKeys, + resolveApiKey, + type StoredApiKey, + setApiKey, +} from "./api-keys.js"; +export { ANTHROPIC_MODELS_FALLBACK, buildBillingHeaderValue, type ClaudeAccount, @@ -7,11 +15,11 @@ export { type ClaudeUsageBucket, type ClaudeUsageReport, discoverClaudeAccounts, - getClaudeAccountsFromDB, fetchAnthropicModels, getAccountUsage, getAnthropicBetas, getAnthropicHeaders, + getClaudeAccountsFromDB, refreshAccountCredentials, refreshAccountCredentialsAsync, SYSTEM_IDENTITY, @@ -27,18 +35,10 @@ export { type OpencodeUsageReport, } from "./opencode.js"; export { - type StoredCredential, - importCredentialsFromFile, - getStoredCredentials, - updateStoredTokens, deleteStoredCredentials, + getStoredCredentials, + importCredentialsFromFile, listStoredCredentials, + type StoredCredential, + updateStoredTokens, } from "./store.js"; -export { - type StoredApiKey, - setApiKey, - getApiKey, - resolveApiKey, - deleteApiKey, - listApiKeys, -} from "./api-keys.js"; diff --git a/packages/core/src/credentials/opencode.ts b/packages/core/src/credentials/opencode.ts index b8a9d4d..d4d4851 100644 --- a/packages/core/src/credentials/opencode.ts +++ b/packages/core/src/credentials/opencode.ts @@ -70,9 +70,7 @@ function parseOcBucket( return { utilization, resetsAt }; } -export async function fetchOpencodeUsage( - keyId: string, -): Promise<OpencodeUsageReport | null> { +export async function fetchOpencodeUsage(keyId: string): Promise<OpencodeUsageReport | null> { const cookie = resolveApiKey("opencode-cookie"); const wsId = getWorkspaceId(keyId); @@ -96,10 +94,7 @@ export async function fetchOpencodeUsage( const html = await response.text(); // Auth redirect check - if ( - html.includes("/auth/authorize") || - html.includes('window.location="/auth/authorize"') - ) { + if (html.includes("/auth/authorize") || html.includes('window.location="/auth/authorize"')) { return null; } diff --git a/packages/core/src/credentials/store.ts b/packages/core/src/credentials/store.ts index 6c814f9..662b322 100644 --- a/packages/core/src/credentials/store.ts +++ b/packages/core/src/credentials/store.ts @@ -1,6 +1,6 @@ +import { existsSync, readFileSync } from "node:fs"; import { getDatabase } from "../db/index.js"; import type { ClaudeCredentials } from "./claude.js"; -import { existsSync, readFileSync } from "node:fs"; export interface StoredCredential { keyId: string; @@ -41,7 +41,8 @@ function parseCredentialsFile(raw: string): ClaudeCredentials | null { accessToken: creds.accessToken as string, refreshToken: creds.refreshToken as string, expiresAt: creds.expiresAt as number, - subscriptionType: typeof creds.subscriptionType === "string" ? creds.subscriptionType : undefined, + subscriptionType: + typeof creds.subscriptionType === "string" ? creds.subscriptionType : undefined, }; } @@ -62,7 +63,10 @@ export function importCredentialsFromFile( try { raw = readFileSync(filePath, "utf-8").trim(); } catch (e) { - return { success: false, error: `Failed to read file: ${e instanceof Error ? e.message : String(e)}` }; + return { + success: false, + error: `Failed to read file: ${e instanceof Error ? e.message : String(e)}`, + }; } if (!raw) { @@ -106,9 +110,11 @@ export function importCredentialsFromFile( */ export function getStoredCredentials(keyId: string): StoredCredential | null { const db = getDatabase(); - const row = db.query( - "SELECT key_id, provider, access_token, refresh_token, expires_at, subscription_type, source_file, imported_at, updated_at FROM credentials WHERE key_id = $keyId", - ).get({ $keyId: keyId }) as Record<string, unknown> | null; + const row = db + .query( + "SELECT key_id, provider, access_token, refresh_token, expires_at, subscription_type, source_file, imported_at, updated_at FROM credentials WHERE key_id = $keyId", + ) + .get({ $keyId: keyId }) as Record<string, unknown> | null; if (!row) return null; @@ -159,9 +165,11 @@ export function deleteStoredCredentials(keyId: string): void { */ export function listStoredCredentials(): StoredCredential[] { const db = getDatabase(); - const rows = db.query( - "SELECT key_id, provider, access_token, refresh_token, expires_at, subscription_type, source_file, imported_at, updated_at FROM credentials ORDER BY key_id", - ).all() as Array<Record<string, unknown>>; + const rows = db + .query( + "SELECT key_id, provider, access_token, refresh_token, expires_at, subscription_type, source_file, imported_at, updated_at FROM credentials ORDER BY key_id", + ) + .all() as Array<Record<string, unknown>>; return rows.map((row) => ({ keyId: row.key_id as string, diff --git a/packages/core/src/db/messages.ts b/packages/core/src/db/messages.ts index 5a8758f..80a1f22 100644 --- a/packages/core/src/db/messages.ts +++ b/packages/core/src/db/messages.ts @@ -10,14 +10,30 @@ export interface MessageRow { createdAt: number; } -export function appendMessage(tabId: string, id: string, role: string, contentJson: string, thinking?: string): void { +export function appendMessage( + tabId: string, + id: string, + role: string, + contentJson: string, + thinking?: string, +): void { const db = getDatabase(); - const maxSeq = db.query("SELECT COALESCE(MAX(seq), -1) as max_seq FROM messages WHERE tab_id = $tabId").get({ $tabId: tabId }) as { max_seq: number }; + const maxSeq = db + .query("SELECT COALESCE(MAX(seq), -1) as max_seq FROM messages WHERE tab_id = $tabId") + .get({ $tabId: tabId }) as { max_seq: number }; const seq = (maxSeq?.max_seq ?? -1) + 1; db.query( `INSERT INTO messages (id, tab_id, seq, role, content_json, thinking, created_at) VALUES ($id, $tabId, $seq, $role, $contentJson, $thinking, $now)`, - ).run({ $id: id, $tabId: tabId, $seq: seq, $role: role, $contentJson: contentJson, $thinking: thinking ?? null, $now: Date.now() }); + ).run({ + $id: id, + $tabId: tabId, + $seq: seq, + $role: role, + $contentJson: contentJson, + $thinking: thinking ?? null, + $now: Date.now(), + }); } export function updateMessage(id: string, contentJson: string, thinking?: string): void { @@ -29,7 +45,9 @@ export function updateMessage(id: string, contentJson: string, thinking?: string export function getMessagesForTab(tabId: string): MessageRow[] { const db = getDatabase(); - const rows = db.query("SELECT * FROM messages WHERE tab_id = $tabId ORDER BY seq ASC").all({ $tabId: tabId }) as Array<Record<string, unknown>>; + const rows = db + .query("SELECT * FROM messages WHERE tab_id = $tabId ORDER BY seq ASC") + .all({ $tabId: tabId }) as Array<Record<string, unknown>>; return rows.map((row) => ({ id: row.id as string, tabId: row.tab_id as string, diff --git a/packages/core/src/db/settings.ts b/packages/core/src/db/settings.ts index 51d6ea2..f9d152e 100644 --- a/packages/core/src/db/settings.ts +++ b/packages/core/src/db/settings.ts @@ -2,7 +2,9 @@ import { getDatabase } from "./index.js"; export function getSetting(key: string): string | null { const db = getDatabase(); - const row = db.query("SELECT value FROM settings WHERE key = $key").get({ $key: key }) as { value: string } | null; + const row = db.query("SELECT value FROM settings WHERE key = $key").get({ $key: key }) as { + value: string; + } | null; return row?.value ?? null; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c5aa81b..283916a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,6 +2,7 @@ // Agent & LLM export { Agent } from "./agent/agent.js"; +export { deleteAgent, getAgentDirs, loadAgents, saveAgent } from "./agents/index.js"; // Config export { configToRuleset, diff --git a/packages/core/src/llm/provider.ts b/packages/core/src/llm/provider.ts index 3131b1a..7cbb829 100644 --- a/packages/core/src/llm/provider.ts +++ b/packages/core/src/llm/provider.ts @@ -1,7 +1,7 @@ -import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; import { createAnthropic } from "@ai-sdk/anthropic"; -import { wrapLanguageModel } from "ai"; +import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; import type { LanguageModelV1Middleware, LanguageModelV1Prompt } from "ai"; +import { wrapLanguageModel } from "ai"; function normalizeMessages(msgs: unknown[]): unknown[] { return msgs.map((msg: unknown) => { @@ -19,7 +19,10 @@ function normalizeMessages(msgs: unknown[]): unknown[] { ); const existingMetadata = (message.providerMetadata ?? {}) as Record<string, unknown>; - const existingOpenAICompat = (existingMetadata.openaiCompatible ?? {}) as Record<string, unknown>; + const existingOpenAICompat = (existingMetadata.openaiCompatible ?? {}) as Record< + string, + unknown + >; return { ...message, @@ -120,4 +123,4 @@ function createAnthropicProvider(config: ProviderConfig) { }; } -export { prefixToolName, unprefixToolName };
\ No newline at end of file +export { prefixToolName, unprefixToolName }; diff --git a/packages/core/src/models/registry.ts b/packages/core/src/models/registry.ts index 9ea0b32..4a24a51 100644 --- a/packages/core/src/models/registry.ts +++ b/packages/core/src/models/registry.ts @@ -10,10 +10,7 @@ export class ModelRegistry { this._initConfig(keys, new Map()); } - private _initConfig( - keys: KeyDefinition[], - existingStates: Map<string, KeyState>, - ): void { + private _initConfig(keys: KeyDefinition[], existingStates: Map<string, KeyState>): void { this.keyOrder = keys.map((k) => k.id); const newStates = new Map<string, KeyState>(); @@ -83,8 +80,7 @@ export class ModelRegistry { return this.keyOrder .map((id) => this.keyStates.get(id)) .filter( - (state): state is KeyState => - state !== undefined && state.definition.provider === provider, + (state): state is KeyState => state !== undefined && state.definition.provider === provider, ); } } diff --git a/packages/core/src/skills/index.ts b/packages/core/src/skills/index.ts index 5f958b3..a3fac70 100644 --- a/packages/core/src/skills/index.ts +++ b/packages/core/src/skills/index.ts @@ -1,7 +1,7 @@ -export { parseSkillFile } from "./parser.js"; export { + createSkillsWatcher, + getSkillByName, loadSkills, resolveSkillsForAgent, - getSkillByName, - createSkillsWatcher, } from "./loader.js"; +export { parseSkillFile } from "./parser.js"; diff --git a/packages/core/src/skills/loader.ts b/packages/core/src/skills/loader.ts index 1dcd39e..5d043dd 100644 --- a/packages/core/src/skills/loader.ts +++ b/packages/core/src/skills/loader.ts @@ -1,89 +1,58 @@ import * as fs from "node:fs"; -import * as path from "node:path"; import * as os from "node:os"; +import * as path from "node:path"; import chokidar from "chokidar"; -import type { SkillDefinition, AgentSkillMapping, SkillScope } from "../types/index.js"; +import type { AgentSkillMapping, SkillDefinition, SkillScope } from "../types/index.js"; import { parseSkillFile } from "./parser.js"; // ─── Internal Helpers ──────────────────────────────────────────── -function loadSkillsFromDir( - dir: string, - scope: SkillScope, -): SkillDefinition[] { - if (!fs.existsSync(dir)) { - return []; - } +/** + * Recursively scan a directory for .md skill files. + * The `directory` field on each skill is the relative path from `baseDir` to the file's parent. + * Skips the `agents/` subdirectory (handled separately). + */ +function scanSkillsRecursive(baseDir: string, scope: SkillScope): SkillDefinition[] { + if (!fs.existsSync(baseDir)) return []; const results: SkillDefinition[] = []; - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } catch { - return []; - } - for (const entry of entries) { - if (!entry.isFile() || !entry.name.endsWith(".md")) { - continue; - } - const filePath = path.join(dir, entry.name); + function walk(dir: string) { + let entries: fs.Dirent[]; try { - const content = fs.readFileSync(filePath, "utf-8"); - const skill = parseSkillFile( - filePath, - content, - scope, - // We'll set directory based on the parent dir segment — caller sets it - "default", - ); - results.push(skill); + entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { - // Skip unreadable files + return; } - } - - return results; -} - -function loadSkillsFromDirWithDirectory( - dir: string, - scope: SkillScope, - directory: SkillDefinition["directory"], -): SkillDefinition[] { - if (!fs.existsSync(dir)) { - return []; - } - const results: SkillDefinition[] = []; - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } catch { - return []; - } - - for (const entry of entries) { - if (!entry.isFile() || !entry.name.endsWith(".md")) { - continue; - } - const filePath = path.join(dir, entry.name); - try { - const content = fs.readFileSync(filePath, "utf-8"); - const skill = parseSkillFile(filePath, content, scope, directory); - results.push(skill); - } catch { - // Skip unreadable files + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + // Skip agents/ at the top level (handled by loadAgentMappings) + const relFromBase = path.relative(baseDir, fullPath); + if (relFromBase === "agents") continue; + walk(fullPath); + } else if (entry.isFile() && entry.name.endsWith(".md")) { + const relDir = path.relative(baseDir, dir); + // relDir is "" for root, "general" for general/, "general/webapps" for nested + const directory = relDir === "." ? "" : relDir; + try { + const content = fs.readFileSync(fullPath, "utf-8"); + const skill = parseSkillFile(fullPath, content, scope, directory); + results.push(skill); + } catch { + // Skip unreadable files + } + } } } + walk(baseDir); return results; } -function loadAgentMappings( - agentsDir: string, - scope: SkillScope, -): AgentSkillMapping[] { +function loadAgentMappings(agentsDir: string, scope: SkillScope): AgentSkillMapping[] { if (!fs.existsSync(agentsDir)) { return []; } @@ -141,44 +110,16 @@ export function loadSkills(projectDir: string): { const skills: SkillDefinition[] = []; const mappings: AgentSkillMapping[] = []; - // 1. Global default/ - skills.push( - ...loadSkillsFromDirWithDirectory( - path.join(globalBase, "default"), - "global", - "default", - ), - ); + // 1. Scan all global skills recursively (skipping agents/) + skills.push(...scanSkillsRecursive(globalBase, "global")); - // 2. Project default/ - skills.push( - ...loadSkillsFromDirWithDirectory( - path.join(projectBase, "default"), - "project", - "default", - ), - ); + // 2. Scan all project skills recursively (skipping agents/) + skills.push(...scanSkillsRecursive(projectBase, "project")); // 3. Agent mappings — global then project mappings.push(...loadAgentMappings(path.join(globalBase, "agents"), "global")); mappings.push(...loadAgentMappings(path.join(projectBase, "agents"), "project")); - // 4. Project/ skills (manually activated) - skills.push( - ...loadSkillsFromDirWithDirectory( - path.join(globalBase, "project"), - "global", - "project", - ), - ); - skills.push( - ...loadSkillsFromDirWithDirectory( - path.join(projectBase, "project"), - "project", - "project", - ), - ); - return { skills, mappings }; } @@ -305,6 +246,3 @@ export function createSkillsWatcher( }, }; } - -// Keep loadSkillsFromDir exported for potential testing use -export { loadSkillsFromDir }; diff --git a/packages/core/src/skills/parser.ts b/packages/core/src/skills/parser.ts index 4df5450..289073f 100644 --- a/packages/core/src/skills/parser.ts +++ b/packages/core/src/skills/parser.ts @@ -1,6 +1,6 @@ -import { parse } from "smol-toml"; import * as path from "node:path"; -import type { SkillDefinition, SkillScope, SkillDirectory } from "../types/index.js"; +import { parse } from "smol-toml"; +import type { SkillDefinition, SkillDirectory, SkillScope } from "../types/index.js"; const FRONTMATTER_DELIMITER = "+++"; diff --git a/packages/core/src/tools/bash-arity.ts b/packages/core/src/tools/bash-arity.ts index 5dde955..1aaba8e 100644 --- a/packages/core/src/tools/bash-arity.ts +++ b/packages/core/src/tools/bash-arity.ts @@ -1,37 +1,37 @@ // Hardcoded dictionary of well-known commands and their arity // (number of tokens that form the "human-understandable" prefix) const ARITY: Record<string, number> = { - "git": 2, // "git checkout", "git commit", etc. - "npm": 3, // "npm run dev", "npm install -g" - "docker": 2, // "docker compose", "docker build" - "kubectl": 2, // "kubectl get", "kubectl apply" - "bun": 2, // "bun install", "bun test" - "cargo": 2, // "cargo build", "cargo test" - "go": 2, // "go build", "go test" - "python": 2, // "python -m", "python script.py" - "python3": 2, - "pip": 2, - "pip3": 2, - "brew": 2, - "apt": 2, + git: 2, // "git checkout", "git commit", etc. + npm: 3, // "npm run dev", "npm install -g" + docker: 2, // "docker compose", "docker build" + kubectl: 2, // "kubectl get", "kubectl apply" + bun: 2, // "bun install", "bun test" + cargo: 2, // "cargo build", "cargo test" + go: 2, // "go build", "go test" + python: 2, // "python -m", "python script.py" + python3: 2, + pip: 2, + pip3: 2, + brew: 2, + apt: 2, "apt-get": 2, - "dnf": 2, - "yum": 2, - "pacman": 2, - "systemctl": 2, - "journalctl": 2, - "ssh": 2, - "scp": 2, - "rsync": 2, - "curl": 2, // "curl -X", "curl https://" - "wget": 2, - "tar": 2, // "tar -xzf", "tar -czf" - "zip": 2, - "unzip": 2, - "chown": 2, - "chmod": 2, - "mount": 2, - "umount": 2, + dnf: 2, + yum: 2, + pacman: 2, + systemctl: 2, + journalctl: 2, + ssh: 2, + scp: 2, + rsync: 2, + curl: 2, // "curl -X", "curl https://" + wget: 2, + tar: 2, // "tar -xzf", "tar -czf" + zip: 2, + unzip: 2, + chown: 2, + chmod: 2, + mount: 2, + umount: 2, // Default: all other commands are arity 1 }; diff --git a/packages/core/src/tools/run-shell.ts b/packages/core/src/tools/run-shell.ts index d549316..608c91d 100644 --- a/packages/core/src/tools/run-shell.ts +++ b/packages/core/src/tools/run-shell.ts @@ -11,12 +11,12 @@ export function createRunShellTool(workingDirectory: string): ToolDefinition { "Execute a shell command in the working directory. Returns stdout, stderr, and exit code. Use for running tests, builds, git operations, package management, and other development tasks.", parameters: z.object({ command: z.string().describe("The shell command to execute"), - timeout: z - .number() - .optional() - .describe("Timeout in milliseconds (default 2 minutes)"), + timeout: z.number().optional().describe("Timeout in milliseconds (default 2 minutes)"), }), - execute: async (args: Record<string, unknown>, context?: ToolExecuteContext): Promise<string> => { + execute: async ( + args: Record<string, unknown>, + context?: ToolExecuteContext, + ): Promise<string> => { const command = args.command as string; const timeout = (args.timeout as number | undefined) ?? DEFAULT_TIMEOUT; @@ -68,7 +68,5 @@ export function createRunShellTool(workingDirectory: string): ToolDefinition { } function getShell(): [string, string[]] { - return process.platform === "win32" - ? ["powershell", ["-Command"]] - : ["bash", ["-c"]]; + return process.platform === "win32" ? ["powershell", ["-Command"]] : ["bash", ["-c"]]; } diff --git a/packages/core/src/tools/shell-analyze.ts b/packages/core/src/tools/shell-analyze.ts index 23bbfc9..b70108b 100644 --- a/packages/core/src/tools/shell-analyze.ts +++ b/packages/core/src/tools/shell-analyze.ts @@ -1,6 +1,6 @@ -import { dirname, isAbsolute, relative, resolve, sep } from "node:path"; import { readFile } from "node:fs/promises"; import { createRequire } from "node:module"; +import { dirname, isAbsolute, relative, resolve, sep } from "node:path"; import * as BashArity from "./bash-arity.js"; // Commands that touch files — triggers external_directory check. @@ -12,9 +12,27 @@ import * as BashArity from "./bash-arity.js"; // - `cd` state changes: we don't track cwd mutations across pipeline stages // - Interpreter escapes: `python -c "open('/etc/passwd')"`, `node -e "..."` bypass this entirely const FILE_COMMANDS = new Set([ - "rm", "cp", "mv", "mkdir", "touch", "chmod", "chown", - "cat", "ls", "find", "grep", - "head", "tail", "less", "more", "wc", "diff", "file", "stat", "du", "df", + "rm", + "cp", + "mv", + "mkdir", + "touch", + "chmod", + "chown", + "cat", + "ls", + "find", + "grep", + "head", + "tail", + "less", + "more", + "wc", + "diff", + "file", + "stat", + "du", + "df", ]); // Lazy-initialized parser @@ -142,6 +160,7 @@ function isInsideWorkspace(filePath: string, wd: string): boolean { // rel === "" means filePath IS the workspace root — that is inside. // If relative path starts with "../" or is ".." exactly, or is an absolute path // (on Windows when drives differ), the file is outside the workspace. - const isOutside = rel.startsWith(`..${sep}`) || rel.startsWith("../") || rel === ".." || isAbsolute(rel); + const isOutside = + rel.startsWith(`..${sep}`) || rel.startsWith("../") || rel === ".." || isAbsolute(rel); return !isOutside; } diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index eaf8669..8f80b08 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -110,7 +110,7 @@ export interface KeyState { // ─── Skills Types ──────────────────────────────────────────────── export type SkillScope = "global" | "project"; -export type SkillDirectory = "default" | "agents" | "project"; +export type SkillDirectory = string; export interface SkillDefinition { name: string; @@ -146,3 +146,29 @@ export interface ConfigError { path: string; message: string; } + +// ─── Agent Definition Types ────────────────────────────────────── + +export interface AgentModelEntry { + key_id: string; + model_id: string; +} + +export interface AgentDefinition { + /** Human-readable name */ + name: string; + /** Short description of what this agent does */ + description: string; + /** Skills to auto-include, as "scope:name" strings */ + skills: string[]; + /** Allowed tools (allowlist) */ + tools: string[]; + /** Key+model fallback hierarchy, tried in order */ + models: AgentModelEntry[]; + /** Where the TOML was loaded from: "global" or a directory path */ + scope: string; + /** The slug (filename without .toml) */ + slug: string; + /** Default working directory for this agent (optional, absolute path) */ + cwd?: string; +} |
