Merge pull request #3218 from code-yeongyu/fix/3216-plugin-scope-filter
fix(plugin-loader): filter project-scoped plugins by cwd (#3216)
This commit is contained in:
@@ -10,6 +10,7 @@ import { join } from "node:path"
|
||||
|
||||
const originalClaudePluginsHome = process.env.CLAUDE_PLUGINS_HOME
|
||||
const temporaryDirectories: string[] = []
|
||||
const originalCwd = process.cwd()
|
||||
|
||||
function createTemporaryDirectory(prefix: string): string {
|
||||
const directory = mkdtempSync(join(tmpdir(), prefix))
|
||||
@@ -17,6 +18,14 @@ function createTemporaryDirectory(prefix: string): string {
|
||||
return directory
|
||||
}
|
||||
|
||||
function writeDatabase(pluginsHome: string, database: unknown): void {
|
||||
writeFileSync(join(pluginsHome, "installed_plugins.json"), JSON.stringify(database), "utf-8")
|
||||
}
|
||||
|
||||
function createInstallPath(prefix: string): string {
|
||||
return createTemporaryDirectory(prefix)
|
||||
}
|
||||
|
||||
describe("discoverInstalledPlugins", () => {
|
||||
beforeEach(() => {
|
||||
mock.module("../../shared/logger", () => ({
|
||||
@@ -36,6 +45,10 @@ describe("discoverInstalledPlugins", () => {
|
||||
process.env.CLAUDE_PLUGINS_HOME = originalClaudePluginsHome
|
||||
}
|
||||
|
||||
if (process.cwd() !== originalCwd) {
|
||||
process.chdir(originalCwd)
|
||||
}
|
||||
|
||||
for (const directory of temporaryDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
@@ -156,4 +169,488 @@ describe("discoverInstalledPlugins", () => {
|
||||
expect(discovered.plugins).toHaveLength(1)
|
||||
expect(discovered.plugins[0]?.name).toBe("oh-my-openagent")
|
||||
})
|
||||
|
||||
describe("#given project-scoped entries in v1 format", () => {
|
||||
it("#when cwd matches projectPath #then the plugin loads", async () => {
|
||||
//#given
|
||||
const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string
|
||||
const projectDirectory = createTemporaryDirectory("omo-v1-project-match-")
|
||||
const installPath = createInstallPath("omo-v1-install-")
|
||||
writeDatabase(pluginsHome, {
|
||||
version: 1,
|
||||
plugins: {
|
||||
"project-plugin@market": {
|
||||
scope: "project",
|
||||
projectPath: projectDirectory,
|
||||
installPath,
|
||||
version: "1.0.0",
|
||||
installedAt: "2026-03-25T00:00:00Z",
|
||||
lastUpdated: "2026-03-25T00:00:00Z",
|
||||
},
|
||||
},
|
||||
})
|
||||
process.chdir(projectDirectory)
|
||||
|
||||
//#when
|
||||
const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v1-match`)
|
||||
const discovered = discoverInstalledPlugins({
|
||||
pluginsHomeOverride: pluginsHome,
|
||||
loadPluginManifestOverride: () => null,
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(discovered.errors).toHaveLength(0)
|
||||
expect(discovered.plugins).toHaveLength(1)
|
||||
expect(discovered.plugins[0]?.name).toBe("project-plugin")
|
||||
})
|
||||
|
||||
it("#when cwd is a subdirectory of projectPath #then the plugin loads", async () => {
|
||||
//#given
|
||||
const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string
|
||||
const projectDirectory = createTemporaryDirectory("omo-v1-project-sub-")
|
||||
const subdirectory = join(projectDirectory, "packages", "app")
|
||||
mkdirSync(subdirectory, { recursive: true })
|
||||
const installPath = createInstallPath("omo-v1-install-")
|
||||
writeDatabase(pluginsHome, {
|
||||
version: 1,
|
||||
plugins: {
|
||||
"sub-plugin@market": {
|
||||
scope: "project",
|
||||
projectPath: projectDirectory,
|
||||
installPath,
|
||||
version: "1.0.0",
|
||||
installedAt: "2026-03-25T00:00:00Z",
|
||||
lastUpdated: "2026-03-25T00:00:00Z",
|
||||
},
|
||||
},
|
||||
})
|
||||
process.chdir(subdirectory)
|
||||
|
||||
//#when
|
||||
const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v1-sub`)
|
||||
const discovered = discoverInstalledPlugins({
|
||||
pluginsHomeOverride: pluginsHome,
|
||||
loadPluginManifestOverride: () => null,
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(discovered.errors).toHaveLength(0)
|
||||
expect(discovered.plugins).toHaveLength(1)
|
||||
expect(discovered.plugins[0]?.name).toBe("sub-plugin")
|
||||
})
|
||||
|
||||
it("#when cwd does not match projectPath #then the plugin is skipped", async () => {
|
||||
//#given
|
||||
const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string
|
||||
const projectDirectory = createTemporaryDirectory("omo-v1-project-miss-")
|
||||
const otherDirectory = createTemporaryDirectory("omo-v1-other-")
|
||||
const installPath = createInstallPath("omo-v1-install-")
|
||||
writeDatabase(pluginsHome, {
|
||||
version: 1,
|
||||
plugins: {
|
||||
"outside-plugin@market": {
|
||||
scope: "project",
|
||||
projectPath: projectDirectory,
|
||||
installPath,
|
||||
version: "1.0.0",
|
||||
installedAt: "2026-03-25T00:00:00Z",
|
||||
lastUpdated: "2026-03-25T00:00:00Z",
|
||||
},
|
||||
},
|
||||
})
|
||||
process.chdir(otherDirectory)
|
||||
|
||||
//#when
|
||||
const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v1-miss`)
|
||||
const discovered = discoverInstalledPlugins({
|
||||
pluginsHomeOverride: pluginsHome,
|
||||
loadPluginManifestOverride: () => null,
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(discovered.errors).toHaveLength(0)
|
||||
expect(discovered.plugins).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("#when projectPath is missing #then the plugin is skipped", async () => {
|
||||
//#given
|
||||
const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string
|
||||
const installPath = createInstallPath("omo-v1-install-")
|
||||
writeDatabase(pluginsHome, {
|
||||
version: 1,
|
||||
plugins: {
|
||||
"no-path-plugin@market": {
|
||||
scope: "project",
|
||||
installPath,
|
||||
version: "1.0.0",
|
||||
installedAt: "2026-03-25T00:00:00Z",
|
||||
lastUpdated: "2026-03-25T00:00:00Z",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
//#when
|
||||
const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v1-noproj`)
|
||||
const discovered = discoverInstalledPlugins({
|
||||
pluginsHomeOverride: pluginsHome,
|
||||
loadPluginManifestOverride: () => null,
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(discovered.errors).toHaveLength(0)
|
||||
expect(discovered.plugins).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("#when scope is user #then it always loads regardless of cwd", async () => {
|
||||
//#given
|
||||
const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string
|
||||
const unrelatedDirectory = createTemporaryDirectory("omo-v1-unrelated-")
|
||||
const installPath = createInstallPath("omo-v1-install-")
|
||||
writeDatabase(pluginsHome, {
|
||||
version: 1,
|
||||
plugins: {
|
||||
"user-plugin@market": {
|
||||
scope: "user",
|
||||
installPath,
|
||||
version: "1.0.0",
|
||||
installedAt: "2026-03-25T00:00:00Z",
|
||||
lastUpdated: "2026-03-25T00:00:00Z",
|
||||
},
|
||||
},
|
||||
})
|
||||
process.chdir(unrelatedDirectory)
|
||||
|
||||
//#when
|
||||
const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v1-user`)
|
||||
const discovered = discoverInstalledPlugins({
|
||||
pluginsHomeOverride: pluginsHome,
|
||||
loadPluginManifestOverride: () => null,
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(discovered.errors).toHaveLength(0)
|
||||
expect(discovered.plugins).toHaveLength(1)
|
||||
expect(discovered.plugins[0]?.name).toBe("user-plugin")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given project and local scoped entries in v2 format", () => {
|
||||
it("#when cwd matches project-scoped projectPath #then it loads while non-matching entries are dropped", async () => {
|
||||
//#given
|
||||
const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string
|
||||
const projectDirectory = createTemporaryDirectory("omo-v2-project-")
|
||||
const otherDirectory = createTemporaryDirectory("omo-v2-other-")
|
||||
const matchingInstall = createInstallPath("omo-v2-match-install-")
|
||||
const missingInstall = createInstallPath("omo-v2-miss-install-")
|
||||
const userInstall = createInstallPath("omo-v2-user-install-")
|
||||
writeDatabase(pluginsHome, {
|
||||
version: 2,
|
||||
plugins: {
|
||||
"matching-project@market": [
|
||||
{
|
||||
scope: "project",
|
||||
projectPath: projectDirectory,
|
||||
installPath: matchingInstall,
|
||||
version: "1.0.0",
|
||||
installedAt: "2026-03-25T00:00:00Z",
|
||||
lastUpdated: "2026-03-25T00:00:00Z",
|
||||
},
|
||||
],
|
||||
"other-project@market": [
|
||||
{
|
||||
scope: "project",
|
||||
projectPath: otherDirectory,
|
||||
installPath: missingInstall,
|
||||
version: "1.0.0",
|
||||
installedAt: "2026-03-25T00:00:00Z",
|
||||
lastUpdated: "2026-03-25T00:00:00Z",
|
||||
},
|
||||
],
|
||||
"global-user@market": [
|
||||
{
|
||||
scope: "user",
|
||||
installPath: userInstall,
|
||||
version: "2.0.0",
|
||||
installedAt: "2026-03-25T00:00:00Z",
|
||||
lastUpdated: "2026-03-25T00:00:00Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
process.chdir(projectDirectory)
|
||||
|
||||
//#when
|
||||
const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v2-mix`)
|
||||
const discovered = discoverInstalledPlugins({
|
||||
pluginsHomeOverride: pluginsHome,
|
||||
loadPluginManifestOverride: () => null,
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(discovered.errors).toHaveLength(0)
|
||||
const names = discovered.plugins.map((plugin) => plugin.name).sort()
|
||||
expect(names).toEqual(["global-user", "matching-project"])
|
||||
})
|
||||
|
||||
it("#when scope is local and cwd matches projectPath #then it loads", async () => {
|
||||
//#given
|
||||
const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string
|
||||
const projectDirectory = createTemporaryDirectory("omo-v2-local-match-")
|
||||
const installPath = createInstallPath("omo-v2-local-install-")
|
||||
writeDatabase(pluginsHome, {
|
||||
version: 2,
|
||||
plugins: {
|
||||
"local-plugin@market": [
|
||||
{
|
||||
scope: "local",
|
||||
projectPath: projectDirectory,
|
||||
installPath,
|
||||
version: "1.0.0",
|
||||
installedAt: "2026-03-25T00:00:00Z",
|
||||
lastUpdated: "2026-03-25T00:00:00Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
process.chdir(projectDirectory)
|
||||
|
||||
//#when
|
||||
const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v2-local-match`)
|
||||
const discovered = discoverInstalledPlugins({
|
||||
pluginsHomeOverride: pluginsHome,
|
||||
loadPluginManifestOverride: () => null,
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(discovered.errors).toHaveLength(0)
|
||||
expect(discovered.plugins).toHaveLength(1)
|
||||
expect(discovered.plugins[0]?.name).toBe("local-plugin")
|
||||
})
|
||||
|
||||
it("#when scope is local and cwd does not match projectPath #then it is skipped", async () => {
|
||||
//#given
|
||||
const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string
|
||||
const projectDirectory = createTemporaryDirectory("omo-v2-local-miss-")
|
||||
const otherDirectory = createTemporaryDirectory("omo-v2-local-other-")
|
||||
const installPath = createInstallPath("omo-v2-local-install-")
|
||||
writeDatabase(pluginsHome, {
|
||||
version: 2,
|
||||
plugins: {
|
||||
"local-plugin@market": [
|
||||
{
|
||||
scope: "local",
|
||||
projectPath: projectDirectory,
|
||||
installPath,
|
||||
version: "1.0.0",
|
||||
installedAt: "2026-03-25T00:00:00Z",
|
||||
lastUpdated: "2026-03-25T00:00:00Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
process.chdir(otherDirectory)
|
||||
|
||||
//#when
|
||||
const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v2-local-miss`)
|
||||
const discovered = discoverInstalledPlugins({
|
||||
pluginsHomeOverride: pluginsHome,
|
||||
loadPluginManifestOverride: () => null,
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(discovered.errors).toHaveLength(0)
|
||||
expect(discovered.plugins).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("#when multiple installations are present #then only the first is considered and scope filtering still applies", async () => {
|
||||
//#given
|
||||
const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string
|
||||
const projectDirectory = createTemporaryDirectory("omo-v2-multi-")
|
||||
const otherDirectory = createTemporaryDirectory("omo-v2-multi-other-")
|
||||
const primaryInstall = createInstallPath("omo-v2-multi-primary-")
|
||||
const secondaryInstall = createInstallPath("omo-v2-multi-secondary-")
|
||||
writeDatabase(pluginsHome, {
|
||||
version: 2,
|
||||
plugins: {
|
||||
"multi-plugin@market": [
|
||||
{
|
||||
scope: "project",
|
||||
projectPath: otherDirectory,
|
||||
installPath: primaryInstall,
|
||||
version: "1.0.0",
|
||||
installedAt: "2026-03-25T00:00:00Z",
|
||||
lastUpdated: "2026-03-25T00:00:00Z",
|
||||
},
|
||||
{
|
||||
scope: "project",
|
||||
projectPath: projectDirectory,
|
||||
installPath: secondaryInstall,
|
||||
version: "2.0.0",
|
||||
installedAt: "2026-03-25T00:00:00Z",
|
||||
lastUpdated: "2026-03-25T00:00:00Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
process.chdir(projectDirectory)
|
||||
|
||||
//#when
|
||||
const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v2-multi`)
|
||||
const discovered = discoverInstalledPlugins({
|
||||
pluginsHomeOverride: pluginsHome,
|
||||
loadPluginManifestOverride: () => null,
|
||||
})
|
||||
|
||||
//#then — existing behavior keeps only the first entry; with scope filter it is
|
||||
// (correctly) skipped because the first entry points at a different project.
|
||||
expect(discovered.errors).toHaveLength(0)
|
||||
expect(discovered.plugins).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given project and local scoped entries in v3 flat-array format", () => {
|
||||
it("#when cwd matches projectPath #then projectPath flows through and the plugin loads", async () => {
|
||||
//#given
|
||||
const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string
|
||||
const projectDirectory = createTemporaryDirectory("omo-v3-match-")
|
||||
const installPath = createInstallPath("omo-v3-install-")
|
||||
writeDatabase(pluginsHome, [
|
||||
{
|
||||
name: "v3-project-plugin",
|
||||
marketplace: "market",
|
||||
scope: "project",
|
||||
projectPath: projectDirectory,
|
||||
installPath,
|
||||
version: "1.0.0",
|
||||
lastUpdated: "2026-03-25T00:00:00Z",
|
||||
},
|
||||
])
|
||||
process.chdir(projectDirectory)
|
||||
|
||||
//#when
|
||||
const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v3-match`)
|
||||
const discovered = discoverInstalledPlugins({
|
||||
pluginsHomeOverride: pluginsHome,
|
||||
loadPluginManifestOverride: () => null,
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(discovered.errors).toHaveLength(0)
|
||||
expect(discovered.plugins).toHaveLength(1)
|
||||
expect(discovered.plugins[0]?.name).toBe("v3-project-plugin")
|
||||
})
|
||||
|
||||
it("#when cwd does not match projectPath #then the plugin is skipped", async () => {
|
||||
//#given
|
||||
const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string
|
||||
const projectDirectory = createTemporaryDirectory("omo-v3-miss-")
|
||||
const otherDirectory = createTemporaryDirectory("omo-v3-miss-other-")
|
||||
const installPath = createInstallPath("omo-v3-install-")
|
||||
writeDatabase(pluginsHome, [
|
||||
{
|
||||
name: "v3-skipped-plugin",
|
||||
marketplace: "market",
|
||||
scope: "project",
|
||||
projectPath: projectDirectory,
|
||||
installPath,
|
||||
version: "1.0.0",
|
||||
lastUpdated: "2026-03-25T00:00:00Z",
|
||||
},
|
||||
{
|
||||
name: "v3-user-plugin",
|
||||
marketplace: "market",
|
||||
scope: "user",
|
||||
installPath: createInstallPath("omo-v3-user-install-"),
|
||||
version: "2.0.0",
|
||||
lastUpdated: "2026-03-25T00:00:00Z",
|
||||
},
|
||||
])
|
||||
process.chdir(otherDirectory)
|
||||
|
||||
//#when
|
||||
const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v3-miss`)
|
||||
const discovered = discoverInstalledPlugins({
|
||||
pluginsHomeOverride: pluginsHome,
|
||||
loadPluginManifestOverride: () => null,
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(discovered.errors).toHaveLength(0)
|
||||
expect(discovered.plugins).toHaveLength(1)
|
||||
expect(discovered.plugins[0]?.name).toBe("v3-user-plugin")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given enabledPluginsOverride combined with scope filtering", () => {
|
||||
it("#when a project-scoped plugin is disabled via override #then it is still skipped even if cwd would match", async () => {
|
||||
//#given
|
||||
const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string
|
||||
const projectDirectory = createTemporaryDirectory("omo-enabled-proj-")
|
||||
const installPath = createInstallPath("omo-enabled-install-")
|
||||
writeDatabase(pluginsHome, {
|
||||
version: 2,
|
||||
plugins: {
|
||||
"gated-plugin@market": [
|
||||
{
|
||||
scope: "project",
|
||||
projectPath: projectDirectory,
|
||||
installPath,
|
||||
version: "1.0.0",
|
||||
installedAt: "2026-03-25T00:00:00Z",
|
||||
lastUpdated: "2026-03-25T00:00:00Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
process.chdir(projectDirectory)
|
||||
|
||||
//#when
|
||||
const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-enabled-off`)
|
||||
const discovered = discoverInstalledPlugins({
|
||||
pluginsHomeOverride: pluginsHome,
|
||||
loadPluginManifestOverride: () => null,
|
||||
enabledPluginsOverride: { "gated-plugin@market": false },
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(discovered.errors).toHaveLength(0)
|
||||
expect(discovered.plugins).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("#when a project-scoped plugin is enabled and cwd matches #then it loads", async () => {
|
||||
//#given
|
||||
const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string
|
||||
const projectDirectory = createTemporaryDirectory("omo-enabled-match-")
|
||||
const installPath = createInstallPath("omo-enabled-match-install-")
|
||||
writeDatabase(pluginsHome, {
|
||||
version: 2,
|
||||
plugins: {
|
||||
"enabled-plugin@market": [
|
||||
{
|
||||
scope: "project",
|
||||
projectPath: projectDirectory,
|
||||
installPath,
|
||||
version: "1.0.0",
|
||||
installedAt: "2026-03-25T00:00:00Z",
|
||||
lastUpdated: "2026-03-25T00:00:00Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
process.chdir(projectDirectory)
|
||||
|
||||
//#when
|
||||
const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-enabled-on`)
|
||||
const discovered = discoverInstalledPlugins({
|
||||
pluginsHomeOverride: pluginsHome,
|
||||
loadPluginManifestOverride: () => null,
|
||||
enabledPluginsOverride: { "enabled-plugin@market": true },
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(discovered.errors).toHaveLength(0)
|
||||
expect(discovered.plugins).toHaveLength(1)
|
||||
expect(discovered.plugins[0]?.name).toBe("enabled-plugin")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { homedir } from "os"
|
||||
import { basename, join } from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { log } from "../../shared/logger"
|
||||
import { shouldLoadPluginForCwd } from "./scope-filter"
|
||||
import type {
|
||||
InstalledPluginsDatabase,
|
||||
InstalledPluginEntryV3,
|
||||
@@ -132,6 +133,7 @@ function v3EntryToInstallation(entry: InstalledPluginEntryV3): PluginInstallatio
|
||||
installedAt: entry.lastUpdated,
|
||||
lastUpdated: entry.lastUpdated,
|
||||
gitCommitSha: entry.gitCommitSha,
|
||||
projectPath: entry.projectPath,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,6 +179,7 @@ export function discoverInstalledPlugins(options?: PluginLoaderOptions): PluginL
|
||||
const settingsEnabledPlugins = settings?.enabledPlugins
|
||||
const overrideEnabledPlugins = options?.enabledPluginsOverride
|
||||
const pluginManifestLoader = options?.loadPluginManifestOverride ?? loadPluginManifest
|
||||
const cwd = process.cwd()
|
||||
|
||||
for (const [pluginKey, installation] of extractPluginEntries(db)) {
|
||||
if (!installation) continue
|
||||
@@ -186,6 +189,14 @@ export function discoverInstalledPlugins(options?: PluginLoaderOptions): PluginL
|
||||
continue
|
||||
}
|
||||
|
||||
if (!shouldLoadPluginForCwd(installation, cwd)) {
|
||||
log(`Skipping ${installation.scope}-scoped plugin outside current cwd: ${pluginKey}`, {
|
||||
projectPath: installation.projectPath,
|
||||
cwd,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const { installPath, scope, version } = installation
|
||||
|
||||
if (!existsSync(installPath)) {
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
|
||||
import { mkdtempSync, rmSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { shouldLoadPluginForCwd } from "./scope-filter"
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
function createTemporaryDirectory(prefix: string): string {
|
||||
const directory = mkdtempSync(join(tmpdir(), prefix))
|
||||
temporaryDirectories.push(directory)
|
||||
return directory
|
||||
}
|
||||
|
||||
describe("shouldLoadPluginForCwd", () => {
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
|
||||
for (const directory of temporaryDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe("#given a user-scoped plugin", () => {
|
||||
it("#when called with any cwd #then it loads", () => {
|
||||
//#given
|
||||
const installation = { scope: "user" as const }
|
||||
|
||||
//#when
|
||||
const result = shouldLoadPluginForCwd(installation, "/tmp/anywhere")
|
||||
|
||||
//#then
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given a managed-scoped plugin", () => {
|
||||
it("#when called with any cwd #then it loads", () => {
|
||||
//#given
|
||||
const installation = { scope: "managed" as const }
|
||||
|
||||
//#when
|
||||
const result = shouldLoadPluginForCwd(installation, "/tmp/anywhere")
|
||||
|
||||
//#then
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given a project-scoped plugin without projectPath", () => {
|
||||
it("#when called with any cwd #then it is skipped", () => {
|
||||
//#given
|
||||
const installation = { scope: "project" as const }
|
||||
|
||||
//#when
|
||||
const result = shouldLoadPluginForCwd(installation, "/tmp/anywhere")
|
||||
|
||||
//#then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given a local-scoped plugin without projectPath", () => {
|
||||
it("#when called with any cwd #then it is skipped", () => {
|
||||
//#given
|
||||
const installation = { scope: "local" as const }
|
||||
|
||||
//#when
|
||||
const result = shouldLoadPluginForCwd(installation, "/tmp/anywhere")
|
||||
|
||||
//#then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given a project-scoped plugin with matching projectPath", () => {
|
||||
it("#when cwd exactly matches projectPath #then it loads", () => {
|
||||
//#given
|
||||
const projectDirectory = createTemporaryDirectory("omo-scope-")
|
||||
const installation = {
|
||||
scope: "project" as const,
|
||||
projectPath: projectDirectory,
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = shouldLoadPluginForCwd(installation, projectDirectory)
|
||||
|
||||
//#then
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("#when cwd is a subdirectory of projectPath #then it loads", () => {
|
||||
//#given
|
||||
const projectDirectory = createTemporaryDirectory("omo-scope-")
|
||||
const installation = {
|
||||
scope: "project" as const,
|
||||
projectPath: projectDirectory,
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = shouldLoadPluginForCwd(installation, join(projectDirectory, "packages", "app"))
|
||||
|
||||
//#then
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given a project-scoped plugin with non-matching projectPath", () => {
|
||||
it("#when cwd is unrelated #then it is skipped", () => {
|
||||
//#given
|
||||
const projectDirectory = createTemporaryDirectory("omo-scope-")
|
||||
const otherDirectory = createTemporaryDirectory("omo-other-")
|
||||
const installation = {
|
||||
scope: "project" as const,
|
||||
projectPath: projectDirectory,
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = shouldLoadPluginForCwd(installation, otherDirectory)
|
||||
|
||||
//#then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("#when cwd is the parent of projectPath #then it is skipped", () => {
|
||||
//#given
|
||||
const projectDirectory = createTemporaryDirectory("omo-scope-")
|
||||
const installation = {
|
||||
scope: "project" as const,
|
||||
projectPath: join(projectDirectory, "nested"),
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = shouldLoadPluginForCwd(installation, projectDirectory)
|
||||
|
||||
//#then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given a local-scoped plugin with matching projectPath", () => {
|
||||
it("#when cwd matches projectPath #then it loads", () => {
|
||||
//#given
|
||||
const projectDirectory = createTemporaryDirectory("omo-scope-")
|
||||
const installation = {
|
||||
scope: "local" as const,
|
||||
projectPath: projectDirectory,
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = shouldLoadPluginForCwd(installation, projectDirectory)
|
||||
|
||||
//#then
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given a local-scoped plugin with non-matching projectPath", () => {
|
||||
it("#when cwd is unrelated #then it is skipped", () => {
|
||||
//#given
|
||||
const projectDirectory = createTemporaryDirectory("omo-scope-")
|
||||
const otherDirectory = createTemporaryDirectory("omo-other-")
|
||||
const installation = {
|
||||
scope: "local" as const,
|
||||
projectPath: projectDirectory,
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = shouldLoadPluginForCwd(installation, otherDirectory)
|
||||
|
||||
//#then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given a project-scoped plugin with a tilde-prefixed projectPath", () => {
|
||||
let fakeHome: string
|
||||
|
||||
beforeEach(() => {
|
||||
fakeHome = createTemporaryDirectory("omo-home-")
|
||||
mock.module("node:os", () => ({
|
||||
homedir: () => fakeHome,
|
||||
tmpdir,
|
||||
}))
|
||||
mock.module("os", () => ({
|
||||
homedir: () => fakeHome,
|
||||
tmpdir,
|
||||
}))
|
||||
})
|
||||
|
||||
it("#when the expanded home matches cwd #then it loads", async () => {
|
||||
//#given
|
||||
const { shouldLoadPluginForCwd: freshShouldLoad } = await import(
|
||||
`./scope-filter?t=${Date.now()}-tilde-match`
|
||||
)
|
||||
const installation = {
|
||||
scope: "project" as const,
|
||||
projectPath: "~/workspace/proj-a",
|
||||
}
|
||||
const cwd = join(fakeHome, "workspace", "proj-a")
|
||||
|
||||
//#when
|
||||
const result = freshShouldLoad(installation, cwd)
|
||||
|
||||
//#then
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("#when the expanded home does not match cwd #then it is skipped", async () => {
|
||||
//#given
|
||||
const { shouldLoadPluginForCwd: freshShouldLoad } = await import(
|
||||
`./scope-filter?t=${Date.now()}-tilde-mismatch`
|
||||
)
|
||||
const installation = {
|
||||
scope: "project" as const,
|
||||
projectPath: "~/workspace/proj-a",
|
||||
}
|
||||
const cwd = join(fakeHome, "workspace", "proj-b")
|
||||
|
||||
//#when
|
||||
const result = freshShouldLoad(installation, cwd)
|
||||
|
||||
//#then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("#when projectPath is exactly ~ and cwd equals fake home #then it loads", async () => {
|
||||
//#given
|
||||
const { shouldLoadPluginForCwd: freshShouldLoad } = await import(
|
||||
`./scope-filter?t=${Date.now()}-tilde-root`
|
||||
)
|
||||
const installation = {
|
||||
scope: "project" as const,
|
||||
projectPath: "~",
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = freshShouldLoad(installation, fakeHome)
|
||||
|
||||
//#then
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import { homedir } from "os"
|
||||
import { join } from "path"
|
||||
import { containsPath } from "../../shared/contains-path"
|
||||
import type { PluginInstallation } from "./types"
|
||||
|
||||
function expandTilde(inputPath: string): string {
|
||||
if (inputPath === "~") {
|
||||
return homedir()
|
||||
}
|
||||
if (inputPath.startsWith("~/") || inputPath.startsWith("~\\")) {
|
||||
return join(homedir(), inputPath.slice(2))
|
||||
}
|
||||
return inputPath
|
||||
}
|
||||
|
||||
export function shouldLoadPluginForCwd(
|
||||
installation: Pick<PluginInstallation, "scope" | "projectPath">,
|
||||
cwd: string = process.cwd(),
|
||||
): boolean {
|
||||
if (installation.scope !== "project" && installation.scope !== "local") {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!installation.projectPath) {
|
||||
return false
|
||||
}
|
||||
|
||||
return containsPath(expandTilde(installation.projectPath), cwd)
|
||||
}
|
||||
@@ -18,6 +18,12 @@ export interface PluginInstallation {
|
||||
lastUpdated: string
|
||||
gitCommitSha?: string
|
||||
isLocal?: boolean
|
||||
/**
|
||||
* Claude Code records this on project/local-scoped installations.
|
||||
* Absolute path (or `~`-prefixed) of the project the plugin was installed for.
|
||||
* Used to filter project/local plugins that do not belong to the current cwd.
|
||||
*/
|
||||
projectPath?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,6 +57,11 @@ export interface InstalledPluginEntryV3 {
|
||||
installPath: string
|
||||
lastUpdated: string
|
||||
gitCommitSha?: string
|
||||
/**
|
||||
* Claude Code records this on project/local-scoped installations.
|
||||
* Absolute path (or `~`-prefixed) of the project the plugin was installed for.
|
||||
*/
|
||||
projectPath?: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user