shakers-cli 0.16.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 (69) hide show
  1. package/README.md +21 -6
  2. package/bin/ai-usage.js +31 -12
  3. package/bin/command-audit-worker.js +19 -0
  4. package/bin/login.js +4 -8
  5. package/bin/mcp.js +32 -21
  6. package/bin/register.js +12 -30
  7. package/bin/shakers-cli.js +15 -10
  8. package/package.json +1 -1
  9. package/src/account-marker.js +40 -0
  10. package/src/account-trace.js +23 -0
  11. package/src/agents-client.js +1 -0
  12. package/src/ai-usage-review.js +103 -0
  13. package/src/auth-client.js +7 -107
  14. package/src/auth-session-store.js +12 -11
  15. package/src/backend-request.js +3 -24
  16. package/src/cli-skill.js +77 -0
  17. package/src/command-audit.js +47 -4
  18. package/src/config.js +2 -16
  19. package/src/consent-flow.js +9 -3
  20. package/src/detect-repos.js +4 -8
  21. package/src/detectors.js +23 -0
  22. package/src/device-auth-store.js +2 -2
  23. package/src/device-login.js +11 -9
  24. package/src/env-paths.js +9 -1
  25. package/src/i18n.js +186 -102
  26. package/src/mcp-ai-usage-result-tool.js +38 -14
  27. package/src/mcp-ai-usage-tool.js +317 -172
  28. package/src/mcp-choice.js +171 -34
  29. package/src/mcp-detector.js +26 -2
  30. package/src/mcp-device-login-tools.js +40 -8
  31. package/src/mcp-install.js +292 -53
  32. package/src/mcp-instructions.js +43 -17
  33. package/src/mcp-onboarding-tools.js +1 -3
  34. package/src/mcp-prompts.js +8 -6
  35. package/src/mcp-register-tools.js +25 -14
  36. package/src/mcp-role-tools.js +59 -18
  37. package/src/mcp-server-name.js +7 -0
  38. package/src/mcp-server.js +35 -7
  39. package/src/mcp-signup-client.js +3 -6
  40. package/src/mcp-signup-flow.js +389 -0
  41. package/src/mcp-signup-tools.js +250 -133
  42. package/src/onboarding-flow.js +72 -146
  43. package/src/onboarding-funnel.js +46 -6
  44. package/src/opencode-sessions.js +92 -0
  45. package/src/profile-agents.js +26 -0
  46. package/src/render-report-text.js +1 -1
  47. package/src/report-gating.js +4 -2
  48. package/src/report-store.js +7 -2
  49. package/src/roles-flow.js +8 -5
  50. package/src/saved-positions-flow.js +3 -3
  51. package/src/scanner.js +104 -12
  52. package/src/session-gate.js +1 -4
  53. package/src/session-normalizers.js +114 -8
  54. package/src/session-refresh.js +5 -16
  55. package/src/session-scan.js +167 -22
  56. package/src/share-consent-state.js +2 -2
  57. package/src/share-payload.js +4 -1
  58. package/src/share.js +2 -2
  59. package/src/signup-client.js +12 -39
  60. package/src/signup-copy.js +545 -58
  61. package/src/skill-install.js +102 -0
  62. package/src/stdin-ask.js +13 -1
  63. package/src/tier-engine.js +14 -2
  64. package/src/trust-narration.js +32 -0
  65. package/src/usage-run-steps.js +68 -6
  66. package/src/usage-scan.js +11 -8
  67. package/src/brand-svg.js +0 -9
  68. package/src/inter-fonts.js +0 -6
  69. package/src/mcp-auth-form.js +0 -480
package/src/mcp-choice.js CHANGED
@@ -1,8 +1,16 @@
1
1
  'use strict';
2
2
 
3
- // Closed questions of the MCP sign-up: a dialog when the client can elicit, otherwise one block the model prints, question last.
3
+ const { signupCopy, legalCopy, termsText } = require('./signup-copy');
4
+ const { CHOICE_RULE, QUESTION_RULE, OPEN_RULE, nextSayRule } = require('./mcp-instructions');
4
5
 
