claudepluginhub 0.5.0 → 0.7.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/dist/args.d.ts CHANGED
@@ -1,10 +1,18 @@
1
1
  import type { Scope } from './prompts.js';
2
+ type ComponentType = 'skill' | 'command' | 'agent';
2
3
  export interface ParsedArgs {
3
4
  command: string;
4
5
  identifier: string | null;
5
6
  plugin: string | null;
6
7
  yes: boolean;
7
8
  scope: Scope | null;
9
+ componentFlag: {
10
+ type: ComponentType;
11
+ repoPath: string;
12
+ pluginPath: string;
13
+ } | null;
8
14
  }
9
15
  export declare function parseArgs(argv: string[]): ParsedArgs;
10
16
  export declare function validatePlugin(args: ParsedArgs): void;
17
+ export declare function validateComponentFlag(args: ParsedArgs): void;
18
+ export {};
package/dist/args.js CHANGED
@@ -1,10 +1,12 @@
1
1
  import { VALID_NAME_RE, isOwnerRepo } from './validation.js';
2
2
  import { printError } from './output.js';
3
3
  const VALID_SCOPES = ['user', 'project', 'local'];
4
+ const COMPONENT_FLAGS = ['--skill', '--command', '--agent'];
4
5
  export function parseArgs(argv) {
5
6
  const yes = argv.includes('--yes') || argv.includes('-y');
6
7
  let scope = null;
7
8
  let plugin = null;
9
+ let componentFlag = null;
8
10
  const scopeIdx = argv.indexOf('--scope');
9
11
  if (scopeIdx !== -1) {
10
12
  const val = argv[scopeIdx + 1];
@@ -31,22 +33,66 @@ export function parseArgs(argv) {
31
33
  }
32
34
  plugin = val;
33
35
  }
36
+ // Parse component flags (--skill, --command, --agent)
37
+ const foundComponentFlags = [];
38
+ for (const flag of COMPONENT_FLAGS) {
39
+ const idx = argv.indexOf(flag);
40
+ if (idx !== -1) {
41
+ foundComponentFlags.push({ flag, type: flag.slice(2), idx });
42
+ }
43
+ }
44
+ if (foundComponentFlags.length > 1) {
45
+ printError('Only one of --skill, --command, or --agent can be specified');
46
+ process.exit(1);
47
+ }
48
+ if (foundComponentFlags.length === 1) {
49
+ const { type, idx } = foundComponentFlags[0];
50
+ const val = argv[idx + 1];
51
+ if (!val || val.startsWith('-')) {
52
+ printError(`Missing value for --${type}`);
53
+ process.exit(1);
54
+ }
55
+ if (plugin) {
56
+ printError('--plugin and component flags (--skill, --command, --agent) cannot be used together');
57
+ process.exit(1);
58
+ }
59
+ // Parse optional --source-path
60
+ let pluginPath = val;
61
+ const sourcePathIdx = argv.indexOf('--source-path');
62
+ if (sourcePathIdx !== -1) {
63
+ const spVal = argv[sourcePathIdx + 1];
64
+ if (!spVal || spVal.startsWith('-')) {
65
+ printError('Missing value for --source-path');
66
+ process.exit(1);
67
+ }
68
+ pluginPath = spVal;
69
+ }
70
+ componentFlag = { type, repoPath: val, pluginPath };
71
+ }
72
+ // --source-path without a component flag
73
+ if (!componentFlag && argv.includes('--source-path')) {
74
+ printError('--source-path requires a component flag (--skill, --command, or --agent)');
75
+ process.exit(1);
76
+ }
34
77
  // Filter out flags and their values
78
+ const flagsWithValues = new Set(['--scope', '--plugin', '--source-path']);
79
+ for (const f of COMPONENT_FLAGS)
80
+ flagsWithValues.add(f);
35
81
  const positional = argv.filter((a, i) => !a.startsWith('-') &&
36
- (i === 0 || (argv[i - 1] !== '--scope' && argv[i - 1] !== '--plugin')));
82
+ (i === 0 || !flagsWithValues.has(argv[i - 1])));
37
83
  const first = positional[0] ?? null;
