From 5482bf3e147a5639dc95f5a10bf630ced5840098 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 22 Dec 2025 00:47:16 +0100 Subject: [PATCH] Add SDK for programmatic AgentSession usage - Add src/core/sdk.ts with createAgentSession() factory and discovery functions - Update loaders to accept cwd/agentDir parameters (skills, hooks, custom-tools, slash-commands, system-prompt) - Export SDK from package index Addresses #272 --- .../src/core/custom-tools/loader.ts | 9 +- .../coding-agent/src/core/hooks/loader.ts | 25 +- packages/coding-agent/src/core/sdk.ts | 593 ++++++++++++++++++ packages/coding-agent/src/core/skills.ts | 22 +- .../coding-agent/src/core/slash-commands.ts | 25 +- .../coding-agent/src/core/system-prompt.ts | 70 ++- packages/coding-agent/src/core/tools/index.ts | 10 +- packages/coding-agent/src/index.ts | 23 + 8 files changed, 737 insertions(+), 40 deletions(-) create mode 100644 packages/coding-agent/src/core/sdk.ts diff --git a/packages/coding-agent/src/core/custom-tools/loader.ts b/packages/coding-agent/src/core/custom-tools/loader.ts index 82ccbedc..7af81acd 100644 --- a/packages/coding-agent/src/core/custom-tools/loader.ts +++ b/packages/coding-agent/src/core/custom-tools/loader.ts @@ -303,7 +303,7 @@ function discoverToolsInDir(dir: string): string[] { /** * Discover and load tools from standard locations: - * 1. ~/.pi/agent/tools/*.ts (global) + * 1. agentDir/tools/*.ts (global) * 2. cwd/.pi/tools/*.ts (project-local) * * Plus any explicitly configured paths from settings or CLI. @@ -311,12 +311,15 @@ function discoverToolsInDir(dir: string): string[] { * @param configuredPaths - Explicit paths from settings.json and CLI --tool flags * @param cwd - Current working directory * @param builtInToolNames - Names of built-in tools to check for conflicts + * @param agentDir - Agent config directory. Default: from getAgentDir() */ export async function discoverAndLoadCustomTools( configuredPaths: string[], cwd: string, builtInToolNames: string[], + agentDir?: string, ): Promise { + const resolvedAgentDir = agentDir ?? getAgentDir(); const allPaths: string[] = []; const seen = new Set(); @@ -331,8 +334,8 @@ export async function discoverAndLoadCustomTools( } }; - // 1. Global tools: ~/.pi/agent/tools/ - const globalToolsDir = path.join(getAgentDir(), "tools"); + // 1. Global tools: agentDir/tools/ + const globalToolsDir = path.join(resolvedAgentDir, "tools"); addPaths(discoverToolsInDir(globalToolsDir)); // 2. Project-local tools: cwd/.pi/tools/ diff --git a/packages/coding-agent/src/core/hooks/loader.ts b/packages/coding-agent/src/core/hooks/loader.ts index 50cef601..4f5273c0 100644 --- a/packages/coding-agent/src/core/hooks/loader.ts +++ b/packages/coding-agent/src/core/hooks/loader.ts @@ -215,17 +215,28 @@ function discoverHooksInDir(dir: string): string[] { } } +export interface DiscoverAndLoadHooksOptions { + /** Explicit paths from settings.json or CLI */ + configuredPaths?: string[]; + /** Current working directory */ + cwd?: string; + /** Agent config directory. Default: from getAgentDir() */ + agentDir?: string; +} + /** * Discover and load hooks from standard locations: - * 1. ~/.pi/agent/hooks/*.ts (global) + * 1. agentDir/hooks/*.ts (global) * 2. cwd/.pi/hooks/*.ts (project-local) * * Plus any explicitly configured paths from settings. - * - * @param configuredPaths - Explicit paths from settings.json - * @param cwd - Current working directory */ -export async function discoverAndLoadHooks(configuredPaths: string[], cwd: string): Promise { +export async function discoverAndLoadHooks( + configuredPaths: string[], + cwd: string, + agentDir?: string, +): Promise { + const resolvedAgentDir = agentDir ?? getAgentDir(); const allPaths: string[] = []; const seen = new Set(); @@ -240,8 +251,8 @@ export async function discoverAndLoadHooks(configuredPaths: string[], cwd: strin } }; - // 1. Global hooks: ~/.pi/agent/hooks/ - const globalHooksDir = path.join(getAgentDir(), "hooks"); + // 1. Global hooks: agentDir/hooks/ + const globalHooksDir = path.join(resolvedAgentDir, "hooks"); addPaths(discoverHooksInDir(globalHooksDir)); // 2. Project-local hooks: cwd/.pi/hooks/ diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts new file mode 100644 index 00000000..9bae188f --- /dev/null +++ b/packages/coding-agent/src/core/sdk.ts @@ -0,0 +1,593 @@ +/** + * SDK for programmatic usage of AgentSession. + * + * Provides a factory function and discovery helpers that allow full control + * over agent configuration, or sensible defaults that match CLI behavior. + * + * @example + * ```typescript + * // Minimal - everything auto-discovered + * const session = await createAgentSession(); + * + * // With custom hooks + * const session = await createAgentSession({ + * hooks: [ + * ...await discoverHooks(), + * { factory: myHookFactory }, + * ], + * }); + * + * // Full control + * const session = await createAgentSession({ + * model: myModel, + * getApiKey: async () => process.env.MY_KEY, + * tools: [readTool, bashTool], + * hooks: [], + * skills: [], + * sessionFile: false, + * }); + * ``` + */ + +import { Agent, ProviderTransport, type ThinkingLevel } from "@mariozechner/pi-agent-core"; +import type { Model } from "@mariozechner/pi-ai"; +import { getAgentDir } from "../config.js"; +import { AgentSession } from "./agent-session.js"; +import { discoverAndLoadCustomTools, type LoadedCustomTool } from "./custom-tools/index.js"; +import type { CustomAgentTool } from "./custom-tools/types.js"; +import { discoverAndLoadHooks, HookRunner, type LoadedHook, wrapToolsWithHooks } from "./hooks/index.js"; +import type { HookFactory } from "./hooks/types.js"; +import { messageTransformer } from "./messages.js"; +import { + findModel as findModelInternal, + getApiKeyForModel, + getAvailableModels, + loadAndMergeModels, +} from "./model-config.js"; +import { SessionManager } from "./session-manager.js"; +import { type Settings, SettingsManager, type SkillsSettings } from "./settings-manager.js"; +import { loadSkills as loadSkillsInternal, type Skill } from "./skills.js"; +import { type FileSlashCommand, loadSlashCommands as loadSlashCommandsInternal } from "./slash-commands.js"; +import { + buildSystemPrompt as buildSystemPromptInternal, + loadProjectContextFiles as loadContextFilesInternal, +} from "./system-prompt.js"; +import { + allTools, + bashTool, + codingTools, + editTool, + findTool, + grepTool, + lsTool, + readOnlyTools, + readTool, + type Tool, + writeTool, +} from "./tools/index.js"; + +// ============================================================================ +// Types +// ============================================================================ + +export interface CreateAgentSessionOptions { + // === Environment === + /** Working directory for project-local discovery. Default: process.cwd() */ + cwd?: string; + /** Global config directory. Default: ~/.pi/agent */ + agentDir?: string; + + // === Model & Thinking === + /** Model to use. Default: from settings, else first available */ + model?: Model; + /** Thinking level. Default: from settings, else 'off' (clamped to model capabilities) */ + thinkingLevel?: ThinkingLevel; + + // === API Key === + /** API key resolver. Default: defaultGetApiKey() */ + getApiKey?: (model: Model) => Promise; + + // === System Prompt === + /** System prompt. String replaces default, function receives default and returns final. */ + systemPrompt?: string | ((defaultPrompt: string) => string); + + // === Tools === + /** Built-in tools to use. Default: codingTools [read, bash, edit, write] */ + tools?: Tool[]; + /** Custom tools. Default: discovered from cwd/.pi/tools/ + agentDir/tools/ */ + customTools?: Array<{ path?: string; tool: CustomAgentTool }>; + + // === Hooks === + /** Hooks. Default: discovered from cwd/.pi/hooks/ + agentDir/hooks/ */ + hooks?: Array<{ path?: string; factory: HookFactory }>; + + // === Context === + /** Skills. Default: discovered from multiple locations */ + skills?: Skill[]; + /** Context files (AGENTS.md content). Default: discovered walking up from cwd */ + contextFiles?: Array<{ path: string; content: string }>; + /** Slash commands. Default: discovered from cwd/.pi/commands/ + agentDir/commands/ */ + slashCommands?: FileSlashCommand[]; + + // === Session === + /** Session file path, or false to disable persistence. Default: auto in agentDir/sessions/ */ + sessionFile?: string | false; + + // === Settings === + /** Settings overrides (merged with agentDir/settings.json) */ + settings?: Partial; +} + +// ============================================================================ +// Re-exports +// ============================================================================ + +export type { CustomAgentTool } from "./custom-tools/types.js"; +export type { HookAPI, HookFactory } from "./hooks/types.js"; +export type { Settings, SkillsSettings } from "./settings-manager.js"; +export type { Skill } from "./skills.js"; +export type { FileSlashCommand } from "./slash-commands.js"; +export type { Tool } from "./tools/index.js"; + +export { + readTool, + bashTool, + editTool, + writeTool, + grepTool, + findTool, + lsTool, + codingTools, + readOnlyTools, + allTools as allBuiltInTools, +}; + +// ============================================================================ +// Helper Functions +// ============================================================================ + +function getDefaultAgentDir(): string { + return getAgentDir(); +} + +// ============================================================================ +// Discovery Functions +// ============================================================================ + +/** + * Get all models (built-in + custom from models.json). + * Note: Uses default agentDir for models.json location. + */ +export function discoverModels(): Model[] { + const { models, error } = loadAndMergeModels(); + if (error) { + throw new Error(error); + } + return models; +} + +/** + * Get models that have valid API keys available. + * Note: Uses default agentDir for models.json and oauth.json location. + */ +export async function discoverAvailableModels(): Promise[]> { + const { models, error } = await getAvailableModels(); + if (error) { + throw new Error(error); + } + return models; +} + +/** + * Find a model by provider and ID. + * Note: Uses default agentDir for models.json location. + * @returns The model, or null if not found + */ +export function findModel(provider: string, modelId: string): Model | null { + const { model, error } = findModelInternal(provider, modelId); + if (error) { + throw new Error(error); + } + return model; +} + +/** + * Discover hooks from cwd and agentDir. + */ +export async function discoverHooks( + cwd?: string, + agentDir?: string, +): Promise> { + const resolvedCwd = cwd ?? process.cwd(); + const resolvedAgentDir = agentDir ?? getDefaultAgentDir(); + + const { hooks, errors } = await discoverAndLoadHooks([], resolvedCwd, resolvedAgentDir); + + // Log errors but don't fail + for (const { path, error } of errors) { + console.error(`Failed to load hook "${path}": ${error}`); + } + + return hooks.map((h) => ({ + path: h.path, + factory: createFactoryFromLoadedHook(h), + })); +} + +/** + * Discover custom tools from cwd and agentDir. + */ +export async function discoverCustomTools( + cwd?: string, + agentDir?: string, +): Promise> { + const resolvedCwd = cwd ?? process.cwd(); + const resolvedAgentDir = agentDir ?? getDefaultAgentDir(); + + const { tools, errors } = await discoverAndLoadCustomTools([], resolvedCwd, Object.keys(allTools), resolvedAgentDir); + + // Log errors but don't fail + for (const { path, error } of errors) { + console.error(`Failed to load custom tool "${path}": ${error}`); + } + + return tools.map((t) => ({ + path: t.path, + tool: t.tool, + })); +} + +/** + * Discover skills from cwd and agentDir. + */ +export function discoverSkills(cwd?: string, agentDir?: string, settings?: SkillsSettings): Skill[] { + const { skills } = loadSkillsInternal({ + ...settings, + cwd: cwd ?? process.cwd(), + agentDir: agentDir ?? getDefaultAgentDir(), + }); + return skills; +} + +/** + * Discover context files (AGENTS.md) walking up from cwd. + */ +export function discoverContextFiles(cwd?: string, agentDir?: string): Array<{ path: string; content: string }> { + return loadContextFilesInternal({ + cwd: cwd ?? process.cwd(), + agentDir: agentDir ?? getDefaultAgentDir(), + }); +} + +/** + * Discover slash commands from cwd and agentDir. + */ +export function discoverSlashCommands(cwd?: string, agentDir?: string): FileSlashCommand[] { + return loadSlashCommandsInternal({ + cwd: cwd ?? process.cwd(), + agentDir: agentDir ?? getDefaultAgentDir(), + }); +} + +// ============================================================================ +// API Key Helpers +// ============================================================================ + +/** + * Create the default API key resolver. + * Checks custom providers (models.json), OAuth, and environment variables. + * Note: Uses default agentDir for models.json and oauth.json location. + */ +export function defaultGetApiKey(): (model: Model) => Promise { + return getApiKeyForModel; +} + +// ============================================================================ +// System Prompt +// ============================================================================ + +export interface BuildSystemPromptOptions { + tools?: Tool[]; + skills?: Skill[]; + contextFiles?: Array<{ path: string; content: string }>; + cwd?: string; + appendPrompt?: string; +} + +/** + * Build the default system prompt. + */ +export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): string { + return buildSystemPromptInternal({ + cwd: options.cwd, + skills: options.skills, + contextFiles: options.contextFiles, + appendSystemPrompt: options.appendPrompt, + }); +} + +// ============================================================================ +// Settings +// ============================================================================ + +/** + * Load settings from agentDir/settings.json. + */ +export function loadSettings(agentDir?: string): Settings { + const manager = new SettingsManager(agentDir ?? getDefaultAgentDir()); + return { + defaultProvider: manager.getDefaultProvider(), + defaultModel: manager.getDefaultModel(), + defaultThinkingLevel: manager.getDefaultThinkingLevel(), + queueMode: manager.getQueueMode(), + theme: manager.getTheme(), + compaction: manager.getCompactionSettings(), + retry: manager.getRetrySettings(), + hideThinkingBlock: manager.getHideThinkingBlock(), + shellPath: manager.getShellPath(), + collapseChangelog: manager.getCollapseChangelog(), + hooks: manager.getHookPaths(), + hookTimeout: manager.getHookTimeout(), + customTools: manager.getCustomToolPaths(), + skills: manager.getSkillsSettings(), + terminal: { showImages: manager.getShowImages() }, + }; +} + +// ============================================================================ +// Internal Helpers +// ============================================================================ + +/** + * Create a HookFactory from a LoadedHook. + * This allows mixing discovered hooks with inline hooks. + */ +function createFactoryFromLoadedHook(loaded: LoadedHook): HookFactory { + return (api) => { + for (const [eventType, handlers] of loaded.handlers) { + for (const handler of handlers) { + api.on(eventType as any, handler as any); + } + } + }; +} + +/** + * Convert hook definitions to LoadedHooks for the HookRunner. + */ +function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; factory: HookFactory }>): LoadedHook[] { + return definitions.map((def) => { + const handlers = new Map Promise>>(); + let sendHandler: (text: string, attachments?: any[]) => void = () => {}; + + const api = { + on: (event: string, handler: (...args: unknown[]) => Promise) => { + const list = handlers.get(event) ?? []; + list.push(handler); + handlers.set(event, list); + }, + send: (text: string, attachments?: any[]) => { + sendHandler(text, attachments); + }, + }; + + def.factory(api as any); + + return { + path: def.path ?? "", + resolvedPath: def.path ?? "", + handlers, + setSendHandler: (handler: (text: string, attachments?: any[]) => void) => { + sendHandler = handler; + }, + }; + }); +} + +// ============================================================================ +// Factory +// ============================================================================ + +/** + * Create an AgentSession with the specified options. + * + * @example + * ```typescript + * // Minimal - uses defaults + * const session = await createAgentSession(); + * + * // With explicit model + * const session = await createAgentSession({ + * model: findModel('anthropic', 'claude-sonnet-4-20250514'), + * thinkingLevel: 'high', + * }); + * + * // Full control + * const session = await createAgentSession({ + * model: myModel, + * getApiKey: async () => process.env.MY_KEY, + * systemPrompt: 'You are helpful.', + * tools: [readTool, bashTool], + * hooks: [], + * skills: [], + * sessionFile: false, + * }); + * ``` + */ +export async function createAgentSession(options: CreateAgentSessionOptions = {}): Promise { + const cwd = options.cwd ?? process.cwd(); + const agentDir = options.agentDir ?? getDefaultAgentDir(); + + // === Settings === + const settingsManager = new SettingsManager(agentDir); + + // === Model Resolution === + let model = options.model; + if (!model) { + // Try settings default + const defaultProvider = settingsManager.getDefaultProvider(); + const defaultModelId = settingsManager.getDefaultModel(); + if (defaultProvider && defaultModelId) { + model = findModel(defaultProvider, defaultModelId) ?? undefined; + // Verify it has an API key + if (model) { + const key = await getApiKeyForModel(model); + if (!key) { + model = undefined; + } + } + } + + // Fall back to first available + if (!model) { + const available = await discoverAvailableModels(); + if (available.length === 0) { + throw new Error( + "No models available. Set an API key environment variable " + + "(ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.) or provide a model explicitly.", + ); + } + model = available[0]; + } + } + + // === Thinking Level Resolution === + let thinkingLevel = options.thinkingLevel; + if (thinkingLevel === undefined) { + thinkingLevel = settingsManager.getDefaultThinkingLevel() ?? "off"; + } + // Clamp to model capabilities + if (!model.reasoning) { + thinkingLevel = "off"; + } + + // === API Key Resolver === + const getApiKey = options.getApiKey ?? defaultGetApiKey(); + + // === Skills === + const skills = options.skills ?? discoverSkills(cwd, agentDir, settingsManager.getSkillsSettings()); + + // === Context Files === + const contextFiles = options.contextFiles ?? discoverContextFiles(cwd, agentDir); + + // === Tools === + const builtInTools = options.tools ?? codingTools; + + // === Custom Tools === + let customToolsResult: { tools: LoadedCustomTool[]; setUIContext: (ctx: any, hasUI: boolean) => void }; + if (options.customTools !== undefined) { + // Use provided custom tools + const loadedTools: LoadedCustomTool[] = options.customTools.map((ct) => ({ + path: ct.path ?? "", + resolvedPath: ct.path ?? "", + tool: ct.tool, + })); + customToolsResult = { + tools: loadedTools, + setUIContext: () => {}, + }; + } else { + // Discover custom tools + const result = await discoverAndLoadCustomTools( + settingsManager.getCustomToolPaths(), + cwd, + Object.keys(allTools), + agentDir, + ); + for (const { path, error } of result.errors) { + console.error(`Failed to load custom tool "${path}": ${error}`); + } + customToolsResult = result; + } + + // === Hooks === + let hookRunner: HookRunner | null = null; + if (options.hooks !== undefined) { + if (options.hooks.length > 0) { + const loadedHooks = createLoadedHooksFromDefinitions(options.hooks); + hookRunner = new HookRunner(loadedHooks, cwd, settingsManager.getHookTimeout()); + } + } else { + // Discover hooks + const { hooks, errors } = await discoverAndLoadHooks(settingsManager.getHookPaths(), cwd, agentDir); + for (const { path, error } of errors) { + console.error(`Failed to load hook "${path}": ${error}`); + } + if (hooks.length > 0) { + hookRunner = new HookRunner(hooks, cwd, settingsManager.getHookTimeout()); + } + } + + // === Combine and wrap tools === + let allToolsArray: Tool[] = [...builtInTools, ...customToolsResult.tools.map((lt) => lt.tool as unknown as Tool)]; + if (hookRunner) { + allToolsArray = wrapToolsWithHooks(allToolsArray, hookRunner) as Tool[]; + } + + // === System Prompt === + let systemPrompt: string; + const defaultPrompt = buildSystemPromptInternal({ + cwd, + agentDir, + skills, + contextFiles, + }); + + if (options.systemPrompt === undefined) { + systemPrompt = defaultPrompt; + } else if (typeof options.systemPrompt === "string") { + systemPrompt = options.systemPrompt; + } else { + systemPrompt = options.systemPrompt(defaultPrompt); + } + + // === Slash Commands === + const slashCommands = options.slashCommands ?? discoverSlashCommands(cwd, agentDir); + + // === Session Manager === + const sessionManager = new SessionManager(false, undefined); + if (options.sessionFile === false) { + sessionManager.disable(); + } else if (typeof options.sessionFile === "string") { + sessionManager.setSessionFile(options.sessionFile); + } + // If undefined, SessionManager uses auto-detection based on cwd + + // === Create Agent === + const agent = new Agent({ + initialState: { + systemPrompt, + model, + thinkingLevel, + tools: allToolsArray, + }, + messageTransformer, + queueMode: settingsManager.getQueueMode(), + transport: new ProviderTransport({ + getApiKey: async () => { + const currentModel = agent.state.model; + if (!currentModel) { + throw new Error("No model selected"); + } + const key = await getApiKey(currentModel); + if (!key) { + throw new Error(`No API key found for provider "${currentModel.provider}"`); + } + return key; + }, + }), + }); + + // === Create Session === + const session = new AgentSession({ + agent, + sessionManager, + settingsManager, + fileCommands: slashCommands, + hookRunner, + customTools: customToolsResult.tools, + skillsSettings: settingsManager.getSkillsSettings(), + }); + + return session; +} diff --git a/packages/coding-agent/src/core/skills.ts b/packages/coding-agent/src/core/skills.ts index 260e3583..c388d32b 100644 --- a/packages/coding-agent/src/core/skills.ts +++ b/packages/coding-agent/src/core/skills.ts @@ -2,7 +2,7 @@ import { existsSync, readdirSync, readFileSync } from "fs"; import { minimatch } from "minimatch"; import { homedir } from "os"; import { basename, dirname, join, resolve } from "path"; -import { CONFIG_DIR_NAME } from "../config.js"; +import { CONFIG_DIR_NAME, getAgentDir } from "../config.js"; import type { SkillsSettings } from "./settings-manager.js"; /** @@ -313,12 +313,21 @@ function escapeXml(str: string): string { .replace(/'/g, "'"); } +export interface LoadSkillsOptions extends SkillsSettings { + /** Working directory for project-local skills. Default: process.cwd() */ + cwd?: string; + /** Agent config directory for global skills. Default: ~/.pi/agent */ + agentDir?: string; +} + /** * Load skills from all configured locations. * Returns skills and any validation warnings. */ -export function loadSkills(options: SkillsSettings = {}): LoadSkillsResult { +export function loadSkills(options: LoadSkillsOptions = {}): LoadSkillsResult { const { + cwd = process.cwd(), + agentDir, enableCodexUser = true, enableClaudeUser = true, enableClaudeProject = true, @@ -329,6 +338,9 @@ export function loadSkills(options: SkillsSettings = {}): LoadSkillsResult { includeSkills = [], } = options; + // Resolve agentDir - if not provided, use default from config + const resolvedAgentDir = agentDir ?? getAgentDir(); + const skillMap = new Map(); const allWarnings: SkillWarning[] = []; const collisionWarnings: SkillWarning[] = []; @@ -375,13 +387,13 @@ export function loadSkills(options: SkillsSettings = {}): LoadSkillsResult { addSkills(loadSkillsFromDirInternal(join(homedir(), ".claude", "skills"), "claude-user", "claude")); } if (enableClaudeProject) { - addSkills(loadSkillsFromDirInternal(resolve(process.cwd(), ".claude", "skills"), "claude-project", "claude")); + addSkills(loadSkillsFromDirInternal(resolve(cwd, ".claude", "skills"), "claude-project", "claude")); } if (enablePiUser) { - addSkills(loadSkillsFromDirInternal(join(homedir(), CONFIG_DIR_NAME, "agent", "skills"), "user", "recursive")); + addSkills(loadSkillsFromDirInternal(join(resolvedAgentDir, "skills"), "user", "recursive")); } if (enablePiProject) { - addSkills(loadSkillsFromDirInternal(resolve(process.cwd(), CONFIG_DIR_NAME, "skills"), "project", "recursive")); + addSkills(loadSkillsFromDirInternal(resolve(cwd, CONFIG_DIR_NAME, "skills"), "project", "recursive")); } for (const customDir of customDirectories) { addSkills(loadSkillsFromDirInternal(customDir.replace(/^~(?=$|[\\/])/, homedir()), "custom", "recursive")); diff --git a/packages/coding-agent/src/core/slash-commands.ts b/packages/coding-agent/src/core/slash-commands.ts index 9c599c83..25f48063 100644 --- a/packages/coding-agent/src/core/slash-commands.ts +++ b/packages/coding-agent/src/core/slash-commands.ts @@ -165,20 +165,31 @@ function loadCommandsFromDir(dir: string, source: "user" | "project", subdir: st return commands; } +export interface LoadSlashCommandsOptions { + /** Working directory for project-local commands. Default: process.cwd() */ + cwd?: string; + /** Agent config directory for global commands. Default: from getCommandsDir() */ + agentDir?: string; +} + /** * Load all custom slash commands from: - * 1. Global: ~/{CONFIG_DIR_NAME}/agent/commands/ - * 2. Project: ./{CONFIG_DIR_NAME}/commands/ + * 1. Global: agentDir/commands/ + * 2. Project: cwd/{CONFIG_DIR_NAME}/commands/ */ -export function loadSlashCommands(): FileSlashCommand[] { +export function loadSlashCommands(options: LoadSlashCommandsOptions = {}): FileSlashCommand[] { + const resolvedCwd = options.cwd ?? process.cwd(); + const resolvedAgentDir = options.agentDir ?? getCommandsDir(); + const commands: FileSlashCommand[] = []; - // 1. Load global commands from ~/{CONFIG_DIR_NAME}/agent/commands/ - const globalCommandsDir = getCommandsDir(); + // 1. Load global commands from agentDir/commands/ + // Note: if agentDir is provided, it should be the agent dir, not the commands dir + const globalCommandsDir = options.agentDir ? join(options.agentDir, "commands") : resolvedAgentDir; commands.push(...loadCommandsFromDir(globalCommandsDir, "user")); - // 2. Load project commands from ./{CONFIG_DIR_NAME}/commands/ - const projectCommandsDir = resolve(process.cwd(), CONFIG_DIR_NAME, "commands"); + // 2. Load project commands from cwd/{CONFIG_DIR_NAME}/commands/ + const projectCommandsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "commands"); commands.push(...loadCommandsFromDir(projectCommandsDir, "project")); return commands; diff --git a/packages/coding-agent/src/core/system-prompt.ts b/packages/coding-agent/src/core/system-prompt.ts index 4b47dd62..5a449cd8 100644 --- a/packages/coding-agent/src/core/system-prompt.ts +++ b/packages/coding-agent/src/core/system-prompt.ts @@ -7,7 +7,7 @@ import { existsSync, readFileSync } from "fs"; import { join, resolve } from "path"; import { getAgentDir, getDocsPath, getReadmePath } from "../config.js"; import type { SkillsSettings } from "./settings-manager.js"; -import { formatSkillsForPrompt, loadSkills } from "./skills.js"; +import { formatSkillsForPrompt, loadSkills, type Skill } from "./skills.js"; import type { ToolName } from "./tools/index.js"; /** Tool descriptions for system prompt */ @@ -58,29 +58,39 @@ function loadContextFileFromDir(dir: string): { path: string; content: string } return null; } +export interface LoadContextFilesOptions { + /** Working directory to start walking up from. Default: process.cwd() */ + cwd?: string; + /** Agent config directory for global context. Default: from getAgentDir() */ + agentDir?: string; +} + /** * Load all project context files in order: - * 1. Global: ~/{CONFIG_DIR_NAME}/agent/AGENTS.md or CLAUDE.md + * 1. Global: agentDir/AGENTS.md or CLAUDE.md * 2. Parent directories (top-most first) down to cwd * Each returns {path, content} for separate messages */ -export function loadProjectContextFiles(): Array<{ path: string; content: string }> { +export function loadProjectContextFiles( + options: LoadContextFilesOptions = {}, +): Array<{ path: string; content: string }> { + const resolvedCwd = options.cwd ?? process.cwd(); + const resolvedAgentDir = options.agentDir ?? getAgentDir(); + const contextFiles: Array<{ path: string; content: string }> = []; const seenPaths = new Set(); - // 1. Load global context from ~/{CONFIG_DIR_NAME}/agent/ - const globalContextDir = getAgentDir(); - const globalContext = loadContextFileFromDir(globalContextDir); + // 1. Load global context from agentDir + const globalContext = loadContextFileFromDir(resolvedAgentDir); if (globalContext) { contextFiles.push(globalContext); seenPaths.add(globalContext.path); } // 2. Walk up from cwd to root, collecting all context files - const cwd = process.cwd(); const ancestorContextFiles: Array<{ path: string; content: string }> = []; - let currentDir = cwd; + let currentDir = resolvedCwd; const root = resolve("/"); while (true) { @@ -107,15 +117,37 @@ export function loadProjectContextFiles(): Array<{ path: string; content: string } export interface BuildSystemPromptOptions { + /** Custom system prompt (replaces default). */ customPrompt?: string; + /** Tools to include in prompt. Default: [read, bash, edit, write] */ selectedTools?: ToolName[]; + /** Text to append to system prompt. */ appendSystemPrompt?: string; + /** Skills settings for discovery. */ skillsSettings?: SkillsSettings; + /** Working directory. Default: process.cwd() */ + cwd?: string; + /** Agent config directory. Default: from getAgentDir() */ + agentDir?: string; + /** Pre-loaded context files (skips discovery if provided). */ + contextFiles?: Array<{ path: string; content: string }>; + /** Pre-loaded skills (skips discovery if provided). */ + skills?: Skill[]; } /** Build the system prompt with tools, guidelines, and context */ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): string { - const { customPrompt, selectedTools, appendSystemPrompt, skillsSettings } = options; + const { + customPrompt, + selectedTools, + appendSystemPrompt, + skillsSettings, + cwd, + agentDir, + contextFiles: providedContextFiles, + skills: providedSkills, + } = options; + const resolvedCwd = cwd ?? process.cwd(); const resolvedCustomPrompt = resolvePromptInput(customPrompt, "system prompt"); const resolvedAppendPrompt = resolvePromptInput(appendSystemPrompt, "append system prompt"); @@ -133,6 +165,14 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin const appendSection = resolvedAppendPrompt ? `\n\n${resolvedAppendPrompt}` : ""; + // Resolve context files: use provided or discover + const contextFiles = providedContextFiles ?? loadProjectContextFiles({ cwd: resolvedCwd, agentDir }); + + // Resolve skills: use provided or discover + const skills = + providedSkills ?? + (skillsSettings?.enabled !== false ? loadSkills({ ...skillsSettings, cwd: resolvedCwd, agentDir }).skills : []); + if (resolvedCustomPrompt) { let prompt = resolvedCustomPrompt; @@ -141,7 +181,6 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin } // Append project context files - const contextFiles = loadProjectContextFiles(); if (contextFiles.length > 0) { prompt += "\n\n# Project Context\n\n"; prompt += "The following project context files have been loaded:\n\n"; @@ -152,14 +191,13 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin // Append skills section (only if read tool is available) const customPromptHasRead = !selectedTools || selectedTools.includes("read"); - if (skillsSettings?.enabled !== false && customPromptHasRead) { - const { skills } = loadSkills(skillsSettings ?? {}); + if (customPromptHasRead && skills.length > 0) { prompt += formatSkillsForPrompt(skills); } // Add date/time and working directory last prompt += `\nCurrent date and time: ${dateTime}`; - prompt += `\nCurrent working directory: ${process.cwd()}`; + prompt += `\nCurrent working directory: ${resolvedCwd}`; return prompt; } @@ -248,7 +286,6 @@ Documentation: } // Append project context files - const contextFiles = loadProjectContextFiles(); if (contextFiles.length > 0) { prompt += "\n\n# Project Context\n\n"; prompt += "The following project context files have been loaded:\n\n"; @@ -258,14 +295,13 @@ Documentation: } // Append skills section (only if read tool is available) - if (skillsSettings?.enabled !== false && hasRead) { - const { skills } = loadSkills(skillsSettings ?? {}); + if (hasRead && skills.length > 0) { prompt += formatSkillsForPrompt(skills); } // Add date/time and working directory last prompt += `\nCurrent date and time: ${dateTime}`; - prompt += `\nCurrent working directory: ${process.cwd()}`; + prompt += `\nCurrent working directory: ${resolvedCwd}`; return prompt; } diff --git a/packages/coding-agent/src/core/tools/index.ts b/packages/coding-agent/src/core/tools/index.ts index 034f554a..d9da52ca 100644 --- a/packages/coding-agent/src/core/tools/index.ts +++ b/packages/coding-agent/src/core/tools/index.ts @@ -1,3 +1,5 @@ +import type { AgentTool } from "@mariozechner/pi-ai"; + export { type BashToolDetails, bashTool } from "./bash.js"; export { editTool } from "./edit.js"; export { type FindToolDetails, findTool } from "./find.js"; @@ -15,8 +17,14 @@ import { lsTool } from "./ls.js"; import { readTool } from "./read.js"; import { writeTool } from "./write.js"; +/** Tool type (AgentTool from pi-ai) */ +export type Tool = AgentTool; + // Default tools for full access mode -export const codingTools = [readTool, bashTool, editTool, writeTool]; +export const codingTools: Tool[] = [readTool, bashTool, editTool, writeTool]; + +// Read-only tools for exploration without modification +export const readOnlyTools: Tool[] = [readTool, grepTool, findTool, lsTool]; // All available tools (including read-only exploration tools) export const allTools = { diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 7683c4a9..9fe63515 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -83,6 +83,29 @@ export { type OAuthPrompt, type OAuthProvider, } from "./core/oauth/index.js"; +// SDK for programmatic usage +export { + allBuiltInTools, + type BuildSystemPromptOptions, + buildSystemPrompt, + type CreateAgentSessionOptions, + // Factory + createAgentSession, + // Helpers + defaultGetApiKey, + discoverAvailableModels, + discoverContextFiles, + discoverCustomTools, + discoverHooks, + // Discovery + discoverModels, + discoverSkills, + discoverSlashCommands, + findModel as findModelByProviderAndId, + loadSettings, + // Tools + readOnlyTools, +} from "./core/sdk.js"; export { type CompactionEntry, createSummaryMessage,