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 CHANGED
@@ -12,37 +12,40 @@ One command does everything:
12
12
  npx techprufer-mcp login
13
13
  ```
14
14
 
15
- This runs two steps:
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
- 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.
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
- 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
20
+ ### Agent-driven login (non-TTY)
23
21
 
24
22
  ```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
23
+ npx -y techprufer-mcp login --tools=cursor,codex --timeout 300
29
24
  ```
30
25
 
31
- ### Setup options (non-interactive)
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 setup --all
35
- npx techprufer-mcp setup --tools=cursor,claude-code,codex
36
- npx techprufer-mcp setup --print --tools=antigravity # print snippets, write nothing
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 --name "Work laptop" # device name shown on the site
43
- npx techprufer-mcp login --no-setup # skip the tool picker
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` so slash commands are `/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
- Type `/techprufer` in your AI tool to list:
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
- | Command | Use |
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({ beforeLogin: true });
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, skipSetup ? {} : { stepLabel: 'step 2/2 · login' });
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 to configure, then device-code login
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
- Setup options:
65
- --tools=id,id Non-interactive: comma-separated tool ids
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;
@@ -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 declare function runLogin(deviceName?: string, opts?: {
10
+ export interface LoginOptions {
10
11
  stepLabel?: string;
11
- }): Promise<void>;
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 deadline = Date.now() + start.expiresIn * 1000;
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('Logged in. You can close this window and use /techprufer/tailor or /techprufer/build-profile.');
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.1.5',
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 a queued resume job', {
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 specific job id; otherwise process all pending jobs in order'),
107
- }, async ({ jobId }) => ({
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
- 'Process TechPrufer MCP tailor jobs.',
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
- 'For each job:',
120
- '1. Call get_tailor_job (with jobId if provided).',
121
- '2. Execute the returned messages verbatim with your own model.',
122
- '3. Produce ONLY the tailored JSON object (experience, projects, skills, meta, linkedinMessage).',
123
- '4. Call submit_tailor_result with that JSON.',
124
- '5. If the tool returns a correctiveMessage (validation / layout fill), fix the JSON and resubmit once.',
125
- '6. Proceed to the next pending job.',
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
- 'Do not invent experience or metrics. Zero hallucination. Complete output only.',
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 declare function runSetup(opts?: SetupOptions): Promise<void>;
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 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.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
- /** Arrow-key multi-select; pre-selects tools that already have a techprufer entry. */
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: configured, configured };
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(items.filter((i) => i.selected).map((i) => i.tool.id));
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
- const answer = await ask('Select tools (numbers or ids, comma-separated; "all"): ');
147
- return parseSelection(answer);
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 — step 1/2 · configure AI tools'
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) {
@@ -1,7 +1,12 @@
1
1
  import { describe, it } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { mergeJsonMcpServers, mergeTomlMcpServers, MCP_SERVER_KEY, MCP_SERVER_ENTRY, } from './tools.js';
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
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "techprufer-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.4.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",