mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-16 22:03:45 +00:00
fix(coding-agent): support local install paths relative to settings files closes #1216
This commit is contained in:
parent
e54dff7efb
commit
2f5235b966
4 changed files with 103 additions and 20 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue