shakers-cli 0.17.0 → 0.18.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.
Files changed (59) hide show
  1. package/README.md +15 -5
  2. package/bin/command-audit-worker.js +19 -0
  3. package/bin/login.js +4 -8
  4. package/bin/mcp.js +29 -19
  5. package/bin/register.js +10 -29
  6. package/bin/shakers-cli.js +14 -9
  7. package/package.json +1 -1
  8. package/src/agents-client.js +1 -0
  9. package/src/ai-usage-review.js +5 -3
  10. package/src/auth-client.js +7 -107
  11. package/src/auth-session-store.js +6 -12
  12. package/src/backend-request.js +1 -24
  13. package/src/cli-skill.js +77 -0
  14. package/src/command-audit.js +47 -4
  15. package/src/config.js +0 -16
  16. package/src/consent-flow.js +7 -2
  17. package/src/detect-repos.js +4 -8
  18. package/src/detectors.js +23 -0
  19. package/src/device-auth-store.js +2 -2
  20. package/src/device-login.js +11 -9
  21. package/src/env-paths.js +9 -1
  22. package/src/i18n.js +162 -88
  23. package/src/mcp-ai-usage-result-tool.js +6 -4
  24. package/src/mcp-ai-usage-tool.js +72 -46
  25. package/src/mcp-choice.js +171 -34
  26. package/src/mcp-detector.js +26 -2
  27. package/src/mcp-device-login-tools.js +40 -8
  28. package/src/mcp-install.js +15 -11
  29. package/src/mcp-instructions.js +43 -17
  30. package/src/mcp-onboarding-tools.js +1 -3
  31. package/src/mcp-prompts.js +6 -4
  32. package/src/mcp-register-tools.js +25 -14
  33. package/src/mcp-role-tools.js +35 -17
  34. package/src/mcp-server-name.js +7 -0
  35. package/src/mcp-server.js +35 -7
  36. package/src/mcp-signup-client.js +3 -6
  37. package/src/mcp-signup-flow.js +389 -0
  38. package/src/mcp-signup-tools.js +237 -126
  39. package/src/onboarding-flow.js +30 -125
  40. package/src/opencode-sessions.js +92 -0
  41. package/src/profile-agents.js +26 -0
  42. package/src/saved-positions-flow.js +3 -3
  43. package/src/scanner.js +104 -12
  44. package/src/session-gate.js +1 -4
  45. package/src/session-normalizers.js +114 -8
  46. package/src/session-refresh.js +5 -16
  47. package/src/session-scan.js +167 -22
  48. package/src/share-payload.js +4 -1
  49. package/src/share.js +2 -2
  50. package/src/signup-client.js +12 -39
  51. package/src/signup-copy.js +380 -68
  52. package/src/skill-install.js +102 -0
  53. package/src/tier-engine.js +14 -2
  54. package/src/trust-narration.js +32 -0
  55. package/src/usage-run-steps.js +12 -3
  56. package/src/usage-scan.js +3 -0
  57. package/src/brand-svg.js +0 -9
  58. package/src/inter-fonts.js +0 -6
  59. package/src/mcp-auth-form.js +0 -484
package/README.md CHANGED
@@ -69,11 +69,21 @@ Run `shakers` with no command to see every command.
69
69
  can leave out agents and text excerpts there. `--consent-revoke` stops future uploads
70
70
  from this computer; it does not delete what was already uploaded. To ask for that, write
71
71
  to talent@shakersworks.com.
72
- - What is saved is mostly derived data (counts, categories, tier, detected technologies).
73
- Two kinds of text travel too, and the review shows how many: the definition text of your
74
- AI agents and short excerpts of what you ask the AI (200 characters at most), both with
75
- secrets, paths and emails removed. Never your code, your full conversations, the names of
76
- your repositories, environment variables or credentials.
72
+ - What is sent is mostly derived data (counts, categories, tier, detected technologies,
73
+ sessions, active days, commits and lines changed). Literal text travels too, and the review
74
+ shows how much: up to 40 fragments of your messages to AI assistants and 20 commands they
75
+ ran (200 characters at most each), and the definition (up to 2,000 characters) and short
76
+ description of each of your AI agents, all after removing anything that looks like a
77
+ secret, a path or an email. The fragments come from the conversations in the repositories
78
+ analysed, or from every conversation on this computer when you analyse the whole machine
79
+ (registration always does). Never your code, your full conversations, the names of your
80
+ repositories, environment variables or credentials.
81
+ - An AI model from an external provider (OpenAI, Google or Anthropic) analyses that text
82
+ automatically for Shakers to compute your evaluation, and then it is discarded. Only the result is stored: levels, scores, counts and the names
83
+ of your tools and agents.
84
+ - Do not run it on a machine or with conversations that hold information you are not
85
+ allowed to share: client, employer or third-party data. Secret removal is automatic and
86
+ best effort; names or business details you wrote can remain in the fragments.
77
87
  - Certification sends secret-scrubbed samples of your code and your interview answers to
78
88
  Shakers to assess them.
79
89
  - Read the full policy at <https://www.shakersworks.com/en/politica-de-privacidad>.
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const { ensureFreshSession } = require('../src/session-refresh');
5
+ const { emitCommandAudit, PAYLOAD_VAR } = require('../src/command-audit');
6
+
7
+ async function main() {
8
+ let payload;
9
+ try {
10
+ payload = JSON.parse(process.env[PAYLOAD_VAR]);
11
+ } catch {
12
+ return;
13
+ }
14
+ if (!payload || typeof payload.command !== 'string') return;
15
+ await ensureFreshSession(process.env).catch(() => null);
16
+ await emitCommandAudit({ command: payload.command, argv: payload.args ? [payload.args] : [], env: process.env, id: typeof payload.id === 'string' ? payload.id : null });
17
+ }
18
+
19
+ main();
package/bin/login.js CHANGED
@@ -5,7 +5,7 @@
5
5
 
