summaryrefslogtreecommitdiffhomepage
path: root/packages/core/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-22 15:24:13 +0900
committerAdam Malczewski <[email protected]>2026-05-22 15:24:13 +0900
commit9ecaabd87c0e51b8a7408dabb0133a9344586859 (patch)
tree4b5809ab23948a5e3f558f3aa34c52d5038f4e19 /packages/core/src
parent8f1dd855f0c4c877bff8d3a4ba193432b268b1c2 (diff)
downloaddispatch-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')
-rw-r--r--packages/core/src/agent/agent.ts147
-rw-r--r--packages/core/src/agents/index.ts1
-rw-r--r--packages/core/src/agents/loader.ts212
-rw-r--r--packages/core/src/config/loader.ts4
-rw-r--r--packages/core/src/config/schema.ts25
-rw-r--r--packages/core/src/config/watcher.ts14
-rw-r--r--packages/core/src/credentials/api-keys.ts19
-rw-r--r--packages/core/src/credentials/claude.ts95
-rw-r--r--packages/core/src/credentials/index.ts26
-rw-r--r--packages/core/src/credentials/opencode.ts9
-rw-r--r--packages/core/src/credentials/store.ts26
-rw-r--r--packages/core/src/db/messages.ts26
-rw-r--r--packages/core/src/db/settings.ts4
-rw-r--r--packages/core/src/index.ts1
-rw-r--r--packages/core/src/llm/provider.ts11
-rw-r--r--packages/core/src/models/registry.ts8
-rw-r--r--packages/core/src/skills/index.ts6
-rw-r--r--packages/core/src/skills/loader.ts140
-rw-r--r--packages/core/src/skills/parser.ts4
-rw-r--r--packages/core/src/tools/bash-arity.ts60
-rw-r--r--packages/core/src/tools/run-shell.ts14
-rw-r--r--packages/core/src/tools/shell-analyze.ts29
-rw-r--r--packages/core/src/types/index.ts28
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;
+}