shakers-cli-beta 0.9.0 → 0.10.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.
@@ -3,6 +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 } = require('./mcp-choice');
6
7
 
7
8
  function isExistingDir(p) {
8
9
  try {
@@ -30,13 +31,17 @@ const AI_USAGE_INPUT_SCHEMA = {
30
31
  consent: {
31
32
  type: 'object',
32
33
  description:
33
- 'Talent consent to persist the AI-usage report to Shakers. Ask the user in the chat before calling; do not assume it. When granted, an email is required.',
34
+ "The talent's explicit answer to the AI-usage question, asked right after showing both disclaimers verbatim. Nothing is scanned without granted:true. A yes to anything else is not this answer.",
34
35
  properties: {
35
- granted: { type: 'boolean', description: 'Whether the talent granted consent to send the report.' },
36
+ granted: { type: 'boolean', description: 'true ONLY after the talent explicitly said yes to the AI-usage question; false when they said no. Omit it to get the disclaimers and the question to ask.' },
36
37
  email: { type: 'string', description: 'The talent email the report is attributed to. Required when granted is true.' },
37
38
  },
38
39
  required: ['granted'],
39
40
  },
41
+ disclaimersShown: {
42
+ type: 'boolean',
43
+ description: 'true only after you showed the talent both AI-usage disclaimers word for word, right before this call. Without consent, it then asks the AI-usage question in a dialog when the client has one.',
44
+ },
40
45
  repoScope: {
41
46
  type: 'object',
42
47
  description: 'Which repositories feed the evaluation. Defaults to the current repository only.',
@@ -61,36 +66,6 @@ const AI_USAGE_INPUT_SCHEMA = {
61
66
  },
62
67
  };
63
68
 
64
- async function defaultCaptureStdout(fn) {
65
- const chunks = [];
66
- const original = process.stdout.write.bind(process.stdout);
67
- process.stdout.write = (chunk, encoding, cb) => {
68
- chunks.push(typeof chunk === 'string' ? chunk : chunk.toString(encoding || 'utf8'));
69
- if (typeof encoding === 'function') encoding();
70
- else if (typeof cb === 'function') cb();
71
- return true;
72
- };
73
- try {
74
- await fn();
75
- } finally {
76
- process.stdout.write = original;
77
- }
78
- return chunks.join('');
79
- }
80
-
81
- function parseReportJson(captured) {
82
- const text = captured || '';
83
- try {
84
- return JSON.parse(text.trim());
85
- } catch {
86
- const start = text.indexOf('{');
87
- if (start === -1) {
88
- throw new Error('ai-usage --json produced no JSON document on stdout');
89
- }
90
- return JSON.parse(text.slice(start));
91
- }
92
- }
93
-
94
69
  function buildArgv({ root, repoScope, lang }) {
95
70
  const argv = ['--json', '--no-save', '--no-ai'];
96
71
  if (root) argv.push('--root', root);
@@ -106,12 +81,11 @@ function buildArgv({ root, repoScope, lang }) {
106
81
 
107
82
  function makeAiUsageTool(deps = {}) {
108
83
  const {
109
- runAiUsage = require('../bin/ai-usage').run,
84
+ scanUsage = (argv) => require('../bin/ai-usage').scanUsage(argv),
110
85
  autoShare = require('./share').autoShare,
111
86
  recordConsent = require('./share').recordConsent,
112
87
  isValidEmail = require('./share').isValidEmail,
113
88
  persistFootprint = require('./report-store').persistFootprint,
114
- captureStdout = defaultCaptureStdout,
115
89
  cwdIsRepo = defaultCwdIsRepo,
116
90
  loadAuthSession = require('./auth-session-store').loadAuthSession,
117
91
  sessionStatus = require('./auth-session-store').sessionStatus,
@@ -128,11 +102,123 @@ function makeAiUsageTool(deps = {}) {
128
102
  renderFullReportText = require('./render-report-text').renderFullReportText,
129
103
  detectFlowLang = require('./i18n').detectFlowLang,
130
104
  aiFluencyWaitMs = 30000,
105
+ signupPhase = () => require('./mcp-signup-tools').signupPhase(),
106
+ usageDisclaimers = (lang) => require('./onboarding-flow').usageDisclaimers(lang),
107
+ signupCopy = require('./signup-copy').signupCopy,
108
+ signupLanguage = require('./signup-language').signupLanguage,
131
109
  } = deps;
132
110
 
133
- async function handler(args = {}) {
134
- const mode = (args.repoScope && args.repoScope.mode) || 'current';
111
+ // The sign-up's one scan: started on the talent's yes once the account exists, uploaded in the background.
112
+ let job = null;
113
+ // The talent's answer in the consent dialog, so a later call cannot contradict it.
114
+ let dialogConsent = null;
115
+
116
+ function startSignupScan(argv, root) {
117
+ if (job) return job;
118
+ job = { state: 'scanning', send: null, uploading: null };
119
+ // performScan is synchronous: start it after this answer has gone out.
120
+ job.scan = new Promise((resolve) => { setImmediate(resolve); }).then(() => scanUsage(argv)).then((scan) => {
121
+ try { persistFootprint({ root, report: scan.report, maturity: scan.maturity }); } catch { /* local copy is best-effort */ }
122
+ return scan;
123
+ });
124
+ job.scan.catch(() => { job.state = 'failed'; });
125
+ job.root = root;
126
+ return job;
127
+ }
128
+
129
+ function uploadSignupScan() {
130
+ if (!job) return Promise.resolve(null);
131
+ if (job.uploading) return job.uploading;
132
+ const current = job;
133
+ current.uploading = current.scan.then(async (scan) => {
134
+ const session = loadAuthSession();
135
+ if (sessionStatus(session) !== 'active' || !session || !isValidEmail(session.email)) {
136
+ current.uploading = null;
137
+ return { ok: false, skipped: true, reason: 'no-session' };
138
+ }
139
+ current.state = 'uploading';
140
+ // Nobody awaits this promise: a local write error must end as a failed send, never as an
141
+ // unhandled rejection that takes the MCP process down.
142
+ let send;
143
+ try {
144
+ recordConsent('granted', session.email, { verified: true });
145
+ send = await autoShare(scan.report, scan.maturity, { root: current.root, bypassThrottle: true });
146
+ } catch (error) {
147
+ send = { ok: false, reason: 'upload-failed', message: error && error.message ? error.message : String(error) };
148
+ }
149
+ current.state = send && send.ok ? 'sent' : 'failed';
150
+ current.send = send;
151
+ return send;
152
+ }, () => ({ ok: false, skipped: true, reason: 'scan-failed' }));
153
+ return current.uploading;
154
+ }
155
+ function consentRequired(lang, ctx) {
156
+ const c = signupCopy(lang);
157
+ const disclaimers = Object.values(usageDisclaimers(lang));
158
+ if (ctx && ctx.elicitation) {
159
+ return {
160
+ ok: false,
161
+ reason: 'consent-required',
162
+ relayVerbatim: disclaimers,
163
+ message: 'Nothing was scanned. Show the talent each relayVerbatim text word for word, each as its own block, without labels or paraphrase; then call ai_usage again with disclaimersShown:true and no consent: it asks the question in a dialog.',
164
+ };
165
+ }
166
+ return {
167
+ ok: false,
168
+ reason: 'consent-required',
169
+ relayVerbatim: disclaimers,
170
+ question: c.aiUsageQuestion,
171
+ options: [c.aiUsageYes, c.aiUsageSkip],
172
+ message: 'Nothing was scanned. Show the talent each relayVerbatim text word for word, each as its own block, without labels or paraphrase, then `question` with its `options` (your native choice buttons if you have them, otherwise a short numbered list). Then call again with consent.granted:true only after they pick the first option, or consent.granted:false if they skip it.',
173
+ };
174
+ }
175
+
176
+ function declined(lang) {
177
+ recordConsent('denied');
178
+ return { ok: false, reason: 'consent-declined', relayVerbatim: signupCopy(lang).aiUsageSkipped, message: 'Nothing was scanned. Show relayVerbatim once, word for word, and carry on with the interview offer.' };
179
+ }
180
+
181
+ async function askConsent(lang, ctx) {
182
+ const c = signupCopy(lang);
183
+ const asked = await askChoice(ctx, { question: c.aiUsageQuestion, options: [c.aiUsageYes, c.aiUsageSkip] });
184
+ if (asked.answer === c.aiUsageSkip) {
185
+ dialogConsent = 'denied';
186
+ return declined(lang);
187
+ }
188
+ if (asked.answer === c.aiUsageYes) {
189
+ dialogConsent = 'granted';
190
+ 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.' };
191
+ }
192
+ return {
193
+ ok: false,
194
+ reason: 'consent-required',
195
+ ...(asked.dismissed ? { dismissed: asked.dismissed } : {}),
196
+ ...asked.chat,
197
+ message: `Nothing was scanned. The disclaimers are already shown: do not repeat them. ${asked.chat.choiceMessage} Then call again with consent.granted:true only after an explicit yes, or consent.granted:false if they say no.`,
198
+ };
199
+ }
200
+
201
+ async function handler(args = {}, ctx = {}) {
202
+ const phase = signupPhase();
203
+ // The sign-up looks at the whole machine: the server rarely runs inside a repository there.
204
+ const repoScope = args.repoScope || (phase === 'account' ? { mode: 'all' } : undefined);
205
+ const mode = (repoScope && repoScope.mode) || 'current';
135
206
  const lang = args.lang === 'es' || args.lang === 'en' ? args.lang : detectFlowLang();
207
+ const textLang = signupLanguage(lang);
208
+ if (phase === 'waiting') {
209
+ return { ok: false, reason: 'account-pending', message: 'Nothing was scanned. Ask about AI usage only once signup_status says the account exists.' };
210
+ }
211
+ const consent = args.consent || {};
212
+ if (consent.granted === false) return declined(textLang);
213
+ if (consent.granted === true && dialogConsent === 'denied') {
214
+ 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.' };
215
+ }
216
+ if (consent.granted !== true) {
217
+ if (args.disclaimersShown !== true || !(ctx && ctx.elicitation)) return consentRequired(textLang, ctx);
218
+ const answered = await askConsent(textLang, ctx);
219
+ // In the sign-up a yes in the dialog runs the scan right away, in the background.
220
+ if (!(answered.reason === 'consent-granted' && phase === 'account')) return answered;
221
+ }
136
222
 
137
223
  let root;
138
224
  if (typeof args.root === 'string' && args.root) {
@@ -155,9 +241,21 @@ function makeAiUsageTool(deps = {}) {
155
241
  }
156
242
  }
157
243
 
158
- const consent = args.consent || {};
159
- const granted = consent.granted === true;
160
244
  const email = typeof consent.email === 'string' ? consent.email.trim() : '';
245
+ const argv = buildArgv({ root, repoScope, lang: args.lang });
246
+
247
+ // In the sign-up the scan never holds the conversation: it runs and uploads in the background.
248
+ if (phase === 'account') {
249
+ const current = startSignupScan(argv, root);
250
+ uploadSignupScan();
251
+ return {
252
+ ok: true,
253
+ background: true,
254
+ send: current.send || { ok: false, skipped: true, reason: 'uploading' },
255
+ scope: { mode, root },
256
+ message: 'The analysis runs and uploads in the background; Shakers completes the profile with it in a few minutes. Do not wait for it and do not call ai_usage again: carry on with the interview offer.',
257
+ };
258
+ }
161
259
 
162
260
  const verifySession = loadAuthSession();
163
261
  const emailVerifiedByLogin =
@@ -165,28 +263,19 @@ function makeAiUsageTool(deps = {}) {
165
263
  && !!verifySession
166
264
  && isValidEmail(verifySession.email);
167
265
 
168
- if (granted) {
169
- if (!isValidEmail(email)) {
170
- throw new Error('consent.granted is true but consent.email is missing or invalid — ask the talent for their email.');
171
- }
172
- if (emailVerifiedByLogin) {
173
- recordConsent('granted', verifySession.email, { verified: true });
174
- } else {
175
- recordConsent('granted', email, { verified: false });
176
- }
266
+ if (!isValidEmail(email)) {
267
+ throw new Error('consent.granted is true but consent.email is missing or invalid — ask the talent for their email.');
268
+ }
269
+ if (emailVerifiedByLogin) {
270
+ recordConsent('granted', verifySession.email, { verified: true });
177
271
  } else {
178
- recordConsent('denied');
272
+ recordConsent('granted', email, { verified: false });
179
273
  }
180
274
 
181
- const canSubmit = granted && emailVerifiedByLogin;
275
+ const canSubmit = emailVerifiedByLogin;
182
276
  const submittedEmail = canSubmit ? verifySession.email : null;
183
277
 
184
- const argv = buildArgv({ root, repoScope: args.repoScope, lang: args.lang });
185
-
186
- let parsed;
187
- const captured = await captureStdout(() => runAiUsage(argv));
188
- parsed = parseReportJson(captured);
189
- const { report, maturity } = parsed;
278
+ const { report, maturity } = await scanUsage(argv);
190
279
 
191
280
  let savedLocally = false;
192
281
  try {
@@ -198,7 +287,7 @@ function makeAiUsageTool(deps = {}) {
198
287
 
199
288
  const send = canSubmit
200
289
  ? await autoShare(report, maturity, { root })
201
- : { ok: false, skipped: true, reason: granted ? 'email-unverified' : 'consent-denied' };
290
+ : { ok: false, skipped: true, reason: 'email-unverified' };
202
291
  const submitted = !!(send && send.ok);
203
292
 
204
293
  const hasAgents = Array.isArray(report.agents) && report.agents.length > 0;
@@ -263,14 +352,12 @@ function makeAiUsageTool(deps = {}) {
263
352
  : enrichment === 'pending'
264
353
  ? 'Report submitted. Shakers is classifying the agents server-side (this is normal, not an error) — call ai_usage_result shortly to get their categories and descriptions.'
265
354
  : 'Report submitted.';
266
- } else if (send.reason === 'consent-denied') {
267
- message = 'Not submitted — consent denied. The report was computed and saved locally only; nothing was sent to Shakers. Do not point the talent to "My work with AI".';
268
355
  } else if (send.reason === 'email-unverified') {
269
356
  message = "Not submitted — the talent's email is not verified. Ask them to log in / verify with the Shakers CLI (`shakers login`) first, then run ai_usage again; nothing was sent to Shakers.";
270
357
  } else if (send.skipped) {
271
- message = `Not submitted (${send.reason}). The report was computed and saved locally only; nothing was sent to Shakers.`;
358
+ message = `Not submitted (${send.reason}). The report was kept on this machine only; nothing was sent to Shakers.`;
272
359
  } else {
273
- message = `Submit failed (${send.reason}). The report was saved locally only; retry later.`;
360
+ message = `Submit failed (${send.reason}). The report was kept on this machine only; retry later.`;
274
361
  }
275
362
 
276
363
  return {
@@ -308,17 +395,17 @@ function makeAiUsageTool(deps = {}) {
308
395
  agents: displayAgents,
309
396
  },
310
397
  scope: {
311
- mode: (args.repoScope && args.repoScope.mode) || 'current',
398
+ mode,
312
399
  root,
313
400
  },
314
- consent: { granted, email: canSubmit ? submittedEmail : (granted ? email : null) },
401
+ consent: { granted: true, email: canSubmit ? submittedEmail : email },
315
402
  };
316
403
  }
317
404
 
318
405
  return {
319
406
  name: 'ai_usage',
320
407
  description:
321
- "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), scores their AI-maturity tier (T0-T7), and, with the talent's consent, submits the report to Shakers so their profile reflects it. Runs entirely on the talent's own machine. Ask the talent for consent and their email in the chat before calling. Submission also requires an active, email-verified Shakers session (via `shakers login` / registration OTP): with consent denied OR no verified session the report is computed and saved locally only and nothing is sent — check the `send` field for the outcome. Always fast and deterministic: it runs no LLM locally. Agent descriptions and catalog classification are produced by Shakers server-side. This waits briefly (~30s) and, if classification finishes, returns the enriched agents inline; otherwise it returns enrichment:'pending' (normal progress, not an error) and you should call `ai_usage_result` to get them. The `text` field is a ready-to-show plain-text 'My work with AI' report (Markdown, no ANSI) mirroring the CLI terminal — render it verbatim to the talent; the matrix grid is inside a fenced code block, keep it fenced so it stays aligned. Use the `display` block for structured access: show the tier (with the tierLegend explaining T0-T7) and the agent list by NAME and category/role. IDs and codes (agent `code`, skillId, catalogId) are internal handles for the add_* tools only — never show raw IDs or codes to the talent.",
408
+ "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), scores their AI-maturity tier (T0-T7), and, with the talent's consent, submits the report to Shakers so their profile reflects it. Runs entirely on the talent's own machine. Before any scan, show the talent both AI-usage disclaimers word for word, each as its own block, right before the AI-usage question (call without consent to get those texts and the question); only an explicit yes allows consent.granted:true, and skipping is consent.granted:false. In the sign-up it is the step right after the account exists and before the interview: on yes it runs and uploads in the background, never wait for it. Submission also requires an active, email-verified Shakers session (via `shakers login` / registration OTP): without a verified session the report stays on this machine and nothing is sent — check the `send` field for the outcome. Always fast and deterministic: it runs no LLM on the machine. Agent descriptions and catalog classification are produced by Shakers server-side. This waits briefly (~30s) and, if classification finishes, returns the enriched agents inline; otherwise it returns enrichment:'pending' (normal progress, not an error) and you should call `ai_usage_result` to get them. The `text` field is a ready-to-show plain-text 'My work with AI' report (Markdown, no ANSI) mirroring the CLI terminal — render it verbatim to the talent; the matrix grid is inside a fenced code block, keep it fenced so it stays aligned. Use the `display` block for structured access: show the tier (with the tierLegend explaining T0-T7) and the agent list by NAME and category/role. IDs and codes (agent `code`, skillId, catalogId) are internal handles for the add_* tools only — never show raw IDs or codes to the talent.",
322
409
  inputSchema: AI_USAGE_INPUT_SCHEMA,
323
410
  handler,
324
411
  };
@@ -328,6 +415,4 @@ module.exports = {
328
415
  makeAiUsageTool,
329
416
  AI_USAGE_INPUT_SCHEMA,
330
417
  buildArgv,
331
- parseReportJson,
332
- defaultCaptureStdout,
333
418
  };