fix(coding-agent): Clean up Path Handling (#4873)

This commit is contained in:
Armin Ronacher
2026-05-22 11:25:15 +02:00
committed by GitHub
parent bf56a86e1e
commit c100620bf4
23 changed files with 363 additions and 214 deletions

View File

@@ -3,6 +3,7 @@ import { homedir } from "os";
import { basename, dirname, join, resolve, sep, win32 } from "path"; import { basename, dirname, join, resolve, sep, win32 } from "path";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import { spawnProcessSync } from "./utils/child-process.ts"; import { spawnProcessSync } from "./utils/child-process.ts";
import { normalizePath } from "./utils/paths.ts";
// ============================================================================= // =============================================================================
// Package Detection // Package Detection
@@ -330,9 +331,7 @@ export function getPackageDir(): string {
// Allow override via environment variable (useful for Nix/Guix where store paths tokenize poorly) // Allow override via environment variable (useful for Nix/Guix where store paths tokenize poorly)
const envDir = process.env.PI_PACKAGE_DIR; const envDir = process.env.PI_PACKAGE_DIR;
if (envDir) { if (envDir) {
if (envDir === "~") return homedir(); return normalizePath(envDir);
if (envDir.startsWith("~/")) return homedir() + envDir.slice(1);
return envDir;
} }
if (isBunBinary) { if (isBunBinary) {
@@ -454,9 +453,7 @@ export const ENV_AGENT_DIR = `${APP_NAME.toUpperCase()}_CODING_AGENT_DIR`;
export const ENV_SESSION_DIR = `${APP_NAME.toUpperCase()}_CODING_AGENT_SESSION_DIR`; export const ENV_SESSION_DIR = `${APP_NAME.toUpperCase()}_CODING_AGENT_SESSION_DIR`;
export function expandTildePath(path: string): string { export function expandTildePath(path: string): string {
if (path === "~") return homedir(); return normalizePath(path);
if (path.startsWith("~/")) return homedir() + path.slice(1);
return path;
} }
const DEFAULT_SHARE_VIEWER_URL = "https://pi.dev/session/"; const DEFAULT_SHARE_VIEWER_URL = "https://pi.dev/session/";

View File

@@ -1,5 +1,6 @@
import { copyFileSync, existsSync, mkdirSync } from "node:fs"; import { copyFileSync, existsSync, mkdirSync } from "node:fs";
import { basename, join, resolve } from "node:path"; import { basename, join, resolve } from "node:path";
import { resolvePath } from "../utils/paths.ts";
import type { AgentSession } from "./agent-session.ts"; import type { AgentSession } from "./agent-session.ts";
import type { AgentSessionRuntimeDiagnostic, AgentSessionServices } from "./agent-session-services.ts"; import type { AgentSessionRuntimeDiagnostic, AgentSessionServices } from "./agent-session-services.ts";
import type { ReplacedSessionContext, SessionShutdownEvent, SessionStartEvent } from "./extensions/index.ts"; import type { ReplacedSessionContext, SessionShutdownEvent, SessionStartEvent } from "./extensions/index.ts";
@@ -337,7 +338,7 @@ export class AgentSessionRuntime {
* @throws {MissingSessionCwdError} When the imported session cwd cannot be resolved and no override is provided. * @throws {MissingSessionCwdError} When the imported session cwd cannot be resolved and no override is provided.
*/ */
async importFromJsonl(inputPath: string, cwdOverride?: string): Promise<{ cancelled: boolean }> { async importFromJsonl(inputPath: string, cwdOverride?: string): Promise<{ cancelled: boolean }> {
const resolvedPath = resolve(inputPath); const resolvedPath = resolvePath(inputPath);
if (!existsSync(resolvedPath)) { if (!existsSync(resolvedPath)) {
throw new SessionImportFileNotFoundError(resolvedPath); throw new SessionImportFileNotFoundError(resolvedPath);
} }

View File

@@ -2,6 +2,7 @@ import { join } from "node:path";
import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import type { Model } from "@earendil-works/pi-ai"; import type { Model } from "@earendil-works/pi-ai";
import { getAgentDir } from "../config.ts"; import { getAgentDir } from "../config.ts";
import { resolvePath } from "../utils/paths.ts";
import { AuthStorage } from "./auth-storage.ts"; import { AuthStorage } from "./auth-storage.ts";
import type { SessionStartEvent, ToolDefinition } from "./extensions/index.ts"; import type { SessionStartEvent, ToolDefinition } from "./extensions/index.ts";
import { ModelRegistry } from "./model-registry.ts"; import { ModelRegistry } from "./model-registry.ts";
@@ -129,8 +130,8 @@ function applyExtensionFlagValues(
export async function createAgentSessionServices( export async function createAgentSessionServices(
options: CreateAgentSessionServicesOptions, options: CreateAgentSessionServicesOptions,
): Promise<AgentSessionServices> { ): Promise<AgentSessionServices> {
const cwd = options.cwd; const cwd = resolvePath(options.cwd);
const agentDir = options.agentDir ?? getAgentDir(); const agentDir = options.agentDir ? resolvePath(options.agentDir) : getAgentDir();
const authStorage = options.authStorage ?? AuthStorage.create(join(agentDir, "auth.json")); const authStorage = options.authStorage ?? AuthStorage.create(join(agentDir, "auth.json"));
const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir); const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir);
const modelRegistry = options.modelRegistry ?? ModelRegistry.create(authStorage, join(agentDir, "models.json")); const modelRegistry = options.modelRegistry ?? ModelRegistry.create(authStorage, join(agentDir, "models.json"));

View File

@@ -14,7 +14,7 @@
*/ */
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { basename, dirname, resolve } from "node:path"; import { basename, dirname } from "node:path";
import type { import type {
Agent, Agent,
AgentEvent, AgentEvent,
@@ -35,6 +35,7 @@ import {
} from "@earendil-works/pi-ai"; } from "@earendil-works/pi-ai";
import { theme } from "../modes/interactive/theme/theme.ts"; import { theme } from "../modes/interactive/theme/theme.ts";
import { stripFrontmatter } from "../utils/frontmatter.ts"; import { stripFrontmatter } from "../utils/frontmatter.ts";
import { resolvePath } from "../utils/paths.ts";
import { sleep } from "../utils/sleep.ts"; import { sleep } from "../utils/sleep.ts";
import { formatNoApiKeyFoundMessage, formatNoModelSelectedMessage } from "./auth-guidance.ts"; import { formatNoApiKeyFoundMessage, formatNoModelSelectedMessage } from "./auth-guidance.ts";
import { type BashResult, executeBashWithOperations } from "./bash-executor.ts"; import { type BashResult, executeBashWithOperations } from "./bash-executor.ts";
@@ -2993,7 +2994,10 @@ export class AgentSession {
* @returns The resolved output file path. * @returns The resolved output file path.
*/ */
exportToJsonl(outputPath?: string): string { exportToJsonl(outputPath?: string): string {
const filePath = resolve(outputPath ?? `session-${new Date().toISOString().replace(/[:.]/g, "-")}.jsonl`); const filePath = resolvePath(
outputPath ?? `session-${new Date().toISOString().replace(/[:.]/g, "-")}.jsonl`,
process.cwd(),
);
const dir = dirname(filePath); const dir = dirname(filePath);
if (!existsSync(dir)) { if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true }); mkdirSync(dir, { recursive: true });

View File

@@ -18,6 +18,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "f
import { dirname, join } from "path"; import { dirname, join } from "path";
import lockfile from "proper-lockfile"; import lockfile from "proper-lockfile";
import { getAgentDir } from "../config.ts"; import { getAgentDir } from "../config.ts";
import { normalizePath } from "../utils/paths.ts";
import { resolveConfigValue } from "./resolve-config-value.ts"; import { resolveConfigValue } from "./resolve-config-value.ts";
export type ApiKeyCredential = { export type ApiKeyCredential = {
@@ -53,7 +54,7 @@ export class FileAuthStorageBackend implements AuthStorageBackend {
private authPath: string; private authPath: string;
constructor(authPath: string = join(getAgentDir(), "auth.json")) { constructor(authPath: string = join(getAgentDir(), "auth.json")) {
this.authPath = authPath; this.authPath = normalizePath(authPath);
} }
private ensureParentDir(): void { private ensureParentDir(): void {

View File

@@ -3,6 +3,7 @@ import { existsSync, readFileSync, writeFileSync } from "fs";
import { basename, join } from "path"; import { basename, join } from "path";
import { APP_NAME, getExportTemplateDir } from "../../config.ts"; import { APP_NAME, getExportTemplateDir } from "../../config.ts";
import { getResolvedThemeColors, getThemeExportColors } from "../../modes/interactive/theme/theme.ts"; import { getResolvedThemeColors, getThemeExportColors } from "../../modes/interactive/theme/theme.ts";
import { normalizePath, resolvePath } from "../../utils/paths.ts";
import type { ToolDefinition } from "../extensions/types.ts"; import type { ToolDefinition } from "../extensions/types.ts";
import type { SessionEntry } from "../session-manager.ts"; import type { SessionEntry } from "../session-manager.ts";
import { SessionManager } from "../session-manager.ts"; import { SessionManager } from "../session-manager.ts";
@@ -270,7 +271,7 @@ export async function exportSessionToHtml(
const html = generateHtml(sessionData, opts.themeName); const html = generateHtml(sessionData, opts.themeName);
let outputPath = opts.outputPath; let outputPath = opts.outputPath ? normalizePath(opts.outputPath) : undefined;
if (!outputPath) { if (!outputPath) {
const sessionBasename = basename(sessionFile, ".jsonl"); const sessionBasename = basename(sessionFile, ".jsonl");
outputPath = `${APP_NAME}-session-${sessionBasename}.html`; outputPath = `${APP_NAME}-session-${sessionBasename}.html`;
@@ -286,12 +287,13 @@ export async function exportSessionToHtml(
*/ */
export async function exportFromFile(inputPath: string, options?: ExportOptions | string): Promise<string> { export async function exportFromFile(inputPath: string, options?: ExportOptions | string): Promise<string> {
const opts: ExportOptions = typeof options === "string" ? { outputPath: options } : options || {}; const opts: ExportOptions = typeof options === "string" ? { outputPath: options } : options || {};
const resolvedInputPath = resolvePath(inputPath);
if (!existsSync(inputPath)) { if (!existsSync(resolvedInputPath)) {
throw new Error(`File not found: ${inputPath}`); throw new Error(`File not found: ${resolvedInputPath}`);
} }
const sm = SessionManager.open(inputPath); const sm = SessionManager.open(resolvedInputPath);
const sessionData: SessionData = { const sessionData: SessionData = {
header: sm.getHeader(), header: sm.getHeader(),
@@ -303,9 +305,9 @@ export async function exportFromFile(inputPath: string, options?: ExportOptions
const html = generateHtml(sessionData, opts.themeName); const html = generateHtml(sessionData, opts.themeName);
let outputPath = opts.outputPath; let outputPath = opts.outputPath ? normalizePath(opts.outputPath) : undefined;
if (!outputPath) { if (!outputPath) {
const inputBasename = basename(inputPath, ".jsonl"); const inputBasename = basename(resolvedInputPath, ".jsonl");
outputPath = `${APP_NAME}-session-${inputBasename}.html`; outputPath = `${APP_NAME}-session-${inputBasename}.html`;
} }

View File

@@ -5,7 +5,6 @@
import * as fs from "node:fs"; import * as fs from "node:fs";
import { createRequire } from "node:module"; import { createRequire } from "node:module";
import * as os from "node:os";
import * as path from "node:path"; import * as path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import * as _bundledPiAgentCore from "@earendil-works/pi-agent-core"; import * as _bundledPiAgentCore from "@earendil-works/pi-agent-core";
@@ -24,6 +23,7 @@ import { CONFIG_DIR_NAME, getAgentDir, isBunBinary } from "../../config.ts";
// NOTE: This import works because loader.ts exports are NOT re-exported from index.ts, // NOTE: This import works because loader.ts exports are NOT re-exported from index.ts,
// avoiding a circular dependency. Extensions can import from @earendil-works/pi-coding-agent. // avoiding a circular dependency. Extensions can import from @earendil-works/pi-coding-agent.
import * as _bundledPiCodingAgent from "../../index.ts"; import * as _bundledPiCodingAgent from "../../index.ts";
import { resolvePath } from "../../utils/paths.ts";
import { createEventBus, type EventBus } from "../event-bus.ts"; import { createEventBus, type EventBus } from "../event-bus.ts";
import type { ExecOptions } from "../exec.ts"; import type { ExecOptions } from "../exec.ts";
import { execCommand } from "../exec.ts"; import { execCommand } from "../exec.ts";
@@ -115,31 +115,6 @@ function getAliases(): Record<string, string> {
return _aliases; return _aliases;
} }
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
function normalizeUnicodeSpaces(str: string): string {
return str.replace(UNICODE_SPACES, " ");
}
function expandPath(p: string): string {
const normalized = normalizeUnicodeSpaces(p);
if (normalized.startsWith("~/")) {
return path.join(os.homedir(), normalized.slice(2));
}
if (normalized.startsWith("~")) {
return path.join(os.homedir(), normalized.slice(1));
}
return normalized;
}
function resolvePath(extPath: string, cwd: string): string {
const expanded = expandPath(extPath);
if (path.isAbsolute(expanded)) {
return expanded;
}
return path.resolve(cwd, expanded);
}
type HandlerFn = (...args: unknown[]) => Promise<unknown>; type HandlerFn = (...args: unknown[]) => Promise<unknown>;
/** /**
@@ -396,7 +371,7 @@ async function loadExtension(
eventBus: EventBus, eventBus: EventBus,
runtime: ExtensionRuntime, runtime: ExtensionRuntime,
): Promise<{ extension: Extension | null; error: string | null }> { ): Promise<{ extension: Extension | null; error: string | null }> {
const resolvedPath = resolvePath(extensionPath, cwd); const resolvedPath = resolvePath(extensionPath, cwd, { normalizeUnicodeSpaces: true });
try { try {
const factory = await loadExtensionModule(resolvedPath); const factory = await loadExtensionModule(resolvedPath);
@@ -426,7 +401,8 @@ export async function loadExtensionFromFactory(
extensionPath = "<inline>", extensionPath = "<inline>",
): Promise<Extension> { ): Promise<Extension> {
const extension = createExtension(extensionPath, extensionPath); const extension = createExtension(extensionPath, extensionPath);
const api = createExtensionAPI(extension, runtime, cwd, eventBus); const resolvedCwd = resolvePath(cwd);
const api = createExtensionAPI(extension, runtime, resolvedCwd, eventBus);
await factory(api); await factory(api);
return extension; return extension;
} }
@@ -437,11 +413,12 @@ export async function loadExtensionFromFactory(
export async function loadExtensions(paths: string[], cwd: string, eventBus?: EventBus): Promise<LoadExtensionsResult> { export async function loadExtensions(paths: string[], cwd: string, eventBus?: EventBus): Promise<LoadExtensionsResult> {
const extensions: Extension[] = []; const extensions: Extension[] = [];
const errors: Array<{ path: string; error: string }> = []; const errors: Array<{ path: string; error: string }> = [];
const resolvedCwd = resolvePath(cwd);
const resolvedEventBus = eventBus ?? createEventBus(); const resolvedEventBus = eventBus ?? createEventBus();
const runtime = createExtensionRuntime(); const runtime = createExtensionRuntime();
for (const extPath of paths) { for (const extPath of paths) {
const { extension, error } = await loadExtension(extPath, cwd, resolvedEventBus, runtime); const { extension, error } = await loadExtension(extPath, resolvedCwd, resolvedEventBus, runtime);
if (error) { if (error) {
errors.push({ path: extPath, error }); errors.push({ path: extPath, error });
@@ -578,6 +555,8 @@ export async function discoverAndLoadExtensions(
agentDir: string = getAgentDir(), agentDir: string = getAgentDir(),
eventBus?: EventBus, eventBus?: EventBus,
): Promise<LoadExtensionsResult> { ): Promise<LoadExtensionsResult> {
const resolvedCwd = resolvePath(cwd);
const resolvedAgentDir = resolvePath(agentDir);
const allPaths: string[] = []; const allPaths: string[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
@@ -592,16 +571,16 @@ export async function discoverAndLoadExtensions(
}; };
// 1. Project-local extensions: cwd/${CONFIG_DIR_NAME}/extensions/ // 1. Project-local extensions: cwd/${CONFIG_DIR_NAME}/extensions/
const localExtDir = path.join(cwd, CONFIG_DIR_NAME, "extensions"); const localExtDir = path.join(resolvedCwd, CONFIG_DIR_NAME, "extensions");
addPaths(discoverExtensionsInDir(localExtDir)); addPaths(discoverExtensionsInDir(localExtDir));
// 2. Global extensions: agentDir/extensions/ // 2. Global extensions: agentDir/extensions/
const globalExtDir = path.join(agentDir, "extensions"); const globalExtDir = path.join(resolvedAgentDir, "extensions");
addPaths(discoverExtensionsInDir(globalExtDir)); addPaths(discoverExtensionsInDir(globalExtDir));
// 3. Explicitly configured paths // 3. Explicitly configured paths
for (const p of configuredPaths) { for (const p of configuredPaths) {
const resolved = resolvePath(p, cwd); const resolved = resolvePath(p, resolvedCwd, { normalizeUnicodeSpaces: true });
if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) { if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
// Check for package.json with pi manifest or index.ts // Check for package.json with pi manifest or index.ts
const entries = resolveExtensionEntries(resolved); const entries = resolveExtensionEntries(resolved);
@@ -617,5 +596,5 @@ export async function discoverAndLoadExtensions(
addPaths([resolved]); addPaths([resolved]);
} }
return loadExtensions(allPaths, cwd, eventBus); return loadExtensions(allPaths, resolvedCwd, eventBus);
} }

View File

@@ -25,6 +25,7 @@ import { type Static, Type } from "typebox";
import { Compile } from "typebox/compile"; import { Compile } from "typebox/compile";
import type { TLocalizedValidationError } from "typebox/error"; import type { TLocalizedValidationError } from "typebox/error";
import { getAgentDir } from "../config.ts"; import { getAgentDir } from "../config.ts";
import { normalizePath } from "../utils/paths.ts";
import type { AuthStatus, AuthStorage } from "./auth-storage.ts"; import type { AuthStatus, AuthStorage } from "./auth-storage.ts";
import { BUILT_IN_PROVIDER_DISPLAY_NAMES } from "./provider-display-names.ts"; import { BUILT_IN_PROVIDER_DISPLAY_NAMES } from "./provider-display-names.ts";
import { import {
@@ -339,7 +340,7 @@ export class ModelRegistry {
private constructor(authStorage: AuthStorage, modelsJsonPath: string | undefined) { private constructor(authStorage: AuthStorage, modelsJsonPath: string | undefined) {
this.authStorage = authStorage; this.authStorage = authStorage;
this.modelsJsonPath = modelsJsonPath; this.modelsJsonPath = modelsJsonPath ? normalizePath(modelsJsonPath) : undefined;
this.loadModels(); this.loadModels();
} }

View File

@@ -30,7 +30,7 @@ import { minimatch } from "minimatch";
import { CONFIG_DIR_NAME } from "../config.ts"; import { CONFIG_DIR_NAME } from "../config.ts";
import { spawnProcess, spawnProcessSync } from "../utils/child-process.ts"; import { spawnProcess, spawnProcessSync } from "../utils/child-process.ts";
import { type GitSource, parseGitUrl } from "../utils/git.ts"; import { type GitSource, parseGitUrl } from "../utils/git.ts";
import { canonicalizePath, isLocalPath, markPathIgnoredByCloudSync } from "../utils/paths.ts"; import { canonicalizePath, isLocalPath, markPathIgnoredByCloudSync, resolvePath } from "../utils/paths.ts";
import { isStdoutTakenOver } from "./output-guard.ts"; import { isStdoutTakenOver } from "./output-guard.ts";
import type { PackageSource, SettingsManager } from "./settings-manager.ts"; import type { PackageSource, SettingsManager } from "./settings-manager.ts";
@@ -763,8 +763,8 @@ export class DefaultPackageManager implements PackageManager {
private progressCallback: ProgressCallback | undefined; private progressCallback: ProgressCallback | undefined;
constructor(options: PackageManagerOptions) { constructor(options: PackageManagerOptions) {
this.cwd = options.cwd; this.cwd = resolvePath(options.cwd);
this.agentDir = options.agentDir; this.agentDir = resolvePath(options.agentDir);
this.settingsManager = options.settingsManager; this.settingsManager = options.settingsManager;
} }
@@ -1968,19 +1968,11 @@ export class DefaultPackageManager implements PackageManager {
} }
private resolvePath(input: string): string { private resolvePath(input: string): string {
const trimmed = input.trim(); return resolvePath(input, this.cwd, { homeDir: getHomeDir(), trim: true });
if (trimmed === "~") return getHomeDir();
if (trimmed.startsWith("~/")) return join(getHomeDir(), trimmed.slice(2));
if (trimmed.startsWith("~")) return join(getHomeDir(), trimmed.slice(1));
return resolve(this.cwd, trimmed);
} }
private resolvePathFromBase(input: string, baseDir: string): string { private resolvePathFromBase(input: string, baseDir: string): string {
const trimmed = input.trim(); return resolvePath(input, baseDir, { homeDir: getHomeDir(), trim: true });
if (trimmed === "~") return getHomeDir();
if (trimmed.startsWith("~/")) return join(getHomeDir(), trimmed.slice(2));
if (trimmed.startsWith("~")) return join(getHomeDir(), trimmed.slice(1));
return resolve(baseDir, trimmed);
} }
private collectPackageResources( private collectPackageResources(

View File

@@ -1,8 +1,8 @@
import { existsSync, readdirSync, readFileSync, statSync } from "fs"; import { existsSync, readdirSync, readFileSync, statSync } from "fs";
import { homedir } from "os"; import { basename, dirname, join, resolve, sep } from "path";
import { basename, dirname, isAbsolute, join, resolve, sep } from "path";
import { CONFIG_DIR_NAME } from "../config.ts"; import { CONFIG_DIR_NAME } from "../config.ts";
import { parseFrontmatter } from "../utils/frontmatter.ts"; import { parseFrontmatter } from "../utils/frontmatter.ts";
import { resolvePath } from "../utils/paths.ts";
import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.ts"; import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.ts";
/** /**
@@ -185,19 +185,6 @@ export interface LoadPromptTemplatesOptions {
includeDefaults: boolean; includeDefaults: boolean;
} }
function normalizePath(input: string): string {
const trimmed = input.trim();
if (trimmed === "~") return homedir();
if (trimmed.startsWith("~/")) return join(homedir(), trimmed.slice(2));
if (trimmed.startsWith("~")) return join(homedir(), trimmed.slice(1));
return trimmed;
}
function resolvePromptPath(p: string, cwd: string): string {
const normalized = normalizePath(p);
return isAbsolute(normalized) ? normalized : resolve(cwd, normalized);
}
/** /**
* Load all prompt templates from: * Load all prompt templates from:
* 1. Global: agentDir/prompts/ * 1. Global: agentDir/prompts/
@@ -205,14 +192,14 @@ function resolvePromptPath(p: string, cwd: string): string {
* 3. Explicit prompt paths * 3. Explicit prompt paths
*/ */
export function loadPromptTemplates(options: LoadPromptTemplatesOptions): PromptTemplate[] { export function loadPromptTemplates(options: LoadPromptTemplatesOptions): PromptTemplate[] {
const resolvedCwd = options.cwd; const resolvedCwd = resolvePath(options.cwd);
const resolvedAgentDir = options.agentDir; const resolvedAgentDir = resolvePath(options.agentDir);
const promptPaths = options.promptPaths; const promptPaths = options.promptPaths;
const includeDefaults = options.includeDefaults; const includeDefaults = options.includeDefaults;
const templates: PromptTemplate[] = []; const templates: PromptTemplate[] = [];
const globalPromptsDir = options.agentDir ? join(options.agentDir, "prompts") : resolvedAgentDir; const globalPromptsDir = join(resolvedAgentDir, "prompts");
const projectPromptsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "prompts"); const projectPromptsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "prompts");
const isUnderPath = (target: string, root: string): boolean => { const isUnderPath = (target: string, root: string): boolean => {
@@ -252,7 +239,7 @@ export function loadPromptTemplates(options: LoadPromptTemplatesOptions): Prompt
// 3. Load explicit prompt paths // 3. Load explicit prompt paths
for (const rawPath of promptPaths) { for (const rawPath of promptPaths) {
const resolvedPath = resolvePromptPath(rawPath, resolvedCwd); const resolvedPath = resolvePath(rawPath, resolvedCwd, { trim: true });
if (!existsSync(resolvedPath)) { if (!existsSync(resolvedPath)) {
continue; continue;
} }

View File

@@ -1,5 +1,4 @@
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve, sep } from "node:path"; import { join, resolve, sep } from "node:path";
import chalk from "chalk"; import chalk from "chalk";
import { CONFIG_DIR_NAME } from "../config.ts"; import { CONFIG_DIR_NAME } from "../config.ts";
@@ -8,7 +7,7 @@ import type { ResourceDiagnostic } from "./diagnostics.ts";
export type { ResourceCollision, ResourceDiagnostic } from "./diagnostics.ts"; export type { ResourceCollision, ResourceDiagnostic } from "./diagnostics.ts";
import { canonicalizePath, isLocalPath } from "../utils/paths.ts"; import { canonicalizePath, isLocalPath, resolvePath } from "../utils/paths.ts";
import { createEventBus, type EventBus } from "./event-bus.ts"; import { createEventBus, type EventBus } from "./event-bus.ts";
import { createExtensionRuntime, loadExtensionFromFactory, loadExtensions } from "./extensions/loader.ts"; import { createExtensionRuntime, loadExtensionFromFactory, loadExtensions } from "./extensions/loader.ts";
import type { Extension, ExtensionFactory, ExtensionRuntime, LoadExtensionsResult } from "./extensions/types.ts"; import type { Extension, ExtensionFactory, ExtensionRuntime, LoadExtensionsResult } from "./extensions/types.ts";
@@ -77,8 +76,8 @@ export function loadProjectContextFiles(options: {
cwd: string; cwd: string;
agentDir: string; agentDir: string;
}): Array<{ path: string; content: string }> { }): Array<{ path: string; content: string }> {
const resolvedCwd = options.cwd; const resolvedCwd = resolvePath(options.cwd);
const resolvedAgentDir = options.agentDir; const resolvedAgentDir = resolvePath(options.agentDir);
const contextFiles: Array<{ path: string; content: string }> = []; const contextFiles: Array<{ path: string; content: string }> = [];
const seenPaths = new Set<string>(); const seenPaths = new Set<string>();
@@ -205,8 +204,8 @@ export class DefaultResourceLoader implements ResourceLoader {
private lastThemePaths: string[]; private lastThemePaths: string[];
constructor(options: DefaultResourceLoaderOptions) { constructor(options: DefaultResourceLoaderOptions) {
this.cwd = options.cwd; this.cwd = resolvePath(options.cwd);
this.agentDir = options.agentDir; this.agentDir = resolvePath(options.agentDir);
this.settingsManager = options.settingsManager ?? SettingsManager.create(this.cwd, this.agentDir); this.settingsManager = options.settingsManager ?? SettingsManager.create(this.cwd, this.agentDir);
this.eventBus = options.eventBus ?? createEventBus(); this.eventBus = options.eventBus ?? createEventBus();
this.packageManager = new DefaultPackageManager({ this.packageManager = new DefaultPackageManager({
@@ -409,8 +408,11 @@ export class DefaultResourceLoader implements ResourceLoader {
} }
for (const p of this.additionalExtensionPaths) { for (const p of this.additionalExtensionPaths) {
if (isLocalPath(p) && !existsSync(p)) { if (isLocalPath(p)) {
extensionsResult.errors.push({ path: p, error: `Extension path does not exist: ${p}` }); const resolved = this.resolveResourcePath(p);
if (!existsSync(resolved)) {
extensionsResult.errors.push({ path: resolved, error: `Extension path does not exist: ${resolved}` });
}
} }
} }
this.extensionsResult = this.extensionsOverride ? this.extensionsOverride(extensionsResult) : extensionsResult; this.extensionsResult = this.extensionsOverride ? this.extensionsOverride(extensionsResult) : extensionsResult;
@@ -423,8 +425,11 @@ export class DefaultResourceLoader implements ResourceLoader {
this.lastSkillPaths = skillPaths; this.lastSkillPaths = skillPaths;
this.updateSkillsFromPaths(skillPaths, metadataByPath); this.updateSkillsFromPaths(skillPaths, metadataByPath);
for (const p of this.additionalSkillPaths) { for (const p of this.additionalSkillPaths) {
if (isLocalPath(p) && !existsSync(p) && !this.skillDiagnostics.some((d) => d.path === p)) { if (isLocalPath(p)) {
this.skillDiagnostics.push({ type: "error", message: "Skill path does not exist", path: p }); const resolved = this.resolveResourcePath(p);
if (!existsSync(resolved) && !this.skillDiagnostics.some((d) => d.path === resolved)) {
this.skillDiagnostics.push({ type: "error", message: "Skill path does not exist", path: resolved });
}
} }
} }
@@ -435,8 +440,15 @@ export class DefaultResourceLoader implements ResourceLoader {
this.lastPromptPaths = promptPaths; this.lastPromptPaths = promptPaths;
this.updatePromptsFromPaths(promptPaths, metadataByPath); this.updatePromptsFromPaths(promptPaths, metadataByPath);
for (const p of this.additionalPromptTemplatePaths) { for (const p of this.additionalPromptTemplatePaths) {
if (isLocalPath(p) && !existsSync(p) && !this.promptDiagnostics.some((d) => d.path === p)) { if (isLocalPath(p)) {
this.promptDiagnostics.push({ type: "error", message: "Prompt template path does not exist", path: p }); const resolved = this.resolveResourcePath(p);
if (!existsSync(resolved) && !this.promptDiagnostics.some((d) => d.path === resolved)) {
this.promptDiagnostics.push({
type: "error",
message: "Prompt template path does not exist",
path: resolved,
});
}
} }
} }
@@ -447,8 +459,9 @@ export class DefaultResourceLoader implements ResourceLoader {
this.lastThemePaths = themePaths; this.lastThemePaths = themePaths;
this.updateThemesFromPaths(themePaths, metadataByPath); this.updateThemesFromPaths(themePaths, metadataByPath);
for (const p of this.additionalThemePaths) { for (const p of this.additionalThemePaths) {
if (!existsSync(p) && !this.themeDiagnostics.some((d) => d.path === p)) { const resolved = this.resolveResourcePath(p);
this.themeDiagnostics.push({ type: "error", message: "Theme path does not exist", path: p }); if (!existsSync(resolved) && !this.themeDiagnostics.some((d) => d.path === resolved)) {
this.themeDiagnostics.push({ type: "error", message: "Theme path does not exist", path: resolved });
} }
} }
@@ -478,10 +491,15 @@ export class DefaultResourceLoader implements ResourceLoader {
private normalizeExtensionPaths( private normalizeExtensionPaths(
entries: Array<{ path: string; metadata: PathMetadata }>, entries: Array<{ path: string; metadata: PathMetadata }>,
): Array<{ path: string; metadata: PathMetadata }> { ): Array<{ path: string; metadata: PathMetadata }> {
return entries.map((entry) => ({ return entries.map((entry) => {
path: this.resolveResourcePath(entry.path), const metadata = entry.metadata.baseDir
metadata: entry.metadata, ? { ...entry.metadata, baseDir: this.resolveResourcePath(entry.metadata.baseDir) }
})); : entry.metadata;
return {
path: this.resolveResourcePath(entry.path),
metadata,
};
});
} }
private updateSkillsFromPaths(skillPaths: string[], metadataByPath?: Map<string, PathMetadata>): void { private updateSkillsFromPaths(skillPaths: string[], metadataByPath?: Map<string, PathMetadata>): void {
@@ -674,16 +692,7 @@ export class DefaultResourceLoader implements ResourceLoader {
} }
private resolveResourcePath(p: string): string { private resolveResourcePath(p: string): string {
const trimmed = p.trim(); return resolvePath(p, this.cwd, { trim: true });
let expanded = trimmed;
if (trimmed === "~") {
expanded = homedir();
} else if (trimmed.startsWith("~/")) {
expanded = join(homedir(), trimmed.slice(2));
} else if (trimmed.startsWith("~")) {
expanded = join(homedir(), trimmed.slice(1));
}
return resolve(this.cwd, expanded);
} }
private loadThemes( private loadThemes(
@@ -704,7 +713,7 @@ export class DefaultResourceLoader implements ResourceLoader {
} }
for (const p of paths) { for (const p of paths) {
const resolved = resolve(this.cwd, p); const resolved = this.resolveResourcePath(p);
if (!existsSync(resolved)) { if (!existsSync(resolved)) {
diagnostics.push({ type: "warning", message: "theme path does not exist", path: resolved }); diagnostics.push({ type: "warning", message: "theme path does not exist", path: resolved });
continue; continue;

View File

@@ -2,6 +2,7 @@ import { join } from "node:path";
import { Agent, type AgentMessage, type ThinkingLevel } from "@earendil-works/pi-agent-core"; import { Agent, type AgentMessage, type ThinkingLevel } from "@earendil-works/pi-agent-core";
import { clampThinkingLevel, type Message, type Model, streamSimple } from "@earendil-works/pi-ai"; import { clampThinkingLevel, type Message, type Model, streamSimple } from "@earendil-works/pi-ai";
import { getAgentDir } from "../config.ts"; import { getAgentDir } from "../config.ts";
import { resolvePath } from "../utils/paths.ts";
import { AgentSession } from "./agent-session.ts"; import { AgentSession } from "./agent-session.ts";
import { formatNoModelsAvailableMessage } from "./auth-guidance.ts"; import { formatNoModelsAvailableMessage } from "./auth-guidance.ts";
import { AuthStorage } from "./auth-storage.ts"; import { AuthStorage } from "./auth-storage.ts";
@@ -191,8 +192,8 @@ function getAttributionHeaders(
* ``` * ```
*/ */
export async function createAgentSession(options: CreateAgentSessionOptions = {}): Promise<CreateAgentSessionResult> { export async function createAgentSession(options: CreateAgentSessionOptions = {}): Promise<CreateAgentSessionResult> {
const cwd = options.cwd ?? options.sessionManager?.getCwd() ?? process.cwd(); const cwd = resolvePath(options.cwd ?? options.sessionManager?.getCwd() ?? process.cwd());
const agentDir = options.agentDir ?? getDefaultAgentDir(); const agentDir = options.agentDir ? resolvePath(options.agentDir) : getDefaultAgentDir();
let resourceLoader = options.resourceLoader; let resourceLoader = options.resourceLoader;
// Use provided or create AuthStorage and ModelRegistry // Use provided or create AuthStorage and ModelRegistry

View File

@@ -16,6 +16,7 @@ import {
import { readdir, readFile, stat } from "fs/promises"; import { readdir, readFile, stat } from "fs/promises";
import { join, resolve } from "path"; import { join, resolve } from "path";
import { getAgentDir as getDefaultAgentDir, getSessionsDir } from "../config.ts"; import { getAgentDir as getDefaultAgentDir, getSessionsDir } from "../config.ts";
import { normalizePath, resolvePath } from "../utils/paths.ts";
import { import {
type BashExecutionMessage, type BashExecutionMessage,
type CustomMessage, type CustomMessage,
@@ -425,8 +426,10 @@ export function buildSessionContext(
* Encodes cwd into a safe directory name under ~/.pi/agent/sessions/. * Encodes cwd into a safe directory name under ~/.pi/agent/sessions/.
*/ */
export function getDefaultSessionDir(cwd: string, agentDir: string = getDefaultAgentDir()): string { export function getDefaultSessionDir(cwd: string, agentDir: string = getDefaultAgentDir()): string {
const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; const resolvedCwd = resolvePath(cwd);
const sessionDir = join(agentDir, "sessions", safePath); const resolvedAgentDir = resolvePath(agentDir);
const safePath = `--${resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
const sessionDir = join(resolvedAgentDir, "sessions", safePath);
if (!existsSync(sessionDir)) { if (!existsSync(sessionDir)) {
mkdirSync(sessionDir, { recursive: true }); mkdirSync(sessionDir, { recursive: true });
} }
@@ -435,9 +438,10 @@ export function getDefaultSessionDir(cwd: string, agentDir: string = getDefaultA
/** Exported for testing */ /** Exported for testing */
export function loadEntriesFromFile(filePath: string): FileEntry[] { export function loadEntriesFromFile(filePath: string): FileEntry[] {
if (!existsSync(filePath)) return []; const resolvedFilePath = normalizePath(filePath);
if (!existsSync(resolvedFilePath)) return [];
const content = readFileSync(filePath, "utf8"); const content = readFileSync(resolvedFilePath, "utf8");
const entries: FileEntry[] = []; const entries: FileEntry[] = [];
const lines = content.trim().split("\n"); const lines = content.trim().split("\n");
@@ -478,10 +482,11 @@ function isValidSessionFile(filePath: string): boolean {
/** Exported for testing */ /** Exported for testing */
export function findMostRecentSession(sessionDir: string): string | null { export function findMostRecentSession(sessionDir: string): string | null {
const resolvedSessionDir = normalizePath(sessionDir);
try { try {
const files = readdirSync(sessionDir) const files = readdirSync(resolvedSessionDir)
.filter((f) => f.endsWith(".jsonl")) .filter((f) => f.endsWith(".jsonl"))
.map((f) => join(sessionDir, f)) .map((f) => join(resolvedSessionDir, f))
.filter(isValidSessionFile) .filter(isValidSessionFile)
.map((path) => ({ path, mtime: statSync(path).mtime })) .map((path) => ({ path, mtime: statSync(path).mtime }))
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); .sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
@@ -717,11 +722,11 @@ export class SessionManager {
private leafId: string | null = null; private leafId: string | null = null;
private constructor(cwd: string, sessionDir: string, sessionFile: string | undefined, persist: boolean) { private constructor(cwd: string, sessionDir: string, sessionFile: string | undefined, persist: boolean) {
this.cwd = cwd; this.cwd = resolvePath(cwd);
this.sessionDir = sessionDir; this.sessionDir = normalizePath(sessionDir);
this.persist = persist; this.persist = persist;
if (persist && sessionDir && !existsSync(sessionDir)) { if (persist && this.sessionDir && !existsSync(this.sessionDir)) {
mkdirSync(sessionDir, { recursive: true }); mkdirSync(this.sessionDir, { recursive: true });
} }
if (sessionFile) { if (sessionFile) {
@@ -733,7 +738,7 @@ export class SessionManager {
/** Switch to a different session file (used for resume and branching) */ /** Switch to a different session file (used for resume and branching) */
setSessionFile(sessionFile: string): void { setSessionFile(sessionFile: string): void {
this.sessionFile = resolve(sessionFile); this.sessionFile = resolvePath(sessionFile);
if (existsSync(this.sessionFile)) { if (existsSync(this.sessionFile)) {
this.fileEntries = loadEntriesFromFile(this.sessionFile); this.fileEntries = loadEntriesFromFile(this.sessionFile);
@@ -1304,7 +1309,7 @@ export class SessionManager {
* @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions/<encoded-cwd>/). * @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions/<encoded-cwd>/).
*/ */
static create(cwd: string, sessionDir?: string): SessionManager { static create(cwd: string, sessionDir?: string): SessionManager {
const dir = sessionDir ?? getDefaultSessionDir(cwd); const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd);
return new SessionManager(cwd, dir, undefined, true); return new SessionManager(cwd, dir, undefined, true);
} }
@@ -1315,13 +1320,14 @@ export class SessionManager {
* @param cwdOverride Optional cwd override instead of the session header cwd. * @param cwdOverride Optional cwd override instead of the session header cwd.
*/ */
static open(path: string, sessionDir?: string, cwdOverride?: string): SessionManager { static open(path: string, sessionDir?: string, cwdOverride?: string): SessionManager {
const resolvedPath = resolvePath(path);
// Extract cwd from session header if possible, otherwise use process.cwd() // Extract cwd from session header if possible, otherwise use process.cwd()
const entries = loadEntriesFromFile(path); const entries = loadEntriesFromFile(resolvedPath);
const header = entries.find((e) => e.type === "session") as SessionHeader | undefined; const header = entries.find((e) => e.type === "session") as SessionHeader | undefined;
const cwd = cwdOverride ?? header?.cwd ?? process.cwd(); const cwd = cwdOverride ?? header?.cwd ?? process.cwd();
// If no sessionDir provided, derive from file's parent directory // If no sessionDir provided, derive from file's parent directory
const dir = sessionDir ?? resolve(path, ".."); const dir = sessionDir ? normalizePath(sessionDir) : resolve(resolvedPath, "..");
return new SessionManager(cwd, dir, path, true); return new SessionManager(cwd, dir, resolvedPath, true);
} }
/** /**
@@ -1330,7 +1336,7 @@ export class SessionManager {
* @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions/<encoded-cwd>/). * @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions/<encoded-cwd>/).
*/ */
static continueRecent(cwd: string, sessionDir?: string): SessionManager { static continueRecent(cwd: string, sessionDir?: string): SessionManager {
const dir = sessionDir ?? getDefaultSessionDir(cwd); const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd);
const mostRecent = findMostRecentSession(dir); const mostRecent = findMostRecentSession(dir);
if (mostRecent) { if (mostRecent) {
return new SessionManager(cwd, dir, mostRecent, true); return new SessionManager(cwd, dir, mostRecent, true);
@@ -1351,17 +1357,19 @@ export class SessionManager {
* @param sessionDir Optional session directory. If omitted, uses default for targetCwd. * @param sessionDir Optional session directory. If omitted, uses default for targetCwd.
*/ */
static forkFrom(sourcePath: string, targetCwd: string, sessionDir?: string): SessionManager { static forkFrom(sourcePath: string, targetCwd: string, sessionDir?: string): SessionManager {
const sourceEntries = loadEntriesFromFile(sourcePath); const resolvedSourcePath = resolvePath(sourcePath);
const resolvedTargetCwd = resolvePath(targetCwd);
const sourceEntries = loadEntriesFromFile(resolvedSourcePath);
if (sourceEntries.length === 0) { if (sourceEntries.length === 0) {
throw new Error(`Cannot fork: source session file is empty or invalid: ${sourcePath}`); throw new Error(`Cannot fork: source session file is empty or invalid: ${resolvedSourcePath}`);
} }
const sourceHeader = sourceEntries.find((e) => e.type === "session") as SessionHeader | undefined; const sourceHeader = sourceEntries.find((e) => e.type === "session") as SessionHeader | undefined;
if (!sourceHeader) { if (!sourceHeader) {
throw new Error(`Cannot fork: source session has no header: ${sourcePath}`); throw new Error(`Cannot fork: source session has no header: ${resolvedSourcePath}`);
} }
const dir = sessionDir ?? getDefaultSessionDir(targetCwd); const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(resolvedTargetCwd);
if (!existsSync(dir)) { if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true }); mkdirSync(dir, { recursive: true });
} }
@@ -1378,8 +1386,8 @@ export class SessionManager {
version: CURRENT_SESSION_VERSION, version: CURRENT_SESSION_VERSION,
id: newSessionId, id: newSessionId,
timestamp, timestamp,
cwd: targetCwd, cwd: resolvedTargetCwd,
parentSession: sourcePath, parentSession: resolvedSourcePath,
}; };
appendFileSync(newSessionFile, `${JSON.stringify(newHeader)}\n`); appendFileSync(newSessionFile, `${JSON.stringify(newHeader)}\n`);
@@ -1390,7 +1398,7 @@ export class SessionManager {
} }
} }
return new SessionManager(targetCwd, dir, newSessionFile, true); return new SessionManager(resolvedTargetCwd, dir, newSessionFile, true);
} }
/** /**
@@ -1400,7 +1408,7 @@ export class SessionManager {
* @param onProgress Optional callback for progress updates (loaded, total) * @param onProgress Optional callback for progress updates (loaded, total)
*/ */
static async list(cwd: string, sessionDir?: string, onProgress?: SessionListProgress): Promise<SessionInfo[]> { static async list(cwd: string, sessionDir?: string, onProgress?: SessionListProgress): Promise<SessionInfo[]> {
const dir = sessionDir ?? getDefaultSessionDir(cwd); const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd);
const sessions = await listSessionsFromDir(dir, onProgress); const sessions = await listSessionsFromDir(dir, onProgress);
sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime()); sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
return sessions; return sessions;

View File

@@ -1,9 +1,9 @@
import type { Transport } from "@earendil-works/pi-ai"; import type { Transport } from "@earendil-works/pi-ai";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { homedir } from "os";
import { dirname, join } from "path"; import { dirname, join } from "path";
import lockfile from "proper-lockfile"; import lockfile from "proper-lockfile";
import { CONFIG_DIR_NAME, getAgentDir } from "../config.ts"; import { CONFIG_DIR_NAME, getAgentDir } from "../config.ts";
import { normalizePath, resolvePath } from "../utils/paths.ts";
import { DEFAULT_HTTP_IDLE_TIMEOUT_MS, parseHttpIdleTimeoutMs } from "./http-dispatcher.ts"; import { DEFAULT_HTTP_IDLE_TIMEOUT_MS, parseHttpIdleTimeoutMs } from "./http-dispatcher.ts";
export interface CompactionSettings { export interface CompactionSettings {
@@ -161,8 +161,10 @@ export class FileSettingsStorage implements SettingsStorage {
private projectSettingsPath: string; private projectSettingsPath: string;
constructor(cwd: string, agentDir: string) { constructor(cwd: string, agentDir: string) {
this.globalSettingsPath = join(agentDir, "settings.json"); const resolvedCwd = resolvePath(cwd);
this.projectSettingsPath = join(cwd, CONFIG_DIR_NAME, "settings.json"); const resolvedAgentDir = resolvePath(agentDir);
this.globalSettingsPath = join(resolvedAgentDir, "settings.json");
this.projectSettingsPath = join(resolvedCwd, CONFIG_DIR_NAME, "settings.json");
} }
private acquireLockSyncWithRetry(path: string): () => void { private acquireLockSyncWithRetry(path: string): () => void {
@@ -577,16 +579,7 @@ export class SettingsManager {
getSessionDir(): string | undefined { getSessionDir(): string | undefined {
const sessionDir = this.settings.sessionDir; const sessionDir = this.settings.sessionDir;
if (!sessionDir) { return sessionDir ? normalizePath(sessionDir) : sessionDir;
return sessionDir;
}
if (sessionDir === "~") {
return homedir();
}
if (sessionDir.startsWith("~/")) {
return join(homedir(), sessionDir.slice(2));
}
return sessionDir;
} }
getDefaultProvider(): string | undefined { getDefaultProvider(): string | undefined {

View File

@@ -1,10 +1,9 @@
import { existsSync, readdirSync, readFileSync, statSync } from "fs"; import { existsSync, readdirSync, readFileSync, statSync } from "fs";
import ignore from "ignore"; import ignore from "ignore";
import { homedir } from "os"; import { basename, dirname, join, relative, resolve, sep } from "path";
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "path";
import { CONFIG_DIR_NAME, getAgentDir } from "../config.ts"; import { CONFIG_DIR_NAME, getAgentDir } from "../config.ts";
import { parseFrontmatter } from "../utils/frontmatter.ts"; import { parseFrontmatter } from "../utils/frontmatter.ts";
import { canonicalizePath } from "../utils/paths.ts"; import { canonicalizePath, resolvePath } from "../utils/paths.ts";
import type { ResourceDiagnostic } from "./diagnostics.ts"; import type { ResourceDiagnostic } from "./diagnostics.ts";
import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.ts"; import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.ts";
@@ -381,28 +380,16 @@ export interface LoadSkillsOptions {
includeDefaults: boolean; includeDefaults: boolean;
} }
function normalizePath(input: string): string {
const trimmed = input.trim();
if (trimmed === "~") return homedir();
if (trimmed.startsWith("~/")) return join(homedir(), trimmed.slice(2));
if (trimmed.startsWith("~")) return join(homedir(), trimmed.slice(1));
return trimmed;
}
function resolveSkillPath(p: string, cwd: string): string {
const normalized = normalizePath(p);
return isAbsolute(normalized) ? normalized : resolve(cwd, normalized);
}
/** /**
* Load skills from all configured locations. * Load skills from all configured locations.
* Returns skills and any validation diagnostics. * Returns skills and any validation diagnostics.
*/ */
export function loadSkills(options: LoadSkillsOptions): LoadSkillsResult { export function loadSkills(options: LoadSkillsOptions): LoadSkillsResult {
const { cwd, agentDir, skillPaths, includeDefaults } = options; const { agentDir, skillPaths, includeDefaults } = options;
// Resolve agentDir - if not provided, use default from config // Resolve agentDir - if not provided, use default from config
const resolvedAgentDir = agentDir ?? getAgentDir(); const resolvedCwd = resolvePath(options.cwd);
const resolvedAgentDir = resolvePath(agentDir ?? getAgentDir());
const skillMap = new Map<string, Skill>(); const skillMap = new Map<string, Skill>();
const realPathSet = new Set<string>(); const realPathSet = new Set<string>();
@@ -442,11 +429,11 @@ export function loadSkills(options: LoadSkillsOptions): LoadSkillsResult {
if (includeDefaults) { if (includeDefaults) {
addSkills(loadSkillsFromDirInternal(join(resolvedAgentDir, "skills"), "user", true)); addSkills(loadSkillsFromDirInternal(join(resolvedAgentDir, "skills"), "user", true));
addSkills(loadSkillsFromDirInternal(resolve(cwd, CONFIG_DIR_NAME, "skills"), "project", true)); addSkills(loadSkillsFromDirInternal(resolve(resolvedCwd, CONFIG_DIR_NAME, "skills"), "project", true));
} }
const userSkillsDir = join(resolvedAgentDir, "skills"); const userSkillsDir = join(resolvedAgentDir, "skills");
const projectSkillsDir = resolve(cwd, CONFIG_DIR_NAME, "skills"); const projectSkillsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "skills");
const isUnderPath = (target: string, root: string): boolean => { const isUnderPath = (target: string, root: string): boolean => {
const normalizedRoot = resolve(root); const normalizedRoot = resolve(root);
@@ -466,7 +453,7 @@ export function loadSkills(options: LoadSkillsOptions): LoadSkillsResult {
}; };
for (const rawPath of skillPaths) { for (const rawPath of skillPaths) {
const resolvedPath = resolveSkillPath(rawPath, cwd); const resolvedPath = resolvePath(rawPath, resolvedCwd, { trim: true });
if (!existsSync(resolvedPath)) { if (!existsSync(resolvedPath)) {
allDiagnostics.push({ type: "warning", message: "skill path does not exist", path: resolvedPath }); allDiagnostics.push({ type: "warning", message: "skill path does not exist", path: resolvedPath });
continue; continue;

View File

@@ -1,12 +1,7 @@
import { accessSync, constants } from "node:fs"; import { accessSync, constants } from "node:fs";
import * as os from "node:os"; import { normalizePath, resolvePath } from "../../utils/paths.ts";
import { isAbsolute, resolve as resolvePath } from "node:path";
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
const NARROW_NO_BREAK_SPACE = "\u202F"; const NARROW_NO_BREAK_SPACE = "\u202F";
function normalizeUnicodeSpaces(str: string): string {
return str.replace(UNICODE_SPACES, " ");
}
function tryMacOSScreenshotPath(filePath: string): string { function tryMacOSScreenshotPath(filePath: string): string {
return filePath.replace(/ (AM|PM)\./gi, `${NARROW_NO_BREAK_SPACE}$1.`); return filePath.replace(/ (AM|PM)\./gi, `${NARROW_NO_BREAK_SPACE}$1.`);
@@ -32,19 +27,8 @@ function fileExists(filePath: string): boolean {
} }
} }
function normalizeAtPrefix(filePath: string): string {
return filePath.startsWith("@") ? filePath.slice(1) : filePath;
}
export function expandPath(filePath: string): string { export function expandPath(filePath: string): string {
const normalized = normalizeUnicodeSpaces(normalizeAtPrefix(filePath)); return normalizePath(filePath, { normalizeUnicodeSpaces: true, stripAtPrefix: true });
if (normalized === "~") {
return os.homedir();
}
if (normalized.startsWith("~/")) {
return os.homedir() + normalized.slice(1);
}
return normalized;
} }
/** /**
@@ -52,11 +36,7 @@ export function expandPath(filePath: string): string {
* Handles ~ expansion and absolute paths. * Handles ~ expansion and absolute paths.
*/ */
export function resolveToCwd(filePath: string, cwd: string): string { export function resolveToCwd(filePath: string, cwd: string): string {
const expanded = expandPath(filePath); return resolvePath(filePath, cwd, { normalizeUnicodeSpaces: true, stripAtPrefix: true });
if (isAbsolute(expanded)) {
return expanded;
}
return resolvePath(cwd, expanded);
} }
export function resolveReadPath(filePath: string, cwd: string): string { export function resolveReadPath(filePath: string, cwd: string): string {

View File

@@ -5,7 +5,6 @@
* createAgentSession() options. The SDK does the heavy lifting. * createAgentSession() options. The SDK does the heavy lifting.
*/ */
import { resolve } from "node:path";
import { createInterface } from "node:readline"; import { createInterface } from "node:readline";
import { type ImageContent, modelsAreEqual } from "@earendil-works/pi-ai"; import { type ImageContent, modelsAreEqual } from "@earendil-works/pi-ai";
import { ProcessTerminal, setKeybindings, TUI } from "@earendil-works/pi-tui"; import { ProcessTerminal, setKeybindings, TUI } from "@earendil-works/pi-tui";
@@ -46,7 +45,7 @@ import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.ts";
import { ExtensionSelectorComponent } from "./modes/interactive/components/extension-selector.ts"; import { ExtensionSelectorComponent } from "./modes/interactive/components/extension-selector.ts";
import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.ts"; import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.ts";
import { handleConfigCommand, handlePackageCommand } from "./package-manager-cli.ts"; import { handleConfigCommand, handlePackageCommand } from "./package-manager-cli.ts";
import { isLocalPath } from "./utils/paths.ts"; import { isLocalPath, normalizePath, resolvePath } from "./utils/paths.ts";
import { cleanupWindowsSelfUpdateQuarantine } from "./utils/windows-self-update.ts"; import { cleanupWindowsSelfUpdateQuarantine } from "./utils/windows-self-update.ts";
/** /**
@@ -147,9 +146,9 @@ type ResolvedSession =
* If it looks like a path, use as-is. Otherwise try to match as session ID prefix. * If it looks like a path, use as-is. Otherwise try to match as session ID prefix.
*/ */
async function resolveSessionPath(sessionArg: string, cwd: string, sessionDir?: string): Promise<ResolvedSession> { async function resolveSessionPath(sessionArg: string, cwd: string, sessionDir?: string): Promise<ResolvedSession> {
// If it looks like a file path, use as-is // If it looks like a file path, resolve it before handing it to the session manager.
if (sessionArg.includes("/") || sessionArg.includes("\\") || sessionArg.endsWith(".jsonl")) { if (sessionArg.includes("/") || sessionArg.includes("\\") || sessionArg.endsWith(".jsonl")) {
return { type: "path", path: sessionArg }; return { type: "path", path: resolvePath(sessionArg, cwd) };
} }
// Try to match as session ID in current project first // Try to match as session ID in current project first
@@ -381,7 +380,7 @@ function buildSessionOptions(
} }
function resolveCliPaths(cwd: string, paths: string[] | undefined): string[] | undefined { function resolveCliPaths(cwd: string, paths: string[] | undefined): string[] | undefined {
return paths?.map((value) => (isLocalPath(value) ? resolve(cwd, value) : value)); return paths?.map((value) => (isLocalPath(value) ? resolvePath(value, cwd) : value));
} }
async function promptForMissingSessionCwd( async function promptForMissingSessionCwd(
@@ -501,7 +500,7 @@ export async function main(args: string[], options?: MainOptions) {
// sessionDir lookup during session selection. // sessionDir lookup during session selection.
const envSessionDir = process.env[ENV_SESSION_DIR]; const envSessionDir = process.env[ENV_SESSION_DIR];
const sessionDir = const sessionDir =
parsed.sessionDir ?? (parsed.sessionDir ? normalizePath(parsed.sessionDir) : undefined) ??
(envSessionDir ? expandTildePath(envSessionDir) : undefined) ?? (envSessionDir ? expandTildePath(envSessionDir) : undefined) ??
startupSettingsManager.getSessionDir(); startupSettingsManager.getSessionDir();
let sessionManager = await createSessionManager(parsed, cwd, sessionDir, startupSettingsManager); let sessionManager = await createSessionManager(parsed, cwd, sessionDir, startupSettingsManager);

View File

@@ -1,7 +1,24 @@
import { realpathSync } from "node:fs"; import { realpathSync } from "node:fs";
import { isAbsolute, relative, resolve as resolvePath, sep } from "node:path"; import { homedir } from "node:os";
import { isAbsolute, join, resolve as nodeResolvePath, relative, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnProcessSync } from "./child-process.ts"; import { spawnProcessSync } from "./child-process.ts";
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
export interface PathInputOptions {
/** Trim leading/trailing whitespace before normalization. */
trim?: boolean;
/** Expand leading `~` to a home directory. Defaults to true. */
expandTilde?: boolean;
/** Home directory used for `~` expansion. Defaults to `os.homedir()`. */
homeDir?: string;
/** Strip a leading `@`, used for CLI @file paths. */
stripAtPrefix?: boolean;
/** Normalize unicode space variants to regular spaces. */
normalizeUnicodeSpaces?: boolean;
}
/** /**
* Resolve a path to its canonical (real) form, following symlinks. * Resolve a path to its canonical (real) form, following symlinks.
* Falls back to the raw path if resolution fails (e.g. the target does * Falls back to the raw path if resolution fails (e.g. the target does
@@ -18,12 +35,12 @@ export function canonicalizePath(path: string): string {
/** /**
* Returns true if the value is NOT a package source (npm:, git:, etc.) * Returns true if the value is NOT a package source (npm:, git:, etc.)
* or a URL protocol. Bare names and relative paths without ./ prefix * or a remote URL protocol. Bare names, relative paths, and file: URLs
* are considered local. * are considered local.
*/ */
export function isLocalPath(value: string): boolean { export function isLocalPath(value: string): boolean {
const trimmed = value.trim(); const trimmed = value.trim();
// Known non-local prefixes // Known non-local prefixes. file: URLs are local paths and are intentionally resolved by resolvePath().
if ( if (
trimmed.startsWith("npm:") || trimmed.startsWith("npm:") ||
trimmed.startsWith("git:") || trimmed.startsWith("git:") ||
@@ -37,13 +54,39 @@ export function isLocalPath(value: string): boolean {
return true; return true;
} }
function resolveAgainstCwd(filePath: string, cwd: string): string { export function normalizePath(input: string, options: PathInputOptions = {}): string {
return isAbsolute(filePath) ? resolvePath(filePath) : resolvePath(cwd, filePath); let normalized = options.trim ? input.trim() : input;
if (options.normalizeUnicodeSpaces) {
normalized = normalized.replace(UNICODE_SPACES, " ");
}
if (options.stripAtPrefix && normalized.startsWith("@")) {
normalized = normalized.slice(1);
}
if (options.expandTilde ?? true) {
const home = options.homeDir ?? homedir();
if (normalized === "~") return home;
if (normalized.startsWith("~/") || (process.platform === "win32" && normalized.startsWith("~\\"))) {
return join(home, normalized.slice(2));
}
}
if (/^file:\/\//.test(normalized)) {
return fileURLToPath(normalized);
}
return normalized;
}
export function resolvePath(input: string, baseDir: string = process.cwd(), options: PathInputOptions = {}): string {
const normalized = normalizePath(input, options);
const normalizedBaseDir = normalizePath(baseDir);
return isAbsolute(normalized) ? nodeResolvePath(normalized) : nodeResolvePath(normalizedBaseDir, normalized);
} }
export function getCwdRelativePath(filePath: string, cwd: string): string | undefined { export function getCwdRelativePath(filePath: string, cwd: string): string | undefined {
const resolvedCwd = resolvePath(cwd); const resolvedCwd = resolvePath(cwd);
const resolvedPath = resolveAgainstCwd(filePath, resolvedCwd); const resolvedPath = resolvePath(filePath, resolvedCwd);
const relativePath = relative(resolvedCwd, resolvedPath); const relativePath = relative(resolvedCwd, resolvedPath);
const isInsideCwd = const isInsideCwd =
relativePath === "" || relativePath === "" ||
@@ -53,7 +96,7 @@ export function getCwdRelativePath(filePath: string, cwd: string): string | unde
} }
export function formatPathRelativeToCwdOrAbsolute(filePath: string, cwd: string): string { export function formatPathRelativeToCwdOrAbsolute(filePath: string, cwd: string): string {
const absolutePath = resolveAgainstCwd(filePath, cwd); const absolutePath = resolvePath(filePath, cwd);
return (getCwdRelativePath(absolutePath, cwd) ?? absolutePath).split(sep).join("/"); return (getCwdRelativePath(absolutePath, cwd) ?? absolutePath).split(sep).join("/");
} }

View File

@@ -123,6 +123,31 @@ describe("extensions discovery", () => {
expect(result.extensions[0].path).toContain("main.ts"); expect(result.extensions[0].path).toContain("main.ts");
}); });
it("keeps package.json pi extension entries with leading tilde package-relative", async () => {
const subdir = path.join(extensionsDir, "tilde-package");
const directExtensionPath = path.join(subdir, "~entry.ts");
const slashExtensionPath = path.join(subdir, "~", "entry.ts");
fs.mkdirSync(path.join(subdir, "~"), { recursive: true });
fs.writeFileSync(directExtensionPath, extensionCode);
fs.writeFileSync(slashExtensionPath, extensionCode);
fs.writeFileSync(
path.join(subdir, "package.json"),
JSON.stringify({
name: "tilde-package",
pi: {
extensions: ["~entry.ts", "~/entry.ts"],
},
}),
);
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
expect(result.errors).toHaveLength(0);
expect(result.extensions.map((extension) => extension.path).sort()).toEqual(
[directExtensionPath, slashExtensionPath].sort(),
);
});
it("package.json can declare multiple extensions", async () => { it("package.json can declare multiple extensions", async () => {
const subdir = path.join(extensionsDir, "my-package"); const subdir = path.join(extensionsDir, "my-package");
fs.mkdirSync(subdir); fs.mkdirSync(subdir);

View File

@@ -584,6 +584,40 @@ Content`,
); );
}); });
it("should keep pi manifest entries with leading tilde package-relative", async () => {
const pkgDir = join(tempDir, "tilde-manifest-package");
const directExtensionPath = join(pkgDir, "~extensions", "main.ts");
const slashExtensionPath = join(pkgDir, "~", "extensions", "alt.ts");
const directSkillPath = join(pkgDir, "~skills", "direct-skill", "SKILL.md");
const slashSkillPath = join(pkgDir, "~", "skills", "slash-skill", "SKILL.md");
mkdirSync(join(pkgDir, "~extensions"), { recursive: true });
mkdirSync(join(pkgDir, "~", "extensions"), { recursive: true });
mkdirSync(join(pkgDir, "~skills", "direct-skill"), { recursive: true });
mkdirSync(join(pkgDir, "~", "skills", "slash-skill"), { recursive: true });
writeFileSync(directExtensionPath, "export default function() {}");
writeFileSync(slashExtensionPath, "export default function() {}");
writeFileSync(directSkillPath, "---\nname: direct-skill\ndescription: Direct\n---\nContent");
writeFileSync(slashSkillPath, "---\nname: slash-skill\ndescription: Slash\n---\nContent");
writeFileSync(
join(pkgDir, "package.json"),
JSON.stringify({
name: "tilde-manifest-package",
pi: {
extensions: ["~extensions/main.ts", "~/extensions/alt.ts"],
skills: ["~skills", "~/skills"],
},
}),
);
const result = await packageManager.resolveExtensionSources([pkgDir]);
expect(result.extensions.some((r) => r.path === directExtensionPath && r.enabled)).toBe(true);
expect(result.extensions.some((r) => r.path === slashExtensionPath && r.enabled)).toBe(true);
expect(result.skills.some((r) => r.path === directSkillPath && r.enabled)).toBe(true);
expect(result.skills.some((r) => r.path === slashSkillPath && r.enabled)).toBe(true);
});
it("should handle directories with auto-discovery layout", async () => { it("should handle directories with auto-discovery layout", async () => {
const pkgDir = join(tempDir, "auto-pkg"); const pkgDir = join(tempDir, "auto-pkg");
mkdirSync(join(pkgDir, "extensions"), { recursive: true }); mkdirSync(join(pkgDir, "extensions"), { recursive: true });

View File

@@ -16,6 +16,11 @@ describe("path-utils", () => {
expect(result).not.toContain("~/"); expect(result).not.toContain("~/");
}); });
it("should keep tilde-prefixed filenames literal", () => {
expect(expandPath("~draft.md")).toBe("~draft.md");
expect(expandPath("@~draft.md")).toBe("~draft.md");
});
it("should normalize Unicode spaces", () => { it("should normalize Unicode spaces", () => {
// Non-breaking space (U+00A0) should become regular space // Non-breaking space (U+00A0) should become regular space
const withNBSP = "file\u00A0name.txt"; const withNBSP = "file\u00A0name.txt";
@@ -26,14 +31,21 @@ describe("path-utils", () => {
describe("resolveToCwd", () => { describe("resolveToCwd", () => {
it("should resolve absolute paths as-is", () => { it("should resolve absolute paths as-is", () => {
const result = resolveToCwd("/absolute/path/file.txt", "/some/cwd"); const absolutePath = resolve(tmpdir(), "absolute", "path", "file.txt");
expect(result).toBe("/absolute/path/file.txt"); const result = resolveToCwd(absolutePath, resolve(tmpdir(), "some", "cwd"));
expect(result).toBe(absolutePath);
}); });
it("should resolve relative paths against cwd", () => { it("should resolve relative paths against cwd", () => {
const result = resolveToCwd("relative/file.txt", "/some/cwd"); const result = resolveToCwd("relative/file.txt", "/some/cwd");
expect(result).toBe(resolve("/some/cwd", "relative/file.txt")); expect(result).toBe(resolve("/some/cwd", "relative/file.txt"));
}); });
it("should resolve tilde-prefixed filenames against cwd", () => {
const cwd = join(tmpdir(), "pi-path-utils-cwd");
expect(resolveToCwd("~draft.md", cwd)).toBe(resolve(cwd, "~draft.md"));
expect(resolveToCwd("@~draft.md", cwd)).toBe(resolve(cwd, "~draft.md"));
});
}); });
describe("resolveReadPath", () => { describe("resolveReadPath", () => {

View File

@@ -1,8 +1,9 @@
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { homedir, tmpdir } from "node:os";
import { join } from "node:path"; import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { canonicalizePath, getCwdRelativePath, isLocalPath } from "../src/utils/paths.ts"; import { canonicalizePath, getCwdRelativePath, isLocalPath, normalizePath, resolvePath } from "../src/utils/paths.ts";
let tempDir: string; let tempDir: string;
@@ -73,6 +74,55 @@ describe("getCwdRelativePath", () => {
}); });
}); });
describe("resolvePath", () => {
it("expands only home tilde shortcuts", () => {
const cwd = join(tmpdir(), "pi-paths-cwd");
expect(normalizePath("~")).toBe(homedir());
expect(normalizePath("~/file.txt")).toBe(join(homedir(), "file.txt"));
expect(resolvePath("~draft.md", cwd)).toBe(resolve(cwd, "~draft.md"));
expect(normalizePath("~draft.md")).toBe("~draft.md");
});
it("resolves relative paths against the base directory", () => {
const cwd = join(tmpdir(), "pi-paths-cwd");
expect(resolvePath("subdir/file.txt", cwd)).toBe(resolve(cwd, "subdir/file.txt"));
expect(resolvePath("subdir/file.txt", pathToFileURL(cwd).href)).toBe(resolve(cwd, "subdir/file.txt"));
});
it("accepts file URLs", () => {
const dir = createTempDir();
const filePath = join(dir, "file with spaces.txt");
expect(resolvePath(pathToFileURL(filePath).href, join(dir, "base"))).toBe(resolve(filePath));
});
it("throws for invalid file URLs", () => {
expect(() => resolvePath("file:///%E0%A4%A")).toThrow();
});
it("preserves POSIX absolute paths with literal percent sequences", () => {
if (process.platform === "win32") {
return;
}
const dir = createTempDir();
for (const filePath of [join(dir, "report%2026.md"), join(dir, "foo%2Fbar"), join(dir, "malformed%A.md")]) {
expect(resolvePath(filePath, join(dir, "base"))).toBe(resolve(filePath));
}
});
it("does not treat Windows file URL pathname strings as native paths", () => {
if (process.platform !== "win32") {
return;
}
const dir = createTempDir();
const filePath = join(dir, "dir", "SKILL.md");
const pathname = pathToFileURL(filePath).pathname;
expect(pathname).toMatch(/^\/[A-Za-z]:/);
expect(resolvePath(pathname, "E:\\project")).toBe(resolve(pathname));
});
});
describe("isLocalPath", () => { describe("isLocalPath", () => {
it("returns true for bare names", () => { it("returns true for bare names", () => {
expect(isLocalPath("my-package")).toBe(true); expect(isLocalPath("my-package")).toBe(true);
@@ -82,6 +132,10 @@ describe("isLocalPath", () => {
expect(isLocalPath("./foo")).toBe(true); expect(isLocalPath("./foo")).toBe(true);
}); });
it("returns true for file URLs", () => {
expect(isLocalPath("file:///tmp/foo")).toBe(true);
});
it("returns false for npm: protocol", () => { it("returns false for npm: protocol", () => {
expect(isLocalPath("npm:package")).toBe(false); expect(isLocalPath("npm:package")).toBe(false);
}); });

View File

@@ -1,6 +1,7 @@
import { mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { AuthStorage } from "../src/core/auth-storage.ts"; import { AuthStorage } from "../src/core/auth-storage.ts";
import { ExtensionRunner } from "../src/core/extensions/runner.ts"; import { ExtensionRunner } from "../src/core/extensions/runner.ts";
@@ -405,6 +406,44 @@ Extra prompt content`,
expect(loadedPrompt?.sourceInfo?.source).toBe("extension:extra"); expect(loadedPrompt?.sourceInfo?.source).toBe("extension:extra");
expect(loadedPrompt?.sourceInfo?.path).toBe(promptPath); expect(loadedPrompt?.sourceInfo?.path).toBe(promptPath);
}); });
it("should load extension resources returned as file URLs", async () => {
const extraSkillDir = join(tempDir, "extra skills", "file-url-skill");
mkdirSync(extraSkillDir, { recursive: true });
const skillPath = join(extraSkillDir, "SKILL.md");
writeFileSync(
skillPath,
`---
name: file-url-skill
description: File URL skill
---
Extra content`,
);
const loader = new DefaultResourceLoader({ cwd, agentDir });
await loader.reload();
loader.extendResources({
skillPaths: [
{
path: pathToFileURL(extraSkillDir).href,
metadata: {
source: "extension:file-url",
scope: "temporary",
origin: "top-level",
baseDir: extraSkillDir,
},
},
],
});
const { skills, diagnostics } = loader.getSkills();
expect(diagnostics).toEqual([]);
const loadedSkill = skills.find((skill) => skill.name === "file-url-skill");
expect(loadedSkill).toBeDefined();
expect(loadedSkill?.filePath).toBe(skillPath);
expect(loadedSkill?.sourceInfo?.source).toBe("extension:file-url");
});
}); });
describe("noSkills option", () => { describe("noSkills option", () => {