Skip to content

Commit 0e42953

Browse files
committed
feature: add RequireSkillForCommand BashHandler with JSON registry
1 parent 6148bb0 commit 0e42953

6 files changed

Lines changed: 600 additions & 10 deletions

File tree

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# State
22

3-
- **Status:** open
4-
- **Progress:** 0%
3+
- **Status:** closed
4+
- **Progress:** 100%
55
- **Dependencies:** []
66
- **Blocks:** []
7+
- **Target Branch:** v2.1

client/src/main/java/io/github/cowwoc/cat/hooks/PreToolUseHook.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import io.github.cowwoc.cat.hooks.bash.BlockWrongBranchCommit;
2121
import io.github.cowwoc.cat.hooks.bash.ComputeBoxLines;
2222
import io.github.cowwoc.cat.hooks.bash.RemindGitSquash;
23+
import io.github.cowwoc.cat.hooks.bash.RequireSkillForCommand;
2324
import io.github.cowwoc.cat.hooks.bash.ValidateCommitType;
2425
import io.github.cowwoc.cat.hooks.bash.ValidateGitFilterBranch;
2526
import io.github.cowwoc.cat.hooks.bash.ValidateGitOperations;
@@ -79,7 +80,8 @@ public PreToolUseHook(JvmScope scope)
7980
new ValidateGitOperations(),
8081
new VerifyStateInCommit(),
8182
new WarnFileExtraction(),
82-
new WarnMainWorkspaceCommit(scope));
83+
new WarnMainWorkspaceCommit(scope),
84+
new RequireSkillForCommand(scope));
8385
}
8486

8587
/**
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
/*
2+
* Copyright (c) 2026 Gili Tzabari. All rights reserved.
3+
*
4+
* Licensed under the CAT Commercial License.
5+
* See LICENSE.md in the project root for license terms.
6+
*/
7+
package io.github.cowwoc.cat.hooks.bash;
8+
9+
import static io.github.cowwoc.requirements13.java.DefaultJavaValidators.requireThat;
10+
import static java.nio.charset.StandardCharsets.UTF_8;
11+
12+
import io.github.cowwoc.cat.hooks.BashHandler;
13+
import io.github.cowwoc.cat.hooks.HookInput;
14+
import io.github.cowwoc.cat.hooks.JvmScope;
15+
import io.github.cowwoc.cat.hooks.util.SkillLoader;
16+
import org.slf4j.Logger;
17+
import org.slf4j.LoggerFactory;
18+
import tools.jackson.databind.JsonNode;
19+
20+
import java.io.IOException;
21+
import java.nio.file.Files;
22+
import java.nio.file.NoSuchFileException;
23+
import java.nio.file.Path;
24+
import java.util.ArrayList;
25+
import java.util.List;
26+
import java.util.Set;
27+
import java.util.regex.Pattern;
28+
29+
/**
30+
* Blocks guarded Bash commands unless the required skill has already been loaded in the current agent's
31+
* session.
32+
* <p>
33+
* A JSON registry file at {@code CLAUDE_PLUGIN_ROOT/config/skill-triggers.json} maps regex patterns
34+
* to required skill names. When a command matches a pattern, this handler checks whether the corresponding
35+
* skill is present in the agent's {@code skills-loaded} marker file. If the skill has not been loaded, the
36+
* command is blocked with an actionable error message.
37+
* <p>
38+
* This handler fails open: if the registry file or marker file cannot be read due to an I/O error, the
39+
* command is allowed to proceed. This prevents blocking legitimate work during setup errors.
40+
*/
41+
public final class RequireSkillForCommand implements BashHandler
42+
{
43+
private final Logger log = LoggerFactory.getLogger(RequireSkillForCommand.class);
44+
private final JvmScope scope;
45+
private final List<GuardEntry> guards;
46+
47+
/**
48+
* A mapping from a compiled regex pattern to the required skill name.
49+
*
50+
* @param pattern the compiled regex pattern to match against bash commands
51+
* @param skill the fully-qualified skill name required when the pattern matches (e.g. {@code cat:git-rebase-agent})
52+
*/
53+
private record GuardEntry(Pattern pattern, String skill)
54+
{
55+
}
56+
57+
/**
58+
* Creates a new handler that reads the registry from the plugin root at construction time.
59+
*
60+
* @param scope the JVM scope providing access to shared resources including the plugin root and JSON mapper
61+
* @throws NullPointerException if {@code scope} is null
62+
*/
63+
public RequireSkillForCommand(JvmScope scope)
64+
{
65+
requireThat(scope, "scope").isNotNull();
66+
this.scope = scope;
67+
this.guards = loadGuards();
68+
}
69+
70+
/**
71+
* Loads guard entries from the skill-triggers.json file.
72+
* <p>
73+
* If the file cannot be read or parsed, logs the error and returns an empty list (fail-open).
74+
*
75+
* @return the list of guard entries, never null
76+
*/
77+
private List<GuardEntry> loadGuards()
78+
{
79+
Path registryFile = scope.getClaudePluginRoot().resolve("config").resolve("skill-triggers.json");
80+
List<GuardEntry> result = new ArrayList<>();
81+
try
82+
{
83+
if (!Files.exists(registryFile))
84+
{
85+
log.warn("RequireSkillForCommand: registry file not found: {}", registryFile);
86+
return result;
87+
}
88+
String content = Files.readString(registryFile, UTF_8);
89+
JsonNode root = scope.getJsonMapper().readTree(content);
90+
JsonNode guardsNode = root.get("guards");
91+
if (guardsNode == null || !guardsNode.isArray())
92+
{
93+
log.warn("RequireSkillForCommand: registry file missing 'guards' array: {}", registryFile);
94+
return result;
95+
}
96+
for (JsonNode entry : guardsNode)
97+
{
98+
JsonNode patternNode = entry.get("pattern");
99+
JsonNode skillNode = entry.get("skill");
100+
if (patternNode == null || !patternNode.isString() || skillNode == null || !skillNode.isString())
101+
{
102+
log.warn("RequireSkillForCommand: skipping invalid guard entry: {}", entry);
103+
continue;
104+
}
105+
String patternString = patternNode.asString();
106+
String skillName = skillNode.asString();
107+
Pattern compiled = Pattern.compile(patternString, Pattern.CASE_INSENSITIVE);
108+
result.add(new GuardEntry(compiled, skillName));
109+
}
110+
}
111+
catch (IOException e)
112+
{
113+
log.error("RequireSkillForCommand: failed to load registry from {}: {}", registryFile,
114+
e.getMessage(), e);
115+
}
116+
return result;
117+
}
118+
119+
/**
120+
* Checks whether the bash command requires a skill that has not yet been loaded.
121+
* <p>
122+
* The agent's {@code skills-loaded} marker file is read once before the guard loop. Each guard whose
123+
* pattern matches the command is then checked against the in-memory skill set. If any required skill is
124+
* absent, the command is blocked. If the command matches no pattern, it is allowed.
125+
* <p>
126+
* This handler fails open: I/O errors reading the marker file or unexpected agent ID formats cause the
127+
* command to be allowed rather than blocked.
128+
*
129+
* @param input the hook input containing the bash command and session information
130+
* @return a block result if a required skill is not loaded, or an allow result otherwise
131+
* @throws NullPointerException if {@code input} is null
132+
*/
133+
@Override
134+
public Result check(HookInput input)
135+
{
136+
requireThat(input, "input").isNotNull();
137+
String command = input.getCommand();
138+
if (command.isBlank())
139+
return Result.allow();
140+
141+
String sessionId = input.getSessionId();
142+
String catAgentId = input.getCatAgentId(sessionId);
143+
Path baseDir = scope.getSessionBasePath().toAbsolutePath().normalize();
144+
Set<String> loadedSkills;
145+
try
146+
{
147+
Path agentDir = SkillLoader.resolveAndValidateContainment(baseDir, catAgentId, "catAgentId");
148+
Path markerFile = agentDir.resolve("skills-loaded");
149+
String content = Files.readString(markerFile, UTF_8);
150+
loadedSkills = SkillLoader.parseSkillNames(content);
151+
}
152+
catch (NoSuchFileException _)
153+
{
154+
loadedSkills = Set.of();
155+
}
156+
catch (IllegalArgumentException e)
157+
{
158+
log.error("RequireSkillForCommand: unexpected catAgentId format '{}': {}", catAgentId,
159+
e.getMessage(), e);
160+
return Result.allow();
161+
}
162+
catch (IOException e)
163+
{
164+
log.error("RequireSkillForCommand: failed to read skills-loaded marker for agent '{}': {}",
165+
catAgentId, e.getMessage(), e);
166+
return Result.allow();
167+
}
168+
169+
for (GuardEntry guard : guards)
170+
{
171+
if (!guard.pattern().matcher(command).find())
172+
continue;
173+
if (!loadedSkills.contains(guard.skill()))
174+
return buildBlockResult(guard.skill());
175+
}
176+
177+
return Result.allow();
178+
}
179+
180+
/**
181+
* Builds a block result with an actionable message naming the required skill.
182+
*
183+
* @param skillName the fully-qualified skill name that must be loaded (e.g. {@code cat:git-rebase-agent})
184+
* @return a block result with the formatted message
185+
*/
186+
private Result buildBlockResult(String skillName)
187+
{
188+
String skillBaseName = SkillLoader.stripPrefix(skillName);
189+
String message = """
190+
BLOCKED: This command requires the %s skill.
191+
192+
Load the skill first:
193+
/cat:%s
194+
195+
Then retry the command.""".formatted(skillName, skillBaseName);
196+
return Result.block(message);
197+
}
198+
}

