@dboio/cli 0.6.2 → 0.6.4

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
@@ -2,6 +2,9 @@
2
2
 
3
3
  import { Command } from 'commander';
4
4
  import { createRequire } from 'module';
5
+ import { existsSync, writeFileSync } from 'fs';
6
+ import { homedir } from 'os';
7
+ import { join } from 'path';
5
8
 
6
9
  const require = createRequire(import.meta.url);
7
10
  const packageJson = require('../package.json');
@@ -28,6 +31,36 @@ import { diffCommand } from '../src/commands/diff.js';
28
31
  import { rmCommand } from '../src/commands/rm.js';
29
32
  import { mvCommand } from '../src/commands/mv.js';
30
33
 
34
+ // First-run welcome message
35
+ function checkFirstRun() {
36
+ const markerFile = join(homedir(), '.dbo-cli-welcomed');
37
+
38
+ if (!existsSync(markerFile)) {
39
+ const RESET = '\x1b[0m';
40
+ const BOLD = '\x1b[1m';
41
+ const DIM = '\x1b[2m';
42
+ const CYAN = '\x1b[36m';
43
+ const YELLOW = '\x1b[33m';
44
+
45
+ console.log('');
46
+ console.log(`${BOLD}${CYAN} Welcome to DBO.io CLI!${RESET}`);
47
+ console.log('');
48
+ console.log(` ${DIM}Available plugins:${RESET}`);
49
+ console.log(` ${YELLOW}Claude Code — /dbo:cli slash command${RESET}`);
50
+ console.log(` ${DIM}dbo install --claudecommand dbo${RESET}`);
51
+ console.log('');
52
+ console.log(` To install all plugins at once, run:`);
53
+ console.log(` ${BOLD}dbo install plugins${RESET}`);
54
+ console.log('');
55
+
56
+ try {
57
+ writeFileSync(markerFile, new Date().toISOString());
58
+ } catch {
59
+ // Ignore write errors
60
+ }
61
+ }
62
+ }
63
+
31
64
  const program = new Command();
32
65
 
33
66
  program
@@ -57,4 +90,7 @@ program.addCommand(diffCommand);
57
90
  program.addCommand(rmCommand);
58
91
  program.addCommand(mvCommand);
59
92
 
93
+ // Show welcome message on first run
94
+ checkFirstRun();
95
+
60
96
  program.parse();
@@ -3,32 +3,22 @@
3
3
  // Post-install hook for `npm i @dboio/cli`
4
4
  // Interactive plugin picker when TTY is available, static message otherwise.
5
5
 
6
- import { openSync, writeSync, closeSync } from 'fs';
7
-
8
6
  const RESET = '\x1b[0m';
9
7
  const BOLD = '\x1b[1m';
10
8
  const DIM = '\x1b[2m';
11
9
  const CYAN = '\x1b[36m';
12
10
  const YELLOW = '\x1b[33m';
13
11
 
14
- // Write to TTY directly to bypass npm's output suppression
12
+ // Simple log function - console.log works fine in postinstall
15
13
  function write(message) {
16
- try {
17
- // Try to write directly to /dev/tty (works on Unix-like systems)
18
- const fd = openSync('/dev/tty', 'w');
19
- writeSync(fd, message);
20
- closeSync(fd);
21
- } catch {
22
- // Fallback to console.error if /dev/tty is not available
23
- console.error(message);
24
- }
14
+ console.log(message);
25
15
  }
26
16
 
27
17
  // Available plugins — add new entries here as plugins are created
28
18
  const PLUGINS = [
29
19
  {
30
20
  name: 'dbo',
31
- label: 'Claude Code — /dbo slash command',
21
+ label: 'Claude Code — /dbo:cli slash command',
32
22
  command: 'dbo install --claudecommand dbo'
33
23
  }
34
24
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dboio/cli",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
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,13 @@
1
+ {
2
+ "name": "dbo",
3
+ "version": "0.6.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
+ ]
13
+ }
@@ -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,18 @@
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 } 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
 
11
12
  const __dirname = dirname(fileURLToPath(import.meta.url));
