@dboio/cli 0.6.4 → 0.6.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dboio/cli",
3
- "version": "0.6.4",
3
+ "version": "0.6.5",
4
4
  "description": "CLI for the DBO.io framework",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,13 +1,8 @@
1
1
  {
2
2
  "name": "dbo",
3
- "version": "0.6.4",
3
+ "version": "0.6.5",
4
4
  "description": "DBO.io CLI integration for Claude Code",
5
- "author": "DBO.io",
6
- "skills": [
7
- {
8
- "path": "skills/cli/SKILL.md",
9
- "name": "cli",
10
- "description": "Execute DBO.io CLI commands"
11
- }
12
- ]
5
+ "author": {
6
+ "name": "DBO.io"
7
+ }
13
8
  }
@@ -1,5 +1,5 @@
1
1
  import { Command } from 'commander';
2
- import { readdir, readFile, writeFile, mkdir, access, copyFile, cp } from 'fs/promises';
2
+ import { readdir, readFile, writeFile, mkdir, access, copyFile, cp, rm } from 'fs/promises';
3
3
  import { join, dirname, resolve } from 'path';
4
4
  import { fileURLToPath } from 'url';
5
5
  import { execSync } from 'child_process';
@@ -9,6 +9,10 @@ import { homedir } from 'os';
9
9
  import { log } from '../lib/logger.js';
10
10
  import { getPluginScope, setPluginScope, isInitialized, removeFromGitignore } from '../lib/config.js';
11
11
 
12
+ const CLAUDE_PLUGINS_DIR = join(homedir(), '.claude', 'plugins');
13
+ const PLUGIN_REGISTRY_PATH = join(CLAUDE_PLUGINS_DIR, 'installed_plugins.json');
14
+ const PLUGIN_MARKETPLACE = 'dboio';
15
+
12
16
  const __dirname = dirname(fileURLToPath(import.meta.url));
13
17
  const CLI_ROOT = join(__dirname, '..', '..');
14
18
  const LEGACY_PLUGINS_DIR = join(__dirname, '..', 'plugins', 'claudecommands');
