dotai-cli 1.0.0 → 1.0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotai-cli",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
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,64 @@ 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
+ */
14
+ async function readMultilineInput() {
15
+ return new Promise((resolve) => {
16
+ const lines = [];
17
+ let emptyLineCount = 0;
18
+
19
+ // Disable echo by using terminal: false
20
+ const rl = readline.createInterface({
21
+ input: process.stdin,
22
+ output: process.stdout,
23
+ terminal: false
24
+ });
25
+
26
+ rl.on('line', (line) => {
27
+ if (line === '') {
28
+ emptyLineCount++;
29
+ if (emptyLineCount >= 2) {
30
+ rl.close();
31
+ return;
32
+ }
33
+ } else {
34
+ emptyLineCount = 0;
35
+ }
36
+ lines.push(line);
37
+ });
38
+
39
+ rl.on('close', () => {
40
+ // Remove trailing empty lines
41
+ while (lines.length > 0 && lines[lines.length - 1] === '') {
42
+ lines.pop();
43
+ }
44
+ const lineCount = lines.length;
45
+ if (lineCount > 0) {
46
+ console.log(chalk.green(`✓ Pasted ${lineCount} lines`));
47
+ }
48
+ resolve(lines.join('\n'));
49
+ });
50
+ });
51
+ }
52
+
9
53
  /**
10
54
  * Get the command to open a file in default editor
11
55
  */
