refactor: migrated imports to unified package root and consolidated skill discovery logic

- Consolidated @oh-my-pi/pi-utils subpath imports into single package root import across 100+ files.
- Moved tryParseJson utility from local web scrapers module to @oh-my-pi/pi-utils package for centralized JSON parsing.
- Renamed loadSkillsFromDir to scanSkillsFromDir and refactored skill discovery to use fs.promises.readdir instead of glob-based approach.
- Replaced custom parseJSON with tryParseJson across discovery modules for consistent error handling.
- Removed emitCustomToolSessionEvent method and cleanupSshResources function, consolidating shutdown logic into dispose method.
- Updated glob pattern construction to use GlobBuilder with literal_separator(true) for improved path handling.
This commit is contained in:
can1357
2026-02-23 20:59:17 +01:00
parent 90b2b881fc
commit a83175c94c
178 changed files with 608 additions and 655 deletions
+4 -2
View File
@@ -1,7 +1,7 @@
//! Shared glob-pattern helpers used by both [`crate::glob`] and
//! [`crate::grep`].
use globset::{Glob, GlobSet, GlobSetBuilder};
use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
use napi::bindgen_prelude::*;
/// Normalize a raw glob string: fix path separators, optionally prepend `**/`
@@ -23,7 +23,9 @@ pub fn build_glob_pattern(glob: &str, recursive: bool) -> String {
pub fn compile_glob(glob: &str, recursive: bool) -> Result<GlobSet> {
let mut builder = GlobSetBuilder::new();
let pattern = build_glob_pattern(glob, recursive);
let glob = Glob::new(&pattern)
let glob = GlobBuilder::new(&pattern)
.literal_separator(true)
.build()
.map_err(|err| Error::from_reason(format!("Invalid glob pattern: {err}")))?;
builder.add(glob);
builder
+8 -7
View File
@@ -30,7 +30,7 @@ For provider-based discovery (native/Claude/Codex/Agents/plugin providers), skil
Nested patterns like `<skills-root>/group/<skill>/SKILL.md` are not discovered by provider loaders.
For `skills.customDirectories`, scanning is recursive and treats any directory containing `SKILL.md` as a skill root.
For `skills.customDirectories`, scanning uses the same non-recursive layout (`*/SKILL.md`).
```text
Provider-discovered layout (non-recursive under skills/):
@@ -44,7 +44,7 @@ Provider-discovered layout (non-recursive under skills/):
└─ internal/
└─ SKILL.md ❌ not discovered by provider loaders
Custom-directory scanning is recursive, so the same nested path is valid when that parent is listed in `skills.customDirectories`.
Custom-directory scanning is also non-recursive, so nested paths are ignored unless you point `customDirectories` at that nested parent.
```
@@ -63,15 +63,15 @@ Current runtime behavior:
- `name` defaults to the skill directory name
- `description` is required for:
- native `.omp` provider skill discovery (`requireDescription: true`)
- `skills.customDirectories` scan in `extensibility/skills.ts`
- `skills.customDirectories` scans via `scanSkillsFromDir` in `src/discovery/helpers.ts` (non-recursive)
- non-native providers can load skills without description
## Discovery pipeline
`loadSkills()` in `src/extensibility/skills.ts` does two passes:
`discoverSkills()` in `src/extensibility/skills.ts` does two passes:
1. **Capability providers** via `loadCapability("skills")`
2. **Custom directories** via recursive scan of `skills.customDirectories`
2. **Custom directories** via `scanSkillsFromDir(..., { requireDescription: true })` (one-level directory enumeration)
If `skills.enabled` is `false`, discovery returns no skills.
@@ -92,7 +92,7 @@ Dedup key is skill name. First item with a given name wins.
### Source toggles and filtering
`loadSkills()` applies these controls:
`discoverSkills()` applies these controls:
- source toggles: `enableCodexUser`, `enableClaudeUser`, `enableClaudeProject`, `enablePiUser`, `enablePiProject`
- glob filters on skill name:
@@ -113,6 +113,7 @@ For providers other than codex/claude/native (for example `agents`, `claude-plug
- `extensibility/skills.ts` additionally:
- de-duplicates identical files by `realpath` (symlink-safe)
- emits collision warnings when a later skill name conflicts
- keeps the convenience `discoverSkillsFromDir({ dir, source })` API as a thin adapter over `scanSkillsFromDir`
- Custom-directory skills are merged after provider skills and follow the same collision behavior
## Runtime usage behavior
@@ -214,5 +215,5 @@ No fallback search is performed for missing assets.
- Put each skill in its own directory: `<skills-root>/<skill-name>/SKILL.md`
- Always include explicit `name` and `description` frontmatter
- Keep referenced assets under the same skill directory and access with `skill://<name>/...`
- If you need nested taxonomy (`team/domain/skill`), use `skills.customDirectories` (recursive scanner), not provider `skills/` roots
- For nested taxonomy (`team/domain/skill`), point `skills.customDirectories` to the nested parent directory; scanning itself remains non-recursive
- Avoid duplicate skill names across sources; first match wins by provider precedence
+1 -2
View File
@@ -10,8 +10,7 @@
import { Database, type Statement } from "bun:sqlite";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { logger } from "@oh-my-pi/pi-utils";
import { getAgentDir } from "@oh-my-pi/pi-utils/dirs";
import { getAgentDir, logger } from "@oh-my-pi/pi-utils";
import { googleGeminiCliUsageProvider } from "./providers/google-gemini-cli-usage";
import { getEnvApiKey } from "./stream";
import type { Provider } from "./types";
+1 -1
View File
@@ -4,7 +4,7 @@
*/
import { Database } from "bun:sqlite";
import * as path from "node:path";
import { getAgentDir } from "@oh-my-pi/pi-utils/dirs";
import { getAgentDir } from "@oh-my-pi/pi-utils";
import type { Api, Model } from "./types";
const CACHE_SCHEMA_VERSION = 2;
+1 -2
View File
@@ -6,8 +6,7 @@
* 2. OAuth credentials in ~/.omp/agent/agent.db (with expiry check)
* 3. ANTHROPIC_API_KEY / ANTHROPIC_BASE_URL fallback
*/
import { $env } from "@oh-my-pi/pi-utils";
import { getAgentDbPath } from "@oh-my-pi/pi-utils/dirs";
import { $env, getAgentDbPath } from "@oh-my-pi/pi-utils";
import { type AuthCredential, AuthCredentialStore } from "../auth-storage";
import { buildAnthropicHeaders as buildProviderAnthropicHeaders } from "../providers/anthropic";
import { getEnvApiKey } from "../stream";
+1 -2
View File
@@ -6,8 +6,7 @@ import * as crypto from "node:crypto";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { $env, abortableSleep, isEnoent } from "@oh-my-pi/pi-utils";
import { getAgentDir } from "@oh-my-pi/pi-utils/dirs";
import { $env, abortableSleep, getAgentDir, isEnoent } from "@oh-my-pi/pi-utils";
import packageJson from "../../../package.json" with { type: "json" };
import type { OAuthController, OAuthCredentials } from "./types";
+1 -2
View File
@@ -9,8 +9,7 @@
import * as fs from "node:fs/promises";
import { getOAuthApiKey } from "@oh-my-pi/pi-ai/utils/oauth";
import type { OAuthCredentials, OAuthProvider } from "@oh-my-pi/pi-ai/utils/oauth/types";
import { isEnoent } from "@oh-my-pi/pi-utils";
import { getTestAuthPath } from "@oh-my-pi/pi-utils/dirs";
import { getTestAuthPath, isEnoent } from "@oh-my-pi/pi-utils";
/**
* E2E tests require explicit opt-in via E2E=1 environment variable.
+1 -2
View File
@@ -5,8 +5,7 @@ import {
streamOpenAICodexResponses,
} from "@oh-my-pi/pi-ai/providers/openai-codex-responses";
import type { Context, Model, ProviderSessionState } from "@oh-my-pi/pi-ai/types";
import { TempDir } from "@oh-my-pi/pi-utils";
import { getAgentDir, setAgentDir } from "@oh-my-pi/pi-utils/dirs";
import { getAgentDir, setAgentDir, TempDir } from "@oh-my-pi/pi-utils";
const originalFetch = global.fetch;
const originalAgentDir = getAgentDir();
+16
View File
@@ -7,6 +7,22 @@
- Made `description` field required in CustomTool interface
### Changed
- Reorganized imports from `@oh-my-pi/pi-utils/dirs` to consolidate with main `@oh-my-pi/pi-utils` exports for cleaner dependency management
- Renamed `loadSkillsFromDir` to `scanSkillsFromDir` with updated interface for improved clarity on skill discovery behavior
- Moved `tryParseJson` utility from local scrapers module to `@oh-my-pi/pi-utils` for centralized JSON parsing
- Simplified patch module exports by consolidating type re-exports with `export * from './types'`
- Removed `emitCustomToolSessionEvent` method from AgentSession for streamlined session lifecycle management
- Changed skill discovery from recursive to non-recursive (one level deep only) for improved performance and clarity
- Simplified skill loading logic by removing recursive directory traversal and consolidating ignore rule handling
### Removed
- Removed `parseJSON` helper function from discovery module (replaced by `tryParseJson` from pi-utils)
- Removed backwards compatibility comment from `AskToolDetails.question` field
- Removed unused SSH resource cleanup functions `closeAllConnections` and `unmountAll` from session imports
## [13.1.2] - 2026-02-23
### Breaking Changes
+5 -5
View File
@@ -623,14 +623,14 @@ Normalization and collision handling:
- `source = "<provider>:<level>",`
- preserves `_source`.
Custom directory loading (separate path):
Custom directory loading (delegated scanner):
- `customDirectories` are scanned directly via `scanDirectoryForSkills`, not via capability providers.
- Recursive scan respects `.gitignore` / `.ignore` / `.fdignore` through `addIgnoreRules` + `shouldIgnore`.
- Directory containing `SKILL.md` is treated as one skill root and is not recursed further.
- `customDirectories` are scanned via `scanSkillsFromDir` from `src/discovery/helpers.ts` (not ad-hoc traversal in `extensibility/skills.ts`).
- Custom directory scans are non-recursive (`*/SKILL.md`).
- Scan uses one-level `readdir` candidate enumeration (`*/SKILL.md`) without recursive descent.
- Custom skills are stamped as `source: "custom:user"` with `_source.provider = "custom"`.
Also exported: `loadSkillsFromDir({ dir, source })` for direct recursive loading of skill directories with ignore-file support.
Also exported: `discoverSkillsFromDir({ dir, source })`, which delegates directly to `scanSkillsFromDir` with non-recursive scanning.
## MCP Manager, LSP Client Boundary, and Internal URL Routing
@@ -8,8 +8,7 @@
*/
import * as os from "node:os";
import * as path from "node:path";
import { logger } from "@oh-my-pi/pi-utils";
import { getProjectDir } from "@oh-my-pi/pi-utils/dirs";
import { getProjectDir, logger } from "@oh-my-pi/pi-utils";
import type { Settings } from "../config/settings";
import { clearCache as clearFsCache, cacheStats as fsCacheStats, invalidate as invalidateFs } from "./fs";
+1 -1
View File
@@ -1,10 +1,10 @@
#!/usr/bin/env bun
import { APP_NAME, VERSION } from "@oh-my-pi/pi-utils";
/**
* CLI entry point — registers all commands explicitly and delegates to the
* lightweight CLI runner from pi-utils.
*/
import { type CommandEntry, run } from "@oh-my-pi/pi-utils/cli";
import { APP_NAME, VERSION } from "@oh-my-pi/pi-utils/dirs";
// Detect known Bun errata that cause TUI crashes (e.g. Bun.stringWidth mishandling OSC sequences).
if (Bun.stringWidth("\x1b[0m\x1b]8;;\x07") !== 0) {
+1 -2
View File
@@ -2,8 +2,7 @@
* CLI argument parsing and help display
*/
import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import { logger } from "@oh-my-pi/pi-utils";
import { APP_NAME, CONFIG_DIR_NAME } from "@oh-my-pi/pi-utils/dirs";
import { APP_NAME, CONFIG_DIR_NAME, logger } from "@oh-my-pi/pi-utils";
import chalk from "chalk";
import { BUILTIN_TOOLS } from "../tools";
+1 -1
View File
@@ -5,7 +5,7 @@
* Uses settings-defs as the source of truth for available settings.
*/
import { APP_NAME, getAgentDir } from "@oh-my-pi/pi-utils/dirs";
import { APP_NAME, getAgentDir } from "@oh-my-pi/pi-utils";
import chalk from "chalk";
import {
getDefault,
@@ -4,8 +4,7 @@
import * as fs from "node:fs";
import * as path from "node:path";
import type { ImageContent } from "@oh-my-pi/pi-ai";
import { isEnoent } from "@oh-my-pi/pi-utils";
import { getProjectDir } from "@oh-my-pi/pi-utils/dirs";
import { getProjectDir, isEnoent } from "@oh-my-pi/pi-utils";
import chalk from "chalk";
import { resolveReadPath } from "../tools/path-utils";
import { formatBytes } from "../tools/render-utils";
+1 -1
View File
@@ -5,7 +5,7 @@
*/
import * as path from "node:path";
import { grep } from "@oh-my-pi/pi-natives";
import { APP_NAME } from "@oh-my-pi/pi-utils/dirs";
import { APP_NAME } from "@oh-my-pi/pi-utils";
import chalk from "chalk";
export interface GrepCommandArgs {
+1 -1
View File
@@ -4,7 +4,7 @@
* Handles `omp jupyter` subcommand for managing the shared Python gateway.
*/
import { APP_NAME } from "@oh-my-pi/pi-utils/dirs";
import { APP_NAME } from "@oh-my-pi/pi-utils";
import chalk from "chalk";
import { getGatewayStatus, shutdownSharedGateway } from "../ipy/gateway-coordinator";
+1 -1
View File
@@ -4,7 +4,7 @@
* Handles `omp plugin <command>` subcommands for plugin lifecycle management.
*/
import { APP_NAME } from "@oh-my-pi/pi-utils/dirs";
import { APP_NAME } from "@oh-my-pi/pi-utils";
import chalk from "chalk";
import { PluginManager, parseSettingValue, validateSetting } from "../extensibility/plugins";
import { theme } from "../modes/theme/theme";
+1 -1
View File
@@ -4,7 +4,7 @@
* Handles `omp setup <component>` to install dependencies for optional features.
*/
import * as path from "node:path";
import { APP_NAME, getPythonEnvDir } from "@oh-my-pi/pi-utils/dirs";
import { APP_NAME, getPythonEnvDir } from "@oh-my-pi/pi-utils";
import { $ } from "bun";
import chalk from "chalk";
import { theme } from "../modes/theme/theme";
+1 -1
View File
@@ -6,7 +6,7 @@
import * as path from "node:path";
import { createInterface } from "node:readline/promises";
import { Shell } from "@oh-my-pi/pi-natives";
import { APP_NAME, getProjectDir } from "@oh-my-pi/pi-utils/dirs";
import { APP_NAME, getProjectDir } from "@oh-my-pi/pi-utils";
import chalk from "chalk";
import { Settings } from "../config/settings";
import { getOrCreateSnapshot } from "../utils/shell-snapshot";
+1 -1
View File
@@ -4,7 +4,7 @@
* Handles `omp ssh <command>` subcommands for SSH host configuration management.
*/
import { getSSHConfigPath } from "@oh-my-pi/pi-utils/dirs";
import { getSSHConfigPath } from "@oh-my-pi/pi-utils";
import chalk from "chalk";
import { addSSHHost, readSSHConfigFile, removeSSHHost, type SSHHostConfig } from "../ssh/config-writer";
+1 -2
View File
@@ -4,8 +4,7 @@
* Handles `omp stats` subcommand for viewing AI usage statistics.
*/
import { formatDuration, formatNumber, formatPercent } from "@oh-my-pi/pi-utils";
import { APP_NAME } from "@oh-my-pi/pi-utils/dirs";
import { APP_NAME, formatDuration, formatNumber, formatPercent } from "@oh-my-pi/pi-utils";
import chalk from "chalk";
import { openPath } from "../utils/open";
+1 -2
View File
@@ -7,8 +7,7 @@
import { execSync, spawnSync } from "node:child_process";
import * as fs from "node:fs";
import { pipeline } from "node:stream/promises";
import { isEnoent } from "@oh-my-pi/pi-utils";
import { APP_NAME, VERSION } from "@oh-my-pi/pi-utils/dirs";
import { APP_NAME, isEnoent, VERSION } from "@oh-my-pi/pi-utils";
import chalk from "chalk";
import { theme } from "../modes/theme/theme";
@@ -4,7 +4,7 @@
* Handles `omp q`/`omp web-search` subcommands for testing web search providers.
*/
import { APP_NAME } from "@oh-my-pi/pi-utils/dirs";
import { APP_NAME } from "@oh-my-pi/pi-utils";
import chalk from "chalk";
import { initTheme, theme } from "../modes/theme/theme";
import { runSearchQuery, type SearchParams } from "../web/search/index";
+2 -1
View File
@@ -1,8 +1,9 @@
/**
* Root command for the coding agent CLI.
*/
import { APP_NAME } from "@oh-my-pi/pi-utils";
import { Args, Command, Flags } from "@oh-my-pi/pi-utils/cli";
import { APP_NAME } from "@oh-my-pi/pi-utils/dirs";
import { parseArgs } from "../cli/args";
import { runRootCommand } from "../main";
@@ -1,7 +1,6 @@
import * as path from "node:path";
import { createInterface } from "node:readline/promises";
import { $env, isEnoent } from "@oh-my-pi/pi-utils";
import { getProjectDir } from "@oh-my-pi/pi-utils/dirs";
import { $env, getProjectDir, isEnoent } from "@oh-my-pi/pi-utils";
import { applyChangelogProposals } from "../../commit/changelog";
import { detectChangelogBoundaries } from "../../commit/changelog/detect";
import { parseUnreleasedSection } from "../../commit/changelog/parse";
+1 -2
View File
@@ -1,7 +1,6 @@
import * as path from "node:path";
import type { Api, Model } from "@oh-my-pi/pi-ai";
import { logger } from "@oh-my-pi/pi-utils";
import { getProjectDir } from "@oh-my-pi/pi-utils/dirs";
import { getProjectDir, logger } from "@oh-my-pi/pi-utils";
import { ModelRegistry } from "../config/model-registry";
import { renderPromptTemplate } from "../config/prompt-templates";
import { Settings } from "../config/settings";
+1 -2
View File
@@ -1,8 +1,7 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { isEnoent, logger } from "@oh-my-pi/pi-utils";
import { CONFIG_DIR_NAME, getAgentDir, getProjectDir } from "@oh-my-pi/pi-utils/dirs";
import { CONFIG_DIR_NAME, getAgentDir, getProjectDir, isEnoent, logger } from "@oh-my-pi/pi-utils";
import type { TSchema } from "@sinclair/typebox";
import { Value } from "@sinclair/typebox/value";
import { Ajv, type ErrorObject, type ValidateFunction } from "ajv";
@@ -8,8 +8,7 @@ import {
matchesKey,
setEditorKeybindings,
} from "@oh-my-pi/pi-tui";
import { isEnoent, logger } from "@oh-my-pi/pi-utils";
import { getAgentDir } from "@oh-my-pi/pi-utils/dirs";
import { getAgentDir, isEnoent, logger } from "@oh-my-pi/pi-utils";
/**
* Application-level actions (coding agent specific).
@@ -1,7 +1,6 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { logger } from "@oh-my-pi/pi-utils";
import { getProjectDir, getProjectPromptsDir, getPromptsDir } from "@oh-my-pi/pi-utils/dirs";
import { getProjectDir, getProjectPromptsDir, getPromptsDir, logger } from "@oh-my-pi/pi-utils";
import Handlebars from "handlebars";
import { computeLineHash } from "../patch/hashline";
import { jtdToTypeScript } from "../tools/jtd-to-typescript";
+9 -2
View File
@@ -13,8 +13,15 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { isEnoent, logger, procmgr, setDefaultTabWidth } from "@oh-my-pi/pi-utils";
import { getAgentDbPath, getAgentDir, getProjectDir } from "@oh-my-pi/pi-utils/dirs";
import {
getAgentDbPath,
getAgentDir,
getProjectDir,
isEnoent,
logger,
procmgr,
setDefaultTabWidth,
} from "@oh-my-pi/pi-utils";
import { YAML } from "bun";
import { type Settings as SettingsCapabilityItem, settingsCapability } from "../capability/settings";
import type { ModelRole } from "../config/model-registry";
+1 -1
View File
@@ -7,7 +7,7 @@ import * as fs from "node:fs/promises";
import * as url from "node:url";
import { getWorkProfile } from "@oh-my-pi/pi-natives";
import { Container, Loader, type SelectItem, SelectList, Spacer, Text } from "@oh-my-pi/pi-tui";
import { getSessionsDir } from "@oh-my-pi/pi-utils/dirs";
import { getSessionsDir } from "@oh-my-pi/pi-utils";
import { DynamicBorder } from "../modes/components/dynamic-border";
import { getSelectListTheme, getSymbolTheme, theme } from "../modes/theme/theme";
import type { InteractiveModeContext } from "../modes/types";
@@ -6,8 +6,7 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
import type { WorkProfile } from "@oh-my-pi/pi-natives";
import { isEnoent } from "@oh-my-pi/pi-utils";
import { APP_NAME, getLogPath, getLogsDir, getReportsDir } from "@oh-my-pi/pi-utils/dirs";
import { APP_NAME, getLogPath, getLogsDir, getReportsDir, isEnoent } from "@oh-my-pi/pi-utils";
import type { CpuProfile, HeapSnapshot } from "./profiler";
import { collectSystemInfo, sanitizeEnv } from "./system-info";
@@ -3,8 +3,7 @@
*/
import * as os from "node:os";
import { formatBytes } from "@oh-my-pi/pi-utils";
import { getProjectDir, VERSION } from "@oh-my-pi/pi-utils/dirs";
import { formatBytes, getProjectDir, VERSION } from "@oh-my-pi/pi-utils";
export interface SystemInfo {
os: string;
@@ -13,7 +13,7 @@ import { type Skill, skillCapability } from "../capability/skill";
import { type SlashCommand, slashCommandCapability } from "../capability/slash-command";
import { type SystemPrompt, systemPromptCapability } from "../capability/system-prompt";
import type { LoadContext, LoadResult } from "../capability/types";
import { buildRuleFromMarkdown, createSourceMeta, loadFilesFromDir, loadSkillsFromDir } from "./helpers";
import { buildRuleFromMarkdown, createSourceMeta, loadFilesFromDir, scanSkillsFromDir } from "./helpers";
const PROVIDER_ID = "agents";
const DISPLAY_NAME = "Agents (standard)";
@@ -28,7 +28,7 @@ async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
const items: Skill[] = [];
const warnings: string[] = [];
for (const userSkillsDir of getUserAgentPathCandidates(ctx, "skills")) {
const result = await loadSkillsFromDir(ctx, {
const result = await scanSkillsFromDir(ctx, {
dir: userSkillsDir,
providerId: PROVIDER_ID,
level: "user",
@@ -4,7 +4,7 @@
* Primary provider for OMP native configs. Supports all capabilities.
*/
import * as path from "node:path";
import { logger } from "@oh-my-pi/pi-utils";
import { logger, tryParseJson } from "@oh-my-pi/pi-utils";
import { registerProvider } from "../capability";
import { type ContextFile, contextFileCapability } from "../capability/context-file";
import { type Extension, type ExtensionManifest, extensionCapability } from "../capability/extension";
@@ -30,9 +30,8 @@ import {
expandEnvVarsDeep,
getExtensionNameFromPath,
loadFilesFromDir,
loadSkillsFromDir,
parseJSON,
SOURCE_PATHS,
scanSkillsFromDir,
} from "./helpers";
const PROVIDER_ID = "native";
@@ -98,7 +97,7 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
const parseMcpServers = (content: string, path: string, level: "user" | "project"): MCPServer[] => {
const result: MCPServer[] = [];
const data = parseJSON<{ mcpServers?: Record<string, unknown> }>(content);
const data = tryParseJson<{ mcpServers?: Record<string, unknown> }>(content);
if (!data?.mcpServers) return result;
const expanded = expandEnvVarsDeep(data.mcpServers);
@@ -245,7 +244,7 @@ async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
const configDirs = await getConfigDirs(ctx);
const results = await Promise.all(
configDirs.map(({ dir, level }) =>
loadSkillsFromDir(ctx, {
scanSkillsFromDir(ctx, {
dir: path.join(dir, "skills"),
providerId: PROVIDER_ID,
level,
@@ -404,7 +403,7 @@ async function loadExtensionModules(ctx: LoadContext): Promise<LoadResult<Extens
if (!settingsContent) continue;
const settingsPath = path.join(dir, "settings.json");
const settingsData = parseJSON<{ extensions?: unknown }>(settingsContent);
const settingsData = tryParseJson<{ extensions?: unknown }>(settingsContent);
const extensions = settingsData?.extensions;
if (!Array.isArray(extensions)) continue;
@@ -508,7 +507,7 @@ async function loadExtensions(ctx: LoadContext): Promise<LoadResult<Extension>>
if (!content) continue;
const { extDir, manifestPath, entryName, level } = manifestCandidates[i];
const manifest = parseJSON<ExtensionManifest>(content);
const manifest = tryParseJson<ExtensionManifest>(content);
if (!manifest) {
warnings.push(`Failed to parse ${manifestPath}`);
continue;
@@ -655,7 +654,7 @@ async function loadTools(ctx: LoadContext): Promise<LoadResult<CustomTool>> {
extensions: ["json", "md", "ts", "js", "sh", "bash", "py"],
transform: (name, content, path, source) => {
if (name.endsWith(".json")) {
const data = parseJSON<{ name?: string; description?: string }>(content);
const data = tryParseJson<{ name?: string; description?: string }>(content);
const toolName = data?.name || name.replace(/\.json$/, "");
const description =
typeof data?.description === "string" && data.description.trim()
@@ -754,7 +753,7 @@ async function loadSettings(ctx: LoadContext): Promise<LoadResult<Settings>> {
const content = await readFile(settingsPath);
if (!content) continue;
const data = parseJSON<Record<string, unknown>>(content);
const data = tryParseJson<Record<string, unknown>>(content);
if (!data) {
warnings.push(`Failed to parse ${settingsPath}`);
continue;
@@ -11,7 +11,7 @@ import { type Skill, skillCapability } from "../capability/skill";
import { type SlashCommand, slashCommandCapability } from "../capability/slash-command";
import { type CustomTool, toolCapability } from "../capability/tool";
import type { LoadContext, LoadResult } from "../capability/types";
import { type ClaudePluginRoot, listClaudePluginRoots, loadFilesFromDir, loadSkillsFromDir } from "./helpers";
import { type ClaudePluginRoot, listClaudePluginRoots, loadFilesFromDir, scanSkillsFromDir } from "./helpers";
const PROVIDER_ID = "claude-plugins";
const DISPLAY_NAME = "Claude Code Marketplace";
@@ -31,7 +31,7 @@ async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
const results = await Promise.all(
roots.map(async root => {
const skillsDir = path.join(root.path, "skills");
return loadSkillsFromDir(ctx, {
return scanSkillsFromDir(ctx, {
dir: skillsDir,
providerId: PROVIDER_ID,
level: root.scope,
@@ -5,6 +5,7 @@
* Priority: 80 (tool-specific, below builtin but above shared standards)
*/
import * as path from "node:path";
import { tryParseJson } from "@oh-my-pi/pi-utils";
import { registerProvider } from "../capability";
import { type ContextFile, contextFileCapability } from "../capability/context-file";
import { type ExtensionModule, extensionModuleCapability } from "../capability/extension-module";
@@ -24,8 +25,7 @@ import {
expandEnvVarsDeep,
getExtensionNameFromPath,
loadFilesFromDir,
loadSkillsFromDir,
parseJSON,
scanSkillsFromDir,
} from "./helpers";
const PROVIDER_ID = "claude";
@@ -77,7 +77,7 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
const parseMcpServers = (content: string | null, path: string, level: "user" | "project"): MCPServer[] => {
if (!content) return [];
const json = parseJSON<{ mcpServers?: Record<string, unknown> }>(content);
const json = tryParseJson<{ mcpServers?: Record<string, unknown> }>(content);
if (!json?.mcpServers) return [];
const mcpServers = expandEnvVarsDeep(json.mcpServers);
@@ -163,8 +163,8 @@ async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
const projectSkillsDir = path.join(getProjectClaude(ctx), "skills");
const results = await Promise.all([
loadSkillsFromDir(ctx, { dir: userSkillsDir, providerId: PROVIDER_ID, level: "user" }),
loadSkillsFromDir(ctx, { dir: projectSkillsDir, providerId: PROVIDER_ID, level: "project" }),
scanSkillsFromDir(ctx, { dir: userSkillsDir, providerId: PROVIDER_ID, level: "user" }),
scanSkillsFromDir(ctx, { dir: projectSkillsDir, providerId: PROVIDER_ID, level: "project" }),
]);
return {
@@ -396,7 +396,7 @@ async function loadSettings(ctx: LoadContext): Promise<LoadResult<Settings>> {
const userContent = await readFile(userSettingsJson);
if (userContent) {
const data = parseJSON<Record<string, unknown>>(userContent);
const data = tryParseJson<Record<string, unknown>>(userContent);
if (data) {
items.push({
path: userSettingsJson,
@@ -413,7 +413,7 @@ async function loadSettings(ctx: LoadContext): Promise<LoadResult<Settings>> {
const projectSettingsJson = path.join(projectBase, "settings.json");
const projectContent = await readFile(projectSettingsJson);
if (projectContent) {
const data = parseJSON<Record<string, unknown>>(projectContent);
const data = tryParseJson<Record<string, unknown>>(projectContent);
if (data) {
items.push({
path: projectSettingsJson,
+3 -3
View File
@@ -35,8 +35,8 @@ import {
discoverExtensionModulePaths,
getExtensionNameFromPath,
loadFilesFromDir,
loadSkillsFromDir,
SOURCE_PATHS,
scanSkillsFromDir,
} from "./helpers";
const PROVIDER_ID = "codex";
@@ -214,12 +214,12 @@ async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
const projectSkillsDir = path.join(codexDir, "skills");
const results = await Promise.all([
loadSkillsFromDir(ctx, {
scanSkillsFromDir(ctx, {
dir: userSkillsDir,
providerId: PROVIDER_ID,
level: "user",
}),
loadSkillsFromDir(ctx, {
scanSkillsFromDir(ctx, {
dir: projectSkillsDir,
providerId: PROVIDER_ID,
level: "project",
@@ -13,6 +13,8 @@
* - rules: From rules/*.mdc files with MDC frontmatter (description, globs, alwaysApply)
* - settings: From settings.json if present
*/
import { tryParseJson } from "@oh-my-pi/pi-utils";
import { registerProvider } from "../capability";
import { readFile } from "../capability/fs";
import { type MCPServer, mcpCapability } from "../capability/mcp";
@@ -28,7 +30,6 @@ import {
getProjectPath,
getUserPath,
loadFilesFromDir,
parseJSON,
} from "./helpers";
const PROVIDER_ID = "cursor";
@@ -46,7 +47,7 @@ function parseMCPServers(
): { items: MCPServer[]; warning?: string } {
const items: MCPServer[] = [];
const parsed = parseJSON<{ mcpServers?: Record<string, unknown> }>(content);
const parsed = tryParseJson<{ mcpServers?: Record<string, unknown> }>(content);
if (!parsed?.mcpServers) {
return { items, warning: `${path}: missing or invalid 'mcpServers' key` };
}
@@ -158,7 +159,7 @@ async function loadSettings(ctx: LoadContext): Promise<LoadResult<Settings>> {
const projectContentPromise = projectPath ? readFile(projectPath) : Promise.resolve(null);
if (userContent && userPath) {
const parsed = parseJSON<Record<string, unknown>>(userContent);
const parsed = tryParseJson<Record<string, unknown>>(userContent);
if (parsed) {
items.push({
path: userPath,
@@ -173,7 +174,7 @@ async function loadSettings(ctx: LoadContext): Promise<LoadResult<Settings>> {
const projectContent = await projectContentPromise;
if (projectContent && projectPath) {
const parsed = parseJSON<Record<string, unknown>>(projectContent);
const parsed = tryParseJson<Record<string, unknown>>(projectContent);
if (parsed) {
items.push({
path: projectPath,
@@ -16,6 +16,7 @@
* - settings: From settings.json
*/
import * as path from "node:path";
import { tryParseJson } from "@oh-my-pi/pi-utils";
import { registerProvider } from "../capability";
import { type ContextFile, contextFileCapability } from "../capability/context-file";
import { type Extension, type ExtensionManifest, extensionCapability } from "../capability/extension";
@@ -33,7 +34,6 @@ import {
getExtensionNameFromPath,
getProjectPath,
getUserPath,
parseJSON,
} from "./helpers";
const PROVIDER_ID = "gemini";
@@ -80,7 +80,7 @@ async function loadMCPFromSettings(
return { items, warnings };
}
const parsed = parseJSON<{ mcpServers?: Record<string, unknown> }>(content);
const parsed = tryParseJson<{ mcpServers?: Record<string, unknown> }>(content);
if (!parsed) {
warnings.push(`Invalid JSON in ${path}`);
return { items, warnings };
@@ -206,7 +206,7 @@ async function loadExtensionsFromDir(extensionsDir: string, level: "user" | "pro
for (const { entry, extPath, manifestPath, content } of results) {
if (!content) continue;
const manifest = parseJSON<ExtensionManifest>(content);
const manifest = tryParseJson<ExtensionManifest>(content);
if (!manifest) {
warnings.push(`Invalid JSON in ${manifestPath}`);
continue;
@@ -268,7 +268,7 @@ async function loadSettings(ctx: LoadContext): Promise<LoadResult<Settings>> {
if (userPath) {
const content = await readFile(userPath);
if (content) {
const parsed = parseJSON<Record<string, unknown>>(content);
const parsed = tryParseJson<Record<string, unknown>>(content);
if (parsed) {
items.push({
path: userPath,
@@ -287,7 +287,7 @@ async function loadSettings(ctx: LoadContext): Promise<LoadResult<Settings>> {
if (projectPath) {
const content = await readFile(projectPath);
if (content) {
const parsed = parseJSON<Record<string, unknown>>(content);
const parsed = tryParseJson<Record<string, unknown>>(content);
if (parsed) {
items.push({
path: projectPath,
+49 -71
View File
@@ -1,7 +1,8 @@
import * as fs from "node:fs";
import * as path from "node:path";
import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import { FileType, glob } from "@oh-my-pi/pi-natives";
import { CONFIG_DIR_NAME } from "@oh-my-pi/pi-utils/dirs";
import { CONFIG_DIR_NAME, tryParseJson } from "@oh-my-pi/pi-utils";
import { readFile } from "../capability/fs";
import { parseRuleConditionAndScope, type Rule, type RuleFrontmatter } from "../capability/rule";
import type { Skill, SkillFrontmatter } from "../capability/skill";
@@ -268,68 +269,56 @@ async function globIf(
}
}
export async function loadSkillsFromDir(
export interface ScanSkillsFromDirOptions {
dir: string;
providerId: string;
level: "user" | "project";
requireDescription?: boolean;
}
export async function scanSkillsFromDir(
_ctx: LoadContext,
options: {
dir: string;
providerId: string;
level: "user" | "project";
requireDescription?: boolean;
},
options: ScanSkillsFromDirOptions,
): Promise<LoadResult<Skill>> {
const items: Skill[] = [];
const warnings: string[] = [];
const { dir, level, providerId, requireDescription = false } = options;
// Use native glob to find all SKILL.md files one level deep
// Pattern */SKILL.md matches <dir>/<subdir>/SKILL.md
const discoveredMatches = new Set<string>();
for (const match of await globIf(dir, "*/SKILL.md", FileType.File)) {
discoveredMatches.add(match.path);
}
for (const match of await globIf(dir, "*", FileType.Dir, false)) {
const skillRelPath = `${match.path}/SKILL.md`;
const content = await readFile(path.join(dir, skillRelPath));
if (content !== null) {
discoveredMatches.add(skillRelPath);
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
const loadSkill = async (skillPath: string) => {
try {
const content = await readFile(skillPath);
if (!content) return;
const { frontmatter, body } = parseFrontmatter(content, { source: skillPath });
if (requireDescription && !frontmatter.description) {
return;
}
const skillDirName = path.basename(path.dirname(skillPath));
items.push({
name: (frontmatter.name as string) || skillDirName,
path: skillPath,
content: body,
frontmatter: frontmatter as SkillFrontmatter,
level,
_source: createSourceMeta(providerId, skillPath, level),
});
} catch {
warnings.push(`Failed to read skill file: ${skillPath}`);
}
};
const work = [];
for (const entry of entries) {
if (entry.name.startsWith(".")) continue;
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
const skillPath = path.join(dir, entry.name, "SKILL.md");
if (fs.existsSync(skillPath)) {
work.push(loadSkill(skillPath));
}
}
const matches = [...discoveredMatches].map(path => ({ path }));
if (matches.length === 0) {
return { items, warnings };
}
await Promise.all(work);
// Read all skill files in parallel
const results = await Promise.all(
matches.map(async match => {
const skillFile = path.join(dir, match.path);
const content = await readFile(skillFile);
if (!content) {
return { item: null as Skill | null, warning: null as string | null };
}
const { frontmatter, body } = parseFrontmatter(content, { source: skillFile });
if (requireDescription && !frontmatter.description) {
return { item: null as Skill | null, warning: null as string | null };
}
// Extract skill name from path: "<skilldir>/SKILL.md" -> "<skilldir>"
const skillDirName = path.basename(path.dirname(skillFile));
return {
item: {
name: (frontmatter.name as string) || skillDirName,
path: skillFile,
content: body,
frontmatter: frontmatter as SkillFrontmatter,
level,
_source: createSourceMeta(providerId, skillFile, level),
},
warning: null as string | null,
};
}),
);
for (const result of results) {
if (result.warning) warnings.push(result.warning);
if (result.item) items.push(result.item);
}
return { items, warnings };
}
@@ -337,7 +326,7 @@ export async function loadSkillsFromDir(
* Expand environment variables in a string.
* Supports ${VAR} and ${VAR:-default} syntax.
*/
export function expandEnvVars(value: string, extraEnv?: Record<string, string>): string {
function expandEnvVars(value: string, extraEnv?: Record<string, string>): string {
return value.replace(/\$\{([^}:]+)(?::-([^}]*))?\}/g, (_, varName: string, defaultValue?: string) => {
const envValue = extraEnv?.[varName] ?? Bun.env[varName];
if (envValue !== undefined) return envValue;
@@ -447,17 +436,6 @@ export async function loadFilesFromDir<T>(
return { items, warnings };
}
/**
* Parse JSON safely.
*/
export function parseJSON<T>(content: string): T | null {
try {
return JSON.parse(content) as T;
} catch {
return null;
}
}
/**
* Calculate depth of target directory relative to current working directory.
* Depth is the number of directory levels from cwd to target.
@@ -480,7 +458,7 @@ async function readExtensionModuleManifest(
const content = await readFile(packageJsonPath);
if (!content) return null;
const pkg = parseJSON<{ omp?: ExtensionModuleManifest; pi?: ExtensionModuleManifest }>(content);
const pkg = tryParseJson<{ omp?: ExtensionModuleManifest; pi?: ExtensionModuleManifest }>(content);
const manifest = pkg?.omp ?? pkg?.pi;
if (manifest && typeof manifest === "object") {
return manifest;
@@ -506,9 +484,9 @@ export async function discoverExtensionModulePaths(_ctx: LoadContext, dir: strin
// 1. Direct *.ts or *.js files
globIf(dir, "*.{ts,js}", FileType.File, false),
// 2. Subdirectory index files
globIf(dir, "*/index.{ts,js}", FileType.File),
globIf(dir, "*/index.{ts,js}", FileType.File, false),
// 3. Subdirectory package.json files
globIf(dir, "*/package.json", FileType.File),
globIf(dir, "*/package.json", FileType.File, false),
]);
// Process direct files
@@ -617,7 +595,7 @@ export interface ClaudePluginRoot {
* Parse Claude Code installed_plugins.json content.
*/
export function parseClaudePluginsRegistry(content: string): ClaudePluginsRegistry | null {
const data = parseJSON<ClaudePluginsRegistry>(content);
const data = tryParseJson<ClaudePluginsRegistry>(content);
if (!data || typeof data !== "object") return null;
if (
typeof data.version !== "number" ||
@@ -7,12 +7,12 @@
* Priority: 5 (low, as this is a fallback after tool-specific providers)
*/
import * as path from "node:path";
import { logger } from "@oh-my-pi/pi-utils";
import { logger, tryParseJson } from "@oh-my-pi/pi-utils";
import { registerProvider } from "../capability";
import { readFile } from "../capability/fs";
import { type MCPServer, mcpCapability } from "../capability/mcp";
import type { LoadContext, LoadResult, SourceMeta } from "../capability/types";
import { createSourceMeta, expandEnvVarsDeep, parseJSON } from "./helpers";
import { createSourceMeta, expandEnvVarsDeep } from "./helpers";
const PROVIDER_ID = "mcp-json";
const DISPLAY_NAME = "MCP Config";
@@ -115,7 +115,7 @@ async function loadMCPJsonFile(
return { items, warnings };
}
const config = parseJSON<MCPConfigFile>(content);
const config = tryParseJson<MCPConfigFile>(content);
if (!config) {
warnings.push(`Failed to parse JSON in ${path}`);
return { items, warnings };
@@ -16,7 +16,7 @@
* Priority: 55 (tool-specific provider)
*/
import * as path from "node:path";
import { logger } from "@oh-my-pi/pi-utils";
import { logger, tryParseJson } from "@oh-my-pi/pi-utils";
import { registerProvider } from "../capability";
import { type ContextFile, contextFileCapability } from "../capability/context-file";
import { type ExtensionModule, extensionModuleCapability } from "../capability/extension-module";
@@ -35,8 +35,7 @@ import {
getProjectPath,
getUserPath,
loadFilesFromDir,
loadSkillsFromDir,
parseJSON,
scanSkillsFromDir,
} from "./helpers";
const PROVIDER_ID = "opencode";
@@ -51,7 +50,7 @@ async function loadJsonConfig(configPath: string): Promise<Record<string, unknow
const content = await readFile(configPath);
if (!content) return null;
const parsed = parseJSON<Record<string, unknown>>(content);
const parsed = tryParseJson<Record<string, unknown>>(content);
if (!parsed) {
logger.warn("Failed to parse OpenCode JSON config", { path: configPath });
return null;
@@ -190,7 +189,7 @@ async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
if (userSkillsDir) {
promises.push(
loadSkillsFromDir(ctx, {
scanSkillsFromDir(ctx, {
dir: userSkillsDir,
providerId: PROVIDER_ID,
level: "user",
@@ -200,7 +199,7 @@ async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
if (projectSkillsDir) {
promises.push(
loadSkillsFromDir(ctx, {
scanSkillsFromDir(ctx, {
dir: projectSkillsDir,
providerId: PROVIDER_ID,
level: "project",
@@ -307,7 +306,7 @@ async function loadSettings(ctx: LoadContext): Promise<LoadResult<Settings>> {
if (userConfigPath) {
const content = await readFile(userConfigPath);
if (content) {
const parsed = parseJSON<Record<string, unknown>>(content);
const parsed = tryParseJson<Record<string, unknown>>(content);
if (parsed) {
items.push({
path: userConfigPath,
@@ -325,7 +324,7 @@ async function loadSettings(ctx: LoadContext): Promise<LoadResult<Settings>> {
const projectConfigPath = path.join(ctx.cwd, "opencode.json");
const content = await readFile(projectConfigPath);
if (content) {
const parsed = parseJSON<Record<string, unknown>>(content);
const parsed = tryParseJson<Record<string, unknown>>(content);
if (parsed) {
items.push({
path: projectConfigPath,
+3 -3
View File
@@ -5,13 +5,13 @@
* Priority: 5 (low, project/user config discovery)
*/
import * as path from "node:path";
import { getSSHConfigPath } from "@oh-my-pi/pi-utils/dirs";
import { getSSHConfigPath, tryParseJson } from "@oh-my-pi/pi-utils";
import { registerProvider } from "../capability";
import { readFile } from "../capability/fs";
import { type SSHHost, sshCapability } from "../capability/ssh";
import type { LoadContext, LoadResult, SourceMeta } from "../capability/types";
import { expandTilde } from "../tools/path-utils";
import { createSourceMeta, expandEnvVarsDeep, parseJSON } from "./helpers";
import { createSourceMeta, expandEnvVarsDeep } from "./helpers";
const PROVIDER_ID = "ssh-json";
const DISPLAY_NAME = "SSH Config";
@@ -95,7 +95,7 @@ async function loadSshJsonFile(
if (content === null) {
return { items, warnings };
}
const parsed = parseJSON<SSHConfigFile>(content);
const parsed = tryParseJson<SSHConfigFile>(content);
if (!parsed) {
warnings.push(`Failed to parse JSON in ${filePath}`);
return { items, warnings };
@@ -4,11 +4,12 @@
* Loads config from `.vscode` directory (project-only).
* Supports MCP server discovery from `mcp.json` with nested `mcp.servers` structure.
*/
import { tryParseJson } from "@oh-my-pi/pi-utils";
import { registerProvider } from "../capability";
import { readFile } from "../capability/fs";
import { type MCPServer, mcpCapability } from "../capability/mcp";
import type { LoadContext, LoadResult } from "../capability/types";
import { createSourceMeta, expandEnvVarsDeep, getProjectPath, parseJSON } from "./helpers";
import { createSourceMeta, expandEnvVarsDeep, getProjectPath } from "./helpers";
const PROVIDER_ID = "vscode";
const DISPLAY_NAME = "VS Code";
@@ -57,7 +58,7 @@ async function loadMCPConfig(
return { items, warnings };
}
const parsed = parseJSON<{ mcp?: { servers?: Record<string, unknown> } }>(content);
const parsed = tryParseJson<{ mcp?: { servers?: Record<string, unknown> } }>(content);
if (!parsed) {
warnings.push(`Invalid JSON in ${path}`);
return { items, warnings };
@@ -10,6 +10,8 @@
* - Rules from .windsurf/rules/*.md and ~/.codeium/windsurf/memories/global_rules.md
* - Legacy .windsurfrules file
*/
import { tryParseJson } from "@oh-my-pi/pi-utils";
import { registerProvider } from "../capability";
import { readFile } from "../capability/fs";
import { type MCPServer, mcpCapability } from "../capability/mcp";
@@ -22,7 +24,6 @@ import {
getProjectPath,
getUserPath,
loadFilesFromDir,
parseJSON,
} from "./helpers";
const PROVIDER_ID = "windsurf";
@@ -78,7 +79,7 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
for (const { content, path, scope } of configs) {
if (!content || !path) continue;
const config = parseJSON<{ mcpServers?: Record<string, unknown> }>(content);
const config = tryParseJson<{ mcpServers?: Record<string, unknown> }>(content);
if (!config?.mcpServers) continue;
for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
@@ -6,7 +6,7 @@
*/
import * as fs from "node:fs";
import * as path from "node:path";
import { getAgentDir } from "@oh-my-pi/pi-utils/dirs";
import { getAgentDir } from "@oh-my-pi/pi-utils";
export interface CustomShareResult {
/** URL to display/open (optional - script may handle everything itself) */
@@ -1,7 +1,6 @@
import * as path from "node:path";
import type { AgentState } from "@oh-my-pi/pi-agent-core";
import { isEnoent } from "@oh-my-pi/pi-utils";
import { APP_NAME } from "@oh-my-pi/pi-utils/dirs";
import { APP_NAME, isEnoent } from "@oh-my-pi/pi-utils";
import { getResolvedThemeColors, getThemeExportColors } from "../../modes/theme/theme";
import { type SessionEntry, type SessionHeader, SessionManager } from "../../session/session-manager";
// Pre-generated template (created by scripts/generate-template.ts at publish time)
@@ -7,8 +7,7 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as piCodingAgent from "@oh-my-pi/pi-coding-agent";
import { isEnoent, logger } from "@oh-my-pi/pi-utils";
import { getAgentDir, getProjectDir } from "@oh-my-pi/pi-utils/dirs";
import { getAgentDir, getProjectDir, isEnoent, logger } from "@oh-my-pi/pi-utils";
import * as typebox from "@sinclair/typebox";
import { getConfigDirs } from "../../config";
import { execCommand } from "../../exec/exec";
@@ -1,7 +1,6 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { isEnoent } from "@oh-my-pi/pi-utils";
import { getAgentDir, getProjectDir } from "@oh-my-pi/pi-utils/dirs";
import { getAgentDir, getProjectDir, isEnoent } from "@oh-my-pi/pi-utils";
import { extractPackageName } from "./parser";
import type { InstalledPlugin } from "./types";
@@ -6,8 +6,7 @@
*/
import * as fs from "node:fs";
import * as path from "node:path";
import { isEnoent } from "@oh-my-pi/pi-utils";
import { getPluginsLockfile, getPluginsNodeModules, getPluginsPackageJson } from "@oh-my-pi/pi-utils/dirs";
import { getPluginsLockfile, getPluginsNodeModules, getPluginsPackageJson, isEnoent } from "@oh-my-pi/pi-utils";
import { getConfigDirPaths } from "../../config";
import type { InstalledPlugin, PluginManifest, PluginRuntimeConfig, ProjectPluginOverrides } from "./types";
@@ -1,6 +1,5 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { isEnoent, logger } from "@oh-my-pi/pi-utils";
import {
getPluginsDir,
getPluginsLockfile,
@@ -8,7 +7,9 @@ import {
getPluginsPackageJson,
getProjectDir,
getProjectPluginOverridesPath,
} from "@oh-my-pi/pi-utils/dirs";
isEnoent,
logger,
} from "@oh-my-pi/pi-utils";
import { extractPackageName, parsePluginSpec } from "./parser";
import type {
DoctorCheck,
+59 -115
View File
@@ -1,18 +1,12 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { logger } from "@oh-my-pi/pi-utils";
import { getProjectDir } from "@oh-my-pi/pi-utils/dirs";
import * as os from "node:os";
import { getProjectDir } from "@oh-my-pi/pi-utils";
import { skillCapability } from "../capability/skill";
import type { SourceMeta } from "../capability/types";
import type { SkillsSettings } from "../config/settings";
import type { Skill as CapabilitySkill, SkillFrontmatter as ImportedSkillFrontmatter } from "../discovery";
import { loadCapability } from "../discovery";
import { type Skill as CapabilitySkill, loadCapability } from "../discovery";
import { scanSkillsFromDir } from "../discovery/helpers";
import { expandTilde } from "../tools/path-utils";
import { parseFrontmatter } from "../utils/frontmatter";
import { addIgnoreRules, createIgnoreMatcher, type IgnoreMatcher, shouldIgnore } from "../utils/ignore-files";
// Re-export SkillFrontmatter for backward compatibility
export type { ImportedSkillFrontmatter as SkillFrontmatter };
export interface Skill {
name: string;
@@ -41,91 +35,31 @@ export interface LoadSkillsFromDirOptions {
source: string;
}
async function readFileContent(filePath: string): Promise<string | null> {
try {
return await fs.readFile(filePath, "utf-8");
} catch {
return null;
}
}
/**
* Load skills from a directory recursively.
* Skills are directories containing a SKILL.md file with frontmatter including a description.
* Respects .gitignore, .ignore, and .fdignore files.
*/
export async function loadSkillsFromDir(options: LoadSkillsFromDirOptions): Promise<LoadSkillsResult> {
const skills: Skill[] = [];
const warnings: SkillWarning[] = [];
const seenPaths = new Set<string>();
const rootDir = options.dir;
const [rawProviderId, rawLevel] = options.source.split(":", 2);
const providerId = rawProviderId || "custom";
const level: "user" | "project" = rawLevel === "project" ? "project" : "user";
const result = await scanSkillsFromDir(
{ cwd: getProjectDir(), home: os.homedir() },
{
dir: options.dir,
providerId,
level,
requireDescription: true,
},
);
async function addSkill(skillFile: string, skillDir: string, dirName: string): Promise<void> {
if (seenPaths.has(skillFile)) return;
try {
const content = await fs.readFile(skillFile, "utf-8");
const { frontmatter } = parseFrontmatter(content, { source: skillFile });
const name = (frontmatter.name as string) || dirName;
const description = frontmatter.description as string;
if (description) {
seenPaths.add(skillFile);
skills.push({
name,
description,
filePath: skillFile,
baseDir: skillDir,
source: options.source,
});
}
} catch (error) {
logger.warn("Failed to load skill", { path: skillFile, error: String(error) });
}
}
async function scanDir(dir: string, ig: IgnoreMatcher): Promise<void> {
try {
// Add ignore rules from this directory
await addIgnoreRules(ig, dir, rootDir, readFileContent);
// First check if this directory itself is a skill
const selfSkillFile = path.join(dir, "SKILL.md");
try {
const s = await fs.stat(selfSkillFile);
if (s.isFile()) {
await addSkill(selfSkillFile, dir, path.basename(dir));
// This directory is a skill, don't recurse
return;
}
} catch {
// No SKILL.md in this directory
}
// Recurse into subdirectories
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
const fullPath = path.join(dir, entry.name);
const isDir = entry.isDirectory();
// Check if this entry should be ignored
if (shouldIgnore(ig, rootDir, fullPath, isDir)) continue;
if (isDir) {
await scanDir(fullPath, ig);
}
}
} catch (err) {
warnings.push({ skillPath: dir, message: `Failed to read directory: ${err}` });
}
}
const ig = createIgnoreMatcher();
await scanDir(options.dir, ig);
return { skills, warnings };
return {
skills: result.items.map(capSkill => ({
name: capSkill.name,
description: typeof capSkill.frontmatter?.description === "string" ? capSkill.frontmatter.description : "",
filePath: capSkill.path,
baseDir: capSkill.path.replace(/\/SKILL\.md$/, ""),
source: options.source,
_source: capSkill._source,
})),
warnings: (result.warnings ?? []).map(message => ({ skillPath: options.dir, message })),
};
}
export interface LoadSkillsOptions extends SkillsSettings {
@@ -225,45 +159,55 @@ export async function loadSkills(options: LoadSkillsOptions = {}): Promise<LoadS
message: `name collision: "${capSkill.name}" already loaded from ${existing.filePath}, skipping this one`,
});
} else {
// Transform capability skill to legacy format
const skill: Skill = {
skillMap.set(capSkill.name, {
name: capSkill.name,
description: capSkill.frontmatter?.description || "",
description: typeof capSkill.frontmatter?.description === "string" ? capSkill.frontmatter.description : "",
filePath: capSkill.path,
baseDir: capSkill.path.replace(/\/SKILL\.md$/, ""),
source: `${capSkill._source.provider}:${capSkill.level}`,
_source: capSkill._source,
};
skillMap.set(capSkill.name, skill);
});
realPathSet.add(resolvedPath);
}
}
// Process custom directories - scan directly without using full provider system
const allCustomSkills: Array<{ skill: Skill; path: string }> = [];
const customScanResults = await Promise.all(
customDirectories.map(dir => loadSkillsFromDir({ dir: expandTilde(dir), source: "custom" })),
const customDirectoryResults = await Promise.all(
customDirectories.map(async dir => {
const expandedDir = expandTilde(dir);
const scanResult = await scanSkillsFromDir(
{ cwd, home: os.homedir() },
{
dir: expandedDir,
providerId: "custom",
level: "user",
requireDescription: true,
},
);
return { expandedDir, scanResult };
}),
);
for (const customSkills of customScanResults) {
for (const s of customSkills.skills) {
if (matchesIgnorePatterns(s.name)) continue;
if (!matchesIncludePatterns(s.name)) continue;
const allCustomSkills: Array<{ skill: Skill; path: string }> = [];
for (const { expandedDir, scanResult } of customDirectoryResults) {
for (const capSkill of scanResult.items) {
if (matchesIgnorePatterns(capSkill.name)) continue;
if (!matchesIncludePatterns(capSkill.name)) continue;
allCustomSkills.push({
skill: {
name: s.name,
description: s.description,
filePath: s.filePath,
baseDir: s.filePath.replace(/\/SKILL\.md$/, ""),
name: capSkill.name,
description:
typeof capSkill.frontmatter?.description === "string" ? capSkill.frontmatter.description : "",
filePath: capSkill.path,
baseDir: capSkill.path.replace(/\/SKILL\.md$/, ""),
source: "custom:user",
_source: { provider: "custom", providerName: "Custom", path: s.filePath, level: "user" },
_source: { ...capSkill._source, providerName: "Custom" },
},
path: s.filePath,
path: capSkill.path,
});
}
collisionWarnings.push(...customSkills.warnings);
collisionWarnings.push(...(scanResult.warnings ?? []).map(message => ({ skillPath: expandedDir, message })));
}
// Batch resolve custom skill paths
const customRealPaths = await Promise.all(
allCustomSkills.map(async ({ path }) => {
try {
@@ -293,6 +237,6 @@ export async function loadSkills(options: LoadSkillsOptions = {}): Promise<LoadS
return {
skills: Array.from(skillMap.values()),
warnings: [...result.warnings.map(w => ({ skillPath: "", message: w })), ...collisionWarnings],
warnings: [...(result.warnings ?? []).map(w => ({ skillPath: "", message: w })), ...collisionWarnings],
};
}
+1 -3
View File
@@ -6,8 +6,7 @@ export { StringEnum } from "@oh-my-pi/pi-ai";
// Re-export TUI components for custom tool rendering
export { Container, Markdown, Spacer, Text } from "@oh-my-pi/pi-tui";
// Logging
export { logger } from "@oh-my-pi/pi-utils";
export { getAgentDir, VERSION } from "@oh-my-pi/pi-utils/dirs";
export { getAgentDir, logger, VERSION } from "@oh-my-pi/pi-utils";
export { formatKeyHint, formatKeyHints } from "./config/keybindings";
export { ModelRegistry } from "./config/model-registry";
// Prompt templates
@@ -89,7 +88,6 @@ export {
loadSkills,
loadSkillsFromDir,
type Skill,
type SkillFrontmatter,
type SkillWarning,
} from "./extensibility/skills";
// Slash commands
+1 -2
View File
@@ -1,6 +1,5 @@
import * as path from "node:path";
import { isEnoent, logger } from "@oh-my-pi/pi-utils";
import { getAgentDir, getProjectDir } from "@oh-my-pi/pi-utils/dirs";
import { getAgentDir, getProjectDir, isEnoent, logger } from "@oh-my-pi/pi-utils";
import { OutputSink } from "../session/streaming-output";
import { shutdownSharedGateway } from "./gateway-coordinator";
import {
@@ -1,8 +1,7 @@
import * as fs from "node:fs";
import { createServer } from "node:net";
import * as path from "node:path";
import { isEnoent, logger, procmgr } from "@oh-my-pi/pi-utils";
import { getAgentDir } from "@oh-my-pi/pi-utils/dirs";
import { getAgentDir, isEnoent, logger, procmgr } from "@oh-my-pi/pi-utils";
import type { Subprocess } from "bun";
import { Settings } from "../config/settings";
import { getOrCreateSnapshot } from "../utils/shell-snapshot";
+1 -1
View File
@@ -1,6 +1,6 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { getAgentModulesDir, getProjectDir, getProjectModulesDir } from "@oh-my-pi/pi-utils/dirs";
import { getAgentModulesDir, getProjectDir, getProjectModulesDir } from "@oh-my-pi/pi-utils";
export type PythonModuleSource = "user" | "project";
+1 -3
View File
@@ -6,9 +6,7 @@
*/
import * as fs from "node:fs";
import * as path from "node:path";
import { $env } from "@oh-my-pi/pi-utils";
import { getPythonEnvDir } from "@oh-my-pi/pi-utils/dirs";
import { $env, getPythonEnvDir } from "@oh-my-pi/pi-utils";
const DEFAULT_ENV_ALLOWLIST = new Set([
"PATH",
+1 -2
View File
@@ -11,8 +11,7 @@ import * as os from "node:os";
import * as path from "node:path";
import { createInterface } from "node:readline/promises";
import { type ImageContent, supportsXhigh } from "@oh-my-pi/pi-ai";
import { $env, logger, postmortem } from "@oh-my-pi/pi-utils";
import { getProjectDir, setProjectDir, VERSION } from "@oh-my-pi/pi-utils/dirs";
import { $env, getProjectDir, logger, postmortem, setProjectDir, VERSION } from "@oh-my-pi/pi-utils";
import chalk from "chalk";
import type { Args } from "./cli/args";
import { processFileArguments } from "./cli/file-processor";
+1 -1
View File
@@ -4,7 +4,7 @@
* Uses the capability system to load MCP servers from multiple sources.
*/
import { getMCPConfigPath } from "@oh-my-pi/pi-utils/dirs";
import { getMCPConfigPath } from "@oh-my-pi/pi-utils";
import { mcpCapability } from "../capability/mcp";
import type { SourceMeta } from "../capability/types";
import type { MCPServer } from "../discovery";
@@ -5,8 +5,7 @@
* Messages are newline-delimited JSON.
*/
import { readJsonl, Snowflake } from "@oh-my-pi/pi-utils";
import { getProjectDir } from "@oh-my-pi/pi-utils/dirs";
import { getProjectDir, readJsonl, Snowflake } from "@oh-my-pi/pi-utils";
import { type Subprocess, spawn } from "bun";
import type { JsonRpcResponse, MCPRequestOptions, MCPStdioServerConfig, MCPTransport } from "../../mcp/types";
+1 -2
View File
@@ -4,8 +4,7 @@ import * as fs from "node:fs/promises";
import * as path from "node:path";
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
import { completeSimple, type Model } from "@oh-my-pi/pi-ai";
import { logger, parseJsonlLenient } from "@oh-my-pi/pi-utils";
import { getAgentDbPath } from "@oh-my-pi/pi-utils/dirs";
import { getAgentDbPath, logger, parseJsonlLenient } from "@oh-my-pi/pi-utils";
import type { ModelRegistry } from "../config/model-registry";
import { parseModelString } from "../config/model-resolver";
import { renderPromptTemplate } from "../config/prompt-templates";
@@ -1,7 +1,6 @@
import * as fs from "node:fs";
import { type Component, padding, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
import { formatNumber } from "@oh-my-pi/pi-utils";
import { getProjectDir } from "@oh-my-pi/pi-utils/dirs";
import { formatNumber, getProjectDir } from "@oh-my-pi/pi-utils";
import { theme } from "../../modes/theme/theme";
import type { AgentSession } from "../../session/agent-session";
import { shortenPath } from "../../tools/render-utils";
@@ -1,6 +1,5 @@
import * as os from "node:os";
import { formatDuration, formatNumber } from "@oh-my-pi/pi-utils";
import { getProjectDir } from "@oh-my-pi/pi-utils/dirs";
import { formatDuration, formatNumber, getProjectDir } from "@oh-my-pi/pi-utils";
import { theme } from "../../../modes/theme/theme";
import { shortenPath } from "../../../tools/render-utils";
import type { RenderedSegment, SegmentContext, StatusLineSegment, StatusLineSegmentId } from "./types";
@@ -13,17 +13,10 @@ import {
Text,
type TUI,
} from "@oh-my-pi/pi-tui";
import { logger } from "@oh-my-pi/pi-utils";
import { getProjectDir } from "@oh-my-pi/pi-utils/dirs";
import { getProjectDir, logger } from "@oh-my-pi/pi-utils";
import type { Theme } from "../../modes/theme/theme";
import { theme } from "../../modes/theme/theme";
import {
computeEditDiff,
computeHashlineDiff,
computePatchDiff,
type EditDiffError,
type EditDiffResult,
} from "../../patch";
import { computeEditDiff, computeHashlineDiff, computePatchDiff, type DiffError, type DiffResult } from "../../patch";
import { BASH_DEFAULT_PREVIEW_LINES } from "../../tools/bash";
import {
formatArgsInline,
@@ -106,7 +99,7 @@ export class ToolExecutionComponent extends Container {
details?: any;
};
// Cached edit diff preview (computed when args arrive, before tool executes)
#editDiffPreview?: EditDiffResult | EditDiffError;
#editDiffPreview?: DiffResult | DiffError;
#editDiffArgsKey?: string; // Track which args the preview is for
// Cached converted images for Kitty protocol (which requires PNG), keyed by index
#convertedImages: Map<number, { data: string; mimeType: string }> = new Map();
@@ -1,5 +1,5 @@
import { type Component, padding, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
import { APP_NAME } from "@oh-my-pi/pi-utils/dirs";
import { APP_NAME } from "@oh-my-pi/pi-utils";
import { theme } from "../../modes/theme/theme";
export interface RecentSession {
@@ -10,8 +10,7 @@ import {
} from "@oh-my-pi/pi-ai";
import { copyToClipboard } from "@oh-my-pi/pi-natives";
import { Loader, Markdown, padding, Spacer, Text, visibleWidth } from "@oh-my-pi/pi-tui";
import { formatDuration, Snowflake } from "@oh-my-pi/pi-utils";
import { setProjectDir } from "@oh-my-pi/pi-utils/dirs";
import { formatDuration, Snowflake, setProjectDir } from "@oh-my-pi/pi-utils";
import { $ } from "bun";
import { reset as resetCapabilities } from "../../capability";
import { loadCustomShare } from "../../export/custom-share";
@@ -4,7 +4,7 @@
* Handles /mcp subcommands for managing MCP servers.
*/
import { Spacer, Text } from "@oh-my-pi/pi-tui";
import { getMCPConfigPath, getProjectDir } from "@oh-my-pi/pi-utils/dirs";
import { getMCPConfigPath, getProjectDir } from "@oh-my-pi/pi-utils";
import type { SourceMeta } from "../../capability/types";
import { analyzeAuthError, discoverOAuthEndpoints, MCPManager } from "../../mcp";
import { connectToServer, disconnectServer, listTools } from "../../mcp/client";
@@ -2,7 +2,7 @@ import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import { getOAuthProviders, type OAuthProvider } from "@oh-my-pi/pi-ai";
import type { Component } from "@oh-my-pi/pi-tui";
import { Input, Loader, Spacer, Text } from "@oh-my-pi/pi-tui";
import { getAgentDbPath, getProjectDir } from "@oh-my-pi/pi-utils/dirs";
import { getAgentDbPath, getProjectDir } from "@oh-my-pi/pi-utils";
import { MODEL_ROLES } from "../../config/model-registry";
import { settings } from "../../config/settings";
import { DebugSelectorComponent } from "../../debug";
@@ -4,7 +4,7 @@
* Handles /ssh subcommands for managing SSH host configurations.
*/
import { Spacer, Text } from "@oh-my-pi/pi-tui";
import { getProjectDir, getSSHConfigPath } from "@oh-my-pi/pi-utils/dirs";
import { getProjectDir, getSSHConfigPath } from "@oh-my-pi/pi-utils";
import { type SSHHost, sshCapability } from "../../capability/ssh";
import { loadCapability } from "../../discovery";
import { addSSHHost, readSSHConfigFile, removeSSHHost, type SSHHostConfig } from "../../ssh/config-writer";
@@ -15,8 +15,7 @@ import {
Text,
TUI,
} from "@oh-my-pi/pi-tui";
import { hsvToRgb, isEnoent, logger, postmortem } from "@oh-my-pi/pi-utils";
import { APP_NAME, getProjectDir } from "@oh-my-pi/pi-utils/dirs";
import { APP_NAME, getProjectDir, hsvToRgb, isEnoent, logger, postmortem } from "@oh-my-pi/pi-utils";
import chalk from "chalk";
import { KeybindingsManager } from "../config/keybindings";
import { renderPromptTemplate } from "../config/prompt-templates";
@@ -776,7 +775,7 @@ export class InteractiveMode implements InteractiveModeContext {
await this.sessionManager.flush();
// Emit shutdown event to hooks
await this.session.emitCustomToolSessionEvent("shutdown");
await this.session.dispose();
if (this.isInitialized) {
this.ui.requestRender(true);
+1 -2
View File
@@ -1,8 +1,7 @@
import * as fs from "node:fs";
import * as path from "node:path";
import type { TabBarTheme } from "@oh-my-pi/pi-tui";
import { isEnoent } from "@oh-my-pi/pi-utils";
import { getProjectDir } from "@oh-my-pi/pi-utils/dirs";
import { getProjectDir, isEnoent } from "@oh-my-pi/pi-utils";
import { theme } from "./theme/theme";
// ═══════════════════════════════════════════════════════════════════════════
@@ -6,8 +6,7 @@ import {
supportsLanguage as nativeSupportsLanguage,
} from "@oh-my-pi/pi-natives";
import type { EditorTheme, MarkdownTheme, SelectListTheme, SymbolTheme } from "@oh-my-pi/pi-tui";
import { adjustHsv, isEnoent, logger } from "@oh-my-pi/pi-utils";
import { getCustomThemesDir } from "@oh-my-pi/pi-utils/dirs";
import { adjustHsv, getCustomThemesDir, isEnoent, logger } from "@oh-my-pi/pi-utils";
import { type Static, Type } from "@sinclair/typebox";
import { TypeCompiler } from "@sinclair/typebox/compiler";
import chalk from "chalk";
+1 -25
View File
@@ -77,31 +77,7 @@ export { normalizeCreateContent, normalizeDiff, parseHunks as parseDiffHunks } f
export type { EditRenderContext, EditToolDetails } from "./shared";
// Rendering
export { editToolRenderer, getLspBatchRequest } from "./shared";
export type {
ApplyPatchOptions,
ApplyPatchResult,
ContextLineResult,
DiffError,
DiffError as EditDiffError,
DiffHunk,
DiffHunk as UpdateChunk,
DiffHunk as UpdateFileChunk,
DiffResult,
DiffResult as EditDiffResult,
FileChange,
FileSystem,
FuzzyMatch as EditMatch,
FuzzyMatch,
HashMismatch,
MatchOutcome as EditMatchOutcome,
MatchOutcome,
Operation,
PatchInput,
SequenceSearchResult,
} from "./types";
// Types
// Legacy aliases for backwards compatibility
export { ApplyPatchError, EditMatchError, ParseError } from "./types";
export * from "./types";
// ═══════════════════════════════════════════════════════════════════════════
// Schemas
+1 -2
View File
@@ -9,8 +9,7 @@ import {
import { type Message, type Model, supportsXhigh } from "@oh-my-pi/pi-ai";
import { prewarmOpenAICodexResponses } from "@oh-my-pi/pi-ai/providers/openai-codex-responses";
import type { Component } from "@oh-my-pi/pi-tui";
import { $env, logger, postmortem } from "@oh-my-pi/pi-utils";
import { getAgentDbPath, getAgentDir, getProjectDir } from "@oh-my-pi/pi-utils/dirs";
import { $env, getAgentDbPath, getAgentDir, getProjectDir, logger, postmortem } from "@oh-my-pi/pi-utils";
import chalk from "chalk";
import { AsyncJobManager } from "./async";
import { loadCapability } from "./capability";
@@ -38,8 +38,7 @@ import type {
UsageReport,
} from "@oh-my-pi/pi-ai";
import { isContextOverflow, modelsAreEqual, supportsXhigh } from "@oh-my-pi/pi-ai";
import { abortableSleep, isEnoent, logger } from "@oh-my-pi/pi-utils";
import { getAgentDbPath } from "@oh-my-pi/pi-utils/dirs";
import { abortableSleep, getAgentDbPath, isEnoent, logger } from "@oh-my-pi/pi-utils";
import type { AsyncJob, AsyncJobManager } from "../async";
import type { Rule } from "../capability/rule";
import { MODEL_ROLE_IDS, type ModelRegistry, type ModelRole } from "../config/model-registry";
@@ -84,8 +83,6 @@ import planModeActivePrompt from "../prompts/system/plan-mode-active.md" with {
import planModeReferencePrompt from "../prompts/system/plan-mode-reference.md" with { type: "text" };
import ttsrInterruptTemplate from "../prompts/system/ttsr-interrupt.md" with { type: "text" };
import type { SecretObfuscator } from "../secrets/obfuscator";
import { closeAllConnections } from "../ssh/connection-manager";
import { unmountAll } from "../ssh/sshfs-mount";
import { outputMeta } from "../tools/output-meta";
import { resolveToCwd } from "../tools/path-utils";
import { getLatestTodoPhasesFromEntries, type TodoItem, type TodoPhase } from "../tools/todo-write";
@@ -273,15 +270,6 @@ const noOpUIContext: ExtensionUIContext = {
setToolsExpanded: () => {},
};
async function cleanupSshResources(): Promise<void> {
const results = await Promise.allSettled([closeAllConnections(), unmountAll()]);
for (const result of results) {
if (result.status === "rejected") {
logger.warn("SSH cleanup failed", { error: String(result.reason) });
}
}
}
// ============================================================================
// AgentSession Class
// ============================================================================
@@ -1275,13 +1263,19 @@ export class AgentSession {
* Call this when completely done with the session.
*/
async dispose(): Promise<void> {
try {
if (this.#extensionRunner?.hasHandlers("session_shutdown")) {
await this.#extensionRunner.emit({ type: "session_shutdown" });
}
} catch (error) {
logger.warn("Failed to emit session_shutdown event", { error: String(error) });
}
const drained = await this.#asyncJobManager?.dispose({ timeoutMs: 3_000 });
const deliveryState = this.#asyncJobManager?.getDeliveryState();
if (drained === false && deliveryState) {
logger.warn("Async job completion deliveries still pending during dispose", { ...deliveryState });
}
await this.sessionManager.flush();
await cleanupSshResources();
for (const state of this.#providerSessionState.values()) {
state.close();
}
@@ -4861,15 +4855,4 @@ Be thorough - include exact file paths, function names, error messages, and tech
get extensionRunner(): ExtensionRunner | undefined {
return this.#extensionRunner;
}
/**
* Emit a custom tool session event (backwards compatibility for older callers).
*/
async emitCustomToolSessionEvent(reason: "start" | "switch" | "branch" | "tree" | "shutdown"): Promise<void> {
if (reason !== "shutdown") return;
if (this.#extensionRunner?.hasHandlers("session_shutdown")) {
await this.#extensionRunner.emit({ type: "session_shutdown" });
}
await cleanupSshResources();
}
}
@@ -2,8 +2,7 @@ import { Database, type Statement } from "bun:sqlite";
import * as fs from "node:fs";
import * as path from "node:path";
import { type AuthCredential, AuthCredentialStore, type StoredAuthCredential } from "@oh-my-pi/pi-ai";
import { isRecord, logger } from "@oh-my-pi/pi-utils";
import { getAgentDbPath } from "@oh-my-pi/pi-utils/dirs";
import { getAgentDbPath, isRecord, logger } from "@oh-my-pi/pi-utils";
import type { RawSettings as Settings } from "../config/settings";
/** Row shape for settings table queries */
@@ -1,8 +1,7 @@
import { Database, type Statement } from "bun:sqlite";
import * as fs from "node:fs";
import * as path from "node:path";
import { logger } from "@oh-my-pi/pi-utils";
import { getAgentDir } from "@oh-my-pi/pi-utils/dirs";
import { getAgentDir, logger } from "@oh-my-pi/pi-utils";
export interface HistoryEntry {
id: number;
@@ -4,8 +4,16 @@ import * as path from "node:path";
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
import type { ImageContent, Message, TextContent, Usage } from "@oh-my-pi/pi-ai";
import { getTerminalId } from "@oh-my-pi/pi-tui";
import { isEnoent, logger, parseJsonlLenient, Snowflake, toError } from "@oh-my-pi/pi-utils";
import { getBlobsDir, getAgentDir as getDefaultAgentDir, getProjectDir } from "@oh-my-pi/pi-utils/dirs";
import {
getBlobsDir,
getAgentDir as getDefaultAgentDir,
getProjectDir,
isEnoent,
logger,
parseJsonlLenient,
Snowflake,
toError,
} from "@oh-my-pi/pi-utils";
import { ArtifactManager } from "./artifacts";
import { type BlobPutResult, BlobStore, externalizeImageData, isBlobRef, resolveImageData } from "./blob-store";
import {
@@ -1,7 +1,6 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { isEnoent, logger } from "@oh-my-pi/pi-utils";
import { getRemoteHostDir, getSshControlDir } from "@oh-my-pi/pi-utils/dirs";
import { getRemoteHostDir, getSshControlDir, isEnoent, logger, postmortem } from "@oh-my-pi/pi-utils";
import { $ } from "bun";
import { buildSshTarget, sanitizeHostName } from "./utils";
@@ -362,6 +361,8 @@ export async function buildRemoteCommand(host: SSHConnectionTarget, command: str
return [...buildCommonArgs(host), buildSshTarget(host.username, host.host), command];
}
let registered = false;
export async function ensureConnection(host: SSHConnectionTarget): Promise<void> {
const key = host.name;
const pending = pendingConnections.get(key);
@@ -375,6 +376,13 @@ export async function ensureConnection(host: SSHConnectionTarget): Promise<void>
ensureControlDir();
await validateKeyPermissions(host.keyPath);
if (!registered) {
registered = true;
postmortem.register("ssh-cleanup", async () => {
await closeAllConnections();
});
}
const target = buildSshTarget(host.username, host.host);
const check = await runSshSync(["-O", "check", ...buildCommonArgs(host), target]);
if (check.exitCode === 0) {
+7 -1
View File
@@ -1,6 +1,6 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { getRemoteDir } from "@oh-my-pi/pi-utils/dirs";
import { getRemoteDir, postmortem } from "@oh-my-pi/pi-utils";
import { $ } from "bun";
import { getControlDir, getControlPathTemplate, type SSHConnectionTarget } from "./connection-manager";
import { buildSshTarget, sanitizeHostName } from "./utils";
@@ -83,6 +83,8 @@ export async function isMounted(path: string): Promise<boolean> {
return result.exitCode === 0;
}
let registered = false;
export async function mountRemote(host: SSHConnectionTarget, remotePath = "/"): Promise<string | undefined> {
if (!hasSshfs()) return undefined;
@@ -90,6 +92,10 @@ export async function mountRemote(host: SSHConnectionTarget, remotePath = "/"):
await Promise.all([ensureDir(REMOTE_DIR), ensureDir(CONTROL_DIR), ensureDir(mountPath)]);
if (await isMounted(mountPath)) {
if (!registered) {
registered = true;
postmortem.register("sshfs-cleanup", unmountAll);
}
mountedPaths.add(mountPath);
return mountPath;
}
+1 -2
View File
@@ -5,8 +5,7 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { $env, hasFsCode, isEnoent, logger } from "@oh-my-pi/pi-utils";
import { getGpuCachePath, getProjectDir } from "@oh-my-pi/pi-utils/dirs";
import { $env, getGpuCachePath, getProjectDir, hasFsCode, isEnoent, logger } from "@oh-my-pi/pi-utils";
import { $ } from "bun";
import { contextFileCapability } from "./capability/context-file";
import { systemPromptCapability } from "./capability/system-prompt";
+1 -2
View File
@@ -1,8 +1,7 @@
import * as fs from "node:fs/promises";
import * as os from "node:os";
import path from "node:path";
import { isEnoent, Snowflake } from "@oh-my-pi/pi-utils";
import { getWorktreeDir } from "@oh-my-pi/pi-utils/dirs";
import { getWorktreeDir, isEnoent, Snowflake } from "@oh-my-pi/pi-utils";
import { $ } from "bun";
export interface WorktreeBaseline {
-1
View File
@@ -59,7 +59,6 @@ export interface QuestionResult {
}
export interface AskToolDetails {
/** Single question mode (backwards compatible) */
question?: string;
options?: string[];
multi?: boolean;
+1 -2
View File
@@ -3,8 +3,7 @@ import * as path from "node:path";
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
import type { Component } from "@oh-my-pi/pi-tui";
import { Text } from "@oh-my-pi/pi-tui";
import { $env, isEnoent } from "@oh-my-pi/pi-utils";
import { getProjectDir } from "@oh-my-pi/pi-utils/dirs";
import { $env, getProjectDir, isEnoent } from "@oh-my-pi/pi-utils";
import { Type } from "@sinclair/typebox";
import { renderPromptTemplate } from "../config/prompt-templates";
import { type BashResult, executeBash } from "../exec/bash-executor";
+1 -2
View File
@@ -3,8 +3,7 @@ import * as path from "node:path";
import { Readability } from "@mozilla/readability";
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
import { StringEnum } from "@oh-my-pi/pi-ai";
import { logger, Snowflake, untilAborted } from "@oh-my-pi/pi-utils";
import { getPuppeteerDir } from "@oh-my-pi/pi-utils/dirs";
import { getPuppeteerDir, logger, Snowflake, untilAborted } from "@oh-my-pi/pi-utils";
import { type Static, Type } from "@sinclair/typebox";
import { type HTMLElement, parseHTML } from "linkedom";
import type {
+1 -1
View File
@@ -4,7 +4,7 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallb
import type { ImageContent } from "@oh-my-pi/pi-ai";
import type { Component } from "@oh-my-pi/pi-tui";
import { Markdown, Text } from "@oh-my-pi/pi-tui";
import { getProjectDir } from "@oh-my-pi/pi-utils/dirs";
import { getProjectDir } from "@oh-my-pi/pi-utils";
import { type Static, Type } from "@sinclair/typebox";
import { renderPromptTemplate } from "../config/prompt-templates";
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
+1 -2
View File
@@ -5,8 +5,7 @@ import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
import { FileType, glob } from "@oh-my-pi/pi-natives";
import type { Component } from "@oh-my-pi/pi-tui";
import { Text } from "@oh-my-pi/pi-tui";
import { ptree, untilAborted } from "@oh-my-pi/pi-utils";
import { getRemoteDir } from "@oh-my-pi/pi-utils/dirs";
import { getRemoteDir, ptree, untilAborted } from "@oh-my-pi/pi-utils";
import { type Static, Type } from "@sinclair/typebox";
import { renderPromptTemplate } from "../config/prompt-templates";
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
@@ -1,8 +1,7 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { logger, ptree, TempDir } from "@oh-my-pi/pi-utils";
import { APP_NAME, getToolsDir } from "@oh-my-pi/pi-utils/dirs";
import { APP_NAME, getToolsDir, logger, ptree, TempDir } from "@oh-my-pi/pi-utils";
const TOOLS_DIR = getToolsDir();
const TOOL_DOWNLOAD_TIMEOUT_MS = 15000;
@@ -1,5 +1,6 @@
import { tryParseJson } from "@oh-my-pi/pi-utils";
import type { RenderResult, SpecialHandler } from "./types";
import { buildResult, formatIsoDate, formatNumber, loadPage, tryParseJson } from "./types";
import { buildResult, formatIsoDate, formatNumber, loadPage } from "./types";
interface ArtifactHubMaintainer {
name: string;
@@ -1,5 +1,6 @@
import { tryParseJson } from "@oh-my-pi/pi-utils";
import type { RenderResult, SpecialHandler } from "./types";
import { buildResult, formatIsoDate, formatNumber, loadPage, tryParseJson } from "./types";
import { buildResult, formatIsoDate, formatNumber, loadPage } from "./types";
interface AurPackage {
Name: string;
@@ -1,5 +1,6 @@
import { tryParseJson } from "@oh-my-pi/pi-utils";
import type { RenderResult, SpecialHandler } from "./types";
import { buildResult, loadPage, tryParseJson } from "./types";
import { buildResult, loadPage } from "./types";
interface BiorxivPaper {
biorxiv_doi?: string;
@@ -1,5 +1,6 @@
import { tryParseJson } from "@oh-my-pi/pi-utils";
import type { RenderResult, SpecialHandler } from "./types";
import { buildResult, formatNumber, loadPage, tryParseJson } from "./types";
import { buildResult, formatNumber, loadPage } from "./types";
const API_BASE = "https://public.api.bsky.app/xrpc";
@@ -1,5 +1,6 @@
import { tryParseJson } from "@oh-my-pi/pi-utils";
import type { RenderResult, SpecialHandler } from "./types";
import { buildResult, formatIsoDate, formatNumber, loadPage, tryParseJson } from "./types";
import { buildResult, formatIsoDate, formatNumber, loadPage } from "./types";
interface NuGetODataEntry {
Id: string;
@@ -1,5 +1,6 @@
import { tryParseJson } from "@oh-my-pi/pi-utils";
import type { RenderResult, SpecialHandler } from "./types";
import { buildResult, loadPage, tryParseJson } from "./types";
import { buildResult, loadPage } from "./types";
interface KevEntry {
cveID: string;
@@ -1,5 +1,6 @@
import { tryParseJson } from "@oh-my-pi/pi-utils";
import type { RenderResult, SpecialHandler } from "./types";
import { buildResult, formatNumber, loadPage, tryParseJson } from "./types";
import { buildResult, formatNumber, loadPage } from "./types";
import { asNumber, asString, isRecord } from "./utils";
function formatLicenses(licenses: unknown): string[] {
@@ -1,5 +1,6 @@
import { tryParseJson } from "@oh-my-pi/pi-utils";
import type { RenderResult, SpecialHandler } from "./types";
import { buildResult, formatNumber, loadPage, tryParseJson } from "./types";
import { buildResult, formatNumber, loadPage } from "./types";
interface CoinGeckoResponse {
id: string;
@@ -1,5 +1,6 @@
import { tryParseJson } from "@oh-my-pi/pi-utils";
import type { RenderResult, SpecialHandler } from "./types";
import { buildResult, formatNumber, loadPage, tryParseJson } from "./types";
import { buildResult, formatNumber, loadPage } from "./types";
/**
* Check if content looks like HTML
@@ -1,5 +1,6 @@
import { tryParseJson } from "@oh-my-pi/pi-utils";
import type { RenderResult, SpecialHandler } from "./types";
import { buildResult, htmlToBasicMarkdown, loadPage, tryParseJson } from "./types";
import { buildResult, htmlToBasicMarkdown, loadPage } from "./types";
interface CrossrefAuthor {
given?: string;

Some files were not shown because too many files have changed in this diff Show More