techprufer-mcp 0.1.6 → 0.2.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.
- package/README.md +51 -8
- package/dist/api.js +12 -6
- package/dist/cli.js +46 -2
- package/dist/login.browserOpen.test.d.ts +1 -0
- package/dist/login.browserOpen.test.js +26 -0
- package/dist/login.d.ts +11 -1
- package/dist/login.js +57 -11
- package/dist/setup.d.ts +17 -0
- package/dist/setup.js +212 -0
- package/dist/tools.d.ts +49 -0
- package/dist/tools.js +186 -0
- package/dist/tools.test.d.ts +1 -0
- package/dist/tools.test.js +67 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -4,9 +4,58 @@ TechPrufer MCP — tailor resumes and build profiles from your AI tool.
|
|
|
4
4
|
|
|
5
5
|
Uses the same TechPrufer cube mark as the site and Chrome extension (shown in MCP clients that support server icons).
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## Quick start
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
One command does everything:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npx techprufer-mcp login
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
This runs two steps:
|
|
16
|
+
|
|
17
|
+
1. **Configure AI tools** — an interactive picker (arrow keys, `Space` to toggle, `a` for all, `Enter` to confirm). Tools that already have the server show `(refresh)` and are pre-selected.
|
|
18
|
+
2. **Device-code login** — opens your browser to approve the device. If the browser doesn't open, paste the printed link manually; the CLI keeps waiting and re-shows the link after 30 seconds.
|
|
19
|
+
|
|
20
|
+
Use `--no-setup` to skip the picker and login only. Restart your AI tools after setup so they pick up the server.
|
|
21
|
+
|
|
22
|
+
## Commands
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npx techprufer-mcp # Start stdio MCP server (what AI tools run)
|
|
26
|
+
npx techprufer-mcp login # Configure tools, then device-code login
|
|
27
|
+
npx techprufer-mcp setup # Configure tools only (no login)
|
|
28
|
+
npx techprufer-mcp status # Show saved credentials
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Setup options (non-interactive)
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npx techprufer-mcp setup --all
|
|
35
|
+
npx techprufer-mcp setup --tools=cursor,claude-code,codex
|
|
36
|
+
npx techprufer-mcp setup --print --tools=antigravity # print snippets, write nothing
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Login options
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
npx techprufer-mcp login --name "Work laptop" # device name shown on the site
|
|
43
|
+
npx techprufer-mcp login --no-setup # skip the tool picker
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Supported tools
|
|
47
|
+
|
|
48
|
+
| Id | Tool | Config written |
|
|
49
|
+
|----|------|----------------|
|
|
50
|
+
| `cursor` | Cursor | `~/.cursor/mcp.json` |
|
|
51
|
+
| `claude` | Claude Desktop | `claude_desktop_config.json` (per-OS path) |
|
|
52
|
+
| `claude-code` | Claude Code | `~/.claude.json` (user scope) |
|
|
53
|
+
| `antigravity` | Antigravity | `~/.gemini/…/mcp_config.json` |
|
|
54
|
+
| `codex` | Codex | `~/.codex/config.toml` |
|
|
55
|
+
|
|
56
|
+
Setup merges into existing config files — other MCP servers are preserved. The server key **must** be `techprufer` so slash commands are `/techprufer/…`.
|
|
57
|
+
|
|
58
|
+
## Manual JSON (any mcpServers client)
|
|
10
59
|
|
|
11
60
|
```json
|
|
12
61
|
{
|
|
@@ -19,12 +68,6 @@ Server key **must** be `techprufer` so slash commands are `/techprufer/…`:
|
|
|
19
68
|
}
|
|
20
69
|
```
|
|
21
70
|
|
|
22
|
-
## Login
|
|
23
|
-
|
|
24
|
-
```bash
|
|
25
|
-
npx techprufer-mcp login
|
|
26
|
-
```
|
|
27
|
-
|
|
28
71
|
## Slash commands
|
|
29
72
|
|
|
30
73
|
Type `/techprufer` in your AI tool to list:
|
package/dist/api.js
CHANGED
|
@@ -8,6 +8,17 @@ export class ApiError extends Error {
|
|
|
8
8
|
this.body = body;
|
|
9
9
|
}
|
|
10
10
|
}
|
|
11
|
+
/** Avoid Error.cause typing (needs lib es2022); Node fetch still sets cause at runtime. */
|
|
12
|
+
function formatFetchError(err) {
|
|
13
|
+
if (!(err instanceof Error))
|
|
14
|
+
return String(err);
|
|
15
|
+
const cause = err.cause;
|
|
16
|
+
if (cause instanceof Error)
|
|
17
|
+
return cause.message;
|
|
18
|
+
if (typeof cause === 'string' && cause)
|
|
19
|
+
return cause;
|
|
20
|
+
return err.message;
|
|
21
|
+
}
|
|
11
22
|
export async function apiFetch(path, init = {}) {
|
|
12
23
|
const creds = loadCredentials();
|
|
13
24
|
const token = init.token === undefined ? creds?.token : init.token;
|
|
@@ -30,12 +41,7 @@ export async function apiFetch(path, init = {}) {
|
|
|
30
41
|
res = await fetch(`${base}${path}`, { ...fetchInit, headers });
|
|
31
42
|
}
|
|
32
43
|
catch (err) {
|
|
33
|
-
|
|
34
|
-
? err.cause.message
|
|
35
|
-
: err instanceof Error
|
|
36
|
-
? err.message
|
|
37
|
-
: String(err);
|
|
38
|
-
throw new Error(`fetch failed (${base}${path}): ${cause}`);
|
|
44
|
+
throw new Error(`fetch failed (${base}${path}): ${formatFetchError(err)}`);
|
|
39
45
|
}
|
|
40
46
|
const text = await res.text();
|
|
41
47
|
let body = null;
|
package/dist/cli.js
CHANGED
|
@@ -1,13 +1,43 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { runLogin, startStdioServer } from './server.js';
|
|
3
3
|
import { loadCredentials, credentialsFilePath, getApiUrl } from './config.js';
|
|
4
|
+
import { listToolsForHelp, runSetup } from './setup.js';
|
|
5
|
+
function parseFlagValue(args, name) {
|
|
6
|
+
const eq = args.find((a) => a.startsWith(`${name}=`));
|
|
7
|
+
if (eq)
|
|
8
|
+
return eq.slice(name.length + 1);
|
|
9
|
+
const idx = args.indexOf(name);
|
|
10
|
+
if (idx >= 0)
|
|
11
|
+
return args[idx + 1];
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
function hasFlag(args, name) {
|
|
15
|
+
return args.includes(name);
|
|
16
|
+
}
|
|
4
17
|
async function main() {
|
|
5
18
|
const args = process.argv.slice(2);
|
|
6
19
|
const cmd = args[0];
|
|
7
20
|
if (cmd === 'login') {
|
|
8
21
|
const nameIdx = args.indexOf('--name');
|
|
9
22
|
const deviceName = nameIdx >= 0 ? args[nameIdx + 1] : undefined;
|
|
10
|
-
|
|
23
|
+
const skipSetup = hasFlag(args, '--no-setup');
|
|
24
|
+
if (!skipSetup) {
|
|
25
|
+
await runSetup({ beforeLogin: true });
|
|
26
|
+
console.error('');
|
|
27
|
+
}
|
|
28
|
+
await runLogin(deviceName, skipSetup ? {} : { stepLabel: 'step 2/2 · login' });
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (cmd === 'setup') {
|
|
32
|
+
const toolsRaw = parseFlagValue(args, '--tools');
|
|
33
|
+
await runSetup({
|
|
34
|
+
all: hasFlag(args, '--all'),
|
|
35
|
+
tools: toolsRaw
|
|
36
|
+
? toolsRaw.split(',').map((s) => s.trim()).filter(Boolean)
|
|
37
|
+
: undefined,
|
|
38
|
+
printOnly: hasFlag(args, '--print'),
|
|
39
|
+
yes: hasFlag(args, '-y') || hasFlag(args, '--yes'),
|
|
40
|
+
});
|
|
11
41
|
return;
|
|
12
42
|
}
|
|
13
43
|
if (cmd === 'status' || cmd === 'whoami') {
|
|
@@ -27,9 +57,23 @@ async function main() {
|
|
|
27
57
|
|
|
28
58
|
Usage:
|
|
29
59
|
npx techprufer-mcp Start stdio MCP server (for AI tools)
|
|
30
|
-
npx techprufer-mcp login
|
|
60
|
+
npx techprufer-mcp login Pick AI tools to configure, then device-code login
|
|
61
|
+
npx techprufer-mcp setup Configure AI tools only (no login)
|
|
31
62
|
npx techprufer-mcp status Show saved credentials
|
|
32
63
|
|
|
64
|
+
Setup options:
|
|
65
|
+
--tools=id,id Non-interactive: comma-separated tool ids
|
|
66
|
+
--all Configure every supported tool
|
|
67
|
+
--print Print config snippets only (no file writes)
|
|
68
|
+
-y, --yes Require --tools or --all (no prompts)
|
|
69
|
+
|
|
70
|
+
Supported tools:
|
|
71
|
+
${listToolsForHelp()}
|
|
72
|
+
|
|
73
|
+
Login options:
|
|
74
|
+
--name <label> Device name shown on the site
|
|
75
|
+
--no-setup Skip the tool picker; login only
|
|
76
|
+
|
|
33
77
|
Env:
|
|
34
78
|
TECHPRUFER_API_URL Override API base (default https://techprufer.com)
|
|
35
79
|
`);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { browserOpenArgs } from './login.js';
|
|
4
|
+
describe('browserOpenArgs', () => {
|
|
5
|
+
const url = 'https://techprufer.com/connect/device?code=ABCD-1234';
|
|
6
|
+
it('uses open on macOS', () => {
|
|
7
|
+
assert.deepEqual(browserOpenArgs(url, 'darwin'), {
|
|
8
|
+
command: 'open',
|
|
9
|
+
args: [url],
|
|
10
|
+
options: {},
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
it('uses cmd /c start on Windows (not spawn start)', () => {
|
|
14
|
+
const result = browserOpenArgs(url, 'win32');
|
|
15
|
+
assert.equal(result.command, 'cmd');
|
|
16
|
+
assert.deepEqual(result.args, ['/c', 'start', '', url]);
|
|
17
|
+
assert.equal(result.options.windowsHide, true);
|
|
18
|
+
});
|
|
19
|
+
it('uses xdg-open on Linux', () => {
|
|
20
|
+
assert.deepEqual(browserOpenArgs(url, 'linux'), {
|
|
21
|
+
command: 'xdg-open',
|
|
22
|
+
args: [url],
|
|
23
|
+
options: {},
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
});
|
package/dist/login.d.ts
CHANGED
|
@@ -1 +1,11 @@
|
|
|
1
|
-
|
|
1
|
+
/** Exported for tests — resolve platform-specific open command. */
|
|
2
|
+
export declare function browserOpenArgs(url: string, os?: NodeJS.Platform): {
|
|
3
|
+
command: string;
|
|
4
|
+
args: string[];
|
|
5
|
+
options: {
|
|
6
|
+
windowsHide?: boolean;
|
|
7
|
+
};
|
|
8
|
+
};
|
|
9
|
+
export declare function runLogin(deviceName?: string, opts?: {
|
|
10
|
+
stepLabel?: string;
|
|
11
|
+
}): Promise<void>;
|
package/dist/login.js
CHANGED
|
@@ -2,10 +2,37 @@ import { spawn } from 'child_process';
|
|
|
2
2
|
import { platform } from 'os';
|
|
3
3
|
import { apiFetch } from './api.js';
|
|
4
4
|
import { getApiUrl, saveCredentials } from './config.js';
|
|
5
|
+
/** Exported for tests — resolve platform-specific open command. */
|
|
6
|
+
export function browserOpenArgs(url, os = platform()) {
|
|
7
|
+
if (os === 'darwin')
|
|
8
|
+
return { command: 'open', args: [url], options: {} };
|
|
9
|
+
if (os === 'win32') {
|
|
10
|
+
// `start` is a cmd.exe builtin, not an executable — spawn('start') → ENOENT.
|
|
11
|
+
// Empty title arg so the URL is not treated as a window title.
|
|
12
|
+
return {
|
|
13
|
+
command: 'cmd',
|
|
14
|
+
args: ['/c', 'start', '', url],
|
|
15
|
+
options: { windowsHide: true },
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
return { command: 'xdg-open', args: [url], options: {} };
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Best-effort browser open. Failures must never kill the login poll —
|
|
22
|
+
* the URL is already printed for the user to open manually.
|
|
23
|
+
*/
|
|
5
24
|
function openBrowser(url) {
|
|
6
|
-
const cmd = platform() === 'darwin' ? 'open' : platform() === 'win32' ? 'start' : 'xdg-open';
|
|
7
25
|
try {
|
|
8
|
-
const
|
|
26
|
+
const { command, args, options } = browserOpenArgs(url);
|
|
27
|
+
const child = spawn(command, args, {
|
|
28
|
+
detached: true,
|
|
29
|
+
stdio: 'ignore',
|
|
30
|
+
...options,
|
|
31
|
+
});
|
|
32
|
+
// spawn errors are async ('error' event); try/catch alone is not enough.
|
|
33
|
+
child.on('error', () => {
|
|
34
|
+
/* ignore — user can open the printed URL */
|
|
35
|
+
});
|
|
9
36
|
child.unref();
|
|
10
37
|
}
|
|
11
38
|
catch {
|
|
@@ -31,9 +58,9 @@ function alignVerifyUrl(verifyUrl, apiUrl) {
|
|
|
31
58
|
return verifyUrl;
|
|
32
59
|
}
|
|
33
60
|
}
|
|
34
|
-
export async function runLogin(deviceName) {
|
|
61
|
+
export async function runLogin(deviceName, opts = {}) {
|
|
35
62
|
const apiUrl = getApiUrl();
|
|
36
|
-
console.error(`TechPrufer MCP — logging in against ${apiUrl}`);
|
|
63
|
+
console.error(`TechPrufer MCP${opts.stepLabel ? ` — ${opts.stepLabel}` : ''} — logging in against ${apiUrl}`);
|
|
37
64
|
const start = await apiFetch('/api/agent/device/start', {
|
|
38
65
|
method: 'POST',
|
|
39
66
|
token: null,
|
|
@@ -45,18 +72,37 @@ export async function runLogin(deviceName) {
|
|
|
45
72
|
console.error(` Code: ${start.userCode}`);
|
|
46
73
|
console.error(` Open: ${verifyUrl}`);
|
|
47
74
|
console.error('');
|
|
48
|
-
console.error('
|
|
75
|
+
console.error('Opening your browser… If it does not open, paste the link above manually.');
|
|
76
|
+
console.error('Waiting for approval (Ctrl+C to cancel)…');
|
|
49
77
|
openBrowser(verifyUrl);
|
|
50
78
|
const intervalMs = Math.max(3, start.interval || 5) * 1000;
|
|
51
79
|
const deadline = Date.now() + start.expiresIn * 1000;
|
|
80
|
+
const startedAt = Date.now();
|
|
81
|
+
let remindedManualOpen = false;
|
|
52
82
|
while (Date.now() < deadline) {
|
|
53
83
|
await sleep(intervalMs);
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
84
|
+
// Still pending after 30s — the browser probably never opened. Re-show the link.
|
|
85
|
+
if (!remindedManualOpen && Date.now() - startedAt > 30_000) {
|
|
86
|
+
remindedManualOpen = true;
|
|
87
|
+
console.error('');
|
|
88
|
+
console.error('Still waiting. If no browser window opened, visit:');
|
|
89
|
+
console.error(` ${verifyUrl}`);
|
|
90
|
+
console.error(` and enter code ${start.userCode}`);
|
|
91
|
+
console.error('');
|
|
92
|
+
}
|
|
93
|
+
let poll;
|
|
94
|
+
try {
|
|
95
|
+
poll = await apiFetch('/api/agent/device/poll', {
|
|
96
|
+
method: 'POST',
|
|
97
|
+
token: null,
|
|
98
|
+
baseUrl: apiUrl,
|
|
99
|
+
body: JSON.stringify({ deviceCode: start.deviceCode }),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// Transient network error — keep waiting until the code expires.
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
60
106
|
if (poll.status === 'pending')
|
|
61
107
|
continue;
|
|
62
108
|
if (poll.status === 'expired') {
|
package/dist/setup.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type AiToolId } from './tools.js';
|
|
2
|
+
/** Parse "1,3", "cursor,codex", "all", "a" into tool ids (fallback prompt + --tools). */
|
|
3
|
+
export declare function parseSelection(input: string): AiToolId[];
|
|
4
|
+
export interface SetupOptions {
|
|
5
|
+
/** Explicit tool ids from --tools=… */
|
|
6
|
+
tools?: string[];
|
|
7
|
+
/** Configure every supported tool. */
|
|
8
|
+
all?: boolean;
|
|
9
|
+
/** Print snippets only; do not write files. */
|
|
10
|
+
printOnly?: boolean;
|
|
11
|
+
/** Skip interactive prompts (fail if tools not specified). */
|
|
12
|
+
yes?: boolean;
|
|
13
|
+
/** Called as step 1 of `login` — tweaks copy and skips the "next" hints. */
|
|
14
|
+
beforeLogin?: boolean;
|
|
15
|
+
}
|
|
16
|
+
export declare function runSetup(opts?: SetupOptions): Promise<void>;
|
|
17
|
+
export declare function listToolsForHelp(): string;
|
package/dist/setup.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { createInterface, emitKeypressEvents } from 'readline';
|
|
2
|
+
import { AI_TOOLS, getTool, installTool, isToolConfigured, printSnippetForTool, resolveToolIds, } from './tools.js';
|
|
3
|
+
const ANSI = {
|
|
4
|
+
hideCursor: '\x1b[?25l',
|
|
5
|
+
showCursor: '\x1b[?25h',
|
|
6
|
+
clearDown: '\x1b[J',
|
|
7
|
+
up: (n) => (n > 0 ? `\x1b[${n}A` : ''),
|
|
8
|
+
dim: (s) => `\x1b[2m${s}\x1b[22m`,
|
|
9
|
+
bold: (s) => `\x1b[1m${s}\x1b[22m`,
|
|
10
|
+
cyan: (s) => `\x1b[36m${s}\x1b[39m`,
|
|
11
|
+
green: (s) => `\x1b[32m${s}\x1b[39m`,
|
|
12
|
+
red: (s) => `\x1b[31m${s}\x1b[39m`,
|
|
13
|
+
};
|
|
14
|
+
function isTty() {
|
|
15
|
+
return Boolean(process.stdin.isTTY && process.stderr.isTTY);
|
|
16
|
+
}
|
|
17
|
+
function ask(question) {
|
|
18
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
19
|
+
return new Promise((resolve) => {
|
|
20
|
+
rl.question(question, (answer) => {
|
|
21
|
+
rl.close();
|
|
22
|
+
resolve(answer.trim());
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
/** Parse "1,3", "cursor,codex", "all", "a" into tool ids (fallback prompt + --tools). */
|
|
27
|
+
export function parseSelection(input) {
|
|
28
|
+
const raw = input.trim().toLowerCase();
|
|
29
|
+
if (!raw)
|
|
30
|
+
return [];
|
|
31
|
+
if (raw === 'a' || raw === 'all')
|
|
32
|
+
return AI_TOOLS.map((t) => t.id);
|
|
33
|
+
const tokens = raw.split(/[\s,]+/).filter(Boolean);
|
|
34
|
+
const ids = [];
|
|
35
|
+
for (const token of tokens) {
|
|
36
|
+
const asNum = Number(token);
|
|
37
|
+
if (Number.isInteger(asNum) && asNum >= 1 && asNum <= AI_TOOLS.length) {
|
|
38
|
+
const id = AI_TOOLS[asNum - 1].id;
|
|
39
|
+
if (!ids.includes(id))
|
|
40
|
+
ids.push(id);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const tool = getTool(token);
|
|
44
|
+
if (!tool) {
|
|
45
|
+
throw new Error(`Unknown selection "${token}". Use numbers 1–${AI_TOOLS.length}, tool ids, or "all".`);
|
|
46
|
+
}
|
|
47
|
+
if (!ids.includes(tool.id))
|
|
48
|
+
ids.push(tool.id);
|
|
49
|
+
}
|
|
50
|
+
return ids;
|
|
51
|
+
}
|
|
52
|
+
function renderPicker(items, cursor) {
|
|
53
|
+
const lines = [];
|
|
54
|
+
const selectedLabels = items.filter((i) => i.selected).map((i) => i.tool.label);
|
|
55
|
+
lines.push(`${ANSI.bold('? Select AI tools to set up')} ${ANSI.dim(`(${AI_TOOLS.length} available)`)}`);
|
|
56
|
+
lines.push(` Selected: ${selectedLabels.length ? ANSI.cyan(selectedLabels.join(', ')) : ANSI.dim('none')}`);
|
|
57
|
+
lines.push(ANSI.dim(' ↑↓ navigate · Space toggle · a all · Enter confirm · q quit'));
|
|
58
|
+
for (let i = 0; i < items.length; i++) {
|
|
59
|
+
const item = items[i];
|
|
60
|
+
const pointer = i === cursor ? ANSI.cyan('>') : ' ';
|
|
61
|
+
const mark = item.selected ? ANSI.green('●') : ANSI.dim('○');
|
|
62
|
+
const refresh = item.configured ? ` ${ANSI.dim('(refresh)')}` : '';
|
|
63
|
+
const label = i === cursor ? ANSI.bold(item.tool.label) : item.tool.label;
|
|
64
|
+
lines.push(`${pointer} ${mark} ${label}${refresh} ${ANSI.dim(item.tool.hint)}`);
|
|
65
|
+
}
|
|
66
|
+
return lines.join('\n');
|
|
67
|
+
}
|
|
68
|
+
/** Arrow-key multi-select; pre-selects tools that already have a techprufer entry. */
|
|
69
|
+
async function interactivePicker() {
|
|
70
|
+
const items = AI_TOOLS.map((tool) => {
|
|
71
|
+
const configured = isToolConfigured(tool);
|
|
72
|
+
return { tool, selected: configured, configured };
|
|
73
|
+
});
|
|
74
|
+
// Nothing configured yet → pre-select everything for a fast first run.
|
|
75
|
+
if (!items.some((i) => i.selected)) {
|
|
76
|
+
for (const item of items)
|
|
77
|
+
item.selected = true;
|
|
78
|
+
}
|
|
79
|
+
let cursor = 0;
|
|
80
|
+
let renderedLines = 0;
|
|
81
|
+
const draw = () => {
|
|
82
|
+
const frame = renderPicker(items, cursor);
|
|
83
|
+
process.stderr.write(ANSI.up(renderedLines) + '\r' + ANSI.clearDown + frame + '\n');
|
|
84
|
+
renderedLines = frame.split('\n').length;
|
|
85
|
+
};
|
|
86
|
+
emitKeypressEvents(process.stdin);
|
|
87
|
+
process.stdin.setRawMode?.(true);
|
|
88
|
+
process.stdin.resume();
|
|
89
|
+
process.stderr.write(ANSI.hideCursor);
|
|
90
|
+
draw();
|
|
91
|
+
return new Promise((resolve, reject) => {
|
|
92
|
+
const cleanup = () => {
|
|
93
|
+
process.stdin.setRawMode?.(false);
|
|
94
|
+
process.stdin.pause();
|
|
95
|
+
process.stdin.removeListener('keypress', onKeypress);
|
|
96
|
+
process.stderr.write(ANSI.showCursor);
|
|
97
|
+
};
|
|
98
|
+
const onKeypress = (_str, key = {}) => {
|
|
99
|
+
if ((key.ctrl && key.name === 'c') || key.name === 'q' || key.name === 'escape') {
|
|
100
|
+
cleanup();
|
|
101
|
+
process.stderr.write('\nCancelled.\n');
|
|
102
|
+
reject(new Error('cancelled'));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (key.name === 'up' || key.name === 'k') {
|
|
106
|
+
cursor = (cursor - 1 + items.length) % items.length;
|
|
107
|
+
}
|
|
108
|
+
else if (key.name === 'down' || key.name === 'j') {
|
|
109
|
+
cursor = (cursor + 1) % items.length;
|
|
110
|
+
}
|
|
111
|
+
else if (key.name === 'space') {
|
|
112
|
+
items[cursor].selected = !items[cursor].selected;
|
|
113
|
+
}
|
|
114
|
+
else if (key.name === 'a') {
|
|
115
|
+
const allOn = items.every((i) => i.selected);
|
|
116
|
+
for (const item of items)
|
|
117
|
+
item.selected = !allOn;
|
|
118
|
+
}
|
|
119
|
+
else if (key.name === 'return' || key.name === 'enter') {
|
|
120
|
+
cleanup();
|
|
121
|
+
resolve(items.filter((i) => i.selected).map((i) => i.tool.id));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
draw();
|
|
125
|
+
};
|
|
126
|
+
process.stdin.on('keypress', onKeypress);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
async function promptToolSelection() {
|
|
130
|
+
if (process.stdin.isTTY && typeof process.stdin.setRawMode === 'function') {
|
|
131
|
+
try {
|
|
132
|
+
return await interactivePicker();
|
|
133
|
+
}
|
|
134
|
+
catch (e) {
|
|
135
|
+
if (e instanceof Error && e.message === 'cancelled')
|
|
136
|
+
process.exit(1);
|
|
137
|
+
// Raw-mode failure → fall through to the plain prompt.
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
console.error('AI tools:');
|
|
141
|
+
AI_TOOLS.forEach((t, i) => {
|
|
142
|
+
console.error(` ${i + 1}) ${t.label.padEnd(16)} ${t.hint}`);
|
|
143
|
+
});
|
|
144
|
+
console.error(' a) all');
|
|
145
|
+
console.error('');
|
|
146
|
+
const answer = await ask('Select tools (numbers or ids, comma-separated; "all"): ');
|
|
147
|
+
return parseSelection(answer);
|
|
148
|
+
}
|
|
149
|
+
function printResults(results, opts = {}) {
|
|
150
|
+
console.error('');
|
|
151
|
+
for (const r of results) {
|
|
152
|
+
if (r.ok) {
|
|
153
|
+
console.error(` ${ANSI.green('✓')} Setup complete for ${ANSI.bold(r.tool.label)} ${ANSI.dim(r.path)}`);
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
console.error(` ${ANSI.red('✗')} ${r.tool.label} ${ANSI.dim(r.path)}`);
|
|
157
|
+
console.error(` ${r.error}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
console.error('');
|
|
161
|
+
console.error(ANSI.dim('Restart your AI tools for the MCP server to take effect.'));
|
|
162
|
+
if (!opts.beforeLogin) {
|
|
163
|
+
console.error('');
|
|
164
|
+
console.error('Next: npx techprufer-mcp login (if you have not already)');
|
|
165
|
+
console.error('Then: /techprufer/tailor or /techprufer/build-profile');
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
export async function runSetup(opts = {}) {
|
|
169
|
+
console.error(ANSI.bold(opts.beforeLogin
|
|
170
|
+
? 'TechPrufer MCP — step 1/2 · configure AI tools'
|
|
171
|
+
: 'TechPrufer MCP — configure AI tools'));
|
|
172
|
+
console.error('');
|
|
173
|
+
let ids;
|
|
174
|
+
if (opts.all) {
|
|
175
|
+
ids = AI_TOOLS.map((t) => t.id);
|
|
176
|
+
}
|
|
177
|
+
else if (opts.tools?.length) {
|
|
178
|
+
ids = resolveToolIds(opts.tools);
|
|
179
|
+
}
|
|
180
|
+
else if (isTty() && !opts.yes) {
|
|
181
|
+
ids = await promptToolSelection();
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
console.error('Non-interactive mode: pass --all or --tools=cursor,claude,claude-code,antigravity,codex');
|
|
185
|
+
process.exit(1);
|
|
186
|
+
}
|
|
187
|
+
if (ids.length === 0) {
|
|
188
|
+
if (opts.beforeLogin) {
|
|
189
|
+
console.error('No tools selected — skipping setup.');
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
console.error('No tools selected.');
|
|
193
|
+
process.exit(1);
|
|
194
|
+
}
|
|
195
|
+
const selected = ids.map((id) => getTool(id));
|
|
196
|
+
if (opts.printOnly) {
|
|
197
|
+
for (const tool of selected) {
|
|
198
|
+
console.error(`--- ${tool.label} (${tool.configPath()}) ---`);
|
|
199
|
+
console.error(printSnippetForTool(tool));
|
|
200
|
+
console.error('');
|
|
201
|
+
}
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const results = selected.map((tool) => installTool(tool));
|
|
205
|
+
printResults(results, { beforeLogin: opts.beforeLogin });
|
|
206
|
+
if (results.some((r) => !r.ok) && !opts.beforeLogin) {
|
|
207
|
+
process.exit(1);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
export function listToolsForHelp() {
|
|
211
|
+
return AI_TOOLS.map((t) => ` ${t.id.padEnd(14)} ${t.label}`).join('\n');
|
|
212
|
+
}
|
package/dist/tools.d.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** Server key must stay `techprufer` so slash commands are `/techprufer/…`. */
|
|
2
|
+
export declare const MCP_SERVER_KEY = "techprufer";
|
|
3
|
+
export declare const MCP_SERVER_ENTRY: {
|
|
4
|
+
readonly command: "npx";
|
|
5
|
+
readonly args: readonly ["-y", "techprufer-mcp"];
|
|
6
|
+
};
|
|
7
|
+
export type AiToolId = 'cursor' | 'claude' | 'claude-code' | 'antigravity' | 'codex';
|
|
8
|
+
export interface AiToolDef {
|
|
9
|
+
id: AiToolId;
|
|
10
|
+
label: string;
|
|
11
|
+
/** Short hint shown in the picker. */
|
|
12
|
+
hint: string;
|
|
13
|
+
/** Config path for the current OS (may not exist yet). */
|
|
14
|
+
configPath: () => string;
|
|
15
|
+
format: 'json-mcpServers' | 'toml-mcp_servers';
|
|
16
|
+
/** Shown after a successful write. */
|
|
17
|
+
restartHint: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Antigravity IDE commonly uses `~/.gemini/antigravity/mcp_config.json`.
|
|
21
|
+
* Newer docs also mention `~/.gemini/config/mcp_config.json` — prefer the path
|
|
22
|
+
* that already exists, else the antigravity path.
|
|
23
|
+
*/
|
|
24
|
+
export declare function antigravityConfigPath(): string;
|
|
25
|
+
export declare const AI_TOOLS: AiToolDef[];
|
|
26
|
+
export declare function getTool(id: string): AiToolDef | undefined;
|
|
27
|
+
export declare function resolveToolIds(raw: string[]): AiToolId[];
|
|
28
|
+
/** Merge techprufer into a Cursor/Claude/Antigravity-style JSON mcpServers file. */
|
|
29
|
+
export declare function mergeJsonMcpServers(existingRaw: string | null): string;
|
|
30
|
+
/**
|
|
31
|
+
* Upsert `[mcp_servers.techprufer]` in a Codex config.toml without a TOML parser.
|
|
32
|
+
* Replaces an existing techprufer table; appends otherwise.
|
|
33
|
+
*/
|
|
34
|
+
export declare function mergeTomlMcpServers(existingRaw: string | null): string;
|
|
35
|
+
/** True if the tool's config file already has a techprufer entry. */
|
|
36
|
+
export declare function isToolConfigured(tool: AiToolDef): boolean;
|
|
37
|
+
export type InstallResult = {
|
|
38
|
+
ok: true;
|
|
39
|
+
tool: AiToolDef;
|
|
40
|
+
path: string;
|
|
41
|
+
created: boolean;
|
|
42
|
+
} | {
|
|
43
|
+
ok: false;
|
|
44
|
+
tool: AiToolDef;
|
|
45
|
+
path: string;
|
|
46
|
+
error: string;
|
|
47
|
+
};
|
|
48
|
+
export declare function installTool(tool: AiToolDef): InstallResult;
|
|
49
|
+
export declare function printSnippetForTool(tool: AiToolDef): string;
|
package/dist/tools.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
2
|
+
import { homedir, platform } from 'os';
|
|
3
|
+
import { dirname, join } from 'path';
|
|
4
|
+
/** Server key must stay `techprufer` so slash commands are `/techprufer/…`. */
|
|
5
|
+
export const MCP_SERVER_KEY = 'techprufer';
|
|
6
|
+
export const MCP_SERVER_ENTRY = {
|
|
7
|
+
command: 'npx',
|
|
8
|
+
args: ['-y', 'techprufer-mcp'],
|
|
9
|
+
};
|
|
10
|
+
function appDataRoaming() {
|
|
11
|
+
if (platform() === 'win32') {
|
|
12
|
+
return process.env.APPDATA || join(homedir(), 'AppData', 'Roaming');
|
|
13
|
+
}
|
|
14
|
+
return '';
|
|
15
|
+
}
|
|
16
|
+
function claudeDesktopConfigPath() {
|
|
17
|
+
const os = platform();
|
|
18
|
+
if (os === 'darwin') {
|
|
19
|
+
return join(homedir(), 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
|
|
20
|
+
}
|
|
21
|
+
if (os === 'win32') {
|
|
22
|
+
return join(appDataRoaming(), 'Claude', 'claude_desktop_config.json');
|
|
23
|
+
}
|
|
24
|
+
return join(homedir(), '.config', 'Claude', 'claude_desktop_config.json');
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Antigravity IDE commonly uses `~/.gemini/antigravity/mcp_config.json`.
|
|
28
|
+
* Newer docs also mention `~/.gemini/config/mcp_config.json` — prefer the path
|
|
29
|
+
* that already exists, else the antigravity path.
|
|
30
|
+
*/
|
|
31
|
+
export function antigravityConfigPath() {
|
|
32
|
+
const antigravity = join(homedir(), '.gemini', 'antigravity', 'mcp_config.json');
|
|
33
|
+
const config = join(homedir(), '.gemini', 'config', 'mcp_config.json');
|
|
34
|
+
if (existsSync(antigravity))
|
|
35
|
+
return antigravity;
|
|
36
|
+
if (existsSync(config))
|
|
37
|
+
return config;
|
|
38
|
+
return antigravity;
|
|
39
|
+
}
|
|
40
|
+
export const AI_TOOLS = [
|
|
41
|
+
{
|
|
42
|
+
id: 'cursor',
|
|
43
|
+
label: 'Cursor',
|
|
44
|
+
hint: '~/.cursor/mcp.json',
|
|
45
|
+
configPath: () => join(homedir(), '.cursor', 'mcp.json'),
|
|
46
|
+
format: 'json-mcpServers',
|
|
47
|
+
restartHint: 'Reload Cursor MCP servers (or restart Cursor).',
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: 'claude',
|
|
51
|
+
label: 'Claude Desktop',
|
|
52
|
+
hint: 'claude_desktop_config.json',
|
|
53
|
+
configPath: claudeDesktopConfigPath,
|
|
54
|
+
format: 'json-mcpServers',
|
|
55
|
+
restartHint: 'Quit and reopen Claude Desktop.',
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: 'claude-code',
|
|
59
|
+
label: 'Claude Code',
|
|
60
|
+
hint: '~/.claude.json (user scope)',
|
|
61
|
+
configPath: () => join(homedir(), '.claude.json'),
|
|
62
|
+
format: 'json-mcpServers',
|
|
63
|
+
restartHint: 'Restart Claude Code / open a new session.',
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: 'antigravity',
|
|
67
|
+
label: 'Antigravity',
|
|
68
|
+
hint: '~/.gemini/…/mcp_config.json',
|
|
69
|
+
configPath: antigravityConfigPath,
|
|
70
|
+
format: 'json-mcpServers',
|
|
71
|
+
restartHint: 'Refresh MCP servers in Antigravity (or restart).',
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
id: 'codex',
|
|
75
|
+
label: 'Codex',
|
|
76
|
+
hint: '~/.codex/config.toml',
|
|
77
|
+
configPath: () => join(homedir(), '.codex', 'config.toml'),
|
|
78
|
+
format: 'toml-mcp_servers',
|
|
79
|
+
restartHint: 'Restart Codex / open a new session.',
|
|
80
|
+
},
|
|
81
|
+
];
|
|
82
|
+
export function getTool(id) {
|
|
83
|
+
return AI_TOOLS.find((t) => t.id === id);
|
|
84
|
+
}
|
|
85
|
+
export function resolveToolIds(raw) {
|
|
86
|
+
const out = [];
|
|
87
|
+
for (const token of raw) {
|
|
88
|
+
const id = token.trim().toLowerCase();
|
|
89
|
+
if (!getTool(id)) {
|
|
90
|
+
throw new Error(`Unknown tool "${token}". Choose: ${AI_TOOLS.map((t) => t.id).join(', ')}`);
|
|
91
|
+
}
|
|
92
|
+
if (!out.includes(id))
|
|
93
|
+
out.push(id);
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
/** Merge techprufer into a Cursor/Claude/Antigravity-style JSON mcpServers file. */
|
|
98
|
+
export function mergeJsonMcpServers(existingRaw) {
|
|
99
|
+
let root = {};
|
|
100
|
+
if (existingRaw?.trim()) {
|
|
101
|
+
try {
|
|
102
|
+
const parsed = JSON.parse(existingRaw);
|
|
103
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
104
|
+
root = parsed;
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
throw new Error('Config root must be a JSON object');
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
catch (e) {
|
|
111
|
+
throw new Error(`Invalid JSON config: ${e instanceof Error ? e.message : String(e)}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const servers = root.mcpServers && typeof root.mcpServers === 'object' && !Array.isArray(root.mcpServers)
|
|
115
|
+
? { ...root.mcpServers }
|
|
116
|
+
: {};
|
|
117
|
+
servers[MCP_SERVER_KEY] = { ...MCP_SERVER_ENTRY };
|
|
118
|
+
root.mcpServers = servers;
|
|
119
|
+
return `${JSON.stringify(root, null, 2)}\n`;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Upsert `[mcp_servers.techprufer]` in a Codex config.toml without a TOML parser.
|
|
123
|
+
* Replaces an existing techprufer table; appends otherwise.
|
|
124
|
+
*/
|
|
125
|
+
export function mergeTomlMcpServers(existingRaw) {
|
|
126
|
+
const block = [
|
|
127
|
+
`[mcp_servers.${MCP_SERVER_KEY}]`,
|
|
128
|
+
'command = "npx"',
|
|
129
|
+
'args = ["-y", "techprufer-mcp"]',
|
|
130
|
+
'',
|
|
131
|
+
].join('\n');
|
|
132
|
+
const text = existingRaw ?? '';
|
|
133
|
+
// Match `[mcp_servers.techprufer]` through the next `[section]` or EOF.
|
|
134
|
+
const re = /\[mcp_servers\.techprufer\][\s\S]*?(?=\n\[|\s*$)/;
|
|
135
|
+
if (re.test(text)) {
|
|
136
|
+
return text.replace(re, block.trimEnd()).replace(/\n{3,}/g, '\n\n');
|
|
137
|
+
}
|
|
138
|
+
const trimmed = text.replace(/\s+$/, '');
|
|
139
|
+
if (!trimmed)
|
|
140
|
+
return `${block}`;
|
|
141
|
+
return `${trimmed}\n\n${block}`;
|
|
142
|
+
}
|
|
143
|
+
/** True if the tool's config file already has a techprufer entry. */
|
|
144
|
+
export function isToolConfigured(tool) {
|
|
145
|
+
const path = tool.configPath();
|
|
146
|
+
if (!existsSync(path))
|
|
147
|
+
return false;
|
|
148
|
+
try {
|
|
149
|
+
const raw = readFileSync(path, 'utf8');
|
|
150
|
+
if (tool.format === 'toml-mcp_servers') {
|
|
151
|
+
return /\[mcp_servers\.techprufer\]/.test(raw);
|
|
152
|
+
}
|
|
153
|
+
const parsed = JSON.parse(raw);
|
|
154
|
+
return Boolean(parsed?.mcpServers && MCP_SERVER_KEY in parsed.mcpServers);
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
export function installTool(tool) {
|
|
161
|
+
const path = tool.configPath();
|
|
162
|
+
try {
|
|
163
|
+
const existed = existsSync(path);
|
|
164
|
+
const prev = existed ? readFileSync(path, 'utf8') : null;
|
|
165
|
+
const next = tool.format === 'toml-mcp_servers'
|
|
166
|
+
? mergeTomlMcpServers(prev)
|
|
167
|
+
: mergeJsonMcpServers(prev);
|
|
168
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
169
|
+
writeFileSync(path, next, 'utf8');
|
|
170
|
+
return { ok: true, tool, path, created: !existed };
|
|
171
|
+
}
|
|
172
|
+
catch (e) {
|
|
173
|
+
return {
|
|
174
|
+
ok: false,
|
|
175
|
+
tool,
|
|
176
|
+
path,
|
|
177
|
+
error: e instanceof Error ? e.message : String(e),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
export function printSnippetForTool(tool) {
|
|
182
|
+
if (tool.format === 'toml-mcp_servers') {
|
|
183
|
+
return mergeTomlMcpServers(null).trimEnd();
|
|
184
|
+
}
|
|
185
|
+
return mergeJsonMcpServers(null).trimEnd();
|
|
186
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mergeJsonMcpServers, mergeTomlMcpServers, MCP_SERVER_KEY, MCP_SERVER_ENTRY, } from './tools.js';
|
|
4
|
+
import { parseSelection } from './setup.js';
|
|
5
|
+
describe('mergeJsonMcpServers', () => {
|
|
6
|
+
it('creates mcpServers when file is empty', () => {
|
|
7
|
+
const out = JSON.parse(mergeJsonMcpServers(null));
|
|
8
|
+
assert.deepEqual(out.mcpServers[MCP_SERVER_KEY], { ...MCP_SERVER_ENTRY });
|
|
9
|
+
});
|
|
10
|
+
it('preserves other servers', () => {
|
|
11
|
+
const prev = JSON.stringify({
|
|
12
|
+
mcpServers: {
|
|
13
|
+
other: { command: 'node', args: ['x.js'] },
|
|
14
|
+
},
|
|
15
|
+
});
|
|
16
|
+
const out = JSON.parse(mergeJsonMcpServers(prev));
|
|
17
|
+
assert.deepEqual(out.mcpServers.other, { command: 'node', args: ['x.js'] });
|
|
18
|
+
assert.deepEqual(out.mcpServers[MCP_SERVER_KEY], { ...MCP_SERVER_ENTRY });
|
|
19
|
+
});
|
|
20
|
+
it('overwrites an existing techprufer entry', () => {
|
|
21
|
+
const prev = JSON.stringify({
|
|
22
|
+
mcpServers: {
|
|
23
|
+
techprufer: { command: 'old', args: [] },
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
const out = JSON.parse(mergeJsonMcpServers(prev));
|
|
27
|
+
assert.equal(out.mcpServers.techprufer.command, 'npx');
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
describe('mergeTomlMcpServers', () => {
|
|
31
|
+
it('appends a techprufer table', () => {
|
|
32
|
+
const out = mergeTomlMcpServers('model = "o3"\n');
|
|
33
|
+
assert.match(out, /\[mcp_servers\.techprufer\]/);
|
|
34
|
+
assert.match(out, /command = "npx"/);
|
|
35
|
+
assert.match(out, /args = \["-y", "techprufer-mcp"\]/);
|
|
36
|
+
assert.match(out, /^model = "o3"/m);
|
|
37
|
+
});
|
|
38
|
+
it('replaces an existing techprufer table', () => {
|
|
39
|
+
const prev = [
|
|
40
|
+
'[mcp_servers.other]',
|
|
41
|
+
'command = "echo"',
|
|
42
|
+
'',
|
|
43
|
+
'[mcp_servers.techprufer]',
|
|
44
|
+
'command = "old"',
|
|
45
|
+
'args = ["x"]',
|
|
46
|
+
'',
|
|
47
|
+
'[features]',
|
|
48
|
+
'foo = true',
|
|
49
|
+
'',
|
|
50
|
+
].join('\n');
|
|
51
|
+
const out = mergeTomlMcpServers(prev);
|
|
52
|
+
assert.match(out, /command = "npx"/);
|
|
53
|
+
assert.doesNotMatch(out, /command = "old"/);
|
|
54
|
+
assert.match(out, /\[mcp_servers\.other\]/);
|
|
55
|
+
assert.match(out, /\[features\]/);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
describe('parseSelection', () => {
|
|
59
|
+
it('accepts numbers, ids, and all', () => {
|
|
60
|
+
assert.deepEqual(parseSelection('1,3'), ['cursor', 'claude-code']);
|
|
61
|
+
assert.deepEqual(parseSelection('codex'), ['codex']);
|
|
62
|
+
assert.deepEqual(parseSelection('all').length, 5);
|
|
63
|
+
});
|
|
64
|
+
it('rejects unknown tokens', () => {
|
|
65
|
+
assert.throws(() => parseSelection('windsurf'), /Unknown selection/);
|
|
66
|
+
});
|
|
67
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "techprufer-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "TechPrufer MCP — tailor resumes and build profiles from your AI tool",
|
|
5
5
|
"homepage": "https://techprufer.com",
|
|
6
6
|
"type": "module",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
],
|
|
32
32
|
"license": "MIT",
|
|
33
33
|
"scripts": {
|
|
34
|
-
"build": "tsc -p tsconfig.json"
|
|
34
|
+
"build": "tsc -p tsconfig.json",
|
|
35
|
+
"test": "npm run build && node --test dist/*.test.js"
|
|
35
36
|
}
|
|
36
37
|
}
|