shakers-cli 0.0.0-stage → 0.14.1
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/LICENSE +59 -0
- package/README.md +1233 -2
- package/bin/add-agent.js +88 -0
- package/bin/add-project.js +88 -0
- package/bin/add-role.js +61 -0
- package/bin/add-skill.js +88 -0
- package/bin/ai-usage.js +99 -0
- package/bin/alma.js +99 -0
- package/bin/applications.js +69 -0
- package/bin/availability.js +70 -0
- package/bin/certifications.js +69 -0
- package/bin/certify.js +73 -0
- package/bin/change-role.js +61 -0
- package/bin/config.js +124 -0
- package/bin/experiences.js +69 -0
- package/bin/find-projects.js +89 -0
- package/bin/invitations.js +69 -0
- package/bin/lang.js +70 -0
- package/bin/login.js +216 -0
- package/bin/mcp.js +124 -0
- package/bin/mcpb.js +5 -0
- package/bin/me.js +69 -0
- package/bin/onboarding.js +134 -0
- package/bin/portfolios.js +69 -0
- package/bin/rate.js +70 -0
- package/bin/register.js +337 -0
- package/bin/report-html.js +134 -0
- package/bin/save.js +77 -0
- package/bin/shakers.js +363 -0
- package/bin/share.js +72 -0
- package/bin/show-project.js +72 -0
- package/bin/socials.js +70 -0
- package/bin/superadmin.js +351 -0
- package/bin/update-check-worker.js +70 -0
- package/bin/update.js +111 -0
- package/package.json +40 -4
- package/src/agent-category.js +13 -0
- package/src/agent-evaluation.js +177 -0
- package/src/agent-org-chart.js +355 -0
- package/src/agent-synthesis.js +132 -0
- package/src/agent-usage.js +118 -0
- package/src/agents-client.js +165 -0
- package/src/ai-fluency-cell.js +72 -0
- package/src/ai-profile-client.js +45 -0
- package/src/ai-profile-preview.js +154 -0
- package/src/ai.js +66 -0
- package/src/alma-client.js +123 -0
- package/src/alma-flow.js +32 -0
- package/src/alma-repo-context.js +47 -0
- package/src/alma-shell.js +161 -0
- package/src/ansi.js +62 -0
- package/src/applications-client.js +79 -0
- package/src/applications-flow.js +58 -0
- package/src/auth-client.js +144 -0
- package/src/auth-device-client.js +118 -0
- package/src/auth-method-picker.js +44 -0
- package/src/auth-methods.js +35 -0
- package/src/auth-session-store.js +215 -0
- package/src/authorship-mismatch.js +62 -0
- package/src/authorship.js +204 -0
- package/src/automations-detector.js +170 -0
- package/src/availability-client.js +93 -0
- package/src/availability-flow.js +124 -0
- package/src/backend-request.js +191 -0
- package/src/blind-approval-extract.js +48 -0
- package/src/brand-ansi.js +14 -0
- package/src/brand-svg.js +9 -0
- package/src/browser-tools-detector.js +21 -0
- package/src/build-next-level.js +56 -0
- package/src/certifications-client.js +89 -0
- package/src/certifications-flow.js +75 -0
- package/src/certify-dimension-client.js +163 -0
- package/src/certify-dimension-flow.js +135 -0
- package/src/certify-disclaimer.js +51 -0
- package/src/certify-remediation-prompt.js +23 -0
- package/src/cli-args.js +83 -0
- package/src/command-audit.js +105 -0
- package/src/config-dir.js +120 -0
- package/src/config.js +877 -0
- package/src/consent-flow.js +115 -0
- package/src/consent-skip.js +27 -0
- package/src/cv-file.js +62 -0
- package/src/cv-text.js +92 -0
- package/src/decisions-extract.js +43 -0
- package/src/detect-repos.js +145 -0
- package/src/detected-tools.js +15 -0
- package/src/detection-evidence.js +105 -0
- package/src/detection-noise.js +132 -0
- package/src/detectors.js +221 -0
- package/src/device-auth-store.js +100 -0
- package/src/device-login.js +120 -0
- package/src/email-verification.js +152 -0
- package/src/env-paths.js +30 -0
- package/src/find-projects-cache.js +89 -0
- package/src/find-projects-client.js +148 -0
- package/src/find-projects-flow.js +164 -0
- package/src/git-activity.js +124 -0
- package/src/git-log-prefetch.js +53 -0
- package/src/graph-certs.js +58 -0
- package/src/graph-generator.js +230 -0
- package/src/graph-scan.js +239 -0
- package/src/i18n.js +3341 -0
- package/src/inter-fonts.js +6 -0
- package/src/interactive-select.js +183 -0
- package/src/interview-client.js +226 -0
- package/src/interview-transcript.js +56 -0
- package/src/inventory-client.js +90 -0
- package/src/invitations-client.js +66 -0
- package/src/invitations-flow.js +50 -0
- package/src/lang-client.js +147 -0
- package/src/lang-codes.js +14 -0
- package/src/lang-flow.js +85 -0
- package/src/legal-notice.js +53 -0
- package/src/locale.js +72 -0
- package/src/markdown-ansi.js +50 -0
- package/src/maturity.js +111 -0
- package/src/mcp-ai-usage-result-tool.js +111 -0
- package/src/mcp-ai-usage-tool.js +401 -0
- package/src/mcp-alma-tools.js +95 -0
- package/src/mcp-applications-tools.js +44 -0
- package/src/mcp-auth-form.js +480 -0
- package/src/mcp-availability-tools.js +45 -0
- package/src/mcp-certifications-tools.js +44 -0
- package/src/mcp-certify-dimension-tools.js +66 -0
- package/src/mcp-choice.js +95 -0
- package/src/mcp-detector.js +223 -0
- package/src/mcp-device-login-tools.js +177 -0
- package/src/mcp-find-projects-tools.js +69 -0
- package/src/mcp-install.js +275 -0
- package/src/mcp-instructions.js +27 -0
- package/src/mcp-invitations-tools.js +44 -0
- package/src/mcp-lang-tools.js +41 -0
- package/src/mcp-onboarding-tools.js +379 -0
- package/src/mcp-portfolios-tools.js +50 -0
- package/src/mcp-profile-tools.js +48 -0
- package/src/mcp-prompts.js +73 -0
- package/src/mcp-rate-tools.js +44 -0
- package/src/mcp-register-tools.js +441 -0
- package/src/mcp-report-tools.js +105 -0
- package/src/mcp-role-tools.js +372 -0
- package/src/mcp-saved-positions-tools.js +59 -0
- package/src/mcp-server.js +290 -0
- package/src/mcp-show-project-tools.js +52 -0
- package/src/mcp-signup-client.js +118 -0
- package/src/mcp-signup-tools.js +1171 -0
- package/src/mcp-socials-tools.js +41 -0
- package/src/memory-structure-detector.js +108 -0
- package/src/model-call-record.js +198 -0
- package/src/onboarding-availability.js +26 -0
- package/src/onboarding-client.js +374 -0
- package/src/onboarding-flow.js +1162 -0
- package/src/onboarding-funnel.js +272 -0
- package/src/onboarding-livekit-client.js +102 -0
- package/src/onboarding-status-client.js +57 -0
- package/src/open-file.js +32 -0
- package/src/osc-link.js +16 -0
- package/src/portfolio-client.js +164 -0
- package/src/portfolio-git-info.js +61 -0
- package/src/portfolios-client.js +85 -0
- package/src/portfolios-flow.js +79 -0
- package/src/profile-client.js +133 -0
- package/src/profile-flow.js +115 -0
- package/src/profile-switch.js +46 -0
- package/src/prompt-select.js +66 -0
- package/src/rate-client.js +81 -0
- package/src/rate-flow.js +137 -0
- package/src/register-context.js +145 -0
- package/src/render-ai-fluency.js +266 -0
- package/src/render-certification.js +177 -0
- package/src/render-html.js +140 -0
- package/src/render-report-text.js +27 -0
- package/src/render-sheet.js +261 -0
- package/src/render-terminal.js +109 -0
- package/src/render-usage-report-text.js +155 -0
- package/src/repo-context.js +215 -0
- package/src/report-gating.js +49 -0
- package/src/report-store.js +226 -0
- package/src/report-theme.js +177 -0
- package/src/report-view-client.js +50 -0
- package/src/reveal-output.js +29 -0
- package/src/roadmap-content.js +453 -0
- package/src/roadmap-personalization.js +132 -0
- package/src/roadmap-prompt.js +154 -0
- package/src/roles-client.js +112 -0
- package/src/roles-flow.js +192 -0
- package/src/sanitize-network-text.js +36 -0
- package/src/saved-positions-client.js +71 -0
- package/src/saved-positions-flow.js +50 -0
- package/src/scan-exclusions.js +50 -0
- package/src/scanner.js +624 -0
- package/src/scope-signals.js +148 -0
- package/src/secret-prompt.js +72 -0
- package/src/session-gate.js +30 -0
- package/src/session-normalizers.js +174 -0
- package/src/session-refresh.js +51 -0
- package/src/session-scan.js +475 -0
- package/src/session-signals.js +36 -0
- package/src/shakers-wordmark.js +17 -0
- package/src/share-card.js +362 -0
- package/src/share-certification.js +111 -0
- package/src/share-consent-state.js +157 -0
- package/src/share-payload.js +300 -0
- package/src/share.js +185 -0
- package/src/show-project-client.js +58 -0
- package/src/show-project-flow.js +119 -0
- package/src/signup-client.js +103 -0
- package/src/signup-copy.js +450 -0
- package/src/signup-language.js +21 -0
- package/src/skills-client.js +102 -0
- package/src/socials-client.js +91 -0
- package/src/socials-flow.js +83 -0
- package/src/specificity-extract.js +41 -0
- package/src/start-add-agents.js +306 -0
- package/src/start-add-portfolio.js +388 -0
- package/src/start-add-skills.js +260 -0
- package/src/stdin-ask.js +129 -0
- package/src/steering-extract.js +43 -0
- package/src/strip-internal-comments.js +45 -0
- package/src/tech-detector.js +295 -0
- package/src/templates/report-sheet.html +572 -0
- package/src/terminal-agents.js +207 -0
- package/src/terminal-format.js +85 -0
- package/src/terminal-progress.js +106 -0
- package/src/terminal-sections.js +219 -0
- package/src/tier-analysis.js +126 -0
- package/src/tier-engine.js +114 -0
- package/src/tier-projection.js +117 -0
- package/src/transcript-turns.js +49 -0
- package/src/translations-client.js +84 -0
- package/src/typewriter.js +154 -0
- package/src/update-flow.js +223 -0
- package/src/update-notifier.js +113 -0
- package/src/usage-model-calls.js +119 -0
- package/src/usage-oneshot-commands.js +180 -0
- package/src/usage-repo-scope.js +162 -0
- package/src/usage-run-steps.js +354 -0
- package/src/work-streams.js +79 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Closed questions of the MCP sign-up: a dialog when the client can elicit, otherwise one block the model prints, question last.
|
|
4
|
+
|
|
5
|
+
const SAY_MESSAGE = 'Print `say` word for word as your whole reply: it already holds the texts, the question and its options as a list. Present them as a choice and pass back the exact label the talent picks, never a number; add nothing of your own and call no tool until the talent answers.';
|
|
6
|
+
|
|
7
|
+
// Fixed texts that are not a question ride on the next question block: a model skips a standalone relay.
|
|
8
|
+
const notices = [];
|
|
9
|
+
|
|
10
|
+
function queueNotice(text) {
|
|
11
|
+
if (text && !notices.includes(text)) notices.push(text);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function takeNotices() {
|
|
15
|
+
return notices.splice(0);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Fixed texts travel with their question, so a model that skips "show this first" still shows them.
|
|
19
|
+
function sayBlock(texts, question, options) {
|
|
20
|
+
return [...texts, question, options.map((o) => `- ${o}`).join('\n')].join('\n\n');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function chatChoice(question, options, texts = []) {
|
|
24
|
+
return { say: sayBlock([...takeNotices(), ...texts], question, options), options, choiceMessage: SAY_MESSAGE };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// The option an answer names: by its label, or by a position number if the talent still types one.
|
|
28
|
+
function optionFor(answer, options) {
|
|
29
|
+
if (typeof answer !== 'string') return null;
|
|
30
|
+
const text = answer.trim();
|
|
31
|
+
if (options.includes(text)) return text;
|
|
32
|
+
const n = /^\d+$/.test(text) ? Number(text) : 0;
|
|
33
|
+
return n >= 1 && n <= options.length ? options[n - 1] : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// { answer } from the dialog, { dismissed: <action> } when the talent closed it, or { chat } when there is no dialog.
|
|
37
|
+
async function askChoice(ctx, { texts: own = [], question, options }) {
|
|
38
|
+
const texts = [...takeNotices(), ...own];
|
|
39
|
+
const elicit = ctx && typeof ctx.elicit === 'function' ? ctx.elicit : null;
|
|
40
|
+
const reply = elicit
|
|
41
|
+
? await elicit({
|
|
42
|
+
message: [...texts, question].join('\n\n'),
|
|
43
|
+
requestedSchema: { type: 'object', properties: { choice: { type: 'string', title: question, enum: options } }, required: ['choice'] },
|
|
44
|
+
})
|
|
45
|
+
: null;
|
|
46
|
+
if (!reply) return { chat: chatChoice(question, options, texts) };
|
|
47
|
+
const choice = reply.action === 'accept' && reply.content ? reply.content.choice : undefined;
|
|
48
|
+
if (options.includes(choice)) return { answer: choice };
|
|
49
|
+
return { dismissed: reply.action, chat: chatChoice(question, options, texts) };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// A closed question after fixed texts: the chat answer the model passed back, else the dialog, else the block to print.
|
|
53
|
+
async function askAfterTexts(ctx, { tool, texts, question, options, answer }) {
|
|
54
|
+
const picked = optionFor(answer, options);
|
|
55
|
+
if (picked) return { answer: picked };
|
|
56
|
+
const asked = ctx && ctx.elicitation ? await askChoice(ctx, { texts, question, options }) : { chat: chatChoice(question, options, texts) };
|
|
57
|
+
if (asked.answer) return { answer: asked.answer };
|
|
58
|
+
return {
|
|
59
|
+
reply: {
|
|
60
|
+
ok: false,
|
|
61
|
+
reason: 'answer-required',
|
|
62
|
+
...(asked.dismissed ? { dismissed: asked.dismissed } : {}),
|
|
63
|
+
...asked.chat,
|
|
64
|
+
message: `${asked.chat.choiceMessage} Then call ${tool} again with answer set to the option they pick.`,
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Questions this process put to the talent in the chat, keyed by exactly what they would allow:
|
|
70
|
+
// an answer counts only for a question the tool asked, so a flag the AI sets by itself never does.
|
|
71
|
+
const pendingConsent = new Set();
|
|
72
|
+
|
|
73
|
+
// The talent's yes or no before a tool reads this computer or sends data: { granted } or { reply } to return.
|
|
74
|
+
async function askConsent(ctx, { tool, key, question, yes, no, answer }) {
|
|
75
|
+
const options = [yes, no];
|
|
76
|
+
const picked = pendingConsent.has(key) ? optionFor(answer, options) : null;
|
|
77
|
+
if (picked) {
|
|
78
|
+
pendingConsent.delete(key);
|
|
79
|
+
return { granted: picked === yes };
|
|
80
|
+
}
|
|
81
|
+
const asked = ctx && ctx.elicitation ? await askChoice(ctx, { question, options }) : { chat: chatChoice(question, options) };
|
|
82
|
+
if (asked.answer) return { granted: asked.answer === yes };
|
|
83
|
+
pendingConsent.add(key);
|
|
84
|
+
return {
|
|
85
|
+
reply: {
|
|
86
|
+
ok: false,
|
|
87
|
+
reason: 'consent-required',
|
|
88
|
+
...(asked.dismissed ? { dismissed: asked.dismissed } : {}),
|
|
89
|
+
...asked.chat,
|
|
90
|
+
message: `Nothing was read or sent. ${asked.chat.choiceMessage} Then call ${tool} again with the same arguments and answer set to the option they pick.`,
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = { askConsent, askChoice, askAfterTexts, chatChoice, optionFor, queueNotice, takeNotices, SAY_MESSAGE };
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { getHomeDir } = require('./env-paths');
|
|
6
|
+
|
|
7
|
+
// KEYWORD -> { SERVICE LABEL, CATEGORY } (issue 110).
|
|
8
|
+
const SERVICE_CATALOG = [
|
|
9
|
+
// --- data ---
|
|
10
|
+
{ kw: 'postgresql', label: 'Postgres', category: 'data' },
|
|
11
|
+
{ kw: 'postgres', label: 'Postgres', category: 'data' },
|
|
12
|
+
{ kw: 'mysql', label: 'MySQL', category: 'data' },
|
|
13
|
+
{ kw: 'sqlite', label: 'SQLite', category: 'data' },
|
|
14
|
+
{ kw: 'mongo', label: 'MongoDB', category: 'data' },
|
|
15
|
+
{ kw: 'redis', label: 'Redis', category: 'data' },
|
|
16
|
+
{ kw: 'snowflake', label: 'Snowflake', category: 'data' },
|
|
17
|
+
{ kw: 'bigquery', label: 'BigQuery', category: 'data' },
|
|
18
|
+
{ kw: 'clickhouse', label: 'ClickHouse', category: 'data' },
|
|
19
|
+
{ kw: 'dynamodb', label: 'DynamoDB', category: 'data' },
|
|
20
|
+
{ kw: 'elastic', label: 'Elasticsearch', category: 'data' },
|
|
21
|
+
{ kw: 'supabase', label: 'Supabase', category: 'data' },
|
|
22
|
+
{ kw: 'airtable', label: 'Airtable', category: 'data' },
|
|
23
|
+
{ kw: 'notion', label: 'Notion', category: 'data' },
|
|
24
|
+
{ kw: 'looker', label: 'Looker', category: 'data' },
|
|
25
|
+
{ kw: 'attio', label: 'Attio', category: 'data' },
|
|
26
|
+
{ kw: 'sheet', label: 'Google Sheets', category: 'data' },
|
|
27
|
+
{ kw: 'drive', label: 'Google Drive', category: 'data' },
|
|
28
|
+
{ kw: 'database', label: null, category: 'data' },
|
|
29
|
+
// --- comms ---
|
|
30
|
+
{ kw: 'slack', label: 'Slack', category: 'comms' },
|
|
31
|
+
{ kw: 'discord', label: 'Discord', category: 'comms' },
|
|
32
|
+
{ kw: 'gmail', label: 'Gmail', category: 'comms' },
|
|
33
|
+
{ kw: 'teams', label: 'Microsoft Teams', category: 'comms' },
|
|
34
|
+
{ kw: 'telegram', label: 'Telegram', category: 'comms' },
|
|
35
|
+
{ kw: 'whatsapp', label: 'WhatsApp', category: 'comms' },
|
|
36
|
+
{ kw: 'twilio', label: 'Twilio', category: 'comms' },
|
|
37
|
+
{ kw: 'zoom', label: 'Zoom', category: 'comms' },
|
|
38
|
+
{ kw: 'email', label: null, category: 'comms' },
|
|
39
|
+
{ kw: 'mail', label: null, category: 'comms' },
|
|
40
|
+
{ kw: 'sms', label: null, category: 'comms' },
|
|
41
|
+
// --- dev ---
|
|
42
|
+
{ kw: 'github', label: 'GitHub', category: 'dev' },
|
|
43
|
+
{ kw: 'gitlab', label: 'GitLab', category: 'dev' },
|
|
44
|
+
{ kw: 'bitbucket', label: 'Bitbucket', category: 'dev' },
|
|
45
|
+
{ kw: 'kubernetes', label: 'Kubernetes', category: 'dev' },
|
|
46
|
+
{ kw: 'k8s', label: 'Kubernetes', category: 'dev' },
|
|
47
|
+
{ kw: 'docker', label: 'Docker', category: 'dev' },
|
|
48
|
+
{ kw: 'sentry', label: 'Sentry', category: 'dev' },
|
|
49
|
+
{ kw: 'linear', label: 'Linear', category: 'dev' },
|
|
50
|
+
{ kw: 'jira', label: 'Jira', category: 'dev' },
|
|
51
|
+
{ kw: 'confluence', label: 'Confluence', category: 'dev' },
|
|
52
|
+
{ kw: 'figma', label: 'Figma', category: 'dev' },
|
|
53
|
+
{ kw: 'terraform', label: 'Terraform', category: 'dev' },
|
|
54
|
+
{ kw: 'cloudflare', label: 'Cloudflare', category: 'dev' },
|
|
55
|
+
{ kw: 'vercel', label: 'Vercel', category: 'dev' },
|
|
56
|
+
{ kw: 'netlify', label: 'Netlify', category: 'dev' },
|
|
57
|
+
{ kw: 'railway', label: 'Railway', category: 'dev' },
|
|
58
|
+
{ kw: 'context7', label: 'Context7', category: 'dev' },
|
|
59
|
+
{ kw: 'aws', label: 'AWS', category: 'dev' },
|
|
60
|
+
{ kw: 'filesystem', label: 'Filesystem', category: 'dev' },
|
|
61
|
+
{ kw: 'git', label: null, category: 'dev' },
|
|
62
|
+
// --- browser ---
|
|
63
|
+
{ kw: 'playwright', label: 'Playwright', category: 'browser' },
|
|
64
|
+
{ kw: 'puppeteer', label: 'Puppeteer', category: 'browser' },
|
|
65
|
+
{ kw: 'browserbase', label: 'Browserbase', category: 'browser' },
|
|
66
|
+
{ kw: 'browser-use', label: 'Browser Use', category: 'browser' },
|
|
67
|
+
{ kw: 'stagehand', label: 'Stagehand', category: 'browser' },
|
|
68
|
+
{ kw: 'chromium', label: 'Chromium', category: 'browser' },
|
|
69
|
+
{ kw: 'chrome', label: 'Chrome', category: 'browser' },
|
|
70
|
+
{ kw: 'browser', label: null, category: 'browser' },
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
// ORDER IS BEHAVIOUR, and it is preserved from the previous table on purpose.
|
|
74
|
+
function matchService(name) {
|
|
75
|
+
const lower = String(name).toLowerCase();
|
|
76
|
+
return SERVICE_CATALOG.find((entry) => lower.includes(entry.kw)) || null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function categorizeMcpServerName(name) {
|
|
80
|
+
const hit = matchService(name);
|
|
81
|
+
return hit ? hit.category : 'other';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// The product behind a server name, or `null` when the name does not carry one.
|
|
85
|
+
function serviceForMcpServerName(name) {
|
|
86
|
+
const hit = matchService(name);
|
|
87
|
+
return hit && hit.label ? hit.label : null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// THE single inventory of MCP config locations (issue 112).
|
|
91
|
+
const MCP_CONFIG_LOCATIONS = [
|
|
92
|
+
// --- Claude Code (already read before 112) ---
|
|
93
|
+
{ tool: 'claude-code', scope: 'project', keys: ['mcpServers'], confidence: 'high', file: (root) => path.join(root, '.mcp.json') },
|
|
94
|
+
{ tool: 'claude-code', scope: 'home', keys: ['mcpServers'], confidence: 'high', file: (root, home) => path.join(home, '.claude.json') },
|
|
95
|
+
// --- Cursor: the project one was read, the GLOBAL one was the bug ---
|
|
96
|
+
{ tool: 'cursor', scope: 'project', keys: ['mcpServers'], confidence: 'high', file: (root) => path.join(root, '.cursor', 'mcp.json') },
|
|
97
|
+
{ tool: 'cursor', scope: 'home', keys: ['mcpServers'], confidence: 'high', file: (root, home) => path.join(home, '.cursor', 'mcp.json') },
|
|
98
|
+
// --- Windsurf (home only; no project-level MCP config known) ---
|
|
99
|
+
{ tool: 'windsurf', scope: 'home', keys: ['mcpServers'], confidence: 'medium', file: (root, home) => path.join(home, '.codeium', 'windsurf', 'mcp_config.json') },
|
|
100
|
+
// --- Gemini CLI: home was read; the project-level settings file was not ---
|
|
101
|
+
{ tool: 'gemini-cli', scope: 'home', keys: ['mcpServers'], confidence: 'medium', file: (root, home) => path.join(home, '.gemini', 'settings.json') },
|
|
102
|
+
{ tool: 'gemini-cli', scope: 'project', keys: ['mcpServers'], confidence: 'medium', file: (root) => path.join(root, '.gemini', 'settings.json') },
|
|
103
|
+
// VS Code: `servers`, not `mcpServers`, and three OS-specific user paths.
|
|
104
|
+
{ tool: null, scope: 'project', keys: ['servers', 'mcpServers'], confidence: 'medium', file: (root) => path.join(root, '.vscode', 'mcp.json') },
|
|
105
|
+
{ tool: null, scope: 'home', keys: ['servers', 'mcpServers'], confidence: 'medium', file: (root, home) => path.join(home, 'Library', 'Application Support', 'Code', 'User', 'mcp.json') },
|
|
106
|
+
{ tool: null, scope: 'home', keys: ['servers', 'mcpServers'], confidence: 'medium', file: (root, home) => path.join(home, '.config', 'Code', 'User', 'mcp.json') },
|
|
107
|
+
{ tool: null, scope: 'home', keys: ['servers', 'mcpServers'], confidence: 'medium', file: (root, home) => path.join(home, 'AppData', 'Roaming', 'Code', 'User', 'mcp.json') },
|
|
108
|
+
// --- Claude Desktop: same `mcpServers` shape, three OS-specific paths ---
|
|
109
|
+
{ tool: null, scope: 'home', keys: ['mcpServers'], confidence: 'medium', file: (root, home) => path.join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json') },
|
|
110
|
+
{ tool: null, scope: 'home', keys: ['mcpServers'], confidence: 'medium', file: (root, home) => path.join(home, '.config', 'Claude', 'claude_desktop_config.json') },
|
|
111
|
+
{ tool: null, scope: 'home', keys: ['mcpServers'], confidence: 'medium', file: (root, home) => path.join(home, 'AppData', 'Roaming', 'Claude', 'claude_desktop_config.json') },
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
// Reads ONLY the top-level keys of the server map from one config file, trying
|
|
115
|
+
// each declared key in order. Never reads or returns any value under those keys.
|
|
116
|
+
function readMcpServerNames(file, keys = ['mcpServers']) {
|
|
117
|
+
let raw;
|
|
118
|
+
try {
|
|
119
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
120
|
+
} catch {
|
|
121
|
+
return [];
|
|
122
|
+
}
|
|
123
|
+
let obj;
|
|
124
|
+
try {
|
|
125
|
+
obj = JSON.parse(raw);
|
|
126
|
+
} catch {
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
129
|
+
if (!obj || typeof obj !== 'object') return [];
|
|
130
|
+
for (const key of keys) {
|
|
131
|
+
const servers = obj[key];
|
|
132
|
+
if (servers && typeof servers === 'object' && !Array.isArray(servers)) return Object.keys(servers);
|
|
133
|
+
}
|
|
134
|
+
return [];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Deterministic (no-LLM) MCP-by-name detection over every location in `MCP_CONFIG_LOCATIONS` (project ∪ home).
|
|
138
|
+
function detectMcpServers(root) {
|
|
139
|
+
const home = getHomeDir();
|
|
140
|
+
// name -> { scopes:Set, tools:Set }
|
|
141
|
+
const found = new Map();
|
|
142
|
+
const byTool = {};
|
|
143
|
+
|
|
144
|
+
for (const loc of MCP_CONFIG_LOCATIONS) {
|
|
145
|
+
let file;
|
|
146
|
+
try {
|
|
147
|
+
file = loc.file(root, home);
|
|
148
|
+
} catch {
|
|
149
|
+
continue; // an unresolvable home never breaks the other locations
|
|
150
|
+
}
|
|
151
|
+
const names = readMcpServerNames(file, loc.keys);
|
|
152
|
+
if (loc.tool && names.length) {
|
|
153
|
+
// Per-tool count is the union across THAT tool's own locations, so a
|
|
154
|
+
// server configured both in the project and globally counts once for it.
|
|
155
|
+
const set = byTool[`__set_${loc.tool}`] || (byTool[`__set_${loc.tool}`] = new Set());
|
|
156
|
+
for (const n of names) set.add(n);
|
|
157
|
+
}
|
|
158
|
+
for (const name of names) {
|
|
159
|
+
const entry = found.get(name) || { scopes: new Set(), tools: new Set() };
|
|
160
|
+
entry.scopes.add(loc.scope);
|
|
161
|
+
if (loc.tool) entry.tools.add(loc.tool);
|
|
162
|
+
found.set(name, entry);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Collapse the per-tool sets into plain counts (and drop the working keys).
|
|
167
|
+
for (const key of Object.keys(byTool)) {
|
|
168
|
+
if (key.startsWith('__set_')) {
|
|
169
|
+
byTool[key.slice('__set_'.length)] = byTool[key].size;
|
|
170
|
+
delete byTool[key];
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const servers = [...found.keys()]
|
|
175
|
+
.sort()
|
|
176
|
+
.map((name) => {
|
|
177
|
+
const entry = found.get(name);
|
|
178
|
+
return {
|
|
179
|
+
name,
|
|
180
|
+
category: categorizeMcpServerName(name),
|
|
181
|
+
// 'project' wins when a server is declared in both: the most specific
|
|
182
|
+
// scope is the honest label for "this repo uses it".
|
|
183
|
+
scope: entry.scopes.has('project') ? 'project' : 'home',
|
|
184
|
+
// Issue 110: the PRODUCT, derived from the name only. `null` when the
|
|
185
|
+
// name carries none — a state, not a hole.
|
|
186
|
+
service: serviceForMcpServerName(name),
|
|
187
|
+
};
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
return buildMcpAggregate(servers, byTool);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Builds the report's `mcp` block from an already-resolved server list + the per-tool counts.
|
|
194
|
+
function buildMcpAggregate(servers, byTool = {}) {
|
|
195
|
+
const sorted = [...servers].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
196
|
+
|
|
197
|
+
const countsByCategory = { data: 0, comms: 0, dev: 0, browser: 0, other: 0 };
|
|
198
|
+
for (const s of sorted) countsByCategory[s.category] += 1;
|
|
199
|
+
|
|
200
|
+
const byService = new Map();
|
|
201
|
+
let unidentified = 0;
|
|
202
|
+
for (const s of sorted) {
|
|
203
|
+
if (!s.service) {
|
|
204
|
+
unidentified += 1;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
byService.set(s.service, (byService.get(s.service) || 0) + 1);
|
|
208
|
+
}
|
|
209
|
+
const services = [...byService.entries()]
|
|
210
|
+
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
211
|
+
.map(([label, count]) => ({ label, count }));
|
|
212
|
+
|
|
213
|
+
return { servers: sorted, services, unidentified, countsByCategory, total: sorted.length, byTool };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
module.exports = {
|
|
217
|
+
detectMcpServers,
|
|
218
|
+
buildMcpAggregate,
|
|
219
|
+
categorizeMcpServerName,
|
|
220
|
+
serviceForMcpServerName,
|
|
221
|
+
MCP_CONFIG_LOCATIONS,
|
|
222
|
+
SERVICE_CATALOG,
|
|
223
|
+
};
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// MCP: two provider-agnostic tools (start returns URL+user_code to relay VERBATIM; poll finishes). device_code stays in the local store, never in a result.
|
|
4
|
+
|
|
5
|
+
const START_SCHEMA = {
|
|
6
|
+
type: 'object',
|
|
7
|
+
properties: {
|
|
8
|
+
provider: { type: 'string', enum: ['google', 'linkedin', 'email'], description: "Optional provider. Defaults to 'google'. Same device flow either way; it selects which option the Shakers sign-in page opens — 'email' opens the email sign-in (the talent reuses their open web session or signs in there, never a password in the chat)." },
|
|
9
|
+
mode: { type: 'string', enum: ['login', 'register'], description: "Optional. 'login' signs an existing talent in; 'register' creates the account if new (the profile steps come from the register/onboarding tools afterwards). Defaults to 'login'." },
|
|
10
|
+
freelanceType: { type: 'string', description: 'Optional, register only: FREELANCE | EMPLOYEE | AGENCY | POTENTIAL_FREELANCE. Used to complete a new account.' },
|
|
11
|
+
preferredLanguage: { type: 'string', description: 'Optional, register only: es | en | it | pt | fr.' },
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const POLL_SCHEMA = { type: 'object', properties: {} };
|
|
16
|
+
|
|
17
|
+
function makeDeviceLoginTools(deps = {}) {
|
|
18
|
+
const {
|
|
19
|
+
getDeviceAuthorizeEndpoint = require('./config').getDeviceAuthorizeEndpoint,
|
|
20
|
+
getDeviceTokenEndpoint = require('./config').getDeviceTokenEndpoint,
|
|
21
|
+
getAuthTokenEndpoint = require('./config').getAuthTokenEndpoint,
|
|
22
|
+
getCompleteRegistrationEndpoint = require('./config').getCompleteRegistrationEndpoint,
|
|
23
|
+
startDeviceLogin = require('./device-login').startDeviceLogin,
|
|
24
|
+
pollDeviceToken = require('./device-login').pollDeviceToken,
|
|
25
|
+
finalizeDeviceSession = require('./device-login').finalizeDeviceSession,
|
|
26
|
+
appendProviderParam = require('./device-login').appendProviderParam,
|
|
27
|
+
savePendingDevice = require('./device-auth-store').savePendingDevice,
|
|
28
|
+
loadPendingDevice = require('./device-auth-store').loadPendingDevice,
|
|
29
|
+
clearPendingDevice = require('./device-auth-store').clearPendingDevice,
|
|
30
|
+
saveAuthSession = require('./auth-session-store').saveAuthSession,
|
|
31
|
+
buildRegistrationContext = require('./signup-client').buildRegistrationContext,
|
|
32
|
+
} = deps;
|
|
33
|
+
|
|
34
|
+
async function start(args = {}) {
|
|
35
|
+
const mode = args.mode === 'register' ? 'register' : 'login';
|
|
36
|
+
const provider = args.provider === 'linkedin' ? 'linkedin' : args.provider === 'email' ? 'email' : 'google';
|
|
37
|
+
const authorizeEndpoint = getDeviceAuthorizeEndpoint();
|
|
38
|
+
const tokenEndpoint = getDeviceTokenEndpoint();
|
|
39
|
+
const authTokenEndpoint = getAuthTokenEndpoint();
|
|
40
|
+
if (!authorizeEndpoint || !tokenEndpoint || !authTokenEndpoint) {
|
|
41
|
+
return { ok: false, reason: 'no-endpoint', message: 'No sign-in endpoint configured — set SHAKERS_CLI_HUB_BASE.' };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const authz = await startDeviceLogin({ authorizeEndpoint });
|
|
45
|
+
if (!authz.ok) return { ok: false, reason: authz.reason || 'authorize-failed' };
|
|
46
|
+
|
|
47
|
+
const verificationUrl = appendProviderParam(authz.verificationUriComplete, provider);
|
|
48
|
+
const expiresAt = new Date(Date.now() + authz.expiresIn * 1000).toISOString();
|
|
49
|
+
try {
|
|
50
|
+
savePendingDevice({
|
|
51
|
+
deviceCode: authz.deviceCode,
|
|
52
|
+
verificationUrl,
|
|
53
|
+
userCode: authz.userCode,
|
|
54
|
+
tokenEndpoint,
|
|
55
|
+
authTokenEndpoint,
|
|
56
|
+
interval: authz.interval,
|
|
57
|
+
expiresAt,
|
|
58
|
+
mode,
|
|
59
|
+
provider,
|
|
60
|
+
registerContext: mode === 'register'
|
|
61
|
+
? { freelanceType: args.freelanceType || null, preferredLanguage: args.preferredLanguage || null }
|
|
62
|
+
: null,
|
|
63
|
+
});
|
|
64
|
+
} catch {
|
|
65
|
+
return { ok: false, reason: 'pending-persist-failed' };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
ok: true,
|
|
70
|
+
status: 'pending',
|
|
71
|
+
provider,
|
|
72
|
+
verificationUrl,
|
|
73
|
+
verificationUri: authz.verificationUri,
|
|
74
|
+
userCode: authz.userCode,
|
|
75
|
+
expiresInSeconds: authz.expiresIn,
|
|
76
|
+
pollIntervalSeconds: authz.interval,
|
|
77
|
+
message: `Show the talent this link and code so they can authorize in their own browser — relay both verbatim, do not invent or complete the code:\n\n${verificationUrl}\n\nCode: ${authz.userCode}\n\nThen call social_signin_poll; if it returns status "pending", wait pollIntervalSeconds and call it again.`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function poll() {
|
|
82
|
+
const pending = loadPendingDevice();
|
|
83
|
+
if (!pending || !pending.deviceCode) {
|
|
84
|
+
return { ok: false, status: 'no-pending', message: 'No social sign-in is in progress — call social_signin_start first.' };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const res = await pollDeviceToken({ deviceCode: pending.deviceCode, tokenEndpoint: pending.tokenEndpoint });
|
|
88
|
+
|
|
89
|
+
const linkBlock = pending.verificationUrl
|
|
90
|
+
? `\n\n${pending.verificationUrl}\n\nCode: ${pending.userCode || ''}`
|
|
91
|
+
: '';
|
|
92
|
+
|
|
93
|
+
if (res.status === 'pending') {
|
|
94
|
+
return {
|
|
95
|
+
ok: true,
|
|
96
|
+
status: 'pending',
|
|
97
|
+
pollIntervalSeconds: pending.interval,
|
|
98
|
+
verificationUrl: pending.verificationUrl,
|
|
99
|
+
userCode: pending.userCode,
|
|
100
|
+
message: `Not authorized yet. Keep showing the talent this link and code so they can finish in their browser:${linkBlock}\n\nThen wait pollIntervalSeconds and call social_signin_poll again.`,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
if (res.status === 'slow_down') {
|
|
104
|
+
const interval = pending.interval + 5;
|
|
105
|
+
try { savePendingDevice({ ...pending, interval }); } catch { /* keep the old interval */ }
|
|
106
|
+
return {
|
|
107
|
+
ok: true,
|
|
108
|
+
status: 'pending',
|
|
109
|
+
pollIntervalSeconds: interval,
|
|
110
|
+
verificationUrl: pending.verificationUrl,
|
|
111
|
+
userCode: pending.userCode,
|
|
112
|
+
message: `Polling too fast — wait pollIntervalSeconds and call social_signin_poll again. Keep the talent's link and code visible:${linkBlock}`,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
if (res.status === 'denied') {
|
|
116
|
+
clearPendingDevice();
|
|
117
|
+
return { ok: false, status: 'denied', message: 'The talent cancelled or did not grant access. No session was started.' };
|
|
118
|
+
}
|
|
119
|
+
if (res.status === 'expired') {
|
|
120
|
+
clearPendingDevice();
|
|
121
|
+
return { ok: false, status: 'expired', message: 'The sign-in request expired. Call social_signin_start to try again.' };
|
|
122
|
+
}
|
|
123
|
+
if (res.status !== 'authorized') {
|
|
124
|
+
return { ok: false, status: 'error', reason: res.reason || 'poll-failed' };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// finalize enforces the order: NEW user -> complete-registration before the JWT exchange. freelanceType is a transient default.
|
|
128
|
+
const rc = pending.registerContext || {};
|
|
129
|
+
const registrationContext = buildRegistrationContext({
|
|
130
|
+
preferredLanguage: rc.preferredLanguage || undefined,
|
|
131
|
+
newsletterConsent: false,
|
|
132
|
+
freelanceType: rc.freelanceType || 'POTENTIAL_FREELANCE',
|
|
133
|
+
});
|
|
134
|
+
const fin = await finalizeDeviceSession({
|
|
135
|
+
sessionToken: res.accessToken,
|
|
136
|
+
isNewUser: res.isNewUser,
|
|
137
|
+
authTokenEndpoint: pending.authTokenEndpoint,
|
|
138
|
+
completeRegistrationEndpoint: getCompleteRegistrationEndpoint(),
|
|
139
|
+
registrationContext,
|
|
140
|
+
});
|
|
141
|
+
if (!fin.ok) {
|
|
142
|
+
// The authorization was consumed; a re-poll can't recover — never treat this as logged in.
|
|
143
|
+
clearPendingDevice();
|
|
144
|
+
return { ok: false, status: 'error', reason: fin.reason };
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
saveAuthSession({ accessToken: fin.token, hubAccessToken: fin.token, email: res.email || pending.email || null });
|
|
148
|
+
} catch {
|
|
149
|
+
clearPendingDevice();
|
|
150
|
+
return { ok: false, status: 'error', reason: 'session-persist-failed' };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
clearPendingDevice();
|
|
154
|
+
return { ok: true, status: 'authorized', method: pending.provider || 'google', email: res.email || null, isNewUser: !!res.isNewUser };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return [
|
|
158
|
+
{
|
|
159
|
+
name: 'social_signin_start',
|
|
160
|
+
title: 'Sign in to Shakers',
|
|
161
|
+
description: 'Opens a Shakers page in your browser where you sign in with Google, LinkedIn or your email. Your password never goes through the chat.',
|
|
162
|
+
guidance: "Begin sign-in (or registration) via the device flow — provider 'google' (default), 'linkedin' or 'email'. Email goes through the SAME web flow: the talent reuses their open Shakers session or signs in with their email on the page, never a password in the chat. Returns a verificationUrl and a userCode: show BOTH to the talent verbatim — they open the URL and enter the code in their own browser (they never type anything in the chat, and you never invent or fill the code). Pass mode 'register' to create the account if it is new. After showing them, call social_signin_poll. Returns { ok, status, provider, verificationUrl, userCode, pollIntervalSeconds, ... }.",
|
|
163
|
+
inputSchema: START_SCHEMA,
|
|
164
|
+
handler: start,
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
name: 'social_signin_poll',
|
|
168
|
+
title: 'Finish signing in',
|
|
169
|
+
description: 'Checks whether you finished signing in on that page and, if so, keeps you signed in on this computer.',
|
|
170
|
+
guidance: "Check whether the talent has finished the social sign-in started by social_signin_start, and persist the session when they have. Call it after showing the code; if it returns status 'pending', wait pollIntervalSeconds and call it again — the 'pending' response repeats the verificationUrl and userCode so you can keep the clickable link in front of the talent. Returns status 'authorized' (session saved, with isNewUser + method), 'pending', 'denied', or 'expired'.",
|
|
171
|
+
inputSchema: POLL_SCHEMA,
|
|
172
|
+
handler: poll,
|
|
173
|
+
},
|
|
174
|
+
];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
module.exports = { makeDeviceLoginTools, START_SCHEMA, POLL_SCHEMA };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// MCP: list the Positions/Projects available to the talent (Find Positions),
|
|
4
|
+
// paginated + filtered server-side. Same params as the CLI so an agent can page.
|
|
5
|
+
|
|
6
|
+
const { TABS, ATTENDANCE, DEFAULT_LIMIT } = require('./find-projects-client');
|
|
7
|
+
const { splitRecommended } = require('./find-projects-flow');
|
|
8
|
+
|
|
9
|
+
const FIND_PROJECTS_SCHEMA = {
|
|
10
|
+
type: 'object',
|
|
11
|
+
properties: {
|
|
12
|
+
tab: { type: 'string', enum: TABS, description: 'all (default order, match desc) or saved (bookmarked).' },
|
|
13
|
+
page: { type: 'integer', minimum: 1, description: '1-based page. offset = (page-1)*limit.' },
|
|
14
|
+
limit: { type: 'integer', minimum: 1, maximum: 50, description: `Items per page (default ${DEFAULT_LIMIT}, max 50).` },
|
|
15
|
+
attendance: { type: 'string', enum: Object.keys(ATTENDANCE), description: 'Work mode filter (server-side).' },
|
|
16
|
+
country: { type: 'string', description: 'ISO 3166-1 alpha-2 country filter (server-side).' },
|
|
17
|
+
recommended: { type: 'boolean', description: 'Client-side: keep only items with a match on this page. If none, falls back to the available items (never empty). `recommended` in the result is "recommended" | "fallback" | null.' },
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function makeFindProjectsTools(deps = {}) {
|
|
22
|
+
const {
|
|
23
|
+
fetchFindProjects = (opts) => require('./find-projects-client').fetchFindProjects({}, opts),
|
|
24
|
+
loadAuthSession = require('./auth-session-store').loadAuthSession,
|
|
25
|
+
sessionStatus = require('./auth-session-store').sessionStatus,
|
|
26
|
+
} = deps;
|
|
27
|
+
|
|
28
|
+
const requireLoginSession = () => {
|
|
29
|
+
const session = loadAuthSession();
|
|
30
|
+
if (sessionStatus(session) !== 'active') {
|
|
31
|
+
throw new Error('no active Shakers session — sign in with social_signin_start (or run `shakers login`) first (find_projects is session-gated).');
|
|
32
|
+
}
|
|
33
|
+
return session;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
async function findProjects(args = {}) {
|
|
37
|
+
const session = requireLoginSession();
|
|
38
|
+
const tab = TABS.includes(args.tab) ? args.tab : 'all';
|
|
39
|
+
const limit = Number.isInteger(args.limit) && args.limit > 0 ? Math.min(args.limit, 50) : DEFAULT_LIMIT;
|
|
40
|
+
const page = Number.isInteger(args.page) && args.page > 0 ? args.page : 1;
|
|
41
|
+
const offset = (page - 1) * limit;
|
|
42
|
+
const attRaw = typeof args.attendance === 'string' ? args.attendance.trim().toLowerCase() : null;
|
|
43
|
+
const attendance = attRaw && ATTENDANCE[attRaw] ? ATTENDANCE[attRaw] : null;
|
|
44
|
+
const countryRaw = typeof args.country === 'string' ? args.country.trim().toUpperCase() : null;
|
|
45
|
+
const country = countryRaw && /^[A-Z]{2}$/.test(countryRaw) ? countryRaw : null;
|
|
46
|
+
|
|
47
|
+
const res = await fetchFindProjects({ hubAccessToken: session.hubAccessToken, tab, limit, offset, attendance, country });
|
|
48
|
+
if (!res.ok) return { ok: false, reason: res.reason };
|
|
49
|
+
// Same client-side recommended filter (+ fallback to available) as the CLI.
|
|
50
|
+
const { shown, mode } = splitRecommended(res.items || [], !!args.recommended);
|
|
51
|
+
return { ok: true, tab, page, limit, filters: { attendance, country, recommended: !!args.recommended }, recommended: mode, meta: res.meta || null, items: shown };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return [
|
|
55
|
+
{
|
|
56
|
+
name: 'find_projects',
|
|
57
|
+
title: 'Find projects',
|
|
58
|
+
description: 'Shows the open Shakers projects that match you best.',
|
|
59
|
+
guidance: "List the Positions/Projects available to the talent right now (Shakers Find Positions), paginated and ordered by match desc. Each item carries its parent Project id/name, title, company, budget, work mode and match. Supports server-side pagination (page/limit) and filters (tab all|saved, attendance, country ISO2). `meta` carries {page,pageSize,total,totalPages}. Requires an active session. The positionId/projectId in each item are INTERNAL handles for calling other tools (e.g. show_project, save_project) — NEVER show or mention them to the talent; refer to a project by its title/company only.",
|
|
60
|
+
inputSchema: FIND_PROJECTS_SCHEMA,
|
|
61
|
+
handler: findProjects,
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = {
|
|
67
|
+
makeFindProjectsTools,
|
|
68
|
+
FIND_PROJECTS_SCHEMA,
|
|
69
|
+
};
|