@dboio/cli 0.4.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.
@@ -0,0 +1,143 @@
1
+ import { Command } from 'commander';
2
+ import { writeFile } from 'fs/promises';
3
+ import chalk from 'chalk';
4
+ import { DboClient } from '../lib/client.js';
5
+ import { buildInputBody } from '../lib/input-parser.js';
6
+ import { formatResponse, formatError } from '../lib/formatter.js';
7
+ import { saveToDisk } from '../lib/save-to-disk.js';
8
+ import { log } from '../lib/logger.js';
9
+
10
+ function collect(value, previous) {
11
+ return previous.concat([value]);
12
+ }
13
+
14
+ export const contentCommand = new Command('content')
15
+ .description('Get, deploy, or pull content from DBO.io')
16
+ .argument('[uid]', 'Content UID')
17
+ .option('-o, --output <path>', 'Save content to local file')
18
+ .option('--format <type>', 'Output format')
19
+ .option('--no-minify', 'Disable minification')
20
+ .option('--json', 'Output raw JSON')
21
+ .option('-v, --verbose', 'Show HTTP request details')
22
+ .option('--domain <host>', 'Override domain');
23
+
24
+ // Subcommand: dbo content deploy <uid> <filepath>
25
+ const deployCmd = new Command('deploy')
26
+ .description('Deploy a local file to a DBO content record')
27
+ .argument('<uid>', 'Content UID (RowUID)')
28
+ .argument('<filepath>', 'Local file path')
29
+ .option('-C, --confirm <value>', 'Commit: true (default) or false', 'true')
30
+ .option('--ticket <id>', 'Override ticket ID')
31
+ .option('--json', 'Output raw JSON')
32
+ .option('-v, --verbose', 'Show HTTP request details')
33
+ .option('--domain <host>', 'Override domain')
34
+ .action(async (uid, filepath, options) => {
35
+ try {
36
+ const client = new DboClient({ domain: options.domain, verbose: options.verbose });
37
+ const extraParams = { '_confirm': options.confirm };
38
+ if (options.ticket) extraParams['_OverrideTicketID'] = options.ticket;
39
+ const body = await buildInputBody([`RowUID:${uid};column:content.Content@${filepath}`], extraParams);
40
+ const result = await client.postUrlEncoded('/api/input/submit', body);
41
+ formatResponse(result, { json: options.json });
42
+ if (!result.successful) process.exit(1);
43
+ } catch (err) {
44
+ formatError(err);
45
+ process.exit(1);
46
+ }
47
+ });
48
+
49
+ // Subcommand: dbo content pull [uid] [--filter]
50
+ const pullCmd = new Command('pull')
51
+ .description('Pull content records to local files (uses content entity defaults)')
52
+ .argument('[uid]', 'Content UID (optional, pull single record)')
53
+ .option('--filter <expr>', 'Filter expression (repeatable)', collect, [])
54
+ .option('--maxrows <n>', 'Maximum rows')
55
+ .option('--json', 'Output raw JSON')
56
+ .option('-v, --verbose', 'Show HTTP request details')
57
+ .option('--domain <host>', 'Override domain')
58
+ .action(async (uid, options) => {
59
+ try {
60
+ const client = new DboClient({ domain: options.domain, verbose: options.verbose });
61
+ const params = { '_format': 'json_raw' };
62
+ if (options.maxrows) params['_maxrows'] = options.maxrows;
63
+
64
+ for (const f of options.filter) {
65
+ const atIdx = f.indexOf('=');
66
+ if (atIdx !== -1) {
67
+ const key = f.substring(0, atIdx);
68
+ const value = f.substring(atIdx + 1);
69
+ params[`_filter@${key}`] = value;
70
+ }
71
+ }
72
+
73
+ let path;
74
+ if (uid) {
75
+ path = `/api/output/entity/content/${uid}`;
76
+ } else {
77
+ path = '/api/output/entity/content';
78
+ }
79
+
80
+ const result = await client.get(path, params);
81
+
82
+ log.success(`Pulled from ${chalk.underline(result.url || '')}`);
83
+
84
+ const data = result.payload || result.data;
85
+
86
+ if (data && data.raw && typeof data.raw === 'string') {
87
+ log.error('Received non-JSON response. Check the content UID.');
88
+ process.exit(1);
89
+ }
90
+
91
+ const rows = Array.isArray(data) ? data : (data?.Rows || data?.rows || [data]);
92
+
93
+ if (rows.length === 0) {
94
+ log.warn('No content records found.');
95
+ return;
96
+ }
97
+
98
+ log.info(`${rows.length} record(s) to save`);
99
+
100
+ const columns = Object.keys(rows[0]);
101
+ await saveToDisk(rows, columns, {
102
+ entity: 'content',
103
+ saveFilename: columns.includes('Name') ? 'Name' : 'UID',
104
+ savePath: columns.includes('Path') ? 'Path' : null,
105
+ saveContent: columns.includes('Content') ? 'Content' : null,
106
+ saveExtension: columns.includes('Extension') ? 'Extension' : null,
107
+ nonInteractive: false,
108
+ });
109
+ } catch (err) {
110
+ formatError(err);
111
+ process.exit(1);
112
+ }
113
+ });
114
+
115
+ contentCommand.addCommand(deployCmd);
116
+ contentCommand.addCommand(pullCmd);
117
+
118
+ // Default action: get content
119
+ contentCommand.action(async (uid, options) => {
120
+ try {
121
+ if (!uid) {
122
+ console.error('Error: provide a content UID or use a subcommand (deploy, pull)');
123
+ process.exit(1);
124
+ }
125
+ const client = new DboClient({ domain: options.domain, verbose: options.verbose });
126
+ const params = {};
127
+ if (options.format) params['_format'] = options.format;
128
+ if (options.noMinify) params['_no_minify'] = 'true';
129
+
130
+ const result = await client.get(`/api/content/${uid}`, params);
131
+
132
+ if (options.output) {
133
+ const text = typeof result.data === 'string' ? result.data : (result.data?.raw || JSON.stringify(result.data, null, 2));
134
+ await writeFile(options.output, text);
135
+ log.success(`Saved to ${options.output}`);
136
+ } else {
137
+ formatResponse(result, { json: options.json });
138
+ }
139
+ } catch (err) {
140
+ formatError(err);
141
+ process.exit(1);
142
+ }
143
+ });
@@ -0,0 +1,89 @@
1
+ import { Command } from 'commander';
2
+ import { readFile } from 'fs/promises';
3
+ import { DboClient } from '../lib/client.js';
4
+ import { buildInputBody } from '../lib/input-parser.js';
5
+ import { formatResponse, formatError } from '../lib/formatter.js';
6
+ import { log } from '../lib/logger.js';
7
+
8
+ const MANIFEST_FILE = 'dbo.deploy.json';
9
+
10
+ export const deployCommand = new Command('deploy')
11
+ .description('Deploy files to DBO.io using a manifest or direct arguments')
12
+ .argument('[name]', 'Deployment name from dbo.deploy.json (e.g., css:colors)')
13
+ .option('--all', 'Deploy all entries in the manifest')
14
+ .option('-C, --confirm <value>', 'Commit: true (default) or false', 'true')
15
+ .option('--ticket <id>', 'Override ticket ID')
16
+ .option('--json', 'Output raw JSON')
17
+ .option('-v, --verbose', 'Show HTTP request details')
18
+ .option('--domain <host>', 'Override domain')
19
+ .action(async (name, options) => {
20
+ try {
21
+ const client = new DboClient({ domain: options.domain, verbose: options.verbose });
22
+
23
+ // Load manifest
24
+ let manifest;
25
+ try {
26
+ const raw = await readFile(MANIFEST_FILE, 'utf8');
27
+ manifest = JSON.parse(raw);
28
+ } catch {
29
+ if (!name) {
30
+ log.error(`No ${MANIFEST_FILE} found and no deployment name specified.`);
31
+ log.dim(' Create a dbo.deploy.json or use: dbo content deploy <uid> <filepath>');
32
+ process.exit(1);
33
+ }
34
+ }
35
+
36
+ if (!manifest?.deployments) {
37
+ log.error(`Invalid ${MANIFEST_FILE}: missing "deployments" key.`);
38
+ process.exit(1);
39
+ }
40
+
41
+ const entries = options.all
42
+ ? Object.entries(manifest.deployments)
43
+ : name
44
+ ? [[name, manifest.deployments[name]]]
45
+ : [];
46
+
47
+ if (entries.length === 0 || (entries.length === 1 && !entries[0][1])) {
48
+ const available = Object.keys(manifest.deployments).join(', ');
49
+ log.error(name ? `Deployment "${name}" not found in manifest.` : 'No deployment specified.');
50
+ log.dim(` Available: ${available}`);
51
+ process.exit(1);
52
+ }
53
+
54
+ for (const [entryName, entry] of entries) {
55
+ if (!entry) {
56
+ log.warn(`Skipping unknown deployment: ${entryName}`);
57
+ continue;
58
+ }
59
+
60
+ const entity = entry.entity || 'content';
61
+ const column = entry.column || 'Content';
62
+ const uid = entry.uid;
63
+ const file = entry.file;
64
+
65
+ if (!uid || !file) {
66
+ log.warn(`Skipping "${entryName}": missing uid or file.`);
67
+ continue;
68
+ }
69
+
70
+ log.info(`Deploying ${entryName}: ${file} → ${entity}.${column} (${uid})`);
71
+
72
+ const extraParams = { '_confirm': options.confirm };
73
+ if (options.ticket) extraParams['_OverrideTicketID'] = options.ticket;
74
+ const body = await buildInputBody([`RowUID:${uid};column:${entity}.${column}@${file}`], extraParams);
75
+ const result = await client.postUrlEncoded('/api/input/submit', body);
76
+
77
+ if (result.successful) {
78
+ log.success(`${entryName} deployed`);
79
+ } else {
80
+ log.error(`${entryName} failed`);
81
+ for (const msg of result.messages) log.label('Message', msg);
82
+ if (!options.all) process.exit(1);
83
+ }
84
+ }
85
+ } catch (err) {
86
+ formatError(err);
87
+ process.exit(1);
88
+ }
89
+ });
@@ -0,0 +1,105 @@
1
+ import { Command } from 'commander';
2
+ import { access } from 'fs/promises';
3
+ import { join } from 'path';
4
+ import { isInitialized, hasLegacyConfig, readLegacyConfig, initConfig, saveCredentials, ensureGitignore } from '../lib/config.js';
5
+ import { installClaudeCommands } from './install.js';
6
+ import { log } from '../lib/logger.js';
7
+
8
+ export const initCommand = new Command('init')
9
+ .description('Initialize DBO CLI configuration for the current directory')
10
+ .option('--domain <host>', 'DBO instance domain (e.g., beta-dev_model.dbo.io)')
11
+ .option('--username <user>', 'DBO username')
12
+ .option('--force', 'Overwrite existing configuration')
13
+ .option('--app <shortName>', 'App short name (for clone)')
14
+ .option('--clone', 'Clone the app after initialization')
15
+ .action(async (options) => {
16
+ try {
17
+ if (await isInitialized() && !options.force) {
18
+ log.warn('Already initialized. Use --force to overwrite.');
19
+ return;
20
+ }
21
+
22
+ let domain = options.domain;
23
+ let username = options.username;
24
+
25
+ // Check for legacy config to offer migration
26
+ if (!domain && await hasLegacyConfig()) {
27
+ const legacy = await readLegacyConfig();
28
+ log.info('Legacy configuration detected (.domain, .username, .password)');
29
+ const inquirer = (await import('inquirer')).default;
30
+ const { migrate } = await inquirer.prompt([{
31
+ type: 'confirm',
32
+ name: 'migrate',
33
+ message: `Migrate from legacy config? Domain: ${legacy.domain || '(none)'}`,
34
+ default: true,
35
+ }]);
36
+ if (migrate) {
37
+ domain = legacy.domain;
38
+ username = legacy.username;
39
+ }
40
+ }
41
+
42
+ // Interactive prompts for missing values
43
+ if (!domain || !username) {
44
+ const inquirer = (await import('inquirer')).default;
45
+ const answers = await inquirer.prompt([
46
+ { type: 'input', name: 'domain', message: 'Domain (e.g., myapp.dbo.io):', default: domain || undefined, when: !domain, validate: v => v ? true : 'Domain is required' },
47
+ { type: 'input', name: 'username', message: 'Username (email):', when: !username },
48
+ ]);
49
+ domain = domain || answers.domain;
50
+ username = username || answers.username;
51
+ }
52
+
53
+ await initConfig(domain);
54
+ if (username) {
55
+ await saveCredentials(username);
56
+ }
57
+
58
+ // Ensure sensitive files are gitignored
59
+ await ensureGitignore(['.dbo/credentials.json', '.dbo/cookies.txt']);
60
+
61
+ log.success(`Initialized .dbo/ for ${domain}`);
62
+ log.dim(' Run "dbo login" to authenticate.');
63
+
64
+ // Clone if requested
65
+ if (options.clone || options.app) {
66
+ let appShortName = options.app;
67
+ if (!appShortName) {
68
+ const inq = (await import('inquirer')).default;
69
+ const { appName } = await inq.prompt([{
70
+ type: 'input', name: 'appName',
71
+ message: 'App short name to clone:',
72
+ validate: v => v.trim() ? true : 'App short name is required',
73
+ }]);
74
+ appShortName = appName;
75
+ }
76
+ const { performClone } = await import('./clone.js');
77
+ await performClone(null, { app: appShortName, domain });
78
+ }
79
+
80
+ // Offer Claude Code integration
81
+ const claudeDir = join(process.cwd(), '.claude');
82
+ const inquirer = (await import('inquirer')).default;
83
+ let hasClaudeDir = false;
84
+ try { await access(claudeDir); hasClaudeDir = true; } catch {}
85
+
86
+ if (hasClaudeDir) {
87
+ const { installCmds } = await inquirer.prompt([{
88
+ type: 'confirm', name: 'installCmds',
89
+ message: 'Install DBO commands for Claude Code? (adds /dbo slash command)',
90
+ default: true,
91
+ }]);
92
+ if (installCmds) await installClaudeCommands();
93
+ } else {
94
+ const { setupClaude } = await inquirer.prompt([{
95
+ type: 'confirm', name: 'setupClaude',
96
+ message: 'Set up Claude Code integration? (creates .claude/commands/)',
97
+ default: false,
98
+ }]);
99
+ if (setupClaude) await installClaudeCommands();
100
+ }
101
+ } catch (err) {
102
+ log.error(err.message);
103
+ process.exit(1);
104
+ }
105
+ });
@@ -0,0 +1,111 @@
1
+ import { Command } from 'commander';
2
+ import { DboClient } from '../lib/client.js';
3
+ import { buildInputBody, parseFileArg, checkSubmitErrors } from '../lib/input-parser.js';
4
+ import { formatResponse, formatError } from '../lib/formatter.js';
5
+ import { loadAppConfig } from '../lib/config.js';
6
+ import { log } from '../lib/logger.js';
7
+
8
+ function collect(value, previous) {
9
+ return previous.concat([value]);
10
+ }
11
+
12
+ export const inputCommand = new Command('input')
13
+ .description('Submit CRUD operations to DBO.io (add, edit, delete records)')
14
+ .requiredOption('-d, --data <expr>', 'DBO input expression (repeatable)', collect, [])
15
+ .option('-f, --file <field=@path>', 'File attachment for multipart upload (repeatable)', collect, [])
16
+ .option('-C, --confirm <value>', 'Commit changes: true (default) or false for validation only', 'true')
17
+ .option('--ticket <id>', 'Override ticket ID (_OverrideTicketID)')
18
+ .option('--login', 'Auto-login user created by this submission')
19
+ .option('--transactional', 'Use transactional processing')
20
+ .option('--json', 'Output raw JSON response')
21
+ .option('--jq <expr>', 'Filter JSON response with jq-like expression (implies --json)')
22
+ .option('-v, --verbose', 'Show HTTP request details')
23
+ .option('--domain <host>', 'Override domain')
24
+ .action(async (options) => {
25
+ try {
26
+ const client = new DboClient({ domain: options.domain, verbose: options.verbose });
27
+
28
+ const extraParams = {};
29
+ extraParams['_confirm'] = options.confirm;
30
+ if (options.ticket) extraParams['_OverrideTicketID'] = options.ticket;
31
+ if (options.login) extraParams['_login'] = 'true';
32
+ if (options.transactional) extraParams['_transactional'] = 'true';
33
+
34
+ // Check if data expressions include AppID; if not and config has one, prompt
35
+ const allDataText = options.data.join(' ');
36
+ const hasAppId = /\.AppID[=@]/.test(allDataText) || /AppID=/.test(allDataText);
37
+ if (!hasAppId) {
38
+ const appConfig = await loadAppConfig();
39
+ if (appConfig.AppID) {
40
+ const inquirer = (await import('inquirer')).default;
41
+ const { appIdChoice } = await inquirer.prompt([{
42
+ type: 'list',
43
+ name: 'appIdChoice',
44
+ message: `You're submitting data without an AppID, but your config has information about the current App. Do you want me to add that Column information along with your submission?`,
45
+ choices: [
46
+ { name: `Yes, use AppID ${appConfig.AppID}`, value: 'use_config' },
47
+ { name: 'No', value: 'none' },
48
+ { name: 'Enter custom AppID', value: 'custom' },
49
+ ],
50
+ }]);
51
+ if (appIdChoice === 'use_config') {
52
+ extraParams['AppID'] = String(appConfig.AppID);
53
+ log.dim(` Using AppID ${appConfig.AppID} from config`);
54
+ } else if (appIdChoice === 'custom') {
55
+ const { customAppId } = await inquirer.prompt([{
56
+ type: 'input', name: 'customAppId',
57
+ message: 'Custom AppID:',
58
+ }]);
59
+ if (customAppId.trim()) extraParams['AppID'] = customAppId.trim();
60
+ }
61
+ }
62
+ }
63
+
64
+ if (options.file.length > 0) {
65
+ // Multipart mode
66
+ const fields = { ...extraParams };
67
+ // Parse data expressions into flat key-value pairs for form fields
68
+ for (const expr of options.data) {
69
+ for (const op of expr.split('&')) {
70
+ const trimmed = op.trim();
71
+ const eqIdx = trimmed.indexOf('=');
72
+ if (eqIdx !== -1) {
73
+ fields[trimmed.substring(0, eqIdx)] = trimmed.substring(eqIdx + 1);
74
+ } else {
75
+ fields[trimmed] = '';
76
+ }
77
+ }
78
+ }
79
+ const files = options.file.map(parseFileArg);
80
+ let result = await client.postMultipart('/api/input/submit', fields, files);
81
+
82
+ // Retry with prompted params if needed (ticket, user)
83
+ const retryParams = await checkSubmitErrors(result);
84
+ if (retryParams) {
85
+ Object.assign(fields, retryParams);
86
+ result = await client.postMultipart('/api/input/submit', fields, files);
87
+ }
88
+
89
+ formatResponse(result, { json: options.json, jq: options.jq });
90
+ if (!result.successful) process.exit(1);
91
+ } else {
92
+ // URL-encoded mode
93
+ let body = await buildInputBody(options.data, extraParams);
94
+ let result = await client.postUrlEncoded('/api/input/submit', body);
95
+
96
+ // Retry with prompted params if needed (ticket, user)
97
+ const retryParams = await checkSubmitErrors(result);
98
+ if (retryParams) {
99
+ Object.assign(extraParams, retryParams);
100
+ body = await buildInputBody(options.data, extraParams);
101
+ result = await client.postUrlEncoded('/api/input/submit', body);
102
+ }
103
+
104
+ formatResponse(result, { json: options.json, jq: options.jq });
105
+ if (!result.successful) process.exit(1);
106
+ }
107
+ } catch (err) {
108
+ formatError(err);
109
+ process.exit(1);
110
+ }
111
+ });
@@ -0,0 +1,186 @@
1
+ import { Command } from 'commander';
2
+ import { readdir, readFile, writeFile, mkdir, access, copyFile } from 'fs/promises';
3
+ import { join, dirname, basename } from 'path';
4
+ import { fileURLToPath } from 'url';
5
+ import { execSync } from 'child_process';
6
+ import { log } from '../lib/logger.js';
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+ const PLUGINS_DIR = join(__dirname, '..', 'plugins', 'claudecommands');
10
+
11
+ async function fileExists(path) {
12
+ try { await access(path); return true; } catch { return false; }
13
+ }
14
+
15
+ 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')
19
+ .action(async (target, options) => {
20
+ try {
21
+ if (options.claudecommand) {
22
+ await installSpecificCommand(options.claudecommand);
23
+ } else if (target === 'claudecode') {
24
+ await installClaudeCode();
25
+ } else if (target === 'claudecommands') {
26
+ await installClaudeCommands();
27
+ } 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();
42
+ }
43
+ }
44
+ } catch (err) {
45
+ log.error(err.message);
46
+ process.exit(1);
47
+ }
48
+ });
49
+
50
+ async function installClaudeCode() {
51
+ // Check if claude is installed
52
+ try {
53
+ const version = execSync('claude --version 2>&1', { encoding: 'utf8' }).trim();
54
+ log.success(`Claude Code already installed: ${version}`);
55
+ } catch {
56
+ log.info('Claude Code not found. Installing...');
57
+ try {
58
+ execSync('npm install -g @anthropic-ai/claude-code', { stdio: 'inherit' });
59
+ log.success('Claude Code installed');
60
+ } catch (err) {
61
+ log.error('Failed to install Claude Code. Try manually: npm install -g @anthropic-ai/claude-code');
62
+ return;
63
+ }
64
+ }
65
+
66
+ // Then install commands
67
+ await installClaudeCommands();
68
+ }
69
+
70
+ export async function installClaudeCommands() {
71
+ const cwd = process.cwd();
72
+ const claudeDir = join(cwd, '.claude');
73
+ const commandsDir = join(claudeDir, 'commands');
74
+
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 });
89
+
90
+ // Find all plugin source files
91
+ let pluginFiles;
92
+ try {
93
+ pluginFiles = (await readdir(PLUGINS_DIR)).filter(f => f.endsWith('.md'));
94
+ } catch {
95
+ log.error(`Plugin source directory not found: ${PLUGINS_DIR}`);
96
+ return;
97
+ }
98
+
99
+ if (pluginFiles.length === 0) {
100
+ log.warn('No Claude command plugins found in source.');
101
+ return;
102
+ }
103
+
104
+ let installed = 0;
105
+ for (const file of pluginFiles) {
106
+ const srcPath = join(PLUGINS_DIR, file);
107
+ const destPath = join(commandsDir, file);
108
+
109
+ // Check for existing file
110
+ if (await fileExists(destPath)) {
111
+ 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,
116
+ }]);
117
+ if (!overwrite) {
118
+ log.dim(` Skipped ${file}`);
119
+ continue;
120
+ }
121
+ }
122
+
123
+ await copyFile(srcPath, destPath);
124
+ log.success(`Installed .claude/commands/${file}`);
125
+ installed++;
126
+
127
+ // Add to .gitignore
128
+ await addToGitignore(cwd, `.claude/commands/${file}`);
129
+ }
130
+
131
+ if (installed > 0) {
132
+ log.info(`${installed} Claude command(s) installed. Use /dbo in Claude Code.`);
133
+ 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
+ }
137
+ }
138
+
139
+ async function installSpecificCommand(name) {
140
+ const fileName = name.endsWith('.md') ? name : `${name}.md`;
141
+ const srcPath = join(PLUGINS_DIR, fileName);
142
+
143
+ if (!await fileExists(srcPath)) {
144
+ log.error(`Command plugin "${name}" not found in ${PLUGINS_DIR}`);
145
+ const available = (await readdir(PLUGINS_DIR)).filter(f => f.endsWith('.md')).map(f => f.replace('.md', ''));
146
+ if (available.length > 0) log.dim(` Available: ${available.join(', ')}`);
147
+ return;
148
+ }
149
+
150
+ const commandsDir = join(process.cwd(), '.claude', 'commands');
151
+ await mkdir(commandsDir, { recursive: true });
152
+
153
+ const destPath = join(commandsDir, fileName);
154
+
155
+ if (await fileExists(destPath)) {
156
+ 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,
161
+ }]);
162
+ if (!overwrite) return;
163
+ }
164
+
165
+ await copyFile(srcPath, destPath);
166
+ log.success(`Installed .claude/commands/${fileName}`);
167
+ 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
+ }
170
+
171
+ async function addToGitignore(projectDir, pattern) {
172
+ const gitignorePath = join(projectDir, '.gitignore');
173
+ let content = '';
174
+ try {
175
+ content = await readFile(gitignorePath, 'utf8');
176
+ } catch { /* no .gitignore yet */ }
177
+
178
+ if (content.includes(pattern)) return; // already there
179
+
180
+ const addition = content.endsWith('\n') || content === ''
181
+ ? `\n# DBO CLI Claude Code commands (managed by dbo-cli)\n${pattern}\n`
182
+ : `\n\n# DBO CLI Claude Code commands (managed by dbo-cli)\n${pattern}\n`;
183
+
184
+ await writeFile(gitignorePath, content + addition);
185
+ log.dim(` Added ${pattern} to .gitignore`);
186
+ }
@@ -0,0 +1,44 @@
1
+ import { Command } from 'commander';
2
+ import { DboClient } from '../lib/client.js';
3
+ import { formatResponse, formatError } from '../lib/formatter.js';
4
+
5
+ export const instanceCommand = new Command('instance')
6
+ .description('Manage DBO.io instances');
7
+
8
+ instanceCommand
9
+ .command('export')
10
+ .description('Export the current instance')
11
+ .option('-C, --confirm <value>', 'Commit: true (default) or false', 'true')
12
+ .option('--json', 'Output raw JSON')
13
+ .option('-v, --verbose', 'Show HTTP request details')
14
+ .option('--domain <host>', 'Override domain')
15
+ .action(async (options) => {
16
+ try {
17
+ const client = new DboClient({ domain: options.domain, verbose: options.verbose });
18
+ const result = await client.postUrlEncoded('/api/instance/export', `_confirm=${options.confirm}`);
19
+ formatResponse(result, { json: options.json });
20
+ if (!result.successful) process.exit(1);
21
+ } catch (err) {
22
+ formatError(err);
23
+ process.exit(1);
24
+ }
25
+ });
26
+
27
+ instanceCommand
28
+ .command('build <uid>')
29
+ .description('Build an instance from a pending instance UID')
30
+ .option('-C, --confirm <value>', 'Commit: true (default) or false', 'true')
31
+ .option('--json', 'Output raw JSON')
32
+ .option('-v, --verbose', 'Show HTTP request details')
33
+ .option('--domain <host>', 'Override domain')
34
+ .action(async (uid, options) => {
35
+ try {
36
+ const client = new DboClient({ domain: options.domain, verbose: options.verbose });
37
+ const result = await client.postUrlEncoded(`/api/instance/build/${uid}`, `_confirm=${options.confirm}`);
38
+ formatResponse(result, { json: options.json });
39
+ if (!result.successful) process.exit(1);
40
+ } catch (err) {
41
+ formatError(err);
42
+ process.exit(1);
43
+ }
44
+ });