shakers-cli-beta 0.8.0-beta.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 (218) hide show
  1. package/LICENSE +59 -0
  2. package/README.md +1175 -0
  3. package/bin/add-agent.js +88 -0
  4. package/bin/add-project.js +88 -0
  5. package/bin/add-role.js +61 -0
  6. package/bin/add-skill.js +88 -0
  7. package/bin/ai-usage.js +81 -0
  8. package/bin/alma.js +99 -0
  9. package/bin/applications.js +69 -0
  10. package/bin/availability.js +70 -0
  11. package/bin/certifications.js +69 -0
  12. package/bin/certify.js +73 -0
  13. package/bin/change-role.js +61 -0
  14. package/bin/config.js +120 -0
  15. package/bin/experiences.js +69 -0
  16. package/bin/find-projects.js +89 -0
  17. package/bin/invitations.js +69 -0
  18. package/bin/lang.js +70 -0
  19. package/bin/login.js +283 -0
  20. package/bin/mcp.js +98 -0
  21. package/bin/me.js +69 -0
  22. package/bin/onboarding.js +134 -0
  23. package/bin/portfolios.js +69 -0
  24. package/bin/rate.js +70 -0
  25. package/bin/register.js +325 -0
  26. package/bin/report-html.js +134 -0
  27. package/bin/save.js +77 -0
  28. package/bin/shakers.js +344 -0
  29. package/bin/share.js +72 -0
  30. package/bin/show-project.js +72 -0
  31. package/bin/socials.js +70 -0
  32. package/bin/superadmin.js +351 -0
  33. package/package.json +40 -0
  34. package/src/agent-category.js +13 -0
  35. package/src/agent-evaluation.js +177 -0
  36. package/src/agent-org-chart.js +355 -0
  37. package/src/agent-synthesis.js +132 -0
  38. package/src/agent-usage.js +118 -0
  39. package/src/agents-client.js +165 -0
  40. package/src/ai-fluency-cell.js +72 -0
  41. package/src/ai-profile-client.js +45 -0
  42. package/src/ai-profile-preview.js +154 -0
  43. package/src/ai.js +66 -0
  44. package/src/alma-client.js +123 -0
  45. package/src/alma-flow.js +32 -0
  46. package/src/alma-repo-context.js +47 -0
  47. package/src/alma-shell.js +161 -0
  48. package/src/ansi.js +62 -0
  49. package/src/applications-client.js +79 -0
  50. package/src/applications-flow.js +58 -0
  51. package/src/auth-client.js +144 -0
  52. package/src/auth-device-client.js +118 -0
  53. package/src/auth-method-picker.js +44 -0
  54. package/src/auth-methods.js +35 -0
  55. package/src/auth-session-store.js +214 -0
  56. package/src/authorship.js +203 -0
  57. package/src/automations-detector.js +170 -0
  58. package/src/availability-client.js +92 -0
  59. package/src/availability-flow.js +105 -0
  60. package/src/backend-request.js +170 -0
  61. package/src/blind-approval-extract.js +48 -0
  62. package/src/brand-ansi.js +14 -0
  63. package/src/brand-svg.js +9 -0
  64. package/src/browser-tools-detector.js +21 -0
  65. package/src/build-next-level.js +56 -0
  66. package/src/certifications-client.js +89 -0
  67. package/src/certifications-flow.js +75 -0
  68. package/src/certify-dimension-client.js +217 -0
  69. package/src/certify-dimension-flow.js +193 -0
  70. package/src/certify-disclaimer.js +51 -0
  71. package/src/certify-remediation-prompt.js +23 -0
  72. package/src/cli-args.js +83 -0
  73. package/src/config-dir.js +120 -0
  74. package/src/config.js +778 -0
  75. package/src/consent-flow.js +115 -0
  76. package/src/consent-skip.js +27 -0
  77. package/src/decisions-extract.js +43 -0
  78. package/src/detect-repos.js +145 -0
  79. package/src/detected-tools.js +15 -0
  80. package/src/detection-evidence.js +105 -0
  81. package/src/detection-noise.js +132 -0
  82. package/src/detectors.js +221 -0
  83. package/src/device-auth-store.js +100 -0
  84. package/src/device-login.js +118 -0
  85. package/src/email-verification.js +152 -0
  86. package/src/env-paths.js +16 -0
  87. package/src/find-projects-cache.js +89 -0
  88. package/src/find-projects-client.js +138 -0
  89. package/src/find-projects-flow.js +164 -0
  90. package/src/git-activity.js +123 -0
  91. package/src/graph-certs.js +58 -0
  92. package/src/graph-generator.js +230 -0
  93. package/src/graph-scan.js +239 -0
  94. package/src/i18n.js +3264 -0
  95. package/src/inter-fonts.js +6 -0
  96. package/src/interactive-select.js +183 -0
  97. package/src/interview-client.js +226 -0
  98. package/src/interview-transcript.js +56 -0
  99. package/src/inventory-client.js +90 -0
  100. package/src/invitations-client.js +66 -0
  101. package/src/invitations-flow.js +50 -0
  102. package/src/lang-client.js +147 -0
  103. package/src/lang-codes.js +14 -0
  104. package/src/lang-flow.js +85 -0
  105. package/src/legal-notice.js +53 -0
  106. package/src/locale.js +51 -0
  107. package/src/markdown-ansi.js +50 -0
  108. package/src/maturity.js +111 -0
  109. package/src/mcp-ai-usage-result-tool.js +109 -0
  110. package/src/mcp-ai-usage-tool.js +333 -0
  111. package/src/mcp-alma-tools.js +91 -0
  112. package/src/mcp-applications-tools.js +42 -0
  113. package/src/mcp-ask-tools.js +50 -0
  114. package/src/mcp-auth-form.js +282 -0
  115. package/src/mcp-availability-tools.js +43 -0
  116. package/src/mcp-certifications-tools.js +42 -0
  117. package/src/mcp-certify-dimension-tools.js +58 -0
  118. package/src/mcp-detector.js +223 -0
  119. package/src/mcp-device-login-tools.js +173 -0
  120. package/src/mcp-find-projects-tools.js +67 -0
  121. package/src/mcp-install.js +245 -0
  122. package/src/mcp-invitations-tools.js +42 -0
  123. package/src/mcp-lang-tools.js +39 -0
  124. package/src/mcp-onboarding-tools.js +388 -0
  125. package/src/mcp-portfolios-tools.js +46 -0
  126. package/src/mcp-profile-tools.js +46 -0
  127. package/src/mcp-rate-tools.js +42 -0
  128. package/src/mcp-register-tools.js +517 -0
  129. package/src/mcp-report-tools.js +101 -0
  130. package/src/mcp-role-tools.js +112 -0
  131. package/src/mcp-saved-positions-tools.js +55 -0
  132. package/src/mcp-server.js +170 -0
  133. package/src/mcp-show-project-tools.js +50 -0
  134. package/src/mcp-socials-tools.js +39 -0
  135. package/src/memory-structure-detector.js +108 -0
  136. package/src/model-call-record.js +198 -0
  137. package/src/onboarding-availability.js +26 -0
  138. package/src/onboarding-client.js +364 -0
  139. package/src/onboarding-flow.js +1078 -0
  140. package/src/onboarding-livekit-client.js +102 -0
  141. package/src/onboarding-status-client.js +42 -0
  142. package/src/open-file.js +32 -0
  143. package/src/osc-link.js +16 -0
  144. package/src/portfolio-client.js +164 -0
  145. package/src/portfolio-git-info.js +61 -0
  146. package/src/portfolios-client.js +85 -0
  147. package/src/portfolios-flow.js +79 -0
  148. package/src/profile-client.js +125 -0
  149. package/src/profile-flow.js +115 -0
  150. package/src/profile-switch.js +46 -0
  151. package/src/prompt-select.js +29 -0
  152. package/src/rate-client.js +82 -0
  153. package/src/rate-flow.js +137 -0
  154. package/src/render-ai-fluency.js +266 -0
  155. package/src/render-certification.js +177 -0
  156. package/src/render-html.js +140 -0
  157. package/src/render-report-text.js +27 -0
  158. package/src/render-sheet.js +261 -0
  159. package/src/render-terminal.js +109 -0
  160. package/src/render-usage-report-text.js +155 -0
  161. package/src/repo-context.js +215 -0
  162. package/src/report-gating.js +49 -0
  163. package/src/report-store.js +226 -0
  164. package/src/report-theme.js +177 -0
  165. package/src/report-view-client.js +50 -0
  166. package/src/reveal-output.js +29 -0
  167. package/src/roadmap-content.js +453 -0
  168. package/src/roadmap-personalization.js +132 -0
  169. package/src/roadmap-prompt.js +154 -0
  170. package/src/roles-client.js +114 -0
  171. package/src/roles-flow.js +192 -0
  172. package/src/sanitize-network-text.js +36 -0
  173. package/src/saved-positions-client.js +71 -0
  174. package/src/saved-positions-flow.js +50 -0
  175. package/src/scan-exclusions.js +50 -0
  176. package/src/scanner.js +612 -0
  177. package/src/scope-signals.js +148 -0
  178. package/src/secret-prompt.js +72 -0
  179. package/src/session-gate.js +30 -0
  180. package/src/session-refresh.js +51 -0
  181. package/src/session-scan.js +336 -0
  182. package/src/session-signals.js +36 -0
  183. package/src/shakers-wordmark.js +17 -0
  184. package/src/share-card.js +362 -0
  185. package/src/share-certification.js +111 -0
  186. package/src/share-consent-state.js +157 -0
  187. package/src/share-payload.js +300 -0
  188. package/src/share.js +184 -0
  189. package/src/show-project-client.js +58 -0
  190. package/src/show-project-flow.js +119 -0
  191. package/src/signup-client.js +91 -0
  192. package/src/skills-client.js +102 -0
  193. package/src/socials-client.js +91 -0
  194. package/src/socials-flow.js +83 -0
  195. package/src/specificity-extract.js +41 -0
  196. package/src/start-add-agents.js +306 -0
  197. package/src/start-add-portfolio.js +388 -0
  198. package/src/start-add-skills.js +260 -0
  199. package/src/stdin-ask.js +129 -0
  200. package/src/steering-extract.js +43 -0
  201. package/src/strip-internal-comments.js +45 -0
  202. package/src/tech-detector.js +295 -0
  203. package/src/templates/report-sheet.html +572 -0
  204. package/src/terminal-agents.js +199 -0
  205. package/src/terminal-format.js +85 -0
  206. package/src/terminal-progress.js +106 -0
  207. package/src/terminal-sections.js +219 -0
  208. package/src/tier-analysis.js +126 -0
  209. package/src/tier-engine.js +114 -0
  210. package/src/tier-projection.js +117 -0
  211. package/src/transcript-turns.js +49 -0
  212. package/src/translations-client.js +84 -0
  213. package/src/typewriter.js +154 -0
  214. package/src/usage-model-calls.js +119 -0
  215. package/src/usage-oneshot-commands.js +180 -0
  216. package/src/usage-repo-scope.js +162 -0
  217. package/src/usage-run-steps.js +338 -0
  218. package/src/work-streams.js +74 -0
