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:
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,8 +58,7 @@ export {
|
||||
isProviderEnabled,
|
||||
listCapabilities,
|
||||
// Loading API
|
||||
load,
|
||||
loadSync,
|
||||
loadCapability,
|
||||
// Cache management
|
||||
reset,
|
||||
setDisabledProviders,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
+24
-11
@@ -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) {
|
||||
|
||||
+19
-15
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: [],
|
||||
});
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user