mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
refactor(coding-agent): resolve built-in extensions as builtin:<name> paths (#10159)
Built-in extensions (mcp, llama.cpp, codemode, tool-search) are now extension resources like files: the package manager resolves them as builtin:<name>, the extensions setting disables them with -builtin:<name> (per project too), pi config lists them, and --no-extensions turns them off unless loaded with -e builtin:<name>. They load after project trust is resolved and after file and package extensions. Built-in extensions and tools are named builtin:<name> in errors, diagnostics, RPC source info, and bug reports.
This commit is contained in:
parent
1d74741e17
commit
4259686d92
25 changed files with 387 additions and 76 deletions
|
|
@ -12,11 +12,14 @@
|
|||
- Added the `provider_stream_event` extension event for observing parsed provider events before normalization, with an opt-in `/debug-provider` example viewer ([#9784](https://github.com/earendil-works/pi/issues/9784)).
|
||||
- Added a show/hide toggle (`H`) in HTML exports for custom messages marked `display: false`. Messages remain hidden by default and can also be revealed from the sidebar ([#8896](https://github.com/earendil-works/pi/issues/8896)).
|
||||
- Added inherited Claude Sonnet 5.5 support for Anthropic with adaptive thinking and a 1M context window.
|
||||
- Added a Built-in section in `pi config` to disable the built-in `mcp`, `llama.cpp`, `codemode`, and `tool-search` extensions globally or per project, stored as `-builtin:<name>` in the `extensions` setting. SDK inline extensions opt in with `builtin: true`.
|
||||
|
||||
### Changed
|
||||
|
||||
- Switched the build from the TypeScript native preview to TypeScript 7.0 with an ES2024 target, and replaced `tsx` with Node's built-in type stripping for running from source ([#9965](https://github.com/earendil-works/pi/issues/9965)).
|
||||
- Removed the `[Themes]` section from the startup banner. Custom themes remain available in `/settings`, and theme conflicts are still reported.
|
||||
- Built-in extensions and tools are named `builtin:<name>` (for example `builtin:mcp` and `builtin:read`) in errors, diagnostics, RPC source info, and bug reports, instead of `<inline:name>` and `<builtin:name>`. Their slash commands no longer carry a `[t]` autocomplete tag.
|
||||
- `--no-extensions` also disables the built-in extensions, including the llama.cpp provider. Load one explicitly with `-e builtin:<name>`, for example `pi -ne -e builtin:mcp`.
|
||||
|
||||
### Fixed
|
||||
|
||||
|
|
|
|||
|
|
@ -170,9 +170,9 @@ pi --extension ./review.ts
|
|||
See [Configuration](configuration.md) for conventional directories and project trust, [Settings](settings.md#resources) for configured paths, and [Pi Packages](packages.md) for package sources.
|
||||
|
||||
- `-e`, `--extension <path>`<br>
|
||||
Loads an extension file or directory and is repeatable.
|
||||
Loads an extension file or directory, or a built-in extension such as `builtin:mcp`, and is repeatable.
|
||||
- `-ne`, `--no-extensions`<br>
|
||||
Disables discovered and configured extensions. Explicit `-e` paths still load.
|
||||
Disables discovered, configured, and built-in extensions. Explicit `-e` paths still load, so `pi -ne -e builtin:mcp` keeps only the built-in MCP support.
|
||||
- `--skill <path>`<br>
|
||||
Loads a skill file or directory and is repeatable.
|
||||
- `-ns`, `--no-skills`<br>
|
||||
|
|
|
|||
|
|
@ -110,3 +110,5 @@ curl http://127.0.0.1:8080/models
|
|||
- **Model missing from `/model` with `--no-models-autoload`:** Load it with `/llama` first.
|
||||
- **Load fails or uses too much memory:** Lower `-c` or unload another model.
|
||||
- **Server is not in router mode:** Start it without `--model`, `-m`, or `-hf`.
|
||||
|
||||
To remove the `llama.cpp` provider and `/llama`, disable `llama.cpp` under Built-in in `pi config`, or set `"extensions": ["-builtin:llama.cpp"]` in [settings](settings.md#resources).
|
||||
|
|
|
|||
|
|
@ -181,7 +181,7 @@ Extensions can add servers for the current session with `pi.registerMcpServer(na
|
|||
|
||||
## Other MCP extensions
|
||||
|
||||
An installed extension that registers the `/mcp` command, such as `pi-mcp-adapter`, replaces the built-in MCP support: pi then neither reads `mcp.json` in sessions nor connects servers, and `/mcp` belongs to that extension. Remove the extension to use the built-in support. Likewise, an extension that registers a tool named `codemode` or `tool_search` replaces the built-in tool of that name. `pi mcp` shell commands always use the built-in support.
|
||||
An installed extension that registers the `/mcp` command, such as `pi-mcp-adapter`, replaces the built-in MCP support: pi then neither reads `mcp.json` in sessions nor connects servers, and `/mcp` belongs to that extension. Remove the extension to use the built-in support. To turn off the built-in support without installing another extension, disable `mcp` under Built-in in `pi config`, or set `"extensions": ["-builtin:mcp"]` in [settings](settings.md#resources); `pi mcp` shell commands still work. Likewise, an extension that registers a tool named `codemode` or `tool_search` replaces the built-in tool of that name. `pi mcp` shell commands always use the built-in support.
|
||||
|
||||
## SDK
|
||||
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ For each resource type:
|
|||
|
||||
Filters narrow the package manifest. They do not expose resources that the package itself did not declare.
|
||||
|
||||
Run `pi config` to enable or disable discovered resources. It starts with personal configuration; press Tab to switch scope, or run `pi config --local` to start with project overrides.
|
||||
Run `pi config` to enable or disable discovered resources and pi's built-in extensions. It starts with personal configuration; press Tab to switch scope, or run `pi config --local` to start with project overrides.
|
||||
|
||||
## Understand scope and identity
|
||||
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ Use `DefaultResourceLoader` when you want standard discovery with selected overr
|
|||
|
||||
<a id="inlineextension"></a>
|
||||
|
||||
Inline extension factories can be supplied through `DefaultResourceLoader`. Give one an `InlineExtension` name only when it needs a stable name in diagnostics and startup output. A named inline extension with `replaceable: true` is left out when another extension registers a tool, command, or flag with a name it registers during loading, instead of both loading with a conflict. The CLI's built-in codemode, tool search, and MCP extensions are replaceable.
|
||||
Inline extension factories can be supplied through `DefaultResourceLoader`. Give one an `InlineExtension` name only when it needs a stable name in diagnostics and startup output. A named inline extension with `replaceable: true` is left out when another extension registers a tool, command, or flag with a name it registers during loading, instead of both loading with a conflict. The CLI's built-in codemode, tool search, and MCP extensions are replaceable. A named entry with `builtin: true` is not an inline extension: it supplies the code of the `builtin:<name>` extension, which loads like a configured extension file. It loads by default, is listed in `pi config`, and is disabled by `-builtin:<name>` in the `extensions` setting or by `noExtensions`; `additionalExtensionPaths: ["builtin:<name>"]` loads it explicitly. It loads after project trust is resolved, so it cannot handle `project_trust`. The CLI's built-in extensions use it.
|
||||
|
||||
<a id="codemode-mcp"></a>
|
||||
|
||||
|
|
|
|||
|
|
@ -143,6 +143,8 @@ Resource paths in user settings resolve from the agent directory. Paths in proje
|
|||
|
||||
Resource arrays support glob exclusions with `!pattern`, exact inclusion with `+path`, and exact exclusion with `-path`. Pi loads resources listed in both user-level and project settings.
|
||||
|
||||
The built-in extensions are named `builtin:mcp`, `builtin:llama.cpp`, `builtin:codemode`, and `builtin:tool-search` in `extensions`. They load by default; `-builtin:mcp` disables one. A `+builtin:<name>` or `-builtin:<name>` entry in project settings overrides the user setting. `pi config` lists them under Built-in. `--no-extensions` disables them too, and `-e builtin:<name>` loads one explicitly.
|
||||
|
||||
## Updates, telemetry, and warnings
|
||||
|
||||
| Setting | Type | Default | Description |
|
||||
|
|
|
|||
|
|
@ -310,8 +310,8 @@ ${chalk.bold("Options:")}
|
|||
--exclude-tools, -xt <tools> Comma-separated denylist of tool names to disable
|
||||
Applies to built-in, extension, and custom tools
|
||||
--thinking <level> Set thinking level: off, minimal, low, medium, high, xhigh, max
|
||||
--extension, -e <path> Load an extension file (can be used multiple times)
|
||||
--no-extensions, -ne Disable extension discovery (explicit -e paths still work)
|
||||
--extension, -e <path> Load an extension file or builtin:<name> (can be used multiple times)
|
||||
--no-extensions, -ne Disable extension discovery and built-in extensions (explicit -e paths still work)
|
||||
--skill <path> Load a skill file or directory (can be used multiple times)
|
||||
--no-skills, -ns Disable skills discovery and loading
|
||||
--prompt-template <path> Load a prompt template file or directory (can be used multiple times)
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ import {
|
|||
} from "./session-manager.ts";
|
||||
import type { CacheWarmingMode, SettingsManager } from "./settings-manager.ts";
|
||||
import type { SlashCommandInfo } from "./slash-commands.ts";
|
||||
import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.ts";
|
||||
import { BUILTIN_PATH_PREFIX, createSyntheticSourceInfo, isSyntheticPath, type SourceInfo } from "./source-info.ts";
|
||||
import {
|
||||
buildSystemPrompt,
|
||||
buildSystemPromptSections,
|
||||
|
|
@ -3222,7 +3222,7 @@ export class AgentSession {
|
|||
}> {
|
||||
return entries.map((entry) => {
|
||||
const source = this.getExtensionSourceLabel(entry.extensionPath);
|
||||
const baseDir = entry.extensionPath.startsWith("<") ? undefined : dirname(entry.extensionPath);
|
||||
const baseDir = isSyntheticPath(entry.extensionPath) ? undefined : dirname(entry.extensionPath);
|
||||
return {
|
||||
path: entry.path,
|
||||
metadata: {
|
||||
|
|
@ -3236,7 +3236,7 @@ export class AgentSession {
|
|||
}
|
||||
|
||||
private getExtensionSourceLabel(extensionPath: string): string {
|
||||
if (extensionPath.startsWith("<")) {
|
||||
if (isSyntheticPath(extensionPath)) {
|
||||
return `extension:${extensionPath.replace(/[<>]/g, "")}`;
|
||||
}
|
||||
const base = basename(extensionPath);
|
||||
|
|
@ -3430,7 +3430,7 @@ export class AgentSession {
|
|||
name,
|
||||
{
|
||||
definition,
|
||||
sourceInfo: createSyntheticSourceInfo(`<builtin:${name}>`, { source: "builtin" }),
|
||||
sourceInfo: createSyntheticSourceInfo(`${BUILTIN_PATH_PREFIX}${name}`, { source: "builtin" }),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
|
@ -3464,7 +3464,9 @@ export class AgentSession {
|
|||
.filter((definition) => isAllowedTool(definition.name))
|
||||
.map((definition) => ({
|
||||
definition,
|
||||
sourceInfo: createSyntheticSourceInfo(`<builtin:${definition.name}>`, { source: "builtin" }),
|
||||
sourceInfo: createSyntheticSourceInfo(`${BUILTIN_PATH_PREFIX}${definition.name}`, {
|
||||
source: "builtin",
|
||||
}),
|
||||
})),
|
||||
runner,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|||
import { dirname, join } from "node:path";
|
||||
import { getAgentDir, VERSION } from "../config.ts";
|
||||
import type { Extension } from "./extensions/types.ts";
|
||||
import { isSyntheticPath } from "./source-info.ts";
|
||||
|
||||
export interface CrashRecord {
|
||||
timestamp: string;
|
||||
|
|
@ -51,7 +52,7 @@ function normalizeStackPath(value: string): string {
|
|||
|
||||
function stackContainsPath(stack: string, targetPath: string, includeDescendants: boolean): boolean {
|
||||
const target = normalizeStackPath(targetPath);
|
||||
if (!target || target.startsWith("<")) return false;
|
||||
if (!target || isSyntheticPath(target)) return false;
|
||||
const caseInsensitive = /^[a-z]:\//iu.test(target);
|
||||
const haystack = caseInsensitive ? stack.toLowerCase() : stack;
|
||||
const needle = caseInsensitive ? target.toLowerCase() : target;
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import type { ExecOptions } from "../exec.ts";
|
|||
import { execCommand } from "../exec.ts";
|
||||
import { type McpServerConfig, McpServerRegistry, validateMcpServerConfig } from "../mcp-servers.ts";
|
||||
import { readPiManifest } from "../pi-manifest.ts";
|
||||
import { createSyntheticSourceInfo } from "../source-info.ts";
|
||||
import { createSyntheticSourceInfo, getSyntheticPathSource, isSyntheticPath } from "../source-info.ts";
|
||||
import { time } from "../timings.ts";
|
||||
import type { ModelRouteRequest, VirtualModelDefinition } from "../virtual-models.ts";
|
||||
import type {
|
||||
|
|
@ -573,11 +573,8 @@ async function loadExtensionModule(extensionPath: string, cacheToken?: Extension
|
|||
* Create an Extension object with empty collections.
|
||||
*/
|
||||
function createExtension(extensionPath: string, resolvedPath: string): Extension {
|
||||
const source =
|
||||
extensionPath.startsWith("<") && extensionPath.endsWith(">")
|
||||
? extensionPath.slice(1, -1).split(":")[0] || "temporary"
|
||||
: "local";
|
||||
const baseDir = extensionPath.startsWith("<") ? undefined : path.dirname(resolvedPath);
|
||||
const source = getSyntheticPathSource(extensionPath) ?? "local";
|
||||
const baseDir = isSyntheticPath(extensionPath) ? undefined : path.dirname(resolvedPath);
|
||||
|
||||
return {
|
||||
path: extensionPath,
|
||||
|
|
|
|||
|
|
@ -1987,7 +1987,10 @@ export type ExtensionFactory = (pi: ExtensionAPI) => void | Promise<void>;
|
|||
export type InlineExtension =
|
||||
| ExtensionFactory
|
||||
| {
|
||||
/** Display name shown as `<inline:name>` in the startup Extensions list. */
|
||||
/**
|
||||
* Display name shown as `<inline:name>` in the startup Extensions list and errors. With
|
||||
* `builtin`, the extension is named `builtin:name` in errors and diagnostics.
|
||||
*/
|
||||
name: string;
|
||||
factory: ExtensionFactory;
|
||||
/** Omit this extension from the startup Extensions list. */
|
||||
|
|
@ -2000,6 +2003,15 @@ export type InlineExtension =
|
|||
* tools, commands, flags, and event handlers.
|
||||
*/
|
||||
replaceable?: boolean;
|
||||
/**
|
||||
* Supply the code of the `builtin:<name>` extension instead of loading as an inline extension.
|
||||
* `builtin:<name>` is an extension resource like a file: it loads by default, `pi config` lists
|
||||
* it, `-builtin:<name>` in the `extensions` setting and `--no-extensions` disable it, and
|
||||
* `-e builtin:<name>` loads it explicitly. It is hidden from the startup Extensions list and
|
||||
* loads after project trust is resolved, so it cannot handle `project_trust`. The CLI's built-in
|
||||
* extensions use it.
|
||||
*/
|
||||
builtin?: boolean;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ import { stripBom } from "../utils/text.ts";
|
|||
import { isStdoutTakenOver } from "./output-guard.ts";
|
||||
import { type PiManifest, readPiManifest } from "./pi-manifest.ts";
|
||||
import type { PackageSource, SettingsManager } from "./settings-manager.ts";
|
||||
import { BUILTIN_PATH_PREFIX } from "./source-info.ts";
|
||||
|
||||
const NETWORK_TIMEOUT_MS = 10000;
|
||||
const UPDATE_CHECK_CONCURRENCY = 4;
|
||||
|
|
@ -132,6 +133,8 @@ interface PackageManagerOptions {
|
|||
cwd: string;
|
||||
agentDir: string;
|
||||
settingsManager: SettingsManager;
|
||||
/** Names of built-in extensions, resolved as `builtin:<name>` extension resources. */
|
||||
builtinExtensions?: string[];
|
||||
}
|
||||
|
||||
type SourceScope = "user" | "project" | "temporary";
|
||||
|
|
@ -187,6 +190,8 @@ interface ResourceAccumulator {
|
|||
* 4 package resource (origin: "package")
|
||||
*/
|
||||
function resourcePrecedenceRank(m: PathMetadata): number {
|
||||
// Built-in extensions load after file and package extensions, whichever scope enables them.
|
||||
if (m.source === "builtin") return 5;
|
||||
if (m.origin === "package") return 4;
|
||||
const scopeBase = m.scope === "project" ? 0 : 2;
|
||||
return scopeBase + (m.source === "local" ? 0 : 1);
|
||||
|
|
@ -808,6 +813,7 @@ export class DefaultPackageManager implements PackageManager {
|
|||
private cwd: string;
|
||||
private agentDir: string;
|
||||
private settingsManager: SettingsManager;
|
||||
private builtinExtensions: string[];
|
||||
private globalNpmRoot: string | undefined;
|
||||
private globalNpmRootCommandKey: string | undefined;
|
||||
private progressCallback: ProgressCallback | undefined;
|
||||
|
|
@ -816,6 +822,7 @@ export class DefaultPackageManager implements PackageManager {
|
|||
this.cwd = resolvePath(options.cwd);
|
||||
this.agentDir = resolvePath(options.agentDir);
|
||||
this.settingsManager = options.settingsManager;
|
||||
this.builtinExtensions = options.builtinExtensions ?? [];
|
||||
}
|
||||
|
||||
setProgressCallback(callback: ProgressCallback | undefined): void {
|
||||
|
|
@ -961,6 +968,23 @@ export class DefaultPackageManager implements PackageManager {
|
|||
|
||||
this.addAutoDiscoveredResources(accumulator, globalSettings, projectSettings, globalBaseDir, projectBaseDir);
|
||||
|
||||
// Built-in extensions are enabled unless the user `extensions` setting excludes them, for example
|
||||
// with `-builtin:mcp`. A matching `+`, `-`, or `!` entry in the project setting overrides that.
|
||||
for (const name of this.builtinExtensions) {
|
||||
const path = `${BUILTIN_PATH_PREFIX}${name}`;
|
||||
const projectEnabled = applyAutoloadDisabledPatterns(
|
||||
[path],
|
||||
getOverridePatterns(projectSettings.extensions ?? []),
|
||||
projectBaseDir,
|
||||
).get(path);
|
||||
this.addResource(
|
||||
accumulator.extensions,
|
||||
path,
|
||||
{ source: "builtin", scope: projectEnabled === undefined ? "user" : "project", origin: "top-level" },
|
||||
projectEnabled ?? isEnabledByOverrides(path, globalSettings.extensions ?? [], globalBaseDir),
|
||||
);
|
||||
}
|
||||
|
||||
return this.toResolvedPaths(accumulator);
|
||||
}
|
||||
|
||||
|
|
@ -970,7 +994,13 @@ export class DefaultPackageManager implements PackageManager {
|
|||
): Promise<ResolvedPaths> {
|
||||
const accumulator = this.createAccumulator();
|
||||
const scope: SourceScope = options?.temporary ? "temporary" : options?.local ? "project" : "user";
|
||||
const packageSources = sources.map((source) => ({ pkg: source as PackageSource, scope }));
|
||||
// `-e builtin:<name>` loads a built-in extension; the resource loader reports unknown names.
|
||||
for (const source of sources.filter((source) => source.startsWith(BUILTIN_PATH_PREFIX))) {
|
||||
this.addResource(accumulator.extensions, source, { source: "builtin", scope, origin: "top-level" }, true);
|
||||
}
|
||||
const packageSources = sources
|
||||
.filter((source) => !source.startsWith(BUILTIN_PATH_PREFIX))
|
||||
.map((source) => ({ pkg: source as PackageSource, scope }));
|
||||
await this.resolvePackageSources(packageSources, accumulator);
|
||||
return this.toResolvedPaths(accumulator);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,13 @@ import {
|
|||
loadExtensionFromFactory,
|
||||
loadExtensionsCached,
|
||||
} from "./extensions/loader.ts";
|
||||
import type { Extension, ExtensionRuntime, InlineExtension, LoadExtensionsResult } from "./extensions/types.ts";
|
||||
import type {
|
||||
Extension,
|
||||
ExtensionFactory,
|
||||
ExtensionRuntime,
|
||||
InlineExtension,
|
||||
LoadExtensionsResult,
|
||||
} from "./extensions/types.ts";
|
||||
import { findGitPaths } from "./footer-data-provider.ts";
|
||||
import { DefaultPackageManager, type PathMetadata, type ResolvedResource } from "./package-manager.ts";
|
||||
import type { PromptTemplate } from "./prompt-templates.ts";
|
||||
|
|
@ -25,7 +31,13 @@ import { loadPromptTemplates } from "./prompt-templates.ts";
|
|||
import { SettingsManager } from "./settings-manager.ts";
|
||||
import type { Skill } from "./skills.ts";
|
||||
import { loadSkills } from "./skills.ts";
|
||||
import { createSourceInfo, type SourceInfo } from "./source-info.ts";
|
||||
import {
|
||||
BUILTIN_PATH_PREFIX,
|
||||
createSourceInfo,
|
||||
getSyntheticPathSource,
|
||||
isSyntheticPath,
|
||||
type SourceInfo,
|
||||
} from "./source-info.ts";
|
||||
import { resetTimings } from "./timings.ts";
|
||||
|
||||
export interface ResourceExtensionPaths {
|
||||
|
|
@ -93,6 +105,13 @@ function mergeExtensionWarnings(
|
|||
];
|
||||
}
|
||||
|
||||
type BuiltinExtension = Exclude<InlineExtension, ExtensionFactory>;
|
||||
|
||||
/** Built-in extensions supply the code of `builtin:<name>` extension paths (see `InlineExtension`). */
|
||||
export function isBuiltinExtension(input: InlineExtension): input is BuiltinExtension {
|
||||
return typeof input !== "function" && input.builtin === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave out replaceable extensions (see `InlineExtension`) that share a tool, command, or flag name
|
||||
* with another extension. For example, a third-party MCP extension that registers `/mcp` replaces
|
||||
|
|
@ -275,6 +294,7 @@ export class DefaultResourceLoader implements ResourceLoader {
|
|||
private additionalPromptTemplatePaths: string[];
|
||||
private additionalThemePaths: string[];
|
||||
private extensionFactories: InlineExtension[];
|
||||
private builtinExtensions: Map<string, BuiltinExtension>;
|
||||
private noExtensions: boolean;
|
||||
private noSkills: boolean;
|
||||
private noPromptTemplates: boolean;
|
||||
|
|
@ -327,16 +347,19 @@ export class DefaultResourceLoader implements ResourceLoader {
|
|||
this.agentDir = resolvePath(options.agentDir);
|
||||
this.settingsManager = options.settingsManager ?? SettingsManager.create(this.cwd, this.agentDir);
|
||||
this.eventBus = options.eventBus ?? createEventBus();
|
||||
const factories = options.extensionFactories ?? [];
|
||||
this.extensionFactories = factories.filter((input) => !isBuiltinExtension(input));
|
||||
this.builtinExtensions = new Map(factories.filter(isBuiltinExtension).map((input) => [input.name, input]));
|
||||
this.packageManager = new DefaultPackageManager({
|
||||
cwd: this.cwd,
|
||||
agentDir: this.agentDir,
|
||||
settingsManager: this.settingsManager,
|
||||
builtinExtensions: [...this.builtinExtensions.keys()],
|
||||
});
|
||||
this.additionalExtensionPaths = options.additionalExtensionPaths ?? [];
|
||||
this.additionalSkillPaths = options.additionalSkillPaths ?? [];
|
||||
this.additionalPromptTemplatePaths = options.additionalPromptTemplatePaths ?? [];
|
||||
this.additionalThemePaths = options.additionalThemePaths ?? [];
|
||||
this.extensionFactories = options.extensionFactories ?? [];
|
||||
this.noExtensions = options.noExtensions ?? false;
|
||||
this.noSkills = options.noSkills ?? false;
|
||||
this.noPromptTemplates = options.noPromptTemplates ?? false;
|
||||
|
|
@ -626,9 +649,11 @@ export class DefaultResourceLoader implements ResourceLoader {
|
|||
});
|
||||
const enabledExtensions = resolvedPaths.extensions.filter((r) => r.enabled).map((r) => r.path);
|
||||
const cliEnabledExtensions = cliExtensionPaths.extensions.filter((r) => r.enabled).map((r) => r.path);
|
||||
const extensionPaths = this.noExtensions
|
||||
? cliEnabledExtensions
|
||||
: this.mergePaths(cliEnabledExtensions, enabledExtensions);
|
||||
// Built-in extensions wait for the final pass: project settings can disable them, and a loaded
|
||||
// extension cannot be unloaded.
|
||||
const extensionPaths = (
|
||||
this.noExtensions ? cliEnabledExtensions : this.mergePaths(cliEnabledExtensions, enabledExtensions)
|
||||
).filter((path) => !path.startsWith(BUILTIN_PATH_PREFIX));
|
||||
const metadataByPath = new Map(
|
||||
[...resolvedPaths.extensions, ...cliExtensionPaths.extensions].map((resource) => [
|
||||
resource.path,
|
||||
|
|
@ -650,61 +675,79 @@ export class DefaultResourceLoader implements ResourceLoader {
|
|||
}
|
||||
|
||||
private resolveExtensionLoadPath(path: string): string {
|
||||
return resolvePath(path, this.cwd, { normalizeUnicodeSpaces: true });
|
||||
return isSyntheticPath(path) ? path : resolvePath(path, this.cwd, { normalizeUnicodeSpaces: true });
|
||||
}
|
||||
|
||||
/** Load extension paths: files from disk and `builtin:<name>` paths from the built-in extensions. */
|
||||
private async loadExtensionPaths(paths: string[], runtime?: ExtensionRuntime): Promise<LoadExtensionsResult> {
|
||||
const isBuiltinPath = (path: string) => path.startsWith(BUILTIN_PATH_PREFIX);
|
||||
const result = await loadExtensionsCached(
|
||||
paths.filter((path) => !isBuiltinPath(path)),
|
||||
this.cwd,
|
||||
this.eventBus,
|
||||
runtime,
|
||||
);
|
||||
for (const path of paths.filter(isBuiltinPath)) {
|
||||
const builtin = this.builtinExtensions.get(path.slice(BUILTIN_PATH_PREFIX.length));
|
||||
if (!builtin) {
|
||||
result.errors.push({ path, error: `Unknown built-in extension: ${path}` });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const extension = await loadExtensionFromFactory(
|
||||
builtin.factory,
|
||||
this.cwd,
|
||||
this.eventBus,
|
||||
result.runtime,
|
||||
path,
|
||||
);
|
||||
extension.hidden = true;
|
||||
extension.replaceable = builtin.replaceable === true;
|
||||
result.extensions.push(extension);
|
||||
} catch (error) {
|
||||
result.errors.push({ path, error: error instanceof Error ? error.message : "failed to load extension" });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private async loadFinalExtensionSet(
|
||||
extensionPaths: string[],
|
||||
preTrustExtensions: LoadExtensionsResult | undefined,
|
||||
): Promise<LoadExtensionsResult> {
|
||||
if (!preTrustExtensions) {
|
||||
const extensionsResult = await loadExtensionsCached(extensionPaths, this.cwd, this.eventBus);
|
||||
const inlineExtensions = await this.loadExtensionFactories(extensionsResult.runtime);
|
||||
extensionsResult.extensions = omitReplacedExtensions([
|
||||
...extensionsResult.extensions,
|
||||
...inlineExtensions.extensions,
|
||||
]);
|
||||
extensionsResult.errors.push(...inlineExtensions.errors);
|
||||
this.addExtensionConflictDiagnostics(extensionsResult);
|
||||
return extensionsResult;
|
||||
}
|
||||
|
||||
// Without a pre-trust pass nothing is preloaded, and inline extensions load here.
|
||||
const preloaded = preTrustExtensions?.extensions ?? [];
|
||||
const preloadedByPath = new Map(
|
||||
preTrustExtensions.extensions
|
||||
preloaded
|
||||
.filter((extension) => !extension.path.startsWith("<inline:"))
|
||||
.map((extension) => [extension.resolvedPath, extension]),
|
||||
);
|
||||
const failedPreloadPaths = new Set(
|
||||
preTrustExtensions.errors.map((error) => this.resolveExtensionLoadPath(error.path)),
|
||||
(preTrustExtensions?.errors ?? []).map((error) => this.resolveExtensionLoadPath(error.path)),
|
||||
);
|
||||
const remainingPaths = extensionPaths.filter((path) => {
|
||||
const resolvedPath = this.resolveExtensionLoadPath(path);
|
||||
return !preloadedByPath.has(resolvedPath) && !failedPreloadPaths.has(resolvedPath);
|
||||
});
|
||||
const remainingExtensions = await loadExtensionsCached(
|
||||
remainingPaths,
|
||||
this.cwd,
|
||||
this.eventBus,
|
||||
preTrustExtensions.runtime,
|
||||
);
|
||||
const remainingExtensions = await this.loadExtensionPaths(remainingPaths, preTrustExtensions?.runtime);
|
||||
const loadedByPath = new Map(preloadedByPath);
|
||||
for (const extension of remainingExtensions.extensions) {
|
||||
loadedByPath.set(extension.resolvedPath, extension);
|
||||
}
|
||||
|
||||
const inlineExtensions = preTrustExtensions.extensions.filter((extension) =>
|
||||
extension.path.startsWith("<inline:"),
|
||||
);
|
||||
const inlineExtensions = preTrustExtensions
|
||||
? { extensions: preloaded.filter((extension) => extension.path.startsWith("<inline:")), errors: [] }
|
||||
: await this.loadExtensionFactories(remainingExtensions.runtime);
|
||||
const orderedExtensions = extensionPaths
|
||||
.map((path) => loadedByPath.get(this.resolveExtensionLoadPath(path)))
|
||||
.filter((extension): extension is Extension => extension !== undefined);
|
||||
orderedExtensions.push(...inlineExtensions);
|
||||
orderedExtensions.push(...inlineExtensions.extensions);
|
||||
|
||||
const extensionsResult: LoadExtensionsResult = {
|
||||
extensions: omitReplacedExtensions(orderedExtensions),
|
||||
errors: [...preTrustExtensions.errors, ...remainingExtensions.errors],
|
||||
warnings: [...(preTrustExtensions.warnings ?? []), ...(remainingExtensions.warnings ?? [])],
|
||||
runtime: preTrustExtensions.runtime,
|
||||
errors: [...(preTrustExtensions?.errors ?? []), ...remainingExtensions.errors, ...inlineExtensions.errors],
|
||||
warnings: [...(preTrustExtensions?.warnings ?? []), ...(remainingExtensions.warnings ?? [])],
|
||||
runtime: remainingExtensions.runtime,
|
||||
};
|
||||
this.addExtensionConflictDiagnostics(extensionsResult);
|
||||
return extensionsResult;
|
||||
|
|
@ -856,7 +899,7 @@ export class DefaultResourceLoader implements ResourceLoader {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
if (resourcePath.startsWith("<")) {
|
||||
if (isSyntheticPath(resourcePath)) {
|
||||
return this.getDefaultSourceInfoForPath(resourcePath);
|
||||
}
|
||||
|
||||
|
|
@ -894,10 +937,11 @@ export class DefaultResourceLoader implements ResourceLoader {
|
|||
}
|
||||
|
||||
private getDefaultSourceInfoForPath(filePath: string): SourceInfo {
|
||||
if (filePath.startsWith("<") && filePath.endsWith(">")) {
|
||||
const syntheticSource = getSyntheticPathSource(filePath);
|
||||
if (syntheticSource) {
|
||||
return {
|
||||
path: filePath,
|
||||
source: filePath.slice(1, -1).split(":")[0] || "temporary",
|
||||
source: syntheticSource,
|
||||
scope: "temporary",
|
||||
origin: "top-level",
|
||||
};
|
||||
|
|
@ -954,7 +998,7 @@ export class DefaultResourceLoader implements ResourceLoader {
|
|||
}
|
||||
|
||||
private resolveResourcePath(p: string): string {
|
||||
return resolvePath(p, this.cwd, { trim: true });
|
||||
return isSyntheticPath(p) ? p : resolvePath(p, this.cwd, { trim: true });
|
||||
}
|
||||
|
||||
private loadThemes(
|
||||
|
|
|
|||
|
|
@ -11,6 +11,23 @@ export interface SourceInfo {
|
|||
baseDir?: string;
|
||||
}
|
||||
|
||||
/** Prefix of built-in tool and extension paths, such as `builtin:read` or `builtin:mcp`. */
|
||||
export const BUILTIN_PATH_PREFIX = "builtin:";
|
||||
|
||||
/**
|
||||
* Source of a path that names no file: `builtin` for `builtin:<name>`, or the prefix of an
|
||||
* angle-bracket path such as `inline` for `<inline:name>`. Undefined for file paths.
|
||||
*/
|
||||
export function getSyntheticPathSource(path: string): string | undefined {
|
||||
if (path.startsWith(BUILTIN_PATH_PREFIX)) return "builtin";
|
||||
if (path.startsWith("<") && path.endsWith(">")) return path.slice(1, -1).split(":")[0] || "temporary";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function isSyntheticPath(path: string): boolean {
|
||||
return path.startsWith(BUILTIN_PATH_PREFIX) || path.startsWith("<");
|
||||
}
|
||||
|
||||
export function createSourceInfo(path: string, metadata: PathMetadata): SourceInfo {
|
||||
return {
|
||||
path,
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@ import mcpExtension from "./mcp/index.ts";
|
|||
import toolSearchExtension from "./tool-search/index.ts";
|
||||
|
||||
export const builtInExtensions: InlineExtension[] = [
|
||||
{ name: "llama.cpp", factory: llamaExtension, hidden: true },
|
||||
{ name: "llama.cpp", factory: llamaExtension, builtin: true },
|
||||
// Replaceable: an extension that registers `codemode`, `tool_search`, or `/mcp` (such as a third-party
|
||||
// MCP extension) takes over instead of running alongside the built-in one.
|
||||
{ name: "codemode", factory: codemodeExtension, hidden: true, replaceable: true },
|
||||
{ name: "tool-search", factory: toolSearchExtension, hidden: true, replaceable: true },
|
||||
{ name: "mcp", factory: mcpExtension, hidden: true, replaceable: true },
|
||||
{ name: "codemode", factory: codemodeExtension, replaceable: true, builtin: true },
|
||||
{ name: "tool-search", factory: toolSearchExtension, replaceable: true, builtin: true },
|
||||
{ name: "mcp", factory: mcpExtension, replaceable: true, builtin: true },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
import { CONFIG_DIR_NAME } from "../../../config.ts";
|
||||
import type { PathMetadata, ResolvedPaths, ResolvedResource } from "../../../core/package-manager.ts";
|
||||
import type { PackageSource, SettingsManager } from "../../../core/settings-manager.ts";
|
||||
import { BUILTIN_PATH_PREFIX } from "../../../core/source-info.ts";
|
||||
import { canonicalizePath, isLocalPath, resolvePath } from "../../../utils/paths.ts";
|
||||
import { theme } from "../theme/theme.ts";
|
||||
import { DynamicBorder } from "./dynamic-border.ts";
|
||||
|
|
@ -84,6 +85,9 @@ function getGroupLabel(metadata: PathMetadata, agentDir: string): string {
|
|||
if (metadata.origin === "package") {
|
||||
return `${metadata.source} (${metadata.scope})`;
|
||||
}
|
||||
if (metadata.source === "builtin") {
|
||||
return metadata.scope === "user" ? "Built-in" : "Built-in (project override)";
|
||||
}
|
||||
// Top-level resources
|
||||
if (metadata.source === "auto") {
|
||||
if (metadata.baseDir) {
|
||||
|
|
@ -131,7 +135,9 @@ function buildGroups(resolved: ResolvedPaths, agentDir: string): ResourceGroup[]
|
|||
const fileName = basename(path);
|
||||
const parentFolder = basename(dirname(path));
|
||||
let displayName: string;
|
||||
if (resourceType === "extensions" && parentFolder !== "extensions") {
|
||||
if (metadata.source === "builtin") {
|
||||
displayName = path.slice(BUILTIN_PATH_PREFIX.length);
|
||||
} else if (resourceType === "extensions" && parentFolder !== "extensions") {
|
||||
displayName = `${parentFolder}/${fileName}`;
|
||||
} else if (resourceType === "skills" && fileName === "SKILL.md") {
|
||||
displayName = parentFolder;
|
||||
|
|
@ -679,7 +685,9 @@ class ResourceList implements Component, Focusable {
|
|||
return !(state === "inherit" && this.isInheritedGlobalItem(item) && target === pattern);
|
||||
});
|
||||
if (state !== "inherit") {
|
||||
if (this.isInheritedGlobalItem(item) && !updated.includes(pattern)) updated.push(pattern);
|
||||
// Project entries name inherited files to override them. Built-in paths need no entry.
|
||||
if (this.isInheritedGlobalItem(item) && item.metadata.source !== "builtin" && !updated.includes(pattern))
|
||||
updated.push(pattern);
|
||||
updated.push(`${state === "load" ? "+" : "-"}${pattern}`);
|
||||
}
|
||||
this.setProjectTopLevelPaths(item.resourceType, updated);
|
||||
|
|
@ -795,7 +803,7 @@ class ResourceList implements Component, Focusable {
|
|||
|
||||
private getResourcePatternForScope(item: ResourceItem, scope: SettingsScope): string {
|
||||
const sourceScope = this.getItemScope(item);
|
||||
if (scope !== sourceScope) return item.path;
|
||||
if (scope !== sourceScope || item.metadata.source === "builtin") return item.path;
|
||||
const baseDir = item.metadata.baseDir ?? this.getTopLevelBaseDir(sourceScope);
|
||||
return relative(baseDir, item.path);
|
||||
}
|
||||
|
|
@ -852,6 +860,7 @@ class ResourceList implements Component, Focusable {
|
|||
}
|
||||
|
||||
private getResourcePattern(item: ResourceItem): string {
|
||||
if (item.metadata.source === "builtin") return item.path;
|
||||
const scope = item.metadata.scope as "user" | "project";
|
||||
const baseDir = item.metadata.baseDir ?? this.getTopLevelBaseDir(scope);
|
||||
return relative(baseDir, item.path);
|
||||
|
|
|
|||
|
|
@ -638,7 +638,8 @@ export class InteractiveMode {
|
|||
}
|
||||
|
||||
private getAutocompleteSourceTag(sourceInfo?: SourceInfo): string | undefined {
|
||||
if (!sourceInfo) {
|
||||
// Built-in extension commands are untagged, like built-in commands.
|
||||
if (!sourceInfo || sourceInfo.source === "builtin") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ import type { InlineExtension } from "./core/extensions/types.ts";
|
|||
import { ModelRuntime } from "./core/model-runtime.ts";
|
||||
import { DefaultPackageManager } from "./core/package-manager.ts";
|
||||
import { type AppMode, resolveProjectTrusted } from "./core/project-trust.ts";
|
||||
import { DefaultResourceLoader } from "./core/resource-loader.ts";
|
||||
import { DefaultResourceLoader, isBuiltinExtension } from "./core/resource-loader.ts";
|
||||
import { SettingsManager } from "./core/settings-manager.ts";
|
||||
import { hasTrustRequiringProjectResources, ProjectTrustStore } from "./core/trust-manager.ts";
|
||||
import { spawnProcess, spawnProcessSync, waitForChildProcess } from "./utils/child-process.ts";
|
||||
|
|
@ -839,14 +839,18 @@ export async function handleConfigCommand(
|
|||
return true;
|
||||
}
|
||||
reportSettingsErrors(settingsManager, "config command");
|
||||
const builtinExtensions = (runtimeOptions.extensionFactories ?? [])
|
||||
.filter(isBuiltinExtension)
|
||||
.map((input) => input.name);
|
||||
const globalSettingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted: false });
|
||||
const globalResolvedPaths = await new DefaultPackageManager({
|
||||
cwd,
|
||||
agentDir,
|
||||
settingsManager: globalSettingsManager,
|
||||
builtinExtensions,
|
||||
}).resolve();
|
||||
const projectResolvedPaths = settingsManager.isProjectTrusted()
|
||||
? await new DefaultPackageManager({ cwd, agentDir, settingsManager }).resolve()
|
||||
? await new DefaultPackageManager({ cwd, agentDir, settingsManager, builtinExtensions }).resolve()
|
||||
: globalResolvedPaths;
|
||||
|
||||
await selectConfig({
|
||||
|
|
|
|||
|
|
@ -43,9 +43,9 @@ export function getFileRevision(path: string): string | undefined {
|
|||
}
|
||||
|
||||
/**
|
||||
* Returns true if the value is NOT a package source (npm:, git:, etc.)
|
||||
* or a remote URL protocol. Bare names, relative paths, and file: URLs
|
||||
* are considered local.
|
||||
* Returns true if the value is NOT a package source (npm:, git:, etc.),
|
||||
* a built-in extension (builtin:), or a remote URL protocol. Bare names,
|
||||
* relative paths, and file: URLs are considered local.
|
||||
*/
|
||||
export function isLocalPath(value: string): boolean {
|
||||
const trimmed = value.trim();
|
||||
|
|
@ -56,7 +56,8 @@ export function isLocalPath(value: string): boolean {
|
|||
trimmed.startsWith("github:") ||
|
||||
trimmed.startsWith("http:") ||
|
||||
trimmed.startsWith("https:") ||
|
||||
trimmed.startsWith("ssh:")
|
||||
trimmed.startsWith("ssh:") ||
|
||||
trimmed.startsWith("builtin:")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -153,7 +153,7 @@ describe("AgentSession dynamic tool registration", () => {
|
|||
origin: "top-level",
|
||||
});
|
||||
expect(readTool?.sourceInfo).toMatchObject({
|
||||
path: "<builtin:read>",
|
||||
path: "builtin:read",
|
||||
source: "builtin",
|
||||
scope: "temporary",
|
||||
origin: "top-level",
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ describe("llama.cpp extension", () => {
|
|||
process.cwd(),
|
||||
createEventBus(),
|
||||
runtime,
|
||||
"<inline:llama.cpp>",
|
||||
"builtin:llama.cpp",
|
||||
);
|
||||
|
||||
expect(extension.commands.get("llama")?.description).toBe("Manage llama.cpp router models");
|
||||
|
|
|
|||
|
|
@ -15,11 +15,12 @@ import lockfile from "proper-lockfile";
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ENV_AGENT_DIR, PACKAGE_NAME, VERSION } from "../src/config.ts";
|
||||
import { ModelRuntime } from "../src/core/model-runtime.ts";
|
||||
import type { ResolvedPaths } from "../src/core/package-manager.ts";
|
||||
import { DefaultPackageManager, type ResolvedPaths } from "../src/core/package-manager.ts";
|
||||
import { InMemorySettingsStorage, SettingsManager } from "../src/core/settings-manager.ts";
|
||||
import { ProjectTrustStore } from "../src/core/trust-manager.ts";
|
||||
import { main } from "../src/main.ts";
|
||||
import { ConfigSelectorComponent } from "../src/modes/interactive/components/config-selector.ts";
|
||||
import { initTheme } from "../src/modes/interactive/theme/theme.ts";
|
||||
import { handlePackageCommand } from "../src/package-manager-cli.ts";
|
||||
import { allowNetwork } from "./test-network-env.ts";
|
||||
|
||||
|
|
@ -525,6 +526,67 @@ if (process.platform !== "win32") fs.chmodSync(piPath, 0o755);
|
|||
expect(settingsManager.getProjectSettings().packages).toEqual([]);
|
||||
});
|
||||
|
||||
it("toggles built-in extensions in config global mode", async () => {
|
||||
const settingsManager = SettingsManager.fromStorage(new InMemorySettingsStorage(), { projectTrusted: true });
|
||||
const resolvedPaths = await new DefaultPackageManager({
|
||||
cwd: projectDir,
|
||||
agentDir,
|
||||
settingsManager,
|
||||
builtinExtensions: ["llama.cpp", "mcp"],
|
||||
}).resolve();
|
||||
const selector = new ConfigSelectorComponent(
|
||||
{ global: resolvedPaths, project: resolvedPaths },
|
||||
settingsManager,
|
||||
projectDir,
|
||||
agentDir,
|
||||
() => {},
|
||||
() => {},
|
||||
() => {},
|
||||
24,
|
||||
"global",
|
||||
);
|
||||
|
||||
const list = selector.getResourceList();
|
||||
initTheme("dark");
|
||||
expect(list.render(80).join("\n")).toContain("Built-in");
|
||||
expect(list.render(80).join("\n")).toContain("llama.cpp");
|
||||
list.handleInput(" ");
|
||||
expect(settingsManager.getGlobalSettings().extensions).toEqual(["-builtin:llama.cpp"]);
|
||||
list.handleInput(" ");
|
||||
expect(settingsManager.getGlobalSettings().extensions).toEqual(["+builtin:llama.cpp"]);
|
||||
});
|
||||
|
||||
it("cycles project built-in extension overrides in config local mode", async () => {
|
||||
const storage = new InMemorySettingsStorage();
|
||||
storage.withLock("global", () => JSON.stringify({ extensions: ["-builtin:mcp"] }));
|
||||
const settingsManager = SettingsManager.fromStorage(storage, { projectTrusted: true });
|
||||
const resolvedPaths = await new DefaultPackageManager({
|
||||
cwd: projectDir,
|
||||
agentDir,
|
||||
settingsManager,
|
||||
builtinExtensions: ["mcp"],
|
||||
}).resolve();
|
||||
const selector = new ConfigSelectorComponent(
|
||||
{ global: resolvedPaths, project: resolvedPaths },
|
||||
settingsManager,
|
||||
projectDir,
|
||||
agentDir,
|
||||
() => {},
|
||||
() => {},
|
||||
() => {},
|
||||
24,
|
||||
"project",
|
||||
);
|
||||
|
||||
const list = selector.getResourceList();
|
||||
list.handleInput(" ");
|
||||
expect(settingsManager.getProjectSettings().extensions).toEqual(["+builtin:mcp"]);
|
||||
list.handleInput(" ");
|
||||
expect(settingsManager.getProjectSettings().extensions).toEqual(["-builtin:mcp"]);
|
||||
list.handleInput(" ");
|
||||
expect(settingsManager.getProjectSettings().extensions).toEqual([]);
|
||||
});
|
||||
|
||||
it("shows a friendly error for unknown install options", async () => {
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
|
|
|
|||
|
|
@ -124,6 +124,35 @@ describe("DefaultPackageManager", () => {
|
|||
expect(result.extensions.some((r) => r.path === extPath && r.enabled)).toBe(true);
|
||||
});
|
||||
|
||||
it("should resolve built-in extensions with user exclusions and project overrides", async () => {
|
||||
const pm = new DefaultPackageManager({
|
||||
cwd: tempDir,
|
||||
agentDir,
|
||||
settingsManager,
|
||||
builtinExtensions: ["mcp", "llama.cpp"],
|
||||
});
|
||||
const builtins = async () =>
|
||||
(await pm.resolve()).extensions.map((r) => [r.path, r.enabled, r.metadata.source, r.metadata.scope]);
|
||||
|
||||
expect(await builtins()).toEqual([
|
||||
["builtin:mcp", true, "builtin", "user"],
|
||||
["builtin:llama.cpp", true, "builtin", "user"],
|
||||
]);
|
||||
|
||||
settingsManager.setExtensionPaths(["-builtin:mcp"]);
|
||||
settingsManager.setProjectExtensionPaths(["+builtin:mcp", "-builtin:llama.cpp"]);
|
||||
expect(await builtins()).toEqual([
|
||||
["builtin:mcp", true, "builtin", "project"],
|
||||
["builtin:llama.cpp", false, "builtin", "project"],
|
||||
]);
|
||||
|
||||
settingsManager.setProjectExtensionPaths([]);
|
||||
expect(await builtins()).toEqual([
|
||||
["builtin:mcp", false, "builtin", "user"],
|
||||
["builtin:llama.cpp", true, "builtin", "user"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("should resolve skill paths from settings", async () => {
|
||||
const skillDir = join(agentDir, "skills", "my-skill");
|
||||
mkdirSync(skillDir, { recursive: true });
|
||||
|
|
|
|||
|
|
@ -1087,6 +1087,101 @@ export default function(pi: ExtensionAPI) {
|
|||
expect(runner.getCommand("mcp")?.description).toBe("other mcp");
|
||||
expect(runner.getCommand("llama")?.description).toBe("built-in llama");
|
||||
});
|
||||
|
||||
it("should skip built-in extensions disabled in settings", async () => {
|
||||
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ extensions: ["-builtin:mcp"] }));
|
||||
const loaded: string[] = [];
|
||||
const loader = new DefaultResourceLoader({
|
||||
cwd,
|
||||
agentDir,
|
||||
extensionFactories: [
|
||||
{ name: "mcp", builtin: true, factory: () => void loaded.push("mcp") },
|
||||
{ name: "llama", builtin: true, factory: () => void loaded.push("llama") },
|
||||
],
|
||||
});
|
||||
await loader.reload();
|
||||
|
||||
expect(loader.getExtensions().extensions.map((extension) => extension.path)).toEqual(["builtin:llama"]);
|
||||
expect(loader.getExtensions().extensions[0].sourceInfo).toMatchObject({
|
||||
path: "builtin:llama",
|
||||
source: "builtin",
|
||||
});
|
||||
expect(loader.getExtensions().extensions[0].hidden).toBe(true);
|
||||
expect(loaded).toEqual(["llama"]);
|
||||
});
|
||||
|
||||
it("should load built-in extensions after file extensions with and without trust resolution", async () => {
|
||||
const userExtDir = join(agentDir, "extensions");
|
||||
mkdirSync(userExtDir, { recursive: true });
|
||||
writeFileSync(join(userExtDir, "user.ts"), "export default function() {}");
|
||||
mkdirSync(join(cwd, ".pi"), { recursive: true });
|
||||
// A project override gives the built-in project scope, which must not move it ahead.
|
||||
writeFileSync(join(cwd, ".pi", "settings.json"), JSON.stringify({ extensions: ["+builtin:mcp"] }));
|
||||
const loader = new DefaultResourceLoader({
|
||||
cwd,
|
||||
agentDir,
|
||||
extensionFactories: [{ name: "mcp", builtin: true, factory: () => {} }],
|
||||
});
|
||||
const expected = [join(userExtDir, "user.ts"), "builtin:mcp"];
|
||||
|
||||
await loader.reload({ resolveProjectTrust: async () => true });
|
||||
expect(loader.getExtensions().extensions.map((extension) => extension.path)).toEqual(expected);
|
||||
await loader.reload();
|
||||
expect(loader.getExtensions().extensions.map((extension) => extension.path)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("should disable built-in extensions with noExtensions unless loaded with -e builtin:<name>", async () => {
|
||||
const loaded: string[] = [];
|
||||
const loader = new DefaultResourceLoader({
|
||||
cwd,
|
||||
agentDir,
|
||||
noExtensions: true,
|
||||
additionalExtensionPaths: ["builtin:mcp", "builtin:missing"],
|
||||
extensionFactories: [
|
||||
{ name: "mcp", builtin: true, factory: () => void loaded.push("mcp") },
|
||||
{ name: "llama", builtin: true, factory: () => void loaded.push("llama") },
|
||||
],
|
||||
});
|
||||
await loader.reload();
|
||||
|
||||
expect(loader.getExtensions().extensions.map((extension) => extension.path)).toEqual(["builtin:mcp"]);
|
||||
expect(loader.getExtensions().errors).toEqual([
|
||||
{ path: "builtin:missing", error: "Unknown built-in extension: builtin:missing" },
|
||||
]);
|
||||
expect(loaded).toEqual(["mcp"]);
|
||||
});
|
||||
|
||||
it("should apply project built-in extension overrides after trust resolves", async () => {
|
||||
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ extensions: ["-builtin:mcp"] }));
|
||||
mkdirSync(join(cwd, ".pi"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(cwd, ".pi", "settings.json"),
|
||||
JSON.stringify({ extensions: ["+builtin:mcp", "-builtin:llama"] }),
|
||||
);
|
||||
const loaded: string[] = [];
|
||||
const loader = new DefaultResourceLoader({
|
||||
cwd,
|
||||
agentDir,
|
||||
extensionFactories: [
|
||||
{ name: "mcp", builtin: true, factory: () => void loaded.push("mcp") },
|
||||
{ name: "plain", factory: () => void loaded.push("plain") },
|
||||
{ name: "llama", builtin: true, factory: () => void loaded.push("llama") },
|
||||
],
|
||||
});
|
||||
await loader.reload({
|
||||
resolveProjectTrust: async ({ extensionsResult }) => {
|
||||
// Built-in extensions wait until project settings are known.
|
||||
expect(extensionsResult.extensions.map((extension) => extension.path)).toEqual(["<inline:plain>"]);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
expect(loader.getExtensions().extensions.map((extension) => extension.path)).toEqual([
|
||||
"builtin:mcp",
|
||||
"<inline:plain>",
|
||||
]);
|
||||
expect(loaded).toEqual(["plain", "mcp"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadProjectContextFiles - nested worktree dedup", () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue