feat: change plugins input from comma-separated to newline-separated (#644)

* feat: change plugins input from comma-separated to newline-separated

Changes:
- Update parsePlugins() to split by newline instead of comma for consistency with marketplaces input
- Update action.yml and base-action/action.yml with newline-separated format and realistic plugin examples
- Add plugin_marketplaces documentation to docs/usage.md
- Update all unit tests to match new installPlugins() signature (marketplaces, plugins, executable)
- Improve JSDoc comments for parsePlugins() and installPlugin() functions
- All 25 install-plugins tests passing

Breaking change: Users must update their workflows to use newline-separated format:
  Before: plugins: "plugin1,plugin2"
  After: plugins: "plugin1\nplugin2"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* test: add comprehensive marketplace functionality tests

Critical fix: All previous tests passed undefined as marketplacesInput parameter,
leaving the entire marketplace functionality completely untested.

Added 13 new tests covering:
- Single marketplace installation
- Multiple marketplaces with newline separation
- Marketplace + plugin installation order verification
- Marketplace URL validation (format, protocol, .git extension)
- Whitespace and empty entry handling
- Error handling for marketplace operations
- Custom executable path for marketplace operations

Test coverage: 38 tests (was 25), 81 expect calls (was 50)
All tests passing 

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Wanghong Yuan
2025-10-27 09:01:34 -07:00
committed by GitHub
parent 8ad13bd20b
commit 29fe50368c
6 changed files with 319 additions and 61 deletions

View File

@@ -102,7 +102,7 @@ inputs:
required: false required: false
default: "" default: ""
plugins: plugins:
description: "Comma-separated list of Claude Code plugin names to install (e.g., 'plugin1,plugin2,plugin3')" description: "Newline-separated list of Claude Code plugin names to install (e.g., 'code-review@claude-code-plugins\nfeature-dev@claude-code-plugins')"
required: false required: false
default: "" default: ""
plugin_marketplaces: plugin_marketplaces:

View File

@@ -56,7 +56,7 @@ inputs:
required: false required: false
default: "" default: ""
plugins: plugins:
description: "Comma-separated list of Claude Code plugin names to install (e.g., 'plugin1,plugin2,plugin3')" description: "Newline-separated list of Claude Code plugin names to install (e.g., 'code-review@claude-code-plugins\nfeature-dev@claude-code-plugins')"
required: false required: false
default: "" default: ""
plugin_marketplaces: plugin_marketplaces:

View File

@@ -18,9 +18,9 @@ async function run() {
// Install Claude Code plugins if specified // Install Claude Code plugins if specified
await installPlugins( await installPlugins(
process.env.INPUT_PLUGIN_MARKETPLACES,
process.env.INPUT_PLUGINS, process.env.INPUT_PLUGINS,
process.env.INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE, process.env.INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE,
process.env.INPUT_PLUGIN_MARKETPLACES,
); );
const promptConfig = await preparePrompt({ const promptConfig = await preparePrompt({

View File

@@ -79,10 +79,13 @@ function parseMarketplaces(marketplaces?: string): string[] {
} }
/** /**
* Parse a comma-separated list of plugin names and return an array of trimmed, non-empty plugin names * Parse a newline-separated list of plugin names and return an array of trimmed, non-empty plugin names
* Validates plugin names to prevent command injection and path traversal attacks * Validates plugin names to prevent command injection and path traversal attacks
* Allows: letters, numbers, @, -, _, /, . (common npm/scoped package characters) * Allows: letters, numbers, @, -, _, /, . (common npm/scoped package characters)
* Disallows: path traversal (../, ./), shell metacharacters, and consecutive dots * Disallows: path traversal (../, ./), shell metacharacters, and consecutive dots
* @param plugins - Newline-separated list of plugin names, or undefined/empty to return empty array
* @returns Array of validated plugin names (empty array if none provided)
* @throws {Error} If any plugin name fails validation
*/ */
function parsePlugins(plugins?: string): string[] { function parsePlugins(plugins?: string): string[] {
const trimmedPlugins = plugins?.trim(); const trimmedPlugins = plugins?.trim();
@@ -91,9 +94,9 @@ function parsePlugins(plugins?: string): string[] {
return []; return [];
} }
// Split by comma and process each plugin // Split by newline and process each plugin
return trimmedPlugins return trimmedPlugins
.split(",") .split("\n")
.map((p) => p.trim()) .map((p) => p.trim())
.filter((p) => { .filter((p) => {
if (p.length === 0) return false; if (p.length === 0) return false;
@@ -139,11 +142,17 @@ async function executeClaudeCommand(
/** /**
* Installs a single Claude Code plugin * Installs a single Claude Code plugin
* @param pluginName - The name of the plugin to install
* @param claudeExecutable - Path to the Claude executable
* @returns Promise that resolves when the plugin is installed successfully
* @throws {Error} If the plugin installation fails
*/ */
async function installPlugin( async function installPlugin(
pluginName: string, pluginName: string,
claudeExecutable: string, claudeExecutable: string,
): Promise<void> { ): Promise<void> {
console.log(`Installing plugin: ${pluginName}`);
return executeClaudeCommand( return executeClaudeCommand(
claudeExecutable, claudeExecutable,
["plugin", "install", pluginName], ["plugin", "install", pluginName],
@@ -172,25 +181,18 @@ async function addMarketplace(
} }
/** /**
* Installs Claude Code plugins from a comma-separated list * Installs Claude Code plugins from a newline-separated list
* @param pluginsInput - Comma-separated list of plugin names, or undefined/empty to skip installation
* @param claudeExecutable - Path to the Claude executable (defaults to "claude")
* @param marketplacesInput - Newline-separated list of marketplace Git URLs * @param marketplacesInput - Newline-separated list of marketplace Git URLs
* @param pluginsInput - Newline-separated list of plugin names
* @param claudeExecutable - Path to the Claude executable (defaults to "claude")
* @returns Promise that resolves when all plugins are installed * @returns Promise that resolves when all plugins are installed
* @throws {Error} If any plugin fails validation or installation (stops on first error) * @throws {Error} If any plugin fails validation or installation (stops on first error)
*/ */
export async function installPlugins( export async function installPlugins(
pluginsInput: string | undefined,
claudeExecutable?: string,
marketplacesInput?: string, marketplacesInput?: string,
pluginsInput?: string,
claudeExecutable?: string,
): Promise<void> { ): Promise<void> {
const plugins = parsePlugins(pluginsInput);
if (plugins.length === 0) {
console.log("No plugins to install");
return;
}
// Resolve executable path with explicit fallback // Resolve executable path with explicit fallback
const resolvedExecutable = claudeExecutable || "claude"; const resolvedExecutable = claudeExecutable || "claude";
@@ -207,13 +209,14 @@ export async function installPlugins(
console.log("No marketplaces specified, skipping marketplace setup"); console.log("No marketplaces specified, skipping marketplace setup");
} }
const plugins = parsePlugins(pluginsInput);
if (plugins.length > 0) {
console.log(`Installing ${plugins.length} plugin(s)...`); console.log(`Installing ${plugins.length} plugin(s)...`);
for (const plugin of plugins) { for (const plugin of plugins) {
console.log(`Installing plugin: ${plugin}`);
await installPlugin(plugin, resolvedExecutable); await installPlugin(plugin, resolvedExecutable);
console.log(`✓ Successfully installed: ${plugin}`); console.log(`✓ Successfully installed: ${plugin}`);
} }
} else {
console.log("All plugins installed successfully"); console.log("No plugins specified, skipping plugins installation");
}
} }

View File

@@ -39,25 +39,25 @@ describe("installPlugins", () => {
test("should not call spawn when no plugins are specified", async () => { test("should not call spawn when no plugins are specified", async () => {
const spy = createMockSpawn(); const spy = createMockSpawn();
await installPlugins(""); await installPlugins(undefined, "");
expect(spy).not.toHaveBeenCalled(); expect(spy).not.toHaveBeenCalled();
}); });
test("should not call spawn when plugins is undefined", async () => { test("should not call spawn when plugins is undefined", async () => {
const spy = createMockSpawn(); const spy = createMockSpawn();
await installPlugins(undefined); await installPlugins(undefined, undefined);
expect(spy).not.toHaveBeenCalled(); expect(spy).not.toHaveBeenCalled();
}); });
test("should not call spawn when plugins is only whitespace", async () => { test("should not call spawn when plugins is only whitespace", async () => {
const spy = createMockSpawn(); const spy = createMockSpawn();
await installPlugins(" "); await installPlugins(undefined, " ");
expect(spy).not.toHaveBeenCalled(); expect(spy).not.toHaveBeenCalled();
}); });
test("should install a single plugin with default executable", async () => { test("should install a single plugin with default executable", async () => {
const spy = createMockSpawn(); const spy = createMockSpawn();
await installPlugins("test-plugin"); await installPlugins(undefined, "test-plugin");
expect(spy).toHaveBeenCalledTimes(1); expect(spy).toHaveBeenCalledTimes(1);
// Only call: install plugin (no marketplace without explicit marketplace input) // Only call: install plugin (no marketplace without explicit marketplace input)
@@ -71,7 +71,7 @@ describe("installPlugins", () => {
test("should install multiple plugins sequentially", async () => { test("should install multiple plugins sequentially", async () => {
const spy = createMockSpawn(); const spy = createMockSpawn();
await installPlugins("plugin1,plugin2,plugin3"); await installPlugins(undefined, "plugin1\nplugin2\nplugin3");
expect(spy).toHaveBeenCalledTimes(3); expect(spy).toHaveBeenCalledTimes(3);
// Install plugins (no marketplace without explicit marketplace input) // Install plugins (no marketplace without explicit marketplace input)
@@ -97,7 +97,7 @@ describe("installPlugins", () => {
test("should use custom claude executable path when provided", async () => { test("should use custom claude executable path when provided", async () => {
const spy = createMockSpawn(); const spy = createMockSpawn();
await installPlugins("test-plugin", "/custom/path/to/claude"); await installPlugins(undefined, "test-plugin", "/custom/path/to/claude");
expect(spy).toHaveBeenCalledTimes(1); expect(spy).toHaveBeenCalledTimes(1);
// Only call: install plugin (no marketplace without explicit marketplace input) // Only call: install plugin (no marketplace without explicit marketplace input)
@@ -111,7 +111,7 @@ describe("installPlugins", () => {
test("should trim whitespace from plugin names before installation", async () => { test("should trim whitespace from plugin names before installation", async () => {
const spy = createMockSpawn(); const spy = createMockSpawn();
await installPlugins(" plugin1 , plugin2 "); await installPlugins(undefined, " plugin1 \n plugin2 ");
expect(spy).toHaveBeenCalledTimes(2); expect(spy).toHaveBeenCalledTimes(2);
// Install plugins (no marketplace without explicit marketplace input) // Install plugins (no marketplace without explicit marketplace input)
@@ -131,7 +131,7 @@ describe("installPlugins", () => {
test("should skip empty entries in plugin list", async () => { test("should skip empty entries in plugin list", async () => {
const spy = createMockSpawn(); const spy = createMockSpawn();
await installPlugins("plugin1,,plugin2"); await installPlugins(undefined, "plugin1\n\nplugin2");
expect(spy).toHaveBeenCalledTimes(2); expect(spy).toHaveBeenCalledTimes(2);
// Install plugins (no marketplace without explicit marketplace input) // Install plugins (no marketplace without explicit marketplace input)
@@ -152,7 +152,7 @@ describe("installPlugins", () => {
test("should handle plugin installation error and throw", async () => { test("should handle plugin installation error and throw", async () => {
createMockSpawn(1, false); // Exit code 1 createMockSpawn(1, false); // Exit code 1
await expect(installPlugins("failing-plugin")).rejects.toThrow( await expect(installPlugins(undefined, "failing-plugin")).rejects.toThrow(
"Failed to install plugin 'failing-plugin' (exit code: 1)", "Failed to install plugin 'failing-plugin' (exit code: 1)",
); );
}); });
@@ -160,7 +160,9 @@ describe("installPlugins", () => {
test("should handle null exit code (process terminated by signal)", async () => { test("should handle null exit code (process terminated by signal)", async () => {
createMockSpawn(null, false); // Exit code null (terminated by signal) createMockSpawn(null, false); // Exit code null (terminated by signal)
await expect(installPlugins("terminated-plugin")).rejects.toThrow( await expect(
installPlugins(undefined, "terminated-plugin"),
).rejects.toThrow(
"Failed to install plugin 'terminated-plugin': process terminated by signal", "Failed to install plugin 'terminated-plugin': process terminated by signal",
); );
}); });
@@ -168,9 +170,9 @@ describe("installPlugins", () => {
test("should stop installation on first error", async () => { test("should stop installation on first error", async () => {
const spy = createMockSpawn(1, false); // Exit code 1 const spy = createMockSpawn(1, false); // Exit code 1
await expect(installPlugins("plugin1,plugin2,plugin3")).rejects.toThrow( await expect(
"Failed to install plugin 'plugin1' (exit code: 1)", installPlugins(undefined, "plugin1\nplugin2\nplugin3"),
); ).rejects.toThrow("Failed to install plugin 'plugin1' (exit code: 1)");
// Should only try to install first plugin before failing // Should only try to install first plugin before failing
expect(spy).toHaveBeenCalledTimes(1); expect(spy).toHaveBeenCalledTimes(1);
@@ -178,7 +180,7 @@ describe("installPlugins", () => {
test("should handle plugins with special characters in names", async () => { test("should handle plugins with special characters in names", async () => {
const spy = createMockSpawn(); const spy = createMockSpawn();
await installPlugins("org/plugin-name,@scope/plugin"); await installPlugins(undefined, "org/plugin-name\n@scope/plugin");
expect(spy).toHaveBeenCalledTimes(2); expect(spy).toHaveBeenCalledTimes(2);
// Install plugins (no marketplace without explicit marketplace input) // Install plugins (no marketplace without explicit marketplace input)
@@ -199,14 +201,18 @@ describe("installPlugins", () => {
test("should handle spawn errors", async () => { test("should handle spawn errors", async () => {
createMockSpawn(0, true); // Trigger error event createMockSpawn(0, true); // Trigger error event
await expect(installPlugins("test-plugin")).rejects.toThrow( await expect(installPlugins(undefined, "test-plugin")).rejects.toThrow(
"Failed to install plugin 'test-plugin': spawn error", "Failed to install plugin 'test-plugin': spawn error",
); );
}); });
test("should install plugins with custom executable and multiple plugins", async () => { test("should install plugins with custom executable and multiple plugins", async () => {
const spy = createMockSpawn(); const spy = createMockSpawn();
await installPlugins("plugin-a,plugin-b", "/usr/local/bin/claude-custom"); await installPlugins(
undefined,
"plugin-a\nplugin-b",
"/usr/local/bin/claude-custom",
);
expect(spy).toHaveBeenCalledTimes(2); expect(spy).toHaveBeenCalledTimes(2);
// Install plugins (no marketplace without explicit marketplace input) // Install plugins (no marketplace without explicit marketplace input)
@@ -228,9 +234,9 @@ describe("installPlugins", () => {
const spy = createMockSpawn(); const spy = createMockSpawn();
// Should throw due to invalid characters (semicolon and spaces) // Should throw due to invalid characters (semicolon and spaces)
await expect(installPlugins("plugin-name; rm -rf /")).rejects.toThrow( await expect(
"Invalid plugin name format", installPlugins(undefined, "plugin-name; rm -rf /"),
); ).rejects.toThrow("Invalid plugin name format");
// Mock should never be called because validation fails first // Mock should never be called because validation fails first
expect(spy).not.toHaveBeenCalled(); expect(spy).not.toHaveBeenCalled();
@@ -239,9 +245,9 @@ describe("installPlugins", () => {
test("should reject plugin names with path traversal using ../", async () => { test("should reject plugin names with path traversal using ../", async () => {
const spy = createMockSpawn(); const spy = createMockSpawn();
await expect(installPlugins("../../../malicious-plugin")).rejects.toThrow( await expect(
"Invalid plugin name format", installPlugins(undefined, "../../../malicious-plugin"),
); ).rejects.toThrow("Invalid plugin name format");
expect(spy).not.toHaveBeenCalled(); expect(spy).not.toHaveBeenCalled();
}); });
@@ -249,9 +255,9 @@ describe("installPlugins", () => {
test("should reject plugin names with path traversal using ./", async () => { test("should reject plugin names with path traversal using ./", async () => {
const spy = createMockSpawn(); const spy = createMockSpawn();
await expect(installPlugins("./../../@scope/package")).rejects.toThrow( await expect(
"Invalid plugin name format", installPlugins(undefined, "./../../@scope/package"),
); ).rejects.toThrow("Invalid plugin name format");
expect(spy).not.toHaveBeenCalled(); expect(spy).not.toHaveBeenCalled();
}); });
@@ -259,7 +265,7 @@ describe("installPlugins", () => {
test("should reject plugin names with consecutive dots", async () => { test("should reject plugin names with consecutive dots", async () => {
const spy = createMockSpawn(); const spy = createMockSpawn();
await expect(installPlugins(".../.../package")).rejects.toThrow( await expect(installPlugins(undefined, ".../.../package")).rejects.toThrow(
"Invalid plugin name format", "Invalid plugin name format",
); );
@@ -269,7 +275,7 @@ describe("installPlugins", () => {
test("should reject plugin names with hidden path traversal", async () => { test("should reject plugin names with hidden path traversal", async () => {
const spy = createMockSpawn(); const spy = createMockSpawn();
await expect(installPlugins("package/../other")).rejects.toThrow( await expect(installPlugins(undefined, "package/../other")).rejects.toThrow(
"Invalid plugin name format", "Invalid plugin name format",
); );
@@ -278,7 +284,7 @@ describe("installPlugins", () => {
test("should accept plugin names with single dots in version numbers", async () => { test("should accept plugin names with single dots in version numbers", async () => {
const spy = createMockSpawn(); const spy = createMockSpawn();
await installPlugins("plugin-v1.0.2"); await installPlugins(undefined, "plugin-v1.0.2");
expect(spy).toHaveBeenCalledTimes(1); expect(spy).toHaveBeenCalledTimes(1);
// Only call: install plugin (no marketplace without explicit marketplace input) // Only call: install plugin (no marketplace without explicit marketplace input)
@@ -292,7 +298,7 @@ describe("installPlugins", () => {
test("should accept plugin names with multiple dots in semantic versions", async () => { test("should accept plugin names with multiple dots in semantic versions", async () => {
const spy = createMockSpawn(); const spy = createMockSpawn();
await installPlugins("@scope/plugin-v1.0.0-beta.1"); await installPlugins(undefined, "@scope/plugin-v1.0.0-beta.1");
expect(spy).toHaveBeenCalledTimes(1); expect(spy).toHaveBeenCalledTimes(1);
// Only call: install plugin (no marketplace without explicit marketplace input) // Only call: install plugin (no marketplace without explicit marketplace input)
@@ -308,7 +314,7 @@ describe("installPlugins", () => {
const spy = createMockSpawn(); const spy = createMockSpawn();
// Using fullwidth dots (U+FF0E) and fullwidth solidus (U+FF0F) // Using fullwidth dots (U+FF0E) and fullwidth solidus (U+FF0F)
await expect(installPlugins("malicious")).rejects.toThrow( await expect(installPlugins(undefined, "malicious")).rejects.toThrow(
"Invalid plugin name format", "Invalid plugin name format",
); );
@@ -318,7 +324,7 @@ describe("installPlugins", () => {
test("should reject path traversal at end of path", async () => { test("should reject path traversal at end of path", async () => {
const spy = createMockSpawn(); const spy = createMockSpawn();
await expect(installPlugins("package/..")).rejects.toThrow( await expect(installPlugins(undefined, "package/..")).rejects.toThrow(
"Invalid plugin name format", "Invalid plugin name format",
); );
@@ -328,7 +334,7 @@ describe("installPlugins", () => {
test("should reject single dot directory reference", async () => { test("should reject single dot directory reference", async () => {
const spy = createMockSpawn(); const spy = createMockSpawn();
await expect(installPlugins("package/.")).rejects.toThrow( await expect(installPlugins(undefined, "package/.")).rejects.toThrow(
"Invalid plugin name format", "Invalid plugin name format",
); );
@@ -338,10 +344,256 @@ describe("installPlugins", () => {
test("should reject path traversal in middle of path", async () => { test("should reject path traversal in middle of path", async () => {
const spy = createMockSpawn(); const spy = createMockSpawn();
await expect(installPlugins("package/../other")).rejects.toThrow( await expect(installPlugins(undefined, "package/../other")).rejects.toThrow(
"Invalid plugin name format", "Invalid plugin name format",
); );
expect(spy).not.toHaveBeenCalled(); expect(spy).not.toHaveBeenCalled();
}); });
// Marketplace functionality tests
test("should add a single marketplace before installing plugins", async () => {
const spy = createMockSpawn();
await installPlugins(
"https://github.com/user/marketplace.git",
"test-plugin",
);
expect(spy).toHaveBeenCalledTimes(2);
// First call: add marketplace
expect(spy).toHaveBeenNthCalledWith(
1,
"claude",
[
"plugin",
"marketplace",
"add",
"https://github.com/user/marketplace.git",
],
{ stdio: "inherit" },
);
// Second call: install plugin
expect(spy).toHaveBeenNthCalledWith(
2,
"claude",
["plugin", "install", "test-plugin"],
{ stdio: "inherit" },
);
});
test("should add multiple marketplaces with newline separation", async () => {
const spy = createMockSpawn();
await installPlugins(
"https://github.com/user/m1.git\nhttps://github.com/user/m2.git",
"test-plugin",
);
expect(spy).toHaveBeenCalledTimes(3); // 2 marketplaces + 1 plugin
// First two calls: add marketplaces
expect(spy).toHaveBeenNthCalledWith(
1,
"claude",
["plugin", "marketplace", "add", "https://github.com/user/m1.git"],
{ stdio: "inherit" },
);
expect(spy).toHaveBeenNthCalledWith(
2,
"claude",
["plugin", "marketplace", "add", "https://github.com/user/m2.git"],
{ stdio: "inherit" },
);
// Third call: install plugin
expect(spy).toHaveBeenNthCalledWith(
3,
"claude",
["plugin", "install", "test-plugin"],
{ stdio: "inherit" },
);
});
test("should add marketplaces before installing multiple plugins", async () => {
const spy = createMockSpawn();
await installPlugins(
"https://github.com/user/marketplace.git",
"plugin1\nplugin2",
);
expect(spy).toHaveBeenCalledTimes(3); // 1 marketplace + 2 plugins
// First call: add marketplace
expect(spy).toHaveBeenNthCalledWith(
1,
"claude",
[
"plugin",
"marketplace",
"add",
"https://github.com/user/marketplace.git",
],
{ stdio: "inherit" },
);
// Next calls: install plugins
expect(spy).toHaveBeenNthCalledWith(
2,
"claude",
["plugin", "install", "plugin1"],
{ stdio: "inherit" },
);
expect(spy).toHaveBeenNthCalledWith(
3,
"claude",
["plugin", "install", "plugin2"],
{ stdio: "inherit" },
);
});
test("should handle only marketplaces without plugins", async () => {
const spy = createMockSpawn();
await installPlugins("https://github.com/user/marketplace.git", undefined);
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenNthCalledWith(
1,
"claude",
[
"plugin",
"marketplace",
"add",
"https://github.com/user/marketplace.git",
],
{ stdio: "inherit" },
);
});
test("should skip empty marketplace entries", async () => {
const spy = createMockSpawn();
await installPlugins(
"https://github.com/user/m1.git\n\nhttps://github.com/user/m2.git",
"test-plugin",
);
expect(spy).toHaveBeenCalledTimes(3); // 2 marketplaces (skip empty) + 1 plugin
});
test("should trim whitespace from marketplace URLs", async () => {
const spy = createMockSpawn();
await installPlugins(
" https://github.com/user/marketplace.git \n https://github.com/user/m2.git ",
"test-plugin",
);
expect(spy).toHaveBeenCalledTimes(3);
expect(spy).toHaveBeenNthCalledWith(
1,
"claude",
[
"plugin",
"marketplace",
"add",
"https://github.com/user/marketplace.git",
],
{ stdio: "inherit" },
);
expect(spy).toHaveBeenNthCalledWith(
2,
"claude",
["plugin", "marketplace", "add", "https://github.com/user/m2.git"],
{ stdio: "inherit" },
);
});
test("should reject invalid marketplace URL format", async () => {
const spy = createMockSpawn();
await expect(
installPlugins("not-a-valid-url", "test-plugin"),
).rejects.toThrow("Invalid marketplace URL format");
expect(spy).not.toHaveBeenCalled();
});
test("should reject marketplace URL without .git extension", async () => {
const spy = createMockSpawn();
await expect(
installPlugins("https://github.com/user/marketplace", "test-plugin"),
).rejects.toThrow("Invalid marketplace URL format");
expect(spy).not.toHaveBeenCalled();
});
test("should reject marketplace URL with non-https protocol", async () => {
const spy = createMockSpawn();
await expect(
installPlugins("http://github.com/user/marketplace.git", "test-plugin"),
).rejects.toThrow("Invalid marketplace URL format");
expect(spy).not.toHaveBeenCalled();
});
test("should skip whitespace-only marketplace input", async () => {
const spy = createMockSpawn();
await installPlugins(" ", "test-plugin");
// Should skip marketplaces and only install plugin
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenNthCalledWith(
1,
"claude",
["plugin", "install", "test-plugin"],
{ stdio: "inherit" },
);
});
test("should handle marketplace addition error", async () => {
createMockSpawn(1, false); // Exit code 1
await expect(
installPlugins("https://github.com/user/marketplace.git", "test-plugin"),
).rejects.toThrow(
"Failed to add marketplace 'https://github.com/user/marketplace.git' (exit code: 1)",
);
});
test("should stop if marketplace addition fails before installing plugins", async () => {
const spy = createMockSpawn(1, false); // Exit code 1
await expect(
installPlugins(
"https://github.com/user/marketplace.git",
"plugin1\nplugin2",
),
).rejects.toThrow("Failed to add marketplace");
// Should only try to add marketplace, not install any plugins
expect(spy).toHaveBeenCalledTimes(1);
});
test("should use custom executable for marketplace operations", async () => {
const spy = createMockSpawn();
await installPlugins(
"https://github.com/user/marketplace.git",
"test-plugin",
"/custom/path/to/claude",
);
expect(spy).toHaveBeenCalledTimes(2);
expect(spy).toHaveBeenNthCalledWith(
1,
"/custom/path/to/claude",
[
"plugin",
"marketplace",
"add",
"https://github.com/user/marketplace.git",
],
{ stdio: "inherit" },
);
expect(spy).toHaveBeenNthCalledWith(
2,
"/custom/path/to/claude",
["plugin", "install", "test-plugin"],
{ stdio: "inherit" },
);
});
}); });

View File

@@ -32,8 +32,10 @@ jobs:
# --max-turns 10 # --max-turns 10
# --model claude-4-0-sonnet-20250805 # --model claude-4-0-sonnet-20250805
# Optional: add custom plugin marketplaces
# plugin_marketplaces: "https://github.com/user/marketplace1.git\nhttps://github.com/user/marketplace2.git"
# Optional: install Claude Code plugins # Optional: install Claude Code plugins
# plugins: "plugin1,plugin2,plugin3" # plugins: "code-review@claude-code-plugins\nfeature-dev@claude-code-plugins"
# Optional: add custom trigger phrase (default: @claude) # Optional: add custom trigger phrase (default: @claude)
# trigger_phrase: "/claude" # trigger_phrase: "/claude"
@@ -76,7 +78,8 @@ jobs:
| `allowed_non_write_users` | **⚠️ RISKY**: Comma-separated list of usernames to allow without write permissions, or '\*' for all users. Only works with `github_token` input. See [Security](./security.md) | No | "" | | `allowed_non_write_users` | **⚠️ RISKY**: Comma-separated list of usernames to allow without write permissions, or '\*' for all users. Only works with `github_token` input. See [Security](./security.md) | No | "" |
| `path_to_claude_code_executable` | Optional path to a custom Claude Code executable. Skips automatic installation. Useful for Nix, custom containers, or specialized environments | No | "" | | `path_to_claude_code_executable` | Optional path to a custom Claude Code executable. Skips automatic installation. Useful for Nix, custom containers, or specialized environments | No | "" |
| `path_to_bun_executable` | Optional path to a custom Bun executable. Skips automatic Bun installation. Useful for Nix, custom containers, or specialized environments | No | "" | | `path_to_bun_executable` | Optional path to a custom Bun executable. Skips automatic Bun installation. Useful for Nix, custom containers, or specialized environments | No | "" |
| `plugins` | Comma-separated list of Claude Code plugin names to install (e.g., `plugin1,plugin2,plugin3`). Plugins are installed before Claude Code execution | No | "" | | `plugin_marketplaces` | Newline-separated list of Claude Code plugin marketplace Git URLs to install from (e.g., see example in workflow above). Marketplaces are added before plugin installation | No | "" |
| `plugins` | Newline-separated list of Claude Code plugin names to install (e.g., see example in workflow above). Plugins are installed before Claude Code execution | No | "" |
### Deprecated Inputs ### Deprecated Inputs