12
56
  function getEditorCommand() {
13
- // Check for $EDITOR environment variable first
14
57
  if (process.env.EDITOR) {
15
58
  return process.env.EDITOR;
16
59
  }
17
-
18
- // Fall back to platform defaults
19
60
  switch (platform()) {
20
61
  case 'darwin':
21
- return 'open -t'; // Opens in default text editor on Mac
62
+ return 'open -t';
22
63
  case 'win32':
23
64
  return 'notepad';
24
65
  default:
@@ -34,11 +75,70 @@ function openInEditor(filePath) {
34
75
  const parts = editor.split(' ');
35
76
  const cmd = parts[0];
36
77
  const args = [...parts.slice(1), filePath];
78
+ return spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref();
79
+ }
80
+
81
+ /**
82
+ * Copy text to clipboard (cross-platform)
83
+ */
84
+ function copyToClipboard(text) {
85
+ try {
86
+ if (platform() === 'darwin') {
87
+ execSync('pbcopy', { input: text });
88
+ } else if (platform() === 'win32') {
89
+ execSync('clip', { input: text });
90
+ } else {
91
+ // Linux - try xclip or xsel
92
+ try {
93
+ execSync('xclip -selection clipboard', { input: text });
94
+ } catch {
95
+ execSync('xsel --clipboard --input', { input: text });
96
+ }
97
+ }
98
+ return true;
99
+ } catch (err) {
100
+ return false;
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Generate AI prompt for skill creation
106
+ */
107
+ function generateAIPrompt(skillName, description, detailedDescription) {
108
+ return `Create a SKILL.md file for an AI coding assistant skill with the following specifications:
109
+
110
+ **Skill Name:** ${skillName}
111
+ **Short Description:** ${description}
112
+
113
+ **What this skill should do:**
114
+ ${detailedDescription}
37
115
 
38
- return spawn(cmd, args, {
39
- detached: true,
40
- stdio: 'ignore'
41
- }).unref();
116
+ ---
117
+
118
+ Please generate a complete SKILL.md file with:
119
+
120
+ 1. **YAML Frontmatter** at the top:
121
+ \`\`\`yaml
122
+ ---
123
+ name: ${skillName}
124
+ description: ${description}
125
+ ---
126
+ \`\`\`
127
+
128
+ 2. **Detailed Instructions** including:
129
+ - When to use this skill (specific scenarios/triggers)
130
+ - Step-by-step guidance for the AI to follow
131
+ - Best practices and conventions
132
+ - Common pitfalls to avoid
133
+ - Example inputs and expected outputs
134
+
135
+ 3. **Format Requirements:**
136
+ - Use clear, actionable language
137
+ - Include code examples where relevant
138
+ - Keep instructions concise but comprehensive
139
+ - Use markdown formatting (headers, lists, code blocks)
140
+
141
+ Generate the complete SKILL.md content now:`;
42
142
  }
43
143
 
44
144
  export async function createCommand(name, options) {
@@ -49,33 +149,19 @@ export async function createCommand(name, options) {
49
149
 
50
150
  // Interactive mode if name not provided
51
151
  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;
152
+ const { name } = await prompt({
153
+ type: 'input',
154
+ name: 'name',
155
+ message: 'Skill name (lowercase, hyphens allowed):',
156
+ validate: (value) => {
157
+ if (!value) return 'Name is required';
158
+ if (!/^[a-z0-9-]+$/.test(value)) {
159
+ return 'Name must be lowercase with hyphens only';
73
160
  }
161
+ return true;
74
162
  }
75
- ]);
76
-
77
- skillName = answers.name;
78
- description = answers.description;
163
+ });
164
+ skillName = name;
79
165
  }
80
166
 
81
167
  // Validate inputs
@@ -84,11 +170,94 @@ export async function createCommand(name, options) {
84
170
  process.exit(1);
85
171
  }
86
172
 
173
+ // Use provided description or generate from name
87
174
  if (!description) {
88
- console.error(chalk.red('Error: Description is required (use -d or --description)'));
89
- process.exit(1);
175
+ description = skillName.replace(/-/g, ' ');
90
176
  }
91
177
 
178
+ // Ask how they want to create instructions
179
+ const { method } = await prompt({
180
+ type: 'select',
181
+ name: 'method',
182
+ message: 'How do you want to write the skill instructions?',
183
+ choices: [
184
+ { name: 'ai', message: '🤖 Generate with AI (creates prompt for ChatGPT/Claude)' },
185
+ { name: 'manual', message: '✏️ Write manually (opens editor with template)' }
186
+ ]
187
+ });
188
+
189
+ if (method === 'ai') {
190
+ // AI-assisted creation - use the short description as context, ask for more detail
191
+ const { detailedDescription } = await prompt({
192
+ type: 'input',
193
+ name: 'detailedDescription',
194
+ message: 'What should the AI do with this skill? (be specific):',
195
+ validate: (value) => value.length > 10 ? true : 'Please provide more detail (at least 10 characters)'
196
+ });
197
+
198
+ const aiPrompt = generateAIPrompt(skillName, description, detailedDescription);
199
+
200
+ console.log(chalk.bold('\n📋 AI Prompt Generated!\n'));
201
+
202
+ // Try to copy to clipboard
203
+ const copied = copyToClipboard(aiPrompt);
204
+
205
+ if (copied) {
206
+ console.log(chalk.green('✓ Prompt copied to clipboard!\n'));
207
+ } else {
208
+ console.log(chalk.yellow('Could not copy to clipboard. Here\'s the prompt:\n'));
209
+ console.log(chalk.dim('─'.repeat(60)));
210
+ console.log(aiPrompt);
211
+ console.log(chalk.dim('─'.repeat(60)));
212
+ }
213
+
214
+ console.log(chalk.bold('Next steps:'));
215
+ console.log(' 1. Paste this prompt into ChatGPT, Claude, or any LLM');
216
+ console.log(' 2. Copy the generated SKILL.md content');
217
+ console.log(' 3. Run this command again and paste the result\n');
218
+
219
+ const { hasContent } = await prompt({
220
+ type: 'confirm',
221
+ name: 'hasContent',
222
+ message: 'Do you have the AI-generated content ready to paste?',
223
+ initial: false
224
+ });
225
+
226
+ if (hasContent) {
227
+ console.log(chalk.dim('\nPaste the SKILL.md content below, then press Enter twice when done:\n'));
228
+
229
+ // Read multiline input using readline
230
+ const content = await readMultilineInput();
231
+
232
+ if (content && content.trim()) {
233
+ const spinner = ora('Creating skill with AI-generated content...').start();
234
+ try {
235
+ const result = await createSkill(skillName, description, '');
236
+ // Write the pasted content directly
237
+ await fs.writeFile(result.skillMdPath, content.trim(), 'utf-8');
238
+ spinner.succeed(chalk.green(`Skill '${skillName}' created with AI content!`));
239
+ console.log(chalk.dim(`\nLocation: ${result.path}`));
240
+ console.log(chalk.yellow('\nInstall with:'));
241
+ console.log(` ${chalk.cyan(`dotai skill install ${skillName}`)}\n`);
242
+ } catch (err) {
243
+ spinner.fail(chalk.red(`Failed: ${err.message}`));
244
+ }
245
+ } else {
246
+ console.log(chalk.yellow('\nNo content provided. Creating with template instead...'));
247
+ await createWithTemplate(skillName, description);
248
+ }
249
+ } else {
250
+ console.log(chalk.dim('\nNo problem! When you have the content ready:'));
251
+ console.log(` 1. Create the skill: ${chalk.cyan(`dotai skill create ${skillName} -d "${description}"`)}`);
252
+ console.log(` 2. Or manually create: ${chalk.cyan(`~/.dotai/skills/${skillName}/SKILL.md`)}\n`);
253
+ }
254
+ } else {
255
+ // Manual creation
256
+ await createWithTemplate(skillName, description);
257
+ }
258
+ }
259
+
260
+ async function createWithTemplate(skillName, description) {
92
261
  const spinner = ora('Creating skill...').start();
93
262
 
94
263
  try {
@@ -97,7 +266,6 @@ export async function createCommand(name, options) {
97
266
 
98
267
  console.log(chalk.dim(`\nLocation: ${result.path}`));
99
268
 
100
- // Ask if user wants to open in editor
101
269
  const { openEditor } = await prompt({
102
270
  type: 'confirm',
103
271
  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
+ }