shakers-cli-beta 0.10.4 → 0.12.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
@@ -487,6 +487,39 @@ const catalogs = {
487
487
  yes: 'Sí, iniciar sesión',
488
488
  no: 'No, continuar sin cuenta',
489
489
  },
490
+ // `update` command: actualiza el CLI a la última versión publicada (solo CLI, no MCP).
491
+ update: {
492
+ help:
493
+ 'update — actualiza el CLI de Shakers a la última versión publicada.\n\n'
494
+ + ' Uso: shakers update [--check]\n\n'
495
+ + ' (sin flag) Comprueba la última versión y, si hay una más nueva, la instala\n'
496
+ + ' por el mismo método con el que se instaló este CLI.\n'
497
+ + ' --check Solo informa de tu versión frente a la última publicada; no instala.\n'
498
+ + ' --json Salida JSON (paquete, versión actual/última, si hay actualización).\n',
499
+ checking: 'Comprobando actualizaciones…',
500
+ current: (v) => `Versión instalada: ${v}`,
501
+ upToDate: (v) => `Ya tienes la última versión (${v}).`,
502
+ available: (from, to) => `Hay una actualización disponible: ${from} → ${to}`,
503
+ checkHint: 'Ejecuta `shakers update` (sin --check) para instalarla.',
504
+ detected: (label) => `Instalación detectada: ${label}`,
505
+ updating: (name, from, to) => `Actualizando ${name}: ${from} → ${to}…`,
506
+ success: (v) => `Actualizado a la versión ${v}.`,
507
+ reRun: 'Vuelve a ejecutar tu comando para usar la nueva versión.',
508
+ latestUnknown: (reason) =>
509
+ `No se pudo determinar la última versión (${reason}). Revisa tu conexión y el acceso al registro.`,
510
+ failInstall: (reason) => `La actualización falló (${reason}).`,
511
+ failHint: (cmd) =>
512
+ `Revisa tu conexión y permisos y vuelve a intentarlo, o actualiza a mano:\n ${cmd}`,
513
+ sourceCheckout:
514
+ 'Este CLI se ejecuta desde un checkout de código — actualízalo con `git pull` en ese repositorio.',
515
+ unknownInstall: (cmd) =>
516
+ `No se pudo detectar cómo se instaló este CLI. Actualiza a mano:\n ${cmd}`,
517
+ },
518
+ // Aviso pasivo "hay una versión nueva" al arrancar cualquier comando (lectura de caché, sin red en el camino caliente).
519
+ updateNotifier: {
520
+ available: (from, to) => `Hay una versión nueva del CLI de Shakers: ${from} → ${to}`,
521
+ howTo: 'Ejecuta `shakers update` para actualizar.',
522
+ },
490
523
  // `report` command (ADR-016): genera y ABRE el informe HTML completo y compartible de este proyecto (uso de IA + Skills certificadas).
