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.
- package/README.md +30 -5
- package/bin/ai-usage.js +32 -19
- package/bin/mcp.js +11 -20
- package/bin/mcpb.js +5 -0
- package/package.json +2 -2
- package/src/auth-session-store.js +1 -0
- package/src/backend-request.js +2 -2
- package/src/config.js +29 -0
- package/src/cv-file.js +62 -0
- package/src/device-login.js +5 -3
- package/src/find-projects-client.js +16 -6
- package/src/i18n.js +23 -22
- package/src/mcp-ai-usage-tool.js +150 -65
- package/src/mcp-auth-form.js +186 -31
- package/src/mcp-choice.js +47 -0
- package/src/mcp-install.js +32 -3
- package/src/mcp-instructions.js +27 -0
- package/src/mcp-onboarding-tools.js +2 -2
- package/src/mcp-prompts.js +8 -6
- package/src/mcp-rate-tools.js +2 -2
- package/src/mcp-register-tools.js +89 -315
- package/src/mcp-role-tools.js +177 -16
- package/src/mcp-server.js +71 -3
- package/src/mcp-signup-client.js +118 -0
- package/src/mcp-signup-tools.js +818 -0
- package/src/onboarding-client.js +28 -52
- package/src/onboarding-flow.js +3 -147
- package/src/onboarding-status-client.js +16 -1
- package/src/register-context.js +1 -17
- package/src/roles-client.js +3 -1
- package/src/signup-client.js +33 -21
- package/src/signup-copy.js +338 -0
- package/src/signup-language.js +21 -0
- package/src/usage-run-steps.js +3 -2
package/src/mcp-ai-usage-tool.js
CHANGED
|
@@ -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
|
-
'
|
|
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: '
|
|
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
|
-
|
|
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
|
-
|
|
134
|
-
|
|
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 (
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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('
|
|
272
|
+
recordConsent('granted', email, { verified: false });
|
|
179
273
|
}
|
|
180
274
|
|
|
181
|
-
const canSubmit =
|
|
275
|
+
const canSubmit = emailVerifiedByLogin;
|
|
182
276
|
const submittedEmail = canSubmit ? verifySession.email : null;
|
|
183
277
|
|
|
184
|
-
const
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
398
|
+
mode,
|
|
312
399
|
root,
|
|
313
400
|
},
|
|
314
|
-
consent: { granted, email: canSubmit ? submittedEmail :
|
|
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.
|
|
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
|
};
|