dotai-cli 1.0.0 → 1.0.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotai-cli",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Unified CLI for AI coding assistants - manage skills and MCP servers across Claude Code, Gemini CLI, Cursor, VS Code, and more",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -2,23 +2,76 @@ import chalk from 'chalk';
2
2
  import Enquirer from 'enquirer';
3
3
  const { prompt } = Enquirer;
4
4
  import ora from 'ora';
5
- import { spawn } from 'child_process';
5
+ import { spawn, execSync } from 'child_process';
6
6
  import { platform } from 'os';
7
+ import fs from 'fs-extra';
8
+ import readline from 'readline';
7
9
  import { createSkill } from '../lib/skills.js';
8
10
 
11
+ /**
12
+ * Read multiline input from stdin until two empty lines or Ctrl+D
13
+ * Handles large paste operations properly by buffering input
14
+ */
15
+ async function readMultilineInput() {
16
+ return new Promise((resolve) => {
17
+ const lines = [];
18
+ let emptyLineCount = 0;
19
+
20
+ const rl = readline.createInterface({
21
+ input: process.stdin,
22
+ output: process.stdout,
23
+ terminal: process.stdin.isTTY,
24
+ crlfDelay: Infinity
25
+ });
26
+
27
+ rl.on('line', (line) => {
28
+ if (line === '') {
29
+ emptyLineCount++;
30
+ if (emptyLineCount >= 2) {
31
+ rl.close();
32
+ return;
33
+ }
34
+ } else {
35
+ emptyLineCount = 0;
36
+ }
37
+ lines.push(line);
38
+ });
39
+
40
+ rl.on('close', () => {
41
+ // Remove trailing empty lines
42
+ while (lines.length > 0 && lines[lines.length - 1] === '') {
43
+ lines.pop();
44
+ }
45
+ const lineCount = lines.length;
46
+ if (lineCount > 0) {
47
+ console.log(chalk.green(`✓ Pasted ${lineCount} lines`));
48
+ }
49
+ resolve(lines.join('\n'));
50
+ });
51
+
52
+ // Handle SIGINT (Ctrl+C) gracefully
53
+ rl.on('SIGINT', () => {
54
+ rl.close();
55
+ });
56
+
57
+ // Handle errors
58
+ rl.on('error', (err) => {
59
+ console.error(chalk.red(`Input error: ${err.message}`));
60
+ rl.close();
61
+ });
62
+ });
63
+ }
64
+
9
65
  /**
10
66
  * Get the command to open a file in default editor
11
67
  */
