shakers-cli-beta 0.8.0-beta.5 → 0.9.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 +9 -4
- package/bin/ai-usage.js +5 -0
- package/bin/login.js +1 -1
- package/bin/mcp.js +18 -5
- package/package.json +2 -1
- package/src/authorship.js +7 -6
- package/src/certify-dimension-client.js +0 -54
- package/src/certify-dimension-flow.js +20 -81
- package/src/config.js +0 -8
- package/src/git-activity.js +8 -7
- package/src/git-log-prefetch.js +53 -0
- package/src/i18n.js +10 -36
- package/src/mcp-auth-form.js +35 -21
- package/src/mcp-certify-dimension-tools.js +2 -2
- package/src/mcp-onboarding-tools.js +1 -1
- package/src/mcp-prompts.js +71 -0
- package/src/mcp-register-tools.js +4 -2
- package/src/mcp-server.js +38 -2
- package/src/scanner.js +13 -1
- package/src/session-normalizers.js +174 -0
- package/src/session-scan.js +149 -10
- package/src/work-streams.js +7 -2
package/src/mcp-auth-form.js
CHANGED
|
@@ -18,24 +18,26 @@ const FONT_STACK = '"Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSy
|
|
|
18
18
|
|
|
19
19
|
const COPY = {
|
|
20
20
|
en: {
|
|
21
|
-
signin: 'Sign in', create: 'Create account', welcome: 'Welcome to Shakers
|
|
21
|
+
signin: 'Sign in', create: 'Create account', welcome: 'Welcome to Shakers. Create your talent account to get started.',
|
|
22
22
|
email: 'Email', password: 'Password', repeat: 'Repeat password', first: 'First name', last: 'Last name',
|
|
23
23
|
lang: 'Language', newsletter: 'Receive the newsletter', how: 'How do you work?', intent: 'Freelance intent',
|
|
24
|
-
hint: 'Your password stays on this
|
|
25
|
-
done: 'You are signed in.', doneSub: 'You can close this tab and
|
|
24
|
+
hint: 'Your password stays on this computer. We never send it to the chat.', mismatch: 'The passwords do not match.',
|
|
25
|
+
done: 'You are signed in.', doneSub: 'You can close this tab and go back to your AI app.',
|
|
26
26
|
chooseTitleLogin: 'How do you want to sign in?', chooseTitleRegister: 'How do you want to create your account?',
|
|
27
|
-
continueGoogle: 'Continue with Google', continueEmail: 'Email and password', continueEmailRegister: 'Continue with email',
|
|
28
|
-
|
|
27
|
+
continueGoogle: 'Continue with Google', continueLinkedin: 'Continue with LinkedIn', continueEmail: 'Email and password', continueEmailRegister: 'Continue with email',
|
|
28
|
+
socialOpening: (name) => `Opening sign-in with ${name}…`,
|
|
29
|
+
socialOpeningSub: 'A sign-in window just opened. Finish there and come back to your AI app, it will carry on.',
|
|
29
30
|
},
|
|
30
31
|
es: {
|
|
31
|
-
signin: 'Iniciar sesión', create: 'Crear cuenta', welcome: 'Bienvenido a Shakers
|
|
32
|
+
signin: 'Iniciar sesión', create: 'Crear cuenta', welcome: 'Bienvenido a Shakers. Crea tu cuenta de talento para empezar.',
|
|
32
33
|
email: 'Email', password: 'Contraseña', repeat: 'Repite la contraseña', first: 'Nombre', last: 'Apellidos',
|
|
33
34
|
lang: 'Idioma', newsletter: 'Recibir la newsletter', how: '¿Cómo trabajas?', intent: 'Intención freelance',
|
|
34
|
-
hint: 'Tu contraseña se queda en este equipo. Nunca
|
|
35
|
+
hint: 'Tu contraseña se queda en este equipo. Nunca la enviamos al chat.', mismatch: 'Las contraseñas no coinciden.',
|
|
35
36
|
done: 'Sesión iniciada.', doneSub: 'Puedes cerrar esta pestaña y volver a tu app de IA.',
|
|
36
37
|
chooseTitleLogin: '¿Cómo quieres iniciar sesión?', chooseTitleRegister: '¿Cómo quieres crear tu cuenta?',
|
|
37
|
-
continueGoogle: 'Continuar con Google', continueEmail: 'Email y contraseña', continueEmailRegister: 'Continuar con email',
|
|
38
|
-
|
|
38
|
+
continueGoogle: 'Continuar con Google', continueLinkedin: 'Continuar con LinkedIn', continueEmail: 'Email y contraseña', continueEmailRegister: 'Continuar con email',
|
|
39
|
+
socialOpening: (name) => `Abriendo el acceso con ${name}…`,
|
|
40
|
+
socialOpeningSub: 'Se acaba de abrir una ventana para terminar el acceso. Cuando acabes, vuelve a tu app de IA y seguirá por su cuenta.',
|
|
39
41
|
},
|
|
40
42
|
};
|
|
41
43
|
|
|
@@ -56,7 +58,7 @@ function styles() {
|
|
|
56
58
|
+ `:root{--background:#ffffff;--foreground:#18181b;--primary:#05342c;--primary-foreground:#fafafa;`
|
|
57
59
|
+ `--secondary:#e2f2f0;--secondary-foreground:#0b5a4c;--muted-foreground:#3f3f46;--accent:#e2f2f0;--accent-foreground:#08473c;`
|
|
58
60
|
+ `--input:#d4d4d8;--border:#e4e4e7;--ring:#0e7d69;--destructive:#e11d48;--radius:0.625rem;--radius-md:calc(var(--radius) - 2px);`
|
|
59
|
-
+ `--shadow-xs:0 1px
|
|
61
|
+
+ `--shadow-xs:0 1px 3px 0px rgb(0 0 0 / .05)}`
|
|
60
62
|
+ `*{box-sizing:border-box}`
|
|
61
63
|
+ `body{font-family:${FONT_STACK};margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#fafafa;color:var(--foreground);padding:1.5rem;-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}`
|
|
62
64
|
+ `.card{width:100%;max-width:26rem;background:var(--background);border:1px solid var(--border);border-radius:var(--radius);box-shadow:0 10px 30px rgb(3 33 28 / .08);overflow:hidden}`
|
|
@@ -104,7 +106,7 @@ function errorBanner(reason) {
|
|
|
104
106
|
|
|
105
107
|
function loginFormHtml(lang, { error } = {}) {
|
|
106
108
|
const c = copy(lang);
|
|
107
|
-
return page(`Shakers
|
|
109
|
+
return page(`Shakers · ${c.signin}`, {
|
|
108
110
|
inner: `<h1>${c.signin}</h1>${errorBanner(error)}`
|
|
109
111
|
+ `<form method="post" action="/submit">`
|
|
110
112
|
+ `<label>${c.email}</label><input name="email" type="email" required autofocus>`
|
|
@@ -117,7 +119,7 @@ function loginFormHtml(lang, { error } = {}) {
|
|
|
117
119
|
|
|
118
120
|
function registerFormHtml(lang, { error } = {}) {
|
|
119
121
|
const c = copy(lang);
|
|
120
|
-
return page(`Shakers
|
|
122
|
+
return page(`Shakers · ${c.create}`, {
|
|
121
123
|
banner: panel(c.welcome),
|
|
122
124
|
inner: `<h1>${c.create}</h1>${errorBanner(error)}`
|
|
123
125
|
+ `<form method="post" action="/submit">`
|
|
@@ -140,22 +142,28 @@ const GOOGLE_G = '<svg class="g" viewBox="0 0 48 48" aria-hidden="true">'
|
|
|
140
142
|
+ '<path fill="#FBBC05" d="M10.4 28.3c-.5-1.5-.8-3.1-.8-4.8s.3-3.3.8-4.8l-7.8-6.1C.9 15.9 0 19.8 0 23.5s.9 7.6 2.6 10.9l7.8-6.1z"/>'
|
|
141
143
|
+ '<path fill="#34A853" d="M24 47c6.5 0 11.9-2.1 15.9-5.8l-7.3-5.7c-2 1.4-4.7 2.3-8.6 2.3-6.4 0-11.8-3.8-13.6-9.8l-7.8 6.1C6.5 41.6 14.6 47 24 47z"/></svg>';
|
|
142
144
|
|
|
145
|
+
const LINKEDIN_IN = '<svg class="g" viewBox="0 0 24 24" aria-hidden="true">'
|
|
146
|
+
+ '<path fill="#0A66C2" d="M20.45 20.45h-3.56v-5.57c0-1.33-.03-3.04-1.85-3.04-1.86 0-2.14 1.45-2.14 2.94v5.67H9.35V9h3.41v1.56h.05c.47-.9 1.63-1.85 3.36-1.85 3.6 0 4.26 2.37 4.26 5.45v6.29zM5.34 7.43a2.06 2.06 0 1 1 0-4.13 2.06 2.06 0 0 1 0 4.13zM7.12 20.45H3.56V9h3.56v11.45zM22.22 0H1.77C.79 0 0 .77 0 1.73v20.54C0 23.22.79 24 1.77 24h20.45c.98 0 1.78-.78 1.78-1.73V1.73C24 .77 23.2 0 22.22 0z"/></svg>';
|
|
147
|
+
|
|
143
148
|
// The method-choice screen (talents-ai-score) — the FIRST screen of the loopback form when more than one method is offered for this flow.
|
|
144
149
|
function methodChoiceHtml(mode, lang, methods) {
|
|
145
150
|
const c = copy(lang);
|
|
146
151
|
const title = mode === 'register' ? c.chooseTitleRegister : c.chooseTitleLogin;
|
|
147
152
|
const emailLabel = mode === 'register' ? c.continueEmailRegister : c.continueEmail;
|
|
148
|
-
const buttons = methods.map((id) =>
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
153
|
+
const buttons = methods.map((id) => {
|
|
154
|
+
if (id === 'google') return `<a class="mbtn primary" href="/google">${GOOGLE_G}${c.continueGoogle}</a>`;
|
|
155
|
+
if (id === 'linkedin') return `<a class="mbtn secondary" href="/linkedin">${LINKEDIN_IN}${c.continueLinkedin}</a>`;
|
|
156
|
+
return `<a class="mbtn secondary" href="/email">${emailLabel}</a>`;
|
|
157
|
+
}).join('');
|
|
158
|
+
return page(`Shakers · ${title}`, {
|
|
152
159
|
inner: `<h1>${title}</h1><div class="actions">${buttons}</div><p class="hint">${c.hint}</p>`,
|
|
153
160
|
});
|
|
154
161
|
}
|
|
155
162
|
|
|
156
|
-
function
|
|
163
|
+
function socialHandoffHtml(lang, provider) {
|
|
157
164
|
const c = copy(lang);
|
|
158
|
-
|
|
165
|
+
const name = provider === 'linkedin' ? 'LinkedIn' : 'Google';
|
|
166
|
+
return page('Shakers', { inner: `<h1>${c.socialOpening(name)}</h1><p>${c.socialOpeningSub}</p>` });
|
|
159
167
|
}
|
|
160
168
|
|
|
161
169
|
function successHtml(lang) {
|
|
@@ -247,8 +255,14 @@ function runLoopbackAuth(
|
|
|
247
255
|
}
|
|
248
256
|
if (req.method === 'GET' && url.pathname === '/google' && methods.includes('google')) {
|
|
249
257
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
250
|
-
res.end(
|
|
251
|
-
finish({ ok: true,
|
|
258
|
+
res.end(socialHandoffHtml(lang, 'google'));
|
|
259
|
+
finish({ ok: true, viaSocial: true, provider: 'google' });
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (req.method === 'GET' && url.pathname === '/linkedin' && methods.includes('linkedin')) {
|
|
263
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
264
|
+
res.end(socialHandoffHtml(lang, 'linkedin'));
|
|
265
|
+
finish({ ok: true, viaSocial: true, provider: 'linkedin' });
|
|
252
266
|
return;
|
|
253
267
|
}
|
|
254
268
|
if (req.method === 'POST' && url.pathname === '/submit') {
|
|
@@ -279,4 +293,4 @@ function runLoopbackAuth(
|
|
|
279
293
|
});
|
|
280
294
|
}
|
|
281
295
|
|
|
282
|
-
module.exports = { runLoopbackAuth, doAuth, loginFormHtml, registerFormHtml, methodChoiceHtml,
|
|
296
|
+
module.exports = { runLoopbackAuth, doAuth, loginFormHtml, registerFormHtml, methodChoiceHtml, socialHandoffHtml, DEFAULT_TIMEOUT_MS };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// MCP: list the offerable dimensions (the interview itself
|
|
3
|
+
// MCP: list the offerable dimensions (the certification interview itself is done on the web).
|
|
4
4
|
|
|
5
5
|
const LIST_CERTIFIABLE_DIMENSIONS_SCHEMA = {
|
|
6
6
|
type: 'object',
|
|
@@ -45,7 +45,7 @@ function makeCertifyDimensionTools(deps = {}) {
|
|
|
45
45
|
return [
|
|
46
46
|
{
|
|
47
47
|
name: 'list_certifiable_dimensions',
|
|
48
|
-
description: "List the dimensions the talent can certify
|
|
48
|
+
description: "List the dimensions the talent can certify right now: their main role's hub dimensions whose interview runs by text (terminal or spoken-case templates) and not already CERTIFIED (UNCERTIFIED/EXPIRED). Requires an active session. The certification interview itself is done on the web; running `shakers certify` in the terminal hands the talent the web link to take it. The dimensionKey/clusterId in each item are INTERNAL handles for tools only — never show or mention them to the talent; refer to a dimension by its name.",
|
|
49
49
|
inputSchema: LIST_CERTIFIABLE_DIMENSIONS_SCHEMA,
|
|
50
50
|
handler: listCertifiableDimensions,
|
|
51
51
|
},
|
|
@@ -131,7 +131,7 @@ function makeOnboardingTools(deps = {}) {
|
|
|
131
131
|
// No explicit `method`: the loopback form opens on its email/social choice screen.
|
|
132
132
|
async function loginViaForm({ method = null } = {}) {
|
|
133
133
|
const result = await runLoopbackAuth(method ? { mode: 'login', method } : { mode: 'login' });
|
|
134
|
-
if (result.
|
|
134
|
+
if (result.viaSocial) return socialSigninPointer('login', result.provider || 'google');
|
|
135
135
|
if (!result.ok) return { ok: false, reason: result.reason || 'login-failed' };
|
|
136
136
|
return { ok: true, method: 'email', email: result.email || null };
|
|
137
137
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// MCP prompts = in-client slash-commands. Each is a thin wrapper that injects a user turn
|
|
4
|
+
// telling the assistant which Shakers tool(s) to use — the logic stays in the tools, so
|
|
5
|
+
// there is no duplication. `buildMessages(args)` returns the prompts/get message list.
|
|
6
|
+
|
|
7
|
+
function userText(text) {
|
|
8
|
+
return [{ role: 'user', content: { type: 'text', text } }];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Only reference tools that exist in the FULL server; `available(toolNames)` hides a prompt
|
|
12
|
+
// when its tool isn't registered (e.g. the --local scan-only server).
|
|
13
|
+
const PROMPTS = [
|
|
14
|
+
{
|
|
15
|
+
name: 'ai_usage',
|
|
16
|
+
description: 'Evaluate and showcase how you work with AI (scans this machine, builds your "My work with AI").',
|
|
17
|
+
tools: ['ai_usage'],
|
|
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.',
|
|
20
|
+
),
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: 'onboarding',
|
|
24
|
+
description: 'Register and onboard on Shakers (profile import, pricing, availability, interview).',
|
|
25
|
+
tools: ['register', 'suggest_register_context'],
|
|
26
|
+
buildMessages: () => userText(
|
|
27
|
+
'Help me get set up on Shakers. Use `suggest_register_context` to pre-fill what you can, show me `register_preview`, then `register`, and offer the `onboarding_interview_*` tools. Ask me only for what you cannot infer.',
|
|
28
|
+
),
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: 'find_projects',
|
|
32
|
+
description: 'Find open Shakers projects/positions matched to you.',
|
|
33
|
+
tools: ['find_projects'],
|
|
34
|
+
buildMessages: () => userText(
|
|
35
|
+
'Find open Shakers positions that match me: call `find_projects` and summarize them by title/company (never show internal ids). Offer `save_project` / `show_project` for ones I like.',
|
|
36
|
+
),
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: 'certify',
|
|
40
|
+
description: 'See which role dimensions you can certify and start a certification.',
|
|
41
|
+
tools: ['list_certifiable_dimensions'],
|
|
42
|
+
buildMessages: () => userText(
|
|
43
|
+
'Help me certify on Shakers: call `list_certifiable_dimensions`, present the dimensions I can certify by name, and guide me through certifying the one I pick.',
|
|
44
|
+
),
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: 'ask',
|
|
48
|
+
description: 'Ask Alma, the Shakers AI assistant, a question.',
|
|
49
|
+
tools: ['ask'],
|
|
50
|
+
arguments: [{ name: 'message', description: 'What to ask Alma.', required: false }],
|
|
51
|
+
buildMessages: (args = {}) => userText(
|
|
52
|
+
`Ask Alma (the Shakers assistant) and relay her answer as-is. My question: ${typeof args.message === 'string' && args.message ? args.message : '(ask me what I want to know first)'}`,
|
|
53
|
+
),
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: 'my_profile',
|
|
57
|
+
description: 'Show your Shakers profile (role, rate, availability, "My work with AI").',
|
|
58
|
+
tools: ['get_profile'],
|
|
59
|
+
buildMessages: () => userText(
|
|
60
|
+
'Show my Shakers profile: call `get_profile` (and `get_rate` / `get_availability` if useful) and present it in plain terms. Any ids/codes are internal — never show them.',
|
|
61
|
+
),
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
function makePrompts(toolNames = null) {
|
|
66
|
+
const available = toolNames instanceof Set ? toolNames : null;
|
|
67
|
+
// Keep a prompt only if at least one of its tools is present in this server.
|
|
68
|
+
return PROMPTS.filter((p) => !available || p.tools.some((t) => available.has(t)));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = { makePrompts, PROMPTS };
|
|
@@ -244,15 +244,17 @@ function makeRegisterTools(deps = {}) {
|
|
|
244
244
|
if (!check.ok) return { ok: false, step: 'signup', reason: check.reason };
|
|
245
245
|
|
|
246
246
|
const created = await runLoopbackAuth({ mode: 'register', deps: resolved, lang, registerFields });
|
|
247
|
-
if (created.
|
|
247
|
+
if (created.viaSocial) {
|
|
248
248
|
// Social registration is the device flow (start/poll tools), not this single blocking call.
|
|
249
|
+
const provider = created.provider || 'google';
|
|
249
250
|
return {
|
|
250
251
|
ok: false,
|
|
251
252
|
step: 'signup',
|
|
252
253
|
reason: 'use-device-flow',
|
|
253
254
|
next: 'social_signin_start',
|
|
254
255
|
mode: 'register',
|
|
255
|
-
|
|
256
|
+
provider,
|
|
257
|
+
message: `To register with ${provider}, call social_signin_start (provider "${provider}", mode "register", freelanceType "${registerFields.freelanceType || ''}") to get a URL and code to show the talent, then social_signin_poll. Once it returns authorized, run the profile steps (import, work situation, pricing).`,
|
|
256
258
|
};
|
|
257
259
|
}
|
|
258
260
|
if (!created.ok) {
|
package/src/mcp-server.js
CHANGED
|
@@ -27,7 +27,7 @@ function errorResponse(id, code, message, data) {
|
|
|
27
27
|
return { jsonrpc: JSONRPC, id: id === undefined ? null : id, error };
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
function createMcpServer({ tools = [], serverInfo = {}, protocolVersion, beforeCall = null, instructions = null } = {}) {
|
|
30
|
+
function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVersion, beforeCall = null, instructions = null } = {}) {
|
|
31
31
|
const registry = new Map();
|
|
32
32
|
for (const t of tools) {
|
|
33
33
|
if (!t || !t.name || typeof t.handler !== 'function') {
|
|
@@ -36,6 +36,14 @@ function createMcpServer({ tools = [], serverInfo = {}, protocolVersion, beforeC
|
|
|
36
36
|
registry.set(t.name, t);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
const promptRegistry = new Map();
|
|
40
|
+
for (const p of prompts) {
|
|
41
|
+
if (!p || !p.name || typeof p.buildMessages !== 'function') {
|
|
42
|
+
throw new Error('each prompt needs a name and a buildMessages function');
|
|
43
|
+
}
|
|
44
|
+
promptRegistry.set(p.name, p);
|
|
45
|
+
}
|
|
46
|
+
|
|
39
47
|
const advertisedVersion = protocolVersion || DEFAULT_PROTOCOL_VERSION;
|
|
40
48
|
|
|
41
49
|
function shapeTool(t) {
|
|
@@ -50,6 +58,27 @@ function createMcpServer({ tools = [], serverInfo = {}, protocolVersion, beforeC
|
|
|
50
58
|
return { tools: [...registry.values()].map(shapeTool) };
|
|
51
59
|
}
|
|
52
60
|
|
|
61
|
+
function shapePrompt(p) {
|
|
62
|
+
const out = { name: p.name, description: p.description || '' };
|
|
63
|
+
if (Array.isArray(p.arguments)) out.arguments = p.arguments;
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function listPrompts() {
|
|
68
|
+
return { prompts: [...promptRegistry.values()].map(shapePrompt) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function getPrompt(params) {
|
|
72
|
+
const name = params && params.name;
|
|
73
|
+
const prompt = name && promptRegistry.get(name);
|
|
74
|
+
if (!prompt) {
|
|
75
|
+
const err = new Error(`unknown prompt: ${name}`);
|
|
76
|
+
err.rpcCode = ERROR.INVALID_PARAMS;
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
return { description: prompt.description || '', messages: prompt.buildMessages((params && params.arguments) || {}) };
|
|
80
|
+
}
|
|
81
|
+
|
|
53
82
|
async function listAvailableTools() {
|
|
54
83
|
const out = [];
|
|
55
84
|
for (const t of registry.values()) {
|
|
@@ -112,7 +141,10 @@ function createMcpServer({ tools = [], serverInfo = {}, protocolVersion, beforeC
|
|
|
112
141
|
const requested = msg.params && msg.params.protocolVersion;
|
|
113
142
|
return resultResponse(id, {
|
|
114
143
|
protocolVersion: requested || advertisedVersion,
|
|
115
|
-
capabilities: {
|
|
144
|
+
capabilities: {
|
|
145
|
+
tools: { listChanged: false },
|
|
146
|
+
...(promptRegistry.size ? { prompts: { listChanged: false } } : {}),
|
|
147
|
+
},
|
|
116
148
|
serverInfo: {
|
|
117
149
|
name: serverInfo.name || 'shakers-mcp',
|
|
118
150
|
version: serverInfo.version || '0.0.0',
|
|
@@ -128,6 +160,10 @@ function createMcpServer({ tools = [], serverInfo = {}, protocolVersion, beforeC
|
|
|
128
160
|
return resultResponse(id, await listAvailableTools());
|
|
129
161
|
case 'tools/call':
|
|
130
162
|
return resultResponse(id, await callTool(msg.params));
|
|
163
|
+
case 'prompts/list':
|
|
164
|
+
return resultResponse(id, listPrompts());
|
|
165
|
+
case 'prompts/get':
|
|
166
|
+
return resultResponse(id, getPrompt(msg.params));
|
|
131
167
|
default:
|
|
132
168
|
if (isNotification(msg)) return null;
|
|
133
169
|
return errorResponse(id, ERROR.METHOD_NOT_FOUND, `method not found: ${method}`);
|
package/src/scanner.js
CHANGED
|
@@ -202,7 +202,19 @@ function computeRecency(paths) {
|
|
|
202
202
|
/* ---------- version: ONLY runs the ALREADY detected binary, with --version ---------- */
|
|
203
203
|
/* Never arbitrary commands; all output is discarded except the version pattern. */
|
|
204
204
|
|
|
205
|
+
// scan() runs once per selected repo; remember each binary's version for a minute instead of asking again.
|
|
206
|
+
const VERSION_TTL_MS = 60 * 1000;
|
|
207
|
+
const versionCache = new Map();
|
|
208
|
+
|
|
205
209
|
function getVersion(bin) {
|
|
210
|
+
const cached = versionCache.get(bin);
|
|
211
|
+
if (cached && Date.now() - cached.at <= VERSION_TTL_MS) return cached.version;
|
|
212
|
+
const version = readVersion(bin);
|
|
213
|
+
versionCache.set(bin, { version, at: Date.now() });
|
|
214
|
+
return version;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function readVersion(bin) {
|
|
206
218
|
try {
|
|
207
219
|
const out = execFileSync(bin, ['--version'], {
|
|
208
220
|
timeout: 3000,
|
|
@@ -609,4 +621,4 @@ function mergeReports(reports) {
|
|
|
609
621
|
};
|
|
610
622
|
}
|
|
611
623
|
|
|
612
|
-
module.exports = { scan, scanForRoots };
|
|
624
|
+
module.exports = { scan, scanForRoots, getVersion };
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Input normalizers: map a RAW session record into the Claude-shaped internal object model
|
|
4
|
+
// that session-scan harvest + transcript-turns already consume
|
|
5
|
+
// ({ cwd, timestamp, type, message:{ role, content:[{type:'text'|'tool_use', ...}] } }).
|
|
6
|
+
// They ONLY translate input shape — downstream redaction/upload (share.js#derivePayload
|
|
7
|
+
// -> POST usage/reports) is unchanged; no field is read that the Claude path didn't expose.
|
|
8
|
+
// FAIL-CLOSED: every normalizer returns null / [] when a record does not match the expected
|
|
9
|
+
// shape — it never fabricates or guesses fields. A documented format that differs from the
|
|
10
|
+
// real one yields "no data for that tool", never wrong data.
|
|
11
|
+
|
|
12
|
+
function str(v) {
|
|
13
|
+
return typeof v === 'string' && v ? v : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function textFromParts(parts) {
|
|
17
|
+
if (typeof parts === 'string') return parts.trim() || null;
|
|
18
|
+
if (!Array.isArray(parts)) return null;
|
|
19
|
+
const out = [];
|
|
20
|
+
for (const p of parts) {
|
|
21
|
+
if (typeof p === 'string') out.push(p);
|
|
22
|
+
else if (p && typeof p === 'object' && typeof p.text === 'string') out.push(p.text);
|
|
23
|
+
}
|
|
24
|
+
const joined = out.join('').trim();
|
|
25
|
+
return joined || null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// --- Codex (~/.codex/sessions/**/rollout-*.jsonl) ---
|
|
29
|
+
// Reads timestamp, cwd (session_meta), user text, and shell commands; a record matching none
|
|
30
|
+
// of those passes through only its own real timestamp/cwd, else is dropped.
|
|
31
|
+
function codexCommand(args) {
|
|
32
|
+
let a = args;
|
|
33
|
+
if (typeof a === 'string') {
|
|
34
|
+
try { a = JSON.parse(a); } catch { return a.trim() || null; }
|
|
35
|
+
}
|
|
36
|
+
if (!a || typeof a !== 'object') return null;
|
|
37
|
+
const cmd = a.command ?? a.cmd ?? a.commandLine;
|
|
38
|
+
if (Array.isArray(cmd)) return cmd.map(String).join(' ').trim() || null;
|
|
39
|
+
return str(cmd);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function normalizeCodexRecord(raw) {
|
|
43
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
44
|
+
const payload = raw.payload && typeof raw.payload === 'object' ? raw.payload : null;
|
|
45
|
+
const item = payload || raw;
|
|
46
|
+
const out = {};
|
|
47
|
+
const ts = str(raw.timestamp) || str(item.timestamp);
|
|
48
|
+
if (ts) out.timestamp = ts;
|
|
49
|
+
const cwd = str(raw.cwd) || str(item.cwd);
|
|
50
|
+
if (cwd) out.cwd = cwd;
|
|
51
|
+
|
|
52
|
+
const role = str(item.role);
|
|
53
|
+
if (role === 'user') {
|
|
54
|
+
const text = textFromParts(item.content);
|
|
55
|
+
if (text) {
|
|
56
|
+
out.type = 'user';
|
|
57
|
+
out.message = { role: 'user', content: [{ type: 'text', text }] };
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const type = str(raw.type) || str(item.type);
|
|
63
|
+
const name = str(item.name) || (type === 'function_call' ? 'shell' : null);
|
|
64
|
+
if (name && /shell|exec|bash|terminal|command/i.test(name)) {
|
|
65
|
+
const command = codexCommand(item.arguments ?? item.input ?? item.args);
|
|
66
|
+
if (command) {
|
|
67
|
+
out.message = { content: [{ type: 'tool_use', name: 'Shell', input: { command } }] };
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return out.timestamp || out.cwd ? out : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// --- Gemini CLI (~/.gemini/tmp/<project-hash>/chats/logs.json | session-*.jsonl) ---
|
|
76
|
+
// logs.json = JSON array of { type:'user'|'model', message, timestamp }; session-*.jsonl =
|
|
77
|
+
// per-line { role, parts }. No cwd is recorded (the dir is a one-way SHA-256 hash), so cwd
|
|
78
|
+
// stays null — global session/time metrics still count; per-repo scoping can't.
|
|
79
|
+
function normalizeGeminiRecord(raw) {
|
|
80
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
81
|
+
const out = {};
|
|
82
|
+
const ts = str(raw.timestamp) || str(raw.time);
|
|
83
|
+
if (ts) out.timestamp = ts;
|
|
84
|
+
|
|
85
|
+
const isUser = str(raw.type) === 'user' || str(raw.role) === 'user';
|
|
86
|
+
if (isUser) {
|
|
87
|
+
const text = str(raw.message) || textFromParts(raw.parts) || textFromParts(raw.content);
|
|
88
|
+
if (text) {
|
|
89
|
+
out.type = 'user';
|
|
90
|
+
out.message = { role: 'user', content: [{ type: 'text', text }] };
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return out.timestamp ? out : null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// --- Continue (continue.dev) ~/.continue/sessions/*.json ---
|
|
98
|
+
// Each file is one session object; expand its history into per-turn objects, carrying
|
|
99
|
+
// workspaceDirectory as cwd (good repo attribution).
|
|
100
|
+
function normalizeContinueRecord(raw) {
|
|
101
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
102
|
+
const cwd = str(raw.workspaceDirectory) || str(raw.workspace) || str(raw.cwd);
|
|
103
|
+
const history = Array.isArray(raw.history) ? raw.history
|
|
104
|
+
: (Array.isArray(raw.messages) ? raw.messages : (Array.isArray(raw.conversation) ? raw.conversation : null));
|
|
105
|
+
// A single message object (not a session) — handle defensively too.
|
|
106
|
+
if (!history) {
|
|
107
|
+
const msg = raw.message && typeof raw.message === 'object' ? raw.message : raw;
|
|
108
|
+
if (str(msg.role) === 'user') {
|
|
109
|
+
const text = typeof msg.content === 'string' ? msg.content.trim() : textFromParts(msg.content);
|
|
110
|
+
if (text) return oneUser(text, cwd, str(raw.timestamp) || str(raw.createdAt));
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
const out = [];
|
|
115
|
+
for (const entry of history) {
|
|
116
|
+
const msg = entry && entry.message && typeof entry.message === 'object' ? entry.message : entry;
|
|
117
|
+
if (!msg || typeof msg !== 'object') continue;
|
|
118
|
+
const ts = str(entry.timestamp) || str(entry.createdAt) || str(msg.timestamp);
|
|
119
|
+
// Only emit on a real user turn (with text) or a record carrying a real timestamp.
|
|
120
|
+
if (str(msg.role) === 'user') {
|
|
121
|
+
const text = typeof msg.content === 'string' ? msg.content.trim() : textFromParts(msg.content);
|
|
122
|
+
if (text) { out.push(oneUser(text, cwd, ts)); continue; }
|
|
123
|
+
}
|
|
124
|
+
if (ts) out.push(cwd ? { cwd, timestamp: ts } : { timestamp: ts });
|
|
125
|
+
}
|
|
126
|
+
return out.length ? out : null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// --- Cline / Roo Code: VS Code globalStorage .../tasks/<id>/api_conversation_history.json ---
|
|
130
|
+
// One JSON array of Anthropic messages (role/content); no cwd and no timestamp in this file
|
|
131
|
+
// (ui_messages.json has ts) → cwd/time attribution absent. Emits only real user turns.
|
|
132
|
+
function normalizeClineRecord(raw) {
|
|
133
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
134
|
+
if (str(raw.role) !== 'user') return null;
|
|
135
|
+
const text = typeof raw.content === 'string' ? raw.content.trim() : textFromParts(raw.content);
|
|
136
|
+
return text ? oneUser(text, null, null) : null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// --- Aider: per-repo .aider.chat.history.md (markdown) ---
|
|
140
|
+
// Repo-level source → cwd is the repo toplevel (good attribution). Markdown parse:
|
|
141
|
+
// '# aider chat started at <ts>' sets the session time; '#### <text>' lines are user
|
|
142
|
+
// instructions. A file matching neither pattern yields 0 records.
|
|
143
|
+
function parseAiderHistory(text, cwd) {
|
|
144
|
+
if (typeof text !== 'string' || !text) return [];
|
|
145
|
+
const out = [];
|
|
146
|
+
let ts = null;
|
|
147
|
+
for (const line of text.split('\n')) {
|
|
148
|
+
const started = /^#\s*aider chat started at\s+(.+?)\s*$/i.exec(line);
|
|
149
|
+
if (started) {
|
|
150
|
+
const t = Date.parse(started[1].replace(' ', 'T'));
|
|
151
|
+
ts = Number.isFinite(t) ? new Date(t).toISOString() : null;
|
|
152
|
+
if (ts) out.push(cwd ? { cwd, timestamp: ts } : { timestamp: ts });
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const user = /^####\s+(.*)$/.exec(line);
|
|
156
|
+
if (user && user[1].trim()) out.push(oneUser(user[1].trim(), cwd, ts));
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function oneUser(text, cwd, ts) {
|
|
162
|
+
const o = { type: 'user', message: { role: 'user', content: [{ type: 'text', text }] } };
|
|
163
|
+
if (cwd) o.cwd = cwd;
|
|
164
|
+
if (ts) o.timestamp = ts;
|
|
165
|
+
return o;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
module.exports = {
|
|
169
|
+
normalizeCodexRecord,
|
|
170
|
+
normalizeGeminiRecord,
|
|
171
|
+
normalizeContinueRecord,
|
|
172
|
+
normalizeClineRecord,
|
|
173
|
+
parseAiderHistory,
|
|
174
|
+
};
|