@dboio/cli 0.4.2 → 0.6.0

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,186 @@
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
+ .alias('i')
148
+ .description('Install or upgrade dbo-cli, plugins, or Claude Code commands')
149
+ .argument('[target]', 'What to install: dbo[@version], plugins, claudecommands, claudecode, or a local path')
150
+ .option('--claudecommand <name>', 'Install/update a specific Claude command by name')
151
+ .option('-g, --global', 'Install Claude commands to user home directory (~/.claude/commands/)')
152
+ .option('--local', 'Install Claude commands to project directory (.claude/commands/)')
19
153
  .action(async (target, options) => {
20
154
  try {
21
155
  if (options.claudecommand) {
22
- await installSpecificCommand(options.claudecommand);
23
- } else if (target === 'claudecode') {
24
- await installClaudeCode();
25
- } else if (target === 'claudecommands') {
26
- await installClaudeCommands();
156
+ await installOrUpdateSpecificCommand(options.claudecommand, options);
27
157
  } 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();
158
+ const parsed = parseTarget(target);
159
+
160
+ switch (parsed.type) {
161
+ case 'cli':
162
+ await installCli(parsed.version);
163
+ break;
164
+ case 'cli-local':
165
+ await installCliFromLocal(parsed.path);
166
+ break;
167
+ case 'plugins':
168
+ await installPlugins(options);
169
+ break;
170
+ case 'claudecommands':
171
+ await installOrUpdateClaudeCommands(options);
172
+ break;
173
+ case 'claudecode':
174
+ await installClaudeCode(options);
175
+ break;
176
+ case 'unknown':
177
+ log.error(`Unknown install target: "${parsed.value}"`);
178
+ log.dim('Available targets: dbo[@version], plugins, claudecommands, claudecode, or a local path');
179
+ break;
180
+ default:
181
+ // Interactive
182
+ await installInteractive(options);
183
+ break;
42
184
  }
43
185
  }
44
186
  } catch (err) {
@@ -47,7 +189,189 @@ export const installCommand = new Command('install')
47
189
  }
48
190
  });
49
191
 