12
68
  function getEditorCommand() {
13
- // Check for $EDITOR environment variable first
14
69
  if (process.env.EDITOR) {
15
70
  return process.env.EDITOR;
16
71
  }
17
-
18
- // Fall back to platform defaults
19
72
  switch (platform()) {
20
73
  case 'darwin':
21
- return 'open -t'; // Opens in default text editor on Mac
74
+ return 'open -t';
22
75
  case 'win32':
23
76
  return 'notepad';
24
77
  default:
@@ -34,11 +87,42 @@ function openInEditor(filePath) {
34
87
  const parts = editor.split(' ');
35
88
  const cmd = parts[0];
36
89
  const args = [...parts.slice(1), filePath];
90
+ return spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref();
91
+ }
92
+
93
+ /**
94
+ * Copy text to clipboard (cross-platform)
95
+ */
96
+ function copyToClipboard(text) {
97
+ try {
98
+ if (platform() === 'darwin') {
99
+ execSync('pbcopy', { input: text });
100
+ } else if (platform() === 'win32') {
101
+ execSync('clip', { input: text });
102
+ } else {
103
+ // Linux - try xclip or xsel
104
+ try {
105
+ execSync('xclip -selection clipboard', { input: text });
106
+ } catch {
107
+ execSync('xsel --clipboard --input', { input: text });
108
+ }
109
+ }
110
+ return true;
111
+ } catch (err) {
112
+ return false;
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Generate AI prompt for skill creation
118
+ */
119
+ function generateAIPrompt(skillName, description, details) {
120
+ return `Create a SKILL.md for: ${skillName}
121
+ ${description}
122
+ ${details}
37
123
 
38
- return spawn(cmd, args, {
39
- detached: true,
40
- stdio: 'ignore'
41
- }).unref();
124
+ Format: YAML frontmatter (name, description) + markdown instructions.
125
+ Keep it short and actionable (under 100 lines).`;
42
126
  }
43
127
 
44
128
  export async function createCommand(name, options) {
@@ -49,33 +133,19 @@ export async function createCommand(name, options) {
49
133
 
50
134
  // Interactive mode if name not provided
51
135
  if (!skillName) {
52
- const answers = await prompt([
53
- {
54
- type: 'input',
55
- name: 'name',
56
- message: 'Skill name (lowercase, hyphens allowed):',
57
- validate: (value) => {
58
- if (!value) return 'Name is required';
59
- if (!/^[a-z0-9-]+$/.test(value)) {
60
- return 'Name must be lowercase with hyphens only';
61
- }
62
- return true;
63
- }
64
- },
65
- {
66
- type: 'input',
67
- name: 'description',
68
- message: 'Short description for auto-discovery (max 200 chars):',
69
- validate: (value) => {
70
- if (!value) return 'Description is required';
71
- if (value.length > 200) return 'Description must be 200 characters or less';
72
- return true;
136
+ const { name } = await prompt({
137
+ type: 'input',
138
+ name: 'name',
139
+ message: 'Skill name (lowercase, hyphens allowed):',
140
+ validate: (value) => {
141
+ if (!value) return 'Name is required';
142
+ if (!/^[a-z0-9-]+$/.test(value)) {
143
+ return 'Name must be lowercase with hyphens only';
73
144
  }
145
+ return true;
74
146
  }
75
- ]);
76
-
77
- skillName = answers.name;
78
- description = answers.description;
147
+ });
148
+ skillName = name;
79
149
  }
80
150
 
81
151
  // Validate inputs
@@ -84,11 +154,85 @@ export async function createCommand(name, options) {
84
154
  process.exit(1);
85
155
  }
86
156
 
157
+ // Use provided description or generate from name
87
158
  if (!description) {
88
- console.error(chalk.red('Error: Description is required (use -d or --description)'));
89
- process.exit(1);
159
+ description = skillName.replace(/-/g, ' ');
90
160
  }
91
161
 
162
+ // Ask how they want to create instructions
163
+ const { method } = await prompt({
164
+ type: 'select',
165
+ name: 'method',
166
+ message: 'How do you want to write the skill instructions?',
167
+ choices: [
168
+ { name: 'ai', message: '🤖 Generate with AI (ChatGPT, Claude, Gemini, etc)' },
169
+ { name: 'manual', message: '✏️ Write manually (opens editor with template)' }
170
+ ]
171
+ });
172
+
173
+ if (method === 'ai') {
174
+ const { details } = await prompt({
175
+ type: 'input',
176
+ name: 'details',
177
+ message: 'What should this skill do?'
178
+ });
179
+
180
+ const aiPrompt = generateAIPrompt(skillName, description, details || '');
181
+
182
+ // Try to copy to clipboard
183
+ const copied = copyToClipboard(aiPrompt);
184
+
185
+ if (copied) {
186
+ console.log(chalk.green('\n✓ Prompt copied to clipboard!\n'));
187
+ } else {
188
+ console.log(chalk.yellow('\nPrompt:\n'));
189
+ console.log(chalk.dim('─'.repeat(40)));
190
+ console.log(aiPrompt);
191
+ console.log(chalk.dim('─'.repeat(40)));
192
+ }
193
+
194
+ console.log('Paste into any AI (ChatGPT, Claude, Gemini, etc), then paste the result here.\n');
195
+
196
+ const { hasContent } = await prompt({
197
+ type: 'confirm',
198
+ name: 'hasContent',
199
+ message: 'Ready to paste AI-generated content?',
200
+ initial: false
201
+ });
202
+
203
+ if (hasContent) {
204
+ console.log(chalk.dim('\nPaste content, then press Enter twice:\n'));
205
+
206
+ // Read multiline input using readline
207
+ const content = await readMultilineInput();
208
+
209
+ if (content && content.trim()) {
210
+ const spinner = ora('Creating skill...').start();
211
+ try {
212
+ const result = await createSkill(skillName, description, '');
213
+ // Write the pasted content directly
214
+ await fs.writeFile(result.skillMdPath, content.trim(), 'utf-8');
215
+ spinner.succeed(chalk.green(`Skill '${skillName}' created!`));
216
+ console.log(chalk.dim(`Location: ${result.path}`));
217
+ console.log(chalk.yellow('\nInstall with:'));
218
+ console.log(` ${chalk.cyan(`dotai skill install ${skillName}`)}\n`);
219
+ } catch (err) {
220
+ spinner.fail(chalk.red(`Failed: ${err.message}`));
221
+ }
222
+ } else {
223
+ console.log(chalk.yellow('\nNo content provided. Creating with template instead...'));
224
+ await createWithTemplate(skillName, description);
225
+ }
226
+ } else {
227
+ console.log(chalk.dim(`\nRun again when ready: ${chalk.cyan(`dotai skill create ${skillName}`)}\n`));
228
+ }
229
+ } else {
230
+ // Manual creation
231
+ await createWithTemplate(skillName, description);
232
+ }
233
+ }
234
+
235
+ async function createWithTemplate(skillName, description) {
92
236
  const spinner = ora('Creating skill...').start();
93
237
 
94
238
  try {
@@ -97,7 +241,6 @@ export async function createCommand(name, options) {
97
241
 
98
242
  console.log(chalk.dim(`\nLocation: ${result.path}`));
99
243
 
100
- // Ask if user wants to open in editor
101
244
  const { openEditor } = await prompt({
102
245
  type: 'confirm',
103
246
  name: 'openEditor',
@@ -0,0 +1,310 @@
1
+ import chalk from 'chalk';
2
+ import Enquirer from 'enquirer';
3
+ const { prompt } = Enquirer;
4
+ import ora from 'ora';
5
+ import {
6
+ addMcpServer,
7
+ removeMcpServer,
8
+ listMcpServers,
9
+ syncMcpToAllProviders,
10
+ getMcpInstallStatus,
11
+ getMcpConfigPath
12
+ } from '../lib/mcp.js';
13
+ import { mcpProviders, getMcpProviderIds } from '../providers/mcp.js';
14
+
15
+ /**
16
+ * Add an MCP server
17
+ */
18
+ export async function mcpAddCommand(options) {
19
+ console.log(chalk.bold('\n➕ Add MCP Server\n'));
20
+
21
+ const answers = await prompt([
22
+ {
23
+ type: 'input',
24
+ name: 'name',
25
+ message: 'Server name (e.g., github, filesystem):',
26
+ validate: (value) => {
27
+ if (!value) return 'Name is required';
28
+ if (!/^[a-z0-9-_]+$/i.test(value)) {
29
+ return 'Name must be alphanumeric with hyphens/underscores';
30
+ }
31
+ return true;
32
+ }
33
+ },
34
+ {
35
+ type: 'select',
36
+ name: 'type',
37
+ message: 'Server type:',
38
+ choices: [
39
+ { name: 'stdio', message: 'stdio - Local command (most common)' },
40
+ { name: 'sse', message: 'sse - Server-sent events (remote)' },
41
+ { name: 'http', message: 'http - HTTP endpoint (remote)' }
42
+ ]
43
+ }
44
+ ]);
45
+
46
+ let serverConfig = {};
47
+
48
+ if (answers.type === 'stdio') {
49
+ const stdioAnswers = await prompt([
50
+ {
51
+ type: 'input',
52
+ name: 'command',
53
+ message: 'Command (e.g., npx, node, python):',
54
+ validate: (value) => value ? true : 'Command is required'
55
+ },
56
+ {
57
+ type: 'input',
58
+ name: 'args',
59
+ message: 'Arguments (comma-separated, e.g., -y,@modelcontextprotocol/server-github):',
60
+ },
61
+ {
62
+ type: 'input',
63
+ name: 'env',
64
+ message: 'Environment variables (KEY=value,KEY2=value2):',
65
+ }
66
+ ]);
67
+
68
+ serverConfig = {
69
+ command: stdioAnswers.command,
70
+ args: stdioAnswers.args ? stdioAnswers.args.split(',').map(a => a.trim()) : []
71
+ };
72
+
73
+ if (stdioAnswers.env) {
74
+ serverConfig.env = {};
75
+ stdioAnswers.env.split(',').forEach(pair => {
76
+ const [key, ...valueParts] = pair.split('=');
77
+ if (key && valueParts.length > 0) {
78
+ serverConfig.env[key.trim()] = valueParts.join('=').trim();
79
+ }
80
+ });
81
+ }
82
+ } else {
83
+ // SSE or HTTP
84
+ const remoteAnswers = await prompt([
85
+ {
86
+ type: 'input',
87
+ name: 'url',
88
+ message: 'Server URL:',
89
+ validate: (value) => value ? true : 'URL is required'
90
+ },
91
+ {
92
+ type: 'input',
93
+ name: 'headers',
94
+ message: 'Headers (KEY=value,KEY2=value2):',
95
+ }
96
+ ]);
97
+
98
+ serverConfig = {
99
+ url: remoteAnswers.url
100
+ };
101
+
102
+ if (remoteAnswers.headers) {
103
+ serverConfig.headers = {};
104
+ remoteAnswers.headers.split(',').forEach(pair => {
105
+ const [key, ...valueParts] = pair.split('=');
106
+ if (key && valueParts.length > 0) {
107
+ serverConfig.headers[key.trim()] = valueParts.join('=').trim();
108
+ }
109
+ });
110
+ }
111
+ }
112
+
113
+ const spinner = ora('Adding MCP server...').start();
114
+
115
+ try {
116
+ await addMcpServer(answers.name, serverConfig);
117
+ spinner.succeed(chalk.green(`Added '${answers.name}'`));
118
+
119
+ console.log(chalk.dim(`\nStored in: ${getMcpConfigPath()}`));
120
+
121
+ const { syncNow } = await prompt({
122
+ type: 'confirm',
123
+ name: 'syncNow',
124
+ message: 'Sync to all providers now?',
125
+ initial: true
126
+ });
127
+
128
+ if (syncNow) {
129
+ await mcpSyncCommand({});
130
+ } else {
131
+ console.log(chalk.yellow(`\nRun ${chalk.cyan('dotai mcp sync')} to deploy to all apps\n`));
132
+ }
133
+
134
+ } catch (err) {
135
+ spinner.fail(chalk.red(`Failed: ${err.message}`));
136
+ }
137
+ }
138
+
139
+ /**
140
+ * List MCP servers
141
+ */
142
+ export async function mcpListCommand(options) {
143
+ console.log(chalk.bold('\n📋 MCP Servers\n'));
144
+
145
+ const servers = await listMcpServers();
146
+ const serverNames = Object.keys(servers);
147
+
148
+ if (serverNames.length === 0) {
149
+ console.log(chalk.yellow('No MCP servers configured.'));
150
+ console.log(`\nAdd one with: ${chalk.cyan('dotai mcp add')}\n`);
151
+ return;
152
+ }
153
+
154
+ for (const name of serverNames) {
155
+ const config = servers[name];
156
+ console.log(chalk.bold.white(name));
157
+
158
+ if (config.command) {
159
+ console.log(chalk.dim(` Command: ${config.command} ${(config.args || []).join(' ')}`));
160
+ }
161
+ if (config.url) {
162
+ console.log(chalk.dim(` URL: ${config.url}`));
163
+ }
164
+
165
+ if (options.verbose) {
166
+ const status = await getMcpInstallStatus(name);
167
+ const installed = Object.entries(status)
168
+ .filter(([_, s]) => s.installed)
169
+ .map(([_, s]) => s.name);
170
+
171
+ if (installed.length > 0) {
172
+ console.log(chalk.green(` Synced to: ${installed.join(', ')}`));
173
+ } else {
174
+ console.log(chalk.yellow(' Not synced yet'));
175
+ }
176
+ }
177
+
178
+ console.log('');
179
+ }
180
+
181
+ console.log(chalk.dim(`Total: ${serverNames.length} server(s)`));
182
+ console.log(chalk.dim(`Config: ${getMcpConfigPath()}\n`));
183
+
184
+ if (!options.verbose) {
185
+ console.log(chalk.dim(`Use ${chalk.cyan('dotai mcp list -v')} to see sync status\n`));
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Sync MCP servers to all providers
191
+ */
192
+ export async function mcpSyncCommand(options) {
193
+ console.log(chalk.bold('\n🔄 Sync MCP Servers\n'));
194
+
195
+ const servers = await listMcpServers();
196
+ const serverCount = Object.keys(servers).length;
197
+
198
+ if (serverCount === 0) {
199
+ console.log(chalk.yellow('No MCP servers to sync.'));
200
+ console.log(`\nAdd one with: ${chalk.cyan('dotai mcp add')}\n`);
201
+ return;
202
+ }
203
+
204
+ // Determine target providers
205
+ let targetProviders = options.providers
206
+ ? options.providers.split(',').map(p => p.trim())
207
+ : getMcpProviderIds();
208
+
209
+ console.log(chalk.dim(`Syncing ${serverCount} server(s) to ${targetProviders.length} app(s)...\n`));
210
+
211
+ const results = await syncMcpToAllProviders(targetProviders);
212
+
213
+ let successCount = 0;
214
+ let failCount = 0;
215
+
216
+ for (const result of results) {
217
+ const provider = mcpProviders[result.providerId];
218
+ if (result.success && result.synced > 0) {
219
+ successCount++;
220
+ console.log(chalk.green(` ✓ ${provider.name}`));
221
+ console.log(chalk.dim(` ${result.path}`));
222
+ } else if (result.success && result.synced === 0) {
223
+ console.log(chalk.dim(` - ${provider.name} (no servers)`));
224
+ } else {
225
+ failCount++;
226
+ console.log(chalk.red(` ✗ ${provider.name}: ${result.error}`));
227
+ }
228
+ }
229
+
230
+ console.log('');
231
+ if (successCount > 0) {
232
+ console.log(chalk.green(`Synced to ${successCount} app(s)`));
233
+ }
234
+ if (failCount > 0) {
235
+ console.log(chalk.red(`Failed for ${failCount} app(s)`));
236
+ }
237
+ console.log('');
238
+ }
239
+
240
+ /**
241
+ * Remove an MCP server
242
+ */
243
+ export async function mcpRemoveCommand(serverName, options) {
244
+ console.log(chalk.bold('\n🗑️ Remove MCP Server\n'));
245
+
246
+ // If no name provided, show picker
247
+ if (!serverName) {
248
+ const servers = await listMcpServers();
249
+ const serverNames = Object.keys(servers);
250
+
251
+ if (serverNames.length === 0) {
252
+ console.log(chalk.yellow('No MCP servers configured.'));
253
+ return;
254
+ }
255
+
256
+ const answer = await prompt({
257
+ type: 'select',
258
+ name: 'server',
259
+ message: 'Select server to remove:',
260
+ choices: serverNames.map(name => ({
261
+ name,
262
+ message: `${name} - ${servers[name].command || servers[name].url || 'configured'}`
263
+ }))
264
+ });
265
+ serverName = answer.server;
266
+ }
267
+
268
+ // Confirm
269
+ if (!options.yes) {
270
+ const { confirm } = await prompt({
271
+ type: 'confirm',
272
+ name: 'confirm',
273
+ message: `Remove '${serverName}' from central config?`,
274
+ initial: false
275
+ });
276
+
277
+ if (!confirm) {
278
+ console.log(chalk.yellow('Cancelled.'));
279
+ return;
280
+ }
281
+ }
282
+
283
+ const removed = await removeMcpServer(serverName);
284
+
285
+ if (removed) {
286
+ console.log(chalk.green(`\n✓ Removed '${serverName}' from central config`));
287
+ console.log(chalk.yellow('\nNote: This does not remove from already-synced providers.'));
288
+ console.log(chalk.dim('Run sync again to update providers, or manually remove from each app.\n'));
289
+ } else {
290
+ console.log(chalk.red(`Server '${serverName}' not found`));
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Show MCP providers
296
+ */
297
+ export async function mcpProvidersCommand() {
298
+ console.log(chalk.bold('\n🔌 MCP Providers\n'));
299
+
300
+ for (const provider of Object.values(mcpProviders)) {
301
+ console.log(chalk.bold.white(provider.name) + chalk.dim(` (${provider.id})`));
302
+ console.log(chalk.dim(` ${provider.description}`));
303
+ console.log(chalk.dim(` Config: ${provider.globalPath()}`));
304
+ console.log(chalk.dim(` Key: ${provider.configKey}`));
305
+ if (provider.note) {
306
+ console.log(chalk.yellow(` Note: ${provider.note}`));
307
+ }
308
+ console.log('');
309
+ }
310
+ }
package/src/index.js CHANGED
@@ -10,6 +10,7 @@ import { syncCommand } from './commands/sync.js';
10
10
  import { uninstallCommand } from './commands/uninstall.js';
11
11
  import { configCommand, enableCommand, disableCommand } from './commands/config.js';
12
12
  import { openCommand, openRepoCommand } from './commands/open.js';
13
+ import { mcpAddCommand, mcpListCommand, mcpSyncCommand, mcpRemoveCommand, mcpProvidersCommand } from './commands/mcp.js';
13
14
 
14
15
  const program = new Command();
15
16
 
@@ -19,7 +20,7 @@ await initConfig();
19
20
  program
20
21
  .name('dotai')
21
22
  .description('Dotfiles for AI - manage skills & MCP servers across all your AI coding assistants')
22
- .version('1.0.0');
23
+ .version('1.0.1');
23
24
 
24
25
  // Skill subcommand
25
26
  const skill = program.command('skill').description('Manage AI agent skills');
@@ -69,30 +70,42 @@ skill
69
70
  .option('-f, --file', 'Open SKILL.md file directly')
70
71
  .action(openCommand);
71
72
 
72
- // MCP subcommand (placeholder for now)
73
- const mcp = program.command('mcp').description('Manage MCP servers (coming soon)');
73
+ // MCP subcommand
74
+ const mcp = program.command('mcp').description('Manage MCP servers across all apps');
74
75
 
75
76
  mcp
76
77
  .command('add')
77
- .description('Add an MCP server (coming soon)')
78
- .action(() => {
79
- console.log(chalk.yellow('\nMCP management coming soon!\n'));
80
- console.log(chalk.dim('This will let you configure MCP servers once and sync to:'));
81
- console.log(chalk.dim(' • Claude Code • Claude Desktop • Cursor'));
82
- console.log(chalk.dim(' • VS Code • Cline • Windsurf\n'));
83
- });
78
+ .description('Add an MCP server to your config')
79
+ .action(mcpAddCommand);
84
80
 
85
81
  mcp
86
82
  .command('list')
87
- .description('List MCP servers (coming soon)')
88
- .action(() => {
89
- console.log(chalk.yellow('\nMCP management coming soon!\n'));
90
- });
83
+ .alias('ls')
84
+ .description('List configured MCP servers')
85
+ .option('-v, --verbose', 'Show sync status')
86
+ .action(mcpListCommand);
87
+
88
+ mcp
89
+ .command('sync')
90
+ .description('Sync MCP servers to all apps')
91
+ .option('-p, --providers <list>', 'Comma-separated list of providers')
92
+ .action(mcpSyncCommand);
93
+
94
+ mcp
95
+ .command('remove [server]')
96
+ .description('Remove an MCP server')
97
+ .option('-y, --yes', 'Skip confirmation')
98
+ .action(mcpRemoveCommand);
99
+
100
+ mcp
101
+ .command('providers')
102
+ .description('List supported MCP apps')
103
+ .action(mcpProvidersCommand);
91
104
 
92
105
  // Config commands
93
106
  program
94
107
  .command('providers')
95
- .description('List all supported providers')
108
+ .description('List all supported skill providers')
96
109
  .action(listProvidersCommand);
97
110
 
98
111
  program
@@ -122,7 +135,7 @@ program.parse();
122
135
  if (!process.argv.slice(2).length) {
123
136
  console.log(chalk.bold(`
124
137
  ╔═══════════════════════════════════════════════════════════╗
125
- ║ dotai v1.0.0
138
+ ║ dotai v1.0.1
126
139
  ║ Dotfiles for AI - Skills & MCP in one place ║
127
140
  ╚═══════════════════════════════════════════════════════════╝
128
141
  `));
@@ -133,9 +146,10 @@ if (!process.argv.slice(2).length) {
133
146
  console.log(` ${chalk.cyan('dotai skill list')} List your skills`);
134
147
  console.log(` ${chalk.cyan('dotai skill sync')} Sync all skills\n`);
135
148
 
136
- console.log(chalk.bold(' MCP Servers - coming soon:'));
137
- console.log(` ${chalk.dim('dotai mcp add')} Add an MCP server`);
138
- console.log(` ${chalk.dim('dotai mcp sync')} Sync to all apps\n`);
149
+ console.log(chalk.bold(' MCP Servers - sync across Claude, Cursor, VS Code & more:'));
150
+ console.log(` ${chalk.cyan('dotai mcp add')} Add an MCP server`);
151
+ console.log(` ${chalk.cyan('dotai mcp list')} List your servers`);
152
+ console.log(` ${chalk.cyan('dotai mcp sync')} Sync to all apps\n`);
139
153
 
140
154
  console.log(chalk.dim(' Run "dotai --help" for all commands\n'));
141
155
  }
package/src/lib/mcp.js ADDED
@@ -0,0 +1,201 @@
1
+ import fs from 'fs-extra';
2
+ import { join } from 'path';
3
+ import { getConfigDir } from './paths.js';
4
+ import { mcpProviders, getMcpProviderIds } from '../providers/mcp.js';
5
+
6
+ /**
7
+ * Get the central MCP config file path
8
+ */
9
+ export function getMcpConfigPath() {
10
+ return join(getConfigDir(), 'mcp_servers.json');
11
+ }
12
+
13
+ /**
14
+ * Load central MCP config
15
+ */
16
+ export async function loadMcpConfig() {
17
+ const configPath = getMcpConfigPath();
18
+
19
+ if (!await fs.pathExists(configPath)) {
20
+ return { mcpServers: {} };
21
+ }
22
+
23
+ try {
24
+ return await fs.readJson(configPath);
25
+ } catch (err) {
26
+ return { mcpServers: {} };
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Save central MCP config
32
+ */
33
+ export async function saveMcpConfig(config) {
34
+ const configPath = getMcpConfigPath();
35
+ await fs.ensureDir(getConfigDir());
36
+ await fs.writeJson(configPath, config, { spaces: 2 });
37
+ }
38
+
39
+ /**
40
+ * Add an MCP server to central config
41
+ */
42
+ export async function addMcpServer(name, serverConfig) {
43
+ const config = await loadMcpConfig();
44
+ config.mcpServers[name] = serverConfig;
45
+ await saveMcpConfig(config);
46
+ return config;
47
+ }
48
+
49
+ /**
50
+ * Remove an MCP server from central config
51
+ */
52
+ export async function removeMcpServer(name) {
53
+ const config = await loadMcpConfig();
54
+ if (config.mcpServers[name]) {
55
+ delete config.mcpServers[name];
56
+ await saveMcpConfig(config);
57
+ return true;
58
+ }
59
+ return false;
60
+ }
61
+
62
+ /**
63
+ * List all MCP servers in central config
64
+ */
65
+ export async function listMcpServers() {
66
+ const config = await loadMcpConfig();
67
+ return config.mcpServers || {};
68
+ }
69
+
70
+ /**
71
+ * Read a provider's MCP config file
72
+ */
73
+ export async function readProviderMcpConfig(providerId) {
74
+ const provider = mcpProviders[providerId];
75
+ if (!provider) return null;
76
+
77
+ const configPath = provider.globalPath();
78
+
79
+ if (!await fs.pathExists(configPath)) {
80
+ return { [provider.configKey]: {} };
81
+ }
82
+
83
+ try {
84
+ const content = await fs.readJson(configPath);
85
+ return content;
86
+ } catch (err) {
87
+ return { [provider.configKey]: {} };
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Write to a provider's MCP config file
93
+ */
94
+ export async function writeProviderMcpConfig(providerId, servers) {
95
+ const provider = mcpProviders[providerId];
96
+ if (!provider) {
97
+ throw new Error(`Unknown provider: ${providerId}`);
98
+ }
99
+
100
+ const configPath = provider.globalPath();
101
+ const configKey = provider.configKey;
102
+
103
+ // Ensure directory exists
104
+ await fs.ensureDir(join(configPath, '..'));
105
+
106
+ // Read existing config to preserve other settings
107
+ let existingConfig = {};
108
+ if (await fs.pathExists(configPath)) {
109
+ try {
110
+ existingConfig = await fs.readJson(configPath);
111
+ } catch (err) {
112
+ existingConfig = {};
113
+ }
114
+ }
115
+
116
+ // Merge MCP servers
117
+ existingConfig[configKey] = {
118
+ ...(existingConfig[configKey] || {}),
119
+ ...servers
120
+ };
121
+
122
+ await fs.writeJson(configPath, existingConfig, { spaces: 2 });
123
+ return configPath;
124
+ }
125
+
126
+ /**
127
+ * Sync all MCP servers to a specific provider
128
+ */
129
+ export async function syncMcpToProvider(providerId) {
130
+ const provider = mcpProviders[providerId];
131
+ if (!provider) {
132
+ throw new Error(`Unknown provider: ${providerId}`);
133
+ }
134
+
135
+ const servers = await listMcpServers();
136
+
137
+ if (Object.keys(servers).length === 0) {
138
+ return { synced: 0, path: null };
139
+ }
140
+
141
+ const configPath = await writeProviderMcpConfig(providerId, servers);
142
+
143
+ return {
144
+ synced: Object.keys(servers).length,
145
+ path: configPath
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Sync all MCP servers to all enabled providers
151
+ */
152
+ export async function syncMcpToAllProviders(providerIds = null) {
153
+ const targetProviders = providerIds || getMcpProviderIds();
154
+ const results = [];
155
+
156
+ for (const providerId of targetProviders) {
157
+ try {
158
+ const result = await syncMcpToProvider(providerId);
159
+ results.push({
160
+ providerId,
161
+ success: true,
162
+ ...result
163
+ });
164
+ } catch (err) {
165
+ results.push({
166
+ providerId,
167
+ success: false,
168
+ error: err.message
169
+ });
170
+ }
171
+ }
172
+
173
+ return results;
174
+ }
175
+
176
+ /**
177
+ * Get MCP server installation status across providers
178
+ */
179
+ export async function getMcpInstallStatus(serverName) {
180
+ const status = {};
181
+
182
+ for (const [providerId, provider] of Object.entries(mcpProviders)) {
183
+ try {
184
+ const config = await readProviderMcpConfig(providerId);
185
+ const servers = config[provider.configKey] || {};
186
+ status[providerId] = {
187
+ name: provider.name,
188
+ installed: !!servers[serverName],
189
+ path: provider.globalPath()
190
+ };
191
+ } catch (err) {
192
+ status[providerId] = {
193
+ name: provider.name,
194
+ installed: false,
195
+ error: err.message
196
+ };
197
+ }
198
+ }
199
+
200
+ return status;
201
+ }