mcpill 1.8.0 → 1.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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.0
4
+
5
+ - `blocking: true` field on `PILL.md` `## Hook:` blocks; `mcpill compile` generates a `.mjs` `PreToolUse` gate script (exits 1 until a required tool has run) and a `PostToolUse` flag script
6
+ - `required_tool`, `gate_window_seconds` (default 120 s), and multiline `instructions` fields supported on blocking hooks
7
+ - Compile-time validation: `required_tool` must name a known tool; error surfaced during `mcpill compile`
8
+
3
9
  ## 1.8.0
4
10
 
5
11
  - `mcpill run` (stdio) now auto-registers the server in Claude config on start and auto-unregisters on SIGINT, SIGTERM, and process exit — no manual step required
package/README.md CHANGED
@@ -78,6 +78,25 @@ Compile also writes a `PreToolUse` hook into `.claude/settings.json` that warns
78
78
 
79
79
  - **`AGENT.md`** — a `## Tools` table with a `Replaces` column (existing format). AGENT.md wins on name conflicts.
80
80
 
81
+ #### Blocking hooks
82
+
83
+ For mandatory workflow steps, add a `## Hook:` section to `PILL.md` with `blocking: true`. Compile generates a hard-enforcement gate instead of a soft reminder:
84
+
85
+ ```markdown
86
+ ## Hook: my-hook
87
+
88
+ trigger: PreToolUse
89
+ blocking: true
90
+ required_tool: analyze-english
91
+ gate_window_seconds: 120
92
+ instructions: |
93
+ Before calling any other tool, call mcp__my-pill__analyze-english first.
94
+ ```
95
+
96
+ At runtime the gate script exits 1 (blocking the tool call) until `mcp__{pill}__analyze-english` has run within the window. After it runs, the flag script records the timestamp and the gate passes for `gate_window_seconds` seconds (default 120). Pill's own MCP tools are always allowed through. The gate is fail-open — if the pill's state file is missing, the gate passes.
97
+
98
+ `required_tool` must name a tool defined in the same pill; `mcpill compile` aborts with an error if it doesn't.
99
+
81
100
  ### `mcpill validate`
82
101
 
83
102
  Validates all pill directories (`.<name>/`) in the project root.
package/dist/cli.js CHANGED
@@ -756,12 +756,45 @@ function extractFencedBlock(body, lang) {
756
756
  }
