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.
- package/README.md +21 -6
- package/bin/ai-usage.js +31 -12
- package/bin/command-audit-worker.js +19 -0
- package/bin/login.js +4 -8
- package/bin/mcp.js +32 -21
- package/bin/register.js +12 -30
- package/bin/shakers-cli.js +15 -10
- package/package.json +1 -1
- package/src/account-marker.js +40 -0
- package/src/account-trace.js +23 -0
- package/src/agents-client.js +1 -0
- package/src/ai-usage-review.js +103 -0
- package/src/auth-client.js +7 -107
- package/src/auth-session-store.js +12 -11
- package/src/backend-request.js +3 -24
- package/src/cli-skill.js +77 -0
- package/src/command-audit.js +47 -4
- package/src/config.js +2 -16
- package/src/consent-flow.js +9 -3
- package/src/detect-repos.js +4 -8
- package/src/detectors.js +23 -0
- package/src/device-auth-store.js +2 -2
- package/src/device-login.js +11 -9
- package/src/env-paths.js +9 -1
- package/src/i18n.js +186 -102
- package/src/mcp-ai-usage-result-tool.js +38 -14
- package/src/mcp-ai-usage-tool.js +317 -172
- package/src/mcp-choice.js +171 -34
- package/src/mcp-detector.js +26 -2
- package/src/mcp-device-login-tools.js +40 -8
- package/src/mcp-install.js +292 -53
- package/src/mcp-instructions.js +43 -17
- package/src/mcp-onboarding-tools.js +1 -3
- package/src/mcp-prompts.js +8 -6
- package/src/mcp-register-tools.js +25 -14
- package/src/mcp-role-tools.js +59 -18
- package/src/mcp-server-name.js +7 -0
- package/src/mcp-server.js +35 -7
- package/src/mcp-signup-client.js +3 -6
- package/src/mcp-signup-flow.js +389 -0
- package/src/mcp-signup-tools.js +250 -133
- package/src/onboarding-flow.js +72 -146
- package/src/onboarding-funnel.js +46 -6
- package/src/opencode-sessions.js +92 -0
- package/src/profile-agents.js +26 -0
- package/src/render-report-text.js +1 -1
- package/src/report-gating.js +4 -2
- package/src/report-store.js +7 -2
- package/src/roles-flow.js +8 -5
- package/src/saved-positions-flow.js +3 -3
- package/src/scanner.js +104 -12
- package/src/session-gate.js +1 -4
- package/src/session-normalizers.js +114 -8
- package/src/session-refresh.js +5 -16
- package/src/session-scan.js +167 -22
- package/src/share-consent-state.js +2 -2
- package/src/share-payload.js +4 -1
- package/src/share.js +2 -2
- package/src/signup-client.js +12 -39
- package/src/signup-copy.js +545 -58
- package/src/skill-install.js +102 -0
- package/src/stdin-ask.js +13 -1
- package/src/tier-engine.js +14 -2
- package/src/trust-narration.js +32 -0
- package/src/usage-run-steps.js +68 -6
- package/src/usage-scan.js +11 -8
- package/src/brand-svg.js +0 -9
- package/src/inter-fonts.js +0 -6
- 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
|
-
|
|
3
|
+
const { signupCopy, legalCopy, termsText } = require('./signup-copy');
|
|
4
|
+
const { CHOICE_RULE, QUESTION_RULE, OPEN_RULE, nextSayRule } = require('./mcp-instructions');
|
|
4
5
|
|
|
5
|
-
|
|
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
|
-
//
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
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: [...
|
|
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,
|
|
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,
|
|
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
|
|
55
|
-
|
|
56
|
-
const
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
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
|
-
|
|
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 };
|
package/src/mcp-detector.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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
|
},
|