From ef1fc3103ee869244800f301993bd4952125db78 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 23 Jan 2026 19:51:23 +0100 Subject: [PATCH] feat(coding-agent): add packages array with filtering support - Add PackageSource type for npm/git sources with optional filtering - Migrate npm:/git: sources from extensions to packages array - Add getPackages(), setPackages(), setProjectPackages() methods - Update package-manager to resolve from packages array - Support selective loading: extensions, skills, prompts, themes per package - Update pi list to show packages - Add migration tests for settings closes #645 --- packages/coding-agent/CHANGELOG.md | 2 + packages/coding-agent/README.md | 34 +++- packages/coding-agent/docs/extensions.md | 37 +++- .../coding-agent/src/core/package-manager.ts | 185 +++++++++++++++--- .../coding-agent/src/core/settings-manager.ts | 71 ++++++- packages/coding-agent/src/index.ts | 1 + packages/coding-agent/src/main.ts | 61 +++--- .../test/settings-manager.test.ts | 106 ++++++++++ 8 files changed, 434 insertions(+), 63 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 79771c7b..ef469ba9 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5,6 +5,7 @@ ### Breaking Changes - Header values in `models.json` now resolve environment variables (if a header value matches an env var name, the env var value is used). This may change behavior if a literal header value accidentally matches an env var name. ([#909](https://github.com/badlogic/pi-mono/issues/909)) +- External packages (npm/git) are now configured via `packages` array in settings.json instead of `extensions`. Existing npm:/git: entries in `extensions` are auto-migrated. ([#645](https://github.com/badlogic/pi-mono/issues/645)) - Resource loading now uses `ResourceLoader` only and settings.json uses arrays for extensions, skills, prompts, and themes ([#645](https://github.com/badlogic/pi-mono/issues/645)) - Removed `discoverAuthStorage` and `discoverModels` from the SDK. `AuthStorage` and `ModelRegistry` now default to `~/.pi/agent` paths unless you pass an `agentDir` ([#645](https://github.com/badlogic/pi-mono/issues/645)) @@ -14,6 +15,7 @@ - Header values in `models.json` now support environment variables and shell commands, matching `apiKey` resolution ([#909](https://github.com/badlogic/pi-mono/issues/909)) - `markdown.codeBlockIndent` setting to customize code block indentation in rendered output - Extension package management with `pi install`, `pi remove`, `pi update`, and `pi list` commands ([#645](https://github.com/badlogic/pi-mono/issues/645)) +- Package filtering: selectively load resources from packages using object form in `packages` array ([#645](https://github.com/badlogic/pi-mono/issues/645)) - `/reload` command to reload extensions, skills, prompts, and themes ([#645](https://github.com/badlogic/pi-mono/issues/645)) - CLI flags for `--skill`, `--prompt-template`, `--theme`, `--no-prompt-templates`, and `--no-themes` ([#645](https://github.com/badlogic/pi-mono/issues/645)) - Show provider alongside the model in the footer if multiple providers are available diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index b9e57b0d..46ede6fd 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -846,7 +846,11 @@ Global `~/.pi/agent/settings.json` stores persistent preferences: | `doubleEscapeAction` | Action for double-escape with empty editor: `tree` or `fork` | `tree` | | `editorPaddingX` | Horizontal padding for input editor (0-3) | `0` | | `markdown.codeBlockIndent` | Prefix for each rendered code block line | `" "` | -| `extensions` | Extension sources or file paths (npm:, git:, local) | `[]` | +| `packages` | External package sources (npm:, git:) with optional filtering | `[]` | +| `extensions` | Local extension file paths or directories | `[]` | +| `skills` | Local skill file paths or directories | `[]` | +| `prompts` | Local prompt template file paths or directories | `[]` | +| `themes` | Local theme file paths or directories | `[]` | --- @@ -983,19 +987,43 @@ Extensions are TypeScript modules that extend pi's behavior. **Locations:** - Global: `~/.pi/agent/extensions/*.ts` or `~/.pi/agent/extensions/*/index.ts` - Project: `.pi/extensions/*.ts` or `.pi/extensions/*/index.ts` -- Settings: `extensions` array supports file paths and `npm:` or `git:` sources +- Settings: `extensions` array for local paths, `packages` array for npm/git sources - CLI: `--extension ` or `-e ` (temporary for this run) -Install and remove extension sources with the CLI: +**Install packages:** ```bash 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 remove npm:@foo/bar +pi list # show installed packages +pi update # update all non-pinned packages ``` Use `-l` to install into project settings (`.pi/settings.json`). +**Package filtering:** By default, packages load all resources (extensions, skills, prompts, themes). To selectively load only certain resources, use the object form in settings.json: + +```json +{ + "packages": [ + "npm:simple-pkg", + { + "source": "npm:shitty-extensions", + "extensions": ["extensions/oracle.ts", "extensions/memory-mode.ts"], + "skills": ["skills/a-nach-b"], + "themes": [], + "prompts": [] + } + ] +} +``` + +- Omit a key to load all of that type +- Use empty array `[]` to load none of that type +- Paths are relative to package root + **Dependencies:** Extensions can have their own dependencies. Place a `package.json` next to the extension (or in a parent directory), run `npm install`, and imports are resolved via [jiti](https://github.com/unjs/jiti). See [examples/extensions/with-deps/](examples/extensions/with-deps/). #### Custom Tools diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 5149ef95..c37c1e69 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -115,22 +115,49 @@ Additional paths via `settings.json`: ```json { - "extensions": [ + "packages": [ "npm:@foo/bar@1.0.0", - "git:github.com/user/repo@v1", - "/path/to/extension.ts", - "/path/to/extension/dir" + "git:github.com/user/repo@v1" + ], + "extensions": [ + "/path/to/local/extension.ts", + "/path/to/local/extension/dir" ] } ``` -Use `pi install` and `pi remove` to manage extension sources in settings: +Use `pi install`, `pi remove`, `pi list`, and `pi update` to manage packages: ```bash 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 remove npm:@foo/bar +pi list # show installed packages +pi update # update all non-pinned packages ``` +**Package filtering:** By default, packages load all resources (extensions, skills, prompts, themes). To selectively load only certain resources: + +```json +{ + "packages": [ + "npm:simple-pkg", + { + "source": "npm:shitty-extensions", + "extensions": ["extensions/oracle.ts", "extensions/memory-mode.ts"], + "skills": ["skills/a-nach-b"], + "themes": [], + "prompts": [] + } + ] +} +``` + +- Omit a key to load all of that type from the package +- Use empty array `[]` to load none of that type +- Paths are relative to package root + **Discovery rules:** 1. **Direct files:** `extensions/*.ts` or `*.js` → loaded directly diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index 52af805c..446efd82 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -4,7 +4,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } import { homedir, tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { CONFIG_DIR_NAME } from "../config.js"; -import type { SettingsManager } from "./settings-manager.js"; +import type { PackageSource, SettingsManager } from "./settings-manager.js"; export interface ResolvedPaths { extensions: string[]; @@ -81,6 +81,13 @@ interface ResourceAccumulator { themes: Set; } +interface PackageFilter { + extensions?: string[]; + skills?: string[]; + prompts?: string[]; + themes?: string[]; +} + export class DefaultPackageManager implements PackageManager { private cwd: string; private agentDir: string; @@ -108,46 +115,66 @@ export class DefaultPackageManager implements PackageManager { const globalSettings = this.settingsManager.getGlobalSettings(); const projectSettings = this.settingsManager.getProjectSettings(); - const extensionSources: Array<{ source: string; scope: SourceScope }> = []; - for (const source of globalSettings.extensions ?? []) { - extensionSources.push({ source, scope: "global" }); + // Resolve packages (npm/git sources) + const packageSources: Array<{ pkg: PackageSource; scope: SourceScope }> = []; + for (const pkg of globalSettings.packages ?? []) { + packageSources.push({ pkg, scope: "global" }); } - for (const source of projectSettings.extensions ?? []) { - extensionSources.push({ source, scope: "project" }); + for (const pkg of projectSettings.packages ?? []) { + packageSources.push({ pkg, scope: "project" }); + } + await this.resolvePackageSources(packageSources, accumulator, onMissing); + + // Resolve local extensions + for (const ext of globalSettings.extensions ?? []) { + this.resolveLocalPath(ext, accumulator.extensions); + } + for (const ext of projectSettings.extensions ?? []) { + this.resolveLocalPath(ext, accumulator.extensions); } - await this.resolveExtensionSourcesInternal(extensionSources, accumulator, onMissing); - + // Resolve local skills + for (const skill of globalSettings.skills ?? []) { + this.addPath(accumulator.skills, this.resolvePath(skill)); + } for (const skill of projectSettings.skills ?? []) { this.addPath(accumulator.skills, this.resolvePath(skill)); } - for (const skill of globalSettings.skills ?? []) { - this.addPath(accumulator.skills, this.resolvePath(skill)); + + // Resolve local prompts + for (const prompt of globalSettings.prompts ?? []) { + this.addPath(accumulator.prompts, this.resolvePath(prompt)); } for (const prompt of projectSettings.prompts ?? []) { this.addPath(accumulator.prompts, this.resolvePath(prompt)); } - for (const prompt of globalSettings.prompts ?? []) { - this.addPath(accumulator.prompts, this.resolvePath(prompt)); + + // Resolve local themes + for (const theme of globalSettings.themes ?? []) { + this.addPath(accumulator.themes, this.resolvePath(theme)); } for (const theme of projectSettings.themes ?? []) { this.addPath(accumulator.themes, this.resolvePath(theme)); } - for (const theme of globalSettings.themes ?? []) { - this.addPath(accumulator.themes, this.resolvePath(theme)); - } return this.toResolvedPaths(accumulator); } + private resolveLocalPath(path: string, target: Set): void { + const resolved = this.resolvePath(path); + if (existsSync(resolved)) { + this.addPath(target, resolved); + } + } + async resolveExtensionSources( sources: string[], options?: { local?: boolean; temporary?: boolean }, ): Promise { const accumulator = this.createAccumulator(); const scope: SourceScope = options?.temporary ? "temporary" : options?.local ? "project" : "global"; - const extensionSources = sources.map((source) => ({ source, scope })); - await this.resolveExtensionSourcesInternal(extensionSources, accumulator); + const packageSources = sources.map((source) => ({ pkg: source as PackageSource, scope })); + await this.resolvePackageSources(packageSources, accumulator); return this.toResolvedPaths(accumulator); } @@ -244,13 +271,16 @@ export class DefaultPackageManager implements PackageManager { } } - private async resolveExtensionSourcesInternal( - sources: Array<{ source: string; scope: SourceScope }>, + private async resolvePackageSources( + sources: Array<{ pkg: PackageSource; scope: SourceScope }>, accumulator: ResourceAccumulator, onMissing?: (source: string) => Promise, ): Promise { - for (const { source, scope } of sources) { - const parsed = this.parseSource(source); + for (const { pkg, scope } of sources) { + const sourceStr = typeof pkg === "string" ? pkg : pkg.source; + const filter = typeof pkg === "object" ? pkg : undefined; + const parsed = this.parseSource(sourceStr); + if (parsed.type === "local") { this.resolveLocalExtensionSource(parsed, accumulator); continue; @@ -261,9 +291,9 @@ export class DefaultPackageManager implements PackageManager { await this.installParsedSource(parsed, scope); return true; } - const action = await onMissing(source); + const action = await onMissing(sourceStr); if (action === "skip") return false; - if (action === "error") throw new Error(`Missing source: ${source}`); + if (action === "error") throw new Error(`Missing source: ${sourceStr}`); await this.installParsedSource(parsed, scope); return true; }; @@ -274,7 +304,7 @@ export class DefaultPackageManager implements PackageManager { const installed = await installMissing(); if (!installed) continue; } - this.collectPackageResources(installedPath, accumulator); + this.collectPackageResources(installedPath, accumulator, filter); continue; } @@ -284,7 +314,7 @@ export class DefaultPackageManager implements PackageManager { const installed = await installMissing(); if (!installed) continue; } - this.collectPackageResources(installedPath, accumulator); + this.collectPackageResources(installedPath, accumulator, filter); } } } @@ -517,7 +547,62 @@ export class DefaultPackageManager implements PackageManager { return resolve(this.cwd, trimmed); } - private collectPackageResources(packageRoot: string, accumulator: ResourceAccumulator): boolean { + private collectPackageResources( + packageRoot: string, + accumulator: ResourceAccumulator, + filter?: PackageFilter, + ): boolean { + // If filter is provided, use it to selectively load resources + if (filter) { + // Empty array means "load none", undefined means "load all" + if (filter.extensions !== undefined) { + for (const entry of filter.extensions) { + const resolved = resolve(packageRoot, entry); + if (existsSync(resolved)) { + this.addPath(accumulator.extensions, resolved); + } + } + } else { + this.collectDefaultExtensions(packageRoot, accumulator); + } + + if (filter.skills !== undefined) { + for (const entry of filter.skills) { + const resolved = resolve(packageRoot, entry); + if (existsSync(resolved)) { + this.addPath(accumulator.skills, resolved); + } + } + } else { + this.collectDefaultSkills(packageRoot, accumulator); + } + + if (filter.prompts !== undefined) { + for (const entry of filter.prompts) { + const resolved = resolve(packageRoot, entry); + if (existsSync(resolved)) { + this.addPath(accumulator.prompts, resolved); + } + } + } else { + this.collectDefaultPrompts(packageRoot, accumulator); + } + + if (filter.themes !== undefined) { + for (const entry of filter.themes) { + const resolved = resolve(packageRoot, entry); + if (existsSync(resolved)) { + this.addPath(accumulator.themes, resolved); + } + } + } else { + this.collectDefaultThemes(packageRoot, accumulator); + } + + return true; + } + + // No filter: load everything based on manifest or directory structure const manifest = this.readPiManifest(packageRoot); if (manifest) { this.addManifestEntries(manifest.extensions, packageRoot, accumulator.extensions); @@ -553,6 +638,54 @@ export class DefaultPackageManager implements PackageManager { return true; } + private collectDefaultExtensions(packageRoot: string, accumulator: ResourceAccumulator): void { + const manifest = this.readPiManifest(packageRoot); + if (manifest?.extensions) { + this.addManifestEntries(manifest.extensions, packageRoot, accumulator.extensions); + return; + } + const extensionsDir = join(packageRoot, "extensions"); + if (existsSync(extensionsDir)) { + this.addPath(accumulator.extensions, extensionsDir); + } + } + + private collectDefaultSkills(packageRoot: string, accumulator: ResourceAccumulator): void { + const manifest = this.readPiManifest(packageRoot); + if (manifest?.skills) { + this.addManifestEntries(manifest.skills, packageRoot, accumulator.skills); + return; + } + const skillsDir = join(packageRoot, "skills"); + if (existsSync(skillsDir)) { + this.addPath(accumulator.skills, skillsDir); + } + } + + private collectDefaultPrompts(packageRoot: string, accumulator: ResourceAccumulator): void { + const manifest = this.readPiManifest(packageRoot); + if (manifest?.prompts) { + this.addManifestEntries(manifest.prompts, packageRoot, accumulator.prompts); + return; + } + const promptsDir = join(packageRoot, "prompts"); + if (existsSync(promptsDir)) { + this.addPath(accumulator.prompts, promptsDir); + } + } + + private collectDefaultThemes(packageRoot: string, accumulator: ResourceAccumulator): void { + const manifest = this.readPiManifest(packageRoot); + if (manifest?.themes) { + this.addManifestEntries(manifest.themes, packageRoot, accumulator.themes); + return; + } + const themesDir = join(packageRoot, "themes"); + if (existsSync(themesDir)) { + this.addPath(accumulator.themes, themesDir); + } + } + private readPiManifest(packageRoot: string): PiManifest | null { const packageJsonPath = join(packageRoot, "package.json"); if (!existsSync(packageJsonPath)) { diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index a3faf227..523ee0ed 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -38,6 +38,21 @@ export interface MarkdownSettings { codeBlockIndent?: string; // default: " " } +/** + * Package source for npm/git packages. + * - String form: load all resources from the package + * - Object form: filter which resources to load + */ +export type PackageSource = + | string + | { + source: string; + extensions?: string[]; + skills?: string[]; + prompts?: string[]; + themes?: string[]; + }; + export interface Settings { lastChangelogVersion?: string; defaultProvider?: string; @@ -54,10 +69,11 @@ export interface Settings { quietStartup?: boolean; shellCommandPrefix?: string; // Prefix prepended to every bash command (e.g., "shopt -s expand_aliases" for alias support) collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full) - extensions?: string[]; // Array of extension file paths or directories - skills?: string[]; // Array of skill file paths or directories - prompts?: string[]; // Array of prompt template paths or directories - themes?: string[]; // Array of theme file paths or directories + packages?: PackageSource[]; // Array of npm/git package sources (string or object with filtering) + extensions?: string[]; // Array of local extension file paths or directories + skills?: string[]; // Array of local skill file paths or directories + prompts?: string[]; // Array of local prompt template paths or directories + themes?: string[]; // Array of local theme file paths or directories enableSkillCommands?: boolean; // default: true - register skills as /skill:name commands terminal?: TerminalSettings; images?: ImageSettings; @@ -156,6 +172,7 @@ export class SettingsManager { delete settings.queueMode; } + // Migrate old skills object format to new array format if ( "skills" in settings && typeof settings.skills === "object" && @@ -176,9 +193,39 @@ export class SettingsManager { } } + // Migrate npm:/git: sources from extensions array to packages array + if (Array.isArray(settings.extensions)) { + const localExtensions: string[] = []; + const packageSources: string[] = []; + + for (const ext of settings.extensions) { + if (typeof ext !== "string") continue; + if (ext.startsWith("npm:") || ext.startsWith("git:") || SettingsManager.looksLikeGitUrl(ext)) { + packageSources.push(ext); + } else { + localExtensions.push(ext); + } + } + + if (packageSources.length > 0) { + const existingPackages = Array.isArray(settings.packages) ? settings.packages : []; + settings.packages = [...existingPackages, ...packageSources]; + settings.extensions = localExtensions.length > 0 ? localExtensions : undefined; + if (settings.extensions === undefined) { + delete settings.extensions; + } + } + } + return settings as Settings; } + private static looksLikeGitUrl(source: string): boolean { + const gitHosts = ["github.com", "gitlab.com", "bitbucket.org", "codeberg.org"]; + const normalized = source.replace(/^https?:\/\//, ""); + return gitHosts.some((host) => normalized.startsWith(`${host}/`)); + } + private loadProjectSettings(): Settings { if (!this.projectSettingsPath || !existsSync(this.projectSettingsPath)) { return {}; @@ -416,6 +463,22 @@ export class SettingsManager { this.save(); } + getPackages(): PackageSource[] { + return [...(this.settings.packages ?? [])]; + } + + setPackages(packages: PackageSource[]): void { + this.globalSettings.packages = packages; + this.save(); + } + + setProjectPackages(packages: PackageSource[]): void { + const projectSettings = this.loadProjectSettings(); + projectSettings.packages = packages; + this.saveProjectSettings(projectSettings); + this.settings = deepMergeSettings(this.globalSettings, projectSettings); + } + getExtensionPaths(): string[] { return [...(this.settings.extensions ?? [])]; } diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 66ee85b7..b278979f 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -168,6 +168,7 @@ export { export { type CompactionSettings, type ImageSettings, + type PackageSource, type RetrySettings, SettingsManager, } from "./core/settings-manager.js"; diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 74964c2b..8c4d7536 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -23,7 +23,7 @@ import { DefaultPackageManager } from "./core/package-manager.js"; import { DefaultResourceLoader } from "./core/resource-loader.js"; import { type CreateAgentSessionOptions, createAgentSession } from "./core/sdk.js"; import { SessionManager } from "./core/session-manager.js"; -import { SettingsManager } from "./core/settings-manager.js"; +import { type PackageSource, SettingsManager } from "./core/settings-manager.js"; import { printTimings, time } from "./core/timings.js"; import { allTools } from "./core/tools/index.js"; import { runMigrations, showDeprecationWarnings } from "./migrations.js"; @@ -104,27 +104,36 @@ function sourcesMatch(a: string, b: string): boolean { return left.type === right.type && left.key === right.key; } -function updateExtensionSources( +function getPackageSourceString(pkg: PackageSource): string { + return typeof pkg === "string" ? pkg : pkg.source; +} + +function packageSourcesMatch(a: PackageSource, b: string): boolean { + const aSource = getPackageSourceString(a); + return sourcesMatch(aSource, b); +} + +function updatePackageSources( settingsManager: SettingsManager, source: string, local: boolean, action: "add" | "remove", ): void { const currentSettings = local ? settingsManager.getProjectSettings() : settingsManager.getGlobalSettings(); - const currentSources = currentSettings.extensions ?? []; + const currentPackages = currentSettings.packages ?? []; - let nextSources: string[]; + let nextPackages: PackageSource[]; if (action === "add") { - const exists = currentSources.some((existing) => sourcesMatch(existing, source)); - nextSources = exists ? currentSources : [...currentSources, source]; + const exists = currentPackages.some((existing) => packageSourcesMatch(existing, source)); + nextPackages = exists ? currentPackages : [...currentPackages, source]; } else { - nextSources = currentSources.filter((existing) => !sourcesMatch(existing, source)); + nextPackages = currentPackages.filter((existing) => !packageSourcesMatch(existing, source)); } if (local) { - settingsManager.setProjectExtensionPaths(nextSources); + settingsManager.setProjectPackages(nextPackages); } else { - settingsManager.setExtensionPaths(nextSources); + settingsManager.setPackages(nextPackages); } } @@ -154,7 +163,7 @@ async function handlePackageCommand(args: string[]): Promise { process.exit(1); } await packageManager.install(options.source, { local: options.local }); - updateExtensionSources(settingsManager, options.source, options.local, "add"); + updatePackageSources(settingsManager, options.source, options.local, "add"); console.log(chalk.green(`Installed ${options.source}`)); return true; } @@ -165,7 +174,7 @@ async function handlePackageCommand(args: string[]): Promise { process.exit(1); } await packageManager.remove(options.source, { local: options.local }); - updateExtensionSources(settingsManager, options.source, options.local, "remove"); + updatePackageSources(settingsManager, options.source, options.local, "remove"); console.log(chalk.green(`Removed ${options.source}`)); return true; } @@ -173,26 +182,28 @@ async function handlePackageCommand(args: string[]): Promise { if (options.command === "list") { const globalSettings = settingsManager.getGlobalSettings(); const projectSettings = settingsManager.getProjectSettings(); - const globalExtensions = globalSettings.extensions ?? []; - const projectExtensions = projectSettings.extensions ?? []; + const globalPackages = globalSettings.packages ?? []; + const projectPackages = projectSettings.packages ?? []; - if (globalExtensions.length === 0 && projectExtensions.length === 0) { - console.log(chalk.dim("No extensions installed.")); + if (globalPackages.length === 0 && projectPackages.length === 0) { + console.log(chalk.dim("No packages installed.")); return true; } - if (globalExtensions.length > 0) { - console.log(chalk.bold("Global extensions:")); - for (const ext of globalExtensions) { - console.log(` ${ext}`); + if (globalPackages.length > 0) { + console.log(chalk.bold("Global packages:")); + for (const pkg of globalPackages) { + const display = typeof pkg === "string" ? pkg : `${pkg.source} (filtered)`; + console.log(` ${display}`); } } - if (projectExtensions.length > 0) { - if (globalExtensions.length > 0) console.log(); - console.log(chalk.bold("Project extensions:")); - for (const ext of projectExtensions) { - console.log(` ${ext}`); + if (projectPackages.length > 0) { + if (globalPackages.length > 0) console.log(); + console.log(chalk.bold("Project packages:")); + for (const pkg of projectPackages) { + const display = typeof pkg === "string" ? pkg : `${pkg.source} (filtered)`; + console.log(` ${display}`); } } @@ -203,7 +214,7 @@ async function handlePackageCommand(args: string[]): Promise { if (options.source) { console.log(chalk.green(`Updated ${options.source}`)); } else { - console.log(chalk.green("Updated extensions")); + console.log(chalk.green("Updated packages")); } return true; } diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index 1598f5a2..f2684e03 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -106,6 +106,112 @@ describe("SettingsManager", () => { }); }); + describe("packages migration", () => { + it("should migrate npm: sources from extensions to packages", () => { + const settingsPath = join(agentDir, "settings.json"); + writeFileSync( + settingsPath, + JSON.stringify({ + extensions: ["npm:pi-doom", "/local/ext.ts", "npm:shitty-extensions"], + }), + ); + + const manager = SettingsManager.create(projectDir, agentDir); + + expect(manager.getPackages()).toEqual(["npm:pi-doom", "npm:shitty-extensions"]); + expect(manager.getExtensionPaths()).toEqual(["/local/ext.ts"]); + }); + + it("should migrate git: sources from extensions to packages", () => { + const settingsPath = join(agentDir, "settings.json"); + writeFileSync( + settingsPath, + JSON.stringify({ + extensions: ["git:github.com/user/repo", "/local/ext.ts"], + }), + ); + + const manager = SettingsManager.create(projectDir, agentDir); + + expect(manager.getPackages()).toEqual(["git:github.com/user/repo"]); + expect(manager.getExtensionPaths()).toEqual(["/local/ext.ts"]); + }); + + it("should migrate raw github URLs from extensions to packages", () => { + const settingsPath = join(agentDir, "settings.json"); + writeFileSync( + settingsPath, + JSON.stringify({ + extensions: ["https://github.com/user/repo", "/local/ext.ts"], + }), + ); + + const manager = SettingsManager.create(projectDir, agentDir); + + expect(manager.getPackages()).toEqual(["https://github.com/user/repo"]); + expect(manager.getExtensionPaths()).toEqual(["/local/ext.ts"]); + }); + + it("should keep local-only extensions in extensions array", () => { + const settingsPath = join(agentDir, "settings.json"); + writeFileSync( + settingsPath, + JSON.stringify({ + extensions: ["/local/ext.ts", "./relative/ext.ts"], + }), + ); + + const manager = SettingsManager.create(projectDir, agentDir); + + expect(manager.getPackages()).toEqual([]); + expect(manager.getExtensionPaths()).toEqual(["/local/ext.ts", "./relative/ext.ts"]); + }); + + it("should preserve existing packages when migrating", () => { + const settingsPath = join(agentDir, "settings.json"); + writeFileSync( + settingsPath, + JSON.stringify({ + packages: ["npm:existing-pkg"], + extensions: ["npm:new-pkg", "/local/ext.ts"], + }), + ); + + const manager = SettingsManager.create(projectDir, agentDir); + + expect(manager.getPackages()).toEqual(["npm:existing-pkg", "npm:new-pkg"]); + expect(manager.getExtensionPaths()).toEqual(["/local/ext.ts"]); + }); + + it("should handle packages with filtering objects", () => { + const settingsPath = join(agentDir, "settings.json"); + writeFileSync( + settingsPath, + JSON.stringify({ + packages: [ + "npm:simple-pkg", + { + source: "npm:shitty-extensions", + extensions: ["extensions/oracle.ts"], + skills: [], + }, + ], + }), + ); + + const manager = SettingsManager.create(projectDir, agentDir); + + const packages = manager.getPackages(); + expect(packages).toHaveLength(2); + expect(packages[0]).toBe("npm:simple-pkg"); + expect(packages[1]).toEqual({ + source: "npm:shitty-extensions", + extensions: ["extensions/oracle.ts"], + skills: [], + }); + }); + }); + describe("shellCommandPrefix", () => { it("should load shellCommandPrefix from settings", () => { const settingsPath = join(agentDir, "settings.json");