mirror of
https://github.com/earendil-works/pi.git
synced 2026-06-18 15:54:04 +08:00
chore: merge main into async-file-tools
This commit is contained in:
@@ -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/";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<AgentSessionServices> {
|
||||
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"));
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string> {
|
||||
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`;
|
||||
}
|
||||
|
||||
|
||||
@@ -605,9 +605,12 @@
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
return String(text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string, string> {
|
||||
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>;
|
||||
|
||||
/**
|
||||
@@ -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 = "<inline>",
|
||||
): Promise<Extension> {
|
||||
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<LoadExtensionsResult> {
|
||||
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<LoadExtensionsResult> {
|
||||
const resolvedCwd = resolvePath(cwd);
|
||||
const resolvedAgentDir = resolvePath(agentDir);
|
||||
const allPaths: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -778,9 +778,21 @@ export class DefaultPackageManager implements PackageManager {
|
||||
scope === "project" ? this.settingsManager.getProjectSettings() : this.settingsManager.getGlobalSettings();
|
||||
const currentPackages = currentSettings.packages ?? [];
|
||||
const normalizedSource = this.normalizePackageSourceForSettings(source, scope);
|
||||
const exists = currentPackages.some((existing) => this.packageSourcesMatch(existing, source, scope));
|
||||
if (exists) {
|
||||
return false;
|
||||
const matchIndex = currentPackages.findIndex((existing) => this.packageSourcesMatch(existing, source, scope));
|
||||
if (matchIndex !== -1) {
|
||||
const existing = currentPackages[matchIndex];
|
||||
if (this.getPackageSourceString(existing) === normalizedSource) {
|
||||
return false;
|
||||
}
|
||||
const nextPackages = [...currentPackages];
|
||||
nextPackages[matchIndex] =
|
||||
typeof existing === "string" ? normalizedSource : { ...existing, source: normalizedSource };
|
||||
if (scope === "project") {
|
||||
this.settingsManager.setProjectPackages(nextPackages);
|
||||
} else {
|
||||
this.settingsManager.setPackages(nextPackages);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const nextPackages = [...currentPackages, normalizedSource];
|
||||
if (scope === "project") {
|
||||
@@ -1723,6 +1735,12 @@ export class DefaultPackageManager implements PackageManager {
|
||||
private async installGit(source: GitSource, scope: SourceScope): Promise<void> {
|
||||
const targetDir = this.getGitInstallPath(source, scope);
|
||||
if (existsSync(targetDir)) {
|
||||
if (source.ref) {
|
||||
await this.ensureGitRef(targetDir, ["fetch", "origin", source.ref], "FETCH_HEAD");
|
||||
return;
|
||||
}
|
||||
const target = await this.getLocalGitUpdateTarget(targetDir);
|
||||
await this.ensureGitRef(targetDir, target.fetchArgs, target.ref);
|
||||
return;
|
||||
}
|
||||
const gitRoot = this.getGitInstallRoot(scope);
|
||||
@@ -1749,23 +1767,26 @@ export class DefaultPackageManager implements PackageManager {
|
||||
}
|
||||
|
||||
const target = await this.getLocalGitUpdateTarget(targetDir);
|
||||
await this.ensureGitRef(targetDir, target.fetchArgs, target.ref);
|
||||
}
|
||||
|
||||
private async ensureGitRef(targetDir: string, fetchArgs: string[], ref: string): Promise<void> {
|
||||
// Fetch only the ref we will reset to, avoiding unrelated branch/tag noise.
|
||||
await this.runCommand("git", target.fetchArgs, { cwd: targetDir });
|
||||
await this.runCommand("git", fetchArgs, { cwd: targetDir });
|
||||
|
||||
const localHead = await this.runCommandCapture("git", ["rev-parse", "HEAD"], {
|
||||
cwd: targetDir,
|
||||
timeoutMs: NETWORK_TIMEOUT_MS,
|
||||
});
|
||||
const refreshedTargetHead = await this.runCommandCapture("git", ["rev-parse", target.ref], {
|
||||
const targetHead = await this.runCommandCapture("git", ["rev-parse", ref], {
|
||||
cwd: targetDir,
|
||||
timeoutMs: NETWORK_TIMEOUT_MS,
|
||||
});
|
||||
if (localHead.trim() === refreshedTargetHead.trim()) {
|
||||
if (localHead.trim() === targetHead.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.runCommand("git", ["reset", "--hard", target.ref], { cwd: targetDir });
|
||||
await this.runCommand("git", ["reset", "--hard", ref], { cwd: targetDir });
|
||||
|
||||
// Clean untracked files (extensions should be pristine)
|
||||
await this.runCommand("git", ["clean", "-fdx"], { cwd: targetDir });
|
||||
@@ -1947,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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string>();
|
||||
@@ -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<string, PathMetadata>): 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;
|
||||
|
||||
@@ -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<CreateAgentSessionResult> {
|
||||
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
|
||||
|
||||
@@ -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/<encoded-cwd>/).
|
||||
*/
|
||||
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/<encoded-cwd>/).
|
||||
*/
|
||||
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<SessionInfo[]> {
|
||||
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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, Skill>();
|
||||
const realPathSet = new Set<string>();
|
||||
@@ -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;
|
||||
|
||||
@@ -209,7 +209,15 @@ function rebuildBashResultRenderComponent(
|
||||
const state = component.state;
|
||||
component.clear();
|
||||
|
||||
const output = getTextOutput(result as any, showImages).trim();
|
||||
let output = getTextOutput(result as any, showImages).trim();
|
||||
const truncation = result.details?.truncation;
|
||||
const fullOutputPath = result.details?.fullOutputPath;
|
||||
if (!options.isPartial && truncation?.truncated && fullOutputPath && output.endsWith("]")) {
|
||||
const footerStart = output.lastIndexOf("\n\n[");
|
||||
if (footerStart !== -1 && output.slice(footerStart).includes(fullOutputPath)) {
|
||||
output = output.slice(0, footerStart).trimEnd();
|
||||
}
|
||||
}
|
||||
|
||||
if (output) {
|
||||
const styledOutput = output
|
||||
@@ -245,8 +253,6 @@ function rebuildBashResultRenderComponent(
|
||||
}
|
||||
}
|
||||
|
||||
const truncation = result.details?.truncation;
|
||||
const fullOutputPath = result.details?.fullOutputPath;
|
||||
if (truncation?.truncated || fullOutputPath) {
|
||||
const warnings: string[] = [];
|
||||
if (fullOutputPath) {
|
||||
|
||||
@@ -259,8 +259,16 @@ export function applyEditsToNormalizedContent(
|
||||
return { baseContent, newContent };
|
||||
}
|
||||
|
||||
/** Generate a standard unified patch. */
|
||||
export function generateUnifiedPatch(path: string, oldContent: string, newContent: string, contextLines = 4): string {
|
||||
return Diff.createTwoFilesPatch(path, path, oldContent, newContent, undefined, undefined, {
|
||||
context: contextLines,
|
||||
headerOptions: Diff.FILE_HEADERS_ONLY,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unified diff string with line numbers and context.
|
||||
* Generate a display-oriented diff string with line numbers and context.
|
||||
* Returns both the diff string and the first changed line number (in the new file).
|
||||
*/
|
||||
export function generateDiffString(
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type EditDiffError,
|
||||
type EditDiffResult,
|
||||
generateDiffString,
|
||||
generateUnifiedPatch,
|
||||
normalizeToLF,
|
||||
restoreLineEndings,
|
||||
stripBom,
|
||||
@@ -57,8 +58,10 @@ type LegacyEditToolInput = EditToolInput & {
|
||||
};
|
||||
|
||||
export interface EditToolDetails {
|
||||
/** Unified diff of the changes made */
|
||||
/** Display-oriented diff of the changes made */
|
||||
diff: string;
|
||||
/** Standard unified patch of the changes made */
|
||||
patch: string;
|
||||
/** Line number of the first change in the new file (for editor navigation) */
|
||||
firstChangedLine?: number;
|
||||
}
|
||||
@@ -353,6 +356,7 @@ export function createEditToolDefinition(
|
||||
throwIfAborted();
|
||||
|
||||
const diffResult = generateDiffString(baseContent, newContent);
|
||||
const patch = generateUnifiedPatch(path, baseContent, newContent);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
@@ -360,7 +364,7 @@ export function createEditToolDefinition(
|
||||
text: `Successfully replaced ${edits.length} block(s) in ${path}.`,
|
||||
},
|
||||
],
|
||||
details: { diff: diffResult.diff, firstChangedLine: diffResult.firstChangedLine },
|
||||
details: { diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine },
|
||||
};
|
||||
} finally {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
|
||||
@@ -45,8 +45,10 @@ export class OutputAccumulator {
|
||||
private tailStartsAtLineBoundary = true;
|
||||
private totalRawBytes = 0;
|
||||
private totalDecodedBytes = 0;
|
||||
private totalLines = 1;
|
||||
private completedLines = 0;
|
||||
private totalLines = 0;
|
||||
private currentLineBytes = 0;
|
||||
private hasOpenLine = false;
|
||||
private finished = false;
|
||||
|
||||
private tempFilePath: string | undefined;
|
||||
@@ -164,10 +166,14 @@ export class OutputAccumulator {
|
||||
}
|
||||
if (newlines === 0) {
|
||||
this.currentLineBytes += bytes;
|
||||
this.hasOpenLine = true;
|
||||
} else {
|
||||
this.totalLines += newlines;
|
||||
this.currentLineBytes = byteLength(text.slice(lastNewline + 1));
|
||||
this.completedLines += newlines;
|
||||
const tail = text.slice(lastNewline + 1);
|
||||
this.currentLineBytes = byteLength(tail);
|
||||
this.hasOpenLine = tail.length > 0;
|
||||
}
|
||||
this.totalLines = this.completedLines + (this.hasOpenLine ? 1 : 0);
|
||||
}
|
||||
|
||||
private trimTail(): void {
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import { accessSync, constants } from "node:fs";
|
||||
import { access } from "node:fs/promises";
|
||||
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.`);
|
||||
@@ -42,19 +37,8 @@ async function fileExistsAsync(filePath: string): Promise<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 });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,11 +46,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 {
|
||||
|
||||
@@ -44,6 +44,17 @@ export interface TruncationOptions {
|
||||
maxBytes?: number;
|
||||
}
|
||||
|
||||
function splitLinesForCounting(content: string): string[] {
|
||||
if (content.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const lines = content.split("\n");
|
||||
if (content.endsWith("\n")) {
|
||||
lines.pop();
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes as human-readable size.
|
||||
*/
|
||||
@@ -69,7 +80,7 @@ export function truncateHead(content: string, options: TruncationOptions = {}):
|
||||
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
||||
|
||||
const totalBytes = Buffer.byteLength(content, "utf-8");
|
||||
const lines = content.split("\n");
|
||||
const lines = splitLinesForCounting(content);
|
||||
const totalLines = lines.length;
|
||||
|
||||
// Check if no truncation needed
|
||||
@@ -159,7 +170,7 @@ export function truncateTail(content: string, options: TruncationOptions = {}):
|
||||
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
||||
|
||||
const totalBytes = Buffer.byteLength(content, "utf-8");
|
||||
const lines = content.split("\n");
|
||||
const lines = splitLinesForCounting(content);
|
||||
const totalLines = lines.length;
|
||||
|
||||
// Check if no truncation needed
|
||||
|
||||
@@ -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<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")) {
|
||||
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);
|
||||
|
||||
@@ -567,7 +567,7 @@ class ResourceList implements Component, Focusable {
|
||||
|
||||
private getResourcePattern(item: ResourceItem): string {
|
||||
const scope = item.metadata.scope as "user" | "project";
|
||||
const baseDir = this.getTopLevelBaseDir(scope);
|
||||
const baseDir = item.metadata.baseDir ?? this.getTopLevelBaseDir(scope);
|
||||
return relative(baseDir, item.path);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getOAuthProviders } from "@earendil-works/pi-ai/oauth";
|
||||
import { getOAuthProviders, type OAuthDeviceCodeInfo } from "@earendil-works/pi-ai/oauth";
|
||||
import { Container, type Focusable, getKeybindings, Input, Spacer, Text, type TUI } from "@earendil-works/pi-tui";
|
||||
import { exec } from "child_process";
|
||||
import { theme } from "../theme/theme.ts";
|
||||
@@ -86,7 +86,7 @@ export class LoginDialogComponent extends Container implements Focusable {
|
||||
/**
|
||||
* Called by onAuth callback - show URL and optional instructions
|
||||
*/
|
||||
showAuth(url: string, instructions?: string): void {
|
||||
showAuth(url: string, instructions?: string, options: { autoOpenBrowser?: boolean } = {}): void {
|
||||
this.contentContainer.clear();
|
||||
this.contentContainer.addChild(new Spacer(1));
|
||||
const linkedUrl = `\x1b]8;;${url}\x07${url}\x1b]8;;\x07`;
|
||||
@@ -101,13 +101,40 @@ export class LoginDialogComponent extends Container implements Focusable {
|
||||
this.contentContainer.addChild(new Text(theme.fg("warning", instructions), 1, 0));
|
||||
}
|
||||
|
||||
// Try to open browser
|
||||
const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
||||
exec(`${openCmd} "${url}"`);
|
||||
|
||||
if (options.autoOpenBrowser ?? true) {
|
||||
this.openUrl(url);
|
||||
}
|
||||
this.tui.requestRender();
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by onDeviceCode callback - show URL and user code.
|
||||
*/
|
||||
showDeviceCode(info: OAuthDeviceCodeInfo): void {
|
||||
this.contentContainer.clear();
|
||||
this.contentContainer.addChild(new Spacer(1));
|
||||
const linkedUrl = `\x1b]8;;${info.verificationUri}\x07${info.verificationUri}\x1b]8;;\x07`;
|
||||
this.contentContainer.addChild(new Text(theme.fg("accent", linkedUrl), 1, 0));
|
||||
|
||||
const clickHint = process.platform === "darwin" ? "Cmd+click to open" : "Ctrl+click to open";
|
||||
const hyperlink = `\x1b]8;;${info.verificationUri}\x07${clickHint}\x1b]8;;\x07`;
|
||||
this.contentContainer.addChild(new Text(theme.fg("dim", hyperlink), 1, 0));
|
||||
this.contentContainer.addChild(new Spacer(1));
|
||||
this.contentContainer.addChild(new Text(theme.fg("warning", `Enter code: ${info.userCode}`), 1, 0));
|
||||
|
||||
this.openUrl(info.verificationUri);
|
||||
this.tui.requestRender();
|
||||
}
|
||||
|
||||
private openUrl(url: string): void {
|
||||
const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
||||
try {
|
||||
exec(`${openCmd} "${url}"`, () => {});
|
||||
} catch {
|
||||
// Ignore browser launch failures. The URL remains visible for manual opening/copying.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show input for manual code/URL entry (for callback server providers)
|
||||
*/
|
||||
|
||||
@@ -4823,13 +4823,15 @@ export class InteractiveMode {
|
||||
manualCodeReject = undefined;
|
||||
}
|
||||
});
|
||||
} else if (providerId === "github-copilot") {
|
||||
// GitHub Copilot polls after onAuth
|
||||
dialog.showWaiting("Waiting for browser authentication...");
|
||||
}
|
||||
// For Anthropic: onPrompt is called immediately after
|
||||
},
|
||||
|
||||
onDeviceCode: (info) => {
|
||||
dialog.showDeviceCode(info);
|
||||
dialog.showWaiting("Waiting for authentication...");
|
||||
},
|
||||
|
||||
onPrompt: async (prompt: { message: string; placeholder?: string }) => {
|
||||
return dialog.showPrompt(prompt.message, prompt.placeholder);
|
||||
},
|
||||
|
||||
@@ -440,20 +440,7 @@ function getBuiltinThemes(): Record<string, ThemeJson> {
|
||||
}
|
||||
|
||||
export function getAvailableThemes(): string[] {
|
||||
const themes = new Set<string>(Object.keys(getBuiltinThemes()));
|
||||
const customThemesDir = getCustomThemesDir();
|
||||
if (fs.existsSync(customThemesDir)) {
|
||||
const files = fs.readdirSync(customThemesDir);
|
||||
for (const file of files) {
|
||||
if (file.endsWith(".json")) {
|
||||
themes.add(file.slice(0, -5));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const name of registeredThemes.keys()) {
|
||||
themes.add(name);
|
||||
}
|
||||
return Array.from(themes).sort();
|
||||
return getAvailableThemesWithPaths().map(({ name }) => name);
|
||||
}
|
||||
|
||||
export interface ThemeInfo {
|
||||
@@ -463,35 +450,58 @@ export interface ThemeInfo {
|
||||
|
||||
export function getAvailableThemesWithPaths(): ThemeInfo[] {
|
||||
const themesDir = getThemesDir();
|
||||
const customThemesDir = getCustomThemesDir();
|
||||
const result: ThemeInfo[] = [];
|
||||
const seen = new Set<string>();
|
||||
const addTheme = (themeInfo: ThemeInfo) => {
|
||||
if (seen.has(themeInfo.name)) {
|
||||
return;
|
||||
}
|
||||
seen.add(themeInfo.name);
|
||||
result.push(themeInfo);
|
||||
};
|
||||
|
||||
// Built-in themes
|
||||
for (const name of Object.keys(getBuiltinThemes())) {
|
||||
result.push({ name, path: path.join(themesDir, `${name}.json`) });
|
||||
addTheme({ name, path: path.join(themesDir, `${name}.json`) });
|
||||
}
|
||||
|
||||
// Custom themes
|
||||
if (fs.existsSync(customThemesDir)) {
|
||||
for (const file of fs.readdirSync(customThemesDir)) {
|
||||
if (file.endsWith(".json")) {
|
||||
const name = file.slice(0, -5);
|
||||
if (!result.some((t) => t.name === name)) {
|
||||
result.push({ name, path: path.join(customThemesDir, file) });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const themeInfo of getCustomThemeInfos()) {
|
||||
addTheme(themeInfo);
|
||||
}
|
||||
|
||||
for (const [name, theme] of registeredThemes.entries()) {
|
||||
if (!result.some((t) => t.name === name)) {
|
||||
result.push({ name, path: theme.sourcePath });
|
||||
}
|
||||
addTheme({ name, path: theme.sourcePath });
|
||||
}
|
||||
|
||||
return result.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
function getCustomThemeInfos(): ThemeInfo[] {
|
||||
const customThemesDir = getCustomThemesDir();
|
||||
const result: ThemeInfo[] = [];
|
||||
if (!fs.existsSync(customThemesDir)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
for (const file of fs.readdirSync(customThemesDir)) {
|
||||
if (!file.endsWith(".json")) {
|
||||
continue;
|
||||
}
|
||||
const themePath = path.join(customThemesDir, file);
|
||||
try {
|
||||
const customTheme = loadThemeFromPath(themePath);
|
||||
if (customTheme.name) {
|
||||
result.push({ name: customTheme.name, path: themePath });
|
||||
}
|
||||
} catch {
|
||||
// Invalid themes are ignored here; the resource loader reports them
|
||||
// during normal startup/reload.
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseThemeJson(label: string, json: unknown): ThemeJson {
|
||||
if (!validateThemeJson.Check(json)) {
|
||||
const errors = Array.from(validateThemeJson.Errors(json));
|
||||
|
||||
@@ -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("/");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user