757
757
  function parseKvBlock(body) {
758
758
  const result = {};
759
- for (const line of body.split("\n")) {
759
+ const lines = body.split("\n");
760
+ let i = 0;
761
+ while (i < lines.length) {
762
+ const line = lines[i];
760
763
  const colonIdx = line.indexOf(":");
761
- if (colonIdx === -1) continue;
764
+ if (colonIdx === -1) {
765
+ i++;
766
+ continue;
767
+ }
762
768
  const key = line.slice(0, colonIdx).trim();
763
769
  const value = line.slice(colonIdx + 1).trim();
764
- if (key) result[key] = value;
770
+ if (!key) {
771
+ i++;
772
+ continue;
773
+ }
774
+ if (value === "|") {
775
+ i++;
776
+ const blockLines = [];
777
+ let baseIndent = -1;
778
+ while (i < lines.length) {
779
+ const nextLine = lines[i];
780
+ const trimmed = nextLine.trimStart();
781
+ if (trimmed === "") {
782
+ blockLines.push("");
783
+ i++;
784
+ continue;
785
+ }
786
+ const indent = nextLine.length - trimmed.length;
787
+ if (baseIndent === -1) baseIndent = indent;
788
+ if (indent < baseIndent) break;
789
+ blockLines.push(nextLine.slice(baseIndent));
790
+ i++;
791
+ }
792
+ while (blockLines.length > 0 && blockLines[blockLines.length - 1] === "") blockLines.pop();
793
+ result[key] = blockLines.join("\n");
794
+ } else {
795
+ result[key] = value;
796
+ i++;
797
+ }
765
798
  }
766
799
  return result;
767
800
  }
@@ -976,13 +1009,31 @@ function parsePillMdHooks(content) {
976
1009
  for (const piece of pieces) {
977
1010
  if (!piece.toLowerCase().startsWith("hook:")) continue;
978
1011
  const nlIdx = piece.indexOf("\n");
1012
+ const sectionTitle = (nlIdx === -1 ? piece : piece.slice(0, nlIdx)).trim();
1013
+ const hookName = sectionTitle.slice("hook:".length).trim();
979
1014
  const body = nlIdx === -1 ? "" : piece.slice(nlIdx + 1);
980
1015
  const kv = parseKvBlock(body);
981
1016
  const trigger = kv["trigger"]?.trim();
982
1017
  const matcher = kv["matcher"]?.trim();
983
1018
  const command = kv["command"]?.trim();
984
- if (!trigger || !command) continue;
985
- hooks.push({ event: trigger, ...matcher ? { matcher } : {}, command });
1019
+ const blocking = kv["blocking"]?.trim().toLowerCase() === "true";
1020
+ if (!trigger || !command && !blocking) continue;
1021
+ const entry = {
1022
+ ...hookName ? { name: hookName } : {},
1023
+ event: trigger,
1024
+ ...matcher ? { matcher } : {},
1025
+ ...command ? { command } : {},
1026
+ ...blocking ? { blocking: true } : {}
1027
+ };
1028
+ if (blocking) {
1029
+ const requiredTool = kv["required_tool"]?.trim();
1030
+ if (requiredTool) entry.required_tool = requiredTool;
1031
+ const windowRaw = kv["gate_window_seconds"]?.trim();
1032
+ if (windowRaw) entry.gate_window_seconds = parseInt(windowRaw, 10);
1033
+ const instructions = kv["instructions"]?.trim();
1034
+ if (instructions) entry.instructions = instructions;
1035
+ }
1036
+ hooks.push(entry);
986
1037
  }
987
1038
  return hooks;
988
1039
  }
@@ -992,8 +1043,23 @@ function parseHookFile(content) {
992
1043
  const trigger = kv["trigger"]?.trim();
993
1044
  const matcher = kv["matcher"]?.trim();
994
1045
  const command = kv["command"]?.trim();
995
- if (!trigger || !command) return null;
996
- return { event: trigger, ...matcher ? { matcher } : {}, command };
1046
+ const blocking = kv["blocking"]?.trim().toLowerCase() === "true";
1047
+ if (!trigger || !command && !blocking) return null;
1048
+ const entry = {
1049
+ event: trigger,
1050
+ ...matcher ? { matcher } : {},
1051
+ ...command ? { command } : {},
1052
+ ...blocking ? { blocking: true } : {}
1053
+ };
1054
+ if (blocking) {
1055
+ const requiredTool = kv["required_tool"]?.trim();
1056
+ if (requiredTool) entry.required_tool = requiredTool;
1057
+ const windowRaw = kv["gate_window_seconds"]?.trim();
1058
+ if (windowRaw) entry.gate_window_seconds = parseInt(windowRaw, 10);
1059
+ const instructions = kv["instructions"]?.trim();
1060
+ if (instructions) entry.instructions = instructions;
1061
+ }
1062
+ return entry;
997
1063
  }
998
1064
  function mergeUserHooksIntoSettings(baseDir, hookBlocks) {
999
1065
  if (hookBlocks.length === 0) return;
@@ -1040,6 +1106,91 @@ function mergeHookIntoSettings(baseDir, hookEntry) {
1040
1106
  mkdirSync(claudeDir, { recursive: true });
1041
1107
  writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
1042
1108
  }
1109
+ function generateGateScript(pillName, requiredTool, windowSeconds, instructions) {
1110
+ const instrJson = JSON.stringify(instructions);
1111
+ return [
1112
+ `import { readFileSync, existsSync } from 'fs';`,
1113
+ `import { homedir } from 'os';`,
1114
+ `import { join } from 'path';`,
1115
+ ``,
1116
+ `const PILL_NAME = ${JSON.stringify(pillName)};`,
1117
+ `const REQUIRED_TOOL = ${JSON.stringify(requiredTool)};`,
1118
+ `const GATE_WINDOW_SECONDS = ${windowSeconds};`,
1119
+ `const INSTRUCTIONS = ${instrJson};`,
1120
+ ``,
1121
+ `const raw = readFileSync(0, 'utf-8');`,
1122
+ `const input = JSON.parse(raw);`,
1123
+ `const toolName = input.tool_name ?? '';`,
1124
+ ``,
1125
+ `if (toolName.startsWith(\`mcp__\${PILL_NAME}__\`)) process.exit(0);`,
1126
+ ``,
1127
+ `const stateFile = join(homedir(), \`.\${PILL_NAME}\`, 'state.json');`,
1128
+ `if (!existsSync(stateFile)) process.exit(0);`,
1129
+ ``,
1130
+ `const state = JSON.parse(readFileSync(stateFile, 'utf-8'));`,
1131
+ `if (state.enabled === false) process.exit(0);`,
1132
+ ``,
1133
+ `const flagFile = join(homedir(), \`.\${PILL_NAME}\`, \`last_\${REQUIRED_TOOL}_run.json\`);`,
1134
+ `if (existsSync(flagFile)) {`,
1135
+ ` const flag = JSON.parse(readFileSync(flagFile, 'utf-8'));`,
1136
+ ` const elapsedMs = Date.now() - new Date(flag.timestamp).getTime();`,
1137
+ ` if (elapsedMs <= GATE_WINDOW_SECONDS * 1000) process.exit(0);`,
1138
+ `}`,
1139
+ ``,
1140
+ `process.stderr.write(INSTRUCTIONS + '\\n');`,
1141
+ `process.exit(1);`,
1142
+ ``
1143
+ ].join("\n");
1144
+ }
1145
+ function generateFlagScript(pillName, requiredTool) {
1146
+ return [
1147
+ `import { writeFileSync, mkdirSync } from 'fs';`,
1148
+ `import { homedir } from 'os';`,
1149
+ `import { join } from 'path';`,
1150
+ ``,
1151
+ `const PILL_NAME = ${JSON.stringify(pillName)};`,
1152
+ `const REQUIRED_TOOL = ${JSON.stringify(requiredTool)};`,
1153
+ ``,
1154
+ `const dir = join(homedir(), \`.\${PILL_NAME}\`);`,
1155
+ `const flagFile = join(dir, \`last_\${REQUIRED_TOOL}_run.json\`);`,
1156
+ ``,
1157
+ `try {`,
1158
+ ` mkdirSync(dir, { recursive: true });`,
1159
+ ` writeFileSync(flagFile, JSON.stringify({ timestamp: new Date().toISOString() }));`,
1160
+ `} catch (e) {`,
1161
+ ` process.stderr.write(\`[\${PILL_NAME}] warning: could not write flag file: \${e.message}\\n\`);`,
1162
+ `}`,
1163
+ ``
1164
+ ].join("\n");
1165
+ }
1166
+ function applyBlockingHookScripts(baseDir, pillName, hook) {
1167
+ const hooksDir = join3(baseDir, ".mcpill", "server", "hooks");
1168
+ mkdirSync(hooksDir, { recursive: true });
1169
+ const hookName = hook.name;
1170
+ const requiredTool = hook.required_tool;
1171
+ const windowSeconds = hook.gate_window_seconds ?? 120;
1172
+ const instructions = hook.instructions ?? `[mcpill:${pillName}] Run mcp__${pillName}__${requiredTool} before calling other tools.`;
1173
+ writeFileSync(
1174
+ join3(hooksDir, `${hookName}-gate.mjs`),
1175
+ generateGateScript(pillName, requiredTool, windowSeconds, instructions)
1176
+ );
1177
+ writeFileSync(
1178
+ join3(hooksDir, `${hookName}-flag.mjs`),
1179
+ generateFlagScript(pillName, requiredTool)
1180
+ );
1181
+ return [
1182
+ {
1183
+ event: "PreToolUse",
1184
+ matcher: ".*",
1185
+ command: `node .mcpill/server/hooks/${hookName}-gate.mjs`
1186
+ },
1187
+ {
1188
+ event: "PostToolUse",
1189
+ matcher: `mcp__${pillName}__${requiredTool}`,
1190
+ command: `node .mcpill/server/hooks/${hookName}-flag.mjs`
1191
+ }
1192
+ ];
1193
+ }
1043
1194
 
1044
1195
  // src/commands/validate.ts
1045
1196
  async function validateOne(mcpillDir) {
@@ -1549,9 +1700,26 @@ async function runCompile(opts) {
1549
1700
  }
1550
1701
  }
1551
1702
  const allUserHooks = [...pillHooks, ...fileHooks];
1552
- if (allUserHooks.length > 0) {
1553
- mergeUserHooksIntoSettings(baseDir, allUserHooks);
1554
- console.log(`\u2713 .claude/settings.json updated with ${allUserHooks.length} user-defined hook(s)`);
1703
+ const blockingHooks = allUserHooks.filter((h) => h.blocking);
1704
+ const regularHooks = allUserHooks.filter((h) => !h.blocking);
1705
+ for (const hook of blockingHooks) {
1706
+ if (!hook.name) throw new Error(`blocking hook is missing a name`);
1707
+ if (!hook.required_tool) throw new Error(`blocking hook '${hook.name}' is missing required_tool`);
1708
+ const toolExists = mergedDoc.tools.some((t) => t.name === hook.required_tool);
1709
+ if (!toolExists) {
1710
+ throw new Error(`blocking hook '${hook.name}' references unknown tool '${hook.required_tool}'`);
1711
+ }
1712
+ }
1713
+ if (regularHooks.length > 0) {
1714
+ mergeUserHooksIntoSettings(baseDir, regularHooks);
1715
+ console.log(`\u2713 .claude/settings.json updated with ${regularHooks.length} user-defined hook(s)`);
1716
+ }
1717
+ if (blockingHooks.length > 0) {
1718
+ const generatedEntries = blockingHooks.flatMap(
1719
+ (hook) => applyBlockingHookScripts(baseDir, mergedDoc.config.name, hook)
1720
+ );
1721
+ mergeUserHooksIntoSettings(baseDir, generatedEntries);
1722
+ console.log(`\u2713 .claude/settings.json updated with ${blockingHooks.length} blocking hook(s)`);
1555
1723
  }
1556
1724
  }
1557
1725
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcpill",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "type": "module",
5
5
  "description": "CLI for building, validating, and publishing MCP servers using the pill format.",
6
6
  "homepage": "https://mcpill.ruco.dev",