@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.
- package/README.md +246 -70
- package/bin/dbo.js +7 -3
- package/package.json +9 -3
- package/src/commands/clone.js +469 -14
- package/src/commands/diff.js +246 -0
- package/src/commands/init.js +30 -22
- package/src/commands/install.js +526 -69
- package/src/commands/mv.js +869 -0
- package/src/commands/pull.js +6 -0
- package/src/commands/push.js +63 -21
- package/src/commands/rm.js +337 -0
- package/src/commands/status.js +28 -1
- package/src/lib/config.js +195 -0
- package/src/lib/diff.js +740 -0
- package/src/lib/save-to-disk.js +71 -4
- package/src/lib/structure.js +36 -0
- package/src/plugins/claudecommands/dbo.md +37 -6
- package/src/commands/update.js +0 -168
package/src/commands/install.js
CHANGED
|
@@ -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,
|
|
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
|
|
17
|
-
.argument('[target]', 'What to install: claudecode,
|
|
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
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
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
|
|
400
|
+
await installOrUpdateClaudeCommands(options);
|
|
68
401
|
}
|
|
69
402
|
|
|
70
|
-
|
|
71
|
-
const cwd = process.cwd();
|
|
72
|
-
const claudeDir = join(cwd, '.claude');
|
|
73
|
-
const commandsDir = join(claudeDir, 'commands');
|
|
403
|
+
// ─── Plugins ────────────────────────────────────────────────────────────────
|
|
74
404
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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 {
|
|
113
|
-
type: 'confirm', name: '
|
|
114
|
-
message:
|
|
115
|
-
default:
|
|
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 (!
|
|
505
|
+
if (!upgrade) {
|
|
118
506
|
log.dim(` Skipped ${file}`);
|
|
507
|
+
skipped++;
|
|
119
508
|
continue;
|
|
120
509
|
}
|
|
121
|
-
}
|
|
122
510
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
//
|
|
128
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
158
|
-
type: 'confirm', name: '
|
|
159
|
-
message:
|
|
160
|
-
default:
|
|
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 (!
|
|
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 = '';
|