fix(coding-agent): support local install paths relative to settings files closes #1216

This commit is contained in:
Mario Zechner 2026-02-03 12:58:34 +01:00
parent e54dff7efb
commit 2f5235b966
4 changed files with 103 additions and 20 deletions

View file

@ -617,7 +617,8 @@ export class DefaultPackageManager implements PackageManager {
return existsSync(path) ? path : undefined;
}
if (parsed.type === "local") {
const path = this.resolvePath(parsed.path);
const baseDir = this.getBaseDirForScope(scope);
const path = this.resolvePathFromBase(parsed.path, baseDir);
return existsSync(path) ? path : undefined;
}
return undefined;
@ -721,6 +722,13 @@ export class DefaultPackageManager implements PackageManager {
await this.installGit(parsed, scope);
return;
}
if (parsed.type === "local") {
const resolved = this.resolvePath(parsed.path);
if (!existsSync(resolved)) {
throw new Error(`Path does not exist: ${resolved}`);
}
return;
}
throw new Error(`Unsupported install source: ${source}`);
});
}
@ -737,6 +745,9 @@ export class DefaultPackageManager implements PackageManager {
await this.removeGit(parsed, scope);
return;
}
if (parsed.type === "local") {
return;
}
throw new Error(`Unsupported remove source: ${source}`);
});
}
@ -748,12 +759,12 @@ export class DefaultPackageManager implements PackageManager {
for (const pkg of globalSettings.packages ?? []) {
const sourceStr = typeof pkg === "string" ? pkg : pkg.source;
if (identity && this.getPackageIdentity(sourceStr) !== identity) continue;
if (identity && this.getPackageIdentity(sourceStr, "user") !== identity) continue;
await this.updateSourceForScope(sourceStr, "user");
}
for (const pkg of projectSettings.packages ?? []) {
const sourceStr = typeof pkg === "string" ? pkg : pkg.source;
if (identity && this.getPackageIdentity(sourceStr) !== identity) continue;
if (identity && this.getPackageIdentity(sourceStr, "project") !== identity) continue;
await this.updateSourceForScope(sourceStr, "project");
}
}
@ -788,7 +799,8 @@ export class DefaultPackageManager implements PackageManager {
const metadata: PathMetadata = { source: sourceStr, scope, origin: "package" };
if (parsed.type === "local") {
this.resolveLocalExtensionSource(parsed, accumulator, filter, metadata);
const baseDir = this.getBaseDirForScope(scope);
this.resolveLocalExtensionSource(parsed, accumulator, filter, metadata, baseDir);
continue;
}
@ -833,8 +845,9 @@ export class DefaultPackageManager implements PackageManager {
accumulator: ResourceAccumulator,
filter: PackageFilter | undefined,
metadata: PathMetadata,
baseDir: string,
): void {
const resolved = this.resolvePath(source.path);
const resolved = this.resolvePathFromBase(source.path, baseDir);
if (!existsSync(resolved)) {
return;
}
@ -949,7 +962,7 @@ export class DefaultPackageManager implements PackageManager {
* Get a unique identity for a package, ignoring version/ref.
* Used to detect when the same package is in both global and project settings.
*/
private getPackageIdentity(source: string): string {
private getPackageIdentity(source: string, scope?: SourceScope): string {
const parsed = this.parseSource(source);
if (parsed.type === "npm") {
return `npm:${parsed.name}`;
@ -957,7 +970,10 @@ export class DefaultPackageManager implements PackageManager {
if (parsed.type === "git") {
return `git:${parsed.repo}`;
}
// For local paths, use the absolute resolved path
if (scope) {
const baseDir = this.getBaseDirForScope(scope);
return `local:${this.resolvePathFromBase(parsed.path, baseDir)}`;
}
return `local:${this.resolvePath(parsed.path)}`;
}
@ -972,7 +988,7 @@ export class DefaultPackageManager implements PackageManager {
for (const entry of packages) {
const sourceStr = typeof entry.pkg === "string" ? entry.pkg : entry.pkg.source;
const identity = this.getPackageIdentity(sourceStr);
const identity = this.getPackageIdentity(sourceStr, entry.scope);
const existing = seen.get(identity);
if (!existing) {
@ -1171,6 +1187,16 @@ export class DefaultPackageManager implements PackageManager {
return join(tmpdir(), "pi-extensions", prefix, hash, suffix ?? "");
}
private getBaseDirForScope(scope: SourceScope): string {
if (scope === "project") {
return join(this.cwd, CONFIG_DIR_NAME);
}
if (scope === "user") {
return this.agentDir;
}
return this.cwd;
}
private resolvePath(input: string): string {
const trimmed = input.trim();
if (trimmed === "~") return homedir();

View file

@ -5,6 +5,8 @@
* createAgentSession() options. The SDK does the heavy lifting.
*/
import { homedir } from "node:os";
import { isAbsolute, join, relative, resolve } from "node:path";
import { type ImageContent, modelsAreEqual, supportsXhigh } from "@mariozechner/pi-ai";
import chalk from "chalk";
import { createInterface } from "readline";
@ -13,7 +15,7 @@ import { selectConfig } from "./cli/config-selector.js";
import { processFileArguments } from "./cli/file-processor.js";
import { listModels } from "./cli/list-models.js";
import { selectSession } from "./cli/session-picker.js";
import { getAgentDir, getModelsPath, VERSION } from "./config.js";
import { CONFIG_DIR_NAME, getAgentDir, getModelsPath, VERSION } from "./config.js";
import { AuthStorage } from "./core/auth-storage.js";
import { DEFAULT_THINKING_LEVEL } from "./core/defaults.js";
import { exportFromFile } from "./core/export-html/index.js";
@ -82,6 +84,38 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined
return { command, source: sources[0], local };
}
function expandTildePath(input: string): string {
const trimmed = input.trim();
if (trimmed === "~") return homedir();
if (trimmed.startsWith("~/")) return resolve(homedir(), trimmed.slice(2));
if (trimmed.startsWith("~")) return resolve(homedir(), trimmed.slice(1));
return trimmed;
}
function resolveLocalSourceFromInput(source: string, cwd: string): string {
const expanded = expandTildePath(source);
return isAbsolute(expanded) ? expanded : resolve(cwd, expanded);
}
function resolveLocalSourceFromSettings(source: string, baseDir: string): string {
const expanded = expandTildePath(source);
return isAbsolute(expanded) ? expanded : resolve(baseDir, expanded);
}
function normalizeLocalSourceForSettings(source: string, baseDir: string, cwd: string): string {
const resolved = resolveLocalSourceFromInput(source, cwd);
const rel = relative(baseDir, resolved);
return rel || ".";
}
function normalizePackageSourceForSettings(source: string, baseDir: string, cwd: string): string {
const normalized = normalizeExtensionSource(source);
if (normalized.type !== "local") {
return source;
}
return normalizeLocalSourceForSettings(source, baseDir, cwd);
}
function normalizeExtensionSource(source: string): { type: "npm" | "git" | "local"; key: string } {
if (source.startsWith("npm:")) {
const spec = source.slice("npm:".length).trim();
@ -100,9 +134,25 @@ function normalizeExtensionSource(source: string): { type: "npm" | "git" | "loca
return { type: "local", key: source };
}
function sourcesMatch(a: string, b: string): boolean {
const left = normalizeExtensionSource(a);
const right = normalizeExtensionSource(b);
function normalizeSourceForInput(source: string, cwd: string): { type: "npm" | "git" | "local"; key: string } {
const normalized = normalizeExtensionSource(source);
if (normalized.type !== "local") {
return normalized;
}
return { type: "local", key: resolveLocalSourceFromInput(source, cwd) };
}
function normalizeSourceForSettings(source: string, baseDir: string): { type: "npm" | "git" | "local"; key: string } {
const normalized = normalizeExtensionSource(source);
if (normalized.type !== "local") {
return normalized;
}
return { type: "local", key: resolveLocalSourceFromSettings(source, baseDir) };
}
function sourcesMatch(a: string, b: string, baseDir: string, cwd: string): boolean {
const left = normalizeSourceForSettings(a, baseDir);
const right = normalizeSourceForInput(b, cwd);
return left.type === right.type && left.key === right.key;
}
@ -110,26 +160,30 @@ function getPackageSourceString(pkg: PackageSource): string {
return typeof pkg === "string" ? pkg : pkg.source;
}
function packageSourcesMatch(a: PackageSource, b: string): boolean {
function packageSourcesMatch(a: PackageSource, b: string, baseDir: string, cwd: string): boolean {
const aSource = getPackageSourceString(a);
return sourcesMatch(aSource, b);
return sourcesMatch(aSource, b, baseDir, cwd);
}
function updatePackageSources(
settingsManager: SettingsManager,
source: string,
local: boolean,
cwd: string,
agentDir: string,
action: "add" | "remove",
): void {
const currentSettings = local ? settingsManager.getProjectSettings() : settingsManager.getGlobalSettings();
const currentPackages = currentSettings.packages ?? [];
const baseDir = local ? join(cwd, CONFIG_DIR_NAME) : agentDir;
const normalizedSource = normalizePackageSourceForSettings(source, baseDir, cwd);
let nextPackages: PackageSource[];
if (action === "add") {
const exists = currentPackages.some((existing) => packageSourcesMatch(existing, source));
nextPackages = exists ? currentPackages : [...currentPackages, source];
const exists = currentPackages.some((existing) => packageSourcesMatch(existing, source, baseDir, cwd));
nextPackages = exists ? currentPackages : [...currentPackages, normalizedSource];
} else {
nextPackages = currentPackages.filter((existing) => !packageSourcesMatch(existing, source));
nextPackages = currentPackages.filter((existing) => !packageSourcesMatch(existing, source, baseDir, cwd));
}
if (local) {
@ -165,7 +219,7 @@ async function handlePackageCommand(args: string[]): Promise<boolean> {
process.exit(1);
}
await packageManager.install(options.source, { local: options.local });
updatePackageSources(settingsManager, options.source, options.local, "add");
updatePackageSources(settingsManager, options.source, options.local, cwd, agentDir, "add");
console.log(chalk.green(`Installed ${options.source}`));
return true;
}
@ -176,7 +230,7 @@ async function handlePackageCommand(args: string[]): Promise<boolean> {
process.exit(1);
}
await packageManager.remove(options.source, { local: options.local });
updatePackageSources(settingsManager, options.source, options.local, "remove");
updatePackageSources(settingsManager, options.source, options.local, cwd, agentDir, "remove");
console.log(chalk.green(`Removed ${options.source}`));
return true;
}