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
@@ -3,7 +3,7 @@
3
3
  const fs = require('fs');
4
4
  const { execFileSync } = require('child_process');
5
5
  const { isRefusedWalkRoot } = require('./scan-exclusions');
6
- const { askChoice, chatChoice, queueNotice } = require('./mcp-choice');
6
+ const { askChoice, chatChoice, forChat, handOver, narrationOf, queueNotice, settle, withNextSay } = require('./mcp-choice');
7
7
 
8
8
  function isExistingDir(p) {
9
9
  try {
@@ -76,6 +76,7 @@ function buildArgv({ root, repoScope, lang }) {
76
76
  }
77
77
 
78
78
  const VERBATIM = 'Print relayVerbatim word for word now, as its own block, with nothing of your own added to it.';
79
+ const CONSENT_QUESTION = 'ai_usage:consent';
79
80
 
80
81
  const UPLOAD_INPUT_SCHEMA = {
81
82
  type: 'object',
@@ -103,6 +104,7 @@ function makeAiUsageTools(deps = {}) {
103
104
  const {
104
105
  scanUsage = (argv) => require('./usage-scan').scanUsage(argv),
105
106
  autoShare = require('./share').autoShare,
107
+ logCommandRun = require('./command-audit').logCommandRun,
106
108
  recordConsent = require('./share').recordConsent,
107
109
  sendThrottled = () => require('./share').isThrottled(require('./share').loadConsentState()),
108
110
  isValidEmail = require('./share').isValidEmail,
@@ -121,6 +123,8 @@ function makeAiUsageTools(deps = {}) {
121
123
  renderFullReportText = require('./render-report-text').renderFullReportText,
122
124
  detectFlowLang = require('./i18n').detectFlowLang,
123
125
  aiFluencyWaitMs = 30000,
126
+ signupInventoryWaitMs = 20000,
127
+ signupProfileWaitMs = 25000,
124
128
  signupPhase = () => require('./mcp-signup-tools').signupPhase(),
125
129
  usageDisclaimers = (lang) => require('./onboarding-flow').usageDisclaimers(lang),
126
130
  signupCopy = require('./signup-copy').signupCopy,
@@ -132,6 +136,8 @@ function makeAiUsageTools(deps = {}) {
132
136
  let job = null;
133
137
  // The talent's answer in the consent dialog, so a later call cannot contradict it.
134
138
  let dialogConsent = null;
139
+ // In the sign-up a consent counts only after its question and the two disclaimers went to the chat.
140
+ let consentAsked = false;
135
141
  let pending = null;
136
142
 
137
143
  function startSignupScan(argv, root, lang) {
@@ -206,20 +212,19 @@ function makeAiUsageTools(deps = {}) {
206
212
  const facts = reviewFacts(review);
207
213
  const texts = [reviewTexts.summaryText(review.lang, facts), reviewTexts.explanationText(review.lang)];
208
214
  if (ctx && ctx.elicitation && typeof ctx.elicit === 'function') {
209
- const reply = await ctx.elicit({ message: [...texts, c.question].join('\n\n'), requestedSchema: reviewSchema(c, facts) });
215
+ const reply = await ctx.elicit({ message: [...narrationOf(ctx), ...texts, c.question].join('\n\n'), requestedSchema: reviewSchema(c, facts) });
210
216
  const content = reply && reply.action === 'accept' && reply.content ? reply.content : null;
211
217
  if (content && content.decision === c.yes) return upload(review, exclusionsFromForm(review, facts, content));
212
218
  if ((content && content.decision === c.no) || (reply && reply.action === 'decline')) return declineUpload(review);
213
219
  }
214
220
  review.askedInChat = true;
215
- const chat = chatChoice(c.question, [c.yes, c.no], [...texts, c.excludeHint]);
221
+ const { choiceMessage, ...shown } = chatChoice(c.question, [c.yes, c.no], [...texts, c.excludeHint]);
216
222
  return {
217
223
  ok: false,
218
224
  reason: 'upload-approval-required',
219
- say: chat.say,
220
- options: chat.options,
225
+ ...shown,
221
226
  excludable: { repos: facts.repos, agents: facts.agents, excerpts: facts.conversationExcerpts + facts.commandExcerpts > 0 },
222
- message: `Nothing has been sent. ${chat.choiceMessage} Then call ai_usage_upload with confirm:true only if they pick "${c.yes}" (adding exclude with the repos and agents they want left out, exactly as listed in excludable, and excerpts:true if they want no text excerpts), or confirm:false if they pick "${c.no}". Only the talent decides: never answer for them.`,
227
+ message: `Nothing has been sent. ${choiceMessage} Then call ai_usage_upload with confirm:true only if they pick "${c.yes}" (adding exclude with the repos and agents they want left out, exactly as listed in excludable, and excerpts:true if they want no text excerpts), or confirm:false if they pick "${c.no}". Only the talent decides: never answer for them.`,
223
228
  };
224
229
  }
225
230
 
@@ -246,7 +251,8 @@ function makeAiUsageTools(deps = {}) {
246
251
  let send;
247
252
  try {
248
253
  recordConsent('granted', session.email, { verified: true });
249
- send = await autoShare(trimmed.report, trimmed.maturity, { root: review.root, bypassThrottle: review.registration });
254
+ const cliCommandId = await logCommandRun({ command: 'mcp:ai_usage_upload:send' });
255
+ send = await autoShare(trimmed.report, trimmed.maturity, { root: review.root, bypassThrottle: review.registration, cliCommandId });
250
256
  } catch (error) {
251
257
  send = { ok: false, reason: 'upload-failed', message: error && error.message ? error.message : String(error) };
252
258
  }
@@ -257,36 +263,32 @@ function makeAiUsageTools(deps = {}) {
257
263
  const excluded = exclude.repos.length > 0 || exclude.agents.length > 0 || !exclude.excerpts;
258
264
  const confirmation = excluded ? [c.uploaded, sent] : [c.uploaded];
259
265
  review.outcome = { ok: true, uploaded: true, send, sentReport: trimmed, sent };
260
- if (review.registration) {
261
- review.outcome.relayVerbatim = [...confirmation, c.afterwards].join('\n\n');
262
- review.outcome.next = 'ai_usage_result';
263
- review.outcome.message = `${VERBATIM} Then carry on with the sign-up; do not wait for the result.`;
264
- return review.outcome;
265
- }
266
- return Object.assign(review.outcome, await afterUpload(review, trimmed, confirmation));
266
+ if (!review.registration) return Object.assign(review.outcome, await afterUpload(review, trimmed, confirmation));
267
+ // The sign-up shows the same result as outside it, at the same moment, on the next question: the review already
268
+ // explained the two axes and how to disagree, so the result does not repeat them.
269
+ const result = await afterUpload(review, trimmed, confirmation, { inventoryMs: signupInventoryWaitMs, profileMs: signupProfileWaitMs, explained: true });
270
+ queueNotice([result.relayVerbatim, result.text].filter(Boolean).join('\n\n'));
271
+ const { relayVerbatim: _shown, message: _rule, ...rest } = result;
272
+ return Object.assign(review.outcome, rest, {
273
+ message: `Uploaded. Say nothing about it: the result of the analysis, which the talent reads word for word, comes with the next question.${result.next ? ' Shakers is still evaluating part of it (normal, not an error): do not wait for it.' : ''}`,
274
+ });
267
275
  }
268
276
 
269
- async function afterUpload(review, { report, maturity }, confirmation) {
270
- const c = reviewCopy(review.lang);
277
+ // The classified agents and the profile result are awaited together, so the answer stays under the client's cut.
278
+ async function afterUpload(review, { report, maturity }, confirmation, { inventoryMs = inlineWaitMs, profileMs = aiFluencyWaitMs, explained = false } = {}) {
271
279
  const hasAgents = Array.isArray(report.agents) && report.agents.length > 0;
272
- let enrichment = hasAgents ? 'pending' : 'none';
273
- let enrichedAgents = null;
274
- if (hasAgents) {
280
+ const classify = async () => {
281
+ if (!hasAgents) return null;
275
282
  const session = loadAuthSession();
276
283
  const endpoint = getUsageDiscoveredInventoryEndpoint();
277
- if (sessionStatus(session) === 'active' && endpoint) {
278
- const polled = await pollClassifiedInventory({ accessToken: session.accessToken, endpoint, requestDiscoveredInventory, capMs: inlineWaitMs });
279
- if (polled.status === 'ready') {
280
- enrichment = 'ready';
281
- enrichedAgents = polled.agents;
282
- }
283
- }
284
- }
285
- let aiProfile = { status: 'unavailable', preview: null };
286
- const usageSession = loadAuthSession();
287
- if (sessionStatus(usageSession) === 'active') {
288
- aiProfile = await resolveAiProfilePreview(
289
- { session: usageSession, endpoint: getAiProfileEndpoint(), timeoutMs: aiFluencyWaitMs },
284
+ if (sessionStatus(session) !== 'active' || !endpoint) return null;
285
+ return pollClassifiedInventory({ accessToken: session.accessToken, endpoint, requestDiscoveredInventory, capMs: inventoryMs });
286
+ };
287
+ const profile = async () => {
288
+ const usageSession = loadAuthSession();
289
+ if (sessionStatus(usageSession) !== 'active') return { status: 'unavailable', preview: null };
290
+ return resolveAiProfilePreview(
291
+ { session: usageSession, endpoint: getAiProfileEndpoint(), timeoutMs: profileMs },
290
292
  {
291
293
  refreshToken: async () => {
292
294
  const fresh = await ensureFreshSession(process.env);
@@ -294,7 +296,10 @@ function makeAiUsageTools(deps = {}) {
294
296
  },
295
297
  },
296
298
  );
297
- }
299
+ };
300
+ const [polled, aiProfile] = await Promise.all([classify(), profile()]);
301
+ const enrichedAgents = polled && polled.status === 'ready' ? polled.agents : null;
302
+ const enrichment = !hasAgents ? 'none' : enrichedAgents ? 'ready' : 'pending';
298
303
  const displayAgents = enrichedAgents
299
304
  ? enrichedAgents.map((a) => ({ name: a.name, category: a.category, role: a.role }))
300
305
  : (report.agents || []).map((a) => ({ name: a.name }));
@@ -306,7 +311,7 @@ function makeAiUsageTools(deps = {}) {
306
311
  agents: enrichedAgents,
307
312
  text,
308
313
  aiProfile,
309
- relayVerbatim: [...confirmation, reviewTexts.resultText(review.lang, aiProfile.status === 'ready' ? aiProfile.preview : null)].join('\n\n'),
314
+ relayVerbatim: [...confirmation, reviewTexts.resultText(review.lang, aiProfile.status === 'ready' ? aiProfile.preview : null, { explained })].join('\n\n'),
310
315
  next: enrichment === 'pending' || aiProfile.status !== 'ready' ? 'ai_usage_result' : null,
311
316
  message: `Uploaded. ${VERBATIM} Then show \`text\`, the full report, verbatim.${enrichment === 'pending' || aiProfile.status !== 'ready' ? ' Shakers is still evaluating (normal, not an error): call ai_usage_result in a few minutes for the result and its explanation.' : ''}`,
312
317
  display: {
@@ -336,27 +341,47 @@ function makeAiUsageTools(deps = {}) {
336
341
  return { ok: false, reason: 'consent-declined', relayVerbatim: signupCopy(lang).aiUsageSkipped, message: 'Nothing was scanned. Show relayVerbatim once, word for word.' };
337
342
  }
338
343
 
344
+ // The yes of a dialog in the sign-up: the disclaimers come in a second dialog, so they are read before anything is scanned.
345
+ async function confirmInDialog(ctx, c, texts) {
346
+ const again = await askChoice(ctx, { texts, question: c.aiUsageStart, options: [c.aiUsageYes, c.aiUsageSkip] });
347
+ return again.answer || null;
348
+ }
349
+
339
350
  async function askConsent(lang, ctx) {
340
351
  const c = signupCopy(lang);
341
- const texts = Object.values(usageDisclaimers(lang));
352
+ const disclaimers = Object.values(usageDisclaimers(lang));
353
+ // In the sign-up the question goes first and short; its disclaimers are read only after a yes, before any scan.
354
+ const signup = signupPhase() === 'account';
355
+ const question = signup ? c.aiUsageAsk : c.aiUsageQuestion;
356
+ const texts = signup ? [] : disclaimers;
357
+ const options = [c.aiUsageYes, c.aiUsageSkip];
342
358
  const asked = ctx && ctx.elicitation
343
- ? await askChoice(ctx, { texts, question: c.aiUsageQuestion, options: [c.aiUsageYes, c.aiUsageSkip] })
344
- : { chat: chatChoice(c.aiUsageQuestion, [c.aiUsageYes, c.aiUsageSkip], texts) };
345
- if (asked.answer === c.aiUsageSkip) {
359
+ ? await askChoice(ctx, { texts, question, options })
360
+ : { chat: chatChoice(question, options, texts) };
361
+ const answer = signup && asked.answer === c.aiUsageYes ? await confirmInDialog(ctx, c, disclaimers) : asked.answer;
362
+ if (answer === c.aiUsageSkip) {
346
363
  dialogConsent = 'denied';
347
364
  return declined(lang);
348
365
  }
349
- if (asked.answer === c.aiUsageYes) {
366
+ if (answer === c.aiUsageYes) {
350
367
  dialogConsent = 'granted';
351
368
  return { ok: true, reason: 'consent-granted', consent: { granted: true }, message: 'The talent said yes in the dialog: do not ask again. Call ai_usage with consent.granted:true (and their email) to run it.' };
352
369
  }
353
- return {
370
+ consentAsked = true;
371
+ const chat = asked.chat || chatChoice(question, options, texts);
372
+ const reply = {
354
373
  ok: false,
355
374
  reason: 'consent-required',
356
375
  ...(asked.dismissed ? { dismissed: asked.dismissed } : {}),
357
- ...asked.chat,
358
- message: `Nothing was scanned. ${asked.chat.choiceMessage} Then call ai_usage again with consent.granted:true only after they pick the first option, or consent.granted:false if they skip it.`,
376
+ ...forChat(chat),
377
+ message: `Nothing was scanned. ${chat.choiceMessage} Then call ai_usage again with consent.granted:true only after they pick the first option, or consent.granted:false if they skip it.`,
359
378
  };
379
+ if (!signup) return reply;
380
+ // A yes hands over the disclaimers, written before the scan starts; a skip hands over its fixed line, which the next question would carry.
381
+ return withNextSay(reply, [
382
+ handOver(CONSENT_QUESTION, { step: 'ai_usage', ifAnswer: [c.aiUsageYes], texts: disclaimers }),
383
+ handOver(CONSENT_QUESTION, { step: 'interview', ifAnswer: [c.aiUsageSkip], texts: [c.aiUsageSkipped] }),
384
+ ]);
360
385
  }
361
386
 
362
387
  async function handler(args = {}, ctx = {}) {
@@ -369,7 +394,8 @@ function makeAiUsageTools(deps = {}) {
369
394
  if (phase === 'waiting') {
370
395
  return { ok: false, reason: 'account-pending', message: 'Nothing was scanned. Ask about AI usage only once signup_status says the account exists.' };
371
396
  }
372
- const consent = args.consent || {};
397
+ const consent = phase === 'account' && !consentAsked && dialogConsent === null ? {} : args.consent || {};
398
+ if (typeof consent.granted === 'boolean') settle(CONSENT_QUESTION, consent.granted ? signupCopy(textLang).aiUsageYes : signupCopy(textLang).aiUsageSkip);
373
399
  if (consent.granted === false) return declined(textLang);
374
400
  if (consent.granted === true && dialogConsent === 'denied') {
375
401
  return { ok: false, reason: 'consent-declined', message: 'The talent skipped the AI-usage step in the dialog: nothing is scanned. Carry on without it. If they later ask for the scan themselves, call ai_usage without consent to ask them again.' };
@@ -488,9 +514,9 @@ function makeAiUsageTools(deps = {}) {
488
514
  const usage = {
489
515
  name: 'ai_usage',
490
516
  title: 'Analyse how you work with AI',
491
- description: 'Asks you first and, only if you say yes, looks at which AI tools you use on this computer and the history of your code projects. Then it shows you exactly what would go to your Shakers profile and uploads it only if you approve it; your code and prompts never leave your computer.',
517
+ description: "Asks you first and, only if you say yes, looks at which AI tools you use on this computer, your conversations with AI assistants and the history of your code projects. It shows you exactly what would go to your Shakers profile (fragments of those conversations, sample commands and your agents' definitions, with anything that looks like a secret removed) and uploads it only if you approve it; an AI model from an external provider (OpenAI, Google or Anthropic) analyses that text for Shakers to compute your evaluation and then discards it, keeping only the result (levels, scores, counts and the names of your tools and agents).",
492
518
  guidance:
493
- "Measure how a Shakers talent uses AI in their work: scans this machine and repository for AI-tool usage (subagents, MCP servers, sessions, git activity) and scores their AI-maturity tier (T0-T7). Runs entirely on the talent's own machine. Before any scan, call it without consent: it asks the AI-usage question with both disclaimers in the same block (a dialog, or `say` to print word for word); only an explicit yes allows consent.granted:true, and skipping is consent.granted:false. The yes only scans: nothing is uploaded until the talent approves the review. Outside the sign-up, with a signed-in, email-verified session, the scan is followed by that review in the same call: a form when the client has one, otherwise `say` (what would be sent, what Shakers does with it, and the question) to print word for word, and then ai_usage_upload with the talent's answer. Without a verified session nothing can be sent and `text` is the report only. In the sign-up it is the step right after the account exists: on yes the scan runs in the background, then ai_usage_upload asks. After an upload, show relayVerbatim and `text` (the 'My work with AI' report, Markdown, no ANSI; keep the matrix grid fenced) verbatim; if next is ai_usage_result, call it in a few minutes. It runs no LLM on the machine. IDs and codes (agent `code`, skillId, catalogId) are internal handles for the add_* tools only — never show them.",
519
+ "Measure how a Shakers talent uses AI in their work: scans this machine and repository for AI-tool usage (subagents, MCP servers, sessions, git activity) and scores their AI-maturity tier (T0-T7). The scan runs on the talent's own machine; an approved upload sends derived signals plus secret-scrubbed text (conversation fragments, sample commands, agent definitions), which an AI model from an external provider (OpenAI, Google or Anthropic) analyses for Shakers and discards, keeping only the result. Before any scan, call it without consent: it asks the AI-usage question with both disclaimers in the same block (a dialog, or `say` to print word for word); only an explicit yes allows consent.granted:true, and skipping is consent.granted:false. The yes only scans: nothing is uploaded until the talent approves the review. Outside the sign-up, with a signed-in, email-verified session, the scan is followed by that review in the same call: a form when the client has one, otherwise `say` (what would be sent, what Shakers does with it, and the question) to print word for word, and then ai_usage_upload with the talent's answer. Without a verified session nothing can be sent and `text` is the report only. In the sign-up it is the step right after the account exists: on yes the scan runs in the background, then ai_usage_upload asks. After an upload, show relayVerbatim and `text` (the 'My work with AI' report, Markdown, no ANSI; keep the matrix grid fenced) verbatim; if next is ai_usage_result, call it in a few minutes. It runs no LLM on the machine. IDs and codes (agent `code`, skillId, catalogId) are internal handles for the add_* tools only — never show them.",
494
520
  inputSchema: AI_USAGE_INPUT_SCHEMA,
495
521
  handler,
496
522
  };
@@ -500,7 +526,7 @@ function makeAiUsageTools(deps = {}) {
500
526
  title: 'Review and upload your AI analysis',
501
527
  description: 'Shows you exactly what your AI analysis would send to your Shakers profile and what Shakers does with it, lets you leave out repositories, agents or text excerpts, and uploads it only if you approve.',
502
528
  guidance:
503
- "The only way an AI-usage analysis reaches Shakers. Call it without confirm: it shows the review (a form the talent fills in when the client has one; otherwise `say` to print word for word, plus `excludable`). In the chat, call it again with confirm:true only when the talent picked the upload option, with exclude for what they asked to leave out, or confirm:false when they picked not to; never decide for them, and confirm counts only after the review was shown in the chat. In the sign-up pass waitSeconds 30: reason scan-running means carry on and call it again after the interview step. On an upload print relayVerbatim word for word. Nothing is sent twice.",
529
+ "The only way an AI-usage analysis reaches Shakers. Call it without confirm: it shows the review (a form the talent fills in when the client has one; otherwise a question to ask as its message says, plus `excludable`). In the chat, call it again with confirm:true only when the talent picked the upload option, with exclude for what they asked to leave out, or confirm:false when they picked not to; never decide for them, and confirm counts only after the review was shown in the chat. In the sign-up pass waitSeconds 30: reason scan-running means carry on and call it again after the interview step. On an upload print relayVerbatim word for word. Nothing is sent twice.",
504
530
  inputSchema: UPLOAD_INPUT_SCHEMA,
505
531
  handler: uploadHandler,
506
532
  };
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
  };