oh-my-harness 0.7.0 → 0.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.
Files changed (53) hide show
  1. package/dist/catalog/blocks/compact-context.d.ts +2 -0
  2. package/dist/catalog/blocks/compact-context.js +27 -0
  3. package/dist/catalog/blocks/config-audit.d.ts +2 -0
  4. package/dist/catalog/blocks/config-audit.js +29 -0
  5. package/dist/catalog/blocks/desktop-notify.d.ts +2 -0
  6. package/dist/catalog/blocks/desktop-notify.js +21 -0
  7. package/dist/catalog/blocks/index.js +12 -0
  8. package/dist/catalog/blocks/sql-guard.d.ts +2 -0
  9. package/dist/catalog/blocks/sql-guard.js +34 -0
  10. package/dist/catalog/blocks/tdd-guard.js +5 -2
  11. package/dist/catalog/blocks/test-on-save.d.ts +2 -0
  12. package/dist/catalog/blocks/test-on-save.js +37 -0
  13. package/dist/catalog/blocks/worktree-setup.d.ts +2 -0
  14. package/dist/catalog/blocks/worktree-setup.js +91 -0
  15. package/dist/catalog/converter.js +5 -7
  16. package/dist/catalog/types.d.ts +8 -8
  17. package/dist/catalog/types.js +2 -2
  18. package/dist/cli/commands/catalog.js +1 -0
  19. package/dist/cli/commands/doctor.d.ts +1 -0
  20. package/dist/cli/commands/doctor.js +2 -1
  21. package/dist/cli/commands/init.js +11 -1
  22. package/dist/cli/index.js +4 -1
  23. package/dist/cli/stats/index.js +4 -0
  24. package/dist/cli/tui/init-flow.js +15 -3
  25. package/dist/cli/tui/provider-setup.d.ts +2 -0
  26. package/dist/cli/tui/provider-setup.js +85 -0
  27. package/dist/core/config-merger.js +28 -0
  28. package/dist/core/harness-converter-v2.js +26 -13
  29. package/dist/core/harness-converter.js +4 -0
  30. package/dist/core/preset-types.d.ts +368 -0
  31. package/dist/core/preset-types.js +4 -0
  32. package/dist/detector/detectors/index.js +2 -0
  33. package/dist/detector/detectors/node.js +1 -1
  34. package/dist/detector/detectors/terraform.d.ts +2 -0
  35. package/dist/detector/detectors/terraform.js +46 -0
  36. package/dist/generators/hooks.js +17 -5
  37. package/dist/generators/settings.js +21 -1
  38. package/dist/nl/config-store.d.ts +11 -0
  39. package/dist/nl/config-store.js +33 -0
  40. package/dist/nl/parse-intent.d.ts +8 -2
  41. package/dist/nl/parse-intent.js +17 -30
  42. package/dist/nl/provider-registry.d.ts +22 -0
  43. package/dist/nl/provider-registry.js +90 -0
  44. package/dist/nl/providers/claude-api.d.ts +2 -0
  45. package/dist/nl/providers/claude-api.js +42 -0
  46. package/dist/nl/providers/claude-cli.d.ts +2 -0
  47. package/dist/nl/providers/claude-cli.js +40 -0
  48. package/dist/nl/providers/gemini-api.d.ts +2 -0
  49. package/dist/nl/providers/gemini-api.js +44 -0
  50. package/dist/nl/providers/openai-api.d.ts +2 -0
  51. package/dist/nl/providers/openai-api.js +41 -0
  52. package/package.json +1 -1
  53. package/presets/terraform/preset.yaml +65 -0
