@dboio/cli 0.6.3 → 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/README.md CHANGED
@@ -62,7 +62,7 @@ Once installed, use `/dbo` in Claude Code:
62
62
  /dbo push assets/css/
63
63
  ```
64
64
 
65
- The command source lives in `tools/dbo-cli/src/plugins/claudecommands/`. Installed copies in `.claude/commands/` are gitignored and managed by `dbo install`. Each plugin's installation scope (project or global) is stored per-plugin in `.dbo/config.local.json`.
65
+ The plugin source lives in `plugins/claude/dbo/` at the repository root. Installed copies in `.claude/plugins/` are gitignored and managed by `dbo install`. Each plugin's installation scope (project or global) is stored per-plugin in `.dbo/config.local.json`.
66
66
 
67
67
  ---
68
68
 
@@ -1169,7 +1169,7 @@ Smart behavior:
1169
1169
  - If plugins are already installed and up to date, reports "already up to date"
1170
1170
  - If plugins differ from source, prompts to upgrade to the latest version
1171
1171
  - Compares file hashes — unchanged files are skipped
1172
- - Adds project-scoped command files to `.gitignore` (source of truth is `src/plugins/claudecommands/`)
1172
+ - Adds project-scoped plugins to `.gitignore` (source of truth is `plugins/claude/` at repo root)
1173
1173
  - Per-plugin scope (project or global) is stored in `.dbo/config.local.json` and remembered for future upgrades
1174
1174
  - On first install of a plugin without a stored preference, prompts for scope
1175
1175
  - `--global` / `--local` flags override stored preferences and update them
package/bin/dbo.js CHANGED
@@ -46,7 +46,7 @@ function checkFirstRun() {
46
46
  console.log(`${BOLD}${CYAN} Welcome to DBO.io CLI!${RESET}`);
47
47
  console.log('');
48
48
  console.log(` ${DIM}Available plugins:${RESET}`);
49
- console.log(` ${YELLOW}Claude Code — /dbo slash command${RESET}`);
49
+ console.log(` ${YELLOW}Claude Code — /dbo:cli slash command${RESET}`);
50
50
  console.log(` ${DIM}dbo install --claudecommand dbo${RESET}`);
51
51
  console.log('');
52
52
  console.log(` To install all plugins at once, run:`);
@@ -18,7 +18,7 @@ function write(message) {
18
18
  const PLUGINS = [
19
19
  {
20
20
  name: 'dbo',
21
- label: 'Claude Code — /dbo slash command',
21
+ label: 'Claude Code — /dbo:cli slash command',
22
22
  command: 'dbo install --claudecommand dbo'
23
23
  }
24
24
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dboio/cli",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
4
4
  "description": "CLI for the DBO.io framework",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,6 +16,7 @@
16
16
  "node": ">=18.0.0"
17
17
  },
18
18
  "scripts": {
19
+ "prepublishOnly": "node scripts/copy-plugins.js",
19
20
  "postinstall": "node bin/postinstall.js",
20
21
  "test": "node --test src/**/*.test.js tests/**/*.test.js"
21
22
  },
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "dbo",
3
+ "version": "0.6.5",
4
+ "description": "DBO.io CLI integration for Claude Code",
5
+ "author": {
6
+ "name": "DBO.io"
7
+ }
8
+ }
@@ -1,6 +1,7 @@
1
1
  ---
2
+ name: cli
2
3
  description: Execute DBO.io CLI commands (pull, push, add, clone, output, input, content, deploy, etc.)
3
- allowed-tools: [Bash, Read, Write, Grep, Glob]
4
+ user-invokable: true
4
5
  ---
5
6
 
6
7
  # DBO CLI Command
@@ -9,7 +10,7 @@ The dbo CLI interacts with DBO.io — a database-driven application framework.
9
10
 
10
11
  **STEP 1: Check if `$ARGUMENTS` is empty or blank.**
11
12
 
12
- If `$ARGUMENTS` is empty — meaning the user typed just `/dbo` with nothing after it — then you MUST NOT run any bash command. Do NOT run `dbo`, `dbo --help`, or anything else. Instead, respond ONLY with this text message:
13
+ If `$ARGUMENTS` is empty — meaning the user typed just `/dbo:cli` with nothing after it — then you MUST NOT run any bash command. Do NOT run `dbo`, `dbo --help`, or anything else. Instead, respond ONLY with this text message:
13
14
 
14
15
  ---
15
16
 
@@ -1,16 +1,22 @@
1
1
  import { Command } from 'commander';
2
- import { readdir, readFile, writeFile, mkdir, access, copyFile } 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';
6
6
  import { createHash } from 'crypto';
7
+ import { existsSync } from 'fs';
7
8
  import { homedir } from 'os';
8
9
  import { log } from '../lib/logger.js';
9
10
  import { getPluginScope, setPluginScope, isInitialized, removeFromGitignore } from '../lib/config.js';
10
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
+
11
16
  const __dirname = dirname(fileURLToPath(import.meta.url));
12
17
  const CLI_ROOT = join(__dirname, '..', '..');
13
- const PLUGINS_DIR = join(__dirname, '..', 'plugins', 'claudecommands');
18
+ const LEGACY_PLUGINS_DIR = join(__dirname, '..', 'plugins', 'claudecommands');
19
+ const PLUGINS_DIR = join(CLI_ROOT, 'plugins', 'claude');
14
20
 
15
21
  async function fileExists(path) {
16
22
  try { await access(path); return true; } catch { return false; }
@@ -21,7 +27,7 @@ function fileHash(content) {
21
27
  }
22
28
 
23
29
  /**
24
- * Get the target commands directory based on scope.
30
+ * Get the target commands directory based on scope (legacy .md format).
25
31
  * @param {'project' | 'global'} scope
26
32
  * @returns {string} Absolute path to commands directory
27
33
  */
@@ -33,10 +39,143 @@ function getCommandsDir(scope) {
33
39
  }
34
40
 
35
41
  /**
36
- * Get plugin name from filename (strips .md extension).
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.
44
+ * @param {'project' | 'global'} scope
45
+ * @returns {string} Absolute path to plugins directory
46
+ */
47
+ function getPluginsDir(scope) {
48
+ if (scope === 'global') {
49
+ return join(CLAUDE_PLUGINS_DIR, 'cache', PLUGIN_MARKETPLACE);
50
+ }
51
+ return join(process.cwd(), '.claude', 'plugins');
52
+ }
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
37
67
  */
38
- function getPluginName(filename) {
39
- return filename.replace(/\.md$/, '');
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
+
119
+ /**
120
+ * Check if a plugin source is a directory-based plugin (has .claude-plugin/).
121
+ * @param {string} pluginPath - Path to the plugin directory
122
+ * @returns {boolean}
123
+ */
124
+ function isDirectoryPlugin(pluginPath) {
125
+ return existsSync(join(pluginPath, '.claude-plugin', 'plugin.json'));
126
+ }
127
+
128
+ /**
129
+ * Discover all available plugins (both legacy .md and new directory-based).
130
+ * @returns {Array<{name: string, type: 'legacy'|'directory', path: string}>}
131
+ */
132
+ async function discoverPlugins() {
133
+ const plugins = [];
134
+
135
+ // Check new directory-based plugins first
136
+ if (existsSync(PLUGINS_DIR)) {
137
+ try {
138
+ const entries = await readdir(PLUGINS_DIR, { withFileTypes: true });
139
+ for (const entry of entries) {
140
+ if (entry.isDirectory()) {
141
+ const pluginPath = join(PLUGINS_DIR, entry.name);
142
+ if (isDirectoryPlugin(pluginPath)) {
143
+ plugins.push({ name: entry.name, type: 'directory', path: pluginPath });
144
+ }
145
+ }
146
+ }
147
+ } catch { /* ignore */ }
148
+ }
149
+
150
+ // Fall back to legacy .md plugins if no directory plugins found
151
+ if (plugins.length === 0 && existsSync(LEGACY_PLUGINS_DIR)) {
152
+ try {
153
+ const files = (await readdir(LEGACY_PLUGINS_DIR)).filter(f => f.endsWith('.md'));
154
+ for (const file of files) {
155
+ plugins.push({ name: file.replace(/\.md$/, ''), type: 'legacy', path: join(LEGACY_PLUGINS_DIR, file) });
156
+ }
157
+ } catch { /* ignore */ }
158
+ }
159
+
160
+ return plugins;
161
+ }
162
+
163
+ /**
164
+ * Compute a hash of a directory's contents for change detection.
165
+ * @param {string} dirPath - Path to the directory
166
+ * @returns {Promise<string>} MD5 hash of concatenated file contents
167
+ */
168
+ async function directoryHash(dirPath) {
169
+ const hash = createHash('md5');
170
+ const entries = await readdir(dirPath, { withFileTypes: true, recursive: true });
171
+ for (const entry of entries) {
172
+ if (!entry.isFile()) continue;
173
+ const parentDir = entry.parentPath || entry.path || dirPath;
174
+ const filePath = join(parentDir, entry.name);
175
+ const content = await readFile(filePath, 'utf8');
176
+ hash.update(entry.name + content);
177
+ }
178
+ return hash.digest('hex');
40
179
  }
41
180
 
42
181
  /**
@@ -51,8 +190,8 @@ async function promptForScope(pluginName) {
51
190
  name: 'scope',
52
191
  message: `Where should the "${pluginName}" command be installed?`,
53
192
  choices: [
54
- { name: 'Project directory (.claude/commands/)', value: 'project' },
55
- { name: 'User home directory (~/.claude/commands/)', value: 'global' },
193
+ { name: 'Project directory (.claude/plugins/)', value: 'project' },
194
+ { name: 'User home directory (~/.claude/plugins/)', value: 'global' },
56
195
  ],
57
196
  default: 'project',
58
197
  }]);
@@ -78,33 +217,48 @@ async function resolvePluginScope(pluginName, options) {
78
217
 
79
218
  /**
80
219
  * Check if plugin exists in both project and global locations.
81
- * @param {string} fileName - Plugin filename (with .md)
220
+ * Checks cache path, old direct path, and legacy (.claude/commands/) paths.
221
+ * @param {string} pluginName - Plugin name (without extension)
222
+ * @param {string} [legacyFileName] - Legacy filename (with .md) for backward compat
82
223
  * @returns {Promise<{project: boolean, global: boolean}>}
83
224
  */
84
- async function checkPluginLocations(fileName) {
85
- const projectPath = join(getCommandsDir('project'), fileName);
86
- const globalPath = join(getCommandsDir('global'), fileName);
87
- return {
88
- project: await fileExists(projectPath),
89
- global: await fileExists(globalPath),
90
- };
225
+ async function checkPluginLocations(pluginName, legacyFileName) {
226
+ const projectPlugin = join(getPluginsDir('project'), pluginName);
227
+ let project = await fileExists(projectPlugin);
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
+ }
237
+
238
+ // Also check legacy locations
239
+ if (legacyFileName) {
240
+ if (!project) project = await fileExists(join(getCommandsDir('project'), legacyFileName));
241
+ if (!global) global = await fileExists(join(getCommandsDir('global'), legacyFileName));
242
+ }
243
+
244
+ return { project, global };
91
245
  }
92
246
 
93
247
  /**
94
248
  * Handle conflict when plugin exists in both locations.
95
- * @param {string} fileName - Plugin filename
249
+ * @param {string} pluginName - Plugin name
96
250
  * @returns {Promise<'project' | 'global' | 'skip'>}
97
251
  */
98
- async function handleDualLocation(fileName) {
99
- log.warn(`${fileName} exists in both project and global directories.`);
252
+ async function handleDualLocation(pluginName) {
253
+ log.warn(`"${pluginName}" exists in both project and global directories.`);
100
254
  const inquirer = (await import('inquirer')).default;
101
255
  const { target } = await inquirer.prompt([{
102
256
  type: 'list',
103
257
  name: 'target',
104
258
  message: `Which location should be updated?`,
105
259
  choices: [
106
- { name: 'Project (.claude/commands/)', value: 'project' },
107
- { name: 'Global (~/.claude/commands/)', value: 'global' },
260
+ { name: 'Project (.claude/plugins/)', value: 'project' },
261
+ { name: 'Global (~/.claude/plugins/)', value: 'global' },
108
262
  { name: 'Skip this plugin', value: 'skip' },
109
263
  ],
110
264
  }]);
@@ -196,7 +350,7 @@ async function installInteractive(options = {}) {
196
350
  message: 'What would you like to install?',
197
351
  choices: [
198
352
  { name: 'DBO CLI (install or upgrade)', value: 'cli' },
199
- { name: 'Claude Code commands (adds /dbo to Claude Code)', value: 'claudecommands' },
353
+ { name: 'Claude Code commands (adds /dbo:cli to Claude Code)', value: 'claudecommands' },
200
354
  { name: 'Claude Code CLI + commands', value: 'claudecode' },
201
355
  { name: 'Plugins (Claude commands)', value: 'plugins' },
202
356
  ],
@@ -404,17 +558,12 @@ export async function installOrUpdateClaudeCommands(options = {}) {
404
558
  const cwd = process.cwd();
405
559
  const hasProject = await isInitialized();
406
560
 
407
- // Find all plugin source files
408
- let pluginFiles;
409
- try {
410
- pluginFiles = (await readdir(PLUGINS_DIR)).filter(f => f.endsWith('.md'));
411
- } catch {
412
- log.error(`Plugin source directory not found: ${PLUGINS_DIR}`);
413
- return;
414
- }
561
+ // Discover all available plugins (directory-based or legacy)
562
+ const plugins = await discoverPlugins();
415
563
 
416
- if (pluginFiles.length === 0) {
417
- log.warn('No Claude command plugins found in source.');
564
+ if (plugins.length === 0) {
565
+ log.error('No Claude plugins found in package.');
566
+ log.dim(` Searched: ${PLUGINS_DIR}`);
418
567
  return;
419
568
  }
420
569
 
@@ -423,23 +572,20 @@ export async function installOrUpdateClaudeCommands(options = {}) {
423
572
  let upToDate = 0;
424
573
  let skipped = 0;
425
574
 
426
- for (const file of pluginFiles) {
427
- const pluginName = getPluginName(file);
428
- const srcPath = join(PLUGINS_DIR, file);
429
- const srcContent = await readFile(srcPath, 'utf8');
575
+ for (const plugin of plugins) {
576
+ const legacyFileName = `${plugin.name}.md`;
430
577
 
431
578
  // Check if plugin exists in both locations
432
- const locations = await checkPluginLocations(file);
579
+ const locations = await checkPluginLocations(plugin.name, legacyFileName);
433
580
  let targetScope;
434
581
 
435
582
  if (locations.project && locations.global) {
436
- // Conflict — explicit flags resolve it, otherwise prompt
437
583
  if (options.global) {
438
584
  targetScope = 'global';
439
585
  } else if (options.local) {
440
586
  targetScope = 'project';
441
587
  } else {
442
- const choice = await handleDualLocation(file);
588
+ const choice = await handleDualLocation(plugin.name);
443
589
  if (choice === 'skip') {
444
590
  skipped++;
445
591
  continue;
@@ -447,11 +593,10 @@ export async function installOrUpdateClaudeCommands(options = {}) {
447
593
  targetScope = choice;
448
594
  }
449
595
  } else {
450
- targetScope = await resolvePluginScope(pluginName, options);
596
+ targetScope = await resolvePluginScope(plugin.name, options);
451
597
  }
452
598
 
453
- // Ensure target directory exists (prompt for project .claude/ only if needed)
454
- const commandsDir = getCommandsDir(targetScope);
599
+ // Ensure target directory exists
455
600
  if (targetScope === 'project' && !await fileExists(join(cwd, '.claude'))) {
456
601
  const inquirer = (await import('inquirer')).default;
457
602
  const { create } = await inquirer.prompt([{
@@ -464,90 +609,192 @@ export async function installOrUpdateClaudeCommands(options = {}) {
464
609
  return;
465
610
  }
466
611
  }
467
- await mkdir(commandsDir, { recursive: true });
468
612
 
469
- // Persist the scope preference
470
- if (options.global || options.local || !await getPluginScope(pluginName)) {
471
- if (hasProject) {
472
- await setPluginScope(pluginName, targetScope);
473
- } else if (targetScope === 'global') {
474
- log.warn(`Cannot persist scope preference (no .dbo/ directory). Run "dbo init" first.`);
475
- }
476
- }
613
+ if (plugin.type === 'directory') {
614
+ // New directory-based plugin installation
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);
623
+ const scopeLabel = targetScope === 'global' ? '~/.claude/plugins/' : '.claude/plugins/';
624
+
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) {
632
+ // Check if upgrade needed via directory hash
633
+ const srcHash = await directoryHash(plugin.path);
634
+ const destHash = await directoryHash(existingPath);
635
+ if (srcHash === destHash && existingPath === destDir) {
636
+ upToDate++;
637
+ continue;
638
+ }
477
639
 
478
- const destPath = join(commandsDir, file);
479
- const scopeLabel = targetScope === 'global' ? '~/.claude/commands/' : '.claude/commands/';
640
+ const inquirer = (await import('inquirer')).default;
641
+ const { upgrade } = await inquirer.prompt([{
642
+ type: 'confirm', name: 'upgrade',
643
+ message: `${scopeLabel}${plugin.name}/ is already installed. Upgrade to latest version?`,
644
+ default: true,
645
+ }]);
646
+ if (!upgrade) {
647
+ log.dim(` Skipped ${plugin.name}`);
648
+ skipped++;
649
+ continue;
650
+ }
480
651
 
481
- if (await fileExists(destPath)) {
482
- // Already installed check if upgrade needed
483
- const destContent = await readFile(destPath, 'utf8');
484
- if (fileHash(srcContent) === fileHash(destContent)) {
485
- upToDate++;
486
- continue;
652
+ await mkdir(destDir, { recursive: true });
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
+
665
+ log.success(`Upgraded ${scopeLabel}${plugin.name}/`);
666
+ updated++;
667
+ } else {
668
+ await mkdir(destDir, { recursive: true });
669
+ await cp(plugin.path, destDir, { recursive: true });
670
+
671
+ if (targetScope === 'global') {
672
+ await registerPlugin(plugin.name, pluginVersion, destDir);
673
+ }
674
+
675
+ log.success(`Installed ${scopeLabel}${plugin.name}/`);
676
+ installed++;
677
+
678
+ if (targetScope === 'project') {
679
+ await addToGitignore(cwd, `.claude/plugins/${plugin.name}/`);
680
+ }
487
681
  }
488
682
 
489
- // File differs prompt for upgrade
490
- const inquirer = (await import('inquirer')).default;
491
- const { upgrade } = await inquirer.prompt([{
492
- type: 'confirm', name: 'upgrade',
493
- message: `${scopeLabel}${file} is already installed. Upgrade to latest version?`,
494
- default: true,
495
- }]);
496
- if (!upgrade) {
497
- log.dim(` Skipped ${file}`);
498
- skipped++;
499
- continue;
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.`);
500
692
  }
