shakers-cli-beta 0.9.0 → 0.10.1

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.
@@ -1,11 +1,28 @@
1
1
  'use strict';
2
2
 
3
- // MCP: role/cluster management — list addable roles, add one, list held roles,
4
- // and set the main role. The onboarding final step is set_main_role after the
5
- // interview (or after skipping it).
3
+ // MCP role tools: list, add and recommend roles, and set the main role (the sign-up's last step before open_web).
4
+
5
+ const { askChoice, askAfterTexts } = require('./mcp-choice');
6
+ const { signupCopy, renderRole } = require('./signup-copy');
7
+ const { signupLanguage } = require('./signup-language');
6
8
 
7
9
  const EMPTY_SCHEMA = { type: 'object', properties: {} };
8
10
 
11
+ // Same bounds as the web's growth-direction step: certs finalizes an interview within ~90 s, polled every 2 s.
12
+ const FINALIZATION_WAIT_LIMIT_MS = 90000;
13
+ const POLL_MS = 2000;
14
+ const MAX_WAIT_SECONDS = 25;
15
+ const PENDING_FINALIZATION = new Set(['COLLECTING', 'PROCESSING']);
16
+ // Alma's signal from the interview first, then the CV import, as the web stacks them.
17
+ const SOURCE_ORDER = { ONBOARDING_INTERVIEW: 0, IMPORT: 1 };
18
+
19
+ const LIST_MY_ROLES_SCHEMA = {
20
+ type: 'object',
21
+ properties: {
22
+ waitSeconds: { type: 'number', description: `In the sign-up, wait up to this many seconds (max ${MAX_WAIT_SECONDS}) for the interview evaluation or the import to recommend roles.` },
23
+ },
24
+ };
25
+
9
26
  const CLUSTER_SCHEMA = {
10
27
  type: 'object',
11
28
  properties: {
@@ -14,7 +31,31 @@ const CLUSTER_SCHEMA = {
14
31
  required: ['clusterId'],
15
32
  };
16
33
 
34
+ const SET_MAIN_ROLE_SCHEMA = {
35
+ type: 'object',
36
+ properties: {
37
+ evidence: {
38
+ type: 'object',
39
+ additionalProperties: { type: 'string' },
40
+ description: 'In the sign-up, for each recommended role (by clusterId from list_my_roles): ONE short concrete fact from the CV, the interview or the AI-usage analysis that shows why it fits (e.g. "6 years with Spark and Airflow"), in the talent\'s language. It is shown under the role.',
41
+ },
42
+ shown: { type: 'boolean', description: 'true only after you showed relayVerbatim from the previous call: it then asks the question in a dialog.' },
43
+ answer: { type: 'string', description: 'The option the talent picked when you asked the question in the chat.' },
44
+ clusterId: { type: 'string', description: 'The role the talent already picked, by clusterId from list_my_roles or from the roles this tool returned. Omit it in the sign-up: the tool asks the talent which recommended role is their main one (a dialog, or options it returns), with the catalogue as the last option.' },
45
+ catalogue: { type: 'boolean', description: 'true when the role comes from the catalogue: with clusterId it adds the role before setting it; without clusterId it asks from the catalogue.' },
46
+ },
47
+ };
48
+
49
+ // One option per role name: the talent picks by name, so two rows with the same name would be one choice.
50
+ function byName(roles) {
51
+ const seen = new Map();
52
+ for (const r of roles) if (r && r.name && !seen.has(r.name)) seen.set(r.name, r);
53
+ return [...seen.values()];
54
+ }
55
+
17
56
  function makeRoleTools(deps = {}) {
57
+ const lang = () => signupLanguage(deps.lang || require('./i18n').detectFlowLang());
58
+ const copy = () => signupCopy(lang());
18
59
  const {
19
60
  getAvailableRolesEndpoint = require('./config').getAvailableRolesEndpoint,
20
61
  getSetMainRoleEndpoint = require('./config').getSetMainRoleEndpoint,
@@ -28,6 +69,12 @@ function makeRoleTools(deps = {}) {
28
69
  loadAuthSession = require('./auth-session-store').loadAuthSession,
29
70
  sessionStatus = require('./auth-session-store').sessionStatus,
30
71
  dedupeByCluster = require('./roles-flow').dedupeByCluster,
72
+ getOnboardingInterviewsEndpoint = require('./config').getOnboardingInterviewsEndpoint,
73
+ requestInterviewFinalization = require('./onboarding-status-client').requestInterviewFinalization,
74
+ completedOnboardingInterview = () => require('./mcp-register-tools').completedOnboardingInterview(),
75
+ countOpenPositions = (opts) => require('./find-projects-client').countOpenPositions({}, opts),
76
+ sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); }),
77
+ now = () => Date.now(),
31
78
  } = deps;
32
79
 
33
80
  const requireSession = () => {
@@ -38,7 +85,14 @@ function makeRoleTools(deps = {}) {
38
85
  return session;
39
86
  };
40
87
 
41
- const shape = (r) => ({ clusterId: r.clusterId, name: r.name, category: r.category, ...(r.proposalKind ? { proposalKind: r.proposalKind } : {}) });
88
+ const shape = (r) => ({
89
+ clusterId: r.clusterId,
90
+ name: r.name,
91
+ category: r.category,
92
+ ...(r.proposalKind ? { proposalKind: r.proposalKind } : {}),
93
+ ...(r.type ? { type: r.type } : {}),
94
+ ...(r.source ? { source: r.source } : {}),
95
+ });
42
96
 
43
97
  async function listAvailableRoles() {
44
98
  const session = requireSession();
@@ -58,9 +112,15 @@ function makeRoleTools(deps = {}) {
58
112
  return { ok: true, clusterId: args.clusterId };
59
113
  }
60
114
 
61
- async function listMyRoles() {
62
- const session = requireSession();
63
- const hubAccessToken = session.hubAccessToken;
115
+ // Whether certs is still evaluating the interview this process completed, within the web's wait.
116
+ async function evaluationPending(session) {
117
+ const interview = completedOnboardingInterview();
118
+ if (!interview || now() - interview.completedAt >= FINALIZATION_WAIT_LIMIT_MS) return false;
119
+ const res = await requestInterviewFinalization({ interviewId: interview.interviewId, accessToken: session.accessToken }, { base: getOnboardingInterviewsEndpoint() });
120
+ return res.ok && PENDING_FINALIZATION.has(res.status);
121
+ }
122
+
123
+ async function readRoles(hubAccessToken) {
64
124
  const held = [];
65
125
  let mainClusterId = null;
66
126
  const cert = await requestMeCertifications({ hubAccessToken }, { endpoint: getMyCertificationsEndpoint() });
@@ -69,14 +129,115 @@ function makeRoleTools(deps = {}) {
69
129
  for (const g of cert.growingInto || []) held.push(g);
70
130
  }
71
131
  const assigned = await requestAssignedClusters({ hubAccessToken }, { endpoint: getAssignedClustersEndpoint() });
72
- if (assigned.ok) for (const c of assigned.clusters || []) held.push(c);
73
- return { ok: true, mainClusterId, roles: dedupeByCluster(held).map(shape) };
132
+ const clusters = assigned.ok ? assigned.clusters || [] : [];
133
+ const recommended = clusters
134
+ .filter((c) => c.type === 'RECOMMENDED')
135
+ .sort((a, b) => (SOURCE_ORDER[a.source] ?? 2) - (SOURCE_ORDER[b.source] ?? 2));
136
+ return { mainClusterId, roles: dedupeByCluster([...held, ...clusters]).map(shape), recommended: recommended.map(shape) };
74
137
  }
75
138
 
76
- async function setMainRole(args = {}) {
139
+ async function listMyRoles(args = {}) {
77
140
  const session = requireSession();
78
- if (!args.clusterId) return { ok: false, reason: 'no-cluster' };
141
+ const deadline = now() + Math.min(Math.max(Number(args.waitSeconds) || 0, 0), MAX_WAIT_SECONDS) * 1000;
142
+ let pending = await evaluationPending(session);
143
+ while (pending && now() < deadline) {
144
+ await sleep(POLL_MS);
145
+ pending = await evaluationPending(session);
146
+ }
147
+ let read = await readRoles(session.hubAccessToken);
148
+ while (!pending && read.recommended.length === 0 && now() < deadline) {
149
+ await sleep(POLL_MS);
150
+ read = await readRoles(session.hubAccessToken);
151
+ }
152
+ let message;
153
+ let next = null;
154
+ if (pending) message = 'The interview evaluation is still recommending roles: tell the talent you are preparing their roles and call list_my_roles again with waitSeconds.';
155
+ else if (read.recommended.length) {
156
+ next = 'set_main_role';
157
+ message = 'Call set_main_role without clusterId: it asks the talent which recommended role is their main one, in a dialog or with options it returns for you to show.';
158
+ } else {
159
+ next = 'set_main_role';
160
+ message = 'Nothing was recommended: call set_main_role without clusterId, it offers the catalogue (list_available_roles), then adds (add_role) and sets the role the talent picks.';
161
+ }
162
+ return { ok: true, ...read, pending, ...(next ? { next } : {}), message };
163
+ }
164
+
165
+ function roleChoice(asked, roles, extra) {
166
+ return {
167
+ ok: false,
168
+ reason: 'main-role-choice-required',
169
+ ...(asked.dismissed ? { dismissed: asked.dismissed } : {}),
170
+ ...asked.chat,
171
+ roles: roles.map((r) => ({ clusterId: r.clusterId, name: r.name })),
172
+ message: `${asked.chat.choiceMessage} Then call set_main_role with the clusterId (from roles) of the one they pick. ${extra}`,
173
+ };
174
+ }
175
+
176
+ // How many open projects look for each role; a failed count leaves its line out.
177
+ async function openCounts(hubAccessToken, roles) {
178
+ return Promise.all(roles.map(async (r) => {
179
+ try {
180
+ const res = await countOpenPositions({ hubAccessToken, clusterId: r.clusterId });
181
+ return res && res.ok ? res.total : null;
182
+ } catch {
183
+ return null;
184
+ }
185
+ }));
186
+ }
187
+
188
+ // The main-role step: why each recommended role fits and how many projects want it, then the question; the catalogue is the last option.
189
+ async function chooseMainRole(session, args, ctx) {
190
+ const recommended = byName((await readRoles(session.hubAccessToken)).recommended);
191
+ if (!recommended.length) return chooseFromCatalogue(args, ctx);
192
+ const c = copy();
193
+ const counts = await openCounts(session.hubAccessToken, recommended);
194
+ const evidence = args.evidence && typeof args.evidence === 'object' ? args.evidence : {};
195
+ const blocks = recommended.map((r, i) => renderRole(lang(), { name: r.name, source: r.source, evidence: evidence[r.clusterId], openProjects: counts[i], recommended: i === 0 }));
196
+ const asked = await askAfterTexts(ctx, {
197
+ tool: 'set_main_role',
198
+ texts: [[c.rolesIntro, ...blocks, c.rolesOutro].join('\n\n')],
199
+ question: c.mainRoleQuestion,
200
+ options: [...recommended.map((r) => r.name), c.mainRoleOther],
201
+ shown: args.shown,
202
+ answer: args.answer,
203
+ });
204
+ if (asked.reply) {
205
+ return { reply: { ...asked.reply, roles: recommended.map((r) => ({ clusterId: r.clusterId, name: r.name })), message: `${asked.reply.message} Pass the same evidence again. If they pick the last option, call set_main_role with catalogue:true and no clusterId.` } };
206
+ }
207
+ if (asked.answer === c.mainRoleOther) return chooseFromCatalogue({}, ctx);
208
+ return { role: recommended.find((r) => r.name === asked.answer) };
209
+ }
210
+
211
+ async function chooseFromCatalogue(args, ctx) {
212
+ const available = await listAvailableRoles();
213
+ if (!available.ok) return { reply: { ok: false, reason: available.reason } };
214
+ const roles = byName(available.roles);
215
+ if (!roles.length) return { reply: { ok: false, reason: 'no-roles' } };
216
+ const typed = roles.find((r) => typeof args.answer === 'string' && r.name === args.answer.trim());
217
+ if (typed) return { role: typed, add: true };
218
+ const asked = await askChoice(ctx, { question: copy().mainRoleQuestion, options: roles.map((r) => r.name) });
219
+ if (!asked.answer) return { reply: roleChoice(asked, roles, 'Pass catalogue:true too: it adds the role first.') };
220
+ return { role: roles.find((r) => r.name === asked.answer), add: true };
221
+ }
222
+
223
+ async function setMainRole(args = {}, ctx = {}) {
224
+ const session = requireSession();
225
+ if (args.clusterId && args.catalogue === true) {
226
+ const added = await addRole({ clusterId: args.clusterId });
227
+ if (!added.ok) return added;
228
+ }
229
+ if (!args.clusterId) {
230
+ const picked = args.catalogue === true ? await chooseFromCatalogue(args, ctx) : await chooseMainRole(session, args, ctx);
231
+ if (picked.reply) return picked.reply;
232
+ if (picked.add) {
233
+ const added = await addRole({ clusterId: picked.role.clusterId });
234
+ if (!added.ok) return added;
235
+ }
236
+ const set = await setMainRole({ clusterId: picked.role.clusterId });
237
+ return set.ok ? { ...set, name: picked.role.name } : set;
238
+ }
79
239
  const res = await requestSetMainRole({ clusterId: args.clusterId, hubAccessToken: session.hubAccessToken }, { endpoint: getSetMainRoleEndpoint() });
240
+ if (res.ok === false && res.reason === 'http-400') return { ok: false, reason: res.reason, message: 'The talent does not hold that role yet: call add_role with it, then set_main_role again.' };
80
241
  if (!res.ok) return { ok: false, reason: res.reason };
81
242
  return { ok: true, clusterId: args.clusterId };
82
243
  }
@@ -96,17 +257,17 @@ function makeRoleTools(deps = {}) {
96
257
  },
97
258
  {
98
259
  name: 'list_my_roles',
99
- description: "List the roles the talent already holds, plus mainClusterId (their current main role). Use it before set_main_role. clusterId is an internal handle — refer to roles by name to the talent. Requires an active session.",
100
- inputSchema: EMPTY_SCHEMA,
260
+ description: "List the roles the talent already holds, plus mainClusterId (their current main role) and `recommended`: the roles the onboarding interview and the CV import suggest, interview first. Use it before set_main_role. In the sign-up pass waitSeconds (20): it waits for the interview evaluation to recommend roles and says when to call again (`pending`) or to fall back to list_available_roles. Follow `message`. clusterId is an internal handle — refer to roles by name to the talent. Requires an active session.",
261
+ inputSchema: LIST_MY_ROLES_SCHEMA,
101
262
  handler: listMyRoles,
102
263
  },
103
264
  {
104
265
  name: 'set_main_role',
105
- description: "Set the talent's MAIN role to one they already hold, by clusterId from list_my_roles. This is the onboarding FINAL step: after onboarding_interview_complete (or if the talent skipped the interview), offer them their held roles and set the one they pick. Also usable standalone to change the main role later. Requires an active session.",
106
- inputSchema: CLUSTER_SCHEMA,
266
+ description: "Set the talent's MAIN role. In the sign-up it is the last step before open_web: after onboarding_interview_complete (or when the talent skipped the interview) and once list_my_roles is no longer pending, call it without clusterId: it asks the talent which of the recommended roles is their main one, with the catalogue as the last option (or straight from the catalogue when nothing is recommended), in a dialog when the client has one; otherwise it returns the question, its options and the roles to call it again with the clusterId they pick. Then it sets that role (adding a catalogue role first). Also usable standalone with clusterId to change the main role later; catalogue:true adds a catalogue role first. Requires an active session.",
267
+ inputSchema: SET_MAIN_ROLE_SCHEMA,
107
268
  handler: setMainRole,
108
269
  },
109
270
  ];
110
271
  }
111
272
 
112
- module.exports = { makeRoleTools, EMPTY_SCHEMA, CLUSTER_SCHEMA };
273
+ module.exports = { makeRoleTools, EMPTY_SCHEMA, CLUSTER_SCHEMA, LIST_MY_ROLES_SCHEMA, SET_MAIN_ROLE_SCHEMA };
package/src/mcp-server.js CHANGED
@@ -4,6 +4,9 @@ const readline = require('readline');
4
4
 
5
5
  const JSONRPC = '2.0';
6
6
  const DEFAULT_PROTOCOL_VERSION = '2025-06-18';
7
+ // A talent answering a dialog takes human time; past this the tool falls back to asking in the chat.
8
+ const ELICITATION_TIMEOUT_MS = 5 * 60 * 1000;
9
+ const ELICITATION_ACTIONS = new Set(['accept', 'decline', 'cancel']);
7
10
 
8
11
  const ERROR = {
9
12
  PARSE: -32700,
@@ -13,6 +16,10 @@ const ERROR = {
13
16
  INTERNAL: -32603,
14
17
  };
15
18
 
19
+ function isResponse(msg) {
20
+ return !!msg && msg.jsonrpc === JSONRPC && msg.method === undefined && msg.id !== undefined && msg.id !== null && ('result' in msg || 'error' in msg);
21
+ }
22
+
16
23
  function isNotification(msg) {
17
24
  return !msg || msg.id === undefined || msg.id === null;
18
25
  }
@@ -27,7 +34,7 @@ function errorResponse(id, code, message, data) {
27
34
  return { jsonrpc: JSONRPC, id: id === undefined ? null : id, error };
28
35
  }
29
36
 
30
- function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVersion, beforeCall = null, instructions = null } = {}) {
37
+ function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVersion, beforeCall = null, instructions = null, elicitationTimeoutMs = ELICITATION_TIMEOUT_MS } = {}) {
31
38
  const registry = new Map();
32
39
  for (const t of tools) {
33
40
  if (!t || !t.name || typeof t.handler !== 'function') {
@@ -46,6 +53,56 @@ function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVe
46
53
 
47
54
  const advertisedVersion = protocolVersion || DEFAULT_PROTOCOL_VERSION;
48
55
 
56
+ let clientCapabilities = {};
57
+ let send = null;
58
+ let lastRequestId = 0;
59
+ const pending = new Map();
60
+
61
+ function request(method, params, timeoutMs) {
62
+ if (!send) return Promise.reject(new Error('no transport to the client'));
63
+ lastRequestId += 1;
64
+ const id = `shakers-${lastRequestId}`;
65
+ return new Promise((resolve, reject) => {
66
+ const timer = setTimeout(() => { pending.delete(id); reject(new Error(`${method} timed out`)); }, timeoutMs);
67
+ pending.set(id, { resolve, reject, timer });
68
+ send({ jsonrpc: JSONRPC, id, method, params });
69
+ });
70
+ }
71
+
72
+ function settle(msg) {
73
+ const waiter = pending.get(msg.id);
74
+ if (!waiter) return;
75
+ pending.delete(msg.id);
76
+ clearTimeout(waiter.timer);
77
+ if (msg.error) waiter.reject(new Error((msg.error && msg.error.message) || 'client error'));
78
+ else waiter.resolve(msg.result);
79
+ }
80
+
81
+ function rejectPending(reason) {
82
+ for (const [id, waiter] of pending) {
83
+ pending.delete(id);
84
+ clearTimeout(waiter.timer);
85
+ waiter.reject(new Error(reason));
86
+ }
87
+ }
88
+
89
+ // An `elicitation` capability without `form` but with `url` only opens links, so it cannot ask a closed question.
90
+ function canElicitForm() {
91
+ const e = clientCapabilities.elicitation;
92
+ return !!e && typeof e === 'object' && (e.form !== undefined || e.url === undefined);
93
+ }
94
+
95
+ // MCP elicitation/create: the client's { action, content }, or null when it has no dialog or the exchange failed.
96
+ async function elicit({ message, requestedSchema }) {
97
+ if (!send || !canElicitForm()) return null;
98
+ try {
99
+ const result = await request('elicitation/create', { message, requestedSchema }, elicitationTimeoutMs);
100
+ return result && ELICITATION_ACTIONS.has(result.action) ? result : null;
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
105
+
49
106
  function shapeTool(t) {
50
107
  return {
51
108
  name: t.name,
@@ -110,7 +167,7 @@ function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVe
110
167
  try { await beforeCall(name); } catch { /* hook must never break a tool call */ }
111
168
  }
112
169
  try {
113
- const value = await tool.handler(args);
170
+ const value = await tool.handler(args, { elicit, elicitation: !!send && canElicitForm() });
114
171
  return {
115
172
  content: [{ type: 'text', text: JSON.stringify(value, null, 2) }],
116
173
  isError: false,
@@ -124,6 +181,10 @@ function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVe
124
181
  }
125
182
 
126
183
  async function handleMessage(msg) {
184
+ if (isResponse(msg)) {
185
+ settle(msg);
186
+ return null;
187
+ }
127
188
  if (!msg || msg.jsonrpc !== JSONRPC || typeof msg.method !== 'string') {
128
189
  if (isNotification(msg)) return null;
129
190
  return errorResponse(msg && msg.id, ERROR.INVALID_REQUEST, 'invalid JSON-RPC request');
@@ -139,6 +200,8 @@ function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVe
139
200
  switch (method) {
140
201
  case 'initialize': {
141
202
  const requested = msg.params && msg.params.protocolVersion;
203
+ clientCapabilities = (msg.params && msg.params.capabilities) || {};
204
+ const text = typeof instructions === 'function' ? instructions({ elicitation: canElicitForm() }) : instructions;
142
205
  return resultResponse(id, {
143
206
  protocolVersion: requested || advertisedVersion,
144
207
  capabilities: {
@@ -151,7 +214,7 @@ function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVe
151
214
  },
152
215
  // Server-level guidance the client keeps in the model's context even
153
216
  // when it loads tool schemas lazily (Claude Code defers MCP tools).
154
- ...(typeof instructions === 'string' && instructions ? { instructions } : {}),
217
+ ...(typeof text === 'string' && text ? { instructions: text } : {}),
155
218
  });
156
219
  }
157
220
  case 'ping':
@@ -178,6 +241,11 @@ function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVe
178
241
  function start({ input = process.stdin, output = process.stdout, onError } = {}) {
179
242
  const rl = readline.createInterface({ input, crlfDelay: Infinity });
180
243
  const write = (obj) => output.write(`${JSON.stringify(obj)}\n`);
244
+ send = write;
245
+ rl.on('close', () => {
246
+ send = null;
247
+ rejectPending('the client closed the connection');
248
+ });
181
249
 
182
250
  rl.on('line', async (line) => {
183
251
  const trimmed = line.trim();
@@ -0,0 +1,118 @@
1
+ 'use strict';
2
+
3
+ const { postJsonWithTimeout, postMultipartWithTimeout } = require('./backend-request');
4
+
5
+ // Hub calls of the MCP sign-up (ticket 39540): public sign-up + claim code, the signed-in talent's import status, and the one-time web login token.
6
+
7
+ const SIGNUP_TIMEOUT_MS = 60000;
8
+ const DEFAULT_TIMEOUT_MS = 15000;
9
+
10
+ // Hub's coded errors on the sign-up, verified against the works-ai exception filter (hub MR !2198); none says whether an account exists.
11
+ const CODE_TO_REASON = {
12
+ MISSING_SOURCE: 'missing-source',
13
+ INVALID_CV: 'invalid-cv',
14
+ INVALID_LINKEDIN_URL: 'invalid-linkedin-url',
15
+ RATE_LIMITED: 'rate-limited',
16
+ };
17
+ const IMPORT_SOURCES = ['linkedin', 'cv'];
18
+
19
+ function parseJson(raw) {
20
+ try {
21
+ return raw ? JSON.parse(raw) : null;
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ function failure(res) {
28
+ const body = parseJson(res.raw);
29
+ const code = body && typeof body.code === 'string' ? body.code : null;
30
+ return { ok: false, reason: (code && CODE_TO_REASON[code]) || `http-${res.status}`, status: res.status };
31
+ }
32
+
33
+ function envelopeData(raw) {
34
+ const json = parseJson(raw);
35
+ return json && json.status === 'OK' && json.data && typeof json.data === 'object' ? json.data : null;
36
+ }
37
+
38
+ function nonEmpty(value) {
39
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
40
+ }
41
+
42
+ async function requestMcpSignup(
43
+ { linkedinUrl, cv, email, firstName, lastName, userQuery, language } = {},
44
+ { endpoint, timeoutMs = SIGNUP_TIMEOUT_MS } = {},
45
+ ) {
46
+ if (!endpoint) return { ok: false, reason: 'no-endpoint' };
47
+ const fields = {
48
+ linkedinUrl: nonEmpty(linkedinUrl),
49
+ email: nonEmpty(email),
50
+ firstName: nonEmpty(firstName),
51
+ lastName: nonEmpty(lastName),
52
+ userQuery: nonEmpty(userQuery),
53
+ language: nonEmpty(language),
54
+ file: cv && Buffer.isBuffer(cv.data) ? cv : undefined,
55
+ };
56
+ let res;
57
+ try {
58
+ res = await postMultipartWithTimeout(endpoint, fields, timeoutMs, 'POST');
59
+ } catch (e) {
60
+ return { ok: false, reason: (e && e.kind) || 'network-error' };
61
+ }
62
+ if (res.status < 200 || res.status >= 300) return failure(res);
63
+ const data = envelopeData(res.raw);
64
+ if (!data || typeof data.claimCode !== 'string' || !data.claimCode) return { ok: false, reason: 'bad-response' };
65
+ return { ok: true, claimCode: data.claimCode, claimCodeExpiresAt: data.claimCodeExpiresAt || null };
66
+ }
67
+
68
+ // hub works/me/import-profile/status → { state: running | done | failed, sources: { linkedin?, cv? } }, the shape signup_status reports.
69
+ function importStateOf(data) {
70
+ const status = data.profileImportStatus;
71
+ const reported = Array.isArray(data.sources) ? data.sources : [];
72
+ const sources = {};
73
+ for (const entry of reported) {
74
+ if (!entry || !IMPORT_SOURCES.includes(entry.source) || !['imported', 'failed'].includes(entry.status)) continue;
75
+ const failed = entry.status === 'failed';
76
+ sources[entry.source] = { state: failed ? 'failed' : 'done', ...(failed && typeof entry.code === 'string' ? { code: entry.code } : {}) };
77
+ }
78
+ if (status === 'PENDING' || status === 'RUNNING') return { state: 'running', sources: {} };
79
+ if (status === 'FAILED' || status === 'SKIPPED') return { state: 'failed', code: data.errorCode || null, sources };
80
+ if (status === 'DONE') return { state: 'done', sources };
81
+ return { state: 'none', sources: {} };
82
+ }
83
+
84
+ async function requestMyImportStatus({ hubAccessToken } = {}, { endpoint, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
85
+ if (!endpoint) return { ok: false, reason: 'no-endpoint' };
86
+ if (!hubAccessToken) return { ok: false, reason: 'no-session' };
87
+ let res;
88
+ try {
89
+ res = await postJsonWithTimeout(endpoint, null, timeoutMs, 'GET', null, { Authorization: `Bearer ${hubAccessToken}` });
90
+ } catch (e) {
91
+ return { ok: false, reason: (e && e.kind) || 'network-error' };
92
+ }
93
+ if (res.status < 200 || res.status >= 300) return { ok: false, reason: `http-${res.status}`, status: res.status };
94
+ const data = envelopeData(res.raw);
95
+ if (!data) return { ok: false, reason: 'bad-response' };
96
+ return { ok: true, ...importStateOf(data) };
97
+ }
98
+
99
+ // better-auth reads the session from the cookie (email sign-in) or the Bearer session token (device flow).
100
+ async function requestOneTimeToken({ cookie = null, sessionToken = null } = {}, { endpoint, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
101
+ if (!endpoint) return { ok: false, reason: 'no-endpoint' };
102
+ const headers = {};
103
+ if (cookie) headers.Cookie = cookie;
104
+ else if (sessionToken) headers.Authorization = `Bearer ${sessionToken}`;
105
+ else return { ok: false, reason: 'no-session' };
106
+ let res;
107
+ try {
108
+ res = await postJsonWithTimeout(endpoint, null, timeoutMs, 'GET', null, headers);
109
+ } catch (e) {
110
+ return { ok: false, reason: (e && e.kind) || 'network-error' };
111
+ }
112
+ if (res.status < 200 || res.status >= 300) return { ok: false, reason: `http-${res.status}`, status: res.status };
113
+ const body = parseJson(res.raw);
114
+ const token = body && (typeof body.token === 'string' ? body.token : (body.data && body.data.token));
115
+ return typeof token === 'string' && token ? { ok: true, token } : { ok: false, reason: 'bad-response' };
116
+ }
117
+
118
+ module.exports = { requestMcpSignup, requestMyImportStatus, requestOneTimeToken, CODE_TO_REASON };