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 `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

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 git:github.com/user/repo@v1
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 list # show installed packages from settings
@ -72,7 +74,7 @@ https://github.com/user/repo@v1
./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

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;
}