@dboio/cli 0.4.2 → 0.5.1

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.
@@ -1,44 +1,185 @@
1
1
  import { Command } from 'commander';
2
2
  import { readdir, readFile, writeFile, mkdir, access, copyFile } from 'fs/promises';
3
- import { join, dirname, basename } from 'path';
3
+ import { join, dirname, resolve } from 'path';
4
4
  import { fileURLToPath } from 'url';
5
5
  import { execSync } from 'child_process';
6
+ import { createHash } from 'crypto';
7
+ import { homedir } from 'os';
6
8
  import { log } from '../lib/logger.js';
9
+ import { getPluginScope, setPluginScope, isInitialized, removeFromGitignore } from '../lib/config.js';
7
10
 
8
11
  const __dirname = dirname(fileURLToPath(import.meta.url));
12
+ const CLI_ROOT = join(__dirname, '..', '..');
9
13
  const PLUGINS_DIR = join(__dirname, '..', 'plugins', 'claudecommands');
10
14
 
11
15
  async function fileExists(path) {
12
16
  try { await access(path); return true; } catch { return false; }
13
17
  }
14
18
 
19
+ function fileHash(content) {
20
+ return createHash('md5').update(content).digest('hex');
21
+ }
22
+
23
+ /**
24
+ * Get the target commands directory based on scope.
25
+ * @param {'project' | 'global'} scope
26
+ * @returns {string} Absolute path to commands directory
27
+ */
28
+ function getCommandsDir(scope) {
29
+ if (scope === 'global') {
30
+ return join(homedir(), '.claude', 'commands');
31
+ }
32
+ return join(process.cwd(), '.claude', 'commands');
33
+ }
34
+
35
+ /**
36
+ * Get plugin name from filename (strips .md extension).
37
+ */
38
+ function getPluginName(filename) {
39
+ return filename.replace(/\.md$/, '');
40
+ }
41
+
42
+ /**
43
+ * Prompt user for plugin installation scope.
44
+ * @param {string} pluginName - Name of the plugin (without .md)
45
+ * @returns {Promise<'project' | 'global'>}
46
+ */
47
+ async function promptForScope(pluginName) {
48
+ const inquirer = (await import('inquirer')).default;
49
+ const { scope } = await inquirer.prompt([{
50
+ type: 'list',
51
+ name: 'scope',
52
+ message: `Where should the "${pluginName}" command be installed?`,
53
+ choices: [
54
+ { name: 'Project directory (.claude/commands/)', value: 'project' },
55
+ { name: 'User home directory (~/.claude/commands/)', value: 'global' },
56
+ ],
57
+ default: 'project',
58
+ }]);
59
+ return scope;
60
+ }
61
+
62
+ /**
63
+ * Resolve the scope for a plugin based on flags and stored preferences.
64
+ * Priority: explicit flag > stored preference > prompt.
65
+ * @param {string} pluginName - Plugin name without .md
66
+ * @param {object} options - Command options with global/local flags
67
+ * @returns {Promise<'project' | 'global'>}
68
+ */
69
+ async function resolvePluginScope(pluginName, options) {
70
+ if (options.global) return 'global';
71
+ if (options.local) return 'project';
72
+
73
+ const storedScope = await getPluginScope(pluginName);
74
+ if (storedScope) return storedScope;
75
+
76
+ return await promptForScope(pluginName);
77
+ }
78
+
79
+ /**
80
+ * Check if plugin exists in both project and global locations.
81
+ * @param {string} fileName - Plugin filename (with .md)
82
+ * @returns {Promise<{project: boolean, global: boolean}>}
83
+ */
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
+ };
91
+ }
92
+
93
+ /**
94
+ * Handle conflict when plugin exists in both locations.
95
+ * @param {string} fileName - Plugin filename
96
+ * @returns {Promise<'project' | 'global' | 'skip'>}
97
+ */
98
+ async function handleDualLocation(fileName) {
99
+ log.warn(`${fileName} exists in both project and global directories.`);
100
+ const inquirer = (await import('inquirer')).default;
101
+ const { target } = await inquirer.prompt([{
102
+ type: 'list',
103
+ name: 'target',
104
+ message: `Which location should be updated?`,
105
+ choices: [
106
+ { name: 'Project (.claude/commands/)', value: 'project' },
107
+ { name: 'Global (~/.claude/commands/)', value: 'global' },
108
+ { name: 'Skip this plugin', value: 'skip' },
109
+ ],
110
+ }]);
111
+ return target;
112
+ }
113
+
114
+ /**
115
+ * Parse a target string like "dbo@latest", "dbo@0.4.1", "plugins", "claudecommands", "claudecode",
116
+ * or a local path like "/path/to/cli/src".
117
+ */
118
+ function parseTarget(target) {
119
+ if (!target) return { type: null };
120
+
121
+ // Check if it's a local path (starts with / or . or ~)
122
+ if (target.startsWith('/') || target.startsWith('.') || target.startsWith('~')) {
123
+ return { type: 'cli-local', path: target };
124
+ }
125
+
126
+ // Check for dbo@version syntax
127
+ if (target.startsWith('dbo@')) {
128
+ const version = target.slice(4);
129
+ return { type: 'cli', version };
130
+ }
131
+
132
+ // Plain targets
133
+ if (target === 'dbo' || target === 'cli') return { type: 'cli', version: 'latest' };
134
+ if (target === 'plugins') return { type: 'plugins' };
135
+ if (target === 'claudecommands') return { type: 'claudecommands' };
136
+ if (target === 'claudecode') return { type: 'claudecode' };
137
+
138
+ // If it looks like a path that exists or contains path separators
139
+ if (target.includes('/') || target.includes('\\')) {
140
+ return { type: 'cli-local', path: target };
141
+ }
142
+
143
+ return { type: 'unknown', value: target };
144
+ }
145
+
15
146
  export const installCommand = new Command('install')
