shakers-cli 0.16.0 → 0.17.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 +11 -6
- package/bin/ai-usage.js +31 -12
- package/bin/mcp.js +5 -4
- package/bin/register.js +2 -1
- package/bin/shakers-cli.js +1 -1
- package/package.json +1 -1
- package/src/account-marker.js +40 -0
- package/src/account-trace.js +23 -0
- package/src/ai-usage-review.js +101 -0
- package/src/auth-session-store.js +7 -0
- package/src/backend-request.js +2 -0
- package/src/config.js +2 -0
- package/src/consent-flow.js +3 -2
- package/src/i18n.js +24 -14
- package/src/mcp-ai-usage-result-tool.js +34 -12
- package/src/mcp-ai-usage-tool.js +281 -162
- package/src/mcp-auth-form.js +6 -2
- package/src/mcp-install.js +279 -44
- package/src/mcp-instructions.js +1 -1
- package/src/mcp-prompts.js +2 -2
- package/src/mcp-role-tools.js +24 -1
- package/src/mcp-signup-tools.js +18 -12
- package/src/onboarding-flow.js +47 -26
- package/src/onboarding-funnel.js +46 -6
- package/src/render-report-text.js +1 -1
- package/src/report-gating.js +4 -2
- package/src/report-store.js +7 -2
- package/src/roles-flow.js +8 -5
- package/src/share-consent-state.js +2 -2
- package/src/signup-copy.js +176 -1
- package/src/stdin-ask.js +13 -1
- package/src/usage-run-steps.js +57 -4
- package/src/usage-scan.js +8 -8
package/src/mcp-install.js
CHANGED
|
@@ -38,11 +38,57 @@ function stableNodePath(env, execPath) {
|
|
|
38
38
|
return execPath;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
const pathFor = (platform) => (platform === 'win32' ? path.win32 : path.posix);
|
|
42
|
+
|
|
43
|
+
function realpathOr(dir) {
|
|
44
|
+
try {
|
|
45
|
+
return fs.realpathSync(dir);
|
|
46
|
+
} catch {
|
|
47
|
+
return dir;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function insideNpxCache(file, { env, platform, homedir }) {
|
|
52
|
+
const p = pathFor(platform);
|
|
53
|
+
const defaultCache = platform === 'win32'
|
|
54
|
+
? p.join(env.LOCALAPPDATA || p.join(homedir, 'AppData', 'Local'), 'npm-cache')
|
|
55
|
+
: p.join(homedir, '.npm');
|
|
56
|
+
return [env.npm_config_cache, defaultCache].filter(Boolean).flatMap((dir) => [dir, realpathOr(dir)]).some((dir) => {
|
|
57
|
+
const rel = p.relative(p.join(dir, '_npx'), file);
|
|
58
|
+
return rel !== '' && !rel.startsWith('..') && !p.isAbsolute(rel);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function withoutNpxBins(value, where) {
|
|
63
|
+
const p = pathFor(where.platform);
|
|
64
|
+
return value.split(p.delimiter).filter((dir) => {
|
|
65
|
+
const parts = p.normalize(dir).split(p.sep);
|
|
66
|
+
const [last, parent] = [parts[parts.length - 1], parts[parts.length - 2]];
|
|
67
|
+
return !(last === 'node-gyp-bin' || (last === '.bin' && parent === 'node_modules') || insideNpxCache(dir, where));
|
|
68
|
+
}).join(p.delimiter);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function npxNextTo(nodePath) {
|
|
72
|
+
const candidate = path.join(path.dirname(nodePath), 'npx');
|
|
73
|
+
try {
|
|
74
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
75
|
+
return candidate;
|
|
76
|
+
} catch {
|
|
77
|
+
return 'npx';
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function serverEntry({ nodePath, cliPath, env, platform = process.platform, homedir = os.homedir(), packageName }) {
|
|
82
|
+
const where = { env, platform, homedir };
|
|
83
|
+
const viaNpx = insideNpxCache(cliPath, where);
|
|
42
84
|
const entryEnv = {};
|
|
43
|
-
|
|
85
|
+
const pathValue = env.PATH && viaNpx ? withoutNpxBins(env.PATH, where) : env.PATH;
|
|
86
|
+
if (pathValue) entryEnv.PATH = pathValue;
|
|
44
87
|
for (const key of FORWARDED_ENV) if (env[key]) entryEnv[key] = env[key];
|
|
45
|
-
return { command: nodePath, args: [cliPath, 'mcp'], env: entryEnv };
|
|
88
|
+
if (!viaNpx) return { command: nodePath, args: [cliPath, 'mcp'], env: entryEnv };
|
|
89
|
+
const npxArgs = ['-y', packageName, 'mcp'];
|
|
90
|
+
if (platform === 'win32') return { command: 'cmd', args: ['/c', 'npx', ...npxArgs], env: entryEnv };
|
|
91
|
+
return { command: npxNextTo(nodePath), args: npxArgs, env: entryEnv };
|
|
46
92
|
}
|
|
47
93
|
|
|
48
94
|
function claudeDesktopConfigPath({ platform, homedir, env }) {
|
|
@@ -51,11 +97,11 @@ function claudeDesktopConfigPath({ platform, homedir, env }) {
|
|
|
51
97
|
return path.join(homedir, '.config', 'Claude', 'claude_desktop_config.json');
|
|
52
98
|
}
|
|
53
99
|
|
|
54
|
-
function
|
|
100
|
+
function writeFileAtomic(file, text, mode) {
|
|
55
101
|
const tmp = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${Date.now()}.tmp`);
|
|
56
102
|
const fd = fs.openSync(tmp, 'wx', mode);
|
|
57
103
|
try {
|
|
58
|
-
fs.writeSync(fd,
|
|
104
|
+
fs.writeSync(fd, text);
|
|
59
105
|
fs.fsyncSync(fd);
|
|
60
106
|
} catch (e) {
|
|
61
107
|
fs.closeSync(fd);
|
|
@@ -78,14 +124,14 @@ function installIntoJsonConfig(file, entry, key = 'mcpServers') {
|
|
|
78
124
|
try {
|
|
79
125
|
config = raw.trim() ? JSON.parse(raw) : {};
|
|
80
126
|
} catch {
|
|
81
|
-
return { status: 'invalid-
|
|
127
|
+
return { status: 'invalid-config' };
|
|
82
128
|
}
|
|
83
|
-
if (!config || typeof config !== 'object' || Array.isArray(config)) return { status: 'invalid-
|
|
129
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) return { status: 'invalid-config' };
|
|
84
130
|
mode = fs.statSync(file).mode & 0o777;
|
|
85
131
|
}
|
|
86
132
|
const current = config[key];
|
|
87
133
|
if (current !== undefined && (!current || typeof current !== 'object' || Array.isArray(current))) {
|
|
88
|
-
return { status: 'invalid-
|
|
134
|
+
return { status: 'invalid-config' };
|
|
89
135
|
}
|
|
90
136
|
const servers = current || {};
|
|
91
137
|
const legacy = Object.prototype.hasOwnProperty.call(servers, LEGACY_SERVER_NAME);
|
|
@@ -94,19 +140,156 @@ function installIntoJsonConfig(file, entry, key = 'mcpServers') {
|
|
|
94
140
|
const next = { ...config, [key]: { ...servers, [SERVER_NAME]: entry } };
|
|
95
141
|
delete next[key][LEGACY_SERVER_NAME];
|
|
96
142
|
if (fs.existsSync(file)) fs.copyFileSync(file, `${file}.bak`);
|
|
97
|
-
|
|
143
|
+
writeFileAtomic(file, `${JSON.stringify(next, null, 2)}\n`, mode);
|
|
98
144
|
return { status: 'configured', removedLegacy: legacy };
|
|
99
145
|
}
|
|
100
146
|
|
|
147
|
+
const TOML_ESCAPES = { '"': '\\"', '\\': '\\\\', '\b': '\\b', '\t': '\\t', '\n': '\\n', '\f': '\\f', '\r': '\\r' };
|
|
148
|
+
const tomlString = (s) => `"${String(s).replace(/["\\\u0000-\u001f\u007f]/g, (ch) => TOML_ESCAPES[ch] || `\\u${ch.charCodeAt(0).toString(16).padStart(4, '0')}`)}"`;
|
|
149
|
+
const tomlKey = (k) => (/^[A-Za-z0-9_-]+$/.test(k) ? k : tomlString(k));
|
|
150
|
+
|
|
151
|
+
function tomlServerBlock(entry, eol = '\n') {
|
|
152
|
+
const env = Object.entries(entry.env || {});
|
|
153
|
+
return [
|
|
154
|
+
`[mcp_servers.${SERVER_NAME}]`,
|
|
155
|
+
`command = ${tomlString(entry.command)}`,
|
|
156
|
+
`args = [${entry.args.map(tomlString).join(', ')}]`,
|
|
157
|
+
...(env.length ? [`env = { ${env.map(([k, v]) => `${tomlKey(k)} = ${tomlString(v)}`).join(', ')} }`] : []),
|
|
158
|
+
'startup_timeout_sec = 30',
|
|
159
|
+
'',
|
|
160
|
+
].join(eol);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function parseTomlKeyPath(text) {
|
|
164
|
+
const keys = [];
|
|
165
|
+
let rest = text.trim();
|
|
166
|
+
while (rest) {
|
|
167
|
+
const m = rest.match(/^(?:([A-Za-z0-9_-]+)|"((?:[^"\\]|\\.)*)"|'([^']*)')\s*/);
|
|
168
|
+
if (!m) return null;
|
|
169
|
+
keys.push(m[1] ?? (m[2] !== undefined ? JSON.parse(`"${m[2]}"`) : m[3]));
|
|
170
|
+
rest = rest.slice(m[0].length);
|
|
171
|
+
if (!rest) break;
|
|
172
|
+
if (rest[0] !== '.') return null;
|
|
173
|
+
rest = rest.slice(1).trimStart();
|
|
174
|
+
if (!rest) return null;
|
|
175
|
+
}
|
|
176
|
+
return keys.length ? keys : null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function scanTomlValue(line, state) {
|
|
180
|
+
let i = 0;
|
|
181
|
+
while (i < line.length) {
|
|
182
|
+
if (state.multiline) {
|
|
183
|
+
const end = line.indexOf(state.multiline, i);
|
|
184
|
+
if (end === -1) return;
|
|
185
|
+
i = end + 3;
|
|
186
|
+
state.multiline = null;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const ch = line[i];
|
|
190
|
+
if (ch === '#') return;
|
|
191
|
+
if (line.startsWith('"""', i) || line.startsWith("'''", i)) {
|
|
192
|
+
state.multiline = line.slice(i, i + 3);
|
|
193
|
+
i += 3;
|
|
194
|
+
} else if (ch === '"' || ch === "'") {
|
|
195
|
+
i += 1;
|
|
196
|
+
while (i < line.length && line[i] !== ch) i += ch === '"' && line[i] === '\\' ? 2 : 1;
|
|
197
|
+
i += 1;
|
|
198
|
+
} else {
|
|
199
|
+
if (ch === '[' || ch === '{') state.depth += 1;
|
|
200
|
+
if (ch === ']' || ch === '}') state.depth -= 1;
|
|
201
|
+
i += 1;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const OUR_TOML_NAMES = [SERVER_NAME, LEGACY_SERVER_NAME];
|
|
207
|
+
const isOurs = (keys) => keys.length >= 2 && keys[0] === 'mcp_servers' && OUR_TOML_NAMES.includes(keys[1]);
|
|
208
|
+
|
|
209
|
+
function removeTomlServers(text) {
|
|
210
|
+
const lines = text.match(/[^\n]*\n|[^\n]+$/g) || [];
|
|
211
|
+
const state = { multiline: null, depth: 0 };
|
|
212
|
+
const kept = [];
|
|
213
|
+
let table = [];
|
|
214
|
+
let ours = false;
|
|
215
|
+
let tail = [];
|
|
216
|
+
let removedLegacy = false;
|
|
217
|
+
const flushTail = () => {
|
|
218
|
+
const firstComment = tail.findIndex((l) => l.trim().startsWith('#'));
|
|
219
|
+
if (firstComment !== -1) kept.push(...tail.slice(firstComment));
|
|
220
|
+
tail = [];
|
|
221
|
+
};
|
|
222
|
+
for (const line of lines) {
|
|
223
|
+
const bare = line.replace(/\r?\n$/, '');
|
|
224
|
+
const trimmed = bare.trim();
|
|
225
|
+
if (!state.multiline && state.depth === 0 && trimmed.startsWith('[')) {
|
|
226
|
+
const header = trimmed.match(/^(\[\[?)\s*(.*?)\s*(\]\]?)\s*(#.*)?$/);
|
|
227
|
+
const keys = header && header[1].length === header[3].length ? parseTomlKeyPath(header[2]) : null;
|
|
228
|
+
if (!keys) return null;
|
|
229
|
+
if (header[1] === '[[' && keys[0] === 'mcp_servers' && (keys.length === 1 || isOurs(keys))) return null;
|
|
230
|
+
if (isOurs(keys)) {
|
|
231
|
+
if (keys[1] === LEGACY_SERVER_NAME) removedLegacy = true;
|
|
232
|
+
ours = true;
|
|
233
|
+
tail = [];
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (ours) flushTail();
|
|
237
|
+
ours = false;
|
|
238
|
+
table = keys;
|
|
239
|
+
kept.push(line);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (!state.multiline && state.depth === 0 && trimmed && !trimmed.startsWith('#')) {
|
|
243
|
+
const eq = bare.match(/^\s*((?:[A-Za-z0-9_.\s-]|"(?:[^"\\]|\\.)*"|'[^']*')+?)\s*=/);
|
|
244
|
+
const keys = eq && parseTomlKeyPath(eq[1]);
|
|
245
|
+
if (!keys) return null;
|
|
246
|
+
const full = [...table, ...keys];
|
|
247
|
+
if (!ours && full[0] === 'mcp_servers' && (full.length === 1 || OUR_TOML_NAMES.includes(full[1]))) return null;
|
|
248
|
+
scanTomlValue(bare.slice(eq[0].length), state);
|
|
249
|
+
} else if (state.multiline || state.depth > 0) {
|
|
250
|
+
scanTomlValue(bare, state);
|
|
251
|
+
}
|
|
252
|
+
if (!ours) kept.push(line);
|
|
253
|
+
else if (!trimmed || trimmed.startsWith('#')) tail.push(line);
|
|
254
|
+
else tail = [];
|
|
255
|
+
}
|
|
256
|
+
if (state.multiline || state.depth !== 0) return null;
|
|
257
|
+
if (ours) flushTail();
|
|
258
|
+
return { text: kept.join(''), removedLegacy };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function installIntoTomlConfig(file, entry) {
|
|
262
|
+
if (!fs.existsSync(path.dirname(file))) return { status: 'not-found' };
|
|
263
|
+
const exists = fs.existsSync(file);
|
|
264
|
+
const raw = exists ? fs.readFileSync(file, 'utf8') : '';
|
|
265
|
+
const removed = removeTomlServers(raw);
|
|
266
|
+
if (!removed) return { status: 'invalid-config' };
|
|
267
|
+
const eol = raw.includes('\r\n') ? '\r\n' : '\n';
|
|
268
|
+
let base = removed.text;
|
|
269
|
+
if (base && !base.endsWith('\n')) base += eol;
|
|
270
|
+
if (base && !/\r?\n\r?\n$/.test(base)) base += eol;
|
|
271
|
+
const next = base + tomlServerBlock(entry, eol);
|
|
272
|
+
if (next === raw) return { status: 'unchanged' };
|
|
273
|
+
const mode = exists ? fs.statSync(file).mode & 0o777 : 0o600;
|
|
274
|
+
if (exists) fs.copyFileSync(file, `${file}.bak`);
|
|
275
|
+
writeFileAtomic(file, next, mode);
|
|
276
|
+
return { status: 'configured', removedLegacy: removed.removedLegacy };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function shellQuote(arg, platform) {
|
|
280
|
+
if (platform === 'win32') return /^[A-Za-z0-9_\-./=@\\:]+$/.test(arg) ? arg : `"${arg}"`;
|
|
281
|
+
return /^[A-Za-z0-9_\-./=@%+,]+$/.test(arg) ? arg : `'${arg.replace(/'/g, "'\\''")}'`;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function claudeCodeAddArgs(entry) {
|
|
285
|
+
const envFlags = Object.entries(entry.env).flatMap(([k, v]) => ['-e', `${k}=${v}`]);
|
|
286
|
+
// Name before `-e`: it swallows every value up to `--`.
|
|
287
|
+
return ['mcp', 'add', SERVER_NAME, '--scope', 'user', ...envFlags, '--', entry.command, ...entry.args];
|
|
288
|
+
}
|
|
289
|
+
|
|
101
290
|
function installIntoClaudeCode(entry, { spawn }) {
|
|
102
291
|
spawn('claude', ['mcp', 'remove', '--scope', 'user', SERVER_NAME], { encoding: 'utf8' });
|
|
103
|
-
const
|
|
104
|
-
const added = spawn(
|
|
105
|
-
'claude',
|
|
106
|
-
// Name first: `-e` takes every value up to `--`, so a name after it is read as one more variable.
|
|
107
|
-
['mcp', 'add', SERVER_NAME, '--scope', 'user', ...envFlags, '--', entry.command, ...entry.args],
|
|
108
|
-
{ encoding: 'utf8' },
|
|
109
|
-
);
|
|
292
|
+
const added = spawn('claude', claudeCodeAddArgs(entry), { encoding: 'utf8' });
|
|
110
293
|
if (added.error || added.status !== 0) return { status: 'failed' };
|
|
111
294
|
return { status: 'configured' };
|
|
112
295
|
}
|
|
@@ -133,18 +316,23 @@ function codexSkill() {
|
|
|
133
316
|
}
|
|
134
317
|
|
|
135
318
|
function installCodexSkill({ env, homedir }) {
|
|
136
|
-
const dir = path.join(env
|
|
319
|
+
const dir = path.join(codexHomeDir({ env, homedir }), 'skills', SERVER_NAME);
|
|
137
320
|
fs.mkdirSync(dir, { recursive: true });
|
|
138
321
|
fs.writeFileSync(path.join(dir, 'SKILL.md'), codexSkill());
|
|
139
322
|
}
|
|
140
323
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
324
|
+
const codexHomeDir = ({ env, homedir }) => env.CODEX_HOME || path.join(homedir, '.codex');
|
|
325
|
+
|
|
326
|
+
function chatgptAppInstalled({ platform, homedir }) {
|
|
327
|
+
return platform === 'darwin' && fs.existsSync(path.join(homedir, 'Library', 'Application Support', 'com.openai.chat'));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function installIntoCodex(entry, { env, homedir }) {
|
|
331
|
+
const dir = codexHomeDir({ env, homedir });
|
|
332
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
333
|
+
const result = installIntoTomlConfig(path.join(dir, 'config.toml'), entry);
|
|
334
|
+
if (result.status === 'configured' || result.status === 'unchanged') installCodexSkill({ env, homedir });
|
|
335
|
+
return result;
|
|
148
336
|
}
|
|
149
337
|
|
|
150
338
|
function vscodeUserDir({ platform, homedir, env }) {
|
|
@@ -165,10 +353,20 @@ function clientTable({ env, platform, homedir, spawn }) {
|
|
|
165
353
|
});
|
|
166
354
|
return [
|
|
167
355
|
jsonClient('claudeDesktop', claudeDesktopConfigPath({ platform, homedir, env })),
|
|
168
|
-
{
|
|
356
|
+
{
|
|
357
|
+
client: 'claudeCode',
|
|
358
|
+
found: () => hasCli('claude') || fs.existsSync(path.join(homedir, '.claude.json')),
|
|
359
|
+
install: (entry) => (hasCli('claude')
|
|
360
|
+
? installIntoClaudeCode(entry, { spawn })
|
|
361
|
+
: { status: 'manual', command: ['claude', ...claudeCodeAddArgs(entry)].map((a) => shellQuote(a, platform)).join(' ') }),
|
|
362
|
+
},
|
|
169
363
|
jsonClient('cursor', path.join(homedir, '.cursor', 'mcp.json')),
|
|
170
364
|
jsonClient('geminiCli', path.join(homedir, '.gemini', 'settings.json')),
|
|
171
|
-
{
|
|
365
|
+
{
|
|
366
|
+
client: 'codex',
|
|
367
|
+
found: () => fs.existsSync(codexHomeDir({ env, homedir })) || hasCli('codex') || chatgptAppInstalled({ platform, homedir }),
|
|
368
|
+
install: (entry) => installIntoCodex(entry, { env, homedir }),
|
|
369
|
+
},
|
|
172
370
|
// VS Code keys servers under "servers" and wants the transport spelled out.
|
|
173
371
|
jsonClient('vscode', path.join(vscodeUserDir({ platform, homedir, env }), 'mcp.json'), 'servers', (entry) => ({ type: 'stdio', ...entry })),
|
|
174
372
|
jsonClient('windsurf', path.join(homedir, '.codeium', 'windsurf', 'mcp_config.json')),
|
|
@@ -196,9 +394,10 @@ function installMcpClients({
|
|
|
196
394
|
homedir = os.homedir(),
|
|
197
395
|
nodePath = stableNodePath(env, process.execPath),
|
|
198
396
|
cliPath = fs.realpathSync(path.join(__dirname, '..', 'bin', 'shakers.js')),
|
|
397
|
+
packageName = require('../package.json').name,
|
|
199
398
|
spawn = spawnSync,
|
|
200
399
|
} = {}) {
|
|
201
|
-
const entry = serverEntry({ nodePath, cliPath, env });
|
|
400
|
+
const entry = serverEntry({ nodePath, cliPath, env, platform, homedir, packageName });
|
|
202
401
|
return {
|
|
203
402
|
entry,
|
|
204
403
|
results: clientTable({ env, platform, homedir, spawn }).map((c) => ({
|
|
@@ -231,45 +430,81 @@ function askTerminal(question) {
|
|
|
231
430
|
});
|
|
232
431
|
}
|
|
233
432
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
433
|
+
const NEEDS_RESTART = new Set(['configured', 'manual']);
|
|
434
|
+
|
|
435
|
+
function parseInstallArgs(args) {
|
|
436
|
+
const flags = new Set(args);
|
|
437
|
+
return {
|
|
438
|
+
yes: flags.has('--yes') || flags.has('-y'),
|
|
439
|
+
json: flags.has('--json'),
|
|
440
|
+
help: flags.has('--help') || flags.has('-h'),
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async function connectClients({ c, opts, out, ask, deps }) {
|
|
241
445
|
const env = deps.env || process.env;
|
|
242
446
|
const found = detectMcpClients(deps);
|
|
243
447
|
out(`\n ${c.title}\n\n`);
|
|
244
448
|
if (found.length === 0) {
|
|
245
449
|
out(` ${c.noneFound}\n\n`);
|
|
246
|
-
return { ok: true, results: [] };
|
|
450
|
+
return { ok: true, found, results: [], reason: 'no-apps' };
|
|
247
451
|
}
|
|
248
452
|
out(` ${c.found(found.map((client) => c.clients[client]).join(', '))}\n`);
|
|
249
|
-
let yes = env.SHAKERS_MCP_INSTALL === '1';
|
|
453
|
+
let yes = opts.yes || env.SHAKERS_MCP_INSTALL === '1';
|
|
250
454
|
if (!yes) {
|
|
251
455
|
const answer = await ask(` ${c.confirm} `);
|
|
252
456
|
if (answer === null) {
|
|
253
|
-
out(`\n ${c.noTerminal}\n\n`);
|
|
254
|
-
return { ok: true, results: [], declined: true };
|
|
457
|
+
out(`\n ${c.noTerminal}\n ${c.yesHint}\n\n`);
|
|
458
|
+
return { ok: true, found, results: [], declined: true, reason: 'no-terminal' };
|
|
255
459
|
}
|
|
256
460
|
yes = !/^\s*(n|no)\s*$/i.test(answer);
|
|
257
461
|
}
|
|
258
462
|
if (!yes) {
|
|
259
463
|
out(`\n ${c.declined}\n\n`);
|
|
260
|
-
return { ok: true, results: [], declined: true };
|
|
464
|
+
return { ok: true, found, results: [], declined: true, reason: 'declined' };
|
|
261
465
|
}
|
|
262
466
|
|
|
263
467
|
const { results } = installMcpClients(deps);
|
|
264
468
|
out('\n');
|
|
265
469
|
for (const r of results.filter((x) => x.status !== 'not-found')) {
|
|
266
470
|
out(` ${c.clients[r.client]}: ${c.status[r.status]}\n`);
|
|
471
|
+
if (r.command) out(` ${r.command}\n`);
|
|
267
472
|
if (r.removedLegacy) out(` ${c.removedLegacy}\n`);
|
|
268
473
|
}
|
|
269
|
-
const touched = results.some((r) =>
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
474
|
+
const touched = results.some((r) => ['configured', 'unchanged', 'manual'].includes(r.status));
|
|
475
|
+
const restart = results.filter((r) => NEEDS_RESTART.has(r.status)).map((r) => c.clients[r.client]);
|
|
476
|
+
const closing = !touched ? c.failedAll : restart.length ? c.restart(restart.join(', ')) : c.alreadyConnected;
|
|
477
|
+
out(`\n ${closing}\n\n`);
|
|
478
|
+
if (results.some((r) => r.status === 'failed' || r.status === 'invalid-config')) process.exitCode = 1;
|
|
479
|
+
return { ok: true, found, results, reason: null };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function jsonReport({ found, results, reason }, c) {
|
|
483
|
+
const rows = reason
|
|
484
|
+
? found.map((client) => ({ client, status: 'skipped' }))
|
|
485
|
+
: results.filter((r) => r.status !== 'not-found');
|
|
486
|
+
const clients = rows.map((r) => ({
|
|
487
|
+
client: r.client,
|
|
488
|
+
name: c.clients[r.client],
|
|
489
|
+
status: r.status,
|
|
490
|
+
restartRequired: NEEDS_RESTART.has(r.status),
|
|
491
|
+
manualCommand: r.command || null,
|
|
492
|
+
}));
|
|
493
|
+
return { reason, clients, restart: clients.filter((r) => r.restartRequired).map((r) => r.name) };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// SHAKERS_MCP_INSTALL=1 answers yes; with no terminal, nothing is touched.
|
|
497
|
+
async function runMcpInstall({ args = [], out = (s) => process.stdout.write(s), lang = null, ask = askTerminal, ...deps } = {}) {
|
|
498
|
+
const { getCatalog, detectFlowLang } = require('./i18n');
|
|
499
|
+
const c = getCatalog(lang || detectFlowLang()).mcpInstall;
|
|
500
|
+
const opts = parseInstallArgs(args);
|
|
501
|
+
if (opts.help) {
|
|
502
|
+
out(`\n ${c.help}\n\n`);
|
|
503
|
+
return { ok: true, results: [] };
|
|
504
|
+
}
|
|
505
|
+
const outcome = await connectClients({ c, opts, out: opts.json ? () => {} : out, ask, deps });
|
|
506
|
+
if (opts.json) out(`${JSON.stringify(jsonReport(outcome, c))}\n`);
|
|
507
|
+
return outcome;
|
|
273
508
|
}
|
|
274
509
|
|
|
275
|
-
module.exports = { installCodexSkill, detectMcpClients, installMcpClients, stableNodePath, runMcpInstall, serverEntry, claudeDesktopConfigPath, SERVER_NAME, LEGACY_SERVER_NAME };
|
|
510
|
+
module.exports = { installCodexSkill, removeTomlServers, tomlServerBlock, detectMcpClients, installMcpClients, stableNodePath, runMcpInstall, serverEntry, claudeDesktopConfigPath, SERVER_NAME, LEGACY_SERVER_NAME };
|
package/src/mcp-instructions.js
CHANGED
|
@@ -17,7 +17,7 @@ function buildServerInstructions({ elicitation = false } = {}) {
|
|
|
17
17
|
'4. signup_draft with your draft of their profile (name, role, city, years, stack, languages, work mode, hours, hourly and annual rate in euros, both always: the talent\'s figures with ratesFromTalent:true, else your estimate; sources: cv and/or linkedin, only those you have). It shows the draft, the data notice and how the account window works, and asks them to confirm; if they want changes, ask what and call it again.',
|
|
18
18
|
'5. Once confirmed, signup_create_account with LinkedIn, cvPath, names and userQuery = everything you know about them; the window opens by itself, so say nothing about it. Then signup_status (waitSeconds 45) until the account exists, and follow its message (a failed LinkedIn import: its fixed line comes with the next question; retry once, then ask for their website or GitHub).',
|
|
19
19
|
'6. New account: save_profile_details with the confirmed draft, without asking. Existing account (the talent signed in instead): update_existing_profile, which shows what would fill their empty fields and asks; nothing they already set is overwritten.',
|
|
20
|
-
'7. AI usage, a step of its own: ai_usage without consent asks the question with the two disclaimers; nothing is scanned before their yes. On yes it
|
|
20
|
+
'7. AI usage, a step of its own: ai_usage without consent asks the question with the two disclaimers; nothing is scanned before their yes. On yes it scans in the background and sends nothing. Then ai_usage_upload (waitSeconds 30) shows the talent what would be sent and what Shakers does with it, and asks; it uploads only on their own pick, and in the chat confirm:true only after they picked the upload option. If it answers scan-running, carry on and call it again right after the interview step. If they skip the analysis, its fixed line comes with the next question.',
|
|
21
21
|
'8. Onboarding interview: skip it if signup_status says it is completed; otherwise onboarding_interview_start without disclaimerAcknowledged (here now or later on the web, default here), then how they want to answer (drafted, own, full), asked with the interview disclaimers. In drafted mode, show each question verbatim with your proposed answer and send it only after the talent approves or edits it; only if they explicitly ask you to answer everything yourself ("hazla entera", "do it all"), complete the remaining questions without per-answer approval and tell them at the end. Full mode is that from the first question. Own mode sends only their words. Never call onboarding_finish in this flow.',
|
|
22
22
|
'9. Main role, also when they skip the interview: list_my_roles (waitSeconds 45, again while pending), then, only when its next is set_main_role, set_main_role without clusterId and with evidence = one concrete fact per recommended role from their CV, the interview or the AI-usage analysis (with nothing recommended: answer = the role their CV names and skills = their stack). Never set a main role the talent did not pick.',
|
|
23
23
|
'10. Finish with open_web: it opens Shakers with the talent signed in.',
|
package/src/mcp-prompts.js
CHANGED
|
@@ -14,9 +14,9 @@ const PROMPTS = [
|
|
|
14
14
|
{
|
|
15
15
|
name: 'ai_usage',
|
|
16
16
|
description: 'Evaluate and showcase how you work with AI (scans this machine, builds your "My work with AI").',
|
|
17
|
-
tools: ['ai_usage'],
|
|
17
|
+
tools: ['ai_usage', 'ai_usage_upload'],
|
|
18
18
|
buildMessages: () => userText(
|
|
19
|
-
'Evaluate how I work with AI for my Shakers profile. First ask me for consent and the email to attribute it to, then call the `ai_usage` tool and show me the resulting report verbatim. Never show internal ids/codes.',
|
|
19
|
+
'Evaluate how I work with AI for my Shakers profile. First ask me for consent and the email to attribute it to, then call the `ai_usage` tool. Before anything is uploaded it shows me what would be sent and asks me: show that review verbatim, let me answer myself, and only then use `ai_usage_upload`. Show me the resulting report verbatim. Never show internal ids/codes.',
|
|
20
20
|
),
|
|
21
21
|
},
|
|
22
22
|
{
|
package/src/mcp-role-tools.js
CHANGED
|
@@ -112,7 +112,24 @@ function makeRoleTools(deps = {}) {
|
|
|
112
112
|
sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); }),
|
|
113
113
|
now = () => Date.now(),
|
|
114
114
|
funnel = require('./onboarding-funnel').NOOP_FUNNEL,
|
|
115
|
+
inSignup = () => require('./mcp-signup-tools').signupPhase() === 'account',
|
|
116
|
+
completeOnboarding = (session) => require('./onboarding-client').requestCompleteOnboarding(
|
|
117
|
+
{ accessToken: session.accessToken, hubAccessToken: session.hubAccessToken },
|
|
118
|
+
{ endpoint: require('./config').getCompleteOnboardingEndpoint() },
|
|
119
|
+
),
|
|
115
120
|
} = deps;
|
|
121
|
+
let clustersCount;
|
|
122
|
+
let onboardingCompleted = false;
|
|
123
|
+
|
|
124
|
+
async function finishSignup(session) {
|
|
125
|
+
if (onboardingCompleted || !inSignup()) return;
|
|
126
|
+
const res = await completeOnboarding(session).catch(() => null);
|
|
127
|
+
onboardingCompleted = !!(res && res.ok);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function trackSuggestions(roles) {
|
|
131
|
+
funnel.viewed('supercluster', { suggested_superclusters: roles.map((r) => r.clusterId), suggestion_source: 'assigned_clusters', suggestions_count: roles.length });
|
|
132
|
+
}
|
|
116
133
|
|
|
117
134
|
const requireSession = () => {
|
|
118
135
|
const session = loadAuthSession();
|
|
@@ -167,6 +184,7 @@ function makeRoleTools(deps = {}) {
|
|
|
167
184
|
}
|
|
168
185
|
const assigned = await requestAssignedClusters({ hubAccessToken }, { endpoint: getAssignedClustersEndpoint() });
|
|
169
186
|
const clusters = assigned.ok ? assigned.clusters || [] : [];
|
|
187
|
+
if (assigned.ok) clustersCount = clusters.length;
|
|
170
188
|
const recommended = clusters
|
|
171
189
|
.filter((c) => c.type === 'RECOMMENDED')
|
|
172
190
|
.sort((a, b) => (SOURCE_ORDER[a.source] ?? 2) - (SOURCE_ORDER[b.source] ?? 2));
|
|
@@ -193,6 +211,7 @@ function makeRoleTools(deps = {}) {
|
|
|
193
211
|
next = 'set_main_role';
|
|
194
212
|
message = 'Call set_main_role without clusterId: it asks the talent which recommended role is their main one, in a dialog or with options it returns for you to show.';
|
|
195
213
|
} else if (read.mainClusterId) {
|
|
214
|
+
await finishSignup(session);
|
|
196
215
|
next = 'open_web';
|
|
197
216
|
message = 'The talent already has a main role and nothing new was recommended: do not ask it again, go on to open_web. Only if the talent asks to change it, call set_main_role with catalogue:true.';
|
|
198
217
|
} else {
|
|
@@ -230,11 +249,13 @@ function makeRoleTools(deps = {}) {
|
|
|
230
249
|
const read = await readRoles(session.hubAccessToken);
|
|
231
250
|
const recommended = byName(read.recommended);
|
|
232
251
|
if (!recommended.length && read.mainClusterId) {
|
|
252
|
+
await finishSignup(session);
|
|
233
253
|
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.' } };
|
|
234
254
|
}
|
|
235
255
|
if (!recommended.length) return chooseFromCatalogue(args, ctx);
|
|
236
256
|
const c = copy();
|
|
237
257
|
const counts = await openCounts(session.hubAccessToken, recommended);
|
|
258
|
+
trackSuggestions(recommended);
|
|
238
259
|
const evidence = args.evidence && typeof args.evidence === 'object' ? args.evidence : {};
|
|
239
260
|
const blocks = recommended.map((r, i) => renderRole(lang(), { name: r.name, source: r.source, evidence: evidence[r.clusterId], openProjects: counts[i], recommended: i === 0 }));
|
|
240
261
|
const asked = await askAfterTexts(ctx, {
|
|
@@ -296,6 +317,7 @@ function makeRoleTools(deps = {}) {
|
|
|
296
317
|
typeof counts[i] === 'number' && counts[i] > 0 ? c.openProjects(counts[i]) : null,
|
|
297
318
|
].filter(Boolean).join('\n'));
|
|
298
319
|
const texts = [[...(blocks.length ? [c.rolesIntro, ...blocks] : []), c.rolesOutro].join('\n\n')];
|
|
320
|
+
trackSuggestions([]);
|
|
299
321
|
const asked = await askChoice(ctx, { texts, question: c.mainRoleQuestion, options: [...options.map((r) => r.name), c.mainRoleNotListed] });
|
|
300
322
|
if (asked.answer === c.mainRoleNotListed) return askOwnRole();
|
|
301
323
|
const role = asked.answer && options.find((r) => r.name === asked.answer);
|
|
@@ -330,8 +352,9 @@ function makeRoleTools(deps = {}) {
|
|
|
330
352
|
const res = await requestSetMainRole({ clusterId, hubAccessToken: session.hubAccessToken }, { endpoint: getSetMainRoleEndpoint() });
|
|
331
353
|
if (res.ok === false && res.reason === 'http-400') return { ok: false, reason: res.reason, message: 'The talent does not hold that role yet: call add_role with it, then set_main_role again.' };
|
|
332
354
|
if (!res.ok) return { ok: false, reason: res.reason };
|
|
333
|
-
funnel.completed('supercluster', { supercluster: clusterId, was_suggestion_accepted: suggestion });
|
|
355
|
+
funnel.completed('supercluster', { supercluster: clusterId, clusters_count: clustersCount, was_suggestion_accepted: suggestion });
|
|
334
356
|
funnel.flush();
|
|
357
|
+
await finishSignup(session);
|
|
335
358
|
return { ok: true, clusterId };
|
|
336
359
|
}
|
|
337
360
|
|
package/src/mcp-signup-tools.js
CHANGED
|
@@ -338,6 +338,7 @@ function makeSignupTools(deps = {}) {
|
|
|
338
338
|
wake: null,
|
|
339
339
|
importStartedAt: null,
|
|
340
340
|
importReported: new Set(),
|
|
341
|
+
authAttempts: undefined,
|
|
341
342
|
};
|
|
342
343
|
currentSignup = state;
|
|
343
344
|
|
|
@@ -345,8 +346,8 @@ function makeSignupTools(deps = {}) {
|
|
|
345
346
|
const before = state.account.state;
|
|
346
347
|
state.account = next;
|
|
347
348
|
if ((next.state === 'created' || next.state === 'existing') && before !== next.state) {
|
|
348
|
-
funnel.completed('account_creation', { auth_method: next.via, is_returning_account: next.state === 'existing' });
|
|
349
|
-
funnel.identified();
|
|
349
|
+
funnel.completed('account_creation', { auth_method: next.via, auth_attempts: state.authAttempts, is_returning_account: next.state === 'existing' });
|
|
350
|
+
funnel.identified({ newAccount: next.state === 'created' });
|
|
350
351
|
}
|
|
351
352
|
if (next.state === 'failed' && before !== 'failed') {
|
|
352
353
|
funnel.errored('account_creation', next.reason);
|
|
@@ -439,6 +440,7 @@ function makeSignupTools(deps = {}) {
|
|
|
439
440
|
onUrl: (url) => { state.windowUrl = url; announce(url); },
|
|
440
441
|
},
|
|
441
442
|
).then((outcome) => {
|
|
443
|
+
if (outcome && outcome.ok) state.authAttempts = outcome.attempts;
|
|
442
444
|
if (outcome && outcome.viaSocial) return;
|
|
443
445
|
if (!outcome || !outcome.ok) {
|
|
444
446
|
setAccount({ state: 'failed', reason: (outcome && outcome.reason) || 'window-failed' });
|
|
@@ -487,6 +489,7 @@ function makeSignupTools(deps = {}) {
|
|
|
487
489
|
// rides on the next question block, because models skip a relay that comes before another tool call.
|
|
488
490
|
if (!nonEmpty(args.answer) && state.cvPath) {
|
|
489
491
|
funnel.completed('start');
|
|
492
|
+
funnel.viewed('import_sources', { sources_offered: DRAFT_SOURCES });
|
|
490
493
|
queueNotice(c.welcome(nonEmpty(args.firstName)));
|
|
491
494
|
return {
|
|
492
495
|
ok: true,
|
|
@@ -505,6 +508,7 @@ function makeSignupTools(deps = {}) {
|
|
|
505
508
|
});
|
|
506
509
|
if (asked.reply) return { ...asked.reply, language: lang() };
|
|
507
510
|
funnel.completed('start');
|
|
511
|
+
funnel.viewed('import_sources', { sources_offered: DRAFT_SOURCES });
|
|
508
512
|
// A CV path the talent gave wins over both answers: searching could read another candidate.
|
|
509
513
|
const cvPath = state.cvPath;
|
|
510
514
|
if (cvPath) {
|
|
@@ -665,10 +669,10 @@ function makeSignupTools(deps = {}) {
|
|
|
665
669
|
const attempted = DRAFT_SOURCES.filter((source) => (source === 'cv' ? !!cv : !!sources.linkedinUrl));
|
|
666
670
|
state.importStartedAt = Object.fromEntries(attempted.map((source) => [source, now()]));
|
|
667
671
|
for (const source of attempted) funnel.track('source_import_started', 'import_sources', { source_type: source });
|
|
668
|
-
funnel.completed('import_sources', { sources_attempted: attempted });
|
|
672
|
+
funnel.completed('import_sources', { sources_attempted: attempted, sources_connected: attempted, sources_connected_count: attempted.length });
|
|
669
673
|
}
|
|
670
674
|
if (!reused) {
|
|
671
|
-
funnel.viewed('account_creation');
|
|
675
|
+
funnel.viewed('account_creation', { auth_methods_offered: require('./mcp-auth-form').WINDOW_METHODS.register });
|
|
672
676
|
await openWindow(identity);
|
|
673
677
|
}
|
|
674
678
|
return {
|
|
@@ -714,7 +718,7 @@ function makeSignupTools(deps = {}) {
|
|
|
714
718
|
|
|
715
719
|
function nextSteps(account) {
|
|
716
720
|
const interview = 'Then the onboarding interview: if onboardingInterview.completed is true, skip it without mentioning it; otherwise call onboarding_interview_start without disclaimerAcknowledged (it asks here now or later on the web). Then, also if they skip it, the main role: list_my_roles (waitSeconds 45), then set_main_role without clusterId only when its next says so. Finally open_web.';
|
|
717
|
-
const aiUsage = 'Then ai_usage without consent: it returns the two AI-usage disclaimers and the question (show them right before asking; no scan happens before a yes).';
|
|
721
|
+
const aiUsage = 'Then ai_usage without consent: it returns the two AI-usage disclaimers and the question (show them right before asking; no scan happens before a yes). After a yes, ai_usage_upload (waitSeconds 30) shows the talent what would be sent and asks; nothing is uploaded without their pick.';
|
|
718
722
|
if (account.state === 'existing') {
|
|
719
723
|
return `This talent already had an account and signed in. Call update_existing_profile: it shows what would change and asks whether to update, and its text tells the talent about the account, so say nothing about it yourself. ${aiUsage} ${interview}`;
|
|
720
724
|
}
|
|
@@ -958,11 +962,12 @@ function makeSignupTools(deps = {}) {
|
|
|
958
962
|
}
|
|
959
963
|
|
|
960
964
|
function trackDraftShown(draft) {
|
|
961
|
-
|
|
962
|
-
funnel.viewed('
|
|
965
|
+
const prefilled = (step) => ({ is_prefilled: STEP_DRAFT_FIELDS[step].some((key) => draft[key] !== undefined) });
|
|
966
|
+
funnel.viewed('work_situation', prefilled('work_situation'));
|
|
967
|
+
funnel.viewed('availability', prefilled('availability'));
|
|
963
968
|
funnel.viewed('rates', { suggested_rate_shown: draft.ratesFromTalent !== true });
|
|
964
|
-
funnel.viewed('languages');
|
|
965
|
-
funnel.viewed('location');
|
|
969
|
+
funnel.viewed('languages', prefilled('languages'));
|
|
970
|
+
funnel.viewed('location', prefilled('location'));
|
|
966
971
|
funnel.viewed('profile_summary');
|
|
967
972
|
}
|
|
968
973
|
|
|
@@ -978,7 +983,8 @@ function makeSignupTools(deps = {}) {
|
|
|
978
983
|
|
|
979
984
|
function trackSavedSteps(details, results) {
|
|
980
985
|
const saved = (section) => results[section] && results[section].ok === true;
|
|
981
|
-
const
|
|
986
|
+
const draftTime = funnel.stepTime('profile_summary');
|
|
987
|
+
const step = (name, props) => funnel.completed(name, { ...props, is_prefilled: true, was_edited: wasEdited(name), time_on_step_ms: draftTime });
|
|
982
988
|
if (saved('workSituation')) {
|
|
983
989
|
step('work_situation', { work_situation: workSituationValue(String(details.workSituation.situation || '').trim().toUpperCase()) });
|
|
984
990
|
}
|
|
@@ -996,7 +1002,7 @@ function makeSignupTools(deps = {}) {
|
|
|
996
1002
|
}
|
|
997
1003
|
if (saved('pricing')) {
|
|
998
1004
|
const types = rateTypes(pricingFromRates(details.pricing).input);
|
|
999
|
-
funnel.completed('rates', { rate_types_selected: types, rate_type: types[0], rate_currency: 'EUR', is_prefilled: !(state.draft && state.draft.ratesFromTalent), was_edited: wasEdited('rates') });
|
|
1005
|
+
funnel.completed('rates', { rate_types_selected: types, rate_type: types[0], rate_currency: 'EUR', is_prefilled: !(state.draft && state.draft.ratesFromTalent), was_edited: wasEdited('rates'), time_on_step_ms: draftTime });
|
|
1000
1006
|
}
|
|
1001
1007
|
if (saved('languages')) {
|
|
1002
1008
|
const codes = details.languages.map((row) => String(row.language || '').trim().toLowerCase()).filter(Boolean);
|
|
@@ -1004,7 +1010,7 @@ function makeSignupTools(deps = {}) {
|
|
|
1004
1010
|
}
|
|
1005
1011
|
const sections = Object.entries(results).filter(([name]) => name !== 'phone');
|
|
1006
1012
|
if (sections.length && sections.every(([, r]) => r.ok === true)) {
|
|
1007
|
-
funnel.completed('profile_summary', { fields_edited_count: draftFieldsEdited() });
|
|
1013
|
+
funnel.completed('profile_summary', { fields_edited_count: draftFieldsEdited(), time_on_step_ms: draftTime });
|
|
1008
1014
|
}
|
|
1009
1015
|
funnel.flush();
|
|
1010
1016
|
}
|