shakers-cli-beta 0.8.0-beta.3 → 0.8.0-beta.5
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 +11 -7
- package/bin/mcp.js +16 -0
- package/package.json +3 -2
- package/src/cv-text.js +48 -13
- package/src/mcp-register-tools.js +12 -9
- package/src/mcp-server.js +4 -1
- package/src/register-context.js +44 -13
package/README.md
CHANGED
|
@@ -135,17 +135,21 @@ env vars and `config.json` always win over the baked default.
|
|
|
135
135
|
| Flavor | Package name | Registry | Baked target | Publish |
|
|
136
136
|
|---|---|---|---|---|
|
|
137
137
|
| Private (current) | `@shakers/shakers-cli` | GitLab proj 201 (`publishConfig`) | dev (new-works) | `export SHAKERS_GITLAB_TOKEN=<PAT, scope write_registry on proj 201>` then `npm publish` |
|
|
138
|
-
| Public beta | `shakers-cli-beta` | npmjs (public) | staging | `
|
|
138
|
+
| Public beta | `shakers-cli-beta` | npmjs (public) | staging | bump `BETA.version` in `scripts/prepare-beta.js`, then `npm run publish:beta` |
|
|
139
139
|
| Local | (checkout, `@shakers/shakers-cli`) | — | dev, or `SHAKERS_CLI_ENV=local` for localhost | run from the clone; no publish |
|
|
140
140
|
|
|
141
141
|
Both publishes bump `version` first (the registry rejects re-publishing an
|
|
142
142
|
existing version). The private one publishes from a checked-out `main` with the
|
|
143
|
-
committed `@shakers/shakers-cli` manifest, unchanged.
|
|
144
|
-
`scripts/prepare-beta.js`
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
143
|
+
committed `@shakers/shakers-cli` manifest, unchanged. For the beta, bump
|
|
144
|
+
`BETA.version` in `scripts/prepare-beta.js` (manual, one line), then run the
|
|
145
|
+
single command `npm run publish:beta`: it stamps `name`/`version`/`publishConfig`,
|
|
146
|
+
runs `npm publish --tag latest`, and restores the private manifest with git. The
|
|
147
|
+
tag is explicit because npm refuses to publish a prerelease (`0.8.0-beta.N`) to
|
|
148
|
+
`latest` without one; `--tag latest` still moves `latest` to the new version, so
|
|
149
|
+
testers install `shakers-cli-beta` bare (which resolves `latest`) with no manual
|
|
150
|
+
`npm dist-tag add` step. The npmjs account for `shakers-cli-beta` must
|
|
151
|
+
exist and you must be `npm login`ed (or set `NODE_AUTH_TOKEN`) first — nothing
|
|
152
|
+
here publishes automatically.
|
|
149
153
|
|
|
150
154
|
`@livekit/rtc-node` is a real `dependencies` entry, so `npm i` / `npm i -g` of
|
|
151
155
|
either package pulls it automatically (GitLab's packument keeps `dependencies`
|
package/bin/mcp.js
CHANGED
|
@@ -53,10 +53,26 @@ const HELP = `\n shakers mcp — local MCP server (stdio)\n\n`
|
|
|
53
53
|
+ ` list_certifications, get_profile, get_rate, get_languages,\n`
|
|
54
54
|
+ ` list_socials, list_experiences, list_portfolios, ask.\n\n`;
|
|
55
55
|
|
|
56
|
+
// The register and onboarding flows span several tools. Clients that load tool
|
|
57
|
+
// schemas on demand only see the tool the model picked first, so the flow lives
|
|
58
|
+
// here, where the client keeps it in context.
|
|
59
|
+
const SERVER_INSTRUCTIONS = [
|
|
60
|
+
'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.',
|
|
61
|
+
'Register flow — load and use these tools in this order:',
|
|
62
|
+
'1. First reply: ONE short question — may you search for and read their CV on this machine to fill everything in?',
|
|
63
|
+
'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.',
|
|
64
|
+
'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.',
|
|
65
|
+
'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.',
|
|
66
|
+
'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.',
|
|
67
|
+
'5. After their confirmation, call register.',
|
|
68
|
+
'Onboarding interview (onboarding_interview_start / onboarding_interview_turn): follow the ANSWER MODE described in onboarding_interview_start.',
|
|
69
|
+
].join('\n');
|
|
70
|
+
|
|
56
71
|
function buildServer() {
|
|
57
72
|
return createMcpServer({
|
|
58
73
|
tools: [makeAiUsageTool(), makeAiUsageResultTool(), ...makeOnboardingTools(), ...makeRoleTools(), ...makeDeviceLoginTools(), ...makeRegisterTools({ lang: detectFlowLang() }), ...makeReportTools(), ...makeCertifyDimensionTools(), ...makeFindProjectsTools(), ...makeShowProjectTools(), ...makeSavedPositionsTools(), ...makeInvitationsTools(), ...makeApplicationsTools(), ...makeAvailabilityTools(), ...makeCertificationsTools(), ...makeProfileTools(), ...makeRateTools(), ...makeLangTools(), ...makeSocialsTools(), ...makePortfolioTools(), ...makeAskTools(), ...makeAlmaTools()],
|
|
59
74
|
serverInfo: { name: 'Shakers', version: VERSION || '0.0.0' },
|
|
75
|
+
instructions: SERVER_INSTRUCTIONS,
|
|
60
76
|
// Long-lived server: refresh the email session's hub JWT from the stored
|
|
61
77
|
// cookie before each tool call so a stale (~15-min) JWT never reaches a tool.
|
|
62
78
|
beforeCall: () => ensureFreshSession(process.env),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shakers-cli-beta",
|
|
3
|
-
"version": "0.8.0-beta.
|
|
3
|
+
"version": "0.8.0-beta.5",
|
|
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": {
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
"scripts": {
|
|
16
16
|
"start": "node bin/shakers.js",
|
|
17
17
|
"shakers": "node bin/shakers.js",
|
|
18
|
-
"test": "node --test test/*.test.js"
|
|
18
|
+
"test": "node --test test/*.test.js",
|
|
19
|
+
"publish:beta": "node scripts/prepare-beta.js && npm publish --tag latest && git checkout -- package.json"
|
|
19
20
|
},
|
|
20
21
|
"engines": {
|
|
21
22
|
"node": ">=18"
|
package/src/cv-text.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// Plain text out of the talent's CV PDF, so the AI can pre-fill from
|
|
4
|
-
// says. Zero-dependency on purpose (install.sh copies the CLI without
|
|
5
|
-
// install): macOS
|
|
6
|
-
// poppler's pdftotext when
|
|
7
|
-
// talent to attach the CV to the chat instead.
|
|
3
|
+
// Plain text out of the talent's CV (PDF, DOC, DOCX), so the AI can pre-fill from
|
|
4
|
+
// what the CV says. Zero-dependency on purpose (install.sh copies the CLI without
|
|
5
|
+
// npm install): on macOS PDFs go through the system PDFKit (JXA) and Word files
|
|
6
|
+
// through textutil; elsewhere PDFs through poppler's pdftotext when installed.
|
|
7
|
+
// With none, the caller asks the talent to attach the CV to the chat instead.
|
|
8
8
|
|
|
9
9
|
const fs = require('fs');
|
|
10
10
|
const path = require('path');
|
|
@@ -22,16 +22,44 @@ function run(cmd, args, exec) {
|
|
|
22
22
|
return String(exec(cmd, args, { encoding: 'utf8', timeout: 20000, maxBuffer: 32 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] }) || '');
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
function extractors(platform) {
|
|
25
|
+
function extractors(platform, file) {
|
|
26
26
|
const list = [];
|
|
27
|
-
if (
|
|
28
|
-
|
|
27
|
+
if (/\.docx?$/i.test(file)) {
|
|
28
|
+
if (platform === 'darwin') list.push({ name: 'textutil', cmd: 'textutil', args: (f) => ['-convert', 'txt', '-stdout', f] });
|
|
29
|
+
return list;
|
|
30
|
+
}
|
|
31
|
+
if (platform === 'darwin') list.push({ name: 'pdfkit', cmd: 'osascript', args: (f) => ['-l', 'JavaScript', '-e', JXA_PDF_TEXT, f] });
|
|
32
|
+
list.push({ name: 'pdftotext', cmd: 'pdftotext', args: (f) => ['-layout', '-q', f, '-'] });
|
|
29
33
|
return list;
|
|
30
34
|
}
|
|
31
35
|
|
|
32
|
-
|
|
36
|
+
const fold = (text) => String(text || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
|
|
37
|
+
|
|
38
|
+
// Whether the CV text names the talent: every name token of 3+ letters present.
|
|
39
|
+
// Lets the AI drop a stranger's CV without showing its contents.
|
|
40
|
+
function mentionsName(text, talentName) {
|
|
41
|
+
const tokens = fold(talentName).split(/[^a-z0-9]+/).filter((t) => t.length >= 3);
|
|
42
|
+
if (tokens.length === 0) return null;
|
|
43
|
+
const body = fold(text);
|
|
44
|
+
return tokens.every((t) => body.includes(t));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const uniq = (list) => [...new Set(list)];
|
|
48
|
+
|
|
49
|
+
// Contact links a CV header usually carries, so the AI can fill them directly.
|
|
50
|
+
function extractLinks(text) {
|
|
51
|
+
const t = String(text || '');
|
|
52
|
+
return {
|
|
53
|
+
linkedin: uniq((t.match(/(?:https?:\/\/)?(?:[a-z]{2,3}\.)?linkedin\.com\/in\/[A-Za-z0-9_%-]+/gi) || []).map((u) => `https://www.${u.replace(/^https?:\/\//i, '').replace(/^(?:[a-z]{2,3}\.)?linkedin/i, 'linkedin')}`)),
|
|
54
|
+
github: uniq((t.match(/(?:https?:\/\/)?github\.com\/[A-Za-z0-9-]+/gi) || []).map((u) => `https://${u.replace(/^https?:\/\//i, '')}`)),
|
|
55
|
+
emails: uniq(t.match(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g) || []),
|
|
56
|
+
phones: uniq((t.match(/\+\d{1,3}[\s.-]?\d[\d\s.-]{6,14}\d/g) || []).map((p) => p.replace(/[\s.-]+/g, ' ').trim())),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readCvText(input, { platform = process.platform, exec = execFileSync, env = process.env, talentName = null } = {}) {
|
|
33
61
|
const file = expandHome(input, env);
|
|
34
|
-
if (typeof file !== 'string' || !path.isAbsolute(file) || !/\.pdf$/i.test(file)) return { ok: false, reason: 'not-a-
|
|
62
|
+
if (typeof file !== 'string' || !path.isAbsolute(file) || !/\.(pdf|docx?)$/i.test(file)) return { ok: false, reason: 'not-a-cv-path' };
|
|
35
63
|
let stat;
|
|
36
64
|
try {
|
|
37
65
|
stat = fs.statSync(file);
|
|
@@ -41,7 +69,7 @@ function readCvText(input, { platform = process.platform, exec = execFileSync, e
|
|
|
41
69
|
if (!stat.isFile()) return { ok: false, reason: 'not-found' };
|
|
42
70
|
if (stat.size > MAX_BYTES) return { ok: false, reason: 'too-large' };
|
|
43
71
|
|
|
44
|
-
for (const extractor of extractors(platform)) {
|
|
72
|
+
for (const extractor of extractors(platform, file)) {
|
|
45
73
|
let text;
|
|
46
74
|
try {
|
|
47
75
|
text = run(extractor.cmd, extractor.args(file), exec).trim();
|
|
@@ -49,9 +77,16 @@ function readCvText(input, { platform = process.platform, exec = execFileSync, e
|
|
|
49
77
|
continue;
|
|
50
78
|
}
|
|
51
79
|
if (!text) continue;
|
|
52
|
-
return {
|
|
80
|
+
return {
|
|
81
|
+
ok: true,
|
|
82
|
+
extractor: extractor.name,
|
|
83
|
+
mentionsTalent: mentionsName(text, talentName),
|
|
84
|
+
links: extractLinks(text),
|
|
85
|
+
truncated: text.length > MAX_CHARS,
|
|
86
|
+
text: text.slice(0, MAX_CHARS),
|
|
87
|
+
};
|
|
53
88
|
}
|
|
54
89
|
return { ok: false, reason: 'no-text-extracted' };
|
|
55
90
|
}
|
|
56
91
|
|
|
57
|
-
module.exports = { readCvText, MAX_CHARS };
|
|
92
|
+
module.exports = { readCvText, extractLinks, mentionsName, MAX_CHARS };
|
|
@@ -96,7 +96,7 @@ function buildSchemas(lang) {
|
|
|
96
96
|
// collection guide with friendly labels inline for every choice field.
|
|
97
97
|
function buildRegisterDescription(lang) {
|
|
98
98
|
const L = getCatalog(lang).onboarding;
|
|
99
|
-
return "The talent registers THEMSELVES on Shakers (self-service; no prior session needed). Your job is to relay the non-secret profile details the talent gives you in the chat as arguments. Credentials never touch the chat or any tool argument: the tool opens a sign-up window on the talent's own machine (127.0.0.1) where they either Continue with Google (no password at all) or type an email and password directly into that local window.\n\
|
|
99
|
+
return "The talent registers THEMSELVES on Shakers (self-service; no prior session needed). Your job is to relay the non-secret profile details the talent gives you in the chat as arguments. Credentials never touch the chat or any tool argument: the tool opens a sign-up window on the talent's own machine (127.0.0.1) where they either Continue with Google (no password at all) or type an email and password directly into that local window.\n\nGO STRAIGHT TO A FILLED DRAFT — never open with a questionnaire or a list of what you do not know. (1) Your FIRST reply is one short question: may you search for and read their CV on this machine to fill everything in? (skip it if they already said yes). (2) With their yes: call suggest_register_context with talentName = the name you know, then read_cv the candidates (nameMatch first, then generic ones like cv.pdf) with talentConsent:true and talentName, until one returns mentionsTalent:true; a CV with mentionsTalent:false belongs to someone else: drop it and never show or use anything from it. From their CV take LinkedIn/GitHub (links), experience, education, languages and location; pass paths exactly as given (the short ~/ form is fine). If they say no, there is no CV, 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. (3) Pre-fill EVERY field: identity from git/your memory/the CV; workSituation from what you know (a company email or employer in memory or CV means employed there — propose it); `pricing` ALWAYS as a concrete per-project estimate from their role, seniority, stack and location, labelled as your estimate (ask only if you know nothing about their work); `workSituation.changeMotivators` as your best match; availability, languages and country from context. What you know about the person outranks git: if the git identity is someone else, ignore it. (4) Call register_preview and show ONE summary that says where each value comes from, for the talent to confirm or correct in one reply. Required fields you truly have no basis for go in one short question at the end; optional unknowns (phone, website, newsletter) in a single 'si quieres' line, never as missing items. (5) After their confirmation, call register. The sign-up goes to suggest_register_context's `environment` (local/development/staging/production): name it correctly and never call a test environment production. All of this is self-declared profile data.\n\nFor whatever you still have to ASK (only what you could not infer), ask in ONE grouped message, not one field at a time, and for every CHOICE field PRESENT THE VALID OPTIONS so the talent just picks one. For every choice field SHOW THE TALENT THE FRIENDLY LABEL, never the raw code (e.g. show 'Nativo', not 'NATIVE'), and pass the code as the value. Fields, grouped:\n1. Identity — name, lastName, email (optional: preferredLanguage, newsletterConsent).\n2. Profile — `profile.linkedinUrl` (required source; optional GitHub/website/CV path).\n3. Work — freelanceType (offer: "
|
|
100
100
|
+ labelledOptions(FREELANCE_TYPES, L.freelanceTypeLabels)
|
|
101
101
|
+ ") and `workSituation` (offer situation: "
|
|
102
102
|
+ labelledOptions(WORK_SITUATIONS, L.workSituationLabels)
|
|
@@ -460,17 +460,19 @@ function makeRegisterTools(deps = {}) {
|
|
|
460
460
|
}
|
|
461
461
|
|
|
462
462
|
// Content-free context the model may PROPOSE to the talent (never auto-write).
|
|
463
|
-
async function suggestContext() {
|
|
463
|
+
async function suggestContext(args = {}) {
|
|
464
464
|
const read = deps.readRegisterContextSuggestions || require('./register-context').readRegisterContextSuggestions;
|
|
465
|
-
return { ok: true, suggestions: read({}), note: 'Propose these to the talent to confirm; never write them without confirmation.' };
|
|
465
|
+
return { ok: true, suggestions: read({ talentName: args.talentName }), note: 'Propose these to the talent to confirm; never write them without confirmation.' };
|
|
466
466
|
}
|
|
467
467
|
|
|
468
468
|
// Text of the CV the talent confirmed as theirs, read only with their permission.
|
|
469
469
|
async function readCv(args = {}) {
|
|
470
470
|
if (args.talentConsent !== true) return { ok: false, reason: 'consent-required', message: 'Ask the talent first whether this CV is theirs and whether you may read it; call again with talentConsent:true only after they say yes.' };
|
|
471
471
|
const read = deps.readCvText || require('./cv-text').readCvText;
|
|
472
|
-
const result = read(args.path);
|
|
472
|
+
const result = read(args.path, { talentName: args.talentName });
|
|
473
473
|
if (!result.ok) return { ...result, message: 'Could not read the CV text here. Ask the talent to attach the CV to the chat, or to tell you what it says.' };
|
|
474
|
+
// Someone else's CV (a candidate they downloaded): never hand its contents over.
|
|
475
|
+
if (result.mentionsTalent === false) return { ok: true, mentionsTalent: false, note: "This CV does not name the talent: it belongs to someone else. Do not use, quote or describe it; try the next candidate." };
|
|
474
476
|
return result;
|
|
475
477
|
}
|
|
476
478
|
|
|
@@ -489,18 +491,19 @@ function makeRegisterTools(deps = {}) {
|
|
|
489
491
|
},
|
|
490
492
|
{
|
|
491
493
|
name: 'suggest_register_context',
|
|
492
|
-
description: "
|
|
493
|
-
inputSchema: { type: 'object', properties: {} },
|
|
494
|
+
description: "Local SUGGESTIONS to fill the registration (no network, no file contents): `environment` the sign-up goes to (local/development/staging/production — name it correctly, never call a test environment production), timezone, git name/email, the GitHub login of the gh CLI (githubUser/githubUrl), and cvCandidates — PDF/DOC/DOCX files in Desktop, Documents, Downloads and iCloud Drive whose NAME looks like a CV or carries the talent's full name, each with nameMatch and generic flags (people who hire keep other people's CVs, so a candidate is only a lead: confirm it with read_cv). Pass talentName = the talent's name as you know it to sharpen the match. Propose values, never write without the talent's confirmation.",
|
|
495
|
+
inputSchema: { type: 'object', properties: { talentName: { type: 'string', description: "The talent's full name as you know it (memory, chat); improves CV matching." } } },
|
|
494
496
|
handler: suggestContext,
|
|
495
497
|
},
|
|
496
498
|
{
|
|
497
499
|
name: 'read_cv',
|
|
498
|
-
description: "Read the TEXT of
|
|
500
|
+
description: "Read the TEXT of a CV file (PDF, DOC, DOCX) on this machine to fill the register and the onboarding from it. Call it once the talent has allowed you to search and read their CV (talentConsent:true), with talentName set. Returns { mentionsTalent, links: { linkedin, github, emails, phones }, text } for the talent's own CV; for a CV that does not name them it returns only mentionsTalent:false — it is someone else's: drop it, never describe it, try the next candidate. Local only: nothing is uploaded by this tool (the CV is sent to Shakers only as profile.cvPath in a confirmed register). On failure ask the talent to attach the CV to the chat.",
|
|
499
501
|
inputSchema: {
|
|
500
502
|
type: 'object',
|
|
501
503
|
properties: {
|
|
502
|
-
path: { type: 'string', description: 'Path to the
|
|
503
|
-
talentConsent: { type: 'boolean', description: 'true ONLY after the talent
|
|
504
|
+
path: { type: 'string', description: 'Path to the CV file, exactly as suggest_register_context gave it (the short ~/ form is fine) or absolute.' },
|
|
505
|
+
talentConsent: { type: 'boolean', description: 'true ONLY after the talent allowed you to search for and read their CV.' },
|
|
506
|
+
talentName: { type: 'string', description: "The talent's full name as you know it; used to tell their CV from other people's." },
|
|
504
507
|
},
|
|
505
508
|
required: ['path', 'talentConsent'],
|
|
506
509
|
},
|
package/src/mcp-server.js
CHANGED
|
@@ -27,7 +27,7 @@ function errorResponse(id, code, message, data) {
|
|
|
27
27
|
return { jsonrpc: JSONRPC, id: id === undefined ? null : id, error };
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
function createMcpServer({ tools = [], serverInfo = {}, protocolVersion, beforeCall = null } = {}) {
|
|
30
|
+
function createMcpServer({ tools = [], serverInfo = {}, protocolVersion, beforeCall = null, instructions = null } = {}) {
|
|
31
31
|
const registry = new Map();
|
|
32
32
|
for (const t of tools) {
|
|
33
33
|
if (!t || !t.name || typeof t.handler !== 'function') {
|
|
@@ -117,6 +117,9 @@ function createMcpServer({ tools = [], serverInfo = {}, protocolVersion, beforeC
|
|
|
117
117
|
name: serverInfo.name || 'shakers-mcp',
|
|
118
118
|
version: serverInfo.version || '0.0.0',
|
|
119
119
|
},
|
|
120
|
+
// Server-level guidance the client keeps in the model's context even
|
|
121
|
+
// when it loads tool schemas lazily (Claude Code defers MCP tools).
|
|
122
|
+
...(typeof instructions === 'string' && instructions ? { instructions } : {}),
|
|
120
123
|
});
|
|
121
124
|
}
|
|
122
125
|
case 'ping':
|
package/src/register-context.js
CHANGED
|
@@ -11,10 +11,12 @@ const path = require('path');
|
|
|
11
11
|
const { execFileSync } = require('child_process');
|
|
12
12
|
const { getHomeDir, tildify } = require('./env-paths');
|
|
13
13
|
|
|
14
|
-
// Where a talent keeps a CV, and how deep we look under each folder.
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
const
|
|
14
|
+
// Where a talent keeps a CV, and how deep we look under each folder. iCloud
|
|
15
|
+
// Drive holds Desktop/Documents for many Mac users.
|
|
16
|
+
const CV_DIRS = ['Desktop', 'Documents', 'Downloads', 'Escritorio', 'Documentos', 'Descargas', 'Library/Mobile Documents/com~apple~CloudDocs'];
|
|
17
|
+
const CV_MAX_DEPTH = 3;
|
|
18
|
+
const CV_MAX_RESULTS = 8;
|
|
19
|
+
const CV_EXTENSIONS = /\.(pdf|docx?)$/i;
|
|
18
20
|
const CV_NAME = /(^|[^a-z])(cv|curriculum|curr[ií]culum|resume|r[ée]sum[ée])([^a-z]|$)/i;
|
|
19
21
|
|
|
20
22
|
function detectTimezone() {
|
|
@@ -56,12 +58,22 @@ function detectGithubUser(home, cwd) {
|
|
|
56
58
|
|
|
57
59
|
const fold = (text) => String(text || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
|
|
58
60
|
|
|
61
|
+
const nameTokens = (personName) => fold(personName).split(/[^a-z0-9]+/).filter((t) => t.length >= 3);
|
|
62
|
+
|
|
59
63
|
// Whether a file name carries a token of the person's name. People who hire keep
|
|
60
64
|
// other people's CVs in Downloads; only a name match makes a candidate theirs.
|
|
61
65
|
function matchesName(fileName, personName) {
|
|
62
|
-
const tokens = fold(personName).split(/[^a-z0-9]+/).filter((t) => t.length >= 3);
|
|
63
66
|
const file = fold(fileName);
|
|
64
|
-
return
|
|
67
|
+
return nameTokens(personName).some((t) => file.includes(t));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// A file named after the person but without a CV word (`Alvaro Torro.pdf`) is a
|
|
71
|
+
// candidate only when it carries their full name, so one common first name does
|
|
72
|
+
// not pull in every contract and invoice.
|
|
73
|
+
function carriesFullName(fileName, personName) {
|
|
74
|
+
const tokens = nameTokens(personName);
|
|
75
|
+
const file = fold(fileName);
|
|
76
|
+
return tokens.length >= 2 && tokens.every((t) => file.includes(t));
|
|
65
77
|
}
|
|
66
78
|
|
|
67
79
|
// Words that say nothing about whose CV it is: `cv.pdf`, `Curriculum_2024_ES.pdf`,
|
|
@@ -73,8 +85,8 @@ function isGenericCvName(fileName) {
|
|
|
73
85
|
return rest.trim().length === 0;
|
|
74
86
|
}
|
|
75
87
|
|
|
76
|
-
//
|
|
77
|
-
// newest. Never opens a file.
|
|
88
|
+
// PDF/Word files whose NAME looks like a CV or carries the person's full name:
|
|
89
|
+
// name matches first, then generic names, then newest. Never opens a file.
|
|
78
90
|
function findCvCandidates(home, personName) {
|
|
79
91
|
const found = [];
|
|
80
92
|
const walk = (dir, depth) => {
|
|
@@ -89,7 +101,8 @@ function findCvCandidates(home, personName) {
|
|
|
89
101
|
const full = path.join(dir, entry.name);
|
|
90
102
|
if (entry.isDirectory()) {
|
|
91
103
|
if (depth < CV_MAX_DEPTH) walk(full, depth + 1);
|
|
92
|
-
} else if (entry.isFile() &&
|
|
104
|
+
} else if (entry.isFile() && CV_EXTENSIONS.test(entry.name)
|
|
105
|
+
&& (CV_NAME.test(entry.name.replace(CV_EXTENSIONS, '')) || carriesFullName(entry.name, personName))) {
|
|
93
106
|
try {
|
|
94
107
|
found.push({
|
|
95
108
|
path: full,
|
|
@@ -110,19 +123,37 @@ function findCvCandidates(home, personName) {
|
|
|
110
123
|
.map(({ path: cvPath, nameMatch, generic }) => ({ path: tildify(cvPath, { SHAKERS_CLI_HOME_DIR: home }), nameMatch, generic }));
|
|
111
124
|
}
|
|
112
125
|
|
|
113
|
-
//
|
|
114
|
-
|
|
126
|
+
// Which backend this install talks to, so the AI never calls a test sign-up
|
|
127
|
+
// "production".
|
|
128
|
+
function detectEnvironment(env) {
|
|
129
|
+
let base = '';
|
|
130
|
+
try {
|
|
131
|
+
base = String(require('./config').getHubBase(env) || '');
|
|
132
|
+
} catch {
|
|
133
|
+
return 'unknown';
|
|
134
|
+
}
|
|
135
|
+
if (/localhost|127\.0\.0\.1/.test(base)) return 'local';
|
|
136
|
+
if (/staging/.test(base)) return 'staging';
|
|
137
|
+
if (/\.dev\.|new-works/.test(base)) return 'development';
|
|
138
|
+
return base ? 'production' : 'unknown';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Returns the signals found, each clearly a SUGGESTION to confirm. `talentName`
|
|
142
|
+
// (what the AI already knows) sharpens the CV match beyond the git name.
|
|
143
|
+
function readRegisterContextSuggestions({ cwd, env = process.env, talentName } = {}) {
|
|
115
144
|
const home = getHomeDir(env);
|
|
116
145
|
const githubUser = detectGithubUser(home, cwd);
|
|
117
146
|
const gitName = gitConfig('user.name', cwd);
|
|
147
|
+
const personName = [talentName, gitName].filter((n) => typeof n === 'string' && n.trim()).join(' ');
|
|
118
148
|
return {
|
|
149
|
+
environment: detectEnvironment(env),
|
|
119
150
|
timezone: detectTimezone(),
|
|
120
151
|
gitName,
|
|
121
152
|
gitEmail: gitConfig('user.email', cwd),
|
|
122
153
|
githubUser,
|
|
123
154
|
githubUrl: githubUser ? `https://github.com/${githubUser}` : null,
|
|
124
|
-
cvCandidates: findCvCandidates(home,
|
|
155
|
+
cvCandidates: findCvCandidates(home, personName),
|
|
125
156
|
};
|
|
126
157
|
}
|
|
127
158
|
|
|
128
|
-
module.exports = { readRegisterContextSuggestions, detectTimezone, findCvCandidates, detectGithubUser, isGenericCvName };
|
|
159
|
+
module.exports = { readRegisterContextSuggestions, detectTimezone, findCvCandidates, detectGithubUser, isGenericCvName, detectEnvironment, nameTokens, fold };
|