shakers-cli 0.15.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.
Files changed (132) hide show
  1. package/README.md +56 -1203
  2. package/bin/ai-usage.js +32 -58
  3. package/bin/login.js +10 -10
  4. package/bin/mcp.js +17 -12
  5. package/bin/mcpb.js +1 -1
  6. package/bin/onboarding.js +3 -2
  7. package/bin/register.js +19 -5
  8. package/bin/report-html.js +2 -2
  9. package/bin/shakers-cli.js +352 -0
  10. package/bin/shakers.js +20 -344
  11. package/bin/share.js +1 -1
  12. package/bin/superadmin.js +5 -5
  13. package/bin/version.js +3 -0
  14. package/package.json +12 -3
  15. package/src/account-marker.js +40 -0
  16. package/src/account-trace.js +23 -0
  17. package/src/agent-category.js +1 -1
  18. package/src/agent-evaluation.js +3 -3
  19. package/src/agent-org-chart.js +5 -5
  20. package/src/agent-synthesis.js +3 -3
  21. package/src/agent-usage.js +1 -1
  22. package/src/agents-client.js +4 -4
  23. package/src/ai-usage-review.js +101 -0
  24. package/src/ai.js +1 -1
  25. package/src/ansi.js +1 -1
  26. package/src/auth-client.js +18 -4
  27. package/src/auth-method-picker.js +1 -1
  28. package/src/auth-methods.js +1 -1
  29. package/src/auth-session-store.js +18 -8
  30. package/src/authorship-mismatch.js +1 -1
  31. package/src/authorship.js +2 -2
  32. package/src/automations-detector.js +1 -1
  33. package/src/backend-request.js +5 -3
  34. package/src/brand-ansi.js +1 -1
  35. package/src/browser-tools-detector.js +1 -1
  36. package/src/certify-disclaimer.js +1 -1
  37. package/src/certify-remediation-prompt.js +1 -1
  38. package/src/cli-args.js +2 -2
  39. package/src/command-audit.js +8 -5
  40. package/src/config-dir.js +1 -1
  41. package/src/config.js +114 -48
  42. package/src/consent-flow.js +7 -6
  43. package/src/consent-skip.js +1 -1
  44. package/src/detection-evidence.js +2 -2
  45. package/src/detection-noise.js +1 -1
  46. package/src/detectors.js +1 -1
  47. package/src/device-login.js +12 -1
  48. package/src/email-verification.js +3 -3
  49. package/src/env-paths.js +1 -1
  50. package/src/find-projects-client.js +12 -0
  51. package/src/find-projects-flow.js +1 -13
  52. package/src/git-activity.js +1 -1
  53. package/src/graph-certs.js +3 -3
  54. package/src/graph-scan.js +9 -9
  55. package/src/i18n.js +148 -138
  56. package/src/interactive-select.js +1 -1
  57. package/src/interview-client.js +6 -1
  58. package/src/interview-transcript.js +1 -1
  59. package/src/legal-notice.js +1 -1
  60. package/src/maturity.js +6 -6
  61. package/src/mcp-ai-usage-result-tool.js +34 -12
  62. package/src/mcp-ai-usage-tool.js +282 -163
  63. package/src/mcp-auth-form.js +7 -3
  64. package/src/mcp-detector.js +3 -3
  65. package/src/mcp-device-login-tools.js +2 -1
  66. package/src/mcp-find-projects-tools.js +1 -2
  67. package/src/mcp-install.js +279 -44
  68. package/src/mcp-instructions.js +1 -1
  69. package/src/mcp-onboarding-tools.js +4 -4
  70. package/src/mcp-prompts.js +4 -4
  71. package/src/mcp-register-tools.js +1 -0
  72. package/src/mcp-role-tools.js +24 -1
  73. package/src/mcp-server.js +5 -1
  74. package/src/mcp-signup-client.js +2 -2
  75. package/src/mcp-signup-tools.js +39 -9
  76. package/src/memory-structure-detector.js +1 -1
  77. package/src/model-call-record.js +2 -2
  78. package/src/onboarding-flow.js +63 -42
  79. package/src/onboarding-funnel.js +153 -20
  80. package/src/open-file.js +1 -1
  81. package/src/osc-link.js +1 -1
  82. package/src/portfolio-client.js +11 -1
  83. package/src/portfolio-git-info.js +1 -1
  84. package/src/profile-flow.js +1 -1
  85. package/src/profile-switch.js +5 -12
  86. package/src/render-certification.js +10 -10
  87. package/src/render-html.js +6 -6
  88. package/src/render-report-text.js +1 -1
  89. package/src/render-sheet.js +10 -10
  90. package/src/render-terminal.js +6 -6
  91. package/src/repo-scope.js +132 -0
  92. package/src/report-gating.js +5 -3
  93. package/src/report-store.js +13 -8
  94. package/src/roadmap-content.js +5 -5
  95. package/src/roadmap-personalization.js +3 -3
  96. package/src/roadmap-prompt.js +2 -2
  97. package/src/roles-flow.js +8 -5
  98. package/src/scan-exclusions.js +1 -1
  99. package/src/scanner.js +14 -14
  100. package/src/secret-prompt.js +1 -1
  101. package/src/session-gate.js +1 -1
  102. package/src/session-refresh.js +33 -3
  103. package/src/session-scan.js +1 -1
  104. package/src/shakers-wordmark.js +1 -1
  105. package/src/share-card.js +2 -2
  106. package/src/share-certification.js +4 -4
  107. package/src/share-consent-state.js +9 -9
  108. package/src/share-payload.js +7 -7
  109. package/src/share.js +7 -7
  110. package/src/signup-client.js +1 -1
  111. package/src/signup-copy.js +177 -2
  112. package/src/skills-client.js +1 -1
  113. package/src/start-add-agents.js +2 -2
  114. package/src/start-add-portfolio.js +3 -13
  115. package/src/start-add-skills.js +2 -2
  116. package/src/stdin-ask.js +16 -4
  117. package/src/strip-internal-comments.js +1 -1
  118. package/src/tech-detector.js +2 -2
  119. package/src/templates/report-sheet.html +11 -13
  120. package/src/terminal-agents.js +8 -8
  121. package/src/terminal-format.js +3 -3
  122. package/src/terminal-sections.js +11 -11
  123. package/src/tier-engine.js +3 -3
  124. package/src/tier-projection.js +2 -2
  125. package/src/transcript-turns.js +1 -1
  126. package/src/typewriter.js +1 -1
  127. package/src/usage-model-calls.js +6 -6
  128. package/src/usage-oneshot-commands.js +5 -5
  129. package/src/usage-repo-scope.js +11 -129
  130. package/src/usage-run-steps.js +82 -181
  131. package/src/usage-scan.js +235 -0
  132. package/src/work-streams.js +1 -1