38
84
  const second = positional[1] ?? null;
39
85
  // Route commands
40
86
  if (first === 'list')
41
- return { command: 'list', identifier: null, plugin, yes, scope };
87
+ return { command: 'list', identifier: null, plugin, yes, scope, componentFlag };
42
88
  if (first === 'update')
43
- return { command: 'update', identifier: second, plugin, yes, scope };
89
+ return { command: 'update', identifier: second, plugin, yes, scope, componentFlag };
44
90
  if (first === 'remove')
45
- return { command: 'remove', identifier: second, plugin, yes, scope };
91
+ return { command: 'remove', identifier: second, plugin, yes, scope, componentFlag };
46
92
  if (first === 'add')
47
- return { command: 'add', identifier: second, plugin, yes, scope };
93
+ return { command: 'add', identifier: second, plugin, yes, scope, componentFlag };
48
94
  // Default: treat first positional as identifier for install
49
- return { command: 'add', identifier: first, plugin, yes, scope };
95
+ return { command: 'add', identifier: first, plugin, yes, scope, componentFlag };
50
96
  }
51
97
  export function validatePlugin(args) {
52
98
  if (!args.plugin)
@@ -60,3 +106,15 @@ export function validatePlugin(args) {
60
106
  process.exit(1);
61
107
  }
62
108
  }
