shakers-cli-beta 0.11.0 → 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/config.js CHANGED
@@ -264,7 +264,7 @@ function getAuthTokenEndpoint(env = process.env) {
264
264
  function getCompleteRegistrationEndpoint(env = process.env) {
265
265
  const explicit = readEnv(env, 'COMPLETE_REGISTRATION_ENDPOINT');
266
266
  if (explicit && explicit.trim()) return explicit.trim();
267
- return deriveFromHub(env, 'works/auth/complete-registration');
267
+ return deriveFromHub(env, 'works/talent/auth/complete-registration');
268
268
  }
269
269
 
270
270
  // Google via device flow (RFC 8628): the Hub broker owns all Google config; the CLI holds no client_id.
@@ -287,7 +287,7 @@ function getDeviceTokenEndpoint(env = process.env) {
287
287
  function getMyCertificationsEndpoint(env = process.env) {
288
288
  const explicit = readEnv(env, 'MY_CERTIFICATIONS_ENDPOINT');
289
289
  if (explicit && explicit.trim()) return explicit.trim();
290
- return deriveFromHub(env, 'works/certifications/me');
290
+ return deriveFromHub(env, 'works/talent/certifications/me');
291
291
  }
292
292
 
293
293
  // Role/cluster endpoints (hub, talent Bearer). available-roles = the catalog the
@@ -296,19 +296,19 @@ function getMyCertificationsEndpoint(env = process.env) {
296
296
  function getAvailableRolesEndpoint(env = process.env) {
297
297
  const explicit = readEnv(env, 'AVAILABLE_ROLES_ENDPOINT');
298
298
  if (explicit && explicit.trim()) return explicit.trim();
299
- return deriveFromHub(env, 'works/certifications/me/available-roles');
299
+ return deriveFromHub(env, 'works/talent/certifications/me/available-roles');
300
300
  }
301
301
 
302
302
  function getSetMainRoleEndpoint(env = process.env) {
303
303
  const explicit = readEnv(env, 'SET_MAIN_ROLE_ENDPOINT');
304
304
  if (explicit && explicit.trim()) return explicit.trim();
305
- return deriveFromHub(env, 'works/certifications/me/main-role');
305
+ return deriveFromHub(env, 'works/talent/certifications/me/main-role');
306
306
  }
307
307
 
308
308
  function getAssignedClustersEndpoint(env = process.env) {
309
309
  const explicit = readEnv(env, 'ASSIGNED_CLUSTERS_ENDPOINT');
310
310
  if (explicit && explicit.trim()) return explicit.trim();
311
- return deriveFromHub(env, 'works/talents/me/work-details/assigned-clusters');
311
+ return deriveFromHub(env, 'works/talent/me/work-details/assigned-clusters');
312
312
  }
313
313
 
314
314
  function getTemplatesByDimensionEndpoint(env = process.env) {
@@ -326,12 +326,20 @@ function getMyAssessmentsEndpoint(env = process.env) {
326
326
  return base ? `${base}/users/me/assessments` : null;
327
327
  }
328
328
 
329
+ // `certify` creates its dimension interview here (certs, talent Bearer).
330
+ function getCertificationInterviewsEndpoint(env = process.env) {
331
+ const explicit = readEnv(env, 'CERTIFICATION_INTERVIEWS_ENDPOINT');
332
+ if (explicit && explicit.trim()) return explicit.trim();
333
+ const base = getCertsBase(env);
334
+ return base ? `${base}/users/me/certification-interviews` : null;
335
+ }
336
+
329
337
  // Find Positions list (hub `works/positions/find`, talent Bearer). Serves the
330
338
  // All/Recommended/Saved tabs via `criteria.savedByMe`; ordered by match desc.
331
339
  function getFindPositionsEndpoint(env = process.env) {
332
340
  const explicit = readEnv(env, 'FIND_POSITIONS_ENDPOINT');
333
341
  if (explicit && explicit.trim()) return explicit.trim();
334
- return deriveFromHub(env, 'works/positions/find');
342
+ return deriveFromHub(env, 'works/talent/positions/find');
335
343
  }
336
344
 
337
345
  // Marketplace read/write (hub, talent Bearer). The id-taking getters return null
@@ -340,20 +348,20 @@ function getPositionDetailEndpoint(positionId, env = process.env) {
340
348
  const explicit = readEnv(env, 'POSITION_DETAIL_ENDPOINT');
341
349
  if (explicit && explicit.trim()) return explicit.trim();
342
350
  if (positionId == null || typeof positionId === 'object') return null;
343
- return deriveFromHub(env, `works/positions/${encodeURIComponent(positionId)}/detail`);
351
+ return deriveFromHub(env, `works/talent/positions/${encodeURIComponent(positionId)}/detail`);
344
352
  }
345
353
 
346
354
  function getSavePositionEndpoint(env = process.env) {
347
355
  const explicit = readEnv(env, 'SAVE_POSITION_ENDPOINT');
348
356
  if (explicit && explicit.trim()) return explicit.trim();
349
- return deriveFromHub(env, 'works/saved-positions');
357
+ return deriveFromHub(env, 'works/talent/saved-positions');
350
358
  }
351
359
 
352
360
  function getUnsavePositionEndpoint(positionId, env = process.env) {
353
361
  const explicit = readEnv(env, 'UNSAVE_POSITION_ENDPOINT');
354
362
  if (explicit && explicit.trim()) return explicit.trim();
355
363
  if (positionId == null || typeof positionId === 'object') return null;
356
- return deriveFromHub(env, `works/saved-positions/${encodeURIComponent(positionId)}`);
364
+ return deriveFromHub(env, `works/talent/saved-positions/${encodeURIComponent(positionId)}`);
357
365
  }
358
366
 
359
367
  function getReceivedInvitationsEndpoint(env = process.env) {
@@ -365,7 +373,7 @@ function getReceivedInvitationsEndpoint(env = process.env) {
365
373
  function getMyCandidaturesEndpoint(env = process.env) {
366
374
  const explicit = readEnv(env, 'MY_CANDIDATURES_ENDPOINT');
367
375
  if (explicit && explicit.trim()) return explicit.trim();
368
- return deriveFromHub(env, 'works/candidatures/me');
376
+ return deriveFromHub(env, 'works/talent/candidatures/me');
369
377
  }
370
378
 
371
379
  function getMeAvailabilityEndpoint(env = process.env) {
@@ -377,13 +385,13 @@ function getMeAvailabilityEndpoint(env = process.env) {
377
385
  function getMeProfileEndpoint(env = process.env) {
378
386
  const explicit = readEnv(env, 'ME_PROFILE_ENDPOINT');
379
387
  if (explicit && explicit.trim()) return explicit.trim();
380
- return deriveFromHub(env, 'works/me/profile');
388
+ return deriveFromHub(env, 'works/talent/me/profile-summary');
381
389
  }
382
390
 
383
391
  function getTalentMeProfileEndpoint(env = process.env) {
384
392
  const explicit = readEnv(env, 'TALENT_ME_PROFILE_ENDPOINT');
385
393
  if (explicit && explicit.trim()) return explicit.trim();
386
- return deriveFromHub(env, 'works/talents/me/profile');
394
+ return deriveFromHub(env, 'works/talent/me/profile');
387
395
  }
388
396
 
389
397
  // Alma (the AI assistant) is a SEPARATE service — NOT derived from hubBase.
@@ -444,7 +452,7 @@ function getUsageReportEndpoint(env = process.env) {
444
452
  function getAiProfileEndpoint(env = process.env) {
445
453
  const explicit = readEnv(env, 'AI_PROFILE_ENDPOINT');
446
454
  if (explicit && explicit.trim()) return explicit.trim();
447
- return deriveFromHub(env, 'works/me/ai-profile');
455
+ return deriveFromHub(env, 'works/talent/me/ai-profile');
448
456
  }
449
457
 
450
458
  // Evidence emission endpoint for the `cli_scan` instrument (talents-ai-score Phase 2).
@@ -476,7 +484,7 @@ function getPortfolioDraftDescriptionEndpoint(env = process.env) {
476
484
  function getPortfolioUpdateEndpoint(env = process.env) {
477
485
  const explicit = readEnv(env, 'PORTFOLIO_UPDATE_ENDPOINT');
478
486
  if (explicit && explicit.trim()) return explicit.trim();
479
- return deriveFromHub(env, 'works/portfolios');
487
+ return deriveFromHub(env, 'works/talent/portfolios');
480
488
  }
481
489
 
482
490
  function getAgentsDeclareEndpoint(env = process.env) {
@@ -502,7 +510,7 @@ function getAgentPortfoliosEndpoint(agentId, env = process.env) {
502
510
  // Null on missing/non-scalar id: no bogus URL, and keeps config.test.js's `fn(env)`
503
511
  // getter sweep honest when the env object lands in `agentId`.
504
512
  if (agentId == null || typeof agentId === 'object') return null;
505
- return deriveFromHub(env, `works/me/agents/${encodeURIComponent(agentId)}/portfolios`);
513
+ return deriveFromHub(env, `works/talent/me/agents/${encodeURIComponent(agentId)}/portfolios`);
506
514
  }
507
515
 
508
516
  // Onboarding endpoints (evaluation-mcp, ADR-011..015/024 — the register flow).
@@ -533,7 +541,7 @@ function getSignUpEndpoint(env = process.env) {
533
541
  function getImportProfileEndpoint(env = process.env) {
534
542
  const explicit = readEnv(env, 'IMPORT_PROFILE_ENDPOINT');
535
543
  if (explicit && explicit.trim()) return explicit.trim();
536
- return deriveFromHub(env, 'works/me/import-profile');
544
+ return deriveFromHub(env, 'works/talent/me/import-profile');
537
545
  }
538
546
 
539
547
  // MCP sign-up (ticket 39540): public UNREGISTERED + claim code, its import status, and the one-time web login.
@@ -542,7 +550,7 @@ function getMcpSignupEndpoint(env = process.env) {
542
550
  }
543
551
 
544
552
  function getMyImportStatusEndpoint(env = process.env) {
545
- return deriveFromHub(env, 'works/me/import-profile/status');
553
+ return deriveFromHub(env, 'works/talent/me/import-profile/status');
546
554
  }
547
555
 
548
556
  function getOneTimeTokenEndpoint(env = process.env) {
@@ -564,25 +572,25 @@ function getOneTimeLoginUrl(token, env = process.env) {
564
572
  function getProfessionalDetailsEndpoint(env = process.env) {
565
573
  const explicit = readEnv(env, 'PROFESSIONAL_DETAILS_ENDPOINT');
566
574
  if (explicit && explicit.trim()) return explicit.trim();
567
- return deriveFromHub(env, 'works/me/professional-details');
575
+ return deriveFromHub(env, 'works/talent/me/professional-details');
568
576
  }
569
577
 
570
578
  function getPricingRateEndpoint(env = process.env) {
571
579
  const explicit = readEnv(env, 'PRICING_RATE_ENDPOINT');
572
580
  if (explicit && explicit.trim()) return explicit.trim();
573
- return deriveFromHub(env, 'works/talents/me/work-details/pricing-rate');
581
+ return deriveFromHub(env, 'works/talent/me/work-details/pricing-rate');
574
582
  }
575
583
 
576
584
  function getSetAvailabilityEndpoint(env = process.env) {
577
585
  const explicit = readEnv(env, 'AVAILABILITY_ENDPOINT');
578
586
  if (explicit && explicit.trim()) return explicit.trim();
579
- return deriveFromHub(env, 'works/talents/me/work-details/availability');
587
+ return deriveFromHub(env, 'works/talent/me/work-details/availability');
580
588
  }
581
589
 
582
590
  function getLanguagesEndpoint(env = process.env) {
583
591
  const explicit = readEnv(env, 'LANGUAGES_ENDPOINT');
584
592
  if (explicit && explicit.trim()) return explicit.trim();
585
- return deriveFromHub(env, 'works/talents/me/work-details/languages');
593
+ return deriveFromHub(env, 'works/talent/me/work-details/languages');
586
594
  }
587
595
 
588
596
  function getLanguagesCatalogEndpoint(env = process.env) {
@@ -616,7 +624,7 @@ function getTranslationsEndpoint(env = process.env) {
616
624
  function getTalentMeEndpoint(env = process.env) {
617
625
  const explicit = readEnv(env, 'TALENT_ME_ENDPOINT');
618
626
  if (explicit && explicit.trim()) return explicit.trim();
619
- return deriveFromHub(env, 'works/me');
627
+ return deriveFromHub(env, 'works/users/me');
620
628
  }
621
629
 
622
630
  function getOnboardingInterviewsEndpoint(env = process.env) {
@@ -758,6 +766,7 @@ module.exports = {
758
766
  getAssignedClustersEndpoint,
759
767
  getTemplatesByDimensionEndpoint,
760
768
  getMyAssessmentsEndpoint,
769
+ getCertificationInterviewsEndpoint,
761
770
  // Find Positions list (`find-projects` command / `find_projects` MCP tool).
762
771
  getFindPositionsEndpoint,
763
772
  // Marketplace (show-project / save-project / unsave-project / invitations).
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- // Find Positions list client (hub `works/positions/find`, talent Bearer). Each
3
+ // Find Positions list client. Each
4
4
  // item carries the parent Project's id/name — a "project" here is the Position's
5
5
  // parent, not a separate list. Pagination (limit/offset) and filters map to the
6
6
  // endpoint's `criteria`; the server orders by match desc and returns the total.
package/src/i18n.js CHANGED
@@ -759,39 +759,9 @@ const catalogs = {
759
759
  dimensionInvalid: 'Esa dimensión no está entre las certificables ahora mismo.',
760
760
  dimensionOnCooldown: (slug, day) => `Aún no puedes repetir ${slug}: estará disponible el ${day}.`,
761
761
  dimensionUsing: (slug) => `Dimensión elegida: ${slug}`,
762
- creating: 'Creando la entrevista…',
763
- createFailed: (reason) => `No se pudo crear la entrevista (${reason}).`,
764
- onCooldown: (day) => (day
765
- ? `Ya hiciste esta dimensión hace poco. Podrás repetirla a partir del ${day}.`
766
- : 'Ya hiciste esta dimensión hace poco y todavía no se puede repetir.'),
767
- needsWeb: 'Esta dimensión lleva un ejercicio que por ahora solo se hace en la web, no por terminal.',
768
- interviewUnavailable: (reason) => `La entrevista no pudo completarse (${reason}).`,
769
- reportPolling: 'Calculando tu veredicto…',
770
- reportTimeout: 'El veredicto todavía se está calculando. Consúltalo en tu perfil en unos minutos.',
771
- reportFailed: (reason) => `No se pudo obtener el veredicto (${reason}). Consúltalo en tu perfil.`,
772
- verdictTitle: 'Veredicto',
773
- verdictDimension: (dim) => `Dimensión: ${dim}`,
774
- verdictBand: (band) => `Nivel: ${band}`,
775
- verdictCertified: 'Resultado: certificada',
776
- verdictNotCertified: 'Resultado: no certificada esta vez',
777
- done: 'Certificación completada.',
778
- // The same case copy the web preview shows (works `certifications.preview`).
779
- caseTitle: 'El caso',
780
- caseExpectationsTitle: 'Qué se espera',
781
- caseExpectations: {
782
- CASE_STUDY: {
783
- reasoning: 'Se evalúa tu razonamiento y los supuestos que haces, no una respuesta perfecta.',
784
- askForData: 'Puedes pedir datos que no están en el caso: di para qué los quieres.',
785
- takeTime: 'Puedes pararte a pensar antes de responder.',
786
- },
787
- ROLE_PLAY: {
788
- reasoning: 'Se evalúa cómo razonas en la conversación y los supuestos que haces, no una respuesta perfecta.',
789
- askForData: 'Puedes pedir a la otra parte datos que no están en el caso.',
790
- takeTime: 'Puedes pararte a pensar antes de responder.',
791
- },
792
- },
793
- caseBeforeStart: 'Alma te va a hacer preguntas sobre este caso. Si te falta algún dato, pídeselo durante la entrevista.',
794
- 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.',
795
765
  },
796
766
  findProjects: {
797
767
  help:
@@ -1244,7 +1214,7 @@ const catalogs = {
1244
1214
  // Branded mini-shell chrome (skill-code-certification / ADR-014).
1245
1215
  login: {
1246
1216
  intro: 'Inicia sesión con tu email y contraseña de Shakers.',
1247
- 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.',
1248
1218
  alreadyLoggedIn: 'Ya has iniciado sesión. Usa `logout` si quieres cambiar de cuenta.',
1249
1219
  emailPrompt: 'Email:',
1250
1220
  passwordPrompt: 'Contraseña:',
@@ -1274,16 +1244,19 @@ const catalogs = {
1274
1244
  googleErrorState: 'La respuesta del navegador no coincidió con lo esperado (posible interferencia). Por seguridad no se inició sesión; inténtalo de nuevo.',
1275
1245
  googleErrorDenied: 'Cancelaste el acceso en Google (o no se concedió). No se inició sesión.',
1276
1246
  googleErrorGeneric: 'No se pudo iniciar sesión con Google. Inténtalo de nuevo.',
1277
- // 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.
1278
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í.',
1279
1251
  deviceVisit: 'Abre esta página en tu navegador:',
1280
1252
  deviceCodeLabel: (code) => `e introduce este código: ${code}`,
1281
1253
  deviceWaiting: 'Esperando a que autorices en el navegador…',
1282
- deviceErrorExpired: 'La solicitud de acceso con Google caducó antes de autorizarse. Vuelve a ejecutar `login --google` cuando estés listo.',
1283
- deviceErrorDenied: 'Se canceló el acceso con Google (o no se concedió). No se inició sesión.',
1284
- 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.',
1285
1258
  chooseMethodHeading: '¿Cómo quieres iniciar sesión?',
1286
- methodEmail: 'Email y contraseña',
1259
+ methodEmail: 'Email',
1287
1260
  methodGoogle: 'Google',
1288
1261
  methodLinkedin: 'LinkedIn',
1289
1262
  selectHint: 'Flechas ↑/↓ para moverte · enter para elegir · esc para cancelar',
@@ -2421,39 +2394,9 @@ const catalogs = {
2421
2394
  dimensionInvalid: 'That dimension is not among the certifiable ones right now.',
2422
2395
  dimensionOnCooldown: (slug, day) => `You can't retake ${slug} yet: it will be available on ${day}.`,
2423
2396
  dimensionUsing: (slug) => `Chosen dimension: ${slug}`,
2424
- creating: 'Creating the interview…',
2425
- createFailed: (reason) => `Could not create the interview (${reason}).`,
2426
- onCooldown: (day) => (day
2427
- ? `You took this dimension recently. You can retake it from ${day}.`
2428
- : 'You took this dimension recently and cannot retake it yet.'),
2429
- needsWeb: 'This dimension has an exercise that for now is only taken on the web, not in the terminal.',
2430
- interviewUnavailable: (reason) => `The interview could not be completed (${reason}).`,
2431
- reportPolling: 'Computing your verdict…',
2432
- reportTimeout: 'The verdict is still being computed. Check your profile in a few minutes.',
2433
- reportFailed: (reason) => `Could not fetch the verdict (${reason}). Check your profile.`,
2434
- verdictTitle: 'Verdict',
2435
- verdictDimension: (dim) => `Dimension: ${dim}`,
2436
- verdictBand: (band) => `Level: ${band}`,
2437
- verdictCertified: 'Result: certified',
2438
- verdictNotCertified: 'Result: not certified this time',
2439
- done: 'Certification complete.',
2440
- // The same case copy the web preview shows (works `certifications.preview`).
2441
- caseTitle: 'The case',
2442
- caseExpectationsTitle: 'What is expected',
2443
- caseExpectations: {
2444
- CASE_STUDY: {
2445
- reasoning: 'We assess your reasoning and the assumptions you make, not a perfect answer.',
2446
- askForData: 'You can ask for data that is not in the case: say what you want it for.',
2447
- takeTime: 'You can stop to think before you answer.',
2448
- },
2449
- ROLE_PLAY: {
2450
- reasoning: 'We assess how you reason in the conversation and the assumptions you make, not a perfect answer.',
2451
- askForData: 'You can ask the other side for data that is not in the case.',
2452
- takeTime: 'You can stop to think before you answer.',
2453
- },
2454
- },
2455
- caseBeforeStart: 'Alma is going to ask you questions about this case. If you are missing any data, ask her for it during the interview.',
2456
- 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.',
2457
2400
  },
2458
2401
  findProjects: {
2459
2402
  help:
@@ -2902,7 +2845,7 @@ const catalogs = {
2902
2845
  // Branded mini-shell chrome (skill-code-certification / ADR-014).
2903
2846
  login: {
2904
2847
  intro: 'Log in with your Shakers email and password.',
2905
- sessionExpiredIntro: 'Your previous session expired. Log in again with your email and password.',
2848
+ sessionExpiredIntro: 'Your previous session expired. Log in again.',
2906
2849
  alreadyLoggedIn: 'You are already logged in. Use `logout` if you want to switch accounts.',
2907
2850
  emailPrompt: 'Email:',
2908
2851
  passwordPrompt: 'Password:',
@@ -2932,17 +2875,20 @@ const catalogs = {
2932
2875
  googleErrorState: 'The browser response did not match what was expected (possible interference). For safety no session was started; try again.',
2933
2876
  googleErrorDenied: 'You cancelled access in Google (or it was not granted). No session was started.',
2934
2877
  googleErrorGeneric: 'Could not log in with Google. Try again.',
2935
- // 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.
2936
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.',
2937
2882
  deviceVisit: 'Open this page in your browser:',
2938
2883
  deviceCodeLabel: (code) => `and enter this code: ${code}`,
2939
2884
  deviceWaiting: 'Waiting for you to authorize in the browser…',
2940
- deviceErrorExpired: 'The Google sign-in request expired before it was authorized. Run `login --google` again when you are ready.',
2941
- deviceErrorDenied: 'The Google sign-in was cancelled or not granted. No session was started.',
2942
- 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.',
2943
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).
2944
2890
  chooseMethodHeading: 'How do you want to log in?',
2945
- methodEmail: 'Email and password',
2891
+ methodEmail: 'Email',
2946
2892
  methodGoogle: 'Google',
2947
2893
  methodLinkedin: 'LinkedIn',
2948
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
  },
@@ -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 : [];