claudepluginhub 0.4.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -42,10 +42,18 @@ npx claudepluginhub list List (u/ collections only)
42
42
  npx claudepluginhub remove <identifier> Remove
43
43
  ```
44
44
 
45
+ ## Examples
46
+
47
+ ```bash
48
+ # Install a specific plugin from a marketplace repo
49
+ npx claudepluginhub vercel/next.js --plugin cache-components
50
+ ```
51
+
45
52
  ## Options
46
53
 
47
54
  | Option | Description |
48
55
  |--------|-------------|
56
+ | `--plugin <name>` | Install a specific plugin from a marketplace repo |
49
57
  | `--yes, -y` | Skip prompts (install all, project scope) |
50
58
  | `--scope <scope>` | Set scope: `user`, `project` (default), `local` |
51
59
  | `-h, --help` | Show help |
package/dist/args.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ import type { Scope } from './prompts.js';
2
+ export interface ParsedArgs {
3
+ command: string;
4
+ identifier: string | null;
5
+ plugin: string | null;
6
+ yes: boolean;
7
+ scope: Scope | null;
8
+ }
9
+ export declare function parseArgs(argv: string[]): ParsedArgs;
10
+ export declare function validatePlugin(args: ParsedArgs): void;
package/dist/args.js ADDED
@@ -0,0 +1,62 @@
1
+ import { VALID_NAME_RE, isOwnerRepo } from './validation.js';
2
+ import { printError } from './output.js';
3
+ const VALID_SCOPES = ['user', 'project', 'local'];
4
+ export function parseArgs(argv) {
5
+ const yes = argv.includes('--yes') || argv.includes('-y');
6
+ let scope = null;
7
+ let plugin = null;
8
+ const scopeIdx = argv.indexOf('--scope');
9
+ if (scopeIdx !== -1) {
10
+ const val = argv[scopeIdx + 1];
11
+ if (!val || val.startsWith('-')) {
12
+ printError('Missing value for --scope. Must be: user, project, or local');
13
+ process.exit(1);
14
+ }
15
+ if (!VALID_SCOPES.includes(val)) {
16
+ printError(`Invalid scope "${val}". Must be: user, project, or local`);
17
+ process.exit(1);
18
+ }
19
+ scope = val;
20
+ }
21
+ const pluginIdx = argv.indexOf('--plugin');
22
+ if (pluginIdx !== -1) {
23
+ const val = argv[pluginIdx + 1];
24
+ if (!val || val.startsWith('-')) {
25
+ printError('Missing value for --plugin');
26
+ process.exit(1);
27
+ }
28
+ if (!VALID_NAME_RE.test(val)) {
29
+ printError(`Invalid plugin name "${val}". Must match [a-zA-Z0-9_.-]+`);
30
+ process.exit(1);
31
+ }
32
+ plugin = val;
33
+ }
34
+ // Filter out flags and their values
35
+ const positional = argv.filter((a, i) => !a.startsWith('-') &&
36
+ (i === 0 || (argv[i - 1] !== '--scope' && argv[i - 1] !== '--plugin')));
37
+ const first = positional[0] ?? null;
38
+ const second = positional[1] ?? null;
39
+ // Route commands
40
+ if (first === 'list')
41
+ return { command: 'list', identifier: null, plugin, yes, scope };
42
+ if (first === 'update')
43
+ return { command: 'update', identifier: second, plugin, yes, scope };
44
+ if (first === 'remove')
45
+ return { command: 'remove', identifier: second, plugin, yes, scope };
46
+ if (first === 'add')
47
+ return { command: 'add', identifier: second, plugin, yes, scope };
48
+ // Default: treat first positional as identifier for install
49
+ return { command: 'add', identifier: first, plugin, yes, scope };
50
+ }
51
+ export function validatePlugin(args) {
52
+ if (!args.plugin)
53
+ return;
54
+ if (args.command !== 'add') {
55
+ printError('--plugin is only valid for install commands');
56
+ process.exit(1);
57
+ }
58
+ if (!args.identifier || !isOwnerRepo(args.identifier)) {
59
+ printError('--plugin is only valid with owner/repo identifiers');
60
+ process.exit(1);
61
+ }
62
+ }
package/dist/detect.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { TreeEntry } from './github.js';
2
- export declare function isOwnerRepo(identifier: string): boolean;
2
+ import { isOwnerRepo } from './validation.js';
3
+ export { isOwnerRepo };
3
4
  export interface MarketplaceDetection {
4
5
  kind: 'marketplace';
5
6
  manifest: {
package/dist/detect.js CHANGED
@@ -1,17 +1,8 @@
1
1
  import { fetchDefaultBranch, fetchTree, downloadFile } from './github.js';
2
2
  import { printError } from './output.js';
3
+ import { VALID_NAME_RE, isOwnerRepo } from './validation.js';
3
4
  import { sanitizeName } from './wrapper.js';
4
- const OWNER_REPO_RE = /^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/;
5
- const VALID_NAME_RE = /^[a-zA-Z0-9_.-]+$/;
6
- export function isOwnerRepo(identifier) {
7
- if (!OWNER_REPO_RE.test(identifier))
8
- return false;
9
- const [owner, repo] = identifier.split('/');
10
- // Reject segments that are purely dots (e.g. "..", ".")
11
- if (/^\.+$/.test(owner) || /^\.+$/.test(repo))
12
- return false;
13
- return true;
14
- }
5
+ export { isOwnerRepo };
15
6
  const COMPONENT_PATTERNS = [
16
7
  /^commands\/.*\.md$/,
17
8
  /^agents\/.*\.md$/,
@@ -95,7 +86,8 @@ export async function detectRepo(ownerRepo) {
95
86
  const source = rawSource &&
96
87
  typeof rawSource === 'object' &&
97
88
  typeof rawSource.source === 'string' &&
98
- typeof rawSource.repo === 'string'
89
+ typeof rawSource.repo === 'string' &&
90
+ isOwnerRepo(rawSource.repo)
99
91
  ? rawSource
100
92
  : { source: 'github', repo: ownerRepo };
101
93
  validPlugins.push({
@@ -136,7 +128,7 @@ export async function detectRepo(ownerRepo) {
136
128
  printError(`plugin.json has an invalid name: "${pluginJson.name}". Must match [a-zA-Z0-9_.-]+`);
137
129
  process.exit(1);
138
130
  }
139
- const strict = pluginJson.strict !== false;
131
+ const strict = pluginJson.strict === false ? false : true;
140
132
  return { kind: 'plugin', name: pluginJson.name, strict };
141
133
  }
142
134
  // Plugin without manifest — check for component files at root
package/dist/download.js CHANGED
@@ -2,7 +2,7 @@ import { writeFileSync, mkdirSync, readFileSync, existsSync } from 'node:fs';
2
2
  import { dirname, join, resolve, sep } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { fetchDefaultBranch, fetchTree, downloadFile, findTreeSha, findBlobSha, listFilesUnder, } from './github.js';
5
- import { printError } from './output.js';
5
+ import { printError, printProgress } from './output.js';
6
6
  function getBaseDir(scope) {
7
7
  if (scope === 'user') {
8
8
  return join(homedir(), '.claude');
@@ -63,18 +63,26 @@ export async function downloadSkill(component, scope) {
63
63
  printError(`Skipping unsafe skill name: ${componentName}`);
64
64
  return null;
65
65
  }
66
- for (const file of files) {
67
- const relativePath = file.path.slice(dirPath.length + 1); // remove dir prefix + /
68
- const destPath = join(installDir, relativePath);
69
- if (!isSafePath(destPath, installDir)) {
70
- printError(`Skipping unsafe path: ${file.path}`);
71
- continue;
72
- }
73
- const content = await downloadFile(source, branch, file.path);
74
- if (content === null)
66
+ printProgress(`${componentName} (skill) downloading ${files.length} file(s)...`);
67
+ const BATCH_SIZE = 5;
68
+ for (let i = 0; i < files.length; i += BATCH_SIZE) {
69
+ const batch = files.slice(i, i + BATCH_SIZE);
70
+ const results = await Promise.all(batch.map(async (file) => {
71
+ const relativePath = file.path.slice(dirPath.length + 1);
72
+ const destPath = join(installDir, relativePath);
73
+ if (!isSafePath(destPath, installDir)) {
74
+ printError(`Skipping unsafe path: ${file.path}`);
75
+ return true;
76
+ }
77
+ const content = await downloadFile(source, branch, file.path);
78
+ if (content === null)
79
+ return false;
80
+ ensureDir(destPath);
81
+ writeFileSync(destPath, content);
82
+ return true;
83
+ }));
84
+ if (results.includes(false))
75
85
  return null;
76
- ensureDir(destPath);
77
- writeFileSync(destPath, content);
78
86
  }
79
87
  const hash = findTreeSha(tree, dirPath) ?? 'unknown';
80
88
  return {
@@ -1,3 +1,3 @@
1
1
  import type { Scope } from './prompts.js';
2
- export declare function runGithubInstall(ownerRepo: string, yes: boolean, scopeOverride: Scope | null): Promise<void>;
2
+ export declare function runGithubInstall(ownerRepo: string, yes: boolean, scopeOverride: Scope | null, pluginName?: string | null): Promise<void>;
3
3
  export declare function runGithubRemove(ownerRepo: string, scopeOverride: Scope | null): Promise<void>;
@@ -3,7 +3,7 @@ import { detectRepo } from './detect.js';
3
3
  import { checkClaudeCli, claudeMarketplaceAdd, claudePluginInstall, claudePluginUninstall, claudeMarketplaceRemove, } from './native.js';
4
4
  import { writeWrapperMarketplace, readWrapperMeta, findWrapperScopes, removeWrapperDir, getWrapperDir, } from './wrapper.js';
5
5
  import { print, printStep, printSuccess, printFail, printError, printWarning, BOLD, RESET, DIM, GREEN, YELLOW, } from './output.js';
6
- export async function runGithubInstall(ownerRepo, yes, scopeOverride) {
6
+ export async function runGithubInstall(ownerRepo, yes, scopeOverride, pluginName = null) {
7
7
  if (!checkClaudeCli())
8
8
  process.exit(1);
9
9
  printStep('Detecting repository type...');
@@ -12,23 +12,36 @@ export async function runGithubInstall(ownerRepo, yes, scopeOverride) {
12
12
  printError(`No plugin or marketplace found in ${ownerRepo}. Expected .claude-plugin/ directory or component files (commands/, agents/, skills/, hooks/, .mcp.json, .lsp.json).`);
13
13
  process.exit(1);
14
14
  }
15
+ if (pluginName && detection.kind === 'plugin') {
16
+ printError(`--plugin is only valid for marketplace repos. "${ownerRepo}" is a standalone plugin.`);
17
+ process.exit(1);
18
+ }
15
19
  if (detection.kind === 'marketplace') {
16
- await installMarketplace(ownerRepo, detection, yes, scopeOverride);
20
+ await installMarketplace(ownerRepo, detection, yes, scopeOverride, pluginName);
17
21
  }
18
22
  else {
19
23
  await installStandalone(ownerRepo, detection, yes, scopeOverride);
20
24
  }
21
25
  }
22
- async function installMarketplace(ownerRepo, detection, yes, scopeOverride) {
26
+ async function installMarketplace(ownerRepo, detection, yes, scopeOverride, pluginName = null) {
23
27
  const { manifest } = detection;
24
28
  printSuccess(`Marketplace "${manifest.name}" — ${manifest.plugins.length} plugin(s)`);
25
29
  if (manifest.plugins.length === 0) {
26
30
  printError('No valid plugins found in marketplace.');
27
31
  process.exit(1);
28
32
  }
29
- // Plugin picker
33
+ // Plugin selection
30
34
  let selectedPlugins = manifest.plugins;
31
- if (!yes && manifest.plugins.length > 1) {
35
+ if (pluginName) {
36
+ const match = manifest.plugins.find((p) => p.name === pluginName);
37
+ if (!match) {
38
+ printError(`Plugin "${pluginName}" not found in marketplace "${manifest.name}". ` +
39
+ `Available: ${manifest.plugins.map((p) => p.name).join(', ')}`);
40
+ process.exit(1);
41
+ }
42
+ selectedPlugins = [match];
43
+ }
44
+ else if (!yes && manifest.plugins.length > 1) {
32
45
  print('');
33
46
  const items = manifest.plugins.map((p) => ({
34
47
  label: p.name,
package/dist/index.js CHANGED
@@ -7,8 +7,8 @@ import { runRemove } from './remove.js';
7
7
  import { runList } from './list.js';
8
8
  import { isOwnerRepo } from './detect.js';
9
9
  import { runGithubInstall, runGithubRemove } from './github-install.js';
10
+ import { parseArgs, validatePlugin } from './args.js';
10
11
  import { print, printBanner, printStep, printSuccess, printError, DIM, RESET, GREEN, CYAN, YELLOW, } from './output.js';
11
- const VALID_SCOPES = ['user', 'project', 'local'];
12
12
  const TYPE_BADGES = {
13
13
  skill: `${GREEN}[skill]${RESET}`,
14
14
  command: `${CYAN}[cmd]${RESET}`,
@@ -38,39 +38,12 @@ Note: owner/repo installs use Claude Code's plugin system.
38
38
  - Standalone: \`npx claudepluginhub remove owner/repo\` for cleanup
39
39
 
40
40
  Options:
41
+ --plugin <name> Install a specific plugin from a marketplace repo
41
42
  --yes, -y Skip prompts (install all, project scope)
42
43
  --scope <scope> Set scope: user, project (default), local
43
44
  -h, --help Show this help
44
45
  `);
