techprufer-mcp 0.2.0 → 0.4.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 +29 -24
- package/dist/api.d.ts +3 -0
- package/dist/api.js +12 -0
- package/dist/cli.js +57 -11
- package/dist/clientHeader.d.ts +10 -0
- package/dist/clientHeader.js +23 -0
- package/dist/commands.d.ts +17 -0
- package/dist/commands.js +54 -0
- package/dist/login.d.ts +8 -2
- package/dist/login.js +17 -2
- package/dist/server.js +288 -17
- package/dist/setup.d.ts +10 -1
- package/dist/setup.js +57 -22
- package/dist/setup.nontty.test.d.ts +1 -0
- package/dist/setup.nontty.test.js +27 -0
- package/dist/tools.d.ts +7 -0
- package/dist/tools.js +62 -1
- package/dist/tools.test.js +54 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,37 +12,40 @@ One command does everything:
|
|
|
12
12
|
npx techprufer-mcp login
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
1. **Pick AI tools** — interactive picker (nothing selected by default; `Space` to toggle, `Enter` when ready).
|
|
16
|
+
2. **Device-code login** — opens your browser to approve the device. Agents can read `TECHPRUFER_LOGIN_URL` / `TECHPRUFER_LOGIN_CODE` from the output.
|
|
16
17
|
|
|
17
|
-
|
|
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.
|
|
18
|
+
Use `--no-setup` to skip the picker and login only. Restart your AI tools after login so they pick up the server.
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
## Commands
|
|
20
|
+
### Agent-driven login (non-TTY)
|
|
23
21
|
|
|
24
22
|
```bash
|
|
25
|
-
npx techprufer-mcp
|
|
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
|
|
23
|
+
npx -y techprufer-mcp login --tools=cursor,codex --timeout 300
|
|
29
24
|
```
|
|
30
25
|
|
|
31
|
-
|
|
26
|
+
If stdin is not a TTY and you omit `--tools`, config is skipped and login still runs (no crash).
|
|
27
|
+
|
|
28
|
+
## Commands
|
|
32
29
|
|
|
33
30
|
```bash
|
|
34
|
-
npx techprufer-mcp
|
|
35
|
-
npx techprufer-mcp
|
|
36
|
-
npx techprufer-mcp
|
|
31
|
+
npx techprufer-mcp # Start stdio MCP server (what AI tools run)
|
|
32
|
+
npx techprufer-mcp login # Pick tools, configure, then device-code login
|
|
33
|
+
npx techprufer-mcp status # Show saved credentials
|
|
37
34
|
```
|
|
38
35
|
|
|
39
36
|
### Login options
|
|
40
37
|
|
|
41
38
|
```bash
|
|
42
|
-
npx techprufer-mcp login --
|
|
43
|
-
npx techprufer-mcp login --
|
|
39
|
+
npx techprufer-mcp login --tools=cursor,claude-code,codex
|
|
40
|
+
npx techprufer-mcp login --all
|
|
41
|
+
npx techprufer-mcp login --print --tools=antigravity # print snippets only
|
|
42
|
+
npx techprufer-mcp login --name "Work laptop"
|
|
43
|
+
npx techprufer-mcp login --no-setup # login only
|
|
44
|
+
npx techprufer-mcp login --timeout 300 # agent-run safety
|
|
44
45
|
```
|
|
45
46
|
|
|
47
|
+
`setup` remains as a deprecated alias of the configure step.
|
|
48
|
+
|
|
46
49
|
## Supported tools
|
|
47
50
|
|
|
48
51
|
| Id | Tool | Config written |
|
|
@@ -51,9 +54,9 @@ npx techprufer-mcp login --no-setup # skip the tool picker
|
|
|
51
54
|
| `claude` | Claude Desktop | `claude_desktop_config.json` (per-OS path) |
|
|
52
55
|
| `claude-code` | Claude Code | `~/.claude.json` (user scope) |
|
|
53
56
|
| `antigravity` | Antigravity | `~/.gemini/…/mcp_config.json` |
|
|
54
|
-
| `codex` | Codex | `~/.codex/config.toml` |
|
|
57
|
+
| `codex` | Codex | `~/.codex/config.toml` + `~/.codex/prompts/techprufer-*.md` |
|
|
55
58
|
|
|
56
|
-
Setup merges into existing config files — other MCP servers are preserved. The server key **must** be `techprufer
|
|
59
|
+
Setup merges into existing config files — other MCP servers are preserved. The server key **must** be `techprufer`.
|
|
57
60
|
|
|
58
61
|
## Manual JSON (any mcpServers client)
|
|
59
62
|
|
|
@@ -68,14 +71,16 @@ Setup merges into existing config files — other MCP servers are preserved. The
|
|
|
68
71
|
}
|
|
69
72
|
```
|
|
70
73
|
|
|
71
|
-
## Slash commands
|
|
74
|
+
## Slash commands (per tool)
|
|
72
75
|
|
|
73
|
-
|
|
76
|
+
| Tool | Tailor | Build profile |
|
|
77
|
+
|------|--------|---------------|
|
|
78
|
+
| Cursor | `/techprufer/tailor` | `/techprufer/build-profile` |
|
|
79
|
+
| Claude Code | `/mcp__techprufer__tailor` | `/mcp__techprufer__build-profile` |
|
|
80
|
+
| Codex | `/techprufer-tailor` | `/techprufer-build-profile` |
|
|
81
|
+
| Claude Desktop | `+` menu → techprufer prompts | same |
|
|
74
82
|
|
|
75
|
-
|
|
76
|
-
|---------|-----|
|
|
77
|
-
| `/techprufer/tailor` | Process queued tailor jobs from the site |
|
|
78
|
-
| `/techprufer/build-profile` | Process queued PDF→profile jobs, or build from a local resume |
|
|
83
|
+
**Tailor (one command)** handles: pending jobs (process or skip) → company picker (tracked apps, incl. untailored) → enqueue + process → show job link + PDF → optional attach to other roles at the same company → edit/delete when asked.
|
|
79
84
|
|
|
80
85
|
> Cursor may show `/user-techprufer/…` for user-scoped installs; same commands.
|
|
81
86
|
|
package/dist/api.d.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { type ClientInfo } from './clientHeader.js';
|
|
1
2
|
export declare class ApiError extends Error {
|
|
2
3
|
status: number;
|
|
3
4
|
body: unknown;
|
|
4
5
|
constructor(status: number, body: unknown, message?: string);
|
|
5
6
|
}
|
|
7
|
+
export declare function setActiveClientInfo(info: ClientInfo | null): void;
|
|
8
|
+
export declare function getActiveClientInfo(): ClientInfo | null;
|
|
6
9
|
export declare function apiFetch<T>(path: string, init?: RequestInit & {
|
|
7
10
|
token?: string | null;
|
|
8
11
|
baseUrl?: string;
|
package/dist/api.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getApiUrl, loadCredentials } from './config.js';
|
|
2
|
+
import { formatClientHeader, TP_CLIENT_HEADER } from './clientHeader.js';
|
|
2
3
|
export class ApiError extends Error {
|
|
3
4
|
status;
|
|
4
5
|
body;
|
|
@@ -19,6 +20,14 @@ function formatFetchError(err) {
|
|
|
19
20
|
return cause;
|
|
20
21
|
return err.message;
|
|
21
22
|
}
|
|
23
|
+
/** Set by the stdio server after the MCP initialize handshake. */
|
|
24
|
+
let activeClientInfo = null;
|
|
25
|
+
export function setActiveClientInfo(info) {
|
|
26
|
+
activeClientInfo = info;
|
|
27
|
+
}
|
|
28
|
+
export function getActiveClientInfo() {
|
|
29
|
+
return activeClientInfo;
|
|
30
|
+
}
|
|
22
31
|
export async function apiFetch(path, init = {}) {
|
|
23
32
|
const creds = loadCredentials();
|
|
24
33
|
const token = init.token === undefined ? creds?.token : init.token;
|
|
@@ -36,6 +45,9 @@ export async function apiFetch(path, init = {}) {
|
|
|
36
45
|
}
|
|
37
46
|
if (token)
|
|
38
47
|
headers.set('Authorization', `Bearer ${token}`);
|
|
48
|
+
const clientHeader = formatClientHeader(activeClientInfo);
|
|
49
|
+
if (clientHeader)
|
|
50
|
+
headers.set(TP_CLIENT_HEADER, clientHeader);
|
|
39
51
|
let res;
|
|
40
52
|
try {
|
|
41
53
|
res = await fetch(`${base}${path}`, { ...fetchInit, headers });
|
package/dist/cli.js
CHANGED
|
@@ -14,20 +14,65 @@ function parseFlagValue(args, name) {
|
|
|
14
14
|
function hasFlag(args, name) {
|
|
15
15
|
return args.includes(name);
|
|
16
16
|
}
|
|
17
|
+
function assertSupportedNode() {
|
|
18
|
+
const major = Number.parseInt(process.versions.node.split('.')[0] ?? '', 10);
|
|
19
|
+
if (Number.isFinite(major) && major >= 18)
|
|
20
|
+
return;
|
|
21
|
+
console.error(`techprufer-mcp requires Node.js 18+ (got v${process.versions.node}).\n` +
|
|
22
|
+
`Headers/fetch are not available on older Node — upgrade Node, then retry:\n` +
|
|
23
|
+
` npx techprufer-mcp@latest login`);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
function parseTimeoutSec(args) {
|
|
27
|
+
const raw = parseFlagValue(args, '--timeout');
|
|
28
|
+
if (!raw)
|
|
29
|
+
return undefined;
|
|
30
|
+
const n = Number(raw);
|
|
31
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
32
|
+
console.error('--timeout must be a positive number of seconds');
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
return Math.floor(n);
|
|
36
|
+
}
|
|
37
|
+
function parseToolsFlag(args) {
|
|
38
|
+
const toolsRaw = parseFlagValue(args, '--tools');
|
|
39
|
+
if (!toolsRaw)
|
|
40
|
+
return undefined;
|
|
41
|
+
return toolsRaw.split(',').map((s) => s.trim()).filter(Boolean);
|
|
42
|
+
}
|
|
17
43
|
async function main() {
|
|
44
|
+
assertSupportedNode();
|
|
18
45
|
const args = process.argv.slice(2);
|
|
19
46
|
const cmd = args[0];
|
|
20
47
|
if (cmd === 'login') {
|
|
21
48
|
const nameIdx = args.indexOf('--name');
|
|
22
49
|
const deviceName = nameIdx >= 0 ? args[nameIdx + 1] : undefined;
|
|
23
50
|
const skipSetup = hasFlag(args, '--no-setup');
|
|
51
|
+
const timeoutSec = parseTimeoutSec(args);
|
|
52
|
+
const tools = parseToolsFlag(args);
|
|
53
|
+
let configuredIds = [];
|
|
24
54
|
if (!skipSetup) {
|
|
25
|
-
await runSetup({
|
|
55
|
+
const outcome = await runSetup({
|
|
56
|
+
beforeLogin: true,
|
|
57
|
+
allowSkip: true,
|
|
58
|
+
all: hasFlag(args, '--all'),
|
|
59
|
+
tools,
|
|
60
|
+
printOnly: hasFlag(args, '--print'),
|
|
61
|
+
yes: hasFlag(args, '-y') || hasFlag(args, '--yes'),
|
|
62
|
+
});
|
|
63
|
+
configuredIds = outcome.configuredIds;
|
|
64
|
+
if (hasFlag(args, '--print'))
|
|
65
|
+
return;
|
|
26
66
|
console.error('');
|
|
27
67
|
}
|
|
28
|
-
await runLogin(deviceName,
|
|
68
|
+
await runLogin(deviceName, {
|
|
69
|
+
stepLabel: skipSetup ? undefined : 'login',
|
|
70
|
+
timeoutSec,
|
|
71
|
+
configuredIds,
|
|
72
|
+
});
|
|
29
73
|
return;
|
|
30
74
|
}
|
|
75
|
+
// Deprecated alias — prefer `login` (which includes tool config).
|
|
31
76
|
if (cmd === 'setup') {
|
|
32
77
|
const toolsRaw = parseFlagValue(args, '--tools');
|
|
33
78
|
await runSetup({
|
|
@@ -57,25 +102,26 @@ async function main() {
|
|
|
57
102
|
|
|
58
103
|
Usage:
|
|
59
104
|
npx techprufer-mcp Start stdio MCP server (for AI tools)
|
|
60
|
-
npx techprufer-mcp login Pick AI tools
|
|
61
|
-
npx techprufer-mcp setup Configure AI tools only (no login)
|
|
105
|
+
npx techprufer-mcp login Pick AI tools, configure, then device-code login
|
|
62
106
|
npx techprufer-mcp status Show saved credentials
|
|
63
107
|
|
|
64
|
-
|
|
65
|
-
--tools=id,id
|
|
108
|
+
Login options:
|
|
109
|
+
--tools=id,id Configure these tools (non-interactive; comma-separated)
|
|
66
110
|
--all Configure every supported tool
|
|
67
|
-
--print Print config snippets only (no file writes)
|
|
111
|
+
--print Print config snippets only (no file writes / no login)
|
|
68
112
|
-y, --yes Require --tools or --all (no prompts)
|
|
113
|
+
--name <label> Device name shown on the site
|
|
114
|
+
--no-setup Skip the tool picker; login only
|
|
115
|
+
--timeout <sec> Fail login poll after N seconds (agent-run safety)
|
|
69
116
|
|
|
70
117
|
Supported tools:
|
|
71
118
|
${listToolsForHelp()}
|
|
72
119
|
|
|
73
|
-
Login options:
|
|
74
|
-
--name <label> Device name shown on the site
|
|
75
|
-
--no-setup Skip the tool picker; login only
|
|
76
|
-
|
|
77
120
|
Env:
|
|
78
121
|
TECHPRUFER_API_URL Override API base (default https://techprufer.com)
|
|
122
|
+
|
|
123
|
+
Deprecated:
|
|
124
|
+
npx techprufer-mcp setup Alias — use login instead
|
|
79
125
|
`);
|
|
80
126
|
return;
|
|
81
127
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Header the stdio server sends so the site can label agent_devices by IDE. */
|
|
2
|
+
export declare const TP_CLIENT_HEADER = "X-TP-Client";
|
|
3
|
+
export interface ClientInfo {
|
|
4
|
+
name: string;
|
|
5
|
+
version?: string;
|
|
6
|
+
}
|
|
7
|
+
/** Format `name/version` for the header (version optional). */
|
|
8
|
+
export declare function formatClientHeader(info: ClientInfo | null | undefined): string | null;
|
|
9
|
+
/** Parse `name/version` from the request header. */
|
|
10
|
+
export declare function parseClientHeader(raw: string | null | undefined): ClientInfo | null;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Header the stdio server sends so the site can label agent_devices by IDE. */
|
|
2
|
+
export const TP_CLIENT_HEADER = 'X-TP-Client';
|
|
3
|
+
/** Format `name/version` for the header (version optional). */
|
|
4
|
+
export function formatClientHeader(info) {
|
|
5
|
+
if (!info?.name?.trim())
|
|
6
|
+
return null;
|
|
7
|
+
const name = info.name.trim().slice(0, 64);
|
|
8
|
+
const version = info.version?.trim().slice(0, 64);
|
|
9
|
+
return version ? `${name}/${version}` : name;
|
|
10
|
+
}
|
|
11
|
+
/** Parse `name/version` from the request header. */
|
|
12
|
+
export function parseClientHeader(raw) {
|
|
13
|
+
if (!raw?.trim())
|
|
14
|
+
return null;
|
|
15
|
+
const value = raw.trim().slice(0, 130);
|
|
16
|
+
const slash = value.indexOf('/');
|
|
17
|
+
if (slash <= 0)
|
|
18
|
+
return { name: value };
|
|
19
|
+
return {
|
|
20
|
+
name: value.slice(0, slash),
|
|
21
|
+
version: value.slice(slash + 1) || undefined,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { AiToolId } from './tools.js';
|
|
2
|
+
/** Canonical Cursor / generic slash paths (server key must be `techprufer`). */
|
|
3
|
+
export declare const MCP_CMD_TAILOR = "/techprufer/tailor";
|
|
4
|
+
export declare const MCP_CMD_BUILD_PROFILE = "/techprufer/build-profile";
|
|
5
|
+
export interface ToolCommands {
|
|
6
|
+
tailor: string;
|
|
7
|
+
buildProfile: string;
|
|
8
|
+
/** Short note when the tool has no typed slash command. */
|
|
9
|
+
hint?: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* How to invoke TechPrufer prompts in each client after MCP is connected.
|
|
13
|
+
* Codex uses custom prompt files written during setup (not MCP prompts).
|
|
14
|
+
* Edit/delete/list flows live under tailor — there is no separate edit command.
|
|
15
|
+
*/
|
|
16
|
+
export declare const TOOL_COMMANDS: Record<AiToolId, ToolCommands>;
|
|
17
|
+
export declare function formatCommandsForTools(ids: AiToolId[]): string;
|
package/dist/commands.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** Canonical Cursor / generic slash paths (server key must be `techprufer`). */
|
|
2
|
+
export const MCP_CMD_TAILOR = '/techprufer/tailor';
|
|
3
|
+
export const MCP_CMD_BUILD_PROFILE = '/techprufer/build-profile';
|
|
4
|
+
/**
|
|
5
|
+
* How to invoke TechPrufer prompts in each client after MCP is connected.
|
|
6
|
+
* Codex uses custom prompt files written during setup (not MCP prompts).
|
|
7
|
+
* Edit/delete/list flows live under tailor — there is no separate edit command.
|
|
8
|
+
*/
|
|
9
|
+
export const TOOL_COMMANDS = {
|
|
10
|
+
cursor: {
|
|
11
|
+
tailor: MCP_CMD_TAILOR,
|
|
12
|
+
buildProfile: MCP_CMD_BUILD_PROFILE,
|
|
13
|
+
},
|
|
14
|
+
'claude-code': {
|
|
15
|
+
tailor: '/mcp__techprufer__tailor',
|
|
16
|
+
buildProfile: '/mcp__techprufer__build-profile',
|
|
17
|
+
},
|
|
18
|
+
claude: {
|
|
19
|
+
tailor: 'techprufer → tailor',
|
|
20
|
+
buildProfile: 'techprufer → build-profile',
|
|
21
|
+
hint: 'Open the + attachments menu and pick the techprufer prompts.',
|
|
22
|
+
},
|
|
23
|
+
codex: {
|
|
24
|
+
tailor: '/techprufer-tailor',
|
|
25
|
+
buildProfile: '/techprufer-build-profile',
|
|
26
|
+
},
|
|
27
|
+
antigravity: {
|
|
28
|
+
tailor: MCP_CMD_TAILOR,
|
|
29
|
+
buildProfile: MCP_CMD_BUILD_PROFILE,
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
export function formatCommandsForTools(ids) {
|
|
33
|
+
if (ids.length === 0) {
|
|
34
|
+
return [
|
|
35
|
+
'After restarting your AI tool, run:',
|
|
36
|
+
` ${MCP_CMD_TAILOR} (pending jobs, edit/delete, or pick a company resume)`,
|
|
37
|
+
` ${MCP_CMD_BUILD_PROFILE}`,
|
|
38
|
+
].join('\n');
|
|
39
|
+
}
|
|
40
|
+
const lines = ['After restarting your AI tool(s), use:'];
|
|
41
|
+
for (const id of ids) {
|
|
42
|
+
const cmds = TOOL_COMMANDS[id];
|
|
43
|
+
const label = id === 'claude-code' ? 'Claude Code' : id === 'claude' ? 'Claude Desktop' : id;
|
|
44
|
+
lines.push(` ${label}:`);
|
|
45
|
+
if (cmds.hint) {
|
|
46
|
+
lines.push(` ${cmds.hint}`);
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
lines.push(` ${cmds.tailor}`);
|
|
50
|
+
lines.push(` ${cmds.buildProfile}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return lines.join('\n');
|
|
54
|
+
}
|
package/dist/login.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AiToolId } from './tools.js';
|
|
1
2
|
/** Exported for tests — resolve platform-specific open command. */
|
|
2
3
|
export declare function browserOpenArgs(url: string, os?: NodeJS.Platform): {
|
|
3
4
|
command: string;
|
|
@@ -6,6 +7,11 @@ export declare function browserOpenArgs(url: string, os?: NodeJS.Platform): {
|
|
|
6
7
|
windowsHide?: boolean;
|
|
7
8
|
};
|
|
8
9
|
};
|
|
9
|
-
export
|
|
10
|
+
export interface LoginOptions {
|
|
10
11
|
stepLabel?: string;
|
|
11
|
-
|
|
12
|
+
/** Hard deadline in seconds (agent-run safety). Default: device-code expiry. */
|
|
13
|
+
timeoutSec?: number;
|
|
14
|
+
/** Tools configured in this session — used for post-login slash-command hints. */
|
|
15
|
+
configuredIds?: AiToolId[];
|
|
16
|
+
}
|
|
17
|
+
export declare function runLogin(deviceName?: string, opts?: LoginOptions): Promise<void>;
|
package/dist/login.js
CHANGED
|
@@ -2,6 +2,7 @@ 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
|
+
import { formatCommandsForTools } from './commands.js';
|
|
5
6
|
/** Exported for tests — resolve platform-specific open command. */
|
|
6
7
|
export function browserOpenArgs(url, os = platform()) {
|
|
7
8
|
if (os === 'darwin')
|
|
@@ -61,6 +62,8 @@ function alignVerifyUrl(verifyUrl, apiUrl) {
|
|
|
61
62
|
export async function runLogin(deviceName, opts = {}) {
|
|
62
63
|
const apiUrl = getApiUrl();
|
|
63
64
|
console.error(`TechPrufer MCP${opts.stepLabel ? ` — ${opts.stepLabel}` : ''} — logging in against ${apiUrl}`);
|
|
65
|
+
console.error('');
|
|
66
|
+
console.error('Login phase: open the link below and approve this device in your browser.');
|
|
64
67
|
const start = await apiFetch('/api/agent/device/start', {
|
|
65
68
|
method: 'POST',
|
|
66
69
|
token: null,
|
|
@@ -68,6 +71,9 @@ export async function runLogin(deviceName, opts = {}) {
|
|
|
68
71
|
body: JSON.stringify({ deviceName: deviceName || undefined }),
|
|
69
72
|
});
|
|
70
73
|
const verifyUrl = alignVerifyUrl(start.verifyUrl, apiUrl);
|
|
74
|
+
// Machine-readable lines for agents driving the terminal.
|
|
75
|
+
console.error(`TECHPRUFER_LOGIN_URL=${verifyUrl}`);
|
|
76
|
+
console.error(`TECHPRUFER_LOGIN_CODE=${start.userCode}`);
|
|
71
77
|
console.error('');
|
|
72
78
|
console.error(` Code: ${start.userCode}`);
|
|
73
79
|
console.error(` Open: ${verifyUrl}`);
|
|
@@ -76,7 +82,11 @@ export async function runLogin(deviceName, opts = {}) {
|
|
|
76
82
|
console.error('Waiting for approval (Ctrl+C to cancel)…');
|
|
77
83
|
openBrowser(verifyUrl);
|
|
78
84
|
const intervalMs = Math.max(3, start.interval || 5) * 1000;
|
|
79
|
-
const
|
|
85
|
+
const deviceDeadline = Date.now() + start.expiresIn * 1000;
|
|
86
|
+
const timeoutDeadline = opts.timeoutSec && opts.timeoutSec > 0
|
|
87
|
+
? Date.now() + opts.timeoutSec * 1000
|
|
88
|
+
: deviceDeadline;
|
|
89
|
+
const deadline = Math.min(deviceDeadline, timeoutDeadline);
|
|
80
90
|
const startedAt = Date.now();
|
|
81
91
|
let remindedManualOpen = false;
|
|
82
92
|
while (Date.now() < deadline) {
|
|
@@ -88,6 +98,8 @@ export async function runLogin(deviceName, opts = {}) {
|
|
|
88
98
|
console.error('Still waiting. If no browser window opened, visit:');
|
|
89
99
|
console.error(` ${verifyUrl}`);
|
|
90
100
|
console.error(` and enter code ${start.userCode}`);
|
|
101
|
+
console.error(`TECHPRUFER_LOGIN_URL=${verifyUrl}`);
|
|
102
|
+
console.error(`TECHPRUFER_LOGIN_CODE=${start.userCode}`);
|
|
91
103
|
console.error('');
|
|
92
104
|
}
|
|
93
105
|
let poll;
|
|
@@ -115,7 +127,10 @@ export async function runLogin(deviceName, opts = {}) {
|
|
|
115
127
|
apiUrl,
|
|
116
128
|
deviceId: poll.deviceId,
|
|
117
129
|
});
|
|
118
|
-
console.error('
|
|
130
|
+
console.error('');
|
|
131
|
+
console.error('Logged in. Credentials saved.');
|
|
132
|
+
console.error('');
|
|
133
|
+
console.error(formatCommandsForTools(opts.configuredIds ?? []));
|
|
119
134
|
return;
|
|
120
135
|
}
|
|
121
136
|
}
|
package/dist/server.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
3
|
import { z } from 'zod';
|
|
4
|
-
import { apiFetch, ApiError } from './api.js';
|
|
4
|
+
import { apiFetch, ApiError, setActiveClientInfo } from './api.js';
|
|
5
5
|
import { brandIcons } from './brand.js';
|
|
6
6
|
import { loadCredentials, credentialsFilePath } from './config.js';
|
|
7
7
|
import { runLogin } from './login.js';
|
|
@@ -21,7 +21,7 @@ export function createServer() {
|
|
|
21
21
|
const server = new McpServer({
|
|
22
22
|
name: 'techprufer',
|
|
23
23
|
title: 'TechPrufer',
|
|
24
|
-
version: '0.
|
|
24
|
+
version: '0.4.0',
|
|
25
25
|
description: 'Tailor resumes and build profiles with your AI tool — no Gemini API key.',
|
|
26
26
|
websiteUrl: 'https://techprufer.com',
|
|
27
27
|
icons: brandIcons(),
|
|
@@ -99,32 +99,52 @@ export function createServer() {
|
|
|
99
99
|
}
|
|
100
100
|
});
|
|
101
101
|
// Slash path in Cursor: /techprufer/tailor (user installs may show /user-techprufer/tailor)
|
|
102
|
-
server.prompt('tailor', 'TechPrufer — tailor
|
|
102
|
+
server.prompt('tailor', 'TechPrufer — pending jobs, company picker, tailor, edit/delete, attach siblings (one command)', {
|
|
103
103
|
jobId: z
|
|
104
104
|
.string()
|
|
105
105
|
.optional()
|
|
106
|
-
.describe('Optional
|
|
107
|
-
|
|
106
|
+
.describe('Optional queued job id — process that job only'),
|
|
107
|
+
applicationId: z
|
|
108
|
+
.string()
|
|
109
|
+
.optional()
|
|
110
|
+
.describe('Optional application id — skip picker (tailor or edit that app)'),
|
|
111
|
+
profileId: z
|
|
112
|
+
.string()
|
|
113
|
+
.optional()
|
|
114
|
+
.describe('Optional base profile id — skip picker (edit that base resume)'),
|
|
115
|
+
}, async ({ jobId, applicationId, profileId }) => ({
|
|
108
116
|
messages: [
|
|
109
117
|
{
|
|
110
118
|
role: 'user',
|
|
111
119
|
content: {
|
|
112
120
|
type: 'text',
|
|
113
121
|
text: [
|
|
114
|
-
'
|
|
115
|
-
jobId
|
|
116
|
-
? `Focus on jobId=${jobId}.`
|
|
117
|
-
: 'Fetch pending jobs and process them ONE AT A TIME in order.',
|
|
122
|
+
'TechPrufer MCP — all resume control under /techprufer/tailor.',
|
|
118
123
|
'',
|
|
119
|
-
'
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
124
|
+
'### Flow',
|
|
125
|
+
jobId
|
|
126
|
+
? `1) Process jobId=${jobId}: get_tailor_job → execute → submit_tailor_result.`
|
|
127
|
+
: '1) Call list_pending_tailor_jobs. Show company/role for each. Ask: process them now, or SKIP to company picker? If process: ONE AT A TIME (get_tailor_job → execute → submit_tailor_result; fix once on correctiveMessage).',
|
|
128
|
+
applicationId
|
|
129
|
+
? `2) applicationId=${applicationId}: enqueue_tailor_job({ applicationId }) then process, OR get_resume(kind=tailored) to edit/delete.`
|
|
130
|
+
: '2) If user gave an application id: enqueue_tailor_job or edit/delete that app.',
|
|
131
|
+
profileId
|
|
132
|
+
? `3) profileId=${profileId}: get_resume(kind=base) → update_resume / delete_resume.`
|
|
133
|
+
: '3) If user gave a base profile id: edit/delete that base resume.',
|
|
134
|
+
'4) Company picker (when skipping pending / no id):',
|
|
135
|
+
' - list_tracked_companies (top 10, paginate, or query= typed name). Show total / withTailored / withoutTailored.',
|
|
136
|
+
' - Ask which company (or they type the name).',
|
|
137
|
+
' - list_applications_at_company(company, onlyUntailored=true preferred for new tailor). If multiple roles, list roles and ask which.',
|
|
138
|
+
' - enqueue_tailor_job({ applicationId }) → process like step 1.',
|
|
139
|
+
' - Or edit/delete via get_resume / update_resume / delete_resume.',
|
|
140
|
+
'5) AFTER a successful submit_tailor_result that returns siblingCount > 0:',
|
|
141
|
+
' - Tell the user: this company has N other tracked roles — attach this resume to them?',
|
|
142
|
+
' - If yes: preview_company_siblings then attach_tailored_to_siblings({ sourceApplicationId, sessionId }).',
|
|
143
|
+
' - If no: leave siblings alone.',
|
|
144
|
+
'6) Always show the user from submit result: jdUrl (job link), resumePdfUrl, dashboardUrl.',
|
|
145
|
+
' On that job page the Chrome extension can inject or download the PDF when resumePdfUrl is set.',
|
|
126
146
|
'',
|
|
127
|
-
'
|
|
147
|
+
'Zero hallucination. Complete JSON only when submitting or saving.',
|
|
128
148
|
].join('\n'),
|
|
129
149
|
},
|
|
130
150
|
},
|
|
@@ -239,6 +259,247 @@ export function createServer() {
|
|
|
239
259
|
return textResult(`Submit failed: ${msg}`, true);
|
|
240
260
|
}
|
|
241
261
|
});
|
|
262
|
+
server.tool('list_resumes', 'List TechPrufer resumes for MCP edit/tailor picker. Paginated. ' +
|
|
263
|
+
'kind=base|tailored|all. For tailored, default is AI-tailored only; ' +
|
|
264
|
+
'pass includeAttachedOrEdited=true to also show attach/edit snapshots. ' +
|
|
265
|
+
'Filter with company and/or role.', {
|
|
266
|
+
kind: z
|
|
267
|
+
.enum(['base', 'tailored', 'all'])
|
|
268
|
+
.optional()
|
|
269
|
+
.describe('Default all'),
|
|
270
|
+
company: z.string().optional(),
|
|
271
|
+
role: z.string().optional(),
|
|
272
|
+
includeAttachedOrEdited: z
|
|
273
|
+
.boolean()
|
|
274
|
+
.optional()
|
|
275
|
+
.describe('Include non-AI tailored snapshots (attach/edit). Default false.'),
|
|
276
|
+
page: z.number().int().min(1).optional(),
|
|
277
|
+
pageSize: z.number().int().min(1).max(50).optional(),
|
|
278
|
+
}, async ({ kind, company, role, includeAttachedOrEdited, page, pageSize }) => {
|
|
279
|
+
const token = await ensureToken();
|
|
280
|
+
if (!token)
|
|
281
|
+
return textResult(requireAuthMessage(), true);
|
|
282
|
+
try {
|
|
283
|
+
const qs = new URLSearchParams();
|
|
284
|
+
qs.set('kind', kind ?? 'all');
|
|
285
|
+
if (company)
|
|
286
|
+
qs.set('company', company);
|
|
287
|
+
if (role)
|
|
288
|
+
qs.set('role', role);
|
|
289
|
+
if (includeAttachedOrEdited)
|
|
290
|
+
qs.set('includeAttachedOrEdited', 'true');
|
|
291
|
+
if (page)
|
|
292
|
+
qs.set('page', String(page));
|
|
293
|
+
if (pageSize)
|
|
294
|
+
qs.set('pageSize', String(pageSize));
|
|
295
|
+
const data = await apiFetch(`/api/agent/resumes?${qs.toString()}`);
|
|
296
|
+
return textResult(JSON.stringify(data, null, 2));
|
|
297
|
+
}
|
|
298
|
+
catch (e) {
|
|
299
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
300
|
+
return textResult(`Failed to list resumes: ${msg}`, true);
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
server.tool('list_resume_companies', 'List companies (and roles) that have tailored resumes — use before asking which company to edit.', {
|
|
304
|
+
includeAttachedOrEdited: z.boolean().optional(),
|
|
305
|
+
}, async ({ includeAttachedOrEdited }) => {
|
|
306
|
+
const token = await ensureToken();
|
|
307
|
+
if (!token)
|
|
308
|
+
return textResult(requireAuthMessage(), true);
|
|
309
|
+
try {
|
|
310
|
+
const qs = includeAttachedOrEdited ? '?includeAttachedOrEdited=true' : '';
|
|
311
|
+
const data = await apiFetch(`/api/agent/resumes/companies${qs}`);
|
|
312
|
+
return textResult(JSON.stringify(data, null, 2));
|
|
313
|
+
}
|
|
314
|
+
catch (e) {
|
|
315
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
316
|
+
return textResult(`Failed to list companies: ${msg}`, true);
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
server.tool('get_resume', 'Load one resume for editing. kind=base (master profile) or tailored (application snapshot).', {
|
|
320
|
+
kind: z.enum(['base', 'tailored']),
|
|
321
|
+
id: z.string().describe('Profile id or application id'),
|
|
322
|
+
}, async ({ kind, id }) => {
|
|
323
|
+
const token = await ensureToken();
|
|
324
|
+
if (!token)
|
|
325
|
+
return textResult(requireAuthMessage(), true);
|
|
326
|
+
try {
|
|
327
|
+
const data = await apiFetch(`/api/agent/resumes/${encodeURIComponent(id)}?kind=${kind}`);
|
|
328
|
+
return textResult(JSON.stringify(data, null, 2));
|
|
329
|
+
}
|
|
330
|
+
catch (e) {
|
|
331
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
332
|
+
return textResult(`Failed to get resume: ${msg}`, true);
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
server.tool('update_resume', 'Save edits to a base profile or tailored application resume. Pass the full CPM profile JSON.', {
|
|
336
|
+
kind: z.enum(['base', 'tailored']),
|
|
337
|
+
id: z.string(),
|
|
338
|
+
profileJson: z.union([z.record(z.string(), z.unknown()), z.string()]),
|
|
339
|
+
name: z.string().optional().describe('Optional new name for base profiles only'),
|
|
340
|
+
}, async ({ kind, id, profileJson, name }) => {
|
|
341
|
+
const token = await ensureToken();
|
|
342
|
+
if (!token)
|
|
343
|
+
return textResult(requireAuthMessage(), true);
|
|
344
|
+
try {
|
|
345
|
+
const parsed = typeof profileJson === 'string' ? JSON.parse(profileJson) : profileJson;
|
|
346
|
+
const result = await apiFetch(`/api/agent/resumes/${encodeURIComponent(id)}`, {
|
|
347
|
+
method: 'PATCH',
|
|
348
|
+
body: JSON.stringify({ kind, profileJson: parsed, name }),
|
|
349
|
+
});
|
|
350
|
+
return textResult(JSON.stringify(result, null, 2));
|
|
351
|
+
}
|
|
352
|
+
catch (e) {
|
|
353
|
+
if (e instanceof ApiError && (e.status === 400 || e.status === 422)) {
|
|
354
|
+
return textResult(JSON.stringify(e.body, null, 2), true);
|
|
355
|
+
}
|
|
356
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
357
|
+
return textResult(`Update failed: ${msg}`, true);
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
server.tool('delete_resume', 'Delete a base profile, or delete the whole application for a tailored resume. Always confirm with the user first.', {
|
|
361
|
+
kind: z.enum(['base', 'tailored']),
|
|
362
|
+
id: z.string(),
|
|
363
|
+
}, async ({ kind, id }) => {
|
|
364
|
+
const token = await ensureToken();
|
|
365
|
+
if (!token)
|
|
366
|
+
return textResult(requireAuthMessage(), true);
|
|
367
|
+
try {
|
|
368
|
+
const result = await apiFetch(`/api/agent/resumes/${encodeURIComponent(id)}?kind=${kind}`, { method: 'DELETE' });
|
|
369
|
+
return textResult(JSON.stringify(result, null, 2));
|
|
370
|
+
}
|
|
371
|
+
catch (e) {
|
|
372
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
373
|
+
return textResult(`Delete failed: ${msg}`, true);
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
server.tool('enqueue_tailor_job', 'Queue a tailor job for MCP. Prefer applicationId when the user named a job/app — JD is loaded from the application. ' +
|
|
377
|
+
'Otherwise pass jdText + profileId (or sourceApplicationId as the source resume).', {
|
|
378
|
+
applicationId: z.string().optional(),
|
|
379
|
+
profileId: z.string().optional(),
|
|
380
|
+
sourceApplicationId: z.string().optional(),
|
|
381
|
+
jdText: z.string().optional(),
|
|
382
|
+
}, async ({ applicationId, profileId, sourceApplicationId, jdText }) => {
|
|
383
|
+
const token = await ensureToken();
|
|
384
|
+
if (!token)
|
|
385
|
+
return textResult(requireAuthMessage(), true);
|
|
386
|
+
try {
|
|
387
|
+
const result = await apiFetch('/api/agent/jobs', {
|
|
388
|
+
method: 'POST',
|
|
389
|
+
body: JSON.stringify({
|
|
390
|
+
applicationId,
|
|
391
|
+
profileId,
|
|
392
|
+
sourceApplicationId,
|
|
393
|
+
jdText,
|
|
394
|
+
}),
|
|
395
|
+
});
|
|
396
|
+
return textResult(JSON.stringify(result, null, 2));
|
|
397
|
+
}
|
|
398
|
+
catch (e) {
|
|
399
|
+
if (e instanceof ApiError && e.status === 402) {
|
|
400
|
+
return textResult(JSON.stringify(e.body, null, 2), true);
|
|
401
|
+
}
|
|
402
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
403
|
+
return textResult(`Enqueue failed: ${msg}`, true);
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
server.tool('list_tracked_companies', 'List companies from tracked applications (includes untailored). Top 10 by default; paginate or pass query= for a name filter. Use after skipping pending jobs.', {
|
|
407
|
+
page: z.number().int().min(1).optional(),
|
|
408
|
+
pageSize: z.number().int().min(1).max(50).optional(),
|
|
409
|
+
query: z.string().optional().describe('Company name filter (substring)'),
|
|
410
|
+
}, async ({ page, pageSize, query }) => {
|
|
411
|
+
const token = await ensureToken();
|
|
412
|
+
if (!token)
|
|
413
|
+
return textResult(requireAuthMessage(), true);
|
|
414
|
+
try {
|
|
415
|
+
const qs = new URLSearchParams();
|
|
416
|
+
if (page)
|
|
417
|
+
qs.set('page', String(page));
|
|
418
|
+
if (pageSize)
|
|
419
|
+
qs.set('pageSize', String(pageSize));
|
|
420
|
+
if (query)
|
|
421
|
+
qs.set('query', query);
|
|
422
|
+
const q = qs.toString();
|
|
423
|
+
const data = await apiFetch(`/api/agent/applications/companies${q ? `?${q}` : ''}`);
|
|
424
|
+
return textResult(JSON.stringify(data, null, 2));
|
|
425
|
+
}
|
|
426
|
+
catch (e) {
|
|
427
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
428
|
+
return textResult(`Failed to list companies: ${msg}`, true);
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
server.tool('list_applications_at_company', 'List applications at a company for the tailor picker. Set onlyUntailored=true to focus on roles that still need a resume.', {
|
|
432
|
+
company: z.string(),
|
|
433
|
+
onlyUntailored: z.boolean().optional(),
|
|
434
|
+
role: z.string().optional(),
|
|
435
|
+
page: z.number().int().min(1).optional(),
|
|
436
|
+
pageSize: z.number().int().min(1).max(50).optional(),
|
|
437
|
+
}, async ({ company, onlyUntailored, role, page, pageSize }) => {
|
|
438
|
+
const token = await ensureToken();
|
|
439
|
+
if (!token)
|
|
440
|
+
return textResult(requireAuthMessage(), true);
|
|
441
|
+
try {
|
|
442
|
+
const qs = new URLSearchParams({ company });
|
|
443
|
+
if (onlyUntailored)
|
|
444
|
+
qs.set('onlyUntailored', 'true');
|
|
445
|
+
if (role)
|
|
446
|
+
qs.set('role', role);
|
|
447
|
+
if (page)
|
|
448
|
+
qs.set('page', String(page));
|
|
449
|
+
if (pageSize)
|
|
450
|
+
qs.set('pageSize', String(pageSize));
|
|
451
|
+
const data = await apiFetch(`/api/agent/applications?${qs.toString()}`);
|
|
452
|
+
return textResult(JSON.stringify(data, null, 2));
|
|
453
|
+
}
|
|
454
|
+
catch (e) {
|
|
455
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
456
|
+
return textResult(`Failed to list applications: ${msg}`, true);
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
server.tool('preview_company_siblings', 'After tailoring one application: preview other roles at the same company (for “attach to N others?”).', {
|
|
460
|
+
sourceApplicationId: z.string(),
|
|
461
|
+
}, async ({ sourceApplicationId }) => {
|
|
462
|
+
const token = await ensureToken();
|
|
463
|
+
if (!token)
|
|
464
|
+
return textResult(requireAuthMessage(), true);
|
|
465
|
+
try {
|
|
466
|
+
const data = await apiFetch(`/api/agent/applications/siblings?sourceApplicationId=${encodeURIComponent(sourceApplicationId)}`);
|
|
467
|
+
return textResult(JSON.stringify(data, null, 2));
|
|
468
|
+
}
|
|
469
|
+
catch (e) {
|
|
470
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
471
|
+
return textResult(`Failed to preview siblings: ${msg}`, true);
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
server.tool('attach_tailored_to_siblings', 'Attach the tailor session from a source application onto other roles at that company (or explicit target ids).', {
|
|
475
|
+
sourceApplicationId: z.string(),
|
|
476
|
+
sessionId: z.string().describe('sessionId from submit_tailor_result'),
|
|
477
|
+
targetApplicationIds: z.array(z.string()).optional(),
|
|
478
|
+
onlyUntailored: z
|
|
479
|
+
.boolean()
|
|
480
|
+
.optional()
|
|
481
|
+
.describe('Default true — only attach to apps that do not already have a tailored resume'),
|
|
482
|
+
}, async ({ sourceApplicationId, sessionId, targetApplicationIds, onlyUntailored }) => {
|
|
483
|
+
const token = await ensureToken();
|
|
484
|
+
if (!token)
|
|
485
|
+
return textResult(requireAuthMessage(), true);
|
|
486
|
+
try {
|
|
487
|
+
const result = await apiFetch('/api/agent/applications/siblings', {
|
|
488
|
+
method: 'POST',
|
|
489
|
+
body: JSON.stringify({
|
|
490
|
+
sourceApplicationId,
|
|
491
|
+
sessionId,
|
|
492
|
+
targetApplicationIds,
|
|
493
|
+
onlyUntailored,
|
|
494
|
+
}),
|
|
495
|
+
});
|
|
496
|
+
return textResult(JSON.stringify(result, null, 2));
|
|
497
|
+
}
|
|
498
|
+
catch (e) {
|
|
499
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
500
|
+
return textResult(`Attach siblings failed: ${msg}`, true);
|
|
501
|
+
}
|
|
502
|
+
});
|
|
242
503
|
// Slash path in Cursor: /techprufer/build-profile
|
|
243
504
|
server.prompt('build-profile', 'TechPrufer — build a profile from PDF job or local resume', {
|
|
244
505
|
jobId: z.string().optional().describe('Optional queued profile job id'),
|
|
@@ -275,6 +536,16 @@ export function createServer() {
|
|
|
275
536
|
}
|
|
276
537
|
export async function startStdioServer() {
|
|
277
538
|
const server = createServer();
|
|
539
|
+
// Capture IDE identity from the MCP initialize handshake (not env vars).
|
|
540
|
+
server.server.oninitialized = () => {
|
|
541
|
+
const info = server.server.getClientVersion();
|
|
542
|
+
if (info?.name) {
|
|
543
|
+
setActiveClientInfo({
|
|
544
|
+
name: info.name,
|
|
545
|
+
version: info.version,
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
};
|
|
278
549
|
const transport = new StdioServerTransport();
|
|
279
550
|
await server.connect(transport);
|
|
280
551
|
}
|
package/dist/setup.d.ts
CHANGED
|
@@ -12,6 +12,15 @@ export interface SetupOptions {
|
|
|
12
12
|
yes?: boolean;
|
|
13
13
|
/** Called as step 1 of `login` — tweaks copy and skips the "next" hints. */
|
|
14
14
|
beforeLogin?: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* When true (login flow, non-TTY, no --tools): skip config with guidance
|
|
17
|
+
* and return [] instead of exiting — login continues.
|
|
18
|
+
*/
|
|
19
|
+
allowSkip?: boolean;
|
|
15
20
|
}
|
|
16
|
-
export
|
|
21
|
+
export type SetupOutcome = {
|
|
22
|
+
configuredIds: AiToolId[];
|
|
23
|
+
skipped: boolean;
|
|
24
|
+
};
|
|
25
|
+
export declare function runSetup(opts?: SetupOptions): Promise<SetupOutcome>;
|
|
17
26
|
export declare function listToolsForHelp(): string;
|
package/dist/setup.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createInterface, emitKeypressEvents } from 'readline';
|
|
2
2
|
import { AI_TOOLS, getTool, installTool, isToolConfigured, printSnippetForTool, resolveToolIds, } from './tools.js';
|
|
3
|
+
import { formatCommandsForTools } from './commands.js';
|
|
3
4
|
const ANSI = {
|
|
4
5
|
hideCursor: '\x1b[?25l',
|
|
5
6
|
showCursor: '\x1b[?25h',
|
|
@@ -10,6 +11,7 @@ const ANSI = {
|
|
|
10
11
|
cyan: (s) => `\x1b[36m${s}\x1b[39m`,
|
|
11
12
|
green: (s) => `\x1b[32m${s}\x1b[39m`,
|
|
12
13
|
red: (s) => `\x1b[31m${s}\x1b[39m`,
|
|
14
|
+
yellow: (s) => `\x1b[33m${s}\x1b[39m`,
|
|
13
15
|
};
|
|
14
16
|
function isTty() {
|
|
15
17
|
return Boolean(process.stdin.isTTY && process.stderr.isTTY);
|
|
@@ -49,11 +51,14 @@ export function parseSelection(input) {
|
|
|
49
51
|
}
|
|
50
52
|
return ids;
|
|
51
53
|
}
|
|
52
|
-
function renderPicker(items, cursor) {
|
|
54
|
+
function renderPicker(items, cursor, emptyHint) {
|
|
53
55
|
const lines = [];
|
|
54
56
|
const selectedLabels = items.filter((i) => i.selected).map((i) => i.tool.label);
|
|
55
|
-
lines.push(`${ANSI.bold('? Select AI tools to
|
|
56
|
-
lines.push(` Selected: ${selectedLabels.length ? ANSI.cyan(selectedLabels.join(', ')) : ANSI.dim('none')}`);
|
|
57
|
+
lines.push(`${ANSI.bold('? Select AI tools to configure')} ${ANSI.dim(`(${AI_TOOLS.length} available)`)}`);
|
|
58
|
+
lines.push(` Selected: ${selectedLabels.length ? ANSI.cyan(selectedLabels.join(', ')) : ANSI.dim('none — Space to select')}`);
|
|
59
|
+
if (emptyHint) {
|
|
60
|
+
lines.push(ANSI.yellow(' Select at least one tool (Space), then press Enter.'));
|
|
61
|
+
}
|
|
57
62
|
lines.push(ANSI.dim(' ↑↓ navigate · Space toggle · a all · Enter confirm · q quit'));
|
|
58
63
|
for (let i = 0; i < items.length; i++) {
|
|
59
64
|
const item = items[i];
|
|
@@ -65,21 +70,20 @@ function renderPicker(items, cursor) {
|
|
|
65
70
|
}
|
|
66
71
|
return lines.join('\n');
|
|
67
72
|
}
|
|
68
|
-
/**
|
|
73
|
+
/**
|
|
74
|
+
* Arrow-key multi-select. Defaults to EMPTY — user must select at least one.
|
|
75
|
+
* Already-configured tools show (refresh) but are not pre-selected.
|
|
76
|
+
*/
|
|
69
77
|
async function interactivePicker() {
|
|
70
78
|
const items = AI_TOOLS.map((tool) => {
|
|
71
79
|
const configured = isToolConfigured(tool);
|
|
72
|
-
return { tool, selected:
|
|
80
|
+
return { tool, selected: false, configured };
|
|
73
81
|
});
|
|
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
82
|
let cursor = 0;
|
|
80
83
|
let renderedLines = 0;
|
|
84
|
+
let emptyHint = false;
|
|
81
85
|
const draw = () => {
|
|
82
|
-
const frame = renderPicker(items, cursor);
|
|
86
|
+
const frame = renderPicker(items, cursor, emptyHint);
|
|
83
87
|
process.stderr.write(ANSI.up(renderedLines) + '\r' + ANSI.clearDown + frame + '\n');
|
|
84
88
|
renderedLines = frame.split('\n').length;
|
|
85
89
|
};
|
|
@@ -103,22 +107,32 @@ async function interactivePicker() {
|
|
|
103
107
|
return;
|
|
104
108
|
}
|
|
105
109
|
if (key.name === 'up' || key.name === 'k') {
|
|
110
|
+
emptyHint = false;
|
|
106
111
|
cursor = (cursor - 1 + items.length) % items.length;
|
|
107
112
|
}
|
|
108
113
|
else if (key.name === 'down' || key.name === 'j') {
|
|
114
|
+
emptyHint = false;
|
|
109
115
|
cursor = (cursor + 1) % items.length;
|
|
110
116
|
}
|
|
111
117
|
else if (key.name === 'space') {
|
|
118
|
+
emptyHint = false;
|
|
112
119
|
items[cursor].selected = !items[cursor].selected;
|
|
113
120
|
}
|
|
114
121
|
else if (key.name === 'a') {
|
|
122
|
+
emptyHint = false;
|
|
115
123
|
const allOn = items.every((i) => i.selected);
|
|
116
124
|
for (const item of items)
|
|
117
125
|
item.selected = !allOn;
|
|
118
126
|
}
|
|
119
127
|
else if (key.name === 'return' || key.name === 'enter') {
|
|
128
|
+
const selected = items.filter((i) => i.selected).map((i) => i.tool.id);
|
|
129
|
+
if (selected.length === 0) {
|
|
130
|
+
emptyHint = true;
|
|
131
|
+
draw();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
120
134
|
cleanup();
|
|
121
|
-
resolve(
|
|
135
|
+
resolve(selected);
|
|
122
136
|
return;
|
|
123
137
|
}
|
|
124
138
|
draw();
|
|
@@ -137,14 +151,19 @@ async function promptToolSelection() {
|
|
|
137
151
|
// Raw-mode failure → fall through to the plain prompt.
|
|
138
152
|
}
|
|
139
153
|
}
|
|
140
|
-
console.error('AI tools:');
|
|
154
|
+
console.error('AI tools (select at least one):');
|
|
141
155
|
AI_TOOLS.forEach((t, i) => {
|
|
142
156
|
console.error(` ${i + 1}) ${t.label.padEnd(16)} ${t.hint}`);
|
|
143
157
|
});
|
|
144
158
|
console.error(' a) all');
|
|
145
159
|
console.error('');
|
|
146
|
-
|
|
147
|
-
|
|
160
|
+
for (;;) {
|
|
161
|
+
const answer = await ask('Select tools (numbers or ids, comma-separated; "all"): ');
|
|
162
|
+
const ids = parseSelection(answer);
|
|
163
|
+
if (ids.length > 0)
|
|
164
|
+
return ids;
|
|
165
|
+
console.error(ANSI.yellow('Select at least one tool.'));
|
|
166
|
+
}
|
|
148
167
|
}
|
|
149
168
|
function printResults(results, opts = {}) {
|
|
150
169
|
console.error('');
|
|
@@ -159,15 +178,26 @@ function printResults(results, opts = {}) {
|
|
|
159
178
|
}
|
|
160
179
|
console.error('');
|
|
161
180
|
console.error(ANSI.dim('Restart your AI tools for the MCP server to take effect.'));
|
|
181
|
+
if (opts.configuredIds?.length) {
|
|
182
|
+
console.error('');
|
|
183
|
+
console.error(formatCommandsForTools(opts.configuredIds));
|
|
184
|
+
}
|
|
162
185
|
if (!opts.beforeLogin) {
|
|
163
186
|
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');
|
|
187
|
+
console.error('Next: npx techprufer-mcp login --no-setup (if you have not already)');
|
|
166
188
|
}
|
|
167
189
|
}
|
|
190
|
+
function printNonInteractiveSkipHint() {
|
|
191
|
+
console.error(ANSI.yellow('No interactive terminal — skipping tool config.'));
|
|
192
|
+
console.error('To configure tools from an agent/script, re-run with:');
|
|
193
|
+
console.error(` npx techprufer-mcp login --tools=${AI_TOOLS.map((t) => t.id).join(',')}`);
|
|
194
|
+
console.error('Or paste the JSON snippet from the TechPrufer MCP hub (Copy JSON).');
|
|
195
|
+
console.error('Continuing to device login…');
|
|
196
|
+
console.error('');
|
|
197
|
+
}
|
|
168
198
|
export async function runSetup(opts = {}) {
|
|
169
199
|
console.error(ANSI.bold(opts.beforeLogin
|
|
170
|
-
? 'TechPrufer MCP —
|
|
200
|
+
? 'TechPrufer MCP — configure AI tools'
|
|
171
201
|
: 'TechPrufer MCP — configure AI tools'));
|
|
172
202
|
console.error('');
|
|
173
203
|
let ids;
|
|
@@ -180,14 +210,18 @@ export async function runSetup(opts = {}) {
|
|
|
180
210
|
else if (isTty() && !opts.yes) {
|
|
181
211
|
ids = await promptToolSelection();
|
|
182
212
|
}
|
|
213
|
+
else if (opts.allowSkip || opts.beforeLogin) {
|
|
214
|
+
printNonInteractiveSkipHint();
|
|
215
|
+
return { configuredIds: [], skipped: true };
|
|
216
|
+
}
|
|
183
217
|
else {
|
|
184
218
|
console.error('Non-interactive mode: pass --all or --tools=cursor,claude,claude-code,antigravity,codex');
|
|
185
219
|
process.exit(1);
|
|
186
220
|
}
|
|
187
221
|
if (ids.length === 0) {
|
|
188
|
-
if (opts.beforeLogin) {
|
|
222
|
+
if (opts.beforeLogin || opts.allowSkip) {
|
|
189
223
|
console.error('No tools selected — skipping setup.');
|
|
190
|
-
return;
|
|
224
|
+
return { configuredIds: [], skipped: true };
|
|
191
225
|
}
|
|
192
226
|
console.error('No tools selected.');
|
|
193
227
|
process.exit(1);
|
|
@@ -199,13 +233,14 @@ export async function runSetup(opts = {}) {
|
|
|
199
233
|
console.error(printSnippetForTool(tool));
|
|
200
234
|
console.error('');
|
|
201
235
|
}
|
|
202
|
-
return;
|
|
236
|
+
return { configuredIds: ids, skipped: false };
|
|
203
237
|
}
|
|
204
238
|
const results = selected.map((tool) => installTool(tool));
|
|
205
|
-
printResults(results, { beforeLogin: opts.beforeLogin });
|
|
239
|
+
printResults(results, { beforeLogin: opts.beforeLogin, configuredIds: ids });
|
|
206
240
|
if (results.some((r) => !r.ok) && !opts.beforeLogin) {
|
|
207
241
|
process.exit(1);
|
|
208
242
|
}
|
|
243
|
+
return { configuredIds: ids, skipped: false };
|
|
209
244
|
}
|
|
210
245
|
export function listToolsForHelp() {
|
|
211
246
|
return AI_TOOLS.map((t) => ` ${t.id.padEnd(14)} ${t.label}`).join('\n');
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { runSetup } from './setup.js';
|
|
4
|
+
describe('runSetup non-TTY login path', () => {
|
|
5
|
+
it('skips config without exiting when beforeLogin + allowSkip', async () => {
|
|
6
|
+
// Ensure we look non-interactive even if the test runner has a TTY.
|
|
7
|
+
const prevIn = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY');
|
|
8
|
+
const prevErr = Object.getOwnPropertyDescriptor(process.stderr, 'isTTY');
|
|
9
|
+
Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: false });
|
|
10
|
+
Object.defineProperty(process.stderr, 'isTTY', { configurable: true, value: false });
|
|
11
|
+
try {
|
|
12
|
+
const outcome = await runSetup({ beforeLogin: true, allowSkip: true });
|
|
13
|
+
assert.equal(outcome.skipped, true);
|
|
14
|
+
assert.deepEqual(outcome.configuredIds, []);
|
|
15
|
+
}
|
|
16
|
+
finally {
|
|
17
|
+
if (prevIn)
|
|
18
|
+
Object.defineProperty(process.stdin, 'isTTY', prevIn);
|
|
19
|
+
else
|
|
20
|
+
delete process.stdin.isTTY;
|
|
21
|
+
if (prevErr)
|
|
22
|
+
Object.defineProperty(process.stderr, 'isTTY', prevErr);
|
|
23
|
+
else
|
|
24
|
+
delete process.stderr.isTTY;
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
});
|
package/dist/tools.d.ts
CHANGED
|
@@ -45,5 +45,12 @@ export type InstallResult = {
|
|
|
45
45
|
path: string;
|
|
46
46
|
error: string;
|
|
47
47
|
};
|
|
48
|
+
/** Codex surfaces custom prompts as `/name` from `~/.codex/prompts/<name>.md`. */
|
|
49
|
+
export declare function codexPromptsDir(): string;
|
|
50
|
+
/** Write Codex slash-command prompt files (idempotent). */
|
|
51
|
+
export declare function writeCodexPromptFiles(): {
|
|
52
|
+
path: string;
|
|
53
|
+
created: boolean;
|
|
54
|
+
}[];
|
|
48
55
|
export declare function installTool(tool: AiToolDef): InstallResult;
|
|
49
56
|
export declare function printSnippetForTool(tool: AiToolDef): string;
|
package/dist/tools.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'fs';
|
|
2
2
|
import { homedir, platform } from 'os';
|
|
3
3
|
import { dirname, join } from 'path';
|
|
4
4
|
/** Server key must stay `techprufer` so slash commands are `/techprufer/…`. */
|
|
@@ -157,6 +157,64 @@ export function isToolConfigured(tool) {
|
|
|
157
157
|
return false;
|
|
158
158
|
}
|
|
159
159
|
}
|
|
160
|
+
/** Codex surfaces custom prompts as `/name` from `~/.codex/prompts/<name>.md`. */
|
|
161
|
+
export function codexPromptsDir() {
|
|
162
|
+
return join(homedir(), '.codex', 'prompts');
|
|
163
|
+
}
|
|
164
|
+
const CODEX_TAILOR_PROMPT = [
|
|
165
|
+
'TechPrufer MCP — all resume control under /techprufer-tailor.',
|
|
166
|
+
'',
|
|
167
|
+
'1) list_pending_tailor_jobs — show them; ask process now or SKIP to company picker.',
|
|
168
|
+
'2) applicationId / profileId: enqueue or edit/delete.',
|
|
169
|
+
'3) Company picker: list_tracked_companies (top 10 / query) → list_applications_at_company → enqueue_tailor_job → process.',
|
|
170
|
+
'4) After submit_tailor_result: show jdUrl + resumePdfUrl. If siblingCount>0, ask to attach to other roles (preview_company_siblings → attach_tailored_to_siblings).',
|
|
171
|
+
'',
|
|
172
|
+
'Zero hallucination. Complete JSON only when submitting or saving.',
|
|
173
|
+
'',
|
|
174
|
+
].join('\n');
|
|
175
|
+
const CODEX_BUILD_PROFILE_PROMPT = [
|
|
176
|
+
'Process TechPrufer MCP profile-build jobs (or build from a local resume).',
|
|
177
|
+
'',
|
|
178
|
+
'Path A — queued PDF jobs from the site:',
|
|
179
|
+
'1. Call list_pending_profile_jobs; for each QUEUED/CLAIMED job call get_profile_job.',
|
|
180
|
+
'2. Execute returned messages verbatim; output ONLY CPM profile JSON.',
|
|
181
|
+
'3. Call submit_profile_job_result. On correctiveMessage, fix once and resubmit.',
|
|
182
|
+
'',
|
|
183
|
+
'Path B — local resume file (user selected / open in this tool):',
|
|
184
|
+
'1. Read the resume file and extract full text.',
|
|
185
|
+
'2. Call get_profile_build_messages with that resumeText.',
|
|
186
|
+
'3. Execute messages verbatim; output ONLY CPM profile JSON.',
|
|
187
|
+
'4. Call submit_built_profile (optional name from file / identity.name).',
|
|
188
|
+
'',
|
|
189
|
+
'Prefer Path A if pending jobs exist; otherwise Path B when a local resume is available.',
|
|
190
|
+
'Zero hallucination. Complete JSON only. Faithful extraction, not rewriting.',
|
|
191
|
+
'',
|
|
192
|
+
].join('\n');
|
|
193
|
+
/** Write Codex slash-command prompt files (idempotent). */
|
|
194
|
+
export function writeCodexPromptFiles() {
|
|
195
|
+
const dir = codexPromptsDir();
|
|
196
|
+
mkdirSync(dir, { recursive: true });
|
|
197
|
+
const files = [
|
|
198
|
+
{ name: 'techprufer-tailor.md', body: CODEX_TAILOR_PROMPT },
|
|
199
|
+
{ name: 'techprufer-build-profile.md', body: CODEX_BUILD_PROFILE_PROMPT },
|
|
200
|
+
];
|
|
201
|
+
// Remove deprecated separate edit prompt if an older package version wrote it.
|
|
202
|
+
const deprecatedEdit = join(dir, 'techprufer-edit.md');
|
|
203
|
+
if (existsSync(deprecatedEdit)) {
|
|
204
|
+
try {
|
|
205
|
+
unlinkSync(deprecatedEdit);
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
/* ignore */
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return files.map(({ name, body }) => {
|
|
212
|
+
const path = join(dir, name);
|
|
213
|
+
const existed = existsSync(path);
|
|
214
|
+
writeFileSync(path, body, 'utf8');
|
|
215
|
+
return { path, created: !existed };
|
|
216
|
+
});
|
|
217
|
+
}
|
|
160
218
|
export function installTool(tool) {
|
|
161
219
|
const path = tool.configPath();
|
|
162
220
|
try {
|
|
@@ -167,6 +225,9 @@ export function installTool(tool) {
|
|
|
167
225
|
: mergeJsonMcpServers(prev);
|
|
168
226
|
mkdirSync(dirname(path), { recursive: true });
|
|
169
227
|
writeFileSync(path, next, 'utf8');
|
|
228
|
+
if (tool.id === 'codex') {
|
|
229
|
+
writeCodexPromptFiles();
|
|
230
|
+
}
|
|
170
231
|
return { ok: true, tool, path, created: !existed };
|
|
171
232
|
}
|
|
172
233
|
catch (e) {
|
package/dist/tools.test.js
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { describe, it } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import {
|
|
3
|
+
import { mkdtempSync, readFileSync, rmSync } from 'fs';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
import { mergeJsonMcpServers, mergeTomlMcpServers, MCP_SERVER_KEY, MCP_SERVER_ENTRY, writeCodexPromptFiles, codexPromptsDir, } from './tools.js';
|
|
4
7
|
import { parseSelection } from './setup.js';
|
|
8
|
+
import { formatClientHeader, parseClientHeader } from './clientHeader.js';
|
|
9
|
+
import { formatCommandsForTools, TOOL_COMMANDS } from './commands.js';
|
|
5
10
|
describe('mergeJsonMcpServers', () => {
|
|
6
11
|
it('creates mcpServers when file is empty', () => {
|
|
7
12
|
const out = JSON.parse(mergeJsonMcpServers(null));
|
|
@@ -64,4 +69,52 @@ describe('parseSelection', () => {
|
|
|
64
69
|
it('rejects unknown tokens', () => {
|
|
65
70
|
assert.throws(() => parseSelection('windsurf'), /Unknown selection/);
|
|
66
71
|
});
|
|
72
|
+
it('empty selection is empty array (picker defaults empty)', () => {
|
|
73
|
+
assert.deepEqual(parseSelection(''), []);
|
|
74
|
+
assert.deepEqual(parseSelection(' '), []);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
describe('clientHeader', () => {
|
|
78
|
+
it('formats and parses name/version', () => {
|
|
79
|
+
assert.equal(formatClientHeader({ name: 'cursor', version: '2.1.3' }), 'cursor/2.1.3');
|
|
80
|
+
assert.equal(formatClientHeader({ name: 'codex' }), 'codex');
|
|
81
|
+
assert.equal(formatClientHeader(null), null);
|
|
82
|
+
assert.deepEqual(parseClientHeader('cursor/2.1.3'), { name: 'cursor', version: '2.1.3' });
|
|
83
|
+
assert.deepEqual(parseClientHeader('claude-code'), { name: 'claude-code' });
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
describe('TOOL_COMMANDS', () => {
|
|
87
|
+
it('exposes per-tool slash paths', () => {
|
|
88
|
+
assert.equal(TOOL_COMMANDS.cursor.tailor, '/techprufer/tailor');
|
|
89
|
+
assert.equal(TOOL_COMMANDS['claude-code'].tailor, '/mcp__techprufer__tailor');
|
|
90
|
+
assert.equal(TOOL_COMMANDS.codex.tailor, '/techprufer-tailor');
|
|
91
|
+
assert.match(formatCommandsForTools(['codex']), /\/techprufer-tailor/);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
describe('writeCodexPromptFiles', () => {
|
|
95
|
+
it('writes idempotent prompt markdown under ~/.codex/prompts', () => {
|
|
96
|
+
// Use real home dir path but write into a temp HOME if possible.
|
|
97
|
+
const prevHome = process.env.HOME;
|
|
98
|
+
const tmp = mkdtempSync(join(tmpdir(), 'tp-codex-'));
|
|
99
|
+
process.env.HOME = tmp;
|
|
100
|
+
try {
|
|
101
|
+
const first = writeCodexPromptFiles();
|
|
102
|
+
assert.equal(first.length, 2);
|
|
103
|
+
assert.equal(first.every((f) => f.created), true);
|
|
104
|
+
assert.equal(codexPromptsDir(), join(tmp, '.codex', 'prompts'));
|
|
105
|
+
const tailor = readFileSync(join(tmp, '.codex', 'prompts', 'techprufer-tailor.md'), 'utf8');
|
|
106
|
+
assert.match(tailor, /list_pending_tailor_jobs/);
|
|
107
|
+
assert.match(tailor, /list_tracked_companies/);
|
|
108
|
+
assert.match(tailor, /attach_tailored_to_siblings/);
|
|
109
|
+
const second = writeCodexPromptFiles();
|
|
110
|
+
assert.equal(second.every((f) => f.created), false);
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
if (prevHome === undefined)
|
|
114
|
+
delete process.env.HOME;
|
|
115
|
+
else
|
|
116
|
+
process.env.HOME = prevHome;
|
|
117
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
118
|
+
}
|
|
119
|
+
});
|
|
67
120
|
});
|