mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 07:03:25 +00:00
feat(coding-agent): prioritize project resources over global
This commit is contained in:
parent
380236a003
commit
f0379384fe
8 changed files with 271 additions and 63 deletions
|
|
@ -2,6 +2,11 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
- Resource precedence for extensions, skills, prompts, themes, and slash-command name collisions is now project-first (`cwd/.pi`) before user-global (`~/.pi/agent`). If you relied on global resources overriding project resources with the same names, rename or reorder your resources.
|
||||||
|
- Extension registration conflicts no longer unload the entire later extension. All extensions stay loaded, and conflicting command/tool/flag names are resolved by first registration in load order.
|
||||||
|
|
||||||
## [0.54.2] - 2026-02-23
|
## [0.54.2] - 2026-02-23
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
|
||||||
|
|
@ -173,7 +173,7 @@ function createExtensionAPI(
|
||||||
options: { description?: string; type: "boolean" | "string"; default?: boolean | string },
|
options: { description?: string; type: "boolean" | "string"; default?: boolean | string },
|
||||||
): void {
|
): void {
|
||||||
extension.flags.set(name, { name, extensionPath: extension.path, ...options });
|
extension.flags.set(name, { name, extensionPath: extension.path, ...options });
|
||||||
if (options.default !== undefined) {
|
if (options.default !== undefined && !runtime.flagValues.has(name)) {
|
||||||
runtime.flagValues.set(name, options.default);
|
runtime.flagValues.set(name, options.default);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -486,14 +486,14 @@ export async function discoverAndLoadExtensions(
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 1. Global extensions: agentDir/extensions/
|
// 1. Project-local extensions: cwd/.pi/extensions/
|
||||||
const globalExtDir = path.join(agentDir, "extensions");
|
|
||||||
addPaths(discoverExtensionsInDir(globalExtDir));
|
|
||||||
|
|
||||||
// 2. Project-local extensions: cwd/.pi/extensions/
|
|
||||||
const localExtDir = path.join(cwd, ".pi", "extensions");
|
const localExtDir = path.join(cwd, ".pi", "extensions");
|
||||||
addPaths(discoverExtensionsInDir(localExtDir));
|
addPaths(discoverExtensionsInDir(localExtDir));
|
||||||
|
|
||||||
|
// 2. Global extensions: agentDir/extensions/
|
||||||
|
const globalExtDir = path.join(agentDir, "extensions");
|
||||||
|
addPaths(discoverExtensionsInDir(globalExtDir));
|
||||||
|
|
||||||
// 3. Explicitly configured paths
|
// 3. Explicitly configured paths
|
||||||
for (const p of configuredPaths) {
|
for (const p of configuredPaths) {
|
||||||
const resolved = resolvePath(p, cwd);
|
const resolved = resolvePath(p, cwd);
|
||||||
|
|
|
||||||
|
|
@ -301,15 +301,17 @@ export class ExtensionRunner {
|
||||||
return this.extensions.map((e) => e.path);
|
return this.extensions.map((e) => e.path);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get all registered tools from all extensions. */
|
/** Get all registered tools from all extensions (first registration per name wins). */
|
||||||
getAllRegisteredTools(): RegisteredTool[] {
|
getAllRegisteredTools(): RegisteredTool[] {
|
||||||
const tools: RegisteredTool[] = [];
|
const toolsByName = new Map<string, RegisteredTool>();
|
||||||
for (const ext of this.extensions) {
|
for (const ext of this.extensions) {
|
||||||
for (const tool of ext.tools.values()) {
|
for (const tool of ext.tools.values()) {
|
||||||
tools.push(tool);
|
if (!toolsByName.has(tool.definition.name)) {
|
||||||
|
toolsByName.set(tool.definition.name, tool);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return tools;
|
return Array.from(toolsByName.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get a tool definition by name. Returns undefined if not found. */
|
/** Get a tool definition by name. Returns undefined if not found. */
|
||||||
|
|
@ -327,7 +329,9 @@ export class ExtensionRunner {
|
||||||
const allFlags = new Map<string, ExtensionFlag>();
|
const allFlags = new Map<string, ExtensionFlag>();
|
||||||
for (const ext of this.extensions) {
|
for (const ext of this.extensions) {
|
||||||
for (const [name, flag] of ext.flags) {
|
for (const [name, flag] of ext.flags) {
|
||||||
allFlags.set(name, flag);
|
if (!allFlags.has(name)) {
|
||||||
|
allFlags.set(name, flag);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return allFlags;
|
return allFlags;
|
||||||
|
|
@ -425,6 +429,7 @@ export class ExtensionRunner {
|
||||||
this.commandDiagnostics = [];
|
this.commandDiagnostics = [];
|
||||||
|
|
||||||
const commands: RegisteredCommand[] = [];
|
const commands: RegisteredCommand[] = [];
|
||||||
|
const commandOwners = new Map<string, string>();
|
||||||
for (const ext of this.extensions) {
|
for (const ext of this.extensions) {
|
||||||
for (const command of ext.commands.values()) {
|
for (const command of ext.commands.values()) {
|
||||||
if (reserved?.has(command.name)) {
|
if (reserved?.has(command.name)) {
|
||||||
|
|
@ -436,6 +441,17 @@ export class ExtensionRunner {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const existingOwner = commandOwners.get(command.name);
|
||||||
|
if (existingOwner) {
|
||||||
|
const message = `Extension command '${command.name}' from ${ext.path} conflicts with ${existingOwner}. Skipping.`;
|
||||||
|
this.commandDiagnostics.push({ type: "warning", message, path: ext.path });
|
||||||
|
if (!this.hasUI()) {
|
||||||
|
console.warn(message);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
commandOwners.set(command.name, ext.path);
|
||||||
commands.push(command);
|
commands.push(command);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -722,14 +722,14 @@ 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();
|
||||||
|
|
||||||
// Collect all packages with scope
|
// Collect all packages with scope (project first so cwd resources win collisions)
|
||||||
const allPackages: Array<{ pkg: PackageSource; scope: SourceScope }> = [];
|
const allPackages: Array<{ pkg: PackageSource; scope: SourceScope }> = [];
|
||||||
for (const pkg of globalSettings.packages ?? []) {
|
|
||||||
allPackages.push({ pkg, scope: "user" });
|
|
||||||
}
|
|
||||||
for (const pkg of projectSettings.packages ?? []) {
|
for (const pkg of projectSettings.packages ?? []) {
|
||||||
allPackages.push({ pkg, scope: "project" });
|
allPackages.push({ pkg, scope: "project" });
|
||||||
}
|
}
|
||||||
|
for (const pkg of globalSettings.packages ?? []) {
|
||||||
|
allPackages.push({ pkg, scope: "user" });
|
||||||
|
}
|
||||||
|
|
||||||
// Dedupe: project scope wins over global for same package identity
|
// Dedupe: project scope wins over global for same package identity
|
||||||
const packageSources = this.dedupePackages(allPackages);
|
const packageSources = this.dedupePackages(allPackages);
|
||||||
|
|
@ -742,17 +742,6 @@ export class DefaultPackageManager implements PackageManager {
|
||||||
const target = this.getTargetMap(accumulator, resourceType);
|
const target = this.getTargetMap(accumulator, resourceType);
|
||||||
const globalEntries = (globalSettings[resourceType] ?? []) as string[];
|
const globalEntries = (globalSettings[resourceType] ?? []) as string[];
|
||||||
const projectEntries = (projectSettings[resourceType] ?? []) as string[];
|
const projectEntries = (projectSettings[resourceType] ?? []) as string[];
|
||||||
this.resolveLocalEntries(
|
|
||||||
globalEntries,
|
|
||||||
resourceType,
|
|
||||||
target,
|
|
||||||
{
|
|
||||||
source: "local",
|
|
||||||
scope: "user",
|
|
||||||
origin: "top-level",
|
|
||||||
},
|
|
||||||
globalBaseDir,
|
|
||||||
);
|
|
||||||
this.resolveLocalEntries(
|
this.resolveLocalEntries(
|
||||||
projectEntries,
|
projectEntries,
|
||||||
resourceType,
|
resourceType,
|
||||||
|
|
@ -764,6 +753,17 @@ export class DefaultPackageManager implements PackageManager {
|
||||||
},
|
},
|
||||||
projectBaseDir,
|
projectBaseDir,
|
||||||
);
|
);
|
||||||
|
this.resolveLocalEntries(
|
||||||
|
globalEntries,
|
||||||
|
resourceType,
|
||||||
|
target,
|
||||||
|
{
|
||||||
|
source: "local",
|
||||||
|
scope: "user",
|
||||||
|
origin: "top-level",
|
||||||
|
},
|
||||||
|
globalBaseDir,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.addAutoDiscoveredResources(accumulator, globalSettings, projectSettings, globalBaseDir, projectBaseDir);
|
this.addAutoDiscoveredResources(accumulator, globalSettings, projectSettings, globalBaseDir, projectBaseDir);
|
||||||
|
|
@ -1600,35 +1600,6 @@ export class DefaultPackageManager implements PackageManager {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
addResources(
|
|
||||||
"extensions",
|
|
||||||
collectAutoExtensionEntries(userDirs.extensions),
|
|
||||||
userMetadata,
|
|
||||||
userOverrides.extensions,
|
|
||||||
globalBaseDir,
|
|
||||||
);
|
|
||||||
addResources(
|
|
||||||
"skills",
|
|
||||||
[...collectAutoSkillEntries(userDirs.skills), ...collectAutoSkillEntries(userAgentsSkillsDir)],
|
|
||||||
userMetadata,
|
|
||||||
userOverrides.skills,
|
|
||||||
globalBaseDir,
|
|
||||||
);
|
|
||||||
addResources(
|
|
||||||
"prompts",
|
|
||||||
collectAutoPromptEntries(userDirs.prompts),
|
|
||||||
userMetadata,
|
|
||||||
userOverrides.prompts,
|
|
||||||
globalBaseDir,
|
|
||||||
);
|
|
||||||
addResources(
|
|
||||||
"themes",
|
|
||||||
collectAutoThemeEntries(userDirs.themes),
|
|
||||||
userMetadata,
|
|
||||||
userOverrides.themes,
|
|
||||||
globalBaseDir,
|
|
||||||
);
|
|
||||||
|
|
||||||
addResources(
|
addResources(
|
||||||
"extensions",
|
"extensions",
|
||||||
collectAutoExtensionEntries(projectDirs.extensions),
|
collectAutoExtensionEntries(projectDirs.extensions),
|
||||||
|
|
@ -1660,6 +1631,35 @@ export class DefaultPackageManager implements PackageManager {
|
||||||
projectOverrides.themes,
|
projectOverrides.themes,
|
||||||
projectBaseDir,
|
projectBaseDir,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
addResources(
|
||||||
|
"extensions",
|
||||||
|
collectAutoExtensionEntries(userDirs.extensions),
|
||||||
|
userMetadata,
|
||||||
|
userOverrides.extensions,
|
||||||
|
globalBaseDir,
|
||||||
|
);
|
||||||
|
addResources(
|
||||||
|
"skills",
|
||||||
|
[...collectAutoSkillEntries(userDirs.skills), ...collectAutoSkillEntries(userAgentsSkillsDir)],
|
||||||
|
userMetadata,
|
||||||
|
userOverrides.skills,
|
||||||
|
globalBaseDir,
|
||||||
|
);
|
||||||
|
addResources(
|
||||||
|
"prompts",
|
||||||
|
collectAutoPromptEntries(userDirs.prompts),
|
||||||
|
userMetadata,
|
||||||
|
userOverrides.prompts,
|
||||||
|
globalBaseDir,
|
||||||
|
);
|
||||||
|
addResources(
|
||||||
|
"themes",
|
||||||
|
collectAutoThemeEntries(userDirs.themes),
|
||||||
|
userMetadata,
|
||||||
|
userOverrides.themes,
|
||||||
|
globalBaseDir,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private collectFilesFromPaths(paths: string[], resourceType: ResourceType): string[] {
|
private collectFilesFromPaths(paths: string[], resourceType: ResourceType): string[] {
|
||||||
|
|
|
||||||
|
|
@ -384,13 +384,10 @@ export class DefaultResourceLoader implements ResourceLoader {
|
||||||
extensionsResult.errors.push(...inlineExtensions.errors);
|
extensionsResult.errors.push(...inlineExtensions.errors);
|
||||||
|
|
||||||
// Detect extension conflicts (tools, commands, flags with same names from different extensions)
|
// Detect extension conflicts (tools, commands, flags with same names from different extensions)
|
||||||
|
// Keep all extensions loaded. Conflicts are reported as diagnostics, and precedence is handled by load order.
|
||||||
const conflicts = this.detectExtensionConflicts(extensionsResult.extensions);
|
const conflicts = this.detectExtensionConflicts(extensionsResult.extensions);
|
||||||
if (conflicts.length > 0) {
|
for (const conflict of conflicts) {
|
||||||
const conflictingPaths = new Set(conflicts.map((c) => c.path));
|
extensionsResult.errors.push({ path: conflict.path, error: conflict.message });
|
||||||
extensionsResult.extensions = extensionsResult.extensions.filter((ext) => !conflictingPaths.has(ext.path));
|
|
||||||
for (const conflict of conflicts) {
|
|
||||||
extensionsResult.errors.push({ path: conflict.path, error: conflict.message });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.extensionsResult = this.extensionsOverride ? this.extensionsOverride(extensionsResult) : extensionsResult;
|
this.extensionsResult = this.extensionsOverride ? this.extensionsOverride(extensionsResult) : extensionsResult;
|
||||||
|
|
|
||||||
|
|
@ -729,7 +729,7 @@ export class InteractiveMode {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [groups.user, groups.project, groups.path].filter(
|
return [groups.project, groups.user, groups.path].filter(
|
||||||
(group) => group.paths.length > 0 || group.packages.size > 0,
|
(group) => group.paths.length > 0 || group.packages.size > 0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -234,6 +234,42 @@ describe("ExtensionRunner", () => {
|
||||||
expect(tools.length).toBe(2);
|
expect(tools.length).toBe(2);
|
||||||
expect(tools.map((t) => t.definition.name).sort()).toEqual(["tool_a", "tool_b"]);
|
expect(tools.map((t) => t.definition.name).sort()).toEqual(["tool_a", "tool_b"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps first tool when two extensions register the same name", async () => {
|
||||||
|
const first = `
|
||||||
|
import { Type } from "@sinclair/typebox";
|
||||||
|
export default function(pi) {
|
||||||
|
pi.registerTool({
|
||||||
|
name: "shared",
|
||||||
|
label: "shared",
|
||||||
|
description: "first",
|
||||||
|
parameters: Type.Object({}),
|
||||||
|
execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const second = `
|
||||||
|
import { Type } from "@sinclair/typebox";
|
||||||
|
export default function(pi) {
|
||||||
|
pi.registerTool({
|
||||||
|
name: "shared",
|
||||||
|
label: "shared",
|
||||||
|
description: "second",
|
||||||
|
parameters: Type.Object({}),
|
||||||
|
execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
fs.writeFileSync(path.join(extensionsDir, "a-first.ts"), first);
|
||||||
|
fs.writeFileSync(path.join(extensionsDir, "b-second.ts"), second);
|
||||||
|
|
||||||
|
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||||
|
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry);
|
||||||
|
const tools = runner.getAllRegisteredTools();
|
||||||
|
|
||||||
|
expect(tools).toHaveLength(1);
|
||||||
|
expect(tools[0]?.definition.description).toBe("first");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("command collection", () => {
|
describe("command collection", () => {
|
||||||
|
|
@ -377,6 +413,36 @@ describe("ExtensionRunner", () => {
|
||||||
expect(flags.has("my-flag")).toBe(true);
|
expect(flags.has("my-flag")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps first flag when two extensions register the same name", async () => {
|
||||||
|
const first = `
|
||||||
|
export default function(pi) {
|
||||||
|
pi.registerFlag("shared-flag", {
|
||||||
|
description: "first",
|
||||||
|
type: "boolean",
|
||||||
|
default: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const second = `
|
||||||
|
export default function(pi) {
|
||||||
|
pi.registerFlag("shared-flag", {
|
||||||
|
description: "second",
|
||||||
|
type: "boolean",
|
||||||
|
default: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
fs.writeFileSync(path.join(extensionsDir, "a-first.ts"), first);
|
||||||
|
fs.writeFileSync(path.join(extensionsDir, "b-second.ts"), second);
|
||||||
|
|
||||||
|
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||||
|
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry);
|
||||||
|
const flags = runner.getFlags();
|
||||||
|
|
||||||
|
expect(flags.get("shared-flag")?.description).toBe("first");
|
||||||
|
expect(result.runtime.flagValues.get("shared-flag")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("can set flag values", async () => {
|
it("can set flag values", async () => {
|
||||||
const extCode = `
|
const extCode = `
|
||||||
export default function(pi) {
|
export default function(pi) {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { AuthStorage } from "../src/core/auth-storage.js";
|
||||||
|
import { ExtensionRunner } from "../src/core/extensions/runner.js";
|
||||||
|
import { ModelRegistry } from "../src/core/model-registry.js";
|
||||||
import { DefaultResourceLoader } from "../src/core/resource-loader.js";
|
import { DefaultResourceLoader } from "../src/core/resource-loader.js";
|
||||||
|
import { SessionManager } from "../src/core/session-manager.js";
|
||||||
import { SettingsManager } from "../src/core/settings-manager.js";
|
import { SettingsManager } from "../src/core/settings-manager.js";
|
||||||
import type { Skill } from "../src/core/skills.js";
|
import type { Skill } from "../src/core/skills.js";
|
||||||
|
|
||||||
|
|
@ -91,6 +95,126 @@ Prompt content.`,
|
||||||
expect(prompts.some((p) => p.name === "test-prompt")).toBe(true);
|
expect(prompts.some((p) => p.name === "test-prompt")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should prefer project resources over user on name collisions", async () => {
|
||||||
|
const userPromptsDir = join(agentDir, "prompts");
|
||||||
|
const projectPromptsDir = join(cwd, ".pi", "prompts");
|
||||||
|
mkdirSync(userPromptsDir, { recursive: true });
|
||||||
|
mkdirSync(projectPromptsDir, { recursive: true });
|
||||||
|
const userPromptPath = join(userPromptsDir, "commit.md");
|
||||||
|
const projectPromptPath = join(projectPromptsDir, "commit.md");
|
||||||
|
writeFileSync(userPromptPath, "User prompt");
|
||||||
|
writeFileSync(projectPromptPath, "Project prompt");
|
||||||
|
|
||||||
|
const userSkillDir = join(agentDir, "skills", "collision-skill");
|
||||||
|
const projectSkillDir = join(cwd, ".pi", "skills", "collision-skill");
|
||||||
|
mkdirSync(userSkillDir, { recursive: true });
|
||||||
|
mkdirSync(projectSkillDir, { recursive: true });
|
||||||
|
const userSkillPath = join(userSkillDir, "SKILL.md");
|
||||||
|
const projectSkillPath = join(projectSkillDir, "SKILL.md");
|
||||||
|
writeFileSync(
|
||||||
|
userSkillPath,
|
||||||
|
`---
|
||||||
|
name: collision-skill
|
||||||
|
description: user
|
||||||
|
---
|
||||||
|
User skill`,
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
projectSkillPath,
|
||||||
|
`---
|
||||||
|
name: collision-skill
|
||||||
|
description: project
|
||||||
|
---
|
||||||
|
Project skill`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const baseTheme = JSON.parse(
|
||||||
|
readFileSync(join(process.cwd(), "src", "modes", "interactive", "theme", "dark.json"), "utf-8"),
|
||||||
|
) as { name: string; vars?: Record<string, string> };
|
||||||
|
baseTheme.name = "collision-theme";
|
||||||
|
const userThemePath = join(agentDir, "themes", "collision.json");
|
||||||
|
const projectThemePath = join(cwd, ".pi", "themes", "collision.json");
|
||||||
|
mkdirSync(join(agentDir, "themes"), { recursive: true });
|
||||||
|
mkdirSync(join(cwd, ".pi", "themes"), { recursive: true });
|
||||||
|
writeFileSync(userThemePath, JSON.stringify(baseTheme, null, 2));
|
||||||
|
if (baseTheme.vars) {
|
||||||
|
baseTheme.vars.accent = "#ff00ff";
|
||||||
|
}
|
||||||
|
writeFileSync(projectThemePath, JSON.stringify(baseTheme, null, 2));
|
||||||
|
|
||||||
|
const loader = new DefaultResourceLoader({ cwd, agentDir });
|
||||||
|
await loader.reload();
|
||||||
|
|
||||||
|
const prompt = loader.getPrompts().prompts.find((p) => p.name === "commit");
|
||||||
|
expect(prompt?.filePath).toBe(projectPromptPath);
|
||||||
|
|
||||||
|
const skill = loader.getSkills().skills.find((s) => s.name === "collision-skill");
|
||||||
|
expect(skill?.filePath).toBe(projectSkillPath);
|
||||||
|
|
||||||
|
const theme = loader.getThemes().themes.find((t) => t.name === "collision-theme");
|
||||||
|
expect(theme?.sourcePath).toBe(projectThemePath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should keep both extensions loaded when command names collide", async () => {
|
||||||
|
const userExtDir = join(agentDir, "extensions");
|
||||||
|
const projectExtDir = join(cwd, ".pi", "extensions");
|
||||||
|
mkdirSync(userExtDir, { recursive: true });
|
||||||
|
mkdirSync(projectExtDir, { recursive: true });
|
||||||
|
|
||||||
|
writeFileSync(
|
||||||
|
join(projectExtDir, "project.ts"),
|
||||||
|
`export default function(pi) {
|
||||||
|
pi.registerCommand("deploy", {
|
||||||
|
description: "project deploy",
|
||||||
|
handler: async () => {},
|
||||||
|
});
|
||||||
|
pi.registerCommand("project-only", {
|
||||||
|
description: "project only",
|
||||||
|
handler: async () => {},
|
||||||
|
});
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
writeFileSync(
|
||||||
|
join(userExtDir, "user.ts"),
|
||||||
|
`export default function(pi) {
|
||||||
|
pi.registerCommand("deploy", {
|
||||||
|
description: "user deploy",
|
||||||
|
handler: async () => {},
|
||||||
|
});
|
||||||
|
pi.registerCommand("user-only", {
|
||||||
|
description: "user only",
|
||||||
|
handler: async () => {},
|
||||||
|
});
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const loader = new DefaultResourceLoader({ cwd, agentDir });
|
||||||
|
await loader.reload();
|
||||||
|
|
||||||
|
const extensionsResult = loader.getExtensions();
|
||||||
|
expect(extensionsResult.extensions).toHaveLength(2);
|
||||||
|
expect(extensionsResult.errors.some((e) => e.error.includes('Command "/deploy" conflicts'))).toBe(true);
|
||||||
|
|
||||||
|
const sessionManager = SessionManager.inMemory();
|
||||||
|
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
|
||||||
|
const modelRegistry = new ModelRegistry(authStorage);
|
||||||
|
const runner = new ExtensionRunner(
|
||||||
|
extensionsResult.extensions,
|
||||||
|
extensionsResult.runtime,
|
||||||
|
cwd,
|
||||||
|
sessionManager,
|
||||||
|
modelRegistry,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(runner.getCommand("deploy")?.description).toBe("project deploy");
|
||||||
|
expect(runner.getCommand("project-only")?.description).toBe("project only");
|
||||||
|
expect(runner.getCommand("user-only")?.description).toBe("user only");
|
||||||
|
|
||||||
|
const commandNames = runner.getRegisteredCommands().map((c) => c.name);
|
||||||
|
expect(commandNames.filter((name) => name === "deploy")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("should honor overrides for auto-discovered resources", async () => {
|
it("should honor overrides for auto-discovered resources", async () => {
|
||||||
const settingsManager = SettingsManager.inMemory();
|
const settingsManager = SettingsManager.inMemory();
|
||||||
settingsManager.setExtensionPaths(["-extensions/disabled.ts"]);
|
settingsManager.setExtensionPaths(["-extensions/disabled.ts"]);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue