@vibe-agent-toolkit/cli 0.1.15 → 0.1.16-rc.2

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.
Files changed (40) hide show
  1. package/dist/commands/claude/build.d.ts +4 -4
  2. package/dist/commands/claude/build.d.ts.map +1 -1
  3. package/dist/commands/claude/build.js +87 -122
  4. package/dist/commands/claude/build.js.map +1 -1
  5. package/dist/commands/claude/verify.d.ts +9 -3
  6. package/dist/commands/claude/verify.d.ts.map +1 -1
  7. package/dist/commands/claude/verify.js +60 -52
  8. package/dist/commands/claude/verify.js.map +1 -1
  9. package/dist/commands/skills/build.d.ts +4 -1
  10. package/dist/commands/skills/build.d.ts.map +1 -1
  11. package/dist/commands/skills/build.js +120 -101
  12. package/dist/commands/skills/build.js.map +1 -1
  13. package/dist/commands/skills/command-helpers.d.ts +12 -4
  14. package/dist/commands/skills/command-helpers.d.ts.map +1 -1
  15. package/dist/commands/skills/command-helpers.js +4 -4
  16. package/dist/commands/skills/command-helpers.js.map +1 -1
  17. package/dist/commands/skills/install-helpers.d.ts +2 -3
  18. package/dist/commands/skills/install-helpers.d.ts.map +1 -1
  19. package/dist/commands/skills/install-helpers.js.map +1 -1
  20. package/dist/commands/skills/install.d.ts +6 -1
  21. package/dist/commands/skills/install.d.ts.map +1 -1
  22. package/dist/commands/skills/install.js +182 -187
  23. package/dist/commands/skills/install.js.map +1 -1
  24. package/dist/commands/skills/shared.d.ts +10 -4
  25. package/dist/commands/skills/shared.d.ts.map +1 -1
  26. package/dist/commands/skills/shared.js +11 -9
  27. package/dist/commands/skills/shared.js.map +1 -1
  28. package/dist/commands/skills/skill-discovery.d.ts +20 -0
  29. package/dist/commands/skills/skill-discovery.d.ts.map +1 -0
  30. package/dist/commands/skills/skill-discovery.js +71 -0
  31. package/dist/commands/skills/skill-discovery.js.map +1 -0
  32. package/dist/commands/skills/uninstall.js +1 -1
  33. package/dist/commands/skills/uninstall.js.map +1 -1
  34. package/dist/commands/skills/validate-command.js +2 -2
  35. package/dist/commands/skills/validate-command.js.map +1 -1
  36. package/dist/commands/skills/validate.d.ts +2 -2
  37. package/dist/commands/skills/validate.d.ts.map +1 -1
  38. package/dist/commands/skills/validate.js +48 -66
  39. package/dist/commands/skills/validate.js.map +1 -1
  40. package/package.json +11 -11
@@ -1,13 +1,18 @@
1
1
  /**
2
- * Install a skill to Claude's plugins directory
2
+ * Install skills to Claude's plugins directory
3
3
  *
4
4
  * Supports installing from:
5
5
  * - npm packages (npm:@scope/package)
6
6
  * - Local ZIP file
7
7
  * - Local directory
8
8
  * - npm postinstall hook (--npm-postinstall)
9
+ *
10
+ * Plugin detection: looks for dist/.claude/plugins/marketplaces/ directory.
11
+ * When present, copies the pre-built directory tree to ~/.claude/plugins/
12
+ * and updates Claude's plugin registry files. When absent, falls back to
13
+ * copying dist/skills/ to ~/.claude/skills/.
9
14
  */
10
- import { existsSync, lstatSync, readFileSync } from 'node:fs';
15
+ import { existsSync, lstatSync, readdirSync, readFileSync } from 'node:fs';
11
16
  import { cp, mkdir, mkdtemp, rm, symlink } from 'node:fs/promises';
12
17
  import { basename, join, resolve } from 'node:path';
13
18
  import { getClaudeUserPaths, installPlugin } from '@vibe-agent-toolkit/claude-marketplace';
@@ -17,44 +22,61 @@ import { Command } from 'commander';
17
22
  import { handleCommandError } from '../../utils/command-error.js';
18
23
  import { createLogger } from '../../utils/logger.js';
19
24
  import { detectSource, downloadNpmPackage, isGlobalNpmInstall, readPackageJsonVatMetadata, writeYamlHeader, } from './install-helpers.js';
20
- /** Relative path within a VAT npm package to its marketplace manifest. */
21
- const MARKETPLACE_JSON_SUBPATH = join('dist', '.claude-plugin', 'marketplace.json');
25
+ /** Relative path within a VAT npm package to its pre-built plugin structure. */
26
+ const PLUGIN_MARKETPLACES_SUBPATH = join('dist', '.claude', 'plugins', 'marketplaces');
22
27
  const PACKAGE_JSON = 'package.json';