5
- const SAY_MESSAGE = 'Print `say` word for word as your whole reply: it already holds the texts, the question and its options as a list. Present them as a choice and pass back the exact label the talent picks, never a number; add nothing of your own and call no tool until the talent answers.';
6
+ // Closed questions of the MCP sign-up: a dialog when the client can elicit; otherwise a step whose text the model
7
+ // already wrote (handed over one answer earlier as `nextSay`) goes to the chat's question tool as `question` and
8
+ // `options`, and a step whose text only exists now hands it over whole as `say`, the question and its options numbered
9
+ // at its end, and the talent answers by typing.
10
+
11
+ const CHOICE_MESSAGE = CHOICE_RULE;
12
+ const QUESTION_ONLY_MESSAGE = `${QUESTION_RULE} Call no other tool until the talent answers, and pass back the option they pick (or what they type).`;
13
+ const OPEN_MESSAGE = OPEN_RULE;
6
14
 
7
15
  // Fixed texts that are not a question ride on the next question block: a model skips a standalone relay.
8
16
  const notices = [];
@@ -15,55 +23,136 @@ function takeNotices() {
15
23
  return notices.splice(0);
16
24
  }
17
25
 
18
- // Fixed texts travel with their question, so a model that skips "show this first" still shows them.
19
- function sayBlock(texts, question, options) {
20
- return [...texts, question, options.map((o) => `- ${o}`).join('\n')].join('\n\n');
26
+ // Texts handed to the model before the turn whose question they belong to: it writes them first in that turn, before
27
+ // any call, the only place a client shows them above its question tool. Each one keeps the question it was handed with
28
+ // (`from`) and the picks that lead to it, and counts as written only once the talent made one of those picks.
29
+ let handed = [];
30
+
31
+ function handOver(from, { step, ifAnswer, texts }) {
32
+ const text = texts.join('\n\n');
33
+ handed = [...handed.filter((h) => h.text !== text), { text, from, ifAnswer, picked: false }];
34
+ return { step, ifAnswer, text };
35
+ }
36
+
37
+ // The talent answered `from` with `picked`: a text handed for that pick was written, one for another pick was not.
38
+ function settle(from, picked) {
39
+ handed = handed
40
+ .filter((h) => h.from !== from || h.ifAnswer.includes(picked))
41
+ .map((h) => (h.from === from ? { ...h, picked: true } : h));
42
+ }
43
+
44
+ // Whether the model wrote this text at the start of the talent's turn.
45
+ function written(text) {
46
+ return handed.some((h) => h.picked && h.text === text);
47
+ }
48
+
49
+ // The next question the model asks closes the turn the handed texts were written in: they cover that question only.
50
+ function closeTurn() {
51
+ handed = [];
21
52
  }
22
53
 
23
- function chatChoice(question, options, texts = []) {
24
- return { say: sayBlock([...takeNotices(), ...texts], question, options), options, choiceMessage: SAY_MESSAGE };
54
+ // A reply that hands over the next step's text, with what the model does with it; a list when two answers lead to two texts.
55
+ function withNextSay(reply, nextSay) {
56
+ if (!nextSay) return reply;
57
+ if (!Array.isArray(nextSay)) return { ...reply, nextSay, message: `${reply.message} ${nextSayRule(nextSay.ifAnswer)}` };
58
+ const rules = nextSay.map((n, i) => nextSayRule(n.ifAnswer, `nextSay[${i}].text`));
59
+ return { ...reply, nextSay, message: [reply.message, ...rules].join(' ') };
25
60
  }
26
61
 
27
- // The option an answer names: by its label, or by a position number if the talent still types one.
62
+ const numbered = (items) => items.map((item, i) => `${i + 1}. ${item}`).join('\n');
63
+
64
+ // What the talent has not read yet goes first, then the question and its options numbered; with `listed`, each option
65
+ // is numbered with its own block, the texts after the list follow it and the question comes last. A question whose
66
+ // texts the model already wrote goes alone to the question tool.
67
+ function chatChoice(question, options, texts = [], listed = null, queued = takeNotices()) {
68
+ const fresh = (blocks) => (blocks.length && written(blocks.join('\n\n')) ? [] : blocks);
69
+ const own = [...new Set([...fresh(queued), ...fresh(texts)])];
70
+ closeTurn();
71
+ if (!own.length && !listed) return { question, options, choiceMessage: QUESTION_ONLY_MESSAGE };
72
+ const body = listed ? [...listed.blocks.map((block, i) => `${i + 1}. ${block}`), ...listed.after, question] : [question, numbered(options)];
73
+ return { say: [...own, ...body].join('\n\n'), choiceMessage: CHOICE_MESSAGE };
74
+ }
75
+
76
+ // What the talent reads or picks from a chat question; its rule travels in the reply's `message`.
77
+ const forChat = ({ choiceMessage: _rule, ...chat }) => chat;
78
+
79
+ // An open question: the step's text ends with what the talent answers by typing, so it is all `say`.
80
+ function openQuestion(text) {
81
+ closeTurn();
82
+ return { say: [...takeNotices(), text].join('\n\n'), message: OPEN_MESSAGE };
83
+ }
84
+
85
+ const plain = (text) => text.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
86
+ const NUMBERED = /^(?:(?:opcion|opcao|opzione|option|numero|number|la|el|the)\s+)?(\d+)(?:\s+(.*))?$/;
87
+
88
+ // The option an answer names: its label, its number, or the talent's own words when they point at one option only:
89
+ // the start of its label, its whole label inside them, or its first word ("si", "no", "backend").
28
90
  function optionFor(answer, options) {
29
91
  if (typeof answer !== 'string') return null;
30
- const text = answer.trim();
31
- if (options.includes(text)) return text;
32
- const n = /^\d+$/.test(text) ? Number(text) : 0;
33
- return n >= 1 && n <= options.length ? options[n - 1] : null;
92
+ if (options.includes(answer.trim())) return answer.trim();
93
+ const said = plain(answer);
94
+ if (!said) return null;
95
+ const labels = options.map(plain);
96
+ const n = NUMBERED.exec(said);
97
+ if (n) {
98
+ const label = labels[Number(n[1]) - 1];
99
+ return label !== undefined && (!n[2] || label.startsWith(n[2])) ? options[Number(n[1]) - 1] : null;
100
+ }
101
+ const first = (text) => text.split(' ')[0];
102
+ const readings = [(l) => l === said, (l) => l.startsWith(`${said} `), (l) => ` ${said} `.includes(` ${l} `), (l) => first(l) === first(said)];
103
+ for (const reads of readings) {
104
+ const hits = options.filter((_, i) => reads(labels[i]));
105
+ if (hits.length) return hits.length === 1 ? hits[0] : null;
106
+ }
107
+ return null;
108
+ }
109
+
110
+ function narrationOf(ctx) {
111
+ return ctx && typeof ctx.narration === 'string' && ctx.narration ? [ctx.narration] : [];
34
112
  }
35
113
 
36
114
  // { answer } from the dialog, { dismissed: <action> } when the talent closed it, or { chat } when there is no dialog.
37
- async function askChoice(ctx, { texts: own = [], question, options }) {
38
- const texts = [...takeNotices(), ...own];
115
+ async function askChoice(ctx, { texts: own = [], question, options, listed = null }) {
116
+ const queued = takeNotices();
39
117
  const elicit = ctx && typeof ctx.elicit === 'function' ? ctx.elicit : null;
40
118
  const reply = elicit
41
119
  ? await elicit({
42
- message: [...texts, question].join('\n\n'),
120
+ message: [...narrationOf(ctx), ...queued, ...own, ...(listed ? [...listed.blocks, ...listed.after] : []), question].join('\n\n'),
43
121
  requestedSchema: { type: 'object', properties: { choice: { type: 'string', title: question, enum: options } }, required: ['choice'] },
44
122
  })
45
123
  : null;
46
- if (!reply) return { chat: chatChoice(question, options, texts) };
124
+ if (!reply) return { chat: chatChoice(question, options, own, listed, queued) };
47
125
  const choice = reply.action === 'accept' && reply.content ? reply.content.choice : undefined;
48
126
  if (options.includes(choice)) return { answer: choice };
49
- return { dismissed: reply.action, chat: chatChoice(question, options, texts) };
127
+ return { dismissed: reply.action, chat: chatChoice(question, options, own, listed, queued) };
50
128
  }
51
129
 
130
+ // The steps whose text went to the chat, keyed by what their answer decides (`about`, the texts by default): an answer
131
+ // counts only after its text was handed over, so the model cannot confirm a draft the talent never read.
132
+ const shownTexts = new Set();
133
+
52
134
  // A closed question after fixed texts: the chat answer the model passed back, else the dialog, else the block to print.
53
- async function askAfterTexts(ctx, { tool, texts, question, options, answer }) {
54
- const picked = optionFor(answer, options);
55
- if (picked) return { answer: picked };
56
- const asked = ctx && ctx.elicitation ? await askChoice(ctx, { texts, question, options }) : { chat: chatChoice(question, options, texts) };
135
+ async function askAfterTexts(ctx, { tool, texts, question, options, answer, listed = null, about = texts, nextSay = null }) {
136
+ const withText = texts.length > 0 || !!listed;
137
+ const key = JSON.stringify([tool, question, options, about]);
138
+ const picked = withText && !shownTexts.has(key) ? null : optionFor(answer, options);
139
+ if (picked) {
140
+ shownTexts.delete(key);
141
+ settle(key, picked);
142
+ return { answer: picked };
143
+ }
144
+ const asked = ctx && ctx.elicitation ? await askChoice(ctx, { texts, question, options, listed }) : { chat: chatChoice(question, options, texts, listed) };
57
145
  if (asked.answer) return { answer: asked.answer };
58
- return {
59
- reply: {
60
- ok: false,
61
- reason: 'answer-required',
62
- ...(asked.dismissed ? { dismissed: asked.dismissed } : {}),
63
- ...asked.chat,
64
- message: `${asked.chat.choiceMessage} Then call ${tool} again with answer set to the option they pick.`,
65
- },
146
+ if (withText) shownTexts.add(key);
147
+ const reply = {
148
+ ok: false,
149
+ reason: 'answer-required',
150
+ ...(asked.dismissed ? { dismissed: asked.dismissed } : {}),
151
+ ...forChat(asked.chat),
152
+ message: `${asked.chat.choiceMessage} Then call ${tool} again with answer = what the talent answers: the number, the option or their own words.`,
66
153
  };
154
+ const handed = Array.isArray(nextSay) ? nextSay.map((n) => handOver(key, n)) : nextSay && handOver(key, nextSay);
155
+ return { reply: nextSay ? withNextSay(reply, handed) : reply };
67
156
  }
68
157
 
69
158
  // Questions this process put to the talent in the chat, keyed by exactly what they would allow:
@@ -71,14 +160,14 @@ async function askAfterTexts(ctx, { tool, texts, question, options, answer }) {
71
160
  const pendingConsent = new Set();
72
161
 
73
162
  // The talent's yes or no before a tool reads this computer or sends data: { granted } or { reply } to return.
74
- async function askConsent(ctx, { tool, key, question, yes, no, answer }) {
163
+ async function askConsent(ctx, { tool, key, texts = [], question, yes, no, answer, field = 'answer' }) {
75
164
  const options = [yes, no];
76
165
  const picked = pendingConsent.has(key) ? optionFor(answer, options) : null;
77
166
  if (picked) {
78
167
  pendingConsent.delete(key);
79
168
  return { granted: picked === yes };
80
169
  }
81
- const asked = ctx && ctx.elicitation ? await askChoice(ctx, { question, options }) : { chat: chatChoice(question, options) };
170
+ const asked = ctx && ctx.elicitation ? await askChoice(ctx, { texts, question, options }) : { chat: chatChoice(question, options, texts) };
82
171
  if (asked.answer) return { granted: asked.answer === yes };
83
172
  pendingConsent.add(key);
84
173
  return {
@@ -86,10 +175,58 @@ async function askConsent(ctx, { tool, key, question, yes, no, answer }) {
86
175
  ok: false,
87
176
  reason: 'consent-required',
88
177
  ...(asked.dismissed ? { dismissed: asked.dismissed } : {}),
89
- ...asked.chat,
90
- message: `Nothing was read or sent. ${asked.chat.choiceMessage} Then call ${tool} again with the same arguments and answer set to the option they pick.`,
178
+ ...forChat(asked.chat),
179
+ message: `Nothing was read or sent. ${asked.chat.choiceMessage} Then call ${tool} again with the same arguments and ${field} = what the talent answers: the number, the option or their own words.`,
91
180
  },
92
181
  };
93
182
  }
94
183
 
95
- module.exports = { askConsent, askChoice, askAfterTexts, chatChoice, optionFor, queueNotice, takeNotices, SAY_MESSAGE };
184
+ const termsAccepted = new Set();
185
+
186
+ // The terms block the terms question carries, with its links: the sign-up hands the same text over one answer early.
187
+ function registrationTermsText(lang) {
188
+ return termsText(lang, (label, url) => `[${label}](${url})`);
189
+ }
190
+
191
+ async function askRegistrationTerms(ctx, { tool, lang, termsAnswer, newsletterAnswer }) {
192
+ const c = signupCopy(lang);
193
+ if (!termsAccepted.has(tool)) {
194
+ const terms = await askConsent(ctx, {
195
+ tool,
196
+ key: `${tool}:terms`,
197
+ field: 'termsAnswer',
198
+ texts: [registrationTermsText(lang)],
199
+ question: c.termsQuestion,
200
+ yes: c.termsAccept,
201
+ no: c.termsDecline,
202
+ answer: termsAnswer,
203
+ });
204
+ if (terms.reply) return { reply: terms.reply };
205
+ if (!terms.granted) return { declined: true };
206
+ termsAccepted.add(tool);
207
+ }
208
+ const newsletter = legalCopy(lang).newsletter;
209
+ const news = await askConsent(ctx, {
210
+ tool,
211
+ key: `${tool}:newsletter`,
212
+ field: 'newsletterAnswer',
213
+ question: c.newsletterQuestion,
214
+ yes: newsletter,
215
+ no: c.newsletterNo,
216
+ answer: newsletterAnswer,
217
+ });
218
+ if (news.reply) return { reply: news.reply };
219
+ termsAccepted.delete(tool);
220
+ return { newsletterConsent: news.granted };
221
+ }
222
+
223
+ // A new sign-up in the same process starts with nothing queued, shown or asked.
224
+ function resetQuestions() {
225
+ takeNotices();
226
+ handed = [];
227
+ shownTexts.clear();
228
+ pendingConsent.clear();
229
+ termsAccepted.clear();
230
+ }
231
+
232
+ module.exports = { askConsent, askChoice, askAfterTexts, askRegistrationTerms, registrationTermsText, narrationOf, chatChoice, forChat, openQuestion, optionFor, queueNotice, takeNotices, handOver, settle, withNextSay, written, resetQuestions, CHOICE_RULE, CHOICE_MESSAGE, QUESTION_ONLY_MESSAGE, OPEN_MESSAGE };
@@ -2,7 +2,8 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
- const { getHomeDir } = require('./env-paths');
5
+ const { getHomeDir, codexHome, piAgentDir } = require('./env-paths');
6
+ const { OWN_MCP_SERVER_NAMES } = require('./mcp-server-name');
6
7
 
7
8
  // KEYWORD -> { SERVICE LABEL, CATEGORY }.
8
9
  const SERVICE_CATALOG = [
@@ -100,6 +101,11 @@ const MCP_CONFIG_LOCATIONS = [
100
101
  // --- Gemini CLI: home was read; the project-level settings file was not ---
101
102
  { tool: 'gemini-cli', scope: 'home', keys: ['mcpServers'], confidence: 'medium', file: (root, home) => path.join(home, '.gemini', 'settings.json') },
102
103
  { tool: 'gemini-cli', scope: 'project', keys: ['mcpServers'], confidence: 'medium', file: (root) => path.join(root, '.gemini', 'settings.json') },
104
+ { tool: 'codex-cli', scope: 'home', read: readTomlMcpServerNames, confidence: 'high', file: () => path.join(codexHome(), 'config.toml') },
105
+ { tool: 'pi', scope: 'home', keys: ['mcpServers'], confidence: 'high', file: () => path.join(piAgentDir(), 'mcp.json') },
106
+ { tool: 'pi', scope: 'project', keys: ['mcpServers'], confidence: 'high', file: (root) => path.join(root, '.pi', 'mcp.json') },
107
+ { tool: 'opencode', scope: 'home', keys: ['mcp'], confidence: 'medium', file: (root, home) => path.join(home, '.config', 'opencode', 'opencode.json') },
108
+ { tool: 'opencode', scope: 'project', keys: ['mcp'], confidence: 'medium', file: (root) => path.join(root, 'opencode.json') },
103
109
  // VS Code: `servers`, not `mcpServers`, and three OS-specific user paths.
104
110
  { tool: null, scope: 'project', keys: ['servers', 'mcpServers'], confidence: 'medium', file: (root) => path.join(root, '.vscode', 'mcp.json') },
105
111
  { tool: null, scope: 'home', keys: ['servers', 'mcpServers'], confidence: 'medium', file: (root, home) => path.join(home, 'Library', 'Application Support', 'Code', 'User', 'mcp.json') },
@@ -134,6 +140,23 @@ function readMcpServerNames(file, keys = ['mcpServers']) {
134
140
  return [];
135
141
  }
136
142
 
143
+ const TOML_MCP_TABLE = /^\s*\[\s*mcp_servers\.(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_-]+))/;
144
+
145
+ function readTomlMcpServerNames(file) {
146
+ let raw;
147
+ try {
148
+ raw = fs.readFileSync(file, 'utf8');
149
+ } catch {
150
+ return [];
151
+ }
152
+ const names = new Set();
153
+ for (const line of raw.split(/\r?\n/)) {
154
+ const m = TOML_MCP_TABLE.exec(line);
155
+ if (m) names.add(m[1] || m[2] || m[3]);
156
+ }
157
+ return [...names];
158
+ }
159
+
137
160
  // Deterministic (no-LLM) MCP-by-name detection over every location in `MCP_CONFIG_LOCATIONS` (project ∪ home).
138
161
  function detectMcpServers(root) {
139
162
  const home = getHomeDir();
@@ -148,7 +171,7 @@ function detectMcpServers(root) {
148
171
  } catch {
149
172
  continue; // an unresolvable home never breaks the other locations
150
173
  }
151
- const names = readMcpServerNames(file, loc.keys);
174
+ const names = (loc.read ? loc.read(file) : readMcpServerNames(file, loc.keys)).filter((n) => !OWN_MCP_SERVER_NAMES.has(n));
152
175
  if (loc.tool && names.length) {
153
176
  // Per-tool count is the union across THAT tool's own locations, so a
154
177
  // server configured both in the project and globally counts once for it.
@@ -220,4 +243,5 @@ module.exports = {
220
243
  serviceForMcpServerName,
221
244
  MCP_CONFIG_LOCATIONS,
222
245
  SERVICE_CATALOG,
246
+ readTomlMcpServerNames,
223
247
  };
@@ -1,5 +1,10 @@
1
1
  'use strict';
2
2
 
3
+ const { askRegistrationTerms } = require('./mcp-choice');
4
+ const { signupCopy } = require('./signup-copy');
5
+ const { signupLanguage } = require('./signup-language');
6
+ const { NOOP_FUNNEL } = require('./onboarding-funnel');
7
+
3
8
  // MCP: two provider-agnostic tools (start returns URL+user_code to relay VERBATIM; poll finishes). device_code stays in the local store, never in a result.
4
9
 
5
10
  const START_SCHEMA = {
@@ -9,6 +14,8 @@ const START_SCHEMA = {
9
14
  mode: { type: 'string', enum: ['login', 'register'], description: "Optional. 'login' signs an existing talent in; 'register' creates the account if new (the profile steps come from the register/onboarding tools afterwards). Defaults to 'login'." },
10
15
  freelanceType: { type: 'string', description: 'Optional, register only: FREELANCE | EMPLOYEE | AGENCY | POTENTIAL_FREELANCE. Used to complete a new account.' },
11
16
  preferredLanguage: { type: 'string', description: 'Optional, register only: es | en | it | pt | fr.' },
17
+ termsAnswer: { type: 'string', description: 'Register only: the option the talent picked when you printed the terms question this tool returned. Never set it yourself.' },
18
+ newsletterAnswer: { type: 'string', description: 'Register only: the option the talent picked when you printed the newsletter question this tool returned. Never set it yourself.' },
12
19
  },
13
20
  };
14
21
 
@@ -23,18 +30,30 @@ function makeDeviceLoginTools(deps = {}) {
23
30
  startDeviceLogin = require('./device-login').startDeviceLogin,
24
31
  pollDeviceToken = require('./device-login').pollDeviceToken,
25
32
  finalizeDeviceSession = require('./device-login').finalizeDeviceSession,
26
- appendProviderParam = require('./device-login').appendProviderParam,
33
+ withDeviceParams = require('./device-login').withDeviceParams,
27
34
  deviceSessionRecord = require('./device-login').deviceSessionRecord,
28
35
  savePendingDevice = require('./device-auth-store').savePendingDevice,
29
36
  loadPendingDevice = require('./device-auth-store').loadPendingDevice,
30
37
  clearPendingDevice = require('./device-auth-store').clearPendingDevice,
31
38
  saveAuthSession = require('./auth-session-store').saveAuthSession,
32
39
  buildRegistrationContext = require('./signup-client').buildRegistrationContext,
40
+ buildRegistrationConsent = require('./signup-client').buildRegistrationConsent,
41
+ funnel = NOOP_FUNNEL,
33
42
  } = deps;
43
+ const lang = () => signupLanguage(deps.lang || 'en');
34
44
 
35
- async function start(args = {}) {
45
+ async function start(args = {}, ctx = {}) {
36
46
  const mode = args.mode === 'register' ? 'register' : 'login';
37
47
  const provider = args.provider === 'linkedin' ? 'linkedin' : args.provider === 'email' ? 'email' : 'google';
48
+ let newsletterConsent = false;
49
+ if (mode === 'register') {
50
+ const terms = await askRegistrationTerms(ctx, { tool: 'social_signin_start', lang: lang(), termsAnswer: args.termsAnswer, newsletterAnswer: args.newsletterAnswer });
51
+ if (terms.reply) return terms.reply;
52
+ if (terms.declined) {
53
+ return { ok: false, status: 'terms-declined', relayVerbatim: signupCopy(lang()).termsDeclined, message: 'Print relayVerbatim word for word. No account was created and nothing was sent.' };
54
+ }
55
+ newsletterConsent = terms.newsletterConsent;
56
+ }
38
57
  const authorizeEndpoint = getDeviceAuthorizeEndpoint();
39
58
  const tokenEndpoint = getDeviceTokenEndpoint();
40
59
  const authTokenEndpoint = getAuthTokenEndpoint();
@@ -42,10 +61,15 @@ function makeDeviceLoginTools(deps = {}) {
42
61
  return { ok: false, reason: 'no-endpoint', message: 'No sign-in endpoint configured — set SHAKERS_CLI_HUB_BASE.' };
43
62
  }
44
63
 
64
+ if (mode === 'register') funnel.begin();
65
+ funnel.viewed('account_creation', { auth_methods_offered: [provider] });
45
66
  const authz = await startDeviceLogin({ authorizeEndpoint });
46
- if (!authz.ok) return { ok: false, reason: authz.reason || 'authorize-failed' };
67
+ if (!authz.ok) {
68
+ funnel.errored('account_creation', authz.reason || 'authorize-failed');
69
+ return { ok: false, reason: authz.reason || 'authorize-failed' };
70
+ }
47
71
 
48
- const verificationUrl = appendProviderParam(authz.verificationUriComplete, provider);
72
+ const verificationUrl = withDeviceParams(authz.verificationUriComplete, { provider, mode, surface: 'mcp' });
49
73
  const expiresAt = new Date(Date.now() + authz.expiresIn * 1000).toISOString();
50
74
  try {
51
75
  savePendingDevice({
@@ -59,7 +83,7 @@ function makeDeviceLoginTools(deps = {}) {
59
83
  mode,
60
84
  provider,
61
85
  registerContext: mode === 'register'
62
- ? { freelanceType: args.freelanceType || null, preferredLanguage: args.preferredLanguage || null }
86
+ ? { freelanceType: args.freelanceType || null, preferredLanguage: args.preferredLanguage || null, newsletterConsent }
63
87
  : null,
64
88
  });
65
89
  } catch {
@@ -75,7 +99,8 @@ function makeDeviceLoginTools(deps = {}) {
75
99
  userCode: authz.userCode,
76
100
  expiresInSeconds: authz.expiresIn,
77
101
  pollIntervalSeconds: authz.interval,
78
- message: `Show the talent this link and code so they can authorize in their own browser — relay both verbatim, do not invent or complete the code:\n\n${verificationUrl}\n\nCode: ${authz.userCode}\n\nThen call social_signin_poll; if it returns status "pending", wait pollIntervalSeconds and call it again.`,
102
+ relayVerbatim: signupCopy(lang()).windowLink(verificationUrl, authz.userCode),
103
+ message: 'Print relayVerbatim word for word (it holds the link and the code) — never invent or complete the code. Then call social_signin_poll; if it returns status "pending", wait pollIntervalSeconds and call it again.',
79
104
  };
80
105
  }
81
106
 
@@ -115,13 +140,16 @@ function makeDeviceLoginTools(deps = {}) {
115
140
  }
116
141
  if (res.status === 'denied') {
117
142
  clearPendingDevice();
143
+ funnel.errored('account_creation', 'device-denied');
118
144
  return { ok: false, status: 'denied', message: 'The talent cancelled or did not grant access. No session was started.' };
119
145
  }
120
146
  if (res.status === 'expired') {
121
147
  clearPendingDevice();
148
+ funnel.errored('account_creation', 'device-expired');
122
149
  return { ok: false, status: 'expired', message: 'The sign-in request expired. Call social_signin_start to try again.' };
123
150
  }
124
151
  if (res.status !== 'authorized') {
152
+ funnel.errored('account_creation', res.reason || 'poll-failed');
125
153
  return { ok: false, status: 'error', reason: res.reason || 'poll-failed' };
126
154
  }
127
155
 
@@ -129,7 +157,7 @@ function makeDeviceLoginTools(deps = {}) {
129
157
  const rc = pending.registerContext || {};
130
158
  const registrationContext = buildRegistrationContext({
131
159
  preferredLanguage: rc.preferredLanguage || undefined,
132
- newsletterConsent: false,
160
+ newsletterConsent: rc.newsletterConsent === true,
133
161
  freelanceType: rc.freelanceType || 'POTENTIAL_FREELANCE',
134
162
  });
135
163
  const fin = await finalizeDeviceSession({
@@ -138,8 +166,10 @@ function makeDeviceLoginTools(deps = {}) {
138
166
  authTokenEndpoint: pending.authTokenEndpoint,
139
167
  completeRegistrationEndpoint: getCompleteRegistrationEndpoint(),
140
168
  registrationContext,
169
+ consent: pending.mode === 'register' ? buildRegistrationConsent({ newsletterConsent: rc.newsletterConsent === true }) : null,
141
170
  });
142
171
  if (!fin.ok) {
172
+ funnel.errored('account_creation', fin.reason);
143
173
  // The authorization was consumed; a re-poll can't recover — never treat this as logged in.
144
174
  clearPendingDevice();
145
175
  return { ok: false, status: 'error', reason: fin.reason };
@@ -152,6 +182,8 @@ function makeDeviceLoginTools(deps = {}) {
152
182
  }
153
183
 
154
184
  clearPendingDevice();
185
+ funnel.completed('account_creation', { auth_method: pending.provider || 'google', auth_attempts: 1, is_returning_account: !res.isNewUser });
186
+ if (funnel.active) funnel.identified({ newAccount: !!res.isNewUser });
155
187
  return { ok: true, status: 'authorized', method: pending.provider || 'google', email: res.email || null, isNewUser: !!res.isNewUser };
156
188
  }
157
189
 
@@ -160,7 +192,7 @@ function makeDeviceLoginTools(deps = {}) {
160
192
  name: 'social_signin_start',
161
193
  title: 'Sign in to Shakers',
162
194
  description: 'Opens a Shakers page in your browser where you sign in with Google, LinkedIn or your email. Your password never goes through the chat.',
163
- guidance: "Begin sign-in (or registration) via the device flow — provider 'google' (default), 'linkedin' or 'email'. Email goes through the SAME web flow: the talent reuses their open Shakers session or signs in with their email on the page, never a password in the chat. Returns a verificationUrl and a userCode: show BOTH to the talent verbatim — they open the URL and enter the code in their own browser (they never type anything in the chat, and you never invent or fill the code). Pass mode 'register' to create the account if it is new. After showing them, call social_signin_poll. Returns { ok, status, provider, verificationUrl, userCode, pollIntervalSeconds, ... }.",
195
+ guidance: "Begin sign-in (or registration) via the device flow — provider 'google' (default), 'linkedin' or 'email'. Email goes through the SAME web flow: the talent reuses their open Shakers session or signs in with their email on the page, never a password in the chat. With mode 'register' it first asks the talent to accept the Terms and Privacy Policy and whether they want the newsletter (a dialog, or `say` to print word for word; pass their picks back as termsAnswer and newsletterAnswer, never your own); a decline creates nothing. Returns relayVerbatim with the Shakers link and the code: print it word for word — they open the link, check the code matches and approve in their own browser (you never invent or fill the code). After showing them, call social_signin_poll. Returns { ok, status, provider, verificationUrl, userCode, pollIntervalSeconds, relayVerbatim, ... }.",
164
196
  inputSchema: START_SCHEMA,
165
197
  handler: start,
166
198
  },