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 +1 -1
- package/src/commands/create.js +205 -37
- package/src/commands/mcp.js +310 -0
- package/src/index.js +33 -19
- package/src/lib/mcp.js +201 -0
package/package.json
CHANGED
package/src/commands/create.js
CHANGED
|
@@ -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';
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
73
|
-
const mcp = program.command('mcp').description('Manage MCP servers
|
|
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
|
|
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
|
-
.
|
|
88
|
-
.
|
|
89
|
-
|
|
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.
|
|
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 -
|
|
137
|
-
console.log(` ${chalk.
|
|
138
|
-
console.log(` ${chalk.
|
|
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
|
+
}
|