diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e2eafdec..a306bd4c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/docs/packages.md b/packages/coding-agent/docs/packages.md index 4928c831..28062114 100644 --- a/packages/coding-agent/docs/packages.md +++ b/packages/coding-agent/docs/packages.md @@ -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 diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index 2047d027..d21d7009 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -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(); diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 35ec57d4..3aeb8606 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -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 { 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 { 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; }