client/src/main/java/io/github/cowwoc/cat/hooks/util/SkillLoader.java

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -245,13 +245,30 @@ public SkillLoader(JvmScope scope, List<String> skillArgs) throws IOException
245245
if (Files.exists(agentMarkerFile))
246246
{
247247
String content = Files.readString(agentMarkerFile, StandardCharsets.UTF_8);
248-
for (String line : content.split("\n"))
249-
{
250-
String trimmed = line.strip();
251-
if (!trimmed.isEmpty())
252-
loadedSkills.add(trimmed);
253-
}
248+
loadedSkills.addAll(parseSkillNames(content));
249+
}
250+
}
251+
252+
/**
253+
* Parses a skills-loaded marker file's content into a set of skill names.
254+
* <p>
255+
* Each non-blank line in the content is treated as a skill name after stripping surrounding whitespace.
256+
*
257+
* @param content the content of a {@code skills-loaded} marker file
258+
* @return the set of skill names present in the content
259+
* @throws NullPointerException if {@code content} is null
260+
*/
261+
public static Set<String> parseSkillNames(String content)
262+
{
263+
requireThat(content, "content").isNotNull();
264+
Set<String> skills = new HashSet<>();
265+
for (String line : content.split("\n"))
266+
{
267+
String stripped = line.strip();
268+
if (!stripped.isEmpty())
269+
skills.add(stripped);
254270
}
271+
return skills;
255272
}
256273

257274
/**
@@ -383,7 +400,7 @@ private String loadRawContent(String skillName) throws IOException
383400
* @param qualifiedName the qualified skill name
384401
* @return the bare skill name without the prefix
385402
*/
386-
private static String stripPrefix(String qualifiedName)
403+
public static String stripPrefix(String qualifiedName)
387404
{
388405
int colonIndex = qualifiedName.indexOf(':');
389406
if (colonIndex >= 0)

0 commit comments

Comments
 (0)