@build-astron-co/nimbus 0.4.1 → 0.4.3

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 (435) hide show
  1. package/CHANGELOG.md +268 -89
  2. package/README.md +26 -567
  3. package/dist/src/agent/compaction-agent.js +24 -12
  4. package/dist/src/agent/context-manager.js +2 -1
  5. package/dist/src/agent/expand-files.js +2 -1
  6. package/dist/src/agent/loop.js +71 -33
  7. package/dist/src/agent/permissions.js +4 -2
  8. package/dist/src/agent/system-prompt.js +34 -17
  9. package/dist/src/app.js +1 -1
  10. package/dist/src/auth/keychain.js +8 -4
  11. package/dist/src/auth/store.js +70 -107
  12. package/dist/src/cli/init.js +35 -19
  13. package/dist/src/cli/run.js +18 -10
  14. package/dist/src/cli/serve.js +4 -2
  15. package/dist/src/cli.js +52 -11
  16. package/dist/src/commands/alias.js +5 -3
  17. package/dist/src/commands/audit/index.js +2 -1
  18. package/dist/src/commands/aws-terraform.js +36 -18
  19. package/dist/src/commands/completions.js +1 -1
  20. package/dist/src/commands/config.js +3 -2
  21. package/dist/src/commands/connect-github.js +92 -0
  22. package/dist/src/commands/cost/index.js +3 -2
  23. package/dist/src/commands/deploy.js +15 -10
  24. package/dist/src/commands/doctor.js +9 -6
  25. package/dist/src/commands/drift/index.js +2 -1
  26. package/dist/src/commands/export.js +5 -3
  27. package/dist/src/commands/generate-terraform.js +110 -2
  28. package/dist/src/commands/import.js +3 -3
  29. package/dist/src/commands/incident.js +10 -5
  30. package/dist/src/commands/login.js +8 -93
  31. package/dist/src/commands/logs.js +16 -8
  32. package/dist/src/commands/onboarding.js +6 -4
  33. package/dist/src/commands/pipeline.js +6 -3
  34. package/dist/src/commands/plugin.js +3 -2
  35. package/dist/src/commands/profile.js +27 -14
  36. package/dist/src/commands/questionnaire.js +1 -1
  37. package/dist/src/commands/rollback.js +3 -2
  38. package/dist/src/commands/rollout.js +5 -3
  39. package/dist/src/commands/runbook.js +17 -10
  40. package/dist/src/commands/schedule.js +10 -5
  41. package/dist/src/commands/status.js +2 -1
  42. package/dist/src/commands/team-context.js +12 -7
  43. package/dist/src/commands/template.js +1 -1
  44. package/dist/src/commands/tf/index.js +6 -3
  45. package/dist/src/commands/upgrade.js +5 -3
  46. package/dist/src/commands/version.js +6 -3
  47. package/dist/src/commands/watch.js +6 -3
  48. package/dist/src/compat/sqlite.js +5 -3
  49. package/dist/src/config/mode-store.js +2 -1
  50. package/dist/src/config/profiles.js +4 -2
  51. package/dist/src/config/types.js +2 -1
  52. package/dist/src/engine/executor.js +8 -4
  53. package/dist/src/engine/planner.js +9 -5
  54. package/dist/src/llm/providers/anthropic.js +6 -3
  55. package/dist/src/llm/providers/ollama.js +1 -1
  56. package/dist/src/llm/router.js +22 -7
  57. package/dist/src/nimbus.js +1 -0
  58. package/dist/src/sessions/manager.js +6 -3
  59. package/dist/src/sharing/viewer.js +2 -1
  60. package/dist/src/tools/file-ops.js +1 -2
  61. package/dist/src/tools/schemas/devops.js +197 -108
  62. package/dist/src/tools/schemas/standard.js +1 -1
  63. package/dist/src/ui/App.js +25 -13
  64. package/dist/src/ui/FileDiffModal.js +22 -11
  65. package/dist/src/ui/HelpModal.js +2 -1
  66. package/dist/src/ui/InputBox.js +6 -3
  67. package/dist/src/ui/MessageList.js +40 -20
  68. package/dist/src/ui/TerminalPane.js +2 -1
  69. package/dist/src/ui/ToolCallDisplay.js +12 -6
  70. package/dist/src/ui/TreePane.js +2 -1
  71. package/dist/src/ui/ink/index.js +37 -21
  72. package/dist/src/version.js +1 -1
  73. package/dist/src/watcher/index.js +8 -4
  74. package/package.json +3 -5
  75. package/src/__tests__/alias.test.ts +0 -133
  76. package/src/__tests__/app.test.ts +0 -76
  77. package/src/__tests__/audit.test.ts +0 -877
  78. package/src/__tests__/circuit-breaker.test.ts +0 -116
  79. package/src/__tests__/cli-run.test.ts +0 -351
  80. package/src/__tests__/compat-sqlite.test.ts +0 -68
  81. package/src/__tests__/context-manager.test.ts +0 -632
  82. package/src/__tests__/context.test.ts +0 -242
  83. package/src/__tests__/devops-terminal-gaps.test.ts +0 -718
  84. package/src/__tests__/doctor.test.ts +0 -48
  85. package/src/__tests__/enterprise.test.ts +0 -401
  86. package/src/__tests__/export.test.ts +0 -236
  87. package/src/__tests__/gap-11-18-20.test.ts +0 -958
  88. package/src/__tests__/generator.test.ts +0 -433
  89. package/src/__tests__/helm-streaming.test.ts +0 -127
  90. package/src/__tests__/hooks.test.ts +0 -582
  91. package/src/__tests__/incident.test.ts +0 -179
  92. package/src/__tests__/init.test.ts +0 -487
  93. package/src/__tests__/intent-parser.test.ts +0 -229
  94. package/src/__tests__/llm-router.test.ts +0 -209
  95. package/src/__tests__/logs.test.ts +0 -107
  96. package/src/__tests__/loop-errors.test.ts +0 -244
  97. package/src/__tests__/lsp.test.ts +0 -293
  98. package/src/__tests__/modes.test.ts +0 -336
  99. package/src/__tests__/perf-optimizations.test.ts +0 -847
  100. package/src/__tests__/permissions.test.ts +0 -338
  101. package/src/__tests__/pipeline.test.ts +0 -50
  102. package/src/__tests__/polish-phase3.test.ts +0 -340
  103. package/src/__tests__/profile.test.ts +0 -237
  104. package/src/__tests__/rollback.test.ts +0 -83
  105. package/src/__tests__/runbook.test.ts +0 -219
  106. package/src/__tests__/schedule.test.ts +0 -206
  107. package/src/__tests__/serve.test.ts +0 -275
  108. package/src/__tests__/sessions.test.ts +0 -322
  109. package/src/__tests__/sharing.test.ts +0 -340
  110. package/src/__tests__/snapshots.test.ts +0 -581
  111. package/src/__tests__/standalone-migration.test.ts +0 -199
  112. package/src/__tests__/state-db.test.ts +0 -334
  113. package/src/__tests__/status.test.ts +0 -158
  114. package/src/__tests__/stream-with-tools.test.ts +0 -778
  115. package/src/__tests__/subagents.test.ts +0 -176
  116. package/src/__tests__/system-prompt.test.ts +0 -248
  117. package/src/__tests__/terminal-gap-v2.test.ts +0 -395
  118. package/src/__tests__/terminal-parity.test.ts +0 -393
  119. package/src/__tests__/tf-apply.test.ts +0 -187
  120. package/src/__tests__/tool-converter.test.ts +0 -256
  121. package/src/__tests__/tool-schemas.test.ts +0 -602
  122. package/src/__tests__/tools.test.ts +0 -144
  123. package/src/__tests__/version-json.test.ts +0 -184
  124. package/src/__tests__/version.test.ts +0 -49
  125. package/src/__tests__/watch.test.ts +0 -129
  126. package/src/agent/compaction-agent.ts +0 -266
  127. package/src/agent/context-manager.ts +0 -499
  128. package/src/agent/context.ts +0 -427
  129. package/src/agent/deploy-preview.ts +0 -487
  130. package/src/agent/expand-files.ts +0 -108
  131. package/src/agent/index.ts +0 -68
  132. package/src/agent/loop.ts +0 -1998
  133. package/src/agent/modes.ts +0 -429
  134. package/src/agent/permissions.ts +0 -513
  135. package/src/agent/subagents/base.ts +0 -116
  136. package/src/agent/subagents/cost.ts +0 -51
  137. package/src/agent/subagents/explore.ts +0 -42
  138. package/src/agent/subagents/general.ts +0 -54
  139. package/src/agent/subagents/index.ts +0 -102
  140. package/src/agent/subagents/infra.ts +0 -59
  141. package/src/agent/subagents/security.ts +0 -69
  142. package/src/agent/system-prompt.ts +0 -990
  143. package/src/app.ts +0 -180
  144. package/src/audit/activity-log.ts +0 -290
  145. package/src/audit/compliance-checker.ts +0 -540
  146. package/src/audit/cost-tracker.ts +0 -318
  147. package/src/audit/index.ts +0 -23
  148. package/src/audit/security-scanner.ts +0 -641
  149. package/src/auth/guard.ts +0 -75
  150. package/src/auth/index.ts +0 -56
  151. package/src/auth/keychain.ts +0 -82
  152. package/src/auth/oauth.ts +0 -465
  153. package/src/auth/providers.ts +0 -470
  154. package/src/auth/sso.ts +0 -113
  155. package/src/auth/store.ts +0 -505
  156. package/src/auth/types.ts +0 -187
  157. package/src/build.ts +0 -141
  158. package/src/cli/index.ts +0 -16
  159. package/src/cli/init.ts +0 -1227
  160. package/src/cli/openapi-spec.ts +0 -356
  161. package/src/cli/run.ts +0 -628
  162. package/src/cli/serve-auth.ts +0 -80
  163. package/src/cli/serve.ts +0 -539
  164. package/src/cli/web.ts +0 -71
  165. package/src/cli.ts +0 -1728
  166. package/src/clients/core-engine-client.ts +0 -227
  167. package/src/clients/enterprise-client.ts +0 -334
  168. package/src/clients/generator-client.ts +0 -351
  169. package/src/clients/git-client.ts +0 -627
  170. package/src/clients/github-client.ts +0 -410
  171. package/src/clients/helm-client.ts +0 -504
  172. package/src/clients/index.ts +0 -80
  173. package/src/clients/k8s-client.ts +0 -497
  174. package/src/clients/llm-client.ts +0 -161
  175. package/src/clients/rest-client.ts +0 -130
  176. package/src/clients/service-discovery.ts +0 -38
  177. package/src/clients/terraform-client.ts +0 -482
  178. package/src/clients/tools-client.ts +0 -1843
  179. package/src/clients/ws-client.ts +0 -115
  180. package/src/commands/alias.ts +0 -100
  181. package/src/commands/analyze/index.ts +0 -352
  182. package/src/commands/apply/helm.ts +0 -473
  183. package/src/commands/apply/index.ts +0 -213
  184. package/src/commands/apply/k8s.ts +0 -454
  185. package/src/commands/apply/terraform.ts +0 -582
  186. package/src/commands/ask.ts +0 -167
  187. package/src/commands/audit/index.ts +0 -357
  188. package/src/commands/auth-cloud.ts +0 -407
  189. package/src/commands/auth-list.ts +0 -134
  190. package/src/commands/auth-profile.ts +0 -121
  191. package/src/commands/auth-refresh.ts +0 -187
  192. package/src/commands/auth-status.ts +0 -141
  193. package/src/commands/aws/ec2.ts +0 -501
  194. package/src/commands/aws/iam.ts +0 -397
  195. package/src/commands/aws/index.ts +0 -133
  196. package/src/commands/aws/lambda.ts +0 -396
  197. package/src/commands/aws/rds.ts +0 -439
  198. package/src/commands/aws/s3.ts +0 -439
  199. package/src/commands/aws/vpc.ts +0 -393
  200. package/src/commands/aws-discover.ts +0 -542
  201. package/src/commands/aws-terraform.ts +0 -755
  202. package/src/commands/azure/aks.ts +0 -376
  203. package/src/commands/azure/functions.ts +0 -253
  204. package/src/commands/azure/index.ts +0 -116
  205. package/src/commands/azure/storage.ts +0 -478
  206. package/src/commands/azure/vm.ts +0 -355
  207. package/src/commands/billing/index.ts +0 -256
  208. package/src/commands/chat.ts +0 -320
  209. package/src/commands/completions.ts +0 -268
  210. package/src/commands/config.ts +0 -372
  211. package/src/commands/cost/cloud-cost-estimator.ts +0 -266
  212. package/src/commands/cost/estimator.ts +0 -79
  213. package/src/commands/cost/index.ts +0 -810
  214. package/src/commands/cost/parsers/terraform.ts +0 -273
  215. package/src/commands/cost/parsers/types.ts +0 -25
  216. package/src/commands/cost/pricing/aws.ts +0 -544
  217. package/src/commands/cost/pricing/azure.ts +0 -499
  218. package/src/commands/cost/pricing/gcp.ts +0 -396
  219. package/src/commands/cost/pricing/index.ts +0 -40
  220. package/src/commands/demo.ts +0 -250
  221. package/src/commands/deploy.ts +0 -260
  222. package/src/commands/doctor.ts +0 -1386
  223. package/src/commands/drift/index.ts +0 -787
  224. package/src/commands/explain.ts +0 -277
  225. package/src/commands/export.ts +0 -146
  226. package/src/commands/feedback.ts +0 -389
  227. package/src/commands/fix.ts +0 -324
  228. package/src/commands/fs/index.ts +0 -402
  229. package/src/commands/gcp/compute.ts +0 -325
  230. package/src/commands/gcp/functions.ts +0 -271
  231. package/src/commands/gcp/gke.ts +0 -438
  232. package/src/commands/gcp/iam.ts +0 -344
  233. package/src/commands/gcp/index.ts +0 -129
  234. package/src/commands/gcp/storage.ts +0 -284
  235. package/src/commands/generate-helm.ts +0 -1249
  236. package/src/commands/generate-k8s.ts +0 -1508
  237. package/src/commands/generate-terraform.ts +0 -1202
  238. package/src/commands/gh/index.ts +0 -863
  239. package/src/commands/git/index.ts +0 -1343
  240. package/src/commands/helm/index.ts +0 -1126
  241. package/src/commands/help.ts +0 -715
  242. package/src/commands/history.ts +0 -149
  243. package/src/commands/import.ts +0 -868
  244. package/src/commands/incident.ts +0 -166
  245. package/src/commands/index.ts +0 -367
  246. package/src/commands/init.ts +0 -1051
  247. package/src/commands/k8s/index.ts +0 -1137
  248. package/src/commands/login.ts +0 -716
  249. package/src/commands/logout.ts +0 -83
  250. package/src/commands/logs.ts +0 -167
  251. package/src/commands/onboarding.ts +0 -405
  252. package/src/commands/pipeline.ts +0 -186
  253. package/src/commands/plan/display.ts +0 -279
  254. package/src/commands/plan/index.ts +0 -599
  255. package/src/commands/plugin.ts +0 -398
  256. package/src/commands/preview.ts +0 -452
  257. package/src/commands/profile.ts +0 -342
  258. package/src/commands/questionnaire.ts +0 -1172
  259. package/src/commands/resume.ts +0 -47
  260. package/src/commands/rollback.ts +0 -315
  261. package/src/commands/rollout.ts +0 -88
  262. package/src/commands/runbook.ts +0 -346
  263. package/src/commands/schedule.ts +0 -236
  264. package/src/commands/status.ts +0 -252
  265. package/src/commands/team/index.ts +0 -346
  266. package/src/commands/team-context.ts +0 -220
  267. package/src/commands/template.ts +0 -233
  268. package/src/commands/tf/index.ts +0 -1093
  269. package/src/commands/upgrade.ts +0 -607
  270. package/src/commands/usage/index.ts +0 -134
  271. package/src/commands/version.ts +0 -174
  272. package/src/commands/watch.ts +0 -153
  273. package/src/compat/index.ts +0 -2
  274. package/src/compat/runtime.ts +0 -12
  275. package/src/compat/sqlite.ts +0 -177
  276. package/src/config/index.ts +0 -17
  277. package/src/config/manager.ts +0 -530
  278. package/src/config/mode-store.ts +0 -62
  279. package/src/config/profiles.ts +0 -84
  280. package/src/config/safety-policy.ts +0 -358
  281. package/src/config/schema.ts +0 -125
  282. package/src/config/types.ts +0 -609
  283. package/src/config/workspace-state.ts +0 -53
  284. package/src/context/context-db.ts +0 -199
  285. package/src/demo/index.ts +0 -349
  286. package/src/demo/scenarios/full-journey.ts +0 -229
  287. package/src/demo/scenarios/getting-started.ts +0 -127
  288. package/src/demo/scenarios/helm-release.ts +0 -341
  289. package/src/demo/scenarios/k8s-deployment.ts +0 -194
  290. package/src/demo/scenarios/terraform-vpc.ts +0 -170
  291. package/src/demo/types.ts +0 -92
  292. package/src/engine/cost-estimator.ts +0 -480
  293. package/src/engine/diagram-generator.ts +0 -256
  294. package/src/engine/drift-detector.ts +0 -902
  295. package/src/engine/executor.ts +0 -1066
  296. package/src/engine/index.ts +0 -76
  297. package/src/engine/orchestrator.ts +0 -636
  298. package/src/engine/planner.ts +0 -787
  299. package/src/engine/safety.ts +0 -743
  300. package/src/engine/verifier.ts +0 -770
  301. package/src/enterprise/audit.ts +0 -348
  302. package/src/enterprise/auth.ts +0 -270
  303. package/src/enterprise/billing.ts +0 -822
  304. package/src/enterprise/index.ts +0 -17
  305. package/src/enterprise/teams.ts +0 -443
  306. package/src/generator/best-practices.ts +0 -1608
  307. package/src/generator/helm.ts +0 -630
  308. package/src/generator/index.ts +0 -37
  309. package/src/generator/intent-parser.ts +0 -514
  310. package/src/generator/kubernetes.ts +0 -976
  311. package/src/generator/terraform.ts +0 -1875
  312. package/src/history/index.ts +0 -8
  313. package/src/history/manager.ts +0 -250
  314. package/src/history/types.ts +0 -34
  315. package/src/hooks/config.ts +0 -432
  316. package/src/hooks/engine.ts +0 -392
  317. package/src/hooks/index.ts +0 -4
  318. package/src/llm/auth-bridge.ts +0 -198
  319. package/src/llm/circuit-breaker.ts +0 -140
  320. package/src/llm/config-loader.ts +0 -201
  321. package/src/llm/cost-calculator.ts +0 -171
  322. package/src/llm/index.ts +0 -8
  323. package/src/llm/model-aliases.ts +0 -115
  324. package/src/llm/provider-registry.ts +0 -63
  325. package/src/llm/providers/anthropic.ts +0 -462
  326. package/src/llm/providers/bedrock.ts +0 -477
  327. package/src/llm/providers/google.ts +0 -405
  328. package/src/llm/providers/ollama.ts +0 -767
  329. package/src/llm/providers/openai-compatible.ts +0 -340
  330. package/src/llm/providers/openai.ts +0 -328
  331. package/src/llm/providers/openrouter.ts +0 -338
  332. package/src/llm/router.ts +0 -1104
  333. package/src/llm/types.ts +0 -232
  334. package/src/lsp/client.ts +0 -298
  335. package/src/lsp/languages.ts +0 -119
  336. package/src/lsp/manager.ts +0 -294
  337. package/src/mcp/client.ts +0 -402
  338. package/src/mcp/index.ts +0 -5
  339. package/src/mcp/manager.ts +0 -133
  340. package/src/nimbus.ts +0 -233
  341. package/src/plugins/index.ts +0 -27
  342. package/src/plugins/loader.ts +0 -334
  343. package/src/plugins/manager.ts +0 -376
  344. package/src/plugins/types.ts +0 -284
  345. package/src/scanners/cicd-scanner.ts +0 -258
  346. package/src/scanners/cloud-scanner.ts +0 -466
  347. package/src/scanners/framework-scanner.ts +0 -469
  348. package/src/scanners/iac-scanner.ts +0 -388
  349. package/src/scanners/index.ts +0 -539
  350. package/src/scanners/language-scanner.ts +0 -276
  351. package/src/scanners/package-manager-scanner.ts +0 -277
  352. package/src/scanners/types.ts +0 -172
  353. package/src/sessions/manager.ts +0 -472
  354. package/src/sessions/types.ts +0 -44
  355. package/src/sharing/sync.ts +0 -300
  356. package/src/sharing/viewer.ts +0 -163
  357. package/src/snapshots/index.ts +0 -2
  358. package/src/snapshots/manager.ts +0 -530
  359. package/src/state/artifacts.ts +0 -147
  360. package/src/state/audit.ts +0 -137
  361. package/src/state/billing.ts +0 -240
  362. package/src/state/checkpoints.ts +0 -117
  363. package/src/state/config.ts +0 -67
  364. package/src/state/conversations.ts +0 -14
  365. package/src/state/credentials.ts +0 -154
  366. package/src/state/db.ts +0 -58
  367. package/src/state/index.ts +0 -26
  368. package/src/state/messages.ts +0 -115
  369. package/src/state/projects.ts +0 -123
  370. package/src/state/schema.ts +0 -236
  371. package/src/state/sessions.ts +0 -147
  372. package/src/state/teams.ts +0 -200
  373. package/src/telemetry.ts +0 -108
  374. package/src/tools/aws-ops.ts +0 -952
  375. package/src/tools/azure-ops.ts +0 -579
  376. package/src/tools/file-ops.ts +0 -615
  377. package/src/tools/gcp-ops.ts +0 -625
  378. package/src/tools/git-ops.ts +0 -773
  379. package/src/tools/github-ops.ts +0 -799
  380. package/src/tools/helm-ops.ts +0 -943
  381. package/src/tools/index.ts +0 -17
  382. package/src/tools/k8s-ops.ts +0 -819
  383. package/src/tools/schemas/converter.ts +0 -184
  384. package/src/tools/schemas/devops.ts +0 -3502
  385. package/src/tools/schemas/index.ts +0 -73
  386. package/src/tools/schemas/standard.ts +0 -1148
  387. package/src/tools/schemas/types.ts +0 -735
  388. package/src/tools/spawn-exec.ts +0 -148
  389. package/src/tools/terraform-ops.ts +0 -862
  390. package/src/types/ambient.d.ts +0 -193
  391. package/src/types/config.ts +0 -83
  392. package/src/types/drift.ts +0 -116
  393. package/src/types/enterprise.ts +0 -335
  394. package/src/types/index.ts +0 -20
  395. package/src/types/plan.ts +0 -44
  396. package/src/types/request.ts +0 -65
  397. package/src/types/response.ts +0 -54
  398. package/src/types/service.ts +0 -51
  399. package/src/ui/App.tsx +0 -2114
  400. package/src/ui/DeployPreview.tsx +0 -174
  401. package/src/ui/FileDiffModal.tsx +0 -162
  402. package/src/ui/Header.tsx +0 -131
  403. package/src/ui/HelpModal.tsx +0 -57
  404. package/src/ui/InputBox.tsx +0 -503
  405. package/src/ui/MessageList.tsx +0 -1032
  406. package/src/ui/PermissionPrompt.tsx +0 -163
  407. package/src/ui/StatusBar.tsx +0 -277
  408. package/src/ui/TerminalPane.tsx +0 -84
  409. package/src/ui/ToolCallDisplay.tsx +0 -643
  410. package/src/ui/TreePane.tsx +0 -132
  411. package/src/ui/chat-ui.ts +0 -850
  412. package/src/ui/index.ts +0 -33
  413. package/src/ui/ink/index.ts +0 -1444
  414. package/src/ui/streaming.ts +0 -176
  415. package/src/ui/theme.ts +0 -104
  416. package/src/ui/types.ts +0 -75
  417. package/src/utils/analytics.ts +0 -72
  418. package/src/utils/cost-warning.ts +0 -27
  419. package/src/utils/env.ts +0 -46
  420. package/src/utils/errors.ts +0 -69
  421. package/src/utils/event-bus.ts +0 -38
  422. package/src/utils/index.ts +0 -24
  423. package/src/utils/logger.ts +0 -171
  424. package/src/utils/rate-limiter.ts +0 -121
  425. package/src/utils/service-auth.ts +0 -49
  426. package/src/utils/validation.ts +0 -53
  427. package/src/version.ts +0 -4
  428. package/src/watcher/index.ts +0 -214
  429. package/src/wizard/approval.ts +0 -383
  430. package/src/wizard/index.ts +0 -25
  431. package/src/wizard/prompts.ts +0 -338
  432. package/src/wizard/types.ts +0 -172
  433. package/src/wizard/ui.ts +0 -556
  434. package/src/wizard/wizard.ts +0 -304
  435. package/tsconfig.json +0 -24