@@ -0,0 +1,2 @@
1
+ import type { BuildingBlock } from "../types.js";
2
+ export declare const compactContext: BuildingBlock;
@@ -0,0 +1,27 @@
1
+ export const compactContext = {
2
+ id: "compact-context",
3
+ name: "Compact Context",
4
+ description: "Re-injects project context after context compaction",
5
+ category: "automation",
6
+ event: "SessionStart",
7
+ matcher: "compact",
8
+ canBlock: false,
9
+ params: [
10
+ {
11
+ name: "contextFile",
12
+ type: "string",
13
+ description: "Path to the context file to re-inject",
14
+ default: "CLAUDE.md",
15
+ required: false,
16
+ },
17
+ ],
18
+ tags: ["context", "compaction", "session", "automation"],
19
+ template: `#!/bin/bash
20
+ set -euo pipefail
21
+ INPUT=$(cat)
22
+ CONTEXT_FILE="{{{contextFile}}}"
23
+ if [[ -f "$CONTEXT_FILE" ]]; then
24
+ cat "$CONTEXT_FILE"
25
+ fi
26
+ exit 0`,
27
+ };
@@ -0,0 +1,2 @@
1
+ import type { BuildingBlock } from "../types.js";
2
+ export declare const configAudit: BuildingBlock;
@@ -0,0 +1,29 @@
1
+ export const configAudit = {
2
+ id: "config-audit",
3
+ name: "Config Audit",
4
+ description: "Logs configuration changes for audit trail",
5
+ category: "audit",
6
+ event: "ConfigChange",
7
+ matcher: "",
8
+ canBlock: false,
9
+ params: [
10
+ {
11
+ name: "logFile",
12
+ type: "string",
13
+ description: "Path to the audit log file",
14
+ default: ".claude/hooks/.state/config-audit.log",
15
+ required: false,
16
+ },
17
+ ],
18
+ tags: ["audit", "config", "logging"],
19
+ template: `#!/bin/bash
20
+ set -euo pipefail
21
+ INPUT=$(cat)
22
+ LOG_FILE="{{{logFile}}}"
23
+ mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true
24
+ SOURCE=$(echo "$INPUT" | jq -r '.source // "unknown"' 2>/dev/null)
25
+ FILE=$(echo "$INPUT" | jq -r '.file_path // "unknown"' 2>/dev/null)
26
+ TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
27
+ echo "{\\"ts\\":\\"$TS\\",\\"source\\":\\"$SOURCE\\",\\"file\\":\\"$FILE\\"}" >> "$LOG_FILE"
28
+ exit 0`,
29
+ };
@@ -0,0 +1,2 @@
1
+ import type { BuildingBlock } from "../types.js";
2
+ export declare const desktopNotify: BuildingBlock;
@@ -0,0 +1,21 @@
1
+ export const desktopNotify = {
2
+ id: "desktop-notify",
3
+ name: "Desktop Notify",
4
+ description: "Sends desktop notification when Claude needs attention",
5
+ category: "notification",
6
+ event: "Notification",
7
+ matcher: "",
8
+ canBlock: false,
9
+ params: [],
10
+ tags: ["notification", "desktop", "alert"],
11
+ template: `#!/bin/bash
12
+ set -euo pipefail
13
+ INPUT=$(cat)
14
+ MESSAGE=$(echo "$INPUT" | jq -r '.message // "Claude Code needs your attention"' 2>/dev/null)
15
+ if [[ "$(uname)" == "Darwin" ]]; then
16
+ osascript -e "display notification \\"$MESSAGE\\" with title \\"Claude Code\\"" 2>/dev/null || true
17
+ elif command -v notify-send &>/dev/null; then
18
+ notify-send "Claude Code" "$MESSAGE" 2>/dev/null || true
19
+ fi
20
+ exit 0`,
21
+ };
@@ -9,6 +9,12 @@ import { formatOnSave } from "./format-on-save.js";
9
9
  import { autoPr } from "./auto-pr.js";
10
10
  import { secretFileGuard } from "./secret-file-guard.js";
11
11
  import { tddGuard } from "./tdd-guard.js";
