shakers-cli 0.16.0 → 0.17.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.
@@ -38,11 +38,57 @@ function stableNodePath(env, execPath) {
38
38
  return execPath;
39
39
  }
40
40
 
41
- function serverEntry({ nodePath, cliPath, env }) {
41
+ const pathFor = (platform) => (platform === 'win32' ? path.win32 : path.posix);
42
+
43
+ function realpathOr(dir) {
44
+ try {
45
+ return fs.realpathSync(dir);
46
+ } catch {
47
+ return dir;
48
+ }
49
+ }
50
+
51
+ function insideNpxCache(file, { env, platform, homedir }) {
52
+ const p = pathFor(platform);
53
+ const defaultCache = platform === 'win32'
54
+ ? p.join(env.LOCALAPPDATA || p.join(homedir, 'AppData', 'Local'), 'npm-cache')
55
+ : p.join(homedir, '.npm');
56
+ return [env.npm_config_cache, defaultCache].filter(Boolean).flatMap((dir) => [dir, realpathOr(dir)]).some((dir) => {
57
+ const rel = p.relative(p.join(dir, '_npx'), file);
58
+ return rel !== '' && !rel.startsWith('..') && !p.isAbsolute(rel);
59
+ });
60
+ }
61
+
62
+ function withoutNpxBins(value, where) {
63
+ const p = pathFor(where.platform);
64
+ return value.split(p.delimiter).filter((dir) => {
65
+ const parts = p.normalize(dir).split(p.sep);
66
+ const [last, parent] = [parts[parts.length - 1], parts[parts.length - 2]];
67
+ return !(last === 'node-gyp-bin' || (last === '.bin' && parent === 'node_modules') || insideNpxCache(dir, where));
68
+ }).join(p.delimiter);
69
+ }
70
+
71
+ function npxNextTo(nodePath) {
72
+ const candidate = path.join(path.dirname(nodePath), 'npx');
73
+ try {
74
+ fs.accessSync(candidate, fs.constants.X_OK);
75
+ return candidate;
76
+ } catch {
77
+ return 'npx';
78
+ }
79
+ }
80
+
81
+ function serverEntry({ nodePath, cliPath, env, platform = process.platform, homedir = os.homedir(), packageName }) {
82
+ const where = { env, platform, homedir };
83
+ const viaNpx = insideNpxCache(cliPath, where);
42
84
  const entryEnv = {};
43
- if (env.PATH) entryEnv.PATH = env.PATH;
85
+ const pathValue = env.PATH && viaNpx ? withoutNpxBins(env.PATH, where) : env.PATH;
86
+ if (pathValue) entryEnv.PATH = pathValue;
44
87
  for (const key of FORWARDED_ENV) if (env[key]) entryEnv[key] = env[key];
45
- return { command: nodePath, args: [cliPath, 'mcp'], env: entryEnv };
88
+ if (!viaNpx) return { command: nodePath, args: [cliPath, 'mcp'], env: entryEnv };
89
+ const npxArgs = ['-y', packageName, 'mcp'];
90
+ if (platform === 'win32') return { command: 'cmd', args: ['/c', 'npx', ...npxArgs], env: entryEnv };
91
+ return { command: npxNextTo(nodePath), args: npxArgs, env: entryEnv };
46
92
  }
47
93
 
48
94
  function claudeDesktopConfigPath({ platform, homedir, env }) {
@@ -51,11 +97,11 @@ function claudeDesktopConfigPath({ platform, homedir, env }) {
51
97
  return path.join(homedir, '.config', 'Claude', 'claude_desktop_config.json');
52
98
  }
53
99
 
54
- function writeJsonAtomic(file, value, mode) {
100
+ function writeFileAtomic(file, text, mode) {
55
101
  const tmp = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${Date.now()}.tmp`);
56
102
  const fd = fs.openSync(tmp, 'wx', mode);
57
103
  try {
58
- fs.writeSync(fd, `${JSON.stringify(value, null, 2)}\n`);
104
+ fs.writeSync(fd, text);
59
105
  fs.fsyncSync(fd);
60
106
  } catch (e) {
61
107
  fs.closeSync(fd);
@@ -78,14 +124,14 @@ function installIntoJsonConfig(file, entry, key = 'mcpServers') {
78
124
  try {
79
125
  config = raw.trim() ? JSON.parse(raw) : {};
80
126
  } catch {
81
- return { status: 'invalid-json' };
127
+ return { status: 'invalid-config' };
82
128
  }
83
- if (!config || typeof config !== 'object' || Array.isArray(config)) return { status: 'invalid-json' };
129
+ if (!config || typeof config !== 'object' || Array.isArray(config)) return { status: 'invalid-config' };
84
130
  mode = fs.statSync(file).mode & 0o777;
85
131
  }
86
132
  const current = config[key];
87
133
  if (current !== undefined && (!current || typeof current !== 'object' || Array.isArray(current))) {
88
- return { status: 'invalid-json' };
134
+ return { status: 'invalid-config' };
89
135
  }
90
136
  const servers = current || {};
91
137
  const legacy = Object.prototype.hasOwnProperty.call(servers, LEGACY_SERVER_NAME);
@@ -94,19 +140,156 @@ function installIntoJsonConfig(file, entry, key = 'mcpServers') {
94
140
  const next = { ...config, [key]: { ...servers, [SERVER_NAME]: entry } };
95
141
  delete next[key][LEGACY_SERVER_NAME];
96
142
  if (fs.existsSync(file)) fs.copyFileSync(file, `${file}.bak`);
97
- writeJsonAtomic(file, next, mode);
143
+ writeFileAtomic(file, `${JSON.stringify(next, null, 2)}\n`, mode);
98
144
  return { status: 'configured', removedLegacy: legacy };
99
145
  }
100
146
 
147
+ const TOML_ESCAPES = { '"': '\\"', '\\': '\\\\', '\b': '\\b', '\t': '\\t', '\n': '\\n', '\f': '\\f', '\r': '\\r' };
148
+ const tomlString = (s) => `"${String(s).replace(/["\\\u0000-\u001f\u007f]/g, (ch) => TOML_ESCAPES[ch] || `\\u${ch.charCodeAt(0).toString(16).padStart(4, '0')}`)}"`;
149
+ const tomlKey = (k) => (/^[A-Za-z0-9_-]+$/.test(k) ? k : tomlString(k));
150
+
151
+ function tomlServerBlock(entry, eol = '\n') {
152
+ const env = Object.entries(entry.env || {});
153
+ return [
154
+ `[mcp_servers.${SERVER_NAME}]`,
155
+ `command = ${tomlString(entry.command)}`,
156
+ `args = [${entry.args.map(tomlString).join(', ')}]`,
157
+ ...(env.length ? [`env = { ${env.map(([k, v]) => `${tomlKey(k)} = ${tomlString(v)}`).join(', ')} }`] : []),
158
+ 'startup_timeout_sec = 30',
159
+ '',
160
+ ].join(eol);
161
+ }
162
+
163
+ function parseTomlKeyPath(text) {
164
+ const keys = [];
165
+ let rest = text.trim();
166
+ while (rest) {
167
+ const m = rest.match(/^(?:([A-Za-z0-9_-]+)|"((?:[^"\\]|\\.)*)"|'([^']*)')\s*/);
168
+ if (!m) return null;
169
+ keys.push(m[1] ?? (m[2] !== undefined ? JSON.parse(`"${m[2]}"`) : m[3]));
170
+ rest = rest.slice(m[0].length);
171
+ if (!rest) break;
172
+ if (rest[0] !== '.') return null;
173
+ rest = rest.slice(1).trimStart();
174
+ if (!rest) return null;
175
+ }
176
+ return keys.length ? keys : null;
177
+ }
178
+
179
+ function scanTomlValue(line, state) {
180
+ let i = 0;
181
+ while (i < line.length) {
182
+ if (state.multiline) {
183
+ const end = line.indexOf(state.multiline, i);
184
+ if (end === -1) return;
185
+ i = end + 3;
186
+ state.multiline = null;
187
+ continue;
188
+ }
189
+ const ch = line[i];
190
+ if (ch === '#') return;
191
+ if (line.startsWith('"""', i) || line.startsWith("'''", i)) {
192
+ state.multiline = line.slice(i, i + 3);
193
+ i += 3;
194
+ } else if (ch === '"' || ch === "'") {
195
+ i += 1;
196
+ while (i < line.length && line[i] !== ch) i += ch === '"' && line[i] === '\\' ? 2 : 1;
197
+ i += 1;
198
+ } else {
199
+ if (ch === '[' || ch === '{') state.depth += 1;
200
+ if (ch === ']' || ch === '}') state.depth -= 1;
201
+ i += 1;
202
+ }
203
+ }
204
+ }
205
+
206
+ const OUR_TOML_NAMES = [SERVER_NAME, LEGACY_SERVER_NAME];
207
+ const isOurs = (keys) => keys.length >= 2 && keys[0] === 'mcp_servers' && OUR_TOML_NAMES.includes(keys[1]);
208
+
209
+ function removeTomlServers(text) {
210
+ const lines = text.match(/[^\n]*\n|[^\n]+$/g) || [];
211
+ const state = { multiline: null, depth: 0 };
212
+ const kept = [];
213
+ let table = [];
214
+ let ours = false;
215
+ let tail = [];
216
+ let removedLegacy = false;
217
+ const flushTail = () => {
218
+ const firstComment = tail.findIndex((l) => l.trim().startsWith('#'));
219
+ if (firstComment !== -1) kept.push(...tail.slice(firstComment));
220
+ tail = [];
221
+ };
222
+ for (const line of lines) {
223
+ const bare = line.replace(/\r?\n$/, '');
224
+ const trimmed = bare.trim();
225
+ if (!state.multiline && state.depth === 0 && trimmed.startsWith('[')) {
226
+ const header = trimmed.match(/^(\[\[?)\s*(.*?)\s*(\]\]?)\s*(#.*)?$/);
227
+ const keys = header && header[1].length === header[3].length ? parseTomlKeyPath(header[2]) : null;
228
+ if (!keys) return null;
229
+ if (header[1] === '[[' && keys[0] === 'mcp_servers' && (keys.length === 1 || isOurs(keys))) return null;
230
+ if (isOurs(keys)) {
231
+ if (keys[1] === LEGACY_SERVER_NAME) removedLegacy = true;
232
+ ours = true;
233
+ tail = [];
234
+ continue;
235
+ }
236
+ if (ours) flushTail();
237
+ ours = false;
238
+ table = keys;
239
+ kept.push(line);
240
+ continue;
241
+ }
242
+ if (!state.multiline && state.depth === 0 && trimmed && !trimmed.startsWith('#')) {
243
+ const eq = bare.match(/^\s*((?:[A-Za-z0-9_.\s-]|"(?:[^"\\]|\\.)*"|'[^']*')+?)\s*=/);
244
+ const keys = eq && parseTomlKeyPath(eq[1]);
245
+ if (!keys) return null;
246
+ const full = [...table, ...keys];
247
+ if (!ours && full[0] === 'mcp_servers' && (full.length === 1 || OUR_TOML_NAMES.includes(full[1]))) return null;
248
+ scanTomlValue(bare.slice(eq[0].length), state);
249
+ } else if (state.multiline || state.depth > 0) {
250
+ scanTomlValue(bare, state);
251
+ }
252
+ if (!ours) kept.push(line);
253
+ else if (!trimmed || trimmed.startsWith('#')) tail.push(line);
254
+ else tail = [];
255
+ }
256
+ if (state.multiline || state.depth !== 0) return null;
257
+ if (ours) flushTail();
258
+ return { text: kept.join(''), removedLegacy };
259
+ }
260
+
261
+ function installIntoTomlConfig(file, entry) {
262
+ if (!fs.existsSync(path.dirname(file))) return { status: 'not-found' };
263
+ const exists = fs.existsSync(file);
264
+ const raw = exists ? fs.readFileSync(file, 'utf8') : '';
265
+ const removed = removeTomlServers(raw);
266
+ if (!removed) return { status: 'invalid-config' };
267
+ const eol = raw.includes('\r\n') ? '\r\n' : '\n';
268
+ let base = removed.text;
269
+ if (base && !base.endsWith('\n')) base += eol;
270
+ if (base && !/\r?\n\r?\n$/.test(base)) base += eol;
271
+ const next = base + tomlServerBlock(entry, eol);
272
+ if (next === raw) return { status: 'unchanged' };
273
+ const mode = exists ? fs.statSync(file).mode & 0o777 : 0o600;
274
+ if (exists) fs.copyFileSync(file, `${file}.bak`);
275
+ writeFileAtomic(file, next, mode);
276
+ return { status: 'configured', removedLegacy: removed.removedLegacy };
277
+ }
278
+
279
+ function shellQuote(arg, platform) {
280
+ if (platform === 'win32') return /^[A-Za-z0-9_\-./=@\\:]+$/.test(arg) ? arg : `"${arg}"`;
281
+ return /^[A-Za-z0-9_\-./=@%+,]+$/.test(arg) ? arg : `'${arg.replace(/'/g, "'\\''")}'`;
282
+ }
283
+
284
+ function claudeCodeAddArgs(entry) {
285
+ const envFlags = Object.entries(entry.env).flatMap(([k, v]) => ['-e', `${k}=${v}`]);
286
+ // Name before `-e`: it swallows every value up to `--`.
287
+ return ['mcp', 'add', SERVER_NAME, '--scope', 'user', ...envFlags, '--', entry.command, ...entry.args];
288
+ }
289
+
101
290
  function installIntoClaudeCode(entry, { spawn }) {
102
291
  spawn('claude', ['mcp', 'remove', '--scope', 'user', SERVER_NAME], { encoding: 'utf8' });
103
- const envFlags = Object.entries(entry.env).flatMap(([k, v]) => ['-e', `${k}=${v}`]);
104
- const added = spawn(
105
- 'claude',
106
- // Name first: `-e` takes every value up to `--`, so a name after it is read as one more variable.
107
- ['mcp', 'add', SERVER_NAME, '--scope', 'user', ...envFlags, '--', entry.command, ...entry.args],
108
- { encoding: 'utf8' },
109
- );
292
+ const added = spawn('claude', claudeCodeAddArgs(entry), { encoding: 'utf8' });
110
293
  if (added.error || added.status !== 0) return { status: 'failed' };
111
294
  return { status: 'configured' };
112
295
  }
@@ -133,18 +316,23 @@ function codexSkill() {
133
316
  }
134
317
 
135
318
  function installCodexSkill({ env, homedir }) {
136
- const dir = path.join(env.CODEX_HOME || path.join(homedir, '.codex'), 'skills', SERVER_NAME);
319
+ const dir = path.join(codexHomeDir({ env, homedir }), 'skills', SERVER_NAME);
137
320
  fs.mkdirSync(dir, { recursive: true });
138
321
  fs.writeFileSync(path.join(dir, 'SKILL.md'), codexSkill());
139
322
  }
140
323
 
141
- function installIntoCodex(entry, { spawn, env, homedir }) {
142
- spawn('codex', ['mcp', 'remove', SERVER_NAME], { encoding: 'utf8' });
143
- const envFlags = Object.entries(entry.env).flatMap(([k, v]) => ['--env', `${k}=${v}`]);
144
- const added = spawn('codex', ['mcp', 'add', SERVER_NAME, ...envFlags, '--', entry.command, ...entry.args], { encoding: 'utf8' });
145
- if (added.error || added.status !== 0) return { status: 'failed' };
146
- installCodexSkill({ env, homedir });
147
- return { status: 'configured' };
324
+ const codexHomeDir = ({ env, homedir }) => env.CODEX_HOME || path.join(homedir, '.codex');
325
+
326
+ function chatgptAppInstalled({ platform, homedir }) {
327
+ return platform === 'darwin' && fs.existsSync(path.join(homedir, 'Library', 'Application Support', 'com.openai.chat'));
328
+ }
329
+
330
+ function installIntoCodex(entry, { env, homedir }) {
331
+ const dir = codexHomeDir({ env, homedir });
332
+ fs.mkdirSync(dir, { recursive: true });
333
+ const result = installIntoTomlConfig(path.join(dir, 'config.toml'), entry);
334
+ if (result.status === 'configured' || result.status === 'unchanged') installCodexSkill({ env, homedir });
335
+ return result;
148
336
  }
149
337
 
150
338
  function vscodeUserDir({ platform, homedir, env }) {
@@ -165,10 +353,20 @@ function clientTable({ env, platform, homedir, spawn }) {
165
353
  });
166
354
  return [
167
355
  jsonClient('claudeDesktop', claudeDesktopConfigPath({ platform, homedir, env })),
168
- { client: 'claudeCode', found: () => hasCli('claude'), install: (entry) => installIntoClaudeCode(entry, { spawn }) },
356
+ {
357
+ client: 'claudeCode',
358
+ found: () => hasCli('claude') || fs.existsSync(path.join(homedir, '.claude.json')),
359
+ install: (entry) => (hasCli('claude')
360
+ ? installIntoClaudeCode(entry, { spawn })
361
+ : { status: 'manual', command: ['claude', ...claudeCodeAddArgs(entry)].map((a) => shellQuote(a, platform)).join(' ') }),
362
+ },
169
363
  jsonClient('cursor', path.join(homedir, '.cursor', 'mcp.json')),
170
364
  jsonClient('geminiCli', path.join(homedir, '.gemini', 'settings.json')),
171
- { client: 'codex', found: () => hasCli('codex'), install: (entry) => installIntoCodex(entry, { spawn, env, homedir }) },
365
+ {
366
+ client: 'codex',
367
+ found: () => fs.existsSync(codexHomeDir({ env, homedir })) || hasCli('codex') || chatgptAppInstalled({ platform, homedir }),
368
+ install: (entry) => installIntoCodex(entry, { env, homedir }),
369
+ },
172
370
  // VS Code keys servers under "servers" and wants the transport spelled out.
173
371
  jsonClient('vscode', path.join(vscodeUserDir({ platform, homedir, env }), 'mcp.json'), 'servers', (entry) => ({ type: 'stdio', ...entry })),
174
372
  jsonClient('windsurf', path.join(homedir, '.codeium', 'windsurf', 'mcp_config.json')),
@@ -196,9 +394,10 @@ function installMcpClients({
196
394
  homedir = os.homedir(),
197
395
  nodePath = stableNodePath(env, process.execPath),
198
396
  cliPath = fs.realpathSync(path.join(__dirname, '..', 'bin', 'shakers.js')),
397
+ packageName = require('../package.json').name,
199
398
  spawn = spawnSync,
200
399
  } = {}) {
201
- const entry = serverEntry({ nodePath, cliPath, env });
400
+ const entry = serverEntry({ nodePath, cliPath, env, platform, homedir, packageName });
202
401
  return {
203
402
  entry,
204
403
  results: clientTable({ env, platform, homedir, spawn }).map((c) => ({
@@ -231,45 +430,81 @@ function askTerminal(question) {
231
430
  });
232
431
  }
233
432
 
234
- /**
235
- * Asks before touching the Talent's AI apps. SHAKERS_MCP_INSTALL=1 answers yes
236
- * without asking; with no terminal to ask on, nothing is touched.
237
- */
238
- async function runMcpInstall({ out = (s) => process.stdout.write(s), lang = null, ask = askTerminal, ...deps } = {}) {
239
- const { getCatalog, detectFlowLang } = require('./i18n');
240
- const c = getCatalog(lang || detectFlowLang()).mcpInstall;
433
+ const NEEDS_RESTART = new Set(['configured', 'manual']);
434
+
435
+ function parseInstallArgs(args) {
436
+ const flags = new Set(args);
437
+ return {
438
+ yes: flags.has('--yes') || flags.has('-y'),
439
+ json: flags.has('--json'),
440
+ help: flags.has('--help') || flags.has('-h'),
441
+ };
442
+ }
443
+
444
+ async function connectClients({ c, opts, out, ask, deps }) {
241
445
  const env = deps.env || process.env;
242
446
  const found = detectMcpClients(deps);
243
447
  out(`\n ${c.title}\n\n`);
244
448
  if (found.length === 0) {
245
449
  out(` ${c.noneFound}\n\n`);
246
- return { ok: true, results: [] };
450
+ return { ok: true, found, results: [], reason: 'no-apps' };
247
451
  }
248
452
  out(` ${c.found(found.map((client) => c.clients[client]).join(', '))}\n`);
249
- let yes = env.SHAKERS_MCP_INSTALL === '1';
453
+ let yes = opts.yes || env.SHAKERS_MCP_INSTALL === '1';
250
454
  if (!yes) {
251
455
  const answer = await ask(` ${c.confirm} `);
252
456
  if (answer === null) {
253
- out(`\n ${c.noTerminal}\n\n`);
254
- return { ok: true, results: [], declined: true };
457
+ out(`\n ${c.noTerminal}\n ${c.yesHint}\n\n`);
458
+ return { ok: true, found, results: [], declined: true, reason: 'no-terminal' };
255
459
  }
256
460
  yes = !/^\s*(n|no)\s*$/i.test(answer);
257
461
  }
258
462
  if (!yes) {
259
463
  out(`\n ${c.declined}\n\n`);
260
- return { ok: true, results: [], declined: true };
464
+ return { ok: true, found, results: [], declined: true, reason: 'declined' };
261
465
  }
262
466
 
263
467
  const { results } = installMcpClients(deps);
264
468
  out('\n');
265
469
  for (const r of results.filter((x) => x.status !== 'not-found')) {
266
470
  out(` ${c.clients[r.client]}: ${c.status[r.status]}\n`);
471
+ if (r.command) out(` ${r.command}\n`);
267
472
  if (r.removedLegacy) out(` ${c.removedLegacy}\n`);
268
473
  }
269
- const touched = results.some((r) => r.status === 'configured' || r.status === 'unchanged');
270
- out(`\n ${touched ? c.restart : c.failedAll}\n\n`);
271
- if (results.some((r) => r.status === 'failed' || r.status === 'invalid-json')) process.exitCode = 1;
272
- return { ok: true, results };
474
+ const touched = results.some((r) => ['configured', 'unchanged', 'manual'].includes(r.status));
475
+ const restart = results.filter((r) => NEEDS_RESTART.has(r.status)).map((r) => c.clients[r.client]);
476
+ const closing = !touched ? c.failedAll : restart.length ? c.restart(restart.join(', ')) : c.alreadyConnected;
477
+ out(`\n ${closing}\n\n`);
478
+ if (results.some((r) => r.status === 'failed' || r.status === 'invalid-config')) process.exitCode = 1;
479
+ return { ok: true, found, results, reason: null };
480
+ }
481
+
482
+ function jsonReport({ found, results, reason }, c) {
483
+ const rows = reason
484
+ ? found.map((client) => ({ client, status: 'skipped' }))
485
+ : results.filter((r) => r.status !== 'not-found');
486
+ const clients = rows.map((r) => ({
487
+ client: r.client,
488
+ name: c.clients[r.client],
489
+ status: r.status,
490
+ restartRequired: NEEDS_RESTART.has(r.status),
491
+ manualCommand: r.command || null,
492
+ }));
493
+ return { reason, clients, restart: clients.filter((r) => r.restartRequired).map((r) => r.name) };
494
+ }
495
+
496
+ // SHAKERS_MCP_INSTALL=1 answers yes; with no terminal, nothing is touched.
497
+ async function runMcpInstall({ args = [], out = (s) => process.stdout.write(s), lang = null, ask = askTerminal, ...deps } = {}) {
498
+ const { getCatalog, detectFlowLang } = require('./i18n');
499
+ const c = getCatalog(lang || detectFlowLang()).mcpInstall;
500
+ const opts = parseInstallArgs(args);
501
+ if (opts.help) {
502
+ out(`\n ${c.help}\n\n`);
503
+ return { ok: true, results: [] };
504
+ }
505
+ const outcome = await connectClients({ c, opts, out: opts.json ? () => {} : out, ask, deps });
506
+ if (opts.json) out(`${JSON.stringify(jsonReport(outcome, c))}\n`);
507
+ return outcome;
273
508
  }
274
509
 
275
- module.exports = { installCodexSkill, detectMcpClients, installMcpClients, stableNodePath, runMcpInstall, serverEntry, claudeDesktopConfigPath, SERVER_NAME, LEGACY_SERVER_NAME };
510
+ module.exports = { installCodexSkill, removeTomlServers, tomlServerBlock, detectMcpClients, installMcpClients, stableNodePath, runMcpInstall, serverEntry, claudeDesktopConfigPath, SERVER_NAME, LEGACY_SERVER_NAME };
@@ -17,7 +17,7 @@ function buildServerInstructions({ elicitation = false } = {}) {
17
17
  '4. signup_draft with your draft of their profile (name, role, city, years, stack, languages, work mode, hours, hourly and annual rate in euros, both always: the talent\'s figures with ratesFromTalent:true, else your estimate; sources: cv and/or linkedin, only those you have). It shows the draft, the data notice and how the account window works, and asks them to confirm; if they want changes, ask what and call it again.',
18
18
  '5. Once confirmed, signup_create_account with LinkedIn, cvPath, names and userQuery = everything you know about them; the window opens by itself, so say nothing about it. Then signup_status (waitSeconds 45) until the account exists, and follow its message (a failed LinkedIn import: its fixed line comes with the next question; retry once, then ask for their website or GitHub).',
19
19
  '6. New account: save_profile_details with the confirmed draft, without asking. Existing account (the talent signed in instead): update_existing_profile, which shows what would fill their empty fields and asks; nothing they already set is overwritten.',
20
- '7. AI usage, a step of its own: ai_usage without consent asks the question with the two disclaimers; nothing is scanned before their yes. On yes it runs in the background: never wait for it. If they skip it, its fixed line comes with the next question.',
20
+ '7. AI usage, a step of its own: ai_usage without consent asks the question with the two disclaimers; nothing is scanned before their yes. On yes it scans in the background and sends nothing. Then ai_usage_upload (waitSeconds 30) shows the talent what would be sent and what Shakers does with it, and asks; it uploads only on their own pick, and in the chat confirm:true only after they picked the upload option. If it answers scan-running, carry on and call it again right after the interview step. If they skip the analysis, its fixed line comes with the next question.',
21
21
  '8. Onboarding interview: skip it if signup_status says it is completed; otherwise onboarding_interview_start without disclaimerAcknowledged (here now or later on the web, default here), then how they want to answer (drafted, own, full), asked with the interview disclaimers. In drafted mode, show each question verbatim with your proposed answer and send it only after the talent approves or edits it; only if they explicitly ask you to answer everything yourself ("hazla entera", "do it all"), complete the remaining questions without per-answer approval and tell them at the end. Full mode is that from the first question. Own mode sends only their words. Never call onboarding_finish in this flow.',
22
22
  '9. Main role, also when they skip the interview: list_my_roles (waitSeconds 45, again while pending), then, only when its next is set_main_role, set_main_role without clusterId and with evidence = one concrete fact per recommended role from their CV, the interview or the AI-usage analysis (with nothing recommended: answer = the role their CV names and skills = their stack). Never set a main role the talent did not pick.',
23
23
  '10. Finish with open_web: it opens Shakers with the talent signed in.',
@@ -14,9 +14,9 @@ const PROMPTS = [
14
14
  {
15
15
  name: 'ai_usage',
16
16
  description: 'Evaluate and showcase how you work with AI (scans this machine, builds your "My work with AI").',
17
- tools: ['ai_usage'],
17
+ tools: ['ai_usage', 'ai_usage_upload'],
18
18
  buildMessages: () => userText(
19
- 'Evaluate how I work with AI for my Shakers profile. First ask me for consent and the email to attribute it to, then call the `ai_usage` tool and show me the resulting report verbatim. Never show internal ids/codes.',
19
+ 'Evaluate how I work with AI for my Shakers profile. First ask me for consent and the email to attribute it to, then call the `ai_usage` tool. Before anything is uploaded it shows me what would be sent and asks me: show that review verbatim, let me answer myself, and only then use `ai_usage_upload`. Show me the resulting report verbatim. Never show internal ids/codes.',
20
20
  ),
21
21
  },
22
22
  {
@@ -112,7 +112,24 @@ function makeRoleTools(deps = {}) {
112
112
  sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); }),
113
113
  now = () => Date.now(),
114
114
  funnel = require('./onboarding-funnel').NOOP_FUNNEL,
115
+ inSignup = () => require('./mcp-signup-tools').signupPhase() === 'account',
116
+ completeOnboarding = (session) => require('./onboarding-client').requestCompleteOnboarding(
117
+ { accessToken: session.accessToken, hubAccessToken: session.hubAccessToken },
118
+ { endpoint: require('./config').getCompleteOnboardingEndpoint() },
119
+ ),
115
120
  } = deps;
121
+ let clustersCount;
122
+ let onboardingCompleted = false;
123
+
124
+ async function finishSignup(session) {
125
+ if (onboardingCompleted || !inSignup()) return;
126
+ const res = await completeOnboarding(session).catch(() => null);
127
+ onboardingCompleted = !!(res && res.ok);
128
+ }
129
+
130
+ function trackSuggestions(roles) {
131
+ funnel.viewed('supercluster', { suggested_superclusters: roles.map((r) => r.clusterId), suggestion_source: 'assigned_clusters', suggestions_count: roles.length });
132
+ }
116
133
 
117
134
  const requireSession = () => {
118
135
  const session = loadAuthSession();
@@ -167,6 +184,7 @@ function makeRoleTools(deps = {}) {
167
184
  }
168
185
  const assigned = await requestAssignedClusters({ hubAccessToken }, { endpoint: getAssignedClustersEndpoint() });
169
186
  const clusters = assigned.ok ? assigned.clusters || [] : [];
187
+ if (assigned.ok) clustersCount = clusters.length;
170
188
  const recommended = clusters
171
189
  .filter((c) => c.type === 'RECOMMENDED')
172
190
  .sort((a, b) => (SOURCE_ORDER[a.source] ?? 2) - (SOURCE_ORDER[b.source] ?? 2));
@@ -193,6 +211,7 @@ function makeRoleTools(deps = {}) {
193
211
  next = 'set_main_role';
194
212
  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.';
195
213
  } else if (read.mainClusterId) {
214
+ await finishSignup(session);
196
215
  next = 'open_web';
197
216
  message = 'The talent already has a main role and nothing new was recommended: do not ask it again, go on to open_web. Only if the talent asks to change it, call set_main_role with catalogue:true.';
198
217
  } else {
@@ -230,11 +249,13 @@ function makeRoleTools(deps = {}) {
230
249
  const read = await readRoles(session.hubAccessToken);
231
250
  const recommended = byName(read.recommended);
232
251
  if (!recommended.length && read.mainClusterId) {
252
+ await finishSignup(session);
233
253
  return { reply: { ok: true, alreadySet: true, clusterId: read.mainClusterId, message: 'The talent already has a main role and nothing new was recommended: nothing to ask. Go on to open_web; only if they ask to change it, call set_main_role with catalogue:true.' } };
234
254
  }
235
255
  if (!recommended.length) return chooseFromCatalogue(args, ctx);
236
256
  const c = copy();
237
257
  const counts = await openCounts(session.hubAccessToken, recommended);
258
+ trackSuggestions(recommended);
238
259
  const evidence = args.evidence && typeof args.evidence === 'object' ? args.evidence : {};
239
260
  const blocks = recommended.map((r, i) => renderRole(lang(), { name: r.name, source: r.source, evidence: evidence[r.clusterId], openProjects: counts[i], recommended: i === 0 }));
240
261
  const asked = await askAfterTexts(ctx, {
@@ -296,6 +317,7 @@ function makeRoleTools(deps = {}) {
296
317
  typeof counts[i] === 'number' && counts[i] > 0 ? c.openProjects(counts[i]) : null,
297
318
  ].filter(Boolean).join('\n'));
298
319
  const texts = [[...(blocks.length ? [c.rolesIntro, ...blocks] : []), c.rolesOutro].join('\n\n')];
320
+ trackSuggestions([]);
299
321
  const asked = await askChoice(ctx, { texts, question: c.mainRoleQuestion, options: [...options.map((r) => r.name), c.mainRoleNotListed] });
300
322
  if (asked.answer === c.mainRoleNotListed) return askOwnRole();
301
323
  const role = asked.answer && options.find((r) => r.name === asked.answer);
@@ -330,8 +352,9 @@ function makeRoleTools(deps = {}) {
330
352
  const res = await requestSetMainRole({ clusterId, hubAccessToken: session.hubAccessToken }, { endpoint: getSetMainRoleEndpoint() });
331
353
  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.' };
332
354
  if (!res.ok) return { ok: false, reason: res.reason };
333
- funnel.completed('supercluster', { supercluster: clusterId, was_suggestion_accepted: suggestion });
355
+ funnel.completed('supercluster', { supercluster: clusterId, clusters_count: clustersCount, was_suggestion_accepted: suggestion });
334
356
  funnel.flush();
357
+ await finishSignup(session);
335
358
  return { ok: true, clusterId };
336
359
  }
337
360
 
@@ -338,6 +338,7 @@ function makeSignupTools(deps = {}) {
338
338
  wake: null,
339
339
  importStartedAt: null,
340
340
  importReported: new Set(),
341
+ authAttempts: undefined,
341
342
  };
342
343
  currentSignup = state;
343
344
 
@@ -345,8 +346,8 @@ function makeSignupTools(deps = {}) {
345
346
  const before = state.account.state;
346
347
  state.account = next;
347
348
  if ((next.state === 'created' || next.state === 'existing') && before !== next.state) {
348
- funnel.completed('account_creation', { auth_method: next.via, is_returning_account: next.state === 'existing' });
349
- funnel.identified();
349
+ funnel.completed('account_creation', { auth_method: next.via, auth_attempts: state.authAttempts, is_returning_account: next.state === 'existing' });
350
+ funnel.identified({ newAccount: next.state === 'created' });
350
351
  }
351
352
  if (next.state === 'failed' && before !== 'failed') {
352
353
  funnel.errored('account_creation', next.reason);
@@ -439,6 +440,7 @@ function makeSignupTools(deps = {}) {
439
440
  onUrl: (url) => { state.windowUrl = url; announce(url); },
440
441
  },
441
442
  ).then((outcome) => {
443
+ if (outcome && outcome.ok) state.authAttempts = outcome.attempts;
442
444
  if (outcome && outcome.viaSocial) return;
443
445
  if (!outcome || !outcome.ok) {
444
446
  setAccount({ state: 'failed', reason: (outcome && outcome.reason) || 'window-failed' });
@@ -487,6 +489,7 @@ function makeSignupTools(deps = {}) {
487
489
  // rides on the next question block, because models skip a relay that comes before another tool call.
488
490
  if (!nonEmpty(args.answer) && state.cvPath) {
489
491
  funnel.completed('start');
492
+ funnel.viewed('import_sources', { sources_offered: DRAFT_SOURCES });
490
493
  queueNotice(c.welcome(nonEmpty(args.firstName)));
491
494
  return {
492
495
  ok: true,
@@ -505,6 +508,7 @@ function makeSignupTools(deps = {}) {
505
508
  });
506
509
  if (asked.reply) return { ...asked.reply, language: lang() };
507
510
  funnel.completed('start');
511
+ funnel.viewed('import_sources', { sources_offered: DRAFT_SOURCES });
508
512
  // A CV path the talent gave wins over both answers: searching could read another candidate.
509
513
  const cvPath = state.cvPath;
510
514
  if (cvPath) {
@@ -665,10 +669,10 @@ function makeSignupTools(deps = {}) {
665
669
  const attempted = DRAFT_SOURCES.filter((source) => (source === 'cv' ? !!cv : !!sources.linkedinUrl));
666
670
  state.importStartedAt = Object.fromEntries(attempted.map((source) => [source, now()]));
667
671
  for (const source of attempted) funnel.track('source_import_started', 'import_sources', { source_type: source });
668
- funnel.completed('import_sources', { sources_attempted: attempted });
672
+ funnel.completed('import_sources', { sources_attempted: attempted, sources_connected: attempted, sources_connected_count: attempted.length });
669
673
  }
670
674
  if (!reused) {
671
- funnel.viewed('account_creation');
675
+ funnel.viewed('account_creation', { auth_methods_offered: require('./mcp-auth-form').WINDOW_METHODS.register });
672
676
  await openWindow(identity);
673
677
  }
674
678
  return {
@@ -714,7 +718,7 @@ function makeSignupTools(deps = {}) {
714
718
 
715
719
  function nextSteps(account) {
716
720
  const interview = 'Then the onboarding interview: if onboardingInterview.completed is true, skip it without mentioning it; otherwise call onboarding_interview_start without disclaimerAcknowledged (it asks here now or later on the web). Then, also if they skip it, the main role: list_my_roles (waitSeconds 45), then set_main_role without clusterId only when its next says so. Finally open_web.';
717
- const aiUsage = 'Then ai_usage without consent: it returns the two AI-usage disclaimers and the question (show them right before asking; no scan happens before a yes).';
721
+ const aiUsage = 'Then ai_usage without consent: it returns the two AI-usage disclaimers and the question (show them right before asking; no scan happens before a yes). After a yes, ai_usage_upload (waitSeconds 30) shows the talent what would be sent and asks; nothing is uploaded without their pick.';
718
722
  if (account.state === 'existing') {
719
723
  return `This talent already had an account and signed in. Call update_existing_profile: it shows what would change and asks whether to update, and its text tells the talent about the account, so say nothing about it yourself. ${aiUsage} ${interview}`;
720
724
  }
@@ -958,11 +962,12 @@ function makeSignupTools(deps = {}) {
958
962
  }
959
963
 
960
964
  function trackDraftShown(draft) {
961
- funnel.viewed('work_situation', { is_prefilled: true });
962
- funnel.viewed('availability');
965
+ const prefilled = (step) => ({ is_prefilled: STEP_DRAFT_FIELDS[step].some((key) => draft[key] !== undefined) });
966
+ funnel.viewed('work_situation', prefilled('work_situation'));
967
+ funnel.viewed('availability', prefilled('availability'));
963
968
  funnel.viewed('rates', { suggested_rate_shown: draft.ratesFromTalent !== true });
964
- funnel.viewed('languages');
965
- funnel.viewed('location');
969
+ funnel.viewed('languages', prefilled('languages'));
970
+ funnel.viewed('location', prefilled('location'));
966
971
  funnel.viewed('profile_summary');
967
972
  }
968
973
 
@@ -978,7 +983,8 @@ function makeSignupTools(deps = {}) {
978
983
 
979
984
  function trackSavedSteps(details, results) {
980
985
  const saved = (section) => results[section] && results[section].ok === true;
981
- const step = (name, props) => funnel.completed(name, { ...props, is_prefilled: true, was_edited: wasEdited(name) });
986
+ const draftTime = funnel.stepTime('profile_summary');
987
+ const step = (name, props) => funnel.completed(name, { ...props, is_prefilled: true, was_edited: wasEdited(name), time_on_step_ms: draftTime });
982
988
  if (saved('workSituation')) {
983
989
  step('work_situation', { work_situation: workSituationValue(String(details.workSituation.situation || '').trim().toUpperCase()) });
984
990
  }
@@ -996,7 +1002,7 @@ function makeSignupTools(deps = {}) {
996
1002
  }
997
1003
  if (saved('pricing')) {
998
1004
  const types = rateTypes(pricingFromRates(details.pricing).input);
999
- funnel.completed('rates', { rate_types_selected: types, rate_type: types[0], rate_currency: 'EUR', is_prefilled: !(state.draft && state.draft.ratesFromTalent), was_edited: wasEdited('rates') });
1005
+ funnel.completed('rates', { rate_types_selected: types, rate_type: types[0], rate_currency: 'EUR', is_prefilled: !(state.draft && state.draft.ratesFromTalent), was_edited: wasEdited('rates'), time_on_step_ms: draftTime });
1000
1006
  }
1001
1007
  if (saved('languages')) {
1002
1008
  const codes = details.languages.map((row) => String(row.language || '').trim().toLowerCase()).filter(Boolean);
@@ -1004,7 +1010,7 @@ function makeSignupTools(deps = {}) {
1004
1010
  }
1005
1011
  const sections = Object.entries(results).filter(([name]) => name !== 'phone');
1006
1012
  if (sections.length && sections.every(([, r]) => r.ok === true)) {
1007
- funnel.completed('profile_summary', { fields_edited_count: draftFieldsEdited() });
1013
+ funnel.completed('profile_summary', { fields_edited_count: draftFieldsEdited(), time_on_step_ms: draftTime });
1008
1014
  }
1009
1015
  funnel.flush();
1010
1016
  }