28
+ /**
29
+ * Install from a pre-built plugin tree: read package.json, copy tree, output success.
30
+ * Shared between npm and local install paths to avoid duplication.
31
+ */
32
+ async function installPluginTreeAndExit(rootDir, marketplacesDir, sourceLabel, sourceType, startTime, logger, dryRun) {
33
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- rootDir from controlled source
34
+ const pkgRaw = readFileSync(join(rootDir, PACKAGE_JSON), 'utf-8');
35
+ const packageJson = JSON.parse(pkgRaw);
36
+ await copyPluginTree(marketplacesDir, packageJson, logger);
37
+ const duration = Date.now() - startTime;
38
+ outputInstallSuccess([], sourceLabel, sourceType, duration, logger, dryRun);
39
+ process.exit(0);
40
+ }
41
+ /**
42
+ * Convert a skill name to a filesystem-safe path segment.
43
+ * Colons in colon-namespaced names (e.g. "pkg:sub") become "__".
44
+ */
45
+ function skillNameToFsPath(name) {
46
+ return name.replaceAll(':', '__');
47
+ }
23
48
  export function createInstallCommand() {
24
49
  const command = new Command('install');
25
50
  command
26
- .description('Install a skill to Claude Code skills directory')
51
+ .description('Install skills to Claude Code plugins directory')
27
52
  .argument('[source]', 'Source to install from (npm:package, ZIP file, or directory path)')
28
53
  .option('-s, --skills-dir <path>', 'Claude skills directory', getClaudeUserPaths().skillsDir)
29
54
  .option('-n, --name <name>', 'Custom name for installed skill (default: auto-detect from source)')
30
55
  .option('-f, --force', 'Overwrite existing skill if present', false)
31
56
  .option('--dry-run', 'Preview installation without creating files', false)
32
57
  .option('--npm-postinstall', 'Run as npm postinstall hook (internal use)', false)
33
- .option('-d, --dev', 'Development mode: symlink skills from cwd package.json (rebuilds reflected immediately)')
58
+ .option('-d, --dev', 'Development mode: symlink skills from dist/skills/ (rebuilds reflected immediately)')
34
59
  .option('--build', 'Build skills before installing (implies --dev)')
35
- .option('--user-install-without-plugin', 'Force skills-only install (skip plugin registry even if claude.marketplaces configured)', false)
60
+ .option('--user-install-without-plugin', 'Force skills-only install (skip plugin registry even if dist/.claude/ exists)', false)
36
61
  .option('--debug', 'Enable debug logging')
37
62
  .action(installCommand)
38
63
  .addHelpText('after', `
39
64
  Description:
40
- Installs skills to Claude Code's skills directory from various sources.
65
+ Installs skills to Claude Code's plugins directory from various sources.
66
+
67
+ Plugin detection: If the package contains dist/.claude/plugins/marketplaces/,
68
+ the pre-built directory tree is copied to ~/.claude/plugins/ (dumb copy).
69
+ Otherwise, falls back to copying dist/skills/ to ~/.claude/skills/.
41
70
 
42
71
  Supported sources:
43
72
  - npm package: npm:@scope/package-name
44
73
  - Local ZIP file: ./path/to/skill.zip
45
74
  - Local directory: ./path/to/skill-dir
46
75
  - npm postinstall: --npm-postinstall (automatic during global install)
47
- - Dev mode: --dev (symlinks from cwd package.json vat.skills[])
48
-
49
- Default skills directory: ~/.claude/skills/
50
-
51
- Dev mode creates symlinks so rebuilds are immediately reflected.
52
- Use --build to auto-build before symlinking.
76
+ - Dev mode: --dev (symlinks from dist/skills/)
53
77
 
54
78
  Output:
55
79
  - status: success/error
56
- - skillName: Name of installed skill
57
- - installPath: Where the skill was installed
58
80
  - source: Original source
59
81
  - sourceType: npm/local/zip/npm-postinstall/dev
60
82
 
@@ -66,9 +88,7 @@ Exit Codes:
66
88
  Example:
67
89
  $ vat skills install --dev # Symlink all skills from cwd
68
90
  $ vat skills install --build # Build + symlink
69
- $ vat skills install --dev --name my-skill # Symlink specific skill
70
91
  $ vat skills install npm:@scope/package # Install from npm
71
- $ vat skills install ./my-skill.zip --force # Install from ZIP
72
92
  `);
73
93
  return command;
74
94
  }
@@ -112,11 +132,9 @@ async function installCommand(source, options) {
112
132
  break;
113
133
  }
