shakers-cli-beta 0.14.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shakers-cli-beta",
3
- "version": "0.14.1",
3
+ "version": "0.15.0",
4
4
  "description": "Shakers AI usage & skills evaluator: a local-first CLI (`shakers` binary) plus a local MCP server. Commands: ai-usage (scan + classify your AI-tool setup, T0-T7), certify (certify Skills from your code), share (branded LinkedIn card).",
5
5
  "type": "commonjs",
6
6
  "bin": {
@@ -3,6 +3,7 @@
3
3
  const http = require('http');
4
4
  const https = require('https');
5
5
  const crypto = require('crypto');
6
+ const { getHubBase } = require('./config');
6
7
 
7
8
  // ONE BACKEND: the certifications service (talents-ai-score, ADR-042).
8
9
 
@@ -16,6 +17,20 @@ function setClientIdentity(identity) {
16
17
  clientIdentity = identity;
17
18
  }
18
19
 
20
+ // Hub profile writes declare which surface made them; reads, auth and the onboarding funnel are not profile updates.
21
+ const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
22
+ const HUB_PROFILE_PATH_RE = /^\/(works\/(talents?\/me|me|users\/me)|works\/talent\/(portfolios|certifications\/me))(\/|$)/;
23
+ const NOT_PROFILE_UPDATE_RE = /^\/works\/talent\/me\/onboarding-events(\/|$)/;
24
+
25
+ function profileUpdateSourceHeader(url, method) {
26
+ if (!MUTATING_METHODS.has(String(method).toUpperCase())) return {};
27
+ const hubBase = getHubBase();
28
+ if (!hubBase || !url.href.startsWith(`${hubBase}/`)) return {};
29
+ const path = url.href.slice(hubBase.length).split(/[?#]/)[0];
30
+ if (!HUB_PROFILE_PATH_RE.test(path) || NOT_PROFILE_UPDATE_RE.test(path)) return {};
31
+ return { 'X-Profile-Update-Source': clientIdentity };
32
+ }
33
+
19
34
  // Extra request headers, sanitized (talents-ai-score, issue 038 — the agent certification interview needs `Authorization: Bearer <sessionToken>`).
20
35
  const UNSAFE_HEADER_VALUE_RE = /[\r\n\0]/;
21
36
 
@@ -49,6 +64,7 @@ function postJsonWithTimeout(url, body, timeoutMs, method = 'POST', idempotencyK
49
64
  Accept: 'application/json',
50
65
  // Tells CLI and MCP traffic apart from the browser.
51
66
  'X-Shakers-Client': clientIdentity,
67
+ ...profileUpdateSourceHeader(u, method),
52
68
  // Only declared when there IS a body — see the bodyless note above.
53
69
  ...(data ? { 'Content-Type': 'application/json', 'Content-Length': data.length } : {}),
54
70
  ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
@@ -129,6 +145,7 @@ function postMultipartWithTimeout(url, fields, timeoutMs, method = 'POST', extra
129
145
  Accept: 'application/json',
130
146
  // See postJsonWithTimeout's identical header for why.
131
147
  'X-Shakers-Client': clientIdentity,
148
+ ...profileUpdateSourceHeader(u, method),
132
149
  'Content-Type': `multipart/form-data; boundary=${boundary}`,
133
150
  'Content-Length': data.length,
134
151
  ...sanitizeExtraHeaders(extraHeaders),
@@ -58,6 +58,7 @@ function makeFindProjectsTools(deps = {}) {
58
58
  description: 'Shows the open Shakers projects that match you best.',
59
59
  guidance: "List the Positions/Projects available to the talent right now (Shakers Find Positions), paginated and ordered by match desc. Each item carries its parent Project id/name, title, company, budget, work mode and match. Supports server-side pagination (page/limit) and filters (tab all|saved, attendance, country ISO2). `meta` carries {page,pageSize,total,totalPages}. Requires an active session. The positionId/projectId in each item are INTERNAL handles for calling other tools (e.g. show_project, save_project) — NEVER show or mention them to the talent; refer to a project by its title/company only.",
60
60
  inputSchema: FIND_PROJECTS_SCHEMA,
61
+ annotations: { readOnlyHint: true },
61
62
  handler: findProjects,
62
63
  },
63
64
  ];
@@ -15,11 +15,11 @@ function buildServerInstructions({ elicitation = false } = {}) {
15
15
  '2. LinkedIn: take it from the CV. If it has none, signup_start with linkedinMissing:true returns the question to print; never ask it in your own words. Only if they say they do not know where to find it, say the linkedinOpening text and call open_linkedin_profile.',
16
16
  '3. signup_email with the best email you know (CV first), or none.',
17
17
  '4. signup_draft with your draft of their profile (name, role, city, years, stack, languages, work mode, hours, hourly and annual rate in euros, both always: the talent\'s figures with ratesFromTalent:true, else your estimate; sources: cv and/or linkedin, only those you have). It shows the draft, the data notice and how the account window works, and asks them to confirm; if they want changes, ask what and call it again.',
18
- '5. Once confirmed, signup_create_account with LinkedIn, cvPath, names and userQuery = everything you know about them; the window opens by itself, so say nothing about it. Then signup_status (waitSeconds 20) until the account exists, and follow its message (a failed LinkedIn import: its fixed line comes with the next question; retry once, then ask for their website or GitHub).',
18
+ '5. Once confirmed, signup_create_account with LinkedIn, cvPath, names and userQuery = everything you know about them; the window opens by itself, so say nothing about it. Then signup_status (waitSeconds 45) until the account exists, and follow its message (a failed LinkedIn import: its fixed line comes with the next question; retry once, then ask for their website or GitHub).',
19
19
  '6. New account: save_profile_details with the confirmed draft, without asking. Existing account (the talent signed in instead): update_existing_profile, which shows what would fill their empty fields and asks; nothing they already set is overwritten.',
20
20
  '7. AI usage, a step of its own: ai_usage without consent asks the question with the two disclaimers; nothing is scanned before their yes. On yes it runs in the background: never wait for it. If they skip it, its fixed line comes with the next question.',
21
21
  '8. Onboarding interview: skip it if signup_status says it is completed; otherwise onboarding_interview_start without disclaimerAcknowledged (here now or later on the web, default here), then how they want to answer (drafted, own, full), asked with the interview disclaimers. In drafted mode, show each question verbatim with your proposed answer and send it only after the talent approves or edits it; only if they explicitly ask you to answer everything yourself ("hazla entera", "do it all"), complete the remaining questions without per-answer approval and tell them at the end. Full mode is that from the first question. Own mode sends only their words. Never call onboarding_finish in this flow.',
22
- '9. Main role, also when they skip the interview: list_my_roles (waitSeconds 20, again while pending), then, only when its next is set_main_role, set_main_role without clusterId and with evidence = one concrete fact per recommended role from their CV, the interview or the AI-usage analysis (with nothing recommended: answer = the role their CV names and skills = their stack). Never set a main role the talent did not pick.',
22
+ '9. Main role, also when they skip the interview: list_my_roles (waitSeconds 45, again while pending), then, only when its next is set_main_role, set_main_role without clusterId and with evidence = one concrete fact per recommended role from their CV, the interview or the AI-usage analysis (with nothing recommended: answer = the role their CV names and skills = their stack). Never set a main role the talent did not pick.',
23
23
  '10. Finish with open_web: it opens Shakers with the talent signed in.',
24
24
  ].join('\n');
25
25
  }
@@ -37,6 +37,7 @@ function makeProfileTools(deps = {}) {
37
37
  description: 'Shows your main role, your rate and your "My work with AI" summary on Shakers.',
38
38
  guidance: "Get the talent's summary: main role, per-project rate, and the 'My work with AI' read model (setup/usage tier+level, 3x3 cell, vision, how-I-work). Requires an active session. Present it in human terms; any internal ids/codes in the result are handles for tools only — never show or mention them to the talent.",
39
39
  inputSchema: GET_PROFILE_SCHEMA,
40
+ annotations: { readOnlyHint: true },
40
41
  handler: getProfile,
41
42
  },
42
43
  ];
@@ -11,7 +11,9 @@ const EMPTY_SCHEMA = { type: 'object', properties: {} };
11
11
  // Same bounds as the web's growth-direction step: certs finalizes an interview within ~90 s, polled every 2 s.
12
12
  const FINALIZATION_WAIT_LIMIT_MS = 90000;
13
13
  const POLL_MS = 2000;
14
- const MAX_WAIT_SECONDS = 25;
14
+ // Cap on the waitSeconds the model may pass; kept under Claude Desktop's ~60 s tool-call cap, higher than
15
+ // the old 25 so fewer polls (and fewer approval prompts) cover the ~90 s finalization.
16
+ const MAX_WAIT_SECONDS = 45;
15
17
  const PENDING_FINALIZATION = new Set(['COLLECTING', 'PROCESSING']);
16
18
  // Alma's signal from the interview first, then the CV import, as the web stacks them.
17
19
  const SOURCE_ORDER = { ONBOARDING_INTERVIEW: 0, IMPORT: 1 };
package/src/mcp-server.js CHANGED
@@ -107,7 +107,7 @@ function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVe
107
107
  }
108
108
  }
