shakers-cli-beta 0.9.0 → 0.10.1
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 +30 -5
- package/bin/ai-usage.js +32 -19
- package/bin/mcp.js +11 -20
- package/bin/mcpb.js +5 -0
- package/package.json +2 -2
- package/src/auth-session-store.js +1 -0
- package/src/backend-request.js +2 -2
- package/src/config.js +29 -0
- package/src/cv-file.js +62 -0
- package/src/device-login.js +5 -3
- package/src/find-projects-client.js +16 -6
- package/src/i18n.js +23 -22
- package/src/mcp-ai-usage-tool.js +150 -65
- package/src/mcp-auth-form.js +186 -31
- package/src/mcp-choice.js +47 -0
- package/src/mcp-install.js +32 -3
- package/src/mcp-instructions.js +27 -0
- package/src/mcp-onboarding-tools.js +2 -2
- package/src/mcp-prompts.js +8 -6
- package/src/mcp-rate-tools.js +2 -2
- package/src/mcp-register-tools.js +89 -315
- package/src/mcp-role-tools.js +177 -16
- package/src/mcp-server.js +71 -3
- package/src/mcp-signup-client.js +118 -0
- package/src/mcp-signup-tools.js +818 -0
- package/src/onboarding-client.js +28 -52
- package/src/onboarding-flow.js +3 -147
- package/src/onboarding-status-client.js +16 -1
- package/src/register-context.js +1 -17
- package/src/roles-client.js +3 -1
- package/src/signup-client.js +33 -21
- package/src/signup-copy.js +338 -0
- package/src/signup-language.js +21 -0
- package/src/usage-run-steps.js +3 -2
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
|
-
- **
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
//
|
|
28
|
-
|
|
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,
|
|
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
|
|
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 :
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shakers-cli-beta",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.1",
|
|
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": "
|
|
26
|
+
"@livekit/rtc-node": "0.13.35"
|
|
27
27
|
},
|
|
28
28
|
"keywords": [
|
|
29
29
|
"shakers",
|
package/src/backend-request.js
CHANGED
|
@@ -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
|
-
|
|
117
|
-
chunks.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\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 };
|
package/src/device-login.js
CHANGED
|
@@ -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
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
110
|
-
en: '
|
|
111
|
-
fr: '
|
|
112
|
-
ca: '
|
|
113
|
-
de: '
|
|
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: '
|
|
117
|
+
pt: 'Portugués',
|
|
117
118
|
},
|
|
118
119
|
askLanguageLevel: 'Tu nivel en ese idioma:',
|
|
119
120
|
languageLevelLabels: {
|
|
120
|
-
NATIVE: 'Nativo o
|
|
121
|
-
ADVANCED: 'Fluido en el trabajo y el
|
|
122
|
-
INTERMEDIATE: 'Puedo trabajar en
|
|
123
|
-
INTERMEDIATE_WRITTEN: 'Puedo trabajar en
|
|
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:
|
|
131
|
-
usageGoalDuration:
|
|
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:
|
|
137
|
-
interviewGoalDuration:
|
|
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:
|
|
1734
|
-
usageGoalDuration:
|
|
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:
|
|
1740
|
-
interviewGoalDuration:
|
|
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.',
|