109
+ export function validateComponentFlag(args) {
110
+ if (!args.componentFlag)
111
+ return;
112
+ if (args.command !== 'add') {
113
+ printError(`--${args.componentFlag.type} is only valid for install commands`);
114
+ process.exit(1);
115
+ }
116
+ if (!args.identifier || !isOwnerRepo(args.identifier)) {
117
+ printError(`--${args.componentFlag.type} is only valid with owner/repo identifiers`);
118
+ process.exit(1);
119
+ }
120
+ }
@@ -0,0 +1,4 @@
1
+ import type { Scope } from './prompts.js';
2
+ type ComponentType = 'skill' | 'command' | 'agent';
3
+ export declare function runDirectComponentInstall(ownerRepo: string, componentType: ComponentType, repoPath: string, pluginPath: string, yes: boolean, scopeOverride: Scope | null): Promise<void>;
4
+ export {};
@@ -0,0 +1,185 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { scopePrompt } from './prompts.js';
4
+ import { installComponent, getBaseDir } from './download.js';
5
+ import { readLock, writeLock, addCollection, componentKey } from './lock.js';
6
+ import { fetchDefaultBranch, fetchTree } from './github.js';
7
+ import { print, printStep, printSuccess, printError, BOLD, RESET, DIM, GREEN, } from './output.js';
8
+ const DIRECT_SENTINEL = '';
9
+ /**
10
+ * Validate that pluginPath has the right extension for its type.
11
+ */
12
+ function validatePathFormat(componentType, pluginPath) {
13
+ if (componentType === 'skill') {
14
+ if (!pluginPath.endsWith('/SKILL.md')) {
15
+ printError('Invalid skill path: must end with /SKILL.md');
16
+ process.exit(1);
17
+ }
18
+ }
19
+ else {
20
+ if (!pluginPath.endsWith('.md')) {
21
+ printError(`Invalid ${componentType} path: must end with .md`);
22
+ process.exit(1);
23
+ }
24
+ }
25
+ }
26
+ /**
27
+ * Validate that repoPath and pluginPath are aligned.
28
+ * Either they are equal (no subdir) or repoPath ends with '/' + pluginPath (monorepo).
29
+ */
30
+ function validatePathAlignment(repoPath, pluginPath) {
31
+ if (!repoPath || !pluginPath) {
32
+ printError('Both repo path and source path must be non-empty');
33
+ process.exit(1);
34
+ }
35
+ if (repoPath !== pluginPath && !repoPath.endsWith('/' + pluginPath)) {
36
+ printError('Repo path and --source-path do not match: repo path must equal source path or end with /<source-path>');
37
+ process.exit(1);
38
+ }
39
+ }
40
+ /**
41
+ * Derive componentName from pluginPath, matching flattenComponents() semantics in fetch.ts.
42
+ */
43
+ function deriveComponentName(componentType, pluginPath) {
44
+ if (componentType === 'skill') {
45
+ // "skills/review/SKILL.md" → "review"
46
+ // "tools/helpers/my-skill/SKILL.md" → "my-skill"
47
+ const withoutSuffix = pluginPath.replace(/\/SKILL\.md$/, '');
48
+ return withoutSuffix.split('/').pop();
49
+ }
50
+ // Commands/agents: strip type prefix if present, then strip .md
51
+ // "commands/git/commit.md" → "git/commit"
52
+ // "custom/cmds/deploy.md" → "custom/cmds/deploy"
53
+ const typeDir = componentType === 'command' ? 'commands/' : 'agents/';
54
+ const relPath = pluginPath.startsWith(typeDir)
55
+ ? pluginPath.slice(typeDir.length)
56
+ : pluginPath;
57
+ return relPath.replace(/\.md$/, '');
58
+ }
59
+ /**
60
+ * Compute the local destination path for a component.
61
+ */
62
+ function computeDestPath(scope, componentType, pluginPath, componentName) {
63
+ const baseDir = getBaseDir(scope);
64
+ if (componentType === 'skill') {
65
+ return join(baseDir, 'skills', componentName);
66
+ }
67
+ const subdir = componentType === 'command' ? 'commands' : 'agents';
68
+ const prefix = subdir + '/';
69
+ const relPath = pluginPath.startsWith(prefix)
70
+ ? pluginPath.slice(prefix.length)
71
+ : pluginPath;
72
+ return join(baseDir, subdir, relPath);
73
+ }
74
+ export async function runDirectComponentInstall(ownerRepo, componentType, repoPath, pluginPath, yes, scopeOverride) {
75
+ // Step 1: Validate path format and alignment
76
+ validatePathFormat(componentType, pluginPath);
77
+ validatePathAlignment(repoPath, pluginPath);
78
+ // Step 2: Fetch tree, handle truncation
79
+ printStep('Fetching repository tree...');
80
+ const branch = await fetchDefaultBranch(ownerRepo);
81
+ if (!branch) {
82
+ printError(`Could not access repository ${ownerRepo}`);
83
+ process.exit(1);
84
+ }
85
+ const tree = await fetchTree(ownerRepo, branch);
86
+ if (!tree) {
87
+ printError(`Could not fetch file tree for ${ownerRepo}`);
88
+ process.exit(1);
89
+ }
90
+ if (tree.truncated) {
91
+ printError('Repository tree is too large (truncated). Cannot verify component path.');
92
+ printError(`Install the full plugin instead: npx claudepluginhub ${ownerRepo}`);
93
+ process.exit(1);
94
+ }
95
+ // Step 3: Verify exact repoPath in tree
96
+ const exists = tree.tree.some((e) => e.type === 'blob' && e.path === repoPath);
97
+ if (!exists) {
98
+ printError(`File not found: ${repoPath}`);
99
+ printError(`Verify the path exists in ${ownerRepo} on branch ${branch}`);
100
+ process.exit(1);
101
+ }
102
+ // Step 4: Skill — reject nested template SKILL.md
103
+ if (componentType === 'skill') {
104
+ const skillDir = repoPath.replace(/\/SKILL\.md$/, '').toLowerCase();
105
+ const isNested = tree.tree.some((e) => e.type === 'blob' &&
106
+ e.path.toLowerCase().endsWith('/skill.md') &&
107
+ e.path.toLowerCase() !== repoPath.toLowerCase() &&
108
+ skillDir.startsWith(e.path.replace(/\/skill\.md$/i, '').toLowerCase() + '/'));
109
+ if (isNested) {
110
+ printError('Cannot install nested SKILL.md (template or asset file inside another skill directory).');
111
+ process.exit(1);
112
+ }
113
+ }
114
+ // Step 5: Derive component name
115
+ const componentName = deriveComponentName(componentType, pluginPath);
116
+ printSuccess(`${componentName} (${componentType})`);
117
+ // Step 6: Scope picker
118
+ let scope = scopeOverride ?? 'project';
119
+ if (!yes && !scopeOverride) {
120
+ print('');
121
+ const chosenScope = await scopePrompt('project');
122
+ if (chosenScope === null) {
123
+ print('\nCancelled.');
124
+ process.exit(0);
125
+ }
126
+ scope = chosenScope;
127
+ }
128
+ // Step 7: Collision detection (two layers)
129
+ const lock = readLock(scope);
130
+ const existing = lock.collections[ownerRepo];
131
+ const newKey = componentKey(ownerRepo, repoPath);
132
+ // Layer 1: Lock collision — different repoPath, same pluginPath
133
+ if (existing) {
134
+ for (const [key, comp] of Object.entries(existing.components)) {
135
+ if (key !== newKey && comp.sourcePath === pluginPath) {
136
+ printError(`Collision: "${pluginPath}" is already installed from a different path in this repo.`);
137
+ printError(`Existing source: ${key}`);
138
+ printError('Remove the existing install first, or install the full plugin instead.');
139
+ process.exit(1);
140
+ }
141
+ }
142
+ }
143
+ // Layer 2: Filesystem collision — destination already occupied
144
+ const isReinstall = existing?.components[newKey] !== undefined;
145
+ if (!isReinstall) {
146
+ const destPath = computeDestPath(scope, componentType, pluginPath, componentName);
147
+ if (existsSync(destPath)) {
148
+ printError(`Destination already exists: ${destPath}`);
149
+ printError('Another plugin, collection, or manual install may be using this path.');
150
+ printError('Remove the existing file/directory first, or install the full plugin instead.');
151
+ process.exit(1);
152
+ }
153
+ }
154
+ // Step 8: Build FlatComponent and install
155
+ print('');
156
+ printStep(`Installing ${componentName}...`);
157
+ const component = {
158
+ source: ownerRepo,
159
+ type: componentType,
160
+ componentName,
161
+ sourcePath: pluginPath,
162
+ downloadPath: repoPath,
163
+ inlineConfig: null,
164
+ };
165
+ const result = await installComponent(component, scope, false);
166
+ if (result.status !== 'installed') {
167
+ printError(`Failed to install ${componentName}`);
168
+ process.exit(1);
169
+ }
170
+ // Step 9: Merge into lock file
171
+ const mergedComponents = {
172
+ ...(existing?.components ?? {}),
173
+ [newKey]: result.lock,
174
+ };
175
+ addCollection(lock, ownerRepo, DIRECT_SENTINEL, scope, mergedComponents);
176
+ writeLock(scope, lock);
177
+ // Step 10: Print result
178
+ printSuccess(componentName);
179
+ print('');
180
+ print(`${GREEN}${BOLD}Done!${RESET} ${componentType} "${componentName}" installed.`);
181
+ print('');
182
+ print(`${DIM}Manage with:${RESET}`);
183
+ print(` ${DIM}npx claudepluginhub remove ${ownerRepo} --scope ${scope}${RESET}`);
184
+ print('');
185
+ }
@@ -5,8 +5,10 @@ export interface FlatComponent {
5
5
  type: 'command' | 'agent' | 'skill' | 'hook' | 'mcp' | 'lsp';
6
6
  componentName: string;
7
7
  sourcePath: string;
8
+ downloadPath?: string;
8
9
  inlineConfig: unknown | null;
9
10
  }