50
- async function installClaudeCode() {
192
+ async function installInteractive(options = {}) {
193
+ const inquirer = (await import('inquirer')).default;
194
+ const { choice } = await inquirer.prompt([{
195
+ type: 'list', name: 'choice',
196
+ message: 'What would you like to install?',
197
+ choices: [
198
+ { name: 'DBO CLI (install or upgrade)', value: 'cli' },
199
+ { name: 'Claude Code commands (adds /dbo to Claude Code)', value: 'claudecommands' },
200
+ { name: 'Claude Code CLI + commands', value: 'claudecode' },
201
+ { name: 'Plugins (Claude commands)', value: 'plugins' },
202
+ ],
203
+ }]);
204
+
205
+ switch (choice) {
206
+ case 'cli': await installCli('latest'); break;
207
+ case 'claudecommands': await installOrUpdateClaudeCommands(options); break;
208
+ case 'claudecode': await installClaudeCode(options); break;
209
+ case 'plugins': await installPlugins(options); break;
210
+ }
211
+ }
212
+
213
+ // ─── CLI Installation ───────────────────────────────────────────────────────
214
+
215
+ async function getInstalledVersion() {
216
+ try {
217
+ const pkg = JSON.parse(await readFile(join(CLI_ROOT, 'package.json'), 'utf8'));
218
+ return pkg.version;
219
+ } catch {
220
+ return null;
221
+ }
222
+ }
223
+
224
+ async function getAvailableVersions() {
225
+ try {
226
+ const output = execSync('npm view @dboio/cli versions --json 2>/dev/null', { encoding: 'utf8' });
227
+ return JSON.parse(output);
228
+ } catch {
229
+ return [];
230
+ }
231
+ }
232
+
233
+ async function getLatestVersion() {
234
+ try {
235
+ const output = execSync('npm view @dboio/cli version 2>/dev/null', { encoding: 'utf8' });
236
+ return output.trim();
237
+ } catch {
238
+ return null;
239
+ }
240
+ }
241
+
242
+ async function installCli(version) {
243
+ const currentVersion = await getInstalledVersion();
244
+ const isGitRepo = await fileExists(join(CLI_ROOT, '.git'));
245
+ const isInNodeModules = CLI_ROOT.includes('node_modules');
246
+
247
+ // If already installed, prompt for upgrade
248
+ if (currentVersion) {
249
+ log.label('Current version', currentVersion);
250
+
251
+ if (isGitRepo && !isInNodeModules) {
252
+ log.info('Detected git-based installation. Use a local path to upgrade from source:');
253
+ log.dim(' dbo install /path/to/local/cli/src');
254
+ log.info('Or switch to npm-based install:');
255
+ log.dim(' npm install -g @dboio/cli');
256
+ return;
257
+ }
258
+
259
+ // npm-based install — check for upgrade
260
+ const latestVersion = version === 'latest' ? await getLatestVersion() : version;
261
+
262
+ if (!latestVersion) {
263
+ log.error('Could not fetch version info from npm. Check your network.');
264
+ return;
265
+ }
266
+
267
+ if (version === 'latest' && latestVersion === currentVersion) {
268
+ log.success(`Already on the latest version (${currentVersion})`);
269
+ return;
270
+ }
271
+
272
+ if (latestVersion === currentVersion) {
273
+ log.success(`Already on version ${currentVersion}`);
274
+ return;
275
+ }
276
+
277
+ // Prompt for upgrade
278
+ const inquirer = (await import('inquirer')).default;
279
+
280
+ if (version === 'latest') {
281
+ const { upgrade } = await inquirer.prompt([{
282
+ type: 'confirm', name: 'upgrade',
283
+ message: `Upgrade from ${currentVersion} to ${latestVersion}?`,
284
+ default: true,
285
+ }]);
286
+ if (!upgrade) { log.dim('Skipped.'); return; }
287
+ } else {
288
+ // Specific version requested — validate it exists
289
+ const versions = await getAvailableVersions();
290
+ if (versions.length > 0 && !versions.includes(version)) {
291
+ log.error(`Version ${version} not found on npm`);
292
+ log.dim(`Available versions: ${versions.slice(-10).join(', ')}`);
293
+ return;
294
+ }
295
+ }
296
+
297
+ log.info(`Installing @dboio/cli@${latestVersion}...`);
298
+ try {
299
+ execSync(`npm install -g @dboio/cli@${latestVersion}`, { stdio: 'inherit' });
300
+ log.success(`dbo-cli upgraded to ${latestVersion}`);
301
+ } catch (err) {
302
+ log.error(`npm install failed: ${err.message}`);
303
+ }
304
+ return;
305
+ }
306
+
307
+ // Fresh install
308
+ const targetVersion = version === 'latest' ? '' : `@${version}`;
309
+ log.info(`Installing @dboio/cli${targetVersion}...`);
310
+ try {
311
+ execSync(`npm install -g @dboio/cli${targetVersion}`, { stdio: 'inherit' });
312
+ log.success('dbo-cli installed');
313
+ const newVersion = await getInstalledVersion();
314
+ if (newVersion) log.label('Version', newVersion);
315
+ } catch (err) {
316
+ log.error(`npm install failed: ${err.message}`);
317
+ }
318
+ }
319
+
320
+ async function installCliFromLocal(localPath) {
321
+ const resolvedPath = resolve(localPath);
322
+
323
+ if (!await fileExists(resolvedPath)) {
324
+ log.error(`Path not found: ${resolvedPath}`);
325
+ return;
326
+ }
327
+
328
+ // Check for package.json in the local path
329
+ const pkgPath = join(resolvedPath, 'package.json');
330
+ if (!await fileExists(pkgPath)) {
331
+ log.error(`No package.json found at ${resolvedPath}`);
332
+ log.dim('Provide the path to the dbo-cli root directory (containing package.json).');
333
+ return;
334
+ }
335
+
336
+ const pkg = JSON.parse(await readFile(pkgPath, 'utf8'));
337
+ if (pkg.name !== '@dboio/cli') {
338
+ log.error(`Package at ${resolvedPath} is "${pkg.name}", not "@dboio/cli"`);
339
+ return;
340
+ }
341
+
342
+ const currentVersion = await getInstalledVersion();
343
+ const localVersion = pkg.version;
344
+
345
+ if (currentVersion) {
346
+ log.label('Current version', currentVersion);
347
+ log.label('Local version', localVersion);
348
+ }
349
+
350
+ const inquirer = (await import('inquirer')).default;
351
+ const { proceed } = await inquirer.prompt([{
352
+ type: 'confirm', name: 'proceed',
353
+ message: currentVersion
354
+ ? `Install dbo-cli from local source (${localVersion}) replacing ${currentVersion}?`
355
+ : `Install dbo-cli from local source (${localVersion})?`,
356
+ default: true,
357
+ }]);
358
+ if (!proceed) { log.dim('Skipped.'); return; }
359
+
360
+ log.info(`Installing from ${resolvedPath}...`);
361
+ try {
362
+ execSync('npm install', { cwd: resolvedPath, stdio: 'inherit' });
363
+ execSync(`npm install -g "${resolvedPath}"`, { stdio: 'inherit' });
364
+ log.success(`dbo-cli installed from local source (${localVersion})`);
365
+ } catch (err) {
366
+ log.error(`Local install failed: ${err.message}`);
367
+ log.dim('You can also use: cd <path> && npm link');
368
+ return;
369
+ }
370
+ }
371
+
372
+ // ─── Claude Code Installation ───────────────────────────────────────────────
373
+
374
+ async function installClaudeCode(options = {}) {
51
375
  // Check if claude is installed
52
376
  try {
53
377
  const version = execSync('claude --version 2>&1', { encoding: 'utf8' }).trim();
@@ -64,28 +388,21 @@ async function installClaudeCode() {
64
388
  }
65
389
 
66
390
  // Then install commands
67
- await installClaudeCommands();
391
+ await installOrUpdateClaudeCommands(options);
68
392
  }
69
393
 
70
- export async function installClaudeCommands() {
71
- const cwd = process.cwd();
72
- const claudeDir = join(cwd, '.claude');
73
- const commandsDir = join(claudeDir, 'commands');
394
+ // ─── Plugins ────────────────────────────────────────────────────────────────
74
395
 
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 });
396
+ async function installPlugins(options = {}) {
397
+ log.info('Installing plugins...');
398
+ await installOrUpdateClaudeCommands(options);
399
+ }
400
+
401
+ // ─── Claude Commands ────────────────────────────────────────────────────────
402
+
403
+ export async function installOrUpdateClaudeCommands(options = {}) {
404
+ const cwd = process.cwd();
405
+ const hasProject = await isInitialized();
89
406
 
90
407
  // Find all plugin source files
91
408
  let pluginFiles;
@@ -102,42 +419,122 @@ export async function installClaudeCommands() {
102
419
  }
103
420
 
104
421
  let installed = 0;
422
+ let updated = 0;
423
+ let upToDate = 0;
424
+ let skipped = 0;
425
+
105
426
  for (const file of pluginFiles) {
427
+ const pluginName = getPluginName(file);
106
428
  const srcPath = join(PLUGINS_DIR, file);
429
+ const srcContent = await readFile(srcPath, 'utf8');
430
+
431
+ // Check if plugin exists in both locations
432
+ const locations = await checkPluginLocations(file);
433
+ let targetScope;
434
+
435
+ if (locations.project && locations.global) {
436
+ // Conflict — explicit flags resolve it, otherwise prompt
437
+ if (options.global) {
438
+ targetScope = 'global';
439
+ } else if (options.local) {
440
+ targetScope = 'project';
441
+ } else {
442
+ const choice = await handleDualLocation(file);
443
+ if (choice === 'skip') {
444
+ skipped++;
445
+ continue;
446
+ }
447
+ targetScope = choice;
448
+ }
449
+ } else {
450
+ targetScope = await resolvePluginScope(pluginName, options);
451
+ }
452
+
453
+ // Ensure target directory exists (prompt for project .claude/ only if needed)
454
+ const commandsDir = getCommandsDir(targetScope);
455
+ if (targetScope === 'project' && !await fileExists(join(cwd, '.claude'))) {
456
+ const inquirer = (await import('inquirer')).default;
457
+ const { create } = await inquirer.prompt([{
458
+ type: 'confirm', name: 'create',
459
+ message: 'No .claude/ directory found. Create it for Claude Code integration?',
460
+ default: true,
461
+ }]);
462
+ if (!create) {
463
+ log.dim('Skipped Claude Code setup.');
464
+ return;
465
+ }
466
+ }
467
+ await mkdir(commandsDir, { recursive: true });
468
+
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
+ }
477
+
107
478
  const destPath = join(commandsDir, file);
479
+ const scopeLabel = targetScope === 'global' ? '~/.claude/commands/' : '.claude/commands/';
108
480
 
109
- // Check for existing file
110
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;
487
+ }
488
+
489
+ // File differs — prompt for upgrade
111
490
  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,
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,
116
495
  }]);