12
13
  const CLI_ROOT = join(__dirname, '..', '..');
13
- const PLUGINS_DIR = join(__dirname, '..', 'plugins', 'claudecommands');
14
+ const LEGACY_PLUGINS_DIR = join(__dirname, '..', 'plugins', 'claudecommands');
15
+ const PLUGINS_DIR = join(CLI_ROOT, 'plugins', 'claude');
14
16
 
15
17
  async function fileExists(path) {
16
18
  try { await access(path); return true; } catch { return false; }
@@ -21,7 +23,7 @@ function fileHash(content) {
21
23
  }
22
24
 
23
25
  /**
24
- * Get the target commands directory based on scope.
26
+ * Get the target commands directory based on scope (legacy .md format).
25
27
  * @param {'project' | 'global'} scope
26
28
  * @returns {string} Absolute path to commands directory
27
29
  */
@@ -33,10 +35,77 @@ function getCommandsDir(scope) {
33
35
  }
34
36
 
35
37
  /**
36
- * Get plugin name from filename (strips .md extension).
38
+ * Get the target plugins directory based on scope (new directory-based format).
39
+ * @param {'project' | 'global'} scope
40
+ * @returns {string} Absolute path to plugins directory
41
+ */
42
+ function getPluginsDir(scope) {
43
+ if (scope === 'global') {
44
+ return join(homedir(), '.claude', 'plugins');
45
+ }
46
+ return join(process.cwd(), '.claude', 'plugins');
47
+ }
48
+
49
+ /**
50
+ * Check if a plugin source is a directory-based plugin (has .claude-plugin/).
51
+ * @param {string} pluginPath - Path to the plugin directory
52
+ * @returns {boolean}
37
53
  */
38
- function getPluginName(filename) {
39
- return filename.replace(/\.md$/, '');
54
+ function isDirectoryPlugin(pluginPath) {
55
+ return existsSync(join(pluginPath, '.claude-plugin', 'plugin.json'));
56
+ }
57
+
58
+ /**
59
+ * Discover all available plugins (both legacy .md and new directory-based).
60
+ * @returns {Array<{name: string, type: 'legacy'|'directory', path: string}>}
61
+ */
62
+ async function discoverPlugins() {
63
+ const plugins = [];
64
+
65
+ // Check new directory-based plugins first
66
+ if (existsSync(PLUGINS_DIR)) {
67
+ try {
68
+ const entries = await readdir(PLUGINS_DIR, { withFileTypes: true });
69
+ for (const entry of entries) {
70
+ if (entry.isDirectory()) {
71
+ const pluginPath = join(PLUGINS_DIR, entry.name);
72
+ if (isDirectoryPlugin(pluginPath)) {
73
+ plugins.push({ name: entry.name, type: 'directory', path: pluginPath });
74
+ }
75
+ }
76
+ }
77
+ } catch { /* ignore */ }
78
+ }
79
+
80
+ // Fall back to legacy .md plugins if no directory plugins found
81
+ if (plugins.length === 0 && existsSync(LEGACY_PLUGINS_DIR)) {
82
+ try {
83
+ const files = (await readdir(LEGACY_PLUGINS_DIR)).filter(f => f.endsWith('.md'));
84
+ for (const file of files) {
85
+ plugins.push({ name: file.replace(/\.md$/, ''), type: 'legacy', path: join(LEGACY_PLUGINS_DIR, file) });
86
+ }
87
+ } catch { /* ignore */ }
88
+ }
89
+
90
+ return plugins;
91
+ }
92
+
93
+ /**
94
+ * Compute a hash of a directory's contents for change detection.
95
+ * @param {string} dirPath - Path to the directory
96
+ * @returns {Promise<string>} MD5 hash of concatenated file contents
97
+ */
98
+ async function directoryHash(dirPath) {
99
+ const hash = createHash('md5');
100
+ const entries = await readdir(dirPath, { withFileTypes: true, recursive: true });
101
+ for (const entry of entries) {
102
+ if (!entry.isFile()) continue;
103
+ const parentDir = entry.parentPath || entry.path || dirPath;
104
+ const filePath = join(parentDir, entry.name);
105
+ const content = await readFile(filePath, 'utf8');
106
+ hash.update(entry.name + content);
107
+ }
108
+ return hash.digest('hex');
40
109
  }
41
110
 
42
111
  /**
@@ -51,8 +120,8 @@ async function promptForScope(pluginName) {
51
120
  name: 'scope',
52
121
  message: `Where should the "${pluginName}" command be installed?`,
53
122
  choices: [
54
- { name: 'Project directory (.claude/commands/)', value: 'project' },
55
- { name: 'User home directory (~/.claude/commands/)', value: 'global' },
123
+ { name: 'Project directory (.claude/plugins/)', value: 'project' },
124
+ { name: 'User home directory (~/.claude/plugins/)', value: 'global' },
56
125
  ],
57
126
  default: 'project',
58
127
  }]);
@@ -78,33 +147,41 @@ async function resolvePluginScope(pluginName, options) {
78
147
 
79
148
  /**
80
149
  * Check if plugin exists in both project and global locations.
81
- * @param {string} fileName - Plugin filename (with .md)
150
+ * Checks both legacy (.claude/commands/) and new (.claude/plugins/) paths.
151
+ * @param {string} pluginName - Plugin name (without extension)
152
+ * @param {string} [legacyFileName] - Legacy filename (with .md) for backward compat
82
153
  * @returns {Promise<{project: boolean, global: boolean}>}
83
154
  */
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
- };
155
+ async function checkPluginLocations(pluginName, legacyFileName) {
156
+ const projectPlugin = join(getPluginsDir('project'), pluginName);
157
+ const globalPlugin = join(getPluginsDir('global'), pluginName);
158
+ let project = await fileExists(projectPlugin);
159
+ let global = await fileExists(globalPlugin);
160
+
161
+ // Also check legacy locations
162
+ if (legacyFileName) {
163
+ if (!project) project = await fileExists(join(getCommandsDir('project'), legacyFileName));
164
+ if (!global) global = await fileExists(join(getCommandsDir('global'), legacyFileName));
165
+ }
166
+
167
+ return { project, global };
91
168
  }
92
169
 
93
170
  /**
94
171
  * Handle conflict when plugin exists in both locations.
95
- * @param {string} fileName - Plugin filename
172
+ * @param {string} pluginName - Plugin name
96
173
  * @returns {Promise<'project' | 'global' | 'skip'>}
97
174
  */
98
- async function handleDualLocation(fileName) {
99
- log.warn(`${fileName} exists in both project and global directories.`);
175
+ async function handleDualLocation(pluginName) {
176
+ log.warn(`"${pluginName}" exists in both project and global directories.`);
100
177
  const inquirer = (await import('inquirer')).default;
101
178
  const { target } = await inquirer.prompt([{
102
179
  type: 'list',
103
180
  name: 'target',
104
181
  message: `Which location should be updated?`,
105
182
  choices: [
106
- { name: 'Project (.claude/commands/)', value: 'project' },
107
- { name: 'Global (~/.claude/commands/)', value: 'global' },
183
+ { name: 'Project (.claude/plugins/)', value: 'project' },
184
+ { name: 'Global (~/.claude/plugins/)', value: 'global' },
108
185
  { name: 'Skip this plugin', value: 'skip' },
109
186
  ],
110
187
  }]);
@@ -196,7 +273,7 @@ async function installInteractive(options = {}) {
196
273
  message: 'What would you like to install?',
197
274
  choices: [
198
275
  { name: 'DBO CLI (install or upgrade)', value: 'cli' },
199
- { name: 'Claude Code commands (adds /dbo to Claude Code)', value: 'claudecommands' },
276
+ { name: 'Claude Code commands (adds /dbo:cli to Claude Code)', value: 'claudecommands' },
200
277
  { name: 'Claude Code CLI + commands', value: 'claudecode' },
201
278
  { name: 'Plugins (Claude commands)', value: 'plugins' },
202
279
  ],
@@ -404,17 +481,12 @@ export async function installOrUpdateClaudeCommands(options = {}) {
404
481
  const cwd = process.cwd();
405
482
  const hasProject = await isInitialized();
406
483
 
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
- }
484
+ // Discover all available plugins (directory-based or legacy)
485
+ const plugins = await discoverPlugins();
415
486
 
416
- if (pluginFiles.length === 0) {
417
- log.warn('No Claude command plugins found in source.');
487
+ if (plugins.length === 0) {
488
+ log.error('No Claude plugins found in package.');
489
+ log.dim(` Searched: ${PLUGINS_DIR}`);
418
490
  return;
419
491
  }
420
492
 
@@ -423,23 +495,20 @@ export async function installOrUpdateClaudeCommands(options = {}) {
423
495
  let upToDate = 0;
424
496
  let skipped = 0;
425
497
 
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');
498
+ for (const plugin of plugins) {
499
+ const legacyFileName = `${plugin.name}.md`;
430
500
 
431
501
  // Check if plugin exists in both locations
432
- const locations = await checkPluginLocations(file);
502
+ const locations = await checkPluginLocations(plugin.name, legacyFileName);
433
503
  let targetScope;
434
504
 
435
505
  if (locations.project && locations.global) {
436
- // Conflict — explicit flags resolve it, otherwise prompt
437
506
  if (options.global) {
438
507
  targetScope = 'global';
439
508
  } else if (options.local) {
440
509
  targetScope = 'project';
441
510
  } else {
442
- const choice = await handleDualLocation(file);
511
+ const choice = await handleDualLocation(plugin.name);
443
512
  if (choice === 'skip') {
444
513
  skipped++;
445
514
  continue;
@@ -447,11 +516,10 @@ export async function installOrUpdateClaudeCommands(options = {}) {
447
516
  targetScope = choice;
448
517
  }
449
518
  } else {
450
- targetScope = await resolvePluginScope(pluginName, options);
519
+ targetScope = await resolvePluginScope(plugin.name, options);
451
520
  }
452
521
 
453
- // Ensure target directory exists (prompt for project .claude/ only if needed)
454
- const commandsDir = getCommandsDir(targetScope);
522
+ // Ensure target directory exists
455
523
  if (targetScope === 'project' && !await fileExists(join(cwd, '.claude'))) {
456
524
  const inquirer = (await import('inquirer')).default;
457
525
  const { create } = await inquirer.prompt([{
@@ -464,90 +532,155 @@ export async function installOrUpdateClaudeCommands(options = {}) {
464
532
  return;
465
533
  }
466
534
  }
467
- await mkdir(commandsDir, { recursive: true });
468
535
 
469
536
  // Persist the scope preference
470
- if (options.global || options.local || !await getPluginScope(pluginName)) {
537
+ if (options.global || options.local || !await getPluginScope(plugin.name)) {
471
538
  if (hasProject) {
472
- await setPluginScope(pluginName, targetScope);
539
+ await setPluginScope(plugin.name, targetScope);
473
540
  } else if (targetScope === 'global') {
474
541
  log.warn(`Cannot persist scope preference (no .dbo/ directory). Run "dbo init" first.`);
475
542
  }
476
543
  }
477
544
 
478
- const destPath = join(commandsDir, file);
479
- const scopeLabel = targetScope === 'global' ? '~/.claude/commands/' : '.claude/commands/';
545
+ if (plugin.type === 'directory') {
546
+ // New directory-based plugin installation
547
+ const destDir = join(getPluginsDir(targetScope), plugin.name);
548
+ const scopeLabel = targetScope === 'global' ? '~/.claude/plugins/' : '.claude/plugins/';
549
+
550
+ if (await fileExists(destDir)) {
551
+ // Check if upgrade needed via directory hash
552
+ const srcHash = await directoryHash(plugin.path);
553
+ const destHash = await directoryHash(destDir);
554
+ if (srcHash === destHash) {
555
+ upToDate++;
556
+ continue;
557
+ }
480
558
 
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;
559
+ const inquirer = (await import('inquirer')).default;
560
+ const { upgrade } = await inquirer.prompt([{
561
+ type: 'confirm', name: 'upgrade',
562
+ message: `${scopeLabel}${plugin.name}/ is already installed. Upgrade to latest version?`,
563
+ default: true,
564
+ }]);
565
+ if (!upgrade) {
566
+ log.dim(` Skipped ${plugin.name}`);
567
+ skipped++;
568
+ continue;
569
+ }
570
+
571
+ await cp(plugin.path, destDir, { recursive: true, force: true });
572
+ log.success(`Upgraded ${scopeLabel}${plugin.name}/`);
573
+ updated++;
574
+ } else {
575
+ await mkdir(destDir, { recursive: true });
576
+ await cp(plugin.path, destDir, { recursive: true });
577
+ log.success(`Installed ${scopeLabel}${plugin.name}/`);
578
+ installed++;
579
+
580
+ if (targetScope === 'project') {
581
+ await addToGitignore(cwd, `.claude/plugins/${plugin.name}/`);
582
+ }
487
583
  }
488
584
 
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;
585
+ // Clean up legacy file if it exists
586
+ const legacyProjectPath = join(getCommandsDir('project'), legacyFileName);
587
+ const legacyGlobalPath = join(getCommandsDir('global'), legacyFileName);
588
+ if (await fileExists(legacyProjectPath)) {
589
+ const { unlink } = await import('fs/promises');
590
+ await unlink(legacyProjectPath);
591
+ await removeFromGitignore(`.claude/commands/${legacyFileName}`);
592
+ log.dim(` Removed legacy command: .claude/commands/${legacyFileName}`);
593
+ }
594
+ if (await fileExists(legacyGlobalPath)) {
595
+ const { unlink } = await import('fs/promises');
596
+ await unlink(legacyGlobalPath);
597
+ log.dim(` Removed legacy command: ~/.claude/commands/${legacyFileName}`);
500
598
  }
501
599
 
502
- await copyFile(srcPath, destPath);
503
- log.success(`Upgraded ${scopeLabel}${file}`);
504
- updated++;
600
+ if (targetScope === 'global' && locations.project) {
601
+ await removeFromGitignore(`.claude/plugins/${plugin.name}/`);
602
+ }
505
603
  } else {
506
- // New install
507
- await copyFile(srcPath, destPath);
508
- log.success(`Installed ${scopeLabel}${file}`);
509
- installed++;
604
+ // Legacy .md plugin installation
605
+ const commandsDir = getCommandsDir(targetScope);
606
+ await mkdir(commandsDir, { recursive: true });
607
+
608
+ const srcContent = await readFile(plugin.path, 'utf8');
609
+ const destPath = join(commandsDir, legacyFileName);
610
+ const scopeLabel = targetScope === 'global' ? '~/.claude/commands/' : '.claude/commands/';
611
+
612
+ if (await fileExists(destPath)) {
613
+ const destContent = await readFile(destPath, 'utf8');
614
+ if (fileHash(srcContent) === fileHash(destContent)) {
615
+ upToDate++;
616
+ continue;
617
+ }
510
618
 
511
- // Only add to gitignore for project-scope installs
512
- if (targetScope === 'project') {
513
- await addToGitignore(cwd, `.claude/commands/${file}`);
619
+ const inquirer = (await import('inquirer')).default;
620
+ const { upgrade } = await inquirer.prompt([{
621
+ type: 'confirm', name: 'upgrade',
622
+ message: `${scopeLabel}${legacyFileName} is already installed. Upgrade to latest version?`,
623
+ default: true,
624
+ }]);
625
+ if (!upgrade) {
626
+ log.dim(` Skipped ${legacyFileName}`);
627
+ skipped++;
628
+ continue;
629
+ }
630
+
631
+ await copyFile(plugin.path, destPath);
632
+ log.success(`Upgraded ${scopeLabel}${legacyFileName}`);
633
+ updated++;
634
+ } else {
635
+ await copyFile(plugin.path, destPath);
636
+ log.success(`Installed ${scopeLabel}${legacyFileName}`);
637
+ installed++;
638
+
639
+ if (targetScope === 'project') {
640
+ await addToGitignore(cwd, `.claude/commands/${legacyFileName}`);
641
+ }
514
642
  }
515
- }
516
643
 
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}`);
644
+ if (targetScope === 'global' && locations.project) {
645
+ await removeFromGitignore(`.claude/commands/${legacyFileName}`);
646
+ }
520
647
  }
521
648
  }
522
649
 
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.`);
650
+ if (installed > 0) log.info(`${installed} plugin(s) installed.`);
651
+ if (updated > 0) log.info(`${updated} plugin(s) upgraded.`);
652
+ if (upToDate > 0) log.dim(`${upToDate} plugin(s) already up to date.`);
653
+ if (skipped > 0) log.dim(`${skipped} plugin(s) skipped.`);
527
654
  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).');
655
+ log.info('Use /dbo:cli in Claude Code.');
656
+ log.warn('Note: Plugins will be available in new Claude Code sessions (restart any active session).');
530
657
  }
531
658
  }
532
659
 
533
660
  // ─── Specific Command ───────────────────────────────────────────────────────
534
661
 
535
662
  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(', ')}`);
663
+ const pluginName = name.replace(/\.md$/, '');
664
+
665
+ // Find the plugin in available sources (directory-based first, then legacy)
666
+ const plugins = await discoverPlugins();
667
+ const plugin = plugins.find(p => p.name === pluginName);
668
+
669
+ if (!plugin) {
670
+ log.error(`Plugin "${pluginName}" not found.`);
671
+ if (plugins.length > 0) {
672
+ log.dim(` Available: ${plugins.map(p => p.name).join(', ')}`);
673
+ } else {
674
+ log.dim(` Searched: ${PLUGINS_DIR}`);
675
+ }
544
676
  return;
545
677
  }
546
678
 
547
679
  const hasProject = await isInitialized();
680
+ const legacyFileName = `${pluginName}.md`;
548
681
 
549
682
  // Check for dual location
550
- const locations = await checkPluginLocations(fileName);
683
+ const locations = await checkPluginLocations(pluginName, legacyFileName);
551
684
  let targetScope;
552
685
 
553
686
  if (locations.project && locations.global) {
@@ -556,7 +689,7 @@ async function installOrUpdateSpecificCommand(name, options = {}) {
556
689
  } else if (options.local) {
557
690
  targetScope = 'project';
558
691
  } else {
559
- const choice = await handleDualLocation(fileName);
692
+ const choice = await handleDualLocation(pluginName);
560
693
  if (choice === 'skip') return;
561
694
  targetScope = choice;
562
695
  }
@@ -573,45 +706,98 @@ async function installOrUpdateSpecificCommand(name, options = {}) {
573
706
  }
574
707
  }
575
708
 
576
- const commandsDir = getCommandsDir(targetScope);
577
- await mkdir(commandsDir, { recursive: true });
709
+ if (plugin.type === 'directory') {
710
+ // Directory-based plugin installation
711
+ const destDir = join(getPluginsDir(targetScope), pluginName);
712
+ const scopeLabel = targetScope === 'global' ? '~/.claude/plugins/' : '.claude/plugins/';
578
713
 
579
- const destPath = join(commandsDir, fileName);
580
- const srcContent = await readFile(srcPath, 'utf8');
581
- const scopeLabel = targetScope === 'global' ? '~/.claude/commands/' : '.claude/commands/';
714
+ if (await fileExists(destDir)) {
715
+ const srcHash = await directoryHash(plugin.path);
716
+ const destHash = await directoryHash(destDir);
717
+ if (srcHash === destHash) {
718
+ log.success(`${pluginName} is already up to date in ${scopeLabel}`);
719
+ return;
720
+ }
582
721
 
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;
722
+ const inquirer = (await import('inquirer')).default;
723
+ const { upgrade } = await inquirer.prompt([{
724
+ type: 'confirm', name: 'upgrade',
725
+ message: `${scopeLabel}${pluginName}/ is already installed. Upgrade to latest version?`,
726
+ default: true,
727
+ }]);
728
+ if (!upgrade) return;
729
+
730
+ await cp(plugin.path, destDir, { recursive: true, force: true });
731
+ log.success(`Upgraded ${scopeLabel}${pluginName}/`);
732
+ } else {
733
+ await mkdir(destDir, { recursive: true });
734
+ await cp(plugin.path, destDir, { recursive: true });
735
+ log.success(`Installed ${scopeLabel}${pluginName}/`);
736
+
737
+ if (targetScope === 'project') {
738
+ await addToGitignore(process.cwd(), `.claude/plugins/${pluginName}/`);
739
+ }
588
740
  }
589
741
 
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}`);
742
+ // Clean up legacy files
743
+ const legacyProjectPath = join(getCommandsDir('project'), legacyFileName);
744
+ const legacyGlobalPath = join(getCommandsDir('global'), legacyFileName);
745
+ if (await fileExists(legacyProjectPath)) {
746
+ const { unlink } = await import('fs/promises');
747
+ await unlink(legacyProjectPath);
748
+ await removeFromGitignore(`.claude/commands/${legacyFileName}`);
749
+ log.dim(` Removed legacy command: .claude/commands/${legacyFileName}`);
750
+ }
751
+ if (await fileExists(legacyGlobalPath)) {
752
+ const { unlink } = await import('fs/promises');
753
+ await unlink(legacyGlobalPath);
754
+ log.dim(` Removed legacy command: ~/.claude/commands/${legacyFileName}`);
755
+ }
756
+
757
+ if (targetScope === 'global' && locations.project) {
758
+ await removeFromGitignore(`.claude/plugins/${pluginName}/`);
759
+ }
600
760
  } else {
601
- await copyFile(srcPath, destPath);
602
- log.success(`Installed ${scopeLabel}${fileName}`);
761
+ // Legacy .md plugin installation
762
+ const commandsDir = getCommandsDir(targetScope);
763
+ await mkdir(commandsDir, { recursive: true });
764
+
765
+ const destPath = join(commandsDir, legacyFileName);
766
+ const srcContent = await readFile(plugin.path, 'utf8');
767
+ const scopeLabel = targetScope === 'global' ? '~/.claude/commands/' : '.claude/commands/';
768
+
769
+ if (await fileExists(destPath)) {
770
+ const destContent = await readFile(destPath, 'utf8');
771
+ if (fileHash(srcContent) === fileHash(destContent)) {
772
+ log.success(`${legacyFileName} is already up to date in ${scopeLabel}`);
773
+ return;
774
+ }
775
+
776
+ const inquirer = (await import('inquirer')).default;
777
+ const { upgrade } = await inquirer.prompt([{
778
+ type: 'confirm', name: 'upgrade',
779
+ message: `${scopeLabel}${legacyFileName} is already installed. Upgrade to latest version?`,
780
+ default: true,
781
+ }]);
782
+ if (!upgrade) return;
603
783
 
604
- if (targetScope === 'project') {
605
- await addToGitignore(process.cwd(), `.claude/commands/${fileName}`);
784
+ await copyFile(plugin.path, destPath);
785
+ log.success(`Upgraded ${scopeLabel}${legacyFileName}`);
786
+ } else {
787
+ await copyFile(plugin.path, destPath);
788
+ log.success(`Installed ${scopeLabel}${legacyFileName}`);
789
+
790
+ if (targetScope === 'project') {
791
+ await addToGitignore(process.cwd(), `.claude/commands/${legacyFileName}`);
792
+ }
606
793
  }
607
- }
608
794
 
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}`);
795
+ if (targetScope === 'global' && locations.project) {
796
+ await removeFromGitignore(`.claude/commands/${legacyFileName}`);
797
+ }
612
798
  }
613
799
 
614
- log.warn('Note: Commands will be available in new Claude Code sessions (restart any active session).');
800
+ log.warn('Note: Plugins will be available in new Claude Code sessions (restart any active session).');
615
801
  }
616
802
 
617
803
  // ─── Helpers ────────────────────────────────────────────────────────────────