@@ -0,0 +1,282 @@
1
+ 'use strict';
2
+
3
+ const http = require('http');
4
+ const { openPath } = require('./open-file');
5
+ const flow = require('./onboarding-flow');
6
+ const { methodIdsForFlow } = require('./auth-methods');
7
+ const { INTER_FONT_FACE_CSS } = require('./inter-fonts');
8
+ const { SHAKERS_MARK_WHITE_SVG, SHAKERS_WORDMARK_SVG } = require('./brand-svg');
9
+
10
+ const DEFAULT_TIMEOUT_MS = 180000;
11
+ const MAX_BODY_BYTES = 64 * 1024;
12
+
13
+ function esc(s) {
14
+ return String(s == null ? '' : s).replace(/[&<>"']/g, (ch) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ch]));
15
+ }
16
+
17
+ const FONT_STACK = '"Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif';
18
+
19
+ const COPY = {
20
+ en: {
21
+ signin: 'Sign in', create: 'Create account', welcome: 'Welcome to Shakers — create your talent account to get started.',
22
+ email: 'Email', password: 'Password', repeat: 'Repeat password', first: 'First name', last: 'Last name',
23
+ lang: 'Language', newsletter: 'Receive the newsletter', how: 'How do you work?', intent: 'Freelance intent',
24
+ hint: 'Your password stays on this machine. It is never sent to the chat.', mismatch: 'Passwords do not match.',
25
+ done: 'You are signed in.', doneSub: 'You can close this tab and return to your AI app.',
26
+ chooseTitleLogin: 'How do you want to sign in?', chooseTitleRegister: 'How do you want to create your account?',
27
+ continueGoogle: 'Continue with Google', continueEmail: 'Email and password', continueEmailRegister: 'Continue with email',
28
+ googleOpening: 'Opening Google sign-in…', googleOpeningSub: 'A Google window is opening. You can return to your AI app; it will confirm once you finish.',
29
+ },
30
+ es: {
31
+ signin: 'Iniciar sesión', create: 'Crear cuenta', welcome: 'Bienvenido a Shakers — crea tu cuenta de talento para empezar.',
32
+ email: 'Email', password: 'Contraseña', repeat: 'Repite la contraseña', first: 'Nombre', last: 'Apellidos',
33
+ lang: 'Idioma', newsletter: 'Recibir la newsletter', how: '¿Cómo trabajas?', intent: 'Intención freelance',
34
+ hint: 'Tu contraseña se queda en este equipo. Nunca se envía al chat.', mismatch: 'Las contraseñas no coinciden.',
35
+ done: 'Sesión iniciada.', doneSub: 'Puedes cerrar esta pestaña y volver a tu app de IA.',
36
+ chooseTitleLogin: '¿Cómo quieres iniciar sesión?', chooseTitleRegister: '¿Cómo quieres crear tu cuenta?',
37
+ continueGoogle: 'Continuar con Google', continueEmail: 'Email y contraseña', continueEmailRegister: 'Continuar con email',
38
+ googleOpening: 'Abriendo el acceso con Google…', googleOpeningSub: 'Se está abriendo una ventana de Google. Puedes volver a tu app de IA; te confirmará cuando termines.',
39
+ },
40
+ };
41
+
42
+ function copy(lang) {
43
+ return COPY[String(lang || 'en').toLowerCase()] || COPY.en;
44
+ }
45
+
46
+ function header() {
47
+ return `<div class="brand"><span class="appicon">${SHAKERS_MARK_WHITE_SVG}</span><span class="badge">MCP</span></div>`;
48
+ }
49
+
50
+ function panel(text) {
51
+ return `<div class="panel"><div class="pbrand">${SHAKERS_MARK_WHITE_SVG}${SHAKERS_WORDMARK_SVG}</div><p>${text}</p></div>`;
52
+ }
53
+
54
+ function styles() {
55
+ return `<style>${INTER_FONT_FACE_CSS}`
56
+ + `:root{--background:#ffffff;--foreground:#18181b;--primary:#05342c;--primary-foreground:#fafafa;`
57
+ + `--secondary:#e2f2f0;--secondary-foreground:#0b5a4c;--muted-foreground:#3f3f46;--accent:#e2f2f0;--accent-foreground:#08473c;`
58
+ + `--input:#d4d4d8;--border:#e4e4e7;--ring:#0e7d69;--destructive:#e11d48;--radius:0.625rem;--radius-md:calc(var(--radius) - 2px);`
59
+ + `--shadow-xs:0 1px 2px 0 rgb(0 0 0 / .05)}`
60
+ + `*{box-sizing:border-box}`
61
+ + `body{font-family:${FONT_STACK};margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#fafafa;color:var(--foreground);padding:1.5rem;-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}`
62
+ + `.card{width:100%;max-width:26rem;background:var(--background);border:1px solid var(--border);border-radius:var(--radius);box-shadow:0 10px 30px rgb(3 33 28 / .08);overflow:hidden}`
63
+ + `.body{padding:2rem}`
64
+ + `.brand{display:flex;align-items:center;gap:.6rem;margin:0 0 1.25rem}`
65
+ + `.appicon{display:inline-flex;align-items:center;justify-content:center;width:2.5rem;height:2.5rem;border-radius:.7rem;background:var(--primary);flex:0 0 auto}`
66
+ + `.appicon .mk{height:1.35rem;width:auto;display:block}`
67
+ + `.badge{margin-left:auto;font-size:.7rem;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--secondary-foreground);background:var(--secondary);border-radius:999px;padding:.2rem .6rem}`
68
+ + `.panel{background:var(--primary);color:var(--primary-foreground);padding:1.75rem 2rem}`
69
+ + `.pbrand{display:flex;align-items:center;gap:.7rem;margin:0 0 .75rem}`
70
+ + `.pbrand .mk{height:1.5rem;width:auto;display:block}`
71
+ + `.pbrand .wm{height:1.05rem;width:auto;display:block;color:var(--primary-foreground)}`
72
+ + `.panel p{margin:0;font-size:.9rem;color:#cfe6e2;line-height:1.45}`
73
+ + `h1{font-size:1.15rem;font-weight:600;color:var(--foreground);margin:0 0 1rem;letter-spacing:-.01em}`
74
+ + `label{display:block;margin:.85rem 0 .35rem;font-size:.875rem;font-weight:500;color:var(--foreground);line-height:1}`
75
+ + `label.chk{display:flex;align-items:center;gap:.5rem;font-weight:400}`
76
+ + `input,select{height:2.25rem;width:100%;min-width:0;padding:.25rem .75rem;border:1px solid var(--input);border-radius:var(--radius-md);font-size:.875rem;font-family:inherit;line-height:1.25rem;color:var(--foreground);background:var(--background);box-shadow:var(--shadow-xs);outline:none;transition:color .15s,box-shadow .15s,border-color .15s}`
77
+ + `input::placeholder{color:var(--muted-foreground)}`
78
+ + `input:focus-visible,select:focus-visible{border-color:var(--ring);box-shadow:0 0 0 3px color-mix(in oklab,var(--ring) 50%,transparent)}`
79
+ + `input[type=checkbox]{height:auto;width:auto;box-shadow:none;accent-color:var(--primary)}`
80
+ + `button{margin-top:1.4rem;display:inline-flex;align-items:center;justify-content:center;gap:.5rem;width:100%;height:2.25rem;padding:.5rem 1rem;border:1px solid var(--primary);border-radius:var(--radius-md);background:var(--primary);color:var(--primary-foreground);font-size:.875rem;font-weight:500;font-family:inherit;cursor:pointer;transition:background .15s,box-shadow .15s}`
81
+ + `button:hover{background:color-mix(in oklab,var(--primary) 90%,transparent)}`
82
+ + `button:focus-visible{outline:none;box-shadow:0 0 0 3px color-mix(in oklab,var(--ring) 50%,transparent)}`
83
+ + `button:disabled{opacity:.5;pointer-events:none}`
84
+ + `.err{background:color-mix(in oklab,var(--destructive) 12%,transparent);color:var(--destructive);padding:.6rem .7rem;border-radius:var(--radius-md);margin:0 0 .75rem;font-size:.875rem}`
85
+ + `.hint{color:var(--muted-foreground);font-size:.8rem;margin:.9rem 0 0;line-height:1.45}`
86
+ + `.actions{display:flex;flex-direction:column;gap:.6rem;margin-top:1.25rem}`
87
+ + `.mbtn{display:inline-flex;align-items:center;justify-content:center;gap:.6rem;width:100%;height:2.5rem;padding:.5rem 1rem;border-radius:var(--radius-md);font-size:.9rem;font-weight:500;font-family:inherit;text-decoration:none;cursor:pointer;border:1px solid transparent;transition:background .15s,border-color .15s,box-shadow .15s}`
88
+ + `.mbtn:focus-visible{outline:none;box-shadow:0 0 0 3px color-mix(in oklab,var(--ring) 50%,transparent)}`
89
+ + `.mbtn.primary{background:var(--primary);color:var(--primary-foreground);border-color:var(--primary)}`
90
+ + `.mbtn.primary:hover{background:color-mix(in oklab,var(--primary) 90%,transparent)}`
91
+ + `.mbtn.secondary{background:var(--background);color:var(--foreground);border-color:var(--border);box-shadow:var(--shadow-xs)}`
92
+ + `.mbtn.secondary:hover{background:var(--accent);color:var(--accent-foreground)}`
93
+ + `.mbtn .g{width:18px;height:18px;flex:0 0 auto}</style>`;
94
+ }
95
+
96
+ function page(title, { banner = '', inner = '' } = {}) {
97
+ return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(title)}</title>`
98
+ + `${styles()}</head><body><main class="card">${banner}<div class="body">${header()}${inner}</div></main></body></html>`;
99
+ }
100
+
101
+ function errorBanner(reason) {
102
+ return reason ? `<div class="err">${esc(reason)}</div>` : '';
103
+ }
104
+
105
+ function loginFormHtml(lang, { error } = {}) {
106
+ const c = copy(lang);
107
+ return page(`Shakers — ${c.signin}`, {
108
+ inner: `<h1>${c.signin}</h1>${errorBanner(error)}`
109
+ + `<form method="post" action="/submit">`
110
+ + `<label>${c.email}</label><input name="email" type="email" required autofocus>`
111
+ + `<label>${c.password}</label><input name="password" type="password" required>`
112
+ + `<button type="submit">${c.signin}</button>`
113
+ + `<p class="hint">${c.hint}</p>`
114
+ + `</form>`,
115
+ });
116
+ }
117
+
118
+ function registerFormHtml(lang, { error } = {}) {
119
+ const c = copy(lang);
120
+ return page(`Shakers — ${c.create}`, {
121
+ banner: panel(c.welcome),
122
+ inner: `<h1>${c.create}</h1>${errorBanner(error)}`
123
+ + `<form method="post" action="/submit">`
124
+ + `<label>${c.password}</label><input name="password" type="password" required minlength="8" autofocus>`
125
+ + `<label>${c.repeat}</label><input name="passwordConfirm" type="password" required minlength="8">`
126
+ + `<button type="submit">${c.create}</button>`
127
+ + `<p class="hint">${c.hint}</p>`
128
+ + `</form>`
129
+ + `<script>(function(){document.forms[0].addEventListener('submit',function(e){var f=this;if(f.password.value!==f.passwordConfirm.value){e.preventDefault();alert(${JSON.stringify(c.mismatch)});}});})();</script>`,
130
+ });
131
+ }
132
+
133
+ function formHtml(mode, lang, opts) {
134
+ return mode === 'register' ? registerFormHtml(lang, opts) : loginFormHtml(lang, opts);
135
+ }
136
+
137
+ const GOOGLE_G = '<svg class="g" viewBox="0 0 48 48" aria-hidden="true">'
138
+ + '<path fill="#EA4335" d="M24 9.5c3.5 0 6.6 1.2 9 3.6l6.7-6.7C35.6 2.6 30.2 0 24 0 14.6 0 6.5 5.4 2.6 13.2l7.8 6.1C12.2 13.3 17.6 9.5 24 9.5z"/>'
139
+ + '<path fill="#4285F4" d="M46.5 24.5c0-1.6-.1-3.1-.4-4.5H24v9h12.6c-.5 3-2.2 5.5-4.7 7.2l7.3 5.7c4.3-4 6.3-9.8 6.3-17.4z"/>'
140
+ + '<path fill="#FBBC05" d="M10.4 28.3c-.5-1.5-.8-3.1-.8-4.8s.3-3.3.8-4.8l-7.8-6.1C.9 15.9 0 19.8 0 23.5s.9 7.6 2.6 10.9l7.8-6.1z"/>'
141
+ + '<path fill="#34A853" d="M24 47c6.5 0 11.9-2.1 15.9-5.8l-7.3-5.7c-2 1.4-4.7 2.3-8.6 2.3-6.4 0-11.8-3.8-13.6-9.8l-7.8 6.1C6.5 41.6 14.6 47 24 47z"/></svg>';
142
+
143
+ // The method-choice screen (talents-ai-score) — the FIRST screen of the loopback form when more than one method is offered for this flow.
144
+ function methodChoiceHtml(mode, lang, methods) {
145
+ const c = copy(lang);
146
+ const title = mode === 'register' ? c.chooseTitleRegister : c.chooseTitleLogin;
147
+ const emailLabel = mode === 'register' ? c.continueEmailRegister : c.continueEmail;
148
+ const buttons = methods.map((id) => (id === 'google'
149
+ ? `<a class="mbtn primary" href="/google">${GOOGLE_G}${c.continueGoogle}</a>`
150
+ : `<a class="mbtn secondary" href="/email">${emailLabel}</a>`)).join('');
151
+ return page(`Shakers — ${title}`, {
152
+ inner: `<h1>${title}</h1><div class="actions">${buttons}</div><p class="hint">${c.hint}</p>`,
153
+ });
154
+ }
155
+
156
+ function googleHandoffHtml(lang) {
157
+ const c = copy(lang);
158
+ return page('Shakers', { inner: `<h1>${c.googleOpening}</h1><p>${c.googleOpeningSub}</p>` });
159
+ }
160
+
161
+ function successHtml(lang) {
162
+ const c = copy(lang);
163
+ return page('Shakers', { inner: `<h1>${c.done}</h1><p>${c.doneSub}</p>` });
164
+ }
165
+
166
+ function parseBody(req) {
167
+ return new Promise((resolve) => {
168
+ let raw = '';
169
+ let aborted = false;
170
+ req.on('data', (chunk) => {
171
+ raw += chunk;
172
+ if (raw.length > MAX_BODY_BYTES) { aborted = true; req.destroy(); }
173
+ });
174
+ req.on('end', () => {
175
+ if (aborted) return resolve({});
176
+ const params = new URLSearchParams(raw);
177
+ const out = {};
178
+ for (const [k, v] of params) out[k] = v;
179
+ resolve(out);
180
+ });
181
+ req.on('error', () => resolve({}));
182
+ });
183
+ }
184
+
185
+ async function doAuth(mode, body, deps, lang, registerFields = {}) {
186
+ const password = typeof body.password === 'string' ? body.password : '';
187
+
188
+ if (mode === 'register') {
189
+ if (!password) return { ok: false, reason: 'missing-fields' };
190
+ if (password !== (body.passwordConfirm || '')) return { ok: false, reason: 'password-mismatch' };
191
+ const email = typeof registerFields.email === 'string' ? registerFields.email.trim() : '';
192
+ const su = await flow.signUp(deps, {
193
+ name: registerFields.name,
194
+ lastName: registerFields.lastName,
195
+ email,
196
+ password,
197
+ preferredLanguage: registerFields.preferredLanguage || lang,
198
+ newsletterConsent: registerFields.newsletterConsent === true,
199
+ freelanceType: registerFields.freelanceType,
200
+ freelanceIntent: registerFields.freelanceIntent,
201
+ });
202
+ if (!su.ok) return { ok: false, reason: su.message || su.reason };
203
+ const sess = await flow.establishSessionFromCredentials(deps, { email, password });
204
+ return sess.ok ? { ok: true, email, accountExists: su.accountExists === true } : { ok: false, reason: sess.reason };
205
+ }
206
+
207
+ const email = typeof body.email === 'string' ? body.email.trim() : '';
208
+ if (!email || !password) return { ok: false, reason: 'missing-fields' };
209
+ const sess = await flow.establishSessionFromCredentials(deps, { email, password });
210
+ return sess.ok ? { ok: true, email } : { ok: false, reason: sess.reason };
211
+ }
212
+
213
+ function runLoopbackAuth(
214
+ { mode = 'login', deps = null, lang = 'en', method = null, registerFields = null } = {},
215
+ { openBrowser = openPath, timeoutMs = DEFAULT_TIMEOUT_MS, onUrl = null } = {},
216
+ ) {
217
+ const resolvedDeps = deps || flow.makeDeps();
218
+ const methods = methodIdsForFlow(mode);
219
+ const showMethodScreen = !method && methods.length > 1;
220
+ return new Promise((resolve) => {
221
+ let settled = false;
222
+ const finish = (result) => {
223
+ if (settled) return;
224
+ settled = true;
225
+ clearTimeout(timer);
226
+ try { server.close(); } catch { /* already closing */ }
227
+ resolve(result);
228
+ };
229
+
230
+ const server = http.createServer(async (req, res) => {
231
+ let url;
232
+ try {
233
+ url = new URL(req.url, 'http://127.0.0.1');
234
+ } catch {
235
+ res.writeHead(400).end();
236
+ return;
237
+ }
238
+ if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '')) {
239
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
240
+ res.end(showMethodScreen ? methodChoiceHtml(mode, lang, methods) : formHtml(mode, lang));
241
+ return;
242
+ }
243
+ if (req.method === 'GET' && url.pathname === '/email') {
244
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
245
+ res.end(formHtml(mode, lang));
246
+ return;
247
+ }
248
+ if (req.method === 'GET' && url.pathname === '/google' && methods.includes('google')) {
249
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
250
+ res.end(googleHandoffHtml(lang));
251
+ finish({ ok: true, viaGoogle: true });
252
+ return;
253
+ }
254
+ if (req.method === 'POST' && url.pathname === '/submit') {
255
+ const body = await parseBody(req);
256
+ const outcome = await doAuth(mode, body, resolvedDeps, lang, registerFields || {});
257
+ if (outcome.ok) {
258
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
259
+ res.end(successHtml(lang));
260
+ finish(outcome);
261
+ } else {
262
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
263
+ res.end(formHtml(mode, lang, { error: outcome.reason }));
264
+ }
265
+ return;
266
+ }
267
+ res.writeHead(404).end();
268
+ });
269
+
270
+ const timer = setTimeout(() => finish({ ok: false, reason: 'browser-timeout' }), timeoutMs);
271
+ if (timer.unref) timer.unref();
272
+ server.on('error', () => finish({ ok: false, reason: 'local-error' }));
273
+ server.listen(0, '127.0.0.1', () => {
274
+ const { port } = server.address();
275
+ const formUrl = `http://127.0.0.1:${port}/`;
276
+ if (typeof onUrl === 'function') onUrl(formUrl);
277
+ try { openBrowser(formUrl); } catch { /* headless: the onUrl fallback covers it */ }
278
+ });
279
+ });
280
+ }
281
+
282
+ module.exports = { runLoopbackAuth, doAuth, loginFormHtml, registerFormHtml, methodChoiceHtml, googleHandoffHtml, DEFAULT_TIMEOUT_MS };
@@ -0,0 +1,43 @@
1
+ 'use strict';
2
+
3
+ // MCP: view the talent's availability. Read-only (no set tool — an AI agent
4
+ // must not write the talent's availability).
5
+
6
+ const GET_AVAILABILITY_SCHEMA = { type: 'object', properties: {} };
7
+
8
+ function makeAvailabilityTools(deps = {}) {
9
+ const {
10
+ fetchAvailability = (opts) => require('./availability-client').fetchAvailability({}, opts),
11
+ loadAuthSession = require('./auth-session-store').loadAuthSession,
12
+ sessionStatus = require('./auth-session-store').sessionStatus,
13
+ } = deps;
14
+
15
+ const requireLoginSession = () => {
16
+ const session = loadAuthSession();
17
+ if (sessionStatus(session) !== 'active') {
18
+ throw new Error('no active Shakers session — call the login tool (or run `shakers login`) first (get_availability is session-gated).');
19
+ }
20
+ return session;
21
+ };
22
+
23
+ async function getAvailability() {
24
+ const session = requireLoginSession();
25
+ const res = await fetchAvailability({ hubAccessToken: session.hubAccessToken });
26
+ if (!res.ok) return { ok: false, reason: res.reason };
27
+ return { ok: true, availability: res.availability };
28
+ }
29
+
30
+ return [
31
+ {
32
+ name: 'get_availability',
33
+ description: "View the talent's availability: open-to-work, monthly hours, remote-only, and location. Read-only. Requires an active session. Any internal ids in the result (e.g. numeric city id) are handles for tools only — never show or mention them to the talent.",
34
+ inputSchema: GET_AVAILABILITY_SCHEMA,
35
+ handler: getAvailability,
36
+ },
37
+ ];
38
+ }
39
+
40
+ module.exports = {
41
+ makeAvailabilityTools,
42
+ GET_AVAILABILITY_SCHEMA,
43
+ };
@@ -0,0 +1,42 @@
1
+ 'use strict';
2
+
3
+ // MCP: the talent's dimension certifications (by role).
4
+
5
+ const LIST_CERTIFICATIONS_SCHEMA = { type: 'object', properties: {} };
6
+
7
+ function makeCertificationsTools(deps = {}) {
8
+ const {
9
+ fetchMeCertifications = (opts) => require('./certifications-client').fetchMeCertifications({}, opts),
10
+ loadAuthSession = require('./auth-session-store').loadAuthSession,
11
+ sessionStatus = require('./auth-session-store').sessionStatus,
12
+ } = deps;
13
+
14
+ const requireLoginSession = () => {
15
+ const session = loadAuthSession();
16
+ if (sessionStatus(session) !== 'active') {
17
+ throw new Error('no active Shakers session — call the login tool (or run `shakers login`) first (list_certifications is session-gated).');
18
+ }
19
+ return session;
20
+ };
21
+
22
+ async function listCertifications() {
23
+ const session = requireLoginSession();
24
+ const res = await fetchMeCertifications({ hubAccessToken: session.hubAccessToken });
25
+ if (!res.ok) return { ok: false, reason: res.reason };
26
+ return { ok: true, mainRole: res.mainRole, growingInto: res.growingInto, dimensions: res.dimensions };
27
+ }
28
+
29
+ return [
30
+ {
31
+ name: 'list_certifications',
32
+ description: "List the talent's role dimensions with their certification state (CERTIFIED/UNCERTIFIED/EXPIRED), band and main role. Requires an active session. Any dimension/cluster ids in the result are INTERNAL handles for calling tools — never show or mention them to the talent; refer to a dimension by its name.",
33
+ inputSchema: LIST_CERTIFICATIONS_SCHEMA,
34
+ handler: listCertifications,
35
+ },
36
+ ];
37
+ }
38
+
39
+ module.exports = {
40
+ makeCertificationsTools,
41
+ LIST_CERTIFICATIONS_SCHEMA,
42
+ };
@@ -0,0 +1,58 @@
1
+ 'use strict';
2
+
3
+ // MCP: list the offerable dimensions (the interview itself runs over LiveKit in the CLI).
4
+
5
+ const LIST_CERTIFIABLE_DIMENSIONS_SCHEMA = {
6
+ type: 'object',
7
+ properties: {},
8
+ };
9
+
10
+ function makeCertifyDimensionTools(deps = {}) {
11
+ const {
12
+ discoverOfferableDimensions = (opts) => require('./certify-dimension-client').discoverOfferableDimensions({}, opts),
13
+ loadAuthSession = require('./auth-session-store').loadAuthSession,
14
+ sessionStatus = require('./auth-session-store').sessionStatus,
15
+ } = deps;
16
+
17
+ const requireLoginSession = () => {
18
+ const session = loadAuthSession();
19
+ if (sessionStatus(session) !== 'active') {
20
+ throw new Error('no active Shakers session — call the login tool (or run `shakers login`) first (certification is session-gated).');
21
+ }
22
+ return session;
23
+ };
24
+
25
+ async function listCertifiableDimensions() {
26
+ const session = requireLoginSession();
27
+ const res = await discoverOfferableDimensions({
28
+ accessToken: session.accessToken,
29
+ hubAccessToken: session.hubAccessToken,
30
+ });
31
+ if (!res.ok) return { ok: false, reason: res.reason };
32
+ return {
33
+ ok: true,
34
+ mainRole: res.mainRole || null,
35
+ dimensions: (res.offerable || []).map((d) => ({
36
+ dimensionKey: d.dimensionKey,
37
+ slug: d.slug,
38
+ clusterId: d.clusterId,
39
+ state: d.state,
40
+ band: d.band || null,
41
+ })),
42
+ };
43
+ }
44
+
45
+ return [
46
+ {
47
+ name: 'list_certifiable_dimensions',
48
+ description: "List the dimensions the talent can certify by CLI right now: their main role's hub dimensions whose interview runs by text (terminal or spoken-case templates) and not already CERTIFIED (UNCERTIFIED/EXPIRED). Requires an active session. The certification interview itself runs over LiveKit via `shakers certify` in the terminal. The dimensionKey/clusterId in each item are INTERNAL handles for tools only — never show or mention them to the talent; refer to a dimension by its name.",
49
+ inputSchema: LIST_CERTIFIABLE_DIMENSIONS_SCHEMA,
50
+ handler: listCertifiableDimensions,
51
+ },
52
+ ];
53
+ }
54
+
55
+ module.exports = {
56
+ makeCertifyDimensionTools,
57
+ LIST_CERTIFIABLE_DIMENSIONS_SCHEMA,
58
+ };
@@ -0,0 +1,223 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { getHomeDir } = require('./env-paths');
6
+
7
+ // KEYWORD -> { SERVICE LABEL, CATEGORY } (issue 110).
8
+ const SERVICE_CATALOG = [
9
+ // --- data ---
10
+ { kw: 'postgresql', label: 'Postgres', category: 'data' },
11
+ { kw: 'postgres', label: 'Postgres', category: 'data' },
12
+ { kw: 'mysql', label: 'MySQL', category: 'data' },
13
+ { kw: 'sqlite', label: 'SQLite', category: 'data' },
14
+ { kw: 'mongo', label: 'MongoDB', category: 'data' },
15
+ { kw: 'redis', label: 'Redis', category: 'data' },
16
+ { kw: 'snowflake', label: 'Snowflake', category: 'data' },
17
+ { kw: 'bigquery', label: 'BigQuery', category: 'data' },
18
+ { kw: 'clickhouse', label: 'ClickHouse', category: 'data' },
19
+ { kw: 'dynamodb', label: 'DynamoDB', category: 'data' },
20
+ { kw: 'elastic', label: 'Elasticsearch', category: 'data' },
21
+ { kw: 'supabase', label: 'Supabase', category: 'data' },
22
+ { kw: 'airtable', label: 'Airtable', category: 'data' },
23
+ { kw: 'notion', label: 'Notion', category: 'data' },
24
+ { kw: 'looker', label: 'Looker', category: 'data' },
25
+ { kw: 'attio', label: 'Attio', category: 'data' },
26
+ { kw: 'sheet', label: 'Google Sheets', category: 'data' },
27
+ { kw: 'drive', label: 'Google Drive', category: 'data' },
28
+ { kw: 'database', label: null, category: 'data' },
29
+ // --- comms ---
30
+ { kw: 'slack', label: 'Slack', category: 'comms' },
31
+ { kw: 'discord', label: 'Discord', category: 'comms' },
32
+ { kw: 'gmail', label: 'Gmail', category: 'comms' },
33
+ { kw: 'teams', label: 'Microsoft Teams', category: 'comms' },
34
+ { kw: 'telegram', label: 'Telegram', category: 'comms' },
35
+ { kw: 'whatsapp', label: 'WhatsApp', category: 'comms' },
36
+ { kw: 'twilio', label: 'Twilio', category: 'comms' },
37
+ { kw: 'zoom', label: 'Zoom', category: 'comms' },
38
+ { kw: 'email', label: null, category: 'comms' },
39
+ { kw: 'mail', label: null, category: 'comms' },
40
+ { kw: 'sms', label: null, category: 'comms' },
41
+ // --- dev ---
42
+ { kw: 'github', label: 'GitHub', category: 'dev' },
43
+ { kw: 'gitlab', label: 'GitLab', category: 'dev' },
44
+ { kw: 'bitbucket', label: 'Bitbucket', category: 'dev' },
45
+ { kw: 'kubernetes', label: 'Kubernetes', category: 'dev' },
46
+ { kw: 'k8s', label: 'Kubernetes', category: 'dev' },
47
+ { kw: 'docker', label: 'Docker', category: 'dev' },
48
+ { kw: 'sentry', label: 'Sentry', category: 'dev' },
49
+ { kw: 'linear', label: 'Linear', category: 'dev' },
50
+ { kw: 'jira', label: 'Jira', category: 'dev' },
51
+ { kw: 'confluence', label: 'Confluence', category: 'dev' },
52
+ { kw: 'figma', label: 'Figma', category: 'dev' },
53
+ { kw: 'terraform', label: 'Terraform', category: 'dev' },
54
+ { kw: 'cloudflare', label: 'Cloudflare', category: 'dev' },
55
+ { kw: 'vercel', label: 'Vercel', category: 'dev' },
56
+ { kw: 'netlify', label: 'Netlify', category: 'dev' },
57
+ { kw: 'railway', label: 'Railway', category: 'dev' },
58
+ { kw: 'context7', label: 'Context7', category: 'dev' },
59
+ { kw: 'aws', label: 'AWS', category: 'dev' },
60
+ { kw: 'filesystem', label: 'Filesystem', category: 'dev' },
61
+ { kw: 'git', label: null, category: 'dev' },
62
+ // --- browser ---
63
+ { kw: 'playwright', label: 'Playwright', category: 'browser' },
64
+ { kw: 'puppeteer', label: 'Puppeteer', category: 'browser' },
65
+ { kw: 'browserbase', label: 'Browserbase', category: 'browser' },
66
+ { kw: 'browser-use', label: 'Browser Use', category: 'browser' },
67
+ { kw: 'stagehand', label: 'Stagehand', category: 'browser' },
68
+ { kw: 'chromium', label: 'Chromium', category: 'browser' },
69
+ { kw: 'chrome', label: 'Chrome', category: 'browser' },
70
+ { kw: 'browser', label: null, category: 'browser' },
71
+ ];
72
+
73
+ // ORDER IS BEHAVIOUR, and it is preserved from the previous table on purpose.
74
+ function matchService(name) {
75
+ const lower = String(name).toLowerCase();
76
+ return SERVICE_CATALOG.find((entry) => lower.includes(entry.kw)) || null;
77
+ }
78
+
79
+ function categorizeMcpServerName(name) {
80
+ const hit = matchService(name);
81
+ return hit ? hit.category : 'other';
82
+ }
83
+
84
+ // The product behind a server name, or `null` when the name does not carry one.
85
+ function serviceForMcpServerName(name) {
86
+ const hit = matchService(name);
87
+ return hit && hit.label ? hit.label : null;
88
+ }
89
+
90
+ // THE single inventory of MCP config locations (issue 112).
91
+ const MCP_CONFIG_LOCATIONS = [
92
+ // --- Claude Code (already read before 112) ---
93
+ { tool: 'claude-code', scope: 'project', keys: ['mcpServers'], confidence: 'high', file: (root) => path.join(root, '.mcp.json') },
94
+ { tool: 'claude-code', scope: 'home', keys: ['mcpServers'], confidence: 'high', file: (root, home) => path.join(home, '.claude.json') },
95
+ // --- Cursor: the project one was read, the GLOBAL one was the bug ---
96
+ { tool: 'cursor', scope: 'project', keys: ['mcpServers'], confidence: 'high', file: (root) => path.join(root, '.cursor', 'mcp.json') },
97
+ { tool: 'cursor', scope: 'home', keys: ['mcpServers'], confidence: 'high', file: (root, home) => path.join(home, '.cursor', 'mcp.json') },
98
+ // --- Windsurf (home only; no project-level MCP config known) ---
99
+ { tool: 'windsurf', scope: 'home', keys: ['mcpServers'], confidence: 'medium', file: (root, home) => path.join(home, '.codeium', 'windsurf', 'mcp_config.json') },
100
+ // --- Gemini CLI: home was read; the project-level settings file was not ---
101
+ { tool: 'gemini-cli', scope: 'home', keys: ['mcpServers'], confidence: 'medium', file: (root, home) => path.join(home, '.gemini', 'settings.json') },
102
+ { tool: 'gemini-cli', scope: 'project', keys: ['mcpServers'], confidence: 'medium', file: (root) => path.join(root, '.gemini', 'settings.json') },
103
+ // VS Code: `servers`, not `mcpServers`, and three OS-specific user paths.
104
+ { tool: null, scope: 'project', keys: ['servers', 'mcpServers'], confidence: 'medium', file: (root) => path.join(root, '.vscode', 'mcp.json') },
105
+ { tool: null, scope: 'home', keys: ['servers', 'mcpServers'], confidence: 'medium', file: (root, home) => path.join(home, 'Library', 'Application Support', 'Code', 'User', 'mcp.json') },
106
+ { tool: null, scope: 'home', keys: ['servers', 'mcpServers'], confidence: 'medium', file: (root, home) => path.join(home, '.config', 'Code', 'User', 'mcp.json') },
107
+ { tool: null, scope: 'home', keys: ['servers', 'mcpServers'], confidence: 'medium', file: (root, home) => path.join(home, 'AppData', 'Roaming', 'Code', 'User', 'mcp.json') },
108
+ // --- Claude Desktop: same `mcpServers` shape, three OS-specific paths ---
109
+ { tool: null, scope: 'home', keys: ['mcpServers'], confidence: 'medium', file: (root, home) => path.join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json') },
110
+ { tool: null, scope: 'home', keys: ['mcpServers'], confidence: 'medium', file: (root, home) => path.join(home, '.config', 'Claude', 'claude_desktop_config.json') },
111
+ { tool: null, scope: 'home', keys: ['mcpServers'], confidence: 'medium', file: (root, home) => path.join(home, 'AppData', 'Roaming', 'Claude', 'claude_desktop_config.json') },
112
+ ];
113
+
114
+ // Reads ONLY the top-level keys of the server map from one config file, trying
115
+ // each declared key in order. Never reads or returns any value under those keys.
116
+ function readMcpServerNames(file, keys = ['mcpServers']) {
117
+ let raw;
118
+ try {
119
+ raw = fs.readFileSync(file, 'utf8');
120
+ } catch {
121
+ return [];
122
+ }
123
+ let obj;
124
+ try {
125
+ obj = JSON.parse(raw);
126
+ } catch {
127
+ return [];
128
+ }
129
+ if (!obj || typeof obj !== 'object') return [];
130
+ for (const key of keys) {
131
+ const servers = obj[key];
132
+ if (servers && typeof servers === 'object' && !Array.isArray(servers)) return Object.keys(servers);
133
+ }
134
+ return [];
135
+ }
136
+
137
+ // Deterministic (no-LLM) MCP-by-name detection over every location in `MCP_CONFIG_LOCATIONS` (project ∪ home).
138
+ function detectMcpServers(root) {
139
+ const home = getHomeDir();
140
+ // name -> { scopes:Set, tools:Set }
141
+ const found = new Map();
142
+ const byTool = {};
143
+
144
+ for (const loc of MCP_CONFIG_LOCATIONS) {
145
+ let file;
146
+ try {
147
+ file = loc.file(root, home);
148
+ } catch {
149
+ continue; // an unresolvable home never breaks the other locations
150
+ }
151
+ const names = readMcpServerNames(file, loc.keys);
152
+ if (loc.tool && names.length) {
153
+ // Per-tool count is the union across THAT tool's own locations, so a
154
+ // server configured both in the project and globally counts once for it.
155
+ const set = byTool[`__set_${loc.tool}`] || (byTool[`__set_${loc.tool}`] = new Set());
156
+ for (const n of names) set.add(n);
157
+ }
158
+ for (const name of names) {
159
+ const entry = found.get(name) || { scopes: new Set(), tools: new Set() };
160
+ entry.scopes.add(loc.scope);
161
+ if (loc.tool) entry.tools.add(loc.tool);
162
+ found.set(name, entry);
163
+ }
164
+ }
165
+
166
+ // Collapse the per-tool sets into plain counts (and drop the working keys).
167
+ for (const key of Object.keys(byTool)) {
168
+ if (key.startsWith('__set_')) {
169
+ byTool[key.slice('__set_'.length)] = byTool[key].size;
170
+ delete byTool[key];
171
+ }
172
+ }
173
+
174
+ const servers = [...found.keys()]
175
+ .sort()
176
+ .map((name) => {
177
+ const entry = found.get(name);
178
+ return {
179
+ name,
180
+ category: categorizeMcpServerName(name),
181
+ // 'project' wins when a server is declared in both: the most specific
182
+ // scope is the honest label for "this repo uses it".
183
+ scope: entry.scopes.has('project') ? 'project' : 'home',
184
+ // Issue 110: the PRODUCT, derived from the name only. `null` when the
185
+ // name carries none — a state, not a hole.
186
+ service: serviceForMcpServerName(name),
187
+ };
188
+ });
189
+
190
+ return buildMcpAggregate(servers, byTool);
191
+ }
192
+
193
+ // Builds the report's `mcp` block from an already-resolved server list + the per-tool counts.
194
+ function buildMcpAggregate(servers, byTool = {}) {
195
+ const sorted = [...servers].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
196
+
197
+ const countsByCategory = { data: 0, comms: 0, dev: 0, browser: 0, other: 0 };
198
+ for (const s of sorted) countsByCategory[s.category] += 1;
199
+
200
+ const byService = new Map();
201
+ let unidentified = 0;
202
+ for (const s of sorted) {
203
+ if (!s.service) {
204
+ unidentified += 1;
205
+ continue;
206
+ }
207
+ byService.set(s.service, (byService.get(s.service) || 0) + 1);
208
+ }
209
+ const services = [...byService.entries()]
210
+ .sort((a, b) => a[0].localeCompare(b[0]))
211
+ .map(([label, count]) => ({ label, count }));
212
+
213
+ return { servers: sorted, services, unidentified, countsByCategory, total: sorted.length, byTool };
214
+ }
215
+
216
+ module.exports = {
217
+ detectMcpServers,
218
+ buildMcpAggregate,
219
+ categorizeMcpServerName,
220
+ serviceForMcpServerName,
221
+ MCP_CONFIG_LOCATIONS,
222
+ SERVICE_CATALOG,
223
+ };