109
109
 
110
- // `description` and `title` are what the talent reads in the client; `guidance` is how the model must use the tool, so it rides in the schema.
110
+ // `description` and `title` are what the talent reads in the client; `guidance` is how the model must use the tool, so it rides in the schema. `annotations` (e.g. readOnlyHint) is a standard MCP hint that passes straight through for clients that act on it.
111
111
  function shapeTool(t) {
112
112
  const schema = t.inputSchema || { type: 'object' };
113
113
  return {
@@ -115,6 +115,7 @@ function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVe
115
115
  ...(t.title ? { title: t.title } : {}),
116
116
  description: t.description || '',
117
117
  inputSchema: t.guidance ? { ...schema, description: [t.guidance, schema.description].filter(Boolean).join(' ') } : schema,
118
+ ...(t.annotations ? { annotations: t.annotations } : {}),
118
119
  };
119
120
  }
120
121
 
@@ -18,7 +18,11 @@ const { NOOP_FUNNEL, weeklyHours, rateTypes, workSituationValue } = require('./o
18
18
  const SIGNUP_WINDOW_TIMEOUT_MS = 15 * 60 * 1000;
19
19
  // The browser opens this long after the tool answers, so the AI's reply is on screen first.
20
20
  const BROWSER_OPEN_DELAY_MS = 4000;
21
- const MAX_WAIT_SECONDS = 25;
21
+ // Each signup_status call holds up to this long waiting for a change, so a higher value means fewer calls
22
+ // (and fewer per-call approval prompts in the client) cover the same wait. Kept under the ~60 s hard cap
23
+ // Claude Desktop puts on a tool call (progress notifications do not extend it there), with margin for the
24
+ // round-trip.
25
+ const MAX_WAIT_SECONDS = 45;
22
26
  const IMPORT_POLL_MS = 3000;
23
27
  // Languages the profile importer writes in.
24
28
  const IMPORT_LANGUAGES = new Set(['es', 'en', 'it', 'pt', 'fr']);
@@ -667,7 +671,7 @@ function makeSignupTools(deps = {}) {
667
671
  ...(cvNote ? { cvNote } : {}),
668
672
  next: 'signup_status',
669
673
  message: (reused ? 'The sign-up window was already open; it now uses the new data. ' : 'The window opens in the talent\'s browser in a few seconds; they read how it works in the draft they confirmed. ')
670
- + 'Say nothing about the window. Only if they say it did not open, call signup_status with windowLink:true. Now call signup_status (waitSeconds 20) until the account exists.',
674
+ + 'Say nothing about the window. Only if they say it did not open, call signup_status with windowLink:true. Now call signup_status (waitSeconds 45) until the account exists.',
671
675
  };
672
676
  }
