feat(coding-agent): converted capability loading and discovery providers to async APIs with caching fs layer

- Converted SettingsManager.create(), loadSettings(), discoverSkills(), loadSlashCommands(), buildSystemPrompt(), loadSkills(), loadProjectContextFiles(), and getShellConfig() from synchronous to asynchronous APIs.
- Changed capability provider load() method from supporting both sync and async to async-only by removing loadSync().
- Removed fs property from LoadContext interface in capability types.
- Added new fs.ts module with caching layer for filesystem operations using readFile, readDirEntries, readDir, walkUp, and cache management functions.
- Refactored all discovery providers (builtin, claude, cline, codex, cursor, gemini, github, mcp-json, ssh, vscode, windsurf) to use async file operations with Promise.all for parallel loading.
This commit is contained in:
can1357
2026-01-11 04:26:43 +01:00
parent 969eb8fc00
commit e6d33e0a48
53 changed files with 1720 additions and 1853 deletions
+10
View File
@@ -1,12 +1,22 @@
# Changelog
## [Unreleased]
### Added
- Added `--no-lsp` to disable LSP tools, formatting, diagnostics, and warmup for a session
### Changed
- Changed `SettingsManager.create()` to be async, requiring `await` when creating settings managers
- Changed `loadSettings()` to be async, requiring `await` when loading settings
- Changed `discoverSkills()` to be async, requiring `await` when discovering skills
- Changed `loadSlashCommands()` to be async, requiring `await` when loading slash commands
- Changed `buildSystemPrompt()` to be async, requiring `await` when building system prompts
- Changed `loadSkills()` to be async, requiring `await` when loading skills
- Changed `loadProjectContextFiles()` to be async, requiring `await` when loading context files
- Changed `getShellConfig()` to be async, requiring `await` when getting shell configuration
- Changed capability provider `load()` methods to be async-only, removing synchronous `loadSync` API
- Updated `plan` agent with enhanced structured planning process, parallel exploration via `explore` agent spawning, and improved output format with examples
- Removed `planner` agent command template, consolidating planning functionality into the `plan` agent
+5 -5
View File
@@ -682,11 +682,11 @@ import { createAgentSession, SettingsManager, SessionManager } from "@oh-my-pi/p
// Default: loads from files (global + project merged)
const { session } = await createAgentSession({
settingsManager: SettingsManager.create(),
settingsManager: await SettingsManager.create(),
});
// With overrides
const settingsManager = SettingsManager.create();
const settingsManager = await SettingsManager.create();
settingsManager.applyOverrides({
compaction: { enabled: false },
retry: { enabled: true, maxRetries: 5 },
@@ -701,13 +701,13 @@ const { session } = await createAgentSession({
// Custom directories
const { session } = await createAgentSession({
settingsManager: SettingsManager.create("/custom/cwd", "/custom/agent"),
settingsManager: await SettingsManager.create("/custom/cwd", "/custom/agent"),
});
```
**Static factories:**
- `SettingsManager.create(cwd?, agentDir?)` - Load from files
- `SettingsManager.create(cwd?, agentDir?)` - Load from files (async)
- `SettingsManager.inMemory(settings?)` - No file I/O
**Project-specific settings:**
@@ -765,7 +765,7 @@ const contextFiles = discoverContextFiles(cwd, agentDir);
const commands = discoverSlashCommands(cwd, agentDir);
// Settings (global + project merged)
const settings = loadSettings(cwd, agentDir);
const settings = await loadSettings(cwd, agentDir);
// Build system prompt manually
const prompt = buildSystemPrompt({
@@ -7,11 +7,11 @@
import { createAgentSession, loadSettings, SessionManager, SettingsManager } from "@oh-my-pi/pi-coding-agent";
// Load current settings (merged global + project)
const settings = loadSettings();
const settings = await loadSettings();
console.log("Current settings:", JSON.stringify(settings, null, 2));
// Override specific settings
const settingsManager = SettingsManager.create();
const settingsManager = await SettingsManager.create();
settingsManager.applyOverrides({
compaction: { enabled: false },
retry: { enabled: true, maxRetries: 5, baseDelayMs: 1000 },
@@ -0,0 +1,90 @@
import type { Dirent } from "node:fs";
import { readdir } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
const contentCache = new Map<string, string | null>();
const dirCache = new Map<string, Dirent[]>();
function resolvePath(path: string): string {
return resolve(path);
}
export async function readFile(path: string): Promise<string | null> {
const abs = resolvePath(path);
if (contentCache.has(abs)) {
return contentCache.get(abs) ?? null;
}
try {
const content = await Bun.file(abs).text();
contentCache.set(abs, content);
return content;
} catch {
contentCache.set(abs, null);
return null;
}
}
export async function readDirEntries(path: string): Promise<Dirent[]> {
const abs = resolvePath(path);
if (dirCache.has(abs)) {
return dirCache.get(abs) ?? [];
}
try {
const entries = await readdir(abs, { withFileTypes: true });
dirCache.set(abs, entries);
return entries;
} catch {
dirCache.set(abs, []);
return [];
}
}
export async function readDir(path: string): Promise<string[]> {
const entries = await readDirEntries(path);
return entries.map((entry) => entry.name);
}
export async function walkUp(
startDir: string,
name: string,
opts: { file?: boolean; dir?: boolean } = {},
): Promise<string | null> {
const { file = true, dir = true } = opts;
let current = resolvePath(startDir);
while (true) {
const entries = await readDirEntries(current);
const entry = entries.find((e) => e.name === name);
if (entry) {
if (file && entry.isFile()) return join(current, name);
if (dir && entry.isDirectory()) return join(current, name);
}
const parent = dirname(current);
if (parent === current) return null;
current = parent;
}
}
export function cacheStats(): { content: number; dir: number } {
return {
content: contentCache.size,
dir: dirCache.size,
};
}
export function clearCache(): void {
contentCache.clear();
dirCache.clear();
}
export function invalidate(path: string): void {
const abs = resolvePath(path);
contentCache.delete(abs);
dirCache.delete(abs);
const parent = dirname(abs);
if (parent !== abs) {
dirCache.delete(parent);
}
}
+44 -230
View File
@@ -7,9 +7,9 @@
* - Loading items for a capability across all providers
*/
import { type Dirent, readdirSync, readFileSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { resolve } from "node:path";
import { clearCache as clearFsCache, cacheStats as fsCacheStats, invalidate as invalidateFs } from "./fs";
import type {
Capability,
CapabilityInfo,
@@ -40,87 +40,6 @@ const disabledProviders = new Set<string>();
/** Settings manager for persistence (if set) */
let settingsManager: { getDisabledProviders(): string[]; setDisabledProviders(ids: string[]): void } | null = null;
// =============================================================================
// Filesystem Cache
// =============================================================================
type StatResult = "file" | "dir" | null;
const statCache = new Map<string, StatResult>();
const contentCache = new Map<string, string | null>();
const dirCache = new Map<string, Dirent[]>();
function clearCache(): void {
statCache.clear();
contentCache.clear();
dirCache.clear();
}
function createFsHelpers(cwd: string): LoadContext["fs"] {
return {
exists(path: string): boolean {
const abs = resolve(cwd, path);
if (!statCache.has(abs)) {
try {
const stat = statSync(abs);
statCache.set(abs, stat.isDirectory() ? "dir" : stat.isFile() ? "file" : null);
} catch {
statCache.set(abs, null);
}
}
return statCache.get(abs) !== null;
},
isDir(path: string): boolean {
this.exists(path);
return statCache.get(resolve(cwd, path)) === "dir";
},
isFile(path: string): boolean {
this.exists(path);
return statCache.get(resolve(cwd, path)) === "file";
},
readFile(path: string): string | null {
const abs = resolve(cwd, path);
if (!contentCache.has(abs)) {
try {
contentCache.set(abs, readFileSync(abs, "utf-8"));
} catch {
contentCache.set(abs, null);
}
}
return contentCache.get(abs) ?? null;
},
readDir(path: string): string[] {
const abs = resolve(cwd, path);
if (!this.isDir(path)) return [];
if (!dirCache.has(abs)) {
try {
dirCache.set(abs, readdirSync(abs, { withFileTypes: true }));
} catch {
dirCache.set(abs, []);
}
}
return (dirCache.get(abs) ?? []).map((e) => e.name);
},
walkUp(name: string, opts: { file?: boolean; dir?: boolean } = {}): string | null {
const { file = true, dir = true } = opts;
let current = cwd;
while (true) {
const candidate = join(current, name);
if (file && this.isFile(candidate)) return candidate;
if (dir && this.isDir(candidate)) return candidate;
const parent = dirname(current);
if (parent === current) return null;
current = parent;
}
},
};
}
// =============================================================================
// Registration API
// =============================================================================
@@ -175,97 +94,9 @@ export function registerProvider<T>(capabilityId: string, provider: Provider<T>)
// =============================================================================
/**
* Core loading logic shared by both load() and loadSync().
* Async loading logic shared by loadCapability().
*/
function loadImpl<T>(
capability: Capability<T>,
providers: Provider<T>[],
ctx: LoadContext,
options: LoadOptions,
): CapabilityResult<T> {
const allItems: Array<T & { _source: SourceMeta; _shadowed?: boolean }> = [];
const allWarnings: string[] = [];
const contributingProviders: string[] = [];
for (const provider of providers) {
try {
const result = provider.load(ctx);
if (result instanceof Promise) {
throw new Error(
`Provider "${provider.id}" returned a Promise. Use load() instead of loadSync() for async providers.`,
);
}
if (result.warnings) {
allWarnings.push(...result.warnings.map((w) => `[${provider.displayName}] ${w}`));
}
if (result.items.length > 0) {
contributingProviders.push(provider.id);
for (const item of result.items) {
const itemWithSource = item as T & { _source: SourceMeta };
if (itemWithSource._source) {
itemWithSource._source.providerName = provider.displayName;
allItems.push(itemWithSource as T & { _source: SourceMeta; _shadowed?: boolean });
} else {
allWarnings.push(`[${provider.displayName}] Item missing _source metadata, skipping`);
}
}
}
} catch (err) {
if (err instanceof Error && err.message.includes("returned a Promise")) {
throw err;
}
allWarnings.push(`[${provider.displayName}] Failed to load: ${err}`);
}
}
// Deduplicate by key (first wins = highest priority)
const seen = new Map<string, number>();
const deduped: Array<T & { _source: SourceMeta }> = [];
for (let i = 0; i < allItems.length; i++) {
const item = allItems[i];
const key = capability.key(item);
if (key === undefined) {
deduped.push(item);
} else if (!seen.has(key)) {
seen.set(key, i);
deduped.push(item);
} else {
item._shadowed = true;
}
}
// Validate items (only non-shadowed items)
if (capability.validate && !options.includeInvalid) {
for (let i = deduped.length - 1; i >= 0; i--) {
const error = capability.validate(deduped[i]);
if (error) {
const source = deduped[i]._source;
allWarnings.push(
`[${source?.providerName ?? "unknown"}] Invalid item at ${source?.path ?? "unknown"}: ${error}`,
);
deduped.splice(i, 1);
}
}
}
return {
items: deduped,
all: allItems,
warnings: allWarnings,
providers: contributingProviders,
};
}
/**
* Async loading logic shared by load().
*/
async function loadImplAsync<T>(
async function loadImpl<T>(
capability: Capability<T>,
providers: Provider<T>[],
ctx: LoadContext,
@@ -275,29 +106,43 @@ async function loadImplAsync<T>(
const allWarnings: string[] = [];
const contributingProviders: string[] = [];
for (const provider of providers) {
try {
const result = await provider.load(ctx);
if (result.warnings) {
allWarnings.push(...result.warnings.map((w) => `[${provider.displayName}] ${w}`));
const results = await Promise.all(
providers.map(async (provider) => {
try {
const result = await provider.load(ctx);
return { provider, result };
} catch (error) {
return { provider, error };
}
}),
);
if (result.items.length > 0) {
contributingProviders.push(provider.id);
for (const entry of results) {
const { provider } = entry;
if ("error" in entry) {
allWarnings.push(`[${provider.displayName}] Failed to load: ${entry.error}`);
continue;
}
for (const item of result.items) {
const itemWithSource = item as T & { _source: SourceMeta };
if (itemWithSource._source) {
itemWithSource._source.providerName = provider.displayName;
allItems.push(itemWithSource as T & { _source: SourceMeta; _shadowed?: boolean });
} else {
allWarnings.push(`[${provider.displayName}] Item missing _source metadata, skipping`);
}
const result = entry.result;
if (!result) continue;
if (result.warnings) {
allWarnings.push(...result.warnings.map((w) => `[${provider.displayName}] ${w}`));
}
if (result.items.length > 0) {
contributingProviders.push(provider.id);
for (const item of result.items) {
const itemWithSource = item as T & { _source: SourceMeta };
if (itemWithSource._source) {
itemWithSource._source.providerName = provider.displayName;
allItems.push(itemWithSource as T & { _source: SourceMeta; _shadowed?: boolean });
} else {
allWarnings.push(`[${provider.displayName}] Item missing _source metadata, skipping`);
}
}
} catch (err) {
allWarnings.push(`[${provider.displayName}] Failed to load: ${err}`);
}
}
@@ -362,7 +207,7 @@ function filterProviders<T>(capability: Capability<T>, options: LoadOptions): Pr
/**
* Load a capability by ID.
*/
export async function load<T>(capabilityId: string, options: LoadOptions = {}): Promise<CapabilityResult<T>> {
export async function loadCapability<T>(capabilityId: string, options: LoadOptions = {}): Promise<CapabilityResult<T>> {
const capability = capabilities.get(capabilityId) as Capability<T> | undefined;
if (!capability) {
throw new Error(`Unknown capability: "${capabilityId}"`);
@@ -370,28 +215,10 @@ export async function load<T>(capabilityId: string, options: LoadOptions = {}):
const cwd = options.cwd ?? process.cwd();
const home = homedir();
const ctx: LoadContext = { cwd, home, fs: createFsHelpers(cwd) };
const ctx: LoadContext = { cwd, home };
const providers = filterProviders(capability, options);
return loadImplAsync(capability, providers, ctx, options);
}
/**
* Synchronous load (for capabilities where all providers are sync).
* Throws if any provider returns a Promise.
*/
export function loadSync<T>(capabilityId: string, options: LoadOptions = {}): CapabilityResult<T> {
const capability = capabilities.get(capabilityId) as Capability<T> | undefined;
if (!capability) {
throw new Error(`Unknown capability: "${capabilityId}"`);
}
const cwd = options.cwd ?? process.cwd();
const home = homedir();
const ctx: LoadContext = { cwd, home, fs: createFsHelpers(cwd) };
const providers = filterProviders(capability, options);
return loadImpl(capability, providers, ctx, options);
return await loadImpl(capability, providers, ctx, options);
}
// =============================================================================
@@ -567,36 +394,23 @@ export function getAllProvidersInfo(): ProviderInfo[] {
* Reset all caches. Call after chdir or filesystem changes.
*/
export function reset(): void {
clearCache();
clearFsCache();
}
/**
* Invalidate cache for a specific path.
* @param path - Absolute or relative path to invalidate
* @param cwd - Working directory for resolving relative paths (defaults to process.cwd())
*/
export function invalidate(path: string, cwd?: string): void {
const abs = resolve(cwd ?? process.cwd(), path);
statCache.delete(abs);
contentCache.delete(abs);
dirCache.delete(abs);
// Also invalidate parent for directory listings
const parent = dirname(abs);
if (parent !== abs) {
statCache.delete(parent);
dirCache.delete(parent);
}
const resolved = cwd ? resolve(cwd, path) : path;
invalidateFs(resolved);
}
/**
* Get cache stats for diagnostics.
*/
export function cacheStats(): { stat: number; content: number; dir: number } {
return {
stat: statCache.size,
content: contentCache.size,
dir: dirCache.size,
};
export function cacheStats(): { content: number; dir: number } {
return fsCacheStats();
}
// =============================================================================
+1 -11
View File
@@ -14,16 +14,6 @@ export interface LoadContext {
cwd: string;
/** User home directory */
home: string;
/** Filesystem helpers (cached) */
fs: {
exists(path: string): boolean;
isDir(path: string): boolean;
isFile(path: string): boolean;
readFile(path: string): string | null;
readDir(path: string): string[];
/** Walk up from cwd looking for a file/dir, returns first match */
walkUp(name: string, opts?: { file?: boolean; dir?: boolean }): string | null;
};
}
/**
@@ -61,7 +51,7 @@ export interface Provider<T> {
* Load items for this capability.
* Returns items in provider's preferred order (usually project before user).
*/
load(ctx: LoadContext): LoadResult<T> | Promise<LoadResult<T>>;
load(ctx: LoadContext): Promise<LoadResult<T>>;
}
/**
@@ -99,7 +99,7 @@ export interface AgentSessionConfig {
/** Tool registry for LSP and settings */
toolRegistry?: Map<string, AgentTool>;
/** System prompt builder that can consider tool availability */
rebuildSystemPrompt?: (toolNames: string[], tools: Map<string, AgentTool>) => string;
rebuildSystemPrompt?: (toolNames: string[], tools: Map<string, AgentTool>) => Promise<string>;
/** TTSR manager for time-traveling stream rules */
ttsrManager?: TtsrManager;
}
@@ -249,7 +249,7 @@ export class AgentSession {
// Tool registry and prompt builder for extensions
private _toolRegistry: Map<string, AgentTool>;
private _rebuildSystemPrompt: ((toolNames: string[], tools: Map<string, AgentTool>) => string) | undefined;
private _rebuildSystemPrompt: ((toolNames: string[], tools: Map<string, AgentTool>) => Promise<string>) | undefined;
private _baseSystemPrompt: string;
// TTSR manager for time-traveling stream rules
@@ -628,7 +628,7 @@ export class AgentSession {
* Also rebuilds the system prompt to reflect the new tool set.
* Changes take effect on the next agent turn.
*/
setActiveToolsByName(toolNames: string[]): void {
async setActiveToolsByName(toolNames: string[]): Promise<void> {
const tools: AgentTool[] = [];
const validToolNames: string[] = [];
for (const name of toolNames) {
@@ -642,7 +642,7 @@ export class AgentSession {
// Rebuild base system prompt with new tool set
if (this._rebuildSystemPrompt) {
this._baseSystemPrompt = this._rebuildSystemPrompt(validToolNames, this._toolRegistry);
this._baseSystemPrompt = await this._rebuildSystemPrompt(validToolNames, this._toolRegistry);
this.agent.setSystemPrompt(this._baseSystemPrompt);
}
}
@@ -142,7 +142,7 @@ function createOutputSink(
* @returns Promise resolving to execution result
*/
export async function executeBash(command: string, options?: BashExecutorOptions): Promise<BashResult> {
const { shell, args, env, prefix } = getShellConfig();
const { shell, args, env, prefix } = await getShellConfig();
// Get or create shell snapshot (for aliases, functions, options)
const snapshotPath = await getOrCreateSnapshot(shell, env);
@@ -9,7 +9,7 @@ import * as os from "node:os";
import * as path from "node:path";
import * as typebox from "@sinclair/typebox";
import { toolCapability } from "../../capability/tool";
import { type CustomTool, loadSync } from "../../discovery";
import { type CustomTool, loadCapability } from "../../discovery";
import * as piCodingAgent from "../../index";
import { theme } from "../../modes/interactive/theme/theme";
import type { ExecOptions } from "../exec";
@@ -225,7 +225,7 @@ export async function discoverAndLoadCustomTools(
};
// 1. Discover tools via capability system (user + project from all providers)
const discoveredTools = loadSync<CustomTool>(toolCapability.id, { cwd });
const discoveredTools = await loadCapability<CustomTool>(toolCapability.id, { cwd });
for (const tool of discoveredTools.items) {
addPath(tool.path, {
provider: tool._source.provider,
@@ -8,7 +8,7 @@ import * as path from "node:path";
import type { KeyId } from "@oh-my-pi/pi-tui";
import * as TypeBox from "@sinclair/typebox";
import { type ExtensionModule, extensionModuleCapability } from "../../capability/extension-module";
import { loadSync } from "../../discovery";
import { loadCapability } from "../../discovery";
import { getExtensionNameFromPath } from "../../discovery/helpers";
import * as piCodingAgent from "../../index";
import { createEventBus, type EventBus } from "../event-bus";
@@ -408,7 +408,7 @@ export async function discoverAndLoadExtensions(
};
// 1. Discover extension modules via capability API (native .omp/.pi only)
const discovered = loadSync<ExtensionModule>(extensionModuleCapability.id, { cwd });
const discovered = await loadCapability<ExtensionModule>(extensionModuleCapability.id, { cwd });
for (const ext of discovered.items) {
if (ext._source.provider !== "native") continue;
if (isDisabledName(ext.name)) continue;
@@ -756,7 +756,7 @@ export type GetActiveToolsHandler = () => string[];
export type GetAllToolsHandler = () => string[];
export type SetActiveToolsHandler = (toolNames: string[]) => void;
export type SetActiveToolsHandler = (toolNames: string[]) => Promise<void>;
export type SetModelHandler = (model: Model<any>) => Promise<boolean>;
@@ -7,7 +7,7 @@ import * as path from "node:path";
import * as typebox from "@sinclair/typebox";
import { hookCapability } from "../../capability/hook";
import type { Hook } from "../../discovery";
import { loadSync } from "../../discovery";
import { loadCapability } from "../../discovery";
import * as piCodingAgent from "../../index";
import { logger } from "../logger";
import type { HookMessage } from "../messages";
@@ -278,7 +278,7 @@ export async function discoverAndLoadHooks(configuredPaths: string[], cwd: strin
};
// 1. Discover hooks via capability API
const discovered = loadSync<Hook>(hookCapability.id, { cwd });
const discovered = await loadCapability<Hook>(hookCapability.id, { cwd });
addPaths(discovered.items.map((hook) => hook.path));
// 2. Explicitly configured paths (can override/add)
+2 -2
View File
@@ -6,7 +6,7 @@
import { mcpCapability } from "../../capability/mcp";
import type { MCPServer } from "../../discovery";
import { load } from "../../discovery";
import { loadCapability } from "../../discovery";
import type { MCPServerConfig } from "./types";
/** Options for loading MCP configs */
@@ -81,7 +81,7 @@ export async function loadAllMCPConfigs(cwd: string, options?: LoadMCPConfigsOpt
const filterExa = options?.filterExa ?? true;
// Load MCP servers via capability system
const result = await load<MCPServer>(mcpCapability.id, { cwd });
const result = await loadCapability<MCPServer>(mcpCapability.id, { cwd });
// Filter out project-level configs if disabled
const servers = enableProjectConfig
+27 -28
View File
@@ -33,7 +33,7 @@ import type { Component } from "@oh-my-pi/pi-tui";
import chalk from "chalk";
// Import discovery to register all providers on startup
import "../discovery";
import { loadSync as loadCapability } from "../capability/index";
import { loadCapability } from "../capability/index";
import { type Rule, ruleCapability } from "../capability/rule";
import { getAgentDir, getConfigDirPaths } from "../config";
import { initializeWithSettings } from "../discovery";
@@ -289,12 +289,12 @@ export async function discoverExtensions(cwd?: string): Promise<LoadExtensionsRe
/**
* Discover skills from cwd and agentDir.
*/
export function discoverSkills(
export async function discoverSkills(
cwd?: string,
_agentDir?: string,
settings?: SkillsSettings,
): { skills: Skill[]; warnings: SkillWarning[] } {
return loadSkillsInternal({
): Promise<{ skills: Skill[]; warnings: SkillWarning[] }> {
return await loadSkillsInternal({
...settings,
cwd: cwd ?? process.cwd(),
});
@@ -304,11 +304,11 @@ export function discoverSkills(
* Discover context files (AGENTS.md) walking up from cwd.
* Returns files sorted by depth (farther from cwd first, so closer files appear last/more prominent).
*/
export function discoverContextFiles(
export async function discoverContextFiles(
cwd?: string,
_agentDir?: string,
): Array<{ path: string; content: string; depth?: number }> {
return loadContextFilesInternal({
): Promise<Array<{ path: string; content: string; depth?: number }>> {
return await loadContextFilesInternal({
cwd: cwd ?? process.cwd(),
});
}
@@ -326,7 +326,7 @@ export async function discoverPromptTemplates(cwd?: string, agentDir?: string):
/**
* Discover file-based slash commands from commands/ directories.
*/
export function discoverSlashCommands(cwd?: string): FileSlashCommand[] {
export async function discoverSlashCommands(cwd?: string): Promise<FileSlashCommand[]> {
return loadSlashCommandsInternal({ cwd: cwd ?? process.cwd() });
}
@@ -367,8 +367,8 @@ export interface BuildSystemPromptOptions {
/**
* Build the default system prompt.
*/
export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): string {
return buildSystemPromptInternal({
export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): Promise<string> {
return await buildSystemPromptInternal({
cwd: options.cwd,
skills: options.skills,
contextFiles: options.contextFiles,
@@ -381,8 +381,8 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
/**
* Load settings from agentDir/settings.json merged with cwd/.omp/settings.json.
*/
export function loadSettings(cwd?: string, agentDir?: string): Settings {
const manager = SettingsManager.create(cwd ?? process.cwd(), agentDir ?? getDefaultAgentDir());
export async function loadSettings(cwd?: string, agentDir?: string): Promise<Settings> {
const manager = await SettingsManager.create(cwd ?? process.cwd(), agentDir ?? getDefaultAgentDir());
return {
modelRoles: manager.getModelRoles(),
defaultThinkingLevel: manager.getDefaultThinkingLevel(),
@@ -546,7 +546,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
const modelRegistry = options.modelRegistry ?? (await discoverModels(authStorage, agentDir));
time("discoverModels");
const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir);
const settingsManager = options.settingsManager ?? (await SettingsManager.create(cwd, agentDir));
time("settingsManager");
initializeWithSettings(settingsManager);
time("initializeWithSettings");
@@ -599,12 +599,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
// Fall back to first available model with a valid API key
if (!model) {
for (const m of modelRegistry.getAll()) {
if (await modelRegistry.getApiKey(m, sessionId)) {
model = m;
break;
}
}
const allModels = modelRegistry.getAll();
const keyResults = await Promise.all(
allModels.map(async (m) => ({ model: m, hasKey: !!(await modelRegistry.getApiKey(m, sessionId)) })),
);
model = keyResults.find((r) => r.hasKey)?.model;
time("findAvailableModel");
if (model) {
if (modelFallbackMessage) {
@@ -639,7 +638,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
skills = options.skills;
skillWarnings = [];
} else {
const discovered = discoverSkills(cwd, agentDir, settingsManager.getSkillsSettings());
const discovered = await discoverSkills(cwd, agentDir, settingsManager.getSkillsSettings());
skills = discovered.skills;
skillWarnings = discovered.warnings;
}
@@ -647,7 +646,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
// Discover rules
const ttsrManager = createTtsrManager(settingsManager.getTtsrSettings());
const rulesResult = loadCapability<Rule>(ruleCapability.id, { cwd });
const rulesResult = await loadCapability<Rule>(ruleCapability.id, { cwd });
for (const rule of rulesResult.items) {
if (rule.ttsrTrigger) {
ttsrManager.addRule(rule);
@@ -656,7 +655,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
time("discoverTtsrRules");
// Filter rules for the rulebook (non-TTSR, non-alwaysApply, with descriptions)
const rulebookRules = rulesResult.items.filter((rule) => {
const rulebookRules = rulesResult.items.filter((rule: Rule) => {
if (rule.ttsrTrigger) return false;
if (rule.alwaysApply) return false;
if (!rule.description) return false;
@@ -664,7 +663,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
});
time("filterRulebookRules");
const contextFiles = options.contextFiles ?? discoverContextFiles(cwd, agentDir);
const contextFiles = options.contextFiles ?? (await discoverContextFiles(cwd, agentDir));
time("discoverContextFiles");
let agent: Agent;
@@ -855,9 +854,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
}
time("combineTools");
const rebuildSystemPrompt = (toolNames: string[], tools: Map<string, AgentTool>): string => {
const rebuildSystemPrompt = async (toolNames: string[], tools: Map<string, AgentTool>): Promise<string> => {
toolContextStore.setToolNames(toolNames);
const defaultPrompt = buildSystemPromptInternal({
const defaultPrompt = await buildSystemPromptInternal({
cwd,
skills,
contextFiles,
@@ -871,7 +870,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
return defaultPrompt;
}
if (typeof options.systemPrompt === "string") {
return buildSystemPromptInternal({
return await buildSystemPromptInternal({
cwd,
skills,
contextFiles,
@@ -885,13 +884,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
return options.systemPrompt(defaultPrompt);
};
const systemPrompt = rebuildSystemPrompt(Array.from(toolRegistry.keys()), toolRegistry);
const systemPrompt = await rebuildSystemPrompt(Array.from(toolRegistry.keys()), toolRegistry);
time("buildSystemPrompt");
const promptTemplates = options.promptTemplates ?? (await discoverPromptTemplates(cwd, agentDir));
time("discoverPromptTemplates");
const slashCommands = options.slashCommands ?? discoverSlashCommands(cwd);
const slashCommands = options.slashCommands ?? (await discoverSlashCommands(cwd));
time("discoverSlashCommands");
// Create convertToLlm wrapper that filters images if blockImages is enabled (defense-in-depth)
+152 -135
View File
@@ -2,7 +2,7 @@ import { existsSync, readFileSync, renameSync } from "node:fs";
import { join } from "node:path";
import { type Settings as SettingsItem, settingsCapability } from "../capability/settings";
import { getAgentDbPath, getAgentDir } from "../config";
import { loadSync } from "../discovery";
import { loadCapability } from "../discovery";
import type { SymbolPreset } from "../modes/interactive/theme/theme";
import { AgentStorage } from "./agent-storage";
import { logger } from "./logger";
@@ -419,14 +419,21 @@ export class SettingsManager {
* @param cwd - Current working directory for project settings discovery
* @param initialSettings - Initial global settings to use
* @param persist - Whether to persist settings changes to storage
* @param projectSettings - Pre-loaded project settings (to avoid async in constructor)
*/
private constructor(storage: AgentStorage | null, cwd: string | null, initialSettings: Settings, persist: boolean) {
private constructor(
storage: AgentStorage | null,
cwd: string | null,
initialSettings: Settings,
persist: boolean,
projectSettings: Settings,
) {
this.storage = storage;
this.cwd = cwd;
this.persist = persist;
this.globalSettings = initialSettings;
this.overrides = {};
this.rebuildSettings();
this.rebuildSettings(projectSettings);
// Apply environment variables from settings
this.applyEnvironmentVariables();
@@ -458,12 +465,12 @@ export class SettingsManager {
* @param agentDir - Agent directory containing agent.db
* @returns Configured SettingsManager with merged global and user settings
*/
static create(cwd: string = process.cwd(), agentDir: string = getAgentDir()): SettingsManager {
static async create(cwd: string = process.cwd(), agentDir: string = getAgentDir()): Promise<SettingsManager> {
const storage = AgentStorage.open(getAgentDbPath(agentDir));
SettingsManager.migrateLegacySettingsFile(storage, agentDir);
// Use capability API to load user-level settings from all providers
const result = loadSync(settingsCapability.id, { cwd });
const result = await loadCapability(settingsCapability.id, { cwd });
// Merge all user-level settings
let globalSettings: Settings = {};
@@ -477,7 +484,10 @@ export class SettingsManager {
const storedSettings = SettingsManager.loadFromStorage(storage);
globalSettings = deepMergeSettings(globalSettings, storedSettings);
return new SettingsManager(storage, cwd, globalSettings, true);
// Load project settings before construction (constructor is sync)
const projectSettings = await SettingsManager.loadProjectSettingsStatic(cwd);
return new SettingsManager(storage, cwd, globalSettings, true, projectSettings);
}
/**
@@ -486,7 +496,7 @@ export class SettingsManager {
* @returns SettingsManager that won't persist changes to disk
*/
static inMemory(settings: Partial<Settings> = {}): SettingsManager {
return new SettingsManager(null, null, settings, false);
return new SettingsManager(null, null, settings, false, {});
}
/**
@@ -538,11 +548,14 @@ export class SettingsManager {
return settings as Settings;
}
private loadProjectSettings(): Settings {
if (!this.cwd) return {};
/**
* Static helper to load project settings (used by create() before construction).
*/
private static async loadProjectSettingsStatic(cwd: string | null): Promise<Settings> {
if (!cwd) return {};
// Use capability API to discover settings from all providers
const result = loadSync(settingsCapability.id, { cwd: this.cwd });
const result = await loadCapability(settingsCapability.id, { cwd });
// Merge only project-level settings (user-level settings are handled separately via globalSettings)
let merged: Settings = {};
@@ -555,24 +568,28 @@ export class SettingsManager {
return SettingsManager.migrateSettings(merged as Record<string, unknown>);
}
private rebuildSettings(projectSettings?: Settings): void {
const resolvedProjectSettings = projectSettings ?? this.loadProjectSettings();
private async loadProjectSettings(): Promise<Settings> {
return SettingsManager.loadProjectSettingsStatic(this.cwd);
}
private rebuildSettings(projectSettings: Settings): void {
this.settings = normalizeSettings(
deepMergeSettings(deepMergeSettings(this.globalSettings, resolvedProjectSettings), this.overrides),
deepMergeSettings(deepMergeSettings(this.globalSettings, projectSettings), this.overrides),
);
}
/** Apply additional overrides on top of current settings */
applyOverrides(overrides: Partial<Settings>): void {
async applyOverrides(overrides: Partial<Settings>): Promise<void> {
this.overrides = deepMergeSettings(this.overrides, overrides);
this.rebuildSettings();
const projectSettings = await this.loadProjectSettings();
this.rebuildSettings(projectSettings);
}
/**
* Persist current global settings to SQLite storage and rebuild merged settings.
* Merges with any concurrent changes in storage before saving.
*/
private save(): void {
private async save(): Promise<void> {
if (this.persist && this.storage) {
try {
const currentSettings = this.storage.getSettings() ?? {};
@@ -585,7 +602,7 @@ export class SettingsManager {
}
// Always re-merge to update active settings (needed for both file and inMemory modes)
const projectSettings = this.loadProjectSettings();
const projectSettings = await this.loadProjectSettings();
this.rebuildSettings(projectSettings);
}
@@ -593,9 +610,9 @@ export class SettingsManager {
return this.settings.lastChangelogVersion;
}
setLastChangelogVersion(version: string): void {
async setLastChangelogVersion(version: string): Promise<void> {
this.globalSettings.lastChangelogVersion = version;
this.save();
await this.save();
}
/**
@@ -608,7 +625,7 @@ export class SettingsManager {
/**
* Set model for a role. Model should be "provider/modelId" format.
*/
setModelRole(role: string, model: string): void {
async setModelRole(role: string, model: string): Promise<void> {
if (!this.globalSettings.modelRoles) {
this.globalSettings.modelRoles = {};
}
@@ -618,7 +635,7 @@ export class SettingsManager {
this.overrides.modelRoles[role] = model;
}
this.save();
await this.save();
}
/**
@@ -632,66 +649,66 @@ export class SettingsManager {
return this.settings.steeringMode || "one-at-a-time";
}
setSteeringMode(mode: "all" | "one-at-a-time"): void {
async setSteeringMode(mode: "all" | "one-at-a-time"): Promise<void> {
this.globalSettings.steeringMode = mode;
this.save();
await this.save();
}
getFollowUpMode(): "all" | "one-at-a-time" {
return this.settings.followUpMode || "one-at-a-time";
}
setFollowUpMode(mode: "all" | "one-at-a-time"): void {
async setFollowUpMode(mode: "all" | "one-at-a-time"): Promise<void> {
this.globalSettings.followUpMode = mode;
this.save();
await this.save();
}
getInterruptMode(): "immediate" | "wait" {
return this.settings.interruptMode || "immediate";
}
setInterruptMode(mode: "immediate" | "wait"): void {
async setInterruptMode(mode: "immediate" | "wait"): Promise<void> {
this.globalSettings.interruptMode = mode;
this.save();
await this.save();
}
getTheme(): string | undefined {
return this.settings.theme;
}
setTheme(theme: string): void {
async setTheme(theme: string): Promise<void> {
this.globalSettings.theme = theme;
this.save();
await this.save();
}
getSymbolPreset(): SymbolPreset | undefined {
return this.settings.symbolPreset;
}
setSymbolPreset(preset: SymbolPreset): void {
async setSymbolPreset(preset: SymbolPreset): Promise<void> {
this.globalSettings.symbolPreset = preset;
this.save();
await this.save();
}
getDefaultThinkingLevel(): "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | undefined {
return this.settings.defaultThinkingLevel;
}
setDefaultThinkingLevel(level: "off" | "minimal" | "low" | "medium" | "high" | "xhigh"): void {
async setDefaultThinkingLevel(level: "off" | "minimal" | "low" | "medium" | "high" | "xhigh"): Promise<void> {
this.globalSettings.defaultThinkingLevel = level;
this.save();
await this.save();
}
getCompactionEnabled(): boolean {
return this.settings.compaction?.enabled ?? true;
}
setCompactionEnabled(enabled: boolean): void {
async setCompactionEnabled(enabled: boolean): Promise<void> {
if (!this.globalSettings.compaction) {
this.globalSettings.compaction = {};
}
this.globalSettings.compaction.enabled = enabled;
this.save();
await this.save();
}
getCompactionReserveTokens(): number {
@@ -714,12 +731,12 @@ export class SettingsManager {
return this.settings.branchSummary?.enabled ?? false;
}
setBranchSummaryEnabled(enabled: boolean): void {
async setBranchSummaryEnabled(enabled: boolean): Promise<void> {
if (!this.globalSettings.branchSummary) {
this.globalSettings.branchSummary = {};
}
this.globalSettings.branchSummary.enabled = enabled;
this.save();
await this.save();
}
getBranchSummarySettings(): { enabled: boolean; reserveTokens: number } {
@@ -733,12 +750,12 @@ export class SettingsManager {
return this.settings.retry?.enabled ?? true;
}
setRetryEnabled(enabled: boolean): void {
async setRetryEnabled(enabled: boolean): Promise<void> {
if (!this.globalSettings.retry) {
this.globalSettings.retry = {};
}
this.globalSettings.retry.enabled = enabled;
this.save();
await this.save();
}
getRetrySettings(): { enabled: boolean; maxRetries: number; baseDelayMs: number } {
@@ -757,48 +774,48 @@ export class SettingsManager {
return this.settings.hideThinkingBlock ?? false;
}
setHideThinkingBlock(hide: boolean): void {
async setHideThinkingBlock(hide: boolean): Promise<void> {
this.globalSettings.hideThinkingBlock = hide;
this.save();
await this.save();
}
getShellPath(): string | undefined {
return this.settings.shellPath;
}
setShellPath(path: string | undefined): void {
async setShellPath(path: string | undefined): Promise<void> {
this.globalSettings.shellPath = path;
this.save();
await this.save();
}
getCollapseChangelog(): boolean {
return this.settings.collapseChangelog ?? false;
}
setCollapseChangelog(collapse: boolean): void {
async setCollapseChangelog(collapse: boolean): Promise<void> {
this.globalSettings.collapseChangelog = collapse;
this.save();
await this.save();
}
getExtensionPaths(): string[] {
return [...(this.settings.extensions ?? [])];
}
setExtensionPaths(paths: string[]): void {
async setExtensionPaths(paths: string[]): Promise<void> {
this.globalSettings.extensions = paths;
this.save();
await this.save();
}
getSkillsEnabled(): boolean {
return this.settings.skills?.enabled ?? true;
}
setSkillsEnabled(enabled: boolean): void {
async setSkillsEnabled(enabled: boolean): Promise<void> {
if (!this.globalSettings.skills) {
this.globalSettings.skills = {};
}
this.globalSettings.skills.enabled = enabled;
this.save();
await this.save();
}
getSkillsSettings(): Required<SkillsSettings> {
@@ -826,48 +843,48 @@ export class SettingsManager {
return this.settings.terminal?.showImages ?? true;
}
setShowImages(show: boolean): void {
async setShowImages(show: boolean): Promise<void> {
if (!this.globalSettings.terminal) {
this.globalSettings.terminal = {};
}
this.globalSettings.terminal.showImages = show;
this.save();
await this.save();
}
getNotificationOnComplete(): NotificationMethod {
return this.settings.notifications?.onComplete ?? "auto";
}
setNotificationOnComplete(method: NotificationMethod): void {
async setNotificationOnComplete(method: NotificationMethod): Promise<void> {
if (!this.globalSettings.notifications) {
this.globalSettings.notifications = {};
}
this.globalSettings.notifications.onComplete = method;
this.save();
await this.save();
}
getImageAutoResize(): boolean {
return this.settings.images?.autoResize ?? true;
}
setImageAutoResize(enabled: boolean): void {
async setImageAutoResize(enabled: boolean): Promise<void> {
if (!this.globalSettings.images) {
this.globalSettings.images = {};
}
this.globalSettings.images.autoResize = enabled;
this.save();
await this.save();
}
getBlockImages(): boolean {
return this.settings.images?.blockImages ?? false;
}
setBlockImages(blocked: boolean): void {
async setBlockImages(blocked: boolean): Promise<void> {
if (!this.globalSettings.images) {
this.globalSettings.images = {};
}
this.globalSettings.images.blockImages = blocked;
this.save();
await this.save();
}
getEnabledModels(): string[] | undefined {
@@ -885,52 +902,52 @@ export class SettingsManager {
};
}
setExaEnabled(enabled: boolean): void {
async setExaEnabled(enabled: boolean): Promise<void> {
if (!this.globalSettings.exa) {
this.globalSettings.exa = {};
}
this.globalSettings.exa.enabled = enabled;
this.save();
await this.save();
}
setExaSearchEnabled(enabled: boolean): void {
async setExaSearchEnabled(enabled: boolean): Promise<void> {
if (!this.globalSettings.exa) {
this.globalSettings.exa = {};
}
this.globalSettings.exa.enableSearch = enabled;
this.save();
await this.save();
}
setExaLinkedinEnabled(enabled: boolean): void {
async setExaLinkedinEnabled(enabled: boolean): Promise<void> {
if (!this.globalSettings.exa) {
this.globalSettings.exa = {};
}
this.globalSettings.exa.enableLinkedin = enabled;
this.save();
await this.save();
}
setExaCompanyEnabled(enabled: boolean): void {
async setExaCompanyEnabled(enabled: boolean): Promise<void> {
if (!this.globalSettings.exa) {
this.globalSettings.exa = {};
}
this.globalSettings.exa.enableCompany = enabled;
this.save();
await this.save();
}
setExaResearcherEnabled(enabled: boolean): void {
async setExaResearcherEnabled(enabled: boolean): Promise<void> {
if (!this.globalSettings.exa) {
this.globalSettings.exa = {};
}
this.globalSettings.exa.enableResearcher = enabled;
this.save();
await this.save();
}
setExaWebsetsEnabled(enabled: boolean): void {
async setExaWebsetsEnabled(enabled: boolean): Promise<void> {
if (!this.globalSettings.exa) {
this.globalSettings.exa = {};
}
this.globalSettings.exa.enableWebsets = enabled;
this.save();
await this.save();
}
// Provider settings
@@ -938,24 +955,24 @@ export class SettingsManager {
return this.settings.providers?.webSearch ?? "auto";
}
setWebSearchProvider(provider: WebSearchProviderOption): void {
async setWebSearchProvider(provider: WebSearchProviderOption): Promise<void> {
if (!this.globalSettings.providers) {
this.globalSettings.providers = {};
}
this.globalSettings.providers.webSearch = provider;
this.save();
await this.save();
}
getImageProvider(): ImageProviderOption {
return this.settings.providers?.image ?? "auto";
}
setImageProvider(provider: ImageProviderOption): void {
async setImageProvider(provider: ImageProviderOption): Promise<void> {
if (!this.globalSettings.providers) {
this.globalSettings.providers = {};
}
this.globalSettings.providers.image = provider;
this.save();
await this.save();
}
getBashInterceptorEnabled(): boolean {
@@ -970,124 +987,124 @@ export class SettingsManager {
return [...(this.settings.bashInterceptor?.patterns ?? DEFAULT_BASH_INTERCEPTOR_RULES)];
}
setBashInterceptorEnabled(enabled: boolean): void {
async setBashInterceptorEnabled(enabled: boolean): Promise<void> {
if (!this.globalSettings.bashInterceptor) {
this.globalSettings.bashInterceptor = {};
}
this.globalSettings.bashInterceptor.enabled = enabled;
this.save();
await this.save();
}
getGitToolEnabled(): boolean {
return this.settings.git?.enabled ?? false;
}
setGitToolEnabled(enabled: boolean): void {
async setGitToolEnabled(enabled: boolean): Promise<void> {
if (!this.globalSettings.git) {
this.globalSettings.git = {};
}
this.globalSettings.git.enabled = enabled;
this.save();
await this.save();
}
getMCPProjectConfigEnabled(): boolean {
return this.settings.mcp?.enableProjectConfig ?? true;
}
setMCPProjectConfigEnabled(enabled: boolean): void {
async setMCPProjectConfigEnabled(enabled: boolean): Promise<void> {
if (!this.globalSettings.mcp) {
this.globalSettings.mcp = {};
}
this.globalSettings.mcp.enableProjectConfig = enabled;
this.save();
await this.save();
}
getLspFormatOnWrite(): boolean {
return this.settings.lsp?.formatOnWrite ?? false;
}
setLspFormatOnWrite(enabled: boolean): void {
async setLspFormatOnWrite(enabled: boolean): Promise<void> {
if (!this.globalSettings.lsp) {
this.globalSettings.lsp = {};
}
this.globalSettings.lsp.formatOnWrite = enabled;
this.save();
await this.save();
}
getLspDiagnosticsOnWrite(): boolean {
return this.settings.lsp?.diagnosticsOnWrite ?? true;
}
setLspDiagnosticsOnWrite(enabled: boolean): void {
async setLspDiagnosticsOnWrite(enabled: boolean): Promise<void> {
if (!this.globalSettings.lsp) {
this.globalSettings.lsp = {};
}
this.globalSettings.lsp.diagnosticsOnWrite = enabled;
this.save();
await this.save();
}
getLspDiagnosticsOnEdit(): boolean {
return this.settings.lsp?.diagnosticsOnEdit ?? false;
}
setLspDiagnosticsOnEdit(enabled: boolean): void {
async setLspDiagnosticsOnEdit(enabled: boolean): Promise<void> {
if (!this.globalSettings.lsp) {
this.globalSettings.lsp = {};
}
this.globalSettings.lsp.diagnosticsOnEdit = enabled;
this.save();
await this.save();
}
getEditFuzzyMatch(): boolean {
return this.settings.edit?.fuzzyMatch ?? true;
}
setEditFuzzyMatch(enabled: boolean): void {
async setEditFuzzyMatch(enabled: boolean): Promise<void> {
if (!this.globalSettings.edit) {
this.globalSettings.edit = {};
}
this.globalSettings.edit.fuzzyMatch = enabled;
this.save();
await this.save();
}
getDisabledProviders(): string[] {
return [...(this.settings.disabledProviders ?? [])];
}
setDisabledProviders(providerIds: string[]): void {
async setDisabledProviders(providerIds: string[]): Promise<void> {
this.globalSettings.disabledProviders = providerIds;
this.save();
await this.save();
}
getDisabledExtensions(): string[] {
return [...(this.settings.disabledExtensions ?? [])];
}
setDisabledExtensions(extensionIds: string[]): void {
async setDisabledExtensions(extensionIds: string[]): Promise<void> {
this.globalSettings.disabledExtensions = extensionIds;
this.save();
await this.save();
}
isExtensionEnabled(extensionId: string): boolean {
return !(this.settings.disabledExtensions ?? []).includes(extensionId);
}
enableExtension(extensionId: string): void {
async enableExtension(extensionId: string): Promise<void> {
const disabled = this.globalSettings.disabledExtensions ?? [];
const index = disabled.indexOf(extensionId);
if (index !== -1) {
disabled.splice(index, 1);
this.globalSettings.disabledExtensions = disabled;
this.save();
await this.save();
}
}
disableExtension(extensionId: string): void {
async disableExtension(extensionId: string): Promise<void> {
const disabled = this.globalSettings.disabledExtensions ?? [];
if (!disabled.includes(extensionId)) {
disabled.push(extensionId);
this.globalSettings.disabledExtensions = disabled;
this.save();
await this.save();
}
}
@@ -1095,57 +1112,57 @@ export class SettingsManager {
return this.settings.ttsr ?? {};
}
setTtsrSettings(settings: TtsrSettings): void {
async setTtsrSettings(settings: TtsrSettings): Promise<void> {
this.globalSettings.ttsr = { ...this.globalSettings.ttsr, ...settings };
this.save();
await this.save();
}
getTtsrEnabled(): boolean {
return this.settings.ttsr?.enabled ?? true;
}
setTtsrEnabled(enabled: boolean): void {
async setTtsrEnabled(enabled: boolean): Promise<void> {
if (!this.globalSettings.ttsr) {
this.globalSettings.ttsr = {};
}
this.globalSettings.ttsr.enabled = enabled;
this.save();
await this.save();
}
getTtsrContextMode(): "keep" | "discard" {
return this.settings.ttsr?.contextMode ?? "discard";
}
setTtsrContextMode(mode: "keep" | "discard"): void {
async setTtsrContextMode(mode: "keep" | "discard"): Promise<void> {
if (!this.globalSettings.ttsr) {
this.globalSettings.ttsr = {};
}
this.globalSettings.ttsr.contextMode = mode;
this.save();
await this.save();
}
getTtsrRepeatMode(): "once" | "after-gap" {
return this.settings.ttsr?.repeatMode ?? "once";
}
setTtsrRepeatMode(mode: "once" | "after-gap"): void {
async setTtsrRepeatMode(mode: "once" | "after-gap"): Promise<void> {
if (!this.globalSettings.ttsr) {
this.globalSettings.ttsr = {};
}
this.globalSettings.ttsr.repeatMode = mode;
this.save();
await this.save();
}
getTtsrRepeatGap(): number {
return this.settings.ttsr?.repeatGap ?? 10;
}
setTtsrRepeatGap(gap: number): void {
async setTtsrRepeatGap(gap: number): Promise<void> {
if (!this.globalSettings.ttsr) {
this.globalSettings.ttsr = {};
}
this.globalSettings.ttsr.repeatGap = gap;
this.save();
await this.save();
}
getVoiceSettings(): Required<VoiceSettings> {
@@ -1159,21 +1176,21 @@ export class SettingsManager {
};
}
setVoiceSettings(settings: VoiceSettings): void {
async setVoiceSettings(settings: VoiceSettings): Promise<void> {
this.globalSettings.voice = { ...this.globalSettings.voice, ...settings };
this.save();
await this.save();
}
getVoiceEnabled(): boolean {
return this.settings.voice?.enabled ?? false;
}
setVoiceEnabled(enabled: boolean): void {
async setVoiceEnabled(enabled: boolean): Promise<void> {
if (!this.globalSettings.voice) {
this.globalSettings.voice = {};
}
this.globalSettings.voice.enabled = enabled;
this.save();
await this.save();
}
// ═══════════════════════════════════════════════════════════════════════════
@@ -1188,7 +1205,7 @@ export class SettingsManager {
return this.settings.statusLine?.preset ?? "default";
}
setStatusLinePreset(preset: StatusLinePreset): void {
async setStatusLinePreset(preset: StatusLinePreset): Promise<void> {
if (!this.globalSettings.statusLine) {
this.globalSettings.statusLine = {};
}
@@ -1198,26 +1215,26 @@ export class SettingsManager {
delete this.globalSettings.statusLine.segmentOptions;
}
this.globalSettings.statusLine.preset = preset;
this.save();
await this.save();
}
getStatusLineSeparator(): StatusLineSeparatorStyle {
return this.settings.statusLine?.separator ?? "powerline-thin";
}
setStatusLineSeparator(separator: StatusLineSeparatorStyle): void {
async setStatusLineSeparator(separator: StatusLineSeparatorStyle): Promise<void> {
if (!this.globalSettings.statusLine) {
this.globalSettings.statusLine = {};
}
this.globalSettings.statusLine.separator = separator;
this.save();
await this.save();
}
getStatusLineLeftSegments(): StatusLineSegmentId[] {
return [...(this.settings.statusLine?.leftSegments ?? [])];
}
setStatusLineLeftSegments(segments: StatusLineSegmentId[]): void {
async setStatusLineLeftSegments(segments: StatusLineSegmentId[]): Promise<void> {
if (!this.globalSettings.statusLine) {
this.globalSettings.statusLine = {};
}
@@ -1226,14 +1243,14 @@ export class SettingsManager {
if (this.globalSettings.statusLine.preset !== "custom") {
this.globalSettings.statusLine.preset = "custom";
}
this.save();
await this.save();
}
getStatusLineRightSegments(): StatusLineSegmentId[] {
return [...(this.settings.statusLine?.rightSegments ?? [])];
}
setStatusLineRightSegments(segments: StatusLineSegmentId[]): void {
async setStatusLineRightSegments(segments: StatusLineSegmentId[]): Promise<void> {
if (!this.globalSettings.statusLine) {
this.globalSettings.statusLine = {};
}
@@ -1242,18 +1259,18 @@ export class SettingsManager {
if (this.globalSettings.statusLine.preset !== "custom") {
this.globalSettings.statusLine.preset = "custom";
}
this.save();
await this.save();
}
getStatusLineSegmentOptions(): StatusLineSegmentOptions {
return { ...this.settings.statusLine?.segmentOptions };
}
setStatusLineSegmentOption<K extends keyof StatusLineSegmentOptions>(
async setStatusLineSegmentOption<K extends keyof StatusLineSegmentOptions>(
segment: K,
option: keyof NonNullable<StatusLineSegmentOptions[K]>,
value: boolean | number | string,
): void {
): Promise<void> {
if (!this.globalSettings.statusLine) {
this.globalSettings.statusLine = {};
}
@@ -1264,13 +1281,13 @@ export class SettingsManager {
this.globalSettings.statusLine.segmentOptions[segment] = {} as NonNullable<StatusLineSegmentOptions[K]>;
}
(this.globalSettings.statusLine.segmentOptions[segment] as Record<string, unknown>)[option as string] = value;
this.save();
await this.save();
}
clearStatusLineSegmentOption<K extends keyof StatusLineSegmentOptions>(
async clearStatusLineSegmentOption<K extends keyof StatusLineSegmentOptions>(
segment: K,
option: keyof NonNullable<StatusLineSegmentOptions[K]>,
): void {
): Promise<void> {
const segmentOptions = this.globalSettings.statusLine?.segmentOptions;
if (!segmentOptions || !segmentOptions[segment]) {
return;
@@ -1282,28 +1299,28 @@ export class SettingsManager {
if (Object.keys(segmentOptions).length === 0) {
delete this.globalSettings.statusLine?.segmentOptions;
}
this.save();
await this.save();
}
getStatusLineShowHookStatus(): boolean {
return this.settings.statusLine?.showHookStatus ?? true;
}
setStatusLineShowHookStatus(show: boolean): void {
async setStatusLineShowHookStatus(show: boolean): Promise<void> {
if (!this.globalSettings.statusLine) {
this.globalSettings.statusLine = {};
}
this.globalSettings.statusLine.showHookStatus = show;
this.save();
await this.save();
}
getDoubleEscapeAction(): "branch" | "tree" {
return this.settings.doubleEscapeAction ?? "tree";
}
setDoubleEscapeAction(action: "branch" | "tree"): void {
async setDoubleEscapeAction(action: "branch" | "tree"): Promise<void> {
this.globalSettings.doubleEscapeAction = action;
this.save();
await this.save();
}
/**
@@ -1317,37 +1334,37 @@ export class SettingsManager {
* Set environment variables in settings (not process.env)
* This will be applied on next startup or reload
*/
setEnvironmentVariables(envVars: Record<string, string>): void {
async setEnvironmentVariables(envVars: Record<string, string>): Promise<void> {
this.globalSettings.env = { ...envVars };
this.save();
await this.save();
}
/**
* Clear all environment variables from settings
*/
clearEnvironmentVariables(): void {
async clearEnvironmentVariables(): Promise<void> {
delete this.globalSettings.env;
this.save();
await this.save();
}
/**
* Set a single environment variable in settings
*/
setEnvironmentVariable(key: string, value: string): void {
async setEnvironmentVariable(key: string, value: string): Promise<void> {
if (!this.globalSettings.env) {
this.globalSettings.env = {};
}
this.globalSettings.env[key] = value;
this.save();
await this.save();
}
/**
* Remove a single environment variable from settings
*/
removeEnvironmentVariable(key: string): void {
async removeEnvironmentVariable(key: string): Promise<void> {
if (this.globalSettings.env) {
delete this.globalSettings.env[key];
this.save();
await this.save();
}
}
}
+72 -51
View File
@@ -1,10 +1,11 @@
import { readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
import { readdirSync, readFileSync, statSync } from "node:fs";
import { realpath } from "node:fs/promises";
import { basename, join } from "node:path";
import { minimatch } from "minimatch";
import { skillCapability } from "../capability/skill";
import type { SourceMeta } from "../capability/types";
import type { Skill as CapabilitySkill, SkillFrontmatter as ImportedSkillFrontmatter } from "../discovery";
import { loadSync } from "../discovery";
import { loadCapability } from "../discovery";
import { parseFrontmatter } from "../discovery/helpers";
import type { SkillsSettings } from "./settings-manager";
@@ -215,7 +216,7 @@ export interface LoadSkillsOptions extends SkillsSettings {
* Load skills from all configured locations.
* Returns skills and any validation warnings.
*/
export function loadSkills(options: LoadSkillsOptions = {}): LoadSkillsResult {
export async function loadSkills(options: LoadSkillsOptions = {}): Promise<LoadSkillsResult> {
const {
cwd = process.cwd(),
enabled = true,
@@ -247,7 +248,7 @@ export function loadSkills(options: LoadSkillsOptions = {}): LoadSkillsResult {
}
// Use capability API to load all skills
const result = loadSync<CapabilitySkill>(skillCapability.id, { cwd });
const result = await loadCapability<CapabilitySkill>(skillCapability.id, { cwd });
const skillMap = new Map<string, Skill>();
const realPathSet = new Set<string>();
@@ -265,28 +266,33 @@ export function loadSkills(options: LoadSkillsOptions = {}): LoadSkillsResult {
return ignoredSkills.some((pattern) => minimatch(name, pattern));
}
// Helper to add a skill to the map
function addSkill(capSkill: CapabilitySkill, sourceProvider: string) {
// Apply ignore filter (glob patterns) - takes precedence over include
if (matchesIgnorePatterns(capSkill.name)) {
return;
}
// Apply include filter (glob patterns)
if (!matchesIncludePatterns(capSkill.name)) {
return;
}
// Filter skills by source and patterns first
const filteredSkills = result.items.filter((capSkill) => {
if (!isSourceEnabled(capSkill._source)) return false;
if (matchesIgnorePatterns(capSkill.name)) return false;
if (!matchesIncludePatterns(capSkill.name)) return false;
return true;
});
// Resolve symlinks to detect duplicate files
let realPath: string;
try {
realPath = realpathSync(capSkill.path);
} catch {
realPath = capSkill.path;
}
// Batch resolve all real paths in parallel
const realPaths = await Promise.all(
filteredSkills.map(async (capSkill) => {
try {
return await realpath(capSkill.path);
} catch {
return capSkill.path;
}
}),
);
// Process skills with resolved paths
for (let i = 0; i < filteredSkills.length; i++) {
const capSkill = filteredSkills[i];
const resolvedPath = realPaths[i];
// Skip silently if we've already loaded this exact file (via symlink)
if (realPathSet.has(realPath)) {
return;
if (realPathSet.has(resolvedPath)) {
continue;
}
const existing = skillMap.get(capSkill.name);
@@ -302,46 +308,61 @@ export function loadSkills(options: LoadSkillsOptions = {}): LoadSkillsResult {
description: capSkill.frontmatter?.description || "",
filePath: capSkill.path,
baseDir: capSkill.path.replace(/\/SKILL\.md$/, ""),
source: `${sourceProvider}:${capSkill.level}`,
source: `${capSkill._source.provider}:${capSkill.level}`,
_source: capSkill._source,
};
skillMap.set(capSkill.name, skill);
realPathSet.add(realPath);
realPathSet.add(resolvedPath);
}
}
// Process skills from capability API
for (const capSkill of result.items) {
// Check if this source is enabled
if (!isSourceEnabled(capSkill._source)) {
continue;
}
addSkill(capSkill, capSkill._source.provider);
}
// Process custom directories - scan directly without using full provider system
const allCustomSkills: Array<{ skill: Skill; path: string }> = [];
for (const dir of customDirectories) {
const customSkills = scanDirectoryForSkills(dir);
for (const s of customSkills.skills) {
// Convert to capability format for addSkill processing
const capSkill: CapabilitySkill = {
name: s.name,
path: s.filePath,
content: "",
frontmatter: { description: s.description },
level: "user",
_source: {
provider: "custom",
providerName: "Custom",
path: s.filePath,
level: "user",
if (matchesIgnorePatterns(s.name)) continue;
if (!matchesIncludePatterns(s.name)) continue;
allCustomSkills.push({
skill: {
name: s.name,
description: s.description,
filePath: s.filePath,
baseDir: s.filePath.replace(/\/SKILL\.md$/, ""),
source: "custom:user",
_source: { provider: "custom", providerName: "Custom", path: s.filePath, level: "user" },
},
};
addSkill(capSkill, "custom");
path: s.filePath,
});
}
for (const warning of customSkills.warnings) {
collisionWarnings.push(warning);
collisionWarnings.push(...customSkills.warnings);
}
// Batch resolve custom skill paths
const customRealPaths = await Promise.all(
allCustomSkills.map(async ({ path }) => {
try {
return await realpath(path);
} catch {
return path;
}
}),
);
for (let i = 0; i < allCustomSkills.length; i++) {
const { skill } = allCustomSkills[i];
const resolvedPath = customRealPaths[i];
if (realPathSet.has(resolvedPath)) continue;
const existing = skillMap.get(skill.name);
if (existing) {
collisionWarnings.push({
skillPath: skill.filePath,
message: `name collision: "${skill.name}" already loaded from ${existing.filePath}, skipping this one`,
});
} else {
skillMap.set(skill.name, skill);
realPathSet.add(resolvedPath);
}
}
@@ -1,6 +1,6 @@
import { slashCommandCapability } from "../capability/slash-command";
import type { SlashCommand } from "../discovery";
import { loadSync } from "../discovery";
import { loadCapability } from "../discovery";
import { parseFrontmatter } from "../discovery/helpers";
import { renderPromptTemplate } from "./prompt-templates";
import { EMBEDDED_COMMAND_TEMPLATES } from "./tools/task/commands";
@@ -108,8 +108,8 @@ export interface LoadSlashCommandsOptions {
* Load all custom slash commands using the capability API.
* Loads from all registered providers (builtin, user, project).
*/
export function loadSlashCommands(options: LoadSlashCommandsOptions = {}): FileSlashCommand[] {
const result = loadSync<SlashCommand>(slashCommandCapability.id, { cwd: options.cwd });
export async function loadSlashCommands(options: LoadSlashCommandsOptions = {}): Promise<FileSlashCommand[]> {
const result = await loadCapability<SlashCommand>(slashCommandCapability.id, { cwd: options.cwd });
const fileCommands: FileSlashCommand[] = result.items.map((cmd) => {
const { description, body } = parseCommandTemplate(cmd.content);
+10 -10
View File
@@ -8,7 +8,7 @@ import { join } from "node:path";
import chalk from "chalk";
import { contextFileCapability } from "../capability/context-file";
import { systemPromptCapability } from "../capability/system-prompt";
import { type ContextFile, loadSync, type SystemPrompt as SystemPromptFile } from "../discovery/index";
import { type ContextFile, loadCapability, type SystemPrompt as SystemPromptFile } from "../discovery/index";
import customSystemPromptTemplate from "../prompts/system/custom-system-prompt.md" with { type: "text" };
import systemPromptTemplate from "../prompts/system/system-prompt.md" with { type: "text" };
import { renderPromptTemplate } from "./prompt-templates";
@@ -558,12 +558,12 @@ export interface LoadContextFilesOptions {
* Returns {path, content, depth} entries for all discovered context files.
* Files are sorted by depth (descending) so files closer to cwd appear last/more prominent.
*/
export function loadProjectContextFiles(
export async function loadProjectContextFiles(
options: LoadContextFilesOptions = {},
): Array<{ path: string; content: string; depth?: number }> {
): Promise<Array<{ path: string; content: string; depth?: number }>> {
const resolvedCwd = options.cwd ?? process.cwd();
const result = loadSync(contextFileCapability.id, { cwd: resolvedCwd });
const result = await loadCapability(contextFileCapability.id, { cwd: resolvedCwd });
// Convert ContextFile items and preserve depth info
const files = result.items.map((item) => {
@@ -590,10 +590,10 @@ export function loadProjectContextFiles(
* Load system prompt customization files (SYSTEM.md).
* Returns combined content from all discovered SYSTEM.md files.
*/
export function loadSystemPromptFiles(options: LoadContextFilesOptions = {}): string | null {
export async function loadSystemPromptFiles(options: LoadContextFilesOptions = {}): Promise<string | null> {
const resolvedCwd = options.cwd ?? process.cwd();
const result = loadSync<SystemPromptFile>(systemPromptCapability.id, { cwd: resolvedCwd });
const result = await loadCapability<SystemPromptFile>(systemPromptCapability.id, { cwd: resolvedCwd });
if (result.items.length === 0) return null;
@@ -631,7 +631,7 @@ export interface BuildSystemPromptOptions {
}
/** Build the system prompt with tools, guidelines, and context */
export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): string {
export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): Promise<string> {
const {
customPrompt,
tools,
@@ -648,7 +648,7 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
const resolvedAppendPrompt = resolvePromptInput(appendSystemPrompt, "append system prompt");
// Load SYSTEM.md customization (prepended to prompt)
const systemPromptCustomization = loadSystemPromptFiles({ cwd: resolvedCwd });
const systemPromptCustomization = await loadSystemPromptFiles({ cwd: resolvedCwd });
const now = new Date();
const dateTime = now.toLocaleString("en-US", {
@@ -663,7 +663,7 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
});
// Resolve context files: use provided or discover
const contextFiles = providedContextFiles ?? loadProjectContextFiles({ cwd: resolvedCwd });
const contextFiles = providedContextFiles ?? (await loadProjectContextFiles({ cwd: resolvedCwd }));
const agentsMdSearch = buildAgentsMdSearch(resolvedCwd);
// Build tool descriptions array
@@ -688,7 +688,7 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
// Resolve skills: use provided or discover
const skills =
providedSkills ??
(skillsSettings?.enabled !== false ? loadSkills({ ...skillsSettings, cwd: resolvedCwd }).skills : []);
(skillsSettings?.enabled !== false ? (await loadSkills({ ...skillsSettings, cwd: resolvedCwd })).skills : []);
// Get git context
const git = loadGitContext(resolvedCwd);
+6 -6
View File
@@ -4,7 +4,7 @@ import { Text } from "@oh-my-pi/pi-tui";
import { Type } from "@sinclair/typebox";
import type { SSHHost } from "../../capability/ssh";
import { sshCapability } from "../../capability/ssh";
import { loadSync } from "../../discovery/index";
import { loadCapability } from "../../discovery/index";
import type { Theme } from "../../modes/interactive/theme/theme";
import sshDescriptionBase from "../../prompts/tools/ssh.md" with { type: "text" };
import type { RenderResultOptions } from "../custom-tools/types";
@@ -97,11 +97,11 @@ function buildRemoteCommand(command: string, cwd: string | undefined, info: SSHH
return `cd -- ${quoteRemotePath(cwd)} && ${command}`;
}
function loadHosts(session: ToolSession): {
async function loadHosts(session: ToolSession): Promise<{
hostNames: string[];
hostsByName: Map<string, SSHHost>;
} {
const result = loadSync<SSHHost>(sshCapability.id, { cwd: session.cwd });
}> {
const result = await loadCapability<SSHHost>(sshCapability.id, { cwd: session.cwd });
const hostsByName = new Map<string, SSHHost>();
for (const host of result.items) {
if (!hostsByName.has(host.name)) {
@@ -112,8 +112,8 @@ function loadHosts(session: ToolSession): {
return { hostNames, hostsByName };
}
export function createSshTool(session: ToolSession): AgentTool<typeof sshSchema> | null {
const { hostNames, hostsByName } = loadHosts(session);
export async function createSshTool(session: ToolSession): Promise<AgentTool<typeof sshSchema> | null> {
const { hostNames, hostsByName } = await loadHosts(session);
if (hostNames.length === 0) {
return null;
}
@@ -6,7 +6,7 @@
import * as path from "node:path";
import { type SlashCommand, slashCommandCapability } from "../../../capability/slash-command";
import { loadSync } from "../../../discovery";
import { loadCapability } from "../../../discovery";
// Embed command markdown files at build time
import initMd from "../../../prompts/agents/init.md" with { type: "text" };
@@ -95,11 +95,11 @@ export function loadBundledCommands(): WorkflowCommand[] {
*
* Precedence (highest wins): .omp > .pi > .claude (project before user), then bundled
*/
export function discoverCommands(cwd: string): WorkflowCommand[] {
export async function discoverCommands(cwd: string): Promise<WorkflowCommand[]> {
const resolvedCwd = path.resolve(cwd);
// Load slash commands from capability API
const result = loadSync<SlashCommand>(slashCommandCapability.id, { cwd: resolvedCwd });
const result = await loadCapability<SlashCommand>(slashCommandCapability.id, { cwd: resolvedCwd });
const commands: WorkflowCommand[] = [];
const seen = new Set<string>();
@@ -255,7 +255,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
}
// Resolve and add model
const resolvedModel = resolveModelPattern(modelOverride || agent.model);
const resolvedModel = await resolveModelPattern(modelOverride || agent.model);
const sessionFile = subtaskSessionFile ?? options.sessionFile ?? null;
const spawnsEnv = agent.spawns === undefined ? "" : agent.spawns === "*" ? "*" : agent.spawns.join(",");
@@ -12,7 +12,7 @@
*/
import { type Settings, settingsCapability } from "../../../capability/settings";
import { loadSync } from "../../../discovery";
import { loadCapability } from "../../../discovery";
import { resolveOmpCommand } from "./omp-command";
/** Cache for available models (provider/modelId format) */
@@ -79,8 +79,8 @@ export function clearModelCache(): void {
/**
* Load model roles from settings files using capability API.
*/
function loadModelRoles(): Record<string, string> {
const result = loadSync<Settings>(settingsCapability.id, { cwd: process.cwd() });
async function loadModelRoles(): Promise<Record<string, string>> {
const result = await loadCapability<Settings>(settingsCapability.id, { cwd: process.cwd() });
// Merge all settings, prioritizing first (highest priority)
let modelRoles: Record<string, string> = {};
@@ -99,8 +99,8 @@ function loadModelRoles(): Record<string, string> {
* Looks up the role in settings.modelRoles and returns the configured model.
* Returns undefined if the role isn't configured.
*/
function resolveOmpAlias(role: string, availableModels: string[]): string | undefined {
const roles = loadModelRoles();
async function resolveOmpAlias(role: string, availableModels: string[]): Promise<string | undefined> {
const roles = await loadModelRoles();
// Look up role in settings (case-insensitive)
const configured = roles[role] || roles[role.toLowerCase()];
@@ -127,7 +127,10 @@ function getModelId(fullModel: string): string {
* @param pattern - Model pattern to resolve
* @param availableModels - Optional pre-fetched list of available models (in provider/modelId format)
*/
export function resolveModelPattern(pattern: string | undefined, availableModels?: string[]): string | undefined {
export async function resolveModelPattern(
pattern: string | undefined,
availableModels?: string[],
): Promise<string | undefined> {
if (!pattern || pattern === "default") {
return undefined;
}
@@ -149,7 +152,7 @@ export function resolveModelPattern(pattern: string | undefined, availableModels
const lower = p.toLowerCase();
if (lower.startsWith("omp/") || lower.startsWith("pi/")) {
const role = lower.startsWith("omp/") ? p.slice(4) : p.slice(3);
const resolved = resolveOmpAlias(role, models);
const resolved = await resolveOmpAlias(role, models);
if (resolved) return resolved;
continue; // Role not configured, try next pattern
}
@@ -19,8 +19,8 @@ import type { TSchema } from "@sinclair/typebox";
import type { AgentSessionEvent } from "../../agent-session";
import { AuthStorage } from "../../auth-storage";
import type { CustomTool } from "../../custom-tools/types";
import { parseModelPattern, parseModelString } from "../../model-resolver";
import { ModelRegistry } from "../../model-registry";
import { parseModelPattern, parseModelString } from "../../model-resolver";
import { createAgentSession, discoverAuthStorage, discoverModels } from "../../sdk";
import { SessionManager } from "../../session-manager";
import { untilAborted } from "../../utils";
@@ -127,16 +127,28 @@ function createMCPProxyTool(metadata: MCPToolMetadata): CustomTool<TSchema> {
parameters: metadata.parameters as TSchema,
execute: async (_toolCallId, params, _onUpdate, _ctx, signal) => {
try {
const result = await callMCPToolViaParent(metadata.name, params as Record<string, unknown>, signal, metadata.timeoutMs);
const result = await callMCPToolViaParent(
metadata.name,
params as Record<string, unknown>,
signal,
metadata.timeoutMs,
);
return {
content: result.content.map((c) =>
c.type === "text" ? { type: "text" as const, text: c.text ?? "" } : { type: "text" as const, text: JSON.stringify(c) },
c.type === "text"
? { type: "text" as const, text: c.text ?? "" }
: { type: "text" as const, text: JSON.stringify(c) },
),
details: { serverName: metadata.serverName, mcpToolName: metadata.mcpToolName, isError: result.isError },
};
} catch (error) {
return {
content: [{ type: "text" as const, text: `MCP error: ${error instanceof Error ? error.message : String(error)}` }],
content: [
{
type: "text" as const,
text: `MCP error: ${error instanceof Error ? error.message : String(error)}`,
},
],
details: { serverName: metadata.serverName, mcpToolName: metadata.mcpToolName, isError: true },
};
}
@@ -8,6 +8,7 @@
import { dirname, join, sep } from "node:path";
import { type ContextFile, contextFileCapability } from "../capability/context-file";
import { readFile } from "../capability/fs";
import { registerProvider } from "../capability/index";
import type { LoadContext, LoadResult } from "../capability/types";
import { calculateDepth, createSourceMeta } from "./helpers";
@@ -19,7 +20,7 @@ const MAX_DEPTH = 20; // Prevent walking up excessively far from cwd
/**
* Load standalone AGENTS.md files.
*/
function loadAgentsMd(ctx: LoadContext): LoadResult<ContextFile> {
async function loadAgentsMd(ctx: LoadContext): Promise<LoadResult<ContextFile>> {
const items: ContextFile[] = [];
const warnings: string[] = [];
@@ -29,30 +30,23 @@ function loadAgentsMd(ctx: LoadContext): LoadResult<ContextFile> {
while (depth < MAX_DEPTH) {
const candidate = join(current, "AGENTS.md");
const content = await readFile(candidate);
if (ctx.fs.isFile(candidate)) {
// Skip if it's inside a config directory (handled by other providers)
if (content !== null) {
const parent = dirname(candidate);
const baseName = parent.split(sep).pop() ?? "";
// Skip if inside .codex, .gemini, or other config dirs
if (!baseName.startsWith(".")) {
const content = ctx.fs.readFile(candidate);
const fileDir = dirname(candidate);
const calculatedDepth = calculateDepth(ctx.cwd, fileDir, sep);
if (content === null) {
warnings.push(`Failed to read: ${candidate}`);
} else {
const fileDir = dirname(candidate);
const calculatedDepth = calculateDepth(ctx.cwd, fileDir, sep);
items.push({
path: candidate,
content,
level: "project",
depth: calculatedDepth,
_source: createSourceMeta(PROVIDER_ID, candidate, "project"),
});
}
items.push({
path: candidate,
content,
level: "project",
depth: calculatedDepth,
_source: createSourceMeta(PROVIDER_ID, candidate, "project"),
});
}
}
+369 -249
View File
@@ -9,6 +9,7 @@ import { dirname, isAbsolute, join, resolve } from "path";
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 { registerProvider } from "../capability/index";
import { type Instruction, instructionCapability } from "../capability/instruction";
@@ -42,12 +43,13 @@ const PATHS = SOURCE_PATHS.native;
const PROJECT_DIRS = [PATHS.projectDir, ...PATHS.aliases];
const USER_DIRS = [PATHS.userBase, ...PATHS.aliases];
function getConfigDirs(ctx: LoadContext): Array<{ dir: string; level: "user" | "project" }> {
async function getConfigDirs(ctx: LoadContext): Promise<Array<{ dir: string; level: "user" | "project" }>> {
const result: Array<{ dir: string; level: "user" | "project" }> = [];
for (const name of PROJECT_DIRS) {
const projectDir = ctx.fs.walkUp(name, { dir: true });
if (projectDir) {
const projectDir = join(ctx.cwd, name);
const entries = await readDirEntries(projectDir);
if (entries.length > 0) {
result.push({ dir: projectDir, level: "project" });
break;
}
@@ -55,7 +57,8 @@ function getConfigDirs(ctx: LoadContext): Array<{ dir: string; level: "user" | "
for (const name of USER_DIRS) {
const userDir = join(ctx.home, name, PATHS.userAgent.replace(`${PATHS.userBase}/`, ""));
if (ctx.fs.isDir(userDir)) {
const entries = await readDirEntries(userDir);
if (entries.length > 0) {
result.push({ dir: userDir, level: "user" });
break;
}
@@ -65,53 +68,19 @@ function getConfigDirs(ctx: LoadContext): Array<{ dir: string; level: "user" | "
}
// MCP
function loadMCPServers(ctx: LoadContext): LoadResult<MCPServer> {
async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>> {
const items: MCPServer[] = [];
const warnings: string[] = [];
for (const name of PROJECT_DIRS) {
const projectDir = ctx.fs.walkUp(name, { dir: true });
if (!projectDir) continue;
for (const filename of ["mcp.json", ".mcp.json"]) {
const path = join(projectDir, filename);
const content = ctx.fs.readFile(path);
if (!content) continue;
const data = parseJSON<{ mcpServers?: Record<string, unknown> }>(content);
if (!data?.mcpServers) continue;
const expanded = expandEnvVarsDeep(data.mcpServers);
for (const [serverName, config] of Object.entries(expanded)) {
const serverConfig = config as Record<string, unknown>;
items.push({
name: serverName,
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,
transport: serverConfig.type as "stdio" | "sse" | "http" | undefined,
_source: createSourceMeta(PROVIDER_ID, path, "project"),
});
}
break;
}
break;
}
for (const name of USER_DIRS) {
const userPath = join(ctx.home, name, "mcp.json");
const content = ctx.fs.readFile(userPath);
if (!content) continue;
const parseMcpServers = (content: string, path: string, level: "user" | "project"): MCPServer[] => {
const result: MCPServer[] = [];
const data = parseJSON<{ mcpServers?: Record<string, unknown> }>(content);
if (!data?.mcpServers) continue;
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>;
items.push({
result.push({
name: serverName,
command: serverConfig.command as string | undefined,
args: serverConfig.args as string[] | undefined,
@@ -119,10 +88,41 @@ function loadMCPServers(ctx: LoadContext): LoadResult<MCPServer> {
url: serverConfig.url as string | undefined,
headers: serverConfig.headers as Record<string, string> | undefined,
transport: serverConfig.type as "stdio" | "sse" | "http" | undefined,
_source: createSourceMeta(PROVIDER_ID, userPath, "user"),
_source: createSourceMeta(PROVIDER_ID, path, level),
});
}
break;
return result;
};
const projectDirs = await Promise.all(
PROJECT_DIRS.map(async (name) => {
const dir = join(ctx.cwd, name);
const entries = await readDirEntries(dir);
return entries.length > 0 ? dir : null;
}),
);
const userPaths = USER_DIRS.map((name) => join(ctx.home, name, "mcp.json"));
const projectDir = projectDirs.find((dir) => dir !== null);
if (projectDir) {
const projectCandidates = ["mcp.json", ".mcp.json"].map((filename) => join(projectDir, filename));
const projectContents = await Promise.all(projectCandidates.map((path) => readFile(path)));
for (let i = 0; i < projectCandidates.length; i++) {
const content = projectContents[i];
if (content) {
items.push(...parseMcpServers(content, projectCandidates[i], "project"));
break;
}
}
}
const userContents = await Promise.all(userPaths.map((path) => readFile(path)));
for (let i = 0; i < userPaths.length; i++) {
const content = userContents[i];
if (content) {
items.push(...parseMcpServers(content, userPaths[i], "user"));
break;
}
}
return { items, warnings };
@@ -137,48 +137,54 @@ registerProvider<MCPServer>(mcpCapability.id, {
});
// System Prompt (SYSTEM.md)
function loadSystemPrompt(ctx: LoadContext): LoadResult<SystemPrompt> {
async function loadSystemPrompt(ctx: LoadContext): Promise<LoadResult<SystemPrompt>> {
const items: SystemPrompt[] = [];
// User level: ~/.omp/agent/SYSTEM.md or ~/.pi/agent/SYSTEM.md
for (const name of USER_DIRS) {
const userPath = join(ctx.home, name, PATHS.userAgent.replace(`${PATHS.userBase}/`, ""), "SYSTEM.md");
const userContent = ctx.fs.readFile(userPath);
if (userContent) {
const userPaths = USER_DIRS.map((name) =>
join(ctx.home, name, PATHS.userAgent.replace(`${PATHS.userBase}/`, ""), "SYSTEM.md"),
);
const userContents = await Promise.all(userPaths.map((p) => readFile(p)));
for (let i = 0; i < userPaths.length; i++) {
const content = userContents[i];
if (content) {
items.push({
path: userPath,
content: userContent,
path: userPaths[i],
content,
level: "user",
_source: createSourceMeta(PROVIDER_ID, userPath, "user"),
_source: createSourceMeta(PROVIDER_ID, userPaths[i], "user"),
});
break; // First match wins
break;
}
}
// Project level: walk up looking for .omp/SYSTEM.md or .pi/SYSTEM.md
const ancestors: string[] = [];
let current = ctx.cwd;
while (true) {
for (const name of PROJECT_DIRS) {
const configDir = join(current, name);
if (ctx.fs.isDir(configDir)) {
const projectPath = join(configDir, "SYSTEM.md");
const content = ctx.fs.readFile(projectPath);
if (content) {
items.push({
path: projectPath,
content,
level: "project",
_source: createSourceMeta(PROVIDER_ID, projectPath, "project"),
});
break; // First config dir in this directory wins
}
}
}
ancestors.push(current);
const parent = dirname(current);
if (parent === current) break;
current = parent;
}
for (const dir of ancestors) {
const configDirs = PROJECT_DIRS.map((name) => join(dir, name));
const entriesResults = await Promise.all(configDirs.map((d) => readDirEntries(d)));
const validConfigDir = configDirs.find((_, i) => entriesResults[i].length > 0);
if (!validConfigDir) continue;
const projectPath = join(validConfigDir, "SYSTEM.md");
const content = await readFile(projectPath);
if (content) {
items.push({
path: projectPath,
content,
level: "project",
_source: createSourceMeta(PROVIDER_ID, projectPath, "project"),
});
}
break;
}
return { items, warnings: [] };
}
@@ -191,23 +197,23 @@ registerProvider<SystemPrompt>(systemPromptCapability.id, {
});
// Skills
function loadSkills(ctx: LoadContext): LoadResult<Skill> {
const items: Skill[] = [];
const warnings: string[] = [];
async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
const configDirs = await getConfigDirs(ctx);
const results = await Promise.all(
configDirs.map(({ dir, level }) =>
loadSkillsFromDir(ctx, {
dir: join(dir, "skills"),
providerId: PROVIDER_ID,
level,
requireDescription: true,
}),
),
);
for (const { dir, level } of getConfigDirs(ctx)) {
const skillsDir = join(dir, "skills");
const result = loadSkillsFromDir(ctx, {
dir: skillsDir,
providerId: PROVIDER_ID,
level,
requireDescription: true,
});
items.push(...result.items);
if (result.warnings) warnings.push(...result.warnings);
}
return { items, warnings };
return {
items: results.flatMap((r) => r.items),
warnings: results.flatMap((r) => r.warnings ?? []),
};
}
registerProvider<Skill>(skillCapability.id, {
@@ -219,13 +225,13 @@ registerProvider<Skill>(skillCapability.id, {
});
// Slash Commands
function loadSlashCommands(ctx: LoadContext): LoadResult<SlashCommand> {
async function loadSlashCommands(ctx: LoadContext): Promise<LoadResult<SlashCommand>> {
const items: SlashCommand[] = [];
const warnings: string[] = [];
for (const { dir, level } of getConfigDirs(ctx)) {
for (const { dir, level } of await getConfigDirs(ctx)) {
const commandsDir = join(dir, "commands");
const result = loadFilesFromDir<SlashCommand>(ctx, commandsDir, PROVIDER_ID, level, {
const result = await loadFilesFromDir<SlashCommand>(ctx, commandsDir, PROVIDER_ID, level, {
extensions: ["md"],
transform: (name, content, path, source) => ({
name: name.replace(/\.md$/, ""),
@@ -251,13 +257,13 @@ registerProvider<SlashCommand>(slashCommandCapability.id, {
});
// Rules
function loadRules(ctx: LoadContext): LoadResult<Rule> {
async function loadRules(ctx: LoadContext): Promise<LoadResult<Rule>> {
const items: Rule[] = [];
const warnings: string[] = [];
for (const { dir, level } of getConfigDirs(ctx)) {
for (const { dir, level } of await getConfigDirs(ctx)) {
const rulesDir = join(dir, "rules");
const result = loadFilesFromDir<Rule>(ctx, rulesDir, PROVIDER_ID, level, {
const result = await loadFilesFromDir<Rule>(ctx, rulesDir, PROVIDER_ID, level, {
extensions: ["md", "mdc"],
transform: (name, content, path, source) => {
const { frontmatter, body } = parseFrontmatter(content);
@@ -289,13 +295,13 @@ registerProvider<Rule>(ruleCapability.id, {
});
// Prompts
function loadPrompts(ctx: LoadContext): LoadResult<Prompt> {
async function loadPrompts(ctx: LoadContext): Promise<LoadResult<Prompt>> {
const items: Prompt[] = [];
const warnings: string[] = [];
for (const { dir, level } of getConfigDirs(ctx)) {
for (const { dir, level } of await getConfigDirs(ctx)) {
const promptsDir = join(dir, "prompts");
const result = loadFilesFromDir<Prompt>(ctx, promptsDir, PROVIDER_ID, level, {
const result = await loadFilesFromDir<Prompt>(ctx, promptsDir, PROVIDER_ID, level, {
extensions: ["md"],
transform: (name, content, path, source) => ({
name: name.replace(/\.md$/, ""),
@@ -320,7 +326,7 @@ registerProvider<Prompt>(promptCapability.id, {
});
// Extension Modules
function loadExtensionModules(ctx: LoadContext): LoadResult<ExtensionModule> {
async function loadExtensionModules(ctx: LoadContext): Promise<LoadResult<ExtensionModule>> {
const items: ExtensionModule[] = [];
const warnings: string[] = [];
@@ -337,45 +343,88 @@ function loadExtensionModules(ctx: LoadContext): LoadResult<ExtensionModule> {
return resolve(ctx.cwd, rawPath);
};
const addExtensionPath = (extPath: string, level: "user" | "project"): void => {
items.push({
name: getExtensionNameFromPath(extPath),
path: extPath,
level,
_source: createSourceMeta(PROVIDER_ID, extPath, level),
});
};
const createExtensionModule = (extPath: string, level: "user" | "project"): ExtensionModule => ({
name: getExtensionNameFromPath(extPath),
path: extPath,
level,
_source: createSourceMeta(PROVIDER_ID, extPath, level),
});
for (const { dir, level } of getConfigDirs(ctx)) {
const extensionsDir = join(dir, "extensions");
const discovered = discoverExtensionModulePaths(ctx, extensionsDir);
for (const extPath of discovered) {
addExtensionPath(extPath, level);
const configDirs = await getConfigDirs(ctx);
const [discoveredResults, settingsResults] = await Promise.all([
Promise.all(configDirs.map(({ dir }) => discoverExtensionModulePaths(ctx, join(dir, "extensions")))),
Promise.all(configDirs.map(({ dir }) => readFile(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 = join(dir, "settings.json");
const settingsContent = ctx.fs.readFile(settingsPath);
if (settingsContent) {
const settingsData = parseJSON<{ extensions?: unknown }>(settingsContent);
const extensions = settingsData?.extensions;
if (Array.isArray(extensions)) {
for (const entry of extensions) {
if (typeof entry !== "string") {
warnings.push(`Invalid extension path in ${settingsPath}: ${String(entry)}`);
continue;
}
const resolvedPath = resolveExtensionPath(entry);
if (ctx.fs.isDir(resolvedPath)) {
for (const extPath of discoverExtensionModulePaths(ctx, resolvedPath)) {
addExtensionPath(extPath, level);
}
} else if (ctx.fs.isFile(resolvedPath)) {
addExtensionPath(resolvedPath, level);
} else {
warnings.push(`Extension path not found: ${resolvedPath}`);
}
}
const settingsData = parseJSON<{ 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));
}
}
@@ -391,40 +440,61 @@ registerProvider<ExtensionModule>(extensionModuleCapability.id, {
});
// Extensions
function loadExtensions(ctx: LoadContext): LoadResult<Extension> {
async function loadExtensions(ctx: LoadContext): Promise<LoadResult<Extension>> {
const items: Extension[] = [];
const warnings: string[] = [];
for (const { dir, level } of getConfigDirs(ctx)) {
const configDirs = await getConfigDirs(ctx);
const entriesResults = await Promise.all(configDirs.map(({ dir }) => readDirEntries(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 = join(dir, "extensions");
if (!ctx.fs.isDir(extensionsDir)) continue;
for (const name of ctx.fs.readDir(extensionsDir)) {
if (name.startsWith(".")) continue;
for (const entry of entries) {
if (entry.name.startsWith(".")) continue;
if (!entry.isDirectory()) continue;
const extDir = join(extensionsDir, name);
if (!ctx.fs.isDir(extDir)) continue;
const manifestPath = join(extDir, "gemini-extension.json");
const content = ctx.fs.readFile(manifestPath);
if (!content) continue;
const manifest = parseJSON<ExtensionManifest>(content);
if (!manifest) {
warnings.push(`Failed to parse ${manifestPath}`);
continue;
}
items.push({
name: manifest.name || name,
path: extDir,
manifest,
const extDir = join(extensionsDir, entry.name);
manifestCandidates.push({
extDir,
manifestPath: join(extDir, "gemini-extension.json"),
entryName: entry.name,
level,
_source: createSourceMeta(PROVIDER_ID, manifestPath, 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 = parseJSON<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 };
}
@@ -437,13 +507,13 @@ registerProvider<Extension>(extensionCapability.id, {
});
// Instructions
function loadInstructions(ctx: LoadContext): LoadResult<Instruction> {
async function loadInstructions(ctx: LoadContext): Promise<LoadResult<Instruction>> {
const items: Instruction[] = [];
const warnings: string[] = [];
for (const { dir, level } of getConfigDirs(ctx)) {
for (const { dir, level } of await getConfigDirs(ctx)) {
const instructionsDir = join(dir, "instructions");
const result = loadFilesFromDir<Instruction>(ctx, instructionsDir, PROVIDER_ID, level, {
const result = await loadFilesFromDir<Instruction>(ctx, instructionsDir, PROVIDER_ID, level, {
extensions: ["md"],
transform: (name, content, path, source) => {
const { frontmatter, body } = parseFrontmatter(content);
@@ -472,35 +542,50 @@ registerProvider<Instruction>(instructionCapability.id, {
});
// Hooks
function loadHooks(ctx: LoadContext): LoadResult<Hook> {
async function loadHooks(ctx: LoadContext): Promise<LoadResult<Hook>> {
const items: Hook[] = [];
for (const { dir, level } of getConfigDirs(ctx)) {
const hooksDir = join(dir, "hooks");
if (!ctx.fs.isDir(hooksDir)) continue;
const configDirs = await getConfigDirs(ctx);
const hookTypes = ["pre", "post"] as const;
for (const hookType of ["pre", "post"] as const) {
const typeDir = join(hooksDir, hookType);
if (!ctx.fs.isDir(typeDir)) continue;
const typeDirRequests: Array<{
typeDir: string;
hookType: (typeof hookTypes)[number];
level: "user" | "project";
}> = [];
for (const name of ctx.fs.readDir(typeDir)) {
if (name.startsWith(".")) continue;
for (const { dir, level } of configDirs) {
for (const hookType of hookTypes) {
typeDirRequests.push({
typeDir: join(dir, "hooks", hookType),
hookType,
level,
});
}
}
const path = join(typeDir, name);
if (!ctx.fs.isFile(path)) continue;
const typeEntriesResults = await Promise.all(typeDirRequests.map(({ typeDir }) => readDirEntries(typeDir)));
const baseName = name.includes(".") ? name.slice(0, name.lastIndexOf(".")) : name;
const tool = baseName === "*" ? "*" : baseName;
for (let i = 0; i < typeDirRequests.length; i++) {
const { typeDir, hookType, level } = typeDirRequests[i];
const typeEntries = typeEntriesResults[i];
items.push({
name,
path,
type: hookType,
tool,
level,
_source: createSourceMeta(PROVIDER_ID, path, level),
});
}
for (const entry of typeEntries) {
if (entry.name.startsWith(".")) continue;
if (!entry.isFile()) continue;
const 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,
type: hookType,
tool,
level,
_source: createSourceMeta(PROVIDER_ID, path, level),
});
}
}
@@ -516,58 +601,86 @@ registerProvider<Hook>(hookCapability.id, {
});
// Custom Tools
function loadTools(ctx: LoadContext): LoadResult<CustomTool> {
async function loadTools(ctx: LoadContext): Promise<LoadResult<CustomTool>> {
const items: CustomTool[] = [];
const warnings: string[] = [];
for (const { dir, level } of getConfigDirs(ctx)) {
const toolsDir = join(dir, "tools");
if (!ctx.fs.isDir(toolsDir)) continue;
const configDirs = await getConfigDirs(ctx);
const entriesResults = await Promise.all(configDirs.map(({ dir }) => readDirEntries(join(dir, "tools"))));
// Load tool files (JSON and Markdown declarative tools)
const result = loadFilesFromDir<CustomTool>(ctx, toolsDir, PROVIDER_ID, level, {
extensions: ["json", "md"],
transform: (name, content, path, source) => {
if (name.endsWith(".json")) {
const data = parseJSON<{ name?: string; description?: string }>(content);
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 = join(dir, "tools");
fileLoadPromises.push(
loadFilesFromDir<CustomTool>(ctx, toolsDir, PROVIDER_ID, level, {
extensions: ["json", "md"],
transform: (name, content, path, source) => {
if (name.endsWith(".json")) {
const data = parseJSON<{ name?: string; description?: string }>(content);
return {
name: data?.name || name.replace(/\.json$/, ""),
path,
description: data?.description,
level,
_source: source,
};
}
const { frontmatter } = parseFrontmatter(content);
return {
name: data?.name || name.replace(/\.json$/, ""),
name: (frontmatter.name as string) || name.replace(/\.md$/, ""),
path,
description: data?.description,
description: frontmatter.description as string | undefined,
level,
_source: source,
};
}
const { frontmatter } = parseFrontmatter(content);
return {
name: (frontmatter.name as string) || name.replace(/\.md$/, ""),
path,
description: frontmatter.description as string | undefined,
level,
_source: source,
};
},
});
},
}),
);
for (const entry of toolEntries) {
if (entry.name.startsWith(".")) continue;
if (!entry.isDirectory()) continue;
subDirCandidates.push({
indexPath: 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);
}
// Load TypeScript tools from subdirectories (tools/mytool/index.ts pattern)
for (const name of ctx.fs.readDir(toolsDir)) {
if (name.startsWith(".")) continue;
const subDir = join(toolsDir, name);
if (!ctx.fs.isDir(subDir)) continue;
const indexPath = join(subDir, "index.ts");
if (ctx.fs.isFile(indexPath)) {
items.push({
name,
path: indexPath,
description: undefined,
level,
_source: createSourceMeta(PROVIDER_ID, indexPath, level),
});
}
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: undefined,
level,
_source: createSourceMeta(PROVIDER_ID, indexPath, level),
});
}
}
@@ -583,13 +696,13 @@ registerProvider<CustomTool>(toolCapability.id, {
});
// Settings
function loadSettings(ctx: LoadContext): LoadResult<Settings> {
async function loadSettings(ctx: LoadContext): Promise<LoadResult<Settings>> {
const items: Settings[] = [];
const warnings: string[] = [];
for (const { dir, level } of getConfigDirs(ctx)) {
for (const { dir, level } of await getConfigDirs(ctx)) {
const settingsPath = join(dir, "settings.json");
const content = ctx.fs.readFile(settingsPath);
const content = await readFile(settingsPath);
if (!content) continue;
const data = parseJSON<Record<string, unknown>>(content);
@@ -618,52 +731,59 @@ registerProvider<Settings>(settingsCapability.id, {
});
// Context Files (AGENTS.md)
function loadContextFiles(ctx: LoadContext): LoadResult<ContextFile> {
async function loadContextFiles(ctx: LoadContext): Promise<LoadResult<ContextFile>> {
const items: ContextFile[] = [];
const warnings: string[] = [];
// User level: ~/.omp/agent/AGENTS.md or ~/.pi/agent/AGENTS.md
for (const name of USER_DIRS) {
const userPath = join(ctx.home, name, PATHS.userAgent.replace(`${PATHS.userBase}/`, ""), "AGENTS.md");
const content = ctx.fs.readFile(userPath);
const userPaths = USER_DIRS.map((name) =>
join(ctx.home, name, PATHS.userAgent.replace(`${PATHS.userBase}/`, ""), "AGENTS.md"),
);
const userContents = await Promise.all(userPaths.map((p) => readFile(p)));
for (let i = 0; i < userPaths.length; i++) {
const content = userContents[i];
if (content) {
items.push({
path: userPath,
path: userPaths[i],
content,
level: "user",
_source: createSourceMeta(PROVIDER_ID, userPath, "user"),
_source: createSourceMeta(PROVIDER_ID, userPaths[i], "user"),
});
break; // First match wins
break;
}
}
// Project level: walk up looking for .omp/AGENTS.md or .pi/AGENTS.md
const ancestors: Array<{ dir: string; depth: number }> = [];
let current = ctx.cwd;
let depth = 0;
while (true) {
for (const name of PROJECT_DIRS) {
const configDir = join(current, name);
if (ctx.fs.isDir(configDir)) {
const projectPath = join(configDir, "AGENTS.md");
const content = ctx.fs.readFile(projectPath);
if (content) {
items.push({
path: projectPath,
content,
level: "project",
depth,
_source: createSourceMeta(PROVIDER_ID, projectPath, "project"),
});
return { items, warnings }; // First config dir wins
}
}
}
ancestors.push({ dir: current, depth });
const parent = dirname(current);
if (parent === current) break;
current = parent;
depth++;
}
for (const { dir, depth: ancestorDepth } of ancestors) {
const configDirs = PROJECT_DIRS.map((name) => join(dir, name));
const entriesResults = await Promise.all(configDirs.map((d) => readDirEntries(d)));
const validConfigDir = configDirs.find((_, i) => entriesResults[i].length > 0);
if (!validConfigDir) continue;
const projectPath = join(validConfigDir, "AGENTS.md");
const content = await readFile(projectPath);
if (content) {
items.push({
path: projectPath,
content,
level: "project",
depth: ancestorDepth,
_source: createSourceMeta(PROVIDER_ID, projectPath, "project"),
});
return { items, warnings };
}
break;
}
return { items, warnings };
}
+177 -286
View File
@@ -5,9 +5,10 @@
* Priority: 80 (tool-specific, below builtin but above shared standards)
*/
import { dirname, join, sep } from "path";
import { join, sep } from "node:path";
import { type ContextFile, contextFileCapability } from "../capability/context-file";
import { type ExtensionModule, extensionModuleCapability } from "../capability/extension-module";
import { readFile } from "../capability/fs";
import { type Hook, hookCapability } from "../capability/hook";
import { registerProvider } from "../capability/index";
import { type MCPServer, mcpCapability } from "../capability/mcp";
@@ -41,45 +42,49 @@ function getUserClaude(ctx: LoadContext): string {
}
/**
* Get project-level .claude path (walks up from cwd).
* Get project-level .claude path (cwd only).
*/
function getProjectClaude(ctx: LoadContext): string | null {
return ctx.fs.walkUp(CONFIG_DIR, { dir: true });
function getProjectClaude(ctx: LoadContext): string {
return join(ctx.cwd, CONFIG_DIR);
}
// =============================================================================
// MCP Servers
// =============================================================================
function loadMCPServers(ctx: LoadContext): LoadResult<MCPServer> {
async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>> {
const items: MCPServer[] = [];
const warnings: string[] = [];
// User-level: ~/.claude.json or ~/.claude/mcp.json
const userBase = getUserClaude(ctx);
const userClaudeJson = join(ctx.home, ".claude.json");
const userMcpJson = join(userBase, "mcp.json");
for (const [path, level] of [
[userClaudeJson, "user"],
[userMcpJson, "user"],
] as const) {
if (!ctx.fs.isFile(path)) continue;
const projectBase = join(ctx.cwd, CONFIG_DIR);
const projectMcpJson = join(projectBase, ".mcp.json");
const projectMcpJsonAlt = join(projectBase, "mcp.json");
const content = ctx.fs.readFile(path);
if (!content) {
warnings.push(`Failed to read ${path}`);
continue;
}
const userPaths = [
{ path: userClaudeJson, level: "user" as const },
{ path: userMcpJson, level: "user" as const },
];
const projectPaths = [
{ path: projectMcpJson, level: "project" as const },
{ path: projectMcpJsonAlt, level: "project" as const },
];
const allPaths = [...userPaths, ...projectPaths];
const contents = await Promise.all(allPaths.map(({ path }) => readFile(path)));
const parseMcpServers = (content: string | null, path: string, level: "user" | "project"): MCPServer[] => {
if (!content) return [];
const json = parseJSON<{ mcpServers?: Record<string, unknown> }>(content);
if (!json?.mcpServers) continue;
if (!json?.mcpServers) return [];
const mcpServers = expandEnvVarsDeep(json.mcpServers);
for (const [name, config] of Object.entries(mcpServers)) {
return Object.entries(mcpServers).map(([name, config]) => {
const serverConfig = config as Record<string, unknown>;
items.push({
return {
name,
command: serverConfig.command as string | undefined,
args: serverConfig.args as string[] | undefined,
@@ -88,45 +93,24 @@ function loadMCPServers(ctx: LoadContext): LoadResult<MCPServer> {
headers: serverConfig.headers as Record<string, string> | undefined,
transport: serverConfig.type as "stdio" | "sse" | "http" | undefined,
_source: createSourceMeta(PROVIDER_ID, path, level),
});
};
});
};
for (let i = 0; i < userPaths.length; i++) {
const servers = parseMcpServers(contents[i], userPaths[i].path, userPaths[i].level);
if (servers.length > 0) {
items.push(...servers);
break;
}
break; // First existing file wins
}
// Project-level: <project>/.mcp.json or <project>/mcp.json
const projectBase = getProjectClaude(ctx);
if (projectBase) {
const projectMcpJson = join(projectBase, ".mcp.json");
const projectMcpJsonAlt = join(projectBase, "mcp.json");
for (const path of [projectMcpJson, projectMcpJsonAlt]) {
if (!ctx.fs.isFile(path)) continue;
const content = ctx.fs.readFile(path);
if (!content) {
warnings.push(`Failed to read ${path}`);
continue;
}
const json = parseJSON<{ mcpServers?: Record<string, unknown> }>(content);
if (!json?.mcpServers) continue;
const mcpServers = expandEnvVarsDeep(json.mcpServers);
for (const [name, config] of Object.entries(mcpServers)) {
const serverConfig = config as Record<string, unknown>;
items.push({
name,
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,
transport: serverConfig.type as "stdio" | "sse" | "http" | undefined,
_source: createSourceMeta(PROVIDER_ID, path, "project"),
});
}
break; // First existing file wins
const projectOffset = userPaths.length;
for (let i = 0; i < projectPaths.length; i++) {
const servers = parseMcpServers(contents[projectOffset + i], projectPaths[i].path, projectPaths[i].level);
if (servers.length > 0) {
items.push(...servers);
break;
}
}
@@ -137,74 +121,35 @@ function loadMCPServers(ctx: LoadContext): LoadResult<MCPServer> {
// Context Files (CLAUDE.md)
// =============================================================================
function loadContextFiles(ctx: LoadContext): LoadResult<ContextFile> {
async function loadContextFiles(ctx: LoadContext): Promise<LoadResult<ContextFile>> {
const items: ContextFile[] = [];
const warnings: string[] = [];
// User-level: ~/.claude/CLAUDE.md
const userBase = getUserClaude(ctx);
const userClaudeMd = join(userBase, "CLAUDE.md");
if (ctx.fs.isFile(userClaudeMd)) {
const content = ctx.fs.readFile(userClaudeMd);
if (content !== null) {
items.push({
path: userClaudeMd,
content,
level: "user",
_source: createSourceMeta(PROVIDER_ID, userClaudeMd, "user"),
});
} else {
warnings.push(`Failed to read ${userClaudeMd}`);
}
const userContent = await readFile(userClaudeMd);
if (userContent !== null) {
items.push({
path: userClaudeMd,
content: userContent,
level: "user",
_source: createSourceMeta(PROVIDER_ID, userClaudeMd, "user"),
});
}
// Project-level: walk up looking for .claude/CLAUDE.md
const projectBase = getProjectClaude(ctx);
if (projectBase) {
const projectClaudeMd = join(projectBase, "CLAUDE.md");
if (ctx.fs.isFile(projectClaudeMd)) {
const content = ctx.fs.readFile(projectClaudeMd);
if (content !== null) {
// Calculate depth (distance from cwd)
const depth = calculateDepth(ctx.cwd, projectBase, sep);
items.push({
path: projectClaudeMd,
content,
level: "project",
depth,
_source: createSourceMeta(PROVIDER_ID, projectClaudeMd, "project"),
});
} else {
warnings.push(`Failed to read ${projectClaudeMd}`);
}
}
}
// Also check for CLAUDE.md in project root (without .claude directory)
const rootClaudeMd = ctx.fs.walkUp("CLAUDE.md", { file: true });
if (rootClaudeMd) {
const content = ctx.fs.readFile(rootClaudeMd);
if (content !== null) {
// Only add if not already added from .claude/CLAUDE.md
const alreadyAdded = items.some((item) => item.path === rootClaudeMd);
if (!alreadyAdded) {
const fileDir = dirname(rootClaudeMd);
const depth = calculateDepth(ctx.cwd, fileDir, sep);
items.push({
path: rootClaudeMd,
content,
level: "project",
depth,
_source: createSourceMeta(PROVIDER_ID, rootClaudeMd, "project"),
});
}
} else {
warnings.push(`Failed to read ${rootClaudeMd}`);
}
const projectClaudeMd = join(projectBase, "CLAUDE.md");
const projectContent = await readFile(projectClaudeMd);
if (projectContent !== null) {
const depth = calculateDepth(ctx.cwd, projectBase, sep);
items.push({
path: projectClaudeMd,
content: projectContent,
level: "project",
depth,
_source: createSourceMeta(PROVIDER_ID, projectClaudeMd, "project"),
});
}
return { items, warnings };
@@ -214,62 +159,52 @@ function loadContextFiles(ctx: LoadContext): LoadResult<ContextFile> {
// Skills
// =============================================================================
function loadSkills(ctx: LoadContext): LoadResult<Skill> {
const items: Skill[] = [];
const warnings: string[] = [];
async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
const userSkillsDir = join(getUserClaude(ctx), "skills");
const userResult = loadSkillsFromDir(ctx, {
dir: userSkillsDir,
providerId: PROVIDER_ID,
level: "user",
});
items.push(...userResult.items);
if (userResult.warnings) warnings.push(...userResult.warnings);
const projectSkillsDir = join(getProjectClaude(ctx), "skills");
const projectBase = getProjectClaude(ctx);
if (projectBase) {
const projectSkillsDir = join(projectBase, "skills");
const projectResult = loadSkillsFromDir(ctx, {
dir: projectSkillsDir,
providerId: PROVIDER_ID,
level: "project",
});
items.push(...projectResult.items);
if (projectResult.warnings) warnings.push(...projectResult.warnings);
}
const results = await Promise.all([
loadSkillsFromDir(ctx, { dir: userSkillsDir, providerId: PROVIDER_ID, level: "user" }),
loadSkillsFromDir(ctx, { dir: projectSkillsDir, providerId: PROVIDER_ID, level: "project" }),
]);
return { items, warnings };
return {
items: results.flatMap((r) => r.items),
warnings: results.flatMap((r) => r.warnings ?? []),
};
}
// =============================================================================
// Extension Modules
// =============================================================================
function loadExtensionModules(ctx: LoadContext): LoadResult<ExtensionModule> {
async function loadExtensionModules(ctx: LoadContext): Promise<LoadResult<ExtensionModule>> {
const items: ExtensionModule[] = [];
const warnings: string[] = [];
const userBase = getUserClaude(ctx);
const userExtensionsDir = join(userBase, "extensions");
for (const extPath of discoverExtensionModulePaths(ctx, userExtensionsDir)) {
items.push({
name: getExtensionNameFromPath(extPath),
path: extPath,
level: "user",
_source: createSourceMeta(PROVIDER_ID, extPath, "user"),
});
}
const projectExtensionsDir = join(ctx.cwd, CONFIG_DIR, "extensions");
const projectBase = getProjectClaude(ctx);
if (projectBase) {
const projectExtensionsDir = join(projectBase, "extensions");
for (const extPath of discoverExtensionModulePaths(ctx, projectExtensionsDir)) {
const dirsToDiscover: { dir: string; level: "user" | "project" }[] = [
{ dir: userExtensionsDir, level: "user" },
{ dir: projectExtensionsDir, level: "project" },
];
const pathsByLevel = await Promise.all(
dirsToDiscover.map(async ({ dir, level }) => {
const paths = await discoverExtensionModulePaths(ctx, dir);
return paths.map((extPath) => ({ extPath, level }));
}),
);
for (const extensions of pathsByLevel) {
for (const { extPath, level } of extensions) {
items.push({
name: getExtensionNameFromPath(extPath),
path: extPath,
level: "project",
_source: createSourceMeta(PROVIDER_ID, extPath, "project"),
level,
_source: createSourceMeta(PROVIDER_ID, extPath, level),
});
}
}
@@ -281,15 +216,14 @@ function loadExtensionModules(ctx: LoadContext): LoadResult<ExtensionModule> {
// Slash Commands
// =============================================================================
function loadSlashCommands(ctx: LoadContext): LoadResult<SlashCommand> {
async function loadSlashCommands(ctx: LoadContext): Promise<LoadResult<SlashCommand>> {
const items: SlashCommand[] = [];
const warnings: string[] = [];
// User-level: ~/.claude/commands/*.md
const userBase = getUserClaude(ctx);
const userCommandsDir = join(userBase, "commands");
const userResult = loadFilesFromDir<SlashCommand>(ctx, userCommandsDir, PROVIDER_ID, "user", {
const userResult = await loadFilesFromDir<SlashCommand>(ctx, userCommandsDir, PROVIDER_ID, "user", {
extensions: ["md"],
transform: (name, content, path, source) => {
const cmdName = name.replace(/\.md$/, "");
@@ -306,28 +240,24 @@ function loadSlashCommands(ctx: LoadContext): LoadResult<SlashCommand> {
items.push(...userResult.items);
if (userResult.warnings) warnings.push(...userResult.warnings);
// Project-level: <project>/.claude/commands/*.md
const projectBase = getProjectClaude(ctx);
if (projectBase) {
const projectCommandsDir = join(projectBase, "commands");
const projectCommandsDir = join(ctx.cwd, CONFIG_DIR, "commands");
const projectResult = loadFilesFromDir<SlashCommand>(ctx, projectCommandsDir, PROVIDER_ID, "project", {
extensions: ["md"],
transform: (name, content, path, source) => {
const cmdName = name.replace(/\.md$/, "");
return {
name: cmdName,
path,
content,
level: "project",
_source: source,
};
},
});
const projectResult = await loadFilesFromDir<SlashCommand>(ctx, projectCommandsDir, PROVIDER_ID, "project", {
extensions: ["md"],
transform: (name, content, path, source) => {
const cmdName = name.replace(/\.md$/, "");
return {
name: cmdName,
path,
content,
level: "project",
_source: source,
};
},
});
items.push(...projectResult.items);
if (projectResult.warnings) warnings.push(...projectResult.warnings);
}
items.push(...projectResult.items);
if (projectResult.warnings) warnings.push(...projectResult.warnings);
return { items, warnings };
}
@@ -336,63 +266,46 @@ function loadSlashCommands(ctx: LoadContext): LoadResult<SlashCommand> {
// Hooks
// =============================================================================
function loadHooks(ctx: LoadContext): LoadResult<Hook> {
async function loadHooks(ctx: LoadContext): Promise<LoadResult<Hook>> {
const items: Hook[] = [];
const warnings: string[] = [];
// User-level: ~/.claude/hooks/pre/* and ~/.claude/hooks/post/*
const userBase = getUserClaude(ctx);
const userHooksDir = join(userBase, "hooks");
const projectBase = getProjectClaude(ctx);
const projectHooksDir = join(projectBase, "hooks");
for (const hookType of ["pre", "post"] as const) {
const hooksTypeDir = join(userHooksDir, hookType);
const hookTypes = ["pre", "post"] as const;
const result = loadFilesFromDir<Hook>(ctx, hooksTypeDir, PROVIDER_ID, "user", {
transform: (name, _content, path, source) => {
// Extract tool name from filename (e.g., "bash.sh" -> "bash", "*.sh" -> "*")
const toolName = name.replace(/\.(sh|bash|zsh|fish)$/, "");
return {
name,
path,
type: hookType,
tool: toolName,
level: "user",
_source: source,
};
},
});
items.push(...result.items);
if (result.warnings) warnings.push(...result.warnings);
const loadTasks: { dir: string; hookType: "pre" | "post"; level: "user" | "project" }[] = [];
for (const hookType of hookTypes) {
loadTasks.push({ dir: join(userHooksDir, hookType), hookType, level: "user" });
}
for (const hookType of hookTypes) {
loadTasks.push({ dir: join(projectHooksDir, hookType), hookType, level: "project" });
}
// Project-level: <project>/.claude/hooks/pre/* and <project>/.claude/hooks/post/*
const projectBase = getProjectClaude(ctx);
if (projectBase) {
const projectHooksDir = join(projectBase, "hooks");
for (const hookType of ["pre", "post"] as const) {
const hooksTypeDir = join(projectHooksDir, hookType);
const result = loadFilesFromDir<Hook>(ctx, hooksTypeDir, PROVIDER_ID, "project", {
const results = await Promise.all(
loadTasks.map(({ dir, hookType, level }) =>
loadFilesFromDir<Hook>(ctx, dir, PROVIDER_ID, level, {
transform: (name, _content, path, source) => {
const toolName = name.replace(/\.(sh|bash|zsh|fish)$/, "");
return {
name,
path,
type: hookType,
tool: toolName,
level: "project",
level,
_source: source,
};
},
});
}),
),
);
items.push(...result.items);
if (result.warnings) warnings.push(...result.warnings);
}
for (const result of results) {
items.push(...result.items);
if (result.warnings) warnings.push(...result.warnings);
}
return { items, warnings };
@@ -402,15 +315,14 @@ function loadHooks(ctx: LoadContext): LoadResult<Hook> {
// Custom Tools
// =============================================================================
function loadTools(ctx: LoadContext): LoadResult<CustomTool> {
async function loadTools(ctx: LoadContext): Promise<LoadResult<CustomTool>> {
const items: CustomTool[] = [];
const warnings: string[] = [];
// User-level: ~/.claude/tools/*
const userBase = getUserClaude(ctx);
const userToolsDir = join(userBase, "tools");
const userResult = loadFilesFromDir<CustomTool>(ctx, userToolsDir, PROVIDER_ID, "user", {
const userResult = await loadFilesFromDir<CustomTool>(ctx, userToolsDir, PROVIDER_ID, "user", {
transform: (name, _content, path, source) => {
const toolName = name.replace(/\.(ts|js|sh|bash|py)$/, "");
@@ -426,27 +338,24 @@ function loadTools(ctx: LoadContext): LoadResult<CustomTool> {
items.push(...userResult.items);
if (userResult.warnings) warnings.push(...userResult.warnings);
// Project-level: <project>/.claude/tools/*
const projectBase = getProjectClaude(ctx);
if (projectBase) {
const projectToolsDir = join(projectBase, "tools");
const projectToolsDir = join(projectBase, "tools");
const projectResult = loadFilesFromDir<CustomTool>(ctx, projectToolsDir, PROVIDER_ID, "project", {
transform: (name, _content, path, source) => {
const toolName = name.replace(/\.(ts|js|sh|bash|py)$/, "");
const projectResult = await loadFilesFromDir<CustomTool>(ctx, projectToolsDir, PROVIDER_ID, "project", {
transform: (name, _content, path, source) => {
const toolName = name.replace(/\.(ts|js|sh|bash|py)$/, "");
return {
name: toolName,
path,
level: "project",
_source: source,
};
},
});
return {
name: toolName,
path,
level: "project",
_source: source,
};
},
});
items.push(...projectResult.items);
if (projectResult.warnings) warnings.push(...projectResult.warnings);
}
items.push(...projectResult.items);
if (projectResult.warnings) warnings.push(...projectResult.warnings);
return { items, warnings };
}
@@ -455,26 +364,21 @@ function loadTools(ctx: LoadContext): LoadResult<CustomTool> {
// System Prompts
// =============================================================================
function loadSystemPrompts(ctx: LoadContext): LoadResult<SystemPrompt> {
async function loadSystemPrompts(ctx: LoadContext): Promise<LoadResult<SystemPrompt>> {
const items: SystemPrompt[] = [];
const warnings: string[] = [];
// User-level: ~/.claude/SYSTEM.md
const userBase = getUserClaude(ctx);
const userSystemMd = join(userBase, "SYSTEM.md");
if (ctx.fs.isFile(userSystemMd)) {
const content = ctx.fs.readFile(userSystemMd);
if (content !== null) {
items.push({
path: userSystemMd,
content,
level: "user",
_source: createSourceMeta(PROVIDER_ID, userSystemMd, "user"),
});
} else {
warnings.push(`Failed to read ${userSystemMd}`);
}
const content = await readFile(userSystemMd);
if (content !== null) {
items.push({
path: userSystemMd,
content,
level: "user",
_source: createSourceMeta(PROVIDER_ID, userSystemMd, "user"),
});
}
return { items, warnings };
@@ -484,55 +388,42 @@ function loadSystemPrompts(ctx: LoadContext): LoadResult<SystemPrompt> {
// Settings
// =============================================================================
function loadSettings(ctx: LoadContext): LoadResult<Settings> {
async function loadSettings(ctx: LoadContext): Promise<LoadResult<Settings>> {
const items: Settings[] = [];
const warnings: string[] = [];
// User-level: ~/.claude/settings.json
const userBase = getUserClaude(ctx);
const userSettingsJson = join(userBase, "settings.json");
if (ctx.fs.isFile(userSettingsJson)) {
const content = ctx.fs.readFile(userSettingsJson);
if (content) {
const data = parseJSON<Record<string, unknown>>(content);
if (data) {
items.push({
path: userSettingsJson,
data,
level: "user",
_source: createSourceMeta(PROVIDER_ID, userSettingsJson, "user"),
});
} else {
warnings.push(`Failed to parse JSON in ${userSettingsJson}`);
}
const userContent = await readFile(userSettingsJson);
if (userContent) {
const data = parseJSON<Record<string, unknown>>(userContent);
if (data) {
items.push({
path: userSettingsJson,
data,
level: "user",
_source: createSourceMeta(PROVIDER_ID, userSettingsJson, "user"),
});
} else {
warnings.push(`Failed to read ${userSettingsJson}`);
warnings.push(`Failed to parse JSON in ${userSettingsJson}`);
}
}
// Project-level: <project>/.claude/settings.json
const projectBase = getProjectClaude(ctx);
if (projectBase) {
const projectSettingsJson = join(projectBase, "settings.json");
if (ctx.fs.isFile(projectSettingsJson)) {
const content = ctx.fs.readFile(projectSettingsJson);
if (content) {
const data = parseJSON<Record<string, unknown>>(content);
if (data) {
items.push({
path: projectSettingsJson,
data,
level: "project",
_source: createSourceMeta(PROVIDER_ID, projectSettingsJson, "project"),
});
} else {
warnings.push(`Failed to parse JSON in ${projectSettingsJson}`);
}
} else {
warnings.push(`Failed to read ${projectSettingsJson}`);
}
const projectSettingsJson = join(projectBase, "settings.json");
const projectContent = await readFile(projectSettingsJson);
if (projectContent) {
const data = parseJSON<Record<string, unknown>>(projectContent);
if (data) {
items.push({
path: projectSettingsJson,
data,
level: "project",
_source: createSourceMeta(PROVIDER_ID, projectSettingsJson, "project"),
});
} else {
warnings.push(`Failed to parse JSON in ${projectSettingsJson}`);
}
}
@@ -554,7 +445,7 @@ registerProvider<MCPServer>(mcpCapability.id, {
registerProvider<ContextFile>(contextFileCapability.id, {
id: PROVIDER_ID,
displayName: DISPLAY_NAME,
description: "Load CLAUDE.md files from .claude/ directories and project root",
description: "Load CLAUDE.md files from .claude/ directories",
priority: PRIORITY,
load: loadContextFiles,
});
+30 -10
View File
@@ -5,6 +5,8 @@
* Project-only (no user-level config).
*/
import { dirname, resolve } from "node:path";
import { readDirEntries, readFile } from "../capability/fs";
import { registerProvider } from "../capability/index";
import type { Rule } from "../capability/rule";
import { ruleCapability } from "../capability/rule";
@@ -15,23 +17,41 @@ const PROVIDER_ID = "cline";
const DISPLAY_NAME = "Cline";
const PRIORITY = 40;
async function findClinerules(startDir: string): Promise<{ path: string; isDir: boolean } | null> {
let current = resolve(startDir);
while (true) {
const entries = await readDirEntries(current);
const entry = entries.find((e) => e.name === ".clinerules");
if (entry) {
return {
path: resolve(current, ".clinerules"),
isDir: entry.isDirectory(),
};
}
const parent = dirname(current);
if (parent === current) return null;
current = parent;
}
}
/**
* Load rules from .clinerules
*/
function loadRules(ctx: LoadContext): LoadResult<Rule> {
async function loadRules(ctx: LoadContext): Promise<LoadResult<Rule>> {
const items: Rule[] = [];
const warnings: string[] = [];
// Project-level only (Cline uses root-level .clinerules)
const projectPath = ctx.fs.walkUp(".clinerules");
if (!projectPath) {
const found = await findClinerules(ctx.cwd);
if (!found) {
return { items, warnings };
}
// Check if .clinerules is a directory or file
if (ctx.fs.isDir(projectPath)) {
if (found.isDir) {
// Directory format: load all *.md files
const result = loadFilesFromDir(ctx, projectPath, PROVIDER_ID, "project", {
const result = await loadFilesFromDir(ctx, found.path, PROVIDER_ID, "project", {
extensions: ["md"],
transform: (name, content, path, source) => {
const { frontmatter, body } = parseFrontmatter(content);
@@ -60,16 +80,16 @@ function loadRules(ctx: LoadContext): LoadResult<Rule> {
items.push(...result.items);
if (result.warnings) warnings.push(...result.warnings);
} else if (ctx.fs.isFile(projectPath)) {
} else {
// Single file format
const content = ctx.fs.readFile(projectPath);
const content = await readFile(found.path);
if (content === null) {
warnings.push(`Failed to read .clinerules at ${projectPath}`);
warnings.push(`Failed to read .clinerules at ${found.path}`);
return { items, warnings };
}
const { frontmatter, body } = parseFrontmatter(content);
const source = createSourceMeta(PROVIDER_ID, projectPath, "project");
const source = createSourceMeta(PROVIDER_ID, found.path, "project");
// Parse globs (can be array or single string)
let globs: string[] | undefined;
@@ -81,7 +101,7 @@ function loadRules(ctx: LoadContext): LoadResult<Rule> {
items.push({
name: "clinerules",
path: projectPath,
path: found.path,
content: body,
globs,
alwaysApply: typeof frontmatter.alwaysApply === "boolean" ? frontmatter.alwaysApply : undefined,
+177 -236
View File
@@ -7,11 +7,12 @@
* User directory: ~/.codex
*/
import { join } from "path";
import { join } from "node:path";
import { parse as parseToml } from "smol-toml";
import type { ContextFile } from "../capability/context-file";
import { contextFileCapability } from "../capability/context-file";
import { type ExtensionModule, extensionModuleCapability } from "../capability/extension-module";
import { readFile } from "../capability/fs";
import type { Hook } from "../capability/hook";
import { hookCapability } from "../capability/hook";
import { registerProvider } from "../capability/index";
@@ -42,27 +43,28 @@ const PROVIDER_ID = "codex";
const DISPLAY_NAME = "OpenAI Codex";
const PRIORITY = 70;
function getProjectCodexDir(ctx: LoadContext): string {
return join(ctx.cwd, ".codex");
}
// =============================================================================
// Context Files (AGENTS.md)
// =============================================================================
function loadContextFiles(ctx: LoadContext): LoadResult<ContextFile> {
async function loadContextFiles(ctx: LoadContext): Promise<LoadResult<ContextFile>> {
const items: ContextFile[] = [];
const warnings: string[] = [];
// User level only: ~/.codex/AGENTS.md
const userBase = join(ctx.home, SOURCE_PATHS.codex.userBase);
if (ctx.fs.isDir(userBase)) {
const agentsMd = join(userBase, "AGENTS.md");
const agentsContent = ctx.fs.readFile(agentsMd);
if (agentsContent) {
items.push({
path: agentsMd,
content: agentsContent,
level: "user",
_source: createSourceMeta(PROVIDER_ID, agentsMd, "user"),
});
}
const agentsMd = join(ctx.home, SOURCE_PATHS.codex.userBase, "AGENTS.md");
const agentsContent = await readFile(agentsMd);
if (agentsContent) {
items.push({
path: agentsMd,
content: agentsContent,
level: "user",
_source: createSourceMeta(PROVIDER_ID, agentsMd, "user"),
});
}
return { items, warnings };
@@ -72,13 +74,19 @@ function loadContextFiles(ctx: LoadContext): LoadResult<ContextFile> {
// MCP Servers (config.toml)
// =============================================================================
function loadMCPServers(ctx: LoadContext): LoadResult<MCPServer> {
const items: MCPServer[] = [];
async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>> {
const warnings: string[] = [];
// User level: ~/.codex/config.toml
const userConfigPath = join(ctx.home, SOURCE_PATHS.codex.userBase, "config.toml");
const userConfig = loadTomlConfig(ctx, userConfigPath);
const codexDir = getProjectCodexDir(ctx);
const projectConfigPath = join(codexDir, "config.toml");
const [userConfig, projectConfig] = await Promise.all([
loadTomlConfig(ctx, userConfigPath),
loadTomlConfig(ctx, projectConfigPath),
]);
const items: MCPServer[] = [];
if (userConfig) {
const servers = extractMCPServersFromToml(userConfig);
for (const [name, config] of Object.entries(servers)) {
@@ -89,29 +97,22 @@ function loadMCPServers(ctx: LoadContext): LoadResult<MCPServer> {
});
}
}
// Project level: .codex/config.toml
const codexDir = ctx.fs.walkUp(".codex", { dir: true });
if (codexDir) {
const projectConfigPath = join(codexDir, "config.toml");
const projectConfig = loadTomlConfig(ctx, projectConfigPath);
if (projectConfig) {
const servers = extractMCPServersFromToml(projectConfig);
for (const [name, config] of Object.entries(servers)) {
items.push({
name,
...config,
_source: createSourceMeta(PROVIDER_ID, projectConfigPath, "project"),
});
}
if (projectConfig) {
const servers = extractMCPServersFromToml(projectConfig);
for (const [name, config] of Object.entries(servers)) {
items.push({
name,
...config,
_source: createSourceMeta(PROVIDER_ID, projectConfigPath, "project"),
});
}
}
return { items, warnings };
}
function loadTomlConfig(ctx: LoadContext, path: string): Record<string, unknown> | null {
const content = ctx.fs.readFile(path);
async function loadTomlConfig(_ctx: LoadContext, path: string): Promise<Record<string, unknown> | null> {
const content = await readFile(path);
if (!content) return null;
try {
@@ -206,30 +207,26 @@ function extractMCPServersFromToml(toml: Record<string, unknown>): Record<string
// Skills (skills/)
// =============================================================================
function loadSkills(ctx: LoadContext): LoadResult<Skill> {
const items: Skill[] = [];
const warnings: string[] = [];
async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
const userSkillsDir = join(ctx.home, SOURCE_PATHS.codex.userBase, "skills");
const userResult = loadSkillsFromDir(ctx, {
dir: userSkillsDir,
providerId: PROVIDER_ID,
level: "user",
});
items.push(...userResult.items);
if (userResult.warnings) warnings.push(...userResult.warnings);
const codexDir = getProjectCodexDir(ctx);
const projectSkillsDir = join(codexDir, "skills");
const codexDir = ctx.fs.walkUp(".codex", { dir: true });
if (codexDir) {
const projectSkillsDir = join(codexDir, "skills");
const projectResult = loadSkillsFromDir(ctx, {
const results = await Promise.all([
loadSkillsFromDir(ctx, {
dir: userSkillsDir,
providerId: PROVIDER_ID,
level: "user",
}),
loadSkillsFromDir(ctx, {
dir: projectSkillsDir,
providerId: PROVIDER_ID,
level: "project",
});
items.push(...projectResult.items);
if (projectResult.warnings) warnings.push(...projectResult.warnings);
}
}),
]);
const items = results.flatMap((r) => r.items);
const warnings = results.flatMap((r) => r.warnings || []);
return { items, warnings };
}
@@ -238,34 +235,32 @@ function loadSkills(ctx: LoadContext): LoadResult<Skill> {
// Extension Modules (extensions/)
// =============================================================================
function loadExtensionModules(ctx: LoadContext): LoadResult<ExtensionModule> {
const items: ExtensionModule[] = [];
async function loadExtensionModules(ctx: LoadContext): Promise<LoadResult<ExtensionModule>> {
const warnings: string[] = [];
// User level: ~/.codex/extensions/
const userExtensionsDir = join(ctx.home, SOURCE_PATHS.codex.userBase, "extensions");
for (const extPath of discoverExtensionModulePaths(ctx, userExtensionsDir)) {
items.push({
const codexDir = getProjectCodexDir(ctx);
const projectExtensionsDir = join(codexDir, "extensions");
const [userPaths, projectPaths] = await Promise.all([
discoverExtensionModulePaths(ctx, userExtensionsDir),
discoverExtensionModulePaths(ctx, projectExtensionsDir),
]);
const items: ExtensionModule[] = [
...userPaths.map((extPath) => ({
name: getExtensionNameFromPath(extPath),
path: extPath,
level: "user",
level: "user" as const,
_source: createSourceMeta(PROVIDER_ID, extPath, "user"),
});
}
// Project level: .codex/extensions/
const codexDir = ctx.fs.walkUp(".codex", { dir: true });
if (codexDir) {
const projectExtensionsDir = join(codexDir, "extensions");
for (const extPath of discoverExtensionModulePaths(ctx, projectExtensionsDir)) {
items.push({
name: getExtensionNameFromPath(extPath),
path: extPath,
level: "project",
_source: createSourceMeta(PROVIDER_ID, extPath, "project"),
});
}
}
})),
...projectPaths.map((extPath) => ({
name: getExtensionNameFromPath(extPath),
path: extPath,
level: "project" as const,
_source: createSourceMeta(PROVIDER_ID, extPath, "project"),
})),
];
return { items, warnings };
}
@@ -274,52 +269,38 @@ function loadExtensionModules(ctx: LoadContext): LoadResult<ExtensionModule> {
// Slash Commands (commands/)
// =============================================================================
function loadSlashCommands(ctx: LoadContext): LoadResult<SlashCommand> {
const items: SlashCommand[] = [];
const warnings: string[] = [];
// User level: ~/.codex/commands/
async function loadSlashCommands(ctx: LoadContext): Promise<LoadResult<SlashCommand>> {
const userCommandsDir = join(ctx.home, SOURCE_PATHS.codex.userBase, "commands");
const userResult = loadFilesFromDir(ctx, userCommandsDir, PROVIDER_ID, "user", {
extensions: ["md"],
transform: (name, content, path, source) => {
const codexDir = getProjectCodexDir(ctx);
const projectCommandsDir = join(codexDir, "commands");
const transformCommand =
(level: "user" | "project") =>
(name: string, content: string, path: string, source: ReturnType<typeof createSourceMeta>) => {
const { frontmatter, body } = parseFrontmatter(content);
const commandName = frontmatter.name || name.replace(/\.md$/, "");
return {
name: String(commandName),
path,
content: body,
level: "user" as const,
level,
_source: source,
};
},
});
items.push(...userResult.items);
warnings.push(...(userResult.warnings || []));
};
// Project level: .codex/commands/
const codexDir = ctx.fs.walkUp(".codex", { dir: true });
if (codexDir) {
const projectCommandsDir = join(codexDir, "commands");
const projectResult = loadFilesFromDir(ctx, projectCommandsDir, PROVIDER_ID, "project", {
const results = await Promise.all([
loadFilesFromDir(ctx, userCommandsDir, PROVIDER_ID, "user", {
extensions: ["md"],
transform: (name, content, path, source) => {
const { frontmatter, body } = parseFrontmatter(content);
const commandName = frontmatter.name || name.replace(/\.md$/, "");
transform: transformCommand("user"),
}),
loadFilesFromDir(ctx, projectCommandsDir, PROVIDER_ID, "project", {
extensions: ["md"],
transform: transformCommand("project"),
}),
]);
return {
name: String(commandName),
path,
content: body,
level: "project" as const,
_source: source,
};
},
});
items.push(...projectResult.items);
warnings.push(...(projectResult.warnings || []));
}
const items = results.flatMap((r) => r.items);
const warnings = results.flatMap((r) => r.warnings || []);
return { items, warnings };
}
@@ -328,52 +309,41 @@ function loadSlashCommands(ctx: LoadContext): LoadResult<SlashCommand> {
// Prompts (prompts/*.md)
// =============================================================================
function loadPrompts(ctx: LoadContext): LoadResult<Prompt> {
const items: Prompt[] = [];
const warnings: string[] = [];
// User level: ~/.codex/prompts/
async function loadPrompts(ctx: LoadContext): Promise<LoadResult<Prompt>> {
const userPromptsDir = join(ctx.home, SOURCE_PATHS.codex.userBase, "prompts");
const userResult = loadFilesFromDir(ctx, userPromptsDir, PROVIDER_ID, "user", {
extensions: ["md"],
transform: (name, content, path, source) => {
const { frontmatter, body } = parseFrontmatter(content);
const promptName = frontmatter.name || name.replace(/\.md$/, "");
const codexDir = getProjectCodexDir(ctx);
const projectPromptsDir = join(codexDir, "prompts");
return {
name: String(promptName),
path,
content: body,
description: frontmatter.description ? String(frontmatter.description) : undefined,
_source: source,
};
},
});
items.push(...userResult.items);
warnings.push(...(userResult.warnings || []));
const transformPrompt = (
name: string,
content: string,
path: string,
source: ReturnType<typeof createSourceMeta>,
) => {
const { frontmatter, body } = parseFrontmatter(content);
const promptName = frontmatter.name || name.replace(/\.md$/, "");
return {
name: String(promptName),
path,
content: body,
description: frontmatter.description ? String(frontmatter.description) : undefined,
_source: source,
};
};
// Project level: .codex/prompts/
const codexDir = ctx.fs.walkUp(".codex", { dir: true });
if (codexDir) {
const projectPromptsDir = join(codexDir, "prompts");
const projectResult = loadFilesFromDir(ctx, projectPromptsDir, PROVIDER_ID, "project", {
const results = await Promise.all([
loadFilesFromDir(ctx, userPromptsDir, PROVIDER_ID, "user", {
extensions: ["md"],
transform: (name, content, path, source) => {
const { frontmatter, body } = parseFrontmatter(content);
const promptName = frontmatter.name || name.replace(/\.md$/, "");
transform: transformPrompt,
}),
loadFilesFromDir(ctx, projectPromptsDir, PROVIDER_ID, "project", {
extensions: ["md"],
transform: transformPrompt,
}),
]);
return {
name: String(promptName),
path,
content: body,
description: frontmatter.description ? String(frontmatter.description) : undefined,
_source: source,
};
},
});
items.push(...projectResult.items);
warnings.push(...(projectResult.warnings || []));
}
const items = results.flatMap((r) => r.items);
const warnings = results.flatMap((r) => r.warnings || []);
return { items, warnings };
}
@@ -382,59 +352,41 @@ function loadPrompts(ctx: LoadContext): LoadResult<Prompt> {
// Hooks (hooks/)
// =============================================================================
function loadHooks(ctx: LoadContext): LoadResult<Hook> {
const items: Hook[] = [];
const warnings: string[] = [];
// User level: ~/.codex/hooks/
async function loadHooks(ctx: LoadContext): Promise<LoadResult<Hook>> {
const userHooksDir = join(ctx.home, SOURCE_PATHS.codex.userBase, "hooks");
const userResult = loadFilesFromDir(ctx, userHooksDir, PROVIDER_ID, "user", {
extensions: ["ts", "js"],
transform: (name, _content, path, source) => {
// Extract hook type and tool from filename (e.g., pre-bash.ts -> type: pre, tool: bash)
const codexDir = getProjectCodexDir(ctx);
const projectHooksDir = join(codexDir, "hooks");
const transformHook =
(level: "user" | "project") =>
(name: string, _content: string, path: string, source: ReturnType<typeof createSourceMeta>) => {
const baseName = name.replace(/\.(ts|js)$/, "");
const match = baseName.match(/^(pre|post)-(.+)$/);
const hookType = (match?.[1] as "pre" | "post") || "pre";
const toolName = match?.[2] || baseName;
return {
name,
path,
type: hookType,
tool: toolName,
level: "user" as const,
level,
_source: source,
};
},
});
items.push(...userResult.items);
warnings.push(...(userResult.warnings || []));
};
// Project level: .codex/hooks/
const codexDir = ctx.fs.walkUp(".codex", { dir: true });
if (codexDir) {
const projectHooksDir = join(codexDir, "hooks");
const projectResult = loadFilesFromDir(ctx, projectHooksDir, PROVIDER_ID, "project", {
const results = await Promise.all([
loadFilesFromDir(ctx, userHooksDir, PROVIDER_ID, "user", {
extensions: ["ts", "js"],
transform: (name, _content, path, source) => {
const baseName = name.replace(/\.(ts|js)$/, "");
const match = baseName.match(/^(pre|post)-(.+)$/);
const hookType = (match?.[1] as "pre" | "post") || "pre";
const toolName = match?.[2] || baseName;
transform: transformHook("user"),
}),
loadFilesFromDir(ctx, projectHooksDir, PROVIDER_ID, "project", {
extensions: ["ts", "js"],
transform: transformHook("project"),
}),
]);
return {
name,
path,
type: hookType,
tool: toolName,
level: "project" as const,
_source: source,
};
},
});
items.push(...projectResult.items);
warnings.push(...(projectResult.warnings || []));
}
const items = results.flatMap((r) => r.items);
const warnings = results.flatMap((r) => r.warnings || []);
return { items, warnings };
}
@@ -443,46 +395,36 @@ function loadHooks(ctx: LoadContext): LoadResult<Hook> {
// Tools (tools/)
// =============================================================================
function loadTools(ctx: LoadContext): LoadResult<CustomTool> {
const items: CustomTool[] = [];
const warnings: string[] = [];
// User level: ~/.codex/tools/
async function loadTools(ctx: LoadContext): Promise<LoadResult<CustomTool>> {
const userToolsDir = join(ctx.home, SOURCE_PATHS.codex.userBase, "tools");
const userResult = loadFilesFromDir(ctx, userToolsDir, PROVIDER_ID, "user", {
extensions: ["ts", "js"],
transform: (name, _content, path, source) => {
const codexDir = getProjectCodexDir(ctx);
const projectToolsDir = join(codexDir, "tools");
const transformTool =
(level: "user" | "project") =>
(name: string, _content: string, path: string, source: ReturnType<typeof createSourceMeta>) => {
const toolName = name.replace(/\.(ts|js)$/, "");
return {
name: toolName,
path,
level: "user" as const,
level,
_source: source,
} as CustomTool;
},
});
items.push(...userResult.items);
warnings.push(...(userResult.warnings || []));
};
// Project level: .codex/tools/
const codexDir = ctx.fs.walkUp(".codex", { dir: true });
if (codexDir) {
const projectToolsDir = join(codexDir, "tools");
const projectResult = loadFilesFromDir(ctx, projectToolsDir, PROVIDER_ID, "project", {
const results = await Promise.all([
loadFilesFromDir(ctx, userToolsDir, PROVIDER_ID, "user", {
extensions: ["ts", "js"],
transform: (name, _content, path, source) => {
const toolName = name.replace(/\.(ts|js)$/, "");
return {
name: toolName,
path,
level: "project" as const,
_source: source,
} as CustomTool;
},
});
items.push(...projectResult.items);
warnings.push(...(projectResult.warnings || []));
}
transform: transformTool("user"),
}),
loadFilesFromDir(ctx, projectToolsDir, PROVIDER_ID, "project", {
extensions: ["ts", "js"],
transform: transformTool("project"),
}),
]);
const items = results.flatMap((r) => r.items);
const warnings = results.flatMap((r) => r.warnings || []);
return { items, warnings };
}
@@ -491,31 +433,30 @@ function loadTools(ctx: LoadContext): LoadResult<CustomTool> {
// Settings (config.toml)
// =============================================================================
function loadSettings(ctx: LoadContext): LoadResult<Settings> {
const items: Settings[] = [];
async function loadSettings(ctx: LoadContext): Promise<LoadResult<Settings>> {
const warnings: string[] = [];
// User level: ~/.codex/config.toml
const userConfigPath = join(ctx.home, SOURCE_PATHS.codex.userBase, "config.toml");
const userConfig = loadTomlConfig(ctx, userConfigPath);
const codexDir = getProjectCodexDir(ctx);
const projectConfigPath = join(codexDir, "config.toml");
const [userConfig, projectConfig] = await Promise.all([
loadTomlConfig(ctx, userConfigPath),
loadTomlConfig(ctx, projectConfigPath),
]);
const items: Settings[] = [];
if (userConfig) {
items.push({
...userConfig,
_source: createSourceMeta(PROVIDER_ID, userConfigPath, "user"),
} as Settings);
}
// Project level: .codex/config.toml
const codexDir = ctx.fs.walkUp(".codex", { dir: true });
if (codexDir) {
const projectConfigPath = join(codexDir, "config.toml");
const projectConfig = loadTomlConfig(ctx, projectConfigPath);
if (projectConfig) {
items.push({
...projectConfig,
_source: createSourceMeta(PROVIDER_ID, projectConfigPath, "project"),
} as Settings);
}
if (projectConfig) {
items.push({
...projectConfig,
_source: createSourceMeta(PROVIDER_ID, projectConfigPath, "project"),
} as Settings);
}
return { items, warnings };
+106 -121
View File
@@ -6,15 +6,15 @@
*
* Sources:
* - User: ~/.cursor
* - Project: .cursor/ (walks up from cwd)
* - Project: .cursor/ (cwd only)
*
* Capabilities:
* - mcps: From mcp.json with mcpServers key
* - rules: From rules/*.mdc files with MDC frontmatter (description, globs, alwaysApply)
* - settings: From settings.json if present
* - Legacy: .cursorrules file in project root as a single rule
*/
import { readFile } from "../capability/fs";
import { registerProvider } from "../capability/index";
import { type MCPServer, mcpCapability } from "../capability/mcp";
import type { Rule } from "../capability/rule";
@@ -40,66 +40,62 @@ const PRIORITY = 50;
// MCP Servers
// =============================================================================
function loadMCPServers(ctx: LoadContext): LoadResult<MCPServer> {
function parseMCPServers(
content: string,
path: string,
level: "user" | "project",
): { items: MCPServer[]; warning?: string } {
const items: MCPServer[] = [];
const parsed = parseJSON<{ mcpServers?: Record<string, unknown> }>(content);
if (!parsed?.mcpServers) {
return { items, warning: `${path}: missing or invalid 'mcpServers' key` };
}
const servers = expandEnvVarsDeep(parsed.mcpServers);
for (const [name, config] of Object.entries(servers)) {
const serverConfig = config as Record<string, unknown>;
items.push({
name,
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,
transport: ["stdio", "sse", "http"].includes(serverConfig.type as string)
? (serverConfig.type as "stdio" | "sse" | "http")
: undefined,
_source: createSourceMeta(PROVIDER_ID, path, level),
});
}
return { items };
}
async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>> {
const items: MCPServer[] = [];
const warnings: string[] = [];
// User-level: ~/.cursor/mcp.json
const userPath = getUserPath(ctx, "cursor", "mcp.json");
if (userPath && ctx.fs.isFile(userPath)) {
const content = ctx.fs.readFile(userPath);
if (content) {
const parsed = parseJSON<{ mcpServers?: Record<string, unknown> }>(content);
if (parsed?.mcpServers) {
const servers = expandEnvVarsDeep(parsed.mcpServers);
for (const [name, config] of Object.entries(servers)) {
const serverConfig = config as Record<string, unknown>;
items.push({
name,
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,
transport: ["stdio", "sse", "http"].includes(serverConfig.type as string)
? (serverConfig.type as "stdio" | "sse" | "http")
: undefined,
_source: createSourceMeta(PROVIDER_ID, userPath, "user"),
});
}
} else {
warnings.push(`${userPath}: missing or invalid 'mcpServers' key`);
}
}
const [userContent, projectPath] = await Promise.all([
userPath ? readFile(userPath) : Promise.resolve(null),
getProjectPath(ctx, "cursor", "mcp.json"),
]);
const projectContentPromise = projectPath ? readFile(projectPath) : Promise.resolve(null);
if (userContent && userPath) {
const result = parseMCPServers(userContent, userPath, "user");
items.push(...result.items);
if (result.warning) warnings.push(result.warning);
}
// Project-level: .cursor/mcp.json
const projectPath = getProjectPath(ctx, "cursor", "mcp.json");
if (projectPath && ctx.fs.isFile(projectPath)) {
const content = ctx.fs.readFile(projectPath);
if (content) {
const parsed = parseJSON<{ mcpServers?: Record<string, unknown> }>(content);
if (parsed?.mcpServers) {
const servers = expandEnvVarsDeep(parsed.mcpServers);
for (const [name, config] of Object.entries(servers)) {
const serverConfig = config as Record<string, unknown>;
items.push({
name,
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,
transport: ["stdio", "sse", "http"].includes(serverConfig.type as string)
? (serverConfig.type as "stdio" | "sse" | "http")
: undefined,
_source: createSourceMeta(PROVIDER_ID, projectPath, "project"),
});
}
} else {
warnings.push(`${projectPath}: missing or invalid 'mcpServers' key`);
}
}
const projectContent = await projectContentPromise;
if (projectContent && projectPath) {
const result = parseMCPServers(projectContent, projectPath, "project");
items.push(...result.items);
if (result.warning) warnings.push(result.warning);
}
return { items, warnings };
@@ -109,45 +105,34 @@ function loadMCPServers(ctx: LoadContext): LoadResult<MCPServer> {
// Rules
// =============================================================================
function loadRules(ctx: LoadContext): LoadResult<Rule> {
async function loadRules(ctx: LoadContext): Promise<LoadResult<Rule>> {
const items: Rule[] = [];
const warnings: string[] = [];
// Legacy: .cursorrules file in project root
const legacyPath = ctx.fs.walkUp(".cursorrules", { file: true });
if (legacyPath) {
const content = ctx.fs.readFile(legacyPath);
if (content) {
items.push({
name: "cursorrules",
path: legacyPath,
content,
_source: createSourceMeta(PROVIDER_ID, legacyPath, "project"),
});
}
}
// User-level: ~/.cursor/rules/*.mdc
const userRulesPath = getUserPath(ctx, "cursor", "rules");
if (userRulesPath && ctx.fs.isDir(userRulesPath)) {
const result = loadFilesFromDir<Rule>(ctx, userRulesPath, PROVIDER_ID, "user", {
extensions: ["mdc", "md"],
transform: transformMDCRule,
});
items.push(...result.items);
if (result.warnings) warnings.push(...result.warnings);
}
// Project-level: .cursor/rules/*.mdc
const projectRulesPath = getProjectPath(ctx, "cursor", "rules");
if (projectRulesPath && ctx.fs.isDir(projectRulesPath)) {
const result = loadFilesFromDir<Rule>(ctx, projectRulesPath, PROVIDER_ID, "project", {
extensions: ["mdc", "md"],
transform: transformMDCRule,
});
items.push(...result.items);
if (result.warnings) warnings.push(...result.warnings);
}
const [userResult, projectResult] = await Promise.all([
userRulesPath
? loadFilesFromDir<Rule>(ctx, userRulesPath, PROVIDER_ID, "user", {
extensions: ["mdc", "md"],
transform: transformMDCRule,
})
: Promise.resolve({ items: [] as Rule[], warnings: undefined }),
projectRulesPath
? loadFilesFromDir<Rule>(ctx, projectRulesPath, PROVIDER_ID, "project", {
extensions: ["mdc", "md"],
transform: transformMDCRule,
})
: Promise.resolve({ items: [] as Rule[], warnings: undefined }),
]);
items.push(...userResult.items);
if (userResult.warnings) warnings.push(...userResult.warnings);
items.push(...projectResult.items);
if (projectResult.warnings) warnings.push(...projectResult.warnings);
return { items, warnings };
}
@@ -192,45 +177,45 @@ function transformMDCRule(
// Settings
// =============================================================================
function loadSettings(ctx: LoadContext): LoadResult<Settings> {
async function loadSettings(ctx: LoadContext): Promise<LoadResult<Settings>> {
const items: Settings[] = [];
const warnings: string[] = [];
// User-level: ~/.cursor/settings.json
const userPath = getUserPath(ctx, "cursor", "settings.json");
if (userPath && ctx.fs.isFile(userPath)) {
const content = ctx.fs.readFile(userPath);
if (content) {
const parsed = parseJSON<Record<string, unknown>>(content);
if (parsed) {
items.push({
path: userPath,
data: parsed,
level: "user",
_source: createSourceMeta(PROVIDER_ID, userPath, "user"),
});
} else {
warnings.push(`${userPath}: invalid JSON`);
}
const [userContent, projectPath] = await Promise.all([
userPath ? readFile(userPath) : Promise.resolve(null),
getProjectPath(ctx, "cursor", "settings.json"),
]);
const projectContentPromise = projectPath ? readFile(projectPath) : Promise.resolve(null);
if (userContent && userPath) {
const parsed = parseJSON<Record<string, unknown>>(userContent);
if (parsed) {
items.push({
path: userPath,
data: parsed,
level: "user",
_source: createSourceMeta(PROVIDER_ID, userPath, "user"),
});
} else {
warnings.push(`${userPath}: invalid JSON`);
}
}
// Project-level: .cursor/settings.json
const projectPath = getProjectPath(ctx, "cursor", "settings.json");
if (projectPath && ctx.fs.isFile(projectPath)) {
const content = ctx.fs.readFile(projectPath);
if (content) {
const parsed = parseJSON<Record<string, unknown>>(content);
if (parsed) {
items.push({
path: projectPath,
data: parsed,
level: "project",
_source: createSourceMeta(PROVIDER_ID, projectPath, "project"),
});
} else {
warnings.push(`${projectPath}: invalid JSON`);
}
const projectContent = await projectContentPromise;
if (projectContent && projectPath) {
const parsed = parseJSON<Record<string, unknown>>(projectContent);
if (parsed) {
items.push({
path: projectPath,
data: parsed,
level: "project",
_source: createSourceMeta(PROVIDER_ID, projectPath, "project"),
});
} else {
warnings.push(`${projectPath}: invalid JSON`);
}
}
+72 -97
View File
@@ -6,7 +6,7 @@
*
* Sources:
* - User: ~/.gemini
* - Project: .gemini/ (walks up from cwd) or GEMINI.md in ancestors
* - Project: .gemini/ (cwd only)
*
* Capabilities:
* - mcps: From settings.json with mcpServers key
@@ -16,10 +16,11 @@
* - settings: From settings.json
*/
import { dirname, join, sep } from "path";
import { join, sep } from "node:path";
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 { registerProvider } from "../capability/index";
import { type MCPServer, mcpCapability } from "../capability/mcp";
import { type Settings, settingsCapability } from "../capability/settings";
@@ -44,22 +45,22 @@ const PRIORITY = 60;
// MCP Servers
// =============================================================================
function loadMCPServers(ctx: LoadContext): LoadResult<MCPServer> {
async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>> {
const items: MCPServer[] = [];
const warnings: string[] = [];
// User-level: ~/.gemini/settings.json → mcpServers
const userPath = getUserPath(ctx, "gemini", "settings.json");
if (userPath && ctx.fs.isFile(userPath)) {
const result = loadMCPFromSettings(ctx, userPath, "user");
if (userPath) {
const result = await loadMCPFromSettings(ctx, userPath, "user");
items.push(...result.items);
if (result.warnings) warnings.push(...result.warnings);
}
// Project-level: .gemini/settings.json → mcpServers
const projectPath = getProjectPath(ctx, "gemini", "settings.json");
if (projectPath && ctx.fs.isFile(projectPath)) {
const result = loadMCPFromSettings(ctx, projectPath, "project");
if (projectPath) {
const result = await loadMCPFromSettings(ctx, projectPath, "project");
items.push(...result.items);
if (result.warnings) warnings.push(...result.warnings);
}
@@ -67,13 +68,16 @@ function loadMCPServers(ctx: LoadContext): LoadResult<MCPServer> {
return { items, warnings };
}
function loadMCPFromSettings(ctx: LoadContext, path: string, level: "user" | "project"): LoadResult<MCPServer> {
async function loadMCPFromSettings(
_ctx: LoadContext,
path: string,
level: "user" | "project",
): Promise<LoadResult<MCPServer>> {
const items: MCPServer[] = [];
const warnings: string[] = [];
const content = ctx.fs.readFile(path);
const content = await readFile(path);
if (!content) {
warnings.push(`Failed to read ${path}`);
return { items, warnings };
}
@@ -118,14 +122,14 @@ function loadMCPFromSettings(ctx: LoadContext, path: string, level: "user" | "pr
// Context Files
// =============================================================================
function loadContextFiles(ctx: LoadContext): LoadResult<ContextFile> {
async function loadContextFiles(ctx: LoadContext): Promise<LoadResult<ContextFile>> {
const items: ContextFile[] = [];
const warnings: string[] = [];
// User-level: ~/.gemini/GEMINI.md
const userGeminiMd = getUserPath(ctx, "gemini", "GEMINI.md");
if (userGeminiMd && ctx.fs.isFile(userGeminiMd)) {
const content = ctx.fs.readFile(userGeminiMd);
if (userGeminiMd) {
const content = await readFile(userGeminiMd);
if (content) {
items.push({
path: userGeminiMd,
@@ -138,8 +142,8 @@ function loadContextFiles(ctx: LoadContext): LoadResult<ContextFile> {
// Project-level: .gemini/GEMINI.md
const projectGeminiMd = getProjectPath(ctx, "gemini", "GEMINI.md");
if (projectGeminiMd && ctx.fs.isFile(projectGeminiMd)) {
const content = ctx.fs.readFile(projectGeminiMd);
if (projectGeminiMd) {
const content = await readFile(projectGeminiMd);
if (content) {
const projectBase = getProjectPath(ctx, "gemini", "");
const depth = projectBase ? calculateDepth(ctx.cwd, projectBase, sep) : 0;
@@ -154,28 +158,6 @@ function loadContextFiles(ctx: LoadContext): LoadResult<ContextFile> {
}
}
// Also check for GEMINI.md in project root (without .gemini directory)
const rootGeminiMd = ctx.fs.walkUp("GEMINI.md", { file: true });
if (rootGeminiMd) {
const content = ctx.fs.readFile(rootGeminiMd);
if (content) {
// Only add if not already added from .gemini/GEMINI.md
const alreadyAdded = items.some((item) => item.path === rootGeminiMd);
if (!alreadyAdded) {
const fileDir = dirname(rootGeminiMd);
const depth = calculateDepth(ctx.cwd, fileDir, sep);
items.push({
path: rootGeminiMd,
content,
level: "project",
depth,
_source: createSourceMeta(PROVIDER_ID, rootGeminiMd, "project"),
});
}
}
}
return { items, warnings };
}
@@ -183,22 +165,22 @@ function loadContextFiles(ctx: LoadContext): LoadResult<ContextFile> {
// Extensions
// =============================================================================
function loadExtensions(ctx: LoadContext): LoadResult<Extension> {
async function loadExtensions(ctx: LoadContext): Promise<LoadResult<Extension>> {
const items: Extension[] = [];
const warnings: string[] = [];
// User-level: ~/.gemini/extensions/*/gemini-extension.json
const userExtPath = getUserPath(ctx, "gemini", "extensions");
if (userExtPath && ctx.fs.isDir(userExtPath)) {
const result = loadExtensionsFromDir(ctx, userExtPath, "user");
if (userExtPath) {
const result = await loadExtensionsFromDir(userExtPath, "user");
items.push(...result.items);
if (result.warnings) warnings.push(...result.warnings);
}
// Project-level: .gemini/extensions/*/gemini-extension.json
const projectExtPath = getProjectPath(ctx, "gemini", "extensions");
if (projectExtPath && ctx.fs.isDir(projectExtPath)) {
const result = loadExtensionsFromDir(ctx, projectExtPath, "project");
if (projectExtPath) {
const result = await loadExtensionsFromDir(projectExtPath, "project");
items.push(...result.items);
if (result.warnings) warnings.push(...result.warnings);
}
@@ -206,27 +188,24 @@ function loadExtensions(ctx: LoadContext): LoadResult<Extension> {
return { items, warnings };
}
function loadExtensionsFromDir(
ctx: LoadContext,
extensionsDir: string,
level: "user" | "project",
): LoadResult<Extension> {
async function loadExtensionsFromDir(extensionsDir: string, level: "user" | "project"): Promise<LoadResult<Extension>> {
const entries = await readDirEntries(extensionsDir);
const dirEntries = entries.filter((entry) => entry.isDirectory());
const results = await Promise.all(
dirEntries.map(async (entry) => {
const extPath = join(extensionsDir, entry.name);
const manifestPath = join(extPath, "gemini-extension.json");
const content = await readFile(manifestPath);
return { entry, extPath, manifestPath, content };
}),
);
const items: Extension[] = [];
const warnings: string[] = [];
const dirs = ctx.fs.readDir(extensionsDir);
for (const dirName of dirs) {
const extPath = join(extensionsDir, dirName);
if (!ctx.fs.isDir(extPath)) continue;
const manifestPath = join(extPath, "gemini-extension.json");
if (!ctx.fs.isFile(manifestPath)) continue;
const content = ctx.fs.readFile(manifestPath);
if (!content) {
warnings.push(`Failed to read ${manifestPath}`);
continue;
}
for (const { entry, extPath, manifestPath, content } of results) {
if (!content) continue;
const manifest = parseJSON<ExtensionManifest>(content);
if (!manifest) {
@@ -235,7 +214,7 @@ function loadExtensionsFromDir(
}
items.push({
name: manifest.name ?? dirName,
name: manifest.name ?? entry.name,
path: extPath,
manifest,
level,
@@ -250,49 +229,45 @@ function loadExtensionsFromDir(
// Extension Modules
// =============================================================================
function loadExtensionModules(ctx: LoadContext): LoadResult<ExtensionModule> {
const items: ExtensionModule[] = [];
const warnings: string[] = [];
async function loadExtensionModules(ctx: LoadContext): Promise<LoadResult<ExtensionModule>> {
const userExtensionsDir = getUserPath(ctx, "gemini", "extensions");
if (userExtensionsDir) {
for (const extPath of discoverExtensionModulePaths(ctx, userExtensionsDir)) {
items.push({
name: getExtensionNameFromPath(extPath),
path: extPath,
level: "user",
_source: createSourceMeta(PROVIDER_ID, extPath, "user"),
});
}
}
const projectExtensionsDir = getProjectPath(ctx, "gemini", "extensions");
if (projectExtensionsDir) {
for (const extPath of discoverExtensionModulePaths(ctx, projectExtensionsDir)) {
items.push({
name: getExtensionNameFromPath(extPath),
path: extPath,
level: "project",
_source: createSourceMeta(PROVIDER_ID, extPath, "project"),
});
}
}
return { items, warnings };
const [userPaths, projectPaths] = await Promise.all([
userExtensionsDir ? discoverExtensionModulePaths(ctx, userExtensionsDir) : Promise.resolve([]),
projectExtensionsDir ? discoverExtensionModulePaths(ctx, projectExtensionsDir) : Promise.resolve([]),
]);
const items: ExtensionModule[] = [
...userPaths.map((extPath) => ({
name: getExtensionNameFromPath(extPath),
path: extPath,
level: "user" as const,
_source: createSourceMeta(PROVIDER_ID, extPath, "user"),
})),
...projectPaths.map((extPath) => ({
name: getExtensionNameFromPath(extPath),
path: extPath,
level: "project" as const,
_source: createSourceMeta(PROVIDER_ID, extPath, "project"),
})),
];
return { items, warnings: [] };
}
// =============================================================================
// Settings
// =============================================================================
function loadSettings(ctx: LoadContext): LoadResult<Settings> {
async function loadSettings(ctx: LoadContext): Promise<LoadResult<Settings>> {
const items: Settings[] = [];
const warnings: string[] = [];
// User-level: ~/.gemini/settings.json
const userPath = getUserPath(ctx, "gemini", "settings.json");
if (userPath && ctx.fs.isFile(userPath)) {
const content = ctx.fs.readFile(userPath);
if (userPath) {
const content = await readFile(userPath);
if (content) {
const parsed = parseJSON<Record<string, unknown>>(content);
if (parsed) {
@@ -310,8 +285,8 @@ function loadSettings(ctx: LoadContext): LoadResult<Settings> {
// Project-level: .gemini/settings.json
const projectPath = getProjectPath(ctx, "gemini", "settings.json");
if (projectPath && ctx.fs.isFile(projectPath)) {
const content = ctx.fs.readFile(projectPath);
if (projectPath) {
const content = await readFile(projectPath);
if (content) {
const parsed = parseJSON<Record<string, unknown>>(content);
if (parsed) {
@@ -354,13 +329,13 @@ registerProvider(contextFileCapability.id, {
// System Prompt
// =============================================================================
function loadSystemPrompt(ctx: LoadContext): LoadResult<SystemPrompt> {
async function loadSystemPrompt(ctx: LoadContext): Promise<LoadResult<SystemPrompt>> {
const items: SystemPrompt[] = [];
// User-level: ~/.gemini/system.md
const userSystemMd = getUserPath(ctx, "gemini", "system.md");
if (userSystemMd && ctx.fs.isFile(userSystemMd)) {
const content = ctx.fs.readFile(userSystemMd);
if (userSystemMd) {
const content = await readFile(userSystemMd);
if (content) {
items.push({
path: userSystemMd,
@@ -373,8 +348,8 @@ function loadSystemPrompt(ctx: LoadContext): LoadResult<SystemPrompt> {
// Project-level: .gemini/system.md
const projectSystemMd = getProjectPath(ctx, "gemini", "system.md");
if (projectSystemMd && ctx.fs.isFile(projectSystemMd)) {
const content = ctx.fs.readFile(projectSystemMd);
if (projectSystemMd) {
const content = await readFile(projectSystemMd);
if (content) {
items.push({
path: projectSystemMd,
+7 -10
View File
@@ -14,6 +14,7 @@
import { basename, dirname, sep } from "node:path";
import { type ContextFile, contextFileCapability } from "../capability/context-file";
import { readFile } from "../capability/fs";
import { registerProvider } from "../capability/index";
import { type Instruction, instructionCapability } from "../capability/instruction";
import type { LoadContext, LoadResult, SourceMeta } from "../capability/types";
@@ -27,14 +28,13 @@ const PRIORITY = 30;
// Context Files
// =============================================================================
function loadContextFiles(ctx: LoadContext): LoadResult<ContextFile> {
async function loadContextFiles(ctx: LoadContext): Promise<LoadResult<ContextFile>> {
const items: ContextFile[] = [];
const warnings: string[] = [];
// Project-level: .github/copilot-instructions.md
const copilotInstructionsPath = getProjectPath(ctx, "github", "copilot-instructions.md");
if (copilotInstructionsPath && ctx.fs.isFile(copilotInstructionsPath)) {
const content = ctx.fs.readFile(copilotInstructionsPath);
if (copilotInstructionsPath) {
const content = await readFile(copilotInstructionsPath);
if (content) {
const fileDir = dirname(copilotInstructionsPath);
const depth = calculateDepth(ctx.cwd, fileDir, sep);
@@ -46,8 +46,6 @@ function loadContextFiles(ctx: LoadContext): LoadResult<ContextFile> {
depth,
_source: createSourceMeta(PROVIDER_ID, copilotInstructionsPath, "project"),
});
} else {
warnings.push(`Failed to read ${copilotInstructionsPath}`);
}
}
@@ -58,14 +56,13 @@ function loadContextFiles(ctx: LoadContext): LoadResult<ContextFile> {
// Instructions
// =============================================================================
function loadInstructions(ctx: LoadContext): LoadResult<Instruction> {
async function loadInstructions(ctx: LoadContext): Promise<LoadResult<Instruction>> {
const items: Instruction[] = [];
const warnings: string[] = [];
// Project-level: .github/instructions/*.instructions.md
const instructionsDir = getProjectPath(ctx, "github", "instructions");
if (instructionsDir && ctx.fs.isDir(instructionsDir)) {
const result = loadFilesFromDir<Instruction>(ctx, instructionsDir, PROVIDER_ID, "project", {
if (instructionsDir) {
const result = await loadFilesFromDir<Instruction>(ctx, instructionsDir, PROVIDER_ID, "project", {
extensions: ["md"],
transform: transformInstruction,
});
+94 -88
View File
@@ -2,8 +2,9 @@
* Shared helpers for discovery providers.
*/
import { join, resolve } from "path";
import { join, resolve } from "node:path";
import { parse as parseYAML } from "yaml";
import { readDirEntries, readFile } from "../capability/fs";
import type { Skill, SkillFrontmatter } from "../capability/skill";
import type { LoadContext, LoadResult, SourceMeta } from "../capability/types";
@@ -71,16 +72,13 @@ export function getUserPath(ctx: LoadContext, source: SourceId, subpath: string)
}
/**
* Get project-level path for a source (walks up from cwd).
* Get project-level path for a source (cwd only).
*/
export function getProjectPath(ctx: LoadContext, source: SourceId, subpath: string): string | null {
const paths = SOURCE_PATHS[source];
if (!paths.projectDir) return null;
const found = ctx.fs.walkUp(paths.projectDir, { dir: true });
if (!found) return null;
return join(found, subpath);
return join(ctx.cwd, paths.projectDir, subpath);
}
/**
@@ -127,51 +125,54 @@ export function parseFrontmatter(content: string): {
}
}
export function loadSkillsFromDir(
ctx: LoadContext,
export async function loadSkillsFromDir(
_ctx: LoadContext,
options: {
dir: string;
providerId: string;
level: "user" | "project";
requireDescription?: boolean;
},
): LoadResult<Skill> {
): Promise<LoadResult<Skill>> {
const items: Skill[] = [];
const warnings: string[] = [];
const { dir, level, providerId, requireDescription = false } = options;
if (!ctx.fs.isDir(dir)) {
return { items, warnings };
}
const entries = await readDirEntries(dir);
const skillDirs = entries.filter(
(entry) => entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules",
);
for (const name of ctx.fs.readDir(dir)) {
if (name.startsWith(".") || name === "node_modules") continue;
const results = await Promise.all(
skillDirs.map(async (entry) => {
const skillFile = join(dir, entry.name, "SKILL.md");
const content = await readFile(skillFile);
if (!content) {
return { item: null as Skill | null, warning: null as string | null };
}
const skillDir = join(dir, name);
if (!ctx.fs.isDir(skillDir)) continue;
const { frontmatter, body } = parseFrontmatter(content);
if (requireDescription && !frontmatter.description) {
return { item: null as Skill | null, warning: null as string | null };
}
const skillFile = join(skillDir, "SKILL.md");
if (!ctx.fs.isFile(skillFile)) continue;
return {
item: {
name: (frontmatter.name as string) || entry.name,
path: skillFile,
content: body,
frontmatter: frontmatter as SkillFrontmatter,
level,
_source: createSourceMeta(providerId, skillFile, level),
},
warning: null as string | null,
};
}),
);
const content = ctx.fs.readFile(skillFile);
if (!content) {
warnings.push(`Failed to read ${skillFile}`);
continue;
}
const { frontmatter, body } = parseFrontmatter(content);
if (requireDescription && !frontmatter.description) {
continue;
}
items.push({
name: (frontmatter.name as string) || name,
path: skillFile,
content: body,
frontmatter: frontmatter as SkillFrontmatter,
level,
_source: createSourceMeta(providerId, skillFile, level),
});
for (const result of results) {
if (result.warning) warnings.push(result.warning);
if (result.item) items.push(result.item);
}
return { items, warnings };
@@ -213,8 +214,8 @@ export function expandEnvVarsDeep<T>(obj: T, extraEnv?: Record<string, string>):
/**
* Load files from a directory matching a pattern.
*/
export function loadFilesFromDir<T>(
ctx: LoadContext,
export async function loadFilesFromDir<T>(
_ctx: LoadContext,
dir: string,
provider: string,
level: "user" | "project",
@@ -226,37 +227,40 @@ export function loadFilesFromDir<T>(
/** Whether to recurse into subdirectories */
recursive?: boolean;
},
): LoadResult<T> {
): Promise<LoadResult<T>> {
const entries = await readDirEntries(dir);
const visibleEntries = entries.filter((entry) => !entry.name.startsWith("."));
const directories = options.recursive ? visibleEntries.filter((entry) => entry.isDirectory()) : [];
const files = visibleEntries
.filter((entry) => entry.isFile())
.filter((entry) => {
if (!options.extensions) return true;
return options.extensions.some((ext) => entry.name.endsWith(`.${ext}`));
});
const [subResults, fileResults] = await Promise.all([
Promise.all(directories.map((entry) => loadFilesFromDir(_ctx, join(dir, entry.name), provider, level, options))),
Promise.all(
files.map(async (entry) => {
const path = join(dir, entry.name);
const content = await readFile(path);
return { entry, path, content };
}),
),
]);
const items: T[] = [];
const warnings: string[] = [];
if (!ctx.fs.isDir(dir)) {
return { items, warnings };
for (const subResult of subResults) {
items.push(...subResult.items);
if (subResult.warnings) warnings.push(...subResult.warnings);
}
const files = ctx.fs.readDir(dir);
for (const name of files) {
if (name.startsWith(".")) continue;
const path = join(dir, name);
if (options.recursive && ctx.fs.isDir(path)) {
const subResult = loadFilesFromDir(ctx, path, provider, level, options);
items.push(...subResult.items);
if (subResult.warnings) warnings.push(...subResult.warnings);
continue;
}
if (!ctx.fs.isFile(path)) continue;
// Check extension
if (options.extensions) {
const hasMatch = options.extensions.some((ext) => name.endsWith(`.${ext}`));
if (!hasMatch) continue;
}
const content = ctx.fs.readFile(path);
for (const { entry, path, content } of fileResults) {
if (content === null) {
warnings.push(`Failed to read file: ${path}`);
continue;
@@ -265,7 +269,7 @@ export function loadFilesFromDir<T>(
const source = createSourceMeta(provider, path, level);
try {
const item = options.transform(name, content, path, source);
const item = options.transform(entry.name, content, path, source);
if (item !== null) {
items.push(item);
}
@@ -303,8 +307,11 @@ interface ExtensionModuleManifest {
extensions?: string[];
}
function readExtensionModuleManifest(ctx: LoadContext, packageJsonPath: string): ExtensionModuleManifest | null {
const content = ctx.fs.readFile(packageJsonPath);
async function readExtensionModuleManifest(
_ctx: LoadContext,
packageJsonPath: string,
): Promise<ExtensionModuleManifest | null> {
const content = await readFile(packageJsonPath);
if (!content) return null;
const pkg = parseJSON<{ omp?: ExtensionModuleManifest; pi?: ExtensionModuleManifest }>(content);
@@ -329,34 +336,35 @@ function isExtensionModuleFile(name: string): boolean {
*
* No recursion beyond one level. Complex packages must use package.json manifest.
*/
export function discoverExtensionModulePaths(ctx: LoadContext, dir: string): string[] {
if (!ctx.fs.isDir(dir)) {
return [];
}
export async function discoverExtensionModulePaths(ctx: LoadContext, dir: string): Promise<string[]> {
const discovered: string[] = [];
const entries = await readDirEntries(dir);
for (const name of ctx.fs.readDir(dir)) {
if (name.startsWith(".") || name === "node_modules") continue;
for (const entry of entries) {
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
const entryPath = join(dir, name);
const entryPath = join(dir, entry.name);
// 1. Direct files: *.ts or *.js
if (ctx.fs.isFile(entryPath) && isExtensionModuleFile(name)) {
if (entry.isFile() && isExtensionModuleFile(entry.name)) {
discovered.push(entryPath);
continue;
}
// 2 & 3. Subdirectories
if (ctx.fs.isDir(entryPath)) {
if (entry.isDirectory()) {
const subEntries = await readDirEntries(entryPath);
const subFileNames = new Set(subEntries.filter((e) => e.isFile()).map((e) => e.name));
// Check for package.json with "omp"/"pi" field first
const packageJsonPath = join(entryPath, "package.json");
if (ctx.fs.isFile(packageJsonPath)) {
const manifest = readExtensionModuleManifest(ctx, packageJsonPath);
if (subFileNames.has("package.json")) {
const packageJsonPath = join(entryPath, "package.json");
const manifest = await readExtensionModuleManifest(ctx, packageJsonPath);
if (manifest?.extensions && Array.isArray(manifest.extensions)) {
for (const extPath of manifest.extensions) {
const resolvedExtPath = resolve(entryPath, extPath);
if (ctx.fs.isFile(resolvedExtPath)) {
const content = await readFile(resolvedExtPath);
if (content !== null) {
discovered.push(resolvedExtPath);
}
}
@@ -365,12 +373,10 @@ export function discoverExtensionModulePaths(ctx: LoadContext, dir: string): str
}
// Check for index.ts or index.js
const indexTs = join(entryPath, "index.ts");
const indexJs = join(entryPath, "index.js");
if (ctx.fs.isFile(indexTs)) {
discovered.push(indexTs);
} else if (ctx.fs.isFile(indexJs)) {
discovered.push(indexJs);
if (subFileNames.has("index.ts")) {
discovered.push(join(entryPath, "index.ts"));
} else if (subFileNames.has("index.js")) {
discovered.push(join(entryPath, "index.js"));
}
}
}
+1 -2
View File
@@ -58,8 +58,7 @@ export {
isProviderEnabled,
listCapabilities,
// Loading API
load,
loadSync,
loadCapability,
// Cache management
reset,
setDisabledProviders,
+14 -17
View File
@@ -8,6 +8,7 @@
*/
import { join } from "node:path";
import { readFile } from "../capability/fs";
import { registerProvider } from "../capability/index";
import { type MCPServer, mcpCapability } from "../capability/mcp";
import type { LoadContext, LoadResult, SourceMeta } from "../capability/types";
@@ -69,17 +70,16 @@ function transformMCPConfig(config: MCPConfigFile, source: SourceMeta): MCPServe
/**
* Load MCP servers from a JSON file.
*/
function loadMCPJsonFile(ctx: LoadContext, path: string, level: "user" | "project"): LoadResult<MCPServer> {
async function loadMCPJsonFile(
_ctx: LoadContext,
path: string,
level: "user" | "project",
): Promise<LoadResult<MCPServer>> {
const warnings: string[] = [];
const items: MCPServer[] = [];
if (!ctx.fs.isFile(path)) {
return { items, warnings };
}
const content = ctx.fs.readFile(path);
const content = await readFile(path);
if (content === null) {
warnings.push(`Failed to read ${path}`);
return { items, warnings };
}
@@ -99,17 +99,14 @@ function loadMCPJsonFile(ctx: LoadContext, path: string, level: "user" | "projec
/**
* MCP JSON Provider loader.
*/
function load(ctx: LoadContext): LoadResult<MCPServer> {
const allItems: MCPServer[] = [];
const allWarnings: string[] = [];
async function load(ctx: LoadContext): Promise<LoadResult<MCPServer>> {
const filenames = ["mcp.json", ".mcp.json"];
const results = await Promise.all(
filenames.map((filename) => loadMCPJsonFile(ctx, join(ctx.cwd, filename), "project")),
);
// Check for mcp.json or .mcp.json in project root (cwd)
for (const filename of ["mcp.json", ".mcp.json"]) {
const path = join(ctx.cwd, filename);
const result = loadMCPJsonFile(ctx, path, "project");
allItems.push(...result.items);
if (result.warnings) allWarnings.push(...result.warnings);
}
const allItems = results.flatMap((r) => r.items);
const allWarnings = results.flatMap((r) => r.warnings ?? []);
return {
items: allItems,
+9 -17
View File
@@ -6,6 +6,7 @@
*/
import { join } from "node:path";
import { readFile } from "../capability/fs";
import { registerProvider } from "../capability/index";
import { type SSHHost, sshCapability } from "../capability/ssh";
import type { LoadContext, LoadResult, SourceMeta } from "../capability/types";
@@ -90,17 +91,12 @@ function normalizeHost(
};
}
function loadSshJsonFile(ctx: LoadContext, path: string): LoadResult<SSHHost> {
async function loadSshJsonFile(_ctx: LoadContext, path: string): Promise<LoadResult<SSHHost>> {
const items: SSHHost[] = [];
const warnings: string[] = [];
if (!ctx.fs.isFile(path)) {
return { items, warnings };
}
const content = ctx.fs.readFile(path);
const content = await readFile(path);
if (content === null) {
warnings.push(`Failed to read ${path}`);
return { items, warnings };
}
@@ -126,7 +122,7 @@ function loadSshJsonFile(ctx: LoadContext, path: string): LoadResult<SSHHost> {
warnings.push(`Invalid host entry in ${path}: ${name}`);
continue;
}
const host = normalizeHost(name, rawHost, source, ctx.home, warnings);
const host = normalizeHost(name, rawHost, source, _ctx.home, warnings);
if (host) items.push(host);
}
@@ -136,16 +132,12 @@ function loadSshJsonFile(ctx: LoadContext, path: string): LoadResult<SSHHost> {
};
}
function load(ctx: LoadContext): LoadResult<SSHHost> {
const allItems: SSHHost[] = [];
const allWarnings: string[] = [];
async function load(ctx: LoadContext): Promise<LoadResult<SSHHost>> {
const filenames = ["ssh.json", ".ssh.json"];
const results = await Promise.all(filenames.map((filename) => loadSshJsonFile(ctx, join(ctx.cwd, filename))));
for (const filename of ["ssh.json", ".ssh.json"]) {
const path = join(ctx.cwd, filename);
const result = loadSshJsonFile(ctx, path);
allItems.push(...result.items);
if (result.warnings) allWarnings.push(...result.warnings);
}
const allItems = results.flatMap((r) => r.items);
const allWarnings = results.flatMap((r) => r.warnings ?? []);
return {
items: allItems,
+10 -5
View File
@@ -5,6 +5,7 @@
* Supports MCP server discovery from `mcp.json` with nested `mcp.servers` structure.
*/
import { readFile } from "../capability/fs";
import { registerProvider } from "../capability/index";
import { type MCPServer, mcpCapability } from "../capability/mcp";
import type { LoadContext, LoadResult } from "../capability/types";
@@ -23,14 +24,14 @@ registerProvider<MCPServer>(mcpCapability.id, {
displayName: DISPLAY_NAME,
description: "Load MCP servers from .vscode/mcp.json",
priority: PRIORITY,
load(ctx: LoadContext): LoadResult<MCPServer> {
async load(ctx: LoadContext): Promise<LoadResult<MCPServer>> {
const items: MCPServer[] = [];
const warnings: string[] = [];
// Project-only (VS Code doesn't support user-level MCP config)
const projectPath = getProjectPath(ctx, "vscode", "mcp.json");
if (projectPath && ctx.fs.isFile(projectPath)) {
const result = loadMCPConfig(ctx, projectPath, "project");
if (projectPath) {
const result = await loadMCPConfig(ctx, projectPath, "project");
items.push(...result.items);
if (result.warnings) warnings.push(...result.warnings);
}
@@ -43,11 +44,15 @@ registerProvider<MCPServer>(mcpCapability.id, {
* Load MCP servers from a mcp.json file.
* VS Code uses nested structure: { "mcp": { "servers": { ... } } }
*/
function loadMCPConfig(ctx: LoadContext, path: string, level: "user" | "project"): LoadResult<MCPServer> {
async function loadMCPConfig(
_ctx: LoadContext,
path: string,
level: "user" | "project",
): Promise<LoadResult<MCPServer>> {
const items: MCPServer[] = [];
const warnings: string[] = [];
const content = ctx.fs.readFile(path);
const content = await readFile(path);
if (!content) {
warnings.push(`Failed to read ${path}`);
return { items, warnings };
+50 -84
View File
@@ -11,6 +11,7 @@
* - Legacy .windsurfrules file
*/
import { readFile } from "../capability/fs";
import { registerProvider } from "../capability/index";
import { type MCPServer, mcpCapability } from "../capability/mcp";
import { type Rule, ruleCapability } from "../capability/rule";
@@ -33,65 +34,58 @@ const PRIORITY = 50;
// MCP Servers
// =============================================================================
function loadMCPServers(ctx: LoadContext): LoadResult<MCPServer> {
function parseServerConfig(
name: string,
serverConfig: unknown,
path: string,
scope: "user" | "project",
): { server?: MCPServer; warning?: string } {
if (typeof serverConfig !== "object" || serverConfig === null) {
return { warning: `Invalid server config for "${name}" in ${path}` };
}
const server = expandEnvVarsDeep(serverConfig as Record<string, unknown>);
return {
server: {
name,
command: server.command as string | undefined,
args: server.args as string[] | undefined,
env: server.env as Record<string, string> | undefined,
url: server.url as string | undefined,
headers: server.headers as Record<string, string> | undefined,
transport: server.type as "stdio" | "sse" | "http" | undefined,
_source: createSourceMeta(PROVIDER_ID, path, scope),
},
};
}
async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>> {
const items: MCPServer[] = [];
const warnings: string[] = [];
// User-level: ~/.codeium/windsurf/mcp_config.json
const userPath = getUserPath(ctx, "windsurf", "mcp_config.json");
if (userPath && ctx.fs.isFile(userPath)) {
const content = ctx.fs.readFile(userPath);
if (content) {
const config = parseJSON<{ mcpServers?: Record<string, unknown> }>(content);
if (config?.mcpServers) {
for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
if (typeof serverConfig !== "object" || serverConfig === null) {
warnings.push(`Invalid server config for "${name}" in ${userPath}`);
continue;
}
const [userContent, projectPath] = await Promise.all([
userPath ? readFile(userPath) : Promise.resolve(null),
getProjectPath(ctx, "windsurf", "mcp_config.json"),
]);
const server = expandEnvVarsDeep(serverConfig as Record<string, unknown>);
items.push({
name,
command: server.command as string | undefined,
args: server.args as string[] | undefined,
env: server.env as Record<string, string> | undefined,
url: server.url as string | undefined,
headers: server.headers as Record<string, string> | undefined,
transport: server.type as "stdio" | "sse" | "http" | undefined,
_source: createSourceMeta(PROVIDER_ID, userPath, "user"),
});
}
}
}
}
const projectContent = projectPath ? await readFile(projectPath) : null;
// Project-level: .windsurf/mcp_config.json
const projectPath = getProjectPath(ctx, "windsurf", "mcp_config.json");
if (projectPath && ctx.fs.isFile(projectPath)) {
const content = ctx.fs.readFile(projectPath);
if (content) {
const config = parseJSON<{ mcpServers?: Record<string, unknown> }>(content);
if (config?.mcpServers) {
for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
if (typeof serverConfig !== "object" || serverConfig === null) {
warnings.push(`Invalid server config for "${name}" in ${projectPath}`);
continue;
}
const configs: Array<{ content: string | null; path: string | null; scope: "user" | "project" }> = [
{ content: userContent, path: userPath, scope: "user" },
{ content: projectContent, path: projectPath, scope: "project" },
];
const server = expandEnvVarsDeep(serverConfig as Record<string, unknown>);
items.push({
name,
command: server.command as string | undefined,
args: server.args as string[] | undefined,
env: server.env as Record<string, string> | undefined,
url: server.url as string | undefined,
headers: server.headers as Record<string, string> | undefined,
transport: server.type as "stdio" | "sse" | "http" | undefined,
_source: createSourceMeta(PROVIDER_ID, projectPath, "project"),
});
}
}
for (const { content, path, scope } of configs) {
if (!content || !path) continue;
const config = parseJSON<{ mcpServers?: Record<string, unknown> }>(content);
if (!config?.mcpServers) continue;
for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
const result = parseServerConfig(name, serverConfig, path, scope);
if (result.warning) warnings.push(result.warning);
if (result.server) items.push(result.server);
}
}
@@ -102,14 +96,14 @@ function loadMCPServers(ctx: LoadContext): LoadResult<MCPServer> {
// Rules
// =============================================================================
function loadRules(ctx: LoadContext): LoadResult<Rule> {
async function loadRules(ctx: LoadContext): Promise<LoadResult<Rule>> {
const items: Rule[] = [];
const warnings: string[] = [];
// User-level: ~/.codeium/windsurf/memories/global_rules.md
const userPath = getUserPath(ctx, "windsurf", "memories/global_rules.md");
if (userPath && ctx.fs.isFile(userPath)) {
const content = ctx.fs.readFile(userPath);
if (userPath) {
const content = await readFile(userPath);
if (content) {
const { frontmatter, body } = parseFrontmatter(content);
@@ -137,7 +131,7 @@ function loadRules(ctx: LoadContext): LoadResult<Rule> {
// Project-level: .windsurf/rules/*.md
const projectRulesDir = getProjectPath(ctx, "windsurf", "rules");
if (projectRulesDir) {
const result = loadFilesFromDir<Rule>(ctx, projectRulesDir, PROVIDER_ID, "project", {
const result = await loadFilesFromDir<Rule>(ctx, projectRulesDir, PROVIDER_ID, "project", {
extensions: ["md"],
transform: (name, content, path, source) => {
const { frontmatter, body } = parseFrontmatter(content);
@@ -167,34 +161,6 @@ function loadRules(ctx: LoadContext): LoadResult<Rule> {
if (result.warnings) warnings.push(...result.warnings);
}
// Legacy: .windsurfrules in project root
const legacyPath = ctx.fs.walkUp(".windsurfrules", { file: true });
if (legacyPath) {
const content = ctx.fs.readFile(legacyPath);
if (content) {
const { frontmatter, body } = parseFrontmatter(content);
// Validate and normalize globs
let globs: string[] | undefined;
if (Array.isArray(frontmatter.globs)) {
globs = frontmatter.globs.filter((g): g is string => typeof g === "string");
} else if (typeof frontmatter.globs === "string") {
globs = [frontmatter.globs];
}
items.push({
name: "windsurfrules",
path: legacyPath,
content: body,
globs,
alwaysApply: frontmatter.alwaysApply as boolean | undefined,
description: frontmatter.description as string | undefined,
ttsrTrigger: typeof frontmatter.ttsr_trigger === "string" ? frontmatter.ttsr_trigger : undefined,
_source: createSourceMeta(PROVIDER_ID, legacyPath, "project"),
});
}
}
return { items, warnings };
}
+1 -1
View File
@@ -465,7 +465,7 @@ export async function main(args: string[]) {
}
const cwd = process.cwd();
const settingsManager = SettingsManager.create(cwd);
const settingsManager = await SettingsManager.create(cwd);
settingsManager.applyEnvironmentVariables();
time("SettingsManager.create");
const { initialMessage, initialImages } = await prepareInitialMessage(parsed, settingsManager.getImageAutoResize());
@@ -33,22 +33,35 @@ import { applyFilter, createInitialState, filterByProvider, refreshState, toggle
import type { DashboardState } from "./types";
export class ExtensionDashboard extends Container {
private state: DashboardState;
private mainList: ExtensionList;
private inspector: InspectorPanel;
private state!: DashboardState;
private mainList!: ExtensionList;
private inspector!: InspectorPanel;
private settingsManager: SettingsManager | null;
private cwd: string;
private terminalHeight: number;
public onClose?: () => void;
constructor(cwd: string, settingsManager: SettingsManager | null = null, terminalHeight?: number) {
private constructor(cwd: string, settingsManager: SettingsManager | null, terminalHeight: number) {
super();
this.cwd = cwd;
this.settingsManager = settingsManager;
this.terminalHeight = terminalHeight ?? process.stdout.rows ?? 24;
const disabledIds = settingsManager?.getDisabledExtensions() ?? [];
this.state = createInitialState(cwd, disabledIds);
this.terminalHeight = terminalHeight;
}
static async create(
cwd: string,
settingsManager: SettingsManager | null = null,
terminalHeight?: number,
): Promise<ExtensionDashboard> {
const dashboard = new ExtensionDashboard(cwd, settingsManager, terminalHeight ?? process.stdout.rows ?? 24);
await dashboard.init();
return dashboard;
}
private async init(): Promise<void> {
const disabledIds = this.settingsManager?.getDisabledExtensions() ?? [];
this.state = await createInitialState(this.cwd, disabledIds);
// Calculate max visible items based on terminal height
// Reserve ~10 lines for header, tabs, help text, borders
@@ -150,7 +163,7 @@ export class ExtensionDashboard extends Container {
private handleProviderToggle(providerId: string): void {
toggleProvider(providerId);
this.refreshFromState();
void this.refreshFromState();
}
private handleExtensionToggle(extensionId: string, enabled: boolean): void {
@@ -162,15 +175,15 @@ export class ExtensionDashboard extends Container {
this.settingsManager.disableExtension(extensionId);
}
this.refreshFromState();
void this.refreshFromState();
}
private refreshFromState(): void {
private async refreshFromState(): Promise<void> {
// Remember current tab ID before refresh
const currentTabId = this.state.tabs[this.state.activeTabIndex]?.id;
const disabledIds = this.settingsManager?.getDisabledExtensions() ?? [];
this.state = refreshState(this.state, this.cwd, disabledIds);
this.state = await refreshState(this.state, this.cwd, disabledIds);
// Find the same tab in the new (re-sorted) list
if (currentTabId) {
@@ -18,7 +18,7 @@ import {
enableProvider,
getAllProvidersInfo,
isProviderEnabled,
loadSync,
loadCapability,
} from "../../../../discovery";
import type {
DashboardState,
@@ -42,7 +42,7 @@ export interface ExtensionSettingsManager {
/**
* Load all extensions from all capabilities.
*/
export function loadAllExtensions(cwd?: string, disabledIds?: string[]): Extension[] {
export async function loadAllExtensions(cwd?: string, disabledIds?: string[]): Promise<Extension[]> {
const extensions: Extension[] = [];
const disabledExtensions = new Set<string>(disabledIds ?? []);
@@ -100,7 +100,7 @@ export function loadAllExtensions(cwd?: string, disabledIds?: string[]): Extensi
// Load skills
try {
const skills = loadSync<Skill>("skills", loadOpts);
const skills = await loadCapability<Skill>("skills", loadOpts);
addItems(skills.all, "skill", {
getDescription: (s) => s.frontmatter?.description,
getTrigger: (s) => s.frontmatter?.globs?.join(", "),
@@ -111,7 +111,7 @@ export function loadAllExtensions(cwd?: string, disabledIds?: string[]): Extensi
// Load rules
try {
const rules = loadSync<Rule>("rules", loadOpts);
const rules = await loadCapability<Rule>("rules", loadOpts);
addItems(rules.all, "rule", {
getDescription: (r) => r.description,
getTrigger: (r) => r.globs?.join(", ") || (r.alwaysApply ? "always" : undefined),
@@ -122,7 +122,7 @@ export function loadAllExtensions(cwd?: string, disabledIds?: string[]): Extensi
// Load custom tools
try {
const tools = loadSync<CustomTool>("tools", loadOpts);
const tools = await loadCapability<CustomTool>("tools", loadOpts);
addItems(tools.all, "tool", {
getDescription: (t) => t.description,
});
@@ -132,7 +132,7 @@ export function loadAllExtensions(cwd?: string, disabledIds?: string[]): Extensi
// Load extension modules
try {
const modules = loadSync<ExtensionModule>("extension-modules", loadOpts);
const modules = await loadCapability<ExtensionModule>("extension-modules", loadOpts);
const nativeModules = modules.all.filter((module) => module._source.provider === "native");
addItems(nativeModules, "extension-module");
} catch {
@@ -141,7 +141,7 @@ export function loadAllExtensions(cwd?: string, disabledIds?: string[]): Extensi
// Load MCP servers
try {
const mcps = loadSync<MCPServer>("mcps", loadOpts);
const mcps = await loadCapability<MCPServer>("mcps", loadOpts);
for (const server of mcps.all) {
const id = makeExtensionId("mcp", server.name);
const isDisabled = disabledExtensions.has(id);
@@ -184,7 +184,7 @@ export function loadAllExtensions(cwd?: string, disabledIds?: string[]): Extensi
// Load prompts
try {
const prompts = loadSync<Prompt>("prompts", loadOpts);
const prompts = await loadCapability<Prompt>("prompts", loadOpts);
addItems(prompts.all, "prompt", {
getDescription: () => undefined,
getTrigger: (p) => `/prompts:${p.name}`,
@@ -195,7 +195,7 @@ export function loadAllExtensions(cwd?: string, disabledIds?: string[]): Extensi
// Load slash commands
try {
const commands = loadSync<SlashCommand>("slash-commands", loadOpts);
const commands = await loadCapability<SlashCommand>("slash-commands", loadOpts);
addItems(commands.all, "slash-command", {
getDescription: () => undefined,
getTrigger: (c) => `/${c.name}`,
@@ -206,7 +206,7 @@ export function loadAllExtensions(cwd?: string, disabledIds?: string[]): Extensi
// Load hooks
try {
const hooks = loadSync<Hook>("hooks", loadOpts);
const hooks = await loadCapability<Hook>("hooks", loadOpts);
for (const hook of hooks.all) {
const id = makeExtensionId("hook", `${hook.type}:${hook.tool}:${hook.name}`);
const isDisabled = disabledExtensions.has(id);
@@ -249,7 +249,7 @@ export function loadAllExtensions(cwd?: string, disabledIds?: string[]): Extensi
// Load context files
try {
const contextFiles = loadSync<ContextFile>("context-files", loadOpts);
const contextFiles = await loadCapability<ContextFile>("context-files", loadOpts);
for (const file of contextFiles.all) {
// Extract filename from path for display
const name = file.path.split("/").pop() || file.path;
@@ -511,8 +511,8 @@ export function filterByProvider(extensions: Extension[], providerId: string): E
/**
* Create initial dashboard state.
*/
export function createInitialState(cwd?: string, disabledIds?: string[]): DashboardState {
const extensions = loadAllExtensions(cwd, disabledIds);
export async function createInitialState(cwd?: string, disabledIds?: string[]): Promise<DashboardState> {
const extensions = await loadAllExtensions(cwd, disabledIds);
const tabs = buildProviderTabs(extensions);
const tabFiltered = extensions; // "all" tab by default
const searchFiltered = tabFiltered;
@@ -546,8 +546,12 @@ export function toggleProvider(providerId: string): boolean {
/**
* Refresh state after toggle.
*/
export function refreshState(state: DashboardState, cwd?: string, disabledIds?: string[]): DashboardState {
const extensions = loadAllExtensions(cwd, disabledIds);
export async function refreshState(
state: DashboardState,
cwd?: string,
disabledIds?: string[],
): Promise<DashboardState> {
const extensions = await loadAllExtensions(cwd, disabledIds);
const tabs = buildProviderTabs(extensions);
// Get current provider from tabs
@@ -111,9 +111,13 @@ export class SelectorController {
* Show the Extension Control Center dashboard.
* Replaces /status with a unified view of all providers and extensions.
*/
showExtensionsDashboard(): void {
async showExtensionsDashboard(): Promise<void> {
const dashboard = await ExtensionDashboard.create(
process.cwd(),
this.ctx.settingsManager,
this.ctx.ui.terminal.rows,
);
this.showSelector((done) => {
const dashboard = new ExtensionDashboard(process.cwd(), this.ctx.settingsManager, this.ctx.ui.terminal.rows);
dashboard.onClose = () => {
done();
this.ctx.ui.requestRender();
@@ -112,6 +112,7 @@ export class InteractiveMode implements InteractiveModeContext {
public lastStatusText: Text | undefined = undefined;
public fileSlashCommands: Set<string> = new Set();
private pendingSlashCommands: SlashCommand[] = [];
private cleanupUnsubscribe?: () => void;
private readonly version: string;
private readonly changelogMarkdown: string | undefined;
@@ -210,14 +211,6 @@ export class InteractiveMode implements InteractiveModeContext {
{ name: "exit", description: "Exit the application" },
];
// Load and convert file commands to SlashCommand format
const fileCommands = loadSlashCommands({ cwd: process.cwd() });
this.fileSlashCommands = new Set(fileCommands.map((cmd) => cmd.name));
const fileSlashCommands: SlashCommand[] = fileCommands.map((cmd) => ({
name: cmd.name,
description: cmd.description,
}));
// Convert hook commands to SlashCommand format
const hookCommands: SlashCommand[] = (this.session.extensionRunner?.getRegisteredCommands() ?? []).map((cmd) => ({
name: cmd.name,
@@ -230,12 +223,8 @@ export class InteractiveMode implements InteractiveModeContext {
description: `${loaded.command.description} (${loaded.source})`,
}));
// Setup autocomplete
const autocompleteProvider = new CombinedAutocompleteProvider(
[...slashCommands, ...fileSlashCommands, ...hookCommands, ...customCommands],
process.cwd(),
);
this.editor.setAutocompleteProvider(autocompleteProvider);
// Store pending commands for init() where file commands are loaded async
this.pendingSlashCommands = [...slashCommands, ...hookCommands, ...customCommands];
this.uiHelpers = new UiHelpers(this);
this.voiceManager = new VoiceManager(this);
@@ -252,6 +241,21 @@ export class InteractiveMode implements InteractiveModeContext {
// Register session manager flush for signal handlers (SIGINT, SIGTERM, SIGHUP)
this.cleanupUnsubscribe = registerAsyncCleanup(() => this.sessionManager.flush());
// Load and convert file commands to SlashCommand format (async)
const fileCommands = await loadSlashCommands({ cwd: process.cwd() });
this.fileSlashCommands = new Set(fileCommands.map((cmd) => cmd.name));
const fileSlashCommands: SlashCommand[] = fileCommands.map((cmd) => ({
name: cmd.name,
description: cmd.description,
}));
// Setup autocomplete with all commands
const autocompleteProvider = new CombinedAutocompleteProvider(
[...this.pendingSlashCommands, ...fileSlashCommands],
process.cwd(),
);
this.editor.setAutocompleteProvider(autocompleteProvider);
// Get current model info for welcome screen
const modelName = this.session.model?.name ?? "Unknown";
const providerName = this.session.model?.provider ?? "Unknown";
@@ -560,7 +564,7 @@ export class InteractiveMode implements InteractiveModeContext {
}
showExtensionsDashboard(): void {
this.selectorController.showExtensionsDashboard();
void this.selectorController.showExtensionsDashboard();
}
showModelSelector(options?: { temporaryOnly?: boolean }): void {
+2 -2
View File
@@ -92,12 +92,12 @@ function buildConfig(shell: string): ShellConfig {
* 3. On Unix: $SHELL if bash/zsh, then fallback paths
* 4. Fallback: sh
*/
export function getShellConfig(): ShellConfig {
export async function getShellConfig(): Promise<ShellConfig> {
if (cachedShellConfig) {
return cachedShellConfig;
}
const settings = SettingsManager.create();
const settings = await SettingsManager.create();
const customShellPath = settings.getShellPath();
// 1. Check user-specified shell path
@@ -62,7 +62,7 @@ describe.skipIf(!API_KEY)("AgentSession branching", () => {
});
sessionManager = noSession ? SessionManager.inMemory() : SessionManager.create(tempDir);
const settingsManager = SettingsManager.create(tempDir, tempDir);
const settingsManager = await SettingsManager.create(tempDir, tempDir);
const authStorage = new AuthStorage(join(tempDir, "auth.json"));
const modelRegistry = new ModelRegistry(authStorage, tempDir);
@@ -80,7 +80,7 @@ describe.skipIf(!API_KEY)("AgentSession branching", () => {
}
it("should allow branching from single message", async () => {
createSession();
await createSession();
// Send one message
await session.prompt("Say hello");
@@ -105,7 +105,7 @@ describe.skipIf(!API_KEY)("AgentSession branching", () => {
});
it("should support in-memory branching in --no-session mode", async () => {
createSession(true);
await createSession(true);
// Verify sessions are disabled
expect(session.sessionFile).toBeUndefined();
@@ -134,7 +134,7 @@ describe.skipIf(!API_KEY)("AgentSession branching", () => {
});
it("should branch from middle of conversation", async () => {
createSession();
await createSession();
// Send multiple messages
await session.prompt("Say one");
@@ -66,7 +66,7 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
});
sessionManager = inMemory ? SessionManager.inMemory() : SessionManager.create(tempDir);
const settingsManager = SettingsManager.create(tempDir, tempDir);
const settingsManager = await SettingsManager.create(tempDir, tempDir);
// Use minimal keepRecentTokens so small test conversations have something to summarize
settingsManager.applyOverrides({ compaction: { keepRecentTokens: 1 } });
const authStorage = new AuthStorage(join(tempDir, "auth.json"));
@@ -88,7 +88,7 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
}
it("should trigger manual compaction via compact()", async () => {
createSession();
await createSession();
// Send a few prompts to build up history
await session.prompt("What is 2+2? Reply with just the number.");
@@ -114,7 +114,7 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
}, 120000);
it("should maintain valid session state after compaction", async () => {
createSession();
await createSession();
// Build up history
await session.prompt("What is the capital of France? One word answer.");
@@ -139,7 +139,7 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
}, 180000);
it("should persist compaction to session file", async () => {
createSession();
await createSession();
await session.prompt("Say hello");
await session.agent.waitForIdle();
@@ -167,7 +167,7 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
}, 120000);
it("should work with --no-session mode (in-memory only)", async () => {
createSession(true); // in-memory mode
await createSession(true); // in-memory mode
// Send prompts
await session.prompt("What is 2+2? Reply with just the number.");
@@ -189,7 +189,7 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
}, 120000);
it("should emit correct events during auto-compaction", async () => {
createSession();
await createSession();
// Build some history
await session.prompt("Say hello");
@@ -67,7 +67,7 @@ describe("AgentSession concurrent prompt guard", () => {
}
});
function createSession() {
async function createSession() {
const model = getModel("anthropic", "claude-sonnet-4-5")!;
let abortSignal: AbortSignal | undefined;
@@ -98,7 +98,7 @@ describe("AgentSession concurrent prompt guard", () => {
});
const sessionManager = SessionManager.inMemory();
const settingsManager = SettingsManager.create(tempDir, tempDir);
const settingsManager = await SettingsManager.create(tempDir, tempDir);
const authStorage = new AuthStorage(join(tempDir, "auth.json"));
const modelRegistry = new ModelRegistry(authStorage, tempDir);
// Set a runtime API key so validation passes
@@ -115,7 +115,7 @@ describe("AgentSession concurrent prompt guard", () => {
}
it("should throw when prompt() called while streaming", async () => {
createSession();
await createSession();
// Start first prompt (don't await, it will block until abort)
const firstPrompt = session.prompt("First message");
@@ -137,7 +137,7 @@ describe("AgentSession concurrent prompt guard", () => {
});
it("should allow steer() while streaming", async () => {
createSession();
await createSession();
// Start first prompt
const firstPrompt = session.prompt("First message");
@@ -153,7 +153,7 @@ describe("AgentSession concurrent prompt guard", () => {
});
it("should allow followUp() while streaming", async () => {
createSession();
await createSession();
// Start first prompt
const firstPrompt = session.prompt("First message");
@@ -189,7 +189,7 @@ describe("AgentSession concurrent prompt guard", () => {
});
const sessionManager = SessionManager.inMemory();
const settingsManager = SettingsManager.create(tempDir, tempDir);
const settingsManager = await SettingsManager.create(tempDir, tempDir);
const authStorage = new AuthStorage(join(tempDir, "auth.json"));
const modelRegistry = new ModelRegistry(authStorage, tempDir);
authStorage.setRuntimeApiKey("anthropic", "test-key");
@@ -103,7 +103,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
});
const sessionManager = SessionManager.create(tempDir);
const settingsManager = SettingsManager.create(tempDir, tempDir);
const settingsManager = await SettingsManager.create(tempDir, tempDir);
const authStorage = new AuthStorage(join(tempDir, "auth.json"));
const modelRegistry = new ModelRegistry(authStorage);
@@ -73,9 +73,7 @@ describe.skipIf(!HAS_ANTIGRAVITY_AUTH)("Compaction with thinking models (Antigra
});
const sessionManager = SessionManager.inMemory();
const settingsManager = SettingsManager.create(tempDir, tempDir);
// Use minimal keepRecentTokens so small test conversations have something to summarize
// settingsManager.applyOverrides({ compaction: { keepRecentTokens: 1 } });
const settingsManager = await SettingsManager.create(tempDir, tempDir);
const authStorage = new AuthStorage(join(tempDir, "auth.json"));
const modelRegistry = new ModelRegistry(authStorage);
@@ -177,7 +175,7 @@ describe.skipIf(!HAS_ANTHROPIC_AUTH)("Compaction with thinking models (Anthropic
});
const sessionManager = SessionManager.inMemory();
const settingsManager = SettingsManager.create(tempDir, tempDir);
const settingsManager = await SettingsManager.create(tempDir, tempDir);
const authStorage = new AuthStorage(join(tempDir, "auth.json"));
const modelRegistry = new ModelRegistry(authStorage);
@@ -32,7 +32,7 @@ describe("SettingsManager", () => {
// Tests that SettingsManager merges with DB state on save rather than blindly overwriting.
// This ensures external edits (via AgentStorage directly) aren't lost when the app saves.
describe("preserves externally added settings", () => {
it("should preserve enabledModels when changing thinking level", () => {
it("should preserve enabledModels when changing thinking level", async () => {
// Seed initial settings in DB
const storage = AgentStorage.open(getAgentDbPath(agentDir));
storage.saveSettings({
@@ -41,7 +41,7 @@ describe("SettingsManager", () => {
});
// Manager loads the initial state
const manager = SettingsManager.create(projectDir, agentDir);
const manager = await SettingsManager.create(projectDir, agentDir);
// Simulate external edit (e.g., user modifying DB directly or another process)
storage.saveSettings({
@@ -60,13 +60,13 @@ describe("SettingsManager", () => {
expect(savedSettings.modelRoles?.default).toBe("claude-sonnet");
});
it("should preserve custom settings when changing theme", () => {
it("should preserve custom settings when changing theme", async () => {
const storage = AgentStorage.open(getAgentDbPath(agentDir));
storage.saveSettings({
modelRoles: { default: "claude-sonnet" },
});
const manager = SettingsManager.create(projectDir, agentDir);
const manager = await SettingsManager.create(projectDir, agentDir);
storage.saveSettings({
modelRoles: { default: "claude-sonnet" },
@@ -82,13 +82,13 @@ describe("SettingsManager", () => {
expect(savedSettings.theme).toBe("light");
});
it("should let in-memory changes override file changes for same key", () => {
it("should let in-memory changes override file changes for same key", async () => {
const storage = AgentStorage.open(getAgentDbPath(agentDir));
storage.saveSettings({
theme: "dark",
});
const manager = SettingsManager.create(projectDir, agentDir);
const manager = await SettingsManager.create(projectDir, agentDir);
storage.saveSettings({
theme: "dark",
+21 -21
View File
@@ -222,8 +222,8 @@ describe("skills", () => {
});
describe("loadSkills with options", () => {
it("should load from customDirectories only when built-ins disabled", () => {
const { skills } = loadSkills({
it("should load from customDirectories only when built-ins disabled", async () => {
const { skills } = await loadSkills({
enableCodexUser: false,
enableClaudeUser: false,
enableClaudeProject: false,
@@ -236,8 +236,8 @@ describe("skills", () => {
expect(skills.every((s) => s.source.startsWith("custom"))).toBe(true);
});
it("should filter out ignoredSkills", () => {
const { skills } = loadSkills({
it("should filter out ignoredSkills", async () => {
const { skills } = await loadSkills({
enableCodexUser: false,
enableClaudeUser: false,
enableClaudeProject: false,
@@ -249,8 +249,8 @@ describe("skills", () => {
expect(skills).toHaveLength(0);
});
it("should support glob patterns in ignoredSkills", () => {
const { skills } = loadSkills({
it("should support glob patterns in ignoredSkills", async () => {
const { skills } = await loadSkills({
enableCodexUser: false,
enableClaudeUser: false,
enableClaudeProject: false,
@@ -262,8 +262,8 @@ describe("skills", () => {
expect(skills.every((s) => !s.name.startsWith("valid-"))).toBe(true);
});
it("should have ignoredSkills take precedence over includeSkills", () => {
const { skills } = loadSkills({
it("should have ignoredSkills take precedence over includeSkills", async () => {
const { skills } = await loadSkills({
enableCodexUser: false,
enableClaudeUser: false,
enableClaudeProject: false,
@@ -277,9 +277,9 @@ describe("skills", () => {
expect(skills.every((s) => s.name !== "valid-skill")).toBe(true);
});
it("should expand ~ in customDirectories", () => {
it("should expand ~ in customDirectories", async () => {
const homeSkillsDir = join(homedir(), ".omp/agent/skills");
const { skills: withTilde } = loadSkills({
const { skills: withTilde } = await loadSkills({
enableCodexUser: false,
enableClaudeUser: false,
enableClaudeProject: false,
@@ -287,7 +287,7 @@ describe("skills", () => {
enablePiProject: false,
customDirectories: ["~/.omp/agent/skills"],
});
const { skills: withoutTilde } = loadSkills({
const { skills: withoutTilde } = await loadSkills({
enableCodexUser: false,
enableClaudeUser: false,
enableClaudeProject: false,
@@ -298,8 +298,8 @@ describe("skills", () => {
expect(withTilde.length).toBe(withoutTilde.length);
});
it("should return empty when all sources disabled and no custom dirs", () => {
const { skills } = loadSkills({
it("should return empty when all sources disabled and no custom dirs", async () => {
const { skills } = await loadSkills({
enableCodexUser: false,
enableClaudeUser: false,
enableClaudeProject: false,
@@ -309,9 +309,9 @@ describe("skills", () => {
expect(skills).toHaveLength(0);
});
it("should filter skills with includeSkills glob patterns", () => {
it("should filter skills with includeSkills glob patterns", async () => {
// Load all skills from fixtures
const { skills: allSkills } = loadSkills({
const { skills: allSkills } = await loadSkills({
enableCodexUser: false,
enableClaudeUser: false,
enableClaudeProject: false,
@@ -322,7 +322,7 @@ describe("skills", () => {
expect(allSkills.length).toBeGreaterThan(0);
// Filter to only include "valid-skill"
const { skills: filtered } = loadSkills({
const { skills: filtered } = await loadSkills({
enableCodexUser: false,
enableClaudeUser: false,
enableClaudeProject: false,
@@ -335,8 +335,8 @@ describe("skills", () => {
expect(filtered[0].name).toBe("valid-skill");
});
it("should support glob patterns in includeSkills", () => {
const { skills } = loadSkills({
it("should support glob patterns in includeSkills", async () => {
const { skills } = await loadSkills({
enableCodexUser: false,
enableClaudeUser: false,
enableClaudeProject: false,
@@ -349,8 +349,8 @@ describe("skills", () => {
expect(skills.every((s) => s.name.startsWith("valid-"))).toBe(true);
});
it("should return all skills when includeSkills is empty", () => {
const { skills: withEmpty } = loadSkills({
it("should return all skills when includeSkills is empty", async () => {
const { skills: withEmpty } = await loadSkills({
enableCodexUser: false,
enableClaudeUser: false,
enableClaudeProject: false,
@@ -359,7 +359,7 @@ describe("skills", () => {
customDirectories: [fixturesDir],
includeSkills: [],
});
const { skills: withoutOption } = loadSkills({
const { skills: withoutOption } = await loadSkills({
enableCodexUser: false,
enableClaudeUser: false,
enableClaudeProject: false,
@@ -3,8 +3,8 @@ import { buildSystemPrompt } from "../src/core/system-prompt";
describe("buildSystemPrompt", () => {
describe("empty tools", () => {
test("shows (none) for empty tools list", () => {
const prompt = buildSystemPrompt({
test("shows (none) for empty tools list", async () => {
const prompt = await buildSystemPrompt({
toolNames: [],
contextFiles: [],
skills: [],
@@ -14,8 +14,8 @@ describe("buildSystemPrompt", () => {
expect(prompt).toContain("<tools>\n(none)\n</tools>");
});
test("shows file paths guideline even with no tools", () => {
const prompt = buildSystemPrompt({
test("shows file paths guideline even with no tools", async () => {
const prompt = await buildSystemPrompt({
toolNames: [],
contextFiles: [],
skills: [],
@@ -26,8 +26,8 @@ describe("buildSystemPrompt", () => {
});
describe("default tools", () => {
test("includes all default tools", () => {
const prompt = buildSystemPrompt({
test("includes all default tools", async () => {
const prompt = await buildSystemPrompt({
contextFiles: [],
skills: [],
});
+1 -1
View File
@@ -410,7 +410,7 @@ function b() {
});
it("should handle process spawn errors", async () => {
const getShellConfigSpy = vi.spyOn(shellModule, "getShellConfig").mockReturnValueOnce({
const getShellConfigSpy = vi.spyOn(shellModule, "getShellConfig").mockResolvedValueOnce({
shell: "/nonexistent-shell-path-xyz123",
args: ["-c"],
env: {},
+1 -1
View File
@@ -100,7 +100,7 @@ export async function createTestSession(options: TestSessionOptions = {}): Promi
});
const sessionManager = options.inMemory ? SessionManager.inMemory() : SessionManager.create(tempDir);
const settingsManager = SettingsManager.create(tempDir, tempDir);
const settingsManager = await SettingsManager.create(tempDir, tempDir);
if (options.settingsOverrides) {
settingsManager.applyOverrides(options.settingsOverrides);