shakers-cli-beta 0.9.0 → 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.
package/README.md CHANGED
@@ -380,15 +380,39 @@ Desktop) can drive the same features as tools. It exposes, among others:
380
380
 
381
381
  - **AI usage**: `ai_usage`, `ai_usage_result`.
382
382
  - **Auth**: `login`, `logout`, `social_signin_start`, `social_signin_poll`.
383
- - **Register / onboarding**: `register`, `register_status`,
384
- `onboarding_interview_start`, `onboarding_interview_turn`,
385
- `onboarding_interview_complete`, `repeat_onboarding_interview`
383
+ - **Sign-up** (from the talent's own AI, ticket 39540; copy from UX review
384
+ 39574): every talent-facing text is fixed, comes from the tools in the
385
+ talent's language (es/en/it/pt, fixed by their first message) and the AI shows
386
+ it word for word. `signup_start` (welcome and permission to look for the CV),
387
+ `signup_email` (the email they read most), `signup_draft` (the profile draft
388
+ and the data notice, confirmed before the account exists),
389
+ `signup_create_account` (starts the hub import of LinkedIn + CV + what the AI
390
+ knows and opens the local password/Google window, which claims that profile
391
+ with a code kept only in memory; if the email already has an account, the
392
+ window asks the talent to sign in instead), `signup_status`,
393
+ `update_existing_profile` (shows what would change in an existing profile and
394
+ re-imports filling only empty fields), `save_profile_details`,
395
+ `import_profile`, `open_web` (opens the web signed in through a one-time
396
+ link), `open_linkedin_profile` (only when the talent does not know where to
397
+ find their LinkedIn URL), plus `suggest_register_context` and `read_cv`.
398
+ `ai_usage` is the step after the account exists, right after its two
399
+ disclaimers.
400
+ - **Onboarding interview**: `onboarding_interview_start`,
401
+ `onboarding_interview_turn`, `onboarding_interview_complete` (if LiveKit
402
+ cannot load, the talent is sent to the web), `repeat_onboarding_interview`
386
403
  (OVERWRITE-repeat — the agent must warn the talent it is irreversible before
387
- calling), `onboarding_finish`.
404
+ calling), `onboarding_finish` (only after a repeat; the sign-up never calls it).
388
405
  - **Roles**: `list_available_roles`, `add_role`, `list_my_roles`, `set_main_role`
389
406
  — `set_main_role` is the onboarding **final step** (after
390
407
  `onboarding_interview_complete`, or if the interview was skipped, offer
391
408
  `list_my_roles` and set the picked one).
409
+ - **Closed questions** of the sign-up (AI-usage consent, which email, interview
410
+ here or later, how to answer it, main role) are answered by picking an option.
411
+ When the client declares the MCP `elicitation` capability (spec 2025-06-18),
412
+ the tool asks in the client's own dialog (`elicitation/create`) and gets the
413
+ pick directly; otherwise it returns `question` and `options` and the AI offers
414
+ them as buttons or a numbered list. The legal texts are always shown in the
415
+ chat before any consent dialog.
392
416
  - **Profile / skills / agents**: `list_addable`, `add_skill`, `add_agent`,
393
417
  `add_project`, `get_profile`, `get_rate`, `get_languages`, `list_socials`,
394
418
  `list_experiences`, `list_portfolios`, `get_availability`.
@@ -398,7 +422,8 @@ Desktop) can drive the same features as tools. It exposes, among others:
398
422
  - **Alma**: `ask`.
399
423
 
400
424
  MCP tool names are **stable** (renaming would break existing client configs); the
401
- role tools above were added, not renamed.
425
+ role tools above were added, not renamed. `register`, `register_preview` and
426
+ `register_status` were removed with the sign-up redesign, not renamed.
402
427
 
403
428
  ## Try it
404
429
 
package/bin/ai-usage.js CHANGED
@@ -24,29 +24,14 @@ const {
24
24
  renderAndFinalize,
25
25
  } = require('../src/usage-run-steps');
26
26
 
