Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ inputs:
required: false
default: ""
plugin_marketplaces:
description: "Newline-separated list of Claude Code plugin marketplace Git URLs to install from (e.g., 'https://github.com/user/marketplace1.git\nhttps://github.com/user/marketplace2.git')"
description: "Newline-separated list of Claude Code plugin marketplace Git URLs to install from. Supports HTTPS and SSH formats (e.g., 'https://github.com/user/marketplace1.git\ngit@github.com:user/marketplace2.git')"
required: false
default: ""

Expand Down
2 changes: 1 addition & 1 deletion base-action/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ inputs:
required: false
default: ""
plugin_marketplaces:
description: "Newline-separated list of Claude Code plugin marketplace Git URLs to install from (e.g., 'https://github.com/user/marketplace1.git\nhttps://github.com/user/marketplace2.git')"
description: "Newline-separated list of Claude Code plugin marketplace Git URLs to install from. Supports HTTPS and SSH formats (e.g., 'https://github.com/user/marketplace1.git\ngit@github.com:user/marketplace2.git')"
required: false
default: ""

Expand Down
26 changes: 18 additions & 8 deletions base-action/src/install-plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const PATH_TRAVERSAL_REGEX =
/\.\.\/|\/\.\.|\.\/|\/\.|(?:^|\/)\.\.$|(?:^|\/)\.$|\.\.(?![0-9])/;
const MARKETPLACE_URL_REGEX =
/^https:\/\/[a-zA-Z0-9\-._~:/?#[\]@!$&'()*+,;=%]+\.git$/;
const SSH_URL_REGEX = /^git@[a-zA-Z0-9\-._]+:[a-zA-Z0-9\-._~\/@]+\.git$/;

/**
* Checks if a marketplace input is a local path (not a URL)
Expand Down Expand Up @@ -39,17 +40,26 @@ function validateMarketplaceInput(input: string): void {
return;
}

// Validate as URL
if (!MARKETPLACE_URL_REGEX.test(normalized)) {
throw new Error(`Invalid marketplace URL format: ${input}`);
// Check if it's a valid SSH URL
const isSSH = SSH_URL_REGEX.test(normalized);
// Check if it's a valid HTTPS URL
const isHTTPS = MARKETPLACE_URL_REGEX.test(normalized);

if (!isSSH && !isHTTPS) {
throw new Error(
`Invalid marketplace URL format (must be HTTPS or SSH): ${input}`,
);
}

// Additional check for valid URL structure
try {
new URL(normalized);
} catch {
throw new Error(`Invalid marketplace URL: ${input}`);
// Additional check for valid URL structure (only for HTTPS URLs)
if (isHTTPS) {
try {
new URL(normalized);
} catch {
throw new Error(`Invalid marketplace URL: ${input}`);
}
}
// SSH URLs don't need URL constructor validation as they're not valid HTTP URLs
}

/**
Expand Down
195 changes: 194 additions & 1 deletion base-action/test/install-plugins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -521,7 +521,7 @@ describe("installPlugins", () => {
expect(spy).not.toHaveBeenCalled();
});

test("should reject marketplace URL with non-https protocol", async () => {
test("should reject marketplace URL with http protocol (non-https)", async () => {
const spy = createMockSpawn();

await expect(
Expand Down Expand Up @@ -597,6 +597,199 @@ describe("installPlugins", () => {
);
});

// SSH marketplace URL tests
test("should accept SSH URL for GitHub marketplace", async () => {
const spy = createMockSpawn();
await installPlugins("git@github.com:user/marketplace.git", "test-plugin");

expect(spy).toHaveBeenCalledTimes(2);
expect(spy).toHaveBeenNthCalledWith(
1,
"claude",
["plugin", "marketplace", "add", "git@github.com:user/marketplace.git"],
{ stdio: "inherit" },
);
expect(spy).toHaveBeenNthCalledWith(
2,
"claude",
["plugin", "install", "test-plugin"],
{ stdio: "inherit" },
);
});

test("should accept SSH URL for GitLab marketplace", async () => {
const spy = createMockSpawn();
await installPlugins("git@gitlab.com:org/project.git", "test-plugin");

expect(spy).toHaveBeenCalledTimes(2);
expect(spy).toHaveBeenNthCalledWith(
1,
"claude",
["plugin", "marketplace", "add", "git@gitlab.com:org/project.git"],
{ stdio: "inherit" },
);
});

test("should accept SSH URL with custom hostname", async () => {
const spy = createMockSpawn();
await installPlugins(
"git@git.example.com:team/marketplace.git",
"test-plugin",
);

expect(spy).toHaveBeenCalledTimes(2);
expect(spy).toHaveBeenNthCalledWith(
1,
"claude",
["plugin", "marketplace", "add", "git@git.example.com:team/marketplace.git"],
{ stdio: "inherit" },
);
});

test("should accept SSH URL with nested path", async () => {
const spy = createMockSpawn();
await installPlugins(
"git@github.com:org/team/project.git",
"test-plugin",
);

expect(spy).toHaveBeenCalledTimes(2);
expect(spy).toHaveBeenNthCalledWith(
1,
"claude",
["plugin", "marketplace", "add", "git@github.com:org/team/project.git"],
{ stdio: "inherit" },
);
});

test("should accept SSH URL with hyphens and underscores", async () => {
const spy = createMockSpawn();
await installPlugins(
"git@github.com:my-org/my_marketplace.git",
"test-plugin",
);

expect(spy).toHaveBeenCalledTimes(2);
expect(spy).toHaveBeenNthCalledWith(
1,
"claude",
["plugin", "marketplace", "add", "git@github.com:my-org/my_marketplace.git"],
{ stdio: "inherit" },
);
});

test("should accept SSH URL with dots in hostname", async () => {
const spy = createMockSpawn();
await installPlugins(
"git@git.corp.example.com:org/repo.git",
"test-plugin",
);

expect(spy).toHaveBeenCalledTimes(2);
expect(spy).toHaveBeenNthCalledWith(
1,
"claude",
["plugin", "marketplace", "add", "git@git.corp.example.com:org/repo.git"],
{ stdio: "inherit" },
);
});

test("should reject SSH URL without .git extension", async () => {
const spy = createMockSpawn();

await expect(
installPlugins("git@github.com:user/marketplace", "test-plugin"),
).rejects.toThrow("Invalid marketplace URL format");

expect(spy).not.toHaveBeenCalled();
});

test("should reject SSH URL with invalid format (missing colon)", async () => {
const spy = createMockSpawn();

await expect(
installPlugins("git@github.com/user/marketplace.git", "test-plugin"),
).rejects.toThrow("Invalid marketplace URL format");

expect(spy).not.toHaveBeenCalled();
});

test("should reject SSH URL with invalid format (missing git@ prefix)", async () => {
const spy = createMockSpawn();

await expect(
installPlugins("github.com:user/marketplace.git", "test-plugin"),
).rejects.toThrow("Invalid marketplace URL format");

expect(spy).not.toHaveBeenCalled();
});

test("should reject SSH URL with spaces", async () => {
const spy = createMockSpawn();

await expect(
installPlugins("git@github.com:user name/marketplace.git", "test-plugin"),
).rejects.toThrow("Invalid marketplace URL format");

expect(spy).not.toHaveBeenCalled();
});

test("should accept mixed HTTPS and SSH marketplace URLs", async () => {
const spy = createMockSpawn();
await installPlugins(
"https://github.com/user/m1.git\ngit@gitlab.com:org/m2.git\nhttps://example.com/m3.git",
"test-plugin",
);

expect(spy).toHaveBeenCalledTimes(4); // 3 marketplaces + 1 plugin
expect(spy).toHaveBeenNthCalledWith(
1,
"claude",
["plugin", "marketplace", "add", "https://github.com/user/m1.git"],
{ stdio: "inherit" },
);
expect(spy).toHaveBeenNthCalledWith(
2,
"claude",
["plugin", "marketplace", "add", "git@gitlab.com:org/m2.git"],
{ stdio: "inherit" },
);
expect(spy).toHaveBeenNthCalledWith(
3,
"claude",
["plugin", "marketplace", "add", "https://example.com/m3.git"],
{ stdio: "inherit" },
);
});

test("should accept mixed SSH, HTTPS, and local marketplace paths", async () => {
const spy = createMockSpawn();
await installPlugins(
"git@github.com:user/m1.git\nhttps://example.com/m2.git\n./local-marketplace",
"test-plugin",
);

expect(spy).toHaveBeenCalledTimes(4); // 3 marketplaces + 1 plugin
expect(spy).toHaveBeenNthCalledWith(
1,
"claude",
["plugin", "marketplace", "add", "git@github.com:user/m1.git"],
{ stdio: "inherit" },
);
expect(spy).toHaveBeenNthCalledWith(
2,
"claude",
["plugin", "marketplace", "add", "https://example.com/m2.git"],
{ stdio: "inherit" },
);
expect(spy).toHaveBeenNthCalledWith(
3,
"claude",
["plugin", "marketplace", "add", "./local-marketplace"],
{ stdio: "inherit" },
);
});

// Local marketplace path tests
test("should accept local marketplace path with ./", async () => {
const spy = createMockSpawn();
Expand Down
6 changes: 3 additions & 3 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ jobs:
# --max-turns 10
# --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: add custom plugin marketplaces (supports HTTPS and SSH URLs)
# plugin_marketplaces: "https://github.com/user/marketplace1.git\ngit@github.com:user/marketplace2.git"
# Optional: install Claude Code plugins
# plugins: "code-review@claude-code-plugins\nfeature-dev@claude-code-plugins"

Expand Down Expand Up @@ -79,7 +79,7 @@ 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 | "" |
| `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 | "" |
| `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 | "" |
| `plugin_marketplaces` | Newline-separated list of Claude Code plugin marketplace Git URLs to install from. Supports HTTPS and SSH formats (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
Expand Down