491
524
  report: {
492
525
  help: 'report — genera y abre el informe HTML completo de este proyecto (uso de IA + Skills certificadas) para compartir con tu equipo.\n'
@@ -726,39 +759,9 @@ const catalogs = {
726
759
  dimensionInvalid: 'Esa dimensión no está entre las certificables ahora mismo.',
727
760
  dimensionOnCooldown: (slug, day) => `Aún no puedes repetir ${slug}: estará disponible el ${day}.`,
728
761
  dimensionUsing: (slug) => `Dimensión elegida: ${slug}`,
729
- creating: 'Creando la entrevista…',
730
- createFailed: (reason) => `No se pudo crear la entrevista (${reason}).`,
731
- onCooldown: (day) => (day
732
- ? `Ya hiciste esta dimensión hace poco. Podrás repetirla a partir del ${day}.`
733
- : 'Ya hiciste esta dimensión hace poco y todavía no se puede repetir.'),
734
- needsWeb: 'Esta dimensión lleva un ejercicio que por ahora solo se hace en la web, no por terminal.',
735
- interviewUnavailable: (reason) => `La entrevista no pudo completarse (${reason}).`,
736
- reportPolling: 'Calculando tu veredicto…',
737
- reportTimeout: 'El veredicto todavía se está calculando. Consúltalo en tu perfil en unos minutos.',
738
- reportFailed: (reason) => `No se pudo obtener el veredicto (${reason}). Consúltalo en tu perfil.`,
739
- verdictTitle: 'Veredicto',
740
- verdictDimension: (dim) => `Dimensión: ${dim}`,
741
- verdictBand: (band) => `Nivel: ${band}`,
742
- verdictCertified: 'Resultado: certificada',
743
- verdictNotCertified: 'Resultado: no certificada esta vez',
744
- done: 'Certificación completada.',
745
- // The same case copy the web preview shows (works `certifications.preview`).
746
- caseTitle: 'El caso',
747
- caseExpectationsTitle: 'Qué se espera',
748
- caseExpectations: {
749
- CASE_STUDY: {
750
- reasoning: 'Se evalúa tu razonamiento y los supuestos que haces, no una respuesta perfecta.',
751
- askForData: 'Puedes pedir datos que no están en el caso: di para qué los quieres.',
752
- takeTime: 'Puedes pararte a pensar antes de responder.',
753
- },
754
- ROLE_PLAY: {
755
- reasoning: 'Se evalúa cómo razonas en la conversación y los supuestos que haces, no una respuesta perfecta.',
756
- askForData: 'Puedes pedir a la otra parte datos que no están en el caso.',
757
- takeTime: 'Puedes pararte a pensar antes de responder.',
758
- },
759
- },
760
- caseBeforeStart: 'Alma te va a hacer preguntas sobre este caso. Si te falta algún dato, pídeselo durante la entrevista.',
761
- caseReadyPrompt: 'Pulsa Enter cuando lo hayas leído para empezar la entrevista.',
762
+ webHandoff: 'Esta certificación se hace en la web. Abre este enlace para empezar la entrevista:',
763
+ webHandoffLink: (url) => ` ${url}`,
764
+ webHandoffNoLink: 'No se pudo generar el enlace. Entra en tu perfil de Shakers y abre la sección de certificaciones.',
762
765
  },
763
766
  findProjects: {
764
767
  help:
@@ -982,6 +985,7 @@ const catalogs = {
982
985
  noTerminal: 'No hay una terminal donde preguntarte, así que no he tocado nada. Vuelve a ejecutar el instalador en tu terminal para conectarlo.',
983
986
  restart: 'Reinicia las apps que estén abiertas para que carguen Shakers.',
984
987
  noneFound: 'No he encontrado ninguna app de IA compatible en esta máquina.',
988
+ failedAll: 'No he podido conectar Shakers a tus apps de IA. Vuelve a ejecutar el instalador para reintentarlo.',
985
989
  },
986
990
  rate: {
987
991
  help: 'rate — ver o fijar tu tarifa por proyecto.\n\nUso:\n rate [--lang es|en] [--json] ver tu tarifa\n rate --set [--lang es|en] fijarla (interactivo, con confirmación)\n\nRequiere una sesión activa (shakers login).',
@@ -1210,7 +1214,7 @@ const catalogs = {
1210
1214
  // Branded mini-shell chrome (skill-code-certification / ADR-014).
1211
1215
  login: {
1212
1216
  intro: 'Inicia sesión con tu email y contraseña de Shakers.',
1213
- sessionExpiredIntro: 'Tu sesión anterior caducó. Vuelve a iniciar sesión con tu email y contraseña.',
1217
+ sessionExpiredIntro: 'Tu sesión anterior caducó. Vuelve a iniciar sesión.',
1214
1218
  alreadyLoggedIn: 'Ya has iniciado sesión. Usa `logout` si quieres cambiar de cuenta.',
1215
1219
  emailPrompt: 'Email:',
1216
1220
  passwordPrompt: 'Contraseña:',
@@ -1240,16 +1244,19 @@ const catalogs = {
1240
1244
  googleErrorState: 'La respuesta del navegador no coincidió con lo esperado (posible interferencia). Por seguridad no se inició sesión; inténtalo de nuevo.',
1241
1245
  googleErrorDenied: 'Cancelaste el acceso en Google (o no se concedió). No se inició sesión.',
1242
1246
  googleErrorGeneric: 'No se pudo iniciar sesión con Google. Inténtalo de nuevo.',
1243
- // Google por device flow (RFC 8628): la config de Google la tiene el Hub; abres una URL y escribes un código.
1247
+ // Device flow (RFC 8628) para TODO método (Google, LinkedIn, email): el Hub tiene la config; abres una URL y, si hace falta, escribes un código.
1244
1248
  deviceIntro: (name) => `Inicia sesión con ${name}. Abre el enlace de abajo e introduce el código para autorizar; luego vuelve aquí.`,
1249
+ // Email por device flow: se abre la web de Shakers; reutiliza tu sesión abierta o entra con tu email ahí. Nunca pide contraseña en la terminal.
1250
+ emailDeviceIntro: 'Inicia sesión con tu email. Abrimos la web de Shakers: entra con tu email o aprueba si ya tienes la sesión abierta. No hace falta contraseña aquí.',
1245
1251
  deviceVisit: 'Abre esta página en tu navegador:',
1246
1252
  deviceCodeLabel: (code) => `e introduce este código: ${code}`,
1247
1253
  deviceWaiting: 'Esperando a que autorices en el navegador…',
1248
- deviceErrorExpired: 'La solicitud de acceso con Google caducó antes de autorizarse. Vuelve a ejecutar `login --google` cuando estés listo.',
1249
- deviceErrorDenied: 'Se canceló el acceso con Google (o no se concedió). No se inició sesión.',
1250
- deviceErrorGeneric: 'No se pudo iniciar sesión con Google. Inténtalo de nuevo.',
1254
+ deviceApproveHint: 'Abrimos tu navegador: apruébalo ahí para terminar (o entra con tu email si aún no has iniciado sesión). No hace falta contraseña.',
1255
+ deviceErrorExpired: 'La solicitud de acceso caducó antes de autorizarse. Vuelve a ejecutar `login` cuando estés listo.',
1256
+ deviceErrorDenied: 'Se canceló el acceso (o no se concedió). No se inició sesión.',
1257
+ deviceErrorGeneric: 'No se pudo iniciar sesión. Inténtalo de nuevo.',
1251
1258
  chooseMethodHeading: '¿Cómo quieres iniciar sesión?',
1252
- methodEmail: 'Email y contraseña',
1259
+ methodEmail: 'Email',
1253
1260
  methodGoogle: 'Google',
1254
1261
  methodLinkedin: 'LinkedIn',
1255
1262
  selectHint: 'Flechas ↑/↓ para moverte · enter para elegir · esc para cancelar',
@@ -2122,6 +2129,39 @@ const catalogs = {
2122
2129
  yes: 'Yes, sign in',
2123
2130
  no: 'No, continue without an account',
2124
2131
  },
2132
+ // `update` command: updates the CLI to the latest published version (CLI-only, not MCP).
2133
+ update: {
2134
+ help:
2135
+ 'update — update the Shakers CLI to the latest published version.\n\n'
2136
+ + ' Usage: shakers update [--check]\n\n'
2137
+ + ' (no flag) Check the latest version and, if a newer one exists, install it\n'
2138
+ + ' the same way this CLI was installed.\n'
2139
+ + ' --check Only report your version vs the latest published; do not install.\n'
2140
+ + ' --json JSON output (package, current/latest version, whether an update exists).\n',
2141
+ checking: 'Checking for updates…',
2142
+ current: (v) => `Installed version: ${v}`,
2143
+ upToDate: (v) => `You are on the latest version (${v}).`,
2144
+ available: (from, to) => `An update is available: ${from} → ${to}`,
2145
+ checkHint: 'Run `shakers update` (without --check) to install it.',
2146
+ detected: (label) => `Detected install: ${label}`,
2147
+ updating: (name, from, to) => `Updating ${name}: ${from} → ${to}…`,
2148
+ success: (v) => `Updated to version ${v}.`,
2149
+ reRun: 'Re-run your command to use the new version.',
2150
+ latestUnknown: (reason) =>
2151
+ `Could not determine the latest version (${reason}). Check your network and registry access.`,
2152
+ failInstall: (reason) => `The update failed (${reason}).`,
2153
+ failHint: (cmd) =>
2154
+ `Check your network and permissions and try again, or update manually:\n ${cmd}`,
2155
+ sourceCheckout:
2156
+ 'This CLI is running from a source checkout — update it with `git pull` in that repository.',
2157
+ unknownInstall: (cmd) =>
2158
+ `Could not detect how this CLI was installed. Update manually:\n ${cmd}`,
2159
+ },
2160
+ // Passive "a newer version exists" notice printed when any command starts (cache read, no network on the hot path).
2161
+ updateNotifier: {
2162
+ available: (from, to) => `A newer Shakers CLI is available: ${from} → ${to}`,
2163
+ howTo: 'Run `shakers update` to upgrade.',
2164
+ },
2125
2165
  // `report` command (ADR-016): builds and OPENS the full, shareable HTML report for this project (AI usage + certified Skills).
2126
2166
  report: {
2127
2167
  help: 'report — build and open the full HTML report for this project (AI usage + certified Skills) to share with your team.\n'
@@ -2354,39 +2394,9 @@ const catalogs = {
2354
2394
  dimensionInvalid: 'That dimension is not among the certifiable ones right now.',
2355
2395
  dimensionOnCooldown: (slug, day) => `You can't retake ${slug} yet: it will be available on ${day}.`,
2356
2396
  dimensionUsing: (slug) => `Chosen dimension: ${slug}`,
2357
- creating: 'Creating the interview…',
2358
- createFailed: (reason) => `Could not create the interview (${reason}).`,
2359
- onCooldown: (day) => (day
2360
- ? `You took this dimension recently. You can retake it from ${day}.`
2361
- : 'You took this dimension recently and cannot retake it yet.'),
2362
- needsWeb: 'This dimension has an exercise that for now is only taken on the web, not in the terminal.',
2363
- interviewUnavailable: (reason) => `The interview could not be completed (${reason}).`,
2364
- reportPolling: 'Computing your verdict…',
2365
- reportTimeout: 'The verdict is still being computed. Check your profile in a few minutes.',
2366
- reportFailed: (reason) => `Could not fetch the verdict (${reason}). Check your profile.`,
2367
- verdictTitle: 'Verdict',
2368
- verdictDimension: (dim) => `Dimension: ${dim}`,
2369
- verdictBand: (band) => `Level: ${band}`,
2370
- verdictCertified: 'Result: certified',
2371
- verdictNotCertified: 'Result: not certified this time',
2372
- done: 'Certification complete.',
2373
- // The same case copy the web preview shows (works `certifications.preview`).
2374
- caseTitle: 'The case',
2375
- caseExpectationsTitle: 'What is expected',
2376
- caseExpectations: {
2377
- CASE_STUDY: {
2378
- reasoning: 'We assess your reasoning and the assumptions you make, not a perfect answer.',
2379
- askForData: 'You can ask for data that is not in the case: say what you want it for.',
2380
- takeTime: 'You can stop to think before you answer.',
2381
- },
2382
- ROLE_PLAY: {
2383
- reasoning: 'We assess how you reason in the conversation and the assumptions you make, not a perfect answer.',
2384
- askForData: 'You can ask the other side for data that is not in the case.',
2385
- takeTime: 'You can stop to think before you answer.',
2386
- },
2387
- },
2388
- caseBeforeStart: 'Alma is going to ask you questions about this case. If you are missing any data, ask her for it during the interview.',
2389
- caseReadyPrompt: 'Press Enter once you have read it to start the interview.',
2397
+ webHandoff: 'This certification is done on the web. Open this link to start the interview:',
2398
+ webHandoffLink: (url) => ` ${url}`,
2399
+ webHandoffNoLink: 'Could not build the link. Go to your Shakers profile and open the certifications section.',
2390
2400
  },
2391
2401
  findProjects: {
2392
2402
  help:
@@ -2610,6 +2620,7 @@ const catalogs = {
2610
2620
  noTerminal: 'There is no terminal to ask you on, so nothing was touched. Run the installer again in your terminal to connect it.',
2611
2621
  restart: 'Restart any of these apps that is open so it loads Shakers.',
2612
2622
  noneFound: 'Found no supported AI app on this machine.',
2623
+ failedAll: 'Could not connect Shakers to your AI apps. Re-run this installer to retry.',
2613
2624
  },
2614
2625
  rate: {
2615
2626
  help: 'rate — view or set your per-project rate.\n\nUsage:\n rate [--lang es|en] [--json] view your rate\n rate --set [--lang es|en] set it (interactive, with confirmation)\n\nRequires an active session (shakers login).',
@@ -2834,7 +2845,7 @@ const catalogs = {
2834
2845
  // Branded mini-shell chrome (skill-code-certification / ADR-014).
2835
2846
  login: {
2836
2847
  intro: 'Log in with your Shakers email and password.',
2837
- sessionExpiredIntro: 'Your previous session expired. Log in again with your email and password.',
2848
+ sessionExpiredIntro: 'Your previous session expired. Log in again.',
2838
2849
  alreadyLoggedIn: 'You are already logged in. Use `logout` if you want to switch accounts.',
2839
2850
  emailPrompt: 'Email:',
2840
2851
  passwordPrompt: 'Password:',
@@ -2864,17 +2875,20 @@ const catalogs = {
2864
2875
  googleErrorState: 'The browser response did not match what was expected (possible interference). For safety no session was started; try again.',
2865
2876
  googleErrorDenied: 'You cancelled access in Google (or it was not granted). No session was started.',
2866
2877
  googleErrorGeneric: 'Could not log in with Google. Try again.',
2867
- // Google via device flow (RFC 8628): the Hub owns Google config; you open a URL and type a code.
2878
+ // Device flow (RFC 8628) for EVERY method (Google, LinkedIn, email): the Hub owns the config; you open a URL and, if needed, type a code.
2868
2879
  deviceIntro: (name) => `Sign in with ${name}. Open the link below and enter the code to authorize; then come back here.`,
2880
+ // Email via device flow: the Shakers web opens; reuse your open session or sign in with your email there. It never asks for a password in the terminal.
2881
+ emailDeviceIntro: 'Sign in with your email. We open the Shakers web: sign in with your email, or approve if you are already signed in. No password is needed here.',
2869
2882
  deviceVisit: 'Open this page in your browser:',
2870
2883
  deviceCodeLabel: (code) => `and enter this code: ${code}`,
2871
2884
  deviceWaiting: 'Waiting for you to authorize in the browser…',
2872
- deviceErrorExpired: 'The Google sign-in request expired before it was authorized. Run `login --google` again when you are ready.',
2873
- deviceErrorDenied: 'The Google sign-in was cancelled or not granted. No session was started.',
2874
- deviceErrorGeneric: 'Could not sign in with Google. Try again.',
2885
+ deviceApproveHint: 'We opened your browser: approve there to finish (or sign in with your email if you are not signed in yet). No password needed.',
2886
+ deviceErrorExpired: 'The sign-in request expired before it was authorized. Run `login` again when you are ready.',
2887
+ deviceErrorDenied: 'The sign-in was cancelled or not granted. No session was started.',
2888
+ deviceErrorGeneric: 'Could not sign in. Try again.',
2875
2889
  // Interactive method picker (`login` with no --email/--google/--provider): reuses the SAME single-select picker `certify agents` uses to choose an agent (src/interactive-select.js).
2876
2890
  chooseMethodHeading: 'How do you want to log in?',
2877
- methodEmail: 'Email and password',
2891
+ methodEmail: 'Email',
2878
2892
  methodGoogle: 'Google',
2879
2893
  methodLinkedin: 'LinkedIn',
2880
2894
  selectHint: 'Arrows ↑/↓ to move · enter to pick · esc to cancel',
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- // Languages client. VIEW = GET `works/talents/me/work-details/languages`; the
3
+ // Languages client. The
4
4
  // catalog (id<->name) = GET `static-data/languages` (@Public); SET = PATCH the
5
5
  // same work-details endpoint (REPLACE-ALL: send the full list of {id, level}).
6
6
 
@@ -5,7 +5,7 @@
5
5
  const START_SCHEMA = {
6
6
  type: 'object',
7
7
  properties: {
8
- provider: { type: 'string', enum: ['google', 'linkedin'], description: "Optional social provider. Defaults to 'google'. Same device flow either way; it selects which social button the sign-in page opens." },
8
+ provider: { type: 'string', enum: ['google', 'linkedin', 'email'], description: "Optional provider. Defaults to 'google'. Same device flow either way; it selects which option the Shakers sign-in page opens — 'email' opens the email sign-in (the talent reuses their open web session or signs in there, never a password in the chat)." },
9
9
  mode: { type: 'string', enum: ['login', 'register'], description: "Optional. 'login' signs an existing talent in; 'register' creates the account if new (the profile steps come from the register/onboarding tools afterwards). Defaults to 'login'." },
10
10
  freelanceType: { type: 'string', description: 'Optional, register only: FREELANCE | EMPLOYEE | AGENCY | POTENTIAL_FREELANCE. Used to complete a new account.' },
11
11
  preferredLanguage: { type: 'string', description: 'Optional, register only: es | en | it | pt | fr.' },
@@ -33,7 +33,7 @@ function makeDeviceLoginTools(deps = {}) {
33
33
 
34
34
  async function start(args = {}) {
35
35
  const mode = args.mode === 'register' ? 'register' : 'login';
36
- const provider = args.provider === 'linkedin' ? 'linkedin' : 'google';
36
+ const provider = args.provider === 'linkedin' ? 'linkedin' : args.provider === 'email' ? 'email' : 'google';
37
37
  const authorizeEndpoint = getDeviceAuthorizeEndpoint();
38
38
  const tokenEndpoint = getDeviceTokenEndpoint();
39
39
  const authTokenEndpoint = getAuthTokenEndpoint();
@@ -157,7 +157,7 @@ function makeDeviceLoginTools(deps = {}) {
157
157
  return [
158
158
  {
159
159
  name: 'social_signin_start',
160
- description: "Begin social sign-in (or registration) via the device flow — provider 'google' (default) or 'linkedin'. Returns a verificationUrl and a userCode: show BOTH to the talent verbatim — they open the URL and enter the code in their own browser (they never type anything in the chat, and you never invent or fill the code). Pass mode 'register' to create the account if it is new. After showing them, call social_signin_poll. Returns { ok, status, provider, verificationUrl, userCode, pollIntervalSeconds, ... }.",
160
+ description: "Begin sign-in (or registration) via the device flow — provider 'google' (default), 'linkedin' or 'email'. Email goes through the SAME web flow: the talent reuses their open Shakers session or signs in with their email on the page, never a password in the chat. Returns a verificationUrl and a userCode: show BOTH to the talent verbatim — they open the URL and enter the code in their own browser (they never type anything in the chat, and you never invent or fill the code). Pass mode 'register' to create the account if it is new. After showing them, call social_signin_poll. Returns { ok, status, provider, verificationUrl, userCode, pollIntervalSeconds, ... }.",
161
161
  inputSchema: START_SCHEMA,
162
162
  handler: start,
163
163
  },
@@ -266,7 +266,8 @@ async function runMcpInstall({ out = (s) => process.stdout.write(s), lang = null
266
266
  out(` ${c.clients[r.client]}: ${c.status[r.status]}\n`);
267
267
  if (r.removedLegacy) out(` ${c.removedLegacy}\n`);
268
268
  }
269
- out(`\n ${c.restart}\n\n`);
269
+ const touched = results.some((r) => r.status === 'configured' || r.status === 'unchanged');
270
+ out(`\n ${touched ? c.restart : c.failedAll}\n\n`);
270
271
  if (results.some((r) => r.status === 'failed' || r.status === 'invalid-json')) process.exitCode = 1;
271
272
  return { ok: true, results };
272
273
  }
@@ -11,10 +11,10 @@ function buildServerInstructions({ elicitation = false } = {}) {
11
11
  ? 'Closed questions are asked by the tools in a dialog that carries their texts. Only if a tool returns `say` (the talent closed the dialog), print it and pass the option they pick back as `answer` (the number or the text).'
12
12
  : 'Closed questions come as `say` with their numbered options: print it, then pass the option they pick back as `answer` (the number or the text).',
13
13
  'Sign-up flow:',
14
- '1. signup_start (welcome and one question: may I look for your CV here?). If yes, find and read their CV (suggest_register_context, read_cv) without asking again; otherwise wait for the CV in the chat.',
14
+ '1. signup_start (welcome and one question: may I look for your CV here?); a CV path in their first message goes as cvPath and skips the question. If yes, find and read their CV (suggest_register_context, read_cv) without asking again; otherwise wait for the CV in the chat.',
15
15
  '2. LinkedIn: take it from the CV. If it has none, ask the linkedinAsk text. Only if they say they do not know where to find it, say the linkedinOpening text and call open_linkedin_profile.',
16
16
  '3. signup_email with the best email you know (CV first), or none.',
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). 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.',
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 20) 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
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.',
@@ -5,7 +5,7 @@ const path = require('path');
5
5
  const LOGIN_SCHEMA = {
6
6
  type: 'object',
7
7
  properties: {
8
- method: { type: 'string', enum: ['email', 'google', 'linkedin'], description: "Optional auth method. 'email' opens an email-and-password window in the talent's browser (they type the password there, never in the chat). 'google'/'linkedin' do not sign in here — they point you to social_signin_start/social_signin_poll (the device flow) with that provider. Omit it to open that window." },
8
+ method: { type: 'string', enum: ['email', 'google', 'linkedin'], description: "Optional auth method. None of them sign in here: this tool points you to social_signin_start/social_signin_poll (the device flow) with that provider. 'email' reuses the talent's open Shakers web session or signs them in on the page (never a password in the chat); 'google'/'linkedin' open that social sign-in. Omit it to default to email (the page still offers Google and LinkedIn)." },
9
9
  },
10
10
  };
11
11
 
@@ -92,7 +92,6 @@ function makeOnboardingTools(deps = {}) {
92
92
  normalizeDomain = require('./start-add-portfolio').normalizeDomain,
93
93
  isValidEmail = require('./share').isValidEmail,
94
94
  normalizeEmail = require('./share').normalizeEmail,
95
- runLoopbackAuth = require('./mcp-auth-form').runLoopbackAuth,
96
95
  } = deps;
97
96
 
98
97
  const requireSession = () => {
@@ -118,8 +117,10 @@ function makeOnboardingTools(deps = {}) {
118
117
  return endpoint;
119
118
  };
120
119
 
121
- // Google/LinkedIn run through the device flow (start/poll tools); a blocking form can't surface the code.
122
- const socialSigninPointer = (mode, provider = 'google') => ({
120
+ // Every method runs through the device flow (start/poll tools); a blocking form
121
+ // can't surface the code, and email now reuses the web session there instead of
122
+ // prompting for a password. The start/poll pair persists the session.
123
+ const socialSigninPointer = (mode, provider = 'email') => ({
123
124
  ok: false,
124
125
  reason: 'use-device-flow',
125
126
  next: 'social_signin_start',
@@ -128,18 +129,11 @@ function makeOnboardingTools(deps = {}) {
128
129
  message: `For ${provider}, call social_signin_start (provider "${provider}", mode "${mode}") to get a URL and code to show the talent, then social_signin_poll.`,
129
130
  });
130
131
 
131
- // No explicit `method`: the loopback form opens on its email/social choice screen.
132
- async function loginViaForm({ method = null } = {}) {
133
- const result = await runLoopbackAuth(method ? { mode: 'login', method } : { mode: 'login' });
134
- if (result.viaSocial) return socialSigninPointer('login', result.provider || 'google');
135
- if (!result.ok) return { ok: false, reason: result.reason || 'login-failed' };
136
- return { ok: true, method: 'email', email: result.email || null };
137
- }
138
-
132
+ // Email, Google and LinkedIn all hand off to social_signin_start/poll. Email
133
+ // defaults when no method is given; the /cli-login page still offers the others.
139
134
  async function login(args = {}) {
140
- if (args.method === 'google' || args.method === 'linkedin') return socialSigninPointer('login', args.method);
141
- if (args.method === 'email') return loginViaForm({ method: 'email' });
142
- return loginViaForm();
135
+ const provider = args.method === 'google' || args.method === 'linkedin' ? args.method : 'email';
136
+ return socialSigninPointer('login', provider);
143
137
  }
144
138
 
145
139
  async function logout() {
@@ -340,7 +334,7 @@ function makeOnboardingTools(deps = {}) {
340
334
  return [
341
335
  {
342
336
  name: 'login',
343
- description: "Sign the talent in to Shakers with email and password and persist the session so the add_* and certify tools can run. The talent types their password into a window in their own browser, never in the chat. For Google or LinkedIn, use social_signin_start + social_signin_poll instead (this tool with method 'google'/'linkedin' just points you there). Returns { ok, method, email } or a structured error.",
337
+ description: "Start signing the talent in to Shakers so the add_* and certify tools can run. This tool does not sign in itself: it points you to social_signin_start + social_signin_poll (the device flow), which open the Shakers web page and persist the session. Email (the default) reuses the talent's open web session or signs them in on the page — never a password in the chat; 'google'/'linkedin' open that social sign-in. Returns a { ok: false, reason: 'use-device-flow', next, provider } pointer; call social_signin_start next.",
344
338
  inputSchema: LOGIN_SCHEMA,
345
339
  handler: login,
346
340
  },
@@ -22,7 +22,12 @@ const fold = (text) => String(text || '').normalize('NFD').replace(/[\u0300-\u03
22
22
 
23
23
  const wordsOf = (text) => fold(text).split(/[^a-z0-9+#.]+/).filter((w) => w.length >= 2);
24
24
 
25
- // Catalogue roles by relevance: the exact name first, then shared role words, then stack words; a word many roles share (engineer) counts less.
25
+ // Words that name no specialty: a role sharing only these with the talent does not match.
26
+ const GENERIC_ROLE_WORDS = new Set(['developer', 'developers', 'dev', 'engineer', 'engineering', 'software', 'programmer', 'specialist', 'expert', 'consultant', 'senior', 'junior', 'lead', 'freelance',
27
+ 'desarrollador', 'desarrolladora', 'programador', 'programadora', 'ingeniero', 'ingeniera', 'especialista', 'experto', 'experta', 'consultor', 'consultora',
28
+ 'sviluppatore', 'sviluppatrice', 'programmatore', 'ingegnere', 'esperto', 'consulente', 'desenvolvedor', 'desenvolvedora', 'engenheiro', 'engenheira']);
29
+
30
+ // Catalogue roles by relevance: the exact name first, then shared role words, then stack words; a word many roles share (engineer) counts less, and only a specific word qualifies a role.
26
31
  function rankRoles(roles, said, skills) {
27
32
  const names = roles.map((r) => wordsOf(r.name));
28
33
  const df = new Map();
@@ -31,13 +36,16 @@ function rankRoles(roles, said, skills) {
31
36
  const asked = new Set(wordsOf(said));
32
37
  const stack = new Set(wordsOf(skills));
33
38
  const exact = fold(said).trim();
34
- const shown = (role, words) => words.map((w) => role.name.split(/[^A-Za-z0-9+#.]+/).find((t) => fold(t) === w) || w);
39
+ const specific = (words) => words.filter((w) => !GENERIC_ROLE_WORDS.has(w));
40
+ const shown = (role, words) => specific(words).map((w) => role.name.split(/[^A-Za-z0-9+#.]+/).find((t) => fold(t) === w) || w);
35
41
  return roles
36
42
  .map((role, i) => {
37
43
  const byRole = names[i].filter((w) => asked.has(w));
38
44
  const byStack = names[i].filter((w) => stack.has(w) && !asked.has(w));
39
- const score = (exact && fold(role.name) === exact ? 100 : 0) + weight(byRole) + weight(byStack) / 2;
40
- return { role, score, byRole: shown(role, byRole), byStack: shown(role, byStack) };
45
+ const isExact = !!exact && fold(role.name) === exact;
46
+ const score = (isExact ? 100 : 0) + weight(byRole) + weight(byStack) / 2;
47
+ const matches = isExact || specific([...byRole, ...byStack]).length > 0;
48
+ return { role, score, matches, byRole: shown(role, byRole), byStack: shown(role, byStack) };
41
49
  })
42
50
  .sort((a, b) => b.score - a.score);
43
51
  }
@@ -269,7 +277,7 @@ function makeRoleTools(deps = {}) {
269
277
  const roles = byName(available.roles);
270
278
  if (!roles.length) return { reply: { ok: false, reason: 'no-roles' } };
271
279
  const ranked = rankRoles(roles, said, args.skills);
272
- const shown = roles.length <= MAX_ROLE_OPTIONS ? ranked : ranked.filter((m) => m.score > 0).slice(0, MAX_ROLE_OPTIONS);
280
+ const shown = roles.length <= MAX_ROLE_OPTIONS ? ranked : ranked.filter((m) => m.matches).slice(0, MAX_ROLE_OPTIONS);
273
281
  if (!shown.length) return askOwnRole();
274
282
  // This call's own list: another call may replace `offered` while the dialog is open.
275
283
  const options = shown.map((m) => m.role);
@@ -65,7 +65,7 @@ async function requestMcpSignup(
65
65
  return { ok: true, claimCode: data.claimCode, claimCodeExpiresAt: data.claimCodeExpiresAt || null };
66
66
  }
67
67
 
68
- // hub works/me/import-profile/status → { state: running | done | failed, sources: { linkedin?, cv? } }, the shape signup_status reports.
68
+ // { state: running | done | failed, sources: { linkedin?, cv? } }, the shape signup_status reports.
69
69
  function importStateOf(data) {
70
70
  const status = data.profileImportStatus;
71
71
  const reported = Array.isArray(data.sources) ? data.sources : [];