@@ -36,16 +40,82 @@ function getCommandsDir(scope) {
36
40
 
37
41
  /**
38
42
  * Get the target plugins directory based on scope (new directory-based format).
43
+ * For global scope, returns the cache path that Claude Code expects.
39
44
  * @param {'project' | 'global'} scope
40
45
  * @returns {string} Absolute path to plugins directory
41
46
  */
42
47
  function getPluginsDir(scope) {
43
48
  if (scope === 'global') {
44
- return join(homedir(), '.claude', 'plugins');
49
+ return join(CLAUDE_PLUGINS_DIR, 'cache', PLUGIN_MARKETPLACE);
45
50
  }
46
51
  return join(process.cwd(), '.claude', 'plugins');
47
52
  }
48
53
 
54
+ /**
55
+ * Get the install path for a global plugin in the cache directory.
56
+ * @param {string} pluginName - Plugin name
57
+ * @param {string} version - Plugin version
58
+ * @returns {string} Absolute path like ~/.claude/plugins/cache/dboio/<name>/<version>/
59
+ */
60
+ function getGlobalPluginCachePath(pluginName, version) {
61
+ return join(CLAUDE_PLUGINS_DIR, 'cache', PLUGIN_MARKETPLACE, pluginName, version);
62
+ }
63
+
64
+ /**
65
+ * Read the Claude Code plugin registry (installed_plugins.json).
66
+ * @returns {Promise<object>} The registry object
67
+ */
68
+ async function readPluginRegistry() {
69
+ try {
70
+ const content = await readFile(PLUGIN_REGISTRY_PATH, 'utf8');
71
+ return JSON.parse(content);
72
+ } catch {
73
+ return { version: 2, plugins: {} };
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Register a plugin in Claude Code's installed_plugins.json.
79
+ * @param {string} pluginName - Plugin name
80
+ * @param {string} version - Plugin version
81
+ * @param {string} installPath - Absolute path to installed plugin
82
+ */
83
+ async function registerPlugin(pluginName, version, installPath) {
84
+ const registry = await readPluginRegistry();
85
+ const key = `${pluginName}@${PLUGIN_MARKETPLACE}`;
86
+ const now = new Date().toISOString();
87
+
88
+ const existing = registry.plugins[key]?.[0];
89
+ if (existing) {
90
+ existing.installPath = installPath;
91
+ existing.version = version;
92
+ existing.lastUpdated = now;
93
+ } else {
94
+ registry.plugins[key] = [{
95
+ scope: 'user',
96
+ installPath,
97
+ version,
98
+ installedAt: now,
99
+ lastUpdated: now,
100
+ }];
101
+ }
102
+
103
+ await writeFile(PLUGIN_REGISTRY_PATH, JSON.stringify(registry, null, 2) + '\n');
104
+ }
105
+
106
+ /**
107
+ * Unregister a plugin from Claude Code's installed_plugins.json.
108
+ * @param {string} pluginName - Plugin name
109
+ */
110
+ async function unregisterPlugin(pluginName) {
111
+ const registry = await readPluginRegistry();
112
+ const key = `${pluginName}@${PLUGIN_MARKETPLACE}`;
113
+ if (registry.plugins[key]) {
114
+ delete registry.plugins[key];
115
+ await writeFile(PLUGIN_REGISTRY_PATH, JSON.stringify(registry, null, 2) + '\n');
116
+ }
117
+ }
118
+
49
119
  /**
50
120
  * Check if a plugin source is a directory-based plugin (has .claude-plugin/).
51
121
  * @param {string} pluginPath - Path to the plugin directory
@@ -147,16 +217,23 @@ async function resolvePluginScope(pluginName, options) {
147
217
 
148
218
  /**
149
219
  * Check if plugin exists in both project and global locations.
150
- * Checks both legacy (.claude/commands/) and new (.claude/plugins/) paths.
220
+ * Checks cache path, old direct path, and legacy (.claude/commands/) paths.
151
221
  * @param {string} pluginName - Plugin name (without extension)
152
222
  * @param {string} [legacyFileName] - Legacy filename (with .md) for backward compat
153
223
  * @returns {Promise<{project: boolean, global: boolean}>}
154
224
  */
155
225
  async function checkPluginLocations(pluginName, legacyFileName) {
156
226
  const projectPlugin = join(getPluginsDir('project'), pluginName);
157
- const globalPlugin = join(getPluginsDir('global'), pluginName);
158
227
  let project = await fileExists(projectPlugin);
159
- let global = await fileExists(globalPlugin);
228
+
229
+ // Check global: cache path first, then old direct path
230
+ const registry = await readPluginRegistry();
231
+ const key = `${pluginName}@${PLUGIN_MARKETPLACE}`;
232
+ let global = !!registry.plugins[key];
233
+ if (!global) {
234
+ // Check old direct path (~/.claude/plugins/<name>/)
235
+ global = await fileExists(join(CLAUDE_PLUGINS_DIR, pluginName, '.claude-plugin', 'plugin.json'));
236
+ }
160
237
 
161
238
  // Also check legacy locations
162
239
  if (legacyFileName) {
@@ -533,25 +610,29 @@ export async function installOrUpdateClaudeCommands(options = {}) {
533
610
  }
534
611
  }
535
612
 
536
- // Persist the scope preference
537
- if (options.global || options.local || !await getPluginScope(plugin.name)) {
538
- if (hasProject) {
539
- await setPluginScope(plugin.name, targetScope);
540
- } else if (targetScope === 'global') {
541
- log.warn(`Cannot persist scope preference (no .dbo/ directory). Run "dbo init" first.`);
542
- }
543
- }
544
-
545
613
  if (plugin.type === 'directory') {
546
614
  // New directory-based plugin installation
547
- const destDir = join(getPluginsDir(targetScope), plugin.name);
615
+ // Read version from plugin.json
616
+ const pluginMeta = JSON.parse(await readFile(join(plugin.path, '.claude-plugin', 'plugin.json'), 'utf8'));
617
+ const pluginVersion = pluginMeta.version || '0.0.0';
618
+
619
+ // For global scope, use the cache path and register in installed_plugins.json
620
+ const destDir = targetScope === 'global'
621
+ ? getGlobalPluginCachePath(plugin.name, pluginVersion)
622
+ : join(getPluginsDir('project'), plugin.name);
548
623
  const scopeLabel = targetScope === 'global' ? '~/.claude/plugins/' : '.claude/plugins/';
549
624
 
550
- if (await fileExists(destDir)) {
625
+ // Check for existing installation (cache path or old direct path)
626
+ const oldDirectPath = join(CLAUDE_PLUGINS_DIR, plugin.name);
627
+ const existingPath = await fileExists(destDir) ? destDir
628
+ : (targetScope === 'global' && await fileExists(oldDirectPath)) ? oldDirectPath
629
+ : null;
630
+
631
+ if (existingPath) {
551
632
  // Check if upgrade needed via directory hash
552
633
  const srcHash = await directoryHash(plugin.path);
553
- const destHash = await directoryHash(destDir);
554
- if (srcHash === destHash) {
634
+ const destHash = await directoryHash(existingPath);
635
+ if (srcHash === destHash && existingPath === destDir) {
555
636
  upToDate++;
556
637
  continue;
557
638
  }
@@ -568,12 +649,29 @@ export async function installOrUpdateClaudeCommands(options = {}) {
568
649
  continue;
569
650
  }
570
651
 
652
+ await mkdir(destDir, { recursive: true });
571
653
  await cp(plugin.path, destDir, { recursive: true, force: true });
654
+
655
+ // Clean up old direct path if migrating to cache path
656
+ if (targetScope === 'global' && existingPath === oldDirectPath && existingPath !== destDir) {
657
+ await rm(oldDirectPath, { recursive: true, force: true });
658
+ log.dim(` Migrated from ${oldDirectPath} to cache path`);
659
+ }
660
+
661
+ if (targetScope === 'global') {
662
+ await registerPlugin(plugin.name, pluginVersion, destDir);
663
+ }
664
+
572
665
  log.success(`Upgraded ${scopeLabel}${plugin.name}/`);
573
666
  updated++;
574
667
  } else {
575
668
  await mkdir(destDir, { recursive: true });
576
669
  await cp(plugin.path, destDir, { recursive: true });
670
+
671
+ if (targetScope === 'global') {
672
+ await registerPlugin(plugin.name, pluginVersion, destDir);
673
+ }
674
+
577
675
  log.success(`Installed ${scopeLabel}${plugin.name}/`);
578
676
  installed++;
579
677
 
@@ -582,6 +680,17 @@ export async function installOrUpdateClaudeCommands(options = {}) {
582
680
  }
583
681
  }
584
682
 
683
+ // Persist scope + metadata to config.local.json
684
+ if (hasProject && (options.global || options.local || !await getPluginScope(plugin.name))) {
685
+ await setPluginScope(plugin.name, {
686
+ scope: targetScope === 'global' ? 'user' : 'project',
687
+ installPath: destDir,
688
+ version: pluginVersion,
689
+ });
690
+ } else if (targetScope === 'global' && !hasProject) {
691
+ log.warn(`Cannot persist scope preference (no .dbo/ directory). Run "dbo init" first.`);
692
+ }
693
+
585
694
  // Clean up legacy file if it exists
586
695
  const legacyProjectPath = join(getCommandsDir('project'), legacyFileName);
587
696
  const legacyGlobalPath = join(getCommandsDir('global'), legacyFileName);
@@ -644,6 +753,11 @@ export async function installOrUpdateClaudeCommands(options = {}) {
644
753
  if (targetScope === 'global' && locations.project) {
645
754
  await removeFromGitignore(`.claude/commands/${legacyFileName}`);
646
755
  }
756
+
757
+ // Persist scope for legacy plugins
758
+ if (hasProject && (options.global || options.local || !await getPluginScope(plugin.name))) {
759
+ await setPluginScope(plugin.name, targetScope);
760
+ }
647
761
  }
648
762
  }
649
763
 
@@ -697,24 +811,26 @@ async function installOrUpdateSpecificCommand(name, options = {}) {
697
811
  targetScope = await resolvePluginScope(pluginName, options);
698
812
  }
699
813
 
700
- // Persist scope
701
- if (options.global || options.local || !await getPluginScope(pluginName)) {
702
- if (hasProject) {
703
- await setPluginScope(pluginName, targetScope);
704
- } else if (targetScope === 'global') {
705
- log.warn(`Cannot persist scope preference (no .dbo/ directory). Run "dbo init" first.`);
706
- }
707
- }
708
-
709
814
  if (plugin.type === 'directory') {
710
815
  // Directory-based plugin installation
711
- const destDir = join(getPluginsDir(targetScope), pluginName);
816
+ const pluginMeta = JSON.parse(await readFile(join(plugin.path, '.claude-plugin', 'plugin.json'), 'utf8'));
817
+ const pluginVersion = pluginMeta.version || '0.0.0';
818
+
819
+ const destDir = targetScope === 'global'
820
+ ? getGlobalPluginCachePath(pluginName, pluginVersion)
821
+ : join(getPluginsDir('project'), pluginName);
712
822
  const scopeLabel = targetScope === 'global' ? '~/.claude/plugins/' : '.claude/plugins/';
713
823
 
714
- if (await fileExists(destDir)) {
824
+ // Check for existing installation (cache path or old direct path)
825
+ const oldDirectPath = join(CLAUDE_PLUGINS_DIR, pluginName);
826
+ const existingPath = await fileExists(destDir) ? destDir
827
+ : (targetScope === 'global' && await fileExists(oldDirectPath)) ? oldDirectPath
828
+ : null;
829
+
830
+ if (existingPath) {
715
831
  const srcHash = await directoryHash(plugin.path);
716
- const destHash = await directoryHash(destDir);
717
- if (srcHash === destHash) {
832
+ const destHash = await directoryHash(existingPath);
833
+ if (srcHash === destHash && existingPath === destDir) {
718
834
  log.success(`${pluginName} is already up to date in ${scopeLabel}`);
719
835
  return;
720
836
  }
@@ -727,11 +843,28 @@ async function installOrUpdateSpecificCommand(name, options = {}) {
727
843
  }]);
728
844
  if (!upgrade) return;
729
845
 
846
+ await mkdir(destDir, { recursive: true });
730
847
  await cp(plugin.path, destDir, { recursive: true, force: true });
848
+
849
+ // Clean up old direct path if migrating to cache path
850
+ if (targetScope === 'global' && existingPath === oldDirectPath && existingPath !== destDir) {
851
+ await rm(oldDirectPath, { recursive: true, force: true });
852
+ log.dim(` Migrated from ${oldDirectPath} to cache path`);
853
+ }
854
+
855
+ if (targetScope === 'global') {
856
+ await registerPlugin(pluginName, pluginVersion, destDir);
857
+ }
858
+
731
859
  log.success(`Upgraded ${scopeLabel}${pluginName}/`);
732
860
  } else {
733
861
  await mkdir(destDir, { recursive: true });
734
862
  await cp(plugin.path, destDir, { recursive: true });
863
+
864
+ if (targetScope === 'global') {
865
+ await registerPlugin(pluginName, pluginVersion, destDir);
866
+ }
867
+
735
868
  log.success(`Installed ${scopeLabel}${pluginName}/`);
736
869
 
737
870
  if (targetScope === 'project') {
@@ -739,6 +872,17 @@ async function installOrUpdateSpecificCommand(name, options = {}) {
739
872
  }
740
873
  }
741
874
 
875
+ // Persist scope + metadata to config.local.json
876
+ if (hasProject && (options.global || options.local || !await getPluginScope(pluginName))) {
877
+ await setPluginScope(pluginName, {
878
+ scope: targetScope === 'global' ? 'user' : 'project',
879
+ installPath: destDir,
880
+ version: pluginVersion,
881
+ });
882
+ } else if (targetScope === 'global' && !hasProject) {
883
+ log.warn(`Cannot persist scope preference (no .dbo/ directory). Run "dbo init" first.`);
884
+ }
885
+
742
886
  // Clean up legacy files
743
887
  const legacyProjectPath = join(getCommandsDir('project'), legacyFileName);
744
888
  const legacyGlobalPath = join(getCommandsDir('global'), legacyFileName);
@@ -795,6 +939,11 @@ async function installOrUpdateSpecificCommand(name, options = {}) {
795
939
  if (targetScope === 'global' && locations.project) {
796
940
  await removeFromGitignore(`.claude/commands/${legacyFileName}`);
797
941
  }
942
+
943
+ // Persist scope for legacy plugins
944
+ if (hasProject && (options.global || options.local || !await getPluginScope(pluginName))) {
945
+ await setPluginScope(pluginName, targetScope);
946
+ }
798
947
  }
799
948
 
800
949
  log.warn('Note: Plugins will be available in new Claude Code sessions (restart any active session).');
package/src/lib/config.js CHANGED
@@ -420,38 +420,81 @@ export async function saveLocalConfig(data) {
420
420
  }
421
421
 
422
422
  /**
423
- * Get the stored scope for a plugin by name (without extension).
424
- * @param {string} pluginName - Plugin name without extension
425
- * @param {string} [category='claudecommands'] - Plugin category
423
+ * Get the stored scope for a plugin.
424
+ * Reads from the registry-style format: plugins["name@marketplace"][0].scope
425
+ * Falls back to legacy format: plugins.claudecommands.name
426
+ * @param {string} pluginName - Plugin name (without extension)
427
+ * @param {string} [marketplace='dboio'] - Marketplace identifier
426
428
  * @returns {Promise<'project' | 'global' | null>}
427
429
  */
428
- export async function getPluginScope(pluginName, category = 'claudecommands') {
430
+ export async function getPluginScope(pluginName, marketplace = 'dboio') {
429
431
  const config = await loadLocalConfig();
430
- return config.plugins?.[category]?.[pluginName] || null;
432
+ const key = `${pluginName}@${marketplace}`;
433
+ const entry = config.plugins?.[key]?.[0];
434
+ if (entry) {
435
+ return entry.scope === 'user' ? 'global' : entry.scope || null;
436
+ }
437
+ // Legacy fallback: plugins.claudecommands.dbo = "global"
438
+ const legacy = config.plugins?.claudecommands?.[pluginName];
439
+ return legacy || null;
431
440
  }
432
441
 
433
442
  /**
434
- * Set the scope for a plugin by name.
435
- * @param {string} pluginName - Plugin name without extension
436
- * @param {'project' | 'global'} scope
437
- * @param {string} [category='claudecommands'] - Plugin category
443
+ * Set plugin metadata in registry-style format.
444
+ * @param {string} pluginName - Plugin name
445
+ * @param {object} meta - Plugin metadata { scope, installPath, version }
446
+ * @param {string} [marketplace='dboio'] - Marketplace identifier
438
447
  */
439
- export async function setPluginScope(pluginName, scope, category = 'claudecommands') {
448
+ export async function setPluginScope(pluginName, meta, marketplace = 'dboio') {
440
449
  const config = await loadLocalConfig();
441
450
  if (!config.plugins) config.plugins = {};
442
- if (!config.plugins[category]) config.plugins[category] = {};
443
- config.plugins[category][pluginName] = scope;
451
+ const key = `${pluginName}@${marketplace}`;
452
+ const now = new Date().toISOString();
453
+
454
+ // Support legacy callers passing just a scope string
455
+ if (typeof meta === 'string') {
456
+ meta = { scope: meta === 'global' ? 'user' : meta };
457
+ }
458
+
459
+ const existing = config.plugins[key]?.[0];
460
+ if (existing) {
461
+ Object.assign(existing, meta);
462
+ existing.lastUpdated = now;
463
+ } else {
464
+ config.plugins[key] = [{
465
+ scope: meta.scope || 'user',
466
+ installPath: meta.installPath || null,
467
+ version: meta.version || null,
468
+ installedAt: now,
469
+ lastUpdated: now,
470
+ }];
471
+ }
472
+
473
+ // Remove legacy entry if present
474
+ if (config.plugins.claudecommands?.[pluginName]) {
475
+ delete config.plugins.claudecommands[pluginName];
476
+ if (Object.keys(config.plugins.claudecommands).length === 0) {
477
+ delete config.plugins.claudecommands;
478
+ }
479
+ }
480
+
444
481
  await saveLocalConfig(config);
445
482
  }
446
483
 
447
484
  /**
448
- * Get all stored plugin scopes for a category.
449
- * Returns object mapping plugin names to their scope strings.
450
- * @param {string} [category='claudecommands'] - Plugin category
485
+ * Get all stored plugin entries.
486
+ * Returns object mapping registry keys to their entry arrays.
451
487
  */
452
- export async function getAllPluginScopes(category = 'claudecommands') {
488
+ export async function getAllPluginScopes() {
453
489
  const config = await loadLocalConfig();
454
- return config.plugins?.[category] || {};
490
+ const result = {};
491
+ if (!config.plugins) return result;
492
+ for (const [key, value] of Object.entries(config.plugins)) {
493
+ // Skip legacy category keys
494
+ if (typeof value === 'object' && !Array.isArray(value) && !value.scope) continue;
495
+ result[key] = value;
496
+ }
497
+ return result;
455
498
  }
456
499
 
457
500
  // ─── Gitignore ────────────────────────────────────────────────────────────