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 +1 -1
- package/src/commands/create.js +180 -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,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';
|
|
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
|
-
|
|
39
|
-
|
|
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
|
|
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;
|
|
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
|
-
|
|
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.
|
|
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
|
+
}
|