Files
vercel__vercel-plugin/hooks/posttooluse-bash-chain.mjs
John Lindquist 8e24d0694a feat: add chainTo skill chaining, prompt signals, validation upgrades, and dedup reset on context clear
Add a chainTo frontmatter field to skills that triggers follow-up skill injection
when PostToolUse file contents match regex patterns. Add upgradeToSkill/upgradeWhy
fields to validation rules so validate errors can recommend loading a specific skill.
Register posttooluse-bash-chain.mjs in hooks.json. Add ChainToRule interface to
patterns.mts and skill-map-frontmatter.mts, with parseChainToRules() parser. Add
duplicate-key detection to the YAML parser. Reset dedup claim dir and session file
on clear/compact events in session-start-seen-skills so skills re-inject into fresh
context windows.

Add chainTo rules to: agent-browser-verify, agent-browser, ai-elements, ai-gateway,
ai-generation-persistence, ai-sdk, auth, bootstrap, chat-sdk, cms, cron-jobs,
deployments-cicd, email, env-vars, geist, investigation-mode, json-render,
marketplace, micro, ncc, next-forge, nextjs, observability, payments,
react-best-practices, routing-middleware, runtime-cache, satori, shadcn,
sign-in-with-vercel, swr, turbopack, turborepo, v0-dev, vercel-agent, vercel-api,
vercel-cli, vercel-firewall, vercel-flags, vercel-functions, vercel-queues,
vercel-sandbox, vercel-storage, verification, workflow. Add upgradeToSkill to
ai-elements and ai-sdk validate rules. Expand ai-sdk validate messages with
Run Skill() hints. Update nextjs, vercel-storage, runtime-cache, workflow, turborepo
skill bodies.

Add new skills: geistdocs (Geist design system docs), zzz-test-meta-name-mask
(test fixture). Add skills/_chain-audit.md chain coverage audit doc.

Delete .claude-plugin/marketplace.json, .claude-plugin/plugin.json (deprecated),
skills/edge-runtime/SKILL.md (consolidated into vercel-functions).

Add tests: posttooluse-chain.test.ts (4699 lines, chain injection e2e),
ai-sdk-companion.test.ts (181 lines). Expand build-skill-map.test.ts (+335 lines),
validate-rules.test.ts (+936 lines), session-start-seen-skills.test.ts (+74 lines),
skill-map-frontmatter.test.ts (+50 lines), verification-skill.test.ts (+20 lines).