501
693
 
502
- await copyFile(srcPath, destPath);
503
- log.success(`Upgraded ${scopeLabel}${file}`);
504
- updated++;
694
+ // Clean up legacy file if it exists
695
+ const legacyProjectPath = join(getCommandsDir('project'), legacyFileName);
696
+ const legacyGlobalPath = join(getCommandsDir('global'), legacyFileName);
697
+ if (await fileExists(legacyProjectPath)) {
698
+ const { unlink } = await import('fs/promises');
699
+ await unlink(legacyProjectPath);
700
+ await removeFromGitignore(`.claude/commands/${legacyFileName}`);
701
+ log.dim(` Removed legacy command: .claude/commands/${legacyFileName}`);
702
+ }
703
+ if (await fileExists(legacyGlobalPath)) {
704
+ const { unlink } = await import('fs/promises');
705
+ await unlink(legacyGlobalPath);
706
+ log.dim(` Removed legacy command: ~/.claude/commands/${legacyFileName}`);
707
+ }
708
+
709
+ if (targetScope === 'global' && locations.project) {
710
+ await removeFromGitignore(`.claude/plugins/${plugin.name}/`);
711
+ }
505
712
  } else {
506
- // New install
507
- await copyFile(srcPath, destPath);
508
- log.success(`Installed ${scopeLabel}${file}`);
509
- installed++;
713
+ // Legacy .md plugin installation
714
+ const commandsDir = getCommandsDir(targetScope);
715
+ await mkdir(commandsDir, { recursive: true });
716
+
717
+ const srcContent = await readFile(plugin.path, 'utf8');
718
+ const destPath = join(commandsDir, legacyFileName);
719
+ const scopeLabel = targetScope === 'global' ? '~/.claude/commands/' : '.claude/commands/';
720
+
721
+ if (await fileExists(destPath)) {
722
+ const destContent = await readFile(destPath, 'utf8');
723
+ if (fileHash(srcContent) === fileHash(destContent)) {
724
+ upToDate++;
725
+ continue;
726
+ }
510
727
 
511
- // Only add to gitignore for project-scope installs
512
- if (targetScope === 'project') {
513
- await addToGitignore(cwd, `.claude/commands/${file}`);
728
+ const inquirer = (await import('inquirer')).default;
729
+ const { upgrade } = await inquirer.prompt([{
730
+ type: 'confirm', name: 'upgrade',
731
+ message: `${scopeLabel}${legacyFileName} is already installed. Upgrade to latest version?`,
732
+ default: true,
733
+ }]);
734
+ if (!upgrade) {
735
+ log.dim(` Skipped ${legacyFileName}`);
736
+ skipped++;
737
+ continue;
738
+ }
739
+
740
+ await copyFile(plugin.path, destPath);
741
+ log.success(`Upgraded ${scopeLabel}${legacyFileName}`);
742
+ updated++;
743
+ } else {
744
+ await copyFile(plugin.path, destPath);
745
+ log.success(`Installed ${scopeLabel}${legacyFileName}`);
746
+ installed++;
747
+
748
+ if (targetScope === 'project') {
749
+ await addToGitignore(cwd, `.claude/commands/${legacyFileName}`);
750
+ }
514
751
  }
515
- }
516
752
 
517
- // If scope changed to global and project copy existed, remove from gitignore
518
- if (targetScope === 'global' && locations.project) {
519
- await removeFromGitignore(`.claude/commands/${file}`);
753
+ if (targetScope === 'global' && locations.project) {
754
+ await removeFromGitignore(`.claude/commands/${legacyFileName}`);
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
+ }
520
761
  }
521
762
  }
