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
This commit is contained in:
Mario Zechner 2026-01-23 19:51:23 +01:00
parent dd838d0fe0
commit ef1fc3103e
8 changed files with 434 additions and 63 deletions

View file

@ -5,6 +5,7 @@
### Breaking Changes ### 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)) - 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)) - 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)) - 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)) - 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 - `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)) - 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)) - `/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)) - 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 - Show provider alongside the model in the footer if multiple providers are available

View file

@ -846,7 +846,11 @@ Global `~/.pi/agent/settings.json` stores persistent preferences:
| `doubleEscapeAction` | Action for double-escape with empty editor: `tree` or `fork` | `tree` | | `doubleEscapeAction` | Action for double-escape with empty editor: `tree` or `fork` | `tree` |
| `editorPaddingX` | Horizontal padding for input editor (0-3) | `0` | | `editorPaddingX` | Horizontal padding for input editor (0-3) | `0` |
| `markdown.codeBlockIndent` | Prefix for each rendered code block line | `" "` | | `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:** **Locations:**
- Global: `~/.pi/agent/extensions/*.ts` or `~/.pi/agent/extensions/*/index.ts` - Global: `~/.pi/agent/extensions/*.ts` or `~/.pi/agent/extensions/*/index.ts`
- Project: `.pi/extensions/*.ts` or `.pi/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 <path>` or `-e <path>` (temporary for this run) - CLI: `--extension <path>` or `-e <path>` (temporary for this run)
Install and remove extension sources with the CLI: **Install packages:**
```bash ```bash
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 remove npm:@foo/bar 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`). 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/). **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 #### Custom Tools

View file

@ -115,22 +115,49 @@ Additional paths via `settings.json`:
```json ```json
{ {
"extensions": [ "packages": [
"npm:@foo/bar@1.0.0", "npm:@foo/bar@1.0.0",
"git:github.com/user/repo@v1", "git:github.com/user/repo@v1"
"/path/to/extension.ts", ],
"/path/to/extension/dir" "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 ```bash
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 https://github.com/user/repo # raw URLs work too
pi remove npm:@foo/bar 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:** **Discovery rules:**
1. **Direct files:** `extensions/*.ts` or `*.js` → loaded directly 1. **Direct files:** `extensions/*.ts` or `*.js` → loaded directly

View file

@ -4,7 +4,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync }
import { homedir, tmpdir } from "node:os"; import { homedir, tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path"; import { dirname, join, resolve } from "node:path";
import { CONFIG_DIR_NAME } from "../config.js"; 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 { export interface ResolvedPaths {
extensions: string[]; extensions: string[];
@ -81,6 +81,13 @@ interface ResourceAccumulator {
themes: Set<string>; themes: Set<string>;
} }
interface PackageFilter {
extensions?: string[];
skills?: string[];
prompts?: string[];
themes?: string[];
}
export class DefaultPackageManager implements PackageManager { export class DefaultPackageManager implements PackageManager {
private cwd: string; private cwd: string;
private agentDir: string; private agentDir: string;
@ -108,46 +115,66 @@ export class DefaultPackageManager implements PackageManager {
const globalSettings = this.settingsManager.getGlobalSettings(); const globalSettings = this.settingsManager.getGlobalSettings();
const projectSettings = this.settingsManager.getProjectSettings(); const projectSettings = this.settingsManager.getProjectSettings();
const extensionSources: Array<{ source: string; scope: SourceScope }> = []; // Resolve packages (npm/git sources)
for (const source of globalSettings.extensions ?? []) { const packageSources: Array<{ pkg: PackageSource; scope: SourceScope }> = [];
extensionSources.push({ source, scope: "global" }); for (const pkg of globalSettings.packages ?? []) {
packageSources.push({ pkg, scope: "global" });
} }
for (const source of projectSettings.extensions ?? []) { for (const pkg of projectSettings.packages ?? []) {
extensionSources.push({ source, scope: "project" }); 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 ?? []) { for (const skill of projectSettings.skills ?? []) {
this.addPath(accumulator.skills, this.resolvePath(skill)); 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 ?? []) { for (const prompt of projectSettings.prompts ?? []) {
this.addPath(accumulator.prompts, this.resolvePath(prompt)); 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 ?? []) { for (const theme of projectSettings.themes ?? []) {
this.addPath(accumulator.themes, this.resolvePath(theme)); this.addPath(accumulator.themes, this.resolvePath(theme));
} }
for (const theme of globalSettings.themes ?? []) {
this.addPath(accumulator.themes, this.resolvePath(theme));
}
return this.toResolvedPaths(accumulator); return this.toResolvedPaths(accumulator);
} }
private resolveLocalPath(path: string, target: Set<string>): void {
const resolved = this.resolvePath(path);
if (existsSync(resolved)) {
this.addPath(target, resolved);
}
}
async resolveExtensionSources( async resolveExtensionSources(
sources: string[], sources: string[],
options?: { local?: boolean; temporary?: boolean }, options?: { local?: boolean; temporary?: boolean },
): Promise<ResolvedPaths> { ): Promise<ResolvedPaths> {
const accumulator = this.createAccumulator(); const accumulator = this.createAccumulator();
const scope: SourceScope = options?.temporary ? "temporary" : options?.local ? "project" : "global"; const scope: SourceScope = options?.temporary ? "temporary" : options?.local ? "project" : "global";
const extensionSources = sources.map((source) => ({ source, scope })); const packageSources = sources.map((source) => ({ pkg: source as PackageSource, scope }));
await this.resolveExtensionSourcesInternal(extensionSources, accumulator); await this.resolvePackageSources(packageSources, accumulator);
return this.toResolvedPaths(accumulator); return this.toResolvedPaths(accumulator);
} }
@ -244,13 +271,16 @@ export class DefaultPackageManager implements PackageManager {
} }
} }
private async resolveExtensionSourcesInternal( private async resolvePackageSources(
sources: Array<{ source: string; scope: SourceScope }>, sources: Array<{ pkg: PackageSource; scope: SourceScope }>,
accumulator: ResourceAccumulator, accumulator: ResourceAccumulator,
onMissing?: (source: string) => Promise<MissingSourceAction>, onMissing?: (source: string) => Promise<MissingSourceAction>,
): Promise<void> { ): Promise<void> {
for (const { source, scope } of sources) { for (const { pkg, scope } of sources) {
const parsed = this.parseSource(source); const sourceStr = typeof pkg === "string" ? pkg : pkg.source;
const filter = typeof pkg === "object" ? pkg : undefined;
const parsed = this.parseSource(sourceStr);
if (parsed.type === "local") { if (parsed.type === "local") {
this.resolveLocalExtensionSource(parsed, accumulator); this.resolveLocalExtensionSource(parsed, accumulator);
continue; continue;
@ -261,9 +291,9 @@ export class DefaultPackageManager implements PackageManager {
await this.installParsedSource(parsed, scope); await this.installParsedSource(parsed, scope);
return true; return true;
} }
const action = await onMissing(source); const action = await onMissing(sourceStr);
if (action === "skip") return false; 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); await this.installParsedSource(parsed, scope);
return true; return true;
}; };
@ -274,7 +304,7 @@ export class DefaultPackageManager implements PackageManager {
const installed = await installMissing(); const installed = await installMissing();
if (!installed) continue; if (!installed) continue;
} }
this.collectPackageResources(installedPath, accumulator); this.collectPackageResources(installedPath, accumulator, filter);
continue; continue;
} }
@ -284,7 +314,7 @@ export class DefaultPackageManager implements PackageManager {
const installed = await installMissing(); const installed = await installMissing();
if (!installed) continue; 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); 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); const manifest = this.readPiManifest(packageRoot);
if (manifest) { if (manifest) {
this.addManifestEntries(manifest.extensions, packageRoot, accumulator.extensions); this.addManifestEntries(manifest.extensions, packageRoot, accumulator.extensions);
@ -553,6 +638,54 @@ export class DefaultPackageManager implements PackageManager {
return true; 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 { private readPiManifest(packageRoot: string): PiManifest | null {
const packageJsonPath = join(packageRoot, "package.json"); const packageJsonPath = join(packageRoot, "package.json");
if (!existsSync(packageJsonPath)) { if (!existsSync(packageJsonPath)) {

View file

@ -38,6 +38,21 @@ export interface MarkdownSettings {
codeBlockIndent?: string; // default: " " 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 { export interface Settings {
lastChangelogVersion?: string; lastChangelogVersion?: string;
defaultProvider?: string; defaultProvider?: string;
@ -54,10 +69,11 @@ export interface Settings {
quietStartup?: boolean; quietStartup?: boolean;
shellCommandPrefix?: string; // Prefix prepended to every bash command (e.g., "shopt -s expand_aliases" for alias support) 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) collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full)
extensions?: string[]; // Array of extension file paths or directories packages?: PackageSource[]; // Array of npm/git package sources (string or object with filtering)
skills?: string[]; // Array of skill file paths or directories extensions?: string[]; // Array of local extension file paths or directories
prompts?: string[]; // Array of prompt template paths or directories skills?: string[]; // Array of local skill file paths or directories
themes?: string[]; // Array of theme 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 enableSkillCommands?: boolean; // default: true - register skills as /skill:name commands
terminal?: TerminalSettings; terminal?: TerminalSettings;
images?: ImageSettings; images?: ImageSettings;
@ -156,6 +172,7 @@ export class SettingsManager {
delete settings.queueMode; delete settings.queueMode;
} }
// Migrate old skills object format to new array format
if ( if (
"skills" in settings && "skills" in settings &&
typeof settings.skills === "object" && 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; 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 { private loadProjectSettings(): Settings {
if (!this.projectSettingsPath || !existsSync(this.projectSettingsPath)) { if (!this.projectSettingsPath || !existsSync(this.projectSettingsPath)) {
return {}; return {};
@ -416,6 +463,22 @@ export class SettingsManager {
this.save(); 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[] { getExtensionPaths(): string[] {
return [...(this.settings.extensions ?? [])]; return [...(this.settings.extensions ?? [])];
} }

View file

@ -168,6 +168,7 @@ export {
export { export {
type CompactionSettings, type CompactionSettings,
type ImageSettings, type ImageSettings,
type PackageSource,
type RetrySettings, type RetrySettings,
SettingsManager, SettingsManager,
} from "./core/settings-manager.js"; } from "./core/settings-manager.js";

View file

@ -23,7 +23,7 @@ import { DefaultPackageManager } from "./core/package-manager.js";
import { DefaultResourceLoader } from "./core/resource-loader.js"; import { DefaultResourceLoader } from "./core/resource-loader.js";
import { type CreateAgentSessionOptions, createAgentSession } from "./core/sdk.js"; import { type CreateAgentSessionOptions, createAgentSession } from "./core/sdk.js";
import { SessionManager } from "./core/session-manager.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 { printTimings, time } from "./core/timings.js";
import { allTools } from "./core/tools/index.js"; import { allTools } from "./core/tools/index.js";
import { runMigrations, showDeprecationWarnings } from "./migrations.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; 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, settingsManager: SettingsManager,
source: string, source: string,
local: boolean, local: boolean,
action: "add" | "remove", action: "add" | "remove",
): void { ): void {
const currentSettings = local ? settingsManager.getProjectSettings() : settingsManager.getGlobalSettings(); const currentSettings = local ? settingsManager.getProjectSettings() : settingsManager.getGlobalSettings();
const currentSources = currentSettings.extensions ?? []; const currentPackages = currentSettings.packages ?? [];
let nextSources: string[]; let nextPackages: PackageSource[];
if (action === "add") { if (action === "add") {
const exists = currentSources.some((existing) => sourcesMatch(existing, source)); const exists = currentPackages.some((existing) => packageSourcesMatch(existing, source));
nextSources = exists ? currentSources : [...currentSources, source]; nextPackages = exists ? currentPackages : [...currentPackages, source];
} else { } else {
nextSources = currentSources.filter((existing) => !sourcesMatch(existing, source)); nextPackages = currentPackages.filter((existing) => !packageSourcesMatch(existing, source));
} }
if (local) { if (local) {
settingsManager.setProjectExtensionPaths(nextSources); settingsManager.setProjectPackages(nextPackages);
} else { } else {
settingsManager.setExtensionPaths(nextSources); settingsManager.setPackages(nextPackages);
} }
} }
@ -154,7 +163,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 });
updateExtensionSources(settingsManager, options.source, options.local, "add"); updatePackageSources(settingsManager, options.source, options.local, "add");
console.log(chalk.green(`Installed ${options.source}`)); console.log(chalk.green(`Installed ${options.source}`));
return true; return true;
} }
@ -165,7 +174,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 });
updateExtensionSources(settingsManager, options.source, options.local, "remove"); updatePackageSources(settingsManager, options.source, options.local, "remove");
console.log(chalk.green(`Removed ${options.source}`)); console.log(chalk.green(`Removed ${options.source}`));
return true; return true;
} }
@ -173,26 +182,28 @@ async function handlePackageCommand(args: string[]): Promise<boolean> {
if (options.command === "list") { if (options.command === "list") {
const globalSettings = settingsManager.getGlobalSettings(); const globalSettings = settingsManager.getGlobalSettings();
const projectSettings = settingsManager.getProjectSettings(); const projectSettings = settingsManager.getProjectSettings();
const globalExtensions = globalSettings.extensions ?? []; const globalPackages = globalSettings.packages ?? [];
const projectExtensions = projectSettings.extensions ?? []; const projectPackages = projectSettings.packages ?? [];
if (globalExtensions.length === 0 && projectExtensions.length === 0) { if (globalPackages.length === 0 && projectPackages.length === 0) {
console.log(chalk.dim("No extensions installed.")); console.log(chalk.dim("No packages installed."));
return true; return true;
} }
if (globalExtensions.length > 0) { if (globalPackages.length > 0) {
console.log(chalk.bold("Global extensions:")); console.log(chalk.bold("Global packages:"));
for (const ext of globalExtensions) { for (const pkg of globalPackages) {
console.log(` ${ext}`); const display = typeof pkg === "string" ? pkg : `${pkg.source} (filtered)`;
console.log(` ${display}`);
} }
} }
if (projectExtensions.length > 0) { if (projectPackages.length > 0) {
if (globalExtensions.length > 0) console.log(); if (globalPackages.length > 0) console.log();
console.log(chalk.bold("Project extensions:")); console.log(chalk.bold("Project packages:"));
for (const ext of projectExtensions) { for (const pkg of projectPackages) {
console.log(` ${ext}`); const display = typeof pkg === "string" ? pkg : `${pkg.source} (filtered)`;
console.log(` ${display}`);
} }
} }
@ -203,7 +214,7 @@ async function handlePackageCommand(args: string[]): Promise<boolean> {
if (options.source) { if (options.source) {
console.log(chalk.green(`Updated ${options.source}`)); console.log(chalk.green(`Updated ${options.source}`));
} else { } else {
console.log(chalk.green("Updated extensions")); console.log(chalk.green("Updated packages"));
} }
return true; return true;
} }

View file

@ -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", () => { describe("shellCommandPrefix", () => {
it("should load shellCommandPrefix from settings", () => { it("should load shellCommandPrefix from settings", () => {
const settingsPath = join(agentDir, "settings.json"); const settingsPath = join(agentDir, "settings.json");