6
6
  const { detectReportLang, getCatalog } = require('../src/i18n');
7
7
  const { getAuthTokenEndpoint, getDeviceAuthorizeEndpoint, getDeviceTokenEndpoint, getCompleteRegistrationEndpoint } = require('../src/config');
8
- const { runDeviceLogin, appendProviderParam, deviceSessionRecord } = require('../src/device-login');
8
+ const { runDeviceLogin, withDeviceParams, deviceSessionRecord } = require('../src/device-login');
9
9
  const { buildRegistrationContext } = require('../src/signup-client');
10
10
  const { openPath } = require('../src/open-file');
11
11
  const { endSession } = require('../src/session-refresh');
@@ -100,14 +100,10 @@ function persistAndReport(result, c, email = null, output = process.stdout) {
100
100
  }
101
101
  }
102
102
  try {
103
- // `cookie`/`cookieExpiresAt` are present for the email (better-auth) flow and
104
- // absent for Google (auth-works JWT) — saveAuthSession picks the right model.
105
103
  saveAuthSession({
106
104
  accessToken: result.accessToken,
107
105
  expiresAt: result.expiresAt,
108
106
  accessTokenExpiresAt: result.expiresAt,
109
- cookie: result.cookie || null,
110
- cookieExpiresAt: result.cookieExpiresAt || null,
111
107
  sessionToken: result.sessionToken || null,
112
108
  sessionExpiresAt: result.sessionExpiresAt || null,
113
109
  email,
@@ -120,7 +116,7 @@ function persistAndReport(result, c, email = null, output = process.stdout) {
120
116
  }
121
117
  output.write(`\n ${c.success}\n`);
122
118
  if (result.expiresAt) output.write(` ${c.expiresAt(result.expiresAt)}\n`);
123
- output.write('\n');
119
+ output.write(`\n ${c.deviceGranted}\n\n`);
124
120
  return true;
125
121
  }
126
122
 
@@ -157,8 +153,8 @@ async function runDeviceFlow(provider, c, { openBrowser = openPath, sleep = unde
157
153
  {
158
154
  ...(sleep ? { sleep } : {}),
159
155
  onPrompt: ({ verificationUriComplete, userCode }) => {
160
- const url = appendProviderParam(verificationUriComplete, provider);
161
- output.write(`\n ${c.deviceVisit}\n ${url}\n\n ${c.deviceCodeLabel(userCode)}\n\n ${c.deviceWaiting}\n`);
156
+ const url = withDeviceParams(verificationUriComplete, { provider, mode: 'login', surface: 'cli' });
157
+ output.write(`\n ${c.deviceTrust}\n\n ${c.deviceVisit}\n ${url}\n\n ${c.deviceCodeLabel(userCode)}\n\n ${c.deviceWaiting}\n`);
162
158
  if (provider === 'email') output.write(` ${c.deviceApproveHint}\n`);
163
159
  try { openBrowser(url); } catch { /* the printed URL covers it */ }
164
160
  },
package/bin/mcp.js CHANGED
@@ -23,13 +23,20 @@ const { makeRoleTools } = require('../src/mcp-role-tools');
23
23
  const { makeDeviceLoginTools } = require('../src/mcp-device-login-tools');
24
24
  const { makeRegisterTools } = require('../src/mcp-register-tools');
25
25
  const { makeSignupTools } = require('../src/mcp-signup-tools');
26
+ const { makeSignupTool, outsideSignup, narratedTool, narrationLanguage, ABSORBED_TOOLS } = require('../src/mcp-signup-flow');
26
27
  const { detectFlowLang } = require('../src/i18n');
27
28
  const { makeReportTools } = require('../src/mcp-report-tools');
28
29
  const { makePrompts } = require('../src/mcp-prompts');
29
30
  const { buildServerInstructions } = require('../src/mcp-instructions');
31
+ const { mcpNarration } = require('../src/trust-narration');
30
32
  const { ensureFreshSession } = require('../src/session-refresh');
33
+ const { emitCommandAudit } = require('../src/command-audit');
31
34
  const { createFunnel } = require('../src/onboarding-funnel');
32
35
 
36
+ function narrateTool(lang) {
37
+ return (name, { failed = false, args } = {}) => mcpNarration(narratedTool(name, args), narrationLanguage(name, args, lang), { failed });
38
+ }
39
+
33
40
  let VERSION = '';
34
41
  try {
35
42
  VERSION = require('../package.json').version || '';
@@ -43,19 +50,15 @@ const HELP = `\n shakers mcp — local MCP server (stdio)\n\n`
43
50
  + ` Usage: shakers mcp\n\n`
44
51
  + ` This command speaks JSON-RPC on stdio; it is meant to be launched by an\n`
45
52
  + ` MCP client, not run by hand.\n`
46
- + ` Tools: ai_usage, ai_usage_upload, ai_usage_result, logout, social_signin_start, social_signin_poll,\n`
47
- + ` list_addable, add_skill, add_agent,\n`
48
- + ` add_project, signup_start, signup_email, signup_draft, signup_create_account,\n`
49
- + ` signup_status, update_existing_profile, import_profile, save_profile_details, open_web,\n`
50
- + ` open_linkedin_profile,\n`
51
- + ` suggest_register_context, read_cv, onboarding_interview_start,\n`
52
- + ` onboarding_interview_turn, onboarding_interview_complete,\n`
53
- + ` repeat_onboarding_interview, onboarding_finish,\n`
54
- + ` list_available_roles, add_role, list_my_roles, set_main_role,\n`
55
- + ` report, share,\n`
56
- + ` list_certifiable_dimensions, find_projects, show_project,\n`
57
- + ` save_project, unsave_project, list_invitations,\n`
58
- + ` list_applications, get_availability,\n`
53
+ + ` Tools: signup (the whole sign-up, step by step), ai_usage, ai_usage_upload,\n`
54
+ + ` ai_usage_result, logout, social_signin_start, social_signin_poll,\n`
55
+ + ` list_addable, add_skill, add_agent, add_project, open_web,\n`
56
+ + ` onboarding_interview_start, onboarding_interview_turn,\n`
57
+ + ` onboarding_interview_complete, repeat_onboarding_interview,\n`
58
+ + ` onboarding_finish, list_available_roles, add_role, list_my_roles,\n`
59
+ + ` set_main_role, report, share, list_certifiable_dimensions,\n`
60
+ + ` find_projects, show_project, save_project, unsave_project,\n`
61
+ + ` list_invitations, list_applications, get_availability,\n`
59
62
  + ` list_certifications, get_profile, get_rate, get_languages,\n`
60
63
  + ` list_socials, list_experiences, list_portfolios, alma_chat, alma_decision.\n\n`;
61
64
 
@@ -68,8 +71,11 @@ const LOCAL_ONLY_TOOL_NAMES = new Set(['ai_usage', 'ai_usage_upload', 'ai_usage_
68
71
  function buildServer({ localOnly = false, funnel = createFunnel({ surface: 'mcp' }), makeInterviewClient = null } = {}) {
69
72
  const lang = detectFlowLang();
70
73
  const aiUsage = makeAiUsageTools();
71
- const allTools = [aiUsage.usage, aiUsage.upload, makeAiUsageResultTool(), ...makeOnboardingTools(), ...makeRoleTools({ lang, funnel }), ...makeDeviceLoginTools(), ...makeSignupTools({ lang, funnel }), ...makeRegisterTools({ lang, funnel, flowDeps: { makeInterviewClient } }), ...makeReportTools(), ...makeCertifyDimensionTools(), ...makeFindProjectsTools(), ...makeShowProjectTools(), ...makeSavedPositionsTools(), ...makeInvitationsTools(), ...makeApplicationsTools(), ...makeAvailabilityTools(), ...makeCertificationsTools(), ...makeProfileTools(), ...makeRateTools(), ...makeLangTools(), ...makeSocialsTools(), ...makePortfolioTools(), ...makeAlmaTools()];
72
- const tools = localOnly ? allTools.filter((t) => LOCAL_ONLY_TOOL_NAMES.has(t.name)) : allTools;
74
+ const allTools = [aiUsage.usage, aiUsage.upload, makeAiUsageResultTool(), ...makeOnboardingTools(), ...makeRoleTools({ lang, funnel }), ...makeDeviceLoginTools({ lang, funnel }), ...makeSignupTools({ lang, funnel }), ...makeRegisterTools({ lang, funnel, flowDeps: { makeInterviewClient } }), ...makeReportTools(), ...makeCertifyDimensionTools(), ...makeFindProjectsTools(), ...makeShowProjectTools(), ...makeSavedPositionsTools(), ...makeInvitationsTools(), ...makeApplicationsTools(), ...makeAvailabilityTools(), ...makeCertificationsTools(), ...makeProfileTools(), ...makeRateTools(), ...makeLangTools(), ...makeSocialsTools(), ...makePortfolioTools(), ...makeAlmaTools()];
75
+ // The sign-up runs through one tool (`signup`), so Claude Desktop asks the talent once instead of once per step.
76
+ const tools = localOnly
77
+ ? allTools.filter((t) => LOCAL_ONLY_TOOL_NAMES.has(t.name))
78
+ : [makeSignupTool(allTools), ...allTools.filter((t) => !ABSORBED_TOOLS.has(t.name)).map(outsideSignup)];
73
79
  // Slash-command prompts, kept to the tools actually registered in this mode.
74
80
  const prompts = makePrompts(new Set(tools.map((t) => t.name)), lang);
75
81
  return createMcpServer({
@@ -77,10 +83,14 @@ function buildServer({ localOnly = false, funnel = createFunnel({ surface: 'mcp'
77
83
  prompts,
78
84
  serverInfo: { name: localOnly ? 'Shakers (local scan)' : 'Shakers', version: VERSION || '0.0.0' },
79
85
  instructions: localOnly ? null : (client) => buildServerInstructions(client),
80
- // Long-lived server: refresh the email session's hub JWT from the stored
81
- // cookie before each tool call so a stale (~15-min) JWT never reaches a tool.
82
86
  beforeCall: () => ensureFreshSession(process.env),
83
- afterCall: () => { funnel.flush(); },
87
+ afterCall: (name) => {
88
+ funnel.flush();
89
+ ensureFreshSession(process.env)
90
+ .catch(() => null)
91
+ .then(() => emitCommandAudit({ command: `mcp:${name}`, env: process.env }));
92
+ },
93
+ narrate: narrateTool(lang),
84
94
  onInitialize: ({ clientInfo }) => funnel.setMcpClient(clientInfo.name),
85
95
  });
86
96
  }
@@ -122,7 +132,7 @@ async function run(argv = process.argv.slice(2), { makeInterviewClient = null }
122
132
  await funnel.settle();
123
133
  }
124
134
 
125
- module.exports = { run, buildServer, buildServerInstructions, prepareProcess };
135
+ module.exports = { run, buildServer, buildServerInstructions, prepareProcess, narrateTool };
126
136
 
127
137
  if (require.main === module) {
128
138
  run();
package/bin/register.js CHANGED
@@ -2,9 +2,7 @@
2
2
  'use strict';
3
3
 
4
4
  const { detectFlowLang, getCatalog } = require('../src/i18n');
5
- const { loadAuthSession, sessionStatus } = require('../src/auth-session-store');
6
5
  const { createStdinAsk } = require('../src/stdin-ask');
7
- const { run: runLogin } = require('./login');
8
6
  const { run: runAiUsage } = require('./ai-usage');
9
7
  const {
10
8
  runOnboarding, makeDeps, CURRENCIES, MONTHLY_HOURS, normalizeAmount,
@@ -20,6 +18,7 @@ const { BRAND_ANSI } = require('../src/brand-ansi');
20
18
  const { typeOut } = require('../src/typewriter');
21
19
  const { withSpinner } = require('../src/terminal-progress');
22
20
  const { openPath } = require('../src/open-file');
21
+ const { termsText, legalCopy } = require('../src/signup-copy');
23
22
  const { c: fmt } = require('../src/terminal-format');
24
23
  const { createFunnel } = require('../src/onboarding-funnel');
25
24
  const red = (s) => `${fmt.danger}${s}${fmt.reset}`;
@@ -151,26 +150,11 @@ function makeIo({ ask, lang, out, acceptDisclaimer, stdinIsTTY = false }) {
151
150
  const telephoneNumber = (await ask(` ${c.phoneNumberAsk} `)).trim();
152
151
  return { available, monthlyHours, workModes, country, subdivision, timezone, longFullTimeProjects, telephoneCode, telephoneNumber };
153
152
  },
154
- askSignUp: async (c) => {
155
- const name = (await ask(` ${c.askName} `)).trim();
156
- const lastName = (await ask(` ${c.askLastName} `)).trim();
157
- const email = (await ask(` ${c.askEmail} `)).trim();
158
- const askSecret = (prompt) => (typeof ask.secret === 'function' ? ask.secret(` ${prompt} `) : ask(` ${prompt} `).then((v) => v.trim()));
159
- let password;
160
- for (;;) {
161
- password = await askSecret(c.askPassword);
162
- const again = await askSecret(c.askPasswordConfirm);
163
- if (password === again) break;
164
- writeErr(c.passwordMismatch);
165
- }
166
- const newsletterConsent = isYes(await ask(` ${c.askNewsletter} `));
167
- // freelanceType is NOT asked here anymore: it is derived from the work
168
- // situation (asked once, after the sources) and sent via professional-details.
169
- return { name, lastName, email, password, newsletterConsent };
170
- },
171
- askGoogleSignUp: async () => {
172
- // Nothing to ask: Google supplies name/email; freelanceType is derived later.
173
- return {};
153
+ askTerms: async (c) => {
154
+ out(`\n ${termsText(lang, (label, url) => `${label} (${url})`)}\n`);
155
+ if (!isYes(await ask(` ${c.termsAsk} `))) return { accepted: false };
156
+ const newsletterConsent = isYes(await ask(` ${c.newsletterAsk(legalCopy(lang).newsletter)} `));
157
+ return { accepted: true, newsletterConsent };
174
158
  },
175
159
  askWorkSituation: async (c) => {
176
160
  const situation = await promptSelect({
@@ -242,13 +226,9 @@ function makeIo({ ask, lang, out, acceptDisclaimer, stdinIsTTY = false }) {
242
226
  }
243
227
  return rows;
244
228
  },
245
- onGoogleAuthUrl: (url, userCode) => {
229
+ onAuthUrl: (url, userCode) => {
246
230
  const g = getCatalog(lang).login;
247
- out(`\n ${g.deviceVisit}\n ${url}\n\n ${g.deviceCodeLabel(userCode)}\n\n ${g.deviceWaiting}\n`);
248
- },
249
- login: async () => {
250
- await runLogin(['--lang', lang], { ask });
251
- return sessionStatus(loadAuthSession()) === 'active';
231
+ out(`\n ${g.deviceTrust}\n\n ${g.deviceVisit}\n ${url}\n\n ${g.deviceCodeLabel(userCode)}\n\n ${g.deviceWaiting}\n`);
252
232
  },
253
233
  // Registration always measures the whole machine (--all-repos).
254
234
  runUsage: async () => { await runAiUsage(['--lang', lang, '--all-repos'], { ask, bypassThrottle: true, inRegistration: true }); },
@@ -332,7 +312,8 @@ async function run(
332
312
  const io = makeIo({ ask, lang, out: write, acceptDisclaimer: o.acceptDisclaimer, stdinIsTTY });
333
313
  // At a terminal the profile opens in the browser at the end; piped runs, tests and --no-open only print it.
334
314
  const openProfile = stdinIsTTY && !o.noOpen ? (openBrowser || openPath) : undefined;
335
- const result = await runOnboarding(io, deps || makeDeps({ makeInterviewClient: createInterviewClient }), { method, openBrowser, onAuthUrl: io.onGoogleAuthUrl, openProfile, funnel: registrationFunnel });
315
+ const openAuth = openBrowser || (deps || o.noOpen ? undefined : openPath);
316
+ const result = await runOnboarding(io, deps || makeDeps({ makeInterviewClient: createInterviewClient }), { method, openBrowser: openAuth, onAuthUrl: io.onAuthUrl, openProfile, funnel: registrationFunnel });
336
317
  if (!result.ok) {
337
318
  process.stderr.write(`\n ${red(`register: ${result.reason}${result.step ? ` (${result.step})` : ''}`)}\n\n`);
338
319
  process.exitCode = 1;
@@ -37,9 +37,10 @@ const { run: runOnboarding } = require('./onboarding');
37
37
  const { run: runConfig } = require('./config');
38
38
  const { run: runUpdate } = require('./update');
39
39
  const { checkOnboardingCompleted } = require('../src/onboarding-availability');
40
- const { emitCommandAudit } = require('../src/command-audit');
40
+ const { startCommandAudit } = require('../src/command-audit');
41
41
  const { readPackageInfo } = require('../src/update-flow');
42
42
  const { isSuppressed, maybeNotify, triggerBackgroundCheck } = require('../src/update-notifier');
43
+ const { cliNarration, changedNothing } = require('../src/trust-narration');
43
44
 
44
45
  let VERSION = '';
45
46
  try {
@@ -93,9 +94,10 @@ const HANDLERS = {
93
94
  logout: runLogout,
94
95
  superadmin: runSuperadmin,
95
96
  mcp: (args) => require('../src/mcp-install').runMcpInstall({ args }),
97
+ skill: (args) => require('../src/skill-install').runSkillInstall({ args }),
96
98
  };
97
99
 
98
- const SESSION_EXEMPT = new Set(['ai-usage', 'register', 'config', 'login', 'logout', 'superadmin', 'mcp', 'share', 'alma', 'update']);
100
+ const SESSION_EXEMPT = new Set(['ai-usage', 'register', 'config', 'login', 'logout', 'superadmin', 'mcp', 'skill', 'share', 'alma', 'update']);
99
101
 
100
102
  // Command FAMILIES — a navigation layer over the flat commands (which all stay
101
103
  // valid as aliases). `shakers <family> <sub>` resolves to the flat handler; every
@@ -178,7 +180,8 @@ function usageText(lang, { hideOnboarding = false } = {}) {
178
180
  + ` Público (sin sesión):\n`
179
181
  + ` ai-usage Escanea este proyecto + tu máquina y puntúa tu setup de IA\n`
180
182
  + ` register Registro completo: perfil, precio, uso de IA y entrevista\n`
181
- + ` mcp Servidor MCP local (stdio) para apps de IA de escritorio\n\n`
183
+ + ` mcp Servidor MCP local (stdio) para apps de IA de escritorio\n`
184
+ + ` skill install Instala la skill shakers-cli en Claude Code y Codex\n\n`
182
185
  + ` Requieren sesión (ejecuta antes: shakers login):\n`
183
186
  + onboardingLine
184
187
  + ` certify Certifica una dimensión de tu rol (entrevista por LiveKit)\n`
@@ -222,7 +225,8 @@ function usageText(lang, { hideOnboarding = false } = {}) {
222
225
  + ` Public (no session):\n`
223
226
  + ` ai-usage Scan this project + machine and score your AI setup\n`
224
227
  + ` register Full registration: profile, pricing, AI usage and interview\n`
225
- + ` mcp Local MCP server (stdio) for desktop AI apps\n\n`
228
+ + ` mcp Local MCP server (stdio) for desktop AI apps\n`
229
+ + ` skill install Install the shakers-cli skill into Claude Code and Codex\n\n`
226
230
  + ` Require a session (run \`shakers login\` first):\n`
227
231
  + onboardingLine
228
232
  + ` certify Certify a dimension of your role (LiveKit interview)\n`
@@ -287,15 +291,13 @@ async function main() {
287
291
 
288
292
  migrateLegacyConfigDir(process.env);
289
293
  sanitizeStaleBakedEndpoints(process.env);
290
- // Mint a fresh hub JWT from the stored cookie once per command (email sessions only; a no-op for Google/legacy or when no session exists).
291
294
  try { await ensureFreshSession(process.env); } catch { /* never blocks a command */ }
292
295
 
293
296
  const token = argv.find((a) => !a.startsWith('-'));
294
297
 
295
- // Single dispatch choke-point: audit EXACTLY once before branching, fire-and-forget (never awaited). MCP tool calls run in-process and never reach here.
296
298
  const auditCommand =
297
299
  !token || token.toLowerCase() === 'help' ? 'help' : token.toLowerCase();
298
- emitCommandAudit({ command: auditCommand, argv, env: process.env });
300
+ startCommandAudit({ command: auditCommand, argv, env: process.env });
299
301
 
300
302
  // Non-blocking update notice: reads a cached version synchronously, registry check runs in a detached worker; stderr-only, suppressed for --json/non-TTY/CI/update/opt-out.
301
303
  if (!isSuppressed({ argv, command: auditCommand, env: process.env })) {
@@ -346,7 +348,10 @@ async function main() {
346
348
  await remindAccountForAiUsage(lang, args);
347
349
  }
348
350
 
349
- await handler(args);
351
+ const narration = wantsHelp || args.includes('--json') ? { before: null, after: null } : cliNarration(command, args, lang);
352
+ if (narration.before) process.stderr.write(`\n ${narration.before}\n`);
353
+ const outcome = await handler(args);
354
+ if (narration.after && !process.exitCode && !changedNothing(outcome)) process.stderr.write(`\n ${narration.after}\n\n`);
350
355
  }
351
356
 
352
- module.exports = { main };
357
+ module.exports = { main, HANDLERS };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shakers-cli",
3
- "version": "0.17.0",
3
+ "version": "0.18.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": {
@@ -54,6 +54,7 @@ async function requestListAgents({ accessToken, hubAccessToken } = {}, { endpoin
54
54
  .map((entry) => ({
55
55
  name: entry && typeof entry.name === 'string' ? entry.name : null,
56
56
  id: entry && (typeof entry.id === 'string' || typeof entry.id === 'number') ? String(entry.id) : null,
57
+ source: entry && typeof entry.source === 'string' ? entry.source : null,
57
58
  }))
58
59
  .filter((a) => a.name !== null);
59
60
  return { ok: true, agents };
@@ -83,9 +83,11 @@ function explanationText(lang) {
83
83
  return [c.explanationTitle, ...c.explanation, c.afterwards].join('\n\n');
84
84
  }
85
85
 
86
- function resultText(lang, preview) {
86
+ // `explained`: the talent already read what the two axes mean and how to disagree (the review in the sign-up), so they are not repeated.
87
+ function resultText(lang, preview, { explained = false } = {}) {
87
88
  const c = reviewCopy(lang);
88
- if (!preview) return [c.resultPending, c.afterwards].join('\n\n');
89
+ const after = explained ? [] : [c.afterwards];
90
+ if (!preview) return [c.resultPending, ...after].join('\n\n');
89
91
  const m = preview.matrix || {};
90
92
  const lines = [
91
93
  c.resultTitle,
@@ -95,7 +97,7 @@ function resultText(lang, preview) {
95
97
  preview.usage && preview.usage.explanation,
96
98
  m.cell ? c.cell(m.cell) : null,
97
99
  ].filter(Boolean).map(sanitizeRenderText);
98
- return [lines.join('\n'), c.explanation[0], c.afterwards].join('\n\n');
100
+ return [lines.join('\n'), ...(explained ? [] : [c.explanation[0]]), ...after].join('\n\n');
99
101
  }
100
102
 
101
103
  module.exports = { uploadFacts, withoutExcluded, repoName, summaryText, explanationText, resultText };
@@ -1,15 +1,9 @@
1
1
  'use strict';
2
2
 
3
- const { postJsonWithTimeout, extractSetCookie } = require('./backend-request');
4
-
5
- // Login / token acquisition against the Hub's better-auth email engine.
3
+ const { postJsonWithTimeout } = require('./backend-request');
6
4
 
7
5
  const DEFAULT_TIMEOUT_MS = 20000;
8
6
  const SIGN_OUT_TIMEOUT_MS = 3000;
9
- const SESSION_COOKIE_NAME = 'better-auth.session_token';
10
- // Refresh the cookie session's own validity from the Set-Cookie Max-Age; fall
11
- // back to 7 days (better-auth's default) when the attribute is absent.
12
- const DEFAULT_COOKIE_TTL_SECONDS = 7 * 24 * 60 * 60;
13
7
 
14
8
  function parseJson(raw) {
15
9
  try { return raw ? JSON.parse(raw) : null; } catch { return null; }
@@ -28,71 +22,10 @@ function expiresAtFromJwt(token) {
28
22
  }
29
23
  }
30
24
 
31
- // ISO expiry for the session cookie, read from the Set-Cookie `Max-Age` (or
32
- // `Expires`) of the named cookie; defaults to +7 days when neither is present.
33
- function cookieExpiresAtFromHeaders(headers, name = SESSION_COOKIE_NAME) {
34
- const raw = headers && headers['set-cookie'];
35
- const list = Array.isArray(raw) ? raw : (typeof raw === 'string' && raw ? [raw] : []);
36
- // Match the base name with or without better-auth's HTTPS `__Secure-`/`__Host-` prefixes.
37
- const names = [name, `__Secure-${name}`, `__Host-${name}`];
38
- const entry = list.find((e) => typeof e === 'string' && names.some((n) => e.startsWith(`${n}=`)));
39
- if (entry) {
40
- const maxAge = /(?:^|;\s*)max-age=(\d+)/i.exec(entry);
41
- if (maxAge) return new Date(Date.now() + Number(maxAge[1]) * 1000).toISOString();
42
- const expires = /(?:^|;\s*)expires=([^;]+)/i.exec(entry);
43
- if (expires) {
44
- const t = Date.parse(expires[1]);
45
- if (Number.isFinite(t)) return new Date(t).toISOString();
46
- }
47
- }
48
- return new Date(Date.now() + DEFAULT_COOKIE_TTL_SECONDS * 1000).toISOString();
49
- }
50
-
51
- function normalizeResponse(status, raw) {
52
- if (status >= 200 && status < 300) {
53
- let json = null;
54
- try { json = raw ? JSON.parse(raw) : null; } catch { json = null; }
55
- const payload = json && typeof json.data === 'object' && json.data !== null ? json.data : json;
56
- const accessToken = payload && typeof payload.accessToken === 'string' && payload.accessToken ? payload.accessToken : null;
57
- if (!accessToken) return { ok: false, reason: 'bad-response', status };
58
- const expiresAt = payload && typeof payload.expiresAt === 'string' && payload.expiresAt ? payload.expiresAt : null;
59
- const emailRaw = payload && (payload.userEmail || payload.email);
60
- const email = typeof emailRaw === 'string' && emailRaw ? emailRaw : null;
61
- const hubAccessToken = payload && typeof payload.hubAccessToken === 'string' && payload.hubAccessToken ? payload.hubAccessToken : null;
62
- return { ok: true, accessToken, expiresAt, email, hubAccessToken };
63
- }
64
- if (status === 401) return { ok: false, reason: 'invalid-credentials', status };
65
- if (status === 409 || status === 422) return { ok: false, reason: 'no-email-password', status };
66
- if (status === 502 || status === 503) return { ok: false, reason: 'upstream', status };
67
- return { ok: false, reason: 'http-error', status };
68
- }
69
-
70
- // Maps the sign-in POST outcome to a captured cookie + email, or a named reason.
71
- function normalizeSignIn(status, raw, headers) {
72
- if (status >= 200 && status < 300) {
73
- const cookie = extractSetCookie(headers, SESSION_COOKIE_NAME);
74
- if (!cookie) return { ok: false, reason: 'bad-response', status };
75
- const body = parseJson(raw);
76
- const user = body && typeof body === 'object' ? body.user : null;
77
- const emailRaw = user && typeof user.email === 'string' ? user.email : null;
78
- return {
79
- ok: true,
80
- cookie,
81
- cookieExpiresAt: cookieExpiresAtFromHeaders(headers),
82
- email: emailRaw || null,
83
- };
84
- }
85
- if (status === 401) return { ok: false, reason: 'invalid-credentials', status };
86
- if (status === 409 || status === 422) return { ok: false, reason: 'no-email-password', status };
87
- if (status === 502 || status === 503) return { ok: false, reason: 'upstream', status };
88
- return { ok: false, reason: 'http-error', status };
89
- }
90
-
91
- // Mints a hub JWT from a better-auth session cookie: `GET {tokenEndpoint}` with the `Cookie` header.
92
- async function requestAuthToken({ cookie, sessionToken } = {}, { endpoint, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
25
+ async function requestAuthToken({ sessionToken } = {}, { endpoint, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
93
26
  if (!endpoint) return { ok: false, reason: 'no-endpoint' };
94
- if (!cookie && !sessionToken) return { ok: false, reason: 'no-cookie' };
95
- const credential = cookie ? { Cookie: cookie } : { Authorization: `Bearer ${sessionToken}` };
27
+ if (!sessionToken) return { ok: false, reason: 'no-session-token' };
28
+ const credential = { Authorization: `Bearer ${sessionToken}` };
96
29
  let res;
97
30
  try {
98
31
  res = await postJsonWithTimeout(endpoint, null, timeoutMs, 'GET', null, credential);
@@ -106,38 +39,9 @@ async function requestAuthToken({ cookie, sessionToken } = {}, { endpoint, timeo
106
39
  return { ok: true, accessToken: token, expiresAt: expiresAtFromJwt(token) };
107
40
  }
108
41
 
109
- // The full email login: sign-in (capture cookie) -> mint JWT.
110
- async function requestLogin(
111
- { email, password },
112
- { signInEndpoint, tokenEndpoint, timeoutMs = DEFAULT_TIMEOUT_MS } = {},
113
- ) {
114
- if (!signInEndpoint) return { ok: false, reason: 'no-endpoint' };
115
- let signIn;
116
- try {
117
- const { status, raw, headers } = await postJsonWithTimeout(signInEndpoint, { email, password }, timeoutMs);
118
- signIn = normalizeSignIn(status, raw, headers);
119
- } catch (e) {
120
- return { ok: false, reason: (e && e.kind) || 'local-error' };
121
- }
122
- if (!signIn.ok) return signIn;
123
-
124
- const tok = await requestAuthToken({ cookie: signIn.cookie }, { endpoint: tokenEndpoint, timeoutMs });
125
- if (!tok.ok) return { ok: false, reason: 'token-fetch-failed', status: tok.status || null };
126
-
127
- return {
128
- ok: true,
129
- accessToken: tok.accessToken,
130
- hubAccessToken: tok.accessToken,
131
- expiresAt: tok.expiresAt,
132
- email: signIn.email || (typeof email === 'string' ? email : null),
133
- cookie: signIn.cookie,
134
- cookieExpiresAt: signIn.cookieExpiresAt,
135
- };
136
- }
137
-
138
- async function requestSignOut({ cookie, sessionToken } = {}, { endpoint, origin = null, timeoutMs = SIGN_OUT_TIMEOUT_MS } = {}) {
139
- if (!endpoint || (!cookie && !sessionToken)) return { ok: false, reason: 'no-credential' };
140
- const credential = cookie ? { Cookie: cookie, ...(origin ? { Origin: origin } : {}) } : { Authorization: `Bearer ${sessionToken}` };
42
+ async function requestSignOut({ sessionToken } = {}, { endpoint, timeoutMs = SIGN_OUT_TIMEOUT_MS } = {}) {
43
+ if (!endpoint || !sessionToken) return { ok: false, reason: 'no-credential' };
44
+ const credential = { Authorization: `Bearer ${sessionToken}` };
141
45
  try {
142
46
  const res = await postJsonWithTimeout(endpoint, {}, timeoutMs, 'POST', null, credential);
143
47
  return { ok: res.status >= 200 && res.status < 300, status: res.status };
@@ -148,11 +52,7 @@ async function requestSignOut({ cookie, sessionToken } = {}, { endpoint, origin
148
52
 
149
53
  module.exports = {
150
54
  requestSignOut,
151
- requestLogin,
152
55
  requestAuthToken,
153
- normalizeSignIn,
154
- normalizeResponse,
155
56
  expiresAtFromJwt,
156
- SESSION_COOKIE_NAME,
157
57
  DEFAULT_TIMEOUT_MS,
158
58
  };
@@ -89,12 +89,11 @@ function saveAuthSession(session, env = process.env, { fs: fsImpl = fs } = {}) {
89
89
  const explicitHubToken = typeof session.hubAccessToken === 'string' && session.hubAccessToken ? session.hubAccessToken : null;
90
90
  const hubAccessToken = explicitHubToken || (typeof session.accessToken === 'string' && session.accessToken ? session.accessToken : null);
91
91
 
92
- const cookie = typeof session.cookie === 'string' && session.cookie ? session.cookie : null;
93
92
  const sessionToken = typeof session.sessionToken === 'string' && session.sessionToken ? session.sessionToken : null;
94
93
  let expiresAt;
95
94
  let accessTokenExpiresAt;
96
- if (cookie || sessionToken) {
97
- expiresAt = [session.cookieExpiresAt, session.sessionExpiresAt, session.expiresAt]
95
+ if (sessionToken) {
96
+ expiresAt = [session.sessionExpiresAt, session.expiresAt]
98
97
  .find((v) => typeof v === 'string' && v) || expiresAtFromToken(session.accessToken);
99
98
  accessTokenExpiresAt = (typeof session.accessTokenExpiresAt === 'string' && session.accessTokenExpiresAt)
100
99
  ? session.accessTokenExpiresAt
@@ -109,7 +108,6 @@ function saveAuthSession(session, env = process.env, { fs: fsImpl = fs } = {}) {
109
108
  accessToken: typeof session.accessToken === 'string' && session.accessToken ? session.accessToken : null,
110
109
  expiresAt: expiresAt ?? null,
111
110
  accessTokenExpiresAt: accessTokenExpiresAt ?? null,
112
- cookie,
113
111
  sessionToken,
114
112
  email: (typeof session.email === 'string' && session.email ? session.email : null)
115
113
  || emailFromHubToken(hubAccessToken),
@@ -142,7 +140,7 @@ function saveAuthSession(session, env = process.env, { fs: fsImpl = fs } = {}) {
142
140
  try { fsImpl.unlinkSync(tmp); } catch { /* never existed */ }
143
141
  throw e;
144
142
  }
145
- recordAccountMarker(cookie ? 'email' : sessionToken ? 'device' : 'token', env, { fs: fsImpl });
143
+ recordAccountMarker(sessionToken ? 'device' : 'token', env, { fs: fsImpl });
146
144
  return target;
147
145
  }
148
146
 
@@ -156,19 +154,16 @@ function loadAuthSession(env = process.env, { fs: fsImpl = fs } = {}) {
156
154
  }
157
155
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
158
156
  if (parsed.version !== SESSION_FILE_VERSION) return null;
159
- // A usable session needs at least ONE credential: the durable cookie (email/
160
- // better-auth) or a JWT (Google/legacy, or an email session whose JWT is cached).
161
- const hasCookie = typeof parsed.cookie === 'string' && parsed.cookie;
162
157
  const hasToken = typeof parsed.accessToken === 'string' && parsed.accessToken;
163
158
  const sessionToken = typeof parsed.sessionToken === 'string' && parsed.sessionToken ? parsed.sessionToken : null;
164
- if (!hasCookie && !hasToken && !sessionToken) return null;
159
+ if (typeof parsed.cookie === 'string' && parsed.cookie && !sessionToken) return null;
160
+ if (!hasToken && !sessionToken) return null;
165
161
  const hubAccessToken = typeof parsed.hubAccessToken === 'string' && parsed.hubAccessToken ? parsed.hubAccessToken : null;
166
162
  const email = (typeof parsed.email === 'string' && parsed.email ? parsed.email : null) || emailFromHubToken(hubAccessToken);
167
163
  return {
168
164
  accessToken: hasToken ? parsed.accessToken : null,
169
165
  expiresAt: typeof parsed.expiresAt === 'string' ? parsed.expiresAt : null,
170
166
  accessTokenExpiresAt: typeof parsed.accessTokenExpiresAt === 'string' ? parsed.accessTokenExpiresAt : null,
171
- cookie: hasCookie ? parsed.cookie : null,
172
167
  sessionToken,
173
168
  email,
174
169
  hubAccessToken,
@@ -193,8 +188,7 @@ function clearAuthSession(env = process.env, { fs: fsImpl = fs } = {}) {
193
188
 
194
189
  // `none | active | expired`, the three states the caller needs to give the Talent a true message.
195
190
  function sessionStatus(session, now = Date.now()) {
196
- // A credential is a durable cookie or session token, or a JWT (Google/legacy).
197
- const hasCred = !!(session && [session.accessToken, session.cookie, session.sessionToken].some((v) => typeof v === 'string' && v));
191
+ const hasCred = !!(session && [session.accessToken, session.sessionToken].some((v) => typeof v === 'string' && v));
198
192
  if (!hasCred) return 'none';
199
193
  const expiry = Date.parse(String(session.expiresAt || ''));
200
194
  if (!Number.isFinite(expiry)) return 'expired';
@@ -80,8 +80,7 @@ function postJsonWithTimeout(url, body, timeoutMs, method = 'POST', idempotencyK
80
80
  raw += chunk;
81
81
  if (raw.length > MAX_RESPONSE_BYTES) req.destroy();
82
82
  });
83
- // `headers` is exposed so the better-auth email flow can read `Set-Cookie` (the session cookie it captures and replays); every other caller reads only `{status, raw}` and ignores it.
84
- res.on('end', () => resolve({ status: res.statusCode, raw, headers: res.headers }));
83
+ res.on('end', () => resolve({ status: res.statusCode, raw }));
85
84
  },
86
85
  );
87
86
  req.on('timeout', () => req.destroy(Object.assign(new Error('request timed out'), { kind: 'timeout' })));
@@ -91,27 +90,6 @@ function postJsonWithTimeout(url, body, timeoutMs, method = 'POST', idempotencyK
91
90
  });
92
91
  }
93
92
 
94
- // Pulls one `name=value` cookie pair out of a response's `Set-Cookie` header (Node exposes it as an array, or a single string).
95
- // Accepts the base cookie name with or without the RFC6265 `__Secure-`/`__Host-`
96
- // prefixes that better-auth applies over HTTPS, and returns the EXACT server-set
97
- // name (prefix included) so it can be forwarded back verbatim in a `Cookie:` header.
98
- function extractSetCookie(headers, name) {
99
- if (!headers) return null;
100
- const raw = headers['set-cookie'];
101
- const list = Array.isArray(raw) ? raw : (typeof raw === 'string' && raw ? [raw] : []);
102
- const candidates = [name, `__Secure-${name}`, `__Host-${name}`];
103
- for (const candidate of candidates) {
104
- const prefix = `${candidate}=`;
105
- for (const entry of list) {
106
- if (typeof entry === 'string' && entry.startsWith(prefix)) {
107
- const value = entry.slice(prefix.length).split(';')[0];
108
- if (value) return `${candidate}=${value}`;
109
- }
110
- }
111
- }
112
- return null;
113
- }
114
-
115
93
  function postMultipartWithTimeout(url, fields, timeoutMs, method = 'POST', extraHeaders = null) {
116
94
  return new Promise((resolve, reject) => {
117
95
  let u;
@@ -204,7 +182,6 @@ module.exports = {
204
182
  MAX_RESPONSE_BYTES,
205
183
  postJsonWithTimeout,
206
184
  postMultipartWithTimeout,
207
- extractSetCookie,
208
185
  requestBackend,
209
186
  setClientIdentity,
210
187
  };