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 originalClaudePluginsHome = process.env.CLAUDE_PLUGINS_HOME
|
||||||
const temporaryDirectories: string[] = []
|
const temporaryDirectories: string[] = []
|
||||||
|
const originalCwd = process.cwd()
|
||||||
|
|
||||||
function createTemporaryDirectory(prefix: string): string {
|
function createTemporaryDirectory(prefix: string): string {
|
||||||
const directory = mkdtempSync(join(tmpdir(), prefix))
|
const directory = mkdtempSync(join(tmpdir(), prefix))
|
||||||
@@ -17,6 +18,14 @@ function createTemporaryDirectory(prefix: string): string {
|
|||||||
return directory
|
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", () => {
|
describe("discoverInstalledPlugins", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mock.module("../../shared/logger", () => ({
|
mock.module("../../shared/logger", () => ({
|
||||||
@@ -36,6 +45,10 @@ describe("discoverInstalledPlugins", () => {
|
|||||||
process.env.CLAUDE_PLUGINS_HOME = originalClaudePluginsHome
|
process.env.CLAUDE_PLUGINS_HOME = originalClaudePluginsHome
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (process.cwd() !== originalCwd) {
|
||||||
|
process.chdir(originalCwd)
|
||||||
|
}
|
||||||
|
|
||||||
for (const directory of temporaryDirectories.splice(0)) {
|
for (const directory of temporaryDirectories.splice(0)) {
|
||||||
rmSync(directory, { recursive: true, force: true })
|
rmSync(directory, { recursive: true, force: true })
|
||||||
}
|
}
|
||||||
@@ -156,4 +169,488 @@ describe("discoverInstalledPlugins", () => {
|
|||||||
expect(discovered.plugins).toHaveLength(1)
|
expect(discovered.plugins).toHaveLength(1)
|
||||||
expect(discovered.plugins[0]?.name).toBe("oh-my-openagent")
|
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 { basename, join } from "path"
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
|
import { shouldLoadPluginForCwd } from "./scope-filter"
|
||||||
import type {
|
import type {
|
||||||
InstalledPluginsDatabase,
|
InstalledPluginsDatabase,
|
||||||
InstalledPluginEntryV3,
|
InstalledPluginEntryV3,
|
||||||
@@ -132,6 +133,7 @@ function v3EntryToInstallation(entry: InstalledPluginEntryV3): PluginInstallatio
|
|||||||
installedAt: entry.lastUpdated,
|
installedAt: entry.lastUpdated,
|
||||||
lastUpdated: entry.lastUpdated,
|
lastUpdated: entry.lastUpdated,
|
||||||
gitCommitSha: entry.gitCommitSha,
|
gitCommitSha: entry.gitCommitSha,
|
||||||
|
projectPath: entry.projectPath,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,6 +179,7 @@ export function discoverInstalledPlugins(options?: PluginLoaderOptions): PluginL
|
|||||||
const settingsEnabledPlugins = settings?.enabledPlugins
|
const settingsEnabledPlugins = settings?.enabledPlugins
|
||||||
const overrideEnabledPlugins = options?.enabledPluginsOverride
|
const overrideEnabledPlugins = options?.enabledPluginsOverride
|
||||||
const pluginManifestLoader = options?.loadPluginManifestOverride ?? loadPluginManifest
|
const pluginManifestLoader = options?.loadPluginManifestOverride ?? loadPluginManifest
|
||||||
|
const cwd = process.cwd()
|
||||||
|
|
||||||
for (const [pluginKey, installation] of extractPluginEntries(db)) {
|
for (const [pluginKey, installation] of extractPluginEntries(db)) {
|
||||||
if (!installation) continue
|
if (!installation) continue
|
||||||
@@ -186,6 +189,14 @@ export function discoverInstalledPlugins(options?: PluginLoaderOptions): PluginL
|
|||||||
continue
|
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
|
const { installPath, scope, version } = installation
|
||||||
|
|
||||||
if (!existsSync(installPath)) {
|
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
|
lastUpdated: string
|
||||||
gitCommitSha?: string
|
gitCommitSha?: string
|
||||||
isLocal?: boolean
|
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
|
installPath: string
|
||||||
lastUpdated: string
|
lastUpdated: string
|
||||||
gitCommitSha?: 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