@peppermint-mcp/wizard 0.1.0

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.
Files changed (3) hide show
  1. package/README.md +81 -0
  2. package/dist/cli.js +893 -0
  3. package/package.json +45 -0
package/README.md ADDED
@@ -0,0 +1,81 @@
1
+ # @peppermint/mcp-wizard
2
+
3
+ One-command installer for [Peppermint](https://peppermint.com) MCP across AI coding hosts.
4
+
5
+ ```bash
6
+ npx @peppermint/mcp-wizard
7
+ ```
8
+
9
+ Detects your installed AI tools, authenticates with Peppermint, and writes the correct MCP config for each host. No manual JSON editing.
10
+
11
+ ## Supported Hosts
12
+
13
+ | Host | Install method | Auth handled by |
14
+ |---|---|---|
15
+ | Claude Code | `claude mcp add` (CLI) | Claude Code (built-in MCP OAuth) |
16
+ | Claude Desktop | Config file + `mcp-remote` shim | Wizard (browser OAuth → API key) |
17
+ | Cursor | Config file (native HTTP) | Wizard (browser OAuth → API key) |
18
+ | Codex CLI | `codex mcp add` (CLI) | Codex (built-in MCP OAuth) |
19
+
20
+ ## Usage
21
+
22
+ ### Install Peppermint MCP
23
+
24
+ ```bash
25
+ npx @peppermint/mcp-wizard
26
+ ```
27
+
28
+ The wizard will:
29
+ 1. Detect which AI hosts are installed on your machine
30
+ 2. Let you choose which ones to configure
31
+ 3. Open your browser to sign in (only for hosts that need it)
32
+ 4. Write the MCP config for each selected host
33
+ 5. Verify the installation
34
+
35
+ ### Other commands
36
+
37
+ ```bash
38
+ # List detected hosts and their status
39
+ npx @peppermint/mcp-wizard list
40
+
41
+ # Health check on existing installation
42
+ npx @peppermint/mcp-wizard doctor
43
+
44
+ # Remove Peppermint from selected hosts
45
+ npx @peppermint/mcp-wizard remove
46
+ ```
47
+
48
+ ### Flags
49
+
50
+ | Flag | Description |
51
+ |---|---|
52
+ | `--server <url>` | MCP server URL (default: `https://api.peppermint.com/mcp/`) |
53
+ | `--dry-run` | Print what would change without writing anything |
54
+ | `--no-verify` | Skip post-install health check |
55
+
56
+ ### Using staging
57
+
58
+ ```bash
59
+ npx @peppermint/mcp-wizard --server https://dev-api.peppermint.com/mcp/
60
+ ```
61
+
62
+ ## How it works
63
+
64
+ **CLI hosts (Claude Code, Codex):** The wizard runs the host's built-in `mcp add` command. Auth is handled by the host itself through the MCP protocol's browser OAuth flow — no token management needed.
65
+
66
+ **File-based hosts (Claude Desktop, Cursor):** The wizard performs a localhost OAuth flow:
67
+ 1. Opens your browser to Peppermint's sign-in page
68
+ 2. Catches the auth callback on a temporary localhost server
69
+ 3. Creates a long-lived API key (`pep_...`) that doesn't expire
70
+ 4. Writes the API key into the host's config JSON
71
+
72
+ Credentials are stored in `~/.config/peppermint/credentials.json`. On subsequent runs, the wizard reuses the existing API key without re-authenticating.
73
+
74
+ ## Development
75
+
76
+ ```bash
77
+ npm install
78
+ npm run build # Build with tsup
79
+ npm run dev # Watch mode
80
+ npm test # Run tests
81
+ ```
package/dist/cli.js ADDED
@@ -0,0 +1,893 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { Command } from "commander";
5
+ import * as p from "@clack/prompts";
6
+ import pc from "picocolors";
7
+
8
+ // src/detection/claude-code.ts
9
+ import { execFile } from "child_process";
10
+ import { promisify } from "util";
11
+ var exec = promisify(execFile);
12
+ async function detectClaudeCode() {
13
+ const warnings = [];
14
+ let version;
15
+ try {
16
+ const { stdout } = await exec("claude", ["--version"], { timeout: 5e3 });
17
+ version = stdout.trim().split("\n")[0];
18
+ } catch {
19
+ return null;
20
+ }
21
+ let alreadyInstalled = false;
22
+ try {
23
+ const { stdout } = await exec("claude", ["mcp", "list"], {
24
+ timeout: 1e4
25
+ });
26
+ alreadyInstalled = stdout.includes("peppermint-memory") || stdout.includes("peppermint");
27
+ } catch {
28
+ warnings.push("Could not check existing MCP config");
29
+ }
30
+ return {
31
+ id: "claude-code",
32
+ name: "Claude Code",
33
+ version,
34
+ installMethod: "cli",
35
+ alreadyInstalled,
36
+ needsRestart: false,
37
+ warnings
38
+ };
39
+ }
40
+
41
+ // src/detection/claude-desktop.ts
42
+ import { existsSync, readFileSync } from "fs";
43
+ import { homedir } from "os";
44
+ import { join } from "path";
45
+ import * as jsonc from "jsonc-parser";
46
+ function getConfigPath() {
47
+ return join(
48
+ homedir(),
49
+ "Library",
50
+ "Application Support",
51
+ "Claude",
52
+ "claude_desktop_config.json"
53
+ );
54
+ }
55
+ function getConfigDir() {
56
+ return join(homedir(), "Library", "Application Support", "Claude");
57
+ }
58
+ async function detectClaudeDesktop() {
59
+ const configDir = getConfigDir();
60
+ const configPath = getConfigPath();
61
+ const appExists = existsSync("/Applications/Claude.app");
62
+ const configDirExists = existsSync(configDir);
63
+ if (!appExists && !configDirExists) {
64
+ return null;
65
+ }
66
+ const warnings = [];
67
+ let alreadyInstalled = false;
68
+ if (existsSync(configPath)) {
69
+ try {
70
+ const content = readFileSync(configPath, "utf-8");
71
+ const parsed = jsonc.parse(content);
72
+ alreadyInstalled = !!parsed?.mcpServers?.peppermint;
73
+ } catch {
74
+ warnings.push("Config file exists but could not be parsed");
75
+ }
76
+ }
77
+ return {
78
+ id: "claude-desktop",
79
+ name: "Claude Desktop",
80
+ installMethod: "file-stdio-shim",
81
+ configPath,
82
+ alreadyInstalled,
83
+ needsRestart: true,
84
+ warnings
85
+ };
86
+ }
87
+
88
+ // src/detection/cursor.ts
89
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
90
+ import { homedir as homedir2 } from "os";
91
+ import { join as join2 } from "path";
92
+ import * as jsonc2 from "jsonc-parser";
93
+ function getConfigPath2() {
94
+ return join2(homedir2(), ".cursor", "mcp.json");
95
+ }
96
+ async function detectCursor() {
97
+ const configPath = getConfigPath2();
98
+ const appExists = existsSync2("/Applications/Cursor.app");
99
+ const configExists = existsSync2(configPath);
100
+ if (!appExists && !configExists) {
101
+ return null;
102
+ }
103
+ const warnings = [];
104
+ let alreadyInstalled = false;
105
+ if (configExists) {
106
+ try {
107
+ const content = readFileSync2(configPath, "utf-8");
108
+ const parsed = jsonc2.parse(content);
109
+ alreadyInstalled = !!parsed?.mcpServers?.peppermint;
110
+ } catch {
111
+ warnings.push("Config file exists but could not be parsed");
112
+ }
113
+ }
114
+ return {
115
+ id: "cursor",
116
+ name: "Cursor",
117
+ installMethod: "file-native-http",
118
+ configPath,
119
+ alreadyInstalled,
120
+ needsRestart: true,
121
+ warnings
122
+ };
123
+ }
124
+
125
+ // src/detection/codex.ts
126
+ import { execFile as execFile2 } from "child_process";
127
+ import { promisify as promisify2 } from "util";
128
+ var exec2 = promisify2(execFile2);
129
+ async function detectCodex() {
130
+ let version;
131
+ try {
132
+ const { stdout } = await exec2("codex", ["--version"], { timeout: 5e3 });
133
+ version = stdout.trim().split("\n")[0];
134
+ } catch {
135
+ return null;
136
+ }
137
+ return {
138
+ id: "codex",
139
+ name: "Codex CLI",
140
+ version,
141
+ installMethod: "cli",
142
+ alreadyInstalled: false,
143
+ needsRestart: false,
144
+ warnings: []
145
+ };
146
+ }
147
+
148
+ // src/detection/index.ts
149
+ async function detectHosts() {
150
+ const results = await Promise.allSettled([
151
+ detectClaudeCode(),
152
+ detectClaudeDesktop(),
153
+ detectCursor(),
154
+ detectCodex()
155
+ ]);
156
+ return results.filter(
157
+ (r) => r.status === "fulfilled" && r.value !== null
158
+ ).map((r) => r.value);
159
+ }
160
+
161
+ // src/auth/token-store.ts
162
+ import { existsSync as existsSync3, mkdirSync, readFileSync as readFileSync3, writeFileSync } from "fs";
163
+ import { homedir as homedir3 } from "os";
164
+ import { dirname, join as join3 } from "path";
165
+ function getCredentialsPath() {
166
+ return join3(homedir3(), ".config", "peppermint", "credentials.json");
167
+ }
168
+ function loadCredentials(server) {
169
+ const path = getCredentialsPath();
170
+ if (!existsSync3(path)) return null;
171
+ try {
172
+ const content = readFileSync3(path, "utf-8");
173
+ const creds = JSON.parse(content);
174
+ if (creds.server !== server) return null;
175
+ if (!creds.api_key || !creds.api_key.startsWith("pep_")) return null;
176
+ return creds;
177
+ } catch {
178
+ return null;
179
+ }
180
+ }
181
+ function saveCredentials(creds) {
182
+ const path = getCredentialsPath();
183
+ const dir = dirname(path);
184
+ if (!existsSync3(dir)) {
185
+ mkdirSync(dir, { recursive: true, mode: 448 });
186
+ }
187
+ writeFileSync(path, JSON.stringify(creds, null, 2), {
188
+ encoding: "utf-8",
189
+ mode: 384
190
+ });
191
+ }
192
+
193
+ // src/auth/localhost-oauth.ts
194
+ import { createServer } from "http";
195
+ import { randomBytes, createHash } from "crypto";
196
+ import { URL, URLSearchParams } from "url";
197
+ import open from "open";
198
+ var AUTH_TIMEOUT_MS = 5 * 60 * 1e3;
199
+ function generatePKCE() {
200
+ const verifier = randomBytes(32).toString("base64url");
201
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
202
+ return { verifier, challenge };
203
+ }
204
+ async function registerClient(serverBase2, redirectUri) {
205
+ const res = await fetch(`${serverBase2}/oauth/register`, {
206
+ method: "POST",
207
+ headers: { "Content-Type": "application/json" },
208
+ body: JSON.stringify({
209
+ client_name: "Peppermint MCP Wizard",
210
+ redirect_uris: [redirectUri],
211
+ grant_types: ["authorization_code"],
212
+ response_types: ["code"],
213
+ token_endpoint_auth_method: "none"
214
+ })
215
+ });
216
+ if (!res.ok) {
217
+ const body = await res.text();
218
+ throw new Error(`Client registration failed (${res.status}): ${body}`);
219
+ }
220
+ const data = await res.json();
221
+ return data.client_id;
222
+ }
223
+ async function exchangeCodeForTokens(serverBase2, code, redirectUri, clientId, codeVerifier) {
224
+ const body = new URLSearchParams({
225
+ grant_type: "authorization_code",
226
+ code,
227
+ redirect_uri: redirectUri,
228
+ client_id: clientId,
229
+ code_verifier: codeVerifier
230
+ });
231
+ const res = await fetch(`${serverBase2}/oauth/token`, {
232
+ method: "POST",
233
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
234
+ body: body.toString()
235
+ });
236
+ if (!res.ok) {
237
+ const text = await res.text();
238
+ throw new Error(`Token exchange failed (${res.status}): ${text}`);
239
+ }
240
+ return res.json();
241
+ }
242
+ async function createApiKey(serverBase2, accessToken) {
243
+ const res = await fetch(`${serverBase2}/auth/api-keys`, {
244
+ method: "POST",
245
+ headers: {
246
+ "Content-Type": "application/json",
247
+ Authorization: `Bearer ${accessToken}`
248
+ },
249
+ body: JSON.stringify({
250
+ name: "mcp-wizard",
251
+ scopes: ["read:own", "write:own", "read:team"]
252
+ })
253
+ });
254
+ if (!res.ok) {
255
+ const text = await res.text();
256
+ throw new Error(`API key creation failed (${res.status}): ${text}`);
257
+ }
258
+ return res.json();
259
+ }
260
+ function waitForCallback(port, expectedState) {
261
+ return new Promise((resolve, reject) => {
262
+ const timeout = setTimeout(() => {
263
+ server.close();
264
+ reject(new Error("Authentication timed out (5 minutes). Please try again."));
265
+ }, AUTH_TIMEOUT_MS);
266
+ const server = createServer((req, res) => {
267
+ const url = new URL(req.url, `http://127.0.0.1:${port}`);
268
+ if (url.pathname !== "/callback") {
269
+ res.writeHead(404);
270
+ res.end("Not found");
271
+ return;
272
+ }
273
+ const error = url.searchParams.get("error");
274
+ if (error) {
275
+ res.writeHead(200, { "Content-Type": "text/html" });
276
+ res.end("<html><body><h2>Authorization denied.</h2><p>You can close this tab.</p></body></html>");
277
+ clearTimeout(timeout);
278
+ server.close();
279
+ reject(new Error(`Authorization denied: ${error}`));
280
+ return;
281
+ }
282
+ const code = url.searchParams.get("code");
283
+ const state = url.searchParams.get("state");
284
+ if (!code) {
285
+ res.writeHead(400);
286
+ res.end("Missing authorization code");
287
+ return;
288
+ }
289
+ if (state !== expectedState) {
290
+ res.writeHead(400);
291
+ res.end("State mismatch");
292
+ clearTimeout(timeout);
293
+ server.close();
294
+ reject(new Error("OAuth state mismatch \u2014 possible CSRF"));
295
+ return;
296
+ }
297
+ res.writeHead(200, { "Content-Type": "text/html" });
298
+ res.end(
299
+ "<html><body><h2>Authenticated!</h2><p>You can close this tab and return to the terminal.</p></body></html>"
300
+ );
301
+ clearTimeout(timeout);
302
+ server.close();
303
+ resolve({ code });
304
+ });
305
+ server.listen(port, "127.0.0.1");
306
+ });
307
+ }
308
+ async function authenticateWithBrowser(serverBase2) {
309
+ const tempServer = createServer();
310
+ await new Promise((resolve) => {
311
+ tempServer.listen(0, "127.0.0.1", () => resolve());
312
+ });
313
+ const port = tempServer.address().port;
314
+ tempServer.close();
315
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
316
+ const clientId = await registerClient(serverBase2, redirectUri);
317
+ const { verifier, challenge } = generatePKCE();
318
+ const state = randomBytes(16).toString("hex");
319
+ const callbackPromise = waitForCallback(port, state);
320
+ const params = new URLSearchParams({
321
+ client_id: clientId,
322
+ redirect_uri: redirectUri,
323
+ response_type: "code",
324
+ code_challenge: challenge,
325
+ code_challenge_method: "S256",
326
+ state
327
+ });
328
+ const authorizeUrl = `${serverBase2}/oauth/authorize?${params}`;
329
+ await open(authorizeUrl);
330
+ const { code } = await callbackPromise;
331
+ const tokens = await exchangeCodeForTokens(
332
+ serverBase2,
333
+ code,
334
+ redirectUri,
335
+ clientId,
336
+ verifier
337
+ );
338
+ const apiKeyResult = await createApiKey(serverBase2, tokens.access_token);
339
+ const creds = {
340
+ api_key: apiKeyResult.key,
341
+ email: tokens.email,
342
+ server: serverBase2,
343
+ client_id: clientId,
344
+ created_at: Date.now()
345
+ };
346
+ saveCredentials(creds);
347
+ return creds;
348
+ }
349
+
350
+ // src/hosts/claude-code.ts
351
+ import { execFile as execFile3 } from "child_process";
352
+ import { promisify as promisify3 } from "util";
353
+ var exec3 = promisify3(execFile3);
354
+ async function installClaudeCode(serverUrl, dryRun) {
355
+ const args = [
356
+ "mcp",
357
+ "add",
358
+ "--scope",
359
+ "user",
360
+ "--transport",
361
+ "http",
362
+ "peppermint-memory",
363
+ serverUrl
364
+ ];
365
+ if (dryRun) {
366
+ return {
367
+ success: true,
368
+ message: `Would run: claude ${args.join(" ")}`,
369
+ needsRestart: false
370
+ };
371
+ }
372
+ try {
373
+ await exec3("claude", args, { timeout: 15e3 });
374
+ return {
375
+ success: true,
376
+ message: "Added peppermint-memory via claude mcp add",
377
+ needsRestart: false
378
+ };
379
+ } catch (err) {
380
+ const message = err instanceof Error ? err.message : "claude mcp add failed";
381
+ return { success: false, message, needsRestart: false };
382
+ }
383
+ }
384
+ async function removeClaudeCode(dryRun) {
385
+ const args = ["mcp", "remove", "--scope", "user", "peppermint-memory"];
386
+ if (dryRun) {
387
+ return {
388
+ success: true,
389
+ message: `Would run: claude ${args.join(" ")}`,
390
+ needsRestart: false
391
+ };
392
+ }
393
+ try {
394
+ await exec3("claude", args, { timeout: 15e3 });
395
+ return {
396
+ success: true,
397
+ message: "Removed peppermint-memory via claude mcp remove",
398
+ needsRestart: false
399
+ };
400
+ } catch (err) {
401
+ const message = err instanceof Error ? err.message : "claude mcp remove failed";
402
+ return { success: false, message, needsRestart: false };
403
+ }
404
+ }
405
+
406
+ // src/hosts/claude-desktop.ts
407
+ import { homedir as homedir4 } from "os";
408
+ import { join as join4 } from "path";
409
+
410
+ // src/hosts/json-config.ts
411
+ import {
412
+ copyFileSync,
413
+ existsSync as existsSync4,
414
+ mkdirSync as mkdirSync2,
415
+ readFileSync as readFileSync4,
416
+ renameSync,
417
+ writeFileSync as writeFileSync2
418
+ } from "fs";
419
+ import { dirname as dirname2 } from "path";
420
+ import * as jsonc3 from "jsonc-parser";
421
+ function writeServerToConfig(options) {
422
+ const { filePath, serverProperty, serverName, serverConfig, dryRun } = options;
423
+ const dir = dirname2(filePath);
424
+ if (!existsSync4(dir)) {
425
+ if (dryRun) {
426
+ return `Would create directory: ${dir}
427
+ Would create: ${filePath}`;
428
+ }
429
+ mkdirSync2(dir, { recursive: true });
430
+ }
431
+ let content = "";
432
+ if (existsSync4(filePath)) {
433
+ content = readFileSync4(filePath, "utf-8");
434
+ }
435
+ const edits = jsonc3.modify(content, [serverProperty, serverName], serverConfig, {
436
+ formattingOptions: { tabSize: 2, insertSpaces: true }
437
+ });
438
+ const updated = jsonc3.applyEdits(content, edits);
439
+ const errors = [];
440
+ jsonc3.parse(updated, errors);
441
+ if (errors.length > 0) {
442
+ throw new Error(
443
+ `Config merge produced invalid JSON: ${errors.map((e) => jsonc3.printParseErrorCode(e.error)).join(", ")}`
444
+ );
445
+ }
446
+ if (dryRun) {
447
+ return `Would write to ${filePath}:
448
+ ${updated}`;
449
+ }
450
+ if (existsSync4(filePath)) {
451
+ const backupPath = `${filePath}.bak.${Date.now()}`;
452
+ copyFileSync(filePath, backupPath);
453
+ }
454
+ const tmpPath = `${filePath}.tmp`;
455
+ writeFileSync2(tmpPath, updated, "utf-8");
456
+ renameSync(tmpPath, filePath);
457
+ return filePath;
458
+ }
459
+ function removeServerFromConfig(filePath, serverProperty, serverName, dryRun) {
460
+ if (!existsSync4(filePath)) {
461
+ return false;
462
+ }
463
+ const content = readFileSync4(filePath, "utf-8");
464
+ const edits = jsonc3.modify(content, [serverProperty, serverName], void 0, {
465
+ formattingOptions: { tabSize: 2, insertSpaces: true }
466
+ });
467
+ if (edits.length === 0) {
468
+ return false;
469
+ }
470
+ const updated = jsonc3.applyEdits(content, edits);
471
+ if (dryRun) {
472
+ return true;
473
+ }
474
+ const backupPath = `${filePath}.bak.${Date.now()}`;
475
+ copyFileSync(filePath, backupPath);
476
+ const tmpPath = `${filePath}.tmp`;
477
+ writeFileSync2(tmpPath, updated, "utf-8");
478
+ renameSync(tmpPath, filePath);
479
+ return true;
480
+ }
481
+
482
+ // src/hosts/claude-desktop.ts
483
+ function getConfigPath3() {
484
+ return join4(
485
+ homedir4(),
486
+ "Library",
487
+ "Application Support",
488
+ "Claude",
489
+ "claude_desktop_config.json"
490
+ );
491
+ }
492
+ async function installClaudeDesktop(serverUrl, apiKey, dryRun) {
493
+ const configPath = getConfigPath3();
494
+ const serverConfig = {
495
+ command: "npx",
496
+ args: [
497
+ "-y",
498
+ "mcp-remote@latest",
499
+ serverUrl,
500
+ "--header",
501
+ "Authorization:${PEPPERMINT_AUTH_HEADER}"
502
+ ],
503
+ env: {
504
+ PEPPERMINT_AUTH_HEADER: `Bearer ${apiKey}`
505
+ }
506
+ };
507
+ try {
508
+ const result = writeServerToConfig({
509
+ filePath: configPath,
510
+ serverProperty: "mcpServers",
511
+ serverName: "peppermint",
512
+ serverConfig,
513
+ dryRun
514
+ });
515
+ return {
516
+ success: true,
517
+ message: dryRun ? result : `Wrote config to ${configPath}`,
518
+ needsRestart: true
519
+ };
520
+ } catch (err) {
521
+ const message = err instanceof Error ? err.message : "Config write failed";
522
+ return { success: false, message, needsRestart: false };
523
+ }
524
+ }
525
+ async function removeClaudeDesktop(dryRun) {
526
+ const configPath = getConfigPath3();
527
+ const removed = removeServerFromConfig(
528
+ configPath,
529
+ "mcpServers",
530
+ "peppermint",
531
+ dryRun
532
+ );
533
+ return {
534
+ success: true,
535
+ message: removed ? "Removed peppermint from Claude Desktop config" : "peppermint not found in Claude Desktop config",
536
+ needsRestart: true
537
+ };
538
+ }
539
+
540
+ // src/hosts/cursor.ts
541
+ import { homedir as homedir5 } from "os";
542
+ import { join as join5 } from "path";
543
+ function getConfigPath4() {
544
+ return join5(homedir5(), ".cursor", "mcp.json");
545
+ }
546
+ async function installCursor(serverUrl, apiKey, dryRun) {
547
+ const configPath = getConfigPath4();
548
+ const serverConfig = {
549
+ url: serverUrl,
550
+ headers: {
551
+ Authorization: `Bearer ${apiKey}`
552
+ }
553
+ };
554
+ try {
555
+ const result = writeServerToConfig({
556
+ filePath: configPath,
557
+ serverProperty: "mcpServers",
558
+ serverName: "peppermint",
559
+ serverConfig,
560
+ dryRun
561
+ });
562
+ return {
563
+ success: true,
564
+ message: dryRun ? result : `Wrote config to ${configPath}`,
565
+ needsRestart: true
566
+ };
567
+ } catch (err) {
568
+ const message = err instanceof Error ? err.message : "Config write failed";
569
+ return { success: false, message, needsRestart: false };
570
+ }
571
+ }
572
+ async function removeCursor(dryRun) {
573
+ const configPath = getConfigPath4();
574
+ const removed = removeServerFromConfig(
575
+ configPath,
576
+ "mcpServers",
577
+ "peppermint",
578
+ dryRun
579
+ );
580
+ return {
581
+ success: true,
582
+ message: removed ? "Removed peppermint from Cursor config" : "peppermint not found in Cursor config",
583
+ needsRestart: true
584
+ };
585
+ }
586
+
587
+ // src/hosts/codex.ts
588
+ import { execFile as execFile4 } from "child_process";
589
+ import { promisify as promisify4 } from "util";
590
+ var exec4 = promisify4(execFile4);
591
+ async function installCodex(serverUrl, dryRun) {
592
+ const addArgs = ["mcp", "add", "peppermint-memory", "--url", serverUrl];
593
+ if (dryRun) {
594
+ return {
595
+ success: true,
596
+ message: `Would run: codex ${addArgs.join(" ")}`,
597
+ needsRestart: false
598
+ };
599
+ }
600
+ try {
601
+ await exec4("codex", addArgs, { timeout: 15e3 });
602
+ return {
603
+ success: true,
604
+ message: "Added peppermint-memory via codex mcp add",
605
+ needsRestart: false
606
+ };
607
+ } catch (err) {
608
+ const message = err instanceof Error ? err.message : "codex mcp add failed";
609
+ return { success: false, message, needsRestart: false };
610
+ }
611
+ }
612
+ async function removeCodex(dryRun) {
613
+ const args = ["mcp", "remove", "peppermint-memory"];
614
+ if (dryRun) {
615
+ return {
616
+ success: true,
617
+ message: `Would run: codex ${args.join(" ")}`,
618
+ needsRestart: false
619
+ };
620
+ }
621
+ try {
622
+ await exec4("codex", args, { timeout: 15e3 });
623
+ return {
624
+ success: true,
625
+ message: "Removed peppermint-memory via codex mcp remove",
626
+ needsRestart: false
627
+ };
628
+ } catch (err) {
629
+ const message = err instanceof Error ? err.message : "codex mcp remove failed";
630
+ return { success: false, message, needsRestart: false };
631
+ }
632
+ }
633
+
634
+ // src/verify/server.ts
635
+ async function checkServerReachable(serverUrl) {
636
+ const start = Date.now();
637
+ try {
638
+ const res = await fetch(serverUrl, {
639
+ method: "POST",
640
+ signal: AbortSignal.timeout(5e3),
641
+ headers: { "Content-Type": "application/json" },
642
+ body: "{}"
643
+ });
644
+ return { reachable: true, latencyMs: Date.now() - start };
645
+ } catch (err) {
646
+ const message = err instanceof Error ? err.message : "Server unreachable";
647
+ return { reachable: false, error: message };
648
+ }
649
+ }
650
+
651
+ // src/verify/host.ts
652
+ import { existsSync as existsSync5, readFileSync as readFileSync5 } from "fs";
653
+ import * as jsonc4 from "jsonc-parser";
654
+ function checkHostConfig(hostId, configPath) {
655
+ if (!configPath) {
656
+ return {
657
+ hostId,
658
+ status: "pass",
659
+ message: "Installed via CLI"
660
+ };
661
+ }
662
+ if (!existsSync5(configPath)) {
663
+ return {
664
+ hostId,
665
+ status: "fail",
666
+ message: `Config file not found: ${configPath}`
667
+ };
668
+ }
669
+ try {
670
+ const content = readFileSync5(configPath, "utf-8");
671
+ const parsed = jsonc4.parse(content);
672
+ const hasPeppermint = !!parsed?.mcpServers?.peppermint;
673
+ if (!hasPeppermint) {
674
+ return {
675
+ hostId,
676
+ status: "fail",
677
+ message: "peppermint entry not found in config"
678
+ };
679
+ }
680
+ return {
681
+ hostId,
682
+ status: "warn",
683
+ message: "Config written; restart required to verify"
684
+ };
685
+ } catch {
686
+ return {
687
+ hostId,
688
+ status: "fail",
689
+ message: `Config file could not be parsed: ${configPath}`
690
+ };
691
+ }
692
+ }
693
+
694
+ // src/cli.ts
695
+ var DEFAULT_SERVER = "https://api.peppermint.com/mcp/";
696
+ function serverBase(serverUrl) {
697
+ return serverUrl.replace(/\/mcp\/?$/, "");
698
+ }
699
+ function needsAuth(hosts) {
700
+ return hosts.some(
701
+ (h) => h.installMethod === "file-native-http" || h.installMethod === "file-stdio-shim"
702
+ );
703
+ }
704
+ async function installHost(host, serverUrl, apiKey, dryRun) {
705
+ switch (host.id) {
706
+ case "claude-code":
707
+ return installClaudeCode(serverUrl, dryRun);
708
+ case "claude-desktop":
709
+ if (!apiKey) throw new Error("API key required for Claude Desktop");
710
+ return installClaudeDesktop(serverUrl, apiKey, dryRun);
711
+ case "cursor":
712
+ if (!apiKey) throw new Error("API key required for Cursor");
713
+ return installCursor(serverUrl, apiKey, dryRun);
714
+ case "codex":
715
+ return installCodex(serverUrl, dryRun);
716
+ }
717
+ }
718
+ async function removeHost(host, dryRun) {
719
+ switch (host.id) {
720
+ case "claude-code":
721
+ return removeClaudeCode(dryRun);
722
+ case "claude-desktop":
723
+ return removeClaudeDesktop(dryRun);
724
+ case "cursor":
725
+ return removeCursor(dryRun);
726
+ case "codex":
727
+ return removeCodex(dryRun);
728
+ }
729
+ }
730
+ async function addCommand(options) {
731
+ p.intro(pc.green("\u{1F33F} Peppermint MCP Wizard"));
732
+ const s = p.spinner();
733
+ s.start("Detecting AI hosts...");
734
+ const hosts = await detectHosts();
735
+ s.stop("Detection complete");
736
+ if (hosts.length === 0) {
737
+ p.log.error(
738
+ "No supported AI hosts detected. Install Claude Code, Claude Desktop, Cursor, or Codex CLI and try again."
739
+ );
740
+ process.exit(6);
741
+ }
742
+ for (const host of hosts) {
743
+ const status = host.alreadyInstalled ? pc.yellow("already configured") : pc.dim("not configured");
744
+ const version = host.version ? pc.dim(` (${host.version})`) : "";
745
+ p.log.info(`${host.alreadyInstalled ? "\u26A0" : "\u2713"} ${host.name}${version} ${status}`);
746
+ }
747
+ const unconfigured = hosts.filter((h) => !h.alreadyInstalled);
748
+ const toInstall = unconfigured.length > 0 ? unconfigured : hosts;
749
+ const selected = await p.multiselect({
750
+ message: "Install Peppermint MCP into which hosts?",
751
+ options: toInstall.map((h) => ({
752
+ value: h.id,
753
+ label: h.name,
754
+ hint: h.alreadyInstalled ? "will reinstall" : void 0
755
+ })),
756
+ initialValues: toInstall.map((h) => h.id)
757
+ });
758
+ if (p.isCancel(selected)) {
759
+ p.cancel("Cancelled.");
760
+ process.exit(0);
761
+ }
762
+ const selectedHosts = hosts.filter(
763
+ (h) => selected.includes(h.id)
764
+ );
765
+ s.start(`Checking server at ${options.server}...`);
766
+ const serverCheck = await checkServerReachable(options.server);
767
+ if (!serverCheck.reachable) {
768
+ s.stop("Server unreachable");
769
+ p.log.error(
770
+ `Cannot reach ${options.server}: ${serverCheck.error}
771
+ Check your internet connection and try again.`
772
+ );
773
+ process.exit(4);
774
+ }
775
+ s.stop(
776
+ `Server reachable ${pc.dim(`(${serverCheck.latencyMs}ms)`)}`
777
+ );
778
+ let apiKey;
779
+ if (needsAuth(selectedHosts)) {
780
+ const base = serverBase(options.server);
781
+ const existing = loadCredentials(base);
782
+ if (existing) {
783
+ apiKey = existing.api_key;
784
+ p.log.success(`Authenticated as ${pc.bold(existing.email || "user")} (cached)`);
785
+ } else {
786
+ p.log.info("Opening browser for authentication...");
787
+ try {
788
+ const creds = await authenticateWithBrowser(base);
789
+ apiKey = creds.api_key;
790
+ p.log.success(`Authenticated as ${pc.bold(creds.email || "user")}`);
791
+ } catch (err) {
792
+ const msg = err instanceof Error ? err.message : "Authentication failed";
793
+ p.log.error(msg);
794
+ process.exit(3);
795
+ }
796
+ }
797
+ }
798
+ p.log.step("Installing...");
799
+ const results = [];
800
+ for (const host of selectedHosts) {
801
+ const result = await installHost(host, options.server, apiKey, options.dryRun);
802
+ const icon = result.success ? pc.green("\u2713") : pc.red("\u2717");
803
+ p.log.info(` ${icon} ${host.name} ${pc.dim(result.message)}`);
804
+ results.push({ host, result });
805
+ }
806
+ if (options.verify && !options.dryRun) {
807
+ p.log.step("Verifying...");
808
+ for (const { host } of results) {
809
+ const check = checkHostConfig(host.id, host.configPath);
810
+ const icon = check.status === "pass" ? pc.green("\u2713") : check.status === "warn" ? pc.yellow("\u26A0") : pc.red("\u2717");
811
+ p.log.info(` ${icon} ${host.name} ${pc.dim(check.message)}`);
812
+ }
813
+ }
814
+ const needRestart = results.filter((r) => r.result.needsRestart);
815
+ const failed = results.filter((r) => !r.result.success);
816
+ p.outro(
817
+ failed.length > 0 ? pc.red(`${failed.length} host(s) failed. Check the output above.`) : needRestart.length > 0 ? pc.green("Done!") + pc.dim(
818
+ ` Restart ${needRestart.map((r) => r.host.name).join(", ")} to finish.`
819
+ ) : pc.green("Done! Peppermint MCP is ready.")
820
+ );
821
+ if (failed.length > 0) process.exit(2);
822
+ }
823
+ async function listCommand(options) {
824
+ p.intro(pc.green("\u{1F33F} Peppermint MCP Wizard \u2014 List"));
825
+ const hosts = await detectHosts();
826
+ if (hosts.length === 0) {
827
+ p.log.warn("No supported AI hosts detected.");
828
+ process.exit(6);
829
+ }
830
+ for (const host of hosts) {
831
+ const status = host.alreadyInstalled ? pc.green("configured") : pc.dim("not configured");
832
+ const version = host.version ? pc.dim(` (${host.version})`) : "";
833
+ p.log.info(`${host.name}${version} ${status}`);
834
+ }
835
+ p.outro(`${hosts.length} host(s) detected`);
836
+ }
837
+ async function doctorCommand(options) {
838
+ p.intro(pc.green("\u{1F33F} Peppermint MCP Wizard \u2014 Doctor"));
839
+ const serverCheck = await checkServerReachable(options.server);
840
+ const serverIcon = serverCheck.reachable ? pc.green("\u2713") : pc.red("\u2717");
841
+ p.log.info(
842
+ `${serverIcon} Server ${serverCheck.reachable ? pc.dim(`(${serverCheck.latencyMs}ms)`) : pc.red(serverCheck.error || "unreachable")}`
843
+ );
844
+ const base = serverBase(options.server);
845
+ const creds = loadCredentials(base);
846
+ const credsIcon = creds ? pc.green("\u2713") : pc.yellow("\u26A0");
847
+ p.log.info(
848
+ `${credsIcon} Credentials ${creds ? pc.dim(creds.email || "API key stored") : pc.yellow("no stored credentials")}`
849
+ );
850
+ const hosts = await detectHosts();
851
+ for (const host of hosts) {
852
+ const check = checkHostConfig(host.id, host.configPath);
853
+ const icon = check.status === "pass" ? pc.green("\u2713") : check.status === "warn" ? pc.yellow("\u26A0") : pc.red("\u2717");
854
+ p.log.info(`${icon} ${host.name} ${pc.dim(check.message)}`);
855
+ }
856
+ p.outro("Health check complete");
857
+ }
858
+ async function removeCommand(options) {
859
+ p.intro(pc.green("\u{1F33F} Peppermint MCP Wizard \u2014 Remove"));
860
+ const hosts = await detectHosts();
861
+ const installed = hosts.filter((h) => h.alreadyInstalled);
862
+ if (installed.length === 0) {
863
+ p.log.info("Peppermint is not installed in any detected hosts.");
864
+ process.exit(0);
865
+ }
866
+ const selected = await p.multiselect({
867
+ message: "Remove Peppermint MCP from which hosts?",
868
+ options: installed.map((h) => ({
869
+ value: h.id,
870
+ label: h.name
871
+ })),
872
+ initialValues: installed.map((h) => h.id)
873
+ });
874
+ if (p.isCancel(selected)) {
875
+ p.cancel("Cancelled.");
876
+ process.exit(0);
877
+ }
878
+ const selectedHosts = hosts.filter(
879
+ (h) => selected.includes(h.id)
880
+ );
881
+ for (const host of selectedHosts) {
882
+ const result = await removeHost(host, options.dryRun);
883
+ const icon = result.success ? pc.green("\u2713") : pc.red("\u2717");
884
+ p.log.info(`${icon} ${host.name} ${pc.dim(result.message)}`);
885
+ }
886
+ p.outro("Removal complete");
887
+ }
888
+ var program = new Command().name("peppermint-mcp-wizard").description("One-command installer for Peppermint MCP").version("0.1.0");
889
+ program.command("add", { isDefault: true }).description("Detect hosts, authenticate, install MCP config").option("--server <url>", "MCP server URL", DEFAULT_SERVER).option("--dry-run", "Print changes without writing", false).option("--no-verify", "Skip post-install verification").action((opts) => addCommand({ server: opts.server, dryRun: opts.dryRun, verify: opts.verify }));
890
+ program.command("list").description("List detected AI hosts and their Peppermint status").option("--server <url>", "MCP server URL", DEFAULT_SERVER).action((opts) => listCommand({ server: opts.server }));
891
+ program.command("doctor").description("Run health checks on existing installation").option("--server <url>", "MCP server URL", DEFAULT_SERVER).action((opts) => doctorCommand({ server: opts.server }));
892
+ program.command("remove").description("Remove Peppermint MCP from selected hosts").option("--server <url>", "MCP server URL", DEFAULT_SERVER).option("--dry-run", "Print changes without writing", false).action((opts) => removeCommand({ server: opts.server, dryRun: opts.dryRun }));
893
+ program.parse();
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@peppermint-mcp/wizard",
3
+ "version": "0.1.0",
4
+ "description": "One-command installer for Peppermint MCP across AI coding hosts",
5
+ "type": "module",
6
+ "bin": {
7
+ "peppermint-mcp-wizard": "./dist/cli.js"
8
+ },
9
+ "files": [
10
+ "dist/"
11
+ ],
12
+ "scripts": {
13
+ "build": "tsup",
14
+ "dev": "tsup --watch",
15
+ "test": "vitest run",
16
+ "test:watch": "vitest",
17
+ "prepublishOnly": "npm run build"
18
+ },
19
+ "engines": {
20
+ "node": ">=18"
21
+ },
22
+ "keywords": [
23
+ "peppermint",
24
+ "mcp",
25
+ "claude",
26
+ "cursor",
27
+ "codex",
28
+ "ai",
29
+ "memory"
30
+ ],
31
+ "license": "MIT",
32
+ "dependencies": {
33
+ "@clack/prompts": "^0.9.1",
34
+ "commander": "^13.1.0",
35
+ "jsonc-parser": "^3.3.1",
36
+ "open": "^10.1.0",
37
+ "picocolors": "^1.1.1",
38
+ "zod": "^3.24.4"
39
+ },
40
+ "devDependencies": {
41
+ "tsup": "^8.4.0",
42
+ "typescript": "^5.7.3",
43
+ "vitest": "^3.1.1"
44
+ }
45
+ }