45
46
  }
46
- function parseArgs(argv) {
47
- const yes = argv.includes('--yes') || argv.includes('-y');
48
- let scope = null;
49
- const scopeIdx = argv.indexOf('--scope');
50
- if (scopeIdx !== -1 && argv[scopeIdx + 1]) {
51
- const val = argv[scopeIdx + 1];
52
- if (!VALID_SCOPES.includes(val)) {
53
- printError(`Invalid scope "${val}". Must be: user, project, or local`);
54
- process.exit(1);
55
- }
56
- scope = val;
57
- }
58
- // Filter out flags and their values
59
- const positional = argv.filter((a, i) => !a.startsWith('-') && (i === 0 || argv[i - 1] !== '--scope'));
60
- const first = positional[0] ?? null;
61
- const second = positional[1] ?? null;
62
- // Route commands
63
- if (first === 'list')
64
- return { command: 'list', identifier: null, yes, scope };
65
- if (first === 'update')
66
- return { command: 'update', identifier: second, yes, scope };
67
- if (first === 'remove')
68
- return { command: 'remove', identifier: second, yes, scope };
69
- if (first === 'add')
70
- return { command: 'add', identifier: second, yes, scope };
71
- // Default: treat first positional as identifier for install
72
- return { command: 'add', identifier: first, yes, scope };
73
- }
74
47
  async function main() {
75
48
  const args = process.argv.slice(2);
76
49
  if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
@@ -78,7 +51,9 @@ async function main() {
78
51
  printUsage();
79
52
  process.exit(0);
80
53
  }
81
- const { command, identifier, yes, scope: scopeOverride } = parseArgs(args);
54
+ const parsed = parseArgs(args);
55
+ validatePlugin(parsed);
56
+ const { command, identifier, plugin, yes, scope: scopeOverride } = parsed;
82
57
  printBanner();
83
58
  // Handle non-install commands
84
59
  if (command === 'list') {
@@ -109,7 +84,7 @@ async function main() {
109
84
  }
110
85
  // owner/repo → native Claude Code install
111
86
  if (isOwnerRepo(identifier)) {
112
- await runGithubInstall(identifier, yes, scopeOverride);
87
+ await runGithubInstall(identifier, yes, scopeOverride, plugin);
113
88
  process.exit(0);
114
89
  }
115
90
  // Resolve identifier to API URL
package/dist/output.d.ts CHANGED
@@ -16,6 +16,7 @@ export declare function printBanner(): void;
16
16
  export declare function printWarning(message: string): void;
17
17
  export declare function printStep(label: string): void;
18
18
  export declare function printSuccess(label: string): void;
19
+ export declare function printProgress(label: string): void;
19
20
  export declare function printFail(label: string, error?: string): void;
20
21
  export declare function printSummary(results: {
21
22
  name: string;
package/dist/output.js CHANGED
@@ -28,6 +28,9 @@ export function printStep(label) {
28
28
  export function printSuccess(label) {
29
29
  print(` ${GREEN}ok${RESET} ${label}`);
30
30
  }
31
+ export function printProgress(label) {
32
+ print(` ${DIM}↓${RESET} ${label}`);
33
+ }
31
34
  export function printFail(label, error) {
32
35
  print(` ${RED}fail${RESET} ${label}${error ? ` ${DIM}(${error})${RESET}` : ''}`);
33
36
  }
@@ -0,0 +1,7 @@
1
+ /** Shared validation constants and helpers for the CLI. */
2
+ /** Matches a single valid name segment: alphanumeric, underscore, dot, hyphen. */
3
+ export declare const VALID_NAME_RE: RegExp;
4
+ /** Matches an owner/repo string (two valid name segments separated by /). */
5
+ export declare const OWNER_REPO_RE: RegExp;
6
+ /** Validates that a string is a well-formed owner/repo identifier. */
7
+ export declare function isOwnerRepo(identifier: string): boolean;
@@ -0,0 +1,15 @@
1
+ /** Shared validation constants and helpers for the CLI. */
2
+ /** Matches a single valid name segment: alphanumeric, underscore, dot, hyphen. */
3
+ export const VALID_NAME_RE = /^[a-zA-Z0-9_.-]+$/;
4
+ /** Matches an owner/repo string (two valid name segments separated by /). */
5
+ export const OWNER_REPO_RE = /^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/;
6
+ /** Validates that a string is a well-formed owner/repo identifier. */
7
+ export function isOwnerRepo(identifier) {
8
+ if (!OWNER_REPO_RE.test(identifier))
9
+ return false;
10
+ const [owner, repo] = identifier.split('/');
11
+ // Reject segments that are purely dots (e.g. "..", ".")
12
+ if (/^\.+$/.test(owner) || /^\.+$/.test(repo))
13
+ return false;
14
+ return true;
15
+ }
package/dist/wrapper.js CHANGED
@@ -2,7 +2,7 @@ import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node
2
2
  import { join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { createHash } from 'node:crypto';
5
- const VALID_NAME_RE = /^[a-zA-Z0-9_.-]+$/;
5
+ import { VALID_NAME_RE } from './validation.js';
6
6
  export function sanitizeName(raw) {
7
7
  return (raw
8
8
  .toLowerCase()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudepluginhub",
3
- "version": "0.4.2",
3
+ "version": "0.6.0",
4
4
  "description": "Install Claude Code components from ClaudePluginHub",
5
5
  "bin": {
6
6
  "claudepluginhub": "dist/index.js"