12
+ import { sqlGuard } from "./sql-guard.js";
13
+ import { testOnSave } from "./test-on-save.js";
14
+ import { desktopNotify } from "./desktop-notify.js";
15
+ import { configAudit } from "./config-audit.js";
16
+ import { compactContext } from "./compact-context.js";
17
+ import { worktreeSetup } from "./worktree-setup.js";
12
18
  export const builtinBlocks = [
13
19
  branchGuard,
14
20
  commitTestGate,
@@ -21,4 +27,10 @@ export const builtinBlocks = [
21
27
  autoPr,
22
28
  secretFileGuard,
23
29
  tddGuard,
30
+ sqlGuard,
31
+ testOnSave,
32
+ desktopNotify,
33
+ configAudit,
34
+ compactContext,
35
+ worktreeSetup,
24
36
  ];
@@ -0,0 +1,2 @@
1
+ import type { BuildingBlock } from "../types.js";
2
+ export declare const sqlGuard: BuildingBlock;
@@ -0,0 +1,34 @@
1
+ export const sqlGuard = {
2
+ id: "sql-guard",
3
+ name: "SQL Guard",
4
+ description: "Blocks dangerous SQL commands in shell",
5
+ category: "security",
6
+ event: "PreToolUse",
7
+ matcher: "Bash",
8
+ canBlock: true,
9
+ params: [
10
+ {
11
+ name: "patterns",
12
+ type: "string[]",
13
+ description: "Dangerous SQL patterns to block",
14
+ default: ["DROP TABLE", "DROP DATABASE", "TRUNCATE TABLE", "DELETE FROM", "DROP COLUMN", "DROP INDEX"],
15
+ required: true,
16
+ },
17
+ ],
18
+ tags: ["security", "sql", "guard", "database"],
19
+ template: `#!/bin/bash
20
+ set -euo pipefail
21
+ INPUT=$(cat)
22
+ COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
23
+ [[ -z "$COMMAND" ]] && exit 0
24
+ COMMAND_LOWER=$(echo "$COMMAND" | tr '[:upper:]' '[:lower:]')
25
+ PATTERNS=({{#each patterns}}"{{{this}}}" {{/each}})
26
+ for PATTERN in "\${PATTERNS[@]}"; do
27
+ PATTERN_LOWER=$(echo "$PATTERN" | tr '[:upper:]' '[:lower:]')
28
+ if echo "$COMMAND_LOWER" | grep -qF -- "$PATTERN_LOWER"; then
29
+ echo "{\\"decision\\": \\"block\\", \\"reason\\": \\"oh-my-harness: SQL command matches blocked pattern: $PATTERN\\"}"
30
+ exit 0
31
+ fi
32
+ done
33
+ exit 0`,
34
+ };
@@ -72,8 +72,11 @@ fi
72
72
 
73
73
  # edit-history에서 테스트 파일 검색
74
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
75
- # 테스트 먼저 수정됨 → 소스 기록 + 통과
76
- UPDATED=$(jq --arg f "\$FILE_PATH" '.edits += [$f] | .edits |= unique' "\$HISTORY_FILE" 2>/dev/null) || true
75
+ # 테스트 먼저 수정됨 → 매칭 테스트 기록 소비(제거) + 소스 기록 + 통과
76
+ UPDATED=$(jq --arg b "\$BASENAME" --arg f "\$FILE_PATH" '
77
+ .edits |= [.[] | select((contains($b) and (contains(".test.") or contains(".spec.") or contains("test_"))) | not)]
78
+ | .edits += [$f] | .edits |= unique
79
+ ' "\$HISTORY_FILE" 2>/dev/null) || true
77
80
  if [[ -n "\$UPDATED" ]]; then
78
81
  echo "\$UPDATED" > "\$HISTORY_FILE"
79
82
  fi
@@ -0,0 +1,2 @@
1
+ import type { BuildingBlock } from "../types.js";
2
+ export declare const testOnSave: BuildingBlock;
@@ -0,0 +1,37 @@
1
+ export const testOnSave = {
2
+ id: "test-on-save",
3
+ name: "Test on Save",
4
+ description: "Runs related tests after source file edits",
5
+ category: "quality",
6
+ event: "PostToolUse",
7
+ matcher: "Edit|Write",
8
+ canBlock: false,
9
+ params: [
10
+ {
11
+ name: "testCommand",
12
+ type: "string",
13
+ description: "Command to run tests (e.g. npx vitest run)",
14
+ required: true,
15
+ },
16
+ {
17
+ name: "filePattern",
18
+ type: "string",
19
+ description: "Regex pattern to match source files",
20
+ default: "\\.(ts|tsx|js|jsx|py)$",
21
+ required: false,
22
+ },
23
+ ],
24
+ tags: ["quality", "testing", "auto-run", "save"],
25
+ template: `#!/bin/bash
26
+ set -euo pipefail
27
+ INPUT=$(cat)
28
+ FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty' 2>/dev/null)
29
+ [[ -z "$FILE_PATH" ]] && exit 0
30
+ FILE_RE='{{{filePattern}}}'
31
+ FILE_RE="\${FILE_RE//\\\\\\\\/\\\\}"
32
+ if [[ "$FILE_PATH" =~ $FILE_RE ]]; then
33
+ echo "oh-my-harness: Running tests after edit..." >&2
34
+ {{{testCommand}}} >&2 2>&1 || true
35
+ fi
36
+ exit 0`,
37
+ };
@@ -0,0 +1,2 @@
1
+ import type { BuildingBlock } from "../types.js";
2
+ export declare const worktreeSetup: BuildingBlock;
@@ -0,0 +1,91 @@
1
+ export const worktreeSetup = {
2
+ id: "worktree-setup",
3
+ name: "Worktree Setup",
4
+ description: "Initializes new git worktrees with symlinked dependencies and copied config files",
5
+ category: "automation",
6
+ event: "WorktreeCreate",
7
+ matcher: "",
8
+ canBlock: false,
9
+ params: [
10
+ {
11
+ name: "symlinkPaths",
12
+ type: "string[]",
13
+ description: "Paths to symlink from main worktree (heavy dependencies)",
14
+ required: false,
15
+ default: ["node_modules", ".venv", "vendor", "target", ".build"],
16
+ },
17
+ {
18
+ name: "copyPaths",
19
+ type: "string[]",
20
+ description: "Paths to hard-copy from main worktree (config files)",
21
+ required: false,
22
+ default: [".env", ".env.local"],
23
+ },
24
+ {
25
+ name: "installCommand",
26
+ type: "string",
27
+ description: "Package install command to run if symlink source missing",
28
+ required: false,
29
+ default: "",
30
+ },
31
+ ],
32
+ tags: ["worktree", "git", "dependencies", "symlink", "automation"],
33
+ template: `#!/bin/bash
34
+ set -euo pipefail
35
+ INPUT=$(cat)
36
+
37
+ # Parse worktree info from stdin
38
+ WORKTREE_PATH=$(echo "$INPUT" | jq -r '.worktree_path // empty' 2>/dev/null)
39
+ if [[ -z "$WORKTREE_PATH" ]]; then
40
+ echo "oh-my-harness: worktree-setup — no worktree_path in input" >&2
41
+ exit 0
42
+ fi
43
+
44
+ # Find the main worktree (parent repo)
45
+ MAIN_WORKTREE=$(git -C "$WORKTREE_PATH" worktree list --porcelain 2>/dev/null | head -1 | sed 's/^worktree //')
46
+ if [[ -z "$MAIN_WORKTREE" ]]; then
47
+ echo "oh-my-harness: worktree-setup — cannot determine main worktree" >&2
48
+ exit 0
49
+ fi
50
+
51
+ echo "oh-my-harness: worktree-setup — setting up $WORKTREE_PATH"
52
+
53
+ # Symlink heavy directories from main worktree
54
+ SYMLINKS=({{#each symlinkPaths}}"{{{this}}}" {{/each}})
55
+ for rel in "\${SYMLINKS[@]}"; do
56
+ [[ -z "$rel" ]] && continue
57
+ src="$MAIN_WORKTREE/$rel"
58
+ dst="$WORKTREE_PATH/$rel"
59
+ if [[ -e "$src" && ! -e "$dst" ]]; then
60
+ mkdir -p "$(dirname "$dst")"
61
+ ln -s "$src" "$dst"
62
+ echo " symlinked: $rel"
63
+ fi
64
+ done
65
+
66
+ # Hard-copy config files from main worktree
67
+ COPIES=({{#each copyPaths}}"{{{this}}}" {{/each}})
68
+ for rel in "\${COPIES[@]}"; do
69
+ [[ -z "$rel" ]] && continue
70
+ src="$MAIN_WORKTREE/$rel"
71
+ dst="$WORKTREE_PATH/$rel"
72
+ if [[ -f "$src" && ! -f "$dst" ]]; then
73
+ mkdir -p "$(dirname "$dst")"
74
+ cp "$src" "$dst"
75
+ echo " copied: $rel"
76
+ fi
77
+ done
78
+
79
+ # Run install command if provided and no symlinked deps exist
80
+ INSTALL_CMD="{{{installCommand}}}"
81
+ if [[ -n "$INSTALL_CMD" ]]; then
82
+ first_symlink="\${SYMLINKS[0]}"
83
+ if [[ -n "$first_symlink" && ! -e "$WORKTREE_PATH/$first_symlink" ]]; then
84
+ echo " running: $INSTALL_CMD"
85
+ cd "$WORKTREE_PATH" && eval "$INSTALL_CMD"
86
+ fi
87
+ fi
88
+
89
+ echo "oh-my-harness: worktree-setup complete"
90
+ exit 0`,
91
+ };
@@ -4,7 +4,7 @@ export async function convertHookEntries(entries, registry, projectDir) {
4
4
  const hooksConfig = {};
5
5
  const scripts = new Map();
6
6
  const errors = [];
7
- const seenBlockIds = new Set();
7
+ const blockInstanceCount = new Map();
8
8
  for (const entry of entries) {
9
9
  const block = registry.get(entry.block);
10
10
  if (!block) {
@@ -17,11 +17,6 @@ export async function convertHookEntries(entries, registry, projectDir) {
17
17
  errors.push(...paramErrors);
18
18
  continue;
19
19
  }
20
- if (seenBlockIds.has(entry.block)) {
21
- errors.push(`Duplicate block id skipped: "${entry.block}"`);
22
- continue;
23
- }
24
- seenBlockIds.add(entry.block);
25
20
  let scriptContent;
26
21
  try {
27
22
  scriptContent = renderTemplate(block.template, resolvedParams);
@@ -30,7 +25,10 @@ export async function convertHookEntries(entries, registry, projectDir) {
30
25
  errors.push(`Failed to render block "${entry.block}": ${err.message}`);
31
26
  continue;
32
27
  }
33
- const scriptName = `${entry.block}.sh`;
28
+ // Support multiple instances of the same block with different params
29
+ const count = blockInstanceCount.get(entry.block) ?? 0;
30
+ blockInstanceCount.set(entry.block, count + 1);
31
+ const scriptName = count === 0 ? `${entry.block}.sh` : `${entry.block}-${count}.sh`;
34
32
  const scriptPath = path.join(projectDir, ".claude", "hooks", scriptName);
35
33
  scripts.set(scriptPath, scriptContent);
36
34
  const hookEntry = {
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
- export type HookEvent = "PreToolUse" | "PostToolUse" | "PreCompact" | "PostCompact" | "Notification" | "Stop" | "SubagentStop" | "PreBash" | "PostBash" | "PreEdit" | "PostEdit" | "PreRead" | "PostRead" | "PreWrite" | "PostWrite" | "SessionStart" | "SessionEnd" | "PreToolResult" | "PostToolResult" | "UserPromptSubmit";
3
- export type BuildingBlockCategory = "git" | "quality" | "security" | "notification" | "formatting" | "custom" | "auto-fix" | "automation" | "file-protection";
2
+ export type HookEvent = "PreToolUse" | "PostToolUse" | "PreCompact" | "PostCompact" | "Notification" | "Stop" | "SubagentStop" | "PreBash" | "PostBash" | "PreEdit" | "PostEdit" | "PreRead" | "PostRead" | "PreWrite" | "PostWrite" | "SessionStart" | "SessionEnd" | "PreToolResult" | "PostToolResult" | "UserPromptSubmit" | "ConfigChange" | "WorktreeCreate" | "WorktreeRemove";
3
+ export type BuildingBlockCategory = "git" | "quality" | "security" | "notification" | "formatting" | "custom" | "auto-fix" | "automation" | "file-protection" | "audit";
4
4
  export interface ParamDefinition {
5
5
  name: string;
6
6
  type: "string" | "boolean" | "number" | "string[]";
@@ -47,8 +47,8 @@ export declare const BuildingBlockSchema: z.ZodObject<{
47
47
  id: z.ZodString;
48
48
  name: z.ZodString;
49
49
  description: z.ZodString;
50
- category: z.ZodEnum<["git", "quality", "security", "notification", "formatting", "custom", "auto-fix", "automation", "file-protection"]>;
51
- event: z.ZodEnum<["PreToolUse", "PostToolUse", "PreCompact", "PostCompact", "Notification", "Stop", "SubagentStop", "PreBash", "PostBash", "PreEdit", "PostEdit", "PreRead", "PostRead", "PreWrite", "PostWrite", "SessionStart", "SessionEnd", "PreToolResult", "PostToolResult", "UserPromptSubmit"]>;
50
+ category: z.ZodEnum<["git", "quality", "security", "notification", "formatting", "custom", "auto-fix", "automation", "file-protection", "audit"]>;
51
+ event: z.ZodEnum<["PreToolUse", "PostToolUse", "PreCompact", "PostCompact", "Notification", "Stop", "SubagentStop", "PreBash", "PostBash", "PreEdit", "PostEdit", "PreRead", "PostRead", "PreWrite", "PostWrite", "SessionStart", "SessionEnd", "PreToolResult", "PostToolResult", "UserPromptSubmit", "ConfigChange", "WorktreeCreate", "WorktreeRemove"]>;
52
52
  matcher: z.ZodOptional<z.ZodString>;
53
53
  canBlock: z.ZodBoolean;
54
54
  params: z.ZodArray<z.ZodObject<{
@@ -85,8 +85,8 @@ export declare const BuildingBlockSchema: z.ZodObject<{
85
85
  template: string;
86
86
  name: string;
87
87
  tags: string[];
88
- category: "custom" | "git" | "quality" | "security" | "notification" | "formatting" | "auto-fix" | "automation" | "file-protection";
89
- event: "PreToolUse" | "PostToolUse" | "PreCompact" | "PostCompact" | "Notification" | "Stop" | "SubagentStop" | "PreBash" | "PostBash" | "PreEdit" | "PostEdit" | "PreRead" | "PostRead" | "PreWrite" | "PostWrite" | "SessionStart" | "SessionEnd" | "PreToolResult" | "PostToolResult" | "UserPromptSubmit";
88
+ event: "PreToolUse" | "PostToolUse" | "SessionStart" | "Notification" | "ConfigChange" | "WorktreeCreate" | "PreCompact" | "PostCompact" | "Stop" | "SubagentStop" | "PreBash" | "PostBash" | "PreEdit" | "PostEdit" | "PreRead" | "PostRead" | "PreWrite" | "PostWrite" | "SessionEnd" | "PreToolResult" | "PostToolResult" | "UserPromptSubmit" | "WorktreeRemove";
89
+ category: "notification" | "custom" | "git" | "quality" | "security" | "formatting" | "auto-fix" | "automation" | "file-protection" | "audit";
90
90
  canBlock: boolean;
91
91
  matcher?: string | undefined;
92
92
  }, {
@@ -102,8 +102,8 @@ export declare const BuildingBlockSchema: z.ZodObject<{
102
102
  template: string;
103
103
  name: string;
104
104
  tags: string[];
105
- category: "custom" | "git" | "quality" | "security" | "notification" | "formatting" | "auto-fix" | "automation" | "file-protection";
106
- event: "PreToolUse" | "PostToolUse" | "PreCompact" | "PostCompact" | "Notification" | "Stop" | "SubagentStop" | "PreBash" | "PostBash" | "PreEdit" | "PostEdit" | "PreRead" | "PostRead" | "PreWrite" | "PostWrite" | "SessionStart" | "SessionEnd" | "PreToolResult" | "PostToolResult" | "UserPromptSubmit";
105
+ event: "PreToolUse" | "PostToolUse" | "SessionStart" | "Notification" | "ConfigChange" | "WorktreeCreate" | "PreCompact" | "PostCompact" | "Stop" | "SubagentStop" | "PreBash" | "PostBash" | "PreEdit" | "PostEdit" | "PreRead" | "PostRead" | "PreWrite" | "PostWrite" | "SessionEnd" | "PreToolResult" | "PostToolResult" | "UserPromptSubmit" | "WorktreeRemove";
106
+ category: "notification" | "custom" | "git" | "quality" | "security" | "formatting" | "auto-fix" | "automation" | "file-protection" | "audit";
107
107
  canBlock: boolean;
108
108
  matcher?: string | undefined;
109
109
  }>;
@@ -10,8 +10,8 @@ export const BuildingBlockSchema = z.object({
10
10
  id: z.string(),
11
11
  name: z.string(),
12
12
  description: z.string(),
13
- category: z.enum(["git", "quality", "security", "notification", "formatting", "custom", "auto-fix", "automation", "file-protection"]),
14
- event: z.enum(["PreToolUse", "PostToolUse", "PreCompact", "PostCompact", "Notification", "Stop", "SubagentStop", "PreBash", "PostBash", "PreEdit", "PostEdit", "PreRead", "PostRead", "PreWrite", "PostWrite", "SessionStart", "SessionEnd", "PreToolResult", "PostToolResult", "UserPromptSubmit"]),
13
+ category: z.enum(["git", "quality", "security", "notification", "formatting", "custom", "auto-fix", "automation", "file-protection", "audit"]),
14
+ event: z.enum(["PreToolUse", "PostToolUse", "PreCompact", "PostCompact", "Notification", "Stop", "SubagentStop", "PreBash", "PostBash", "PreEdit", "PostEdit", "PreRead", "PostRead", "PreWrite", "PostWrite", "SessionStart", "SessionEnd", "PreToolResult", "PostToolResult", "UserPromptSubmit", "ConfigChange", "WorktreeCreate", "WorktreeRemove"]),
15
15
  matcher: z.string().optional(),
16
16
  canBlock: z.boolean(),
17
17
  params: z.array(ParamDefinitionSchema),
@@ -11,6 +11,7 @@ const CATEGORY_EMOJI = {
11
11
  notification: "🔔",
12
12
  prompt: "💬",
13
13
  permission: "🔐",
14
+ audit: "📝",
14
15
  };
15
16
  function categoryLabel(category) {
16
17
  const emoji = CATEGORY_EMOJI[category] ?? "•";
@@ -3,6 +3,7 @@ export interface DoctorOptions {
3
3
  }
4
4
  export interface DoctorResult {
5
5
  healthy: boolean;
6
+ exitCode: number;
6
7
  checks: {
7
8
  stateFile: boolean;
8
9
  claudeMd: boolean;
@@ -71,6 +71,7 @@ export async function doctorCommand(options = {}) {
71
71
  checks.hooksExecutable = true;
72
72
  }
73
73
  const healthy = Object.values(checks).every(Boolean);
74
+ const exitCode = healthy ? 0 : 1;
74
75
  if (healthy) {
75
76
  console.log("oh-my-harness: all checks passed");
76
77
  }
@@ -80,5 +81,5 @@ export async function doctorCommand(options = {}) {
80
81
  console.log(` ${msg}`);
81
82
  }
82
83
  }
83
- return { healthy, checks, messages };
84
+ return { healthy, exitCode, checks, messages };
84
85
  }
@@ -59,7 +59,17 @@ export async function initCommand(presetNames, options = {}) {
59
59
  const presetsDir = options.presetsDir ?? getDefaultPresetsDir();
60
60
  // If --preset flag is used, go through preset flow
61
61
  if (options.preset && options.preset.length > 0) {
62
- await initWithPresets(options.preset, projectDir, presetsDir, options);
62
+ try {
63
+ await initWithPresets(options.preset, projectDir, presetsDir, options);
64
+ }
65
+ catch (err) {
66
+ const message = err instanceof Error ? err.message : String(err);
67
+ console.error(`oh-my-harness: ${message}`);
68
+ if (!message.includes("Preset not found")) {
69
+ console.error("Run `oh-my-harness init --help` for usage information.");
70
+ }
71
+ process.exitCode = 1;
72
+ }
63
73
  return;
64
74
  }
65
75
  // If preset names are provided as positional args, use legacy flow
package/dist/cli/index.js CHANGED
@@ -36,7 +36,10 @@ export function createCli() {
36
36
  .description("Validate harness configuration health")
37
37
  .action(async () => {
38
38
  const { doctorCommand } = await import("./commands/doctor.js");
39
- await doctorCommand();
39
+ const result = await doctorCommand();
40
+ if (result.exitCode !== 0) {
41
+ process.exitCode = result.exitCode;
42
+ }
40
43
  });
41
44
  program
42
45
  .command("test")
@@ -3,6 +3,10 @@ import React from "react";
3
3
  import { loadStatsData } from "./data.js";
4
4
  import { App } from "./App.js";
5
5
  export async function statsCommand(options = {}) {
6
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
7
+ console.error("oh-my-harness: stats requires an interactive terminal (TTY). Run this command directly in your terminal.");
8
+ return;
9
+ }
6
10
  const projectDir = options.projectDir ?? process.cwd();
7
11
  const data = await loadStatsData(projectDir);
8
12
  const { waitUntilExit } = render(React.createElement(App, { initialData: data, projectDir }));
@@ -9,7 +9,9 @@ import { PresetRegistry } from "../../core/preset-registry.js";
9
9
  import { loadAndMergePresets, writeHarnessState } from "../commands/init.js";
10
10
  import { mergePresets } from "../../core/config-merger.js";
11
11
  import { generate } from "../../core/generator.js";
12
- import { generateHarnessConfig } from "../../nl/parse-intent.js";
12
+ import { generateHarnessConfig, createDefaultRunner } from "../../nl/parse-intent.js";
13
+ import { hasProviderConfig } from "../../nl/config-store.js";
14
+ import { runProviderSetup } from "./provider-setup.js";
13
15
  import { mergeEnforcementAndHooks } from "../../core/harness-converter.js";
14
16
  import { HarnessConfigSchema } from "../../core/harness-schema.js";
15
17
  import { detectProject } from "../../detector/project-detector.js";
@@ -187,7 +189,17 @@ export async function runInitTUI(options) {
187
189
  let harnessConfig;
188
190
  let presetNames;
189
191
  if (mode === "nl") {
190
- // Step 4a: NL Mode
192
+ // Step 4a: NL Mode — check provider config
193
+ const hasConfig = await hasProviderConfig();
194
+ if (!hasConfig) {
195
+ p.log.info("No AI provider configured yet. Let's set one up.");
196
+ const providerConfig = await runProviderSetup();
197
+ if (!providerConfig) {
198
+ p.cancel("Provider setup cancelled. Use preset mode instead.");
199
+ process.exit(0);
200
+ }
201
+ }
202
+ const runner = await createDefaultRunner();
191
203
  const description = await p.text({
192
204
  message: "Describe your project:",
193
205
  placeholder: "e.g., Next.js e-commerce app with Stripe and Tailwind",
@@ -208,7 +220,7 @@ export async function runInitTUI(options) {
208
220
  description: b.description,
209
221
  params: b.params.map((pp) => ({ name: pp.name, required: pp.required, default: pp.default, description: pp.description })),
210
222
  }));
211
- harnessConfig = await generateHarnessConfig(description, undefined, catalogBlocks, projectFacts);
223
+ harnessConfig = await generateHarnessConfig(description, runner, catalogBlocks, projectFacts);
212
224
  genSpinner.stop("Configuration generated");
213
225
  }
214
226
  catch (err) {
@@ -0,0 +1,2 @@
1
+ import { type ProviderConfig } from "../../nl/config-store.js";
2
+ export declare function runProviderSetup(): Promise<ProviderConfig | undefined>;
@@ -0,0 +1,85 @@
1
+ import * as p from "@clack/prompts";
2
+ import { getAvailableProviders, getProviderDefinition, } from "../../nl/provider-registry.js";
3
+ import { saveProviderConfig, } from "../../nl/config-store.js";
4
+ export async function runProviderSetup() {
5
+ p.intro("AI Provider Setup");
6
+ const providers = getAvailableProviders();
7
+ // Step 1: Select provider
8
+ const providerName = await p.select({
9
+ message: "Select AI provider for natural language mode:",
10
+ options: providers.map((prov) => ({
11
+ value: prov.name,
12
+ label: prov.displayName,
13
+ })),
14
+ });
15
+ if (p.isCancel(providerName)) {
16
+ p.cancel("Provider setup cancelled.");
17
+ return undefined;
18
+ }
19
+ const def = getProviderDefinition(providerName);
20
+ // Step 2: Select method (CLI or API)
21
+ let method;
22
+ if (def.supportsCli && def.supportsApi) {
23
+ const selected = await p.select({
24
+ message: "How would you like to connect?",
25
+ options: [
26
+ { value: "cli", label: `CLI tool (${def.cliCommand ?? def.name})` },
27
+ { value: "api", label: "API Key" },
28
+ ],
29
+ });
30
+ if (p.isCancel(selected)) {
31
+ p.cancel("Provider setup cancelled.");
32
+ return undefined;
33
+ }
34
+ method = selected;
35
+ }
36
+ else if (def.supportsCli) {
37
+ method = "cli";
38
+ }
39
+ else {
40
+ method = "api";
41
+ }
42
+ const config = {
43
+ provider: providerName,
44
+ method,
45
+ };
46
+ // Step 3: Get API key if needed
47
+ if (method === "api") {
48
+ const apiKey = await p.text({
49
+ message: `Enter your ${def.displayName} API key:`,
50
+ placeholder: "sk-...",
51
+ validate: (value) => {
52
+ if (!value || !value.trim())
53
+ return "API key is required";
54
+ return undefined;
55
+ },
56
+ });
57
+ if (p.isCancel(apiKey)) {
58
+ p.cancel("Provider setup cancelled.");
59
+ return undefined;
60
+ }
61
+ config.apiKey = apiKey;
62
+ // Select model from available list
63
+ const selectedModel = await p.select({
64
+ message: "Select model:",
65
+ options: def.availableModels.map((m) => ({
66
+ value: m.id,
67
+ label: m.label,
68
+ hint: m.id === def.defaultModel ? "default" : undefined,
69
+ })),
70
+ initialValue: def.defaultModel,
71
+ });
72
+ if (p.isCancel(selectedModel)) {
73
+ p.cancel("Provider setup cancelled.");
74
+ return undefined;
75
+ }
76
+ config.model = selectedModel;
77
+ }
78
+ else {
79
+ config.cliCommand = def.cliCommand ?? def.name;
80
+ }
81
+ // Save config
82
+ await saveProviderConfig(config);
83
+ p.log.success(`Provider saved: ${def.displayName} (${method})`);
84
+ return config;
85
+ }