Update build-manifest.ts to emit chainTo rules and upgradeToSkill fields. Rebuild
generated/skill-manifest.json, generated/skill-catalog.md, generated/build-from-skills.manifest.json.
Rebuild all compiled hooks/*.mjs. Update CLAUDE.md lexical prompt default to on.
Update vercel.md ecosystem graph, docs, and cli-reference.
2026-03-11 15:18:35 -06:00

407 lines
15 KiB
JavaScript
Executable File

#!/usr/bin/env node
// hooks/src/posttooluse-bash-chain.mts
import { readFileSync, realpathSync } from "fs";
import { join, resolve } from "path";
import { fileURLToPath } from "url";
import { detectPlatform } from "./compat.mjs";
import {
pluginRoot as resolvePluginRoot,
readSessionFile,
safeReadFile,
tryClaimSessionKey,
syncSessionFileFromClaims
} from "./hook-env.mjs";
import { extractFrontmatter } from "./skill-map-frontmatter.mjs";
import { createLogger } from "./logger.mjs";
var PLUGIN_ROOT = resolvePluginRoot();
var CHAIN_BUDGET_BYTES = 18e3;
var DEFAULT_CHAIN_CAP = 2;
var PACKAGE_SKILL_MAP = {
// Express / traditional Node servers → Vercel Functions
express: {
skill: "vercel-functions",
message: "Express.js detected \u2014 Vercel uses Web API route handlers, not Express. Loading Vercel Functions guidance."
},
fastify: {
skill: "vercel-functions",
message: "Fastify detected \u2014 consider Vercel Functions with Web Request/Response API for serverless deployment."
},
koa: {
skill: "vercel-functions",
message: "Koa detected \u2014 consider Vercel Functions with Web Request/Response API for serverless deployment."
},
// Queue / background job libraries → Vercel Queues
bullmq: {
skill: "vercel-queues",
message: "BullMQ detected \u2014 Vercel Queues provides durable event streaming without self-managed Redis. Loading Queues guidance."
},
bull: {
skill: "vercel-queues",
message: "Bull detected \u2014 Vercel Queues provides durable event streaming without self-managed Redis. Loading Queues guidance."
},
// Database / ORM libraries → Vercel Storage
mongoose: {
skill: "vercel-storage",
message: "Mongoose detected \u2014 loading Vercel Storage guidance for database options on the platform."
},
prisma: {
skill: "vercel-storage",
message: "Prisma detected \u2014 loading Vercel Storage guidance for Neon Postgres (recommended) and other Marketplace databases."
},
"@libsql/client": {
skill: "vercel-storage",
message: "@libsql/client detected \u2014 loading Vercel Storage guidance for Marketplace database alternatives."
},
"@vercel/postgres": {
skill: "vercel-storage",
message: "@vercel/postgres is sunset \u2014 use @neondatabase/serverless instead. Loading Storage migration guidance."
},
"@vercel/kv": {
skill: "vercel-storage",
message: "@vercel/kv is sunset \u2014 use @upstash/redis instead. Loading Storage migration guidance."
},
// Payments → Stripe integration
stripe: {
skill: "payments",
message: "Stripe detected \u2014 loading Vercel Marketplace Stripe integration guidance for checkout, webhooks, and subscriptions."
},
// Direct AI provider SDKs → AI Gateway
openai: {
skill: "ai-gateway",
message: "Direct OpenAI SDK detected \u2014 AI Gateway provides OIDC auth, failover, and cost tracking with no manual API keys. Loading AI Gateway guidance."
},
"@anthropic-ai/sdk": {
skill: "ai-gateway",
message: "Direct Anthropic SDK detected \u2014 AI Gateway provides unified access to all providers. Loading AI Gateway guidance."
},
"@google/generative-ai": {
skill: "ai-gateway",
message: "Direct Google AI SDK detected \u2014 AI Gateway provides unified access to all providers. Loading AI Gateway guidance."
},
langchain: {
skill: "ai-sdk",
message: "LangChain detected \u2014 AI SDK v6 provides native tool calling, agents, and streaming without the LangChain abstraction layer. Loading AI SDK guidance."
},
"@langchain/core": {
skill: "ai-sdk",
message: "LangChain Core detected \u2014 AI SDK v6 provides native tool calling, agents, and streaming without the LangChain abstraction layer. Loading AI SDK guidance."
},
// Auth
"next-auth": {
skill: "auth",
message: "next-auth detected \u2014 consider Clerk via Vercel Marketplace for managed auth with auto-provisioned env vars. Loading auth guidance."
},
"@clerk/nextjs": {
skill: "auth",
message: "@clerk/nextjs detected \u2014 loading Vercel Marketplace Clerk integration guidance for middleware auth and sign-in flows."
},
// CMS
"@sanity/client": {
skill: "cms",
message: "@sanity/client detected \u2014 loading Vercel Marketplace Sanity integration guidance for studio, preview mode, and revalidation."
},
contentful: {
skill: "cms",
message: "Contentful detected \u2014 loading CMS integration guidance for content modeling, preview mode, and revalidation webhooks."
},
// Chat platforms → Chat SDK
"@slack/bolt": {
skill: "chat-sdk",
message: "@slack/bolt detected \u2014 Chat SDK provides a unified multi-platform API (Slack, Teams, Discord, Telegram) with a single codebase. Loading Chat SDK guidance."
},
"@slack/web-api": {
skill: "chat-sdk",
message: "@slack/web-api detected \u2014 Chat SDK provides a unified multi-platform API with cards, streaming, and state management. Loading Chat SDK guidance."
},
"discord.js": {
skill: "chat-sdk",
message: "discord.js detected \u2014 Chat SDK provides a unified multi-platform API (Slack, Teams, Discord, Telegram) with a single codebase. Loading Chat SDK guidance."
},
telegraf: {
skill: "chat-sdk",
message: "Telegraf detected \u2014 Chat SDK provides a unified multi-platform API (Slack, Teams, Discord, Telegram) with a single codebase. Loading Chat SDK guidance."
},
grammy: {
skill: "chat-sdk",
message: "Grammy detected \u2014 Chat SDK provides a unified multi-platform API (Slack, Teams, Discord, Telegram) with a single codebase. Loading Chat SDK guidance."
},
// Email
resend: {
skill: "email",
message: "Resend detected \u2014 loading Vercel Marketplace Resend integration guidance for transactional emails and React Email templates."
},
// Workflow-related
workflow: {
skill: "workflow",
message: "Workflow DevKit installed \u2014 loading WDK guidance for durable workflows."
},
// AI SDK
ai: {
skill: "ai-sdk",
message: "AI SDK installed \u2014 loading AI SDK v6 guidance."
},
"@ai-sdk/react": {
skill: "ai-sdk",
message: "@ai-sdk/react installed \u2014 loading AI SDK v6 guidance for React hooks."
},
// Feature flags
"@vercel/flags": {
skill: "vercel-flags",
message: "Vercel Flags installed \u2014 loading feature flags guidance."
},
// SWR
swr: {
skill: "swr",
message: "SWR installed \u2014 loading data-fetching guidance."
},
// Security / middleware
helmet: {
skill: "vercel-firewall",
message: "Helmet detected \u2014 Vercel Firewall provides DDoS protection, WAF rules, and security headers at the edge. Loading Firewall guidance."
},
cors: {
skill: "routing-middleware",
message: "cors detected \u2014 Vercel Routing Middleware handles CORS at the platform level with rewrites and headers. Loading Routing Middleware guidance."
},
// Env management
dotenv: {
skill: "env-vars",
message: "dotenv detected \u2014 Vercel manages environment variables natively via `vercel env`. Loading env-vars guidance."
},
// Cron / scheduling → Vercel cron jobs
"node-cron": {
skill: "cron-jobs",
message: "node-cron detected \u2014 Vercel Cron Jobs provides managed scheduling via vercel.json. Loading cron guidance."
},
cron: {
skill: "cron-jobs",
message: "cron package detected \u2014 Vercel Cron Jobs provides managed scheduling via vercel.json. Loading cron guidance."
}
};
var log = createLogger();
var INSTALL_CMD_RE = /(?:npm\s+(?:install|i|add)|yarn\s+add|pnpm\s+(?:add|install)|bun\s+(?:add|install))\s+(.+)/;
function parseInstallCommand(command) {
if (!command || typeof command !== "string") return [];
const match = INSTALL_CMD_RE.exec(command);
if (!match) return [];
const pkgString = match[1];
const packages = [];
for (const token of pkgString.split(/\s+/)) {
if (!token) continue;
if (token.startsWith("-")) continue;
if (token.startsWith(".") || token.startsWith("/")) continue;
let pkgName = token;
if (pkgName.startsWith("@")) {
const slashIndex = pkgName.indexOf("/");
if (slashIndex > 0) {
const afterSlash = pkgName.slice(slashIndex + 1);
const versionAt = afterSlash.indexOf("@");
if (versionAt > 0) {
pkgName = pkgName.slice(0, slashIndex + 1 + versionAt);
}
}
} else {
const atIndex = pkgName.indexOf("@");
if (atIndex > 0) {
pkgName = pkgName.slice(0, atIndex);
}
}
if (pkgName) packages.push(pkgName);
}
return packages;
}
function resolveSessionId(input) {
const sessionId = input.session_id ?? input.conversation_id;
return typeof sessionId === "string" && sessionId.trim() !== "" ? sessionId : null;
}
function parseBashInput(raw, logger) {
const l = logger || log;
const trimmed = (raw || "").trim();
if (!trimmed) return null;
let input;
try {
input = JSON.parse(trimmed);
} catch {
return null;
}
const toolName = input.tool_name || "";
if (toolName !== "Bash") {
l.debug("posttooluse-bash-chain-skip", { reason: "not_bash_tool", toolName });
return null;
}
const toolInput = input.tool_input || {};
const command = toolInput.command || "";
if (!command) {
l.debug("posttooluse-bash-chain-skip", { reason: "no_command" });
return null;
}
const sessionId = resolveSessionId(input);
const platform = detectPlatform(input);
return { command, sessionId, platform };
}
function runBashChainInjection(packages, sessionId, pluginRoot, logger, env = process.env) {
const l = logger || log;
const result = { injected: [], totalBytes: 0 };
if (packages.length === 0) return result;
const chainCap = Math.max(
1,
parseInt(env.VERCEL_PLUGIN_CHAIN_CAP || "", 10) || DEFAULT_CHAIN_CAP
);
const fileSeen = sessionId ? readSessionFile(sessionId, "seen-skills") : "";
const seenSet = new Set(fileSeen.split(",").filter(Boolean));
const targetsSeen = /* @__PURE__ */ new Set();
for (const pkg of packages) {
const mapping = PACKAGE_SKILL_MAP[pkg];
if (!mapping) continue;
const { skill, message } = mapping;
if (targetsSeen.has(skill)) continue;
targetsSeen.add(skill);
if (result.injected.length >= chainCap) {
l.debug("posttooluse-bash-chain-cap-reached", {
cap: chainCap,
remaining: packages.length - result.injected.length
});
break;
}
if (seenSet.has(skill)) {
l.debug("posttooluse-bash-chain-skip-dedup", { pkg, skill });
continue;
}
const skillPath = join(pluginRoot, "skills", skill, "SKILL.md");
const skillContent = safeReadFile(skillPath);
if (!skillContent) {
l.debug("posttooluse-bash-chain-skip-missing", { pkg, skill, path: skillPath });
continue;
}
const { body } = extractFrontmatter(skillContent);
const trimmedBody = body.trim();
if (!trimmedBody) continue;
const bytes = Buffer.byteLength(trimmedBody, "utf-8");
if (result.totalBytes + bytes > CHAIN_BUDGET_BYTES) {
l.debug("posttooluse-bash-chain-budget-exceeded", {
pkg,
skill,
bytes,
totalBytes: result.totalBytes,
budget: CHAIN_BUDGET_BYTES
});
break;
}
if (sessionId) {
const claimed = tryClaimSessionKey(sessionId, "seen-skills", skill);
if (!claimed) {
l.debug("posttooluse-bash-chain-skip-concurrent-claim", { pkg, skill });
seenSet.add(skill);
continue;
}
syncSessionFileFromClaims(sessionId, "seen-skills");
}
seenSet.add(skill);
result.injected.push({ packageName: pkg, skill, message, content: trimmedBody });
result.totalBytes += bytes;
l.debug("posttooluse-bash-chain-injected", { pkg, skill, bytes, totalBytes: result.totalBytes });
}
if (result.injected.length > 0) {
l.summary("posttooluse-bash-chain-result", {
injectedCount: result.injected.length,
totalBytes: result.totalBytes,
targets: result.injected.map((i) => i.skill)
});
}
return result;
}
function formatPlatformOutput(platform, additionalContext) {
if (platform === "cursor") {
return JSON.stringify({ additional_context: additionalContext });
}
const output = {
hookSpecificOutput: {
hookEventName: "PostToolUse",
additionalContext
}
};
return JSON.stringify(output);
}
function formatBashChainOutput(chainResult, platform = "claude-code") {
if (chainResult.injected.length === 0) return "{}";
const parts = [];
for (const chain of chainResult.injected) {
parts.push(
`<!-- posttooluse-bash-chain: ${chain.packageName} \u2192 ${chain.skill} -->`,
`**Skill context auto-loaded** (${chain.skill}): ${chain.message}`,
"",
chain.content,
`<!-- /posttooluse-bash-chain: ${chain.skill} -->`
);
}
const metadata = {
version: 1,
hook: "posttooluse-bash-chain",
packages: chainResult.injected.map((i) => i.packageName),
chainedSkills: chainResult.injected.map((i) => i.skill)
};
parts.push(`<!-- postBashChain: ${JSON.stringify(metadata)} -->`);
return formatPlatformOutput(platform, parts.join("\n"));
}
function run() {
const tStart = log.active ? log.now() : 0;
let raw;
try {
raw = readFileSync(0, "utf-8");
} catch {
return "{}";
}
const parsed = parseBashInput(raw, log);
if (!parsed) return "{}";
const { command, sessionId, platform } = parsed;
const packages = parseInstallCommand(command);
if (packages.length === 0) {
log.debug("posttooluse-bash-chain-skip", { reason: "no_packages_detected", command });
return "{}";
}
log.debug("posttooluse-bash-chain-packages", { packages, command });
const chainResult = runBashChainInjection(packages, sessionId, PLUGIN_ROOT, log);
const output = formatBashChainOutput(chainResult, platform);
log.complete("posttooluse-bash-chain-done", {
matchedCount: packages.length,
injectedCount: chainResult.injected.length,
dedupedCount: 0,
cappedCount: 0
}, log.active ? { total: Math.round(log.now() - tStart) } : {});
return output;
}
function isMainModule() {
try {
const scriptPath = realpathSync(resolve(process.argv[1] || ""));
const modulePath = realpathSync(fileURLToPath(import.meta.url));
return scriptPath === modulePath;
} catch {
return false;
}
}
if (isMainModule()) {
try {
const output = run();
process.stdout.write(output);
} catch (err) {
const entry = [
`[${(/* @__PURE__ */ new Date()).toISOString()}] CRASH in posttooluse-bash-chain.mts`,
` error: ${err?.message || String(err)}`,
` stack: ${err?.stack || "(no stack)"}`,
` PLUGIN_ROOT: ${PLUGIN_ROOT}`,
""
].join("\n");
process.stderr.write(entry);
process.stdout.write("{}");
}
}
export {
PACKAGE_SKILL_MAP,
formatBashChainOutput,
parseBashInput,
parseInstallCommand,
run,
runBashChainInjection
};