Files
oh-my-pi/packages/coding-agent/src/discovery/builtin.ts
T
16d7f9aec0 feat: monorepo-friendly discovery for AGENTS.md and skills (#293)
* feat: monorepo-friendly discovery for AGENTS.md and skills

Support hierarchical config discovery in monorepos by walking up from
cwd through ancestor directories, combining files from all levels
instead of only checking the immediate working directory.

AGENTS.md context files:
- Change dedup key from file.level to file.level + depth, so context
  files at different directory levels coexist instead of shadowing
- Root AGENTS.md and sub-project AGENTS.md are both included in the
  system prompt, ordered from least specific to most specific

Skills:
- Modify all 4 providers with project-level skill directories (native,
  claude, codex, opencode) to walk up from cwd through ancestors,
  scanning for skills at each level
- Skills at closer directories win on name conflicts via existing
  name-based dedup

Repo root boundary:
- Add findRepoRoot() utility that detects .git to identify repo root
- Add repoRoot field to LoadContext, computed once in loadCapability()
- All walk-up traversals (AGENTS.md, skills, nearest config dir) stop
  at the repo root, preventing discovery from leaking outside the repo
- When not in a git repo, falls back to walking to filesystem root

Files changed:
- capability/fs.ts: add findRepoRoot()
- capability/types.ts: add repoRoot to LoadContext
- capability/index.ts: compute repoRoot in loadCapability()
- capability/context-file.ts: depth-aware dedup key
- discovery/agents-md.ts: bound walk-up at repo root
- discovery/builtin.ts: getAncestorDirs stopAt param, skill walk-up
- discovery/claude.ts: skill walk-up with repo root bound
- discovery/codex.ts: skill walk-up with repo root bound
- discovery/opencode.ts: skill walk-up with repo root bound
- extensibility/skills.ts: add repoRoot to inline LoadContext

* feat(agents-provider): add project-level discovery with ancestor walk-up for all capability types

The agents provider (.agent/.agents directories) previously only loaded
capabilities from the user home directory (~/.agent/, ~/.agents/). This
meant project-level .agents/ directories in monorepo roots were not
discovered when sessions started from subdirectories.

Add getProjectPathCandidates() helper that walks from cwd up to repoRoot,
scanning both .agent/ and .agents/ at each ancestor level. Apply this to
all six capability types: skills, rules, prompts, commands, context files
(AGENTS.md), and system prompts (SYSTEM.md). This matches the ancestor
walk-up behavior already present in the builtin (.omp), claude, codex,
and opencode providers.

All loaders now parallelize project-level and user-level scans via
Promise.all. Project-level results appear closest-first so dedup at the
capability layer picks the nearest override.

* fix(discovery): remove hard cap of 20 on ancestor directory walking

All walk-up loops already terminate naturally at repoRoot or filesystem
root. The depth < 20 / .slice(0, 20) / MAX_DEPTH caps were redundant
safety guards that would silently stop discovery in deeply nested
projects.

Removed from: agents.ts, agents-md.ts, builtin.ts, claude.ts, codex.ts,
opencode.ts, and corresponding test files.

* fix(agents-provider): set depth on project-level ContextFile entries for correct dedup

loadContextFiles was creating project-level ContextFile entries without
a depth field. Since contextFileCapability.key uses
`project:${file.depth ?? 0}`, all ancestor-level AGENTS.md files
collapsed to the same key and only the first survived dedup.

Compute depth via calculateDepth(cwd, ancestorDir) where ancestorDir is
two levels up from the file path (past the .agent/.agents config dir).
This gives each ancestor level a distinct dedup key.

* fix(discovery): stop ancestor walk-up at $HOME when not in a git repo

When repoRoot is null (no .git found), walk-up loops traversed all the
way to filesystem root. This caused $HOME to be scanned as a project-
level directory and then again as user-level, producing duplicates.

All providers now use `ctx.repoRoot ?? ctx.home` as the stop boundary:
stop at repoRoot if in a repo, otherwise stop at home. Applied to
agents.ts, agents-md.ts, builtin.ts, claude.ts, codex.ts, and
opencode.ts.

* fix(context-files): clamp depth >= 0 in dedup key and fix provider depth computations

The dedup key `project:${file.depth}` used raw depth, which could be
negative when providers computed it from config subdirectories (e.g.
.claude/, .github/, .gemini/) rather than the ancestor directory. This
caused same-scope cwd-level files to get distinct keys like project:-1
and project:0, bypassing dedup and injecting conflicting instructions.

Two-layer fix:
1. Key function: clamp to Math.max(0, depth) so any file at or below
   cwd is treated as cwd-scope (depth 0). Defensive against future
   providers.
2. Providers: fix root cause in claude.ts, gemini.ts, github.ts to
   compute depth from the ancestor directory (parent of the config
   subdir), not the config subdir itself.

---------

Co-authored-by: Can Bölük <can1357@users.noreply.github.com>
2026-03-04 22:36:55 +01:00

836 lines
25 KiB
TypeScript

/**
* Builtin Provider (.omp)
*
* Primary provider for OMP native configs. Supports all capabilities.
*/
import * as path from "node:path";
import { logger, tryParseJson } from "@oh-my-pi/pi-utils";
import { registerProvider } from "../capability";
import { type ContextFile, contextFileCapability } from "../capability/context-file";
import { type Extension, type ExtensionManifest, extensionCapability } from "../capability/extension";
import { type ExtensionModule, extensionModuleCapability } from "../capability/extension-module";
import { readDirEntries, readFile } from "../capability/fs";
import { type Hook, hookCapability } from "../capability/hook";
import { type Instruction, instructionCapability } from "../capability/instruction";
import { type MCPServer, mcpCapability } from "../capability/mcp";
import { type Prompt, promptCapability } from "../capability/prompt";
import { type Rule, ruleCapability } from "../capability/rule";
import { type Settings, settingsCapability } from "../capability/settings";
import { type Skill, skillCapability } from "../capability/skill";
import { type SlashCommand, slashCommandCapability } from "../capability/slash-command";
import { type SystemPrompt, systemPromptCapability } from "../capability/system-prompt";
import { type CustomTool, toolCapability } from "../capability/tool";
import type { LoadContext, LoadResult } from "../capability/types";
import { expandTilde } from "../tools/path-utils";
import { parseFrontmatter } from "../utils/frontmatter";
import {
buildRuleFromMarkdown,
createSourceMeta,
discoverExtensionModulePaths,
expandEnvVarsDeep,
getExtensionNameFromPath,
loadFilesFromDir,
SOURCE_PATHS,
scanSkillsFromDir,
} from "./helpers";
const PROVIDER_ID = "native";
const DISPLAY_NAME = "OMP";
const DESCRIPTION = "Native OMP configuration from ~/.omp and .omp/";
const PRIORITY = 100;
const PATHS = SOURCE_PATHS.native;
async function ifNonEmptyDir(...seg: string[]): Promise<string | null> {
let dir = path.join(...seg);
const entries = await readDirEntries(dir);
if (entries.length > 0) {
if (!path.isAbsolute(dir)) {
dir = path.resolve(dir);
}
return dir;
}
return null;
}
async function getConfigDirs(ctx: LoadContext): Promise<Array<{ dir: string; level: "user" | "project" }>> {
const result: Array<{ dir: string; level: "user" | "project" }> = [];
const projectDir = await ifNonEmptyDir(ctx.cwd, PATHS.projectDir);
if (projectDir) {
result.push({ dir: projectDir, level: "project" });
}
const userDir = await ifNonEmptyDir(ctx.home, PATHS.userAgent);
if (userDir) {
result.push({ dir: userDir, level: "user" });
}
return result;
}
function getAncestorDirs(cwd: string, stopAt?: string | null): Array<{ dir: string; depth: number }> {
const ancestors: Array<{ dir: string; depth: number }> = [];
let current = cwd;
let depth = 0;
while (true) {
ancestors.push({ dir: current, depth });
if (stopAt && current === stopAt) break;
const parent = path.dirname(current);
if (parent === current) break;
current = parent;
depth++;
}
return ancestors;
}
async function findNearestProjectConfigDir(
cwd: string,
repoRoot?: string | null,
): Promise<{ dir: string; depth: number } | null> {
for (const ancestor of getAncestorDirs(cwd, repoRoot)) {
const configDir = await ifNonEmptyDir(ancestor.dir, PATHS.projectDir);
if (configDir) return { dir: configDir, depth: ancestor.depth };
}
return null;
}
// MCP
async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>> {
const items: MCPServer[] = [];
const warnings: string[] = [];
const parseMcpServers = (content: string, path: string, level: "user" | "project"): MCPServer[] => {
const result: MCPServer[] = [];
const data = tryParseJson<{ mcpServers?: Record<string, unknown> }>(content);
if (!data?.mcpServers) return result;
const expanded = expandEnvVarsDeep(data.mcpServers);
for (const [serverName, config] of Object.entries(expanded)) {
const serverConfig = config as Record<string, unknown>;
// Validate enabled: coerce string "true"/"false", warn on other types
let enabled: boolean | undefined;
if (serverConfig.enabled === undefined || serverConfig.enabled === null) {
enabled = undefined;
} else if (typeof serverConfig.enabled === "boolean") {
enabled = serverConfig.enabled;
} else if (typeof serverConfig.enabled === "string") {
const lower = serverConfig.enabled.toLowerCase();
if (lower === "false" || lower === "0") enabled = false;
else if (lower === "true" || lower === "1") enabled = true;
else {
logger.warn(`MCP server "${serverName}": invalid enabled value "${serverConfig.enabled}", ignoring`);
enabled = undefined;
}
} else {
logger.warn(`MCP server "${serverName}": invalid enabled type ${typeof serverConfig.enabled}, ignoring`);
enabled = undefined;
}
// Validate timeout: coerce numeric strings, warn on invalid
let timeout: number | undefined;
if (serverConfig.timeout === undefined || serverConfig.timeout === null) {
timeout = undefined;
} else if (typeof serverConfig.timeout === "number") {
if (Number.isFinite(serverConfig.timeout) && serverConfig.timeout > 0) {
timeout = serverConfig.timeout;
} else {
logger.warn(`MCP server "${serverName}": invalid timeout ${serverConfig.timeout}, ignoring`);
timeout = undefined;
}
} else if (typeof serverConfig.timeout === "string") {
const parsed = Number(serverConfig.timeout);
if (Number.isFinite(parsed) && parsed > 0) {
timeout = parsed;
} else {
logger.warn(`MCP server "${serverName}": invalid timeout "${serverConfig.timeout}", ignoring`);
timeout = undefined;
}
} else {
logger.warn(`MCP server "${serverName}": invalid timeout type ${typeof serverConfig.timeout}, ignoring`);
timeout = undefined;
}
result.push({
name: serverName,
enabled,
timeout,
command: serverConfig.command as string | undefined,
args: serverConfig.args as string[] | undefined,
env: serverConfig.env as Record<string, string> | undefined,
url: serverConfig.url as string | undefined,
headers: serverConfig.headers as Record<string, string> | undefined,
auth: serverConfig.auth as { type: "oauth" | "apikey"; credentialId?: string } | undefined,
oauth: serverConfig.oauth as { clientId?: string; callbackPort?: number } | undefined,
transport: serverConfig.type as "stdio" | "sse" | "http" | undefined,
_source: createSourceMeta(PROVIDER_ID, path, level),
});
}
return result;
};
const paths = [
{ path: path.join(ctx.cwd, PATHS.projectDir, "mcp.json"), level: "project" as const },
{ path: path.join(ctx.cwd, PATHS.projectDir, ".mcp.json"), level: "project" as const },
{ path: path.join(ctx.home, PATHS.userAgent, "mcp.json"), level: "user" as const },
{ path: path.join(ctx.home, PATHS.userAgent, ".mcp.json"), level: "user" as const },
];
const contents = await Promise.allSettled(
paths.map(async p => {
const content = await readFile(p.path);
if (content) {
return { path: p.path, content, level: p.level };
}
return null;
}),
);
for (const result of contents) {
if (result.status === "fulfilled" && result.value) {
const { path, content, level } = result.value;
items.push(...parseMcpServers(content, path, level));
}
}
return { items, warnings };
}
registerProvider<MCPServer>(mcpCapability.id, {
id: PROVIDER_ID,
displayName: DISPLAY_NAME,
description: DESCRIPTION,
priority: PRIORITY,
load: loadMCPServers,
});
// System Prompt (SYSTEM.md)
async function loadSystemPrompt(ctx: LoadContext): Promise<LoadResult<SystemPrompt>> {
const items: SystemPrompt[] = [];
const userPath = path.join(ctx.home, PATHS.userAgent, "SYSTEM.md");
const userContent = await readFile(userPath);
if (userContent) {
items.push({
path: userPath,
content: userContent,
level: "user",
_source: createSourceMeta(PROVIDER_ID, userPath, "user"),
});
}
const nearestProjectConfigDir = await findNearestProjectConfigDir(ctx.cwd, ctx.repoRoot);
if (nearestProjectConfigDir) {
const projectPath = path.join(nearestProjectConfigDir.dir, "SYSTEM.md");
const projectContent = await readFile(projectPath);
if (projectContent) {
items.push({
path: projectPath,
content: projectContent,
level: "project",
_source: createSourceMeta(PROVIDER_ID, projectPath, "project"),
});
}
}
return { items, warnings: [] };
}
registerProvider<SystemPrompt>(systemPromptCapability.id, {
id: PROVIDER_ID,
displayName: DISPLAY_NAME,
description: "Custom system prompt from SYSTEM.md",
priority: PRIORITY,
load: loadSystemPrompt,
});
// Skills
async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
// Walk up from cwd finding .omp/skills/ in ancestors (closest first)
const ancestors = getAncestorDirs(ctx.cwd, ctx.repoRoot ?? ctx.home);
const projectScans = ancestors.map(({ dir }) =>
scanSkillsFromDir(ctx, {
dir: path.join(dir, PATHS.projectDir, "skills"),
providerId: PROVIDER_ID,
level: "project",
requireDescription: true,
}),
);
// User-level scan from ~/.omp/agent/skills/
const userScan = scanSkillsFromDir(ctx, {
dir: path.join(ctx.home, PATHS.userAgent, "skills"),
providerId: PROVIDER_ID,
level: "user",
requireDescription: true,
});
const results = await Promise.all([...projectScans, userScan]);
return {
items: results.flatMap(r => r.items),
warnings: results.flatMap(r => r.warnings ?? []),
};
}
registerProvider<Skill>(skillCapability.id, {
id: PROVIDER_ID,
displayName: DISPLAY_NAME,
description: DESCRIPTION,
priority: PRIORITY,
load: loadSkills,
});
// Slash Commands
async function loadSlashCommands(ctx: LoadContext): Promise<LoadResult<SlashCommand>> {
const items: SlashCommand[] = [];
const warnings: string[] = [];
for (const { dir, level } of await getConfigDirs(ctx)) {
const commandsDir = path.join(dir, "commands");
const result = await loadFilesFromDir<SlashCommand>(ctx, commandsDir, PROVIDER_ID, level, {
extensions: ["md"],
transform: (name, content, path, source) => ({
name: name.replace(/\.md$/, ""),
path,
content,
level,
_source: source,
}),
});
items.push(...result.items);
if (result.warnings) warnings.push(...result.warnings);
}
return { items, warnings };
}
registerProvider<SlashCommand>(slashCommandCapability.id, {
id: PROVIDER_ID,
displayName: DISPLAY_NAME,
description: DESCRIPTION,
priority: PRIORITY,
load: loadSlashCommands,
});
// Rules
async function loadRules(ctx: LoadContext): Promise<LoadResult<Rule>> {
const items: Rule[] = [];
const warnings: string[] = [];
for (const { dir, level } of await getConfigDirs(ctx)) {
const rulesDir = path.join(dir, "rules");
const result = await loadFilesFromDir<Rule>(ctx, rulesDir, PROVIDER_ID, level, {
extensions: ["md", "mdc"],
transform: (name, content, path, source) =>
buildRuleFromMarkdown(name, content, path, source, { stripNamePattern: /\.(md|mdc)$/ }),
});
items.push(...result.items);
if (result.warnings) warnings.push(...result.warnings);
}
return { items, warnings };
}
registerProvider<Rule>(ruleCapability.id, {
id: PROVIDER_ID,
displayName: DISPLAY_NAME,
description: DESCRIPTION,
priority: PRIORITY,
load: loadRules,
});
// Prompts
async function loadPrompts(ctx: LoadContext): Promise<LoadResult<Prompt>> {
const items: Prompt[] = [];
const warnings: string[] = [];
for (const { dir, level } of await getConfigDirs(ctx)) {
const promptsDir = path.join(dir, "prompts");
const result = await loadFilesFromDir<Prompt>(ctx, promptsDir, PROVIDER_ID, level, {
extensions: ["md"],
transform: (name, content, path, source) => ({
name: name.replace(/\.md$/, ""),
path,
content,
_source: source,
}),
});
items.push(...result.items);
if (result.warnings) warnings.push(...result.warnings);
}
return { items, warnings };
}
registerProvider<Prompt>(promptCapability.id, {
id: PROVIDER_ID,
displayName: DISPLAY_NAME,
description: DESCRIPTION,
priority: PRIORITY,
load: loadPrompts,
});
// Extension Modules
async function loadExtensionModules(ctx: LoadContext): Promise<LoadResult<ExtensionModule>> {
const items: ExtensionModule[] = [];
const warnings: string[] = [];
const resolveExtensionPath = (rawPath: string): string => {
const expanded = expandTilde(rawPath, ctx.home);
if (path.isAbsolute(expanded)) {
return expanded;
}
return path.resolve(ctx.cwd, expanded);
};
const createExtensionModule = (extPath: string, level: "user" | "project"): ExtensionModule => ({
name: getExtensionNameFromPath(extPath),
path: extPath,
level,
_source: createSourceMeta(PROVIDER_ID, extPath, level),
});
const configDirs = await getConfigDirs(ctx);
const [discoveredResults, settingsResults] = await Promise.all([
Promise.all(configDirs.map(({ dir }) => discoverExtensionModulePaths(ctx, path.join(dir, "extensions")))),
Promise.all(configDirs.map(({ dir }) => readFile(path.join(dir, "settings.json")))),
]);
for (let i = 0; i < configDirs.length; i++) {
const { level } = configDirs[i];
for (const extPath of discoveredResults[i]) {
items.push(createExtensionModule(extPath, level));
}
}
const settingsExtensions: Array<{
resolvedPath: string;
settingsPath: string;
level: "user" | "project";
}> = [];
for (let i = 0; i < configDirs.length; i++) {
const { dir, level } = configDirs[i];
const settingsContent = settingsResults[i];
if (!settingsContent) continue;
const settingsPath = path.join(dir, "settings.json");
const settingsData = tryParseJson<{ extensions?: unknown }>(settingsContent);
const extensions = settingsData?.extensions;
if (!Array.isArray(extensions)) continue;
for (const entry of extensions) {
if (typeof entry !== "string") {
warnings.push(`Invalid extension path in ${settingsPath}: ${String(entry)}`);
continue;
}
settingsExtensions.push({
resolvedPath: resolveExtensionPath(entry),
settingsPath,
level,
});
}
}
const [entriesResults, fileContents] = await Promise.all([
Promise.all(settingsExtensions.map(({ resolvedPath }) => readDirEntries(resolvedPath))),
Promise.all(settingsExtensions.map(({ resolvedPath }) => readFile(resolvedPath))),
]);
const dirDiscoveryPromises: Array<{
promise: Promise<string[]>;
level: "user" | "project";
}> = [];
for (let i = 0; i < settingsExtensions.length; i++) {
const { resolvedPath, level } = settingsExtensions[i];
const entries = entriesResults[i];
const content = fileContents[i];
if (entries.length > 0) {
dirDiscoveryPromises.push({
promise: discoverExtensionModulePaths(ctx, resolvedPath),
level,
});
} else if (content !== null) {
items.push(createExtensionModule(resolvedPath, level));
} else {
warnings.push(`Extension path not found: ${resolvedPath}`);
}
}
const dirDiscoveryResults = await Promise.all(dirDiscoveryPromises.map(d => d.promise));
for (let i = 0; i < dirDiscoveryPromises.length; i++) {
const { level } = dirDiscoveryPromises[i];
for (const extPath of dirDiscoveryResults[i]) {
items.push(createExtensionModule(extPath, level));
}
}
return { items, warnings };
}
registerProvider<ExtensionModule>(extensionModuleCapability.id, {
id: PROVIDER_ID,
displayName: DISPLAY_NAME,
description: DESCRIPTION,
priority: PRIORITY,
load: loadExtensionModules,
});
// Extensions
async function loadExtensions(ctx: LoadContext): Promise<LoadResult<Extension>> {
const items: Extension[] = [];
const warnings: string[] = [];
const configDirs = await getConfigDirs(ctx);
const entriesResults = await Promise.all(configDirs.map(({ dir }) => readDirEntries(path.join(dir, "extensions"))));
const manifestCandidates: Array<{
extDir: string;
manifestPath: string;
entryName: string;
level: "user" | "project";
}> = [];
for (let i = 0; i < configDirs.length; i++) {
const { dir, level } = configDirs[i];
const entries = entriesResults[i];
const extensionsDir = path.join(dir, "extensions");
for (const entry of entries) {
if (entry.name.startsWith(".")) continue;
if (!entry.isDirectory()) continue;
const extDir = path.join(extensionsDir, entry.name);
manifestCandidates.push({
extDir,
manifestPath: path.join(extDir, "gemini-extension.json"),
entryName: entry.name,
level,
});
}
}
const manifestContents = await Promise.all(manifestCandidates.map(({ manifestPath }) => readFile(manifestPath)));
for (let i = 0; i < manifestCandidates.length; i++) {
const content = manifestContents[i];
if (!content) continue;
const { extDir, manifestPath, entryName, level } = manifestCandidates[i];
const manifest = tryParseJson<ExtensionManifest>(content);
if (!manifest) {
warnings.push(`Failed to parse ${manifestPath}`);
continue;
}
items.push({
name: manifest.name || entryName,
path: extDir,
manifest,
level,
_source: createSourceMeta(PROVIDER_ID, manifestPath, level),
});
}
return { items, warnings };
}
registerProvider<Extension>(extensionCapability.id, {
id: PROVIDER_ID,
displayName: DISPLAY_NAME,
description: DESCRIPTION,
priority: PRIORITY,
load: loadExtensions,
});
// Instructions
async function loadInstructions(ctx: LoadContext): Promise<LoadResult<Instruction>> {
const items: Instruction[] = [];
const warnings: string[] = [];
for (const { dir, level } of await getConfigDirs(ctx)) {
const instructionsDir = path.join(dir, "instructions");
const result = await loadFilesFromDir<Instruction>(ctx, instructionsDir, PROVIDER_ID, level, {
extensions: ["md"],
transform: (name, content, path, source) => {
const { frontmatter, body } = parseFrontmatter(content, { source: path });
return {
name: name.replace(/\.instructions\.md$/, "").replace(/\.md$/, ""),
path,
content: body,
applyTo: frontmatter.applyTo as string | undefined,
_source: source,
};
},
});
items.push(...result.items);
if (result.warnings) warnings.push(...result.warnings);
}
return { items, warnings };
}
registerProvider<Instruction>(instructionCapability.id, {
id: PROVIDER_ID,
displayName: DISPLAY_NAME,
description: DESCRIPTION,
priority: PRIORITY,
load: loadInstructions,
});
// Hooks
async function loadHooks(ctx: LoadContext): Promise<LoadResult<Hook>> {
const items: Hook[] = [];
const configDirs = await getConfigDirs(ctx);
const hookTypes = ["pre", "post"] as const;
const typeDirRequests: Array<{
typeDir: string;
hookType: (typeof hookTypes)[number];
level: "user" | "project";
}> = [];
for (const { dir, level } of configDirs) {
for (const hookType of hookTypes) {
typeDirRequests.push({
typeDir: path.join(dir, "hooks", hookType),
hookType,
level,
});
}
}
const typeEntriesResults = await Promise.all(typeDirRequests.map(({ typeDir }) => readDirEntries(typeDir)));
for (let i = 0; i < typeDirRequests.length; i++) {
const { typeDir, hookType, level } = typeDirRequests[i];
const typeEntries = typeEntriesResults[i];
for (const entry of typeEntries) {
if (entry.name.startsWith(".")) continue;
if (!entry.isFile()) continue;
const hookPath = path.join(typeDir, entry.name);
const baseName = entry.name.includes(".") ? entry.name.slice(0, entry.name.lastIndexOf(".")) : entry.name;
const tool = baseName === "*" ? "*" : baseName;
items.push({
name: entry.name,
path: hookPath,
type: hookType,
tool,
level,
_source: createSourceMeta(PROVIDER_ID, hookPath, level),
});
}
}
return { items, warnings: [] };
}
registerProvider<Hook>(hookCapability.id, {
id: PROVIDER_ID,
displayName: DISPLAY_NAME,
description: DESCRIPTION,
priority: PRIORITY,
load: loadHooks,
});
// Custom Tools
async function loadTools(ctx: LoadContext): Promise<LoadResult<CustomTool>> {
const items: CustomTool[] = [];
const warnings: string[] = [];
const configDirs = await getConfigDirs(ctx);
const entriesResults = await Promise.all(configDirs.map(({ dir }) => readDirEntries(path.join(dir, "tools"))));
const fileLoadPromises: Array<Promise<{ items: CustomTool[]; warnings?: string[] }>> = [];
const subDirCandidates: Array<{
indexPath: string;
entryName: string;
level: "user" | "project";
}> = [];
for (let i = 0; i < configDirs.length; i++) {
const { dir, level } = configDirs[i];
const toolEntries = entriesResults[i];
if (toolEntries.length === 0) continue;
const toolsDir = path.join(dir, "tools");
fileLoadPromises.push(
loadFilesFromDir<CustomTool>(ctx, toolsDir, PROVIDER_ID, level, {
extensions: ["json", "md", "ts", "js", "sh", "bash", "py"],
transform: (name, content, path, source) => {
if (name.endsWith(".json")) {
const data = tryParseJson<{ name?: string; description?: string }>(content);
const toolName = data?.name || name.replace(/\.json$/, "");
const description =
typeof data?.description === "string" && data.description.trim()
? data.description
: `${toolName} custom tool`;
return {
name: toolName,
path,
description,
level,
_source: source,
};
}
if (name.endsWith(".md")) {
const { frontmatter } = parseFrontmatter(content, { source: path });
const toolName = (frontmatter.name as string) || name.replace(/\.md$/, "");
const description =
typeof frontmatter.description === "string" && frontmatter.description.trim()
? String(frontmatter.description)
: `${toolName} custom tool`;
return {
name: toolName,
path,
description,
level,
_source: source,
};
}
// Executable tool files (.ts, .js, .sh, .bash, .py)
const toolName = name.replace(/\.(ts|js|sh|bash|py)$/, "");
return {
name: toolName,
path,
description: `${toolName} custom tool`,
level,
_source: source,
};
},
}),
);
for (const entry of toolEntries) {
if (entry.name.startsWith(".")) continue;
if (!entry.isDirectory()) continue;
subDirCandidates.push({
indexPath: path.join(toolsDir, entry.name, "index.ts"),
entryName: entry.name,
level,
});
}
}
const [fileResults, indexContents] = await Promise.all([
Promise.all(fileLoadPromises),
Promise.all(subDirCandidates.map(({ indexPath }) => readFile(indexPath))),
]);
for (const result of fileResults) {
items.push(...result.items);
if (result.warnings) warnings.push(...result.warnings);
}
for (let i = 0; i < subDirCandidates.length; i++) {
const indexContent = indexContents[i];
if (indexContent !== null) {
const { indexPath, entryName, level } = subDirCandidates[i];
items.push({
name: entryName,
path: indexPath,
description: `${entryName} custom tool`,
level,
_source: createSourceMeta(PROVIDER_ID, indexPath, level),
});
}
}
return { items, warnings };
}
registerProvider<CustomTool>(toolCapability.id, {
id: PROVIDER_ID,
displayName: DISPLAY_NAME,
description: DESCRIPTION,
priority: PRIORITY,
load: loadTools,
});
// Settings
async function loadSettings(ctx: LoadContext): Promise<LoadResult<Settings>> {
const items: Settings[] = [];
const warnings: string[] = [];
for (const { dir, level } of await getConfigDirs(ctx)) {
const settingsPath = path.join(dir, "settings.json");
const content = await readFile(settingsPath);
if (!content) continue;
const data = tryParseJson<Record<string, unknown>>(content);
if (!data) {
warnings.push(`Failed to parse ${settingsPath}`);
continue;
}
items.push({
path: settingsPath,
data,
level,
_source: createSourceMeta(PROVIDER_ID, settingsPath, level),
});
}
return { items, warnings };
}
registerProvider<Settings>(settingsCapability.id, {
id: PROVIDER_ID,
displayName: DISPLAY_NAME,
description: DESCRIPTION,
priority: PRIORITY,
load: loadSettings,
});
// Context Files (AGENTS.md)
async function loadContextFiles(ctx: LoadContext): Promise<LoadResult<ContextFile>> {
const items: ContextFile[] = [];
const warnings: string[] = [];
const userPath = path.join(ctx.home, PATHS.userAgent, "AGENTS.md");
const userContent = await readFile(userPath);
if (userContent) {
items.push({
path: userPath,
content: userContent,
level: "user",
_source: createSourceMeta(PROVIDER_ID, userPath, "user"),
});
}
const nearestProjectConfigDir = await findNearestProjectConfigDir(ctx.cwd, ctx.repoRoot);
if (nearestProjectConfigDir) {
const projectPath = path.join(nearestProjectConfigDir.dir, "AGENTS.md");
const projectContent = await readFile(projectPath);
if (projectContent) {
items.push({
path: projectPath,
content: projectContent,
level: "project",
depth: nearestProjectConfigDir.depth,
_source: createSourceMeta(PROVIDER_ID, projectPath, "project"),
});
return { items, warnings };
}
}
return { items, warnings };
}
registerProvider<ContextFile>(contextFileCapability.id, {
id: PROVIDER_ID,
displayName: DISPLAY_NAME,
description: "Load AGENTS.md from .omp/ directories",
priority: PRIORITY,
load: loadContextFiles,
});