shakers-cli-beta 0.8.0-beta.5 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +39 -9
  2. package/bin/ai-usage.js +37 -19
  3. package/bin/login.js +1 -1
  4. package/bin/mcp.js +25 -21
  5. package/bin/mcpb.js +5 -0
  6. package/package.json +3 -2
  7. package/src/auth-session-store.js +1 -0
  8. package/src/authorship.js +7 -6
  9. package/src/backend-request.js +2 -2
  10. package/src/certify-dimension-client.js +0 -54
  11. package/src/certify-dimension-flow.js +20 -81
  12. package/src/config.js +29 -8
  13. package/src/cv-file.js +62 -0
  14. package/src/device-login.js +5 -3
  15. package/src/find-projects-client.js +16 -6
  16. package/src/git-activity.js +8 -7
  17. package/src/git-log-prefetch.js +53 -0
  18. package/src/i18n.js +33 -58
  19. package/src/mcp-ai-usage-tool.js +150 -65
  20. package/src/mcp-auth-form.js +213 -44
  21. package/src/mcp-certify-dimension-tools.js +2 -2
  22. package/src/mcp-choice.js +47 -0
  23. package/src/mcp-install.js +32 -3
  24. package/src/mcp-instructions.js +27 -0
  25. package/src/mcp-onboarding-tools.js +3 -3
  26. package/src/mcp-prompts.js +73 -0
  27. package/src/mcp-rate-tools.js +2 -2
  28. package/src/mcp-register-tools.js +89 -313
  29. package/src/mcp-role-tools.js +177 -16
  30. package/src/mcp-server.js +108 -4
  31. package/src/mcp-signup-client.js +118 -0
  32. package/src/mcp-signup-tools.js +818 -0
  33. package/src/onboarding-client.js +28 -52
  34. package/src/onboarding-flow.js +3 -147
  35. package/src/onboarding-status-client.js +16 -1
  36. package/src/register-context.js +1 -17
  37. package/src/roles-client.js +3 -1
  38. package/src/scanner.js +13 -1
  39. package/src/session-normalizers.js +174 -0
  40. package/src/session-scan.js +149 -10
  41. package/src/signup-client.js +33 -21
  42. package/src/signup-copy.js +338 -0
  43. package/src/signup-language.js +21 -0
  44. package/src/usage-run-steps.js +3 -2
  45. package/src/work-streams.js +7 -2