@@ -3,8 +3,7 @@
3
3
  // MCP: list the Positions/Projects available to the talent (Find Positions),
4
4
  // paginated + filtered server-side. Same params as the CLI so an agent can page.
5
5
 
6
- const { TABS, ATTENDANCE, DEFAULT_LIMIT } = require('./find-projects-client');
7
- const { splitRecommended } = require('./find-projects-flow');
6
+ const { TABS, ATTENDANCE, DEFAULT_LIMIT, splitRecommended } = require('./find-projects-client');
8
7
 
9
8
  const FIND_PROJECTS_SCHEMA = {
10
9
  type: 'object',
@@ -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.',
@@ -76,6 +76,7 @@ function makeOnboardingTools(deps = {}) {
76
76
  requestDiscoveredInventory = require('./inventory-client').requestDiscoveredInventory,
77
77
  requestDiscoveredInventoryWithRefresh = require('./inventory-client').requestDiscoveredInventoryWithRefresh,
78
78
  ensureFreshSession = require('./session-refresh').ensureFreshSession,
79
+ endSession = require('./session-refresh').endSession,
79
80
  requestDeclareSkill = require('./skills-client').requestDeclareSkill,
80
81
  requestDeclareAgent = require('./agents-client').requestDeclareAgent,
81
82
  requestListAgents = require('./agents-client').requestListAgents,
@@ -86,7 +87,7 @@ function makeOnboardingTools(deps = {}) {
86
87
  getRemoteUrl = require('./portfolio-git-info').getRemoteUrl,
87
88
  getCommitDateRange = require('./portfolio-git-info').getCommitDateRange,
88
89
  consentCopy = () => require('./signup-copy').signupCopy(require('./signup-language').signupLanguage(require('./i18n').detectFlowLang())).consent,
89
- normalizeDomain = require('./start-add-portfolio').normalizeDomain,
90
+ normalizeDomain = require('./portfolio-client').normalizeDomain,
90
91
  isValidEmail = require('./share').isValidEmail,
91
92
  normalizeEmail = require('./share').normalizeEmail,
92
93
  } = deps;
@@ -115,8 +116,7 @@ function makeOnboardingTools(deps = {}) {
115
116
  };
116
117
 
117
118
  async function logout() {
118
- const wasLoggedIn = loadAuthSession() !== null;
119
- try { clearAuthSession(); } catch { /* idempotent — nothing to clear */ }
119
+ const wasLoggedIn = await endSession(process.env, { loadAuthSession, clearAuthSession });
120
120
  return { ok: true, wasLoggedIn };
121
121
  }
122
122
 
@@ -251,7 +251,7 @@ function makeOnboardingTools(deps = {}) {
251
251
  const relatedInput = Array.isArray(args.relatedPortfolios) ? args.relatedPortfolios : [];
252
252
  if (relatedInput.length === 0 || !agentId) return { ...base, portfolios };
253
253
 
254
- // ADR-041 replace-set: validate every portfolioId against the talent's own
254
+ // Replace-set: validate every portfolioId against the talent's own
255
255
  // list, then PATCH the whole set in ONE call.
256
256
  const items = [];
257
257
  const relateFailures = [];
@@ -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
  {
@@ -37,10 +37,10 @@ const PROMPTS = [
37
37
  },
38
38
  {
39
39
  name: 'certify',
40
- description: 'See which role dimensions you can certify and start a certification.',
40
+ description: 'See which role dimensions you can certify; the certification itself is taken on the Shakers web.',
41
41
  tools: ['list_certifiable_dimensions'],
42
42
  buildMessages: () => userText(
43
- 'Help me certify on Shakers: call `list_certifiable_dimensions`, present the dimensions I can certify by name, and guide me through certifying the one I pick.',
43
+ 'Help me certify on Shakers: call `list_certifiable_dimensions` and present the dimensions I can certify by name, and when I can retry the others. The certification interview is taken on the Shakers web, not in this chat: offer to open it with `open_web`. Never show internal ids/codes.',
44
44
  ),
45
45
  },
46
46
  {
@@ -204,6 +204,7 @@ function makeRegisterTools(deps = {}) {
204
204
 
205
205
  // Creates the interview, joins its room and reads the greeting; `room` lets a timed-out caller close what opened late.
206
206
  async function openRoom(args, room) {
207
+ if (typeof resolved.makeInterviewClient !== 'function') return onWeb('connect', 'livekit-not-installed');
207
208
  const start = await flow.createAndStartLivekitOnboarding(resolved, {
208
209
  candidateId: args.candidateId,
209
210
  language: args.language || lang,
@@ -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
 
package/src/mcp-server.js CHANGED
@@ -34,7 +34,7 @@ function errorResponse(id, code, message, data) {
34
34
  return { jsonrpc: JSONRPC, id: id === undefined ? null : id, error };
35
35
  }
36
36
 
37
- function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVersion, beforeCall = null, onInitialize = null, instructions = null, elicitationTimeoutMs = ELICITATION_TIMEOUT_MS } = {}) {
37
+ function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVersion, beforeCall = null, afterCall = null, onInitialize = null, instructions = null, elicitationTimeoutMs = ELICITATION_TIMEOUT_MS } = {}) {
38
38
  const registry = new Map();
39
39
  for (const t of tools) {
40
40
  if (!t || !t.name || typeof t.handler !== 'function') {
@@ -185,6 +185,10 @@ function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVe
185
185
  content: [{ type: 'text', text: `${name} tool error: ${e && e.message ? e.message : String(e)}` }],
186
186
  isError: true,
187
187
  };
188
+ } finally {
189
+ if (typeof afterCall === 'function') {
190
+ try { afterCall(name); } catch { /* hook must never break a tool call */ }
191
+ }
188
192
  }
189
193
  }
190
194
 
@@ -2,12 +2,12 @@
2
2
 
3
3
  const { postJsonWithTimeout, postMultipartWithTimeout } = require('./backend-request');
4
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.
5
+ // Hub calls of the MCP sign-up: public sign-up + claim code, the signed-in talent's import status, and the one-time web login token.
6
6
 
7
7
  const SIGNUP_TIMEOUT_MS = 60000;
8
8
  const DEFAULT_TIMEOUT_MS = 15000;
9
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.
10
+ // Hub's coded errors on the sign-up; none says whether an account exists.
11
11
  const CODE_TO_REASON = {
12
12
  MISSING_SOURCE: 'missing-source',
13
13
  INVALID_CV: 'invalid-cv',