Skip to content

Commit 653f9cd

Browse files
gor-stclaude
andauthored
feat: support local plugin marketplace paths (#761)
* feat: support local plugin marketplace paths Enable installing plugins from local directories in addition to remote Git URLs. This allows users to use local plugin marketplaces within their repository without requiring them to be hosted in a separate Git repo. Example usage: plugin_marketplaces: "./my-local-marketplace" plugins: "my-plugin@my-local-marketplace" Supported path formats: - Relative paths: ./plugins, ../shared-plugins - Absolute Unix paths: /home/user/plugins - Absolute Windows paths: C:\Users\user\plugins Fixes #664 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * support hidden folders * Revert "support hidden folders" This reverts commit a55626c. --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent b17b541 commit 653f9cd

File tree

2 files changed

+150
-22
lines changed

2 files changed

+150
-22
lines changed

base-action/src/install-plugins.ts

Lines changed: 43 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,26 +8,47 @@ const MARKETPLACE_URL_REGEX =
88
/^https:\/\/[a-zA-Z0-9\-._~:/?#[\]@!$&'()*+,;=%]+\.git$/;
99

1010
/**
11-
* Validates a marketplace URL for security issues
12-
* @param url - The marketplace URL to validate
13-
* @throws {Error} If the URL is invalid
11+
* Checks if a marketplace input is a local path (not a URL)
12+
* @param input - The marketplace input to check
13+
* @returns true if the input is a local path, false if it's a URL
1414
*/
15-
function validateMarketplaceUrl(url: string): void {
16-
const normalized = url.trim();
15+
function isLocalPath(input: string): boolean {
16+
// Local paths start with ./, ../, /, or a drive letter (Windows)
17+
return (
18+
input.startsWith("./") ||
19+
input.startsWith("../") ||
20+
input.startsWith("/") ||
21+
/^[a-zA-Z]:[\\\/]/.test(input)
22+
);
23+
}
24+
25+
/**
26+
* Validates a marketplace URL or local path
27+
* @param input - The marketplace URL or local path to validate
28+
* @throws {Error} If the input is invalid
29+
*/
30+
function validateMarketplaceInput(input: string): void {
31+
const normalized = input.trim();
1732

1833
if (!normalized) {
19-
throw new Error("Marketplace URL cannot be empty");
34+
throw new Error("Marketplace URL or path cannot be empty");
35+
}
36+
37+
// Local paths are passed directly to Claude Code which handles them
38+
if (isLocalPath(normalized)) {
39+
return;
2040
}
2141

42+
// Validate as URL
2243
if (!MARKETPLACE_URL_REGEX.test(normalized)) {
23-
throw new Error(`Invalid marketplace URL format: ${url}`);
44+
throw new Error(`Invalid marketplace URL format: ${input}`);
2445
}
2546

2647
// Additional check for valid URL structure
2748
try {
2849
new URL(normalized);
2950
} catch {
30-
throw new Error(`Invalid marketplace URL: ${url}`);
51+
throw new Error(`Invalid marketplace URL: ${input}`);
3152
}
3253
}
3354

@@ -55,9 +76,9 @@ function validatePluginName(pluginName: string): void {
5576
}
5677

5778
/**
58-
* Parse a newline-separated list of marketplace URLs and return an array of validated URLs
59-
* @param marketplaces - Newline-separated list of marketplace Git URLs
60-
* @returns Array of validated marketplace URLs (empty array if none provided)
79+
* Parse a newline-separated list of marketplace URLs or local paths and return an array of validated entries
80+
* @param marketplaces - Newline-separated list of marketplace Git URLs or local paths
81+
* @returns Array of validated marketplace URLs or paths (empty array if none provided)
6182
*/
6283
function parseMarketplaces(marketplaces?: string): string[] {
6384
const trimmed = marketplaces?.trim();
@@ -66,14 +87,14 @@ function parseMarketplaces(marketplaces?: string): string[] {
6687
return [];
6788
}
6889

69-
// Split by newline and process each URL
90+
// Split by newline and process each entry
7091
return trimmed
7192
.split("\n")
72-
.map((url) => url.trim())
73-
.filter((url) => {
74-
if (url.length === 0) return false;
93+
.map((entry) => entry.trim())
94+
.filter((entry) => {
95+
if (entry.length === 0) return false;
7596

76-
validateMarketplaceUrl(url);
97+
validateMarketplaceInput(entry);
7798
return true;
7899
});
79100
}
@@ -163,26 +184,26 @@ async function installPlugin(
163184
/**
164185
* Adds a Claude Code plugin marketplace
165186
* @param claudeExecutable - Path to the Claude executable
166-
* @param marketplaceUrl - The marketplace Git URL to add
187+
* @param marketplace - The marketplace Git URL or local path to add
167188
* @returns Promise that resolves when the marketplace add command completes
168189
* @throws {Error} If the command fails to execute
169190
*/
170191
async function addMarketplace(
171192
claudeExecutable: string,
172-
marketplaceUrl: string,
193+
marketplace: string,
173194
): Promise<void> {
174-
console.log(`Adding marketplace: ${marketplaceUrl}`);
195+
console.log(`Adding marketplace: ${marketplace}`);
175196

176197
return executeClaudeCommand(
177198
claudeExecutable,
178-
["plugin", "marketplace", "add", marketplaceUrl],
179-
`Failed to add marketplace '${marketplaceUrl}'`,
199+
["plugin", "marketplace", "add", marketplace],
200+
`Failed to add marketplace '${marketplace}'`,
180201
);
181202
}
182203

183204
/**
184205
* Installs Claude Code plugins from a newline-separated list
185-
* @param marketplacesInput - Newline-separated list of marketplace Git URLs
206+
* @param marketplacesInput - Newline-separated list of marketplace Git URLs or local paths
186207
* @param pluginsInput - Newline-separated list of plugin names
187208
* @param claudeExecutable - Path to the Claude executable (defaults to "claude")
188209
* @returns Promise that resolves when all plugins are installed

base-action/test/install-plugins.test.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -596,4 +596,111 @@ describe("installPlugins", () => {
596596
{ stdio: "inherit" },
597597
);
598598
});
599+
600+
// Local marketplace path tests
601+
test("should accept local marketplace path with ./", async () => {
602+
const spy = createMockSpawn();
603+
await installPlugins("./my-local-marketplace", "test-plugin");
604+
605+
expect(spy).toHaveBeenCalledTimes(2);
606+
expect(spy).toHaveBeenNthCalledWith(
607+
1,
608+
"claude",
609+
["plugin", "marketplace", "add", "./my-local-marketplace"],
610+
{ stdio: "inherit" },
611+
);
612+
expect(spy).toHaveBeenNthCalledWith(
613+
2,
614+
"claude",
615+
["plugin", "install", "test-plugin"],
616+
{ stdio: "inherit" },
617+
);
618+
});
619+
620+
test("should accept local marketplace path with absolute Unix path", async () => {
621+
const spy = createMockSpawn();
622+
await installPlugins("/home/user/my-marketplace", "test-plugin");
623+
624+
expect(spy).toHaveBeenCalledTimes(2);
625+
expect(spy).toHaveBeenNthCalledWith(
626+
1,
627+
"claude",
628+
["plugin", "marketplace", "add", "/home/user/my-marketplace"],
629+
{ stdio: "inherit" },
630+
);
631+
});
632+
633+
test("should accept local marketplace path with Windows absolute path", async () => {
634+
const spy = createMockSpawn();
635+
await installPlugins("C:\\Users\\user\\marketplace", "test-plugin");
636+
637+
expect(spy).toHaveBeenCalledTimes(2);
638+
expect(spy).toHaveBeenNthCalledWith(
639+
1,
640+
"claude",
641+
["plugin", "marketplace", "add", "C:\\Users\\user\\marketplace"],
642+
{ stdio: "inherit" },
643+
);
644+
});
645+
646+
test("should accept mixed local and remote marketplaces", async () => {
647+
const spy = createMockSpawn();
648+
await installPlugins(
649+
"./local-marketplace\nhttps://github.com/user/remote.git",
650+
"test-plugin",
651+
);
652+
653+
expect(spy).toHaveBeenCalledTimes(3);
654+
expect(spy).toHaveBeenNthCalledWith(
655+
1,
656+
"claude",
657+
["plugin", "marketplace", "add", "./local-marketplace"],
658+
{ stdio: "inherit" },
659+
);
660+
expect(spy).toHaveBeenNthCalledWith(
661+
2,
662+
"claude",
663+
["plugin", "marketplace", "add", "https://github.com/user/remote.git"],
664+
{ stdio: "inherit" },
665+
);
666+
});
667+
668+
test("should accept local path with ../ (parent directory)", async () => {
669+
const spy = createMockSpawn();
670+
await installPlugins("../shared-plugins/marketplace", "test-plugin");
671+
672+
expect(spy).toHaveBeenCalledTimes(2);
673+
expect(spy).toHaveBeenNthCalledWith(
674+
1,
675+
"claude",
676+
["plugin", "marketplace", "add", "../shared-plugins/marketplace"],
677+
{ stdio: "inherit" },
678+
);
679+
});
680+
681+
test("should accept local path with nested directories", async () => {
682+
const spy = createMockSpawn();
683+
await installPlugins("./plugins/my-org/my-marketplace", "test-plugin");
684+
685+
expect(spy).toHaveBeenCalledTimes(2);
686+
expect(spy).toHaveBeenNthCalledWith(
687+
1,
688+
"claude",
689+
["plugin", "marketplace", "add", "./plugins/my-org/my-marketplace"],
690+
{ stdio: "inherit" },
691+
);
692+
});
693+
694+
test("should accept local path with dots in directory name", async () => {
695+
const spy = createMockSpawn();
696+
await installPlugins("./my.plugin.marketplace", "test-plugin");
697+
698+
expect(spy).toHaveBeenCalledTimes(2);
699+
expect(spy).toHaveBeenNthCalledWith(
700+
1,
701+
"claude",
702+
["plugin", "marketplace", "add", "./my.plugin.marketplace"],
703+
{ stdio: "inherit" },
704+
);
705+
});
599706
});

0 commit comments

Comments
 (0)