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>
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Added project-level discovery for `.agent/` and `.agents/` directories, walking up from cwd to repo root (matching behavior of other providers like `.omp`, `.claude`, `.codex`). Applies to skills, rules, prompts, commands, context files (AGENTS.md), and system prompts (SYSTEM.md)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed `:thinking` suffix in `modelRoles` config values silently breaking model resolution (e.g., `slow: anthropic/claude-opus-4-6:high`) and being stripped on Ctrl+P role cycling
|
||||
|
||||
@@ -27,9 +27,12 @@ export const contextFileCapability = defineCapability<ContextFile>({
|
||||
id: "context-files",
|
||||
displayName: "Context Files",
|
||||
description: "Persistent instruction files (CLAUDE.md, AGENTS.md, etc.) that guide agent behavior",
|
||||
// Deduplicate by level: one user-level file, one project-level file
|
||||
// Higher-priority providers shadow lower-priority ones at the same scope
|
||||
key: file => file.level,
|
||||
// Deduplicate by scope: one user-level file, and one project-level file per directory depth.
|
||||
// Within each depth level, higher-priority providers shadow lower-priority ones.
|
||||
// This supports monorepo hierarchies where AGENTS.md exists at multiple ancestor levels.
|
||||
// Clamp depth >= 0: files inside config subdirectories of an ancestor (e.g. .claude/, .github/)
|
||||
// are same-scope as the ancestor itself.
|
||||
key: file => (file.level === "user" ? "user" : `project:${Math.max(0, file.depth ?? 0)}`),
|
||||
validate: file => {
|
||||
if (!file.path) return "Missing path";
|
||||
if (file.content === undefined) return "Missing content";
|
||||
|
||||
@@ -66,6 +66,24 @@ export async function walkUp(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk up from startDir looking for a `.git` entry (file or directory).
|
||||
* Returns the directory containing `.git` (the repo root), or null if not in a git repo.
|
||||
* Results are based on the cached readDirEntries, so repeated calls are cheap.
|
||||
*/
|
||||
export async function findRepoRoot(startDir: string): Promise<string | null> {
|
||||
let current = resolvePath(startDir);
|
||||
while (true) {
|
||||
const entries = await readDirEntries(current);
|
||||
if (entries.some(e => e.name === ".git")) {
|
||||
return current;
|
||||
}
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) return null;
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
export function cacheStats(): { content: number; dir: number } {
|
||||
return {
|
||||
content: contentCache.size,
|
||||
|
||||
@@ -11,7 +11,7 @@ import * as path from "node:path";
|
||||
import { getProjectDir, logger } from "@oh-my-pi/pi-utils";
|
||||
|
||||
import type { Settings } from "../config/settings";
|
||||
import { clearCache as clearFsCache, cacheStats as fsCacheStats, invalidate as invalidateFs } from "./fs";
|
||||
import { clearCache as clearFsCache, findRepoRoot, cacheStats as fsCacheStats, invalidate as invalidateFs } from "./fs";
|
||||
import type {
|
||||
Capability,
|
||||
CapabilityInfo,
|
||||
@@ -220,7 +220,8 @@ export async function loadCapability<T>(capabilityId: string, options: LoadOptio
|
||||
|
||||
const cwd = options.cwd ?? getProjectDir();
|
||||
const home = os.homedir();
|
||||
const ctx: LoadContext = { cwd, home };
|
||||
const repoRoot = await findRepoRoot(cwd);
|
||||
const ctx: LoadContext = { cwd, home, repoRoot };
|
||||
const providers = filterProviders(capability, options);
|
||||
|
||||
return await loadImpl(capability, providers, ctx, options);
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface LoadContext {
|
||||
cwd: string;
|
||||
/** User home directory */
|
||||
home: string;
|
||||
/** Git repository root (directory containing .git), or null if not in a repo */
|
||||
repoRoot: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,7 +14,6 @@ import { calculateDepth, createSourceMeta } from "./helpers";
|
||||
|
||||
const PROVIDER_ID = "agents-md";
|
||||
const DISPLAY_NAME = "AGENTS.md";
|
||||
const MAX_DEPTH = 20; // Prevent walking up excessively far from cwd
|
||||
|
||||
/**
|
||||
* Load standalone AGENTS.md files.
|
||||
@@ -25,9 +24,8 @@ async function loadAgentsMd(ctx: LoadContext): Promise<LoadResult<ContextFile>>
|
||||
|
||||
// Walk up from cwd looking for AGENTS.md files
|
||||
let current = ctx.cwd;
|
||||
let depth = 0;
|
||||
|
||||
while (depth < MAX_DEPTH) {
|
||||
while (true) {
|
||||
const candidate = path.join(current, "AGENTS.md");
|
||||
const content = await readFile(candidate);
|
||||
|
||||
@@ -49,11 +47,12 @@ async function loadAgentsMd(ctx: LoadContext): Promise<LoadResult<ContextFile>>
|
||||
}
|
||||
}
|
||||
|
||||
if (current === (ctx.repoRoot ?? ctx.home)) break; // scanned repo root or home, stop
|
||||
|
||||
// Move to parent directory
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) break; // Reached filesystem root
|
||||
current = parent;
|
||||
depth++;
|
||||
}
|
||||
|
||||
return { items, warnings };
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* Agents (standard) Provider
|
||||
*
|
||||
* Loads user-level skills, rules, prompts, commands, context files, and system prompts from ~/.agent/.
|
||||
* Loads skills, rules, prompts, commands, context files, and system prompts
|
||||
* from .agent/ and .agents/ directories at both user (~/) and project levels.
|
||||
* Project-level discovery walks up from cwd to repoRoot.
|
||||
*/
|
||||
import * as path from "node:path";
|
||||
import { registerProvider } from "../capability";
|
||||
@@ -13,76 +15,91 @@ 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 { LoadContext, LoadResult } from "../capability/types";
|
||||
import { buildRuleFromMarkdown, createSourceMeta, loadFilesFromDir, scanSkillsFromDir } from "./helpers";
|
||||
import { buildRuleFromMarkdown, calculateDepth, createSourceMeta, loadFilesFromDir, scanSkillsFromDir } from "./helpers";
|
||||
|
||||
const PROVIDER_ID = "agents";
|
||||
const DISPLAY_NAME = "Agents (standard)";
|
||||
const PRIORITY = 70;
|
||||
const USER_AGENT_DIR_CANDIDATES = [".agent", ".agents"] as const;
|
||||
const AGENT_DIR_CANDIDATES = [".agent", ".agents"] as const;
|
||||
|
||||
function getUserAgentPathCandidates(ctx: LoadContext, ...segments: string[]): string[] {
|
||||
return USER_AGENT_DIR_CANDIDATES.map(baseDir => path.join(ctx.home, baseDir, ...segments));
|
||||
/** User-level paths: ~/.agent/<segments> and ~/.agents/<segments>. */
|
||||
function getUserPathCandidates(ctx: LoadContext, ...segments: string[]): string[] {
|
||||
return AGENT_DIR_CANDIDATES.map(baseDir => path.join(ctx.home, baseDir, ...segments));
|
||||
}
|
||||
|
||||
async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
|
||||
const items: Skill[] = [];
|
||||
const warnings: string[] = [];
|
||||
for (const userSkillsDir of getUserAgentPathCandidates(ctx, "skills")) {
|
||||
const result = await scanSkillsFromDir(ctx, {
|
||||
dir: userSkillsDir,
|
||||
providerId: PROVIDER_ID,
|
||||
level: "user",
|
||||
});
|
||||
items.push(...result.items);
|
||||
warnings.push(...(result.warnings ?? []));
|
||||
/** Project-level paths: walk up from cwd to repoRoot, returning .agent/<segments> and .agents/<segments> at each level. */
|
||||
function getProjectPathCandidates(ctx: LoadContext, ...segments: string[]): string[] {
|
||||
const paths: string[] = [];
|
||||
let current = ctx.cwd;
|
||||
while (true) {
|
||||
for (const baseDir of AGENT_DIR_CANDIDATES) {
|
||||
paths.push(path.join(current, baseDir, ...segments));
|
||||
}
|
||||
if (current === (ctx.repoRoot ?? ctx.home)) break;
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) break;
|
||||
current = parent;
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
// Skills
|
||||
async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
|
||||
const projectScans = getProjectPathCandidates(ctx, "skills").map(dir =>
|
||||
scanSkillsFromDir(ctx, { dir, providerId: PROVIDER_ID, level: "project" }),
|
||||
);
|
||||
const userScans = getUserPathCandidates(ctx, "skills").map(dir =>
|
||||
scanSkillsFromDir(ctx, { dir, providerId: PROVIDER_ID, level: "user" }),
|
||||
);
|
||||
|
||||
const results = await Promise.all([...projectScans, ...userScans]);
|
||||
|
||||
return {
|
||||
items,
|
||||
warnings,
|
||||
items: results.flatMap(r => r.items),
|
||||
warnings: results.flatMap(r => r.warnings ?? []),
|
||||
};
|
||||
}
|
||||
|
||||
registerProvider<Skill>(skillCapability.id, {
|
||||
id: PROVIDER_ID,
|
||||
displayName: DISPLAY_NAME,
|
||||
description: "Load skills from ~/.agent/skills (fallback ~/.agents/skills)",
|
||||
description: "Load skills from .agent/skills and .agents/skills (project walk-up + user home)",
|
||||
priority: PRIORITY,
|
||||
load: loadSkills,
|
||||
});
|
||||
|
||||
// Rules
|
||||
async function loadRules(ctx: LoadContext): Promise<LoadResult<Rule>> {
|
||||
const items: Rule[] = [];
|
||||
const warnings: string[] = [];
|
||||
for (const userRulesDir of getUserAgentPathCandidates(ctx, "rules")) {
|
||||
const result = await loadFilesFromDir<Rule>(ctx, userRulesDir, PROVIDER_ID, "user", {
|
||||
const load = (dir: string, level: "user" | "project") =>
|
||||
loadFilesFromDir<Rule>(ctx, dir, PROVIDER_ID, level, {
|
||||
extensions: ["md", "mdc"],
|
||||
transform: (name, content, filePath, source) =>
|
||||
buildRuleFromMarkdown(name, content, filePath, source, { stripNamePattern: /\.(md|mdc)$/ }),
|
||||
});
|
||||
items.push(...result.items);
|
||||
warnings.push(...(result.warnings ?? []));
|
||||
}
|
||||
|
||||
const results = await Promise.all([
|
||||
...getProjectPathCandidates(ctx, "rules").map(dir => load(dir, "project")),
|
||||
...getUserPathCandidates(ctx, "rules").map(dir => load(dir, "user")),
|
||||
]);
|
||||
|
||||
return {
|
||||
items,
|
||||
warnings,
|
||||
items: results.flatMap(r => r.items),
|
||||
warnings: results.flatMap(r => r.warnings ?? []),
|
||||
};
|
||||
}
|
||||
|
||||
registerProvider<Rule>(ruleCapability.id, {
|
||||
id: PROVIDER_ID,
|
||||
displayName: DISPLAY_NAME,
|
||||
description: "Load rules from ~/.agent/rules (fallback ~/.agents/rules)",
|
||||
description: "Load rules from .agent/rules and .agents/rules (project walk-up + user home)",
|
||||
priority: PRIORITY,
|
||||
load: loadRules,
|
||||
});
|
||||
|
||||
// Prompts
|
||||
async function loadPrompts(ctx: LoadContext): Promise<LoadResult<Prompt>> {
|
||||
const items: Prompt[] = [];
|
||||
const warnings: string[] = [];
|
||||
for (const userPromptsDir of getUserAgentPathCandidates(ctx, "prompts")) {
|
||||
const result = await loadFilesFromDir<Prompt>(ctx, userPromptsDir, PROVIDER_ID, "user", {
|
||||
const load = (dir: string, level: "user" | "project") =>
|
||||
loadFilesFromDir<Prompt>(ctx, dir, PROVIDER_ID, level, {
|
||||
extensions: ["md"],
|
||||
transform: (name, content, filePath, source) => ({
|
||||
name: name.replace(/\.md$/, ""),
|
||||
@@ -91,109 +108,106 @@ async function loadPrompts(ctx: LoadContext): Promise<LoadResult<Prompt>> {
|
||||
_source: source,
|
||||
}),
|
||||
});
|
||||
items.push(...result.items);
|
||||
warnings.push(...(result.warnings ?? []));
|
||||
}
|
||||
|
||||
const results = await Promise.all([
|
||||
...getProjectPathCandidates(ctx, "prompts").map(dir => load(dir, "project")),
|
||||
...getUserPathCandidates(ctx, "prompts").map(dir => load(dir, "user")),
|
||||
]);
|
||||
|
||||
return {
|
||||
items,
|
||||
warnings,
|
||||
items: results.flatMap(r => r.items),
|
||||
warnings: results.flatMap(r => r.warnings ?? []),
|
||||
};
|
||||
}
|
||||
|
||||
registerProvider<Prompt>(promptCapability.id, {
|
||||
id: PROVIDER_ID,
|
||||
displayName: DISPLAY_NAME,
|
||||
description: "Load prompts from ~/.agent/prompts (fallback ~/.agents/prompts)",
|
||||
description: "Load prompts from .agent/prompts and .agents/prompts (project walk-up + user home)",
|
||||
priority: PRIORITY,
|
||||
load: loadPrompts,
|
||||
});
|
||||
|
||||
// Slash Commands
|
||||
async function loadSlashCommands(ctx: LoadContext): Promise<LoadResult<SlashCommand>> {
|
||||
const items: SlashCommand[] = [];
|
||||
const warnings: string[] = [];
|
||||
for (const userCommandsDir of getUserAgentPathCandidates(ctx, "commands")) {
|
||||
const result = await loadFilesFromDir<SlashCommand>(ctx, userCommandsDir, PROVIDER_ID, "user", {
|
||||
const load = (dir: string, level: "user" | "project") =>
|
||||
loadFilesFromDir<SlashCommand>(ctx, dir, PROVIDER_ID, level, {
|
||||
extensions: ["md"],
|
||||
transform: (name, content, filePath, source) => ({
|
||||
name: name.replace(/\.md$/, ""),
|
||||
path: filePath,
|
||||
content,
|
||||
level: "user",
|
||||
level,
|
||||
_source: source,
|
||||
}),
|
||||
});
|
||||
items.push(...result.items);
|
||||
warnings.push(...(result.warnings ?? []));
|
||||
}
|
||||
|
||||
const results = await Promise.all([
|
||||
...getProjectPathCandidates(ctx, "commands").map(dir => load(dir, "project")),
|
||||
...getUserPathCandidates(ctx, "commands").map(dir => load(dir, "user")),
|
||||
]);
|
||||
|
||||
return {
|
||||
items,
|
||||
warnings,
|
||||
items: results.flatMap(r => r.items),
|
||||
warnings: results.flatMap(r => r.warnings ?? []),
|
||||
};
|
||||
}
|
||||
|
||||
registerProvider<SlashCommand>(slashCommandCapability.id, {
|
||||
id: PROVIDER_ID,
|
||||
displayName: DISPLAY_NAME,
|
||||
description: "Load commands from ~/.agent/commands (fallback ~/.agents/commands)",
|
||||
description: "Load commands from .agent/commands and .agents/commands (project walk-up + user home)",
|
||||
priority: PRIORITY,
|
||||
load: loadSlashCommands,
|
||||
});
|
||||
|
||||
// Context Files (AGENTS.md)
|
||||
async function loadContextFiles(ctx: LoadContext): Promise<LoadResult<ContextFile>> {
|
||||
const items: ContextFile[] = [];
|
||||
for (const agentsPath of getUserAgentPathCandidates(ctx, "AGENTS.md")) {
|
||||
const content = await readFile(agentsPath);
|
||||
if (!content) {
|
||||
continue;
|
||||
}
|
||||
items.push({
|
||||
path: agentsPath,
|
||||
content,
|
||||
level: "user",
|
||||
_source: createSourceMeta(PROVIDER_ID, agentsPath, "user"),
|
||||
});
|
||||
}
|
||||
return {
|
||||
items,
|
||||
warnings: [],
|
||||
const load = async (filePath: string, level: "user" | "project"): Promise<ContextFile | null> => {
|
||||
const content = await readFile(filePath);
|
||||
if (!content) return null;
|
||||
// filePath is <ancestor>/.agent(s)/AGENTS.md — go up past the config dir to the ancestor
|
||||
const ancestorDir = path.dirname(path.dirname(filePath));
|
||||
const depth = level === "project" ? calculateDepth(ctx.cwd, ancestorDir, path.sep) : undefined;
|
||||
return { path: filePath, content, level, depth, _source: createSourceMeta(PROVIDER_ID, filePath, level) };
|
||||
};
|
||||
|
||||
const results = await Promise.all([
|
||||
...getProjectPathCandidates(ctx, "AGENTS.md").map(p => load(p, "project")),
|
||||
...getUserPathCandidates(ctx, "AGENTS.md").map(p => load(p, "user")),
|
||||
]);
|
||||
|
||||
return { items: results.filter((r): r is ContextFile => r !== null), warnings: [] };
|
||||
}
|
||||
|
||||
registerProvider<ContextFile>(contextFileCapability.id, {
|
||||
id: PROVIDER_ID,
|
||||
displayName: DISPLAY_NAME,
|
||||
description: "Load AGENTS.md from ~/.agent (fallback ~/.agents)",
|
||||
description: "Load AGENTS.md from .agent and .agents (project walk-up + user home)",
|
||||
priority: PRIORITY,
|
||||
load: loadContextFiles,
|
||||
});
|
||||
|
||||
// System Prompt (SYSTEM.md)
|
||||
async function loadSystemPrompt(ctx: LoadContext): Promise<LoadResult<SystemPrompt>> {
|
||||
const items: SystemPrompt[] = [];
|
||||
for (const systemPath of getUserAgentPathCandidates(ctx, "SYSTEM.md")) {
|
||||
const content = await readFile(systemPath);
|
||||
if (!content) {
|
||||
continue;
|
||||
}
|
||||
items.push({
|
||||
path: systemPath,
|
||||
content,
|
||||
level: "user",
|
||||
_source: createSourceMeta(PROVIDER_ID, systemPath, "user"),
|
||||
});
|
||||
}
|
||||
return {
|
||||
items,
|
||||
warnings: [],
|
||||
const load = async (filePath: string, level: "user" | "project"): Promise<SystemPrompt | null> => {
|
||||
const content = await readFile(filePath);
|
||||
if (!content) return null;
|
||||
return { path: filePath, content, level, _source: createSourceMeta(PROVIDER_ID, filePath, level) };
|
||||
};
|
||||
|
||||
const results = await Promise.all([
|
||||
...getProjectPathCandidates(ctx, "SYSTEM.md").map(p => load(p, "project")),
|
||||
...getUserPathCandidates(ctx, "SYSTEM.md").map(p => load(p, "user")),
|
||||
]);
|
||||
|
||||
return { items: results.filter((r): r is SystemPrompt => r !== null), warnings: [] };
|
||||
}
|
||||
|
||||
registerProvider<SystemPrompt>(systemPromptCapability.id, {
|
||||
id: PROVIDER_ID,
|
||||
displayName: DISPLAY_NAME,
|
||||
description: "Load SYSTEM.md from ~/.agent (fallback ~/.agents)",
|
||||
description: "Load SYSTEM.md from .agent and .agents (project walk-up + user home)",
|
||||
priority: PRIORITY,
|
||||
load: loadSystemPrompt,
|
||||
});
|
||||
|
||||
@@ -68,12 +68,13 @@ async function getConfigDirs(ctx: LoadContext): Promise<Array<{ dir: string; lev
|
||||
return result;
|
||||
}
|
||||
|
||||
function getAncestorDirs(cwd: string): Array<{ dir: string; depth: number }> {
|
||||
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;
|
||||
@@ -82,8 +83,11 @@ function getAncestorDirs(cwd: string): Array<{ dir: string; depth: number }> {
|
||||
return ancestors;
|
||||
}
|
||||
|
||||
async function findNearestProjectConfigDir(cwd: string): Promise<{ dir: string; depth: number } | null> {
|
||||
for (const ancestor of getAncestorDirs(cwd)) {
|
||||
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 };
|
||||
}
|
||||
@@ -215,7 +219,7 @@ async function loadSystemPrompt(ctx: LoadContext): Promise<LoadResult<SystemProm
|
||||
});
|
||||
}
|
||||
|
||||
const nearestProjectConfigDir = await findNearestProjectConfigDir(ctx.cwd);
|
||||
const nearestProjectConfigDir = await findNearestProjectConfigDir(ctx.cwd, ctx.repoRoot);
|
||||
if (nearestProjectConfigDir) {
|
||||
const projectPath = path.join(nearestProjectConfigDir.dir, "SYSTEM.md");
|
||||
const projectContent = await readFile(projectPath);
|
||||
@@ -242,18 +246,27 @@ registerProvider<SystemPrompt>(systemPromptCapability.id, {
|
||||
|
||||
// Skills
|
||||
async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
|
||||
const configDirs = await getConfigDirs(ctx);
|
||||
const results = await Promise.all(
|
||||
configDirs.map(({ dir, level }) =>
|
||||
scanSkillsFromDir(ctx, {
|
||||
dir: path.join(dir, "skills"),
|
||||
providerId: PROVIDER_ID,
|
||||
level,
|
||||
requireDescription: true,
|
||||
}),
|
||||
),
|
||||
// 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 ?? []),
|
||||
@@ -795,7 +808,7 @@ async function loadContextFiles(ctx: LoadContext): Promise<LoadResult<ContextFil
|
||||
});
|
||||
}
|
||||
|
||||
const nearestProjectConfigDir = await findNearestProjectConfigDir(ctx.cwd);
|
||||
const nearestProjectConfigDir = await findNearestProjectConfigDir(ctx.cwd, ctx.repoRoot);
|
||||
if (nearestProjectConfigDir) {
|
||||
const projectPath = path.join(nearestProjectConfigDir.dir, "AGENTS.md");
|
||||
const projectContent = await readFile(projectPath);
|
||||
|
||||
@@ -145,7 +145,7 @@ async function loadContextFiles(ctx: LoadContext): Promise<LoadResult<ContextFil
|
||||
const projectClaudeMd = path.join(projectBase, "CLAUDE.md");
|
||||
const projectContent = await readFile(projectClaudeMd);
|
||||
if (projectContent !== null) {
|
||||
const depth = calculateDepth(ctx.cwd, projectBase, path.sep);
|
||||
const depth = calculateDepth(ctx.cwd, path.dirname(projectBase), path.sep);
|
||||
items.push({
|
||||
path: projectClaudeMd,
|
||||
content: projectContent,
|
||||
@@ -164,11 +164,27 @@ async function loadContextFiles(ctx: LoadContext): Promise<LoadResult<ContextFil
|
||||
|
||||
async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
|
||||
const userSkillsDir = path.join(getUserClaude(ctx), "skills");
|
||||
const projectSkillsDir = path.join(getProjectClaude(ctx), "skills");
|
||||
|
||||
const [userResult, projectResult] = await Promise.allSettled([
|
||||
// Walk up from cwd finding .claude/skills/ in ancestors
|
||||
const projectScans: Promise<LoadResult<Skill>>[] = [];
|
||||
let current = ctx.cwd;
|
||||
while (true) {
|
||||
projectScans.push(
|
||||
scanSkillsFromDir(ctx, {
|
||||
dir: path.join(current, CONFIG_DIR, "skills"),
|
||||
providerId: PROVIDER_ID,
|
||||
level: "project",
|
||||
}),
|
||||
);
|
||||
if (current === (ctx.repoRoot ?? ctx.home)) break;
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) break; // filesystem root
|
||||
current = parent;
|
||||
}
|
||||
|
||||
const [userResult, ...projectResults] = await Promise.allSettled([
|
||||
scanSkillsFromDir(ctx, { dir: userSkillsDir, providerId: PROVIDER_ID, level: "user" }),
|
||||
scanSkillsFromDir(ctx, { dir: projectSkillsDir, providerId: PROVIDER_ID, level: "project" }),
|
||||
...projectScans,
|
||||
]);
|
||||
|
||||
const items: Skill[] = [];
|
||||
@@ -181,11 +197,13 @@ async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
|
||||
warnings.push(`Failed to scan Claude user skills in ${userSkillsDir}: ${String(userResult.reason)}`);
|
||||
}
|
||||
|
||||
if (projectResult.status === "fulfilled") {
|
||||
items.push(...projectResult.value.items);
|
||||
warnings.push(...(projectResult.value.warnings ?? []));
|
||||
} else if (!isMissingDirectoryError(projectResult.reason)) {
|
||||
warnings.push(`Failed to scan Claude project skills in ${projectSkillsDir}: ${String(projectResult.reason)}`);
|
||||
for (const projectResult of projectResults) {
|
||||
if (projectResult.status === "fulfilled") {
|
||||
items.push(...projectResult.value.items);
|
||||
warnings.push(...(projectResult.value.warnings ?? []));
|
||||
} else if (!isMissingDirectoryError(projectResult.reason)) {
|
||||
warnings.push(`Failed to scan Claude project skills: ${String(projectResult.reason)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { items, warnings };
|
||||
|
||||
@@ -209,26 +209,33 @@ function extractMCPServersFromToml(toml: Record<string, unknown>): Record<string
|
||||
|
||||
async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
|
||||
const userSkillsDir = path.join(ctx.home, SOURCE_PATHS.codex.userBase, "skills");
|
||||
const codexDir = getProjectCodexDir(ctx);
|
||||
const projectSkillsDir = path.join(codexDir, "skills");
|
||||
|
||||
// Walk up from cwd finding .codex/skills/ in ancestors
|
||||
const projectScans: Promise<LoadResult<Skill>>[] = [];
|
||||
let current = ctx.cwd;
|
||||
while (true) {
|
||||
projectScans.push(
|
||||
scanSkillsFromDir(ctx, {
|
||||
dir: path.join(current, ".codex", "skills"),
|
||||
providerId: PROVIDER_ID,
|
||||
level: "project",
|
||||
}),
|
||||
);
|
||||
if (current === (ctx.repoRoot ?? ctx.home)) break;
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) break; // filesystem root
|
||||
current = parent;
|
||||
}
|
||||
|
||||
const results = await Promise.all([
|
||||
scanSkillsFromDir(ctx, {
|
||||
dir: userSkillsDir,
|
||||
providerId: PROVIDER_ID,
|
||||
level: "user",
|
||||
}),
|
||||
scanSkillsFromDir(ctx, {
|
||||
dir: projectSkillsDir,
|
||||
providerId: PROVIDER_ID,
|
||||
level: "project",
|
||||
}),
|
||||
scanSkillsFromDir(ctx, { dir: userSkillsDir, providerId: PROVIDER_ID, level: "user" }),
|
||||
...projectScans,
|
||||
]);
|
||||
|
||||
const items = results.flatMap(r => r.items);
|
||||
const warnings = results.flatMap(r => r.warnings || []);
|
||||
|
||||
return { items, warnings };
|
||||
return {
|
||||
items: results.flatMap(r => r.items),
|
||||
warnings: results.flatMap(r => r.warnings || []),
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -144,8 +144,7 @@ async function loadContextFiles(ctx: LoadContext): Promise<LoadResult<ContextFil
|
||||
if (projectGeminiMd) {
|
||||
const content = await readFile(projectGeminiMd);
|
||||
if (content) {
|
||||
const projectBase = getProjectPath(ctx, "gemini", "");
|
||||
const depth = projectBase ? calculateDepth(ctx.cwd, projectBase, path.sep) : 0;
|
||||
const depth = projectBase ? calculateDepth(ctx.cwd, path.dirname(projectBase), path.sep) : 0;
|
||||
|
||||
items.push({
|
||||
path: projectGeminiMd,
|
||||
|
||||
@@ -36,8 +36,8 @@ async function loadContextFiles(ctx: LoadContext): Promise<LoadResult<ContextFil
|
||||
if (copilotInstructionsPath) {
|
||||
const content = await readFile(copilotInstructionsPath);
|
||||
if (content) {
|
||||
const fileDir = path.dirname(copilotInstructionsPath);
|
||||
const depth = calculateDepth(ctx.cwd, fileDir, path.sep);
|
||||
const ancestorDir = path.dirname(fileDir);
|
||||
const depth = calculateDepth(ctx.cwd, ancestorDir, path.sep);
|
||||
|
||||
items.push({
|
||||
path: copilotInstructionsPath,
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
getProjectPath,
|
||||
getUserPath,
|
||||
loadFilesFromDir,
|
||||
SOURCE_PATHS,
|
||||
scanSkillsFromDir,
|
||||
} from "./helpers";
|
||||
|
||||
@@ -183,7 +184,6 @@ function extractMCPServers(
|
||||
|
||||
async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
|
||||
const userSkillsDir = getUserPath(ctx, "opencode", "skills");
|
||||
const projectSkillsDir = getProjectPath(ctx, "opencode", "skills");
|
||||
|
||||
const promises: Promise<LoadResult<Skill>>[] = [];
|
||||
|
||||
@@ -197,14 +197,25 @@ async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
|
||||
);
|
||||
}
|
||||
|
||||
if (projectSkillsDir) {
|
||||
promises.push(
|
||||
scanSkillsFromDir(ctx, {
|
||||
dir: projectSkillsDir,
|
||||
providerId: PROVIDER_ID,
|
||||
level: "project",
|
||||
}),
|
||||
);
|
||||
// Walk up from cwd finding .opencode/skills/ in ancestors
|
||||
const projectDir = SOURCE_PATHS.opencode.projectDir;
|
||||
if (projectDir) {
|
||||
const projectScans: Promise<LoadResult<Skill>>[] = [];
|
||||
let current = ctx.cwd;
|
||||
while (true) {
|
||||
projectScans.push(
|
||||
scanSkillsFromDir(ctx, {
|
||||
dir: path.join(current, projectDir, "skills"),
|
||||
providerId: PROVIDER_ID,
|
||||
level: "project",
|
||||
}),
|
||||
);
|
||||
if (current === (ctx.repoRoot ?? ctx.home)) break;
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) break; // filesystem root
|
||||
current = parent;
|
||||
}
|
||||
promises.push(...projectScans);
|
||||
}
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
@@ -40,7 +40,7 @@ export async function loadSkillsFromDir(options: LoadSkillsFromDirOptions): Prom
|
||||
const providerId = rawProviderId || "custom";
|
||||
const level: "user" | "project" = rawLevel === "project" ? "project" : "user";
|
||||
const result = await scanSkillsFromDir(
|
||||
{ cwd: getProjectDir(), home: os.homedir() },
|
||||
{ cwd: getProjectDir(), home: os.homedir(), repoRoot: null },
|
||||
{
|
||||
dir: options.dir,
|
||||
providerId,
|
||||
@@ -175,7 +175,7 @@ export async function loadSkills(options: LoadSkillsOptions = {}): Promise<LoadS
|
||||
customDirectories.map(async dir => {
|
||||
const expandedDir = expandTilde(dir);
|
||||
const scanResult = await scanSkillsFromDir(
|
||||
{ cwd, home: os.homedir() },
|
||||
{ cwd, home: os.homedir(), repoRoot: null },
|
||||
{
|
||||
dir: expandedDir,
|
||||
providerId: "custom",
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
/**
|
||||
* Tests that the agents provider walks up from cwd to find capabilities in ancestor
|
||||
* .agent/ and .agents/ directories (project-level discovery).
|
||||
*
|
||||
* Instead of testing the full provider flow (which requires the entire capability registry),
|
||||
* this test verifies the building blocks (scanSkillsFromDir, loadFilesFromDir, readFile)
|
||||
* with the same walk-up pattern used by the agents provider.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { clearCache, readFile } from "@oh-my-pi/pi-coding-agent/capability/fs";
|
||||
import type { Skill } from "@oh-my-pi/pi-coding-agent/capability/skill";
|
||||
import type { LoadContext, LoadResult } from "@oh-my-pi/pi-coding-agent/capability/types";
|
||||
import {
|
||||
buildRuleFromMarkdown,
|
||||
calculateDepth,
|
||||
createSourceMeta,
|
||||
loadFilesFromDir,
|
||||
scanSkillsFromDir,
|
||||
} from "@oh-my-pi/pi-coding-agent/discovery/helpers";
|
||||
import type { Rule } from "@oh-my-pi/pi-coding-agent/capability/rule";
|
||||
|
||||
const AGENT_DIR_CANDIDATES = [".agent", ".agents"] as const;
|
||||
const PROVIDER_ID = "agents";
|
||||
|
||||
function getProjectPathCandidates(ctx: LoadContext, ...segments: string[]): string[] {
|
||||
const paths: string[] = [];
|
||||
let current = ctx.cwd;
|
||||
while (true) {
|
||||
for (const baseDir of AGENT_DIR_CANDIDATES) {
|
||||
paths.push(path.join(current, baseDir, ...segments));
|
||||
}
|
||||
if (current === (ctx.repoRoot ?? ctx.home)) break;
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) break;
|
||||
current = parent;
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
function writeSkill(dir: string, name: string, description: string): void {
|
||||
const skillDir = path.join(dir, name);
|
||||
fs.mkdirSync(skillDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(skillDir, "SKILL.md"),
|
||||
`---\nname: ${name}\ndescription: ${description}\n---\n\n# ${name}\n\nSkill content.\n`,
|
||||
);
|
||||
}
|
||||
|
||||
function writeFile(filePath: string, content: string): void {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, content);
|
||||
}
|
||||
|
||||
describe("agents provider project-level discovery", () => {
|
||||
let tempDir!: string;
|
||||
let repoRoot!: string;
|
||||
let subProject!: string;
|
||||
let ctx!: LoadContext;
|
||||
|
||||
beforeEach(() => {
|
||||
clearCache();
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-agents-monorepo-"));
|
||||
repoRoot = path.join(tempDir, "repo");
|
||||
subProject = path.join(repoRoot, "packages", "my-app");
|
||||
fs.mkdirSync(subProject, { recursive: true });
|
||||
fs.mkdirSync(path.join(repoRoot, ".git"), { recursive: true });
|
||||
ctx = { cwd: subProject, home: tempDir, repoRoot };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearCache();
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Skills
|
||||
// =========================================================================
|
||||
|
||||
describe("skills", () => {
|
||||
test("finds .agents/skills in monorepo root from sub-project cwd", async () => {
|
||||
writeSkill(path.join(repoRoot, ".agents", "skills"), "root-skill", "From repo root");
|
||||
|
||||
const results = await Promise.all(
|
||||
getProjectPathCandidates(ctx, "skills").map(dir =>
|
||||
scanSkillsFromDir(ctx, { dir, providerId: PROVIDER_ID, level: "project" }),
|
||||
),
|
||||
);
|
||||
const names = results.flatMap(r => r.items).map(s => s.name);
|
||||
expect(names).toContain("root-skill");
|
||||
});
|
||||
|
||||
test("finds .agent/skills in monorepo root from sub-project cwd", async () => {
|
||||
writeSkill(path.join(repoRoot, ".agent", "skills"), "root-skill", "From repo root");
|
||||
|
||||
const results = await Promise.all(
|
||||
getProjectPathCandidates(ctx, "skills").map(dir =>
|
||||
scanSkillsFromDir(ctx, { dir, providerId: PROVIDER_ID, level: "project" }),
|
||||
),
|
||||
);
|
||||
const names = results.flatMap(r => r.items).map(s => s.name);
|
||||
expect(names).toContain("root-skill");
|
||||
});
|
||||
|
||||
test("finds skills at both sub-project and repo root, closest first", async () => {
|
||||
writeSkill(path.join(subProject, ".agents", "skills"), "local-skill", "From sub-project");
|
||||
writeSkill(path.join(repoRoot, ".agents", "skills"), "root-skill", "From repo root");
|
||||
|
||||
const results = await Promise.all(
|
||||
getProjectPathCandidates(ctx, "skills").map(dir =>
|
||||
scanSkillsFromDir(ctx, { dir, providerId: PROVIDER_ID, level: "project" }),
|
||||
),
|
||||
);
|
||||
const names = results.flatMap(r => r.items).map(s => s.name);
|
||||
expect(names).toContain("local-skill");
|
||||
expect(names).toContain("root-skill");
|
||||
expect(names.indexOf("local-skill")).toBeLessThan(names.indexOf("root-skill"));
|
||||
});
|
||||
|
||||
test("discovers skills from both .agent and .agents at same level", async () => {
|
||||
writeSkill(path.join(repoRoot, ".agent", "skills"), "agent-skill", "From .agent");
|
||||
writeSkill(path.join(repoRoot, ".agents", "skills"), "agents-skill", "From .agents");
|
||||
|
||||
const results = await Promise.all(
|
||||
getProjectPathCandidates(ctx, "skills").map(dir =>
|
||||
scanSkillsFromDir(ctx, { dir, providerId: PROVIDER_ID, level: "project" }),
|
||||
),
|
||||
);
|
||||
const names = results.flatMap(r => r.items).map(s => s.name);
|
||||
expect(names).toContain("agent-skill");
|
||||
expect(names).toContain("agents-skill");
|
||||
});
|
||||
|
||||
test("walk-up stops at repo root", async () => {
|
||||
writeSkill(path.join(tempDir, ".agents", "skills"), "above-repo-skill", "Above repo");
|
||||
writeSkill(path.join(repoRoot, ".agents", "skills"), "root-skill", "At repo root");
|
||||
|
||||
const results = await Promise.all(
|
||||
getProjectPathCandidates(ctx, "skills").map(dir =>
|
||||
scanSkillsFromDir(ctx, { dir, providerId: PROVIDER_ID, level: "project" }),
|
||||
),
|
||||
);
|
||||
const names = results.flatMap(r => r.items).map(s => s.name);
|
||||
expect(names).toContain("root-skill");
|
||||
expect(names).not.toContain("above-repo-skill");
|
||||
});
|
||||
|
||||
test("walk-up stops at home when no repo root", async () => {
|
||||
// ctx without repoRoot
|
||||
const noRepoCtx: LoadContext = { cwd: subProject, home: repoRoot, repoRoot: null };
|
||||
// Skill above home (should NOT be found)
|
||||
writeSkill(path.join(tempDir, ".agents", "skills"), "above-home-skill", "Above home");
|
||||
// Skill at home (should be found)
|
||||
writeSkill(path.join(repoRoot, ".agents", "skills"), "home-skill", "At home");
|
||||
|
||||
const results = await Promise.all(
|
||||
getProjectPathCandidates(noRepoCtx, "skills").map(dir =>
|
||||
scanSkillsFromDir(noRepoCtx, { dir, providerId: PROVIDER_ID, level: "project" }),
|
||||
),
|
||||
);
|
||||
const names = results.flatMap(r => r.items).map(s => s.name);
|
||||
expect(names).toContain("home-skill");
|
||||
expect(names).not.toContain("above-home-skill");
|
||||
});
|
||||
|
||||
test("returns empty when no ancestor has skills", async () => {
|
||||
const results = await Promise.all(
|
||||
getProjectPathCandidates(ctx, "skills").map(dir =>
|
||||
scanSkillsFromDir(ctx, { dir, providerId: PROVIDER_ID, level: "project" }),
|
||||
),
|
||||
);
|
||||
expect(results.flatMap(r => r.items)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Rules
|
||||
// =========================================================================
|
||||
|
||||
describe("rules", () => {
|
||||
test("finds .agents/rules in monorepo root from sub-project cwd", async () => {
|
||||
writeFile(path.join(repoRoot, ".agents", "rules", "my-rule.md"), "# My Rule\n\nDo the thing.");
|
||||
|
||||
const results = await Promise.all(
|
||||
getProjectPathCandidates(ctx, "rules").map(dir =>
|
||||
loadFilesFromDir<Rule>(ctx, dir, PROVIDER_ID, "project", {
|
||||
extensions: ["md", "mdc"],
|
||||
transform: (name, content, filePath, source) =>
|
||||
buildRuleFromMarkdown(name, content, filePath, source, {
|
||||
stripNamePattern: /\.(md|mdc)$/,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
const names = results.flatMap(r => r.items).map(r => r.name);
|
||||
expect(names).toContain("my-rule");
|
||||
});
|
||||
|
||||
test("finds rules at both sub-project and repo root, closest first", async () => {
|
||||
writeFile(path.join(subProject, ".agents", "rules", "local-rule.md"), "# Local\n\nLocal rule.");
|
||||
writeFile(path.join(repoRoot, ".agents", "rules", "root-rule.md"), "# Root\n\nRoot rule.");
|
||||
|
||||
const results = await Promise.all(
|
||||
getProjectPathCandidates(ctx, "rules").map(dir =>
|
||||
loadFilesFromDir<Rule>(ctx, dir, PROVIDER_ID, "project", {
|
||||
extensions: ["md", "mdc"],
|
||||
transform: (name, content, filePath, source) =>
|
||||
buildRuleFromMarkdown(name, content, filePath, source, {
|
||||
stripNamePattern: /\.(md|mdc)$/,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
const names = results.flatMap(r => r.items).map(r => r.name);
|
||||
expect(names).toContain("local-rule");
|
||||
expect(names).toContain("root-rule");
|
||||
expect(names.indexOf("local-rule")).toBeLessThan(names.indexOf("root-rule"));
|
||||
});
|
||||
|
||||
test("walk-up stops at repo root", async () => {
|
||||
writeFile(path.join(tempDir, ".agents", "rules", "above-rule.md"), "# Above\n\nAbove rule.");
|
||||
writeFile(path.join(repoRoot, ".agents", "rules", "root-rule.md"), "# Root\n\nRoot rule.");
|
||||
|
||||
const results = await Promise.all(
|
||||
getProjectPathCandidates(ctx, "rules").map(dir =>
|
||||
loadFilesFromDir<Rule>(ctx, dir, PROVIDER_ID, "project", {
|
||||
extensions: ["md", "mdc"],
|
||||
transform: (name, content, filePath, source) =>
|
||||
buildRuleFromMarkdown(name, content, filePath, source, {
|
||||
stripNamePattern: /\.(md|mdc)$/,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
const names = results.flatMap(r => r.items).map(r => r.name);
|
||||
expect(names).toContain("root-rule");
|
||||
expect(names).not.toContain("above-rule");
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Prompts
|
||||
// =========================================================================
|
||||
|
||||
describe("prompts", () => {
|
||||
test("finds .agents/prompts in monorepo root from sub-project cwd", async () => {
|
||||
writeFile(path.join(repoRoot, ".agents", "prompts", "my-prompt.md"), "You are a helpful assistant.");
|
||||
|
||||
const results = await Promise.all(
|
||||
getProjectPathCandidates(ctx, "prompts").map(dir =>
|
||||
loadFilesFromDir(ctx, dir, PROVIDER_ID, "project", {
|
||||
extensions: ["md"],
|
||||
transform: (name, content, filePath, source) => ({
|
||||
name: name.replace(/\.md$/, ""),
|
||||
path: filePath,
|
||||
content,
|
||||
_source: source,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
const names = results.flatMap(r => r.items).map(p => p.name);
|
||||
expect(names).toContain("my-prompt");
|
||||
});
|
||||
|
||||
test("finds prompts at both sub-project and repo root, closest first", async () => {
|
||||
writeFile(path.join(subProject, ".agents", "prompts", "local.md"), "Local prompt.");
|
||||
writeFile(path.join(repoRoot, ".agents", "prompts", "root.md"), "Root prompt.");
|
||||
|
||||
const results = await Promise.all(
|
||||
getProjectPathCandidates(ctx, "prompts").map(dir =>
|
||||
loadFilesFromDir(ctx, dir, PROVIDER_ID, "project", {
|
||||
extensions: ["md"],
|
||||
transform: (name, content, filePath, source) => ({
|
||||
name: name.replace(/\.md$/, ""),
|
||||
path: filePath,
|
||||
content,
|
||||
_source: source,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
const names = results.flatMap(r => r.items).map(p => p.name);
|
||||
expect(names).toContain("local");
|
||||
expect(names).toContain("root");
|
||||
expect(names.indexOf("local")).toBeLessThan(names.indexOf("root"));
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Commands
|
||||
// =========================================================================
|
||||
|
||||
describe("commands", () => {
|
||||
test("finds .agents/commands in monorepo root from sub-project cwd", async () => {
|
||||
writeFile(path.join(repoRoot, ".agents", "commands", "deploy.md"), "Run the deploy pipeline.");
|
||||
|
||||
const results = await Promise.all(
|
||||
getProjectPathCandidates(ctx, "commands").map(dir =>
|
||||
loadFilesFromDir(ctx, dir, PROVIDER_ID, "project", {
|
||||
extensions: ["md"],
|
||||
transform: (name, content, filePath, source) => ({
|
||||
name: name.replace(/\.md$/, ""),
|
||||
path: filePath,
|
||||
content,
|
||||
level: "project" as const,
|
||||
_source: source,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
const names = results.flatMap(r => r.items).map(c => c.name);
|
||||
expect(names).toContain("deploy");
|
||||
});
|
||||
|
||||
test("finds commands at both sub-project and repo root, closest first", async () => {
|
||||
writeFile(path.join(subProject, ".agents", "commands", "local-cmd.md"), "Local command.");
|
||||
writeFile(path.join(repoRoot, ".agents", "commands", "root-cmd.md"), "Root command.");
|
||||
|
||||
const results = await Promise.all(
|
||||
getProjectPathCandidates(ctx, "commands").map(dir =>
|
||||
loadFilesFromDir(ctx, dir, PROVIDER_ID, "project", {
|
||||
extensions: ["md"],
|
||||
transform: (name, content, filePath, source) => ({
|
||||
name: name.replace(/\.md$/, ""),
|
||||
path: filePath,
|
||||
content,
|
||||
level: "project" as const,
|
||||
_source: source,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
const names = results.flatMap(r => r.items).map(c => c.name);
|
||||
expect(names).toContain("local-cmd");
|
||||
expect(names).toContain("root-cmd");
|
||||
expect(names.indexOf("local-cmd")).toBeLessThan(names.indexOf("root-cmd"));
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Context Files (AGENTS.md)
|
||||
// =========================================================================
|
||||
|
||||
describe("context files (AGENTS.md)", () => {
|
||||
test("finds .agents/AGENTS.md in monorepo root from sub-project cwd", async () => {
|
||||
writeFile(path.join(repoRoot, ".agents", "AGENTS.md"), "# Project Rules\n\nFollow these rules.");
|
||||
|
||||
const paths = getProjectPathCandidates(ctx, "AGENTS.md");
|
||||
const results = await Promise.all(paths.map(p => readFile(p)));
|
||||
const found = results.filter(r => r !== null);
|
||||
expect(found).toHaveLength(1);
|
||||
expect(found[0]).toContain("Project Rules");
|
||||
});
|
||||
|
||||
test("finds AGENTS.md at both sub-project and repo root", async () => {
|
||||
writeFile(path.join(subProject, ".agents", "AGENTS.md"), "# Local Rules");
|
||||
writeFile(path.join(repoRoot, ".agents", "AGENTS.md"), "# Root Rules");
|
||||
|
||||
const paths = getProjectPathCandidates(ctx, "AGENTS.md");
|
||||
const results = await Promise.all(paths.map(p => readFile(p)));
|
||||
const found = results.filter(r => r !== null);
|
||||
expect(found).toHaveLength(2);
|
||||
// Closest first (sub-project before root)
|
||||
expect(found[0]).toContain("Local Rules");
|
||||
expect(found[1]).toContain("Root Rules");
|
||||
});
|
||||
|
||||
test("walk-up stops at repo root", async () => {
|
||||
writeFile(path.join(tempDir, ".agents", "AGENTS.md"), "# Above Repo");
|
||||
writeFile(path.join(repoRoot, ".agents", "AGENTS.md"), "# Root Rules");
|
||||
|
||||
const paths = getProjectPathCandidates(ctx, "AGENTS.md");
|
||||
const results = await Promise.all(paths.map(p => readFile(p)));
|
||||
const found = results.filter(r => r !== null);
|
||||
expect(found).toHaveLength(1);
|
||||
expect(found[0]).toContain("Root Rules");
|
||||
});
|
||||
|
||||
test("multi-level context files get distinct depth values for dedup", async () => {
|
||||
writeFile(path.join(subProject, ".agents", "AGENTS.md"), "# Local Rules");
|
||||
writeFile(path.join(repoRoot, ".agents", "AGENTS.md"), "# Root Rules");
|
||||
|
||||
const paths = getProjectPathCandidates(ctx, "AGENTS.md");
|
||||
const items: Array<{ content: string; depth: number }> = [];
|
||||
for (const p of paths) {
|
||||
const content = await readFile(p);
|
||||
if (!content) continue;
|
||||
const ancestorDir = path.dirname(path.dirname(p));
|
||||
const depth = calculateDepth(ctx.cwd, ancestorDir, path.sep);
|
||||
items.push({ content, depth });
|
||||
}
|
||||
|
||||
expect(items).toHaveLength(2);
|
||||
// Depths must differ so dedup keys are distinct
|
||||
expect(items[0]!.depth).not.toBe(items[1]!.depth);
|
||||
// Local (depth 0) before root (positive depth)
|
||||
expect(items[0]!.depth).toBe(0);
|
||||
expect(items[0]!.content).toContain("Local Rules");
|
||||
expect(items[1]!.depth).toBeGreaterThan(0);
|
||||
expect(items[1]!.content).toContain("Root Rules");
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// System Prompt (SYSTEM.md)
|
||||
// =========================================================================
|
||||
|
||||
describe("system prompt (SYSTEM.md)", () => {
|
||||
test("finds .agents/SYSTEM.md in monorepo root from sub-project cwd", async () => {
|
||||
writeFile(path.join(repoRoot, ".agents", "SYSTEM.md"), "You are a coding assistant.");
|
||||
|
||||
const paths = getProjectPathCandidates(ctx, "SYSTEM.md");
|
||||
const results = await Promise.all(paths.map(p => readFile(p)));
|
||||
const found = results.filter(r => r !== null);
|
||||
expect(found).toHaveLength(1);
|
||||
expect(found[0]).toContain("coding assistant");
|
||||
});
|
||||
|
||||
test("finds SYSTEM.md at both sub-project and repo root", async () => {
|
||||
writeFile(path.join(subProject, ".agents", "SYSTEM.md"), "# Local System");
|
||||
writeFile(path.join(repoRoot, ".agents", "SYSTEM.md"), "# Root System");
|
||||
|
||||
const paths = getProjectPathCandidates(ctx, "SYSTEM.md");
|
||||
const results = await Promise.all(paths.map(p => readFile(p)));
|
||||
const found = results.filter(r => r !== null);
|
||||
expect(found).toHaveLength(2);
|
||||
expect(found[0]).toContain("Local System");
|
||||
expect(found[1]).toContain("Root System");
|
||||
});
|
||||
|
||||
test("walk-up stops at repo root", async () => {
|
||||
writeFile(path.join(tempDir, ".agents", "SYSTEM.md"), "# Above Repo");
|
||||
writeFile(path.join(repoRoot, ".agents", "SYSTEM.md"), "# Root System");
|
||||
|
||||
const paths = getProjectPathCandidates(ctx, "SYSTEM.md");
|
||||
const results = await Promise.all(paths.map(p => readFile(p)));
|
||||
const found = results.filter(r => r !== null);
|
||||
expect(found).toHaveLength(1);
|
||||
expect(found[0]).toContain("Root System");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { type ContextFile, contextFileCapability } from "@oh-my-pi/pi-coding-agent/capability/context-file";
|
||||
|
||||
function makeContextFile(overrides: Partial<ContextFile> & Pick<ContextFile, "path" | "level">): ContextFile {
|
||||
return {
|
||||
content: `content of ${overrides.path}`,
|
||||
depth: undefined,
|
||||
_source: { provider: "test", providerName: "Test", path: overrides.path, level: overrides.level },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("contextFileCapability.key", () => {
|
||||
const key = contextFileCapability.key.bind(contextFileCapability);
|
||||
|
||||
test("user-level files share the same key regardless of depth", () => {
|
||||
const a = makeContextFile({ path: "/home/user/.omp/agent/AGENTS.md", level: "user" });
|
||||
const b = makeContextFile({ path: "/home/user/.claude/CLAUDE.md", level: "user" });
|
||||
expect(key(a)).toBe("user");
|
||||
expect(key(b)).toBe("user");
|
||||
expect(key(a)).toBe(key(b));
|
||||
});
|
||||
|
||||
test("project-level files at the same depth share the same key", () => {
|
||||
const a = makeContextFile({ path: "/repo/AGENTS.md", level: "project", depth: 0 });
|
||||
const b = makeContextFile({ path: "/repo/.claude/CLAUDE.md", level: "project", depth: 0 });
|
||||
expect(key(a)).toBe(key(b));
|
||||
});
|
||||
|
||||
test("project-level files at different depths have different keys", () => {
|
||||
const atCwd = makeContextFile({ path: "/repo/packages/app/AGENTS.md", level: "project", depth: 0 });
|
||||
const atParent = makeContextFile({ path: "/repo/packages/AGENTS.md", level: "project", depth: 1 });
|
||||
const atRoot = makeContextFile({ path: "/repo/AGENTS.md", level: "project", depth: 2 });
|
||||
|
||||
expect(key(atCwd)).not.toBe(key(atParent));
|
||||
expect(key(atParent)).not.toBe(key(atRoot));
|
||||
expect(key(atCwd)).not.toBe(key(atRoot));
|
||||
});
|
||||
|
||||
test("project-level file with no depth uses 0 as default", () => {
|
||||
const withDepth = makeContextFile({ path: "/repo/AGENTS.md", level: "project", depth: 0 });
|
||||
const noDepth = makeContextFile({ path: "/repo/AGENTS.md", level: "project" });
|
||||
expect(key(withDepth)).toBe(key(noDepth));
|
||||
});
|
||||
|
||||
test("user key never collides with any project key", () => {
|
||||
const user = makeContextFile({ path: "/home/user/.omp/AGENTS.md", level: "user" });
|
||||
for (let depth = 0; depth < 20; depth++) {
|
||||
const project = makeContextFile({ path: `/repo/AGENTS.md`, level: "project", depth });
|
||||
expect(key(user)).not.toBe(key(project));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("contextFileCapability.validate", () => {
|
||||
test("accepts valid context file", () => {
|
||||
const file = makeContextFile({ path: "/repo/AGENTS.md", level: "project", depth: 0 });
|
||||
expect(contextFileCapability.validate!(file)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("rejects missing path", () => {
|
||||
const file = makeContextFile({ path: "", level: "project" });
|
||||
expect(contextFileCapability.validate!(file)).toBe("Missing path");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Tests that skill discovery walks up from cwd to find skills in ancestor config directories.
|
||||
*
|
||||
* Instead of testing the full provider flow (which requires the entire capability registry),
|
||||
* this test verifies scanSkillsFromDir — the building block every provider uses — finds skills
|
||||
* at different directory levels, and that the walk-up pattern produces the correct ordering.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { clearCache } from "@oh-my-pi/pi-coding-agent/capability/fs";
|
||||
import type { Skill } from "@oh-my-pi/pi-coding-agent/capability/skill";
|
||||
import type { LoadContext, LoadResult } from "@oh-my-pi/pi-coding-agent/capability/types";
|
||||
import { scanSkillsFromDir } from "@oh-my-pi/pi-coding-agent/discovery/helpers";
|
||||
|
||||
function writeSkill(dir: string, name: string, description: string): void {
|
||||
const skillDir = path.join(dir, name);
|
||||
fs.mkdirSync(skillDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(skillDir, "SKILL.md"),
|
||||
`---\nname: ${name}\ndescription: ${description}\n---\n\n# ${name}\n\nSkill content.\n`,
|
||||
);
|
||||
}
|
||||
|
||||
describe("monorepo skill discovery", () => {
|
||||
let tempDir!: string;
|
||||
let repoRoot!: string;
|
||||
let subProject!: string;
|
||||
let ctx!: LoadContext;
|
||||
|
||||
beforeEach(() => {
|
||||
clearCache();
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-monorepo-skills-"));
|
||||
repoRoot = path.join(tempDir, "repo");
|
||||
subProject = path.join(repoRoot, "packages", "my-app");
|
||||
fs.mkdirSync(subProject, { recursive: true });
|
||||
// Create .git at repo root so findRepoRoot can detect it
|
||||
fs.mkdirSync(path.join(repoRoot, ".git"), { recursive: true });
|
||||
ctx = { cwd: subProject, home: tempDir, repoRoot };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearCache();
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("finds skills in ancestor .omp/skills/ directories", async () => {
|
||||
// Root has a skill
|
||||
writeSkill(path.join(repoRoot, ".omp", "skills"), "root-skill", "From repo root");
|
||||
// Sub-project has a skill
|
||||
writeSkill(path.join(subProject, ".omp", "skills"), "local-skill", "From sub-project");
|
||||
|
||||
// Simulate the walk-up pattern used by the builtin provider
|
||||
const results: LoadResult<Skill>[] = [];
|
||||
let current = subProject;
|
||||
while (true) {
|
||||
const result = await scanSkillsFromDir(ctx, {
|
||||
dir: path.join(current, ".omp", "skills"),
|
||||
providerId: "native",
|
||||
level: "project",
|
||||
});
|
||||
results.push(result);
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) break;
|
||||
current = parent;
|
||||
}
|
||||
|
||||
const allItems = results.flatMap(r => r.items);
|
||||
const names = allItems.map(s => s.name);
|
||||
|
||||
// Both skills found
|
||||
expect(names).toContain("local-skill");
|
||||
expect(names).toContain("root-skill");
|
||||
// Local skill appears first (closest to cwd wins on dedup)
|
||||
expect(names.indexOf("local-skill")).toBeLessThan(names.indexOf("root-skill"));
|
||||
});
|
||||
|
||||
test("closest skill wins when same name exists at multiple levels", async () => {
|
||||
// Same skill name at root and sub-project
|
||||
writeSkill(path.join(repoRoot, ".omp", "skills"), "shared-skill", "Root version");
|
||||
writeSkill(path.join(subProject, ".omp", "skills"), "shared-skill", "Local version");
|
||||
|
||||
const results: LoadResult<Skill>[] = [];
|
||||
let current = subProject;
|
||||
while (true) {
|
||||
const result = await scanSkillsFromDir(ctx, {
|
||||
dir: path.join(current, ".omp", "skills"),
|
||||
providerId: "native",
|
||||
level: "project",
|
||||
});
|
||||
results.push(result);
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) break;
|
||||
current = parent;
|
||||
}
|
||||
|
||||
const allItems = results.flatMap(r => r.items);
|
||||
const sharedSkills = allItems.filter(s => s.name === "shared-skill");
|
||||
|
||||
// Both found (dedup happens at capability level, not here)
|
||||
expect(sharedSkills).toHaveLength(2);
|
||||
// Closest comes first — will win dedup
|
||||
expect(sharedSkills[0]!.path).toContain("my-app");
|
||||
});
|
||||
|
||||
test("works when no ancestor has skills", async () => {
|
||||
// No skills anywhere
|
||||
const results: LoadResult<Skill>[] = [];
|
||||
let current = subProject;
|
||||
while (true) {
|
||||
const result = await scanSkillsFromDir(ctx, {
|
||||
dir: path.join(current, ".omp", "skills"),
|
||||
providerId: "native",
|
||||
level: "project",
|
||||
});
|
||||
results.push(result);
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) break;
|
||||
current = parent;
|
||||
}
|
||||
|
||||
const allItems = results.flatMap(r => r.items);
|
||||
expect(allItems).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("finds skills across multiple ancestor levels", async () => {
|
||||
// Three levels: repo root, packages/, and sub-project
|
||||
const packagesDir = path.join(repoRoot, "packages");
|
||||
writeSkill(path.join(repoRoot, ".omp", "skills"), "root-skill", "Root");
|
||||
writeSkill(path.join(packagesDir, ".omp", "skills"), "packages-skill", "Packages");
|
||||
writeSkill(path.join(subProject, ".omp", "skills"), "app-skill", "App");
|
||||
|
||||
const results: LoadResult<Skill>[] = [];
|
||||
let current = subProject;
|
||||
while (true) {
|
||||
const result = await scanSkillsFromDir(ctx, {
|
||||
dir: path.join(current, ".omp", "skills"),
|
||||
providerId: "native",
|
||||
level: "project",
|
||||
});
|
||||
results.push(result);
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) break;
|
||||
current = parent;
|
||||
}
|
||||
|
||||
const names = results.flatMap(r => r.items).map(s => s.name);
|
||||
expect(names).toContain("root-skill");
|
||||
expect(names).toContain("packages-skill");
|
||||
expect(names).toContain("app-skill");
|
||||
|
||||
// Ordering: closest first
|
||||
expect(names.indexOf("app-skill")).toBeLessThan(names.indexOf("packages-skill"));
|
||||
expect(names.indexOf("packages-skill")).toBeLessThan(names.indexOf("root-skill"));
|
||||
});
|
||||
|
||||
test("walk-up stops at repo root and does not find skills above it", async () => {
|
||||
// Skill ABOVE the repo root (should NOT be found)
|
||||
writeSkill(path.join(tempDir, ".omp", "skills"), "above-repo-skill", "Above repo");
|
||||
// Skill AT the repo root (should be found)
|
||||
writeSkill(path.join(repoRoot, ".omp", "skills"), "root-skill", "At repo root");
|
||||
|
||||
// Simulate the walk-up with repo root boundary (matching builtin provider pattern)
|
||||
const results: LoadResult<Skill>[] = [];
|
||||
let current = subProject;
|
||||
while (true) {
|
||||
const result = await scanSkillsFromDir(ctx, {
|
||||
dir: path.join(current, ".omp", "skills"),
|
||||
providerId: "native",
|
||||
level: "project",
|
||||
});
|
||||
results.push(result);
|
||||
if (current === (ctx.repoRoot ?? ctx.home)) break; // stop at repo root or home
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) break;
|
||||
current = parent;
|
||||
}
|
||||
|
||||
const names = results.flatMap(r => r.items).map(s => s.name);
|
||||
expect(names).toContain("root-skill");
|
||||
expect(names).not.toContain("above-repo-skill");
|
||||
});
|
||||
});
|
||||
@@ -183,7 +183,7 @@ describe("skills", () => {
|
||||
const claudeProvider = capability?.providers.find(provider => provider.id === "claude");
|
||||
expect(claudeProvider).toBeDefined();
|
||||
|
||||
const result = await claudeProvider!.load({ cwd: tempProjectDir, home: tempHomeDir });
|
||||
const result = await claudeProvider!.load({ cwd: tempProjectDir, home: tempHomeDir, repoRoot: null });
|
||||
expect(result.items.some(skill => skill.name === "user-only-skill" && skill.level === "user")).toBe(true);
|
||||
} finally {
|
||||
await fs.rm(tempProjectDir, { recursive: true, force: true });
|
||||
|
||||
Reference in New Issue
Block a user