27
- // `run(argv, { ask })`: shared by the dispatcher and register flow; an injected
28
- // `ask` is caller-owned. `bypassThrottle` (options, not argv) stays register-only.
29
- async function run(argv = process.argv.slice(2), { ask: injectedAsk = null, bypassThrottle = false, inRegistration = false } = {}) {
30
- const opts = parseArgs(argv);
31
-
32
- // Resolve language first so even --help is localized.
33
- const lang = opts.lang || detectReportLang();
34
- const catalog = getCatalog(lang);
35
-
36
- if (opts.help) {
37
- process.stdout.write(catalog.cli.help);
38
- return;
39
- }
40
-
41
- // One-shot commands (consent/endpoint config): act immediately, do NOT scan.
42
- if (await handleOneShotCommand({ opts, catalog, injectedAsk })) return;
43
-
27
+ // Steps 1-6 of a run: scan, scope, aggregate, classify; nothing is rendered or sent.
28
+ async function computeUsage({ opts, catalog, lang, injectedAsk = null, inRegistration = false }) {
44
29
  // 1. Local scan + classify (always computed and shown, ADR-011/013).
45
30
  const { report, root, rootDir } = performScan({ opts, catalog });
46
31
 
47
32
  // 2. Identity gate + local agent-usage signal. Roadmap only for anonymous runs
48
33
  // (gate.showRoadmap = !loggedIn) AND never during register/onboarding.
49
- const { gate: identityGate } = prepareIdentityAndUsage({ report, catalog });
34
+ const { gate: identityGate } = prepareIdentityAndUsage({ report, catalog, quiet: opts.json === true });
50
35
  const gate = (inRegistration && identityGate.showRoadmap)
51
36
  ? { ...identityGate, showRoadmap: false }
52
37
  : identityGate;
@@ -73,12 +58,40 @@ async function run(argv = process.argv.slice(2), { ask: injectedAsk = null, bypa
73
58
  await runModelCalls({
74
59
  report, maturity, aiConsentGranted, opts, gate, hasAgents, agentRoots, rootDir, lang, catalog,
75
60
  });
61
+ return { report, maturity, root, gate };
62
+ }
63
+
64
+ // The MCP's scan: same steps, returned instead of printed, since stdout is its JSON-RPC channel.
65
+ async function scanUsage(argv) {
66
+ const opts = parseArgs(argv);
67
+ const lang = opts.lang || detectReportLang();
68
+ const { report, maturity } = await computeUsage({ opts, catalog: getCatalog(lang), lang });
69
+ return { report, maturity };
70
+ }
71
+
72
+ // Shared by the dispatcher and register flow: an injected `ask` is caller-owned, `bypassThrottle` is register-only.
73
+ async function run(argv = process.argv.slice(2), { ask: injectedAsk = null, bypassThrottle = false, inRegistration = false } = {}) {
74
+ const opts = parseArgs(argv);
75
+
76
+ // Resolve language first so even --help is localized.
77
+ const lang = opts.lang || detectReportLang();
78
+ const catalog = getCatalog(lang);
79
+
80
+ if (opts.help) {
81
+ process.stdout.write(catalog.cli.help);
82
+ return;
83
+ }
84
+
85
+ // One-shot commands (consent/endpoint config): act immediately, do NOT scan.
86
+ if (await handleOneShotCommand({ opts, catalog, injectedAsk })) return;
87
+
88
+ const { report, maturity, root, gate } = await computeUsage({ opts, catalog, lang, injectedAsk, inRegistration });
76
89
 
77
90
  // 7. Render (or emit --json), persist, optional --build-next-level, auto-share.
78
91
  await renderAndFinalize({ report, maturity, lang, opts, root, gate, catalog, bypassThrottle });
79
92
  }
80
93
 
81
- module.exports = { run, resolveRepoScope, machineWideToplevels, promptScope };
94
+ module.exports = { run, scanUsage, resolveRepoScope, machineWideToplevels, promptScope };
82
95
 
83
96
  // Auto-run only when executed directly, not when require()'d by the dispatcher.
84
97
  if (require.main === module) {
package/bin/mcp.js CHANGED
@@ -23,9 +23,11 @@ const { makeOnboardingTools } = require('../src/mcp-onboarding-tools');
23
23
  const { makeRoleTools } = require('../src/mcp-role-tools');
24
24
  const { makeDeviceLoginTools } = require('../src/mcp-device-login-tools');
25
25
  const { makeRegisterTools } = require('../src/mcp-register-tools');
26
+ const { makeSignupTools } = require('../src/mcp-signup-tools');
26
27
  const { detectFlowLang } = require('../src/i18n');
27
28
  const { makeReportTools } = require('../src/mcp-report-tools');
28
29
  const { makePrompts } = require('../src/mcp-prompts');
30
+ const { buildServerInstructions } = require('../src/mcp-instructions');
29
31
  const { ensureFreshSession } = require('../src/session-refresh');
30
32
 
31
33
  let VERSION = '';
@@ -43,7 +45,10 @@ const HELP = `\n shakers mcp — local MCP server (stdio)\n\n`
43
45
  + ` MCP client, not run by hand.\n`
44
46
  + ` Tools: ai_usage, ai_usage_result, login, logout, social_signin_start, social_signin_poll,\n`
45
47
  + ` list_addable, add_skill, add_agent,\n`
46
- + ` add_project, suggest_register_context, read_cv, register_preview, register, register_status, onboarding_interview_start,\n`
48
+ + ` add_project, signup_start, signup_email, signup_draft, signup_create_account,\n`
49
+ + ` signup_status, update_existing_profile, import_profile, save_profile_details, open_web,\n`
50
+ + ` open_linkedin_profile,\n`
51
+ + ` suggest_register_context, read_cv, onboarding_interview_start,\n`
47
52
  + ` onboarding_interview_turn, onboarding_interview_complete,\n`
48
53
  + ` repeat_onboarding_interview, onboarding_finish,\n`
49
54
  + ` list_available_roles, add_role, list_my_roles, set_main_role,\n`
@@ -54,21 +59,6 @@ const HELP = `\n shakers mcp — local MCP server (stdio)\n\n`
54
59
  + ` list_certifications, get_profile, get_rate, get_languages,\n`
55
60
  + ` list_socials, list_experiences, list_portfolios, ask.\n\n`;
56
61
 
57
- // The register and onboarding flows span several tools. Clients that load tool
58
- // schemas on demand only see the tool the model picked first, so the flow lives
59
- // here, where the client keeps it in context.
60
- const SERVER_INSTRUCTIONS = [
61
- 'Shakers talent tools. When the talent asks to register or sign up on Shakers, go straight to a filled draft: you fill the registration from what you know and from their CV, and they only confirm. Never open with a questionnaire or a list of what you do not know.',
62
- 'Register flow — load and use these tools in this order:',
63
- '1. First reply: ONE short question — may you search for and read their CV on this machine to fill everything in?',
64
- '2. With their yes: suggest_register_context (with talentName), then read_cv on the candidates (talentConsent:true, talentName) until one has mentionsTalent:true. Drop any CV with mentionsTalent:false (someone else\'s) without describing it. Take LinkedIn, GitHub, experience, languages and location from their CV.',
65
- 'No CV, no permission, or none of the CVs is theirs: do NOT fall back to questions. Build the same draft from everything else you know about them: your memory of past conversations, a search of those conversations if you have that tool, this conversation and any context or memory files you can see. The CV adds facts; it is never a precondition.',
66
- '3. Infer EVERY field: employment from what you know (a company email or employer means employed there), pricing as a concrete estimate from role, seniority and location, motivation as your best match. Call register_preview.',
67
- '4. Show ONE summary saying where each value comes from; only truly unknown required fields as a short question at the end, optional unknowns in one line. The sign-up goes to suggest_register_context\'s `environment`: never call a test environment production.',
68
- '5. After their confirmation, call register.',
69
- 'Onboarding interview (onboarding_interview_start / onboarding_interview_turn): follow the ANSWER MODE described in onboarding_interview_start.',
70
- ].join('\n');
71
-
72
62
  // The LOCAL (filesystem/machine) tools — the only ones the .mcpb bundle exposes; the
73
63
  // server-backed tools belong to the remote connector.
74
64
  const LOCAL_ONLY_TOOL_NAMES = new Set(['ai_usage', 'ai_usage_result', 'read_cv', 'suggest_register_context']);
@@ -76,15 +66,16 @@ const LOCAL_ONLY_TOOL_NAMES = new Set(['ai_usage', 'ai_usage_result', 'read_cv',
76
66
  // `localOnly` builds the scan-only server for the Claude .mcpb bundle, reusing the SAME
77
67
  // tool registry (just filtered) — no duplicate registration.
78
68
  function buildServer({ localOnly = false } = {}) {
79
- const allTools = [makeAiUsageTool(), makeAiUsageResultTool(), ...makeOnboardingTools(), ...makeRoleTools(), ...makeDeviceLoginTools(), ...makeRegisterTools({ lang: detectFlowLang() }), ...makeReportTools(), ...makeCertifyDimensionTools(), ...makeFindProjectsTools(), ...makeShowProjectTools(), ...makeSavedPositionsTools(), ...makeInvitationsTools(), ...makeApplicationsTools(), ...makeAvailabilityTools(), ...makeCertificationsTools(), ...makeProfileTools(), ...makeRateTools(), ...makeLangTools(), ...makeSocialsTools(), ...makePortfolioTools(), ...makeAskTools(), ...makeAlmaTools()];
69
+ const lang = detectFlowLang();
70
+ const allTools = [makeAiUsageTool(), makeAiUsageResultTool(), ...makeOnboardingTools(), ...makeRoleTools({ lang }), ...makeDeviceLoginTools(), ...makeSignupTools({ lang }), ...makeRegisterTools({ lang }), ...makeReportTools(), ...makeCertifyDimensionTools(), ...makeFindProjectsTools(), ...makeShowProjectTools(), ...makeSavedPositionsTools(), ...makeInvitationsTools(), ...makeApplicationsTools(), ...makeAvailabilityTools(), ...makeCertificationsTools(), ...makeProfileTools(), ...makeRateTools(), ...makeLangTools(), ...makeSocialsTools(), ...makePortfolioTools(), ...makeAskTools(), ...makeAlmaTools()];
80
71
  const tools = localOnly ? allTools.filter((t) => LOCAL_ONLY_TOOL_NAMES.has(t.name)) : allTools;
81
72
  // Slash-command prompts, kept to the tools actually registered in this mode.
82
- const prompts = makePrompts(new Set(tools.map((t) => t.name)));
73
+ const prompts = makePrompts(new Set(tools.map((t) => t.name)), lang);
83
74
  return createMcpServer({
84
75
  tools,
85
76
  prompts,
86
77
  serverInfo: { name: localOnly ? 'Shakers (local scan)' : 'Shakers', version: VERSION || '0.0.0' },
87
- instructions: localOnly ? null : SERVER_INSTRUCTIONS,
78
+ instructions: localOnly ? null : (client) => buildServerInstructions(client),
88
79
  // Long-lived server: refresh the email session's hub JWT from the stored
89
80
  // cookie before each tool call so a stale (~15-min) JWT never reaches a tool.
90
81
  beforeCall: () => ensureFreshSession(process.env),
@@ -120,7 +111,7 @@ async function run(argv = process.argv.slice(2)) {
120
111
  });
121
112
  }
122
113
 
123
- module.exports = { run, buildServer };
114
+ module.exports = { run, buildServer, buildServerInstructions };
124
115
 
125
116
  if (require.main === module) {
126
117
  run();
package/bin/mcpb.js ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // Claude Desktop's built-in Node imports this entry from its own host, so it starts without a require.main check.
5
+ require('./mcp').run();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shakers-cli-beta",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Shakers AI usage & skills evaluator: a local-first CLI (`shakers` binary) plus a local MCP server. Commands: ai-usage (scan + classify your AI-tool setup, T0-T7), certify (certify Skills from your code), share (branded LinkedIn card).",
5
5
  "type": "commonjs",
6
6
  "bin": {
@@ -23,7 +23,7 @@
23
23
  "node": ">=18"
24
24
  },
25
25
  "dependencies": {
26
- "@livekit/rtc-node": "^0.13.0"
26
+ "@livekit/rtc-node": "0.13.35"
27
27
  },
28
28
  "keywords": [
29
29
  "shakers",
@@ -210,5 +210,6 @@ module.exports = {
210
210
  isPlatformTalent,
211
211
  PLATFORM_TALENT_USER_TYPES,
212
212
  decodeJwtPayload,
213
+ emailFromHubToken,
213
214
  firstNameFromSession,
214
215
  };
@@ -113,8 +113,8 @@ function postMultipartWithTimeout(url, fields, timeoutMs, method = 'POST', extra
113
113
  chunks.push(value.data, Buffer.from('\r\n'));
114
114
  continue;
115
115
  }
116
- const clean = String(value).replace(UNSAFE_HEADER_VALUE_RE, '');
117
- chunks.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${clean}\r\n`));
116
+ // The boundary delimits a part, so a value keeps its line breaks (a multi-line userQuery).
117
+ chunks.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${String(value)}\r\n`));
118
118
  }
119
119
  chunks.push(Buffer.from(`--${boundary}--\r\n`));
120
120
  const data = Buffer.concat(chunks);
package/src/config.js CHANGED
@@ -528,6 +528,31 @@ function getImportProfileEndpoint(env = process.env) {
528
528
  return deriveFromHub(env, 'works/me/import-profile');
529
529
  }
530
530
 
531
+ // MCP sign-up (ticket 39540): public UNREGISTERED + claim code, its import status, and the one-time web login.
532
+ function getMcpSignupEndpoint(env = process.env) {
533
+ return deriveFromHub(env, 'works-ai/talents/mcp-signup');
534
+ }
535
+
536
+ function getMyImportStatusEndpoint(env = process.env) {
537
+ return deriveFromHub(env, 'works/me/import-profile/status');
538
+ }
539
+
540
+ function getOneTimeTokenEndpoint(env = process.env) {
541
+ return deriveFromHub(env, 'auth/one-time-token/generate');
542
+ }
543
+
544
+ // The ONE place the web route that redeems a one-time token is spelled (the route itself is a later works-frontend ticket).
545
+ function getOneTimeLoginUrl(token, env = process.env) {
546
+ if (typeof token !== 'string' || !token) return null;
547
+ const profile = getTalentProfileUrl(env);
548
+ if (!profile) return null;
549
+ try {
550
+ return `${new URL(profile).origin}/auth/one-time?token=${encodeURIComponent(token)}`;
551
+ } catch {
552
+ return null;
553
+ }
554
+ }
555
+
531
556
  function getProfessionalDetailsEndpoint(env = process.env) {
532
557
  const explicit = readEnv(env, 'PROFESSIONAL_DETAILS_ENDPOINT');
533
558
  if (explicit && explicit.trim()) return explicit.trim();
@@ -763,6 +788,10 @@ module.exports = {
763
788
  sanitizeStaleBakedEndpoints,
764
789
  getSignUpEndpoint,
765
790
  getImportProfileEndpoint,
791
+ getMcpSignupEndpoint,
792
+ getMyImportStatusEndpoint,
793
+ getOneTimeTokenEndpoint,
794
+ getOneTimeLoginUrl,
766
795
  getProfessionalDetailsEndpoint,
767
796
  getPricingRateEndpoint,
768
797
  getSetAvailabilityEndpoint,
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,
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.',
@@ -1730,14 +1731,14 @@ const catalogs = {
1730
1731
  languagesUnavailable: 'We could not save your languages right now. You can add them later.',
1731
1732
  languagesSkipped: 'OK, you can add your languages later.',
1732
1733
  usageTitle: 'Evaluate your AI usage (optional)',
1733
- 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.',
1734
- 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,
1735
1736
  usageAccept: 'Do you want to evaluate your AI usage now? (y/n):',
1736
1737
  usageSkippedEvidence: 'You already have AI-usage evidence on Shakers; skipping this step.',
1737
1738
  interviewTitle: 'Onboarding interview (optional)',
1738
1739
  interviewOnlyTitle: 'Onboarding interview',
1739
- interviewInfoAccessed: 'Accessed: your answers in this interview and your imported profile. It is conducted by the Shakers server.',
1740
- 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,
1741
1742
  interviewAccept: 'Do you want to take the interview now? (y/n):',
1742
1743
  interviewComplete: 'Interview complete. Thank you!',
1743
1744
  interviewAlreadyDone: 'You already completed the onboarding interview; it cannot be repeated from the CLI. You can keep refining your profile.',