package/src/cv-file.js ADDED
@@ -0,0 +1,62 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { expandHome } = require('./env-paths');
6
+
7
+ // The talent names a file on their own machine. Read it only when the resolved file (symlinks
8
+ // followed) really is a CV: an allowed extension, a regular file, under the cap, and starting
9
+ // with that format's signature. Anything else is refused before it is loaded or sent.
10
+ const CV_MAX_BYTES = 10 * 1024 * 1024;
11
+
12
+ const CV_FORMATS = {
13
+ '.pdf': { contentType: 'application/pdf', signature: Buffer.from('%PDF-') },
14
+ '.doc': { contentType: 'application/msword', signature: Buffer.from([0xd0, 0xcf, 0x11, 0xe0]) },
15
+ '.docx': {
16
+ contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
17
+ signature: Buffer.from([0x50, 0x4b, 0x03, 0x04]),
18
+ },
19
+ };
20
+
21
+ function readLocalCv(input, { extensions, badFormat }) {
22
+ if (typeof input !== 'string' || !input.trim()) return { ok: false, reason: 'cv-not-found' };
23
+ const given = expandHome(input.trim());
24
+ const ext = path.extname(given).toLowerCase();
25
+ if (!extensions.includes(ext)) return { ok: false, reason: badFormat };
26
+ const format = CV_FORMATS[ext];
27
+ let real;
28
+ try {
29
+ real = fs.realpathSync(given);
30
+ } catch {
31
+ return { ok: false, reason: 'cv-not-found' };
32
+ }
33
+ if (path.extname(real).toLowerCase() !== ext) return { ok: false, reason: badFormat };
34
+
35
+ let fd;
36
+ try {
37
+ fd = fs.openSync(real, 'r');
38
+ } catch {
39
+ return { ok: false, reason: 'cv-not-found' };
40
+ }
41
+ try {
42
+ const stat = fs.fstatSync(fd);
43
+ if (!stat.isFile()) return { ok: false, reason: 'cv-not-found' };
44
+ if (stat.size > CV_MAX_BYTES) return { ok: false, reason: 'cv-too-large' };
45
+ const data = Buffer.alloc(stat.size);
46
+ let read = 0;
47
+ while (read < stat.size) {
48
+ const n = fs.readSync(fd, data, read, stat.size - read, read);
49
+ if (n === 0) break;
50
+ read += n;
51
+ }
52
+ const content = data.subarray(0, read);
53
+ if (!content.subarray(0, format.signature.length).equals(format.signature)) return { ok: false, reason: badFormat };
54
+ return { ok: true, file: { filename: path.basename(given), contentType: format.contentType, data: content } };
55
+ } catch {
56
+ return { ok: false, reason: 'cv-not-found' };
57
+ } finally {
58
+ fs.closeSync(fd);
59
+ }
60
+ }
61
+
62
+ module.exports = { readLocalCv, CV_MAX_BYTES };
@@ -32,7 +32,7 @@ async function pollDeviceToken({ deviceCode, tokenEndpoint, clientId, timeoutMs
32
32
 
33
33
  // ORDER: a NEW user needs complete-registration BEFORE GET /auth/token (else the Hub 500s: no domain user).
34
34
  async function finalizeDeviceSession(
35
- { sessionToken, isNewUser, authTokenEndpoint, completeRegistrationEndpoint = null, registrationContext = null, timeoutMs } = {},
35
+ { sessionToken, isNewUser, authTokenEndpoint, completeRegistrationEndpoint = null, registrationContext = null, claimCode = null, timeoutMs } = {},
36
36
  {
37
37
  requestCompleteRegistration = require('./signup-client').requestCompleteRegistration,
38
38
  requestApiToken: apiToken = requestApiToken,
@@ -40,14 +40,16 @@ async function finalizeDeviceSession(
40
40
  ) {
41
41
  if (!authTokenEndpoint) return { ok: false, reason: 'no-endpoint' };
42
42
 
43
+ let claimed = false;
43
44
  if (isNewUser && completeRegistrationEndpoint) {
44
- const done = await requestCompleteRegistration(registrationContext || {}, { endpoint: completeRegistrationEndpoint, bearerToken: sessionToken, timeoutMs });
45
+ const done = await requestCompleteRegistration(registrationContext || {}, { endpoint: completeRegistrationEndpoint, bearerToken: sessionToken, claimCode, timeoutMs });
45
46
  if (!done.ok) return { ok: false, reason: done.reason || 'complete-registration-failed' };
47
+ claimed = done.claimed === true;
46
48
  }
47
49
 
48
50
  const jwt = await apiToken({ accessToken: sessionToken }, { endpoint: authTokenEndpoint, timeoutMs });
49
51
  if (!jwt.ok) return { ok: false, reason: 'token-exchange-failed' };
50
- return { ok: true, token: jwt.token };
52
+ return claimCode ? { ok: true, token: jwt.token, claimed } : { ok: true, token: jwt.token };
51
53
  }
52
54
 
53
55
  async function runDeviceLogin(
@@ -47,12 +47,13 @@ function strArray(v) {
47
47
  }
48
48
 
49
49
  // Only fields the readview's criteria map supports server-side: savedByMe,
50
- // attendance, country (confirmed in FindPositionsService + its prisma readview).
50
+ // attendance, country, clusterIds (confirmed in FindPositionsService + its prisma readview).
51
51
  // languages and skills are output-only (no filter field) → not built here.
52
- function buildCriteria(tab, { limit = DEFAULT_LIMIT, offset = 0, attendance = null, country = null } = {}) {
52
+ function buildCriteria(tab, { limit = DEFAULT_LIMIT, offset = 0, attendance = null, country = null, clusterId = null } = {}) {
53
53
  const criteria = { limit, offset };
54
54
  const conditions = [];
55
55
  if (tab === 'saved') conditions.push({ field: 'savedByMe', operator: 'EQUALS', value: true });
56
+ if (clusterId) conditions.push({ field: 'clusterIds', operator: 'IN', value: [clusterId] });
56
57
  if (attendance) conditions.push({ field: 'attendance', operator: 'IN', value: [attendance] });
57
58
  if (country) conditions.push({ field: 'country', operator: 'IN', value: [country] });
58
59
  if (conditions.length) criteria.filter = { conjunction: 'AND', conditions };
@@ -93,12 +94,12 @@ function normalizeItem(raw) {
93
94
 
94
95
  async function requestFindProjects(
95
96
  { hubAccessToken } = {},
96
- { tab = 'all', endpoint, timeoutMs = DEFAULT_TIMEOUT_MS, limit = DEFAULT_LIMIT, offset = 0, attendance = null, country = null } = {},
97
+ { tab = 'all', endpoint, timeoutMs = DEFAULT_TIMEOUT_MS, limit = DEFAULT_LIMIT, offset = 0, attendance = null, country = null, clusterId = null } = {},
97
98
  ) {
98
99
  if (!endpoint) return { ok: false, reason: 'no-endpoint' };
99
100
  if (!hubAccessToken) return { ok: false, reason: 'no-hub-token' };
100
101
 
101
- const criteria = buildCriteria(tab, { limit, offset, attendance, country });
102
+ const criteria = buildCriteria(tab, { limit, offset, attendance, country, clusterId });
102
103
  const url = `${endpoint}${endpoint.includes('?') ? '&' : '?'}criteria=${encodeURIComponent(JSON.stringify(criteria))}`;
103
104
  let res;
104
105
  try {
@@ -117,18 +118,27 @@ async function requestFindProjects(
117
118
  }
118
119
 
119
120
  // CLI+MCP entry: resolve the endpoint from config, then fetch one page.
120
- async function fetchFindProjects(deps = {}, { hubAccessToken, tab = 'all', timeoutMs = DEFAULT_TIMEOUT_MS, limit = DEFAULT_LIMIT, offset = 0, attendance = null, country = null } = {}) {
121
+ async function fetchFindProjects(deps = {}, { hubAccessToken, tab = 'all', timeoutMs = DEFAULT_TIMEOUT_MS, limit = DEFAULT_LIMIT, offset = 0, attendance = null, country = null, clusterId = null } = {}) {
121
122
  const {
122
123
  getFindPositionsEndpoint = require('./config').getFindPositionsEndpoint,
123
124
  requestFindProjects: request = requestFindProjects,
124
125
  } = deps;
125
126
  const endpoint = getFindPositionsEndpoint();
126
127
  if (!endpoint) return { ok: false, reason: 'no-endpoint' };
127
- return request({ hubAccessToken }, { tab, endpoint, timeoutMs, limit, offset, attendance, country });
128
+ return request({ hubAccessToken }, { tab, endpoint, timeoutMs, limit, offset, attendance, country, clusterId });
129
+ }
130
+
131
+ // Open positions visible to the talent that look for one role (cluster): the list's own total, one row fetched.
132
+ async function countOpenPositions(deps = {}, { hubAccessToken, clusterId, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
133
+ const res = await fetchFindProjects(deps, { hubAccessToken, timeoutMs, limit: 1, clusterId });
134
+ if (!res.ok) return res;
135
+ const total = res.meta && Number.isInteger(res.meta.total) ? res.meta.total : null;
136
+ return total === null ? { ok: false, reason: 'no-total' } : { ok: true, total };
128
137
  }
129
138
 
130
139
  module.exports = {
131
140
  TABS,
141
+ countOpenPositions,
132
142
  ATTENDANCE,
133
143
  DEFAULT_LIMIT,
134
144
  buildCriteria,
@@ -3,9 +3,14 @@
3
3
  const { execFileSync } = require('child_process');
4
4
  const path = require('path');
5
5
 
6
+ const { takePrefetchedGitLog } = require('./git-log-prefetch');
7
+
6
8
  const GIT_TIMEOUT_MS = 15000;
7
9
  const MAX_FILE_TYPES = 50;
8
10
 
11
+ // The one history read behind the activity numbers (\x01 = commit marker, \x1f = field sep).
12
+ const GIT_ACTIVITY_LOG_ARGS = ['log', '--no-merges', '--numstat', '--pretty=format:\x01%ae\x1f%at'];
13
+
9
14
  function runGit(root, args) {
10
15
  try {
11
16
  return execFileSync('git', ['-C', root, ...args], {
@@ -56,12 +61,8 @@ function collectGitActivity(root, verifiedEmail) {
56
61
 
57
62
  const sep = String.fromCharCode(1);
58
63
  const unit = String.fromCharCode(31);
59
- const log = runGit(root, [
60
- 'log',
61
- '--no-merges',
62
- '--numstat',
63
- `--pretty=format:${sep}%ae${unit}%at`,
64
- ]);
64
+ const prefetchedLog = takePrefetchedGitLog(root, GIT_ACTIVITY_LOG_ARGS);
65
+ const log = prefetchedLog !== undefined ? prefetchedLog : runGit(root, GIT_ACTIVITY_LOG_ARGS);
65
66
  if (log === null) return null;
66
67
 
67
68
  const target = String(verifiedEmail || '')
@@ -120,4 +121,4 @@ function collectGitActivity(root, verifiedEmail) {
120
121
  };
121
122
  }
122
123
 
123
- module.exports = { collectGitActivity, extensionOf, isGitRepo };
124
+ module.exports = { collectGitActivity, extensionOf, isGitRepo, GIT_ACTIVITY_LOG_ARGS };
@@ -0,0 +1,53 @@
1
+ 'use strict';
2
+
3
+ const { execFile } = require('child_process');
4
+
5
+ // Runs the aggregation's per-repo git logs concurrently with the collectors' exact args; each result is taken once and expires.
6
+
7
+ const GIT_TIMEOUT_MS = 15000;
8
+ const GIT_MAX_BUFFER = 64 * 1024 * 1024;
9
+ const MAX_CONCURRENT = 8;
10
+ const TTL_MS = 60 * 1000;
11
+
12
+ const prefetched = new Map();
13
+
14
+ const keyOf = (root, args) => JSON.stringify([root, args]);
15
+
16
+ function runAsync(root, args) {
17
+ return new Promise((resolve) => {
18
+ execFile(
19
+ 'git',
20
+ ['-C', root, ...args],
21
+ { encoding: 'utf8', timeout: GIT_TIMEOUT_MS, maxBuffer: GIT_MAX_BUFFER },
22
+ (err, stdout) => resolve(err ? null : stdout),
23
+ );
24
+ });
25
+ }
26
+
27
+ // Runs every (root, args) pair, at most MAX_CONCURRENT at a time. Never throws.
28
+ async function prefetchGitLogs(roots, argSets) {
29
+ const jobs = [];
30
+ for (const root of roots || []) {
31
+ for (const args of argSets) jobs.push({ root, args });
32
+ }
33
+ let next = 0;
34
+ const worker = async () => {
35
+ while (next < jobs.length) {
36
+ const { root, args } = jobs[next++];
37
+ const out = await runAsync(root, args);
38
+ if (out !== null) prefetched.set(keyOf(root, args), { out, at: Date.now() });
39
+ }
40
+ };
41
+ await Promise.all(Array.from({ length: Math.min(MAX_CONCURRENT, jobs.length) }, worker));
42
+ }
43
+
44
+ // The prefetched stdout for exactly this command, once; `undefined` on a miss.
45
+ function takePrefetchedGitLog(root, args) {
46
+ const key = keyOf(root, args);
47
+ const hit = prefetched.get(key);
48
+ if (!hit) return undefined;
49
+ prefetched.delete(key);
50
+ return Date.now() - hit.at <= TTL_MS ? hit.out : undefined;
51
+ }
52
+
53
+ module.exports = { prefetchGitLogs, takePrefetchedGitLog };
package/src/i18n.js CHANGED
@@ -3,6 +3,7 @@
3
3
  const { detectLangCode } = require('./locale');
4
4
  const { toLowerLang } = require('./lang-codes');
5
5
  const { CATEGORIES } = require('./detectors');
6
+ const { LEGAL } = require('./signup-copy');
6
7
 
7
8
  // Localization of the report (HTML + terminal) and of the CLI notices tied to the report.
8
9
 
@@ -38,7 +39,7 @@ const catalogs = {
38
39
  askFreelanceOpinion: 'Cual es tu opinion sobre el freelance?',
39
40
  freelanceOpinionLabels: {
40
41
  WAS_FREELANCE_BEFORE: 'Ya he sido freelance',
41
- OPEN_TO_FREELANCE: 'Me plantearia ser freelance',
42
+ OPEN_TO_FREELANCE: 'Me plantearía ser freelance',
42
43
  NOT_INTERESTED: 'No me interesa',
43
44
  },
44
45
  askChangeMotivators: 'Que te motiva del freelance o de Shakers?',
@@ -50,7 +51,7 @@ const catalogs = {
50
51
  COMMUNITY: 'Pertenecer a una comunidad',
51
52
  LIFESTYLE_CHANGE: 'Cambiar de estilo de vida',
52
53
  },
53
- professionalSaved: 'Situacion laboral guardada.',
54
+ professionalSaved: 'Situación laboral guardada.',
54
55
  chooseMethodHeading: '¿Cómo quieres crear tu cuenta?',
55
56
  signupTitle: 'Crea tu cuenta de Shakers',
56
57
  askName: 'Nombre:',
@@ -87,7 +88,7 @@ const catalogs = {
87
88
  availableAsk: 'Estas disponible para nuevos proyectos? (s/n):',
88
89
  monthlyHoursAsk: 'Cuantas horas al mes?',
89
90
  workModesAsk: 'En que modalidad quieres trabajar? (marca una o varias)',
90
- workModeLabels: { REMOTE: 'Remoto', HYBRID: 'Hibrido', IN_PERSON: 'Presencial' },
91
+ workModeLabels: { REMOTE: 'Remoto', HYBRID: 'Híbrido', IN_PERSON: 'Presencial' },
91
92
  workModesHint: 'Espacio para marcar/desmarcar · Enter para confirmar',
92
93
  onlyRemoteNote: 'Has marcado solo Remoto: no veras proyectos hibridos ni presenciales.',
93
94
  countryAsk: 'Pais (codigo ISO-2 de 2 letras, p.ej. ES):',
@@ -97,7 +98,7 @@ const catalogs = {
97
98
  longFullTimeAsk: 'Abierto a proyectos largos a tiempo completo? (s/n):',
98
99
  phonePrefixAsk: 'Prefijo telefonico (opcional, p.ej. +34, Enter para omitir):',
99
100
  phoneNumberAsk: 'Numero de telefono (opcional, Enter para omitir):',
100
- phoneSaved: 'Telefono guardado.',
101
+ phoneSaved: 'Teléfono guardado.',
101
102
  phoneUnavailable: 'No pudimos guardar tu telefono ahora. Puedes actualizarlo mas tarde.',
102
103
  availabilitySaved: 'Disponibilidad guardada.',
103
104
  availabilityUnavailable: 'No pudimos guardar tu disponibilidad ahora. Puedes actualizarla mas tarde.',
@@ -106,35 +107,35 @@ const catalogs = {
106
107
  languagesAsk: 'Quieres indicar los idiomas que hablas? (s/n):',
107
108
  askLanguageCode: 'Idioma:',
108
109
  languageNames: {
109
- es: 'Espanol',
110
- en: 'Ingles',
111
- fr: 'Frances',
112
- ca: 'Catalan',
113
- de: 'Aleman',
110
+ es: 'Español',
111
+ en: 'Inglés',
112
+ fr: 'Francés',
113
+ ca: 'Catalán',
114
+ de: 'Alemán',
114
115
  eu: 'Euskera',
115
116
  it: 'Italiano',
116
- pt: 'Portugues',
117
+ pt: 'Portugués',
117
118
  },
118
119
  askLanguageLevel: 'Tu nivel en ese idioma:',
119
120
  languageLevelLabels: {
120
- NATIVE: 'Nativo o bilingue',
121
- ADVANCED: 'Fluido en el trabajo y el dia a dia',
122
- INTERMEDIATE: 'Puedo trabajar en el (hablado y escrito)',
123
- INTERMEDIATE_WRITTEN: 'Puedo trabajar en el, prefiero comunicacion escrita',
121
+ NATIVE: 'Nativo o bilingüe',
122
+ ADVANCED: 'Fluido en el trabajo y el día a día',
123
+ INTERMEDIATE: 'Puedo trabajar en él (hablado y escrito)',
124
+ INTERMEDIATE_WRITTEN: 'Puedo trabajar en él, prefiero comunicación escrita',
124
125
  },
125
126
  askAnotherLanguage: 'Quieres anadir otro idioma? (s/n):',
126
127
  languagesSaved: 'Idiomas guardados.',
127
128
  languagesUnavailable: 'No pudimos guardar tus idiomas ahora. Puedes anadirlos mas tarde.',
128
129
  languagesSkipped: 'De acuerdo, puedes indicar tus idiomas mas tarde.',
129
130
  usageTitle: 'Evaluacion de tu uso de IA (opcional)',
130
- usageInfoAccessed: 'Se accede a: la estructura de tus herramientas de IA y a extractos redactados de tu actividad en este equipo. Tu codigo y tus prompts en crudo NUNCA salen de tu maquina.',
131
- usageGoalDuration: 'Objetivo: medir como trabajas con IA para tu perfil. Duracion: ~1-2 minutos.',
131
+ usageInfoAccessed: LEGAL.es.usageInfoAccessed,
132
+ usageGoalDuration: LEGAL.es.usageGoalDuration,
132
133
  usageAccept: 'Quieres evaluar tu uso de IA ahora? (s/n):',
133
134
  usageSkippedEvidence: 'Ya tienes evidencia de uso de IA en Shakers; nos saltamos este paso.',
134
135
  interviewTitle: 'Entrevista de onboarding (opcional)',
135
136
  interviewOnlyTitle: 'Entrevista de onboarding',
136
- interviewInfoAccessed: 'Se accede a: tus respuestas en esta entrevista y tu perfil importado. La conduce el servidor de Shakers.',
137
- interviewGoalDuration: 'Objetivo: conocer tu rol, tu dedicacion y como usas la IA. Duracion: ~5 minutos.',
137
+ interviewInfoAccessed: LEGAL.es.interviewInfoAccessed,
138
+ interviewGoalDuration: LEGAL.es.interviewGoalDuration,
138
139
  interviewAccept: 'Quieres hacer la entrevista ahora? (s/n):',
139
140
  interviewComplete: 'Entrevista completada. Gracias!',
140
141
  interviewAlreadyDone: 'Ya completaste la entrevista de onboarding; no se puede repetir desde la CLI. Puedes seguir ajustando tu perfil.',
@@ -702,11 +703,11 @@ const catalogs = {
702
703
  // language the Talent may not read. Vocabulario CONTEXT: Talent, Skill.
703
704
  certifyDimension: {
704
705
  help:
705
- 'certify — certifica una DIMENSIÓN de tu rol con una entrevista por LiveKit (como el onboarding).\n\n'
706
+ 'certify - certifica una DIMENSIÓN de tu rol. La entrevista se hace en la web.\n\n'
706
707
  + 'Uso:\n'
707
708
  + ' certify [--lang es|en] [--dimension <clave|slug|n>]\n\n'
708
709
  + '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',
710
+ + 'Eliges la dimensión aquí y te damos el enlace para certificarla en la web.\n',
710
711
  title: 'Certificar una dimensión',
711
712
  loginRequired: '"certify" requiere una sesión activa.\n Falta: iniciar sesión. Ejecuta primero: shakers login',
712
713
  discovering: 'Buscando las dimensiones que puedes certificar…',
@@ -722,22 +723,9 @@ const catalogs = {
722
723
  stateExpired: 'caducada',
723
724
  dimensionInvalid: 'Esa dimensión no está entre las certificables ahora mismo.',
724
725
  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.',
726
+ webHandoff: 'La entrevista de certificación se hace en la web.',
727
+ webHandoffLink: (url) => `Ábrela aquí para certificar esta dimensión:\n ${url}`,
728
+ webHandoffNoLink: 'Entra en tu perfil de Shakers en la web y ve a Certificaciones para hacerla.',
741
729
  },
742
730
  findProjects: {
743
731
  help:
@@ -1743,14 +1731,14 @@ const catalogs = {
1743
1731
  languagesUnavailable: 'We could not save your languages right now. You can add them later.',
1744
1732
  languagesSkipped: 'OK, you can add your languages later.',
1745
1733
  usageTitle: 'Evaluate your AI usage (optional)',
1746
- usageInfoAccessed: 'Accessed: the structure of your AI tools and redacted excerpts of your activity on this machine. Your raw code and prompts NEVER leave your machine.',
1747
- usageGoalDuration: 'Goal: measure how you work with AI for your profile. Duration: ~1-2 minutes.',
1734
+ usageInfoAccessed: LEGAL.en.usageInfoAccessed,
1735
+ usageGoalDuration: LEGAL.en.usageGoalDuration,
1748
1736
  usageAccept: 'Do you want to evaluate your AI usage now? (y/n):',
1749
1737
  usageSkippedEvidence: 'You already have AI-usage evidence on Shakers; skipping this step.',
1750
1738
  interviewTitle: 'Onboarding interview (optional)',
1751
1739
  interviewOnlyTitle: 'Onboarding interview',
1752
- interviewInfoAccessed: 'Accessed: your answers in this interview and your imported profile. It is conducted by the Shakers server.',
1753
- interviewGoalDuration: 'Goal: learn your role, your dedication and how you use AI. Duration: ~5 minutes.',
1740
+ interviewInfoAccessed: LEGAL.en.interviewInfoAccessed,
1741
+ interviewGoalDuration: LEGAL.en.interviewGoalDuration,
1754
1742
  interviewAccept: 'Do you want to take the interview now? (y/n):',
1755
1743
  interviewComplete: 'Interview complete. Thank you!',
1756
1744
  interviewAlreadyDone: 'You already completed the onboarding interview; it cannot be repeated from the CLI. You can keep refining your profile.',
@@ -2310,11 +2298,11 @@ const catalogs = {
2310
2298
  // English mirror of the `certify` catalog. Same content/invariants.
2311
2299
  certifyDimension: {
2312
2300
  help:
2313
- 'certify — certify a DIMENSION of your role with a LiveKit interview (like onboarding).\n\n'
2301
+ 'certify - certify a DIMENSION of your role. The interview is done on the web.\n\n'
2314
2302
  + 'Usage:\n'
2315
2303
  + ' certify [--lang es|en] [--dimension <key|slug|n>]\n\n'
2316
2304
  + 'Requires an active session (run `shakers login` first).\n'
2317
- + 'Only dimensions you can certify here and have not already certified are offered.\n',
2305
+ + 'You pick the dimension here and we hand you the link to certify it on the web.\n',
2318
2306
  title: 'Certify a dimension',
2319
2307
  loginRequired: '"certify" requires an active session.\n Missing: sign-in. Run `shakers login` first.',
2320
2308
  discovering: 'Looking up the dimensions you can certify…',
@@ -2330,22 +2318,9 @@ const catalogs = {
2330
2318
  stateExpired: 'expired',
2331
2319
  dimensionInvalid: 'That dimension is not among the certifiable ones right now.',
2332
2320
  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.',
2321
+ webHandoff: 'The certification interview is done on the web.',
2322
+ webHandoffLink: (url) => `Open it here to certify this dimension:\n ${url}`,
2323
+ webHandoffNoLink: 'Open your Shakers profile on the web and go to Certifications to take it.',
2349
2324
  },
2350
2325
  findProjects: {
2351
2326
  help: