oh-my-customcode 1.1.52 → 1.1.54
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/dist/cli/index.js +509 -5
- package/dist/index.js +505 -1
- package/package.json +1 -1
- package/templates/.claude/hooks/hooks.json +24 -34
- package/templates/.claude/hooks/scripts/fail-axis-cause-advisor.sh +38 -3
- package/templates/.claude/hooks/scripts/r007-r008-drift-advisor.sh +20 -2
- package/templates/.claude/hooks/scripts/stuck-detector.sh +117 -6
- package/templates/.claude/rules/MAY-optimization.md +2 -0
- package/templates/.claude/rules/MUST-completion-verification.md +2 -0
- package/templates/.claude/rules/MUST-enforcement-policy.md +8 -2
- package/templates/.claude/settings.json +650 -0
- package/templates/manifest.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -243,7 +243,7 @@ var init_package = __esm(() => {
|
|
|
243
243
|
workspaces: [
|
|
244
244
|
"packages/*"
|
|
245
245
|
],
|
|
246
|
-
version: "1.1.
|
|
246
|
+
version: "1.1.54",
|
|
247
247
|
description: "Batteries-included agent harness for Claude Code",
|
|
248
248
|
type: "module",
|
|
249
249
|
bin: {
|
|
@@ -7634,7 +7634,7 @@ async function _scanDirForLockfiles(dir2, depth, seen, results, home, currentVer
|
|
|
7634
7634
|
}
|
|
7635
7635
|
}
|
|
7636
7636
|
async function _findProjectsFromLockfiles(options, currentVersion) {
|
|
7637
|
-
const { dirname:
|
|
7637
|
+
const { dirname: dirname4 } = await import("node:path");
|
|
7638
7638
|
const fs3 = await import("node:fs/promises");
|
|
7639
7639
|
const seen = new Set;
|
|
7640
7640
|
const results = [];
|
|
@@ -7644,7 +7644,7 @@ async function _findProjectsFromLockfiles(options, currentVersion) {
|
|
|
7644
7644
|
const cwd = process.cwd();
|
|
7645
7645
|
if (!searchPaths.includes(cwd))
|
|
7646
7646
|
searchPaths.push(cwd);
|
|
7647
|
-
const parent =
|
|
7647
|
+
const parent = dirname4(cwd);
|
|
7648
7648
|
if (parent !== cwd && !searchPaths.includes(parent))
|
|
7649
7649
|
searchPaths.push(parent);
|
|
7650
7650
|
}
|
|
@@ -27949,6 +27949,489 @@ function getDefaultWorkflow() {
|
|
|
27949
27949
|
};
|
|
27950
27950
|
}
|
|
27951
27951
|
|
|
27952
|
+
// src/core/hooks-settings.ts
|
|
27953
|
+
init_fs();
|
|
27954
|
+
import { dirname as dirname3 } from "node:path";
|
|
27955
|
+
var IDENT_CHAR = /[A-Za-z0-9_.]/;
|
|
27956
|
+
var IDENT_START = /[A-Za-z_]/;
|
|
27957
|
+
function consumeSingleCharOp(input, i) {
|
|
27958
|
+
const ch = input[i];
|
|
27959
|
+
if (ch === "(" || ch === ")" || ch === "!") {
|
|
27960
|
+
return { token: { type: "OP", value: ch }, next: i + 1 };
|
|
27961
|
+
}
|
|
27962
|
+
return null;
|
|
27963
|
+
}
|
|
27964
|
+
function consumeTwoCharOp(input, i) {
|
|
27965
|
+
const two = input.slice(i, i + 2);
|
|
27966
|
+
if (two === "&&" || two === "||" || two === "==") {
|
|
27967
|
+
return { token: { type: "OP", value: two }, next: i + 2 };
|
|
27968
|
+
}
|
|
27969
|
+
return null;
|
|
27970
|
+
}
|
|
27971
|
+
function consumeStringLiteral(input, i) {
|
|
27972
|
+
const n = input.length;
|
|
27973
|
+
let j = i + 1;
|
|
27974
|
+
let value = "";
|
|
27975
|
+
while (j < n && input[j] !== '"') {
|
|
27976
|
+
if (input[j] === "\\" && j + 1 < n) {
|
|
27977
|
+
value += input[j + 1];
|
|
27978
|
+
j += 2;
|
|
27979
|
+
} else {
|
|
27980
|
+
value += input[j];
|
|
27981
|
+
j++;
|
|
27982
|
+
}
|
|
27983
|
+
}
|
|
27984
|
+
if (j >= n) {
|
|
27985
|
+
throw new Error(`unterminated string literal in matcher expression: ${input}`);
|
|
27986
|
+
}
|
|
27987
|
+
return { token: { type: "STRING", value }, next: j + 1 };
|
|
27988
|
+
}
|
|
27989
|
+
function consumeIdentifierOrKeyword(input, i) {
|
|
27990
|
+
const n = input.length;
|
|
27991
|
+
let j = i;
|
|
27992
|
+
while (j < n && IDENT_CHAR.test(input[j]))
|
|
27993
|
+
j++;
|
|
27994
|
+
const word = input.slice(i, j);
|
|
27995
|
+
const token = word === "matches" ? { type: "OP", value: "matches" } : { type: "IDENT", value: word };
|
|
27996
|
+
return { token, next: j };
|
|
27997
|
+
}
|
|
27998
|
+
function tokenizeMatcher(input) {
|
|
27999
|
+
const tokens = [];
|
|
28000
|
+
const n = input.length;
|
|
28001
|
+
let i = 0;
|
|
28002
|
+
while (i < n) {
|
|
28003
|
+
const ch = input[i];
|
|
28004
|
+
if (ch === " " || ch === "\t" || ch === `
|
|
28005
|
+
` || ch === "\r") {
|
|
28006
|
+
i++;
|
|
28007
|
+
continue;
|
|
28008
|
+
}
|
|
28009
|
+
const twoCharOp = consumeTwoCharOp(input, i);
|
|
28010
|
+
if (twoCharOp) {
|
|
28011
|
+
tokens.push(twoCharOp.token);
|
|
28012
|
+
i = twoCharOp.next;
|
|
28013
|
+
continue;
|
|
28014
|
+
}
|
|
28015
|
+
const singleCharOp = consumeSingleCharOp(input, i);
|
|
28016
|
+
if (singleCharOp) {
|
|
28017
|
+
tokens.push(singleCharOp.token);
|
|
28018
|
+
i = singleCharOp.next;
|
|
28019
|
+
continue;
|
|
28020
|
+
}
|
|
28021
|
+
if (ch === '"') {
|
|
28022
|
+
const literal = consumeStringLiteral(input, i);
|
|
28023
|
+
tokens.push(literal.token);
|
|
28024
|
+
i = literal.next;
|
|
28025
|
+
continue;
|
|
28026
|
+
}
|
|
28027
|
+
if (IDENT_START.test(ch)) {
|
|
28028
|
+
const ident = consumeIdentifierOrKeyword(input, i);
|
|
28029
|
+
tokens.push(ident.token);
|
|
28030
|
+
i = ident.next;
|
|
28031
|
+
continue;
|
|
28032
|
+
}
|
|
28033
|
+
throw new Error(`unexpected character '${ch}' at offset ${i} in matcher expression: ${input}`);
|
|
28034
|
+
}
|
|
28035
|
+
return tokens;
|
|
28036
|
+
}
|
|
28037
|
+
|
|
28038
|
+
class MatcherParser {
|
|
28039
|
+
tokens;
|
|
28040
|
+
pos = 0;
|
|
28041
|
+
constructor(tokens) {
|
|
28042
|
+
this.tokens = tokens;
|
|
28043
|
+
}
|
|
28044
|
+
parse() {
|
|
28045
|
+
const node = this.parseOr();
|
|
28046
|
+
if (this.pos !== this.tokens.length) {
|
|
28047
|
+
throw new Error(`trailing tokens after position ${this.pos} in matcher expression`);
|
|
28048
|
+
}
|
|
28049
|
+
return node;
|
|
28050
|
+
}
|
|
28051
|
+
peek() {
|
|
28052
|
+
return this.tokens[this.pos];
|
|
28053
|
+
}
|
|
28054
|
+
next() {
|
|
28055
|
+
const t2 = this.tokens[this.pos];
|
|
28056
|
+
if (!t2)
|
|
28057
|
+
throw new Error("unexpected end of matcher expression");
|
|
28058
|
+
this.pos++;
|
|
28059
|
+
return t2;
|
|
28060
|
+
}
|
|
28061
|
+
expectOp(value) {
|
|
28062
|
+
const t2 = this.next();
|
|
28063
|
+
if (t2.type !== "OP" || t2.value !== value) {
|
|
28064
|
+
throw new Error(`expected '${value}', got ${JSON.stringify(t2)}`);
|
|
28065
|
+
}
|
|
28066
|
+
}
|
|
28067
|
+
parseOr() {
|
|
28068
|
+
let left = this.parseAnd();
|
|
28069
|
+
while (this.peek()?.type === "OP" && this.peek()?.value === "||") {
|
|
28070
|
+
this.next();
|
|
28071
|
+
const right = this.parseAnd();
|
|
28072
|
+
left = { kind: "or", left, right };
|
|
28073
|
+
}
|
|
28074
|
+
return left;
|
|
28075
|
+
}
|
|
28076
|
+
parseAnd() {
|
|
28077
|
+
let left = this.parseUnary();
|
|
28078
|
+
while (this.peek()?.type === "OP" && this.peek()?.value === "&&") {
|
|
28079
|
+
this.next();
|
|
28080
|
+
const right = this.parseUnary();
|
|
28081
|
+
left = { kind: "and", left, right };
|
|
28082
|
+
}
|
|
28083
|
+
return left;
|
|
28084
|
+
}
|
|
28085
|
+
parseUnary() {
|
|
28086
|
+
if (this.peek()?.type === "OP" && this.peek()?.value === "!") {
|
|
28087
|
+
this.next();
|
|
28088
|
+
this.expectOp("(");
|
|
28089
|
+
const inner = this.parseOr();
|
|
28090
|
+
this.expectOp(")");
|
|
28091
|
+
return { kind: "not", child: inner };
|
|
28092
|
+
}
|
|
28093
|
+
return this.parseAtom();
|
|
28094
|
+
}
|
|
28095
|
+
parseAtom() {
|
|
28096
|
+
if (this.peek()?.type === "OP" && this.peek()?.value === "(") {
|
|
28097
|
+
this.next();
|
|
28098
|
+
const inner = this.parseOr();
|
|
28099
|
+
this.expectOp(")");
|
|
28100
|
+
return inner;
|
|
28101
|
+
}
|
|
28102
|
+
const identTok = this.next();
|
|
28103
|
+
if (identTok.type !== "IDENT") {
|
|
28104
|
+
throw new Error(`expected identifier, got ${JSON.stringify(identTok)}`);
|
|
28105
|
+
}
|
|
28106
|
+
const opTok = this.next();
|
|
28107
|
+
if (opTok.type !== "OP" || opTok.value !== "==" && opTok.value !== "matches") {
|
|
28108
|
+
throw new Error(`expected '==' or 'matches' after identifier '${identTok.value}'`);
|
|
28109
|
+
}
|
|
28110
|
+
const strTok = this.next();
|
|
28111
|
+
if (strTok.type !== "STRING") {
|
|
28112
|
+
throw new Error(`expected string literal after '${opTok.value}'`);
|
|
28113
|
+
}
|
|
28114
|
+
if (opTok.value === "==") {
|
|
28115
|
+
if (identTok.value !== "tool") {
|
|
28116
|
+
throw new Error(`unsupported '==' comparison on field '${identTok.value}' (only 'tool' is supported)`);
|
|
28117
|
+
}
|
|
28118
|
+
return { kind: "toolEq", tool: strTok.value };
|
|
28119
|
+
}
|
|
28120
|
+
return { kind: "fieldMatch", field: identTok.value, pattern: strTok.value };
|
|
28121
|
+
}
|
|
28122
|
+
}
|
|
28123
|
+
function parseMatcherExpr(raw) {
|
|
28124
|
+
const tokens = tokenizeMatcher(raw);
|
|
28125
|
+
if (tokens.length === 0) {
|
|
28126
|
+
throw new Error("empty matcher expression");
|
|
28127
|
+
}
|
|
28128
|
+
return new MatcherParser(tokens).parse();
|
|
28129
|
+
}
|
|
28130
|
+
function isToolOnly(node) {
|
|
28131
|
+
switch (node.kind) {
|
|
28132
|
+
case "toolEq":
|
|
28133
|
+
return true;
|
|
28134
|
+
case "and":
|
|
28135
|
+
case "or":
|
|
28136
|
+
return isToolOnly(node.left) && isToolOnly(node.right);
|
|
28137
|
+
case "not":
|
|
28138
|
+
return isToolOnly(node.child);
|
|
28139
|
+
case "fieldMatch":
|
|
28140
|
+
return false;
|
|
28141
|
+
}
|
|
28142
|
+
}
|
|
28143
|
+
function collectToolNames(node) {
|
|
28144
|
+
switch (node.kind) {
|
|
28145
|
+
case "toolEq":
|
|
28146
|
+
return [node.tool];
|
|
28147
|
+
case "and":
|
|
28148
|
+
case "or":
|
|
28149
|
+
return [...collectToolNames(node.left), ...collectToolNames(node.right)];
|
|
28150
|
+
default:
|
|
28151
|
+
return [];
|
|
28152
|
+
}
|
|
28153
|
+
}
|
|
28154
|
+
function flattenAnd(node) {
|
|
28155
|
+
if (node.kind === "and") {
|
|
28156
|
+
return [...flattenAnd(node.left), ...flattenAnd(node.right)];
|
|
28157
|
+
}
|
|
28158
|
+
return [node];
|
|
28159
|
+
}
|
|
28160
|
+
function tryPureOrFieldRegex(node, field) {
|
|
28161
|
+
if (node.kind === "fieldMatch") {
|
|
28162
|
+
return node.field === field ? node.pattern : null;
|
|
28163
|
+
}
|
|
28164
|
+
if (node.kind === "or") {
|
|
28165
|
+
const left = tryPureOrFieldRegex(node.left, field);
|
|
28166
|
+
const right = tryPureOrFieldRegex(node.right, field);
|
|
28167
|
+
if (left === null || right === null)
|
|
28168
|
+
return null;
|
|
28169
|
+
return `${left}|${right}`;
|
|
28170
|
+
}
|
|
28171
|
+
return null;
|
|
28172
|
+
}
|
|
28173
|
+
function fallbackExtractToolNames(raw) {
|
|
28174
|
+
const names = new Set;
|
|
28175
|
+
const re = /tool\s*==\s*"([^"]+)"/g;
|
|
28176
|
+
let m;
|
|
28177
|
+
while ((m = re.exec(raw)) !== null) {
|
|
28178
|
+
names.add(m[1]);
|
|
28179
|
+
}
|
|
28180
|
+
return Array.from(names);
|
|
28181
|
+
}
|
|
28182
|
+
function analyzeMixedAndExpr(ast, raw) {
|
|
28183
|
+
const parts = flattenAnd(ast);
|
|
28184
|
+
const toolParts = parts.filter(isToolOnly);
|
|
28185
|
+
const fieldParts = parts.filter((p) => !isToolOnly(p));
|
|
28186
|
+
if (toolParts.length === 0) {
|
|
28187
|
+
const names = fallbackExtractToolNames(raw);
|
|
28188
|
+
if (names.length === 0) {
|
|
28189
|
+
throw new Error(`cannot extract a tool matcher from expression (no top-level tool==X clause): ${raw}`);
|
|
28190
|
+
}
|
|
28191
|
+
return { toolRegex: names.join("|"), fieldNode: null };
|
|
28192
|
+
}
|
|
28193
|
+
const tools = Array.from(new Set(toolParts.flatMap(collectToolNames)));
|
|
28194
|
+
const toolRegex = tools.join("|") || "*";
|
|
28195
|
+
if (fieldParts.length === 0) {
|
|
28196
|
+
return { toolRegex, fieldNode: null };
|
|
28197
|
+
}
|
|
28198
|
+
const fieldNode = fieldParts.reduce((acc, part) => acc === null ? part : { kind: "and", left: acc, right: part }, null);
|
|
28199
|
+
return { toolRegex, fieldNode };
|
|
28200
|
+
}
|
|
28201
|
+
function analyzeMatcher(raw) {
|
|
28202
|
+
if (raw === undefined || raw === "*" || raw.trim() === "") {
|
|
28203
|
+
return { toolRegex: "*", fieldNode: null };
|
|
28204
|
+
}
|
|
28205
|
+
let ast;
|
|
28206
|
+
try {
|
|
28207
|
+
ast = parseMatcherExpr(raw);
|
|
28208
|
+
} catch (err) {
|
|
28209
|
+
const names = fallbackExtractToolNames(raw);
|
|
28210
|
+
if (names.length === 0) {
|
|
28211
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
28212
|
+
throw new Error(`unparseable matcher expression and no fallback tool name found: ${raw} (${message})`);
|
|
28213
|
+
}
|
|
28214
|
+
return { toolRegex: names.join("|"), fieldNode: null };
|
|
28215
|
+
}
|
|
28216
|
+
if (isToolOnly(ast)) {
|
|
28217
|
+
const tools = Array.from(new Set(collectToolNames(ast)));
|
|
28218
|
+
return { toolRegex: tools.join("|") || "*", fieldNode: null };
|
|
28219
|
+
}
|
|
28220
|
+
const mcpRegex = tryPureOrFieldRegex(ast, "mcp_tool_name");
|
|
28221
|
+
if (mcpRegex !== null) {
|
|
28222
|
+
return { toolRegex: mcpRegex, fieldNode: null };
|
|
28223
|
+
}
|
|
28224
|
+
return analyzeMixedAndExpr(ast, raw);
|
|
28225
|
+
}
|
|
28226
|
+
function fieldNodeToJq(node) {
|
|
28227
|
+
switch (node.kind) {
|
|
28228
|
+
case "and":
|
|
28229
|
+
return `(${fieldNodeToJq(node.left)} and ${fieldNodeToJq(node.right)})`;
|
|
28230
|
+
case "or":
|
|
28231
|
+
return `(${fieldNodeToJq(node.left)} or ${fieldNodeToJq(node.right)})`;
|
|
28232
|
+
case "not":
|
|
28233
|
+
return `(${fieldNodeToJq(node.child)} | not)`;
|
|
28234
|
+
case "fieldMatch": {
|
|
28235
|
+
const jqPath = node.field === "mcp_tool_name" ? ".tool_name" : `.${node.field}`;
|
|
28236
|
+
return `((${jqPath} // "") | test(${JSON.stringify(node.pattern)}))`;
|
|
28237
|
+
}
|
|
28238
|
+
case "toolEq":
|
|
28239
|
+
return "true";
|
|
28240
|
+
}
|
|
28241
|
+
}
|
|
28242
|
+
function shellSingleQuote(value) {
|
|
28243
|
+
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
28244
|
+
}
|
|
28245
|
+
function buildGuardedCommand(jqCond, originalCommand) {
|
|
28246
|
+
const quotedCond = shellSingleQuote(jqCond);
|
|
28247
|
+
return [
|
|
28248
|
+
"input=$(cat)",
|
|
28249
|
+
`if printf '%s' "$input" | jq -e ${quotedCond} >/dev/null 2>&1; then`,
|
|
28250
|
+
` printf '%s' "$input" | (`,
|
|
28251
|
+
originalCommand,
|
|
28252
|
+
" )",
|
|
28253
|
+
"else",
|
|
28254
|
+
` printf '%s' "$input"`,
|
|
28255
|
+
"fi"
|
|
28256
|
+
].join(`
|
|
28257
|
+
`);
|
|
28258
|
+
}
|
|
28259
|
+
var CLASSIFICATION_TABLE = [
|
|
28260
|
+
{
|
|
28261
|
+
event: "PreToolUse",
|
|
28262
|
+
description: "Block dev servers outside tmux - ensures you can access logs",
|
|
28263
|
+
decision: "wrap-guard",
|
|
28264
|
+
reason: 'inline script unconditionally `exit 1`-blocks on ANY match once invoked; it never re-parses tool_input.command, so widening the matcher to bare "Bash" would block every Bash call'
|
|
28265
|
+
},
|
|
28266
|
+
{
|
|
28267
|
+
event: "PreToolUse",
|
|
28268
|
+
description: "Reminder to use tmux for long-running commands",
|
|
28269
|
+
decision: "wrap-guard",
|
|
28270
|
+
reason: 'inline script only checks `[ -z "$TMUX" ]`; it never re-checks tool_input.command against the npm/pnpm/yarn/bun/cargo/docker/pytest/vitest/playwright pattern, so widening the matcher would nag on every Bash call'
|
|
28271
|
+
},
|
|
28272
|
+
{
|
|
28273
|
+
event: "PreToolUse",
|
|
28274
|
+
description: "Pause before git push to review changes",
|
|
28275
|
+
decision: "wrap-guard",
|
|
28276
|
+
reason: 'inline script unconditionally prints the pause prompt once invoked; it never re-checks tool_input.command for "git push", so widening the matcher would pause on every Bash call'
|
|
28277
|
+
},
|
|
28278
|
+
{
|
|
28279
|
+
event: "PreToolUse",
|
|
28280
|
+
description: "Block creation of random .md files - keeps docs consolidated",
|
|
28281
|
+
decision: "drop-condition",
|
|
28282
|
+
reason: "inline script re-parses tool_input.file_path via jq and re-tests both the `\\.(md|txt)$` and the README/CLAUDE/AGENT/SKILL exclusion with its own [[ =~ ]] checks before blocking — the matcher condition is fully redundant with the script body"
|
|
28283
|
+
},
|
|
28284
|
+
{
|
|
28285
|
+
event: "PreToolUse",
|
|
28286
|
+
description: "Warn before destructive git commands — advisory recovery guidance (R001/R021)",
|
|
28287
|
+
decision: "drop-condition",
|
|
28288
|
+
reason: "destructive-git-guard.sh re-parses tool_input.command via jq/python3 and re-greps for each destructive git subcommand (reset --hard, clean -f*, restore, checkout -- ., branch -D) itself before warning — matcher condition is redundant"
|
|
28289
|
+
},
|
|
28290
|
+
{
|
|
28291
|
+
event: "PreToolUse",
|
|
28292
|
+
description: "Block rule file deletion — requires individual user confirmation per rule (R001 safety)",
|
|
28293
|
+
decision: "drop-condition",
|
|
28294
|
+
reason: 'rule-deletion-guard.sh explicitly re-checks `tool != "Bash"` (passthrough) and re-greps tool_input.command for rm/git rm/mv/unlink AND a .claude/rules path before blocking — matcher condition is redundant'
|
|
28295
|
+
},
|
|
28296
|
+
{
|
|
28297
|
+
event: "PostToolUse",
|
|
28298
|
+
description: "Auto-format JS/TS files with Prettier after edits",
|
|
28299
|
+
decision: "wrap-guard",
|
|
28300
|
+
reason: 'inline script only checks file existence (`[ -f "$file_path" ]`); it never re-checks the .ts/.tsx/.js/.jsx extension, so widening the matcher to bare "Edit" would run prettier on every edited file regardless of type'
|
|
28301
|
+
},
|
|
28302
|
+
{
|
|
28303
|
+
event: "PostToolUse",
|
|
28304
|
+
description: "TypeScript check after editing .ts/.tsx files",
|
|
28305
|
+
decision: "wrap-guard",
|
|
28306
|
+
reason: "inline script never re-checks the .ts/.tsx extension before walking up for tsconfig.json and invoking tsc — widening the matcher would run a TS project check after editing non-TS files"
|
|
28307
|
+
},
|
|
28308
|
+
{
|
|
28309
|
+
event: "PostToolUse",
|
|
28310
|
+
description: "Warn about console.log statements after edits",
|
|
28311
|
+
decision: "wrap-guard",
|
|
28312
|
+
reason: "inline script greps the edited file for `console\\.log` unconditionally on file existence; it never re-checks the .ts/.tsx/.js/.jsx extension, so widening the matcher would run this grep on every edited file type"
|
|
28313
|
+
},
|
|
28314
|
+
{
|
|
28315
|
+
event: "PostToolUse",
|
|
28316
|
+
description: "Auto-format Go files with gofmt after edits",
|
|
28317
|
+
decision: "wrap-guard",
|
|
28318
|
+
reason: "inline script never re-checks the .go extension before invoking gofmt -w; running gofmt on a non-Go file would error/corrupt output"
|
|
28319
|
+
},
|
|
28320
|
+
{
|
|
28321
|
+
event: "PostToolUse",
|
|
28322
|
+
description: "Auto-format and lint Python files with ruff after edits",
|
|
28323
|
+
decision: "wrap-guard",
|
|
28324
|
+
reason: "inline script never re-checks the .py extension before invoking ruff format/check --fix; running ruff on a non-Python file would error"
|
|
28325
|
+
},
|
|
28326
|
+
{
|
|
28327
|
+
event: "PostToolUse",
|
|
28328
|
+
description: "Type check Python files with ty after edits",
|
|
28329
|
+
decision: "wrap-guard",
|
|
28330
|
+
reason: "inline script never re-checks the .py extension before invoking `ty check`; running a Python type checker on a non-Python file would error"
|
|
28331
|
+
}
|
|
28332
|
+
];
|
|
28333
|
+
function findClassification(event, description) {
|
|
28334
|
+
if (description === undefined)
|
|
28335
|
+
return;
|
|
28336
|
+
return CLASSIFICATION_TABLE.find((c) => c.event === event && c.description === description);
|
|
28337
|
+
}
|
|
28338
|
+
function looksHardBlock(command) {
|
|
28339
|
+
return /\bexit\s+[12]\b/.test(command);
|
|
28340
|
+
}
|
|
28341
|
+
function convertHookCommand(cmd, fieldNode, decision, warnCollector, context) {
|
|
28342
|
+
if (fieldNode === null || decision === "drop-condition") {
|
|
28343
|
+
return { ...cmd };
|
|
28344
|
+
}
|
|
28345
|
+
if (cmd.type !== "command" || typeof cmd.command !== "string") {
|
|
28346
|
+
warnCollector.push(`hooks: ${context} — cannot wrap a non-command hook (type="${cmd.type}") with a stdin guard; condition dropped, hook now fires unconditionally on matcher match`);
|
|
28347
|
+
return { ...cmd };
|
|
28348
|
+
}
|
|
28349
|
+
let jqCond;
|
|
28350
|
+
try {
|
|
28351
|
+
jqCond = fieldNodeToJq(fieldNode);
|
|
28352
|
+
} catch (err) {
|
|
28353
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
28354
|
+
if (looksHardBlock(cmd.command)) {
|
|
28355
|
+
warnCollector.push(`hooks: ${context} — could not translate condition to jq (${message}); hook looks hard-block (exit 1/2) so it was conservatively EXCLUDED from the converted settings — manual review required`);
|
|
28356
|
+
return null;
|
|
28357
|
+
}
|
|
28358
|
+
warnCollector.push(`hooks: ${context} — could not translate condition to jq (${message}); relaxed to unconditional firing (advisory hook, over-firing preferred over silent drop)`);
|
|
28359
|
+
return { ...cmd };
|
|
28360
|
+
}
|
|
28361
|
+
return {
|
|
28362
|
+
...cmd,
|
|
28363
|
+
command: buildGuardedCommand(jqCond, cmd.command)
|
|
28364
|
+
};
|
|
28365
|
+
}
|
|
28366
|
+
function convertHooksEntry(event, entry, warnCollector) {
|
|
28367
|
+
const context = `${event} / "${entry.description ?? "(no description)"}"`;
|
|
28368
|
+
let analysis;
|
|
28369
|
+
try {
|
|
28370
|
+
analysis = analyzeMatcher(entry.matcher);
|
|
28371
|
+
} catch (err) {
|
|
28372
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
28373
|
+
warnCollector.push(`hooks: ${context} — matcher could not be auto-converted, entry EXCLUDED: ${message}`);
|
|
28374
|
+
return null;
|
|
28375
|
+
}
|
|
28376
|
+
let decision = "auto";
|
|
28377
|
+
if (analysis.fieldNode !== null) {
|
|
28378
|
+
const classification = findClassification(event, entry.description);
|
|
28379
|
+
if (classification) {
|
|
28380
|
+
decision = classification.decision;
|
|
28381
|
+
} else {
|
|
28382
|
+
decision = "wrap-guard";
|
|
28383
|
+
warnCollector.push(`hooks: ${context} — has a tool_input condition not present in CLASSIFICATION_TABLE; defaulted to wrap-guard (safer) — add an explicit classification entry after reading the script`);
|
|
28384
|
+
}
|
|
28385
|
+
}
|
|
28386
|
+
const convertedHooks = entry.hooks.map((cmd) => convertHookCommand(cmd, analysis.fieldNode, decision, warnCollector, context)).filter((cmd) => cmd !== null);
|
|
28387
|
+
if (convertedHooks.length === 0) {
|
|
28388
|
+
return null;
|
|
28389
|
+
}
|
|
28390
|
+
const out = { hooks: convertedHooks };
|
|
28391
|
+
if (analysis.toolRegex !== "*") {
|
|
28392
|
+
out.matcher = analysis.toolRegex;
|
|
28393
|
+
} else if (entry.matcher !== undefined) {
|
|
28394
|
+
out.matcher = "*";
|
|
28395
|
+
}
|
|
28396
|
+
if (entry.description !== undefined) {
|
|
28397
|
+
out.description = entry.description;
|
|
28398
|
+
}
|
|
28399
|
+
return out;
|
|
28400
|
+
}
|
|
28401
|
+
function convertHooksJson(hooksJson) {
|
|
28402
|
+
const warnings = [];
|
|
28403
|
+
const warnCollector = { push: (msg) => warnings.push(msg) };
|
|
28404
|
+
const result = {};
|
|
28405
|
+
for (const [event, entries] of Object.entries(hooksJson.hooks ?? {})) {
|
|
28406
|
+
const convertedEntries = [];
|
|
28407
|
+
for (const entry of entries) {
|
|
28408
|
+
const converted = convertHooksEntry(event, entry, warnCollector);
|
|
28409
|
+
if (converted)
|
|
28410
|
+
convertedEntries.push(converted);
|
|
28411
|
+
}
|
|
28412
|
+
if (convertedEntries.length > 0) {
|
|
28413
|
+
result[event] = convertedEntries;
|
|
28414
|
+
}
|
|
28415
|
+
}
|
|
28416
|
+
return { hooks: result, warnings };
|
|
28417
|
+
}
|
|
28418
|
+
async function mergeHooksIntoSettings(settingsPath, hooksJsonPath) {
|
|
28419
|
+
const hooksJson = await readJsonFile(hooksJsonPath);
|
|
28420
|
+
const { hooks, warnings } = convertHooksJson(hooksJson);
|
|
28421
|
+
let settings = {};
|
|
28422
|
+
try {
|
|
28423
|
+
const existingRaw = await readTextFile(settingsPath);
|
|
28424
|
+
settings = JSON.parse(existingRaw);
|
|
28425
|
+
} catch {
|
|
28426
|
+
settings = {};
|
|
28427
|
+
}
|
|
28428
|
+
settings.hooks = hooks;
|
|
28429
|
+
await ensureDirectory(dirname3(settingsPath));
|
|
28430
|
+
await writeTextFile(settingsPath, `${JSON.stringify(settings, null, 2)}
|
|
28431
|
+
`);
|
|
28432
|
+
return { warnings };
|
|
28433
|
+
}
|
|
28434
|
+
|
|
27952
28435
|
// src/core/scope-filter.ts
|
|
27953
28436
|
function getSkillScope(content) {
|
|
27954
28437
|
const cleaned = content.replace(/^\uFEFF/, "");
|
|
@@ -28129,6 +28612,26 @@ async function installSettingsLocal(targetDir, result) {
|
|
|
28129
28612
|
await writeJsonFile(settingsPath, statusLineConfig);
|
|
28130
28613
|
debug("install.settings_local_created", {});
|
|
28131
28614
|
}
|
|
28615
|
+
async function installHooksSettings(targetDir, _options, result) {
|
|
28616
|
+
const layout = getProviderLayout();
|
|
28617
|
+
const hooksJsonPath = join8(targetDir, getComponentPath("hooks"), "hooks.json");
|
|
28618
|
+
const settingsPath = join8(targetDir, layout.rootDir, "settings.local.json");
|
|
28619
|
+
if (!await fileExists(hooksJsonPath)) {
|
|
28620
|
+
debug("install.hooks_settings_skipped", { reason: "hooks.json not installed" });
|
|
28621
|
+
return;
|
|
28622
|
+
}
|
|
28623
|
+
try {
|
|
28624
|
+
const mergeResult = await mergeHooksIntoSettings(settingsPath, hooksJsonPath);
|
|
28625
|
+
if (mergeResult && Array.isArray(mergeResult.warnings) && mergeResult.warnings.length > 0) {
|
|
28626
|
+
result.warnings.push(...mergeResult.warnings);
|
|
28627
|
+
}
|
|
28628
|
+
debug("install.hooks_settings_merged", {});
|
|
28629
|
+
} catch (err) {
|
|
28630
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
28631
|
+
result.warnings.push(`Failed to merge hooks into settings.local.json: ${message}`);
|
|
28632
|
+
warn("install.hooks_settings_failed", { error: message });
|
|
28633
|
+
}
|
|
28634
|
+
}
|
|
28132
28635
|
async function installEntryDocWithTracking(targetDir, options, result) {
|
|
28133
28636
|
const language = options.language ?? DEFAULT_LANGUAGE2;
|
|
28134
28637
|
const overwrite = !!(options.force || options.backup);
|
|
@@ -28187,6 +28690,7 @@ async function install(options) {
|
|
|
28187
28690
|
await installStatusline(options.targetDir, options, result);
|
|
28188
28691
|
await installTestsConfig(options.targetDir, options, result);
|
|
28189
28692
|
await installSettingsLocal(options.targetDir, result);
|
|
28693
|
+
await installHooksSettings(options.targetDir, options, result);
|
|
28190
28694
|
await installEntryDocWithTracking(options.targetDir, options, result);
|
|
28191
28695
|
if (preservation) {
|
|
28192
28696
|
const layout = getProviderLayout();
|
|
@@ -29670,7 +30174,7 @@ async function initCommand(options) {
|
|
|
29670
30174
|
}
|
|
29671
30175
|
|
|
29672
30176
|
// src/cli/list.ts
|
|
29673
|
-
import { basename as basename5, dirname as
|
|
30177
|
+
import { basename as basename5, dirname as dirname4, join as join14, relative as relative3 } from "node:path";
|
|
29674
30178
|
init_fs();
|
|
29675
30179
|
var ALLOWED_TOP_LEVEL_KEYS = new Set(["name", "type", "description", "version", "category"]);
|
|
29676
30180
|
function parseKeyValue(line) {
|
|
@@ -29871,7 +30375,7 @@ async function getSkills(targetDir, rootDir = ".claude", config) {
|
|
|
29871
30375
|
const customSkillPaths = new Set(customComponents.filter((c) => c.type === "skill").map((c) => c.path));
|
|
29872
30376
|
const skillMdFiles = await listFiles(skillsDir, { recursive: true, pattern: "SKILL.md" });
|
|
29873
30377
|
const skills = await Promise.all(skillMdFiles.map(async (skillMdPath) => {
|
|
29874
|
-
const skillDir =
|
|
30378
|
+
const skillDir = dirname4(skillMdPath);
|
|
29875
30379
|
const indexYamlPath = join14(skillDir, "index.yaml");
|
|
29876
30380
|
const { description, version } = await tryReadIndexYamlMetadata(indexYamlPath);
|
|
29877
30381
|
const relativePath = relative3(targetDir, skillDir);
|