@build-astron-co/nimbus 0.2.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 (313) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +628 -0
  3. package/bin/nimbus +38 -0
  4. package/package.json +80 -0
  5. package/src/__tests__/app.test.ts +76 -0
  6. package/src/__tests__/audit.test.ts +877 -0
  7. package/src/__tests__/circuit-breaker.test.ts +116 -0
  8. package/src/__tests__/cli-run.test.ts +115 -0
  9. package/src/__tests__/context-manager.test.ts +502 -0
  10. package/src/__tests__/context.test.ts +242 -0
  11. package/src/__tests__/enterprise.test.ts +401 -0
  12. package/src/__tests__/generator.test.ts +433 -0
  13. package/src/__tests__/hooks.test.ts +582 -0
  14. package/src/__tests__/init.test.ts +436 -0
  15. package/src/__tests__/intent-parser.test.ts +229 -0
  16. package/src/__tests__/llm-router.test.ts +209 -0
  17. package/src/__tests__/lsp.test.ts +293 -0
  18. package/src/__tests__/modes.test.ts +336 -0
  19. package/src/__tests__/permissions.test.ts +338 -0
  20. package/src/__tests__/serve.test.ts +275 -0
  21. package/src/__tests__/sessions.test.ts +227 -0
  22. package/src/__tests__/sharing.test.ts +288 -0
  23. package/src/__tests__/snapshots.test.ts +581 -0
  24. package/src/__tests__/state-db.test.ts +334 -0
  25. package/src/__tests__/stream-with-tools.test.ts +732 -0
  26. package/src/__tests__/subagents.test.ts +176 -0
  27. package/src/__tests__/system-prompt.test.ts +169 -0
  28. package/src/__tests__/tool-converter.test.ts +256 -0
  29. package/src/__tests__/tool-schemas.test.ts +397 -0
  30. package/src/__tests__/tools.test.ts +143 -0
  31. package/src/__tests__/version.test.ts +49 -0
  32. package/src/agent/compaction-agent.ts +227 -0
  33. package/src/agent/context-manager.ts +435 -0
  34. package/src/agent/context.ts +427 -0
  35. package/src/agent/deploy-preview.ts +426 -0
  36. package/src/agent/index.ts +68 -0
  37. package/src/agent/loop.ts +717 -0
  38. package/src/agent/modes.ts +429 -0
  39. package/src/agent/permissions.ts +466 -0
  40. package/src/agent/subagents/base.ts +116 -0
  41. package/src/agent/subagents/cost.ts +51 -0
  42. package/src/agent/subagents/explore.ts +42 -0
  43. package/src/agent/subagents/general.ts +54 -0
  44. package/src/agent/subagents/index.ts +102 -0
  45. package/src/agent/subagents/infra.ts +59 -0
  46. package/src/agent/subagents/security.ts +69 -0
  47. package/src/agent/system-prompt.ts +436 -0
  48. package/src/app.ts +122 -0
  49. package/src/audit/activity-log.ts +290 -0
  50. package/src/audit/compliance-checker.ts +540 -0
  51. package/src/audit/cost-tracker.ts +318 -0
  52. package/src/audit/index.ts +23 -0
  53. package/src/audit/security-scanner.ts +596 -0
  54. package/src/auth/guard.ts +75 -0
  55. package/src/auth/index.ts +56 -0
  56. package/src/auth/oauth.ts +455 -0
  57. package/src/auth/providers.ts +470 -0
  58. package/src/auth/sso.ts +113 -0
  59. package/src/auth/store.ts +505 -0
  60. package/src/auth/types.ts +187 -0
  61. package/src/build.ts +141 -0
  62. package/src/cli/index.ts +16 -0
  63. package/src/cli/init.ts +854 -0
  64. package/src/cli/openapi-spec.ts +356 -0
  65. package/src/cli/run.ts +237 -0
  66. package/src/cli/serve-auth.ts +80 -0
  67. package/src/cli/serve.ts +462 -0
  68. package/src/cli/web.ts +67 -0
  69. package/src/cli.ts +1417 -0
  70. package/src/clients/core-engine-client.ts +227 -0
  71. package/src/clients/enterprise-client.ts +334 -0
  72. package/src/clients/generator-client.ts +351 -0
  73. package/src/clients/git-client.ts +627 -0
  74. package/src/clients/github-client.ts +410 -0
  75. package/src/clients/helm-client.ts +504 -0
  76. package/src/clients/index.ts +80 -0
  77. package/src/clients/k8s-client.ts +497 -0
  78. package/src/clients/llm-client.ts +161 -0
  79. package/src/clients/rest-client.ts +130 -0
  80. package/src/clients/service-discovery.ts +33 -0
  81. package/src/clients/terraform-client.ts +482 -0
  82. package/src/clients/tools-client.ts +1843 -0
  83. package/src/clients/ws-client.ts +115 -0
  84. package/src/commands/analyze/index.ts +352 -0
  85. package/src/commands/apply/helm.ts +473 -0
  86. package/src/commands/apply/index.ts +213 -0
  87. package/src/commands/apply/k8s.ts +454 -0
  88. package/src/commands/apply/terraform.ts +582 -0
  89. package/src/commands/ask.ts +167 -0
  90. package/src/commands/audit/index.ts +238 -0
  91. package/src/commands/auth-cloud.ts +294 -0
  92. package/src/commands/auth-list.ts +134 -0
  93. package/src/commands/auth-profile.ts +121 -0
  94. package/src/commands/auth-status.ts +141 -0
  95. package/src/commands/aws/ec2.ts +501 -0
  96. package/src/commands/aws/iam.ts +397 -0
  97. package/src/commands/aws/index.ts +133 -0
  98. package/src/commands/aws/lambda.ts +396 -0
  99. package/src/commands/aws/rds.ts +439 -0
  100. package/src/commands/aws/s3.ts +439 -0
  101. package/src/commands/aws/vpc.ts +393 -0
  102. package/src/commands/aws-discover.ts +649 -0
  103. package/src/commands/aws-terraform.ts +805 -0
  104. package/src/commands/azure/aks.ts +376 -0
  105. package/src/commands/azure/functions.ts +253 -0
  106. package/src/commands/azure/index.ts +116 -0
  107. package/src/commands/azure/storage.ts +478 -0
  108. package/src/commands/azure/vm.ts +355 -0
  109. package/src/commands/billing/index.ts +256 -0
  110. package/src/commands/chat.ts +314 -0
  111. package/src/commands/config.ts +346 -0
  112. package/src/commands/cost/cloud-cost-estimator.ts +266 -0
  113. package/src/commands/cost/estimator.ts +79 -0
  114. package/src/commands/cost/index.ts +594 -0
  115. package/src/commands/cost/parsers/terraform.ts +273 -0
  116. package/src/commands/cost/parsers/types.ts +25 -0
  117. package/src/commands/cost/pricing/aws.ts +544 -0
  118. package/src/commands/cost/pricing/azure.ts +499 -0
  119. package/src/commands/cost/pricing/gcp.ts +396 -0
  120. package/src/commands/cost/pricing/index.ts +40 -0
  121. package/src/commands/demo.ts +250 -0
  122. package/src/commands/doctor.ts +794 -0
  123. package/src/commands/drift/index.ts +439 -0
  124. package/src/commands/explain.ts +277 -0
  125. package/src/commands/feedback.ts +389 -0
  126. package/src/commands/fix.ts +324 -0
  127. package/src/commands/fs/index.ts +402 -0
  128. package/src/commands/gcp/compute.ts +325 -0
  129. package/src/commands/gcp/functions.ts +271 -0
  130. package/src/commands/gcp/gke.ts +438 -0
  131. package/src/commands/gcp/iam.ts +344 -0
  132. package/src/commands/gcp/index.ts +129 -0
  133. package/src/commands/gcp/storage.ts +284 -0
  134. package/src/commands/generate-helm.ts +1249 -0
  135. package/src/commands/generate-k8s.ts +1560 -0
  136. package/src/commands/generate-terraform.ts +1460 -0
  137. package/src/commands/gh/index.ts +863 -0
  138. package/src/commands/git/index.ts +1343 -0
  139. package/src/commands/helm/index.ts +1126 -0
  140. package/src/commands/help.ts +539 -0
  141. package/src/commands/history.ts +142 -0
  142. package/src/commands/import.ts +868 -0
  143. package/src/commands/index.ts +367 -0
  144. package/src/commands/init.ts +1046 -0
  145. package/src/commands/k8s/index.ts +1137 -0
  146. package/src/commands/login.ts +631 -0
  147. package/src/commands/logout.ts +83 -0
  148. package/src/commands/onboarding.ts +228 -0
  149. package/src/commands/plan/display.ts +279 -0
  150. package/src/commands/plan/index.ts +599 -0
  151. package/src/commands/preview.ts +452 -0
  152. package/src/commands/questionnaire.ts +1270 -0
  153. package/src/commands/resume.ts +55 -0
  154. package/src/commands/team/index.ts +346 -0
  155. package/src/commands/template.ts +232 -0
  156. package/src/commands/tf/index.ts +1034 -0
  157. package/src/commands/upgrade.ts +550 -0
  158. package/src/commands/usage/index.ts +134 -0
  159. package/src/commands/version.ts +170 -0
  160. package/src/compat/index.ts +2 -0
  161. package/src/compat/runtime.ts +12 -0
  162. package/src/compat/sqlite.ts +107 -0
  163. package/src/config/index.ts +17 -0
  164. package/src/config/manager.ts +530 -0
  165. package/src/config/safety-policy.ts +358 -0
  166. package/src/config/schema.ts +125 -0
  167. package/src/config/types.ts +527 -0
  168. package/src/context/context-db.ts +199 -0
  169. package/src/demo/index.ts +349 -0
  170. package/src/demo/scenarios/full-journey.ts +229 -0
  171. package/src/demo/scenarios/getting-started.ts +127 -0
  172. package/src/demo/scenarios/helm-release.ts +341 -0
  173. package/src/demo/scenarios/k8s-deployment.ts +194 -0
  174. package/src/demo/scenarios/terraform-vpc.ts +170 -0
  175. package/src/demo/types.ts +92 -0
  176. package/src/engine/cost-estimator.ts +438 -0
  177. package/src/engine/diagram-generator.ts +256 -0
  178. package/src/engine/drift-detector.ts +902 -0
  179. package/src/engine/executor.ts +1035 -0
  180. package/src/engine/index.ts +76 -0
  181. package/src/engine/orchestrator.ts +636 -0
  182. package/src/engine/planner.ts +720 -0
  183. package/src/engine/safety.ts +743 -0
  184. package/src/engine/verifier.ts +770 -0
  185. package/src/enterprise/audit.ts +348 -0
  186. package/src/enterprise/auth.ts +270 -0
  187. package/src/enterprise/billing.ts +822 -0
  188. package/src/enterprise/index.ts +17 -0
  189. package/src/enterprise/teams.ts +443 -0
  190. package/src/generator/best-practices.ts +1608 -0
  191. package/src/generator/helm.ts +630 -0
  192. package/src/generator/index.ts +37 -0
  193. package/src/generator/intent-parser.ts +514 -0
  194. package/src/generator/kubernetes.ts +976 -0
  195. package/src/generator/terraform.ts +1867 -0
  196. package/src/history/index.ts +8 -0
  197. package/src/history/manager.ts +322 -0
  198. package/src/history/types.ts +34 -0
  199. package/src/hooks/config.ts +432 -0
  200. package/src/hooks/engine.ts +391 -0
  201. package/src/hooks/index.ts +4 -0
  202. package/src/llm/auth-bridge.ts +198 -0
  203. package/src/llm/circuit-breaker.ts +140 -0
  204. package/src/llm/config-loader.ts +201 -0
  205. package/src/llm/cost-calculator.ts +171 -0
  206. package/src/llm/index.ts +8 -0
  207. package/src/llm/model-aliases.ts +115 -0
  208. package/src/llm/provider-registry.ts +63 -0
  209. package/src/llm/providers/anthropic.ts +433 -0
  210. package/src/llm/providers/bedrock.ts +477 -0
  211. package/src/llm/providers/google.ts +405 -0
  212. package/src/llm/providers/ollama.ts +767 -0
  213. package/src/llm/providers/openai-compatible.ts +340 -0
  214. package/src/llm/providers/openai.ts +328 -0
  215. package/src/llm/providers/openrouter.ts +338 -0
  216. package/src/llm/router.ts +1035 -0
  217. package/src/llm/types.ts +232 -0
  218. package/src/lsp/client.ts +298 -0
  219. package/src/lsp/languages.ts +116 -0
  220. package/src/lsp/manager.ts +278 -0
  221. package/src/mcp/client.ts +402 -0
  222. package/src/mcp/index.ts +5 -0
  223. package/src/mcp/manager.ts +133 -0
  224. package/src/nimbus.ts +214 -0
  225. package/src/plugins/index.ts +27 -0
  226. package/src/plugins/loader.ts +334 -0
  227. package/src/plugins/manager.ts +376 -0
  228. package/src/plugins/types.ts +284 -0
  229. package/src/scanners/cicd-scanner.ts +258 -0
  230. package/src/scanners/cloud-scanner.ts +466 -0
  231. package/src/scanners/framework-scanner.ts +469 -0
  232. package/src/scanners/iac-scanner.ts +388 -0
  233. package/src/scanners/index.ts +539 -0
  234. package/src/scanners/language-scanner.ts +276 -0
  235. package/src/scanners/package-manager-scanner.ts +277 -0
  236. package/src/scanners/types.ts +172 -0
  237. package/src/sessions/manager.ts +365 -0
  238. package/src/sessions/types.ts +44 -0
  239. package/src/sharing/sync.ts +296 -0
  240. package/src/sharing/viewer.ts +97 -0
  241. package/src/snapshots/index.ts +2 -0
  242. package/src/snapshots/manager.ts +530 -0
  243. package/src/state/artifacts.ts +147 -0
  244. package/src/state/audit.ts +137 -0
  245. package/src/state/billing.ts +240 -0
  246. package/src/state/checkpoints.ts +117 -0
  247. package/src/state/config.ts +67 -0
  248. package/src/state/conversations.ts +14 -0
  249. package/src/state/credentials.ts +154 -0
  250. package/src/state/db.ts +58 -0
  251. package/src/state/index.ts +26 -0
  252. package/src/state/messages.ts +115 -0
  253. package/src/state/projects.ts +123 -0
  254. package/src/state/schema.ts +236 -0
  255. package/src/state/sessions.ts +147 -0
  256. package/src/state/teams.ts +200 -0
  257. package/src/telemetry.ts +108 -0
  258. package/src/tools/aws-ops.ts +952 -0
  259. package/src/tools/azure-ops.ts +579 -0
  260. package/src/tools/file-ops.ts +593 -0
  261. package/src/tools/gcp-ops.ts +625 -0
  262. package/src/tools/git-ops.ts +773 -0
  263. package/src/tools/github-ops.ts +799 -0
  264. package/src/tools/helm-ops.ts +943 -0
  265. package/src/tools/index.ts +17 -0
  266. package/src/tools/k8s-ops.ts +819 -0
  267. package/src/tools/schemas/converter.ts +184 -0
  268. package/src/tools/schemas/devops.ts +612 -0
  269. package/src/tools/schemas/index.ts +73 -0
  270. package/src/tools/schemas/standard.ts +1144 -0
  271. package/src/tools/schemas/types.ts +705 -0
  272. package/src/tools/terraform-ops.ts +862 -0
  273. package/src/types/ambient.d.ts +193 -0
  274. package/src/types/config.ts +83 -0
  275. package/src/types/drift.ts +116 -0
  276. package/src/types/enterprise.ts +335 -0
  277. package/src/types/index.ts +20 -0
  278. package/src/types/plan.ts +44 -0
  279. package/src/types/request.ts +65 -0
  280. package/src/types/response.ts +54 -0
  281. package/src/types/service.ts +51 -0
  282. package/src/ui/App.tsx +997 -0
  283. package/src/ui/DeployPreview.tsx +169 -0
  284. package/src/ui/Header.tsx +68 -0
  285. package/src/ui/InputBox.tsx +350 -0
  286. package/src/ui/MessageList.tsx +585 -0
  287. package/src/ui/PermissionPrompt.tsx +151 -0
  288. package/src/ui/StatusBar.tsx +158 -0
  289. package/src/ui/ToolCallDisplay.tsx +409 -0
  290. package/src/ui/chat-ui.ts +853 -0
  291. package/src/ui/index.ts +33 -0
  292. package/src/ui/ink/index.ts +711 -0
  293. package/src/ui/streaming.ts +176 -0
  294. package/src/ui/types.ts +57 -0
  295. package/src/utils/analytics.ts +72 -0
  296. package/src/utils/cost-warning.ts +27 -0
  297. package/src/utils/env.ts +46 -0
  298. package/src/utils/errors.ts +69 -0
  299. package/src/utils/event-bus.ts +38 -0
  300. package/src/utils/index.ts +24 -0
  301. package/src/utils/logger.ts +171 -0
  302. package/src/utils/rate-limiter.ts +121 -0
  303. package/src/utils/service-auth.ts +49 -0
  304. package/src/utils/validation.ts +53 -0
  305. package/src/version.ts +4 -0
  306. package/src/watcher/index.ts +163 -0
  307. package/src/wizard/approval.ts +383 -0
  308. package/src/wizard/index.ts +25 -0
  309. package/src/wizard/prompts.ts +338 -0
  310. package/src/wizard/types.ts +171 -0
  311. package/src/wizard/ui.ts +556 -0
  312. package/src/wizard/wizard.ts +304 -0
  313. package/tsconfig.json +24 -0