673
677
 
@@ -703,7 +707,7 @@ function makeSignupTools(deps = {}) {
703
707
  }
704
708
 
705
709
  function nextSteps(account) {
706
- const interview = 'Then the onboarding interview: if onboardingInterview.completed is true, skip it without mentioning it; otherwise call onboarding_interview_start without disclaimerAcknowledged (it asks here now or later on the web). Then, also if they skip it, the main role: list_my_roles (waitSeconds 20), then set_main_role without clusterId only when its next says so. Finally open_web.';
710
+ const interview = 'Then the onboarding interview: if onboardingInterview.completed is true, skip it without mentioning it; otherwise call onboarding_interview_start without disclaimerAcknowledged (it asks here now or later on the web). Then, also if they skip it, the main role: list_my_roles (waitSeconds 45), then set_main_role without clusterId only when its next says so. Finally open_web.';
707
711
  const aiUsage = 'Then ai_usage without consent: it returns the two AI-usage disclaimers and the question (show them right before asking; no scan happens before a yes).';
708
712
  if (account.state === 'existing') {
709
713
  return `This talent already had an account and signed in. Call update_existing_profile: it shows what would change and asks whether to update, and its text tells the talent about the account, so say nothing about it yourself. ${aiUsage} ${interview}`;
@@ -1121,8 +1125,9 @@ function makeSignupTools(deps = {}) {
1121
1125
  name: 'signup_status',
1122
1126
  title: 'Sign-up progress',
1123
1127
  description: 'Shows whether your account is ready and how the import of your profile is going.',
1124
- guidance: "State of the sign-up: account (waiting | created | existing | failed) and, once it exists, the profile import per source (linkedin, cv: running | done | failed). Pass waitSeconds (up to 25) to wait for a change. Follow `message`: it gives the remaining steps. Never show internal codes to the talent.",
1128
+ guidance: "State of the sign-up: account (waiting | created | existing | failed) and, once it exists, the profile import per source (linkedin, cv: running | done | failed). Pass waitSeconds (up to 45) to wait for a change; a high value means fewer calls cover the wait. Follow `message`: it gives the remaining steps. Never show internal codes to the talent.",
1125
1129
  inputSchema: STATUS_SCHEMA,
1130
+ annotations: { readOnlyHint: true },
1126
1131
  handler: signupStatus,
1127
1132
  },
1128
1133
  {