16
- .description('Install dbo-cli components (Claude Code commands, plugins)')
17
- .argument('[target]', 'What to install: claudecode, claudecommands')
18
- .option('--claudecommand <name>', 'Install a specific Claude command by name')
147
+ .description('Install or upgrade dbo-cli, plugins, or Claude Code commands')
148
+ .argument('[target]', 'What to install: dbo[@version], plugins, claudecommands, claudecode, or a local path')
149
+ .option('--claudecommand <name>', 'Install/update a specific Claude command by name')
150
+ .option('-g, --global', 'Install Claude commands to user home directory (~/.claude/commands/)')
151
+ .option('--local', 'Install Claude commands to project directory (.claude/commands/)')
19
152
  .action(async (target, options) => {
20
153
  try {
21
154
  if (options.claudecommand) {
22
- await installSpecificCommand(options.claudecommand);
23
- } else if (target === 'claudecode') {
24
- await installClaudeCode();
25
- } else if (target === 'claudecommands') {
26
- await installClaudeCommands();
155
+ await installOrUpdateSpecificCommand(options.claudecommand, options);
27
156
  } else {
28
- // Interactive
29
- const inquirer = (await import('inquirer')).default;
30
- const { choice } = await inquirer.prompt([{
31
- type: 'list', name: 'choice',
32
- message: 'What would you like to install?',
33
- choices: [
34
- 'Claude Code commands (adds /dbo to Claude Code)',
35
- 'Claude Code CLI + commands',
36
- ],
37
- }]);
38
- if (choice.includes('CLI')) {
39
- await installClaudeCode();
40
- } else {
41
- await installClaudeCommands();
157
+ const parsed = parseTarget(target);
158
+
159
+ switch (parsed.type) {
160
+ case 'cli':
161
+ await installCli(parsed.version);
162
+ break;
163
+ case 'cli-local':
164
+ await installCliFromLocal(parsed.path, options);
165
+ break;
166
+ case 'plugins':
167
+ await installPlugins(options);
168
+ break;
169
+ case 'claudecommands':
170
+ await installOrUpdateClaudeCommands(options);
171
+ break;
172
+ case 'claudecode':
173
+ await installClaudeCode(options);
174
+ break;
175
+ case 'unknown':
176
+ log.error(`Unknown install target: "${parsed.value}"`);
177
+ log.dim('Available targets: dbo[@version], plugins, claudecommands, claudecode, or a local path');
178
+ break;
179
+ default:
180
+ // Interactive
181
+ await installInteractive(options);
182
+ break;
42
183
  }
43
184
  }
44
185
  } catch (err) {
@@ -47,7 +188,199 @@ export const installCommand = new Command('install')
47
188
  }
48
189
  });
49
190
 
50
- async function installClaudeCode() {
191
+ async function installInteractive(options = {}) {
192
+ const inquirer = (await import('inquirer')).default;
193
+ const { choice } = await inquirer.prompt([{
194
+ type: 'list', name: 'choice',
195
+ message: 'What would you like to install?',
196
+ choices: [
197
+ { name: 'DBO CLI (install or upgrade)', value: 'cli' },
198
+ { name: 'Claude Code commands (adds /dbo to Claude Code)', value: 'claudecommands' },
199
+ { name: 'Claude Code CLI + commands', value: 'claudecode' },
200
+ { name: 'Plugins (Claude commands)', value: 'plugins' },
201
+ ],
202
+ }]);
203
+
204
+ switch (choice) {
205
+ case 'cli': await installCli('latest'); break;
206
+ case 'claudecommands': await installOrUpdateClaudeCommands(options); break;
207
+ case 'claudecode': await installClaudeCode(options); break;
208
+ case 'plugins': await installPlugins(options); break;
209
+ }
210
+ }
211
+
212
+ // ─── CLI Installation ───────────────────────────────────────────────────────
213
+
214
+ async function getInstalledVersion() {
215
+ try {
216
+ const pkg = JSON.parse(await readFile(join(CLI_ROOT, 'package.json'), 'utf8'));
217
+ return pkg.version;
218
+ } catch {
219
+ return null;
220
+ }
221
+ }
222
+
223
+ async function getAvailableVersions() {
224
+ try {
225
+ const output = execSync('npm view @dboio/cli versions --json 2>/dev/null', { encoding: 'utf8' });
226
+ return JSON.parse(output);
227
+ } catch {
228
+ return [];
229
+ }
230
+ }
231
+
232
+ async function getLatestVersion() {
233
+ try {
234
+ const output = execSync('npm view @dboio/cli version 2>/dev/null', { encoding: 'utf8' });
235
+ return output.trim();
236
+ } catch {
237
+ return null;
238
+ }
239
+ }
240
+
241
+ async function installCli(version) {
242
+ const currentVersion = await getInstalledVersion();
243
+ const isGitRepo = await fileExists(join(CLI_ROOT, '.git'));
244
+ const isInNodeModules = CLI_ROOT.includes('node_modules');
245
+
246
+ // If already installed, prompt for upgrade
247
+ if (currentVersion) {
248
+ log.label('Current version', currentVersion);
249
+
250
+ if (isGitRepo && !isInNodeModules) {
251
+ log.info('Detected git-based installation. Use a local path to upgrade from source:');
252
+ log.dim(' dbo install /path/to/local/cli/src');
253
+ log.info('Or switch to npm-based install:');
254
+ log.dim(' npm install -g @dboio/cli');
255
+ return;
256
+ }
257
+
258
+ // npm-based install — check for upgrade
259
+ const latestVersion = version === 'latest' ? await getLatestVersion() : version;
260
+
261
+ if (!latestVersion) {
262
+ log.error('Could not fetch version info from npm. Check your network.');
263
+ return;
264
+ }
265
+
266
+ if (version === 'latest' && latestVersion === currentVersion) {
267
+ log.success(`Already on the latest version (${currentVersion})`);
268
+ return;
269
+ }
270
+
271
+ if (latestVersion === currentVersion) {
272
+ log.success(`Already on version ${currentVersion}`);
273
+ return;
274
+ }
275
+
276
+ // Prompt for upgrade
277
+ const inquirer = (await import('inquirer')).default;
278
+
279
+ if (version === 'latest') {
280
+ const { upgrade } = await inquirer.prompt([{
281
+ type: 'confirm', name: 'upgrade',
282
+ message: `Upgrade from ${currentVersion} to ${latestVersion}?`,
283
+ default: true,
284
+ }]);
285
+ if (!upgrade) { log.dim('Skipped.'); return; }
286
+ } else {
287
+ // Specific version requested — validate it exists
288
+ const versions = await getAvailableVersions();
289
+ if (versions.length > 0 && !versions.includes(version)) {
290
+ log.error(`Version ${version} not found on npm`);
291
+ log.dim(`Available versions: ${versions.slice(-10).join(', ')}`);
292
+ return;
293
+ }
294
+ }
295
+
296
+ log.info(`Installing @dboio/cli@${latestVersion}...`);
297
+ try {
298
+ execSync(`npm install -g @dboio/cli@${latestVersion}`, { stdio: 'inherit' });
299
+ log.success(`dbo-cli upgraded to ${latestVersion}`);
300
+ } catch (err) {
301
+ log.error(`npm install failed: ${err.message}`);
302
+ }
303
+ return;
304
+ }
305
+
306
+ // Fresh install
307
+ const targetVersion = version === 'latest' ? '' : `@${version}`;
308
+ log.info(`Installing @dboio/cli${targetVersion}...`);
309
+ try {
310
+ execSync(`npm install -g @dboio/cli${targetVersion}`, { stdio: 'inherit' });
311
+ log.success('dbo-cli installed');
312
+ const newVersion = await getInstalledVersion();
313
+ if (newVersion) log.label('Version', newVersion);
314
+ } catch (err) {
315
+ log.error(`npm install failed: ${err.message}`);
316
+ }
317
+ }
318
+
319
+ async function installCliFromLocal(localPath, options = {}) {
320
+ const resolvedPath = resolve(localPath);
321
+
322
+ if (!await fileExists(resolvedPath)) {
323
+ log.error(`Path not found: ${resolvedPath}`);
324
+ return;
325
+ }
326
+
327
+ // Check for package.json in the local path
328
+ const pkgPath = join(resolvedPath, 'package.json');
329
+ if (!await fileExists(pkgPath)) {
330
+ log.error(`No package.json found at ${resolvedPath}`);
331
+ log.dim('Provide the path to the dbo-cli root directory (containing package.json).');
332
+ return;
333
+ }
334
+
335
+ const pkg = JSON.parse(await readFile(pkgPath, 'utf8'));
336
+ if (pkg.name !== '@dboio/cli') {
337
+ log.error(`Package at ${resolvedPath} is "${pkg.name}", not "@dboio/cli"`);
338
+ return;
339
+ }
340
+
341
+ const currentVersion = await getInstalledVersion();
342
+ const localVersion = pkg.version;
343
+
344
+ if (currentVersion) {
345
+ log.label('Current version', currentVersion);
346
+ log.label('Local version', localVersion);
347
+ }
348
+
349
+ const inquirer = (await import('inquirer')).default;
350
+ const { proceed } = await inquirer.prompt([{
351
+ type: 'confirm', name: 'proceed',
352
+ message: currentVersion
353
+ ? `Install dbo-cli from local source (${localVersion}) replacing ${currentVersion}?`
354
+ : `Install dbo-cli from local source (${localVersion})?`,
355
+ default: true,
356
+ }]);
357
+ if (!proceed) { log.dim('Skipped.'); return; }
358
+
359
+ log.info(`Installing from ${resolvedPath}...`);
360
+ try {
361
+ execSync('npm install', { cwd: resolvedPath, stdio: 'inherit' });
362
+ execSync(`npm install -g "${resolvedPath}"`, { stdio: 'inherit' });
363
+ log.success(`dbo-cli installed from local source (${localVersion})`);
364
+ } catch (err) {
365
+ log.error(`Local install failed: ${err.message}`);
366
+ log.dim('You can also use: cd <path> && npm link');
367
+ return;
368
+ }
369
+
370
+ // Offer to install/upgrade plugins after CLI install
371
+ const { installPluginsNow } = await inquirer.prompt([{
372
+ type: 'confirm', name: 'installPluginsNow',
373
+ message: 'Install/upgrade Claude Code command plugins?',
374
+ default: true,
375
+ }]);
376
+ if (installPluginsNow) {
377
+ await installOrUpdateClaudeCommands(options);
378
+ }
379
+ }
380
+
381
+ // ─── Claude Code Installation ───────────────────────────────────────────────
382
+
383
+ async function installClaudeCode(options = {}) {
51
384
  // Check if claude is installed
52
385
  try {
53
386
  const version = execSync('claude --version 2>&1', { encoding: 'utf8' }).trim();
@@ -64,28 +397,21 @@ async function installClaudeCode() {
64
397
  }
65
398
 
66
399
  // Then install commands
67
- await installClaudeCommands();
400
+ await installOrUpdateClaudeCommands(options);
68
401
  }
69
402
 
70
- export async function installClaudeCommands() {
71
- const cwd = process.cwd();
72
- const claudeDir = join(cwd, '.claude');
73
- const commandsDir = join(claudeDir, 'commands');
403
+ // ─── Plugins ────────────────────────────────────────────────────────────────
74
404
 
75
- // Check/create .claude/commands/
76
- if (!await fileExists(claudeDir)) {
77
- const inquirer = (await import('inquirer')).default;
78
- const { create } = await inquirer.prompt([{
79
- type: 'confirm', name: 'create',
80
- message: 'No .claude/ directory found. Create it for Claude Code integration?',
81
- default: true,
82
- }]);
83
- if (!create) {
84
- log.dim('Skipped Claude Code setup.');
85
- return;
86
- }
87
- }
88
- await mkdir(commandsDir, { recursive: true });
405
+ async function installPlugins(options = {}) {
406
+ log.info('Installing plugins...');
407
+ await installOrUpdateClaudeCommands(options);
408
+ }
409
+
410
+ // ─── Claude Commands ────────────────────────────────────────────────────────
411
+
412
+ export async function installOrUpdateClaudeCommands(options = {}) {
413
+ const cwd = process.cwd();
414
+ const hasProject = await isInitialized();
89
415
 
90
416
  // Find all plugin source files
91
417
  let pluginFiles;
@@ -102,42 +428,122 @@ export async function installClaudeCommands() {
102
428
  }
103
429
 
104
430
  let installed = 0;
431
+ let updated = 0;
432
+ let upToDate = 0;
433
+ let skipped = 0;
434
+
105
435
  for (const file of pluginFiles) {
436
+ const pluginName = getPluginName(file);
106
437
  const srcPath = join(PLUGINS_DIR, file);
438
+ const srcContent = await readFile(srcPath, 'utf8');
439
+
440
+ // Check if plugin exists in both locations
441
+ const locations = await checkPluginLocations(file);
442
+ let targetScope;
443
+
444
+ if (locations.project && locations.global) {
445
+ // Conflict — explicit flags resolve it, otherwise prompt
446
+ if (options.global) {
447
+ targetScope = 'global';
448
+ } else if (options.local) {
449
+ targetScope = 'project';
450
+ } else {
451
+ const choice = await handleDualLocation(file);
452
+ if (choice === 'skip') {
453
+ skipped++;
454
+ continue;
455
+ }
456
+ targetScope = choice;
457
+ }
458
+ } else {
459
+ targetScope = await resolvePluginScope(pluginName, options);
460
+ }
461
+
462
+ // Ensure target directory exists (prompt for project .claude/ only if needed)
463
+ const commandsDir = getCommandsDir(targetScope);
464
+ if (targetScope === 'project' && !await fileExists(join(cwd, '.claude'))) {
465
+ const inquirer = (await import('inquirer')).default;
466
+ const { create } = await inquirer.prompt([{
467
+ type: 'confirm', name: 'create',
468
+ message: 'No .claude/ directory found. Create it for Claude Code integration?',
469
+ default: true,
470
+ }]);
471
+ if (!create) {
472
+ log.dim('Skipped Claude Code setup.');
473
+ return;
474
+ }
475
+ }
476
+ await mkdir(commandsDir, { recursive: true });
477
+
478
+ // Persist the scope preference
479
+ if (options.global || options.local || !await getPluginScope(pluginName)) {
480
+ if (hasProject) {
481
+ await setPluginScope(pluginName, targetScope);
482
+ } else if (targetScope === 'global') {
483
+ log.warn(`Cannot persist scope preference (no .dbo/ directory). Run "dbo init" first.`);
484
+ }
485
+ }
486
+
107
487
  const destPath = join(commandsDir, file);
488
+ const scopeLabel = targetScope === 'global' ? '~/.claude/commands/' : '.claude/commands/';
108
489
 
109
- // Check for existing file
110
490
  if (await fileExists(destPath)) {
491
+ // Already installed — check if upgrade needed
492
+ const destContent = await readFile(destPath, 'utf8');
493
+ if (fileHash(srcContent) === fileHash(destContent)) {
494
+ upToDate++;
495
+ continue;
496
+ }
497
+
498
+ // File differs — prompt for upgrade
111
499
  const inquirer = (await import('inquirer')).default;
112
- const { overwrite } = await inquirer.prompt([{
113
- type: 'confirm', name: 'overwrite',
114
- message: `Overwrite existing .claude/commands/${file}?`,
115
- default: false,
500
+ const { upgrade } = await inquirer.prompt([{
501
+ type: 'confirm', name: 'upgrade',
502
+ message: `${scopeLabel}${file} is already installed. Upgrade to latest version?`,
503
+ default: true,
116
504
  }]);
117
- if (!overwrite) {
505
+ if (!upgrade) {
118
506
  log.dim(` Skipped ${file}`);
507
+ skipped++;
119
508
  continue;
120
509
  }
121
- }
122
510
 
123
- await copyFile(srcPath, destPath);
124
- log.success(`Installed .claude/commands/${file}`);
125
- installed++;
511
+ await copyFile(srcPath, destPath);
512
+ log.success(`Upgraded ${scopeLabel}${file}`);
513
+ updated++;
514
+ } else {
515
+ // New install
516
+ await copyFile(srcPath, destPath);
517
+ log.success(`Installed ${scopeLabel}${file}`);
518
+ installed++;
519
+
520
+ // Only add to gitignore for project-scope installs
521
+ if (targetScope === 'project') {
522
+ await addToGitignore(cwd, `.claude/commands/${file}`);
523
+ }
524
+ }
126
525
 
127
- // Add to .gitignore
128
- await addToGitignore(cwd, `.claude/commands/${file}`);
526
+ // If scope changed to global and project copy existed, remove from gitignore
527
+ if (targetScope === 'global' && locations.project) {
528
+ await removeFromGitignore(`.claude/commands/${file}`);
529
+ }
129
530
  }
130
531
 
131
- if (installed > 0) {
132
- log.info(`${installed} Claude command(s) installed. Use /dbo in Claude Code.`);
532
+ if (installed > 0) log.info(`${installed} command(s) installed.`);
533
+ if (updated > 0) log.info(`${updated} command(s) upgraded.`);
534
+ if (upToDate > 0) log.dim(`${upToDate} command(s) already up to date.`);
535
+ if (skipped > 0) log.dim(`${skipped} command(s) skipped.`);
536
+ if (installed > 0 || updated > 0) {
537
+ log.info('Use /dbo in Claude Code.');
133
538
  log.warn('Note: Commands will be available in new Claude Code sessions (restart any active session).');
134
- } else {
135
- log.dim('No commands installed.');
136
539
  }
137
540
  }
138
541
 
139
- async function installSpecificCommand(name) {
542
+ // ─── Specific Command ───────────────────────────────────────────────────────
543
+
544
+ async function installOrUpdateSpecificCommand(name, options = {}) {
140
545
  const fileName = name.endsWith('.md') ? name : `${name}.md`;
546
+ const pluginName = getPluginName(fileName);
141
547
  const srcPath = join(PLUGINS_DIR, fileName);
142
548
 
143
549
  if (!await fileExists(srcPath)) {
@@ -147,27 +553,78 @@ async function installSpecificCommand(name) {
147
553
  return;
148
554
  }
149
555
 
150
- const commandsDir = join(process.cwd(), '.claude', 'commands');
556
+ const hasProject = await isInitialized();
557
+
558
+ // Check for dual location
559
+ const locations = await checkPluginLocations(fileName);
560
+ let targetScope;
561
+
562
+ if (locations.project && locations.global) {
563
+ if (options.global) {
564
+ targetScope = 'global';
565
+ } else if (options.local) {
566
+ targetScope = 'project';
567
+ } else {
568
+ const choice = await handleDualLocation(fileName);
569
+ if (choice === 'skip') return;
570
+ targetScope = choice;
571
+ }
572
+ } else {
573
+ targetScope = await resolvePluginScope(pluginName, options);
574
+ }
575
+
576
+ // Persist scope
577
+ if (options.global || options.local || !await getPluginScope(pluginName)) {
578
+ if (hasProject) {
579
+ await setPluginScope(pluginName, targetScope);
580
+ } else if (targetScope === 'global') {
581
+ log.warn(`Cannot persist scope preference (no .dbo/ directory). Run "dbo init" first.`);
582
+ }
583
+ }
584
+
585
+ const commandsDir = getCommandsDir(targetScope);
151
586
  await mkdir(commandsDir, { recursive: true });
152
587
 
153
588
  const destPath = join(commandsDir, fileName);
589
+ const srcContent = await readFile(srcPath, 'utf8');
590
+ const scopeLabel = targetScope === 'global' ? '~/.claude/commands/' : '.claude/commands/';
154
591
 
155
592
  if (await fileExists(destPath)) {
593
+ const destContent = await readFile(destPath, 'utf8');
594
+ if (fileHash(srcContent) === fileHash(destContent)) {
595
+ log.success(`${fileName} is already up to date in ${scopeLabel}`);
596
+ return;
597
+ }
598
+
156
599
  const inquirer = (await import('inquirer')).default;
157
- const { overwrite } = await inquirer.prompt([{
158
- type: 'confirm', name: 'overwrite',
159
- message: `Overwrite existing .claude/commands/${fileName}?`,
160
- default: false,
600
+ const { upgrade } = await inquirer.prompt([{
601
+ type: 'confirm', name: 'upgrade',
602
+ message: `${scopeLabel}${fileName} is already installed. Upgrade to latest version?`,
603
+ default: true,
161
604
  }]);
162
- if (!overwrite) return;
605
+ if (!upgrade) return;
606
+
607
+ await copyFile(srcPath, destPath);
608
+ log.success(`Upgraded ${scopeLabel}${fileName}`);
609
+ } else {
610
+ await copyFile(srcPath, destPath);
611
+ log.success(`Installed ${scopeLabel}${fileName}`);
612
+
613
+ if (targetScope === 'project') {
614
+ await addToGitignore(process.cwd(), `.claude/commands/${fileName}`);
615
+ }
616
+ }
617
+
618
+ // If scope changed to global and project copy existed, remove from gitignore
619
+ if (targetScope === 'global' && locations.project) {
620
+ await removeFromGitignore(`.claude/commands/${fileName}`);
163
621
  }
164
622
 
165
- await copyFile(srcPath, destPath);
166
- log.success(`Installed .claude/commands/${fileName}`);
167
623
  log.warn('Note: Commands will be available in new Claude Code sessions (restart any active session).');
168
- await addToGitignore(process.cwd(), `.claude/commands/${fileName}`);
169
624
  }
170
625
 
626
+ // ─── Helpers ────────────────────────────────────────────────────────────────
627
+
171
628
  async function addToGitignore(projectDir, pattern) {
172
629
  const gitignorePath = join(projectDir, '.gitignore');
173
630
  let content = '';