@@ -0,0 +1,582 @@
1
+ /**
2
+ * Hooks System Tests
3
+ *
4
+ * Validates the hooks configuration loader, hook definition validation,
5
+ * the HookEngine class (matching, execution), and the convenience
6
+ * functions (runPreToolHooks, runPostToolHooks, runPermissionHooks).
7
+ *
8
+ * Tests that require actual script execution use temporary directories
9
+ * with real hook scripts and hooks.yaml configuration files.
10
+ */
11
+
12
+ import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
13
+ import * as fs from 'node:fs';
14
+ import * as path from 'node:path';
15
+ import * as os from 'node:os';
16
+
17
+ import {
18
+ loadHooksConfig,
19
+ validateHookDefinition,
20
+ DEFAULT_HOOK_TIMEOUT,
21
+ type HookDefinition,
22
+ } from '../hooks/config';
23
+ import {
24
+ HookEngine,
25
+ runPreToolHooks,
26
+ runPostToolHooks,
27
+ runPermissionHooks,
28
+ type HookContext,
29
+ } from '../hooks/engine';
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Helpers
33
+ // ---------------------------------------------------------------------------
34
+
35
+ /** Create a temporary directory for test isolation. */
36
+ function createTempDir(): string {
37
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'nimbus-hooks-test-'));
38
+ }
39
+
40
+ /** Remove a temporary directory and all its contents. */
41
+ function removeTempDir(dir: string): void {
42
+ try {
43
+ fs.rmSync(dir, { recursive: true, force: true });
44
+ } catch {
45
+ // Best effort
46
+ }
47
+ }
48
+
49
+ /** Create a .nimbus/hooks.yaml file in the given project directory. */
50
+ function writeHooksYaml(projectDir: string, content: string): void {
51
+ const nimbusDir = path.join(projectDir, '.nimbus');
52
+ fs.mkdirSync(nimbusDir, { recursive: true });
53
+ fs.writeFileSync(path.join(nimbusDir, 'hooks.yaml'), content, 'utf-8');
54
+ }
55
+
56
+ /** Write an executable script file. */
57
+ function writeScript(filePath: string, content: string): void {
58
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
59
+ fs.writeFileSync(filePath, content, { mode: 0o755 });
60
+ }
61
+
62
+ /** Build a minimal HookContext for testing. */
63
+ function makeContext(toolName: string): HookContext {
64
+ return {
65
+ tool: toolName,
66
+ input: {},
67
+ sessionId: 'test-session',
68
+ agent: 'build',
69
+ timestamp: new Date().toISOString(),
70
+ };
71
+ }
72
+
73
+ // ===========================================================================
74
+ // loadHooksConfig
75
+ // ===========================================================================
76
+
77
+ describe('loadHooksConfig', () => {
78
+ let tmpDir: string;
79
+
80
+ beforeEach(() => {
81
+ tmpDir = createTempDir();
82
+ });
83
+
84
+ afterEach(() => {
85
+ removeTempDir(tmpDir);
86
+ });
87
+
88
+ test('returns null when no file exists', () => {
89
+ const config = loadHooksConfig(tmpDir);
90
+ expect(config).toBeNull();
91
+ });
92
+
93
+ test('loads valid hooks.yaml', () => {
94
+ writeHooksYaml(
95
+ tmpDir,
96
+ `hooks:
97
+ PreToolUse:
98
+ - match: "edit_file|write_file"
99
+ command: ".nimbus/hooks/pre-edit.sh"
100
+ timeout: 5000
101
+ PostToolUse:
102
+ - match: ".*"
103
+ command: ".nimbus/hooks/post-all.sh"
104
+ `
105
+ );
106
+
107
+ const config = loadHooksConfig(tmpDir);
108
+ expect(config).not.toBeNull();
109
+ expect(config!.hooks.PreToolUse).toHaveLength(1);
110
+ expect(config!.hooks.PreToolUse[0].match).toBe('edit_file|write_file');
111
+ expect(config!.hooks.PreToolUse[0].command).toBe('.nimbus/hooks/pre-edit.sh');
112
+ expect(config!.hooks.PreToolUse[0].timeout).toBe(5000);
113
+ expect(config!.hooks.PostToolUse).toHaveLength(1);
114
+ expect(config!.hooks.PostToolUse[0].match).toBe('.*');
115
+ // PermissionRequest should default to empty array
116
+ expect(config!.hooks.PermissionRequest).toHaveLength(0);
117
+ });
118
+
119
+ test('throws for invalid hook event name', () => {
120
+ writeHooksYaml(
121
+ tmpDir,
122
+ `hooks:
123
+ InvalidEvent:
124
+ - match: ".*"
125
+ command: "echo hi"
126
+ `
127
+ );
128
+
129
+ expect(() => loadHooksConfig(tmpDir)).toThrow(/unknown hook event/);
130
+ });
131
+
132
+ test('throws for missing top-level hooks key', () => {
133
+ writeHooksYaml(
134
+ tmpDir,
135
+ `something_else:
136
+ PreToolUse:
137
+ - match: ".*"
138
+ command: "echo hi"
139
+ `
140
+ );
141
+
142
+ expect(() => loadHooksConfig(tmpDir)).toThrow(/missing top-level "hooks" key/);
143
+ });
144
+ });
145
+
146
+ // ===========================================================================
147
+ // validateHookDefinition
148
+ // ===========================================================================
149
+
150
+ describe('validateHookDefinition', () => {
151
+ test('passes for valid hook', () => {
152
+ const hook: HookDefinition = {
153
+ match: 'edit_file|write_file',
154
+ command: '.nimbus/hooks/pre-edit.sh',
155
+ timeout: 5000,
156
+ };
157
+ const errors = validateHookDefinition(hook);
158
+ expect(errors).toHaveLength(0);
159
+ });
160
+
161
+ test('passes for valid hook without timeout', () => {
162
+ const hook: HookDefinition = {
163
+ match: '.*',
164
+ command: 'echo hello',
165
+ };
166
+ const errors = validateHookDefinition(hook);
167
+ expect(errors).toHaveLength(0);
168
+ });
169
+
170
+ test('catches invalid regex in match', () => {
171
+ const hook: HookDefinition = {
172
+ match: '[invalid(regex',
173
+ command: 'echo hello',
174
+ };
175
+ const errors = validateHookDefinition(hook);
176
+ expect(errors.length).toBeGreaterThan(0);
177
+ expect(errors.some(e => e.includes('not a valid regex'))).toBe(true);
178
+ });
179
+
180
+ test('catches empty command', () => {
181
+ const hook: HookDefinition = {
182
+ match: '.*',
183
+ command: '',
184
+ };
185
+ const errors = validateHookDefinition(hook);
186
+ expect(errors.length).toBeGreaterThan(0);
187
+ expect(errors.some(e => e.includes('command'))).toBe(true);
188
+ });
189
+
190
+ test('catches empty match', () => {
191
+ const hook = {
192
+ match: '',
193
+ command: 'echo hello',
194
+ } as HookDefinition;
195
+ const errors = validateHookDefinition(hook);
196
+ expect(errors.length).toBeGreaterThan(0);
197
+ expect(errors.some(e => e.includes('match'))).toBe(true);
198
+ });
199
+
200
+ test('catches negative timeout', () => {
201
+ const hook: HookDefinition = {
202
+ match: '.*',
203
+ command: 'echo hello',
204
+ timeout: -1,
205
+ };
206
+ const errors = validateHookDefinition(hook);
207
+ expect(errors.length).toBeGreaterThan(0);
208
+ expect(errors.some(e => e.includes('timeout'))).toBe(true);
209
+ });
210
+
211
+ test('catches zero timeout', () => {
212
+ const hook: HookDefinition = {
213
+ match: '.*',
214
+ command: 'echo hello',
215
+ timeout: 0,
216
+ };
217
+ const errors = validateHookDefinition(hook);
218
+ expect(errors.length).toBeGreaterThan(0);
219
+ expect(errors.some(e => e.includes('timeout'))).toBe(true);
220
+ });
221
+ });
222
+
223
+ // ===========================================================================
224
+ // HookEngine — matching
225
+ // ===========================================================================
226
+
227
+ describe('HookEngine.hasHooks', () => {
228
+ let tmpDir: string;
229
+
230
+ beforeEach(() => {
231
+ tmpDir = createTempDir();
232
+ });
233
+
234
+ afterEach(() => {
235
+ removeTempDir(tmpDir);
236
+ });
237
+
238
+ test('returns true for matching tools', () => {
239
+ writeHooksYaml(
240
+ tmpDir,
241
+ `hooks:
242
+ PreToolUse:
243
+ - match: "edit_file|write_file"
244
+ command: "echo pre"
245
+ `
246
+ );
247
+
248
+ const engine = new HookEngine(tmpDir);
249
+ expect(engine.hasHooks('PreToolUse', 'edit_file')).toBe(true);
250
+ expect(engine.hasHooks('PreToolUse', 'write_file')).toBe(true);
251
+ });
252
+
253
+ test('returns false for non-matching tools', () => {
254
+ writeHooksYaml(
255
+ tmpDir,
256
+ `hooks:
257
+ PreToolUse:
258
+ - match: "edit_file|write_file"
259
+ command: "echo pre"
260
+ `
261
+ );
262
+
263
+ const engine = new HookEngine(tmpDir);
264
+ expect(engine.hasHooks('PreToolUse', 'read_file')).toBe(false);
265
+ expect(engine.hasHooks('PreToolUse', 'glob')).toBe(false);
266
+ });
267
+
268
+ test('returns false when no config is loaded', () => {
269
+ const engine = new HookEngine();
270
+ expect(engine.hasHooks('PreToolUse', 'edit_file')).toBe(false);
271
+ });
272
+
273
+ test('returns false for event types without hooks', () => {
274
+ writeHooksYaml(
275
+ tmpDir,
276
+ `hooks:
277
+ PreToolUse:
278
+ - match: "edit_file"
279
+ command: "echo pre"
280
+ `
281
+ );
282
+
283
+ const engine = new HookEngine(tmpDir);
284
+ expect(engine.hasHooks('PostToolUse', 'edit_file')).toBe(false);
285
+ expect(engine.hasHooks('PermissionRequest', 'edit_file')).toBe(false);
286
+ });
287
+ });
288
+
289
+ describe('HookEngine.getMatchingHooks', () => {
290
+ let tmpDir: string;
291
+
292
+ beforeEach(() => {
293
+ tmpDir = createTempDir();
294
+ });
295
+
296
+ afterEach(() => {
297
+ removeTempDir(tmpDir);
298
+ });
299
+
300
+ test('returns correct hooks for matching tool', () => {
301
+ writeHooksYaml(
302
+ tmpDir,
303
+ `hooks:
304
+ PreToolUse:
305
+ - match: "edit_file"
306
+ command: "echo first"
307
+ - match: "edit_file|write_file"
308
+ command: "echo second"
309
+ - match: "bash"
310
+ command: "echo bash-only"
311
+ `
312
+ );
313
+
314
+ const engine = new HookEngine(tmpDir);
315
+ const matches = engine.getMatchingHooks('PreToolUse', 'edit_file');
316
+ expect(matches).toHaveLength(2);
317
+ expect(matches[0].command).toBe('echo first');
318
+ expect(matches[1].command).toBe('echo second');
319
+ });
320
+
321
+ test('returns empty array for no matches', () => {
322
+ writeHooksYaml(
323
+ tmpDir,
324
+ `hooks:
325
+ PreToolUse:
326
+ - match: "edit_file"
327
+ command: "echo first"
328
+ `
329
+ );
330
+
331
+ const engine = new HookEngine(tmpDir);
332
+ const matches = engine.getMatchingHooks('PreToolUse', 'read_file');
333
+ expect(matches).toHaveLength(0);
334
+ });
335
+
336
+ test('returns empty array when no config loaded', () => {
337
+ const engine = new HookEngine();
338
+ const matches = engine.getMatchingHooks('PreToolUse', 'edit_file');
339
+ expect(matches).toHaveLength(0);
340
+ });
341
+
342
+ test('wildcard pattern matches all tools', () => {
343
+ writeHooksYaml(
344
+ tmpDir,
345
+ `hooks:
346
+ PostToolUse:
347
+ - match: ".*"
348
+ command: "echo all"
349
+ `
350
+ );
351
+
352
+ const engine = new HookEngine(tmpDir);
353
+ expect(engine.getMatchingHooks('PostToolUse', 'edit_file')).toHaveLength(1);
354
+ expect(engine.getMatchingHooks('PostToolUse', 'read_file')).toHaveLength(1);
355
+ expect(engine.getMatchingHooks('PostToolUse', 'terraform')).toHaveLength(1);
356
+ });
357
+ });
358
+
359
+ // ===========================================================================
360
+ // Convenience functions — no-op behavior
361
+ // ===========================================================================
362
+
363
+ describe('runPreToolHooks (no hooks match)', () => {
364
+ test('returns allowed: true when no hooks match', async () => {
365
+ const engine = new HookEngine(); // no config loaded
366
+ const ctx = makeContext('edit_file');
367
+ const result = await runPreToolHooks(engine, ctx);
368
+ expect(result.allowed).toBe(true);
369
+ });
370
+ });
371
+
372
+ describe('runPostToolHooks (no hooks match)', () => {
373
+ test('completes without error when no hooks match', async () => {
374
+ const engine = new HookEngine();
375
+ const ctx = makeContext('edit_file');
376
+ // Should not throw
377
+ await runPostToolHooks(engine, ctx);
378
+ });
379
+ });
380
+
381
+ describe('runPermissionHooks (no hooks match)', () => {
382
+ test('completes without error when no hooks match', async () => {
383
+ const engine = new HookEngine();
384
+ const ctx = makeContext('terraform');
385
+ // Should not throw
386
+ await runPermissionHooks(engine, ctx);
387
+ });
388
+ });
389
+
390
+ // ===========================================================================
391
+ // HookEngine — execution with real scripts
392
+ // ===========================================================================
393
+
394
+ describe('HookEngine execution with real scripts', () => {
395
+ let tmpDir: string;
396
+
397
+ beforeEach(() => {
398
+ tmpDir = createTempDir();
399
+ });
400
+
401
+ afterEach(() => {
402
+ removeTempDir(tmpDir);
403
+ });
404
+
405
+ test('PreToolUse hook with exit 0 allows the tool', async () => {
406
+ const scriptPath = path.join(tmpDir, '.nimbus', 'hooks', 'allow.sh');
407
+ writeScript(scriptPath, '#!/bin/sh\nexit 0\n');
408
+
409
+ writeHooksYaml(
410
+ tmpDir,
411
+ `hooks:
412
+ PreToolUse:
413
+ - match: "edit_file"
414
+ command: "${scriptPath}"
415
+ timeout: 10000
416
+ `
417
+ );
418
+
419
+ const engine = new HookEngine(tmpDir);
420
+ const ctx = makeContext('edit_file');
421
+ const result = await runPreToolHooks(engine, ctx);
422
+ expect(result.allowed).toBe(true);
423
+ });
424
+
425
+ test('PreToolUse hook with exit 2 blocks the tool', async () => {
426
+ const scriptPath = path.join(tmpDir, '.nimbus', 'hooks', 'block.sh');
427
+ writeScript(scriptPath, '#!/bin/sh\necho "Blocked by policy" >&2\nexit 2\n');
428
+
429
+ writeHooksYaml(
430
+ tmpDir,
431
+ `hooks:
432
+ PreToolUse:
433
+ - match: "edit_file"
434
+ command: "${scriptPath}"
435
+ timeout: 10000
436
+ `
437
+ );
438
+
439
+ const engine = new HookEngine(tmpDir);
440
+ const ctx = makeContext('edit_file');
441
+ const result = await runPreToolHooks(engine, ctx);
442
+ expect(result.allowed).toBe(false);
443
+ expect(result.message).toContain('Blocked by policy');
444
+ });
445
+
446
+ test('executeHooks returns results for each matching hook', async () => {
447
+ const allowPath = path.join(tmpDir, '.nimbus', 'hooks', 'allow.sh');
448
+ writeScript(allowPath, '#!/bin/sh\nexit 0\n');
449
+
450
+ const logPath = path.join(tmpDir, '.nimbus', 'hooks', 'log.sh');
451
+ writeScript(logPath, '#!/bin/sh\necho "logged" >&2\nexit 0\n');
452
+
453
+ writeHooksYaml(
454
+ tmpDir,
455
+ `hooks:
456
+ PreToolUse:
457
+ - match: "edit_file"
458
+ command: "${allowPath}"
459
+ timeout: 10000
460
+ - match: ".*"
461
+ command: "${logPath}"
462
+ timeout: 10000
463
+ `
464
+ );
465
+
466
+ const engine = new HookEngine(tmpDir);
467
+ const ctx = makeContext('edit_file');
468
+ const results = await engine.executeHooks('PreToolUse', ctx);
469
+ expect(results).toHaveLength(2);
470
+ expect(results[0].allowed).toBe(true);
471
+ expect(results[0].exitCode).toBe(0);
472
+ expect(results[1].allowed).toBe(true);
473
+ expect(results[1].exitCode).toBe(0);
474
+ });
475
+
476
+ test('hook with non-zero non-2 exit code still allows but reports error', async () => {
477
+ const scriptPath = path.join(tmpDir, '.nimbus', 'hooks', 'error.sh');
478
+ writeScript(scriptPath, '#!/bin/sh\necho "something failed" >&2\nexit 1\n');
479
+
480
+ writeHooksYaml(
481
+ tmpDir,
482
+ `hooks:
483
+ PreToolUse:
484
+ - match: "bash"
485
+ command: "${scriptPath}"
486
+ timeout: 10000
487
+ `
488
+ );
489
+
490
+ const engine = new HookEngine(tmpDir);
491
+ const ctx = makeContext('bash');
492
+ const results = await engine.executeHooks('PreToolUse', ctx);
493
+ expect(results).toHaveLength(1);
494
+ expect(results[0].allowed).toBe(true);
495
+ expect(results[0].exitCode).toBe(1);
496
+ expect(results[0].message).toContain('something failed');
497
+ });
498
+
499
+ test('PostToolUse hooks execute successfully', async () => {
500
+ const scriptPath = path.join(tmpDir, '.nimbus', 'hooks', 'post.sh');
501
+ writeScript(scriptPath, '#!/bin/sh\necho "post-hook ran"\nexit 0\n');
502
+
503
+ writeHooksYaml(
504
+ tmpDir,
505
+ `hooks:
506
+ PostToolUse:
507
+ - match: "edit_file"
508
+ command: "${scriptPath}"
509
+ timeout: 10000
510
+ `
511
+ );
512
+
513
+ const engine = new HookEngine(tmpDir);
514
+ const ctx: HookContext = {
515
+ ...makeContext('edit_file'),
516
+ result: { output: 'file edited', isError: false },
517
+ };
518
+ // Should complete without throwing
519
+ await runPostToolHooks(engine, ctx);
520
+ });
521
+
522
+ test('hook receives context on stdin', async () => {
523
+ // This script writes stdin to a file so we can verify it
524
+ const outputFile = path.join(tmpDir, 'stdin-capture.json');
525
+ const scriptPath = path.join(tmpDir, '.nimbus', 'hooks', 'capture.sh');
526
+ writeScript(scriptPath, `#!/bin/sh\ncat > "${outputFile}"\nexit 0\n`);
527
+
528
+ writeHooksYaml(
529
+ tmpDir,
530
+ `hooks:
531
+ PreToolUse:
532
+ - match: "terraform"
533
+ command: "${scriptPath}"
534
+ timeout: 10000
535
+ `
536
+ );
537
+
538
+ const engine = new HookEngine(tmpDir);
539
+ const ctx = makeContext('terraform');
540
+ ctx.input = { action: 'plan', workdir: '/tmp/tf' };
541
+ await engine.executeHooks('PreToolUse', ctx);
542
+
543
+ // Verify the script received the JSON context
544
+ const captured = fs.readFileSync(outputFile, 'utf-8');
545
+ const parsed = JSON.parse(captured);
546
+ expect(parsed.tool).toBe('terraform');
547
+ expect(parsed.sessionId).toBe('test-session');
548
+ expect(parsed.input.action).toBe('plan');
549
+ });
550
+
551
+ test('hook duration is tracked', async () => {
552
+ const scriptPath = path.join(tmpDir, '.nimbus', 'hooks', 'fast.sh');
553
+ writeScript(scriptPath, '#!/bin/sh\nexit 0\n');
554
+
555
+ writeHooksYaml(
556
+ tmpDir,
557
+ `hooks:
558
+ PreToolUse:
559
+ - match: "edit_file"
560
+ command: "${scriptPath}"
561
+ timeout: 10000
562
+ `
563
+ );
564
+
565
+ const engine = new HookEngine(tmpDir);
566
+ const ctx = makeContext('edit_file');
567
+ const results = await engine.executeHooks('PreToolUse', ctx);
568
+ expect(results).toHaveLength(1);
569
+ expect(typeof results[0].duration).toBe('number');
570
+ expect(results[0].duration).toBeGreaterThanOrEqual(0);
571
+ });
572
+ });
573
+
574
+ // ===========================================================================
575
+ // DEFAULT_HOOK_TIMEOUT
576
+ // ===========================================================================
577
+
578
+ describe('DEFAULT_HOOK_TIMEOUT', () => {
579
+ test('is 30 seconds', () => {
580
+ expect(DEFAULT_HOOK_TIMEOUT).toBe(30_000);
581
+ });
582
+ });