shakers-cli-beta 0.8.0-beta.4 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/i18n.js CHANGED
@@ -702,11 +702,11 @@ const catalogs = {
702
702
  // language the Talent may not read. Vocabulario CONTEXT: Talent, Skill.
703
703
  certifyDimension: {
704
704
  help:
705
- 'certify — certifica una DIMENSIÓN de tu rol con una entrevista por LiveKit (como el onboarding).\n\n'
705
+ 'certify - certifica una DIMENSIÓN de tu rol. La entrevista se hace en la web.\n\n'
706
706
  + 'Uso:\n'
707
707
  + ' certify [--lang es|en] [--dimension <clave|slug|n>]\n\n'
708
708
  + 'Requiere una sesión activa (ejecuta antes: shakers login).\n'
709
- + 'Solo se ofrecen las dimensiones que puedes certificar por aquí y que aún no tienes certificadas.\n',
709
+ + 'Eliges la dimensión aquí y te damos el enlace para certificarla en la web.\n',
710
710
  title: 'Certificar una dimensión',
711
711
  loginRequired: '"certify" requiere una sesión activa.\n Falta: iniciar sesión. Ejecuta primero: shakers login',
712
712
  discovering: 'Buscando las dimensiones que puedes certificar…',
@@ -722,22 +722,9 @@ const catalogs = {
722
722
  stateExpired: 'caducada',
723
723
  dimensionInvalid: 'Esa dimensión no está entre las certificables ahora mismo.',
724
724
  dimensionUsing: (slug) => `Dimensión elegida: ${slug}`,
725
- creating: 'Creando la entrevista…',
726
- createFailed: (reason) => `No se pudo crear la entrevista (${reason}).`,
727
- onCooldown: (day) => (day
728
- ? `Ya hiciste esta dimensión hace poco. Podrás repetirla a partir del ${day}.`
729
- : 'Ya hiciste esta dimensión hace poco y todavía no se puede repetir.'),
730
- needsWeb: 'Esta dimensión lleva un ejercicio que por ahora solo se hace en la web, no por terminal.',
731
- interviewUnavailable: (reason) => `La entrevista no pudo completarse (${reason}).`,
732
- reportPolling: 'Calculando tu veredicto…',
733
- reportTimeout: 'El veredicto todavía se está calculando. Consúltalo en tu perfil en unos minutos.',
734
- reportFailed: (reason) => `No se pudo obtener el veredicto (${reason}). Consúltalo en tu perfil.`,
735
- verdictTitle: 'Veredicto',
736
- verdictDimension: (dim) => `Dimensión: ${dim}`,
737
- verdictBand: (band) => `Nivel: ${band}`,
738
- verdictCertified: 'Resultado: certificada',
739
- verdictNotCertified: 'Resultado: no certificada esta vez',
740
- done: 'Certificación completada.',
725
+ webHandoff: 'La entrevista de certificación se hace en la web.',
726
+ webHandoffLink: (url) => `Ábrela aquí para certificar esta dimensión:\n ${url}`,
727
+ webHandoffNoLink: 'Entra en tu perfil de Shakers en la web y ve a Certificaciones para hacerla.',
741
728
  },
742
729
  findProjects: {
743
730
  help:
@@ -2310,11 +2297,11 @@ const catalogs = {
2310
2297
  // English mirror of the `certify` catalog. Same content/invariants.
2311
2298
  certifyDimension: {
2312
2299
  help:
2313
- 'certify — certify a DIMENSION of your role with a LiveKit interview (like onboarding).\n\n'
2300
+ 'certify - certify a DIMENSION of your role. The interview is done on the web.\n\n'
2314
2301
  + 'Usage:\n'
2315
2302
  + ' certify [--lang es|en] [--dimension <key|slug|n>]\n\n'
2316
2303
  + 'Requires an active session (run `shakers login` first).\n'
2317
- + 'Only dimensions you can certify here and have not already certified are offered.\n',
2304
+ + 'You pick the dimension here and we hand you the link to certify it on the web.\n',
2318
2305
  title: 'Certify a dimension',
2319
2306
  loginRequired: '"certify" requires an active session.\n Missing: sign-in. Run `shakers login` first.',
2320
2307
  discovering: 'Looking up the dimensions you can certify…',
@@ -2330,22 +2317,9 @@ const catalogs = {
2330
2317
  stateExpired: 'expired',
2331
2318
  dimensionInvalid: 'That dimension is not among the certifiable ones right now.',
2332
2319
  dimensionUsing: (slug) => `Chosen dimension: ${slug}`,
2333
- creating: 'Creating the interview…',
2334
- createFailed: (reason) => `Could not create the interview (${reason}).`,
2335
- onCooldown: (day) => (day
2336
- ? `You took this dimension recently. You can retake it from ${day}.`
2337
- : 'You took this dimension recently and cannot retake it yet.'),
2338
- needsWeb: 'This dimension has an exercise that for now is only taken on the web, not in the terminal.',
2339
- interviewUnavailable: (reason) => `The interview could not be completed (${reason}).`,
2340
- reportPolling: 'Computing your verdict…',
2341
- reportTimeout: 'The verdict is still being computed. Check your profile in a few minutes.',
2342
- reportFailed: (reason) => `Could not fetch the verdict (${reason}). Check your profile.`,
2343
- verdictTitle: 'Verdict',
2344
- verdictDimension: (dim) => `Dimension: ${dim}`,
2345
- verdictBand: (band) => `Level: ${band}`,
2346
- verdictCertified: 'Result: certified',
2347
- verdictNotCertified: 'Result: not certified this time',
2348
- done: 'Certification complete.',
2320
+ webHandoff: 'The certification interview is done on the web.',
2321
+ webHandoffLink: (url) => `Open it here to certify this dimension:\n ${url}`,
2322
+ webHandoffNoLink: 'Open your Shakers profile on the web and go to Certifications to take it.',
2349
2323
  },
2350
2324
  findProjects: {
2351
2325
  help:
@@ -18,24 +18,26 @@ const FONT_STACK = '"Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSy
18
18
 
19
19
  const COPY = {
20
20
  en: {
21
- signin: 'Sign in', create: 'Create account', welcome: 'Welcome to Shakers — create your talent account to get started.',
21
+ signin: 'Sign in', create: 'Create account', welcome: 'Welcome to Shakers. Create your talent account to get started.',
22
22
  email: 'Email', password: 'Password', repeat: 'Repeat password', first: 'First name', last: 'Last name',
23
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.',
24
+ hint: 'Your password stays on this computer. We never send it to the chat.', mismatch: 'The passwords do not match.',
25
+ done: 'You are signed in.', doneSub: 'You can close this tab and go back to your AI app.',
26
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.',
27
+ continueGoogle: 'Continue with Google', continueLinkedin: 'Continue with LinkedIn', continueEmail: 'Email and password', continueEmailRegister: 'Continue with email',
28
+ socialOpening: (name) => `Opening sign-in with ${name}…`,
29
+ socialOpeningSub: 'A sign-in window just opened. Finish there and come back to your AI app, it will carry on.',
29
30
  },
30
31
  es: {
31
- signin: 'Iniciar sesión', create: 'Crear cuenta', welcome: 'Bienvenido a Shakers — crea tu cuenta de talento para empezar.',
32
+ signin: 'Iniciar sesión', create: 'Crear cuenta', welcome: 'Bienvenido a Shakers. Crea tu cuenta de talento para empezar.',
32
33
  email: 'Email', password: 'Contraseña', repeat: 'Repite la contraseña', first: 'Nombre', last: 'Apellidos',
33
34
  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
+ hint: 'Tu contraseña se queda en este equipo. Nunca la enviamos al chat.', mismatch: 'Las contraseñas no coinciden.',
35
36
  done: 'Sesión iniciada.', doneSub: 'Puedes cerrar esta pestaña y volver a tu app de IA.',
36
37
  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.',
38
+ continueGoogle: 'Continuar con Google', continueLinkedin: 'Continuar con LinkedIn', continueEmail: 'Email y contraseña', continueEmailRegister: 'Continuar con email',
39
+ socialOpening: (name) => `Abriendo el acceso con ${name}…`,
40
+ socialOpeningSub: 'Se acaba de abrir una ventana para terminar el acceso. Cuando acabes, vuelve a tu app de IA y seguirá por su cuenta.',
39
41
  },
40
42
  };
41
43
 
@@ -56,7 +58,7 @@ function styles() {
56
58
  + `:root{--background:#ffffff;--foreground:#18181b;--primary:#05342c;--primary-foreground:#fafafa;`
57
59
  + `--secondary:#e2f2f0;--secondary-foreground:#0b5a4c;--muted-foreground:#3f3f46;--accent:#e2f2f0;--accent-foreground:#08473c;`
58
60
  + `--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)}`
61
+ + `--shadow-xs:0 1px 3px 0px rgb(0 0 0 / .05)}`
60
62
  + `*{box-sizing:border-box}`
61
63
  + `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
64
  + `.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}`
@@ -104,7 +106,7 @@ function errorBanner(reason) {
104
106
 
105
107
  function loginFormHtml(lang, { error } = {}) {
106
108
  const c = copy(lang);
107
- return page(`Shakers — ${c.signin}`, {
109
+ return page(`Shakers · ${c.signin}`, {
108
110
  inner: `<h1>${c.signin}</h1>${errorBanner(error)}`
109
111
  + `<form method="post" action="/submit">`
110
112
  + `<label>${c.email}</label><input name="email" type="email" required autofocus>`
@@ -117,7 +119,7 @@ function loginFormHtml(lang, { error } = {}) {
117
119
 
118
120
  function registerFormHtml(lang, { error } = {}) {
119
121
  const c = copy(lang);
120
- return page(`Shakers — ${c.create}`, {
122
+ return page(`Shakers · ${c.create}`, {
121
123
  banner: panel(c.welcome),
122
124
  inner: `<h1>${c.create}</h1>${errorBanner(error)}`
123
125
  + `<form method="post" action="/submit">`
@@ -140,22 +142,28 @@ const GOOGLE_G = '<svg class="g" viewBox="0 0 48 48" aria-hidden="true">'
140
142
  + '<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
143
  + '<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
144
 
145
+ const LINKEDIN_IN = '<svg class="g" viewBox="0 0 24 24" aria-hidden="true">'
146
+ + '<path fill="#0A66C2" d="M20.45 20.45h-3.56v-5.57c0-1.33-.03-3.04-1.85-3.04-1.86 0-2.14 1.45-2.14 2.94v5.67H9.35V9h3.41v1.56h.05c.47-.9 1.63-1.85 3.36-1.85 3.6 0 4.26 2.37 4.26 5.45v6.29zM5.34 7.43a2.06 2.06 0 1 1 0-4.13 2.06 2.06 0 0 1 0 4.13zM7.12 20.45H3.56V9h3.56v11.45zM22.22 0H1.77C.79 0 0 .77 0 1.73v20.54C0 23.22.79 24 1.77 24h20.45c.98 0 1.78-.78 1.78-1.73V1.73C24 .77 23.2 0 22.22 0z"/></svg>';
147
+
143
148
  // 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
149
  function methodChoiceHtml(mode, lang, methods) {
145
150
  const c = copy(lang);
146
151
  const title = mode === 'register' ? c.chooseTitleRegister : c.chooseTitleLogin;
147
152
  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}`, {
153
+ const buttons = methods.map((id) => {
154
+ if (id === 'google') return `<a class="mbtn primary" href="/google">${GOOGLE_G}${c.continueGoogle}</a>`;
155
+ if (id === 'linkedin') return `<a class="mbtn secondary" href="/linkedin">${LINKEDIN_IN}${c.continueLinkedin}</a>`;
156
+ return `<a class="mbtn secondary" href="/email">${emailLabel}</a>`;
157
+ }).join('');
158
+ return page(`Shakers · ${title}`, {
152
159
  inner: `<h1>${title}</h1><div class="actions">${buttons}</div><p class="hint">${c.hint}</p>`,
153
160
  });
154
161
  }
155
162
 
156
- function googleHandoffHtml(lang) {
163
+ function socialHandoffHtml(lang, provider) {
157
164
  const c = copy(lang);
158
- return page('Shakers', { inner: `<h1>${c.googleOpening}</h1><p>${c.googleOpeningSub}</p>` });
165
+ const name = provider === 'linkedin' ? 'LinkedIn' : 'Google';
166
+ return page('Shakers', { inner: `<h1>${c.socialOpening(name)}</h1><p>${c.socialOpeningSub}</p>` });
159
167
  }
160
168
 
161
169
  function successHtml(lang) {
@@ -247,8 +255,14 @@ function runLoopbackAuth(
247
255
  }
248
256
  if (req.method === 'GET' && url.pathname === '/google' && methods.includes('google')) {
249
257
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
250
- res.end(googleHandoffHtml(lang));
251
- finish({ ok: true, viaGoogle: true });
258
+ res.end(socialHandoffHtml(lang, 'google'));
259
+ finish({ ok: true, viaSocial: true, provider: 'google' });
260
+ return;
261
+ }
262
+ if (req.method === 'GET' && url.pathname === '/linkedin' && methods.includes('linkedin')) {
263
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
264
+ res.end(socialHandoffHtml(lang, 'linkedin'));
265
+ finish({ ok: true, viaSocial: true, provider: 'linkedin' });
252
266
  return;
253
267
  }
254
268
  if (req.method === 'POST' && url.pathname === '/submit') {
@@ -279,4 +293,4 @@ function runLoopbackAuth(
279
293
  });
280
294
  }
281
295
 
282
- module.exports = { runLoopbackAuth, doAuth, loginFormHtml, registerFormHtml, methodChoiceHtml, googleHandoffHtml, DEFAULT_TIMEOUT_MS };
296
+ module.exports = { runLoopbackAuth, doAuth, loginFormHtml, registerFormHtml, methodChoiceHtml, socialHandoffHtml, DEFAULT_TIMEOUT_MS };
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- // MCP: list the offerable dimensions (the interview itself runs over LiveKit in the CLI).
3
+ // MCP: list the offerable dimensions (the certification interview itself is done on the web).
4
4
 
5
5
  const LIST_CERTIFIABLE_DIMENSIONS_SCHEMA = {
6
6
  type: 'object',
@@ -45,7 +45,7 @@ function makeCertifyDimensionTools(deps = {}) {
45
45
  return [
46
46
  {
47
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.",
48
+ description: "List the dimensions the talent can certify 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 is done on the web; running `shakers certify` in the terminal hands the talent the web link to take it. 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
49
  inputSchema: LIST_CERTIFIABLE_DIMENSIONS_SCHEMA,
50
50
  handler: listCertifiableDimensions,
51
51
  },
@@ -131,7 +131,7 @@ function makeOnboardingTools(deps = {}) {
131
131
  // No explicit `method`: the loopback form opens on its email/social choice screen.
132
132
  async function loginViaForm({ method = null } = {}) {
133
133
  const result = await runLoopbackAuth(method ? { mode: 'login', method } : { mode: 'login' });
134
- if (result.viaGoogle) return socialSigninPointer('login', 'google');
134
+ if (result.viaSocial) return socialSigninPointer('login', result.provider || 'google');
135
135
  if (!result.ok) return { ok: false, reason: result.reason || 'login-failed' };
136
136
  return { ok: true, method: 'email', email: result.email || null };
137
137
  }
@@ -0,0 +1,71 @@
1
+ 'use strict';
2
+
3
+ // MCP prompts = in-client slash-commands. Each is a thin wrapper that injects a user turn
4
+ // telling the assistant which Shakers tool(s) to use — the logic stays in the tools, so
5
+ // there is no duplication. `buildMessages(args)` returns the prompts/get message list.
6
+
7
+ function userText(text) {
8
+ return [{ role: 'user', content: { type: 'text', text } }];
9
+ }
10
+
11
+ // Only reference tools that exist in the FULL server; `available(toolNames)` hides a prompt
12
+ // when its tool isn't registered (e.g. the --local scan-only server).
13
+ const PROMPTS = [
14
+ {
15
+ name: 'ai_usage',
16
+ description: 'Evaluate and showcase how you work with AI (scans this machine, builds your "My work with AI").',
17
+ tools: ['ai_usage'],
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.',
20
+ ),
21
+ },
22
+ {
23
+ name: 'onboarding',
24
+ description: 'Register and onboard on Shakers (profile import, pricing, availability, interview).',
25
+ tools: ['register', 'suggest_register_context'],
26
+ buildMessages: () => userText(
27
+ 'Help me get set up on Shakers. Use `suggest_register_context` to pre-fill what you can, show me `register_preview`, then `register`, and offer the `onboarding_interview_*` tools. Ask me only for what you cannot infer.',
28
+ ),
29
+ },
30
+ {
31
+ name: 'find_projects',
32
+ description: 'Find open Shakers projects/positions matched to you.',
33
+ tools: ['find_projects'],
34
+ buildMessages: () => userText(
35
+ 'Find open Shakers positions that match me: call `find_projects` and summarize them by title/company (never show internal ids). Offer `save_project` / `show_project` for ones I like.',
36
+ ),
37
+ },
38
+ {
39
+ name: 'certify',
40
+ description: 'See which role dimensions you can certify and start a certification.',
41
+ tools: ['list_certifiable_dimensions'],
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.',
44
+ ),
45
+ },
46
+ {
47
+ name: 'ask',
48
+ description: 'Ask Alma, the Shakers AI assistant, a question.',
49
+ tools: ['ask'],
50
+ arguments: [{ name: 'message', description: 'What to ask Alma.', required: false }],
51
+ buildMessages: (args = {}) => userText(
52
+ `Ask Alma (the Shakers assistant) and relay her answer as-is. My question: ${typeof args.message === 'string' && args.message ? args.message : '(ask me what I want to know first)'}`,
53
+ ),
54
+ },
55
+ {
56
+ name: 'my_profile',
57
+ description: 'Show your Shakers profile (role, rate, availability, "My work with AI").',
58
+ tools: ['get_profile'],
59
+ buildMessages: () => userText(
60
+ 'Show my Shakers profile: call `get_profile` (and `get_rate` / `get_availability` if useful) and present it in plain terms. Any ids/codes are internal — never show them.',
61
+ ),
62
+ },
63
+ ];
64
+
65
+ function makePrompts(toolNames = null) {
66
+ const available = toolNames instanceof Set ? toolNames : null;
67
+ // Keep a prompt only if at least one of its tools is present in this server.
68
+ return PROMPTS.filter((p) => !available || p.tools.some((t) => available.has(t)));
69
+ }
70
+
71
+ module.exports = { makePrompts, PROMPTS };
@@ -96,7 +96,7 @@ function buildSchemas(lang) {
96
96
  // collection guide with friendly labels inline for every choice field.
97
97
  function buildRegisterDescription(lang) {
98
98
  const L = getCatalog(lang).onboarding;
99
- return "The talent registers THEMSELVES on Shakers (self-service; no prior session needed). Your job is to relay the non-secret profile details the talent gives you in the chat as arguments. Credentials never touch the chat or any tool argument: the tool opens a sign-up window on the talent's own machine (127.0.0.1) where they either Continue with Google (no password at all) or type an email and password directly into that local window.\n\nSAVE THE TALENT TYPING: first call suggest_register_context (timezone, local git identity, GitHub login, CV files) and PROPOSE those values for them to confirm; then call register_preview with the partial payload to get a review (what would be written, what is still missing) and show the talent a short summary to confirm or correct BEFORE calling register. Pre-fill EVERY field you can reasonably infer from what you know about the talent (your memory of past conversations, the chat, context) — including `pricing` and `workSituation.changeMotivators`. PRICING: whenever you know anything about their role, seniority, stack or location, ALWAYS propose a concrete per-project amount yourself (full-time and/or part-time, in their currency), labelled as your estimate — do not hand the question back to the talent; only ask for it when you know nothing at all about their work. MOTIVATORS: pick the one that best matches what they have told you. What you know about the person outranks suggest_register_context: if the git identity does not match the person you know, ignore it. For each inferred value briefly say where it comes from. Ask ONLY for what you genuinely have no basis for. Use suggest_register_context's githubUrl and cvCandidates to propose profile.githubUrl and profile.cvPath (cvCandidates often include OTHER people's CVs, e.g. candidates they downloaded: propose a CV ONLY if its file name matches the talent's name — nameMatch:true or a name you know — and never one named after someone else; if none matches but there is a generic:true one like cv.pdf, ASK the talent whether it is theirs and whether you may read it and send it with the registration; otherwise ask for the path). Once the talent confirms a CV and allows it, ALWAYS call read_cv BEFORE register_preview and use its text to pre-fill or correct the other fields (languages and levels, experience, location); if the CV adds or changes any value, show the talent just those changes and get their OK before calling register; pass paths exactly as given, the short ~/ form is fine. For optional fields you have no basis for (phone, website, newsletter), do not ask one by one: add a single closing line offering them (e.g. '¿Quieres añadir teléfono o web?'). The goal is that the talent only has to answer 'sí' or correct a value. Never write without the talent's confirmation of the summary. All of this is self-declared profile data.\n\nFor whatever you still have to ASK (only what you could not infer), ask in ONE grouped message, not one field at a time, and for every CHOICE field PRESENT THE VALID OPTIONS so the talent just picks one. For every choice field SHOW THE TALENT THE FRIENDLY LABEL, never the raw code (e.g. show 'Nativo', not 'NATIVE'), and pass the code as the value. Fields, grouped:\n1. Identity — name, lastName, email (optional: preferredLanguage, newsletterConsent).\n2. Profile — `profile.linkedinUrl` (required source; optional GitHub/website/CV path).\n3. Work — freelanceType (offer: "
99
+ return "The talent registers THEMSELVES on Shakers (self-service; no prior session needed). Your job is to relay the non-secret profile details the talent gives you in the chat as arguments. Credentials never touch the chat or any tool argument: the tool opens a sign-up window on the talent's own machine (127.0.0.1) where they either Continue with Google (no password at all) or type an email and password directly into that local window.\n\nGO STRAIGHT TO A FILLED DRAFT — never open with a questionnaire or a list of what you do not know. (1) Your FIRST reply is one short question: may you search for and read their CV on this machine to fill everything in? (skip it if they already said yes). (2) With their yes: call suggest_register_context with talentName = the name you know, then read_cv the candidates (nameMatch first, then generic ones like cv.pdf) with talentConsent:true and talentName, until one returns mentionsTalent:true; a CV with mentionsTalent:false belongs to someone else: drop it and never show or use anything from it. From their CV take LinkedIn/GitHub (links), experience, education, languages and location; pass paths exactly as given (the short ~/ form is fine). If they say no, there is no CV, or none of the CVs is theirs, do NOT fall back to questions: build the same draft from everything else you know about them — your memory of past conversations, a search of those conversations if you have that tool, this conversation and any context or memory files you can see; the CV adds facts, it is never a precondition. (3) Pre-fill EVERY field: identity from git/your memory/the CV; workSituation from what you know (a company email or employer in memory or CV means employed there — propose it); `pricing` ALWAYS as a concrete per-project estimate from their role, seniority, stack and location, labelled as your estimate (ask only if you know nothing about their work); `workSituation.changeMotivators` as your best match; availability, languages and country from context. What you know about the person outranks git: if the git identity is someone else, ignore it. (4) Call register_preview and show ONE summary that says where each value comes from, for the talent to confirm or correct in one reply. Required fields you truly have no basis for go in one short question at the end; optional unknowns (phone, website, newsletter) in a single 'si quieres' line, never as missing items. (5) After their confirmation, call register. The sign-up goes to suggest_register_context's `environment` (local/development/staging/production): name it correctly and never call a test environment production. All of this is self-declared profile data.\n\nFor whatever you still have to ASK (only what you could not infer), ask in ONE grouped message, not one field at a time, and for every CHOICE field PRESENT THE VALID OPTIONS so the talent just picks one. For every choice field SHOW THE TALENT THE FRIENDLY LABEL, never the raw code (e.g. show 'Nativo', not 'NATIVE'), and pass the code as the value. Fields, grouped:\n1. Identity — name, lastName, email (optional: preferredLanguage, newsletterConsent).\n2. Profile — `profile.linkedinUrl` (required source; optional GitHub/website/CV path).\n3. Work — freelanceType (offer: "
100
100
  + labelledOptions(FREELANCE_TYPES, L.freelanceTypeLabels)
101
101
  + ") and `workSituation` (offer situation: "
102
102
  + labelledOptions(WORK_SITUATIONS, L.workSituationLabels)
@@ -244,15 +244,17 @@ function makeRegisterTools(deps = {}) {
244
244
  if (!check.ok) return { ok: false, step: 'signup', reason: check.reason };
245
245
 
246
246
  const created = await runLoopbackAuth({ mode: 'register', deps: resolved, lang, registerFields });
247
- if (created.viaGoogle) {
247
+ if (created.viaSocial) {
248
248
  // Social registration is the device flow (start/poll tools), not this single blocking call.
249
+ const provider = created.provider || 'google';
249
250
  return {
250
251
  ok: false,
251
252
  step: 'signup',
252
253
  reason: 'use-device-flow',
253
254
  next: 'social_signin_start',
254
255
  mode: 'register',
255
- message: `To register with a social provider, call social_signin_start (provider "google" or "linkedin", mode "register", freelanceType "${registerFields.freelanceType || ''}") to get a URL and code to show the talent, then social_signin_poll. Once it returns authorized, run the profile steps (import, work situation, pricing).`,
256
+ provider,
257
+ message: `To register with ${provider}, call social_signin_start (provider "${provider}", mode "register", freelanceType "${registerFields.freelanceType || ''}") to get a URL and code to show the talent, then social_signin_poll. Once it returns authorized, run the profile steps (import, work situation, pricing).`,
256
258
  };
257
259
  }
258
260
  if (!created.ok) {
@@ -460,17 +462,19 @@ function makeRegisterTools(deps = {}) {
460
462
  }
461
463
 
462
464
  // Content-free context the model may PROPOSE to the talent (never auto-write).
463
- async function suggestContext() {
465
+ async function suggestContext(args = {}) {
464
466
  const read = deps.readRegisterContextSuggestions || require('./register-context').readRegisterContextSuggestions;
465
- return { ok: true, suggestions: read({}), note: 'Propose these to the talent to confirm; never write them without confirmation.' };
467
+ return { ok: true, suggestions: read({ talentName: args.talentName }), note: 'Propose these to the talent to confirm; never write them without confirmation.' };
466
468
  }
467
469
 
468
470
  // Text of the CV the talent confirmed as theirs, read only with their permission.
469
471
  async function readCv(args = {}) {
470
472
  if (args.talentConsent !== true) return { ok: false, reason: 'consent-required', message: 'Ask the talent first whether this CV is theirs and whether you may read it; call again with talentConsent:true only after they say yes.' };
471
473
  const read = deps.readCvText || require('./cv-text').readCvText;
472
- const result = read(args.path);
474
+ const result = read(args.path, { talentName: args.talentName });
473
475
  if (!result.ok) return { ...result, message: 'Could not read the CV text here. Ask the talent to attach the CV to the chat, or to tell you what it says.' };
476
+ // Someone else's CV (a candidate they downloaded): never hand its contents over.
477
+ if (result.mentionsTalent === false) return { ok: true, mentionsTalent: false, note: "This CV does not name the talent: it belongs to someone else. Do not use, quote or describe it; try the next candidate." };
474
478
  return result;
475
479
  }
476
480
 
@@ -489,18 +493,19 @@ function makeRegisterTools(deps = {}) {
489
493
  },
490
494
  {
491
495
  name: 'suggest_register_context',
492
- description: "Get local SUGGESTIONS to speed up registration: the environment timezone, the local git identity (user.name / user.email), the GitHub login the gh CLI is signed in with (githubUser/githubUrl), and cvCandidates — PDF files in Desktop/Documents/Downloads whose NAME looks like a CV (names only, never read), each with nameMatch (file name contains the git name). Only propose a CV that is clearly the talent's own; never propose one named after someone else. A generic:true name (cv.pdf, curriculum_2024.pdf) may be theirs: ask 'is this CV yours? may I read it and send it with your registration?'. These are PROPOSALS only — present them to the talent to confirm (e.g. 'I see your timezone is Europe/Madrid and your git email is X — use these?') and never write any of them without the talent's confirmation. No session needed. Use it before register_preview to pre-fill availability.timezone, name/email, profile.githubUrl and profile.cvPath.",
493
- inputSchema: { type: 'object', properties: {} },
496
+ description: "Local SUGGESTIONS to fill the registration (no network, no file contents): `environment` the sign-up goes to (local/development/staging/production — name it correctly, never call a test environment production), timezone, git name/email, the GitHub login of the gh CLI (githubUser/githubUrl), and cvCandidates — PDF/DOC/DOCX files in Desktop, Documents, Downloads and iCloud Drive whose NAME looks like a CV or carries the talent's full name, each with nameMatch and generic flags (people who hire keep other people's CVs, so a candidate is only a lead: confirm it with read_cv). Pass talentName = the talent's name as you know it to sharpen the match. Propose values, never write without the talent's confirmation.",
497
+ inputSchema: { type: 'object', properties: { talentName: { type: 'string', description: "The talent's full name as you know it (memory, chat); improves CV matching." } } },
494
498
  handler: suggestContext,
495
499
  },
496
500
  {
497
501
  name: 'read_cv',
498
- description: "Read the TEXT of the talent's CV PDF so you can pre-fill the register and the onboarding from what it says (experience, projects, stack, languages, education). Only for a CV the talent has CONFIRMED is theirs, and only after they gave you PERMISSION to read it — set talentConsent:true only then. Typical path: one of suggest_register_context's cvCandidates. Local only: the file is read on this machine and its text comes back to you; nothing is uploaded by this tool (the CV is sent to Shakers only if the talent also confirms it as profile.cvPath in register). Returns { ok, text, truncated } or { ok:false, reason } — on failure ask the talent to attach the CV to the chat.",
502
+ description: "Read the TEXT of a CV file (PDF, DOC, DOCX) on this machine to fill the register and the onboarding from it. Call it once the talent has allowed you to search and read their CV (talentConsent:true), with talentName set. Returns { mentionsTalent, links: { linkedin, github, emails, phones }, text } for the talent's own CV; for a CV that does not name them it returns only mentionsTalent:false — it is someone else's: drop it, never describe it, try the next candidate. Local only: nothing is uploaded by this tool (the CV is sent to Shakers only as profile.cvPath in a confirmed register). On failure ask the talent to attach the CV to the chat.",
499
503
  inputSchema: {
500
504
  type: 'object',
501
505
  properties: {
502
- path: { type: 'string', description: 'Path to the PDF the talent confirmed as their CV, exactly as suggest_register_context gave it (the short ~/ form is fine) or absolute.' },
503
- talentConsent: { type: 'boolean', description: 'true ONLY after the talent explicitly agreed that this is their CV and that you may read it.' },
506
+ path: { type: 'string', description: 'Path to the CV file, exactly as suggest_register_context gave it (the short ~/ form is fine) or absolute.' },
507
+ talentConsent: { type: 'boolean', description: 'true ONLY after the talent allowed you to search for and read their CV.' },
508
+ talentName: { type: 'string', description: "The talent's full name as you know it; used to tell their CV from other people's." },
504
509
  },
505
510
  required: ['path', 'talentConsent'],
506
511
  },
package/src/mcp-server.js CHANGED
@@ -27,7 +27,7 @@ function errorResponse(id, code, message, data) {
27
27
  return { jsonrpc: JSONRPC, id: id === undefined ? null : id, error };
28
28
  }
29
29
 
30
- function createMcpServer({ tools = [], serverInfo = {}, protocolVersion, beforeCall = null, instructions = null } = {}) {
30
+ function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVersion, beforeCall = null, instructions = null } = {}) {
31
31
  const registry = new Map();
32
32
  for (const t of tools) {
33
33
  if (!t || !t.name || typeof t.handler !== 'function') {
@@ -36,6 +36,14 @@ function createMcpServer({ tools = [], serverInfo = {}, protocolVersion, beforeC
36
36
  registry.set(t.name, t);
37
37
  }
38
38
 
39
+ const promptRegistry = new Map();
40
+ for (const p of prompts) {
41
+ if (!p || !p.name || typeof p.buildMessages !== 'function') {
42
+ throw new Error('each prompt needs a name and a buildMessages function');
43
+ }
44
+ promptRegistry.set(p.name, p);
45
+ }
46
+
39
47
  const advertisedVersion = protocolVersion || DEFAULT_PROTOCOL_VERSION;
40
48
 
41
49
  function shapeTool(t) {
@@ -50,6 +58,27 @@ function createMcpServer({ tools = [], serverInfo = {}, protocolVersion, beforeC
50
58
  return { tools: [...registry.values()].map(shapeTool) };
51
59
  }
52
60
 
61
+ function shapePrompt(p) {
62
+ const out = { name: p.name, description: p.description || '' };
63
+ if (Array.isArray(p.arguments)) out.arguments = p.arguments;
64
+ return out;
65
+ }
66
+
67
+ function listPrompts() {
68
+ return { prompts: [...promptRegistry.values()].map(shapePrompt) };
69
+ }
70
+
71
+ function getPrompt(params) {
72
+ const name = params && params.name;
73
+ const prompt = name && promptRegistry.get(name);
74
+ if (!prompt) {
75
+ const err = new Error(`unknown prompt: ${name}`);
76
+ err.rpcCode = ERROR.INVALID_PARAMS;
77
+ throw err;
78
+ }
79
+ return { description: prompt.description || '', messages: prompt.buildMessages((params && params.arguments) || {}) };
80
+ }
81
+
53
82
  async function listAvailableTools() {
54
83
  const out = [];
55
84
  for (const t of registry.values()) {
@@ -112,7 +141,10 @@ function createMcpServer({ tools = [], serverInfo = {}, protocolVersion, beforeC
112
141
  const requested = msg.params && msg.params.protocolVersion;
113
142
  return resultResponse(id, {
114
143
  protocolVersion: requested || advertisedVersion,
115
- capabilities: { tools: { listChanged: false } },
144
+ capabilities: {
145
+ tools: { listChanged: false },
146
+ ...(promptRegistry.size ? { prompts: { listChanged: false } } : {}),
147
+ },
116
148
  serverInfo: {
117
149
  name: serverInfo.name || 'shakers-mcp',
118
150
  version: serverInfo.version || '0.0.0',
@@ -128,6 +160,10 @@ function createMcpServer({ tools = [], serverInfo = {}, protocolVersion, beforeC
128
160
  return resultResponse(id, await listAvailableTools());
129
161
  case 'tools/call':
130
162
  return resultResponse(id, await callTool(msg.params));
163
+ case 'prompts/list':
164
+ return resultResponse(id, listPrompts());
165
+ case 'prompts/get':
166
+ return resultResponse(id, getPrompt(msg.params));
131
167
  default:
132
168
  if (isNotification(msg)) return null;
133
169
  return errorResponse(id, ERROR.METHOD_NOT_FOUND, `method not found: ${method}`);
@@ -11,10 +11,12 @@ const path = require('path');
11
11
  const { execFileSync } = require('child_process');
12
12
  const { getHomeDir, tildify } = require('./env-paths');
13
13
 
14
- // Where a talent keeps a CV, and how deep we look under each folder.
15
- const CV_DIRS = ['Desktop', 'Documents', 'Downloads', 'Escritorio', 'Documentos', 'Descargas'];
16
- const CV_MAX_DEPTH = 2;
17
- const CV_MAX_RESULTS = 5;
14
+ // Where a talent keeps a CV, and how deep we look under each folder. iCloud
15
+ // Drive holds Desktop/Documents for many Mac users.
16
+ const CV_DIRS = ['Desktop', 'Documents', 'Downloads', 'Escritorio', 'Documentos', 'Descargas', 'Library/Mobile Documents/com~apple~CloudDocs'];
17
+ const CV_MAX_DEPTH = 3;
18
+ const CV_MAX_RESULTS = 8;
19
+ const CV_EXTENSIONS = /\.(pdf|docx?)$/i;
18
20
  const CV_NAME = /(^|[^a-z])(cv|curriculum|curr[ií]culum|resume|r[ée]sum[ée])([^a-z]|$)/i;
19
21
 
20
22
  function detectTimezone() {
@@ -56,12 +58,22 @@ function detectGithubUser(home, cwd) {
56
58
 
57
59
  const fold = (text) => String(text || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
58
60
 
61
+ const nameTokens = (personName) => fold(personName).split(/[^a-z0-9]+/).filter((t) => t.length >= 3);
62
+
59
63
  // Whether a file name carries a token of the person's name. People who hire keep
60
64
  // other people's CVs in Downloads; only a name match makes a candidate theirs.
61
65
  function matchesName(fileName, personName) {
62
- const tokens = fold(personName).split(/[^a-z0-9]+/).filter((t) => t.length >= 3);
63
66
  const file = fold(fileName);
64
- return tokens.some((t) => file.includes(t));
67
+ return nameTokens(personName).some((t) => file.includes(t));
68
+ }
69
+
70
+ // A file named after the person but without a CV word (`Alvaro Torro.pdf`) is a
71
+ // candidate only when it carries their full name, so one common first name does
72
+ // not pull in every contract and invoice.
73
+ function carriesFullName(fileName, personName) {
74
+ const tokens = nameTokens(personName);
75
+ const file = fold(fileName);
76
+ return tokens.length >= 2 && tokens.every((t) => file.includes(t));
65
77
  }
66
78
 
67
79
  // Words that say nothing about whose CV it is: `cv.pdf`, `Curriculum_2024_ES.pdf`,
@@ -73,8 +85,8 @@ function isGenericCvName(fileName) {
73
85
  return rest.trim().length === 0;
74
86
  }
75
87
 
76
- // PDFs whose NAME looks like a CV: name matches first, then generic names, then
77
- // newest. Never opens a file.
88
+ // PDF/Word files whose NAME looks like a CV or carries the person's full name:
89
+ // name matches first, then generic names, then newest. Never opens a file.
78
90
  function findCvCandidates(home, personName) {
79
91
  const found = [];
80
92
  const walk = (dir, depth) => {
@@ -89,7 +101,8 @@ function findCvCandidates(home, personName) {
89
101
  const full = path.join(dir, entry.name);
90
102
  if (entry.isDirectory()) {
91
103
  if (depth < CV_MAX_DEPTH) walk(full, depth + 1);
92
- } else if (entry.isFile() && /\.pdf$/i.test(entry.name) && CV_NAME.test(entry.name.replace(/\.pdf$/i, ''))) {
104
+ } else if (entry.isFile() && CV_EXTENSIONS.test(entry.name)
105
+ && (CV_NAME.test(entry.name.replace(CV_EXTENSIONS, '')) || carriesFullName(entry.name, personName))) {
93
106
  try {
94
107
  found.push({
95
108
  path: full,
@@ -110,19 +123,37 @@ function findCvCandidates(home, personName) {
110
123
  .map(({ path: cvPath, nameMatch, generic }) => ({ path: tildify(cvPath, { SHAKERS_CLI_HOME_DIR: home }), nameMatch, generic }));
111
124
  }
112
125
 
113
- // Returns the signals found, each clearly a SUGGESTION to confirm.
114
- function readRegisterContextSuggestions({ cwd, env = process.env } = {}) {
126
+ // Which backend this install talks to, so the AI never calls a test sign-up
127
+ // "production".
128
+ function detectEnvironment(env) {
129
+ let base = '';
130
+ try {
131
+ base = String(require('./config').getHubBase(env) || '');
132
+ } catch {
133
+ return 'unknown';
134
+ }
135
+ if (/localhost|127\.0\.0\.1/.test(base)) return 'local';
136
+ if (/staging/.test(base)) return 'staging';
137
+ if (/\.dev\.|new-works/.test(base)) return 'development';
138
+ return base ? 'production' : 'unknown';
139
+ }
140
+
141
+ // Returns the signals found, each clearly a SUGGESTION to confirm. `talentName`
142
+ // (what the AI already knows) sharpens the CV match beyond the git name.
143
+ function readRegisterContextSuggestions({ cwd, env = process.env, talentName } = {}) {
115
144
  const home = getHomeDir(env);
116
145
  const githubUser = detectGithubUser(home, cwd);
117
146
  const gitName = gitConfig('user.name', cwd);
147
+ const personName = [talentName, gitName].filter((n) => typeof n === 'string' && n.trim()).join(' ');
118
148
  return {
149
+ environment: detectEnvironment(env),
119
150
  timezone: detectTimezone(),
120
151
  gitName,
121
152
  gitEmail: gitConfig('user.email', cwd),
122
153
  githubUser,
123
154
  githubUrl: githubUser ? `https://github.com/${githubUser}` : null,
124
- cvCandidates: findCvCandidates(home, gitName),
155
+ cvCandidates: findCvCandidates(home, personName),
125
156
  };
126
157
  }
127
158
 
128
- module.exports = { readRegisterContextSuggestions, detectTimezone, findCvCandidates, detectGithubUser, isGenericCvName };
159
+ module.exports = { readRegisterContextSuggestions, detectTimezone, findCvCandidates, detectGithubUser, isGenericCvName, detectEnvironment, nameTokens, fold };
package/src/scanner.js CHANGED
@@ -202,7 +202,19 @@ function computeRecency(paths) {
202
202
  /* ---------- version: ONLY runs the ALREADY detected binary, with --version ---------- */
203
203
  /* Never arbitrary commands; all output is discarded except the version pattern. */
204
204
 
205
+ // scan() runs once per selected repo; remember each binary's version for a minute instead of asking again.
206
+ const VERSION_TTL_MS = 60 * 1000;
207
+ const versionCache = new Map();
208
+
205
209
  function getVersion(bin) {
210
+ const cached = versionCache.get(bin);
211
+ if (cached && Date.now() - cached.at <= VERSION_TTL_MS) return cached.version;
212
+ const version = readVersion(bin);
213
+ versionCache.set(bin, { version, at: Date.now() });
214
+ return version;
215
+ }
216
+
217
+ function readVersion(bin) {
206
218
  try {
207
219
  const out = execFileSync(bin, ['--version'], {
208
220
  timeout: 3000,
@@ -609,4 +621,4 @@ function mergeReports(reports) {
609
621
  };
610
622
  }
611
623
 
612
- module.exports = { scan, scanForRoots };
624
+ module.exports = { scan, scanForRoots, getVersion };