From c100620bf447349ae7a4866bc1cb6757cc9f67c4 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 22 May 2026 11:25:15 +0200 Subject: [PATCH] fix(coding-agent): Clean up Path Handling (#4873) --- packages/coding-agent/src/config.ts | 9 +-- .../src/core/agent-session-runtime.ts | 3 +- .../src/core/agent-session-services.ts | 5 +- .../coding-agent/src/core/agent-session.ts | 8 ++- .../coding-agent/src/core/auth-storage.ts | 3 +- .../src/core/export-html/index.ts | 14 ++-- .../src/core/extensions/loader.ts | 45 ++++--------- .../coding-agent/src/core/model-registry.ts | 3 +- .../coding-agent/src/core/package-manager.ts | 18 ++--- .../coding-agent/src/core/prompt-templates.ts | 25 ++----- .../coding-agent/src/core/resource-loader.ts | 67 +++++++++++-------- packages/coding-agent/src/core/sdk.ts | 5 +- .../coding-agent/src/core/session-manager.ts | 56 +++++++++------- .../coding-agent/src/core/settings-manager.ts | 19 ++---- packages/coding-agent/src/core/skills.ts | 29 +++----- .../coding-agent/src/core/tools/path-utils.ts | 26 +------ packages/coding-agent/src/main.ts | 11 ++- packages/coding-agent/src/utils/paths.ts | 57 ++++++++++++++-- .../test/extensions-discovery.test.ts | 25 +++++++ .../coding-agent/test/package-manager.test.ts | 34 ++++++++++ packages/coding-agent/test/path-utils.test.ts | 16 ++++- packages/coding-agent/test/paths.test.ts | 60 ++++++++++++++++- .../coding-agent/test/resource-loader.test.ts | 39 +++++++++++ 23 files changed, 363 insertions(+), 214 deletions(-) diff --git a/packages/coding-agent/src/config.ts b/packages/coding-agent/src/config.ts index e55fa5f3..dd091804 100644 --- a/packages/coding-agent/src/config.ts +++ b/packages/coding-agent/src/config.ts @@ -3,6 +3,7 @@ import { homedir } from "os"; import { basename, dirname, join, resolve, sep, win32 } from "path"; import { fileURLToPath } from "url"; import { spawnProcessSync } from "./utils/child-process.ts"; +import { normalizePath } from "./utils/paths.ts"; // ============================================================================= // Package Detection @@ -330,9 +331,7 @@ export function getPackageDir(): string { // Allow override via environment variable (useful for Nix/Guix where store paths tokenize poorly) const envDir = process.env.PI_PACKAGE_DIR; if (envDir) { - if (envDir === "~") return homedir(); - if (envDir.startsWith("~/")) return homedir() + envDir.slice(1); - return envDir; + return normalizePath(envDir); } 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 function expandTildePath(path: string): string { - if (path === "~") return homedir(); - if (path.startsWith("~/")) return homedir() + path.slice(1); - return path; + return normalizePath(path); } const DEFAULT_SHARE_VIEWER_URL = "https://pi.dev/session/"; diff --git a/packages/coding-agent/src/core/agent-session-runtime.ts b/packages/coding-agent/src/core/agent-session-runtime.ts index b4756d4c..baab11b0 100644 --- a/packages/coding-agent/src/core/agent-session-runtime.ts +++ b/packages/coding-agent/src/core/agent-session-runtime.ts @@ -1,5 +1,6 @@ import { copyFileSync, existsSync, mkdirSync } from "node:fs"; import { basename, join, resolve } from "node:path"; +import { resolvePath } from "../utils/paths.ts"; import type { AgentSession } from "./agent-session.ts"; import type { AgentSessionRuntimeDiagnostic, AgentSessionServices } from "./agent-session-services.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. */ async importFromJsonl(inputPath: string, cwdOverride?: string): Promise<{ cancelled: boolean }> { - const resolvedPath = resolve(inputPath); + const resolvedPath = resolvePath(inputPath); if (!existsSync(resolvedPath)) { throw new SessionImportFileNotFoundError(resolvedPath); } diff --git a/packages/coding-agent/src/core/agent-session-services.ts b/packages/coding-agent/src/core/agent-session-services.ts index 852776a9..adf6f9e0 100644 --- a/packages/coding-agent/src/core/agent-session-services.ts +++ b/packages/coding-agent/src/core/agent-session-services.ts @@ -2,6 +2,7 @@ import { join } from "node:path"; import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { Model } from "@earendil-works/pi-ai"; import { getAgentDir } from "../config.ts"; +import { resolvePath } from "../utils/paths.ts"; import { AuthStorage } from "./auth-storage.ts"; import type { SessionStartEvent, ToolDefinition } from "./extensions/index.ts"; import { ModelRegistry } from "./model-registry.ts"; @@ -129,8 +130,8 @@ function applyExtensionFlagValues( export async function createAgentSessionServices( options: CreateAgentSessionServicesOptions, ): Promise { - const cwd = options.cwd; - const agentDir = options.agentDir ?? getAgentDir(); + const cwd = resolvePath(options.cwd); + const agentDir = options.agentDir ? resolvePath(options.agentDir) : getAgentDir(); const authStorage = options.authStorage ?? AuthStorage.create(join(agentDir, "auth.json")); const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir); const modelRegistry = options.modelRegistry ?? ModelRegistry.create(authStorage, join(agentDir, "models.json")); diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index d18591b7..cf13594b 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -14,7 +14,7 @@ */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { basename, dirname, resolve } from "node:path"; +import { basename, dirname } from "node:path"; import type { Agent, AgentEvent, @@ -35,6 +35,7 @@ import { } from "@earendil-works/pi-ai"; import { theme } from "../modes/interactive/theme/theme.ts"; import { stripFrontmatter } from "../utils/frontmatter.ts"; +import { resolvePath } from "../utils/paths.ts"; import { sleep } from "../utils/sleep.ts"; import { formatNoApiKeyFoundMessage, formatNoModelSelectedMessage } from "./auth-guidance.ts"; import { type BashResult, executeBashWithOperations } from "./bash-executor.ts"; @@ -2993,7 +2994,10 @@ export class AgentSession { * @returns The resolved output file path. */ 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); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); diff --git a/packages/coding-agent/src/core/auth-storage.ts b/packages/coding-agent/src/core/auth-storage.ts index ae2996c4..7630fc80 100644 --- a/packages/coding-agent/src/core/auth-storage.ts +++ b/packages/coding-agent/src/core/auth-storage.ts @@ -18,6 +18,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "f import { dirname, join } from "path"; import lockfile from "proper-lockfile"; import { getAgentDir } from "../config.ts"; +import { normalizePath } from "../utils/paths.ts"; import { resolveConfigValue } from "./resolve-config-value.ts"; export type ApiKeyCredential = { @@ -53,7 +54,7 @@ export class FileAuthStorageBackend implements AuthStorageBackend { private authPath: string; constructor(authPath: string = join(getAgentDir(), "auth.json")) { - this.authPath = authPath; + this.authPath = normalizePath(authPath); } private ensureParentDir(): void { diff --git a/packages/coding-agent/src/core/export-html/index.ts b/packages/coding-agent/src/core/export-html/index.ts index a831ae42..3762c264 100644 --- a/packages/coding-agent/src/core/export-html/index.ts +++ b/packages/coding-agent/src/core/export-html/index.ts @@ -3,6 +3,7 @@ import { existsSync, readFileSync, writeFileSync } from "fs"; import { basename, join } from "path"; import { APP_NAME, getExportTemplateDir } from "../../config.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 { SessionEntry } from "../session-manager.ts"; import { SessionManager } from "../session-manager.ts"; @@ -270,7 +271,7 @@ export async function exportSessionToHtml( const html = generateHtml(sessionData, opts.themeName); - let outputPath = opts.outputPath; + let outputPath = opts.outputPath ? normalizePath(opts.outputPath) : undefined; if (!outputPath) { const sessionBasename = basename(sessionFile, ".jsonl"); outputPath = `${APP_NAME}-session-${sessionBasename}.html`; @@ -286,12 +287,13 @@ export async function exportSessionToHtml( */ export async function exportFromFile(inputPath: string, options?: ExportOptions | string): Promise { const opts: ExportOptions = typeof options === "string" ? { outputPath: options } : options || {}; + const resolvedInputPath = resolvePath(inputPath); - if (!existsSync(inputPath)) { - throw new Error(`File not found: ${inputPath}`); + if (!existsSync(resolvedInputPath)) { + throw new Error(`File not found: ${resolvedInputPath}`); } - const sm = SessionManager.open(inputPath); + const sm = SessionManager.open(resolvedInputPath); const sessionData: SessionData = { header: sm.getHeader(), @@ -303,9 +305,9 @@ export async function exportFromFile(inputPath: string, options?: ExportOptions const html = generateHtml(sessionData, opts.themeName); - let outputPath = opts.outputPath; + let outputPath = opts.outputPath ? normalizePath(opts.outputPath) : undefined; if (!outputPath) { - const inputBasename = basename(inputPath, ".jsonl"); + const inputBasename = basename(resolvedInputPath, ".jsonl"); outputPath = `${APP_NAME}-session-${inputBasename}.html`; } diff --git a/packages/coding-agent/src/core/extensions/loader.ts b/packages/coding-agent/src/core/extensions/loader.ts index 60ad625d..6a7d8abb 100644 --- a/packages/coding-agent/src/core/extensions/loader.ts +++ b/packages/coding-agent/src/core/extensions/loader.ts @@ -5,7 +5,6 @@ import * as fs from "node:fs"; import { createRequire } from "node:module"; -import * as os from "node:os"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; 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, // avoiding a circular dependency. Extensions can import from @earendil-works/pi-coding-agent. import * as _bundledPiCodingAgent from "../../index.ts"; +import { resolvePath } from "../../utils/paths.ts"; import { createEventBus, type EventBus } from "../event-bus.ts"; import type { ExecOptions } from "../exec.ts"; import { execCommand } from "../exec.ts"; @@ -115,31 +115,6 @@ function getAliases(): Record { 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; /** @@ -396,7 +371,7 @@ async function loadExtension( eventBus: EventBus, runtime: ExtensionRuntime, ): Promise<{ extension: Extension | null; error: string | null }> { - const resolvedPath = resolvePath(extensionPath, cwd); + const resolvedPath = resolvePath(extensionPath, cwd, { normalizeUnicodeSpaces: true }); try { const factory = await loadExtensionModule(resolvedPath); @@ -426,7 +401,8 @@ export async function loadExtensionFromFactory( extensionPath = "", ): Promise { 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); return extension; } @@ -437,11 +413,12 @@ export async function loadExtensionFromFactory( export async function loadExtensions(paths: string[], cwd: string, eventBus?: EventBus): Promise { const extensions: Extension[] = []; const errors: Array<{ path: string; error: string }> = []; + const resolvedCwd = resolvePath(cwd); const resolvedEventBus = eventBus ?? createEventBus(); const runtime = createExtensionRuntime(); 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) { errors.push({ path: extPath, error }); @@ -578,6 +555,8 @@ export async function discoverAndLoadExtensions( agentDir: string = getAgentDir(), eventBus?: EventBus, ): Promise { + const resolvedCwd = resolvePath(cwd); + const resolvedAgentDir = resolvePath(agentDir); const allPaths: string[] = []; const seen = new Set(); @@ -592,16 +571,16 @@ export async function discoverAndLoadExtensions( }; // 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)); // 2. Global extensions: agentDir/extensions/ - const globalExtDir = path.join(agentDir, "extensions"); + const globalExtDir = path.join(resolvedAgentDir, "extensions"); addPaths(discoverExtensionsInDir(globalExtDir)); // 3. Explicitly configured paths 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()) { // Check for package.json with pi manifest or index.ts const entries = resolveExtensionEntries(resolved); @@ -617,5 +596,5 @@ export async function discoverAndLoadExtensions( addPaths([resolved]); } - return loadExtensions(allPaths, cwd, eventBus); + return loadExtensions(allPaths, resolvedCwd, eventBus); } diff --git a/packages/coding-agent/src/core/model-registry.ts b/packages/coding-agent/src/core/model-registry.ts index 4d040463..6e3a1031 100644 --- a/packages/coding-agent/src/core/model-registry.ts +++ b/packages/coding-agent/src/core/model-registry.ts @@ -25,6 +25,7 @@ import { type Static, Type } from "typebox"; import { Compile } from "typebox/compile"; import type { TLocalizedValidationError } from "typebox/error"; import { getAgentDir } from "../config.ts"; +import { normalizePath } from "../utils/paths.ts"; import type { AuthStatus, AuthStorage } from "./auth-storage.ts"; import { BUILT_IN_PROVIDER_DISPLAY_NAMES } from "./provider-display-names.ts"; import { @@ -339,7 +340,7 @@ export class ModelRegistry { private constructor(authStorage: AuthStorage, modelsJsonPath: string | undefined) { this.authStorage = authStorage; - this.modelsJsonPath = modelsJsonPath; + this.modelsJsonPath = modelsJsonPath ? normalizePath(modelsJsonPath) : undefined; this.loadModels(); } diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index b8c333bc..994adb56 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -30,7 +30,7 @@ import { minimatch } from "minimatch"; import { CONFIG_DIR_NAME } from "../config.ts"; import { spawnProcess, spawnProcessSync } from "../utils/child-process.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 type { PackageSource, SettingsManager } from "./settings-manager.ts"; @@ -763,8 +763,8 @@ export class DefaultPackageManager implements PackageManager { private progressCallback: ProgressCallback | undefined; constructor(options: PackageManagerOptions) { - this.cwd = options.cwd; - this.agentDir = options.agentDir; + this.cwd = resolvePath(options.cwd); + this.agentDir = resolvePath(options.agentDir); this.settingsManager = options.settingsManager; } @@ -1968,19 +1968,11 @@ export class DefaultPackageManager implements PackageManager { } private resolvePath(input: string): string { - const trimmed = input.trim(); - 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); + return resolvePath(input, this.cwd, { homeDir: getHomeDir(), trim: true }); } private resolvePathFromBase(input: string, baseDir: string): string { - const trimmed = input.trim(); - 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); + return resolvePath(input, baseDir, { homeDir: getHomeDir(), trim: true }); } private collectPackageResources( diff --git a/packages/coding-agent/src/core/prompt-templates.ts b/packages/coding-agent/src/core/prompt-templates.ts index b646896d..581a34eb 100644 --- a/packages/coding-agent/src/core/prompt-templates.ts +++ b/packages/coding-agent/src/core/prompt-templates.ts @@ -1,8 +1,8 @@ import { existsSync, readdirSync, readFileSync, statSync } from "fs"; -import { homedir } from "os"; -import { basename, dirname, isAbsolute, join, resolve, sep } from "path"; +import { basename, dirname, join, resolve, sep } from "path"; import { CONFIG_DIR_NAME } from "../config.ts"; import { parseFrontmatter } from "../utils/frontmatter.ts"; +import { resolvePath } from "../utils/paths.ts"; import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.ts"; /** @@ -185,19 +185,6 @@ export interface LoadPromptTemplatesOptions { 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: * 1. Global: agentDir/prompts/ @@ -205,14 +192,14 @@ function resolvePromptPath(p: string, cwd: string): string { * 3. Explicit prompt paths */ export function loadPromptTemplates(options: LoadPromptTemplatesOptions): PromptTemplate[] { - const resolvedCwd = options.cwd; - const resolvedAgentDir = options.agentDir; + const resolvedCwd = resolvePath(options.cwd); + const resolvedAgentDir = resolvePath(options.agentDir); const promptPaths = options.promptPaths; const includeDefaults = options.includeDefaults; 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 isUnderPath = (target: string, root: string): boolean => { @@ -252,7 +239,7 @@ export function loadPromptTemplates(options: LoadPromptTemplatesOptions): Prompt // 3. Load explicit prompt paths for (const rawPath of promptPaths) { - const resolvedPath = resolvePromptPath(rawPath, resolvedCwd); + const resolvedPath = resolvePath(rawPath, resolvedCwd, { trim: true }); if (!existsSync(resolvedPath)) { continue; } diff --git a/packages/coding-agent/src/core/resource-loader.ts b/packages/coding-agent/src/core/resource-loader.ts index 57d90f97..eb043cd5 100644 --- a/packages/coding-agent/src/core/resource-loader.ts +++ b/packages/coding-agent/src/core/resource-loader.ts @@ -1,5 +1,4 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; -import { homedir } from "node:os"; import { join, resolve, sep } from "node:path"; import chalk from "chalk"; import { CONFIG_DIR_NAME } from "../config.ts"; @@ -8,7 +7,7 @@ import type { 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 { createExtensionRuntime, loadExtensionFromFactory, loadExtensions } from "./extensions/loader.ts"; import type { Extension, ExtensionFactory, ExtensionRuntime, LoadExtensionsResult } from "./extensions/types.ts"; @@ -77,8 +76,8 @@ export function loadProjectContextFiles(options: { cwd: string; agentDir: string; }): Array<{ path: string; content: string }> { - const resolvedCwd = options.cwd; - const resolvedAgentDir = options.agentDir; + const resolvedCwd = resolvePath(options.cwd); + const resolvedAgentDir = resolvePath(options.agentDir); const contextFiles: Array<{ path: string; content: string }> = []; const seenPaths = new Set(); @@ -205,8 +204,8 @@ export class DefaultResourceLoader implements ResourceLoader { private lastThemePaths: string[]; constructor(options: DefaultResourceLoaderOptions) { - this.cwd = options.cwd; - this.agentDir = options.agentDir; + this.cwd = resolvePath(options.cwd); + this.agentDir = resolvePath(options.agentDir); this.settingsManager = options.settingsManager ?? SettingsManager.create(this.cwd, this.agentDir); this.eventBus = options.eventBus ?? createEventBus(); this.packageManager = new DefaultPackageManager({ @@ -409,8 +408,11 @@ export class DefaultResourceLoader implements ResourceLoader { } for (const p of this.additionalExtensionPaths) { - if (isLocalPath(p) && !existsSync(p)) { - extensionsResult.errors.push({ path: p, error: `Extension path does not exist: ${p}` }); + if (isLocalPath(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; @@ -423,8 +425,11 @@ export class DefaultResourceLoader implements ResourceLoader { this.lastSkillPaths = skillPaths; this.updateSkillsFromPaths(skillPaths, metadataByPath); for (const p of this.additionalSkillPaths) { - if (isLocalPath(p) && !existsSync(p) && !this.skillDiagnostics.some((d) => d.path === p)) { - this.skillDiagnostics.push({ type: "error", message: "Skill path does not exist", path: p }); + if (isLocalPath(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.updatePromptsFromPaths(promptPaths, metadataByPath); for (const p of this.additionalPromptTemplatePaths) { - if (isLocalPath(p) && !existsSync(p) && !this.promptDiagnostics.some((d) => d.path === p)) { - this.promptDiagnostics.push({ type: "error", message: "Prompt template path does not exist", path: p }); + if (isLocalPath(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.updateThemesFromPaths(themePaths, metadataByPath); for (const p of this.additionalThemePaths) { - if (!existsSync(p) && !this.themeDiagnostics.some((d) => d.path === p)) { - this.themeDiagnostics.push({ type: "error", message: "Theme path does not exist", path: p }); + const resolved = this.resolveResourcePath(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( entries: Array<{ path: string; metadata: PathMetadata }>, ): Array<{ path: string; metadata: PathMetadata }> { - return entries.map((entry) => ({ - path: this.resolveResourcePath(entry.path), - metadata: entry.metadata, - })); + return entries.map((entry) => { + const metadata = entry.metadata.baseDir + ? { ...entry.metadata, baseDir: this.resolveResourcePath(entry.metadata.baseDir) } + : entry.metadata; + return { + path: this.resolveResourcePath(entry.path), + metadata, + }; + }); } private updateSkillsFromPaths(skillPaths: string[], metadataByPath?: Map): void { @@ -674,16 +692,7 @@ export class DefaultResourceLoader implements ResourceLoader { } private resolveResourcePath(p: string): string { - const trimmed = p.trim(); - 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); + return resolvePath(p, this.cwd, { trim: true }); } private loadThemes( @@ -704,7 +713,7 @@ export class DefaultResourceLoader implements ResourceLoader { } for (const p of paths) { - const resolved = resolve(this.cwd, p); + const resolved = this.resolveResourcePath(p); if (!existsSync(resolved)) { diagnostics.push({ type: "warning", message: "theme path does not exist", path: resolved }); continue; diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 7caa0fa1..f19581e4 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -2,6 +2,7 @@ import { join } from "node:path"; 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 { getAgentDir } from "../config.ts"; +import { resolvePath } from "../utils/paths.ts"; import { AgentSession } from "./agent-session.ts"; import { formatNoModelsAvailableMessage } from "./auth-guidance.ts"; import { AuthStorage } from "./auth-storage.ts"; @@ -191,8 +192,8 @@ function getAttributionHeaders( * ``` */ export async function createAgentSession(options: CreateAgentSessionOptions = {}): Promise { - const cwd = options.cwd ?? options.sessionManager?.getCwd() ?? process.cwd(); - const agentDir = options.agentDir ?? getDefaultAgentDir(); + const cwd = resolvePath(options.cwd ?? options.sessionManager?.getCwd() ?? process.cwd()); + const agentDir = options.agentDir ? resolvePath(options.agentDir) : getDefaultAgentDir(); let resourceLoader = options.resourceLoader; // Use provided or create AuthStorage and ModelRegistry diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 533916f3..e7d12314 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -16,6 +16,7 @@ import { import { readdir, readFile, stat } from "fs/promises"; import { join, resolve } from "path"; import { getAgentDir as getDefaultAgentDir, getSessionsDir } from "../config.ts"; +import { normalizePath, resolvePath } from "../utils/paths.ts"; import { type BashExecutionMessage, type CustomMessage, @@ -425,8 +426,10 @@ export function buildSessionContext( * Encodes cwd into a safe directory name under ~/.pi/agent/sessions/. */ export function getDefaultSessionDir(cwd: string, agentDir: string = getDefaultAgentDir()): string { - const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; - const sessionDir = join(agentDir, "sessions", safePath); + const resolvedCwd = resolvePath(cwd); + const resolvedAgentDir = resolvePath(agentDir); + const safePath = `--${resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; + const sessionDir = join(resolvedAgentDir, "sessions", safePath); if (!existsSync(sessionDir)) { mkdirSync(sessionDir, { recursive: true }); } @@ -435,9 +438,10 @@ export function getDefaultSessionDir(cwd: string, agentDir: string = getDefaultA /** Exported for testing */ 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 lines = content.trim().split("\n"); @@ -478,10 +482,11 @@ function isValidSessionFile(filePath: string): boolean { /** Exported for testing */ export function findMostRecentSession(sessionDir: string): string | null { + const resolvedSessionDir = normalizePath(sessionDir); try { - const files = readdirSync(sessionDir) + const files = readdirSync(resolvedSessionDir) .filter((f) => f.endsWith(".jsonl")) - .map((f) => join(sessionDir, f)) + .map((f) => join(resolvedSessionDir, f)) .filter(isValidSessionFile) .map((path) => ({ path, mtime: statSync(path).mtime })) .sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); @@ -717,11 +722,11 @@ export class SessionManager { private leafId: string | null = null; private constructor(cwd: string, sessionDir: string, sessionFile: string | undefined, persist: boolean) { - this.cwd = cwd; - this.sessionDir = sessionDir; + this.cwd = resolvePath(cwd); + this.sessionDir = normalizePath(sessionDir); this.persist = persist; - if (persist && sessionDir && !existsSync(sessionDir)) { - mkdirSync(sessionDir, { recursive: true }); + if (persist && this.sessionDir && !existsSync(this.sessionDir)) { + mkdirSync(this.sessionDir, { recursive: true }); } if (sessionFile) { @@ -733,7 +738,7 @@ export class SessionManager { /** Switch to a different session file (used for resume and branching) */ setSessionFile(sessionFile: string): void { - this.sessionFile = resolve(sessionFile); + this.sessionFile = resolvePath(sessionFile); if (existsSync(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//). */ 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); } @@ -1315,13 +1320,14 @@ export class SessionManager { * @param cwdOverride Optional cwd override instead of the session header cwd. */ static open(path: string, sessionDir?: string, cwdOverride?: string): SessionManager { + const resolvedPath = resolvePath(path); // 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 cwd = cwdOverride ?? header?.cwd ?? process.cwd(); // If no sessionDir provided, derive from file's parent directory - const dir = sessionDir ?? resolve(path, ".."); - return new SessionManager(cwd, dir, path, true); + const dir = sessionDir ? normalizePath(sessionDir) : resolve(resolvedPath, ".."); + 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//). */ static continueRecent(cwd: string, sessionDir?: string): SessionManager { - const dir = sessionDir ?? getDefaultSessionDir(cwd); + const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd); const mostRecent = findMostRecentSession(dir); if (mostRecent) { 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. */ 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) { - 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; 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)) { mkdirSync(dir, { recursive: true }); } @@ -1378,8 +1386,8 @@ export class SessionManager { version: CURRENT_SESSION_VERSION, id: newSessionId, timestamp, - cwd: targetCwd, - parentSession: sourcePath, + cwd: resolvedTargetCwd, + parentSession: resolvedSourcePath, }; 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) */ static async list(cwd: string, sessionDir?: string, onProgress?: SessionListProgress): Promise { - const dir = sessionDir ?? getDefaultSessionDir(cwd); + const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd); const sessions = await listSessionsFromDir(dir, onProgress); sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime()); return sessions; diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 2fedad35..40f97cd3 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -1,9 +1,9 @@ import type { Transport } from "@earendil-works/pi-ai"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; -import { homedir } from "os"; import { dirname, join } from "path"; import lockfile from "proper-lockfile"; 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"; export interface CompactionSettings { @@ -161,8 +161,10 @@ export class FileSettingsStorage implements SettingsStorage { private projectSettingsPath: string; constructor(cwd: string, agentDir: string) { - this.globalSettingsPath = join(agentDir, "settings.json"); - this.projectSettingsPath = join(cwd, CONFIG_DIR_NAME, "settings.json"); + const resolvedCwd = resolvePath(cwd); + const resolvedAgentDir = resolvePath(agentDir); + this.globalSettingsPath = join(resolvedAgentDir, "settings.json"); + this.projectSettingsPath = join(resolvedCwd, CONFIG_DIR_NAME, "settings.json"); } private acquireLockSyncWithRetry(path: string): () => void { @@ -577,16 +579,7 @@ export class SettingsManager { getSessionDir(): string | undefined { const sessionDir = this.settings.sessionDir; - if (!sessionDir) { - return sessionDir; - } - if (sessionDir === "~") { - return homedir(); - } - if (sessionDir.startsWith("~/")) { - return join(homedir(), sessionDir.slice(2)); - } - return sessionDir; + return sessionDir ? normalizePath(sessionDir) : sessionDir; } getDefaultProvider(): string | undefined { diff --git a/packages/coding-agent/src/core/skills.ts b/packages/coding-agent/src/core/skills.ts index 675da3a7..c104c856 100644 --- a/packages/coding-agent/src/core/skills.ts +++ b/packages/coding-agent/src/core/skills.ts @@ -1,10 +1,9 @@ import { existsSync, readdirSync, readFileSync, statSync } from "fs"; import ignore from "ignore"; -import { homedir } from "os"; -import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "path"; +import { basename, dirname, join, relative, resolve, sep } from "path"; import { CONFIG_DIR_NAME, getAgentDir } from "../config.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 { createSyntheticSourceInfo, type SourceInfo } from "./source-info.ts"; @@ -381,28 +380,16 @@ export interface LoadSkillsOptions { 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. * Returns skills and any validation diagnostics. */ 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 - const resolvedAgentDir = agentDir ?? getAgentDir(); + const resolvedCwd = resolvePath(options.cwd); + const resolvedAgentDir = resolvePath(agentDir ?? getAgentDir()); const skillMap = new Map(); const realPathSet = new Set(); @@ -442,11 +429,11 @@ export function loadSkills(options: LoadSkillsOptions): LoadSkillsResult { if (includeDefaults) { 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 projectSkillsDir = resolve(cwd, CONFIG_DIR_NAME, "skills"); + const projectSkillsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "skills"); const isUnderPath = (target: string, root: string): boolean => { const normalizedRoot = resolve(root); @@ -466,7 +453,7 @@ export function loadSkills(options: LoadSkillsOptions): LoadSkillsResult { }; for (const rawPath of skillPaths) { - const resolvedPath = resolveSkillPath(rawPath, cwd); + const resolvedPath = resolvePath(rawPath, resolvedCwd, { trim: true }); if (!existsSync(resolvedPath)) { allDiagnostics.push({ type: "warning", message: "skill path does not exist", path: resolvedPath }); continue; diff --git a/packages/coding-agent/src/core/tools/path-utils.ts b/packages/coding-agent/src/core/tools/path-utils.ts index 0c10b52e..1ed1e2bc 100644 --- a/packages/coding-agent/src/core/tools/path-utils.ts +++ b/packages/coding-agent/src/core/tools/path-utils.ts @@ -1,12 +1,7 @@ import { accessSync, constants } from "node:fs"; -import * as os from "node:os"; -import { isAbsolute, resolve as resolvePath } from "node:path"; +import { normalizePath, resolvePath } from "../../utils/paths.ts"; -const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g; const NARROW_NO_BREAK_SPACE = "\u202F"; -function normalizeUnicodeSpaces(str: string): string { - return str.replace(UNICODE_SPACES, " "); -} function tryMacOSScreenshotPath(filePath: string): string { 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 { - const normalized = normalizeUnicodeSpaces(normalizeAtPrefix(filePath)); - if (normalized === "~") { - return os.homedir(); - } - if (normalized.startsWith("~/")) { - return os.homedir() + normalized.slice(1); - } - return normalized; + return normalizePath(filePath, { normalizeUnicodeSpaces: true, stripAtPrefix: true }); } /** @@ -52,11 +36,7 @@ export function expandPath(filePath: string): string { * Handles ~ expansion and absolute paths. */ export function resolveToCwd(filePath: string, cwd: string): string { - const expanded = expandPath(filePath); - if (isAbsolute(expanded)) { - return expanded; - } - return resolvePath(cwd, expanded); + return resolvePath(filePath, cwd, { normalizeUnicodeSpaces: true, stripAtPrefix: true }); } export function resolveReadPath(filePath: string, cwd: string): string { diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 47f53c3d..5395e045 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -5,7 +5,6 @@ * createAgentSession() options. The SDK does the heavy lifting. */ -import { resolve } from "node:path"; import { createInterface } from "node:readline"; import { type ImageContent, modelsAreEqual } from "@earendil-works/pi-ai"; 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 { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.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"; /** @@ -147,9 +146,9 @@ type ResolvedSession = * 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 { - // 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")) { - return { type: "path", path: sessionArg }; + return { type: "path", path: resolvePath(sessionArg, cwd) }; } // 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 { - return paths?.map((value) => (isLocalPath(value) ? resolve(cwd, value) : value)); + return paths?.map((value) => (isLocalPath(value) ? resolvePath(value, cwd) : value)); } async function promptForMissingSessionCwd( @@ -501,7 +500,7 @@ export async function main(args: string[], options?: MainOptions) { // sessionDir lookup during session selection. const envSessionDir = process.env[ENV_SESSION_DIR]; const sessionDir = - parsed.sessionDir ?? + (parsed.sessionDir ? normalizePath(parsed.sessionDir) : undefined) ?? (envSessionDir ? expandTildePath(envSessionDir) : undefined) ?? startupSettingsManager.getSessionDir(); let sessionManager = await createSessionManager(parsed, cwd, sessionDir, startupSettingsManager); diff --git a/packages/coding-agent/src/utils/paths.ts b/packages/coding-agent/src/utils/paths.ts index fd8d176e..7f101344 100644 --- a/packages/coding-agent/src/utils/paths.ts +++ b/packages/coding-agent/src/utils/paths.ts @@ -1,7 +1,24 @@ 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"; +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. * 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.) - * 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. */ export function isLocalPath(value: string): boolean { const trimmed = value.trim(); - // Known non-local prefixes + // Known non-local prefixes. file: URLs are local paths and are intentionally resolved by resolvePath(). if ( trimmed.startsWith("npm:") || trimmed.startsWith("git:") || @@ -37,13 +54,39 @@ export function isLocalPath(value: string): boolean { return true; } -function resolveAgainstCwd(filePath: string, cwd: string): string { - return isAbsolute(filePath) ? resolvePath(filePath) : resolvePath(cwd, filePath); +export function normalizePath(input: string, options: PathInputOptions = {}): string { + 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 { const resolvedCwd = resolvePath(cwd); - const resolvedPath = resolveAgainstCwd(filePath, resolvedCwd); + const resolvedPath = resolvePath(filePath, resolvedCwd); const relativePath = relative(resolvedCwd, resolvedPath); const isInsideCwd = relativePath === "" || @@ -53,7 +96,7 @@ export function getCwdRelativePath(filePath: string, cwd: string): string | unde } 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("/"); } diff --git a/packages/coding-agent/test/extensions-discovery.test.ts b/packages/coding-agent/test/extensions-discovery.test.ts index 3613e902..b1b1232f 100644 --- a/packages/coding-agent/test/extensions-discovery.test.ts +++ b/packages/coding-agent/test/extensions-discovery.test.ts @@ -123,6 +123,31 @@ describe("extensions discovery", () => { 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 () => { const subdir = path.join(extensionsDir, "my-package"); fs.mkdirSync(subdir); diff --git a/packages/coding-agent/test/package-manager.test.ts b/packages/coding-agent/test/package-manager.test.ts index 9f3476a8..30068da1 100644 --- a/packages/coding-agent/test/package-manager.test.ts +++ b/packages/coding-agent/test/package-manager.test.ts @@ -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 () => { const pkgDir = join(tempDir, "auto-pkg"); mkdirSync(join(pkgDir, "extensions"), { recursive: true }); diff --git a/packages/coding-agent/test/path-utils.test.ts b/packages/coding-agent/test/path-utils.test.ts index 85c2605f..77a8345f 100644 --- a/packages/coding-agent/test/path-utils.test.ts +++ b/packages/coding-agent/test/path-utils.test.ts @@ -16,6 +16,11 @@ describe("path-utils", () => { 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", () => { // Non-breaking space (U+00A0) should become regular space const withNBSP = "file\u00A0name.txt"; @@ -26,14 +31,21 @@ describe("path-utils", () => { describe("resolveToCwd", () => { it("should resolve absolute paths as-is", () => { - const result = resolveToCwd("/absolute/path/file.txt", "/some/cwd"); - expect(result).toBe("/absolute/path/file.txt"); + const absolutePath = resolve(tmpdir(), "absolute", "path", "file.txt"); + const result = resolveToCwd(absolutePath, resolve(tmpdir(), "some", "cwd")); + expect(result).toBe(absolutePath); }); it("should resolve relative paths against cwd", () => { const result = resolveToCwd("relative/file.txt", "/some/cwd"); 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", () => { diff --git a/packages/coding-agent/test/paths.test.ts b/packages/coding-agent/test/paths.test.ts index 01151635..accf9989 100644 --- a/packages/coding-agent/test/paths.test.ts +++ b/packages/coding-agent/test/paths.test.ts @@ -1,8 +1,9 @@ import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { homedir, tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; 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; @@ -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", () => { it("returns true for bare names", () => { expect(isLocalPath("my-package")).toBe(true); @@ -82,6 +132,10 @@ describe("isLocalPath", () => { expect(isLocalPath("./foo")).toBe(true); }); + it("returns true for file URLs", () => { + expect(isLocalPath("file:///tmp/foo")).toBe(true); + }); + it("returns false for npm: protocol", () => { expect(isLocalPath("npm:package")).toBe(false); }); diff --git a/packages/coding-agent/test/resource-loader.test.ts b/packages/coding-agent/test/resource-loader.test.ts index f023855a..693f05c9 100644 --- a/packages/coding-agent/test/resource-loader.test.ts +++ b/packages/coding-agent/test/resource-loader.test.ts @@ -1,6 +1,7 @@ import { mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { pathToFileURL } from "node:url"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { AuthStorage } from "../src/core/auth-storage.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?.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", () => {