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

@ -9,6 +9,7 @@
### Added ### Added
- Added `ExtensionAPI.getCommands()` to let extensions list available slash commands (extensions, prompt templates, skills) for invocation via `prompt` ([#1210](https://github.com/badlogic/pi-mono/pull/1210) by [@w-winter](https://github.com/w-winter)) - Added `ExtensionAPI.getCommands()` to let extensions list available slash commands (extensions, prompt templates, skills) for invocation via `prompt` ([#1210](https://github.com/badlogic/pi-mono/pull/1210) by [@w-winter](https://github.com/w-winter))
- Added local path support for `pi install` and `pi remove` with relative paths stored against the target settings file ([#1216](https://github.com/badlogic/pi-mono/issues/1216))
## [0.51.2] - 2026-02-03 ## [0.51.2] - 2026-02-03

View file

@ -23,6 +23,8 @@ Pi packages bundle extensions, skills, prompt templates, and themes so you can s
pi install npm:@foo/bar@1.0.0 pi install npm:@foo/bar@1.0.0
pi install git:github.com/user/repo@v1 pi install git:github.com/user/repo@v1
pi install https://github.com/user/repo # raw URLs work too pi install https://github.com/user/repo # raw URLs work too
pi install /absolute/path/to/package
pi install ./relative/path/to/package
pi remove npm:@foo/bar pi remove npm:@foo/bar
pi list # show installed packages from settings pi list # show installed packages from settings
@ -72,7 +74,7 @@ https://github.com/user/repo@v1
./relative/path/to/package ./relative/path/to/package
``` ```
Local paths work in settings but not with `pi install`. If the path is a file, it loads as a single extension. If it is a directory, pi loads resources using package rules. Local paths point to files or directories on disk and are added to settings without copying. Relative paths are resolved against the settings file they appear in. If the path is a file, it loads as a single extension. If it is a directory, pi loads resources using package rules.
## Creating a Pi Package ## Creating a Pi Package

View file

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

View file

@ -5,6 +5,8 @@
* createAgentSession() options. The SDK does the heavy lifting. * 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 { type ImageContent, modelsAreEqual, supportsXhigh } from "@mariozechner/pi-ai";
import chalk from "chalk"; import chalk from "chalk";
import { createInterface } from "readline"; import { createInterface } from "readline";
@ -13,7 +15,7 @@ import { selectConfig } from "./cli/config-selector.js";
import { processFileArguments } from "./cli/file-processor.js"; import { processFileArguments } from "./cli/file-processor.js";
import { listModels } from "./cli/list-models.js"; import { listModels } from "./cli/list-models.js";
import { selectSession } from "./cli/session-picker.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 { AuthStorage } from "./core/auth-storage.js";
import { DEFAULT_THINKING_LEVEL } from "./core/defaults.js"; import { DEFAULT_THINKING_LEVEL } from "./core/defaults.js";
import { exportFromFile } from "./core/export-html/index.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 }; 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 } { function normalizeExtensionSource(source: string): { type: "npm" | "git" | "local"; key: string } {
if (source.startsWith("npm:")) { if (source.startsWith("npm:")) {
const spec = source.slice("npm:".length).trim(); const spec = source.slice("npm:".length).trim();
@ -100,9 +134,25 @@ function normalizeExtensionSource(source: string): { type: "npm" | "git" | "loca
return { type: "local", key: source }; return { type: "local", key: source };
} }
function sourcesMatch(a: string, b: string): boolean { function normalizeSourceForInput(source: string, cwd: string): { type: "npm" | "git" | "local"; key: string } {
const left = normalizeExtensionSource(a); const normalized = normalizeExtensionSource(source);
const right = normalizeExtensionSource(b); 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; 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; 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); const aSource = getPackageSourceString(a);
return sourcesMatch(aSource, b); return sourcesMatch(aSource, b, baseDir, cwd);
} }
function updatePackageSources( function updatePackageSources(
settingsManager: SettingsManager, settingsManager: SettingsManager,
source: string, source: string,
local: boolean, local: boolean,
cwd: string,
agentDir: string,
action: "add" | "remove", action: "add" | "remove",
): void { ): void {
const currentSettings = local ? settingsManager.getProjectSettings() : settingsManager.getGlobalSettings(); const currentSettings = local ? settingsManager.getProjectSettings() : settingsManager.getGlobalSettings();
const currentPackages = currentSettings.packages ?? []; const currentPackages = currentSettings.packages ?? [];
const baseDir = local ? join(cwd, CONFIG_DIR_NAME) : agentDir;
const normalizedSource = normalizePackageSourceForSettings(source, baseDir, cwd);
let nextPackages: PackageSource[]; let nextPackages: PackageSource[];
if (action === "add") { if (action === "add") {
const exists = currentPackages.some((existing) => packageSourcesMatch(existing, source)); const exists = currentPackages.some((existing) => packageSourcesMatch(existing, source, baseDir, cwd));
nextPackages = exists ? currentPackages : [...currentPackages, source]; nextPackages = exists ? currentPackages : [...currentPackages, normalizedSource];
} else { } else {
nextPackages = currentPackages.filter((existing) => !packageSourcesMatch(existing, source)); nextPackages = currentPackages.filter((existing) => !packageSourcesMatch(existing, source, baseDir, cwd));
} }
if (local) { if (local) {
@ -165,7 +219,7 @@ async function handlePackageCommand(args: string[]): Promise<boolean> {
process.exit(1); process.exit(1);
} }
await packageManager.install(options.source, { local: options.local }); 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}`)); console.log(chalk.green(`Installed ${options.source}`));
return true; return true;
} }
@ -176,7 +230,7 @@ async function handlePackageCommand(args: string[]): Promise<boolean> {
process.exit(1); process.exit(1);
} }
await packageManager.remove(options.source, { local: options.local }); 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}`)); console.log(chalk.green(`Removed ${options.source}`));
return true; return true;
} }