114
134
  case 'npm-postinstall': {
115
- // This case is handled by --npm-postinstall flag, not by source detection
116
135
  throw new Error('npm-postinstall source type should be handled by --npm-postinstall flag');
117
136
  }
118
137
  case 'dev': {
119
- // This case is handled by --dev flag, not by source detection
120
138
  throw new Error('dev source type should be handled by --dev flag');
121
139
  }
122
140
  }
@@ -127,7 +145,6 @@ async function installCommand(source, options) {
127
145
  }
128
146
  /**
129
147
  * Handle npm package installation
130
- * Installs ALL skills from the package (or filtered by --name)
131
148
  */
132
149
  async function handleNpmInstall(source, options, logger, startTime) {
133
150
  const packageName = source.startsWith('npm:') ? source.slice(4) : source;
@@ -138,80 +155,73 @@ async function handleNpmInstall(source, options, logger, startTime) {
138
155
  // Download and extract npm package
139
156
  logger.info(' Downloading package...');
140
157
  const extractedPath = downloadNpmPackage(packageName, tempDir);
141
- // If the package ships a plugin, install via the plugin system (namespaced skills).
142
- // Only fall back to ~/.claude/skills/ when no plugin is present or the caller
143
- // explicitly opts out with --user-install-without-plugin.
144
- const marketplaceJsonPath = join(extractedPath, MARKETPLACE_JSON_SUBPATH);
158
+ // If the package ships a pre-built plugin tree, install via dumb copy.
159
+ const marketplacesDir = join(extractedPath, PLUGIN_MARKETPLACES_SUBPATH);
145
160
  // eslint-disable-next-line security/detect-non-literal-fs-filename -- path from controlled extractedPath + constant subpath
146
- const hasPlugin = !options.userInstallWithoutPlugin && existsSync(marketplaceJsonPath);
161
+ const hasPlugin = !options.userInstallWithoutPlugin && existsSync(marketplacesDir);
147
162
  if (hasPlugin) {
148
- logger.info(' Plugin detected — installing via Claude plugin system (skills will be namespaced)');
149
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- path from controlled extractedPath + constant subpath
150
- const pkgRaw = readFileSync(join(extractedPath, PACKAGE_JSON), 'utf-8');
151
- const packageJson = JSON.parse(pkgRaw);
152
- await tryInstallPluginRegistry(extractedPath, packageJson, logger);
153
- const duration = Date.now() - startTime;
154
- outputInstallSuccess([], `npm:${packageName}`, 'npm', duration, logger, options.dryRun);
155
- process.exit(0);
163
+ logger.info(' Plugin detected — installing via Claude plugin system');
164
+ await installPluginTreeAndExit(extractedPath, marketplacesDir, `npm:${packageName}`, 'npm', startTime, logger, options.dryRun);
156
165
  }
157
- // No plugin — install skills directly to ~/.claude/skills/
166
+ // No plugin tree — install skills directly to ~/.claude/skills/
158
167
  const { skills } = await readPackageJsonVatMetadata(extractedPath);
159
168
  if (skills.length === 0) {
160
169
  throw new Error(`No skills found in package ${packageName}`);
161
170
  }
162
- // Filter by --name if specified, otherwise install all
163
- const skillsToInstall = options.name
164
- ? skills.filter(s => s.name === options.name)
171
+ // Filter by --name if specified
172
+ const skillNames = options.name
173
+ ? skills.filter(s => s === options.name)
165
174
  : skills;
166
- if (skillsToInstall.length === 0) {
175
+ if (skillNames.length === 0) {
167
176
  throw new Error(`Skill "${options.name ?? ''}" not found in package ${packageName}. ` +
168
- `Available: ${skills.map(s => s.name).join(', ')}`);
177
+ `Available: ${skills.join(', ')}`);
169
178
  }
170
179
  const skillsDir = options.skillsDir ?? getClaudeUserPaths().skillsDir;
171
- for (const skill of skillsToInstall) {
172
- const skillPath = resolve(extractedPath, skill.path);
173
- await installSkillFromPath(skillPath, skill.name, options, logger);
180
+ for (const skillName of skillNames) {
181
+ const skillPath = join(extractedPath, 'dist', 'skills', skillNameToFsPath(skillName));
182
+ await installSkillFromPath(skillPath, skillName, options, logger);
174
183
  }
175
184
  const duration = Date.now() - startTime;
176
- outputInstallSuccess(skillsToInstall.map(s => ({
177
- name: s.name,
178
- installPath: join(skillsDir, s.name),
185
+ outputInstallSuccess(skillNames.map(name => ({
186
+ name,
187
+ installPath: join(skillsDir, name),
179
188
  })), `npm:${packageName}`, 'npm', duration, logger, options.dryRun);
180
189
  process.exit(0);
181
190
  }
182
191
  finally {
183
- // Cleanup temp directory
184
192
  await rm(tempDir, { recursive: true, force: true });
185
193
  }
186
194
  }
187
195
  /**
188
196
  * Handle local directory installation
189
- * If directory has package.json with vat.skills, installs ALL skills (or filtered by --name)
190
197
  */
191
198
  async function handleLocalInstall(source, options, logger, startTime) {
192
199
  const sourcePath = resolve(source);
193
200
  logger.info(`📥 Installing skill from directory: ${sourcePath}`);
201
+ const skillsDir = options.skillsDir ?? getClaudeUserPaths().skillsDir;
202
+ // Check for pre-built plugin tree first
203
+ const marketplacesDir = join(sourcePath, PLUGIN_MARKETPLACES_SUBPATH);
204
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- User-provided CLI argument
205
+ if (!options.userInstallWithoutPlugin && existsSync(marketplacesDir)) {
206
+ await installPluginTreeAndExit(sourcePath, marketplacesDir, `local:${sourcePath}`, 'local', startTime, logger, options.dryRun);
207
+ }
194
208
  // Check if directory contains package.json with vat.skills
195
209
  const packageJsonPath = join(sourcePath, 'package.json');
196
210
  // eslint-disable-next-line security/detect-non-literal-fs-filename -- User-provided CLI argument
197
211
  const hasPackageJson = existsSync(packageJsonPath);
198
- const skillsDir = options.skillsDir ?? getClaudeUserPaths().skillsDir;
199
212
  let installed;
200
213
  if (hasPackageJson) {
201
- // Read vat metadata - install all skills (or filtered by --name)
202
214
  const { packageJson, skills } = await readPackageJsonVatMetadata(sourcePath);
203
- const skillsToInstall = options.name
204
- ? skills.filter(s => s.name === options.name)
205
- : skills;
206
- if (skillsToInstall.length === 0) {
215
+ const skillNames = options.name ? skills.filter(s => s === options.name) : skills;
216
+ if (skillNames.length === 0) {
207
217
  throw new Error(`Skill "${options.name ?? ''}" not found in package ${packageJson.name}. ` +
208
- `Available: ${skills.map(s => s.name).join(', ')}`);
218
+ `Available: ${skills.join(', ')}`);
209
219
  }
210
- for (const skill of skillsToInstall) {
211
- const skillPath = resolve(sourcePath, skill.path);
212
- await installSkillFromPath(skillPath, skill.name, options, logger);
220
+ for (const skillName of skillNames) {
221
+ const skillPath = join(sourcePath, 'dist', 'skills', skillNameToFsPath(skillName));
222
+ await installSkillFromPath(skillPath, skillName, options, logger);
213
223
  }
214
- installed = skillsToInstall.map(s => ({ name: s.name, installPath: join(skillsDir, s.name) }));
224
+ installed = skillNames.map(name => ({ name, installPath: join(skillsDir, name) }));
215
225
  }
216
226
  else {
217
227
  // Plain directory - use directory name
@@ -229,7 +239,6 @@ async function handleLocalInstall(source, options, logger, startTime) {
229
239
  async function handleZipInstall(source, options, logger, startTime) {
230
240
  const sourcePath = resolve(source);
231
241
  logger.info(`📥 Installing skill from ZIP: ${sourcePath}`);
232
- // Validate ZIP exists
233
242
  // eslint-disable-next-line security/detect-non-literal-fs-filename -- User-provided CLI argument
234
243
  if (!existsSync(sourcePath)) {
235
244
  throw new Error(`ZIP file not found: ${sourcePath}`);
@@ -237,7 +246,6 @@ async function handleZipInstall(source, options, logger, startTime) {
237
246
  const skillName = options.name ?? basename(sourcePath, '.zip');
238
247
  const { installPath } = await prepareInstallation(skillName, options);
239
248
  if (!options.dryRun) {
240
- // Extract ZIP
241
249
  logger.info(' Extracting ZIP...');
242
250
  const zip = new AdmZip(sourcePath);
243
251
  // eslint-disable-next-line sonarjs/no-unsafe-unzip -- User-provided local files, isolated plugin directory
@@ -247,38 +255,62 @@ async function handleZipInstall(source, options, logger, startTime) {
247
255
  outputInstallSuccess([{ name: skillName, installPath }], sourcePath, 'zip', duration, logger, options.dryRun);
248
256
  process.exit(0);
249
257
  }
258
+ /**
259
+ * Install a single skill as a dev symlink.
260
+ * Extracted from handleDevInstall to reduce cognitive complexity.
261
+ */
262
+ async function installDevSkill(skillName, cwd, skillsDir, options, logger) {
263
+ const fsPath = skillNameToFsPath(skillName);
264
+ const sourcePath = resolve(cwd, 'dist', 'skills', fsPath);
265
+ const installPath = join(skillsDir, skillName);
266
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- Validated path from package.json
267
+ if (!existsSync(sourcePath)) {
268
+ const buildCmd = options.name ? `vat skills build --skill ${skillName}` : 'vat skills build';
269
+ throw new Error(`Skill "${skillName}" not built at ${sourcePath}\n` +
270
+ `Run: ${buildCmd}\n` +
271
+ `Or use: vat skills install --build`);
272
+ }
273
+ const existingIsSymlink = await handleExistingDevInstall(installPath, skillName, options);
274
+ if (!options.dryRun) {
275
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- Skills directory from config
276
+ await mkdir(skillsDir, { recursive: true });
277
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- Validated paths
278
+ await symlink(sourcePath, installPath, 'dir');
279
+ }
280
+ const action = existingIsSymlink ? 'Re-symlinked' : 'Symlinked';
281
+ logger.info(` ${options.dryRun ? 'Would symlink' : action}: ${skillName} → ${sourcePath}`);
282
+ return { name: skillName, installPath, sourcePath };
283
+ }
250
284
  /**
251
285
  * Handle development mode installation (symlinks)
252
- * Reads package.json vat.skills[], symlinks each built skill to ~/.claude/skills/
286
+ * Reads package.json vat.skills[] (skill name strings), symlinks each built skill to ~/.claude/skills/
253
287
  */
254
288
  async function handleDevInstall(options, logger, startTime) {
255
- // Windows check - symlinks require elevated privileges
256
289
  if (process.platform === 'win32') {
257
290
  throw new Error('--dev (symlink) not supported on Windows.\n' +
258
291
  'Use copy mode (omit --dev) or WSL for development.');
259
292
  }
260
293
  const cwd = process.cwd();
261
- // If --build, shell out to vat skills build first
262
294
  if (options.build) {
263
295
  runSkillsBuild(cwd, options.name, logger);
264
296
  }
265
- // Read package.json for skill metadata
297
+ // Read package.json for skill names
266
298
  const { packageJson, skills } = await readPackageJsonVatMetadata(cwd);
267
299
  logger.info(`📥 Dev-installing skills from ${packageJson.name}`);
268
300
  // Filter by --name if specified
269
- const skillsToInstall = options.name
270
- ? skills.filter(s => s.name === options.name)
301
+ const skillNames = options.name
302
+ ? skills.filter(s => s === options.name)
271
303
  : skills;
272
- if (skillsToInstall.length === 0) {
304
+ if (skillNames.length === 0) {
273
305
  const msg = options.name
274
- ? `Skill "${options.name}" not found in package. Available: ${skills.map(s => s.name).join(', ')}`
306
+ ? `Skill "${options.name}" not found in package. Available: ${skills.join(', ')}`
275
307
  : `No skills found in ${packageJson.name}`;
276
308
  throw new Error(msg);
277
309
  }
278
310
  const skillsDir = options.skillsDir ?? getClaudeUserPaths().skillsDir;
279
311
  const installed = [];
280
- for (const skill of skillsToInstall) {
281
- const result = await symlinkSkill(skill, cwd, skillsDir, options, logger);
312
+ for (const skillName of skillNames) {
313
+ const result = await installDevSkill(skillName, cwd, skillsDir, options, logger);
282
314
  installed.push(result);
283
315
  }
284
316
  const duration = Date.now() - startTime;
@@ -301,43 +333,14 @@ function runSkillsBuild(cwd, skillName, logger) {
301
333
  });
302
334
  logger.info('');
303
335
  }
304
- /**
305
- * Symlink a single skill to the skills directory
306
- * Verifies skill is built, checks for existing installation, creates symlink
307
- */
308
- async function symlinkSkill(skill, cwd, skillsDir, options, logger) {
309
- const sourcePath = resolve(cwd, skill.path);
310
- const installPath = join(skillsDir, skill.name);
311
- // Verify skill is built
312
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- Validated path from package.json
313
- if (!existsSync(sourcePath)) {
314
- const buildCmd = options.name ? `vat skills build --skill ${skill.name}` : 'vat skills build';
315
- throw new Error(`Skill "${skill.name}" not built at ${sourcePath}\n` +
316
- `Run: ${buildCmd}\n` +
317
- `Or use: vat skills install --build`);
318
- }
319
- // Check existing installation
320
- const existingIsSymlink = await handleExistingDevInstall(installPath, skill.name, options);
321
- if (!options.dryRun) {
322
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- Skills directory from config
323
- await mkdir(skillsDir, { recursive: true });
324
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- Validated paths
325
- await symlink(sourcePath, installPath, 'dir');
326
- }
327
- const action = existingIsSymlink ? 'Re-symlinked' : 'Symlinked';
328
- logger.info(` ${options.dryRun ? 'Would symlink' : action}: ${skill.name} → ${sourcePath}`);
329
- return { name: skill.name, installPath, sourcePath };
330
- }
331
336
  /**
332
337
  * Check if a skill is already installed at the target path
333
- * Returns whether the existing entry was a symlink (for logging)
334
338
  */
335
339
  async function handleExistingDevInstall(installPath, skillName, options) {
336
340
  let existingIsSymlink = false;
337
341
  try {
338
342
  // eslint-disable-next-line security/detect-non-literal-fs-filename -- Validated install path
339
343
  lstatSync(installPath);
340
- // Path exists (lstat doesn't follow symlinks, so broken symlinks are detected)
341
344
  if (!options.force) {
342
345
  throw new Error(`Skill "${skillName}" already installed at ${installPath}.\n` +
343
346
  `Use --force to overwrite.`);
@@ -349,69 +352,39 @@ async function handleExistingDevInstall(installPath, skillName, options) {
349
352
  }
350
353
  }
351
354
  catch (error) {
352
- // Re-throw if it's our "already installed" error
353
355
  if (error instanceof Error && error.message.includes('already installed')) {
354
356
  throw error;
355
357
  }
356
- // Otherwise: not installed, continue
357
358
  }
358
359
  return existingIsSymlink;
359
360
  }
360
- /**
361
- * Output success YAML for dev install (multiple skills)
362
- */
363
- function outputDevSuccess(installed, packageName, duration, logger, dryRun) {
364
- writeYamlHeader(dryRun);
365
- process.stdout.write(`sourceType: dev\n`);
366
- process.stdout.write(`package: "${packageName}"\n`);
367
- process.stdout.write(`skillsInstalled: ${installed.length}\n`);
368
- process.stdout.write(`symlink: true\n`);
369
- process.stdout.write(`skills:\n`);
370
- for (const skill of installed) {
371
- process.stdout.write(` - name: ${skill.name}\n`);
372
- process.stdout.write(` installPath: ${skill.installPath}\n`);
373
- process.stdout.write(` sourcePath: ${skill.sourcePath}\n`);
374
- }
375
- process.stdout.write(`duration: ${duration}ms\n`);
376
- if (dryRun) {
377
- logger.info(`\n✅ Dry-run complete: ${installed.length} skill(s) would be symlinked`);
378
- }
379
- else {
380
- logger.info(`\n✅ Dev-installed ${installed.length} skill(s) via symlink`);
381
- logger.info(` After rebuilding, run /reload-skills in Claude Code`);
382
- }
383
- }
384
361
  /**
385
362
  * Handle npm postinstall hook
386
363
  */
387
364
  async function handleNpmPostinstall(options, logger, startTime) {
388
365
  logger.info(`📥 Running npm postinstall hook`);
389
- // Check if this is a global install
390
366
  if (!isGlobalNpmInstall()) {
391
367
  logger.info(' Skipping: Not a global npm install');
392
368
  process.exit(0);
393
369
  }
394
- // Read package.json from current directory
395
370
  const cwd = process.cwd();
396
- // If the package ships a plugin, install via the plugin system (namespaced skills).
397
- // Only fall back to ~/.claude/skills/ when explicitly opted out with --user-install-without-plugin.
398
- const marketplaceJsonPath = join(cwd, MARKETPLACE_JSON_SUBPATH);
399
- const marketplaceExists = existsSync(marketplaceJsonPath);
371
+ // Check for pre-built plugin tree (dist/.claude/plugins/marketplaces/)
372
+ const marketplacesDir = join(cwd, PLUGIN_MARKETPLACES_SUBPATH);
373
+ const hasPluginTree = existsSync(marketplacesDir);
400
374
  if (!options.userInstallWithoutPlugin) {
401
- if (marketplaceExists) {
375
+ if (hasPluginTree) {
402
376
  const pkgRaw = readFileSync(join(cwd, PACKAGE_JSON), 'utf-8');
403
377
  const packageJson = JSON.parse(pkgRaw);
404
378
  logger.info(` Package: ${packageJson.name}@${packageJson.version ?? 'unknown'}`);
405
- logger.info(` Plugin detected — installing via Claude plugin system (skills will be namespaced)`);
406
- await tryInstallPluginRegistry(cwd, packageJson, logger);
379
+ logger.info(` Plugin tree detected — copying to ~/.claude/plugins/`);
380
+ await copyPluginTree(marketplacesDir, packageJson, logger);
407
381
  const duration = Date.now() - startTime;
408
382
  logger.info(`✅ Installed plugin from ${packageJson.name}`);
409
383
  logger.info(` Duration: ${duration}ms`);
410
384
  }
411
385
  else {
412
- // Plugin not built yet — guide the publisher to run vat build first.
413
- logger.info(` No plugin found at ${MARKETPLACE_JSON_SUBPATH}`);
414
- logger.info(` Run 'vat build' to generate dist/.claude-plugin/marketplace.json before publishing.`);
386
+ logger.info(` No plugin tree found at dist/.claude/plugins/marketplaces/`);
387
+ logger.info(` Run 'vat build' to generate plugin artifacts before publishing.`);
415
388
  logger.info(` Skipping install — no skills registered.`);
416
389
  }
417
390
  process.exit(0);
@@ -420,9 +393,9 @@ async function handleNpmPostinstall(options, logger, startTime) {
420
393
  const { packageJson, skills } = await readPackageJsonVatMetadata(cwd);
421
394
  logger.info(` Package: ${packageJson.name}@${packageJson.version}`);
422
395
  logger.info(` Skills found: ${skills.length}`);
423
- for (const skill of skills) {
424
- const skillPath = resolve(cwd, skill.path);
425
- await installSkillFromPath(skillPath, skill.name, options, logger);
396
+ for (const skillName of skills) {
397
+ const skillPath = join(cwd, 'dist', 'skills', skillNameToFsPath(skillName));
398
+ await installSkillFromPath(skillPath, skillName, options, logger);
426
399
  }
427
400
  const duration = Date.now() - startTime;
428
401
  logger.info(`✅ Installed ${skills.length} skill(s) from ${packageJson.name}`);
@@ -430,63 +403,63 @@ async function handleNpmPostinstall(options, logger, startTime) {
430
403
  process.exit(0);
431
404
  }
432
405
  /**
433
- * Attempt to register installed plugins with the Claude plugin registry.
434
- * Reads dist/.claude-plugin/marketplace.json (published in the npm package's dist/).
435
- * Failures are logged as warningsnever throws.
406
+ * Copy pre-built plugin tree to ~/.claude/plugins/ and update registry.
407
+ *
408
+ * This is a "dumb copy"the dist/.claude/plugins/marketplaces/ tree mirrors
409
+ * the target ~/.claude/plugins/marketplaces/ structure exactly. No path rewriting,
410
+ * no assembly, no skill resolution. Just recursive copy + registry update.
436
411
  */
437
- async function tryInstallPluginRegistry(cwd, packageJson, logger) {
438
- const marketplaceJsonPath = join(cwd, MARKETPLACE_JSON_SUBPATH);
439
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- Resolved from known cwd + constant subpath
440
- if (!existsSync(marketplaceJsonPath)) {
441
- logger.info(` ℹ️ No plugin artifacts found (dist/.claude-plugin/marketplace.json)`);
442
- logger.info(` Run 'vat build' before publishing to register plugins in Claude`);
443
- return;
444
- }
445
- let marketplace;
446
- try {
447
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- Resolved from known cwd + constant subpath
448
- const raw = readFileSync(marketplaceJsonPath, 'utf-8');
449
- marketplace = JSON.parse(raw);
450
- }
451
- catch (error) {
452
- logger.info(` Warning: Could not parse dist/.claude-plugin/marketplace.json: ${String(error)}`);
453
- return;
454
- }
455
- const marketplaceName = marketplace.name;
456
- const plugins = marketplace.plugins ?? [];
412
+ async function copyPluginTree(marketplacesDir, packageJson, logger) {
457
413
  const paths = getClaudeUserPaths();
458
414
  const version = packageJson.version ?? '0.0.0';
459
- for (const plugin of plugins) {
460
- const pluginDir = join(cwd, 'dist', 'plugins', plugin.name);
461
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- Resolved from known cwd + marketplace data
462
- if (!existsSync(pluginDir)) {
463
- logger.info(` Skipping plugin ${plugin.name}: not built at ${pluginDir}`);
415
+ // Scan for marketplace directories
416
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- Resolved from constant subpath
417
+ const marketplaceNames = readdirSync(marketplacesDir, { withFileTypes: true })
418
+ .filter(d => d.isDirectory())
419
+ .map(d => d.name);
420
+ for (const mpName of marketplaceNames) {
421
+ const srcMpDir = join(marketplacesDir, mpName);
422
+ const destMpDir = join(paths.marketplacesDir, mpName);
423
+ // Copy entire marketplace directory tree
424
+ logger.info(` Copying marketplace: ${mpName} → ${destMpDir}`);
425
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- Resolved from constant subpath
426
+ await mkdir(destMpDir, { recursive: true });
427
+ await cp(srcMpDir, destMpDir, { recursive: true, force: true });
428
+ // Discover plugins within the marketplace for registry updates
429
+ const pluginsDir = join(srcMpDir, 'plugins');
430
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- Resolved from constant subpath
431
+ if (!existsSync(pluginsDir)) {
464
432
  continue;
465
433
  }
466
- try {
467
- await installPlugin({
468
- marketplaceName,
469
- pluginName: plugin.name,
470
- pluginDir,
471
- version,
472
- source: { source: 'npm', package: packageJson.name, version },
473
- paths,
474
- });
475
- logger.info(` Registered plugin ${plugin.name}@${marketplaceName} in Claude plugin registry`);
476
- }
477
- catch (error) {
478
- logger.info(` Warning: Could not register plugin ${plugin.name}: ${String(error)}`);
434
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- Resolved from constant subpath
435
+ const pluginNames = readdirSync(pluginsDir, { withFileTypes: true })
436
+ .filter(d => d.isDirectory())
437
+ .map(d => d.name);
438
+ for (const pluginName of pluginNames) {
439
+ const pluginDir = join(destMpDir, 'plugins', pluginName);
440
+ try {
441
+ await installPlugin({
442
+ marketplaceName: mpName,
443
+ pluginName,
444
+ pluginDir,
445
+ version,
446
+ source: { source: 'npm', package: packageJson.name, version },
447
+ paths,
448
+ });
449
+ logger.info(` Registered plugin ${pluginName}@${mpName} in Claude plugin registry`);
450
+ }
451
+ catch (error) {
452
+ logger.info(` Warning: Could not register plugin ${pluginName}: ${String(error)}`);
453
+ }
479
454
  }
480
455
  }
481
456
  }
482
457
  /**
483
458
  * Prepare plugins directory and check for conflicts
484
- * Returns the install path for the skill
485
459
  */
486
460
  async function prepareInstallation(skillName, options) {
487
461
  const skillsDir = options.skillsDir ?? getClaudeUserPaths().skillsDir;
488
462
  const installPath = join(skillsDir, skillName);
489
- // Check if skill already exists (lstatSync detects broken symlinks too)
490
463
  let exists = false;
491
464
  try {
492
465
  // eslint-disable-next-line security/detect-non-literal-fs-filename -- Install path from config
@@ -503,7 +476,6 @@ async function prepareInstallation(skillName, options) {
503
476
  await rm(installPath, { recursive: true, force: true });
504
477
  }
505
478
  if (!options.dryRun) {
506
- // Create skills directory
507
479
  // eslint-disable-next-line security/detect-non-literal-fs-filename -- Skills directory path, safe
508
480
  await mkdir(skillsDir, { recursive: true });
509
481
  }
@@ -519,13 +491,36 @@ async function installSkillFromPath(skillPath, skillName, options, logger) {
519
491
  }
520
492
  const { installPath } = await prepareInstallation(skillName, options);
521
493
  if (!options.dryRun) {
522
- // Copy skill to plugins directory
523
494
  logger.info(` Installing ${skillName}...`);
524
495
  await cp(skillPath, installPath, { recursive: true, force: true });
525
496
  }
526
497
  }
527
498
  /**
528
- * Output success YAML and human-readable messages for install (supports multiple skills)
499
+ * Output success YAML for dev install
500
+ */
501
+ function outputDevSuccess(installed, packageName, duration, logger, dryRun) {
502
+ writeYamlHeader(dryRun);
503
+ process.stdout.write(`sourceType: dev\n`);
504
+ process.stdout.write(`package: "${packageName}"\n`);
505
+ process.stdout.write(`skillsInstalled: ${installed.length}\n`);
506
+ process.stdout.write(`symlink: true\n`);
507
+ process.stdout.write(`skills:\n`);
508
+ for (const skill of installed) {
509
+ process.stdout.write(` - name: ${skill.name}\n`);
510
+ process.stdout.write(` installPath: ${skill.installPath}\n`);
511
+ process.stdout.write(` sourcePath: ${skill.sourcePath}\n`);
512
+ }
513
+ process.stdout.write(`duration: ${duration}ms\n`);
514
+ if (dryRun) {
515
+ logger.info(`\n✅ Dry-run complete: ${installed.length} skill(s) would be symlinked`);
516
+ }
517
+ else {
518
+ logger.info(`\n✅ Dev-installed ${installed.length} skill(s) via symlink`);
519
+ logger.info(` After rebuilding, run /reload-skills in Claude Code`);
520
+ }
521
+ }
522
+ /**
523
+ * Output success YAML for install
529
524
  */
530
525
  function outputInstallSuccess(skills, source, sourceType, duration, logger, dryRun) {
531
526
  writeYamlHeader(dryRun);