@@ -1,3502 +0,0 @@
1
- /**
2
- * DevOps Tool Definitions
3
- *
4
- * Defines the 12 DevOps-specific tools available to the Nimbus agentic loop.
5
- * Each tool wraps existing infrastructure operations from `src/tools/` modules
6
- * or invokes the appropriate CLI via child_process.
7
- *
8
- * Tools:
9
- * terraform, kubectl, helm, cloud_discover, cost_estimate,
10
- * drift_detect, deploy_preview, terraform_plan_analyze,
11
- * kubectl_context, helm_values, git, task
12
- *
13
- * @module tools/schemas/devops
14
- */
15
-
16
- import { z } from 'zod';
17
- import { exec } from 'node:child_process';
18
- import { promisify } from 'node:util';
19
- import { existsSync, unlinkSync } from 'node:fs';
20
- import { join as pathJoin } from 'node:path';
21
- import type { ToolDefinition, ToolResult } from './types';
22
- import { spawnExec } from '../spawn-exec';
23
-
24
- const execAsync = promisify(exec);
25
-
26
- /** GAP-20: Default timeout for spawnExec calls (10 minutes). */
27
- const DEFAULT_TIMEOUT = 600_000;
28
-
29
- /** GAP-26: Map from cwd → plan file path, for terraform plan → apply workflow */
30
- const terraformPlanFiles = new Map<string, string>();
31
-
32
- // ---------------------------------------------------------------------------
33
- // Helpers
34
- // ---------------------------------------------------------------------------
35
-
36
- /** Build a successful ToolResult. */
37
- function ok(output: string): ToolResult {
38
- return { output, isError: false };
39
- }
40
-
41
- /** Build an error ToolResult. */
42
- function err(message: string): ToolResult {
43
- return { output: '', error: message, isError: true };
44
- }
45
-
46
- /**
47
- * Extract a readable message from an unknown error value. If the error
48
- * originates from `child_process.exec` it may carry `stdout` / `stderr`
49
- * properties that provide richer context than `message` alone.
50
- */
51
- function errorMessage(error: unknown): string {
52
- if (error !== null && typeof error === 'object' && 'stdout' in error) {
53
- const execErr = error as {
54
- stdout?: string;
55
- stderr?: string;
56
- message?: string;
57
- };
58
- const combined = [execErr.stdout, execErr.stderr].filter(Boolean).join('\n');
59
- return combined || execErr.message || 'Command failed';
60
- }
61
- return error instanceof Error ? error.message : String(error);
62
- }
63
-
64
- // ---------------------------------------------------------------------------
65
- // H6: Output formatting helpers
66
- // ---------------------------------------------------------------------------
67
-
68
- /**
69
- * Format `kubectl get pods` tabular output with status emoji indicators.
70
- * Prefixes each pod row with [OK] (Running), [!!](Pending/Init), [XX] (Error/CrashLoop).
71
- */
72
- export function formatKubectlPodsOutput(raw: string): string {
73
- const lines = raw.split('\n');
74
- const result: string[] = [];
75
- for (const line of lines) {
76
- if (!line.trim() || line.startsWith('NAME')) {
77
- result.push(line);
78
- continue;
79
- }
80
- const cols = line.trim().split(/\s+/);
81
- // Status is typically the 3rd column in `kubectl get pods` output
82
- const status = cols[2] ?? '';
83
- let emoji: string;
84
- if (/Running/i.test(status)) {
85
- emoji = '[OK]';
86
- } else if (/Pending|Init:|ContainerCreating|PodInitializing/i.test(status)) {
87
- emoji = '[!!]';
88
- } else if (/Error|CrashLoop|OOMKilled|Evicted|Failed|ImagePullBackOff|ErrImagePull/i.test(status)) {
89
- emoji = '[XX]';
90
- } else if (/Completed|Succeeded/i.test(status)) {
91
- emoji = '[OK]';
92
- } else if (/Terminating/i.test(status)) {
93
- emoji = '[!!]';
94
- } else {
95
- emoji = ' ';
96
- }
97
- result.push(`${emoji} ${line}`);
98
- }
99
- return result.join('\n');
100
- }
101
-
102
- /**
103
- * Format `helm list -o json` output into a human-readable list with ASCII status icons.
104
- */
105
- export function formatHelmListOutput(raw: string): string {
106
- try {
107
- const releases = JSON.parse(raw) as Array<{
108
- name: string;
109
- namespace: string;
110
- revision: string;
111
- status: string;
112
- chart: string;
113
- app_version: string;
114
- updated: string;
115
- }>;
116
- if (!Array.isArray(releases) || releases.length === 0) return 'No Helm releases found.';
117
- const lines = releases.map(r => {
118
- let emoji: string;
119
- const s = r.status?.toLowerCase() ?? '';
120
- if (s === 'deployed') emoji = '[OK]';
121
- else if (s === 'pending-install' || s === 'pending-upgrade') emoji = '[!!]';
122
- else if (s === 'failed') emoji = '[XX]';
123
- else if (s === 'superseded') emoji = '[~~]';
124
- else emoji = ' ';
125
- return `${emoji} ${r.name} (${r.namespace}) — ${r.chart} rev.${r.revision} [${r.status}]`;
126
- });
127
- return lines.join('\n');
128
- } catch {
129
- return raw;
130
- }
131
- }
132
-
133
- /**
134
- * Check if a Terraform workdir uses a remote backend (cloud {} or backend "remote").
135
- * If so, returns a warning message; otherwise null.
136
- */
137
- async function checkRemoteBackend(workdir: string): Promise<string | null> {
138
- try {
139
- const { readdir, readFile } = await import('node:fs/promises');
140
- const { join: joinPath } = await import('node:path');
141
- const entries = await readdir(workdir);
142
- const tfFiles = entries.filter(f => f.endsWith('.tf'));
143
- for (const file of tfFiles) {
144
- const fileContent = await readFile(joinPath(workdir, file), 'utf-8');
145
- if (/^\s*(cloud|backend\s+"remote")\s*\{/m.test(fileContent)) {
146
- return 'Remote backend detected — this operation affects shared state. Ensure you have the correct permissions and workspace selected.';
147
- }
148
- }
149
- } catch { /* ignore FS errors */ }
150
- return null;
151
- }
152
-
153
- // ---------------------------------------------------------------------------
154
- // 1. terraform
155
- // ---------------------------------------------------------------------------
156
-
157
- const terraformSchema = z.object({
158
- action: z
159
- .enum([
160
- 'init', 'plan', 'apply', 'validate', 'fmt', 'destroy', 'import',
161
- 'state', 'state-list', 'state-show', 'state-rm', 'state-mv',
162
- 'output', 'workspace-list', 'workspace-select', 'workspace-new',
163
- 'providers', 'graph', 'force-unlock',
164
- ])
165
- .describe('The Terraform sub-command to run'),
166
- workdir: z.string().describe('Working directory containing the Terraform configuration'),
167
- args: z.string().optional().describe('Additional CLI arguments'),
168
- var_file: z.string().optional().describe('Path to a .tfvars variable file'),
169
- state_address: z.string().optional().describe('Resource address for state operations (e.g., "aws_instance.example")'),
170
- workspace: z.string().optional().describe('Workspace name for workspace-select/workspace-new'),
171
- output_name: z.string().optional().describe('Output name for terraform output (omit for all outputs)'),
172
- lock_id: z.string().optional().describe('Lock ID for force-unlock'),
173
- env: z.record(z.string(), z.string()).optional().describe('Extra environment variables (e.g., AWS_PROFILE, TF_WORKSPACE)'),
174
- });
175
-
176
- export const terraformTool: ToolDefinition = {
177
- name: 'terraform',
178
- description:
179
- 'Execute Terraform operations. Supports init, plan, apply, validate, fmt, destroy, import, state, output, workspace, providers, graph, and force-unlock commands.',
180
- inputSchema: terraformSchema,
181
- permissionTier: 'always_ask',
182
- category: 'devops',
183
- isDestructive: true,
184
-
185
- async execute(raw: unknown, ctx?: import('./types').ToolExecuteContext): Promise<ToolResult> {
186
- try {
187
- const input = terraformSchema.parse(raw);
188
-
189
- // C2: If no workspace specified but session has a workspace context, note it in output
190
- const sessionWorkspace = ctx?.infraContext?.terraformWorkspace;
191
-
192
- // For apply: run validate → plan first to catch errors early
193
- if (input.action === 'apply') {
194
- // Step 1: validate
195
- try {
196
- const { stdout: valOut, stderr: valErr } = await execAsync(
197
- `terraform -chdir=${input.workdir} validate -no-color`,
198
- { timeout: 60_000, maxBuffer: 2 * 1024 * 1024 }
199
- );
200
- const valCombined = [valOut, valErr].filter(Boolean).join('\n');
201
- if (valCombined.includes('Error:')) {
202
- return err(`Terraform validate failed — fix errors before applying:\n${valCombined}`);
203
- }
204
- } catch (valErr: unknown) {
205
- return err(`Terraform validate failed:\n${errorMessage(valErr)}`);
206
- }
207
- }
208
-
209
- // For destroy: require explicit confirmation keyword in args to prevent accidents
210
- if (input.action === 'destroy') {
211
- const prodIndicators = ['prod', 'production', 'prd', 'live'];
212
- const workdirLower = input.workdir.toLowerCase();
213
- const isProd = prodIndicators.some(p => workdirLower.includes(p));
214
- if (isProd && !input.args?.includes('--confirmed-destroy')) {
215
- return err(
216
- `SAFETY CHECK: Production environment detected in workdir "${input.workdir}".\n` +
217
- `To proceed with destroy, add "--confirmed-destroy" to args.\n` +
218
- `This is a safety guard against accidental production teardowns.`
219
- );
220
- }
221
- }
222
-
223
- // Build the terraform command
224
- let command: string;
225
-
226
- if (input.action === 'state-list') {
227
- command = `terraform -chdir=${input.workdir} state list${input.args ? ' ' + input.args : ''}`;
228
- } else if (input.action === 'state-show') {
229
- if (!input.state_address) return err('state-show requires state_address');
230
- command = `terraform -chdir=${input.workdir} state show "${input.state_address}"`;
231
- } else if (input.action === 'state-rm') {
232
- if (!input.state_address) return err('state-rm requires state_address');
233
- command = `terraform -chdir=${input.workdir} state rm "${input.state_address}"`;
234
- } else if (input.action === 'state-mv') {
235
- if (!input.state_address) return err('state-mv requires state_address (format: "source dest")');
236
- command = `terraform -chdir=${input.workdir} state mv ${input.state_address}`;
237
- } else if (input.action === 'state') {
238
- command = `terraform -chdir=${input.workdir} state${input.args ? ' ' + input.args : ' list'}`;
239
- } else if (input.action === 'output') {
240
- command = `terraform -chdir=${input.workdir} output -json${input.output_name ? ' ' + input.output_name : ''}`;
241
- } else if (input.action === 'workspace-list') {
242
- command = `terraform -chdir=${input.workdir} workspace list`;
243
- } else if (input.action === 'workspace-select') {
244
- if (!input.workspace) return err('workspace-select requires workspace name');
245
- command = `terraform -chdir=${input.workdir} workspace select "${input.workspace}"`;
246
- } else if (input.action === 'workspace-new') {
247
- if (!input.workspace) return err('workspace-new requires workspace name');
248
- command = `terraform -chdir=${input.workdir} workspace new "${input.workspace}"`;
249
- } else if (input.action === 'providers') {
250
- command = `terraform -chdir=${input.workdir} providers`;
251
- } else if (input.action === 'graph') {
252
- command = `terraform -chdir=${input.workdir} graph${input.args ? ' ' + input.args : ''}`;
253
- } else if (input.action === 'force-unlock') {
254
- if (!input.lock_id) return err('force-unlock requires lock_id');
255
- command = `terraform -chdir=${input.workdir} force-unlock -force "${input.lock_id}"`;
256
- } else {
257
- const parts: string[] = ['terraform', `-chdir=${input.workdir}`, input.action];
258
-
259
- if (input.var_file) {
260
- parts.push(`-var-file=${input.var_file}`);
261
- }
262
-
263
- // Auto-approve for apply/destroy -- the permission engine handles
264
- // user confirmation before execute() is ever called.
265
- if (input.action === 'apply' || input.action === 'destroy') {
266
- parts.push('-auto-approve');
267
- }
268
-
269
- // Add -no-color for cleaner output in non-TTY contexts.
270
- if (['plan', 'apply', 'destroy', 'init'].includes(input.action)) {
271
- parts.push('-no-color');
272
- }
273
-
274
- // GAP-26: For plan, save the plan to a file so apply can use it
275
- if (input.action === 'plan') {
276
- const planFilePath = pathJoin(input.workdir, '.nimbus-plan');
277
- parts.push(`-out=.nimbus-plan`);
278
- terraformPlanFiles.set(input.workdir, planFilePath);
279
- }
280
-
281
- // GAP-26: For apply, use the saved plan file if available
282
- if (input.action === 'apply') {
283
- const planFile = terraformPlanFiles.get(input.workdir);
284
- if (planFile && existsSync(planFile)) {
285
- // Replace the apply command with one that uses the plan file
286
- // Remove the -auto-approve flag since plan files don't need it
287
- const applyIdx = parts.indexOf('-auto-approve');
288
- if (applyIdx !== -1) parts.splice(applyIdx, 1);
289
- parts.push(planFile);
290
- }
291
- }
292
-
293
- if (input.args) {
294
- // Strip our internal safety flag before passing to terraform
295
- const cleanedArgs = input.args.replace('--confirmed-destroy', '').trim();
296
- if (cleanedArgs) {
297
- parts.push(cleanedArgs);
298
- }
299
- }
300
- command = parts.join(' ');
301
- }
302
-
303
- const spawnResult = await spawnExec(command, {
304
- cwd: input.workdir,
305
- env: { ...process.env, ...(input.env ?? {}) } as NodeJS.ProcessEnv,
306
- onChunk: ctx?.onProgress,
307
- timeout: ctx?.timeout ?? DEFAULT_TIMEOUT, // GAP-20: per-tool timeout from NIMBUS.md, else 10 min default
308
- });
309
-
310
- if (spawnResult.exitCode !== 0) {
311
- // GAP-26: Clean up plan file on apply failure
312
- if (input.action === 'apply') {
313
- const planFile = terraformPlanFiles.get(input.workdir);
314
- if (planFile) {
315
- terraformPlanFiles.delete(input.workdir);
316
- try { unlinkSync(planFile); } catch { /* ignore */ }
317
- }
318
- }
319
- const combinedErr = [spawnResult.stdout, spawnResult.stderr].filter(Boolean).join('\n');
320
- // Check for state lock error — extract Lock ID for force-unlock hint (M1 / G14)
321
- const lockMatch = combinedErr.match(/Lock Info[\s\S]*?ID:\s*([a-f0-9-]+)/);
322
- if (lockMatch) {
323
- return err(`${combinedErr}\n\nHINT: State is locked. To unlock: terraform force-unlock ${lockMatch[1]}`);
324
- }
325
- // G14: Also detect direct "Lock ID:" line format from terraform output
326
- const lockIdMatch = combinedErr.match(/Lock\s+ID:\s*([a-f0-9-]{36})/i);
327
- if (lockIdMatch) {
328
- return err(`${combinedErr}\n\n[STATE LOCK DETECTED] Lock ID: ${lockIdMatch[1]}\nTo force-unlock: terraform force-unlock ${lockIdMatch[1]}\nWARNING: Only force-unlock if no other operations are running.`);
329
- }
330
- return err(`Terraform command failed:\n${combinedErr}`);
331
- }
332
- const combinedOut = [spawnResult.stdout, spawnResult.stderr].filter(Boolean).join('\n');
333
-
334
- // GAP-26: Clean up plan file after successful apply
335
- if (input.action === 'apply') {
336
- const planFile = terraformPlanFiles.get(input.workdir);
337
- if (planFile) {
338
- terraformPlanFiles.delete(input.workdir);
339
- try { unlinkSync(planFile); } catch { /* ignore */ }
340
- }
341
- }
342
-
343
- // Check for remote backend before mutating actions (M1)
344
- if (['apply', 'destroy', 'import', 'state-rm'].includes(input.action)) {
345
- const remoteWarning = await checkRemoteBackend(input.workdir);
346
- if (remoteWarning) {
347
- return ok(`${remoteWarning}\n\n${combinedOut || '(no output)'}`);
348
- }
349
- }
350
-
351
- return ok(combinedOut || '(no output)');
352
- } catch (error: unknown) {
353
- return err(`Terraform command failed: ${errorMessage(error)}`);
354
- }
355
- },
356
- };
357
-
358
- // ---------------------------------------------------------------------------
359
- // 2. kubectl
360
- // ---------------------------------------------------------------------------
361
-
362
- const kubectlSchema = z.object({
363
- action: z
364
- .enum([
365
- 'get', 'apply', 'delete', 'logs', 'scale', 'rollout', 'exec', 'describe',
366
- 'patch', 'port-forward', 'cp', 'top', 'label', 'annotate',
367
- 'cordon', 'drain', 'taint', 'wait', 'diff',
368
- ])
369
- .describe('The kubectl sub-command to run'),
370
- resource: z.string().optional().describe('Resource type and/or name (e.g., "pods my-pod")'),
371
- namespace: z.string().optional().describe('Kubernetes namespace'),
372
- args: z.string().optional().describe('Additional CLI arguments'),
373
- patch_type: z.enum(['strategic', 'merge', 'json']).optional().describe('Patch type for patch action'),
374
- patch: z.string().optional().describe('JSON patch string for patch action'),
375
- local_path: z.string().optional().describe('Local path for cp action'),
376
- container_path: z.string().optional().describe('Container path for cp action'),
377
- env: z.record(z.string(), z.string()).optional().describe('Extra environment variables (e.g., KUBECONFIG, AWS_PROFILE)'),
378
- });
379
-
380
- export const kubectlTool: ToolDefinition = {
381
- name: 'kubectl',
382
- description: 'Execute kubectl operations against a Kubernetes cluster.',
383
- inputSchema: kubectlSchema,
384
- permissionTier: 'always_ask',
385
- category: 'devops',
386
- isDestructive: true,
387
-
388
- async execute(raw: unknown, ctx?: import('./types').ToolExecuteContext): Promise<ToolResult> {
389
- try {
390
- const input = kubectlSchema.parse(raw);
391
-
392
- // C2: Use session infraContext as kubectl context fallback
393
- const contextFlag = ctx?.infraContext?.kubectlContext
394
- ? `--context=${ctx.infraContext.kubectlContext} `
395
- : '';
396
-
397
- const parts: string[] = ['kubectl', input.action];
398
-
399
- // Special handling for new actions
400
- if (input.action === 'patch') {
401
- const patchType = input.patch_type ?? 'strategic';
402
- if (!input.patch) return err('patch action requires patch field with JSON patch string');
403
- if (input.resource) parts.push(input.resource);
404
- if (input.namespace) parts.push('-n', input.namespace);
405
- parts.push(`--type=${patchType}`);
406
- parts.push('-p', `'${input.patch}'`);
407
- } else if (input.action === 'port-forward') {
408
- if (input.resource) parts.push(input.resource);
409
- if (input.namespace) parts.push('-n', input.namespace);
410
- if (input.args) parts.push(input.args);
411
- } else if (input.action === 'cp') {
412
- if (input.local_path && input.container_path) {
413
- parts.push(input.local_path, input.container_path);
414
- } else {
415
- if (input.args) parts.push(input.args);
416
- }
417
- } else if (input.action === 'top') {
418
- if (input.resource) parts.push(input.resource);
419
- if (input.namespace) parts.push('-n', input.namespace);
420
- if (input.args) parts.push(input.args);
421
- } else if (input.action === 'cordon' || input.action === 'taint') {
422
- if (input.resource) parts.push(input.resource);
423
- if (input.args) parts.push(input.args);
424
- } else if (input.action === 'drain') {
425
- if (input.resource) parts.push(input.resource);
426
- parts.push('--ignore-daemonsets', '--delete-emptydir-data');
427
- if (input.args) parts.push(input.args);
428
- } else if (input.action === 'wait') {
429
- if (input.resource) parts.push(input.resource);
430
- if (input.namespace) parts.push('-n', input.namespace);
431
- if (input.args) parts.push(input.args);
432
- else parts.push('--for=condition=Ready', '--timeout=120s');
433
- } else if (input.action === 'diff') {
434
- // G12: kubectl diff — exit code 1 means diffs exist (not an error)
435
- const manifest = input.args || '-';
436
- const nsFlag = input.namespace ? `-n ${input.namespace}` : '';
437
- const diffCmd = ['kubectl', 'diff', '-f', manifest, nsFlag].filter(Boolean).join(' ');
438
- try {
439
- const { stdout: diffOut } = await execAsync(diffCmd, { timeout: 120_000, maxBuffer: 10 * 1024 * 1024 });
440
- return ok(diffOut.trim() || 'No differences found — manifests match cluster state.');
441
- } catch (diffErr: unknown) {
442
- const execError = diffErr as { stdout?: string; stderr?: string; code?: number };
443
- // Exit code 1 with stdout = normal diff output (changes detected)
444
- if (execError.code === 1 && execError.stdout) return ok(execError.stdout.trim());
445
- return err(errorMessage(diffErr));
446
- }
447
- } else {
448
- if (input.resource) {
449
- parts.push(input.resource);
450
- }
451
- if (input.namespace) {
452
- parts.push('-n', input.namespace);
453
- }
454
- if (input.args) {
455
- parts.push(input.args);
456
- }
457
- }
458
-
459
- const rawCommand = parts.join(' ');
460
- // C2: Inject kubectl context from session infraContext if not already specified
461
- const command = contextFlag && !rawCommand.includes('--context=')
462
- ? rawCommand.replace('kubectl ', `kubectl ${contextFlag}`)
463
- : rawCommand;
464
- const streamingActions = ['apply', 'delete', 'rollout', 'port-forward'];
465
- if (ctx?.onProgress && streamingActions.includes(input.action)) {
466
- const defaultKubectlTimeoutMs = input.action === 'port-forward' ? 300_000 : 120_000;
467
- const timeoutMs = ctx?.timeout ?? defaultKubectlTimeoutMs; // GAP-20: per-tool timeout from NIMBUS.md
468
- const result = await spawnExec(command, { onChunk: ctx.onProgress, timeout: timeoutMs });
469
- const combined = [result.stdout, result.stderr].filter(Boolean).join('\n');
470
- if (result.exitCode !== 0) return err(`kubectl command failed:\n${combined}`);
471
- return ok(combined || '(no output)');
472
- }
473
- const cmdEnv = { ...process.env, ...(input.env ?? {}) } as NodeJS.ProcessEnv;
474
- const { stdout, stderr } = await execAsync(command, {
475
- timeout: 120_000,
476
- maxBuffer: 10 * 1024 * 1024,
477
- env: cmdEnv,
478
- });
479
-
480
- let combined = [stdout, stderr].filter(Boolean).join('\n');
481
- // H6: Format pod output with status emoji for scannability
482
- if (input.action === 'get' && input.resource && /\bpods?\b/i.test(input.resource)) {
483
- combined = formatKubectlPodsOutput(combined);
484
- }
485
- return ok(combined || '(no output)');
486
- } catch (error: unknown) {
487
- return err(`kubectl command failed: ${errorMessage(error)}`);
488
- }
489
- },
490
- };
491
-
492
- // ---------------------------------------------------------------------------
493
- // 3. helm
494
- // ---------------------------------------------------------------------------
495
-
496
- const helmSchema = z.object({
497
- action: z
498
- .enum([
499
- 'install', 'upgrade', 'uninstall', 'list', 'rollback', 'template', 'lint',
500
- 'secrets-encrypt', 'secrets-decrypt', 'secrets-view',
501
- 'get-values', 'get-manifest', 'get-all', 'get-hooks', 'status', 'history',
502
- 'test', 'repo-add', 'repo-update', 'repo-list', 'search-repo',
503
- 'show-chart', 'show-values',
504
- ])
505
- .describe('The Helm sub-command to run'),
506
- release: z.string().optional().describe('Helm release name'),
507
- chart: z.string().optional().describe('Chart reference (e.g., "bitnami/nginx")'),
508
- values: z.string().optional().describe('Path to a values.yaml or SOPS-encrypted values file'),
509
- namespace: z.string().optional().describe('Kubernetes namespace for the release'),
510
- revision: z.number().optional().describe('Release revision number (for history/rollback)'),
511
- repo_name: z.string().optional().describe('Helm repo name (for repo-add)'),
512
- repo_url: z.string().optional().describe('Helm repo URL (for repo-add)'),
513
- env: z.record(z.string(), z.string()).optional().describe('Extra environment variables passed to helm'),
514
- });
515
-
516
- /** Last time `helm repo update` was auto-run (prevents repeated runs). */
517
- let lastHelmRepoUpdate = 0;
518
- const HELM_REPO_UPDATE_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
519
-
520
- export const helmTool: ToolDefinition = {
521
- name: 'helm',
522
- description: 'Execute Helm operations for Kubernetes package management.',
523
- inputSchema: helmSchema,
524
- permissionTier: 'always_ask',
525
- category: 'devops',
526
- isDestructive: true,
527
-
528
- async execute(raw: unknown, ctx?: import('./types').ToolExecuteContext): Promise<ToolResult> {
529
- try {
530
- const input = helmSchema.parse(raw);
531
-
532
- // M5: Helm secrets plugin actions (SOPS-encrypted values)
533
- if (input.action === 'secrets-encrypt' || input.action === 'secrets-decrypt' || input.action === 'secrets-view') {
534
- const file = input.values;
535
- if (!file) return err('helm secrets requires a values file path (values field)');
536
- const secretsAction = input.action.replace('secrets-', '');
537
- const command = `helm secrets ${secretsAction} ${file}`;
538
- const { stdout, stderr } = await execAsync(command, {
539
- timeout: 60_000,
540
- maxBuffer: 5 * 1024 * 1024,
541
- });
542
- return ok([stdout, stderr].filter(Boolean).join('\n') || '(no output)');
543
- }
544
-
545
- // New introspection/repo actions
546
- if (['get-values', 'get-manifest', 'get-all', 'get-hooks'].includes(input.action)) {
547
- if (!input.release) return err(`${input.action} requires a release name`);
548
- const subCmd = input.action.replace('get-', 'get ');
549
- const nsFlag = input.namespace ? ` -n ${input.namespace}` : '';
550
- const { stdout: getOut, stderr: getErr } = await execAsync(
551
- `helm ${subCmd} ${input.release}${nsFlag}`,
552
- { timeout: 30_000, maxBuffer: 5 * 1024 * 1024 }
553
- );
554
- return ok([getOut, getErr].filter(Boolean).join('\n') || '(no output)');
555
- }
556
- if (input.action === 'status') {
557
- if (!input.release) return err('status requires a release name');
558
- const nsFlag = input.namespace ? ` -n ${input.namespace}` : '';
559
- const { stdout: statusOut, stderr: statusErr } = await execAsync(
560
- `helm status ${input.release}${nsFlag}`,
561
- { timeout: 30_000, maxBuffer: 5 * 1024 * 1024 }
562
- );
563
- return ok([statusOut, statusErr].filter(Boolean).join('\n') || '(no output)');
564
- }
565
- if (input.action === 'history') {
566
- if (!input.release) return err('history requires a release name');
567
- const nsFlag = input.namespace ? ` -n ${input.namespace}` : '';
568
- try {
569
- const { stdout: histOut } = await execAsync(
570
- `helm history ${input.release}${nsFlag} --max 10 --output json`,
571
- { timeout: 30_000, maxBuffer: 5 * 1024 * 1024 }
572
- );
573
- const histData: Array<{revision: number; updated: string; status: string; chart: string; description: string}> = JSON.parse(histOut || '[]');
574
- const lines = histData.map(h => ` Rev ${h.revision}: ${h.chart} [${h.status}] ${h.updated} — ${h.description}`);
575
- return ok(`Release history for ${input.release}:\n${lines.join('\n')}`);
576
- } catch {
577
- const { stdout: histOut2, stderr: histErr2 } = await execAsync(
578
- `helm history ${input.release}${nsFlag}`,
579
- { timeout: 30_000, maxBuffer: 5 * 1024 * 1024 }
580
- );
581
- return ok([histOut2, histErr2].filter(Boolean).join('\n') || '(no output)');
582
- }
583
- }
584
- if (input.action === 'test') {
585
- if (!input.release) return err('test requires a release name');
586
- const nsFlag = input.namespace ? ` -n ${input.namespace}` : '';
587
- const { stdout: testOut, stderr: testErr } = await execAsync(
588
- `helm test ${input.release}${nsFlag}`,
589
- { timeout: 120_000, maxBuffer: 5 * 1024 * 1024 }
590
- );
591
- return ok([testOut, testErr].filter(Boolean).join('\n') || '(no output)');
592
- }
593
- if (input.action === 'repo-add') {
594
- if (!input.repo_name || !input.repo_url) return err('repo-add requires repo_name and repo_url');
595
- const { stdout: raOut, stderr: raErr } = await execAsync(
596
- `helm repo add ${input.repo_name} ${input.repo_url}`,
597
- { timeout: 30_000, maxBuffer: 1 * 1024 * 1024 }
598
- );
599
- return ok([raOut, raErr].filter(Boolean).join('\n') || '(no output)');
600
- }
601
- if (input.action === 'repo-update') {
602
- const { stdout: ruOut, stderr: ruErr } = await execAsync(
603
- 'helm repo update',
604
- { timeout: 60_000, maxBuffer: 2 * 1024 * 1024 }
605
- );
606
- return ok([ruOut, ruErr].filter(Boolean).join('\n') || '(no output)');
607
- }
608
- if (input.action === 'repo-list') {
609
- const { stdout: rlOut, stderr: rlErr } = await execAsync(
610
- 'helm repo list --output json',
611
- { timeout: 30_000, maxBuffer: 2 * 1024 * 1024 }
612
- );
613
- return ok([rlOut, rlErr].filter(Boolean).join('\n') || '(no repos configured)');
614
- }
615
- if (input.action === 'search-repo') {
616
- const query = input.chart ?? input.release ?? '';
617
- if (!query) return err('search-repo requires chart or release field as search term');
618
- const { stdout: srOut, stderr: srErr } = await execAsync(
619
- `helm search repo ${query}`,
620
- { timeout: 30_000, maxBuffer: 2 * 1024 * 1024 }
621
- );
622
- return ok([srOut, srErr].filter(Boolean).join('\n') || '(no results)');
623
- }
624
- if (input.action === 'show-chart' || input.action === 'show-values') {
625
- const target = input.chart ?? input.release;
626
- if (!target) return err(`${input.action} requires chart or release field`);
627
- const subCmd = input.action === 'show-chart' ? 'chart' : 'values';
628
- const { stdout: showOut, stderr: showErr } = await execAsync(
629
- `helm show ${subCmd} ${target}`,
630
- { timeout: 30_000, maxBuffer: 5 * 1024 * 1024 }
631
- );
632
- return ok([showOut, showErr].filter(Boolean).join('\n') || '(no output)');
633
- }
634
-
635
- // H6: helm list — use JSON output for formatted display
636
- if (input.action === 'list') {
637
- const nsFlag = input.namespace ? ` -n ${input.namespace}` : ' -A';
638
- try {
639
- const { stdout: listJson } = await execAsync(`helm list -o json${nsFlag}`, {
640
- timeout: 30_000,
641
- maxBuffer: 5 * 1024 * 1024,
642
- });
643
- return ok(formatHelmListOutput(listJson));
644
- } catch {
645
- // Fall through to plain helm list
646
- const { stdout: listOut, stderr: listErr } = await execAsync(`helm list${nsFlag}`, {
647
- timeout: 30_000,
648
- maxBuffer: 5 * 1024 * 1024,
649
- });
650
- return ok([listOut, listErr].filter(Boolean).join('\n') || '(no releases found)');
651
- }
652
- }
653
-
654
- // G17: Auto-update helm repos if cache is stale (>1 hour) before install/upgrade
655
- if ((input.action === 'install' || input.action === 'upgrade') && Date.now() - lastHelmRepoUpdate > HELM_REPO_UPDATE_INTERVAL_MS) {
656
- try {
657
- await execAsync('helm repo update', { timeout: 30000 });
658
- lastHelmRepoUpdate = Date.now();
659
- } catch { /* non-critical — proceed with install/upgrade */ }
660
- }
661
-
662
- const parts: string[] = ['helm', input.action];
663
-
664
- if (input.release) {
665
- parts.push(input.release);
666
- }
667
-
668
- if (input.chart) {
669
- parts.push(input.chart);
670
- }
671
-
672
- if (input.values) {
673
- parts.push('-f', input.values);
674
- }
675
-
676
- if (input.namespace) {
677
- parts.push('-n', input.namespace);
678
- }
679
-
680
- const command = parts.join(' ');
681
- // G10: stream output for long-running helm actions so users see progress
682
- const HELM_STREAMING_ACTIONS = new Set(['install', 'upgrade', 'rollback', 'uninstall']);
683
- if (HELM_STREAMING_ACTIONS.has(input.action)) {
684
- const { stdout: sout, stderr: serr } = await spawnExec(command, {
685
- onChunk: ctx?.onProgress,
686
- timeout: ctx?.timeout ?? DEFAULT_TIMEOUT, // GAP-20: per-tool timeout from NIMBUS.md, else 10 min default
687
- });
688
- const combined = [sout, serr].filter(Boolean).join('\n');
689
- return ok(combined.trim() || '(no output)');
690
- }
691
- const helmEnv = { ...process.env, ...(input.env ?? {}) } as NodeJS.ProcessEnv;
692
- const { stdout, stderr } = await execAsync(command, {
693
- timeout: 300_000, // 5 minutes
694
- maxBuffer: 10 * 1024 * 1024,
695
- env: helmEnv,
696
- });
697
-
698
- const combined = [stdout, stderr].filter(Boolean).join('\n');
699
- return ok(combined || '(no output)');
700
- } catch (error: unknown) {
701
- return err(`Helm command failed: ${errorMessage(error)}`);
702
- }
703
- },
704
- };
705
-
706
- // ---------------------------------------------------------------------------
707
- // 4. cloud_discover
708
- // ---------------------------------------------------------------------------
709
-
710
- const cloudDiscoverSchema = z.object({
711
- provider: z.enum(['aws', 'gcp', 'azure']).describe('Cloud provider to discover resources from'),
712
- resource_type: z
713
- .string()
714
- .describe(
715
- 'Full CLI service and command for the provider. AWS: "ec2 describe-instances", "s3api list-buckets", "rds describe-db-instances", "lambda list-functions", "eks list-clusters". GCP: "compute instances list", "container clusters list". Azure: "vm list".'
716
- ),
717
- region: z.string().optional().describe('Cloud region to scope the discovery'),
718
- regions: z.array(z.string()).optional().describe('Multiple regions for parallel discovery (max 5 concurrent)'),
719
- });
720
-
721
- export const cloudDiscoverTool: ToolDefinition = {
722
- name: 'cloud_discover',
723
- description:
724
- 'Discover cloud resources across AWS, GCP, or Azure. Returns a list of resources of the specified type.',
725
- inputSchema: cloudDiscoverSchema,
726
- permissionTier: 'auto_allow',
727
- category: 'devops',
728
-
729
- async execute(raw: unknown): Promise<ToolResult> {
730
- try {
731
- const input = cloudDiscoverSchema.parse(raw);
732
-
733
- // H2: Multi-region parallel discovery
734
- const targetRegions = input.regions && input.regions.length > 0
735
- ? input.regions.slice(0, 10) // cap at 10 regions
736
- : input.region ? [input.region] : [undefined];
737
-
738
- if (targetRegions.length > 1) {
739
- // Run up to 5 regions concurrently
740
- const concurrencyLimit = 5;
741
- const allResults: string[] = [];
742
- for (let i = 0; i < targetRegions.length; i += concurrencyLimit) {
743
- const chunk = targetRegions.slice(i, i + concurrencyLimit);
744
- const chunkResults = await Promise.allSettled(
745
- chunk.map(async (region) => {
746
- let cmd: string;
747
- switch (input.provider) {
748
- case 'aws': {
749
- const rf = region ? ` --region ${region}` : '';
750
- cmd = `aws ${input.resource_type}${rf} --output json`;
751
- break;
752
- }
753
- case 'gcp': {
754
- const rf = region ? ` --regions=${region}` : '';
755
- cmd = `gcloud ${input.resource_type}${rf} --format json`;
756
- break;
757
- }
758
- case 'azure': {
759
- cmd = `az ${input.resource_type} list --output json`;
760
- break;
761
- }
762
- default:
763
- cmd = '';
764
- }
765
- const { stdout, stderr } = await execAsync(cmd, { timeout: 60_000, maxBuffer: 5 * 1024 * 1024 });
766
- return { region: region ?? 'default', output: [stdout, stderr].filter(Boolean).join('\n') };
767
- })
768
- );
769
- for (const res of chunkResults) {
770
- if (res.status === 'fulfilled') {
771
- allResults.push(`\n## Region: ${res.value.region}\n${res.value.output}`);
772
- } else {
773
- allResults.push(`\n## Region: ${chunk[chunkResults.indexOf(res)]} — Error: ${res.reason}`);
774
- }
775
- }
776
- }
777
- return ok(allResults.join('\n') || 'No resources found across specified regions.');
778
- }
779
-
780
- let command: string;
781
-
782
- switch (input.provider) {
783
- case 'aws': {
784
- const regionFlag = input.region ? ` --region ${input.region}` : '';
785
- // resource_type is the full service+command, e.g. "ec2 describe-instances", "s3api list-buckets"
786
- command = `aws ${input.resource_type}${regionFlag} --output json`;
787
- break;
788
- }
789
- case 'gcp': {
790
- const regionFlag = input.region ? ` --regions=${input.region}` : '';
791
- // resource_type is the full subcommand, e.g. "compute instances list", "container clusters list"
792
- command = `gcloud ${input.resource_type}${regionFlag} --format json`;
793
- break;
794
- }
795
- case 'azure': {
796
- command = `az ${input.resource_type} list --output json`;
797
- break;
798
- }
799
- }
800
-
801
- const { stdout, stderr } = await execAsync(command, {
802
- timeout: 120_000,
803
- maxBuffer: 10 * 1024 * 1024,
804
- });
805
-
806
- const combined = [stdout, stderr].filter(Boolean).join('\n');
807
-
808
- // Parse and summarize JSON output for readability
809
- try {
810
- const data = JSON.parse(combined);
811
- const items = Array.isArray(data) ? data : (data.Reservations ? data.Reservations.flatMap((r: { Instances?: unknown[] }) => r.Instances ?? []) : [data]);
812
- if (items.length === 0) {
813
- return ok('No resources found.');
814
- }
815
-
816
- // Build structured per-resource-type summary
817
- const summary = items.slice(0, 50).map((item: Record<string, unknown>) => {
818
- // Security flags
819
- const securityFlags: string[] = [];
820
-
821
- // EC2 instance formatter
822
- if (item.InstanceId || item.InstanceType) {
823
- const name = (item.Tags as Array<{ Key: string; Value: string }>)?.find(t => t.Key === 'Name')?.Value ?? item.InstanceId ?? '(unnamed)';
824
- const state = (item.State as Record<string, unknown>)?.Name ?? item.state ?? '';
825
- const az = (item.Placement as Record<string, unknown>)?.AvailabilityZone ?? '';
826
- const publicIp = item.PublicIpAddress ?? '';
827
- const privateIp = item.PrivateIpAddress ?? '';
828
- const sgs = (item.SecurityGroups as Array<Record<string, unknown>> | undefined) ?? [];
829
- if (sgs.length > 0) securityFlags.push('check-sg-rules');
830
- const flagStr = securityFlags.length > 0 ? ` [${securityFlags.join(', ')}]` : '';
831
- return ` - EC2: ${name} (${item.InstanceType ?? ''}) ${state}${az ? ` [${az}]` : ''}${publicIp ? ` pub:${publicIp}` : ''}${privateIp ? ` priv:${privateIp}` : ''}${flagStr}`;
832
- }
833
- // RDS formatter
834
- if (item.DBInstanceIdentifier) {
835
- const id = item.DBInstanceIdentifier as string;
836
- const engine = `${item.Engine ?? ''}${item.EngineVersion ? ' ' + item.EngineVersion : ''}`;
837
- const status = item.DBInstanceStatus ?? '';
838
- const multiAz = item.MultiAZ ? 'Multi-AZ' : 'Single-AZ';
839
- const endpoint = (item.Endpoint as Record<string, unknown>)?.Address ?? '';
840
- if (!item.StorageEncrypted) securityFlags.push('unencrypted');
841
- const flagStr = securityFlags.length > 0 ? ` [${securityFlags.join(', ')}]` : '';
842
- return ` - RDS: ${id} (${engine}) ${status} ${multiAz}${endpoint ? ` -> ${endpoint}` : ''}${flagStr}`;
843
- }
844
- // EKS formatter
845
- if ((item.arn && String(item.arn).includes(':cluster/')) || (item.ClusterName && item.kubernetesNetworkConfig)) {
846
- const name = item.name ?? item.ClusterName ?? '(unnamed)';
847
- const version = item.version ?? item.Version ?? '';
848
- const status = item.status ?? item.Status ?? '';
849
- return ` - EKS: ${name} (k8s ${version}) ${status}`;
850
- }
851
- // S3 formatter
852
- if (item.BucketName || (item.Name && !item.InstanceType && !item.DBInstanceIdentifier)) {
853
- const name = item.BucketName ?? item.Name ?? '(unnamed)';
854
- const region = item.LocationConstraint ?? item.region ?? '';
855
- if (item.PublicAccessBlockConfiguration && !(item.PublicAccessBlockConfiguration as Record<string, unknown>).BlockPublicAcls) {
856
- securityFlags.push('public-access');
857
- }
858
- const flagStr = securityFlags.length > 0 ? ` [${securityFlags.join(', ')}]` : '';
859
- return ` - S3: ${name}${region ? ` [${region}]` : ''}${flagStr}`;
860
- }
861
- // GCE formatter
862
- if (item.machineType || (item.kind && String(item.kind).includes('Instance'))) {
863
- const name = item.name ?? '(unnamed)';
864
- const machineType = String(item.machineType ?? '').split('/').pop() ?? '';
865
- const status = item.status ?? '';
866
- const zone = String(item.zone ?? '').split('/').pop() ?? '';
867
- const networkInterfaces = item.networkInterfaces as Array<Record<string, unknown>> | undefined;
868
- const extIp = networkInterfaces?.[0]?.accessConfigs
869
- ? (networkInterfaces[0].accessConfigs as Array<Record<string, unknown>>)?.[0]?.natIP ?? ''
870
- : '';
871
- return ` - GCE: ${name} (${machineType}) ${status}${zone ? ` [${zone}]` : ''}${extIp ? ` pub:${extIp}` : ''}`;
872
- }
873
- // AKS formatter
874
- if (item.type && String(item.type).includes('managedClusters')) {
875
- const name = item.name ?? '(unnamed)';
876
- const location = item.location ?? '';
877
- const k8sVersion = (item.properties as Record<string, unknown>)?.kubernetesVersion ?? '';
878
- const agentCount = ((item.properties as Record<string, unknown>)?.agentPoolProfiles as unknown[])?.length ?? 0;
879
- return ` - AKS: ${name} (k8s ${k8sVersion}) ${location ? `[${location}]` : ''} ${agentCount} agent pool(s)`;
880
- }
881
- // Generic fallback
882
- const name =
883
- (item.Tags as Array<{ Key: string; Value: string }>)?.find((t) => t.Key === 'Name')?.Value ||
884
- item.DBInstanceIdentifier || item.FunctionName || item.ClusterName || item.BucketName ||
885
- item.Name || item.name || (item.metadata as Record<string, unknown>)?.name ||
886
- item.InstanceId || item.id || '(unnamed)';
887
- const type = item.InstanceType || item.DBInstanceClass || item.Runtime || item.Status || item.state || item.status || '';
888
- const region = (item.Placement as Record<string, unknown>)?.AvailabilityZone || (item.DBInstanceArn as string | undefined)?.split(':')[3] || item.region || '';
889
- return ` - ${name}${type ? ` (${type})` : ''}${region ? ` [${region}]` : ''}`;
890
- });
891
-
892
- return ok(
893
- `Found ${items.length} resource(s):\n${summary.join('\n')}` +
894
- (items.length > 50 ? `\n\n[+${items.length - 50} more — use specific region/filter to narrow]` : '')
895
- );
896
- } catch {
897
- // Not JSON or failed to parse — return raw output truncated
898
- return ok((combined || '(no resources found)').slice(0, 10_000));
899
- }
900
- } catch (error: unknown) {
901
- return err(`Cloud discovery failed: ${errorMessage(error)}`);
902
- }
903
- },
904
- };
905
-
906
- // ---------------------------------------------------------------------------
907
- // 5. cost_estimate
908
- // ---------------------------------------------------------------------------
909
-
910
- const costEstimateSchema = z.object({
911
- plan_file: z.string().optional().describe('Path to a saved Terraform plan file'),
912
- workdir: z.string().optional().describe('Working directory containing Terraform configuration'),
913
- action: z.enum(['estimate', 'compare', 'savings-plan', 'rightsizing', 'budget'])
914
- .optional().default('estimate').describe('Cost action to perform (default: estimate)'),
915
- provider: z.enum(['aws', 'gcp', 'azure']).optional().describe('Cloud provider for savings/rightsizing/budget actions'),
916
- region: z.string().optional().describe('Cloud region for budget/savings queries'),
917
- /** Gap 13: target compute platform for non-Terraform estimates */
918
- target: z.enum(['terraform', 'kubernetes', 'ecs', 'lambda', 'gcp-gke', 'azure-aks'])
919
- .optional().default('terraform').describe('Target platform for cost estimation (default: terraform)'),
920
- namespace: z.string().optional().describe('Kubernetes namespace for k8s cost estimation'),
921
- function_name: z.string().optional().describe('Lambda function name for serverless cost estimation'),
922
- });
923
-
924
- export const costEstimateTool: ToolDefinition = {
925
- name: 'cost_estimate',
926
- description: 'Estimate infrastructure costs, compare across providers, check savings plans, rightsizing, or budgets.',
927
- inputSchema: costEstimateSchema,
928
- permissionTier: 'auto_allow',
929
- category: 'devops',
930
-
931
- async execute(raw: unknown): Promise<ToolResult> {
932
- try {
933
- const input = costEstimateSchema.parse(raw);
934
-
935
- // M6: multi-cloud cost actions
936
- if (input.action === 'savings-plan') {
937
- const p = input.provider ?? 'aws';
938
- try {
939
- if (p === 'aws') {
940
- const { stdout } = await execAsync('aws ce get-savings-plans-utilization --time-period Start=$(date -v-30d +%Y-%m-%d),End=$(date +%Y-%m-%d) --output json', { timeout: 30_000, maxBuffer: 2 * 1024 * 1024 });
941
- return ok(`AWS Savings Plans Utilization:\n${stdout.slice(0, 5000)}`);
942
- } else if (p === 'gcp') {
943
- const { stdout } = await execAsync('gcloud billing accounts list --format=json', { timeout: 30_000, maxBuffer: 2 * 1024 * 1024 });
944
- return ok(`GCP Billing Accounts:\n${stdout.slice(0, 5000)}`);
945
- }
946
- return err(`Savings plan query not supported for provider: ${p}`);
947
- } catch (error) { return err(`Savings plan query failed: ${errorMessage(error)}`); }
948
- }
949
-
950
- if (input.action === 'rightsizing') {
951
- try {
952
- const { stdout } = await execAsync('aws ce get-rightsizing-recommendation --service AmazonEC2 --output json', { timeout: 30_000, maxBuffer: 2 * 1024 * 1024 });
953
- return ok(`AWS Rightsizing Recommendations:\n${stdout.slice(0, 5000)}`);
954
- } catch (error) { return err(`Rightsizing query failed: ${errorMessage(error)}`); }
955
- }
956
-
957
- if (input.action === 'budget') {
958
- const p = input.provider ?? 'aws';
959
- try {
960
- if (p === 'aws') {
961
- const acct = (await execAsync('aws sts get-caller-identity --query Account --output text', { timeout: 10_000 })).stdout.trim();
962
- const { stdout } = await execAsync(`aws budgets describe-budgets --account-id ${acct} --output json`, { timeout: 30_000, maxBuffer: 2 * 1024 * 1024 });
963
- return ok(`AWS Budgets:\n${stdout.slice(0, 5000)}`);
964
- } else if (p === 'gcp') {
965
- const { stdout } = await execAsync('gcloud billing budgets list --format=json', { timeout: 30_000, maxBuffer: 2 * 1024 * 1024 });
966
- return ok(`GCP Budgets:\n${stdout.slice(0, 5000)}`);
967
- }
968
- return err(`Budget query not supported for provider: ${p}`);
969
- } catch (error) { return err(`Budget query failed: ${errorMessage(error)}`); }
970
- }
971
-
972
- if (input.action === 'compare') {
973
- // Run infracost for current workdir and summarize
974
- const cwd = input.workdir ?? '.';
975
- try {
976
- const { stdout } = await execAsync(`infracost breakdown --path ${cwd} --format json`, { timeout: 60_000, maxBuffer: 5 * 1024 * 1024 });
977
- const ic = JSON.parse(stdout);
978
- const lines = ['--- Multi-cloud Cost Comparison ---', '', `Current (${cwd}): $${parseFloat(ic.totalMonthlyCost ?? '0').toFixed(2)}/month`, '', 'To compare across providers, run infracost diff with alternative configs.'];
979
- return ok(lines.join('\n'));
980
- } catch { return ok('infracost not available. Install infracost for cross-provider cost comparison.'); }
981
- }
982
-
983
- // Gap 13: non-Terraform platform cost estimation
984
- if (input.target === 'kubernetes') {
985
- const nsFlag = input.namespace ? `-n ${input.namespace}` : '--all-namespaces';
986
- try {
987
- const { stdout } = await execAsync(`kubectl get pods ${nsFlag} -o json`, { timeout: 30_000, maxBuffer: 5 * 1024 * 1024 });
988
- const data = JSON.parse(stdout);
989
- const pods = data.items ?? [];
990
- let cpuMillis = 0;
991
- let memMiB = 0;
992
- for (const pod of pods) {
993
- for (const container of (pod.spec?.containers ?? [])) {
994
- const req = container.resources?.requests ?? {};
995
- const cpu = req.cpu ?? '0';
996
- const mem = req.memory ?? '0';
997
- cpuMillis += cpu.endsWith('m') ? parseInt(cpu) : parseInt(cpu) * 1000;
998
- memMiB += mem.endsWith('Mi') ? parseInt(mem) : mem.endsWith('Gi') ? parseInt(mem) * 1024 : 0;
999
- }
1000
- }
1001
- const cpuCost = (cpuMillis / 1000) * 0.048 * 730; // ~$0.048/vCPU-hour * 730h/month
1002
- const memCost = (memMiB / 1024) * 0.006 * 730; // ~$0.006/GB-hour * 730h/month
1003
- return ok([
1004
- `Kubernetes Cost Estimate (${input.namespace ?? 'all namespaces'}):`,
1005
- ` Pods: ${pods.length}`,
1006
- ` CPU requests: ${cpuMillis}m = ${(cpuMillis / 1000).toFixed(2)} vCPU`,
1007
- ` Memory requests: ${memMiB} MiB`,
1008
- ` Estimated monthly cost: $${(cpuCost + memCost).toFixed(2)}/month`,
1009
- ` (CPU: $${cpuCost.toFixed(2)} + Memory: $${memCost.toFixed(2)})`,
1010
- ' Note: Actual cost depends on node type, region, and spot pricing.',
1011
- ].join('\n'));
1012
- } catch (error) { return err(`Kubernetes cost estimate failed: ${errorMessage(error)}`); }
1013
- }
1014
-
1015
- if (input.target === 'ecs') {
1016
- try {
1017
- const taskFamily = input.workdir ?? 'all';
1018
- const cmd = taskFamily === 'all'
1019
- ? 'aws ecs list-task-definitions --output json'
1020
- : `aws ecs describe-task-definition --task-definition ${taskFamily} --output json`;
1021
- const { stdout } = await execAsync(cmd, { timeout: 30_000, maxBuffer: 2 * 1024 * 1024 });
1022
- return ok(`ECS Task Definition Info:\n${stdout.slice(0, 5000)}\n\nNote: Use AWS Pricing Calculator for exact Fargate costs based on vCPU and memory.`);
1023
- } catch (error) { return err(`ECS cost estimate failed: ${errorMessage(error)}`); }
1024
- }
1025
-
1026
- if (input.target === 'lambda') {
1027
- const fn = input.function_name ?? input.workdir;
1028
- if (!fn) return err('function_name required for Lambda cost estimation');
1029
- try {
1030
- const { stdout } = await execAsync(`aws lambda get-function-configuration --function-name ${fn} --output json`, { timeout: 15_000 });
1031
- const cfg = JSON.parse(stdout);
1032
- const memMB = cfg.MemorySize ?? 128;
1033
- const timeout = cfg.Timeout ?? 3;
1034
- return ok([
1035
- `Lambda Cost Estimate: ${fn}`,
1036
- ` Memory: ${memMB} MB`,
1037
- ` Timeout: ${timeout}s`,
1038
- ` Cost per 1M invocations (${memMB}MB, avg ${timeout}s): $${((memMB / 1024) * timeout * 0.0000166667 * 1_000_000).toFixed(2)}`,
1039
- ' Free tier: 1M requests + 400,000 GB-seconds/month',
1040
- ' Note: Actual cost depends on invocation count and average duration.',
1041
- ].join('\n'));
1042
- } catch (error) { return err(`Lambda cost estimate failed: ${errorMessage(error)}`); }
1043
- }
1044
-
1045
- if (!input.plan_file && !input.workdir) {
1046
- return err('Either plan_file or workdir must be provided.');
1047
- }
1048
-
1049
- const cwd = input.workdir ?? '.';
1050
- const planArg = input.plan_file ?? '';
1051
-
1052
- // Try infracost first (real dollar amounts)
1053
- try {
1054
- const targetFlag = planArg ? `--path ${planArg}` : `--path ${cwd}`;
1055
- const { stdout: icOut } = await execAsync(
1056
- `infracost breakdown ${targetFlag} --format json`,
1057
- { timeout: 60_000, maxBuffer: 5 * 1024 * 1024 }
1058
- );
1059
- const ic = JSON.parse(icOut);
1060
- const totalMonthly = parseFloat(ic.totalMonthlyCost ?? '0').toFixed(2);
1061
- const diffMonthly = parseFloat(ic.diffTotalMonthlyCost ?? '0');
1062
- const lines = [
1063
- '--- Cost Estimate (Infracost) ---',
1064
- `Monthly total: $${totalMonthly}`,
1065
- diffMonthly !== 0 ? `Monthly change: ${diffMonthly > 0 ? '+' : ''}$${diffMonthly.toFixed(2)}` : null,
1066
- '',
1067
- 'By resource:',
1068
- ...(ic.projects?.[0]?.resources ?? []).slice(0, 20).map((r: { name: string; monthlyCost?: string }) =>
1069
- ` ${r.name}: $${parseFloat(r.monthlyCost ?? '0').toFixed(2)}/month`
1070
- ),
1071
- ].filter(Boolean);
1072
- return ok(lines.join('\n'));
1073
- } catch {
1074
- // infracost not installed or failed — fall through to resource count
1075
- }
1076
-
1077
- // Attempt to extract resource information from a Terraform plan.
1078
- const showCommand = planArg
1079
- ? `terraform show -json ${planArg}`
1080
- : `terraform -chdir=${cwd} show -json`;
1081
-
1082
- const { stdout } = await execAsync(showCommand, {
1083
- timeout: 60_000,
1084
- maxBuffer: 10 * 1024 * 1024,
1085
- });
1086
-
1087
- // Parse the plan JSON to count resources.
1088
- let resourceCount = 0;
1089
- let resourceTypes: string[] = [];
1090
-
1091
- try {
1092
- const plan = JSON.parse(stdout);
1093
- const changes = plan?.resource_changes ?? [];
1094
- resourceCount = changes.length;
1095
- resourceTypes = [
1096
- ...new Set(changes.map((r: { type?: string }) => r.type).filter(Boolean)),
1097
- ] as string[];
1098
- } catch {
1099
- // If JSON parsing fails, fall back to a basic output.
1100
- return ok(
1101
- `Cost estimate (raw plan output):\n${stdout.slice(0, 5000)}` +
1102
- '\n\nNote: Full cost estimation requires integration with a pricing API (e.g., Infracost).'
1103
- );
1104
- }
1105
-
1106
- // Built-in pricing lookup for common resource types
1107
- const RESOURCE_PRICES: Record<string, number> = {
1108
- 'aws_instance': 30, 'aws_db_instance': 50, 'aws_s3_bucket': 5,
1109
- 'aws_nat_gateway': 32, 'aws_lb': 25, 'aws_alb': 25,
1110
- 'aws_eks_cluster': 73, 'aws_elasticache_cluster': 25,
1111
- 'aws_rds_cluster': 50, 'aws_lambda_function': 2,
1112
- 'aws_cloudfront_distribution': 10, 'aws_ecs_cluster': 30,
1113
- 'google_compute_instance': 30, 'google_container_cluster': 73,
1114
- 'google_sql_database_instance': 50, 'google_storage_bucket': 5,
1115
- 'azurerm_virtual_machine': 30, 'azurerm_kubernetes_cluster': 73,
1116
- 'azurerm_sql_database': 50, 'azurerm_storage_account': 5,
1117
- };
1118
-
1119
- let estimatedMonthly = 0;
1120
- const priceLines: string[] = [];
1121
- for (const rt of resourceTypes) {
1122
- const price = RESOURCE_PRICES[rt] ?? 5; // default $5 for unknown
1123
- estimatedMonthly += price;
1124
- priceLines.push(` ${rt}: ~$${price}/month`);
1125
- }
1126
-
1127
- const lines = [
1128
- '--- Cost Estimate (Built-in Pricing Tables) ---',
1129
- '',
1130
- `Total resources: ${resourceCount}`,
1131
- `Estimated monthly cost: ~$${estimatedMonthly}/month`,
1132
- `Estimated annual cost: ~$${estimatedMonthly * 12}/year`,
1133
- '',
1134
- 'Resource estimates:',
1135
- ...priceLines.slice(0, 20),
1136
- '',
1137
- 'Note: For accurate cost estimates install Infracost (infracost.io) or use the AWS/GCP/Azure pricing calculators.',
1138
- 'Built-in prices are approximate 2025 on-demand rates for us-east-1.',
1139
- ];
1140
-
1141
- return ok(lines.join('\n'));
1142
- } catch (error: unknown) {
1143
- return err(`Cost estimation failed: ${errorMessage(error)}`);
1144
- }
1145
- },
1146
- };
1147
-
1148
- // ---------------------------------------------------------------------------
1149
- // 6. drift_detect
1150
- // ---------------------------------------------------------------------------
1151
-
1152
- const driftDetectSchema = z.object({
1153
- workdir: z.string().describe('Working directory containing IaC configuration'),
1154
- provider: z
1155
- .enum(['terraform', 'kubernetes', 'helm'])
1156
- .optional()
1157
- .default('terraform')
1158
- .describe('IaC provider to check for drift (default: terraform)'),
1159
- });
1160
-
1161
- export const driftDetectTool: ToolDefinition = {
1162
- name: 'drift_detect',
1163
- description: 'Detect infrastructure drift between desired state (IaC) and actual state.',
1164
- inputSchema: driftDetectSchema,
1165
- permissionTier: 'auto_allow',
1166
- category: 'devops',
1167
-
1168
- async execute(raw: unknown): Promise<ToolResult> {
1169
- try {
1170
- const input = driftDetectSchema.parse(raw);
1171
-
1172
- switch (input.provider) {
1173
- case 'terraform': {
1174
- // Exit code 0 = no changes, 1 = error, 2 = changes detected.
1175
- const command = `terraform -chdir=${input.workdir} plan -detailed-exitcode -no-color`;
1176
-
1177
- try {
1178
- const { stdout, stderr } = await execAsync(command, {
1179
- timeout: 300_000,
1180
- maxBuffer: 10 * 1024 * 1024,
1181
- });
1182
- const combined = [stdout, stderr].filter(Boolean).join('\n');
1183
- return ok(`No drift detected.\n\n${combined}`);
1184
- } catch (planError: unknown) {
1185
- // Exit code 2 from terraform plan means drift was detected.
1186
- if (
1187
- planError !== null &&
1188
- typeof planError === 'object' &&
1189
- 'code' in planError &&
1190
- (planError as { code: number }).code === 2
1191
- ) {
1192
- const execErr = planError as { stdout?: string; stderr?: string };
1193
- const output = [execErr.stdout, execErr.stderr].filter(Boolean).join('\n');
1194
- return ok(`DRIFT DETECTED\n\n${output}`);
1195
- }
1196
- throw planError;
1197
- }
1198
- }
1199
-
1200
- case 'kubernetes': {
1201
- const results: string[] = [];
1202
-
1203
- // Step 1: kubectl diff for locally-tracked manifests
1204
- try {
1205
- const { stdout: diffOut } = await execAsync(`kubectl diff -f ${input.workdir} 2>&1 || true`, {
1206
- timeout: 120_000, maxBuffer: 10 * 1024 * 1024,
1207
- });
1208
- if (diffOut.trim()) {
1209
- results.push('## Tracked Resource Drift (kubectl diff):\n' + diffOut);
1210
- }
1211
- } catch { /* ignore */ }
1212
-
1213
- // Step 2: Fetch live cluster resources to find untracked items
1214
- const clusterResources: Record<string, Set<string>> = {};
1215
- try {
1216
- const { stdout: clusterJson } = await execAsync(
1217
- 'kubectl get all,configmap,ingress,pvc -A -o json 2>/dev/null',
1218
- { timeout: 60_000, maxBuffer: 20 * 1024 * 1024 }
1219
- );
1220
- const clusterData = JSON.parse(clusterJson);
1221
- for (const item of (clusterData.items ?? [])) {
1222
- const kind: string = item.kind ?? 'Unknown';
1223
- if (!clusterResources[kind]) clusterResources[kind] = new Set();
1224
- clusterResources[kind].add(`${item.metadata?.namespace ?? 'default'}/${item.metadata?.name}`);
1225
- }
1226
- } catch { /* ignore kubectl errors */ }
1227
-
1228
- // Step 3: Parse local YAML files
1229
- const localResources: Set<string> = new Set();
1230
- try {
1231
- const { readdirSync, readFileSync } = await import('node:fs');
1232
- const { join: joinPath } = await import('node:path');
1233
- // Simple YAML scanner for kind/name
1234
- const scanDir = (dir: string): void => {
1235
- try {
1236
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
1237
- const full = joinPath(dir, entry.name);
1238
- if (entry.isDirectory()) scanDir(full);
1239
- else if (entry.name.endsWith('.yaml') || entry.name.endsWith('.yml')) {
1240
- const fileContent = readFileSync(full, 'utf-8');
1241
- const kindMatch = fileContent.match(/^kind:\s*(\S+)/m);
1242
- const nsMatch = fileContent.match(/^\s*namespace:\s*(\S+)/m);
1243
- const nameMatch = fileContent.match(/^\s*name:\s*(\S+)/m);
1244
- if (kindMatch && nameMatch) {
1245
- const ns = nsMatch?.[1] ?? 'default';
1246
- localResources.add(`${kindMatch[1]}/${ns}/${nameMatch[1]}`);
1247
- }
1248
- }
1249
- }
1250
- } catch { /* ignore */ }
1251
- };
1252
- scanDir(input.workdir);
1253
- } catch { /* ignore */ }
1254
-
1255
- // Step 4: Find cluster resources not in local files
1256
- const untracked: string[] = [];
1257
- for (const [kind, names] of Object.entries(clusterResources)) {
1258
- for (const ns_name of names) {
1259
- const key = `${kind}/${ns_name}`;
1260
- if (!localResources.has(key)) {
1261
- // Skip system resources
1262
- const parts = ns_name.split('/');
1263
- const ns = parts[0];
1264
- const name = parts[1];
1265
- if (!['kube-system', 'kube-public', 'kube-node-lease'].includes(ns ?? '') &&
1266
- !name?.startsWith('kube-') && !name?.startsWith('system:')) {
1267
- untracked.push(key);
1268
- }
1269
- }
1270
- }
1271
- }
1272
-
1273
- if (untracked.length > 0) {
1274
- results.push(`## Untracked Cluster Resources (${untracked.length} total):\n` +
1275
- untracked.slice(0, 100).map(r => ` - ${r}`).join('\n') +
1276
- (untracked.length > 100 ? `\n ... and ${untracked.length - 100} more` : ''));
1277
- }
1278
-
1279
- if (results.length === 0) {
1280
- return ok('No drift detected in Kubernetes resources.');
1281
- }
1282
- return ok(`DRIFT DETECTED\n\n${results.join('\n\n')}`);
1283
- }
1284
-
1285
- case 'helm': {
1286
- // Try helm-diff plugin first for real drift detection
1287
- try {
1288
- const release = (input as { release?: string }).release ?? '';
1289
- const diffCmd = release
1290
- ? `helm diff upgrade ${release} . --allow-unreleased 2>&1`
1291
- : `helm list -A --output json`;
1292
- const { stdout } = await execAsync(diffCmd, { timeout: 60_000, maxBuffer: 5 * 1024 * 1024 });
1293
- if (!stdout.trim() || stdout.trim() === '[]') {
1294
- return ok('No drift detected in Helm releases.');
1295
- }
1296
- return ok(`Helm drift:\n\n${stdout}`);
1297
- } catch {
1298
- // helm-diff not installed — list releases with install hint
1299
- try {
1300
- const { stdout } = await execAsync('helm list -A --output json', { timeout: 30_000 });
1301
- const releases: Array<{ name: string; namespace: string; status: string; chart: string; updated: string }> = JSON.parse(stdout || '[]');
1302
- if (releases.length === 0) return ok('No Helm releases found.');
1303
- const lines = releases.map(r =>
1304
- ` ${r.name} (${r.namespace}): ${r.status} — ${r.chart}, updated ${r.updated}`
1305
- );
1306
- return ok(
1307
- `Helm releases:\n${lines.join('\n')}\n\n` +
1308
- `Note: Install helm-diff for detailed drift: helm plugin install https://github.com/databus23/helm-diff`
1309
- );
1310
- } catch (e2) {
1311
- return err(`Helm drift detection failed: ${errorMessage(e2)}`);
1312
- }
1313
- }
1314
- }
1315
- }
1316
- } catch (error: unknown) {
1317
- return err(`Drift detection failed: ${errorMessage(error)}`);
1318
- }
1319
- },
1320
- };
1321
-
1322
- // ---------------------------------------------------------------------------
1323
- // 7. deploy_preview
1324
- // ---------------------------------------------------------------------------
1325
-
1326
- const deployPreviewSchema = z.object({
1327
- action: z
1328
- .string()
1329
- .describe('The deployment action (e.g., terraform apply, kubectl apply, helm install)'),
1330
- workdir: z.string().describe('Working directory for the deployment'),
1331
- });
1332
-
1333
- export const deployPreviewTool: ToolDefinition = {
1334
- name: 'deploy_preview',
1335
- description: 'Generate a dry-run preview of infrastructure changes with blast radius analysis.',
1336
- inputSchema: deployPreviewSchema,
1337
- permissionTier: 'auto_allow',
1338
- category: 'devops',
1339
-
1340
- async execute(raw: unknown): Promise<ToolResult> {
1341
- try {
1342
- const input = deployPreviewSchema.parse(raw);
1343
- const actionLower = input.action.toLowerCase();
1344
-
1345
- let command: string;
1346
-
1347
- if (actionLower.includes('terraform')) {
1348
- command = `terraform -chdir=${input.workdir} plan -no-color`;
1349
- } else if (actionLower.includes('kubectl')) {
1350
- command = `kubectl apply --dry-run=client -f ${input.workdir} 2>&1`;
1351
- } else if (actionLower.includes('helm')) {
1352
- // For helm, use template to preview rendered manifests.
1353
- command = `helm template ${input.workdir}`;
1354
- } else {
1355
- return err(
1356
- `Unsupported action: ${input.action}. ` + 'Supported keywords: terraform, kubectl, helm.'
1357
- );
1358
- }
1359
-
1360
- const { stdout, stderr } = await execAsync(command, {
1361
- timeout: 300_000,
1362
- maxBuffer: 10 * 1024 * 1024,
1363
- });
1364
-
1365
- const combined = [stdout, stderr].filter(Boolean).join('\n');
1366
-
1367
- // Build a summary header.
1368
- const lines = [
1369
- '--- Deploy Preview (Dry Run) ---',
1370
- `Action: ${input.action}`,
1371
- `Workdir: ${input.workdir}`,
1372
- '',
1373
- combined || '(no changes detected)',
1374
- ];
1375
-
1376
- return ok(lines.join('\n'));
1377
- } catch (error: unknown) {
1378
- return err(`Deploy preview failed: ${errorMessage(error)}`);
1379
- }
1380
- },
1381
- };
1382
-
1383
- // ---------------------------------------------------------------------------
1384
- // 7b. terraform_plan_analyze
1385
- // ---------------------------------------------------------------------------
1386
-
1387
- const terraformPlanAnalyzeSchema = z.object({
1388
- plan_file: z.string().optional().describe('Path to a saved .tfplan binary or .json plan file'),
1389
- workdir: z.string().optional().describe('Working directory — runs terraform show -json on the current state'),
1390
- });
1391
-
1392
- export const terraformPlanAnalyzeTool: ToolDefinition = {
1393
- name: 'terraform_plan_analyze',
1394
- description: 'Analyze a Terraform plan file or working directory state. Returns a structured summary of resources to add, change, and destroy with risk assessment.',
1395
- inputSchema: terraformPlanAnalyzeSchema,
1396
- permissionTier: 'auto_allow',
1397
- category: 'devops',
1398
-
1399
- async execute(raw: unknown): Promise<ToolResult> {
1400
- try {
1401
- const input = terraformPlanAnalyzeSchema.parse(raw);
1402
-
1403
- if (!input.plan_file && !input.workdir) {
1404
- return err('Either plan_file or workdir must be provided.');
1405
- }
1406
-
1407
- const showCmd = input.plan_file
1408
- ? `terraform show -json ${input.plan_file}`
1409
- : `terraform -chdir=${input.workdir} show -json`;
1410
-
1411
- const { stdout } = await execAsync(showCmd, {
1412
- timeout: 60_000,
1413
- maxBuffer: 10 * 1024 * 1024,
1414
- });
1415
-
1416
- let plan: Record<string, unknown>;
1417
- try {
1418
- plan = JSON.parse(stdout);
1419
- } catch {
1420
- return err('Failed to parse terraform show output as JSON. Make sure the plan file is valid.');
1421
- }
1422
-
1423
- const changes = (plan.resource_changes as Array<{
1424
- address: string;
1425
- type: string;
1426
- name: string;
1427
- change: { actions: string[] };
1428
- }>) ?? [];
1429
-
1430
- const toAdd = changes.filter(r => r.change?.actions?.includes('create'));
1431
- const toChange = changes.filter(r => r.change?.actions?.includes('update'));
1432
- const toDestroy = changes.filter(r => r.change?.actions?.includes('delete'));
1433
- const toReplace = changes.filter(
1434
- r =>
1435
- r.change?.actions?.includes('create') && r.change?.actions?.includes('delete')
1436
- );
1437
-
1438
- // Risk assessment
1439
- const highRiskTypes = ['aws_instance', 'aws_db_instance', 'aws_rds_cluster', 'google_sql_database_instance', 'azurerm_sql_server', 'aws_eks_cluster'];
1440
- const highRiskDestroys = toDestroy.filter(r => highRiskTypes.includes(r.type));
1441
-
1442
- const lines = [
1443
- '=== Terraform Plan Analysis ===',
1444
- '',
1445
- `Resources to CREATE: ${toAdd.length}`,
1446
- ...toAdd.slice(0, 10).map(r => ` + ${r.address}`),
1447
- toAdd.length > 10 ? ` ... and ${toAdd.length - 10} more` : '',
1448
- '',
1449
- `Resources to CHANGE: ${toChange.length}`,
1450
- ...toChange.slice(0, 10).map(r => ` ~ ${r.address}`),
1451
- toChange.length > 10 ? ` ... and ${toChange.length - 10} more` : '',
1452
- '',
1453
- `Resources to DESTROY: ${toDestroy.length}`,
1454
- ...toDestroy.slice(0, 10).map(r => ` - ${r.address}`),
1455
- toDestroy.length > 10 ? ` ... and ${toDestroy.length - 10} more` : '',
1456
- '',
1457
- toReplace.length > 0 ? `Resources to REPLACE (destroy+create): ${toReplace.length}` : '',
1458
- ...toReplace.map(r => ` ± ${r.address}`),
1459
- '',
1460
- '=== Risk Assessment ===',
1461
- toDestroy.length === 0 && toReplace.length === 0
1462
- ? 'LOW RISK: No destructive changes'
1463
- : toDestroy.length > 0 && highRiskDestroys.length > 0
1464
- ? `HIGH RISK: Destroying ${highRiskDestroys.length} high-risk resource(s): ${highRiskDestroys.map(r => r.address).join(', ')}`
1465
- : toDestroy.length > 0
1466
- ? `MEDIUM RISK: ${toDestroy.length} resource(s) will be destroyed`
1467
- : 'LOW RISK: Changes only (no destroys)',
1468
- ].filter(l => l !== '');
1469
-
1470
- return ok(lines.join('\n'));
1471
- } catch (error: unknown) {
1472
- return err(`Terraform plan analysis failed: ${errorMessage(error)}`);
1473
- }
1474
- },
1475
- };
1476
-
1477
- // ---------------------------------------------------------------------------
1478
- // 10. kubectl_context
1479
- // ---------------------------------------------------------------------------
1480
-
1481
- const kubectlContextSchema = z.object({
1482
- action: z
1483
- .enum(['list', 'current', 'switch', 'namespaces'])
1484
- .describe('Action: list all contexts, show current context, switch to a context, or list namespaces'),
1485
- context: z.string().optional().describe('Context name to switch to (required for switch action)'),
1486
- });
1487
-
1488
- export const kubectlContextTool: ToolDefinition = {
1489
- name: 'kubectl_context',
1490
- description: 'Manage Kubernetes contexts (kubeconfig). List, inspect, or switch between cluster contexts without running raw kubectl commands.',
1491
- inputSchema: kubectlContextSchema,
1492
- permissionTier: 'auto_allow',
1493
- category: 'devops',
1494
-
1495
- async execute(raw: unknown): Promise<ToolResult> {
1496
- try {
1497
- const input = kubectlContextSchema.parse(raw);
1498
-
1499
- switch (input.action) {
1500
- case 'current': {
1501
- const { stdout } = await execAsync('kubectl config current-context', { timeout: 5000 });
1502
- const ctx = stdout.trim();
1503
- // Also get cluster info
1504
- try {
1505
- const { stdout: clusterInfo } = await execAsync(
1506
- `kubectl config get-clusters | grep -v NAME`,
1507
- { timeout: 5000 }
1508
- );
1509
- return ok(`Current context: ${ctx}\n\nAll clusters:\n${clusterInfo.trim()}`);
1510
- } catch {
1511
- return ok(`Current context: ${ctx}`);
1512
- }
1513
- }
1514
-
1515
- case 'list': {
1516
- const { stdout } = await execAsync('kubectl config get-contexts', { timeout: 5000, maxBuffer: 1024 * 1024 });
1517
- return ok(stdout.trim() || 'No contexts found in kubeconfig.');
1518
- }
1519
-
1520
- case 'switch': {
1521
- if (!input.context) {
1522
- return err('context parameter is required for switch action');
1523
- }
1524
- const { stdout } = await execAsync(
1525
- `kubectl config use-context ${input.context}`,
1526
- { timeout: 5000 }
1527
- );
1528
- return ok(stdout.trim());
1529
- }
1530
-
1531
- case 'namespaces': {
1532
- const { stdout } = await execAsync('kubectl get namespaces -o wide', {
1533
- timeout: 15_000,
1534
- maxBuffer: 1024 * 1024,
1535
- });
1536
- return ok(stdout.trim());
1537
- }
1538
- }
1539
- } catch (error: unknown) {
1540
- return err(`kubectl_context failed: ${errorMessage(error)}`);
1541
- }
1542
- },
1543
- };
1544
-
1545
- // ---------------------------------------------------------------------------
1546
- // 11. helm_values
1547
- // ---------------------------------------------------------------------------
1548
-
1549
- const helmValuesSchema = z.object({
1550
- action: z
1551
- .enum(['show-defaults', 'get-release', 'diff-values'])
1552
- .describe('Action: show default chart values, get values for a deployed release, or diff values between releases'),
1553
- chart: z.string().optional().describe('Chart reference (e.g., bitnami/nginx) for show-defaults'),
1554
- release: z.string().optional().describe('Release name for get-release or diff-values'),
1555
- namespace: z.string().optional().describe('Kubernetes namespace for the release'),
1556
- });
1557
-
1558
- export const helmValuesTool: ToolDefinition = {
1559
- name: 'helm_values',
1560
- description: 'Inspect Helm chart values. Show default values for a chart, get values for a deployed release, or diff two revisions.',
1561
- inputSchema: helmValuesSchema,
1562
- permissionTier: 'auto_allow',
1563
- category: 'devops',
1564
-
1565
- async execute(raw: unknown): Promise<ToolResult> {
1566
- try {
1567
- const input = helmValuesSchema.parse(raw);
1568
-
1569
- switch (input.action) {
1570
- case 'show-defaults': {
1571
- if (!input.chart) {
1572
- return err('chart parameter is required for show-defaults action');
1573
- }
1574
- const { stdout } = await execAsync(`helm show values ${input.chart}`, {
1575
- timeout: 60_000,
1576
- maxBuffer: 5 * 1024 * 1024,
1577
- });
1578
- return ok(stdout.trim() || '(no default values)');
1579
- }
1580
-
1581
- case 'get-release': {
1582
- if (!input.release) {
1583
- return err('release parameter is required for get-release action');
1584
- }
1585
- const nsFlag = input.namespace ? `-n ${input.namespace}` : '';
1586
- const { stdout } = await execAsync(
1587
- `helm get values ${input.release} ${nsFlag} --all`,
1588
- { timeout: 30_000, maxBuffer: 5 * 1024 * 1024 }
1589
- );
1590
- return ok(stdout.trim() || '(no custom values — using defaults)');
1591
- }
1592
-
1593
- case 'diff-values': {
1594
- if (!input.release) {
1595
- return err('release parameter is required for diff-values action');
1596
- }
1597
- const nsFlag = input.namespace ? `-n ${input.namespace}` : '';
1598
- // Get history
1599
- const { stdout: histOut } = await execAsync(
1600
- `helm history ${input.release} ${nsFlag} --output json`,
1601
- { timeout: 30_000, maxBuffer: 1024 * 1024 }
1602
- );
1603
- const history = JSON.parse(histOut || '[]') as Array<{ revision: number }>;
1604
- if (history.length < 2) {
1605
- return ok(`Only ${history.length} revision(s) found. Need at least 2 to diff.`);
1606
- }
1607
- const latest = history[history.length - 1].revision;
1608
- const previous = history[history.length - 2].revision;
1609
- const [latestVals, prevVals] = await Promise.all([
1610
- execAsync(`helm get values ${input.release} ${nsFlag} --revision ${latest}`, { timeout: 30_000 }),
1611
- execAsync(`helm get values ${input.release} ${nsFlag} --revision ${previous}`, { timeout: 30_000 }),
1612
- ]);
1613
- if (latestVals.stdout === prevVals.stdout) {
1614
- return ok(`No value changes between revision ${previous} and ${latest}.`);
1615
- }
1616
- return ok(
1617
- `Values diff (revision ${previous} → ${latest}):\n\n` +
1618
- `=== Revision ${previous} ===\n${prevVals.stdout.trim()}\n\n` +
1619
- `=== Revision ${latest} ===\n${latestVals.stdout.trim()}`
1620
- );
1621
- }
1622
- }
1623
- } catch (error: unknown) {
1624
- return err(`helm_values failed: ${errorMessage(error)}`);
1625
- }
1626
- },
1627
- };
1628
-
1629
- // ---------------------------------------------------------------------------
1630
- // 8. git
1631
- // ---------------------------------------------------------------------------
1632
-
1633
- const gitSchema = z.object({
1634
- action: z
1635
- .enum(['status', 'add', 'commit', 'push', 'pull', 'branch', 'checkout', 'diff', 'log'])
1636
- .describe('The git sub-command to run'),
1637
- args: z.string().optional().describe('Additional CLI arguments'),
1638
- });
1639
-
1640
- export const gitTool: ToolDefinition = {
1641
- name: 'git',
1642
- description:
1643
- 'Execute git operations. Supports status, add, commit, push, pull, branch, checkout, diff, and log.',
1644
- inputSchema: gitSchema,
1645
- permissionTier: 'ask_once',
1646
- category: 'devops',
1647
- isDestructive: true,
1648
-
1649
- async execute(raw: unknown): Promise<ToolResult> {
1650
- try {
1651
- const input = gitSchema.parse(raw);
1652
-
1653
- const parts: string[] = ['git', input.action];
1654
-
1655
- if (input.args) {
1656
- parts.push(input.args);
1657
- }
1658
-
1659
- const command = parts.join(' ');
1660
- const { stdout, stderr } = await execAsync(command, {
1661
- timeout: 60_000,
1662
- maxBuffer: 10 * 1024 * 1024,
1663
- });
1664
-
1665
- const combined = [stdout, stderr].filter(Boolean).join('\n');
1666
- return ok(combined || '(no output)');
1667
- } catch (error: unknown) {
1668
- return err(`git command failed: ${errorMessage(error)}`);
1669
- }
1670
- },
1671
- };
1672
-
1673
- // ---------------------------------------------------------------------------
1674
- // 9. task (subagent)
1675
- // ---------------------------------------------------------------------------
1676
-
1677
- const taskSchema = z.object({
1678
- prompt: z.string().describe('The task for the subagent to perform'),
1679
- agent: z
1680
- .enum(['explore', 'infra', 'security', 'cost', 'general'])
1681
- .optional()
1682
- .default('general')
1683
- .describe('Subagent specialization to handle the task (default: general)'),
1684
- });
1685
-
1686
- export const taskTool: ToolDefinition = {
1687
- name: 'task',
1688
- description:
1689
- 'Spawn a subagent to handle a specific task. The subagent runs with its own isolated context and returns results. Use for parallelizable research, code exploration, security audits, cost analysis, or infrastructure checks.',
1690
- inputSchema: taskSchema,
1691
- permissionTier: 'auto_allow',
1692
- category: 'devops',
1693
-
1694
- async execute(raw: unknown): Promise<ToolResult> {
1695
- try {
1696
- const input = taskSchema.parse(raw);
1697
-
1698
- // Get the LLM router from the app context
1699
- const { getAppContext } = await import('../../app');
1700
- const ctx = getAppContext();
1701
- if (!ctx) {
1702
- return err('App not initialised. Cannot spawn subagent.');
1703
- }
1704
-
1705
- // Create and run the appropriate subagent
1706
- const { createSubagent } = await import('../../agent/subagents/index');
1707
- const subagent = createSubagent(input.agent as any);
1708
- const result = await subagent.run(input.prompt, ctx.router);
1709
-
1710
- const header = [
1711
- `[Subagent: ${input.agent}]`,
1712
- `Turns: ${result.turns} | Tokens: ${result.totalTokens}`,
1713
- result.interrupted ? '(interrupted)' : '',
1714
- '---',
1715
- ]
1716
- .filter(Boolean)
1717
- .join('\n');
1718
-
1719
- return ok(`${header}\n${result.output}`);
1720
- } catch (error: unknown) {
1721
- return err(`Subagent execution failed: ${errorMessage(error)}`);
1722
- }
1723
- },
1724
- };
1725
-
1726
-
1727
- // ---------------------------------------------------------------------------
1728
- // 13. docker
1729
- // ---------------------------------------------------------------------------
1730
-
1731
- const dockerSchema = z.object({
1732
- action: z.enum(['build','push','pull','run','ps','stop','rm','images',
1733
- 'compose-up','compose-down','logs','exec','inspect','prune'])
1734
- .describe('Docker action to perform'),
1735
- image: z.string().optional().describe('Image name (with optional tag)'),
1736
- container: z.string().optional().describe('Container name or ID'),
1737
- tag: z.string().optional().describe('Image tag (default: latest)'),
1738
- file: z.string().optional().describe('Dockerfile path'),
1739
- args: z.string().optional().describe('Additional arguments'),
1740
- workdir: z.string().optional().describe('Working directory for build/compose'),
1741
- });
1742
-
1743
- export const dockerTool: ToolDefinition = {
1744
- name: 'docker',
1745
- description: 'Execute Docker operations: build images, manage containers, run compose, view logs, inspect, and prune.',
1746
- inputSchema: dockerSchema,
1747
- permissionTier: 'always_ask',
1748
- category: 'devops',
1749
- isDestructive: true,
1750
-
1751
- async execute(raw: unknown, ctx?: import('./types').ToolExecuteContext): Promise<ToolResult> {
1752
- try {
1753
- const input = dockerSchema.parse(raw);
1754
-
1755
- // Safety: block --privileged / --network=host in run args
1756
- if (input.action === 'run' && input.args) {
1757
- if (input.args.includes('--privileged') || input.args.includes('--network=host')) {
1758
- return err(
1759
- 'SAFETY CHECK: --privileged and --network=host flags are blocked by default.\n' +
1760
- 'These flags grant significant host access. Remove them or confirm intent explicitly.'
1761
- );
1762
- }
1763
- }
1764
-
1765
- let command: string;
1766
- const wdir = input.workdir ?? '.';
1767
-
1768
- switch (input.action) {
1769
- case 'build': {
1770
- const tag = input.tag ? `:${input.tag}` : ':latest';
1771
- const imageRef = input.image ? `${input.image}${tag}` : 'local-build:latest';
1772
- const fileFlag = input.file ? `-f ${input.file}` : '';
1773
- command = `docker build -t ${imageRef} ${fileFlag} ${wdir}`.trim().replace(/\s+/g, ' ');
1774
- break;
1775
- }
1776
- case 'push':
1777
- command = `docker push ${input.image}${input.tag ? `:${input.tag}` : ''}`;
1778
- break;
1779
- case 'pull':
1780
- command = `docker pull ${input.image}${input.tag ? `:${input.tag}` : ''}`;
1781
- break;
1782
- case 'run': {
1783
- const imageRef = `${input.image ?? 'unknown'}${input.tag ? `:${input.tag}` : ''}`;
1784
- command = `docker run ${input.args ?? ''} ${imageRef}`.trim();
1785
- break;
1786
- }
1787
- case 'ps':
1788
- command = `docker ps ${input.args ?? ''}`.trim();
1789
- break;
1790
- case 'stop':
1791
- command = `docker stop ${input.container ?? ''}`.trim();
1792
- break;
1793
- case 'rm':
1794
- command = `docker rm ${input.container ?? ''} ${input.args ?? ''}`.trim();
1795
- break;
1796
- case 'images':
1797
- command = `docker images ${input.args ?? ''}`.trim();
1798
- break;
1799
- case 'compose-up':
1800
- command = `docker compose -f ${input.file ?? 'docker-compose.yml'} up -d ${input.args ?? ''}`.trim();
1801
- break;
1802
- case 'compose-down':
1803
- command = `docker compose -f ${input.file ?? 'docker-compose.yml'} down ${input.args ?? ''}`.trim();
1804
- break;
1805
- case 'logs':
1806
- command = `docker logs ${input.container ?? ''} ${input.args ?? '--tail=100'}`.trim();
1807
- break;
1808
- case 'exec':
1809
- command = `docker exec ${input.container ?? ''} ${input.args ?? '/bin/sh'}`.trim();
1810
- break;
1811
- case 'inspect':
1812
- command = `docker inspect ${input.container ?? input.image ?? ''}`.trim();
1813
- break;
1814
- case 'prune':
1815
- command = `docker system prune -f ${input.args ?? ''}`.trim();
1816
- break;
1817
- default:
1818
- return err(`Unknown docker action: ${input.action}`);
1819
- }
1820
-
1821
- // Override permissionTier for read-only actions
1822
- const readOnlyActions = ['ps', 'images', 'logs', 'inspect'];
1823
- if (readOnlyActions.includes(input.action)) {
1824
- // These are safe — no special gate needed
1825
- }
1826
-
1827
- // M2: Docker build — use spawnExec with progress filter when ctx?.onProgress available
1828
- if (input.action === 'build' && ctx?.onProgress) {
1829
- const filteredProgress = (chunk: string) => {
1830
- const lines = chunk.split('\n');
1831
- for (const line of lines) {
1832
- const trimmed = line.trim();
1833
- if (!trimmed) continue;
1834
- // Keep: Step N/M, Using cache, Successfully built, error, FROM/RUN/COPY step info
1835
- if (/^Step\s+\d+\/\d+/i.test(trimmed) ||
1836
- /---> Using cache/i.test(trimmed) ||
1837
- /Successfully built/i.test(trimmed) ||
1838
- /Successfully tagged/i.test(trimmed) ||
1839
- /error/i.test(trimmed) ||
1840
- /warning/i.test(trimmed)) {
1841
- ctx.onProgress!(line + '\n');
1842
- }
1843
- }
1844
- };
1845
- const buildResult = await spawnExec(command, { onChunk: filteredProgress, timeout: ctx?.timeout ?? 300_000 });
1846
- const combined = [buildResult.stdout, buildResult.stderr].filter(Boolean).join('\n');
1847
- if (buildResult.exitCode !== 0) return err(`Docker build failed:\n${combined}`);
1848
- return ok(combined || 'Build complete.');
1849
- }
1850
-
1851
- const { stdout, stderr } = await execAsync(command, {
1852
- timeout: 300_000,
1853
- maxBuffer: 10 * 1024 * 1024,
1854
- });
1855
-
1856
- const combined = [stdout, stderr].filter(Boolean).join('\n');
1857
- return ok(combined || '(no output)');
1858
- } catch (error: unknown) {
1859
- return err(`Docker command failed: ${errorMessage(error)}`);
1860
- }
1861
- },
1862
- };
1863
-
1864
- // ---------------------------------------------------------------------------
1865
- // 14. secrets
1866
- // ---------------------------------------------------------------------------
1867
-
1868
- const secretsSchema = z.object({
1869
- action: z.enum(['get','list','put','delete','rotate','versions'])
1870
- .describe('Action to perform on the secret'),
1871
- provider: z.enum(['vault','aws','gcp','azure'])
1872
- .describe('Secrets provider to use'),
1873
- path: z.string().describe('Secret path, ARN, or name'),
1874
- value: z.string().optional().describe('Secret value for put action'),
1875
- version: z.number().optional().describe('Secret version number'),
1876
- region: z.string().optional().describe('Cloud region'),
1877
- namespace: z.string().optional().describe('Vault namespace'),
1878
- });
1879
-
1880
- export const secretsTool: ToolDefinition = {
1881
- name: 'secrets',
1882
- description: 'Manage secrets across Vault, AWS Secrets Manager, GCP Secret Manager, and Azure Key Vault. Secret values are always redacted in output.',
1883
- inputSchema: secretsSchema,
1884
- permissionTier: 'always_ask',
1885
- category: 'devops',
1886
- isDestructive: true,
1887
-
1888
- async execute(raw: unknown): Promise<ToolResult> {
1889
- try {
1890
- const input = secretsSchema.parse(raw);
1891
-
1892
- let command: string;
1893
-
1894
- switch (input.provider) {
1895
- case 'vault': {
1896
- const nsFlag = input.namespace ? `VAULT_NAMESPACE=${input.namespace} ` : '';
1897
- switch (input.action) {
1898
- case 'get':
1899
- command = `${nsFlag}vault kv get -format=json ${input.path}`;
1900
- break;
1901
- case 'list':
1902
- command = `${nsFlag}vault kv list -format=json ${input.path}`;
1903
- break;
1904
- case 'put':
1905
- if (!input.value) return err('value is required for put action');
1906
- command = `${nsFlag}vault kv put ${input.path} value=${input.value}`;
1907
- break;
1908
- case 'delete':
1909
- command = `${nsFlag}vault kv delete ${input.path}`;
1910
- break;
1911
- case 'versions':
1912
- command = `${nsFlag}vault kv metadata get -format=json ${input.path}`;
1913
- break;
1914
- case 'rotate':
1915
- return err('rotate is not supported for Vault — use put to update the secret value');
1916
- }
1917
- break;
1918
- }
1919
- case 'aws': {
1920
- const regionFlag = input.region ? `--region ${input.region}` : '';
1921
- switch (input.action) {
1922
- case 'get':
1923
- command = `aws secretsmanager get-secret-value --secret-id ${input.path} ${regionFlag} --output json`;
1924
- break;
1925
- case 'list':
1926
- command = `aws secretsmanager list-secrets ${regionFlag} --output json`;
1927
- break;
1928
- case 'put':
1929
- if (!input.value) return err('value is required for put action');
1930
- command = `aws secretsmanager put-secret-value --secret-id ${input.path} --secret-string '${input.value.replace(/'/g, "'\\''")}' ${regionFlag}`;
1931
- break;
1932
- case 'delete':
1933
- command = `aws secretsmanager delete-secret --secret-id ${input.path} ${regionFlag} --force-delete-without-recovery`;
1934
- break;
1935
- case 'versions':
1936
- command = `aws secretsmanager list-secret-version-ids --secret-id ${input.path} ${regionFlag} --output json`;
1937
- break;
1938
- case 'rotate':
1939
- command = `aws secretsmanager rotate-secret --secret-id ${input.path} ${regionFlag}`;
1940
- break;
1941
- }
1942
- break;
1943
- }
1944
- case 'gcp': {
1945
- switch (input.action) {
1946
- case 'get':
1947
- command = `gcloud secrets versions access ${input.version ?? 'latest'} --secret=${input.path} --format=json`;
1948
- break;
1949
- case 'list':
1950
- command = `gcloud secrets list --format=json`;
1951
- break;
1952
- case 'put':
1953
- if (!input.value) return err('value is required for put action');
1954
- command = `echo '${input.value.replace(/'/g, "'\\''")}' | gcloud secrets create ${input.path} --data-file=-`;
1955
- break;
1956
- case 'delete':
1957
- command = `gcloud secrets delete ${input.path} --quiet`;
1958
- break;
1959
- case 'versions':
1960
- command = `gcloud secrets versions list ${input.path} --format=json`;
1961
- break;
1962
- case 'rotate':
1963
- return err('rotate for GCP: create a new version with put action');
1964
- }
1965
- break;
1966
- }
1967
- case 'azure': {
1968
- const vaultFlag = input.namespace ? `--vault-name ${input.namespace}` : '';
1969
- switch (input.action) {
1970
- case 'get':
1971
- command = `az keyvault secret show --name ${input.path} ${vaultFlag} --output json`;
1972
- break;
1973
- case 'list':
1974
- command = `az keyvault secret list ${vaultFlag} --output json`;
1975
- break;
1976
- case 'put':
1977
- if (!input.value) return err('value is required for put action');
1978
- command = `az keyvault secret set --name ${input.path} --value '${input.value.replace(/'/g, "'\\''")}' ${vaultFlag}`;
1979
- break;
1980
- case 'delete':
1981
- command = `az keyvault secret delete --name ${input.path} ${vaultFlag}`;
1982
- break;
1983
- case 'versions':
1984
- command = `az keyvault secret list-versions --name ${input.path} ${vaultFlag} --output json`;
1985
- break;
1986
- case 'rotate':
1987
- return err('rotate for Azure: use put to set a new secret version');
1988
- }
1989
- break;
1990
- }
1991
- default:
1992
- return err(`Unknown provider: ${input.provider}`);
1993
- }
1994
-
1995
- const { stdout, stderr } = await execAsync(command!, {
1996
- timeout: 30_000,
1997
- maxBuffer: 1 * 1024 * 1024,
1998
- });
1999
-
2000
- const combined = [stdout, stderr].filter(Boolean).join('\n');
2001
-
2002
- // CRITICAL: Redact secret values from output
2003
- let output = combined;
2004
- if (input.action === 'get') {
2005
- try {
2006
- const parsed = JSON.parse(combined);
2007
- // AWS: redact SecretString
2008
- if (parsed.SecretString) {
2009
- parsed.SecretString = '[REDACTED — value retrieved successfully]';
2010
- }
2011
- // Vault: redact data fields
2012
- if (parsed.data?.data) {
2013
- for (const key of Object.keys(parsed.data.data)) {
2014
- parsed.data.data[key] = '[REDACTED]';
2015
- }
2016
- }
2017
- // GCP: redact payload
2018
- if (parsed.payload?.data) {
2019
- parsed.payload.data = '[REDACTED — value retrieved successfully]';
2020
- }
2021
- // Azure: redact value
2022
- if (parsed.value) {
2023
- parsed.value = '[REDACTED — value retrieved successfully]';
2024
- }
2025
- output = JSON.stringify(parsed, null, 2);
2026
- } catch {
2027
- // Not JSON or parse failed — redact with regex
2028
- output = combined.replace(/"(SecretString|value|data)"\s*:\s*"[^"]*"/g, '"$1": "[REDACTED]"');
2029
- }
2030
- }
2031
-
2032
- return ok(output || '(success)');
2033
- } catch (error: unknown) {
2034
- return err(`Secrets operation failed: ${errorMessage(error)}`);
2035
- }
2036
- },
2037
- };
2038
-
2039
- // ---------------------------------------------------------------------------
2040
- // 15. cicd (CI/CD pipeline management)
2041
- // ---------------------------------------------------------------------------
2042
-
2043
- const cicdSchema = z.object({
2044
- action: z.enum(['list','get','trigger','retry','cancel','logs','status','artifacts'])
2045
- .describe('CI/CD action to perform'),
2046
- provider: z.enum(['github','gitlab','circleci'])
2047
- .describe('CI/CD provider'),
2048
- repo: z.string().optional().describe('Repository in owner/repo format'),
2049
- workflow: z.string().optional().describe('Workflow file or pipeline ID'),
2050
- branch: z.string().optional().describe('Branch name'),
2051
- run_id: z.string().optional().describe('Run/pipeline ID'),
2052
- project_slug: z.string().optional().describe('CircleCI project slug: org-type/org/repo'),
2053
- });
2054
-
2055
- export const cicdTool: ToolDefinition = {
2056
- name: 'cicd',
2057
- description: 'Manage CI/CD pipelines across GitHub Actions, GitLab CI, and CircleCI. List, trigger, retry, cancel, and fetch logs.',
2058
- inputSchema: cicdSchema,
2059
- permissionTier: 'ask_once',
2060
- category: 'devops',
2061
-
2062
- async execute(raw: unknown): Promise<ToolResult> {
2063
- try {
2064
- const input = cicdSchema.parse(raw);
2065
-
2066
- let command: string;
2067
-
2068
- switch (input.provider) {
2069
- case 'github': {
2070
- const repoFlag = input.repo ? `--repo ${input.repo}` : '';
2071
- switch (input.action) {
2072
- case 'list':
2073
- command = `gh workflow list ${repoFlag}`;
2074
- break;
2075
- case 'get':
2076
- command = `gh workflow view ${input.workflow ?? ''} ${repoFlag}`;
2077
- break;
2078
- case 'trigger':
2079
- command = `gh workflow run ${input.workflow ?? ''} ${repoFlag} ${input.branch ? `--ref ${input.branch}` : ''}`.trim();
2080
- break;
2081
- case 'retry':
2082
- command = `gh run rerun ${input.run_id ?? ''} ${repoFlag}`;
2083
- break;
2084
- case 'cancel':
2085
- command = `gh run cancel ${input.run_id ?? ''} ${repoFlag}`;
2086
- break;
2087
- case 'logs':
2088
- command = `gh run view ${input.run_id ?? ''} ${repoFlag} --log 2>&1 | tail -200`;
2089
- break;
2090
- case 'status':
2091
- command = `gh run list ${repoFlag} ${input.workflow ? `--workflow ${input.workflow}` : ''} --limit 10`;
2092
- break;
2093
- case 'artifacts':
2094
- command = `gh run download ${input.run_id ?? ''} ${repoFlag} --dir /tmp/nimbus-artifacts`;
2095
- break;
2096
- default:
2097
- return err(`Unknown action ${input.action} for GitHub Actions`);
2098
- }
2099
- break;
2100
- }
2101
- case 'gitlab': {
2102
- switch (input.action) {
2103
- case 'list':
2104
- command = `glab ci list`;
2105
- break;
2106
- case 'get':
2107
- command = `glab ci get ${input.run_id ?? ''}`;
2108
- break;
2109
- case 'trigger':
2110
- command = `glab ci run ${input.workflow ?? ''} ${input.branch ? `--ref ${input.branch}` : ''}`.trim();
2111
- break;
2112
- case 'retry':
2113
- command = `glab ci retry ${input.run_id ?? ''}`;
2114
- break;
2115
- case 'cancel':
2116
- command = `glab ci cancel ${input.run_id ?? ''}`;
2117
- break;
2118
- case 'logs':
2119
- command = `glab ci trace ${input.run_id ?? ''} 2>&1 | tail -200`;
2120
- break;
2121
- case 'status':
2122
- command = `glab ci status`;
2123
- break;
2124
- case 'artifacts':
2125
- command = `glab ci artifact ${input.run_id ?? ''}`;
2126
- break;
2127
- default:
2128
- return err(`Unknown action ${input.action} for GitLab CI`);
2129
- }
2130
- break;
2131
- }
2132
- case 'circleci': {
2133
- const token = process.env.CIRCLECI_TOKEN;
2134
- const tokenFlag = token ? `-H "Circle-Token: ${token}"` : '';
2135
- const slug = input.project_slug ?? input.repo?.replace('/', '/github/') ?? '';
2136
- switch (input.action) {
2137
- case 'list':
2138
- command = `curl -s ${tokenFlag} "https://circleci.com/api/v2/project/github/${slug}/pipeline?limit=20"`;
2139
- break;
2140
- case 'get':
2141
- command = `curl -s ${tokenFlag} "https://circleci.com/api/v2/pipeline/${input.run_id}"`;
2142
- break;
2143
- case 'trigger':
2144
- command = `curl -s -X POST ${tokenFlag} -H "Content-Type: application/json" -d '{"branch":"${input.branch ?? 'main'}"}' "https://circleci.com/api/v2/project/github/${slug}/pipeline"`;
2145
- break;
2146
- case 'retry':
2147
- command = `curl -s -X POST ${tokenFlag} "https://circleci.com/api/v2/workflow/${input.run_id}/rerun"`;
2148
- break;
2149
- case 'cancel':
2150
- command = `curl -s -X POST ${tokenFlag} "https://circleci.com/api/v2/workflow/${input.run_id}/cancel"`;
2151
- break;
2152
- case 'logs':
2153
- command = `curl -s ${tokenFlag} "https://circleci.com/api/v2/workflow/${input.run_id}/job" | head -200`;
2154
- break;
2155
- case 'status':
2156
- command = `curl -s ${tokenFlag} "https://circleci.com/api/v2/project/github/${slug}/pipeline?limit=10"`;
2157
- break;
2158
- default:
2159
- return err(`Unknown action ${input.action} for CircleCI`);
2160
- }
2161
- break;
2162
- }
2163
- default:
2164
- return err(`Unknown CI/CD provider: ${input.provider}`);
2165
- }
2166
-
2167
- const { stdout, stderr } = await execAsync(command!, {
2168
- timeout: 60_000,
2169
- maxBuffer: 5 * 1024 * 1024,
2170
- });
2171
-
2172
- const combined = [stdout, stderr].filter(Boolean).join('\n');
2173
- // Truncate logs at 200 lines
2174
- const lines = combined.split('\n');
2175
- const truncated = lines.length > 200;
2176
- const output = truncated
2177
- ? lines.slice(0, 200).join('\n') + '\n\n... truncated (showing first 200 lines)'
2178
- : combined;
2179
-
2180
- return ok(output || '(no output)');
2181
- } catch (error: unknown) {
2182
- return err(`CI/CD operation failed: ${errorMessage(error)}`);
2183
- }
2184
- },
2185
- };
2186
-
2187
- // ---------------------------------------------------------------------------
2188
- // 16. monitor (observability)
2189
- // ---------------------------------------------------------------------------
2190
-
2191
- const monitorSchema = z.object({
2192
- action: z.enum(['query','logs','metrics','alerts','dashboards','incidents','ack','resolve','on-call'])
2193
- .describe('Observability action: query/logs/metrics/alerts/dashboards, or PagerDuty/Opsgenie: incidents/ack/resolve/on-call'),
2194
- provider: z.enum(['prometheus','cloudwatch','grafana','datadog','newrelic','pagerduty','opsgenie'])
2195
- .describe('Monitoring or alerting provider'),
2196
- query: z.string().optional().describe('PromQL, CloudWatch Insights, or metric selector'),
2197
- namespace: z.string().optional().describe('Metric namespace or Kubernetes namespace'),
2198
- start_time: z.string().optional().describe('Start time: ISO8601 or relative (-1h, -30m)'),
2199
- end_time: z.string().optional().describe('End time: ISO8601 or "now"'),
2200
- region: z.string().optional().describe('Cloud region'),
2201
- log_group: z.string().optional().describe('CloudWatch log group name'),
2202
- incident_id: z.string().optional().describe('Incident/alert ID for ack or resolve actions'),
2203
- });
2204
-
2205
- export const monitorTool: ToolDefinition = {
2206
- name: 'monitor',
2207
- description: 'Query observability data from Prometheus, CloudWatch, Grafana, Datadog, and New Relic. Read-only.',
2208
- inputSchema: monitorSchema,
2209
- permissionTier: 'auto_allow',
2210
- category: 'devops',
2211
-
2212
- async execute(raw: unknown): Promise<ToolResult> {
2213
- try {
2214
- const input = monitorSchema.parse(raw);
2215
-
2216
- // Parse relative times
2217
- function parseTime(t: string | undefined, defaultSecs: number): number {
2218
- if (!t) return Math.floor(Date.now() / 1000) - defaultSecs;
2219
- if (t.startsWith('-')) {
2220
- const val = parseInt(t.slice(1));
2221
- const unit = t.slice(-1);
2222
- const mult = unit === 'h' ? 3600 : unit === 'm' ? 60 : 1;
2223
- return Math.floor(Date.now() / 1000) - val * mult;
2224
- }
2225
- return Math.floor(new Date(t).getTime() / 1000);
2226
- }
2227
-
2228
- const startTs = parseTime(input.start_time, 3600);
2229
- const endTs = parseTime(input.end_time, 0);
2230
-
2231
- switch (input.provider) {
2232
- case 'prometheus': {
2233
- const baseUrl = process.env.PROMETHEUS_URL ?? 'http://localhost:9090';
2234
- const q = encodeURIComponent(input.query ?? 'up');
2235
- const cmd = `curl -sf "${baseUrl}/api/v1/query_range?query=${q}&start=${startTs}&end=${endTs}&step=60" | head -c 50000`;
2236
- const { stdout } = await execAsync(cmd, { timeout: 30_000 });
2237
- try {
2238
- const data = JSON.parse(stdout);
2239
- const results = data?.data?.result ?? [];
2240
- const lines = results.slice(0, 100).map((r: { metric: Record<string, string>; values: [number, string][] }) => {
2241
- const metric = Object.entries(r.metric).map(([k, v]) => `${k}="${v}"`).join(',');
2242
- const latest = r.values[r.values.length - 1];
2243
- return `{${metric}} = ${latest?.[1] ?? 'N/A'} (at ${latest ? new Date(latest[0] * 1000).toISOString() : 'N/A'})`;
2244
- });
2245
- return ok(`Prometheus query results (${results.length} series):\n${lines.join('\n')}`);
2246
- } catch {
2247
- return ok(stdout.slice(0, 5000));
2248
- }
2249
- }
2250
-
2251
- case 'cloudwatch': {
2252
- const regionFlag = input.region ? `--region ${input.region}` : '';
2253
- if (input.action === 'logs' && input.log_group) {
2254
- const cmd = `aws logs filter-log-events --log-group-name ${input.log_group} --start-time ${startTs * 1000} --end-time ${endTs * 1000} ${regionFlag} --output json`;
2255
- const { stdout } = await execAsync(cmd, { timeout: 60_000, maxBuffer: 5 * 1024 * 1024 });
2256
- const data = JSON.parse(stdout);
2257
- const events = (data.events ?? []).slice(0, 100);
2258
- return ok(events.map((e: { timestamp: number; message: string }) => `[${new Date(e.timestamp).toISOString()}] ${e.message}`).join('\n'));
2259
- }
2260
- const metricName = input.query ?? 'CPUUtilization';
2261
- const ns = input.namespace ?? 'AWS/EC2';
2262
- const cmd = `aws cloudwatch get-metric-statistics --metric-name ${metricName} --namespace ${ns} --start-time ${new Date(startTs * 1000).toISOString()} --end-time ${new Date(endTs * 1000).toISOString()} --period 300 --statistics Average ${regionFlag} --output json`;
2263
- const { stdout } = await execAsync(cmd, { timeout: 30_000, maxBuffer: 2 * 1024 * 1024 });
2264
- return ok(stdout.slice(0, 5000));
2265
- }
2266
-
2267
- case 'grafana': {
2268
- const baseUrl = process.env.GRAFANA_URL ?? 'http://localhost:3000';
2269
- const token = process.env.GRAFANA_TOKEN ?? '';
2270
- const authFlag = token ? `-H "Authorization: Bearer ${token}"` : '';
2271
- const cmd = `curl -sf ${authFlag} "${baseUrl}/api/dashboards/home" | head -c 10000`;
2272
- const { stdout } = await execAsync(cmd, { timeout: 15_000 });
2273
- return ok(stdout || '(no dashboards found)');
2274
- }
2275
-
2276
- case 'datadog': {
2277
- const apiKey = process.env.DD_API_KEY ?? '';
2278
- const appKey = process.env.DD_APP_KEY ?? '';
2279
- if (!apiKey) return err('DD_API_KEY environment variable not set');
2280
- const q = encodeURIComponent(input.query ?? 'avg:system.cpu.user{*}');
2281
- const cmd = `curl -sf -H "DD-API-KEY: ${apiKey}" -H "DD-APPLICATION-KEY: ${appKey}" "https://api.datadoghq.com/api/v1/query?from=${startTs}&to=${endTs}&query=${q}"`;
2282
- const { stdout } = await execAsync(cmd, { timeout: 30_000 });
2283
- const data = JSON.parse(stdout);
2284
- const series = (data.series ?? []).slice(0, 100);
2285
- return ok(`Datadog query (${series.length} series):\n` + JSON.stringify(series.map((s: { metric: string; pointlist: [number, number][] }) => ({ metric: s.metric, points: s.pointlist.length })), null, 2));
2286
- }
2287
-
2288
- case 'newrelic': {
2289
- const apiKey = process.env.NEW_RELIC_API_KEY ?? '';
2290
- if (!apiKey) return err('NEW_RELIC_API_KEY environment variable not set');
2291
- const nrqlQuery = input.query ?? `SELECT average(cpuPercent) FROM SystemSample SINCE 1 hour ago`;
2292
- const body = JSON.stringify({ query: `{ actor { nrql(accounts: 0, query: "${nrqlQuery.replace(/"/g, '\\"')}") { results } } }` });
2293
- const cmd = `curl -sf -X POST -H "Content-Type: application/json" -H "API-Key: ${apiKey}" -d '${body.replace(/'/g, "'\\''")}' "https://api.newrelic.com/graphql"`;
2294
- const { stdout } = await execAsync(cmd, { timeout: 30_000 });
2295
- return ok(stdout.slice(0, 5000));
2296
- }
2297
-
2298
- // Gap 5: PagerDuty alert management
2299
- case 'pagerduty': {
2300
- const pdKey = process.env.PD_API_KEY ?? '';
2301
- if (!pdKey) return err('PD_API_KEY environment variable not set');
2302
- const authHeader = `-H "Authorization: Token token=${pdKey}" -H "Accept: application/vnd.pagerduty+json;version=2"`;
2303
- switch (input.action) {
2304
- case 'incidents':
2305
- return ok((await execAsync(`curl -sf ${authHeader} "https://api.pagerduty.com/incidents?statuses[]=triggered&statuses[]=acknowledged&limit=25"`, { timeout: 15_000 })).stdout.slice(0, 5000));
2306
- case 'alerts':
2307
- return ok((await execAsync(`curl -sf ${authHeader} "https://api.pagerduty.com/alerts?limit=25"`, { timeout: 15_000 })).stdout.slice(0, 5000));
2308
- case 'ack': {
2309
- if (!input.incident_id) return err('incident_id required for ack action');
2310
- const body = JSON.stringify({ incident: { type: 'incident_reference', status: 'acknowledged' } });
2311
- return ok((await execAsync(`curl -sf -X PUT ${authHeader} -H "Content-Type: application/json" -d '${body}' "https://api.pagerduty.com/incidents/${input.incident_id}"`, { timeout: 15_000 })).stdout.slice(0, 2000));
2312
- }
2313
- case 'resolve': {
2314
- if (!input.incident_id) return err('incident_id required for resolve action');
2315
- const body = JSON.stringify({ incident: { type: 'incident_reference', status: 'resolved' } });
2316
- return ok((await execAsync(`curl -sf -X PUT ${authHeader} -H "Content-Type: application/json" -d '${body}' "https://api.pagerduty.com/incidents/${input.incident_id}"`, { timeout: 15_000 })).stdout.slice(0, 2000));
2317
- }
2318
- case 'on-call':
2319
- return ok((await execAsync(`curl -sf ${authHeader} "https://api.pagerduty.com/oncalls?limit=25"`, { timeout: 15_000 })).stdout.slice(0, 3000));
2320
- default:
2321
- return err(`PagerDuty action not supported: ${input.action}`);
2322
- }
2323
- }
2324
-
2325
- // Gap 5: Opsgenie alert management
2326
- case 'opsgenie': {
2327
- const ogKey = process.env.OPSGENIE_API_KEY ?? '';
2328
- if (!ogKey) return err('OPSGENIE_API_KEY environment variable not set');
2329
- const authHeader = `-H "Authorization: GenieKey ${ogKey}"`;
2330
- switch (input.action) {
2331
- case 'alerts':
2332
- case 'incidents':
2333
- return ok((await execAsync(`curl -sf ${authHeader} "https://api.opsgenie.com/v2/alerts?limit=25"`, { timeout: 15_000 })).stdout.slice(0, 5000));
2334
- case 'ack': {
2335
- if (!input.incident_id) return err('incident_id required for ack action');
2336
- const body = JSON.stringify({ note: 'Acknowledged via Nimbus' });
2337
- return ok((await execAsync(`curl -sf -X POST ${authHeader} -H "Content-Type: application/json" -d '${JSON.stringify(body)}' "https://api.opsgenie.com/v2/alerts/${input.incident_id}/acknowledge"`, { timeout: 15_000 })).stdout.slice(0, 2000));
2338
- }
2339
- case 'resolve': {
2340
- if (!input.incident_id) return err('incident_id required for resolve action');
2341
- const body = JSON.stringify({ note: 'Resolved via Nimbus' });
2342
- return ok((await execAsync(`curl -sf -X POST ${authHeader} -H "Content-Type: application/json" -d '${JSON.stringify(body)}' "https://api.opsgenie.com/v2/alerts/${input.incident_id}/close"`, { timeout: 15_000 })).stdout.slice(0, 2000));
2343
- }
2344
- case 'on-call':
2345
- return ok((await execAsync(`curl -sf ${authHeader} "https://api.opsgenie.com/v2/schedules/on-calls"`, { timeout: 15_000 })).stdout.slice(0, 3000));
2346
- default:
2347
- return err(`Opsgenie action not supported: ${input.action}`);
2348
- }
2349
- }
2350
-
2351
- default:
2352
- return err(`Unknown monitoring provider: ${input.provider}`);
2353
- }
2354
- } catch (error: unknown) {
2355
- return err(`Monitoring query failed: ${errorMessage(error)}`);
2356
- }
2357
- },
2358
- };
2359
-
2360
- // ---------------------------------------------------------------------------
2361
- // 17. gitops (ArgoCD & Flux)
2362
- // ---------------------------------------------------------------------------
2363
-
2364
- const gitopsSchema = z.object({
2365
- action: z.enum(['list','get','sync','reconcile','diff','history','rollback','health','logs','argocd-status','flux-status'])
2366
- .describe('GitOps action to perform. argocd-status/flux-status: concise cluster-wide status summary'),
2367
- provider: z.enum(['argocd','flux'])
2368
- .describe('GitOps provider'),
2369
- app: z.string().optional().describe('Application or HelmRelease name'),
2370
- namespace: z.string().optional().describe('Kubernetes namespace'),
2371
- server: z.string().optional().describe('ArgoCD server URL (or use ARGOCD_SERVER env)'),
2372
- revision: z.string().optional().describe('Revision or rollback target'),
2373
- });
2374
-
2375
- export const gitopsTool: ToolDefinition = {
2376
- name: 'gitops',
2377
- description: 'Manage GitOps deployments via ArgoCD and Flux. Sync apps, check health, view diffs, and rollback.',
2378
- inputSchema: gitopsSchema,
2379
- permissionTier: 'ask_once',
2380
- category: 'devops',
2381
- isDestructive: false,
2382
-
2383
- async execute(raw: unknown): Promise<ToolResult> {
2384
- try {
2385
- const input = gitopsSchema.parse(raw);
2386
-
2387
- let command: string;
2388
-
2389
- if (input.provider === 'argocd') {
2390
- const server = input.server ?? process.env.ARGOCD_SERVER ?? '';
2391
- const serverFlag = server ? `--server ${server}` : '';
2392
- const token = process.env.ARGOCD_TOKEN ?? '';
2393
- const tokenFlag = token ? `--auth-token ${token}` : '';
2394
- const flags = [serverFlag, tokenFlag, '--grpc-web'].filter(Boolean).join(' ');
2395
- const nsFlag = input.namespace ? `-n ${input.namespace}` : '';
2396
-
2397
- switch (input.action) {
2398
- case 'list':
2399
- command = `argocd app list ${flags}`;
2400
- break;
2401
- case 'get':
2402
- command = `argocd app get ${input.app ?? ''} ${flags}`;
2403
- break;
2404
- case 'sync':
2405
- command = `argocd app sync ${input.app ?? ''} ${flags}`;
2406
- break;
2407
- case 'diff':
2408
- command = `argocd app diff ${input.app ?? ''} ${flags}`;
2409
- break;
2410
- case 'history':
2411
- command = `argocd app history ${input.app ?? ''} ${flags}`;
2412
- break;
2413
- case 'rollback':
2414
- command = `argocd app rollback ${input.app ?? ''} ${input.revision ?? ''} ${flags}`;
2415
- break;
2416
- case 'health':
2417
- command = `argocd app get ${input.app ?? ''} ${flags} -o json`;
2418
- break;
2419
- case 'logs':
2420
- command = `argocd app logs ${input.app ?? ''} ${flags} ${nsFlag} --tail=200`;
2421
- break;
2422
- case 'argocd-status':
2423
- command = `argocd app list ${flags} -o wide`;
2424
- break;
2425
- case 'flux-status':
2426
- command = `argocd app list ${flags}`;
2427
- break;
2428
- default:
2429
- return err(`Action ${input.action} not supported for ArgoCD`);
2430
- }
2431
- } else if (input.provider === 'flux') {
2432
- const nsFlag = input.namespace ? `-n ${input.namespace}` : '';
2433
- switch (input.action) {
2434
- case 'list':
2435
- command = `flux get all ${nsFlag}`;
2436
- break;
2437
- case 'get':
2438
- command = `flux get kustomizations ${input.app ?? ''} ${nsFlag}`;
2439
- break;
2440
- case 'sync':
2441
- case 'reconcile':
2442
- command = `flux reconcile kustomization ${input.app ?? 'flux-system'} ${nsFlag}`;
2443
- break;
2444
- case 'diff':
2445
- command = `flux diff kustomization ${input.app ?? ''} ${nsFlag}`;
2446
- break;
2447
- case 'history':
2448
- command = `kubectl get events ${nsFlag} --field-selector reason=ReconcileSucceeded`;
2449
- break;
2450
- case 'rollback':
2451
- return err('Flux rollback: revert the Git commit and reconcile to roll back');
2452
- case 'health':
2453
- command = `flux get all ${nsFlag} -o json`;
2454
- break;
2455
- case 'logs':
2456
- command = `flux logs ${nsFlag} --tail=200`;
2457
- break;
2458
- case 'flux-status':
2459
- command = `flux get all ${nsFlag}`;
2460
- break;
2461
- case 'argocd-status':
2462
- command = `flux get all ${nsFlag} -o json`;
2463
- break;
2464
- default:
2465
- return err(`Action ${input.action} not supported for Flux`);
2466
- }
2467
- } else {
2468
- return err(`Unknown provider: ${input.provider}`);
2469
- }
2470
-
2471
- const { stdout, stderr } = await execAsync(command!, {
2472
- timeout: 120_000,
2473
- maxBuffer: 5 * 1024 * 1024,
2474
- });
2475
-
2476
- // For health action, parse and simplify ArgoCD JSON
2477
- if (input.action === 'health' && input.provider === 'argocd') {
2478
- try {
2479
- const app = JSON.parse(stdout);
2480
- const health = app?.status?.health?.status ?? 'Unknown';
2481
- const sync = app?.status?.sync?.status ?? 'Unknown';
2482
- const conditions = (app?.status?.conditions ?? []).map((c: { type: string; message: string }) => ` ${c.type}: ${c.message}`).join('\n');
2483
- return ok(`App: ${app?.metadata?.name}\nHealth: ${health}\nSync: ${sync}\n${conditions ? 'Conditions:\n' + conditions : ''}`);
2484
- } catch {
2485
- // Fall through to raw output
2486
- }
2487
- }
2488
-
2489
- const combined = [stdout, stderr].filter(Boolean).join('\n');
2490
- return ok(combined || '(no output)');
2491
- } catch (error: unknown) {
2492
- return err(`GitOps operation failed: ${errorMessage(error)}`);
2493
- }
2494
- },
2495
- };
2496
-
2497
- // ---------------------------------------------------------------------------
2498
- // 18. cloud_action
2499
- // ---------------------------------------------------------------------------
2500
-
2501
- const cloudActionSchema = z.object({
2502
- action: z.enum(['start','stop','restart','create','delete','scale','describe','list'])
2503
- .describe('Action to perform on the cloud resource'),
2504
- provider: z.enum(['aws','gcp','azure'])
2505
- .describe('Cloud provider'),
2506
- service: z.string().describe('Service type: ec2, rds, eks, ecs, gce, gke, aks, functions, etc.'),
2507
- resource_id: z.string().optional().describe('Resource ID, name, or ARN'),
2508
- config: z.record(z.string(), z.unknown()).optional().describe('Additional configuration parameters'),
2509
- region: z.string().optional().describe('Cloud region'),
2510
- });
2511
-
2512
- export const cloudActionTool: ToolDefinition = {
2513
- name: 'cloud_action',
2514
- description: 'Perform actions on cloud resources (start/stop/scale/create/delete) across AWS, GCP, and Azure.',
2515
- inputSchema: cloudActionSchema,
2516
- permissionTier: 'ask_once',
2517
- category: 'devops',
2518
- isDestructive: true,
2519
-
2520
- async execute(raw: unknown): Promise<ToolResult> {
2521
- try {
2522
- const input = cloudActionSchema.parse(raw);
2523
- const regionFlag = input.region ? `--region ${input.region}` : '';
2524
- const id = input.resource_id ?? '';
2525
-
2526
- let command: string;
2527
-
2528
- if (input.provider === 'aws') {
2529
- switch (`${input.service}:${input.action}`) {
2530
- case 'ec2:start':
2531
- command = `aws ec2 start-instances --instance-ids ${id} ${regionFlag} --output json`;
2532
- break;
2533
- case 'ec2:stop':
2534
- command = `aws ec2 stop-instances --instance-ids ${id} ${regionFlag} --output json`;
2535
- break;
2536
- case 'ec2:describe':
2537
- case 'ec2:list':
2538
- command = `aws ec2 describe-instances --instance-ids ${id} ${regionFlag} --output json`;
2539
- break;
2540
- case 'rds:start':
2541
- command = `aws rds start-db-instance --db-instance-identifier ${id} ${regionFlag}`;
2542
- break;
2543
- case 'rds:stop':
2544
- command = `aws rds stop-db-instance --db-instance-identifier ${id} ${regionFlag}`;
2545
- break;
2546
- case 'ecs:scale':
2547
- const desired = (input.config as Record<string,unknown>)?.desired ?? 1;
2548
- command = `aws ecs update-service --service ${id} --desired-count ${desired} ${regionFlag}`;
2549
- break;
2550
- default:
2551
- command = `aws ${input.service} ${input.action} ${id} ${regionFlag} --output json`;
2552
- }
2553
- } else if (input.provider === 'gcp') {
2554
- switch (`${input.service}:${input.action}`) {
2555
- case 'gce:start':
2556
- command = `gcloud compute instances start ${id}`;
2557
- break;
2558
- case 'gce:stop':
2559
- command = `gcloud compute instances stop ${id}`;
2560
- break;
2561
- default:
2562
- command = `gcloud ${input.service} ${input.action} ${id} --format=json`;
2563
- }
2564
- } else if (input.provider === 'azure') {
2565
- switch (`${input.service}:${input.action}`) {
2566
- case 'vm:start':
2567
- command = `az vm start --name ${id} --output json`;
2568
- break;
2569
- case 'vm:stop':
2570
- command = `az vm stop --name ${id} --output json`;
2571
- break;
2572
- default:
2573
- command = `az ${input.service} ${input.action} --name ${id} --output json`;
2574
- }
2575
- } else {
2576
- return err(`Unknown provider: ${input.provider}`);
2577
- }
2578
-
2579
- const { stdout, stderr } = await execAsync(command!, {
2580
- timeout: 120_000,
2581
- maxBuffer: 5 * 1024 * 1024,
2582
- });
2583
-
2584
- const combined = [stdout, stderr].filter(Boolean).join('\n');
2585
- return ok(combined || '(success)');
2586
- } catch (error: unknown) {
2587
- return err(`Cloud action failed: ${errorMessage(error)}`);
2588
- }
2589
- },
2590
- };
2591
-
2592
- // ---------------------------------------------------------------------------
2593
- // 19. logs (log streaming)
2594
- // ---------------------------------------------------------------------------
2595
-
2596
- const logsSchema = z.object({
2597
- action: z.enum(['tail','search','download'])
2598
- .describe('Log action to perform'),
2599
- provider: z.enum(['cloudwatch','kubernetes','loki','elasticsearch'])
2600
- .describe('Log provider'),
2601
- source: z.string().describe('Log group, pod name, Loki label selector, or index'),
2602
- filter: z.string().optional().describe('Filter expression or query string'),
2603
- lines: z.number().optional().default(100).describe('Number of lines to retrieve (default: 100)'),
2604
- since: z.string().optional().describe('Time range: -1h, -30m, or ISO8601'),
2605
- namespace: z.string().optional().describe('Kubernetes namespace'),
2606
- region: z.string().optional().describe('Cloud region'),
2607
- follow: z.boolean().optional().default(false).describe('Follow/stream logs in real-time (only valid for kubernetes provider)'),
2608
- });
2609
-
2610
- export const logsTool: ToolDefinition = {
2611
- name: 'logs',
2612
- description: 'Tail, search, or download logs from CloudWatch, Kubernetes pods, Loki, or Elasticsearch. Read-only.',
2613
- inputSchema: logsSchema,
2614
- permissionTier: 'auto_allow',
2615
- category: 'devops',
2616
-
2617
- async execute(raw: unknown, ctx?: import('./types').ToolExecuteContext): Promise<ToolResult> {
2618
- try {
2619
- const input = logsSchema.parse(raw);
2620
- const maxLines = Math.min(input.lines ?? 100, 200);
2621
-
2622
- let command: string;
2623
-
2624
- switch (input.provider) {
2625
- case 'kubernetes': {
2626
- const nsFlag = input.namespace ? `-n ${input.namespace}` : '';
2627
- const sinceFlag = input.since ? `--since=${input.since.replace('-', '')}` : '';
2628
- const followFlag = input.follow ? '-f' : `--tail=${maxLines}`;
2629
- command = `kubectl logs ${input.source} ${nsFlag} ${sinceFlag} ${followFlag} ${input.filter ? `| grep ${input.filter}` : ''}`.trim();
2630
-
2631
- // For follow mode, use spawnExec with streaming
2632
- if (input.follow && ctx?.onProgress) {
2633
- const timeoutMs = ctx?.timeout ?? 300_000;
2634
- const abortController = new AbortController();
2635
- if (ctx?.signal) {
2636
- ctx.signal.addEventListener('abort', () => abortController.abort());
2637
- }
2638
- const spawnResult = await spawnExec(command, { onChunk: ctx.onProgress, timeout: timeoutMs });
2639
- const combined = [spawnResult.stdout, spawnResult.stderr].filter(Boolean).join('\n');
2640
- return ok(combined || '(log stream ended)');
2641
- }
2642
- break;
2643
- }
2644
- case 'cloudwatch': {
2645
- const regionFlag = input.region ? `--region ${input.region}` : '';
2646
- const endMs = Date.now();
2647
- const sinceMs = input.since
2648
- ? (input.since.startsWith('-')
2649
- ? Date.now() - parseInt(input.since.slice(1)) * (input.since.endsWith('h') ? 3600000 : 60000)
2650
- : new Date(input.since).getTime())
2651
- : endMs - 3600000;
2652
- command = `aws logs filter-log-events --log-group-name ${input.source} --start-time ${sinceMs} --end-time ${endMs} ${input.filter ? `--filter-pattern "${input.filter}"` : ''} ${regionFlag} --output json`;
2653
- break;
2654
- }
2655
- case 'loki': {
2656
- const lokiUrl = process.env.LOKI_URL ?? 'http://localhost:3100';
2657
- const q = encodeURIComponent(input.filter ? `{${input.source}} |= "${input.filter}"` : `{${input.source}}`);
2658
- command = `curl -sf "${lokiUrl}/loki/api/v1/query_range?query=${q}&limit=${maxLines}" | head -c 50000`;
2659
- break;
2660
- }
2661
- case 'elasticsearch': {
2662
- const esUrl = process.env.ELASTICSEARCH_URL ?? 'http://localhost:9200';
2663
- const body = JSON.stringify({ query: { match_all: {} }, size: maxLines });
2664
- command = `curl -sf -X POST "${esUrl}/${input.source}/_search" -H "Content-Type: application/json" -d '${body.replace(/'/g, "'\\''")}' | head -c 50000`;
2665
- break;
2666
- }
2667
- default:
2668
- return err(`Unknown log provider: ${input.provider}`);
2669
- }
2670
-
2671
- const { stdout, stderr } = await execAsync(command!, {
2672
- timeout: 60_000,
2673
- maxBuffer: 5 * 1024 * 1024,
2674
- });
2675
-
2676
- const combined = [stdout, stderr].filter(Boolean).join('\n');
2677
- const lines = combined.split('\n');
2678
- const output = lines.length > maxLines
2679
- ? lines.slice(0, maxLines).join('\n') + `\n\n... truncated at ${maxLines} lines`
2680
- : combined;
2681
-
2682
- return ok(output || '(no logs found)');
2683
- } catch (error: unknown) {
2684
- return err(`Log query failed: ${errorMessage(error)}`);
2685
- }
2686
- },
2687
- };
2688
-
2689
- // ---------------------------------------------------------------------------
2690
- // 20. certs (certificate management)
2691
- // ---------------------------------------------------------------------------
2692
-
2693
- const certsSchema = z.object({
2694
- action: z.enum(['list','get','renew','issue','delete','status'])
2695
- .describe('Certificate action to perform'),
2696
- provider: z.enum(['cert-manager','acm','gcp','letsencrypt'])
2697
- .describe('Certificate provider'),
2698
- domain: z.string().optional().describe('Domain name'),
2699
- namespace: z.string().optional().describe('Kubernetes namespace for cert-manager'),
2700
- arn: z.string().optional().describe('ACM certificate ARN'),
2701
- });
2702
-
2703
- export const certsTool: ToolDefinition = {
2704
- name: 'certs',
2705
- description: "Manage TLS certificates via cert-manager, AWS ACM, GCP Certificate Manager, and Let\'s Encrypt.",
2706
- inputSchema: certsSchema,
2707
- permissionTier: 'ask_once',
2708
- category: 'devops',
2709
-
2710
- async execute(raw: unknown): Promise<ToolResult> {
2711
- try {
2712
- const input = certsSchema.parse(raw);
2713
- const nsFlag = input.namespace ? `-n ${input.namespace}` : '';
2714
-
2715
- let command: string;
2716
-
2717
- switch (input.provider) {
2718
- case 'cert-manager':
2719
- switch (input.action) {
2720
- case 'list':
2721
- command = `kubectl get certificates ${nsFlag} -o wide`;
2722
- break;
2723
- case 'get':
2724
- command = `kubectl describe certificate ${input.domain ?? ''} ${nsFlag}`;
2725
- break;
2726
- case 'status':
2727
- command = `kubectl get certificaterequest ${nsFlag} -o wide`;
2728
- break;
2729
- case 'renew':
2730
- command = `kubectl annotate certificate ${input.domain ?? ''} ${nsFlag} cert-manager.io/issuer-name=$(kubectl get cert ${input.domain ?? ''} ${nsFlag} -o jsonpath='{.spec.issuerRef.name}') --overwrite`;
2731
- break;
2732
- case 'issue':
2733
- return err('Issue via cert-manager: create a Certificate resource manifest and apply with kubectl');
2734
- case 'delete':
2735
- command = `kubectl delete certificate ${input.domain ?? ''} ${nsFlag}`;
2736
- break;
2737
- }
2738
- break;
2739
- case 'acm':
2740
- switch (input.action) {
2741
- case 'list':
2742
- command = `aws acm list-certificates --output json`;
2743
- break;
2744
- case 'get':
2745
- case 'status':
2746
- command = `aws acm describe-certificate --certificate-arn ${input.arn ?? ''} --output json`;
2747
- break;
2748
- case 'renew':
2749
- command = `aws acm renew-certificate --certificate-arn ${input.arn ?? ''}`;
2750
- break;
2751
- case 'issue':
2752
- command = `aws acm request-certificate --domain-name ${input.domain ?? ''} --validation-method DNS --output json`;
2753
- break;
2754
- case 'delete':
2755
- command = `aws acm delete-certificate --certificate-arn ${input.arn ?? ''}`;
2756
- break;
2757
- }
2758
- break;
2759
- case 'gcp':
2760
- switch (input.action) {
2761
- case 'list':
2762
- command = `gcloud certificate-manager certificates list --format=json`;
2763
- break;
2764
- case 'get':
2765
- case 'status':
2766
- command = `gcloud certificate-manager certificates describe ${input.domain ?? ''} --format=json`;
2767
- break;
2768
- case 'issue':
2769
- command = `gcloud certificate-manager certificates create ${input.domain ?? ''} --domains=${input.domain ?? ''} --format=json`;
2770
- break;
2771
- case 'delete':
2772
- command = `gcloud certificate-manager certificates delete ${input.domain ?? ''} --quiet`;
2773
- break;
2774
- default:
2775
- return err(`Action ${input.action} not supported for GCP Certificate Manager`);
2776
- }
2777
- break;
2778
- case 'letsencrypt':
2779
- switch (input.action) {
2780
- case 'issue':
2781
- command = `certbot certonly --standalone -d ${input.domain ?? ''} --non-interactive --agree-tos`;
2782
- break;
2783
- case 'renew':
2784
- command = `certbot renew --cert-name ${input.domain ?? ''} --non-interactive`;
2785
- break;
2786
- case 'list':
2787
- command = `certbot certificates`;
2788
- break;
2789
- case 'status':
2790
- command = `certbot certificates --cert-name ${input.domain ?? ''}`;
2791
- break;
2792
- default:
2793
- return err(`Action ${input.action} not supported for Let\'s Encrypt`);
2794
- }
2795
- break;
2796
- default:
2797
- return err(`Unknown certificate provider: ${input.provider}`);
2798
- }
2799
-
2800
- const { stdout, stderr } = await execAsync(command!, {
2801
- timeout: 120_000,
2802
- maxBuffer: 2 * 1024 * 1024,
2803
- });
2804
-
2805
- const combined = [stdout, stderr].filter(Boolean).join('\n');
2806
- return ok(combined || '(success)');
2807
- } catch (error: unknown) {
2808
- return err(`Certificate operation failed: ${errorMessage(error)}`);
2809
- }
2810
- },
2811
- };
2812
-
2813
- // ---------------------------------------------------------------------------
2814
- // 21. mesh (service mesh — Istio & Linkerd)
2815
- // ---------------------------------------------------------------------------
2816
-
2817
- const meshSchema = z.object({
2818
- action: z.enum(['status','traffic-split','mtls-status','virtual-service','gateway','inject','tap','routes'])
2819
- .describe('Service mesh action to perform'),
2820
- provider: z.enum(['istio','linkerd'])
2821
- .describe('Service mesh provider'),
2822
- namespace: z.string().optional().describe('Kubernetes namespace'),
2823
- service: z.string().optional().describe('Service name'),
2824
- args: z.string().optional().describe('Additional arguments'),
2825
- });
2826
-
2827
- export const meshTool: ToolDefinition = {
2828
- name: 'mesh',
2829
- description: 'Manage Istio and Linkerd service mesh operations: traffic splitting, mTLS status, virtual services, and routes.',
2830
- inputSchema: meshSchema,
2831
- permissionTier: 'ask_once',
2832
- category: 'devops',
2833
-
2834
- async execute(raw: unknown): Promise<ToolResult> {
2835
- try {
2836
- const input = meshSchema.parse(raw);
2837
- const nsFlag = input.namespace ? `-n ${input.namespace}` : '';
2838
-
2839
- let command: string;
2840
-
2841
- if (input.provider === 'istio') {
2842
- switch (input.action) {
2843
- case 'status':
2844
- command = `istioctl proxy-status ${input.service ?? ''} ${nsFlag}`;
2845
- break;
2846
- case 'mtls-status':
2847
- command = `istioctl x describe pod ${input.service ?? ''} ${nsFlag}`;
2848
- break;
2849
- case 'virtual-service':
2850
- command = `kubectl get virtualservice ${input.service ?? ''} ${nsFlag} -o yaml`;
2851
- break;
2852
- case 'gateway':
2853
- command = `kubectl get gateway ${nsFlag} -o yaml`;
2854
- break;
2855
- case 'inject':
2856
- return err('Inject: use `kubectl label namespace <ns> istio-injection=enabled` and redeploy');
2857
- case 'tap':
2858
- command = `istioctl proxy-config ${input.args ?? 'cluster'} ${input.service ?? ''} ${nsFlag}`;
2859
- break;
2860
- case 'routes':
2861
- command = `istioctl proxy-config routes ${input.service ?? ''} ${nsFlag}`;
2862
- break;
2863
- case 'traffic-split':
2864
- command = `kubectl get virtualservice,destinationrule ${nsFlag} -o yaml`;
2865
- break;
2866
- default:
2867
- return err(`Unknown action ${input.action} for Istio`);
2868
- }
2869
- } else if (input.provider === 'linkerd') {
2870
- switch (input.action) {
2871
- case 'status':
2872
- command = `linkerd check ${nsFlag}`;
2873
- break;
2874
- case 'mtls-status':
2875
- command = `linkerd edges pod ${nsFlag}`;
2876
- break;
2877
- case 'tap':
2878
- command = `linkerd tap ${input.service ?? ''} ${nsFlag} ${input.args ?? ''}`.trim();
2879
- break;
2880
- case 'routes':
2881
- command = `linkerd routes ${input.service ?? ''} ${nsFlag}`;
2882
- break;
2883
- case 'traffic-split':
2884
- command = `kubectl get trafficsplit ${nsFlag} -o yaml`;
2885
- break;
2886
- default:
2887
- return err(`Action ${input.action} not supported for Linkerd`);
2888
- }
2889
- } else {
2890
- return err(`Unknown service mesh provider: ${input.provider}`);
2891
- }
2892
-
2893
- const { stdout, stderr } = await execAsync(command!, {
2894
- timeout: 60_000,
2895
- maxBuffer: 5 * 1024 * 1024,
2896
- });
2897
-
2898
- const combined = [stdout, stderr].filter(Boolean).join('\n');
2899
- return ok(combined || '(no output)');
2900
- } catch (error: unknown) {
2901
- return err(`Service mesh operation failed: ${errorMessage(error)}`);
2902
- }
2903
- },
2904
- };
2905
-
2906
- // ---------------------------------------------------------------------------
2907
- // 22. cfn (CloudFormation & CDK)
2908
- // ---------------------------------------------------------------------------
2909
-
2910
- const cfnSchema = z.object({
2911
- action: z.enum(['list','describe','create','update','delete','validate','events','drift','deploy','diff'])
2912
- .describe('CloudFormation/CDK action'),
2913
- stack_name: z.string().optional().describe('CloudFormation stack name'),
2914
- template: z.string().optional().describe('Template file path or URL'),
2915
- parameters: z.string().optional().describe('Key=Value pairs for stack parameters'),
2916
- region: z.string().optional().describe('AWS region'),
2917
- provider: z.enum(['cloudformation','cdk']).default('cloudformation').describe('IaC provider'),
2918
- });
2919
-
2920
- export const cfnTool: ToolDefinition = {
2921
- name: 'cfn',
2922
- description: 'Manage AWS CloudFormation stacks and CDK applications: list, describe, create, update, delete, validate, and detect drift.',
2923
- inputSchema: cfnSchema,
2924
- permissionTier: 'ask_once',
2925
- category: 'devops',
2926
- isDestructive: true,
2927
-
2928
- async execute(raw: unknown): Promise<ToolResult> {
2929
- try {
2930
- const input = cfnSchema.parse(raw);
2931
- const regionFlag = input.region ? `--region ${input.region}` : '';
2932
- const stack = input.stack_name ?? '';
2933
-
2934
- let command: string;
2935
-
2936
- if (input.provider === 'cdk') {
2937
- switch (input.action) {
2938
- case 'list':
2939
- command = `cdk list`;
2940
- break;
2941
- case 'diff':
2942
- command = `cdk diff ${stack}`;
2943
- break;
2944
- case 'deploy':
2945
- command = `cdk deploy ${stack} --require-approval never`;
2946
- break;
2947
- case 'delete':
2948
- command = `cdk destroy ${stack} --force`;
2949
- break;
2950
- default:
2951
- return err(`CDK does not support action: ${input.action}. Use deploy, diff, list, or delete.`);
2952
- }
2953
- } else {
2954
- switch (input.action) {
2955
- case 'list':
2956
- command = `aws cloudformation list-stacks --stack-status-filter CREATE_COMPLETE UPDATE_COMPLETE ${regionFlag} --output json`;
2957
- break;
2958
- case 'describe':
2959
- command = `aws cloudformation describe-stacks --stack-name ${stack} ${regionFlag} --output json`;
2960
- break;
2961
- case 'create':
2962
- command = `aws cloudformation create-stack --stack-name ${stack} --template-body file://${input.template ?? 'template.yaml'} ${input.parameters ? `--parameters ${input.parameters}` : ''} ${regionFlag}`;
2963
- break;
2964
- case 'update':
2965
- command = `aws cloudformation update-stack --stack-name ${stack} --template-body file://${input.template ?? 'template.yaml'} ${input.parameters ? `--parameters ${input.parameters}` : ''} ${regionFlag}`;
2966
- break;
2967
- case 'delete':
2968
- command = `aws cloudformation delete-stack --stack-name ${stack} ${regionFlag}`;
2969
- break;
2970
- case 'validate':
2971
- command = `aws cloudformation validate-template --template-body file://${input.template ?? 'template.yaml'} ${regionFlag}`;
2972
- break;
2973
- case 'events':
2974
- command = `aws cloudformation describe-stack-events --stack-name ${stack} ${regionFlag} --output json`;
2975
- break;
2976
- case 'drift':
2977
- command = `aws cloudformation detect-stack-drift --stack-name ${stack} ${regionFlag} --output json`;
2978
- break;
2979
- case 'deploy':
2980
- command = `aws cloudformation deploy --stack-name ${stack} --template-file ${input.template ?? 'template.yaml'} ${input.parameters ? `--parameter-overrides ${input.parameters}` : ''} ${regionFlag}`;
2981
- break;
2982
- case 'diff':
2983
- command = `aws cloudformation get-template --stack-name ${stack} ${regionFlag} --output json`;
2984
- break;
2985
- default:
2986
- return err(`Unknown CloudFormation action: ${input.action}`);
2987
- }
2988
- }
2989
-
2990
- const { stdout, stderr } = await execAsync(command!, {
2991
- timeout: 300_000,
2992
- maxBuffer: 10 * 1024 * 1024,
2993
- });
2994
-
2995
- const combined = [stdout, stderr].filter(Boolean).join('\n');
2996
- return ok(combined || '(success)');
2997
- } catch (error: unknown) {
2998
- return err(`CloudFormation/CDK operation failed: ${errorMessage(error)}`);
2999
- }
3000
- },
3001
- };
3002
-
3003
- // ---------------------------------------------------------------------------
3004
- // 23. k8s_rbac (Kubernetes RBAC management)
3005
- // ---------------------------------------------------------------------------
3006
-
3007
- const k8sRbacSchema = z.object({
3008
- action: z.enum(['list','get','create','delete','bind','unbind','audit','who-can'])
3009
- .describe('RBAC action to perform'),
3010
- resource_type: z.enum(['serviceaccount','role','clusterrole','rolebinding','clusterrolebinding'])
3011
- .optional()
3012
- .describe('RBAC resource type'),
3013
- name: z.string().optional().describe('Resource name'),
3014
- namespace: z.string().optional().describe('Kubernetes namespace'),
3015
- subject: z.string().optional().describe('Subject (user, group, or serviceaccount)'),
3016
- verb: z.string().optional().describe('Verb for who-can checks (get, list, create, delete, etc.)'),
3017
- });
3018
-
3019
- export const k8sRbacTool: ToolDefinition = {
3020
- name: 'k8s_rbac',
3021
- description: 'Manage Kubernetes RBAC: ServiceAccounts, Roles, ClusterRoles, RoleBindings. Audit permissions and check access.',
3022
- inputSchema: k8sRbacSchema,
3023
- permissionTier: 'ask_once',
3024
- category: 'devops',
3025
- isDestructive: true,
3026
-
3027
- async execute(raw: unknown): Promise<ToolResult> {
3028
- try {
3029
- const input = k8sRbacSchema.parse(raw);
3030
- const nsFlag = input.namespace ? `-n ${input.namespace}` : '';
3031
- const resType = input.resource_type ?? 'role';
3032
-
3033
- let command: string;
3034
-
3035
- switch (input.action) {
3036
- case 'list':
3037
- command = `kubectl get ${resType} ${nsFlag} -o wide`;
3038
- break;
3039
- case 'get':
3040
- command = `kubectl describe ${resType} ${input.name ?? ''} ${nsFlag}`;
3041
- break;
3042
- case 'audit':
3043
- command = `kubectl auth can-i --list ${nsFlag}`;
3044
- break;
3045
- case 'who-can':
3046
- if (!input.verb || !input.name) {
3047
- return err('verb and name (resource) are required for who-can checks');
3048
- }
3049
- command = `kubectl who-can ${input.verb} ${input.name} ${nsFlag}`;
3050
- break;
3051
- case 'create':
3052
- if (resType === 'serviceaccount') {
3053
- command = `kubectl create serviceaccount ${input.name ?? ''} ${nsFlag}`;
3054
- } else {
3055
- return err('For create: use kubectl with a manifest file for roles and bindings');
3056
- }
3057
- break;
3058
- case 'bind':
3059
- if (!input.subject || !input.name) {
3060
- return err('subject and name (role) are required for bind action');
3061
- }
3062
- command = `kubectl create rolebinding ${input.subject}-binding --${resType === 'clusterrole' ? 'clusterrole' : 'role'}=${input.name} --user=${input.subject} ${nsFlag}`;
3063
- break;
3064
- case 'unbind':
3065
- command = `kubectl delete rolebinding ${input.name ?? ''} ${nsFlag}`;
3066
- break;
3067
- case 'delete':
3068
- command = `kubectl delete ${resType} ${input.name ?? ''} ${nsFlag}`;
3069
- break;
3070
- default:
3071
- return err(`Unknown RBAC action: ${input.action}`);
3072
- }
3073
-
3074
- // Warn on wildcard rules before create/bind
3075
- if (['create', 'bind'].includes(input.action) && input.name === '*') {
3076
- return err('SAFETY CHECK: Wildcard (*) resource names in RBAC grant excessive permissions. Use specific resource names instead.');
3077
- }
3078
-
3079
- const { stdout, stderr } = await execAsync(command!, {
3080
- timeout: 30_000,
3081
- maxBuffer: 2 * 1024 * 1024,
3082
- });
3083
-
3084
- const combined = [stdout, stderr].filter(Boolean).join('\n');
3085
- return ok(combined || '(success)');
3086
- } catch (error: unknown) {
3087
- return err(`K8s RBAC operation failed: ${errorMessage(error)}`);
3088
- }
3089
- },
3090
- };
3091
-
3092
- // ---------------------------------------------------------------------------
3093
- // aws, gcloud, az — Cloud CLI tools (M5)
3094
- // ---------------------------------------------------------------------------
3095
-
3096
- const awsSchema = z.object({
3097
- service: z.string().describe('AWS service (e.g., "ec2", "s3", "iam", "ecs", "eks")'),
3098
- action: z.string().describe('Service action (e.g., "describe-instances", "list-buckets")'),
3099
- args: z.string().optional().describe('Additional CLI arguments'),
3100
- profile: z.string().optional().describe('AWS profile name (overrides AWS_PROFILE)'),
3101
- region: z.string().optional().describe('AWS region (overrides AWS_DEFAULT_REGION)'),
3102
- output: z.enum(['json', 'text', 'table']).optional().default('json').describe('Output format'),
3103
- });
3104
-
3105
- export const awsTool: ToolDefinition = {
3106
- name: 'aws',
3107
- description: 'Execute AWS CLI commands. Use for cloud resource management, IAM, EC2, S3, EKS, RDS, and all AWS services. Prefer this over bash for AWS operations.',
3108
- inputSchema: awsSchema,
3109
- permissionTier: 'ask_once',
3110
- category: 'devops',
3111
-
3112
- async execute(raw: unknown): Promise<ToolResult> {
3113
- try {
3114
- const input = awsSchema.parse(raw);
3115
- const parts = ['aws', input.service, input.action];
3116
- if (input.profile) parts.push('--profile', input.profile);
3117
- else if (process.env.AWS_PROFILE) parts.push('--profile', process.env.AWS_PROFILE);
3118
- if (input.region) parts.push('--region', input.region);
3119
- parts.push('--output', input.output ?? 'json');
3120
- if (input.args) parts.push(input.args);
3121
- const command = parts.join(' ');
3122
- const env = { ...process.env } as NodeJS.ProcessEnv;
3123
- const { stdout, stderr } = await execAsync(command, {
3124
- timeout: 60_000,
3125
- maxBuffer: 10 * 1024 * 1024,
3126
- env,
3127
- });
3128
- const combined = [stdout, stderr].filter(Boolean).join('\n');
3129
- return ok(combined || '(no output)');
3130
- } catch (error: unknown) {
3131
- return err(`AWS CLI failed: ${errorMessage(error)}`);
3132
- }
3133
- },
3134
- };
3135
-
3136
- const gcloudSchema = z.object({
3137
- service: z.string().describe('GCP service group (e.g., "compute", "container", "sql", "storage")'),
3138
- action: z.string().describe('Service action (e.g., "instances list", "clusters get-credentials")'),
3139
- args: z.string().optional().describe('Additional CLI arguments'),
3140
- project: z.string().optional().describe('GCP project ID'),
3141
- region: z.string().optional().describe('GCP region'),
3142
- output: z.enum(['json', 'yaml', 'text', 'table']).optional().default('json').describe('Output format'),
3143
- });
3144
-
3145
- export const gcloudTool: ToolDefinition = {
3146
- name: 'gcloud',
3147
- description: 'Execute Google Cloud CLI (gcloud) commands. Use for GCP resource management, GKE, Cloud SQL, GCS, and all GCP services. Prefer this over bash for GCP operations.',
3148
- inputSchema: gcloudSchema,
3149
- permissionTier: 'ask_once',
3150
- category: 'devops',
3151
-
3152
- async execute(raw: unknown): Promise<ToolResult> {
3153
- try {
3154
- const input = gcloudSchema.parse(raw);
3155
- const parts = ['gcloud', input.service, input.action];
3156
- if (input.project) parts.push('--project', input.project);
3157
- if (input.region) parts.push('--region', input.region);
3158
- parts.push('--format', input.output ?? 'json');
3159
- if (input.args) parts.push(input.args);
3160
- const command = parts.join(' ');
3161
- const { stdout, stderr } = await execAsync(command, {
3162
- timeout: 60_000,
3163
- maxBuffer: 10 * 1024 * 1024,
3164
- });
3165
- const combined = [stdout, stderr].filter(Boolean).join('\n');
3166
- return ok(combined || '(no output)');
3167
- } catch (error: unknown) {
3168
- return err(`gcloud CLI failed: ${errorMessage(error)}`);
3169
- }
3170
- },
3171
- };
3172
-
3173
- const azSchema = z.object({
3174
- service: z.string().describe('Azure service group (e.g., "vm", "aks", "storage", "sql", "network")'),
3175
- action: z.string().describe('Service action (e.g., "list", "show", "create", "delete")'),
3176
- args: z.string().optional().describe('Additional CLI arguments'),
3177
- subscription: z.string().optional().describe('Azure subscription ID'),
3178
- resource_group: z.string().optional().describe('Azure resource group'),
3179
- output: z.enum(['json', 'yaml', 'table', 'tsv']).optional().default('json').describe('Output format'),
3180
- });
3181
-
3182
- export const azTool: ToolDefinition = {
3183
- name: 'az',
3184
- description: 'Execute Azure CLI (az) commands. Use for Azure resource management, AKS, Azure SQL, Storage, and all Azure services. Prefer this over bash for Azure operations.',
3185
- inputSchema: azSchema,
3186
- permissionTier: 'ask_once',
3187
- category: 'devops',
3188
-
3189
- async execute(raw: unknown): Promise<ToolResult> {
3190
- try {
3191
- const input = azSchema.parse(raw);
3192
- const parts = ['az', input.service, input.action];
3193
- if (input.subscription) parts.push('--subscription', input.subscription);
3194
- if (input.resource_group) parts.push('--resource-group', input.resource_group);
3195
- parts.push('--output', input.output ?? 'json');
3196
- if (input.args) parts.push(input.args);
3197
- const command = parts.join(' ');
3198
- const { stdout, stderr } = await execAsync(command, {
3199
- timeout: 60_000,
3200
- maxBuffer: 10 * 1024 * 1024,
3201
- });
3202
- const combined = [stdout, stderr].filter(Boolean).join('\n');
3203
- return ok(combined || '(no output)');
3204
- } catch (error: unknown) {
3205
- return err(`az CLI failed: ${errorMessage(error)}`);
3206
- }
3207
- },
3208
- };
3209
-
3210
- // ---------------------------------------------------------------------------
3211
- // 27. incident
3212
- // ---------------------------------------------------------------------------
3213
-
3214
- const incidentSchema = z.object({
3215
- provider: z.enum(['pagerduty', 'opsgenie']).describe('Incident management provider'),
3216
- action: z.enum(['list', 'get', 'acknowledge', 'resolve', 'create', 'on-call']).describe('Action to perform'),
3217
- id: z.string().optional().describe('Incident/alert ID for get/acknowledge/resolve'),
3218
- title: z.string().optional().describe('Title for create action'),
3219
- body: z.string().optional().describe('Description for create action'),
3220
- urgency: z.enum(['high', 'low']).optional().describe('Urgency for create action (PagerDuty)'),
3221
- service_id: z.string().optional().describe('Service ID for create action (PagerDuty)'),
3222
- team_id: z.string().optional().describe('Team ID for Opsgenie alerts'),
3223
- status: z.enum(['triggered', 'acknowledged', 'resolved']).optional().describe('Filter by status for list action'),
3224
- });
3225
-
3226
- export const incidentTool: ToolDefinition = {
3227
- name: 'incident',
3228
- description: 'Manage incidents and alerts via PagerDuty or Opsgenie — list, acknowledge, resolve, and create incidents',
3229
- category: 'devops',
3230
- permissionTier: 'ask_once',
3231
- inputSchema: incidentSchema,
3232
- execute: async (rawInput) => {
3233
- const input = rawInput as z.infer<typeof incidentSchema>;
3234
- const { provider, action, id, title, body, urgency, service_id, team_id, status } = input;
3235
-
3236
- if (provider === 'pagerduty') {
3237
- const apiKey = process.env.PD_API_KEY || process.env.PAGERDUTY_API_KEY;
3238
- if (!apiKey) {
3239
- return err('PagerDuty API key not found. Set PD_API_KEY or PAGERDUTY_API_KEY environment variable.');
3240
- }
3241
- const baseUrl = 'https://api.pagerduty.com';
3242
- const headers = { 'Authorization': `Token token=${apiKey}`, 'Accept': 'application/vnd.pagerduty+json;version=2', 'Content-Type': 'application/json' };
3243
-
3244
- try {
3245
- if (action === 'list') {
3246
- const params = new URLSearchParams();
3247
- if (status) params.set('statuses[]', status);
3248
- params.set('limit', '20');
3249
- const res = await fetch(`${baseUrl}/incidents?${params}`, { headers });
3250
- if (!res.ok) return err(`PagerDuty API error: ${res.status} ${res.statusText}`);
3251
- const data = await res.json() as { incidents: Array<{ id: string; title: string; status: string; urgency: string; created_at: string }> };
3252
- if (!data.incidents.length) return ok('No incidents found.');
3253
- return ok(data.incidents.map(i => `[${i.status.toUpperCase()}] ${i.id}: ${i.title} (${i.urgency}) — ${i.created_at}`).join('\n'));
3254
- }
3255
- if (action === 'get' && id) {
3256
- const res = await fetch(`${baseUrl}/incidents/${id}`, { headers });
3257
- if (!res.ok) return err(`PagerDuty API error: ${res.status} ${res.statusText}`);
3258
- const data = await res.json() as { incident: { id: string; title: string; status: string; urgency: string; body?: { details?: string }; created_at: string } };
3259
- const inc = data.incident;
3260
- return ok(`ID: ${inc.id}\nTitle: ${inc.title}\nStatus: ${inc.status}\nUrgency: ${inc.urgency}\nCreated: ${inc.created_at}\n${inc.body?.details ? `Details: ${inc.body.details}` : ''}`);
3261
- }
3262
- if (action === 'acknowledge' && id) {
3263
- const res = await fetch(`${baseUrl}/incidents/${id}`, {
3264
- method: 'PUT', headers,
3265
- body: JSON.stringify({ incident: { type: 'incident_reference', status: 'acknowledged' } }),
3266
- });
3267
- if (!res.ok) return err(`PagerDuty API error: ${res.status} ${res.statusText}`);
3268
- return ok(`Incident ${id} acknowledged.`);
3269
- }
3270
- if (action === 'resolve' && id) {
3271
- const res = await fetch(`${baseUrl}/incidents/${id}`, {
3272
- method: 'PUT', headers,
3273
- body: JSON.stringify({ incident: { type: 'incident_reference', status: 'resolved' } }),
3274
- });
3275
- if (!res.ok) return err(`PagerDuty API error: ${res.status} ${res.statusText}`);
3276
- return ok(`Incident ${id} resolved.`);
3277
- }
3278
- if (action === 'create') {
3279
- if (!title || !service_id) return err('create action requires title and service_id');
3280
- const res = await fetch(`${baseUrl}/incidents`, {
3281
- method: 'POST', headers,
3282
- body: JSON.stringify({ incident: { type: 'incident', title, urgency: urgency ?? 'high', service: { id: service_id, type: 'service_reference' }, body: body ? { type: 'incident_body', details: body } : undefined } }),
3283
- });
3284
- if (!res.ok) return err(`PagerDuty API error: ${res.status} ${res.statusText}`);
3285
- const data = await res.json() as { incident: { id: string } };
3286
- return ok(`Incident created: ${data.incident.id}`);
3287
- }
3288
- if (action === 'on-call') {
3289
- const res = await fetch(`${baseUrl}/oncalls?limit=10`, { headers });
3290
- if (!res.ok) return err(`PagerDuty API error: ${res.status} ${res.statusText}`);
3291
- const data = await res.json() as { oncalls: Array<{ user: { summary: string }; schedule?: { summary?: string }; start: string; end: string }> };
3292
- return ok(data.oncalls.map(o => `${o.user.summary}${o.schedule?.summary ? ` (${o.schedule.summary})` : ''} until ${o.end}`).join('\n') || 'No on-call data found.');
3293
- }
3294
- return err(`Unknown action: ${action}`);
3295
- } catch (e) {
3296
- return err(errorMessage(e));
3297
- }
3298
- } else {
3299
- // Opsgenie
3300
- const apiKey = process.env.OPSGENIE_API_KEY;
3301
- if (!apiKey) {
3302
- return err('Opsgenie API key not found. Set OPSGENIE_API_KEY environment variable.');
3303
- }
3304
- const baseUrl = 'https://api.opsgenie.com/v2';
3305
- const headers = { 'Authorization': `GenieKey ${apiKey}`, 'Content-Type': 'application/json' };
3306
-
3307
- try {
3308
- if (action === 'list') {
3309
- const params = new URLSearchParams({ limit: '20', sort: 'createdAt', order: 'desc' });
3310
- if (status) params.set('query', `status=${status}`);
3311
- const res = await fetch(`${baseUrl}/alerts?${params}`, { headers });
3312
- if (!res.ok) return err(`Opsgenie API error: ${res.status} ${res.statusText}`);
3313
- const data = await res.json() as { data: Array<{ id: string; tinyId: string; message: string; status: string; priority: string; createdAt: string }> };
3314
- if (!data.data.length) return ok('No alerts found.');
3315
- return ok(data.data.map(a => `[${a.status.toUpperCase()}] ${a.tinyId}: ${a.message} (${a.priority}) — ${a.createdAt}`).join('\n'));
3316
- }
3317
- if (action === 'get' && id) {
3318
- const res = await fetch(`${baseUrl}/alerts/${id}`, { headers });
3319
- if (!res.ok) return err(`Opsgenie API error: ${res.status} ${res.statusText}`);
3320
- const data = await res.json() as { data: { id: string; message: string; status: string; priority: string; description?: string; createdAt: string } };
3321
- const a = data.data;
3322
- return ok(`ID: ${a.id}\nMessage: ${a.message}\nStatus: ${a.status}\nPriority: ${a.priority}\nCreated: ${a.createdAt}\n${a.description ? `Description: ${a.description}` : ''}`);
3323
- }
3324
- if (action === 'acknowledge' && id) {
3325
- const res = await fetch(`${baseUrl}/alerts/${id}/acknowledge`, {
3326
- method: 'POST', headers, body: JSON.stringify({ note: 'Acknowledged via Nimbus' }),
3327
- });
3328
- if (!res.ok) return err(`Opsgenie API error: ${res.status} ${res.statusText}`);
3329
- return ok(`Alert ${id} acknowledged.`);
3330
- }
3331
- if (action === 'resolve' && id) {
3332
- const res = await fetch(`${baseUrl}/alerts/${id}/close`, {
3333
- method: 'POST', headers, body: JSON.stringify({ note: 'Resolved via Nimbus' }),
3334
- });
3335
- if (!res.ok) return err(`Opsgenie API error: ${res.status} ${res.statusText}`);
3336
- return ok(`Alert ${id} resolved.`);
3337
- }
3338
- if (action === 'create') {
3339
- if (!title) return err('create action requires title');
3340
- const res = await fetch(`${baseUrl}/alerts`, {
3341
- method: 'POST', headers,
3342
- body: JSON.stringify({ message: title, description: body, priority: urgency === 'high' ? 'P1' : 'P3', teams: team_id ? [{ id: team_id }] : undefined }),
3343
- });
3344
- if (!res.ok) return err(`Opsgenie API error: ${res.status} ${res.statusText}`);
3345
- const data = await res.json() as { requestId: string };
3346
- return ok(`Alert created. Request ID: ${data.requestId}`);
3347
- }
3348
- if (action === 'on-call') {
3349
- const res = await fetch(`${baseUrl}/schedules/on-calls`, { headers });
3350
- if (!res.ok) return err(`Opsgenie API error: ${res.status} ${res.statusText}`);
3351
- const data = await res.json() as { data: Array<{ _parent?: { name?: string }; onCallParticipants: Array<{ name: string }> }> };
3352
- return ok(data.data.map(s => `${s._parent?.name}: ${s.onCallParticipants.map((p) => p.name).join(', ')}`).join('\n') || 'No on-call data.');
3353
- }
3354
- return err(`Unknown action: ${action}`);
3355
- } catch (e) {
3356
- return err(errorMessage(e));
3357
- }
3358
- }
3359
- },
3360
- };
3361
-
3362
- // ---------------------------------------------------------------------------
3363
- // 28. generate_infra (IaC generation from natural language)
3364
- // ---------------------------------------------------------------------------
3365
-
3366
- const generateInfraSchema = z.object({
3367
- type: z.enum(['terraform', 'kubernetes', 'helm'])
3368
- .describe('Type of infrastructure to generate'),
3369
- intent: z.string().describe('Natural language description of what to generate'),
3370
- provider: z.enum(['aws', 'gcp', 'azure']).optional()
3371
- .describe('Cloud provider (for terraform generation)'),
3372
- outputDir: z.string().optional()
3373
- .describe('Directory to write generated files to (default: ./generated/)'),
3374
- });
3375
-
3376
- export const generateInfraTool: ToolDefinition = {
3377
- name: 'generate_infra',
3378
- description: 'Generate infrastructure as code (Terraform, Kubernetes manifests, or Helm charts) from natural language descriptions. Writes files to outputDir.',
3379
- inputSchema: generateInfraSchema,
3380
- permissionTier: 'ask_once',
3381
- category: 'devops',
3382
-
3383
- async execute(raw: unknown): Promise<ToolResult> {
3384
- try {
3385
- const input = generateInfraSchema.parse(raw);
3386
- const { mkdirSync, writeFileSync } = await import('node:fs');
3387
- const { join } = await import('node:path');
3388
- const outputDir = input.outputDir ?? './generated';
3389
-
3390
- mkdirSync(outputDir, { recursive: true });
3391
-
3392
- if (input.type === 'terraform') {
3393
- const { TerraformProjectGenerator } = await import('../../generator');
3394
- const provider = input.provider ?? 'aws';
3395
- const generator = new TerraformProjectGenerator();
3396
- const projectName = input.intent.replace(/[^a-z0-9-]/gi, '-').toLowerCase().slice(0, 32) || 'nimbus-infra';
3397
- const project = await generator.generate({
3398
- projectName,
3399
- provider,
3400
- region: provider === 'aws' ? 'us-east-1' : provider === 'gcp' ? 'us-central1' : 'eastus',
3401
- components: [],
3402
- });
3403
- const files: string[] = [];
3404
- for (const file of project.files) {
3405
- const parts = file.path.split('/').slice(0, -1).join('/');
3406
- if (parts) mkdirSync(join(outputDir, parts), { recursive: true });
3407
- const filePath = join(outputDir, file.path);
3408
- writeFileSync(filePath, file.content, 'utf-8');
3409
- files.push(file.path);
3410
- }
3411
- return ok(`Generated ${files.length} Terraform files in ${outputDir}:\n${files.join('\n')}`);
3412
- }
3413
-
3414
- if (input.type === 'kubernetes') {
3415
- const { createKubernetesGenerator } = await import('../../generator');
3416
- const appName = input.intent.replace(/[^a-z0-9-]/gi, '-').toLowerCase().slice(0, 32) || 'app';
3417
- const generator = createKubernetesGenerator({
3418
- appName,
3419
- namespace: 'default',
3420
- workloadType: 'deployment',
3421
- image: `${appName}:latest`,
3422
- replicas: 2,
3423
- containerPort: 8080,
3424
- resources: { requests: { cpu: '100m', memory: '128Mi' }, limits: { cpu: '500m', memory: '512Mi' } },
3425
- });
3426
- const manifests = generator.generate();
3427
- const files: string[] = [];
3428
- for (const manifest of manifests) {
3429
- const filename = `${manifest.kind.toLowerCase()}-${manifest.name}.yaml`;
3430
- const filePath = join(outputDir, filename);
3431
- writeFileSync(filePath, manifest.content, 'utf-8');
3432
- files.push(filename);
3433
- }
3434
- return ok(`Generated ${files.length} Kubernetes manifests in ${outputDir}:\n${files.join('\n')}`);
3435
- }
3436
-
3437
- if (input.type === 'helm') {
3438
- const { createHelmGenerator } = await import('../../generator');
3439
- const chartName = input.intent.replace(/[^a-z0-9-]/gi, '-').toLowerCase().slice(0, 32) || 'my-chart';
3440
- const generator = createHelmGenerator({
3441
- name: chartName,
3442
- description: input.intent,
3443
- version: '0.1.0',
3444
- appVersion: '1.0.0',
3445
- values: {
3446
- image: { repository: chartName, tag: 'latest' },
3447
- },
3448
- });
3449
- const chartFiles = generator.generate();
3450
- const files: string[] = [];
3451
- for (const file of chartFiles) {
3452
- const parts = file.path.split('/').slice(0, -1).join('/');
3453
- if (parts) mkdirSync(join(outputDir, parts), { recursive: true });
3454
- const filePath = join(outputDir, file.path);
3455
- writeFileSync(filePath, file.content, 'utf-8');
3456
- files.push(file.path);
3457
- }
3458
- return ok(`Generated Helm chart in ${outputDir}:\n${files.join('\n')}`);
3459
- }
3460
-
3461
- return err(`Unknown type: ${input.type}`);
3462
- } catch (error: unknown) {
3463
- return err(`Infrastructure generation failed: ${errorMessage(error)}`);
3464
- }
3465
- },
3466
- };
3467
-
3468
- // ---------------------------------------------------------------------------
3469
- // Aggregate export
3470
- // ---------------------------------------------------------------------------
3471
-
3472
- /** All 28 DevOps tools as an ordered array. */
3473
- export const devopsTools: ToolDefinition[] = [
3474
- terraformTool,
3475
- kubectlTool,
3476
- helmTool,
3477
- cloudDiscoverTool,
3478
- costEstimateTool,
3479
- driftDetectTool,
3480
- deployPreviewTool,
3481
- terraformPlanAnalyzeTool,
3482
- kubectlContextTool,
3483
- helmValuesTool,
3484
- gitTool,
3485
- taskTool,
3486
- dockerTool,
3487
- secretsTool,
3488
- cicdTool,
3489
- monitorTool,
3490
- gitopsTool,
3491
- cloudActionTool,
3492
- logsTool,
3493
- certsTool,
3494
- meshTool,
3495
- cfnTool,
3496
- k8sRbacTool,
3497
- awsTool,
3498
- gcloudTool,
3499
- azTool,
3500
- incidentTool,
3501
- generateInfraTool,
3502
- ];