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.
- package/README.md +15 -5
- package/bin/command-audit-worker.js +19 -0
- package/bin/login.js +4 -8
- package/bin/mcp.js +29 -19
- package/bin/register.js +10 -29
- package/bin/shakers-cli.js +14 -9
- package/package.json +1 -1
- package/src/agents-client.js +1 -0
- package/src/ai-usage-review.js +5 -3
- package/src/auth-client.js +7 -107
- package/src/auth-session-store.js +6 -12
- package/src/backend-request.js +1 -24
- package/src/cli-skill.js +77 -0
- package/src/command-audit.js +47 -4
- package/src/config.js +0 -16
- package/src/consent-flow.js +7 -2
- 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 +162 -88
- package/src/mcp-ai-usage-result-tool.js +6 -4
- package/src/mcp-ai-usage-tool.js +72 -46
- 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 +15 -11
- package/src/mcp-instructions.js +43 -17
- package/src/mcp-onboarding-tools.js +1 -3
- package/src/mcp-prompts.js +6 -4
- package/src/mcp-register-tools.js +25 -14
- package/src/mcp-role-tools.js +35 -17
- 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 +237 -126
- package/src/onboarding-flow.js +30 -125
- package/src/opencode-sessions.js +92 -0
- package/src/profile-agents.js +26 -0
- 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-payload.js +4 -1
- package/src/share.js +2 -2
- package/src/signup-client.js +12 -39
- package/src/signup-copy.js +380 -68
- package/src/skill-install.js +102 -0
- package/src/tier-engine.js +14 -2
- package/src/trust-narration.js +32 -0
- package/src/usage-run-steps.js +12 -3
- package/src/usage-scan.js +3 -0
- package/src/brand-svg.js +0 -9
- package/src/inter-fonts.js +0 -6
- package/src/mcp-auth-form.js +0 -484
package/src/mcp-role-tools.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// MCP role tools: list, add and recommend roles, and set the main role (the sign-up's last step before open_web).
|
|
4
4
|
|
|
5
|
-
const { askChoice, askAfterTexts, chatChoice, optionFor,
|
|
5
|
+
const { askChoice, askAfterTexts, chatChoice, forChat, optionFor, OPEN_MESSAGE } = require('./mcp-choice');
|
|
6
6
|
const { signupCopy, renderRole } = require('./signup-copy');
|
|
7
7
|
const { signupLanguage } = require('./signup-language');
|
|
8
8
|
|
|
@@ -17,8 +17,10 @@ const MAX_WAIT_SECONDS = 45;
|
|
|
17
17
|
const PENDING_FINALIZATION = new Set(['COLLECTING', 'PROCESSING']);
|
|
18
18
|
// Alma's signal from the interview first, then the CV import, as the web stacks them.
|
|
19
19
|
const SOURCE_ORDER = { ONBOARDING_INTERVIEW: 0, IMPORT: 1 };
|
|
20
|
-
//
|
|
21
|
-
|
|
20
|
+
// The chat's question tool takes two to four options: at most four roles are offered and the talent types any other one;
|
|
21
|
+
// a single role gets the "other role" button as its second option.
|
|
22
|
+
const MAX_ROLE_OPTIONS = 4;
|
|
23
|
+
const withOther = (names, other) => (names.length === 1 ? [...names, other] : names);
|
|
22
24
|
|
|
23
25
|
const fold = (text) => String(text || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
|
|
24
26
|
|
|
@@ -77,7 +79,7 @@ const SET_MAIN_ROLE_SCHEMA = {
|
|
|
77
79
|
description: 'In the sign-up, for each recommended role (by clusterId from list_my_roles): ONE short concrete fact from the CV, the interview or the AI-usage analysis that shows why it fits (e.g. "6 years with Spark and Airflow"), in the talent\'s language. It is shown under the role.',
|
|
78
80
|
},
|
|
79
81
|
answer: { type: 'string', description: 'The option the talent picked when you asked the question in the chat.' },
|
|
80
|
-
clusterId: { type: 'string', description: 'The role the talent already picked, by clusterId from list_my_roles or from the roles this tool returned. Omit it in the sign-up: the tool asks the talent which recommended role is their main one (a dialog, or options it returns)
|
|
82
|
+
clusterId: { type: 'string', description: 'The role the talent already picked, by clusterId from list_my_roles or from the roles this tool returned. Omit it in the sign-up: the tool asks the talent which recommended role is their main one (a dialog, or options it returns); a role they type instead is looked up in the catalogue.' },
|
|
81
83
|
catalogue: { type: 'boolean', description: 'true when the role comes from the catalogue: with clusterId it adds the role before setting it; without clusterId it asks from the catalogue.' },
|
|
82
84
|
},
|
|
83
85
|
};
|
|
@@ -120,6 +122,8 @@ function makeRoleTools(deps = {}) {
|
|
|
120
122
|
} = deps;
|
|
121
123
|
let clustersCount;
|
|
122
124
|
let onboardingCompleted = false;
|
|
125
|
+
// The recommended roles were offered in the chat, so an answer that names none of them is a role the talent typed.
|
|
126
|
+
let recommendedAsked = false;
|
|
123
127
|
|
|
124
128
|
async function finishSignup(session) {
|
|
125
129
|
if (onboardingCompleted || !inSignup()) return;
|
|
@@ -226,7 +230,7 @@ function makeRoleTools(deps = {}) {
|
|
|
226
230
|
ok: false,
|
|
227
231
|
reason: 'main-role-choice-required',
|
|
228
232
|
...(asked.dismissed ? { dismissed: asked.dismissed } : {}),
|
|
229
|
-
...asked.chat,
|
|
233
|
+
...forChat(asked.chat),
|
|
230
234
|
roles: roles.map((r) => ({ clusterId: r.clusterId, name: r.name })),
|
|
231
235
|
message: `${asked.chat.choiceMessage} Then call set_main_role with the clusterId (from roles) of the one they pick. ${extra}`,
|
|
232
236
|
};
|
|
@@ -244,10 +248,10 @@ function makeRoleTools(deps = {}) {
|
|
|
244
248
|
}));
|
|
245
249
|
}
|
|
246
250
|
|
|
247
|
-
// The main-role step: why each recommended
|
|
251
|
+
// The main-role step: why each of the best recommended roles fits and how many projects want it, then the question; any other role the talent types is looked up in the catalogue.
|
|
248
252
|
async function chooseMainRole(session, args, ctx) {
|
|
249
253
|
const read = await readRoles(session.hubAccessToken);
|
|
250
|
-
const recommended = byName(read.recommended);
|
|
254
|
+
const recommended = byName(read.recommended).slice(0, MAX_ROLE_OPTIONS);
|
|
251
255
|
if (!recommended.length && read.mainClusterId) {
|
|
252
256
|
await finishSignup(session);
|
|
253
257
|
return { reply: { ok: true, alreadySet: true, clusterId: read.mainClusterId, message: 'The talent already has a main role and nothing new was recommended: nothing to ask. Go on to open_web; only if they ask to change it, call set_main_role with catalogue:true.' } };
|
|
@@ -257,17 +261,29 @@ function makeRoleTools(deps = {}) {
|
|
|
257
261
|
const counts = await openCounts(session.hubAccessToken, recommended);
|
|
258
262
|
trackSuggestions(recommended);
|
|
259
263
|
const evidence = args.evidence && typeof args.evidence === 'object' ? args.evidence : {};
|
|
260
|
-
const
|
|
264
|
+
const options = withOther(recommended.map((r) => r.name), c.mainRoleOther);
|
|
265
|
+
// Each option numbered with why it fits; "another role" is its own label.
|
|
266
|
+
const blocks = options.map((name, i) => (recommended[i] ? renderRole(lang(), { name, source: recommended[i].source, evidence: evidence[recommended[i].clusterId], openProjects: counts[i], recommended: i === 0 }) : name));
|
|
267
|
+
const typed = typeof args.answer === 'string' ? args.answer.trim() : '';
|
|
268
|
+
if (recommendedAsked && typed && !optionFor(typed, options)) {
|
|
269
|
+
recommendedAsked = false;
|
|
270
|
+
return chooseFromCatalogue({ answer: typed, skills: args.skills }, ctx);
|
|
271
|
+
}
|
|
261
272
|
const asked = await askAfterTexts(ctx, {
|
|
262
273
|
tool: 'set_main_role',
|
|
263
|
-
texts: [
|
|
274
|
+
texts: [c.rolesIntro],
|
|
275
|
+
listed: { blocks, after: [c.rolesOutro] },
|
|
264
276
|
question: c.mainRoleQuestion,
|
|
265
|
-
options
|
|
277
|
+
options,
|
|
266
278
|
answer: args.answer,
|
|
279
|
+
about: options,
|
|
267
280
|
});
|
|
268
281
|
if (asked.reply) {
|
|
269
|
-
|
|
282
|
+
recommendedAsked = true;
|
|
283
|
+
const other = options.includes(c.mainRoleOther) ? ' If they pick the last option, call set_main_role with catalogue:true and no clusterId.' : '';
|
|
284
|
+
return { reply: { ...asked.reply, roles: recommended.map((r) => ({ clusterId: r.clusterId, name: r.name })), message: `${asked.reply.message} Pass the same evidence again.${other} If they type another role instead of picking one, pass what they typed as answer: the closest catalogue roles are offered.` } };
|
|
270
285
|
}
|
|
286
|
+
recommendedAsked = false;
|
|
271
287
|
if (asked.answer === c.mainRoleOther) return chooseFromCatalogue({}, ctx);
|
|
272
288
|
return { role: recommended.find((r) => r.name === asked.answer) };
|
|
273
289
|
}
|
|
@@ -282,7 +298,7 @@ function makeRoleTools(deps = {}) {
|
|
|
282
298
|
ok: false,
|
|
283
299
|
reason: 'main-role-ask',
|
|
284
300
|
say: copy().mainRoleAsk,
|
|
285
|
-
message: `${
|
|
301
|
+
message: `${OPEN_MESSAGE} Then call set_main_role with catalogue:true and answer = what they say: it offers the closest catalogue roles.`,
|
|
286
302
|
},
|
|
287
303
|
};
|
|
288
304
|
}
|
|
@@ -291,7 +307,7 @@ function makeRoleTools(deps = {}) {
|
|
|
291
307
|
const c = copy();
|
|
292
308
|
const said = typeof args.answer === 'string' ? args.answer.trim() : '';
|
|
293
309
|
if (offered) {
|
|
294
|
-
const picked = optionFor(said,
|
|
310
|
+
const picked = optionFor(said, withOther(offered.map((r) => r.name), c.mainRoleNotListed));
|
|
295
311
|
if (picked === c.mainRoleNotListed) return askOwnRole();
|
|
296
312
|
const role = picked && offered.find((r) => r.name === picked);
|
|
297
313
|
if (role) { offered = null; return { role, add: true }; }
|
|
@@ -318,7 +334,8 @@ function makeRoleTools(deps = {}) {
|
|
|
318
334
|
].filter(Boolean).join('\n'));
|
|
319
335
|
const texts = [[...(blocks.length ? [c.rolesIntro, ...blocks] : []), c.rolesOutro].join('\n\n')];
|
|
320
336
|
trackSuggestions([]);
|
|
321
|
-
const
|
|
337
|
+
const names = withOther(options.map((r) => r.name), c.mainRoleNotListed);
|
|
338
|
+
const asked = await askChoice(ctx, { texts, question: c.mainRoleQuestion, options: names });
|
|
322
339
|
if (asked.answer === c.mainRoleNotListed) return askOwnRole();
|
|
323
340
|
const role = asked.answer && options.find((r) => r.name === asked.answer);
|
|
324
341
|
if (role) {
|
|
@@ -326,7 +343,8 @@ function makeRoleTools(deps = {}) {
|
|
|
326
343
|
return { role, add: true };
|
|
327
344
|
}
|
|
328
345
|
offered = options;
|
|
329
|
-
|
|
346
|
+
const other = names.includes(c.mainRoleNotListed) ? ' If they pick the last option, call set_main_role with catalogue:true and answer = that option.' : '';
|
|
347
|
+
return { reply: roleChoice(asked.chat ? asked : { chat: chatChoice(c.mainRoleQuestion, names, texts) }, options, `Pass catalogue:true too: it adds the role first.${other} If they type another role instead, call set_main_role with catalogue:true and answer = what they typed.`) };
|
|
330
348
|
}
|
|
331
349
|
|
|
332
350
|
async function setMainRole(args = {}, ctx = {}) {
|
|
@@ -379,7 +397,7 @@ function makeRoleTools(deps = {}) {
|
|
|
379
397
|
name: 'list_my_roles',
|
|
380
398
|
title: 'Your roles',
|
|
381
399
|
description: 'Shows your roles on Shakers and the ones suggested by your interview and your CV.',
|
|
382
|
-
guidance: "List the roles the talent already holds, plus mainClusterId (their current main role) and `recommended`: the roles the onboarding interview and the CV import suggest, interview first. Use it before set_main_role. In the sign-up pass waitSeconds (
|
|
400
|
+
guidance: "List the roles the talent already holds, plus mainClusterId (their current main role) and `recommended`: the roles the onboarding interview and the CV import suggest, interview first. Use it before set_main_role. In the sign-up pass waitSeconds (45): it waits for the interview evaluation to recommend roles and says when to call again (`pending`) or to fall back to list_available_roles. Follow `message`. clusterId is an internal handle — refer to roles by name to the talent. Requires an active session.",
|
|
383
401
|
inputSchema: LIST_MY_ROLES_SCHEMA,
|
|
384
402
|
handler: listMyRoles,
|
|
385
403
|
},
|
|
@@ -387,7 +405,7 @@ function makeRoleTools(deps = {}) {
|
|
|
387
405
|
name: 'set_main_role',
|
|
388
406
|
title: 'Choose your main role',
|
|
389
407
|
description: 'Asks you which role is your main one and saves it on your Shakers profile.',
|
|
390
|
-
guidance: "Set the talent's MAIN role. In the sign-up it is the last step before open_web: after onboarding_interview_complete (or when the talent skipped the interview) and once list_my_roles is no longer pending, call it without clusterId: it asks the talent which of the recommended roles is their main one
|
|
408
|
+
guidance: "Set the talent's MAIN role. In the sign-up it is the last step before open_web: after onboarding_interview_complete (or when the talent skipped the interview) and once list_my_roles is no longer pending, call it without clusterId: it asks the talent which of the (at most four) recommended roles is their main one (or straight from the catalogue when nothing is recommended), in a dialog when the client has one; otherwise it returns the question and the roles, to call it again with the clusterId they pick, or with answer = another role they type, which it looks up in the catalogue. Then it sets that role (adding a catalogue role first). Also usable standalone with clusterId to change the main role later; catalogue:true adds a catalogue role first. Requires an active session.",
|
|
391
409
|
inputSchema: SET_MAIN_ROLE_SCHEMA,
|
|
392
410
|
handler: setMainRole,
|
|
393
411
|
},
|
package/src/mcp-server.js
CHANGED
|
@@ -34,7 +34,28 @@ function errorResponse(id, code, message, data) {
|
|
|
34
34
|
return { jsonrpc: JSONRPC, id: id === undefined ? null : id, error };
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
// Cowork advertises elicitation, drops the request and leaves the call hanging ~180 s (anthropics/claude-ai-mcp#1046);
|
|
38
|
+
// it names itself in clientInfo ('claude-code', version '... (remote_cowork)'). Claude Desktop ('claude-ai') does not
|
|
39
|
+
// advertise it today and is matched so that advertising it later cannot lead to the same hang. Both ask in the chat.
|
|
40
|
+
// Matched on clientInfo only, the one thing the handshake says about the client.
|
|
41
|
+
function dropsElicitation(clientInfo = {}) {
|
|
42
|
+
const name = typeof clientInfo.name === 'string' ? clientInfo.name.toLowerCase() : '';
|
|
43
|
+
const version = typeof clientInfo.version === 'string' ? clientInfo.version.toLowerCase() : '';
|
|
44
|
+
return name === 'claude-ai' || name.includes('cowork') || version.includes('cowork');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The tool's narration opens every result. A reply with `say` (text the talent must read) follows it in the same
|
|
48
|
+
// plain-text block, so the model writes both as one message, and the rest goes as JSON after it: a model writes a
|
|
49
|
+
// readable text before it reasons over a JSON field, and the talent never sees tool results.
|
|
50
|
+
function contentOf(value, narration = null) {
|
|
51
|
+
if (value && typeof value === 'object' && typeof value.say === 'string' && value.say) {
|
|
52
|
+
const { say, ...rest } = value;
|
|
53
|
+
return [{ type: 'text', text: [narration, say].filter(Boolean).join('\n\n') }, { type: 'text', text: JSON.stringify(rest, null, 2) }];
|
|
54
|
+
}
|
|
55
|
+
return [...(narration ? [{ type: 'text', text: narration }] : []), { type: 'text', text: JSON.stringify(value, null, 2) }];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVersion, beforeCall = null, afterCall = null, narrate = null, onInitialize = null, instructions = null, elicitationTimeoutMs = ELICITATION_TIMEOUT_MS } = {}) {
|
|
38
59
|
const registry = new Map();
|
|
39
60
|
for (const t of tools) {
|
|
40
61
|
if (!t || !t.name || typeof t.handler !== 'function') {
|
|
@@ -54,6 +75,7 @@ function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVe
|
|
|
54
75
|
const advertisedVersion = protocolVersion || DEFAULT_PROTOCOL_VERSION;
|
|
55
76
|
|
|
56
77
|
let clientCapabilities = {};
|
|
78
|
+
let clientInfo = {};
|
|
57
79
|
let send = null;
|
|
58
80
|
let lastRequestId = 0;
|
|
59
81
|
const pending = new Map();
|
|
@@ -91,7 +113,7 @@ function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVe
|
|
|
91
113
|
// (`{ url }` without `{ form }`), which only opens links and falls back to the chat.
|
|
92
114
|
function canElicitForm() {
|
|
93
115
|
const e = clientCapabilities.elicitation;
|
|
94
|
-
if (!e) return false;
|
|
116
|
+
if (!e || dropsElicitation(clientInfo)) return false;
|
|
95
117
|
if (typeof e === 'object' && e.url !== undefined && e.form === undefined) return false;
|
|
96
118
|
return true;
|
|
97
119
|
}
|
|
@@ -174,15 +196,20 @@ function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVe
|
|
|
174
196
|
if (typeof beforeCall === 'function') {
|
|
175
197
|
try { await beforeCall(name); } catch { /* hook must never break a tool call */ }
|
|
176
198
|
}
|
|
199
|
+
const narration = (failed) => {
|
|
200
|
+
const text = typeof narrate === 'function' ? narrate(name, { failed, args }) : null;
|
|
201
|
+
return text ? [{ type: 'text', text }] : [];
|
|
202
|
+
};
|
|
177
203
|
try {
|
|
178
|
-
const
|
|
204
|
+
const asking = narration(false);
|
|
205
|
+
const value = await tool.handler(args, { elicit, elicitation: !!send && canElicitForm(), narration: asking.length ? asking[0].text : null });
|
|
179
206
|
return {
|
|
180
|
-
content:
|
|
207
|
+
content: contentOf(value, asking.length ? asking[0].text : null),
|
|
181
208
|
isError: false,
|
|
182
209
|
};
|
|
183
210
|
} catch (e) {
|
|
184
211
|
return {
|
|
185
|
-
content: [{ type: 'text', text: `${name} tool error: ${e && e.message ? e.message : String(e)}` }],
|
|
212
|
+
content: [...narration(true), { type: 'text', text: `${name} tool error: ${e && e.message ? e.message : String(e)}` }],
|
|
186
213
|
isError: true,
|
|
187
214
|
};
|
|
188
215
|
} finally {
|
|
@@ -213,8 +240,9 @@ function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVe
|
|
|
213
240
|
case 'initialize': {
|
|
214
241
|
const requested = msg.params && msg.params.protocolVersion;
|
|
215
242
|
clientCapabilities = (msg.params && msg.params.capabilities) || {};
|
|
243
|
+
clientInfo = (msg.params && msg.params.clientInfo) || {};
|
|
216
244
|
if (typeof onInitialize === 'function') {
|
|
217
|
-
try { onInitialize({ clientInfo
|
|
245
|
+
try { onInitialize({ clientInfo }); } catch { /* never breaks the handshake */ }
|
|
218
246
|
}
|
|
219
247
|
// One line per connection on stderr (never stdout: it carries JSON-RPC), so the host's
|
|
220
248
|
// declared elicitation shape and the resulting decision are visible in the MCP log.
|
|
@@ -292,4 +320,4 @@ function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVe
|
|
|
292
320
|
return { handleMessage, listTools, listAvailableTools, callTool, start };
|
|
293
321
|
}
|
|
294
322
|
|
|
295
|
-
module.exports = { createMcpServer, ERROR, DEFAULT_PROTOCOL_VERSION };
|
|
323
|
+
module.exports = { createMcpServer, dropsElicitation, ERROR, DEFAULT_PROTOCOL_VERSION };
|
package/src/mcp-signup-client.js
CHANGED
|
@@ -96,13 +96,10 @@ async function requestMyImportStatus({ hubAccessToken } = {}, { endpoint, timeou
|
|
|
96
96
|
return { ok: true, ...importStateOf(data) };
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
|
|
100
|
-
async function requestOneTimeToken({ cookie = null, sessionToken = null } = {}, { endpoint, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
99
|
+
async function requestOneTimeToken({ sessionToken = null } = {}, { endpoint, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
101
100
|
if (!endpoint) return { ok: false, reason: 'no-endpoint' };
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
else if (sessionToken) headers.Authorization = `Bearer ${sessionToken}`;
|
|
105
|
-
else return { ok: false, reason: 'no-session' };
|
|
101
|
+
if (!sessionToken) return { ok: false, reason: 'no-session' };
|
|
102
|
+
const headers = { Authorization: `Bearer ${sessionToken}` };
|
|
106
103
|
let res;
|
|
107
104
|
try {
|
|
108
105
|
res = await postJsonWithTimeout(endpoint, null, timeoutMs, 'GET', null, headers);
|