117
- if (!overwrite) {
496
+ if (!upgrade) {
118
497
  log.dim(` Skipped ${file}`);
498
+ skipped++;
119
499
  continue;
120
500
  }
121
- }
122
501
 
123
- await copyFile(srcPath, destPath);
124
- log.success(`Installed .claude/commands/${file}`);
125
- installed++;
502
+ await copyFile(srcPath, destPath);
503
+ log.success(`Upgraded ${scopeLabel}${file}`);
504
+ updated++;
505
+ } else {
506
+ // New install
507
+ await copyFile(srcPath, destPath);
508
+ log.success(`Installed ${scopeLabel}${file}`);
509
+ installed++;
510
+
511
+ // Only add to gitignore for project-scope installs
512
+ if (targetScope === 'project') {
513
+ await addToGitignore(cwd, `.claude/commands/${file}`);
514
+ }
515
+ }
126
516
 
127
- // Add to .gitignore
128
- await addToGitignore(cwd, `.claude/commands/${file}`);
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}`);
520
+ }
129
521
  }
130
522
 
131
- if (installed > 0) {
132
- log.info(`${installed} Claude command(s) installed. Use /dbo in Claude Code.`);
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.`);
527
+ if (installed > 0 || updated > 0) {
528
+ log.info('Use /dbo in Claude Code.');
133
529
  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
530
  }
