oh-my-harness 0.6.1 โ 0.7.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/README.md +3 -3
- package/dist/catalog/blocks/command-guard.js +2 -2
- package/dist/catalog/blocks/commit-test-gate.js +2 -2
- package/dist/catalog/blocks/commit-typecheck-gate.js +2 -2
- package/dist/catalog/blocks/format-on-save.js +3 -3
- package/dist/catalog/blocks/lint-on-save.js +3 -3
- package/dist/catalog/blocks/lockfile-guard.js +1 -1
- package/dist/catalog/blocks/path-guard.js +1 -1
- package/dist/catalog/blocks/secret-file-guard.js +1 -1
- package/dist/catalog/blocks/tdd-guard.js +11 -6
- package/dist/catalog/template-engine.js +5 -0
- package/dist/cli/command-checker.js +6 -2
- package/dist/cli/harness-tester.js +20 -11
- package/dist/cli/stats/components/Blocks.js +1 -1
- package/dist/cli/stats/components/Overview.js +1 -1
- package/dist/cli/stats/data.d.ts +1 -0
- package/dist/cli/stats/data.js +24 -3
- package/dist/cli/tool-checker.js +7 -1
- package/dist/cli/tui/init-flow.d.ts +1 -0
- package/dist/cli/tui/init-flow.js +134 -20
- package/dist/core/harness-converter-v2.js +2 -4
- package/dist/detector/detectors/node.js +38 -0
- package/dist/detector/detectors/python.js +56 -8
- package/package.json +1 -1
- package/presets/actix/preset.yaml +55 -0
- package/presets/cargo/preset.yaml +11 -0
- package/presets/cpp/preset.yaml +50 -0
- package/presets/csharp/preset.yaml +42 -0
- package/presets/dart/preset.yaml +43 -0
- package/presets/django/preset.yaml +72 -0
- package/presets/elixir/preset.yaml +45 -0
- package/presets/express/preset.yaml +61 -0
- package/presets/fastapi/preset.yaml +1 -1
- package/presets/flask/preset.yaml +65 -0
- package/presets/flutter/preset.yaml +64 -0
- package/presets/gin/preset.yaml +57 -0
- package/presets/go/preset.yaml +43 -0
- package/presets/gradle/preset.yaml +13 -0
- package/presets/java/preset.yaml +48 -0
- package/presets/javascript/preset.yaml +44 -0
- package/presets/laravel/preset.yaml +70 -0
- package/presets/maven/preset.yaml +13 -0
- package/presets/nextjs/preset.yaml +1 -1
- package/presets/nextjs-fastapi/preset.yaml +1 -1
- package/presets/npm/preset.yaml +12 -0
- package/presets/phoenix/preset.yaml +64 -0
- package/presets/php/preset.yaml +46 -0
- package/presets/pip/preset.yaml +13 -0
- package/presets/pipenv/preset.yaml +12 -0
- package/presets/pnpm/preset.yaml +12 -0
- package/presets/python/preset.yaml +48 -0
- package/presets/rails/preset.yaml +65 -0
- package/presets/react/preset.yaml +61 -0
- package/presets/ruby/preset.yaml +45 -0
- package/presets/rust/preset.yaml +42 -0
- package/presets/scala/preset.yaml +44 -0
- package/presets/springboot/preset.yaml +61 -0
- package/presets/swift/preset.yaml +44 -0
- package/presets/typescript/preset.yaml +46 -0
- package/presets/uv/preset.yaml +12 -0
- package/presets/vue/preset.yaml +62 -0
- package/presets/yarn/preset.yaml +12 -0
- package/presets/zig/preset.yaml +41 -0
package/README.md
CHANGED
|
@@ -122,7 +122,7 @@ oh-my-harness automatically detects your project type and injects accurate facts
|
|
|
122
122
|
| Language | Detection | Commands |
|
|
123
123
|
|----------|-----------|----------|
|
|
124
124
|
| ๐ฆ TypeScript/JS | package.json, tsconfig | pnpm/npm/yarn test, eslint, tsc |
|
|
125
|
-
| ๐ Python | pyproject.toml, requirements.txt | pytest, ruff, mypy |
|
|
125
|
+
| ๐ Python | pyproject.toml, requirements.txt, Pipfile, manage.py, .python-version | pytest, ruff, black, isort, mypy |
|
|
126
126
|
| ๐ Swift | Package.swift, .xcodeproj | swift test, xcodebuild |
|
|
127
127
|
| ๐ฆ Rust | Cargo.toml | cargo test, cargo clippy |
|
|
128
128
|
| ๐น Go | go.mod | go test, golangci-lint |
|
|
@@ -154,7 +154,7 @@ All enforcement is powered by **catalog blocks** โ reusable, parameterized hoo
|
|
|
154
154
|
| โ๏ธ `lint-on-save` | auto-fix | Auto-lint on file save |
|
|
155
155
|
| ๐จ `format-on-save` | auto-fix | Auto-format on file save |
|
|
156
156
|
| ๐ `auto-pr` | automation | Auto-create PR after push |
|
|
157
|
-
| ๐งช `tdd-guard` | quality | Blocks source edits unless test modified first (JS/TS) |
|
|
157
|
+
| ๐งช `tdd-guard` | quality | Blocks source edits unless test modified first (JS/TS/Python) |
|
|
158
158
|
|
|
159
159
|
### Usage in `harness.yaml`
|
|
160
160
|
|
|
@@ -331,7 +331,7 @@ oh-my-harness/
|
|
|
331
331
|
โ โโโ parse-intent.ts # claude -p integration
|
|
332
332
|
โ โโโ prompt-templates.ts # LLM prompt construction
|
|
333
333
|
โโโ presets/ # Built-in preset definitions
|
|
334
|
-
โโโ tests/ #
|
|
334
|
+
โโโ tests/ # 745+ tests (unit + integration)
|
|
335
335
|
```
|
|
336
336
|
|
|
337
337
|
---
|
|
@@ -21,9 +21,9 @@ set -euo pipefail
|
|
|
21
21
|
INPUT=$(cat)
|
|
22
22
|
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
23
23
|
[[ -z "$COMMAND" ]] && exit 0
|
|
24
|
-
PATTERNS=({{#each patterns}}"{{this}}" {{/each}})
|
|
24
|
+
PATTERNS=({{#each patterns}}"{{{this}}}" {{/each}})
|
|
25
25
|
for PATTERN in "\${PATTERNS[@]}"; do
|
|
26
|
-
if echo "$COMMAND" | grep -qF "$PATTERN"; then
|
|
26
|
+
if echo "$COMMAND" | grep -qF -- "$PATTERN"; then
|
|
27
27
|
_log_event "block" "oh-my-harness: command matches blocked pattern: $PATTERN"
|
|
28
28
|
echo "{\\"decision\\": \\"block\\", \\"reason\\": \\"oh-my-harness: command matches blocked pattern: $PATTERN\\"}"
|
|
29
29
|
exit 0
|
|
@@ -15,8 +15,8 @@ set -euo pipefail
|
|
|
15
15
|
INPUT=$(cat)
|
|
16
16
|
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
17
17
|
if echo "$COMMAND" | grep -qE "git commit"; then
|
|
18
|
-
echo "oh-my-harness: Running {{testCommand}} before commit..." >&2
|
|
19
|
-
if ! {{testCommand}} >&2 2>&1; then
|
|
18
|
+
echo "oh-my-harness: Running {{{testCommand}}} before commit..." >&2
|
|
19
|
+
if ! {{{testCommand}}} >&2 2>&1; then
|
|
20
20
|
_log_event "block" "oh-my-harness: pre-commit check failed"
|
|
21
21
|
echo "{\\"decision\\": \\"block\\", \\"reason\\": \\"oh-my-harness: pre-commit check failed\\"}"
|
|
22
22
|
exit 0
|
|
@@ -15,8 +15,8 @@ set -euo pipefail
|
|
|
15
15
|
INPUT=$(cat)
|
|
16
16
|
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
17
17
|
if echo "$COMMAND" | grep -qE "git commit"; then
|
|
18
|
-
echo "oh-my-harness: Running {{typecheckCommand}} before commit..." >&2
|
|
19
|
-
if ! {{typecheckCommand}} >&2 2>&1; then
|
|
18
|
+
echo "oh-my-harness: Running {{{typecheckCommand}}} before commit..." >&2
|
|
19
|
+
if ! {{{typecheckCommand}}} >&2 2>&1; then
|
|
20
20
|
_log_event "block" "oh-my-harness: pre-commit check failed"
|
|
21
21
|
echo "{\\"decision\\": \\"block\\", \\"reason\\": \\"oh-my-harness: pre-commit check failed\\"}"
|
|
22
22
|
exit 0
|
|
@@ -26,11 +26,11 @@ set -euo pipefail
|
|
|
26
26
|
INPUT=$(cat)
|
|
27
27
|
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty' 2>/dev/null)
|
|
28
28
|
[[ -z "$FILE_PATH" ]] && exit 0
|
|
29
|
-
PATTERN='{{filePattern}}'
|
|
29
|
+
PATTERN='{{{filePattern}}}'
|
|
30
30
|
BASENAME=$(basename "$FILE_PATH")
|
|
31
31
|
if [[ "$BASENAME" == $PATTERN ]]; then
|
|
32
|
-
echo "oh-my-harness: Running {{command}} on $FILE_PATH..." >&2
|
|
33
|
-
{{command}} "$FILE_PATH" >&2 2>&1 || true
|
|
32
|
+
echo "oh-my-harness: Running {{{command}}} on $FILE_PATH..." >&2
|
|
33
|
+
{{{command}}} "$FILE_PATH" >&2 2>&1 || true
|
|
34
34
|
fi
|
|
35
35
|
exit 0`,
|
|
36
36
|
};
|
|
@@ -26,11 +26,11 @@ set -euo pipefail
|
|
|
26
26
|
INPUT=$(cat)
|
|
27
27
|
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty' 2>/dev/null)
|
|
28
28
|
[[ -z "$FILE_PATH" ]] && exit 0
|
|
29
|
-
PATTERN='{{filePattern}}'
|
|
29
|
+
PATTERN='{{{filePattern}}}'
|
|
30
30
|
BASENAME=$(basename "$FILE_PATH")
|
|
31
31
|
if [[ "$BASENAME" == $PATTERN ]]; then
|
|
32
|
-
echo "oh-my-harness: Running {{command}} on $FILE_PATH..." >&2
|
|
33
|
-
{{command}} "$FILE_PATH" >&2 2>&1 || true
|
|
32
|
+
echo "oh-my-harness: Running {{{command}}} on $FILE_PATH..." >&2
|
|
33
|
+
{{{command}}} "$FILE_PATH" >&2 2>&1 || true
|
|
34
34
|
fi
|
|
35
35
|
exit 0`,
|
|
36
36
|
};
|
|
@@ -22,7 +22,7 @@ INPUT=$(cat)
|
|
|
22
22
|
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty' 2>/dev/null)
|
|
23
23
|
[[ -z "$FILE_PATH" ]] && exit 0
|
|
24
24
|
BASENAME=$(basename "$FILE_PATH")
|
|
25
|
-
LOCKFILES=({{#each lockfiles}}"{{this}}" {{/each}})
|
|
25
|
+
LOCKFILES=({{#each lockfiles}}"{{{this}}}" {{/each}})
|
|
26
26
|
for LOCKFILE in "\${LOCKFILES[@]}"; do
|
|
27
27
|
if [[ "$BASENAME" == "$LOCKFILE" ]]; then
|
|
28
28
|
_log_event "block" "oh-my-harness: direct edits to lockfile $BASENAME are blocked. Use the package manager instead."
|
|
@@ -20,7 +20,7 @@ set -euo pipefail
|
|
|
20
20
|
INPUT=$(cat)
|
|
21
21
|
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty' 2>/dev/null)
|
|
22
22
|
[[ -z "$FILE_PATH" ]] && exit 0
|
|
23
|
-
BLOCKED_PATHS=({{#each blockedPaths}}"{{this}}" {{/each}})
|
|
23
|
+
BLOCKED_PATHS=({{#each blockedPaths}}"{{{this}}}" {{/each}})
|
|
24
24
|
for BLOCKED in "\${BLOCKED_PATHS[@]}"; do
|
|
25
25
|
if [[ "$BLOCKED" == */ ]]; then
|
|
26
26
|
if [[ "$FILE_PATH" == "$BLOCKED"* || "$FILE_PATH" == *"/$BLOCKED"* ]]; then
|
|
@@ -22,7 +22,7 @@ INPUT=$(cat)
|
|
|
22
22
|
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty' 2>/dev/null)
|
|
23
23
|
[[ -z "$FILE_PATH" ]] && exit 0
|
|
24
24
|
BASENAME=$(basename "$FILE_PATH")
|
|
25
|
-
PATTERNS=({{#each patterns}}"{{this}}" {{/each}})
|
|
25
|
+
PATTERNS=({{#each patterns}}"{{{this}}}" {{/each}})
|
|
26
26
|
for PATTERN in "\${PATTERNS[@]}"; do
|
|
27
27
|
if [[ "$BASENAME" == $PATTERN ]]; then
|
|
28
28
|
_log_event "block" "oh-my-harness: file $BASENAME matches secret file pattern: $PATTERN"
|
|
@@ -38,7 +38,12 @@ STATE_DIR=".claude/hooks/.state"
|
|
|
38
38
|
HISTORY_FILE="\$STATE_DIR/edit-history.json"
|
|
39
39
|
mkdir -p "\$STATE_DIR" 2>/dev/null || true
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
TEST_RE='{{{testPattern}}}'
|
|
42
|
+
SRC_RE='{{{srcPattern}}}'
|
|
43
|
+
# Normalize double backslashes to single for [[ =~ ]] compatibility
|
|
44
|
+
TEST_RE="\${TEST_RE//\\\\\\\\/\\\\}"
|
|
45
|
+
SRC_RE="\${SRC_RE//\\\\\\\\/\\\\}"
|
|
46
|
+
if [[ "\$FILE_PATH" =~ \$TEST_RE ]]; then
|
|
42
47
|
# ํ
์คํธ ํ์ผ ์์ โ ๊ธฐ๋ก + ํต๊ณผ
|
|
43
48
|
if [[ ! -f "\$HISTORY_FILE" ]]; then
|
|
44
49
|
echo '{"edits":[]}' > "\$HISTORY_FILE"
|
|
@@ -50,13 +55,13 @@ if echo "\$FILE_PATH" | grep -qE '{{testPattern}}'; then
|
|
|
50
55
|
exit 0
|
|
51
56
|
fi
|
|
52
57
|
|
|
53
|
-
# ์์ค
|
|
54
|
-
if !
|
|
58
|
+
# ์์ค ํ์ผ์ด ์๋๋ฉด ํต๊ณผ
|
|
59
|
+
if [[ ! "\$FILE_PATH" =~ \$SRC_RE ]]; then
|
|
55
60
|
exit 0
|
|
56
61
|
fi
|
|
57
62
|
|
|
58
|
-
# ๋์ ํ
์คํธ ํ์ผ ํ์ธ
|
|
59
|
-
BASENAME=$(basename "\$FILE_PATH" | sed -E 's/\\.
|
|
63
|
+
# ๋์ ํ
์คํธ ํ์ผ ํ์ธ โ ํ์ฅ์ ์ ๊ฑฐ
|
|
64
|
+
BASENAME=$(basename "\$FILE_PATH" | sed -E 's/\\.[^.]+$//')
|
|
60
65
|
TEST_SUFFIX=".test."
|
|
61
66
|
|
|
62
67
|
if [[ ! -f "\$HISTORY_FILE" ]]; then
|
|
@@ -66,7 +71,7 @@ if [[ ! -f "\$HISTORY_FILE" ]]; then
|
|
|
66
71
|
fi
|
|
67
72
|
|
|
68
73
|
# edit-history์์ ํ
์คํธ ํ์ผ ๊ฒ์
|
|
69
|
-
if jq -e --arg b "\$BASENAME" '.edits[] | select(contains($b
|
|
74
|
+
if jq -e --arg b "\$BASENAME" '.edits[] | select(contains($b) and (contains(".test.") or contains(".spec.") or contains("test_")))' "\$HISTORY_FILE" >/dev/null 2>&1; then
|
|
70
75
|
# ํ
์คํธ ๋จผ์ ์์ ๋จ โ ์์ค ๊ธฐ๋ก + ํต๊ณผ
|
|
71
76
|
UPDATED=$(jq --arg f "\$FILE_PATH" '.edits += [$f] | .edits |= unique' "\$HISTORY_FILE" 2>/dev/null) || true
|
|
72
77
|
if [[ -n "\$UPDATED" ]]; then
|
|
@@ -9,6 +9,11 @@ export function applyDefaults(block, params) {
|
|
|
9
9
|
if (result[param.name] === undefined && param.default !== undefined) {
|
|
10
10
|
result[param.name] = param.default;
|
|
11
11
|
}
|
|
12
|
+
// Auto-wrap string into array for string[] params
|
|
13
|
+
if (param.type === "string[]" && typeof result[param.name] === "string") {
|
|
14
|
+
const str = result[param.name];
|
|
15
|
+
result[param.name] = str.includes(",") ? str.split(",").map((s) => s.trim()).filter((s) => s.length > 0) : [str];
|
|
16
|
+
}
|
|
12
17
|
}
|
|
13
18
|
return result;
|
|
14
19
|
}
|
|
@@ -3,11 +3,15 @@ import { promisify } from "node:util";
|
|
|
3
3
|
const execFileAsync = promisify(execFile);
|
|
4
4
|
// ๋ช
๋ น์ด์์ ๋ฐ์ด๋๋ฆฌ ์ถ์ถ (์ฒซ ํ ํฐ, ๋ํผ ์ฒ๋ฆฌ)
|
|
5
5
|
export function extractExecutable(command) {
|
|
6
|
-
|
|
6
|
+
let parts = command.trim().split(/\s+/);
|
|
7
|
+
// Skip environment variable prefixes (KEY=VALUE)
|
|
8
|
+
while (parts.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(parts[0])) {
|
|
9
|
+
parts = parts.slice(1);
|
|
10
|
+
}
|
|
7
11
|
// npm run X โ npm
|
|
8
12
|
// npx X โ npx
|
|
9
13
|
// bash script.sh โ bash
|
|
10
|
-
return parts[0];
|
|
14
|
+
return parts[0] ?? "";
|
|
11
15
|
}
|
|
12
16
|
// ๋ช
๋ น์ด ์คํ ๊ฐ๋ฅ ์ฌ๋ถ ํ์ธ
|
|
13
17
|
export async function checkCommandExecutable(binary) {
|
|
@@ -189,13 +189,16 @@ export function generateBlockTestCases(hookEntries, blocks, currentBranch, regis
|
|
|
189
189
|
break;
|
|
190
190
|
}
|
|
191
191
|
case "lockfile-guard": {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
192
|
+
const lockfiles = params.lockfiles ?? ["package-lock.json"];
|
|
193
|
+
for (const lf of lockfiles) {
|
|
194
|
+
cases.push({
|
|
195
|
+
name: `${lf} โ BLOCKED`,
|
|
196
|
+
category: "lockfile-guard",
|
|
197
|
+
hookScript,
|
|
198
|
+
input: { tool_name: "Edit", tool_input: { file_path: lf } },
|
|
199
|
+
expectation: "block",
|
|
200
|
+
});
|
|
201
|
+
}
|
|
199
202
|
cases.push({
|
|
200
203
|
name: "package.json โ ALLOWED",
|
|
201
204
|
category: "lockfile-guard",
|
|
@@ -224,12 +227,18 @@ export function generateBlockTestCases(hookEntries, blocks, currentBranch, regis
|
|
|
224
227
|
}
|
|
225
228
|
case "tdd-guard": {
|
|
226
229
|
const stateFile = ".claude/hooks/.state/edit-history.json";
|
|
230
|
+
const srcPat = params.srcPattern ?? "\\.(ts|tsx|js|jsx)$";
|
|
231
|
+
const testPat = params.testPattern ?? "\\.(test|spec)\\.(ts|tsx|js|jsx)$";
|
|
232
|
+
// Derive sample file extensions from patterns
|
|
233
|
+
const srcExt = srcPat.includes(".py") ? ".py" : ".ts";
|
|
234
|
+
const testFile = testPat.includes("test_") ? `test_example${srcExt}` : `example.test${srcExt}`;
|
|
235
|
+
const srcFile = `src/example${srcExt}`;
|
|
227
236
|
// block case: source file without prior test edit
|
|
228
237
|
cases.push({
|
|
229
|
-
name:
|
|
238
|
+
name: `${srcFile} without test โ BLOCKED`,
|
|
230
239
|
category: "tdd-guard",
|
|
231
240
|
hookScript,
|
|
232
|
-
input: { tool_name: "Edit", tool_input: { file_path:
|
|
241
|
+
input: { tool_name: "Edit", tool_input: { file_path: srcFile } },
|
|
233
242
|
expectation: "block",
|
|
234
243
|
setup: async (projectDir) => {
|
|
235
244
|
const historyPath = path.join(projectDir, stateFile);
|
|
@@ -252,10 +261,10 @@ export function generateBlockTestCases(hookEntries, blocks, currentBranch, regis
|
|
|
252
261
|
});
|
|
253
262
|
// allow case: test file edit
|
|
254
263
|
cases.push({
|
|
255
|
-
name:
|
|
264
|
+
name: `${testFile} โ ALLOWED`,
|
|
256
265
|
category: "tdd-guard",
|
|
257
266
|
hookScript,
|
|
258
|
-
input: { tool_name: "Edit", tool_input: { file_path:
|
|
267
|
+
input: { tool_name: "Edit", tool_input: { file_path: testFile } },
|
|
259
268
|
expectation: "allow",
|
|
260
269
|
setup: async (projectDir) => {
|
|
261
270
|
const historyPath = path.join(projectDir, stateFile);
|
|
@@ -10,5 +10,5 @@ export function Blocks({ data, selectedIndex }) {
|
|
|
10
10
|
}
|
|
11
11
|
const clampedIndex = Math.max(0, Math.min(selectedIndex, allBlocks.length - 1));
|
|
12
12
|
const selected = allBlocks[clampedIndex];
|
|
13
|
-
return (_jsxs(Box, { paddingX: 1, children: [_jsx(Box, { flexDirection: "column", width: 28, children: allBlocks.map((block, i) => (_jsxs(Text, { inverse: i === clampedIndex, color: block.hits === 0 ? "gray" : undefined, children: [i === clampedIndex ? "โถ" : " ", " ", block.id, block.hits > 0 ? ` (${block.hits})` : ""] },
|
|
13
|
+
return (_jsxs(Box, { paddingX: 1, children: [_jsx(Box, { flexDirection: "column", width: 28, children: allBlocks.map((block, i) => (_jsxs(Text, { inverse: i === clampedIndex, color: block.hits === 0 ? "gray" : undefined, children: [i === clampedIndex ? "โถ" : " ", " ", block.id, block.hits > 0 ? ` (${block.hits})` : ""] }, i))) }), _jsx(Box, { flexDirection: "column", borderStyle: "single", paddingX: 1, flexGrow: 1, children: selected && _jsx(BlockDetail, { block: selected }) })] }));
|
|
14
14
|
}
|
|
@@ -2,5 +2,5 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
|
2
2
|
import { Text, Box } from "ink";
|
|
3
3
|
import { HitBar } from "./HitBar.js";
|
|
4
4
|
export function Overview({ data }) {
|
|
5
|
-
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsxs(Text, { bold: true, children: ["Active: ", data.activeBlocks.length] }), _jsx(Text, { children: " " }), _jsxs(Text, { children: ["Events: ", data.totalEvents] }), _jsx(Text, { children: " " }), _jsxs(Text, { children: ["Block rate: ", data.blockRate, "%"] })] }), data.activeBlocks.map(block => (_jsxs(Box, { children: [_jsx(Box, { width: 22, children: _jsx(Text, { color: block.canBlock ? "cyan" : "gray", children: block.id }) }), _jsx(HitBar, { blockCount: block.blockCount, allowCount: block.allowCount }), _jsxs(Text, { children: [" ", block.hits, " hits"] }), block.blockCount > 0 && _jsxs(Text, { color: "red", children: [" (", block.blockCount, " block)"] })] },
|
|
5
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsxs(Text, { bold: true, children: ["Active: ", data.activeBlocks.length] }), _jsx(Text, { children: " " }), _jsxs(Text, { children: ["Events: ", data.totalEvents] }), _jsx(Text, { children: " " }), _jsxs(Text, { children: ["Block rate: ", data.blockRate, "%"] })] }), data.activeBlocks.map((block, i) => (_jsxs(Box, { children: [_jsx(Box, { width: 22, children: _jsx(Text, { color: block.canBlock ? "cyan" : "gray", children: block.id }) }), _jsx(HitBar, { blockCount: block.blockCount, allowCount: block.allowCount }), _jsxs(Text, { children: [" ", block.hits, " hits"] }), block.blockCount > 0 && _jsxs(Text, { color: "red", children: [" (", block.blockCount, " block)"] })] }, i))), data.dormantBlocks.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, dimColor: true, children: "Dormant (0 hits):" }), data.dormantBlocks.map((block, i) => (_jsxs(Text, { dimColor: true, children: [" \u2591 ", block.id] }, i)))] }))] }));
|
|
6
6
|
}
|
package/dist/cli/stats/data.d.ts
CHANGED
|
@@ -33,6 +33,7 @@ export interface StatsData {
|
|
|
33
33
|
hourlyDistribution: HourlyBucket[];
|
|
34
34
|
dateRange: DateRange;
|
|
35
35
|
}
|
|
36
|
+
export declare function deduplicateBlocks(blocks: BlockStats[]): BlockStats[];
|
|
36
37
|
export declare function getActiveBlocks(hookEntries: HookEntry[], allBlocks: BuildingBlock[]): {
|
|
37
38
|
block: BuildingBlock;
|
|
38
39
|
params: Record<string, unknown>;
|
package/dist/cli/stats/data.js
CHANGED
|
@@ -4,6 +4,27 @@ import { builtinBlocks } from "../../catalog/blocks/index.js";
|
|
|
4
4
|
import fs from "node:fs/promises";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import yaml from "js-yaml";
|
|
7
|
+
export function deduplicateBlocks(blocks) {
|
|
8
|
+
const map = new Map();
|
|
9
|
+
for (const b of blocks) {
|
|
10
|
+
const existing = map.get(b.id);
|
|
11
|
+
if (existing) {
|
|
12
|
+
existing.hits += b.hits;
|
|
13
|
+
existing.blockCount += b.blockCount;
|
|
14
|
+
existing.allowCount += b.allowCount;
|
|
15
|
+
if (b.lastHit && (!existing.lastHit || b.lastHit > existing.lastHit)) {
|
|
16
|
+
existing.lastHit = b.lastHit;
|
|
17
|
+
}
|
|
18
|
+
if (b.lastBlockReason) {
|
|
19
|
+
existing.lastBlockReason = b.lastBlockReason;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
map.set(b.id, { ...b });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return [...map.values()];
|
|
27
|
+
}
|
|
7
28
|
export function getActiveBlocks(hookEntries, allBlocks) {
|
|
8
29
|
const result = [];
|
|
9
30
|
for (const entry of hookEntries) {
|
|
@@ -94,9 +115,9 @@ export async function loadStatsData(projectDir, dateRange = "all") {
|
|
|
94
115
|
}
|
|
95
116
|
const activeBlocksWithParams = getActiveBlocks(hookEntries, builtinBlocks);
|
|
96
117
|
const dormantIds = getDormantBlocks(activeBlocksWithParams, events);
|
|
97
|
-
const blocks = activeBlocksWithParams.map(ab => getBlockDetail(ab.block.id, events, builtinBlocks, ab.params));
|
|
98
|
-
const activeBlocks = blocks.filter(b => !dormantIds.includes(b.id));
|
|
99
|
-
const dormantBlocks = blocks.filter(b => dormantIds.includes(b.id));
|
|
118
|
+
const blocks = deduplicateBlocks(activeBlocksWithParams.map(ab => getBlockDetail(ab.block.id, events, builtinBlocks, ab.params)));
|
|
119
|
+
const activeBlocks = deduplicateBlocks(blocks.filter(b => !dormantIds.includes(b.id)));
|
|
120
|
+
const dormantBlocks = deduplicateBlocks(blocks.filter(b => dormantIds.includes(b.id)));
|
|
100
121
|
const hourlyDistribution = getHourlyDistribution(events);
|
|
101
122
|
const stats = aggregateStats(events);
|
|
102
123
|
const peakHour = hourlyDistribution.reduce((max, b) => b.total > max.total ? b : max, hourlyDistribution[0]).hour;
|
package/dist/cli/tool-checker.js
CHANGED
|
@@ -17,7 +17,13 @@ function extractBinary(command) {
|
|
|
17
17
|
const trimmed = command.trim();
|
|
18
18
|
if (!trimmed)
|
|
19
19
|
return undefined;
|
|
20
|
-
|
|
20
|
+
let parts = trimmed.split(/\s+/);
|
|
21
|
+
// Skip environment variable prefixes (KEY=VALUE)
|
|
22
|
+
while (parts.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(parts[0])) {
|
|
23
|
+
parts = parts.slice(1);
|
|
24
|
+
}
|
|
25
|
+
if (parts.length === 0)
|
|
26
|
+
return undefined;
|
|
21
27
|
// Skip gradle wrapper commands (managed by project)
|
|
22
28
|
if (parts[0] === "./gradlew" || parts[0] === "gradlew")
|
|
23
29
|
return undefined;
|
|
@@ -4,6 +4,7 @@ import type { ProjectFacts } from "../../detector/types.js";
|
|
|
4
4
|
export declare function formatDepResults(deps: DepCheck[]): string;
|
|
5
5
|
export declare function formatConfigSummary(config: HarnessConfig): string;
|
|
6
6
|
export declare function formatProjectFacts(facts: ProjectFacts): string;
|
|
7
|
+
export declare function buildPresetExtends(language: string, framework: string | undefined, packageManager: string | undefined): string[];
|
|
7
8
|
export declare function runInitTUI(options?: {
|
|
8
9
|
projectDir?: string;
|
|
9
10
|
presetsDir?: string;
|
|
@@ -88,6 +88,16 @@ export function formatProjectFacts(facts) {
|
|
|
88
88
|
}
|
|
89
89
|
return lines.length > 0 ? lines.join("\n") : ` ${chalk.dim("No project signals detected")}`;
|
|
90
90
|
}
|
|
91
|
+
export function buildPresetExtends(language, framework, packageManager) {
|
|
92
|
+
const result = ["_base", language];
|
|
93
|
+
if (framework && framework !== "none") {
|
|
94
|
+
result.push(framework);
|
|
95
|
+
}
|
|
96
|
+
if (packageManager) {
|
|
97
|
+
result.push(packageManager);
|
|
98
|
+
}
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
91
101
|
function handleCancel(value) {
|
|
92
102
|
if (p.isCancel(value)) {
|
|
93
103
|
p.cancel("Operation cancelled.");
|
|
@@ -190,7 +200,15 @@ export async function runInitTUI(options) {
|
|
|
190
200
|
const genSpinner = p.spinner();
|
|
191
201
|
genSpinner.start("Generating harness configuration...");
|
|
192
202
|
try {
|
|
193
|
-
|
|
203
|
+
// Load catalog blocks so LLM knows available building blocks + validation
|
|
204
|
+
const { createDefaultRegistry } = await import("../../catalog/registry.js");
|
|
205
|
+
const catalogRegistry = await createDefaultRegistry();
|
|
206
|
+
const catalogBlocks = catalogRegistry.list().map((b) => ({
|
|
207
|
+
id: b.id,
|
|
208
|
+
description: b.description,
|
|
209
|
+
params: b.params.map((pp) => ({ name: pp.name, required: pp.required, default: pp.default, description: pp.description })),
|
|
210
|
+
}));
|
|
211
|
+
harnessConfig = await generateHarnessConfig(description, undefined, catalogBlocks, projectFacts);
|
|
194
212
|
genSpinner.stop("Configuration generated");
|
|
195
213
|
}
|
|
196
214
|
catch (err) {
|
|
@@ -213,28 +231,124 @@ export async function runInitTUI(options) {
|
|
|
213
231
|
}
|
|
214
232
|
}
|
|
215
233
|
else if (mode === "preset") {
|
|
216
|
-
// Step 4b: Preset Mode
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
234
|
+
// Step 4b: Preset Mode โ 3-step interactive language/framework/PM selection
|
|
235
|
+
p.log.info(`${chalk.dim("_base preset is always included automatically.")}`);
|
|
236
|
+
// Step 4b-1: Language
|
|
237
|
+
const language = await p.select({
|
|
238
|
+
message: "Choose your language",
|
|
239
|
+
options: [
|
|
240
|
+
{ value: "python", label: "Python" },
|
|
241
|
+
{ value: "typescript", label: "TypeScript" },
|
|
242
|
+
{ value: "javascript", label: "JavaScript" },
|
|
243
|
+
{ value: "rust", label: "Rust" },
|
|
244
|
+
{ value: "go", label: "Go" },
|
|
245
|
+
{ value: "java", label: "Java" },
|
|
246
|
+
{ value: "ruby", label: "Ruby" },
|
|
247
|
+
{ value: "php", label: "PHP" },
|
|
248
|
+
{ value: "swift", label: "Swift" },
|
|
249
|
+
{ value: "dart", label: "Dart" },
|
|
250
|
+
{ value: "csharp", label: "C#" },
|
|
251
|
+
{ value: "elixir", label: "Elixir" },
|
|
252
|
+
{ value: "scala", label: "Scala" },
|
|
253
|
+
{ value: "zig", label: "Zig" },
|
|
254
|
+
{ value: "cpp", label: "C/C++" },
|
|
255
|
+
],
|
|
256
|
+
});
|
|
257
|
+
handleCancel(language);
|
|
258
|
+
const frameworkOptions = {
|
|
259
|
+
python: [
|
|
260
|
+
{ value: "django", label: "Django" },
|
|
261
|
+
{ value: "fastapi", label: "FastAPI" },
|
|
262
|
+
{ value: "flask", label: "Flask" },
|
|
263
|
+
{ value: "none", label: "None" },
|
|
264
|
+
],
|
|
265
|
+
typescript: [
|
|
266
|
+
{ value: "nextjs", label: "Next.js" },
|
|
267
|
+
{ value: "react", label: "React" },
|
|
268
|
+
{ value: "vue", label: "Vue" },
|
|
269
|
+
{ value: "express", label: "Express" },
|
|
270
|
+
{ value: "none", label: "None" },
|
|
271
|
+
],
|
|
272
|
+
javascript: [
|
|
273
|
+
{ value: "nextjs", label: "Next.js" },
|
|
274
|
+
{ value: "react", label: "React" },
|
|
275
|
+
{ value: "vue", label: "Vue" },
|
|
276
|
+
{ value: "express", label: "Express" },
|
|
277
|
+
{ value: "none", label: "None" },
|
|
278
|
+
],
|
|
279
|
+
java: [
|
|
280
|
+
{ value: "springboot", label: "Spring Boot" },
|
|
281
|
+
{ value: "none", label: "None" },
|
|
282
|
+
],
|
|
283
|
+
ruby: [
|
|
284
|
+
{ value: "rails", label: "Rails" },
|
|
285
|
+
{ value: "none", label: "None" },
|
|
286
|
+
],
|
|
287
|
+
php: [
|
|
288
|
+
{ value: "laravel", label: "Laravel" },
|
|
289
|
+
{ value: "none", label: "None" },
|
|
290
|
+
],
|
|
291
|
+
go: [
|
|
292
|
+
{ value: "gin", label: "Gin" },
|
|
293
|
+
{ value: "none", label: "None" },
|
|
294
|
+
],
|
|
295
|
+
rust: [
|
|
296
|
+
{ value: "actix", label: "Actix" },
|
|
297
|
+
{ value: "none", label: "None" },
|
|
298
|
+
],
|
|
299
|
+
dart: [
|
|
300
|
+
{ value: "flutter", label: "Flutter" },
|
|
301
|
+
{ value: "none", label: "None" },
|
|
302
|
+
],
|
|
303
|
+
elixir: [
|
|
304
|
+
{ value: "phoenix", label: "Phoenix" },
|
|
305
|
+
{ value: "none", label: "None" },
|
|
306
|
+
],
|
|
307
|
+
};
|
|
308
|
+
// Step 4b-2: Framework (only for languages with options)
|
|
309
|
+
let framework;
|
|
310
|
+
const fwOptions = frameworkOptions[language];
|
|
311
|
+
if (fwOptions && fwOptions.length > 1) {
|
|
312
|
+
const fw = await p.select({
|
|
313
|
+
message: "Choose your framework",
|
|
314
|
+
options: fwOptions,
|
|
315
|
+
});
|
|
316
|
+
handleCancel(fw);
|
|
317
|
+
framework = fw;
|
|
223
318
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
}
|
|
233
|
-
|
|
319
|
+
const pmOptions = {
|
|
320
|
+
python: [
|
|
321
|
+
{ value: "uv", label: "uv" },
|
|
322
|
+
{ value: "pipenv", label: "pipenv" },
|
|
323
|
+
{ value: "pip", label: "pip" },
|
|
324
|
+
],
|
|
325
|
+
typescript: [
|
|
326
|
+
{ value: "pnpm", label: "pnpm" },
|
|
327
|
+
{ value: "npm", label: "npm" },
|
|
328
|
+
{ value: "yarn", label: "yarn" },
|
|
329
|
+
],
|
|
330
|
+
javascript: [
|
|
331
|
+
{ value: "pnpm", label: "pnpm" },
|
|
332
|
+
{ value: "npm", label: "npm" },
|
|
333
|
+
{ value: "yarn", label: "yarn" },
|
|
334
|
+
],
|
|
335
|
+
java: [
|
|
336
|
+
{ value: "gradle", label: "gradle" },
|
|
337
|
+
{ value: "maven", label: "maven" },
|
|
338
|
+
],
|
|
339
|
+
};
|
|
340
|
+
// Step 4b-3: Package Manager (only for languages with multiple options)
|
|
341
|
+
let packageManager;
|
|
342
|
+
const pmOpts = pmOptions[language];
|
|
343
|
+
if (pmOpts && pmOpts.length > 1) {
|
|
344
|
+
const pm = await p.select({
|
|
345
|
+
message: "Choose your package manager",
|
|
346
|
+
options: pmOpts,
|
|
234
347
|
});
|
|
235
|
-
handleCancel(
|
|
236
|
-
|
|
348
|
+
handleCancel(pm);
|
|
349
|
+
packageManager = pm;
|
|
237
350
|
}
|
|
351
|
+
presetNames = buildPresetExtends(language, framework, packageManager);
|
|
238
352
|
}
|
|
239
353
|
else if (mode === "import") {
|
|
240
354
|
// Step 4c: Import Mode
|
|
@@ -13,11 +13,8 @@ export async function harnessToMergedConfigV2(harness, registry, projectDir) {
|
|
|
13
13
|
// Resolve registry โ use provided one or create the default
|
|
14
14
|
const resolvedRegistry = registry ?? (await createDefaultRegistry());
|
|
15
15
|
const catalogResult = await convertHookEntries(allHookEntries, resolvedRegistry, projectDir ?? ".");
|
|
16
|
-
// If there are errors, return base config with catalogErrors attached
|
|
17
|
-
if (catalogResult.errors.length > 0) {
|
|
18
|
-
return { ...base, catalogErrors: catalogResult.errors };
|
|
19
|
-
}
|
|
20
16
|
// Convert hooksConfig entries from catalog into HookDefinition format.
|
|
17
|
+
// Errors are reported as warnings but don't block valid hooks.
|
|
21
18
|
const additionalPreToolUse = [];
|
|
22
19
|
const additionalPostToolUse = [];
|
|
23
20
|
for (const [event, entries] of Object.entries(catalogResult.hooksConfig)) {
|
|
@@ -45,5 +42,6 @@ export async function harnessToMergedConfigV2(harness, registry, projectDir) {
|
|
|
45
42
|
preToolUse: [...base.hooks.preToolUse, ...additionalPreToolUse],
|
|
46
43
|
postToolUse: [...base.hooks.postToolUse, ...additionalPostToolUse],
|
|
47
44
|
},
|
|
45
|
+
...(catalogResult.errors.length > 0 ? { catalogErrors: catalogResult.errors } : {}),
|
|
48
46
|
};
|
|
49
47
|
}
|
|
@@ -43,6 +43,44 @@ export const nodeDetector = {
|
|
|
43
43
|
if (!(await fileExists(packageJsonPath))) {
|
|
44
44
|
return {};
|
|
45
45
|
}
|
|
46
|
+
// Check if this is a real JS/TS project or just tooling (husky, linter wrappers)
|
|
47
|
+
const pkgCheck = await readJsonFile(packageJsonPath);
|
|
48
|
+
const deps = pkgCheck && typeof pkgCheck.dependencies === "object" && pkgCheck.dependencies !== null
|
|
49
|
+
? Object.keys(pkgCheck.dependencies)
|
|
50
|
+
: [];
|
|
51
|
+
const devDeps = pkgCheck && typeof pkgCheck.devDependencies === "object" && pkgCheck.devDependencies !== null
|
|
52
|
+
? Object.keys(pkgCheck.devDependencies)
|
|
53
|
+
: [];
|
|
54
|
+
const pkgScriptsRaw = pkgCheck && typeof pkgCheck.scripts === "object" && pkgCheck.scripts !== null
|
|
55
|
+
? Object.values(pkgCheck.scripts).join(" ")
|
|
56
|
+
: "";
|
|
57
|
+
const jsDevIndicators = /typescript|react|vue|angular|next|vite|webpack|eslint|jest|vitest|mocha|babel|svelte|nuxt/;
|
|
58
|
+
const jsScriptIndicators = /vitest|jest|mocha|tsc|eslint|webpack|vite|next|react-scripts/;
|
|
59
|
+
// Also check for JS ecosystem config files
|
|
60
|
+
const jsConfigFiles = [
|
|
61
|
+
"tsconfig.json", ".eslintrc", ".eslintrc.js", ".eslintrc.json",
|
|
62
|
+
"eslint.config.js", "eslint.config.ts", "eslint.config.mjs",
|
|
63
|
+
"biome.json", "next.config.js", "next.config.ts", "next.config.mjs",
|
|
64
|
+
"vite.config.ts", "vite.config.js", "webpack.config.js",
|
|
65
|
+
];
|
|
66
|
+
let hasJsConfigFile = false;
|
|
67
|
+
for (const cf of jsConfigFiles) {
|
|
68
|
+
if (await fileExists(path.join(projectDir, cf))) {
|
|
69
|
+
hasJsConfigFile = true;
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const hasWorkspaces = pkgCheck && (Array.isArray(pkgCheck.workspaces) || typeof pkgCheck.workspaces === "object");
|
|
74
|
+
const hasPackageManager = pkgCheck && typeof pkgCheck.packageManager === "string";
|
|
75
|
+
const hasJsIndicator = deps.length > 0
|
|
76
|
+
|| devDeps.some((d) => jsDevIndicators.test(d))
|
|
77
|
+
|| jsScriptIndicators.test(pkgScriptsRaw)
|
|
78
|
+
|| hasJsConfigFile
|
|
79
|
+
|| hasWorkspaces
|
|
80
|
+
|| hasPackageManager;
|
|
81
|
+
if (!hasJsIndicator) {
|
|
82
|
+
return {};
|
|
83
|
+
}
|
|
46
84
|
const detectedFiles = ["package.json"];
|
|
47
85
|
const languages = [];
|
|
48
86
|
const frameworks = [];
|