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 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 | `node scripts/prepare-beta.js` → `npm publish --tag beta` → `git checkout -- package.json` |
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. The beta one uses
144
- `scripts/prepare-beta.js` to stamp `name`/`version`/`publishConfig` (version
145
- `0.8.0-beta.0`, dist-tag `beta`), publishes, then restores the manifest with
146
- git. The npmjs org/account for `shakers-cli-beta` must exist and you must be
147
- `npm login`ed to it (or set `NODE_AUTH_TOKEN`) before publishing — nothing here
148
- publishes automatically.
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",
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 what the CV
4
- // says. Zero-dependency on purpose (install.sh copies the CLI without npm
5
- // install): macOS reads it with the system PDFKit through JXA, elsewhere with
6
- // poppler's pdftotext when it is installed. With neither, the caller asks the
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 (platform === 'darwin') list.push({ name: 'pdfkit', cmd: 'osascript', args: (file) => ['-l', 'JavaScript', '-e', JXA_PDF_TEXT, file] });
28
- list.push({ name: 'pdftotext', cmd: 'pdftotext', args: (file) => ['-layout', '-q', file, '-'] });
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
- function readCvText(input, { platform = process.platform, exec = execFileSync, env = process.env } = {}) {
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-pdf-path' };
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 { ok: true, extractor: extractor.name, truncated: text.length > MAX_CHARS, text: text.slice(0, MAX_CHARS) };
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\nSAVE THE TALENT TYPING: first call suggest_register_context (timezone, local git identity, GitHub login, CV files) and PROPOSE those values for them to confirm; then call register_preview with the partial payload to get a review (what would be written, what is still missing) and show the talent a short summary to confirm or correct BEFORE calling register. Pre-fill EVERY field you can reasonably infer from what you know about the talent (your memory of past conversations, the chat, context) — including `pricing` and `workSituation.changeMotivators`. PRICING: whenever you know anything about their role, seniority, stack or location, ALWAYS propose a concrete per-project amount yourself (full-time and/or part-time, in their currency), labelled as your estimate — do not hand the question back to the talent; only ask for it when you know nothing at all about their work. MOTIVATORS: pick the one that best matches what they have told you. What you know about the person outranks suggest_register_context: if the git identity does not match the person you know, ignore it. For each inferred value briefly say where it comes from. Ask ONLY for what you genuinely have no basis for. Use suggest_register_context's githubUrl and cvCandidates to propose profile.githubUrl and profile.cvPath (cvCandidates often include OTHER people's CVs, e.g. candidates they downloaded: propose a CV ONLY if its file name matches the talent's name — nameMatch:true or a name you know — and never one named after someone else; if none matches but there is a generic:true one like cv.pdf, ASK the talent whether it is theirs and whether you may read it and send it with the registration; otherwise ask for the path). Once the talent confirms a CV and allows it, ALWAYS call read_cv BEFORE register_preview and use its text to pre-fill or correct the other fields (languages and levels, experience, location); if the CV adds or changes any value, show the talent just those changes and get their OK before calling register; pass paths exactly as given, the short ~/ form is fine. For optional fields you have no basis for (phone, website, newsletter), do not ask one by one: add a single closing line offering them (e.g. '¿Quieres añadir teléfono o web?'). The goal is that the talent only has to answer 'sí' or correct a value. Never write without the talent's confirmation of the summary. All of this is self-declared profile data.\n\nCOLLECT THE DETAILS IN A FEW GROUPED BATCHES, 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. Suggested batches:\n1. Identity — name, lastName, email (optional: preferredLanguage, newsletterConsent).\n2. Profile — `profile.linkedinUrl` (required source; optional GitHub/website/CV path).\n3. Work — freelanceType (offer: "
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: "Get local SUGGESTIONS to speed up registration: the environment timezone, the local git identity (user.name / user.email), the GitHub login the gh CLI is signed in with (githubUser/githubUrl), and cvCandidates — PDF files in Desktop/Documents/Downloads whose NAME looks like a CV (names only, never read), each with nameMatch (file name contains the git name). Only propose a CV that is clearly the talent's own; never propose one named after someone else. A generic:true name (cv.pdf, curriculum_2024.pdf) may be theirs: ask 'is this CV yours? may I read it and send it with your registration?'. These are PROPOSALS only — present them to the talent to confirm (e.g. 'I see your timezone is Europe/Madrid and your git email is X — use these?') and never write any of them without the talent's confirmation. No session needed. Use it before register_preview to pre-fill availability.timezone, name/email, profile.githubUrl and profile.cvPath.",
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 the talent's CV PDF so you can pre-fill the register and the onboarding from what it says (experience, projects, stack, languages, education). Only for a CV the talent has CONFIRMED is theirs, and only after they gave you PERMISSION to read it — set talentConsent:true only then. Typical path: one of suggest_register_context's cvCandidates. Local only: the file is read on this machine and its text comes back to you; nothing is uploaded by this tool (the CV is sent to Shakers only if the talent also confirms it as profile.cvPath in register). Returns { ok, text, truncated } or { ok:false, reason } — on failure ask the talent to attach the CV to the chat.",
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 PDF the talent confirmed as their CV, exactly as suggest_register_context gave it (the short ~/ form is fine) or absolute.' },
503
- talentConsent: { type: 'boolean', description: 'true ONLY after the talent explicitly agreed that this is their CV and that you may read it.' },
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':
@@ -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
- const CV_DIRS = ['Desktop', 'Documents', 'Downloads', 'Escritorio', 'Documentos', 'Descargas'];
16
- const CV_MAX_DEPTH = 2;
17
- const CV_MAX_RESULTS = 5;
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 tokens.some((t) => file.includes(t));
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
- // PDFs whose NAME looks like a CV: name matches first, then generic names, then
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() && /\.pdf$/i.test(entry.name) && CV_NAME.test(entry.name.replace(/\.pdf$/i, ''))) {
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
- // Returns the signals found, each clearly a SUGGESTION to confirm.
114
- function readRegisterContextSuggestions({ cwd, env = process.env } = {}) {
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, gitName),
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 };