137
531
  }
138
532
 
139
- async function installSpecificCommand(name) {
533
+ // ─── Specific Command ───────────────────────────────────────────────────────
534
+
535
+ async function installOrUpdateSpecificCommand(name, options = {}) {
140
536
  const fileName = name.endsWith('.md') ? name : `${name}.md`;
537
+ const pluginName = getPluginName(fileName);
141
538
  const srcPath = join(PLUGINS_DIR, fileName);
142
539
 
143
540
  if (!await fileExists(srcPath)) {
@@ -147,27 +544,78 @@ async function installSpecificCommand(name) {
147
544
  return;
148
545
  }
149
546
 
150
- const commandsDir = join(process.cwd(), '.claude', 'commands');
547
+ const hasProject = await isInitialized();
548
+
549
+ // Check for dual location
550
+ const locations = await checkPluginLocations(fileName);
551
+ let targetScope;
552
+
553
+ if (locations.project && locations.global) {
554
+ if (options.global) {
555
+ targetScope = 'global';
556
+ } else if (options.local) {
557
+ targetScope = 'project';
558
+ } else {
559
+ const choice = await handleDualLocation(fileName);
560
+ if (choice === 'skip') return;
561
+ targetScope = choice;
562
+ }
563
+ } else {
564
+ targetScope = await resolvePluginScope(pluginName, options);
565
+ }
566
+
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
+ }
575
+
576
+ const commandsDir = getCommandsDir(targetScope);
151
577
  await mkdir(commandsDir, { recursive: true });
152
578
 
153
579
  const destPath = join(commandsDir, fileName);
580
+ const srcContent = await readFile(srcPath, 'utf8');
581
+ const scopeLabel = targetScope === 'global' ? '~/.claude/commands/' : '.claude/commands/';
154
582
 
155
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;
588
+ }
589
+
156
590
  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,
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,
161
595
  }]);
162
- if (!overwrite) return;
596
+ if (!upgrade) return;
597
+
598
+ await copyFile(srcPath, destPath);
599
+ log.success(`Upgraded ${scopeLabel}${fileName}`);
600
+ } else {
601
+ await copyFile(srcPath, destPath);
602
+ log.success(`Installed ${scopeLabel}${fileName}`);
603
+
604
+ if (targetScope === 'project') {
605
+ await addToGitignore(process.cwd(), `.claude/commands/${fileName}`);
606
+ }
607
+ }
608
+
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}`);
163
612
  }
164
613
 
165
- await copyFile(srcPath, destPath);
166
- log.success(`Installed .claude/commands/${fileName}`);
167
614
  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
615
  }
170
616
 
617
+ // ─── Helpers ────────────────────────────────────────────────────────────────
618
+
171
619
  async function addToGitignore(projectDir, pattern) {
172
620
  const gitignorePath = join(projectDir, '.gitignore');
173
621
  let content = '';