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 +6 -0
- package/README.md +19 -0
- package/dist/cli.js +178 -10
- package/package.json +1 -1
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
|
-
|
|
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)
|
|
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)
|
|
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
|
-
|
|
985
|
-
|
|
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
|
-
|
|
996
|
-
|
|
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
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
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
|
|