11
+ export declare function getBaseDir(scope: Scope): string;
10
12
  export type InstallResult = {
11
13
  status: 'installed';
12
14
  lock: ComponentLock;
package/dist/download.js CHANGED
@@ -2,8 +2,8 @@ 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';
6
- function getBaseDir(scope) {
5
+ import { printError, printProgress } from './output.js';
6
+ export function getBaseDir(scope) {
7
7
  if (scope === 'user') {
8
8
  return join(homedir(), '.claude');
9
9
  }
@@ -43,8 +43,9 @@ function isSafePath(destPath, baseDir) {
43
43
  }
44
44
  export async function downloadSkill(component, scope) {
45
45
  const { source, sourcePath, componentName } = component;
46
- // Strip /SKILL.md to get directory path
47
- const dirPath = sourcePath.replace(/\/SKILL\.md$/, '');
46
+ const downloadSource = component.downloadPath ?? sourcePath;
47
+ // Strip /SKILL.md to get directory path (repo-root-relative for GitHub API)
48
+ const dirPath = downloadSource.replace(/\/SKILL\.md$/, '');
48
49
  const branch = await fetchDefaultBranch(source);
49
50
  if (!branch)
50
51
  return null;
@@ -63,18 +64,26 @@ export async function downloadSkill(component, scope) {
63
64
  printError(`Skipping unsafe skill name: ${componentName}`);
64
65
  return null;
65
66
  }
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)
67
+ printProgress(`${componentName} (skill) downloading ${files.length} file(s)...`);
68
+ const BATCH_SIZE = 5;
69
+ for (let i = 0; i < files.length; i += BATCH_SIZE) {
70
+ const batch = files.slice(i, i + BATCH_SIZE);
71
+ const results = await Promise.all(batch.map(async (file) => {
72
+ const relativePath = file.path.slice(dirPath.length + 1);
73
+ const destPath = join(installDir, relativePath);
74
+ if (!isSafePath(destPath, installDir)) {
75
+ printError(`Skipping unsafe path: ${file.path}`);
76
+ return true;
77
+ }
78
+ const content = await downloadFile(source, branch, file.path);
79
+ if (content === null)
80
+ return false;
81
+ ensureDir(destPath);
82
+ writeFileSync(destPath, content);
83
+ return true;
84
+ }));
85
+ if (results.includes(false))
75
86
  return null;
76
- ensureDir(destPath);
77
- writeFileSync(destPath, content);
78
87
  }
79
88
  const hash = findTreeSha(tree, dirPath) ?? 'unknown';
80
89
  return {
@@ -91,6 +100,7 @@ export async function downloadSkill(component, scope) {
91
100
  }
92
101
  export async function downloadSingleFile(component, scope) {
93
102
  const { source, type, sourcePath, componentName } = component;
103
+ const effectivePath = component.downloadPath ?? sourcePath;
94
104
  const branch = await fetchDefaultBranch(source);
95
105
  if (!branch)
96
106
  return null;
@@ -107,12 +117,12 @@ export async function downloadSingleFile(component, scope) {
107
117
  printError(`Skipping unsafe path: ${sourcePath}`);
108
118
  return null;
109
119
  }
110
- const content = await downloadFile(source, branch, sourcePath);
120
+ const content = await downloadFile(source, branch, effectivePath);
111
121
  if (content === null)
112
122
  return null;
113
123
  ensureDir(destPath);
114
124
  writeFileSync(destPath, content);
115
- const hash = findBlobSha(tree, sourcePath) ?? 'unknown';
125
+ const hash = findBlobSha(tree, effectivePath) ?? 'unknown';
116
126
  return {
117
127
  source,
118
128
  type,
package/dist/index.js CHANGED
@@ -3,11 +3,13 @@ import { resolveMarketplaceUrl, fetchMarketplace, flattenComponents } from './fe
3
3
  import { checkboxPrompt, scopePrompt, confirmPrompt } from './prompts.js';
4
4
  import { runInstall } from './install.js';
5
5
  import { runUpdate } from './update.js';
6
- import { runRemove } from './remove.js';
6
+ import { runRemove, runDirectRemove } 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
+ import { findWrapperScopes } from './wrapper.js';
11
+ import { readLock } from './lock.js';
12
+ import { parseArgs, validatePlugin, validateComponentFlag } from './args.js';
11
13
  import { print, printBanner, printStep, printSuccess, printError, DIM, RESET, GREEN, CYAN, YELLOW, } from './output.js';
12
14
  const TYPE_BADGES = {
13
15
  skill: `${GREEN}[skill]${RESET}`,
@@ -38,12 +40,27 @@ Note: owner/repo installs use Claude Code's plugin system.
38
40
  - Standalone: \`npx claudepluginhub remove owner/repo\` for cleanup
39
41
 
40
42
  Options:
41
- --plugin <name> Install a specific plugin from a marketplace repo
42
- --yes, -y Skip prompts (install all, project scope)
43
- --scope <scope> Set scope: user, project (default), local
44
- -h, --help Show this help
43
+ --plugin <name> Install a specific plugin from a marketplace repo
44
+ --skill <path> Install a single skill directly from a repo
45
+ --command <path> Install a single command directly from a repo
46
+ --agent <path> Install a single agent directly from a repo
47
+ --source-path <path> Plugin-relative path (for monorepo installs)
48
+ --yes, -y Skip prompts (install all, project scope)
49
+ --scope <scope> Set scope: user, project (default), local
50
+ -h, --help Show this help
45
51
  `);
46
52
  }
53
+ function findDirectInstallScopes(identifier) {
54
+ const found = [];
55
+ for (const scope of ['project', 'local', 'user']) {
56
+ const lock = readLock(scope);
57
+ const collection = lock.collections[identifier];
58
+ if (collection && collection.apiUrl === '') {
59
+ found.push(scope);
60
+ }
61
+ }
62
+ return found;
63
+ }
47
64
  async function main() {
48
65
  const args = process.argv.slice(2);
49
66
  if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
@@ -53,7 +70,8 @@ async function main() {
53
70
  }
54
71
  const parsed = parseArgs(args);
55
72
  validatePlugin(parsed);
56
- const { command, identifier, plugin, yes, scope: scopeOverride } = parsed;
73
+ validateComponentFlag(parsed);
74
+ const { command, identifier, plugin, yes, scope: scopeOverride, componentFlag } = parsed;
57
75
  printBanner();
58
76
  // Handle non-install commands
59
77
  if (command === 'list') {
@@ -69,9 +87,42 @@ async function main() {
69
87
  printError('Missing identifier. Usage: npx claudepluginhub remove <identifier>');
70
88
  process.exit(1);
71
89
  }
72
- // owner/repo remove → github wrapper cleanup
73
90
  if (isOwnerRepo(identifier)) {
74
- await runGithubRemove(identifier, scopeOverride);
91
+ // Scope-aware remove: check both direct installs and wrapper installs
92
+ const directScopes = findDirectInstallScopes(identifier);
93
+ const wrapperScopes = findWrapperScopes(identifier);
94
+ const allMatches = [
95
+ ...directScopes.map((s) => ({ scope: s, kind: 'direct' })),
96
+ ...wrapperScopes.map((s) => ({ scope: s, kind: 'wrapper' })),
97
+ ];
98
+ const filtered = scopeOverride
99
+ ? allMatches.filter((m) => m.scope === scopeOverride)
100
+ : allMatches;
101
+ // Case 1: Nothing found anywhere → fall back for marketplace guidance
102
+ if (allMatches.length === 0) {
103
+ await runGithubRemove(identifier, scopeOverride);
104
+ process.exit(0);
105
+ }
106
+ // Case 2: Found in other scopes but not the requested one
107
+ if (filtered.length === 0) {
108
+ const scopeList = [...new Set(allMatches.map((m) => m.scope))].join(', ');
109
+ printError(`"${identifier}" not found in ${scopeOverride} scope (found in: ${scopeList})`);
110
+ process.exit(1);
111
+ }
112
+ // Case 3: Multiple scopes without --scope
113
+ const uniqueScopes = [...new Set(filtered.map((m) => m.scope))];
114
+ if (uniqueScopes.length > 1) {
115
+ printError(`"${identifier}" found in multiple scopes: ${uniqueScopes.join(', ')}. Use --scope to specify.`);
116
+ process.exit(1);
117
+ }
118
+ // Case 4: Single scope — remove all matching kinds
119
+ const targetScope = uniqueScopes[0];
120
+ if (filtered.some((m) => m.kind === 'direct')) {
121
+ await runDirectRemove(identifier, targetScope);
122
+ }
123
+ if (filtered.some((m) => m.kind === 'wrapper')) {
124
+ await runGithubRemove(identifier, targetScope);
125
+ }
75
126
  process.exit(0);
76
127
  }
77
128
  await runRemove(identifier);
@@ -82,6 +133,12 @@ async function main() {
82
133
  printUsage();
83
134
  process.exit(1);
84
135
  }
136
+ // Direct component install: owner/repo + --skill/--command/--agent
137
+ if (componentFlag && isOwnerRepo(identifier)) {
138
+ const { runDirectComponentInstall } = await import('./component-install.js');
139
+ await runDirectComponentInstall(identifier, componentFlag.type, componentFlag.repoPath, componentFlag.pluginPath, yes, scopeOverride);
140
+ process.exit(0);
141
+ }
85
142
  // owner/repo → native Claude Code install
86
143
  if (isOwnerRepo(identifier)) {
87
144
  await runGithubInstall(identifier, yes, scopeOverride, plugin);
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
  }
package/dist/remove.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { Scope } from './prompts.js';
1
2
  import type { ComponentLock } from './lock.js';
2
3
  export declare function removeComponentByLock(comp: ComponentLock): void;
4
+ export declare function runDirectRemove(identifier: string, scope: Scope): Promise<void>;
3
5
  export declare function runRemove(identifier: string): Promise<void>;
package/dist/remove.js CHANGED
@@ -85,6 +85,24 @@ function removeFiles(lock) {
85
85
  export function removeComponentByLock(comp) {
86
86
  removeFiles(comp);
87
87
  }
88
+ export async function runDirectRemove(identifier, scope) {
89
+ const lock = readLock(scope);
90
+ const collection = lock.collections[identifier];
91
+ if (!collection) {
92
+ printError(`"${identifier}" not found in ${scope} scope`);
93
+ process.exit(1);
94
+ }
95
+ printStep(`Removing ${Object.keys(collection.components).length} component(s) from ${scope} scope...`);
96
+ for (const [, comp] of Object.entries(collection.components)) {
97
+ removeFiles(comp);
98
+ printSuccess(`${comp.componentName} (${comp.type})`);
99
+ }
100
+ removeCollection(lock, identifier);
101
+ writeLock(scope, lock);
102
+ print('');
103
+ print(`${BOLD}Removed${RESET} ${identifier}`);
104
+ print('');
105
+ }
88
106
  export async function runRemove(identifier) {
89
107
  // Try all scopes to find the collection
90
108
  const scopes = ['project', 'local', 'user'];
package/dist/update.js CHANGED
@@ -29,6 +29,11 @@ export async function runUpdate(identifier) {
29
29
  if (identifier && collection.identifier !== identifier)
30
30
  continue;
31
31
  found = true;
32
+ // Skip direct installs (apiUrl === '' sentinel)
33
+ if (collection.apiUrl === '') {
34
+ print(`${BOLD}${collection.identifier}${RESET} — direct install, re-run install command to update`);
35
+ continue;
36
+ }
32
37
  print(`${BOLD}Updating${RESET} ${collection.identifier}...`);
33
38
  const manifest = await fetchMarketplace(collection.apiUrl);
34
39
  if (!manifest) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudepluginhub",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Install Claude Code components from ClaudePluginHub",
5
5
  "bin": {
6
6
  "claudepluginhub": "dist/index.js"