522
763
 
523
- if (installed > 0) log.info(`${installed} command(s) installed.`);
524
- if (updated > 0) log.info(`${updated} command(s) upgraded.`);
525
- if (upToDate > 0) log.dim(`${upToDate} command(s) already up to date.`);
526
- if (skipped > 0) log.dim(`${skipped} command(s) skipped.`);
764
+ if (installed > 0) log.info(`${installed} plugin(s) installed.`);
765
+ if (updated > 0) log.info(`${updated} plugin(s) upgraded.`);
766
+ if (upToDate > 0) log.dim(`${upToDate} plugin(s) already up to date.`);
767
+ if (skipped > 0) log.dim(`${skipped} plugin(s) skipped.`);
527
768
  if (installed > 0 || updated > 0) {
528
- log.info('Use /dbo in Claude Code.');
529
- log.warn('Note: Commands will be available in new Claude Code sessions (restart any active session).');
769
+ log.info('Use /dbo:cli in Claude Code.');
770
+ log.warn('Note: Plugins will be available in new Claude Code sessions (restart any active session).');
530
771
  }
531
772
  }
532
773
 
533
774
  // ─── Specific Command ───────────────────────────────────────────────────────
534
775
 
535
776
  async function installOrUpdateSpecificCommand(name, options = {}) {
536
- const fileName = name.endsWith('.md') ? name : `${name}.md`;
537
- const pluginName = getPluginName(fileName);
538
- const srcPath = join(PLUGINS_DIR, fileName);
539
-
540
- if (!await fileExists(srcPath)) {
541
- log.error(`Command plugin "${name}" not found in ${PLUGINS_DIR}`);
542
- const available = (await readdir(PLUGINS_DIR)).filter(f => f.endsWith('.md')).map(f => f.replace('.md', ''));
543
- if (available.length > 0) log.dim(` Available: ${available.join(', ')}`);
777
+ const pluginName = name.replace(/\.md$/, '');
778
+
779
+ // Find the plugin in available sources (directory-based first, then legacy)
780
+ const plugins = await discoverPlugins();
781
+ const plugin = plugins.find(p => p.name === pluginName);
782
+
783
+ if (!plugin) {
784
+ log.error(`Plugin "${pluginName}" not found.`);
785
+ if (plugins.length > 0) {
786
+ log.dim(` Available: ${plugins.map(p => p.name).join(', ')}`);
787
+ } else {
788
+ log.dim(` Searched: ${PLUGINS_DIR}`);
789
+ }
544
790
  return;
545
791
  }
546
792
 
547
793
  const hasProject = await isInitialized();
794
+ const legacyFileName = `${pluginName}.md`;
548
795
 
549
796
  // Check for dual location
550
- const locations = await checkPluginLocations(fileName);
797
+ const locations = await checkPluginLocations(pluginName, legacyFileName);
551
798
  let targetScope;
552
799
 
553
800
  if (locations.project && locations.global) {
@@ -556,7 +803,7 @@ async function installOrUpdateSpecificCommand(name, options = {}) {
556
803
  } else if (options.local) {
557
804
  targetScope = 'project';
558
805
  } else {
559
- const choice = await handleDualLocation(fileName);
806
+ const choice = await handleDualLocation(pluginName);
560
807
  if (choice === 'skip') return;
561
808
  targetScope = choice;
562
809
  }
@@ -564,54 +811,142 @@ async function installOrUpdateSpecificCommand(name, options = {}) {
564
811
  targetScope = await resolvePluginScope(pluginName, options);
565
812
  }
566
813
 
567
- // Persist scope
568
- if (options.global || options.local || !await getPluginScope(pluginName)) {
569
- if (hasProject) {
570
- await setPluginScope(pluginName, targetScope);
571
- } else if (targetScope === 'global') {
572
- log.warn(`Cannot persist scope preference (no .dbo/ directory). Run "dbo init" first.`);
573
- }
574
- }
814
+ if (plugin.type === 'directory') {
815
+ // Directory-based plugin installation
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);
822
+ const scopeLabel = targetScope === 'global' ? '~/.claude/plugins/' : '.claude/plugins/';
823
+
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) {
831
+ const srcHash = await directoryHash(plugin.path);
832
+ const destHash = await directoryHash(existingPath);
833
+ if (srcHash === destHash && existingPath === destDir) {
834
+ log.success(`${pluginName} is already up to date in ${scopeLabel}`);
835
+ return;
836
+ }
575
837
 
576
- const commandsDir = getCommandsDir(targetScope);
577
- await mkdir(commandsDir, { recursive: true });
838
+ const inquirer = (await import('inquirer')).default;
839
+ const { upgrade } = await inquirer.prompt([{
840
+ type: 'confirm', name: 'upgrade',
841
+ message: `${scopeLabel}${pluginName}/ is already installed. Upgrade to latest version?`,
842
+ default: true,
843
+ }]);
844
+ if (!upgrade) return;
578
845
 
579
- const destPath = join(commandsDir, fileName);
580
- const srcContent = await readFile(srcPath, 'utf8');
581
- const scopeLabel = targetScope === 'global' ? '~/.claude/commands/' : '.claude/commands/';
846
+ await mkdir(destDir, { recursive: true });
847
+ await cp(plugin.path, destDir, { recursive: true, force: true });
582
848
 
583
- if (await fileExists(destPath)) {
584
- const destContent = await readFile(destPath, 'utf8');
585
- if (fileHash(srcContent) === fileHash(destContent)) {
586
- log.success(`${fileName} is already up to date in ${scopeLabel}`);
587
- return;
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
+
859
+ log.success(`Upgraded ${scopeLabel}${pluginName}/`);
860
+ } else {
861
+ await mkdir(destDir, { recursive: true });
862
+ await cp(plugin.path, destDir, { recursive: true });
863
+
864
+ if (targetScope === 'global') {
865
+ await registerPlugin(pluginName, pluginVersion, destDir);
866
+ }
867
+
868
+ log.success(`Installed ${scopeLabel}${pluginName}/`);
869
+
870
+ if (targetScope === 'project') {
871
+ await addToGitignore(process.cwd(), `.claude/plugins/${pluginName}/`);
872
+ }
588
873
  }
589
874
 
590
- const inquirer = (await import('inquirer')).default;
591
- const { upgrade } = await inquirer.prompt([{
592
- type: 'confirm', name: 'upgrade',
593
- message: `${scopeLabel}${fileName} is already installed. Upgrade to latest version?`,
594
- default: true,
595
- }]);
596
- if (!upgrade) return;
597
-
598
- await copyFile(srcPath, destPath);
599
- log.success(`Upgraded ${scopeLabel}${fileName}`);
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
+
886
+ // Clean up legacy files
887
+ const legacyProjectPath = join(getCommandsDir('project'), legacyFileName);
888
+ const legacyGlobalPath = join(getCommandsDir('global'), legacyFileName);
889
+ if (await fileExists(legacyProjectPath)) {
890
+ const { unlink } = await import('fs/promises');
891
+ await unlink(legacyProjectPath);
892
+ await removeFromGitignore(`.claude/commands/${legacyFileName}`);
893
+ log.dim(` Removed legacy command: .claude/commands/${legacyFileName}`);
894
+ }
895
+ if (await fileExists(legacyGlobalPath)) {
896
+ const { unlink } = await import('fs/promises');
897
+ await unlink(legacyGlobalPath);
898
+ log.dim(` Removed legacy command: ~/.claude/commands/${legacyFileName}`);
899
+ }
900
+
901
+ if (targetScope === 'global' && locations.project) {
902
+ await removeFromGitignore(`.claude/plugins/${pluginName}/`);
903
+ }
600
904
  } else {
601
- await copyFile(srcPath, destPath);
602
- log.success(`Installed ${scopeLabel}${fileName}`);
905
+ // Legacy .md plugin installation
906
+ const commandsDir = getCommandsDir(targetScope);
907
+ await mkdir(commandsDir, { recursive: true });
908
+
909
+ const destPath = join(commandsDir, legacyFileName);
910
+ const srcContent = await readFile(plugin.path, 'utf8');
911
+ const scopeLabel = targetScope === 'global' ? '~/.claude/commands/' : '.claude/commands/';
912
+
913
+ if (await fileExists(destPath)) {
914
+ const destContent = await readFile(destPath, 'utf8');
915
+ if (fileHash(srcContent) === fileHash(destContent)) {
916
+ log.success(`${legacyFileName} is already up to date in ${scopeLabel}`);
917
+ return;
918
+ }
919
+
920
+ const inquirer = (await import('inquirer')).default;
921
+ const { upgrade } = await inquirer.prompt([{
922
+ type: 'confirm', name: 'upgrade',
923
+ message: `${scopeLabel}${legacyFileName} is already installed. Upgrade to latest version?`,
924
+ default: true,
925
+ }]);
926
+ if (!upgrade) return;
603
927
 
604
- if (targetScope === 'project') {
605
- await addToGitignore(process.cwd(), `.claude/commands/${fileName}`);
928
+ await copyFile(plugin.path, destPath);
929
+ log.success(`Upgraded ${scopeLabel}${legacyFileName}`);
930
+ } else {
931
+ await copyFile(plugin.path, destPath);
932
+ log.success(`Installed ${scopeLabel}${legacyFileName}`);
933
+
934
+ if (targetScope === 'project') {
935
+ await addToGitignore(process.cwd(), `.claude/commands/${legacyFileName}`);
936
+ }
606
937
  }
607
- }
608
938
 
609
- // If scope changed to global and project copy existed, remove from gitignore
610
- if (targetScope === 'global' && locations.project) {
611
- await removeFromGitignore(`.claude/commands/${fileName}`);
939
+ if (targetScope === 'global' && locations.project) {
940
+ await removeFromGitignore(`.claude/commands/${legacyFileName}`);
941
+ }
942
+
943
+ // Persist scope for legacy plugins
944
+ if (hasProject && (options.global || options.local || !await getPluginScope(pluginName))) {
945
+ await setPluginScope(pluginName, targetScope);
946
+ }
612
947
  }
613
948
 
614
- log.warn('Note: Commands will be available in new Claude Code sessions (restart any active session).');
949
+ log.warn('Note: Plugins will be available in new Claude Code sessions (restart any active session).');
615
950
  }
616
951
 
617
952
  // ─── Helpers ────────────────────────────────────────────────────────────────
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 ────────────────────────────────────────────────────────────