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

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` |
| `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 <path>` or `-e <path>` (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

View file

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

View file

@ -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<string>;
}
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<string>): void {
const resolved = this.resolvePath(path);
if (existsSync(resolved)) {
this.addPath(target, resolved);
}
}
async resolveExtensionSources(
sources: string[],
options?: { local?: boolean; temporary?: boolean },
): Promise<ResolvedPaths> {
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<MissingSourceAction>,
): Promise<void> {
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)) {

View file

@ -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 ?? [])];
}

View file

@ -168,6 +168,7 @@ export {
export {
type CompactionSettings,
type ImageSettings,
type PackageSource,
type RetrySettings,
SettingsManager,
} 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 { 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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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;
}

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